Day 07 · Overview
Week 2FNX 101 · 07Standing on Giants' Shoulders
Borrow a pre-trained network's eyes and teach it rock-paper-scissors from just 36 photos — transfer learning in action.
Today you will
3- Understand transfer learning: reusing a network trained on millions of images
- Freeze a pretrained MobileNetV3 and give it a new head for three classes
- Fine-tune on 36 photos and reach 90%+ accuracy in about a minute
Today's items
2 h 30 minLab
FNX 101 · 07Day 7 — Standing on Giants' Shoulders 🏔️
Mission briefing: The Lab wants a rock-paper-scissors referee that watches a hand through the camera and calls the move. Small problem: you have exactly 36 photos to learn from. Training a fresh network on 36 images is hopeless... so today you do what every real AI team does — you borrow a giant.
Previously in the Lab: On Day 6 you trained a convolutional network from scratch and unlocked 🕵️ Photo Detective. It needed thousands of images to get good.
Today you will:
- Understand transfer learning — reusing a network already trained on millions of images
- Freeze a pretrained MobileNetV3 and give it a brand-new "head" for your 3 classes
- Fine-tune on your handful of photos and reach 90%+ accuracy in about a minute
- Prove why the giant matters by racing it against a from-scratch model
Today you unlock: 🔓 RPS Arena — play rock-paper-scissors against the model you fine-tuned, live on camera.
A precise reading
The recorded lab freezes all existing parameters, then replaces the final Linear(1024, 1000) layer with Linear(1024, 3): 3,075 trainable parameters and 1,517,856 retained frozen parameters. The comparison trains the entire random network, so initialization and the training strategy both differ. Frozen weights do not freeze batch-normalization running statistics in train mode.
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ 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/day07
%pip -q install gradio==6.19.0
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 = 2 if SMOKE else 25 # passes over the tiny photo set (needs a few to converge)
BATCH_SIZE = 16 # photos per gradient step (dataset is tiny)
LEARNING_RATE = 1e-3 # how big a step the new head takes1. Standing on giants' shoulders 🏔️
Imagine the Lab hired a world-class chef with 20 years of experience. To teach them one new dish you don't send them back to culinary school — you show them the recipe once and they nail it, because they already own the fundamentals: knife skills, heat, seasoning, timing.
Transfer learning is that exact move for neural networks. Instead of growing a network from a baby (millions of images, days of training), we borrow a giant that is already an expert at seeing and teach it one new trick.
Today's giant is MobileNetV3, trained by Google on ImageNet — 1.2 million photos across 1,000 categories. Along the way it learned the universal vocabulary of vision: edges, textures, curves, and yes — fingers and hands. We keep all of that and teach it just three new words: rock, paper, scissors.
That is why 36 photos is enough. We are not teaching it to see. We are teaching it to sort what it already sees.
2. The entire dataset: 36 photos 📸
Real labs never have enough labeled data, and neither do you today. The repo ships with 12 photos per class (300×300, real hands) in data_samples/rps_starter/. That is the whole training set.
Every photo must pass through the exact pipeline the giant expects: shrink so the short side is 256, center-crop to 224×224, and normalize with ImageNet's mean/std (the pixel statistics of those original 1.2M photos). Feed it anything else and the giant sees noise.
from torchvision import transforms
# ImageNet statistics — the giant was trained on pixels normalized this way.
MEAN = [0.485, 0.456, 0.406]
STD = [0.229, 0.224, 0.225]
# The TRAINING pipeline (given): resize, crop, two random augmentations, normalize.
train_tf = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.RandomHorizontalFlip(), # random augmentation
transforms.RandomRotation(10), # random augmentation
transforms.ToTensor(),
transforms.Normalize(MEAN, STD),
])
print("Training pipeline:", [type(t).__name__ for t in train_tf.transforms])🧪 Exercise 1 — Finish the validation pipeline
train_tf is done. Now build val_tf: the same shape steps, but with no random augmentation — at test time we want clean, repeatable images so accuracy is honest.
- Resize to 256, then center-crop to 224
- Convert to a tensor, then
Normalize(MEAN, STD) - Do not flip or rotate
You should end up with exactly 4 steps.
val_tf = transforms.Compose([
# ==================== YOUR CODE HERE ====================
### HINT: the same first two steps as train_tf...
### HINT: ...then ToTensor and Normalize(MEAN, STD) — but NO flip/rotate
...
])
assert len(val_tf.transforms) == 4, "val_tf should have exactly 4 steps (no random augmentation)."
print("Validation pipeline:", [type(t).__name__ for t in val_tf.transforms])🎁 Data augmentation = free extra photos
Notice train_tf randomly flips and rotates each training image. Every epoch the model sees a slightly different version of the same hand — a free way to turn 36 photos into hundreds of variations, so it learns "scissors is scissors" at any angle. We never augment the validation set: we want a fixed, honest measuring stick.
from torchvision import datasets
from sklearn.model_selection import train_test_split
STARTER = SAMPLES_DIR / "rps_starter"
COLLECTED = REPO / "data" / "rps" # your own photos land here (optional, next section)
def all_targets(ds):
"""Every sample's class index — works for ImageFolder AND ConcatDataset."""
if hasattr(ds, "targets"):
return list(ds.targets)
out = []
for d in ds.datasets:
out += list(d.targets)
return out
def keep_jpg(path):
"""ImageFolder scans recursively — skip Jupyter's .ipynb_checkpoints duplicates."""
return path.lower().endswith(".jpg") and ".ipynb_checkpoints" not in path
def build(tf):
"""One dataset with transform `tf`: starter photos (+ your collected ones if any)."""
base = datasets.ImageFolder(STARTER, transform=tf)
has_extra = COLLECTED.exists() and any(
(COLLECTED / c).is_dir() and any((COLLECTED / c).glob("*.jpg"))
for c in base.classes)
if has_extra:
extra = datasets.ImageFolder(COLLECTED, transform=tf, is_valid_file=keep_jpg)
assert extra.class_to_idx == base.class_to_idx, (
"Name your collected folders exactly rock/paper/scissors so the labels line up.")
return torch.utils.data.ConcatDataset([base, extra]), base.classes
return base, base.classes
# Two views of the SAME images: one augmented (train), one clean (val).
full_train, class_names = build(train_tf)
full_val, _ = build(val_tf)
print("class_to_idx:", {c: i for i, c in enumerate(class_names)}) # alphabetical!
print("classes (training order):", class_names)
# Split the SAME indices 80/20, stratified so every class appears in both sets.
targets = all_targets(full_train)
train_idx, val_idx = train_test_split(
list(range(len(full_train))), test_size=0.2, random_state=SEED, stratify=targets)
train_dataset = torch.utils.data.Subset(full_train, train_idx)
val_dataset = torch.utils.data.Subset(full_val, val_idx)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=0)
val_loader = torch.utils.data.DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)
print(f"{len(train_dataset)} training photos, {len(val_dataset)} validation photos.")import matplotlib.pyplot as plt
from PIL import Image
fig, axes = plt.subplots(len(class_names), 4, figsize=(9, 7))
for row, cls in enumerate(class_names):
files = sorted((STARTER / cls).glob("*.jpg"))[:4]
for col, f in enumerate(files):
axes[row, col].imshow(Image.open(f))
axes[row, col].set_ylabel(cls if col == 0 else "", fontsize=12)
axes[row, col].set_xticks([]); axes[row, col].set_yticks([])
fig.suptitle("Your entire training set — 12 photos per class (4 shown)")
plt.tight_layout(); plt.show()3. (Optional) Grow your own dataset 📷
Totally optional — the 36 starter photos already work. But a referee that has only seen 36 hands is fragile. Want a tougher model? Collect your own.
Run the cell below to open a mini camera app. Show a hand, pick its true label, hit Save — it writes JPGs into data/rps/<label>/ with incrementing filenames.
# ⭐ OPTIONAL cell — skip it entirely if you're happy with the 36 starter photos.
import gradio as gr
from PIL import Image
RPS_DIR = REPO / "data" / "rps"
for c in ["rock", "paper", "scissors"]:
(RPS_DIR / c).mkdir(parents=True, exist_ok=True)
def save_photo(frame, label):
if frame is None:
return "📷 Nothing to save yet — show a hand to the webcam (give it a second), or pick a file on the Upload tab."
try:
folder = RPS_DIR / label
n = len(list(folder.glob("*.jpg")))
path = folder / f"{label}_{n:03d}.jpg"
Image.fromarray(frame).convert("RGB").save(path, quality=90)
except Exception as e:
return f"⚠️ Could not save that frame: {e}"
total = sum(len(list((RPS_DIR / c).glob("*.jpg"))) for c in ["rock", "paper", "scissors"])
return f"✅ Saved {label}/{path.name}. You now have {total} collected photo(s) total."
with gr.Blocks() as collector:
gr.Markdown(
"### 📸 Lab Camera — grow your dataset\n"
"Pick the **Webcam** or **Upload** tab, get a hand photo, set the true move, hit **Save**."
)
wlatest = gr.State(None) # latest live webcam frame (Save reads THIS, not the Image -> no 422)
with gr.Row():
with gr.Column(scale=2):
with gr.Tabs():
with gr.Tab("📷 Webcam"):
webcam = gr.Image(sources=["webcam"], streaming=True, type="numpy",
height=360, label="Show your hand")
save_w = gr.Button("Save webcam shot 📥", variant="primary")
with gr.Tab("📁 Upload"):
upload = gr.Image(sources=["upload"], type="numpy",
height=360, label="Pick a photo")
save_u = gr.Button("Save uploaded photo 📥", variant="primary")
with gr.Column(scale=1):
lab = gr.Radio(["rock", "paper", "scissors"], value="rock", label="What move is this?")
out = gr.Markdown()
webcam.stream(lambda f, cur: f if f is not None else cur,
inputs=[webcam, wlatest], outputs=wlatest, stream_every=0.25)
save_w.click(save_photo, [wlatest, lab], out)
save_u.click(save_photo, [upload, lab], out) # upload payloads are valid -> read the component directly
if not SMOKE:
collector.launch(share=IN_COLAB)
if not IN_COLAB:
print("👉 For the full-size camera, open http://127.0.0.1:7860 in a browser tab.")# 🛑 Run this to STOP the Lab Camera and free port 7860 (re-run the cell above to restart it).
if not SMOKE:
collector.close()
print("Lab Camera stopped — port 7860 is free.")💡 On Colab: a public share link appears — open it on your phone, and your phone camera becomes the lab camera. Snap 10-20 hands per class from different angles and lighting.
After collecting, scroll back up and re-run the dataset cell in section 2. It automatically folds your new photos in next to the starter set (that's the COLLECTED branch). Then keep going below.
4. Meet the giant 🦣
We load MobileNetV3-Small with its trained weights (DEFAULT). "Small" is relative — it still has about 1.5 million learned numbers. The final few layers, called the classifier (or "head"), turn the giant's visual understanding into a guess over its original 1,000 ImageNet categories. Print it below and look at the very last Linear layer — that's the part we'll swap.
from torchvision.models import mobilenet_v3_small, MobileNet_V3_Small_Weights
weights = MobileNet_V3_Small_Weights.DEFAULT # downloads once (~10 MB), then cached
model = mobilenet_v3_small(weights=weights).to(DEVICE)
# The head is a small Sequential stack. Note the LAST layer outputs 1000 classes.
print(model.classifier)🧪 Exercise 2 — Freeze the existing parameters 🧊
Before training, freeze every parameter so gradient descent can't disturb the 1.2M-photos worth of knowledge inside. Loop over model.parameters() and set each one's requires_grad = False.
Right after, the code counts trainable parameters — you should see it drop to 0 (for now, nothing can learn).
# ==================== YOUR CODE HERE ====================
### HINT: loop over model.parameters() and set p.requires_grad = False
...
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total params: {total:,}")
print(f"Trainable params: {trainable:,} <- frozen solid for now")5. The head transplant 🔧
The giant's head ends in Linear(1024 -> 1000) — one output per ImageNet category. We don't want 1,000 guesses, we want 3: rock, paper, scissors.
So we perform a transplant: replace only the last layer (index 3 of model.classifier) with a fresh Linear that has 3 outputs. A brand-new layer starts with requires_grad = True automatically — so it becomes the only part that learns.
🧪 Exercise 3 — Perform the transplant
Replace model.classifier[3] with nn.Linear(<in_features>, 3).
- The new layer's input size must match the old one — read it from
model.classifier[3].in_features - The output size is
3(our classes)
After this, trainable params jump from 0 up to a few thousand: you'll train about 0.3% of the whole brain.
import torch.nn as nn
# For reference, model.classifier is:
# Sequential(Linear(576,1024), Hardswish, Dropout, Linear(1024,1000))
# ^^^^^^^^ index 3 = the last layer
# ==================== YOUR CODE HERE ====================
### HINT: model.classifier[3] = nn.Linear(model.classifier[3].in_features, 3)
...
model = model.to(DEVICE) # the new layer needs to live on DEVICE too
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Trainable params now: {trainable:,} ({100*trainable/total:.2f}% of the brain)")
assert trainable > 0, "The new head should have trainable parameters — did the transplant run?"6. Fine-tuning: teach the new head 🎓
Now we train — but only the tiny head moves. This is why fine-tuning is so fast: 99.7% of the network is frozen, so each step only adjusts a few thousand numbers.
The training loop below is given. Your job is the one line that matters: build the optimizer and point it at only the parameters that still require gradients.
🧪 Exercise 4 — Point the optimizer at the new head
Create an Adam optimizer over only the trainable parameters:
torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=LEARNING_RATE)
The filter(...) skips every frozen parameter, so Adam only ever touches the new head. Expected result after training: validation accuracy of 0.8 or higher (usually 0.9-1.0).
import torch.nn as nn
from tqdm.auto import tqdm
criterion = nn.CrossEntropyLoss()
# ==================== YOUR CODE HERE ====================
### HINT: Adam over filter(lambda p: p.requires_grad, model.parameters()), lr=LEARNING_RATE
...
def run_epoch(net, loader, train, optim=None):
"""One pass over `loader`. Returns (avg_loss, accuracy)."""
net.train() if train else net.eval()
torch.set_grad_enabled(train)
total_loss, correct, n = 0.0, 0, 0
for xb, yb in loader:
xb, yb = xb.to(DEVICE), yb.to(DEVICE)
logits = net(xb)
loss = criterion(logits, yb)
if train:
optim.zero_grad(); loss.backward(); optim.step()
total_loss += loss.item() * len(xb)
correct += (logits.argmax(1) == yb).sum().item()
n += len(xb)
torch.set_grad_enabled(True)
return total_loss / n, correct / n
val_accs = []
for epoch in range(1, EPOCHS + 1):
tr_loss, tr_acc = run_epoch(model, train_loader, True, optimizer)
va_loss, va_acc = run_epoch(model, val_loader, False)
val_accs.append(va_acc)
print(f"Epoch {epoch:2d}/{EPOCHS} | train loss {tr_loss:.3f} | val acc {va_acc:.0%}")
pretrained_acc = max(val_accs)
print(f"\n🏆 Best validation accuracy: {pretrained_acc:.0%}")
if not SMOKE:
assert pretrained_acc >= 0.7, (
f"Best val accuracy {pretrained_acc:.0%} on only {len(val_dataset)} validation "
f"photos - re-run, or add a few of your own photos (section 3) for a steadier score.")That was ~1-2 minutes to reach 90%+ from 36 photos. From scratch (Day 6) that would have been impossible. The giant did the heavy lifting; you just taught it to sort.
Let's look at any mistakes — where does the referee still get confused?
%matplotlib inline
model.eval()
mean_t = torch.tensor(MEAN).view(3, 1, 1)
std_t = torch.tensor(STD).view(3, 1, 1)
wrong = []
with torch.no_grad():
for i in range(len(val_dataset)):
x, y = val_dataset[i]
pred = model(x.unsqueeze(0).to(DEVICE)).argmax(1).item()
if pred != y:
wrong.append((x, y, pred))
print(f"{len(wrong)} of {len(val_dataset)} validation photos misclassified.")
if wrong:
fig, axes = plt.subplots(1, min(len(wrong), 4), figsize=(9, 3), squeeze=False)
for ax, (x, y, p) in zip(axes[0], wrong[:4]):
img = (x * std_t + mean_t).clamp(0, 1).permute(1, 2, 0).numpy() # undo Normalize
ax.imshow(img); ax.axis("off")
ax.set_title(f"true: {class_names[y]}\npred: {class_names[p]}", fontsize=10)
plt.tight_layout(); plt.show()
else:
print("🎉 Zero mistakes on the validation set!")7. Does the giant really matter? 🔬
Bold claim: the 1.2 million photos of pretraining are what made this work. Let's test it the scientific way — a controlled experiment.
We build the exact same MobileNetV3 architecture but with weights=None: identical shape, random numbers inside, zero experience. Same photos, same epochs. If pretraining didn't matter, it should do just as well. Let's see.
🧪 Exercise 5 — Build a giant with no experience
Create scratch = the same model without pretrained weights. The only change from before is the weights= argument: pass None instead of the DEFAULT weights.
This time we train the whole network (there's no precious knowledge to protect). Expect the accuracy to collapse toward random guessing (~33-60%).
# ==================== YOUR CODE HERE ====================
### HINT: mobilenet_v3_small(weights=None) — no borrowed knowledge
...
scratch.classifier[3] = nn.Linear(scratch.classifier[3].in_features, 3)
scratch = scratch.to(DEVICE)
# Nothing is frozen, so we train ALL of it (and Adam sees every parameter):
scratch_opt = torch.optim.Adam(scratch.parameters(), lr=LEARNING_RATE)
scratch_accs = []
for epoch in range(1, EPOCHS + 1):
tr_loss, _ = run_epoch(scratch, train_loader, True, scratch_opt)
_, va_acc = run_epoch(scratch, val_loader, False)
scratch_accs.append(va_acc)
print(f"Epoch {epoch:2d}/{EPOCHS} | train loss {tr_loss:.3f} | val acc {va_acc:.0%}")
scratch_acc = max(scratch_accs)
print(f"\nFrom-scratch best validation accuracy: {scratch_acc:.0%}")%matplotlib inline
# ^ the Lab Camera (Gradio) silently switches matplotlib to the headless "Agg"
# backend, which makes plt.show() a no-op — this line switches it back.
labels_bar = ["Pretrained\n(borrowed giant)", "From scratch\n(random giant)"]
accs = [pretrained_acc, scratch_acc]
plt.figure(figsize=(6, 4))
bars = plt.bar(labels_bar, accs, color=["#2a9d8f", "#e76f51"])
plt.axhline(1/3, ls="--", color="gray")
plt.text(1.0, 1/3 + 0.02, "random guessing (33%)", color="gray", ha="center")
for b, a in zip(bars, accs):
plt.text(b.get_x() + b.get_width() / 2, a + 0.02, f"{a:.0%}", ha="center", fontsize=12)
plt.ylim(0, 1.1); plt.ylabel("Best validation accuracy")
plt.title("1.2 million photos of experience = the difference")
plt.tight_layout(); plt.show()The comparison uses the same architecture and small dataset, but two different training strategies: a frozen pretrained model with a new head, and an entirely trainable random model. The result supports transfer learning as a practical starting point; it does not isolate initialization as the only cause.
The head has 1024 × 3 + 3 = 3,075 trainable parameters. Frozen parameters still participate in every forward pass.
8. Instant rematch — a referee function 🎬
Time to actually use the model. A referee needs one function: given a photo, return the move and how confident it is. It runs the same clean val_tf pipeline, then reads the model's softmax probabilities.
🧪 Exercise 6 — Teach the model to referee
Finish predict(pil_img):
- Run the image tensor
xthrough the model - Take output
[0], applytorch.softmax(..., dim=0)to turn logits into probabilities argmaxgives the winning class index
The preprocessing line is already written for you. You just add the forward pass + softmax + argmax.
%matplotlib inline
def predict(pil_img):
"""Return (label, confidence) for one PIL image, using the clean val pipeline."""
x = val_tf(pil_img.convert("RGB")).unsqueeze(0).to(DEVICE) # (1, 3, 224, 224)
# ==================== YOUR CODE HERE ====================
### HINT: probs = torch.softmax(model(x)[0], dim=0); idx = int(probs.argmax())
...
return class_names[idx], float(probs[idx])
# Quick test: one starter image per class.
model.eval()
fig, axes = plt.subplots(1, len(class_names), figsize=(9, 3))
for ax, cls in zip(axes, class_names):
img = Image.open(sorted((STARTER / cls).glob("*.jpg"))[0]).convert("RGB")
label, conf = predict(img)
ax.imshow(img); ax.axis("off")
mark = "✅" if label == cls else "❌"
ax.set_title(f"{mark} pred: {label}\n({conf:.0%} sure)", fontsize=10)
plt.tight_layout(); plt.show()🔓 Unlock your Studio module
Your referee is trained — now hand it to the Studio. We need to ship two files (exactly what the RPS Arena module expects):
rps_arena.pt— the model in TorchScript form (a portable, self-contained snapshot that runs without your training code)rps_labels.json— the class names in training order, so the app knows index 0 = paper, 1 = rock, 2 = scissors
torch.jit.trace runs one fake image (1, 3, 224, 224) through the model and records every operation into a standalone file.
import json
model.cpu().eval() # trace/save on CPU (the Studio loads on CPU)
example = torch.zeros(1, 3, 224, 224) # the input contract: 224x224 RGB
traced = torch.jit.trace(model, example)
traced.save(str(MODELS_DIR / "rps_arena.pt"))
# class_names came straight from ImageFolder.classes — the exact training order.
(MODELS_DIR / "rps_labels.json").write_text(json.dumps(class_names), encoding="utf-8")
REQUIRED_FILES = ["rps_arena.pt", "rps_labels.json"]
missing = [f for f in REQUIRED_FILES if not (MODELS_DIR / f).exists()]
assert not missing, f"Something didn't save: {missing}"
model.to(DEVICE) # move back in case you keep experimenting
print("Saved labels:", class_names)
print("🔓 UNLOCKED: RPS Arena! Run your Studio and beat your own creation:")
print(" python ai_studio/app.py (from the repo root) -> 🪨📄✂️ RPS Arena")🏁 Checkpoint
Today you borrowed a giant. You froze MobileNetV3's 1.5M pretrained parameters, transplanted a fresh 3-class head, and fine-tuned on 36 photos to a 90%+ referee in about a minute — then proved, with a controlled experiment, that the pretraining is what made it possible. That's transfer learning, the way real AI teams actually ship.
Studio unlocked: 🪨📄✂️ RPS Arena — go challenge the model you built.
Machines that see: done. Tomorrow — machines that READ and WRITE. We crack open the transformers behind ChatGPT and discover the one absurdly simple idea driving them: predict the next word, a trillion times. See you in the Language Lab. 💬