{ "cells": [ { "cell_type": "markdown", "id": "title", "metadata": {}, "source": [ "# Time-to-event TMLE: readmission when death competes\n", "\n", "## The situation\n", "\n", "A health plan runs a navigation program for adults discharged home from participating hospitals.\n", "A navigator can contact a patient at discharge and again on day 31. The plan wants to know if\n", "navigation lowers unplanned readmission in the first 60 days after discharge.\n", "\n", "Follow-up has two 30-day periods. Three events can end a patient's follow-up.\n", "\n", "| event | what it does to the record | role in the analysis |\n", "| --- | --- | --- |\n", "| first unplanned readmission | ends the time at risk for the outcome | the event of interest |\n", "| death before a recorded readmission | makes a later readmission impossible | a competing event |\n", "| disenrollment from the plan | stops the claims, so the plan cannot see a later readmission or death | censoring |\n", "\n", "Death is not missing data. A patient who dies cannot be readmitted, and more follow-up does not\n", "change that. Disenrollment is different. A disenrolled patient can still be readmitted, but the\n", "plan does not see it.\n", "\n", "The plan records the variables in this order:\n", "\n", "$$\n", "W \\rightarrow A_1 \\rightarrow C_1 \\rightarrow (R_1, D_1) \\rightarrow L_2 \\rightarrow A_2\n", "\\rightarrow C_2 \\rightarrow (R_2, D_2)\n", "$$\n", "\n", "| symbol | column | meaning | recorded for |\n", "| --- | --- | --- | --- |\n", "| $W$ | `age`, `baseline_readiness` | baseline covariates, measured before discharge | every patient |\n", "| $A_1$ | `navigation_p1` | the navigation offer at discharge | every patient |\n", "| $C_1$ | `enrolled_p1` | 1 if the patient stays enrolled through period 1 | every patient |\n", "| $R_1$, $D_1$ | `readmission_p1`, `death_p1` | readmission, or death before readmission, in period 1 | patients enrolled through period 1 |\n", "| $L_2$ | `identified_needs` | the care needs recorded by day 30 | patients at risk on day 31 |\n", "| $A_2$ | `navigation_p2` | the navigation offer on day 31 | patients at risk on day 31 |\n", "| $C_2$, $R_2$, $D_2$ | `enrolled_p2`, `readmission_p2`, `death_p2` | the same events in period 2 | patients at risk on day 31 |\n", "\n", "Each column is an observed variable. The question is about counterfactual quantities, which are\n", "never columns. $R_t^{a}$ is 1 if the patient would be readmitted by the end of period $t$ under\n", "navigation plan $a$, had the patient stayed enrolled. $D_t^{a}$ is the same for death before\n", "readmission.\n", "\n", "### The causal graph\n", "\n", "The first line gives the time order. Each later line lists the direct effects of one node in the\n", "synthetic law of this page. The law has these arrows and no others.\n", "\n", "```text\n", "time order: W A1 C1 (R1, D1) L2 A2 C2 (R2, D2)\n", "\n", "W --> A1, C1, R1, D1, L2, A2, R2, D2\n", "A1 --> C1, R1, D1, L2, A2, R2, D2\n", "L2 --> A2, C2, R2, D2\n", "A2 --> R2, D2\n", "(R1, D1) --> every period-2 node: an event ends follow-up\n", "C1, C2 --> the record: disenrollment hides the later events\n", "```\n", "\n", "| arrow or absent arrow | what it asserts | consequence for the analysis |\n", "| --- | --- | --- |\n", "| $W \\rightarrow A_1, A_2$ | age and readiness change who receives an offer | $W$ confounds both offers |\n", "| $W \\rightarrow R_t, D_t, C_1, L_2$ | the same covariates change the event hazards. `age` alone also changes period-1 disenrollment and the needs | the analysis adjusts for $W$ at every node |\n", "| $A_1 \\rightarrow L_2$ | navigation at discharge raises the recorded needs | $L_2$ is a mediator of the first offer |\n", "| $L_2 \\rightarrow A_2, R_2, D_2$ | needs drive the day-31 offer and the period-2 hazards | $L_2$ confounds the second offer |\n", "| $A_1 \\rightarrow C_1$, $L_2 \\rightarrow C_2$ | navigation and needs change disenrollment | censoring depends on the history, so the analysis must model it |\n", "| $A_1 \\rightarrow R_t, D_t$, $A_2 \\rightarrow R_2, D_2$ | navigation changes both hazards and which cause occurs | these paths make up the effect to estimate |\n", "| $(R_1, D_1) \\rightarrow$ period 2 | an event in period 1 ends the time at risk | readmission and death are absorbing, and death competes with readmission |\n", "| no unmeasured common cause of an offer, disenrollment, or an event | the recorded history holds every common cause | this absence is sequential exchangeability and independent censoring. No data check can confirm it |\n", "| no arrow between patients | one patient's offer does not change another patient's events | this absence is no interference |\n", "\n", "$L_2$ is a mediator for the first offer and a confounder for the second. One regression on both\n", "offers cannot handle it. Adjustment for $L_2$ blocks part of the effect of the first offer.\n", "Omission of $L_2$ leaves the second offer confounded. A sequential method adjusts for $L_2$ at the\n", "second decision only.\n", "\n", "The hazards depend on $W$ and $L_2$, so the effect can differ between patients. These variables\n", "are effect modifiers. The estimands below average over them." ] }, { "cell_type": "markdown", "id": "plan", "metadata": {}, "source": [ "## The question and the estimand\n", "\n", "**Target population.** Adults discharged home from a participating hospital and enrolled in the\n", "plan at discharge.\n", "\n", "**Interventions.** The analysis compares two plans.\n", "\n", "| plan | rule |\n", "| --- | --- |\n", "| always | offer navigation at discharge, and again on day 31 if the patient has had neither event |\n", "| never | offer no navigation at either decision |\n", "\n", "Under both plans, the estimand describes the patients had all of them stayed enrolled. This is a\n", "hypothetical strategy for censoring. It suits disenrollment, because disenrollment hides events\n", "and does not prevent them.\n", "\n", "**Estimands.** For plan $a$ and period $t$, where $t = 1$ ends on day 30 and $t = 2$ on day 60:\n", "\n", "| estimand | definition | what it answers |\n", "| --- | --- | --- |\n", "| readmission incidence $F_R^{a}(t)$ | $P(R_t^{a} = 1)$: readmission before death, by period $t$ | how many patients the plan would see readmitted |\n", "| death incidence $F_D^{a}(t)$ | $P(D_t^{a} = 1)$: death before readmission, by period $t$ | how many patients die first |\n", "| event-free survival $S^{a}(t)$ | $1 - F_R^{a}(t) - F_D^{a}(t)$ | how many patients reach the end of period $t$ alive and not readmitted |\n", "\n", "The effects are the always-minus-never differences. The question is about $F_R$.\n", "\n", "The analysis also reports $F_D$ and $S$, for one reason. If navigation prevents deaths, more patients remain at\n", "risk of readmission. $F_R$ can then rise even when navigation lowers each patient's readmission\n", "hazard. [Young et al. (2020)](https://doi.org/10.1002/sim.8471) call the readmission difference a\n", "total effect. It includes this path through death.\n", "\n", "**Why a simpler estimand does not answer the question.**\n", "\n", "| simpler estimand | why it fails here | where the page shows it |\n", "| --- | --- | --- |\n", "| the readmission share among patients whose offers followed a plan | offers go to patients at higher risk, and disenrolled patients drop out | Step 3 |\n", "| readmission risk with death treated as censoring, such as one minus Kaplan-Meier | it describes a world in which nobody dies. That world needs more assumptions, and the program cannot create it | Step 8 |\n", "| the effect of the discharge offer alone | it describes a different intervention, and $L_2$ makes one regression wrong for two offers | the causal graph |\n", "\n", "**Identification assumptions.**\n", "\n", "| assumption | what it requires here | can the data check it? |\n", "| --- | --- | --- |\n", "| consistency | each recorded offer is the declared navigation version, and each recorded event is the event under that plan | no. It rests on the protocol's version records |\n", "| no interference | one patient's offers do not change another patient's events | no. It rests on the navigator capacity design |\n", "| sequential exchangeability | at each decision, among patients still event-free and enrolled, the offer is independent of the counterfactual events, given $W$, $L_2$, and earlier offers | no. It rests on judgment about the recorded history |\n", "| independent censoring | in each period, staying enrolled is independent of the counterfactual events, given the same history | no. It rests on the same judgment, for disenrollment |\n", "| positivity | every kind of patient has some chance of following each plan and staying enrolled | partly, through the support report of Step 9 |\n", "\n", "Under these assumptions, the sequential g-formula identifies each estimand from the observed\n", "data.\n", "\n", "## Why this method\n", "\n", "Each feature of the method answers one feature of the problem.\n", "\n", "| feature of the problem | method feature | what it costs |\n", "| --- | --- | --- |\n", "| two offers, and $L_2$ is caused by the first and confounds the second | longitudinal TMLE: one regression at each node, from the last period back to the first | one regression per node, per horizon, per cause |\n", "| readmission and death are absorbing | each node's regression uses only the patients at risk entering that node | none |\n", "| death competes with readmission | a cause-specific recursion. A patient who died carries a zero forward for readmission | one backward pass per cause. The causes share the treatment and censoring models |\n", "| disenrollment depends on the history | the targeting step weights by the probability of following the plan and staying enrolled | it needs positivity for staying enrolled |\n", "| 4,000 patients and compact parametric learners | in-sample fits without cross-fitting | inference rests on the Donsker condition, which these learners satisfy |\n", "\n", "For one event type, `outcome=` takes one column per period. For competing events, it takes a\n", "mapping from each cause to its columns.\n", "\n", "| term | plain meaning |\n", "| --- | --- |\n", "| node | one time at which a column is recorded, such as the day-31 offer |\n", "| risk set | the patients who have had neither event and are still enrolled when a node starts |\n", "| nuisance | a model the estimate needs but the question does not ask about, such as the offer model |\n", "| targeting | a small update to each outcome regression, weighted by the treatment and censoring models, that removes first-order bias |\n", "| influence curve | how much each patient moves the estimate. Its variance gives the standard error |\n", "\n", "[Targeting and bounds](../user-guide/methods-learners.md#targeting-and-bounds) and the\n", "[inference entry](../technical-reference/inference.md) define the last two terms. The\n", "[technical entry](../technical-reference/longitudinal-tmle.md#survival-and-competing-risks) gives\n", "the event-process recursion." ] }, { "cell_type": "markdown", "id": "setup-heading", "metadata": {}, "source": [ "## Step 1: set up\n", "\n", "The setup imports the learners and prints the installed `cleverly` version. Every fit passes its\n", "learners and random seed explicitly, so a rerun reproduces the stored outputs." ] }, { "cell_type": "code", "execution_count": 1, "id": "setup", "metadata": { "execution": { "iopub.execute_input": "2026-10-16T02:37:40.914243Z", "iopub.status.busy": "2026-10-16T02:37:40.913991Z", "iopub.status.idle": "2026-10-16T02:37:43.072455Z", "shell.execute_reply": "2026-10-16T02:37:43.070963Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "cleverly 0.1.3\n" ] } ], "source": [ "import pandas as pd\n", "from sklearn.linear_model import LinearRegression, LogisticRegression\n", "\n", "import cleverly\n", "\n", "pd.set_option(\"display.width\", 160)\n", "pd.set_option(\"display.max_columns\", 20)\n", "print(\"cleverly\", cleverly.__version__)" ] }, { "cell_type": "markdown", "id": "setup-reading", "metadata": {}, "source": [ "**What this output tells you.** The stored outputs came from the version named above. A\n", "different version can print different numbers." ] }, { "cell_type": "markdown", "id": "data-heading", "metadata": {}, "source": [ "## Step 2: the data\n", "\n", "The data come from `make_longitudinal_competing`, a synthetic law with a known answer. The code\n", "renames its columns to the program's names. It counts how patients leave the first risk set, and\n", "it prints the true value of each estimand." ] }, { "cell_type": "code", "execution_count": 2, "id": "data", "metadata": { "execution": { "iopub.execute_input": "2026-10-16T02:37:43.074621Z", "iopub.status.busy": "2026-10-16T02:37:43.074312Z", "iopub.status.idle": "2026-10-16T02:37:46.844079Z", "shell.execute_reply": "2026-10-16T02:37:46.842299Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "rows and columns: (4000, 11)\n", "['age', 'baseline_readiness', 'navigation_p1', 'enrolled_p1', 'readmission_p1', 'death_p1', 'identified_needs', 'navigation_p2', 'enrolled_p2', 'readmission_p2', 'death_p2']\n", " age baseline_readiness navigation_p1 enrolled_p1 readmission_p1 death_p1 identified_needs navigation_p2 enrolled_p2 readmission_p2 death_p2\n", "0 0.105 0.891 0.0 1.0 0.0 0.0 -0.347 0.0 1.0 1.0 0.0\n", "1 -0.807 -0.103 1.0 0.0 NaN NaN NaN NaN NaN NaN NaN\n", "2 0.892 1.262 0.0 1.0 0.0 0.0 0.102 1.0 1.0 0.0 0.0\n", "3 -0.929 0.727 0.0 1.0 0.0 0.0 -1.423 1.0 1.0 0.0 0.0\n", "4 -1.136 -0.282 0.0 1.0 1.0 0.0 NaN NaN NaN 1.0 0.0\n", "\n", "disenrolled in period 1: 451\n", "readmitted in period 1: 457\n", "died in period 1: 253\n", "at risk entering period 2: 2839\n", "disenrolled in period 2: 233\n", "\n", "known values of the synthetic law, by plan:\n", " time readmission_always death_always event_free_always readmission_never death_never event_free_never\n", " 1 0.1187 0.0310 0.8503 0.1452 0.1127 0.7421\n", " 2 0.2467 0.0705 0.6828 0.2443 0.2109 0.5448\n" ] } ], "source": [ "from cleverly.datasets import make_longitudinal_competing\n", "\n", "frame, truth = make_longitudinal_competing(n=4_000, seed=53, censoring=True)\n", "frame = frame.rename(\n", " columns={\n", " \"W1\": \"age\",\n", " \"W2\": \"baseline_readiness\",\n", " \"A1\": \"navigation_p1\",\n", " \"C1\": \"enrolled_p1\",\n", " \"R1\": \"readmission_p1\",\n", " \"D1\": \"death_p1\",\n", " \"L2\": \"identified_needs\",\n", " \"A2\": \"navigation_p2\",\n", " \"C2\": \"enrolled_p2\",\n", " \"R2\": \"readmission_p2\",\n", " \"D2\": \"death_p2\",\n", " }\n", ")\n", "print(\"rows and columns:\", frame.shape)\n", "print(list(frame.columns))\n", "print(frame.head().round(3).to_string())\n", "print()\n", "print(\"disenrolled in period 1: \", int(frame[\"enrolled_p1\"].eq(0).sum()))\n", "print(\"readmitted in period 1: \", int(frame[\"readmission_p1\"].eq(1).sum()))\n", "print(\"died in period 1: \", int(frame[\"death_p1\"].eq(1).sum()))\n", "print(\"at risk entering period 2: \", int(frame[\"navigation_p2\"].notna().sum()))\n", "print(\"disenrolled in period 2: \", int(frame[\"enrolled_p2\"].eq(0).sum()))\n", "print()\n", "\n", "# The generator names readmission \"relapse\". The page uses the program's word.\n", "population = {key.replace(\"relapse\", \"readmission\"): value for key, value in truth.items()}\n", "rows = []\n", "for horizon in (1, 2):\n", " row = {\"time\": horizon}\n", " for plan in (\"always\", \"never\"):\n", " readmission = population[f\"cif_regimen[{plan}, readmission @ t={horizon}]\"]\n", " death = population[f\"cif_regimen[{plan}, death @ t={horizon}]\"]\n", " row |= {\n", " f\"readmission_{plan}\": readmission,\n", " f\"death_{plan}\": death,\n", " f\"event_free_{plan}\": 1.0 - readmission - death,\n", " }\n", " rows.append(row)\n", "print(\"known values of the synthetic law, by plan:\")\n", "print(pd.DataFrame(rows).round(4).to_string(index=False))" ] }, { "cell_type": "markdown", "id": "data-reading", "metadata": {}, "source": [ "**What this output tells you.** Each row is one patient. Row 1 disenrolled in period 1, so\n", "every later node is `NaN`. Row 4 was readmitted in period 1, so `readmission_p2` carries the 1\n", "forward and the day-31 offer does not exist.\n", "\n", "In period 1, 451 patients disenrolled, 457 were readmitted, and 253 died. That leaves 2839\n", "patients at risk entering period 2. Of these, 233 disenrolled in period 2.\n", "\n", "| plan | readmission by day 60 | death by day 60 | event-free at day 60 |\n", "| --- | --- | --- | --- |\n", "| always | 0.2467 | 0.0705 | 0.6828 |\n", "| never | 0.2443 | 0.2109 | 0.5448 |\n", "\n", "In this law, navigation lowers the hazard of any event and raises the share of events that are\n", "readmissions. Death by day 60 falls from 0.2109 to 0.0705. Readmission by day 30 falls from 0.1452\n", "to 0.1187, but readmission by day 60 rises from 0.2443 to 0.2467. A real program has no known\n", "values. The comparisons with them below are a teaching device." ] }, { "cell_type": "markdown", "id": "association-heading", "metadata": {}, "source": [ "## Step 3: association first\n", "\n", "This comparison shows why the analysis needs adjustment. Its target is the always-minus-never\n", "difference of each incidence. The naive estimate is the event share among patients whose recorded\n", "offers followed each plan and who stayed enrolled. It assumes that the offers and disenrollment\n", "are unrelated to risk. The causal graph says that they are related, through $W$ and $L_2$." ] }, { "cell_type": "code", "execution_count": 3, "id": "association", "metadata": { "execution": { "iopub.execute_input": "2026-10-16T02:37:46.846867Z", "iopub.status.busy": "2026-10-16T02:37:46.846590Z", "iopub.status.idle": "2026-10-16T02:37:46.871833Z", "shell.execute_reply": "2026-10-16T02:37:46.870124Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "baseline means by the discharge offer:\n", " age baseline_readiness\n", "navigation_p1 \n", "0.0 -0.127 0.179\n", "1.0 0.140 -0.197\n", "\n", "mean identified_needs by the day-31 offer, among patients at risk:\n", "navigation_p2\n", "0.0 -0.104\n", "1.0 0.793\n", "Name: identified_needs, dtype: float64\n", "\n", "share with the event among enrolled followers, always minus never:\n", " readmission t=1: always 0.1345, never 0.1236, difference 0.0109, population -0.0265\n", " readmission t=2: always 0.3317, never 0.2707, difference 0.0610, population 0.0024\n", " death t=1: always 0.0314, never 0.1075, difference -0.0761, population -0.0817\n", " death t=2: always 0.0920, never 0.2425, difference -0.1506, population -0.1404\n" ] } ], "source": [ "print(\"baseline means by the discharge offer:\")\n", "print(frame.groupby(\"navigation_p1\")[[\"age\", \"baseline_readiness\"]].mean().round(3))\n", "print()\n", "at_risk_p2 = frame[\"navigation_p2\"].notna()\n", "needs = frame.loc[at_risk_p2].groupby(\"navigation_p2\")[\"identified_needs\"].mean()\n", "print(\"mean identified_needs by the day-31 offer, among patients at risk:\")\n", "print(needs.round(3))\n", "print()\n", "\n", "# A follower's recorded offers match the plan at every decision the patient reached.\n", "first = {\"always\": frame[\"navigation_p1\"].eq(1), \"never\": frame[\"navigation_p1\"].eq(0)}\n", "second = {\"always\": ~frame[\"navigation_p2\"].eq(0), \"never\": ~frame[\"navigation_p2\"].eq(1)}\n", "followers = {1: first, 2: {plan: first[plan] & second[plan] for plan in first}}\n", "naive_differences = {}\n", "print(\"share with the event among enrolled followers, always minus never:\")\n", "for cause in (\"readmission\", \"death\"):\n", " for horizon in (1, 2):\n", " outcome = frame[f\"{cause}_p{horizon}\"]\n", " shares = {\n", " plan: float(outcome[rows & outcome.notna()].mean())\n", " for plan, rows in followers[horizon].items()\n", " }\n", " naive = naive_differences[cause, horizon] = shares[\"always\"] - shares[\"never\"]\n", " target = population[f\"ate_regimen[always vs never, {cause} @ t={horizon}]\"]\n", " print(\n", " f\" {cause:11s} t={horizon}: always {shares['always']:.4f}, \"\n", " f\"never {shares['never']:.4f}, difference {naive:.4f}, population {target:.4f}\"\n", " )" ] }, { "cell_type": "markdown", "id": "association-reading", "metadata": {}, "source": [ "**What this output tells you.** The groups differ before each offer.\n", "\n", "| comparison | offered | not offered |\n", "| --- | --- | --- |\n", "| mean `age` at the discharge offer | 0.140 | -0.127 |\n", "| mean `baseline_readiness` at the discharge offer | -0.197 | 0.179 |\n", "| mean `identified_needs` at the day-31 offer | 0.793 | -0.104 |\n", "\n", "In this law, older age, lower readiness, and more needs raise the event hazard. The offers\n", "therefore go to patients at higher risk.\n", "\n", "| cause and day | naive difference | population difference |\n", "| --- | --- | --- |\n", "| readmission, day 30 | 0.0109 | -0.0265 |\n", "| readmission, day 60 | 0.0610 | 0.0024 |\n", "| death, day 30 | -0.0761 | -0.0817 |\n", "| death, day 60 | -0.1506 | -0.1404 |\n", "\n", "On this draw, the naive day-30 readmission difference has the wrong sign. The naive share also\n", "drops every disenrolled patient. Disenrollment depends on navigation and needs, so the patients\n", "who remain are not a random subset." ] }, { "cell_type": "markdown", "id": "protocol-heading", "metadata": {}, "source": [ "## Step 4: write the protocol\n", "\n", "A `StudyProtocol` records the scientific design before any model runs.\n", "[Point-treatment TMLE](point-treatment-tmle.ipynb#step-4-write-the-protocol) introduces its\n", "fields. The code starts from `longitudinal_navigation_protocol()`, the two-decision program of the\n", "[shared study design](index.md#the-shared-study-design). It replaces only the fields that this\n", "question changes." ] }, { "cell_type": "code", "execution_count": 4, "id": "protocol", "metadata": { "execution": { "iopub.execute_input": "2026-10-16T02:37:46.874295Z", "iopub.status.busy": "2026-10-16T02:37:46.874052Z", "iopub.status.idle": "2026-10-16T02:37:46.879878Z", "shell.execute_reply": "2026-10-16T02:37:46.878575Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "causal study protocol: schema 1; 9b9cb1dda3683cf0\n", "target population: Adults discharged home from a participating hospital during the enrollment period\n", "eligibility: ['Age 18 years or older', 'Discharged alive', 'Discharged home from a participating hospital', 'Enrolled in the health plan at discharge']\n", "time zero: Hospital discharge, after baseline measurement and before first assignment\n", "treatment strategies: ['Offer navigation at the start of each 30-day period until readmission or death', 'Offer no navigation at either decision']\n", "treatment versions: ['The declared navigation contacts at discharge and on day 31', 'Usual discharge support without navigation contacts']\n", "outcome: First unplanned readmission, and death before a recorded readmission, recorded at the end of each 30-day period\n", "horizon: 30 and 60 days after discharge\n", "intercurrent-event handling: ['Treat death before readmission as a competing event that stays in the history', 'Treat disenrollment before readmission or death as censoring, so the target is the incidence had every patient stayed enrolled (hypothetical strategy)', 'Make no navigation decision after either event', 'Analyze each navigation offer regardless of completed contacts']\n", "interference unit: Individual patient\n", "assumption rationale: ['Recorded history covers the measured common causes of navigation, disenrollment, readmission, and death in each period', 'Version records support consistency at both navigation decisions', 'Reserved navigator capacity supports no interference between patients']\n" ] } ], "source": [ "from dataclasses import replace\n", "\n", "from cleverly.datasets import longitudinal_navigation_protocol\n", "\n", "program = longitudinal_navigation_protocol()\n", "protocol = replace(\n", " program,\n", " eligibility=(*program.eligibility, \"Enrolled in the health plan at discharge\"),\n", " treatment_strategies=(\n", " \"Offer navigation at the start of each 30-day period until readmission or death\",\n", " program.treatment_strategies[1],\n", " ),\n", " treatment_versions=(\n", " \"The declared navigation contacts at discharge and on day 31\",\n", " program.treatment_versions[1],\n", " ),\n", " outcome=(\n", " \"First unplanned readmission, and death before a recorded readmission, \"\n", " \"recorded at the end of each 30-day period\"\n", " ),\n", " horizon=\"30 and 60 days after discharge\",\n", " intercurrent_event_handling=(\n", " \"Treat death before readmission as a competing event that stays in the history\",\n", " \"Treat disenrollment before readmission or death as censoring, so the target is the \"\n", " \"incidence had every patient stayed enrolled (hypothetical strategy)\",\n", " \"Make no navigation decision after either event\",\n", " program.intercurrent_event_handling[2],\n", " ),\n", " assumption_rationale=(\n", " \"Recorded history covers the measured common causes of navigation, disenrollment, \"\n", " \"readmission, and death in each period\",\n", " *program.assumption_rationale[1:],\n", " ),\n", ")\n", "print(\"\\n\".join(protocol.summary_lines()))" ] }, { "cell_type": "markdown", "id": "protocol-reading", "metadata": {}, "source": [ "**What this output tells you.** The fingerprint is `9b9cb1dda3683cf0`. The code changes\n", "seven fields: the eligibility, the treatment strategies and versions, the outcome, the horizon, the\n", "intercurrent-event handling, and the assumption rationale.\n", "\n", "The intercurrent-event handling records the decisions of the estimand section. Death before\n", "readmission is a competing event. Disenrollment is censoring under a hypothetical strategy. No\n", "navigation decision follows either event. The protocol stores text only, so the design of Step 5\n", "must match these entries." ] }, { "cell_type": "markdown", "id": "identify-heading", "metadata": {}, "source": [ "## Step 5: design and identification\n", "\n", "The design places every column at its node. The `outcome=` mapping declares two competing causes,\n", "with one indicator column per period for each. The `censoring=` role declares enrollment in each\n", "period. `time_varying=` places `identified_needs` before the day-31 offer. `RegimeMean` asks for\n", "each incidence under each plan at both horizons. The\n", "[estimands guide](../user-guide/estimands.md) defines the estimand objects." ] }, { "cell_type": "code", "execution_count": 5, "id": "identify", "metadata": { "execution": { "iopub.execute_input": "2026-10-16T02:37:46.882008Z", "iopub.status.busy": "2026-10-16T02:37:46.881804Z", "iopub.status.idle": "2026-10-16T02:37:46.909434Z", "shell.execute_reply": "2026-10-16T02:37:46.907929Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "mean outcome under each declared regime\n", "identified by explicit-adjustment: sequential g-formula under the declared treatment regimen\n", "history at navigation_p1: ['age', 'baseline_readiness']\n", "history at navigation_p2: ['age', 'baseline_readiness', 'identified_needs', 'navigation_p1']\n", "required nuisances: ['sequential_outcome_regressions', 'treatment_and_censoring_mechanisms']\n", "assumptions:\n", " - consistency: each observed history equals the potential history under its realized regimen\n", " - no interference: one unit's potential history does not depend on other units' regimens\n", " - sequential exchangeability given the recorded history at every node\n", " - sequential positivity for treatment and remaining under observation\n", "causal study protocol: schema 1; 9b9cb1dda3683cf0\n" ] } ], "source": [ "from cleverly import CausalStudy, LongitudinalTreatment, RegimeMean\n", "\n", "# Step 7 reuses the offers and the history with another censoring definition.\n", "navigation_nodes = {\n", " \"treatment\": (\"navigation_p1\", \"navigation_p2\"),\n", " \"baseline\": (\"age\", \"baseline_readiness\"),\n", " \"time_varying\": ((), (\"identified_needs\",)),\n", "}\n", "plans = {\"always\": 1, \"never\": 0}\n", "levels_by_plan = RegimeMean(plans, reference=\"never\", horizons=(1, 2))\n", "study = CausalStudy(\n", " frame,\n", " design=LongitudinalTreatment(\n", " outcome={\n", " \"readmission\": (\"readmission_p1\", \"readmission_p2\"),\n", " \"death\": (\"death_p1\", \"death_p2\"),\n", " },\n", " censoring=(\"enrolled_p1\", \"enrolled_p2\"),\n", " **navigation_nodes,\n", " ),\n", " protocol=protocol,\n", ")\n", "effect = study.identify(levels_by_plan)\n", "print(effect.summary(protocol=\"fingerprint\"))" ] }, { "cell_type": "markdown", "id": "identify-reading", "metadata": {}, "source": [ "**What this output tells you.** The identification is the sequential g-formula. The\n", "history at the day-31 offer is `age`, `baseline_readiness`, `identified_needs`, and the first\n", "offer. The first offer has no `identified_needs`, because the needs are recorded later. The\n", "required nuisances are the sequential outcome regressions and the treatment-and-censoring\n", "mechanisms. The printed assumptions are those of the estimand section. \"Remaining under\n", "observation\" is the independent-censoring assumption for enrollment." ] }, { "cell_type": "markdown", "id": "estimate-heading", "metadata": {}, "source": [ "## Step 6: estimate the incidences\n", "\n", "The fit uses compact parametric learners on all rows, without cross-fitting.\n", "\n", "| setting | value | why |\n", "| --- | --- | --- |\n", "| `outcome_learner` | logistic regression | the last node regresses a binary event |\n", "| `pseudo_learner` | linear regression | the earlier node regresses a predicted probability, not an event |\n", "| `treatment_learner`, `censoring_learner` | logistic regression on the history | in this law, these have the form of the true offer and enrollment models |\n", "| `CrossFitting(enabled=False)` | in-sample | these learners satisfy the Donsker condition, so in-sample fits keep influence-curve inference valid under the [remaining conditions](../technical-reference/cv-tmle.md#what-this-solves). With misspecified outcome regressions, the standard error can be conservative |\n", "| `Runtime(random_state=41, n_jobs=1)` | fixed seed, one process | the output is reproducible |\n", "\n", "The outcome regressions do not match the law. Its hazards contain a `tanh` term, and each cause is\n", "a hazard times a cause share. TMLE is doubly robust: the estimate stays consistent when the offer\n", "and enrollment models are correct. Here that condition holds by construction. A real analysis\n", "cannot verify it." ] }, { "cell_type": "code", "execution_count": 6, "id": "estimate", "metadata": { "execution": { "iopub.execute_input": "2026-10-16T02:37:46.911770Z", "iopub.status.busy": "2026-10-16T02:37:46.911500Z", "iopub.status.idle": "2026-10-16T02:37:47.682349Z", "shell.execute_reply": "2026-10-16T02:37:47.680063Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Longitudinal TMLE (2 time points, n = 4000)\n", "\n", "parameter estimate std. error 95% CI p-value\n", "-------------------------------------- -------- ---------- ---------------- -------\n", "cif_regimen[always, readmission @ t=1] 0.1192 0.0076 [0.1042, 0.1341] <1e-4 \n", "cif_regimen[always, readmission @ t=2] 0.2508 0.0116 [0.2281, 0.2735] <1e-4 \n", "cif_regimen[always, death @ t=1] 0.0303 0.0042 [0.0220, 0.0385] <1e-4 \n", "cif_regimen[always, death @ t=2] 0.0722 0.0070 [0.0585, 0.0859] <1e-4 \n", "cif_regimen[never, readmission @ t=1] 0.1364 0.0084 [0.1200, 0.1529] <1e-4 \n", "cif_regimen[never, readmission @ t=2] 0.2285 0.0141 [0.2008, 0.2561] <1e-4 \n", "cif_regimen[never, death @ t=1] 0.1113 0.0076 [0.0965, 0.1262] <1e-4 \n", "cif_regimen[never, death @ t=2] 0.2098 0.0140 [0.1824, 0.2373] <1e-4 \n", "\n", " time points: 2\n", " outcome: competing risks, absorbing causes readmission, death at readmission_p1, readmission_p2, death_p1, death_p2\n", " horizons reported: t = 1, 2\n", " causes reported: readmission, death\n", " outcome family: binomial\n", " regimens: always=(1/1), never=(0/0)\n", " cross-fitting: none -- nuisances fitted in sample\n", " g_bounds: fixed [0.01, 1] on each cumulative treatment-and-censoring probability (package default; R ltmle-compatible heuristic -- inspect truncation diagnostics)\n", " survival model: iterated_mean\n", " targeting: iterative\n", " isotonic projection: none\n", " confidence level: 95%\n", " random_state: 41\n", " causal estimand: mean outcome under each declared regime\n", " identification: explicit-adjustment; sequential g-formula under the declared treatment regimen\n", " required nuisances: sequential_outcome_regressions, treatment_and_censoring_mechanisms\n", " identification assumptions: consistency: each observed history equals the potential history under its realized regimen; no interference: one unit's potential history does not depend on other units' regimens; sequential exchangeability given the recorded history at every node; sequential positivity for treatment and remaining under observation\n", " causal study protocol: schema 1; 9b9cb1dda3683cf0\n", " data 0243e934597a0cc0 | folds 4dc224c385945072 | cleverly 0.1.3 | 2026-10-16T02:37:47+00:00\n", " always: 938 of 4000 units followed it throughout; max weight 14.5, effective n 786; max truncated share 0.0% at t=1\n", " never: 671 of 4000 units followed it throughout; max weight 28.7, effective n 529; max truncated share 0.0% at t=1\n", "\n", " simultaneous 95% bands (multiplier bootstrap, critical value 2.679 vs 1.960 pointwise):\n", " cif_regimen[always, readmission @ t=1] [0.0987, 0.1396]\n", " cif_regimen[always, readmission @ t=2] [0.2197, 0.2818]\n", " cif_regimen[always, death @ t=1] [0.0190, 0.0415]\n", " cif_regimen[always, death @ t=2] [0.0535, 0.0910]\n", " cif_regimen[never, readmission @ t=1] [0.1139, 0.1589]\n", " cif_regimen[never, readmission @ t=2] [0.1907, 0.2663]\n", " cif_regimen[never, death @ t=1] [0.0911, 0.1316]\n", " cif_regimen[never, death @ t=2] [0.1723, 0.2473]\n" ] } ], "source": [ "from cleverly import CrossFitting, ModelSpec, Runtime, TMLEMethod\n", "\n", "sequential = TMLEMethod(\n", " models=ModelSpec(\n", " outcome_learner=LogisticRegression(max_iter=1000, random_state=41),\n", " pseudo_learner=LinearRegression(),\n", " treatment_learner=LogisticRegression(max_iter=1000, random_state=41),\n", " censoring_learner=LogisticRegression(max_iter=1000, random_state=41),\n", " ),\n", " cross_fitting=CrossFitting(enabled=False),\n", " runtime=Runtime(random_state=41, n_jobs=1),\n", ")\n", "result = effect.estimate(method=sequential)\n", "print(result.summary(protocol=\"fingerprint\"))" ] }, { "cell_type": "markdown", "id": "estimate-reading", "metadata": {}, "source": [ "**What this output tells you.** The table has one row per plan, per cause, per horizon.\n", "Each row is a cumulative incidence with its pointwise 95% interval. These lines of the summary\n", "matter for this fit.\n", "\n", "| summary line | what it says |\n", "| --- | --- |\n", "| `causes reported: readmission, death` | one backward pass for each cause |\n", "| `cross-fitting: none` | the in-sample construction |\n", "| `g_bounds: fixed [0.01, 1]` | the lower bound on each cumulative probability of following the plan and staying enrolled. Step 9 shows that no patient reached it |\n", "| `always: 938 of 4000 units followed it throughout` | 938 patients followed always, and 671 followed never. The largest weights are 14.5 and 28.7 |\n", "\n", "The summary also prints simultaneous bands. This page reads only the pointwise intervals.\n", "[Method evidence](#method-evidence) links the registered studies, which also measure the bands.\n", "The question asks for differences, which Step 7 computes." ] }, { "cell_type": "markdown", "id": "contrasts-heading", "metadata": {}, "source": [ "## Step 7: compare the plans\n", "\n", "Each difference uses `result.contrast(...)`. It applies the delta method to the joint influence\n", "curve of the estimates it combines. All the incidences come from one sample, and the two\n", "incidences of one plan come from the same patients. The joint curve includes the correlation\n", "between their errors. Event-free survival is\n", "one minus the sum of both incidences. One minus a single incidence is not event-free survival." ] }, { "cell_type": "code", "execution_count": 7, "id": "contrasts", "metadata": { "execution": { "iopub.execute_input": "2026-10-16T02:37:47.686319Z", "iopub.status.busy": "2026-10-16T02:37:47.686033Z", "iopub.status.idle": "2026-10-16T02:37:47.701624Z", "shell.execute_reply": "2026-10-16T02:37:47.700522Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "always minus never, cause-specific cumulative incidence:\n", " readmission t=1 -0.0172 SE 0.0113 CI (-0.0393, 0.0048) population -0.0265\n", " readmission t=2 0.0223 SE 0.0181 CI (-0.0132, 0.0578) population 0.0024\n", " death t=1 -0.0811 SE 0.0087 CI (-0.0980, -0.0641) population -0.0817\n", " death t=2 -0.1376 SE 0.0156 CI (-0.1682, -0.1070) population -0.1404\n", "\n", "event-free survival, one minus both incidences:\n", " always t=1 0.8506 SE 0.0085 CI ( 0.8339, 0.8672) population 0.8503\n", " never t=1 0.7522 SE 0.0103 CI ( 0.7320, 0.7725) population 0.7421\n", " difference t=1 0.0983 SE 0.0133 CI ( 0.0723, 0.1243) population 0.1082\n", " always t=2 0.6770 SE 0.0126 CI ( 0.6523, 0.7017) population 0.6828\n", " never t=2 0.5617 SE 0.0167 CI ( 0.5290, 0.5944) population 0.5448\n", " difference t=2 0.1153 SE 0.0207 CI ( 0.0748, 0.1558) population 0.1380\n" ] } ], "source": [ "def incidence(plan, cause, horizon):\n", " return f\"cif_regimen[{plan}, {cause} @ t={horizon}]\"\n", "\n", "\n", "def report(label, estimate, target):\n", " low, high = estimate.ci\n", " print(\n", " f\" {label:22s} {estimate.psi:7.4f} SE {estimate.std_error:.4f} \"\n", " f\"CI ({low:7.4f}, {high:7.4f}) population {target:7.4f}\"\n", " )\n", "\n", "\n", "# Each contrast reads the joint influence curve of the estimates it combines.\n", "differences = {}\n", "print(\"always minus never, cause-specific cumulative incidence:\")\n", "for cause in (\"readmission\", \"death\"):\n", " for horizon in (1, 2):\n", " names = [incidence(\"always\", cause, horizon), incidence(\"never\", cause, horizon)]\n", " differences[cause, horizon] = result.contrast(\n", " lambda psi: psi[0] - psi[1], names, name=f\"{cause} difference @ t={horizon}\"\n", " )\n", " target = population[f\"ate_regimen[always vs never, {cause} @ t={horizon}]\"]\n", " report(f\"{cause} t={horizon}\", differences[cause, horizon], target)\n", "print()\n", "\n", "# Event-free survival is one minus the sum of both causes, never one minus one cause.\n", "event_free = {}\n", "print(\"event-free survival, one minus both incidences:\")\n", "for horizon in (1, 2):\n", " for plan in (\"always\", \"never\"):\n", " names = [incidence(plan, \"readmission\", horizon), incidence(plan, \"death\", horizon)]\n", " event_free[plan, horizon] = result.contrast(\n", " lambda psi: 1.0 - psi[0] - psi[1], names, name=f\"event-free {plan} @ t={horizon}\"\n", " )\n", " target = 1.0 - sum(population[name] for name in names)\n", " report(f\"{plan} t={horizon}\", event_free[plan, horizon], target)\n", " names = [\n", " incidence(plan, cause, horizon)\n", " for plan in (\"always\", \"never\")\n", " for cause in (\"readmission\", \"death\")\n", " ]\n", " differences[\"event-free\", horizon] = result.contrast(\n", " lambda psi: (psi[2] + psi[3]) - (psi[0] + psi[1]),\n", " names,\n", " name=f\"event-free difference @ t={horizon}\",\n", " )\n", " target = sum(population[name] for name in names[2:]) - sum(\n", " population[name] for name in names[:2]\n", " )\n", " report(f\"difference t={horizon}\", differences[\"event-free\", horizon], target)" ] }, { "cell_type": "markdown", "id": "contrasts-reading", "metadata": {}, "source": [ "**What this output tells you.**\n", "\n", "| always minus never | day 30 (95% CI) | day 60 (95% CI) | population, day 60 |\n", "| --- | --- | --- | --- |\n", "| readmission incidence | -0.0172 (-0.0393, 0.0048) | 0.0223 (-0.0132, 0.0578) | 0.0024 |\n", "| death incidence | -0.0811 (-0.0980, -0.0641) | -0.1376 (-0.1682, -0.1070) | -0.1404 |\n", "| event-free survival | 0.0983 (0.0723, 0.1243) | 0.1153 (0.0748, 0.1558) | 0.1380 |\n", "\n", "The readmission intervals contain zero at both horizons. The death and event-free intervals\n", "exclude zero. On this draw, each of the ten printed intervals contains its population value. That\n", "is one draw, not a coverage result. [Method evidence](#method-evidence) links the\n", "repeated-sampling evidence." ] }, { "cell_type": "markdown", "id": "failure-heading", "metadata": {}, "source": [ "## Step 8: the failure mode, coding death as censoring\n", "\n", "A common shortcut codes death as censoring, as one minus Kaplan-Meier does. `cleverly` refuses to\n", "eliminate a competing event, with the code `different_estimand`. A manual recoding of the columns\n", "bypasses that refusal. The fit then runs, but it targets a different estimand: readmission risk\n", "in a world where death is prevented. The two\n", "analyses should differ here for a known reason. Censoring at death treats each patient who died as\n", "if the patient stayed at risk of readmission. More patients die under the never plan, so the\n", "shortcut raises the never-plan risk the most.\n", "\n", "The code builds a new censoring indicator, $K_t = C_t (1 - D_t)$. A patient stays in the\n", "readmission risk set only while enrolled and alive. The code fits the shortcut and writes the\n", "protocol it implies. The synthetic law defines no world without death, because it draws an event\n", "and then its cause. The code therefore writes the value of the g-formula functional that the\n", "recoding targets. It computes that value by quadrature from the generator's structural equations." ] }, { "cell_type": "code", "execution_count": 8, "id": "failure-mode", "metadata": { "execution": { "iopub.execute_input": "2026-10-16T02:37:47.707611Z", "iopub.status.busy": "2026-10-16T02:37:47.707377Z", "iopub.status.idle": "2026-10-16T02:37:47.861491Z", "shell.execute_reply": "2026-10-16T02:37:47.860807Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "protocol fingerprint, death as a competing event: 9b9cb1dda3683cf0\n", "protocol fingerprint, death as censoring: cd5958d44e088c86\n", "\n", "readmission risk at t=2 never always always - never SE target\n", "death as a competing event 0.2285 0.2508 0.0223 0.0181 0.0024\n", "death as censoring 0.2879 0.2698 -0.0180 0.0210 -0.0403\n", "\n", "death as censoring, 95% CI of the difference: (-0.0592, 0.0231)\n", "functional difference, death first : -0.0403\n", "functional difference, readmission first: -0.0093\n" ] } ], "source": [ "# A patient leaves the readmission risk set at disenrollment or at death, whichever is first.\n", "# The new censoring indicator is K_k = C_k (1 - D_k).\n", "kept_p1 = frame[\"enrolled_p1\"] * (1.0 - frame[\"death_p1\"].fillna(0.0))\n", "through_p1 = kept_p1.eq(1) & frame[\"readmission_p1\"].eq(0)\n", "kept_p2 = (frame[\"enrolled_p2\"] * (1.0 - frame[\"death_p2\"].fillna(0.0))).where(through_p1)\n", "death_as_censoring = frame.assign(\n", " enrolled_alive_p1=kept_p1,\n", " enrolled_alive_p2=kept_p2,\n", " readmission_p1=frame[\"readmission_p1\"].where(kept_p1.eq(1)),\n", " readmission_p2=frame[\"readmission_p2\"].where(kept_p2.eq(1) | frame[\"readmission_p1\"].eq(1)),\n", ")\n", "eliminated_protocol = replace(\n", " protocol,\n", " outcome=\"First unplanned readmission, recorded at the end of each 30-day period\",\n", " intercurrent_event_handling=(\n", " \"Treat death as censoring, so the target is readmission risk if death were \"\n", " \"prevented (hypothetical strategy)\",\n", " protocol.intercurrent_event_handling[1],\n", " \"Make no navigation decision after readmission or death\",\n", " protocol.intercurrent_event_handling[3],\n", " ),\n", " assumption_rationale=(\n", " \"Recorded history covers the measured common causes of navigation, disenrollment, \"\n", " \"readmission, and death in each period, including every common cause of death and \"\n", " \"readmission\",\n", " *protocol.assumption_rationale[1:],\n", " ),\n", ")\n", "eliminated = (\n", " CausalStudy(\n", " death_as_censoring,\n", " design=LongitudinalTreatment(\n", " outcome=(\"readmission_p1\", \"readmission_p2\"),\n", " censoring=(\"enrolled_alive_p1\", \"enrolled_alive_p2\"),\n", " **navigation_nodes,\n", " ),\n", " protocol=eliminated_protocol,\n", " )\n", " .identify(levels_by_plan)\n", " .estimate(method=sequential)\n", ")\n", "eliminated_t2 = eliminated.contrast(\n", " lambda psi: psi[0] - psi[1],\n", " [\"risk_regimen[always @ t=2]\", \"risk_regimen[never @ t=2]\"],\n", " name=\"death-as-censoring difference @ t=2\",\n", ")\n", "\n", "# The law defines no world without death. These constants are values of the g-formula functional\n", "# that the recoding targets, by quadrature over the generator's structural equations. Within a\n", "# period, the functional needs an order for death and readmission, and the two orders differ.\n", "functional = {\n", " \"death first\": {\"never\": 0.304906, \"always\": 0.264627},\n", " \"readmission first\": {\"never\": 0.260970, \"always\": 0.251680},\n", "}\n", "print(\"protocol fingerprint, death as a competing event:\", protocol.fingerprint)\n", "print(\"protocol fingerprint, death as censoring: \", eliminated_protocol.fingerprint)\n", "print()\n", "print(\"readmission risk at t=2 never always always - never SE target\")\n", "rows = (\n", " (\n", " \"death as a competing event\",\n", " result[incidence(\"never\", \"readmission\", 2)].psi,\n", " result[incidence(\"always\", \"readmission\", 2)].psi,\n", " differences[\"readmission\", 2],\n", " population[\"ate_regimen[always vs never, readmission @ t=2]\"],\n", " ),\n", " (\n", " \"death as censoring\",\n", " eliminated[\"risk_regimen[never @ t=2]\"].psi,\n", " eliminated[\"risk_regimen[always @ t=2]\"].psi,\n", " eliminated_t2,\n", " functional[\"death first\"][\"always\"] - functional[\"death first\"][\"never\"],\n", " ),\n", ")\n", "for label, never, always, contrast, target in rows:\n", " print(\n", " f\"{label:28s} {never:.4f} {always:.4f} {contrast.psi:7.4f} \"\n", " f\"{contrast.std_error:.4f} {target:7.4f}\"\n", " )\n", "print()\n", "print(\"death as censoring, 95% CI of the difference:\", tuple(round(v, 4) for v in eliminated_t2.ci))\n", "for order, values in functional.items():\n", " print(f\"functional difference, {order:17s}: {values['always'] - values['never']:.4f}\")" ] }, { "cell_type": "markdown", "id": "failure-reading", "metadata": {}, "source": [ "**What this output tells you.** The two analyses carry different protocol fingerprints,\n", "`9b9cb1dda3683cf0` and `cd5958d44e088c86`. The death-as-censoring protocol changes three fields:\n", "the outcome, the intercurrent-event handling, and the assumption rationale. Both designs run. Only\n", "the protocol records that the question changed.\n", "\n", "| analysis | estimand | day-60 difference (SE) | value of its target | added assumptions |\n", "| --- | --- | --- | --- | --- |\n", "| death as a competing event | the total effect on readmission incidence | 0.0223 (0.0181) | 0.0024 | none beyond Step 5 |\n", "| death as censoring | readmission risk if death were prevented | -0.0180 (0.0210) | -0.0403 | exchangeability and positivity for death given the history, and a defined intervention that prevents death |\n", "\n", "On this draw, the shortcut reports a reduction, and the competing-event analysis reports an\n", "increase. The never-plan risk rises from 0.2285 to 0.2879. The always-plan risk rises from 0.2508\n", "to 0.2698. The shortcut's interval, (-0.0592, 0.0231), contains both target values, 0.0024 and\n", "-0.0403. This draw cannot tell them apart. The protocol states which estimand the analysis answers.\n", "\n", "The shortcut is not doubly robust here. Its censoring indicator mixes disenrollment with death, so\n", "the logistic model of $K_t$ is misspecified, and so are the outcome regressions. Nothing ensures\n", "that the shortcut is consistent for its own functional.\n", "\n", "Do not read -0.0180 as a direct effect of navigation on readmission. That reading needs a world\n", "where death is prevented, and the program has no such intervention.\n", "\n", "In discrete time, the functional also depends on the order of death and readmission within a\n", "period. With death first, as the recoding assumes, its value is -0.0403. With readmission first,\n", "it is -0.0093. The two orders encode two different assumptions about the prevented deaths. A real\n", "analysis must defend its order, and no data check can confirm it.\n", "\n", "No registered study covers this construction.\n", "[Young et al. (2020)](https://doi.org/10.1002/sim.8471) define both estimands and their identifying conditions." ] }, { "cell_type": "markdown", "id": "assessment-heading", "metadata": {}, "source": [ "## Step 9: diagnostics, what the fit can check\n", "\n", "The assessment lists the checks that ran on the fit. The support report describes the weights at\n", "each node. It counts the patients who follow each plan, gives the largest inverse-probability\n", "weight, and says whether any probability reached the bound." ] }, { "cell_type": "code", "execution_count": 9, "id": "assessment", "metadata": { "execution": { "iopub.execute_input": "2026-10-16T02:37:47.865211Z", "iopub.status.busy": "2026-10-16T02:37:47.864800Z", "iopub.status.idle": "2026-10-16T02:37:47.987797Z", "shell.execute_reply": "2026-10-16T02:37:47.986443Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ " surface check status\n", "validation score_equations passed\n", "validation support completed\n", "validation nuisance_models completed\n", "needs attention: ()\n", "\n", "regimen cause horizon time n_followed share_assigned_1 max_weight effective_n share_truncated epsilon converged\n", " always readmission 1 1 1688 1.0 7.0535 1564.5728 0.0 -0.0041 True\n", " always readmission 2 1 1688 1.0 7.0535 1564.5728 0.0 0.0029 True\n", " always readmission 2 2 938 1.0 14.5333 786.2736 0.0 -0.0049 True\n", " always death 1 1 1688 1.0 7.0535 1564.5728 0.0 -0.0126 True\n", " always death 2 1 1688 1.0 7.0535 1564.5728 0.0 -0.0016 True\n", " always death 2 2 938 1.0 14.5333 786.2736 0.0 -0.0090 True\n", " never readmission 1 1 1861 0.0 6.1974 1764.7557 0.0 0.0054 True\n", " never readmission 2 1 1861 0.0 6.1974 1764.7557 0.0 0.0054 True\n", " never readmission 2 2 671 0.0 28.7198 529.0079 0.0 -0.0679 True\n", " never death 1 1 1861 0.0 6.1974 1764.7557 0.0 -0.0053 True\n", " never death 2 1 1861 0.0 6.1974 1764.7557 0.0 -0.0028 True\n", " never death 2 2 671 0.0 28.7198 529.0079 0.0 -0.0071 True\n", "minimum effective-sample-size ratio: 78.8%\n" ] } ], "source": [ "assessment = result.assess()\n", "ledger = assessment.to_frame()\n", "ran = ledger[ledger[\"status\"].isin([\"passed\", \"completed\"])]\n", "print(ran[[\"surface\", \"check\", \"status\"]].to_string(index=False))\n", "print(\"needs attention:\", tuple(item.name for item in assessment.attention))\n", "print()\n", "support = assessment.report(\"support\").to_frame()\n", "print(support.round(4).to_string(index=False))\n", "ratio = (support[\"effective_n\"] / support[\"n_followed\"]).min()\n", "print(f\"minimum effective-sample-size ratio: {ratio:.1%}\")" ] }, { "cell_type": "markdown", "id": "assessment-reading", "metadata": {}, "source": [ "**What this output tells you.** The score-equation check `passed`: targeting solved the\n", "estimating equation of each parameter. The support and nuisance reports are `completed`, which\n", "means that they ran. No row needs attention.\n", "\n", "| support column | value on this fit |\n", "| --- | --- |\n", "| `n_followed` | 671 patients follow never through the second node, the smallest group |\n", "| `max_weight`, `effective_n` | the largest weight is 28.7, for never at the second node, with an effective sample of about 529 |\n", "| `share_truncated` | 0.0 on every row. No probability reached the bound |\n", "| `epsilon` | the targeting coefficient. The largest, -0.0679, is on the never-plan readmission row at the second node |\n", "| `converged` | targeting converged on every row |\n", "\n", "The minimum effective-sample-size ratio is 78.8%. These numbers come from the fitted logistic\n", "offer and enrollment models. They describe those models, not the population. They do not\n", "establish exchangeability, independent censoring, or correct outcome regressions." ] }, { "cell_type": "markdown", "id": "sensitivity-heading", "metadata": {}, "source": [ "## Step 10: how much a recorded cause moves the estimate\n", "\n", "A sensitivity analysis asks how strong an unmeasured confounder must be to change the conclusion.\n", "No omitted-variable bound is implemented for a longitudinal fit, so this step uses a benchmark. It\n", "refits the day-60 differences without one recorded covariate at a time. Each refit uses the same\n", "data, method, plans, and protocol. The\n", "[sensitivity guide](../user-guide/results-assessment.md#sensitivity-analysis) describes the\n", "approach." ] }, { "cell_type": "code", "execution_count": 10, "id": "sensitivity", "metadata": { "execution": { "iopub.execute_input": "2026-10-16T02:37:47.990058Z", "iopub.status.busy": "2026-10-16T02:37:47.989852Z", "iopub.status.idle": "2026-10-16T02:37:48.586487Z", "shell.execute_reply": "2026-10-16T02:37:48.585873Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "full history, readmission difference at t=2: 0.0223, SE 0.0181\n", "full history, death difference at t=2: -0.1376, SE 0.0156\n", " dropped cause psi move move_in_se\n", " age readmission 0.0540 0.0317 1.75\n", " age death -0.1276 0.0100 0.64\n", "baseline_readiness readmission 0.0332 0.0109 0.60\n", "baseline_readiness death -0.1318 0.0057 0.37\n", " identified_needs readmission 0.0366 0.0143 0.79\n", " identified_needs death -0.1201 0.0175 1.12\n" ] } ], "source": [ "full_design = study.design\n", "benchmarks = []\n", "for dropped in (\"age\", \"baseline_readiness\", \"identified_needs\"):\n", " reduced = replace(\n", " full_design,\n", " baseline=tuple(name for name in full_design.baseline if name != dropped),\n", " time_varying=tuple(\n", " tuple(name for name in node if name != dropped) for node in full_design.time_varying\n", " ),\n", " )\n", " refit = (\n", " CausalStudy(frame, design=reduced, protocol=protocol)\n", " .identify(levels_by_plan)\n", " .estimate(method=sequential)\n", " )\n", " for cause in (\"readmission\", \"death\"):\n", " names = [incidence(\"always\", cause, 2), incidence(\"never\", cause, 2)]\n", " moved = refit.contrast(lambda psi: psi[0] - psi[1], names, name=f\"{cause} difference\")\n", " full = differences[cause, 2]\n", " benchmarks.append(\n", " {\n", " \"dropped\": dropped,\n", " \"cause\": cause,\n", " \"psi\": round(moved.psi, 4),\n", " \"move\": round(moved.psi - full.psi, 4),\n", " \"move_in_se\": round((moved.psi - full.psi) / full.std_error, 2),\n", " }\n", " )\n", "benchmark = pd.DataFrame(benchmarks)\n", "for cause in (\"readmission\", \"death\"):\n", " full = differences[cause, 2]\n", " print(f\"full history, {cause} difference at t=2: {full.psi:.4f}, SE {full.std_error:.4f}\")\n", "print(benchmark.to_string(index=False))" ] }, { "cell_type": "markdown", "id": "sensitivity-reading", "metadata": {}, "source": [ "**What this output tells you.** Each row drops one covariate. `move_in_se` divides the\n", "move of the day-60 difference by the standard error of the full fit.\n", "\n", "| dropped covariate | readmission move (SE) | death move (SE) |\n", "| --- | --- | --- |\n", "| `age` | 1.75 | 0.64 |\n", "| `baseline_readiness` | 0.60 | 0.37 |\n", "| `identified_needs` | 0.79 | 1.12 |\n", "\n", "Every move is positive here. A dropped covariate leaves both the offer models and the enrollment\n", "models, so each move mixes offer confounding with informative censoring. Each covariate sends\n", "offers toward patients at higher risk, so its omission makes navigation look worse. The largest death move is 0.0175, against a death difference\n", "of -0.1376. The readmission difference is 0.0223, and the move without `age` is 0.0317. An\n", "unrecorded cause that acts like `age` could therefore change the sign of the readmission\n", "difference.\n", "\n", "Read the table as a benchmark, not as a bound. An unrecorded cause can be stronger,\n", "or act in the other direction." ] }, { "cell_type": "markdown", "id": "method-evidence", "metadata": {}, "source": [ "## Method evidence\n", "\n", "The fits on this page use one draw of a synthetic law. The registered studies in the table\n", "measure each method over repeated draws. Each study page gives its law, its nuisance models,\n", "and the result of each cell.\n", "\n", "| fit on this page | registered study | law of the study |\n", "| --- | --- | --- |\n", "| Step 6, the incidence of each cause | [Ordinary competing-risk longitudinal TMLE](../technical-reference/method-evidence/ordinary-competing-risk-longitudinal-tmle.md) | another law |\n", "| Step 7, the event-free difference | [Ordinary survival-curve longitudinal TMLE](../technical-reference/method-evidence/ordinary-survival-curve-longitudinal-tmle.md) | this page's law, with both causes merged into one event |\n", "\n", "Both studies supply the mechanisms exactly. No registered study fits the offer and enrollment\n", "models from data, as this page does.\n", "\n", "The [technical entry](../technical-reference/longitudinal-tmle.md#validation-issues-special-to-this-method) lists the evidence and the limits of the method." ] }, { "cell_type": "markdown", "id": "interpret", "metadata": {}, "source": [ "## What the analysis shows\n", "\n", "The question was whether navigation at discharge and on day 31 lowers readmission by day 60. The\n", "estimates describe enrolled adults discharged home, under the plan that offers navigation at both\n", "decisions, had every patient stayed enrolled. Each interval is a pointwise 95% interval from the\n", "influence curve.\n", "\n", "| estimand, always minus never at day 60 | estimate (95% CI) | reading |\n", "| --- | --- | --- |\n", "| readmission incidence | 0.0223 (-0.0132, 0.0578) | the data do not show a change. At the 95% level, they rule out a difference below -0.0132 or above 0.0578 |\n", "| death incidence | -0.1376 (-0.1682, -0.1070) | fewer patients die before readmission |\n", "| event-free survival | 0.1153 (0.0748, 0.1558) | more patients reach day 60 alive and not readmitted |\n", "\n", "Read the readmission result with the death result. A plan that prevents deaths keeps more\n", "patients at risk of readmission. A readmission total effect near zero therefore does not show that\n", "navigation leaves the readmission hazard unchanged. This analysis does not separate the paths\n", "through death. That separation needs a different estimand, with assumptions that this page does\n", "not defend.\n", "\n", "The analysis does not verify sequential exchangeability, independent censoring, consistency, no\n", "interference, or the correctness of the offer and enrollment models. The point estimates rely on\n", "those models, because the outcome regressions are misspecified.\n", "\n", "| the analysis does not support | because |\n", "| --- | --- |\n", "| a claim about readmission in a world without death | that is the estimand of Step 8, with more assumptions |\n", "| a claim about other offer schedules | the fit estimates the always and never plans only |\n", "| a coverage claim for these intervals | one draw cannot measure coverage |" ] }, { "cell_type": "markdown", "id": "where-next", "metadata": {}, "source": [ "## Where to go next\n", "\n", "A program that compares many navigation schedules needs a summary across plans and horizons.\n", "[MSM projections](msm-projections.ipynb) fit that summary. The\n", "[examples index](index.md#the-program) lists every tutorial in the program." ] } ], "metadata": { "cleverly_execution": { "command": [ "python", "scripts/execute_notebook.py", "docs/examples/longitudinal-survival.ipynb" ], "gated": { "code_source_sha256": "4bfb0c500d52c9dea837d96ffc951a5becd52bc5b7b024c48ab4a6cb117ef429", "execution_payload_sha256": "732b1f7946ee65f2a199e068b83cc886e40d5ea726939a80733fb0908443d1c6" }, "recorded": { "cleverly_commit": "ec1a299671c27b3be291b4a370985678a68333ce", "cleverly_version": "0.1.3", "cleverly_worktree_clean": false, "dependency_lock_sha256": "2e4f7ad3264ff9b35cf7588e5c93a0e20fc5c0852092ac1da365491543836682", "generator_files": [ "scripts/execute_notebook.py", "tests/notebooks.py" ], "generator_sha256": "f247771ab390b23e7e092d7eb35794e241c2ac84fc0526695df1cee1fc457a83", "package_source_sha256": "334b4a319e31bd35e94ae5f8988dd4ccd3cf8e042f8b0dc48f28c627723d9b25" }, "schema_version": 5 }, "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.16" } }, "nbformat": 4, "nbformat_minor": 5 }