Exercise 4 — VAE¶
Neste exercício treinei um VAE no MNIST. No Exercise 1 o espaço latente tem $d = 16$ dimensões, e acompanho os dois termos da perda separadamente. No Exercise 2 o mesmo modelo tem $d = 2$, o que permite desenhar o espaço latente e medir o custo dessa redução.
Como fiz.
- O VAE está em
code/vae.py: encoder, as duas cabeças ($\boldsymbol{\mu}$ e $\log \boldsymbol{\sigma}^2$) e decoder com a arquitetura do enunciado. O autograd do PyTorch calcula os gradientes. A reparametrização, os dois termos da perda e a amostragem da prior foram escritos à mão, sem classe de VAE pronta e semtorch.distributions. - Todos os valores estão em nats por imagem: soma nos 784 pixels (ou nas $d$ dimensões) e média nas imagens.
- Treino com Adam ($10^{-3}$), lote 128 e 20 épocas. Chamo
torch.manual_seed(42)antes de construir cada modelo. A validação é medida ao fim de cada época. O teste só é usado depois do treino, para os números finais. - Na validação e no teste, $z$ é sorteado de $q(z \mid x)$, uma amostra por imagem. Esse sorteio usa um gerador próprio com seed fixa, para que medir a validação não altere o treino.
- Em algumas análises usei um classificador de dígitos auxiliar (
code/digit_judge.py): uma rede 784 → 256 → 10 treinada no conjunto de treino, com 96.89% de acurácia na validação. Ele não faz parte do VAE. Uso a confiança dele para medir se uma imagem gerada é reconhecível como dígito, como no laboratório da Aula 17. - Fixei
torch.set_num_threads(4). Rodei os scripts do zero mais de uma vez, em pastas diferentes, e também o notebook. Os números foram iguais em todas as execuções. Cada treino de 20 épocas levou cerca de 3.5 minutos.
O notebook com todas as saídas está em code/vae_notebook.ipynb.
Este notebook roda os mesmos scripts da pasta code/, na mesma ordem, com as saídas.
Implementação do VAE (code/vae.py)¶
"""VAE para o MNIST: dados, modelo, perda e treino. Usado pelos Exercises 1 e 2.
O autograd do PyTorch calcula os gradientes. A reparametrização, os dois termos da perda e a
amostragem estão escritos aqui à mão: não uso classe de VAE de biblioteca nem torch.distributions.
"""
from pathlib import Path
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, TensorDataset, random_split
from torchvision import datasets, transforms
DATA = Path.cwd() / "data" # o MNIST é baixado aqui na primeira execução
DATA.mkdir(exist_ok=True)
LR, BATCH, EPOCHS, BETA = 1e-3, 128, 20, 1.0 # Adam 1e-3, lote 128, 20 épocas, beta = 1
# ---------------------------------------------------------------- dados
def load_mnist():
"""Treino 50 000 e validação 10 000 (split do enunciado) e o teste oficial de 10 000.
Devolve (X_train, y_train, X_val, y_val, X_test, y_test), com X em tensores (N, 784) e pixels em [0, 1].
"""
train_full = datasets.MNIST(DATA, train=True, download=True, transform=transforms.ToTensor())
test = datasets.MNIST(DATA, train=False, download=True, transform=transforms.ToTensor())
train, val = random_split(train_full, [50_000, 10_000],
generator=torch.Generator().manual_seed(42))
def to_tensors(dataset, indices):
# ToTensor() divide o uint8 por 255. Aplico a mesma conta de uma vez nas imagens escolhidas
# pelo split, em vez de converter imagem por imagem (o resultado é o mesmo, só mais rápido).
X = dataset.data[indices].reshape(-1, 784).float() / 255.0
return X, dataset.targets[indices]
X_train, y_train = to_tensors(train_full, torch.tensor(train.indices))
X_val, y_val = to_tensors(train_full, torch.tensor(val.indices))
X_test, y_test = to_tensors(test, torch.arange(len(test)))
assert torch.equal(X_train[0], train[0][0].reshape(784)) # confere: igual ao ToTensor() do split
assert torch.equal(X_val[0], val[0][0].reshape(784))
return X_train, y_train, X_val, y_val, X_test, y_test
# ---------------------------------------------------------------- modelo
class VAE(nn.Module):
"""Encoder 784 -> 512 -> 256 (ReLU), cabeças mu e log sigma^2 (256 -> d), decoder d -> 256 -> 512 -> 784."""
def __init__(self, d):
super().__init__()
self.d = d
self.encoder = nn.Sequential(nn.Linear(784, 512), nn.ReLU(),
nn.Linear(512, 256), nn.ReLU())
self.mu_head = nn.Linear(256, d)
self.logvar_head = nn.Linear(256, d) # a rede prevê log sigma^2, que pode ser qualquer real
self.decoder = nn.Sequential(nn.Linear(d, 256), nn.ReLU(),
nn.Linear(256, 512), nn.ReLU(),
nn.Linear(512, 784), nn.Sigmoid()) # cada pixel vira uma probabilidade
def encode(self, x):
h = self.encoder(x)
return self.mu_head(h), self.logvar_head(h)
def reparameterize(self, mu, logvar, generator=None):
"""Truque da reparametrização: z = mu + sigma * eps, com eps ~ N(0, I).
O sorteio fica todo em eps, que não depende dos parâmetros. Assim z é uma função
diferenciável de mu e sigma, e o gradiente chega no encoder.
"""
sigma = torch.exp(0.5 * logvar) # sigma = exp(log sigma^2 / 2)
eps = torch.randn(sigma.shape, generator=generator)
return mu + sigma * eps
def decode(self, z):
return self.decoder(z)
def forward(self, x, generator=None):
mu, logvar = self.encode(x)
z = self.reparameterize(mu, logvar, generator)
return self.decode(z), mu, logvar
@torch.no_grad()
def sample(self, n):
"""Amostra do modelo: z ~ N(0, I) (a prior) e decodifica. Usa o gerador global do torch."""
z = torch.randn(n, self.d)
return self.decode(z)
# ---------------------------------------------------------------- perda
def loss_terms(x, x_hat, mu, logvar):
"""Os dois termos da perda em nats por imagem: soma nos 784 pixels (ou nas d dimensões), média nas imagens.
Reconstrução: BCE somada nos pixels = -log p(x|z) com cada pixel Bernoulli.
KL: forma fechada de KL(N(mu, sigma^2) || N(0, I)) = 1/2 * sum(mu^2 + sigma^2 - 1 - log sigma^2).
"""
n = x.shape[0]
recon = F.binary_cross_entropy(x_hat, x, reduction="sum") / n # "sum" e não "mean" (ver o relatório)
kl = 0.5 * torch.sum(mu.pow(2) + logvar.exp() - 1.0 - logvar) / n
return recon, kl
@torch.no_grad()
def evaluate(model, X, use_mean=False, seed=0):
"""Reconstrução e KL médios por imagem em X, com o modelo em modo de avaliação.
Por padrão z é sorteado de q(z|x) (uma amostra por imagem), que é a estimativa usual do ELBO.
O sorteio usa um gerador próprio com seed fixa, então avaliar não mexe na sequência aleatória do treino.
Com use_mean=True o decoder recebe mu em vez de um z sorteado.
"""
model.eval()
gen = torch.Generator().manual_seed(seed)
total_recon = total_kl = 0.0
for i in range(0, len(X), 1000):
x = X[i:i + 1000]
mu, logvar = model.encode(x)
z = mu if use_mean else model.reparameterize(mu, logvar, gen)
recon, kl = loss_terms(x, model.decode(z), mu, logvar)
total_recon += recon.item() * len(x)
total_kl += kl.item() * len(x)
return total_recon / len(X), total_kl / len(X)
# ---------------------------------------------------------------- treino
def train_vae(d, X_train, X_val, epochs=EPOCHS, verbose=True):
"""Treina um VAE com latente de dimensão d. Devolve o modelo e o histórico dos dois termos por época."""
torch.manual_seed(42) # seed fixada antes de construir cada modelo
model = VAE(d)
opt = torch.optim.Adam(model.parameters(), lr=LR)
loader = DataLoader(TensorDataset(X_train), batch_size=BATCH, shuffle=True)
history = {"train_recon": [], "train_kl": [], "val_recon": [], "val_kl": []}
history["init_recon"], history["init_kl"] = evaluate(model, X_val) # validação antes da 1ª época
for epoch in range(1, epochs + 1):
model.train()
sum_recon = sum_kl = 0.0
for (x,) in loader:
x_hat, mu, logvar = model(x)
recon, kl = loss_terms(x, x_hat, mu, logvar)
loss = recon + BETA * kl # -ELBO por imagem
opt.zero_grad()
loss.backward()
opt.step()
sum_recon += recon.item() * len(x) # média da época ponderada pelo tamanho do lote
sum_kl += kl.item() * len(x)
history["train_recon"].append(sum_recon / len(X_train))
history["train_kl"].append(sum_kl / len(X_train))
val_recon, val_kl = evaluate(model, X_val) # validação ao fim de cada época
history["val_recon"].append(val_recon)
history["val_kl"].append(val_kl)
if verbose:
print(f" época {epoch:2d}: treino rec {history['train_recon'][-1]:7.2f} KL {history['train_kl'][-1]:5.2f}"
f" | validação rec {val_recon:7.2f} KL {val_kl:5.2f}")
return model, history
def load_or_train(d, X_train, X_val):
"""Carrega o modelo salvo pelo Exercise 1 (se existir) ou treina de novo com a mesma seed."""
path = DATA / f"vae_d{d}.pt"
if path.exists():
torch.manual_seed(42)
model = VAE(d)
model.load_state_dict(torch.load(path))
model.eval()
return model
model, _ = train_vae(d, X_train, X_val, verbose=False)
torch.save(model.state_dict(), path)
return model
Exercise 1¶
A VAE on MNIST. Script do exercício:
import time
from pathlib import Path
import numpy as np
import torch
import matplotlib
import matplotlib.pyplot as plt
from digit_judge import train_judge
FIG = Path.cwd().parent / "figures"
FIG.mkdir(exist_ok=True)
torch.set_num_threads(4) # fixa o número de threads para o resultado não variar entre máquinas
A — Load and split the data¶
Carreguei o MNIST do torchvision com transforms.ToTensor() e fiz o random_split como no enunciado: 50 000 imagens de treino e 10 000 de validação, com torch.Generator().manual_seed(42). As 10 000 imagens oficiais de teste formam o conjunto de teste. Cada imagem vira um vetor de 784 pixels em $[0, 1]$.
Para não converter as imagens a cada época, guardo os três conjuntos como tensores. Uso os índices escolhidos pelo random_split e divido o uint8 por 255, que é a conta do ToTensor(). O código confere que o resultado é igual ao do ToTensor().
X_train, y_train, X_val, y_val, X_test, y_test = load_mnist()
print(f"A) treino {tuple(X_train.shape)}, validação {tuple(X_val.shape)}, teste {tuple(X_test.shape)}; "
f"pixels em [{X_train.min():.0f}, {X_train.max():.0f}]")
A) treino (50000, 784), validação (10000, 784), teste (10000, 784); pixels em [0, 1]
B — Build the model¶
| parte | camadas |
|---|---|
| encoder | $784 \to 512 \to 256$, ReLU depois de cada uma |
| cabeças | duas lineares $256 \to 16$: uma para $\boldsymbol{\mu}$, outra para $\log \boldsymbol{\sigma}^2$ |
| decoder | $16 \to 256 \to 512 \to 784$, ReLU nas ocultas e sigmoide na saída |
O modelo tem 1 079 600 parâmetros. As partes específicas do VAE, em vae.py:
- Reparametrização: $\mathbf{z} = \boldsymbol{\mu} + \boldsymbol{\sigma} \odot \boldsymbol{\varepsilon}$, com $\boldsymbol{\varepsilon} \sim \mathcal{N}(0, I)$ e $\boldsymbol{\sigma} = \exp(\tfrac12 \log \boldsymbol{\sigma}^2)$. O sorteio fica em $\boldsymbol{\varepsilon}$, que não depende dos parâmetros. Assim o gradiente passa por $\boldsymbol{\mu}$ e $\boldsymbol{\sigma}$ até o encoder.
- Reconstrução: BCE somada nos 784 pixels, que é $-\log p(\mathbf{x} \mid \mathbf{z})$ com cada pixel Bernoulli. Uso
F.binary_cross_entropy(..., reduction="sum")e divido pelo tamanho do lote. Comreduction="mean"a BCE seria dividida também por 784, o que equivale a $\beta \approx 784$. - KL em forma fechada: $\tfrac12 \sum_{i=1}^{d} (\mu_i^2 + \sigma_i^2 - 1 - \log \sigma_i^2)$.
- Perda: reconstrução $+\ \beta \cdot$ KL, com $\beta = 1$, na média das imagens do lote.
- Amostragem: $\mathbf{z} \sim \mathcal{N}(0, I)$ com
torch.randn, seguido do decoder.
D = 16
print(f"B) {VAE(D)}")
print(f" parâmetros: {sum(p.numel() for p in VAE(D).parameters())}")
B) VAE(
(encoder): Sequential(
(0): Linear(in_features=784, out_features=512, bias=True)
(1): ReLU()
(2): Linear(in_features=512, out_features=256, bias=True)
(3): ReLU()
)
(mu_head): Linear(in_features=256, out_features=16, bias=True)
(logvar_head): Linear(in_features=256, out_features=16, bias=True)
(decoder): Sequential(
(0): Linear(in_features=16, out_features=256, bias=True)
(1): ReLU()
(2): Linear(in_features=256, out_features=512, bias=True)
(3): ReLU()
(4): Linear(in_features=512, out_features=784, bias=True)
(5): Sigmoid()
)
)
parâmetros: 1079600
C — Train¶
Treinei 20 épocas com Adam ($10^{-3}$) e lote 128, registrando os dois termos no treino (média da época) e na validação (fim da época).
Resultado no teste: reconstrução 81.77, KL 19.94 e $-$ELBO 101.72 nats por imagem, próximo da referência do enunciado (cerca de 81 e 20). Na validação, o KL foi 13.44 na primeira época e 19.88 na última, e a reconstrução foi de 129.94 para 82.39.
Na primeira época, a curva de treino fica acima da de validação na reconstrução (177.50 contra 129.94) e abaixo no KL (6.73 contra 13.44). Isso ocorre porque o valor de treino é a média da época, que inclui o início do treino, e a validação é medida no fim da época. A partir da época 5 as curvas ficam próximas. Na época 20 a reconstrução foi 81.09 no treino e 82.39 na validação, então não há sinal de overfitting.
t0 = time.time()
model, hist = train_vae(D, X_train, X_val)
print(f"C) treino de {EPOCHS} épocas em {time.time() - t0:.0f} s")
torch.save(model.state_dict(), DATA / "vae_d16.pt")
# O teste só é usado aqui, depois do treino, para os números finais
test_recon, test_kl = evaluate(model, X_test)
print(f"C) teste: reconstrução {test_recon:.2f}, KL {test_kl:.2f}, -ELBO {test_recon + test_kl:.2f} nats por imagem")
print(f" validação: KL na época 1 = {hist['val_kl'][0]:.2f}, na época {EPOCHS} = {hist['val_kl'][-1]:.2f}")
epochs = np.arange(1, EPOCHS + 1)
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
for ax, term, label in [(axes[0], "recon", "reconstrução (BCE)"), (axes[1], "kl", "KL")]:
ax.plot(epochs, hist[f"train_{term}"], "o-", ms=3, label="treino (média da época)")
ax.plot(epochs, hist[f"val_{term}"], "s-", ms=3, label="validação (fim da época)")
ax.set_xlabel("época"); ax.set_ylabel("nats por imagem")
ax.set_title(label); ax.set_xticks(epochs[::2]); ax.legend()
fig.suptitle(f"Figure 1 — Exercise 1: os dois termos da perda por época (d = {D})")
fig.tight_layout()
fig.savefig(FIG / "fig1.png", bbox_inches="tight"); plt.show()
época 1: treino rec 177.50 KL 6.73 | validação rec 129.94 KL 13.44
época 2: treino rec 117.93 KL 14.50 | validação rec 108.30 KL 15.70
época 3: treino rec 102.97 KL 16.49 | validação rec 98.92 KL 17.13
época 4: treino rec 96.12 KL 17.65 | validação rec 94.49 KL 18.10
época 5: treino rec 92.55 KL 18.17 | validação rec 91.87 KL 17.97
época 6: treino rec 90.37 KL 18.47 | validação rec 89.68 KL 18.75
época 7: treino rec 88.77 KL 18.70 | validação rec 89.29 KL 18.17
época 8: treino rec 87.48 KL 18.87 | validação rec 87.62 KL 18.71
época 9: treino rec 86.44 KL 19.01 | validação rec 87.14 KL 18.43
época 10: treino rec 85.59 KL 19.10 | validação rec 86.19 KL 19.22
época 11: treino rec 84.85 KL 19.21 | validação rec 85.97 KL 18.89
época 12: treino rec 84.24 KL 19.28 | validação rec 85.02 KL 19.12
época 13: treino rec 83.72 KL 19.35 | validação rec 85.11 KL 19.02
época 14: treino rec 83.15 KL 19.41 | validação rec 85.17 KL 18.66
época 15: treino rec 82.70 KL 19.47 | validação rec 83.81 KL 19.46
época 16: treino rec 82.31 KL 19.51 | validação rec 83.33 KL 19.83
época 17: treino rec 81.99 KL 19.58 | validação rec 83.48 KL 19.54
época 18: treino rec 81.68 KL 19.61 | validação rec 82.73 KL 19.72
época 19: treino rec 81.29 KL 19.66 | validação rec 82.84 KL 19.51
época 20: treino rec 81.09 KL 19.67 | validação rec 82.39 KL 19.88 C) treino de 20 épocas em 179 s
C) teste: reconstrução 81.77, KL 19.94, -ELBO 101.72 nats por imagem validação: KL na época 1 = 13.44, na época 20 = 19.88
D — Reconstructions and samples¶
Linha de cima: a primeira imagem de teste de cada dígito (índices 3, 2, 1, 18, 4, 8, 11, 0, 61 e 7, para os dígitos de 0 a 9). Linha de baixo: a reconstrução decodificando $\boldsymbol{\mu}$, sem sortear $z$.
A Figure 3 tem 64 imagens decodificadas de $\mathbf{z} \sim \mathcal{N}(0, I)$, sorteadas depois de torch.manual_seed(0).
model.eval()
first_idx = [int((y_test == k).nonzero()[0]) for k in range(10)] # primeira imagem de teste de cada dígito
x10 = X_test[first_idx]
with torch.no_grad():
mu10, _ = model.encode(x10)
rec10 = model.decode(mu10) # reconstrução a partir de mu, sem sortear z
print(f"D) índices de teste da Figure 2 (dígitos 0 a 9): {first_idx}")
fig, axes = plt.subplots(2, 10, figsize=(12, 2.9))
for k in range(10):
for row, imgs in enumerate([x10, rec10]):
axes[row, k].imshow(imgs[k].reshape(28, 28), cmap="gray", vmin=0, vmax=1)
axes[row, k].set_xticks([]); axes[row, k].set_yticks([])
axes[0, k].set_title(f"dígito {k}", fontsize=9)
axes[0, 0].set_ylabel("original"); axes[1, 0].set_ylabel("reconstrução\n(decodifica μ)")
fig.supxlabel("dígito (primeira imagem de teste de cada um)", y=-0.02)
fig.suptitle("Figure 2 — Exercise 1: primeira imagem de teste de cada dígito e sua reconstrução (d = 16)")
fig.savefig(FIG / "fig2.png", bbox_inches="tight"); plt.show()
torch.manual_seed(0)
samples = model.sample(64) # z ~ N(0, I), depois o decoder
grid = samples.reshape(8, 8, 28, 28).permute(0, 2, 1, 3).reshape(8 * 28, 8 * 28)
fig, ax = plt.subplots(figsize=(6, 6.3))
ax.imshow(grid, cmap="gray", vmin=0, vmax=1)
ax.set_xticks(np.arange(8) * 28 + 13.5); ax.set_xticklabels(range(1, 9))
ax.set_yticks(np.arange(8) * 28 + 13.5); ax.set_yticklabels(range(1, 9))
ax.set_xlabel("coluna da grade"); ax.set_ylabel("linha da grade")
ax.set_title("Figure 3 — Exercise 1: 64 amostras de z ~ N(0, I)\ndecodificadas (d = 16, torch.manual_seed(0))")
fig.savefig(FIG / "fig3.png", bbox_inches="tight"); plt.show()
E — Analysis¶
Por que o KL sobe no começo enquanto a perda total cai? Antes do treino, o KL de validação era 0.03 e a reconstrução era 544.71. Com os pesos iniciais, $\boldsymbol{\mu} \approx 0$ e $\boldsymbol{\sigma} \approx 1$, então $q(z \mid x)$ é quase igual à prior para qualquer imagem. Nesse caso $z$ não contém informação sobre a imagem, e o decoder não consegue reconstruí-la. Para reduzir a reconstrução, o encoder precisa afastar $\boldsymbol{\mu}$ de zero e reduzir $\boldsymbol{\sigma}$, o que aumenta o KL. No início, a queda da reconstrução é muito maior que o aumento do KL:
- do início até a época 4, o KL subiu 18.07 nats (de 0.03 para 18.10) e a reconstrução caiu 450.22 (de 544.71 para 94.49);
- da época 1 para a época 4, o KL subiu 4.66 e a reconstrução caiu 35.45, cerca de 7.6 nats de reconstrução para cada nat de KL.
Como o otimizador minimiza a soma dos dois termos, aumentar o KL reduz a perda total. Depois da época 4 o KL quase não sobe (de 18.10 para 19.88 em 16 épocas), enquanto a reconstrução ainda cai 12.10 nats.
Por que a Figure 3 é pior que a Figure 2?
- Onde as amostras da prior caem. Na Figure 2, $z$ é o $\boldsymbol{\mu}$ de uma imagem real e está numa região em que o decoder foi treinado. Na Figure 3, $z$ vem de $\mathcal{N}(0, I)$, e o KL não garante que os códigos das imagens reais cubram toda a prior. Ficam regiões sem códigos de imagens reais, os "buracos" do espaço latente da Aula 17, onde o decoder não foi treinado. Para medir isso, usei as 11 dimensões ativas (as outras 5 têm KL praticamente zero e não carregam informação) e calculei a distância de cada ponto até o $\boldsymbol{\mu}$ mais próximo de 5 000 imagens de validação. Para códigos de outras 5 000 imagens de validação, a distância mediana foi 1.47. Para as 64 amostras da Figure 3, foi 1.83. 23 das 64 amostras ficaram acima do quantil de 95% dos códigos reais (seriam cerca de 3 se as amostras caíssem onde caem os códigos reais). O classificador auxiliar confia menos nessas amostras: confiança média de 0.73 nelas e 0.85 nas outras 41, contra 0.91 nas reconstruções da Figure 2.
- O que o decoder devolve para um $z$. O decoder devolve a probabilidade de cada pixel estar aceso, e a saída que minimiza a perda é a média das imagens compatíveis com aquele $z$ (Aula 17). Uma média de imagens é borrada. A fração de pixels cinza (entre 0.1 e 0.9) é 9.2% nas imagens originais da Figure 2, 17.8% nas reconstruções e 16.5% nas amostras da Figure 3. O borrão, portanto, aparece nas duas figuras. A diferença está em quais imagens são compatíveis com $z$. O $\boldsymbol{\mu}$ de uma imagem real corresponde a imagens parecidas com ela, e a média continua sendo aquele dígito. Um $z$ da prior entre códigos de dígitos diferentes é compatível com imagens de vários dígitos, e a média mistura os traços deles, como nas posições (2, 2) e (8, 2) da Figure 3. Por isso aparecem formas que não são dígitos, mesmo sem mais pixels cinza.
Por que decodificar $\boldsymbol{\mu}$ e não um $z$ sorteado na Figure 2? $\boldsymbol{\mu}$ é o centro de $q(z \mid x)$, o código mais provável da imagem. Um $z$ sorteado é $\boldsymbol{\mu}$ mais um ruído de desvio $\boldsymbol{\sigma}$, e esse ruído é grande: na validação, o $\sigma$ médio foi 0.440 e o $|\mu|$ médio foi 0.555. Com $z$ sorteado, cada execução daria uma reconstrução diferente, e a figura mostraria o ruído do sorteio além da qualidade do modelo. Com $\boldsymbol{\mu}$ a reconstrução é determinística e melhor: 77.42 nats na validação, contra 82.39 com $z$ sorteado.
# 1) KL subindo no começo: quanto a reconstrução caiu enquanto o KL subia
print(f"E1) validação antes do treino: reconstrução {hist['init_recon']:.2f}, KL {hist['init_kl']:.2f}")
print(f" KL de validação por época: {np.round(hist['val_kl'], 2).tolist()}")
print(f" reconstrução de validação por época: {np.round(hist['val_recon'], 2).tolist()}")
k = int(np.argmax(np.diff(hist["val_kl"]) < 0)) # última época da subida inicial do KL (índice)
r0, r1 = hist["init_recon"], hist["val_recon"][k]
k0, k1 = hist["init_kl"], hist["val_kl"][k]
print(f" do início à época {k + 1} (fim da subida contínua): KL {k0:.2f} -> {k1:.2f} ({k1 - k0:+.2f}), "
f"reconstrução {r0:.2f} -> {r1:.2f} ({r1 - r0:+.2f}), total {r0 + k0:.2f} -> {r1 + k1:.2f}")
print(f" da época 1 à época {k + 1}: KL {hist['val_kl'][0]:.2f} -> {k1:.2f} ({k1 - hist['val_kl'][0]:+.2f}), "
f"reconstrução {hist['val_recon'][0]:.2f} -> {r1:.2f} ({r1 - hist['val_recon'][0]:+.2f})")
with torch.no_grad():
mu_v, logvar_v = model.encode(X_val)
kl_dim = 0.5 * (mu_v.pow(2) + logvar_v.exp() - 1 - logvar_v).mean(0) # KL médio por dimensão latente
print(f"E2) KL médio por dimensão (validação): {np.round(kl_dim.numpy(), 2).tolist()}")
active = kl_dim >= 0.1
print(f" dimensões ativas (KL >= 0.1 nat): {active.sum().item()} de {D}")
# 2) Onde as amostras da prior caem: distância até o código mu de imagem real mais próximo, só nas
# dimensões ativas. Referência: mu das 5 000 primeiras imagens de validação. Para comparar, faço a mesma
# conta com códigos z ~ q(z|x) de outras 5 000 imagens de validação (que não estão na referência).
ref = mu_v[:5000, active]
gen = torch.Generator().manual_seed(1)
with torch.no_grad():
z_post = model.reparameterize(mu_v[5000:], logvar_v[5000:], gen) # códigos de imagens reais
torch.manual_seed(0)
z_prior = torch.randn(64, D) # os mesmos z da Figure 3
d_post = torch.cdist(z_post[:, active], ref).min(1).values
d_prior = torch.cdist(z_prior[:, active], ref).min(1).values
thr = torch.quantile(d_post, 0.95)
far = d_prior > thr # amostras "longe" de qualquer imagem real
print(f"E2) distância mediana até o mu real mais próximo: códigos de imagens reais {d_post.median():.2f}, "
f"amostras da prior {d_prior.median():.2f}")
print(f" amostras da Figure 3 acima do quantil 95% dos códigos reais ({thr:.2f}): {far.sum().item()} de 64")
# 3) O decoder devolve probabilidades: fração de pixels "cinza" (entre 0.1 e 0.9)
gray = lambda imgs: ((imgs > 0.1) & (imgs < 0.9)).float().mean().item() * 100
print(f"E2) pixels entre 0.1 e 0.9: originais da Figure 2 {gray(x10):.1f}%, reconstruções {gray(rec10):.1f}%, "
f"amostras da Figure 3 {gray(samples):.1f}%")
print(f" nas amostras da Figure 3: {gray(samples[far]):.1f}% nas {far.sum().item()} longe, "
f"{gray(samples[~far]):.1f}% nas outras {(~far).sum().item()}")
far_cells = [(i // 8 + 1, i % 8 + 1) for i in far.nonzero().flatten().tolist()]
print(f" posições (linha, coluna) das amostras longe: {far_cells}")
# 3b) Parece um dígito? Confiança do classificador auxiliar (digit_judge.py)
judge = train_judge(X_train, y_train)
_, c_rec = judge(rec10)
_, c_smp = judge(samples)
f = far.numpy()
print(f" confiança média do classificador: reconstruções da Figure 2 {c_rec.mean():.2f}, "
f"amostras da Figure 3 {c_smp.mean():.2f} (longe {c_smp[f].mean():.2f}, outras {c_smp[~f].mean():.2f})")
print(f" amostras da Figure 3 com confiança < 0.6: {(c_smp < 0.6).sum()} de 64 ({(c_smp[f] < 0.6).sum()} delas longe)")
# 4) Decodificar mu ou um z sorteado
rec_mu = evaluate(model, X_val, use_mean=True)[0]
rec_z = evaluate(model, X_val)[0]
print(f"E3) reconstrução de validação decodificando mu {rec_mu:.2f} vs z sorteado {rec_z:.2f} nats por imagem")
print(f" sigma médio no encoder: {torch.exp(0.5 * logvar_v).mean():.3f}; |mu| médio: {mu_v.abs().mean():.3f}")
E1) validação antes do treino: reconstrução 544.71, KL 0.03
KL de validação por época: [13.44, 15.7, 17.13, 18.1, 17.97, 18.75, 18.17, 18.71, 18.43, 19.22, 18.89, 19.12, 19.02, 18.66, 19.46, 19.83, 19.54, 19.72, 19.51, 19.88]
reconstrução de validação por época: [129.94, 108.3, 98.92, 94.49, 91.87, 89.68, 89.29, 87.62, 87.14, 86.19, 85.97, 85.02, 85.11, 85.17, 83.81, 83.33, 83.48, 82.73, 82.84, 82.39]
do início à época 4 (fim da subida contínua): KL 0.03 -> 18.10 (+18.07), reconstrução 544.71 -> 94.49 (-450.22), total 544.73 -> 112.59
da época 1 à época 4: KL 13.44 -> 18.10 (+4.66), reconstrução 129.94 -> 94.49 (-35.45)
E2) KL médio por dimensão (validação): [2.1600000858306885, 1.8700000047683716, 1.4299999475479126, 1.5399999618530273, 0.0, 2.2100000381469727, 2.509999990463257, 0.0, 0.0, 1.2999999523162842, 1.7899999618530273, 1.1699999570846558, 1.8799999952316284, 0.0, 2.0199999809265137, 0.0]
dimensões ativas (KL >= 0.1 nat): 11 de 16
E2) distância mediana até o mu real mais próximo: códigos de imagens reais 1.47, amostras da prior 1.83
amostras da Figure 3 acima do quantil 95% dos códigos reais (2.04): 23 de 64
E2) pixels entre 0.1 e 0.9: originais da Figure 2 9.2%, reconstruções 17.8%, amostras da Figure 3 16.5%
nas amostras da Figure 3: 14.5% nas 23 longe, 17.6% nas outras 41
posições (linha, coluna) das amostras longe: [(1, 4), (1, 6), (3, 6), (4, 1), (4, 2), (4, 3), (4, 8), (5, 2), (5, 3), (5, 5), (5, 6), (6, 2), (6, 4), (6, 8), (7, 3), (7, 4), (7, 7), (7, 8), (8, 1), (8, 2), (8, 3), (8, 5), (8, 7)]
confiança média do classificador: reconstruções da Figure 2 0.91, amostras da Figure 3 0.80 (longe 0.73, outras 0.85)
amostras da Figure 3 com confiança < 0.6: 14 de 64 (8 delas longe)
E3) reconstrução de validação decodificando mu 77.42 vs z sorteado 82.39 nats por imagem
sigma médio no encoder: 0.440; |mu| médio: 0.555
Exercise 2¶
A Latent Space You Can See. Script do exercício:
from pathlib import Path
import numpy as np
import torch
from scipy.stats import norm
from sklearn.neighbors import KNeighborsClassifier
import matplotlib
import matplotlib.pyplot as plt
from digit_judge import train_judge
FIG = Path.cwd().parent / "figures"
FIG.mkdir(exist_ok=True)
torch.set_num_threads(4) # o mesmo número de threads do Exercise 1
X_train, y_train, X_val, y_val, X_test, y_test = load_mnist()
A — Train a 2-D VAE¶
Treinei o mesmo modelo mudando só $d = 2$: mesmos dados, seed, $\beta = 1$ e orçamento de treino (20 épocas, Adam $10^{-3}$, lote 128).
| modelo | reconstrução no teste | KL no teste | $-$ELBO no teste |
|---|---|---|---|
| $d = 16$ (Exercise 1) | 81.77 | 19.94 | 101.72 |
| $d = 2$ | 133.29 | 6.75 | 140.04 |
| diferença ($d = 2$ menos $d = 16$) | +51.52 | −13.20 | +38.32 |
Os valores de $d = 2$ estão próximos da referência do enunciado (cerca de 135 e 7). Na validação, o KL de $d = 2$ foi de 4.83 na primeira época para 6.74 na última.
# Mesmo modelo, dados, seed, beta e orçamento do Exercise 1. Só muda d.
model2, hist2 = train_vae(2, X_train, X_val)
torch.save(model2.state_dict(), DATA / "vae_d2.pt")
model16 = load_or_train(16, X_train, X_val)
model2.eval(); model16.eval()
rec2, kl2 = evaluate(model2, X_test) # teste só para os números finais
rec16, kl16 = evaluate(model16, X_test)
print("A) teste, nats por imagem:")
print(f" d = 16: reconstrução {rec16:.2f}, KL {kl16:.2f}, -ELBO {rec16 + kl16:.2f}")
print(f" d = 2: reconstrução {rec2:.2f}, KL {kl2:.2f}, -ELBO {rec2 + kl2:.2f}")
print(f" diferença (d=2 - d=16): reconstrução {rec2 - rec16:+.2f}, KL {kl2 - kl16:+.2f}, -ELBO {rec2 + kl2 - rec16 - kl16:+.2f}")
print(f" d = 2, validação: KL na época 1 = {hist2['val_kl'][0]:.2f}, na época {EPOCHS} = {hist2['val_kl'][-1]:.2f}; "
f"reconstrução na época {EPOCHS} = {hist2['val_recon'][-1]:.2f}")
época 1: treino rec 183.71 KL 3.68 | validação rec 163.09 KL 4.83
época 2: treino rec 157.62 KL 5.21 | validação rec 153.23 KL 5.55
época 3: treino rec 150.44 KL 5.66 | validação rec 148.72 KL 5.49
época 4: treino rec 146.59 KL 5.83 | validação rec 145.26 KL 5.89
época 5: treino rec 144.19 KL 5.93 | validação rec 143.89 KL 5.78
época 6: treino rec 142.45 KL 6.05 | validação rec 142.37 KL 6.07
época 7: treino rec 141.19 KL 6.15 | validação rec 141.34 KL 6.01
época 8: treino rec 140.06 KL 6.22 | validação rec 141.03 KL 6.06
época 9: treino rec 138.89 KL 6.30 | validação rec 139.78 KL 6.28
época 10: treino rec 137.96 KL 6.36 | validação rec 138.40 KL 6.35
época 11: treino rec 137.31 KL 6.42 | validação rec 137.91 KL 6.49
época 12: treino rec 136.62 KL 6.46 | validação rec 137.99 KL 6.41
época 13: treino rec 135.84 KL 6.52 | validação rec 136.99 KL 6.38
época 14: treino rec 135.24 KL 6.57 | validação rec 136.53 KL 6.45
época 15: treino rec 134.40 KL 6.62 | validação rec 136.20 KL 6.51
época 16: treino rec 133.74 KL 6.67 | validação rec 135.03 KL 6.70
época 17: treino rec 133.71 KL 6.67 | validação rec 135.93 KL 6.68
época 18: treino rec 133.16 KL 6.71 | validação rec 135.02 KL 6.57
época 19: treino rec 132.55 KL 6.75 | validação rec 134.49 KL 6.86
época 20: treino rec 132.26 KL 6.77 | validação rec 133.91 KL 6.74
A) teste, nats por imagem: d = 16: reconstrução 81.77, KL 19.94, -ELBO 101.72 d = 2: reconstrução 133.29, KL 6.75, -ELBO 140.04 diferença (d=2 - d=16): reconstrução +51.52, KL -13.20, -ELBO +38.32 d = 2, validação: KL na época 1 = 4.83, na época 20 = 6.74; reconstrução na época 20 = 133.91
B — The encoded test set¶
A Figure 4 mostra $\boldsymbol{\mu}$ das 10 000 imagens de teste, com uma cor por dígito. Os pontos foram desenhados em ordem aleatória, para que nenhum dígito fique escondido sob outro. O quadrado tracejado é a área coberta pela Figure 5.
with torch.no_grad():
mu_test = model2.encode(X_test)[0].numpy()
mu_train = model2.encode(X_train)[0].numpy()
colors = plt.cm.tab10(np.arange(10))
order = np.random.default_rng(0).permutation(len(mu_test)) # ordem aleatória: nenhum dígito fica escondido por baixo
fig, ax = plt.subplots(figsize=(8, 7))
ax.scatter(mu_test[order, 0], mu_test[order, 1], s=3, alpha=0.6, color=colors[y_test.numpy()[order]])
for k in range(10): # pontos vazios só para montar a legenda
ax.scatter([], [], s=12, color=colors[k], label=f"{k}")
lim = norm.ppf(0.95)
ax.add_patch(plt.Rectangle((-lim, -lim), 2 * lim, 2 * lim, fill=False, ls="--", color="black",
label="área da Figure 5"))
ax.set_xlabel("$\\mu_1$"); ax.set_ylabel("$\\mu_2$")
ax.set_title("Figure 4 — Exercise 2: μ das 10 000 imagens de teste (d = 2), cor = dígito")
ax.legend(title="dígito", markerscale=1.5, loc="center left", bbox_to_anchor=(1.01, 0.5))
fig.savefig(FIG / "fig4.png", bbox_inches="tight"); plt.show()
# Sobreposição medida: um k-vizinhos no plano latente, ajustado com o treino e aplicado no teste.
# Os pares que ele mais confunde são os dígitos que se misturam na Figure 4.
knn = KNeighborsClassifier(n_neighbors=15).fit(mu_train, y_train.numpy())
pred = knn.predict(mu_test)
print(f"B) acurácia de um 15-vizinhos no plano latente (teste): {(pred == y_test.numpy()).mean():.4f}")
conf = np.zeros((10, 10), int)
np.add.at(conf, (y_test.numpy(), pred), 1)
pairs = sorted(((conf[i, j] + conf[j, i], i, j) for i in range(10) for j in range(i + 1, 10)), reverse=True)
print(" pares mais confundidos (erros nos dois sentidos):", [(f"{i}-{j}", n) for n, i, j in pairs[:6]])
centers = np.array([np.median(mu_test[y_test.numpy() == k], axis=0) for k in range(10)])
print(" mediana de μ por dígito:", {k: centers[k].round(2).tolist() for k in range(10)})
B) acurácia de um 15-vizinhos no plano latente (teste): 0.8319
pares mais confundidos (erros nos dois sentidos): [('4-9', np.int64(608)), ('5-8', np.int64(172)), ('3-8', np.int64(172)), ('7-9', np.int64(116)), ('3-5', np.int64(100)), ('2-3', np.int64(67))]
mediana de μ por dígito: {0: [-0.07000000029802322, -1.7599999904632568], 1: [-0.75, 1.75], 2: [-1.6299999952316284, -0.14000000059604645], 3: [-0.7300000190734863, -0.1899999976158142], 4: [0.7400000095367432, 0.0], 5: [-0.20999999344348907, 0.14000000059604645], 6: [0.27000001072883606, -0.6600000262260437], 7: [1.5800000429153442, 0.3700000047683716], 8: [-0.4699999988079071, 0.15000000596046448], 9: [0.9399999976158142, 0.029999999329447746]}
# Classificador de dígitos de digit_judge.py: diz "isto parece o dígito k" nas imagens geradas.
judge = train_judge(X_train, y_train)
print(f" classificador auxiliar: acurácia na validação {(judge(X_val)[0] == y_val.numpy()).mean():.4f}")
classificador auxiliar: acurácia na validação 0.9689
C — The decoded plane¶
Decodifiquei uma grade de $15 \times 15$ pontos com $z_1$ e $z_2$ iguais a $\Phi^{-1}(q)$, para 15 valores de $q$ igualmente espaçados em $[0.05, 0.95]$ (de −1.64 a 1.64). Na figura, $z_1$ cresce da esquerda para a direita e $z_2$ de baixo para cima, como na Figure 4.
Por que esse espaçamento: como $q$ é a probabilidade acumulada da $\mathcal{N}(0, 1)$ e os valores de $q$ são igualmente espaçados, entre dois pontos vizinhos da grade há sempre a mesma massa da prior ($0.9/14 \approx 0.064$ em cada eixo) e, como a prior é independente nos dois eixos, cada célula recebe a mesma massa da prior.
q = np.linspace(0.05, 0.95, 15)
zs = norm.ppf(q) # quantis da N(0, 1): cada faixa tem a mesma massa da prior
print(f"C) valores de z na grade: {zs.round(2).tolist()}")
Z1, Z2 = np.meshgrid(zs, zs[::-1]) # linha de cima = z2 maior, como no gráfico da Figure 4
grid_z = torch.tensor(np.stack([Z1.ravel(), Z2.ravel()], 1), dtype=torch.float32)
with torch.no_grad():
grid_img = model2.decode(grid_z)
tile = grid_img.reshape(15, 15, 28, 28).permute(0, 2, 1, 3).reshape(15 * 28, 15 * 28)
fig, ax = plt.subplots(figsize=(9, 9.3))
ax.imshow(tile, cmap="gray", vmin=0, vmax=1)
ticks = np.arange(15) * 28 + 13.5
ax.set_xticks(ticks); ax.set_xticklabels([f"{v:.2f}" for v in zs], rotation=90)
ax.set_yticks(ticks); ax.set_yticklabels([f"{v:.2f}" for v in zs[::-1]])
ax.set_xlabel("$z_1 = \\Phi^{-1}(q)$"); ax.set_ylabel("$z_2 = \\Phi^{-1}(q)$")
ax.set_title("Figure 5 — Exercise 2: decoder de d = 2 numa grade 15×15\nnos quantis q = 0.05, ..., 0.95 da N(0, 1)")
fig.savefig(FIG / "fig5.png", bbox_inches="tight"); plt.show()
lab_grid, conf_grid = judge(grid_img)
print(" dígito que o classificador vê em cada célula da Figure 5 (linha de cima = z2 maior):")
for r in range(15):
print(" " + " ".join(f"{l}{'?' if c < 0.6 else ' '}" for l, c in
zip(lab_grid[r * 15:(r + 1) * 15], conf_grid[r * 15:(r + 1) * 15])))
print(f" células com confiança < 0.6 (marcadas com ?): {(conf_grid < 0.6).sum()} de 225")
print(f" dígitos que aparecem na grade: {sorted(set(lab_grid.tolist()))}; "
f"células por dígito: {np.bincount(lab_grid, minlength=10).tolist()}")
def cell_of(z):
"""Célula (linha, coluna) da Figure 5 mais próxima de um ponto z, contando de 1."""
col = int(np.abs(zs - z[0]).argmin()) + 1
row = int(np.abs(zs[::-1] - z[1]).argmin()) + 1
return row, col
print(" célula da Figure 5 mais próxima da mediana de cada dígito:",
{k: cell_of(centers[k]) for k in range(10)})
C) valores de z na grade: [-1.64, -1.2, -0.92, -0.7, -0.5, -0.33, -0.16, -0.0, 0.16, 0.33, 0.5, 0.7, 0.92, 1.2, 1.64]
dígito que o classificador vê em cada célula da Figure 5 (linha de cima = z2 maior):
1 1 1 1 1 1 1 1 1 1 9 7 7 7 7
1 1 1 1 1 1 1 1 4? 4 9 9 9 7 7
1 1 1? 8 8 8 8 5 4? 4 4 9 9 7 7
1 8 8 8 8 8 5 5 5 4 4 9 9 7 7
2 8 8 8 8 8 5 5 5 4 4 4 9 7 7
2 2 8? 8 8 8 5 5 4 4 4 4 9 9 7
2 2 3 8? 8 8 5 5 6? 4 4 4 9 9 7
2 2 3 3 8 8 5 5 6 4 4 4 4 9 7
2 2 3 3 3 3 3 6 6 6 4 4 4? 9 7
2 2 3 3 3 3 3 6 6 6 6? 4 4 9 9
2 2 3 3 5 5 3 6 6 6 6 4 4 4 9
2 2 3 3 5 5 5 6 6 6 6 6 4 4 9
2 3 3 3? 5 5 0 0 6 6 6 6 6 4 4
2 3 3 5 5 0 0 0 0 0 6 6 6 4? 4
2 3 5 0 0 0 0 0 0 0 0 0 6 6 4?
células com confiança < 0.6 (marcadas com ?): 11 de 225
dígitos que aparecem na grade: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; células por dígito: [16, 22, 18, 24, 36, 23, 28, 16, 23, 19]
célula da Figure 5 mais próxima da mediana de cada dígito: {0: (15, 8), 1: (1, 4), 2: (9, 1), 3: (9, 4), 4: (8, 12), 5: (7, 7), 6: (12, 10), 7: (6, 15), 8: (7, 5), 9: (8, 13)}
with torch.no_grad():
rec_val = {d: m.decode(m.encode(X_val)[0]) for d, m in [(2, model2), (16, model16)]}
yv = y_val.numpy()
for d in (16, 2):
acc = (judge(rec_val[d])[0] == yv).mean()
print(f"E2) d = {d:2d}: o classificador acerta o dígito em {acc:.4f} das reconstruções de validação")
def within_class_spread(imgs):
"""Distância quadrática média de cada imagem até a média do seu dígito (variação de estilo)."""
return np.mean([((imgs[yv == k] - imgs[yv == k].mean(0)) ** 2).sum(1).mean().item() for k in range(10)])
s_orig = within_class_spread(X_val)
for d in (16, 2):
print(f" d = {d:2d}: variação dentro do dígito nas reconstruções = {within_class_spread(rec_val[d]) / s_orig:.1%} "
f"da variação nas imagens originais")
E2) d = 16: o classificador acerta o dígito em 0.9584 das reconstruções de validação
E2) d = 2: o classificador acerta o dígito em 0.8279 das reconstruções de validação
d = 16: variação dentro do dígito nas reconstruções = 72.9% da variação nas imagens originais
d = 2: variação dentro do dígito nas reconstruções = 34.5% da variação nas imagens originais
D — Interpolation¶
Escolhi as imagens de teste de índice 3 (um 0) e 2 (um 1), as primeiras desses dígitos no teste.
Os 10 valores de $t$ vão de 0 a 1. A linha de cima interpola os pixels, $(1 - t)\,\mathbf{x}_a + t\,\mathbf{x}_b$. A linha de baixo interpola os códigos do modelo $d = 16$ do Exercise 1 e decodifica, $(1 - t)\,\boldsymbol{\mu}_a + t\,\boldsymbol{\mu}_b$.
IA, IB = 3, 2 # índices de teste: um 0 e um 1
print(f"D) índices de teste: a = {IA} (dígito {y_test[IA].item()}), b = {IB} (dígito {y_test[IB].item()})")
t = torch.linspace(0, 1, 10)[:, None]
def interpolate(xa, xb):
"""Linha de cima: mistura dos pixels. Linha de baixo: mistura dos mu do modelo d = 16, decodificada."""
pix = (1 - t) * xa + t * xb
with torch.no_grad():
mua, mub = model16.encode(torch.stack([xa, xb]))[0]
lat = model16.decode((1 - t) * mua + t * mub)
return pix, lat
pix, lat = interpolate(X_test[IA], X_test[IB])
fig, axes = plt.subplots(2, 10, figsize=(13, 3.2))
for j in range(10):
for row, imgs in enumerate([pix, lat]):
axes[row, j].imshow(imgs[j].reshape(28, 28), cmap="gray", vmin=0, vmax=1)
axes[row, j].set_xticks([]); axes[row, j].set_yticks([])
axes[0, j].set_title(f"t = {t[j].item():.2f}", fontsize=9)
axes[0, 0].set_ylabel("pixels"); axes[1, 0].set_ylabel("latente\n(d = 16)")
fig.supxlabel("t (fração do caminho da imagem a até a imagem b)", y=-0.02)
fig.suptitle(f"Figure 6 — Exercise 2: interpolação da imagem de teste {IA} (dígito {y_test[IA].item()}) "
f"até a {IB} (dígito {y_test[IB].item()})")
fig.savefig(FIG / "fig6.png", bbox_inches="tight"); plt.show()
gray = lambda imgs: ((imgs > 0.1) & (imgs < 0.9)).float().mean(1).numpy() * 100 # % de pixels "cinza"
for name, imgs in [("pixels", pix), ("latente", lat)]:
lab, c = judge(imgs)
print(f" {name:7s}: dígito visto {lab.tolist()}")
print(f" confiança {np.round(c, 2).tolist()}")
print(f" % de pixels entre 0.1 e 0.9 {np.round(gray(imgs), 1).tolist()}")
# O mesmo teste nos 45 pares formados pelas primeiras imagens de teste de cada dígito (as da Figure 2).
# Só os 8 quadros do meio (t de 0.11 a 0.89) entram na média.
first_idx = [int((y_test == k).nonzero()[0]) for k in range(10)]
stats = {"pixels": {"cinza": [], "conf": []}, "latente": {"cinza": [], "conf": []}}
for i in range(10):
for j in range(i + 1, 10):
for name, imgs in zip(["pixels", "latente"], interpolate(X_test[first_idx[i]], X_test[first_idx[j]])):
stats[name]["cinza"].append(gray(imgs[1:-1]).mean())
stats[name]["conf"].append(judge(imgs[1:-1])[1].mean())
for name in stats:
print(f" 45 pares, {name:7s}: pixels cinza nos quadros do meio {np.mean(stats[name]['cinza']):.1f}%, "
f"confiança média do classificador {np.mean(stats[name]['conf']):.2f}")
print(f" pares em que a linha latente tem menos pixels cinza que a de pixels: "
f"{(np.array(stats['latente']['cinza']) < np.array(stats['pixels']['cinza'])).sum()} de 45")
D) índices de teste: a = 3 (dígito 0), b = 2 (dígito 1)
pixels : dígito visto [0, 0, 0, 0, 0, 0, 8, 1, 1, 1]
confiança [1.0, 1.0, 0.9900000095367432, 0.9700000286102295, 0.8199999928474426, 0.38999998569488525, 0.28999999165534973, 0.3199999928474426, 0.9399999976158142, 1.0]
% de pixels entre 0.1 e 0.9 [9.399999618530273, 22.799999237060547, 24.5, 24.100000381469727, 24.100000381469727, 24.200000762939453, 23.200000762939453, 22.200000762939453, 18.399999618530273, 4.800000190734863]
latente: dígito visto [0, 0, 0, 0, 0, 2, 1, 1, 1, 1]
confiança [1.0, 1.0, 1.0, 0.9900000095367432, 0.6800000071525574, 0.30000001192092896, 0.6100000143051147, 0.9700000286102295, 0.9900000095367432, 1.0]
% de pixels entre 0.1 e 0.9 [13.300000190734863, 14.199999809265137, 18.5, 19.799999237060547, 20.299999237060547, 18.200000762939453, 14.199999809265137, 10.199999809265137, 7.300000190734863, 5.900000095367432]
45 pares, pixels : pixels cinza nos quadros do meio 23.1%, confiança média do classificador 0.81
45 pares, latente: pixels cinza nos quadros do meio 20.2%, confiança média do classificador 0.81
pares em que a linha latente tem menos pixels cinza que a de pixels: 39 de 45
E — Analysis¶
Quais dígitos se sobrepõem na Figure 4? O 4 e o 9 formam quase uma única nuvem, à direita do centro: as medianas de $\boldsymbol{\mu}$ são (0.74, 0.00) e (0.94, 0.03), a 0.2 de distância. No centro, o 3, o 5 e o 8 também se misturam. Para medir isso, usei um classificador de 15 vizinhos no plano latente, ajustado com os $\boldsymbol{\mu}$ do treino e aplicado no teste. Ele acertou 83.19%, e os pares mais confundidos foram 4 e 9 (608 erros nos dois sentidos), 3 e 8 (172), 5 e 8 (172), 7 e 9 (116) e 3 e 5 (100).
Na Figure 5, a transição do 4 para o 9 está no lado direito. Na linha $z_2 = 0.50$, o classificador auxiliar reconhece as células de $z_1 = 0.33$ a $0.70$ como 4 e a de $z_1 = 0.92$ como 9. As imagens intermediárias são um 4 com a parte de cima cada vez mais fechada, que pode ser lido como 4 ou como 9. Algumas têm traços mais fracos, como uma sobreposição dos dois dígitos. O 3, o 5 e o 8 dividem o centro da grade da mesma forma: na linha $z_2 = 0.00$, indo do 5 para a esquerda, as imagens passam a um 8 e depois a um 3.
O que $d = 2$ perde. Com dois números por imagem, o modelo guarda 6.75 nats de informação sobre cada imagem, contra 19.94 com $d = 16$. Esses dois números codificam principalmente qual é o dígito, além de inclinação e espessura. O restante do estilo (forma dos traços, voltas e detalhes de cada escrita) é perdido. A Figure 5 mostra isso: o plano contém uma versão média de cada dígito, que varia apenas em inclinação e largura, e nenhuma célula mostra um estilo diferente. Duas medidas na validação, decodificando $\boldsymbol{\mu}$, confirmam isso:
- a variação dentro de cada dígito (distância média de cada imagem até a média do seu dígito) nas reconstruções é 72.9% da variação das imagens originais com $d = 16$ e 34.5% com $d = 2$;
- o classificador auxiliar reconhece o dígito correto em 95.84% das reconstruções com $d = 16$ e em 82.79% com $d = 2$. Nos dígitos que se sobrepõem, $d = 2$ perde também a identidade do dígito.
Por isso a reconstrução é 51.52 nats pior.
As duas linhas da Figure 6. A linha latente passa por imagens que parecem dígitos: é uma única forma que se transforma, com o 0 ficando mais estreito e alongado até virar um 1. Os quadros de $t = 0.44$ e $0.56$ são os menos claros, uma forma oval e estreita entre o 0 e o 1, mas ainda formada por um único traço. A linha de pixels não passa por dígitos: ela mostra um 0 e um 1 sobrepostos, cada um com parte do brilho. Nos quadros do meio cerca de 24% dos pixels são cinza, contra 9.4% no 0 original. A média de duas imagens de dígitos em geral não é a imagem de um dígito. No espaço latente, cada imagem é uma gaussiana, e a sobreposição dessas gaussianas preenche o espaço entre os exemplos de treino (Aula 17). Por isso o segmento entre $\boldsymbol{\mu}_a$ e $\boldsymbol{\mu}_b$ passa por códigos que o decoder transforma em imagens plausíveis.
Repeti a comparação nos 45 pares formados pelas imagens da Figure 2. Nos quadros do meio, a linha de pixels teve 23.1% de pixels cinza e a latente 20.2%, e a latente teve menos pixels cinza em 39 dos 45 pares. A diferença é pequena porque o decoder também gera pixels cinza (20.3% no quadro latente de $t = 0.44$). O classificador auxiliar deu a mesma confiança média às duas linhas (0.81), porque classifica a sobreposição pelo dígito mais forte no quadro. Por isso ele não mede bem essa diferença.
Results summary¶
| # | Quantity | Value |
|---|---|---|
| 1 | Ex. 1 — test reconstruction, KL and negative ELBO ($d = 16$) | reconstrução 81.77; KL 19.94; $-$ELBO 101.72 nats por imagem |
| 2 | Ex. 1 — validation KL at the first and at the last epoch | 13.44 (época 1) e 19.88 (época 20) |
| 3 | Ex. 2 — test reconstruction and KL ($d = 2$) | reconstrução 133.29; KL 6.75 nats por imagem ($d = 16$: 81.77 e 19.94) |
| 4 | Ex. 2 — the two test indices interpolated | 3 (dígito 0) e 2 (dígito 1) |





