A 05-02 vam destil·lar el principi dels autoencoders en una frase: «el que és rar es reconstrueix malament». I vam prometre aplicar-lo al frau. Aquest tercer projecte ho compleix: construiràs un detector de transaccions fraudulentes per a TecnoMarket entrenant un autoencoder només amb transaccions normals i fent servir l'error de reconstrucció com a puntuació d'anomalia. A més del model, aquest projecte t'ensenya dues disciplines imprescindibles en problemes reals: avaluar amb mètriques correctes quan les classes estan brutalment desbalancejades i traduir la matriu de confusió a cost de negoci.

Contingut

  1. Enunciat del projecte i per què no és classificació supervisada
  2. Fase 1: el dataset de transaccions
  3. Fase 2: partició i escalatge sense fuga
  4. Fase 3: l'autoencoder, entrenat només amb el que és normal
  5. Fase 4: error de reconstrucció com a puntuació d'anomalia
  6. Fase 5: tria del llindar
  7. Fase 6: mètriques davant del desbalanç i matriu de confusió en cost de negoci
  8. Fase 7: lliurament — cua de revisió i API

Enunciat del projecte i per què no és classificació supervisada

Context de negoci. TecnoMarket processa desenes de milers de comandes al dia; una fracció mínima és frau (targetes robades, comptes compromesos). L'equip antifrau no dona l'abast revisant a mà.

Per què no entrenar un classificador supervisat frau/no-frau com els de 04-03? Dues raons:

  • Gairebé no hi ha exemples de frau (aquí, 0.5 %), i els que hi ha estan esbiaixats cap als fraus que ja sabem detectar.
  • El frau muta: un patró nou no s'assembla als etiquetats. Un classificador supervisat només reconeix el frau del passat.

L'enfocament no supervisat de 05-02 esquiva totes dues: l'autoencoder aprèn a comprimir i reconstruir únicament transaccions normals. Qualsevol cosa que no encaixi en aquest motlle — frau vell o nou — es reconstruirà malament i farà saltar l'alarma.

Fase 1: el dataset de transaccions

Generem amb numpy un dataset fictici però amb estructura realista: cinc variables per transacció, patrons normals coherents i un 0.5 % de fraus amb patrons diferents (la mateixa filosofia de dades sintètiques controlades que vam fer servir amb la sèrie de vendes de 04-04):

import numpy as np

rng = np.random.default_rng(42)   # llavor: 06-04
N_NORMAL, N_FRAU = 20_000, 100    # 0.5 % de frau

# --- Transaccions normals ---
import_   = rng.lognormal(mean=3.6, sigma=0.7, size=N_NORMAL)      # ~20-150 EUR
hora      = np.clip(rng.normal(15, 4, N_NORMAL) % 24, 0, 24)       # pic a la tarda
n_items   = rng.poisson(2, N_NORMAL) + 1                           # 1-5 articles
dist_km   = rng.exponential(30, N_NORMAL)                          # enviament proper
antig_dies= rng.exponential(400, N_NORMAL)                         # comptes veterans

X_normal = np.column_stack([import_, hora, n_items, dist_km, antig_dies])

# --- Fraus: patrons diferents, no una sola recepta ---
meitat = N_FRAU // 2
# Patró A: import alt, matinada, compte acabat de crear
frau_a = np.column_stack([
    rng.lognormal(6.2, 0.4, meitat),           # ~300-900 EUR
    rng.uniform(1, 5, meitat),                 # 1-5 de la matinada
    rng.poisson(1, meitat) + 1,
    rng.exponential(30, meitat),
    rng.uniform(0, 10, meitat),                # compte de dies
])
# Patró B: molts articles cars a una adreça llunyana
frau_b = np.column_stack([
    rng.lognormal(5.8, 0.3, N_FRAU - meitat),
    np.clip(rng.normal(15, 4, N_FRAU - meitat) % 24, 0, 24),
    rng.poisson(8, N_FRAU - meitat) + 3,       # 8-15 articles
    rng.exponential(600, N_FRAU - meitat),     # enviament molt lluny
    rng.uniform(0, 60, N_FRAU - meitat),
])
X_frau = np.vstack([frau_a, frau_b])

COLUMNES = ["import", "hora", "n_items", "dist_km", "antig_dies"]

Explora abans de modelar (fase 1 de 07-01): histogrames per variable superposant normal i frau. Veuràs que cap variable separa el frau per si sola — és la combinació (import alt + matinada + compte nou) el que és anòmal. Just el que un autoencoder pot capturar i una regla manual per columnes no.

Fase 2: partició i escalatge sense fuga

Dues regles d'or, totes dues ja conegudes:

  1. L'autoencoder s'entrena NOMÉS amb transaccions normals. El frau no entra a l'entrenament: es reserva íntegre per avaluar.
  2. L'scaler s'ajusta només amb el train (la regla anti-fuga de 04-04): si l'escalatge veiés el test — o els fraus —, filtraríem informació del futur.
from sklearn.preprocessing import StandardScaler

rng.shuffle(X_normal)
n_train = 14_000
X_train      = X_normal[:n_train]            # només normals: entrenament
X_val_normal = X_normal[n_train:17_000]      # només normals: llindar i aturada
X_test = np.vstack([X_normal[17_000:], X_frau])   # normals + TOT el frau
y_test = np.concatenate([np.zeros(3_000), np.ones(N_FRAU)])

scaler = StandardScaler().fit(X_train)       # NOMÉS amb train
X_train_s      = scaler.transform(X_train)
X_val_s        = scaler.transform(X_val_normal)
X_test_s       = scaler.transform(X_test)

Fixa't en l'asimetria deliberada de la partició: el train és pur normal; el test concentra els 100 fraus al costat de 3 000 normals. Aquesta és l'estructura estàndard d'un problema de detecció d'anomalies.

Fase 3: l'autoencoder, entrenat només amb el que és normal

L'arquitectura és l'autoencoder dens de 05-02, adaptat d'imatges a tabular: 5 → coll d'ampolla → 5. El coll de 2-3 unitats obliga a comprimir; només els patrons freqüents (normals) sobreviuen a la compressió:

import tensorflow as tf
from tensorflow.keras import layers, models

tf.random.set_seed(42)

autoencoder = models.Sequential([
    layers.Input(shape=(5,)),
    layers.Dense(16, activation="relu"),
    layers.Dense(3, activation="relu", name="coll"),     # compressio 5 -> 3
    layers.Dense(16, activation="relu"),
    layers.Dense(5, activation="linear"),                # reconstrueix les 5 vars
])

autoencoder.compile(optimizer="adam", loss="mse")

historial = autoencoder.fit(
    X_train_s, X_train_s,              # entrada = objectiu: aprendre a copiar
    validation_data=(X_val_s, X_val_s),
    epochs=100, batch_size=64,
    callbacks=[tf.keras.callbacks.EarlyStopping(patience=10,
                                                restore_best_weights=True)],
    verbose=0,
)
print(f"MSE final val: {historial.history['val_loss'][-1]:.4f}")  # típic ~0.05-0.15

Detalls clau:

  • Sortida linear, pèrdua MSE: les dades escalades viuen en tota la recta real, no en [0,1] com els píxels de 05-02.
  • La validació aquí és de normals: mesura si el model generalitza a normals no vistos, i governa l'EarlyStopping (06-01). El frau continua sense aparèixer.
  • Entrena en segons: el valor d'aquest projecte no és en la mida del model sinó en el protocol.

Fase 4: error de reconstrucció com a puntuació d'anomalia

Ara apliquem el principi de 05-02: passem cada transacció per l'autoencoder i mesurem com s'assembla la reconstrucció a l'original.

def puntuacio_anomalia(model, X):
    reconstruccio = model.predict(X, verbose=0)
    return np.mean((X - reconstruccio) ** 2, axis=1)   # MSE per transacció

score_val  = puntuacio_anomalia(autoencoder, X_val_s)
score_test = puntuacio_anomalia(autoencoder, X_test_s)

print(f"Score mitjà normals (test): {score_test[y_test == 0].mean():.3f}")  # ~0.1
print(f"Score mitjà fraus   (test): {score_test[y_test == 1].mean():.3f}")  # ~3-15

Pinta els dos histogrames (normal vs. frau) en escala logarítmica: veuràs dues distribucions separades però amb solapament — alguns normals atípics puntuen alt i algun frau discret puntua baix. Aquest solapament és la raó que triar llindar sigui un compromís i no una frontera màgica.

Fase 5: tria del llindar

El llindar converteix la puntuació contínua en decisió binària. Enfocament pràctic: fixar-lo com un percentil dels scores de validació (només normals) — per exemple, el percentil 99 significa «acceptem assenyalar ~1 % de transaccions legítimes»:

llindar = np.percentile(score_val, 99)
y_pred = (score_test > llindar).astype(int)

Però un percentil triat a cegues pot no ser el millor compromís. Explora'n uns quants i mesura'n les conseqüències:

from sklearn.metrics import precision_score, recall_score, f1_score

print(f"{'percentil':>9} | {'llindar':>7} | {'precision':>9} | {'recall':>6} | {'F1':>5}")
for p in [90, 95, 97, 99, 99.5]:
    u = np.percentile(score_val, p)
    pred = (score_test > u).astype(int)
    print(f"{p:>9} | {u:>7.2f} | {precision_score(y_test, pred):>9.2f} "
          f"| {recall_score(y_test, pred):>6.2f} | {f1_score(y_test, pred):>5.2f}")

Resultat típic amb aquest dataset (les teves xifres variaran una mica):

Percentil Precision Recall Lectura de negoci
90 ~0.23 ~0.98 Caça gairebé tot el frau, però 3 de cada 4 alertes són falses
95 ~0.38 ~0.96 Menys soroll, continua caçant gairebé tot
99 ~0.72 ~0.88 Alertes majoritàriament certes; s'escapa ~12 % del frau
99.5 ~0.83 ~0.80 Molt fiable, però 1 de cada 5 fraus passa

Apujar el llindar compra precision (menys clients molestats) venent recall (més frau colat). No existeix el llindar correcte en abstracte: existeix el llindar correcte per a uns costos.

Fase 6: mètriques davant del desbalanç i matriu de confusió en cost de negoci

Per què l'accuracy enganya aquí. Amb un 0.5 % de frau, un «model» que respongui sempre «normal» encerta el 99.5 %. Espectacular i inútil: recall 0. Amb desbalanç fort, l'accuracy mesura sobretot la mida de la classe majoritària. Per això aquest projecte s'avalua amb precision, recall i F1:

Mètrica Pregunta que respon A TecnoMarket
Precision Del que assenyalo, quant és frau de veritat? Quants clients legítims molesto
Recall Del frau real, quant en caço? Quants diners deixo de perdre
F1 Mitjana harmònica de totes dues Compromís en un sol número

La matriu de confusió, en euros. Suposem el llindar del percentil 99 i la seva matriu típica sobre el test (3 000 normals + 100 fraus):

Predit normal Predit frau
Real normal 2 966 (TN) 34 (FP)
Real frau 12 (FN) 88 (TP)

Traducció a cost, amb supòsits explícits (frau mitjà no detectat ≈ 400 EUR perduts; falsa alerta ≈ 5 EUR de cost de revisió + fricció amb el client):

  • FN (frau perdut): 12 × 400 = 4 800 EUR de pèrdua directa.
  • FP (client molestat): 34 × 5 = 170 EUR + risc reputacional.

Amb aquests supòsits, un FN costa ~80 vegades més que un FP: convé abaixar el llindar (percentil 95-97) i acceptar més alertes falses. Si el teu procés de revisió fos car o bloquegés comandes automàticament, la balança canviaria. El llindar és una decisió de negoci informada pel model, no un hiperparàmetre tècnic.

Fase 7: lliurament — cua de revisió i API

La decisió final no és binària sinó a tres bandes, reutilitzant la cua de revisió humana de 03-04:

LLINDAR_REVISAR = np.percentile(score_val, 95)    # sospitós: a revisió
LLINDAR_BLOQUEJAR = np.percentile(score_val, 99.9) # flagrant: retenir la comanda

def avaluar_transaccio(x_raw):
    x = scaler.transform(x_raw.reshape(1, -1))          # MATEIX scaler del train
    score = float(puntuacio_anomalia(autoencoder, x)[0])
    if score > LLINDAR_BLOQUEJAR:
        return {"score": score, "accio": "retenir_i_verificar"}
    if score > LLINDAR_REVISAR:
        return {"score": score, "accio": "cua_revisio"}
    return {"score": score, "accio": "aprovar"}

Per desplegar seguim 06-05 al peu de la lletra: scaler i model es desen i es versionen com una unitat (per exemple, scaler serialitzat al costat del .keras, o integrat com a capa Normalization dins del model d'inferència), es valida sobre un conjunt congelat abans de cada actualització, i se serveix darrere d'un endpoint FastAPI (POST /avaluar-transaccio) idèntic en estructura al del classificador de ressenyes. Punt operatiu important: el que és «normal» canvia amb les temporades (recorda el Black Friday de la sèrie de 04-04) — programa reentrenaments periòdics amb dades normals recents perquè la campanya de rebaixes no es converteixi en una tempesta de falses alarmes.

Errors Comuns i Consells

  • Colar fraus a l'entrenament: si l'autoencoder aprèn a reconstruir frau, el frau deixa de puntuar alt. La puresa del train és la premissa de tot el mètode.
  • Ajustar l'scaler amb totes les dades: fuga clàssica (04-04). L'scaler s'ajusta amb el train normal i s'aplica, congelat, a tota la resta.
  • Presumir d'accuracy: un 99 % d'accuracy aquí no significa res. Reporta sempre precision, recall i F1, i la matriu de confusió.
  • Triar el llindar sobre el test: el test (amb els seus fraus) és el conjunt congelat. El llindar es fixa amb validació (percentils de normals) o amb costos de negoci; el test només mesura el resultat.
  • Llindar únic i immòbil: la distribució de scores deriva amb el temps. Monitora el percentatge d'alertes diari: si es dispara sense cap campanya que ho expliqui, toca reentrenar o recalibrar.

Exercicis

  1. Redueix el coll d'ampolla a 2 unitats i augmenta'l a 4. Compara la separació entre scores de normals i fraus (per exemple, amb la mediana de cada grup) en els tres casos. Què passa amb un coll massa ample?
  2. Afegeix un tercer patró de frau que no existeixi a les dades actuals (per exemple, import normal però centenars d'articles) i avalua el detector sense reentrenar-lo. El detecta? Què et diu això davant d'un classificador supervisat?
  3. Defineix una funció de cost total cost(llindar) = 400 * FN + 5 * FP, avalua-la per a percentils de 90 a 99.9 i troba el llindar que la minimitza. Compara'l amb el que vas triar a ull a la fase 5.

Solucions

  1. Amb coll de 2, la reconstrucció de normals empitjora una mica (puja l'score base) però la separació relativa sol mantenir-se o millorar; amb 4-5 unitats l'autoencoder s'acosta a la identitat i reconstrueix també els fraus raonablement bé: la separació s'enfonsa. Lliçó: el coll estret no és un defecte, és el mecanisme.
  2. Genera per exemple np.column_stack([rng.lognormal(3.6,0.7,50), hores_normals, rng.poisson(200,50), dist_normals, antig_normals]), escala amb el mateix scaler i puntua. El detector típicament el caça amb scores altíssims: n_items=200 és lluníssim de tot el que ha après. Un classificador supervisat entrenat només amb els patrons A i B no tindria cap garantia de detectar-lo — aquest és l'avantatge estructural de l'enfocament no supervisat.
  3. Recorre percentils, calcula la matriu de confusió amb confusion_matrix(y_test, pred).ravel() (ordre tn, fp, fn, tp) i avalua 400*fn + 5*fp. Amb els costos donats, el mínim sol caure entre els percentils 95 i 97 — més agressiu que el 99 «intuïtiu», confirmant l'anàlisi de la fase 6. Si ho repeteixes amb un cost de FP de 50 EUR, l'òptim es desplaça cap amunt: el llindar òptim és funció dels costos.

Conclusió

Tercer projecte lliurat: un detector de frau que no necessita exemples de frau per entrenar-se. Has aplicat l'autoencoder de 05-02 amb la disciplina de dades de 04-04 (escalatge sense fuga, particions honestes), convertit l'error de reconstrucció en puntuació d'anomalia, triat llindar com a compromís precision-recall i traduït la matriu de confusió a euros — el pas que converteix un exercici tècnic en una eina de negoci amb la seva cua de revisió (03-04) i la seva API (06-05). A la propera lliçó tornem a les imatges i saldem el deute més esperat del mòdul 5: el bucle d'entrenament complet d'una GAN amb GradientTape, per al prototip de generació de creativitats de TecnoMarket.

Curs de Deep Learning

Mòdul 1: Introducció al Deep Learning

Mòdul 2: Fonaments de Xarxes Neuronals

Mòdul 3: Xarxes Neuronals Convolucionals (CNN)

Mòdul 4: Xarxes Neuronals Recurrents (RNN)

Mòdul 5: Tècniques Avançades en Deep Learning

Mòdul 6: Eines i Frameworks

Mòdul 7: Projectes Pràctics

Mòdul 8: Consideracions Ètiques i Futur del Deep Learning

© Copyright 2026. Tots els drets reservats