{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "ba6ac836",
   "metadata": {},
   "source": [
    "# What learning is · Which kind is your problem?\n",
    "\n",
    "Four lessons in, the sorting is easy when somebody hands you a clean case. The\n",
    "one nobody hands you is the case everybody actually has:\n",
    "\n",
    "> **some of it is labelled, and most of it is not.**\n",
    "\n",
    "Three hundred rows somebody marked, forty thousand nobody did. That is the\n",
    "normal condition of every business dataset in the world, and it looks like it\n",
    "wants a clever combination of two lessons.\n",
    "\n",
    "This notebook tests whether it does."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "f6970459",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T08:14:57.443725Z",
     "iopub.status.busy": "2026-08-19T08:14:57.443572Z",
     "iopub.status.idle": "2026-08-19T08:14:57.843980Z",
     "shell.execute_reply": "2026-08-19T08:14:57.843735Z"
    }
   },
   "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": "24dae75a",
   "metadata": {},
   "source": [
    "## 1. The same ninety arrows, mostly unlabelled\n",
    "\n",
    "Lesson three's three kinds of arrow. This time a few of them have been marked\n",
    "by hand — somebody picked up that arrow, saw it was warped, and wrote it down —\n",
    "and the rest have not.\n",
    "\n",
    "The job: put a kind against **all ninety**."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "6162333d",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T08:14:57.845264Z",
     "iopub.status.busy": "2026-08-19T08:14:57.845146Z",
     "iopub.status.idle": "2026-08-19T08:14:57.848991Z",
     "shell.execute_reply": "2026-08-19T08:14:57.848779Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "arrows_total = 90\n",
      "kinds = 3\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "3"
      ]
     },
     "execution_count": 2,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#| caption: Three kinds, ninety arrows, and only a handful marked\n",
    "import random\n",
    "\n",
    "WOBBLE = 4.0\n",
    "EACH = 30\n",
    "KINDS = [('heavy', 0.0, -12.0), ('light', 2.0, 10.0), ('warped', -13.0, -1.0)]\n",
    "\n",
    "def sample(seed):\n",
    "    rng = random.Random(seed)\n",
    "    points, truth = [], []\n",
    "    for kind, (_, dx, dy) in enumerate(KINDS):\n",
    "        for _ in range(EACH):\n",
    "            points.append((dx + rng.gauss(0, WOBBLE), dy + rng.gauss(0, WOBBLE)))\n",
    "            truth.append(kind)\n",
    "    return points, truth\n",
    "\n",
    "record('arrows_total', len(KINDS) * EACH)\n",
    "record('kinds', len(KINDS))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d7976c4a",
   "metadata": {},
   "source": [
    "## 2. Two ways to spend a handful of labels\n",
    "\n",
    "**Labels only.** Ignore that the unlabelled arrows exist. For any arrow, find\n",
    "the nearest marked one and copy its kind. Supervised, and nothing else.\n",
    "\n",
    "**Group first, then name.** Cluster all ninety without looking at any label —\n",
    "that is lesson three, and it needs none. Then use the handful of labels only to\n",
    "put a *name* on each group.\n",
    "\n",
    "The second sounds obviously better. It uses everything, where the first throws\n",
    "most of the data away."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "42598b01",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T08:14:57.850087Z",
     "iopub.status.busy": "2026-08-19T08:14:57.850010Z",
     "iopub.status.idle": "2026-08-19T08:14:58.027628Z",
     "shell.execute_reply": "2026-08-19T08:14:58.027280Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      " 3 labels -> labels only  71.2%   group first  71.9%\n",
      " 6 labels -> labels only  89.0%   group first  89.5%\n",
      "15 labels -> labels only  95.9%   group first  96.1%\n",
      "30 labels -> labels only  96.8%   group first  96.1%\n",
      "90 labels -> labels only 100.0%   group first  96.1%\n",
      "labels_3_only = 71\n",
      "labels_6_only = 89\n",
      "labels_15_only = 96\n",
      "labels_all_only = 100\n",
      "labels_all_grouped = 96\n",
      "grouping_ceiling = 96\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "96"
      ]
     },
     "execution_count": 3,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#| caption: Nearest marked arrow, against grouping first and naming after\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\n",
    "\n",
    "def labels_only(pts, truth, marked):\n",
    "    \"\"\"Copy the kind of the nearest arrow somebody marked.\"\"\"\n",
    "    right = 0\n",
    "    for i, p in enumerate(pts):\n",
    "        nearest = min(marked, key=lambda q: (p[0] - pts[q][0]) ** 2 + (p[1] - pts[q][1]) ** 2)\n",
    "        right += truth[nearest] == truth[i]\n",
    "    return right / len(pts)\n",
    "\n",
    "def group_then_name(pts, truth, marked):\n",
    "    \"\"\"Cluster blind, then let the marked arrows vote on what each group is called.\"\"\"\n",
    "    assign = k_means(pts, len(KINDS))\n",
    "    naming = {}\n",
    "    for g in range(len(KINDS)):\n",
    "        votes = [truth[q] for q in marked if assign[q] == g]\n",
    "        naming[g] = max(set(votes), key=votes.count) if votes else -1\n",
    "    return sum(1 for i in range(len(pts)) if naming[assign[i]] == truth[i]) / len(pts)\n",
    "\n",
    "def compare(n_labels, trials=20):\n",
    "    a, b = [], []\n",
    "    for s in range(trials):\n",
    "        pts, truth = sample(s)\n",
    "        marked = random.Random(1000 + s).sample(range(len(pts)), n_labels)\n",
    "        a.append(labels_only(pts, truth, marked))\n",
    "        b.append(group_then_name(pts, truth, marked))\n",
    "    return 100 * sum(a) / trials, 100 * sum(b) / trials\n",
    "\n",
    "budgets = [3, 6, 15, 30, 90]\n",
    "only, grouped = zip(*(compare(n) for n in budgets))\n",
    "for n, o, g in zip(budgets, only, grouped):\n",
    "    print(f'{n:>2} labels -> labels only {o:5.1f}%   group first {g:5.1f}%')\n",
    "\n",
    "record('labels_3_only', round(only[0]))\n",
    "record('labels_6_only', round(only[1]))\n",
    "record('labels_15_only', round(only[2]))\n",
    "record('labels_all_only', round(only[-1]))\n",
    "record('labels_all_grouped', round(grouped[-1]))\n",
    "record('grouping_ceiling', round(max(grouped)))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "87b13c3b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T08:14:58.028820Z",
     "iopub.status.busy": "2026-08-19T08:14:58.028725Z",
     "iopub.status.idle": "2026-08-19T08:14:58.102211Z",
     "shell.execute_reply": "2026-08-19T08:14:58.101960Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "figure what-a-handful-of-labels-buys -> which-kind-is-your-problem.what-a-handful-of-labels-buys.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "#| caption: The clever combination against the simple thing\n",
    "def plot_labels(ax):\n",
    "    ax.plot(budgets, only, color='#ee785b', linewidth=2.4, marker='o', markersize=7,\n",
    "            label='labels only')\n",
    "    ax.plot(budgets, grouped, color='#3b6fd4', linewidth=2.2, marker='s', markersize=6,\n",
    "            label='group first, then name')\n",
    "    ax.set_xlabel('arrows somebody marked by hand')\n",
    "    ax.set_ylabel('all ninety filed correctly (%)')\n",
    "    ax.set_ylim(60, 104)\n",
    "    ax.legend(frameon=False, fontsize=10, loc='lower right')\n",
    "    ax.spines[['top', 'right']].set_visible(False)\n",
    "\n",
    "save_fig('what-a-handful-of-labels-buys', plot_labels, figsize=(7.2, 4.0))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8dc1d3cf",
   "metadata": {},
   "source": [
    "## 3. It did not help, and the reason is worth more than the result\n",
    "\n",
    "The two lines sit on top of each other, and then the clever one **loses**.\n",
    "\n",
    "With every arrow marked, copying the nearest label is right every time, and\n",
    "grouping-first is still stuck at its ceiling — because it can never be better\n",
    "than its own groups. Naming a group perfectly does nothing about the arrows the\n",
    "grouping put in the wrong group in the first place.\n",
    "\n",
    "That is the general shape of it: **a composite is capped by its weakest part**,\n",
    "and the part doing the work here was never the clever one. Which is exactly the\n",
    "thing this whole path has been asking — not which technique sounds best, but\n",
    "what the feedback you actually have will support."
   ]
  }
 ],
 "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
}
