Project: train and deploy an image classifier
From a folder of images to a public web demo, with error analysis
Everything in this phase becomes real when you ship something: a model trained on your own data, evaluated honestly, explained with Grad-CAM and running on a public URL that a friend can try. This project is the first rung of the portfolio ladder in 11.3 Portfolio project ladder, and its pattern — fine-tune a pretrained model, evaluate with a confusion matrix, deploy with Gradio on Hugging Face Spaces — repeats for the LLM (8.13 Project: fine-tune a small LLM with LoRA and ship a chat app), RAG (9.11 Project: a cited RAG assistant over your own documents, with evals) and agent (10.12 Project: build a research agent with tools, an MCP server and an eval harness) projects later.
You'll be able to
- Assemble and split a custom image dataset without leakage
- Fine-tune a pretrained backbone with a modern recipe and track experiments
- Perform error analysis with confusion matrices, top losses and Grad-CAM
- Export the model and deploy a Gradio demo on Hugging Face Spaces
- Write a concise, honest project report
Key ideas
- Deduplicate and split at the source level before anything else; near-duplicate leakage is the most common reason a beginner's accuracy is fake.
- Always report a trivial baseline and a frozen-embedding baseline before fine-tuning.
- Log every run and change one variable at a time; correct normalization and model.eval() are the two silent killers.
Show 3 more key ideas
- Error analysis is the product: confusion pairs, top-loss images, Grad-CAM shortcuts, calibration temperature and a shift test.
- Deploy with Gradio on a free Hugging Face Space; ship the class names and preprocessing config with the weights.
- Use the test set exactly once, for the final number in the README.
Suggested path through this lesson
The brief
Build a classifier for 5–20 visual categories you care about (plant species in your area, kinds of food, tools in a workshop, hand-written symbols, dog breeds from a public dataset). Target: a model that beats a reasonable baseline, an error analysis you can explain, and a live demo. Budget: 6–10 hours over a week on a free Colab/Kaggle GPU. If collecting images is hard, use Oxford-IIIT Pets (37 breeds, 7,400 images) or a 10-class subset of Food-101.
Milestone 1 — Data (1–2 h)
Collect 100–500 images per class (your own photos, a public dataset, or a careful web scrape you have the right to use). Put them in data/train/<class>/ folders so torchvision.datasets.ImageFolder works. Then:
- Deduplicate with perceptual hashes (
imagehash) — near-duplicates across train and validation inflate accuracy, the most common beginner leak (3.5 Evaluating models: metrics, validation and the bias–variance trade-off). - Split 70/15/15 at the level of the source (same photo session or same object stays in one split), stratified by class.
- Look at the data: a grid of 64 random images per class; note label noise and class imbalance now, not after training.
Milestone 2 — Baseline and model (2–3 h)
Start with a trivial baseline (majority class; then logistic regression on frozen convnext_tiny embeddings — often strong, 5.4 Transfer learning and training CNNs in practice). Then fine-tune:
import timm, torch, torch.nn as nn
from torchvision import datasets
model = timm.create_model("convnext_tiny", pretrained=True, num_classes=NUM_CLASSES).cuda()
cfg = timm.data.resolve_data_config({}, model=model)
train_ds = datasets.ImageFolder("data/train", timm.data.create_transform(**cfg, is_training=True, auto_augment="rand-m9-mstd0.5"))
val_ds = datasets.ImageFolder("data/val", timm.data.create_transform(**cfg, is_training=False))
train_dl = torch.utils.data.DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4)
val_dl = torch.utils.data.DataLoader(val_ds, batch_size=64, num_workers=4)
opt = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.05)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=EPOCHS * len(train_dl))
loss_fn = nn.CrossEntropyLoss(label_smoothing=0.1)
for epoch in range(EPOCHS):
model.train()
for x, y in train_dl:
with torch.autocast("cuda", dtype=torch.bfloat16):
loss = loss_fn(model(x.cuda()), y.cuda())
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
model.eval(); correct = 0
with torch.no_grad():
for x, y in val_dl:
correct += (model(x.cuda()).argmax(1).cpu() == y).sum().item()
print(epoch, loss.item(), correct / len(val_ds))
torch.save(model.state_dict(), "model.pt")
Log every run (a CSV or Weights & Biases, 2.5 Tooling: Git, GPUs, experiments and the Hugging Face Hub): backbone, resolution, LR, augmentation, epochs, validation accuracy. Change one thing at a time. Expect fine-tuning to beat the frozen-embedding baseline by a few points when you have a few hundred images per class, and to be close when you have fewer.
Milestone 3 — Error analysis (1–2 h)
- Confusion matrix on the validation set; name the top three confused pairs and look at those images.
- Top losses: the 32 highest-loss validation images — fix label errors, remove junk, note genuinely hard cases.
- Grad-CAM (5.9 Interpretability, robustness and adversarial examples) on 10 correct and 10 wrong predictions: is the model looking at the object or at a shortcut (background, watermark, a hand)?
- Calibration: reliability diagram; fit a temperature so your demo's confidence means something.
- Shift test: 20 photos taken under new conditions. Report the drop honestly. Only now touch the test set, once, for the final number.
Milestone 4 — Deploy (1–2 h)
Export the weights (and the class names and preprocessing config!) and write app.py:
import gradio as gr, timm, torch, json
from PIL import Image
classes = json.load(open("classes.json"))
model = timm.create_model("convnext_tiny", pretrained=False, num_classes=len(classes))
model.load_state_dict(torch.load("model.pt", map_location="cpu")); model.eval()
cfg = timm.data.resolve_data_config({}, model=model)
tf = timm.data.create_transform(**cfg, is_training=False)
T = 1.8 # temperature from calibration
def predict(img: Image.Image):
with torch.no_grad():
probs = torch.softmax(model(tf(img).unsqueeze(0)) / T, dim=1)[0]
return {c: float(p) for c, p in zip(classes, probs)}
gr.Interface(predict, gr.Image(type="pil"), gr.Label(num_top_classes=3),
title="My classifier", examples=["examples/a.jpg", "examples/b.jpg"]).launch()
Create a Space on Hugging Face (SDK: Gradio), push app.py, model.pt, classes.json, a requirements.txt (gradio, timm, torch) and a few example images. CPU Spaces are free and fine for a ConvNeXt-Tiny at ~100 ms per image. Stretch goals: export to ONNX for faster CPU inference, add a Grad-CAM overlay output, or ship a TorchScript model inside a small FastAPI service.
Milestone 5 — Write-up (1 h)
A README with: the problem and data (sizes, splits, how leakage was prevented), the baseline and final results in a table (with the test set used once), the three most informative failure cases with Grad-CAM, the shift-test result, limitations, and the demo link. Honest beats impressive: an 88% model with a clear error analysis is a better portfolio piece than a 97% model with hidden leakage.
Rubric
| criterion | what good looks like |
|---|---|
| data hygiene | deduplicated, source-level split, class counts reported |
| baselines | majority class and frozen-embedding baseline reported |
| training | logged runs, one-change-at-a-time, correct normalization and eval mode |
| analysis | confusion matrix, top losses, Grad-CAM, calibration, shift test |
| deployment | public demo with examples; preprocessing matches training |
| communication | README someone else could reproduce from |
Where this shows up later
- The same fine-tune → evaluate → deploy loop for an LLM — 8.13 Project: fine-tune a small LLM with LoRA and ship a chat app, a RAG app — 9.11 Project: a cited RAG assistant over your own documents, with evals and an agent — 10.12 Project: build a research agent with tools, an MCP server and an eval harness.
- Portfolio and write-up standards — 11.3 Portfolio project ladder; experiment tracking — 2.5 Tooling: Git, GPUs, experiments and the Hugging Face Hub; evaluation discipline — 3.5 Evaluating models: metrics, validation and the bias–variance trade-off.
Common pitfalls (4)
- Splitting randomly at the image level when several images come from the same object/session.
- Forgetting to save and reuse the exact preprocessing (resize, mean/std) in the deployed app.
- Tuning hyperparameters against the test set, then reporting it as held-out.
- Shipping raw softmax confidences without temperature scaling — users will trust an over-confident model.
Recap: all 6 key points
- Deduplicate and split at the source level before anything else; near-duplicate leakage is the most common reason a beginner's accuracy is fake.
- Always report a trivial baseline and a frozen-embedding baseline before fine-tuning.
- Log every run and change one variable at a time; correct normalization and model.eval() are the two silent killers.
- Error analysis is the product: confusion pairs, top-loss images, Grad-CAM shortcuts, calibration temperature and a shift test.
- Deploy with Gradio on a free Hugging Face Space; ship the class names and preprocessing config with the weights.
- Use the test set exactly once, for the final number in the README.
Watch
Data cleaning via top losses and deploying a Gradio app to Hugging Face Spaces — the exact workflow of milestones 3–4.
Read
Exercises
- Milestones 1–2: data and model
Build the dataset folder, deduplicate, split at source level and train the baseline and the fine-tuned model. Deliverable: a table with majority-class, frozen-embedding and fine-tuned validation accuracy, plus your run log.
- Milestone 3: error analysis
Produce the confusion matrix, the 32 top-loss images with notes, 20 Grad-CAM overlays, a reliability diagram before/after temperature scaling, and the shift-test accuracy. Deliverable: a one-page analysis with the three most informative failures.
- Milestones 4–5: deploy and write up
Publish the Gradio demo on Hugging Face Spaces with 3+ example images and write the README following the rubric. Deliverable: the Space URL and the repository link. Stretch: ONNX export with a latency comparison.
Quiz
Flashcards
These cards are now in your spaced-repetition queue. Review all due cards →