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
\(\arg\min\) selects a discrete index. Discrete operations have zero gradient everywhere!
The computation graph:
\[\text{Input} \;\xrightarrow{\text{Encoder}}\; \mathbf{z}_e \;\xrightarrow{\arg\min}\; k^* \;\xrightarrow{\text{lookup}}\; \mathbf{z}_q \;\xrightarrow{\text{Decoder}}\; \hat{\mathbf{x}}\]
The decoder produces gradients \(\frac{\partial \mathcal{L}}{\partial \mathbf{z}_q}\). These need to reach \(\mathbf{z}_e\) to update the encoder.
But the argmin/lookup step blocks them.
The fix: during the forward pass, use \(\mathbf{z}_q\) (the actual codebook entry). During the backward pass, pretend the quantization didn’t happen — copy gradients directly from \(\mathbf{z}_q\) to \(\mathbf{z}_e\).
Implementation — a single line of code:
Forward pass: z_e + (z_q - z_e) = z_q ✓ — the decoder receives the quantized vector.
Backward pass: (z_q - z_e).detach() has zero gradient. So PyTorch treats this as just z_e for gradient computation. Gradients from the decoder flow straight through to the encoder.
If \(\mathbf{z}_e\) is close to its nearest codebook entry \(\mathbf{e}_{k^*}\), then:
\[\mathbf{z}_q = \mathbf{e}_{k^*} \approx \mathbf{z}_e\]
The approximation is good when encoder outputs stay close to codebook entries.
If the encoder output drifts far from all codebook entries, the straight-through gradient becomes poor. The encoder gets misleading gradient signals.
This is why we’ll need two alignment losses — one to keep the encoder close to the codebook and one to keep the codebook close to the encoder.
\[\mathcal{L}_{\text{codebook}} = \|\text{sg}[\mathbf{z}_e] - \mathbf{e}_{k^*}\|_2^2\]
\(\text{sg}[\cdot]\) is the stop-gradient operator. It says: “use this value in the forward pass, but treat it as a constant (zero gradient) in the backward pass.”
Forward: compute the L2 distance between the encoder output and its assigned codebook entry.
Backward: only the codebook entry \(\mathbf{e}_{k^*}\) receives gradients. The encoder output \(\mathbf{z}_e\) is treated as a fixed target.
This pulls the codebook entry toward the encoder output. The codebook learns to track where the encoder is putting things.
\[\mathcal{L}_{\text{commit}} = \beta_c \|\mathbf{z}_e - \text{sg}[\mathbf{e}_{k^*}]\|_2^2\]
Now the stop-gradient is on the codebook entry. The codebook is treated as a fixed target.
Forward: same L2 distance.
Backward: only the encoder output \(\mathbf{z}_e\) receives gradients. The codebook entry is frozen.
This pulls the encoder output toward its assigned codebook entry. The encoder learns to commit to the codebook’s structure.
Two losses pulling toward each other, but with stop-gradients so they don’t interfere.
The codebook loss only updates the codebook. The commitment loss only updates the encoder. Each treats the other’s output as a fixed target.
Without stop-gradients: both losses would tug on both networks, creating unstable dynamics where the encoder and codebook chase each other in circles.
With stop-gradients: coordinated, stable convergence.
Instead of gradient-based codebook updates, track the running mean of encoder outputs assigned to each entry:
# For each codebook entry k:
N_k = decay * N_k + (1 - decay) * n_k # count
m_k = decay * m_k + (1 - decay) * sum(z_e) # sum of assigned vectors
e_k = m_k / N_k # updated entryThis is literally the k-means centroid update applied as an exponential moving average.
More stable than gradient descent on the codebook. No codebook learning rate to tune.
When using EMA updates, the codebook loss term is dropped — EMA handles the codebook directly.
Memory leak danger!!!! Running averages if not treated appropriately will grow the tensor graph and cause massive memory usage.
The VQ equivalent of posterior collapse: some codebook entries never get selected.
The encoder’s output distribution drifts away from unused entries. Those entries receive no updates. They become dead — wasting capacity.
If \(K = 512\) but only 100 entries are active, you effectively have a much smaller codebook than intended.
Fix: periodically reinitialize dead entries by replacing them with randomly sampled encoder outputs from the current batch.
# Every N steps:
usage_count = count_assignments_per_entry()
dead = usage_count == 0
if dead.any():
random_z_e = z_e_flat[torch.randint(0, len(z_e_flat), (dead.sum(),))]
codebook.weight.data[dead] = random_z_eMonitor via perplexity measure.
\[\mathcal{L} = \underbrace{\text{L1} + \lambda \cdot \text{LPIPS} + w_{\text{gan}} \cdot \mathcal{L}_{\text{GAN}}}_{\text{reconstruction + sharpness}} + \underbrace{\|\text{sg}[\mathbf{z}_e] - \mathbf{e}\|^2}_{\text{codebook}} + \underbrace{\beta_c \|\mathbf{z}_e - \text{sg}[\mathbf{e}]\|^2}_{\text{commitment}}\]
The KL term is gone. Replaced by two simple L2 penalties that keep encoder and codebook aligned.
This architecture — VQ-VAE with perceptual loss and a PatchGAN discriminator — is called a VQGAN (Esser, Rombach, & Ommer, “Taming Transformers for High-Resolution Image Synthesis,” CVPR 2021).
With conditioning (next class), this is DALL-E 1.0!
| Term | What it does | What it updates |
|---|---|---|
| L1 | Pixel-level reconstruction | Encoder + Decoder |
| LPIPS | Feature-level perceptual quality | Encoder + Decoder |
| GAN | Sharpness, texture realism | Decoder (through D) |
| Codebook loss | Pull entries toward encoder | Codebook only |
| Commitment loss | Pull encoder toward entries | Encoder only |
The first three terms care about image quality.
The last two terms care about keeping the quantization well-behaved so the straight-through estimator works.
| VAE-GAN | VQ-VAE-GAN | |
|---|---|---|
| Regularization | KL (beta, free bits, warmup) | Codebook size \(K\) |
| Bottleneck | Flat vector, 256 dims | Spatial grid, \(4 \times 4\) tokens |
| Spatial structure | Destroyed, must reconstruct | Preserved through bottleneck |
| Convergence | Slow (KL-recon tension) | Fast (no tension) |
| Latent type | Continuous | Discrete (codebook indices) |
| Generation | Sample \(\mathbf{z} \sim \mathcal{N}(0,I)\) | Needs autoregressive model |
Images have strong spatial structure — nearby pixels are correlated, objects have consistent shapes, backgrounds are coherent. The spatial bottleneck preserves this.
Images have strong redundancy — many patches of fur look similar, many backgrounds are similar green grass. The codebook exploits this by learning shared visual words.
Images need sharp boundaries — edges between foreground and background, transitions between colors. Discrete codebook entries produce cleaner boundaries than continuous values that blur between entries.
The flat bottleneck forces the decoder to reconstruct spatial layout from a vector with no spatial organization.
The VQ-VAE’s spatial bottleneck preserves the layout. Position \((1,1)\) in the \(4 \times 4\) grid corresponds to the top-left of the image. The decoder starts with a rough spatial map and refines it.
But note: each spatial position is independent. Position \((1,1)\) doesn’t know about position \((1,2)\). The model can’t enforce “left eye and right eye should match.”
We built two improvements to the VAE today:
VAE-GAN: Added a PatchGAN discriminator for sharpness. Three gradient signals keep the decoder honest and sharp. Delayed start prevents collapse.
VQ-VAE-GAN (VQGAN): Replaced the flat Gaussian bottleneck with a spatial discrete codebook. Eliminated KL entirely. Codebook size \(K\) controls compression. Spatial structure preserved. Faster training, no hyperparameter balancing.
Key references:
We can reconstruct sharply with good spatial structure. But we can’t yet control what we generate.
Next lecture: interpreting VQ-VAE codebooks, conditioning, editing, the ethical implications, and the generation problem that bridges us to sequences and transformers.