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}$ |
| R² | $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:
- Preprocés complet: imputació, escalat, encoding, tractament de desequilibri de classes (SMOTE o class_weight).
- Entrena 5 models: LogReg, Decision Tree, Random Forest, XGBoost, LightGBM.
- Avalua amb cross-validation 5-fold: accuracy, precision, recall, F1, AUC-ROC.
- Construeix una taula comparativa amb totes les mètriques.
- Dibuixa les corbes ROC de tots els models en el mateix gràfic.
- Aplica el test de McNemar per comparar els dos millors models.
- Optimitza el millor model amb Optuna (75 trials).
- Analitza el biaix-variança del model final amb corbes d'aprenentatge.
- Registra tots els experiments amb MLflow.
- 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