Salta al contenuto principale

02 - Transfer Learning: Riutilizzare Modelli Pre-trained

Guida completa a Transfer Learning: Riutilizzare Modelli Pre-trained

Cosa ne pensi di questo articolo?

02 - Transfer Learning: Riutilizzare Modelli Pre-trained
Computer VisionFoto:Brett SaylesPexels

In Breve

Guida completa a Transfer Learning: Riutilizzare Modelli Pre-trained

Transfer Learning: Riutilizzare Modelli Pre-trained

Immagina di dover insegnare a un bambino a riconoscere le razze di cani. Se quel bambino ha già imparato a riconoscere forme, colori, texture e strutture anatomiche generali, il compito diventa enormemente più semplice. Non deve ripartire da zero: può trasferire la conoscenza già acquisita al nuovo compito. Questo e esattamente ciò che fa il Transfer Learning nel deep learning.

In questo secondo articolo della serie Computer Vision con Deep Learning, esploreremo in profondità il Transfer Learning: perchè funziona, quali strategie esistono, come scegliere il modello pre-addestrato giusto e come implementare pipeline complete in PyTorch. Vedremo un case study industriale reale e le tecniche avanzate che i professionisti usano quotidianamente.

Panoramica della Serie

# Articolo Focus
1CNN: Reti ConvoluzionaliArchitettura, training, deployment
2Sei qui - Transfer Learning e Fine-TuningModelli pre-addestrati, domain adaptation
3Object Detection con YOLORilevamento oggetti in tempo reale
4Segmentazione SemanticaClassificazione a livello di pixel
5Image Generation con GAN e DiffusionGenerazione immagini sintetiche
6Edge Deployment e OttimizzazioneModelli su dispositivi embedded

Cosa Imparerai

  • Cos'è il Transfer Learning e perchè funziona (feature hierarchies nelle CNN)
  • Le strategie principali: feature extraction, fine-tuning, domain adaptation
  • Come scegliere il modello pre-addestrato giusto (ResNet, EfficientNet, ViT, ConvNeXt)
  • Implementazione completa in PyTorch: dalla preparazione dei dati al deployment
  • Tecniche avanzate: discriminative learning rates, gradual unfreezing, learning rate warmup
  • Data augmentation ottimizzata per Transfer Learning
  • Case study pratico: classificazione difetti industriali con ResNet-50
  • Transfer Learning applicato all'object detection (Faster R-CNN, YOLO)
  • Errori comuni e come evitarli

1. Cos'è il Transfer Learning

Il Transfer Learning e una tecnica di machine learning in cui un modello addestrato su un task (detto source task) viene riutilizzato come punto di partenza per un task diverso (detto target task). Invece di addestrare una rete neurale da zero su milioni di immagini, prendiamo un modello già addestrato (tipicamente su ImageNet, 1.2 milioni di immagini in 1000 classi) e lo adattiamo al nostro specifico problema.

1.1 L'Analogia Umana

Il nostro cervello opera costantemente in modalità transfer learning. Un chirurgo che impara una nuova tecnica operatoria non deve reimparare l'anatomia, la fisiologia e la manualita di base. Un musicista classico che passa al jazz trasferisce la tecnica strumentale, la lettura dello spartito e la teoria armonica. Un programmatore Python che impara Rust trasferisce i concetti di programmazione, il debugging mentale e la logica algoritmica. In tutti questi casi, la conoscenza pregressa accelera enormemente l'apprendimento del nuovo dominio.

1.2 perchè Funziona: La Gerarchia delle Feature

La ragione fondamentale per cui il Transfer Learning funziona nelle CNN risiede nella gerarchia delle feature apprese. I ricercatori hanno dimostrato che le CNN addestrate su ImageNet apprendono feature organizzate in livelli di astrazione crescente:

Gerarchia delle Feature nelle CNN

Layer Feature Apprese Specificità Trasferibilita
Layer 1-2 Bordi, angoli, gradienti di colore Generiche (task-agnostic) Altissima
Layer 3-4 Texture, pattern ripetuti, motivi geometrici Semi-generiche Alta
Layer 5-6 Parti di oggetti (occhi, ruote, finestre) Semi-specifiche Media
Layer 7+ Oggetti completi, scene, composizioni Task-specifiche Bassa

I primi layer apprendono feature universali: bordi, texture e gradienti che sono utili per qualsiasi task visivo. I layer intermedi catturano pattern più complessi ma ancora ragionevolmente generici. Solo gli ultimi layer sono altamente specifici per il task originale. Questo significa che possiamo riutilizzare la maggior parte della rete come un potente estrattore di feature e adattare solo le parti finali al nostro task.

Visualizzazione della Gerarchia delle Feature

ImageNet Pre-trained CNN (es. ResNet-50):

Input Immagine
    |
    v
[Layer 1-2]  --->  Bordi orizzontali, verticali, diagonali
    |               Gradienti di colore, blob
    |               UNIVERSALI: utili per QUALSIASI immagine
    v
[Layer 3-4]  --->  Texture (pelo, metallo, legno, tessuto)
    |               Pattern geometrici, griglie
    |               SEMI-GENERICHE: utili per molti domini
    v
[Layer 5-6]  --->  Parti di oggetti (occhi, ruote, ali)
    |               Composizioni locali
    |               SEMI-SPECIFICHE: dipendono dal dominio
    v
[Layer 7+]   --->  Classi intere (gatto, auto, uccello)
    |               Feature altamente specifiche per ImageNet
    |               TASK-SPECIFICHE: da sostituire/adattare
    v
[Classifier] --->  1000 classi ImageNet
                    SEMPRE da sostituire per il tuo task
    

Definizione Formale

Dato un dominio sorgente D_s con task T_s e un dominio target D_t con task T_t, il Transfer Learning mira a migliorare la funzione di apprendimento f_t nel dominio target utilizzando la conoscenza estratta da D_s e T_s, dove D_s != D_t oppure T_s != T_t. In pratica, i pesi theta appresi dal source task vengono usati come inizializzazione (theta_0) per il training sul target task, invece di un'inizializzazione casuale.

2. Strategie di Transfer Learning

Non esiste un unico modo di applicare il Transfer Learning. La strategia ottimale dipende dalla dimensione del dataset target, dalla sua somiglianza con il dataset sorgente e dalle risorse computazionali disponibili. Esaminiamo le quattro strategie principali.

2.1 Feature Extraction (Freeze Backbone, Train Classifier)

La strategia più semplice e diretta: si congela (freeze) l'intera rete pre-addestrata e si usa come un estrattore di feature fisso. L'unica parte che viene addestrata e un nuovo classificatore aggiunto in cima. I pesi del backbone non vengono aggiornati durante il training.

Quando usarla: Dataset target piccolo (centinaia/poche migliaia di immagini) e dominio simile al source (es. classificare razze di cani quando il modello e pre-addestrato su ImageNet che contiene molte immagini di cani).

Schema Feature Extraction

Pre-trained ResNet-50 (ImageNet):
+---------------------------------------------------+
| [Conv layers] --> [Res blocks] --> [Global AvgPool] | CONGELATO (frozen)
|   25.5M parametri - NON aggiornati                  |   - requires_grad = False
+---------------------------------------------------+
                        |
                        v
                  Feature vector (2048-dim)
                        |
                        v
              +-------------------+
              | [Linear 2048->N]  |  ADDESTRATO (trainable)
              |   N = tue classi  |    - requires_grad = True
              +-------------------+
                        |
                        v
                  Output: N classi

Vantaggi:
  + Training velocissimo (pochi parametri da ottimizzare)
  + Non richiede GPU potente
  + Rischio minimo di overfitting
  + Bastano pochi dati

Svantaggi:
  - Meno flessibile (le feature sono fisse)
  - Performance limitata se il dominio e molto diverso
    

2.2 Fine-Tuning (Unfreeze Some/All Layers)

Nel fine-tuning, dopo aver inizializzato la rete con pesi pre-addestrati, si scongelano alcuni o tutti i layer e si ri-addestra l'intera rete (o parte di essa) con un learning rate molto basso. I layer pre-addestrati vengono leggermente aggiornati per adattarsi al nuovo dominio, preservando la conoscenza già acquisita.

Quando usarlo: Dataset target medio-grande (migliaia-decine di migliaia di immagini) e/o dominio moderatamente diverso dal source.

Schema Fine-Tuning

Strategia di Fine-Tuning Progressivo:

Fase 1 - Feature Extraction (5-10 epochs):
  [Backbone CONGELATO] --> [Nuovo Classifier] ADDESTRATO (lr=1e-3)

Fase 2 - Partial Fine-Tuning (10-20 epochs):
  [Layer 1-3 CONGELATI] --> [Layer 4 SCONGELATO lr=1e-5] --> [Classifier lr=1e-4]

Fase 3 - Full Fine-Tuning (opzionale, 5-10 epochs):
  [TUTTI i layer SCONGELATI lr=1e-6] --> [Classifier lr=1e-5]

Learning Rates progressivi:
  Layer iniziali:  lr = 1e-6  (feature generiche, cambiare poco)
  Layer intermedi: lr = 1e-5  (adattare gradualmente)
  Layer finali:    lr = 1e-4  (adattare al nuovo dominio)
  Classifier:     lr = 1e-3  (apprendere da zero)
    

2.3 Domain Adaptation

La Domain Adaptation e una forma specializzata di Transfer Learning usata quando il source domain e il target domain condividono le stesse classi ma hanno distribuzioni di dati diverse. Ad esempio, un modello addestrato su foto professionali di prodotti che deve funzionare su foto scattate in fabbrica con illuminazione variabile. Tecniche come DANN (Domain-Adversarial Neural Network) aggiungono un discriminatore di dominio che forza la rete a imparare feature invarianti rispetto al dominio.

2.4 Zero-Shot e Few-Shot Transfer

Con l'avvento di modelli come CLIP (Contrastive Language-Image Pre-training), e possibile classificare immagini in categorie mai viste durante il training (zero-shot) o con pochissimi esempi (few-shot). CLIP apprende una rappresentazione congiunta testo-immagine: dato un prompt testuale come "una foto di un difetto di saldatura", il modello può classificare immagini senza alcun training specifico.

Confronto Strategie di Transfer Learning

Strategia Dati Necessari Tempo Training Performance Rischio Overfitting
Feature Extraction 100-1000 Minuti Buona Molto basso
Partial Fine-Tuning 1000-10000 Ore Molto buona Basso
Full Fine-Tuning 10000+ Ore-Giorni Ottima Medio
Domain Adaptation Variabile Ore Buona-Ottima Medio
Zero-Shot (CLIP) 0 Nessuno Variabile Nessuno

3. Modelli Pre-trained per Computer Vision

La scelta del modello pre-addestrato e una decisione cruciale. Ogni architettura ha trade-off diversi tra accuratezza, velocità di inference, dimensione del modello e requisiti di memoria. Ecco una panoramica dei modelli più utilizzati nel 2025-2026.

Tabella Comparativa Modelli Pre-trained

Modello Parametri ImageNet Top-1 Tipo Uso Ideale
ResNet-50 25.6M 76.1% (v1) / 80.9% (v2) CNN Baseline solida, deployment facile
EfficientNet-B0 5.3M 77.1% CNN Mobile, edge, risorse limitate
EfficientNet-B7 66M 84.3% CNN Massima accuratezza CNN
ViT-B/16 86M 77.9% (ImageNet-1k) Transformer Dataset grandi, pre-training su larga scala
ConvNeXt-T 28.6M 82.1% CNN moderna Best trade-off accuracy/velocità
ConvNeXt-B 88.6M 83.8% CNN moderna Quando serve alta accuratezza con CNN
Swin-T 28.3M 81.3% Transformer Detection e segmentazione
CLIP ViT-B/32 151M (visual) 63.2% (zero-shot) Multimodale Zero-shot, ricerca visuale
DINOv2 ViT-S/14 22M 81.1% (linear probe) Self-supervised Feature generiche, pochi dati labeled

3.1 ResNet-50: Il Cavallo di Battaglia

ResNet-50 resta il modello più popolare per il Transfer Learning grazie alla sua semplicità, stabilità nel training e ampio supporto ecosistemico. Le skip connections (introdotte nell'articolo precedente) permettono di addestrare reti profonde senza problemi di vanishing gradient. La versione V2 dei pesi (IMAGENET1K_V2), addestrata con tecniche moderne come Mixup, CutMix e Random Erasing, raggiunge un impressionante 80.9% top-1.

3.2 EfficientNet: Scalabilità Compound

La famiglia EfficientNet utilizza un metodo di compound scaling che scala uniformemente profondità, larghezza e risoluzione della rete. EfficientNet-B0 e ideale per dispositivi con risorse limitate (5.3M parametri), mentre B7 offre la massima accuratezza (84.3%) a costo di un modello molto più grande (66M parametri).

3.3 Vision Transformer (ViT) e Swin Transformer

I Vision Transformers applicano l'architettura Transformer (originariamente creata per il NLP) alla computer vision. L'immagine viene divisa in patch (es. 16x16 pixel), ogni patch viene trattata come un "token" e processata con self-attention. I ViT eccellono quando pre-addestrati su grandi dataset (ImageNet-21k, JFT-300M) ma possono essere meno efficaci con dataset piccoli rispetto alle CNN. Swin Transformer introduce attenzione a finestre scorrevoli (shifted windows), rendendolo più efficiente e particolarmente adatto per task densi come detection e segmentazione.

3.4 ConvNeXt: CNN Modernizzata

ConvNeXt dimostra che le CNN possono competere con i Transformer se modernizzate con le stesse tecniche di training (AdamW, Mixup, layer scale, Stochastic Depth). ConvNeXt-T raggiunge 82.1% con soli 28.6M parametri, offrendo un eccellente trade-off tra accuratezza, velocità e semplicità di deployment.

3.5 DINOv2: Self-Supervised Learning

DINOv2 e un modello addestrato con self-supervised learning (senza label) su un enorme dataset curato (LVD-142M immagini). Le feature estratte sono estremamente generiche e trasferibili: un semplice classificatore lineare aggiunto in cima raggiunge risultati competitivi con il fine-tuning completo di modelli supervised. E particolarmente utile quando hai pochi dati etichettati nel target domain.

4. Quando Usare il Transfer Learning: La Matrice Decisionale

La scelta della strategia dipende da due fattori chiave: la dimensione del dataset target e la somiglianza tra il dominio sorgente e il dominio target. Questo genera quattro quadranti decisionali.

Matrice Decisionale per il Transfer Learning

                        SOMIGLIANZA CON SOURCE DOMAIN
                    Alta                        Bassa
              +-------------------------+-------------------------+
              |                         |                         |
    Grande    |  QUADRANTE 1            |  QUADRANTE 2            |
    (10k+)    |  Fine-tuning completo   |  Fine-tuning con        |
              |  - Unfreeze tutti i     |  cautela                |
 D            |    layer                |  - Unfreeze solo layer  |
 A            |  - Learning rate basso  |    finali               |
 T            |  - Alta performance     |  - Learning rate molto  |
 A            |                         |    basso per backbone   |
 S            |  Es: Razze di cani      |  - Augmentation forte   |
 E            |  (ImageNet contiene     |                         |
 T            |   già molti cani)       |  Es: Immagini medicali  |
              |                         |  (molto diverse da      |
 S            +-------------------------+  ImageNet)              |
 I            |                         +-------------------------+
 Z            |  QUADRANTE 3            |  QUADRANTE 4            |
 E            |  Feature Extraction     |  Opzioni limitate       |
              |  - Freeze backbone      |  - Prova feature        |
    Piccolo   |  - Solo train           |    extraction           |
    (100-1k)  |    classifier           |  - Augmentation molto   |
              |  - No overfitting       |    aggressiva           |
              |  - Training veloce      |  - Considera raccolta   |
              |                         |    più dati             |
              |  Es: 200 foto di fiori  |  - DINOv2 / CLIP       |
              |  (simili a ImageNet)    |    (self-supervised)    |
              +-------------------------+-------------------------+
    

Regola Pratica

Nel 2025-2026, la risposta alla domanda "Devo usare il Transfer Learning?" e quasi sempre si. Addestrare una CNN da zero e giustificato solo in casi molto specifici: dataset enormi (milioni di immagini), dominio radicalmente diverso dalle immagini naturali (es. spettrogrammi, segnali radar) o vincoli architetturali particolari.

5. Implementazione con PyTorch

Passiamo alla pratica. Implementeremo il Transfer Learning passo dopo passo in PyTorch, partendo dal caricamento di un modello pre-addestrato fino al training completo.

5.1 Caricamento di un Modello Pre-trained

PyTorch offre due API per caricare modelli pre-addestrati. L'API moderna (introdotta in torchvision 0.13+) usa l'enum Weights che fornisce informazioni dettagliate sui pesi, incluse le trasformazioni di preprocessing richieste.

Caricamento ResNet-50 Pre-trained (API Moderna)

import torch
import torch.nn as nn
from torchvision import models
from torchvision.models import ResNet50_Weights

# ---- API moderna (raccomandata): usa l'enum Weights ----
# Carica ResNet-50 con pesi IMAGENET1K_V2 (80.9% top-1)
weights = ResNet50_Weights.IMAGENET1K_V2
model = models.resnet50(weights=weights)

# I pesi includono le trasformazioni di preprocessing
preprocess = weights.transforms()
# Questo crea automaticamente: Resize(232) -> CenterCrop(224)
#   -> ToTensor() -> Normalize(mean, std)

print(f"Modello caricato: {sum(p.numel() for p in model.parameters()):,} parametri")
print(f"Device: {next(model.parameters()).device}")

# ---- API legacy (deprecata ma ancora funzionante) ----
# model = models.resnet50(pretrained=True)  # NON usare più

# ---- Ispeziona la struttura del modello ----
# ResNet-50 ha: conv1, bn1, relu, maxpool, layer1-4, avgpool, fc
print(model)
    

5.2 Feature Extraction: Freeze e Replace Classifier

Per usare ResNet-50 come feature extractor, dobbiamo: (1) congelare tutti i parametri del backbone, (2) sostituire il layer fully connected finale con uno nuovo adatto al nostro numero di classi.

Feature Extraction con ResNet-50

import torch
import torch.nn as nn
from torchvision import models
from torchvision.models import ResNet50_Weights


def create_feature_extractor(num_classes: int, device: str = 'cuda') -> nn.Module:
    """Crea un feature extractor basato su ResNet-50 pre-trained.

    Args:
        num_classes: Numero di classi per il task target
        device: Device su cui caricare il modello

    Returns:
        Modello con backbone congelato e classificatore trainable
    """
    # 1. Carica il modello pre-addestrato
    weights = ResNet50_Weights.IMAGENET1K_V2
    model = models.resnet50(weights=weights)

    # 2. CONGELA tutti i parametri del backbone
    for param in model.parameters():
        param.requires_grad = False

    # 3. Sostituisci il classificatore finale
    # Il layer fc originale: Linear(2048 -> 1000)
    num_features = model.fc.in_features  # 2048

    model.fc = nn.Sequential(
        nn.Linear(num_features, 512),
        nn.ReLU(inplace=True),
        nn.Dropout(0.3),
        nn.Linear(512, num_classes)
    )
    # I nuovi layer hanno requires_grad=True di default

    # 4. Sposta sul device
    model = model.to(device)

    # Verifica parametri trainable vs congelati
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    total = sum(p.numel() for p in model.parameters())
    frozen = total - trainable
    print(f"Parametri totali:    {total:,}")
    print(f"Parametri congelati: {frozen:,} ({frozen/total*100:.1f}%)")
    print(f"Parametri trainable: {trainable:,} ({trainable/total*100:.1f}%)")

    return model


# Esempio: classificazione binaria (difettoso / non difettoso)
model = create_feature_extractor(num_classes=2)
# Output:
# Parametri totali:    24,607,170
# Parametri congelati: 23,508,032 (95.5%)
# Parametri trainable: 1,099,138  (4.5%)
    

5.3 Data Augmentation per Transfer Learning

La data augmentation e fondamentale nel Transfer Learning, specialmente con dataset piccoli. Le trasformazioni devono essere compatibili con il preprocessing del modello pre-addestrato: in particolare, la normalizzazione deve usare media e deviazione standard di ImageNet (mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]).

Pipeline di Data Augmentation

from torchvision import transforms
from torchvision.transforms import v2  # API v2 (PyTorch 2.0+)
from torch.utils.data import DataLoader
from torchvision.datasets import ImageFolder

# ---- Normalizzazione ImageNet (OBBLIGATORIA per modelli pre-trained) ----
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]

# ---- Trasformazioni per il training (con augmentation) ----
train_transforms = transforms.Compose([
    transforms.RandomResizedCrop(224, scale=(0.7, 1.0)),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomVerticalFlip(p=0.2),
    transforms.RandomRotation(degrees=15),
    transforms.ColorJitter(
        brightness=0.3,
        contrast=0.3,
        saturation=0.2,
        hue=0.1
    ),
    transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)),
    transforms.RandomGrayscale(p=0.05),
    transforms.ToTensor(),
    transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
    transforms.RandomErasing(p=0.2, scale=(0.02, 0.15)),
])

# ---- Trasformazioni per validazione/test (SENZA augmentation) ----
val_transforms = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
])

# ---- Caricamento dataset con ImageFolder ----
# Struttura directory richiesta:
#   data/
#     train/
#       classe_A/  (img1.jpg, img2.jpg, ...)
#       classe_B/  (img1.jpg, img2.jpg, ...)
#     val/
#       classe_A/  (...)
#       classe_B/  (...)

train_dataset = ImageFolder(root='data/train', transform=train_transforms)
val_dataset = ImageFolder(root='data/val', transform=val_transforms)

train_loader = DataLoader(
    train_dataset,
    batch_size=32,
    shuffle=True,
    num_workers=4,
    pin_memory=True,
    drop_last=True
)

val_loader = DataLoader(
    val_dataset,
    batch_size=64,
    shuffle=False,
    num_workers=4,
    pin_memory=True
)

print(f"Classi: {train_dataset.classes}")
print(f"Training: {len(train_dataset)} immagini")
print(f"Validation: {len(val_dataset)} immagini")
    

RandAugment e TrivialAugmentWide

Per semplificare la scelta delle augmentation, PyTorch offre policy automatiche. RandAugment applica N trasformazioni casuali con intensità M. TrivialAugmentWide applica una singola trasformazione con intensità casuale, ed e spesso più efficace di strategie complesse. Basta sostituire le trasformazioni manuali con una riga: transforms.TrivialAugmentWide() oppure transforms.RandAugment(num_ops=2, magnitude=9).

5.4 Training Loop Completo

Il training loop per il Transfer Learning e simile a quello standard, ma con alcune accortezze: learning rate più bassi, warm-up graduale e monitoraggio attento dell'overfitting.

Training Loop per Transfer Learning

import torch
import torch.nn as nn
from torch.optim import AdamW
from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts
from typing import Tuple
import time


def train_one_epoch(
    model: nn.Module,
    train_loader,
    criterion: nn.Module,
    optimizer: torch.optim.Optimizer,
    device: str,
    epoch: int
) -> Tuple[float, float]:
    """Addestra il modello per un'epoca."""
    model.train()
    running_loss = 0.0
    correct = 0
    total = 0

    for batch_idx, (images, labels) in enumerate(train_loader):
        images = images.to(device, non_blocking=True)
        labels = labels.to(device, non_blocking=True)

        # Forward pass
        outputs = model(images)
        loss = criterion(outputs, labels)

        # Backward pass
        optimizer.zero_grad(set_to_none=True)  # Più efficiente di zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
        optimizer.step()

        # Metriche
        running_loss += loss.item() * images.size(0)
        _, predicted = outputs.max(1)
        total += labels.size(0)
        correct += predicted.eq(labels).sum().item()

    epoch_loss = running_loss / total
    epoch_acc = 100.0 * correct / total
    return epoch_loss, epoch_acc


@torch.no_grad()
def evaluate(
    model: nn.Module,
    val_loader,
    criterion: nn.Module,
    device: str
) -> Tuple[float, float]:
    """Valuta il modello sul validation set."""
    model.eval()
    running_loss = 0.0
    correct = 0
    total = 0

    for images, labels in val_loader:
        images = images.to(device, non_blocking=True)
        labels = labels.to(device, non_blocking=True)

        outputs = model(images)
        loss = criterion(outputs, labels)

        running_loss += loss.item() * images.size(0)
        _, predicted = outputs.max(1)
        total += labels.size(0)
        correct += predicted.eq(labels).sum().item()

    val_loss = running_loss / total
    val_acc = 100.0 * correct / total
    return val_loss, val_acc


def train_transfer_learning(
    model: nn.Module,
    train_loader,
    val_loader,
    num_epochs: int = 30,
    lr: float = 1e-3,
    device: str = 'cuda'
) -> dict:
    """Pipeline completa di training per Transfer Learning."""

    model = model.to(device)

    # Ottimizzatore: solo parametri trainable (non congelati)
    trainable_params = [p for p in model.parameters() if p.requires_grad]
    optimizer = AdamW(trainable_params, lr=lr, weight_decay=1e-4)

    # Scheduler: cosine annealing con warm restarts
    scheduler = CosineAnnealingWarmRestarts(optimizer, T_0=10, T_mult=2)

    # Loss function con label smoothing
    criterion = nn.CrossEntropyLoss(label_smoothing=0.1)

    # Tracking
    best_val_acc = 0.0
    history = {'train_loss': [], 'val_loss': [], 'train_acc': [], 'val_acc': []}

    for epoch in range(num_epochs):
        start_time = time.time()

        # Training
        train_loss, train_acc = train_one_epoch(
            model, train_loader, criterion, optimizer, device, epoch
        )

        # Validation
        val_loss, val_acc = evaluate(model, val_loader, criterion, device)

        # Scheduler step
        scheduler.step()

        # Salvataggio metriche
        history['train_loss'].append(train_loss)
        history['val_loss'].append(val_loss)
        history['train_acc'].append(train_acc)
        history['val_acc'].append(val_acc)

        elapsed = time.time() - start_time

        print(
            f"Epoch {epoch+1}/{num_epochs} "
            f"[{elapsed:.1f}s] "
            f"Train: {train_loss:.4f} ({train_acc:.1f}%) | "
            f"Val: {val_loss:.4f} ({val_acc:.1f}%)"
        )

        # Salva il miglior modello
        if val_acc > best_val_acc:
            best_val_acc = val_acc
            torch.save({
                'epoch': epoch,
                'model_state_dict': model.state_dict(),
                'optimizer_state_dict': optimizer.state_dict(),
                'val_acc': val_acc,
            }, 'best_model.pth')
            print(f"  --> Nuovo miglior modello salvato! Val Acc: {val_acc:.2f}%")

    print(f"\nMiglior Val Accuracy: {best_val_acc:.2f}%")
    return history
    

6. Fine-Tuning Avanzato

Il fine-tuning basico (scongelamento di tutti i layer con un unico learning rate) spesso non e la strategia ottimale. Le tecniche avanzate permettono di ottenere performance significativamente migliori, specialmente con dataset di dimensioni medie.

6.1 Discriminative Learning Rates

L'idea e semplice ma potente: assegnare learning rate diversi a gruppi di layer diversi. I layer iniziali (che hanno imparato feature generiche) devono essere aggiornati molto poco, i layer intermedi un po' di più e il classificatore finale con il learning rate più alto. Questo preserva la conoscenza nei layer iniziali mentre adatta quelli finali.

Discriminative Learning Rates con ResNet-50

from torchvision import models
from torchvision.models import ResNet50_Weights
import torch.nn as nn
from torch.optim import AdamW


def create_finetuning_model(num_classes: int, device: str = 'cuda'):
    """Crea modello con gruppi di parametri per discriminative LR."""

    weights = ResNet50_Weights.IMAGENET1K_V2
    model = models.resnet50(weights=weights)

    # Sostituisci il classificatore
    num_features = model.fc.in_features
    model.fc = nn.Sequential(
        nn.Linear(num_features, 512),
        nn.ReLU(inplace=True),
        nn.Dropout(0.3),
        nn.Linear(512, num_classes)
    )

    model = model.to(device)

    # Definisci gruppi di parametri con LR diversi
    # Gruppo 1: Layer iniziali (conv1, bn1, layer1, layer2) - LR molto basso
    # Gruppo 2: Layer intermedi (layer3) - LR medio
    # Gruppo 3: Layer finali (layer4) - LR più alto
    # Gruppo 4: Classificatore (fc) - LR massimo

    base_lr = 1e-4

    param_groups = [
        {
            'params': list(model.conv1.parameters()) +
                      list(model.bn1.parameters()) +
                      list(model.layer1.parameters()) +
                      list(model.layer2.parameters()),
            'lr': base_lr * 0.01,    # 1e-6
            'name': 'early_layers'
        },
        {
            'params': list(model.layer3.parameters()),
            'lr': base_lr * 0.1,     # 1e-5
            'name': 'mid_layers'
        },
        {
            'params': list(model.layer4.parameters()),
            'lr': base_lr,            # 1e-4
            'name': 'late_layers'
        },
        {
            'params': list(model.fc.parameters()),
            'lr': base_lr * 10,       # 1e-3
            'name': 'classifier'
        },
    ]

    optimizer = AdamW(param_groups, weight_decay=1e-4)

    # Verifica i learning rate
    for group in optimizer.param_groups:
        n_params = sum(p.numel() for p in group['params'])
        print(f"{group.get('name', 'unknown'):<15} LR={group['lr']:.2e}  "
              f"Params={n_params:,}")

    return model, optimizer
    

6.2 Gradual Unfreezing

Invece di scongeleare tutti i layer contemporaneamente, lo scongelamento graduale procede dagli ultimi layer ai primi, epoca dopo epoca. Questo da al classificatore il tempo di adattarsi prima di modificare le feature sottostanti, evitando aggiornamenti distruttivi.

Gradual Unfreezing Strategy

import torch.nn as nn
from torchvision import models
from torchvision.models import ResNet50_Weights


def gradual_unfreeze_training(
    num_classes: int,
    train_loader,
    val_loader,
    device: str = 'cuda'
):
    """Training con scongelamento graduale dei layer."""

    # Carica modello e congela tutto
    weights = ResNet50_Weights.IMAGENET1K_V2
    model = models.resnet50(weights=weights)

    # Congela tutti i layer
    for param in model.parameters():
        param.requires_grad = False

    # Sostituisci il classificatore (trainable di default)
    model.fc = nn.Sequential(
        nn.Linear(model.fc.in_features, 512),
        nn.ReLU(inplace=True),
        nn.Dropout(0.3),
        nn.Linear(512, num_classes)
    )
    model = model.to(device)

    # Definisci le fasi di unfreezing
    unfreeze_schedule = [
        # (epoch_start, layers_to_unfreeze, learning_rate)
        (0,  [],                         1e-3),   # Solo classifier
        (5,  [model.layer4],             5e-4),   # + layer4
        (10, [model.layer3],             2e-4),   # + layer3
        (15, [model.layer2],             1e-4),   # + layer2
        (20, [model.layer1, model.conv1, model.bn1], 5e-5),  # Tutto
    ]

    criterion = nn.CrossEntropyLoss(label_smoothing=0.1)
    current_phase = 0

    for epoch in range(30):
        # Controlla se dobbiamo scongeleare nuovi layer
        if (current_phase < len(unfreeze_schedule) - 1 and
                epoch >= unfreeze_schedule[current_phase + 1][0]):
            current_phase += 1
            phase = unfreeze_schedule[current_phase]

            # Scongela i nuovi layer
            for layer_group in phase[1]:
                for param in layer_group.parameters():
                    param.requires_grad = True

            # Ricrea optimizer con nuovo LR
            trainable_params = [
                p for p in model.parameters() if p.requires_grad
            ]
            optimizer = torch.optim.AdamW(
                trainable_params, lr=phase[2], weight_decay=1e-4
            )

            n_trainable = sum(
                p.numel() for p in model.parameters() if p.requires_grad
            )
            print(f"\n=== Fase {current_phase}: Epoch {epoch} ===")
            print(f"Parametri trainable: {n_trainable:,}")
            print(f"Learning rate: {phase[2]:.2e}")

        elif epoch == 0:
            # Prima fase: solo classifier
            optimizer = torch.optim.AdamW(
                model.fc.parameters(), lr=1e-3, weight_decay=1e-4
            )

        # Training e validation standard
        # (usa le funzioni train_one_epoch e evaluate definite prima)
        train_loss, train_acc = train_one_epoch(
            model, train_loader, criterion, optimizer, device, epoch
        )
        val_loss, val_acc = evaluate(
            model, val_loader, criterion, device
        )

        print(
            f"Epoch {epoch+1}/30 | "
            f"Train: {train_loss:.4f} ({train_acc:.1f}%) | "
            f"Val: {val_loss:.4f} ({val_acc:.1f}%)"
        )
    

6.3 Learning Rate Warmup e Cosine Annealing

Un learning rate warmup graduale nei primi epoch previene aggiornamenti troppo aggressivi che potrebbero distruggere i pesi pre-addestrati. Dopo il warmup, un cosine annealing schedule riduce gradualmente il learning rate per un fine-tuning sempre più delicato.

Warmup + Cosine Annealing Scheduler

import math
from torch.optim.lr_scheduler import LambdaLR


def get_cosine_schedule_with_warmup(
    optimizer,
    num_warmup_steps: int,
    num_training_steps: int,
    min_lr_ratio: float = 0.01
):
    """Cosine annealing con linear warmup.

    Args:
        optimizer: L'ottimizzatore PyTorch
        num_warmup_steps: Numero di step di warmup
        num_training_steps: Numero totale di step di training
        min_lr_ratio: Rapporto LR minimo / LR massimo

    Returns:
        LambdaLR scheduler
    """
    def lr_lambda(current_step: int) -> float:
        # Fase di warmup: LR cresce linearmente da 0 a max
        if current_step < num_warmup_steps:
            return float(current_step) / float(max(1, num_warmup_steps))

        # Fase di cosine annealing: LR decresce con coseno
        progress = float(current_step - num_warmup_steps) / float(
            max(1, num_training_steps - num_warmup_steps)
        )
        cosine_decay = 0.5 * (1.0 + math.cos(math.pi * progress))
        return max(min_lr_ratio, cosine_decay)

    return LambdaLR(optimizer, lr_lambda)


# Esempio di utilizzo
num_epochs = 30
steps_per_epoch = len(train_loader)
total_steps = num_epochs * steps_per_epoch
warmup_steps = 3 * steps_per_epoch  # 3 epoche di warmup

scheduler = get_cosine_schedule_with_warmup(
    optimizer,
    num_warmup_steps=warmup_steps,
    num_training_steps=total_steps
)

# Nel training loop, chiama scheduler.step() DOPO ogni batch
# for batch in train_loader:
#     ...
#     optimizer.step()
#     scheduler.step()  # Step per batch, non per epoca
    
Visualizzazione LR Schedule

Learning Rate durante il Training (warmup + cosine annealing):

LR
 ^
 |         ___
 |       /     \
 |      /        \
 |     /            \
 |    /                \
 |   /                    \___
 |  /                          \___
 | /                                \___
 |/                                      \___
 +--+--------+--------+--------+--------+--->  Epoch
   0  warmup   5       10       20       30

Fase 1 (Epoch 0-3): Linear Warmup
  LR cresce linearmente da ~0 a LR_max

Fase 2 (Epoch 3-30): Cosine Annealing
  LR decresce seguendo una curva coseno fino a min_lr
    

6.4 Mixup e CutMix Augmentation

Mixup e CutMix sono tecniche di augmentation avanzate particolarmente efficaci nel fine-tuning. Mixup crea nuovi campioni come combinazione lineare di due immagini e le rispettive label. CutMix taglia e incolla rettangoli tra immagini diverse. Entrambe funzionano come regolarizzatori potenti e migliorano la generalizzazione.

Mixup e CutMix con torchvision

from torchvision.transforms import v2


# CutMix e MixUp applicati a livello di batch (non di singola immagine)
cutmix = v2.CutMix(num_classes=10)
mixup = v2.MixUp(num_classes=10, alpha=0.2)

# Combina entrambi con probabilità 50/50
cutmix_or_mixup = v2.RandomChoice([cutmix, mixup])

# Nel training loop:
# for images, labels in train_loader:
#     images, labels = cutmix_or_mixup(images, labels)
#     # Nota: labels ora sono soft (one-hot probabilistico)
#     # La CrossEntropyLoss gestisce automaticamente soft labels
#     outputs = model(images)
#     loss = criterion(outputs, labels)
    

7. Data Augmentation Avanzata per Transfer Learning

La scelta della data augmentation ha un impatto enorme sulle performance, specialmente con dataset piccoli. Vediamo le opzioni più efficaci nel 2025-2026.

7.1 Albumentations: Augmentation di Livello Professionale

Albumentations e una libreria specializzata per la data augmentation delle immagini, significativamente più veloce di torchvision.transforms grazie all'uso di OpenCV. Offre trasformazioni particolarmente utili per domini specializzati (medicale, industriale, satellitare).

Pipeline Albumentations per Transfer Learning

import albumentations as A
from albumentations.pytorch import ToTensorV2
import cv2
import numpy as np
from torch.utils.data import Dataset
from PIL import Image


def get_albumentations_transforms(is_training: bool = True):
    """Pipeline di augmentation con Albumentations."""

    if is_training:
        return A.Compose([
            A.RandomResizedCrop(height=224, width=224, scale=(0.7, 1.0)),
            A.HorizontalFlip(p=0.5),
            A.VerticalFlip(p=0.2),

            # Augmentation geometriche
            A.ShiftScaleRotate(
                shift_limit=0.1,
                scale_limit=0.15,
                rotate_limit=15,
                border_mode=cv2.BORDER_REFLECT_101,
                p=0.5
            ),

            # Augmentation di colore/illuminazione
            A.OneOf([
                A.RandomBrightnessContrast(
                    brightness_limit=0.3, contrast_limit=0.3, p=1.0
                ),
                A.HueSaturationValue(
                    hue_shift_limit=10, sat_shift_limit=30,
                    val_shift_limit=20, p=1.0
                ),
                A.CLAHE(clip_limit=4.0, p=1.0),
            ], p=0.7),

            # Rumore e blur
            A.OneOf([
                A.GaussNoise(var_limit=(10, 50), p=1.0),
                A.GaussianBlur(blur_limit=(3, 5), p=1.0),
                A.MotionBlur(blur_limit=5, p=1.0),
            ], p=0.3),

            # Dropout spaziale
            A.CoarseDropout(
                max_holes=8, max_height=16, max_width=16,
                min_holes=1, min_height=8, min_width=8,
                fill_value=0, p=0.3
            ),

            # Normalizzazione ImageNet
            A.Normalize(
                mean=[0.485, 0.456, 0.406],
                std=[0.229, 0.224, 0.225]
            ),
            ToTensorV2(),
        ])
    else:
        return A.Compose([
            A.Resize(256, 256),
            A.CenterCrop(224, 224),
            A.Normalize(
                mean=[0.485, 0.456, 0.406],
                std=[0.229, 0.224, 0.225]
            ),
            ToTensorV2(),
        ])


class AlbumentationsDataset(Dataset):
    """Dataset wrapper che usa Albumentations per le trasformazioni."""

    def __init__(self, image_paths: list, labels: list, transform=None):
        self.image_paths = image_paths
        self.labels = labels
        self.transform = transform

    def __len__(self) -> int:
        return len(self.image_paths)

    def __getitem__(self, idx: int):
        # Leggi immagine con OpenCV (BGR -> RGB)
        image = cv2.imread(self.image_paths[idx])
        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        label = self.labels[idx]

        if self.transform:
            augmented = self.transform(image=image)
            image = augmented['image']

        return image, label
    

8. Case Study: Classificazione Difetti Industriali con ResNet-50

Mettiamo tutto insieme con un caso d'uso reale: un sistema di quality inspection industriale che classifica difetti in componenti elettronici. Il dataset contiene immagini ad alta risoluzione di circuiti stampati (PCB) con quattro classi: OK (nessun difetto), solder_bridge (ponte di saldatura), missing_component (componente mancante) e scratch (graffio).

Setup del Case Study

  • Dataset: 4000 immagini (1000 per classe), split 70/15/15
  • Risoluzione: 512x512 pixel, RGB
  • Modello: ResNet-50 pre-trained su ImageNet (V2 weights)
  • Strategia: Feature extraction -> Fine-tuning graduale
  • Hardware: GPU NVIDIA con 8GB+ VRAM
Pipeline Completa: Difetti Industriali con ResNet-50

"""
Pipeline completa per classificazione difetti industriali.
Transfer Learning con ResNet-50 e gradual unfreezing.
"""

import torch
import torch.nn as nn
from torch.optim import AdamW
from torch.utils.data import DataLoader
from torchvision import models, transforms
from torchvision.models import ResNet50_Weights
from torchvision.datasets import ImageFolder
from sklearn.metrics import (
    classification_report,
    confusion_matrix,
    f1_score
)
import numpy as np
from pathlib import Path


# ============================================================
# 1. CONFIGURAZIONE
# ============================================================
class Config:
    """Configurazione centralizzata del training."""
    data_dir = Path('data/pcb_defects')
    num_classes = 4
    class_names = ['OK', 'missing_component', 'scratch', 'solder_bridge']

    # Training
    batch_size = 32
    num_workers = 4
    phase1_epochs = 10   # Feature extraction
    phase2_epochs = 15   # Fine-tuning layer4
    phase3_epochs = 10   # Full fine-tuning
    phase1_lr = 1e-3
    phase2_lr = 1e-4
    phase3_lr = 1e-5

    # Device
    device = 'cuda' if torch.cuda.is_available() else 'cpu'


# ============================================================
# 2. PREPARAZIONE DATI
# ============================================================
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]

train_transforms = transforms.Compose([
    transforms.RandomResizedCrop(224, scale=(0.7, 1.0)),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomVerticalFlip(p=0.5),
    transforms.RandomRotation(degrees=20),
    transforms.ColorJitter(brightness=0.3, contrast=0.3),
    transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)),
    transforms.ToTensor(),
    transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
    transforms.RandomErasing(p=0.2),
])

val_transforms = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
])

cfg = Config()
train_dataset = ImageFolder(cfg.data_dir / 'train', transform=train_transforms)
val_dataset = ImageFolder(cfg.data_dir / 'val', transform=val_transforms)
test_dataset = ImageFolder(cfg.data_dir / 'test', transform=val_transforms)

train_loader = DataLoader(
    train_dataset, batch_size=cfg.batch_size,
    shuffle=True, num_workers=cfg.num_workers, pin_memory=True
)
val_loader = DataLoader(
    val_dataset, batch_size=cfg.batch_size * 2,
    shuffle=False, num_workers=cfg.num_workers, pin_memory=True
)
test_loader = DataLoader(
    test_dataset, batch_size=cfg.batch_size * 2,
    shuffle=False, num_workers=cfg.num_workers, pin_memory=True
)


# ============================================================
# 3. MODELLO
# ============================================================
def build_model(num_classes: int, device: str) -> nn.Module:
    """Crea il modello ResNet-50 per il Transfer Learning."""
    weights = ResNet50_Weights.IMAGENET1K_V2
    model = models.resnet50(weights=weights)

    # Congela tutto
    for param in model.parameters():
        param.requires_grad = False

    # Nuovo classificatore
    model.fc = nn.Sequential(
        nn.Linear(2048, 512),
        nn.ReLU(inplace=True),
        nn.Dropout(0.4),
        nn.Linear(512, 128),
        nn.ReLU(inplace=True),
        nn.Dropout(0.2),
        nn.Linear(128, num_classes)
    )

    return model.to(device)


# ============================================================
# 4. TRAINING MULTI-FASE
# ============================================================
def run_training():
    """Esegue il training multi-fase con gradual unfreezing."""

    model = build_model(cfg.num_classes, cfg.device)
    criterion = nn.CrossEntropyLoss(label_smoothing=0.1)
    best_val_acc = 0.0

    # ---- Fase 1: Feature Extraction ----
    print("=" * 60)
    print("FASE 1: Feature Extraction (solo classifier)")
    print("=" * 60)

    optimizer = AdamW(model.fc.parameters(), lr=cfg.phase1_lr, weight_decay=1e-4)

    for epoch in range(cfg.phase1_epochs):
        train_loss, train_acc = train_one_epoch(
            model, train_loader, criterion, optimizer, cfg.device, epoch
        )
        val_loss, val_acc = evaluate(
            model, val_loader, criterion, cfg.device
        )
        print(f"  Epoch {epoch+1}/{cfg.phase1_epochs} | "
              f"Train: {train_acc:.1f}% | Val: {val_acc:.1f}%")

        if val_acc > best_val_acc:
            best_val_acc = val_acc
            torch.save(model.state_dict(), 'best_model.pth')

    # ---- Fase 2: Fine-tuning layer4 ----
    print("\n" + "=" * 60)
    print("FASE 2: Fine-tuning layer4 + classifier")
    print("=" * 60)

    for param in model.layer4.parameters():
        param.requires_grad = True

    optimizer = AdamW([
        {'params': model.layer4.parameters(), 'lr': cfg.phase2_lr * 0.1},
        {'params': model.fc.parameters(), 'lr': cfg.phase2_lr},
    ], weight_decay=1e-4)

    for epoch in range(cfg.phase2_epochs):
        train_loss, train_acc = train_one_epoch(
            model, train_loader, criterion, optimizer, cfg.device, epoch
        )
        val_loss, val_acc = evaluate(
            model, val_loader, criterion, cfg.device
        )
        print(f"  Epoch {epoch+1}/{cfg.phase2_epochs} | "
              f"Train: {train_acc:.1f}% | Val: {val_acc:.1f}%")

        if val_acc > best_val_acc:
            best_val_acc = val_acc
            torch.save(model.state_dict(), 'best_model.pth')

    # ---- Fase 3: Full Fine-tuning ----
    print("\n" + "=" * 60)
    print("FASE 3: Full Fine-tuning (tutti i layer)")
    print("=" * 60)

    for param in model.parameters():
        param.requires_grad = True

    optimizer = AdamW([
        {'params': model.conv1.parameters(), 'lr': cfg.phase3_lr * 0.01},
        {'params': model.layer1.parameters(), 'lr': cfg.phase3_lr * 0.1},
        {'params': model.layer2.parameters(), 'lr': cfg.phase3_lr * 0.1},
        {'params': model.layer3.parameters(), 'lr': cfg.phase3_lr},
        {'params': model.layer4.parameters(), 'lr': cfg.phase3_lr * 2},
        {'params': model.fc.parameters(), 'lr': cfg.phase3_lr * 10},
    ], weight_decay=1e-4)

    for epoch in range(cfg.phase3_epochs):
        train_loss, train_acc = train_one_epoch(
            model, train_loader, criterion, optimizer, cfg.device, epoch
        )
        val_loss, val_acc = evaluate(
            model, val_loader, criterion, cfg.device
        )
        print(f"  Epoch {epoch+1}/{cfg.phase3_epochs} | "
              f"Train: {train_acc:.1f}% | Val: {val_acc:.1f}%")

        if val_acc > best_val_acc:
            best_val_acc = val_acc
            torch.save(model.state_dict(), 'best_model.pth')

    print(f"\nMiglior Val Accuracy: {best_val_acc:.2f}%")
    return model
    

8.1 Evaluation e Metriche

Per un sistema di quality inspection, l'accuracy globale non basta. Dobbiamo analizzare precision, recall e F1-score per ogni classe di difetto. Un falso negativo (difetto non rilevato) ha un costo molto superiore a un falso positivo (pezzo buono scartato).

Evaluation Completa con Confusion Matrix

@torch.no_grad()
def full_evaluation(
    model: nn.Module,
    test_loader,
    class_names: list,
    device: str = 'cuda'
) -> dict:
    """Valutazione completa con metriche dettagliate."""

    model.eval()
    all_preds = []
    all_labels = []
    all_probs = []

    for images, labels in test_loader:
        images = images.to(device)
        outputs = model(images)
        probs = torch.softmax(outputs, dim=1)

        _, predicted = outputs.max(1)
        all_preds.extend(predicted.cpu().numpy())
        all_labels.extend(labels.numpy())
        all_probs.extend(probs.cpu().numpy())

    all_preds = np.array(all_preds)
    all_labels = np.array(all_labels)

    # Classification Report
    report = classification_report(
        all_labels, all_preds,
        target_names=class_names,
        digits=4
    )
    print("Classification Report:")
    print(report)

    # Confusion Matrix
    cm = confusion_matrix(all_labels, all_preds)
    print("\nConfusion Matrix:")
    print(f"{'':.<15} " + " ".join(f"{name:>12}" for name in class_names))
    for i, row in enumerate(cm):
        print(f"{class_names[i]:<15} " + " ".join(f"{val:>12d}" for val in row))

    # F1-Score (macro e weighted)
    f1_macro = f1_score(all_labels, all_preds, average='macro')
    f1_weighted = f1_score(all_labels, all_preds, average='weighted')
    print(f"\nF1-Score Macro:    {f1_macro:.4f}")
    print(f"F1-Score Weighted: {f1_weighted:.4f}")

    return {
        'report': report,
        'confusion_matrix': cm,
        'f1_macro': f1_macro,
        'f1_weighted': f1_weighted,
        'predictions': all_preds,
        'labels': all_labels,
        'probabilities': np.array(all_probs)
    }


# Esegui la valutazione
model.load_state_dict(torch.load('best_model.pth', weights_only=True))
results = full_evaluation(model, test_loader, Config.class_names)
    

8.2 Deployment con TorchScript

Per portare il modello in produzione, convertiamo in TorchScript: un formato ottimizzato che può essere eseguito senza Python, ideale per l'integrazione in sistemi C++, applicazioni mobile o server di inference ad alte prestazioni.

Export con TorchScript e ONNX

import torch


def export_model(model: torch.nn.Module, save_dir: str = 'exported_models'):
    """Esporta il modello in formati ottimizzati per la produzione."""
    from pathlib import Path

    save_path = Path(save_dir)
    save_path.mkdir(exist_ok=True)

    model.eval()
    model = model.cpu()
    dummy_input = torch.randn(1, 3, 224, 224)

    # ---- TorchScript (tracing) ----
    scripted = torch.jit.trace(model, dummy_input)
    scripted_path = save_path / 'model_scripted.pt'
    scripted.save(str(scripted_path))
    print(f"TorchScript salvato: {scripted_path} "
          f"({scripted_path.stat().st_size / 1024 / 1024:.1f} MB)")

    # ---- ONNX ----
    onnx_path = save_path / 'model.onnx'
    torch.onnx.export(
        model,
        dummy_input,
        str(onnx_path),
        input_names=['input'],
        output_names=['output'],
        dynamic_axes={
            'input': {0: 'batch_size'},
            'output': {0: 'batch_size'},
        },
        opset_version=17
    )
    print(f"ONNX salvato: {onnx_path} "
          f"({onnx_path.stat().st_size / 1024 / 1024:.1f} MB)")

    # ---- Verifica ----
    loaded = torch.jit.load(str(scripted_path))
    with torch.no_grad():
        orig_out = model(dummy_input)
        loaded_out = loaded(dummy_input)
        diff = (orig_out - loaded_out).abs().max().item()
        print(f"Differenza max originale vs TorchScript: {diff:.2e}")

    return scripted_path, onnx_path


export_model(model)
    

9. Transfer Learning per Object Detection

Il Transfer Learning non si limita alla classificazione. Tutti i principali framework di object detection utilizzano backbone pre-addestrati come estrattori di feature. Vediamo come applicare il fine-tuning a Faster R-CNN e YOLO.

9.1 Fine-tuning Faster R-CNN

Faster R-CNN con backbone ResNet-50-FPN e il detector di riferimento in torchvision. La FPN (Feature Pyramid Network) estrae feature multi-scala, fondamentali per rilevare oggetti di dimensioni diverse. Il fine-tuning richiede di sostituire la testa di classificazione del box predictor.

Fine-tuning Faster R-CNN con torchvision

import torchvision
from torchvision.models.detection import (
    fasterrcnn_resnet50_fpn_v2,
    FasterRCNN_ResNet50_FPN_V2_Weights
)
from torchvision.models.detection.faster_rcnn import FastRCNNPredictor


def get_detection_model(num_classes: int) -> torchvision.models.detection.FasterRCNN:
    """Crea un Faster R-CNN pre-trained per il fine-tuning.

    Args:
        num_classes: Numero di classi target + 1 (background)

    Returns:
        Modello Faster R-CNN pronto per il fine-tuning
    """
    # Carica il modello pre-trained su COCO (91 classi)
    weights = FasterRCNN_ResNet50_FPN_V2_Weights.DEFAULT
    model = fasterrcnn_resnet50_fpn_v2(weights=weights)

    # Sostituisci la testa del box predictor
    in_features = model.roi_heads.box_predictor.cls_score.in_features
    model.roi_heads.box_predictor = FastRCNNPredictor(
        in_features, num_classes
    )

    return model


# Esempio: rilevamento difetti (3 tipi + background = 4 classi)
det_model = get_detection_model(num_classes=4)

# Training: Faster R-CNN usa una loss composita
# (classification_loss + box_regression_loss + RPN losses)
# Non serve definire la loss manualmente: model(images, targets)
# restituisce direttamente le losses in training mode
    

9.2 Fine-tuning YOLO

Per YOLO, il Transfer Learning e ancora più semplice grazie alla CLI di Ultralytics. YOLO usa un backbone pre-addestrato (spesso CSPDarknet o varianti) e supporta il fine-tuning con una singola riga di comando o poche righe di codice.

Fine-tuning YOLOv8/YOLO11 con Ultralytics

from ultralytics import YOLO

# Carica modello pre-trained su COCO
model = YOLO('yolo11n.pt')  # nano (più veloce) o yolo11s.pt, yolo11m.pt

# Fine-tuning sul tuo dataset
# Il dataset deve essere in formato YOLO (images/ + labels/ con .txt)
results = model.train(
    data='pcb_defects.yaml',    # Config del dataset
    epochs=100,
    imgsz=640,
    batch=16,
    lr0=0.001,                  # Learning rate iniziale
    lrf=0.01,                   # Learning rate finale (ratio)
    warmup_epochs=3,
    freeze=10,                  # Congela i primi 10 layer del backbone
    augment=True,               # Augmentation automatica
    patience=20,                # Early stopping
    device='0'                  # GPU 0
)

# Valutazione sul test set
metrics = model.val(data='pcb_defects.yaml', split='test')
print(f"mAP50:    {metrics.box.map50:.4f}")
print(f"mAP50-95: {metrics.box.map:.4f}")
    

10. Errori Comuni e Come Evitarli

Il Transfer Learning sembra semplice in teoria, ma nella pratica ci sono molte trappole che possono rovinare le performance. Ecco gli errori più frequenti.

Top 10 Errori nel Transfer Learning

# Errore Conseguenza Soluzione
1 Learning rate troppo alto Distrugge i pesi pre-addestrati Usa LR 10-100x più basso del training da zero (1e-4 o meno)
2 Normalizzazione sbagliata Feature completamente errate Usa SEMPRE mean/std di ImageNet per modelli pre-trained su ImageNet
3 Nessuna data augmentation Overfitting con dataset piccoli Augmentation aggressiva: flip, rotation, color jitter, CutMix
4 BatchNorm non in eval mode Statistiche instabili con batch piccoli model.eval() per le feature frozen, o congela esplicitamente i BN
5 Immagini di input non ridimensionate Errori o performance pessime Resize a 224x224 (o la dimensione attesa dal modello)
6 Fine-tuning senza warmup Aggiornamenti iniziali troppo aggressivi Warmup lineare per 3-5 epoche
7 Stesso LR per tutti i layer Layer iniziali sovra-modificati Discriminative learning rates
8 Dataset troppo piccolo senza regolarizzazione Overfitting grave Dropout, weight decay, label smoothing, early stopping
9 Non salvare il miglior modello Usa il modello dell'ultimo epoch (overfittato) Model checkpoint basato su val accuracy/loss
10 Ignorare lo sbilanciamento classi Modello biased verso la classe maggioritaria Weighted loss, oversampling, Focal Loss
Gestione BatchNorm durante il Fine-tuning

def freeze_batchnorm(model: nn.Module):
    """Congela i layer BatchNorm anche quando il modello e in train mode.

    Importante quando si fa fine-tuning con batch size piccoli:
    le statistiche di batch sarebbero instabili e rovinerebbero
    le running_mean e running_var accumulate durante il pre-training.
    """
    for module in model.modules():
        if isinstance(module, (nn.BatchNorm2d, nn.BatchNorm1d)):
            module.eval()        # Usa running stats, non batch stats
            module.weight.requires_grad = False
            module.bias.requires_grad = False


# Utilizzo nel training loop:
model.train()
freeze_batchnorm(model)  # BN resta in eval mode
    

11. Decision Tree: Scegliere Modello e Strategia

Per semplificare la scelta, ecco un albero decisionale pratico che copre i casi d'uso più comuni.

Albero Decisionale per il Transfer Learning

                    HAI UN DATASET ETICHETTATO?
                    /                       \
                 SI                          NO
                 /                             \
          Quante immagini?               Usa CLIP (zero-shot)
         /        |         \            oppure DINOv2 + kNN
    <500      500-10k      >10k
      |          |            |
  Feature     Partial      Full
  Extraction  Fine-tune    Fine-tune
      |          |            |
  Modello?    Modello?     Modello?
      |          |            |
      v          v            v
  Serve velocità?         Serve massima accuracy?
   /          \              /          \
  SI          NO            SI          NO
  |            |            |            |
EfficientNet ConvNeXt-T  ConvNeXt-B   ResNet-50
-B0/B1       o ResNet-50  o ViT-L     (baseline)

  Serve multi-scala (detection/segmentation)?
   --> Swin Transformer o backbone con FPN

  Dominio molto diverso da ImageNet?
   --> DINOv2 (self-supervised) come backbone

  Budget computazionale limitato?
   --> EfficientNet-B0 con feature extraction
    

Raccomandazione Pratica 2025-2026

Per la maggior parte dei progetti di computer vision, inizia con ResNet-50 V2 come baseline. Se hai bisogno di più accuratezza, passa a ConvNeXt-T (miglior trade-off accuracy/velocità). Per task con pochissimi dati, usa DINOv2 come feature extractor. Per deployment su edge/mobile, scegli EfficientNet-B0 o MobileNet-V3.

12. Conclusioni

Il Transfer Learning ha democratizzato la computer vision. Quello che una volta richiedeva milioni di immagini, settimane di training e hardware costoso, oggi può essere realizzato con poche centinaia di immagini, una singola GPU e poche ore di lavoro. Le intuizioni chiave di questo articolo sono:

  • Le feature delle CNN sono gerarchiche e trasferibili: i primi layer catturano pattern universali (bordi, texture), gli ultimi sono task-specifici. Questo rende il Transfer Learning possibile ed efficace.
  • La strategia dipende da dati e dominio: pochi dati in dominio simile? Feature extraction. Molti dati in dominio diverso? Full fine-tuning con cautela. La matrice a 4 quadranti guida la decisione.
  • I dettagli fanno la differenza: discriminative learning rates, gradual unfreezing, warmup, normalizzazione corretta e data augmentation appropriata possono migliorare le performance di 5-15% rispetto a un fine-tuning naive.
  • L'ecosistema e maturo: PyTorch, torchvision e Ultralytics offrono API semplici e modelli pre-addestrati di alta qualità. Non c'è quasi mai una ragione per addestrare da zero.

Nel prossimo articolo della serie, applicheremo il Transfer Learning all'Object Detection con YOLO: non solo classificare cosa c'è in un'immagine, ma anche dove si trova, con bounding box in tempo reale.

Prossimo Articolo

Articolo 3: Object Detection con YOLO - Rilevamento oggetti in tempo reale, architettura YOLO, anchor-free detection, training su dataset custom e deployment per applicazioni real-time.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti