Перейти к содержанию

Отладка ML-моделей

~5 минут чтения

Предварительно: Метрики | Выбор модели

Модель даёт плохие метрики -- проблема в данных, модели, обучении или оценке? Этот чеклист покрывает 95% типичных ошибок: от data leakage (враг #1 -- подозрительно высокие метрики на валидации) до неправильного порога классификации. Диагностику всегда начинай с данных: 80% проблем обнаруживаются до первого model.fit().

Дерево диагностики проблем

graph TD
    START["Модель работает плохо?"] --> DATA["ДАННЫЕ"]
    START --> MODEL["МОДЕЛЬ"]
    START --> TRAIN["ОБУЧЕНИЕ"]
    START --> METRICS["МЕТРИКИ"]

    DATA --> D1["Data leakage"]
    DATA --> D2["Неправильный split"]
    DATA --> D3["Пропуски / выбросы"]
    DATA --> D4["Неправильный preprocessing"]

    MODEL --> M1["Underfitting<br/>(высокий bias)"]
    MODEL --> M2["Overfitting<br/>(высокая variance)"]
    MODEL --> M3["Неправильная архитектура"]

    TRAIN --> T1["Learning rate"]
    TRAIN --> T2["Градиенты<br/>(vanishing/exploding)"]
    TRAIN --> T3["Оптимизатор"]

    METRICS --> ME1["Неправильная метрика"]
    METRICS --> ME2["Дисбаланс классов"]
    METRICS --> ME3["Проблемы с порогом"]

    style START fill:#e8eaf6,stroke:#3f51b5
    style DATA fill:#fce4ec,stroke:#c62828
    style MODEL fill:#fff3e0,stroke:#ef6c00
    style TRAIN fill:#f3e5f5,stroke:#9c27b0
    style METRICS fill:#e8f5e9,stroke:#4caf50

Data Debugging

Data Leakage — враг #1

Если метрики на валидации подозрительно хорошие (accuracy > 99%), а на проде все плохо — почти наверняка data leakage. Три самых частых источника: (1) нормализация/encoding ДО split, (2) features из будущего, (3) target encoding без nested CV.

Проверка Data Leakage

# Признаки утечки:
# 1. Слишком хорошие метрики на валидации
# 2. Резкое падение на продакшене

# Проверка: удалить подозрительные признаки
suspicious_features = ['future_feature', 'target_related']
X_clean = X.drop(columns=suspicious_features)
# Если метрики сильно упали — была утечка

# Типичные источники утечки:
# - Feature из будущего (дата после таргета)
# - Агрегаты включающие test
# - Normalize до split
# - Target encoding без CV

Проверка качества данных

import pandas as pd
import numpy as np

def data_quality_report(df):
    report = pd.DataFrame({
        'dtype': df.dtypes,
        'missing': df.isna().sum(),
        'missing_pct': df.isna().sum() / len(df) * 100,
        'unique': df.nunique(),
        'sample': df.iloc[0]
    })
    return report

# Проверка выбросов
def detect_outliers(df, cols, method='iqr'):
    outliers = {}
    for col in cols:
        if method == 'iqr':
            Q1 = df[col].quantile(0.25)
            Q3 = df[col].quantile(0.75)
            IQR = Q3 - Q1
            outliers[col] = ((df[col] < Q1 - 1.5*IQR) | (df[col] > Q3 + 1.5*IQR)).sum()
        elif method == 'zscore':
            z = (df[col] - df[col].mean()) / df[col].std()
            outliers[col] = (np.abs(z) > 3).sum()
    return outliers

# Проверка распределения таргета
print(df['target'].value_counts(normalize=True))

Проверка train/test split

# Train и test должны быть из одного распределения
from scipy import stats

for col in numeric_cols:
    stat, pval = stats.ks_2samp(X_train[col], X_test[col])
    if pval < 0.05:
        print(f"{col}: распределения различаются (p={pval:.4f})")

# Для категориальных
for col in cat_cols:
    train_dist = X_train[col].value_counts(normalize=True)
    test_dist = X_test[col].value_counts(normalize=True)
    # Сравни визуально

Model Debugging

Underfitting vs Overfitting

Underfitting (High Bias) Overfitting (High Variance)
Симптомы Train и Val score оба низкие, Train \(\approx\) Val Train score высокий, Val score низкий, Train \(\gg\) Val
Причины Модель слишком простая; мало признаков; слишком сильная регуляризация Модель слишком сложная; мало данных; слабая регуляризация
Решения Более сложная модель; feature engineering; уменьшить регуляризацию; дольше обучать Регуляризация (L1, L2, dropout); меньше параметров; больше данных/аугментация; early stopping; cross-validation
Диагностика Learning curve: кривые сходятся на низком уровне Learning curve: большой gap между train и val

Learning Curves

from sklearn.model_selection import learning_curve
import matplotlib.pyplot as plt

train_sizes, train_scores, val_scores = learning_curve(
    model, X, y,
    train_sizes=np.linspace(0.1, 1.0, 10),
    cv=5,
    scoring='accuracy',
    n_jobs=-1
)

plt.figure(figsize=(10, 6))
plt.plot(train_sizes, train_scores.mean(axis=1), 'o-', label='Train')
plt.plot(train_sizes, val_scores.mean(axis=1), 'o-', label='Validation')
plt.fill_between(train_sizes,
                 train_scores.mean(axis=1) - train_scores.std(axis=1),
                 train_scores.mean(axis=1) + train_scores.std(axis=1), alpha=0.1)
plt.xlabel('Training Size')
plt.ylabel('Score')
plt.legend()
plt.title('Learning Curves')

# Интерпретация:
# - Кривые сходятся низко → underfitting
# - Большой gap → overfitting
# - Validation растёт → больше данных поможет

Validation Curves

from sklearn.model_selection import validation_curve

param_range = [0.001, 0.01, 0.1, 1, 10, 100]
train_scores, val_scores = validation_curve(
    model, X, y,
    param_name='C',
    param_range=param_range,
    cv=5,
    scoring='accuracy'
)

plt.semilogx(param_range, train_scores.mean(axis=1), 'o-', label='Train')
plt.semilogx(param_range, val_scores.mean(axis=1), 'o-', label='Validation')
plt.xlabel('C')
plt.ylabel('Score')
plt.legend()

Neural Network Debugging

Градиенты

# Проверка градиентов
for name, param in model.named_parameters():
    if param.grad is not None:
        grad_norm = param.grad.norm()
        print(f"{name}: grad_norm={grad_norm:.6f}")

        # Vanishing gradients
        if grad_norm < 1e-7:
            print(f"  WARNING: Vanishing gradient!")

        # Exploding gradients
        if grad_norm > 100:
            print(f"  WARNING: Exploding gradient!")

# Решения для vanishing:
# - ReLU вместо sigmoid/tanh
# - Skip connections (ResNet)
# - BatchNorm / LayerNorm
# - Правильная инициализация

# Решения для exploding:
# - Gradient clipping
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

Learning Rate

# Learning Rate Finder
def find_lr(model, train_loader, optimizer, criterion, start_lr=1e-7, end_lr=10, num_iter=100):
    lrs = []
    losses = []
    lr = start_lr

    for i, (x, y) in enumerate(train_loader):
        if i >= num_iter:
            break

        for param_group in optimizer.param_groups:
            param_group['lr'] = lr

        optimizer.zero_grad()
        output = model(x)
        loss = criterion(output, y)
        loss.backward()
        optimizer.step()

        lrs.append(lr)
        losses.append(loss.item())
        lr *= (end_lr / start_lr) ** (1 / num_iter)

    plt.semilogx(lrs, losses)
    plt.xlabel('Learning Rate')
    plt.ylabel('Loss')
    # Выбери LR где loss начинает падать, но до минимума

Loss не уменьшается

# Чек-лист:
# 1. Проверь что модель в train mode
model.train()

# 2. Проверь что градиенты обнуляются
optimizer.zero_grad()  # В НАЧАЛЕ итерации!

# 3. Проверь что backward вызывается
loss.backward()

# 4. Проверь что step вызывается
optimizer.step()

# 5. Проверь learning rate
for param_group in optimizer.param_groups:
    print(f"LR: {param_group['lr']}")

# 6. Проверь что loss правильный
print(f"Loss: {loss.item()}")  # Должен быть конечным числом

# 7. Попробуй переобучиться на одном батче
for _ in range(1000):
    optimizer.zero_grad()
    output = model(single_batch_x)
    loss = criterion(output, single_batch_y)
    loss.backward()
    optimizer.step()
    if _ % 100 == 0:
        print(f"Loss: {loss.item():.4f}")
# Если loss не падает даже на одном батче — баг в модели/данных

NaN в loss

# Причины NaN:
# 1. Exploding gradients → gradient clipping
# 2. Learning rate слишком большой → уменьшить
# 3. log(0) или деление на 0 → добавить epsilon
# 4. Неправильная инициализация весов

# Проверка NaN в данных
print(f"NaN in X: {torch.isnan(X).sum()}")
print(f"Inf in X: {torch.isinf(X).sum()}")

# Добавить epsilon в loss
loss = -torch.log(pred + 1e-8)

# Использовать стабильные функции
# Вместо log(softmax(x)) используй log_softmax(x)
import torch.nn.functional as F
loss = F.cross_entropy(logits, labels)  # Стабильнее чем softmax + log + nll

Classification Debugging

Дисбаланс классов

# Проверка
print(y.value_counts(normalize=True))

# Решения:

# 1. Class weights
from sklearn.utils.class_weight import compute_class_weight
weights = compute_class_weight('balanced', classes=np.unique(y), y=y)

# В sklearn
model = RandomForestClassifier(class_weight='balanced')

# В PyTorch
criterion = nn.CrossEntropyLoss(weight=torch.tensor(weights))

# 2. Resampling
from imblearn.over_sampling import SMOTE
from imblearn.under_sampling import RandomUnderSampler

smote = SMOTE()
X_resampled, y_resampled = smote.fit_resample(X, y)

# 3. Правильные метрики
from sklearn.metrics import f1_score, precision_recall_curve, average_precision_score
# НЕ accuracy!

Confusion Matrix анализ

from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
import matplotlib.pyplot as plt

cm = confusion_matrix(y_test, y_pred)
disp = ConfusionMatrixDisplay(cm, display_labels=class_names)
disp.plot()

# Анализ ошибок
# Найти примеры где модель ошибается
errors_idx = np.where(y_pred != y_test)[0]
error_samples = X_test.iloc[errors_idx]

# Посмотреть паттерны в ошибках
for i in errors_idx[:10]:
    print(f"True: {y_test.iloc[i]}, Pred: {y_pred[i]}")
    print(X_test.iloc[i])
    print("---")

Подбор порога

from sklearn.metrics import precision_recall_curve, f1_score

y_prob = model.predict_proba(X_test)[:, 1]
precisions, recalls, thresholds = precision_recall_curve(y_test, y_prob)

# F1 для разных порогов
f1_scores = 2 * (precisions * recalls) / (precisions + recalls + 1e-8)
best_threshold = thresholds[np.argmax(f1_scores[:-1])]

# Или выбрать по бизнес-требованию
# Например, нужен recall >= 0.9
idx = np.where(recalls >= 0.9)[0]
threshold_for_recall_90 = thresholds[idx[-1]]

Regression Debugging

Анализ остатков

residuals = y_test - y_pred

# Должны быть нормально распределены
import scipy.stats as stats
fig, axes = plt.subplots(1, 3, figsize=(15, 4))

# 1. Histogram
axes[0].hist(residuals, bins=30)
axes[0].set_title('Residuals Distribution')

# 2. QQ-plot
stats.probplot(residuals, plot=axes[1])
axes[1].set_title('QQ Plot')

# 3. Residuals vs Predicted
axes[2].scatter(y_pred, residuals)
axes[2].axhline(y=0, color='r', linestyle='--')
axes[2].set_xlabel('Predicted')
axes[2].set_ylabel('Residuals')
axes[2].set_title('Residuals vs Predicted')

# Проблемы:
# - Паттерн в остатках → нелинейность
# - Воронка → гетероскедастичность
# - Выбросы → влиятельные точки

Влиятельные точки

from sklearn.linear_model import LinearRegression
import statsmodels.api as sm

# Cook's distance
model_sm = sm.OLS(y, sm.add_constant(X)).fit()
influence = model_sm.get_influence()
cooks_d = influence.cooks_distance[0]

# Точки с Cook's D > 4/n подозрительные
threshold = 4 / len(y)
influential = np.where(cooks_d > threshold)[0]
print(f"Influential points: {influential}")

Feature Debugging

Feature Importance

# Tree-based
importances = model.feature_importances_
feature_importance = pd.DataFrame({
    'feature': X.columns,
    'importance': importances
}).sort_values('importance', ascending=False)

# Permutation importance (любая модель)
from sklearn.inspection import permutation_importance
result = permutation_importance(model, X_test, y_test, n_repeats=10)
perm_importance = pd.DataFrame({
    'feature': X.columns,
    'importance': result.importances_mean
}).sort_values('importance', ascending=False)

# SHAP (лучшее объяснение)
import shap
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)
shap.summary_plot(shap_values, X_test)

Корреляции

# Корреляция признаков
corr_matrix = X.corr()

# Высоко коррелированные
high_corr = np.where(np.abs(corr_matrix) > 0.9)
high_corr_pairs = [(corr_matrix.columns[x], corr_matrix.columns[y])
                   for x, y in zip(*high_corr) if x != y and x < y]

# VIF для мультиколлинеарности
from statsmodels.stats.outliers_influence import variance_inflation_factor
vif = pd.DataFrame()
vif['feature'] = X.columns
vif['VIF'] = [variance_inflation_factor(X.values, i) for i in range(X.shape[1])]
# VIF > 10 → проблема

Quick Checklist

□ Данные
  □ Нет data leakage
  □ Train/val/test из одного распределения
  □ Нет NaN/Inf
  □ Выбросы обработаны
  □ Категории закодированы правильно
  □ Нормализация ПОСЛЕ split

□ Модель
  □ Baseline работает
  □ Learning curves проверены
  □ Нет сильного overfitting/underfitting
  □ Гиперпараметры подобраны

□ Обучение (NN)
  □ model.train() / model.eval()
  □ optimizer.zero_grad()
  □ Градиенты не NaN/Inf
  □ Loss уменьшается на одном батче

□ Метрики
  □ Правильная метрика для задачи
  □ Учтён дисбаланс классов
  □ Порог подобран (если нужно)

Типичные заблуждения

Заблуждение: Высокая accuracy на валидации = модель работает

Если accuracy на валидации 99%, а на проде 60% -- это почти наверняка data leakage, а не хорошая модель. Три самых частых источника: (1) preprocessing (scaler, encoder) fit на всём датасете ДО split, (2) features содержат информацию из будущего, (3) target encoding без nested CV. Первый шаг диагностики: train самую простую модель (LogReg) -- если и она показывает 99%, проблема в данных.

Заблуждение: SMOTE решает проблему дисбаланса классов

SMOTE генерирует синтетические примеры в feature space, но создаёт два скрытых риска: (1) применённый ДО cross-validation -- data leakage (синтетические примеры из test fold попадают в train), (2) на высокоразмерных данных SMOTE интерполирует в пустоте (все точки далеко друг от друга). В продакшене чаще эффективнее: class_weight='balanced' + правильный порог + PR-AUC вместо ROC-AUC.


Вопросы для собеседования

У модели train accuracy 95%, val accuracy 55%. Как систематически диагностировать проблему?

❌ «Модель переобучилась, нужно добавить регуляризацию» -- неполный подход без диагностики.

✅ Пошаговая диагностика: (1) Проверить data leakage -- если простая модель (LogReg) тоже показывает 95%/55%, проблема в данных, а не в сложности модели. (2) Learning curves -- если val не растёт с увеличением данных, проблема в features или модели. (3) Validation curve -- прогнать по range гиперпараметров (C, max_depth), найти точку где val оптимален. (4) Если данных мало -- аугментация, feature engineering. (5) Если данных достаточно -- регуляризация, early stopping, уменьшение сложности.

Модель в продакшене деградирует через 2 недели. В чём причина и как мониторить?

❌ «Нужно переобучить модель на новых данных» -- лечение симптома, а не причины.

✅ Вероятные причины: (1) Data drift -- изменилось распределение входных данных (KS-тест, PSI index для каждого признака). (2) Concept drift -- изменилась связь X->Y (мониторить prediction distribution + ground truth feedback loop). (3) Feature pipeline сломался -- NaN, изменились схемы API. Мониторинг: KS-тест по признакам еженедельно, PSI > 0.2 = alert, shadow mode для новых версий модели. Retrain schedule зависит от скорости drift: финансы -- ежедневно, рекомендации -- еженедельно.

Как правильно применить SMOTE с cross-validation?

❌ «SMOTE на всём датасете, потом cross-validation» -- data leakage.

✅ SMOTE только внутри каждого train fold. Правильно: (1) Pipeline([('smote', SMOTE()), ('clf', model)]) через imblearn.pipeline.Pipeline (не sklearn!), (2) передать в cross_val_score. Альтернатива без SMOTE: class_weight='balanced' + threshold tuning по PR-curve. На высокоразмерных данных (>50 features) SMOTE часто вредит -- class_weight надёжнее.

Самопроверка

  1. Data leakage детектор: Дан датасет с accuracy 99.5% на валидации. Напишите 3 конкретных проверки, которые помогут подтвердить или опровергнуть наличие leakage. Для каждой проверки -- какой результат указывает на leakage?

  2. Диагностика по learning curve: Модель A: train=98%, val=65%, gap растёт. Модель B: train=70%, val=68%, gap минимален. Для каждой модели: какой тип проблемы и какие 2 конкретных действия предпринять?

  3. Production debugging: Рекомендательная модель (CTR prediction) деградировала на 15% за неделю. Логи показывают: входные данные изменились (2 новых категории в одном из признаков). Опишите: (1) почему модель деградировала, (2) quick fix, (3) long-term fix.


See Also


Источники

  1. Google ML Crash Course -- "Debugging ML Models"
  2. Scikit-learn Documentation -- learning_curve, validation_curve, permutation_importance
  3. SHAP Documentation -- TreeExplainer, summary_plot
  4. Statsmodels -- Cook's distance, VIF
  5. Imbalanced-learn -- SMOTE, Pipeline