Фаза 03 · урок 06

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

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

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

Курс
AI Engineering с нуля
Фаза
Основы глубокого обучения
Чтение
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 — Регуляризация