Градиентный спуск

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

Идея

Мы стоим на склоне в тумане и хотим спуститься в долину. Видно только землю под ногами. Разумная стратегия: определить, куда склон уходит вниз круче всего, сделать шаг туда и повторить. Это и есть градиентный спуск:

\theta \leftarrow \theta - \eta \, \nabla_\theta L(\theta)
  • \nabla_\theta L — градиент: направление быстрейшего роста потерь, поэтому идём со знаком минус;
  • \eta (эта) — шаг обучения (learning rate), главный гиперпараметр.

Алгоритм:

  1. Инициализировать параметры (нулями или случайно).
  2. Посчитать потери и градиент.
  3. Сделать шаг \theta \leftarrow \theta - \eta\nabla L.
  4. Повторять, пока потери уменьшаются.

Шаг обучения

Поиграйте с виджетом: меняйте функцию, шаг и стартовую точку.

  • Маленький шаг — спуск медленный, нужно очень много итераций.
  • Подходящий шаг — быстро скатываемся на дно.
  • Слишком большой — перепрыгиваем минимум, начинаем колебаться, а при ещё большем шаге колебания растут и спуск расходится.
  • На функции с двумя ямами (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

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

Потери при обучении растут и уходят в бесконечность (nan). Что вероятнее всего нужно сделать?

Задание 2 Ввод ответа

В выборке 10 000 объектов, размер батча 100. Сколько шагов градиентного спуска (итераций) в одной эпохе?

Задание 3 Заполнить таблицу

Свойства вариантов градиентного спуска

полный (batch)стохастический (1 объект)мини-батч
Самый точный градиент на каждом шаге
Самый «шумный» путь
Стандарт для обучения нейросетей
Нужна вся выборка в памяти для одного шага
Отметьте ячейку — «+», пустая — «−».
Задание 4 Один вариант ответа

Для функции f(x) = x^2 градиентный спуск x \leftarrow x - \eta \cdot 2x. При каком шаге \eta он расходится?

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

Мини-батч градиентный спуск. Обучите линейную модель \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)}"

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

Подбор шага. Напишите 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 расходятся для крутой функции"

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