Отладка 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 надёжнее.
Самопроверка
-
Data leakage детектор: Дан датасет с accuracy 99.5% на валидации. Напишите 3 конкретных проверки, которые помогут подтвердить или опровергнуть наличие leakage. Для каждой проверки -- какой результат указывает на leakage?
-
Диагностика по learning curve: Модель A: train=98%, val=65%, gap растёт. Модель B: train=70%, val=68%, gap минимален. Для каждой модели: какой тип проблемы и какие 2 конкретных действия предпринять?
-
Production debugging: Рекомендательная модель (CTR prediction) деградировала на 15% за неделю. Логи показывают: входные данные изменились (2 новых категории в одном из признаков). Опишите: (1) почему модель деградировала, (2) quick fix, (3) long-term fix.
See Also¶
- Метрики -- правильный выбор метрик для оценки
- Гиперпараметры -- тюнинг параметров, если модель не учится
- Выбор модели -- может, нужна другая модель?
- Classical ML Interview Q&A -- развёрнутые ответы по дебагу
- Deep Learning Interview Q&A -- vanishing gradients, learning rate
Источники¶
- Google ML Crash Course -- "Debugging ML Models"
- Scikit-learn Documentation -- learning_curve, validation_curve, permutation_importance
- SHAP Documentation -- TreeExplainer, summary_plot
- Statsmodels -- Cook's distance, VIF
- Imbalanced-learn -- SMOTE, Pipeline