Cos'è un AutoEncoder? Guida completa
Computer Vision 101

Un autoencoder è un'architettura neurale utilizzata per apprendere una rappresentazione compressa dell'input e ricostruirlo a partire da esso.
È un'architettura storica, molto usata nel mondo ML. In computer vision, prima dell'avvento dei Transformer e dei Diffusion Model, era lo standard de facto per la riduzione della dimensionalità.
L'Architettura di un AutoEncoder
E' costituito da un blocco Encoder, CNN o Transformer che sia, seguito da una componente cruciale chiamata Collo di Bottiglia (Bottleneck) e infine da un Decoder.
Ecco come interagiscono:
- Encoder: Prende il dato originale (ad esempio un'immagine ad alta risoluzione) e lo comprime progressivamente, riducendone le dimensioni spaziali ma aumentandone la profondità delle feature.
- Bottleneck (Spazio Latente): È il punto più "stretto" della rete. Qui risiede il vettore latente, una rappresentazione compressa che contiene solo le informazioni essenziali dell'input, scartando il rumore e i dettagli superflui.
- Decoder: Speculare all'Encoder, prende il vettore latente e tenta di ricostruire il dato originale, riportandolo alle dimensioni iniziali.
Il Processo di Addestramento
L'addestramento di un AutoEncoder avviene in modalità Self-Supervised (auto-supervisionata).
A differenza della classificazione classica, qui non abbiamo bisogno di etichette umane (label). L'obiettivo della rete è l'identità: l'input funge anche da target! Durante il training, la rete cerca di minimizzare la differenza tra l'immagine in ingresso e quella ricostruita in uscita. Costringendo i dati a passare attraverso il bottleneck, impediamo alla rete di imparare semplicemente a "copiare" pixel per pixel (memorizzazione), forzandola invece ad apprendere le feature strutturali (forme, pattern, semantica) per poter ricostruire l'immagine partendo da poche informazioni.
Casi d'Uso Pratici
Praticamente un Autoencoder può servire a:
- Riduzione della Dimensionalità: Simile alla PCA (Principal Component Analysis), ma non lineare e molto più potente. Utile per visualizzare dati complessi in 2D o 3D. A partire dal modello originale, si considera esclusivamente l'Encoder: il bottleneck può essere utilizzato come un tensore efficiente e ricco di informazioni da unire ad un blocco per la predizione.
- Denoising (Rimozione Rumore): Si addestra la rete fornendo in input immagini "sporche" o rumorose e imponendo come target l'immagine pulita. L'Autoencoder impara a ignorare il rumore statico.
- Anomaly Detection: Molto usato nell'industria. Se addestri un AE solo su prodotti "sani", quando gli mostri un prodotto difettoso, non riuscirà a ricostruirlo bene (avrà un errore di ricostruzione alto), segnalando così l'anomalia.
- Compressione Dati: Per trasmettere meno dati salvando solo il vettore latente (anche se per la compressione standard come JPEG si usano altri algoritmi, gli AE sono promettenti per la compressione semantica).
Implementazione e Loss Function di un Autoencoder
La funzione di costo (Loss Function) standard e semplice per le immagini è solitamente l'errore quadratico medio (MSE Loss), che calcola la media delle differenze al quadrato tra i pixel originali e quelli ricostruiti.
Ecco un esempio essenziale di un Autoencoder convoluzionale in PyTorch:
import torch
import torch.nn as nn
class AutoEncoder224(nn.Module):
def __init__(self):
super(AutoEncoder224, self).__init__()
# ENCODER
# L'obiettivo è ridurre le dimensioni spaziali (H, W) e aumentare la profondità (Channels)
# Input atteso: [Batch_Size, 3, 224, 224] (Immagini RGB)
self.encoder = nn.Sequential(
# Layer 1: Input [B, 3, 224, 224] -> Output [B, 32, 112, 112]
nn.Conv2d(3, 32, kernel_size=3, stride=2, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(),
# Layer 2: Input [B, 32, 112, 112] -> Output [B, 64, 56, 56]
nn.Conv2d(32, 64, kernel_size=3, stride=2, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(),
# Layer 3: Input [B, 64, 56, 56] -> Output [B, 128, 28, 28]
nn.Conv2d(64, 128, kernel_size=3, stride=2, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(),
# Layer 4: Input [B, 128, 28, 28] -> Output [B, 256, 14, 14]
nn.Conv2d(128, 256, kernel_size=3, stride=2, padding=1),
nn.BatchNorm2d(256),
nn.ReLU(),
# Layer 5 (Verso il Bottleneck): Input [B, 256, 14, 14] -> Output [B, 512, 7, 7]
nn.Conv2d(256, 512, kernel_size=3, stride=2, padding=1),
nn.BatchNorm2d(512),
nn.ReLU()
)
# BOTTLENECK
# A questo punto, l'immagine originale 3x224x224 (150,528 valori per pixel)
# è stata compressa in una rappresentazione compressa di shape:
# [Batch_Size, 512, 7, 7] (25,088 valori per "immagine compressa") !!
# Abbiamo una compressione di circa 6 volte, mantenendo le feature più astratte.
# DECODER
# L'obiettivo è fare l'operazione inversa: upsampling per tornare a 224x224.
# Usiamo ConvTranspose2d. La combinazione kernel=3, stride=2, padding=1, output_padding=1
# serve a raddoppiare esattamente le dimensioni spaziali.
self.decoder = nn.Sequential(
# Layer 1 (Up): Input [B, 512, 7, 7] -> Output [B, 256, 14, 14]
nn.ConvTranspose2d(512, 256, kernel_size=3, stride=2, padding=1, output_padding=1),
nn.BatchNorm2d(256),
nn.ReLU(),
# Layer 2 (Up): Input [B, 256, 14, 14] -> Output [B, 128, 28, 28]
nn.ConvTranspose2d(256, 128, kernel_size=3, stride=2, padding=1, output_padding=1),
nn.BatchNorm2d(128),
nn.ReLU(),
# Layer 3 (Up): Input [B, 128, 28, 28] -> Output [B, 64, 56, 56]
nn.ConvTranspose2d(128, 64, kernel_size=3, stride=2, padding=1, output_padding=1),
nn.BatchNorm2d(64),
nn.ReLU(),
# Layer 4 (Up): Input [B, 64, 56, 56] -> Output [B, 32, 112, 112]
nn.ConvTranspose2d(64, 32, kernel_size=3, stride=2, padding=1, output_padding=1),
nn.BatchNorm2d(32),
nn.ReLU(),
# Layer 5 (Final Output): Input [B, 32, 112, 112] -> Output [B, 3, 224, 224]
# Nota: output_padding=1 non serve qui perché 112*2 = 224 esatto con kernel 3 e padding 1.
# Riduciamo i canali a 3 (RGB)
nn.ConvTranspose2d(32, 3, kernel_size=3, stride=2, padding=1, output_padding=1),
# Usiamo Sigmoid finale per forzare l'output tra [0, 1], assumendo che l'input sia normalizzato ;)
nn.Sigmoid()
)
def forward(self, x):
# Passaggio attraverso l'encoder
encoded = self.encoder(x)
# Passaggio attraverso il decoder
decoded = self.decoder(encoded)
return decoded
# Metodo helper per ispezionare la shape del bottleneck senza fare un forward completo
def get_latent_shape(self, x):
with torch.no_grad():
encoded = self.encoder(x)
return encoded.shape
if __name__ == '__main__':
# Creiamo un tensore dummy che simula un batch di 4 immagini RGB 224x224
dummy_input = torch.randn(4, 3, 224, 224)
# Istanziamo il modello
model = Autoencoder224()
# Eseguiamo un forward pass
output = model(dummy_input)
print(f"Shape dell'input originale: {dummy_input.shape}")
# Verifica della shape del bottleneck
latent_shape = model.get_latent_shape(dummy_input)
print(f"Shape del collo di bottiglia (Spazio Latente): {latent_shape}")
# Output atteso: torch.Size([4, 512, 7, 7])
print(f"Shape dell'output ricostruito: {output.shape}")
# Output atteso: torch.Size([4, 3, 224, 224])
# Verifica che le dimensioni siano tornate quelle originali
assert dummy_input.shape == output.shape
print("\nTest superato: L'immagine ricostruita ha le stesse dimensioni dell'input.")
Curiosità sugli Autoencoder
Se rimuoviamo tutte le funzioni di attivazione non lineari (come ReLU o Sigmoid) tra i vari strati, riducendo la rete a semplici moltiplicazioni di matrici, l'Autoencoder finisce per comportarsi quasi esattamente come la PCA (Analisi delle Componenti Principali)! In pratica, sotto queste condizioni limitanti, lo spazio latente impara a proiettare i dati sugli stessi assi di varianza della PCA. Questo ci fa capire che l'Autoencoder è, di fatto, una generalizzazione non-lineare e molto più potente delle tecniche statistiche classiche.
Il loro peggior nemico è una capacità eccessiva. Se il "collo di bottiglia" (lo spazio latente) è troppo grande, ovvero ha troppe dimensioni rispetto alla complessità dei dati, la rete diventa "pigra". Invece di imparare a estrarre pattern significativi e comprimere l'informazione, impara semplicemente a copiare l'input in output (impara la funzione identità a memoria).
Gli Autoencoder un tempo erano usati non per generare immagini, ma per permettere alle reti profonde di esistere. Prima dell'invenzione di tecniche moderne come la Batch Normalization o le connessioni residuali (ResNet), era quasi impossibile addestrare reti molto profonde da zero. I ricercatori usavano quindi gli Stacked Autoencoders per addestrare la rete "a strati" (layer-wise pre-training): si addestrava un piccolo autoencoder, si congelavano i pesi, se ne aggiungeva un altro sopra, e così via. Solo alla fine si usava la rete per la classificazione. Di fatto, gli Autoencoder hanno tenuto in vita il Deep Learning durante i suoi anni più difficili :)