Перейти к содержимому
Ступень 33.1 От нейрона к многослойной сети

Прямой и обратный проход

Как сеть узнаёт, куда сдвинуть каждый из миллионов весов, — выводим обратный проход (backprop) для линейного слоя, ReLU и softmax + cross-entropy, проверяем градиенты численно и разбираемся, почему он стоит всего пару прямых проходов.

УРОКstage-3/3-1-neural-nets/02-forward-backward.mdx1 ч 40 минУрок 2 из 2
После урока вы сможете
  • Вывести градиенты линейного слоя (dW=X⊤dYdW = X^\top dY, dX=dY W⊤dX = dY\,W^\top, db=∑dYdb = \sum dY), ReLU и softmax + cross-entropy и проверять их по формам матриц
  • Написать прямой и обратный проход двухслойной сети на NumPy
  • Проверять градиенты численно (gradient check) и читать относительную ошибку
  • Объяснить, почему backprop эффективен, откуда правило «6N FLOPs на токен» и чем за это платят памятью

Чтобы обучить сеть градиентным спуском (урок 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 — градиенты линейного слоя и численную проверку. Сегодня соберём всё это в обучение целой сети. Напомним главное: если LL зависит от zz, а zz — от ww, то

∂L∂w=∂L∂z⏟пришло сверху⋅∂z∂w⏟локальная производная\frac{\partial L}{\partial w} = \underbrace{\frac{\partial L}{\partial z}}_{\text{пришло сверху}} \cdot \underbrace{\frac{\partial z}{\partial w}}_{\text{локальная производная}}

Пример: z=3wz = 3w, L=z2L = z^2, w=1w = 1. Тогда z=3z = 3, «сверху» приходит ∂L/∂z=2z=6\partial L / \partial z = 2z = 6, локальная производная ∂z/∂w=3\partial z / \partial w = 3, и ∂L/∂w=6⋅3=18\partial L / \partial w = 6 \cdot 3 = 18. Проверка напрямую: L=9w2L = 9w^2, производная 18w=1818w = 18.

Это и есть весь backprop: градиент сверху умножить на локальную производную и передать дальше вниз. Каждый слой знает только свою локальную производную. Ему не нужно понимать, что происходит во всей сети.

Договоримся о записи. Для любой величины AA пишем dAdA вместо ∂L/∂A\partial L / \partial A: это массив той же формы, что AA. В нём записано, как изменится loss, если чуть-чуть сдвинуть каждый элемент AA. Пример: если в dWdW на месте (1,2)(1, 2) стоит 0.50.5, то увеличение веса W12W_{12} на 0.010.01 увеличит loss примерно на 0.5⋅0.01=0.0050.5 \cdot 0.01 = 0.005. Значит, чтобы loss уменьшился, этот вес надо немного уменьшить — это и делает шаг градиентного спуска W←W−η dWW \leftarrow W - \eta\, dW.

Возьмём двухслойную сеть для классификации на KK классов — тот же MLP из прошлого урока:

Z1=XW1+b1,H=ReLU(Z1),Z2=HW2+b2,P=softmax(Z2),L=−1n∑i=1nlog⁡Pi,yi\begin{gathered} Z_1 = X W_1 + b_1, \quad H = \mathrm{ReLU}(Z_1), \\ Z_2 = H W_2 + b_2, \quad P = \mathrm{softmax}(Z_2), \\ L = -\frac{1}{n} \sum_{i=1}^{n} \log P_{i, y_i} \end{gathered}
ВеличинаФормаЧто это
XXn×dn \times dбатч из nn примеров
W1W_1, b1b_1d×hd \times h, hhпервый слой
Z1Z_1, HHn×hn \times hскрытый слой до и после ReLU
W2W_2, b2b_2h×Kh \times K, KKвторой слой
Z2Z_2, PPn×Kn \times Kлогиты и вероятности
LLчислосредний 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₁"]
Сплошные стрелки — прямой проход, пунктирные — обратный: градиенты идут справа налево

Обратите внимание: обратному проходу понадобятся XX, Z1Z_1, HH и PP. Поэтому прямой проход их сохраняет — обычно в структуру cache. Запомните это: именно здесь появляется главная статья расхода памяти при обучении.

Любая сеть из этого урока собирается из трёх операций. Выведем градиент для каждой.

Это вы уже вывели в уроках 1.3 и 2.2: для одного примера градиент по логитам равен «предсказание минус правда», p−onehot(y)p - \mathrm{onehot}(y). Loss усредняется по батчу, поэтому добавляется деление на nn:

dZ2=1n(P−Yonehot)dZ_2 = \frac{1}{n}\left(P - Y_{\text{onehot}}\right)

В коде onehot даже не нужен. Достаточно в каждой строке PP вычесть 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() # копия, чтобы не испортить P
dZ2[np.arange(n), y] -= 1 # пары (0, 0), (1, 1), (2, 2): у правильного класса вычитаем 1
dZ2 /= n # loss усреднён по батчу — делим на n
print(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). Каждая строка dZ2dZ_2 в сумме даёт ноль. Логит правильного класса тянем вверх (градиент отрицательный — значит, шаг спуска изменит веса так, чтобы этот логит вырос), остальные — вниз.

Эти формулы вы уже выводили в уроке 1.2.3, там же — подробный пример с числами. Коротко повторим. Пусть Y=XW+bY = XW + b и сверху пришёл dYdY формы n×mn \times m. Распишем один элемент: Yij=∑kXikWkj+bjY_{ij} = \sum_k X_{ik} W_{kj} + b_j. Вес WkjW_{kj} влияет на YijY_{ij} во всех строках ii, с коэффициентом XikX_{ik}. По цепному правилу соберём вклады всех строк:

∂L∂Wkj=∑idYij Xik=(X⊤dY)kj\frac{\partial L}{\partial W_{kj}} = \sum_{i} dY_{ij}\, X_{ik} = \left(X^\top dY\right)_{kj}

Так же выводятся остальные две формулы. В итоге (запись dYi,:dY_{i,:} означает строку ii матрицы dYdY, так что dbdb — сумма всех строк dYdY, в коде dY.sum(axis=0)):

  dW=X⊤dY,db=∑i=1ndYi,:,dX=dY W⊤  \boxed{\;dW = X^\top dY, \qquad db = \sum_{i=1}^{n} dY_{i,:}, \qquad dX = dY\, W^\top\;}

Почему в dWdW и dbdb стоит сумма по примерам? Одни и те же веса применяются к каждому примеру батча, и каждый пример тянет их в свою сторону. Общий градиент — сумма этих «тяг». Это частный случай правила «если величина используется несколько раз, её градиенты складываются» (ветвление графа из урока 1.2.2). В модуле 3.2 это правило станет строчкой += в нашем автограде.

ReLU работает поэлементно. Её производная равна 1 там, где Z>0Z > 0, и 0 там, где Z<0Z < 0:

dZ1=dH⊙1[Z1>0]dZ_1 = dH \odot \mathbb{1}[Z_1 > 0]

Здесь ⊙\odot — поэлементное умножение, а 1[⋅]\mathbb{1}[\cdot] — маска из нулей и единиц: 1 там, где условие в скобках выполнено, и 0, где нет. Пример: Z1=(1.5, −0.5, −2)Z_1 = (1.5,\ -0.5,\ -2), сверху пришло dH=(0.2, −0.1, 0.7)dH = (0.2,\ -0.1,\ 0.7). Маска 1[Z1>0]=(1, 0, 0)\mathbb{1}[Z_1 > 0] = (1,\ 0,\ 0), и dZ1=(0.2, 0, 0)dZ_1 = (0.2,\ 0,\ 0). В NumPy это dH * (Z1 > 0): сравнение даёт массив True/False, а при умножении True считается единицей, False — нулём.

ReLU работает как маршрутизатор: градиент проходит только через нейроны, которые были активны на этом примере. В точке Z=0Z = 0 производная не определена, по соглашению берут 0.

  1. Прямой проход: посчитать Z1,H,Z2,P,LZ_1, H, Z_2, P, L и сохранить их в cache.
  2. Выход: dZ2=(P−Yonehot)/ndZ_2 = (P - Y_{\text{onehot}}) / n.
  3. Второй слой: dW2=H⊤dZ2dW_2 = H^\top dZ_2, db2=∑dZ2db_2 = \sum dZ_2, dH=dZ2 W2⊤dH = dZ_2\, W_2^\top.
  4. ReLU: dZ1=dH⊙1[Z1>0]dZ_1 = dH \odot \mathbb{1}[Z_1 > 0].
  5. Первый слой: dW1=X⊤dZ1dW_1 = X^\top dZ_1, db1=∑dZ1db_1 = \sum dZ_1. Величина dXdX здесь не нужна: входы мы не обучаем. Но в глубокой сети dXdX одного слоя становится dYdY для предыдущего, и цепочка продолжается.
  6. Шаг: W←W−η dWW \leftarrow W - \eta\, dW для всех параметров.

Один пример x=(1,2)x = (1, 2), правильный класс y=0y = 0, два скрытых нейрона, два класса:

W1=(0.5−10.250.5), b1=(0, −0.5),W2=(1−120.5), b2=(0, 0)W_1 = \begin{pmatrix} 0.5 & -1 \\ 0.25 & 0.5 \end{pmatrix},\ b_1 = (0,\ -0.5), \qquad W_2 = \begin{pmatrix} 1 & -1 \\ 2 & 0.5 \end{pmatrix},\ b_2 = (0,\ 0)

Прямой проход.

ШагВычислениеРезультат
Z1=xW1+b1Z_1 = xW_1 + b_1(0.5+0.5, −1+1−0.5)(0.5 + 0.5,\ -1 + 1 - 0.5)(1, −0.5)(1,\ -0.5)
H=ReLU(Z1)H = \mathrm{ReLU}(Z_1)второй нейрон молчит(1, 0)(1,\ 0)
Z2=HW2+b2Z_2 = HW_2 + b_2первая строка W2W_2(1, −1)(1,\ -1)
P=softmax(Z2)P = \mathrm{softmax}(Z_2)e1/(e1+e−1)e^{1} / (e^{1} + e^{-1})(0.881, 0.119)(0.881,\ 0.119)
L=−ln⁡P0L = -\ln P_0−ln⁡0.881-\ln 0.8810.1270.127

Обратный проход.

ШагВычислениеРезультат
dZ2=P−onehot(0)dZ_2 = P - \mathrm{onehot}(0)n=1n = 1(−0.119, 0.119)(-0.119,\ 0.119)
dW2=H⊤dZ2dW_2 = H^\top dZ_2строка второго нейрона нулевая: h2=0h_2 = 0(−0.1190.11900)\begin{pmatrix} -0.119 & 0.119 \\ 0 & 0 \end{pmatrix}
db2=dZ2db_2 = dZ_2один пример: сумма по батчу из одной строки(−0.119, 0.119)(-0.119,\ 0.119)
dH=dZ2W2⊤dH = dZ_2 W_2^\topdH1=−0.119⋅1+0.119⋅(−1)dH_1 = -0.119 \cdot 1 + 0.119 \cdot (-1), dH2=−0.119⋅2+0.119⋅0.5dH_2 = -0.119 \cdot 2 + 0.119 \cdot 0.5(−0.238, −0.179)(-0.238,\ -0.179)
dZ1=dH⊙[Z1>0]dZ_1 = dH \odot [Z_1 > 0]маска (1,0)(1, 0)(−0.238, 0)(-0.238,\ 0)
dW1=x⊤dZ1dW_1 = x^\top dZ_1столбец xx на строку dZ1dZ_1(−0.2380−0.4770)\begin{pmatrix} -0.238 & 0 \\ -0.477 & 0 \end{pmatrix}
db1=dZ1db_1 = dZ_1(−0.238, 0)(-0.238,\ 0)

Прочитаем ответ. Градиент по первому столбцу W1W_1 отрицательный. Значит, шаг спуска увеличит эти веса, h1h_1 вырастет, логит класса 0 тоже вырастет — то, что нужно. Второй нейрон молчал, поэтому его веса не получили сигнала. После одного шага с η=1\eta = 1 loss падает с 0.1270.127 до 0.00340.0034, вероятность правильного класса растёт с 0.881 до 0.997.

Вывести формулы — полдела. Нужно ещё убедиться, что код их правильно реализует. Классический инструмент — проверка градиентапроверка градиентаEN gradient checkСравнение аналитического градиента с численным (центральные разности) по относительной ошибке. Главный способ найти баг в backward.: сравнить аналитический градиент с численным. В уроке 1.2.3 вы проверяли так один слой, теперь проверим целую сеть. Численная производная по одному параметру считается центральной разностью:

∂L∂θ≈L(θ+ε)−L(θ−ε)2ε\frac{\partial L}{\partial \theta} \approx \frac{L(\theta + \varepsilon) - L(\theta - \varepsilon)}{2 \varepsilon}

Словами: сдвигаем один параметр чуть вверх и чуть вниз, дважды считаем loss и делим разницу на расстояние между точками. Центральная разность точнее односторонней (L(θ+ε)−L(θ))/ε(L(\theta + \varepsilon) - L(\theta)) / \varepsilon: её ошибка пропорциональна ε2\varepsilon^2, а не ε\varepsilon, — уменьшили шаг в 10 раз, ошибка упала в 100 раз (урок 1.2.1). В нашем примере для W1[0,0]W_1[0,0] численно получается −0.2384058441-0.2384058441, аналитически −0.2384058440-0.2384058440.

Сравнивают по относительной ошибке, потому что абсолютная ничего не говорит о градиентах разного масштаба. Разница 0.0010.001 — это пустяк, если градиент равен 100, и катастрофа, если он равен 0.0020.002. Поэтому разницу делят на размер самих чисел (а 10−810^{-8} в знаменателе страхует от деления на ноль, когда оба градиента нулевые):

err=∣gан−gчисл∣max⁡(∣gан∣+∣gчисл∣, 10−8)\text{err} = \frac{\lvert g_{\text{ан}} - g_{\text{числ}} \rvert}{\max(\lvert g_{\text{ан}} \rvert + \lvert g_{\text{числ}} \rvert,\ 10^{-8})}

Пример: gан=0.2500g_{\text{ан}} = 0.2500, gчисл=0.2501g_{\text{числ}} = 0.2501. Ошибка =0.0001/0.5001≈2⋅10−4= 0.0001 / 0.5001 \approx 2 \cdot 10^{-4} — подозрительно.

Относительная ошибкаЧто это значит
меньше 10−710^{-7}всё верно
10−710^{-7} … 10−410^{-4}подозрительно; допустимо, если рядом изломы (ReLU)
больше 10−210^{-2}почти наверняка баг

Правила хорошего gradient check:

  • считайте в float64: в float32 ошибка округления сравнима с самой разностью;
  • берите ε≈10−5\varepsilon \approx 10^{-5}…10−610^{-6}; слишком маленький ε\varepsilon теряет точность из-за округления;
  • у ReLU есть излом в нуле: если ZZ ближе к нулю, чем ε\varepsilon, численная производная «перепрыгнет» излом и солжёт;
  • проверяйте на маленькой сети и маленьком батче: численный градиент требует двух прямых проходов на каждый параметр;
  • после сдвига обязательно верните параметр на место.

У сети NN параметров. Численный градиент — это 2N2N прямых проходов. Backprop — один прямой и один обратный. Для 1560 параметров это 3120 проходов против двух. Для модели на 7 млрд параметров — 14 млрд прямых проходов на один шаг обучения. Скрипт в практике ниже покажет разницу на реальном замере.

Посчитаем операции для одного линейного слоя n×d→n×mn \times d \to n \times m. FLOPs — число операций с плавающей точкой (умножений и сложений); умножение матриц (n×d)(d×m)(n \times d)(d \times m) стоит около 2ndm2ndm таких операций (урок 1.1.2):

ЧтоФормулаFLOPs
прямой проходY=XWY = XW2ndm2ndm
градиент по входуdX=dY W⊤dX = dY\, W^\top2ndm2ndm
градиент по весамdW=X⊤dYdW = X^\top dY2ndm2ndm

Обратный проход стоит примерно два прямых, а весь шаг обучения — около трёх. Отсюда правило, которым считают бюджеты обучения LLM: ≈ 6N6N FLOPs на каждый токен (2N2N вперёд и 4N4N назад, где NN — число параметров). Модель на 7 млрд параметров, обученная на 1 трлн токенов, требует около 6⋅7⋅109⋅1012≈4⋅10226 \cdot 7 \cdot 10^{9} \cdot 10^{12} \approx 4 \cdot 10^{22} FLOPs. Такие бюджеты вы будете считать в уроке 5.1.

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

  • с конца (обратный режим, backprop): начинаем со строки ∂L/∂Zвыход\partial L / \partial Z_{\text{выход}} и каждый раз умножаем вектор на матрицу. Это дёшево, и один проход даёт производные по всем параметрам;
  • с начала (прямой режим): протаскиваем вперёд производную по одному входу. Это тоже вектор на матрицу, но повторить придётся для каждого из NN параметров.

Аналогия: нужно узнать, как каждый из миллиона ручейков влияет на уровень воды в одном озере. Можно миллион раз пустить краску в очередной ручей и смотреть, дойдёт ли она до озера (прямой режим). А можно один раз пройти от озера вверх по течению и по дороге записать вклад каждого ручья (обратный режим). Когда выход один, а входов много, обратный режим выигрывает в NN раз. Поэтому все фреймворки глубокого обучения используют именно его. Как автоматизировать эти вычисления для любого графа — тема модуля 3.2.

За скорость платят памятью: все промежуточные активации (XX, Z1Z_1, HH, PP в нашем примере) нужно хранить до обратного прохода. При обучении LLM с длинным контекстом и большим батчем активации часто занимают больше памяти, чем сами веса. Стандартный приём — пересчёт (recomputation, activation checkpointing): часть активаций выбрасывают после прямого прохода и считают заново во время обратного. Это дополнительные вычисления, зато заметно меньше памяти.

Напишите прямой и обратный проход двухслойной сети и численный градиент. Тесты сравнят ваши градиенты с численными, проверят «мёртвый» нейрон и убедятся, что 100 шагов спуска на ваших градиентах снижают loss.

Идите по порядку: softmax → forward → backward → numerical_grad. Формулы для backward уже выписаны в его докстринге — это шаги 2–5 из раздела «Собираем алгоритм». Кнопка «Запустить» печатает разницу между вашим и численным градиентом: когда всё верно, она порядка 10−1010^{-10} или меньше.

Backprop для двухслойной сети + gradient checknumpy · нужен интернет8 тестов
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 time
import numpy as np
import torch
import 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-5
for 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] = old
t_numeric = time.perf_counter() - t0
print(f"параметров: {n_params}, прямых проходов для численного градиента: {2 * n_params}")
print(f"численный градиент медленнее backprop примерно в {t_numeric / t_backprop:.0f} раз")

Ожидаемый вывод (последняя строка зависит от машины и её загрузки — у нас получалось от 500 до 1900 раз):

loss: numpy 2.330625, torch 2.330625
dW1: max |numpy − torch| = 6.9e-18
db1: max |numpy − torch| = 6.9e-18
dW2: max |numpy − torch| = 1.4e-17
db2: max |numpy − torch| = 2.8e-17
параметров: 1560, прямых проходов для численного градиента: 3120
численный градиент медленнее backprop примерно в 1077 раз

Почему не ровно в 3120 раз? Один «прямой + обратный» проход сам стоит как два-три прямых, да и у NumPy на таких крошечных матрицах заметная доля времени уходит на накладные расходы вызовов. Порядок величины — около тысячи — от этого не меняется, а с ростом сети разрыв только растёт: он пропорционален числу параметров.

Ваши формулы совпали с autograd PyTorch с точностью до 10−1710^{-17} — это просто ошибки округления float64: loss.backward() делает то же самое. Попробуйте сломать одну формулу, например забыть /= n или маску ReLU, и посмотрите, какие строки разойдутся.

Проверь себя6 вопросов
1 Слой Y=XW+bY = XW + b: XX формы 32×10032 \times 100, WW формы 100×64100 \times 64. Какая формула даёт dWdW?
2 Один пример, softmax дал P=(0.7, 0.2, 0.1)P = (0.7,\ 0.2,\ 0.1), правильный класс — второй (индекс 1). Чему равна компонента dZdZ для правильного класса?
3 Перед ReLU Z1=(2, −1, 0.5)Z_1 = (2,\ -1,\ 0.5), сверху пришёл dH=(0.3, 0.4, −0.2)dH = (0.3,\ 0.4,\ -0.2). Чему равна сумма компонент dZ1dZ_1?
4 Какие утверждения про gradient check верны?Можно выбрать несколько ответов
5 Модель на 7 млрд параметров обучают на 1 трлн токенов. Сколько примерно FLOPs потребуется? Ответ дайте в единицах 102110^{21}.
6 Почему backprop эффективнее численного дифференцирования?
  • Backprop — это цепное правило, применённое от loss к входу: «градиент сверху × локальная производная».
  • Три кирпичика: softmax + CE даёт dZ=(P−Y)/ndZ = (P - Y)/n. Линейный слой: dW=X⊤dYdW = X^\top dY, db=∑dYdb = \sum dY, dX=dY W⊤dX = dY\,W^\top. ReLU: dZ=dH⊙1[Z>0]dZ = dH \odot \mathbb{1}[Z > 0]. Формулы проверяются формами матриц.
  • Gradient check: центральная разность в float64 и относительная ошибка; меньше 10−710^{-7} — хорошо, 10−210^{-2} — баг.
  • Обратный проход стоит примерно два прямых, отсюда правило 6N6N FLOPs на токен. Платим памятью под активации, экономим её пересчётом — как DeepSeek-V3.

Дальше: модуль 3.2. Вместо того чтобы выводить backward для каждого слоя вручную, напишем свой автоград — класс Value, который сам строит вычислительный граф и сам считает производные.