Python, dalle fondamenta Lezione 56 / 60

PyTorch: il default moderno

Tensor, autograd, il modulo nn, e quel feeling pythonico che ha fatto vincere PyTorch.

Nel 2016 Meta rilasciò PyTorch e quasi nessuno lo stava usando. Il framework di deep learning di default era TensorFlow, che aveva il budget di marketing di Google dietro e una feature chiamata “static graph” che il marketing diceva fosse un beneficio. Per il 2019 il mondo ML accademico era passato in massa a PyTorch. Per il 2022 anche l’industria aveva seguito. Per il 2026 la situazione è chiusa: PyTorch è il default. I nuovi paper di ricerca pubblicano implementazioni di riferimento in PyTorch. L’intero ecosistema di Hugging Face è PyTorch-first. I principali provider cloud hanno tutti supporto PyTorch di prima classe. JAX ha una nicchia tra i ricercatori che si interessano ossessivamente alle performance a livello di compiler e i team interni di Google. TensorFlow sopravvive nei prodotti Google e in una lunga coda di progetti legacy, ma se stai iniziando un nuovo progetto di deep learning oggi e non hai una ragione specifica per scegliere altrimenti, scegli PyTorch.

Questa lezione è sul PyTorch che userai davvero giorno per giorno. Tensor. Autograd. L’API nn.Module. Optimizer. Il DataLoader. Poi definiremo una piccola rete di classificazione e seziona ogni pezzo.

Perché PyTorch ha vinto

L’esperienza di TensorFlow 1.x, nel 2017, era: definisci un grafo computazionale statico, compilalo, poi gli dai i dati in pasto. Il debug significava stampare le forme dei tensor inserendoli nel grafo come side effect. Il control flow significava op TensorFlow speciali come tf.cond. Tutto sembrava di scrivere in un linguaggio diverso che per caso condivideva la sintassi di Python.

La scommessa di PyTorch era: sii solo una libreria Python. I tensor sono oggetti che puoi stampare, slice e passare in giro. Il grafo si costruisce da solo mentre chiami le operazioni, e viene buttato via quando hai finito. Se vuoi un ciclo for nella tua rete, scrivi un ciclo for. Se vuoi stampare valori intermedi, chiami print. Il framework sembrava NumPy con feature in più. I ricercatori l’hanno preferito immediatamente. TensorFlow alla fine ha recuperato con l’eager execution nella 2.x, ma per allora la guerra era finita.

La lezione qui non è strettamente sui framework di deep learning. È sul design di librerie: rendi facile la cosa facile, anche con un costo in performance, e il mondo ti sceglierà. JAX, il framework che è arrivato dopo PyTorch da Google, ha fatto una scommessa diversa, la sua purezza funzionale e la compilazione JIT gli danno vantaggi di velocità su certi workload, e JAX si è guadagnato una nicchia reale ma limitata.

Tensor: NumPy più tre cose

Un tensor di PyTorch è esattamente quello che NumPy chiama ndarray dalla lezione 43, con tre aggiunte: supporto GPU, automatic differentiation, e un’API leggermente diversa. Puoi spostare un tensor su una GPU e le operazioni su di esso gireranno sulla GPU. Puoi chiedere a qualsiasi tensor di tracciare i gradient, e PyTorch registrerà ogni operazione che ci fai sopra.

import torch

# Creazione, in larga parte rispecchia NumPy
a = torch.tensor([1.0, 2.0, 3.0])
b = torch.zeros((3, 4))
c = torch.ones((2, 2))
d = torch.randn((100, 10))    # standard normal
e = torch.arange(10)
f = torch.eye(5)              # identity matrix

# Math element-wise, broadcasting, indexing: tutto a forma NumPy
print(a + 1)
print(d.shape, d.dtype)       # torch.Size([100, 10]) torch.float32
print(d[0, :3])
print(d.mean(dim=0))          # mean lungo asse 0 -> shape (10,)

I dtype di default sono diversi da NumPy. PyTorch fa default a float32 per i float (NumPy fa default a float64). Per il deep learning, float32 è il default giusto, float64 è due volte più lento e occupa il doppio di memoria e quasi mai ti dà un modello significativamente migliore. Resta sui default.

Device: CPU, CUDA, MPS

I tensor vivono su un device. Di default, CPU. Per usare una GPU, sposta il tensor:

device = torch.device(
    "cuda" if torch.cuda.is_available()
    else "mps" if torch.backends.mps.is_available()
    else "cpu"
)
print(f"Using device: {device}")

x = torch.randn(1000, 1000).to(device)
y = torch.randn(1000, 1000).to(device)
z = x @ y                     # matmul sul device

Il boilerplate qui sopra è il pattern giusto nel 2026. cuda sono GPU NVIDIA (il caso di produzione). mps è il Metal Performance Shaders di Apple Silicon, funziona sui Mac serie M, abbastanza veloce per sviluppo e modelli piccoli, non ancora competitivo con NVIDIA per il training serio. Il fallback cpu esiste per ambienti senza GPU.

Le operazioni richiedono che tutti i tensor siano sullo stesso device. Un tensor su CPU più un tensor su GPU è un errore. La sessione di debug più comune di ogni nuovo utente PyTorch è dimenticarsi di chiamare .to(device) o sul modello o sui dati.

Autograd: la macchina dei gradient

Qualsiasi tensor con requires_grad=True traccia ogni operazione a cui partecipa. Quando chiami .backward() su uno scalare che dipende da esso, PyTorch percorre le operazioni registrate al contrario e calcola i gradient dello scalare rispetto a ogni tensor che aveva requires_grad=True.

x = torch.tensor(3.0, requires_grad=True)
y = x ** 2 + 2 * x + 1        # y = (x+1)^2
y.backward()                  # calcola dy/dx
print(x.grad)                 # 2*(x+1) = 8.0

È tutto qui il macchinario del training. Non scrivi derivate. Scrivi il forward pass, calcoli una loss (uno scalare), chiami loss.backward(), e PyTorch riempie l’attributo .grad di ogni parametro. L’optimizer poi usa quei gradient per aggiornare i parametri.

Un dettaglio sottile ma importante: i gradient si accumulano. Se chiami .backward() due volte senza azzerare i gradient in mezzo, ottieni la somma dei due gradient in .grad. Per questo ogni training loop chiama optimizer.zero_grad() all’inizio. È un footgun per i principianti e una feature per usi avanzati (gradient accumulation tra micro-batch).

L’nn.Module: dove definisci le reti

Potresti definire una rete come una funzione libera e un sacco di tensor. Non dovresti. La classe base nn.Module ti dà gestione dei parametri, spostamento sui device, e serializzazione gratis. Due metodi: __init__ dichiara i layer, forward definisce la computazione.

import torch.nn as nn
import torch.nn.functional as F

class MLP(nn.Module):
    def __init__(self, in_dim: int, hidden_dim: int, n_classes: int):
        super().__init__()
        self.fc1 = nn.Linear(in_dim, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, hidden_dim)
        self.fc3 = nn.Linear(hidden_dim, n_classes)
        self.dropout = nn.Dropout(p=0.2)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = F.relu(self.fc1(x))
        x = self.dropout(x)
        x = F.relu(self.fc2(x))
        x = self.dropout(x)
        return self.fc3(x)         # logits, non probabilita'

model = MLP(in_dim=20, hidden_dim=128, n_classes=3).to(device)
print(model)
print(f"Trainable parameters: {sum(p.numel() for p in model.parameters() if p.requires_grad):,}")

Alcune cose vale la pena segnalarle. nn.Linear(in, out) è esattamente y = x @ W.T + b con W di shape (out, in), il tuo standard fully-connected layer. Le activation function vivono in torch.nn.functional (importato come F per convenzione), o come moduli in nn.ReLU() se preferisci. nn.Dropout è un regolarizzatore che mette a zero a caso le activation durante il training e non fa niente durante l’evaluation. Il metodo forward ritorna logits, score grezzi, non probabilità. Le loss function di PyTorch si aspettano logits e applicano la softmax internamente; fare la softmax tu stesso in forward e poi di nuovo nella loss è un bug classico.

Per pile semplici dove non ti serve nessuna logica custom, nn.Sequential è più corto:

model = nn.Sequential(
    nn.Linear(20, 128),
    nn.ReLU(),
    nn.Dropout(0.2),
    nn.Linear(128, 128),
    nn.ReLU(),
    nn.Dropout(0.2),
    nn.Linear(128, 3),
).to(device)

Uso nn.Module per qualsiasi cosa che mi aspetto cresca. nn.Sequential per prototipi usa-e-getta.

Optimizer e loss

L’optimizer tiene i parametri del modello e sa come aggiornarli dati i gradient. Le scelte standard nel 2026:

  • torch.optim.SGD con momentum: classico, ancora usato per la computer vision.
  • torch.optim.Adam: il default per la maggior parte delle cose. Robusto, convergenza veloce, indulgente con learning rate sbagliati.
  • torch.optim.AdamW: Adam con weight decay fatto come si deve. Lo standard per i transformer e la maggior parte del training moderno.
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2)

La loss function è un callable che prende (predizioni, target) e ritorna uno scalare. Le due che userai l’80% del tempo:

  • nn.CrossEntropyLoss per la classificazione multi-classe. Prende logits di shape (batch, n_classes) e label di classe intere di shape (batch,).
  • nn.MSELoss per la regressione.
  • nn.BCEWithLogitsLoss per la classificazione binaria (binary cross-entropy che prende logits, numericamente stabile).
criterion = nn.CrossEntropyLoss()

DataLoader: portare dentro batch di dati

Il deep learning si allena su batch. Ti serve un oggetto che ti consegna (input, target) di una batch size fissa, opzionalmente shufflati, opzionalmente su più worker process. Il Dataset e il DataLoader di PyTorch ti danno questo.

from torch.utils.data import TensorDataset, DataLoader

# Immaginiamo di averli gia' come tensor
X_train = torch.randn(10000, 20)
y_train = torch.randint(0, 3, (10000,))

train_ds = TensorDataset(X_train, y_train)
train_loader = DataLoader(
    train_ds,
    batch_size=64,
    shuffle=True,
    num_workers=2,    # processi paralleli di data loading
    pin_memory=True,  # trasferimento CPU->GPU piu' veloce
)

for inputs, targets in train_loader:
    inputs, targets = inputs.to(device), targets.to(device)
    # ... training step ...
    break

Per dataset reali, immagini su disco, testo da un file, qualsiasi cosa che richieda preprocessing, fai il subclass di Dataset e definisci __len__ e __getitem__. Il DataLoader lo wrappa. Useremo TensorDataset per gli esempi giocattolo in questo modulo; il training loop della lezione 57 dà per scontato che tu abbia un DataLoader funzionante.

torch.compile: la velocizzazione della 2.x

PyTorch 2.0, rilasciato nel 2023, ha introdotto torch.compile. Wrappi il tuo modello e PyTorch fa JIT-compile del grafo computazionale per il tuo hardware:

model = torch.compile(model)   # una riga

In genere ottieni una velocizzazione del training di 1.5-3x sulle GPU moderne. Ci sono caveat, la prima iterazione è lenta a causa della compilazione, le shape dinamiche sono ancora ruvide, ma per training run di produzione stabili, torch.compile è essenzialmente performance gratuite. Per il 2026 è il default nella maggior parte dei progetti seri.

Quando JAX ha senso invece

Non scegliere JAX come tuo primo framework di deep learning. PyTorch è il default giusto. Ma sentirai parlare di JAX, quindi la versione corta:

JAX è funzionale. I modelli sono funzioni pure; i parametri sono pytree espliciti passati dentro e fuori. JAX compila aggressivamente via XLA, che gli dà seri vantaggi di velocità su TPU e su workload enormi in batch con shape statiche. Google usa JAX pesantemente all’interno. Alcuni gruppi di ricerca lo preferiscono. Il tradeoff è che lo stile funzionale e il modello compilation-first sono meno ergonomici per codice di ricerca disordinato con control flow dinamico. Resta su PyTorch a meno che tu non abbia una ragione specifica e il team per supportare la scelta.

Cosa abbiamo finora

Abbiamo i tensor. Abbiamo un modello. Abbiamo un optimizer e una loss function. Abbiamo un DataLoader. Abbiamo tutto tranne il loop che li lega insieme. La lezione 57 è quel loop, le cinque righe che costituiscono il vero training step, la contabilità intorno a esse che fa di un sistema di training reale, e le alternative di framework (Lightning, Hugging Face Trainer) che ti permettono di saltare il boilerplate quando non ti serve.

Cerca