Цепное правило и вычислительный граф
Как найти производную функции, собранной из многих звеньев, и как прямой и обратный проход по вычислительному графу дают все производные сразу — то, что 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 напишем свой автоград.
Цепное правило
Заголовок раздела «Цепное правило»Возьмём . Это композиция (урок 1.0.1): внутреннее звено , внешнее — .
Интуиция: шестерёнки. Пусть шестерёнка крутится в 3 раза быстрее шестерёнки , а шестерёнка — в 8 раз быстрее . Тогда крутится в раза быстрее . Скорости звеньев перемножаются:
Запись Лейбница справа удобна для запоминания: кажется, будто «сокращается» (это не строгое доказательство, но мнемоника работает). Обратите внимание: внешнюю производную вычисляют в точке , а не в .
Посчитаем в точке . Внутреннее звено: , . Внешнее: . Итого . Центральная разность из прошлого урока даёт 24.000 — сходится.
Ещё три примера
Заголовок раздела «Ещё три примера»Экспонента с минусом. : внешнее звено с производной , внутреннее с производной . Ответ: . В точке это : при росте функция убывает, поэтому производная отрицательна.
Сигмоида — ещё раз. (степень — это «единица, делённая на», урок 1.0.2). Внешнее звено по правилу при даёт , внутреннее — по предыдущему примеру — даёт :
Тот же ответ, что и по правилу частного в прошлом уроке, — хороший знак.
Обещанный tanh. Можно проверить, что . Например, при : , а и . Тогда по цепному правилу, обозначив (внешнее звено даёт , внутреннее даёт 2):
Совпало: . Численно при обе формулы дают 0.63474.
Длинные цепочки и исчезающий градиент
Заголовок раздела «Длинные цепочки и исчезающий градиент»Для цепочки из многих звеньев правило применяется многократно: производная — произведение всех локальных производных вдоль пути. Отсюда важное следствие. Производная сигмоиды не больше 0.25, поэтому у цепочки из десяти сигмоид производная не больше . Сигнал об ошибке почти не доходит до первых звеньев — это исчезающий градиент. Если же множители больше 1, произведение взрывается.
Современные сети борются с этим двумя приёмами, которые вы встретите на ступени 3. У ReLU производная равна 1 на положительной половине. А остаточная связь имеет производную : даже если мала, единица пропускает сигнал напрямую (урок 3.4.2).
Вычислительный граф
Заголовок раздела «Вычислительный граф»Цепное правило для формулы на бумаге — одно дело. Нейросеть же — программа из тысяч операций. Чтобы не запутаться, её представляют вычислительным графом: узлы — элементарные операции (+, ×, σ, …), рёбра — числа, которые текут между ними.
Возьмём один нейрон с сигмоидой и квадратичной ошибкой — модель на одном примере:
Здесь и — параметры, которые мы хотим обучать, — вход, — правильный ответ.
Прямой проход
Заголовок раздела «Прямой проход»Прямой проходпрямой проход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"]| Узел | Формула | Значение |
|---|---|---|
| умножение | 3 | |
| сложение | 2 | |
| сигмоида | 0.8808 | |
| вычитание | −0.1192 | |
| квадрат | 0.01421 |
Обратный проход
Заголовок раздела «Обратный проход»Теперь идём справа налево. (Не путайте промежуточную переменную с буквой d в записи производной: читается «производная по ».)
Начинаем с : loss по самому себе меняется один к одному. Каждый узел получает сверху «градиент по своему выходу» и умножает его на свою локальную производную — производную выхода узла по его входу. Так по цепному правилу получается градиент по входу узла, и он передаётся дальше влево. Например, у узла «квадрат» локальная производная равна — ровно по таблице производных из прошлого урока.
| Шаг | Градиент сверху | Локальная производная | Результат |
|---|---|---|---|
| квадрат | |||
| вычитание | |||
| сигмоида | |||
| сложение | , | ||
| умножение | , | , |
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 уменьшится. И правда: больший даёт больший , сигмоида ближе к 1, а правильный ответ . Шаг градиентного спуска сдвинет вверх, — тоже.
Заметьте, что для сигмоиды мы использовали сохранённое значение , а не пересчитывали . Именно поэтому прямой проход запоминает промежуточные значения — они нужны для локальных производных.
Шаблоны узлов
Заголовок раздела «Шаблоны узлов»Обратный проход собирается из нескольких повторяющихся «кирпичиков». Запомните их — и любой граф будет вам по силам.
| Узел | Что делает с градиентом сверху | Пример из таблицы |
|---|---|---|
| (сложение) | отдаёт всем входам без изменений — «распределитель» | |
| (умножение) | отдаёт каждому входу , умноженный на другой вход — «перестановка» | , |
| умножает на | ||
| умножает на | ||
| (ReLU) | пропускает , если , иначе отдаёт 0 — «маршрутизатор» |
Ветвление: градиенты складываются
Заголовок раздела «Ветвление: градиенты складываются»Что если одна и та же переменная используется в графе дважды? Возьмём в точке , . Граф: , затем . Переменная входит в граф двумя путями: напрямую в умножение и через .
flowchart LR
X["x = 3"] --> P("+")
Y["y = −1"] --> P
P -- "q = 2" --> M("×")
X --> M
M --> F["f = 6"]Обратный проход. Узел «×»: градиент по равен , по «прямому» входу — равен . Узел «+» передаёт градиент 3 дальше к и без изменений. В итоге к пришли два вклада: 2 по прямому пути и 3 через . Их нужно сложить: , а .
Проверим без графа: , производная по при замороженном — это . Сошлось. (Значок — «частная производная»: производная по одной переменной, когда остальные заморожены. Подробно — в следующем уроке.)
Почему складываются? Маленькое изменение «растекается» по обоим путям, и их влияния на суммируются. В нейросетях ветвление повсюду: один вектор токена идёт и в attention, и в остаточную связь; в GPT матрица эмбеддингов часто используется и на входе, и на выходе (weight tying, урок 4.6.2).
Почему проход «обратный»
Заголовок раздела «Почему проход «обратный»»Можно было бы идти и вперёд: для каждого входа протащить через граф «скорость изменения» вместе со значениями. Это прямой режим дифференцирования. Но за один такой проход вы получаете производные всего графа по одному входу. Для модели с 7 млрд параметров понадобилось бы 7 млрд проходов.
Обратный режим устроен наоборот: за один проход получаются производные одного выхода по всем входам. А у нас как раз один выход — число loss — и миллиарды параметров. Обратный проход стоит примерно вдвое дороже прямого, поэтому шаг обучения обходится примерно в три прямых прохода. Из этого соотношения в уроке 5.1.1 вырастет знаменитая оценка стоимости обучения операций.
Плата за скорость — память: все промежуточные значения прямого прохода нужно хранить до обратного. Поэтому обучение требует намного больше памяти, чем генерация текста. Когда памяти не хватает, часть значений не хранят, а пересчитывают заново на обратном проходе (activation checkpointing) — тратят вычисления, чтобы сэкономить память.
Проверка в PyTorch
Заголовок раздела «Проверка в PyTorch»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) ** 2L.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 NoneL = 0.01421dL/dw = -0.03755, dL/db = -0.02503, dL/dx = -0.05006PowBackward0SubBackward0SigmoidBackward0AddBackward0MulBackward0Те же числа, что в нашей таблице. А имена узлов — это наш граф, прочитанный от loss к входам: квадрат, вычитание, сигмоида, сложение, умножение. У каждого такого узла PyTorch хранит свою локальную производную — ровно как мы.
Практика
Заголовок раздела «Практика»Три задачи с нарастающей сложностью: прямой и обратный проход для нейрона из урока, градиент выражения с ветвлением и обратный проход через два «слоя» с tanh. Тесты сверят ответы с численными производными.
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))
Задание на бумаге (очень советуем). Нарисуйте граф для при , , , , . Выполните прямой и обратный проход и найдите производные по трём параметрам , и . Потом проверьте себя в PyTorch. Ответы для самопроверки: , , , . А что случится с градиентами по и , если взять ?
Проверь себя
Заголовок раздела «Проверь себя»- Цепное правило: производная композиции — произведение производных звеньев, . В длинных цепочках множители меньше 1 дают исчезающий градиент.
- Вычислительный граф раскладывает функцию на элементарные операции. Прямой проход считает и запоминает значения, обратный идёт от loss к параметрам: градиент сверху × локальная производная.
- Шаблоны: «+» распределяет градиент, «×» умножает на другой вход, σ — на , ReLU пропускает или блокирует.
- При ветвлении градиенты складываются (
+=). Обратный режим даёт все производные за один проход ценой памяти на промежуточные значения.
Дальше: частные производные и градиент — как всё это выглядит для функций многих переменных и матриц, и откуда берётся формула .