{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "a1l7-title",
   "metadata": {},
   "source": [
    "# A1 · L7 — The honest number\n",
    "\n",
    "Every score so far was measured on the markets the model was fitted to. This\n",
    "notebook keeps some back instead — and then shows why keeping back *once* is\n",
    "not enough.\n",
    "\n",
    "Every figure and number this notebook produces is what the lesson prints."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "a1l7-header",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T09:12:03.945061Z",
     "iopub.status.busy": "2026-08-19T09:12:03.944874Z",
     "iopub.status.idle": "2026-08-19T09:12:04.215190Z",
     "shell.execute_reply": "2026-08-19T09:12:04.214502Z"
    }
   },
   "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",
    "\n",
    "from pathlib import Path\n",
    "REL = 'public/datasets/a1-regression/advertising-media-mix.csv'\n",
    "here = Path.cwd()\n",
    "LOCAL = next((p / REL for p in [here, *here.parents] if (p / REL).exists()), None)\n",
    "CSV = LOCAL or 'https://raw.githubusercontent.com/Dr-Shashank-S-Sharma/expedify-ai-courses/main/datasets/a1-regression/advertising-media-mix.csv'"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "a1l7-split",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T09:12:04.225233Z",
     "iopub.status.busy": "2026-08-19T09:12:04.223005Z",
     "iopub.status.idle": "2026-08-19T09:12:04.435460Z",
     "shell.execute_reply": "2026-08-19T09:12:04.435216Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "fitted on 140 markets, scored on 60 it never saw\n",
      "  training   RMSE 1.733k   R2 0.876\n",
      "  held out   RMSE 1.566k   R2 0.914\n"
     ]
    }
   ],
   "source": [
    "#| caption: Fit on 70% of the markets, score on the 30% never seen\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "\n",
    "df = pd.read_csv(CSV)\n",
    "y = df['sales'].to_numpy()\n",
    "n = len(df)\n",
    "X = df[['tv_spend', 'radio_spend']].to_numpy()   # the model L6 chose\n",
    "\n",
    "def fit_and_score(X, train, test):\n",
    "    \"\"\"Fit on the training rows only, then score on BOTH sets.\n",
    "\n",
    "    The whole discipline of this lesson is in one line: `coef` is computed from\n",
    "    X[train] and never sees X[test].\n",
    "    \"\"\"\n",
    "    design = lambda rows: np.column_stack([np.ones(len(rows)), X[rows]])\n",
    "    coef, *_ = np.linalg.lstsq(design(train), y[train], rcond=None)\n",
    "\n",
    "    def scores(rows):\n",
    "        err = y[rows] - design(rows) @ coef\n",
    "        rmse = float(np.sqrt((err ** 2).mean()))\n",
    "        r2 = 1 - (err ** 2).sum() / ((y[rows] - y[rows].mean()) ** 2).sum()\n",
    "        return rmse, float(r2)\n",
    "\n",
    "    return scores(train), scores(test)\n",
    "\n",
    "TRAIN_SHARE = 0.7\n",
    "cut = int(TRAIN_SHARE * n)\n",
    "order = np.random.default_rng(42).permutation(n)\n",
    "train, test = order[:cut], order[cut:]\n",
    "\n",
    "(train_rmse, train_r2), (test_rmse, test_r2) = fit_and_score(X, train, test)\n",
    "print(f'fitted on {len(train)} markets, scored on {len(test)} it never saw')\n",
    "print(f'  training   RMSE {train_rmse:.3f}k   R2 {train_r2:.3f}')\n",
    "print(f'  held out   RMSE {test_rmse:.3f}k   R2 {test_r2:.3f}')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "a1l7-split-fig",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T09:12:04.437883Z",
     "iopub.status.busy": "2026-08-19T09:12:04.437778Z",
     "iopub.status.idle": "2026-08-19T09:12:04.485833Z",
     "shell.execute_reply": "2026-08-19T09:12:04.485608Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "n_markets = 200\n",
      "n_train = 140\n",
      "n_test = 60\n",
      "train_share = 70%\n",
      "train_rmse = 1.73\n",
      "test_rmse = 1.57\n",
      "train_r2 = 0.876\n",
      "test_r2 = 0.914\n",
      "insample_rmse = 1.67\n",
      "insample_r2 = 0.897\n",
      "figure honest-split -> the-honest-number.honest-split.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "# hide — metrics + figure\n",
    "record('n_markets', n)\n",
    "record('n_train', len(train))\n",
    "record('n_test', len(test))\n",
    "record('train_share', f'{TRAIN_SHARE:.0%}')\n",
    "record('train_rmse', round(train_rmse, 2))\n",
    "record('test_rmse', round(test_rmse, 2))\n",
    "record('train_r2', round(train_r2, 3))\n",
    "record('test_r2', round(test_r2, 3))\n",
    "\n",
    "# The in-sample score of the chosen model on all 200 markets — the number this\n",
    "# path would have quoted if the lesson had stopped at L6.\n",
    "every = np.arange(n)\n",
    "(insample_rmse, insample_r2), _ = fit_and_score(X, every, every)\n",
    "record('insample_rmse', round(insample_rmse, 2))\n",
    "record('insample_r2', round(insample_r2, 3))\n",
    "\n",
    "def plot(ax):\n",
    "    ax.bar(['fitted on these\\n(training)', 'never seen\\n(held out)'],\n",
    "           [train_rmse, test_rmse], width=0.5)\n",
    "    for i, v in enumerate([train_rmse, test_rmse]):\n",
    "        ax.annotate(f'{v:.2f}k', xy=(i, v), xytext=(0, 4), textcoords='offset points',\n",
    "                    ha='center', fontsize=9)\n",
    "    ax.set_ylabel('RMSE (thousands of units)')\n",
    "    ax.set_ylim(0, max(train_rmse, test_rmse) * 1.25)\n",
    "\n",
    "save_fig('honest-split', plot, figsize=(7, 3.4))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a1l7-md-overfit",
   "metadata": {},
   "source": [
    "## 1. Give the model something to memorise\n",
    "\n",
    "Two real columns and 140 training markets leave almost nothing to memorise. So hand\n",
    "the model the junk columns from the previous lesson — one at a time — and score it\n",
    "on both sets after each one."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "a1l7-overfit",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T09:12:04.487216Z",
     "iopub.status.busy": "2026-08-19T09:12:04.487114Z",
     "iopub.status.idle": "2026-08-19T09:12:04.592196Z",
     "shell.execute_reply": "2026-08-19T09:12:04.591723Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      " junk  train R2  held-out R2  held-out RMSE\n",
      "    0     0.876        0.914          1.57k\n",
      "   12     0.886        0.906          1.63k\n",
      "   24     0.899        0.889          1.78k\n",
      "   36     0.908        0.876          1.87k\n",
      "   48     0.920        0.858          2.01k\n",
      "   60     0.931        0.832          2.19k\n"
     ]
    }
   ],
   "source": [
    "#| caption: Training score against held-out score, as junk columns pile up\n",
    "MAX_JUNK, DRAWS = 60, 20\n",
    "rng = np.random.default_rng(5)\n",
    "junk = rng.standard_normal((DRAWS, MAX_JUNK, n))\n",
    "\n",
    "steps = list(range(0, MAX_JUNK + 1, 4))\n",
    "curve = {'train_r2': [], 'test_r2': [], 'train_rmse': [], 'test_rmse': []}\n",
    "for j in steps:\n",
    "    runs = [fit_and_score(np.column_stack([X] + [junk[d, k] for k in range(j)]), train, test)\n",
    "            for d in range(DRAWS)]\n",
    "    curve['train_rmse'].append(float(np.mean([tr[0] for tr, _ in runs])))\n",
    "    curve['train_r2'].append(float(np.mean([tr[1] for tr, _ in runs])))\n",
    "    curve['test_rmse'].append(float(np.mean([te[0] for _, te in runs])))\n",
    "    curve['test_r2'].append(float(np.mean([te[1] for _, te in runs])))\n",
    "\n",
    "print(f\"{'junk':>5s} {'train R2':>9s} {'held-out R2':>12s} {'held-out RMSE':>14s}\")\n",
    "for i, j in enumerate(steps):\n",
    "    if j % 12 == 0:\n",
    "        print(f\"{j:5d} {curve['train_r2'][i]:9.3f} {curve['test_r2'][i]:12.3f} \"\n",
    "              f\"{curve['test_rmse'][i]:13.2f}k\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "a1l7-overfit-fig",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T09:12:04.626692Z",
     "iopub.status.busy": "2026-08-19T09:12:04.599694Z",
     "iopub.status.idle": "2026-08-19T09:12:04.722921Z",
     "shell.execute_reply": "2026-08-19T09:12:04.721632Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "max_junk = 60\n",
      "junk_draws = 20\n",
      "train_r2_junk = 0.931\n",
      "test_r2_junk = 0.832\n",
      "test_rmse_junk = 2.19\n",
      "test_rmse_junk_rise = 0.62\n",
      "n_knobs_junk = 63\n",
      "train_up_steps = 15 of 15\n",
      "test_down_steps = 15 of 15\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "figure overfit -> the-honest-number.overfit.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "# hide — metrics + figure\n",
    "last = len(steps) - 1\n",
    "record('max_junk', MAX_JUNK)\n",
    "record('junk_draws', DRAWS)\n",
    "record('train_r2_junk', round(curve['train_r2'][last], 3))\n",
    "record('test_r2_junk', round(curve['test_r2'][last], 3))\n",
    "record('test_rmse_junk', round(curve['test_rmse'][last], 2))\n",
    "record('test_rmse_junk_rise', round(curve['test_rmse'][last] - curve['test_rmse'][0], 2))\n",
    "record('n_knobs_junk', MAX_JUNK + 3)\n",
    "\n",
    "# \"The two lines go opposite ways\" is prose about a picture, which nothing else\n",
    "# in this pipeline can check. So count the steps and quote the count.\n",
    "up = sum(curve['train_r2'][j] < curve['train_r2'][j + 1] for j in range(last))\n",
    "down = sum(curve['test_r2'][j] > curve['test_r2'][j + 1] for j in range(last))\n",
    "record('train_up_steps', f'{up} of {last}')\n",
    "record('test_down_steps', f'{down} of {last}')\n",
    "\n",
    "def plot(ax):\n",
    "    ax.plot(steps, curve['train_r2'], marker='o', markersize=3.5, label='training R²')\n",
    "    ax.plot(steps, curve['test_r2'], marker='s', markersize=3.5, label='held-out R²')\n",
    "    ax.set_xlabel('Columns of random numbers added')\n",
    "    ax.set_ylabel('R²')\n",
    "    ax.legend(frameon=False, fontsize=9)\n",
    "\n",
    "save_fig('overfit', plot, figsize=(7, 3.8))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a1l7-md-lottery",
   "metadata": {},
   "source": [
    "## 2. One split is a lottery\n",
    "\n",
    "The held-out number above came from one arbitrary shuffle. Deal the cards 200 more\n",
    "times and see how much the answer depends on which markets happened to land where."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "a1l7-lottery",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T09:12:04.728002Z",
     "iopub.status.busy": "2026-08-19T09:12:04.727128Z",
     "iopub.status.idle": "2026-08-19T09:12:04.767304Z",
     "shell.execute_reply": "2026-08-19T09:12:04.766165Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "held-out RMSE over 200 splits:\n",
      "  best  1.22k     worst 2.19k     mean 1.71k\n",
      "average training RMSE 1.65k  vs  average held-out RMSE 1.71k\n"
     ]
    }
   ],
   "source": [
    "#| caption: The same model, 200 different 70/30 splits\n",
    "N_SPLITS = 200\n",
    "trains, tests = [], []\n",
    "for seed in range(N_SPLITS):\n",
    "    o = np.random.default_rng(seed).permutation(n)\n",
    "    (tr_rmse, _), (te_rmse, _) = fit_and_score(X, o[:cut], o[cut:])\n",
    "    trains.append(tr_rmse)\n",
    "    tests.append(te_rmse)\n",
    "trains, tests = np.array(trains), np.array(tests)\n",
    "\n",
    "print(f'held-out RMSE over {N_SPLITS} splits:')\n",
    "print(f'  best  {tests.min():.2f}k     worst {tests.max():.2f}k     mean {tests.mean():.2f}k')\n",
    "print(f'average training RMSE {trains.mean():.2f}k  vs  average held-out RMSE {tests.mean():.2f}k')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "a1l7-lottery-fig",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T09:12:04.771902Z",
     "iopub.status.busy": "2026-08-19T09:12:04.771531Z",
     "iopub.status.idle": "2026-08-19T09:12:04.880984Z",
     "shell.execute_reply": "2026-08-19T09:12:04.880753Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "n_splits = 200\n",
      "test_rmse_best = 1.22\n",
      "test_rmse_worst = 2.19\n",
      "test_rmse_mean = 1.71\n",
      "train_rmse_mean = 1.65\n",
      "optimism = 0.06\n",
      "lottery_spread = 0.98\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "figure split-lottery -> the-honest-number.split-lottery.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "# hide — metrics + figure\n",
    "record('n_splits', N_SPLITS)\n",
    "record('test_rmse_best', round(tests.min(), 2))\n",
    "record('test_rmse_worst', round(tests.max(), 2))\n",
    "record('test_rmse_mean', round(tests.mean(), 2))\n",
    "record('train_rmse_mean', round(trains.mean(), 2))\n",
    "# The difference between the two numbers as PRINTED, so a reader who subtracts\n",
    "# them gets what the lesson says rather than a rounding-error apart from it.\n",
    "record('optimism', round(round(tests.mean(), 2) - round(trains.mean(), 2), 2))\n",
    "record('lottery_spread', round(tests.max() - tests.min(), 2))\n",
    "\n",
    "def plot(ax):\n",
    "    ax.hist(tests, bins=28)\n",
    "    ax.axvline(tests.mean(), linewidth=1.8, color='#2f9e6e', zorder=4)\n",
    "    ax.annotate(f'mean {tests.mean():.2f}k', xy=(tests.mean(), ax.get_ylim()[1] * 0.9),\n",
    "                xytext=(8, 0), textcoords='offset points', fontsize=9, color='#2f9e6e')\n",
    "    ax.set_xlabel('Held-out RMSE (thousands of units)')\n",
    "    ax.set_ylabel(f'Splits (of {N_SPLITS})')\n",
    "\n",
    "save_fig('split-lottery', plot, figsize=(7, 3.6))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a1l7-md-cv",
   "metadata": {},
   "source": [
    "## 3. Cross-validation — every market held out exactly once\n",
    "\n",
    "Cut the markets into five folds. Fit five times, each time holding one fold back.\n",
    "Every market is scored once, by a model that never saw it."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "a1l7-cv",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T09:12:04.882233Z",
     "iopub.status.busy": "2026-08-19T09:12:04.882146Z",
     "iopub.status.idle": "2026-08-19T09:12:04.886178Z",
     "shell.execute_reply": "2026-08-19T09:12:04.884815Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "fold 1: held out 40 markets, RMSE 1.77k\n",
      "fold 2: held out 40 markets, RMSE 1.65k\n",
      "fold 3: held out 40 markets, RMSE 2.07k\n",
      "fold 4: held out 40 markets, RMSE 1.39k\n",
      "fold 5: held out 40 markets, RMSE 1.59k\n",
      "\n",
      "cross-validated RMSE: 1.69k (spread 1.39–2.07)\n"
     ]
    }
   ],
   "source": [
    "#| caption: Five-fold cross-validation\n",
    "K = 5\n",
    "folds = np.array_split(np.random.default_rng(0).permutation(n), K)\n",
    "\n",
    "fold_rmse = []\n",
    "for k in range(K):\n",
    "    held = folds[k]\n",
    "    rest = np.concatenate([folds[j] for j in range(K) if j != k])\n",
    "    _, (rmse, _) = fit_and_score(X, rest, held)\n",
    "    fold_rmse.append(rmse)\n",
    "    print(f'fold {k + 1}: held out {len(held)} markets, RMSE {rmse:.2f}k')\n",
    "\n",
    "print(f'\\ncross-validated RMSE: {np.mean(fold_rmse):.2f}k '\n",
    "      f'(spread {min(fold_rmse):.2f}–{max(fold_rmse):.2f})')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "a1l7-cv-fig",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T09:12:04.887194Z",
     "iopub.status.busy": "2026-08-19T09:12:04.887096Z",
     "iopub.status.idle": "2026-08-19T09:12:04.941487Z",
     "shell.execute_reply": "2026-08-19T09:12:04.941051Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "k_folds = 5\n",
      "cv_rmse = 1.69\n",
      "cv_fold_best = 1.39\n",
      "cv_fold_worst = 2.07\n",
      "cv_vs_lottery_mean = 0.02\n",
      "honest_rmse = 1.69\n",
      "honest_rmse_pct = 12%\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "figure cv-folds -> the-honest-number.cv-folds.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "# hide — metrics + figure\n",
    "record('k_folds', K)\n",
    "record('cv_rmse', round(float(np.mean(fold_rmse)), 2))\n",
    "record('cv_fold_best', round(min(fold_rmse), 2))\n",
    "record('cv_fold_worst', round(max(fold_rmse), 2))\n",
    "record('cv_vs_lottery_mean', round(abs(float(np.mean(fold_rmse)) - tests.mean()), 2))\n",
    "\n",
    "# The number the lesson ends on: what to tell a marketer this model is worth.\n",
    "record('honest_rmse', round(float(np.mean(fold_rmse)), 2))\n",
    "record('honest_rmse_pct', f'{np.mean(fold_rmse) / y.mean():.0%}')\n",
    "\n",
    "def plot(ax):\n",
    "    labels = [f'fold {k + 1}' for k in range(K)]\n",
    "    ax.bar(labels, fold_rmse, width=0.55)\n",
    "    mean = float(np.mean(fold_rmse))\n",
    "    ax.axhline(mean, linestyle='--', linewidth=1.6, color='#2f9e6e')\n",
    "    ax.annotate(f'cross-validated RMSE {mean:.2f}k', xy=(K - 0.5, mean), xytext=(0, 6),\n",
    "                textcoords='offset points', ha='right', fontsize=9, color='#2f9e6e')\n",
    "    ax.set_ylabel('Held-out RMSE (thousands of units)')\n",
    "    ax.set_ylim(0, max(fold_rmse) * 1.3)\n",
    "\n",
    "save_fig('cv-folds', plot, figsize=(7, 3.6))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1b9d9cf2",
   "metadata": {},
   "source": [
    "## 4. Using it — a forecast with an honest interval\n",
    "\n",
    "The point of all of this. Fit the chosen model on every market, then quote a plan's\n",
    "forecast with a range built from the cross-validated error rather than the training\n",
    "error — because the cross-validated error is the one measured on markets the model\n",
    "had never seen."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "7d5bd4c5",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T09:12:04.942975Z",
     "iopub.status.busy": "2026-08-19T09:12:04.942878Z",
     "iopub.status.idle": "2026-08-19T09:12:04.945295Z",
     "shell.execute_reply": "2026-08-19T09:12:04.945001Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "base sales             2.92k units\n",
      "per $1k of TV        +0.0458k units\n",
      "per $1k of radio     +0.1880k units\n",
      "\n",
      "plan: $150k TV + $30k radio\n",
      "forecast              15.42k units\n",
      "honest range         12.04k to 18.81k (±2 × cross-validated RMSE)\n"
     ]
    }
   ],
   "source": [
    "#| caption: The shipped model, and what it says about one plan\n",
    "design = np.column_stack([np.ones(n), X])\n",
    "ship, *_ = np.linalg.lstsq(design, y, rcond=None)\n",
    "base, per_tv, per_radio = ship\n",
    "\n",
    "PLAN_TV, PLAN_RADIO = 150.0, 30.0        # both inside the range the model has seen\n",
    "forecast = base + per_tv * PLAN_TV + per_radio * PLAN_RADIO\n",
    "margin = 2 * np.mean(fold_rmse)          # ~95% of markets, if the errors behave\n",
    "\n",
    "print(f'base sales           {base:6.2f}k units')\n",
    "print(f'per $1k of TV        {per_tv:+.4f}k units')\n",
    "print(f'per $1k of radio     {per_radio:+.4f}k units')\n",
    "print(f'\\nplan: ${PLAN_TV:.0f}k TV + ${PLAN_RADIO:.0f}k radio')\n",
    "print(f'forecast             {forecast:6.2f}k units')\n",
    "print(f'honest range         {forecast - margin:.2f}k to {forecast + margin:.2f}k '\n",
    "      f'(±2 × cross-validated RMSE)')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "768d0416",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-19T09:12:04.946350Z",
     "iopub.status.busy": "2026-08-19T09:12:04.946260Z",
     "iopub.status.idle": "2026-08-19T09:12:04.951334Z",
     "shell.execute_reply": "2026-08-19T09:12:04.951064Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "ship_intercept = 2.92\n",
      "ship_tv = 0.0458\n",
      "ship_radio = 0.188\n",
      "plan_tv = 150\n",
      "plan_radio = 30\n",
      "plan_forecast = 15.4\n",
      "plan_low = 12.0\n",
      "plan_high = 18.8\n",
      "plan_margin = 3.4\n",
      "tv_max_seen = 296.4\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "296.4"
      ]
     },
     "execution_count": 11,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "# hide — metrics for the shipped model, quoted by the closing lesson\n",
    "record('ship_intercept', round(float(base), 2))\n",
    "record('ship_tv', round(float(per_tv), 4))\n",
    "record('ship_radio', round(float(per_radio), 4))\n",
    "record('plan_tv', int(PLAN_TV))\n",
    "record('plan_radio', int(PLAN_RADIO))\n",
    "record('plan_forecast', round(float(forecast), 1))\n",
    "record('plan_low', round(float(forecast - margin), 1))\n",
    "record('plan_high', round(float(forecast + margin), 1))\n",
    "record('plan_margin', round(float(margin), 1))\n",
    "record('tv_max_seen', round(float(df['tv_spend'].max()), 1))"
   ]
  }
 ],
 "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
}
