Skip to main content

Week 07 — From uncertain predictions to generating images

A fitted line makes one prediction. What other lines could explain the same measurements? Later, an encoder will give an image several plausible hidden codes. We will inspect both kinds of uncertainty, then use a decoder to generate new images.

Educational draft for instructor review. This is a guided reading with working code: predict what will happen, run a short calculation, and explain the resulting figure. The exercises ask you to change settings; no missing code blocks prevent Run all.

Route Question Suggested discussion time
1–2 · Regression Which lines remain plausible after observing data? 20 min
3 · Dropout What happens when prediction includes random masks? 10 min
4–6 · VAE anatomy How does an image become a distribution of codes? 30 min
7–8 · Train and explore Can we reconstruct, interpolate, and generate? 25 min
Interpretation What was uncertain in each model? 5 min

Scene 2 derives the weight posterior from Bayes’ theorem. Scene 6 includes a 5–10 minute theory walkthrough connecting latent inference to the ELBO and the implemented loss. Both explanations are self-contained; the slides are optional companions.

Optional: a short Naive Bayes exercise. For a shorter class, skip the dropout comparison and optional exercises; keep the VAE walkthrough.

Learning objectives: distinguish parameter uncertainty from observation noise; read an encoder with two output heads; connect a regression posterior to Bayes’ theorem; explain reparameterization; derive the ELBO and its two VAE loss terms; distinguish reconstruction from generation.

Read and run: Open in Colab, or download and upload this notebook. Run cells in order. Use Python 3 with NumPy, Matplotlib, PyTorch, Torchvision, and IPython. No new library is needed beyond the existing course environment. The default path trains on a small thresholded MNIST subset on CPU; allow up to a few minutes of computation, depending on hardware, in addition to reading. A timer reports the actual run. MNIST is loaded from cache or downloaded in the explicitly labeled data cell. The offline check uses labeled synthetic patterns instead of digits.

TODO(instructor): review the educational text, 90-minute pacing, MNIST download/environment choice, and classroom subset size before release.

Notation: what is uncertain?

Symbol / code name Meaning and shape
\(\mathcal D\), x_train, y_train Observed training data; regression arrays of length \(n\)
\(\theta=(b,w)\) Regression intercept and slope; column vector with 2 entries
\(X\), design Regression design matrix \((n,2)\); each row is \([1,x_i]\)
\(\sigma^2\), NOISE_SD**2 Known observation-noise variance in the synthetic regression model
\(\tau^2\), PRIOR_SD**2 Variance of each independent zero-mean regression weight prior
\(B\), \(P\) Image batch size and pixel count; \(P=784\)
\(u\), u VAE latent code; shape \((B,2)\)
\(q_\psi(u\mid x)\) Encoder’s approximate latent posterior; encoder weights \(\psi\)
mu, logvar Mean and log variance of each latent coordinate; both \((B,2)\)
\(p_\theta(x\mid u)\) Decoder’s image distribution; decoder weights \(\theta\)
\(L\), \(\mathcal E\) Minimized loss and maximized evidence lower bound (ELBO)

The context distinguishes regression weights from decoder weights, both denoted \(\theta\) in the lecture. We retain \(u\) for latent codes, \(\psi\) for encoder weights, and \(\phi\) for activations; some papers instead use \(z\) and \(\phi\) for the first two.

Natural logarithms give losses in nats. For images, sum pixel contributions and latent-coordinate contributions within each image, then average across images. * on same-shaped tensors multiplies coordinatewise; @ is matrix multiplication. Batch rows contain observations; network features occupy columns.

Setup: imports and reproducible CPU settings
# @title Setup — run once
import copy
import os
import time
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
import torch
from torch import nn
from torch.nn import functional as F
from IPython.display import display, Markdown

started = time.perf_counter()
SEED = 17
EXPORT_FIGURES = False  # Instructor setting; requires a local repository checkout.
repository_root = Path.cwd().resolve()
if not (repository_root / "_quarto.yml").exists():
    repository_root = repository_root.parent
figure_directory = repository_root / "assets" / "figures" / "shared" / "week07"
if EXPORT_FIGURES:
    assert (repository_root / "_quarto.yml").exists(), "Export from the repository or tutorials directory."
    figure_directory.mkdir(parents=True, exist_ok=True)
plt.rcParams["svg.hashsalt"] = "week07-tutorial"
np.random.seed(SEED)
torch.manual_seed(SEED)
torch.set_num_threads(2)
plt.rcParams.update({"font.size": 11, "figure.dpi": 110})
print(f"PyTorch {torch.__version__}; calculations and training use CPU.")
PyTorch 2.8.0; calculations and training use CPU.

Auxiliary figures

Run this folded cell once, then continue to Scene 1. Plotting and display functions live here so the scenes keep their calculations visible. Model definitions, posterior calculations, sampling, losses, and training remain beside their explanations. Figures are generated from this run; captions explain the same information in words.

Auxiliary plotting helpers (expand to edit the figures)
# @title Auxiliary plotting helpers

def export_figure(fig, name):
    if EXPORT_FIGURES:
        fig.savefig(figure_directory / f"{name}.svg", metadata={"Date": None})
        fig.savefig(figure_directory / f"{name}.png", dpi=150)


def line_plot(x, y, grid, curves, labels, title):
    fig, ax = plt.subplots(figsize=(7.5, 3.5), layout="constrained")
    ax.scatter(x, y, c="black", s=25, label="Training observations", zorder=3)
    for curve, label in zip(curves, labels):
        ax.plot(grid, curve, label=label)
    ax.axvspan(-1, 1, color="gray", alpha=0.08, label="Training input range")
    ax.set(xlabel="Input x", ylabel="Target / predicted mean", title=title)
    ax.legend(fontsize=9)
    plt.show()


def posterior_plot(x, y, grid, draws, mean, mean_sd, future_sd):
    fig, axes = plt.subplots(1, 2, figsize=(9, 3.7), layout="constrained")
    for ax in axes:
        ax.scatter(x, y, c="black", s=20, zorder=3)
        ax.plot(grid, mean, color="tab:blue", label="Posterior mean")
        ax.axvspan(-1, 1, color="gray", alpha=0.08)
        ax.set(xlabel="Input x", ylabel="Target / predicted mean")
    axes[0].plot(grid, draws.T, color="tab:blue", alpha=0.2, linewidth=0.8)
    axes[0].fill_between(grid, mean-1.96*mean_sd, mean+1.96*mean_sd,
                         alpha=0.2, label="95% band: mean function")
    axes[1].fill_between(grid, mean-1.96*future_sd, mean+1.96*future_sd,
                         color="tab:orange", alpha=0.25, label="95%: one future observation")
    axes[0].set_title("Plausible lines: weights vary")
    axes[1].set_title("Future observations also include noise")
    for ax in axes:
        ax.legend(fontsize=8)
    export_figure(fig, "tutorial-regression-uncertainty")
    plt.show()


def dropout_plot(x, y, grid, draws, deterministic, bayesian_mean):
    fig, axes = plt.subplots(1, 2, figsize=(9, 3.6), layout="constrained")
    for ax in axes:
        ax.scatter(x, y, c="black", s=20, zorder=3)
        ax.axvspan(-1, 1, color="gray", alpha=0.08)
        ax.set(xlabel="Input x", ylabel="Predicted mean")
    axes[0].plot(grid, draws[:20].T, color="tab:green", alpha=0.25)
    axes[0].plot(grid, deterministic, color="black", linestyle="--", label="Dropout off")
    axes[0].set_title("Different masks, different predictions")
    low, high = np.quantile(draws, [0.025, 0.975], axis=0)
    axes[1].fill_between(grid, low, high, color="tab:green", alpha=0.2,
                         label="Central 95% of masked predictions")
    axes[1].plot(grid, draws.mean(axis=0), color="tab:green", label="Dropout mean")
    axes[1].plot(grid, bayesian_mean, color="tab:blue", linestyle="--", label="Bayesian line mean")
    axes[1].set_title("Two different model families")
    for ax in axes:
        ax.legend(fontsize=8)
    plt.show()
Image galleries, architecture diagram, and offline pattern generator
# @title Auxiliary image helpers

def image_gallery(rows, row_names, title, n=8):
    fig, axes = plt.subplots(len(rows), n, figsize=(9, 1.5*len(rows)),
                             squeeze=False, layout="constrained")
    for r, (images, name) in enumerate(zip(rows, row_names)):
        for j in range(n):
            axes[r, j].imshow(images[j].detach().cpu().reshape(28, 28),
                              cmap="gray", vmin=0, vmax=1)
            axes[r, j].set_xticks([])
            axes[r, j].set_yticks([])
            if j == 0:
                axes[r, j].set_ylabel(name, fontsize=10)
    fig.suptitle(title)
    plt.show()


def architecture_figure():
    fig, ax = plt.subplots(figsize=(9, 3.2), layout="constrained")
    ax.set(xlim=(-0.6, 5.6), ylim=(-1.6, 1.6))
    ax.axis("off")
    boxes = [(0, 0, "Image x\n(B, 784)"), (1.2, 0, "Encoder\n784 → 128\nReLU"),
             (2.55, 0.9, "Mean μ\n(B, 2)"), (2.55, -0.9, "Log variance\n(B, 2)"),
             (3.9, 0, "Sample u\nμ + σ * ε\n(B, 2)"),
             (5.2, 0, "Decoder\n2 → 128 → 784\nPixel logits")]
    for x, y, label in boxes:
        ax.text(x, y, label, ha="center", va="center", fontsize=10,
                bbox={"boxstyle":"round,pad=0.4", "fc":"#eaf2f8", "ec":"#345b7e"})
    for start, end in [((0.45,0),(.68,0)), ((1.7,.15),(2.15,.7)),
                       ((1.7,-.15),(2.15,-.7)), ((2.95,.75),(3.5,.15)),
                       ((2.95,-.75),(3.5,-.15)), ((4.38,0),(4.65,0))]:
        ax.annotate("", xy=end, xytext=start,
                    arrowprops={"arrowstyle":"->", "color":"#345b7e"})
    ax.text(3.9, -1.35, "ε ~ N(0, I₂); σ = exp(logvar / 2)", ha="center", fontsize=10)
    export_figure(fig, "tutorial-vae-architecture")
    plt.show()


def sampling_figure(mu, std, codes):
    fig, ax = plt.subplots(figsize=(5.7, 3.6), layout="constrained")
    ax.scatter(codes[:, 0], codes[:, 1], s=12, alpha=0.3, label="Sampled latent codes")
    ax.scatter(mu[0], mu[1], marker="x", c="black", s=70, label="Encoder mean")
    ax.set(xlabel="Latent coordinate u₁", ylabel="Latent coordinate u₂",
           title=f"Same image: SDs {std[0]:.2f} and {std[1]:.2f}")
    ax.set_aspect("equal", adjustable="datalim")
    ax.legend(fontsize=9)
    plt.show()


def history_figure(history):
    fig, axes = plt.subplots(1, 2, figsize=(9, 3.4), layout="constrained")
    epochs = np.arange(1, len(history)+1)
    for ax, key, title in zip(axes, ["nll", "kl"], ["Reconstruction NLL", "KL to latent prior"]):
        ax.plot(epochs, [h["train_"+key] for h in history], marker="o", label="Training")
        ax.plot(epochs, [h["val_"+key] for h in history], marker="s", label="Validation")
        ax.set(xlabel="Epoch", ylabel="Nats per image", title=title)
        ax.legend(fontsize=9)
    plt.show()


def latent_figure(means, labels, name):
    fig, ax = plt.subplots(figsize=(6.5, 4.2), layout="constrained")
    for label in np.unique(labels):
        selected = labels == label
        ax.scatter(means[selected, 0], means[selected, 1], s=10, alpha=0.5,
                   label=str(label))
    ax.set(xlabel="Encoder mean μ₁", ylabel="Encoder mean μ₂", title=name)
    ax.legend(title="Inspection label", bbox_to_anchor=(1.02, 1), loc="upper left", fontsize=8)
    export_figure(fig, "tutorial-vae-latent-map")
    plt.show()


def decoder_grid(probabilities, coordinates):
    side = len(coordinates)
    tiles = probabilities.detach().cpu().reshape(side, side, 28, 28)
    mosaic = tiles.permute(0, 2, 1, 3).reshape(side*28, side*28)
    fig, ax = plt.subplots(figsize=(5.7, 5.7), layout="constrained")
    ax.imshow(mosaic, cmap="gray", vmin=0, vmax=1)
    centers = np.arange(side)*28 + 13.5
    ax.set_xticks(centers, [f"{v:.1f}" for v in coordinates])
    ax.set_yticks(centers, [f"{v:.1f}" for v in coordinates[::-1]])
    ax.set(xlabel="Chosen u₁", ylabel="Chosen u₂", title="Decoder probabilities across latent coordinates")
    plt.show()


def binary_patterns(count, seed):
    # Offline mechanics check: bars, crosses, and boxes. These are not MNIST.
    rng = np.random.default_rng(seed)
    images = np.zeros((count, 28, 28), dtype=np.float32)
    labels = rng.integers(0, 4, count)
    for i, label in enumerate(labels):
        top, left = rng.integers(4, 10, 2)
        width = int(rng.integers(10, 16))
        if label in (0, 2, 3):
            images[i, top:top+2, left:left+width] = 1
        if label in (1, 2, 3):
            images[i, top:top+width, left:left+2] = 1
        if label == 3:
            images[i, top+width-2:top+width, left:left+width] = 1
            images[i, top:top+width, left+width-2:left+width] = 1
    return torch.from_numpy(images.reshape(count, 784)), torch.from_numpy(labels)

Scene 1 — One fitted line

Imagine a sensor whose reading increases with its input. The synthetic generator is

\[y_i=0.3+2x_i+\epsilon_i,\qquad \epsilon_i\sim\mathcal N(0,\sigma^2).\]

We know the generator because we wrote it. A learner sees only the observations. Here noise is independent across observations and has the same known standard deviation everywhere.

Draw three independent sets before fitting. Training fits the models; validation provides a descriptive check. The test set is reserved and remains unused throughout this tutorial. Inputs in all three sets lie in \([-1,1]\). The wider plotting grid is an extrapolation probe, not a test dataset.

# Student setting: use 8, 20, or 80 training observations.
N_TRAIN = 20
NOISE_SD = 0.25
assert 3 <= N_TRAIN <= 120 and NOISE_SD > 0

rng_data = np.random.default_rng(SEED)
x_pool = rng_data.uniform(-1, 1, 120)
y_pool = 0.3 + 2*x_pool + rng_data.normal(0, NOISE_SD, 120)
x_train, y_train = x_pool[:N_TRAIN], y_pool[:N_TRAIN]
x_validation = rng_data.uniform(-1, 1, 60)
y_validation = 0.3 + 2*x_validation + rng_data.normal(0, NOISE_SD, 60)
x_test = rng_data.uniform(-1, 1, 60)
y_test = 0.3 + 2*x_test + rng_data.normal(0, NOISE_SD, 60)

grid = np.linspace(-2, 2, 200)
design = np.column_stack([np.ones(N_TRAIN), x_train])
design_grid = np.column_stack([np.ones(len(grid)), grid])
design_validation = np.column_stack([np.ones(len(x_validation)), x_validation])
theta_ols = np.linalg.lstsq(design, y_train, rcond=None)[0]
line_plot(x_train, y_train, grid,
          [0.3 + 2*grid, design_grid @ theta_ols],
          ["Known synthetic mean", "Least-squares fitted mean"],
          "One noisy dataset, one fitted line")
print(f"Fitted intercept = {theta_ols[0]:.3f}; slope = {theta_ols[1]:.3f}")

Training observations and fitted least-squares line compared with the known synthetic mean.

Fitted intercept = 0.266; slope = 1.929

The fitted line minimizes summed squared residuals. Under our common-variance Gaussian observation model, it also maximizes the likelihood. It gives a point estimate of \(\theta\); additional procedures would be needed to report uncertainty.

Pause: with only eight measurements, would you trust the fitted slope as much as with eighty? The next scene keeps several possible slopes and intercepts instead of selecting just one.

Scene 2 — Keep a distribution over lines

Why are the weights variables?

Our prediction rule is \(f_\theta(x)=b+wx\), with \(\theta=(b,w)^\top\). In the synthetic sensor model there is one fixed, unknown intercept and slope. The sensor does not draw new weights every time we measure it; repeated readings vary because of observation noise.

What varies in Bayesian inference is our uncertainty about the unknown weights. Before seeing enough data, several pairs \((b,w)\) could be plausible. A probability distribution over \(\theta\) describes that uncertainty. Drawing weights from it lets us inspect alternative explanations of the same observations. These draws are not gradient-descent updates.

Keep two operations separate. A point-estimation method changes candidate weights while searching for one fit. Bayesian inference updates a distribution over candidate weights when data arrive. The distribution can then supply a point summary, such as its mean, while retaining the spread around it.

1 · Put the regression model into Bayes’ theorem

Let \(y=(y_1,\ldots,y_n)^\top\) and let \(X\) be the design matrix with rows \([1,x_i]\). We condition on these observed inputs. Bayes’ theorem gives

\[ p(\theta\mid\mathcal D) =\frac{\overbrace{p(y\mid X,\theta)}^{\text{likelihood}} \overbrace{p(\theta)}^{\text{prior}}} {\underbrace{p(y\mid X)}_{\text{evidence}}}. \]

The prior says which weights are plausible before these observations. The likelihood asks how well a particular candidate pair of weights explains the observed targets. The posterior combines them. The evidence normalizes the result:

\[p(y\mid X)=\int_{\mathbb R^2}p(y\mid X,v)p(v)\,dv.\]

Here \(v\) is a dummy variable ranging over possible weight pairs. The evidence depends on the data and the assumed model, but not on the candidate \(\theta\) in the numerator. To find the posterior’s shape, we can first write \(p(\theta\mid\mathcal D)\propto p(y\mid X,\theta)p(\theta)\), where \(\propto\) means “proportional to,” then normalize.

2 · Specify the prior and likelihood

Choose independent zero-mean Gaussian priors for intercept and slope:

\[\theta\sim\mathcal N(0,\tau^2 I_2),\qquad p(\theta)\propto\exp\!\left(-\frac{\theta^\top\theta}{2\tau^2}\right).\]

The prior prefers values near zero, with strength controlled by PRIOR_SD \(=\tau\). It does not assert that the true slope is zero. Its scale depends on the chosen units. The noise scale \(\sigma\) and prior scale \(\tau\) are held fixed in this example.

Independent Gaussian observation noise gives

\[y\mid X,\theta\sim\mathcal N(X\theta,\sigma^2 I_n),\]

\[p(y\mid X,\theta)\propto \exp\!\left(-\frac{(y-X\theta)^\top(y-X\theta)}{2\sigma^2}\right).\]

This is a probability density over possible targets when \(\theta\) is fixed. After observing \(y\), we evaluate it as a function of candidate weights: the likelihood. The likelihood alone is not a normalized probability density over weights.

3 · Multiply the two factors and collect the quadratic terms

Bayes’ numerator becomes

\[p(\theta\mid\mathcal D)\propto \exp\!\left[-\frac12\left( \frac{(y-X\theta)^\top(y-X\theta)}{\sigma^2} +\frac{\theta^\top\theta}{\tau^2} \right)\right].\]

Expand the squared residual:

\[(y-X\theta)^\top(y-X\theta) =y^\top y-2\theta^\top X^\top y+\theta^\top X^\top X\theta.\]

The term \(y^\top y\) is constant in \(\theta\), so it affects normalization but not the posterior’s center or spread. Define

\[A=\frac{I_2}{\tau^2}+\frac{X^\top X}{\sigma^2}.\]

The remaining exponent is \(-\tfrac12[\theta^\top A\theta-2\theta^\top X^\top y/\sigma^2]\). Notice the two contributions to \(A\): prior information and information supplied by the observed inputs.

4 · Recognize the Gaussian by completing the square

Choose \(m\) so that \(Am=X^\top y/\sigma^2\). Then

\[\begin{aligned} \theta^\top A\theta-2\theta^\top\frac{X^\top y}{\sigma^2} &=(\theta-m)^\top A(\theta-m)-m^\top Am. \end{aligned}\]

Again, the last term is constant in \(\theta\). The posterior therefore has the Gaussian form

\[p(\theta\mid\mathcal D)=\mathcal N(\theta; m,S),\qquad S=A^{-1},\qquad m=S\frac{X^\top y}{\sigma^2}.\]

\(S\) is the posterior covariance: its diagonal entries are the variances of intercept and slope; its off-diagonal entries describe their posterior covariance. \(A=S^{-1}\) is called precision. In this Gaussian model, prior precision and data precision add. A larger precision corresponds to a more constrained distribution.

This exact Gaussian answer relies on a linear mean \(X\theta\), fixed Gaussian noise, and a Gaussian weight prior. Gaussian observation noise plus a Gaussian weight prior does not generally give a Gaussian posterior for a nonlinear neural network.

5 · Match the equations to the next cell

posterior_precision implements \(A\). Solving \(AS=I_2\) gives the covariance. Solving \(Am=X^\top y/\sigma^2\) gives the mean, without explicitly constructing an inverse for that calculation. We keep both \(m\) and \(S\), then use them to draw plausible lines.

For this Gaussian posterior, its mean also equals its mode, so \(m\) is the MAP (maximum a posteriori) weight estimate. Keeping only \(m\) would lose the uncertainty stored in \(S\).

Checkpoint: what is observed and fixed when we evaluate the likelihood? What variable does the posterior describe? Why can the evidence be omitted while collecting terms in \(\theta\)?

Reference: Bishop, Pattern Recognition and Machine Learning, §3.3. The derivation above uses this notebook’s two-weight notation and known-noise assumptions.

PRIOR_SD = 1.5  # TODO(student): try 0.2 and 3.0, especially with little data.
assert PRIOR_SD > 0
prior_precision = np.eye(2) / PRIOR_SD**2
posterior_precision = prior_precision + design.T @ design / NOISE_SD**2
posterior_cov = np.linalg.solve(posterior_precision, np.eye(2))
posterior_mean = np.linalg.solve(posterior_precision, design.T @ y_train / NOISE_SD**2)

assert np.all(np.linalg.eigvalsh(posterior_cov) > 0)
np.testing.assert_allclose(posterior_precision @ posterior_cov, np.eye(2), atol=1e-12)
print("Posterior mean [intercept, slope]:", posterior_mean.round(3))
print("Posterior standard deviations:", np.sqrt(np.diag(posterior_cov)).round(3))
Posterior mean [intercept, slope]: [0.264 1.921]
Posterior standard deviations: [0.058 0.095]

From uncertain weights to uncertain predictions

At a new input, let \(a_*=[1,x_*]\). The mean function is \(f_*=a_*\theta\). Its posterior variance is \(a_*Sa_*^\top\). A new noisy observation has an additional \(\sigma^2\):

\[\mathbb E[y_*\mid x_*,\mathcal D]=a_*m,\qquad \operatorname{Var}(y_*\mid x_*,\mathcal D)= \underbrace{a_*Sa_*^\top}_{\text{uncertainty in the mean}}+ \underbrace{\sigma^2}_{\text{observation noise}}.\]

Draw plausible weights, then turn each draw into a whole line. One weight draw is shared across all inputs on that line. The two shaded bands below are pointwise intervals under this specified model; they are not simultaneous bands for the entire curve.

rng_posterior = np.random.default_rng(SEED + 1)
theta_draws = rng_posterior.multivariate_normal(posterior_mean, posterior_cov, size=30)
line_draws = theta_draws @ design_grid.T
bayesian_mean = design_grid @ posterior_mean
mean_variance = np.sum((design_grid @ posterior_cov) * design_grid, axis=1)
future_variance = mean_variance + NOISE_SD**2
posterior_plot(x_train, y_train, grid, line_draws, bayesian_mean,
               np.sqrt(mean_variance), np.sqrt(future_variance))
print("Validation RMSE (descriptive, no tuning):")
for name, theta in [("Least squares", theta_ols), ("Bayesian posterior mean", posterior_mean)]:
    rmse = np.sqrt(np.mean((design_validation @ theta - y_validation)**2))
    print(f"  {name}: {rmse:.3f}")

Posterior line draws and a mean-function uncertainty band, beside a wider band for future noisy observations.

Validation RMSE (descriptive, no tuning):
  Least squares: 0.262
  Bayesian posterior mean: 0.263

Read the figure: the left panel varies the fitted mean because weights remain uncertain. The right panel includes new measurement noise. The mean-function band can become narrow with enough data; individual readings still scatter. Outside the observed input range, these straight-line predictions rely on the assumed linear relationship continuing to hold.

TODO(student): predict, then change N_TRAIN from 20 to 80 and rerun Scenes 1–2. What happens to the two bands? Next return to 20 and increase NOISE_SD to 0.6. Finally try a narrow prior. Describe separately the movement of the mean and the change in spread. Rerun the whole notebook when changing a setting used by later scenes.

# Verification: posterior draws reproduce the analytic mean-function variance.
check_rng = np.random.default_rng(SEED + 2)
check_draws = check_rng.multivariate_normal(posterior_mean, posterior_cov, size=30000)
probe = np.array([1.0, 0.5])
np.testing.assert_allclose(np.var(check_draws @ probe), probe @ posterior_cov @ probe,
                           rtol=0.04)
# With the same noise model/prior, adding data cannot increase covariance.
extra_design = np.column_stack([np.ones(120), x_pool])
more_cov = np.linalg.solve(prior_precision + extra_design.T @ extra_design / NOISE_SD**2,
                           np.eye(2))
assert np.linalg.eigvalsh(posterior_cov - more_cov).min() > -1e-12
print("Posterior covariance and sampling checks passed.")
Posterior covariance and sampling checks passed.

Scene 3 — A network with random masks

A tiny MLP gives us a familiar architecture with one new operation:

scalar input → 32 features → dropout → 32 features → dropout → scalar output

During training, dropout randomly zeros some hidden features and rescales the retained ones. At prediction time, ordinary eval() disables it. We will deliberately turn only the dropout layers back on and run several forward passes.

This is a short illustration of Monte Carlo (MC) dropout. Its Bayesian interpretation depends on modeling assumptions; repeated masks do not automatically provide calibrated uncertainty. Read Gal & Ghahramani (2016) for the approximate inference argument.

The Bayesian model is constrained to straight lines; the MLP can bend. Their different shapes and uncertainty mechanisms make this an exploration, rather than a ranking of inference methods.

DROPOUT_P = 0.1  # TODO(student): try 0.05 or 0.3; retrain after changing this.
assert 0 <= DROPOUT_P < 1

torch.manual_seed(SEED)
dropout_model = nn.Sequential(
    nn.Linear(1, 32), nn.Tanh(), nn.Dropout(DROPOUT_P),
    nn.Linear(32, 32), nn.Tanh(), nn.Dropout(DROPOUT_P),
    nn.Linear(32, 1),
)
x_tensor = torch.tensor(x_train[:, None], dtype=torch.float32)
y_tensor = torch.tensor(y_train[:, None], dtype=torch.float32)
grid_tensor = torch.tensor(grid[:, None], dtype=torch.float32)
dropout_optimizer = torch.optim.Adam(dropout_model.parameters(), lr=0.01)

# Full-batch training keeps this small example easy to read.
dropout_model.train()
for step in range(500):
    dropout_optimizer.zero_grad()
    prediction = dropout_model(x_tensor)
    dropout_loss = F.mse_loss(prediction, y_tensor)
    dropout_loss.backward()
    dropout_optimizer.step()
assert torch.isfinite(dropout_loss)
print(f"Final masked training MSE: {dropout_loss.item():.4f}")
Final masked training MSE: 0.0377

Predict repeatedly with dropout active

First get the ordinary deterministic prediction. Then activate the dropout modules and collect 100 stochastic passes. Gradients are unnecessary for prediction. This model has no batch-normalization layers; selectively enabling dropout also avoids accidentally changing such layers in a larger model.

dropout_model.eval()
with torch.no_grad():
    deterministic = dropout_model(grid_tensor).squeeze(1).numpy()
    for layer in dropout_model.modules():
        if isinstance(layer, nn.Dropout):
            layer.train()  # Random masks during prediction only.
    torch.manual_seed(SEED + 3)
    masked_draws = torch.stack([dropout_model(grid_tensor).squeeze(1)
                               for _ in range(100)]).numpy()
dropout_model.eval()
assert masked_draws.shape == (100, len(grid))
if DROPOUT_P > 0:
    assert masked_draws.std(axis=0).max() > 0

dropout_plot(x_train, y_train, grid, masked_draws, deterministic, bayesian_mean)
with torch.no_grad():
    val_input = torch.tensor(x_validation[:, None], dtype=torch.float32)
    val_prediction = dropout_model(val_input).squeeze(1).numpy()
print(f"Dropout-off validation RMSE: {np.sqrt(np.mean((val_prediction-y_validation)**2)):.3f}")

Repeated masked MLP predictions and their empirical envelope, compared with the Bayesian line mean.

Dropout-off validation RMSE: 0.268

Read the figure: the green envelope summarizes sampled network outputs. We did not add observation noise to these draws, so it describes spread in the network’s mean predictions. It has a different meaning from the orange future-observation interval in Scene 2. Ordinary dropout also samples masks per batch entry; each displayed pass is a set of stochastic evaluations, not necessarily one coherent posterior function draw.

TODO(student): set DROPOUT_P = 0, rerun Scene 3, and explain what disappears. Then try 0.3 with fresh training. Does a wider envelope guarantee more accurate predictions? Compare inside and outside the training range without claiming calibration from one plot.

Transition: so far uncertainty affected a prediction. Now we will use a distribution inside an image architecture. The VAE’s distribution describes hidden codes for an image; the network weights themselves will be fitted point estimates.

Scene 4 — An image becomes a distribution

A deterministic autoencoder compresses an image to one code and decodes it. A variational autoencoder (VAE) adds a probabilistic structure: a prior for generating codes, a decoder distribution for images, and an encoder that approximates inference about codes.

For this tutorial, the latent prior is \(p(u)=\mathcal N(0,I_2)\). The encoder predicts an approximate posterior

\[q_\psi(u\mid x)=\mathcal N\!\left(\mu_u(x),\operatorname{diag}(\sigma_u^2(x))\right).\]

There are two latent coordinates, each with its own mean and variance. The diagonal covariance assumes they are independent within this Gaussian approximation for one image. It does not imply that encoder means across different images are independent.

Network weights are learned point estimates. The encoder’s distribution is about an image’s hidden code, unlike Scene 2’s posterior over regression weights. We can write Bayes’ theorem for that code just as we wrote it for regression weights. A nonlinear decoder makes exact inference harder, so the encoder supplies an approximation. Scene 6 derives the training objective from that Bayes rule in a continuous, step-by-step walkthrough. The inference architecture and training principle follow Kingma & Welling, Auto-Encoding Variational Bayes.

architecture_figure()

VAE architecture: image to encoder features, two mean and log-variance heads, sampled code, and decoder pixel logits.

Architecture caption: a batch of 784-pixel images enters one shared encoder. Two heads output mean and log variance, both with shape (B, 2). Independent standard-normal noise is shifted and scaled to obtain a code; the decoder returns 784 pixel logits per image.

Load a small, clearly defined dataset

Use MNIST’s training split only. Choose disjoint training and validation indices before processing; the official test split remains untouched. Labels are kept for coloring a later plot and never enter the VAE loss.

We threshold each grayscale image at pixel / 255 > 0.5. The resulting binary observations match the decoder’s Bernoulli pixel model. Reconstruction displays will be grayscale probabilities, even though the targets are binary.

The settings below explicitly enable a one-time MNIST download if the cache is missing (about 12 MB compressed). Set DOWNLOAD_MNIST = False to require a cache. CI must use the offline pattern mode; it never downloads MNIST. In Colab, the cache usually needs populating once per runtime. Dataset background: the Torchvision MNIST documentation.

USE_MNIST = True
DOWNLOAD_MNIST = True  # Explicit data-download setting; switch off when using a cache.
N_IMAGES_TRAIN = 8000
N_IMAGES_VALIDATION = 1000
assert N_IMAGES_TRAIN >= 32 and N_IMAGES_VALIDATION >= 32
assert N_IMAGES_TRAIN + N_IMAGES_VALIDATION <= 60000

if USE_MNIST:
    from torchvision.datasets import MNIST
    cache = Path.home() / ".cache" / "intro-ml-2026" / "mnist"
    # Automated offline checks replace USE_MNIST; CI cannot download data here.
    allow_download = DOWNLOAD_MNIST and not bool(os.environ.get("CI"))
    source = MNIST(str(cache), train=True, download=allow_download)
    indices = torch.randperm(len(source), generator=torch.Generator().manual_seed(SEED))
    train_ids = indices[:N_IMAGES_TRAIN]
    validation_ids = indices[N_IMAGES_TRAIN:N_IMAGES_TRAIN+N_IMAGES_VALIDATION]
    assert set(train_ids.tolist()).isdisjoint(validation_ids.tolist())
    images_train = (source.data[train_ids].reshape(-1, 784).float()/255 > 0.5).float()
    images_validation = (source.data[validation_ids].reshape(-1, 784).float()/255 > 0.5).float()
    labels_validation = source.targets[validation_ids]
    DATA_NAME = "Thresholded MNIST"
else:
    images_train, _ = binary_patterns(N_IMAGES_TRAIN, SEED)
    images_validation, labels_validation = binary_patterns(N_IMAGES_VALIDATION, SEED + 1)
    DATA_NAME = "Synthetic binary patterns (offline check; not digits)"

assert images_train.shape == (N_IMAGES_TRAIN, 784)
assert images_validation.shape == (N_IMAGES_VALIDATION, 784)
assert torch.all((images_train == 0) | (images_train == 1))
assert torch.all((images_validation == 0) | (images_validation == 1))
print(f"{DATA_NAME}: {len(images_train)} training / {len(images_validation)} validation images.")
image_gallery([images_train[:8]], ["Training"], f"Actual inputs: {DATA_NAME}")
Thresholded MNIST: 8000 training / 1000 validation images.

Eight actual binary training inputs from the selected dataset.

The encoder has two heads

Flattening preserves 784 pixel values but discards the explicit 2D arrangement. Our small MLP is chosen for readable code; a convolutional VAE would add image-specific structure.

First create 128 learned features with a linear layer and ReLU. Two separate linear heads read the same features: one predicts the latent mean; the other predicts log variance. The variance is positive after exponentiation, so the log-variance head can output any real number.

The following modules will become parts of our final VAE. We reuse these exact objects when assembling it.

torch.manual_seed(SEED + 4)
encoder = nn.Sequential(nn.Linear(784, 128), nn.ReLU())
mu_head = nn.Linear(128, 2)
logvar_head = nn.Linear(128, 2)
decoder = nn.Sequential(nn.Linear(2, 128), nn.ReLU(), nn.Linear(128, 784))

batch_images = images_train[:32]             # (B, 784)
features = encoder(batch_images)              # (B, 128)
mu = mu_head(features)                        # (B, 2): coordinate centers
logvar = logvar_head(features)                # (B, 2): ln(coordinate variances)
std = torch.exp(0.5 * logvar)                 # (B, 2): coordinate standard deviations
assert features.shape == (32, 128)
assert mu.shape == logvar.shape == std.shape == (32, 2)
print("First image's mean:", mu[0].detach().numpy().round(3))
print("First image's standard deviations:", std[0].detach().numpy().round(3))
First image's mean: [-0.1   -0.084]
First image's standard deviations: [1.028 0.974]

These are outputs of an untrained encoder. They show shapes and operations, not meaningful learned image representations yet.

Pause: why does one image need four output numbers for a two-dimensional code? Two specify the center; two specify the spread. A code sample itself still contains only two numbers.

Scene 5 — Sample without breaking the gradient path

We would like to draw from the encoder’s Gaussian and still train the encoder using reconstruction error. Separate parameter-independent randomness from its transformation:

\[\epsilon\sim\mathcal N(0,I_2),\qquad u=\mu_u+\exp\!\left(\tfrac12\mathrm{logvar}_u\right)\odot\epsilon.\]

This is reparameterization. epsilon supplies fresh noise; mu and std tell us how to move and stretch it. The factor one half converts log variance to log standard deviation.

Begin with chosen numbers, independently of the untrained encoder. Fix the same noise samples while changing the settings below.

MU_DEMO = [0.5, -0.3]        # TODO(student): change only the center first.
STD_DEMO = [0.7, 0.25]      # Then change one spread; both must be positive.
mu_demo = torch.tensor(MU_DEMO, dtype=torch.float32, requires_grad=True)
assert min(STD_DEMO) > 0
logvar_demo = (2 * torch.log(torch.tensor(STD_DEMO))).requires_grad_()
epsilon_demo = torch.randn(300, 2, generator=torch.Generator().manual_seed(SEED))
codes_demo = mu_demo + torch.exp(0.5 * logvar_demo) * epsilon_demo
sampling_figure(mu_demo.detach().numpy(), np.array(STD_DEMO), codes_demo.detach().numpy())

# One sampled coordinate: both center and spread have a differentiable path.
mu_gradient, logvar_gradient = torch.autograd.grad(codes_demo[0, 0], (mu_demo, logvar_demo))
expected_logvar_gradient = 0.5 * STD_DEMO[0] * epsilon_demo[0, 0]
torch.testing.assert_close(mu_gradient, torch.tensor([1.0, 0.0]))
torch.testing.assert_close(logvar_gradient[0], expected_logvar_gradient)
print(f"First ε: {epsilon_demo[0].numpy().round(3)}")
print(f"First u: {codes_demo[0].detach().numpy().round(3)}")
print(f"du₁/dμ₁ = {mu_gradient[0]:.3f}; du₁/dlogvar₁ = {logvar_gradient[0]:.3f}")

A cloud of sampled two-dimensional codes around the chosen center with independently adjustable coordinate spreads.

First ε: [-1.046  1.231]
First u: [-0.232  0.008]
du₁/dμ₁ = 1.000; du₁/dlogvar₁ = -0.366

Sampling caption: changing MU_DEMO moves the sample cloud; changing STD_DEMO stretches it along a coordinate. The plotted cloud uses the same noise in each rerun.

During one backward pass, the sampled noise is held fixed. Gradients pass through the addition, multiplication, and exponential to both encoder heads. Later forward passes draw fresh noise. We fixed the noise here only to make the comparison visible.

Decode one sampled code

Now return to our actual image batch. The decoder produces logits, which the sigmoid maps to probabilities of white pixels. Given a code, we model pixel observations as conditionally independent Bernoulli draws. The shared code can still induce dependence between pixels when it is marginalized out.

epsilon = torch.randn(mu.shape, generator=torch.Generator().manual_seed(SEED + 5))
u = mu + std * epsilon                    # (B, 2)
pixel_logits = decoder(u)                 # (B, 784)
pixel_probabilities = torch.sigmoid(pixel_logits)
assert pixel_logits.shape == batch_images.shape
assert torch.all((pixel_probabilities > 0) & (pixel_probabilities < 1))
image_gallery([batch_images, pixel_probabilities.detach()],
              ["Input", "Untrained p"], "Before training: decoder probabilities")

Binary input images above untrained decoder pixel probabilities near one half.

The untrained outputs are close to gray because many probabilities are near one half. They are not successful reconstructions. We will reuse the same encoder and decoder in a trainable VAE.

Assemble the route

The class below registers the modules we already created and packages the operations we just inspected. Each method has one job. forward returns the quantities needed by the loss; decode returns logits so training can use a numerically stable loss.

class VAE(nn.Module):
    def __init__(self, encoder, mu_head, logvar_head, decoder):
        super().__init__()
        self.encoder = encoder
        self.mu_head = mu_head
        self.logvar_head = logvar_head
        self.decoder = decoder

    def encode(self, x):
        features = self.encoder(x)              # (B, 784) → (B, 128)
        return self.mu_head(features), self.logvar_head(features)

    def reparameterize(self, mu, logvar, epsilon=None):
        std = torch.exp(0.5 * logvar)            # log variance → standard deviation
        if epsilon is None:
            epsilon = torch.randn_like(std)     # Fresh parameter-independent noise
        return mu + std * epsilon               # Differentiable sampled code

    def decode(self, u):
        return self.decoder(u)                  # Pixel logits, not sampled pixels

    def forward(self, x, epsilon=None):
        mu, logvar = self.encode(x)
        u = self.reparameterize(mu, logvar, epsilon)
        return self.decode(u), mu, logvar

vae = VAE(encoder, mu_head, logvar_head, decoder)
initial_vae_state = copy.deepcopy(vae.state_dict())
# Same modules + same noise must reproduce the earlier explicit calculation.
replayed_logits, replayed_mu, replayed_logvar = vae(batch_images, epsilon)
torch.testing.assert_close(replayed_logits, pixel_logits)
torch.testing.assert_close(replayed_mu, mu)
torch.testing.assert_close(replayed_logvar, logvar)
print(f"Registered trainable parameters: {sum(p.numel() for p in vae.parameters()):,}")
Registered trainable parameters: 202,516

Scene 6 — Two terms train the VAE

From Bayes’ theorem to the ELBO: a 5–10 minute walkthrough

We have seen what the encoder and decoder compute. Now follow one question: how can we train these networks when the exact posterior over an image’s code is difficult to calculate? Read the following steps in order; each equation answers the question left by the previous one.

1 · Specify a generator before asking it to infer anything

For one image, the generative story is: draw a hidden code \(u\) from \(p(u)=\mathcal N(0,I_2)\), then draw pixels from the decoder distribution \(p_\theta(x\mid u)\). The product rule gives the joint distribution

\[p_\theta(x,u)=p(u)p_\theta(x\mid u).\]

The decoder weights \(\theta\) are shared across all images. Each image has its own unobserved code \(u\). Our VAE learns one set of decoder weights; it does not maintain a Bayesian posterior over those weights.

This is the connection to regression: the same Bayes rule applies, but the unknown we infer is different.

Earlier regression This VAE, for a fixed decoder
Observations: targets \(y\) at inputs \(X\) Observation: image \(x\)
Unknown: shared weights \(\theta\) Unknown: the image’s code \(u\)
Prior: \(p(\theta)\) Prior: \(p(u)\)
Likelihood: \(p(y\mid X,\theta)\) Likelihood: \(p_\theta(x\mid u)\)
Posterior: \(p(\theta\mid\mathcal D)\) Posterior: \(p_\theta(u\mid x)\)

2 · State what learning a good generator would mean

We observe images, not their generating codes. To score an observed image, average over all possible codes under the prior:

\[p_\theta(x)=\int p_\theta(x\mid u)p(u)\,du.\]

This is the evidence, also called the marginal likelihood. It measures how much probability the whole generative model assigns to the image, after accounting for all possible hidden explanations. It is not the likelihood at one chosen code.

For independent training images, a natural learning objective would be to maximize

\[\sum_i\log p_\theta(x_i).\]

However, a nonlinear neural decoder generally makes the evidence integral impractical to evaluate exactly. Even two latent coordinates do not guarantee a simple analytic integral.

3 · Apply Bayes’ theorem to infer the code

Given an observed image, Bayes’ theorem says

\[p_\theta(u\mid x)=\frac{p_\theta(x\mid u)p(u)}{p_\theta(x)}.\]

This posterior answers: which codes could plausibly have produced this particular image? At a chosen code we can evaluate the prior and decoder likelihood. The difficult denominator is exactly the evidence integral from Step 2.

So two tasks share one obstacle: we want to learn from image evidence, and we want to infer hidden codes, but we cannot conveniently calculate the exact evidence or posterior.

4 · Introduce an encoder as an approximate inference model

Choose a tractable distribution \(q_\psi(u\mid x)\) and let the encoder predict its parameters. In our code, this is the diagonal Gaussian specified by mu and logvar. A Gaussian prior does not make the nonlinear decoder’s exact posterior Gaussian. Our chosen family can miss correlated codes or several separated groups of plausible codes.

The encoder’s output is a proposal for the posterior, not an application of Bayes’ rule that magically computes the exact answer. “Variational” means that we vary a chosen family of approximating distributions to make the approximation useful. The shared encoder weights \(\psi\) let one network perform this inference for many images, instead of optimizing a separate inference procedure from scratch for every image.

We still need a way to train \(q\) without supplying exact posterior targets. That is the purpose of the next three steps.

5 · Use Bayes’ theorem to separate evidence from the inference gap

For this derivation, fix \(x\) and \(\theta\) and abbreviate \(q_\psi(u\mid x)\) as \(q(u)\). The expectation \(\mathbb E_q\) averages over possible codes drawn from this distribution.

KL divergence measures the discrepancy from \(q\) to the exact posterior:

\[\operatorname{KL}(q\Vert p_\theta(u\mid x)) =\mathbb E_q[\log q(u)-\log p_\theta(u\mid x)].\]

Take logarithms of Bayes’ theorem:

\[\log p_\theta(u\mid x) =\log p_\theta(x,u)-\log p_\theta(x).\]

Substitute this expression into the KL definition:

\[\begin{aligned} \operatorname{KL}(q\Vert p_\theta(u\mid x)) &=\mathbb E_q[\log q(u)-\log p_\theta(x,u)+\log p_\theta(x)]\\ &=\log p_\theta(x) -\mathbb E_q[\log p_\theta(x,u)-\log q(u)]. \end{aligned}\]

Why did \(\log p_\theta(x)\) leave the expectation? The observed image and decoder are fixed here, so that quantity is constant as \(u\) varies. An average of a constant is the same constant.

Name the remaining expectation the evidence lower bound, or ELBO:

\[\mathcal E(\theta,\psi;x) :=\mathbb E_q[\log p_\theta(x,u)-\log q(u)].\]

Rearranging gives the identity that explains the bound:

\[\log p_\theta(x) =\mathcal E(\theta,\psi;x) +\operatorname{KL}(q\Vert p_\theta(u\mid x)).\]

KL is nonnegative, so \(\mathcal E\le\log p_\theta(x)\). The gap is zero if \(q\) equals the exact posterior. For a fixed decoder, maximizing the ELBO is therefore equivalent to minimizing this inference gap. We assume positive evidence and finite expectations with suitable support.

The gap explains the mathematics; it is not a term we calculate against an unavailable exact posterior during training.

6 · Rewrite the bound into two computable terms

Expand the joint from Step 1 inside the ELBO:

\[\begin{aligned} \mathcal E &=\mathbb E_q[\log p_\theta(x\mid u)+\log p(u)-\log q(u)]\\ &=\underbrace{\mathbb E_q[\log p_\theta(x\mid u)]}_{\text{explain the observed pixels}} -\underbrace{\operatorname{KL}(q_\psi(u\mid x)\Vert p(u))}_{\text{compare the encoder distribution with the prior}}. \end{aligned}\]

The evidence integral has disappeared from this computable expression. We estimate the first term by sampling codes from \(q\). The second term has an analytic formula for our diagonal Gaussian and standard-normal prior.

There are two different KL divergences in the argument:

Comparison Its job Computed in our loss?
\(\operatorname{KL}(q\Vert p_\theta(u\mid x))\) Gap between the bound and exact log evidence No; the exact posterior is unavailable
\(\operatorname{KL}(q\Vert p(u))\) Prior-comparison term inside the usable bound Yes; analytic Gaussian formula

Matching the exact posterior makes the gap zero. It need not make the prior KL zero: seeing an image usually changes what we believe about its code.

7 · Turn a maximized bound into the minimized loss in Python

Our optimizer minimizes, so negate the ELBO:

\[L_{\mathrm{VAE}}=-\mathcal E =\underbrace{\mathbb E_q[-\log p_\theta(x\mid u)]}_{\text{reconstruction NLL}} +\underbrace{\operatorname{KL}(q_\psi(u\mid x)\Vert p(u))}_{\text{prior KL}}.\]

NLL means negative log likelihood. For each image, sample \(\epsilon\sim\mathcal N(0,I_2)\) and form \(u=\mu_u+\sigma_u\odot\epsilon\). With one sample, the stochastic loss estimate is

\[\widehat L(x) =-\log p_\theta(x\mid u) +\operatorname{KL}(q_\psi(u\mid x)\Vert p(u)).\]

Average these estimates across the minibatch. The reconstruction gradient passes through the decoder, sampled code, and reparameterization to both encoder heads. The prior KL supplies additional encoder gradients. The optimizer updates both encoder weights \(\psi\) and decoder weights \(\theta\); it does not store the sampled \(u\) as a trainable parameter.

We maximize a bound while learning both networks. Because changing \(\theta\) also changes the evidence and exact posterior, an improvement in the joint ELBO does not by itself guarantee an improvement in exact evidence. Also, the bound applies to the expected ELBO; an individual Monte Carlo estimate need not lie below log evidence.

8 · Follow the argument into the code

Mathematical action Notebook operation
Propose \(q_\psi(u\mid x)\) encode(x) returns mu, logvar
Sample a plausible code from \(q\) reparameterize(mu, logvar)
Specify \(p_\theta(x\mid u)\) decode(u) returns pixel logits
Score the observed binary pixels Sum binary_cross_entropy_with_logits over pixels
Compare \(q\) with \(p(u)\) Sum the analytic Gaussian KL over coordinates
Minimize a minibatch negative-ELBO estimate Add the terms, average images, backpropagate, update

There are no labels for the true latent codes and no exact posterior targets. The bound trains the encoder and generator together using the observed images. The standard VAE gives the prior KL coefficient one; changing that coefficient changes the objective.

Checkpoint: explain the route aloud: generative model → evidence → Bayes posterior → approximate encoder → ELBO identity → two computable terms → sampled training loss. Which integral was difficult? Which KL explains the gap? Which KL appears in the code?

Reference: Kingma & Welling, Auto-Encoding Variational Bayes, §2.2, equations (1)–(3), and §§2.3–2.4. Here the paper’s \(z,\phi\) become the course’s \(u,\psi\); we use \(\mathcal E\) for the maximized bound and \(L\) for the minimized loss.

Calculate the terms before combining them

Now implement Step 7 for the binary-image generator. Conditional independence of pixels given \(u\) makes their log likelihoods add. Use binary_cross_entropy_with_logits directly on logits. reduction="none" retains every pixel contribution; sum pixels for each image.

For the diagonal-Gaussian prior KL, sum the two coordinate contributions:

\[\mathrm{KL}_i=\frac12\sum_{j=1}^{2} \left[\mu_{ij}^2+\exp(\mathrm{logvar}_{ij})-1-\mathrm{logvar}_{ij}\right].\]

This is the prior KL from Step 6, not the unknown posterior gap. It is zero when every coordinate has mean zero and variance one. It penalizes departure from the prior in both center and spread, without demanding that every image’s approximate posterior equal the prior exactly.

reconstruction_per_image = F.binary_cross_entropy_with_logits(
    pixel_logits, batch_images, reduction="none"
).sum(dim=1)  # (B, 784) → (B,): add the pixel log losses.

kl_per_image = 0.5 * (
    mu.square() + logvar.exp() - 1 - logvar
).sum(dim=1)  # (B, 2) → (B,): add the latent coordinate terms.

loss = (reconstruction_per_image + kl_per_image).mean()  # Average images.
print(f"Image 0: reconstruction = {reconstruction_per_image[0].item():.2f}, "
      f"KL = {kl_per_image[0].item():.2f} nats")
print(f"Batch mean negative-ELBO estimate = {loss.item():.2f} nats/image")
assert reconstruction_per_image.shape == kl_per_image.shape == (32,)
assert torch.all(kl_per_image >= -1e-6)
Image 0: reconstruction = 547.83, KL = 0.01 nats
Batch mean negative-ELBO estimate = 550.13 nats/image

Why does the reconstruction term initially look large? An uninformative probability of one half costs about ln(2) per binary pixel. With 784 pixels, those small costs add to roughly 543 nats per image.

We next put the same calculation in a reusable function. It returns per-image terms so the training loop can average them and the reports can keep them separate. The optional beta setting multiplies KL; the standard VAE uses beta = 1. Other values change the objective and should be labeled as such.

def vae_terms(logits, x, mu, logvar):
    reconstruction = F.binary_cross_entropy_with_logits(logits, x, reduction="none").sum(1)
    kl = 0.5 * (mu.square() + logvar.exp() - 1 - logvar).sum(1)
    return reconstruction, kl

# Independent library check of the analytic KL formula.
q = torch.distributions.Normal(mu, torch.exp(0.5 * logvar))
prior = torch.distributions.Normal(torch.zeros_like(mu), torch.ones_like(mu))
library_kl = torch.distributions.kl_divergence(q, prior).sum(1)
torch.testing.assert_close(kl_per_image, library_kl)
zero = torch.zeros(3, 2)
_, zero_kl = vae_terms(torch.zeros(3, 784), images_train[:3], zero, zero)
torch.testing.assert_close(zero_kl, torch.zeros(3))

# Trace one complete backward pass, without changing weights yet.
vae.zero_grad()
checked_logits, checked_mu, checked_logvar = vae(batch_images, epsilon)
checked_nll, checked_kl = vae_terms(checked_logits, batch_images, checked_mu, checked_logvar)
(checked_nll + checked_kl).mean().backward()
for name, module in [("Encoder", vae.encoder), ("Mean head", vae.mu_head),
                     ("Log-variance head", vae.logvar_head), ("Decoder", vae.decoder)]:
    gradients = [p.grad for p in module.parameters()]
    assert all(g is not None and torch.isfinite(g).all() for g in gradients)
    norm = sum(g.square().sum().item() for g in gradients)**0.5
    assert norm > 0
    print(f"{name} gradient norm: {norm:.3f}")
vae.zero_grad()
Encoder gradient norm: 8.108
Mean head gradient norm: 3.434
Log-variance head gradient norm: 3.908
Decoder gradient norm: 46.786

Checkpoint: which dimension do we sum for pixels? Which for latent coordinates? Which dimension do we average? Changing a pixel sum to a pixel mean alone changes the relative influence of KL by a factor of 784.

Return to the Bayes-to-ELBO walkthrough whenever the two KL comparisons become confusing. The lecture’s algebra is a compact companion; in Colab use the published lecture.

At this point we have derived the objective from Bayes’ theorem and checked its operations, tensor shapes, Gaussian KL, and gradient flow through both heads. The next experiment uses that objective to train the image model.

Scene 7 — Train the model we just opened

Each minibatch follows the same familiar loop: predict, calculate loss, clear old gradients, backpropagate, update weights. Sampling happens inside forward on every batch.

We use a fixed epoch budget rather than selecting an epoch after seeing results. Validation is held out from optimization. Its reconstruction NLL averages four fixed noise draws per image, so changes across epochs are easier to inspect. It is still a Monte Carlo estimate, not exact image evidence.

Measure validation without updating weights

The KL needs no sampling. We average sampled reconstruction terms over the four draws and accumulate sums across images. The final division reports nats per image, including a smaller final batch correctly.

validation_noise = torch.randn(
    4, len(images_validation), 2,
    generator=torch.Generator().manual_seed(SEED + 10)
)

@torch.no_grad()
def evaluate_vae(model, images, noise, batch_size=256):
    model.eval()
    total_nll, total_kl = 0.0, 0.0
    for start in range(0, len(images), batch_size):
        x = images[start:start+batch_size]
        mu, logvar = model.encode(x)
        nll_draws = []
        for epsilon in noise[:, start:start+len(x)]:
            logits = model.decode(model.reparameterize(mu, logvar, epsilon))
            nll, kl = vae_terms(logits, x, mu, logvar)
            nll_draws.append(nll)
        total_nll += torch.stack(nll_draws).mean(0).sum().item()
        total_kl += kl.sum().item()
    return total_nll / len(images), total_kl / len(images)

Read the complete training loop

The function below makes retraining explicit and reusable for the optional comparison. It owns its optimizer, shuffles training indices each epoch, and samples fresh latent noise. Resetting the two random streams makes the optional comparison use the same batch order and noise sequence.

The training report averages losses encountered while weights were changing during the epoch. Validation evaluates the weights at the end of that epoch. This difference can matter when comparing the two curves.

def train_vae(model, images, validation_images, epochs, beta, noise):
    optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
    order_rng = torch.Generator().manual_seed(SEED + 20)
    torch.manual_seed(SEED + 21)  # Latent noise; separate from batch ordering.
    history = []
    for epoch in range(epochs):
        model.train()
        order = torch.randperm(len(images), generator=order_rng)
        total_nll, total_kl = 0.0, 0.0
        for ids in order.split(256):
            x = images[ids]
            optimizer.zero_grad()
            logits, mu, logvar = model(x)
            reconstruction, kl = vae_terms(logits, x, mu, logvar)
            loss = (reconstruction + beta * kl).mean()
            assert torch.isfinite(loss), "Non-finite loss: inspect the settings."
            loss.backward()
            optimizer.step()
            total_nll += reconstruction.detach().sum().item()
            total_kl += kl.detach().sum().item()
        val_nll, val_kl = evaluate_vae(model, validation_images, noise)
        row = {"train_nll": total_nll/len(images), "train_kl": total_kl/len(images),
               "val_nll": val_nll, "val_kl": val_kl}
        assert all(np.isfinite(value) for value in row.values())
        history.append(row)
        print(f"Epoch {epoch+1:2d}/{epochs}: "
              f"validation reconstruction {val_nll:.2f}, KL {val_kl:.2f} nats/image")
    return history

Run the standard VAE

Start from the exact modules inspected above. The assembly cell saved their initial weights for the optional paired experiment; rerunning the training cell restores those weights. Ten short epochs are a teaching budget; this is not a state-of-the-art digit generator.

TODO(student): after completing the default run, try five or fifteen epochs. Predict which outputs might improve. Keep the validation set fixed, and describe the observed change without treating repeated exploration as a final test.

EPOCHS = 10
BETA_VAE = 1.0  # Standard VAE; keep this at one for the main walkthrough.
assert isinstance(EPOCHS, int) and EPOCHS > 0
assert BETA_VAE > 0
vae.load_state_dict(initial_vae_state)  # Rerunning this cell starts fresh.
before_nll, before_kl = evaluate_vae(vae, images_validation, validation_noise)
training_started = time.perf_counter()
history = train_vae(vae, images_train, images_validation,
                    EPOCHS, BETA_VAE, validation_noise)
training_seconds = time.perf_counter() - training_started
print(f"Training took {training_seconds:.1f} seconds on this machine.")
history_figure(history)
last = history[-1]
display(Markdown(
    f"**This run ({DATA_NAME}, beta={BETA_VAE:g}):** validation reconstruction NLL "
    f"changed from **{before_nll:.2f}** to **{last['val_nll']:.2f}** nats/image; "
    f"final prior KL is **{last['val_kl']:.2f}** nats/image. "
    "Read the two terms separately; they describe different pressures."
))
Epoch  1/10: validation reconstruction 225.69, KL 20.39 nats/image
Epoch  2/10: validation reconstruction 208.67, KL 11.28 nats/image
Epoch  3/10: validation reconstruction 195.15, KL 10.54 nats/image
Epoch  4/10: validation reconstruction 187.66, KL 9.20 nats/image
Epoch  5/10: validation reconstruction 183.92, KL 8.39 nats/image
Epoch  6/10: validation reconstruction 181.58, KL 7.59 nats/image
Epoch  7/10: validation reconstruction 179.69, KL 7.12 nats/image
Epoch  8/10: validation reconstruction 177.97, KL 6.89 nats/image
Epoch  9/10: validation reconstruction 176.39, KL 6.76 nats/image
Epoch 10/10: validation reconstruction 175.18, KL 6.67 nats/image
Training took 1.0 seconds on this machine.

Training and held-out validation curves for reconstruction negative log likelihood and prior KL in separate panels.

This run (Thresholded MNIST, beta=1): validation reconstruction NLL changed from 551.80 to 175.18 nats/image; final prior KL is 6.67 nats/image. Read the two terms separately; they describe different pressures.

Training-curve caption: reconstruction NLL and prior KL are plotted in separate panels. Validation observations never supply optimizer gradients. These values describe this initialization, subset, and training budget; a single seed does not establish a general architecture ranking.

Scene 8 — Reconstruct, move, generate

Reconstruct an observed image

Encode validation images, then decode their mean codes. This deterministic display is convenient for comparison. It differs from the sampled reconstruction term used during training and validation scoring.

vae.eval()
with torch.no_grad():
    validation_mu, validation_logvar = vae.encode(images_validation)
    reconstruction_probabilities = torch.sigmoid(vae.decode(validation_mu[:8]))
image_gallery([images_validation[:8], reconstruction_probabilities],
              ["Input", "Decoded μ"], f"Held-out reconstructions: {DATA_NAME}")

Held-out binary images above trained reconstructions obtained by decoding encoder means.

Reconstruction caption: the top row contains held-out binary inputs; the bottom row contains decoded pixel probabilities. A gray pixel means intermediate probability, not a third outcome in our binary observation model. A two-dimensional code and short training budget limit detail.

Look at the learned code space

Place each validation image at its encoder mean. Color by labels for inspection only. The VAE was not trained to classify these labels, and two dimensions need not separate them cleanly. This scatter shows centers across images; it omits each image’s predicted spread.

latent_figure(validation_mu.numpy(), labels_validation.numpy(),
              f"Validation encoder means: {DATA_NAME}")

Validation encoder means in two latent coordinates colored by inspection labels unused during training.

Move between two images

Choose two validation image indices. Interpolate linearly between their mean codes, then decode the path. This is a coordinate experiment; it is neither a prior sample nor proof that every intermediate image is realistic.

TODO(student): change the endpoint indices. Does the transition look smooth? Does smoothness guarantee a recognizable intermediate digit? Compare the endpoints with their reconstructions.

IMAGE_A = 0
IMAGE_B = 5
assert 0 <= IMAGE_A < len(images_validation) and 0 <= IMAGE_B < len(images_validation)
alpha = torch.linspace(0, 1, 8)[:, None]
code_a = validation_mu[IMAGE_A]
code_b = validation_mu[IMAGE_B]
interpolated_codes = (1-alpha)*code_a + alpha*code_b
with torch.no_grad():
    interpolated_probabilities = torch.sigmoid(vae.decode(interpolated_codes))
image_gallery([images_validation[[IMAGE_A, IMAGE_B]]], ["Endpoints"], "Chosen input images", n=2)
image_gallery([interpolated_probabilities], ["Decoded"], "From mean code A (left) to mean code B (right)")

Chosen endpoint images followed by eight probability images along a linear interpolation of their mean codes.

Chosen endpoint images followed by eight probability images along a linear interpolation of their mean codes.

Generate from the prior

Reconstruction starts with an image. Generation starts with a fresh code from N(0, I₂) and uses only the decoder. Show both decoder probabilities and binary pixel samples. The latter are actual observations drawn from the Bernoulli image model. They can look speckled: every uncertain pixel is sampled independently given the code. Smooth probability images and sampled binary images answer different questions.

TODO(student): change GENERATION_SEED. Which outputs change? Why do we not call the encoder here?

GENERATION_SEED = 29
sample_rng = torch.Generator().manual_seed(GENERATION_SEED)
prior_codes = torch.randn(8, 2, generator=sample_rng)
with torch.no_grad():
    generated_probabilities = torch.sigmoid(vae.decode(prior_codes))
    generated_binary = torch.bernoulli(generated_probabilities, generator=sample_rng)
assert torch.all((generated_binary == 0) | (generated_binary == 1))
image_gallery([generated_probabilities, generated_binary],
              ["Pixel p", "Sampled x"], f"Fresh prior generations: {DATA_NAME}")

Decoder pixel probabilities for fresh prior codes, above sampled binary images from those probabilities.

Explore a whole patch of latent space

Decode a grid of chosen coordinates. Each tile is a probability image. This grid uses evenly spaced coordinates, while independent prior samples concentrate around the origin.

TODO(student): change GRID_LIMIT from 2 to 4. Describe what happens far from the origin. Those coordinates may be poorly supported by the prior and learned codes; the decoder still returns an output.

GRID_LIMIT = 2.0
assert GRID_LIMIT > 0
coordinates = torch.linspace(-GRID_LIMIT, GRID_LIMIT, 7)
u1, u2 = torch.meshgrid(coordinates, coordinates.flip(0), indexing="xy")
grid_codes = torch.stack([u1.flatten(), u2.flatten()], dim=1)
with torch.no_grad():
    grid_probabilities = torch.sigmoid(vae.decode(grid_codes))
decoder_grid(grid_probabilities, coordinates.numpy())

Seven by seven grid of decoded probability images with chosen latent coordinates labeled on both axes.

Optional experiment — Change the KL pressure

Complete the default walkthrough first. Then set RUN_BETA_EXPERIMENT = True and choose a second positive KL weight. Train a fresh copy from the saved initial weights with the same subset, epoch count, optimizer, batch order, and latent noise stream. The standard VAE above remains available for the main galleries.

Predict how reconstruction and KL might respond before running. Compare unweighted validation terms, rather than total losses with different coefficients. Stronger KL pressure does not guarantee monotonic results or better generated images under finite training.

Optional fresh paired training run (off by default)
# @title Optional: compare one different KL weight
RUN_BETA_EXPERIMENT = False
SECOND_BETA = 4.0  # TODO(student): try 0.1 or 4.0; write your prediction first.
assert SECOND_BETA > 0
if RUN_BETA_EXPERIMENT:
    alternate_vae = copy.deepcopy(vae)
    alternate_vae.load_state_dict(initial_vae_state)
    alternate_history = train_vae(alternate_vae, images_train, images_validation,
                                  EPOCHS, SECOND_BETA, validation_noise)
    alternate_last = alternate_history[-1]
    display(Markdown(
        "| KL weight | Reconstruction NLL | Prior KL |\n|---|---:|---:|\n"
        f"| {BETA_VAE:g} | {last['val_nll']:.2f} | {last['val_kl']:.2f} |\n"
        f"| {SECOND_BETA:g} | {alternate_last['val_nll']:.2f} | {alternate_last['val_kl']:.2f} |\n\n"
        "All values are nats/image on the same validation observations and noise draws."
    ))
    with torch.no_grad():
        alternate_prior_probabilities = torch.sigmoid(alternate_vae.decode(prior_codes))
    image_gallery([generated_probabilities, alternate_prior_probabilities],
                  [f"β={BETA_VAE:g}", f"β={SECOND_BETA:g}"], "Same prior codes, two trained decoders")
else:
    print("Optional training is off. The main VAE experiment is complete.")
Optional training is off. The main VAE experiment is complete.

Optional exercise — Naive Bayes in a few lines

Return to the lecture’s two binary features. The stipulated model has classes \(A,B\), priors \((0.4,0.6)\), and feature probabilities shown in the code below. These are synthetic assumptions, not estimates from a real dataset.

Conditional independence lets us multiply feature likelihoods within each class:

\[p(C\mid F_1,F_2)\propto p(C)\,p(F_1\mid C)\,p(F_2\mid C).\]

This is Bayes’ rule for a class label, not necessarily Bayesian inference over uncertain classifier parameters. The table parameters here are fixed.

TODO(student): before running, predict the favored class for two present features. Change PRIOR_A, then change the observed features. Finally duplicate the first feature using DUPLICATE_FIRST = True; explain why treating repeated evidence as independent changes confidence without supplying new information.

PRIOR_A = 0.4
OBSERVED_FEATURES = np.array([1, 1])
DUPLICATE_FIRST = False
assert 0 < PRIOR_A < 1
assert np.all(np.isin(OBSERVED_FEATURES, [0, 1]))

class_prior = np.array([PRIOR_A, 1-PRIOR_A])
feature_probabilities = np.array([[0.8, 0.7], [0.2, 0.3]])  # Rows A/B, columns F1/F2.
feature_likelihood = np.where(OBSERVED_FEATURES == 1,
                              feature_probabilities, 1-feature_probabilities)
scores = class_prior * feature_likelihood.prod(axis=1)
if DUPLICATE_FIRST:
    scores = scores * feature_likelihood[:, 0]  # Deliberately count F1 twice.
class_posterior = scores / scores.sum()
assert np.isclose(class_posterior.sum(), 1)
print("Class scores [A, B]:", scores.round(4))
print("Posterior class probabilities:", class_posterior.round(4))
if PRIOR_A == 0.4 and np.all(OBSERVED_FEATURES == 1) and not DUPLICATE_FIRST:
    np.testing.assert_allclose(class_posterior, [0.224/0.260, 0.036/0.260])
Class scores [A, B]: [0.224 0.036]
Posterior class probabilities: [0.8615 0.1385]

What did we learn?

Complete three short statements using your own outputs:

  1. Regression: after changing data count or noise, the mean-function band …, while the future-observation band …, because … .
  2. Dropout, if run: repeated masks varied … . This figure alone does not establish … .
  3. VAE: the encoder supplies …; reparameterization lets …; the decoder supplies … . Reconstruction starts with …, whereas generation starts with … .

The common idea is to specify what varies and which distribution describes it. Regression kept a posterior over weights. MC dropout varied network masks. The VAE learned an approximate distribution over each image’s latent code and a decoder distribution over binary pixels.

This tutorial used one reproducible initialization and small datasets. Validation supported exploration; both the regression test set and MNIST’s official test split remained unused. No result establishes universal superiority or calibrated uncertainty under distribution shift.

References: Week 07 lecture; Bishop, PRML §3.3 for Bayesian linear regression and §8.2 for graphical-model factorization; Gal & Ghahramani (2016) for dropout; Kingma & Welling §§2–3 and Appendix B for the VAE. Plots and diagrams are original course calculations; image galleries use MNIST via Torchvision, or explicitly labeled procedural patterns in offline checks. The trained outputs here come from this notebook, independently of the lecture’s longer pilot.

TODO(instructor): accept the teaching text, figures, experiment settings, and pacing before publication. The synthetic-rainfall decision in the earlier Week 07 draft is superseded by the instructor-approved regression/VAE scope; no real-weather claim is used here.

assert torch.isfinite(validation_mu).all()
assert torch.isfinite(validation_logvar).all()
assert len(history) == EPOCHS
print(f"Completed: {DATA_NAME}; {N_IMAGES_TRAIN} training images, {EPOCHS} epochs, beta={BETA_VAE:g}.")
print(f"Computation since setup: {time.perf_counter()-started:.1f} seconds (includes any interactive pauses).")
Completed: Thresholded MNIST; 8000 training images, 10 epochs, beta=1.
Computation since setup: 4.6 seconds (includes any interactive pauses).