March 24, 2026
The VAE with perceptual loss produces diverse, structured outputs — different breeds, poses, colors, backgrounds.
But everything is blurry.
The standalone GAN produces sharp images but collapsed on our dataset. No encoder, no reconstruction, no structured latent space.
Today: combine them — then replace the bottleneck entirely.
Keep the full VAE. Add a PatchGAN discriminator that judges the decoder’s reconstructions.
The decoder now receives three gradient signals simultaneously:
The first two keep it honest. The third keeps it sharp.
GAN discriminators produced a single real/fake score per image. Need more information for reconstruction
The PatchGAN instead outputs a grid of scores — each one corresponding to an overlapping receptive field of the input.
Goal: For each patch in the image, try to maximize the realness score given by the adversarial discriminator.
Each position in the \(15 \times 15\) output sees a roughly \(34 \times 34\) patch of the \(128 \times 128\) input. Adjacent positions overlap heavily — offset by only 8 pixels.
Before final 1x1 convolution on the channels (weighted average of the channel values), each position in the 15x15 grid is associated with a vector of features (perceptual)
The weighted average links to the real/fake score by saying “Is this collection of features in this broad area realistic?”
This connects to the perceptual loss idea!
The discriminator doesn’t care about global composition — that’s the reconstruction loss’s job.
It cares about whether the local features at any given point could plausibly come from a real image.
Realistic fur texture? Realistic edge sharpness? Realistic eye detail? Wherever they appear?
class PatchDiscriminator(nn.Module):
def __init__(self):
super().__init__()
self.model = nn.Sequential(
nn.Conv2d(3, 64, 4, stride=2, padding=1), # 128→64, RF: 4
nn.LeakyReLU(0.2),
nn.Conv2d(64, 128, 4, stride=2, padding=1), # 64→32, RF: 10
nn.GroupNorm(32, 128),
nn.LeakyReLU(0.2),
nn.Conv2d(128, 256, 4, stride=2, padding=1), # 32→16, RF: 22
nn.GroupNorm(32, 256),
nn.LeakyReLU(0.2),
nn.Conv2d(256, 1, 4, stride=1, padding=1), # 16→15, RF: 34
)The first three layers downsample with stride=2, building up a \(34 \times 34\) receptive field. The final layer uses stride=1 — it doesn’t downsample further, just produces the real/fake score at each position.
LeakyReLU, not ReLU — the discriminator needs gradients for “obviously fake” regions.
\[\mathcal{L}_G = \underbrace{\text{L1}(\mathbf{x}, \hat{\mathbf{x}})}_{\text{pixel}} + \underbrace{\lambda \cdot \text{LPIPS}(\mathbf{x}, \hat{\mathbf{x}})}_{\text{perceptual}} + \underbrace{\beta \cdot D_{KL}(Q \| P)}_{\text{regularization}} + \underbrace{w_{\text{gan}} \cdot \mathcal{L}_{\text{GAN}}}_{\text{sharpness}}\]
\(w_{\text{gan}}\) is set by the same gradient balancing principle — now balancing combined reconstruction vs GAN:
\[w_{\text{gan}} = \frac{\|\nabla_{\mathbf{w}} \mathcal{L}_{\text{recon}}\|}{\|\nabla_{\mathbf{w}} \mathcal{L}_{\text{GAN}}\| + \epsilon}\]
If we activate the discriminator from epoch 1, it overwhelms the VAE before the encoder and decoder have learned useful representations.
Train as a pure VAE (L1 + LPIPS + KL) for the first N epochs. Then activate the discriminator.
The VAE establishes good reconstructions first. The GAN sharpens on top of an already-working model.
The standalone GAN collapsed because the generator had no anchor — its only objective was fooling D. It found one good image and repeated it.
The VAE-GAN can’t collapse because of the reconstruction loss. Every training image demands its own reconstruction. Diversity is mandatory, not optional!
for data, *labels in train_loader:
# ── Discriminator step ──
recon, mu, logvar = model(data)
real_logits = D(data)
fake_logits = D(recon.detach()) # detach: don't update VAE
d_loss = discriminator_loss(real_logits, fake_logits)
opt_D.zero_grad(); d_loss.backward(); opt_D.step()
# ── Generator (VAE) step ──
recon, mu, logvar = model(data) # fresh forward pass
fake_logits = D(recon) # no detach: grads to VAE
recon_loss = l1 + adaptive_weight * lpips
gan_loss = generator_loss(fake_logits)
w_gan = compute_adaptive_weight(recon_loss, gan_loss, last_layer)
total = recon_loss + beta * kl + disc_factor * w_gan * gan_loss
opt_G.zero_grad(); total.backward(); opt_G.step()The images are sharper, but look closely:
Two problems:
The flat vector bottleneck — \(\mathbf{z} \in \mathbb{R}^{256}\) destroys all spatial information. The decoder must reconstruct “what goes where” from scratch.
KL-reconstruction tension — training is slooooooooooooowwwwwww. In initial stages, the VAE pushes the KL divergence close to zero and the rest of the training procedure is spent moving away from the prior.
Can we fix both at once?
The current bottleneck:
\[\text{Encoder} \to (B, 512, 4, 4) \to \text{Flatten} \to \mathbb{R}^{8192} \to \text{fc\_mu} \to \mathbb{R}^{256}\]
The encoder produces a spatial feature map — position \((0,0)\) represents the top-left region, position \((3,3)\) represents the bottom-right.
Then we flatten and project — mixing every spatial position into one vector.
The decoder receives \(\mathbb{R}^{256}\) and must figure out: what goes in the top-left? What goes in the bottom-right? How do eyes relate to ears? All from a vector with no spatial organization.
What if we skip the flatten entirely?
\[\text{Encoder} \to (B, D, 4, 4)\]
Each position in the \(4 \times 4\) grid corresponds to a specific region of the image. Top-left stuff stays in the top-left. The decoder starts with a rough spatial map and refines it.
The decoder’s job is now much easier: position \((0,0)\) should produce the top-left of the image. It receives features that already encode what the top-left looks like.
The spatial bottleneck helps with structure. But each of those \(D \times 4 \times 4\) continuous values still needs KL regularization toward \(\mathcal{N}(0, 1)\).
That’s \(D \times 16\) KL terms pulling each latent dimension toward zero — fighting the reconstruction loss at every step.
For D = 256, that’s 4096 KL terms! If each term contributes even a small amount to the loss, it adds up quickly and overwhelms the reconstruction loss
We’re left at an impasse - even weak spatial structure will kill our VAE!
What if instead of pushing continuous latents toward a Gaussian, we snap each spatial position to the nearest prototype from a learned dictionary?
This is clustering. K-means finds \(K\) centroids that summarize the data. Our codebook learns \(K\) prototype feature vectors that summarize the encoder’s output space.
Clustering is dimensionality reduction — project a complicated, redundant pixel space to a small set of representative locations. We’ve been doing this all semester:
A codebook \(\mathbf{E} \in \mathbb{R}^{K \times D}\) — a matrix of \(K\) learned vectors, each of dimension \(D\).
Each row is a visual word — a prototype feature that captures a commonly occurring pattern in the encoder’s output.
Think of it like a palette. An artist doesn’t use infinitely many colors — they use a finite palette and paint from those. The codebook is a palette of visual features.
The encoder outputs a continuous spatial feature map: \(4 \times 4 \times D\)
For each of the 16 spatial positions \((h, w)\):
The output is a grid of 16 codebook indices — discrete tokens, not continuous values.
Once the visual vocabulary for an image has been created in the latent space, decode to an image!
Locations in the 4x4 grid are spatially meaningful!
Decoder is highly nonlinear and can figure out how grass background, gray fur, cat eyes, and cat ears should go together!
With a continuous VAE, regularization (e.g. generalization) comes from the KL divergence — forcing the latent distribution toward \(\mathcal{N}(0, I)\).
With VQ-VAE, regularization comes from the codebook size \(K\).
Large \(K\) (e.g. 4096): enough visual words for fine distinctions. Less compression. Risk of memorizing rather than generalizing.
\(K\) directly controls the compression-reconstruction tradeoff — the same role \(\beta\) plays in the VAE, but as an architectural choice rather than a loss weight.
How much variation can we really have with only 512 vectors, though?
With \(K = 512\) codebook entries and a \(4 \times 4\) spatial grid (16 positions):
\[\text{Number of possible images} = K^{16} = 512^{16} \approx 10^{43}\]
Even with a small codebook (\(K = 64\)):
\[64^{16} \approx 10^{29}\]
Still billions of billions of billions of unique images. Discretization does not limit expressivity — it structures it.
The prior over codebook entries is uniform — each entry is equally likely.
\[D_{KL}(Q(\mathbf{z} | \mathbf{x}) \| P(\mathbf{z})) = \log K\]
This is a constant. It drops out of the optimization entirely.
No beta. No free bits. No warmup schedule. No annealing. No slow reconstruction tension.
The codebook provides structure through discretization rather than explicit prior tension