Python: от данных до своей сводки Урок 45 из 56
Обучение и проверка: почему нельзя мерить на своём
Сорок четвёртый урок курса по Python. Та же модель на тех же данных: 2.48 пункта ошибки на годах, которым её учили, и 35.99 на тех, которых она не видела. Случайный разрез вместо разреза по времени показывает 5.37 и врёт. Многочлен пятой степени учится почти идеально и ошибается втрое хуже прямой.
Зачем это нужно
Прошлый урок закончился признанием: обе модели мерились на тех же годах, на которых учились. Так меряют себя все — и это единственная проверка, которую модель проходит всегда.
Честная проверка стоит одной строки кода и меняет все выводы. Этот урок про то, как разрезать ряд по времени, почему случайный разрез здесь врёт и как выглядит переобучение, когда его наконец видно.
Сразу целиком
Файл proverka.py. Ряд тот же, что в прошлом уроке: индекс потребительских цен Казахстана, Всемирный банк.
"""Урок 44: обучение и проверка — почему нельзя мерить на своём.
Ряд тот же, что в прошлом уроке: индекс потребительских цен Казахстана,
2010 = 100, показатель FP.CPI.TOTL Всемирного банка.
"""
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import train_test_split
CPI = pd.Series({
2010: 100.00, 2011: 108.45, 2012: 114.09, 2013: 120.87, 2014: 129.15,
2015: 137.77, 2016: 157.56, 2017: 169.28, 2018: 179.72, 2019: 189.30,
2020: 202.02, 2021: 218.27, 2022: 251.07, 2023: 287.54, 2024: 312.53,
2025: 348.12,
})
YEARS = CPI.index.to_numpy()
VALUES = CPI.to_numpy()
# Годы уводим к нулю и делим на десять: большие числа в степенях дают
# неустойчивый счёт, а нам ниже понадобятся степени.
def feature(years, degree=1):
return np.vander((years - 2010) / 10, degree + 1)
def log_line(years, values):
"""Прямая по логарифму — та самая форма из прошлого урока."""
model = LinearRegression().fit(feature(years), np.log(values))
return lambda ask: np.exp(model.predict(feature(ask)))
print("== Разрез по времени")
past = YEARS <= 2021
guess = log_line(YEARS[past], VALUES[past])
on_train = mean_absolute_error(VALUES[past], guess(YEARS[past]))
on_test = mean_absolute_error(VALUES[~past], guess(YEARS[~past]))
print(" учим на:", int(YEARS[past].min()), "-", int(YEARS[past].max()),
f"({int(past.sum())} лет)")
print(" проверяем на:", [int(y) for y in YEARS[~past]])
print(" ошибка на обучении:", round(on_train, 2), "пункта")
print(" ошибка на проверке:", round(on_test, 2), "пункта")
print(" проверка хуже обучения в", round(on_test / on_train, 1), "раза")
print()
print("== Случайный разрез льстит")
y_train, y_test, v_train, v_test = train_test_split(
YEARS, VALUES, test_size=4, random_state=0)
shuffled = log_line(y_train, v_train)
print(" проверочные годы:", [int(y) for y in sorted(y_test)])
print(" ошибка на проверке:", round(mean_absolute_error(v_test, shuffled(y_test)), 2), "пункта")
print(" та же модель на разрезе по времени:", round(on_test, 2), "пункта")
print()
print("== Чем сложнее, тем хуже")
rows = []
for degree in (1, 2, 3, 5):
model = LinearRegression().fit(feature(YEARS[past], degree), VALUES[past])
rows.append({
"степень": degree,
"на обучении": round(mean_absolute_error(
VALUES[past], model.predict(feature(YEARS[past], degree))), 2),
"на проверке": round(mean_absolute_error(
VALUES[~past], model.predict(feature(YEARS[~past], degree))), 2),
})
table = pd.DataFrame(rows).set_index("степень")
print(table.to_string())
print()
print("== Что выбрать")
best_train = table["на обучении"].idxmin()
best_test = table["на проверке"].idxmin()
print(" по обучению лучшая степень:", best_train, "— ошибка на проверке",
table.loc[best_train, "на проверке"])
print(" по проверке лучшая степень:", best_test, "— ошибка на проверке",
table.loc[best_test, "на проверке"])
print(" прямая по логарифму:", round(on_test, 2), "— лучше всех многочленов")
Вывод:
== Разрез по времени
учим на: 2010 - 2021 (12 лет)
проверяем на: [2022, 2023, 2024, 2025]
ошибка на обучении: 2.48 пункта
ошибка на проверке: 35.99 пункта
проверка хуже обучения в 14.5 раза
== Случайный разрез льстит
проверочные годы: [2011, 2016, 2018, 2019]
ошибка на проверке: 5.37 пункта
та же модель на разрезе по времени: 35.99 пункта
== Чем сложнее, тем хуже
на обучении на проверке
степень
1 3.51 60.78
2 2.30 41.22
3 2.13 57.09
5 0.99 116.69
== Что выбрать
по обучению лучшая степень: 5 — ошибка на проверке 116.69
по проверке лучшая степень: 2 — ошибка на проверке 41.22
прямая по логарифму: 35.99 — лучше всех многочленов
Разбор
Проверка — это годы, которых модель не видела
ошибка на обучении: 2.48 пункта
ошибка на проверке: 35.99 пункта
Модель и данные те же, что в сорок третьем уроке. Изменилось одно: её учили на 2010–2021 и спросили про 2022–2025. Ошибка выросла в 14.5 раза.
Ни одно из этих чисел не «настоящее», а другое «поддельное». Просто они отвечают на разные вопросы. Ошибка на обучении говорит, насколько хорошо модель описала прошлое; ошибка на проверке — насколько она годится для того, ради чего её обычно строят.
Случайный разрез льстит, и вот почему
проверочные годы: [2011, 2016, 2018, 2019]
ошибка на проверке: 5.37 пункта
та же модель на разрезе по времени: 35.99 пункта
train_test_split перемешивает данные — по умолчанию и не спрашивая. На таблице клиентов это правильно. На ряде по времени это подлог: 2019 год оказывается проверочным, а 2018 и 2020 остаются в обучении, и модель не предсказывает 2019-й, а вставляет его между двумя известными точками. Задача из «что будет дальше» превращается в «что было посередине», а это несравнимо проще.
Хуже того, при случайном разрезе модель видит будущее: в обучение попадают годы после проверочных. В настоящей работе так не бывает никогда, и цифра 5.37 — это обещание, которого модель не сможет выполнить ни разу.
Правило: ряд по времени режут по времени. Последние годы — проверка, и только они.
Переобучение видно, только когда есть проверка
на обучении на проверке
1 3.51 60.78
2 2.30 41.22
3 2.13 57.09
5 0.99 116.69
Многочлен пятой степени описывает прошлое почти идеально — ошибка меньше пункта. На годах, которых он не видел, он ошибается на 117 пунктов: он выучил не форму ряда, а его частности, вплоть до случайных.
Это и есть переобучение, и заметьте: по левому столбцу его не видно. Там всё выглядит как улучшение. Переобучение — это не свойство модели, которое можно рассмотреть; это разница между двумя столбцами.
Выбирают по валидации, а тест оставляют на конец
по обучению лучшая степень: 5 — ошибка на проверке 116.69
по проверке лучшая степень: 2 — ошибка на проверке 41.22
Если выбирать модель по тому, как она описала прошлое, выбор падает на худшую из имеющихся. Разница в задании считается числом: 75 пунктов — цена одного неправильного критерия.
И отдельно: прямая по логарифму, у которой правильная форма, даёт на проверке 35.99 и обыгрывает все многочлены. Форма важнее сложности — тот же вывод, что в прошлом уроке, но теперь он получен на данных, которых модель не видела.
Чего этот счёт не умеет
Проверочных лет четыре. Это лучше, чем ноль, и всё же мало: попади в них один спокойный год вместо 2022-го, числа были бы другими. Честный вывод из такой проверки — «модель ошибается на десятки пунктов», а не «модель ошибается на 35.99».
В этом учебном примере последние четыре года служат валидацией: по ним сравнивают формы модели. Независимого теста после такого выбора уже не остаётся, поэтому эти ошибки нельзя называть окончательной оценкой качества. В реальной работе тестовый период откладывают отдельно и смотрят на него один раз после выбора модели.
И ещё одно, о чём стоит помнить с этого урока: валидационные годы тратятся. Если подбирать модель, глядя на проверку, и так десять раз, то проверка постепенно превращается в обучение — просто более медленным способом.
Карта урока
Скажите своими словами
Ответьте вслух или на бумаге, не подглядывая. Ответы — в конце урока.
- Почему ошибка на обучении почти всегда меньше ошибки на проверке?
- Чем плох случайный разрез на ряде по времени?
- Как выглядит переобучение в двух столбцах ошибок?
Разминка
Три коротких шага перед заданием: предсказать, дополнить, починить. Ответы — в конце урока, но сначала ответьте сами.
1. Предскажите. Какие годы попадут в проверку и будут ли они идти подряд?
import numpy as np
from sklearn.model_selection import train_test_split
years = np.arange(2010, 2026)
train, test = train_test_split(years, test_size=4, random_state=0)
print("проверочные годы:", [int(y) for y in sorted(test)])
print("идут ли они подряд:", sorted(int(y) for y in test) == list(range(2022, 2026)))
2. Заполните пропуск. Вместо ... отделите последние четыре года без всякой случайности.
# последние четыре года — проверочные, остальное — обучение
import numpy as np
years = np.arange(2010, 2026)
past = ...
print("обучение:", int(years[past].min()), "-", int(years[past].max()))
print("проверка:", [int(y) for y in years[~past]])
3. Почините. Программа печатает одну ошибку и называет её качеством модели.
# качество модели — это две ошибки, а не одна
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error
years = np.arange(2010, 2026).reshape(-1, 1)
values = np.array([100.0, 108.45, 114.09, 120.87, 129.15, 137.77, 157.56, 169.28,
179.72, 189.3, 202.02, 218.27, 251.07, 287.54, 312.53, 348.12])
model = LinearRegression().fit(years, values)
print("ошибка:", round(mean_absolute_error(values, model.predict(years)), 2))
Задание
Обязательное. Разрежьте ряд по времени: последние четыре года — проверочные. Напечатайте состав обеих частей и отдельной строкой убедитесь, что ни один год не попал в обе. Обучите прямую по логарифму, напечатайте обе ошибки и во сколько раз проверка хуже. Затем пройдите степени многочлена от первой до пятой и напечатайте таблицу двух ошибок; назовите степень, выбранную по обучению, степень, выбранную по проверке, и цену неправильного выбора в пунктах. В конце повторите разрез случайно с тремя разными семенами и покажите ошибки рядом с честной.
Ожидаемый вывод:
== Разрез по времени
обучение: [2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021]
проверка: [2022, 2023, 2024, 2025]
годы, попавшие в обе части: нет
прямая по логарифму: обучение 2.48 — проверка 35.99
проверка хуже в 14.5 раза
== Степени многочлена
на обучении на проверке
степень
1 3.51 60.78
2 2.30 41.22
3 2.13 57.09
4 2.13 52.01
5 0.99 116.69
выбор по обучению: 5 — на проверке она даёт 116.69
выбор по проверке: 2 — на проверке она даёт 41.22
цена неправильного выбора: 75.47 пункта
== Случайный разрез, три попытки
seed 0: проверочные годы [2011, 2016, 2018, 2019] — ошибка 5.37
seed 1: проверочные годы [2012, 2013, 2017, 2023] — ошибка 4.54
seed 42: проверочные годы [2010, 2011, 2015, 2024] — ошибка 7.68
по времени: 35.99 — и это единственная честная цифра
Готово — это: вывод совпадает построчно; проверка на утечку печатается всегда, а не только когда утечка есть; степень выбирается кодом по каждому из двух критериев отдельно; случайные разрезы сделаны с явно указанными семенами, иначе числа не повторятся.
На своих данных. Возьмите свой ряд по времени и отрежьте последнюю пятую часть. Обучите что угодно — хоть среднее — на первой части и посмотрите ошибку на второй. Почти всегда она оказывается заметно больше той, что вы видели раньше, и это нормально: вы впервые померили то, что собирались.
По желанию.
- Сдвиньте границу разреза на 2019 год и посмотрите, как изменятся обе ошибки: проверка станет длиннее, а 2022-й попадёт в неё.
- Проверьте модель скользящим окном: учить на годах до N, спрашивать про N + 1, и так для всех N. Получится ряд ошибок вместо одного числа.
- Посмотрите, что делает
train_test_split(..., shuffle=False): разрез станет по порядку, и это ближайший к честному вариант из готовых.
Куда это встанет в проекте
Шаг двадцать третий: сводка спрашивает свою модель про год, которого та не видела.
esep.trend теперь учит прямую второй раз — без последнего года — и спрашивает про него. На странице стоят обе ошибки: Казахстан 3.55 на своих годах и 4.85 на чужом, Узбекистан 1.02 и 2.56. У России наоборот: 2.95 и 1.32, на невиданном году точнее. Так бывает, когда проверочная точка одна, и именно поэтому столбец не называется «настоящей ошибкой»: один отложенный год — это начало проверки, а не приговор.
Прогноза по-прежнему нет. Прямая, которая промахивается на пять пунктов по известному году, не становится надёжной насчёт неизвестного оттого, что её вежливо попросили.
Заодно в этом шаге починено хранилище. saqtau.save писал ставки с двумя знаками после запятой, и один и тот же отчёт говорил 3.55 на прогоне, который сходил в сеть, и 3.56 на прогоне, который прочитал файл. Для ставки инфляции двух знаков хватает, для прямой через шестнадцать таких ставок — уже нет. Хранилище, которое меняет ответ, — не хранилище.
Ответы
Показать ответы
На вопросы
- Потому что на обучении модель подбирала свои коэффициенты под эти самые точки — она видела ответы. На проверке она отвечает впервые. Разрыв в полтора-два раза обычен; разрыв в четырнадцать раз, как здесь, означает, что модель описывала прошлое, а не закономерность.
- Тем, что перемешивание ставит проверочный год между известными, и задача «предсказать» подменяется задачей «вставить пропущенное». Вдобавок в обучение попадают годы, которые в реальности наступят позже проверочных, — модель видит будущее. Цифра выходит красивая и невыполнимая.
- Левый столбец падает, правый растёт. Отдельно левый столбец про переобучение не говорит ничего: у пятой степени он лучший из всех, а на проверке эта модель худшая.
К разминке
train_test_splitперемешивает по умолчанию, поэтому годы разбросаны по всему ряду и подряд не идут.
проверочные годы: [2011, 2016, 2018, 2019]
идут ли они подряд: False
years <= 2021. Сравнение с годом, а не случайность: границу разреза видно в коде, и она не меняется от запуска к запуску.
# последние четыре года — проверочные, остальное — обучение
import numpy as np
years = np.arange(2010, 2026)
past = years <= 2021
print("обучение:", int(years[past].min()), "-", int(years[past].max()))
print("проверка:", [int(y) for y in years[~past]])
обучение: 2010 - 2021
проверка: [2022, 2023, 2024, 2025]
- Модель училась на всём ряде, поэтому напечатанная ошибка — это ошибка на обучении. Ряд надо разрезать и печатать обе.
# ошибку меряют на годах, которых модель не видела
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error
years = np.arange(2010, 2026).reshape(-1, 1)
values = np.array([100.0, 108.45, 114.09, 120.87, 129.15, 137.77, 157.56, 169.28,
179.72, 189.3, 202.02, 218.27, 251.07, 287.54, 312.53, 348.12])
past = (years <= 2021).ravel()
model = LinearRegression().fit(years[past], values[past])
print("на обучении:", round(mean_absolute_error(values[past], model.predict(years[past])), 2))
print("на проверке:", round(mean_absolute_error(values[~past], model.predict(years[~past])), 2))
на обучении: 3.51
на проверке: 60.78
К заданию
Строка про утечку печатается всегда — и это не формальность. Утечка редко выглядит как ошибка: код работает, числа выходят красивые, и единственный признак — что они слишком красивые. Проверка, которая печатает «нет» каждый раз, стоит дёшево, а находит то, что иначе не находится вовсе.
Три случайных разреза дают 5.37, 4.54 и 7.68 — все три в несколько раз меньше честных 35.99. Обратите внимание, что они и между собой не совпадают: от семени зависит не только красота цифры, но и сама цифра. Когда результат зависит от случайного числа, его печатают вместе с этим числом.
Источники
- train_test_split, scikit-learn — параметры
shuffleиrandom_state, из-за которых всё это и происходит. - Кросс-валидация на временных рядах, scikit-learn —
TimeSeriesSplit, готовый разрез по времени. - Индекс потребительских цен, Всемирный банк — ряд, на котором построен урок.
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.