Первый слой и NumPy
Тот же backward, который мы посчитали руками, теперь запишем несколькими операциями над массивами
NumPy не меняет правило
Матричные операции только позволяют сразу выполнить несколько уже знакомых умножений. Сам алгоритм backward остаётся тем же.
На предыдущем шаге мы получили для первого слоя:
\[ x=[4,2,6] \]
и пришедший к линейной части градиент:
\[ \frac{\partial L}{\partial z_1} = [-46,23]. \]
Первый слой во время forward вычислял:
\[ z_1=xW_1+b_1. \]
Теперь нужно получить градиенты его параметров:
\[ \frac{\partial L}{\partial W_1} \qquad\text{и}\qquad \frac{\partial L}{\partial b_1}. \]
Сначала вспомним ручной расчёт
Первый вход:
\[ x_1=4. \]
Он связан с двумя скрытыми нейронами, поэтому получаем два градиента:
\[ 4\cdot[-46,23] = [-184,92]. \]
Второй вход:
\[ 2\cdot[-46,23] = [-92,46]. \]
Третий вход:
\[ 6\cdot[-46,23] = [-276,138]. \]
Собираем три строки:
\[ \frac{\partial L}{\partial W_1} = \begin{bmatrix} -184 & 92\\ -92 & 46\\ -276 & 138 \end{bmatrix}. \]
Для смещений локальная производная равна 1, поэтому:
\[ \frac{\partial L}{\partial b_1} = [-46,23]. \]
Что общего у шести умножений
Посмотрим на матрицу градиентов:
\[ \begin{bmatrix} 4\cdot(-46) & 4\cdot23\\ 2\cdot(-46) & 2\cdot23\\ 6\cdot(-46) & 6\cdot23 \end{bmatrix}. \]
Каждый элемент получен одинаково: берём один элемент входа \(x\) и один элемент градиента \(dz_1\), затем перемножаем их.
То есть нам нужны все пары:
\[ x_i\cdot dz_j. \]
Такая операция называется внешним произведением.
Внешнее произведение не вводит нового правила backprop.
Оно просто сразу выполняет все произведения «вход × градиент скрытого нейрона».
То, что руками потребовало шести умножений, NumPy запишет одной строкой.
Внешнее произведение
У нас есть два вектора:
\[ x=[4,2,6], \qquad dz_1=[-46,23]. \]
Их внешнее произведение:
\[ x^T dz_1 = \begin{bmatrix} 4\\ 2\\ 6 \end{bmatrix} \begin{bmatrix} -46 & 23 \end{bmatrix}. \]
Получаем:
\[ \begin{bmatrix} 4\cdot(-46) & 4\cdot23\\ 2\cdot(-46) & 2\cdot23\\ 6\cdot(-46) & 6\cdot23 \end{bmatrix} = \begin{bmatrix} -184 & 92\\ -92 & 46\\ -276 & 138 \end{bmatrix}. \]
В NumPy эта операция записывается:
dW1 = np.outer(x, dz1)
Проверяем форму
Вектор \(x\) содержит 3 элемента.
Вектор \(dz_1\) содержит 2 элемента.
Поэтому внешнее произведение имеет форму:
\[ 3\times2. \]
Именно такую форму имеет и матрица весов первого слоя:
\[ W_1: 3\times2. \]
Контроль формы:
\[ W_1:(3\times2) \qquad\Longrightarrow\qquad dW_1:(3\times2). \]
Градиент параметра должен иметь ту же форму, что и сам параметр.
Теперь запишем весь forward на NumPy
import numpy as np
x = np.array([4.0, 2.0, 6.0])
W1 = np.array([
[ 0.5, -0.25],
[ 1.0, 0.5 ],
[-0.5, 0.25]
])
b1 = np.array([1.0, 2.0])
W2 = np.array([2.0, -1.0])
b2 = 8.0
y = 20.0
z1 = x @ W1 + b1
a1 = np.maximum(0, z1)
prediction = a1 @ W2 + b2
loss = (prediction - y) ** 2
Здесь:
x @ W1 — матричное умножение;
np.maximum(0, z1) — ReLU сразу для двух элементов;
a1 @ W2 — вычисление линейной комбинации выходного слоя.
Результаты те же, что и при ручном вычислении:
\[ z_1=[2,3.5], \]
\[ a_1=[2,3.5], \]
\[ \hat y=8.5, \qquad L=132.25. \]
Теперь backward
Начинаем с функции потерь:
d_prediction = 2 * (prediction - y)
Это та же производная:
\[ \frac{\partial L}{\partial \hat y} = 2(\hat y-y) = -23. \]
Выходной слой
Градиенты его весов:
dW2 = a1 * d_prediction
db2 = d_prediction
NumPy выполняет сразу два знакомых умножения:
\[ [2,3.5]\cdot(-23) = [-46,-80.5]. \]
Поэтому:
\[ dW_2=[-46,-80.5], \qquad db_2=-23. \]
Здесь \(W_2\) в коде хранится как одномерный массив из двух элементов, поэтому и \(dW_2\) имеет такую же форму. В математической записи эти значения можно изображать столбцом.
Передаём градиент назад
Чтобы продолжить backward, нужен градиент по входам выходного слоя:
da1 = W2 * d_prediction
Получаем:
\[ da_1 = [2,-1]\cdot(-23) = [-46,23]. \]
ReLU в NumPy
Теперь нужно пройти назад через ReLU. Мы уже знаем правило:
если \(z>0\), множитель равен 1;
если \(z\leq0\), множитель равен 0.
В NumPy условие:
z1 > 0
для нашего массива:
\[ z_1=[2,3.5] \]
даёт:
[True, True]
При умножении логические значения работают как \(1\) и \(0\): True соответствует 1, False — 0.
Поэтому backward через ReLU можно записать:
dz1 = da1 * (z1 > 0)
В нашем случае:
\[ [-46,23]\cdot[1,1] = [-46,23]. \]
Маска ReLU
Выражение (z1 > 0) — это компактная запись уже знакомого правила:
положительное \(z\) → пропускаем градиент;
неположительное \(z\) → обнуляем градиент.
Первый слой
Теперь осталась операция, которую мы уже разобрали вручную:
dW1 = np.outer(x, dz1)
db1 = dz1
Получаем:
\[ dW_1 = \begin{bmatrix} -184 & 92\\ -92 & 46\\ -276 & 138 \end{bmatrix}, \]
\[ db_1=[-46,23]. \]
Весь backward в коде
# loss → prediction
d_prediction = 2 * (prediction - y)
# выходной слой
dW2 = a1 * d_prediction
db2 = d_prediction
# передаём градиент скрытому слою
da1 = W2 * d_prediction
# назад через ReLU
dz1 = da1 * (z1 > 0)
# первый слой
dW1 = np.outer(x, dz1)
db1 = dz1
Сравним forward и backward
Forward
z1 = x @ W1 + b1
a1 = ReLU(z1)
prediction = a1 @ W2 + b2
loss = ...
Backward
d_prediction = ...
dW2, db2, da1
dz1
dW1, db1
Порядок действительно обратный: последняя операция forward разбирается первой во время backward.
Главная мысль
NumPy не выполняет backpropagation автоматически. Здесь все производные по-прежнему вывели мы сами.
NumPy только помогает записать сразу несколько одинаковых вычислений: матричное умножение, поэлементное умножение, маску ReLU и внешнее произведение.
Следующий уровень автоматизации — позволить библиотеке самой построить вычислительный граф и выполнить этот backward.