Фаза 03 · урок 06
Оптимизаторы
Цель урока: Вы вычислили градиенты. Вы знаете, что вес № 4 721 должен уменьшиться на 0,003, чтобы сократить функцию потерь. Но 0,003 — в каких единицах? На что это масштабировать? И нужно ли делать одинаковый шаг на шаге 1 и на шаге 1 000?
Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.
Содержание урока
- Цели обучения
- Проблема
- Концепция
- Стохастический градиентный спуск (SGD)
- Момент
- RMSProp
- Adam: момент + RMSProp
- AdamW: корректно реализованный weight decay
- Скорость обучения: самый важный гиперпараметр
- Сравнение оптимизаторов
- Когда выигрывает каждый оптимизатор
- Соберите это
- Шаг 1: обычный SGD
- Шаг 2: SGD с моментом
- Шаг 3: Adam
- Шаг 4: AdamW
- Шаг 5: сравнение обучения
- Используйте это
- Выпустите это
- Упражнения
- Ключевые термины
- Дополнительное чтение
Градиентный спуск говорит, в каком направлении двигаться. Но ничего не говорит о том, как далеко или как быстро. 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— промпт для выбора подходящего оптимизатора и скорости обучения для любой архитектуры
Упражнения
-
Реализуйте момент Нестерова, в котором вы вычисляете градиент в позиции «заглядывания вперёд» (
w - lr * beta * v), а не в текущей позиции. Сравните сходимость со стандартным моментом на наборе данных с кругом. -
Реализуйте расписание разогрева скорости обучения: линейный подъём от 0 до
max_lrна первых 10% шагов обучения, затем косинусное затухание до 0. Обучите Adam с разогревом и без него. Измерьте, сколько эпох требуется для достижения 90% точности на наборе данных с кругом. -
Отслеживайте эффективную скорость обучения каждого параметра во время обучения Adam. Эффективная скорость равна
lr * m_hat / (sqrt(v_hat) + eps). Постройте распределение эффективных скоростей после 10, 50 и 200 шагов. Все ли параметры обновляются с одинаковой скоростью? -
Реализуйте обрезание градиента (по глобальной норме). Установите максимальную норму градиента в 1,0. Обучите с обрезанием и без него при высокой скорости обучения (
lr=0.01для Adam). Подсчитайте, сколько запусков расходится (потери становятсяNaN) с обрезанием и без него на 10 случайных seed. -
Сравните 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 — Регуляризация