Содержание курса 20 / 27

20 / 27 · Нейронные сети

Обратное распространение ошибки

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

На этой странице 13

В предыдущей заметке построены сети с тысячами параметров; у маленького классификатора цифр оттуда их уже 17 226. Градиентному спуску на каждом шаге нужен градиент функции потерь по всем ним. Для логистической регрессии градиент имел замкнутый вид. Для сети функция потерь — композиция многих функций, и выписывать каждую частную производную вручную безнадёжно. Обратное распространение ошибки (backpropagation) вычисляет их все сразу, применяя цепное правило от функции потерь назад и переиспользуя каждый промежуточный результат. Его стоимость — небольшая константа, умноженная на стоимость вычисления функции потерь, при любом числе параметров. Для нейросетей его популяризировали Rumelhart, Hinton and Williams (1986), и это частный случай автоматического дифференцирования в обратном режиме (reverse-mode automatic differentiation), восходящего к Linnainmaa (1976) (Baydin et al., 2018, раздел 3.3).

Обозначения. Как и в предыдущей заметке, zl=Wlhl−1+blz_l = W_l h_{l-1} + b_l и hl=φ(zl)h_l = \varphi(z_l), а функция потерь LL — скаляр. Для любой величины uu (скаляра, вектора или матрицы) uˉ=∂L/∂u\bar u = \partial L / \partial u — градиент функции потерь по ней, называемый её сопряжённой величиной (adjoint); он имеет ту же форму, что и uu. Для отображения v=g(u)v = g(u) из Rn\mathbb R^n в Rm\mathbb R^m якобиан (Jacobian) J=∂v/∂uJ = \partial v / \partial u — это матрица m×nm \times n с элементами Jij=∂vi/∂ujJ_{ij} = \partial v_i / \partial u_j. ⊙\odot — поэлементное произведение, eye_y — one-hot-вектор класса yy.

Почему не дифференцировать численно?#

Отправная точка — определение производной. Сдвинем один параметр, заново прогоним сеть и поделим:

∂L∂θi≈L(θ+εei)−L(θ−εei)2ε.\frac{\partial L}{\partial \theta_i} \approx \frac{L(\theta + \varepsilon e_i) - L(\theta - \varepsilon e_i)}{2\varepsilon} .

Это стоит двух прямых проходов на параметр: 34 452 прохода на шаг для классификатора цифр и невообразимо много для сети с миллионами весов. К тому же результат лишь приближённый, как показывает последний раздел. Символьное дифференцирование, при котором каждая ∂L/∂θi\partial L / \partial \theta_i выписывается формулой, избавляет от приближения, но повторяет одни и те же подвыражения в каждой формуле.

Вычислительный граф#

Любое вычисление LL — последовательность элементарных операций. Если нарисовать её как ориентированный ациклический граф, вершинами будут промежуточные значения, а ребро из uu в vv означает, что vv вычисляется из uu. Прямой проход вычисляет вершины по порядку. Обратный проход обходит их в обратном порядке и применяет цепное правило: если uu используется вершинами v1,…,vkv_1, \dots, v_k, его влияние на LL проходит через все них, и многомерное цепное правило складывает вклады:

uˉ=∑c=1kvˉc ∂vc∂u.\bar u = \sum_{c=1}^{k} \bar v_c\, \frac{\partial v_c}{\partial u} .

Разобранный пример: один нейрон. Сигмоидный нейрон с квадратичной ошибкой, z=wx+bz = wx + b, a=σ(z)a = \sigma(z), L=(a−y)2L = (a - y)^2, в точке x=1.5x = 1.5, w=0.8w = 0.8, b=−0.2b = -0.2, y=1y = 1:

прямой проходобратный проход
z=wx+bz = wx + b1.01.0aˉ=2(a−y)\bar a = 2(a - y)−0.538-0.538
a=σ(z)a = \sigma(z)0.7310.731zˉ=aˉ σ(z)(1−σ(z))=−0.538⋅0.197\bar z = \bar a\, \sigma(z)(1 - \sigma(z)) = -0.538 \cdot 0.197−0.106-0.106
L=(a−y)2L = (a - y)^20.07230.0723wˉ=zˉ x\bar w = \bar z\, x,  bˉ=zˉ\ \bar b = \bar z−0.159-0.159,  −0.106\ -0.106

Обратный столбец начинается с Lˉ=1\bar L = 1 и в каждой вершине умножается на одну локальную производную. Локальная производная σ′(z)=a(1−a)\sigma'(z) = a(1 - a) использует значение aa, сохранённое при прямом проходе, — поэтому значения прямого прохода и хранят.

Цепное правило в матричной форме#

Для векторов то же правило выглядит так: если v=g(u)v = g(u) с якобианом JJ, то

uˉ=J⊤vˉ(n компонент=(n×m) (m компонент)).\bar u = J^\top \bar v \qquad (n \text{ компонент} = (n \times m)\,(m \text{ компонент})).

Обратный проход умножает на транспонированные якобианы, и ему никогда не нужно строить их как матрицы. Три операции покрывают MLP.

Линейный слой z=Wh+bz = W h + b, где W∈Rm×nW \in \mathbb R^{m \times n}. Поскольку zi=∑jWijhj+biz_i = \sum_j W_{ij} h_j + b_i, производная LL по одному весу собирает только тот выход, в который этот вес идёт: ∂L/∂Wij=zˉihj\partial L / \partial W_{ij} = \bar z_i h_j. В матричной форме

Wˉ=zˉ h⊤ (m×n),bˉ=zˉ (m),hˉ=W⊤zˉ (n).\bar W = \bar z\, h^\top \ (m \times n), \qquad \bar b = \bar z \ (m), \qquad \bar h = W^\top \bar z \ (n) .

Градиент по весам — внешнее произведение сопряжённой величины, пришедшей сверху, и входа, сохранённого при прямом проходе.

Поэлементная активация h=φ(z)h = \varphi(z). Её якобиан диагональный, diag⁡(φ′(z))\operatorname{diag}(\varphi'(z)), поэтому

zˉ=φ′(z)⊙hˉ.\bar z = \varphi'(z) \odot \bar h .

Для ReLU φ′(z)\varphi'(z) — это маска 1[z>0]\mathbf 1[z > 0]: градиент проходит через активные нейроны без изменений и блокируется на неактивных.

Мини-батч. Если объекты записаны строками HH, то Z=HW⊤+1b⊤Z = H W^\top + \mathbf 1 b^\top, и при функции потерь, усреднённой по батчу,

Hˉ=Zˉ W,Wˉ=Zˉ⊤H,bˉ=Zˉ⊤1.\bar H = \bar Z\, W, \qquad \bar W = \bar Z^\top H, \qquad \bar b = \bar Z^\top \mathbf 1 .

Zˉ⊤H\bar Z^\top H суммирует внешние произведения по объектам — так складываются градиенты объектов батча. Полезная проверка: сопряжённая величина имеет форму исходной, и для каждой формулы есть лишь один способ перемножить множители так, чтобы размерности совпали.

Двухслойная сеть: прямой и обратный проход#

Возьмём x∈Rdx \in \mathbb R^d, скрытый слой из HH ReLU-нейронов и KK классов:

z1=W1x+b1∈RH,h=ReLU⁡(z1),z2=W2h+b2∈RK,p=softmax⁡(z2),L=−log⁡py.\begin{aligned} z_1 &= W_1 x + b_1 \in \mathbb R^{H}, & h &= \operatorname{ReLU}(z_1), \\ z_2 &= W_2 h + b_2 \in \mathbb R^{K}, & p &= \operatorname{softmax}(z_2), \qquad L = -\log p_y . \end{aligned}

Через softmax и кросс-энтропию. Запишем L=−z2,y+log⁡∑jez2,jL = -z_{2,y} + \log \sum_j e^{z_{2,j}}. При дифференцировании по z2,kz_{2,k} первое слагаемое даёт −1[k=y]-\mathbf 1[k = y], а второе — ez2,k/∑jez2,j=pke^{z_{2,k}} / \sum_j e^{z_{2,j}} = p_k. Следовательно,

zˉ2=p−ey:\bar z_2 = p - e_y :

предсказанные вероятности минус one-hot-вектор цели — ровно градиент логистической регрессии по её логитам. Дальше — три правила выше в порядке, обратном прямому проходу:

Wˉ2=zˉ2 h⊤,bˉ2=zˉ2,hˉ=W2⊤zˉ2,zˉ1=hˉ⊙1[z1>0],Wˉ1=zˉ1 x⊤,bˉ1=zˉ1.\begin{aligned} \bar W_2 &= \bar z_2\, h^\top, & \bar b_2 &= \bar z_2, & \bar h &= W_2^\top \bar z_2, \\ \bar z_1 &= \bar h \odot \mathbf 1[z_1 > 0], & \bar W_1 &= \bar z_1\, x^\top, & \bar b_1 &= \bar z_1 . \end{aligned}
величинаформасопряжённая величинаформа произведения
z2z_2KKp−eyp - e_yKK
W2W_2K×HK \times Hzˉ2h⊤\bar z_2 h^\top(K×1)(1×H)(K \times 1)(1 \times H)
hhHHW2⊤zˉ2W_2^\top \bar z_2(H×K)(K)(H \times K)(K)
z1z_1HHhˉ⊙1[z1>0]\bar h \odot \mathbf 1[z_1 > 0]HH
W1W_1H×dH \times dzˉ1x⊤\bar z_1 x^\top(H×1)(1×d)(H \times 1)(1 \times d)

Для батча из nn объектов с усреднённой функцией потерь Zˉ2=(P−Y)/n\bar Z_2 = (P - Y)/n, где строки YY — one-hot-векторы целей, и дальше работают формулы для батча из предыдущего раздела.

Разобранный пример. Возьмём d=2d = 2, H=3H = 3, K=2K = 2, нулевые смещения, истинный класс y=1y = 1 (первый) и

x=(12),W1=(10−110.5−1),W2=(1−120.51−1).x = \begin{pmatrix} 1 \\ 2 \end{pmatrix}, \qquad W_1 = \begin{pmatrix} 1 & 0 \\ -1 & 1 \\ 0.5 & -1 \end{pmatrix}, \qquad W_2 = \begin{pmatrix} 1 & -1 & 2 \\ 0.5 & 1 & -1 \end{pmatrix} .

Прямой проход: z1=(1,1,−1.5)z_1 = (1, 1, -1.5), поэтому h=(1,1,0)h = (1, 1, 0); z2=(0,1.5)z_2 = (0, 1.5), p=(0.182,0.818)p = (0.182, 0.818) и L=−log⁡0.182=1.701L = -\log 0.182 = 1.701. Обратный проход:

  • zˉ2=p−e1=(−0.818,0.818)\bar z_2 = p - e_1 = (-0.818, 0.818);
  • Wˉ2=zˉ2h⊤\bar W_2 = \bar z_2 h^\top имеет строки (−0.818,−0.818,0)(-0.818, -0.818, 0) и (0.818,0.818,0)(0.818, 0.818, 0);
  • hˉ=W2⊤zˉ2=(−0.409,1.635,−2.453)\bar h = W_2^\top \bar z_2 = (-0.409, 1.635, -2.453);
  • zˉ1=hˉ⊙(1,1,0)=(−0.409,1.635,0)\bar z_1 = \bar h \odot (1, 1, 0) = (-0.409, 1.635, 0);
  • Wˉ1=zˉ1x⊤\bar W_1 = \bar z_1 x^\top имеет строки (−0.409,−0.818)(-0.409, -0.818), (1.635,3.270)(1.635, 3.270) и (0,0)(0, 0).

Третий скрытый нейрон получает наибольшую по модулю сопряжённую величину, hˉ3=−2.453\bar h_3 = -2.453, но он неактивен, поэтому маска её останавливает, и его входные веса получают от этого примера нулевой градиент. Нейрон, неактивный на всех объектах, никогда не обучается — это мёртвый ReLU-нейрон из предыдущей заметки.

Рисунок

Обратное распространение в сети с двумя слоями

Прямой и обратный проходВерхний ряд: x, W₁, z₁, ReLU, h, W₂, z₂, softmax, p и потери L, стрелки вправо. Нижний ряд: градиенты L̄, z̄₂, h̄, z̄₁, стрелки влево через p − e₁, W₂ᵀ и маску ReLU; снизу матрицы градиентов весов W̄₁ и W̄₂.прямой проход: значенияобратный проход: градиенты12xW111−1.50z11ReLU110h2W201.50z2softmax0.180.82p−log p₁1.70L31Lp − e₁−0.820.82z24W2T−0.411.64−2.45h−0.82−0.8200.820.820W2 = z2hT5⊙1[z₁ > 0]−0.411.640z16−0.41−0.821.643.2700W1 = z1xT7входсостояниевеса и гейтывыходградиентПрямой и обратный проходЛевый столбец сверху вниз: x, W₁, z₁, ReLU, h, W₂, z₂, softmax, p, L. Правый столбец снизу вверх: L̄, p − e₁, z̄₂, W₂ᵀ, h̄, маска ReLU, z̄₁. Внизу матрицы W̄₁ и W̄₂.прямой проход ↓обратный проход ↑12xW111−1.50z11ReLU110h2W201.50z2softmax0.180.82p−log p₁1.70L31Lp − e₁−0.820.82z24W2T−0.411.64−2.45h5−0.82−0.8200.820.820W2 = z2hT⊙1[z₁ > 0]−0.411.640z16−0.41−0.821.643.2700W1 = z1xT7входсостояниевеса и гейтывыходградиент
Пример из текста: x = (1, 2), веса W₁ и W₂ из текста, нулевые смещения, истинный класс 1. Верхний ряд — прямой проход слева направо (1–3): z₁ = (1, 1, −1.5), h = (1, 1, 0), z₂ = (0, 1.5), p = (0.182, 0.818), L = 1.701. Нижний ряд — обратный проход справа налево (4–7): начиная с L̄ = 1, каждый шаг умножает градиент на транспонированный якобиан своей операции и использует значения, сохранённые при прямом проходе (пунктир). Третий нейрон выключен (z₁ < 0), поэтому маска ReLU обнуляет его градиент, и его входные веса для этого примера не меняются.
  1. Первый слой вычисляет z1=W1x+b1z_1 = W_1 x + b_1.
  2. ReLU оставляет положительные компоненты: h=max⁡(0,z1)h = \max(0, z_1), а прямой проход сохраняет маску 1[z1>0]\mathbf 1[z_1 > 0].
  3. Второй слой даёт логиты z2z_2, softmax — вероятности pp, а функция потерь равна L=−log⁡p1L = -\log p_1.
  4. Обратный проход начинается с Lˉ=1\bar L = 1; через softmax и кросс-энтропию zˉ2=p−e1\bar z_2 = p - e_1.
  5. Второй слой даёт свой градиент по весам Wˉ2=zˉ2h⊤\bar W_2 = \bar z_2 h^\top и передаёт вниз hˉ=W2⊤zˉ2\bar h = W_2^\top \bar z_2.
  6. Маска ReLU, сохранённая на шаге 2, обнуляет сопряжённую величину неактивного нейрона: zˉ1=hˉ⊙1[z1>0]\bar z_1 = \bar h \odot \mathbf 1[z_1 > 0].
  7. Первый слой даёт Wˉ1=zˉ1x⊤\bar W_1 = \bar z_1 x^\top и bˉ1=zˉ1\bar b_1 = \bar z_1.

Почему обратный режим стоит примерно одного прямого прохода#

Посчитаем работу для одного линейного слоя с батчем из nn объектов. Прямое произведение HW⊤H W^\top требует n din doutn\, d_{\text{in}}\, d_{\text{out}} умножений со сложением. Обратный проход вычисляет два произведения того же размера, ZˉW\bar Z W и Zˉ⊤H\bar Z^\top H, поэтому стоит вдвое больше прямого; поэлементные шаги пренебрежимо малы по сравнению с матричными произведениями. Весь градиент, таким образом, стоит около трёх прямых проходов, и это отношение не зависит от числа параметров. В общем случае для f:Rn→Rmf: \mathbb R^n \to \mathbb R^m обратный режим вычисляет полный якобиан за mm обратных проходов со стоимостью m⋅c⋅ops⁡(f)m \cdot c \cdot \operatorname{ops}(f), где гарантировано c<6c < 6, а обычно cc лежит между 2 и 3 (Baydin et al., 2018, раздел 3.2; Griewank and Walther, 2008). Функция потерь — одно число, m=1m = 1, так что один проход даёт весь градиент.

Прямой режим (forward mode) применяет цепное правило в другом направлении. Вместе с каждым промежуточным значением uu он несёт его производную u˙=∂u/∂θi\dot u = \partial u / \partial \theta_i по одному входному направлению и за один проход вычисляет произведение якобиана на вектор JvJ v. Тогда градиенту по nn параметрам нужно nn проходов со стоимостью n⋅c⋅ops⁡(f)n \cdot c \cdot \operatorname{ops}(f). Прямой режим выигрывает, когда входов мало, а выходов много; при обучении входов миллионы (параметры), а выход один (функция потерь), поэтому все библиотеки глубинного обучения построены на обратном режиме. В разделе «Код» 17 частных производных разобранного примера вычислены обоими способами: 17 проходов прямого режима против одного обратного прохода, с одинаковыми результатами.

Цена обратного режима — память. Обратному проходу нужны значения прямого прохода, например hh для Wˉ2\bar W_2 и маски ReLU, поэтому все активации всех слоёв приходится хранить, пока до них не дойдёт обратный проход. У больших сетей именно это, а не параметры, заполняет память ускорителя. Контрольные точки градиента (gradient checkpointing) сохраняют лишь некоторые слои и пересчитывают остальные во время обратного прохода; для сети из nn слоёв это требует O(n)O(\sqrt n) памяти ценой одного дополнительного прямого прохода (Chen et al., 2016).

Что вычисляет autograd#

Библиотеки вроде PyTorch реализуют обратный режим автоматически. Пока идёт прямой проход, каждая операция над тензором, которому нужны градиенты, записывает вершину вычислительного графа вместе с функцией, переводящей сопряжённую величину её выхода в сопряжённые величины её входов. PyTorch строит этот граф заново при каждом прямом проходе — это подход к автоматическому дифференцированию в обратном режиме через перегрузку операторов (operator overloading; Paszke et al., 2019, раздел 4.3), — поэтому обычные управляющие конструкции Python дифференцируемы.

математикаPyTorch
параметры θ\thetaтензоры с requires_grad=True (любой nn.Parameter)
вычислительный граф прямого проходацепочка вершин grad_fn, прикреплённых к каждому результату
начать обратный проход с Lˉ=1\bar L = 1loss.backward()
сопряжённая величина θˉ\bar\thetaparam.grad
сумма по потребителям вершиныградиенты прибавляются к .grad, поэтому перед каждым шагом вызывают optimizer.zero_grad()
один шаг градиентного спускаoptimizer.step()
произведение вектора на якобиан v⊤Jv^\top J для нескалярного выходаout.backward(v) или torch.autograd.grad(out, inputs, grad_outputs=v)
прямой проход, который ничего не записываетwith torch.no_grad():

Полезно знать три следствия:

  • Градиенты накапливаются. Поскольку к .grad прибавляют, два вызова backward() без обнуления удваивают градиент. Это сумма из многомерного цепного правила, и она позволяет намеренно накапливать градиент по нескольким маленьким батчам.
  • Для изломов есть соглашение. У ReLU нет производной в 0; PyTorch использует там 0. Любое значение из [0,1][0, 1] — допустимый субградиент, а точные нули на практике редки.
  • Общие веса складываются. Вес, используемый в нескольких местах, например одна и та же рекуррентная матрица на каждом временном шаге, получает сумму сопряжённых величин от всех использований. Развёрнутая во времени рекуррентная сеть — это просто вычислительный граф с общими весами, и обратное распространение ошибки во времени (backpropagation through time) — это содержание этой заметки, применённое к нему; курс NLP показывает, как произведения якобианов вдоль оси времени заставляют эти градиенты затухать или взрываться.

Autograd вычисляет точные производные той программы, которая была выполнена, с точностью до арифметики с плавающей точкой. Он не решает, полезны ли эти производные: градиент, который равен нулю или почти нулю (мёртвый ReLU, насыщенная сигмоида) или астрономически велик, вычисляется столь же добросовестно. Дискретное решение — другой случай: у него вообще нет пути для градиента. torch.argmax возвращает целочисленный индекс, не связанный с графом (requires_grad равно False), поэтому функцию потерь, вычисленную только по индексу, нельзя продифференцировать обратным проходом, а функция потерь, которая использует индекс для выбора, не передаёт градиента оценкам, по которым этот выбор сделан. Когда сам выбор нужно обучать, его заменяют дифференцируемым приближением, например softmax с температурой; раздел «Код» показывает и то, и другое.

Проверка градиентов конечными разностями#

Написанный вручную обратный проход или собственная функция autograd могут содержать ошибки, при которых функция потерь всё равно убывает. Стандартный тест сравнивает их с центральной разностью из первого раздела, по одной координате за раз, через относительную ошибку

∣gbackprop−gnumeric∣max⁡(∣gbackprop∣,∣gnumeric∣,δ),\frac{\lvert g_{\text{backprop}} - g_{\text{numeric}} \rvert}{\max(\lvert g_{\text{backprop}} \rvert, \lvert g_{\text{numeric}} \rvert, \delta)} ,

с небольшим нижним порогом, например δ=10−8\delta = 10^{-8}, чтобы два нулевых градиента считались совпадением, а не давали 0/00/0.

Центральная разность лучше односторонней [L(θ+εei)−L(θ)]/ε[L(\theta + \varepsilon e_i) - L(\theta)]/\varepsilon. По формуле Тейлора L(θ±εei)=L±εL′+12ε2L′′±16ε3L′′′+…L(\theta \pm \varepsilon e_i) = L \pm \varepsilon L' + \tfrac12 \varepsilon^2 L'' \pm \tfrac16 \varepsilon^3 L''' + \dots, поэтому у одностороннего отношения ошибка 12εL′′\tfrac12 \varepsilon L'', а в центральном чётные члены сокращаются и ошибка равна 16ε2L′′′\tfrac16 \varepsilon^2 L'''. Меньшее ε\varepsilon не всегда лучше: каждое значение функции потерь несёт ошибку округления порядка 10−1610^{-16} относительно в двойной точности, а деление на ε\varepsilon её усиливает.

Рисунок

Ошибка конечных разностей в зависимости от шага

Две V-образные кривые ошибкиОшибка центральной и правой разностей против шага в логарифмическом масштабе: справа обе падают с уменьшением шага, слева растут из-за округления; минимум центральной разности ниже и правее.10⁻¹⁴10⁻¹²10⁻¹⁰10⁻⁸10⁻⁶10⁻⁴10⁻²10⁻¹³10⁻¹⁰10⁻⁷10⁻⁴10⁻¹шаг εусечение ∼ ε² и εокругление ∼ 1/εцентральная разностьправая разностьДве V-образные кривые ошибкиОшибка центральной и правой разностей против шага в логарифмическом масштабе: справа обе падают с уменьшением шага, слева растут из-за округления; минимум центральной разности ниже и правее.10⁻¹⁴10⁻¹²10⁻¹⁰10⁻⁸10⁻⁶10⁻⁴10⁻²10⁻¹³10⁻¹⁰10⁻⁷10⁻⁴10⁻¹шаг εусечение ∼ ε² и εокругление ∼ 1/εцентральная разностьправая разность
Производная функции потерь из примера в тексте по одному весу (W₁)₁₁, точное значение из обратного распространения −0.40879, вычисления в двойной точности; обе оси логарифмические. Справа ошибка определяется усечением ряда Тейлора: при ε = 0.1 она равна 2.0·10⁻⁵ для центральной разности и 1.9·10⁻³ для правой и падает как ε² и как ε. Слева её определяет округление, которое растёт как 1/ε. Центральная разность лучше всего при ε ≈ 10⁻⁴·⁵ (ошибка порядка 10⁻¹²), правая — при ε ≈ 10⁻⁷·⁵ (порядка 10⁻¹⁰).

На практике:

  • считать в двойной точности (torch.float64); в одинарной точности ошибка округления порядка 10−710^{-7}, и проверка становится ненадёжной;
  • брать ε\varepsilon порядка 10−610^{-6}–10−510^{-5} с центральными разностями; относительная ошибка около 10−810^{-8} или ниже означает совпадение, около 10−210^{-2} или выше — ошибку в коде;
  • избегать точек рядом с изломами: если вход ReLU ближе чем на ε\varepsilon к нулю, две стороны видят разные наклоны;
  • в большой сети проверять несколько случайных координат каждого тензора параметров; torch.autograd.gradcheck делает то же для собственной функции.

В разделе «Код» написанный вручную обратный проход разобранного примера совпадает с центральными разностями по всем 17 параметрам с относительной ошибкой 3.6⋅10−103.6 \cdot 10^{-10}. Если убрать маску ReLU из обратного прохода — типичная ошибка, — она вырастает до 1.01.0: веса третьего нейрона получают ненулевой градиент там, где истинный равен нулю.

Что изменилось#

Численное дифференцированиеОбратное распространение ошибки
Стоимость полного градиентадва прямых прохода на параметроколо трёх прямых проходов в сумме
Точностьошибки усечения и округленияточно с точностью до плавающей точки
Памятьодин прямой проходвсе сохранённые активации (или контрольные точки)
Что нужно написатьтолько прямой проходтолько прямой проход, если есть autograd
Роль сегодняпроверка обратного проходатак обучается любая сеть

Ограничения#

  • Память растёт с глубиной и размером батча, потому что активации прямого прохода хранятся для обратного.
  • Нужны дифференцируемые операции. Дискретные выборы, сэмплирование и argmax не дают полезного градиента: argmax возвращает индекс вне графа, а у ступенчатой функции производная почти всюду равна нулю. Дифференцируемые приближения вроде softmax с температурой и приёмы вроде трюка репараметризации (reparameterization trick) из курса о генеративных моделях делают некоторые из них дифференцируемыми.
  • Градиент локален. Обратное распространение точно вычисляет производную в текущих весах; оно не говорит, придёт ли градиентный спуск к хорошему решению, а произведения многих якобианов могут заставить градиент затухать или взрываться в глубоких или рекуррентных сетях.
  • Последовательность по глубине. Обратный проход слоя ждёт слоя над ним, и это ограничивает, насколько слои глубокой сети можно вычислять параллельно.

Что нужно запомнить#

  • Сопряжённая величина uˉ=∂L/∂u\bar u = \partial L / \partial u имеет форму uu; вершина, используемая несколько раз, суммирует вклады своих потребителей.
  • Линейный слой: Wˉ=zˉh⊤\bar W = \bar z h^\top, bˉ=zˉ\bar b = \bar z, hˉ=W⊤zˉ\bar h = W^\top \bar z; активация: zˉ=φ′(z)⊙hˉ\bar z = \varphi'(z) \odot \bar h; softmax с кросс-энтропией: zˉ=p−ey\bar z = p - e_y.
  • Обратный режим стоит небольшую константу, умноженную на один прямой проход (около 3 для MLP), независимо от числа параметров; прямому режиму нужен один проход на параметр.
  • Цена — память: все активации хранятся, пока их не использует обратный проход.
  • PyTorch записывает граф во время прямого прохода; loss.backward() заполняет .grad прибавлением, отсюда zero_grad().
  • Написанный вручную градиент проверяют центральными разностями в двойной точности, ε≈10−6\varepsilon \approx 10^{-6}, по относительной ошибке.

Код#

Разобранный пример вручную и через autograd, проверка градиента, которая ловит подложенную ошибку, шаг конечных разностей, прямой режим против обратного, два соглашения autograd и argmax без градиента:

import numpy as np, torch
import torch.autograd.forward_ad as fwAD

# разобранный пример: x = (1, 2), истинный класс 0 (первый), нулевые смещения
x = np.array([1.0, 2.0]); y = 0
W1 = np.array([[1.0, 0.0], [-1.0, 1.0], [0.5, -1.0]]); b1 = np.zeros(3)
W2 = np.array([[1.0, -1.0, 2.0], [0.5, 1.0, -1.0]]); b2 = np.zeros(2)

def forward(W1, b1, W2, b2):
    z1 = W1 @ x + b1; h = np.maximum(z1, 0); z2 = W2 @ h + b2
    m = z2.max(); logZ = m + np.log(np.exp(z2 - m).sum())
    return z1, h, z2, np.exp(z2 - logZ), logZ - z2[y]

def backward(W1, b1, W2, b2, mask=True):
    z1, h, z2, p, L = forward(W1, b1, W2, b2)
    dz2 = p - np.eye(2)[y]                       # softmax + кросс-энтропия
    dW2, db2, dh = np.outer(dz2, h), dz2, W2.T @ dz2
    dz1 = dh * (z1 > 0) if mask else dh          # mask=False подкладывает ошибку: ReLU пропущена
    return np.outer(dz1, x), dz1, dW2, db2

z1, h, z2, p, L = forward(W1, b1, W2, b2)
dW1, db1, dW2, db2 = backward(W1, b1, W2, b2)
print(p.round(3), round(L, 3))                   # [0.182 0.818] 1.701
print(dW2.round(3).tolist())                      # [[-0.818, -0.818, -0.0], [0.818, 0.818, 0.0]]
print((W2.T @ (p - np.eye(2)[y])).round(3), dW1.round(3).tolist())
# [-0.409  1.635 -2.453] [[-0.409, -0.818], [1.635, 3.27], [-0.0, -0.0]]

# те же градиенты от autograd
T = lambda a: torch.tensor(a, requires_grad=True)
tW1, tb1, tW2, tb2 = T(W1), T(b1), T(W2), T(b2)
loss = torch.nn.functional.cross_entropy((tW2 @ torch.relu(tW1 @ torch.tensor(x) + tb1) + tb2)[None], torch.tensor([y]))
loss.backward()
print(np.allclose(tW1.grad.numpy(), dW1), np.allclose(tW2.grad.numpy(), dW2))   # True True

# проверка градиента: центральные разности по каждому параметру, относительная ошибка против backprop
def check(mask):
    params = [W1.copy(), b1.copy(), W2.copy(), b2.copy()]
    grads = backward(*params, mask=mask)
    worst, eps = 0.0, 1e-6
    for P, G in zip(params, grads):
        for i in np.ndindex(P.shape):
            old = P[i]
            P[i] = old + eps; Lp = forward(*params)[-1]
            P[i] = old - eps; Lm = forward(*params)[-1]
            P[i] = old
            num = (Lp - Lm) / (2 * eps)
            worst = max(worst, abs(num - G[i]) / max(abs(num), abs(G[i]), 1e-8))
    return worst
print(f'{check(True):.1e} {check(False):.1e}')   # 3.6e-10 1.0e+00

# шаг конечной разности: ошибка усечения при больших шагах, ошибка округления при крошечных
f = lambda a: forward(np.array([[a, 0.0], [-1.0, 1.0], [0.5, -1.0]]), b1, W2, b2)[-1]
for eps in (1e-1, 1e-5, 1e-12):
    print(eps, f'{abs((f(1 + eps) - f(1 - eps)) / (2 * eps) - dW1[0, 0]):.1e}')
# 0.1 2.0e-05 | 1e-05 7.4e-12 | 1e-12 1.1e-04

# прямому режиму нужен один проход на параметр, обратному — один проход всего
flat = torch.cat([t.detach().flatten() for t in (tW1, tb1, tW2, tb2)])
def L_of(theta):
    A, a, B, b = theta[:6].view(3, 2), theta[6:9], theta[9:15].view(2, 3), theta[15:]
    return torch.nn.functional.cross_entropy((B @ torch.relu(A @ torch.tensor(x) + a) + b)[None], torch.tensor([y]))
def directional(v):                              # один проход прямого режима: производная вдоль v
    with fwAD.dual_level():
        return fwAD.unpack_dual(L_of(fwAD.make_dual(flat, v))).tangent
fwd = torch.stack([directional(torch.eye(17, dtype=flat.dtype)[i]) for i in range(17)])
print(len(fwd), torch.allclose(fwd, torch.cat([t.grad.flatten() for t in (tW1, tb1, tW2, tb2)])))   # 17 True

# детали autograd: у ReLU производная 0 в нуле, а .grad накапливается, пока его не обнулят
u = torch.tensor(0.0, requires_grad=True); torch.relu(u).backward(); print(u.grad.item())   # 0.0
w = torch.tensor(3.0, requires_grad=True)
for _ in range(2):
    (w ** 2).backward()
print(w.grad.item())                              # 12.0: два вызова backward складывают 6 + 6

# argmax возвращает целочисленный индекс без пути для autograd; softmax с температурой 0.5 — дифференцируемое приближение
s = torch.tensor([1.0, 3.0, 2.0], requires_grad=True)
k = s.argmax(); print(k.item(), k.dtype, k.requires_grad)   # 1 torch.int64 False
(torch.softmax(s / 0.5, 0) @ torch.arange(3.0)).backward()   # ожидаемый индекс под softmax, 1.101
print([round(g, 3) for g in s.grad.tolist()])                 # [-0.035, -0.176, 0.211]

Дальше#

Обратное распространение ошибки даёт точные градиенты для сети любой глубины. Точный не значит полезный: в глубокой стопке градиент — произведение одного якобиана на слой и может геометрически уменьшаться или расти, а сеть, достаточно гибкая, чтобы подогнаться под что угодно, подгоняется и под шум обучающей выборки. Следующая заметка разбирает, что делает глубокие сети обучаемыми и удерживает их от переобучения: инициализацию, сохраняющую масштаб сигналов, дропаут, затухание весов, расписания шага обучения, аугментацию данных и остаточные связи.

Литература#