Shanraq.org Shanraq.org
Обучение и проверка: почему нельзя мерить на своём
IT

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. Почему ошибка на обучении почти всегда меньше ошибки на проверке?
  2. Чем плох случайный разрез на ряде по времени?
  3. Как выглядит переобучение в двух столбцах ошибок?

Разминка

Три коротких шага перед заданием: предсказать, дополнить, починить. Ответы — в конце урока, но сначала ответьте сами.

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 на прогоне, который прочитал файл. Для ставки инфляции двух знаков хватает, для прямой через шестнадцать таких ставок — уже нет. Хранилище, которое меняет ответ, — не хранилище.

Ответы

Показать ответы

На вопросы

  1. Потому что на обучении модель подбирала свои коэффициенты под эти самые точки — она видела ответы. На проверке она отвечает впервые. Разрыв в полтора-два раза обычен; разрыв в четырнадцать раз, как здесь, означает, что модель описывала прошлое, а не закономерность.
  2. Тем, что перемешивание ставит проверочный год между известными, и задача «предсказать» подменяется задачей «вставить пропущенное». Вдобавок в обучение попадают годы, которые в реальности наступят позже проверочных, — модель видит будущее. Цифра выходит красивая и невыполнимая.
  3. Левый столбец падает, правый растёт. Отдельно левый столбец про переобучение не говорит ничего: у пятой степени он лучший из всех, а на проверке эта модель худшая.

К разминке

  1. train_test_split перемешивает по умолчанию, поэтому годы разбросаны по всему ряду и подряд не идут.
проверочные годы: [2011, 2016, 2018, 2019]
идут ли они подряд: False
  1. 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]
  1. Модель училась на всём ряде, поэтому напечатанная ошибка — это ошибка на обучении. Ряд надо разрезать и печатать обе.
# ошибку меряют на годах, которых модель не видела
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. Обратите внимание, что они и между собой не совпадают: от семени зависит не только красота цифры, но и сама цифра. Когда результат зависит от случайного числа, его печатают вместе с этим числом.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.