{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "c4e5bea9",
   "metadata": {},
   "source": [
    "# Day 7 — Standing on Giants' Shoulders 🏔️\n",
    "\n",
    "> **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**.\n",
    "\n",
    "**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.\n",
    "\n",
    "**Today you will:**\n",
    "- Understand **transfer learning** — reusing a network already trained on millions of images\n",
    "- Freeze a pretrained **MobileNetV3** and give it a brand-new \"head\" for your 3 classes\n",
    "- Fine-tune on your handful of photos and reach 90%+ accuracy in about a minute\n",
    "- Prove *why* the giant matters by racing it against a from-scratch model\n",
    "\n",
    "**Today you unlock:** 🔓 **RPS Arena** — play rock-paper-scissors against the model you fine-tuned, live on camera.\n",
    "\n",
    "<details data-resource-precision=\"7\"><summary>A precise reading</summary><p>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.</p></details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3a61353d",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ╔═══════════════════════════════════════════════════════════════════╗\n",
    "# ║ SETUP — run me first! (identical in every Lab notebook)           ║\n",
    "# ╚═══════════════════════════════════════════════════════════════════╝\n",
    "import os, sys, pathlib\n",
    "\n",
    "IN_COLAB = \"google.colab\" in sys.modules\n",
    "SMOKE = os.environ.get(\"SMOKE_TEST\", \"0\") == \"1\"   # tiny fast run for automated tests\n",
    "\n",
    "REPO_URL = \"https://github.com/A-Halimi/ai-studio-internship.git\"  # instructor: set once\n",
    "\n",
    "if IN_COLAB:\n",
    "    if not pathlib.Path(\"/content/ai-studio-internship\").exists():\n",
    "        !git clone -q {REPO_URL} /content/ai-studio-internship\n",
    "    %cd /content/ai-studio-internship/notebooks/day07\n",
    "    %pip -q install gradio==6.19.0\n",
    "\n",
    "HERE = pathlib.Path.cwd()\n",
    "REPO = HERE.parents[1]                       # .../notebooks/dayNN -> repo root\n",
    "DATA_DIR = pathlib.Path(os.environ.get(\"COURSE_DATA_DIR\", REPO / \"data\"))\n",
    "SAMPLES_DIR = REPO / \"data_samples\"          # small datasets shipped with the repo\n",
    "MODELS_DIR = REPO / \"ai_studio\" / \"models\"   # where your Studio modules unlock\n",
    "for p in (REPO / \"data\", MODELS_DIR):\n",
    "    p.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "import random\n",
    "import numpy as np\n",
    "SEED = 42\n",
    "random.seed(SEED); np.random.seed(SEED)\n",
    "print(f\"✅ Setup done | Colab: {IN_COLAB} | Smoke test: {SMOKE}\")\n",
    "print(f\"   data: {DATA_DIR}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "393b9471",
   "metadata": {},
   "outputs": [],
   "source": [
    "import torch\n",
    "torch.manual_seed(SEED)\n",
    "DEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n",
    "if DEVICE.type == \"cuda\":\n",
    "    print(f\"⚡ Running on GPU: {torch.cuda.get_device_name(0)}\")\n",
    "else:\n",
    "    print(\"🐢 No GPU found — running on CPU. Everything still works, just slower.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f10f4fdc",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── CONFIG — the Lab's control panel. Tweak me later! ──────────────\n",
    "EPOCHS        = 2 if SMOKE else 25   # passes over the tiny photo set (needs a few to converge)\n",
    "BATCH_SIZE    = 16                   # photos per gradient step (dataset is tiny)\n",
    "LEARNING_RATE = 1e-3                 # how big a step the new head takes"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "245640fc",
   "metadata": {},
   "source": [
    "## 1. Standing on giants' shoulders 🏔️\n",
    "\n",
    "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.\n",
    "\n",
    "**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*.\n",
    "\n",
    "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*.\n",
    "\n",
    "That is why **36 photos** is enough. We are not teaching it to see. We are teaching it to *sort* what it already sees."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b9bb09ba",
   "metadata": {},
   "source": [
    "## 2. The entire dataset: 36 photos 📸\n",
    "\n",
    "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.\n",
    "\n",
    "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."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e8f4fd2f",
   "metadata": {},
   "outputs": [],
   "source": [
    "from torchvision import transforms\n",
    "\n",
    "# ImageNet statistics — the giant was trained on pixels normalized this way.\n",
    "MEAN = [0.485, 0.456, 0.406]\n",
    "STD  = [0.229, 0.224, 0.225]\n",
    "\n",
    "# The TRAINING pipeline (given): resize, crop, two random augmentations, normalize.\n",
    "train_tf = transforms.Compose([\n",
    "    transforms.Resize(256),\n",
    "    transforms.CenterCrop(224),\n",
    "    transforms.RandomHorizontalFlip(),      # random augmentation\n",
    "    transforms.RandomRotation(10),          # random augmentation\n",
    "    transforms.ToTensor(),\n",
    "    transforms.Normalize(MEAN, STD),\n",
    "])\n",
    "print(\"Training pipeline:\", [type(t).__name__ for t in train_tf.transforms])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "282edce5",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 1 — Finish the validation pipeline\n",
    "\n",
    "`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.\n",
    "\n",
    "- Resize to 256, then center-crop to 224\n",
    "- Convert to a tensor, then `Normalize(MEAN, STD)`\n",
    "- Do **not** flip or rotate\n",
    "\n",
    "You should end up with exactly **4 steps**."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "fc6ac3fb",
   "metadata": {},
   "outputs": [],
   "source": [
    "val_tf = transforms.Compose([\n",
    "    # ==================== YOUR CODE HERE ====================\n",
    "    ### HINT: the same first two steps as train_tf...\n",
    "    ### HINT: ...then ToTensor and Normalize(MEAN, STD) — but NO flip/rotate\n",
    "    ...\n",
    "])\n",
    "assert len(val_tf.transforms) == 4, \"val_tf should have exactly 4 steps (no random augmentation).\"\n",
    "print(\"Validation pipeline:\", [type(t).__name__ for t in val_tf.transforms])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6d5fea79",
   "metadata": {},
   "source": [
    "### 🎁 Data augmentation = free extra photos\n",
    "\n",
    "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."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "dfe18999",
   "metadata": {},
   "outputs": [],
   "source": [
    "from torchvision import datasets\n",
    "from sklearn.model_selection import train_test_split\n",
    "\n",
    "STARTER   = SAMPLES_DIR / \"rps_starter\"\n",
    "COLLECTED = REPO / \"data\" / \"rps\"          # your own photos land here (optional, next section)\n",
    "\n",
    "def all_targets(ds):\n",
    "    \"\"\"Every sample's class index — works for ImageFolder AND ConcatDataset.\"\"\"\n",
    "    if hasattr(ds, \"targets\"):\n",
    "        return list(ds.targets)\n",
    "    out = []\n",
    "    for d in ds.datasets:\n",
    "        out += list(d.targets)\n",
    "    return out\n",
    "\n",
    "def keep_jpg(path):\n",
    "    \"\"\"ImageFolder scans recursively — skip Jupyter's .ipynb_checkpoints duplicates.\"\"\"\n",
    "    return path.lower().endswith(\".jpg\") and \".ipynb_checkpoints\" not in path\n",
    "\n",
    "def build(tf):\n",
    "    \"\"\"One dataset with transform `tf`: starter photos (+ your collected ones if any).\"\"\"\n",
    "    base = datasets.ImageFolder(STARTER, transform=tf)\n",
    "    has_extra = COLLECTED.exists() and any(\n",
    "        (COLLECTED / c).is_dir() and any((COLLECTED / c).glob(\"*.jpg\"))\n",
    "        for c in base.classes)\n",
    "    if has_extra:\n",
    "        extra = datasets.ImageFolder(COLLECTED, transform=tf, is_valid_file=keep_jpg)\n",
    "        assert extra.class_to_idx == base.class_to_idx, (\n",
    "            \"Name your collected folders exactly rock/paper/scissors so the labels line up.\")\n",
    "        return torch.utils.data.ConcatDataset([base, extra]), base.classes\n",
    "    return base, base.classes\n",
    "\n",
    "# Two views of the SAME images: one augmented (train), one clean (val).\n",
    "full_train, class_names = build(train_tf)\n",
    "full_val,   _           = build(val_tf)\n",
    "print(\"class_to_idx:\", {c: i for i, c in enumerate(class_names)})   # alphabetical!\n",
    "print(\"classes (training order):\", class_names)\n",
    "\n",
    "# Split the SAME indices 80/20, stratified so every class appears in both sets.\n",
    "targets = all_targets(full_train)\n",
    "train_idx, val_idx = train_test_split(\n",
    "    list(range(len(full_train))), test_size=0.2, random_state=SEED, stratify=targets)\n",
    "train_dataset = torch.utils.data.Subset(full_train, train_idx)\n",
    "val_dataset   = torch.utils.data.Subset(full_val,   val_idx)\n",
    "\n",
    "train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True,  num_workers=0)\n",
    "val_loader   = torch.utils.data.DataLoader(val_dataset,   batch_size=BATCH_SIZE, shuffle=False, num_workers=0)\n",
    "print(f\"{len(train_dataset)} training photos, {len(val_dataset)} validation photos.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c71ce5dd",
   "metadata": {},
   "outputs": [],
   "source": [
    "import matplotlib.pyplot as plt\n",
    "from PIL import Image\n",
    "\n",
    "fig, axes = plt.subplots(len(class_names), 4, figsize=(9, 7))\n",
    "for row, cls in enumerate(class_names):\n",
    "    files = sorted((STARTER / cls).glob(\"*.jpg\"))[:4]\n",
    "    for col, f in enumerate(files):\n",
    "        axes[row, col].imshow(Image.open(f))\n",
    "        axes[row, col].set_ylabel(cls if col == 0 else \"\", fontsize=12)\n",
    "        axes[row, col].set_xticks([]); axes[row, col].set_yticks([])\n",
    "fig.suptitle(\"Your entire training set — 12 photos per class (4 shown)\")\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "91914ebb",
   "metadata": {},
   "source": [
    "## 3. (Optional) Grow your own dataset 📷\n",
    "\n",
    "**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.\n",
    "\n",
    "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."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5cd304b8",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ⭐ OPTIONAL cell — skip it entirely if you're happy with the 36 starter photos.\n",
    "import gradio as gr\n",
    "from PIL import Image\n",
    "\n",
    "RPS_DIR = REPO / \"data\" / \"rps\"\n",
    "for c in [\"rock\", \"paper\", \"scissors\"]:\n",
    "    (RPS_DIR / c).mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "def save_photo(frame, label):\n",
    "    if frame is None:\n",
    "        return \"📷 Nothing to save yet — show a hand to the webcam (give it a second), or pick a file on the Upload tab.\"\n",
    "    try:\n",
    "        folder = RPS_DIR / label\n",
    "        n = len(list(folder.glob(\"*.jpg\")))\n",
    "        path = folder / f\"{label}_{n:03d}.jpg\"\n",
    "        Image.fromarray(frame).convert(\"RGB\").save(path, quality=90)\n",
    "    except Exception as e:\n",
    "        return f\"⚠️ Could not save that frame: {e}\"\n",
    "    total = sum(len(list((RPS_DIR / c).glob(\"*.jpg\"))) for c in [\"rock\", \"paper\", \"scissors\"])\n",
    "    return f\"✅ Saved {label}/{path.name}. You now have {total} collected photo(s) total.\"\n",
    "\n",
    "with gr.Blocks() as collector:\n",
    "    gr.Markdown(\n",
    "        \"### 📸 Lab Camera — grow your dataset\\n\"\n",
    "        \"Pick the **Webcam** or **Upload** tab, get a hand photo, set the true move, hit **Save**.\"\n",
    "    )\n",
    "    wlatest = gr.State(None)          # latest live webcam frame (Save reads THIS, not the Image -> no 422)\n",
    "    with gr.Row():\n",
    "        with gr.Column(scale=2):\n",
    "            with gr.Tabs():\n",
    "                with gr.Tab(\"📷 Webcam\"):\n",
    "                    webcam = gr.Image(sources=[\"webcam\"], streaming=True, type=\"numpy\",\n",
    "                                      height=360, label=\"Show your hand\")\n",
    "                    save_w = gr.Button(\"Save webcam shot 📥\", variant=\"primary\")\n",
    "                with gr.Tab(\"📁 Upload\"):\n",
    "                    upload = gr.Image(sources=[\"upload\"], type=\"numpy\",\n",
    "                                      height=360, label=\"Pick a photo\")\n",
    "                    save_u = gr.Button(\"Save uploaded photo 📥\", variant=\"primary\")\n",
    "        with gr.Column(scale=1):\n",
    "            lab = gr.Radio([\"rock\", \"paper\", \"scissors\"], value=\"rock\", label=\"What move is this?\")\n",
    "            out = gr.Markdown()\n",
    "    webcam.stream(lambda f, cur: f if f is not None else cur,\n",
    "                  inputs=[webcam, wlatest], outputs=wlatest, stream_every=0.25)\n",
    "    save_w.click(save_photo, [wlatest, lab], out)\n",
    "    save_u.click(save_photo, [upload, lab], out)   # upload payloads are valid -> read the component directly\n",
    "\n",
    "if not SMOKE:\n",
    "    collector.launch(share=IN_COLAB)\n",
    "    if not IN_COLAB:\n",
    "        print(\"👉 For the full-size camera, open http://127.0.0.1:7860 in a browser tab.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6ff7ed93",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 🛑 Run this to STOP the Lab Camera and free port 7860 (re-run the cell above to restart it).\n",
    "if not SMOKE:\n",
    "    collector.close()\n",
    "    print(\"Lab Camera stopped — port 7860 is free.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b417b4ab",
   "metadata": {},
   "source": [
    "💡 **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.\n",
    "\n",
    "**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."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12b6add0",
   "metadata": {},
   "source": [
    "## 4. Meet the giant 🦣\n",
    "\n",
    "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."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b97c7ec0",
   "metadata": {},
   "outputs": [],
   "source": [
    "from torchvision.models import mobilenet_v3_small, MobileNet_V3_Small_Weights\n",
    "\n",
    "weights = MobileNet_V3_Small_Weights.DEFAULT     # downloads once (~10 MB), then cached\n",
    "model = mobilenet_v3_small(weights=weights).to(DEVICE)\n",
    "\n",
    "# The head is a small Sequential stack. Note the LAST layer outputs 1000 classes.\n",
    "print(model.classifier)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1d3f6ff1",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 2 — Freeze the existing parameters 🧊\n",
    "\n",
    "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`.\n",
    "\n",
    "Right after, the code counts trainable parameters — you should see it drop to **0** (for now, nothing can learn)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6c25c6d2",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ==================== YOUR CODE HERE ====================\n",
    "### HINT: loop over model.parameters() and set p.requires_grad = False\n",
    "...\n",
    "\n",
    "total     = sum(p.numel() for p in model.parameters())\n",
    "trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)\n",
    "print(f\"Total params:     {total:,}\")\n",
    "print(f\"Trainable params: {trainable:,}   <- frozen solid for now\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9cd52c16",
   "metadata": {},
   "source": [
    "## 5. The head transplant 🔧\n",
    "\n",
    "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.\n",
    "\n",
    "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."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bd97046f",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 3 — Perform the transplant\n",
    "\n",
    "Replace `model.classifier[3]` with `nn.Linear(<in_features>, 3)`.\n",
    "\n",
    "- The new layer's input size must match the old one — read it from `model.classifier[3].in_features`\n",
    "- The output size is `3` (our classes)\n",
    "\n",
    "After this, trainable params jump from 0 up to a few thousand: **you'll train about 0.3% of the whole brain.**"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e6c02aa9",
   "metadata": {},
   "outputs": [],
   "source": [
    "import torch.nn as nn\n",
    "\n",
    "# For reference, model.classifier is:\n",
    "#   Sequential(Linear(576,1024), Hardswish, Dropout, Linear(1024,1000))\n",
    "#                                                     ^^^^^^^^ index 3 = the last layer\n",
    "# ==================== YOUR CODE HERE ====================\n",
    "### HINT: model.classifier[3] = nn.Linear(model.classifier[3].in_features, 3)\n",
    "...\n",
    "model = model.to(DEVICE)                 # the new layer needs to live on DEVICE too\n",
    "\n",
    "total     = sum(p.numel() for p in model.parameters())\n",
    "trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)\n",
    "print(f\"Trainable params now: {trainable:,}   ({100*trainable/total:.2f}% of the brain)\")\n",
    "assert trainable > 0, \"The new head should have trainable parameters — did the transplant run?\""
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6f2f390d",
   "metadata": {},
   "source": [
    "## 6. Fine-tuning: teach the new head 🎓\n",
    "\n",
    "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.\n",
    "\n",
    "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*."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "65401329",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 4 — Point the optimizer at the new head\n",
    "\n",
    "Create an **Adam** optimizer over **only the trainable parameters**:\n",
    "\n",
    "```\n",
    "torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=LEARNING_RATE)\n",
    "```\n",
    "\n",
    "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)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "26fb7aac",
   "metadata": {},
   "outputs": [],
   "source": [
    "import torch.nn as nn\n",
    "from tqdm.auto import tqdm\n",
    "\n",
    "criterion = nn.CrossEntropyLoss()\n",
    "\n",
    "# ==================== YOUR CODE HERE ====================\n",
    "### HINT: Adam over filter(lambda p: p.requires_grad, model.parameters()), lr=LEARNING_RATE\n",
    "...\n",
    "\n",
    "def run_epoch(net, loader, train, optim=None):\n",
    "    \"\"\"One pass over `loader`. Returns (avg_loss, accuracy).\"\"\"\n",
    "    net.train() if train else net.eval()\n",
    "    torch.set_grad_enabled(train)\n",
    "    total_loss, correct, n = 0.0, 0, 0\n",
    "    for xb, yb in loader:\n",
    "        xb, yb = xb.to(DEVICE), yb.to(DEVICE)\n",
    "        logits = net(xb)\n",
    "        loss = criterion(logits, yb)\n",
    "        if train:\n",
    "            optim.zero_grad(); loss.backward(); optim.step()\n",
    "        total_loss += loss.item() * len(xb)\n",
    "        correct    += (logits.argmax(1) == yb).sum().item()\n",
    "        n          += len(xb)\n",
    "    torch.set_grad_enabled(True)\n",
    "    return total_loss / n, correct / n\n",
    "\n",
    "val_accs = []\n",
    "for epoch in range(1, EPOCHS + 1):\n",
    "    tr_loss, tr_acc = run_epoch(model, train_loader, True, optimizer)\n",
    "    va_loss, va_acc = run_epoch(model, val_loader,   False)\n",
    "    val_accs.append(va_acc)\n",
    "    print(f\"Epoch {epoch:2d}/{EPOCHS} | train loss {tr_loss:.3f} | val acc {va_acc:.0%}\")\n",
    "\n",
    "pretrained_acc = max(val_accs)\n",
    "print(f\"\\n🏆 Best validation accuracy: {pretrained_acc:.0%}\")\n",
    "if not SMOKE:\n",
    "    assert pretrained_acc >= 0.7, (\n",
    "            f\"Best val accuracy {pretrained_acc:.0%} on only {len(val_dataset)} validation \"\n",
    "            f\"photos - re-run, or add a few of your own photos (section 3) for a steadier score.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9736433c",
   "metadata": {},
   "source": [
    "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.\n",
    "\n",
    "Let's look at any mistakes — where does the referee still get confused?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "493ce67d",
   "metadata": {},
   "outputs": [],
   "source": [
    "%matplotlib inline\n",
    "model.eval()\n",
    "mean_t = torch.tensor(MEAN).view(3, 1, 1)\n",
    "std_t  = torch.tensor(STD).view(3, 1, 1)\n",
    "\n",
    "wrong = []\n",
    "with torch.no_grad():\n",
    "    for i in range(len(val_dataset)):\n",
    "        x, y = val_dataset[i]\n",
    "        pred = model(x.unsqueeze(0).to(DEVICE)).argmax(1).item()\n",
    "        if pred != y:\n",
    "            wrong.append((x, y, pred))\n",
    "\n",
    "print(f\"{len(wrong)} of {len(val_dataset)} validation photos misclassified.\")\n",
    "if wrong:\n",
    "    fig, axes = plt.subplots(1, min(len(wrong), 4), figsize=(9, 3), squeeze=False)\n",
    "    for ax, (x, y, p) in zip(axes[0], wrong[:4]):\n",
    "        img = (x * std_t + mean_t).clamp(0, 1).permute(1, 2, 0).numpy()   # undo Normalize\n",
    "        ax.imshow(img); ax.axis(\"off\")\n",
    "        ax.set_title(f\"true: {class_names[y]}\\npred: {class_names[p]}\", fontsize=10)\n",
    "    plt.tight_layout(); plt.show()\n",
    "else:\n",
    "    print(\"🎉 Zero mistakes on the validation set!\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e24d1a11",
   "metadata": {},
   "source": [
    "## 7. Does the giant *really* matter? 🔬\n",
    "\n",
    "Bold claim: the 1.2 million photos of pretraining are what made this work. Let's test it the scientific way — a controlled experiment.\n",
    "\n",
    "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."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5df25eec",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 5 — Build a giant with no experience\n",
    "\n",
    "Create `scratch` = the same model **without** pretrained weights. The only change from before is the `weights=` argument: pass **`None`** instead of the `DEFAULT` weights.\n",
    "\n",
    "This time we train the *whole* network (there's no precious knowledge to protect). Expect the accuracy to **collapse** toward random guessing (~33-60%)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c4fc0876",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ==================== YOUR CODE HERE ====================\n",
    "### HINT: mobilenet_v3_small(weights=None)  — no borrowed knowledge\n",
    "...\n",
    "scratch.classifier[3] = nn.Linear(scratch.classifier[3].in_features, 3)\n",
    "scratch = scratch.to(DEVICE)\n",
    "\n",
    "# Nothing is frozen, so we train ALL of it (and Adam sees every parameter):\n",
    "scratch_opt = torch.optim.Adam(scratch.parameters(), lr=LEARNING_RATE)\n",
    "\n",
    "scratch_accs = []\n",
    "for epoch in range(1, EPOCHS + 1):\n",
    "    tr_loss, _      = run_epoch(scratch, train_loader, True, scratch_opt)\n",
    "    _,       va_acc = run_epoch(scratch, val_loader,   False)\n",
    "    scratch_accs.append(va_acc)\n",
    "    print(f\"Epoch {epoch:2d}/{EPOCHS} | train loss {tr_loss:.3f} | val acc {va_acc:.0%}\")\n",
    "\n",
    "scratch_acc = max(scratch_accs)\n",
    "print(f\"\\nFrom-scratch best validation accuracy: {scratch_acc:.0%}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "eeb6df17",
   "metadata": {},
   "outputs": [],
   "source": [
    "%matplotlib inline\n",
    "# ^ the Lab Camera (Gradio) silently switches matplotlib to the headless \"Agg\"\n",
    "#   backend, which makes plt.show() a no-op — this line switches it back.\n",
    "labels_bar = [\"Pretrained\\n(borrowed giant)\", \"From scratch\\n(random giant)\"]\n",
    "accs = [pretrained_acc, scratch_acc]\n",
    "\n",
    "plt.figure(figsize=(6, 4))\n",
    "bars = plt.bar(labels_bar, accs, color=[\"#2a9d8f\", \"#e76f51\"])\n",
    "plt.axhline(1/3, ls=\"--\", color=\"gray\")\n",
    "plt.text(1.0, 1/3 + 0.02, \"random guessing (33%)\", color=\"gray\", ha=\"center\")\n",
    "for b, a in zip(bars, accs):\n",
    "    plt.text(b.get_x() + b.get_width() / 2, a + 0.02, f\"{a:.0%}\", ha=\"center\", fontsize=12)\n",
    "plt.ylim(0, 1.1); plt.ylabel(\"Best validation accuracy\")\n",
    "plt.title(\"1.2 million photos of experience = the difference\")\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0d9d4894",
   "metadata": {},
   "source": [
    "<p>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.</p><p>The head has <code>1024 × 3 + 3 = 3,075</code> trainable parameters. Frozen parameters still participate in every forward pass.</p>"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f74d461e",
   "metadata": {},
   "source": [
    "## 8. Instant rematch — a referee function 🎬\n",
    "\n",
    "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."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ea6c0513",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 6 — Teach the model to referee\n",
    "\n",
    "Finish `predict(pil_img)`:\n",
    "\n",
    "- Run the image tensor `x` through the model\n",
    "- Take output `[0]`, apply `torch.softmax(..., dim=0)` to turn logits into probabilities\n",
    "- `argmax` gives the winning class index\n",
    "\n",
    "The preprocessing line is already written for you. You just add the forward pass + softmax + argmax."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "eb6be22a",
   "metadata": {},
   "outputs": [],
   "source": [
    "%matplotlib inline\n",
    "def predict(pil_img):\n",
    "    \"\"\"Return (label, confidence) for one PIL image, using the clean val pipeline.\"\"\"\n",
    "    x = val_tf(pil_img.convert(\"RGB\")).unsqueeze(0).to(DEVICE)   # (1, 3, 224, 224)\n",
    "    # ==================== YOUR CODE HERE ====================\n",
    "    ### HINT: probs = torch.softmax(model(x)[0], dim=0); idx = int(probs.argmax())\n",
    "    ...\n",
    "    return class_names[idx], float(probs[idx])\n",
    "\n",
    "# Quick test: one starter image per class.\n",
    "model.eval()\n",
    "fig, axes = plt.subplots(1, len(class_names), figsize=(9, 3))\n",
    "for ax, cls in zip(axes, class_names):\n",
    "    img = Image.open(sorted((STARTER / cls).glob(\"*.jpg\"))[0]).convert(\"RGB\")\n",
    "    label, conf = predict(img)\n",
    "    ax.imshow(img); ax.axis(\"off\")\n",
    "    mark = \"✅\" if label == cls else \"❌\"\n",
    "    ax.set_title(f\"{mark} pred: {label}\\n({conf:.0%} sure)\", fontsize=10)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4177c3a2",
   "metadata": {},
   "source": [
    "## 🔓 Unlock your Studio module\n",
    "\n",
    "Your referee is trained — now hand it to the Studio. We need to ship two files (exactly what the **RPS Arena** module expects):\n",
    "\n",
    "- `rps_arena.pt` — the model in **TorchScript** form (a portable, self-contained snapshot that runs without your training code)\n",
    "- `rps_labels.json` — the class names **in training order**, so the app knows index 0 = paper, 1 = rock, 2 = scissors\n",
    "\n",
    "`torch.jit.trace` runs one fake image `(1, 3, 224, 224)` through the model and records every operation into a standalone file."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c242663b",
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "\n",
    "model.cpu().eval()                                   # trace/save on CPU (the Studio loads on CPU)\n",
    "example = torch.zeros(1, 3, 224, 224)                # the input contract: 224x224 RGB\n",
    "traced = torch.jit.trace(model, example)\n",
    "traced.save(str(MODELS_DIR / \"rps_arena.pt\"))\n",
    "\n",
    "# class_names came straight from ImageFolder.classes — the exact training order.\n",
    "(MODELS_DIR / \"rps_labels.json\").write_text(json.dumps(class_names), encoding=\"utf-8\")\n",
    "\n",
    "REQUIRED_FILES = [\"rps_arena.pt\", \"rps_labels.json\"]\n",
    "missing = [f for f in REQUIRED_FILES if not (MODELS_DIR / f).exists()]\n",
    "assert not missing, f\"Something didn't save: {missing}\"\n",
    "\n",
    "model.to(DEVICE)                                     # move back in case you keep experimenting\n",
    "print(\"Saved labels:\", class_names)\n",
    "print(\"🔓 UNLOCKED: RPS Arena! Run your Studio and beat your own creation:\")\n",
    "print(\"   python ai_studio/app.py   (from the repo root)   ->   🪨📄✂️ RPS Arena\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "008d4263",
   "metadata": {},
   "source": [
    "## 🏁 Checkpoint\n",
    "\n",
    "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.\n",
    "\n",
    "**Studio unlocked:** 🪨📄✂️ RPS Arena — go challenge the model you built.\n",
    "\n",
    "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. 💬"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "688bc06e",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "<div align=\"center\" style=\"background:#14161E;border-radius:12px;padding:18px 12px 14px;margin-top:6px\">\n",
    "  <a href=\"https://ahalimi.com\" target=\"_blank\">\n",
    "    <img src=\"data:image/webp;base64,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\" alt=\"Dr. Abdelghafour HALIMI\" width=\"260\"/>\n",
    "  </a>\n",
    "</div>\n",
    "\n",
    "<div align=\"center\" style=\"text-align:center;margin-top:8px\"><sub>The Lab · AI Studio — Intro to AI · © 2026 <b>Dr. Abdelghafour HALIMI</b> · <a href=\"https://ahalimi.com\">ahalimi.com</a> · All rights reserved.</sub></div>"
   ]
  }
 ],
 "metadata": {
  "authors": [
   {
    "name": "Dr. Abdelghafour HALIMI"
   }
  ],
  "copyright": "© 2026 Dr. Abdelghafour HALIMI · ahalimi.com · All rights reserved",
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.12.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
