Quart projecte i un enemic nou: el desequilibri extrem. Construiràs un detector de transaccions fraudulentes amb targeta en què només l'1 % dels casos són frau — l'escenari on l'accuracy enganya, els llindars es decideixen en euros i les mètriques del mòdul 6 es guanyen el sou. El problema et resultarà familiar per partida doble: és el detector de spam del teorema de Bayes de 02-05 amb diners en joc, i el parent supervisat de les anomalies en comandes que vas caçar amb DBSCAN a 05-04 — només que aquí que tenim etiquetes històriques de frau, així que podem entrenar classificadors. Farem servir un dataset sintètic generat al mateix codi (els datasets reals de frau són confidencials per naturalesa; el clàssic públic de Kaggle arriba anonimitzat per PCA), cosa que a més ens dona control total sobre l'experiment.

Contingut

  1. Definició del problema, costos i mètrica objectiu
  2. Generació del dataset sintètic
  3. EDA amb classes desequilibrades
  4. Per què l'accuracy menteix al 99 %
  5. Estratègies contra el desequilibri
  6. Modelatge i comparació amb CV
  7. La corba precision-recall i l'AUC-PR
  8. El llindar òptim, en euros
  9. Avaluació final en test
  10. Conclusions i connexió amb producció

Definició del problema, costos i mètrica objectiu

  • Problema: classificació binària — aquesta transacció és fraudulenta (1) o legítima (0)?
  • L'asimetria que ho governa tot: els dos errors no costen el mateix. Un frau no detectat (fals negatiu) costa l'import defraudat més la gestió, posem 150 € de mitjana; bloquejar una transacció legítima (fals positiu) costa la revisió manual i la fricció amb el client, posem 5 €. Aquesta matriu de costos, com vas aprendre a 06-04 "Corba ROC i AUC", decidirà el llindar.
  • Mètriques: precision, recall i F1 de la classe frau (06-02 "Mètriques d'avaluació"), corba precision-recall amb el seu AUC-PR com a mètrica de comparació entre models, i el cost total en euros com a mètrica final de negoci. L'accuracy queda vetada, i de seguida veuràs per què.
Predita: legítima Predita: frau
Real: legítima 0 € 5 € (revisió + fricció)
Real: frau 150 € (pèrdua) 0 € (frau evitat)

Generació del dataset sintètic

make_classification genera un problema de classificació amb estructura controlada; amb weights=[0.99, 0.01] hi imposem el desequilibri. Després vestim les features abstractes amb noms i escales realistes per poder raonar com a analistes:

import numpy as np
import pandas as pd
from sklearn.datasets import make_classification

X_raw, y = make_classification(
    n_samples=50_000, n_features=8, n_informative=5, n_redundant=1,
    weights=[0.99, 0.01],          # 1% de frau
    class_sep=1.0, flip_y=0.005,   # una mica de soroll en etiquetes: realisme
    random_state=42,
)

columnes = [
    "import_eur",           # import de la transaccio
    "hora_dia",             # hora (el frau prefereix la matinada)
    "dist_domicili_km",     # distancia del comerc al domicili del titular
    "freq_comerc_mes",      # vegades que el titular compra en aquest comerc al mes
    "ops_darrera_hora",     # transaccions de la targeta en la darrera hora
    "antiguitat_targeta",   # mesos des de l'emissio
    "ratio_import_mitja",   # import / despesa mitjana historica del titular
    "pagament_online",      # senyal de canal (mes gran => mes online)
]
df = pd.DataFrame(X_raw, columns=columnes)
df["frau"] = y

print(df["frau"].value_counts())            # ~49500 / ~500
print(f"Taxa de frau: {y.mean():.3%}")

Què estem simulant i què no: les columnes generades són combinacions gaussianes abstractes a les quals posem noms per raonar-hi — l'estructura del problema (poques features informatives, 1 % de positius, una mica de soroll en etiquetes) sí que és fidel a la realitat; els valors no són hores ni euros literals. En un cas real, features com dist_domicili_km o ratio_import_mitja sortirien de l'enginyeria de característiques de 03-06 sobre l'historial del titular — exactament com vas construir el RFM de MercaFresh.

Dividim estratificant, imprescindible amb un 1 % de positius (06-01):

from sklearn.model_selection import train_test_split

X = df.drop(columns="frau")
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, stratify=y, random_state=42
)
print("Fraus en train:", y_train.sum(), "| en test:", y_test.sum())

EDA amb classes desequilibrades

Amb desequilibri, els histogrames globals són inútils: els fraus desapareixen sota la massa legítima. L'eina correcta és comparar distribucions per classe:

import matplotlib.pyplot as plt

train = X_train.copy(); train["frau"] = y_train

fig, eixos = plt.subplots(2, 4, figsize=(15, 7))
for eix, col in zip(eixos.ravel(), columnes):
    for classe, color in [(0, "tab:blue"), (1, "tab:red")]:
        eix.hist(train.loc[train.frau == classe, col], bins=40,
                 density=True, alpha=0.5, color=color)
    eix.set_title(col)
plt.tight_layout(); plt.show()

print(train.groupby("frau").mean().round(2).T)

El density=True és la clau: normalitza cada histograma per poder superposar 370 fraus sobre 37.000 legítimes. Busca les features on les dues campanes se separen (les informatives) i les que se solapen del tot (les que gairebé no aportaran res). Aquest mateix gràfic, comparant "avui" contra "l'històric", serà el teu detector de drift en producció — el reprenem al final.

Per què l'accuracy menteix al 99 %

L'experiment que ho demostra, amb el DummyClassifier en el paper d'estafador de mètriques:

from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, recall_score, precision_score

gandul = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
pred = gandul.predict(X_test)

print("Accuracy:", round(accuracy_score(y_test, pred), 4))   # ~0.99
print("Recall frau:", recall_score(y_test, pred))            # 0.0
print("Precision frau:", precision_score(y_test, pred, zero_division=0))  # 0.0

Un model que diu "tot és legítim" encerta el 99 % de les vegades i no detecta ni un sol frau: cost total = tots els fraus pagats. És l'exemple canònic de 06-02: quan les classes estan desequilibrades, l'accuracy mesura sobretot la proporció de la classe majoritària. Des d'aquí, precision/recall/F1 de la classe frau i euros.

Estratègies contra el desequilibri

Tres famílies, de més simple a més invasiva:

  1. class_weight="balanced": el model pondera cada exemple de frau ~99 vegades més en la seva funció de pèrdua. No toca les dades, una línia, gairebé sempre el primer que cal provar.
  2. Moure el llindar: entrenar normal, però no tallar a 0,5 sinó allà on la matriu de costos digui (06-04). És l'estratègia més honesta perquè separa el model (probabilitats) de la decisió (llindar de negoci).
  3. Remostreig: canviar el train mateix. Sobremostrejar la minoria (duplicar fraus, o generar-ne de sintètics interpolats amb SMOTE, disponible a la llibreria imbalanced-learn) o submostrejar la majoria. En codi ho il·lustrem amb el resample de sklearn:
from sklearn.utils import resample

train_leg = train[train.frau == 0]
train_fra = train[train.frau == 1]

fra_sobre = resample(train_fra, replace=True,
                     n_samples=len(train_leg) // 10,  # ratio 10:1, no 1:1
                     random_state=42)
train_bal = pd.concat([train_leg, fra_sobre])
print(train_bal["frau"].value_counts())

Regla d'or: el remostreig s'aplica només al train, mai al test (volem avaluar contra la realitat de l'1 %) i, si es combina amb CV, dins de cada partició — remostrejar abans de dividir duplica fraus entre train i validació: la fuita de 06-01 en versió desequilibrada.

Modelatge i comparació amb CV

Tres models del curs amb class_weight allà on existeix, comparats amb CV estratificada (06-03) sobre AUC-PR (average_precision), recall i precision:

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, HistGradientBoostingClassifier
from sklearn.model_selection import cross_validate, StratifiedKFold

models = {
    "Logistica": Pipeline([
        ("esc", StandardScaler()),
        ("m", LogisticRegression(class_weight="balanced", max_iter=1000)),
    ]),
    "Random Forest": RandomForestClassifier(
        n_estimators=200, class_weight="balanced", n_jobs=-1, random_state=42),
    "HistGradientBoosting": HistGradientBoostingClassifier(random_state=42),
}

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scoring = {"ap": "average_precision", "recall": "recall", "precision": "precision"}

files = []
for nom, m in models.items():
    r = cross_validate(m, X_train, y_train, cv=cv, scoring=scoring)
    files.append({"Model": nom,
                  "AUC-PR": r["test_ap"].mean(),
                  "[email protected]": r["test_recall"].mean(),
                  "[email protected]": r["test_precision"].mean()})
print(pd.DataFrame(files).round(3).to_string(index=False))

Resultats orientatius:

Model AUC-PR (CV) Recall @0,5 Precision @0,5 Lectura
HistGradientBoosting ≈ 0,80 ≈ 0,55 ≈ 0,85 Millor rànquing; el seu llindar 0,5 és conservador (sense class_weight)
Random Forest ≈ 0,75 ≈ 0,65 ≈ 0,70 Sòlid; el pes de classe li apuja el recall
Logística ≈ 0,65 ≈ 0,85 ≈ 0,10 El pes 99:1 la bolca a cridar "frau" amb poca precision

Fixa't que les columnes @0,5 són gairebé anecdòtiques: depenen d'un llindar que encara no hem decidit. L'AUC-PR, en canvi, avalua la qualitat del rànquing de probabilitats a tots els llindars alhora — per això és la mètrica de comparació. I una nota sobre el ROC-AUC: amb un 1 % de positius sol sortir altíssim (0,95+) per a tots, perquè els abundants veritables negatius l'inflen; la corba precision-recall, que ignora els veritables negatius, és molt més exigent i realista aquí. És el matís final que va quedar apuntat a 06-04.

La corba precision-recall i l'AUC-PR

Triat el HistGradientBoosting, en dibuixem la corba amb probabilitats de CV (sense tocar el test):

from sklearn.model_selection import cross_val_predict
from sklearn.metrics import precision_recall_curve, average_precision_score

millor = HistGradientBoostingClassifier(random_state=42)
proba_cv = cross_val_predict(millor, X_train, y_train, cv=cv,
                             method="predict_proba")[:, 1]

prec, rec, llindars = precision_recall_curve(y_train, proba_cv)
print("AUC-PR:", round(average_precision_score(y_train, proba_cv), 3))

plt.plot(rec, prec)
plt.axhline(y_train.mean(), ls="--", color="gray",
            label=f"Atzar = {y_train.mean():.1%}")
plt.xlabel("Recall (fraus detectats)")
plt.ylabel("Precision (encerts entre alertes)")
plt.title("Corba precision-recall (CV)"); plt.legend(); plt.show()

La línia grisa és humiliant i necessària: un classificador aleatori té una precision de l'1 % en aquest problema. Tota la corba per sobre és mèrit del model. La corba és un menú de compromisos: cada punt és un llindar possible — més recall (caçar més fraus) pagant precision (més falses alarmes). Quin triar? El que minimitzi euros.

El llindar òptim, en euros

Apliquem la matriu de costos a cada llindar candidat, com a 06-04 però amb moneda:

COST_FN = 150  # frau no detectat
COST_FP = 5    # legitima bloquejada/revisada

resultats = []
for u in np.linspace(0.01, 0.99, 99):
    pred = (proba_cv >= u).astype(int)
    fn = ((y_train == 1) & (pred == 0)).sum()
    fp = ((y_train == 0) & (pred == 1)).sum()
    resultats.append({"llindar": u, "cost": fn * COST_FN + fp * COST_FP})

costos = pd.DataFrame(resultats)
optim = costos.loc[costos.cost.idxmin()]
print(f"Llindar optim: {optim.llindar:.2f} | cost CV: {optim.cost:,.0f} EUR")

plt.plot(costos.llindar, costos.cost)
plt.axvline(optim.llindar, color="red", ls="--")
plt.xlabel("Llindar"); plt.ylabel("Cost total (EUR)")
plt.title("Cost esperat segons el llindar"); plt.show()

Amb un fals negatiu 30 vegades més car que un fals positiu, el llindar òptim cau molt per sota de 0,5 (típicament entre 0,05 i 0,15): compensa revisar moltes transaccions legítimes a canvi de caçar més fraus. Compara el cost del llindar òptim amb dues referències: el llindar 0,5 "per defecte" i la política "no bloquejar res" (= tots els fraus pagats). La diferència, en euros, és l'argument amb què aquest projecte es defensa davant de negoci — molt més persuasiu que qualsevol F1.

Avaluació final en test

Entrenem amb tot el train, apliquem el llindar decidit i obrim el test una única vegada:

from sklearn.metrics import classification_report, confusion_matrix

millor.fit(X_train, y_train)
proba_test = millor.predict_proba(X_test)[:, 1]
pred_test = (proba_test >= optim.llindar).astype(int)

print(confusion_matrix(y_test, pred_test))
print(classification_report(y_test, pred_test,
                            target_names=["legitima", "frau"], digits=3))

fn = ((y_test == 1) & (pred_test == 0)).sum()
fp = ((y_test == 0) & (pred_test == 1)).sum()
cost_model = fn * COST_FN + fp * COST_FP
cost_sense_model = y_test.sum() * COST_FN
print(f"Cost amb model: {cost_model:,.0f} EUR | sense model: {cost_sense_model:,.0f} EUR")
print(f"Estalvi: {cost_sense_model - cost_model:,.0f} EUR")

L'informe final ha d'explicar tres números: el recall de frau (quina fracció del frau cacem), la precision (quina fracció de les alertes són reals — és la càrrega de feina de l'equip de revisió) i l'estalvi en euros davant de no fer res. Si coincideixen raonablement amb el que s'havia estimat en CV, el protocol va ser net.

Errors Comuns i Consells

  • Dividir sense estratificar. Amb un 1 % de positius, una divisió aleatòria pot deixar el test gairebé sense fraus i les mètriques es tornen soroll. stratify=y sempre.
  • Remostrejar abans de dividir. Duplicar fraus i després repartir train/test posa còpies del mateix frau a banda i banda: mètriques inflades i il·lusòries. Remostreig només al train, i dins de cada partició si hi ha CV.
  • Presumir de ROC-AUC. Un 0,95 de ROC-AUC amb un 1 % de positius pot conviure amb una precision del 8 %. Reporta l'AUC-PR i la corba precision-recall.
  • Fixar el llindar amb el test. El llindar és un hiperparàmetre de la decisió: es tria amb CV sobre el train. Triar-lo mirant el test és sobreajustar la decisió final.
  • Donar la matriu de costos per eterna. Els 150 €/5 € són estimacions de negoci que canvien (imports mitjans, cost de l'equip de revisió). Documenta les xifres i recalcula el llindar quan canviïn.

Reptes per ampliar

  1. IsolationForest, el cosí no supervisat. Entrena sklearn.ensemble.IsolationForest ignorant les etiquetes i comprova quants fraus reals apareixen entre les seves anomalies, connectant amb l'enfocament de 05-04 "Anàlisi d'agrupament DBSCAN". Pista: fes servir contamination=0.01; és l'eina per a l'arrencada en fred, quan encara no hi ha etiquetes històriques.
  2. Sensibilitat a la matriu de costos. Recalcula el llindar òptim per a diversos escenaris (FN = 50 €, 300 €; FP = 1 €, 20 €) i dibuixa com es mou. Pista: si el llindar òptim gairebé no canvia entre escenaris plausibles, la teva decisió és robusta; si balla, negoci ha d'afinar les seves estimacions abans de desplegar.
  3. Drift temporal. Simula que el frau evoluciona: genera un segon dataset amb un random_state diferent i una mica més de class_sep o un desplaçament sumat a dues features, i avalua-hi el model antic. Pista: mesura quant cauen el recall i l'AUC-PR i quins histogrames per classe delaten el canvi — estàs reproduint en miniatura el cicle de 08-03.

Conclusions i connexió amb producció

Aquest projecte t'ha ensenyat a treballar quan la classe que importa és una agulla en un paller: estratificar-ho tot, desconfiar de l'accuracy (i fins i tot del ROC-AUC), comparar models per AUC-PR i, sobretot, separar el model de la decisió — les probabilitats les dona l'algorisme, el llindar el donen els euros, exactament el mètode de 06-04 portat a les últimes conseqüències. També has vist l'arsenal contra el desequilibri (pesos de classe, llindar, remostreig) i les seves trampes. I queda l'advertiment final, que connecta amb 08-02 "Implementació de models en producció" i 08-03 "Manteniment i monitoratge de models": el frau és el problema amb més drift que existeix, perquè a l'altra banda hi ha adversaris que s'adapten al teu detector — el model que avui estalvia milers d'euros es degrada en mesos, i el monitoratge per histogrames i el reentrenament periòdic no són opcionals sinó part del producte. Amb això tanques el bloc de projectes supervisats; en l'últim projecte tornem a casa, a MercaFresh, per tancar el cercle amb l'aprenentatge no supervisat: segmentar els clients de principi a fi.

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