Day 06 · Overview
Week 2FNX 101 · 06Machines That See
Convolutions turn pixels into perception: train a CNN photo detective on CIFAR-10's ten everyday classes.
Today you will
3- See exactly why a flat network struggles with photos
- Build a convolution by hand and watch it find edges
- Train a CNN on CIFAR-10 and read its confusion matrix
Today's items
2 h 30 minLab
FNX 101 · 06Day 6 — Machines That See 👁️
Mission briefing: The Lab's camera team needs a photo detective: point it at a picture and it names what's there — one of ten everyday objects. But a plain network treats a photo as a flat list of numbers and falls apart. Today you give it real eyes: convolutions.
Previously in the Lab: on Day 5 you trained your first real networks with PyTorch and unlocked Digit Reader. Those digits were tiny and gray — today we go to full color.
Today you will:
- See exactly why a flat network struggles with photos
- Build a convolution by hand and watch it find edges
- Train a CNN on CIFAR-10 — ten object classes, in color
- Read a confusion matrix and look through the network's own eyes
- Road-test it on real photos — and meet its honest limits
Today you unlock: 🔓 Photo Detective — upload a photo and the CNN you trained investigates.
A precise reading
Flattening preserves pixel values in a fixed order; it removes the explicit spatial structure from the layer interface. A CNN builds in local connectivity and shared weights. Day 5 trained both an MLP and a CNN. CIFAR-10 contains 50,000 training and 10,000 test images. Report the measured test score and failure cases rather than assuming a universal human baseline.
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ SETUP — run me first! (identical in every Lab notebook) ║
# ╚═══════════════════════════════════════════════════════════════════╝
import os, sys, pathlib
IN_COLAB = "google.colab" in sys.modules
SMOKE = os.environ.get("SMOKE_TEST", "0") == "1" # tiny fast run for automated tests
REPO_URL = "https://github.com/A-Halimi/ai-studio-internship.git" # instructor: set once
if IN_COLAB:
if not pathlib.Path("/content/ai-studio-internship").exists():
!git clone -q {REPO_URL} /content/ai-studio-internship
%cd /content/ai-studio-internship/notebooks/day06 # ← ADAPT day folder
%pip -q install gradio==6.19.0 # ← ADAPT per-day installs (see spec; delete line if none)
HERE = pathlib.Path.cwd()
REPO = HERE.parents[1] # .../notebooks/dayNN -> repo root
DATA_DIR = pathlib.Path(os.environ.get("COURSE_DATA_DIR", REPO / "data"))
SAMPLES_DIR = REPO / "data_samples" # small datasets shipped with the repo
MODELS_DIR = REPO / "ai_studio" / "models" # where your Studio modules unlock
for p in (REPO / "data", MODELS_DIR):
p.mkdir(parents=True, exist_ok=True)
import random
import numpy as np
SEED = 42
random.seed(SEED); np.random.seed(SEED)
print(f"✅ Setup done | Colab: {IN_COLAB} | Smoke test: {SMOKE}")
print(f" data: {DATA_DIR}")import torch
torch.manual_seed(SEED)
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
if DEVICE.type == "cuda":
print(f"⚡ Running on GPU: {torch.cuda.get_device_name(0)}")
else:
print("🐢 No GPU found — running on CPU. Everything still works, just slower.")# ── CONFIG — the Lab's control panel. Tweak me later! ──────────────
EPOCHS = 1 if SMOKE else 8
TRAIN_SUBSET = 1_000 if SMOKE else 20_000 # how many training images to use
BATCH_SIZE = 128
LEARNING_RATE = 1e-3
print(f"EPOCHS={EPOCHS} TRAIN_SUBSET={TRAIN_SUBSET:,} BATCH_SIZE={BATCH_SIZE} LR={LEARNING_RATE}")1. Why a flat network can't really see
On Day 5 the very first layer was nn.Flatten(): it unrolled each 28×28 image into a list of 784 numbers. That worked for neat, centered digits. For real photos it's a disaster, for three reasons:
- It destroys neighborhoods. Flattening puts pixel (0,0) next to (0,1) but far from (1,0) — even though those two are neighbors on the image. The network has to re-learn which pixels are adjacent. That's wasteful and hard.
- It's not shift-invariant. Move a cat 3 pixels to the right and every input number changes. To a flat network, the shifted cat is a brand-new, unrecognizable image.
- It explodes in size. A color 32×32 photo is 32·32·3 = 3,072 numbers. One dense layer to just 512 hidden units needs 3072·512 ≈ 1.5 million weights — for a single layer, on a tiny image.
Convolutions fix all three at once.
2. A convolution is a sliding stencil
Picture a tiny magnifying glass, 3×3 pixels wide. You slide it across the image, and at every position it asks one fixed question — say, "is there a vertical edge right here?" — and writes down how strongly the answer is "yes."
That little 3×3 grid of numbers is a kernel (or filter). Sliding it over the whole image produces a new image — a feature map — that lights up wherever the pattern appears.
The magic is that the same kernel is used at every position. So it finds its pattern anywhere in the image (shift-invariance ✓), only ever looks at neighbors (respects the 2-D layout ✓), and costs just 9 numbers instead of millions ✓.
Let's build one by hand and point it at a real photo.
import numpy as np
import torch
import torch.nn.functional as F
import matplotlib.pyplot as plt
from sklearn.datasets import load_sample_image
photo = load_sample_image("china.jpg") # a real photo, no download needed
gray = photo.mean(axis=2) # collapse color -> grayscale
img = torch.tensor(gray, dtype=torch.float32).reshape(1, 1, *gray.shape)
# A horizontal-edge detector: rows of -1 / 0 / +1
h_kernel = torch.tensor([[-1., -1., -1.],
[ 0., 0., 0.],
[ 1., 1., 1.]]).reshape(1, 1, 3, 3)
h_edges = F.conv2d(img, h_kernel)
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
axes[0].imshow(gray, cmap="gray"); axes[0].set_title("original"); axes[0].axis("off")
axes[1].imshow(h_edges[0, 0], cmap="gray"); axes[1].set_title("horizontal edges"); axes[1].axis("off")
plt.tight_layout(); plt.show()🧪 Exercise 1 — the vertical-edge kernel
The horizontal detector had rows of −1 / 0 / +1. To find vertical edges, you need columns of −1 / 0 / +1 instead — the same idea, turned 90°.
- Build
v_kerneland apply it withF.conv2d. - Expected: the output lights up on vertical boundaries (the sides of pillars and doorframes) instead of horizontal ones.
# ==================== YOUR CODE HERE ====================
### HINT: transpose the pattern — make columns of -1, 0, +1 instead of rows
...
v_edges = F.conv2d(img, v_kernel)
fig, axes = plt.subplots(1, 3, figsize=(10, 4))
axes[0].imshow(gray, cmap="gray"); axes[0].set_title("original"); axes[0].axis("off")
axes[1].imshow(h_edges[0, 0], cmap="gray"); axes[1].set_title("horizontal edges"); axes[1].axis("off")
axes[2].imshow(v_edges[0, 0], cmap="gray"); axes[2].set_title("vertical edges"); axes[2].axis("off")
plt.tight_layout(); plt.show()🧪 Exercise 2 — a blur kernel
Not every kernel finds edges. A kernel of equal positive weights that sum to 1 averages each neighborhood — a blur.
- Build a 3×3 kernel where every entry is
1/9, then apply it. - Expected: a softer, smeared version of the photo.
# ==================== YOUR CODE HERE ====================
### HINT: torch.ones(1, 1, 3, 3) / 9 makes nine equal weights that sum to 1
...
blurred = F.conv2d(img, blur_kernel)
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
axes[0].imshow(gray, cmap="gray"); axes[0].set_title("original"); axes[0].axis("off")
axes[1].imshow(blurred[0, 0], cmap="gray"); axes[1].set_title("blurred (3×3 average)"); axes[1].axis("off")
plt.tight_layout(); plt.show()3. Stacking convolutions = seeing bigger things
One kernel finds one pattern. A convolution layer learns many kernels at once (our detective learns 32 in its first layer). Stack layers and something beautiful happens:
- Layer 1 learns edges and color blobs — like the ones you just built by hand.
- Layer 2 combines those edges into corners, curves, and textures.
- Layer 3 combines those into object parts — an ear, a wheel, an eye.
And you never hand-design these kernels. The network learns them by gradient descent: loss.backward() discovers which 3×3 patterns matter.
HPC echo: a convolution is secretly a big matrix multiply. Libraries unroll each image patch into a column (a trick called
im2col) and hit the whole thing with one giant@. It's the same operation you've been staring at since Day 4 — which is exactly why GPUs love convolutions, and why HPC engineers obsess over making@fast.
4. Meet CIFAR-10 — the world in 32×32 color
CIFAR-10 is 60,000 tiny color photos, each 32×32 pixels, across 10 classes: airplane, automobile, bird, cat, deer, dog, frog, horse, ship, truck. It's the classic proving ground for "can your network actually see?"
This time we do normalize the pixels to roughly −1…+1. Centering the numbers keeps training stable — and it's the exact recipe your Studio module expects, so remember it for the unlock: Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), i.e. (x − 0.5) / 0.5.
from torchvision import datasets, transforms
from torch.utils.data import DataLoader, Subset
tfm = transforms.Compose([
transforms.ToTensor(), # -> [0, 1]
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), # -> ~[-1, 1] (module contract!)
])
# The official CIFAR-10 server (cs.toronto.edu) is often slow — use a fast md5-verified mirror.
# (In the Docker container the dataset is already baked in, so nothing downloads at all.)
datasets.CIFAR10.url = "https://data.brainchip.com/dataset-mirror/cifar10/cifar-10-python.tar.gz"
train_full = datasets.CIFAR10(DATA_DIR, train=True, download=True, transform=tfm)
test_set = datasets.CIFAR10(DATA_DIR, train=False, download=True, transform=tfm)
classes = train_full.classes
train_set = Subset(train_full, range(TRAIN_SUBSET))
# num_workers=0: CIFAR-10 is small and already local, so one process is plenty,
# and it avoids the harmless-but-noisy DataLoader worker-shutdown warnings in Jupyter.
train_loader = DataLoader(train_set, batch_size=BATCH_SIZE, shuffle=True, num_workers=0)
test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=0)
print("classes:", classes)
print(f"train images: {len(train_set):,} test images: {len(test_set):,}")import matplotlib.pyplot as plt
fig, axes = plt.subplots(4, 8, figsize=(10, 6))
for ax, idx in zip(axes.ravel(), range(32)):
img_t, label = train_full[idx]
ax.imshow((img_t * 0.5 + 0.5).permute(1, 2, 0)) # un-normalize: img*0.5+0.5 -> [0,1]
ax.set_title(classes[label], fontsize=8)
ax.axis("off")
fig.suptitle("CIFAR-10 — 32×32 color photos")
plt.tight_layout(); plt.show()5. Build the detective
Here's the blueprint: two convolution blocks to see, then two dense layers to decide.
Conv2d(3 → 32, 3×3, pad 1) → ReLU → MaxPool(2) # 32×32 → 16×16
Conv2d(32 → 64, 3×3, pad 1) → ReLU → MaxPool(2) # 16×16 → 8×8
Flatten → Linear(64·8·8 → 256) → ReLU → Dropout(0.3) → Linear(256 → 10)
padding=1 keeps each conv the same width; MaxPool2d(2) halves it and keeps only the strongest response in each 2×2 patch. Dropout randomly silences 30% of neurons during training so the network can't over-rely on any single one — cheap insurance against memorizing.
🧪 Exercise 3 — assemble the CNN
Translate the blueprint into an nn.Sequential. Match the numbers exactly — the unlock at the end traces this shape.
import torch.nn as nn
# ==================== YOUR CODE HERE ====================
...
print(detective)
# shape check: a fake batch of 4 images should come out (4, 10)
_probe = detective(torch.zeros(4, 3, 32, 32, device=DEVICE))
assert _probe.shape == (4, 10), "The network should output 10 scores per image"
print("✅ shape check passed:", tuple(_probe.shape))🧪 Exercise 4 — count the cost
Add up the detective's parameters and compare to that 1.5-million-weight single MLP layer from Section 1.
# ==================== YOUR CODE HERE ====================
### HINT: sum(p.numel() for p in detective.parameters())
...
mlp_one_layer = 32 * 32 * 3 * 512 + 512
print(f"The whole detective: {n_params:,} parameters")
print(f"One dense 3072→512 layer alone: {mlp_one_layer:,} parameters")
print(f"Entire CNN smaller than that one layer? {n_params < mlp_one_layer}")6. Train the detective
Same five-step loop as Day 5 — you know this pattern cold now. We print the test accuracy after every epoch so you can watch it climb. On the Lab's GPU the full run takes a few minutes; grab water and watch the number go up.
import torch.nn as nn
from tqdm.auto import tqdm
def evaluate(model, loader):
model.eval()
correct = total = 0
with torch.no_grad():
for images, labels in loader:
preds = model(images.to(DEVICE)).argmax(1).cpu()
correct += (preds == labels).sum().item()
total += labels.size(0)
return correct / total
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(detective.parameters(), lr=LEARNING_RATE)
for epoch in range(1, EPOCHS + 1):
detective.train()
running = 0.0
for images, labels in tqdm(train_loader, desc=f"epoch {epoch}/{EPOCHS}"):
images, labels = images.to(DEVICE), labels.to(DEVICE)
optimizer.zero_grad()
loss = loss_fn(detective(images), labels)
loss.backward()
optimizer.step()
running += loss.item() * images.size(0)
train_loss = running / len(train_loader.dataset)
test_acc = evaluate(detective, test_loader)
print(f"epoch {epoch}: train loss {train_loss:.3f} | test accuracy {test_acc:.3f}")
if not SMOKE:
assert test_acc > 0.60, "Expected > 60% after the full run — give it all 8 epochs on GPU"How good is 70%, really?
If that number feels low, hold on. Random guessing across 10 classes is 10% — your detective is already 7× better than chance, learned from just 20,000 tiny images. Humans score about 94% on CIFAR-10, and they've had years of practice.
The gap is real, though, and tomorrow you'll close most of it with one trick. For now, look at where it goes wrong — that's where the story is.
7. Where does it get confused?
A confusion matrix counts, for every true class, what the network actually guessed. The diagonal is correct answers; bright spots off the diagonal are mix-ups.
🧪 Exercise 5 — build the confusion matrix
Loop over the test set and tally cm[true, predicted] += 1 for each image.
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
detective.eval()
cm = np.zeros((10, 10), dtype=int)
with torch.no_grad():
for images, labels in test_loader:
preds = detective(images.to(DEVICE)).argmax(1).cpu().numpy()
# ==================== YOUR CODE HERE ====================
### HINT: for each (true, pred) pair, do cm[true, pred] += 1
...
plt.figure(figsize=(8, 6))
sns.heatmap(cm, annot=True, fmt="d", cmap="Blues",
xticklabels=classes, yticklabels=classes)
plt.xlabel("predicted class"); plt.ylabel("true class")
plt.title("Confusion matrix — where the detective gets confused")
plt.tight_layout(); plt.show()Look at the brightest off-diagonal cells. Almost always it's cat ↔ dog and deer ↔ horse — four-legged, furry, similar silhouettes once you shrink them to 32×32. Your network isn't making random mistakes; it's making the same ones a squinting human would. That's a sign it learned something real about shape and texture.
8. See through the detective's eyes
Two ways to peek inside. First, the 32 filters its first layer learned — tiny 3×3 color patterns, the machine-designed cousins of your hand-built edge kernels. Second, the feature maps: what those filters light up on for a real image.
import matplotlib.pyplot as plt
# The 32 first-layer filters, shown as little color squares
w = detective[0].weight.detach().cpu() # shape (32, 3, 3, 3)
w = (w - w.min()) / (w.max() - w.min()) # scale to [0,1] for display
fig, axes = plt.subplots(4, 8, figsize=(9, 5))
for ax, i in zip(axes.ravel(), range(32)):
ax.imshow(w[i].permute(1, 2, 0)) # (3,3,3) C,H,W -> H,W,C
ax.axis("off")
fig.suptitle("Layer 1 — the 32 tiny 3×3 filters the detective learned")
plt.tight_layout(); plt.show()# Feature maps: run one test image through just the first conv layer
detective.eval()
img_t, label = test_set[0]
with torch.no_grad():
fmaps = detective[0](img_t.unsqueeze(0).to(DEVICE))[0].cpu() # (32, 32, 32)
fig, axes = plt.subplots(4, 8, figsize=(9, 5))
for ax, i in zip(axes.ravel(), range(32)):
ax.imshow(fmaps[i], cmap="viridis")
ax.axis("off")
fig.suptitle(f"Feature maps of conv-1 for a '{classes[label]}' image")
plt.tight_layout(); plt.show()🧪 Exercise 6 — look at a different class
Pick a class name (try "ship", "frog", or "airplane"), find the first test image of that class, and show conv-1's feature maps for it. Notice how a different set of the 32 maps lights up than for the previous image — same filters, different features triggered.
target_class = "ship"
# ==================== YOUR CODE HERE ====================
### HINT: scan test_set for the first image whose label name matches target_class
...
fig, axes = plt.subplots(4, 8, figsize=(9, 5))
for ax, i in zip(axes.ravel(), range(32)):
ax.imshow(fmaps[i], cmap="viridis")
ax.axis("off")
fig.suptitle(f"Feature maps of conv-1 for a '{classes[label]}' image")
plt.tight_layout(); plt.show()9. Why train on a GPU?
Convolutions are even hungrier than the plain matrix multiplies you timed on Day 5 — so a GPU pulls even further ahead. Let's time a few real training steps of the detective on each device.
🧪 Exercise 7 — time a training step, GPU vs CPU
Fill in the timed loop. The warm-up pass and torch.cuda.synchronize() are given — remember, a GPU runs asynchronously, so you must wait for it to finish before stopping the clock.
import time
import copy
import torch.nn as nn
loss_fn = nn.CrossEntropyLoss()
images, labels = next(iter(train_loader))
N_STEPS = 3 if SMOKE else 20
def time_training_steps(model, imgs, lbls, n_steps):
opt = torch.optim.Adam(model.parameters(), lr=LEARNING_RATE)
model.train()
# warm-up (first pass builds kernels / caches)
loss_fn(model(imgs), lbls).backward()
if imgs.is_cuda:
torch.cuda.synchronize()
# ==================== YOUR CODE HERE ====================
### HINT: time n_steps of (zero_grad, forward+loss, backward, step); synchronize if on GPU
...
cpu_model = copy.deepcopy(detective).cpu()
cpu_t = time_training_steps(cpu_model, images.cpu(), labels.cpu(), N_STEPS)
print(f"CPU: {cpu_t * 1e3:8.1f} ms per training step")
if DEVICE.type == "cuda":
gpu_model = copy.deepcopy(detective).to(DEVICE)
gpu_t = time_training_steps(gpu_model, images.to(DEVICE), labels.to(DEVICE), N_STEPS)
print(f"GPU: {gpu_t * 1e3:8.1f} ms per training step")
print(f"→ the GPU is {cpu_t / gpu_t:.1f}× faster at training this CNN")
else:
print("🐢 CPU only — on the Lab's GPU this step would be many times faster.")10. Road test on real photos
Your detective only ever saw 32×32 CIFAR images. What happens on a full-size photo from the wild? The helper below center-crops any image, shrinks it to the detective's tiny 32×32 world, normalizes it exactly like training, and shows you both what you see and what it sees.
import numpy as np
from PIL import Image
import matplotlib.pyplot as plt
from sklearn.datasets import load_sample_image
def road_test(pil_img, title):
im = pil_img.convert("RGB")
side = min(im.size) # square center-crop
left, top = (im.width - side) // 2, (im.height - side) // 2
im32 = im.crop((left, top, left + side, top + side)).resize((32, 32), Image.LANCZOS)
x = torch.from_numpy(np.asarray(im32, np.float32) / 255.0).permute(2, 0, 1).unsqueeze(0)
x = (x - 0.5) / 0.5 # the exact training normalization
detective.eval()
dev = next(detective.parameters()).device # follow the model onto whatever device it is on
with torch.no_grad():
probs = torch.softmax(detective(x.to(dev))[0], dim=0)
top = int(probs.argmax())
fig, axes = plt.subplots(1, 2, figsize=(6, 3))
axes[0].imshow(im); axes[0].set_title(title); axes[0].axis("off")
axes[1].imshow(im32); axes[1].axis("off")
axes[1].set_title(f"detective sees 32×32\n→ {classes[top]} ({float(probs[top]):.0%})")
plt.tight_layout(); plt.show()
road_test(Image.fromarray(load_sample_image("flower.jpg")), "flower.jpg")
road_test(Image.fromarray(load_sample_image("china.jpg")), "china.jpg")
road_test(Image.open(SAMPLES_DIR / "rps_starter" / "rock" / "rock_01.jpg"), "your rock ✊")
road_test(Image.open(SAMPLES_DIR / "rps_starter" / "scissors" / "scissors_01.jpg"), "your scissors ✌️")The honest limits
On the flower and the landscape, the detective does its best to squeeze them into its 10 boxes. But your hand photos? It has no "hand" class — it was never taught one — so it is forced to shout "cat!" or "bird!" with total confidence.
That's not a bug; it's the deal you made when you trained on only 10 classes and 20,000 tiny images. Tomorrow you'll fix exactly this, by borrowing a network that has already seen over a million photos of a thousand kinds of things.
11. The Photo Detective mini-app
Same preprocessing, wrapped in a little upload-and-investigate interface — a preview of the Studio tab you're about to unlock.
import gradio as gr
detective.eval()
def investigate(img):
if img is None:
return {}, None
im = Image.fromarray(img).convert("RGB")
side = min(im.size)
left, top = (im.width - side) // 2, (im.height - side) // 2
im32 = im.crop((left, top, left + side, top + side)).resize((32, 32), Image.LANCZOS)
x = torch.from_numpy(np.asarray(im32, np.float32) / 255.0).permute(2, 0, 1).unsqueeze(0)
x = (x - 0.5) / 0.5
dev = next(detective.parameters()).device # follow the model onto whatever device it is on
with torch.no_grad():
probs = torch.softmax(detective(x.to(dev))[0], dim=0)
preview = np.kron(np.asarray(im32), np.ones((8, 8, 1), np.uint8))
return {c: float(p) for c, p in zip(classes, probs)}, preview
with gr.Blocks() as demo:
gr.Markdown("## 🕵️ Photo Detective — upload a photo of one of the 10 classes")
with gr.Row():
inp = gr.Image(type="numpy", label="Evidence (your photo)")
with gr.Column():
out = gr.Label(num_top_classes=5, label="Verdict")
seen = gr.Image(label="What the detective sees (32×32)", height=200)
inp.change(investigate, inp, [out, seen])
if not SMOKE:
demo.launch(share=IN_COLAB)🔓 Unlock your Studio module
Ship it. We export the trained CNN as TorchScript, plus a small JSON file listing the 10 class names so the Studio knows what each output means. The input contract: a (1, 3, 32, 32) tensor, normalized with (x − 0.5) / 0.5 — exactly what your road_test helper produced.
import json
REQUIRED_FILES = ["photo_detective.pt", "photo_detective_classes.json"]
detective.eval()
example = torch.zeros(1, 3, 32, 32) # input contract: (1,3,32,32), normalized
scripted = torch.jit.trace(detective.cpu(), example) # trace on CPU; the Studio runs on CPU
scripted.save(str(MODELS_DIR / "photo_detective.pt"))
detective.to(DEVICE) # put the live model back on its device
(MODELS_DIR / "photo_detective_classes.json").write_text(
json.dumps(list(classes)), encoding="utf-8")
missing = [f for f in REQUIRED_FILES if not (MODELS_DIR / f).exists()]
assert not missing, f"Something didn't save: {missing}"
print("🔓 UNLOCKED: Photo Detective! Run the Studio to see it live:")
print(" python ai_studio/app.py (from the repo root)")🏁 Checkpoint
Today you gave a network eyes:
- ✅ Saw why flattening a photo throws away everything that matters
- ✅ Built edge and blur convolutions by hand with
F.conv2d - ✅ Trained a CNN on CIFAR-10 to roughly 7× better than guessing
- ✅ Read a confusion matrix and visualized the filters it learned
- 🔓 Unlocked Photo Detective in your AI Studio
Your detective learned from just 20,000 tiny images. That's why it tops out around 70% — and why a photo of your hand comes back as "cat."
Tomorrow — Day 7, "Standing on Giants": 36 photos could never train a great network from scratch. So you won't. You'll borrow 1.2 million photos' worth of experience from a network trained on ImageNet and fine-tune it on rock-paper-scissors in minutes. It's the shortcut the pros actually use. 🪨📄✂️