Choosing a navigation policy: three proposals, three estimands#
A regional health plan offers care-transition navigation to adults who are discharged home. The program office must choose next year’s navigation standard, and it has three proposals. Each proposal changes a different part of current practice, so each one defines a different causal quantity. This notebook writes each proposal as an estimand, chooses the TMLE variant that the estimand needs, fits it, checks it, and states what the result supports.
proposal |
what it changes |
|---|---|
screen: offer navigation only to patients whose baseline discharge risk is above the population average |
who receives the offer, by a fixed rule on baseline data |
nudge: double each patient’s current odds of an offer |
how often discharge teams offer, while each team keeps its own judgment |
raise: raise each patient’s assigned navigation intensity by 0.5 index units, unless the result would exceed the staffing capacity of 5 units |
how much navigation a patient receives |
The office fixes the rule, the odds multiplier, the raise, and the capacity before it sees the data.
The situation#
Two synthetic datasets stand in for the program records. Each row is one discharge. In each dataset the baseline record comes first, then the navigation decision, then the 30-day outcome.
dataset |
proposals |
treatment A |
outcome Y |
baseline covariates W |
|---|---|---|---|---|
|
screen, nudge |
|
|
|
|
raise |
|
|
|
The raise needs a continuous exposure, and the offer data record only offer or no offer. The raise therefore uses its own dataset. Its covariates and outcome scale differ, so do not compare its numbers with those of the other two proposals.
The data contain observed variables only: W, A, and Y. Each proposal asks about a counterfactual outcome, the score that a patient would have under the proposed policy. No row records it.
The causal graph#
Both datasets have this graph.
W
/ \
v v
A --> Y
element |
what it asserts |
what the analysis does with it |
|---|---|---|
node W |
the baseline covariates, recorded before the decision |
adjusts for them |
node A |
the navigation decision: the offer, or the assigned intensity |
changes it, as each proposal states |
node Y |
the 30-day transition score |
estimates its mean under each policy |
arrow W → A |
the decision depends on the baseline record. In the offer data every covariate enters it. In the intensity data, |
together with W → Y, it forms the back-door path A ← W → Y. Adjustment for W blocks that path |
arrow W → Y |
baseline state changes the score |
see the previous row. |
arrow A → Y |
the decision changes the score. In the offer data the effect, on the logit scale, grows with |
this is the effect to estimate. The effect modification by risk is why a screen on risk can keep most of the gain |
no unrecorded common cause of A and Y |
no unmeasured confounding |
the data cannot test this. No step on this page measures it. The omitted-variable bound in point-treatment TMLE is the analysis for an ATE |
no node after A other than Y |
completed contacts and length of stay occur after the decision |
the analysis does not adjust for them. Adjustment would block part of the effect |
The synthetic laws draw no deaths, no censoring, and no missing scores. The graph therefore has no competing-event or censoring node. A real program needs them, as the shared study design explains.
The problem#
The target population is the program population: adults discharged home from a participating hospital. Each proposal defines one contrast of mean 30-day scores between two counterfactual worlds. In the table, \(g(1 \mid W)\) is the probability of an offer given W under current practice.
proposal |
intervention |
estimand |
reference world |
|---|---|---|---|
screen |
\(d(W) = 1\) when |
\(E[Y^{d}] - E[Y^{0}]\) |
no patient receives an offer |
nudge |
offer with odds \(2\,g(1 \mid W) / \{1 - g(1 \mid W)\}\) |
\(E[Y^{q_2}] - E[Y]\) |
current practice |
raise |
\(d(a, w) = a + 0.5\) when \(a + 0.5 \le 5\), else \(a\) |
\(E[Y^{d(A, W)}] - E[Y]\) |
current practice |
The screen replaces current practice with a fixed rule. Its fit also estimates offer to all, \(E[Y^{1}] - E[Y^{0}]\), which is the ATE. The office compares the screen with offer to all. The nudge and the raise modify current practice, so their reference world is current practice. The three contrasts have different reference worlds, so do not rank the proposals by their numbers.
Why the ATE does not answer these questions#
proposal |
why a simpler estimand fails |
|---|---|
screen |
the ATE describes an offer to every patient, which the office cannot staff. The effect varies with risk, so the screen’s value depends on which patients it selects. No rescaling of the ATE gives it |
nudge |
under the nudge, one patient can receive either arm. No static contrast describes that world |
raise |
a dose-response value \(E[Y^{a}]\) at one fixed intensity sets every patient to the same dose. Nobody proposes that policy, and it needs support for that dose at every W. The raise moves each patient from the dose the patient receives now |
What identifies each estimand#
assumption |
screen |
nudge |
raise |
how it is judged |
|---|---|---|---|---|
consistency: a patient’s observed score equals the counterfactual score under the received treatment |
needed |
needed |
needed |
scientific judgment, supported by a standardized offer and version records |
no interference |
needed |
needed |
needed |
design: reserved navigator capacity |
no unmeasured confounding given W |
needed |
needed |
needed |
scientific judgment. No data check confirms it |
positivity |
each patient needs some chance of the arm the rule assigns |
none |
the raised intensity needs positive density given W |
partly checkable: the support reports describe the fitted models, not the law |
the policy is fixed before the data |
the rule |
not applicable: the nudge is defined through g |
the raise and the cap |
protocol review |
Kennedy (2019), Section 3.2, identifies the nudge without positivity. The modified treatment policies section states the support condition of the raise.
The method#
Each estimand needs its own TMLE variant. The table connects each feature of the problem to a choice in the fit.
feature of the problem |
choice in the fit |
reason |
|---|---|---|
the screen is a fixed rule on W, written before fitting |
|
a known rule adds no estimation term to the influence curve. Positivity is needed only for the arm that the rule assigns |
the nudge is defined through the current assignment mechanism g |
|
the estimand moves when g moves. The influence curve carries a term for g, so the fit targets g as well as Q |
the raise acts on a continuous exposure, with a capacity fixed by the staffing plan |
|
the clever covariate is a ratio of conditional densities, so the fit estimates the density of the intensity given W. The cap is declared, not estimated |
the offer-data score depends on W through sines, squares, and interactions |
gradient boosting for Q, with 3-fold cross-fitting |
each row’s prediction comes from models fit without that row, so the flexible Q does not fit the noise of the rows it targets |
the offer-data score is a share of a maximum |
|
keeps every prediction of the score inside its range |
the intensity-data fit uses parametric models |
in-sample targeting |
Step 11 gives the reason: a cross-fitted density ratio makes the standard error too large |
The table defines the terms that this notebook uses most.
term |
plain meaning |
definition |
|---|---|---|
estimand |
the number the question asks for, written before any model is chosen |
|
nuisance |
a model the estimate needs but the question does not ask about: the outcome regression Q, and the treatment mechanism g or the treatment density |
|
targeting |
a small update to Q, weighted by a clever covariate built from g, that removes first-order bias |
|
clever covariate |
the weight each row carries in targeting. Each proposal builds its own |
|
influence curve |
how much each row moves the estimate. Its variance gives the standard error |
|
positivity |
every kind of patient has some chance of the treatment the policy asks for |
|
effective sample size |
(the sum of the weights)² divided by the sum of the squared weights: the number of equally weighted rows with the same concentration. It is not the precision of the estimate |
Step 1: set up#
The setup imports the learners and prints the installed cleverly version. Every fit passes its
learners, fold count, and random seed, so a rerun reproduces the stored outputs.
import warnings
from dataclasses import replace
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
from sklearn.linear_model import LinearRegression, LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
import cleverly
pd.set_option("display.width", 130)
pd.set_option("display.max_columns", 20)
print("cleverly", cleverly.__version__)
cleverly 0.1.3
What this output tells you. The stored outputs in this notebook came from the version named above. A different version can print different numbers.
Step 2: the offer data#
navigation_data draws from the synthetic law nonlinear_bounded_dgp under the program’s column
names. law.expectation integrates a function of W over the known law, so the code can print the
population value of each estimand. A real program cannot do that. The generator returns no value
for the screen, so the code computes it. The code also prints how often current practice offers
navigation in each screen group.
from cleverly.datasets import navigation_data, nonlinear_bounded_dgp
frame, truth = navigation_data(n=3_000, seed=31)
law = nonlinear_bounded_dgp()
def effect_of_offer(latent):
return law.outcome_mean(latent, 1.0, None) - law.outcome_mean(latent, 0.0, None)
screen_truth = law.expectation(lambda latent: effect_of_offer(latent) * (latent[:, 0] > 0))
print("rows and columns:", frame.shape)
print(frame.head().round(3))
print()
flagged = frame["discharge_risk"] > 0
current = frame.groupby(flagged)["transition_navigation"].agg(["size", "mean"])
current.index = ["not flagged", "flagged"]
current.columns = ["patients", "share offered now"]
print(current.round(3))
print()
print("known values of the synthetic law:")
print(f" offer to all vs offer to none (the ATE): {truth['ate']:.3f}")
print(f" screen on risk vs offer to none: {screen_truth:.3f}")
print(f" share of the ATE the screen keeps: {screen_truth / truth['ate']:.3f}")
rows and columns: (3000, 6)
transition_score transition_navigation discharge_risk prior_utilization medication_burden age
0 0.367 1.0 -0.395 0.264 0.607 -0.972
1 0.768 1.0 0.768 0.255 0.783 0.272
2 0.514 0.0 1.162 -0.938 1.776 1.202
3 0.464 1.0 -0.600 0.660 0.445 -1.746
4 0.775 0.0 0.595 -0.585 -0.250 -0.602
patients share offered now
not flagged 1479 0.383
flagged 1521 0.581
known values of the synthetic law:
offer to all vs offer to none (the ATE): 0.163
screen on risk vs offer to none: 0.105
share of the ATE the screen keeps: 0.648
What this output tells you. The baseline covariates are independent standard-normal draws. The screen flags 1521 of the 3000 patients.
Current practice offers navigation to 0.581 of flagged patients and to 0.383 of the others. The offer depends on W, as the arrow W → A asserts, so a raw comparison of the arms is confounded. Both groups contain offered and unoffered patients. The law also keeps every offer probability between 0.05 and 0.95, so each arm that the screen assigns has positive probability at every W.
known value |
what it means for the office |
|---|---|
offer to all, 0.163 |
the mean gain if every patient receives an offer. This is the ATE |
screen, 0.105 |
the mean gain over all patients if only flagged patients receive an offer |
share kept, 0.648 |
the screen keeps about two thirds of the ATE and offers navigation to about half of the patients |
The screen keeps more than half of the gain because the effect of an offer grows with
discharge_risk. The ATE alone cannot give this number.
Proposal 1: the screen, a known regime#
Step 3: write the protocol#
A StudyProtocol records the scientific design before any model runs. This protocol starts from
navigation_protocol(), the protocol of the
shared study design. dataclasses.replace changes three
fields: the strategies, their versions, and the assumption rationale.
from cleverly.datasets import navigation_protocol
program = navigation_protocol()
regime_protocol = replace(
program,
treatment_strategies=(
"Offer navigation to no eligible patient",
"Offer navigation to every eligible patient",
"Offer navigation when baseline discharge risk is above the population average",
),
treatment_versions=(
program.treatment_versions[1],
program.treatment_versions[0],
"The standard offer for a flagged patient, and usual support for every other patient",
),
assumption_rationale=(
*program.assumption_rationale,
"The screen reads only discharge risk recorded before the offer",
),
)
print("\n".join(regime_protocol.summary_lines()))
causal study protocol: schema 1; 3f91dfe6affe6445
target population: Adults with a discharge-home order at a participating hospital during the enrollment period
eligibility: ['Age 18 years or older', 'Discharge home ordered at a participating hospital']
time zero: Discharge-home order, after baseline measurement and before the navigation offer
treatment strategies: ['Offer navigation to no eligible patient', 'Offer navigation to every eligible patient', 'Offer navigation when baseline discharge risk is above the population average']
treatment versions: ['No access to the transition-navigation offer', 'Bedside transition plan and two scheduled navigator contacts within 30 days', 'The standard offer for a flagged patient, and usual support for every other patient']
outcome: Patient-reported transition score, as a share of the maximum score
horizon: 30 days after discharge
intercurrent-event handling: ['Use the transition score regardless of readmission', 'Analyze the offer regardless of completed contacts', 'The protocol scores death before day 30 as the worst transition score (composite strategy)']
interference unit: Individual patient
assumption rationale: ['The recorded baseline variables cover the measured common causes', 'The standardized offer and version records support consistency', 'Reserved navigator capacity and access controls support no interference', 'The screen reads only discharge risk recorded before the offer']
What this output tells you. The first line gives the fingerprint 3f91dfe6affe6445. Every
result fitted from this study carries it.
protocol field |
what it records |
|---|---|
treatment strategies and versions, changed |
offer to none, offer to all, and the screen. Offer to none reuses the program’s version for usual support |
assumption rationale, changed |
the program’s three arguments, and a fourth: the screen reads only discharge risk, which is recorded before the offer |
time zero, unchanged |
the discharge-home order, before the offer |
The protocol states the rule in words only. It cannot check the Rule of the next step. A reviewer
must compare the two.
Step 4: identify the regime contrasts#
Static assigns one arm to every patient. Rule assigns an arm from a function of W.
rule_kind="known" declares that the function is fixed before the fit. A fit accepts only this
value. The reference plan is offer to none.
from cleverly import CausalStudy, PointTreatment, RegimeContrast
from cleverly.interventions import Rule, Static
study = CausalStudy(
frame,
design=PointTreatment(
outcome="transition_score",
treatment="transition_navigation",
adjustment=("discharge_risk", "prior_utilization", "medication_burden", "age"),
),
protocol=regime_protocol,
)
plans = (
Static(0, name="offer to none"),
Static(1, name="offer to all"),
Rule(
lambda data: (data["discharge_risk"] > 0).astype(float),
name="screen on risk",
rule_kind="known",
),
)
regimes = study.identify(RegimeContrast(plans, reference="offer to none"))
print(regimes.summary(protocol="fingerprint"))
contrast of each regime against the reference
identified by explicit-adjustment: identified regime-indexed plug-in functional of E(transition_score | transition_navigation=a, W) for a in [0, 1] with influence correction
adjustment/history: ['discharge_risk', 'prior_utilization', 'medication_burden', 'age']
required nuisances: ['outcome_regression', 'treatment_mechanism']
assumptions:
- consistency: Y = Y^a when A = a
- no interference: one unit's potential outcome does not depend on other units' treatment assignments
- no unmeasured confounding: Y^a is independent of A given W
- positivity *for the regime*: g(a | W) > 0 wherever the regime assigns arm a with positive probability -- a weaker requirement than positivity for every arm when the regime is deterministic, and a different one
- the regime is a known function of W: g* does not depend on the observed-data law, so the influence function carries no term for estimating it. An intervention defined *through* the estimated mechanism is a different parameter with a further term, and is ey_ipsi
causal study protocol: schema 1; 3f91dfe6affe6445
What this output tells you. The required nuisances are Q and g. Two of the listed assumptions differ from those of the ATE.
assumption |
what it means for the screen |
|---|---|
positivity for the regime |
a flagged patient needs some chance of an offer, and an unflagged patient needs some chance of usual support |
the regime is a known function of W |
the influence curve has no term for estimating the rule |
The summary ends with the protocol fingerprint.
Step 5: estimate the regime contrasts#
setting |
value |
reason |
|---|---|---|
|
gradient boosting |
Q is nonlinear in W in this law |
|
a degree-2 logistic model |
a flexible parametric model of g, with squares and pairwise products. It is not correctly specified, because the law’s assignment has a step in |
|
three folds |
each row’s nuisance predictions come from models fit without that row. CV-TMLE defines it |
|
the score’s range |
a cross-fitted fit of a continuous outcome must declare its range. Without it the fit refuses, because the scale would come from the held-out rows as well |
|
a fixed seed, one process |
makes the fit reproducible |
The code prints the result, then each contrast beside its population value. It then estimates the
gain that the screen gives up against offer to all. contrast computes that difference from the
joint influence curve, because both estimates come from the same rows.
from cleverly import CrossFitting, ModelSpec, Runtime, Targeting, TMLEMethod
method = TMLEMethod(
models=ModelSpec(
outcome_learner=HistGradientBoostingRegressor(random_state=31),
treatment_learner=make_pipeline(
StandardScaler(), PolynomialFeatures(2), LogisticRegression(C=1000.0, max_iter=5000)
),
),
cross_fitting=CrossFitting(n_folds=3),
targeting=Targeting(q_bounds=(0.0, 1.0)),
runtime=Runtime(random_state=31, n_jobs=1),
)
regime_result = regimes.estimate(method=method)
print(regime_result.summary(protocol="fingerprint"))
print()
OFFER_ALL = "ate_regime[offer to all vs offer to none]"
SCREEN = "ate_regime[screen on risk vs offer to none]"
regime_truth = {OFFER_ALL: truth["ate"], SCREEN: screen_truth}
def against_truth(result, population_values):
"""Each estimate and its interval beside the population value of its estimand."""
table = result.to_frame()[["estimand", "psi", "ci_lower", "ci_upper"]]
table["population value"] = table["estimand"].map(population_values)
return table
print(against_truth(regime_result, regime_truth).round(3).to_string(index=False))
print()
given_up = regime_result.contrast(
lambda p: p[0] - p[1], [OFFER_ALL, SCREEN], name="offer to all - screen"
)
print(
f"gain the screen gives up: {given_up.psi:.3f} "
f"(95% CI {given_up.ci[0]:.3f} to {given_up.ci[1]:.3f}); "
f"population value {truth['ate'] - screen_truth:.3f}"
)
Targeted maximum likelihood estimation
======================================
n = 3000; covariates = 4; P(A=1) = 0.4833
causal estimand: contrast of each regime against the reference
identification: explicit-adjustment; identified regime-indexed plug-in functional of E(transition_score | transition_navigation=a, W) for a in [0, 1] with influence correction
required nuisances: outcome_regression, treatment_mechanism
identification assumptions: consistency: Y = Y^a when A = a; no interference: one unit's potential outcome does not depend on other units' treatment assignments; no unmeasured confounding: Y^a is independent of A given W; positivity *for the regime*: g(a | W) > 0 wherever the regime assigns arm a with positive probability -- a weaker requirement than positivity for every arm when the regime is deterministic, and a different one; the regime is a known function of W: g* does not depend on the observed-data law, so the influence function carries no term for estimating it. An intervention defined *through* the estimated mechanism is a different parameter with a further term, and is ey_ipsi
causal study protocol: schema 1; 3f91dfe6affe6445
outcome family: gaussian; fluctuation: logistic (iterative)
stacked CV-TMLE (Levy): nuisances cross-fitted over 3 folds; targeting: pooled
propensity truncated to [0.0114, 0.9886]; ATT/ATC to [0.025, 0.975]
data 17a5d0ba630bf0d3 | folds 2357f29b62daa39f | cleverly 0.1.3 | 2026-10-16T01:17:07+00:00
estimand psi std_err 95% CI p_value support
------------------------------------------- ------- -------- ------------------ ------- ------------
ate_regime[offer to all vs offer to none] 0.17118 0.006074 [0.15928, 0.18309] <1e-4 experimental
ate_regime[screen on risk vs offer to none] 0.11473 0.004793 [0.10533, 0.12412] <1e-4 experimental
experimental: No registered study covers this configuration. Nearest row: T02.continuous (canonical-cvtmle), differing on family.
simultaneous 95% bands (multiplier bootstrap, critical value 2.212 vs 1.960 pointwise) [experimental]:
ate_regime[offer to all vs offer to none] [0.15775, 0.18462]
ate_regime[screen on risk vs offer to none] [0.10412, 0.12533]
estimand psi ci_lower ci_upper population value
ate_regime[offer to all vs offer to none] 0.171 0.159 0.183 0.163
ate_regime[screen on risk vs offer to none] 0.115 0.105 0.124 0.105
gain the screen gives up: 0.056 (95% CI 0.048 to 0.065); population value 0.057
What this output tells you. The summary names the construction, stacked CV-TMLE over 3 folds, and the propensity bound [0.0114, 0.9886].
contrast |
estimate |
95% interval |
population value |
|---|---|---|---|
offer to all vs offer to none |
0.171 |
(0.159, 0.183) |
0.163 |
screen vs offer to none |
0.115 |
(0.105, 0.124) |
0.105 |
offer to all minus screen |
0.056 |
(0.048, 0.065) |
0.057 |
On this draw each interval contains its population value. The screen interval does so by a narrow
margin: its lower end is 0.10533. One draw is not a coverage result.
The review probe reviews/notebook-review/probes/interventions-final/sweep_binary.py reruns this
fit on 120 new draws. Each regime interval covered its population value on 117 of 120 draws.
The last row answers the office’s question. An offer to every patient, instead of the flagged patients only, adds 0.056 to the mean score. It costs offers to the 1479 unflagged patients.
The summary also prints simultaneous 95% bands, with critical value 2.212. Use them when a report reads both regime contrasts together.
The support column marks both contrasts and the bands experimental. The note under the table
says that no registered study covers this configuration. The support label makes no coverage claim, and
Method evidence names the gap.
Step 6: check the support of each plan#
A rule needs positivity only where it assigns. The support report has one row for each plan. It reads the fitted g.
regime_assessment = regime_result.assess()
print("needs attention:", tuple(item.name for item in regime_assessment.attention))
print()
regime_support = regime_assessment.report("support")
print(regime_support.summary())
needs attention: ()
regime support (n = 3000)
regime min g max ratio ratio effective n score load unsupported
-------------- --------- --------- ----------------- ------------------------ -----------
offer to none 0.06253 15.58 1295.4 1295.4/3000 (draw 01/01) 0
offer to all 2.664e-06 35.73 947.7 947.7/3000 (draw 01/01) 0
screen on risk 0.007032 26.06 1459.0 1459.1/3000 (draw 01/01) 0
min g, max ratio and ratio effective n read the treatment mechanism before truncation.
score load reads the truncated mechanism of the targeting step, as Kish-equivalent mask rows from abs(w_i * H_ij), not estimator ESS.
What this output tells you. needs attention is empty.
column |
what it shows |
the screen |
|---|---|---|
min g |
the smallest fitted probability of the arm the plan assigns, before truncation |
0.007032 |
max ratio |
the largest ratio of the plan’s probability to the fitted probability of the observed arm |
26.06 |
ratio effective n |
the effective sample size of those ratios |
1459.0 of 3000 |
The bound 0.0114 caps a weight at about 88. The largest ratio at the observed arm is below that cap for each plan: 15.58, 35.73, and 26.06. Truncation therefore leaves the influence-curve weights unchanged on this draw. Targeting also updates Q at the assigned arm of every row. For a row whose fitted g of that arm is below 0.0114, the bound limits that update.
Offer to all has the smallest effective sample size, 947.7. It needs an offer probability for every patient, including the patients whom current practice rarely offers. The screen asks for offers where current practice already offers more often, and its effective sample size is the largest.
These numbers describe the fitted degree-2 logistic model, not the law. The model’s smallest probability of an offer is 2.664e-06, and the law never assigns an offer with probability below 0.05. That row’s observed arm is usual support, so it adds no weight to the offer-to-all influence curve.
Proposal 2: the nudge, an incremental intervention#
The nudge assigns no patient a fixed arm. It describes a campaign that makes discharge teams more likely to offer navigation, while each team keeps its own ranking of patients. Under the nudge, each patient’s odds of an offer are twice the current odds.
Step 7: write the protocol, then identify the nudge#
The nudge uses the data of Step 2 and the design of Step 4. Its protocol changes the strategies,
the versions, and the assumption rationale. Incremental(1.0) keeps the current odds, so it is
current practice. Incremental(2.0) doubles the odds. law.incremental_truth returns the
population values of the known law.
from cleverly import IncrementalEffect
from cleverly.interventions import Incremental
incremental_protocol = replace(
program,
treatment_strategies=(
"Keep each patient's current conditional odds of a navigation offer",
"Multiply each patient's current conditional odds of a navigation offer by two",
),
treatment_versions=(
"The offer as the current discharge-team process assigns it",
"The standard offer, assigned at random with the doubled odds given the recorded "
"baseline variables",
),
assumption_rationale=(
*program.assumption_rationale,
"The program can run a randomized offer at the doubled odds",
),
)
incremental_truth = law.incremental_truth((1.0, 2.0))
incremental_study = CausalStudy(frame, design=study.design, protocol=incremental_protocol)
incremental_effect = incremental_study.identify(
IncrementalEffect(
(
Incremental(1.0, name="current odds"),
Incremental(2.0, name="double odds"),
)
)
)
print(incremental_effect.summary())
contrast of incremental propensity interventions
identified by explicit-adjustment: identified ipsi-indexed plug-in functional of E(transition_score | transition_navigation=a, W) for a in [0, 1] with influence correction
adjustment/history: ['discharge_risk', 'prior_utilization', 'medication_burden', 'age']
required nuisances: ['outcome_regression', 'treatment_mechanism']
assumptions:
- consistency: Y = Y^a when A = a
- no interference: one unit's potential outcome does not depend on other units' treatment assignments
- no unmeasured confounding: Y^a is independent of A given W
- *no positivity assumption*: the clever covariate is delta/D at A=1 and 1/D at A=0 with D = delta*g + 1 - g, so it lies between min(delta, 1/delta) and max(delta, 1/delta) however small g is. This is the estimand's reason for existing, not an oversight in the list
- the intervention is a functional of the observed-data law: q_delta is built out of g, so the influence function carries a term for the pathwise derivative through it and the estimator fluctuates the mechanism as well as Qbar
causal study protocol: schema 1; 17aa1c666565ec98
target population: Adults with a discharge-home order at a participating hospital during the enrollment period
eligibility: ['Age 18 years or older', 'Discharge home ordered at a participating hospital']
time zero: Discharge-home order, after baseline measurement and before the navigation offer
treatment strategies: ["Keep each patient's current conditional odds of a navigation offer", "Multiply each patient's current conditional odds of a navigation offer by two"]
treatment versions: ['The offer as the current discharge-team process assigns it', 'The standard offer, assigned at random with the doubled odds given the recorded baseline variables']
outcome: Patient-reported transition score, as a share of the maximum score
horizon: 30 days after discharge
intercurrent-event handling: ['Use the transition score regardless of readmission', 'Analyze the offer regardless of completed contacts', 'The protocol scores death before day 30 as the worst transition score (composite strategy)']
interference unit: Individual patient
assumption rationale: ['The recorded baseline variables cover the measured common causes', 'The standardized offer and version records support consistency', 'Reserved navigator capacity and access controls support no interference', 'The program can run a randomized offer at the doubled odds']
What this output tells you. The protocol fingerprint is 17aa1c666565ec98. Two assumptions
differ from those of the screen.
assumption |
what it means for the nudge |
|---|---|
no positivity assumption |
the clever covariate stays between one half and two for this multiplier, however small g is |
the intervention is a functional of the observed-data law |
the policy is built from g, so the fit also targets g |
The second assumption has a cost. Double robustness means that the point estimate stays consistent when either nuisance model is consistent. The estimator is not doubly robust, because g is part of the estimand: a good Q cannot correct an inconsistent g. Kennedy (2019), Section 3.3, states this, and the incremental section gives both properties.
To run the nudge, the program needs each patient’s current odds. In practice it estimates them, so the policy it runs is built from an estimated g. The cited sources do not establish inference for that realized policy.
Step 8: estimate the nudge contrast#
The fit reuses method from Step 5, so only the estimand and the protocol change. The estimate
depends on g, so the code also prints the nuisance report and the score-equation check.
incremental_result = incremental_effect.estimate(method=method)
double_odds = incremental_truth["ate_ipsi[odds x2 vs natural course]"]
incremental_table = against_truth(
incremental_result, {"ate_ipsi[double odds vs current odds]": double_odds}
)
print(incremental_table.round(3).to_string(index=False))
tilt = incremental_result["ate_ipsi[double odds vs current odds]"]
distance = (tilt.psi - double_odds) / tilt.std_error
print(f"distance from the population value: {distance:.1f} standard errors")
print("protocol fingerprint on the result:", incremental_result.provenance.protocol_fingerprint)
print()
incremental_assessment = incremental_result.assess()
print("needs attention:", tuple(item.name for item in incremental_assessment.attention))
print()
print(incremental_assessment.report("nuisance_models").summary())
print()
print("score-equation check:")
for row in incremental_assessment.report("score_equations").rows:
size = "below 0.01" if row.ratio < 0.01 else f"{row.ratio:.2f}"
verdict = "solved" if row.passed else "not solved"
print(f" {row.name:38s} {row.kind:16s} {verdict}; |score| / threshold {size}")
estimand psi ci_lower ci_upper population value
ate_ipsi[double odds vs current odds] 0.025 0.023 0.027 0.024
distance from the population value: 0.9 standard errors
protocol fingerprint on the result: 17aa1c666565ec98
needs attention: ()
Nuisance model diagnostics
----------------------------------------
model auc brier log_loss r2 mse cal_slope cal_se reg_slope
---------- ------ ------ -------- ------ ------ --------- ------ ---------
propensity 0.6960 0.2197 0.6275 - - 0.9353 0.0545 -
outcome - - - 0.6465 0.0202 - - 0.9538
Calibration slope: logistic recalibration of the out-of-fold predictions with one intercept per fold.
A finding needs the 95.0% interval to lie above 0 and exclude 1, a Bonferroni level over 1 tested model(s).
VERDICT: nuisance fits look reasonable.
score-equation check:
ipsi fluctuation solved; |score| / threshold below 0.01
ipsi (mechanism) fluctuation solved; |score| / threshold below 0.01
ate_ipsi[double odds vs current odds] influence curve solved; |score| / threshold below 0.01
What this output tells you. The estimate is 0.025, with a 95% interval of (0.023, 0.027). The
population value is 0.024, so the interval contains it on this draw. The estimate lies 0.9 standard
errors above the population value. The review probe
reviews/notebook-review/probes/interventions-final/truth.py recomputes the population value from
the structural equations as 0.024137.
The score-equation check has two fluctuation rows. ipsi (mechanism) is the targeting equation
for g, which the screen fit does not have. The check marks both rows as solved.
The nuisance report gives the propensity a calibration slope of 0.9353 and finds no problem. That verdict describes the out-of-fold fit on these data. It does not show that the model has the law’s form.
The estimator is not doubly robust, so the form of g matters more here than for the screen. A calibration check cannot settle it. The binary review probe refit the nudge with two treatment models on 120 draws.
treatment model for the nudge |
intervals that covered |
mean error (Monte Carlo SE) |
median calibration slope |
|---|---|---|---|
this page’s degree-2 logistic model |
116 of 120 |
+0.00011 (0.00008) |
0.93 |
a shallow, regularized booster |
110 of 120 |
+0.00066 (0.00008) |
0.94 |
The booster’s estimates ran above the population value by about eight Monte Carlo standard errors on average. The two calibration slopes are alike, so the calibration check did not separate the models. Only the sweep did, and only because the law is known. A real analysis cannot run this check, so it needs a treatment model chosen for its flexibility and calibration.
Proposal 3: the raise, a modified treatment policy#
The office sets a navigation intensity for each patient at discharge. The raise adds 0.5 index units to each patient’s intensity. A patient whose new intensity would exceed the capacity of 5 units keeps the current intensity.
Step 9: the intensity data#
make_shift_dose draws its own law, shift_dgp, and returns the population value of each declared
policy. The code declares four policies. Current practice is the reference. The capped raise is the
proposal. Two uncapped raises show what the cap and the size of the raise change, in Steps 12
and 13.
from cleverly.datasets import make_shift_dose
dose_frame, dose_truth = make_shift_dose(
n=3_000,
seed=32,
policies=(
(0.0, None, "current practice"),
(0.5, 5.0, "+0.5 capped at 5"),
(0.5, None, "+0.5 uncapped"),
(1.0, None, "+1.0 uncapped"),
),
)
dose_frame = dose_frame.rename(
columns={
"Y": "transition_score",
"A": "assigned_navigation_intensity",
"W1": "discharge_risk",
"W2": "navigator_caseload",
"W3": "caregiver_support",
}
)
intensity = dose_frame["assigned_navigation_intensity"]
print("rows and columns:", dose_frame.shape)
print(dose_frame.head().round(3))
print()
print(
f"assigned intensity: mean {intensity.mean():.3f}, minimum {intensity.min():.3f}, "
f"maximum {intensity.max():.3f}, share below zero {np.mean(intensity < 0):.1%}"
)
print()
print("known values of the synthetic law:")
for name, value in dose_truth.items():
print(f" {name:50s} {value:.4f}")
rows and columns: (3000, 5)
transition_score assigned_navigation_intensity discharge_risk navigator_caseload caregiver_support
0 1.694 0.358 0.909 0.751 -0.026
1 5.417 2.977 0.653 -0.651 -1.829
2 4.794 2.306 1.679 0.477 -2.066
3 0.334 1.579 -1.059 -0.620 0.342
4 2.644 1.082 1.327 0.308 -0.433
assigned intensity: mean 1.978, minimum -2.741, maximum 7.113, share below zero 5.1%
known values of the synthetic law:
ey_policy[current practice] 3.3950
ey_policy[+0.5 capped at 5] 4.1711
ey_policy[+0.5 uncapped] 4.2075
ey_policy[+1.0 uncapped] 5.1450
ate_policy[+0.5 capped at 5 vs current practice] 0.7761
ate_policy[+0.5 uncapped vs current practice] 0.8125
ate_policy[+1.0 uncapped vs current practice] 1.7500
What this output tells you. assigned_navigation_intensity is an index on a synthetic scale
with no physical zero. In this sample 5.1% of the values are below zero, and the law puts 5.6% of
its values there (reviews/notebook-review/probes/interventions-final/truth.py). The protocol in
Step 10 therefore calls the exposure an intensity index, not a count of contacts. Given W, the
intensity is normal with SD 1, as shift_dgp in src/cleverly/datasets/synthetic.py declares.
The known values give each policy mean, ey_policy, and each contrast against current practice,
ate_policy. The capped and the uncapped +0.5 raises add the same amount and have different
values, 0.7761 and 0.8125. Step 12 explains the gap.
Step 10: write the protocol, then identify the policies#
The intensity question keeps the population, the eligibility, and the horizon of the program protocol. It changes six fields, which the code lists. The outcome field states the scale: this law draws a continuous score with Gaussian noise, so no fixed range limits it.
treatment_kind="continuous" tells the design that the exposure is continuous.
Shift(delta, cap=..., name=...) adds delta to each patient’s intensity. A patient whose new
intensity would exceed the cap keeps the current intensity. cap has no default, so every policy
states it, and cap=None declares no cap.
from cleverly import ModifiedTreatmentPolicyEffect
from cleverly.interventions import Shift
dose_protocol = replace(
program,
outcome="Patient-reported transition score on a continuous composite scale with no fixed range",
time_zero=(
"Discharge-home order, after baseline measurement and before the navigation "
"intensity is assigned"
),
treatment_strategies=(
"Keep the navigation intensity that current practice assigns",
"Raise the assigned intensity by 0.5 index units unless the result would exceed "
"the declared capacity of 5 index units",
"Raise the assigned intensity by 0.5 index units",
"Raise the assigned intensity by 1.0 index unit",
),
treatment_versions=(
"Navigator support at the intensity current practice assigns",
"Navigator support at the raised intensity, or at the current intensity where "
"the raise would exceed 5 index units",
"Navigator support at the raised intensity",
"Navigator support at the raised intensity",
),
intercurrent_event_handling=(
program.intercurrent_event_handling[0],
"Analyze the assigned intensity regardless of the contacts delivered",
program.intercurrent_event_handling[2],
),
assumption_rationale=(
"Discharge risk, navigator caseload, and caregiver support cover the measured "
"common causes",
"Intensity records on the program's navigation intensity index support consistency",
"The staffing plan sets the capacity of 5 index units before fitting, not these data",
program.assumption_rationale[2],
),
)
dose_study = CausalStudy(
dose_frame,
design=PointTreatment(
outcome="transition_score",
treatment="assigned_navigation_intensity",
adjustment=("discharge_risk", "navigator_caseload", "caregiver_support"),
treatment_kind="continuous",
),
protocol=dose_protocol,
)
policies = (
Shift(0.0, cap=None, name="current practice"),
Shift(0.5, cap=5.0, name="+0.5 capped at 5"),
Shift(0.5, cap=None, name="+0.5 uncapped"),
Shift(1.0, cap=None, name="+1.0 uncapped"),
)
shift_effect = dose_study.identify(ModifiedTreatmentPolicyEffect(policies))
print(shift_effect.summary())
contrast of modified treatment policies
identified by explicit-adjustment: identified policy-indexed plug-in functional of E(transition_score | assigned_navigation_intensity=a, W) with influence correction
adjustment/history: ['discharge_risk', 'navigator_caseload', 'caregiver_support']
required nuisances: ['outcome_regression', 'treatment_density']
assumptions:
- consistency: Y = Y^a when A = a
- no interference: one unit's potential outcome does not depend on other units' treatment assignments
- no unmeasured confounding: Y^a is independent of A given W
- positivity *for the assigned dose*: g(d(a, w) | w) > 0 wherever g(a | w) > 0, so the dose the policy assigns is one the data have seen at that covariate value. The policy must preserve conditional treatment support. An upper cap alone does not ensure this for negative shifts or support gaps. The fit warns when assigned doses leave the observed range in either direction. check_policy_support reports estimated zero density at assigned doses; it does not establish true conditional support or identification
- the policy is a known function of (A, W), and of a randomizer of known law when it is randomized: d does not depend on the observed-data law, so the influence function carries no term for estimating it. A cap fitted from the data would break this, which is why cap= is required rather than defaulted to max(A)
- on a continuous treatment, d is piecewise smooth invertible (Diaz, Williams, Hoffman & Schenck 2023, Assumption 4): a policy that maps an interval to one dose is not pathwise differentiable and is refused
- with delta=: missingness at random given (A, W), and positivity for it *at the assigned dose* -- P(Delta = 1 | A = d(a, w), W = w) > 0, not only at the dose observed. The fluctuation updates Qbar as a function of the dose, so obtaining Qbar*(d(A, W), W) reads the mechanism where the policy sends the unit
- with intermediate=: the same two statements for P(Z = z | A, W), plus that Delta is not caused by Z -- the assumption missingness_design() states for an arm
causal study protocol: schema 1; 52fa85ddd9f40bc1
target population: Adults with a discharge-home order at a participating hospital during the enrollment period
eligibility: ['Age 18 years or older', 'Discharge home ordered at a participating hospital']
time zero: Discharge-home order, after baseline measurement and before the navigation intensity is assigned
treatment strategies: ['Keep the navigation intensity that current practice assigns', 'Raise the assigned intensity by 0.5 index units unless the result would exceed the declared capacity of 5 index units', 'Raise the assigned intensity by 0.5 index units', 'Raise the assigned intensity by 1.0 index unit']
treatment versions: ['Navigator support at the intensity current practice assigns', 'Navigator support at the raised intensity, or at the current intensity where the raise would exceed 5 index units', 'Navigator support at the raised intensity', 'Navigator support at the raised intensity']
outcome: Patient-reported transition score on a continuous composite scale with no fixed range
horizon: 30 days after discharge
intercurrent-event handling: ['Use the transition score regardless of readmission', 'Analyze the assigned intensity regardless of the contacts delivered', 'The protocol scores death before day 30 as the worst transition score (composite strategy)']
interference unit: Individual patient
assumption rationale: ['Discharge risk, navigator caseload, and caregiver support cover the measured common causes', "Intensity records on the program's navigation intensity index support consistency", 'The staffing plan sets the capacity of 5 index units before fitting, not these data', 'Reserved navigator capacity and access controls support no interference']
What this output tells you. The protocol fingerprint is 52fa85ddd9f40bc1. The four strategies
name the policies in the order of the fit. The protocol states the capacity of 5 index units in
words, and its rationale records that the staffing plan sets the capacity before the fit. The
protocol cannot check the cap=5.0 in the code.
The required nuisances are Q and treatment_density, the conditional density of the intensity. Two
assumptions are new.
assumption |
what it means for the raise |
|---|---|
positivity for the assigned dose |
the data must contain the intensity a policy assigns, at that covariate value. An upper cap alone does not ensure this |
the policy is a known function of (A, W) |
the cap comes from the staffing plan, not from the data. A cap fitted from the data defines a different parameter |
The summary also lists assumptions for delta= and intermediate=. This fit declares neither, so
those two do not apply.
Step 11: estimate the policy contrasts#
setting |
value |
reason |
|---|---|---|
|
degree-2 polynomial least squares |
fits Q from |
|
a regularized gradient boosting classifier |
fits the conditional density of the intensity as a pooled hazard over bins. Depth 2, learning rate 0.05, 200 iterations, and an L2 penalty of 1.0 limit it. scikit-learn stops a booster early only above 10,000 rows, so its default settings overfit 3000 rows |
|
40 bins |
sets the resolution of the density |
|
no outer folds |
fits each nuisance on every row |
This fit targets in sample, as the registered study does. The standard error of a shift fit reads
the estimated density ratio, and a ratio fit on held-out rows is noisier. The modified treatment
policies reference
gives a probe of a cross-fitted +1.0 shift. With a default booster density, its mean standard
error was 3.91 times the empirical SD. With the exact density it was 0.98 times. A cross-fitted fit of this score would also need
a declared q_bounds, and the score has no fixed range.
The fit can raise a PositivityWarning. The code records each warning and prints it above the
results.
from cleverly import PositivityWarning
shift_method = TMLEMethod(
models=ModelSpec(
outcome_learner=make_pipeline(PolynomialFeatures(2), LinearRegression()),
treatment_learner=HistGradientBoostingClassifier(
max_depth=2,
learning_rate=0.05,
max_iter=200,
l2_regularization=1.0,
random_state=32,
),
density_bins=40,
),
cross_fitting=CrossFitting(enabled=False),
runtime=Runtime(random_state=32, n_jobs=1),
)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", PositivityWarning)
shift_result = shift_effect.estimate(method=shift_method)
positivity_warnings = [str(w.message) for w in caught if w.category is PositivityWarning]
print("\n".join(positivity_warnings))
print()
shift_table = against_truth(shift_result, dose_truth)
shift_table["interval width"] = shift_table["ci_upper"] - shift_table["ci_lower"]
print(shift_table.round(3).to_string(index=False))
print()
print("protocol fingerprint on the result:", shift_result.provenance.protocol_fingerprint)
policy '+0.5 uncapped' has cap=None, and 0.1% of rows are assigned a dose above the largest one observed (7.11). The outcome regression is extrapolating there. Identification requires the policy to preserve conditional treatment support; a cap alone does not establish this.
policy '+1.0 uncapped' has cap=None, and 0.1% of rows are assigned a dose above the largest one observed (7.11). The outcome regression is extrapolating there. Identification requires the policy to preserve conditional treatment support; a cap alone does not establish this.
estimand psi ci_lower ci_upper population value interval width
ate_policy[+0.5 capped at 5 vs current practice] 0.777 0.751 0.803 0.776 0.052
ate_policy[+0.5 uncapped vs current practice] 0.816 0.792 0.840 0.812 0.048
ate_policy[+1.0 uncapped vs current practice] 1.746 1.681 1.812 1.750 0.131
protocol fingerprint on the result: 52fa85ddd9f40bc1
What this output tells you. Two warnings name the uncapped policies. Some rows receive an intensity above the observed maximum of 7.11, and Q extrapolates there. The capped policy moves no row above 5, which is below 7.11, so it raises no warning. The absence of a warning is not a support check. Step 13 reads the support.
policy against current practice |
estimate |
95% interval |
population value |
|---|---|---|---|
|
0.777 |
(0.751, 0.803) |
0.776 |
|
0.816 |
(0.792, 0.840) |
0.812 |
|
1.746 |
(1.681, 1.812) |
1.750 |
On this draw each interval contains its population value. The +1.0 uncapped interval is the
widest, 0.131 against 0.052 and 0.048. The result carries the protocol fingerprint
52fa85ddd9f40bc1.
Step 12: the cap is part of the question#
The capped and the uncapped +0.5 raises add the same amount. They differ only for the patients
whose current intensity is above 4.5: the cap holds them at their current intensity. The two
policies are two estimands, and their gap measures what the capacity costs. The code estimates the
gap with contrast, which uses the joint influence curve, because both estimates come from the
same rows.
dose = intensity.to_numpy()
capped_name = "ate_policy[+0.5 capped at 5 vs current practice]"
uncapped_name = "ate_policy[+0.5 uncapped vs current practice]"
capped, uncapped = shift_result[capped_name], shift_result[uncapped_name]
gap = shift_result.contrast(
lambda p: p[0] - p[1], [uncapped_name, capped_name], name="uncapped - capped"
)
print(f"rows the cap holds back: {np.mean(dose + 0.5 > 5.0):.1%}")
print(
"population value, uncapped - capped: "
f"{dose_truth[uncapped_name] - dose_truth[capped_name]:.3f}"
)
print(f"estimate, uncapped - capped: {gap.psi:.3f}")
print(f"95% interval of the gap: {gap.ci[0]:.3f} to {gap.ci[1]:.3f}")
print(f"standard error of the gap: {gap.std_error:.4f}")
print(
f"standard error of each estimate: {capped.std_error:.4f} (capped), "
f"{uncapped.std_error:.4f} (uncapped)"
)
rows the cap holds back: 2.5%
population value, uncapped - capped: 0.036
estimate, uncapped - capped: 0.038
95% interval of the gap: 0.029 to 0.047
standard error of the gap: 0.0047
standard error of each estimate: 0.0133 (capped), 0.0123 (uncapped)
What this output tells you. The cap holds back 2.5% of the rows. The population values of the two policies differ by 0.036. On this draw the estimated gap is 0.038, with a 95% interval from 0.029 to 0.047, which contains the population gap.
The standard error of the gap is 0.0047, below the standard error of each estimate, 0.0133 and 0.0123. The two estimates share every row, and their influence curves differ only on the rows that the cap holds back. Do not judge a gap between two estimates of one fit against the standard error of one estimate.
The gap is small because few patients reach the cap. That is a fact about these data, not about the estimand. A capacity inside the bulk of the intensity distribution would separate the two policies further. The office must state the capacity, because it is a fact about staffing. The modified treatment policies section explains why a cap estimated from the data would change the parameter.
Step 13: check the support of each raise#
The support report of a shift fit has one entry for each policy. Each entry describes the estimated density ratio of that policy. The code then prints the effective sample size fraction that the true density ratio gives for each uncapped raise. For a normal intensity with SD 1, that fraction is exp(-δ²).
shift_assessment = shift_result.assess()
print("needs attention:", tuple(item.name for item in shift_assessment.attention))
print()
for report in shift_assessment.report("support").values():
print(report.summary())
for delta in (0.5, 1.0):
print(f"Kish fraction under the true density ratio for +{delta}: {np.exp(-(delta**2)):.1%}")
needs attention: ('support',)
current practice: min g(A|W)=0.00243, max ratio=1, ESS=3000 (100.0% of n), moved=0.0%, capped=0.0%, unsupported=0, score load=3000.0/3000 Kish-equivalent mask rows (draw 01 of 01)
ratio quantiles -- 1%: 1, 5%: 1, 50%: 1, 95%: 1, 99%: 1
mean ratio -- 1 overall, per fold 1 (under the true density: P(d(A, W) in the conditional support), equal to 1 when the policy preserves support, for capped and uncapped policies; finite-sample means also vary with sampling and density estimation). Unsupported counts refer to estimated zero density at assigned doses; these diagnostics do not establish true support or identification.
+0.5 capped at 5: min g(A|W)=0.00243, max ratio=9.46, ESS=2028 (67.6% of n), moved=97.5%, capped=2.5%, unsupported=0, score load=2028.4/3000 Kish-equivalent mask rows (draw 01 of 01)
ratio quantiles -- 1%: 0.195, 5%: 0.402, 50%: 0.896, 95%: 2.08, 99%: 3.92
mean ratio -- 1.04 overall, per fold 1.04 (under the true density: P(d(A, W) in the conditional support), equal to 1 when the policy preserves support, for capped and uncapped policies; finite-sample means also vary with sampling and density estimation). Unsupported counts refer to estimated zero density at assigned doses; these diagnostics do not establish true support or identification.
+0.5 uncapped: min g(A|W)=0.00243, max ratio=8.46, ESS=2126 (70.9% of n), moved=100.0%, capped=0.0%, unsupported=2, score load=2126.1/3000 Kish-equivalent mask rows (draw 01 of 01)
ratio quantiles -- 1%: 0.195, 5%: 0.402, 50%: 0.896, 95%: 2.04, 99%: 3.42
mean ratio -- 1.03 overall, per fold 1.03 (under the true density: P(d(A, W) in the conditional support), equal to 1 when the policy preserves support, for capped and uncapped policies; finite-sample means also vary with sampling and density estimation). Unsupported counts refer to estimated zero density at assigned doses; these diagnostics do not establish true support or identification.
+1.0 uncapped: min g(A|W)=0.00243, max ratio=43.3, ESS=761 (25.4% of n), moved=100.0%, capped=0.0%, unsupported=3, score load=760.5/3000 Kish-equivalent mask rows (draw 01 of 01)
ratio quantiles -- 1%: 0.0716, 5%: 0.103, 50%: 0.64, 95%: 3.23, 99%: 6.8
mean ratio -- 1.08 overall, per fold 1.08 (under the true density: P(d(A, W) in the conditional support), equal to 1 when the policy preserves support, for capped and uncapped policies; finite-sample means also vary with sampling and density estimation). Unsupported counts refer to estimated zero density at assigned doses; these diagnostics do not establish true support or identification.
Kish fraction under the true density ratio for +0.5: 77.9%
Kish fraction under the true density ratio for +1.0: 36.8%
What this output tells you. needs attention names support. The fitted 40-bin density
assigns zero density to 2 shifted intensities for +0.5 uncapped and to 3 for +1.0 uncapped,
outside its bin range. These zeros describe the fitted model. The true conditional density is
positive at every intensity.
policy |
estimated effective sample size |
true-density reference |
|---|---|---|
current practice |
100.0% |
100% |
|
67.6% |
not printed |
|
70.9% |
77.9% |
|
25.4% |
36.8% |
A larger raise moves patients further into intensities that are rare for their covariates. The
true-density reference measures that strain in the population: 77.9% for a raise of 0.5 and 36.8%
for a raise of 1.0. The largest estimated ratio of the +1.0 raise is 43.3. A smaller raise, a cap, or a larger sample reduces the strain.
On this draw the estimated ratios are more dispersed than the true ratios, so each estimated
effective sample size is below its reference. The mean ratio line gives a second check: with a
correct density, its population value is 1 for every policy here. The fitted means are 1.04, 1.03,
and 1.08, so the estimated ratio runs high, most for +1.0. The review probe
reviews/notebook-review/probes/interventions-final/sweep_dose.py reruns this fit on 120 draws. There,
the raise intervals covered on 114 to 117 of 120 draws, and these means averaged 1.03 to 1.05. With the exact density they averaged 1.00 to 1.01.
Step 14: a stress test of the screen#
No diagnostic can detect an unmeasured confounder. A
sensitivity analysis asks how far the
estimate moves under a stated violation. cleverly has no omitted-variable bound for a regime
contrast. Roadmap item X9 tracks it. For a regime fit, cleverly runs a simulated common-cause
stress surface, which the
stress-surface section
describes.
The grid ConfounderStrengthGrid(treatment=(0.0, 0.1), outcome=(0.0,)) flips the treatment of each
row whose latent value lies in the top 10% of a standard normal. It has no outcome strength. An
outcome strength subtracts a multiple of the latent value from the score. That moves the score
outside its declared range of 0 to 1. The nonzero cell refits both nuisances once.
from cleverly.sensitivity import ConfounderStrengthGrid
surface = regime_result.sensitivity.simulated_confounding(
estimand=SCREEN,
grid=ConfounderStrengthGrid(treatment=(0.0, 0.1), outcome=(0.0,)),
)
print(surface.summary())
print()
screen_se = regime_result[SCREEN].std_error
for cell in surface.cells:
print(
f"treatment strength {cell.treatment_strength:.2f}: movement "
f"{cell.displacement / screen_se:+.1f} standard errors of the screen estimate"
)
Simulated common-cause stress surface for 'ate_regime[screen on risk vs offer to none]'
original estimate: 0.11473
seeds: root=31, latent=2459163703, refit=31
cross-fitting: 1 draw, no repeat aggregation
target measure: unweighted
target population: baseline
conditioning arm: None
baseline stratum: None
strata columns: ()
association population: full_fitted_population
calibration population: full_fitted_population
refit population: full_fitted_population
Treatment is flipped in the declared upper latent-normal tail.
Gaussian outcome subtracts signed strength times the shared latent value.
treatment strength outcome strength estimate movement (estimate difference) induced association population fraction
------------------ ---------------- -------- ------------------------------ ------------------- -------------------
0 0 0.11473 +0 +0.0034 1.0000
0.1 0 0.085688 -0.029037 +0.0415 1.0000
The induced association is the correlation between the latent vector and the treatment of the cell within the selected baseline population.
An association near zero says the treatment axis moved the estimate by misclassification and not by confounding.
This surface is qualitative. It is not a bound or sensitivity-adjusted inference.
treatment strength 0.00: movement +0.0 standard errors of the screen estimate
treatment strength 0.10: movement -6.1 standard errors of the screen estimate
What this output tells you. The flip moves the screen estimate from 0.11473 to 0.085688, a movement of -6.1 standard errors. The induced association between the latent value and the flipped treatment is +0.0415, which is small. The latent value does not enter the score. The movement therefore comes from misclassification of the treatment, not from confounding, and the summary reads it the same way.
This surface is qualitative. It is not a bound, and it does not say how strong a confounder would need to be to change the screen conclusion. The case for no unmeasured confounding rests on the protocol’s list of recorded common causes. A program reviewer must judge whether a variable such as caregiver availability at discharge also drives the offer.
Method evidence#
The fits on this page use one draw of a synthetic law. The registered studies in the table measure each method over repeated draws. Each study page gives its law, its nuisance models, and the result of each cell.
fit on this page |
registered study |
law of the study |
|---|---|---|
Step 5, the screen |
another law |
|
Step 8, the nudge |
another law |
|
Step 11, the raise |
the Step 9 intensity law, with another curvature |
Each study supplies g or the density exactly. No registered study fits them from data, as this page does.
A review probe is a script under reviews/notebook-review/probes/. The step readings quote its
results. A review probe is not a registered study.
The technical entry lists the evidence and the limits of the method.
What the results mean for the office#
Each estimate answers one proposal, for the program population, under the assumptions in the problem. Each interval is a pointwise 95% Wald interval from the influence curve.
proposal |
the estimate on this draw |
what it means |
what it does not mean |
|---|---|---|---|
screen |
offer to all minus screen: 0.056 (0.048, 0.065) |
an offer to every patient, instead of the flagged half, adds this much to the mean score |
the value of the screen in another population, or with another risk cut-off. Each rule is its own estimand |
nudge |
0.025 (0.023, 0.027) against current practice |
doubling every patient’s offer odds raises the mean score by this much |
the value of a nudge that the program runs with an estimated g, or of a different multiplier |
raise |
0.777 (0.751, 0.803) against current practice, on the composite score scale |
raising each intensity by 0.5 units within the capacity raises the mean composite score by this much |
a dose-response value, or the value under a different capacity |
The raise uses a different dataset and scale, so do not compare it with the other two. The screen
and the nudge share one population and one outcome, but their contrasts on this page have different
reference worlds. To choose between them, the office needs both policy means against one reference,
with a joint interval. cleverly fits one intervention class at a time, so this page cannot give
that joint interval.
The office must still weigh each gain against the navigator hours it costs. These data do not record cost.
Where to go next#
Every proposal on this page acts at one decision time. Read sequential pricing for a policy that moves a continuous price at three decision times. Read MSM projections for a working model that summarizes many plans as one trend. Point-treatment TMLE estimates the ATE and runs the omitted-variable bound that this page cannot run.
The examples index lists every tutorial in the program.