Градиентный спуск
Идея
Мы стоим на склоне в тумане и хотим спуститься в долину. Видно только землю под ногами. Разумная стратегия: определить, куда склон уходит вниз круче всего, сделать шаг туда и повторить. Это и есть градиентный спуск:
- \nabla_\theta L — градиент: направление быстрейшего роста потерь, поэтому идём со знаком минус;
- \eta (эта) — шаг обучения (learning rate), главный гиперпараметр.
Алгоритм:
- Инициализировать параметры (нулями или случайно).
- Посчитать потери и градиент.
- Сделать шаг \theta \leftarrow \theta - \eta\nabla L.
- Повторять, пока потери уменьшаются.
Шаг обучения
Поиграйте с виджетом: меняйте функцию, шаг и стартовую точку.
- Маленький шаг — спуск медленный, нужно очень много итераций.
- Подходящий шаг — быстро скатываемся на дно.
- Слишком большой — перепрыгиваем минимум, начинаем колебаться, а при ещё большем шаге колебания растут и спуск расходится.
- На функции с двумя ямами (
x⁴ − 3x² + x) результат зависит от старта: можно застрять в локальном минимуме.
import numpy as np
import vis
f = lambda x: x ** 2
df = lambda x: 2 * x
curves = {}
for lr in (0.02, 0.3, 0.9, 1.02):
x, hist = 3.0, []
for _ in range(30):
hist.append(f(x))
x -= lr * df(x)
curves[f"lr = {lr}"] = hist
vis.line(curves, title="Потери при разных шагах обучения (f = x²)", xlabel="шаг", ylabel="f(x)", logy=True)
Почему так? Для f(x) = x^2 шаг даёт x \leftarrow x - \eta \cdot 2x = (1 - 2\eta)\,x. Если |1 - 2\eta| < 1, x сжимается к нулю; если больше 1 — растёт. У крутых функций (большая вторая производная) допустимый шаг меньше. В нейросетях шаг подбирают экспериментально: обычно пробуют значения вроде 0.1, 0.01, 0.001 (для Adam типично 0.001).
Полный, стохастический и мини-батч
Эмпирический риск — среднее по всем N объектам, и его градиент — тоже среднее градиентов по объектам. Если объектов миллион, считать его целиком на каждом шаге дорого. Варианты:
| Вариант | Градиент на шаге по | Плюсы | Минусы |
|---|---|---|---|
| Полный (batch GD) | всем N объектам | точное направление | один шаг = проход по всем данным |
| Стохастический (SGD) | 1 случайному объекту | очень дешёвые шаги | шумный путь |
| Мини-батч | B объектам (32–512) | баланс, эффективно на GPU | — |
На практике почти всегда используют мини-батчи, и словом «SGD» обычно называют именно их.
- Итерация — один шаг по одному батчу.
- Эпоха — проход по всей выборке: N / B итераций. Перед каждой эпохой данные перемешивают.
Шум мини-батчей — не только недостаток: он помогает выбираться из плохих мелких минимумов и седловых точек.
import numpy as np
import vis
rng = np.random.default_rng(0)
N = 500
X = np.c_[rng.normal(size=N), np.ones(N)]
y = X @ np.array([2.0, -1.0]) + rng.normal(0, 0.5, N)
def train(batch_size, lr=0.05, epochs=5):
theta, path = np.array([-2.0, 2.0]), []
for _ in range(epochs):
idx = rng.permutation(N)
for s in range(0, N, batch_size):
b = idx[s:s + batch_size]
theta = theta - lr * 2 / len(b) * X[b].T @ (X[b] @ theta - y[b])
path.append(theta.copy())
return np.array(path)
for bs in (N, 32, 1):
p = train(bs)
print(f"батч {bs:3}: шагов {len(p):5}, итог θ = {p[-1].round(3)}")
vis.line(p[:, 0], p[:, 1], title=f"Путь параметров, батч = {bs}", xlabel="w", ylabel="b", points=p[:: max(1, len(p) // 25)])
За одинаковое число эпох мини-батч сделал намного больше шагов и пришёл к ответу, а полный спуск с тем же шагом ещё в пути. Стохастический (батч 1) дошёл быстро, но «дрожит» около минимума.
Масштаб признаков
Если один признак измеряется в тысячах, а другой в долях, поверхность потерь превращается в вытянутый овраг: по одному направлению очень круто, по другому полого. Шаг приходится выбирать под самое крутое направление, и вдоль пологого спуск ползёт.
import numpy as np
rng = np.random.default_rng(1)
area = rng.uniform(30, 120, 200) # десятки
floor = rng.uniform(1, 25, 200) # единицы
y = 0.15 * area + 0.1 * floor + 1 + rng.normal(0, 0.3, 200)
def run(X, lr, steps=500):
th = np.zeros(X.shape[1])
for _ in range(steps):
th -= lr * 2 / len(X) * X.T @ (X @ th - y)
return ((X @ th - y) ** 2).mean()
raw = np.c_[area, floor, np.ones(200)]
std = np.c_[(area - area.mean()) / area.std(), (floor - floor.mean()) / floor.std(), np.ones(200)]
print("без нормализации, lr = 1e-4: MSE =", round(run(raw, 1e-4), 4))
print("без нормализации, lr = 1e-3: MSE =", run(raw, 1e-3)) # разошёлся
print("с нормализацией, lr = 0.1: MSE =", round(run(std, 0.1), 4))
Локальные минимумы, седла и плато
Поверхность потерь нейросети — не аккуратная чаша, а сложный рельеф в миллионах измерений.
- Локальный минимум — яма, но не самая глубокая. В маленьких задачах это проблема; в больших сетях, как выяснилось, большинство локальных минимумов почти так же хороши, как глобальный.
- Седловая точка — по одним направлениям минимум, по другим максимум (как седло лошади). Градиент там ноль, и спуск замедляется. В многомерных пространствах сёдла встречаются гораздо чаще минимумов.
- Плато — почти плоские области с крошечным градиентом.
С сёдлами и плато помогают шум мини-батчей и оптимизаторы с инерцией (Momentum, Adam) — им будет посвящён отдельный урок.
import numpy as np
import vis
f = lambda p: p[0] ** 2 - p[1] ** 2 # седло
g = lambda p: np.array([2 * p[0], -2 * p[1]])
p = np.array([1.0, 1e-6]); path = [p]
for _ in range(40):
p = p - 0.1 * g(p); path.append(p)
path = np.array(path)
print("градиент в начале пути:", g(path[0]).round(4), " после 10 шагов:", g(path[10]).round(6))
vis.line(path[:, 0], path[:, 1], title="Седло f = x² − y²: спуск долго «стоит», потом соскальзывает", xlabel="x", ylabel="y", points=path)
Расписание шага обучения
В начале обучения хочется большой шаг, чтобы быстро продвинуться, а в конце — маленький, чтобы аккуратно «осесть» в минимум, а не скакать вокруг. Поэтому шаг часто уменьшают по ходу обучения:
- ступенчато: в 10 раз каждые k эпох;
- экспоненциально: \eta_t = \eta_0 \gamma^t;
- косинусное затухание: плавно от \eta_0 до почти нуля;
- разогрев (warmup): первые шаги шаг растёт от нуля — стандарт для трансформеров.
import numpy as np
import vis
t = np.arange(100)
vis.line(t, {
"ступенчато": 0.1 * 0.1 ** (t // 40),
"экспоненциально": 0.1 * 0.97 ** t,
"косинус": 0.05 * (1 + np.cos(np.pi * t / 100)),
"разогрев + косинус": np.where(t < 10, 0.1 * t / 10, 0.05 * (1 + np.cos(np.pi * (t - 10) / 90))),
}, title="Расписания шага обучения", xlabel="эпоха", ylabel="lr")
Итог
- Градиентный спуск: \theta \leftarrow \theta - \eta\nabla L; шаг обучения \eta — главный гиперпараметр.
- Маленький шаг — медленно, большой — колебания и расходимость.
- Мини-батчи — стандарт: итерация — шаг по батчу, эпоха — проход по данным; данные перемешивают.
- Нормализуйте признаки — иначе поверхность потерь превращается в овраг.
- Локальные минимумы, сёдла, плато; шаг обучения часто уменьшают по расписанию.
Практика решено 0 из 6
Потери при обучении растут и уходят в бесконечность (nan). Что вероятнее всего нужно сделать?
В выборке 10 000 объектов, размер батча 100. Сколько шагов градиентного спуска (итераций) в одной эпохе?
Свойства вариантов градиентного спуска
| полный (batch) | стохастический (1 объект) | мини-батч | |
|---|---|---|---|
| Самый точный градиент на каждом шаге | |||
| Самый «шумный» путь | |||
| Стандарт для обучения нейросетей | |||
| Нужна вся выборка в памяти для одного шага |
Для функции f(x) = x^2 градиентный спуск x \leftarrow x - \eta \cdot 2x. При каком шаге \eta он расходится?
Мини-батч градиентный спуск. Обучите линейную модель \hat y = X\theta (признаки уже
со столбцом единиц) на MSE. Напишите minibatch_gd(X, y, lr=0.05, epochs=50, batch_size=16, seed=0):
thetaначинается с нулей;- в начале каждой эпохи перемешайте индексы:
rng.permutation(len(X)), гдеrng = np.random.default_rng(seed)создаётся один раз до цикла; - идите по перемешанным индексам кусками по
batch_sizeи для каждого куска делайте шагtheta -= lr * 2 / len(Xb) * Xb.T @ (Xb @ theta - yb).
Верните theta.
Какие тесты проверяют код (1)
rng = np.random.default_rng(42)
X = np.c_[rng.normal(size=(200, 2)), np.ones(200)]
y = X @ np.array([1.5, -2.0, 0.7]) + rng.normal(0, 0.05, 200)
th = minibatch_gd(X, y)
assert np.shape(th) == (3,)
assert np.allclose(th, [1.5, -2.0, 0.7], atol=0.05), f"Получено {np.round(th, 3)}"
import numpy as np
def minibatch_gd(X, y, lr=0.05, epochs=50, batch_size=16, seed=0):
rng = np.random.default_rng(seed)
theta = np.zeros(X.shape[1])
for _ in range(epochs):
idx = rng.permutation(len(X))
for start in range(0, len(X), batch_size):
b = idx[start:start + batch_size]
Xb, yb = X[b], y[b]
theta -= lr * 2 / len(Xb) * Xb.T @ (Xb @ theta - yb)
return theta
Подбор шага. Напишите best_lr(f, grad, x0, lrs, steps=100): для каждого шага из списка lrs
запустите градиентный спуск из точки x0 на steps шагов (числа, одна переменная) и верните тот
шаг, при котором итоговое значение f минимально. Если спуск разошёлся (значение стало nan
или inf, или |x| > 1e6), этот шаг не подходит.
Какие тесты проверяют код (2)
f = lambda x: (x - 3) ** 2 g = lambda x: 2 * (x - 3) assert best_lr(f, g, 0.0, [0.0001, 0.001, 0.4, 1.5], steps=20) == 0.4 f = lambda x: 10 * x ** 2 g = lambda x: 20 * x assert best_lr(f, g, 1.0, [0.2, 0.01, 0.12], steps=50) == 0.01, "0.2 и 0.12 расходятся для крутой функции"
import math
def best_lr(f, grad, x0, lrs, steps=100):
best, best_val = None, math.inf
for lr in lrs:
x = x0
for _ in range(steps):
x = x - lr * grad(x)
if not math.isfinite(x) or abs(x) > 1e6:
break
if math.isfinite(x) and abs(x) <= 1e6 and f(x) < best_val:
best, best_val = lr, f(x)
return best
Войдите, чтобы отмечать пройденные уроки.