Перейти к содержимому
Ступень 22.1 Что такое машинное обучение

Обобщение, переобучение, регуляризация

Почему модель, выучившая train наизусть, проваливается на новых данных, и как это лечить — bias–variance, кривые обучения, L2, ранняя остановка, кросс-валидация и утечки данных.

УРОКstage-2/2-1-ml-basics/02-generalization.mdx2 чУрок 2 из 2
После урока вы сможете
  • Диагностировать недообучение и переобучение по ошибкам на train и validation и по кривым обучения
  • Разложить ошибку на смещение, разброс и шум и объяснить, как сложность модели сдвигает баланс между ними
  • Применить L2-регуляризацию, раннюю остановку и k-fold кросс-валидацию для выбора модели
  • Находить утечки данных — от нормализации по всей выборке до бенчмарков, попавших в обучающий корпус LLM

Представьте: вы дообучаете Qwen на 500 вопросах службы поддержки. Ставите 10 эпох, loss на train падает до 0.05. Вы довольны, пока не задаёте вопрос, которого не было в данных. Модель отвечает хуже, чем до дообучения, зато обучающие ответы воспроизводит слово в слово. Она запомнила, но не научилась.

Цель машинного обучения — обобщение, обобщающая способностьобобщение, обобщающая способностьEN generalization: хорошо работать на данных, которых модель не видела. Ошибка на train — лишь подсказка, и часто обманчивая. Сегодня разберём, почему так происходит, как это диагностировать и чем лечить. Эти инструменты понадобятся и при обучении LLM: дообучение на маленьких данных переобучается очень легко, а бенчмарки, «протёкшие» в обучающий корпус, дают красивые и бессмысленные цифры.

Возьмём задачу, где ответ знаем мы, но не модель: y=sin⁡(2πx)+εy = \sin(2\pi x) + \varepsilon. Разберём запись.

  • sin⁡\sin — синус, школьная функция-«волна». Вам не нужно помнить тригонометрию: важна только форма. Пока xx идёт от 0 до 1, sin⁡(2πx)\sin(2\pi x) делает ровно одну волну: поднимается от 0 до 1, опускается до −1 и возвращается в 0. Прямая такую кривую заведомо не повторит — это нам и нужно.
  • ε\varepsilon («эпсилон») — случайный шум, своя небольшая случайная добавка к каждой точке. Его стандартное отклонениестандартное отклонениеEN standard deviationКорень из дисперсии; измеряется в тех же единицах, что и сама величина. (урок 0.3.2) равно 0.2: типичное отклонение точки от волны — около 0.2. Дисперсия шума 0.22=0.040.2^2 = 0.04 — это нижняя граница MSE: шум не предскажет никакая модель (как в практике прошлого урока).

Дадим модели 12 обучающих точек и будем подгонять к ним полиномы разных степеней. Качество проверим на 1000 новых точек.

Полином степени dd — это y^=w0+w1x+w2x2+⋯+wdxd\hat y = w_0 + w_1 x + w_2 x^2 + \dots + w_d x^d, у него d+1d + 1 параметр. Удобный приём: составим матрицу признаков XX со столбцами 1,x,x2,…,xd1, x, x^2, \dots, x^d. Например, при d=2d = 2 точке x=3x = 3 соответствует строка (1,3,9)(1, 3, 9), и предсказание равно w0⋅1+w1⋅3+w2⋅9w_0 \cdot 1 + w_1 \cdot 3 + w_2 \cdot 9. Это обычная взвешенная сумма признаков, как у прямой из прошлого урока, только признаков больше. Поэтому говорят, что модель линейна по весам, y^=Xw\hat y = Xw (матрица на вектор — урок 1.1.2), хотя по xx она кривая.

Веса подбирает метод наименьших квадратов — минимизация MSE на train. У этой задачи есть готовая формула w=(X⊤X)−1X⊤yw = (X^\top X)^{-1} X^\top y, где −1^{-1} — обратная матрица из урока 1.1.3. Откуда эта формула берётся, разберём в уроке 2.2.1, а пока это одна строка np.polyfit(x, y, d).

СтепеньПараметровMSE на trainMSE на testСамый большой коэффициент
010.30720.5650.02
120.15400.2671.4
340.01310.04933
560.01250.04856
9100.00351.850241 533
11120.0000143 0392 790 689 858

Три режима видны сразу:

  • Степени 0–1 — недообучениенедообучениеEN underfitting. Прямая не может изогнуться в синусоиду. Ошибка велика и на train, и на test. Модели не хватает выразительности.
  • Степени 3–5 — норма. Ошибка на test ≈ 0.05 при шуме 0.22=0.040.2^2 = 0.04. Лучше почти невозможно.
  • Степени 9–11 — переобучениепереобучениеEN overfitting. На train почти ноль, на test катастрофа. У полинома 11-й степени 12 коэффициентов на 12 точек: он проходит через каждую точку, включая шум. Чтобы так извиваться, ему нужны коэффициенты в миллиарды.

Разность «ошибка на test − ошибка на train» называют разрывом обобщения (generalization gap). Растущий разрыв — главный симптом переобучения.

Почему простая модель ошибается иначе, чем сложная? Представьте, что мы много раз собираем новые 20 обучающих точек (и положения точек, и шум каждый раз другие) и каждый раз обучаем модель заново. Получим много немного разных кривых.

  • Смещение (bias) — насколько средняя из этих кривых промахивается мимо истины. Прямая промахивается всегда, сколько данных ни дай: это систематическая ошибка.
  • Разброс (variance) — насколько кривые отличаются друг от друга. Полином 9-й степени на каждой выборке выдаёт что-то своё: он подстраивается под случайный шум.

Аналогия с дартсом: смещение — все дротики кучно легли, но в стороне от центра. Разброс — дротики вокруг центра, но разлетелись по всей мишени.

Для квадратичной ошибки это разложение можно записать точно. Формулу ниже не нужно запоминать: достаточно понимать, что ошибка складывается из трёх частей. Значки — из урока 1.3.1: E\mathbb{E} — математическое ожиданиематематическое ожидание (матожидание)EN expected value (expectation)E[X] = Σ x·p(x) — среднее значение «в долгую», центр тяжести распределения. (среднее по всем случайным исходам), Var\mathrm{Var} — дисперсиядисперсияEN varianceVar[X] = E[(X − E[X])²] — средний квадрат отклонения от матожидания. (средний квадрат отклонения от среднего). Пусть y=f(x)+εy = f(x) + \varepsilon, где ff — истинная зависимость (у нас синус), шум в среднем равен нулю (E ε=0\mathbb{E}\,\varepsilon = 0) и имеет дисперсию Var ε=σ2\mathrm{Var}\,\varepsilon = \sigma^2, а f^D\hat f_D — модель, обученная на случайной выборке DD. Тогда в каждой точке xx

ED,ε[(y−f^D(x))2]=(EDf^D(x)−f(x))2⏟смещение2+ED[(f^D(x)−EDf^D(x))2]⏟разброс+σ2⏟шум.\mathbb{E}_{D,\varepsilon}\big[(y - \hat f_D(x))^2\big] = \underbrace{\big(\mathbb{E}_D \hat f_D(x) - f(x)\big)^2}_{\text{смещение}^2} + \underbrace{\mathbb{E}_D\big[(\hat f_D(x) - \mathbb{E}_D \hat f_D(x))^2\big]}_{\text{разброс}} + \underbrace{\sigma^2}_{\text{шум}}.

Что здесь написано. ED\mathbb{E}_D — среднее по всем возможным обучающим выборкам. EDf^D(x)\mathbb{E}_D \hat f_D(x) — «средняя кривая» из нашего мысленного эксперимента. Слева — средний квадрат ошибки на новом примере. Справа три слагаемых: насколько средняя кривая промахивается мимо истины f(x)f(x), насколько отдельные кривые разбросаны вокруг средней, и шум, который не предскажет никто.

Посчитаем честно: 2000 раз сгенерируем по 20 точек, обучим полиномы и усредним слагаемые по сетке из 101 значения xx от 0 до 1. Например, у кубического полинома выходит 0.0055+0.017+0.04≈0.0630.0055 + 0.017 + 0.04 \approx 0.063.

СтепеньСмещение²РазбросСумма + шум 0.04
00.4950.0270.562
10.2050.0320.276
30.00550.0170.063
50.00010.150.19
70.047575

С ростом сложности смещение падает, а разброс растёт. Минимум суммы — посередине, у степени 3. Это и есть компромисс смещения и разбросакомпромисс смещения и разбросаEN bias–variance tradeoff. Если нарисовать ошибку на новых данных в зависимости от сложности модели, получится кривая, похожая на букву U: слева высокая из-за смещения, справа — из-за разброса, внизу — золотая середина.

Разброс у степени 7 огромен, но почти весь (96%) его дают 1% самых неудачных выборок: на краю отрезка не оказалось точек, и полином там «взрывается». Поэтому от запуска к запуску это число меняется в разы. Те же взорвавшиеся выборки сдвигают и «среднюю кривую» — отсюда смещение 0.04 у степени 7 вместо почти нуля у степени 5. У степени 9 разброс уходит в десятки и сотни тысяч.

Как понять, что именно происходит с вашей моделью? Нарисовать кривые обучениякривая обученияEN learning curve. Их два вида.

Ошибка в зависимости от размера выборки. Медианы по 200 повторам для того же синуса (в каждом повторе — новая обучающая выборка и 1000 новых тестовых точек):

Обучающих точекСтепень 1: train / testСтепень 9: train / test
150.193 / 0.2610.013 / 7.047
300.217 / 0.2530.027 / 0.082
1000.233 / 0.2400.036 / 0.045
10000.235 / 0.2360.039 / 0.040

У прямой обе кривые быстро сходятся к ≈ 0.236 и дальше стоят. Это предел для прямой: 0.196 — промах лучшей возможной прямой мимо синусоиды (смещение²) плюс 0.04 шума. Больше данных не поможет — нужна модель сложнее. У полинома 9-й степени огромный разрыв при малых данных, но он закрывается с ростом выборки. Здесь больше данных — лучшее лекарство. Именно поэтому большие модели требуют больших корпусов.

Ошибка в зависимости от эпохи. При итеративном обучении (градиентный спуск, нейросети) модель улучшается шаг за шагом. ЭпохаэпохаEN epochОдин полный проход по обучающим данным. LLM на претрейне часто не делают и одной эпохи. — один полный проход по всем обучающим данным (урок 1.4.1). После каждой эпохи считают loss на train и на validation:

  • оба падают — учим дальше;
  • train падает, validation остановился и пошёл вверх — началось переобучение, пора останавливаться;
  • train почти не падает — проблема в оптимизации (слишком большой или маленький learning rate, неудачные начальные значения весов, баг в коде) или модель слишком проста.

В схеме ниже встретится ещё одна причина плохого validation — сдвиг распределения: новые данные устроены иначе, чем обучающие. Например, модель учили на отзывах о фильмах, а проверяют на отзывах о ресторанах.

flowchart TD
A["Loss на train большой?"] -->|да| B["Модель слишком простая<br/>или плохая оптимизация"]
B --> B1["Сложнее модель, больше признаков;<br/>проверить lr и код"]
A -->|нет| C["Loss на validation большой?"]
C -->|да| D["Переобучение<br/>или сдвиг распределения"]
D --> D1["Больше данных, регуляризация,<br/>ранняя остановка, проще модель"]
C -->|нет| E["Готово — проверяем на test один раз"]
Порядок диагностики: сначала train, потом validation

Переобученный полином выдаёт себя гигантскими коэффициентами. Идея регуляризациирегуляризацияEN regularization: штрафовать модель за сложность прямо в функции потерь. Самый популярный штраф — квадрат нормы весов:

L(w)=∥Xw−y∥2+λ∥w∥2.L(w) = \lVert Xw - y \rVert^2 + \lambda \lVert w \rVert^2 .

Значок ∥v∥\lVert v \rVert — длина (норма) вектора из урока 1.1.1, а квадрат длины — просто сумма квадратов его элементов. Поэтому первое слагаемое — знакомая сумма квадратов ошибок: Xw−yXw - y — вектор ошибок на всех примерах. Второе — сумма квадратов весов ∑jwj2\sum_j w_j^2, умноженная на число λ≥0\lambda \ge 0 («лямбда»). При λ=0\lambda = 0 штрафа нет; чем больше λ\lambda, тем дороже модели обходятся большие веса.

Пример. Веса w=(3,−4)w = (3, -4), сумма квадратов ошибок равна 1.5, λ=0.1\lambda = 0.1. Штраф: 0.1⋅(32+42)=0.1⋅25=2.50.1 \cdot (3^2 + 4^2) = 0.1 \cdot 25 = 2.5, и L=1.5+2.5=4L = 1.5 + 2.5 = 4. Другие веса w=(1,−1)w = (1, -1) ошибаются чуть сильнее, сумма квадратов ошибок 2.0, зато штраф всего 0.1⋅2=0.20.1 \cdot 2 = 0.2, и L=2.2L = 2.2. С регуляризацией выигрывает вторая, более «скромная» модель.

Для линейной модели решение по-прежнему находится формулой: к X⊤XX^\top X из метода наименьших квадратов добавляется λI\lambda I. Здесь II — единичная матрицаединичная матрицаEN identity matrixКвадратная матрица I с единицами на диагонали и нулями вне её; Ix = x. В NumPy — np.eye(n). из урока 1.1.2 (единицы на диагонали, нули вне её), так что +λI+\lambda I означает «прибавить λ\lambda к каждому диагональному элементу». Это гребневая регрессия (ridge), L2-регуляризациягребневая регрессия (ridge), L2-регуляризацияEN ridge regressionШтраф λ‖w‖² к функции потерь. В нейросетях та же идея называется weight decay. (ridge):

w=(X⊤X+λI)−1X⊤y.w = (X^\top X + \lambda I)^{-1} X^\top y .

Добавка λI\lambda I заодно делает матрицу обратимой, даже когда признаков больше, чем примеров (подробнее — в уроке 2.2.1). Вот полином 9-й степени на тех же 12 точках:

λ\lambdaMSE на trainMSE на test∥w∥\lVert w \rVert
00.00351.850361 661
10−610^{-6}0.00850.074137
10−410^{-4}0.01390.05220
10−310^{-3}0.02280.07710.8
10−210^{-2}0.04360.1184.6
10.16820.3380.6

Ошибка на train с ростом λ\lambda только растёт: модель теряет свободу. Ошибка на test описывает ту же U-кривую: λ=0\lambda = 0 — переобучение, λ=1\lambda = 1 — недообучение, оптимум около 10−410^{-4}. Крошечный штраф 10−610^{-6} уже уменьшил ошибку на test в 25 раз. Число λ\lambda — гиперпараметргиперпараметрEN hyperparameterНастройка, которую не учат градиентом, а выбирают по валидации — степень полинома, λ, learning rate, число слоёв.: градиентом его не найти, его выбирают по валидации.

Ещё три взгляда на тот же штраф:

  • Градиент. Производная λ∥w∥2\lambda \lVert w \rVert^2 равна 2λw2\lambda w. Каждый шаг спуска сначала умножает веса на (1−2ηλ)(1 - 2\eta\lambda), а потом делает обычный шаг (η\eta, «эта», — learning rate из урока 1.4.1). Например, при η=0.1\eta = 0.1 и λ=0.01\lambda = 0.01 веса на каждом шаге умножаются на 1−2⋅0.1⋅0.01=0.9981 - 2 \cdot 0.1 \cdot 0.01 = 0.998. Веса «затухают» — отсюда название weight decayзатухание весов (weight decay)EN weight decayНа каждом шаге веса умножаются на (1 − ηλ) — «притягиваются» к нулю. Для обычного SGD равносильно L2-регуляризации, для Adam — нет. («затухание весов»). В AdamW из урока 1.4.2 это затухание отделено от адаптивного шага. При предобучении GPT-3 и Llama брали weight decay = 0.1.
  • Вероятность (для любознательных, можно пропустить). L2-штраф — это априорное убеждение «до всяких данных», что веса небольшие: априорное распределение (prior) весов — нормальное с центром в нуле. Обучение с ним — уже не чистый метод максимального правдоподобия из урока 1.3.2, а поиск самой вероятной модели с учётом prior по правилу Байеса из урока 1.3.1 (MAP-оценка).
  • Практика. Свободный член bb обычно не штрафуют: сдвиг всех ответов на константу сложности не добавляет. В задании ниже для простоты штрафуются все коэффициенты, включая w0w_0. У L1-штрафа λ∑∣wj∣\lambda \sum |w_j| (сумма модулей вместо суммы квадратов) другое свойство: он обнуляет часть весов, то есть отбирает признаки. Dropout — ещё один регуляризатор нейросетей, о нём в уроке 3.4.2.

У итеративного обучения есть бесплатный регуляризатор — время. Веса стартуют с малых значений и растут по мере обучения. Если остановиться вовремя, они не успеют подстроиться под шум. Для линейных моделей можно показать, что ранняя остановкаранняя остановкаEN early stopping градиентного спуска действует почти как L2-штраф.

Алгоритм:

  1. После каждой эпохи считаем loss на валидации.
  2. Если он лучший за всё время — сохраняем веса (чекпоинт).
  3. Если pp эпох подряд (patience) улучшений нет — останавливаемся и откатываемся к лучшему чекпоинту.

У LLM та же логика. Чекпоинты сохраняют регулярно, а при SFT главный рычаг — число эпох: на небольших датасетах обычно хватает одной-трёх. Код — в практике ниже.

Валидационная выборка из 20 примеров даёт шумную оценку: повезёт с разбиением — модель выглядит лучше, не повезёт — хуже. Когда данных мало, используют кросс-валидация, перекрёстная проверкакросс-валидация, перекрёстная проверкаEN cross-validationСамый частый вариант — k-кратная кросс-валидация (k-fold cross-validation). по k блокам (k-fold):

  1. Перемешать данные и разрезать на kk равных частей (обычно k=5k = 5 или 10).
  2. Для каждой части jj: обучить модель на остальных k−1k - 1 частях, посчитать ошибку EjE_j на части jj.
  3. Оценка качества: CV=1k∑j=1kEj\mathrm{CV} = \frac{1}{k}\sum_{j=1}^{k} E_j.

Каждый пример ровно один раз побывал в валидации, и все данные поработали на обучение. Цена — kk обучений вместо одного. Выбираем степень полинома по 5-fold CV на 30 точках:

Степень0123456789
MSE на train0.4940.2870.2840.0310.0310.0250.0250.0240.0230.023
5-fold CV0.5320.3760.4250.0490.0490.0390.0470.0420.0520.170
Test (1000 точек)0.5470.2520.2550.0460.0460.0450.0450.0450.0480.051

Ошибка на train монотонно падает и выбрала бы степень 9. CV уверенно отбрасывает степени 0–2 и 9, а степени 3–7 для неё почти равны: разница в пределах шума. В таких случаях часто берут самую простую модель из почти равных. После выбора гиперпараметра модель обучают заново на всех данных.

Варианты: стратифицированная CV сохраняет доли классов в каждой части; групповая не разрывает группы (все снимки пациента в одной части); для временных рядов валидация всегда идёт после обучающего отрезка. Когда какую брать и насколько неверная схема завышает качество — в уроке 2.4.4. Для больших нейросетей CV слишком дорога, поэтому берут одну большую валидационную выборку.

утечка данныхутечка данныхEN data leakage — ситуация, когда в обучение или выбор модели просачивается информация, которой не будет при реальном применении. Результат всегда один: оценка лучше, чем реальность. Типичные источники:

  1. Тест внутри train. Дубликаты и почти-дубликаты: одна и та же фотография в разном размере, один текст с разной разметкой.
  2. Предобработка по всем данным. Среднее для нормализации, отбор признаков, заполнение пропусков, PCA посчитали до разбиения. Самый коварный вид — демонстрация ниже.
  3. Утечка целевой переменной. Признак, который знает ответ. Предсказываем уход клиента, а среди признаков — «дата расторжения договора». На тесте блестяще, в продакшене бесполезно.
  4. Время. Случайное разбиение временного ряда: модель учится на завтрашних данных, чтобы предсказать вчерашние.
  5. Группы. Один пользователь в train и в test: модель узнаёт пользователя, а не закономерность.

Насколько это опасно? Возьмём 100 объектов, 5000 признаков чистого шума и случайные метки. Предсказывать нечего, честная точность — 50%, как у монетки. Отберём 20 признаков, сильнее всего коррелирующих с меткой, и обучим простой классификатор. Два новых слова:

  • Корреляция признака с меткой — число, которое показывает, насколько признак «ходит вместе» с меткой: у объектов с меткой 1 он в среднем заметно больше (или меньше), чем у объектов с меткой 0. Если связи нет, корреляция около нуля. В коде мы считаем её модуль, то есть силу связи без учёта направления.
  • Классификатор «ближайший центр» — самый простой из возможных. Усредняем точки каждого класса и получаем два «центра», а новый объект относим к классу, чей центр ближе. Похожую идею вы ещё встретите в k-means (урок 2.3.2).

Код длинный, но читать его можно по комментариям: важны две строки, отмеченные стрелкой ←.

import numpy as np
rng = np.random.default_rng(0)
n, p, top = 100, 5000, 20 # 100 объектов, 5000 признаков, отбираем 20 лучших
X = rng.normal(size=(n, p)) # 5000 признаков чистого шума: таблица 100 × 5000
y = rng.integers(0, 2, size=n) # случайные метки 0/1: предсказывать НЕЧЕГО
def select(X, y, top):
"""Индексы признаков, сильнее всего коррелирующих с меткой."""
Xc = (X - X.mean(0)) / X.std(0) # стандартизуем каждый столбец (среднее 0, std 1)
corr = np.abs(Xc.T @ (y - y.mean())) / len(y) # сила связи каждого признака с меткой: 5000 чисел
return np.argsort(corr)[-top:] # argsort упорядочивает по возрастанию → берём 20 последних
def nearest_centroid(X_tr, y_tr, X_te):
"""Классификатор «ближайший центр»."""
c0, c1 = X_tr[y_tr == 0].mean(0), X_tr[y_tr == 1].mean(0) # центры классов 0 и 1 (маски — урок 0.3.1)
d0 = ((X_te - c0) ** 2).sum(1) # квадрат расстояния каждого объекта до центра 0
d1 = ((X_te - c1) ** 2).sum(1) # ... и до центра 1
return (d1 < d0).astype(int) # ближе к центру 1 → класс 1, иначе 0
def cv_accuracy(leaky, k=5):
"""Точность по 5-fold кросс-валидации (см. раздел выше)."""
folds = np.array_split(rng.permutation(n), k) # перемешанные номера, разрезанные на 5 частей по 20
feats_all = select(X, y, top) # ← отбор по ВСЕМ данным, включая валидацию (утечка)
accs = []
for i in range(k):
va = folds[i] # часть i — валидация
tr = np.concatenate([folds[j] for j in range(k) if j != i]) # остальные четыре — train
feats = feats_all if leaky else select(X[tr], y[tr], top) # ← честно: отбираем только по train
pred = nearest_centroid(X[tr][:, feats], y[tr], X[va][:, feats])
accs.append((pred == y[va]).mean()) # доля верных ответов на валидации
return np.mean(accs)
print(f"с утечкой: accuracy = {cv_accuracy(leaky=True):.2f}")
print(f"честно: accuracy = {cv_accuracy(leaky=False):.2f}")
с утечкой: accuracy = 0.88
честно: accuracy = 0.44

88% на чистом шуме. Среди 5000 случайных признаков всегда найдутся 20, которые случайно совпали с метками на этих 100 объектах, включая валидационные. Если отбирать признаки внутри каждого фолда, точность честно падает до уровня монетки. Правило: всё, что обучается, включая предобработку, обучается только на train. В scikit-learn для этого есть Pipeline (урок 2.4.4).

Соберите полиномиальную регрессию с L2-регуляризацией и выбор степени по валидации. В заготовке четыре функции с # TODO: mse, poly_features, fit_ridge и select_degree (функция predict уже готова). Делайте их по одной в этом порядке и после каждой нажимайте «Запустить». Когда всё будет готово, кнопка напечатает ошибки на train и val для степеней 0, 1, 3, 5, 9 и лучшую степень по валидации среди 0–9. Посмотрите, где начинается переобучение.

Новая для вас функция NumPy — np.linalg.solve(A, b): она находит вектор ww, для которого Aw=bA w = b. Это то же самое, что A−1bA^{-1} b, но считается точнее и быстрее, чем через обратную матрицу.

Полиномы: от недообучения к переобучениюnumpy · нужен интернет7 тестов
import numpy as np


def poly_features(x, degree):
    """Матрица [1, x, x², …, x^degree] формы (len(x), degree + 1).

    Пример: poly_features([2, 3], 2) → [[1, 2, 4], [1, 3, 9]]
    (строка — одна точка, столбец j — x в степени j).
    """
    # TODO: ваш код. Шаг 1: x = np.asarray(x, dtype=float) — на вход может прийти список.
    # Шаг 2: все степени сразу через broadcasting (урок 0.3.2).
    ...


def fit_ridge(x, y, degree, lam=0.0):
    """Коэффициенты полинома, минимизирующие ||Xw − y||² + lam·||w||².

    Решение в замкнутой форме: w = (XᵀX + lam·I)⁻¹ Xᵀy, где X = poly_features(x, degree).
    При lam = 0 это обычный метод наименьших квадратов.
    Пригодятся: np.eye(m) — единичная матрица m × m;
    np.linalg.solve(A, b) — решение уравнения A·w = b (то же, что inv(A) @ b, но точнее).
    """
    # TODO: ваш код
    ...


def predict(x, w):
    """Значения полинома с коэффициентами w в точках x (уже готово)."""
    return poly_features(x, len(w) - 1) @ w


def mse(y, y_hat):
    """Среднеквадратичная ошибка: среднее от (y − y_hat)². Верните float.

    Пример: mse([1, 2, 3], [1, 2, 5]) → (0 + 0 + 4) / 3 ≈ 1.3333
    """
    # TODO: ваш код
    ...


def select_degree(x_tr, y_tr, x_val, y_val, degrees, lam=0.0):
    """Обучите полином каждой степени из degrees на train и верните ту степень,
    у которой ошибка на ВАЛИДАЦИИ минимальна.

    План: для каждой степени d — w = fit_ridge(x_tr, y_tr, d, lam), ошибка mse(y_val, predict(x_val, w));
    затем верните ту d, у которой ошибка наименьшая (не номер в списке, а саму степень).
    """
    # TODO: ваш код
    ...


# «Запустить»: train- и val-ошибка для разных степеней
rng = np.random.default_rng(0)
x_tr = rng.uniform(0, 1, 15)
y_tr = np.sin(2 * np.pi * x_tr) + rng.normal(0, 0.2, 15)
x_val = rng.uniform(0, 1, 100)
y_val = np.sin(2 * np.pi * x_val) + rng.normal(0, 0.2, 100)
if poly_features([1.0], 1) is None or fit_ridge(x_tr, y_tr, 1) is None or mse([0.0], [0.0]) is None:
    print("Допишите poly_features, fit_ridge и mse — тогда здесь появится таблица ошибок.")
else:
    for d in [0, 1, 3, 5, 9]:
        w = fit_ridge(x_tr, y_tr, d)
        print(f"степень {d}: train {mse(y_tr, predict(x_tr, w)):.4f}   val {mse(y_val, predict(x_val, w)):.4f}")
    best = select_degree(x_tr, y_tr, x_val, y_val, list(range(10)))
    print("лучшая степень по валидации:", best if best is not None else "допишите select_degree")

После решения поэкспериментируйте: вызовите fit_ridge(x_tr, y_tr, 9, lam) для λ\lambda из списка [0, 1e-8, 1e-6, 1e-4, 1e-2, 1] и найдите лучшее значение по валидации. Данные в задании другие (15 точек), поэтому точного совпадения с таблицей урока не ждите. Но картина та же: при λ=0\lambda = 0 ошибка на val почти в 20 раз больше, чем при крошечном штрафе 10−610^{-6}, а при λ=1\lambda = 1 модель уже недообучена.

Запустите локально. Сеть на 66 тысяч параметров учится на 20 точках, шум со стандартным отклонением 0.3, то есть неустранимая ошибка 0.32=0.090.3^2 = 0.09. PyTorch и нейросети подробно разберём на ступени 3, а сейчас относитесь к сети как к «чёрному ящику» с настраиваемыми весами и следите только за логикой цикла: шаг обучения, замер на валидации, чекпоинт, счётчик терпения. Одна эпоха здесь — один шаг спуска по всем 20 точкам сразу. Строки, которые делают шаг обучения (zero_grad, backward, step), — стандартное заклинание PyTorch: вы встречали его в уроке 1.4.1, а разберёте до винтика в уроке 3.3.2.

import copy
import torch
from torch import nn
torch.set_num_threads(1) # для крошечной сети один поток быстрее
torch.manual_seed(2) # сид: при каждом запуске одни и те же «случайные» числа
f = lambda x: torch.sin(2 * torch.pi * x) # истинная зависимость — синус
x_tr = torch.rand(20, 1); y_tr = f(x_tr) + 0.3 * torch.randn(20, 1) # 20 обучающих точек с шумом
x_va = torch.rand(200, 1); y_va = f(x_va) + 0.3 * torch.randn(200, 1) # 200 валидационных
# «Чёрный ящик»: нейросеть из трёх линейных слоёв (ступень 3), ~66 тысяч весов
model = nn.Sequential(nn.Linear(1, 256), nn.ReLU(),
nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, 1))
opt = torch.optim.Adam(model.parameters(), lr=1e-3) # оптимизатор Adam из урока 1.4.2
loss_fn = nn.MSELoss() # функция потерь — MSE
best_val, best_epoch, best_state = float("inf"), 0, None # inf — «бесконечность»: любой loss будет лучше
patience, bad = 1000, 0 # сколько эпох терпим без улучшения; счётчик неудачных эпох
for epoch in range(1, 3001):
model.train() # режим обучения
opt.zero_grad() # стереть градиенты с прошлого шага
loss = loss_fn(model(x_tr), y_tr) # предсказания на train и их MSE
loss.backward() # посчитать градиенты по всем весам
opt.step() # сделать шаг спуска
model.eval() # режим оценки
with torch.no_grad(): # при замере градиенты не нужны
val = loss_fn(model(x_va), y_va).item() # .item() превращает тензор из одного числа в float
if val < best_val: # новый рекорд — запоминаем веса
best_val, best_epoch, bad = val, epoch, 0
best_state = copy.deepcopy(model.state_dict()) # копия всех весов — это и есть чекпоинт
else:
bad += 1 # ещё одна эпоха без улучшения
if epoch in (1, 50, 100, 150) or epoch % 250 == 0: # печатаем выборочно
print(f"эпоха {epoch:4d}: train {loss.item():.3f} val {val:.3f}")
if bad >= patience: # терпение кончилось — останавливаемся
print(f"стоп на эпохе {epoch}: лучшая была {best_epoch} (val {best_val:.3f})")
break
model.load_state_dict(best_state) # откатываемся к лучшим весам
эпоха 1: train 0.558 val 0.604
эпоха 50: train 0.131 val 0.185
эпоха 100: train 0.052 val 0.113
эпоха 150: train 0.046 val 0.102
эпоха 250: train 0.045 val 0.103
эпоха 500: train 0.041 val 0.107
эпоха 750: train 0.036 val 0.111
эпоха 1000: train 0.031 val 0.124
стоп на эпохе 1138: лучшая была 138 (val 0.102)

Первые полторы сотни эпох падают оба loss: модель выучивает саму синусоиду. Дальше train продолжает снижаться до 0.031 — это ниже уровня шума 0.09. Модель заучивает шум конкретных 20 точек, и val loss растёт. Лучшей была 138-я эпоха, к ней мы и откатились. Числа на другой версии PyTorch могут немного отличаться, картина — нет. Попробуйте добавить weight_decay=1e-2 в torch.optim.AdamW вместо Adam и сравните.

Проверь себя6 вопросов
1 MSE на train = 0.30, на validation = 0.31, уровень шума в данных ≈ 0.04. Что происходит?
2 Модель переобучилась: train loss 0.01, validation loss 0.9. Что из этого поможет?Можно выбрать несколько ответов
3 В точке xx смещение² = 0.01, разброс = 0.05, дисперсия шума σ2\sigma^2 = 0.04. Чему равна ожидаемая квадратичная ошибка на новых данных?
4 Признаки стандартизировали (вычли среднее, поделили на стандартное отклонение) по всему датасету, а потом разбили его на train и test. В чём проблема?
5 В датасете 1000 примеров, используем 5-fold кросс-валидацию. Сколько примеров в обучающей части каждого из пяти обучений?
6 Что будет с ridge-регрессией, если λ→∞\lambda \to \infty и штрафуются все веса?
  • Цель — обобщение. Недообучение: обе ошибки высоки. Переобучение: train низкая, validation высокая.
  • Ожидаемая ошибка = смещение² + разброс + шум. Сложность модели переливает одно в другое. Большие нейросети живут за пределами классической U-кривой (двойной спуск).
  • Кривые обучения подсказывают лекарство: при высоком смещении — сложнее модель, при высоком разбросе — больше данных и регуляризация.
  • L2 / weight decay, ранняя остановка и кросс-валидация — базовый набор против переобучения. λ\lambda, степень и число эпох выбирают по валидации.
  • Утечки делают оценку красивой и ложной: предобработку учим только на train, а у LLM следим за загрязнением бенчмарков.

Дальше: модуль 2.2 — линейная регрессия, логистическая регрессия и метрики качества с нуля. А классические алгоритмы — kNN, деревья, бустинг — ждут в модуле 2.3.