← Программа курса
Занятие 04 · Модуль 1 · Классический ML · 40 мин чтения

Как модель учится: градиентный спуск

Заглядываем под капот «обучения»: функция потерь как рельеф, производная-наклон, шаг против градиента, роль learning rate, нормализация признаков и SGD — всё на настоящем спуске на numpy с графиками.

функция потерьградиентlearning rateнормализацияSGD
Скачать ноутбук урока ↓

В уроке про линейную регрессию scikit-learn за две строки «нашёл лучшие веса». Но что значит «нашёл»? Для линейной регрессии есть точная формула, а вот для нейросети с миллионами весов формулы нет, и всё равно она как-то обучается. Сегодня заглянем под капот и разберём градиентный спуск — главный способ, которым учатся почти все модели в ML, от линейной регрессии до огромных языковых моделей. Без формул-страшилок: на интуиции, на картинках и на настоящем спуске, который мы запрограммируем сами в десяток строк numpy.

Это один из самых важных уроков курса. Поймёшь градиентный спуск по-настоящему, и слова «обучение», «функция потерь», «сходимость», «learning rate» станут понятны: за ними стоит простая и красивая механика.

Обучение — это поиск минимума

Вспомним: у модели есть веса ww (для линейной регрессии это w0,w1,w_0, w_1, \dots), и для каждого набора весов можно посчитать ошибку — например, MSE. Ошибка как функция весов называется функцией потерь L(w)L(w):

L(w)=1i=1(a(xi)yi)2L(w) = \frac{1}{\ell}\sum_{i=1}^{\ell}\bigl(a(x_i) - y_i\bigr)^2

Здесь a(xi)a(x_i) — предсказание модели с текущими весами ww на ii-м объекте, yiy_i — настоящий ответ, \ell — число объектов. Важно увидеть: при фиксированных данных LL зависит только от весов ww. Меняем веса — меняется ошибка.

Чем меньше потеря, тем лучше модель. Значит, обучение — это задача поиска такого ww, при котором L(w)L(w) минимальна.

Представь, что L(w)L(w) — это рельеф местности: где-то горы (большая ошибка), где-то долина (маленькая). Мы хотим спуститься на самое дно долины, но мы в густом тумане: всей карты не видно, видно только землю под ногами. Что делать? Нащупать ногой, в какую сторону склон уходит вниз круче всего, сделать шаг туда — и повторять. Рано или поздно так спустишься ко дну. Это и есть градиентный спуск, целиком.

0246810 04812 вес w (параметр модели) ошибка L(w) шаг 1 шаг 2 шаг 3 шаг 4 минимум ✓ ошибка велика
Градиентный спуск: шаг за шагом скатываемся к минимуму ошибки.

Производная подсказывает направление

Чтобы «нащупать склон под ногами», математика даёт точный инструмент — производную. Если по-простому, производная L(w)L'(w) показывает, насколько и в какую сторону изменится ошибка, если чуть-чуть сдвинуть вес. Геометрически это наклон касательной к функции в текущей точке.

Возьмём простую чашу-параболу L(w)L(w) с минимумом и посмотрим на наклон в двух точках — слева и справа от дна:

import numpy as np, matplotlib.pyplot as plt
w = np.linspace(-1.5, 5.5, 300)
L = (w - 2) ** 2 + 0.5          # минимум при w = 2

plt.plot(w, L)
# наклон (производная) L'(w) = 2*(w-2): слева отрицательный, справа положительный
plt.show()
Парабола функции потерь; в левой точке касательная с отрицательным наклоном, в правой с положительным, внизу минимум
Наклон функции потерь в двух точках. Слева наклон отрицательный (надо идти вправо), справа положительный (надо идти влево). В обоих случаях движемся против знака наклона — к минимуму.

Прочитаем картинку:

Заметь закономерность: в обоих случаях мы двигаемся в сторону, противоположную знаку производной. Эта мысль — фундамент всего метода.

Шаг градиентного спуска

Когда вес один, наклон — это одно число (производная). Когда весов несколько, наклон считают отдельно по каждому весу — такой «наклон вдоль одной оси» называют частной производной. Представь холм в 3D: у тебя есть отдельный наклон вдоль «север-юг» и отдельный вдоль «восток-запад». Собери эти наклоны в один набор чисел — получится градиент (его обозначают значком \nabla, читается «на́бла»).

Градиент указывает направление самого быстрого роста ошибки. А раз мы хотим ошибку уменьшать, шагаем в противоположную сторону. Один шаг обновления веса выглядит так:

wnew=woldηL(wold)w_{\text{new}} = w_{\text{old}} - \eta \cdot \nabla L(w_{\text{old}})

Разберём по частям:

Повторяем этот шаг много раз — и потихоньку скатываемся ко дну. Каждая итерация чуть-чуть улучшает веса. Когда градиент становится почти нулевым (наклона нет), мы у минимума — это называется сходимостью.

Спуск своими руками

Хватит теории — реализуем градиентный спуск сами, без sklearn, для самой простой модели a(x)=wxa(x) = w \cdot x (один вес, без сдвига) и функции потерь MSE. Для MSE градиент по ww выводится аналитически: L=2nxi(wxiyi)\nabla L = \frac{2}{n}\sum x_i\,(w x_i - y_i) — мы просто им воспользуемся.

import numpy as np

# данные: ровно y = 2 * x (без шума), чтобы было видно, к чему сходимся
x = np.array([1, 2, 3, 4, 5], dtype=float)
y = np.array([2, 4, 6, 8, 10], dtype=float)

w = 0.0          # стартуем с нуля
eta = 0.01       # learning rate
n = len(x)

for step in range(50):
    y_pred = w * x
    grad = (2 / n) * np.sum(x * (y_pred - y))   # градиент MSE по w
    w = w - eta * grad                          # шаг против градиента
    loss = np.mean((w * x - y) ** 2)            # ошибку считаем уже с НОВЫМ w
    if step % 10 == 0:
        print(f"шаг {step:2d}: w = {w:.3f}, ошибка = {loss:.3f}")

Запустив, увидишь настоящую сходимость:

шаг  0: w = 0.440, ошибка = 26.770
шаг 10: w = 1.870, ошибка = 0.186
шаг 20: w = 1.989, ошибка = 0.001
шаг 30: w = 1.999, ошибка = 0.000
шаг 40: w = 2.000, ошибка = 0.000

Смотри: w шаг за шагом приближается к 2 (истинному наклону данных y=2xy = 2x), а ошибка падает почти к нулю. Модель «учится» шаг за шагом, без всякой магии. Нарисуем этот путь прямо на чаше ошибки: каждая точка — значение ww после очередного шага, лежащее на параболе L(w)L(w).

def loss(w): return np.mean((w * x - y) ** 2)

w, eta, hist = 0.0, 0.02, [0.0]
for _ in range(15):
    grad = (2 / n) * np.sum(x * (w * x - y))
    w = w - eta * grad
    hist.append(w)

ws = np.linspace(-0.3, 3.3, 300)
plt.plot(ws, [loss(v) for v in ws])             # чаша
plt.plot(hist, [loss(v) for v in hist], "o-")   # путь спуска
plt.show()
Парабола ошибки и цепочка точек, спускающихся по ней от старта w=0 ко дну w=2; шаги укорачиваются у дна
Реальный путь спуска по чаше ошибки. Точки — значения w после каждого шага. У краёв наклон крутой, шаги большие; ближе ко дну наклон мал, шаги укорачиваются — спуск сам тормозит у минимума.

Обрати внимание на красивую деталь: чем ближе ко дну, тем короче шаги. Так выходит само, без всякой специальной настройки: у дна наклон мал, а шаг пропорционален наклону (ηL\eta \cdot \nabla L). Спуск автоматически тормозит у минимума.

Learning rate: размер шага решает всё

η\eta — самый важный «ручной» параметр спуска (гиперпараметр): это насколько широко мы шагаем вслепую по склону. От него зависит, дойдём ли мы до дна вообще. Запустим тот же спуск с тремя разными η\eta и сравним, как ведёт себя ошибка по шагам:

def run(eta, steps=40):
    w, losses = 0.0, []
    for _ in range(steps):
        grad = (2 / n) * np.sum(x * (w * x - y))
        w = w - eta * grad
        losses.append(np.mean((w * x - y) ** 2))
    return losses

for eta in [0.002, 0.02, 0.09]:
    plt.plot(run(eta), label=f"eta = {eta}")
plt.yscale("log"); plt.legend(); plt.show()
Три кривые ошибки по шагам в логарифмическом масштабе: маленький eta медленно ползёт вниз, средний быстро падает, большой не убывает
Три learning rate на одних данных (ось ошибки логарифмическая). Маленький (0.002) ползёт медленно; в самый раз (0.02) быстро сходится; большой (0.09) не сходится — ошибка застряла, веса скачут туда-сюда.

Подбор learning rate — это всегда баланс. На практике начинают с небольшого значения (например, 0.01 или 0.1), смотрят на кривую ошибки и корректируют: если ползёт медленно, увеличивают шаг, а если скачет, уменьшают.

Нормализация признаков: делаем чашу круглой

Есть подводный камень, который не виден на одном признаке. Если один признак измеряется в тысячах (зарплата), а другой в единицах (стаж в годах), поверхность ошибки становится сильно вытянутой — как длинный узкий овраг вместо круглой чаши. Спуск по такому оврагу долго зигзагует от стенки к стенке вместо прямого пути ко дну.

Посмотрим на линии уровня (как на топографической карте) и путь спуска по вытянутой и по круглой чаше:

Слева вытянутые овальные линии уровня и зигзагообразный путь спуска; справа круглые линии уровня и почти прямой путь к центру
Без нормализации (слева) чаша вытянута, спуск зигзагует к центру долго. После нормализации (справа) чаша круглая, и спуск идёт почти прямо ко дну за несколько шагов.

А теперь — та же идея в объёме. Покрути обе «чаши» мышью: слева видно, как путь спуска бьётся зигзагом о крутые стенки узкого оврага, а справа на круглой чаше идёт прямо ко дну.

без нормализации (вытянутая чаша)
после нормализации (круглая чаша)
Та же идея в 3D. Шаги спуска идут по нижней плоскости (вес 1, вес 2) — это пространство параметров; пунктиры поднимаются к поверхности и показывают, какой ошибке отвечает каждый шаг. Слева чаша вытянута (без нормализации) — путь зигзагует поперёк узкого «оврага»; справа круглая (после нормализации) — путь идёт почти прямо ко дну. Каждую чашу можно вращать мышью (зажми и потяни).

Лекарство простое: привести все признаки к одному масштабу — например, у каждого признака вычесть его среднее и поделить на разброс (стандартное отклонение). Тогда все признаки станут «в одних единицах», и чаша округлится.

from sklearn.preprocessing import StandardScaler

# в реальном пайплайне scaler учат на train, затем применяют к test;
# здесь — на всех данных для наглядности
X_scaled = StandardScaler().fit_transform(X)   # у каждого признака среднее 0, разброс 1

Аналогия: спускаться в круглую чашу проще, чем в длинный узкий овраг. Нормализация делает «чашу» круглой, и спуск сходится в разы быстрее. К нормализации мы ещё вернёмся в уроке про предобработку — там это станет обязательным шагом.

SGD: спуск по порциям

В нашем коде на каждом шаге мы считали градиент по всем данным сразу — это называется полный (batch) градиентный спуск. Когда объектов пять, это мгновенно. А когда их миллионы (или у нейросети миллиарды), считать градиент по всей выборке на каждом шаге — непозволительно дорого.

Идея стохастического градиентного спуска (SGD): на каждом шаге брать не всю выборку, а небольшую случайную порцию (mini-batch) — скажем, 32 или 256 объектов. Градиент по такой порции — лишь приблизительная оценка настоящего, поэтому шаги становятся чуть «шумными», слегка виляют. Зато каждый шаг во много раз дешевле, и за то же время мы делаем гораздо больше шагов. Так обучают все большие модели и нейросети.

Аналогия: вместо того чтобы перед каждым шагом опросить весь город о направлении (точно, но долго), мы спрашиваем случайных пять прохожих (грубее, но мгновенно), и всё равно в среднем идём вниз.

Почему это работает почти везде

Дело в том, что нам не нужна формула минимума. Достаточно уметь считать градиент (наклон под ногами) и шагать вниз. Для линейной регрессии точная формула есть, и sklearn ею пользуется. Но для сложных моделей (нейросетей с миллионами весов) точной формулы нет в принципе, и градиентный спуск становится единственным способом обучения. Поэтому он и лежит в основе всего современного ML и глубокого обучения.

Единственное, что нужно от модели и функции потерь, — чтобы можно было посчитать градиент. Для этого придумали автоматическое дифференцирование (его дают библиотеки вроде PyTorch): ты пишешь модель, а градиент считается сам. Но идея под капотом — ровно та, что мы разобрали сегодня: wwηLw \leftarrow w - \eta\,\nabla L.

Итог урока

  1. Функция потерь L(w)L(w) — это ошибка модели как функция её весов; при фиксированных данных зависит только от ww.
  2. Обучение — это поиск минимума L(w)L(w), спуск ко дну «чаши ошибки».
  3. Производная (градиент) показывает направление роста ошибки; мы шагаем в противоположную сторону.
  4. Формула шага: wnew=woldηLw_{\text{new}} = w_{\text{old}} - \eta \cdot \nabla L; минус — потому что идём вниз.
  5. Learning rate η\eta — размер шага: маленький замедляет спуск, большой заставляет ошибку скакать и расходиться.
  6. Нормализация признаков превращает вытянутый «овраг» в круглую чашу и ускоряет сходимость.
  7. SGD считает градиент по случайным порциям данных — дёшево и масштабируемо; так учат нейросети.
  8. Метод работает даже там, где нет формулы минимума, поэтому он лежит в основе всего глубокого обучения.

Дальше курс уходит в классификацию — но под капотом логистической регрессии и многих других моделей крутится ровно тот же спуск, что мы сегодня разобрали руками.


Домашнее задание

Задания идут от простых к сложным. 🧠 — теория, 💻 — код, 🔮 — «что будет, если…». Сначала попробуй сам, потом открой разбор.

  1. 🧠 Объясни своими словами, почему в формуле шага стоит минус перед градиентом.
Разбор

Градиент указывает направление самого быстрого роста ошибки. Нам нужно ошибку уменьшать, то есть идти в обратную сторону — поэтому перед градиентом стоит минус: wηLw - \eta\nabla L сдвигает веса против роста, вниз по склону.

  1. 🧠 Производная функции потерь в точке равна +4+4. В какую сторону (увеличивать или уменьшать вес) нужно шагнуть, чтобы уменьшить ошибку?
Разбор

Производная положительна (+4) → функция растёт вправо → чтобы ошибку уменьшить, идём влево, то есть уменьшаем вес. По формуле: wnew=wη4w_{\text{new}} = w - \eta \cdot 4, при положительном η\eta это уменьшает ww. Всё сходится.

  1. 💻 Запусти код спуска из урока. Собери список значений ошибки по всем 50 шагам и построй график — убедись, что ошибка убывает.
Разбор
import numpy as np, matplotlib.pyplot as plt
x = np.array([1,2,3,4,5], float); y = np.array([2,4,6,8,10], float); n = len(x)
w, eta, losses = 0.0, 0.01, []
for _ in range(50):
    grad = (2/n)*np.sum(x*(w*x - y)); w -= eta*grad
    losses.append(np.mean((w*x - y)**2))
plt.plot(losses); plt.xlabel("шаг"); plt.ylabel("MSE"); plt.show()

Кривая монотонно падает от ~36 почти до 0: при правильном η\eta каждый шаг уменьшает ошибку.

  1. 🔮 Поменяй eta на 0.001, потом на 0.09. Опиши, что происходит с ошибкой в каждом случае и почему.
Разбор

При eta = 0.001 шаги крошечные: за 50 итераций ошибка едва сдвинется, спуск не успеет дойти до дна — нужно гораздо больше шагов. При eta = 0.09 шаг слишком велик, веса перепрыгивают минимум и скачут (w мечется между ~4 и ~0), ошибка почти не убывает — как на третьем графике урока. Вывод: η\eta нужно подбирать; и слишком малый, и слишком большой плохи.

  1. 💻 Добавь в модель второй параметр — сдвиг bb: модель a(x)=wx+ba(x) = w x + b. Градиент по bb — это среднее от 2(предсказаниеистина)2(\text{предсказание} - \text{истина}). Обнови шаг спуска для двух параметров и обучи на y=2x+1y = 2x + 1.
Разбор
import numpy as np
x = np.array([1,2,3,4,5], float); y = np.array([3,5,7,9,11], float); n = len(x)
w, b, eta = 0.0, 0.0, 0.01
for _ in range(2000):
    pred = w*x + b
    gw = (2/n)*np.sum(x*(pred - y))
    gb = (2/n)*np.sum(pred - y)
    w -= eta*gw; b -= eta*gb
print(round(w,3), round(b,3))   # стремится к w≈2, b≈1

Оба параметра обновляются своими частными производными. С достаточным числом шагов w2w \to 2, b1b \to 1 — истинные коэффициенты. (За 200 шагов будет ещё не точно — около 2.06 и 0.79; дайте спуску больше итераций.)

  1. 🧠 Почему градиентный спуск особенно важен для нейросетей, хотя для линейной регрессии есть точная формула?
Разбор

Для линейной регрессии минимум MSE даёт точная формула w=(XX)1Xyw = (X^\top X)^{-1}X^\top y, и спуск не обязателен. Но у нейросети миллионы весов и сложная нелинейная функция потерь — точной формулы минимума не существует. Зато градиент посчитать можно (автодифференцирование), поэтому шаговый спуск wwηLw \leftarrow w - \eta\nabla L остаётся единственным реальным способом обучения. В этом универсальность метода.

  1. 🔮 Что произойдёт, если стартовать спуск из очень далёкой точки (например, w = 1000)? Дойдёт ли он до минимума? Проверь на коде.
Разбор

С разумным η\eta (например, 0.01) спуск всё равно дойдёт до минимума — просто потратит больше шагов на дорогу: вначале наклон огромный, шаги большие, и w быстро летит к области минимума, затормаживая у дна. Для выпуклой чаши (как у линейной регрессии) стартовая точка не важна — дно одно, и оттуда дойдём из любого места. А вот при слишком большом η\eta далёкий старт может сразу «взорвать» веса до бесконечности.

Нужен разбор?

Застрял на теме — разберём один на один

Объясню сложное на пальцах и доведу до результата: код, формулы, проект.

Записаться на разбор