État des servicesCréer un compte
Cartes de la chance

9 способов убить модель

9 способов убить модель (и даже не заметить)

Хорошие метрики на валидации, еще не победа.

Очень часто модель показывает шикарные статы на валидации, а в реальной жизни разваливается. Причем ломается она не из-за какого-то сложного алгоритма, а из-за простых и вполне понятных ошибок на этапе подготовки данных, разбиения и проверки.

В этой статье разберу 9 самых частых способов убить модель так, что вы об этом даже не узнаете, или узнаете слишком поздно. А во второй половине соберем рабочий пайплайн целиком, который эти грабли обходит, на живом примере предсказания цен на авто.

Большая часть касается классического ML, но некоторые ошибки фатальны и для нейросетей.


1. Утечка данных

Самая коварная и самая распространенная ошибка. Модель видит информацию, которой в реальной жизни у нее не будет. В итоге на валидации все выглядит прекрасно, а фактически метрики сильно проседают.

Классические примеры утечки:

  • Нормализация, заполнение пропусков или расчет статистик на всем датасете до разбиения на train/val
  • Target Encoding, посчитанный с использованием целевой переменной тестовой части
  • Использование данных из будущего (например, «средняя цена за следующий месяц»)
  • Фичи, которые косвенно содержат информацию о таргете (leakage features)

Плохо:

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)          # фит на всем X
X_train, X_test = train_test_split(X_scaled, ...)

Модель уже подглядела распределение тестовых данных. Даже если вы потом честно разделите выборку, информация уже утекла.

Как правильно:

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)   # фит только на обучающую выборку
X_test_scaled = scaler.transform(X_test)         # трансформ на тест

Главное правило: любая информация, которая использует целевую переменную или статистику всего датасета, должна считаться строго внутри обучающей выборки (или внутри каждого фолда кросс-валидации).

Особенно опасно это в пайплайнах с Target Encoding, WOE и подобных техниках. Там утечка может быть очень тонкой и незаметной.


2. Неправильный сплит

train_test_split с перемешиванием. Враг всех задач, где есть время или дата.

Если вы предсказываете цены, спрос, отток, кредитный риск или любые временные процессы и при этом перемешиваете данные случайным образом, модель учится на будущем. А в реальной жизни будущего у нее не будет.

Плохой вариант:

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, shuffle=True, random_state=42
)

Хороший вариант для временных рядов:

tscv = TimeSeriesSplit(n_splits=5)

for train_idx, test_idx in tscv.split(X):
    X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
    y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]

Или просто жестко режем по дате:

train = df[df['date'] < '2026-09-11']
test = df[df['date'] >= '2026-09-11']

На временных данных разница между случайным сплитом и правильным часто составляет десятки процентов качества. Модель, которая лицезрела будущее, почти всегда показывает завышенные метрики.

Если в данных есть группы (пользователи, магазины, регионы), тоже нельзя просто перемешивать. Нужен GroupKFold или аналогичный подход, иначе будет утечка между объектами одной группы.


3. Отсутствие нормального бейзлайна

Очень часто сложная модель почти не выигрывает у примитивной. А иногда сложная даже хуже.

Без бейзлайна невозможно понять, действительно ли ваша модель что-то умеет, или она просто выучила среднее.

Минимальный набор бейзлайнов:

  • Среднее или медиана целевой переменной
  • Линейная или логистическая регрессия на 2–3 самых коррелирующих признака
  • Константа по группам (например, средняя цена по категории)
  • Предыдущее значение (для временных рядов)
pred = np.full_like(y_test, y_train.median())
print("MAE медианы:", mean_absolute_error(y_test, pred))

Если бустинг улучшает бейзлайн всего на 3–5%, стоит серьезно подумать, нужен ли он вообще. Иногда простая модель и хорошие фичи дают почти тот же результат, но гораздо стабильнее и понятнее.


4. Переобучение на валидации

Вы 40 раз подобрали learning_rate, max_depth и num_leaves, глядя на одну и ту же валидационную выборку. В итоге модель идеально подстроилась именно под нее.

Тестовая выборка при этом уже не является честной оценкой. Вы просто переобучились на валидацию.

Как защититься:

  1. Держите финальный холдаут-набор, который используете один раз в самом конце.
  2. Для подбора гиперпараметров используйте кросс-валидацию.
  3. Не смотрите на тестовую выборку до финальной оценки.
scores = cross_val_score(
    model, X_train, y_train,
    cv=5,
    scoring='neg_mean_absolute_error'
)
print(-scores.mean())

Главный принцип: чем больше раз модель смотрит на одни и те же данные, тем сильнее она к ним подстраивается. Это относится не только к гиперпараметрам, но и к выбору фич, порогов и даже идей для новых признаков.


5. Игнорирование дисбаланса классов

Точность 99%. Кажется, что модель хорошая.

А положительного класса в данных всего 1,5%. Модель просто научилась всегда говорить «нет».

В задачах с сильным дисбалансом accuracy почти бесполезна. Она может быть высокой даже у константной модели.

Что делать:

  • Смотреть на Precision, Recall, F1, ROC-AUC, PR-AUC
  • Использовать class_weight='balanced'
  • Пробовать undersampling / oversampling (осторожно)
  • Настраивать порог классификации под нужную метрику
  • Смотреть на матрицу ошибок, а не только на одну цифру
model = lgb.LGBMClassifier(class_weight='balanced', random_state=42)

Или руками:

print(classification_report(y_test, preds))

Важно помнить: оптимизация под одну метрику (например, ROC-AUC) не всегда дает хороший результат по другой.


6. Отсутствие работы с выбросами

Представьте, что мы обучаем модель, прогнозирующую цены на автомобили. Один человек продал редкий коллекционный автомобиль за 18 миллионов долларов, и это сильно сдвинуло деревья.

Выбросы бывают двух типов:

  • Ошибки данных (нужно удалять или чинить)
  • Редкие, но настоящие случаи (нужно решать, хотите ли вы их учитывать)

Если не работать с выбросами, модель может тратить свою емкость на объяснение единичных аномалий вместо того, чтобы хорошо работать на типичных объектах.

Простые способы:

# Через квантили
q_low = df['price'].quantile(0.01)
q_hi  = df['price'].quantile(0.99)
df_clean = df[(df['price'] >= q_low) & (df['price'] <= q_hi)]

Или через IQR, z-score, isolation forest и т.д.

Важно: не делайте это молча на всем датасете до разбиения, снова получите утечку. Лучше обучать правила очистки только на трейне и применять их к тесту.


7. Категориальные признаки: две крайности

Два варианта, которые встречаются постоянно:

  1. One-Hot Encoding на колонку с 8000 уникальных значений → получаем гигантскую разреженную матрицу и адовый рост размерности.
  2. Label Encoding на номинальной категории → модель думает, что Москва < Санкт-Петербург < Иркутск.

Оба варианта могут сильно ухудшить качество (особенно на линейных моделях и нейросетях).

Что обычно работает лучше:

  • Для деревьев: просто astype('category') (LightGBM и CatBoost умеют работать с категориями нативно)
  • Target Encoding (только внутри фолдов)
  • Frequency Encoding
freq = X_train['city'].value_counts(normalize=True)
X_train['city_freq'] = X_train['city'].map(freq)
X_test['city_freq'] = X_test['city'].map(freq).fillna(0)

Target Encoding особенно опасен утечками, его почти всегда нужно считать внутри кросс-валидации.


8. Игнорирование дрейфа данных

Модель обучена на данных 22–23 годов. В 2026 распределение уже другое.

Качество медленно, но верно падает. Причем падает незаметно, без явных ошибок и падений сервиса.

Что можно и нужно мониторить:

  • Распределение ключевых фич
  • Среднее и дисперсию предсказаний
  • Качество модели по времени (график метрики по неделям/месяцам)
  • Долю пропусков и аномальных значений
  • Сравнение с простыми бейзлайнами во времени

Даже простой график средней ошибки по месяцам уже спасает от кучи сюрпризов.

Дрейф бывает разный: дрейф данных (изменилось распределение фич) и концепт дрейф (изменилась сама зависимость между фичами и целевой переменной). Второй тип лечится только переобучением на свежих данных.


9. Отсутствие воспроизводимости

Сегодня модель показала MAE 2. Завтра при том же коде, 5. Через неделю снова 2.3.

Причины обычно простые и грустные:

  • Не зафиксирован random_state (и не только в модели, но и в сплите, сэмплировании, кросс-валидации)
  • Данные приходят в разном порядке
  • Разные версии библиотек на разных машинах
  • Не сохранены параметры предобработки
  • Не зафиксированы версии данных
  • Недетерминированные операции (особенно на GPU)

Что делать:

  1. Фиксируйте random_state везде, где он есть.
  2. Фиксируйте версии всех библиотек (requirements.txt).
  3. Сохраняйте весь пайплайн предобработки (sklearn Pipeline + joblib).
  4. Версионируйте данные (DVC, хеши, даты срезов).
  5. Периодически специально проверяйте, что модель воспроизводится.

Без воспроизводимости невозможно нормально сравнивать эксперименты, дебажить проблемы и деплоить модели из исследования.


Практика: учим модель предсказывать цены на авто

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

Запустить код можно прямо в браузере (например, в Google Colab) или на любом сервере с Python, большие мощности не нужны.

Шаг 1. Готовим инструменты

Понадобятся проверенные библиотеки:

  • Pandas и NumPy, чтобы собирать датасеты
  • Sklearn, для разбиения выборки и подсчета метрик
  • LightGBM, быстрый градиентный бустинг над деревьями, один из главных рабочих инструментов в коммерческом ML прямо сейчас
# Если работаете в Jupyter / Colab:
# !pip install -q lightgbm scikit-learn pandas numpy
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error, r2_score
import lightgbm as lgb

Шаг 2. Собираем данные

Чтобы не тратить время на скачивание многогигабайтных архивов с Kaggle, сгенерим синтетический, но вполне реалистичный срез вторичного авторынка на 5000 объявлений.

Цена зависит от марки, возраста, пробега, коробки передач и числа владельцев. Зададим эти закономерности:

np.random.seed(42)  # фиксируем генератор случайных чисел, пункт 9 вспомнил
n_samples = 5000

brands = np.random.choice(['Toyota', 'BMW', 'Lada', 'Hyundai', 'Mercedes'], size=n_samples, p=[0.25, 0.2, 0.25, 0.2, 0.1])
years = np.random.randint(2008, 2024, size=n_samples)

# Пробег логично растет с возрастом авто (плюс случайный разброс)
mileages = np.clip((2024 - years) * 18000 + np.random.normal(0, 25000, n_samples), 5000, 450000).astype(int)

transmissions = np.random.choice(['MT', 'AT'], size=n_samples, p=[0.4, 0.6])
owners_count = np.random.randint(1, 5, size=n_samples)

Теперь посчитаем цену с учетом рыночной амортизации:

brand_base_price = {'Toyota': 1.6e6, 'BMW': 2.8e6, 'Lada': 0.7e6, 'Hyundai': 1.3e6, 'Mercedes': 3.2e6}
base = np.array([brand_base_price[b] for b in brands])

# Чем старше авто и выше пробег, тем сильнее падает цена
depreciation = (2024 - years) * 0.05 + (mileages / 100000) * 0.12
price = base * (1 - np.clip(depreciation, 0.1, 0.75))

# Автомат ценится дороже, а каждый лишний владелец снижает стоимость
price = price * np.where(transmissions == 'AT', 1.08, 0.95) - (owners_count - 1) * 35000

# Рыночный шум (случайный торг, дефекты кузова)
price = np.round(price + np.random.normal(0, 40000, n_samples), -3)

df = pd.DataFrame({
    'brand': brands,
    'year': years,
    'mileage': mileages,
    'transmission': transmissions,
    'owners': owners_count,
    'price': price
})

print(df.head())

Шаг 3. Инженерия признаков

Сырые данные редко скармливают модели сразу. Попробуем подумать как покупатель: машина с пробегом 150 000 км за 10 лет, это норм, а те же 150 000 км за 2 года, подозрительно.

Создадим производный признак, среднегодовой пробег:

df['km_per_year'] = df['mileage'] / (2024 - df['year'] + 1)

А как быть с текстом? Модели не умеют читать слова вроде «Toyota» или «AT».

Фича LightGBM в том, что не нужно вручную кодировать категории через One-Hot, достаточно перевести столбцы в специальный тип category (пункт 7, помните?):

df['brand'] = df['brand'].astype('category')
df['transmission'] = df['transmission'].astype('category')

Кстати, о нормализации. Нужно ли приводить километры (до 400 000) и число владельцев (1–4) к диапазону от 0 до 1? Для деревьев и бустинга нет. Алгоритм ищет пороги вида «пробег > 120000», и масштаб чисел никак не меняет их порядок. Если бы мы учили линейную регрессию или нейросеть, масштабирование было бы обязательным. И если бы масштабировали, то строго после сплита (пункт 1).

Шаг 4. Сначала бейзлайн

Прежде чем делить и учить что-то умное, посчитаем примитив: медианную цену (пункт 3). Иначе потом не поймем, чем бустинг вообще лучше константы:

baseline_mae = mean_absolute_error(y_test, np.full_like(y_test, y_train.median()))

Забегая вперед: бустинг ошибается в разы реже, чем медиана, и вот теперь у нас есть этот факт, а не «модель выдала красивую цифру».

Шаг 5. Делим выборку

Отделяем признаки от целевой переменной и разбиваем: 80% на тренировку, 20% прячем на валидацию:

X = df.drop(columns=['price'])
y = df['price']

X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    random_state=42
)

print(f"Обучающая выборка: {len(X_train)} строк")
print(f"Валидационная выборка: {len(X_test)} строк")

Важная оговорка: здесь обычно делают ошибку из пункта 2, перемешивая временные данные. В нашем синтетическом срезе нет дат, все объявления условно одномоментные, поэтому шафл в порядке. Но если бы у вас были объявления с датами публикации (а на реальном авторынке они есть), правильный сплит был бы по времени, как в пункте 2.

Шаг 6. Обучаем модель

Модель последовательно построит 300 неглубоких деревьев, где каждое следующее исправляет ошибки предыдущих:

model = lgb.LGBMRegressor(
    n_estimators=300,
    learning_rate=0.05,
    max_depth=5,
    random_state=42,
    verbose=-1
)

model.fit(X_train, y_train)

Заметьте, random_state стоит и в генерации данных, и в сплите, и в модели. Дешевая привычка, которая экономит кучу нервов (и это снова пункт 9).

Шаг 7. Оцениваем результат

Даем модели незнакомые машины из валидационной выборки и сверяем ее предположения с реальными ценами.

Смотрим на две метрики:

  • MAE, на сколько рублей в среднем модель ошибается
  • R², какую долю закономерностей выучила модель (1.0 идеал, 0.0 уровень простого среднего)
preds = model.predict(X_test)

mae = mean_absolute_error(y_test, preds)
r2 = r2_score(y_test, preds)
baseline_mae = mean_absolute_error(y_test, np.full_like(y_test, y_train.median()))

print(f"Бейзлайн MAE: {baseline_mae:,.0f}")
print(f"MAE: {mae:,.0f}")
print(f"R²: {r2:.3f}")

При таких параметрах ошибка обычно около 34 000 рублей, что укладывается в уровень случайного торга на рынке, а R² ≈ 0.996. Модель уловила всю структуру зависимости, а сравнение с бейзлайном показывает это честно.

Итог

Всего за ~50 строк кода прошли полный жизненный цикл ML:

  • Подготовили данные
  • Сконструировали новую фичу (km_per_year)
  • Сначала посчитали бейзлайн, потом уже бустинг
  • Разделили выборку на train/val
  • Обучили градиентный бустинг
  • Замерили ошибку и сравнили ее с константой

Именно так работают агрегаторы объявлений и аналитика цен. А главное, теперь вы знаете девятью способами, как все это уничтожить, чтобы случайно не получилось как у всех.

И последнее, помните про пункт 8, дрейф. В реальном мире автопарк, курсы валют и тренды меняются, и модель, обученная один раз, стареет. Даже простой график MAE по месяцам спасает от половины сюрпризов.

À lire ensuite

Des articles de la même rubrique et sur les mêmes thèmes.

Services
Il vous reste des questions ?

Parcourez les autres articles de la base de connaissances : les sujets voisins y sont aussi traités.