Проект: честная валидация и подбор гиперпараметров
Сквозной проект на scikit-learn: утечки и их цена в числах, Pipeline и ColumnTransformer, KFold, StratifiedKFold, GroupKFold и TimeSeriesSplit, случайный и байесовский поиск, сравнение моделей с учётом разброса, калибровка и одна финальная проверка на отложенном тесте.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Коллега приносит модель оттока клиентов: 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.961 | 0.780 |
| Стандартизация и заполнение пропусков по всему dev до разбиения на фолды | 0.7799 | 0.7799 |
| Наивный target encoding редкой категории (урок 2.4.2) | 0.919 на шуме | 0.529 |
| Строки одного клиента в train и validation (KFold вместо GroupKFold) | 0.875 | 0.601 |
| Временной ряд: случайный KFold вместо «прошлое → будущее», RMSE | 1.73 | 6.08 |
Неожиданная строка — вторая: «классическая» утечка через стандартизацию по всем данным на 4800 строках не изменила AUC даже в четвёртом знаке. Среднее и разброс признака почти одинаковы на любом большом куске данных. Это не повод так делать — на маленьких данных и при отборе признаков эффект огромный (эксперимент урока 2.1.2 дал 88% на шуме), — но повод понимать: самые разрушительные утечки — признаки, которых не будет в момент прогноза, и неверная схема разбиения. Защита от всех сразу — Pipeline плюс правильная кросс-валидация. Утечка — лишь один из тихих багов, которые не роняют код, а портят метрику; их каталог для рабочего ML-кода — в уроке 11.1.2.
Pipeline и ColumnTransformer
Заголовок раздела «Pipeline и ColumnTransformer»конвейерконвейер (Pipeline)EN ML pipeline (scikit-learn Pipeline)Препроцессинг и модель как один объект с fit/predict. В кросс-валидации все шаги обучаются только на train-части фолда — утечки через предобработку исключены. склеивает препроцессинг и модель в один объект с методами fit и predict.
ColumnTransformer применяет к разным столбцам разную обработку:
from sklearn.compose import ColumnTransformerfrom sklearn.impute import SimpleImputerfrom sklearn.linear_model import LogisticRegressionfrom sklearn.model_selection import train_test_splitfrom sklearn.pipeline import Pipelinefrom 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) | случайно на частей | строки независимы, баланс классов не важен |
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) берёт случайных точек из распределений. Бергстра и Бенджио (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 lgbfrom scipy.stats import loguniform, randint, uniformfrom 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 + wsearch = 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 optunafrom 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-AUC | Std по 10 фолдам | |
|---|---|---|
| Логистическая регрессия | 0.7815 | 0.0257 |
LightGBM (300 деревьев, 15 листьев, learning_rate 0.05) | 0.7410 | 0.0240 |
| Разность LightGBM − логрег по фолдам | −0.0404 | 0.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 |
|---|---|---|---|---|
| LightGBM | 0.03 · 0.07 · 0.14 · 0.24 · 0.51 | 0.05 · 0.08 · 0.22 · 0.19 · 0.43 | 0.197 | 0.139 |
то же, class_weight="balanced" | 0.07 · 0.19 · 0.35 · 0.52 · 0.76 | 0.05 · 0.09 · 0.16 · 0.27 · 0.40 | 0.378 | 0.186 |
balanced + CalibratedClassifierCV(method="isotonic") | 0.04 · 0.10 · 0.17 · 0.25 · 0.44 | 0.06 · 0.09 · 0.16 · 0.25 · 0.41 | 0.201 | 0.137 |
Доля ушедших — 0.194. Сбалансированная модель в среднем предсказывает 0.378, почти вдвое больше правды, при почти
том же AUC (0.738 против 0.739): ранжирование не пострадало, числа — да. CalibratedClassifierCV по
кросс-валидации учит монотонное преобразование прогнозов (изотоническая регрессия или сигмоида Платта) и
возвращает вероятности к реальности.
Одна финальная проверка и отчёт
Заголовок раздела «Одна финальная проверка и отчёт»Когда все решения приняты по кросс-валидации, лучшая конфигурация обучается на всём dev и один раз проверяется на тесте. Итоговый отчёт — короткий и проверяемый:
| Раздел | Что написать |
|---|---|
| Данные | источник, число строк, доля классов, что нашли разведкой и что почистили |
| Схема валидации | holdout-тест (сколько и как отложен), вид кросс-валидации и почему он похож на применение |
| Метрика | какая и почему; бейзлайн (константа, простая модель) |
| Модели | кандидаты, оценки по фолдам (среднее ± std), парное сравнение |
| Гиперпараметры | пространство поиска, число попыток, найденные значения |
| Тест | одно число с доверительным интервалом (бутстрэп); калибровка, если нужны вероятности |
| Ограничения | на каких данных модель не проверена, какие утечки исключены и как |
Те же правила — для LLM
Заголовок раздела «Те же правила — для LLM»- Бенчмарк — тестовая выборка. Подбирать промпт, температуру или чекпоинт по тесту — переобучение; для этого заводят 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 фолда; локально берите больше.
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()})
Проверь себя
Заголовок раздела «Проверь себя»- Валидация должна повторять применение модели. Тест открывают один раз; все решения — по кросс-валидации на train.
- Самые разрушительные утечки — признаки, которых не будет в момент прогноза, и неверная схема разбиения (группы, время). Pipeline с ColumnTransformer делает утечки через препроцессинг невозможными.
StratifiedKFold— для классификации,GroupKFold— когда в будущем новые клиенты или компании,TimeSeriesSplit— когда прогнозируем будущее. Всегда сравнивайте с простым бейзлайном.- Случайный поиск эффективнее сетки, Optuna (TPE) — умнее при большом бюджете; лучший результат поиска оптимистичен.
- Модели сравнивают парно на одних и тех же фолдах; вероятности проверяют кривой калибровки и Brier score; в отчёт идут схема валидации, среднее ± std по фолдам и одно число на тесте с интервалом.
Проверьте себя так, как спросят на собеседовании: вопросы о валидации, утечках и метриках с разбором ответов — в уроке 11.3.2.
Дальше: ступень 3 — нейрон и многослойная сеть. Таблицы остаются территорией бустинга, а для текста, изображений и звука нужны нейросети: соберём их с нуля, от одного нейрона до обратного распространения ошибки.