{ "cells": [ { "cell_type": "markdown", "id": "scalar-autograd-simple-01", "metadata": {}, "source": [ "# Scalar autograd: PyTorch, from scratch, then a fused sigmoid\n", "\n", "We will start with one graph:\n", "\n", "$$\n", "m=wx,\\qquad a=m+b,\\qquad e=a-y,\\qquad L=e^2\n", "$$\n", "\n", "with $w=2$, $x=3$, $b=1$, and $y=10$.\n", "\n", "First we let PyTorch differentiate it. Then we build the smallest useful version of the same idea\n", "ourselves. The point is not to replace PyTorch—it is to see what `.backward()` does. A final optional\n", "example then compares one fused sigmoid operation with the same sigmoid expanded into atomic operations.\n", "\n", "
\n",
" Forward computes left → right. Backward begins at L.grad = 1 and travels right → left.\n",
" The teal number in each box is that node's final ∂L/∂node. On a phone, scroll sideways.\n",
"
\n",
" v.parents owns the ParentLink, and that link points back to u.\n",
" The engine does not need u to keep a list of its children. On a phone, scroll sideways.\n",
"
m = w × x, choose v = m and the parent u = w.\n",
" Backward reads upstream m.grad = −6, reads local ∂m/∂w = x = 3 from that link,\n",
" computes the temporary contribution −6 × 3 = −18, and adds it to w.grad.\n",
" The other parent link uses u = x, local derivative w = 2, and contributes −12 to x.grad.\n",
"\n", " The formal name for any ordering that puts every dependency before the value that uses it is a\n", " topological order. We first understand the readiness rule; the name is secondary.\n", " On a phone, scroll sideways.\n", "
\n", "\n", "First apply the append-after-parents rule to one small part of the graph:\n", "\n", "```text\n", "visit(m):\n", " visit(w) → w has no parents → append w\n", " visit(x) → x has no parents → append x\n", " both parents are ready → append m\n", "```\n", "\n", "Starting from `L` applies that same rule recursively to the whole graph. With our stored parent order,\n", "the traversal produces exactly\n", "\n", "```text\n", "dependency-first: w, x, m, b, a, y, e, L\n", "process back: L, e, y, a, b, m, x, w\n", "```\n", "\n", "Other dependency-safe lists are possible—for example, two independent leaves can swap places.\n", "`seen` matters when a value feeds several later operations: following links from `L` may reach that same\n", "object more than once, but it must be appended and processed only once. `seen` deduplicates `Value`\n", "objects in the node schedule—not edges. If an operation is `w * w`, it still stores two `ParentLink`s,\n", "and backward still processes both contributions. For $r=w^2$ at $w=2$, `w` appears once in the node\n", "schedule, but the two links each contribute $2$; `w.grad += contribution` therefore gives $4$." ] }, { "cell_type": "code", "execution_count": 4, "id": "scalar-autograd-simple-09", "metadata": { "purpose": "Define parent links, local rules, a dependency-safe ordering helper, and backward" }, "outputs": [], "source": [ "class ParentLink:\n", " def __init__(self, value, local_grad):\n", " self.value = value\n", " self.local_grad = float(local_grad)\n", "\n", "class Value:\n", " def __init__(self, data, label=\"\", parents=(), op=\"\"):\n", " self.data = float(data)\n", " self.grad = 0.0\n", " self.label = label\n", " self.parents = tuple(parents)\n", " self.op = op\n", "\n", "def multiply(u, v, label):\n", " return Value(u.data * v.data, label,\n", " parents=(ParentLink(u, v.data),\n", " ParentLink(v, u.data)), op=\"×\")\n", "\n", "def add(u, v, label):\n", " return Value(u.data + v.data, label,\n", " parents=(ParentLink(u, 1.0),\n", " ParentLink(v, 1.0)), op=\"+\")\n", "\n", "def subtract(u, v, label):\n", " return Value(u.data - v.data, label,\n", " parents=(ParentLink(u, 1.0),\n", " ParentLink(v, -1.0)), op=\"−\")\n", "\n", "def square(u, label):\n", " return Value(u.data ** 2, label,\n", " parents=(ParentLink(u, 2 * u.data),), op=\"²\")\n", "\n", "def dependency_safe_order(root):\n", " \"\"\"Return reachable Values with every parent before its output.\"\"\"\n", " safe_order = []\n", " seen = set()\n", "\n", " def append_after_parents(node):\n", " # A shared Value may be reachable from the loss by several paths.\n", " # Visit and append that object only once.\n", " if id(node) in seen:\n", " return\n", " seen.add(id(node))\n", "\n", " # Follow output -> ParentLink -> operand, starting from the loss.\n", " for link in node.parents:\n", " append_after_parents(link.value)\n", "\n", " # Only now are all direct parents earlier in safe_order.\n", " safe_order.append(node)\n", "\n", " append_after_parents(root)\n", " return safe_order\n", "\n", "def backward(root):\n", " safe_order = dependency_safe_order(root)\n", "\n", " # 1. Clear old accumulated gradients, then seed the loss.\n", " for node in safe_order:\n", " node.grad = 0.0\n", " root.grad = 1.0\n", "\n", " # 2. Reverse the safe order. A node's full upstream gradient is\n", " # ready before that node sends contributions to its parents.\n", " steps = []\n", " for output in reversed(safe_order):\n", " # One saved parent link gives one chain-rule update.\n", " for link in output.parents:\n", " parent = link.value\n", " upstream = output.grad\n", " local = link.local_grad\n", " contribution = upstream * local\n", " before = parent.grad\n", " parent.grad += contribution\n", "\n", " # Keep a teaching trace; autograd only needs the update above.\n", " steps.append({\n", " \"output\": output.label,\n", " \"upstream\": upstream,\n", " \"parent\": parent.label,\n", " \"local\": local,\n", " \"downstream\": contribution,\n", " \"before\": before,\n", " \"after\": parent.grad,\n", " })\n", " return steps" ] }, { "cell_type": "markdown", "id": "scalar-autograd-simple-10", "metadata": {}, "source": [ "The visible code separates **finding a safe order** from **doing the calculus**:\n", "\n", "1. `dependency_safe_order(root)` starts at `L`. `append_after_parents` follows each stored link to a\n", " direct operand and calls itself there first. Only after those calls return does it append the current\n", " node. `seen` makes a shared object a no-op on its second visit.\n", "2. `backward(root)` clears every reachable `.grad`, then seeds `L.grad = 1` because\n", " $\\partial L/\\partial L=1$.\n", "3. `reversed(safe_order)` processes `L, e, y, a, b, m, x, w`. At every saved link from output $v$\n", " to parent $u$, it reads the now-complete upstream gradient from `v.grad`, multiplies by the saved\n", " local derivative, and accumulates the result in `u.grad`.\n", "\n", "Leaves such as `w` still appear in the processing list. They simply have no parent links, so there is\n", "nothing further to update when their turn arrives.\n", "\n", "| quantity | where it lives |\n", "|---|---|\n", "| upstream $g_v$ | already accumulated in `v.grad` |\n", "| local $\\partial v/\\partial u$ | saved in `link.local_grad` during the forward pass |\n", "| edge contribution to parent $\\Delta g_u$ | temporary variable `contribution` for this one edge |\n", "| accumulated $g_u$ | updated in `parent.grad` |\n", "\n", "The autograd graph does **not** store a separate downstream gradient forever. It computes one edge\n", "contribution, adds it to the parent's buffer, and that buffer later becomes the upstream gradient for\n", "the parent. Our returned `steps` list is only a teaching log: it copies each contribution and the\n", "before/after values so we can display them.\n", "\n", "Three deliberate boundaries keep this engine small:\n", "\n", "- it assumes an acyclic computation graph (a DAG);\n", "- it seeds a scalar loss with `1`; vector outputs would need an explicit upstream seed;\n", "- it clears reachable `.grad` buffers at the start of every call. PyTorch normally **accumulates**\n", " gradients across `.backward()` calls until you clear them." ] }, { "cell_type": "code", "execution_count": 5, "id": "scalar-autograd-simple-11", "metadata": { "cellView": "form", "jupyter": { "source_hidden": true }, "purpose": "Define the compact graph, backward trace cards, and complete stored-state view" }, "outputs": [], "source": [ "#| echo: false\n", "import importlib.util\n", "import json\n", "import subprocess\n", "import sys\n", "import warnings\n", "from html import escape as escape_html\n", "\n", "if importlib.util.find_spec(\"graphviz\") is None:\n", " subprocess.run(\n", " [sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"graphviz\"],\n", " check=True,\n", " )\n", "\n", "from graphviz import Digraph\n", "from IPython.display import HTML, display\n", "\n", "def draw_graph(root, show_grad=True, min_width=780):\n", " nodes = dependency_safe_order(root)\n", "\n", " dot = Digraph(format=\"svg\")\n", " dot.attr(rankdir=\"LR\", bgcolor=\"transparent\", pad=\"0.15\",\n", " nodesep=\"0.25\", ranksep=\"0.45\")\n", " dot.attr(\"node\", fontname=\"Helvetica\", fontsize=\"11\")\n", " dot.attr(\"edge\", fontname=\"Helvetica\", fontsize=\"9\", color=\"#60777b\")\n", "\n", " for node in nodes:\n", " node_id = \"value_\" + node.label\n", " grad = f\"{node.grad:g}\" if show_grad else \"—\"\n", " fill = \"#e8f7f5\" if show_grad and node.grad != 0 else \"#ffffff\"\n", " border = \"#2C7A7B\" if show_grad and node.grad != 0 else \"#1f3a40\"\n", " dot.node(\n", " node_id,\n", " label=f\"{{ {node.label} | value {node.data:g} | grad {grad} }}\",\n", " shape=\"record\", style=\"rounded,filled\", fillcolor=fill,\n", " color=border, fontcolor=\"#1f3a40\",\n", " )\n", " if node.op:\n", " op_id = \"op_\" + node.label\n", " dot.node(op_id, label=node.op, shape=\"circle\",\n", " width=\"0.32\", height=\"0.32\", margin=\"0.03\",\n", " style=\"filled\", fillcolor=\"#2B6CB0\",\n", " color=\"#2B6CB0\", fontcolor=\"white\")\n", " dot.edge(op_id, node_id)\n", " for link in node.parents:\n", " local_label = (\n", " f\"∂{node.label}/∂{link.value.label}=\"\n", " f\"{link.local_grad:g}\"\n", " )\n", " dot.edge(\n", " \"value_\" + link.value.label,\n", " op_id,\n", " label=local_label,\n", " fontcolor=\"#2B6CB0\",\n", " )\n", " svg = dot.pipe(format=\"svg\").decode(\"utf-8\")\n", " svg = svg.replace(\n", " \"