Оптимизаторы градиентного спуска
Чем плох простой градиентный спуск
Обычный (стохастический) градиентный спуск \theta \leftarrow \theta - \eta g страдает от трёх проблем:
- Овраги. Если по одному направлению поверхность крутая, а по другому пологая, шаг приходится выбирать под крутое — иначе спуск скачет со стенки на стенку. Вдоль пологого дна он тогда ползёт.
- Шум. Градиент по мини-батчу — шумная оценка настоящего. Путь получается дёрганым.
- Один шаг для всех. У разных параметров градиенты разного масштаба: веса редкого признака получают крошечные градиенты и почти не учатся.
Оптимизаторы — это способы «умнее» использовать градиенты.
Momentum: инерция
Представьте шар, катящийся по склону: он набирает скорость в направлении спуска, а мелкие неровности его почти не сбивают. Momentum добавляет «скорость» v — накопленный градиент:
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) заменяет сумму на скользящее среднее:
Деление на \sqrt{s} «нормирует» градиент: в крутом направлении (большие g) шаг уменьшается, в пологом — увеличивается. Овраг становится похож на круглую чашу.
Adam
Adam (Кингма и Ба, 2014) объединяет обе идеи: инерцию (первый момент m — среднее градиентов) и адаптивный шаг (второй момент v — среднее квадратов):
Стандартные значения: \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) применяет затухание отдельно, прямо к весам:
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
Что делает momentum (инерция) в градиентном спуске?
У параметра градиенты на протяжении обучения маленькие (редкий признак), у другого — большие. Как поведёт себя RMSprop?
Зачем в Adam нужна поправка на смещение \hat m = m / (1 - \beta_1^t)?
Чем AdamW отличается от Adam с L2-регуляризацией (weight_decay в градиенте)?
Momentum. Напишите класс Momentum(lr=0.01, beta=0.9) с методом step(params, grads):
params и grads — списки NumPy-массивов одинаковой формы. Храните скорость 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}"
import numpy as np
class Momentum:
def __init__(self, lr=0.01, beta=0.9):
self.lr, self.beta = lr, beta
self.v = None
def step(self, params, grads):
if self.v is None:
self.v = [np.zeros_like(p) for p in params]
for p, g, v in zip(params, grads, self.v):
v *= self.beta
v += g
p -= self.lr * v
Adam. Напишите класс Adam(lr=0.001, b1=0.9, b2=0.999, eps=1e-8) с методом step(params, grads)
(списки NumPy-массивов, обновление на месте). На шаге t (считается с 1):
Какие тесты проверяют код (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"
import numpy as np
class Adam:
def __init__(self, lr=0.001, b1=0.9, b2=0.999, eps=1e-8):
self.lr, self.b1, self.b2, self.eps = lr, b1, b2, eps
self.m = self.v = None
self.t = 0
def step(self, params, grads):
if self.m is None:
self.m = [np.zeros_like(p) for p in params]
self.v = [np.zeros_like(p) for p in params]
self.t += 1
for p, g, m, v in zip(params, grads, self.m, self.v):
m *= self.b1
m += (1 - self.b1) * g
v *= self.b2
v += (1 - self.b2) * g ** 2
m_hat = m / (1 - self.b1 ** self.t)
v_hat = v / (1 - self.b2 ** self.t)
p -= self.lr * m_hat / (np.sqrt(v_hat) + self.eps)
Войдите, чтобы отмечать пройденные уроки.