Оптимизаторы градиентного спуска

⏱ 45 мин · 🧩 заданий: 6

Чем плох простой градиентный спуск

Обычный (стохастический) градиентный спуск \theta \leftarrow \theta - \eta g страдает от трёх проблем:

  1. Овраги. Если по одному направлению поверхность крутая, а по другому пологая, шаг приходится выбирать под крутое — иначе спуск скачет со стенки на стенку. Вдоль пологого дна он тогда ползёт.
  2. Шум. Градиент по мини-батчу — шумная оценка настоящего. Путь получается дёрганым.
  3. Один шаг для всех. У разных параметров градиенты разного масштаба: веса редкого признака получают крошечные градиенты и почти не учатся.

Оптимизаторы — это способы «умнее» использовать градиенты.

Momentum: инерция

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

v \leftarrow \beta v + g, \qquad \theta \leftarrow \theta - \eta v, \qquad \beta \approx 0.9

v — экспоненциальное скользящее среднее градиентов (с точностью до множителя): недавние градиенты весят больше, старые затухают как \beta^k. Эффективно усредняются последние \frac{1}{1 - \beta} = 10 шагов. Что это даёт:

  • в направлении, где градиент постоянно один и тот же (вдоль дна оврага), скорость растёт — до 10 раз при \beta = 0.9;
  • в направлении, где градиент меняет знак (поперёк оврага), вклады гасят друг друга;
  • шум мини-батчей усредняется.

Нестеров (Nesterov momentum) — небольшое улучшение: градиент считается не в текущей точке, а там, куда нас и так унесёт инерция, — «заглядываем вперёд» и раньше тормозим перед минимумом.

Адаптивный шаг: AdaGrad и RMSprop

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

AdaGrad (2011) делит на корень из суммы квадратов всех прошлых градиентов: s \leftarrow s + g^2, \theta \leftarrow \theta - \eta\, g / (\sqrt{s} + \varepsilon). Редкие признаки получают большие шаги — отлично для разреженных данных. Но сумма только растёт, и шаг со временем падает до нуля.

RMSprop (Хинтон, 2012) заменяет сумму на скользящее среднее:

s \leftarrow \rho s + (1 - \rho)\,g^2, \qquad \theta \leftarrow \theta - \eta \frac{g}{\sqrt{s} + \varepsilon}

Деление на \sqrt{s} «нормирует» градиент: в крутом направлении (большие g) шаг уменьшается, в пологом — увеличивается. Овраг становится похож на круглую чашу.

Adam

Adam (Кингма и Ба, 2014) объединяет обе идеи: инерцию (первый момент m — среднее градиентов) и адаптивный шаг (второй момент v — среднее квадратов):

m \leftarrow \beta_1 m + (1 - \beta_1) g, \qquad v \leftarrow \beta_2 v + (1 - \beta_2) g^2
\hat m = \frac{m}{1 - \beta_1^t}, \quad \hat v = \frac{v}{1 - \beta_2^t}, \qquad \theta \leftarrow \theta - \eta \frac{\hat m}{\sqrt{\hat v} + \varepsilon}

Стандартные значения: \beta_1 = 0.9, \beta_2 = 0.999, \varepsilon = 10^{-8}, \eta = 10^{-3}.

Поправка на смещение. m и v начинаются с нуля, и в первые шаги они сильно занижены: после первого шага m = 0.1g. Деление на 1 - \beta^t это исправляет; через сотню-другую шагов поправка становится ≈ 1.

Шаг Adam по каждому параметру примерно равен \eta (отношение \hat m / \sqrt{\hat v} по модулю около 1) — это делает его малочувствительным к масштабу градиентов, а выбор \eta — простым. Adam — оптимизатор «по умолчанию» для большинства задач.

Сравнение на овраге

Запустим оптимизаторы на вытянутой «чаше» f(x, y) = \frac{1}{2}(x^2 + 50y^2) из одной точки, у каждого — примерно лучший для него шаг:

import numpy as np
import nanotorch as nt
import vis

def grad(p):
    return p * np.array([1.0, 50.0])

def run(make_opt, steps=60):
    p = nt.Parameter(np.array([-8.0, 1.5]))
    opt = make_opt([p])
    path = [p.data.copy()]
    for _ in range(steps):
        p.grad = grad(p.data)
        opt.step()
        path.append(p.data.copy())
    return np.array(path)

for name, make in [
    ("SGD, lr = 0.039", lambda ps: nt.SGD(ps, lr=0.039)),
    ("Momentum 0.9, lr = 0.02", lambda ps: nt.SGD(ps, lr=0.02, momentum=0.9)),
    ("RMSprop, lr = 0.1", lambda ps: nt.RMSprop(ps, lr=0.1)),
    ("Adam, lr = 0.5", lambda ps: nt.Adam(ps, lr=0.5)),
]:
    path = run(make)
    f = 0.5 * (path[:, 0] ** 2 + 50 * path[:, 1] ** 2)
    print(f"{name:24}: f через 60 шагов = {f[-1]:.2e}")
    vis.line(path[:, 0], path[:, 1], title=name, xlabel="x", ylabel="y", points=path[::2])
  • SGD: шаг ограничен крутым направлением y (при \eta > 2/50 он бы разошёлся), и по x движение медленное.
  • Momentum: колеблется поперёк оврага, но быстро разгоняется вдоль дна.
  • RMSprop и Adam: нормируют шаг по каждой координате и идут почти напрямую; у Adam заметна инерция.

Сравнение на нейросети

import numpy as np
import nanotorch as nt
import ml_data
import vis

X, y = ml_data.load_digits()
X = X / 16.0
curves = {}
for name, make in [
    ("SGD 0.05", lambda ps: nt.SGD(ps, lr=0.05)),
    ("Momentum 0.05", lambda ps: nt.SGD(ps, lr=0.05, momentum=0.9)),
    ("RMSprop 0.001", lambda ps: nt.RMSprop(ps, lr=0.001)),
    ("Adam 0.001", lambda ps: nt.Adam(ps, lr=0.001)),
]:
    nt.manual_seed(0)
    model = nt.Sequential(nt.Linear(64, 128), nt.ReLU(), nt.Linear(128, 64), nt.ReLU(), nt.Linear(64, 10))
    h = nt.fit(model, X, y, nt.cross_entropy, make(model.parameters()), epochs=10, batch_size=64, verbose=False)
    curves[name] = h["loss"]
    print(f"{name:14}: loss после 10 эпох = {h['loss'][-1]:.4f}")
vis.line(curves, title="Обучение MLP на цифрах разными оптимизаторами", xlabel="эпоха", ylabel="кросс-энтропия", logy=True)

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

AdamW и затухание весов

Регуляризация L2 (подробно — в уроке о переобучении) добавляет к потерям \frac{\lambda}{2}\|w\|^2, что равносильно добавке \lambda w к градиенту: веса на каждом шаге немного «притягиваются» к нулю. В SGD это то же самое, что умножать веса на (1 - \eta\lambda) — затухание весов (weight decay).

В Adam эти два способа не эквивалентны: добавка \lambda w к градиенту делится на \sqrt{\hat v}, и параметры с большими градиентами регуляризуются слабее. AdamW (Лощилов и Хуттер, 2017) применяет затухание отдельно, прямо к весам:

\theta \leftarrow \theta - \eta\left(\frac{\hat m}{\sqrt{\hat v} + \varepsilon} + \lambda\theta\right)

AdamW — стандарт для обучения трансформеров. В nanotorch: nt.AdamW(params, lr, weight_decay=0.01).

Как выбрать

Ситуация Что взять
Не знаю, с чего начать Adam, lr = 1e-3
Трансформеры, большие модели AdamW + разогрев + косинусное расписание
Свёрточные сети для картинок, борьба за последние доли процента SGD с momentum 0.9 и расписанием (часто обобщает чуть лучше)
Потери скачут или растут уменьшить lr в 3–10 раз
import inspect
import nanotorch as nt

print(inspect.getsource(nt.Adam.step))

Итог

  • Momentum: v \leftarrow \beta v + g — скользящее среднее градиентов; ускоряет вдоль оврага, гасит колебания и шум.
  • RMSprop: делит градиент на \sqrt{\text{среднее } g^2} — свой шаг для каждого параметра.
  • Adam = инерция + адаптивный шаг + поправка на смещение; по умолчанию \eta = 10^{-3}, \beta = (0.9, 0.999).
  • AdamW применяет затухание весов отдельно от адаптивного шага — стандарт для трансформеров.

Практика решено 0 из 6

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Один вариант ответа

Что делает momentum (инерция) в градиентном спуске?

Задание 2 Один вариант ответа

У параметра градиенты на протяжении обучения маленькие (редкий признак), у другого — большие. Как поведёт себя RMSprop?

Задание 3 Один вариант ответа

Зачем в Adam нужна поправка на смещение \hat m = m / (1 - \beta_1^t)?

Задание 4 Один вариант ответа

Чем AdamW отличается от Adam с L2-регуляризацией (weight_decay в градиенте)?

Задание 5 Программа на Python

Momentum. Напишите класс Momentum(lr=0.01, beta=0.9) с методом step(params, grads): params и grads — списки NumPy-массивов одинаковой формы. Храните скорость v для каждого параметра (нули при первом вызове) и обновляйте на месте:

v \leftarrow \beta v + g, \qquad \theta \leftarrow \theta - \eta v
Какие тесты проверяют код (2)
p = np.array([1.0]); opt = Momentum(lr=0.1, beta=0.5)
opt.step([p], [np.array([1.0])])
assert np.allclose(p, [0.9]), f"После первого шага: 1 − 0.1·1 = 0.9, получено {p}"
opt.step([p], [np.array([1.0])])
assert np.allclose(p, [0.75]), f"После второго: v = 0.5·1 + 1 = 1.5, p = 0.9 − 0.15 = 0.75, получено {p}"

A = np.array([1.0, 50.0])                 # овраг f = ½(x² + 50y²)
p = np.array([10.0, 1.0]); opt = Momentum(lr=0.03, beta=0.9)
for _ in range(200):
    opt.step([p], [A * p])
assert np.linalg.norm(p) < 1e-3, f"Momentum должен дойти до минимума оврага, получено {p}"

Задание 6 Программа на Python

Adam. Напишите класс Adam(lr=0.001, b1=0.9, b2=0.999, eps=1e-8) с методом step(params, grads) (списки NumPy-массивов, обновление на месте). На шаге t (считается с 1):

m \leftarrow \beta_1 m + (1 - \beta_1)g, \quad v \leftarrow \beta_2 v + (1 - \beta_2)g^2, \quad \hat m = \frac{m}{1 - \beta_1^t}, \quad \hat v = \frac{v}{1 - \beta_2^t}, \quad \theta \leftarrow \theta - \eta\frac{\hat m}{\sqrt{\hat v} + \varepsilon}
Какие тесты проверяют код (2)
p = np.array([1.0, -2.0]); opt = Adam(lr=0.1)
opt.step([p], [np.array([0.5, -100.0])])
assert np.allclose(p, [0.9, -1.9], atol=1e-6), f"Первый шаг Adam равен lr·sign(g) для каждого параметра, получено {p}"

import nanotorch as nt
rng = np.random.default_rng(0)
p_mine = rng.normal(size=(3, 2)); p_ref = nt.Parameter(p_mine.copy())
mine, ref = Adam(lr=0.05), nt.Adam([p_ref], lr=0.05)
for k in range(20):
    g = np.sin(p_mine * (k + 1))
    mine.step([p_mine], [g.copy()])
    p_ref.grad = np.sin(p_ref.data * (k + 1)); ref.step()
assert np.allclose(p_mine, p_ref.data), "Результат должен совпасть с nanotorch.Adam"

Войдите, чтобы отмечать пройденные уроки.