SuperAPI AI Directory

model-evaluation-patterns

Agents & models

View repository

Updated 10-16 09:59

About this skill

Model Evaluation Patterns

Expert patterns for classification metrics, calibration, sliced evaluation, fairness, and LLM evaluation.

Pattern 1: Complete Classification Evaluation Report

All key metrics in one evaluation pass.

import numpy as np
import pandas as pd
from sklearn.metrics import (
    f1_score, accuracy_score, roc_auc_score, average_precision_score,
    matthews_corrcoef, confusion_matrix, classification_report,
    brier_score_loss, calibration_curve
)
from scipy import stats
import matplotlib.pyplot as plt

def full_evaluation_report( y_true: np.ndarray, y_pred: np.ndarray, y_proba: np.ndarray, label_names: list = None, n_bootstrap: int = 1000, ) -> dict: """Comprehensive evaluation with bootstrapped confidence intervals.""" # Core metrics metrics = { "accuracy": accuracy_score(y_true, y_pred), "f1_macro": f1_score(y_true, y_pred, average="macro"), "f1_weighted": f1_score(y_true, y_pred, average="weighted"), "mcc": matthews_corrcoef(y_true, y_pred), }

# Binary-only metrics
if y_proba.ndim == 2 and y_proba.shape[1] == 2:
    proba_pos = y_proba[:, 1]
    metrics["auc_roc"] = roc_auc_score(y_true, proba_pos)
    metrics["auc_pr"] = average_precision_score(y_true, proba_pos)
    metrics["brier_score"] = brier_score_loss(y_true, proba_pos)

# Bootstrap confidence intervals (95%)
ci = {}
rng = np.random.default_rng(42)
for metric_name, metric_fn in [
    ("f1_macro", lambda yt, yp: f1_score(yt, yp, average="macro")),
    ("auc_roc", lambda yt, yp: roc_auc_score(yt, y_proba[np.arange(len(yt))][:, 1]) if y_proba.shape[1] == 2 else None),
]:
    if metric_name not in metrics:
        continue
    boot_scores = []
    for _ in range(n_bootstrap):
        idx = rng.integers(0, len(y_true), len(y_true))
        if len(np.unique(y_true[idx])) < 2:
            continue
        boot_scores.append(metric_fn(y_true[idx], y_pred[idx]))
    if boot_scores:
        ci[metric_name] = (
            np.percentile(boot_scores, 2.5),
            np.percentile(boot_scores, 97.5)
        )

# Per-class metrics
report = classification_report(y_true, y_pred,
                               target_names=label_names, output_dict=True)

return {
    "overall_metrics": metrics,
    "confidence_intervals_95": ci,
    "per_class_metrics": report,
    "confusion_matrix": confusion_matrix(y_true, y_pred).tolist(),
    "n_samples": len(y_true),
}
Usage

report = full_evaluation_report(y_test, y_pred, y_proba_test, label_names=["neg", "pos"]) print(f"AUC-ROC: {report['overall_metrics']['auc_roc']:.4f} " f"95% CI: [{report['confidence_intervals_95']['auc_roc'][0]:.4f}, " f"{report['confidence_intervals_95']['auc_roc'][1]:.4f}]")


## Pattern 2: Calibration Analysis

Check whether model probabilities reflect true frequencies.

```python
from sklearn.calibration import calibration_curve, CalibratedClassifierCV
from sklearn.metrics import brier_score_loss
import numpy as np
import matplotlib.pyplot as plt

def calibration_analysis(y_true: np.ndarray, y_proba: np.ndarray,
                          n_bins: int = 10, model_name: str = "Model") -> dict:
    """Compute calibration curve and ECE."""
    fraction_of_positives, mean_predicted_value = calibration_curve(
        y_true, y_proba, n_bins=n_bins, strategy="quantile"
    )

    # Expected Calibration Error
    n = len(y_true)
    bin_boundaries = np.linspace(0, 1, n_bins + 1)
    ece = 0.0
    for i in range(n_bins):
        mask = (y_proba >= bin_boundaries[i]) & (y_proba < bin_boundaries[i+1])
        if mask.sum() == 0:
            continue
        bin_acc = y_true[mask].mean()
        bin_conf = y_proba[mask].mean()
        bin_size = mask.sum()
        ece += (bin_size / n) * abs(bin_acc - bin_conf)

    brier = brier_score_loss(y_true, y_proba)

    # Determine calibration quality
    if ece < 0.05:
        quality = "well-calibrated"
    elif ece < 0.10:
        quality = "acceptable"
    else:
        quality = "poorly-calibrated"

    return {
        "ece": round(ece, 4),
        "brier_score": round(brier, 4),
        "quality": quality,
        "fraction_of_positives": fraction_of_positives.tolist(),
        "mean_predicted_value": mean_predicted_value.tolist(),
    }

def recalibrate_model(model, X_val, y_val, method: str = "isotonic"):
    """Apply Platt scaling or isotonic regression calibration."""
    calibrated = CalibratedClassifierCV(
        model,
        method=method,   # 'sigmoid' = Platt scaling, 'isotonic' = more flexible
        cv="prefit"      # model already fitted
    )
    calibrated.fit(X_val, y_val)
    return calibrated

# Compare calibration before/after
before = calibration_analysis(y_val, model.predict_proba(X_val)[:, 1], model_name="Uncalibrated")
cal_model = recalibrate_model(model, X_val, y_val, method="isotonic")
after = calibration_analysis(y_val, cal_model.predict_proba(X_val)[:, 1], model_name="Calibrated")
print(f"ECE before: {before['ece']:.4f} → after: {after['ece']:.4f}")

Pattern 3: Sliced Evaluation

Evaluate model performance by subgroup to find systematic failures.

import pandas as pd
import numpy as np
from sklearn.metrics import f1_score, roc_auc_score

def sliced_evaluation(
    df: pd.DataFrame,
    y_true_col: str,
    y_pred_col: str,
    y_proba_col: str,
    slice_cols: list[str],
    min_slice_size: int = 50
) -> pd.DataFrame:
    """
    Evaluate model performance for each subgroup in slice_cols.
    Returns DataFrame with metrics per slice.
    """
    y_true = df[y_true_col].values
    y_pred = df[y_pred_col].values
    y_proba = df[y_proba_col].values

    overall_f1 = f1_score(y_true, y_pred, average="weighted")
    overall_auc = roc_auc_score(y_true, y_proba)

    records = [{"slice": "OVERALL", "n": len(df),
                "f1_weighted": overall_f1, "auc_roc": overall_auc,
                "f1_delta": 0, "reliable": True}]

    for col in slice_cols:
        for val, group in df.groupby(col):
            n = len(group)
            yt = group[y_true_col].values
            yp = group[y_pred_col].values
            yprob = group[y_proba_col].values

            if len(np.unique(yt)) < 2:
                continue  # can't compute AUC with one class

            f1 = f1_score(yt, yp, average="weighted")
            auc = roc_auc_score(yt, yprob) if len(np.unique(yt)) == 2 else np.nan

            records.append({
                "slice": f"{col}={val}",
                "n": n,
                "f1_weighted": round(f1, 4),
                "auc_roc": round(auc, 4) if not np.isnan(auc) else None,
                "f1_delta": round(f1 - overall_f1, 4),
                "reliable": n >= min_slice_size,
            })

    result = pd.DataFrame(records)
    # Flag poor-performing slices
    result["concern"] = (
        (result["f1_delta"] < -0.05) & result["reliable"]
    )
    return result.sort_values("f1_delta")

# Usage
eval_df = pd.DataFrame({
    "y_true": y_test,
    "y_pred": y_pred,
    "y_proba": y_proba_pos,
    "user_segment": user_segments,
    "device_type": device_types,
    "country": countries,
})

slices = sliced_evaluation(
    eval_df, "y_true", "y_pred", "y_proba",
    slice_cols=["user_segment", "device_type", "country"]
)
print(slices[slices["concern"]].to_string(index=False))

Pattern 4: Fairness Metrics

Measure demographic parity and equalized odds across protected attributes.

import numpy as np
from fairlearn.metrics import (
    demographic_parity_difference,
    demographic_parity_ratio,
    equalized_odds_difference,
    MetricFrame
)
from sklearn.metrics import accuracy_score, f1_score, false_positive_rate

def fairness_report(
    y_true: np.ndarray,
    y_pred: np.ndarray,
    y_proba: np.ndarray,
    sensitive_features: np.ndarray,
    attribute_name: str = "group"
) -> dict:
    """
    Compute demographic parity and equalized odds.
    sensitive_features: array of group labels per example.
    """
    dp_diff = demographic_parity_difference(y_true, y_pred,
                                             sensitive_features=sensitive_features)
    dp_ratio = demographic_parity_ratio(y_true, y_pred,
                                         sensitive_features=sensitive_features)
    eo_diff = equalized_odds_difference(y_true, y_pred,
                                         sensitive_features=sensitive_features)

    # Per-group metrics
    metric_frame = MetricFrame(
        metrics={
            "accuracy": accuracy_score,
            "f1": f1_score,
            "selection_rate": lambda yt, yp: yp.mean(),
        },
        y_true=y_true,
        y_pred=y_pred,
        sensitive_features=sensitive_features
    )

    # Thresholds for concern
    concerns = []
    if abs(dp_diff) > 0.10:
        concerns.append(f"Demographic parity diff={dp_diff:.3f} exceeds threshold 0.10")
    if abs(eo_diff) > 0.10:
        concerns.append(f"Equalized odds diff={eo_diff:.3f} exceeds threshold 0.10")

    return {
        "attribute": attribute_name,
        "demographic_parity_difference": round(dp_diff, 4),
        "demographic_parity_ratio": round(dp_ratio, 4),
        "equalized_odds_difference": round(eo_diff, 4),
        "per_group_metrics": metric_frame.by_group.to_dict(),
        "concerns": concerns,
    }

Pattern 5: LLM Evaluation with BERTScore and RAGAS

Evaluate generative model outputs.

from evaluate import load
import numpy as np

# BERTScore: contextual embedding similarity
bertscore = load("bertscore")

def evaluate_generation(
    predictions: list[str],
    references: list[str],
    lang: str = "en"
) -> dict:
    """Compute BLEU, ROUGE, and BERTScore for generation evaluation."""
    # BLEU
    bleu = load("bleu")
    bleu_result = bleu.compute(
        predictions=predictions,
        references=[[r] for r in references]
    )

    # ROUGE
    rouge = load("rouge")
    rouge_result = rouge.compute(predictions=predictions, references=references)

    # BERTScore (correlates better with human judgment than BLEU)
    bert_result = bertscore.compute(
        predictions=predictions,
        references=references,
        lang=lang,
        model_type="microsoft/deberta-xlarge-mnli"
    )

    return {
        "bleu": round(bleu_result["bleu"], 4),
        "rouge1": round(rouge_result["rouge1"], 4),
        "rougeL": round(rouge_result["rougeL"], 4),
        "bertscore_f1_mean": round(np.mean(bert_result["f1"]), 4),
        "bertscore_precision_mean": round(np.mean(bert_result["precision"]), 4),
        "bertscore_recall_mean": round(np.mean(bert_result["recall"]), 4),
    }

# RAGAS for RAG evaluation
from ragas import evaluate as ragas_evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_recall, context_precision
from datasets import Dataset

def evaluate_rag_pipeline(
    questions: list[str],
    answers: list[str],
    contexts: list[list[str]],
    ground_truths: list[str]
) -> dict:
    dataset = Dataset.from_dict({
        "question": questions,
        "answer": answers,
        "contexts": contexts,
        "ground_truth": ground_truths,
    })
    result = ragas_evaluate(
        dataset,
        metrics=[faithfulness, answer_relevancy, context_recall, context_precision]
    )
    return result.to_pandas().mean().to_dict()

Anti-Patterns

Anti-Pattern 1: Reporting Only Accuracy

Accuracy on a 95:5 imbalanced dataset can be 95% for a model that always predicts the majority class. Use MCC, AUC-PR, or F1-macro. Never report accuracy alone for imbalanced problems.

Anti-Pattern 2: No Confidence Intervals

A single-run metric number is a point estimate. Bootstrap 1000 samples to get 95% CI. "Model A: 0.847 F1 vs Model B: 0.851 F1" is noise if CIs overlap. Statistical significance matters.

Anti-Pattern 3: Only Overall Metrics

A model with 92% overall accuracy may perform at 65% accuracy on a specific user segment. Sliced evaluation is not optional — it is the difference between knowing your model works and assuming it works.

Anti-Pattern 4: Ignoring Calibration for Probability-Based Decisions

If your downstream system thresholds probabilities (e.g., send SMS if P(churn) > 0.7), calibration determines whether that threshold makes sense. A model with AUC=0.95 but ECE=0.20 will generate wrong decisions in production.