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 |
|---|---|---|
| 1 | CNN: Reti Convoluzionali | Architettura, training, deployment |
| 2 | Sei qui - Transfer Learning e Fine-Tuning | Modelli pre-addestrati, domain adaptation |
| 3 | Object Detection con YOLO | Rilevamento oggetti in tempo reale |
| 4 | Segmentazione Semantica | Classificazione a livello di pixel |
| 5 | Image Generation con GAN e Diffusion | Generazione immagini sintetiche |
| 6 | Edge Deployment e Ottimizzazione | Modelli 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.
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).
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.
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.
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.
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.
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]).
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.
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.
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.
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.
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
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.
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).
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 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).
@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.
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.
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.
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 |
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.
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.







