A la lliçó anterior vas obrir el capó de TensorFlow i vas descobrir que model.fit() és, en el fons, un bucle de GradientTape empaquetat. Avui coneixeràs el framework que va decidir no empaquetar aquest bucle: PyTorch. Creat per Meta (Facebook) i publicat el 2017, PyTorch és avui l'estàndard de facto en recerca —la gran majoria dels papers que llegiràs publiquen el seu codi en PyTorch— i una opció de primera línia a la indústria. La seva filosofia és donar-te el control explícit de cada pas. Per demostrar-te que ja saps gairebé tot el que cal, en aquesta lliçó reproduirem el mateix exemple de gradient de 06-01 i reescriurem en PyTorch la xarxa MNIST 784-128-64-10 de 02-05, entrenant-la fins a comparar resultats amb la versió Keras. L'equip de TecnoMarket vol parlar els dos idiomes; tu també.

Contingut

  1. Què és PyTorch i la seva filosofia
  2. Tensors de PyTorch: creació, operacions i dispositius
  3. Autograd: el GradientTape de PyTorch
  4. Construir models amb nn.Module
  5. El bucle d'entrenament explícit, línia a línia
  6. Avaluació amb torch.no_grad()
  7. Entrenar la xarxa MNIST i comparar amb Keras

Què és PyTorch i la seva filosofia

PyTorch va néixer als laboratoris de recerca de Meta com a successor "pythònic" de Torch (un framework en Lua). Tres idees en defineixen el caràcter:

  • Define-by-run (definició dinàmica): el graf d'operacions es construeix mentre s'executa el codi Python, no abans. Cada forward pass pot ser diferent: pots fer servir if, bucles for, imprimir tensors intermedis amb un simple print. Se sent com numpy amb gradients.
  • Control explícit: res de fit(). El bucle d'entrenament l'escrius tu. Més línies de codi, sí, però zero màgia: quan alguna cosa falla, veus exactament on.
  • Estàndard de facto en recerca: si al mòdul 5 et va picar la curiositat pels transformers i els LLMs, gairebé tot el seu codi obert (inclosa la llibreria transformers de Hugging Face) està construït sobre PyTorch.
Filosofia Keras/TensorFlow PyTorch
Lema implícit "El que és comú ha de ser fàcil" "Tot ha de ser explícit"
Bucle d'entrenament Empaquetat a fit() L'escrius tu
Sensació en fer-lo servir Conduir un automàtic Conduir un manual

Instal·lació (ja la vas fer a 01-05 amb pip install torch; afegeix-hi torchvision per als datasets de visió):

import torch
print(torch.__version__)              # p. ex. 2.x
print(torch.cuda.is_available())      # True si hi ha GPU NVIDIA configurada

Tensors de PyTorch: creació, operacions i dispositius

Els tensors de PyTorch són conceptualment idèntics als de TensorFlow. Canvia la sintaxi, no la idea:

import torch

escalar = torch.tensor(4.99)                       # preu d'un cable USB
vector  = torch.tensor([120.0, 15.5, 899.0])       # preus de 3 productes
matriu  = torch.rand(2, 3)                         # aleatori uniforme, shape (2,3)
zeros   = torch.zeros(3, 3)
print(vector.shape)    # torch.Size([3])
print(vector.dtype)    # torch.float32  (mateix default que TF)

Operacions, amb la seva equivalència:

Operació TensorFlow (06-01) PyTorch
Producte matricial tf.matmul(a, b) torch.matmul(a, b) o a @ b
Mitjana tf.reduce_mean(a) a.mean()
Canviar la forma tf.reshape(a, (4,1)) a.reshape(4, 1) o a.view(4, 1)
Convertir el tipus tf.cast(a, tf.float32) a.float() o a.to(torch.float32)
A numpy a.numpy() a.numpy() (idèntic)

El broadcasting funciona amb les mateixes regles que vas veure a 06-01: dimensions iguals o de mida 1 són compatibles.

El pont amb numpy

import numpy as np

arr = np.array([1.0, 2.0, 3.0])
t = torch.from_numpy(arr)     # numpy -> tensor (comparteixen memòria!)
arr2 = t.numpy()              # tensor -> numpy (també comparteixen memòria)

t[0] = 99.0
print(arr[0])                 # 99.0 -> canviar-ne un canvia l'altre

Que comparteixin memòria és eficient (no hi ha còpia) però pot sorprendre: si no vols el vincle, fes servir torch.tensor(arr) (còpia) en lloc de from_numpy.

Dispositius: .to(device)

A PyTorch moure dades entre CPU i GPU és explícit (a TensorFlow era automàtic). El patró universal que veuràs a tot el codi PyTorch:

device = "cuda" if torch.cuda.is_available() else "cpu"

x = torch.rand(32, 784)
x = x.to(device)              # moure el tensor a la GPU (o quedar-se a la CPU)
# model.to(device)            # els models es mouen igual

Regla d'or: model i dades han de ser al mateix dispositiu, o PyTorch llançarà un error del tipus Expected all tensors to be on the same device. És dels errors més comuns en començar.

Autograd: el GradientTape de PyTorch

PyTorch també grava les operacions per derivar automàticament, però sense cinta explícita: n'hi ha prou de marcar un tensor amb requires_grad=True i totes les operacions que s'hi fan queden registrades. Reproduïm el mateix exemple de 06-01 —minimitzar L(w) = (w - 4)²— per comparar els dos estils cara a cara:

import torch

w = torch.tensor(0.0, requires_grad=True)   # ~ tf.Variable(0.0)

loss = (w - 4.0) ** 2       # el graf es construeix sol, sense "with tape"
loss.backward()             # ~ tape.gradient(loss, w): deriva cap enrere
print(w.grad)               # tensor(-8.) -> el mateix -8.0 que a 06-01

Diferències clau respecte a GradientTape:

  • No hi ha context with: qualsevol operació sobre un tensor amb requires_grad=True es grava sempre.
  • backward() es crida sobre la pèrdua, i els gradients apareixen a l'atribut .grad de cada tensor fulla.
  • Els gradients s'acumulen: si crides backward() dues vegades sense netejar, .grad conté la suma. Per això tot bucle PyTorch neteja gradients a cada pas.

El descens del gradient complet, bessó del de 06-01:

w = torch.tensor(0.0, requires_grad=True)
lr = 0.1

for pas in range(30):
    loss = (w - 4.0) ** 2
    loss.backward()                  # calcula dL/dw i el deixa a w.grad
    with torch.no_grad():            # actualitzar SENSE gravar aquesta operació
        w -= lr * w.grad             # w = w - lr * gradient
    w.grad.zero_()                   # netejar! si no, s'acumularia

print(w.item())   # ~3.995: convergeix a 4, igual que numpy (02-03) i TF (06-01)

Tres versions del mateix algorisme, tres nivells d'automatització:

Versió Derivada Actualització Bucle
numpy (02-03) A mà A mà A mà
TensorFlow (06-01) Automàtica (tape) A mà (assign_sub) A mà
PyTorch (avui) Automàtica (autograd) A mà (després la farà optimizer.step()) A mà
Keras fit() Automàtica Automàtica Automàtic

Construir models amb nn.Module

A PyTorch els models es construeixen sempre amb subclassing —la tercera API que vas veure a 06-01 aquí és la forma estàndard—. S'hereta de nn.Module, es declaren les capes a __init__ i es connecten a forward(). Reescriguem la xarxa MNIST 784-128-64-10 de 02-05:

import torch
from torch import nn

class XarxaMNIST(nn.Module):
    def __init__(self):
        super().__init__()
        self.capa1 = nn.Linear(784, 128)   # ~ Dense(128) sobre entrada 784
        self.capa2 = nn.Linear(128, 64)    # ~ Dense(64)
        self.sortida = nn.Linear(64, 10)   # ~ Dense(10)
        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.relu(self.capa1(x))
        x = self.relu(self.capa2(x))
        return self.sortida(x)             # logits SENSE softmax (vegeu la nota)

model = XarxaMNIST()
print(model)   # imprimeix l'estructura, com summary() a Keras

Observacions importants:

  • nn.Linear(784, 128) demana entrada i sortida explícites; Keras inferia l'entrada de la dada. Més escriptura, menys sorpreses.
  • No hi ha softmax a la sortida. A PyTorch, la funció de pèrdua nn.CrossEntropyLoss espera logits crus (aplica log-softmax internament per estabilitat numèrica). És l'equivalent exacte de compilar a Keras amb SparseCategoricalCrossentropy(from_logits=True). Posar softmax + CrossEntropyLoss és un error clàssic que degrada l'entrenament.
  • No crides mai forward() directament: crides el model, model(x), i PyTorch invoca forward amb la maquinària de hooks necessària.
  • Comptar paràmetres: sum(p.numel() for p in model.parameters()) → 109.386, exactament els mateixos que la versió Keras de 02-05 (784·128+128 + 128·64+64 + 64·10+10).

El bucle d'entrenament explícit, línia a línia

Aquí hi ha la gran diferència amb Keras. Primer, les dades amb DataLoader (el bessó de tf.data):

from torch.utils.data import DataLoader, TensorDataset
from torchvision import datasets, transforms

transform = transforms.ToTensor()   # PIL -> tensor float32 en [0,1]
train_ds = datasets.MNIST(root="data", train=True, download=True, transform=transform)
test_ds  = datasets.MNIST(root="data", train=False, download=True, transform=transform)

train_loader = DataLoader(train_ds, batch_size=32, shuffle=True)   # ~ shuffle+batch de tf.data
test_loader  = DataLoader(test_ds, batch_size=32)

I ara el bucle complet, comentat línia a línia:

device = "cuda" if torch.cuda.is_available() else "cpu"
model = XarxaMNIST().to(device)                        # model al dispositiu

loss_fn = nn.CrossEntropyLoss()                        # ~ sparse_categorical_crossentropy
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)  # ~ optimizer="adam"

for epoch in range(5):                                 # ~ epochs=5 a fit()
    model.train()                                      # mode entrenament (afecta dropout/BN)
    for x_batch, y_batch in train_loader:              # ~ fit() itera els lots per tu
        x_batch = x_batch.view(x_batch.size(0), -1)    # (32,1,28,28) -> (32,784), el flatten
        x_batch, y_batch = x_batch.to(device), y_batch.to(device)

        logits = model(x_batch)                        # 1. FORWARD: prediccions
        loss = loss_fn(logits, y_batch)                # 2. PÈRDUA: quant ens equivoquem

        optimizer.zero_grad()                          # 3. NETEJAR gradients acumulats
        loss.backward()                                # 4. BACKWARD: repartir la culpa (02-03)
        optimizer.step()                               # 5. ACTUALITZAR pesos amb Adam

    print(f"Època {epoch+1}: pèrdua darrer lot = {loss.item():.4f}")

Desglossament dels cinc passos del cor del bucle:

  1. Forward (model(x_batch)): les dades travessen la xarxa i en surten els logits. És la propagació cap endavant de 02-03.
  2. Pèrdua (loss_fn(...)): un únic número que mesura l'error del lot, com a 02-04.
  3. optimizer.zero_grad(): posa a zero els .grad de tots els paràmetres. Recorda: autograd acumula; sense aquesta línia, cada pas sumaria els gradients de tots els passos anteriors i l'entrenament divergiria.
  4. loss.backward(): backpropagation. Calcula el gradient de la pèrdua respecte a cadascun dels 109.386 paràmetres.
  5. optimizer.step(): Adam actualitza cada paràmetre fent servir el seu .grad. És el w.assign_sub(lr * grad) de 06-01, amb la sofisticació d'Adam (02-04).

Què automatitza Keras i què exposa PyTorch

Tasca Keras fit() PyTorch
Iterar èpoques i lots Automàtic for epoch... / for x, y in loader
Moure dades a la GPU Automàtic .to(device) manual
Forward Automàtic model(x)
Càlcul de la pèrdua Automàtic (la dones a compile) loss_fn(logits, y)
Netejar gradients Automàtic optimizer.zero_grad()
Backward Automàtic loss.backward()
Actualitzar pesos Automàtic optimizer.step()
Mètriques i barra de progrés Automàtic Les calcules/imprimeixes tu
Mode train/eval (dropout, BN) Automàtic model.train() / model.eval()

Cap columna no és "millor": Keras et dona velocitat per al que és estàndard; PyTorch et dona transparència per al que no ho és. Quan a 05-01 vas veure l'esquelet de l'entrenament d'una GAN (dues xarxes, dos optimitzadors alternant-se), aquesta mena de bucle a mida és on l'estil explícit de PyTorch brilla.

Avaluació amb torch.no_grad()

En avaluar no volem entrenar: no calen gradients. torch.no_grad() desactiva la gravació d'autograd, estalviant memòria i temps:

model.eval()                              # mode avaluació (dropout off, BN congelada)
correctes, total = 0, 0

with torch.no_grad():                     # res del que hi ha a dins no es grava
    for x_batch, y_batch in test_loader:
        x_batch = x_batch.view(x_batch.size(0), -1).to(device)
        y_batch = y_batch.to(device)
        logits = model(x_batch)
        preds = logits.argmax(dim=1)      # classe amb el logit més alt
        correctes += (preds == y_batch).sum().item()
        total += y_batch.size(0)

print(f"Precisió en test: {correctes / total:.4f}")

Dues coses diferents que se solen confondre:

  • model.eval() canvia el comportament de certes capes (dropout deixa d'apagar neurones, batch norm fa servir les seves estadístiques acumulades — ho vas veure a 05-04).
  • torch.no_grad() desactiva el càlcul de gradients.

En avaluació es fan servir totes dues. És l'equivalent del que model.evaluate() de Keras fa tot sol.

Entrenar la xarxa MNIST i comparar amb Keras

Executant el bucle anterior 5 èpoques (el mateix pressupost que a 02-05), un resultat típic:

Keras (02-05) PyTorch (avui)
Arquitectura 784-128-64-10, ReLU Idèntica
Paràmetres 109.386 109.386
Optimitzador / pèrdua Adam / crossentropy Adam / CrossEntropyLoss
Precisió en test (~5 èpoques) ~97,7 % ~97,5 – 97,9 %
Línies de codi d'entrenament ~5 (compile + fit) ~25 (bucle explícit)

La conclusió que l'equip de TecnoMarket apunta a la seva pissarra: mateixa xarxa, mateixes dades, mateix resultat. Les petites diferències decimals venen de la inicialització aleatòria i de l'ordre dels lots, no del framework. El que canvia és l'experiència de desenvolupament, i això és exactament el que compararem sistemàticament a la propera lliçó.

Errors Comuns i Consells

  • Oblidar optimizer.zero_grad(): l'error número u. Els gradients s'acumulen, la pèrdua balla o explota i no hi ha cap missatge d'error que t'avisi. Si el teu entrenament PyTorch "no convergeix", revisa això primer.
  • Softmax + CrossEntropyLoss: nn.CrossEntropyLoss ja inclou el softmax (logarítmic). Afegir nn.Softmax a la sortida entrena malament en silenci. Sortida = logits crus.
  • Model i dades en dispositius diferents: Expected all tensors to be on the same device. Mou-los tots dos amb .to(device); el model un cop, les dades a cada lot.
  • Oblidar model.eval() en avaluar: amb el dropout actiu en test, la precisió mesurada serà pitjor que la real. I a l'inrevés, oblidar model.train() en tornar a entrenar.
  • .item() vs. el tensor: loss és un tensor connectat al graf; per registrar-ne el valor fes servir loss.item(). Acumular tensors en una llista durant moltes èpoques reté el graf i esgota la memòria.
  • Consell: quan una shape no quadri, aprofita el define-by-run: posa print(x.shape) dins de forward(). Aquesta depuració immediata és un dels grans plaers de PyTorch.

Exercicis

Exercici 1: gradient a mà i amb autograd

Calcula amb autograd el gradient de L(w, b) = (3*w + b - 10)**2 a w=1.0, b=0.0 (el mateix de l'exercici 2 de 06-01). Comprova que obtens -42 i -14, idèntics a la versió GradientTape. Després executa 200 passos de descens amb lr 0.01 fent servir torch.no_grad() per actualitzar.

Exercici 2: la xarxa de ressenyes en nn.Module

TecnoMarket vol el classificador binari de ressenyes (positiva/negativa) com a mòdul PyTorch: entrada de 200 característiques (la ressenya ja vectoritzada, com a 04-03), capes ocultes de 64 i 32 amb ReLU, i sortida d'1 logit. Escriu la classe ClassificadorRessenyes(nn.Module) i indica quina funció de pèrdua faries servir i per què la sortida no porta sigmoide.

Exercici 3: llegir el bucle

Sense executar-lo, explica què fallaria (i amb quin símptoma) en aquest bucle:

for x, y in train_loader:
    logits = model(x)
    loss = loss_fn(logits, y)
    loss.backward()
    optimizer.step()

Solucions

Solució 1:

import torch

w = torch.tensor(1.0, requires_grad=True)
b = torch.tensor(0.0, requires_grad=True)

loss = (3.0 * w + b - 10.0) ** 2
loss.backward()
print(w.grad.item(), b.grad.item())   # -42.0, -14.0 (idèntic a 06-01)

lr = 0.01
for _ in range(200):
    loss = (3.0 * w + b - 10.0) ** 2
    if w.grad is not None:
        w.grad.zero_(); b.grad.zero_()   # netejar abans de recalcular
    loss.backward()
    with torch.no_grad():
        w -= lr * w.grad
        b -= lr * b.grad

print(loss.item())        # ~0.0

Mateix resultat que amb GradientTape: els dos autograds calculen la mateixa matemàtica de 02-03.

Solució 2:

from torch import nn

class ClassificadorRessenyes(nn.Module):
    def __init__(self):
        super().__init__()
        self.capa1 = nn.Linear(200, 64)
        self.capa2 = nn.Linear(64, 32)
        self.sortida = nn.Linear(32, 1)    # 1 logit per a classificació binària
        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.relu(self.capa1(x))
        x = self.relu(self.capa2(x))
        return self.sortida(x)

Pèrdua: nn.BCEWithLogitsLoss. Igual que CrossEntropyLoss amb el softmax, aquesta pèrdua inclou la sigmoide internament (amb més estabilitat numèrica), així que la xarxa ha de retornar el logit cru. És l'equivalent de BinaryCrossentropy(from_logits=True) a Keras.

Solució 3: Hi falten tres coses:

  1. optimizer.zero_grad(): els gradients s'acumulen pas a pas; símptoma: la pèrdua deixa de baixar o divergeix sense errors visibles.
  2. Aplanament i dispositiu: si x ve de MNIST amb shape (32, 1, 28, 28) i el model espera (32, 784), hi haurà un error de shapes a nn.Linear; i si el model és a la GPU i les dades no, error de dispositius.
  3. model.train() abans del bucle (rellevant si el model té dropout/BN).

L'ordre correcte del nucli és: forward → pèrdua → zero_grad()backward()step().

Conclusió

Ja parles els dos idiomes. PyTorch t'ha mostrat l'altre estil: tensors gairebé idèntics als de TensorFlow (amb moviment explícit .to(device) i pont directe amb numpy), autograd que resol el mateix gradient que GradientTape amb requires_grad + backward(), models com a classes nn.Module (el subclassing de 06-01 com a norma, no com a opció) i, sobretot, el bucle d'entrenament explícit: forward, pèrdua, zero_grad, backward, step — els cinc passos que Keras empaqueta a fit(). La xarxa MNIST de 02-05 reescrita en PyTorch assoleix la mateixa precisió: el framework no canvia la matemàtica, canvia qui escriu cada peça.

La pregunta inevitable és: quin fer servir? A la propera lliçó farem la comparació honesta —filosofia, depuració, ecosistemes, indústria vs. recerca— i veurem què triaria l'equip de TecnoMarket i per què, amb una conclusió tranquil·litzadora: els conceptes que has après viatgen amb tu, sigui quina sigui l'eina.

Curs de Deep Learning

Mòdul 1: Introducció al Deep Learning

Mòdul 2: Fonaments de Xarxes Neuronals

Mòdul 3: Xarxes Neuronals Convolucionals (CNN)

Mòdul 4: Xarxes Neuronals Recurrents (RNN)

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

Mòdul 6: Eines i Frameworks

Mòdul 7: Projectes Pràctics

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

© Copyright 2026. Tots els drets reservats