{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Timeliness against the clinical standard\n",
    "\n",
    "*Protection referrals, 2024*\n",
    "\n",
    "Cassion · data-analysis.cassion.dev\n",
    "\n",
    "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/thecassion/cassion-learning-platform/blob/main/apps/data-analysis/public/datasets/examples/protection-referrals-2024/timeliness.python.en.ipynb)"
   ],
   "id": "cell-000"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## The question and why the clock matters\n",
    "\n",
    "For a GBV survivor seeking health care, 72 hours is not an administrative target.\n",
    "Post-exposure prophylaxis for HIV must start within 72 hours to work, and\n",
    "emergency contraception has a similar window. A referral that arrives on day four\n",
    "is a different event from one that arrives on day two, and an average delay hides\n",
    "that entirely.\n",
    "\n",
    "This dataset is synthetic. No real person is described, and it must never be used\n",
    "as a template for storing real case data.\n",
    "\n",
    "## Setup"
   ],
   "id": "cell-001"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "import numpy as np\n",
    "\n",
    "URL = (\n",
    "    \"https://data-analysis.cassion.dev/datasets/files/\"\n",
    "    \"protection-referrals-2024.v1.csv\"\n",
    ")\n",
    "\n",
    "cases = pd.read_csv(URL, dtype={\"case_id\": \"string\"})\n",
    "print(cases.shape)"
   ],
   "id": "cell-002"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Build the denominator deliberately\n",
    "\n",
    "Timeliness is measured on referrals that were **accepted** — a referral that\n",
    "never reached a service has no service date and belongs in the completion\n",
    "indicator, not this one. Then narrow to the population the standard applies to."
   ],
   "id": "cell-003"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "accepted = cases[cases[\"referral_accepted\"]].copy()\n",
    "\n",
    "gbv_health = accepted[\n",
    "    (accepted[\"case_category\"] == \"gbv\")\n",
    "    & (accepted[\"service_requested\"] == \"health\")\n",
    "].copy()\n",
    "\n",
    "print(f\"all cases                      : {len(cases)}\")\n",
    "print(f\"accepted referrals             : {len(accepted)}\")\n",
    "print(f\"accepted GBV health referrals  : {len(gbv_health)}\")\n",
    "print(f\"  with a service time recorded : {int(gbv_health['days_to_first_service'].notna().sum())}\")"
   ],
   "id": "cell-004"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Each narrowing is a decision, and each one has to be defensible. The 72-hour\n",
    "standard is clinical and applies to health care after sexual violence — applying\n",
    "it to a legal aid referral would be inventing a target.\n",
    "\n",
    "## The missing dates are the analysis\n",
    "\n",
    "Forty accepted referrals across the dataset have no service time recorded. **The\n",
    "timeliness denominator is therefore smaller than the completion denominator**,\n",
    "and how you treat those forty changes the answer."
   ],
   "id": "cell-005"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "missing = gbv_health[\"days_to_first_service\"].isna()\n",
    "print(f\"accepted GBV health referrals with no service time: {int(missing.sum())}\")"
   ],
   "id": "cell-006"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Three defensible treatments, three different numbers:"
   ],
   "id": "cell-007"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "WINDOW_DAYS = 3  # 72 hours\n",
    "\n",
    "within = gbv_health[\"days_to_first_service\"] <= WINDOW_DAYS\n",
    "recorded = gbv_health[\"days_to_first_service\"].notna()\n",
    "\n",
    "treatments = pd.DataFrame([\n",
    "    {\n",
    "        \"treatment\": \"exclude missing (recorded only)\",\n",
    "        \"denominator\": int(recorded.sum()),\n",
    "        \"within 72h\": int((within & recorded).sum()),\n",
    "    },\n",
    "    {\n",
    "        \"treatment\": \"count missing as outside the window\",\n",
    "        \"denominator\": len(gbv_health),\n",
    "        \"within 72h\": int((within & recorded).sum()),\n",
    "    },\n",
    "    {\n",
    "        \"treatment\": \"count missing as inside the window\",\n",
    "        \"denominator\": len(gbv_health),\n",
    "        \"within 72h\": int((within & recorded).sum() + missing.sum()),\n",
    "    },\n",
    "])\n",
    "treatments[\"rate\"] = (treatments[\"within 72h\"] / treatments[\"denominator\"]).round(3)\n",
    "treatments"
   ],
   "id": "cell-008"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Which is right?** The first is the honest default: it reports what is known and\n",
    "states the denominator. The second is the conservative option and is defensible\n",
    "if you have reason to think a missing date means the service was never actually\n",
    "delivered. The third is not defensible — it assumes the best case for the cases\n",
    "you know least about.\n",
    "\n",
    "Report the first, and report the number of records it excluded in the same\n",
    "sentence. A timeliness figure without its denominator is not a measurement.\n",
    "\n",
    "## The distribution, not the mean"
   ],
   "id": "cell-009"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "recorded_times = gbv_health.loc[recorded, \"days_to_first_service\"]\n",
    "\n",
    "print(f\"mean  : {recorded_times.mean():.1f} days\")\n",
    "print(f\"median: {recorded_times.median():.0f} days\")\n",
    "\n",
    "recorded_times.value_counts().sort_index().head(12)"
   ],
   "id": "cell-010"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The mean is close to useless here. The standard is a threshold, so what matters\n",
    "is the share on the correct side of it and how far past it the rest fall. A\n",
    "programme with a mean of four days could have every case at four days, or half at\n",
    "one day and half at seven — and only one of those is a service failure."
   ],
   "id": "cell-011"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "bands = pd.cut(\n",
    "    recorded_times,\n",
    "    bins=[-1, 3, 7, 14, np.inf],\n",
    "    labels=[\"within 72h\", \"4-7 days\", \"8-14 days\", \"over 14 days\"],\n",
    ")\n",
    "(bands.value_counts(normalize=True).sort_index() * 100).round(1)"
   ],
   "id": "cell-012"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Where the delay is"
   ],
   "id": "cell-013"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def timeliness(df, by, window=WINDOW_DAYS):\n",
    "    known = df[df[\"days_to_first_service\"].notna()]\n",
    "    out = known.groupby(by).agg(\n",
    "        referrals=(\"days_to_first_service\", \"size\"),\n",
    "        within=(\"days_to_first_service\", lambda s: (s <= window).sum()),\n",
    "        median_days=(\"days_to_first_service\", \"median\"),\n",
    "    )\n",
    "    out[\"within 72h\"] = (out[\"within\"] / out[\"referrals\"]).round(3)\n",
    "    return out.sort_values(\"within 72h\")\n",
    "\n",
    "timeliness(gbv_health, \"admin2\")"
   ],
   "id": "cell-014"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Small denominators again — the areas contribute between six and twenty-seven\n",
    "referrals each, so the ordering between them is weak and the smallest is not\n",
    "interpretable at all. Report the areas, report the counts, and resist ranking\n",
    "them."
   ],
   "id": "cell-015"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "timeliness(accepted[accepted[\"service_requested\"] == \"health\"], \"case_category\")"
   ],
   "id": "cell-016"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## What this cannot tell you\n",
    "\n",
    "The clock here starts at the referral and stops at the first service. It does not\n",
    "start at the incident, because this dataset deliberately holds no incident date —\n",
    "and under GBV information management principles it should not.\n",
    "\n",
    "That means **a survivor who reached a caseworker on day five and a service on day\n",
    "six appears here as a one-day referral**, well inside the window, while the\n",
    "clinical window had already closed. The indicator measures the referral pathway,\n",
    "not the survivor's total time to care, and a report that conflates the two\n",
    "overstates what the programme achieved.\n",
    "\n",
    "Say that in the limitations. It is the difference between an honest pathway\n",
    "indicator and a claim about clinical outcomes the data cannot support.\n",
    "\n",
    "## What to report\n",
    "\n",
    "The share within 72 hours, its denominator, the number of accepted referrals\n",
    "excluded for want of a service date, the distribution rather than the mean, and\n",
    "an explicit statement that the clock starts at referral rather than at incident."
   ],
   "id": "cell-017"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "file_extension": ".py"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
