Python, dalle fondamenta Lezione 57 / 60

Il training loop, in codice

Le cinque righe che trasformano una rete inizializzata a caso in un modello allenato, e la contabilita' che le rende production-grade.

Una neural network appena uscita da nn.Module è una funzione casuale. I suoi weight sono inizializzati a piccoli numeri casuali; le sue predizioni sono rumore. Il training loop è ciò che trasforma quella funzione casuale in una utile. Il nucleo concettuale sono cinque righe di codice. La versione di produzione è più tipo 80, perché il training reale richiede checkpointing, validation, tracciamento delle metriche, mixed precision, gradient clipping, e un learning rate schedule. Per la fine di questa lezione avrai scritto entrambe, e saprai quando ognuna è appropriata.

Le cinque righe

Ogni training step di PyTorch mai scritto si riduce a queste cinque righe:

optimizer.zero_grad()                 # 1. azzera i gradient dallo step precedente
outputs = model(inputs)               # 2. forward pass: calcola le predizioni
loss = criterion(outputs, targets)    # 3. calcola la loss
loss.backward()                       # 4. backward pass: calcola i gradient
optimizer.step()                      # 5. aggiorna i parametri

Quello è l’intero algoritmo di training. Tutto il resto è iterazione e contabilità. Wrappa quelle cinque righe in un loop sui batch dal tuo DataLoader, poi wrappa quello in un altro loop sulle epoch (un passaggio completo sul dataset), e hai uno script di training completo.

for epoch in range(n_epochs):
    for inputs, targets in train_loader:
        inputs, targets = inputs.to(device), targets.to(device)
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, targets)
        loss.backward()
        optimizer.step()

Eccolo. Quello è un training loop funzionante. Allenerà il tuo modello. Gli mancano anche tutti i pezzi di contabilità che ti servono per fidarti davvero del risultato, spedirlo, e riprenderti dai disastri. Li aggiungeremo dopo.

Validation

Non puoi giudicare un modello dalla sua training loss. La training loss ti dice quanto bene il modello ha memorizzato il training set, che non è quello che ti interessa. Ti interessa quanto bene generalizza a dati che non ha mai visto. Quindi alla fine di ogni epoch (o ogni N batch, per epoch molto lunghe), valuti su un validation set tenuto da parte.

Due cose cambiano in evaluation. Primo, chiami model.eval(), che mette layer come Dropout e BatchNorm in evaluation mode. Secondo, wrappi il loop in torch.no_grad() per disabilitare il tracciamento dei gradient, non serve, e disabilitarlo risparmia memoria e tempo.

def evaluate(model, loader, criterion, device):
    model.eval()
    total_loss = 0.0
    n_correct = 0
    n_total = 0
    with torch.no_grad():
        for inputs, targets in loader:
            inputs, targets = inputs.to(device), targets.to(device)
            outputs = model(inputs)
            loss = criterion(outputs, targets)
            total_loss += loss.item() * inputs.size(0)
            preds = outputs.argmax(dim=1)
            n_correct += (preds == targets).sum().item()
            n_total += inputs.size(0)
    model.train()
    return total_loss / n_total, n_correct / n_total

Ricordati sempre di chiamare model.train() per rimetterlo in training mode dopo aver fatto evaluation. Dimenticare questo è un bug classico, il tuo dropout resta disabilitato per il resto del training e il tuo modello va in overfitting.

Checkpointing

I training run falliscono. Il cluster fa reboot. Scopri un bug dopo l’epoch 47 di 100 e vuoi tornare indietro all’epoch 30. Ti rendi conto che ti serve confrontare due modelli da punti diversi del training. Niente di tutto questo è possibile se non salvi lo stato del modello. Salva i checkpoint.

import torch
from pathlib import Path

CKPT_DIR = Path("checkpoints")
CKPT_DIR.mkdir(exist_ok=True)

def save_checkpoint(model, optimizer, epoch, val_loss, path):
    torch.save({
        "epoch": epoch,
        "model_state_dict": model.state_dict(),
        "optimizer_state_dict": optimizer.state_dict(),
        "val_loss": val_loss,
    }, path)

def load_checkpoint(path, model, optimizer=None):
    ckpt = torch.load(path, map_location="cpu", weights_only=True)
    model.load_state_dict(ckpt["model_state_dict"])
    if optimizer is not None:
        optimizer.load_state_dict(ckpt["optimizer_state_dict"])
    return ckpt["epoch"], ckpt["val_loss"]

Due pattern che uso sempre: salva il latest checkpoint alla fine di ogni epoch (così posso riprendere dopo un crash), e salva il best checkpoint ogni volta che la validation loss migliora (così ho il modello che ha performato meglio, non solo quello dell’ultima epoch). Non tenere tutti i checkpoint, sono enormi e non ti servono.

Tracciamento delle metriche

Le print statement vanno bene per esperimenti minuscoli. Per qualsiasi cosa seria, usa un tracker vero. I due tool dominanti nel 2026:

  • TensorBoard è integrato in PyTorch via torch.utils.tensorboard.SummaryWriter. Gratis, locale, a basso attrito.
  • Weights & Biases (wandb) è lo standard cloud-hosted. Traccia metriche, statistiche di sistema, iperparametri, versioni del codice. Il free tier è generoso; il piano team è quello che la maggior parte dei lab usa.

Uso wandb per il lavoro collaborativo e TensorBoard per i run da solo. L’interfaccia è simile:

from torch.utils.tensorboard import SummaryWriter

writer = SummaryWriter("runs/exp1")

# dentro il training loop:
writer.add_scalar("train/loss", loss.item(), global_step)
writer.add_scalar("val/loss", val_loss, epoch)
writer.add_scalar("val/accuracy", val_acc, epoch)

Anche se non usi mai una UI, scaricare le metriche su un file JSON man mano che procedi non è negoziabile. Vorrai plottare le curve di training dopo. Non ti ricorderai i numeri esatti. Salvali.

Mixed precision

Le GPU NVIDIA moderne (V100 in poi, qualsiasi cosa che useresti nel 2026) computano molto più velocemente in float16 o bfloat16 che in float32. Il mixed-precision training tiene i weight in float32 per stabilità ma fa il forward e il backward pass in precisione più bassa, con un “gradient scaler” per prevenire l’underflow. Il beneficio è all’incirca una velocizzazione del training di 2x gratis, più dimezzato l’uso di memoria GPU.

from torch.cuda.amp import GradScaler, autocast

scaler = GradScaler()

for inputs, targets in train_loader:
    inputs, targets = inputs.to(device), targets.to(device)
    optimizer.zero_grad()
    with autocast(device_type="cuda", dtype=torch.float16):
        outputs = model(inputs)
        loss = criterion(outputs, targets)
    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()

Quasi nessun motivo per non usare la mixed precision nel 2026. L’API di PyTorch è stabile, la velocizzazione è reale, e le GPU moderne sono sempre più disegnate intorno a essa.

Gradient clipping e learning rate scheduling

Altri due pezzi che vedrai in qualsiasi script di training di produzione.

Gradient clipping previene che l’occasionale gradient enorme faccia esplodere i tuoi weight. Subito dopo loss.backward() e prima di optimizer.step():

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

Questo riscala il vettore dei gradient così la sua norma L2 è al massimo 1.0. Essenziale per allenare i transformer; un’utile assicurazione per tutto il resto.

Learning rate scheduling: un learning rate costante è raramente ottimale. La ricetta standard nel 2026 è un breve linear warmup seguito da cosine decay. PyTorch spedisce diversi scheduler in torch.optim.lr_scheduler:

scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer, T_max=n_epochs
)
# alla fine di ogni epoch:
scheduler.step()

CosineAnnealingLR decade dal tuo LR iniziale fino a zero seguendo una curva cosine. OneCycleLR e ReduceLROnPlateau sono le altre scelte comuni.

Mettiamolo insieme: un training loop reale

Ecco la versione production-grade. Circa 80 righe. Questo è il tipo di file che ogni progetto di deep learning ha.

import json
import torch
import torch.nn as nn
from pathlib import Path
from torch.cuda.amp import GradScaler, autocast

def train(
    model: nn.Module,
    train_loader,
    val_loader,
    criterion,
    optimizer,
    scheduler,
    n_epochs: int,
    device,
    ckpt_dir: Path,
    use_amp: bool = True,
    grad_clip: float = 1.0,
):
    ckpt_dir.mkdir(exist_ok=True, parents=True)
    scaler = GradScaler(enabled=use_amp)
    best_val_loss = float("inf")
    history = []

    for epoch in range(n_epochs):
        # --- training ---
        model.train()
        train_loss = 0.0
        n_seen = 0
        for inputs, targets in train_loader:
            inputs, targets = inputs.to(device), targets.to(device)
            optimizer.zero_grad()
            with autocast(device_type=device.type, enabled=use_amp):
                outputs = model(inputs)
                loss = criterion(outputs, targets)
            scaler.scale(loss).backward()
            scaler.unscale_(optimizer)
            torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
            scaler.step(optimizer)
            scaler.update()
            train_loss += loss.item() * inputs.size(0)
            n_seen += inputs.size(0)
        train_loss /= n_seen
        scheduler.step()

        # --- validation ---
        val_loss, val_acc = evaluate(model, val_loader, criterion, device)
        lr = optimizer.param_groups[0]["lr"]
        history.append({
            "epoch": epoch,
            "train_loss": train_loss,
            "val_loss": val_loss,
            "val_acc": val_acc,
            "lr": lr,
        })
        print(f"epoch {epoch:3d} | lr {lr:.2e} | "
              f"train {train_loss:.4f} | val {val_loss:.4f} | acc {val_acc:.4f}")

        # --- checkpoint ---
        torch.save({
            "epoch": epoch,
            "model_state_dict": model.state_dict(),
            "optimizer_state_dict": optimizer.state_dict(),
        }, ckpt_dir / "latest.pt")
        if val_loss < best_val_loss:
            best_val_loss = val_loss
            torch.save(model.state_dict(), ckpt_dir / "best.pt")

    with open(ckpt_dir / "history.json", "w") as f:
        json.dump(history, f, indent=2)
    return history

Pesante di boilerplate ma ogni riga si guadagna il suo posto. La validation non è negoziabile. Il checkpointing non è negoziabile. Il JSON di history ti permette di plottare le curve di training dopo i fatti anche se i tuoi log di TensorBoard sono andati in fumo.

Distributed training, brevemente

Se il tuo modello e i dati ci stanno su una GPU, il training su singola GPU è la scelta giusta. Se non ci stanno, ti serve training data-parallel o model-parallel su più GPU.

Il meccanismo standard in PyTorch è torch.nn.parallel.DistributedDataParallel (DDP). Ogni GPU fa girare una copia identica del modello su uno shard diverso di ogni batch; i gradient vengono mediati tra le GPU dopo il backward pass. Lanci lo script con torchrun --nproc_per_node=N script.py e PyTorch gestisce la sincronizzazione. I cambiamenti al tuo codice di training sono minimi, wrappa il tuo modello in DDP(...), usa un DistributedSampler sul tuo DataLoader, e salva i checkpoint solo sul rank 0.

Per modelli troppo grossi per stare su una singola GPU anche con batch size 1, transformer di frontiera, principalmente, ti serve fully sharded data parallel (FSDP) o pipeline parallelism. Quello è un suo corso a parte. Non andarci finché non sei costretto.

PyTorch Lightning: salta il boilerplate

PyTorch Lightning wrappa il pattern del training loop visto sopra in un’API class-based. Definisci un LightningModule con i metodi training_step, validation_step, e configure_optimizers, lo passi a un Trainer, e Lightning gestisce checkpointing, AMP, distributed training, e logging delle metriche per te.

import lightning as L

class LitMLP(L.LightningModule):
    def __init__(self, model, lr=1e-3):
        super().__init__()
        self.model = model
        self.criterion = nn.CrossEntropyLoss()
        self.lr = lr

    def training_step(self, batch, batch_idx):
        inputs, targets = batch
        outputs = self.model(inputs)
        loss = self.criterion(outputs, targets)
        self.log("train_loss", loss)
        return loss

    def validation_step(self, batch, batch_idx):
        inputs, targets = batch
        outputs = self.model(inputs)
        loss = self.criterion(outputs, targets)
        acc = (outputs.argmax(dim=1) == targets).float().mean()
        self.log("val_loss", loss)
        self.log("val_acc", acc)

    def configure_optimizers(self):
        return torch.optim.AdamW(self.parameters(), lr=self.lr)

trainer = L.Trainer(
    max_epochs=20,
    precision="16-mixed",
    accelerator="auto",
    devices="auto",
)
trainer.fit(LitMLP(model), train_loader, val_loader)

Circa 30 righe, che sostituiscono 80 di PyTorch grezzo. Per problemi di training diretti, Lightning è una vera vittoria di produttività. Il costo è l’opacità, quando succede qualcosa di strano, devi scavare tra le interiora di Lightning per capirlo.

Hugging Face Trainer: per i transformer

Per fare fine-tuning di modelli transformer dall’Hugging Face Hub, la classe transformers.Trainer è lo standard. Gestisce tokenizzazione, padding dinamico, mixed precision, distributed training, e si integra con la libreria datasets di Hugging Face. Se stai facendo fine-tuning di un BERT, di un Llama, o di qualsiasi modello dall’Hub, quasi sempre usi Trainer invece di scriverti il tuo loop.

Roll-your-own vs framework

La regola di decisione onesta:

  • Scrivi il tuo quando stai imparando (non puoi capire il deep learning scrivendo solo LightningModule.training_step), quando hai requisiti di training insoliti, o quando stai facendo ricerca che richiede pieno controllo.
  • Usa Lightning per il training di produzione di architetture standard dove il boilerplate è puro overhead.
  • Usa Hugging Face Trainer per fare fine-tuning di modelli dall’Hugging Face Hub.

Scrivo un training loop da zero per quasi ogni nuovo progetto all’inizio, perché mi costringe a pensare a ogni pezzo. Poi rifattorizzo a Lightning se il progetto resta in giro e il codice sta diventando un peso di manutenzione. Le cinque righe all’inizio di questa lezione sono il nucleo; il resto è scegliere quanto vuoi scrivere tu stesso.

Fine del modulo 10

Quella è la fondazione. La lezione 55 era l’intuizione: una rete è una funzione, la backprop è solo la chain rule, il deep learning vince dove la feature engineering è impossibile. La lezione 56 era PyTorch: tensor, autograd, l’API nn.Module. La lezione 57, questa, era il loop: cinque righe di nucleo, più la contabilità. Con queste tre lezioni in testa, puoi leggere l’implementazione di riferimento di qualsiasi paper di deep learning moderno e riconoscere cosa fa ogni blocco. Il modulo 11 costruisce su questo con un piccolo progetto di classificazione di immagini, e da lì ci muoviamo nel territorio più specializzato delle CNN, dei transformer, e dell’uso di modelli pre-allenati da Hugging Face.

Il primo progetto di deep learning è sempre lento. Il secondo è veloce. Per il terzo starai aggiustando i learning rate schedule davanti a un caffè. Benvenuto.

Cerca