La lliçó anterior va acabar amb una inquietud: totes les nostres mètriques es van calcular sobre una única divisió entrenament/prova, i aquella divisió depèn de l'atzar d'un random_state. I si amb una altra llavor l'F1 hagués estat notablement diferent? En aquesta lliçó primer demostrarem que aquesta variabilitat és real, i després la resoldrem amb la validació creuada (cross-validation, CV): en lloc d'avaluar una vegada, avaluar K vegades rotant les dades, i reportar mitjana i desviació. És la tècnica estàndard per comparar models de manera honesta —la usarem per enfrontar per fi els algorismes del mòdul 4 sobre el churn de MercaFresh— i la base sobre la qual es construeix la cerca d'hiperparàmetres que veurem a 07-05.

Contingut

  1. El problema: una divisió, un número, molta variància
  2. K-fold pas a pas
  3. cross_val_score i cross_validate a la pràctica
  4. StratifiedKFold per al churn
  5. Interpretar mitjana ± desviació: és significativa la diferència?
  6. Torneig honest: els models del mòdul 4 cara a cara
  7. Pipeline dins de la CV: avaluació sense fuites
  8. Variants i cost computacional

El problema: una divisió, un número, molta variància

Comprovem-ho empíricament: mateix model, mateixes dades, diferent llavor de divisió.

from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score
import numpy as np

resultats = []
for llavor in range(20):                       # 20 divisions diferents
    X_ent, X_pr, y_ent, y_pr = train_test_split(
        X, y, test_size=0.2, random_state=llavor, stratify=y
    )
    model = LogisticRegression(max_iter=1000).fit(X_ent, y_ent)
    resultats.append(f1_score(y_pr, model.predict(X_pr)))

resultats = np.array(resultats)
print(f"F1 minim : {resultats.min():.3f}")
print(f"F1 maxim : {resultats.max():.3f}")
print(f"F1 mitja : {resultats.mean():.3f} +/- {resultats.std():.3f}")

Una sortida típica amb un dataset mitjà: mínim 0.58, màxim 0.69, mitjana 0.63 ± 0.03. Onze punts percentuals de diferència entre la pitjor i la millor llavor, sense canviar absolutament res del model. Dues conseqüències:

  • Un únic número ("el meu F1 és 0.66") amaga una loteria: potser et va tocar una llavor afortunada.
  • Comparar dos models amb divisions diferents (o fins i tot amb la mateixa, si la diferència és petita) pot portar a conclusions errònies: la diferència observada pot ser pur atzar de la partició.

El bucle anterior ja apunta a la solució: avaluar diverses vegades i resumir amb mitjana i desviació. La validació creuada fa exactament això, però de manera més eficient: sense malbaratar dades i garantint que cada fila s'usa exactament una vegada com a prova.

K-fold pas a pas

La validació creuada K-fold funciona així:

  1. Es divideix el conjunt de dades (el d'entrenament; la prova final de 06-01 continua dins la seva caixa forta) en K blocs ("folds") de la mateixa mida. Valors habituals: K = 5 o K = 10.
  2. Es repeteixen K rondes. A la ronda i, el fold i actua de conjunt d'avaluació i els K−1 restants, d'entrenament.
  3. S'obtenen K mètriques, una per ronda, i se'n reporta la mitjana i la desviació estàndard.
flowchart TD
    D["Dades d'entrenament<br/>dividides en 5 folds: F1 F2 F3 F4 F5"] --> R1
    subgraph Rondes["5 rondes d'entrenament i avaluacio"]
        R1["Ronda 1: entrena F2-F5, avalua F1 → metrica m1"]
        R2["Ronda 2: entrena F1,F3-F5, avalua F2 → metrica m2"]
        R3["Ronda 3: entrena F1,F2,F4,F5, avalua F3 → metrica m3"]
        R4["Ronda 4: entrena F1-F3,F5, avalua F4 → metrica m4"]
        R5["Ronda 5: entrena F1-F4, avalua F5 → metrica m5"]
    end
    R1 --> M["Resultat: mitjana(m1..m5) ± desviacio(m1..m5)"]
    R2 --> M
    R3 --> M
    R4 --> M
    R5 --> M

Avantatges davant de la divisió única:

  • Cada fila s'avalua exactament una vegada (i s'entrena K−1 vegades): cap dada no es "malbarata" com a prova permanent.
  • La mitjana de K mesures és molt més estable que una mesura solta.
  • La desviació estàndard quantifica la incertesa: sabem fins a quin punt fiar-nos del número.

Important: la CV produeix K models d'un sol ús l'única finalitat dels quals és estimar el rendiment. El model final que aniria a producció s'entrena després amb totes les dades d'entrenament.

cross_val_score i cross_validate a la pràctica

scikit-learn automatitza tot el procés:

from sklearn.model_selection import cross_val_score
from sklearn.linear_model import LogisticRegression

model = LogisticRegression(max_iter=1000)

# CV de 5 folds mesurant F1; cv=5 usa StratifiedKFold automaticament en classificacio
scores = cross_val_score(model, X_entrenament, y_entrenament, cv=5, scoring="f1")
print("F1 per fold:", np.round(scores, 3))       # p. ex. [0.61 0.65 0.63 0.60 0.66]
print(f"F1: {scores.mean():.3f} +/- {scores.std():.3f}")

Detalls clau:

  • scoring accepta les mètriques de 06-02 pel nom: "accuracy", "precision", "recall", "f1", "roc_auc" (la veurem a 06-04), "neg_mean_absolute_error", "r2"... Les d'error van en negatiu (neg_) perquè scikit-learn sempre maximitza: un MAE de 18 apareix com a −18.
  • cross_val_score rep el model sense entrenar: ell s'encarrega de clonar, entrenar i avaluar a cada fold.

Quan es volen diverses mètriques alhora (i els temps), s'usa cross_validate:

from sklearn.model_selection import cross_validate

res = cross_validate(
    model, X_entrenament, y_entrenament, cv=5,
    scoring=["f1", "recall", "precision", "accuracy"],
    return_train_score=True,        # util per diagnosticar overfitting (06-05)
)
print(f"F1 val   : {res['test_f1'].mean():.3f} +/- {res['test_f1'].std():.3f}")
print(f"Recall   : {res['test_recall'].mean():.3f}")
print(f"F1 entren: {res['train_f1'].mean():.3f}")   # bretxa entrenament-validacio -> 06-05
print(f"Temps/fold: {res['fit_time'].mean():.2f} s")

StratifiedKFold per al churn

Amb el churn al 20 %, els folds han de conservar aquesta proporció, per la mateixa raó que estratificàvem a 06-01: un fold que per atzar tingués un 12 % de churn donaria una mètrica no comparable. StratifiedKFold estratifica cada fold:

from sklearn.model_selection import StratifiedKFold

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X_entrenament, y_entrenament, cv=cv, scoring="f1")

Dos apunts:

  • En classificació, passar cv=5 (un enter) ja usa StratifiedKFold per sota; construir l'objecte explícit permet fixar shuffle=True i random_state (recomanable: barreja abans de tallar els folds i fa l'experiment reproduïble).
  • En regressió, cv=5 usa KFold sense estratificar, que és el que pertoca.

Interpretar mitjana ± desviació: és significativa la diferència?

Suposa dos models de churn amb CV de 5 folds:

  • Regressió logística: F1 = 0.63 ± 0.02
  • Arbre de decisió (max_depth=5): F1 = 0.61 ± 0.04

És la logística "millor"? Aquí reapareix la lliçó 02-04: una mitjana mostral porta incertesa, i abans de declarar un guanyador cal preguntar-se si la diferència és significativa o compatible amb l'atzar. La intuïció pràctica, en l'esperit dels intervals de confiança:

  • Si els rangs mitjana ± desviació se solapen àmpliament (com aquí: 0.61–0.65 davant de 0.57–0.65), l'evidència que un model sigui millor és feble.
  • Si un model queda clarament per sobre fins i tot comptant la desviació (p. ex., 0.70 ± 0.02 davant de 0.61 ± 0.03), la diferència és difícilment atribuïble a l'atzar.
  • Per afinar més es poden comparar els resultats fold a fold (tots dos models avaluats sobre els mateixos folds formen parelles, la mateixa idea del t-test aparellat de 02-04), tot i que amb només 5 valors el contrast formal té poca potència; a la pràctica professional la regla del solapament, usada amb prudència, resol la majoria de decisions.

Moralitat: reporta sempre mitjana ± desviació, i desconfia de rànquings decidits per diferències de mil·lèsimes.

Torneig honest: els models del mòdul 4 cara a cara

Al mòdul 4 vam presentar cada algorisme per separat, amb divisions instrumentals. Ara podem comparar-los amb rigor: mateixa CV, mateixos folds, mateixa mètrica.

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.neural_network import MLPClassifier
from sklearn.model_selection import StratifiedKFold, cross_validate

models = {
    "Regressio logistica": LogisticRegression(max_iter=1000),
    "Arbre (max_depth=5)": DecisionTreeClassifier(max_depth=5, random_state=42),
    "SVM (RBF)":           SVC(),
    "K-NN (k=15)":         KNeighborsClassifier(n_neighbors=15),
    "Naive Bayes":         GaussianNB(),
    "MLP":                 MLPClassifier(hidden_layer_sizes=(32,), max_iter=1000,
                                         random_state=42),
}

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)  # mateixos folds per a tots

for nom, clf in models.items():
    pipe = Pipeline([("scaler", StandardScaler()), ("clf", clf)])  # vegeu la seccio seguent
    res = cross_validate(pipe, X_entrenament, y_entrenament, cv=cv, scoring=["f1", "recall"])
    print(f"{nom:22s} F1 = {res['test_f1'].mean():.3f} +/- {res['test_f1'].std():.3f}"
          f"   Recall = {res['test_recall'].mean():.3f}")

Un resultat típic sobre el churn de MercaFresh:

Model F1 (mitjana ± std) Recall Comentari
Regressió logística 0.63 ± 0.02 0.60 Sòlida, estable, interpretable
SVM (RBF) 0.63 ± 0.03 0.58 Empata, amb més cost de còmput
MLP 0.62 ± 0.04 0.59 Similar i més variable
Arbre (max_depth=5) 0.61 ± 0.04 0.62 Competitiu; sensible a la partició
K-NN (k=15) 0.59 ± 0.03 0.55 Una mica per darrere
Naive Bayes 0.56 ± 0.03 0.66 Millor recall, pitjor precision

(Els números concrets dependran de les teves dades; el que és transferible és el mètode.) Lectures: amb els solapaments de les desviacions, la logística, l'SVM i l'MLP estan empatats a la pràctica — i llavors decideixen altres criteris: interpretabilitat, velocitat, simplicitat (la logística guanya tots tres a MercaFresh). Fixa't també que cada model usa aquí els seus hiperparàmetres "raonables" sense optimitzar; la cerca sistemàtica que podria canviar aquest rànquing es recolza en aquesta mateixa CV i és el tema de 07-05 (GridSearchCV).

Pipeline dins de la CV: avaluació sense fuites

Atenció a un detall del codi anterior: l'StandardScaler va dins del Pipeline que es passa a cross_validate. No és estètica, és la regla anti-fuites de 06-01 aplicada a la CV:

# INCORRECTE: escalar abans de la CV
X_esc = StandardScaler().fit_transform(X_entrenament)   # veu TOTS els folds alhora
cross_val_score(LogisticRegression(max_iter=1000), X_esc, y_entrenament, cv=5)

# CORRECTE: el Pipeline reajusta l'escalador a cada ronda,
# nomes amb els folds d'entrenament d'aquella ronda
pipe = Pipeline([("scaler", StandardScaler()),
                 ("clf", LogisticRegression(max_iter=1000))])
cross_val_score(pipe, X_entrenament, y_entrenament, cv=5)

En la versió incorrecta, l'escalador s'ajusta amb dades que a cada ronda faran de "prova", contaminant les K avaluacions. Amb imputació, selecció de features o codificacions dependents del target, la fuita pot inflar seriosament les mètriques. Regla d'or: tot pas que aprengui de les dades va dins del Pipeline, i el Pipeline sencer va dins de la CV. És la recompensa de la feina del mòdul 3: els nostres preprocessaments ja estaven empaquetats en Pipelines/ColumnTransformers, així que encaixen aquí sense canvis.

Variants i cost computacional

Dues variants que convé conèixer:

  • LeaveOneOut (LOO): el cas extrem K = n (cada fila és un fold). Aprofita al màxim datasets molt petits, però exigeix entrenar n models i la seva estimació té alta variància. Rarament compensa davant d'un 5-fold o 10-fold repetit.
  • TimeSeriesSplit: per a dades temporals com la demanda de MercaFresh, on el K-fold clàssic és invàlid (entrenaria amb el futur per avaluar el passat, la fuita temporal de 06-01). Genera talls creixents que sempre entrenen amb el passat i avaluen amb el bloc següent: entrena [1], avalua [2]; entrena [1,2], avalua [3]; entrena [1,2,3], avalua [4]...
from sklearn.model_selection import TimeSeriesSplit
cv_temporal = TimeSeriesSplit(n_splits=5)
scores = cross_val_score(model_demanda, X_demanda, y_demanda,
                         cv=cv_temporal, scoring="neg_mean_absolute_error")

Sobre el cost: la CV multiplica el temps d'entrenament per K (i pel nombre de models comparats, i més endavant per cada combinació d'hiperparàmetres de 07-05). Guia pràctica:

Situació Estratègia raonable
Dataset petit/mitjà, models ràpids 5-fold o 10-fold sense dubtar
Models costosos (SVM/MLP grans, moltes dades) 5-fold, o 3-fold en exploració inicial
Dataset enorme (milions de files) Una divisió única ben feta ja és estable
Sèries temporals TimeSeriesSplit
Dataset diminut (< 200 files) 10-fold o LOO, assumint-ne la variància

cross_validate(..., n_jobs=-1) paral·lelitza els folds entre els nuclis de la CPU, un alleujament gairebé gratuït.

Errors Comuns i Consells

  • Fer CV sobre tot el dataset, prova inclosa. La CV substitueix el conjunt de validació, no el de prova: s'aplica sobre les dades d'entrenament, i la prova de 06-01 continua reservada per al veredicte final.
  • Preprocessar abans de la CV. La fuita silenciosa més freqüent en notebooks reals. Pipeline dins de la CV, sempre.
  • Comparar models amb folds diferents. Usa el mateix objecte StratifiedKFold (mateixa llavor) per a tots els contendents; si no, part de la diferència serà soroll de partició.
  • Reportar només la mitjana. Sense la desviació no es pot jutjar si una diferència entre models importa.
  • Usar K-fold barrejat en sèries temporals. És avaluar predint el passat amb el futur; usa TimeSeriesSplit.
  • Oblidar reentrenar al final. Després de triar model amb CV, el model definitiu s'entrena amb totes les dades d'entrenament abans de l'avaluació final en prova (i abans de producció).
  • Consell: fixa un "protocol d'avaluació" a l'inici del projecte (CV, mètrica, llavor) i no el canviïs a mig camí; canviar les regles del joc quan ja has vist resultats convida a l'autoengany.

Exercicis

Exercici 1

Explica per què aquest codi sobreestima el rendiment i corregeix-lo:

from sklearn.impute import SimpleImputer
X_imp = SimpleImputer(strategy="mean").fit_transform(X_entrenament)
X_esc = StandardScaler().fit_transform(X_imp)
scores = cross_val_score(LogisticRegression(max_iter=1000), X_esc, y_entrenament,
                         cv=5, scoring="f1")

Exercici 2

Amb CV de 5 folds obtens: model A, recall = 0.64 ± 0.05; model B, recall = 0.66 ± 0.06. El teu cap a MercaFresh vol anunciar que "B és millor". Què li respondries, i què faries per augmentar la confiança en la comparació?

Exercici 3

Escriu el codi que compara DecisionTreeClassifier amb max_depth 3, 5 i 10 sobre el churn usant el mateix StratifiedKFold(5, shuffle=True, random_state=42) i scoring="f1", imprimint mitjana ± desviació de cadascun. (Estàs fent a mà, en miniatura, el que GridSearchCV automatitzarà a 07-05.)

Solucions

Solució 1. L'imputador i l'escalador s'ajusten amb tot X_entrenament abans de la CV, de manera que a cada ronda el fold d'avaluació ja ha influït en la mitjana d'imputació i en els paràmetres d'escalat: fuita de dades que infla les mètriques. Correcció — posar tots dos passos en un Pipeline que es reajusti dins de cada ronda:

from sklearn.pipeline import Pipeline
pipe = Pipeline([
    ("imputer", SimpleImputer(strategy="mean")),
    ("scaler", StandardScaler()),
    ("clf", LogisticRegression(max_iter=1000)),
])
scores = cross_val_score(pipe, X_entrenament, y_entrenament, cv=5, scoring="f1")

Solució 2. La diferència (0.02) és molt menor que les desviacions (0.05–0.06): els rangs 0.59–0.69 i 0.60–0.72 se solapen gairebé del tot, així que l'avantatge de B és perfectament compatible amb l'atzar de la partició — com vam veure a 02-04, una diferència no és una conclusió fins que es descarta que sigui soroll. Per guanyar confiança: (a) avaluar tots dos amb els mateixos folds i comparar fold a fold (comparació aparellada); (b) repetir la CV amb diverses llavors (p. ex., RepeatedStratifiedKFold) per tenir més mesures; (c) si després d'això continuen empatats, triar per altres criteris (cost, interpretabilitat, recall mínim garantit).

Solució 3.

from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

for profunditat in [3, 5, 10]:
    arbre = DecisionTreeClassifier(max_depth=profunditat, random_state=42)
    scores = cross_val_score(arbre, X_entrenament, y_entrenament, cv=cv, scoring="f1")
    print(f"max_depth={profunditat:2d} -> F1 = {scores.mean():.3f} +/- {scores.std():.3f}")

En usar el mateix objecte cv, els tres arbres s'avaluen sobre folds idèntics i la comparació és justa. Un patró típic: 3 es queda curt, 10 sobreajusta i 5 guanya — el perquè d'aquesta forma d'"U invertida" el veurem amb les corbes de validació a 06-05, i l'automatització d'aquesta cerca, a 07-05.

Conclusió

La validació creuada resol la fragilitat de la divisió única: el K-fold rota les dades perquè cada fila sigui avaluada una vegada, cross_val_score/cross_validate ho automatitzen, StratifiedKFold preserva la proporció de churn a cada fold, i el resultat es llegeix com a mitjana ± desviació, aplicant l'escepticisme estadístic de 02-04 abans de declarar vencedors. Amb ella hem pogut celebrar el primer torneig just entre els models del mòdul 4, amb el Pipeline dins de la CV com a garantia anti-fuites, i hem deixat apuntades les variants (LeaveOneOut, TimeSeriesSplit) i el cost computacional. Però en tot aquest torneig els classificadors decidien amb el seu llindar per defecte de 0.5, i ja sabem des de 04-02 que aquell llindar és una palanca de negoci. La propera lliçó l'explota a fons: recorrerem tots els llindars possibles alhora amb la corba ROC i resumirem el resultat en un sol número, l'AUC.

Curs de Machine Learning

Mòdul 1: Introducció al Machine Learning

Mòdul 2: Fonaments d'Estadística i Probabilitat

Mòdul 3: Preprocessament de Dades

Mòdul 4: Algorismes de Machine Learning Supervisat

Mòdul 5: Algorismes de Machine Learning No Supervisat

Mòdul 6: Avaluació i Validació de Models

Mòdul 7: Tècniques Avançades i Optimització

Mòdul 8: Implementació i Desplegament de Models

Mòdul 9: Projectes Pràctics

Mòdul 10: Recursos Addicionals

© Copyright 2026. Tots els drets reservats