Nella lezione precedente hai ricavato l'ELBO e la formula della KL; qui li trasformi in codice. Un VAE completo, in PyTorch, sta in una trentina di righe: un encoder che produce μ e log σ², una riga di riparametrizzazione, un decoder, e una perdita che somma ricostruzione e KL. Il lavoro vero è capire che cosa misurare: quanti nat di informazione porta il codice, se i campioni da N(0, I) sono davvero cifre, se tutte le dieci cifre compaiono, se interpolare nello spazio latente produce passaggi graduali, quante dimensioni del codice servono davvero. Tutti i numeri qui sotto vengono da esecuzioni reali, eseguite due volte con uscite identiche, su cifre scritte a mano 8×8 e non su MNIST: l'esecuzione non ha scaricato dataset. Il risultato più istruttivo non è quello che mi aspettavo, e lo racconto come è venuto.
Le parole di questa lezione
Questa lezione usa i termini della precedente (prior, posterior, ELBO, KL, riparametrizzazione, encoder, decoder) e ne aggiunge pochi. Il logit di un pixel è il numero reale che il decoder produce prima di trasformarlo in probabilità con la sigmoide. L'entropia incrociata binaria (BCE) è la perdita −[x·log p + (1 − x)·log(1 − p)] per un pixel con valore x e probabilità prevista p: sommata sui pixel è l'opposto del logaritmo della verosimiglianza di Bernoulli, il termine di ricostruzione dell'ELBO per immagini in bianco e nero. I nat per immagine sono l'unità in cui misuriamo la perdita (logaritmo naturale), sommata sui pixel e mediata sulle immagini. Campionare dal prior significa estrarre z ~ N(0, I) e passarlo al decoder, l'operazione che l'autoencoder normale non poteva fare con senso.
L'interpolazione latente decodifica i punti sul segmento fra i codici di due immagini. Una dimensione latente attiva è una componente di z il cui valore medio varia davvero da un'immagine all'altra. Il giudice, infine, è un classificatore di cifre addestrato a parte che usiamo per «guardare» le immagini generate: non misura la qualità percettiva, ma permette di contare diversità e coerenza.
L'analogia: l'apprendista diventato pasticciere
Nella lezione precedente l'apprendista (encoder) guardava una torta e proponeva «quale bigliettino di ricetta» l'ha prodotta, con un margine di incertezza; il pasticciere (decoder) imparava a preparare la torta dal bigliettino. Ora, a lavoro finito, congediamo l'apprendista, peschiamo un bigliettino dal barattolo N(0, I) e chiediamo al pasticciere di preparare una torta nuova. Quel che speriamo è che il barattolo e le ricette che il pasticciere ha davvero imparato coincidano: ecco perché l'apprendista era stato obbligato, con la KL, a non proporre mai bigliettini fuori dal barattolo.
Il modello, riga per riga
L'architettura è volutamente piccola: un solo strato nascosto da 128 unità per l'encoder, due «teste» lineari che producono μ e log σ² a 10 dimensioni, e un decoder simmetrico che produce 64 logit. I dati sono le 1797 cifre di load_digits di scikit-learn (valori da 0 a 16, qui riportati in [0, 1]), divise 70/30 con stratificazione: 1257 per l'addestramento, 540 per il test.
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
torch.set_num_threads(2)
X, y = load_digits(return_X_y=True) # 1797 digits, 8x8 pixels, values 0..16
Xtr, Xte, ytr, yte = train_test_split(X / 16.0, y, test_size=0.3, random_state=0, stratify=y)
Xtr, Xte = torch.tensor(Xtr, dtype=torch.float32), torch.tensor(Xte, dtype=torch.float32)
ytr, yte = torch.tensor(ytr), torch.tensor(yte)
D, LATENT = 64, 10
class VAE(nn.Module):
def __init__(self, latent=LATENT, hidden=128):
super().__init__()
self.enc = nn.Sequential(nn.Linear(D, hidden), nn.ReLU())
self.mu = nn.Linear(hidden, latent) # mean of q(z|x)
self.logvar = nn.Linear(hidden, latent) # log of the variance of q(z|x)
self.dec = nn.Sequential(nn.Linear(latent, hidden), nn.ReLU(), nn.Linear(hidden, D))
def encode(self, x):
h = self.enc(x)
return self.mu(h), self.logvar(h)
def reparametrize(self, mu, logvar):
eps = torch.randn_like(mu) # noise, independent of every parameter
return mu + torch.exp(0.5 * logvar) * eps # z = mu + sigma * eps
def decode(self, z):
return self.dec(z) # LOGITS of the pixel probabilities
def forward(self, x):
mu, logvar = self.encode(x)
z = self.reparametrize(mu, logvar)
return self.decode(z), mu, logvar
def vae_loss(logits, x, mu, logvar):
"""Negative ELBO per image, in nats: (Bernoulli reconstruction) + (KL to N(0, I)), both SUMMED, then averaged over the batch."""
recon = F.binary_cross_entropy_with_logits(logits, x, reduction="sum") / x.size(0)
kl = (0.5 * (mu.pow(2) + logvar.exp() - logvar - 1)).sum() / x.size(0)
return recon + kl, recon, kl
print("parameters:", sum(p.numel() for p in VAE().parameters()))
parameters: 20564
VAE.__init__ costruisce le quattro parti: self.enc (64 → 128 con ReLU), le due teste self.mu e self.logvar (128 → 10 ciascuna) e self.dec (10 → 128 → 64). encode restituisce la coppia (μ, log σ²). reparametrize è la riga fondamentale: mu + exp(0.5·logvar)·eps, dove exp(0.5·logvar) è σ (la radice di σ² = exp(logvar)) e eps = torch.randn_like(mu) è il rumore, estratto a ogni chiamata e indipendente da tutti i parametri, quindi z è derivabile rispetto a mu e logvar. decode restituisce i logit, non le probabilità: la sigmoide viene applicata dentro la perdita (in modo numericamente stabile) o all'uso. forward mette in fila i tre passaggi e restituisce anche mu e logvar perché servono alla KL. Il programma stampa 20.564 parametri; il conto a mano torna: 8320 + 2·1290 + 1408 + 8256.
vae_loss è l'opposto dell'ELBO. Il termine di ricostruzione F.binary_cross_entropy_with_logits(logits, x, reduction="sum") / x.size(0) somma la BCE sui 64 pixel e sulle immagini del batch, poi divide per il numero di immagini: è nat per immagine. La KL è la formula ricavata, sommata sulle 10 dimensioni e mediata sulle immagini. Il punto cruciale: entrambi i termini sono sommati sulle componenti e mediati solo sulle immagini, così il loro rapporto è quello dell'ELBO.
Addestramento
Il ciclo è quello di sempre: mini-batch da 64 immagini, vae(x), vae_loss, backward, step con Adam (learning rate 0.001), per 100 epoche. Misuriamo sul test, con vae.eval() e torch.no_grad(), la perdita e le sue due parti; per brevità ne stampiamo sette epoche.
torch.manual_seed(0)
vae = VAE()
opt = torch.optim.Adam(vae.parameters(), lr=1e-3)
@torch.no_grad()
def evaluate(model):
model.eval()
logits, mu, logvar = model(Xte)
loss, recon, kl = vae_loss(logits, Xte, mu, logvar)
model.train()
return loss.item(), recon.item(), kl.item()
for epoch in range(1, 101):
perm = torch.randperm(len(Xtr))
for i in range(0, len(Xtr), 64):
x = Xtr[perm[i:i + 64]]
logits, mu, logvar = vae(x)
loss, recon, kl = vae_loss(logits, x, mu, logvar)
opt.zero_grad(); loss.backward(); opt.step()
if epoch in (1, 2, 5, 10, 20, 50, 100):
t_loss, t_recon, t_kl = evaluate(vae)
print(f"epoch {epoch:3d} test: -ELBO {t_loss:6.2f} = reconstruction {t_recon:6.2f} + KL {t_kl:5.2f} (nats per image)")
epoch 1 test: -ELBO 35.61 = reconstruction 34.91 + KL 0.70 (nats per image)
epoch 2 test: -ELBO 31.21 = reconstruction 30.62 + KL 0.59 (nats per image)
epoch 5 test: -ELBO 28.20 = reconstruction 27.86 + KL 0.34 (nats per image)
epoch 10 test: -ELBO 26.64 = reconstruction 25.36 + KL 1.28 (nats per image)
epoch 20 test: -ELBO 25.78 = reconstruction 23.76 + KL 2.02 (nats per image)
epoch 50 test: -ELBO 24.98 = reconstruction 22.14 + KL 2.85 (nats per image)
epoch 100 test: -ELBO 24.56 = reconstruction 21.47 + KL 3.10 (nats per image)
La perdita totale scende da 35.6 a 24.6 nat per immagine. La ricostruzione scende da 34.9 a 21.5; la KL parte da 0.70, scende a 0.34 alla quinta epoca e poi sale lentamente fino a 3.10. È un andamento istruttivo: all'inizio il modello minimizza la perdita facendo dire all'encoder quasi niente (la KL è costosa e il decoder impara prima l'immagine media), poi, mentre il decoder migliora, l'encoder comincia a «dire di più» su ogni immagine e ne paga il prezzo in KL.
A fine addestramento il codice porta circa 3.1 nat (≈ 4.5 bit) di informazione su ogni cifra, e la perdita totale di 24.56 nat corrisponde a 35.4 bit per immagine, cioè 0.55 bit per pixel. Un'avvertenza onesta: i pixel sono tonalità di grigio in [0, 1] usate come bersagli della BCE, non binarizzati; formalmente non è una verosimiglianza di Bernoulli valida, ma è la convenzione più diffusa nei tutorial, e questi numeri non sono confrontabili con quelli pubblicati su dataset binarizzati.
Quanto ricostruisce e quanto somiglia a una cifra
Misuriamo la ricostruzione in MSE (la metrica che avevamo già), decodificando la media del codice senza rumore. Poi estraiamo 2000 codici dal prior, li decodifichiamo, e misuriamo la distanza media dall'immagine di addestramento più vicina (in spazio dei pixel; più piccola = più vicina a una cifra reale), con tre riferimenti: le cifre vere del test, il rumore uniforme e l'immagine media.
# Reconstruction quality (MSE on the mean decoded image)
with torch.no_grad():
logits, mu, logvar = vae(Xte)
recon_from_mean = torch.sigmoid(vae.decode(mu)) # decode the MEAN code, no noise
print(f"VAE reconstruction MSE (latent {LATENT}):", round(F.mse_loss(recon_from_mean, Xte).item(), 4))
# Generation: decode z ~ N(0, I). Is it digit-like? Compare with real digits via distance to the nearest TRAINING image.
def nn_distance(images):
return torch.cdist(images, Xtr).min(dim=1).values.mean().item()
with torch.no_grad():
samples = torch.sigmoid(vae.decode(torch.randn(2000, LATENT)))
real = Xte
noise = torch.rand(2000, D)
mean_img = Xtr.mean(0).expand(2000, D)
print("mean distance to the nearest training image (pixel space):")
print(f" real test digits : {nn_distance(real):.2f}")
print(f" VAE samples, z ~ N(0,I) : {nn_distance(samples):.2f}")
print(f" uniform noise images : {nn_distance(noise):.2f}")
print(f" the mean image : {nn_distance(mean_img):.2f}")
VAE reconstruction MSE (latent 10): 0.0282
mean distance to the nearest training image (pixel space):
real test digits : 1.08
VAE samples, z ~ N(0,I) : 1.21
uniform noise images : 3.56
the mean image : 1.51
L'MSE del VAE è 0.0282. Un autoencoder normale con lo stesso codice di 10 numeri (provato più avanti) ottiene 0.0112: il VAE ricostruisce molto peggio, ed è il prezzo della regolarizzazione KL, che impedisce di usare lo spazio latente come si vuole. Per le distanze: una cifra reale del test dista in media 1.08 dalla cifra di addestramento più vicina, un'immagine di rumore uniforme 3.56, l'immagine media 1.51. I campioni del VAE stanno a 1.21: molto più vicini a cifre vere del rumore, un po' più lontani di una cifra reale, e più vicini dell'immagine media. Questo dice qualcosa di onesto: i campioni sono plausibili ma sfocati. La distanza dal vicino più prossimo non misura la qualità percettiva, e un valore basso non vuol dire «bello»: è un controllo grossolano.
Generare: z ~ N(0, I), con un giudice
Per sapere se tutte le cifre compaiono, addestriamo un piccolo classificatore (un MLP da 64 → 64 → 10, 60 epoche) e lo usiamo come giudice: quale cifra vede in ciascun campione, con quale frequenza. Prima verifichiamo che il giudice sia affidabile sulle cifre vere: la sua accuratezza sul test è 97.8%. Poi lo applichiamo ai 2000 campioni del VAE, e a un'interpolazione fra un 3 e un 7 del test (le medie μ_a e μ_b dei due codici, nove punti sul segmento (1 − t)·μ_a + t·μ_b).
# A classifier is used as a "judge": which digit does it see, and how sure is it?
torch.manual_seed(0)
clf = nn.Sequential(nn.Linear(D, 64), nn.ReLU(), nn.Linear(64, 10))
opt_c = torch.optim.Adam(clf.parameters(), lr=1e-3)
for _ in range(60):
perm = torch.randperm(len(Xtr))
for i in range(0, len(Xtr), 64):
idx = perm[i:i + 64]
loss = F.cross_entropy(clf(Xtr[idx]), ytr[idx]); opt_c.zero_grad(); loss.backward(); opt_c.step()
with torch.no_grad():
print("judge accuracy on real test digits:", round((clf(Xte).argmax(-1) == yte).float().mean().item(), 4))
probs = F.softmax(clf(samples), dim=-1)
hist = torch.bincount(probs.argmax(-1), minlength=10).float() / len(samples)
print("classes the judge sees in 2000 VAE samples:", [round(v, 2) for v in hist.tolist()])
print("entropy of that distribution (max ln 10 = 2.30):", round(-(hist[hist > 0] * hist[hist > 0].log()).sum().item(), 2))
# Interpolation in latent space between a "3" and a "7": does the path go through plausible digits?
i3 = (yte == 3).nonzero()[0].item(); i7 = (yte == 7).nonzero()[0].item()
with torch.no_grad():
mu_a, _ = vae.encode(Xte[i3:i3 + 1]); mu_b, _ = vae.encode(Xte[i7:i7 + 1])
for t in torch.linspace(0, 1, 9):
img = torch.sigmoid(vae.decode((1 - t) * mu_a + t * mu_b))
p = F.softmax(clf(img), dim=-1)
print(f"t={t:.2f} judge sees a {p.argmax().item()} with probability {p.max().item():.2f}")
judge accuracy on real test digits: 0.9778
classes the judge sees in 2000 VAE samples: [0.09, 0.07, 0.08, 0.16, 0.12, 0.1, 0.12, 0.09, 0.09, 0.08]
entropy of that distribution (max ln 10 = 2.30): 2.27
t=0.00 judge sees a 3 with probability 0.83
t=0.12 judge sees a 3 with probability 0.84
t=0.25 judge sees a 3 with probability 0.86
t=0.38 judge sees a 3 with probability 0.85
t=0.50 judge sees a 3 with probability 0.74
t=0.62 judge sees a 7 with probability 0.41
t=0.75 judge sees a 7 with probability 0.90
t=0.88 judge sees a 7 with probability 0.98
t=1.00 judge sees a 7 with probability 1.00
Il giudice vede in 2000 campioni tutte e dieci le cifre, ciascuna con una frequenza fra il 7% e il 16%; l'entropia di questa distribuzione è 2.27 nat su un massimo di 2.30 (ln 10): quasi uniforme. Il VAE non ha etichette per le cifre, ma lo spazio latente organizzato dalla KL permette di pescare in tutte le «zone». Il giudice tende però ad assegnare una cifra anche a immagini sfocate, quindi interpreto il risultato come «nessuna classe è mancante», non come «tutte sono nitide». L'interpolazione è graduale e senza buchi nel senso che il giudice vede un 3 fino a t = 0.50 e un 7 da t = 0.62, senza passare per altre cifre; l'incertezza maggiore sta fra i due, dove la probabilità del 7 a t = 0.62 è solo 0.41. Nota che anche gli estremi non sono sicuri: il 3 ricostruito dalla media del codice ha probabilità 0.83, non 1, perché il VAE ricostruisce in modo sfocato. L'interpolazione usa μ (senza rumore), cioè i centri delle distribuzioni.
Autoencoder normale a confronto, con lo stesso test
Per rendere il confronto equo alleniamo un autoencoder normale con lo stesso codice di 10 numeri, la stessa architettura e le stesse 100 epoche, e applichiamo esattamente lo stesso test: estrarre z ~ N(0, I) e decodificare. Il giudice è lo stesso di prima.
# The same generation test with a PLAIN autoencoder of the same size (code of 10 numbers, same layers, BCE loss)
class AE(nn.Module):
def __init__(self, k=LATENT, hidden=128):
super().__init__()
self.enc = nn.Sequential(nn.Linear(D, hidden), nn.ReLU(), nn.Linear(hidden, k))
self.dec = nn.Sequential(nn.Linear(k, hidden), nn.ReLU(), nn.Linear(hidden, D))
torch.manual_seed(0)
ae = AE()
opt_a = torch.optim.Adam(ae.parameters(), lr=1e-3)
for epoch in range(100):
perm = torch.randperm(len(Xtr))
for i in range(0, len(Xtr), 64):
x = Xtr[perm[i:i + 64]]
loss = F.binary_cross_entropy_with_logits(ae.dec(ae.enc(x)), x)
opt_a.zero_grad(); loss.backward(); opt_a.step()
with torch.no_grad():
rec = torch.sigmoid(ae.dec(ae.enc(Xte)))
print(f"AE reconstruction MSE (code {LATENT}):", round(F.mse_loss(rec, Xte).item(), 4))
codes = ae.enc(Xtr)
print("AE code statistics: mean of means", round(codes.mean().item(), 2), " mean std", round(codes.std(0).mean().item(), 2))
ae_samples = torch.sigmoid(ae.dec(torch.randn(2000, LATENT))) # the SAME recipe used for the VAE
probs = F.softmax(clf(ae_samples), dim=-1)
ae_hist = torch.bincount(probs.argmax(-1), minlength=10).float() / 2000
print(f"AE samples, z ~ N(0,I): nearest-training-image distance {nn_distance(ae_samples):.2f}")
print("classes the judge sees:", [round(v, 2) for v in ae_hist.tolist()])
print("entropy of that distribution:", round(-(ae_hist[ae_hist > 0] * ae_hist[ae_hist > 0].log()).sum().item(), 2))
AE reconstruction MSE (code 10): 0.0112
AE code statistics: mean of means 0.26 mean std 2.49
AE samples, z ~ N(0,I): nearest-training-image distance 1.88
classes the judge sees: [0.03, 0.12, 0.15, 0.04, 0.19, 0.17, 0.02, 0.26, 0.01, 0.03]
entropy of that distribution: 1.94
Primo, l'autoencoder ricostruisce molto meglio (0.0112 contro 0.0282), come previsto. Secondo, i suoi codici non hanno la scala del prior: la deviazione standard media di ogni componente è 2.49, non 1. Terzo, i campioni da N(0, I) cadono più lontano da cifre di addestramento (distanza 1.88, contro 1.21 del VAE e 1.08 delle cifre vere). Quarto, e decisivo: le classi sono sbilanciate: il giudice vede il 26% di 7, il 19% di 4 e il 17% di 5, ma solo l'1% di 8 e il 2% di 6, con entropia 1.94 contro 2.27 del VAE. Un campionamento «a caso» da un autoencoder produce una parte soltanto delle cifre, perché N(0, I) cade in una porzione dello spazio dei suoi codici diversa da quella in cui il decoder ha imparato. Il VAE, con la KL, ha invece fatto coincidere il barattolo con le ricette. Un seme per rete: l'ordine delle entropie è netto, le singole frequenze no.
Quante dimensioni latenti usa davvero?
Abbiamo concesso 10 dimensioni. Le usa tutte? Per ogni dimensione calcoliamo la varianza di μ fra le immagini del test (se è vicina a zero, quella dimensione ha lo stesso valore per tutte le immagini e non porta informazione) e la KL media.
# How many of the latent dimensions does the VAE actually use?
with torch.no_grad():
mu, logvar = vae.encode(Xte)
var_of_means = mu.var(dim=0) # how much the MEAN code varies across images, per dimension
kl_per_dim = (0.5 * (mu.pow(2) + logvar.exp() - logvar - 1)).mean(dim=0)
print("variance of mu per dimension :", [round(v, 2) for v in var_of_means.tolist()])
print("KL per dimension (nats) :", [round(v, 2) for v in kl_per_dim.tolist()])
print(f"active dimensions (var of mu > 0.01):", int((var_of_means > 0.01).sum()), f"of {LATENT}")
variance of mu per dimension : [0.08, 0.79, 0.01, 0.85, 0.03, 0.67, 0.06, 0.14, 0.85, 0.05]
KL per dimension (nats) : [0.05, 0.72, 0.0, 0.86, 0.02, 0.52, 0.03, 0.08, 0.78, 0.03]
active dimensions (var of mu > 0.01): 9 of 10
Qui il risultato non è quello che mi aspettavo. Con la soglia di 0.01 sulla varianza di μ le dimensioni «attive» sono 9 su 10, ma la KL per dimensione dice un'altra cosa: solo quattro dimensioni (la seconda, la quarta, la sesta e la nona, contando da uno) portano più di 0.5 nat, con 0.72, 0.86, 0.52 e 0.78, mentre le altre sei stanno sotto 0.1. La somma è circa 3.1, che è tutta la KL dell'addestramento.
Il VAE ha quindi usato in modo sostanziale 4 dimensioni su 10: è un collasso parziale del posterior, e la soglia sulla varianza di μ è troppo permissiva per accorgersene, perché una dimensione può variare un poco senza portare informazione utile. La lezione successiva mostra questo fenomeno e come controllarlo con il peso β. Un seme e un solo valore di dimensione: non so dire quale parte del collasso dipenda dal dataset piccolo e quale dalla ricetta.
Trade-off
La ricostruzione si paga: il VAE ricostruisce peggio dell'autoencoder (0.0282 contro 0.0112) ma genera in modo bilanciato e permette interpolazioni senza buchi, ed è il compromesso del termine KL. Le immagini sono morbide, perché la perdita media sui pixel e la gaussiana diagonale favoriscono medie; per immagini più nitide servono decoder più potenti o altre perdite. La dimensione latente ha un doppio rischio: troppo piccola dà ricostruzioni povere, troppo grande lascia dimensioni inutilizzate. In compenso un VAE si addestra in modo stabile con pochi iperparametri.
Trappole reali
- Mediare la KL sulle componenti e sommare la ricostruzione sui pixel (o viceversa). Cambia il peso relativo dei due termini di un fattore pari al numero di pixel o di componenti, e la rete finisce per ignorare uno dei due. Somma entrambi sulle componenti, media sulle immagini.
- Dimenticare
eval()eno_grad()in valutazione. Qui il VAE non ha dropout né BatchNorm, quindieval()è innocuo, ma il campionamento diepsrende la perdita di valutazione rumorosa; per un numero stabile conviene usareμo mediare più campioni. - Campionare dal posterior di un'immagine invece che dal prior per «generare». Se decodifichi
μ + σ·εdi un'immagine stai ricostruendo, non generando. - Giudicare la generazione da poche immagini scelte. Guarda un campione grande e usa misure (distanza, diversità), non solo le immagini più belle.
- Pensare che un ELBO più basso significhi immagini più belle. L'ELBO misura la verosimiglianza sotto un modello di pixel indipendenti; non ha una relazione semplice con la qualità percettiva.
exp(logvar)che esplode. Selogvardiventa grande,expva in overflow e la perdita diventanan; con learning rate alti o dati non normalizzati si può limitarelogvarcon unclamp. Non l'ho provocato qui.
Le sei trappole condividono una causa: ciò che il programma stampa (una perdita, un'immagine, una generazione) non coincide con ciò che vuoi misurare. Il caso delle dimensioni attive di questa lezione è un esempio: la soglia sulla varianza dice «9 su 10», la KL per dimensione dice «4».
Un esempio svolto a mano: una dimensione latente e un pixel
La KL di una componente. Supponi che l'encoder, per una certa immagine, produca per la componente 3 del codice μ = 0.8 e log σ² = −0.5. Allora σ² = e^(−0.5) = 0.607 e la KL di quella componente vale ½(μ² + σ² − log σ² − 1) = ½(0.64 + 0.607 + 0.5 − 1) = 0.373 nat. Una componente con μ = 0 e log σ² = 0 ha KL zero: è una dimensione «inattiva» per quell'immagine. La riparametrizzazione. Con rumore ε = 0.3 si ha z = μ + σ·ε = 0.8 + 0.779·0.3 = 1.034, con σ = √0.607 = 0.779; con ε = −1.2, z = 0.8 − 0.935 = −0.135: stessa immagine, codici diversi, ed è questa casualità che fa imparare al decoder una zona e non un punto.
La perdita di un pixel. Sia x = 1 (pixel acceso) e il decoder produca il logit 2.0: la probabilità è σ(2) = 0.881 e la BCE vale −ln 0.881 = 0.127 nat. Se invece il pixel fosse grigio, x = 0.3, con lo stesso logit la BCE vale −[0.3·ln 0.881 + 0.7·ln 0.119] = 1.527, molto più alta. E il minimo possibile per x = 0.3 non è zero ma 0.611 (l'entropia di una Bernoulli di parametro 0.3), raggiunto prevedendo p = 0.3. Questo spiega perché la ricostruzione non può scendere a zero con pixel grigi, e perché il confronto con i numeri pubblicati su dataset binarizzati non è valido. I conti di questa sezione sono fatti a mano, non con il programma.
Domande frequenti
Perché il decoder restituisce logit invece di probabilità? Perché binary_cross_entropy_with_logits combina sigmoide e logaritmo in modo numericamente stabile, evitando log(0).
Perché due teste lineari invece di una rete che produce 20 numeri? È equivalente, ma le due teste rendono esplicita la struttura. Nel codice reale spesso si usa un unico Linear(128, 2·latent) e si divide l'uscita con chunk(2, dim=-1).
Quante epoche servono? Qui 100 portano a 24.56 nat e la curva scende ancora lentamente (24.98 a 50 epoche). Con più epoche la perdita diminuirebbe ancora; non l'ho misurato.
Posso condizionare la generazione su una cifra? Sì con un VAE condizionato (si dà l'etichetta a encoder e decoder), ma non è ciò che abbiamo costruito: il nostro VAE non ha mai visto le etichette. Il giudice serve solo a valutare.
Perché non campiono dal posterior per generare? Il posterior dipende da un'immagine x che durante la generazione non c'è. Il prior è la distribuzione che il training ha reso compatibile con il decoder.
Che cosa sai fare adesso
Sai scrivere un VAE in PyTorch: un encoder che produce μ e log σ², una riga di riparametrizzazione, un decoder a logit e una perdita di BCE sommata sui pixel più KL sommata sulle componenti. Hai misurato che, dopo 100 epoche su cifre 8×8, il −ELBO è 24.56 nat (21.47 di ricostruzione, 3.10 di KL), che i campioni da N(0, I) coprono tutte le dieci cifre (7-16% ciascuna) e che l'interpolazione 3→7 non passa per altre cifre. Hai visto lo stesso test su un autoencoder normale dare una distribuzione di cifre sbilanciata, e hai scoperto che il VAE usa in modo sostanziale solo 4 dimensioni su 10: un collasso parziale che la lezione successiva affronta con il peso β e le altre instabilità dell'addestramento.
Fonti
- Kingma e Welling, «Auto-Encoding Variational Bayes» (arXiv 1312.6114) - la perdita come ELBO con encoder gaussiano diagonale, riparametrizzazione, un campione per punto con mini-batch di 100; PDF letto, consultata il 2026-11-12
- PyTorch,
binary_cross_entropy_with_logits- la BCE che combina sigmoide e logaritmo; pagina raggiunta (200), consultata il 2026-11-12 - scikit-learn,
sklearn.datasets.load_digits- il dataset di cifre 8×8 usato negli esperimenti, consultata il 2026-11-12 - Misure: i listati di questa lezione, eseguiti come un unico script due volte con il Python del
ml-service(Python 3.12.13, PyTorch 2.12.0 CPU, scikit-learn 1.9.0, due thread), seme fisso, uscite identiche
Commenti
Caricamento commenti...
Accedi per commentare