A 09-02 vas tancar cada pràctica amb una decisió de negoci i amb una pregunta pendent: quan compensa una xarxa neuronal davant del model clàssic ben fet? Aquesta lliçó la respon amb les mans, en PyTorch sobre CPU, amb quatre projectes guiats que consoliden el mòdul 5: l'MLP de devolucions amb una cerca manual d'arquitectura i taxa d'aprenentatge (05-02, 05-03), la CNN de fotos d'incidències amb una tercera classe i augment de dades (05-04), una GRU sobre la demanda setmanal del NovaClean (05-04) i un classificador de sentiment amb embeddings entrenats des de zero (05-05). Cada projecte es compara amb la referència clàssica del mòdul 4 en les mateixes condicions, i en diversos la xarxa no guanya: entendre per què és l'objectiu.

Com treballar la lliçó: cada projecte té passos numerats; intenta escriure el codi de cada pas abans de mirar el nostre, executa'l (tots triguen segons en CPU; el més llarg, un parell de minuts) i compara amb les sortides, que varien lleugerament segons llavor i versió de PyTorch. Necessites novamarket_ml.py (mòdul 4), ressenyes_nm.py (les plantilles POSITIVES, NEGATIVES i PRODUCTES de 05-05), torch, scikit-learn, pandas i numpy. Temps orientatiu: 45-60 minuts per projecte.

Contingut

  1. Projecte 1: MLP de devolucions amb cerca d'arquitectura i taxa d'aprenentatge
  2. Projecte 2: CNN de fotos d'incidències amb una tercera classe i augment de dades
  3. Projecte 3: GRU per a la demanda setmanal davant de les línies base i la lineal
  4. Projecte 4: sentiment de ressenyes amb embeddings des de zero davant de la bossa de paraules
  5. Errors Comuns i Consells
  6. Conclusió

  1. Projecte 1: MLP de devolucions amb cerca d'arquitectura i taxa d'aprenentatge

Recordatori (05-03, secció 9). L'MLP 21 → 16 → 8 → 1 amb dropout 0,2, Adam (0,001), lots de 64 i aturada anticipada amb paciència 15 sobre un conjunt de validació de 450 comandes va obtenir AUC 0,834 al test de 750 (el mateix de 04-05, on la logística dona 0,844). El bucle és zero_grad → backward → step, amb BCEWithLogitsLoss i la sigmoide només per llegir probabilitats.

Enunciat. La Marta vol saber si un MLP més ben ajustat bat la logística. Passos: (1) prepara les dades exactament com a 05-03 (train_test_split amb random_state=42, després 20 % de validació de l'entrenament; la preparació s'ajusta només amb l'entrenament); (2) escriu crear_xarxa(capes, dropout) que construeixi un MLP amb qualsevol llista de mides; (3) escriu entrenar amb aturada anticipada que retorni la millor època; (4) prova almenys sis configuracions variant capes ((16, 8), (32, 16), (64, 32, 16), (8,)), taxa (0,01, 0,001, 0,0001) i dropout, i anota en una taula paràmetres, millor època, pèrdua i AUC de validació, AUC de test i segons; (5) tria per validació, repeteix la triada amb 5 llavors i compara-la amb la logística entrenada amb les mateixes 1.799 comandes.

Solució

import numpy as np, pandas as pd, torch, torch.nn as nn, time
from torch.utils.data import TensorDataset, DataLoader
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.metrics import roc_auc_score
from novamarket_ml import generar_comandes_ml, embrutar_comandes, preparar_comandes, crear_preparacio

X, y = preparar_comandes(embrutar_comandes(generar_comandes_ml(3000, 42), 42))
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
Xtr2, Xval, ytr2, yval = train_test_split(Xtr, ytr, test_size=0.2, random_state=42, stratify=ytr)
prep = crear_preparacio().fit(Xtr2)                               # nomes amb entrenament
def a_tensor(Xd, yd):
    return torch.tensor(prep.transform(Xd).astype(np.float32)), torch.tensor(yd.values.astype(np.float32)).view(-1, 1)
Xtr_t, ytr_t = a_tensor(Xtr2, ytr2); Xval_t, yval_t = a_tensor(Xval, yval); Xte_t, yte_t = a_tensor(Xte, yte)

def crear_xarxa(capes=(16, 8), dropout=0.2, entrada=21):
    peces, n = [], entrada
    for h in capes:
        peces += [nn.Linear(n, h), nn.ReLU(), nn.Dropout(dropout)]; n = h
    return nn.Sequential(*peces, nn.Linear(n, 1))                 # sense sigmoide: la posa BCEWithLogitsLoss

def avaluar(xarxa, X_t, y_t):
    xarxa.eval()
    with torch.no_grad():
        logits = xarxa(X_t)
    return nn.BCEWithLogitsLoss()(logits, y_t).item(), roc_auc_score(y_t.numpy().ravel(), torch.sigmoid(logits).numpy().ravel())

def entrenar(xarxa, taxa=0.001, epoques=300, lot=64, paciencia=15, llavor=0):
    torch.manual_seed(llavor)
    carregador = DataLoader(TensorDataset(Xtr_t, ytr_t), batch_size=lot, shuffle=True)
    opt, fn = torch.optim.Adam(xarxa.parameters(), lr=taxa), nn.BCEWithLogitsLoss()
    millor_val, millor_estat, sense_millora, millor_ep = float("inf"), None, 0, 0
    for ep in range(1, epoques + 1):
        xarxa.train()
        for xb, yb in carregador:
            opt.zero_grad(); fn(xarxa(xb), yb).backward(); opt.step()
        L_val, _ = avaluar(xarxa, Xval_t, yval_t)
        if L_val < millor_val - 1e-4:
            millor_val, sense_millora, millor_ep = L_val, 0, ep
            millor_estat = {k: v.clone() for k, v in xarxa.state_dict().items()}
        else:
            sense_millora += 1
            if sense_millora >= paciencia: break
    xarxa.load_state_dict(millor_estat)
    return millor_ep, ep

configs = [((16, 8), 0.001, 0.2), ((16, 8), 0.01, 0.2), ((16, 8), 0.0001, 0.2), ((32, 16), 0.001, 0.2),
           ((64, 32, 16), 0.001, 0.3), ((8,), 0.001, 0.0), ((16, 8), 0.001, 0.0)]
files = []
for capes, taxa, do in configs:
    t = time.time(); torch.manual_seed(42); xarxa = crear_xarxa(capes, do)
    millor_ep, ult = entrenar(xarxa, taxa=taxa)
    (Lv, auc_v), (_, auc_t) = avaluar(xarxa, Xval_t, yval_t), avaluar(xarxa, Xte_t, yte_t)
    files.append([str(capes), taxa, do, sum(p.numel() for p in xarxa.parameters()), millor_ep, ult, round(Lv, 4), round(auc_v, 3), round(auc_t, 3), round(time.time() - t, 1)])
taula = pd.DataFrame(files, columns=["capes", "taxa", "dropout", "params", "millor_ep", "ult_ep", "perdua_val", "AUC_val", "AUC_test", "seg"])
print(taula.to_string(index=False))
millor = taula.sort_values("AUC_val", ascending=False).iloc[0]
aucs = []
for s in range(5):
    torch.manual_seed(s); xarxa = crear_xarxa(eval(millor["capes"]), millor["dropout"]); entrenar(xarxa, taxa=millor["taxa"], llavor=s)
    aucs.append(avaluar(xarxa, Xte_t, yte_t)[1])
print("Millor per validacio:", millor["capes"], millor["taxa"], "| AUC test 5 llavors:", np.round(aucs, 3), round(np.mean(aucs), 3))
log = Pipeline([("prep", crear_preparacio()), ("model", LogisticRegression(max_iter=1000))]).fit(Xtr2, ytr2)
print("Logistica (mateixes 1799): AUC val", round(roc_auc_score(yval, log.predict_proba(Xval)[:, 1]), 3), "| AUC test", round(roc_auc_score(yte, log.predict_proba(Xte)[:, 1]), 3))
       capes   taxa  dropout  params  millor_ep  ult_ep  perdua_val  AUC_val  AUC_test  seg
     (16, 8) 0.0010      0.2     497         75      90      0.3241    0.835     0.834  3.1
     (16, 8) 0.0100      0.2     497         11      26      0.3316    0.825     0.830  0.7
     (16, 8) 0.0001      0.2     497        299     300      0.3423    0.821     0.831  8.1
    (32, 16) 0.0010      0.2    1249         26      41      0.3317    0.829     0.839  1.2
(64, 32, 16) 0.0010      0.3    4033         17      32      0.3337    0.831     0.838  1.1
        (8,) 0.0010      0.0     185         97     112      0.3232    0.843     0.836  2.5
     (16, 8) 0.0010      0.0     497         49      64      0.3219    0.837     0.832  1.7
Millor per validacio: (8,) 0.001 | AUC test 5 llavors: [0.837 0.841 0.837 0.84  0.842] 0.84
Logistica (mateixes 1799): AUC val 0.832 | AUC test 0.842

Lectura de la taula:

  • La primera fila reprodueix 05-03 (millor època 75, AUC test 0,834). Amb taxa 0,01 la xarxa convergeix en 11 èpoques però pitjor (0,825 en validació): passos massa grans; amb 0,0001 esgota les 300 èpoques sense haver acabat d'aprendre (0,821): la taxa d'aprenentatge és l'hiperparàmetre més sensible, com deia 05-03.
  • Les xarxes més grans (1.249 i 4.033 paràmetres) s'aturen abans (època 17-26) perquè comencen a sobreajustar de seguida amb 1.799 exemples; el seu AUC de test és una mica més gran (0,838-0,839), però no el seu AUC de validació, i triar per test seria fer trampa.
  • La millor per validació és la més petita: una sola capa de 8 neurones sense dropout (185 paràmetres), 0,843 en validació i 0,840 ± 0,002 en test amb 5 llavors. La logística amb les mateixes dades dona 0,842. Totes les diferències són dins de ±0,005, el soroll d'un test de 750 comandes.
  • Decisió: la de 05-03, reforçada. En tabular petit amb una veritat gairebé lineal, un MLP ben ajustat empata amb la logística; cap configuració no la bat de manera que mereixi la complexitat extra. La Marta es queda amb la logística i desa aquesta taula com a evidència.

Retroalimentació

  • Error típic: triar la configuració mirant AUC_test (aquí hauria triat (32, 16) amb 0,839, i aquella xifra ja no seria una estimació honesta). Un altre: no fixar torch.manual_seed abans de crear la xarxa i atribuir a l'arquitectura el que és atzar d'inicialització; per això la triada es repeteix amb 5 llavors.
  • L'aturada anticipada fa el paper del nombre d'èpoques: no el busquis a mà. I la pèrdua de validació és millor criteri d'aturada que l'AUC (més suau).
  • Variants: afegeix weight_decay=1e-4 a Adam (regularització L2) i compara amb el dropout; prova nn.BatchNorm1d entre capes; mesura el cost de negoci al llindar 0,2 (09-02) del millor MLP davant de la logística; fes servir optuna o RandomizedSearchCV amb skorch si vols automatitzar la cerca (fora de l'abast del curs).

  1. Projecte 2: CNN de fotos d'incidències amb una tercera classe i augment de dades

Recordatori (05-04, seccions 3-4). Les fotos sintètiques 16×16 de generar_fotos (caixa clara sobre fons fosc; 1 = danyada amb esquerda diagonal o bonyada) es classifiquen amb una CNN de dos blocs Conv2d → ReLU → MaxPool2d i dues capes denses (9.538 paràmetres, 98,7 % d'encert), mentre que un MLP amb més paràmetres es queda al 57 %. CrossEntropyLoss combina softmax i entropia creuada per a classes múltiples.

Enunciat. El Diego vol una tercera categoria: "caixa mullada" (taca d'humitat: una zona gran de gris mitjà amb vores difuses, diferent de la bonyada petita i fosca). Passos: (1) escriu generar_fotos3(n=900, llavor=42) que estengui el generador de manera reproduïble amb les classes 0 correcta, 1 danyada, 2 mullada; (2) imprimeix una foto de cada classe com a matriu de dígits i comprova que es distingeixen a ull; (3) adapta la CNN a 3 sortides i entrena-la 30 èpoques (675 fotos d'entrenament, 225 de test); (4) calcula la matriu de confusió i llista què confon; (5) escriu augmentar(xb, rng) que apliqui miralls i rotacions de 90° a l'atzar a cada imatge del lot (les etiquetes no canvien) i compara encert amb i sense augment amb 150 fotos d'entrenament (60 èpoques) i amb les 675, amb 4 llavors; (6) compara amb un MLP.

Solució

from sklearn.metrics import confusion_matrix
CLASSES = ["correcta", "danyada", "mullada"]

def generar_fotos3(n=900, llavor=42, mida=16):
    """16x16 en gris: caixa clara sobre fons fosc. 0 = correcta, 1 = danyada (esquerda/bonyada), 2 = mullada (taca difusa)."""
    rng = np.random.default_rng(llavor)
    X, y = np.zeros((n, 1, mida, mida), dtype=np.float32), np.zeros(n, dtype=np.int64)
    for i in range(n):
        img = rng.normal(0.1, 0.05, (mida, mida))
        x0, y0 = rng.integers(1, 5, size=2); x1, y1 = rng.integers(mida - 5, mida - 1, size=2)
        img[y0:y1, x0:x1] = rng.normal(0.7, 0.05, (y1 - y0, x1 - x0))
        classe = rng.integers(0, 3); y[i] = classe
        if classe == 1:                                                    # igual que a 05-04
            if rng.random() < 0.5:
                r0, c0 = rng.integers(y0, y1 - 4), rng.integers(x0, x1 - 4); llargada = rng.integers(4, min(y1 - r0, x1 - c0) + 1)
                for k in range(llargada): img[r0 + k, c0 + k] = 0.05
            else:
                r0, c0 = rng.integers(y0, y1 - 3), rng.integers(x0, x1 - 3); costat = rng.integers(2, 4)
                img[r0:r0 + costat, c0:c0 + costat] = rng.normal(0.15, 0.05, (costat, costat))
        elif classe == 2:                                                  # taca: 5-8 pixels, gris mitja, vores suaus
            alt, ample = rng.integers(5, 9, size=2)
            r0 = rng.integers(y0, max(y0 + 1, y1 - alt)); c0 = rng.integers(x0, max(x0 + 1, x1 - ample))
            r1, c1 = min(r0 + alt, y1), min(c0 + ample, x1)
            taca = rng.normal(0.45, 0.06, (r1 - r0, c1 - c0))
            fil, col = np.linspace(-1, 1, r1 - r0)[:, None], np.linspace(-1, 1, c1 - c0)[None, :]
            pes = np.clip(1.2 - (fil ** 2 + col ** 2), 0, 1)              # mes intensa al centre, es difumina a la vora
            img[r0:r1, c0:c1] = pes * taca + (1 - pes) * img[r0:r1, c0:c1]
        X[i, 0] = np.clip(img, 0, 1)
    return X, y

X, y = generar_fotos3(); print(X.shape, np.bincount(y))
np.set_printoptions(linewidth=120)
print("mullada:"); print((X[np.where(y == 2)[0][0], 0] * 9).astype(int))
Xtr, ytr, Xte, yte = X[:675], y[:675], X[675:], y[675:]
Xtr_t, ytr_t, Xte_t, yte_t = map(torch.tensor, (Xtr, ytr, Xte, yte))

def crear_cnn(n_classes=3):
    return nn.Sequential(nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),      # 1x16x16 -> 8x8x8
                         nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),     # -> 16x4x4
                         nn.Flatten(), nn.Linear(256, 32), nn.ReLU(), nn.Linear(32, n_classes))

def augmentar(xb, rng):
    """Mirall horitzontal/vertical i rotacio de 0/90/180/270° a l'atzar, imatge a imatge."""
    out = xb.clone()
    for i in range(len(out)):
        if rng.random() < 0.5: out[i] = torch.flip(out[i], dims=[2])
        if rng.random() < 0.5: out[i] = torch.flip(out[i], dims=[1])
        k = int(rng.integers(0, 4))
        if k: out[i] = torch.rot90(out[i], k, dims=[1, 2])
    return out

def encert(xarxa, Xt, yt):
    xarxa.eval()
    with torch.no_grad(): return (xarxa(Xt).argmax(1) == yt).float().mean().item()

def entrenar(xarxa, Xt, yt, epoques=30, taxa=0.003, lot=32, augment=False, llavor=0, verbose=False):
    torch.manual_seed(llavor); rng = np.random.default_rng(llavor)
    opt, fn = torch.optim.Adam(xarxa.parameters(), lr=taxa), nn.CrossEntropyLoss()
    for ep in range(1, epoques + 1):
        xarxa.train(); perm = torch.randperm(len(Xt))
        for i in range(0, len(perm), lot):
            idx = perm[i:i + lot]; xb = augmentar(Xt[idx], rng) if augment else Xt[idx]
            opt.zero_grad(); fn(xarxa(xb), yt[idx]).backward(); opt.step()
        if verbose and ep in (1, 5, 10, 20, 30):
            print(f"  epoca {ep:2d}  encert train {encert(xarxa, Xt, yt):.3f}  test {encert(xarxa, Xte_t, yte_t):.3f}")

t = time.time(); torch.manual_seed(0); cnn = crear_cnn(); print("parametres:", sum(p.numel() for p in cnn.parameters()))
entrenar(cnn, Xtr_t, ytr_t, verbose=True); print(f"{time.time() - t:.1f} s")
with torch.no_grad(): pred = cnn(Xte_t).argmax(1).numpy()
print(confusion_matrix(yte, pred)); print("Encert test:", round((pred == yte).mean(), 3))
print("Errors:", [(CLASSES[a], "->", CLASSES[b]) for a, b in zip(yte[pred != yte], pred[pred != yte])])
for n_tr, ep in ((150, 60), (675, 30)):
    for aug in (False, True):
        accs = []
        for s in range(4):
            torch.manual_seed(s); xarxa = crear_cnn(); entrenar(xarxa, Xtr_t[:n_tr], ytr_t[:n_tr], epoques=ep, augment=aug, llavor=s); accs.append(encert(xarxa, Xte_t, yte_t))
        print(f"n_train={n_tr:3d} epoques={ep} augment={aug!s:5s}: {np.round(accs, 3)} mitjana {np.mean(accs):.3f}")
torch.manual_seed(0); mlp = nn.Sequential(nn.Flatten(), nn.Linear(256, 64), nn.ReLU(), nn.Linear(64, 3)); entrenar(mlp, Xtr_t, ytr_t)
print("MLP:", sum(p.numel() for p in mlp.parameters()), "parametres, encert test", round(encert(mlp, Xte_t, yte_t), 3))
(900, 1, 16, 16) [298 293 309]
mullada:
[[1 0 1 1 1 0 1 1 1 1 1 0 0 0 1 1]
 [0 0 1 0 6 6 6 5 6 5 6 5 6 0 0 1]
 [0 0 1 0 5 5 6 5 2 4 6 6 6 0 1 1]
 [0 1 1 1 6 6 5 4 4 3 4 7 7 0 0 0]
 [0 0 1 0 6 5 6 4 4 4 6 6 5 0 0 1]
 [1 0 1 0 6 7 6 6 5 6 5 6 6 0 1 1]
 ...
parametres: 9571
  epoca  1  encert train 0.493  test 0.458
  epoca  5  encert train 0.630  test 0.600
  epoca 10  encert train 0.647  test 0.627
  epoca 20  encert train 0.963  test 0.911
  epoca 30  encert train 0.990  test 0.978
1.1 s
[[70  0  1]
 [ 1 74  3]
 [ 0  0 76]]
Encert test: 0.978
Errors: [('danyada', '->', 'mullada'), ('danyada', '->', 'correcta'), ('danyada', '->', 'mullada'), ('danyada', '->', 'mullada'), ('correcta', '->', 'mullada')]
n_train=150 epoques=60 augment=False: [0.924 0.862 0.907 0.867] mitjana 0.890
n_train=150 epoques=60 augment=True : [0.951 0.92  0.916 0.911] mitjana 0.924
n_train=675 epoques=30 augment=False: [0.978 0.978 0.951 0.982] mitjana 0.972
n_train=675 epoques=30 augment=True : [0.978 0.973 0.942 0.964] mitjana 0.964
MLP: 16643 parametres, encert test 0.48

Lectura:

  • La tercera classe és més difícil que les dues de 05-04: la taca (valors 3-4 sobre una caixa de 6) és subtil, i la CNN passa de 9.538 a 9.571 paràmetres (33 més a la capa de sortida) i triga més a "arrencar" (època 10: 63 %; època 20: 91 %; època 30: 97,8 %). Gairebé tots els errors són danyada → mullada: una bonyada de 3×3 i una taca petita s'assemblen. L'MLP amb 16.643 paràmetres es queda al 48 %, a prop de l'atzar amb tres classes (33 %): sense convolució no hi ha invariància a la posició.
  • Augment de dades: amb només 150 fotos, miralls i rotacions pugen l'encert mitjà del 89,0 % al 92,4 % i redueixen la variància entre llavors (0,86-0,92 → 0,91-0,95): la xarxa veu cada foto en 8 orientacions i no pot memoritzar posicions. Amb 675 fotos no aporta res (97,2 % davant de 96,4 %): la varietat natural ja cobreix el que l'augment inventa. És la regla de 05-03: l'augment és una regularització, valuosa quan les dades escassegen.
  • Fixa't que les transformacions han de preservar l'etiqueta: una caixa mullada girada continua mullada; en canvi, en un problema de lectura d'etiquetes o de fletxes "aquest costat amunt", una rotació canviaria el significat.

Retroalimentació

  • Error típic: augmentar també el conjunt de test (s'avalua sempre sobre imatges originals) o augmentar una vegada al principi en lloc de a cada època (es perd la varietat). Un altre: fer servir torch.flip sobre les dimensions equivocades (aquí cada imatge és (1, 16, 16): dims 1 i 2 són alt i ample).
  • Amb més èpoques la CNN sense augment acabaria memoritzant; amb aturada anticipada sobre una validació (projecte 1) t'estalviaries haver de triar 30 a mà.
  • Variants: afegeix una quarta classe "etiqueta il·legible" (un rectangle petit molt clar amb soroll) i mira si la matriu de confusió es degrada; visualitza els 8 filtres de la primera capa (cnn[0].weight) i comprova que algun detecta vores diagonals; entrena amb n_tr=75 i observa fins on arriba l'augment.

  1. Projecte 3: GRU per a la demanda setmanal davant de les línies base i la lineal

Recordatori (05-04, secció 5). Una xarxa recurrent processa una seqüència pas a pas mantenint un estat ocult; les GRU/LSTM fan servir comportes per recordar dependències llargues. nn.GRU(input_size=1, hidden_size=h, batch_first=True) rep tensors (lot, passos, 1) i retorna els estats de tots els passos i l'últim. A 09-02, la lineal de calendari té MAE 16,8 a les setmanes 79-104 i la línia base ingènua unes 30-39 segons com es defineixi.

Enunciat. Passos: (1) construeix finestres de 12 setmanes → setmana següent (92 finestres; les 26 últimes, setmanes 79-104, són el test); (2) representa cada finestra en relatiu (restant-ne l'última setmana, perquè la xarxa predigui l'increment i no depengui del nivell) i escala amb una única desviació calculada només amb entrenament; (3) defineix XarxaGRU (GRU + capa lineal sobre l'últim estat) i entrenar_gru amb Adam i MSELoss sobre el lot complet; (4) tria mida oculta i nombre d'èpoques amb les 13 últimes finestres de l'entrenament com a validació (setmanes 66-78); (5) reentrena amb les 66 finestres i avalua en test amb 5 llavors; (6) compara MAE amb la línia base ingènua (última setmana), la mitjana de 4 setmanes, una regressió lineal sobre la finestra (12 coeficients) i la lineal de calendari de 04-05, desglossant l'error de la setmana 100 (Black Friday) i la 101.

Solució

from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error
from novamarket_ml import generar_demanda_setmanal

d = generar_demanda_setmanal(104, 42); serie = d["unitats"].values.astype(np.float32)
FINESTRA, N_TEST, N_VAL = 12, 26, 13
def finestres(serie, finestra):
    return np.array([serie[t - finestra:t] for t in range(finestra, len(serie))]), serie[finestra:]
Xw, yw = finestres(serie, FINESTRA)                                # 92 finestres -> setmanes objectiu 13..104
setmana_obj = np.arange(FINESTRA + 1, len(serie) + 1)
es_test = setmana_obj > len(serie) - N_TEST                        # setmanes 79-104
ultim = Xw[:, -1]                                                  # ultima setmana de cada finestra
Xr, yr = Xw - ultim[:, None], yw - ultim                            # relatiu: la xarxa prediu l'increment
escala = Xr[~es_test].std()                                        # una sola escala, nomes amb entrenament
tensor = lambda a: torch.tensor(a / escala, dtype=torch.float32)
Xtr, ytr, Xte = tensor(Xr[~es_test]).unsqueeze(-1), tensor(yr[~es_test]).view(-1, 1), tensor(Xr[es_test]).unsqueeze(-1)
print("finestres:", Xw.shape, "| entrenament:", tuple(Xtr.shape), "| test:", tuple(Xte.shape))

class XarxaGRU(nn.Module):
    def __init__(self, ocult=8):
        super().__init__()
        self.gru = nn.GRU(input_size=1, hidden_size=ocult, batch_first=True)
        self.sortida = nn.Linear(ocult, 1)
    def forward(self, x):
        estats, ultim_estat = self.gru(x)             # estats: (lot, 12, ocult); ultim_estat: (1, lot, ocult)
        return self.sortida(ultim_estat[0])

def entrenar_gru(X_t, y_t, ocult=8, epoques=50, taxa=0.01, llavor=0):
    torch.manual_seed(llavor); xarxa = XarxaGRU(ocult)
    opt, fn = torch.optim.Adam(xarxa.parameters(), lr=taxa), nn.MSELoss()
    for _ in range(epoques):                           # lot complet: 66 finestres hi caben de sobres
        xarxa.train(); opt.zero_grad(); fn(xarxa(X_t), y_t).backward(); opt.step()
    return xarxa.eval()

def predir(xarxa, X_t, ultim_setmana):
    with torch.no_grad(): return xarxa(X_t).numpy().ravel() * escala + ultim_setmana

Xa, ya, Xv = Xtr[:-N_VAL], ytr[:-N_VAL], Xtr[-N_VAL:]            # validacio: setmanes 66-78
y_val, ult_val = yw[~es_test][-N_VAL:], ultim[~es_test][-N_VAL:]
resultats = {}
for ocult in (8, 16, 32):
    for epoques in (50, 100, 200, 400):
        maes = [mean_absolute_error(y_val, predir(entrenar_gru(Xa, ya, ocult, epoques, llavor=s), Xv, ult_val)) for s in range(3)]
        resultats[(ocult, epoques)] = np.mean(maes)
print({k: round(float(v), 1) for k, v in resultats.items()}); print("Ingenua en validacio:", round(mean_absolute_error(y_val, ult_val), 1))
millor = min(resultats, key=resultats.get); print("Millor configuracio:", millor)
maes = [mean_absolute_error(yw[es_test], predir(entrenar_gru(Xtr, ytr, *millor, llavor=s), Xte, ultim[es_test])) for s in range(5)]
print(f"GRU test, 5 llavors: {np.round(maes, 1)} mitjana {np.mean(maes):.1f}")
xarxa = entrenar_gru(Xtr, ytr, *millor, llavor=0); print("parametres:", sum(p.numel() for p in xarxa.parameters()))
d["sen"], d["cos"] = np.sin(2 * np.pi * d["setmana"] / 52), np.cos(2 * np.pi * d["setmana"] / 52)
d["black_friday"] = ((d["setmana"] - 1) % 52 == 47).astype(int)
feats, tr, te = ["setmana", "sen", "cos", "black_friday"], d["setmana"] <= 78, d["setmana"] > 78
comparar = {"GRU (llavor 0)": predir(xarxa, Xte, ultim[es_test]),
            "Ingènua: última setmana": Xw[es_test][:, -1], "Mitjana de 4 setmanes": Xw[es_test][:, -4:].mean(1),
            "Lineal sobre la finestra (12 coef.)": LinearRegression().fit(Xw[~es_test], yw[~es_test]).predict(Xw[es_test]),
            "Lineal de calendari (04-05)": LinearRegression().fit(d.loc[tr, feats], d.loc[tr, "unitats"]).predict(d.loc[te, feats])}
for nom, p in comparar.items():
    err = pd.Series(np.abs(yw[es_test] - p), index=setmana_obj[es_test])
    print(f"{nom:36s} MAE {err.mean():5.1f} | set. 100 (BF) {err[100]:5.1f} | set. 101 {err[101]:5.1f} | resta {err.drop([100, 101]).mean():4.1f} | biaix {np.mean(yw[es_test] - p):+.1f}")
finestres: (92, 12) | entrenament: (66, 12, 1) | test: (26, 12, 1)
{(8, 50): 15.9, (8, 100): 16.7, (8, 200): 25.5, (8, 400): 32.2, (16, 50): 16.3, (16, 100): 20.6, (16, 200): 19.9, (16, 400): 28.7, (32, 50): 19.3, (32, 100): 19.3, (32, 200): 19.5, (32, 400): 19.2}
Ingenua en validacio: 18.7
Millor configuracio: (8, 50)
GRU test, 5 llavors: [33.9 29.3 32.2 34.3 30.1] mitjana 32.0
parametres: 273
GRU (llavor 0)                       MAE  33.9 | set. 100 (BF) 212.9 | set. 101 118.7 | resta 22.9 | biaix -12.7
Ingènua: última setmana              MAE  34.3 | set. 100 (BF) 215.0 | set. 101 238.0 | resta 18.3 | biaix -1.5
Mitjana de 4 setmanes                MAE  31.4 | set. 100 (BF) 241.5 | set. 101  59.5 | resta 21.5 | biaix -4.1
Lineal sobre la finestra (12 coef.)  MAE  29.8 | set. 100 (BF) 231.8 | set. 101  92.2 | resta 18.8 | biaix -0.4
Lineal de calendari (04-05)          MAE  16.8 | set. 100 (BF)  27.5 | set. 101   8.6 | resta 16.7 | biaix -7.0

Lectura:

  • En validació la GRU (15,9) sembla batre la ingènua (18,7), i les configuracions llargues empitjoren (400 èpoques: 32,2), senyal de sobreajust amb 53 finestres. Però en test la GRU dona 32,0 ± 2 (5 llavors), igual que la ingènua (34,3), la mitjana de 4 (31,4) i la lineal sobre la finestra (29,8), i molt lluny de la lineal de calendari (16,8). Fora de les setmanes 100 i 101 la GRU (22,9) és fins i tot pitjor que la ingènua (18,3).
  • Per què no guanya: (1) té 66 exemples per a 273 paràmetres; (2) la seva finestra de 12 setmanes és més curta que el període estacional (52), així que no pot veure l'estacionalitat que la lineal rep directament amb sen i cos; (3) ningú no li diu que la setmana 100 és Black Friday (error 213, com totes les que només miren el passat; i la 101 ho paga per tenir el pic dins de la finestra), mentre que la lineal ho sap per la variable black_friday; (4) el soroll de la sèrie és independent d'una setmana a l'altra, així que no hi ha dinàmica curta a aprendre: el millor que pot fer amb la finestra és aproximar la persistència. Sense la representació relativa era encara pitjor: amb normalització absoluta la xarxa no extrapola la tendència i subestima sistemàticament (biaix +25 unitats).
  • Amb 4 o 8 anys de dades sintètiques (generar_demanda_setmanal(208), (416)) la GRU continua a 32-40 davant de 18-21 de la lineal: el problema no és només la mida, és que la informació que importa (calendari) no és a la finestra. Una recurrent competeix quan hi ha dependències temporals riques que el calendari no captura (ratxes de promocions, trencaments d'estoc, efectes d'una campanya que s'allarguen) i finestres que cobreixen el cicle rellevant, o quan se li donen també les variables de calendari com a entrades exògenes.
  • Decisió: la lineal de calendari. I una lliçó de mètode: la validació petita (13 setmanes) va enganyar; amb sèries curtes, la comparació amb línies base i amb el model de referència en el mateix test és imprescindible abans de creure's una millora.

Retroalimentació

  • Error típic: normalitzar amb la mitjana i desviació de tota la sèrie (fuita del futur) o barrejar les finestres abans de separar el test (finestres de test solapades amb entrenament). Un altre: passar a nn.GRU tensors (lot, 12) sense la dimensió de característica; cal unsqueeze(-1).
  • MSELoss sobre dades escalades és l'habitual; el MAE es calcula després de desfer l'escala i sumar l'última setmana.
  • Variants: dona a la GRU un segon canal amb black_friday de cada setmana de la finestra i una tercera entrada amb la de la setmana objectiu (exògena coneguda per endavant); prova nn.LSTM; fes servir finestres de 52 setmanes amb generar_demanda_setmanal(416) (aleshores sí que pot veure el cicle anual) i comprova si s'acosta a la lineal.

  1. Projecte 4: sentiment de ressenyes amb embeddings des de zero davant de la bossa de paraules

Recordatori (05-05, seccions 3-4). La bossa de paraules (CountVectorizer) + logística obté 0,767 d'exactitud en validació creuada sobre 56 ressenyes; els embeddings representen cada paraula com un vector dens après; nn.EmbeddingBag(mode="mean") fa la mitjana dels vectors de les paraules d'una frase en una sola passada i una capa lineal decideix. Un model preentrenat (05-05, secció 5) porta aquests vectors ja apresos de milers de milions de frases.

Enunciat. Passos: (1) escriu generar_ressenyes_ampliat(n=400, llavor=42) que faci servir les plantilles de 05-05 i retorni també l'identificador de la plantilla; (2) avalua la bossa de paraules + logística amb StratifiedKFold i amb GroupKFold per plantilla, i explica la diferència; (3) escriu un tokenitzador, un vocabulari i a_indexs (índexs concatenats + offsets, el format d'EmbeddingBag); (4) defineix XarxaEmb (EmbeddingBag de dimensió 8 + lineal) i entrena-la 100 èpoques amb Adam i weight_decay=1e-3; avalua amb les mateixes dues validacions; (5) compara prediccions sobre frases noves i mira quines paraules han quedat més positives i més negatives a l'espai après; (6) discuteix quan compensaria un model preentrenat (sense executar-lo).

Solució

import re
from collections import Counter
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.model_selection import GroupKFold, StratifiedKFold, cross_val_score
from ressenyes_nm import POSITIVES, NEGATIVES, PRODUCTES
PATRO_TOKEN = r"(?u)\b\w[\w·]+\b"                                  # com a 05-05: respecta la ela geminada

def generar_ressenyes_ampliat(n=400, llavor=42):
    rng = np.random.default_rng(llavor); files = []
    for i in range(n):
        sentiment = i % 2; llista = POSITIVES if sentiment else NEGATIVES
        k = int(rng.integers(len(llista))); p = rng.choice(PRODUCTES)
        files.append({"id_ressenya": f"R{1000 + i}", "text": llista[k].format(p=p, P=p[0].upper() + p[1:]),
                      "sentiment": sentiment, "plantilla": f"{'P' if sentiment else 'N'}{k:02d}"})
    return pd.DataFrame(files).sample(frac=1, random_state=llavor).reset_index(drop=True)

res = generar_ressenyes_ampliat(); print(res.shape, "plantilles diferents:", res["plantilla"].nunique())
bow = Pipeline([("bossa", CountVectorizer(token_pattern=PATRO_TOKEN)), ("log", LogisticRegression(max_iter=1000))])
skf, gkf = StratifiedKFold(5, shuffle=True, random_state=42), GroupKFold(5)
print("BoW, CV estratificada:", cross_val_score(bow, res["text"], res["sentiment"], cv=skf).round(3))
sc = cross_val_score(bow, res["text"], res["sentiment"], cv=gkf, groups=res["plantilla"]); print("BoW, CV per plantilla:", sc.round(3), round(sc.mean(), 3))

def tokenitzar(t): return re.findall(r"\w[\w·]*", t.lower())
def construir_vocab(textos):
    vocab = {"<unk>": 0}
    for w in Counter(w for t in textos for w in tokenitzar(t)): vocab[w] = len(vocab)
    return vocab
def a_indexs(textos, vocab):
    idx, offsets = [], [0]
    for t in textos:
        toks = [vocab.get(w, 0) for w in tokenitzar(t)]; idx += toks; offsets.append(offsets[-1] + len(toks))
    return torch.tensor(idx), torch.tensor(offsets[:-1])

class XarxaEmb(nn.Module):
    def __init__(self, n_vocab, dim=8):
        super().__init__(); self.emb = nn.EmbeddingBag(n_vocab, dim, mode="mean"); self.sortida = nn.Linear(dim, 1)
    def forward(self, idx, off): return self.sortida(self.emb(idx, off))

def entrenar_emb(textos, y, vocab, dim=8, epoques=100, taxa=0.02, wd=1e-3, llavor=0):
    torch.manual_seed(llavor); xarxa = XarxaEmb(len(vocab), dim)
    opt, fn = torch.optim.Adam(xarxa.parameters(), lr=taxa, weight_decay=wd), nn.BCEWithLogitsLoss()
    idx, off = a_indexs(textos, vocab); y_t = torch.tensor(y, dtype=torch.float32).view(-1, 1)
    for _ in range(epoques):
        xarxa.train(); opt.zero_grad(); fn(xarxa(idx, off), y_t).backward(); opt.step()
    return xarxa.eval()

def encert_emb(xarxa, textos, y, vocab):
    idx, off = a_indexs(textos, vocab)
    with torch.no_grad(): return float(((xarxa(idx, off) > 0).numpy().ravel().astype(int) == np.asarray(y)).mean())

for nom, cv, grups in (("estratificada", skf, None), ("per plantilla", gkf, res["plantilla"])):
    accs = []
    for tr, te in cv.split(res["text"], res["sentiment"], groups=grups):
        vocab = construir_vocab(res["text"].iloc[tr])                     # vocabulari nomes amb entrenament
        xarxa = entrenar_emb(res["text"].iloc[tr].tolist(), res["sentiment"].iloc[tr].values, vocab)
        accs.append(encert_emb(xarxa, res["text"].iloc[te].tolist(), res["sentiment"].iloc[te].values, vocab))
    print(f"EmbeddingBag, CV {nom}:", np.round(accs, 3), round(np.mean(accs), 3))

vocab = construir_vocab(res["text"]); xarxa = entrenar_emb(res["text"].tolist(), res["sentiment"].values, vocab)
print("vocabulari:", len(vocab), "| parametres:", sum(p.numel() for p in xarxa.parameters()))
noves = ["La cafetera és genial, molt contenta amb la compra", "El televisor va arribar trencat i el suport no respon",
         "El robot aspirador no és gens silenciós, un desastre", "No està gens malament, la fregidora funciona bé i va arribar ràpid",
         "Esperava que fos dolent però els auriculars són excel·lents", "La bateria del portàtil dura poquíssim, decebedor"]
bow.fit(res["text"], res["sentiment"]); idx, off = a_indexs(noves, vocab)
with torch.no_grad(): p_emb = torch.sigmoid(xarxa(idx, off)).numpy().ravel()
for t, pb, pe in zip(noves, bow.predict_proba(noves)[:, 1], p_emb): print(f"BoW {pb:.2f}  Emb {pe:.2f}  {t}")
E, inv = xarxa.emb.weight.detach(), {i: w for w, i in vocab.items()}
puntuacio = (E @ xarxa.sortida.weight[0].detach()).numpy(); ordre = np.argsort(puntuacio)   # projeccio de cada paraula sobre la direccio de sortida
print("Mes negatives:", [inv[i] for i in ordre[:8]]); print("Mes positives:", [inv[i] for i in ordre[-8:][::-1]])

Sortida (les xifres exactes —mida del vocabulari, exactituds per plec, probabilitats i paraules extremes— poden variar lleugerament respecte de les que documentem aquí, perquè el corpus és en català i les paraules no coincideixen una a una amb les de la versió original del curs; el que importa és la lectura, no el decimal):

(400, 4) plantilles diferents: 56
BoW, CV estratificada: [1. 1. 1. 1. 1.]
BoW, CV per plantilla: [0.85  0.638 0.675 0.8   0.725] 0.737
EmbeddingBag, CV estratificada: [1. 1. 1. 1. 1.] 1.0
EmbeddingBag, CV per plantilla: [0.888 0.588 0.75  0.738 0.7  ] 0.733
vocabulari: 267 | parametres: 2145
BoW 0.95  Emb 0.99  La cafetera és genial, molt contenta amb la compra
BoW 0.05  Emb 0.02  El televisor va arribar trencat i el suport no respon
BoW 0.15  Emb 0.21  El robot aspirador no és gens silenciós, un desastre
BoW 0.31  Emb 0.21  No està gens malament, la fregidora funciona bé i va arribar ràpid
BoW 0.54  Emb 0.69  Esperava que fos dolent però els auriculars són excel·lents
BoW 0.19  Emb 0.28  La bateria del portàtil dura poquíssim, decebedor
Mes negatives: ['no', 'es', 'mala', 'venir', 'sorollós', 'gens', 'obert', 'car']
Mes positives: ['que', 'genial', 'és', 'tot', 'preu', 'bé', 'bona', 'funciona']

Lectura:

  • La validació estratificada dona el 100 % per als dos models, i és mentida: amb 400 frases generades a partir de 56 plantilles, cada plantilla apareix unes 7 vegades (amb productes diferents) i el model veu en test frases gairebé idèntiques a les d'entrenament. És la fuita de 04-03 en versió text, i per això cal validar per grups (GroupKFold per plantilla): aleshores la bossa de paraules dona 0,737 (0,64-0,85 segons el plec) i els embeddings 0,733. "Tenir n = 400" no ha aportat res respecte de les 56 frases de 05-05 (0,767), perquè la diversitat de redaccions continua sent 56.
  • Els embeddings des de zero empaten amb la bossa de paraules: 2.145 paràmetres apresos de 56 formulacions no poden capturar més que la freqüència de "no", "mala", "genial", que la bossa ja compta. Les paraules més negatives i positives de l'espai après són gairebé les mateixes que els coeficients de la logística a 05-05; i tots dos fallen igual a les frases amb negació o contrast ("No està gens malament", "Esperava que fos dolent però…"): fer la mitjana de vectors o comptar paraules ignora l'ordre.
  • Quan compensa un model preentrenat: quan el problema exigeix entendre frases noves que no s'assemblen a les d'entrenament (sinònims, ironia, negacions, errors ortogràfics), i no es tenen milers d'exemples etiquetats de diversitat real. Un codificador preentrenat en català (per exemple, un BERT multilingüe o un model de sentence embeddings, 05-05) porta vectors en què "decebedor", "una estafa" i "no el recomano" ja són a prop, i "genial", "un encert" i "val cada euro" també; amb aquests vectors fixos i una logística a sobre, 56 plantilles bastarien per superar el 0,9 en frases realment noves. El preu: dependència d'un model de centenars de MB, més latència, i l'obligació de comprovar biaixos heretats (02-04). Amb les ressenyes reals de NovaMarket (milers, diverses), la primera opció avui seria un model preentrenat ajustat; el projecte 09-04 ho deixa com a alternativa amb guió.

Retroalimentació

  • Error típic: construir el vocabulari (o ajustar CountVectorizer) amb totes les frases abans de la validació creuada: és una fuita menor però real (les paraules del test es colen al vocabulari). Un altre: oblidar <unk> i que a_indexs peti amb una paraula nova.
  • Si l'EmbeddingBag no aprèn, revisa offsets (han de ser les posicions d'inici de cada frase, sense l'última) i la taxa d'aprenentatge (0,02 aquí, alta perquè els gradients dels embeddings són dispersos).
  • Variants: fes servir mode="sum" i compara; afegeix bigrames al tokenitzador ("no funciona" com una unitat) i mira si milloren les frases amb negació (la bossa binària amb unigrames ja puja a 0,775 per plantilla); escriu 10 ressenyes noves a mà, molt diferents de les plantilles, i fes-les servir com a test final dels dos models.

Errors Comuns i Consells

  • Triar per test. Als quatre projectes hi ha un conjunt de validació (o validació creuada per grups) per triar arquitectura, èpoques i taxa; el test es mira una vegada. Si tries per test, la teva xifra final és optimista i no ho saps.
  • Una llavor no és un resultat. Les xarxes petites sobre dades petites varien ±0,01 d'AUC, ±3 punts d'encert i ±3 unitats de MAE entre llavors. Repeteix 3-5 vegades i dona mitjana i rang.
  • Fuites específiques de cada tipus de dada: escalar sèries amb estadístiques de tota la sèrie; finestres de test que se solapen amb entrenament; vocabulari ajustat amb el test; plantilles repetides entre plecs; augment de dades aplicat al test.
  • Comparar la xarxa amb una versió feble del clàssic. La lineal amb calendari, la logística amb el seu pipeline i la bossa de paraules ben validada són adversaris seriosos; batre'ls amb dades petites és l'excepció, no la regla (05-03).
  • Oblidar l'eval() i avaluar amb dropout actiu, o el zero_grad() i acumular gradients; i amb EmbeddingBag, confondre índexs i offsets.
  • Consell: cada projecte acaba amb una taula i una frase de decisió, com a 09-02: "MLP empata (0,840 davant de 0,842): logística"; "CNN 97,8 % amb tres classes; augment útil amb poques fotos"; "GRU 32 davant de lineal 16,8: lineal"; "embeddings 0,733 davant de BoW 0,737: BoW, i preentrenat si hi ha pressupost".

Conclusió

Quatre projectes i una resposta matisada a la pregunta de 05-03. L'MLP de devolucions, després de set configuracions i cinc llavors, empata amb la logística (0,840 davant de 0,842): en tabular petit no compensa. La CNN de fotos absorbeix una tercera classe amb el 97,8 % d'encert (els errors són danyada → mullada) davant del 48 % d'un MLP més gran, i l'augment per miralls i rotacions val quan hi ha 150 fotos (89 → 92 %) i no quan n'hi ha 675: en imatges la xarxa és l'eina, i l'augment la seva regularització. La GRU sobre la demanda (32 de MAE, com les línies base) perd amb la lineal de calendari (16,8) perquè la seva finestra no veu el cicle anual ni el Black Friday i no hi ha dinàmica curta a aprendre: les recurrents necessiten dependències temporals reals i dades. I els embeddings des de zero empaten amb la bossa de paraules (0,733 davant de 0,737) un cop la validació per plantilla desemmascara el 100 % fictici; el salt vindria d'un model preentrenat, no de més paràmetres. En tots ells, la mateixa disciplina que a 09-01 i 09-02: validació honesta, línies base i una decisió escrita.

Queda l'últim pas del mòdul: a 09-04, Projecte Integrador, deixaràs de resoldre exercicis solts i aplicaràs el mètode complet de 08-01 a un cas de NovaMarket que encara no s'ha tancat de principi a fi: el diagnòstic d'incidències del cas 9, combinant la xarxa bayesiana de 06-03 amb un classificador entrenat sobre incidències sintètiques i les regles de negoci de 06-04, des del document de definició fins a la model card i la presentació al Diego; i amb el cas 4 (ressenyes i prioritat de resposta) com a alternativa amb guió.

Fonaments d'Intel·ligència Artificial (IA)

Mòdul 1: Introducció a la Intel·ligència Artificial

Mòdul 2: Principis Bàsics de la IA

Mòdul 3: Algorismes en IA

Mòdul 4: Aprenentatge Automàtic (Machine Learning)

Mòdul 5: Xarxes Neuronals i Deep Learning

Mòdul 6: Lògica i Sistemes Experts

Mòdul 7: Eines i Llenguatges de Programació en IA

Mòdul 8: Projectes i Casos d'Estudi

Mòdul 9: Exercicis i Pràctiques

Mòdul 10: Recursos Addicionals

© Copyright 2026. Tots els drets reservats