Python, de la zero Lecția 57 / 60

Training loop-ul, in cod

Cele cinci linii care transforma o retea initializata aleatoriu intr-un model antrenat si contabilitatea care le face de calitate productie.

O rețea neuronală scoasă direct dintr-un nn.Module e o funcție aleatorie. Weights-urile ei sunt inițializate la numere aleatorii mici; predicțiile ei sunt zgomot. Training loop-ul e ceea ce transformă acea funcție aleatorie într-una utilă. Nucleul conceptual e cinci linii de cod. Versiunea de producție e mai degrabă 80, fiindcă antrenarea reală cere checkpointing, validare, urmărire de metrice, mixed precision, gradient clipping și un learning rate schedule. Până la sfârșitul acestei lecții vei fi scris ambele și vei ști când e potrivită fiecare.

Cele cinci linii

Fiecare pas de antrenare PyTorch scris vreodată se reduce la aceste cinci linii:

optimizer.zero_grad()                 # 1. clear gradients from last step
outputs = model(inputs)               # 2. forward pass: compute predictions
loss = criterion(outputs, targets)    # 3. compute the loss
loss.backward()                       # 4. backward pass: compute gradients
optimizer.step()                      # 5. update parameters

Ăsta e tot algoritmul de antrenare. Tot restul e iterație și contabilitate. Învelește acele cinci linii într-un loop peste batch-uri din DataLoader-ul tău, apoi învelește asta în alt loop peste epoci (un pas complet prin dataset), și ai un script complet de antrenare.

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()

Asta e tot. Ăsta e un training loop funcțional. Va antrena modelul tău. De asemenea îi lipsește fiecare bucată de contabilitate de care ai nevoie ca să ai cu adevărat încredere în rezultat, să-l livrezi și să-ți revii din dezastre. Le vom adăuga în continuare.

Validare

Nu poți judeca un model după loss-ul lui de antrenare. Loss-ul de antrenare îți spune cât de bine modelul a memorat setul de antrenament, ceea ce nu e ce te interesează. Te interesează cât de bine generalizează la date pe care nu le-a văzut niciodată. Deci la sfârșitul fiecărei epoci (sau la fiecare N batch-uri, pentru epoci foarte lungi), evaluezi pe un set de validare reținut.

Două lucruri se schimbă în evaluare. Întâi, apelezi model.eval(), care comută layere precum Dropout și BatchNorm în modul de evaluare. Al doilea, învelești loop-ul în torch.no_grad() ca să dezactivezi urmărirea gradienților, nu e necesară, iar dezactivarea ei economisește memorie și timp.

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

Întotdeauna ține minte să apelezi model.train() ca să-l pui înapoi în modul de antrenare după evaluare. Uitarea asta e un bug clasic, dropout-ul tău rămâne dezactivat pentru restul antrenării și modelul tău face overfitting.

Checkpointing

Rulările de antrenare eșuează. Cluster-ul reboot-ează. Descoperi un bug după epoca 47 din 100 și vrei să te întorci la epoca 30. Realizezi că trebuie să compari două modele din puncte diferite în antrenare. Niciunul din astea nu e posibil dacă nu salvezi starea modelului. Salvează checkpoints.

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"]

Două pattern-uri pe care le folosesc mereu: salvează cel mai recent checkpoint la sfârșitul fiecărei epoci (ca să pot relua după un crash) și salvează cel mai bun checkpoint oricând se îmbunătățește validation loss-ul (ca să am modelul care a performat cel mai bine, nu doar cel din ultima epocă). Nu păstra toate checkpoint-urile, sunt uriașe și nu ai nevoie de ele.

Urmarirea metricilor

Print statements funcționează pentru experimente minuscule. Pentru orice serios, folosește un tracker real. Cele două unelte dominante în 2026:

  • TensorBoard e construit în PyTorch via torch.utils.tensorboard.SummaryWriter. Gratuit, local, fricțiune scăzută.
  • Weights & Biases (wandb) e standardul găzduit în cloud. Urmărește metrici, statistici de sistem, hiperparametri, versiuni de cod. Tier-ul gratuit e generos; planul de echipă e ce folosesc majoritatea laboratoarelor.

Folosesc wandb pentru muncă în colaborare și TensorBoard pentru rulări solo. Interfața e similară:

from torch.utils.tensorboard import SummaryWriter

writer = SummaryWriter("runs/exp1")

# inside 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)

Chiar dacă nu folosești niciodată un UI, dump-uirea metricilor într-un fișier JSON pe măsură ce mergi e nenegociabil. Vei vrea să ploți curbe de antrenare mai târziu. Nu îți vei aminti numerele exacte. Salvează-le.

Mixed precision

GPU-urile NVIDIA moderne (V100 încoace, orice ai folosi în 2026) calculează mult mai rapid în float16 sau bfloat16 decât în float32. Antrenarea cu mixed precision păstrează weights-urile în float32 pentru stabilitate, dar face forward și backward pass în precizie mai mică, cu un „gradient scaler” pentru a preveni underflow. Beneficiul e aproximativ 2x accelerare de antrenare gratuit, plus jumătate din utilizarea memoriei 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()

Aproape niciun motiv să nu folosești mixed precision în 2026. API-ul PyTorch e stabil, accelerarea e reală, iar GPU-urile moderne sunt tot mai mult proiectate în jurul ei.

Gradient clipping si learning rate scheduling

Încă două bucăți pe care le vei vedea în orice script de antrenare în producție.

Gradient clipping previne ca un gradient ocazional uriaș să-ți arunce în aer weights-urile. Imediat după loss.backward() și înainte de optimizer.step():

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

Asta rescalează vectorul de gradient astfel încât norma lui L2 să fie cel mult 1.0. Esențial pentru antrenarea transformerelor; asigurare utilă pentru tot restul.

Learning rate scheduling: un learning rate constant e rar optim. Rețeta standard în 2026 e un warmup liniar scurt urmat de cosine decay. PyTorch livrează mai multe scheduler-e în torch.optim.lr_scheduler:

scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer, T_max=n_epochs
)
# at the end of each epoch:
scheduler.step()

CosineAnnealingLR decay-ează de la LR-ul tău inițial până la zero urmând o curbă cosinus. OneCycleLR și ReduceLROnPlateau sunt celelalte alegeri obișnuite.

Punand totul cap la cap: un training loop real

Iată versiunea de calitate producție. Aproximativ 80 de linii. Ăsta e genul de fișier pe care îl are fiecare proiect de deep learning.

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

Plin de boilerplate, dar fiecare linie își câștigă locul. Validarea e nenegociabilă. Checkpointing-ul e nenegociabil. JSON-ul cu history îți permite să ploți curbele de antrenare după aceea, chiar dacă log-urile tale TensorBoard au fost șterse.

Distributed training, pe scurt

Dacă modelul și datele tale încap pe un GPU, antrenarea single-GPU e alegerea corectă. Dacă nu încap, ai nevoie de antrenare data-parallel sau model-parallel pe mai multe GPU-uri.

Mecanismul standard în PyTorch e torch.nn.parallel.DistributedDataParallel (DDP). Fiecare GPU rulează o copie identică a modelului pe un shard diferit din fiecare batch; gradienții sunt mediați peste GPU-uri după backward pass. Lansezi script-ul cu torchrun --nproc_per_node=N script.py și PyTorch se ocupă de sincronizare. Schimbările la codul tău de antrenare sunt minime, învelește modelul tău în DDP(...), folosește un DistributedSampler pe DataLoader-ul tău și salvează checkpoints doar pe rank 0.

Pentru modele prea mari să încapă pe un singur GPU chiar și la batch size 1, transformere de frontieră, în mare, ai nevoie de fully sharded data parallel (FSDP) sau pipeline parallelism. Ăsta e un curs propriu. Nu te duce acolo până nu trebuie.

PyTorch Lightning: sari peste boilerplate

PyTorch Lightning învelește pattern-ul de training loop de mai sus într-un API bazat pe clase. Definești un LightningModule cu metode training_step, validation_step și configure_optimizers, îl dai unui Trainer, iar Lightning se ocupă de checkpointing, AMP, distributed training și logging de metrice pentru tine.

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)

Aproximativ 30 de linii, înlocuind 80 de PyTorch brut. Pentru probleme directe de antrenare, Lightning e o victorie reală de productivitate. Costul e opacitatea, când se întâmplă ceva ciudat, trebuie să sapi prin internalele Lightning ca să înțelegi.

Hugging Face Trainer: pentru transformere

Pentru fine-tuning de modele transformer din Hugging Face Hub, clasa transformers.Trainer e standardul. Se ocupă de tokenizare, padding dinamic, mixed precision, distributed training și se integrează cu biblioteca datasets de la Hugging Face. Dacă faci fine-tuning la un BERT, un Llama sau orice model din Hub, aproape întotdeauna folosești Trainer în loc să scrii propriul tău loop.

Roll-your-own vs framework

Regula onestă de decizie:

  • Scrie singur când înveți (nu poți înțelege deep learning scriind doar LightningModule.training_step), când ai cerințe neobișnuite de antrenare sau când faci cercetare ce are nevoie de control complet.
  • Folosește Lightning pentru antrenare în producție de arhitecturi standard unde boilerplate-ul e overhead pur.
  • Folosește Hugging Face Trainer pentru fine-tuning de modele din Hugging Face Hub.

Scriu un training loop de la zero pentru aproape fiecare proiect nou la început, fiindcă mă forțează să mă gândesc la fiecare bucată. Apoi refactorizez la Lightning dacă proiectul rămâne și codul devine o povară de mentenanță. Cele cinci linii de la începutul acestei lecții sunt nucleul; restul e alegerea cât vrei să scrii singur.

Sfarsitul Modulului 10

Asta e fundația. Lecția 55 a fost intuiția: o rețea e o funcție, backprop e doar regula lanțului, deep learning-ul câștigă unde feature engineering-ul e imposibil. Lecția 56 a fost PyTorch: tensori, autograd, API-ul nn.Module. Lecția 57, asta, a fost loop-ul: cinci linii de nucleu, plus contabilitatea. Cu aceste trei lecții în cap, poți citi orice implementare de referință a unei lucrări moderne de deep learning și recunoaște ce face fiecare bloc. Modulul 11 construiește pe asta cu un mic proiect de clasificare de imagini, și de acolo intrăm în teritoriul mai specializat al CNN-urilor, transformerelor și folosirii modelelor preantrenate de la Hugging Face.

Primul proiect de deep learning e mereu lent. Al doilea e rapid. Până la al treilea vei regla learning rate schedules peste cafea. Bun venit.

Caută