| name | ml-model-evaluation |
| description | Model evaluation framework — cross-validation strategies, metric selection by task type, baseline requirements, ablation design, and train/val/test discipline. Use for all ML experiments before reporting results. |
| allowed_agents | ["experiment","experiment_coding","native_coding"] |
| preload_for | ["experiment"] |
ML Model Evaluation
Overview
Rigorous model evaluation is the foundation of credible ML research. This skill covers the complete evaluation pipeline: how to split data correctly, which metrics to report for which tasks, what baselines are required, how to structure ablation studies, and how to verify that results are statistically meaningful. Apply this skill before writing any results section or claiming model performance.
When to Use This Skill
Use this skill when:
- Selecting cross-validation strategy for a new experiment
- Choosing which metrics to compute and report for a task
- Setting up baseline comparisons before training models
- Designing ablation studies to identify component contributions
- Assessing whether train/val/test splits are clean and leak-free
- Tuning hyperparameters with nested cross-validation
- Reporting confidence intervals and checking statistical significance
- Completing the results section of a paper or technical report
Hard Rules (follow for every experiment)
- NEVER report accuracy alone for imbalanced datasets — always pair with F1, AUC-PR, or MCC
- ALWAYS compare against at least one trivial baseline before claiming model performance
- ALWAYS use cross-validation (or a proper held-out test set) — never evaluate on training data
- ALWAYS report confidence intervals or standard deviation across folds
- For ablation studies: change ONE component at a time and report the delta
Cross-Validation Taxonomy
Choose the CV strategy based on your data structure. Wrong CV choice is one of the most common sources of inflated reported performance.
Standard K-Fold
Use when: data is IID, classes are balanced, no group structure.
from sklearn.model_selection import KFold, cross_validate
from sklearn.ensemble import RandomForestClassifier
import numpy as np
kf = KFold(n_splits=5, shuffle=True, random_state=42)
model = RandomForestClassifier(n_estimators=100, random_state=42)
results = cross_validate(
model, X, y,
cv=kf,
scoring=['accuracy', 'f1_macro', 'roc_auc'],
return_train_score=True
)
print(f"Val accuracy: {results['test_accuracy'].mean():.3f} ± {results['test_accuracy'].std():.3f}")
print(f"Train accuracy: {results['train_accuracy'].mean():.3f} ± {results['train_accuracy'].std():.3f}")
Stratified K-Fold
Use when: classification task with class imbalance. Preserves class distribution in each fold.
from sklearn.model_selection import StratifiedKFold
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
results = cross_validate(model, X, y, cv=skf, scoring='f1_macro')
Group K-Fold
Use when: samples are not independent — they come from groups (e.g., multiple measurements per patient, sentences from the same document). Leaking groups across folds gives optimistically biased estimates.
from sklearn.model_selection import GroupKFold
gkf = GroupKFold(n_splits=5)
results = cross_validate(model, X, y, cv=gkf.split(X, y, groups=groups),
scoring='f1_macro')
Time-Series Split
Use when: data has temporal ordering. Future data must never appear in training folds.
Expanding window (default TimeSeriesSplit) — training set grows with each fold:
from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5)
for train_idx, val_idx in tscv.split(X):
X_train, X_val = X[train_idx], X[val_idx]
y_train, y_val = y[train_idx], y[val_idx]
model.fit(X_train, y_train)
Sliding window — fixed-size training window (manual):
window_size = 1000
step_size = 200
fold_results = []
for start in range(0, len(X) - window_size - step_size, step_size):
train_idx = slice(start, start + window_size)
val_idx = slice(start + window_size, start + window_size + step_size)
model.fit(X[train_idx], y[train_idx])
score = model.score(X[val_idx], y[val_idx])
fold_results.append(score)
print(f"Sliding-window val: {np.mean(fold_results):.3f} ± {np.std(fold_results):.3f}")
Nested Cross-Validation (Hyperparameter Tuning + Evaluation)
Use nested CV when you tune hyperparameters AND want an unbiased generalization estimate. The outer loop estimates generalization; the inner loop selects hyperparameters.
from sklearn.model_selection import StratifiedKFold, GridSearchCV, cross_val_score
from sklearn.svm import SVC
outer_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
inner_cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=0)
param_grid = {'C': [0.1, 1, 10], 'kernel': ['rbf', 'linear']}
clf = GridSearchCV(SVC(), param_grid, cv=inner_cv, scoring='f1_macro')
nested_scores = cross_val_score(clf, X, y, cv=outer_cv, scoring='f1_macro')
print(f"Nested CV F1: {nested_scores.mean():.3f} ± {nested_scores.std():.3f}")
Rule: never use the same CV split for both tuning and evaluation — this is a common source of optimistic bias.
Metric Selection Guide by Task Type
Classification
| Metric | When to Use | When NOT to Use |
|---|
| Accuracy | Balanced classes only | Imbalanced datasets |
| Balanced Accuracy | Imbalanced, multi-class | — |
| Precision / Recall | When false positives or false negatives have asymmetric costs | Don't use alone |
| F1 (macro) | Multi-class, treat all classes equally | When class sizes matter |
| F1 (weighted) | Multi-class, weight by support | Obscures minority class performance |
| AUC-ROC | Binary, probabilistic output | Severely imbalanced (prefer AUC-PR) |
| AUC-PR | Imbalanced binary problems | Not useful for balanced data |
| MCC | Best single metric for imbalanced binary | Less intuitive to report |
from sklearn.metrics import (
accuracy_score, balanced_accuracy_score,
precision_recall_fscore_support, roc_auc_score,
average_precision_score, matthews_corrcoef,
confusion_matrix, classification_report
)
import numpy as np
def classification_metrics(y_true, y_pred, y_prob=None, pos_label=1):
"""Compute full classification metric suite."""
results = {}
results['accuracy'] = accuracy_score(y_true, y_pred)
results['balanced_accuracy'] = balanced_accuracy_score(y_true, y_pred)
p, r, f1, support = precision_recall_fscore_support(
y_true, y_pred, average=None, zero_division=0
)
results['precision_per_class'] = p
results['recall_per_class'] = r
results['f1_per_class'] = f1
for avg in ['macro', 'micro', 'weighted']:
_, _, f, _ = precision_recall_fscore_support(
y_true, y_pred, average=avg, zero_division=0
)
results[f'f1_{avg}'] = f
results['mcc'] = matthews_corrcoef(y_true, y_pred)
results['confusion_matrix'] = confusion_matrix(y_true, y_pred)
if y_prob is not None:
if y_prob.ndim == 2:
results['auc_roc'] = roc_auc_score(
y_true, y_prob, multi_class='ovr', average='macro'
)
results['auc_pr'] = average_precision_score(
y_true, y_prob, average='macro'
)
else:
results['auc_roc'] = roc_auc_score(y_true, y_prob)
results['auc_pr'] = average_precision_score(y_true, y_prob)
class_counts = np.bincount(y_true)
imbalance_ratio = class_counts.max() / class_counts.min()
results['imbalance_ratio'] = imbalance_ratio
if imbalance_ratio > 3:
results['imbalance_warning'] = (
f"Class imbalance detected (ratio {imbalance_ratio:.1f}:1). "
"Do NOT rely on accuracy. Use F1-macro, AUC-PR, or MCC."
)
return results
Regression
| Metric | Formula | When to Use |
|---|
| RMSE | √(mean((ŷ-y)²)) | Default; penalizes large errors; interpretable in target units |
| MAE | mean(|ŷ-y|) | When outliers are expected and should not dominate |
| R² | 1 - SS_res/SS_tot | Explained variance; use as secondary metric |
| MAPE | mean(|ŷ-y|/|y|)×100 | When relative error matters; AVOID when y≈0 |
| Huber Loss | Hybrid MAE/MSE | Robust to outliers; use δ≈1.35×MAD |
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
import numpy as np
def regression_metrics(y_true, y_pred, huber_delta=1.35):
"""Compute full regression metric suite."""
results = {}
results['rmse'] = np.sqrt(mean_squared_error(y_true, y_pred))
results['mae'] = mean_absolute_error(y_true, y_pred)
results['r2'] = r2_score(y_true, y_pred)
nonzero = y_true != 0
if nonzero.sum() > 0:
results['mape'] = np.mean(
np.abs((y_true[nonzero] - y_pred[nonzero]) / y_true[nonzero])
) * 100
else:
results['mape'] = float('nan')
residuals = y_true - y_pred
huber = np.where(
np.abs(residuals) <= huber_delta,
0.5 * residuals**2,
huber_delta * (np.abs(residuals) - 0.5 * huber_delta)
)
results['huber_loss'] = np.mean(huber)
return results
Clustering
Distinguish external metrics (require true labels) from internal metrics (only use features):
from sklearn.metrics import (
silhouette_score, davies_bouldin_score,
adjusted_rand_score, normalized_mutual_info_score
)
def clustering_metrics(X, labels_pred, labels_true=None):
results = {}
if len(set(labels_pred)) > 1:
results['silhouette'] = silhouette_score(X, labels_pred)
results['davies_bouldin'] = davies_bouldin_score(X, labels_pred)
if labels_true is not None:
results['ari'] = adjusted_rand_score(labels_true, labels_pred)
results['nmi'] = normalized_mutual_info_score(
labels_true, labels_pred, average_method='arithmetic'
)
return results
Ranking / Retrieval
import numpy as np
def ndcg_at_k(relevance_scores, k):
"""relevance_scores: list of relevance for ranked items (desc order)."""
def dcg(scores):
return sum(rel / np.log2(i + 2) for i, rel in enumerate(scores[:k]))
ideal = sorted(relevance_scores, reverse=True)
return dcg(relevance_scores) / (dcg(ideal) + 1e-10)
def mean_average_precision(relevant_sets, ranked_lists):
"""AP averaged over queries."""
aps = []
for relevant, ranked in zip(relevant_sets, ranked_lists):
hits, ap = 0, 0.0
for i, item in enumerate(ranked, 1):
if item in relevant:
hits += 1
ap += hits / i
aps.append(ap / max(len(relevant), 1))
return np.mean(aps)
def mean_reciprocal_rank(relevant_sets, ranked_lists):
rrs = []
for relevant, ranked in zip(relevant_sets, ranked_lists):
for i, item in enumerate(ranked, 1):
if item in relevant:
rrs.append(1 / i)
break
else:
rrs.append(0.0)
return np.mean(rrs)
Generation / Seq2Seq
import evaluate
bleu = evaluate.load("bleu")
result = bleu.compute(predictions=["the cat sat"], references=[["the cat sat on the mat"]])
print(f"BLEU: {result['bleu']:.4f}")
rouge = evaluate.load("rouge")
result = rouge.compute(predictions=["the cat sat"], references=["the cat sat on the mat"])
bertscore = evaluate.load("bertscore")
result = bertscore.compute(
predictions=["the cat sat"],
references=["the cat sat on the mat"],
lang="en"
)
print(f"BERTScore F1: {result['f1'][0]:.4f}")
Baseline Requirements
Every paper must include baselines. Report your model's metrics alongside all of the following that apply:
| Baseline | When Required | Code |
|---|
| Random | Always | DummyClassifier(strategy='uniform') |
| Majority class | Classification | DummyClassifier(strategy='most_frequent') |
| Mean predictor | Regression | DummyRegressor(strategy='mean') |
| Linear model | Always | LogisticRegression() / Ridge() |
| Published SOTA | If benchmarking | Cite paper + reproduce or use reported numbers |
from sklearn.dummy import DummyClassifier, DummyRegressor
from sklearn.linear_model import LogisticRegression, Ridge
from sklearn.model_selection import cross_val_score
baselines = {
'random': DummyClassifier(strategy='uniform', random_state=42),
'majority': DummyClassifier(strategy='most_frequent'),
'logistic_reg': LogisticRegression(max_iter=1000, random_state=42),
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for name, clf in baselines.items():
scores = cross_val_score(clf, X_train, y_train, cv=cv, scoring='f1_macro')
print(f"{name:20s}: {scores.mean():.3f} ± {scores.std():.3f}")
scores = cross_val_score(your_model, X_train, y_train, cv=cv, scoring='f1_macro')
print(f"{'your_model':20s}: {scores.mean():.3f} ± {scores.std():.3f}")
A model that does not beat the majority-class or linear baseline is not publishable — investigate before proceeding.
Train / Val / Test Discipline
Split Ratios
| Dataset Size | Train | Val | Test |
|---|
| < 1K samples | 60% | 20% | 20% (or use CV) |
| 1K–100K | 70% | 15% | 15% |
| > 100K | 80–90% | 5–10% | 5–10% |
from sklearn.model_selection import train_test_split
X_temp, X_test, y_temp, y_test = train_test_split(
X, y, test_size=0.15, stratify=y, random_state=42
)
X_train, X_val, y_train, y_val = train_test_split(
X_temp, y_temp, test_size=0.176, stratify=y_temp, random_state=42
)
print(f"Train: {len(X_train)}, Val: {len(X_val)}, Test: {len(X_test)}")
Data Leakage Prevention
Leakage causes inflated metrics that vanish at deployment. Check each of the following:
- Fit preprocessing only on training data:
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
pipe = Pipeline([
('scaler', StandardScaler()),
('clf', LogisticRegression())
])
pipe.fit(X_train, y_train)
pipe.score(X_val, y_val)
scaler = StandardScaler().fit(X)
- Never use test set for any model selection decision — not for threshold tuning, feature selection, or early stopping.
- Group leakage: ensure the same entity (patient, user, document) does not appear in both train and test.
- Temporal leakage: ensure all test timestamps are strictly after all training timestamps.
When to Touch the Test Set
Touch the test set exactly once: after all hyperparameter decisions are finalized using the validation set or CV. If you look at test results and change anything, your test set has become a validation set — find or hold out new data for a true final evaluation.
Ablation Study Design Protocol
Ablations identify which components actually matter. A weak ablation section is a common reviewer complaint.
Protocol:
- Define your full system: list all components (e.g., data augmentation, pre-training, attention mechanism, loss function, post-processing).
- Train the full system first — this is your ceiling.
- Remove one component at a time and retrain from scratch.
- Report the metric delta (full − ablated) for each component.
- Run the same CV setup for all ablations so comparisons are valid.
- If components interact, run a 2×2 factorial for the interacting pair.
import pandas as pd
components = ['data_augmentation', 'pretrained_encoder', 'attention', 'auxiliary_loss']
results = {}
results['full_system'] = run_experiment(use_all_components=True)
for component in components:
config = {c: True for c in components}
config[component] = False
results[f'w/o_{component}'] = run_experiment(**config)
df = pd.DataFrame(results, index=['f1_macro', 'std']).T
df['delta_from_full'] = df['f1_macro'] - df.loc['full_system', 'f1_macro']
print(df.sort_values('delta_from_full'))
Overfitting Signals
Train / Val Gap Analysis
import matplotlib.pyplot as plt
train_scores, val_scores = [], []
for train_idx, val_idx in cv.split(X, y):
model.fit(X[train_idx], y[train_idx])
train_scores.append(metric(y[train_idx], model.predict(X[train_idx])))
val_scores.append(metric(y[val_idx], model.predict(X[val_idx])))
gap = np.mean(train_scores) - np.mean(val_scores)
print(f"Train: {np.mean(train_scores):.3f}, Val: {np.mean(val_scores):.3f}, Gap: {gap:.3f}")
if gap > 0.05:
print("WARNING: Large train/val gap — likely overfitting.")
Learning Curves
from sklearn.model_selection import learning_curve
train_sizes, train_scores, val_scores = learning_curve(
model, X, y, cv=5, scoring='f1_macro',
train_sizes=np.linspace(0.1, 1.0, 10), n_jobs=-1
)
plt.figure(figsize=(8, 5))
plt.plot(train_sizes, train_scores.mean(axis=1), label='Train')
plt.fill_between(train_sizes,
train_scores.mean(1) - train_scores.std(1),
train_scores.mean(1) + train_scores.std(1), alpha=0.2)
plt.plot(train_sizes, val_scores.mean(axis=1), label='Validation')
plt.fill_between(train_sizes,
val_scores.mean(1) - val_scores.std(1),
val_scores.mean(1) + val_scores.std(1), alpha=0.2)
plt.xlabel('Training Set Size')
plt.ylabel('F1 (macro)')
plt.legend()
plt.title('Learning Curve')
plt.tight_layout()
plt.savefig('learning_curve.pdf', dpi=300, bbox_inches='tight')
Regularization Checklist
When overfitting is detected, try in order:
Hyperparameter Search
| Method | When to Use | Library |
|---|
| Grid Search | ≤ 3 params, small search space | GridSearchCV |
| Random Search | > 3 params, limited budget | RandomizedSearchCV |
| Bayesian (TPE) | Large budget, expensive model | optuna |
import optuna
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score
def objective(trial):
params = {
'n_estimators': trial.suggest_int('n_estimators', 50, 500),
'max_depth': trial.suggest_int('max_depth', 2, 8),
'learning_rate': trial.suggest_float('learning_rate', 1e-3, 0.3, log=True),
'subsample': trial.suggest_float('subsample', 0.5, 1.0),
'min_samples_leaf': trial.suggest_int('min_samples_leaf', 1, 20),
}
clf = GradientBoostingClassifier(**params, random_state=42)
cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
return cross_val_score(clf, X_train, y_train, cv=cv, scoring='f1_macro').mean()
study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=100, timeout=600)
print("Best params:", study.best_params)
print("Best val F1:", study.best_value)
Statistical Significance
Bootstrap Confidence Intervals for Metrics
import numpy as np
from sklearn.utils import resample
def bootstrap_metric(y_true, y_pred, metric_fn, n_bootstrap=1000, ci=0.95):
"""Bootstrap CI for any scalar metric."""
scores = []
for _ in range(n_bootstrap):
idx = resample(np.arange(len(y_true)), replace=True)
scores.append(metric_fn(y_true[idx], y_pred[idx]))
alpha = (1 - ci) / 2
lower = np.percentile(scores, alpha * 100)
upper = np.percentile(scores, (1 - alpha) * 100)
return np.mean(scores), lower, upper
from sklearn.metrics import f1_score
mean_f1, lo, hi = bootstrap_metric(
y_test, y_pred,
lambda yt, yp: f1_score(yt, yp, average='macro')
)
print(f"F1 = {mean_f1:.3f} [{lo:.3f}, {hi:.3f}]")
Paired t-Test for Comparing Two Models
Use when you have matched predictions from two models on the same CV folds:
from scipy import stats
model_a_scores = np.array([0.81, 0.83, 0.79, 0.82, 0.80])
model_b_scores = np.array([0.76, 0.78, 0.74, 0.77, 0.75])
t_stat, p_value = stats.ttest_rel(model_a_scores, model_b_scores)
diff = model_a_scores - model_b_scores
mean_diff = diff.mean()
ci = stats.t.interval(0.95, len(diff)-1, loc=mean_diff, scale=stats.sem(diff))
print(f"Mean difference: {mean_diff:.4f}, 95% CI: [{ci[0]:.4f}, {ci[1]:.4f}]")
print(f"t({len(diff)-1}) = {t_stat:.3f}, p = {p_value:.4f}")
if p_value < 0.05:
print("Models differ significantly (p < 0.05)")
else:
print("No significant difference detected — difference may be noise")
For multiple model comparisons, use Bonferroni correction or Wilcoxon signed-rank test as a nonparametric alternative.
Reporting Checklist
A complete results section must include all of the following:
Metrics
Baselines
Experimental Setup
Ablations
Reproducibility
Statistical Validity
Resources
Scripts Directory
- evaluate_model.py: Command-line evaluation script for classification, regression, clustering, and ranking. Run as:
python evaluate_model.py --predictions pred.csv --labels labels.csv --task classification --output report.md
Common Pitfalls
- Reporting a single number: Always pair with CI or fold std.
- Accuracy on imbalanced data: A 95%-majority-class dataset gives 95% accuracy with a trivial classifier.
- Evaluating on training data: Always use held-out fold or test set.
- Tuning on test data: Any decision made after looking at test results makes test = validation.
- Ignoring group structure: Patient-level splits look much worse than sample-level splits — that is correct, not a bug.
- Running ablations with different random seeds: Seed variation can exceed component contribution — control seeds.
- Claiming significance without a significance test: A 0.2 F1 improvement in one fold is not "significant".