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
- Projecte 1: MLP de devolucions amb cerca d'arquitectura i taxa d'aprenentatge
- Projecte 2: CNN de fotos d'incidències amb una tercera classe i augment de dades
- Projecte 3: GRU per a la demanda setmanal davant de les línies base i la lineal
- Projecte 4: sentiment de ressenyes amb embeddings des de zero davant de la bossa de paraules
- Errors Comuns i Consells
- Conclusió
- 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.842Lectura 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 fixartorch.manual_seedabans 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-4a Adam (regularització L2) i compara amb el dropout; provann.BatchNorm1dentre capes; mesura el cost de negoci al llindar 0,2 (09-02) del millor MLP davant de la logística; fes serviroptunaoRandomizedSearchCVambskorchsi vols automatitzar la cerca (fora de l'abast del curs).
- 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.48Lectura:
- 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.flipsobre 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 ambn_tr=75i observa fins on arriba l'augment.
- 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.0Lectura:
- 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
senicos; (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 variableblack_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.GRUtensors(lot, 12)sense la dimensió de característica; calunsqueeze(-1). MSELosssobre 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_fridayde cada setmana de la finestra i una tercera entrada amb la de la setmana objectiu (exògena coneguda per endavant); provann.LSTM; fes servir finestres de 52 setmanes ambgenerar_demanda_setmanal(416)(aleshores sí que pot veure el cicle anual) i comprova si s'acosta a la lineal.
- 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 (
GroupKFoldper 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 quea_indexspeti amb una paraula nova. - Si l'
EmbeddingBagno aprèn, revisaoffsets(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 elzero_grad()i acumular gradients; i ambEmbeddingBag, 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
- Conceptes Fonamentals: Agents, Entorns i Racionalitat
- Tipus d'Intel·ligència Artificial
- Les Dades com a Matèria Primera de la IA
- Ètica i Consideracions en IA
Mòdul 3: Algorismes en IA
- Introducció als Algorismes
- Algorismes de Cerca
- Cerca amb Adversari: Jocs i Minimax
- Algorismes d'Optimització
Mòdul 4: Aprenentatge Automàtic (Machine Learning)
- Conceptes Bàsics de Machine Learning
- Tipus d'Aprenentatge Automàtic
- Preparació de Dades i Característiques
- Algorismes de Machine Learning
- Avaluació i Validació de Models
- Sobreajust, Regularització i Ajust d'Hiperparàmetres
Mòdul 5: Xarxes Neuronals i Deep Learning
- Introducció a les Xarxes Neuronals
- Arquitectura de Xarxes Neuronals
- Com Aprèn una Xarxa: Descens del Gradient i Retropropagació
- Deep Learning i les seves Aplicacions
- Transformers, Grans Models de Llenguatge i IA Generativa
Mòdul 6: Lògica i Sistemes Experts
- Lògica en IA
- Sistemes Experts
- Raonament amb Incertesa: Probabilitat i Xarxes Bayesianes
- Aplicacions dels Sistemes Experts
Mòdul 7: Eines i Llenguatges de Programació en IA
- Llenguatges de Programació per a IA
- Python Científic: NumPy, pandas i Matplotlib
- Eines i Llibreries Populars
- Entorns de Desenvolupament
Mòdul 8: Projectes i Casos d'Estudi
Mòdul 9: Exercicis i Pràctiques
- Exercicis d'Algorismes
- Pràctiques de Machine Learning
- Projectes de Xarxes Neuronals
- Projecte Integrador: de la Idea al Prototip
