Статья Автор: Деникина Н.В., Деникин А.В.

Первый слой и NumPy

Первый слой и 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.


Проверь себя

Почему backward выполняется в обратном порядке?

Печать