diff --git a/00_micrograd.ipynb b/00_micrograd.ipynb
index 5db4d43..4684e7c 100644
--- a/00_micrograd.ipynb
+++ b/00_micrograd.ipynb
@@ -58,6 +58,65 @@
"direction that makes the output less wrong."
]
},
+ {
+ "cell_type": "markdown",
+ "id": "334f0f03",
+ "metadata": {},
+ "source": [
+ "### Mental picture — a factory line with tags\n",
+ "\n",
+ "Imagine a **cookie factory**. Each station does one small job and puts a **tag** on the box\n",
+ "saying *who gave me ingredients* and *what I did* (mixed, baked, frosted).\n",
+ "\n",
+ "| Step | Real life | In code |\n",
+ "|------|-----------|---------|\n",
+ "| Forward pass | Ingredients move down the line; you get a final cookie score | `d = a * b + c` computes numbers left to right |\n",
+ "| History | Every box keeps tags — no separate diary | `_prev` (parents) and `_op` (operation) on each `Value` |\n",
+ "| Gradient | *If I change this one ingredient a tiny bit, how much does the final score change?* | `.grad` on each `Value` |\n",
+ "| Backprop | Walk backward along the tags from the final score to every ingredient | `loss.backward()` |\n",
+ "\n",
+ "A normal float is like a score on a napkin: **4**. You cannot tell where it came from.\n",
+ "A **`Value`** is the same score **on a tagged box** — you can always walk back.\n",
+ "\n",
+ "**Blame traveling backward (the chain rule)**\n",
+ "\n",
+ "The final taste score is off. At each station you ask: *if your output had been 1 point\n",
+ "sweeter, how much would the final score have moved?*\n",
+ "\n",
+ "- **`+` (mix two bowls)** — nudge either bowl; the mix moves the same amount. Blame passes\n",
+ " through to both inputs equally.\n",
+ "- **`*` (recipe ratio, e.g. 2 cups × 3 batches)** — change flour a little → effect depends\n",
+ " on batch count; change batches → effect depends on flour. Each side's blame depends on\n",
+ " the **other** side's amount.\n",
+ "\n",
+ "**Backprop** starts at the output (\"downstream moved by 1\"), walks **backward** along the\n",
+ "tags, and at each station multiplies by that station's local rule. Small effect here ×\n",
+ "small effect there = total effect on the end.\n",
+ "\n",
+ "**Why gradients add (`+=`)**\n",
+ "\n",
+ "One bag of sugar might go into **both** dough and icing. Both paths affect the final taste,\n",
+ "so sugar's total blame is the **sum** of both paths — same when one `Value` is used twice\n",
+ "in an expression.\n",
+ "\n",
+ "**Training the MLP (section 6)**\n",
+ "\n",
+ "Same factory, but the knobs are **weights and biases** (how much each input counts):\n",
+ "\n",
+ "1. **Forward** — run points through the line; get predictions.\n",
+ "2. **Loss** — how wrong were we? (distance from the target).\n",
+ "3. **Backward** — `loss.backward()` sends blame to every knob.\n",
+ "4. **Update** — turn each knob a tiny step **against** its gradient (downhill = less wrong).\n",
+ "\n",
+ "The **two moons** dataset is two swirls of red and blue beads. The network learns a curved\n",
+ "boundary — not by memorizing dots, but by turning knobs until wrong guesses hurt (high loss)\n",
+ "and right ones don't.\n",
+ "\n",
+ "> **One line to keep:** Forward = tagged boxes down the line. Backward = follow tags from\n",
+ "> the final score back to each ingredient and assign fair blame. PyTorch does the same thing\n",
+ "> at scale — millions of tagged boxes, fast hardware."
+ ]
+ },
{
"cell_type": "markdown",
"id": "84448625",
@@ -74,22 +133,30 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 1,
"id": "e31a52ef",
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "ready\n"
+ ]
+ }
+ ],
"source": [
- "import math\n",
- "import random\n",
- "import numpy as np\n",
+ "import math # exp, tanh used inside Value activations\n",
+ "import random # random weight init for Neuron\n",
+ "import numpy as np # dataset + optional plots only (not the autograd engine)\n",
"\n",
- "random.seed(1337)\n",
- "np.random.seed(1337)\n",
+ "random.seed(1337) # same random weights every run\n",
+ "np.random.seed(1337) # same noisy moon dots every run\n",
"\n",
"# Plots are optional: the notebook runs fine without matplotlib (it just skips pictures).\n",
"try:\n",
" import matplotlib.pyplot as plt\n",
- " HAS_PLT = True\n",
+ " HAS_PLT = True # flag so later cells can skip plotting gracefully\n",
"except Exception:\n",
" HAS_PLT = False\n",
" print(\"matplotlib not installed -> plots will be skipped (everything else still works)\")\n",
@@ -97,6 +164,205 @@
"print(\"ready\")"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "ace0626a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from IPython.display import HTML, display\n",
+ "import json\n",
+ "\n",
+ "_GRAPH_UID = 0 # unique id per render so multiple cells don't clash\n",
+ "\n",
+ "\n",
+ "def show_graph(root, title=\"Computation graph — hover a node\"):\n",
+ " \"\"\"Draw an interactive SVG graph for a Value expression.\n",
+ "\n",
+ " Hover (or click) any node to see its value, operation, and parents.\n",
+ " After `.backward()`, gradients appear on each node automatically.\n",
+ " \"\"\"\n",
+ " global _GRAPH_UID\n",
+ " _GRAPH_UID += 1\n",
+ " gid = f\"mg{_GRAPH_UID}\"\n",
+ "\n",
+ " nodes, seen = [], set()\n",
+ "\n",
+ " def build_topo(v):\n",
+ " if v not in seen:\n",
+ " seen.add(v)\n",
+ " for ch in v._prev:\n",
+ " build_topo(ch)\n",
+ " nodes.append(v)\n",
+ "\n",
+ " build_topo(root)\n",
+ " id_map = {v: i for i, v in enumerate(nodes)}\n",
+ "\n",
+ " depth = {}\n",
+ "\n",
+ " def mark_depth(v, d=0):\n",
+ " depth[v] = max(depth.get(v, 0), d)\n",
+ " for ch in v._prev:\n",
+ " mark_depth(ch, d + 1)\n",
+ "\n",
+ " mark_depth(root, 0)\n",
+ " max_depth = max(depth.values()) if depth else 0\n",
+ " layer_of = {v: max_depth - depth[v] for v in nodes}\n",
+ "\n",
+ " from collections import defaultdict\n",
+ "\n",
+ " layers = defaultdict(list)\n",
+ " for v in nodes:\n",
+ " layers[layer_of[v]].append(v)\n",
+ "\n",
+ " NODE_W, NODE_H, GAP_X, GAP_Y = 108, 58, 36, 72\n",
+ " positions = {}\n",
+ " max_w = 0\n",
+ " for ly in sorted(layers.keys()):\n",
+ " row = layers[ly]\n",
+ " row_w = len(row) * NODE_W + max(0, len(row) - 1) * GAP_X\n",
+ " max_w = max(max_w, row_w)\n",
+ " x0 = (max_w - row_w) / 2\n",
+ " for i, v in enumerate(row):\n",
+ " positions[v] = (x0 + i * (NODE_W + GAP_X), ly * (NODE_H + GAP_Y))\n",
+ "\n",
+ " svg_w = int(max_w + 48)\n",
+ " svg_h = int((max(layers.keys()) + 1) * (NODE_H + GAP_Y) + 36)\n",
+ " show_grad = any(getattr(v, \"grad\", 0.0) for v in nodes)\n",
+ "\n",
+ " def node_name(v):\n",
+ " lbl = getattr(v, \"label\", \"\") or \"\"\n",
+ " return lbl if lbl else f\"n{id_map[v]}\"\n",
+ "\n",
+ " def esc(s):\n",
+ " return (\n",
+ " str(s)\n",
+ " .replace(\"&\", \"&\")\n",
+ " .replace(\"<\", \"<\")\n",
+ " .replace(\">\", \">\")\n",
+ " .replace('\"', \""\")\n",
+ " )\n",
+ "\n",
+ " edge_lines = []\n",
+ " for v in nodes:\n",
+ " for ch in v._prev:\n",
+ " x1, y1 = positions[ch]\n",
+ " x2, y2 = positions[v]\n",
+ " cx1, cy1 = x1 + NODE_W / 2, y1 + NODE_H\n",
+ " cx2, cy2 = x2 + NODE_W / 2, y2\n",
+ " eid = f\"{gid}-e{id_map[ch]}-{id_map[v]}\"\n",
+ " edge_lines.append(\n",
+ " f''\n",
+ " )\n",
+ "\n",
+ " node_groups = []\n",
+ " meta_nodes = {}\n",
+ " for v in nodes:\n",
+ " x, y = positions[v]\n",
+ " nid = id_map[v]\n",
+ " node_id = f\"{gid}-n{nid}\"\n",
+ " nm = esc(node_name(v))\n",
+ " op = esc(v._op or \"leaf\")\n",
+ " val = f\"{v.data:.4f}\"\n",
+ " grad = f\"{getattr(v, 'grad', 0.0):.4f}\"\n",
+ " fill = \"#dcfce7\" if not v._prev else \"#dbeafe\"\n",
+ " grad_svg = (\n",
+ " f''\n",
+ " f\"grad = {grad}\"\n",
+ " if show_grad\n",
+ " else \"\"\n",
+ " )\n",
+ " node_groups.append(\n",
+ " f''\n",
+ " f''\n",
+ " f'{nm}'\n",
+ " f''\n",
+ " f\"op: {op} val: {val}\"\n",
+ " f\"{grad_svg}\"\n",
+ " f'{nm}: {val} ({op})'\n",
+ " )\n",
+ " meta_nodes[node_id] = {\n",
+ " \"name\": node_name(v),\n",
+ " \"op\": v._op or \"leaf\",\n",
+ " \"val\": val,\n",
+ " \"grad\": grad,\n",
+ " \"parents\": \", \".join(node_name(p) for p in v._prev) or \"—\",\n",
+ " }\n",
+ "\n",
+ " meta_json = json.dumps({\"gid\": gid, \"show_grad\": show_grad, \"nodes\": meta_nodes})\n",
+ "\n",
+ " html = f\"\"\"\n",
+ "
\n",
+ "
{esc(title)}
\n",
+ " \n",
+ "
\n",
+ " Hover a node to inspect it. Arrows flow from inputs (bottom) to output (top).\n",
+ "
"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
"source": [
"def make_moons_fallback(n_samples=100, noise=0.1):\n",
- " n = n_samples // 2\n",
- " t = np.linspace(0, np.pi, n)\n",
- " # outer moon\n",
+ " n = n_samples // 2 # half the points per moon\n",
+ " t = np.linspace(0, np.pi, n) # angle along each crescent\n",
+ " # outer moon — upper arc (cos, sin)\n",
" x1 = np.stack([np.cos(t), np.sin(t)], axis=1)\n",
- " # inner moon, shifted\n",
+ " # inner moon — lower arc, shifted down so the two curves interleave\n",
" x2 = np.stack([1 - np.cos(t), 1 - np.sin(t) - 0.5], axis=1)\n",
- " X = np.concatenate([x1, x2], axis=0)\n",
- " X += noise * np.random.randn(*X.shape)\n",
- " y = np.array([0] * n + [1] * n)\n",
+ " X = np.concatenate([x1, x2], axis=0) # stack into one (100, 2) array\n",
+ " X += noise * np.random.randn(*X.shape) # jitter dots so it's not perfectly clean\n",
+ " y = np.array([0] * n + [1] * n) # label 0 = outer moon, 1 = inner moon\n",
" return X, y\n",
"\n",
"try:\n",
@@ -136,16 +423,16 @@
" X, y = make_moons(n_samples=100, noise=0.1, random_state=1337)\n",
" print(\"using sklearn make_moons\")\n",
"except Exception as e:\n",
- " X, y = make_moons_fallback(100, noise=0.1)\n",
+ " X, y = make_moons_fallback(100, noise=0.1) # same shape, no sklearn needed\n",
" print(\"sklearn not available, using NumPy fallback:\", type(e).__name__)\n",
"\n",
- "print(\"X shape:\", X.shape, \" y shape:\", y.shape)\n",
+ "print(\"X shape:\", X.shape, \" y shape:\", y.shape) # expect (100, 2) and (100,)\n",
"print(\"first 3 points:\", X[:3].tolist())\n",
"print(\"first 3 labels:\", y[:3].tolist())\n",
"\n",
"if HAS_PLT:\n",
" plt.figure(figsize=(5, 4))\n",
- " plt.scatter(X[:, 0], X[:, 1], c=y, s=20, cmap=\"bwr\")\n",
+ " plt.scatter(X[:, 0], X[:, 1], c=y, s=20, cmap=\"bwr\") # red vs blue by class\n",
" plt.title(\"two moons — can a tiny net separate the colors?\")\n",
" plt.show()"
]
@@ -170,32 +457,47 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 3,
"id": "eea9bc18",
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "d = Value(data=4.0)\n",
+ "d was made by op: '+'\n",
+ "d's parents: {Value(data=-6.0), Value(data=10.0)}\n"
+ ]
+ }
+ ],
"source": [
"class Value:\n",
" def __init__(self, data, _children=(), _op=''):\n",
- " self.data = data\n",
- " self._prev = set(_children)\n",
- " self._op = _op\n",
+ " self.data = data # the number itself (forward pass result)\n",
+ " # HISTORY: who made me? _prev = parent Value objects; _op = the operation (+, *, ...)\n",
+ " # Forward pass builds a linked tree/DAG automatically — no separate \"tape\" step.\n",
+ " self._prev = set(_children) # set of input Values that fed into this one\n",
+ " self._op = _op # string tag: '+', '*', or '' for a leaf (input)\n",
"\n",
" def __repr__(self):\n",
- " return f\"Value(data={self.data})\"\n",
+ " return f\"Value(data={self.data})\" # pretty print when you print(a)\n",
"\n",
" def __add__(self, other):\n",
+ " # New number = sum; we pass (self, other) so this node remembers both parents.\n",
" return Value(self.data + other.data, (self, other), '+')\n",
"\n",
" def __mul__(self, other):\n",
+ " # Same idea: result remembers it came from self * other.\n",
" return Value(self.data * other.data, (self, other), '*')\n",
"\n",
"\n",
- "a = Value(2.0)\n",
+ "# --- tiny demo: build d = a*b + c and inspect the graph tags ---\n",
+ "a = Value(2.0) # leaf — no parents, _op is ''\n",
"b = Value(-3.0)\n",
"c = Value(10.0)\n",
- "e = a * b\n",
- "d = e + c\n",
+ "e = a * b # e.data = -6; e._prev = {a, b}; e._op = '*'\n",
+ "d = e + c # d.data = 4; d._prev = {e, c}; d._op = '+'\n",
"print(\"d =\", d)\n",
"print(\"d was made by op:\", repr(d._op))\n",
"print(\"d's parents:\", d._prev)"
@@ -210,22 +512,58 @@
"\n",
"`d = a*b + c` is really a little tree: `d` points back to `e` and `c`; `e` points back to\n",
"`a` and `b`. Let's print it as an indented tree so the structure is visible. (No graphviz\n",
- "dependency — just recursion.)"
+ "dependency — just recursion.) Section **1.3** draws the same wiring as an interactive picture."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "3a436597",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "4.0000 (op=+)\n",
+ " -6.0000 (op=*)\n",
+ " 2.0000 (op=leaf)\n",
+ " -3.0000 (op=leaf)\n",
+ " 10.0000 (op=leaf)\n"
+ ]
+ }
+ ],
+ "source": [
+ "def show(v, indent=0):\n",
+ " # print this node's value and which op created it ('leaf' = raw input)\n",
+ " print(\" \" * indent + f\"{v.data:.4f} (op={v._op or 'leaf'})\")\n",
+ " for child in v._prev: # follow parent links (deeper indent = further back in time)\n",
+ " show(child, indent + 1) # recurse until we hit leaves with no _prev\n",
+ "\n",
+ "show(d) # prints the tree: d (+) -> e (*) -> a, b and c"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f4e9da6b",
+ "metadata": {},
+ "source": [
+ "### 1.3 Interactive graph — see the wiring\n",
+ "\n",
+ "The text tree above is correct but flat. The same expression as a **picture**: green boxes are\n",
+ "raw inputs (leaves), blue boxes are operations. **Hover** a node to highlight its connections;\n",
+ "**click** to pin the detail panel. Arrows run from ingredients (bottom) up to the final score (top)."
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "3a436597",
+ "id": "f149d4da",
"metadata": {},
"outputs": [],
"source": [
- "def show(v, indent=0):\n",
- " print(\" \" * indent + f\"{v.data:.4f} (op={v._op or 'leaf'})\")\n",
- " for child in v._prev:\n",
- " show(child, indent + 1)\n",
- "\n",
- "show(d)"
+ "# Re-use d = a*b + c from section 1.1 (run that cell first if you restarted the kernel)\n",
+ "show_graph(d, title=\"Forward pass: d = a × b + c\")"
]
},
{
@@ -252,12 +590,12 @@
"outputs": [],
"source": [
"def f_out(a_, b_, c_):\n",
- " return (a_ * b_) + c_ # plain floats\n",
+ " return (a_ * b_) + c_ # plain floats — no graph, just the math\n",
"\n",
- "h = 1e-6\n",
- "base = f_out(2.0, -3.0, 10.0)\n",
+ "h = 1e-6 # tiny nudge size (like a small step on a hill)\n",
+ "base = f_out(2.0, -3.0, 10.0) # output before any nudge = 4.0\n",
"\n",
- "# how sensitive is the output to a?\n",
+ "# how sensitive is the output to a? slope ≈ (f(a+h) - f(a)) / h\n",
"da = (f_out(2.0 + h, -3.0, 10.0) - base) / h\n",
"# to b?\n",
"db = (f_out(2.0, -3.0 + h, 10.0) - base) / h\n",
@@ -265,9 +603,9 @@
"dc = (f_out(2.0, -3.0, 10.0 + h) - base) / h\n",
"\n",
"print(f\"output = {base}\")\n",
- "print(f\"d(out)/da = {da:.4f} (equals b = -3)\")\n",
- "print(f\"d(out)/db = {db:.4f} (equals a = 2)\")\n",
- "print(f\"d(out)/dc = {dc:.4f} (equals 1)\")"
+ "print(f\"d(out)/da = {da:.4f} (equals b = -3)\") # derivative of a*b w.r.t. a is b\n",
+ "print(f\"d(out)/db = {db:.4f} (equals a = 2)\") # derivative w.r.t. b is a\n",
+ "print(f\"d(out)/dc = {dc:.4f} (equals 1)\") # derivative of +c w.r.t. c is 1"
]
},
{
@@ -280,17 +618,74 @@
"The nudge-and-measure trick is correct but slow: one re-run per input. Real networks have\n",
"millions of inputs. The **chain rule** gets every sensitivity in *one* backward pass.\n",
"\n",
- "The rule, in words: *if a affects e, and e affects d, then a's effect on d is the product of\n",
- "the two local effects.*\n",
+ "#### The one idea (relay race, not one long jump)\n",
"\n",
- "d(d)/d(a) = d(d)/d(e) * d(e)/d(a)\n",
+ "When one thing changes another thing, which then changes a third, you **multiply the two\n",
+ "\"sensitivity\" steps**.\n",
+ "\n",
+ "- **a** — starting point (e.g. how many projects you take)\n",
+ "- **e** — middle step (hours you work)\n",
+ "- **d** — final result (your pay)\n",
+ "\n",
+ "**d** does not feel **a** directly. It only feels **e**. So ask two smaller questions:\n",
+ "\n",
+ "1. If **a** moves a little, how much does **e** move? → `d(e)/d(a)`\n",
+ "2. If **e** moves a little, how much does **d** move? → `d(d)/d(e)`\n",
+ "\n",
+ "The full effect is those two multiplied:\n",
+ "\n",
+ "`d(d)/d(a) = d(d)/d(e) × d(e)/d(a)`\n",
+ "\n",
+ "> **One line to keep:** If **d** depends on **e**, and **e** depends on **a**, then \"how fast\n",
+ "> **d** responds to **a**\" = \"how fast **d** responds to **e**\" × \"how fast **e** responds to **a**.\"\n",
+ "\n",
+ "#### Units make it click (the middle cancels)\n",
+ "\n",
+ "Like converting units:\n",
+ "\n",
+ "- Pay per hour → dollars/hour\n",
+ "- Hours per project → hours/project\n",
+ "\n",
+ "Multiply: `(dollars/hour) × (hours/project) = dollars/project`\n",
+ "\n",
+ "The **hour** in the middle cancels. You get \"how pay changes when projects change.\" The formula\n",
+ "looks like fraction cancellation — that is the story, not magic algebra.\n",
+ "\n",
+ "#### Tiny wiggle picture\n",
+ "\n",
+ "Bump **a** by a tiny amount Δa.\n",
+ "\n",
+ "- **e** moves about `(d(e)/d(a)) × Δa`\n",
+ "- **d** moves about `(d(d)/d(e)) ×` that amount\n",
+ "\n",
+ "So total change in **d** is `(d(d)/d(e)) × (d(e)/d(a)) × Δa`. The chain rule is just: the\n",
+ "multiplier on Δa is the **product of the two hops**.\n",
+ "\n",
+ "#### Worked example\n",
+ "\n",
+ "Let `a = 2`, `e = a²`, `d = 3e`.\n",
+ "\n",
+ "- `d(e)/d(a) = 2a = 4` (e changes 4 units per unit of a)\n",
+ "- `d(d)/d(e) = 3` (d changes 3 units per unit of e)\n",
+ "\n",
+ "So `d(d)/d(a) = 3 × 4 = 12` — when **a** goes up by 1, **d** goes up by about 12.\n",
+ "\n",
+ "Check directly: `d = 3a²`, so `d(d)/d(a) = 6a = 12` at `a = 2`. Same answer.\n",
+ "\n",
+ "#### Why college notation felt confusing\n",
+ "\n",
+ "Textbooks often write `f(g(x))` and say \"derivative of outside × derivative of inside.\" That\n",
+ "is correct, but it hides the story: **indirect influence through a middle variable**. The relay\n",
+ "race picture is the same thing with names you can follow.\n",
+ "\n",
+ "#### Local rules (what each station does)\n",
"\n",
"Each operation knows its own **local** derivative:\n",
"\n",
"- for `+`: output changes 1-for-1 with each input, so the gradient just **passes through**.\n",
"- for `*`: each input's local derivative is **the other input**.\n",
"\n",
- "Backprop = start with gradient 1.0 at the output, then walk backward multiplying by each\n",
+ "**Backprop** = start with gradient 1.0 at the output, then walk backward multiplying by each\n",
"local derivative."
]
},
@@ -301,6 +696,31 @@
"source": [
"### 4.1 Adding gradients and local backward rules\n",
"\n",
+ "#### What is autograd?\n",
+ "\n",
+ "**Autograd** = **automatic differentiation** — the computer figures out gradients for you\n",
+ "after you write the forward math.\n",
+ "\n",
+ "Real-life picture: like a GPS that logged every turn on your drive. When you ask \"how do I get\n",
+ "back?\", it does not guess — it rewinds those turns and tells you how each step affected the\n",
+ "final destination.\n",
+ "\n",
+ "**Is it a library?** Two meanings:\n",
+ "\n",
+ "| Meaning | What it is | In this notebook |\n",
+ "|---|---|---|\n",
+ "| **The idea** | Any system that records ops, then runs `backward()` to fill `.grad` | Our homemade **micrograd** `Value` class |\n",
+ "| **A PyPI package** | A library literally named [`autograd`](https://github.com/HIPS/autograd) | We do **not** use it — we build from scratch |\n",
+ "\n",
+ "Later, **PyTorch** ships its own autograd (`loss.backward()` in notebooks `01` and `06`). Same\n",
+ "idea, industrial scale.\n",
+ "\n",
+ "When code says \"analytic gradients via autograd,\" it means: run forward through `Value` objects,\n",
+ "call `.backward()` once, read `.grad` — instead of nudging each input by hand with\n",
+ "`numeric_grad`.\n",
+ "\n",
+ "---\n",
+ "\n",
"Now the full `Value`. Each operation also defines a `_backward()` that says how to push the\n",
"output's gradient onto its inputs. Note we **accumulate** (`+=`) gradients, because one value\n",
"can feed into several places.\n",
@@ -308,6 +728,31 @@
"We add `+`, `*`, `**` (power), `tanh`, and `exp` — enough to build a neural net. We also add\n",
"convenience operators (`-`, `/`, right-hand versions) so expressions read naturally.\n",
"\n",
+ "#### `_op` and `_prev` — the tags on every box\n",
+ "\n",
+ "Every `Value` stores two pieces of history in `__init__`:\n",
+ "\n",
+ "- **`_op`** — *what happened at this station?* A stamp on the box: `'+'` (mixed), `'*'` (scaled),\n",
+ " `'tanh'` (squashed), or `''` for a raw leaf input (flour, sugar — nothing done yet).\n",
+ "- **`_prev`** — *who gave me ingredients?* The set of parent `Value`s that fed into this one.\n",
+ " Like \"received from\" arrows on the tag.\n",
+ "\n",
+ "**Cookie-factory picture for `d = a * b + c`:**\n",
+ "\n",
+ "1. `e = a * b` → `e._op = '*'`, `e._prev = {a, b}` — \"made by multiplying; parents are a and b.\"\n",
+ "2. `d = e + c` → `d._op = '+'`, `d._prev = {e, c}` — \"made by adding; parents are e and c.\"\n",
+ "\n",
+ "A normal float is a score on a napkin: **4**. You cannot tell how you got it. A `Value` is the\n",
+ "same score **on a tagged box** — you can always walk back.\n",
+ "\n",
+ "**Why this matters:** forward pass moves ingredients down the line. Backprop starts at the final\n",
+ "score and walks **backward** along `_prev`. At each stop, `_op` tells you which local rule to\n",
+ "apply (add passes blame through; multiply splits blame using the other input). Without these\n",
+ "tags there is no map from the final number back to the knobs you need to turn.\n",
+ "\n",
+ "> **One line:** `_op` = what was done here; `_prev` = who fed into it. Together they are the\n",
+ "> breadcrumb trail from output back to inputs.\n",
+ "\n",
"**-> Training:** `tanh` is the neuron's \"squashing\" activation; its local derivative is\n",
"`1 - tanh(x)^2`."
]
@@ -321,90 +766,99 @@
"source": [
"class Value:\n",
" def __init__(self, data, _children=(), _op='', label=''):\n",
- " self.data = data\n",
- " self.grad = 0.0\n",
- " self._backward = lambda: None\n",
- " self._prev = set(_children)\n",
- " self._op = _op\n",
- " self.label = label\n",
+ " self.data = data # the actual number (forward value)\n",
+ " self.grad = 0.0 # how much the final loss changes if THIS number nudges +1\n",
+ " self._backward = lambda: None # filled in by each op: \"push grad to my parents\"\n",
+ " self._prev = set(_children) # graph edges pointing backward to parents\n",
+ " self._op = _op # which op created this node ('+', '*', 'tanh', ...)\n",
+ " self.label = label # optional name for debugging / graph drawing\n",
"\n",
" def __repr__(self):\n",
" return f\"Value(data={self.data:.4f}, grad={self.grad:.4f})\"\n",
"\n",
" def __add__(self, other):\n",
- " other = other if isinstance(other, Value) else Value(other)\n",
- " out = Value(self.data + other.data, (self, other), '+')\n",
+ " other = other if isinstance(other, Value) else Value(other) # allow Value + 3\n",
+ " out = Value(self.data + other.data, (self, other), '+') # forward: add numbers\n",
" def _backward():\n",
+ " # local rule for +: d(a+b)/da = 1, d(a+b)/db = 1 → grad flows 1:1 to both\n",
" self.grad += 1.0 * out.grad\n",
" other.grad += 1.0 * out.grad\n",
- " out._backward = _backward\n",
+ " out._backward = _backward # closure captures self/other/out — runs later in backward()\n",
" return out\n",
"\n",
" def __mul__(self, other):\n",
" other = other if isinstance(other, Value) else Value(other)\n",
- " out = Value(self.data * other.data, (self, other), '*')\n",
+ " out = Value(self.data * other.data, (self, other), '*') # forward: multiply\n",
" def _backward():\n",
+ " # local rule for *: d(a*b)/da = b, d(a*b)/db = a (chain rule: × out.grad)\n",
" self.grad += other.data * out.grad\n",
" other.grad += self.data * out.grad\n",
" out._backward = _backward\n",
" return out\n",
"\n",
" def __pow__(self, other):\n",
- " assert isinstance(other, (int, float)), \"only int/float powers\"\n",
- " out = Value(self.data ** other, (self,), f'**{other}')\n",
+ " assert isinstance(other, (int, float)), \"only int/float powers\" # exponent is constant\n",
+ " out = Value(self.data ** other, (self,), f'**{other}') # forward: x^n\n",
" def _backward():\n",
+ " # d(x^n)/dx = n * x^(n-1), then chain rule × upstream grad\n",
" self.grad += other * (self.data ** (other - 1)) * out.grad\n",
" out._backward = _backward\n",
" return out\n",
"\n",
" def tanh(self):\n",
" x = self.data\n",
- " t = (math.exp(2 * x) - 1) / (math.exp(2 * x) + 1)\n",
+ " t = (math.exp(2 * x) - 1) / (math.exp(2 * x) + 1) # forward tanh (squash to -1..1)\n",
" out = Value(t, (self,), 'tanh')\n",
" def _backward():\n",
+ " # d tanh(x)/dx = 1 - tanh(x)^2 (use saved t, not recompute from self.data)\n",
" self.grad += (1 - t ** 2) * out.grad\n",
" out._backward = _backward\n",
" return out\n",
"\n",
" def exp(self):\n",
- " out = Value(math.exp(self.data), (self,), 'exp')\n",
+ " out = Value(math.exp(self.data), (self,), 'exp') # forward: e^x\n",
" def _backward():\n",
+ " # d e^x / dx = e^x — out.data IS e^x at forward time\n",
" self.grad += out.data * out.grad\n",
" out._backward = _backward\n",
" return out\n",
"\n",
- " # convenience\n",
+ " # --- convenience ops so expressions like 3 + a or a / b work naturally ---\n",
" def __neg__(self):\n",
- " return self * -1\n",
+ " return self * -1 # unary minus via multiply by -1\n",
"\n",
" def __sub__(self, other):\n",
- " return self + (-other if isinstance(other, Value) else Value(-other))\n",
+ " return self + (-other if isinstance(other, Value) else Value(-other)) # a - b = a + (-b)\n",
"\n",
" def __radd__(self, other):\n",
- " return self + other\n",
+ " return self + other # handles 3 + a (Python calls __radd__ on a)\n",
"\n",
" def __rmul__(self, other):\n",
- " return self * other\n",
+ " return self * other # handles 3 * a\n",
"\n",
" def __truediv__(self, other):\n",
" other = other if isinstance(other, Value) else Value(other)\n",
- " return self * other ** -1\n",
+ " return self * other ** -1 # a / b = a * b^(-1)\n",
"\n",
" def backward(self):\n",
- " # build topological order so children come before parents\n",
+ " # Step 1: list every node in the graph, parents before children (topological sort).\n",
+ " # We walk _prev links; reversed(topo) then visits output → inputs.\n",
" topo = []\n",
" visited = set()\n",
" def build_topo(v):\n",
" if v not in visited:\n",
" visited.add(v)\n",
- " for child in v._prev:\n",
+ " for child in v._prev: # visit all ancestors first\n",
" build_topo(child)\n",
- " topo.append(v)\n",
- " build_topo(self)\n",
- " # seed the output gradient, then sweep backward\n",
+ " topo.append(v) # post-order: children before parent\n",
+ " build_topo(self) # start from the output node (e.g. loss)\n",
+ "\n",
+ " # Step 2: seed — \"if output goes up 1, output goes up 1\" (dL/dL = 1).\n",
" self.grad = 1.0\n",
- " for node in reversed(topo):\n",
- " node._backward()\n",
+ "\n",
+ " # Step 3: reverse sweep; each node runs its local _backward (chain rule × parents).\n",
+ " for node in reversed(topo): # output first, leaves last\n",
+ " node._backward() # each op pushes blame to its _prev parents\n",
"\n",
"\n",
"print(\"full Value class ready\")"
@@ -429,18 +883,37 @@
"metadata": {},
"outputs": [],
"source": [
- "a = Value(2.0, label='a')\n",
+ "a = Value(2.0, label='a') # leaf inputs — knobs with no parents yet\n",
"b = Value(-3.0, label='b')\n",
"c = Value(10.0, label='c')\n",
- "e = a * b\n",
- "d = e + c\n",
+ "e = a * b # builds multiply node; graph remembers a, b\n",
+ "d = e + c # builds add node; graph remembers e, c\n",
"\n",
- "d.backward()\n",
+ "d.backward() # one backward pass fills .grad on every node in the graph\n",
"\n",
- "print(\"d =\", d.data)\n",
- "print(\"a.grad =\", a.grad, \" (expect -3)\")\n",
- "print(\"b.grad =\", b.grad, \" (expect 2)\")\n",
- "print(\"c.grad =\", c.grad, \" (expect 1)\")"
+ "print(\"d =\", d.data) # forward answer still 4.0\n",
+ "print(\"a.grad =\", a.grad, \" (expect -3)\") # ∂d/∂a = b\n",
+ "print(\"b.grad =\", b.grad, \" (expect 2)\") # ∂d/∂b = a\n",
+ "print(\"c.grad =\", c.grad, \" (expect 1)\") # ∂d/∂c = 1"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "0083fec0",
+ "metadata": {},
+ "source": [
+ "Same graph **after backprop** — orange gradient numbers show how much each node would nudge the\n",
+ "final output. Hover `a` and notice its grad equals `b` (−3): the multiply rule in action."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c14c82ca",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "show_graph(d, title=\"After backward() — gradients filled in\")"
]
},
{
@@ -451,7 +924,42 @@
"### 4.3 Trust but verify — numeric gradient check\n",
"\n",
"A good habit: confirm the analytic gradient (from `backward()`) matches the slow\n",
- "nudge-and-measure gradient. If they agree, the engine is correct."
+ "nudge-and-measure gradient. If they agree, the engine is correct.\n",
+ "\n",
+ "**Real-life picture:** change one recipe ingredient by a tiny pinch, cook again, and see\n",
+ "how much the final score moves. That ratio — *change in score ÷ size of pinch* — is the\n",
+ "gradient for that ingredient. Repeat for each ingredient, one at a time. Slow, but it is\n",
+ "the definition of \"sensitivity.\"\n",
+ "\n",
+ "**Two ways to measure the same hill**\n",
+ "\n",
+ "| | Nudge-and-measure (numeric) | `backward()` (analytic) |\n",
+ "|---|---|---|\n",
+ "| Idea | Tiny change in one input → re-run forward | Chain rule walks the graph once |\n",
+ "| Cost | One forward pass **per input** | One backward pass for **all** inputs |\n",
+ "| Result | slope ≈ `(f(x+h) − f(x)) / h` | `.grad` on each leaf `Value` |\n",
+ "\n",
+ "**Outline — nudge-and-measure (what `numeric_grad` does)**\n",
+ "\n",
+ "1. Run the same math as the graph, but on plain floats (no `Value` objects).\n",
+ "2. Pick a tiny step `h` (e.g. `1e-6`).\n",
+ "3. Save the baseline output at the current inputs.\n",
+ "4. For **each** input `i`, one at a time:\n",
+ " - copy the inputs;\n",
+ " - add `h` only to input `i`;\n",
+ " - re-run forward;\n",
+ " - slope = (new output − baseline) / `h`.\n",
+ "5. That slope is the numeric gradient for input `i`.\n",
+ "\n",
+ "**Outline — analytic check (what we trust after verifying)**\n",
+ "\n",
+ "1. Build the expression with `Value` objects (the graph remembers parents via `_prev`).\n",
+ "2. Call `.backward()` once on the final output.\n",
+ "3. Read `.grad` on each leaf — chain rule applied in one reverse sweep.\n",
+ "\n",
+ "**Compare:** print analytic vs numeric side by side; they **match** when\n",
+ "`|analytic − numeric| < 1e-4`. Same hill, two rulers — if they agree, the engine's local\n",
+ "`_backward()` rules are wired correctly."
]
},
{
@@ -462,19 +970,19 @@
"outputs": [],
"source": [
"def numeric_grad(f, inputs, i, h=1e-6):\n",
- " base = f(inputs)\n",
- " bumped = list(inputs)\n",
- " bumped[i] += h\n",
- " return (f(bumped) - base) / h\n",
+ " base = f(inputs) # output at current inputs\n",
+ " bumped = list(inputs) # copy so we don't mutate the original list\n",
+ " bumped[i] += h # nudge only input i by a tiny amount\n",
+ " return (f(bumped) - base) / h # slope = rise / run\n",
"\n",
- "f = lambda v: (v[0] * v[1]) + v[2]\n",
+ "f = lambda v: (v[0] * v[1]) + v[2] # same math as a*b+c but on plain floats\n",
"inputs = [2.0, -3.0, 10.0]\n",
"\n",
"a = Value(2.0); b = Value(-3.0); c = Value(10.0)\n",
- "(a * b + c).backward()\n",
+ "(a * b + c).backward() # analytic gradients via autograd\n",
"\n",
"for i, (name, val) in enumerate(zip(\"abc\", (a, b, c))):\n",
- " ng = numeric_grad(f, inputs, i)\n",
+ " ng = numeric_grad(f, inputs, i) # slow finite-difference gradient for input i\n",
" print(f\"{name}: analytic={val.grad:+.4f} numeric={ng:+.4f} match={abs(val.grad-ng)<1e-4}\")"
]
},
@@ -496,12 +1004,12 @@
"outputs": [],
"source": [
"# Your turn 4 — fill in and run\n",
- "a = Value(0.5); b = Value(2.0); c = Value(-1.0)\n",
- "g = (a * b + c).tanh()\n",
- "g.backward()\n",
+ "a = Value(0.5); b = Value(2.0); c = Value(-1.0) # inputs\n",
+ "g = (a * b + c).tanh() # forward: multiply, add, squash\n",
+ "g.backward() # backprop through tanh, +, *\n",
"\n",
- "f = lambda v: math.tanh(v[0]*v[1] + v[2])\n",
- "ng_a = numeric_grad(f, [0.5, 2.0, -1.0], 0)\n",
+ "f = lambda v: math.tanh(v[0]*v[1] + v[2]) # same formula on plain floats\n",
+ "ng_a = numeric_grad(f, [0.5, 2.0, -1.0], 0) # numeric ∂g/∂a (nudge index 0)\n",
"print(f\"a.grad analytic = {a.grad:+.4f} numeric = {ng_a:+.4f}\")"
]
},
@@ -512,13 +1020,126 @@
"source": [
"### 5.1 Neuron -> Layer -> MLP, all from `Value`\n",
"\n",
- "A **neuron** computes `tanh(w . x + b)` — a weighted vote of its inputs, squashed to (-1, 1).\n",
- "A **layer** is a list of neurons. An **MLP** (multi-layer perceptron) is a stack of layers.\n",
+ "Everything below is still built from plain `Value` objects — the same tagged boxes and\n",
+ "`backward()` trail from section 4. Here we stack them into a small **neural network**.\n",
"\n",
- "Real-life picture: each neuron is a tiny committee member weighing the evidence; layers stack\n",
- "committees so later ones judge the opinions of earlier ones.\n",
+ "#### One story to hold in your head: renting an apartment\n",
"\n",
- "**-> Training:** `parameters()` returns every weight and bias — exactly the knobs we nudge."
+ "You apply with two numbers on the form: **monthly income** and **credit score**.\n",
+ "The landlord wants one answer: *likely good tenant* vs *likely risky*.\n",
+ "\n",
+ "**One neuron = one reviewer on the panel**\n",
+ "\n",
+ "A single neuron looks at your whole application and gives **one opinion**, squashed to a\n",
+ "number between -1 and +1:\n",
+ "\n",
+ "`tanh(w · x + b)` means:\n",
+ "\n",
+ "- **`w`** — how much this reviewer cares about each field (income vs credit). Big weight =\n",
+ " \"this matters a lot to me.\"\n",
+ "- **`b`** — this reviewer's baseline strictness before seeing you (always picky, or always\n",
+ " lenient).\n",
+ "- **`w · x + b`** — weighted score: multiply each input by its importance, add bias.\n",
+ "- **`tanh(...)`** — squash the score so it cannot run away to infinity; strong yes or strong no,\n",
+ " but bounded (like capping enthusiasm at \"very pro\" or \"very against\").\n",
+ "\n",
+ "So one neuron is not magic — it is **one weighted vote**, softened at the edges.\n",
+ "\n",
+ "**One layer = a whole panel, all reading the same application**\n",
+ "\n",
+ "A **layer** is several neurons in **parallel**. Each sees the **same** inputs `x`, but each\n",
+ "has its **own** weights and bias — different reviewers, different priorities:\n",
+ "\n",
+ "- Reviewer A might care mostly about income.\n",
+ "- Reviewer B might care mostly about credit.\n",
+ "- Reviewer C might look for a balance of both.\n",
+ "\n",
+ "The layer's output is a **list of opinions** (one per neuron). Eight neurons → eight numbers\n",
+ "describing the application from eight angles.\n",
+ "\n",
+ "**An MLP = panels stacked in stages**\n",
+ "\n",
+ "An **MLP** (multi-layer perceptron) feeds one layer's opinions into the next:\n",
+ "\n",
+ "1. **First hidden layer** — junior reviewers turn raw form fields into rough themes (\"cash-flow\n",
+ " looks fine\", \"credit is shaky\", …).\n",
+ "2. **Second hidden layer** — senior reviewers debate those themes, not the raw form.\n",
+ "3. **Output layer** — one final judge reads the debate and emits a single score (here: one\n",
+ " neuron → one number for the two-moons task).\n",
+ "\n",
+ "That is why the code uses shapes like `MLP(2, [8, 8, 1)`:\n",
+ "\n",
+ "| Piece | In the story | In code |\n",
+ "|---|---|---|\n",
+ "| 2 inputs | income + credit (here: x, y on the plot) | `nin = 2` |\n",
+ "| 8 + 8 hidden | two panels of 8 reviewers | two `Layer`s with `nout=8` |\n",
+ "| 1 output | final hire / no-hire score | last layer with `nout=1` |\n",
+ "\n",
+ "Data flows **forward** only: form → junior panel → senior panel → final judge.\n",
+ "Every multiply, add, and `tanh` is still a `Value` node, so `loss.backward()` can later\n",
+ "blame the right weight when the answer was wrong.\n",
+ "\n",
+ "#### The three building blocks (code ↔ picture)\n",
+ "\n",
+ "| Class | What it is | Real-life |\n",
+ "|---|---|---|\n",
+ "| **`Neuron`** | one weighted vote + squash | one reviewer |\n",
+ "| **`Layer`** | list of neurons, same inputs | one panel sitting together |\n",
+ "| **`MLP`** | layers wired in sequence | whole hiring process, stage by stage |\n",
+ "\n",
+ "**-> Training:** `parameters()` collects every weight and bias in the network — all the knobs\n",
+ "on all reviewers' score sheets. Section 6 nudges those knobs downhill until the final judge\n",
+ "gets the moons right."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "93e51095",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ " \n",
+ " "
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Interactive companion to section 5.1 — drag the inputs, hover lines/dots, press play.\n",
+ "# Shows the same 2 -> 8 -> 8 -> 1 reviewer-panel network as the code below.\n",
+ "import base64\n",
+ "from pathlib import Path\n",
+ "from IPython.display import IFrame, display\n",
+ "\n",
+ "html_path = Path(\"mlp_playground.html\")\n",
+ "if not html_path.exists():\n",
+ " html_path = Path.cwd() / \"mlp_playground.html\"\n",
+ "\n",
+ "if not html_path.exists():\n",
+ " raise FileNotFoundError(\n",
+ " \"mlp_playground.html not found. Keep it next to this notebook, then re-run.\"\n",
+ " )\n",
+ "\n",
+ "# Notebook UIs often cannot load local files via IFrame src=\"file.html\".\n",
+ "# Pack the page into a data URI so the browser runs it inline with scripts intact.\n",
+ "b64 = base64.b64encode(html_path.read_bytes()).decode(\"ascii\")\n",
+ "display(IFrame(src=f\"data:text/html;base64,{b64}\", width=\"100%\", height=640))"
]
},
{
@@ -530,44 +1151,47 @@
"source": [
"class Neuron:\n",
" def __init__(self, nin):\n",
+ " # one weight per input + one bias — all are Value objects (trainable knobs)\n",
" self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]\n",
" self.b = Value(random.uniform(-1, 1))\n",
"\n",
" def __call__(self, x):\n",
+ " # weighted sum: w1*x1 + w2*x2 + ... + b (each * and + builds the graph)\n",
" act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)\n",
- " return act.tanh()\n",
+ " return act.tanh() # squash activation to (-1, 1)\n",
"\n",
" def parameters(self):\n",
- " return self.w + [self.b]\n",
+ " return self.w + [self.b] # every knob this neuron owns\n",
"\n",
"\n",
"class Layer:\n",
" def __init__(self, nin, nout):\n",
- " self.neurons = [Neuron(nin) for _ in range(nout)]\n",
+ " self.neurons = [Neuron(nin) for _ in range(nout)] # nout parallel neurons\n",
"\n",
" def __call__(self, x):\n",
- " outs = [n(x) for n in self.neurons]\n",
- " return outs[0] if len(outs) == 1 else outs\n",
+ " outs = [n(x) for n in self.neurons] # each neuron sees the same input vector x\n",
+ " return outs[0] if len(outs) == 1 else outs # scalar if one neuron, else list\n",
"\n",
" def parameters(self):\n",
- " return [p for n in self.neurons for p in n.parameters()]\n",
+ " return [p for n in self.neurons for p in n.parameters()] # flatten all weights\n",
"\n",
"\n",
"class MLP:\n",
" def __init__(self, nin, nouts):\n",
- " sizes = [nin] + nouts\n",
+ " sizes = [nin] + nouts # e.g. [2, 8, 8, 1] for 2→8→8→1\n",
+ " # layer i connects sizes[i] inputs to sizes[i+1] outputs\n",
" self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]\n",
"\n",
" def __call__(self, x):\n",
- " for layer in self.layers:\n",
+ " for layer in self.layers: # data flows forward through each layer\n",
" x = layer(x)\n",
" return x\n",
"\n",
" def parameters(self):\n",
- " return [p for layer in self.layers for p in layer.parameters()]\n",
+ " return [p for layer in self.layers for p in layer.parameters()] # all trainable Values\n",
"\n",
"\n",
- "net = MLP(2, [8, 8, 1]) # 2 inputs -> 8 -> 8 -> 1 output\n",
+ "net = MLP(2, [8, 8, 1]) # 2 inputs -> 8 hidden -> 8 hidden -> 1 output\n",
"print(\"parameter count:\", len(net.parameters()))\n",
"print(\"one prediction:\", net([Value(0.5), Value(-0.2)]))"
]
@@ -596,30 +1220,32 @@
"metadata": {},
"outputs": [],
"source": [
- "net = MLP(2, [8, 8, 1])\n",
+ "net = MLP(2, [8, 8, 1]) # fresh network for training\n",
"\n",
- "# map labels {0,1} -> {-1,+1} to match tanh output range\n",
+ "# map labels {0,1} -> {-1,+1} to match tanh output range (-1 to +1)\n",
"ys = [1.0 if yi == 1 else -1.0 for yi in y]\n",
+ "# wrap each (x, y) coordinate pair as Value objects so forward builds a graph\n",
"Xv = [[Value(float(xi[0])), Value(float(xi[1]))] for xi in X]\n",
"\n",
- "lr = 0.1\n",
- "for step in range(60):\n",
- " # forward + MSE loss\n",
+ "lr = 0.1 # learning rate — how big each knob turn per step\n",
+ "for step in range(60): # 60 gradient-descent steps (small count: micrograd is slow)\n",
+ "\n",
+ " # --- 1. FORWARD: predict every point, build one big graph ending in loss ---\n",
" preds = [net(xrow) for xrow in Xv]\n",
- " loss = sum(((p - yt) ** 2 for p, yt in zip(preds, ys)), Value(0.0))\n",
- " loss = loss * (1.0 / len(ys))\n",
+ " loss = sum(((p - yt) ** 2 for p, yt in zip(preds, ys)), Value(0.0)) # sum of squared errors\n",
+ " loss = loss * (1.0 / len(ys)) # average MSE so scale doesn't grow with dataset size\n",
"\n",
- " # backward (reset grads first)\n",
+ " # --- 2. BACKWARD: zero old grads, then propagate blame from loss to every weight ---\n",
" for p in net.parameters():\n",
- " p.grad = 0.0\n",
- " loss.backward()\n",
+ " p.grad = 0.0 # must reset — backward() accumulates with +=\n",
+ " loss.backward() # fills p.grad for every parameter\n",
"\n",
- " # update\n",
+ " # --- 3. UPDATE: walk downhill — turn each knob opposite its gradient ---\n",
" for p in net.parameters():\n",
- " p.data -= lr * p.grad\n",
+ " p.data -= lr * p.grad # new value = old value - lr * (how loss rises if knob rises)\n",
"\n",
" if step % 10 == 0 or step == 59:\n",
- " # accuracy\n",
+ " # sign match: prediction > 0 same as target > 0 counts as correct\n",
" acc = sum((1 if (p.data > 0) == (yt > 0) else 0) for p, yt in zip(preds, ys)) / len(ys)\n",
" print(f\"step {step:3d} loss {loss.data:.4f} acc {acc:.2%}\")"
]
@@ -645,20 +1271,21 @@
"outputs": [],
"source": [
"if HAS_PLT:\n",
+ " # pad plot area a little beyond the data points\n",
" xmin, xmax = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5\n",
" ymin, ymax = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5\n",
- " xs = np.linspace(xmin, xmax, 30)\n",
- " ys_grid = np.linspace(ymin, ymax, 30)\n",
+ " xs = np.linspace(xmin, xmax, 30) # 30 grid columns\n",
+ " ys_grid = np.linspace(ymin, ymax, 30) # 30 grid rows\n",
"\n",
- " Z = np.zeros((len(ys_grid), len(xs)))\n",
+ " Z = np.zeros((len(ys_grid), len(xs))) # store net output at each grid cell\n",
" for i, gy in enumerate(ys_grid):\n",
" for j, gx in enumerate(xs):\n",
- " out = net([Value(float(gx)), Value(float(gy))])\n",
- " Z[i, j] = out.data\n",
+ " out = net([Value(float(gx)), Value(float(gy))]) # forward only (no training)\n",
+ " Z[i, j] = out.data # >0 one side of boundary, <0 other side\n",
"\n",
" plt.figure(figsize=(5, 4))\n",
- " plt.contourf(xs, ys_grid, Z, levels=[-1e9, 0, 1e9], cmap=\"bwr\", alpha=0.3)\n",
- " plt.scatter(X[:, 0], X[:, 1], c=y, s=20, cmap=\"bwr\")\n",
+ " plt.contourf(xs, ys_grid, Z, levels=[-1e9, 0, 1e9], cmap=\"bwr\", alpha=0.3) # colored regions\n",
+ " plt.scatter(X[:, 0], X[:, 1], c=y, s=20, cmap=\"bwr\") # training dots on top\n",
" plt.title(\"decision boundary learned by the micrograd MLP\")\n",
" plt.show()\n",
"else:\n",
@@ -704,18 +1331,19 @@
"try:\n",
" import torch\n",
"\n",
+ " # requires_grad=True tells PyTorch to track history (like our Value graph)\n",
" at = torch.tensor([0.5], requires_grad=True)\n",
" bt = torch.tensor([2.0], requires_grad=True)\n",
" ct = torch.tensor([-1.0], requires_grad=True)\n",
"\n",
- " gt = torch.tanh(at * bt + ct)\n",
- " gt.backward()\n",
+ " gt = torch.tanh(at * bt + ct) # same expression as micrograd Your turn 4\n",
+ " gt.backward() # PyTorch autograd — same idea as Value.backward()\n",
" print(\"PyTorch: a.grad =\", at.grad.item())\n",
"except OSError as e:\n",
" print(\"PyTorch could not load in this environment:\", e)\n",
" print(\"(That is fine here - the micrograd result below is the point.)\")\n",
"\n",
- "# our engine, same expression\n",
+ "# our engine, same expression — should match PyTorch's a.grad\n",
"a = Value(0.5); b = Value(2.0); c = Value(-1.0)\n",
"(a * b + c).tanh().backward()\n",
"print(\"micrograd: a.grad =\", a.grad)"
@@ -759,10 +1387,18 @@
"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.10"
}
},
"nbformat": 4,
"nbformat_minor": 5
-}
\ No newline at end of file
+}
diff --git a/_build_07.py b/_build_07.py
deleted file mode 100644
index f017d16..0000000
--- a/_build_07.py
+++ /dev/null
@@ -1,276 +0,0 @@
-import json
-import uuid
-from pathlib import Path
-
-cells = []
-
-
-def _src(text):
- lines = text.split("\n")
- out = [line + "\n" if i < len(lines) - 1 else line for i, line in enumerate(lines)]
- if out and out[-1] == "":
- out.pop()
- return out
-
-
-def md(text):
- cells.append({"cell_type": "markdown", "id": uuid.uuid4().hex[:8],
- "metadata": {}, "source": _src(text)})
-
-
-def code(text):
- cells.append({"cell_type": "code", "execution_count": None, "id": uuid.uuid4().hex[:8],
- "metadata": {}, "outputs": [], "source": _src(text)})
-
-
-# ----------------------------------------------------------------------------
-md("""# The GPT tokenizer — Byte Pair Encoding from scratch
-
-Turn raw text into tokens the model can read — **concept -> code -> Your turn** each step.
-
-This is notebook **07**, the final piece of the pipeline. Every notebook so far fed the model
-either single characters (Shakespeare) or single letters (names). Real GPTs use **subword**
-tokens built by **Byte Pair Encoding (BPE)**. Here we build a BPE tokenizer from scratch, then
-see why tokenization quietly causes many famous LLM quirks. Follows Karpathy's
-*"Let's build the GPT Tokenizer"*.""")
-
-md("""## Prologue — in plain English
-
-A neural net only eats numbers, so text must be chopped into pieces (**tokens**) and each piece
-mapped to an id. Two extremes:
-
-- **One token per character** (what we did): tiny vocabulary, but sequences are very long and
- the model must relearn common spellings everywhere.
-- **One token per word**: short sequences, but the vocabulary explodes and unseen words break it.
-
-**BPE** is the practical middle: start from raw bytes, then repeatedly **merge the most common
-adjacent pair** into a new token. Frequent chunks like `the`, `ing`, or ` and` become single
-tokens; rare text still survives as smaller pieces.
-
-Real-life picture: inventing shorthand. You start writing letter by letter, notice you keep
-writing "t-h-e", so you invent one squiggle for "the." Then you notice " a-n-d" and give it a
-squiggle too. BPE does this automatically, keeping the most useful shorthands.""")
-
-# ----------------------------------------------------------------------------
-md("""### 1.1 Text -> UTF-8 bytes (the 256 starting tokens)
-
-Computers already store text as **bytes** (numbers 0-255) via UTF-8. So our alphabet starts
-with exactly 256 tokens — every possible byte. Plain English letters are one byte each;
-accented or non-Latin characters take several bytes (which is why they cost more tokens later).""")
-
-code("""sample = "Hello world! tokenization is fun."
-the_bytes = sample.encode("utf-8")
-print("text :", sample)
-print("bytes :", list(the_bytes))
-print("length :", len(the_bytes), "bytes ->", len(the_bytes), "starting tokens")
-
-multi = "café na\u00efve" # accented letters take >1 byte each
-print("\\nnon-ASCII example:", multi)
-print("bytes:", list(multi.encode("utf-8")), "(note: more bytes than characters)")""")
-
-# ----------------------------------------------------------------------------
-md("""### 2.1 Two tiny helpers: count pairs, and merge a pair
-
-- `get_stats` counts how often each adjacent pair appears.
-- `merge` replaces every occurrence of a chosen pair with a single new token id.
-
-That is the entire mechanism of BPE.""")
-
-code("""def get_stats(ids):
- counts = {}
- for pair in zip(ids, ids[1:]):
- counts[pair] = counts.get(pair, 0) + 1
- return counts
-
-def merge(ids, pair, idx):
- new_ids = []
- i = 0
- while i < len(ids):
- if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
- new_ids.append(idx)
- i += 2
- else:
- new_ids.append(ids[i])
- i += 1
- return new_ids
-
-# demo on a toy list: merge the most common pair
-demo = [1, 2, 3, 1, 2, 3, 1, 2]
-st = get_stats(demo)
-top = max(st, key=st.get)
-print("counts:", st)
-print("most common pair:", top, "->", st[top], "times")
-print("after merging", top, "into 99:", merge(demo, top, 99))""")
-
-md("""### 2.2 Train BPE — learn the merges
-
-We train on a few KB of real text (Shakespeare). Starting from raw bytes, we repeatedly find
-the most common pair and merge it, recording each merge. After `num_merges` rounds we have a
-vocabulary of `256 + num_merges` tokens.""")
-
-code("""train_text = open("input.txt", "r", encoding="utf-8").read()[:20000]
-ids = list(train_text.encode("utf-8"))
-print("training bytes:", len(ids))
-
-num_merges = 60
-merges = {} # (a, b) -> new_id
-work = list(ids)
-for i in range(num_merges):
- stats = get_stats(work)
- pair = max(stats, key=stats.get)
- idx = 256 + i
- work = merge(work, pair, idx)
- merges[pair] = idx
-
-print("merges learned:", len(merges))
-print("tokens after training:", len(work), "(was", len(ids), "bytes)")
-print(f"compression: {len(ids) / len(work):.2f}x")""")
-
-# ----------------------------------------------------------------------------
-md("""### 3.1 encode and decode
-
-- **decode**: turn token ids back into text. We build each token's byte string by stitching the
- merges back together.
-- **encode**: turn new text into token ids by applying the learned merges, always doing the
- *earliest-learned* applicable merge first.""")
-
-code("""# build the id -> bytes table
-vocab = {idx: bytes([idx]) for idx in range(256)}
-for (p0, p1), idx in merges.items():
- vocab[idx] = vocab[p0] + vocab[p1]
-
-def decode(ids):
- data = b"".join(vocab[idx] for idx in ids)
- return data.decode("utf-8", errors="replace")
-
-def encode(text):
- tokens = list(text.encode("utf-8"))
- while len(tokens) >= 2:
- stats = get_stats(tokens)
- # pick the pair whose merge was learned earliest
- pair = min(stats, key=lambda p: merges.get(p, float("inf")))
- if pair not in merges:
- break
- tokens = merge(tokens, pair, merges[pair])
- return tokens
-
-trial = "the king and the queen"
-enc = encode(trial)
-print("text :", trial)
-print("tokens:", enc)
-print("count :", len(enc), "tokens for", len(trial), "characters")
-print("decode round-trip ok:", decode(enc) == trial)""")
-
-md("""### 3.2 See what the learned tokens are
-
-The merges discovered the common chunks of English on their own. Let's print a few learned
-tokens (ids >= 256) as the text they stand for.""")
-
-code("""print("a few learned tokens:")
-for idx in range(256, 256 + min(20, num_merges)):
- piece = vocab[idx].decode("utf-8", errors="replace")
- print(f" id {idx}: {piece!r}")""")
-
-# ----------------------------------------------------------------------------
-md("""### 4.1 Regex pre-splitting (what real GPTs add)
-
-A subtlety: plain BPE might merge across spaces and punctuation (e.g. glue `dog.` or `the the`
-into weird tokens). GPT-2 first **splits** the text into sensible chunks with a regex (words,
-runs of spaces, punctuation), then runs BPE **inside** each chunk only.
-
-Real GPT uses the `regex` library with Unicode classes; here is a simplified version with the
-standard `re` module to show the idea.""")
-
-code("""import re
-
-# simplified GPT-2-style splitter (real one uses the `regex` lib with \\p{L}, \\p{N})
-pat = re.compile(r"'s|'t|'re|'ve|'m|'ll|'d| ?\\w+| ?[^\\s\\w]+|\\s+")
-
-demo_text = "Hello, world! It's 2026 already."
-chunks = re.findall(pat, demo_text)
-print("split into chunks (BPE then runs inside each one):")
-print(chunks)""")
-
-md("""### 4.2 Special tokens
-
-Real tokenizers also reserve **special tokens** that are not learned from text but inserted to
-mark structure, for example `<|endoftext|>` between documents, or chat markers like
-`<|im_start|>` / `<|im_end|>`. They get their own ids above the learned vocabulary.
-
-Real-life picture: punctuation the model never "spells" — single reserved symbols that mean
-"new document starts here" or "the user is speaking now."
-
-```
-<|endoftext|> -> id 50256 in GPT-2 (separates documents)
-<|im_start|> -> chat role marker in chat models
-```""")
-
-# ----------------------------------------------------------------------------
-md("""### 5.1 Why tokenization causes LLM quirks
-
-Many puzzling LLM behaviours trace back to how text is tokenized. We measure a few.
-
-- **Spelling / reversing words is hard**: a word is often a *single* token, so the model does
- not "see" its letters.
-- **Arithmetic is fragile**: numbers split into arbitrary chunks, not clean digits.
-- **Non-English costs more**: it falls back to many byte-tokens, so the same meaning uses more
- tokens (and more money / context).""")
-
-code("""def n_tokens(s):
- return len(encode(s))
-
-print("spelling: 'extraordinary' ->", n_tokens("extraordinary"), "token(s)")
-print(" the model sees a chunk, not the 13 letters\\n")
-
-print("numbers : '1234567' ->", n_tokens("1234567"), "tokens (split oddly, not per-digit)")
-print(" '127 + 677' ->", n_tokens("127 + 677"), "tokens\\n")
-
-eng = "hello how are you"
-non = "\u4f60\u597d\u4f60\u597d\u4f60\u597d" # non-English of similar visible length
-print("non-English costs more tokens for similar content:")
-print(" english :", repr(eng), "->", n_tokens(eng), "tokens")
-print(" non-latin :", repr(non), "->", n_tokens(non), "tokens")""")
-
-md("""**Your turn 7** — Raise `num_merges` (e.g. to 300) in section 2.2, retrain, and re-check the
-compression ratio and the token counts in section 5.1. More merges = better compression, but a
-bigger vocabulary. This trade-off is exactly what real tokenizer designers tune.""")
-
-# ----------------------------------------------------------------------------
-md("""## Series wrap-up
-
-You built the whole stack, from the ground up:
-
-- `00_micrograd.ipynb` — backprop from scratch (the engine)
-- `01_build_gpt.ipynb` — the bigram baseline
-- `02_makemore_mlp.ipynb` — an MLP with embeddings
-- `03_batchnorm_activations.ipynb` — keeping deep nets healthy
-- `04_backprop_ninja.ipynb` — gradients by hand
-- `05_wavenet.ipynb` — a deeper, hierarchical model
-- `06_build_gpt_attention.ipynb` — the transformer (GPT)
-- `07_gpt_tokenizer.ipynb` — turning text into tokens (this notebook)
-
-Together these cover Andrej Karpathy's *Neural Networks: Zero to Hero* course, on real data,
-with runnable code at every step.
-
-**Checklist**
-- [ ] Text -> UTF-8 bytes (256 base tokens)
-- [ ] `get_stats` + `merge` = the BPE mechanism
-- [ ] Trained merges; measured compression
-- [ ] encode / decode round-trips
-- [ ] Regex pre-splitting and special tokens
-- [ ] Explained tokenization-caused LLM quirks""")
-
-# ----------------------------------------------------------------------------
-nb = {
- "cells": cells,
- "metadata": {
- "kernelspec": {"display_name": ".venv (3.12.10)", "language": "python", "name": "python3"},
- "language_info": {"name": "python", "version": "3.12.10"},
- },
- "nbformat": 4,
- "nbformat_minor": 5,
-}
-
-out_path = Path(r"d:\BeastProjects\gpt_from_scratch\07_gpt_tokenizer.ipynb")
-out_path.write_text(json.dumps(nb, ensure_ascii=False, indent=2), encoding="utf-8")
-print("wrote", out_path, "with", len(cells), "cells")
diff --git a/kylin_remote_setup.md b/kylin_remote_setup.md
new file mode 100644
index 0000000..ecb369b
--- /dev/null
+++ b/kylin_remote_setup.md
@@ -0,0 +1,475 @@
+# Kylin Remote Control & Monitoring Setup
+
+Complete reference for controlling and monitoring a Kylin Linux machine from a
+Windows 11 PC over a local phone-hotspot network. No VPN is used for the SSH
+connection itself — only the local Wi-Fi/hotspot.
+
+---
+
+## 0. Bootstrap — START HERE in a new session
+
+**The connection is already fully set up** (SSH key login + passwordless sudo).
+A new session does **NOT** need to repeat the setup in §2–§3 — those sections are
+records of what was already done. Just connect and go.
+
+**Verify everything with one command** (run from the Windows machine, e.g. Claude Code's Bash tool):
+
+```bash
+ssh -o BatchMode=yes -o ConnectTimeout=10 kylin 'echo SSH_OK; whoami; hostname; sudo -n true && echo SUDO_OK'
+```
+
+Expected: `SSH_OK` / `caichl` / `caichl-HUAWEIQINGYUNL420` / `SUDO_OK`.
+
+- ✅ **`SSH_OK` + `SUDO_OK`** → full control. Run anything (incl. `sudo`) via
+ `ssh -o BatchMode=yes kylin ""`. No password needed.
+- ❌ **asks for password / times out** → most likely the **hotspot IP changed**
+ (`192.168.43.248` is DHCP-assigned and can change between sessions). Ask the user
+ to re-check the Kylin IP (`hostname -I` on that machine), update
+ `C:\Users\adusu\.ssh\config`, then re-verify. If it asks for a password, the key
+ may have been removed — see §2.3 / §2.4 to re-establish.
+- ℹ️ SSH prints a harmless **post-quantum warning** to stderr — ignore it (the
+ examples in this doc filter it out).
+
+**Connect manually:** `ssh kylin` (alias in `C:\Users\adusu\.ssh\config` → `caichl@192.168.43.248`,
+with the pinned cipher/MAC already configured — see §2).
+
+> A fresh Claude Code session also auto-loads memory pointers (`MEMORY.md` →
+> `kylin-remote-machine`, `kylin-command-logging`, `kylin-mihomo-vpn`) that link
+> back here. This file is the authoritative, self-contained reference.
+
+---
+
+## 1. The two machines
+
+| Role | Details |
+|------|---------|
+| **Controller** | Windows 11 (`C:\Users\adusu`), where Claude Code + VPN run |
+| **Target** | Kylin V10 SP1, Huawei QINGYUN L420, **ARM64 / aarch64** |
+| **Network** | Shared phone hotspot, subnet `192.168.43.x` |
+| **Target IP** | `192.168.43.248` |
+| **Target user** | `caichl` |
+
+> **Key idea:** Claude Code needs the VPN to reach Anthropic's servers, but the
+> SSH hop from Windows → Kylin is **local LAN traffic** and needs no VPN. The two
+> connections are independent.
+
+---
+
+## 2. SSH connection
+
+### 2.1 Required cipher/MAC (important)
+
+The default cipher fails over this hotspot link with
+`Corrupted MAC on input`. The connection must pin a specific cipher and MAC:
+
+```
+-c aes256-ctr -m hmac-sha2-256
+```
+
+### 2.2 Windows SSH config
+
+File: `C:\Users\adusu\.ssh\config`
+
+```sshconfig
+Host kylin kyln
+ HostName 192.168.43.248
+ User caichl
+ Ciphers aes256-ctr
+ MACs hmac-sha2-256
+```
+
+With this in place, simply run:
+
+```powershell
+ssh kylin # or: ssh kyln (both aliases work)
+```
+
+The equivalent explicit command (no config) is:
+
+```powershell
+ssh -m hmac-sha2-256 -c aes256-ctr caichl@192.168.43.248
+```
+
+### 2.3 Passwordless login (SSH key)
+
+The Windows key `~/.ssh/id_ed25519.pub` was copied to the Kylin machine:
+
+```powershell
+Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub | `
+ ssh kylin "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
+```
+
+Result: `ssh kylin` logs in with **no password**.
+
+### 2.4 Passwordless sudo
+
+So admin commands can run non-interactively from Windows:
+
+```bash
+# Run once on the Kylin machine (asks for password the one time)
+sudo bash -c 'echo "caichl ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/caichl-nopasswd; \
+ chmod 440 /etc/sudoers.d/caichl-nopasswd; \
+ visudo -cf /etc/sudoers.d/caichl-nopasswd'
+```
+
+> **Security note:** This makes `caichl` a passwordless-sudo account — acceptable
+> for a disposable machine only. To revert: `sudo rm /etc/sudoers.d/caichl-nopasswd`.
+
+After this, from Windows you can run, e.g.:
+
+```powershell
+ssh -o BatchMode=yes kylin "sudo systemctl status ssh"
+```
+
+### 2.5 Account credentials (recovery only)
+
+> ⚠️ **Plaintext password below — keep this file private; do NOT commit or share it.**
+
+| Item | Value |
+|------|-------|
+| User | `caichl` |
+| Password | `Caicai@2026` |
+
+**Normally not needed** — SSH login uses the key (§2.3) and sudo is passwordless
+(§2.4). Use this only to **recover** if the key auth or sudoers drop-in is ever lost
+(e.g. to log in interactively and re-copy the key, or to re-create
+`/etc/sudoers.d/caichl-nopasswd`). To change it: run `passwd` on the Kylin machine.
+
+---
+
+## 3. Initial SSH server setup (done on the Kylin machine)
+
+```bash
+sudo apt update
+sudo apt install -y openssh-server
+sudo systemctl enable --now ssh
+hostname -I # find the IP (192.168.43.248)
+whoami # confirm the username (caichl)
+```
+
+---
+
+## 4. Monitoring
+
+### 4.1 SSH logins & sessions (works out of the box)
+
+```bash
+who # who is logged in + source
+w # sessions + current command
+last # login history with IPs
+lastb # FAILED login attempts (sudo)
+sudo grep "Accepted" /var/log/auth.log # successful SSH logins
+sudo grep "Failed" /var/log/auth.log # failed/suspicious attempts
+sudo tail -f /var/log/auth.log # live SSH activity
+```
+
+### 4.2 Command logging — no reboot (WORKING)
+
+Logs every command typed in an interactive shell, with user, tty, source IP,
+and the command text.
+
+**`/etc/profile.d/cmdlog.sh`**
+```bash
+# Log interactive shell commands to syslog (facility local6) -> /var/log/cmdlog.log
+if [ -n "$BASH" ] && [ -n "$PS1" ]; then
+ export PROMPT_COMMAND='logger -p local6.notice -t cmdlog "user=$USER tty=$(tty 2>/dev/null) from=$(who am i 2>/dev/null | sed -n "s/.*(\(.*\)).*/\1/p") cmd=$(history 1 | sed "s/^ *[0-9]* *//")"'
+fi
+```
+
+**`/etc/rsyslog.d/30-cmdlog.conf`**
+```
+local6.* /var/log/cmdlog.log
+```
+
+**Setup commands**
+```bash
+sudo touch /var/log/cmdlog.log
+sudo chown syslog:adm /var/log/cmdlog.log # MUST be syslog-owned (rsyslog drops privs)
+sudo chmod 640 /var/log/cmdlog.log
+sudo systemctl restart rsyslog
+```
+
+**View the logs**
+```bash
+sudo cat /var/log/cmdlog.log # all logged commands
+sudo tail -f /var/log/cmdlog.log # live
+```
+
+Example line:
+```
+Jun 11 10:54:22 caichl-HUAWEIQINGYUNL420 cmdlog: user=caichl tty=/dev/pts/5 from=192.168.43.180 cmd=echo hello
+```
+
+> **Gotcha:** rsyslog runs as the `syslog` user. If `/var/log/cmdlog.log` is owned
+> by `root:root`, nothing is written. It must be `chown syslog:adm`.
+
+### 4.3 System-wide auditing — auditd (QUEUED, UNVERIFIED)
+
+`auditd` is installed and enabled, with a rule logging all `execve` for real
+users:
+
+**`/etc/audit/rules.d/cmdlog.rules`**
+```
+-a exit,always -F arch=b64 -S execve -F auid>=1000 -F auid!=4294967295 -k cmdlog
+```
+
+It cannot run yet because the kernel booted with `audit=0`. This was changed to
+`audit=1`:
+
+```bash
+sudo sed -i 's/audit=0/audit=1/' /etc/default/grub
+# /boot is read-only on this device — remount to rebuild grub
+sudo mount -o remount,rw /boot
+sudo update-grub
+sudo mount -o remount,ro /boot
+```
+
+> **Caveat:** This device appears to boot via **Huawei firmware, not standard
+> GRUB** (running kernel `-27` is not the one in `grub.cfg` which lists `-11`, and
+> the kernel cmdline has Huawei-specific params). So the `audit=1` change **may
+> not take effect** on reboot. The no-reboot logging in §4.2 already covers
+> interactive command logging, so auditd is a bonus.
+
+**Verify after any reboot**
+```bash
+cat /proc/sys/kernel/audit_enabled # want: 1
+sudo systemctl is-active auditd # want: active
+sudo ausearch -k cmdlog -i | tail -40 # human-readable command audit
+sudo aureport -x --summary # summary of executables run
+```
+
+---
+
+## 5. Device-specific quirks (Huawei Kylin ARM64)
+
+- **Architecture is ARM64 (aarch64).** Software must have an ARM64 build; x86-only
+ proprietary apps will not install/run.
+- **`/boot` is a separate ext4 partition mounted read-only.** Remount rw to change
+ GRUB, then back to ro (see §4.3).
+- **Boot likely controlled by Huawei firmware**, limiting effectiveness of GRUB
+ cmdline edits.
+- **Hotspot link needs `aes256-ctr` / `hmac-sha2-256`** or SSH fails with
+ "Corrupted MAC on input".
+
+---
+
+## 6. Quick reference
+
+```powershell
+# Connect
+ssh kylin
+
+# Run a one-off command from Windows
+ssh kylin "uptime"
+
+# Run an admin command from Windows
+ssh kylin "sudo systemctl status ssh"
+
+# See logged commands
+ssh kylin "sudo tail -50 /var/log/cmdlog.log"
+
+# Watch live
+ssh kylin "sudo tail -f /var/log/cmdlog.log"
+
+# SSH login history
+ssh kylin "last"
+```
+
+---
+
+## 7. Troubleshooting
+
+| Symptom | Cause / Fix |
+|---------|-------------|
+| `Corrupted MAC on input` | Use `-c aes256-ctr -m hmac-sha2-256` (already in config) |
+| `Connection timed out` | Both devices not on same hotspot, or AP/client isolation on the phone — disable isolation |
+| `ssh kyln` "could not resolve" | Alias spelling — config now accepts both `kylin` and `kyln` |
+| Still asks for password | SSH key not copied (§2.3) |
+| `sudo` asks for password | sudoers drop-in empty/missing (§2.4) |
+| `audit support not in kernel` | Kernel booted `audit=0`; needs reboot with `audit=1` (§4.3) |
+| `cmdlog.log` empty | File not owned by `syslog:adm`; `sudo chown syslog:adm /var/log/cmdlog.log` then restart rsyslog |
+| `grub.cfg: Read-only file system` | `/boot` is read-only; `sudo mount -o remount,rw /boot` first |
+| VPN GUI app won't run (`GLIBC_2.34 not found`) | Kylin has glibc 2.31; modern Flutter/Electron clients need 2.34+. Use the Go-based mihomo core instead (see §8) |
+
+---
+
+## 8. VPN / proxy: mihomo + web dashboard
+
+**Why not FlClash / GUI clients:** Kylin V10 SP1 has **glibc 2.31**, but FlClash 0.8.93
+and similar modern GUI clients require **glibc 2.34+** and fail to run. Upgrading
+glibc would risk breaking the OS. mihomo is a **statically-linked Go binary** with
+no glibc dependency, so it runs fine — controlled via a browser dashboard.
+
+### Current state
+> **VPN is currently OFF.** The `mihomo` service is **stopped** and **disabled**
+> (auto-start removed), so it stays off across reboots until re-enabled. Ports
+> `7890`/`9090` are closed. The full configuration below is intact and ready to
+> resume.
+
+### Turn the VPN on / off (run on Kylin, or via `ssh kylin`)
+```bash
+# TURN ON (and restore auto-start on boot)
+sudo systemctl enable --now mihomo
+
+# TURN OFF for this session only (comes back on next reboot)
+sudo systemctl stop mihomo
+
+# TURN OFF and keep it off across reboots <-- current state
+sudo systemctl stop mihomo && sudo systemctl disable mihomo
+
+# Check state
+systemctl is-active mihomo # active / inactive
+systemctl is-enabled mihomo # enabled / disabled
+```
+> Reminder: if you had set the Kylin **system proxy** to `127.0.0.1:7890`, switch
+> it back to "None" while the VPN is off, or apps will fail to reach the network.
+
+### Desktop access
+A launcher **"Mihomo VPN Dashboard"** was added to the app menu (Network category)
+at `/usr/share/applications/mihomo-dashboard.desktop`; it opens
+`http://127.0.0.1:9090/ui/`. (Only works while the service is running.)
+
+### Installed components
+| Item | Location / value |
+|------|------------------|
+| mihomo core | `/usr/local/bin/mihomo` (Clash.Meta v1.19.27, arm64) |
+| Config | `/etc/mihomo/config.yaml` |
+| Web dashboard (zashboard) | `/etc/mihomo/ui/` |
+| systemd service | `/etc/systemd/system/mihomo.service` (auto-starts on boot) |
+| Proxy port (HTTP+SOCKS) | `7890` |
+| Dashboard API | `0.0.0.0:9090` |
+| Dashboard secret | `428968aebf7c301565c6b6d3a11826c4` |
+
+### Install steps (done; for reference / rebuild)
+```bash
+# binary (downloaded on Windows through VPN, scp'd over to dodge chicken-and-egg)
+gunzip mihomo-linux-arm64-vX.gz
+sudo install -m 755 mihomo-linux-arm64 /usr/local/bin/mihomo
+# dashboard
+sudo mkdir -p /etc/mihomo/ui && sudo unzip zashboard-dist.zip -d /etc/mihomo/ui
+# config.yaml: mixed-port 7890, external-controller 0.0.0.0:9090, secret, external-ui: ui,
+# proxy-providers (subscription URL), PROXY (select) + AUTO (url-test) groups
+sudo systemctl enable --now mihomo
+```
+> The subscription URL lives in `/etc/mihomo/config.yaml` under `proxy-providers:`.
+> `external-ui-name` must NOT be set (it makes mihomo look in a `ui/` subdir
+> and auto-download); serve `/etc/mihomo/ui` directly.
+
+### Open the dashboard
+- **From Windows browser:** `http://192.168.43.248:9090/ui/`
+ - backend/API: `http://192.168.43.248:9090` • secret: `428968aebf7c301565c6b6d3a11826c4`
+- **From Kylin desktop browser:** `http://127.0.0.1:9090/ui/`
+
+### Use the proxy
+Point apps at `127.0.0.1:7890` (HTTP/SOCKS), or set the Kylin system proxy
+(Settings → Network → Proxy → Manual → `127.0.0.1:7890`). For transparent
+whole-system routing, enable mihomo **TUN mode** (`/dev/net/tun` is present).
+
+### Manage via API (examples)
+```bash
+S=428968aebf7c301565c6b6d3a11826c4
+# switch PROXY group to a node / AUTO
+curl -X PUT -H "Authorization: Bearer $S" http://127.0.0.1:9090/proxies/PROXY -d '{"name":"AUTO"}'
+# verify exit IP goes through the proxy
+curl -x http://127.0.0.1:7890 -s https://api.ip.sb/geoip
+# update subscription / reload
+sudo systemctl restart mihomo
+```
+
+**Verified working:** exit IP resolved to Singapore and `google.com/generate_204`
+returned HTTP 204 through the proxy.
+
+---
+
+## 9. Browsers
+
+| Browser | App-menu name | Type / location | Notes |
+|---------|---------------|-----------------|-------|
+| **Chromium** | "Chromium" | snap `chromium` v149 (`/snap/bin/chromium`) | Installed as the Chrome/Edge equivalent — same engine, Chrome Web Store extensions work. **Set as the default browser.** |
+
+- **Chrome & Edge cannot be installed** — neither has an official ARM64 Linux build
+ (same architecture wall as the VPN GUI clients). Chromium is the substitute.
+- Installing the chromium snap first required **updating snapd** (Kylin shipped 2.54,
+ too old → error `assumes unsupported features: snapd2.55`). Fix:
+ `sudo snap install snapd` (brought it to 2.75.2), then `sudo snap install chromium`.
+- Default browser is recorded in `~/.config/mimeapps.list`
+ (`text/html`, `x-scheme-handler/http(s)` → `chromium_chromium.desktop`). If a
+ double-click opens the wrong app, right-click the file → **Open with → Chromium →
+ Set as default**.
+
+### Desktop instruction files (for the user at the machine)
+- `~/桌面/VPN_Guide.html` + `~/桌面/VPN_Guide.md` (copies also in `~/Desktop/`):
+ a user-facing "how to turn the VPN on/off" guide. Double-click the **`.html`** to
+ read it rendered in a browser; the **`.md`** opens in **VS Code** (`code`, installed)
+ with `Ctrl+Shift+V` preview.
+- App-menu launcher **"Mihomo VPN Dashboard"** → opens `http://127.0.0.1:9090/ui/`
+ (only works while the mihomo service is running).
+
+---
+
+## 10. Inventory — everything set up on the Kylin machine
+
+| Area | What | State |
+|------|------|-------|
+| Access | SSH alias `kylin`/`kyln`, key login, passwordless sudo | ✅ active |
+| Monitoring | SSH login logs (`auth.log`); command logging → `/var/log/cmdlog.log` | ✅ active |
+| Monitoring | auditd execve rule | ⏳ queued (needs reboot + `audit=1`; may not take on Huawei firmware) |
+| VPN | mihomo core + zashboard dashboard, subscription configured | ⛔ installed, **OFF** (stopped + disabled) |
+| Browser | Chromium v149 (default), 360/CNOOC browser | ✅ installed |
+| Editors | VS Code (`code`), pluma, WPS Office | ✅ pre-installed |
+| IDE | Cursor 3.7.27 (`/opt/cursor`) via `env -i` wrapper | ✅ installed (see §11) |
+| Desktop docs | `VPN_Guide.html` / `.md`, dashboard launcher | ✅ on desktop |
+
+---
+
+## 11. Cursor IDE (Electron) — install + crash fixes
+
+Cursor (AI code editor, Electron-based) installed as an **extracted AppImage** at
+`/opt/cursor` (currently **v3.7.27**, ARM64). It will NOT run with a normal launcher
+on this Huawei/Kylin box — it needs a special wrapper.
+
+### Why the special launcher (three Kylin/Huawei ARM64 problems)
+1. **EFAULT machine-ID probe crash** — Cursor runs a command to generate a unique
+ machine ID; the Kylin kernel blocks that memory `write` as suspicious →
+ `Error: EFAULT: bad address in system call argument, write` → instant crash.
+ **Fix:** `env -i` strips the environment (especially D-Bus) so Cursor skips the probe.
+2. **Mali GPU crash (段错误 / segfault)** — hardware rendering conflicts with Huawei's
+ Mali GPU driver (`dmesg`: `mali gpu: kctx ... create/destroyed` at crash time).
+ **Fix:** `--disable-gpu` (software rendering).
+3. **Blocked sandbox** — `--no-sandbox`.
+ Also installed `xdg-desktop-portal` + `xdg-desktop-portal-gtk` (1.6.0) for the
+ Wayland file picker (necessary but not sufficient alone).
+
+### The launcher (already installed)
+- **Wrapper:** `/usr/local/bin/cursor-launch`
+- **Start-menu entry:** `/usr/share/applications/cursor.desktop` → `Exec=/usr/local/bin/cursor-launch %F`
+
+```sh
+#!/bin/sh
+exec env -i \
+ HOME="$HOME" DISPLAY="${DISPLAY:-:0}" PATH="$PATH" \
+ /opt/cursor/usr/share/cursor/cursor --no-sandbox --disable-gpu "$@"
+```
+
+### Updating Cursor to a newer version
+1. Get the latest ARM64 AppImage URL (downloads on Windows through the VPN):
+ ```bash
+ curl -sL -A "Mozilla/5.0" -H "Accept: application/json" \
+ "https://www.cursor.com/api/download?platform=linux-arm64&releaseTrack=stable"
+ # -> JSON with "downloadUrl" (…/Cursor-X.Y.Z-aarch64.AppImage) and "version"
+ curl -L -C - -o Cursor-new.AppImage ""
+ ```
+2. `scp` it to Kylin, then:
+ ```bash
+ chmod +x Cursor-new.AppImage
+ cd /tmp && ./Cursor-new.AppImage --appimage-extract # -> /tmp/squashfs-root
+ sudo rm -rf /opt/cursor && sudo cp -r /tmp/squashfs-root /opt/cursor
+ sudo chown -R root:root /opt/cursor && rm -rf /tmp/squashfs-root
+ ```
+3. **No change needed** to the wrapper or menu entry — paths stay the same.
+ Verify glibc first (must need ≤ 2.31): `objdump -T /opt/cursor/usr/share/cursor/cursor | grep -oE 'GLIBC_[0-9.]+' | sort -V | tail`.
+
+### Cleanup note
+Old AppImages in `~/software/` (`Cursor-3.2.11`, `Cursor-3.7.27`) and the user's
+manual extract `~/software/squashfs-root` (3.2.11) are redundant now that `/opt/cursor`
+is the install — safe to delete to reclaim space.
diff --git a/merge_sheet.py b/merge_sheet.py
new file mode 100644
index 0000000..89c0305
--- /dev/null
+++ b/merge_sheet.py
@@ -0,0 +1,27 @@
+import os
+import pandas as pdc
+from openpyxl import load_workbook
+
+input_folder = r"d:\input"
+
+merged_dfs = []
+total_rows = 0
+
+excel_files = [f for f in os.listdir(input_folder) if f.endswith(".xlsx")]
+
+for excel_file in sorted(excel_files):
+ try:
+ wb = load_workbook(excel_file, data_only=False)
+ sheet_name = wb.sheetnames[0] # Get first sheet name
+ df = pdc.read_excel(excel_file, sheet_name=sheet_name)
+ merged_dfs.append(df)
+ total_rows += len(df)
+ except Exception as e:
+ print(f"Error processing {excel_file}: " + str(e))
+ continue
+
+if merged_dfs:
+ merged_output = pd.concat(merged_dfs, ignore_index=True)
+ output_file = r"d:\output\merged_file.xlsx"
+ merged_output.to_excel(output_file, index=False)
+ print(f"Successfully saved {total_rows} rows to output file")
\ No newline at end of file
diff --git a/mlp_playground.html b/mlp_playground.html
new file mode 100644
index 0000000..a3732a8
--- /dev/null
+++ b/mlp_playground.html
@@ -0,0 +1,403 @@
+
+
+
+
+
+MLP Playground — Neuron → Layer → MLP (section 5.1)
+
+
+
+
+
MLP Playground — Neuron → Layer → MLP
+
+ The reviewer-panel story from section 5.1, made live. Two inputs
+ (income, credit) flow through two panels of 8 reviewers into one final judge:
+ the 2 → 8 → 8 → 1 network. Every line is a weight, every dot is a
+ tanh(w·x + b) opinion. Drag the inputs, hover anything, or press play.
+
+
+
+
+
Inputs (the application)
+
+
+
+
+
+
+
+
+
+
Controls
+
+
+
+
+
Read the colors
+
+
positive weight / activation
+
negative weight / activation
+
thin line = weak, thick = strong
+
+
+
+ Final judge score (tanh)
+ —
+
+
Closer to +1 = likely good tenant, closer to −1 = likely risky.
+
+
+
+
+
+ Hover a line to see its weight, or a dot to see that reviewer's
+ w·x + b and squashed opinion. Layers left→right: form → junior panel → senior panel → final judge.
+
+
+
+
+
+
+
+
+
diff --git a/temp.txt b/temp.txt
new file mode 100644
index 0000000..e69de29
diff --git a/temp_script.txt b/temp_script.txt
new file mode 100644
index 0000000..0d744a6
Binary files /dev/null and b/temp_script.txt differ
diff --git a/website/CLAUDE.md b/website/CLAUDE.md
new file mode 100644
index 0000000..34918b6
--- /dev/null
+++ b/website/CLAUDE.md
@@ -0,0 +1,86 @@
+# Server Information
+
+**SSH Access:** `ssh root@39.102.124.161` (Alibaba Cloud)
+**OS:** CentOS with Baota panel pre-configured
+**Website:** https://www.thereisnospoonadu.com/
+**Web Server:** Tengine (Alibaba's fork of Nginx) — use `tengine` commands, NOT `nginx`
+
+## Gitea (self-hosted Git server)
+
+**Web UI:** https://git.thereisnospoonadu.com
+**Admin username:** beastgitea2026
+**API token:** paste fresh when needed (not stored)
+**SSH remote:** `git@git.thereisnospoonadu.com:beastgitea2026/.git`
+**SSH port:** 222 — configured in `~/.ssh/config` on dev machine
+**Gitea data:** `/var/lib/gitea/` | **Docker container name:** `gitea`
+**Tengine config:** `/etc/tengine/conf.d/gitea.conf`
+
+## Critical File Paths
+
+**Web Root:** `/www/wwwroot/soft_download/`
+**Tengine Config (ACTUAL):** `/etc/tengine/conf.d/thereisnospoonadu.conf`
+ - Site uses HTTPS (port 443), HTTP redirects to HTTPS
+ - Always test changes with: `tengine -t && tengine -s reload`
+
+**Auth File:** `/etc/tengine/.htpasswd`
+**Logs:** Check tengine log paths inside `/etc/tengine/conf.d/thereisnospoonadu.conf`
+
+## User Management
+
+Add a new end user (can login and download files):
+```bash
+# Add user (file already exists — do NOT use -c or it will overwrite all users)
+htpasswd /etc/tengine/.htpasswd username
+
+# Verify a user's password
+htpasswd -v /etc/tengine/.htpasswd username
+
+# Delete a user
+htpasswd -D /etc/tengine/.htpasswd username
+
+# List current users
+cat /etc/tengine/.htpasswd
+```
+Note: `htpasswd` requires `httpd-tools` package (`yum install -y httpd-tools`).
+
+## Architecture
+
+**Download Portal System:**
+- Login page: `/login.html` (public)
+- File listing: `/list/` (requires HTTP Basic Auth via Authorization header)
+- File downloads: Direct links to `.exe`, `.md`, `.pdf`, etc. (NO auth required)
+- Security model: Must login to see file list, but downloads are public once you know the filename
+
+**Key Files:**
+- `/www/wwwroot/soft_download/index.html` - Main file browser
+- `/www/wwwroot/soft_download/login.html` - Login page
+- `/www/wwwroot/soft_download/api/validate` - Auth validation endpoint
+
+## Important Notes
+
+- When making Tengine changes, ALWAYS edit `/etc/tengine/conf.d/thereisnospoonadu.conf`
+- Test against the actual domain: `curl -I https://www.thereisnospoonadu.com/filename`
+- PHP is NOT configured/working on this server - use Tengine-only solutions
+- Large files (170MB+ exe) need native browser downloads, not blob/XHR approach
+
+## Verification Checklist (Before Making Changes)
+
+```bash
+# 1. Verify which Tengine config is active
+tengine -T | grep -A 30 'server_name.*thereisnospoonadu'
+
+# 2. Test current behavior against actual domain
+curl -I https://www.thereisnospoonadu.com/版本升级说明(用记事本打开).md
+
+# 3. After changes: test and reload
+tengine -t && tengine -s reload
+```
+
+## Troubleshooting (Only When Issues Occur)
+
+```bash
+# Find log paths from config
+grep 'access_log\|error_log' /etc/tengine/conf.d/thereisnospoonadu.conf
+```
+
+**Note:** Logs are automatically rotated daily, keeping 7 days of history (compressed).
diff --git a/website/admin_server.py b/website/admin_server.py
new file mode 100644
index 0000000..9dfb847
--- /dev/null
+++ b/website/admin_server.py
@@ -0,0 +1,276 @@
+#!/usr/bin/env python3
+import re
+import os
+from http.server import HTTPServer, BaseHTTPRequestHandler
+from datetime import datetime, timedelta
+from collections import defaultdict
+
+LOG_FILE = "/usr/local/nginx/logs/download_access.log"
+
+LOG_RE = re.compile(
+ r'(?P\S+) - (?P\S+) \[(?P