{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "bdc9311c",
   "metadata": {},
   "source": [
    "# What learning is · When nobody tells you anything\n",
    "\n",
    "Take the target away.\n",
    "\n",
    "Nobody says where the arrow landed. Nobody says whether it was good. There is\n",
    "no coach, no score, and no right answer to check anything against — which\n",
    "sounds like the end of learning, and is not.\n",
    "\n",
    "The robot is shooting **three kinds of arrow** and does not know it. Some are\n",
    "heavy and fall low. Some are light and fly high. Some are warped and veer left.\n",
    "Nobody labels a single shot. This notebook asks what can still be found out."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "f862a326",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T08:40:17.802901Z",
     "iopub.status.busy": "2026-08-19T08:40:17.802744Z",
     "iopub.status.idle": "2026-08-19T08:40:18.126137Z",
     "shell.execute_reply": "2026-08-19T08:40:18.125906Z"
    }
   },
   "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"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3213d9dc",
   "metadata": {},
   "source": [
    "## 1. Three causes nobody wrote down\n",
    "\n",
    "The kinds below are real — they are what makes the arrows land where they do.\n",
    "They are also the thing the rest of this notebook is never allowed to look at.\n",
    "Every method here sees two numbers per arrow, where it landed, and nothing\n",
    "else."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "a3c5ba2b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T08:40:18.127491Z",
     "iopub.status.busy": "2026-08-19T08:40:18.127372Z",
     "iopub.status.idle": "2026-08-19T08:40:18.131744Z",
     "shell.execute_reply": "2026-08-19T08:40:18.131526Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "kinds = 3\n",
      "arrows_each = 30\n",
      "arrows_total = 90\n",
      "wobble_cm = 4.0\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "4.0"
      ]
     },
     "execution_count": 2,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#| caption: Three kinds of arrow, and the only thing anyone gets to see\n",
    "import random\n",
    "\n",
    "WOBBLE = 4.0          # cm: the same irreducible scatter as the other lessons\n",
    "ARROWS_EACH = 30\n",
    "\n",
    "# (name, direction, tension) — the cause, in centimetres.\n",
    "KINDS = [\n",
    "    ('heavy', 0.0, -12.0),    # falls low\n",
    "    ('light', 2.0, 10.0),     # flies high\n",
    "    ('warped', -13.0, -1.0),  # veers left\n",
    "]\n",
    "\n",
    "def shoot_all(scale=1.0, seed=3):\n",
    "    \"\"\"Every arrow: where it landed, and (kept aside, never used) which kind it was.\"\"\"\n",
    "    rng = random.Random(seed)\n",
    "    points, truth = [], []\n",
    "    for kind, (name, dx, dy) in enumerate(KINDS):\n",
    "        for _ in range(ARROWS_EACH):\n",
    "            points.append((dx * scale + rng.gauss(0, WOBBLE),\n",
    "                           dy * scale + rng.gauss(0, WOBBLE)))\n",
    "            truth.append(kind)\n",
    "    return points, truth\n",
    "\n",
    "points, truth = shoot_all()\n",
    "record('kinds', len(KINDS))\n",
    "record('arrows_each', ARROWS_EACH)\n",
    "record('arrows_total', len(points))\n",
    "record('wobble_cm', WOBBLE)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "38ddae30",
   "metadata": {},
   "source": [
    "## 2. All anyone gives you\n",
    "\n",
    "This is the whole of the input. Ninety dots, no colours, no target, no score.\n",
    "Whatever is found has to come out of this picture."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "38360a04",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T08:40:18.132782Z",
     "iopub.status.busy": "2026-08-19T08:40:18.132705Z",
     "iopub.status.idle": "2026-08-19T08:40:18.166776Z",
     "shell.execute_reply": "2026-08-19T08:40:18.166474Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "figure where-they-fell -> when-nobody-tells-you-anything.where-they-fell.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "#| caption: Ninety arrows, and no target to score them against\n",
    "import matplotlib.pyplot as plt\n",
    "import matplotlib.patches as patches\n",
    "\n",
    "def draw_field(ax, shots, colours=None, archer=True):\n",
    "    \"\"\"The same range as lesson one, with the target taken off it.\"\"\"\n",
    "    if archer:\n",
    "        ax.plot([0, 0], [-54, -42], color='#3a3a3a', lw=1.5)\n",
    "        ax.plot([-4, 0, 4], [-60, -54, -60], color='#3a3a3a', lw=1.5)\n",
    "        ax.plot([-8, 0, 5], [-45, -44, -42], color='#3a3a3a', lw=1.5)\n",
    "        ax.add_artist(patches.Circle((0, -38), 3, fc='none', ec='#3a3a3a', lw=1.5))\n",
    "        ax.add_artist(patches.Arc((-9, -45), 7, 15, theta1=90, theta2=270, color='#3a3a3a', lw=1.5))\n",
    "    ax.scatter([p[0] for p in shots], [p[1] for p in shots], s=40,\n",
    "               color=colours if colours is not None else '#8a8a94', alpha=0.8, zorder=2)\n",
    "    ax.set_xlim(-34, 30); ax.set_ylim(-64, 30); ax.set_aspect('equal')\n",
    "    ax.set_xticks([]); ax.set_yticks([]); ax.grid(False)\n",
    "    for side in ('top', 'right', 'bottom', 'left'):\n",
    "        ax.spines[side].set_visible(False)\n",
    "\n",
    "save_fig('where-they-fell', lambda ax: draw_field(ax, points), figsize=(5.0, 5.6))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f408ad66",
   "metadata": {},
   "source": [
    "## 3. Finding groups without being told\n",
    "\n",
    "k-means, written out rather than imported, because it is short enough to read\n",
    "and the point is that nothing in it consults `truth`:\n",
    "\n",
    "1. put `k` centres somewhere;\n",
    "2. give every point to its nearest centre;\n",
    "3. move each centre to the middle of the points it was given;\n",
    "4. repeat until nothing moves."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "3bf1c179",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T08:40:18.168660Z",
     "iopub.status.busy": "2026-08-19T08:40:18.168547Z",
     "iopub.status.idle": "2026-08-19T08:40:18.173989Z",
     "shell.execute_reply": "2026-08-19T08:40:18.173752Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "purity_pct = 98\n",
      "98% of arrows were filed with their own kind — and nothing was told\n"
     ]
    }
   ],
   "source": [
    "#| caption: k-means — twenty lines, and none of them look at the answer\n",
    "def k_means(pts, k, rounds=30):\n",
    "    ordered = sorted(pts)\n",
    "    centres = [ordered[min(len(ordered) - 1, int((i + 0.5) / k * len(ordered)))] for i in range(k)]\n",
    "    assign = [0] * len(pts)\n",
    "    for _ in range(rounds):\n",
    "        for i, p in enumerate(pts):\n",
    "            assign[i] = min(range(k),\n",
    "                            key=lambda c: (p[0] - centres[c][0]) ** 2 + (p[1] - centres[c][1]) ** 2)\n",
    "        for c in range(k):\n",
    "            mine = [p for p, a in zip(pts, assign) if a == c]\n",
    "            if mine:\n",
    "                centres[c] = (sum(p[0] for p in mine) / len(mine),\n",
    "                              sum(p[1] for p in mine) / len(mine))\n",
    "    return assign, centres\n",
    "\n",
    "def purity(assign, truth, k):\n",
    "    \"\"\"Share of arrows filed with the kind that dominates their group.\"\"\"\n",
    "    total = 0\n",
    "    for c in range(k):\n",
    "        mine = [t for a, t in zip(assign, truth) if a == c]\n",
    "        if mine:\n",
    "            total += max(mine.count(t) for t in set(mine))\n",
    "    return total / len(truth)\n",
    "\n",
    "found, centres = k_means(points, len(KINDS))\n",
    "score = purity(found, truth, len(KINDS))\n",
    "record('purity_pct', round(100 * score))\n",
    "print(f'{100 * score:.0f}% of arrows were filed with their own kind — and nothing was told')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "e474bd73",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T08:40:18.175017Z",
     "iopub.status.busy": "2026-08-19T08:40:18.174943Z",
     "iopub.status.idle": "2026-08-19T08:40:18.214027Z",
     "shell.execute_reply": "2026-08-19T08:40:18.213728Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "figure the-groups-it-found -> when-nobody-tells-you-anything.the-groups-it-found.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "#| caption: The groups it found, on the same field\n",
    "def plot_found(ax):\n",
    "    tones = ['#ee785b', '#2f9e6e', '#3b6fd4']\n",
    "    draw_field(ax, points, colours=[tones[a] for a in found])\n",
    "    for c in centres:\n",
    "        ax.scatter([c[0]], [c[1]], marker='x', s=90, color='#3a3a3a', linewidths=2, zorder=3)\n",
    "    ax.legend(handles=[plt.Line2D([], [], marker='o', ls='', color=tones[i], label=f'group {i + 1}')\n",
    "                       for i in range(len(KINDS))], frameon=False, fontsize=10, loc='upper right')\n",
    "\n",
    "save_fig('the-groups-it-found', plot_found, figsize=(5.0, 5.6))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dad46448",
   "metadata": {},
   "source": [
    "## 4. The question the data cannot answer\n",
    "\n",
    "Nothing above told it there were three. It was *asked* for three.\n",
    "\n",
    "So ask for two, and for four, and for six, and score each by how tight the\n",
    "groups are — the total squared distance from every arrow to its own centre.\n",
    "The obvious hope is that the score gets worse when you ask for the wrong\n",
    "number. It does not."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "d1f4f053",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T08:40:18.215476Z",
     "iopub.status.busy": "2026-08-19T08:40:18.215392Z",
     "iopub.status.idle": "2026-08-19T08:40:18.286263Z",
     "shell.execute_reply": "2026-08-19T08:40:18.286060Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "1 groups -> tightness 15,282\n",
      "2 groups -> tightness 7,477\n",
      "3 groups -> tightness 2,611\n",
      "4 groups -> tightness 2,296\n",
      "5 groups -> tightness 1,850\n",
      "6 groups -> tightness 1,462\n",
      "8 groups -> tightness 1,225\n",
      "tight_two = 7477\n",
      "tight_three = 2611\n",
      "tight_eight = 1225\n",
      "k_asked_max = 8\n",
      "drop_to_three = 4,866\n",
      "drop_to_four = 315\n",
      "figure how-many-groups -> when-nobody-tells-you-anything.how-many-groups.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "#| caption: Tightness against the number of groups asked for\n",
    "def tightness(pts, assign, centres):\n",
    "    return sum((p[0] - centres[a][0]) ** 2 + (p[1] - centres[a][1]) ** 2\n",
    "               for p, a in zip(pts, assign))\n",
    "\n",
    "ks = [1, 2, 3, 4, 5, 6, 8]\n",
    "scores = []\n",
    "for k in ks:\n",
    "    a, c = k_means(points, k)\n",
    "    scores.append(tightness(points, a, c))\n",
    "\n",
    "for k, s in zip(ks, scores):\n",
    "    print(f'{k} groups -> tightness {s:,.0f}')\n",
    "\n",
    "record('tight_two', round(scores[ks.index(2)]))\n",
    "record('tight_three', round(scores[ks.index(3)]))\n",
    "record('tight_eight', round(scores[ks.index(8)]))\n",
    "record('k_asked_max', ks[-1])\n",
    "# The bend: what asking for the third group bought, against the fourth.\n",
    "record('drop_to_three', f'{round(scores[ks.index(2)] - scores[ks.index(3)]):,}')\n",
    "record('drop_to_four', f'{round(scores[ks.index(3)] - scores[ks.index(4)]):,}')\n",
    "\n",
    "def plot_ks(ax):\n",
    "    ax.plot(ks, scores, color='#ee785b', linewidth=2.4, marker='o', markersize=6)\n",
    "    ax.set_xlabel('groups asked for')\n",
    "    ax.set_ylabel('total distance to own centre')\n",
    "    ax.set_ylim(0, max(scores) * 1.1)\n",
    "    ax.spines[['top', 'right']].set_visible(False)\n",
    "\n",
    "save_fig('how-many-groups', plot_ks, figsize=(7.0, 3.8))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c7137b51",
   "metadata": {},
   "source": [
    "## 5. What happens when the causes overlap\n",
    "\n",
    "The three kinds above are far apart, which is the kind case. Move them closer\n",
    "together — same method, same number of groups asked for — and watch what the\n",
    "method does about it.\n",
    "\n",
    "It does not complain. It returns groups either way."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "7973c7da",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T08:40:18.287327Z",
     "iopub.status.busy": "2026-08-19T08:40:18.287250Z",
     "iopub.status.idle": "2026-08-19T08:40:18.407719Z",
     "shell.execute_reply": "2026-08-19T08:40:18.407481Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "causes at 1.00 x apart -> 95% filed correctly\n",
      "causes at 0.60 x apart -> 85% filed correctly\n",
      "causes at 0.40 x apart -> 68% filed correctly\n",
      "causes at 0.25 x apart -> 55% filed correctly\n",
      "purity_far = 95\n",
      "purity_close = 55\n",
      "close_scale = 0.25\n",
      "guessing_pct = 33\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "figure when-they-overlap -> when-nobody-tells-you-anything.when-they-overlap.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "#| caption: Purity as the three causes are pushed together\n",
    "scales = [1.0, 0.6, 0.4, 0.25]\n",
    "purities = []\n",
    "for sc in scales:\n",
    "    runs = []\n",
    "    for seed in range(12):\n",
    "        pts, tr = shoot_all(scale=sc, seed=seed)\n",
    "        a, _ = k_means(pts, len(KINDS))\n",
    "        runs.append(purity(a, tr, len(KINDS)))\n",
    "    purities.append(sum(runs) / len(runs))\n",
    "\n",
    "for sc, pu in zip(scales, purities):\n",
    "    print(f'causes at {sc:.2f} x apart -> {100 * pu:.0f}% filed correctly')\n",
    "\n",
    "record('purity_far', round(100 * purities[0]))\n",
    "record('purity_close', round(100 * purities[-1]))\n",
    "record('close_scale', scales[-1])\n",
    "record('guessing_pct', round(100 / len(KINDS)))\n",
    "\n",
    "def plot_overlap(ax):\n",
    "    labels = [f'{s:.2f}x' for s in scales]\n",
    "    bars = ax.bar(labels, [100 * p for p in purities],\n",
    "                  color=['#ee785b'] + ['#9aa1ab'] * (len(scales) - 1))\n",
    "    for b, p in zip(bars, purities):\n",
    "        ax.text(b.get_x() + b.get_width() / 2, 100 * p + 1.5, f'{100 * p:.0f}%',\n",
    "                ha='center', fontsize=10)\n",
    "    ax.axhline(100 / len(KINDS), color='#6b7280', linestyle=':', linewidth=1.2)\n",
    "    ax.text(-0.42, 100 / len(KINDS) + 2.5, 'what guessing would get',\n",
    "            fontsize=9.5, color='#6b7280')\n",
    "    ax.set_ylabel('filed with their own kind (%)')\n",
    "    ax.set_xlabel('how far apart the three causes are')\n",
    "    ax.set_ylim(0, 108)\n",
    "    ax.spines[['top', 'right']].set_visible(False)\n",
    "\n",
    "save_fig('when-they-overlap', plot_overlap, figsize=(7.0, 3.8))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "623ad0ed",
   "metadata": {},
   "source": [
    "## 6. What was learned, and what was not\n",
    "\n",
    "It found the groups. It never found their **names** — nothing in any of this\n",
    "knows that group 1 is the heavy arrows, and nothing could. The names are ours.\n",
    "\n",
    "And there was no accuracy to report, because there was no answer to be right\n",
    "about. `purity` was computed here only because this is a simulation and we\n",
    "happen to hold the truth aside. On real data nobody has it — which is exactly\n",
    "why the number of groups has to be argued for rather than measured."
   ]
  }
 ],
 "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
}
