В уроке про линейную регрессию scikit-learn за две строки «нашёл лучшие веса». Но что значит «нашёл»? Для линейной регрессии есть точная формула, а вот для нейросети с миллионами весов формулы нет, и всё равно она как-то обучается. Сегодня заглянем под капот и разберём градиентный спуск — главный способ, которым учатся почти все модели в ML, от линейной регрессии до огромных языковых моделей. Без формул-страшилок: на интуиции, на картинках и на настоящем спуске, который мы запрограммируем сами в десяток строк numpy.
Это один из самых важных уроков курса. Поймёшь градиентный спуск по-настоящему, и слова «обучение», «функция потерь», «сходимость», «learning rate» станут понятны: за ними стоит простая и красивая механика.
Обучение — это поиск минимума
Вспомним: у модели есть веса (для линейной регрессии это ), и для каждого набора весов можно посчитать ошибку — например, MSE. Ошибка как функция весов называется функцией потерь :
Здесь — предсказание модели с текущими весами на -м объекте, — настоящий ответ, — число объектов. Важно увидеть: при фиксированных данных зависит только от весов . Меняем веса — меняется ошибка.
Чем меньше потеря, тем лучше модель. Значит, обучение — это задача поиска такого , при котором минимальна.
Представь, что — это рельеф местности: где-то горы (большая ошибка), где-то долина (маленькая). Мы хотим спуститься на самое дно долины, но мы в густом тумане: всей карты не видно, видно только землю под ногами. Что делать? Нащупать ногой, в какую сторону склон уходит вниз круче всего, сделать шаг туда — и повторять. Рано или поздно так спустишься ко дну. Это и есть градиентный спуск, целиком.
Производная подсказывает направление
Чтобы «нащупать склон под ногами», математика даёт точный инструмент — производную. Если по-простому, производная показывает, насколько и в какую сторону изменится ошибка, если чуть-чуть сдвинуть вес. Геометрически это наклон касательной к функции в текущей точке.
Возьмём простую чашу-параболу с минимумом и посмотрим на наклон в двух точках — слева и справа от дна:
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: у тебя есть отдельный наклон вдоль «север-юг» и отдельный вдоль «восток-запад». Собери эти наклоны в один набор чисел — получится градиент (его обозначают значком , читается «на́бла»).
Градиент указывает направление самого быстрого роста ошибки. А раз мы хотим ошибку уменьшать, шагаем в противоположную сторону. Один шаг обновления веса выглядит так:
Разберём по частям:
- — градиент в текущей точке (наклон поверхности ошибки под ногами);
- знак минус — идём против роста, то есть вниз;
- (греческая «эта») — скорость обучения (learning rate): насколько большой шаг делаем;
- — куда мы шагнём, — где стояли.
Повторяем этот шаг много раз — и потихоньку скатываемся ко дну. Каждая итерация чуть-чуть улучшает веса. Когда градиент становится почти нулевым (наклона нет), мы у минимума — это называется сходимостью.
Спуск своими руками
Хватит теории — реализуем градиентный спуск сами, без sklearn, для самой простой модели (один вес, без сдвига) и функции потерь MSE. Для MSE градиент по выводится аналитически: — мы просто им воспользуемся.
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 (истинному наклону данных ), а ошибка падает почти к нулю. Модель «учится» шаг за шагом, без всякой магии. Нарисуем этот путь прямо на чаше ошибки: каждая точка — значение после очередного шага, лежащее на параболе .
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()
Обрати внимание на красивую деталь: чем ближе ко дну, тем короче шаги. Так выходит само, без всякой специальной настройки: у дна наклон мал, а шаг пропорционален наклону (). Спуск автоматически тормозит у минимума.
Learning rate: размер шага решает всё
— самый важный «ручной» параметр спуска (гиперпараметр): это насколько широко мы шагаем вслепую по склону. От него зависит, дойдём ли мы до дна вообще. Запустим тот же спуск с тремя разными и сравним, как ведёт себя ошибка по шагам:
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 = 0.09:шаг 0: w=3.96, loss=42.3 шаг 1: w=0.08, loss=40.6 шаг 2: w=3.88, loss=39.0 шаг 3: w=0.16, loss=37.4Видишь?
wмечется между ~4 и ~0, перепрыгивая минимум (он в 2), и ошибка почти не уменьшается. При ещё большем она бы взорвалась до бесконечности. -
В самый раз — уверенно сходимся к минимуму за разумное число шагов (синяя кривая).
Подбор learning rate — это всегда баланс. На практике начинают с небольшого значения (например, 0.01 или 0.1), смотрят на кривую ошибки и корректируют: если ползёт медленно, увеличивают шаг, а если скачет, уменьшают.
Нормализация признаков: делаем чашу круглой
Есть подводный камень, который не виден на одном признаке. Если один признак измеряется в тысячах (зарплата), а другой в единицах (стаж в годах), поверхность ошибки становится сильно вытянутой — как длинный узкий овраг вместо круглой чаши. Спуск по такому оврагу долго зигзагует от стенки к стенке вместо прямого пути ко дну.
Посмотрим на линии уровня (как на топографической карте) и путь спуска по вытянутой и по круглой чаше:
А теперь — та же идея в объёме. Покрути обе «чаши» мышью: слева видно, как путь спуска бьётся зигзагом о крутые стенки узкого оврага, а справа на круглой чаше идёт прямо ко дну.
Лекарство простое: привести все признаки к одному масштабу — например, у каждого признака вычесть его среднее и поделить на разброс (стандартное отклонение). Тогда все признаки станут «в одних единицах», и чаша округлится.
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): ты пишешь модель, а градиент считается сам. Но идея под капотом — ровно та, что мы разобрали сегодня: .
Итог урока
- Функция потерь — это ошибка модели как функция её весов; при фиксированных данных зависит только от .
- Обучение — это поиск минимума , спуск ко дну «чаши ошибки».
- Производная (градиент) показывает направление роста ошибки; мы шагаем в противоположную сторону.
- Формула шага: ; минус — потому что идём вниз.
- Learning rate — размер шага: маленький замедляет спуск, большой заставляет ошибку скакать и расходиться.
- Нормализация признаков превращает вытянутый «овраг» в круглую чашу и ускоряет сходимость.
- SGD считает градиент по случайным порциям данных — дёшево и масштабируемо; так учат нейросети.
- Метод работает даже там, где нет формулы минимума, поэтому он лежит в основе всего глубокого обучения.
Дальше курс уходит в классификацию — но под капотом логистической регрессии и многих других моделей крутится ровно тот же спуск, что мы сегодня разобрали руками.
Домашнее задание
Задания идут от простых к сложным. 🧠 — теория, 💻 — код, 🔮 — «что будет, если…». Сначала попробуй сам, потом открой разбор.
- 🧠 Объясни своими словами, почему в формуле шага стоит минус перед градиентом.
Разбор
Градиент указывает направление самого быстрого роста ошибки. Нам нужно ошибку уменьшать, то есть идти в обратную сторону — поэтому перед градиентом стоит минус: сдвигает веса против роста, вниз по склону.
- 🧠 Производная функции потерь в точке равна . В какую сторону (увеличивать или уменьшать вес) нужно шагнуть, чтобы уменьшить ошибку?
Разбор
Производная положительна (+4) → функция растёт вправо → чтобы ошибку уменьшить, идём влево, то есть уменьшаем вес. По формуле: , при положительном это уменьшает . Всё сходится.
- 💻 Запусти код спуска из урока. Собери список значений ошибки по всем 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на0.001, потом на0.09. Опиши, что происходит с ошибкой в каждом случае и почему.
Разбор
При eta = 0.001 шаги крошечные: за 50 итераций ошибка едва сдвинется, спуск не успеет дойти до дна — нужно гораздо больше шагов. При eta = 0.09 шаг слишком велик, веса перепрыгивают минимум и скачут (w мечется между ~4 и ~0), ошибка почти не убывает — как на третьем графике урока. Вывод: нужно подбирать; и слишком малый, и слишком большой плохи.
- 💻 Добавь в модель второй параметр — сдвиг : модель . Градиент по — это среднее от . Обнови шаг спуска для двух параметров и обучи на .
Разбор
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Оба параметра обновляются своими частными производными. С достаточным числом шагов , — истинные коэффициенты. (За 200 шагов будет ещё не точно — около 2.06 и 0.79; дайте спуску больше итераций.)
- 🧠 Почему градиентный спуск особенно важен для нейросетей, хотя для линейной регрессии есть точная формула?
Разбор
Для линейной регрессии минимум MSE даёт точная формула , и спуск не обязателен. Но у нейросети миллионы весов и сложная нелинейная функция потерь — точной формулы минимума не существует. Зато градиент посчитать можно (автодифференцирование), поэтому шаговый спуск остаётся единственным реальным способом обучения. В этом универсальность метода.
- 🔮 Что произойдёт, если стартовать спуск из очень далёкой точки (например,
w = 1000)? Дойдёт ли он до минимума? Проверь на коде.
Разбор
С разумным (например, 0.01) спуск всё равно дойдёт до минимума — просто потратит больше шагов на дорогу: вначале наклон огромный, шаги большие, и w быстро летит к области минимума, затормаживая у дна. Для выпуклой чаши (как у линейной регрессии) стартовая точка не важна — дно одно, и оттуда дойдём из любого места. А вот при слишком большом далёкий старт может сразу «взорвать» веса до бесконечности.