{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "41ae32cd",
   "metadata": {},
   "source": [
    "# Day 4 — Inside the Brain 🧠\n",
    "\n",
    "> **Mission briefing:** The Chief Scientist drops by with a challenge and a grin: *\"Anyone can call a library. Today you build a neural network with your bare hands — just NumPy — and you'll train it to think. After this, the black box is never black again.\"* No PyTorch. No shortcuts. Let's open the brain.\n",
    "\n",
    "**Previously in the Lab:** yesterday you unlocked the 🎨 **Pattern Finder** and watched machines find structure with no labels. Today you build the thing everyone *means* when they say \"AI\": a **neural network** — from scratch.\n",
    "\n",
    "**Today you will:**\n",
    "- Understand a single **neuron** and why one is never enough\n",
    "- Discover that a layer of neurons is really **one matrix multiplication** (remember this — it comes back on Day 10)\n",
    "- Write the **forward pass**, a **loss**, and teach the network to learn by the **nudge method**\n",
    "- *Watch* the decision boundary bend around the data as it trains\n",
    "- Peek at the **loss landscape** the network rolls down\n",
    "\n",
    "**Today you unlock:** nothing to save — today you **forge the engine** that tomorrow's Studio module runs on. Day 5 hands you PyTorch and a GPU to turbocharge exactly what you build today.\n",
    "\n",
    "<details data-resource-precision=\"4\"><summary>A precise reading</summary><p>The gradient points toward increasing loss; subtract it to descend. Finite differences approximate derivatives. Backpropagation uses one forward pass and one backward pass, not two forward passes. Matrix products are central, alongside biases, nonlinear activations and other operations. A sigmoid output between 0 and 1 is not automatically a calibrated probability.</p></details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8c7f2191",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:29.077099Z",
     "iopub.status.busy": "2026-08-17T12:54:29.076940Z",
     "iopub.status.idle": "2026-08-17T12:54:29.188894Z",
     "shell.execute_reply": "2026-08-17T12:54:29.187718Z"
    }
   },
   "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/day04\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": "47fb8300",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:29.190503Z",
     "iopub.status.busy": "2026-08-17T12:54:29.190300Z",
     "iopub.status.idle": "2026-08-17T12:54:29.194318Z",
     "shell.execute_reply": "2026-08-17T12:54:29.193353Z"
    }
   },
   "outputs": [],
   "source": [
    "# ── CONFIG — the Lab's control panel. Tweak me later! ──────────────\n",
    "EPOCHS        = 50 if SMOKE else 400     # how many full passes over the data\n",
    "LEARNING_RATE = 0.5                      # how big a step each weight takes downhill\n",
    "HIDDEN        = 16                       # neurons in the hidden layer\n",
    "print(f\"Training {EPOCHS} epochs | learning rate {LEARNING_RATE} | {HIDDEN} hidden neurons\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "15928ad1",
   "metadata": {},
   "source": [
    "## 1 · The neuron: a bouncer at a door\n",
    "\n",
    "A neuron is almost embarrassingly simple. Picture a bouncer deciding whether to open a door.\n",
    "\n",
    "Each reason to open (an **input**) has a **weight** — how much that reason matters. The bouncer multiplies each input by its weight, adds them all up, tosses in a personal **bias** (a good or bad mood), and if the total clears the bar, the door opens.\n",
    "\n",
    "In math that's just:\n",
    "\n",
    "`output = activation( input₁·w₁ + input₂·w₂ + … + bias )`\n",
    "\n",
    "The **weights** and **bias** are the knobs we'll tune. The **activation** is the \"does it clear the bar?\" squashing step. Stack a few of these bouncers into layers and you get a network like this one — 2 inputs, a hidden layer of 16, and 1 output:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "16c2528b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:29.196048Z",
     "iopub.status.busy": "2026-08-17T12:54:29.195890Z",
     "iopub.status.idle": "2026-08-17T12:54:29.534015Z",
     "shell.execute_reply": "2026-08-17T12:54:29.532863Z"
    }
   },
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "# A quick diagram of our network (just for intuition).\n",
    "fig, ax = plt.subplots(figsize=(8, 5))\n",
    "layers = [2, 16, 1]\n",
    "xs = [0, 1, 2]\n",
    "pos = {}\n",
    "for li, (x, n) in enumerate(zip(xs, layers)):\n",
    "    for ni, y in enumerate(np.linspace(0.05, 0.95, n)):\n",
    "        pos[(li, ni)] = (x, y)\n",
    "        ax.add_patch(plt.Circle((x, y), 0.02, color=\"#1f77b4\", zorder=3))\n",
    "for li in range(len(layers) - 1):\n",
    "    for a in range(layers[li]):\n",
    "        for b in range(layers[li + 1]):\n",
    "            x0, y0 = pos[(li, a)]\n",
    "            x1, y1 = pos[(li + 1, b)]\n",
    "            ax.plot([x0, x1], [y0, y1], color=\"gray\", lw=0.3, alpha=0.5, zorder=1)\n",
    "for x, name in zip(xs, [\"2 inputs\", \"16 hidden neurons\", \"1 output\"]):\n",
    "    ax.text(x, 1.03, name, ha=\"center\", fontsize=11)\n",
    "ax.set_xlim(-0.3, 2.3)\n",
    "ax.set_ylim(0, 1.12)\n",
    "ax.axis(\"off\")\n",
    "ax.set_title(\"our network: information flows left → right\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e694c215",
   "metadata": {},
   "source": [
    "## 2 · The challenge data: two interlocking moons\n",
    "\n",
    "To make this real we need data that's *hard* — something no single straight line can split. `make_moons` gives us two interleaving crescents: think of them as two teams tangled together. We also **standardize** the inputs (mean 0, spread 1), which keeps the sigmoids happy and helps the network learn faster."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "19ece337",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:29.536022Z",
     "iopub.status.busy": "2026-08-17T12:54:29.535821Z",
     "iopub.status.idle": "2026-08-17T12:54:30.735726Z",
     "shell.execute_reply": "2026-08-17T12:54:30.734601Z"
    }
   },
   "outputs": [],
   "source": [
    "from sklearn.datasets import make_moons\n",
    "\n",
    "X_raw, y_raw = make_moons(n_samples=300, noise=0.20, random_state=SEED)\n",
    "X = (X_raw - X_raw.mean(axis=0)) / X_raw.std(axis=0)   # standardize\n",
    "y = y_raw.reshape(-1, 1).astype(float)                 # shape (300, 1)\n",
    "\n",
    "plt.figure(figsize=(6, 5))\n",
    "plt.scatter(X[:, 0], X[:, 1], c=y.ravel(), cmap=\"RdBu\", edgecolor=\"k\", s=25)\n",
    "plt.xlabel(\"feature 1\")\n",
    "plt.ylabel(\"feature 2\")\n",
    "plt.title(\"two interlocking teams — no straight line separates them\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a2282054",
   "metadata": {},
   "source": [
    "## 3 · The squashing step: sigmoid\n",
    "\n",
    "Our bouncer needs to turn a \"sum of reasons\" (any number, positive or negative, big or small) into a clean answer between 0 and 1 — a **confidence**. The **sigmoid** function does exactly that: huge positive → near 1, huge negative → near 0, and zero → exactly ½.\n",
    "\n",
    "`sigmoid(z) = 1 / (1 + e^(−z))`\n",
    "\n",
    "### 🧪 Exercise 1 — Implement the sigmoid\n",
    "\n",
    "- Fill in `sigmoid(z)` using `np.exp`.\n",
    "- It should work on a whole array at once (NumPy handles that for free).\n",
    "\n",
    "Sanity check: `sigmoid(0)` must equal `0.5`, and the plot should be a smooth S-curve from 0 to 1."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "dbfc5e2b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:30.737812Z",
     "iopub.status.busy": "2026-08-17T12:54:30.737534Z",
     "iopub.status.idle": "2026-08-17T12:54:30.749129Z",
     "shell.execute_reply": "2026-08-17T12:54:30.748147Z"
    }
   },
   "outputs": [],
   "source": [
    "def sigmoid(z):\n",
    "    \"\"\"Squash any number into the range (0, 1).\"\"\"\n",
    "    # ==================== YOUR CODE HERE ====================\n",
    "    ### HINT: the formula is 1 / (1 + np.exp(-z))\n",
    "    ...\n",
    "\n",
    "\n",
    "zs = np.linspace(-10, 10, 200)\n",
    "plt.figure(figsize=(6, 3.5))\n",
    "plt.plot(zs, sigmoid(zs))\n",
    "plt.axhline(0.5, color=\"gray\", ls=\"--\", lw=1)\n",
    "plt.xlabel(\"z  (sum of weighted inputs)\")\n",
    "plt.ylabel(\"sigmoid(z)  =  confidence\")\n",
    "plt.title(\"sigmoid: any number in → a 0-to-1 confidence out\")\n",
    "plt.show()\n",
    "\n",
    "assert abs(sigmoid(0) - 0.5) < 1e-9, \"sigmoid(0) should be exactly 0.5\"\n",
    "print(\"sigmoid(0) =\", sigmoid(0))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7704e13a",
   "metadata": {},
   "source": [
    "## 4 · One neuron tries… and fails\n",
    "\n",
    "Let's see how far a *single* neuron gets on the moons. One neuron can only draw a single straight cut across the plane — so it's doomed here, but the failure is the whole point. The helper below trains one neuron for us (that's just logistic regression, in pure NumPy) and draws its decision boundary. We'll reuse this `plot_boundary` picture all day long."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "42693b29",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:30.750741Z",
     "iopub.status.busy": "2026-08-17T12:54:30.750574Z",
     "iopub.status.idle": "2026-08-17T12:54:30.802693Z",
     "shell.execute_reply": "2026-08-17T12:54:30.801540Z"
    }
   },
   "outputs": [],
   "source": [
    "def plot_boundary(predict_fn, X, y, ax=None, title=\"\"):\n",
    "    \"\"\"Shade the plane by the model's prediction, then scatter the real points.\"\"\"\n",
    "    if ax is None:\n",
    "        _, ax = plt.subplots(figsize=(5, 4))\n",
    "    pad = 0.5\n",
    "    xx, yy = np.meshgrid(\n",
    "        np.linspace(X[:, 0].min() - pad, X[:, 0].max() + pad, 200),\n",
    "        np.linspace(X[:, 1].min() - pad, X[:, 1].max() + pad, 200),\n",
    "    )\n",
    "    grid = np.c_[xx.ravel(), yy.ravel()]\n",
    "    zz = predict_fn(grid).reshape(xx.shape)\n",
    "    ax.contourf(xx, yy, zz, levels=20, cmap=\"RdBu\", alpha=0.7)\n",
    "    ax.scatter(X[:, 0], X[:, 1], c=y.ravel(), cmap=\"RdBu\", edgecolor=\"k\", s=18)\n",
    "    ax.set_xlabel(\"feature 1\")\n",
    "    ax.set_ylabel(\"feature 2\")\n",
    "    ax.set_title(title)\n",
    "\n",
    "\n",
    "# Train a single neuron with a tiny gradient loop (pure NumPy).\n",
    "rng = np.random.default_rng(SEED)\n",
    "w = 0.1 * rng.standard_normal((2, 1))\n",
    "b = 0.0\n",
    "for _ in range(1500):\n",
    "    p = 1 / (1 + np.exp(-(X @ w + b)))\n",
    "    error = p - y                       # (300, 1)\n",
    "    w -= 1.0 * (X.T @ error) / len(X)\n",
    "    b -= 1.0 * float(error.mean())\n",
    "\n",
    "plot_boundary(lambda g: 1 / (1 + np.exp(-(g @ w + b))), X, y,\n",
    "              title=\"one neuron = one straight cut (it can't win)\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e7a6f1f7",
   "metadata": {},
   "source": [
    "See the problem? One straight line, two tangled moons — it always misclassifies a chunk. A single neuron draws a single cut. To *bend* the boundary we need **many neurons working together.**"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "351404f4",
   "metadata": {},
   "source": [
    "## 5 · ⭐ The big secret: a neural network is matrix multiplication\n",
    "\n",
    "Here's the idea this entire course eventually rests on.\n",
    "\n",
    "Our hidden layer has 16 neurons, and each one looks at all 300 data points. That sounds like a nested loop — 16 × 300 weighted sums. But line the weights up in a grid and it collapses into **a single matrix multiply**:\n",
    "\n",
    "`(300 × 2) data  @  (2 × 16) weights  →  (300 × 16) hidden values`\n",
    "\n",
    "One `@`. That's the whole layer. Stack layers and a neural network is, underneath, **just a chain of matrix multiplications** with a squash in between. Training a network means doing *zillions* of these multiplies.\n",
    "\n",
    "Which raises the question an entire field — high-performance computing — is built on: **how fast can you multiply matrices?** Let's feel the difference right now.\n",
    "\n",
    "### 🧪 Exercise 2 — Matrix multiply, by hand vs NumPy\n",
    "\n",
    "- Fill in `matmul_by_hand(A, B)` with the classic **triple loop** (rows × columns × the shared dimension).\n",
    "- We check it against NumPy's `@`, then time both on 200×200 matrices.\n",
    "\n",
    "Expect the *exact same answer* — and NumPy winning by **hundreds of times**."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "50ab7781",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:30.804560Z",
     "iopub.status.busy": "2026-08-17T12:54:30.804389Z",
     "iopub.status.idle": "2026-08-17T12:54:32.835132Z",
     "shell.execute_reply": "2026-08-17T12:54:32.833609Z"
    }
   },
   "outputs": [],
   "source": [
    "import time\n",
    "\n",
    "\n",
    "def matmul_by_hand(A, B):\n",
    "    \"\"\"Multiply A (n×m) by B (m×p) with explicit loops — the definition of matmul.\"\"\"\n",
    "    n, m = A.shape\n",
    "    m2, p = B.shape\n",
    "    assert m == m2, \"inner dimensions must match\"\n",
    "    C = np.zeros((n, p))\n",
    "    # ==================== YOUR CODE HERE ====================\n",
    "    ### HINT: three nested loops — i over rows of A, j over cols of B, k over the shared dim\n",
    "    ...\n",
    "    return C\n",
    "\n",
    "\n",
    "A = np.random.default_rng(0).standard_normal((200, 200))\n",
    "B = np.random.default_rng(1).standard_normal((200, 200))\n",
    "\n",
    "t0 = time.perf_counter(); C_hand = matmul_by_hand(A, B); t_hand = time.perf_counter() - t0\n",
    "t0 = time.perf_counter(); C_np = A @ B;                   t_np = time.perf_counter() - t0\n",
    "\n",
    "assert np.allclose(C_hand, C_np), \"your matmul doesn't match NumPy — check the loops!\"\n",
    "print(f\"by hand : {t_hand:8.3f} s\")\n",
    "print(f\"NumPy @ : {t_np:8.5f} s\")\n",
    "print(f\"NumPy is about {t_hand / t_np:.0f}x faster — same math, same answer.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "87d6d5c6",
   "metadata": {},
   "source": [
    "**Write that number down.** That gap — the same multiplication, hundreds of times faster — is not a NumPy trick you can forget. It's the reason GPUs exist, the reason training big models is even possible, and the entire subject of **high-performance computing**. Same math. Wildly different speed. Everything a network does, it does by multiplying matrices — so making *that* fast makes *everything* fast."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e1433c4e",
   "metadata": {},
   "source": [
    "## 6 · The forward pass: information flows through\n",
    "\n",
    "Now we assemble the network. Two layers, each a matrix multiply followed by a sigmoid squash:\n",
    "\n",
    "1. hidden values: `H = sigmoid(X @ W1 + b1)`   → shape (300, 16)\n",
    "2. output:        `ŷ = sigmoid(H @ W2 + b2)`   → shape (300, 1)\n",
    "\n",
    "That's the **forward pass** — inputs in, prediction out.\n",
    "\n",
    "### 🧪 Exercise 3 — Write the forward pass\n",
    "\n",
    "- Fill in `forward` so it chains the two layers exactly as above.\n",
    "- Then we initialize small random weights and run it once. Untrained, it should output a (300, 1) array of mush near 0.5 — it hasn't learned anything yet."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "812e4962",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:32.837628Z",
     "iopub.status.busy": "2026-08-17T12:54:32.837439Z",
     "iopub.status.idle": "2026-08-17T12:54:32.844089Z",
     "shell.execute_reply": "2026-08-17T12:54:32.842767Z"
    }
   },
   "outputs": [],
   "source": [
    "def forward(X, W1, b1, W2, b2):\n",
    "    \"\"\"Run inputs through the 2-layer network. Returns predictions in (0, 1).\"\"\"\n",
    "    # ==================== YOUR CODE HERE ====================\n",
    "    ### HINT: layer 1 -> H = sigmoid(X @ W1 + b1);  layer 2 -> sigmoid(H @ W2 + b2)\n",
    "    ...\n",
    "\n",
    "\n",
    "def init_weights(hidden=HIDDEN, seed=SEED):\n",
    "    \"\"\"Small random starting weights — the knobs we'll tune. Returns [W1, b1, W2, b2].\"\"\"\n",
    "    r = np.random.default_rng(seed)\n",
    "    W1 = 0.5 * r.standard_normal((2, hidden))\n",
    "    b1 = np.zeros((1, hidden))\n",
    "    W2 = 0.5 * r.standard_normal((hidden, 1))\n",
    "    b2 = np.zeros((1, 1))\n",
    "    return [W1, b1, W2, b2]\n",
    "\n",
    "\n",
    "preds = forward(X, *init_weights())\n",
    "print(\"prediction shape:\", preds.shape)\n",
    "print(\"a few untrained predictions:\", np.round(preds[:4, 0], 3))\n",
    "assert preds.shape == (300, 1), \"forward should return one prediction per point\""
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0207129e",
   "metadata": {},
   "source": [
    "## 7 · Loss: how wrong are we?\n",
    "\n",
    "To improve, the network needs a single number saying how bad its guesses are. We'll use **mean squared error**: the average of (prediction − truth)². Zero means perfect; bigger means worse.\n",
    "\n",
    "### 🧪 Exercise 4 — Implement the loss\n",
    "\n",
    "- Fill in `mse(y_pred, y_true)` = the mean of the squared differences.\n",
    "- Print the loss of the untrained network. Guessing ~0.5 everywhere gives a loss near 0.25."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4443fb5e",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:32.845968Z",
     "iopub.status.busy": "2026-08-17T12:54:32.845806Z",
     "iopub.status.idle": "2026-08-17T12:54:32.850427Z",
     "shell.execute_reply": "2026-08-17T12:54:32.849193Z"
    }
   },
   "outputs": [],
   "source": [
    "def mse(y_pred, y_true):\n",
    "    \"\"\"Mean squared error — the average squared gap between guess and truth.\"\"\"\n",
    "    # ==================== YOUR CODE HERE ====================\n",
    "    ### HINT: np.mean((y_pred - y_true) ** 2)\n",
    "    ...\n",
    "\n",
    "\n",
    "start_loss = mse(forward(X, *init_weights()), y)\n",
    "print(f\"untrained loss: {start_loss:.4f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e447d175",
   "metadata": {},
   "source": [
    "## 8 · Learning without calculus: the nudge method\n",
    "\n",
    "Here's the beautiful part. The network has **65 knobs** (all the weights and biases). We want to know, for each one: *should I turn it up or down to lower the loss?*\n",
    "\n",
    "The simplest possible answer: **try it.** Nudge a knob up a hair, check the loss. Nudge it down a hair, check again. Whichever direction lowers the loss, step that way a little. Do it for every knob, over and over. That slope-from-nudging is exactly what a **gradient** is.\n",
    "\n",
    "The helper below computes that nudge for every weight. You'll write the one line that actually *takes the step*."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cbfbe814",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:32.852365Z",
     "iopub.status.busy": "2026-08-17T12:54:32.852211Z",
     "iopub.status.idle": "2026-08-17T12:54:32.856587Z",
     "shell.execute_reply": "2026-08-17T12:54:32.855446Z"
    }
   },
   "outputs": [],
   "source": [
    "def numerical_gradient(loss_fn, params, eps=1e-4):\n",
    "    \"\"\"The nudge method: estimate the gradient of loss_fn for every weight.\n",
    "\n",
    "    For each weight w we measure loss(w + eps) and loss(w - eps); their\n",
    "    difference over 2*eps is the slope (gradient) of the loss along that weight.\n",
    "    Two forward passes per weight — remember that number for the Math Corner.\n",
    "    \"\"\"\n",
    "    grads = []\n",
    "    for p in params:\n",
    "        g = np.zeros_like(p)\n",
    "        it = np.nditer(p, flags=[\"multi_index\"])\n",
    "        while not it.finished:\n",
    "            idx = it.multi_index\n",
    "            original = p[idx]\n",
    "            p[idx] = original + eps          # nudge up\n",
    "            loss_up = loss_fn()\n",
    "            p[idx] = original - eps          # nudge down\n",
    "            loss_down = loss_fn()\n",
    "            p[idx] = original                # put it back exactly\n",
    "            g[idx] = (loss_up - loss_down) / (2 * eps)\n",
    "            it.iternext()\n",
    "        grads.append(g)\n",
    "    return grads"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0d4f35b5",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 5 — Take the step (this is learning)\n",
    "\n",
    "Everything below is given except the heart of it. Once per epoch we compute the nudge for every weight; you write the update that moves each weight **downhill**:\n",
    "\n",
    "- For each weight array `p` and its gradient `g`, subtract `lr * g` from `p`, **in place** (`p -= …`).\n",
    "\n",
    "Run it and watch the loss fall epoch by epoch as the network figures out the moons.\n",
    "\n",
    "*(Cost check: 65 weights × 2 forward passes × 400 epochs ≈ 52,000 tiny forward passes on a 300-point network — a handful of seconds.)*"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "98082aec",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:32.858398Z",
     "iopub.status.busy": "2026-08-17T12:54:32.858248Z",
     "iopub.status.idle": "2026-08-17T12:54:34.847260Z",
     "shell.execute_reply": "2026-08-17T12:54:34.846010Z"
    }
   },
   "outputs": [],
   "source": [
    "def train_network(hidden=HIDDEN, epochs=EPOCHS, lr=LEARNING_RATE, record_every=50):\n",
    "    \"\"\"Train with the nudge method. Returns (params, history).\"\"\"\n",
    "    params = init_weights(hidden)\n",
    "    loss_fn = lambda: mse(forward(X, *params), y)\n",
    "\n",
    "    history = {\"epoch\": [], \"loss\": [], \"snapshots\": []}\n",
    "    for epoch in range(epochs):\n",
    "        grads = numerical_gradient(loss_fn, params)\n",
    "        # ↓ the one line that makes learning happen: step every weight downhill\n",
    "        # ==================== YOUR CODE HERE ====================\n",
    "        ### HINT: in place, so use  p -= lr * g   (not  p = p - lr*g)\n",
    "        ...\n",
    "        if epoch % record_every == 0 or epoch == epochs - 1:\n",
    "            L = loss_fn()\n",
    "            history[\"epoch\"].append(epoch)\n",
    "            history[\"loss\"].append(L)\n",
    "            history[\"snapshots\"].append([p.copy() for p in params])\n",
    "            print(f\"epoch {epoch:4d}   loss {L:.4f}\")\n",
    "    return params, history\n",
    "\n",
    "\n",
    "trained_params, history = train_network()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2e6efe22",
   "metadata": {},
   "source": [
    "## 9 · Watch it learn\n",
    "\n",
    "We saved a snapshot of the network every 50 epochs. Let's replay them: watch the boundary start as a vague blob and bend itself around the two moons."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "af15ad2b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:34.849219Z",
     "iopub.status.busy": "2026-08-17T12:54:34.849054Z",
     "iopub.status.idle": "2026-08-17T12:54:35.397414Z",
     "shell.execute_reply": "2026-08-17T12:54:35.396249Z"
    }
   },
   "outputs": [],
   "source": [
    "snaps = history[\"snapshots\"]\n",
    "eps_rec = history[\"epoch\"]\n",
    "n = len(snaps)\n",
    "cols = 3\n",
    "rows = int(np.ceil(n / cols))\n",
    "fig, axes = plt.subplots(rows, cols, figsize=(11, 3.4 * rows))\n",
    "axes = np.atleast_1d(axes).ravel()\n",
    "for ax, ep, snap in zip(axes, eps_rec, snaps):\n",
    "    plot_boundary(lambda g, s=snap: forward(g, *s), X, y, ax=ax, title=f\"epoch {ep}\")\n",
    "for ax in axes[n:]:\n",
    "    ax.axis(\"off\")\n",
    "fig.suptitle(\"the decision boundary learning to bend around the moons\")\n",
    "fig.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3dc6b558",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:35.399179Z",
     "iopub.status.busy": "2026-08-17T12:54:35.399036Z",
     "iopub.status.idle": "2026-08-17T12:54:35.408749Z",
     "shell.execute_reply": "2026-08-17T12:54:35.407833Z"
    }
   },
   "outputs": [],
   "source": [
    "plt.figure(figsize=(6, 4))\n",
    "plt.plot(history[\"epoch\"], history[\"loss\"], \"o-\")\n",
    "plt.xlabel(\"epoch\")\n",
    "plt.ylabel(\"MSE loss\")\n",
    "plt.title(\"the loss falling as the network learns\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c82b9e6c",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 6 — Score it\n",
    "\n",
    "Loss is abstract; accuracy is human. Turn the network's confidences into 0/1 decisions and measure the fraction it gets right.\n",
    "\n",
    "- In `accuracy`, threshold the predictions at 0.5, then compare to the truth and take the mean.\n",
    "- Our hand-built net should land around **88%** here — a giant leap from the single neuron's straight-line failure. (The ceiling on this noisy data is ~97%; tomorrow's power tools close most of that gap. Our simple \"nudge every weight\" trainer is honest but slow.)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "20924365",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:35.410561Z",
     "iopub.status.busy": "2026-08-17T12:54:35.410411Z",
     "iopub.status.idle": "2026-08-17T12:54:35.414898Z",
     "shell.execute_reply": "2026-08-17T12:54:35.413979Z"
    }
   },
   "outputs": [],
   "source": [
    "def accuracy(y_pred, y_true):\n",
    "    \"\"\"Fraction correct, calling anything above 0.5 a '1'.\"\"\"\n",
    "    # ==================== YOUR CODE HERE ====================\n",
    "    ### HINT: (y_pred > 0.5) turns confidences into 0/1; compare with == and take the mean\n",
    "    ...\n",
    "\n",
    "\n",
    "final_acc = accuracy(forward(X, *trained_params), y)\n",
    "print(f\"final accuracy: {final_acc:.1%}\")\n",
    "if not SMOKE:\n",
    "    assert final_acc > 0.85, \"accuracy is low — check forward(), the loss, or the update step\""
   ]
  },
  {
   "cell_type": "markdown",
   "id": "693b10ff",
   "metadata": {},
   "source": [
    "## 10 · Experiments: how many neurons do you need?\n",
    "\n",
    "The hidden layer's size controls how *wiggly* a boundary the network can draw. Too few neurons and it can't bend enough; too many and it can over-wiggle (memorizing noise instead of the shape). Let's see both extremes.\n",
    "\n",
    "### 🧪 Exercise 7 — Retrain with 2 and 64 hidden neurons\n",
    "\n",
    "- Inside the loop, train a network with `hidden=h` and plot its boundary.\n",
    "- Then compare the shapes you get."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "40533361",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:35.416388Z",
     "iopub.status.busy": "2026-08-17T12:54:35.416241Z",
     "iopub.status.idle": "2026-08-17T12:54:51.974324Z",
     "shell.execute_reply": "2026-08-17T12:54:51.973386Z"
    }
   },
   "outputs": [],
   "source": [
    "fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))\n",
    "for ax, h in zip(axes, [2, 64]):\n",
    "    # ==================== YOUR CODE HERE ====================\n",
    "    ### HINT: params, _ = train_network(hidden=h);  then plot_boundary(lambda g,s=params: forward(g,*s), X, y, ax=ax, ...)\n",
    "    ...\n",
    "fig.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cab9fe8b",
   "metadata": {},
   "source": [
    "**Describe what you see** *(double-click to edit):*\n",
    "- With **2 neurons**, the boundary is ...\n",
    "- With **64 neurons**, the boundary is ...\n",
    "\n",
    "Fewer neurons → simpler boundary. More neurons → more flexible (and, past a point, prone to memorizing noise). Choosing the right size is part of the art."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0fce4991",
   "metadata": {},
   "source": [
    "And one knob you should *not* crank: the learning rate. A sensible step (our 0.5) walks steadily downhill. But make the step wildly too big and the weights get slammed into saturation — the network collapses to guessing one class and the loss flatlines. Watch it break."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9e8792ba",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:51.983070Z",
     "iopub.status.busy": "2026-08-17T12:54:51.982933Z",
     "iopub.status.idle": "2026-08-17T12:54:55.680728Z",
     "shell.execute_reply": "2026-08-17T12:54:55.679488Z"
    }
   },
   "outputs": [],
   "source": [
    "import warnings\n",
    "\n",
    "with warnings.catch_warnings():\n",
    "    warnings.simplefilter(\"ignore\")            # giant steps overflow the sigmoids — that's the point\n",
    "    chaos_params, chaos = train_network(lr=100.0)\n",
    "\n",
    "broken_acc = accuracy(forward(X, *chaos_params), y)\n",
    "print(f\"accuracy with a giant learning rate: {broken_acc:.1%}  (≈ a coin flip — it never learned)\")\n",
    "\n",
    "plt.figure(figsize=(6, 4))\n",
    "plt.plot(chaos[\"epoch\"], chaos[\"loss\"], \"o-\", color=\"crimson\")\n",
    "plt.xlabel(\"epoch\")\n",
    "plt.ylabel(\"MSE loss\")\n",
    "plt.title(\"learning rate 100 — too big: the loss flatlines, the network never learns\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "952b51a3",
   "metadata": {},
   "source": [
    "## 11 · The loss landscape: what \"downhill\" means\n",
    "\n",
    "Every setting of the weights produces some loss. Picture that as a landscape — the weights are your map coordinates, the loss is the altitude. **Learning is just walking downhill** on this surface, and the gradient is the compass pointing down.\n",
    "\n",
    "We can't draw a 65-dimensional landscape, but we can take a **2-weight slice**: freeze every weight at its trained value except two, sweep those two across a grid, and plot the loss as height. The red star marks where training actually landed."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d315635f",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T12:54:55.682388Z",
     "iopub.status.busy": "2026-08-17T12:54:55.682237Z",
     "iopub.status.idle": "2026-08-17T12:54:55.734705Z",
     "shell.execute_reply": "2026-08-17T12:54:55.733386Z"
    }
   },
   "outputs": [],
   "source": [
    "i_a, i_b = 0, 2                       # vary W1[0,0] and W2[0,0]\n",
    "a_star = trained_params[i_a][0, 0]\n",
    "b_star = trained_params[i_b][0, 0]\n",
    "span = 3.0\n",
    "a_vals = np.linspace(a_star - span, a_star + span, 25)\n",
    "b_vals = np.linspace(b_star - span, b_star + span, 25)\n",
    "\n",
    "Z = np.zeros((25, 25))\n",
    "for r, bv in enumerate(b_vals):\n",
    "    for c, av in enumerate(a_vals):\n",
    "        trial = [p.copy() for p in trained_params]\n",
    "        trial[i_a][0, 0] = av\n",
    "        trial[i_b][0, 0] = bv\n",
    "        Z[r, c] = mse(forward(X, *trial), y)\n",
    "\n",
    "AA, BB = np.meshgrid(a_vals, b_vals)\n",
    "plt.figure(figsize=(7, 5))\n",
    "cs = plt.contourf(AA, BB, Z, levels=30, cmap=\"viridis\")\n",
    "plt.colorbar(cs, label=\"loss\")\n",
    "plt.scatter([a_star], [b_star], color=\"red\", marker=\"*\", s=280,\n",
    "            edgecolor=\"white\", linewidth=1.2, label=\"where training landed\", zorder=5)\n",
    "plt.xlabel(\"weight W1[0, 0]\")\n",
    "plt.ylabel(\"weight W2[0, 0]\")\n",
    "plt.title(\"a 2-weight slice of the loss landscape\")\n",
    "plt.legend()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fd7676ec",
   "metadata": {},
   "source": [
    "## 12 · Math Corner (optional)\n",
    "\n",
    "<details><summary>🔢 <b>Math Corner</b> — the nudge method vs. real backprop (optional)</summary>\n",
    "\n",
    "A **derivative** is just a slope: nudge the input a tiny bit, see how much the output moves, divide by the nudge. That's literally what `numerical_gradient` does — one pair of nudges per weight.\n",
    "\n",
    "It works, but it's *slow*. Our tiny network has **2·16 + 16 + 16 + 1 = 65 weights**. The nudge method needs two forward passes **per weight** to estimate its slope — that's **130 forward passes for a single training step.** A network with a million weights would need two million forward passes per step. Hopeless.\n",
    "\n",
    "Real networks use **backpropagation**: with the chain rule from calculus, it computes the *exact* slope for **every** weight in just **two passes total** (one forward, one backward) — no matter how many weights there are. 130 versus 2. That efficiency is the only reason training large networks is even possible.\n",
    "\n",
    "Tomorrow, PyTorch's **autograd** runs this backward pass for you automatically — and on a GPU, where (remember Section 5) those matrix multiplies fly.\n",
    "\n",
    "</details>"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "36b8c3c9",
   "metadata": {},
   "source": [
    "## 🏁 Checkpoint\n",
    "\n",
    "Today, with nothing but NumPy, you:\n",
    "\n",
    "- built a **neuron** and saw why one straight cut isn't enough\n",
    "- learned the course's biggest secret — **a network is chained matrix multiplications** (and clocked NumPy beating a hand-written loop by hundreds of times)\n",
    "- wrote the **forward pass** and a **loss**, then trained the net with the **nudge method**\n",
    "- watched the decision **boundary bend** around the moons and read the **loss landscape** it descended\n",
    "\n",
    "You forged the engine by hand. **Tomorrow the Lab hands you power tools** — PyTorch, automatic gradients, and a GPU — and you'll teach a network to read handwritten digits, the very same ones you mapped with t-SNE yesterday.\n",
    "\n",
    "**Next: Day 5 — The Digit Reader ⚡**"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "81cdd0d3",
   "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",
   "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
}
