Перейти к содержимому
Ступень 11.2 Производные и градиенты

Цепное правило и вычислительный граф

Как найти производную функции, собранной из многих звеньев, и как прямой и обратный проход по вычислительному графу дают все производные сразу — то, что PyTorch делает автоматически, а мы сделаем вручную на маленьком примере.

УРОКstage-1/1-2-calculus/02-chain-rule.mdx1 ч 40 минУрок 2 из 3
После урока вы сможете
  • Применять цепное правило к сложным функциям и объяснить, почему производные звеньев перемножаются
  • Нарисовать вычислительный граф выражения и выполнить по нему прямой и обратный проход с конкретными числами
  • Объяснить, почему при ветвлении градиенты складываются и почему обратный проход эффективнее прямого
  • Сверить ручной обратный проход с autograd в PyTorch

В прошлом уроке мы научились находить производную одной функции. Но нейросеть — это композиция сотен функций: эмбеддинг → слой → активация → слой → … → softmax → loss. Вес из первого слоя влияет на loss через все последующие. Как узнать, насколько loss чувствителен к этому весу?

Ответ даёт цепное правилоцепное правилоEN chain ruleПроизводная композиции — произведение производных звеньев — (f(g(x)))′ = f′(g(x))·g′(x). Математическая основа backprop. — главная формула всего глубокого обучения. А чтобы применять его к миллиардам параметров без хаоса, вычисления раскладывают на вычислительный графвычислительный графEN computational graphСхема вычисления — узлы-операции и рёбра-значения. По нему идёт прямой проход (значения) и обратный (градиенты). и проходят по нему дважды: вперёд, чтобы посчитать значения, и назад, чтобы посчитать производные. Это и есть обратное распространение ошибкиобратное распространение ошибкиEN backpropagation (backpropagation), которое запускает строчка loss.backward(). Сегодня мы сделаем всё это руками на маленьком примере, а в модуле 3.2 напишем свой автоград.

Возьмём h(x)=(3x+1)2h(x) = (3x + 1)^2. Это композиция (урок 1.0.1): внутреннее звено u=g(x)=3x+1u = g(x) = 3x + 1, внешнее — f(u)=u2f(u) = u^2.

Интуиция: шестерёнки. Пусть шестерёнка uu крутится в 3 раза быстрее шестерёнки xx, а шестерёнка yy — в 8 раз быстрее uu. Тогда yy крутится в 3⋅8=243 \cdot 8 = 24 раза быстрее xx. Скорости звеньев перемножаются:

(f(g(x)))′=f′(g(x))⋅g′(x),илиdydx=dydu⋅dudx\big(f(g(x))\big)' = f'(g(x)) \cdot g'(x), \qquad \text{или} \qquad \frac{dy}{dx} = \frac{dy}{du} \cdot \frac{du}{dx}

Запись Лейбница справа удобна для запоминания: кажется, будто dudu «сокращается» (это не строгое доказательство, но мнемоника работает). Обратите внимание: внешнюю производную f′f' вычисляют в точке g(x)g(x), а не в xx.

Посчитаем в точке x=1x = 1. Внутреннее звено: u=4u = 4, dudx=3\frac{du}{dx} = 3. Внешнее: dydu=2u=8\frac{dy}{du} = 2u = 8. Итого dydx=8⋅3=24\frac{dy}{dx} = 8 \cdot 3 = 24. Центральная разность из прошлого урока даёт 24.000 — сходится.

Экспонента с минусом. ddxe−x\frac{d}{dx} e^{-x}: внешнее звено eue^u с производной eue^u, внутреннее u=−xu = -x с производной −1-1. Ответ: −e−x-e^{-x}. В точке x=0x = 0 это −1-1: при росте xx функция e−xe^{-x} убывает, поэтому производная отрицательна.

Сигмоида — ещё раз. σ(x)=(1+e−x)−1\sigma(x) = (1 + e^{-x})^{-1} (степень −1-1 — это «единица, делённая на», урок 1.0.2). Внешнее звено u−1u^{-1} по правилу (xn)′=nxn−1(x^n)' = n x^{n-1} при n=−1n = -1 даёт −u−2-u^{-2}, внутреннее 1+e−x1 + e^{-x} — по предыдущему примеру — даёт −e−x-e^{-x}:

σ′(x)=−1(1+e−x)2⋅(−e−x)=e−x(1+e−x)2=σ(x) (1−σ(x))\sigma'(x) = -\frac{1}{(1 + e^{-x})^2} \cdot (-e^{-x}) = \frac{e^{-x}}{(1 + e^{-x})^2} = \sigma(x)\,(1 - \sigma(x))

Тот же ответ, что и по правилу частного в прошлом уроке, — хороший знак.

Обещанный tanh. Можно проверить, что tanh⁡x=2σ(2x)−1\tanh x = 2\sigma(2x) - 1. Например, при x=0.5x = 0.5: tanh⁡0.5≈0.4621\tanh 0.5 \approx 0.4621, а σ(1)≈0.7311\sigma(1) \approx 0.7311 и 2⋅0.7311−1=0.46212 \cdot 0.7311 - 1 = 0.4621. Тогда по цепному правилу, обозначив s=σ(2x)s = \sigma(2x) (внешнее звено 2σ(u)−12\sigma(u) - 1 даёт 2σ′(u)2\sigma'(u), внутреннее u=2xu = 2x даёт 2):

tanh⁡′(x)=2⋅σ′(2x)⋅2=4s(1−s),1−tanh⁡2x=1−(2s−1)2=4s−4s2=4s(1−s)\begin{gathered} \tanh'(x) = 2 \cdot \sigma'(2x) \cdot 2 = 4s(1 - s), \\ 1 - \tanh^2 x = 1 - (2s - 1)^2 = 4s - 4s^2 = 4s(1 - s) \end{gathered}

Совпало: tanh⁡′=1−tanh⁡2\tanh' = 1 - \tanh^2. Численно при x=0.7x = 0.7 обе формулы дают 0.63474.

Для цепочки из многих звеньев y=f3(f2(f1(x)))y = f_3(f_2(f_1(x))) правило применяется многократно: производная — произведение всех локальных производных вдоль пути. Отсюда важное следствие. Производная сигмоиды не больше 0.25, поэтому у цепочки из десяти сигмоид производная не больше 0.2510≈10−60.25^{10} \approx 10^{-6}. Сигнал об ошибке почти не доходит до первых звеньев — это исчезающий градиент. Если же множители больше 1, произведение взрывается.

Современные сети борются с этим двумя приёмами, которые вы встретите на ступени 3. У ReLU производная равна 1 на положительной половине. А остаточная связь y=x+F(x)y = x + F(x) имеет производную 1+F′(x)1 + F'(x): даже если F′F' мала, единица пропускает сигнал напрямую (урок 3.4.2).

Цепное правило для формулы на бумаге — одно дело. Нейросеть же — программа из тысяч операций. Чтобы не запутаться, её представляют вычислительным графом: узлы — элементарные операции (+, ×, σ, …), рёбра — числа, которые текут между ними.

Возьмём один нейрон с сигмоидой и квадратичной ошибкой — модель на одном примере:

L=(σ(w⋅x+b)−y)2,x=1.5,  w=2,  b=−1,  y=1L = \big(\sigma(w \cdot x + b) - y\big)^2, \qquad x = 1.5, \; w = 2, \; b = -1, \; y = 1

Здесь ww и bb — параметры, которые мы хотим обучать, xx — вход, yy — правильный ответ.

Прямой проходпрямой проходEN forward passВычисление выхода и loss по графу от входов к выходу с запоминанием промежуточных значений для обратного прохода. — считаем слева направо и запоминаем каждое промежуточное значение:

flowchart LR
X["x = 1.5"] --> M("×")
W["w = 2"] --> M
M -- "u = 3" --> P("+")
B["b = −1"] --> P
P -- "z = 2" --> S("σ")
S -- "a = 0.8808" --> D("− y")
D -- "d = −0.1192" --> Q("( )²")
Q --> L["L = 0.01421"]
Прямой проход: значения текут от входов к loss
УзелФормулаЗначение
умножениеu=w⋅xu = w \cdot x3
сложениеz=u+bz = u + b2
сигмоидаa=σ(z)a = \sigma(z)0.8808
вычитаниеd=a−yd = a - y−0.1192
квадратL=d2L = d^20.01421

Теперь идём справа налево. (Не путайте промежуточную переменную dd с буквой d в записи производной: dLdd\frac{dL}{dd} читается «производная LL по dd».)

Начинаем с dLdL=1\frac{dL}{dL} = 1: loss по самому себе меняется один к одному. Каждый узел получает сверху «градиент по своему выходу» и умножает его на свою локальную производную — производную выхода узла по его входу. Так по цепному правилу получается градиент по входу узла, и он передаётся дальше влево. Например, у узла «квадрат» L=d2L = d^2 локальная производная равна 2d=2⋅(−0.1192)=−0.23842d = 2 \cdot (-0.1192) = -0.2384 — ровно по таблице производных из прошлого урока.

ШагГрадиент сверхуЛокальная производнаяРезультат
квадратdLdL=1\frac{dL}{dL} = 1dLdd=2d=−0.2384\frac{dL}{dd} = 2d = -0.2384dLdd=−0.2384\frac{dL}{dd} = -0.2384
вычитание−0.2384-0.2384ddda=1\frac{dd}{da} = 1dLda=−0.2384\frac{dL}{da} = -0.2384
сигмоида−0.2384-0.2384dadz=a(1−a)=0.1050\frac{da}{dz} = a(1 - a) = 0.1050dLdz=−0.02503\frac{dL}{dz} = -0.02503
сложение−0.02503-0.02503dzdu=1\frac{dz}{du} = 1, dzdb=1\frac{dz}{db} = 1dLdu=dLdb=−0.02503\frac{dL}{du} = \frac{dL}{db} = -0.02503
умножение−0.02503-0.02503dudw=x=1.5\frac{du}{dw} = x = 1.5, dudx=w=2\frac{du}{dx} = w = 2dLdw=−0.03755\frac{dL}{dw} = -0.03755, dLdx=−0.05006\frac{dL}{dx} = -0.05006
flowchart RL
L["dL/dL = 1"] --> Q("( )²")
Q -- "−0.2384" --> D("− y")
D -- "−0.2384" --> S("σ")
S -- "−0.02503" --> P("+")
P -- "dL/db = −0.02503" --> B["b"]
P -- "−0.02503" --> M("×")
M -- "dL/dw = −0.03755" --> W["w"]
M -- "dL/dx = −0.05006" --> X["x"]
Обратный проход: градиенты текут от loss к параметрам

Что означают ответы. dLdw=−0.0375<0\frac{dL}{dw} = -0.0375 < 0: если чуть увеличить ww, loss уменьшится. И правда: больший ww даёт больший zz, сигмоида ближе к 1, а правильный ответ y=1y = 1. Шаг градиентного спуска сдвинет ww вверх, bb — тоже.

Заметьте, что для сигмоиды мы использовали сохранённое значение aa, а не пересчитывали σ(z)\sigma(z). Именно поэтому прямой проход запоминает промежуточные значения — они нужны для локальных производных.

Обратный проход собирается из нескольких повторяющихся «кирпичиков». Запомните их — и любой граф будет вам по силам.

УзелЧто делает с градиентом сверху ggПример из таблицы
z=u+bz = u + b (сложение)отдаёт gg всем входам без изменений — «распределитель»dLdb=dLdu=dLdz\frac{dL}{db} = \frac{dL}{du} = \frac{dL}{dz}
u=w⋅xu = w \cdot x (умножение)отдаёт каждому входу gg, умноженный на другой вход — «перестановка»dLdw=g⋅x\frac{dL}{dw} = g \cdot x, dLdx=g⋅w\frac{dL}{dx} = g \cdot w
L=d2L = d^2умножает на 2d2d−0.2384=1⋅2⋅(−0.1192)-0.2384 = 1 \cdot 2 \cdot (-0.1192)
a=σ(z)a = \sigma(z)умножает на a(1−a)a(1 - a)−0.02503=−0.2384⋅0.105-0.02503 = -0.2384 \cdot 0.105
max⁡(0,z)\max(0, z) (ReLU)пропускает gg, если z>0z > 0, иначе отдаёт 0 — «маршрутизатор»

Что если одна и та же переменная используется в графе дважды? Возьмём f(x,y)=(x+y)⋅xf(x, y) = (x + y) \cdot x в точке x=3x = 3, y=−1y = -1. Граф: q=x+y=2q = x + y = 2, затем f=q⋅x=6f = q \cdot x = 6. Переменная xx входит в граф двумя путями: напрямую в умножение и через qq.

flowchart LR
X["x = 3"] --> P("+")
Y["y = −1"] --> P
P -- "q = 2" --> M("×")
X --> M
M --> F["f = 6"]
x используется дважды: в сумме и в произведении

Обратный проход. Узел «×»: градиент по qq равен x=3x = 3, по «прямому» входу xx — равен q=2q = 2. Узел «+» передаёт градиент 3 дальше к xx и yy без изменений. В итоге к xx пришли два вклада: 2 по прямому пути и 3 через qq. Их нужно сложить: ∂f∂x=2+3=5\frac{\partial f}{\partial x} = 2 + 3 = 5, а ∂f∂y=3\frac{\partial f}{\partial y} = 3.

Проверим без графа: f=x2+xyf = x^2 + xy, производная по xx при замороженном yy — это 2x+y=6−1=52x + y = 6 - 1 = 5. Сошлось. (Значок ∂\partial — «частная производная»: производная по одной переменной, когда остальные заморожены. Подробно — в следующем уроке.)

Почему складываются? Маленькое изменение xx «растекается» по обоим путям, и их влияния на ff суммируются. В нейросетях ветвление повсюду: один вектор токена идёт и в attention, и в остаточную связь; в GPT матрица эмбеддингов часто используется и на входе, и на выходе (weight tying, урок 4.6.2).

Можно было бы идти и вперёд: для каждого входа протащить через граф «скорость изменения» вместе со значениями. Это прямой режим дифференцирования. Но за один такой проход вы получаете производные всего графа по одному входу. Для модели с 7 млрд параметров понадобилось бы 7 млрд проходов.

Обратный режим устроен наоборот: за один проход получаются производные одного выхода по всем входам. А у нас как раз один выход — число loss — и миллиарды параметров. Обратный проход стоит примерно вдвое дороже прямого, поэтому шаг обучения обходится примерно в три прямых прохода. Из этого соотношения в уроке 5.1.1 вырастет знаменитая оценка стоимости обучения ≈6ND\approx 6ND операций.

Плата за скорость — память: все промежуточные значения прямого прохода нужно хранить до обратного. Поэтому обучение требует намного больше памяти, чем генерация текста. Когда памяти не хватает, часть значений не хранят, а пересчитывают заново на обратном проходе (activation checkpointing) — тратят вычисления, чтобы сэкономить память.

PyTorch строит граф автоматически во время прямого прохода и проходит его в обратную сторону по backward() (о PyTorch — заметка в уроке 1.1.2):

import torch
x = torch.tensor(1.5, requires_grad=True) # requires_grad=True: «нужна производная по этой величине»
w = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(-1.0, requires_grad=True)
y = torch.tensor(1.0) # правильный ответ — производная по нему не нужна
z = w * x + b # прямой проход строит граф
a = torch.sigmoid(z)
L = (a - y) ** 2
L.backward() # обратный проход по тому же графу; результаты — в w.grad, b.grad, x.grad
print(f"L = {L.item():.5f}")
print(f"dL/dw = {w.grad.item():.5f}, dL/db = {b.grad.item():.5f}, dL/dx = {x.grad.item():.5f}")
# Необязательная часть: печатаем имена узлов графа. Досконально понимать эти строки не нужно.
node = L.grad_fn # узел, который создал L (последняя операция)
while node is not None: # идём по графу от loss к входам
print(type(node).__name__) # имя узла
# next_functions — узлы, из которых пришли входы; служебные узлы AccumulateGrad пропускаем
nxt = [n for n, _ in node.next_functions if n is not None and type(n).__name__ != "AccumulateGrad"]
node = nxt[0] if nxt else None
L = 0.01421
dL/dw = -0.03755, dL/db = -0.02503, dL/dx = -0.05006
PowBackward0
SubBackward0
SigmoidBackward0
AddBackward0
MulBackward0

Те же числа, что в нашей таблице. А имена узлов — это наш граф, прочитанный от loss к входам: квадрат, вычитание, сигмоида, сложение, умножение. У каждого такого узла PyTorch хранит свою локальную производную — ровно как мы.

Три задачи с нарастающей сложностью: прямой и обратный проход для нейрона из урока, градиент выражения с ветвлением и обратный проход через два «слоя» с tanh. Тесты сверят ответы с численными производными.

Обратный проход по графу вручную5 тестов
import math


def sigmoid(z):
    """Устойчивая сигмоида (уже готова)."""
    if z >= 0:
        return 1 / (1 + math.exp(-z))
    e = math.exp(z)
    return e / (1 + e)


# ── Часть 1: нейрон  L = (σ(w·x + b) − y)² ────────────────────────────────

def forward(x, w, b, y):
    """Прямой проход. Верните словарь со ВСЕМИ промежуточными значениями:
    {"x": x, "w": w, "b": b, "y": y, "z": ..., "a": ..., "L": ...},
    где z = w·x + b, a = σ(z), L = (a − y)².
    """
    # TODO: ваш код
    ...


def backward(cache):
    """Обратный проход по графу: верните {"w": dL/dw, "b": dL/db, "x": dL/dx}.

    Идите от конца к началу: dL/da → dL/dz → dL/dw, dL/db, dL/dx.
    Каждый шаг — «градиент сверху» × локальная производная.
    """
    # TODO: ваш код
    ...


# ── Часть 2: ветвление  f(x, y) = (x + y) · x ─────────────────────────────

def grad_branch(x, y):
    """Верните (df/dx, df/dy) для f = q · x, где q = x + y.

    x используется в графе ДВАЖДЫ — градиенты по двум путям нужно СЛОЖИТЬ.
    """
    # TODO: ваш код
    ...


# ── Часть 3: два «слоя»  h = tanh(w1·x + b1),  out = w2·h + b2,  L = (out − y)² ──

def backward_two_layers(x, y, w1, b1, w2, b2):
    """Сделайте прямой проход, затем обратный. Верните {"w1": ..., "b1": ..., "w2": ..., "b2": ...}.

    Подсказка: tanh′(t) = 1 − tanh²(t).
    """
    # TODO: ваш код
    ...


# Проверьте себя кнопкой «Запустить»:
cache = forward(1.5, 2.0, -1.0, 1.0)
print(cache)                        # z = 2.0, a ≈ 0.8808, L ≈ 0.0142
print(backward(cache))              # w ≈ -0.03755, b ≈ -0.02503, x ≈ -0.05006
print(grad_branch(3.0, -1.0))       # (5.0, 3.0)
print(backward_two_layers(0.5, 1.0, 0.8, 0.1, 1.5, -0.2))

Задание на бумаге (очень советуем). Нарисуйте граф для L=(w2⋅ReLU(w1x+b1)−y)2L = (w_2 \cdot \mathrm{ReLU}(w_1 x + b_1) - y)^2 при x=2x = 2, w1=−1w_1 = -1, b1=3b_1 = 3, w2=0.5w_2 = 0.5, y=2y = 2. Выполните прямой и обратный проход и найдите производные по трём параметрам w1w_1, b1b_1 и w2w_2. Потом проверьте себя в PyTorch. Ответы для самопроверки: L=2.25L = 2.25, dLdw2=−3\frac{dL}{dw_2} = -3, dLdw1=−3\frac{dL}{dw_1} = -3, dLdb1=−1.5\frac{dL}{db_1} = -1.5. А что случится с градиентами по w1w_1 и b1b_1, если взять b1=1b_1 = 1?

Проверь себя6 вопросов
1 Найдите производную h(x)=(2x+1)3h(x) = (2x + 1)^3 в точке x=1x = 1.
2 Узел умножения u=w⋅xu = w \cdot x при w=4w = 4, x=−2x = -2 получил сверху градиент dLdu=0.5\frac{dL}{du} = 0.5. Чему равен dLdw\frac{dL}{dw}?
3 Для f(x,y)=x⋅y+xf(x, y) = x \cdot y + x найдите ∂f∂x\frac{\partial f}{\partial x} в точке x=2x = 2, y=5y = 5.
4 Какие утверждения верны?Можно выбрать несколько ответов
5 Производная сигмоиды не больше 0.25. Какова наибольшая возможная производная цепочки из 5 последовательных сигмоид? Ответ дайте десятичной дробью, округлив до пяти знаков после запятой.
6 Почему нейросети учат обратным, а не прямым режимом дифференцирования?
  • Цепное правило: производная композиции — произведение производных звеньев, dydx=dydu⋅dudx\frac{dy}{dx} = \frac{dy}{du} \cdot \frac{du}{dx}. В длинных цепочках множители меньше 1 дают исчезающий градиент.
  • Вычислительный граф раскладывает функцию на элементарные операции. Прямой проход считает и запоминает значения, обратный идёт от loss к параметрам: градиент сверху × локальная производная.
  • Шаблоны: «+» распределяет градиент, «×» умножает на другой вход, σ — на a(1−a)a(1 - a), ReLU пропускает или блокирует.
  • При ветвлении градиенты складываются (+=). Обратный режим даёт все производные за один проход ценой памяти на промежуточные значения.

Дальше: частные производные и градиент — как всё это выглядит для функций многих переменных и матриц, и откуда берётся формула ∂L∂W=X⊤∂L∂Y\frac{\partial L}{\partial W} = X^\top \frac{\partial L}{\partial Y}.