Salta el contingut

Avaluació de Models de Machine Learning

Introducció

Construir un model és la part fàcil. Avaluar-lo honestament —i entendre exactament en quins casos falla— és l'habilitat que distingeix els professionals competents dels novells. Un model pot mostrar un 99% d'accuracy en un dataset i ser completament inútil en producció. Les mètriques equivocades, l'avaluació incorrecta o la manca de monitorització continuada han causat fracassos espectaculars en projectes ML d'empreses punteres.

Aquest tema cobreix el cicle complet d'avaluació: des de la matriu de confusió fins als tests estadístics per comparar models, passant per les mètriques específiques de cada domini i les eines MLOps per a la monitorització en producció.


Matriu de Confusió

La matriu de confusió és el punt de partida per a qualsevol avaluació de classificació. Per a un problema binari (positiu/negatiu), té quatre cel·les:

Predicció: Positiu Predicció: Negatiu
Real: Positiu TP (Vertader Positiu) FN (Fals Negatiu)
Real: Negatiu FP (Fals Positiu) TN (Vertader Negatiu)
  • TP (True Positive): El model prediu positiu i és positiu. Detecció correcta.
  • TN (True Negative): El model prediu negatiu i és negatiu. Rebuig correcte.
  • FP (False Positive): El model prediu positiu però és negatiu. Error tipus I.
  • FN (False Negative): El model prediu negatiu però és positiu. Error tipus II.
from sklearn.metrics import (confusion_matrix, ConfusionMatrixDisplay,
                              classification_report, roc_auc_score,
                              roc_curve, precision_recall_curve,
                              average_precision_score)
import matplotlib.pyplot as plt
import numpy as np

# Exemple: model de detecció de frau
y_true = np.array([0, 0, 1, 1, 0, 1, 0, 1, 0, 0])
y_pred = np.array([0, 1, 1, 0, 0, 1, 0, 1, 1, 0])
y_prob = np.array([0.1, 0.7, 0.9, 0.3, 0.2, 0.8, 0.15, 0.95, 0.65, 0.05])

# Matriu de confusió
cm = confusion_matrix(y_true, y_pred)
TP, FN = cm[1, 1], cm[1, 0]
FP, TN = cm[0, 1], cm[0, 0]
print(f"TP={TP}, TN={TN}, FP={FP}, FN={FN}")

# Visualitzar
disp = ConfusionMatrixDisplay(confusion_matrix=cm,
                               display_labels=["No frau", "Frau"])
disp.plot(cmap="Blues", colorbar=False)
plt.title("Matriu de Confusio")
plt.tight_layout()
plt.savefig("matriu_confusio.png", dpi=150)

Mètriques de Classificació

Accuracy

$$\text{Accuracy} = \frac{TP + TN}{TP + TN + FP + FN}$$

L'accuracy mesura la proporció total de prediccions correctes. Enganyosa per a classes desequilibrades: un model que sempre prediu "no frau" en un dataset amb 1% de frau obté 99% d'accuracy, però és completament inútil.

Precision (Precisió)

$$\text{Precision} = \frac{TP}{TP + FP}$$

De tots els casos que el model prediu com a positius, quants ho eren realment? Alta precision → pocs falsos positius. Important quan el cost dels falsos positius és alt (ex: classificar un correu legítim com a spam).

Recall (Sensibilitat / Exhaustivitat)

$$\text{Recall} = \frac{TP}{TP + FN}$$

De tots els casos realment positius, quants va detectar el model? Alt recall → pocs falsos negatius. Important quan el cost dels falsos negatius és alt (ex: no detectar un càncer, no detectar frau).

F1-Score

$$F_1 = 2 \cdot \frac{\text{Precision} \cdot \text{Recall}}{\text{Precision} + \text{Recall}}$$

La mitjana harmònica de precision i recall. Útil quan les classes estan desequilibrades i volem un únic valor que capturi tant la precisió com l'exhaustivitat.

AUC-ROC

La corba ROC (Receiver Operating Characteristic) representa el trade-off entre la taxa de vertaders positius (TPR = Recall) i la taxa de falsos positius (FPR = FP/(FP+TN)) a mesura que variem el llindar de classificació. L'AUC (Area Under the Curve) resumeix la corba en un únic valor entre 0 i 1.

  • AUC = 1.0: Model perfecte
  • AUC = 0.5: Model equivalent a aleatori
  • AUC > 0.9: Excel·lent
  • AUC 0.7-0.9: Bo
  • AUC < 0.7: Mediocre
# Corba ROC i AUC
fpr, tpr, thresholds = roc_curve(y_true, y_prob)
auc = roc_auc_score(y_true, y_prob)

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# ROC Curve
axes[0].plot(fpr, tpr, color="darkorange", lw=2, label=f"ROC (AUC = {auc:.3f})")
axes[0].plot([0, 1], [0, 1], color="navy", lw=1, linestyle="--", label="Aleatori")
axes[0].set_xlim([0.0, 1.0])
axes[0].set_ylim([0.0, 1.05])
axes[0].set_xlabel("False Positive Rate")
axes[0].set_ylabel("True Positive Rate (Recall)")
axes[0].set_title("Corba ROC")
axes[0].legend(loc="lower right")
axes[0].grid(True, alpha=0.3)

# Precision-Recall Curve (millor per a classes molt desequilibrades)
precisions, recalls, _ = precision_recall_curve(y_true, y_prob)
ap = average_precision_score(y_true, y_prob)

axes[1].plot(recalls, precisions, color="steelblue", lw=2, label=f"PR (AP = {ap:.3f})")
axes[1].axhline(y=y_true.mean(), color="navy", lw=1, linestyle="--",
                label=f"Baseline ({y_true.mean():.3f})")
axes[1].set_xlabel("Recall")
axes[1].set_ylabel("Precision")
axes[1].set_title("Corba Precision-Recall")
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("roc_pr_curves.png", dpi=150)
print(f"AUC-ROC: {auc:.4f}")
print(f"Average Precision: {ap:.4f}")

# Informe complet
print("\n" + classification_report(y_true, y_pred, target_names=["No frau", "Frau"]))

Optimització del Llindar de Classificació

El llindar per defecte de 0.5 rarament és òptim. Cal triar el llindar en funció dels costos del negoci:

# Trobar el llindar que maximitza el F1-score
from sklearn.metrics import f1_score

millor_llindar = 0.5
millor_f1 = 0.0

for llindar in np.arange(0.1, 0.9, 0.01):
    y_pred_llindar = (y_prob >= llindar).astype(int)
    f1 = f1_score(y_true, y_pred_llindar, zero_division=0)
    if f1 > millor_f1:
        millor_f1 = f1
        millor_llindar = llindar

print(f"Millor llindar: {millor_llindar:.2f} (F1={millor_f1:.4f})")

Mètriques de Regressió

from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
import numpy as np

y_true_reg = np.array([3.5, 2.0, 8.0, 5.5, 1.0, 6.0, 4.0, 9.0])
y_pred_reg = np.array([3.2, 2.5, 7.8, 5.0, 1.5, 6.3, 3.8, 8.5])

# MSE — Mean Squared Error
mse = mean_squared_error(y_true_reg, y_pred_reg)
print(f"MSE:  {mse:.4f}")

# RMSE — Root Mean Squared Error (mateixa escala que la variable objectiu)
rmse = np.sqrt(mse)
print(f"RMSE: {rmse:.4f}")

# MAE — Mean Absolute Error (robust a outliers)
mae = mean_absolute_error(y_true_reg, y_pred_reg)
print(f"MAE:  {mae:.4f}")

# MAPE — Mean Absolute Percentage Error (interpretable en %)
mape = np.mean(np.abs((y_true_reg - y_pred_reg) / y_true_reg)) * 100
print(f"MAPE: {mape:.2f}%")

# R² — Coeficient de determinació (proporció de variança explicada)
r2 = r2_score(y_true_reg, y_pred_reg)
print(f"R²:   {r2:.4f}")  # 1.0 = perfecte, 0.0 = model constant, <0 = pitjor que la mitjana
Mètrica Fórmula Sensibilitat als outliers Interpretació
MSE $\frac{1}{n}\sum(y_i - \hat{y}_i)^2$ Molt alta En unitats al quadrat
RMSE $\sqrt{MSE}$ Alta En unitats originals
MAE $\frac{1}{n}\sum y_i - \hat{y}_i $
MAPE $\frac{100}{n}\sum\frac{ y_i - \hat{y}_i }{y_i}$
$1 - \frac{SS_{res}}{SS_{tot}}$ Mitjana Proporció variança explicada

Mètriques NLP

BLEU (Bilingual Evaluation Understudy)

Mesura la qualitat de la traducció automàtica comparant n-grames de la traducció generada amb una o més traduccions de referència. Valor entre 0 i 1 (o 0-100).

from nltk.translate.bleu_score import sentence_bleu, corpus_bleu, SmoothingFunction

referencia = [["el", "gat", "va", "seure", "al", "terra"]]
hipotesi = ["el", "gat", "seu", "al", "paviment"]

bleu_1 = sentence_bleu(referencia, hipotesi, weights=(1, 0, 0, 0))
bleu_4 = sentence_bleu(referencia, hipotesi, weights=(0.25, 0.25, 0.25, 0.25),
                        smoothing_function=SmoothingFunction().method1)

print(f"BLEU-1: {bleu_1:.4f}")
print(f"BLEU-4: {bleu_4:.4f}")

ROUGE (Recall-Oriented Understudy for Gisting Evaluation)

Usada per avaluar la qualitat del resum automàtic. ROUGE-N mesura la sobreposició de n-grames; ROUGE-L mesura la subsequència comuna més llarga.

BERTScore

A diferència de BLEU/ROUGE que comparen textos exactament, BERTScore utilitza embeddings contextuals de BERT per mesurar la similitud semàntica. Millor correlació amb el judici humà.

from bert_score import score as bert_score

candidats = ["el gat seu al terra"]
referencies = ["el gat va seure al terra"]

P, R, F1 = bert_score(candidats, referencies, lang="ca", verbose=False)
print(f"BERTScore F1: {F1.mean().item():.4f}")

Perplexitat

La perplexitat mesura com de bé un model de llenguatge prediu una seqüència de text. Com menor és la perplexitat, millor el model:

$$PPL = \exp\left(-\frac{1}{N}\sum_{i=1}^{N}\log P(w_i | w_1, \dots, w_{i-1})\right)$$


Mètriques de Generació d'Imatge

FID (Fréchet Inception Distance): Mesura la distància entre la distribució de features (extretes per InceptionV3) de les imatges reals i generades. Com menor és el FID, millor la qualitat i diversitat de les imatges generades.

Inception Score (IS): Mesura la qualitat i diversitat de les imatges generades. Imatges nítides i diverses → alt IS.

# FID amb la biblioteca pytorch-fid
# pip install pytorch-fid
# python -m pytorch_fid path/to/real_images path/to/generated_images

Biaix-Variança: Diagnòstic Visual

import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import validation_curve
from sklearn.tree import DecisionTreeClassifier

# Corba de validació: rendiment en funció de la complexitat del model
param_range = [1, 2, 3, 5, 7, 10, 15, 20, 30]
train_scores, val_scores = validation_curve(
    DecisionTreeClassifier(random_state=42),
    X_train, y_train,
    param_name="max_depth",
    param_range=param_range,
    cv=5,
    scoring="f1"
)

plt.figure(figsize=(10, 6))
plt.plot(param_range, train_scores.mean(axis=1), "o-", color="blue", label="Train F1")
plt.plot(param_range, val_scores.mean(axis=1), "o-", color="red", label="Validation F1")
plt.fill_between(param_range,
                 train_scores.mean(axis=1) - train_scores.std(axis=1),
                 train_scores.mean(axis=1) + train_scores.std(axis=1),
                 alpha=0.1, color="blue")
plt.fill_between(param_range,
                 val_scores.mean(axis=1) - val_scores.std(axis=1),
                 val_scores.mean(axis=1) + val_scores.std(axis=1),
                 alpha=0.1, color="red")
plt.xlabel("Profunditat màxima de l'arbre (max_depth)")
plt.ylabel("F1-Score")
plt.title("Corba de Validacio — Biaix vs Varianca")
plt.legend()
plt.grid(True, alpha=0.3)
plt.axvline(x=5, color="green", linestyle="--", alpha=0.5, label="Punt optim aprox.")
plt.tight_layout()
plt.savefig("validation_curve.png", dpi=150)

Validació Creuada Avançada

Time Series Split

Per a sèries temporals, la cross-validation estàndard no és vàlida perquè pot usar dades futures per predir el passat (leakage temporal). TimeSeriesSplit garanteix que el conjunt de validació sempre és posterior al d'entrenament.

from sklearn.model_selection import TimeSeriesSplit, cross_val_score

tscv = TimeSeriesSplit(n_splits=5)

scores_ts = cross_val_score(
    model_pipeline, X, y, cv=tscv, scoring="neg_root_mean_squared_error"
)
print(f"RMSE per plec: {-scores_ts}")
print(f"RMSE mig: {-scores_ts.mean():.4f} ± {scores_ts.std():.4f}")

Tests Estadístics per Comparar Models

Test de McNemar

Compara dos classificadors sobre el mateix test set. Adequat quan tenim les prediccions individuals (no sols la mètrica agregada).

from statsmodels.stats.contingency_tables import mcnemar

# Taula de contingència
# [ambdós correctes, model1 correcte i model2 incorrecte]
# [model1 incorrecte i model2 correcte, ambdós incorrectes]
contingency_table = np.array([[50, 10],
                               [5,  35]])
result = mcnemar(contingency_table, exact=True)
print(f"McNemar statistic: {result.statistic:.4f}")
print(f"p-valor: {result.pvalue:.4f}")
print("Diferencia significativa" if result.pvalue < 0.05 else "Sense diferencia significativa")

MLOps: Monitorització de Models en Producció

El cicle de vida d'un model no s'acaba amb el desplegament. Els models en producció es degraden amb el temps per dos motius principals:

Data Drift: La distribució de les dades d'entrada canvia respecte a la distribució d'entrenament. Exemple: un model de detecció de frau entrenat pre-pandèmia rep patrons de transaccions totalment nous.

Concept Drift: La relació entre les features i la variable objectiu canvia. Exemple: un model de scoring de crèdit entrenat en una economia normal falla en una recessió.

graph TD
    A["Model en produccio"] --> B["Monitoring"]
    B --> C{"Drift detectat?"}
    C -->|No| B
    C -->|Si| D["Alerta al equip"]
    D --> E{"Quin tipus?"}
    E -->|Data drift| F["Reentrenament amb dades noves"]
    E -->|Concept drift| G["Revisio de features i arquitectura"]
    F --> H["Avaluacio del nou model"]
    G --> H
    H --> I{"Millora?"}
    I -->|Si| J["Desplegament nou model"]
    I -->|No| K["Investigacio addicional"]
    J --> A

MLflow: Seguiment d'Experiments

import mlflow
import mlflow.sklearn

# Configurar MLflow
mlflow.set_tracking_uri("http://localhost:5000")
mlflow.set_experiment("frau_bancari_v2")

with mlflow.start_run(run_name="xgboost_optuna"):
    # Log de paràmetres
    mlflow.log_param("n_estimators", 300)
    mlflow.log_param("max_depth", 6)
    mlflow.log_param("learning_rate", 0.05)
    mlflow.log_param("dataset_version", "2025-03")

    # Entrenar model
    model_pipeline.fit(X_train, y_train)
    y_pred = model_pipeline.predict(X_test)
    y_proba = model_pipeline.predict_proba(X_test)[:, 1]

    # Log de mètriques
    mlflow.log_metric("accuracy", (y_pred == y_test).mean())
    mlflow.log_metric("f1", f1_score(y_test, y_pred))
    mlflow.log_metric("auc_roc", roc_auc_score(y_test, y_proba))

    # Desar el model
    mlflow.sklearn.log_model(model_pipeline, "model",
                              registered_model_name="FrauDetector")

    # Desar artefactes (gràfics)
    mlflow.log_artifact("matriu_confusio.png")
    mlflow.log_artifact("roc_pr_curves.png")

    print(f"Run ID: {mlflow.active_run().info.run_id}")

Weights & Biases (W&B)

import wandb

wandb.init(
    project="iabd-ml-5072",
    name="cnn-cifar10-baseline",
    config={
        "architecture": "CNN_CIFAR10",
        "dataset": "CIFAR-10",
        "epochs": 30,
        "batch_size": 128,
        "learning_rate": 3e-4,
    }
)

# Durant l'entrenament
for epoch in range(N_EPOCHS):
    tr_loss, tr_acc = train_epoch(model, train_loader, optimizer, criterion, DEVICE)
    te_loss, te_acc = eval_epoch(model, test_loader, criterion, DEVICE)

    wandb.log({
        "epoch": epoch,
        "train/loss": tr_loss,
        "train/accuracy": tr_acc,
        "val/loss": te_loss,
        "val/accuracy": te_acc,
        "learning_rate": scheduler.get_last_lr()[0]
    })

wandb.finish()

Bones pràctiques de MLOps

  • Versiona sempre els datasets i models (DVC, MLflow Model Registry).
  • Registra tots els experiments amb les seves mètriques i paràmetres.
  • Implementa tests automàtics que s'executen abans de desplegar un nou model.
  • Monitora estadístiques de les distribucions d'entrada en producció (PSI, KL divergence).
  • Defineix un SLA per al rendiment del model: si cau per sota d'un llindar, dispara una alerta.

Miniactivitat — Dashboard MLflow

Instal·la MLflow localment (pip install mlflow). Entrena 5 variants del teu model de detecció de frau (variants en n_estimators, max_depth, learning_rate). Registra cada experiment amb MLflow. Accedeix a la interfície web (mlflow ui) i compara els resultats. Quin experiment ha produït el millor F1-score?


Exercici Pràctic — Avaluació Comparativa

Objectiu: Avaluació rigorosa i comparativa de múltiples models per a un problema real.

Dataset: Telco Customer Churn (Kaggle) — Predicció de baixa de clients de telecomunicacions.

Tasques:

  1. Preprocés complet: imputació, escalat, encoding, tractament de desequilibri de classes (SMOTE o class_weight).
  2. Entrena 5 models: LogReg, Decision Tree, Random Forest, XGBoost, LightGBM.
  3. Avalua amb cross-validation 5-fold: accuracy, precision, recall, F1, AUC-ROC.
  4. Construeix una taula comparativa amb totes les mètriques.
  5. Dibuixa les corbes ROC de tots els models en el mateix gràfic.
  6. Aplica el test de McNemar per comparar els dos millors models.
  7. Optimitza el millor model amb Optuna (75 trials).
  8. Analitza el biaix-variança del model final amb corbes d'aprenentatge.
  9. Registra tots els experiments amb MLflow.
  10. Reflexió escrita (500 paraules): quin model escolliríes per a producció i per quin motiu? Quin és el cost d'un fals negatiu en aquest context?

Codi de l'activitat: AC5072/06