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

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

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

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

step 0 · loss = 28.00

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

Эта статья о том, что именно происходит внутри каждого из этих шагов. Мы начнём с одного нейрона и дойдём до многослойных сетей — с интерактивными виджетами и кодом на 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)

где ff — функция активации. Каждый вход xix_i умножается на соответствующий вес wiw_i, произведения складываются вместе со смещением bb, а результат пропускается через функцию активации ff. Это всё, что делает нейрон: умножить, сложить, активировать.

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

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

Зачем нужны функции активации? Без них каждый нейрон — просто линейная функция (умножить и сложить), а составление линейных функций друг с другом всё равно даёт линейную функцию, сколько бы слоёв вы ни добавили. Функции активации вносят нелинейность, а она и позволяет нейросетям учить кривые, границы и сложные паттерны, а не только прямые линии. Более того, нейросеть с достаточным числом нейронов и нелинейными активациями может приблизить практически любую функцию — это известно как теорема об универсальной аппроксимации. Именно функция активации делает это возможным. Прекрасное визуальное объяснение того, как сети используют эти нелинейные преобразования, чтобы искривлять и складывать входное пространство, пока сложные паттерны не станут разделимыми, есть у Криса Олаха: Neural Networks, Manifolds, and Topology.

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

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

output=f(wx+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]. Скалярное произведение wx\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(wx+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 outputstack 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

Когда мы вычисляем W @ x, каждая строка скалярно перемножается со входом — все нейроны слоя посчитаны одной операцией. Вот почему нейросети используют линейную алгебру и почему GPU (созданные для матричной математики) делают обучение быстрым.

Каждый вес и каждое смещение — это параметр. Посчитайте их в сети выше: в слое 1 их 9, в слое 2 — 12, в выходном — 4, итого 25 параметров. Это крошечная сеть. У GPT-2 было около 1,5 миллиарда параметров; у GPT-3 — 175 миллиардов. Исследования законов масштабирования показали, что качество модели, как правило, предсказуемо улучшается с ростом размера модели, объёма обучающих данных и вычислений — поэтому индустрия и продолжает наращивать эти числа. Хотя есть признаки того, что простое добавление параметров упирается в убывающую отдачу, и фокус смещается к более качественным обучающим данным, более эффективным архитектурам и приёмам вроде рассуждений и цепочки мыслей, выжимающим больше из уже имеющихся размеров модели.

Чтобы почувствовать масштаб вычислений: обучение GPT-3 потребовало примерно 3 × 10²³ операций. При миллиарде операций в секунду это 10 миллионов лет на одном процессоре. Тысячи GPU, работающих параллельно, сжали это до недель.

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

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

Общий цикл обучения, повторяющийся на каждой итерации, выглядит так:

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

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

Обучение (этапы 2, 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)

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

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

Чтобы понять, как это работает, сведём всё к простейшей возможной сети: один нейрон, один вход, один вес, одно смещение. Как только вы увидите, как градиентный спуск работает с 2 параметрами (одним весом и одним смещением), переход к 25 или 25 миллиардам — вопрос лишь масштаба.

Вспомните, что вычисляет одиночный нейрон: f(wx+b)f(w \cdot x + b), где xx — вектор входов, а ww — вектор весов. Сведите к одному входу и проигнорируйте функцию активации — получите f(x)=wx+bf(x) = wx + b, ту самую базовую линейную функцию из школы. Это не упрощение; это буквально то, что нейрон делает до нелинейности через функции активации. Так что обучение одного нейрона подгонять прямую — самая чистая версия задачи.

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

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

Это противоположность тому, что вы делали в школе. На алгебре вам дают уравнение вроде y=3x+5y = 3x + 5 и просят «решить относительно xx» — параметры функции w=3w=3 и b=5b=5 известны, вы ищете вход xx. В нашем случае мы не решаем относительно xx. Мы решаем относительно параметров, задающих саму функцию: ww (наклон, насколько прямая крутая) и bb (сдвиг, где она пересекает ось y).

Но зачем искать функцию, если у нас уже есть данные? Затем, что весь смысл в том, чтобы обрабатывать входы, которых вы никогда не видели. Если кто-то спросит: «какой будет выход при x = 1.5?», а 1.5 нет в ваших данных, таблица не поможет. Но если вы обнаружили, что лежащая в основе функция это f(x) = 2x + 1, вы мгновенно ответите: 4. Это и есть обобщение — способность делать верные предсказания на новых, невиданных входах.

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

0.0
0.0

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

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

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

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

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

MSE=1ni=1n(yprediyactuali)2=(ypred1yactual1)2+(ypred2yactual2)2++(yprednyactualn)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

Возводим каждую ошибку в квадрат (чтобы отрицательные не гасились), затем усредняем. Результат — одно число: потери 2.0 означают, что наши предсказания промахиваются примерно на 1.4 в среднем (21.4\sqrt{2} \approx 1.4). Больше — хуже подгонка, ноль — идеально. Когда мы задали w=2w = 2 и b=1b = 1 в виджете выше, потери упали до нуля, потому что это ровно те параметры, что породили данные.

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

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

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

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

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

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

Backpropagation

-3.0
3.0

Gradient Descent

0.10

Computation (step 0)

После одного шага прямая всё ещё неверна, но менее неверна. Запустите ещё раз. И ещё. Каждый шаг уменьшает ошибку, градиенты сжимаются, и прямая подползает к цели. Разверните секцию «Computation», чтобы увидеть числа на каждом шаге. Пока оставьте скорость обучения по умолчанию (0.1) — что она делает и как её выбирать, разберём в следующем разделе.

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

Это происходит, хотя скорость обучения всё время остаётся постоянной. Помните, градиент это dw = 2 * mean(error * x) — он вычисляется из ошибок. По мере приближения модели к верному ответу ошибки уменьшаются, что уменьшает градиент, что уменьшает обновление lr * dw. Скорость обучения не меняется, но шаги становятся меньше автоматически, потому что исправлять уже почти нечего. Это саморегулирующееся поведение — визитная карточка градиентного спуска: он быстр там, где это важнее всего, а затем аккуратно доводит результат без вашего вмешательства.

Каждый клик по «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 — это прямой проход и потери. Прямой проход прогоняет каждый вход через y = wx + b, получая предсказание (по-входовые предсказания видны в панели Computation). Затем вычисление потерь измеряет, насколько мы неправы: разницу между каждым предсказанием и фактическим значением, возведённую в квадрат и усреднённую в одно число (MSE). Как работает MSE, мы разобрали в предыдущем разделе.

Шаги 3 и 4 — это то, где происходит обучение. Обратное распространение (шаг 3) вычисляет градиент для каждого параметра: в какую сторону его подтолкнуть и насколько. Градиентный спуск (шаг 4) применяет эти градиенты, вычитая из каждого параметра небольшую долю (скорость обучения). Коротко коснёмся скорости обучения — того самого lr из шага 4 — прежде чем нырять в обратное распространение, где живёт большая часть сложности.

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

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

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

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

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

  • Слишком мала (попробуйте 0.01) — каждый шаг крошечный. Модель ползёт к ответу, тратя сотни шагов. Безопасно, но мучительно медленно.
  • В самый раз (попробуйте 0.1) — модель делает уверенные шаги, сходясь за 20–30 шагов. Потери сначала быстро падают, затем идёт доводка.
  • Немного великовата (попробуйте 0.5) — модель проскакивает минимум, прыгая через него туда-обратно. Но каждый перелёт приземляется ближе ко дну, где градиенты меньше, так что колебания затухают и она всё же сходится — просто зигзагом и за большее число шагов, чем при lr = 0.1.
  • Слишком велика (попробуйте 1.0) — перелёты становятся резче. Каждый шаг приземляется далеко от минимума, где градиент всё ещё велик, что вызывает следующий большой шаг. Она может сойтись, но дёргано и расточительно.
  • Совсем чрезмерна (попробуйте 1.5) — перелёты настолько сильны, что каждый шаг приземляется хуже предыдущего. Градиент становится больше, а не меньше, так что следующий шаг ещё больше — обратная связь, уносящая потери вверх по спирали. Это называется расходимостью.

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

0.10

Формулы «правильной» скорости обучения нет. На практике большинство начинает с распространённого значения по умолчанию (0.001 или 0.0001), использует адаптивный оптимизатор вроде Adam, который автоматически подстраивает размер шага для каждого параметра исходя из поведения его градиентов, и применяет расписание скорости обучения, которое начинается с больших значений (крупные шаги, чтобы примерно подобраться) и уменьшает их по ходу обучения (мелкие шаги для доводки). Практически все используют Adam или его вариант вместо чистого градиентного спуска.

Всё это — надстройки над тем же 4-этапным циклом обучения. Основной алгоритм не меняется.

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

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

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, зафиксированном тем, что говорит ползунок. Меняя w, вы меняете это фиксированное значение, что меняет ошибки при каждом b и даёт совершенно другую кривую. То же самое в обратную сторону: потяните b — и меняет форму левая кривая. Лучшее значение для w зависит от того, где находится b, и наоборот — они связаны.

Формулы градиентов получаются взятием производной этих кривых — измерением того, насколько меняются потери при крошечном подталкивании каждого параметра. Так что 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.254=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

По мере уменьшения dx отношение сходится к точной производной.

В этом вся идея: производная — это то, к чему стремится df/dxdf/dx, когда dxdx стягивается к нулю: точная скорость изменения в одной точке.

Общая формула выглядит сложно, но это ровно то, что мы только что сделали:

f(x)=limdx0f(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, чтобы получить отношение. Часть limdx0\lim_{dx \to 0} просто означает «стягивайте dxdx к нулю» — ровно то, что вы делали ползунком, наблюдая, как отношение сходится к точному значению.

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

f(x+dx)=(x+dx)2=x2+2xdx+dx2f(x + dx) = (x + dx)^2 = x^2 + 2x \cdot dx + dx^2 f(x+dx)f(x)=2xdx+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

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

В этом весь смысл анализа: он избавляет от необходимости выбирать dxdx. Виджет показывает, почему точной формуле можно доверять: какой бы dxdx вы ни взяли, отношение стремится к 2x2x по мере его уменьшения. Так что мы пропускаем уменьшение и просто используем 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 содержится только в f1f_1. Нам нужно вычислить производную потерь по w, а loss (f3f_3) не принимает w — она принимает error. И error (f2f_2) тоже не принимает w — она принимает y_pred. Только y_pred (f1f_1) в конце концов принимает w.

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

wf1y_predf2errorf3error2w \xrightarrow{f_1} y\_pred \xrightarrow{f_2} error \xrightarrow{f_3} error^2

Распишем:

  • f1(w)=wx+bf_1(w) = w \cdot x + b — предсказание модели
  • f2(y_pred)=y_predyf_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=f1f2f3\frac{d(\text{loss})}{dw} = f'_1 \cdot f'_2 \cdot f'_3

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

f(x)=limdx0f(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 — изменение входа. Значит, всё вместе это dfdx\frac{df}{dx} — «изменение ff, делённое на изменение xx». Это другой способ записать f(x)f'(x). Выход сверху, вход снизу:

  • f1f'_1: выход y_predy\_pred, вход wwd(y_pred)dw\frac{d(y\_pred)}{dw}
  • f2f'_2: выход errorerror, вход y_predy\_predd(error)d(y_pred)\frac{d(\text{error})}{d(y\_pred)}
  • f3f'_3: выход error2error^2, вход errorerrord(error2)d(error)\frac{d(\text{error}^2)}{d(\text{error})}

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

d(loss)dw=f1f2f3=d(y_pred)dwd(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 изменится на 1, умноженную на то, насколько изменился y_pred. Затем 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 масштабируется на gg', а затем это изменение масштабируется ещё раз на ff'.

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

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

  • Чтобы вычислить f1=d(y_pred)dwf'_1 = \frac{d(y\_pred)}{dw}, пользуемся тем, что производная ax+bax + b равна aa. Поскольку y_pred=wx+by\_pred = w \cdot x + b, производная равна x.
  • Чтобы вычислить f2=d(error)d(y_pred)f'_2 = \frac{d(\text{error})}{d(y\_pred)}, пользуемся тем, что производная xcx - c равна 11. Поскольку error=y_predyerror = 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=f1f2f3=x1(2error)=2errorx\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

Это 2errorx2 \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

Цепное правило: f1f2f3=214=8f'_1 \cdot f'_2 \cdot f'_3 = 2 \cdot 1 \cdot 4 = 8. Это значит, что если подтолкнуть w на 1, квадрат ошибки в этой точке изменится на 8.

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

xxyyypred=3x+1y_{pred} = 3x + 1errorerror2errorx2 \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
0110200=02 \cdot 0 \cdot 0 = 0
1341211=22 \cdot 1 \cdot 1 = 2
2572222=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=1ni=1n2errorixi=21ni=1nerrorixi\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 цепочка та же, только f1f'_1 другая: поскольку y_pred=wx+by\_pred = w \cdot x + b, производная по b равна просто 1 (вместо x). Значит:

d(loss)db=f1f2f3=11(2error)=2error\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)=1ni=1n(wxi+byi)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). Получается парабола, и её низшая точка при w = 2, где потери падают до нуля. Потяните ползунок w и следите за расчётом ниже: для каждой из 5 точек нашей обучающей выборки (x = [-2, -1, 0, 1, 2]) он вычисляет предсказание, ошибку и квадрат ошибки, а затем усредняет их в одно значение потерь. Это и есть белая точка на кривой. (Диапазон от -5 до 5 произволен — просто достаточно широкий, чтобы показать форму и включить минимум. Можно было бы пройтись от -100 до 100, но кривая была бы слишком мелкой, чтобы разглядеть детали.)

То же самое можно сделать для 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 мы вычисляли потери во множестве значений по всему диапазону, чтобы нарисовать кривую целиком. Это помогает построить интуицию, но на практике так никто не делает. С 2 параметрами перебрать все комбинации тривиально. Но у настоящей нейросети миллионы параметров. Чтобы нарисовать ландшафт потерь, пришлось бы перебрать их все во всех комбинациях — недостижимо дорого. Поэтому нам и нужна производная: вместо того чтобы картировать всю кривую в поисках минимума, мы вычисляем наклон в одной точке и шагаем под гору. Мы никогда не видим общей картины. Мы просто щупаем землю под ногами.

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

Заметьте, что мы только что сделали: чтобы понять, как потери зависят от 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)

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

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

Заметьте кое-что интересное: когда вы тянете w, правый график меняет форму — и наоборот. Почему?

Левый график спрашивает: «для каждого возможного w какие потери?» — при b, зафиксированном тем, что говорит ползунок. Когда вы тянете w, вы просто выбираете, в какой точке этой кривой стоять. Сама кривая не меняется, потому что b не изменилось.

А правый график спрашивает: «для каждого возможного b какие потери?» — при фиксированном w. Когда вы тянете w, вы меняете то фиксированное w, которое используется для вычисления каждой точки правой кривой. Другое w означает другие ошибки при каждом b, а значит, совершенно другую кривую. (И наоборот: перетаскивание b меняет форму левого графика, но лишь двигает точку на правом.)

Вот почему мы вычисляем обе частные производные из одних и тех же ошибок до каких-либо обновлений. Лучшее направление для подталкивания w зависит от того, где сейчас b, и наоборот — так что сначала измеряем оба наклона, а потом двигаем оба параметра.

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

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

Два графика выше — на самом деле лишь двумерные срезы трёхмерной поверхности. С двумя параметрами можно визуализировать весь ландшафт потерь: w по одной оси, b по другой, а потери как высота. Хотя наша модель линейна (y = wx + b), функция потерь квадратична — форма чаши, — потому что MSE возводит ошибки в квадрат. Другие функции потерь (например, кросс-энтропия) дают ландшафты иной формы. Такая чаша называется выпуклым ландшафтом потерь: долина только одна, так что откуда бы вы ни стартовали, любой спуск ведёт на одно и то же дно. Попробуйте нажимать Step (both) из разных стартовых точек — вы всегда окажетесь при w ≈ 2, b ≈ 1. У глубоких сетей ландшафты сложнее, невыпуклые, со множеством долин, но тот же алгоритм градиентного спуска на практике всё равно работает удивительно хорошо.

-3.0
3.0

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

Жёлтая стрелка — это вектор градиента: он показывает направление следующего шага. Он объединяет dw и db в одно направление: «двигайся сюда, чтобы уменьшить потери быстрее всего». Нажимая Step (w) или Step (b), вы двигаетесь только вдоль одной компоненты этого вектора. Нажимая Step (both), вы следуете за стрелкой целиком.

Вы можете заметить, что стрелка направлена в основном вдоль оси w. Это потому, что градиенты не равны: в стартовой точке dw = -20, а db = 4. Компонента w в 5 раз больше, поэтому она и задаёт направление. Градиентный спуск движется не одинаково во все стороны; он движется пропорционально тому, насколько потери чувствительны к каждому параметру. Здесь потери гораздо круче меняются вдоль w, поэтому w исправляется первым.

Почему потери настолько чувствительнее к w, чем к b? Ответ в формулах градиентов. Сравните их: dw = 2 * mean(error * x) против db = 2 * mean(error). Обратите внимание на ключевую разницу: dw умножает каждую ошибку на соответствующее значение x, тогда как db использует только сами ошибки. Наши значения x лежат от -2 до 2, так что когда модель сильно ошибается (большие ошибки) и входы велики, произведение error * x становится огромным. Градиент смещения db усредняет лишь сами ошибки, без умножения на x, поэтому он естественным образом меньше.

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

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

Наши игрушечные потери — идеальная чаша по конкретной причине: модель y = wx + b линейна по своим параметрам, а возведение ошибок линейной модели в квадрат всегда даёт квадратичные потери, а всякая квадратичная функция — это одна выпуклая долина. Добавьте всего один скрытый слой с нелинейной активацией — и эта гарантия исчезает.

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

f(xi)=β0+k=1Kβkg ⁣(wk0+j=1pwkjxij),f(x_i) = \beta_0 + \sum_{k=1}^{K} \beta_k \, g\!\left(w_{k0} + \sum_{j=1}^{p} w_{kj} x_{ij}\right),

где каждый скрытый узел kk оборачивает свою взвешенную сумму в нелинейность gg. Потери 12i(yif(xi))2\frac{1}{2}\sum_i (y_i - f(x_i))^2 теперь глубоко составная функция весов, а составные функции изгибаются — у них растут бугры и впадины, а не одна чистая долина.

Вторая — симметрия. Скрытые узлы взаимозаменяемы: поменяйте узел 1 с узлом 2 (вместе с их весами) — и сеть вычислит ровно ту же функцию с ровно теми же потерями. При KK скрытых узлах таких перенумераций K!K!, так что у каждого решения есть толпа близнецов, разбросанных по ландшафту. Функция потерь со множеством одинаково хороших низших точек по определению не является одной чашей.

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

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

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

До сих пор мы вычисляли градиенты по всем обучающим данным сразу. Каждый раз, когда вы нажимали «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 точками это тривиально. Преимущество использования всех данных в том, что усреднённый градиент указывает в наилучшем возможном направлении: голосует каждая точка, так что ни один выброс не уведёт обновление в сторону. Но реальные датасеты содержат миллионы или миллиарды примеров. Считать градиент для каждого и усреднять всё это перед единственным шагом дорого. Представьте датасет из миллиарда примеров — придётся обработать весь миллиард, чтобы обновить w и b хотя бы один раз. Это один шаг. Потом всё заново для следующего. Каждый шаг даёт очень точный градиент, но между обновлениями вы ждёте целую вечность.

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

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

w=wlr1BiBlossiww = 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)

Каждый батч прогоняет полный 4-шаговый цикл (прямой проход → потери → обратное распространение → градиентный спуск), так что за эпоху делается 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 обновления параметров, но SGD делает это втрое дешевле по вычислениям. Обновления шумнее, но экономия на масштабе колоссальна: с миллиардом примеров и батчем в 1000 одна эпоха даёт миллион обновлений, обработав каждый пример лишь один раз. Полному батчу пришлось бы обрабатывать все миллиард примеров для каждого из этих обновлений. Виджет не может показать эту разницу в стоимости (оба — просто клик), но на реальном масштабе мини-батч приходит к ответу быстрее по времени на часах, несмотря на большее число шагов.

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

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

На практике распространены размеры батча 32, 64 или 256. Компромисс: меньшие батчи означают больше шагов за эпоху (шумнее, но каждый шаг быстрее); большие батчи — меньше шагов (плавнее, но больше вычислений на шаг). Шум от маленьких батчей может даже помогать: он мешает модели застревать в мелких локальных минимумах.

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

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

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

Цепочка от каждого параметра к потерям имела 3 звена (f1f2f3)(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

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

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

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

Градиент для v₁ (слой 2) активен по умолчанию — v₁ это вес на связи от h₁ к g₁. Его цепочка коротка: только эта связь, затем g₁→ŷ→потери. Обратите внимание, что подсвечивается только связь h₁→g₁, а не h₂→g₁ или h₃→g₁. Почему? Потому что при вычислении частной производной по v₁ остальные входы g₁ держатся постоянными: они умножаются на другие веса и в производную по v₁ не входят. Это тот же принцип, что и в модели с одним нейроном: производная w·x + b по w равна просто x — другой параметр b не появляется. Здесь так же: производная v₁·h₁ + v₂·h₂ + v₃·h₃ по v₁ равна просто h₁. Итак, градиент:

lossv1=h1локальная производнаяlossg1от выхода → к потерям\frac{\partial \text{loss}}{\partial v_1} = \underbrace{h_1}_{\text{локальная производная}} \cdot \underbrace{\frac{\partial \text{loss}}{\partial g_1}}_{\text{от выхода → к потерям}}

Другой способ взглянуть на эту формулу — раскрыть lossg1\frac{\partial \text{loss}}{\partial g_1}: поскольку потери это просто error², а ошибка приходит прямо с выхода, это сводится к 2 · error:

lossv1=h1локальная производнаяlossg1от выхода → к потерям=h1вход2errorот потерь\frac{\partial \text{loss}}{\partial v_1} = \underbrace{h_1}_{\text{локальная производная}} \cdot \underbrace{\frac{\partial \text{loss}}{\partial g_1}}_{\text{от выхода → к потерям}} = \underbrace{h_1}_{\text{вход}} \cdot \underbrace{2 \cdot error}_{\text{от потерь}}

Теперь нажмите Gradient for w₁ (layer 1) — w₁ это вес на связи x₁→h₁. Цепочка начинается так же (одна связь), но затем выход h₁ подаётся в каждый нейрон второго слоя — g₁, g₂ и g₃. Изменение w₁ прокатывается через них всех, прежде чем достичь выхода и потерь. Градиент обязан просуммировать вклады всех трёх путей:

lossw1=x1relu(z1)(v1lossg1+v2lossg2+v3lossg3)сумма по всем путям через слой 2\frac{\partial \text{loss}}{\partial w_1} = x_1 \cdot \text{relu}'(z_1) \cdot \underbrace{\left(v_1 \cdot \frac{\partial \text{loss}}{\partial g_1} + v_2 \cdot \frac{\partial \text{loss}}{\partial g_2} + v_3 \cdot \frac{\partial \text{loss}}{\partial g_3}\right)}_{\text{сумма по всем путям через слой 2}}

Раскрывая каждую lossgi\frac{\partial \text{loss}}{\partial g_i} до 2 · error (как и раньше — потери это просто error²):

=x1relu(z1)(v12error+v22error+v32error)= x_1 \cdot \text{relu}'(z_1) \cdot \left(v_1 \cdot 2 \cdot error + v_2 \cdot 2 \cdot error + v_3 \cdot 2 \cdot error\right)

Так что цепочка не только длиннее (больше слоёв пройти), но и шире (больше путей суммировать на каждом слое). Более глубокие сети означают более длинные цепочки; более широкие слои — больше путей на цепочку.

Это тот же принцип цепного правила, что мы использовали с f1f2f3f'_1 \cdot f'_2 \cdot f'_3. Выходной нейрон вычисляет ŷ = v₁·h₁ + v₂·h₂ + b. Когда мы спрашиваем «как изменится ŷ, если изменится h₁?», локальная производная равна v₁ — точно так же, как в модели с одним нейроном производная w·x + b по x была равна w. То есть v₁ появляется не как оптимизируемый параметр, а как локальная производная функции, связывающей h₁ с ŷ. Каждое звено цепочки вносит свою локальную производную, и мы их все перемножаем — просто одна из этих производных оказывается весом другого слоя. Это ключевое отличие от нашей однослойной модели: градиенты более ранних слоёв обязаны проходить через веса всех последующих слоёв.

Когда все градиенты получены, обновление такое же, как раньше — вычесть lr × градиент из каждого веса:

# Output layer (short chain)
W2 = W2 - lr * dW2          # 2 weights

# Hidden layer (long chain — gradients passed through W2)
W1 = W1 - lr * dW1          # 4 weights (2×2 matrix)

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

# Gradient for v₁ (output layer) — short chain
dv1 = h1 * 2 * error
#     ↑    ↑
#     │    └── from loss
#     └── local derivative: ∂ŷ/∂v₁ = h₁

# Gradient for w₁₁ (hidden layer) — longer chain
dw11 = x1 * relu_deriv(z1) * v1 * 2 * error
#      ↑    ↑                 ↑    ↑
#      │    │                 │    └── from loss
#      │    │                 └── passes through output layer weight
#      │    └── activation derivative
#      └── local derivative: ∂z₁/∂w₁₁ = x₁

У веса выходного слоя v₁ в цепочке 2 множителя. У веса скрытого слоя w₁₁ их 4 — ему нужно пройти через функцию активации и через вес выходного слоя v₁, чтобы добраться до потерь. Каждый добавленный слой удлиняет цепочку всех предыдущих слоёв ещё на одно умножение. При 100 слоях градиент первого слоя — произведение более чем 100 множителей. Что происходит, когда перемножаешь столько чисел?

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

Если локальная производная каждого слоя равна 0.5, то после 100 слоёв градиент равен 0.51000.5^{100} — число настолько малое, что фактически нулевое. Первые слои не получают полезного градиентного сигнала. Они не учатся. Это проблема затухающего градиента, десятилетиями мучившая глубокие сети.

Обратное так же плохо: если локальные производные больше 1, градиент взрывается, вырастая настолько, что обновления становятся дико нестабильными.

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

8
0.50

Вот почему глубокое обучение долго стояло на месте: с сигмоидными активациями (чьи производные всегда < 1) градиенты затухали в сетях глубже нескольких слоёв. Прорывы, которые это исправили:

  • Активация ReLU — её производная равна либо 0, либо 1, так что градиенты не сжимаются, проходя сквозь неё
  • Остаточные связи (skip connections) — дают градиенту короткий путь в обход слоёв, так что ему не нужно перемножаться через каждый из них
  • Батч-нормализация — держит значения, текущие через каждый слой, в удобном диапазоне, не давая производным стабильно уменьшаться или расти

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

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