Перейти к содержимому
Ступень 22.4 Практический ML на табличных данных

Проект: честная валидация и подбор гиперпараметров

Сквозной проект на scikit-learn: утечки и их цена в числах, Pipeline и ColumnTransformer, KFold, StratifiedKFold, GroupKFold и TimeSeriesSplit, случайный и байесовский поиск, сравнение моделей с учётом разброса, калибровка и одна финальная проверка на отложенном тесте.

УРОКstage-2/2-4-tabular-practice/04-tabular-pipeline.mdx2 ч 30 минУрок 4 из 4
После урока вы сможете
  • Собрать Pipeline с ColumnTransformer, в котором препроцессинг обучается только на train каждого фолда
  • Выбрать стратегию кросс-валидации под задачу (KFold, StratifiedKFold, GroupKFold, TimeSeriesSplit) и показать на числах, как неверная стратегия завышает качество
  • Подобрать гиперпараметры случайным поиском и Optuna, понимая, почему лучший результат поиска оптимистичен
  • Сравнить модели по фолдам с учётом разброса, проверить калибровку вероятностей и написать итоговый отчёт с одной проверкой на тесте

Коллега приносит модель оттока клиентов: ROC-AUC 0.96. Через месяц в продакшене она еле отличает уходящих от остающихся. В его коде нет ни одной синтаксической ошибки — есть ошибка оценки: модель проверяли не так, как её будут применять. Это самая дорогая ошибка в ML, потому что она не падает с исключением, а выглядит как успех.

Сегодня соберём всё, что было в модуле, в один проект и научимся оценивать модель честно: какие утечки опасны на самом деле, как устроить кросс-валидацию под задачу, как подбирать гиперпараметры, не обманывая себя, как сравнивать модели с учётом разброса и проверять вероятности. Те же правила потом работают для LLM: бенчмарк — это тестовая выборка, подбор промпта по нему — переобучение, а абляции в выпускном проекте (ступень 10) без повторов с разными seed — гадание.

Сквозной датасет — синтетический отток клиентов телеком-оператора: 6000 клиентов, регион, тариф, стаж, плата, обращения в поддержку, автоплатёж, возраст и дни с последнего входа (с пропусками), а ещё столбец retention_offer — «отдел удержания предложил скидку». Ушло 19.4% клиентов. Генератор — в задании ниже.

Вспомним роли из урока 2.1.1: train — подбирать веса, validation — выбирать модель и гиперпараметры, test — один раз измерить итог. На маленьких данных validation заменяют кросс-валидацией на train (урок 2.1.2).

flowchart LR
A["Все данные"] --> B["dev, 80%:<br/>всё обучение и все решения"]
A --> C["test, 20%:<br/>отложен и не трогается"]
B --> D["кросс-валидация:<br/>сравнение моделей,<br/>подбор гиперпараметров"]
D --> E["лучшая конфигурация,<br/>обученная на всём dev"]
E --> F["одна проверка на test<br/>→ число в отчёт"]
C --> F
Схема честного проекта: тест открывают один раз, в самом конце

Почему тест — только один раз? Каждый раз, когда вы смотрите на тест и что-то меняете (признак, порог, гиперпараметр), вы немножко подгоняете модель под него, и тест становится ещё одной валидацией. На Kaggle это видно наглядно: участники, которые подбирали решения по публичной таблице лидеров, падают на десятки мест, когда открывается закрытая часть теста. В китайском сообществе подгонку под лидерборд называют «шуабан» — «накручивать рейтинг» (урок 2.1.2). Симуляция такой перетряски и атака Блюма–Хардта, которая поднимается по публичному лидерборду, не глядя в данные, — в уроке 11.4.3.

Утечкаутечка данныхEN data leakage бывает разного калибра. Измерим её цену на наших данных (5-fold кросс-валидация на dev, ROC-AUC; эксперименты с группами и временем — на отдельной синтетике, описанной ниже):

Что сделалиОценка по CVЧестная оценка
Признак-следствие цели (retention_offer) в модели0.9610.780
Стандартизация и заполнение пропусков по всему dev до разбиения на фолды0.77990.7799
Наивный target encoding редкой категории (урок 2.4.2)0.919 на шуме0.529
Строки одного клиента в train и validation (KFold вместо GroupKFold)0.8750.601
Временной ряд: случайный KFold вместо «прошлое → будущее», RMSE1.736.08

Неожиданная строка — вторая: «классическая» утечка через стандартизацию по всем данным на 4800 строках не изменила AUC даже в четвёртом знаке. Среднее и разброс признака почти одинаковы на любом большом куске данных. Это не повод так делать — на маленьких данных и при отборе признаков эффект огромный (эксперимент урока 2.1.2 дал 88% на шуме), — но повод понимать: самые разрушительные утечки — признаки, которых не будет в момент прогноза, и неверная схема разбиения. Защита от всех сразу — Pipeline плюс правильная кросс-валидация. Утечка — лишь один из тихих багов, которые не роняют код, а портят метрику; их каталог для рабочего ML-кода — в уроке 11.1.2.

конвейерконвейер (Pipeline)EN ML pipeline (scikit-learn Pipeline)Препроцессинг и модель как один объект с fit/predict. В кросс-валидации все шаги обучаются только на train-части фолда — утечки через предобработку исключены. склеивает препроцессинг и модель в один объект с методами fit и predict. ColumnTransformer применяет к разным столбцам разную обработку:

from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
df = make_churn(6000, seed=0) # генератор — из задания в конце урока
X_dev, X_test, y_dev, y_test = train_test_split( # тест откладываем первым делом и не трогаем
df.drop(columns=["churn"]), df["churn"], test_size=0.2, stratify=df["churn"], random_state=0)
NUM = ["tenure_months", "monthly_fee", "support_calls", "autopay", "age", "days_since_login"]
CAT = ["region", "tariff"]
prep = ColumnTransformer([
("num", Pipeline([("impute", SimpleImputer(strategy="median")), # медиана — по данным fit
("scale", StandardScaler())]), NUM), # среднее и std — тоже
("cat", OneHotEncoder(handle_unknown="ignore"), CAT), # новый регион → нули, а не ошибка
])
pipe = Pipeline([("prep", prep), ("model", LogisticRegression(max_iter=1000))])

Столбцы, не названные в ColumnTransformer (customer_id, retention_offer), по умолчанию отбрасываются — утечка в модель не попадёт. Когда cross_val_score(pipe, X_dev, y_dev, cv=5) обучает модель на четырёх фолдах, весь Pipeline вызывает fit только на них: медианы, средние и список категорий берутся из train-части, а валидация лишь проходит через transform. Утечки через препроцессинг становятся невозможными по построению. Ещё два плюса: в продакшен уходит один объект (никто не забудет отмасштабировать признаки), а гиперпараметры любого шага доступны по имени шаг__параметр: model__C, prep__num__impute__strategy. По этим именам их перебирает поиск.

Главный вопрос при выборе схемы: как модель будут применять? Валидация должна повторять это применение.

СхемаКак режет данныеКогда брать
KFold(shuffle=True)случайно на kk частейстроки независимы, баланс классов не важен
StratifiedKFoldстратифицированная кросс-валидацияEN stratified k-foldКаждый фолд сохраняет доли классов всей выборки; стандарт для классификации, особенно при дисбалансе.так же, но доля классов в каждом фолде как в данныхклассификация, особенно при дисбалансе
GroupKFoldгрупповая кросс-валидация (GroupKFold)EN group k-foldВсе строки одной группы (клиента, пациента, компании) попадают в один фолд. Нужна, когда в будущем модель увидит новые группы.все строки группы — в одном фолденесколько строк на клиента, пациента, компанию; в будущем — новые группы
TimeSeriesSplitкросс-валидация для временных рядовEN time series split (forward chaining)Обучение на прошлом, валидация на следующем отрезке времени; модель никогда не видит будущего.обучение на прошлом, валидация на следующем отрезкепрогноз будущего: продажи, нагрузка, отток по месяцам

Как выглядят фолды на 12 строках (номера строк):

StratifiedKFold(3): в каждом фолде 3 нуля и 1 единица (а KFold может дать 0 единиц в фолде)
GroupKFold(3), группы 0,0,0,1,1,2,2,2,3,3,4,4: валидация — {1, 2}, затем {0}, затем {3, 4}
TimeSeriesSplit(3):
train [0, 1, 2] val [3, 4, 5]
train [0, 1, 2, 3, 4, 5] val [6, 7, 8]
train [0, 1, 2, 3, 4, 5, 6, 7, 8] val [9, 10, 11]

Группы на числах. 400 клиентов по 5 месячных снимков, метка — уйдёт ли клиент. Признаки: зашумлённый сигнал и постоянная для клиента «характеристика устройства» — по сути отпечаток клиента. LightGBM с мелкими листьями по случайному KFold даёт AUC 0.875: строки того же клиента есть в train, и модель узнаёт его по отпечатку. GroupKFold даёт 0.601 — столько модель стоит на новых клиентах. Без столбца-отпечатка обе схемы дают около 0.61: отпечаток лишь помогал «вспомнить» ответ.

Время на числах. 1500 дней спроса, который блуждает вверх-вниз, плюс эффект выходных. Модель по номеру дня на случайном KFold получает RMSE 1.73: для любого дня в train есть соседи слева и справа, и она просто интерполирует. На TimeSeriesSplit (обучение на прошлом, проверка на следующих 100 днях) — RMSE 6.08. Честная модель на признаках «вчера» и «неделю назад» даёт 2.82 на TimeSeriesSplit — и проигрывает наивному правилу «завтра как вчера» (2.37). Без правильной схемы и без простого бейзлайна этого не увидеть.

Перебор по сетке (GridSearchCV) пробует все комбинации из заданных списков. Случайный поиск (RandomizedSearchCV) берёт nn случайных точек из распределений. Бергстра и Бенджио (2012) показали, почему второй обычно лучше: важны лишь 1–2 гиперпараметра из многих. Сетка 3 × 3 = 9 попыток по learning_rate и num_leaves перепробует всего 3 значения learning_rate (0.01, 0.1, 0.3), а 9 случайных попыток — 9 разных: 0.011, 0.011, 0.025, 0.064, 0.079, 0.087, 0.12, 0.159, 0.223. Масштабные параметры (learning rate, коэффициент регуляризации) берут из логарифмически равномерного распределения loguniform: значения от 0.01 до 0.1 выпадают так же часто, как от 0.1 до 1.

import lightgbm as lgb
from scipy.stats import loguniform, randint, uniform
from sklearn.model_selection import RandomizedSearchCV, StratifiedKFold
lgbm = Pipeline([("prep", ColumnTransformer([("num", "passthrough", NUM), # деревьям масштаб не нужен
("cat", OneHotEncoder(handle_unknown="ignore"), CAT)])),
("model", lgb.LGBMClassifier(n_jobs=1, verbose=-1, random_state=0))])
space = {"model__num_leaves": randint(4, 64), "model__learning_rate": loguniform(0.01, 0.3),
"model__min_child_samples": randint(5, 100), "model__n_estimators": randint(100, 600),
"model__subsample": uniform(0.5, 0.5), "model__subsample_freq": [1],
"model__colsample_bytree": uniform(0.5, 0.5)} # uniform(a, w) — от a до a + w
search = RandomizedSearchCV(lgbm, space, n_iter=30, scoring="roc_auc", random_state=0,
cv=StratifiedKFold(5, shuffle=True, random_state=0)) # n_jobs=-1 — на всех ядрах
search.fit(X_dev, y_dev)
print(round(search.best_score_, 4), search.best_params_)

30 конфигураций × 5 фолдов = 150 обучений, около двух минут на одном потоке (с n_jobs=-1 на 48 ядрах — секунды). Лучшая — 0.765 ± 0.017 по фолдам, медианная — 0.742, худшая — 0.699: подбор важен. Но запомним цифру 0.765.

Байесовская оптимизация тратит попытки умнее: по уже проверенным точкам строит дешёвую модель-заменитель «гиперпараметры → качество» и выбирает следующую точку там, где ожидает улучшения — балансируя между уточнением хороших мест и разведкой неизвестных. Популярная реализация — Optuna (Preferred Networks); её алгоритм по умолчанию, TPE, моделирует распределения «хороших» и «плохих» точек. Локально: pip install optuna.

import optuna
from sklearn.model_selection import cross_val_score
def objective(trial):
model = lgb.LGBMClassifier(
n_estimators=trial.suggest_int("n_estimators", 100, 600),
learning_rate=trial.suggest_float("learning_rate", 0.01, 0.3, log=True), # лог-шкала, как loguniform
num_leaves=trial.suggest_int("num_leaves", 4, 64),
min_child_samples=trial.suggest_int("min_child_samples", 5, 100),
subsample=trial.suggest_float("subsample", 0.5, 1.0), subsample_freq=1,
colsample_bytree=trial.suggest_float("colsample_bytree", 0.5, 1.0),
n_jobs=1, verbose=-1, random_state=0)
return cross_val_score(lgbm.set_params(model=model), X_dev, y_dev, scoring="roc_auc",
cv=StratifiedKFold(5, shuffle=True, random_state=0)).mean()
optuna.logging.set_verbosity(optuna.logging.WARNING) # без лога каждой попытки
study = optuna.create_study(direction="maximize", sampler=optuna.samplers.TPESampler(seed=0))
study.optimize(objective, n_trials=30)
print(round(study.best_value, 4)) # 0.7646

На 30 попытках TPE пришёл к тому же уровню, что и случайный поиск: выигрыш байесовских методов заметен, когда попыток сотни, а одно обучение дорогое.

Выбрав максимум из 30 оценок, мы выбрали и ту конфигурацию, которой повезло на этих фолдах. Крайний случай: 50 случайных конфигураций LightGBM на чистом шуме, валидация из 100 строк. Лучшая показала на валидации ROC-AUC 0.644, а на тесте из 1000 строк — 0.465. В нашем проекте эффект скромнее: лучшая конфигурация обещала 0.765 по CV, а на отложенном тесте дала 0.747. Поэтому оценка, по которой выбирали, не идёт в отчёт. В отчёт идёт тест — или вложенная кросс-валидациявложенная кросс-валидацияEN nested cross-validationВнешний цикл оценивает качество, внутренний на каждой train-части заново подбирает гиперпараметры — честная оценка всей процедуры подбора.: внешний цикл оценивает, внутренний на каждой train-части заново подбирает гиперпараметры.

Сравним логистическую регрессию и LightGBM на одних и тех же фолдах: 5-fold, повторённый дважды с разным перемешиванием (RepeatedStratifiedKFold(n_splits=5, n_repeats=2)):

Среднее ROC-AUCStd по 10 фолдам
Логистическая регрессия0.78150.0257
LightGBM (300 деревьев, 15 листьев, learning_rate 0.05)0.74100.0240
Разность LightGBM − логрег по фолдам−0.04040.0144

Разброс каждой модели по фолдам (0.025) больше половины разницы между ними. Если сравнивать просто два средних с их разбросом, вывод был бы шатким. Но фолды одни и те же, и парные разности стабильны: LightGBM проиграл на всех 10 фолдах из 10. Это то же парное сравнение двух моделей на одних и тех же вопросах, что в уроке 1.3.4. Оговорка: оценки по фолдам не независимы (обучающие части пересекаются), поэтому формальные доверительные интервалы по ним получаются слишком узкими — относитесь к std как к ориентиру.

Почему бустинг проиграл? В нашем генераторе вероятность ухода — логистическая функция от признаков: ровно модель логистической регрессии. Бустинг сильнее на сложных нелинейных зависимостях, а здесь простая модель угадала форму. Правило проекта: всегда начинайте с простого бейзлайна — и только потом решайте, окупается ли сложная модель. На отложенном тесте: логистическая регрессия 0.761, подобранный LightGBM 0.747 (бутстрэп-интервал 95% для LightGBM — от 0.714 до 0.781: на 1200 строках точность оценки около ±0.03).

Метрику выбирают до экспериментов, исходя из того, как будут использовать прогноз (урок 2.2.3). ROC-AUC оценивает ранжирование: кого обзвонить первыми. Если же вероятности используют как числа — «ожидаемые потери = вероятность × выручка клиента» — важна калибровкакалибровка вероятностейEN probability calibrationСреди объектов с прогнозом p событие происходит примерно в доле p случаев. Проверяют кривой калибровки и Brier score; чинят изотонической регрессией или сигмоидой Платта.: среди клиентов с прогнозом 0.3 должно уходить около 30%. Её проверяют кривой калибровки — средний прогноз против реальной доли по корзинам — и Brier score, это MSE между вероятностью и меткой 0/1 (меньше — лучше).

Частая поломка калибровки — балансировка классов. С class_weight="balanced" LightGBM «думает», что уходящих столько же, сколько остающихся. Тест, 5 корзин по квантилям прогноза:

МодельСредний прогноз в корзинахРеальная доля ушедшихСредняя вероятностьBrier
LightGBM0.03 · 0.07 · 0.14 · 0.24 · 0.510.05 · 0.08 · 0.22 · 0.19 · 0.430.1970.139
то же, class_weight="balanced"0.07 · 0.19 · 0.35 · 0.52 · 0.760.05 · 0.09 · 0.16 · 0.27 · 0.400.3780.186
balanced + CalibratedClassifierCV(method="isotonic")0.04 · 0.10 · 0.17 · 0.25 · 0.440.06 · 0.09 · 0.16 · 0.25 · 0.410.2010.137

Доля ушедших — 0.194. Сбалансированная модель в среднем предсказывает 0.378, почти вдвое больше правды, при почти том же AUC (0.738 против 0.739): ранжирование не пострадало, числа — да. CalibratedClassifierCV по кросс-валидации учит монотонное преобразование прогнозов (изотоническая регрессия или сигмоида Платта) и возвращает вероятности к реальности.

Когда все решения приняты по кросс-валидации, лучшая конфигурация обучается на всём dev и один раз проверяется на тесте. Итоговый отчёт — короткий и проверяемый:

РазделЧто написать
Данныеисточник, число строк, доля классов, что нашли разведкой и что почистили
Схема валидацииholdout-тест (сколько и как отложен), вид кросс-валидации и почему он похож на применение
Метрикакакая и почему; бейзлайн (константа, простая модель)
Моделикандидаты, оценки по фолдам (среднее ± std), парное сравнение
Гиперпараметрыпространство поиска, число попыток, найденные значения
Тестодно число с доверительным интервалом (бутстрэп); калибровка, если нужны вероятности
Ограниченияна каких данных модель не проверена, какие утечки исключены и как
  • Бенчмарк — тестовая выборка. Подбирать промпт, температуру или чекпоинт по тесту — переобучение; для этого заводят dev-часть (урок 7.3).
  • Загрязнение бенчмарков (урок 2.1.2) — утечка «тест внутри train» в масштабе интернета.
  • Вопросы одного документа или одной задачи — группа: их нельзя разносить между dev и test.
  • Абляции и сравнения моделей — по нескольким seed и с парными разностями на одних и тех же вопросах.
  • «0.2 пункта лучше на бенчмарке из 500 вопросов» — внутри шума (урок 1.3.4).

Задание: найти утечку и собрать честный пайплайн

Заголовок раздела «Задание: найти утечку и собрать честный пайплайн»

В заготовке — генератор данных и «слишком хорошее» решение коллеги с AUC 0.96. Найдите в нём три ошибки, выбросите идентификатор и утечку, отложите стратифицированный тест, соберите Pipeline, подберите C случайным поиском только по train и один раз проверьте на тесте. Тесты требуют, чтобы AUC на тесте попал в коридор 0.70–0.86 (выше — осталась утечка, ниже — сломан пайплайн) и чтобы оценка по кросс-валидации была близка к тесту. В браузере экономим время: 4 кандидата × 3 фолда; локально берите больше.

Честный пайплайн вместо «слишком хорошего» решенияpandas, scikit-learn · нужен интернет7 тестов
import numpy as np
import pandas as pd
from scipy.stats import loguniform
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import RandomizedSearchCV, StratifiedKFold, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler


def make_churn(n=1500, seed=0):
    """Синтетический отток клиентов телеком-оператора (все числа условные). Цель — churn (1 = ушёл)."""
    rng = np.random.default_rng(seed)
    region = rng.choice(["Москва", "Казань", "Самара", "Новосибирск", "Владивосток", "Калининград"], n,
                        p=[0.35, 0.15, 0.15, 0.15, 0.1, 0.1])
    tariff = rng.choice(["basic", "plus", "pro"], n, p=[0.5, 0.35, 0.15])
    tenure = rng.integers(1, 73, n)
    fee = (pd.Series(tariff).map({"basic": 500, "plus": 900, "pro": 1600}).to_numpy() + rng.normal(0, 120, n)).round(0)
    calls = rng.poisson(1.2, n)
    autopay = (rng.random(n) < 0.6).astype(int)
    age = rng.integers(18, 76, n).astype(float)
    days = rng.exponential(10, n).round(0)
    region_eff = pd.Series(region).map({"Москва": 0.0, "Казань": -0.2, "Самара": 0.1, "Новосибирск": 0.2,
                                         "Владивосток": 0.5, "Калининград": -0.3}).to_numpy()
    logit = (-0.9 - 0.035 * tenure + 0.0008 * (fee - 900) + 0.45 * calls - 0.9 * autopay
             + 0.05 * days + region_eff - 0.01 * (age - 45))
    churn = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    retention_offer = np.where(churn == 1, rng.random(n) < 0.85, rng.random(n) < 0.03).astype(int)
    age[rng.random(n) < 0.10] = np.nan
    days[rng.random(n) < 0.05] = np.nan
    return pd.DataFrame({
        "customer_id": [f"C{i:05d}" for i in range(n)],
        "region": region, "tariff": tariff, "tenure_months": tenure, "monthly_fee": fee,
        "support_calls": calls, "autopay": autopay, "age": age, "days_since_login": days,
        "retention_offer": retention_offer,   # отдел удержания предложил скидку (после заявки на расторжение!)
        "churn": churn,
    })


def too_good_solution(df):
    """«Слишком хорошее» решение коллеги. Найдите в нём три ошибки (не меняйте эту функцию)."""
    y = df["churn"]
    X = pd.get_dummies(df.drop(columns=["churn", "customer_id"]), dtype=float)
    X = X.fillna(X.mean())                      # средние — по ВСЕМ данным, включая будущий тест
    X = (X - X.mean()) / X.std()                # и стандартизация тоже
    X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, random_state=0)
    model = LogisticRegression(max_iter=1000).fit(X_tr, y_tr)
    return roc_auc_score(y_te, model.predict_proba(X_te)[:, 1])


NUM_COLS = ["tenure_months", "monthly_fee", "support_calls", "autopay", "age", "days_since_login"]
CAT_COLS = ["region", "tariff"]

# TODO: какие столбцы нельзя давать модели? (идентификатор и утечка)
DROP_COLUMNS = []


def split_holdout(df, test_size=0.25, seed=0):
    """Отложить тест ОДИН раз: стратифицированное разбиение по churn.

    X — все столбцы, кроме "churn" и DROP_COLUMNS. Вернуть (X_train, X_test, y_train, y_test).
    """
    # TODO: ваш код (train_test_split с stratify=y и random_state=seed)
    ...


def build_pipeline(C=1.0):
    """Pipeline: ColumnTransformer → LogisticRegression(C=C, max_iter=1000).

    Числовые NUM_COLS: SimpleImputer(strategy="median") → StandardScaler().
    Категориальные CAT_COLS: OneHotEncoder(handle_unknown="ignore").
    Шаги назовите "prep" и "model" — по этим именам RandomizedSearchCV найдёт параметр model__C.
    """
    # TODO: ваш код
    ...


def tune(X_train, y_train, seed=0):
    """Подобрать C случайным поиском по кросс-валидации ТОЛЬКО на train.

    RandomizedSearchCV(build_pipeline(), {"model__C": loguniform(1e-3, 1e2)}, n_iter=4,
    scoring="roc_auc", cv=StratifiedKFold(3, shuffle=True, random_state=seed), random_state=seed).
    Вернуть обученный объект поиска.
    """
    # TODO: ваш код
    ...


def honest_report(df, seed=0):
    """Весь честный цикл: holdout → подбор на train → одна проверка на тесте.

    Вернуть словарь {"cv_auc": лучший AUC по кросс-валидации, "best_C": найденное C,
                     "test_auc": AUC лучшей модели на отложенном тесте}.
    """
    # TODO: ваш код
    ...


# «Запустить»
data = make_churn()
print("доля ушедших:", round(data["churn"].mean(), 3))
print("«слишком хорошее» решение, AUC:", round(too_good_solution(data), 3))
report = honest_report(data)
if report is not None:
    print("честный отчёт:", {k: round(float(v), 3) for k, v in report.items()})
Проверь себя6 вопросов
1 Модель предсказывает, купит ли пользователь премиум. У каждого пользователя в данных 10 строк — по одной на сессию, в продакшене модель будет работать на новых пользователях. Какую схему кросс-валидации взять?
2 Какие действия превращают тест в ещё одну валидацию?Можно выбрать несколько ответов
3 Поиск по сетке: 4 значения learning_rate × 5 значений num_leaves. Сколько РАЗНЫХ значений learning_rate он проверит? А сколько — случайный поиск с тем же числом попыток (при непрерывном распределении)?
4 Модель с class_weight="balanced" даёт ROC-AUC 0.74 и среднюю вероятность 0.38 при доле положительных 0.19. Что верно?
5 По 10 общим фолдам модель B лучше модели A в среднем на 0.040, std парных разностей 0.014. Чему равна стандартная ошибка средней разности, если (наивно) считать фолды независимыми? Округлите до трёх знаков.
6 Почему лучший результат RandomizedSearchCV нельзя писать в отчёт как итоговое качество?
  • Валидация должна повторять применение модели. Тест открывают один раз; все решения — по кросс-валидации на train.
  • Самые разрушительные утечки — признаки, которых не будет в момент прогноза, и неверная схема разбиения (группы, время). Pipeline с ColumnTransformer делает утечки через препроцессинг невозможными.
  • StratifiedKFold — для классификации, GroupKFold — когда в будущем новые клиенты или компании, TimeSeriesSplit — когда прогнозируем будущее. Всегда сравнивайте с простым бейзлайном.
  • Случайный поиск эффективнее сетки, Optuna (TPE) — умнее при большом бюджете; лучший результат поиска оптимистичен.
  • Модели сравнивают парно на одних и тех же фолдах; вероятности проверяют кривой калибровки и Brier score; в отчёт идут схема валидации, среднее ± std по фолдам и одно число на тесте с интервалом.

Проверьте себя так, как спросят на собеседовании: вопросы о валидации, утечках и метриках с разбором ответов — в уроке 11.3.2.

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