Как учатся нейросети: обратное распространение и градиентный спуск

Нейросеть использует параметры, в том числе веса и смещения, чтобы преобразовывать входы в предсказания. Архитектура задаёт вычисления, а значения параметров определяют, как эти вычисления реагируют на вход. Обучение подстраивает значения по примерам, позволяя сети освоить такие задачи, как распознавание рукописных цифр или перевод текста.

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

Вот как это выглядит на простейшем возможном примере — модель учится подогнать прямую. Нажмите Step несколько раз и понаблюдайте:

step 0 · loss = 28.00

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

Мы разберём эти этапы, начав с одного нейрона и расширив вычисления на несколько слоёв. Виджеты показывают изменения чисел, а фрагменты Python связывают их с кодом.

Начните с примеров нейрона и функции потерь, затем перейдите к производным и цепному правилу. Если вы уже знаете дифференциальное исчисление, можно сразу перейти к цепному правилу через много слоёв.

Нейросеть как набор параметров

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

Виджет ниже показывает нейрон с 3 входами. Слева вы задаёте входные значения, справа — веса, смещение и функцию активации. Веса и смещение — обучаемые параметры; функцию активации мы выбираем при проектировании сети.

w₁x₁ + w₂x₂ + w₃x₃ + b → activation(sum) → output
0.5·1 + 0.5·0 + 0.5·1 + 0.0 = 1.0 → perceptron(1.0) = 1
inputs
1.0
0.0
1.0
neuron parameters
0.5
0.5
0.5
0.0

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

Запишем это уравнением:

output=f(w1x1+w2x2+w3x3+b)\text{output} = f(w_1 x_1 + w_2 x_2 + w_3 x_3 + b)

Здесь xix_i — вход, wiw_i — его вес, bb — смещение, а ff — функция активации.

Попробуйте несколько конфигураций, чтобы построить интуицию:

  • Вес определяет влияние входа. Задайте x₁ = 1.0, остальные входы — 0. Меняйте w₁ и наблюдайте выход. При w₁ = 0 этот вход не влияет на результат независимо от своего значения.
  • Знак веса меняет его вклад. Задайте x₁ = 1.0, w₁ = -1.5, остальное — 0. Взвешенная сумма отрицательна, поэтому перцептронная активация выдаёт 0. Теперь задайте x₁ = -1.0: произведение станет положительным. Отрицательный вес уменьшает сумму при положительном входе и увеличивает при отрицательном.
  • Смещение сдвигает границу решения. При нулевых входах сумму определяет только смещение. Для перцептронной активации положительное смещение даёт 1; отрицательное требует, чтобы взвешенная сумма входов его преодолела.
  • Активация задаёт форму выхода. Переключитесь с Perceptron (жёсткие 0/1) на Sigmoid: выход становится плавным значением между 0 и 1. ReLU пропускает положительные значения без изменений и обрезает отрицательные до нуля.

Зачем нужны функции активации? Без нелинейных активаций каждый слой вычисляет аффинную функцию: линейное преобразование плюс смещение. Композиция таких слоёв остаётся одной аффинной функцией при любой глубине. Нелинейные активации позволяют представлять более сложные зависимости. Например, они могут преобразовать данные так, чтобы классы, не разделяемые прямой, стали разделимыми. Эту геометрию иллюстрирует Chris Olah в Neural Networks, Manifolds, and Topology.

Веса и смещение — это параметры нейрона, значения, которые ему нужно выучить. Нейрон хранит по одному весу на вход — в нашем виджете это вектор из 3 значений. Веса определяют, насколько важен каждый вход, а смещение сдвигает результат вверх или вниз. Вместе эти параметры задают, на что нейрон реагирует. С разными весами и смещениями один и тот же нейрон обнаруживает совершенно разные паттерны в своих входах.

Поскольку умножение со сложением — это просто скалярное произведение, обычно это записывают в векторной форме:

output=f(w⋅x+b)\text{output} = f(\mathbf{w} \cdot \mathbf{x} + b)

Здесь x\mathbf{x} — вектор (список) всех входов, например [x1,x2,x3][x_1, x_2, x_3], а w\mathbf{w} — вектор всех весов, например [w1,w2,w3][w_1, w_2, w_3]. Скалярное произведение w⋅x\mathbf{w} \cdot \mathbf{x} перемножает каждую пару и складывает результаты: w1x1+w2x2+w3x3w_1 x_1 + w_2 x_2 + w_3 x_3.

На Python это может выглядеть так:

import numpy as np

class Neuron:
    def __init__(self, n_inputs):
        self.w = np.random.randn(n_inputs)   # w — weight vector, e.g. [w₁, w₂, w₃]
        self.b = 0.0                         # b — bias

    def forward(self, x):                    # x — input vector, e.g. [x₁, x₂, x₃]
        z = np.dot(self.w, x) + self.b       # w · x + b — dot product + bias
        return max(0, z)                     # f(z) — activation function (ReLU)

# Create a neuron with 3 inputs and run it
neuron = Neuron(3)
output = neuron.forward(np.array([1.0, 0.5, 0.7]))

Слой — это набор нейронов

Соберите много таких нейронов вместе — получите слой. Соедините несколько слоёв — получите нейросеть. Вот небольшая: 2 входа, два скрытых слоя по 3 нейрона и 1 выход. «Скрытыми» их называют потому, что снаружи видно только входы, которые заходят, и выход, который выходит; слои посередине внутренние для сети и снаружи невидимы:

x₁x₂yinputlayer 1layer 2output

Каждый нейрон хранит по одному весу на вход и смещение, а затем применяет активацию:

output=f(w1x1+w2x2+w3x3+b)\text{output} = f(w_1 x_1 + w_2 x_2 + w_3 x_3 + b)

Слой обычно хранит их все вместе в матрице весов (WW) — по одной строке весов на нейрон. Для изображённой выше сети layer1.W — это матрица 3×2: 3 нейрона, у каждого по 2 веса, поскольку каждый нейрон получает 2 входа:

layer1.W = [[ 0.4, -0.2],    ← neuron 0: weights for x₁, x₂
            [ 0.1,  0.7],    ← neuron 1: weights for x₁, x₂
            [-0.3,  0.5]]    ← neuron 2: weights for x₁, x₂

Для одного нейрона у нас было скалярное произведение одного вектора весов на вход: f(w⋅x+b)f(\mathbf{w} \cdot \mathbf{x} + b). Для целого слоя мы складываем все векторы весов в матрицу WW, а все смещения в вектор b\mathbf{b}, так что одна и та же операция применяется сразу ко всем нейронам:

output=f(Wx+b)\text{output} = f(W\mathbf{x} + \mathbf{b})

Когда мы вычисляем WxW\mathbf{x}, каждая строка WW скалярно перемножается со входом — это и есть взвешенная сумма одного нейрона. Матричное умножение делает их все за одну операцию.

single neuron (dot product)w₁w₂w₃w·x₁x₂x₃x=outone row → one output↓stack 3 neuronsfull layer (matrix multiply)0.4-0.2← n₀0.10.7← n₁-0.30.5← n₂W@x₁x₂x=w₀·xw₁·xw₂·x← neuron 0← neuron 1← neuron 2

Геометрический взгляд на это вычисление — в статье что на самом деле делает матрица весов. Она прослеживает четыре входные точки через скрытый слой и показывает, как матрица весов, смещение и нелинейная активация меняют их представление и делают классы разделимыми.

На Python это выглядит так:

import numpy as np

class Layer:
    def __init__(self, n_inputs, n_neurons):
        # W is a matrix where each ROW is one neuron's weights.
        # Shape: (n_neurons, n_inputs) — so W[0] is neuron 0's weights,
        # W[1] is neuron 1's weights, etc.
        self.W = np.random.randn(n_neurons, n_inputs)
        self.b = np.zeros(n_neurons)  # b — bias vector, one per neuron

    def forward(self, x):
        # W @ x multiplies every neuron's weight row by the input,
        # computing all dot products at once
        return np.maximum(0, self.W @ x + self.b)  # ReLU activation

# Build the network from the diagram above
layer1 = Layer(2, 3)   # 2 inputs  → 3 neurons  (6 weights + 3 biases = 9)
layer2 = Layer(3, 3)   # 3 inputs  → 3 neurons  (9 weights + 3 biases = 12)
output = Layer(3, 1)   # 3 inputs  → 1 neuron   (3 weights + 1 bias   = 4)

# Forward pass — each layer's output feeds into the next
x = np.array([0.5, 0.8])
h1 = layer1.forward(x)       # input → hidden layer 1
h2 = layer2.forward(h1)      # hidden layer 1 → hidden layer 2
y  = output.forward(h2)      # hidden layer 2 → output

Матричные операции позволяют библиотекам одновременно вычислять результаты для многих нейронов и примеров, что хорошо подходит для параллельного выполнения на GPU.

Каждый вес и каждое смещение — параметр. В первом слое их 9, во втором 12, в выходном 4: всего 25 параметров. При обучении нужно вычислить обновление для каждого.

Как сеть учится?

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

Каждая итерация обучения состоит из четырёх этапов:

ЭтапЧто он делает
1. Прямой проходПрогнать входы через каждый слой сети, умножая на веса и применяя активации, чтобы получить предсказание
2. Вычисление потерьСравнить предсказание с фактическим целевым значением через функцию потерь (например, MSE), которая сводит все ошибки к одному числу: насколько модель неправа?
3. Обратное распространениеПройдите по сети в обратном направлении, применяя цепное правило, чтобы вычислить, как потери меняются по каждому параметру
4. Градиентный спускВычтите из каждого параметра его градиент, умноженный на скорость обучения, чтобы сделать шаг в сторону уменьшения потерь

Обучение включает все четыре этапа, начиная с прямого прохода. При инференсе сеть использует текущие параметры для предсказаний; она не вычисляет обучающие градиенты и не обновляет веса.

Далее мы сосредоточимся на измерении ошибки, вычислении градиентов и обновлении параметров с их помощью.

У процесса два направления: данные идут вперёд для вычисления предсказания, затем градиенты идут назад, чтобы оптимизатор мог обновить веса:

  Forward pass: each layer receives activations, passes output →

              activations    activations    activations
  Input ─────────▶ Layer 1 ─────────▶ Layer 2 ─────────▶ Output ──▶ Loss

  Backward pass: each layer receives gradient signal, passes it ←

              gradients      gradients      gradients
  Input ◀───────── Layer 1 ◀───────── Layer 2 ◀───────── Output ◀── ∂L
              ↓ ∂L/∂W₁           ↓ ∂L/∂W₂           ↓ ∂L/∂W₃
           (own weight       (own weight         (own weight
            gradients)        gradients)          gradients)

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

Простой пример: подгонка прямой

Начнём с одного нейрона, одного входа, одного веса и одного смещения. Так мы сможем исследовать потери всего по двум параметрам, прежде чем перейти к нескольким слоям.

С одним входом и тождественной активацией, возвращающей вход без изменений, нейрон вычисляет y^=wx+b\hat y = wx + b. Здесь ww и xx — скаляры. Подгонка прямой позволяет изучить алгоритм обучения, не усложняя вычисления производной активации.

Предположим, кто-то даёт вам пять точек, говорит, что они получены из линейной функции f(x)=wx+bf(x) = wx + b, и просит найти w и b:

x (вход)-2-1012
y (выход)-3-1135

Данные задают входы и выходы. Нам нужно найти связывающие их параметры: ww, наклон прямой, и bb, её пересечение с осью y.

Изучив функцию, мы можем предсказывать результаты для входов, отсутствующих в обучающих данных. Если зависимость имеет вид f(x)=2x+1f(x) = 2x + 1, получаем f(1.5)=4f(1.5) = 4, хотя 1.5 нет в таблице. Точные предсказания на новых входах называют обобщением. Наши данные точно лежат на прямой; для реальных данных одно лишь хорошее совпадение на обучающем наборе не доказывает способность к обобщению.

Итак, нанесём эти точки на график (зелёные точки) и попробуем найти w и b вручную, двигая ползунки. Ориентируйтесь на значение потерь — тяните и смотрите, удастся ли свести потери к нулю. Вы обнаружите, что w=2w = 2 и b=1b = 1 дают ноль — это ровно те параметры, которые породили данные, то есть y=2x+1y = 2x + 1:

0.0
0.0

Ползунки показывают потери: одно число, обобщающее ошибки предсказаний по набору данных.

Пока вы двигаете ползунки, обратите внимание на красные пунктирные линии — это отдельные ошибки в каждой точке данных, показывающие, насколько предсказание отклоняется от фактического значения. Ошибку в каждой точке мы считаем как error=prediction−actualerror = prediction - actual.

Разные функции потерь обобщают эти ошибки по-разному:

  • Среднеквадратичная ошибка (MSE) — для регрессии (предсказания чисел). Возводит каждую ошибку в квадрат и усредняет.
  • Кросс-энтропия — для классификации (предсказания категорий). Измеряет, насколько предсказанные вероятности далеки от истинных меток.
  • Средняя абсолютная ошибка (MAE) — как MSE, но с модулями вместо квадратов, менее чувствительна к выбросам.

Мы подгоняем прямую, то есть решаем задачу регрессии — предсказываем непрерывное число. Поэтому используем MSE, среднюю квадратичную ошибку: возводим каждую ошибку в квадрат и усредняем. Квадраты неотрицательны, поэтому ошибки разных знаков не сокращаются, а большие ошибки получают больший вес:

MSE=1n∑i=1n(ypredi−yactuali)2=(ypred1−yactual1)2+(ypred2−yactual2)2+⋯+(ypredn−yactualn)2nMSE = \frac{1}{n} \sum_{i=1}^{n} (y_{\text{pred}_i} - y_{\text{actual}_i})^2 = \frac{(y_{\text{pred}_1} - y_{\text{actual}_1})^2 + (y_{\text{pred}_2} - y_{\text{actual}_2})^2 + \cdots + (y_{\text{pred}_n} - y_{\text{actual}_n})^2}{n}

Применим эту формулу к нашим данным. Скажем, ваша текущая догадка w=3w = 3, b=1b = 1, то есть f(x)=3x+1f(x) = 3x + 1. Для каждой из 5 точек данных считаем предсказание, ошибку (насколько промахнулись) и квадрат ошибки:

xxyactualy_{\text{actual}}ypred=3x+1y_{\text{pred}} = 3x + 1ошибкаошибка²
-2-3-5-24
-1-1-2-11
01100
13411
25724
среднее →потери = 2.0

MSE равна 2.0. Её корень, RMSE, равен 2≈1.41\sqrt{2} \approx 1.41 и измеряется в тех же единицах, что и предсказания. Это отличается от средней абсолютной ошибки, которая здесь равна (2+1+0+1+2)/5=1.2(2 + 1 + 0 + 1 + 2)/5 = 1.2. При w=2w = 2 и b=1b = 1 все ошибки и обе меры равны нулю.

Два параметра мы подобрали вручную, но при миллионах параметров такой подход непрактичен. Позже применим эти вычисления к распознаванию рукописных цифр сетью с более чем 100 000 параметров. Нужен систематический метод: обратное распространение вычисляет градиенты, а градиентный спуск использует их для небольших обновлений. Повторяя эти шаги, мы уменьшаем потери.

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

Цикл обучения шаг за шагом

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

  • Левый график: зелёные точки данных и синяя прямая предсказаний при текущих ww и bb. Красные линии показывают ошибки, квадраты которых усредняются в MSE.
  • Правый график: потери после каждого обновления. При скорости обучения по умолчанию они уменьшаются к нулю. Плато может означать сходимость; рост потерь — слишком большую скорость обучения.

Нажмите Step, чтобы выполнить одно обновление градиентного спуска, или Step x10, чтобы сделать десять сразу.

Backpropagation

-3.0
3.0

Gradient Descent

0.10

▶ Computation (step 0)

Оставьте скорость обучения 0.1 и нажимайте Step. Потери уменьшаются, а прямая приближается к данным. Раскройте Computation, чтобы увидеть предсказания, ошибки и градиенты.

В этом примере градиенты уменьшаются по мере приближения параметров к минимуму. Поэтому обновления lr * dw и lr * db уменьшаются даже при постоянном lr. Параметры сходятся к w = 2, b = 1. Большее значение скорости обучения может нарушить сходимость, как покажет следующий виджет.

Каждый клик по «Step» прогоняет одну полную итерацию обучения — четыре шага, соответствующие описанному ранее циклу обучения, который на Python выглядит так:

# 1. forward pass
y_pred = w * x + b

# 2. loss computation
error  = y_pred - y
loss   = np.mean(error ** 2)

# 3. backpropagation
dw = 2 * np.mean(error * x)
db = 2 * np.mean(error)

# 4. gradient descent
w = w - lr * dw
b = b - lr * db

Этапы 1 и 2 вычисляют предсказания и MSE. Этап 3 находит градиенты потерь. Этап 4 вычитает из каждого параметра его градиент, умноженный на скорость обучения lr. Сначала рассмотрим скорость обучения, затем выведем dw и db.

Выбор скорости обучения

В секции с вычислениями выше видно, что градиентный спуск обновляет параметры так:

w = w - lr * dw
b = b - lr * db

Градиент (dw, db) говорит нам, в какую сторону двигать каждый параметр и насколько относительно остальных. Но как далеко на самом деле шагать? Этим управляет скорость обучения (lr) — она масштабирует каждый градиент перед применением.

Для дифференцируемой функции потерь с ненулевым градиентом отрицательный градиент локально указывает направление уменьшения. Но конечный шаг может перескочить минимум и увеличить потери. В нашем примере:

  • Медленная сходимость (0.01): небольшие обновления требуют многих шагов до минимума.
  • Плавная сходимость (0.1): потери уменьшаются на каждом шаге.
  • Затухающие колебания (0.4): w пересекает оптимум на каждом шаге, но расстояние до него сокращается.
  • Незатухающие колебания (0.5): w чередуется между -3 и 7. Смещение сразу достигает 1, но после первого шага потери остаются равными 50.
  • Расходимость (1.0 или 1.5): w всё дальше перескакивает оптимум, и потери растут.

Попробуйте сами — измените скорость обучения и нажмите Step x10, чтобы увидеть эффект:

0.10

Устойчивый диапазон скорости обучения зависит от функции потерь. Для этих данных сходимость из произвольной начальной точки требует положительного значения меньше 0.5. Причину увидим, упростив формулу потерь.

В больших сетях скорость обучения подбирают под модель, данные и оптимизатор. Расписание скорости обучения меняет её по ходу обучения. Адаптивные оптимизаторы, например Adam, также используют историю градиентов для масштабирования обновлений. Это меняет правило обновления, сохраняя прямой проход, вычисление потерь и обратное распространение.

Вычисление обратного распространения

В секции с вычислениями выше видно, что обратное распространение считает градиенты так:

dw = 2 * np.mean(error * x)
db = 2 * np.mean(error)

В этих двух строках упаковано немало. Почему для dw мы умножаем error на x, а для db нет? Откуда берётся 2? При чём тут mean? Разберём по шагам.

Помните, потери вычисляются из предсказаний, а предсказания зависят от w и b. В конечном счёте потери — функция параметров: измените w или b, и потери изменятся. Потяните w или b в виджете ниже и понаблюдайте, как меняются потери — белая точка движется по кривой, показывая, где именно вы находитесь на ландшафте потерь:

-3.0
3.0

Передвиньте w: точка идёт по левой кривой, а правая сдвигается вертикально. Изменение b действует наоборот. Для этих центрированных данных минимум по w остаётся при 2, а по b — при 1. Здесь параметры можно оптимизировать независимо; ниже мы выведем причину.

Формулы градиентов получаются взятием производной этих кривых — измерением того, насколько меняются потери при крошечном подталкивании каждого параметра. Так что 2 * mean(error * x) — это просто производная функции потерь по w.

Чтобы понять, как мы приходим от функции потерь к 2 * mean(error * x), нужны три понятия, надстраивающиеся друг над другом:

  1. Производные — что значит измерить, как меняется функция
  2. Цепное правило — как считать производные, когда функции сцеплены друг с другом
  3. Частные производные и градиенты — как обращаться с несколькими параметрами сразу

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

Формула, которую мы только что видели, — 2 * mean(error * x) — специфична для MSE-потерь с линейной моделью. Другие функции потерь и архитектуры дают другие формулы градиентов, но лежащие в основе математические принципы всегда те же. Для нашей простой модели формулу можно вывести вручную; для глубоких сетей с миллионами параметров фреймворки вроде PyTorch и TensorFlow считают производные автоматически с помощью autograd (автоматического дифференцирования).

Производная: наклон в точке

Производная отвечает на один вопрос: если я чуть-чуть подтолкну этот вход, насколько изменится выход? Считайте её наклоном кривой в одной точке. Если вы стоите на холме, производная говорит вам, насколько круто под ногами — и в какую сторону идёт спуск.

Возьмём простую функцию вроде f(x) = x². Потяните точку x вдоль кривой и посмотрите, как меняются наклон и производная:

At x=1.0 derivative is 2.0
1.0
0.80

▼ Computation

Задайте x = 2 и dx = 0.5 в виджете. Жёлтая линия (dxdx) — это подталкивание входа, зелёная линия (dfdf) — насколько в ответ изменился выход. Секция Computation под графиком показывает, как они складываются в производную.

Сначала мы вычисляем функцию в нашей точке: f(2)=4f(2) = 4. Затем подталкиваем вход на dx и вычисляем снова: f(2.5)=6.25f(2.5) = 6.25. Разница говорит, насколько изменился выход: df=6.25−4=2.25df = 6.25 - 4 = 2.25. Деление на подталкивание даёт скорость изменения: df/dx=2.25/0.5=4.5df/dx = 2.25 / 0.5 = 4.5.

Это отношение (4.5) приблизительно равно производной в точке x = 2 — оно говорит о темпе: в этой точке выход меняется примерно в 4 раза быстрее входа. Не ровно 4, потому что dx = 0.5 — всё ещё крупное подталкивание. Теперь уменьшим dx — попробуйте стянуть его до 0.1:

  • f(2)=4f(2) = 4
  • f(2.1)=4.41f(2.1) = 4.41
  • df=0.41df = 0.41
  • df/dx=0.41/0.1=4.1df/dx = 0.41 / 0.1 = 4.1 — ближе к 4

Производная — предел этого отношения, когда изменение входа стремится к нулю:

f′(x)=lim⁡dx→0f(x+dx)−f(x)dxf'(x) = \lim_{dx \to 0} \frac{f(x + dx) - f(x)}{dx}

f(x+dx)−f(x)f(x + dx) - f(x) — это изменение выхода (dfdf). Разделите на dxdx, чтобы получить отношение. Часть lim⁡dx→0\lim_{dx \to 0} просто означает «стягивайте dxdx к нулю» — ровно то, что вы делали ползунком, наблюдая, как отношение сходится к точному значению.

Для f(x)=x2f(x) = x^2 можно всё расписать:

f(x+dx)=(x+dx)2=x2+2x⋅dx+dx2f(x + dx) = (x + dx)^2 = x^2 + 2x \cdot dx + dx^2 f(x+dx)−f(x)=2x⋅dx+dx2f(x + dx) - f(x) = 2x \cdot dx + dx^2 f(x+dx)−f(x)dx=2x+dx\frac{f(x + dx) - f(x)}{dx} = 2x + dx

При dx→0dx \to 0 это просто 2x2x. Значит, dfdx=2x\frac{df}{dx} = 2x.

Получив формулу производной, мы можем сразу вычислять 2x2x, а не оценивать наклон через всё меньшие изменения входа.

Ещё одно визуальное введение — The Essence of Calculus от 3Blue1Brown.

Цепное правило: производные составных функций

Мы умеем брать производную простой функции вроде f(x)=x2f(x) = x^2. Но что происходит, когда одна функция подаёт результат в другую? Это называется композицией функций — и именно это делает наше вычисление:

y_pred = w * x + b             # prediction
error  = y_pred - y            # how far off
loss   = np.mean(error ** 2)   # squared error, averaged

Для вывода градиента сначала рассмотрим одну точку. Её квадрат ошибки зависит от w через три операции: предсказание, вычитание цели и возведение в квадрат. Усредним по набору данных после дифференцирования этой цепочки.

Так что вычисление потерь из w — это не одна функция, а цепочка из трёх функций, каждая подаёт свой выход в следующую:

w→f1y_pred→f2error→f3error2w \xrightarrow{f_1} y\_pred \xrightarrow{f_2} error \xrightarrow{f_3} error^2

Распишем:

  • f1(w)=w⋅x+bf_1(w) = w \cdot x + b — предсказание модели
  • f2(y_pred)=y_pred−yf_2(y\_pred) = y\_pred - y — насколько мы промахнулись
  • f3(error)=error2f_3(error) = error^2 — квадрат ошибки (то, что мы хотим минимизировать)

Потери — это f3(f2(f1(w)))f_3(f_2(f_1(w))), три функции, вложенные друг в друга.

Мы умеем находить производную каждой отдельной функции, но как объединить их, чтобы получить производную всей цепочки? Ответ — цепное правило: перемножить локальные производные.

d(loss)dw=f1′⋅f2′⋅f3′\frac{d(\text{loss})}{dw} = f'_1 \cdot f'_2 \cdot f'_3

Вспомните формулу производной:

f′(x)=lim⁡dx→0f(x+dx)−f(x)dxf'(x) = \lim_{dx \to 0} \frac{f(x + dx) - f(x)}{dx}.

Отношение конечных изменений в пределе приближается к производной. Её записывают как df/dxdf/dx: выходная величина сверху, входная снизу. Для трёх функций нашей цепочки:

  • f1′f'_1: выход y_predy\_pred, вход ww → d(y_pred)dw\frac{d(y\_pred)}{dw}
  • f2′f'_2: выход errorerror, вход y_predy\_pred → d(error)d(y_pred)\frac{d(\text{error})}{d(y\_pred)}
  • f3′f'_3: выход error2error^2, вход errorerror → d(error2)d(error)\frac{d(\text{error}^2)}{d(\text{error})}

В этой записи цепное правило разворачивается так:

d(loss)dw=f1′⋅f2′⋅f3′=d(y_pred)dw⋅d(error)d(y_pred)⋅d(error2)d(error)\frac{d(\text{loss})}{dw} = f'_1 \cdot f'_2 \cdot f'_3 = \frac{d(y\_pred)}{dw} \cdot \frac{d(\text{error})}{d(y\_pred)} \cdot \frac{d(\text{error}^2)}{d(\text{error})}

Почему умножение? Выход одной функции становится входом следующей. Малое изменение w меняет y_pred в x раз сильнее. Затем error меняется на ту же величину. При достаточно малом изменении error² меняется приблизительно на 2·error, умноженное на изменение error. Каждое звено масштабирует изменение, а масштабы перемножаются.

Три способа объединить функции

Есть три фундаментальных способа объединить две функции f(x)f(x) и g(x)g(x), и у каждого своё правило того, как объединяются производные:

  1. Сложение: h(x)=f(x)+g(x)h(x) = f(x) + g(x) — производные складываются. Если ff меняется на 3, а gg на 5, сумма меняется на 8. Это правило суммы: h′(x)=f′(x)+g′(x)h'(x) = f'(x) + g'(x).

  2. Умножение: h(x)=f(x)⋅g(x)h(x) = f(x) \cdot g(x) — здесь сложнее, потому что меняться могут оба сомножителя. Это правило произведения: h′(x)=f′(x)⋅g(x)+f(x)⋅g′(x)h'(x) = f'(x) \cdot g(x) + f(x) \cdot g'(x). Нужно учесть изменение каждой функции при фиксированной другой.

  3. Композиция (вложение): h(x)=f(g(x))h(x) = f(g(x)) — выход gg подаётся в ff. Производные перемножаются. Это цепное правило: h′(x)=f′(g(x))⋅g′(x)h'(x) = f'(g(x)) \cdot g'(x). Подталкивание xx масштабируется на g′g', а затем это изменение масштабируется ещё раз на f′f'.

Наше вычисление потерь — это композиция, f3(f2(f1(w)))f_3(f_2(f_1(w))), поэтому мы перемножаем производные. Если бы функции складывались или перемножались, мы бы использовали соответствующее правило. На практике нейросети используют все три: сложение (члены смещения), умножение (веса на входы) и композицию (слои, подающие результат друг другу). Обратное распространение применяет то правило, которое соответствует каждой операции.

Хорошо, давайте вычислим совокупную производную нашей цепочки потерь. Мы показали, как найти производную x2x^2, и получили 2x2x. Тот же подход работает для более простых функций: производная ax+bax + b — это просто aa (постоянный множитель), а производная x−cx - c — это 11 (вычитание константы не меняет скорость). Это делает вычисление каждой отдельной производной элементарным:

  • Чтобы вычислить f1′=d(y_pred)dwf'_1 = \frac{d(y\_pred)}{dw}, пользуемся тем, что производная ax+bax + b равна aa. Поскольку y_pred=w⋅x+by\_pred = w \cdot x + b, производная равна x.
  • Чтобы вычислить f2′=d(error)d(y_pred)f'_2 = \frac{d(\text{error})}{d(y\_pred)}, пользуемся тем, что производная x−cx - c равна 11. Поскольку error=y_pred−yerror = y\_pred - y, производная равна 1.
  • Чтобы вычислить f3′=d(error2)d(error)f'_3 = \frac{d(\text{error}^2)}{d(\text{error})}, пользуемся тем, что производная x2x^2 равна 2x2x. Поскольку функция это error2error^2, производная равна 2 · error.

Что даёт нам:

d(loss)dw=f1′⋅f2′⋅f3′=x⋅1⋅(2⋅error)=2⋅error⋅x\frac{d(\text{loss})}{dw} = f'_1 \cdot f'_2 \cdot f'_3 = x \cdot 1 \cdot (2 \cdot error) = 2 \cdot error \cdot x

Это 2⋅error⋅x2 \cdot error \cdot x для одной точки данных.

Проследим на числах. При w = 3, b = 1 возьмём точку x = 2, y = 5:

w = 3
  ↓  × x = ×2
y_pred = 3·2 + 1 = 7
  ↓  × 1
error = 7 - 5 = 2
  ↓  × 2·error = ×4
error² = 4

Цепное правило даёт f1′⋅f2′⋅f3′=2⋅1⋅4=8f'_1 \cdot f'_2 \cdot f'_3 = 2 \cdot 1 \cdot 4 = 8. При малом изменении Δw\Delta w квадрат ошибки меняется приблизительно на 8Δw8\Delta w. Это локальное приближение: увеличение w с 3 до 4 меняет квадрат ошибки с 4 до 16, то есть на 12, а не на 8.

Но у нас 5 точек данных, а не одна. Поскольку MSE усредняет квадраты ошибок по всем точкам, производные тоже надо усреднить. Для каждой точки считаем 2⋅error⋅x2 \cdot error \cdot x:

xxyyypred=3x+1y_{pred} = 3x + 1errorerror2⋅error⋅x2 \cdot error \cdot x
-2-3-5-22⋅(−2)⋅(−2)=82 \cdot (-2) \cdot (-2) = 8
-1-1-2-12⋅(−1)⋅(−1)=22 \cdot (-1) \cdot (-1) = 2
01102⋅0⋅0=02 \cdot 0 \cdot 0 = 0
13412⋅1⋅1=22 \cdot 1 \cdot 1 = 2
25722⋅2⋅2=82 \cdot 2 \cdot 2 = 8

Усредняем: 8+2+0+2+85=4\frac{8 + 2 + 0 + 2 + 8}{5} = 4. Значит, dw = 4 — градиент говорит, что при увеличении w потери растут, значит, w надо уменьшать. (И действительно, истинное значение w = 2, что меньше нашей догадки 3.)

В математической записи это:

d(loss)dw=1n∑i=1n2⋅errori⋅xi=2⋅1n∑i=1nerrori⋅xi\frac{d(\text{loss})}{dw} = \frac{1}{n} \sum_{i=1}^{n} 2 \cdot error_i \cdot x_i = 2 \cdot \frac{1}{n} \sum_{i=1}^{n} error_i \cdot x_i

А на Python:

dw = 2 * np.mean(error * x)

Виджет ниже позволяет проследить эту цепочку для каждой точки данных. Нажимайте разные кнопки x=, чтобы увидеть, как меняются локальные производные — обратите внимание, что цепное правило даёт для каждой точки своё значение, потому что x и error разные:

Tracing d(loss)/dw for data point:
w
→
y_pred = w·x + b
→
error = y_pred - y
→
error²
-3.0
3.0

Для db цепочка та же, только f1′f'_1 другая: поскольку y_pred=w⋅x+by\_pred = w \cdot x + b, производная по b равна просто 1 (вместо x). Значит:

d(loss)db=f1′⋅f2′⋅f3′=1⋅1⋅(2⋅error)=2⋅error\frac{d(\text{loss})}{db} = f'_1 \cdot f'_2 \cdot f'_3 = 1 \cdot 1 \cdot (2 \cdot error) = 2 \cdot error

А в нашем коде на Python это выглядит так:

db = 2 * np.mean(error)

Вот почему цепное правило важно: всякий раз, когда потери вычисляются через последовательность операций (а так всегда и есть), оно нужно, чтобы проследить, как каждый параметр повлиял на итоговый результат. Для нашей модели с 2 параметрами цепочка состоит из 3 шагов. В глубокой сети их могут быть сотни — по одному на слой, — но принцип идентичен: каждый слой это ещё одна функция в композиции, ещё одна локальная производная в произведении.

Применяем производные к нашей функции потерь

Теперь, когда мы умеем вычислять отдельные производные и объединять их цепным правилом, применим это знание к нашей задаче. «Кривая», которую мы хотим минимизировать, — это наша функция потерь, формула, выбранная для измерения ошибки. Мы знаем её точно:

loss(w,b)=1n∑i=1n(w⋅xi+b−yi)2loss(w, b) = \frac{1}{n}\sum_{i=1}^{n}(w \cdot x_i + b - y_i)^2

loss = np.mean((w * x + b - y) ** 2)

Чего мы не знаем, так это при каких значениях w и b она минимальна. Производная помогает это выяснить: она говорит, если я увеличу w на крошечную величину, потери вырастут или упадут? И насколько быстро?

Данные (x и y) фиксированы — это наша обучающая выборка. Если ещё и b пока держать постоянным (скажем, b = 3), потери становятся функцией одного w, и её можно нарисовать простой кривой. Например, при w = 0:

y_pred = w * x + b                  # 0 * [-2,-1,0,1,2] + 3 = [3, 3, 3, 3, 3]
error  = y_pred - y                 # [3,3,3,3,3] - [-3,-1,1,3,5] = [6, 4, 2, 0, -2]
loss   = np.mean(error ** 2)        # mean([36, 16, 4, 0, 4]) = 12.0

Это даёт одну точку кривой: (w=0, loss=12). Проделайте так для каждого w от -5 до 5 (держа b = 3 фиксированным) — и получите полную картину: потери как функция одного w:

0.0(b = 3.0 fixed)

Ось x показывает w, ось y — потери. Минимум находится при w = 2, но потери там равны 4, поскольку b по-прежнему зафиксировано на 3. Каждое предсказание на 2 выше цели. Двигайте ползунок и смотрите предсказания, ошибки и их квадраты для каждой точки кривой.

То же самое можно сделать для b — на этот раз зафиксировав w = 2 и меняя b от -5 до 5:

3.0(w = 2.0 fixed)

Та же форма параболы, но теперь по оси x отложено b. Минимум при b = 1, где потери падают до нуля. Вместе w = 2 и b = 1 — ровно те параметры, что породили наши данные: y = 2x + 1.

Эти графики вычисляют потери при многих значениях параметров, чтобы показать форму поверхности. Перебор всех сочетаний становится непрактичным при росте числа параметров. Вместо этого градиентный спуск находит производные при текущих параметрах и выбирает следующий шаг.

Частные производные

Заметьте, что мы только что сделали: чтобы понять, как потери зависят от w, мы заморозили b и меняли только w. Чтобы понять зависимость от b, заморозили w и меняли только b. Именно это и есть частная производная — производная потерь по одному параметру при фиксированных остальных:

  • ∂loss/∂w — как меняются потери при подталкивании w (при замороженном b)
  • ∂loss/∂b — как меняются потери при подталкивании b (при замороженном w)

Каждая из двух кривых выше — это срез ландшафта потерь вдоль одного параметра. Наклон этой кривой в любой точке и есть частная производная:

dw = 2 * np.mean(error * x)        # ∂loss/∂w — how loss changes with w
db = 2 * np.mean(error)            # ∂loss/∂b — how loss changes with b

Виджет ниже — совмещённый вид двух кривых, которые мы видели выше, теперь показывающий частные производные в действии. Левый график меняет w (держа b фиксированным), правый график меняет b (держа w фиксированным). На каждом графике белая точка — где вы сейчас, синяя пунктирная линия — касательная (её наклон и есть частная производная), а зелёная стрелка показывает, в какую сторону двигаться, чтобы уменьшить потери.

-3.0(b = 3.0 fixed)
3.0(w = -3.0 fixed)

Тяните ползунки и смотрите, что происходит:

  • Далеко от минимума — кривая крутая, касательная резко наклонена, а производная — большое число. Здесь градиентный спуск делает большие шаги.
  • Рядом с минимумом — кривая выполаживается, касательная почти горизонтальна, а производная близка к нулю. Шаги становятся крошечными — модель занимается доводкой.
  • В минимуме — касательная идеально горизонтальна. Производная равна нулю. Идти некуда — вы прибыли.

Для наших пяти точек yi=2xi+1y_i = 2x_i + 1, среднее xx равно нулю, а среднее x2x^2 равно 2. Раскрывая выражение потерь, получаем:

L(w,b)=15∑i((w−2)xi+(b−1))2=2(w−2)2+(b−1)2L(w,b) = \frac{1}{5}\sum_i\big((w-2)x_i + (b-1)\big)^2 = 2(w-2)^2 + (b-1)^2

Смешанный член исчезает, потому что сумма входов равна нулю. Поэтому изменение w сдвигает кривую по b вертикально, не смещая её минимум, и наоборот. Производные упрощаются:

∂L∂w=4(w−2),∂L∂b=2(b−1)\frac{\partial L}{\partial w} = 4(w-2), \qquad \frac{\partial L}{\partial b} = 2(b-1)

После одного обновления градиентным спуском расстояния до оптимума становятся:

wnew−2=(1−4lr)(w−2),bnew−1=(1−2lr)(b−1)w_{\text{new}}-2 = (1-4lr)(w-2), \qquad b_{\text{new}}-1 = (1-2lr)(b-1)

Оба расстояния уменьшаются при 0<lr<0.50 < lr < 0.5. При lr = 0.5 первый множитель равен -1, поэтому w колеблется, не приближаясь к оптимуму. При lr = 1 множитель равен -3, и расстояние утраивается на каждом шаге.

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

От производной к градиенту

Градиент — это просто вектор из всех частных производных, собранных вместе: [dw, db]. Он указывает в направлении наискорейшего роста потерь. Поэтому мы двигаемся в противоположную сторону — вот почему правило обновления вычитает: w = w - lr * dw.

При двух параметрах потери можно показать поверхностью 3D: w на одной оси, b на другой, потери — высота. Наша поверхность выпуклая, с единственным минимумом при w = 2, b = 1. Предыдущие кривые — её сечения. Попробуйте Step (both) из разных точек: при фиксированной скорости обучения виджета 0.1 параметры приближаются к одному минимуму.

-3.0
3.0

Попробуйте нажимать Step (w) и Step (b) по отдельности — вы увидите, как точка движется вдоль одной оси за раз, рисуя лесенку вниз по чаше. Затем попробуйте Step (both) — так поступает настоящий градиентный спуск, обновляя оба параметра сразу. Поверхность можно вращать перетаскиванием, чтобы посмотреть на неё под разными углами.

Жёлтая стрелка показывает направление обновления: её компоненты вдоль осей параметров пропорциональны [-dw, -db], отрицательному градиенту. Высота следует за изменением потерь. Step (w) и Step (b) двигают вдоль одной оси, а Step (both) объединяет обновления.

В начальной точке w = -3, b = 3 градиенты равны dw = -20 и db = 4, поэтому обновление сильнее сдвигает w. Соотношение зависит и от кривизны потерь, и от текущих параметров. Например, при w = 2, b = 3 имеем dw = 0, и меняется только смещение.

Градиент измеряет локальную чувствительность, а не долю «вины» параметра в ошибке. Малый градиент бывает рядом с минимумом, но может возникнуть и на плоском участке с большими потерями.

За пределами чаши: почему глубокие сети невыпуклы

Наши квадратичные потери выпуклы, поскольку это сумма квадратов выражений, аффинных по параметрам. Для этих данных L=2(w−2)2+(b−1)2L = 2(w-2)^2 + (b-1)^2 имеет единственный минимум. Квадратичная функция вообще не обязана быть выпуклой: например, −w2-w^2 изгибается вниз.

В многослойной сети параметры соседних слоёв взаимодействуют через произведения и активации, поэтому потери обычно невыпуклы. Это видно даже на двух скалярных линейных слоях. Пусть их веса — aa и cc, вход равен 1, цель тоже 1:

y^=ca,L(a,c)=(ca−1)2\hat y = ca, \qquad L(a,c) = (ca-1)^2

Точки (a,c)=(1,1)(a,c)=(1,1) и (−1,−1)(-1,-1) обе дают нулевые потери. В середине между ними, (0,0)(0,0), потери равны 1. У выпуклой функции значение в середине не может превышать среднее значений на концах, значит, эти потери невыпуклы. Нелинейные активации добавляют структуру, но для невыпуклости по параметрам они не обязательны.

Такие поверхности могут содержать локальные минимумы, седловые точки и плоские области. Нулевой градиент не доказывает, что найдено лучшее решение, а градиентный спуск не гарантирует глобальный минимум. Подробнее — в главе об оптимизации учебника Deep Learning.

Цель обучения — параметры, полезные для предсказаний на новых данных. Помимо обучающих потерь мы следим за качеством на валидации. Ранняя остановка и регуляризация могут ограничить переобучение, но не превращают невыпуклые потери в выпуклые.

Стохастический градиентный спуск

До сих пор мы вычисляли градиенты по всем обучающим данным сразу. Каждый раз, когда вы нажимали «Step» в виджете выше, dw = 2 * mean(error * x) вычислял 2 * error * x для каждой из наших 5 точек по отдельности, а затем усреднял их в один градиент:

dw = 2 * mean(error * x)
   = 2 * mean([-24.00, -7.00, 0.00, -3.00, -16.00])
   = -20.00

Все 5 точек дают точный градиент обучающих потерь. Это не защищает обновление от выбросов: большая ошибка всё ещё может доминировать в градиенте MSE. На большом наборе обработка всех примеров перед каждым обновлением дорога. Мини-батчи позволяют обновлять параметры после меньшей части данных.

Перемешайте примеры и разбейте их на мини-батчи. На каждом выполните 4 этапа: прямой проход, вычисление потерь, обратное распространение и обновление параметров. Каждое обновление использует только примеры своего батча.

Это стохастический градиентный спуск (SGD). «Стохастический» означает случайный; здесь речь о перемешивании. Правило обновления то же, но усреднение идёт по мини-батчу, а не всему набору:

w=w−lr⋅1∣B∣∑i∈B∂lossi∂ww = w - lr \cdot \frac{1}{|B|} \sum_{i \in B} \frac{\partial \text{loss}_i}{\partial w}

BB — текущий мини-батч. Его градиент оценивает градиент полного набора при текущих параметрах. Отдельное обновление может увеличить полные потери. Поскольку параметры меняются между батчами, одна эпоха мини-батчевых обновлений не эквивалентна одному полнобатчевому обновлению.

Когда вы прошли через все примеры — это одна эпоха. Перемешайте снова и начинайте следующую. Вот почему в логах обучения встречается слово «эпоха»: каждая эпоха означает, что модель увидела каждый пример датасета ровно один раз.

Для наших 5 точек данных при размере батча 2 это выглядит так:

ЭпохаПеремешанные данныеБатч 1Батч 2Батч 3
1[0, 2, -1, -2, 1](0, 2)(-1, -2)(1)
2[2, -2, 1, 0, -1](2, -2)(1, 0)(-1)
3[-1, 1, -2, 2, 0](-1, 1)(-2, 2)(0)

Каждый батч выполняет полный цикл (прямой проход → потери → обратное распространение → градиентный спуск), поэтому за эпоху получается 3 обновления вместо 1. Каждый пример используется ровно один раз. Перемешивание меняет группировку примеров и уменьшает зависимость от их исходного порядка.

Виджет ниже прогоняет оба метода бок о бок на нашем датасете из 5 точек, чтобы их можно было сравнить напрямую. Оба стартуют с одних параметров (w = -3, b = 3) и используют одну скорость обучения. Каждый клик по «Step» делает по одному обновлению параметров для каждого метода. Синяя линия (полный батч) использует все 5 точек на каждом шаге. Оранжевая линия (мини-батч) использует только batch_size точек — оранжевые кружки показывают, какие именно, а полоса эпохи отслеживает продвижение по датасету.

2
0.10

Нажмите Step несколько раз и посмотрите на потери справа. Синяя полнобатчевая кривая плавно снижается при скорости обучения по умолчанию. Оранжевая мини-батчевая идёт зигзагами.

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

Проследите первые шаги: между 1 и 2 потери падают, между 2 и 3 растут — обновление помогает точкам своего батча, но мешает другим. Между 3 и 4 они снова падают. Каждый батч видит только часть данных, поэтому отдельные шаги могут увеличивать полные потери. При настройках по умолчанию общий тренд всё же ведёт к их снижению.

Полнобатчевый запуск по умолчанию приближается к решению за меньшее число обновлений, но каждое обрабатывает больше примеров. Сравним стоимость 3 обновлений на 5 точках:

  • Полный батч (3 шага): каждый шаг использует все 5 точек. Это 3 × 5 = 15 обработок точек. Каждая точка обрабатывается 3 раза.
  • SGD с батчем 2 (3 шага = 1 эпоха): каждый шаг использует только 2 точки (или 1 для последнего батча). Это 2 + 2 + 1 = 5 обработок точек. Каждая точка обрабатывается один раз.

Оба метода выполняют 3 обновления, но мини-батчи обрабатывают втрое меньше примеров. Это сравнение объёма работы, а не прогресса до заданных потерь: после обновлений результаты могут отличаться. Для больших наборов полезно сравнивать время до целевого качества на валидации; оно также зависит от оборудования и размера батча.

Можно также попробовать разные размеры батча, чтобы увидеть разницу в поведении:

  • batch size = 1 — один пример на обновление, поэтому обновления могут сильно различаться. 5 шагов — 1 эпоха. Это исходный вариант стохастического градиентного спуска.
  • batch size = 2 — меньше шума, 3 шага на эпоху (2 + 2 + 1 оставшийся пример).
  • batch size = 5 — весь набор, то есть полный батч. Обе линии совпадают. 1 шаг — 1 эпоха.

Размер батча влияет на шум градиента, расход памяти и эффективность оборудования. Меньшие батчи дают больше обновлений за проход по данным, большие усредняют больше примеров в обновлении. Шум иногда помогает оптимизации, но не гарантирует выхода из локального минимума или сходимости к глобальному.

Цепное правило через много слоёв

Помните, как обратное распространение использует цепное правило, перемножая локальные производные? У нас был простейший случай: один нейрон с одним весом w, одним смещением b и одним входом:

wbxnŷerrlossinput1 neuronoutput∂loss/∂w: x · 1 · 2·error ∂loss/∂b: 1 · 1 · 2·error

Цепочка от каждого параметра к потерям имела 3 звена (f1′⋅f2′⋅f3′)(f'_1 \cdot f'_2 \cdot f'_3), что на Python выглядело так:

y_pred = w * x + b                  # f1: prediction
error  = y_pred - y                 # f2: how far off
loss   = error ** 2                 # f3: squared error

dw = x * 1 * (2 * error)           # chain rule for w: f'1 · f'2 · f'3
db = 1 * 1 * (2 * error)           # chain rule for b: same chain, different f'1

Но в настоящих сетях в каждом слое много нейронов, у каждого свои веса. Вычисление градиента не меняется — мы по-прежнему считаем частную производную для каждого отдельного веса цепным правилом, как и раньше. Разница в масштабе. Цепочка становится длиннее для весов, сидящих дальше от потерь: больше слоёв пройти, больше производных перемножить. И становится шире — выход нейрона может подаваться во множество нейронов следующего слоя, так что градиент должен просуммировать вклады со всех этих путей.

На следующей диаграмме два скрытых слоя по 3 нейрона и скалярный выход. Переключайте кнопки, чтобы сравнить вес во втором скрытом слое с весом в первом:

w₁v₁x₁x₂h₁h₂h₃g₁g₂g₃ŷlossinputlayer 1layer 2output

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

z=W1x+b1,h=ReLU⁡(z),q=W2h+b2,g=ReLU⁡(q),y^=u⋅g+c,L=(y^−y)2.\begin{aligned} \mathbf{z} &= W_1\mathbf{x}+\mathbf{b}_1, & \mathbf{h} &= \operatorname{ReLU}(\mathbf{z}),\\ \mathbf{q} &= W_2\mathbf{h}+\mathbf{b}_2, & \mathbf{g} &= \operatorname{ReLU}(\mathbf{q}),\\ \hat y &= \mathbf{u}\cdot\mathbf{g}+c, & L &= (\hat y-y)^2. \end{aligned}

W1W_1 имеет форму 3×23\times2, W2W_2 — 3×33\times3, а вектор выходных весов u\mathbf{u} содержит 3 элемента. z\mathbf{z} и q\mathbf{q} — взвешенные суммы до активации; h\mathbf{h} и g\mathbf{g} — выходы после неё. Вес w1w_1 соединяет x1x_1 с h1h_1, а v1v_1 — h1h_1 с g1g_1. Веса от h1h_1 к g2g_2 и g3g_3 обозначим v2v_2 и v3v_3. Вместе эти три веса образуют первый столбец W2W_2.

Сначала активен Gradient for v₁ (layer 2). Этот вес влияет на потери через q1q_1, g1g_1 и y^\hat y. При error=y^−yerror=\hat y-y его производная равна:

∂L∂v1=h1⏟∂q1/∂v1⋅ReLU⁡′(q1)⏟∂g1/∂q1⋅u1⏟∂y^/∂g1⋅2 error⏟∂L/∂y^.\frac{\partial L}{\partial v_1} = \underbrace{h_1}_{\partial q_1/\partial v_1} \cdot \underbrace{\operatorname{ReLU}'(q_1)}_{\partial g_1/\partial q_1} \cdot \underbrace{u_1}_{\partial\hat y/\partial g_1} \cdot \underbrace{2\,error}_{\partial L/\partial\hat y}.

Остальные входы g1g_1 умножаются на другие веса, поэтому не входят в ∂q1/∂v1\partial q_1/\partial v_1. Они по-прежнему влияют на q1q_1 и предсказание, при которых вычисляется производная. Заметьте: ∂L/∂g1=u1 2 error\partial L/\partial g_1 = u_1\,2\,error. Выходное соединение вносит свой вес, хотя мы дифференцируем параметр более раннего слоя.

Выберите Gradient for w₁ (layer 1). Изменение w1w_1 меняет h1h_1, которое поступает во все три нейрона второго скрытого слоя. Нужно сложить вклады всех трёх путей:

∂L∂w1=x1 ReLU⁡′(z1)∑j=13vj ReLU⁡′(qj) uj 2 error.\frac{\partial L}{\partial w_1} = x_1\,\operatorname{ReLU}'(z_1) \sum_{j=1}^{3} v_j\,\operatorname{ReLU}'(q_j)\,u_j\,2\,error.

Каждый путь вносит произведение локальных производных. В точках ветвления обратное распространение суммирует вклады. Для двух выделенных весов получаем:

# All values come from the same forward pass; indices start at 0.
# ReLU's derivative is 1 for positive inputs, 0 for negative inputs.
# At exactly zero, we use 0 as the implementation convention.
d_output = 2 * error

# Gradients with respect to the second layer's pre-activations q.
d_q = (q > 0) * u * d_output            # shape: (3,)

# v₁ is W2[0, 0]: h₁ -> g₁.
dv1 = h[0] * d_q[0]

# Sum the three paths from h₁ through the second layer.
d_h1 = np.dot(W2[:, 0], d_q)

# w₁ is W1[0, 0]: x₁ -> h₁.
dw1 = x[0] * (z[0] > 0) * d_h1

Теми же операциями вычислим градиенты всех весов и смещений. Этот код завершает обратное распространение для одного примера, а затем обновляет параметры:

# Output layer: y_pred = u @ g + c
du = g * d_output                      # 3 weights
dc = d_output                         # scalar bias

# Second hidden layer: g = ReLU(W2 @ h + b2)
dW2 = np.outer(d_q, h)                 # shape: (3, 3)
db2 = d_q

# First hidden layer: h = ReLU(W1 @ x + b1)
d_z = (z > 0) * (W2.T @ d_q)
dW1 = np.outer(d_z, x)                 # shape: (3, 2)
db1 = d_z

# Apply updates only after every gradient has been computed.
u = u - lr * du
c = c - lr * dc
W2 = W2 - lr * dW2
b2 = b2 - lr * db2
W1 = W1 - lr * dW1
b1 = b1 - lr * db1

Для мини-батча с MSE усредните градиенты отдельных примеров до обновления. Обратное распространение повторно использует промежуточные градиенты, например d_q и d_z, поэтому не нужно отдельно прослеживать каждый путь для каждого параметра.

Добавление слоёв вводит больше весов и производных активаций вдоль каждого пути. Повторное умножение может сильно уменьшить или увеличить градиент к моменту его прихода в ранний слой.

Проблема затухающего градиента

В скалярной цепочке, если каждый слой вносит производную 0.5, прохождение 100 слоёв умножает входящий градиент на 0.5100≈7.9×10−310.5^{100} \approx 7.9\times10^{-31}. Ранний слой получает очень слабый сигнал для обновления. Это проблема затухающего градиента. Более широкие сети используют матричные произведения и суммы по путям, но повторное сжатие может давать тот же эффект.

Повторное усиление может, напротив, вызвать взрыв градиентов и сделать обновления неустойчивыми.

Виджет ниже позволяет увидеть это в действии. Опустите локальную производную ниже 1 и понаблюдайте, как градиент угасает до нуля, пока течёт назад по слоям. Затем попробуйте значения выше 1 и посмотрите, как он взрывается:

8
0.50

Производная сигмоиды не превышает 0.25, поэтому произведение многих таких производных может сильно уменьшить градиент. На его размер влияют и матрицы весов: одни производные активаций не определяют, затухнет полный градиент или взорвётся.

Несколько архитектурных решений помогают обучать более глубокие сети:

  • ReLU имеет производную 1 при положительном входе, поэтому на активной ветви не уменьшает градиент. При отрицательном входе производная равна 0 и блокирует градиент через нейрон. Матрицы весов по-прежнему могут усиливать или уменьшать сигнал.
  • Остаточные связи добавляют тождественный путь в обход блока слоёв. Для блока h+F(h)h + F(h) производная содержит тождественный член наряду с производной FF, предоставляя градиентам дополнительный маршрут. См. статью о ResNet.
  • Батч-нормализация нормализует промежуточные значения по статистикам мини-батча и обучает масштаб и смещение. Она может облегчить оптимизацию, но не делает все производные равными 1 и не гарантирует устойчивых градиентов. См. статью о batch normalization.

Инициализация, архитектура и настройки оптимизатора влияют на распространение градиентов. Эти методы решают разные части проблемы; ни один не гарантирует полезный масштаб каждого градиента.

Готовы применить всё это на практике? В следующей статье мы построим и обучим настоящую нейросеть на MNIST, написав прямой проход, обратное распространение и градиентный спуск с нуля на NumPy, а затем сравним с реализацией на Keras.