ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Project 7 of 7 180 min

Mini project: MNIST autoencoder

Train a PyTorch autoencoder on MNIST without labels, visualise its latent space, generate digits from it and use reconstruction error to flag smudged handwriting.

Words in this lessonAutoencoder · Latent space · Unsupervised learning · Reconstruction loss · Anomaly detection · Loss function
Autoencoder
A network trained to compress its input into a few numbers and rebuild it from them. Like describing a cake over the phone in ten words so the baker at the other end can rebuild it.
Latent space
A compressed numerical representation of data where a model does its work, smaller than the raw pixels or sound. Like working from a small, detailed sketch and only painting the full-size canvas at the end.
Unsupervised learning
Learning patterns from data that has no answers attached. Like sorting a box of buttons into groups without anyone telling you what the groups should be.
Reconstruction loss
How different a model’s rebuilt version of an input is from the original. Like comparing a photocopy with the original and measuring how much was lost.
Anomaly detection
Flagging inputs that look unlike the normal data a model has seen. Like a shop assistant who has seen thousands of normal banknotes and notices when one feels different.
Loss function
A formula that turns “how wrong is the model?” into a single number to minimise. Like a scorecard that gives one total penalty for every mistake made on a tray of pastries.
On this page
  1. The brief
  2. What you will build
  3. Architecture
  4. Tech stack
  5. Build it, milestone by milestone
  6. 1. Load MNIST and look at the digits
  7. 2. Build the encoder and decoder
  8. 3. Train with a reconstruction loss
  9. 4. Compare reconstructions with the originals
  10. 5. Plot the latent space coloured by digit
  11. 6. Walk the latent space to generate new digits
  12. 7. Flag unusual digits by reconstruction error
  13. Done means done
  14. Stretch goals
  15. Common pitfalls
  16. Show it off
  17. Go deeper

Custom cake orders at Crumb still start on paper. A customer stands at the counter, describes the cake, and the staff member writes the details on a pre-printed slip: phone number, pickup date, number of servings. At closing time someone types the slips into the order system.

Last month, fourteen of those phone numbers were wrong. A 7 read as a 1. A 4 read as a 9. A coffee ring over the last two digits. Fourteen customers who never got their “your cake is ready” message, and two cakes that went uncollected.

“Eventually we’ll read the slips automatically,” Priya said. “That’s the OCR project in Module 29. But I want you to start somewhere stranger. Train a network on seventy thousand handwritten digits without telling it what any of them are. No labels. See what it figures out on its own.”

“What’s the point if it doesn’t know which digit is which?”

“Two points. First, you’ll see that a network can discover structure nobody labelled, which is how most modern AI learns before anyone fine-tunes it. Second, a network that knows what normal handwriting looks like can tell you when something isn’t normal. Like a coffee ring over a phone number.”

Important

What you will ship: a PyTorch autoencoder trained on MNIST without labels that compresses each 28×28 digit into a handful of numbers and rebuilds it, with plots of its reconstructions and its latent space, a grid of digits it generates from scratch, and a detector that flags smudged or unusual digits for a human to check.

The brief

MNIST is a classic dataset of 70,000 small greyscale images of handwritten digits, 0 to 9, each 28×28 pixels, collected from American census staff and students in the 1990s. Each pixel is a brightness from 0 (black) to 1 (white). It comes with labels, but you will ignore them for training and use them only afterwards, to understand what the model learned.

An autoencoder is a network trained to copy its input to its output through a narrow bottleneck. The first half, the encoder, squeezes the 784 pixels down to a few numbers. The second half, the decoder, rebuilds 784 pixels from those few numbers. The loss is simply how different the rebuilt image is from the original, the reconstruction loss. No labels anywhere. This is unsupervised learning (learning patterns from data with no answers attached).

Why would copying be hard, or useful? Because of the bottleneck. If the middle layer has only 2 numbers, the network cannot memorise pixels. It has to discover the few things that matter most about a digit (its shape, its slant, its thickness) and throw everything else away. Those few numbers are a latent representation, and the space they live in is the latent space (a map where each digit image becomes a point, and similar-looking digits land near each other).

Priya’s analogy: describing a cake over the phone. You cannot send the cake. You have about ten words. A good describer says “two-tier, chocolate, white piping, ‘Happy 40th’”, and the baker on the other end can rebuild something very close. A bad describer wastes words on the plate. Training an autoencoder is training a describer and a rebuilder together until the ten words carry everything that matters.

What you will build

Must have:

  • A data pipeline that loads MNIST as tensors, and a look at the raw data.
  • An Autoencoder module with a separate encoder and decoder and a configurable latent size.
  • A training loop with a reconstruction loss, logged on training and test images.
  • A side-by-side figure of original and reconstructed digits.
  • A scatter plot of the 2-D latent space coloured by the true digit (labels used only for colouring).
  • A grid of digits generated by decoding evenly spaced points from the latent space.
  • An “unusual digit” detector based on reconstruction error, tested on artificially smudged digits.

Nice to have:

  • A comparison of latent sizes 2 and 16.
  • A test of how useful the representation is when you only have 100 labels.
  • A convolutional encoder and decoder.

Architecture

flowchart LR
    A["Digit image 1x28x28"] --> B["Encoder: 784 to 256 to 64 to 2"]
    B --> C["Latent code: 2 numbers"]
    C --> D["Decoder: 2 to 64 to 256 to 784"]
    D --> E["Rebuilt image 1x28x28"]
    A --> F["Reconstruction loss"]
    E --> F
    C --> G["Latent space plot"]
    F --> H["Unusual digit detector"]

Digit image. One MNIST image as a tensor of shape (1, 28, 28): one colour channel, 28 rows, 28 columns.

Encoder. Flattens the image to 784 numbers and narrows it through layers of 256 and 64 neurons down to the latent size. Each layer is a linear layer plus ReLU, as in this module’s earlier lessons.

Latent code. The bottleneck. With 2 numbers you can plot every digit on a flat map. With 16, reconstructions are much sharper but you cannot see the space directly.

Decoder. The mirror image: widens from the latent code back to 784 numbers, squashes each into 0 to 1 with a sigmoid (pixel brightness), and reshapes to 28×28.

Reconstruction loss. Compares rebuilt and original pixel by pixel. This is the only training signal.

Latent space plot and detector. Two uses of the trained model: one to understand it, one to put it to work.

Tech stack

Piece Tool Why
Framework PyTorch Same tools as the rest of this module
Data torchvision datasets.MNIST Downloads and loads MNIST in two lines
Plots matplotlib Reconstructions, latent space, generated grid
Few-label test (optional) scikit-learn LogisticRegression A quick classifier on the latent codes
Hardware Laptop CPU or GPU About 7 to 10 seconds per epoch on an M-series Mac; under a minute on most CPUs
uv init digit-autoencoder && cd digit-autoencoder
uv add torch torchvision matplotlib scikit-learn

Library APIs change quickly. If a name has moved, check the PyTorch tutorials and the torchvision documentation linked from them.

Build it, milestone by milestone

1. Load MNIST and look at the digits

Why. Every project starts by looking at the data, not at the model. You need to know the shape, the range of values and what “normal” looks like, because the detector in milestone 7 is entirely about “normal”.

# data.py
import torch
from torch.utils.data import DataLoader
from torchvision import datasets
from torchvision.transforms import v2

to_tensor = v2.Compose([v2.ToImage(), v2.ToDtype(torch.float32, scale=True)])   # pixels -> 0..1

train_set = datasets.MNIST("data", train=True, download=True, transform=to_tensor)
test_set = datasets.MNIST("data", train=False, download=True, transform=to_tensor)

train_loader = DataLoader(train_set, batch_size=128, shuffle=True)
test_loader = DataLoader(test_set, batch_size=512)

if __name__ == "__main__":
    print(len(train_set), len(test_set))               # 60000 10000
    img, label = train_set[0]
    print(img.shape, img.dtype, img.min().item(), img.max().item(), label)
    print(f"{(img > 0).float().mean().item():.2f} of pixels are non-zero")

datasets.MNIST is a ready-made Dataset (an object that returns one example at a time, as you will build yourself in Module 28). The transform converts each image to a float tensor with values from 0 to 1. scale=True is what divides the 0 to 255 byte values by 255.

Working looks like:

60000 10000
torch.Size([1, 28, 28]) torch.float32 0.0 1.0 5
0.21 of pixels are non-zero

The first training image is a 5. Only about a fifth of its pixels are lit; the rest is black background. That fact will matter for the loss.

Now plot twenty digits with matplotlib (plt.imshow(img[0], cmap="gray") in a grid of subplots) and really look at them. Notice how differently people write 4s and 7s, and how thick some strokes are.

Most likely bug: forgetting the transform, so the dataset returns PIL images, and the DataLoader fails with a message about not knowing how to batch PIL.Image objects.

Stuck? Hint

If the download fails behind a corporate network, download once on another network and copy the data/MNIST folder across; torchvision will find it and skip the download.

2. Build the encoder and decoder

Why. Keeping the encoder and decoder as separate attributes lets you use each one alone later: the encoder to map digits into the latent space, the decoder to generate digits from points you choose.

# model.py
from torch import nn

class Autoencoder(nn.Module):
    def __init__(self, latent_dim: int = 2):
        super().__init__()
        self.encoder = nn.Sequential(
            nn.Flatten(),                              # (batch, 1, 28, 28) -> (batch, 784)
            nn.Linear(784, 256), nn.ReLU(),
            nn.Linear(256, 64), nn.ReLU(),
            nn.Linear(64, latent_dim),                 # no activation: latent can be any number
        )
        self.decoder = nn.Sequential(
            nn.Linear(latent_dim, 64), nn.ReLU(),
            nn.Linear(64, 256), nn.ReLU(),
            nn.Linear(256, 784), nn.Sigmoid(),         # each pixel squashed to 0..1
            nn.Unflatten(1, (1, 28, 28)),              # (batch, 784) -> (batch, 1, 28, 28)
        )

    def forward(self, x):
        z = self.encoder(x)          # (batch, latent_dim)
        return self.decoder(z), z    # rebuilt image and the code

Two choices to understand. The encoder’s last layer has no activation, because latent coordinates should be free to be negative or large. The decoder’s last layer has a sigmoid, because pixel brightness must be between 0 and 1. And forward returns both the image and the code, because you will want the code for plotting.

Check it before training. Shapes first:

import torch
from model import Autoencoder

model = Autoencoder(latent_dim=2)
x = torch.rand(8, 1, 28, 28)
recon, z = model(x)
print(recon.shape, z.shape)                            # (8, 1, 28, 28) and (8, 2)
print(sum(p.numel() for p in model.parameters()))      # 435858

Working looks like: those shapes, and 435,858 parameters. Almost all of them sit in the two big layers at each end: in the encoder’s first layer and in the decoder’s last.

Most likely bug: putting a ReLU after the latent layer. Then every latent coordinate is forced to be zero or positive, half the space is wasted, and many digits collapse onto the axes. If your latent plot later shows everything squashed against two lines, check this.

Stuck? Hint

If you get a shape error from the decoder, check nn.Unflatten(1, (1, 28, 28)): the first argument is the dimension to unflatten (dimension 1, since dimension 0 is the batch).

3. Train with a reconstruction loss

Why. This is the whole unsupervised trick: the input is also the target. Notice the training loop ignores the labels completely: for x, _ in train_loader.

Which loss? Each output pixel is a brightness between 0 and 1, and most target pixels are exactly 0 or close to 1. That makes binary cross-entropy (nn.BCELoss, the yes/no loss from this module, applied to every pixel as “how white is it?”) a good fit: it punishes confidently wrong pixels hard, which keeps strokes crisp. Mean squared error also works and is a fine choice; it tends to give slightly blurrier results.

# train.py
import torch
from torch import nn
from data import train_loader, test_loader
from model import Autoencoder

device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
torch.manual_seed(0)

def train(latent_dim: int, epochs: int = 15) -> Autoencoder:
    model = Autoencoder(latent_dim).to(device)
    loss_fn = nn.BCELoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

    for epoch in range(1, epochs + 1):
        model.train()
        total, n = 0.0, 0
        for x, _ in train_loader:                  # labels ignored
            x = x.to(device)
            recon, _ = model(x)
            loss = loss_fn(recon, x)               # the input IS the target
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            total += loss.item() * len(x)
            n += len(x)

        model.eval()
        test_total, test_n = 0.0, 0
        with torch.no_grad():
            for x, _ in test_loader:
                x = x.to(device)
                test_total += loss_fn(model(x)[0], x).item() * len(x)
                test_n += len(x)
        print(f"epoch {epoch:2d}  train {total / n:.4f}  test {test_total / test_n:.4f}")

    torch.save(model.state_dict(), f"ae_{latent_dim}.pt")
    return model

if __name__ == "__main__":
    train(latent_dim=2)

Working looks like: an untrained model scores about 0.69 (every pixel guessed as grey). Then:

epoch  1  train 0.2443  test 0.2139
epoch  5  train 0.1876  test 0.1866
epoch 10  train 0.1788  test 0.1798
epoch 15  train 0.1746  test 0.1763

Train and test losses stay close together, so the model is learning general handwriting, not memorising the 60,000 training images. Now train a second model with latent_dim=16. It reaches about 0.094 on the test set, much lower, because 16 numbers can carry far more detail than 2.

Most likely bug: using nn.BCEWithLogitsLoss with a decoder that already ends in a sigmoid (the double-sigmoid trap from this module’s training-loop lesson). The loss plateaus around 0.6 and the reconstructions are uniform grey. Pair nn.Sigmoid() with nn.BCELoss, or drop the sigmoid and use BCEWithLogitsLoss.

Stuck? Hint

If training is slow on a CPU, train for 5 epochs first; most of the improvement happens early. If the loss becomes nan, lower the learning rate to 3e-4.

4. Compare reconstructions with the originals

Why. A loss of 0.176 means nothing to a human. A picture of what the model rebuilds tells you immediately what it has and has not learned.

# show_recon.py
import torch
import matplotlib.pyplot as plt
from data import test_loader
from model import Autoencoder

def show(latent_dim: int) -> None:
    model = Autoencoder(latent_dim)
    model.load_state_dict(torch.load(f"ae_{latent_dim}.pt", map_location="cpu"))
    model.eval()
    x, _ = next(iter(test_loader))
    x = x[:10]
    with torch.no_grad():
        recon, _ = model(x)
    fig, axes = plt.subplots(2, 10, figsize=(10, 2.2))
    for i in range(10):
        axes[0, i].imshow(x[i, 0], cmap="gray")        # top row: originals
        axes[1, i].imshow(recon[i, 0], cmap="gray")    # bottom row: rebuilt
        axes[0, i].axis("off"); axes[1, i].axis("off")
    plt.savefig(f"recon_{latent_dim}.png", bbox_inches="tight")

show(2)
show(16)

Working looks like: with a 2-number code, the rebuilt digits are recognisable but blurry, like a photocopy of a photocopy. The 7, 2, 1 and 0 come back clearly. But look closely at the 4s: the 2-D model often rebuilds a 4 as something closer to a 9, and a scrawled 5 as a blurry 9. With a 16-number code, nearly everything comes back sharp and correct.

That 4-to-9 drift is your first real finding. Two numbers are not enough room to keep 4s and 9s apart, because they share so much shape. Remember that the next time someone writes a phone number with an open-topped 4.

Most likely bug: showing the reconstruction of a different batch than the originals (two separate next(iter(...)) calls on a shuffled loader). Use the test loader, which does not shuffle, and reuse the same x.

Stuck? Hint

If every reconstruction looks like the same blurry blob, the model collapsed to predicting the average digit. Check that the loss actually decreased past about 0.26, and that you loaded the trained weights rather than a fresh model.

5. Plot the latent space coloured by digit

Why. This is the moment the project is named for. The model was never told which digit is which. If its 2-D codes nonetheless group by digit, it discovered “digit identity” by itself, purely because that is what best explains the pixels.

# latent_plot.py
import torch
import matplotlib.pyplot as plt
from data import test_loader
from model import Autoencoder

model = Autoencoder(2)
model.load_state_dict(torch.load("ae_2.pt", map_location="cpu"))
model.eval()

codes, labels = [], []
with torch.no_grad():
    for x, y in test_loader:
        codes.append(model.encoder(x))
        labels.append(y)                     # labels used ONLY for colouring
Z, Y = torch.cat(codes), torch.cat(labels)

plt.figure(figsize=(7, 6))
points = plt.scatter(Z[:, 0], Z[:, 1], c=Y, cmap="tab10", s=2)
plt.colorbar(points, ticks=range(10))
plt.title("MNIST test digits in a 2-D latent space")
plt.savefig("latent_2.png", bbox_inches="tight")

for d in range(10):
    print(d, Z[Y == d].mean(dim=0).numpy().round(1))   # the centre of each digit's cloud

Working looks like: a scatter of 10,000 points that is clearly not random. In one run:

  • The 0s form a big, spread-out cloud far to one side, centred around . Zeros come in many sizes and widths, so their cloud is wide.
  • The 1s form a tight, separate streak on the other side, around . A 1 is a 1.
  • 4s, 7s and 9s pile up on top of each other in a band down the middle. 3s, 5s and 8s overlap near the centre.

Your coordinates will differ (the axes are arbitrary; a different seed can rotate or flip the whole map), but the pattern holds: distinctive digits separate, similar-looking digits overlap. The overlap of 4 and 9 is the same confusion you saw in milestone 4, now visible as geometry.

How useful is this representation? Here is a test with a real-world flavour. Suppose someone labels only 100 digits by hand, 10 per class on average. Train a simple classifier (scikit-learn’s LogisticRegression) on just those 100, using three different inputs, and test on all 10,000 test digits:

Input to the classifier Test accuracy with 100 labels
Raw pixels (784 numbers) 72.4%
2-D latent codes 55.2%
16-D latent codes 77.3%

The 2-D code throws away too much. But the 16-D code, learned without a single label, beats the raw pixels using 49 times fewer numbers. That is the promise of unsupervised representation learning: learn good features from lots of unlabelled data, then you need far fewer labels for the task you care about. Scale that idea up enormously and you have how LLMs are pretrained on raw text before anyone fine-tunes them (Module 33).

Most likely bug: using model(x) and plotting the reconstruction instead of the code. Call model.encoder(x) directly.

Stuck? Hint

For the few-label test: encode the 100 chosen training images and all 10,000 test images with model.encoder, convert to NumPy with .numpy(), then LogisticRegression(max_iter=2000).fit(train_codes, train_labels) and .score(test_codes, test_labels). Choose the 100 with a seeded torch.randperm so your result is repeatable.

6. Walk the latent space to generate new digits

Why. The decoder is a function from any 2 numbers to an image. You can feed it points that no real digit was encoded to and see what it imagines. This is your first generative model, and its flaws explain why better ones exist.

# latent_grid.py
import torch
import matplotlib.pyplot as plt
from model import Autoencoder

model = Autoencoder(2)
model.load_state_dict(torch.load("ae_2.pt", map_location="cpu"))
model.eval()

n = 12
xs = torch.linspace(-15, 8, n)       # choose ranges that cover YOUR scatter plot
ys = torch.linspace(5, -12, n)
grid = torch.zeros(28 * n, 28 * n)
with torch.no_grad():
    for r, yv in enumerate(ys):
        for c, xv in enumerate(xs):
            img = model.decoder(torch.tensor([[xv, yv]]))[0, 0]
            grid[r * 28:(r + 1) * 28, c * 28:(c + 1) * 28] = img

plt.figure(figsize=(6, 6))
plt.imshow(grid, cmap="gray")
plt.axis("off")
plt.savefig("latent_grid.png", bbox_inches="tight")

Working looks like: a 12×12 sheet of digits that morph smoothly into each other. In one run, the left side is all 0s that gradually tilt and narrow into 6s, the centre passes through 5s, 8s and 2s, a region of 9s and 7s sits below, and the right edge becomes a column of 1s leaning at different angles. Moving in one direction changes slant; moving in another changes which digit it is.

Look for the ugly cells too: places between clusters where the decoder produces smeared shapes that are not quite any digit. Those are regions of the latent space where no training digit ever landed, so the decoder never learned what to draw there. That gap problem is exactly what variational autoencoders were invented to fix, by forcing the codes to fill the space smoothly. You will build one in Module 31.

Most likely bug: choosing grid ranges that miss your clusters (for example to when your codes span to ), so every cell shows the same blur. Read the ranges off your scatter plot.

Stuck? Hint

Pick ranges from the data automatically: Z.quantile(0.05, dim=0) and Z.quantile(0.95, dim=0) from milestone 5 give a box that covers most of the points without stretching to far outliers.

7. Flag unusual digits by reconstruction error

Why. This is the part Crumb can use. The autoencoder has only ever seen normal handwriting. When it sees something unusual (a smudge, a coffee ring, a digit written over another), it rebuilds what it expects a digit to look like, and the difference between that and the actual image is large. So reconstruction error works as an “unusualness” score, with no examples of smudges needed for training. This is called anomaly detection.

To test it, make a batch of artificially smudged digits by stamping a soft blob onto each test image, then compare error distributions:

# detector.py
import torch
from torchvision import datasets
from torchvision.transforms import v2
from model import Autoencoder

to_tensor = v2.Compose([v2.ToImage(), v2.ToDtype(torch.float32, scale=True)])
test_set = datasets.MNIST("data", train=False, download=True, transform=to_tensor)
X = torch.stack([test_set[i][0] for i in range(len(test_set))])     # (10000, 1, 28, 28)

def smudge(images: torch.Tensor, seed: int = 0) -> torch.Tensor:
    """Stamp a round blob (a 'coffee ring') somewhere on each digit."""
    g = torch.Generator().manual_seed(seed)
    yy, xx = torch.meshgrid(torch.arange(28), torch.arange(28), indexing="ij")
    out = images.clone()
    for i in range(len(out)):
        cy, cx = torch.randint(6, 22, (2,), generator=g)
        blob = (((yy - cy) ** 2 + (xx - cx) ** 2) < 20).float()
        out[i, 0] = torch.clamp(out[i, 0] + blob, 0, 1)
    return out

def recon_error(model: Autoencoder, images: torch.Tensor) -> torch.Tensor:
    with torch.no_grad():
        recon, _ = model(images)
    return ((recon - images) ** 2).mean(dim=(1, 2, 3))      # one score per image

for latent_dim in (2, 16):
    model = Autoencoder(latent_dim)
    model.load_state_dict(torch.load(f"ae_{latent_dim}.pt", map_location="cpu"))
    model.eval()
    normal = recon_error(model, X)
    smudged = recon_error(model, smudge(X))
    threshold = torch.quantile(normal, 0.95)                 # flag the oddest 5% of normal digits
    caught = (smudged > threshold).float().mean()
    print(f"latent {latent_dim:2d}: normal {normal.mean():.4f}  smudged {smudged.mean():.4f}  "
          f"threshold {threshold:.4f}  smudged flagged {caught:.0%}")

The threshold is chosen from normal digits only: the error level that only the oddest 5% of real, clean digits exceed. That means about 1 in 20 normal slips will be flagged for a human to double-check (the false alarm rate), which is a cost you would agree with Meera, exactly like choosing the threshold for late deliveries in this module’s evaluation lesson.

Working looks like:

latent  2: normal 0.0384  smudged 0.0821  threshold 0.0704  smudged flagged 64%
latent 16: normal 0.0105  smudged 0.0453  threshold 0.0230  smudged flagged 86%

The 16-D model rebuilds normal digits so well that smudges stand out sharply: it catches 86% of smudged digits while flagging only 5% of clean ones. The 2-D model is a weaker detector because it rebuilds even normal digits poorly, so the gap between normal and abnormal is smaller.

Finally, print the eight normal test digits with the highest error from the 2-D model and look at them. In one run they were a looped 2, an unclosed 0, a 2 lying almost on its side, a heavy 7, and an 8 with a broken loop. Real digits, but written in ways the model rarely saw. That is exactly what you want a “please double-check this” flag to catch.

Most likely bug: choosing the threshold from the smudged data, or from a mix. In real use you will not have labelled smudges; the threshold must come from normal data alone, or your detector is quietly cheating.

Stuck? Hint

If almost nothing is flagged, check that the smudge is actually visible: plot a few smudged images. If almost everything is flagged, check that the model is in eval() mode and that you loaded trained weights.

Done means done

  • data.py prints 60,000 and 10,000 images of shape (1, 28, 28) `data.py` prints 60,000 and 10,000 images of shape `(1, 28, 28)` with values from 0 to 1.
  • The autoencoder has separate encoder and decoder The autoencoder has separate `encoder` and `decoder` attributes and 435,858 parameters at latent size 2.
  • Training logs show test reconstruction loss falling to about 0.18 (latent 2) and about 0.09 (latent 16), with train and test close together.
  • recon_2.png and recon_16.png `recon_2.png` and `recon_16.png` show originals above reconstructions, and you can name one digit the 2-D model confuses.
  • latent_2.png `latent_2.png` shows digit clusters, and you can explain which digits separate and which overlap, and why.
  • latent_grid.png `latent_grid.png` shows digits morphing smoothly, and you can point to a "gap" region.
  • detector.py `detector.py` reports the share of smudged digits flagged at a 5% false alarm rate for both latent sizes.
  • A README explains, in plain words, what the model learned without labels.

Stretch goals

  • Convolutional autoencoder. Replace the linear layers with nn.Conv2d in the encoder and nn.ConvTranspose2d in the decoder, after Module 28’s CNN lesson. Compare sharpness at the same latent size.
  • Denoising autoencoder. Train with smudged or noisy inputs but clean targets. The model learns to remove smudges. Could it clean up a scanned order slip?
  • Latent size sweep. Train latent sizes 2, 4, 8, 16 and 32 and plot test loss and few-label accuracy against size. Where do the returns flatten?
  • Real slips. Photograph ten handwritten phone numbers, crop single digits, resize to 28×28 with white-on-black like MNIST, and see which ones the detector flags. Expect surprises: real photos look very different from MNIST, which is a lesson in itself.
  • t-SNE or UMAP of the 16-D codes. Project the 16-D latent space to 2-D for plotting and compare it with the native 2-D autoencoder.

Common pitfalls

  • Treating the latent axes as meaningful. “Axis 1 is slant” is sometimes roughly true and never guaranteed. A different seed rotates the map. Talk about clusters and neighbours, not axes.
  • Using labels in training by accident. If you write for x, y in train_loader and then use y, it is no longer unsupervised. Labels are for colouring plots and for the few-label test only.
  • Picking a loss that does not match the output. Sigmoid output with BCELoss; raw output with BCEWithLogitsLoss or MSE.
  • Expecting the 2-D model to reconstruct well. It is a teaching tool for visualisation. Use larger latents for real tasks.
  • Overclaiming the detector. It flags “unlike MNIST”, not “wrong phone number”. A perfectly clear but wrong digit will pass straight through. It is a prompt for a human, not a replacement for one.

Show it off

Your README should lead with three images side by side: the latent scatter plot, the generated grid and a strip of flagged unusual digits. Under them, one paragraph: “This model never saw a label. It learned to compress handwritten digits into 2 numbers, and in doing so discovered that 0s and 1s are very different and that 4s and 9s are easily confused. With 16 numbers, a classifier trained on only 100 labelled digits beats one trained on raw pixels.”

Record a short screen capture scrolling across the latent grid, or make an animated GIF by decoding points along a straight line from the centre of the 0 cluster to the centre of the 1 cluster, so viewers watch a 0 morph into a 1.

For LinkedIn, lead with the surprising result, not the technique: “I trained a network on 60,000 handwritten digits without ever telling it which digit was which. It sorted them anyway.” Then the plot. Then a sentence on the practical use: flagging smudged or unusual handwriting for human review, catching 86% of smudged digits at a 5% false alarm rate.

Meera looked at the morphing grid for a long time. “So it worked out on its own that a 1 is a 1 and a 0 is a 0. And it gets muddled between 4s and 9s, same as my staff.”

“Same as everyone,” said Priya. “That’s the next three months in miniature: learn the shape of the data first, then teach the task. But first Arjun’s training code needs to grow up. Notebooks won’t survive photos of real pastries.”

Go deeper

Your build checklist

0/7 done

Check your understanding

3 quick questions
1. Why does an autoencoder need a narrow bottleneck to learn anything useful?
2. In the 2-D latent space, 0s and 1s form separate clusters while 4s, 7s and 9s overlap. What does this tell you?
3. How should the threshold for flagging unusual digits be chosen?