Прямой и обратный проход
Как сеть узнаёт, куда сдвинуть каждый из миллионов весов, — выводим обратный проход (backprop) для линейного слоя, ReLU и softmax + cross-entropy, проверяем градиенты численно и разбираемся, почему он стоит всего пару прямых проходов.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Чтобы обучить сеть градиентным спуском (урок 1.4.1), нужна производная loss по каждому параметру. В MLP из прошлого урока их 109 тысяч, в Qwen3-0.6B — 600 миллионов, в DeepSeek-V3 — 671 миллиард. Считать каждую производную отдельно немыслимо. Обратное распространение ошибкиобратное распространение ошибкиEN backpropagation (backprop) получает их все за один проход по сети от выхода к входу, и стоит этот проход примерно как два прямых.
Каждый шаг обучения любой LLM — это ровно то, что мы разберём сегодня: прямой проход, loss, обратный проход, шаг
оптимизатора. В loss.backward() PyTorch нет никакой магии, только три формулы, которые вы выведете сами. А когда
что-то сломается (NaN в градиентах, «мёртвые» нейроны, обучение стоит), вы будете знать, куда смотреть.
Повторим цепное правило
Заголовок раздела «Повторим цепное правило»В уроке 1.2.2 вы считали производные на вычислительном графевычислительный графEN computational graphСхема вычисления — узлы-операции и рёбра-значения. По нему идёт прямой проход (значения) и обратный (градиенты). по цепному правилуцепное правилоEN chain ruleПроизводная композиции — произведение производных звеньев — (f(g(x)))′ = f′(g(x))·g′(x). Математическая основа backprop., а в уроке 1.2.3 — градиенты линейного слоя и численную проверку. Сегодня соберём всё это в обучение целой сети. Напомним главное: если зависит от , а — от , то
Пример: , , . Тогда , «сверху» приходит , локальная производная , и . Проверка напрямую: , производная .
Это и есть весь backprop: градиент сверху умножить на локальную производную и передать дальше вниз. Каждый слой знает только свою локальную производную. Ему не нужно понимать, что происходит во всей сети.
Договоримся о записи. Для любой величины пишем вместо : это массив той же формы, что . В нём записано, как изменится loss, если чуть-чуть сдвинуть каждый элемент . Пример: если в на месте стоит , то увеличение веса на увеличит loss примерно на . Значит, чтобы loss уменьшился, этот вес надо немного уменьшить — это и делает шаг градиентного спуска .
Сеть и прямой проход
Заголовок раздела «Сеть и прямой проход»Возьмём двухслойную сеть для классификации на классов — тот же MLP из прошлого урока:
| Величина | Форма | Что это |
|---|---|---|
| батч из примеров | ||
| , | , | первый слой |
| , | скрытый слой до и после ReLU | |
| , | , | второй слой |
| , | логиты и вероятности | |
| число | средний loss по батчу |
flowchart LR X["X"] --> L1["Linear₁"] --> Z1["Z₁"] --> R["ReLU"] --> H["H"] --> L2["Linear₂"] --> Z2["Z₂"] --> S["softmax + CE"] --> LOSS["L"] LOSS -.->|"dZ₂ = (P − Y)/n"| L2 L2 -.->|"dH = dZ₂·W₂ᵀ"| R R -.->|"dZ₁ = dH ⊙ [Z₁ > 0]"| L1 L2 -.->|"dW₂, db₂"| G2["шаг для W₂, b₂"] L1 -.->|"dW₁, db₁"| G1["шаг для W₁, b₁"]
Обратите внимание: обратному проходу понадобятся , , и . Поэтому прямой проход их сохраняет —
обычно в структуру cache. Запомните это: именно здесь появляется главная статья расхода памяти при обучении.
Обратный проход: три кирпичика
Заголовок раздела «Обратный проход: три кирпичика»Любая сеть из этого урока собирается из трёх операций. Выведем градиент для каждой.
1. Softmax + cross-entropy
Заголовок раздела «1. Softmax + cross-entropy»Это вы уже вывели в уроках 1.3 и 2.2: для одного примера градиент по логитам равен «предсказание минус правда», . Loss усредняется по батчу, поэтому добавляется деление на :
В коде onehot даже не нужен. Достаточно в каждой строке вычесть 1 в столбце правильного класса:
import numpy as np
P = np.array([[0.6, 0.3, 0.1], # вероятности для 3 примеров и 3 классов [0.1, 0.8, 0.1], [0.3, 0.3, 0.4]])y = np.array([0, 1, 2]) # правильные классыn = len(y)
dZ2 = P.copy() # копия, чтобы не испортить PdZ2[np.arange(n), y] -= 1 # пары (0, 0), (1, 1), (2, 2): у правильного класса вычитаем 1dZ2 /= n # loss усреднён по батчу — делим на nprint(dZ2.round(3))print(np.allclose(dZ2.sum(axis=1), 0)) # каждая строка в сумме даёт 0?[[-0.133 0.1 0.033] [ 0.033 -0.067 0.033] [ 0.1 0.1 -0.2 ]]TrueЗапись dZ2[np.arange(n), y] берёт по одному элементу из каждой строки: из строки 0 — столбец y[0], из строки 1 —
столбец y[1] и так далее (урок 0.3.1). Каждая строка в сумме даёт ноль. Логит правильного класса тянем вверх
(градиент отрицательный — значит, шаг спуска изменит веса так, чтобы этот логит вырос), остальные — вниз.
2. Линейный слой
Заголовок раздела «2. Линейный слой»Эти формулы вы уже выводили в уроке 1.2.3, там же — подробный пример с числами. Коротко повторим. Пусть и сверху пришёл формы . Распишем один элемент: . Вес влияет на во всех строках , с коэффициентом . По цепному правилу соберём вклады всех строк:
Так же выводятся остальные две формулы. В итоге (запись означает строку матрицы , так что —
сумма всех строк , в коде dY.sum(axis=0)):
Почему в и стоит сумма по примерам? Одни и те же веса применяются к каждому примеру батча, и каждый пример
тянет их в свою сторону. Общий градиент — сумма этих «тяг». Это частный случай правила «если величина используется
несколько раз, её градиенты складываются» (ветвление графа из урока 1.2.2). В модуле 3.2 это правило станет
строчкой += в нашем автограде.
3. ReLU
Заголовок раздела «3. ReLU»ReLU работает поэлементно. Её производная равна 1 там, где , и 0 там, где :
Здесь — поэлементное умножение, а — маска из нулей и единиц: 1 там, где условие в
скобках выполнено, и 0, где нет. Пример: , сверху пришло . Маска
, и . В NumPy это dH * (Z1 > 0): сравнение даёт массив
True/False, а при умножении True считается единицей, False — нулём.
ReLU работает как маршрутизатор: градиент проходит только через нейроны, которые были активны на этом примере. В точке производная не определена, по соглашению берут 0.
Собираем алгоритм
Заголовок раздела «Собираем алгоритм»- Прямой проход: посчитать и сохранить их в
cache. - Выход: .
- Второй слой: , , .
- ReLU: .
- Первый слой: , . Величина здесь не нужна: входы мы не обучаем. Но в глубокой сети одного слоя становится для предыдущего, и цепочка продолжается.
- Шаг: для всех параметров.
Пример на числах
Заголовок раздела «Пример на числах»Один пример , правильный класс , два скрытых нейрона, два класса:
Прямой проход.
| Шаг | Вычисление | Результат |
|---|---|---|
| второй нейрон молчит | ||
| первая строка | ||
Обратный проход.
| Шаг | Вычисление | Результат |
|---|---|---|
| строка второго нейрона нулевая: | ||
| один пример: сумма по батчу из одной строки | ||
| , | ||
| маска | ||
| столбец на строку | ||
Прочитаем ответ. Градиент по первому столбцу отрицательный. Значит, шаг спуска увеличит эти веса, вырастет, логит класса 0 тоже вырастет — то, что нужно. Второй нейрон молчал, поэтому его веса не получили сигнала. После одного шага с loss падает с до , вероятность правильного класса растёт с 0.881 до 0.997.
Проверка градиентов
Заголовок раздела «Проверка градиентов»Вывести формулы — полдела. Нужно ещё убедиться, что код их правильно реализует. Классический инструмент — проверка градиентапроверка градиентаEN gradient checkСравнение аналитического градиента с численным (центральные разности) по относительной ошибке. Главный способ найти баг в backward.: сравнить аналитический градиент с численным. В уроке 1.2.3 вы проверяли так один слой, теперь проверим целую сеть. Численная производная по одному параметру считается центральной разностью:
Словами: сдвигаем один параметр чуть вверх и чуть вниз, дважды считаем loss и делим разницу на расстояние между точками. Центральная разность точнее односторонней : её ошибка пропорциональна , а не , — уменьшили шаг в 10 раз, ошибка упала в 100 раз (урок 1.2.1). В нашем примере для численно получается , аналитически .
Сравнивают по относительной ошибке, потому что абсолютная ничего не говорит о градиентах разного масштаба. Разница — это пустяк, если градиент равен 100, и катастрофа, если он равен . Поэтому разницу делят на размер самих чисел (а в знаменателе страхует от деления на ноль, когда оба градиента нулевые):
Пример: , . Ошибка — подозрительно.
| Относительная ошибка | Что это значит |
|---|---|
| меньше | всё верно |
| … | подозрительно; допустимо, если рядом изломы (ReLU) |
| больше | почти наверняка баг |
Правила хорошего gradient check:
- считайте в float64: в float32 ошибка округления сравнима с самой разностью;
- берите …; слишком маленький теряет точность из-за округления;
- у ReLU есть излом в нуле: если ближе к нулю, чем , численная производная «перепрыгнет» излом и солжёт;
- проверяйте на маленькой сети и маленьком батче: численный градиент требует двух прямых проходов на каждый параметр;
- после сдвига обязательно верните параметр на место.
Почему backprop эффективен
Заголовок раздела «Почему backprop эффективен»Сравним с численным градиентом
Заголовок раздела «Сравним с численным градиентом»У сети параметров. Численный градиент — это прямых проходов. Backprop — один прямой и один обратный. Для 1560 параметров это 3120 проходов против двух. Для модели на 7 млрд параметров — 14 млрд прямых проходов на один шаг обучения. Скрипт в практике ниже покажет разницу на реальном замере.
Сколько стоит обратный проход
Заголовок раздела «Сколько стоит обратный проход»Посчитаем операции для одного линейного слоя . FLOPs — число операций с плавающей точкой (умножений и сложений); умножение матриц стоит около таких операций (урок 1.1.2):
| Что | Формула | FLOPs |
|---|---|---|
| прямой проход | ||
| градиент по входу | ||
| градиент по весам |
Обратный проход стоит примерно два прямых, а весь шаг обучения — около трёх. Отсюда правило, которым считают бюджеты обучения LLM: ≈ FLOPs на каждый токен ( вперёд и назад, где — число параметров). Модель на 7 млрд параметров, обученная на 1 трлн токенов, требует около FLOPs. Такие бюджеты вы будете считать в уроке 5.1.
Почему именно «назад»
Заголовок раздела «Почему именно «назад»»Loss — одно число, а параметров — миллионы. Цепное правило для глубокой сети — это произведение матриц локальных производных, по одной на слой (якобианы из урока 1.2.3). Перемножать их можно с двух концов, и от выбора конца зависит цена:
- с конца (обратный режим, backprop): начинаем со строки и каждый раз умножаем вектор на матрицу. Это дёшево, и один проход даёт производные по всем параметрам;
- с начала (прямой режим): протаскиваем вперёд производную по одному входу. Это тоже вектор на матрицу, но повторить придётся для каждого из параметров.
Аналогия: нужно узнать, как каждый из миллиона ручейков влияет на уровень воды в одном озере. Можно миллион раз пустить краску в очередной ручей и смотреть, дойдёт ли она до озера (прямой режим). А можно один раз пройти от озера вверх по течению и по дороге записать вклад каждого ручья (обратный режим). Когда выход один, а входов много, обратный режим выигрывает в раз. Поэтому все фреймворки глубокого обучения используют именно его. Как автоматизировать эти вычисления для любого графа — тема модуля 3.2.
Чем платим: память
Заголовок раздела «Чем платим: память»За скорость платят памятью: все промежуточные активации (, , , в нашем примере) нужно хранить до обратного прохода. При обучении LLM с длинным контекстом и большим батчем активации часто занимают больше памяти, чем сами веса. Стандартный приём — пересчёт (recomputation, activation checkpointing): часть активаций выбрасывают после прямого прохода и считают заново во время обратного. Это дополнительные вычисления, зато заметно меньше памяти.
Практика
Заголовок раздела «Практика»Напишите прямой и обратный проход двухслойной сети и численный градиент. Тесты сравнят ваши градиенты с численными, проверят «мёртвый» нейрон и убедятся, что 100 шагов спуска на ваших градиентах снижают loss.
Идите по порядку: softmax → forward → backward → numerical_grad. Формулы для backward уже выписаны в его
докстринге — это шаги 2–5 из раздела «Собираем алгоритм». Кнопка «Запустить» печатает разницу между вашим и численным
градиентом: когда всё верно, она порядка или меньше.
import numpy as np
def softmax(Z):
"""Устойчивый softmax по строкам матрицы Z формы (n, K)."""
# TODO: ваш код (вычтите максимум каждой строки)
...
def forward(X, y, params):
"""Прямой проход двухслойной сети:
Z1 = X @ W1 + b1 → H = ReLU(Z1) → Z2 = H @ W2 + b2 → P = softmax(Z2)
loss = среднее по примерам от −log P[i, y[i]]
X: (n, d), y: (n,) — номера правильных классов, params: словарь W1, b1, W2, b2.
Возвращает (loss: float, cache: dict) — в cache положите всё, что понадобится обратному проходу.
"""
# TODO: ваш код
...
def backward(cache, params):
"""Обратный проход. Возвращает словарь градиентов {'W1', 'b1', 'W2', 'b2'} той же формы, что параметры.
Шаги (от конца к началу):
dZ2 = (P − onehot(y)) / n
dW2 = H.T @ dZ2, db2 = сумма dZ2 по примерам, dH = dZ2 @ W2.T
dZ1 = dH * (Z1 > 0)
dW1 = X.T @ dZ1, db1 = сумма dZ1 по примерам
"""
# TODO: ваш код
...
def numerical_grad(f, x, eps=1e-5):
"""Численный градиент центральной разностью: (f(x + eps) − f(x − eps)) / (2 eps) по каждой координате.
f() — функция БЕЗ аргументов, которая считает loss, читая текущие значения массива x.
Меняйте x[idx] на месте и обязательно возвращайте исходное значение.
Подсказка: for idx in np.ndindex(x.shape): ...
"""
# TODO: ваш код
...
# Проверьте себя кнопкой «Запустить»: gradient check на маленькой сети
rng = np.random.default_rng(0)
X = rng.normal(size=(5, 4))
y = np.array([0, 2, 1, 2, 0])
params = {"W1": rng.normal(size=(4, 6)) * 0.5, "b1": np.zeros(6),
"W2": rng.normal(size=(6, 3)) * 0.5, "b2": np.zeros(3)}
result = forward(X, y, params)
if result is not None:
loss, cache = result
grads = backward(cache, params)
num = numerical_grad(lambda: forward(X, y, params)[0], params["W1"])
print(f"loss = {loss:.4f}")
if grads is not None and num is not None:
print("max |аналитический − численный| для W1:", np.abs(grads["W1"] - num).max())
Сверяем с PyTorch и замеряем скорость (локально)
Заголовок раздела «Сверяем с PyTorch и замеряем скорость (локально)»Этот скрипт запускается у вас на компьютере (нужны NumPy и PyTorch из урока 0.1.2). PyTorch мы подробно разберём в
модуле 3.3, а здесь он нужен как «эталон»: его автоматические градиенты должны совпасть с вашими формулами. Запись
forward_backward(X, y, *params) — распаковка списка в аргументы функции из урока 0.2.2: то же, что
forward_backward(X, y, params[0], params[1], params[2], params[3]).
import timeimport numpy as npimport torchimport torch.nn.functional as F
def forward_backward(X, y, W1, b1, W2, b2, need_grads=True): """Прямой и обратный проход двухслойной сети по формулам урока.""" n = X.shape[0] # размер батча Z1 = X @ W1 + b1 # первый линейный слой H = np.maximum(Z1, 0) # ReLU Z2 = H @ W2 + b2 # второй линейный слой: логиты Z2s = Z2 - Z2.max(axis=1, keepdims=True) # устойчивый softmax (урок 1.3.2) P = np.exp(Z2s) / np.exp(Z2s).sum(axis=1, keepdims=True) loss = -np.log(P[np.arange(n), y]).mean() if not need_grads: # численному градиенту нужен только loss return loss, None dZ2 = P.copy() # кирпичик 1: softmax + CE dZ2[np.arange(n), y] -= 1 dZ2 /= n dW2, db2 = H.T @ dZ2, dZ2.sum(axis=0) # кирпичик 2: линейный слой (веса и сдвиг) dZ1 = (dZ2 @ W2.T) * (Z1 > 0) # dH = dZ2 @ W2.T, затем кирпичик 3: маска ReLU dW1, db1 = X.T @ dZ1, dZ1.sum(axis=0) # снова линейный слой return loss, [dW1, db1, dW2, db2]
rng = np.random.default_rng(0)n, d, h, K = 64, 20, 50, 10 # батч, входы, скрытые нейроны, классыX, y = rng.normal(size=(n, d)), rng.integers(0, K, size=n) # случайные данные и меткиparams = [rng.normal(size=(d, h)) * 0.1, np.zeros(h), rng.normal(size=(h, K)) * 0.1, np.zeros(K)]loss, grads = forward_backward(X, y, *params)
# 1) Сверяем с autograd PyTorch (float64, те же веса)# requires_grad=True просит PyTorch посчитать градиент по этому тензору (подробно — урок 3.3.1)tp = [torch.tensor(p, requires_grad=True) for p in params]logits = torch.relu(torch.tensor(X) @ tp[0] + tp[1]) @ tp[2] + tp[3]tloss = F.cross_entropy(logits, torch.tensor(y))tloss.backward()print(f"loss: numpy {loss:.6f}, torch {tloss.item():.6f}")for name, g, t in zip(["W1", "b1", "W2", "b2"], grads, tp): # zip идёт по трём спискам сразу (урок 0.2.2) print(f"d{name}: max |numpy − torch| = {np.abs(g - t.grad.numpy()).max():.1e}")
# 2) Сколько стоит численный градиент по ВСЕМ параметрамn_params = sum(p.size for p in params) # p.size — число элементов массиваt0 = time.perf_counter()for _ in range(100): forward_backward(X, y, *params)t_backprop = (time.perf_counter() - t0) / 100 # один прямой + обратный проходt0 = time.perf_counter()eps = 1e-5for p in params: for idx in np.ndindex(p.shape): # перебирает все индексы массива: (0, 0), (0, 1), … old = p[idx] p[idx] = old + eps; f_plus = forward_backward(X, y, *params, need_grads=False)[0] # только прямой проход p[idx] = old - eps; f_minus = forward_backward(X, y, *params, need_grads=False)[0] p[idx] = oldt_numeric = time.perf_counter() - t0print(f"параметров: {n_params}, прямых проходов для численного градиента: {2 * n_params}")print(f"численный градиент медленнее backprop примерно в {t_numeric / t_backprop:.0f} раз")Ожидаемый вывод (последняя строка зависит от машины и её загрузки — у нас получалось от 500 до 1900 раз):
loss: numpy 2.330625, torch 2.330625dW1: max |numpy − torch| = 6.9e-18db1: max |numpy − torch| = 6.9e-18dW2: max |numpy − torch| = 1.4e-17db2: max |numpy − torch| = 2.8e-17параметров: 1560, прямых проходов для численного градиента: 3120численный градиент медленнее backprop примерно в 1077 разПочему не ровно в 3120 раз? Один «прямой + обратный» проход сам стоит как два-три прямых, да и у NumPy на таких крошечных матрицах заметная доля времени уходит на накладные расходы вызовов. Порядок величины — около тысячи — от этого не меняется, а с ростом сети разрыв только растёт: он пропорционален числу параметров.
Ваши формулы совпали с autograd PyTorch с точностью до — это просто ошибки округления float64:
loss.backward() делает то же самое. Попробуйте сломать
одну формулу, например забыть /= n или маску ReLU, и посмотрите, какие строки разойдутся.
Проверь себя
Заголовок раздела «Проверь себя»- Backprop — это цепное правило, применённое от loss к входу: «градиент сверху × локальная производная».
- Три кирпичика: softmax + CE даёт . Линейный слой: , , . ReLU: . Формулы проверяются формами матриц.
- Gradient check: центральная разность в float64 и относительная ошибка; меньше — хорошо, — баг.
- Обратный проход стоит примерно два прямых, отсюда правило FLOPs на токен. Платим памятью под активации, экономим её пересчётом — как DeepSeek-V3.
Дальше: модуль 3.2. Вместо того чтобы выводить backward для каждого слоя вручную, напишем свой автоград —
класс Value, который сам строит вычислительный граф и сам считает производные.