{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "c9cdfdab",
   "metadata": {},
   "source": [
    "# Introduction to AI · What you cannot write down\n",
    "\n",
    "A task you perform perfectly, in under a second, without being able to say\n",
    "how. Then an honest attempt to write the rules for it.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "1a97a193",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T13:31:14.748986Z",
     "iopub.status.busy": "2026-08-17T13:31:14.748626Z",
     "iopub.status.idle": "2026-08-17T13:31:15.937065Z",
     "shell.execute_reply": "2026-08-17T13:31:15.936827Z"
    }
   },
   "outputs": [],
   "source": [
    "# hide — plumbing so this file runs both in the repo and in Colab.\n",
    "try:\n",
    "    from _figkit import save_fig, record\n",
    "except ImportError:  # Colab — no repo, no problem\n",
    "    def save_fig(name, plot, **kw):\n",
    "        import matplotlib.pyplot as plt\n",
    "        fig, ax = plt.subplots(figsize=kw.get('figsize', (7, 4.2))); plot(ax); plt.show()\n",
    "    def record(key, value):\n",
    "        print(f'{key} = {value}'); return value\n",
    "import numpy as np\n",
    "from sklearn.datasets import load_digits   # bundled — no download\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "5039da55",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T13:31:15.938309Z",
     "iopub.status.busy": "2026-08-17T13:31:15.938178Z",
     "iopub.status.idle": "2026-08-17T13:31:15.947423Z",
     "shell.execute_reply": "2026-08-17T13:31:15.947086Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "n_digits = 1,797\n",
      "img_size = 8x8\n",
      "n_pixels = 64\n",
      "n_classes = 10\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "10"
      ]
     },
     "execution_count": 2,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#| caption: 1,797 handwritten digits, eight pixels square\n",
    "D = load_digits()\n",
    "IMG, Y = D.images, D.target\n",
    "record('n_digits', f'{len(Y):,}')\n",
    "record('img_size', f'{IMG.shape[1]}x{IMG.shape[2]}')\n",
    "record('n_pixels', IMG.shape[1] * IMG.shape[2])\n",
    "record('n_classes', len(set(Y)))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "46dbc2e1",
   "metadata": {},
   "source": [
    "## 1. Read these\n",
    "\n",
    "You will get all ten right, and it will take you about a second.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "bfd8cd38",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T13:31:15.948686Z",
     "iopub.status.busy": "2026-08-17T13:31:15.948581Z",
     "iopub.status.idle": "2026-08-17T13:31:15.996151Z",
     "shell.execute_reply": "2026-08-17T13:31:15.995858Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "figure ten-digits -> what-you-cannot-write-down.ten-digits.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "#| caption: Ten digits, one of each\n",
    "from matplotlib.patches import Rectangle\n",
    "from matplotlib.collections import PatchCollection\n",
    "\n",
    "picks = [int(np.where(Y == d)[0][1]) for d in range(10)]\n",
    "\n",
    "def plot(ax):\n",
    "    # Rectangles, not imshow: imshow embeds a base64 raster and the page's\n",
    "    # SVG guard refuses <image>. Alpha carries the ink so it reads on either theme.\n",
    "    rects, alphas = [], []\n",
    "    for k, idx in enumerate(picks):\n",
    "        ox = k * 9\n",
    "        for r in range(8):\n",
    "            for c in range(8):\n",
    "                v = IMG[idx][r, c] / 16.0\n",
    "                if v > 0.02:\n",
    "                    rects.append(Rectangle((ox + c, r), 1, 1)); alphas.append(v)\n",
    "    pc = PatchCollection(rects, facecolor=(0.91, 0.41, 0.25), edgecolor='none')\n",
    "    pc.set_alpha(None); pc.set_facecolors([(0.91, 0.41, 0.25, a) for a in alphas])\n",
    "    ax.add_collection(pc)\n",
    "    ax.set_xlim(0, 90); ax.set_ylim(8.6, -0.6); ax.set_aspect('equal')\n",
    "    ax.set_xticks([]); ax.set_yticks([])\n",
    "    for s in ax.spines.values(): s.set_visible(False)\n",
    "save_fig('ten-digits', plot, figsize=(7, 1.5))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "119e0b14",
   "metadata": {},
   "source": [
    "## 2. Now write the rule\n",
    "\n",
    "Here are the features a person actually proposes when asked how they tell\n",
    "digits apart — ink in each band, symmetry, how wide it is, whether it has a\n",
    "hole in it. All of them measurable, all of them sensible.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "8bdd7302",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T13:31:15.997477Z",
     "iopub.status.busy": "2026-08-17T13:31:15.997390Z",
     "iopub.status.idle": "2026-08-17T13:31:16.648589Z",
     "shell.execute_reply": "2026-08-17T13:31:16.648389Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "n_features = 10\n",
      "feature_list = total ink, ink in the top, ink in the middle, ink at the bottom, …\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "'total ink, ink in the top, ink in the middle, ink at the bottom, …'"
      ]
     },
     "execution_count": 4,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#| caption: The features a human would name\n",
    "def holes(img):\n",
    "    \"\"\"Enclosed background regions — the thing that makes 0 and 8 obvious.\"\"\"\n",
    "    bg = img < 4; H, W = bg.shape\n",
    "    seen = np.zeros_like(bg, bool); n = 0\n",
    "    for sr in range(H):\n",
    "        for sc in range(W):\n",
    "            if not bg[sr, sc] or seen[sr, sc]: continue\n",
    "            stack, cells, touches = [(sr, sc)], [], False\n",
    "            seen[sr, sc] = True\n",
    "            while stack:\n",
    "                r, c = stack.pop(); cells.append((r, c))\n",
    "                if r in (0, H-1) or c in (0, W-1): touches = True\n",
    "                for dr, dc in ((1,0),(-1,0),(0,1),(0,-1)):\n",
    "                    nr, nc = r+dr, c+dc\n",
    "                    if 0 <= nr < H and 0 <= nc < W and bg[nr, nc] and not seen[nr, nc]:\n",
    "                        seen[nr, nc] = True; stack.append((nr, nc))\n",
    "            if not touches: n += 1\n",
    "    return n\n",
    "\n",
    "def features(img):\n",
    "    ink = img.sum()\n",
    "    return {\n",
    "        'total ink':        ink,\n",
    "        'ink in the top':   img[:3].sum(),\n",
    "        'ink in the middle':img[3:5].sum(),\n",
    "        'ink at the bottom':img[5:].sum(),\n",
    "        'ink on the left':  img[:, :4].sum(),\n",
    "        'ink on the right': img[:, 4:].sum(),\n",
    "        'left-right symmetry': -abs(img[:, :4].sum() - img[:, 4:][:, ::-1].sum()),\n",
    "        'how wide it is':   np.count_nonzero(img.sum(axis=0) > 2),\n",
    "        'how tall it is':   np.count_nonzero(img.sum(axis=1) > 2),\n",
    "        'holes in it':      holes(img),\n",
    "    }\n",
    "\n",
    "NAMES = list(features(IMG[0]))\n",
    "X = np.array([[features(im)[n] for n in NAMES] for im in IMG], float)\n",
    "record('n_features', len(NAMES))\n",
    "record('feature_list', ', '.join(NAMES[:4]) + ', …')\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8b1c52f3",
   "metadata": {},
   "source": [
    "## 3. One rule per digit, chosen as well as possible\n",
    "\n",
    "For each digit, find the single best `IF feature is between a and b THEN it\n",
    "is this digit` rule available. Then two conditions. Then three.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "f39e36d3",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T13:31:16.649661Z",
     "iopub.status.busy": "2026-08-17T13:31:16.649581Z",
     "iopub.status.idle": "2026-08-17T13:31:16.750726Z",
     "shell.execute_reply": "2026-08-17T13:31:16.750490Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "no_rule_fires = 21%\n",
      "rules_contradict = 43%\n",
      "exactly_one_rule = 36%\n",
      "acc_1cond = 2.9%\n",
      "acc_2cond = 14.4%\n",
      "acc_3cond = 21.6%\n",
      "acc_4cond = 23.4%\n",
      "acc_5cond = 24.2%\n",
      "best_rule_acc = 24.2%\n",
      "guessing = 10%\n",
      "figure rules-vs-you -> what-you-cannot-write-down.rules-vs-you.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "#| caption: Hand-style rules, tuned to the best they can be\n",
    "def best_rule(target, k):\n",
    "    \"\"\"Best k-condition rule for one digit: pick the k features whose bands\n",
    "    separate it best, and take the tightest band that keeps 80% of them.\"\"\"\n",
    "    is_t = Y == target\n",
    "    scored = []\n",
    "    for j in range(X.shape[1]):\n",
    "        lo, hi = np.percentile(X[is_t, j], [10, 90])\n",
    "        fires = (X[:, j] >= lo) & (X[:, j] <= hi)\n",
    "        prec = (fires & is_t).sum() / max(fires.sum(), 1)\n",
    "        scored.append((prec, j, lo, hi))\n",
    "    scored.sort(reverse=True)\n",
    "    return [(j, lo, hi) for _, j, lo, hi in scored[:k]]\n",
    "\n",
    "def accuracy(k, tally=None):\n",
    "    rules = {d: best_rule(d, k) for d in range(10)}\n",
    "    right = 0\n",
    "    for i in range(len(Y)):\n",
    "        votes = [d for d, conds in rules.items()\n",
    "                 if all(lo <= X[i, j] <= hi for j, lo, hi in conds)]\n",
    "        if tally is not None:\n",
    "            tally['none' if not votes else 'many' if len(votes) > 1 else 'one'] += 1\n",
    "        if len(votes) == 1 and votes[0] == Y[i]: right += 1\n",
    "    return right / len(Y)\n",
    "\n",
    "# The two failures an expert system actually suffers, counted (OUTLINE L7):\n",
    "# digits NO rule covers, and digits where rules CONTRADICT each other.\n",
    "tally = {'none': 0, 'one': 0, 'many': 0}\n",
    "accuracy(3, tally)\n",
    "record('no_rule_fires', f\"{tally['none']/len(Y):.0%}\")\n",
    "record('rules_contradict', f\"{tally['many']/len(Y):.0%}\")\n",
    "record('exactly_one_rule', f\"{tally['one']/len(Y):.0%}\")\n",
    "\n",
    "curve = [accuracy(k) for k in range(1, 6)]\n",
    "for k, a in zip(range(1, 6), curve):\n",
    "    record(f'acc_{k}cond', f'{a:.1%}')\n",
    "record('best_rule_acc', f'{max(curve):.1%}')\n",
    "record('guessing', f'{1/10:.0%}')\n",
    "\n",
    "def plot(ax):\n",
    "    ax.plot(range(1, 6), [100*c for c in curve], marker='o', lw=2.2)\n",
    "    ax.axhline(10, ls=':', lw=1.2)\n",
    "    ax.text(5, 12, 'guessing', ha='right', fontsize=9)\n",
    "    ax.axhline(97, ls='--', lw=1.2)\n",
    "    ax.text(1.05, 92, 'you, just now, in one second', fontsize=9)\n",
    "    ax.set_xticks(range(1, 6))\n",
    "    ax.set_xlabel('conditions per rule')\n",
    "    ax.set_ylabel('digits identified correctly (%)')\n",
    "    ax.set_ylim(0, 105)\n",
    "save_fig('rules-vs-you', plot, figsize=(7, 3.8))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "550f2466",
   "metadata": {},
   "source": [
    "## 4. Why it cannot work: look at the overlap\n",
    "\n",
    "Take the single feature that best separates any two digits, and plot it.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "e5f0f0bd",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T13:31:16.751894Z",
     "iopub.status.busy": "2026-08-17T13:31:16.751825Z",
     "iopub.status.idle": "2026-08-17T13:31:16.840663Z",
     "shell.execute_reply": "2026-08-17T13:31:16.840402Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "best_feature_1v7 = ink at the bottom\n",
      "overlap_1v7 = 81%\n",
      "figure the-overlap -> what-you-cannot-write-down.the-overlap.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "#| caption: The best feature for telling a 1 from a 7\n",
    "def overlap(a, b):\n",
    "    best = None\n",
    "    for j in range(X.shape[1]):\n",
    "        ma, mb = X[Y == a, j], X[Y == b, j]\n",
    "        lo = max(ma.min(), mb.min()); hi = min(ma.max(), mb.max())\n",
    "        share = (((ma >= lo) & (ma <= hi)).mean() + ((mb >= lo) & (mb <= hi)).mean()) / 2\n",
    "        if best is None or share < best[0]: best = (share, j)\n",
    "    return best\n",
    "\n",
    "share, j = overlap(1, 7)\n",
    "record('best_feature_1v7', NAMES[j])\n",
    "record('overlap_1v7', f'{share:.0%}')\n",
    "\n",
    "def plot(ax):\n",
    "    bins = np.linspace(X[:, j].min(), X[:, j].max(), 26)\n",
    "    ax.hist(X[Y == 1, j], bins=bins, alpha=.75, label='the digit 1')\n",
    "    ax.hist(X[Y == 7, j], bins=bins, alpha=.75, label='the digit 7')\n",
    "    ax.set_xlabel(NAMES[j])\n",
    "    ax.set_ylabel('how many digits')\n",
    "    ax.legend(frameon=False)\n",
    "save_fig('the-overlap', plot, figsize=(7, 3.4))\n"
   ]
  }
 ],
 "metadata": {
  "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.13.5"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
