Xarxes neuronals
- Origen i justificació
- La neurona
- Xarxa neuronal bàsica
- Arquitectura d’una xarxa
- Propagació cap endavant
- Entrenament de xarxes neuronals
- Funcions d’activació
- Regressió
- Classificació multiclasse
- PyTorch per tipus de problema
- Evitar el sobreajustament
- Models preentrenats i transfer learning
- L’arquitectura Transformer i els LLMs
Origen i justificació
Les xarxes neuronals són una branca de l’aprenentatge automàtic inspirada en l’estructura del cervell. Les xarxes amb moltes capes formen l’aprenentatge profund (deep learning), capaç d’aprendre representacions complexes a partir de grans volums de dades.
Els models tradicionals necessiten que algú dissenyi a mà les característiques d’entrada (l’enginyeria de característiques). Les xarxes neuronals, en canvi, poden aprendre representacions útils directament de dades en brut, com imatges, àudio o text sense processar. Això ha fet possible la visió per computador, el reconeixement de veu o la traducció automàtica actuals.
A més, escalen amb les dades: el seu rendiment continua millorant a mesura que tenen més dades, mentre que models com la regressió lineal o logística s’estanquen aviat. Aquesta capacitat, juntament amb el maquinari especialitzat com les GPU, explica bona part dels avenços de la IA de la darrera dècada.
La neurona
La unitat bàsica d’una xarxa és la neurona artificial. Rep diverses entrades, multiplica cadascuna pel seu pes, suma el resultat amb un biaix i hi aplica una funció d’activació:
\[ z = w_1 x_1 + w_2 x_2 + \cdots + w_n x_n + b \qquad a = g(z) \]
- x₁ … xₙ: les entrades
- w₁ … wₙ: un pes per a cada entrada, que indica la seva importància
- b: el biaix
- g: la funció d’activació, i a la sortida de la neurona (l’activació)
En paraules senzilles: és exactament la regressió logística quan g és la sigmoide. Una neurona sola només pot separar classes amb una recta.
Una nota històrica. El primer model d’aquest tipus va ser el perceptró de Frank Rosenblatt (1957). Feia servir una funció esglaó (sortida 0 o 1) i no podia resoldre problemes que no se separen amb una recta, com la porta lògica XOR. Les neurones actuals fan servir funcions d’activació suaus (diferenciables), que permeten entrenar-les amb descens del gradient, i s’organitzen en capes per aprendre relacions no lineals.
Xarxa neuronal bàsica
Combinant moltes neurones en capes obtenim una xarxa neuronal. Vegem-ne un exemple.
Volem predir si un producte tindrà una alta demanda. La sortida de la xarxa serà la probabilitat que esdevingui un supervendes.
- Característiques d’entrada: preu, cost d’enviament, esforç de màrqueting, material.
- Capa d’entrada: rep aquests quatre valors sense fer cap càlcul.
- Capa oculta: calcula característiques de nivell més alt (les activacions), aprenent a donar més importància a unes entrades o a unes altres. Per exemple:
- Assequibilitat (a partir de preu i enviament)
- Coneixement del producte (a partir de màrqueting)
- Qualitat percebuda (a partir de preu i material)
- Capa de sortida: rep els valors de la capa oculta i retorna la probabilitat estimada.
Les capes entre l’entrada i la sortida s’anomenen capes ocultes perquè no veiem directament què hi passa: aprenen representacions intermèdies útils per a la tasca.
L’arquitectura és [4-3-1], de dues capes (no comptem la d’entrada), i de tipus feedforward (la informació només va cap endavant).
Tot i que el diagrama només dibuixa algunes connexions, és una xarxa totalment connectada o densa (dense layer): cada neurona de la capa oculta rep les 4 entrades, i la de sortida rep les 3 neurones ocultes. Cada connexió té un pes i cada neurona un biaix, així que la xarxa té:
- Capa oculta: 4 × 3 = 12 pesos + 3 biaixos = 15 paràmetres
- Capa de sortida: 3 pesos + 1 biaix = 4 paràmetres
- Total: 19 paràmetres entrenables
En el disseny del diagrama nosaltres hem decidit quines entrades fa servir cada neurona. En una xarxa real, cada neurona rep totes les sortides de la capa anterior, i és l’entrenament qui decideix quines importen, ajustant els pesos.
Intuïció: si ignorem la capa d’entrada, la capa de sortida és una regressió logística amb tres entrades, més útils que les quatre originals. La xarxa fa l’enginyeria de característiques per nosaltres.
Arquitectura d’una xarxa
Per construir una xarxa cal decidir quantes capes ocultes tindrà i quantes neurones per capa. Aquestes decisions en defineixen l’arquitectura. La més habitual, amb capes denses una darrere l’altra, s’anomena perceptró multicapa (multilayer perceptron, MLP), un nom històric que s’ha mantingut.
Quan descrivim una arquitectura no comptem la capa d’entrada, perquè no transforma les dades. Una xarxa [64-25-15-1] té 3 capes entrenables: dues ocultes (25 i 15 neurones) i una de sortida (1 neurona).
Imaginem una imatge en escala de grisos de 1000×1000 píxels. Si l’aplanem, tenim un vector d’entrada d’1.000.000 de valors de brillantor. La sortida és la probabilitat que la cara sigui d’una persona concreta. Què aprèn cada capa?
- Capa baixa: detecta vores i línies
- Capa intermèdia: reconeix parts de la cara
- Capa alta: identifica cares completes
Cada capa combina el que ha detectat l’anterior. Són representacions jeràrquiques, i la xarxa les descobreix sola durant l’entrenament.
Propagació cap endavant
La propagació cap endavant (forward propagation) és el procés pel qual la xarxa transforma una entrada en una predicció: cada capa rep les activacions de l’anterior, hi aplica els seus pesos, biaixos i activació, i passa el resultat a la següent.
Exemple: reconèixer si una imatge de 8×8 píxels és un “0” o un “1”, amb una xarxa [64-25-15-1]:
- Entrada: els 64 píxels, aplanats en un vector.
- Primera capa oculta (25 neurones): detecta patrons simples, com vores i línies.
- Segona capa oculta (15 neurones): combina aquests patrons en formes més complexes.
- Sortida (1 neurona amb sigmoide): la probabilitat que sigui un “1”. Si és 0.5 o més, diem “1”.
Cada neurona fa el càlcul de la secció anterior. La sigmoide, que ja coneixem de la regressió logística, retorna valors entre 0 i 1: \(\sigma(0) = 0.5\), \(\sigma(5) \approx 0.993\), \(\sigma(-5) \approx 0.007\). Més endavant veurem altres activacions, com ReLU.
La propagació en forma de matrius
Agrupant els pesos de cada capa en una matriu \(W\), la xarxa de l’exemple calcula:
\[ a^{(1)} = \sigma(W^{(1)} x + b^{(1)}) \qquad a^{(2)} = \sigma(W^{(2)} a^{(1)} + b^{(2)}) \qquad \hat{y} = \sigma(W^{(3)} a^{(2)} + b^{(3)}) \]
- a⁽¹⁾, a⁽²⁾: les activacions de cada capa oculta
- W⁽ˡ⁾, b⁽ˡ⁾: els pesos i biaixos de la capa l
Les dimensions d’aquestes matrius i com es calcula tot un lot alhora són a Xarxes neuronals: ampliació.
Entrenament de xarxes neuronals
L’entrenament té tres passos: definir el model, definir la funció de pèrdua i minimitzar el cost.
A PyTorch, les dades i els pesos van en tensors, els arrays de NumPy per a xarxes. Com es creen, quin tipus i quina forma han de tenir i com es calculen els gradients és a Tensors de PyTorch.
Definició del model
El model diu com es calcula la sortida a partir de l’entrada. A PyTorch:
import torch
import torch.nn as nn
class ThreeLayerNN(nn.Module):
def __init__(self):
super().__init__()
# Arquitectura: 64 → 25 → 15 → 1
self.hidden1 = nn.Linear(64, 25) # Primera capa oculta
self.hidden2 = nn.Linear(25, 15) # Segona capa oculta
self.output = nn.Linear(15, 1) # Capa de sortida
def forward(self, x):
x = torch.sigmoid(self.hidden1(x)) # Activació capa oculta 1
x = torch.sigmoid(self.hidden2(x)) # Activació capa oculta 2
return self.output(x) # Sortida sense sigmoide (logits)
model = ThreeLayerNN()
__init__ crea les capes. forward es crida quan fem servir el model com una funció, model(x), i calcula les activacions. L’última capa no aplica la sigmoide: retorna les puntuacions (logits), i la sigmoide s’aplica després, com veurem a la funció de pèrdua.
Quan la xarxa és una cadena de capes, una darrere l’altra, nn.Sequential fa el mateix sense escriure la classe. Les activacions hi són capes (nn.Sigmoid, nn.ReLU):
model = nn.Sequential(
nn.Linear(64, 25), nn.Sigmoid(),
nn.Linear(25, 15), nn.Sigmoid(),
nn.Linear(15, 1), # logits
)
La classe cal quan forward fa alguna cosa més que encadenar capes.
Funció de pèrdua
- Funció de pèrdua (loss): mesura l’error d’una sola mostra.
- Cost: la mitjana de la pèrdua sobre totes les mostres del lot.
Per a classificació binària fem servir la mateixa entropia creuada que a la regressió logística, que a PyTorch s’anomena Binary Cross-Entropy (BCE). Hi ha dues versions:
# Opció 1: si el model aplica la sigmoide a la sortida
loss_fn = nn.BCELoss()
# Opció 2 (RECOMANADA, la del nostre model): si retorna les puntuacions sense sigmoide (logits)
loss_fn = nn.BCEWithLogitsLoss()
Els logits són les sortides de la xarxa abans d’aplicar la sigmoide. BCEWithLogitsLoss aplica la sigmoide i calcula la pèrdua en una sola operació, cosa que evita problemes de precisió numèrica.
Entrenament per minimitzar el cost
L’entrenament repeteix quatre passos moltes vegades:
-
Passada cap endavant (forward pass): la xarxa calcula una predicció \(\hat{y}\) per a cada entrada.
-
Càlcul de la pèrdua: es compara la predicció amb el valor real.
-
Passada cap enrere (backward pass o retropropagació, backpropagation): es calcula com ha contribuït cada pes a l’error, de la sortida cap a l’entrada. És com si la xarxa es preguntés:
“Si canviés una mica aquest pes, l’error milloraria o empitjoraria?”
-
Actualització dels pesos: l’optimitzador mou cada pes una mica en la direcció que redueix l’error, amb el mateix descens del gradient de la regressió lineal:
\[ w := w - \alpha \frac{\partial L}{\partial w} \]
- w: un pes de la xarxa
- L: la pèrdua
- α: la taxa d’aprenentatge (learning rate)
La taxa d’aprenentatge es comporta com a la regressió lineal: massa gran i l’entrenament oscil·la; massa petita i és molt lent. Un valor inicial típic és entre 0.001 i 0.1.
Com es calculen les derivades? No cal fer-ho a mà. PyTorch registra totes les operacions que fem durant la passada cap endavant (un graf computacional) i, quan cridem loss.backward(), el recorre al revés per calcular automàticament la derivada de la pèrdua respecte de cada pes. Això s’anomena diferenciació automàtica (autograd) i és el que permet entrenar xarxes amb milions de paràmetres. Els detalls són a Xarxes neuronals: ampliació.
Mini-lots i èpoques
A la pràctica no actualitzem els pesos amb totes les dades alhora, sinó per mini-lots (mini-batches):
- Una època (epoch) és una passada sencera per tot el conjunt d’entrenament.
- Cada època es divideix en mini-lots, típicament de 16 a 512 mostres.
- Els pesos s’actualitzen un cop per mini-lot.
Per exemple, amb 1000 mostres i batch_size=100, cada època té 10 mini-lots i fa 10 actualitzacions. Amb 100 èpoques, 1000 actualitzacions.
Entrenem la xarxa amb dades reals: les imatges de 8×8 píxels dels dígits 0 i 1 que inclou scikit-learn. Primer separem el test i escalem amb les estadístiques de l’entrenament, com a l’aprenentatge supervisat. Després el bucle d’entrenament, que posem en una funció per reutilitzar-la a la resta del document:
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from torch.utils.data import DataLoader, TensorDataset
# Dades: imatges de 8×8 píxels (64 valors) dels dígits 0 i 1
X, y = load_digits(n_class=2, return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
# Escalar amb les estadístiques de l'entrenament i passar a tensors
scaler = StandardScaler().fit(X_train)
X_train = torch.tensor(scaler.transform(X_train), dtype=torch.float32)
X_test = torch.tensor(scaler.transform(X_test), dtype=torch.float32)
y_train = torch.tensor(y_train, dtype=torch.float32).unsqueeze(1) # forma [mostres, 1]
y_test = torch.tensor(y_test, dtype=torch.float32).unsqueeze(1)
train_loader = DataLoader(TensorDataset(X_train, y_train), batch_size=32, shuffle=True)
def train(model, loader, loss_fn, optimizer, epochs):
for epoch in range(epochs):
model.train()
for X_batch, y_batch in loader:
logits = model(X_batch) # 1. passada cap endavant
loss = loss_fn(logits, y_batch) # 2. pèrdua
optimizer.zero_grad()
loss.backward() # 3. retropropagació
optimizer.step() # 4. actualització
torch.manual_seed(42) # resultats reproduïbles
model = ThreeLayerNN()
train(model, train_loader, nn.BCEWithLogitsLoss(), torch.optim.SGD(model.parameters(), lr=0.1), epochs=50)
shuffle=True barreja les dades a cada època, perquè els mini-lots no siguin sempre els mateixos. stratify=y manté la proporció de 0 i 1 al test. model.train() posa la xarxa en mode d’entrenament, i ho expliquem dues seccions més avall.
Hi ha tres variants segons la mida del lot:
| Criteri | Batch GD | Mini-batch GD | Stochastic GD |
|---|---|---|---|
| Mida del lot | Tot el dataset | 16-512 mostres | 1 mostra |
| Actualitzacions/època (1000 mostres) | 1 | ~3-60 | 1000 |
| Qualitat del gradient | Molt precís | Prou precís | Sorollós |
| Velocitat de convergència | Lenta | Ràpida | Variable |
| Ús de memòria | Molt alt | Moderat | Molt baix |
| Paral·lelització GPU | Bona | Excel·lent | Dolenta |
| Estabilitat | Molt estable | Estable | Inestable |
| Ús pràctic | Poc recomanat | Recomanat | Poc recomanat |
L’optimitzador torch.optim.SGD serveix per a les tres: el que canvia és el batch_size. El codi de cada variant és a Xarxes neuronals: ampliació.
Modes d’entrenament i avaluació
PyTorch distingeix dos modes, perquè algunes capes (com Dropout, que veurem més endavant) es comporten diferent en cada cas:
model.train(): mode d’entrenament.model.eval(): mode d’avaluació, per fer prediccions deterministes.
A més, torch.no_grad() desactiva el graf computacional durant l’avaluació, i estalvia memòria i temps. model.eval() canvia el comportament de les capes; torch.no_grad() només desactiva el càlcul de gradients. Durant l’avaluació, fes servir tots dos.
model.eval()
with torch.no_grad():
probs = torch.sigmoid(model(X_test)) # el model retorna logits: la sigmoide els fa probabilitats
y_pred = (probs > 0.5).float() # llindar 0.5
accuracy = (y_pred == y_test).float().mean()
print(f"Accuracy: {accuracy:.2%}") # Accuracy: 100.00%
Distingir un 0 d’un 1 és fàcil: la xarxa els encerta tots al test. model(X_test) calcula les 72 prediccions del test d’un sol cop, amb operacions de matrius que aprofiten biblioteques optimitzades per a CPU i GPU.
Inferència: fer prediccions
Un cop la xarxa està entrenada, fer una predicció (inferència) és aplicar la propagació cap endavant, en mode d’avaluació, igual que acabem de fer amb tot el test. El model sempre rep un lot (batch): per predir una sola imatge, li passem un lot d’una.
model.eval()
with torch.no_grad():
prob = torch.sigmoid(model(X_test[:1])) # un lot amb una sola imatge: forma [1, 64]
print(f"Probabilitat que sigui un 1: {prob.item():.2f}") # 0.03: la xarxa diu que és un 0, i ho és
Optimitzadors: SGD i Adam
Fins ara hem entrenat amb torch.optim.SGD, el descens del gradient: tots els pesos avancen amb la mateixa taxa d’aprenentatge α. L’optimitzador Adam (Adaptive Moment Estimation) ajusta automàticament la taxa per a cada paràmetre: la fa més gran si els passos són petits i van sempre en la mateixa direcció, i més petita si oscil·len.
És una bona elecció per defecte. Només cal canviar l’optimitzador:
# Abans (SGD):
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
# Després (Adam):
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
Important: Adam necessita una taxa d’aprenentatge més petita que SGD. Els valors habituals van de 1e-4 a 1e-2 (de 0.0001 a 0.01), i 1e-3 és un bon punt de partida. Amb lr=0.1, l’entrenament probablement divergirà.
Funcions d’activació
La funció d’activació decideix com transforma cada neurona la seva entrada. Triar-la bé és important per al rendiment de la xarxa.
Fins ara hem fet servir la sigmoide. És útil a la capa de sortida per a classificació binària, perquè retorna una probabilitat. A les capes ocultes, en canvi, s’aplana tant als extrems que la seva derivada és gairebé zero, i els pesos aprenen molt a poc a poc (el problema del gradient que s’esvaeix, vanishing gradient).
L’activació més utilitzada a les capes ocultes és la ReLU (Rectified Linear Unit):
\[ g(z) = \max(0, z) \]
En paraules senzilles: si z és negatiu retorna 0, i si és positiu el deixa igual. És molt ràpida de calcular i no s’aplana per la banda positiva.
Com triar la funció d’activació
A la capa de sortida, la tria depèn del que volem predir:
| Problema | Activació de sortida |
|---|---|
| Classificació binària | Sigmoide |
| Regressió | Lineal (identitat: \(g(z) = z\)): la sortida pot prendre qualsevol valor. És l’opció per defecte |
| Regressió d’un valor que mai no pot ser negatiu (p. ex. un preu), si cal garantir-ho | ReLU |
| Classificació multiclasse | Softmax |
A les capes ocultes, comença amb ReLU. Si moltes neurones queden sempre a zero (neurones “mortes”), variants com Leaky ReLU ho eviten. No facis servir mai l’activació lineal a les capes ocultes: la xarxa sencera es reduiria a una regressió lineal.
| Funció | Rang | Ús típic | Avantatge | Desavantatge |
|---|---|---|---|---|
| Sigmoide | (0, 1) | Sortida (classificació binària) | Es llegeix com a probabilitat | S’aplana: gradient que s’esvaeix |
| ReLU | [0, ∞) | Capes ocultes | Senzilla i eficient | Neurones mortes |
| Leaky ReLU | (-∞, ∞) | Capes ocultes | Evita neurones mortes | Un paràmetre més |
| Tanh | (-1, 1) | Capes ocultes | Centrada en zero | S’aplana: gradient que s’esvaeix |
| Lineal | (-∞, ∞) | Sortida (regressió) | Qualsevol valor | No és no lineal |
| Softmax | (0, 1), suma 1 | Sortida (multiclasse) | Probabilitats sobre classes | Més cost de càlcul |
Canviar a ReLU
Per passar la nostra xarxa a ReLU només cal canviar l’activació de les capes ocultes al mètode forward. Com que la resta és igual, podem heretar de ThreeLayerNN:
import torch.nn.functional as F
class ReLUNN(ThreeLayerNN):
def forward(self, x):
x = F.relu(self.hidden1(x)) # ReLU en lloc de sigmoide
x = F.relu(self.hidden2(x))
return self.output(x) # la sortida continua retornant logits
torch.manual_seed(42)
model = ReLUNN()
train(model, train_loader, nn.BCEWithLogitsLoss(), torch.optim.SGD(model.parameters(), lr=0.1), epochs=50)
Amb els dígits 0 i 1 també encerta el 100% del test: la diferència entre sigmoide i ReLU es nota en xarxes més profundes, on el gradient de la sigmoide s’esvaeix. A partir d’aquí, totes les xarxes del document fan servir ReLU a les capes ocultes.
Regressió
Per predir un valor continu canvien tres coses respecte de la classificació binària:
- Capa de sortida: sense activació (lineal), l’opció per defecte.
- Funció de pèrdua:
nn.MSELoss(), l’error quadràtic mitjà. - Mètrica: un error mitjà, com el MAE, en lloc de la taxa d’encerts.
L’exemple fa servir el dataset diabetes de scikit-learn: 10 mesures de 442 pacients, i com a objectiu un índex de progressió de la malaltia un any després (valors entre 25 i 346).
from sklearn.datasets import load_diabetes
X, y = load_diabetes(return_X_y=True)
Xr_train, Xr_test, yr_train, yr_test = train_test_split(X, y, test_size=0.2, random_state=42)
scaler = StandardScaler().fit(Xr_train)
Xr_train = torch.tensor(scaler.transform(Xr_train), dtype=torch.float32)
Xr_test = torch.tensor(scaler.transform(Xr_test), dtype=torch.float32)
yr_train = torch.tensor(yr_train, dtype=torch.float32).unsqueeze(1)
yr_test = torch.tensor(yr_test, dtype=torch.float32).unsqueeze(1)
class RegressionNN(nn.Module):
def __init__(self):
super().__init__()
# Arquitectura: 10 → 25 → 15 → 1
self.hidden1 = nn.Linear(10, 25)
self.hidden2 = nn.Linear(25, 15)
self.output = nn.Linear(15, 1)
def forward(self, x):
x = F.relu(self.hidden1(x))
x = F.relu(self.hidden2(x))
return self.output(x) # Sortida lineal (sense activació)
torch.manual_seed(42)
model = RegressionNN()
loader = DataLoader(TensorDataset(Xr_train, yr_train), batch_size=32, shuffle=True)
# Adam ajusta sol la mida del pas: amb SGD, aquest entrenament divergeix (ho expliquem sota)
train(model, loader, nn.MSELoss(), torch.optim.Adam(model.parameters(), lr=0.01), epochs=100)
model.eval()
with torch.no_grad():
mae = torch.abs(model(Xr_test) - yr_test).mean()
print(f"MAE: {mae.item():.1f}") # MAE: 41.9
De mitjana, la predicció s’equivoca en 42 punts. Una regressió lineal amb les mateixes dades fa pràcticament el mateix (un MAE de 42.8, a Avaluació de models): amb poques dades en forma de taula, una xarxa neuronal no sempre guanya un model senzill.
Amb torch.optim.SGD i lr=0.01, aquest mateix entrenament divergeix: la pèrdua creix fins a nan. La sortida té valors de centenars, els errors al quadrat són enormes i els passos del descens del gradient, massa grans. Hi ha dues solucions: un optimitzador que ajusta el pas, com Adam, o escalar també la sortida (i desfer l’escala en predir).
Classificació multiclasse
Per reconèixer dígits del 0 al 9 fem servir la mateixa idea que la regressió softmax: l’última capa té 10 neurones, una per classe, i la softmax converteix les seves puntuacions en probabilitats que sumen 1.
A PyTorch es fa servir nn.CrossEntropyLoss, amb dues precaucions:
- El model ha de retornar els logits, les puntuacions abans de la softmax.
- No apliquis
softmaxdins deforward:CrossEntropyLossja l’aplica internament, de manera més estable numèricament.
Les dades són ara totes les imatges de dígits, de les 10 classes. Les etiquetes es passen com a índexs de classe (tipus long), no com a float:
X, y = load_digits(return_X_y=True) # 10 classes
Xm_train, Xm_test, ym_train, ym_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
scaler = StandardScaler().fit(Xm_train)
Xm_train = torch.tensor(scaler.transform(Xm_train), dtype=torch.float32)
Xm_test = torch.tensor(scaler.transform(Xm_test), dtype=torch.float32)
ym_train = torch.tensor(ym_train, dtype=torch.long) # forma [mostres]: un índex per mostra
ym_test = torch.tensor(ym_test, dtype=torch.long)
class MulticlassNN(nn.Module):
def __init__(self):
super().__init__()
# Arquitectura: 64 → 25 → 15 → 10
self.hidden1 = nn.Linear(64, 25)
self.hidden2 = nn.Linear(25, 15)
self.output = nn.Linear(15, 10) # 10 classes
def forward(self, x):
x = F.relu(self.hidden1(x))
x = F.relu(self.hidden2(x))
return self.output(x) # sense softmax!
torch.manual_seed(42)
model = MulticlassNN()
loader = DataLoader(TensorDataset(Xm_train, ym_train), batch_size=32, shuffle=True)
train(model, loader, nn.CrossEntropyLoss(), torch.optim.SGD(model.parameters(), lr=0.1), epochs=50)
model.eval()
with torch.no_grad():
y_pred = torch.argmax(model(Xm_test), dim=1) # la classe amb més puntuació
print(f"Accuracy: {(y_pred == ym_test).float().mean():.2%}") # Accuracy: 96.67%
Quan una mateixa entrada pot tenir diverses etiquetes alhora (una pel·lícula d’acció i comèdia), es parla de classificació multietiqueta: consulta Xarxes neuronals: ampliació.
PyTorch per tipus de problema
| Aspecte | Classificació binària | Classificació multiclasse | Classificació multietiqueta | Regressió |
|---|---|---|---|---|
| Capa de sortida | 1 neurona | C neurones | C neurones | 1 neurona (o més) |
| Activació a la sortida | Cap (logits) | Cap (logits) | Cap (logits) | Cap (lineal) |
| Funció de pèrdua | nn.BCEWithLogitsLoss() | nn.CrossEntropyLoss() | nn.BCEWithLogitsLoss() | nn.MSELoss() o nn.L1Loss() |
| Format dels targets | [batch, 1] float (0.0 o 1.0) | [batch] long (índexs) | [batch, C] float (0s i 1s) | [batch, 1] float (continus) |
| Com fer prediccions | torch.sigmoid(output) > 0.5 | torch.argmax(output, dim=1) | torch.sigmoid(output) > 0.5 | output directament |
| Com obtenir probabilitats | torch.sigmoid(output) | F.softmax(output, dim=1) | torch.sigmoid(output) | N/A |
| Exemple de target | [1.0] (classe positiva) | 2 (classe índex 2) | [0, 1, 1, 0] (etiquetes 1 i 2) | [23.5] (valor continu) |
Evitar el sobreajustament
Les xarxes neuronals tenen molts paràmetres i poden sobreajustar-se fàcilment. Com que s’entrenen època a època, ho podem veure mentre passa.
Pèrdua d’entrenament i de validació
Si després de cada època calculem la pèrdua amb el conjunt d’entrenament i amb el de validació, sol passar això:
- Al principi, les dues baixen.
- En algun moment, la de validació s’atura i comença a pujar, mentre la d’entrenament continua baixant.
A partir d’aquí la xarxa ja no aprèn patrons generals: memoritza les dades d’entrenament. Les tècniques següents ataquen aquest problema.
Early stopping
La parada anticipada (early stopping) atura l’entrenament quan la pèrdua de validació deixa de millorar i recupera els pesos del millor moment:
- Després de cada època, calcula la pèrdua de validació.
- Si és la millor fins ara, guarda els pesos.
- Si no millora durant N èpoques seguides (la paciència, patience, típicament 5-20), atura i restaura els pesos guardats.
Ho provem amb la xarxa multiclasse dels dígits. Com que cal un conjunt de validació, en separem una part de l’entrenament:
import copy
# Validació: un 20% de l'entrenament. El test no es toca
Xt, Xv, yt, yv = train_test_split(Xm_train, ym_train, test_size=0.2, stratify=ym_train, random_state=42)
loader = DataLoader(TensorDataset(Xt, yt), batch_size=32, shuffle=True)
torch.manual_seed(42)
model = MulticlassNN()
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
best_val_loss = float('inf')
patience, patience_counter = 10, 0
for epoch in range(200):
train(model, loader, loss_fn, optimizer, epochs=1) # una època
model.eval()
with torch.no_grad():
val_loss = loss_fn(model(Xv), yv).item()
if val_loss < best_val_loss:
best_val_loss = val_loss
best_state = copy.deepcopy(model.state_dict()) # guardem els millors pesos
patience_counter = 0
else:
patience_counter += 1
if patience_counter >= patience:
print(f"Aturem a l'època {epoch + 1}")
break
model.load_state_dict(best_state) # tornem als pesos de la millor època
Amb els dígits, la pèrdua de validació deixa de millorar cap a l’època 20, i l’entrenament s’atura a la 30 (20 més 10 de paciència), en lloc d’arribar a les 200.
No és exclusiva de les xarxes: funciona amb qualsevol model que s’entreni per iteracions, com el gradient boosting dels arbres de decisió.
Dropout
El dropout apaga a l’atzar un percentatge de neurones a cada pas d’entrenament. Amb un 50%, cada neurona té la meitat de probabilitat de quedar desactivada en aquell pas.
Això impedeix que la xarxa depengui massa d’unes poques neurones concretes, i la força a aprendre representacions més robustes i redundants. És com entrenar moltes xarxes diferents i fer-ne la mitjana.
- Durant l’entrenament (
model.train()): s’apaguen neurones, i PyTorch amplifica les que queden actives perquè la suma total no canviï. - Durant la inferència (
model.eval()): totes les neurones estan actives i no cal cap correcció.
Valors típics: entre 0.2 i 0.5 a les capes ocultes denses. El dropout té sentit en xarxes amb molts paràmetres, que són les que es poden sobreajustar. Per això l’exemple fa servir una xarxa més gran que les anteriors, amb 128 i 64 neurones:
class DropoutNN(nn.Module):
def __init__(self):
super().__init__()
self.hidden1 = nn.Linear(64, 128)
self.dropout1 = nn.Dropout(p=0.5) # apaga el 50% de les neurones a cada pas
self.hidden2 = nn.Linear(128, 64)
self.dropout2 = nn.Dropout(p=0.3)
self.output = nn.Linear(64, 10)
def forward(self, x):
x = self.dropout1(F.relu(self.hidden1(x)))
x = self.dropout2(F.relu(self.hidden2(x)))
return self.output(x)
nn.Dropout es posa després de l’activació de cada capa oculta, i la xarxa s’entrena exactament igual que les anteriors. La funció train crida model.train(), que activa el dropout, i en avaluar model.eval() el desactiva. Aquest és el motiu pel qual cal fer servir sempre tots dos modes.
Weight decay
La regularització que penalitza els pesos grans també s’aplica a les xarxes. A PyTorch es fa amb el paràmetre weight_decay de l’optimitzador, que fa el paper de λ. La variant AdamW és la recomanada quan es fa servir weight decay amb Adam:
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
Com λ, weight_decay es tria amb el conjunt de validació. Valors habituals: entre 0.0001 i 0.1.
Una recepta per a xarxes
Les xarxes grans tendeixen a tenir biaix baix: si són prou grans, s’ajusten a les dades d’entrenament. Això dona una recepta pràctica:
- Entrena i mira l’error d’entrenament. Si és alt respecte del que és raonable esperar (biaix alt), fes la xarxa més gran (més capes o més neurones) i torna a començar.
- Si no, mira l’error de validació. Si és molt més alt que el d’entrenament (variància alta), aconsegueix més dades o regularitza més (dropout, weight decay, early stopping) i torna a començar.
- Si tots dos són bons, has acabat.
Una xarxa més gran i ben regularitzada sol ser almenys tan bona com una de més petita. El límit és el cost de càlcul i les dades que pots aconseguir.
Models preentrenats i transfer learning
Entrenar una xarxa gran des de zero requereix moltes dades i molt de càlcul. A la pràctica, per a imatges, text o àudio, gairebé sempre es parteix d’un model preentrenat: una xarxa que algú ja ha entrenat amb un volum enorme de dades, i que ja sap detectar vores i formes, o el significat de les paraules.
La transferència d’aprenentatge (transfer learning) aprofita aquest coneixement: es reutilitzen les capes del model preentrenat i només s’entrenen (o s’ajusten lleugerament) les últimes per a la nostra tasca. Amb pocs centenars d’exemples es poden obtenir bons resultats. Les estratègies concretes són a la Guia pràctica, i els embeddings que veurem a continuació en són un ús directe.
L’arquitectura Transformer i els LLMs
Les xarxes d’aquest document (capes denses) són la base del deep learning. Per a text i llenguatge, a partir de 2017 es va imposar l’arquitectura Transformer, i els LLMs actuals (GPT, Llama, Gemini…) s’hi basen. Els conceptes d’aquest document (propagació, retropropagació, activacions, optimitzadors) en continuen sent els fonaments. El Transformer s’explica a Transformers i models de llenguatge.
Per a imatges i senyals es fan servir sovint capes convolucionals, i tot l’entrenament s’accelera amb GPU. Totes dues coses s’expliquen a Xarxes neuronals: ampliació.