{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell-000",
   "metadata": {},
   "source": [
    "# Lesson 5 — Interactive Dashboards with Streamlit\n",
    "\n",
    "**Companion notebook** for the Lesson 5 slides. A dashboard is two things: **pure\n",
    "functions** that compute the numbers, and a thin **UI** that calls them. You'll write the\n",
    "portfolio-impact functions behind the Executive Underwriting Portal, then a small\n",
    "Streamlit app, and test it headlessly with Streamlit's `AppTest`.\n",
    "\n",
    "To run an app yourself: save it as `portal.py` and run `streamlit run portal.py`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-001",
   "metadata": {},
   "outputs": [],
   "source": [
    "def check(label, got, expected, tol=1e-6):\n",
    "    \"\"\"Compare your answer with the expected value; prints ✅ or ❌ (never raises).\"\"\"\n",
    "    import math\n",
    "    try:\n",
    "        if isinstance(expected, float) or isinstance(got, float):\n",
    "            ok = math.isclose(float(got), float(expected), rel_tol=tol, abs_tol=tol)\n",
    "        elif isinstance(expected, (list, tuple)) and expected and isinstance(expected[0], str):\n",
    "            ok = list(got) == list(expected)\n",
    "        elif hasattr(expected, \"__len__\") and not isinstance(expected, str):\n",
    "            import numpy as np\n",
    "            ok = np.allclose(np.asarray(got, dtype=float), np.asarray(expected, dtype=float), rtol=tol, atol=tol)\n",
    "        else:\n",
    "            ok = got == expected\n",
    "    except Exception:\n",
    "        ok = False\n",
    "    print((\"✅ \" if ok else \"❌ \") + f\"{label}: got {got!r}, expected {expected!r}\")\n",
    "import textwrap\n",
    "from dataclasses import dataclass\n",
    "from pathlib import Path\n",
    "\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "from sklearn.compose import ColumnTransformer\n",
    "from sklearn.linear_model import GammaRegressor, PoissonRegressor\n",
    "from sklearn.pipeline import Pipeline\n",
    "from sklearn.preprocessing import FunctionTransformer, OneHotEncoder\n",
    "from streamlit.testing.v1 import AppTest\n",
    "\n",
    "MOTOR = Path(\"../data/motor\")\n",
    "EDGES = [16, 21, 25, 30, 40, 50, 65, 75, 121]\n",
    "LABELS = [\"17-20\", \"21-24\", \"25-29\", \"30-39\", \"40-49\", \"50-64\", \"65-74\", \"75+\"]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-002",
   "metadata": {},
   "source": [
    "## 1. Expected losses from the champion model\n",
    "\n",
    "The Course 3 champion GLM (frequency × severity, balanced to the training data), fitted\n",
    "on all five years, gives each 2025 vehicle an expected annual liability loss, trended\n",
    "1.5 years at 4%."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-003",
   "metadata": {},
   "outputs": [],
   "source": [
    "def age_band(age):\n",
    "    return pd.cut(age, EDGES, right=False, labels=LABELS).astype(str)\n",
    "\n",
    "\n",
    "def glm(columns, family):\n",
    "    cats = [c for c in (\"vehicle_style\", \"age_band\") if c in columns]\n",
    "    steps = [(\"cat\", OneHotEncoder(drop=[{\"vehicle_style\": \"Sedan\", \"age_band\": \"40-49\"}[c] for c in cats],\n",
    "                                   handle_unknown=\"ignore\", sparse_output=False), cats),\n",
    "             (\"urban\", \"passthrough\", [\"urban\"])]\n",
    "    if \"annual_mileage\" in columns:\n",
    "        steps.append((\"miles\", FunctionTransformer(lambda m: np.log(m / 12_000)), [\"annual_mileage\"]))\n",
    "    return Pipeline([(\"prep\", ColumnTransformer(steps)),\n",
    "                     (\"glm\", family(alpha=1e-4, solver=\"newton-cholesky\", tol=1e-8, max_iter=1000))])\n",
    "\n",
    "\n",
    "frame = pd.read_parquet(MOTOR / \"model_frame.parquet\").assign(age_band=lambda d: age_band(d[\"driver_age\"]))\n",
    "freq = glm([\"vehicle_style\", \"age_band\", \"urban\", \"annual_mileage\"], PoissonRegressor)\n",
    "freq.fit(frame, frame[\"n_liab\"] / frame[\"exposure\"], glm__sample_weight=frame[\"exposure\"])\n",
    "claims = frame[frame[\"n_liab\"] > 0]\n",
    "sev = glm([\"vehicle_style\", \"urban\"], GammaRegressor)\n",
    "sev.fit(claims, claims[\"inc_liab\"] / claims[\"n_liab\"], glm__sample_weight=claims[\"n_liab\"])\n",
    "f_scale = frame[\"n_liab\"].sum() / (freq.predict(frame) * frame[\"exposure\"]).sum()\n",
    "s_scale = claims[\"inc_liab\"].sum() / (sev.predict(claims) * claims[\"n_liab\"]).sum()\n",
    "\n",
    "book = pd.read_csv(MOTOR / \"book_2025.csv\").assign(age_band=lambda d: age_band(d[\"driver_age\"]))\n",
    "book[\"expected_liab_loss\"] = (freq.predict(book) * f_scale * sev.predict(book) * s_scale * 1.04 ** 1.5).round(2)\n",
    "book[[\"vehicle_id\", \"driver_age\", \"vehicle_style\", \"urban\", \"liab_premium\", \"expected_liab_loss\"]].head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-004",
   "metadata": {},
   "source": [
    "---\n",
    "## Your turn 1 — reprice the book\n",
    "\n",
    "`Adjustments` holds the levers. Write `reprice(book, adj)`: each vehicle's proposed\n",
    "liability premium is its current `liab_premium` × (1 + base_change) × the urban or\n",
    "non-urban factor × the under-25 factor (if `driver_age < 25`) × the Sports factor (if\n",
    "Sports); with a `cap`, the combined factor is clipped to [1 − cap, 1 + cap]. Add the\n",
    "columns `proposed_liab` (rounded to cents) and `change` (proposed ÷ current − 1)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-005",
   "metadata": {},
   "outputs": [],
   "source": [
    "@dataclass(frozen=True)\n",
    "class Adjustments:\n",
    "    base_change: float = 0.0\n",
    "    urban_factor: float = 1.0\n",
    "    non_urban_factor: float = 1.0\n",
    "    young_factor: float = 1.0\n",
    "    sports_factor: float = 1.0\n",
    "    cap: float | None = None\n",
    "\n",
    "\n",
    "def reprice(book, adj):\n",
    "    ...  # your code here\n",
    "\n",
    "\n",
    "PROPOSAL = Adjustments(urban_factor=1.25, non_urban_factor=0.857, cap=0.25)\n",
    "priced = reprice(book, PROPOSAL)\n",
    "check(\"premium change, rate review proposal\",\n",
    "      None if priced is None else round(priced[\"proposed_liab\"].sum() / priced[\"liab_premium\"].sum() - 1, 4), 0.0917)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-006",
   "metadata": {},
   "source": [
    "## Your turn 2 — the headline numbers\n",
    "\n",
    "Write `summary(priced, expense_ratio=0.25)` returning a dict with `loss_ratio_current`\n",
    "and `loss_ratio_proposed` (expected loss ÷ premium), `margin_current` and\n",
    "`margin_proposed` (1 − loss ratio − expense ratio), and `up_over_15` (vehicles whose\n",
    "change exceeds +15%)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-007",
   "metadata": {},
   "outputs": [],
   "source": [
    "def summary(priced, expense_ratio=0.25):\n",
    "    ...  # your code here\n",
    "\n",
    "\n",
    "s = None if priced is None else summary(priced)\n",
    "print(s)\n",
    "check(\"current projected loss ratio\", None if s is None else round(s[\"loss_ratio_current\"], 3), 0.777)\n",
    "check(\"proposed loss ratio\", None if s is None else round(s[\"loss_ratio_proposed\"], 3), 0.712)\n",
    "check(\"proposed margin\", None if s is None else round(s[\"margin_proposed\"], 3), 0.038)\n",
    "check(\"vehicles up more than 15%\", None if s is None else s[\"up_over_15\"], 10902)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-008",
   "metadata": {},
   "source": [
    "## Your turn 3 — which segment is the problem?\n",
    "\n",
    "Loss ratio by vehicle style, current and proposed. Which style is furthest above the\n",
    "68% target even after the proposal? Store its name."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-009",
   "metadata": {},
   "outputs": [],
   "source": [
    "worst = None\n",
    "...  # your code here\n",
    "check(\"worst style after the proposal\", worst, \"Sports\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-010",
   "metadata": {},
   "source": [
    "## 2. A Streamlit app is a script\n",
    "\n",
    "Streamlit reruns the whole script from the top on every interaction. Widgets return\n",
    "their current value; `st.session_state` survives the reruns. `AppTest` runs a script\n",
    "headlessly and lets a test move widgets and read what was displayed."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-011",
   "metadata": {},
   "outputs": [],
   "source": [
    "Path(\"book_2025_scored.csv\").write_text(book.to_csv(index=False))\n",
    "\n",
    "HELLO = '''\n",
    "import streamlit as st\n",
    "st.title(\"Hello\")\n",
    "n = st.slider(\"Vehicles\", 0, 100, 10, key=\"n\")\n",
    "st.metric(\"Twice\", n * 2)\n",
    "'''\n",
    "Path(\"hello.py\").write_text(HELLO)\n",
    "at = AppTest.from_file(str(Path(\"hello.py\").resolve())).run()\n",
    "print(at.metric[0].value)\n",
    "at.slider(key=\"n\").set_value(21).run()\n",
    "print(at.metric[0].value)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-012",
   "metadata": {},
   "source": [
    "## Your turn 4 — a one-lever portal\n",
    "\n",
    "Write `APP`, a Streamlit script that:\n",
    "1. reads `book_2025_scored.csv` once (`@st.cache_data`)\n",
    "2. has a slider `\"Urban factor\"` from 0.80 to 1.50, step 0.01, default 1.00, `key=\"urban\"`\n",
    "3. shows `st.metric(\"Premium change\", f\"{change:+.1%}\")` and\n",
    "   `st.metric(\"Projected loss ratio\", f\"{lr:.1%}\")` for urban × the slider, non-urban × 1\n",
    "\n",
    "The test below moves the slider to 1.25."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-013",
   "metadata": {},
   "outputs": [],
   "source": [
    "APP = HELLO\n",
    "...  # your code here\n",
    "Path(\"portal.py\").write_text(APP)\n",
    "values = None\n",
    "try:\n",
    "    at = AppTest.from_file(str(Path(\"portal.py\").resolve()), default_timeout=30).run()\n",
    "    at.slider(key=\"urban\").set_value(1.25).run()\n",
    "    values = {m.label: m.value for m in at.metric}\n",
    "    print(values)\n",
    "except Exception as exc:          # until APP is written, the test can't find the slider\n",
    "    print(\"not yet:\", type(exc).__name__, exc)\n",
    "check(\"premium change at urban x 1.25\", None if values is None else values.get(\"Premium change\"), \"+14.9%\")\n",
    "check(\"loss ratio at urban x 1.25\", None if values is None else values.get(\"Projected loss ratio\"), \"67.6%\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-014",
   "metadata": {},
   "source": [
    "## Your turn 5 — remember scenarios across reruns\n",
    "\n",
    "Add a button `\"Save scenario\"` (`key=\"save\"`) that appends `{\"urban\": urban, \"change\":\n",
    "change}` to `st.session_state[\"scenarios\"]` (create it as an empty list if it's not\n",
    "there yet), and show the saved scenarios with `st.dataframe`. The test saves two."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-015",
   "metadata": {},
   "outputs": [],
   "source": [
    "APP2 = APP\n",
    "...  # your code here\n",
    "Path(\"portal2.py\").write_text(APP2)\n",
    "saved = None\n",
    "try:\n",
    "    at = AppTest.from_file(str(Path(\"portal2.py\").resolve()), default_timeout=30).run()\n",
    "    at.slider(key=\"urban\").set_value(1.10).run()\n",
    "    at.button(key=\"save\").click().run()\n",
    "    at.slider(key=\"urban\").set_value(1.25).run()\n",
    "    at.button(key=\"save\").click().run()\n",
    "    saved = [round(sc[\"urban\"], 2) for sc in at.session_state[\"scenarios\"]]\n",
    "except Exception as exc:\n",
    "    print(\"not yet:\", type(exc).__name__, exc)\n",
    "check(\"saved scenarios\", saved, [1.10, 1.25])"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
