Фаза 03 · урок 06

Оптимизаторы

Цель урока: Вы вычислили градиенты. Вы знаете, что вес № 4 721 должен уменьшиться на 0,003, чтобы сократить функцию потерь. Но 0,003 — в каких единицах? На что это масштабировать? И нужно ли делать одинаковый шаг на шаге 1 и на шаге 1 000?

Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.

Курс
AI Engineering from Scratch
Фаза
Основы глубокого обучения
Чтение
18 мин.
Проверено
Содержание урока
  1. Цели обучения
  2. Проблема
  3. Концепция
  4. Стохастический градиентный спуск (SGD)
  5. Момент
  6. RMSProp
  7. Adam: момент + RMSProp
  8. AdamW: корректно реализованный weight decay
  9. Скорость обучения: самый важный гиперпараметр
  10. Сравнение оптимизаторов
  11. Когда выигрывает каждый оптимизатор
  12. Соберите это
  13. Шаг 1: обычный SGD
  14. Шаг 2: SGD с моментом
  15. Шаг 3: Adam
  16. Шаг 4: AdamW
  17. Шаг 5: сравнение обучения
  18. Используйте это
  19. Выпустите это
  20. Упражнения
  21. Ключевые термины
  22. Дополнительное чтение

Градиентный спуск говорит, в каком направлении двигаться. Но ничего не говорит о том, как далеко или как быстро. SGD — это компас. Adam — GPS с данными о пробках.

Тип: Сборка Языки: Python Предварительные требования: Урок 03.05 (Функции потерь) Время: ~75 минут

Цели обучения

  • Реализовать с нуля на Python оптимизаторы SGD, SGD с моментом, Adam и AdamW
  • Объяснить, как коррекция смещения Adam компенсирует оценки моментов, инициализированные нулём, на ранних шагах обучения
  • Показать, почему AdamW даёт лучшее обобщение, чем Adam с L2-регуляризацией, на одной и той же задаче
  • Выбрать подходящий оптимизатор и гиперпараметры по умолчанию для трансформеров, CNN, GAN и дообучения

Проблема

Вы вычислили градиенты. Вы знаете, что вес № 4 721 должен уменьшиться на 0,003, чтобы сократить функцию потерь. Но 0,003 — в каких единицах? На что это масштабировать? И нужно ли делать одинаковый шаг на шаге 1 и на шаге 1 000?

Обычный градиентный спуск применяет одну и ту же скорость обучения к каждому параметру на каждом шаге: w = w - lr * gradient. На практике это создаёт три проблемы, из-за которых обучать нейронные сети тяжело.

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

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

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

Adam решает все три проблемы. Он поддерживает два скользящих средних для каждого параметра — средний градиент (момент, устраняет колебания) и средний квадрат градиента (адаптивная скорость, работает с разными масштабами). В сочетании с коррекцией смещения на первых шагах он даёт один оптимизатор, который работает на 80% задач с гиперпараметрами по умолчанию. В этом уроке мы построим его с нуля, чтобы вы точно понимали, когда и почему он терпит неудачу на остальных 20%.

Концепция

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

Самый простой оптимизатор. Вычислите градиент на мини-пакете и сделайте шаг в противоположном направлении.

w = w - lr * gradient

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

Скорость обучения — единственный регулятор. Слишком высокая: функция потерь расходится. Слишком низкая: обучение длится вечность. Оптимальное значение зависит от архитектуры, данных, размера пакета и текущей стадии обучения. Для обычного SGD в современных сетях типичные значения лежат от 0,01 до 0,1. Но даже в одном запуске обучения идеальная скорость обучения меняется.

Момент

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

m_t = beta * m_{t-1} + gradient
w = w - lr * m_t

Beta (обычно 0,9) управляет тем, сколько истории сохранять. При beta = 0.9 момент примерно равен среднему последних 10 градиентов (1 / (1 - 0.9) = 10).

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

Реальные числа: один SGD на плохо обусловленном ландшафте потерь может потребовать 10 000 шагов. SGD с моментом (beta=0.9) обычно проходит ту же задачу за 3 000–5 000 шагов. Ускорение далеко не незначительное.

RMSProp

Первый метод адаптивной скорости обучения для каждого параметра, который действительно заработал. Предложен Хинтоном на лекции Coursera (формально никогда не публиковался).

s_t = beta * s_{t-1} + (1 - beta) * gradient^2
w = w - lr * gradient / (sqrt(s_t) + epsilon)

s_t отслеживает скользящее среднее квадратов градиентов. Параметры с постоянно большими градиентами делятся на большое число (меньшая эффективная скорость обучения). Параметры с малыми градиентами делятся на малое число (большая эффективная скорость обучения).

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

Epsilon (обычно 1e-8) предотвращает деление на ноль, когда параметр ещё не обновлялся.

Adam: момент + RMSProp

Adam объединяет обе идеи. Он поддерживает два экспоненциальных скользящих средних для каждого параметра:

m_t = beta1 * m_{t-1} + (1 - beta1) * gradient        (first moment: mean)
v_t = beta2 * v_{t-1} + (1 - beta2) * gradient^2       (second moment: variance)

Коррекция смещения — ключевая деталь, которую пропускает большинство объяснений. На шаге 1 m_1 = (1 - beta1) * gradient. При beta1 = 0.9 это 0.1 * gradient — в десять раз слишком мало. Скользящее среднее ещё не успело разогреться. Коррекция смещения компенсирует это:

m_hat = m_t / (1 - beta1^t)
v_hat = v_t / (1 - beta2^t)

На шаге 1 при beta1 = 0.9: m_hat = m_1 / (1 - 0.9) = m_1 / 0.1 — фактический градиент. На шаге 100 (1 - 0.9^100) приблизительно равно 1,0, поэтому коррекция исчезает. Коррекция смещения важна в первые ~10 шагов и несущественна после ~50.

Обновление:

w = w - lr * m_hat / (sqrt(v_hat) + epsilon)

Значения Adam по умолчанию: lr = 0.001, beta1 = 0.9, beta2 = 0.999, epsilon = 1e-8. Эти значения работают в 80% задач. Когда не работают, сначала меняйте lr. Затем beta2. Почти никогда не меняйте beta1 или epsilon.

AdamW: корректно реализованный weight decay

L2-регуляризация добавляет lambda * w^2 к функции потерь. В обычном SGD это эквивалентно затуханию весов (вычитанию lambda * w из веса на каждом шаге). В Adam эта эквивалентность нарушается.

Идея Loshchilov & Hutter: когда вы добавляете L2 к потерям, а затем Adam обрабатывает градиент, адаптивная скорость обучения масштабирует и член регуляризации. Параметры с большой дисперсией градиента получают меньше регуляризации. Параметры с малой дисперсией — больше. Это не то, что нужно: нужна равномерная регуляризация независимо от статистики градиента.

AdamW исправляет это, применяя затухание весов непосредственно к весам после обновления Adam:

w = w - lr * m_hat / (sqrt(v_hat) + epsilon) - lr * lambda * w

Член затухания весов (lr * lambda * w) не масштабируется адаптивным множителем Adam. Каждый параметр получает одинаковое пропорциональное сжатие.

Кажется, что это мелочь. Это не так. AdamW сходится к лучшим решениям, чем Adam + L2-регуляризация, практически на каждой задаче. Это оптимизатор по умолчанию в PyTorch для обучения трансформеров, диффузионных моделей и большинства современных архитектур. BERT, GPT, LLaMA, Stable Diffusion — все обучались с AdamW.

Скорость обучения: самый важный гиперпараметр

Диаграмма к уроку «Оптимизаторы»

Если настраивать один гиперпараметр, настраивайте скорость обучения. Изменение скорости обучения в 10 раз важнее любого архитектурного решения, которое вы примете. Распространённые значения по умолчанию:

  • SGD: lr = 0.01 до 0.1
  • Adam/AdamW: lr = 1e-4 до 3e-4
  • Дообучение предобученных моделей: lr = 1e-5 до 5e-5
  • Разогрев скорости обучения: линейный подъём на первых 1–10% шагов

Сравнение оптимизаторов

Диаграмма к уроку «Оптимизаторы»

Когда выигрывает каждый оптимизатор

Диаграмма к уроку «Оптимизаторы»

optimizer-trajectory

Соберите это

Шаг 1: обычный SGD

class SGD:
    def __init__(self, lr=0.01):
        self.lr = lr

    def step(self, params, grads):
        for i in range(len(params)):
            params[i] -= self.lr * grads[i]

Шаг 2: SGD с моментом

class SGDMomentum:
    def __init__(self, lr=0.01, beta=0.9):
        self.lr = lr
        self.beta = beta
        self.velocities = None

    def step(self, params, grads):
        if self.velocities is None:
            self.velocities = [0.0] * len(params)
        for i in range(len(params)):
            self.velocities[i] = self.beta * self.velocities[i] + grads[i]
            params[i] -= self.lr * self.velocities[i]

Шаг 3: Adam

import math

class Adam:
    def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8):
        self.lr = lr
        self.beta1 = beta1
        self.beta2 = beta2
        self.epsilon = epsilon
        self.m = None
        self.v = None
        self.t = 0

    def step(self, params, grads):
        if self.m is None:
            self.m = [0.0] * len(params)
            self.v = [0.0] * len(params)

        self.t += 1

        for i in range(len(params)):
            self.m[i] = self.beta1 * self.m[i] + (1 - self.beta1) * grads[i]
            self.v[i] = self.beta2 * self.v[i] + (1 - self.beta2) * grads[i] ** 2

            m_hat = self.m[i] / (1 - self.beta1 ** self.t)
            v_hat = self.v[i] / (1 - self.beta2 ** self.t)

            params[i] -= self.lr * m_hat / (math.sqrt(v_hat) + self.epsilon)

Шаг 4: AdamW

class AdamW:
    def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8, weight_decay=0.01):
        self.lr = lr
        self.beta1 = beta1
        self.beta2 = beta2
        self.epsilon = epsilon
        self.weight_decay = weight_decay
        self.m = None
        self.v = None
        self.t = 0

    def step(self, params, grads):
        if self.m is None:
            self.m = [0.0] * len(params)
            self.v = [0.0] * len(params)

        self.t += 1

        for i in range(len(params)):
            self.m[i] = self.beta1 * self.m[i] + (1 - self.beta1) * grads[i]
            self.v[i] = self.beta2 * self.v[i] + (1 - self.beta2) * grads[i] ** 2

            m_hat = self.m[i] / (1 - self.beta1 ** self.t)
            v_hat = self.v[i] / (1 - self.beta2 ** self.t)

            params[i] -= self.lr * m_hat / (math.sqrt(v_hat) + self.epsilon)
            params[i] -= self.lr * self.weight_decay * params[i]

Шаг 5: сравнение обучения

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

import random

def sigmoid(x):
    x = max(-500, min(500, x))
    return 1.0 / (1.0 + math.exp(-x))

def make_circle_data(n=200, seed=42):
    random.seed(seed)
    data = []
    for _ in range(n):
        x = random.uniform(-2, 2)
        y = random.uniform(-2, 2)
        label = 1.0 if x * x + y * y < 1.5 else 0.0
        data.append(([x, y], label))
    return data


class OptimizerTestNetwork:
    def __init__(self, optimizer, hidden_size=8):
        random.seed(0)
        self.hidden_size = hidden_size
        self.optimizer = optimizer

        self.w1 = [[random.gauss(0, 0.5) for _ in range(2)] for _ in range(hidden_size)]
        self.b1 = [0.0] * hidden_size
        self.w2 = [random.gauss(0, 0.5) for _ in range(hidden_size)]
        self.b2 = 0.0

    def get_params(self):
        params = []
        for row in self.w1:
            params.extend(row)
        params.extend(self.b1)
        params.extend(self.w2)
        params.append(self.b2)
        return params

    def set_params(self, params):
        idx = 0
        for i in range(self.hidden_size):
            for j in range(2):
                self.w1[i][j] = params[idx]
                idx += 1
        for i in range(self.hidden_size):
            self.b1[i] = params[idx]
            idx += 1
        for i in range(self.hidden_size):
            self.w2[i] = params[idx]
            idx += 1
        self.b2 = params[idx]

    def forward(self, x):
        self.x = x
        self.z1 = []
        self.h = []
        for i in range(self.hidden_size):
            z = self.w1[i][0] * x[0] + self.w1[i][1] * x[1] + self.b1[i]
            self.z1.append(z)
            self.h.append(max(0.0, z))

        self.z2 = sum(self.w2[i] * self.h[i] for i in range(self.hidden_size)) + self.b2
        self.out = sigmoid(self.z2)
        return self.out

    def compute_grads(self, target):
        eps = 1e-15
        p = max(eps, min(1 - eps, self.out))
        d_loss = -(target / p) + (1 - target) / (1 - p)
        d_sigmoid = self.out * (1 - self.out)
        d_out = d_loss * d_sigmoid

        grads = [0.0] * (self.hidden_size * 2 + self.hidden_size + self.hidden_size + 1)
        idx = 0
        for i in range(self.hidden_size):
            d_relu = 1.0 if self.z1[i] > 0 else 0.0
            d_h = d_out * self.w2[i] * d_relu
            grads[idx] = d_h * self.x[0]
            grads[idx + 1] = d_h * self.x[1]
            idx += 2

        for i in range(self.hidden_size):
            d_relu = 1.0 if self.z1[i] > 0 else 0.0
            grads[idx] = d_out * self.w2[i] * d_relu
            idx += 1

        for i in range(self.hidden_size):
            grads[idx] = d_out * self.h[i]
            idx += 1

        grads[idx] = d_out
        return grads

    def train(self, data, epochs=300):
        losses = []
        for epoch in range(epochs):
            total_loss = 0.0
            correct = 0
            for x, y in data:
                pred = self.forward(x)
                grads = self.compute_grads(y)
                params = self.get_params()
                self.optimizer.step(params, grads)
                self.set_params(params)

                eps = 1e-15
                p = max(eps, min(1 - eps, pred))
                total_loss += -(y * math.log(p) + (1 - y) * math.log(1 - p))
                if (pred >= 0.5) == (y >= 0.5):
                    correct += 1
            avg_loss = total_loss / len(data)
            accuracy = correct / len(data) * 100
            losses.append((avg_loss, accuracy))
            if epoch % 75 == 0 or epoch == epochs - 1:
                print(f"    Epoch {epoch:3d}: loss={avg_loss:.4f}, accuracy={accuracy:.1f}%")
        return losses

Используйте это

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

import torch
import torch.optim as optim

model = torch.nn.Sequential(
    torch.nn.Linear(784, 256),
    torch.nn.ReLU(),
    torch.nn.Linear(256, 10),
)

optimizer = optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)

scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100)

for epoch in range(100):
    optimizer.zero_grad()
    output = model(torch.randn(32, 784))
    loss = torch.nn.functional.cross_entropy(output, torch.randint(0, 10, (32,)))
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    optimizer.step()
    scheduler.step()

Шаблон всегда один: zero_grad, прямой проход, функция потерь, обратный проход, (clip), step, (schedule). Запомните этот порядок. Ошибка в нём (например, вызов scheduler.step() перед optimizer.step()) — частый источник трудноуловимых багов.

Для CNN многие практики по-прежнему предпочитают SGD + момент (lr=0.1, momentum=0.9, weight_decay=1e-4) с пошаговым или косинусным расписанием. SGD находит более плоские минимумы, которые часто лучше обобщаются. Для трансформеров и LLM универсальное значение по умолчанию — AdamW с разогревом + косинусным затуханием. Не спорьте с консенсусом без измеримой причины.

Выпустите это

Этот урок создаёт:

  • outputs/prompt-optimizer-selector.md — промпт для выбора подходящего оптимизатора и скорости обучения для любой архитектуры

Упражнения

  1. Реализуйте момент Нестерова, в котором вы вычисляете градиент в позиции «заглядывания вперёд» (w - lr * beta * v), а не в текущей позиции. Сравните сходимость со стандартным моментом на наборе данных с кругом.

  2. Реализуйте расписание разогрева скорости обучения: линейный подъём от 0 до max_lr на первых 10% шагов обучения, затем косинусное затухание до 0. Обучите Adam с разогревом и без него. Измерьте, сколько эпох требуется для достижения 90% точности на наборе данных с кругом.

  3. Отслеживайте эффективную скорость обучения каждого параметра во время обучения Adam. Эффективная скорость равна lr * m_hat / (sqrt(v_hat) + eps). Постройте распределение эффективных скоростей после 10, 50 и 200 шагов. Все ли параметры обновляются с одинаковой скоростью?

  4. Реализуйте обрезание градиента (по глобальной норме). Установите максимальную норму градиента в 1,0. Обучите с обрезанием и без него при высокой скорости обучения (lr=0.01 для Adam). Подсчитайте, сколько запусков расходится (потери становятся NaN) с обрезанием и без него на 10 случайных seed.

  5. Сравните Adam и AdamW в сети с большими весами. Инициализируйте все веса случайными значениями из [-5, 5] (намного больше обычного). Обучайте 200 эпох с weight_decay=0.1. Постройте норму L2 весов в ходе обучения для обоих оптимизаторов. AdamW должен показать более быстрое сжатие весов.

Ключевые термины

Термин Как обычно говорят Что это действительно означает
Скорость обучения «Размер шага» Скалярный множитель обновления по градиенту; самый влиятельный гиперпараметр при обучении
SGD «Базовый градиентный спуск» Стохастический градиентный спуск: обновляет веса вычитанием lr * gradient, вычисленного на мини-пакете
Момент «Аналогия с катящимся мячом» Экспоненциальное скользящее среднее прошлых градиентов; подавляет колебания и ускоряет устойчивые направления
RMSProp «Адаптивная скорость обучения» Делит градиент каждого параметра на скользящее RMS его недавних градиентов; выравнивает скорости обучения
Adam «Оптимизатор по умолчанию» Объединяет момент (первый момент) и RMSProp (второй момент) с коррекцией смещения на начальных шагах
AdamW «Adam, сделанный правильно» Adam с отвязанным затуханием весов; применяет регуляризацию непосредственно к весам, а не через градиент
Коррекция смещения «Разогрев скользящих средних» Деление на (1 - beta^t) для компенсации нулевой инициализации оценок моментов Adam
Затухание весов «Сжать веса» Вычитание доли значения веса на каждом шаге; регуляризатор, штрафующий большие веса
Расписание скорости обучения «Изменение lr со временем» Функция, корректирующая скорость обучения во время обучения; разогрев + косинусное затухание — современное значение по умолчанию
Обрезание градиента «Ограничение нормы градиента» Масштабирование вектора градиента вниз, когда его норма превышает порог; предотвращает взрывные обновления градиента

Дополнительное чтение

  • Kingma & Ba, «Adam: A Method for Stochastic Optimization» (2014) — исходная статья об Adam с анализом сходимости и выводом коррекции смещения
  • Loshchilov & Hutter, «Decoupled Weight Decay Regularization» (2017) — доказали, что L2-регуляризация и затухание весов в Adam неэквивалентны, и предложили AdamW
  • Smith, «Cyclical Learning Rates for Training Neural Networks» (2017) — представил тест диапазона LR и циклические расписания, избавляющие от необходимости настраивать фиксированную скорость обучения
  • Ruder, «An Overview of Gradient Descent Optimization Algorithms» (2016) — лучший единый обзор вариантов оптимизаторов с понятными сравнениями и интуицией

Источник: Optimizers 03.05 — Функции потерь · Фаза 3 — Основы глубокого обучения · Полный каталог · 03.07 — Регуляризация