MSM projections: summarize three navigation cadences with one trend#
A marginal structural model (MSM) projection fits a declared working model to counterfactual means. This notebook fits a straight line in assigned contacts to the means of three navigation cadences. Each step shows its code, its output, and what the output tells you. MSM projections gives the projection, its clever covariate, and the algorithm.
The situation#
A hospital network sends adults home after a discharge-home order. At that order, staff assign one of three navigation cadences. Nobody randomized the assignment.
tier |
assigned protocol |
assigned contacts in 30 days |
|---|---|---|
|
one transition-planning contact |
1 |
|
planning plus one follow-up contact |
2 |
|
planning plus five follow-up contacts |
6 |
The table separates what the data record from what the question asks about.
quantity |
kind |
when it is fixed |
column |
|---|---|---|---|
discharge risk, age, baseline support need |
observed baseline covariates, W |
before assignment |
|
assigned cadence |
observed treatment, A |
at the discharge-home order, which is time zero |
|
transition score |
observed outcome, Y |
30 days after discharge |
|
the score had the patient been assigned cadence a |
counterfactual outcome, Y^a |
never for every a. Each patient shows only the score of the assigned cadence |
none |
Completed contacts, readmission, and death occur after assignment. The data set does not record them, and the analysis must not adjust for them.
The causal graph#
discharge_risk ──┬───────────────────► transition_score ◄── baseline_support_need
│ ▲ ▲
▼ │ │
cadence ───────────────────┘ │
▲ │
age ─────────────┴────────────────────────────────┘
arrow |
what it asserts |
what the analysis does with it |
|---|---|---|
|
staff use risk and age to choose the cadence |
with the next row, it makes both variables confounders |
|
risk and age change the score under every cadence |
the adjustment set includes both, which blocks the two back-door paths |
|
support need changes the score, and it does not change the cadence |
it is not a confounder. The adjustment set includes it to explain outcome variance |
|
the effect that the projection summarizes |
the only open path from cadence to score after adjustment |
absent arrow |
what it asserts |
what the analysis does with it |
|---|---|---|
no unmeasured cause of both |
no unmeasured confounding |
a scientific judgment. The data cannot check it, and Step 13 asks how strong a violation must be |
no arrow from one patient’s cadence to another patient’s score |
no interference |
a scientific judgment, which the protocol supports with reserved navigator capacity |
no node for completed contacts, readmission, or death |
these events occur after assignment |
completed contacts are a mediator, and death is a competing event. The adjustment set excludes all three. Step 4 states how the protocol handles each |
The graph has no censoring node, because every patient has a score. In this synthetic law, the cadence effect does not vary with the covariates, so no variable is an effect modifier. A real program would need to check that claim. The graph says nothing about the shape of the cadence effect. A straight line in contacts is therefore a choice of summary, not a property of the graph.
What you will learn#
after this notebook you can |
the step that shows it |
|---|---|
say why unadjusted cadence means mislead here |
Step 3 |
estimate one counterfactual mean per cadence |
Step 6 |
write the board’s question as a projection and fit it |
Step 7 |
code the cadences for the question the board asks |
Step 8 |
test whether the line fits the three means |
the failure mode, Step 9 |
say why the projection weight is part of the estimand |
Step 10 |
check the projection against the per-cadence report |
Step 11 |
read the support, truncation, and sensitivity evidence |
Steps 12 and 13 |
The causal question and the estimand#
The program board wants one number for its report: how the mean 30-day score changes with assigned contacts across the three cadences.
element |
definition |
|---|---|
target population |
adults with a discharge-home order at a participating hospital. Step 4 records the eligibility |
interventions |
assign every eligible patient |
counterfactual means |
E[Y^a] for each cadence a |
estimand |
the coefficients (β0, β1) of the line β0 + β1 c(a) that minimizes the sum over cadences of h(a) (E[Y^a] − β0 − β1 c(a))². Here c(a) is the assigned contacts and h(a) = 1 |
The slope β1 has a value whether or not the three means lie on a line. When they do not, β1 is a summary of the means. It is not the effect of one more contact.
simpler estimand |
why it does not answer the board’s question |
|---|---|
the difference in observed mean scores |
confounding biases it. Step 3 shows the observed order of two cadences reversed |
the three means, or the two contrasts against |
they answer one question per cadence and give no single per-contact number. They stay the primary result, and Step 11 reports them |
the contact coefficient of an outcome regression |
it is a projection of the observed data, so its value depends on the covariate model. It does not target E[Y^a] |
the slope of a regression weighted by stabilized inverse probabilities |
it uses h(a) = P(A = a). That is a different estimand when the cadence shares differ and the line does not fit (Step 10) |
Identification needs four assumptions.
assumption |
what it means for this program |
basis |
|---|---|---|
consistency |
each cadence always means its declared script and contacts |
scientific judgment |
no interference |
one patient’s cadence does not change another patient’s score |
scientific judgment |
no unmeasured confounding |
risk, age, and support need block every common cause of cadence and score |
scientific judgment |
positivity |
each covariate profile has some chance of each cadence |
checkable in part, through the support report of Step 12 |
Comparisons on this page#
Each comparison isolates one reason why two numbers differ. The page names that reason before the output.
step |
comparison |
estimands |
mechanism that makes them differ |
evidence |
|---|---|---|---|---|
3 |
observed cadence means against the true means |
observed conditional means against E[Y^a] |
risk and age confound the assignment |
the confounder means by cadence |
8 |
the contact coding against the step coding |
two projections with different designs |
each coding spaces the cadences differently, and the line does not fit |
the population slope of each coding |
9 |
the fitted line against the three cadence means |
projection values against E[Y^a] |
the gain per contact falls from |
the miss at |
10 |
the uniform weight against the share and 1:10:1 weights |
three projections with different weights |
the line does not fit, so the weight decides which cadences it fits best |
the population slope under each weight |
11 |
the saturated projection against the cadence contrasts |
the same estimand, written two ways |
none. The two must agree |
equal estimates and influence curves |
Why this method#
Each method choice answers one feature of the problem.
feature of the problem |
method choice |
reason |
|---|---|---|
one assignment at time zero, with baseline confounders only |
point-treatment TMLE |
no later treatment decision, time-varying confounder, or censoring needs longitudinal TMLE |
a treatment with three unordered labels |
a multinomial treatment model g |
each cadence gets its own probability, and positivity applies to each cadence |
a board question about one per-contact summary |
an MSM projection with a known design and a uniform weight |
it reduces three means to two coefficients, each with an influence curve and an interval |
a summary whose working model can be wrong |
a lack-of-fit contrast and a saturated control |
Steps 9 and 11 show what the line hides |
a score observed for every patient |
no missing-outcome model |
no score is missing in this data set |
finite-dimensional learners and no documented score range |
nuisances fitted in sample, without cross-fitting |
Step 6 gives the reason |
The projection has three costs. Its coefficients mean what the working model says. You code each cadence label as a number yourself. The slope is not the effect of one more contact unless the three means lie on a line.
term |
plain meaning |
|---|---|
estimand |
the number the question asks for, written before any model is chosen. See estimands |
counterfactual mean |
the mean score if every eligible patient were assigned one cadence |
working model |
the line you declare, as a design with named terms. It summarizes the means and does not claim to be the true response |
projection weight |
how much each cadence counts when the line is fitted to the means. Here every cadence counts equally |
nuisance |
a model the estimate needs but the question does not ask about. Here, the outcome regression Q and the treatment mechanism g. See point-treatment TMLE |
saturated model |
a working model with one free coefficient per cadence, so it reproduces the three means exactly |
Step 1: set up#
The setup imports the learners and prints the installed cleverly version. Every fit below passes
linear learners, a fold count, and a random seed. A rerun therefore reproduces the stored outputs.
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression, LogisticRegression
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 data#
make_multi_arm draws a three-arm confounded law with known counterfactual means. The code renames
its columns to the program’s names. It records the contact count of each cadence, prints the first
rows, and prints the known means.
from cleverly.datasets import make_multi_arm
frame, truth = make_multi_arm(n=3_000, seed=61)
frame = frame.rename(
columns={
"Y": "transition_score",
"A": "cadence",
"W1": "discharge_risk",
"W2": "age",
"W3": "baseline_support_need",
}
)
ARMS = ("low", "medium", "high")
CONTACTS_30D = {"low": 1.0, "medium": 2.0, "high": 6.0}
contacts = np.array([CONTACTS_30D[arm] for arm in ARMS])
population = np.array([truth[f"ey[{arm}]"] for arm in ARMS])
print("rows and columns:", frame.shape)
print("cadence labels:", sorted(frame["cadence"].unique()))
print(frame.head().round(3))
print()
print("known counterfactual means of the synthetic law:")
for arm, value in zip(ARMS, population, strict=True):
print(f" ey[{arm}] ({CONTACTS_30D[arm]:.0f} contacts): {value:.4f}")
gain_per_contact = np.diff(population) / np.diff(contacts)
print(f"gain per contact, low to medium: {gain_per_contact[0]:.2f}")
print(f"gain per contact, medium to high: {gain_per_contact[1]:.2f}")
rows and columns: (3000, 5)
cadence labels: ['high', 'low', 'medium']
transition_score cadence discharge_risk age baseline_support_need
0 1.266 high -0.567 -1.057 1.062
1 -0.691 low -1.129 -1.779 0.655
2 1.325 medium 0.841 -0.949 -0.565
3 5.003 medium 2.780 -1.017 -0.506
4 0.847 medium 1.179 0.745 1.612
known counterfactual means of the synthetic law:
ey[low] (1 contacts): -0.0000
ey[medium] (2 contacts): 0.6000
ey[high] (6 contacts): 1.4400
gain per contact, low to medium: 0.60
gain per contact, medium to high: 0.21
What this output tells you. Each row is one discharge, and cadence holds a text label. The
published truths use the generator’s labels, so the labels stay unchanged.
The generator draws the three covariates as independent standard-normal variables. Risk and age enter both the cadence probabilities and the score. Support need enters only the score. These are the arrows of the causal graph. The score has no unit and no documented range.
The true means are 0 for low (printed as -0.0000 after rounding), 0.6000 for medium, and
1.4400 for high. The gain per contact falls from 0.60 between low and medium to 0.21 between
medium and high. No straight line in assigned contacts passes through the three means, so the
working model of Step 7 is misspecified on purpose.
A real program has no truth. Every comparison against it below is a teaching device.
Step 3: association first#
A confounder is a variable that changes both the assigned cadence and the outcome. The code compares the cadences before any adjustment. It prints the observed mean score, the means of the two confounders, the share of patients, and the true counterfactual mean.
observed = frame.groupby("cadence")[["transition_score", "discharge_risk", "age"]].mean()
observed = observed.loc[list(ARMS)]
observed["share"] = frame["cadence"].value_counts(normalize=True).loc[list(ARMS)]
observed["true mean"] = population
print(observed.round(3))
transition_score discharge_risk age share true mean
cadence
low -0.082 -0.132 -0.128 0.272 -0.00
medium 1.324 0.532 -0.343 0.368 0.60
high 0.808 -0.387 0.474 0.360 1.44
What this output tells you. The observed means put medium above high, at 1.324 against
0.808. The true means reverse that order, at 0.60 against 1.44.
The confounded assignment causes the reversal. The table compares the two confounders in the medium and
high groups.
confounder |
|
|
effect on the score in this law |
|---|---|---|---|
|
0.532 |
-0.387 |
a higher value raises the score |
|
-0.343 |
0.474 |
a higher value lowers the score |
Both confounders push the observed medium mean above the observed high mean. A trend fitted to
these observed means would summarize the confounding too.
The shares are 0.272, 0.368, and 0.360. Step 10 uses them to show that the projection weight is part of the estimand.
Step 4: write the protocol#
A StudyProtocol records the scientific design before any model runs. The record gets a
fingerprint, and every result fitted from it carries that fingerprint. This page starts from
navigation_protocol(), the protocol of the
shared study design. dataclasses.replace lists the three
cadences as three strategies, each with its version.
from dataclasses import replace
from cleverly.datasets import navigation_protocol
program = navigation_protocol()
protocol = replace(
program,
outcome="Patient-reported transition score in synthetic units, with no documented range",
time_zero="Discharge-home order, after baseline measurement and before cadence assignment",
treatment_strategies=(
"Assign the low navigation cadence",
"Assign the medium navigation cadence",
"Assign the high navigation cadence",
),
treatment_versions=(
"One transition-planning contact within 30 days",
"Transition planning plus one follow-up contact within 30 days",
"Transition planning plus five follow-up contacts within 30 days",
),
intercurrent_event_handling=(
program.intercurrent_event_handling[0],
"Analyze the assigned cadence regardless of completed contacts",
"The score has no worst value, so the protocol handles death before day 30 "
"by the hypothetical strategy: the score had the patient not died",
),
assumption_rationale=(
"The recorded baseline variables cover the measured common causes of cadence and score",
"Every cadence uses the same script and access rules, which supports consistency",
program.assumption_rationale[2],
),
)
print("\n".join(protocol.summary_lines()))
causal study protocol: schema 1; ec77f97f713c2306
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 cadence assignment
treatment strategies: ['Assign the low navigation cadence', 'Assign the medium navigation cadence', 'Assign the high navigation cadence']
treatment versions: ['One transition-planning contact within 30 days', 'Transition planning plus one follow-up contact within 30 days', 'Transition planning plus five follow-up contacts within 30 days']
outcome: Patient-reported transition score in synthetic units, with no documented range
horizon: 30 days after discharge
intercurrent-event handling: ['Use the transition score regardless of readmission', 'Analyze the assigned cadence regardless of completed contacts', 'The score has no worst value, so the protocol handles death before day 30 by the hypothetical strategy: the score had the patient not died']
interference unit: Individual patient
assumption rationale: ['The recorded baseline variables cover the measured common causes of cadence and score', 'Every cadence uses the same script and access rules, which supports consistency', 'Reserved navigator capacity and access controls support no interference']
What this output tells you. The first line gives the schema version and the fingerprint
ec77f97f713c2306. The other lines repeat each field. This page changes six fields of
navigation_protocol(), and
point-treatment TMLE reads the others.
protocol field |
what this page writes |
|---|---|
outcome |
the score is in synthetic units and has no documented range. The program records a share of the maximum score |
time zero |
the discharge-home order, before cadence assignment |
treatment strategies and versions |
the three cadences, with their contacts in words |
intercurrent-event handling |
the assigned cadence is analyzed regardless of completed contacts. The program scores death as the worst score, and this score has no worst value. This page uses the hypothetical strategy for death |
assumption rationale |
the common causes of cadence and score, and one script for every cadence |
Under the hypothetical strategy, a real analysis treats death as censoring. It then needs exchangeability and positivity for death given the recorded history. This synthetic law draws no deaths, so the choice does not change the estimate here.
A score with no documented range has no range to declare as q_bounds. Step 6 uses that fact.
The protocol has no field for the contact mapping, the working model, or the projection weight. The typed estimand in Step 7 owns all three.
Step 5: design and identification#
The design is an ordinary point-treatment design with a three-level treatment. CounterfactualMean
asks for one mean per cadence. The projection in Step 7 summarizes those means.
from cleverly import CausalStudy, CounterfactualMean, PointTreatment
study = CausalStudy(
frame,
design=PointTreatment(
outcome="transition_score",
treatment="cadence",
adjustment=("discharge_risk", "age", "baseline_support_need"),
),
protocol=protocol,
)
arm_means_effect = study.identify(CounterfactualMean())
print(arm_means_effect.summary(protocol="fingerprint"))
counterfactual mean under each treatment, E[Y^a]
identified by explicit-adjustment: E_W[E(transition_score | cadence=a, W)] for a in ['high', 'low', 'medium']
adjustment/history: ['discharge_risk', 'age', 'baseline_support_need']
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: P(cadence = a | W) > 0 almost surely for every supported treatment level a in ['high', 'low', 'medium']
causal study protocol: schema 1; ec77f97f713c2306
What this output tells you. The formula averages the outcome regression over all patients, once for each cadence. The required nuisances are the outcome regression and the treatment mechanism. The four assumptions are the ones that the causal question lists with their basis.
The positivity condition names high, low, and medium. Each level needs a positive probability
at every covariate value where the target places mass. The summary then prints the fingerprint of
the stored protocol.
Step 6: estimate each cadence’s mean#
Start with the per-cadence report, because the projection is a summary of it. The linear outcome model and the multinomial logistic treatment model are correctly specified for this law.
setting |
value |
what it does |
|---|---|---|
|
|
fits Q, the expected score given the cadence and the covariates |
|
|
fits g, the probability of each cadence given the covariates |
|
no outer folds |
fits each nuisance on every row |
|
fixed seed, one process |
makes the fit reproducible |
The fit runs in sample for two reasons. A cross-fitted fit of a continuous outcome declares the
outcome’s range as q_bounds, and this score has no documented range. The learners are
finite-dimensional regressions. That makes plausible the data-reuse condition that
CV-TMLE removes. The
cross-fitting tutorial shows the fold layer on a score with a recorded range.
Targeting makes a small update to Q, weighted by g, that removes first-order bias. Targeting and bounds defines it.
from cleverly import CrossFitting, ModelSpec, Runtime, TMLEMethod
method = TMLEMethod(
models=ModelSpec(
outcome_learner=LinearRegression(),
treatment_learner=LogisticRegression(max_iter=1000, random_state=61),
),
cross_fitting=CrossFitting(enabled=False),
runtime=Runtime(random_state=61, n_jobs=1),
)
arm_result = arm_means_effect.estimate(method=method)
arm_table = arm_result.to_frame().set_index("estimand").loc[[f"ey[{arm}]" for arm in ARMS]]
arm_table = arm_table[["psi", "std_err", "ci_lower", "ci_upper"]]
arm_table["true mean"] = population
print(arm_table.round(4))
print()
print("protocol fingerprint on the fit:", arm_result.provenance.protocol_fingerprint)
psi std_err ci_lower ci_upper true mean
estimand
ey[low] -0.0038 0.0365 -0.0754 0.0678 -0.00
ey[medium] 0.6399 0.0386 0.5641 0.7156 0.60
ey[high] 1.4642 0.0390 1.3877 1.5408 1.44
protocol fingerprint on the fit: ec77f97f713c2306
What this output tells you. Each row is one counterfactual mean. The medium estimate is 0.6399
with the interval (0.5641, 0.7156), and the true mean is 0.60. The high estimate is 1.4642, and
the low estimate is -0.0038.
Each interval contains its true mean on this draw. That is one draw, not a coverage result. The
interval comes from the influence curve, which measures how much each row moves the estimate. See
inference. The fit carries the protocol fingerprint
ec77f97f713c2306.
Step 7: declare the working model and fit the trend#
The design function receives one cadence label and the covariate frame. It returns the design
matrix for that cadence, with one column per term. The terms tuple names the columns.
The declaration leaves weights unset, which sets the projection weight h(a, V) to 1 for every
cadence. The same method fits the projection.
from cleverly import MSMProjection
from cleverly.msm import MSM
def contacts_design(arm, data):
"""One row per patient: an intercept and the cadence's assigned contact count."""
return np.column_stack([np.ones(len(data)), np.full(len(data), CONTACTS_30D[arm])])
trend = MSM(design=contacts_design, terms=("(intercept)", "assigned contacts"), design_kind="known")
trend_effect = study.identify(MSMProjection(trend))
trend_result = trend_effect.estimate(method=method)
print(trend_result.summary(protocol="fingerprint"))
Targeted maximum likelihood estimation
======================================
n = 3000; covariates = 3; arm shares: high=0.36, low=0.272, medium=0.368
causal estimand: projection of counterfactual means onto a working model
identification: explicit-adjustment; identified msm-indexed plug-in functional of E(transition_score | cadence=a, W) for a in ['high', 'low', 'medium'] 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: P(cadence = a | W) > 0 almost surely for every supported treatment level a in ['high', 'low', 'medium']; the working model and its weights are known functions of (a, V): neither phi nor h depends on the observed-data law, so the influence function carries no term for estimating them; the weighted Gram matrix is invertible, so the projection is a single coefficient vector rather than a set of them
causal study protocol: schema 1; ec77f97f713c2306
outcome family: gaussian; fluctuation: logistic (iterative)
TMLE (in-sample nuisances): nuisances fitted in-sample (cross_fit=False)
propensity truncated to [0.0114, 0.9886]; ATT/ATC to [0.025, 0.975]
outcome scaled from [-6.044, 7.723] to [0, 1]
data 248bf6e493a8a2be | folds 9ba86d4d13cb065e | cleverly 0.1.3 | 2026-10-16T01:17:23+00:00
estimand psi std_err 95% CI p_value support
---------------------- ------- -------- -------------------- ------- ------------
msm[(intercept)] -0.1068 0.03703 [-0.17937, -0.03423] 0.0039 experimental
msm[assigned contacts] 0.26875 0.008594 [0.25191, 0.2856] <1e-4 experimental
experimental: No registered study covers this configuration. Nearest row: T18 (point-msm), differing on treatment.
simultaneous 95% bands (multiplier bootstrap, critical value 2.107 vs 1.960 pointwise) [experimental]:
msm[(intercept)] [-0.18484, -0.028769]
msm[assigned contacts] [0.25064, 0.28686]
What this output tells you. The identification assumptions line lists the four causal
assumptions from Step 5 and adds two.
added assumption |
what it means here |
|---|---|
the working model and its weights are known functions |
|
the weighted Gram matrix is invertible |
the three cadences have distinct contact counts, so one line is closest |
The configuration lines have the same meaning as in point-treatment TMLE. Here the fluctuation solves one score equation per coefficient, not one per cadence.
The slope is 0.26875 transition-score units per additional assigned contact. The intercept is -0.1068, the line’s value at zero contacts, which no cadence assigns.
The simultaneous 95% bands are built to contain both coefficients at the same time, so their critical value is 2.107 instead of 1.960. Quote the pointwise interval when you report the slope alone.
The support column marks both coefficients and the bands experimental, because no registered
study covers this configuration. The support label makes no coverage claim. The review probe
reviews/notebook-review/probes/msm-projections-final/sweep.py repeats this fit on 4,000 draws of
this law at n = 3,000. The interval covered on 3,762 of 4,000 draws, a rate of 0.9405. The probe
does not isolate the cause of the shortfall.
With a uniform weight, the slope is a fixed contrast of the three means: (-2 E[Y(low)] - E[Y(medium)] + 3 E[Y(high)]) / 14. Step 9 checks that identity on the population means.
Step 8: code the cadences for the question the board asks#
The contact counts are the program’s decision, not the estimator’s. The number that a design
function writes for each label sets the question the slope answers. The code fits a second known
design that codes each cadence by its step, 0, 1, and 2. It prints both slopes beside the population
slope of each coding.
CADENCE_STEP = {"low": 0.0, "medium": 1.0, "high": 2.0}
def cadence_step_design(arm, data):
"""One row per patient: an intercept and the cadence's step, 0, 1, or 2."""
return np.column_stack([np.ones(len(data)), np.full(len(data), CADENCE_STEP[arm])])
step_trend = MSM(
design=cadence_step_design, terms=("(intercept)", "cadence step"), design_kind="known"
)
step_result = study.identify(MSMProjection(step_trend)).estimate(method=method)
codings = (
("assigned contacts", CONTACTS_30D, trend_result),
("cadence step", CADENCE_STEP, step_result),
)
coding_rows = []
for term, coding, result in codings:
values = np.array([coding[arm] for arm in ARMS])
coding_line, *_ = np.linalg.lstsq(np.column_stack([np.ones(3), values]), population, rcond=None)
slope = result[f"msm[{term}]"]
coding_rows.append(
{
"coefficient": f"msm[{term}]",
"low, medium, high": ", ".join(f"{value:.0f}" for value in values),
"slope": f"{slope.psi:.4f}",
"std_err": f"{slope.std_error:.4f}",
"95% CI": f"({slope.ci[0]:.4f}, {slope.ci[1]:.4f})",
"population slope": f"{coding_line[1]:.4f}",
}
)
print(pd.DataFrame(coding_rows).to_string(index=False))
coefficient low, medium, high slope std_err 95% CI population slope
msm[assigned contacts] 1, 2, 6 0.2688 0.0086 (0.2519, 0.2856) 0.2657
msm[cadence step] 0, 1, 2 0.7341 0.0224 (0.6902, 0.7779) 0.7200
What this output tells you. The two rows read the same three means through two codings.
coefficient |
slope |
interval |
population slope |
question it answers |
|---|---|---|---|---|
|
0.2688 |
(0.2519, 0.2856) |
0.2657 |
the change per assigned contact |
|
0.7341 |
(0.6902, 0.7779) |
0.7200 |
the change per cadence step |
The step coding counts the move from medium to high as one unit. The contact coding counts the
same move as four contacts. Neither line passes through the three means. The board asks for the
change per contact, so the rest of this page reads the contact slope. Each interval contains its
population slope on this draw.
Under the uniform weight, the per-step slope is half the difference between the high and low
means. Its population value is 1.44 / 2 = 0.7200. Step 11 prints the fitted high vs low contrast
as 1.4681, and half of that is 0.734.
Write the coding in the design function, as both fits here do. The labels have no order of their
own. Sorted as text, they are high, low, and medium.
Step 9: the failure mode, a projection is not a fitted curve#
The working model from Step 7 is wrong for this law. This step compares two estimands: the
projection’s value at each cadence, β0 + β1 c(a), and the cadence mean E[Y^a]. The mechanism that
separates them is the falling gain per contact that Step 2 prints. The code compares the line with the means,
first in the population and then in the fits. It measures the miss at medium with
arm_result.contrast(...), which reads the joint influence curve of the three cadence means. It
also computes the population slope as the fixed contrast of the three means.
design = np.vstack([contacts_design(arm, frame.iloc[:1]) for arm in ARMS])
names = [f"msm[{term}]" for term in trend.terms]
projection, *_ = np.linalg.lstsq(design, population, rcond=None)
estimated_line = design @ np.array([trend_result[name].psi for name in names])
in_population = pd.DataFrame(
{
"contacts": contacts,
"population mean": population,
"population line": design @ projection,
"line minus mean": design @ projection - population,
},
index=list(ARMS),
)
in_fits = (
arm_table[["psi", "ci_lower", "ci_upper"]]
.set_axis(list(ARMS))
.rename(columns={"psi": "estimated mean"})
)
in_fits["estimated line"] = estimated_line
in_fits["line minus mean"] = estimated_line - in_fits["estimated mean"]
print("in the population:")
print(in_population.round(4).to_string())
print()
print("in the fits:")
print(in_fits.round(4).to_string())
step7_medium_miss = in_fits.loc["medium", "line minus mean"]
print(f"absolute Step 7 miss at medium: {abs(step7_medium_miss):.4f}")
print()
# The least-squares line at medium is a fixed weighted sum of the three means.
misfit_weights = (design @ np.linalg.pinv(design))[1] - np.eye(3)[1]
medium_misfit = arm_result.contrast(
lambda means: float(misfit_weights @ means),
[f"ey[{arm}]" for arm in ARMS],
name="medium: line minus mean",
gradient=lambda means: misfit_weights,
)
low, high = medium_misfit.ci
print(f"medium: line minus mean, {medium_misfit.psi:.4f} CI=({low:.4f}, {high:.4f})")
print(f"absolute difference from the Step 7 miss: {abs(medium_misfit.psi - step7_medium_miss):.4f}")
print()
print(f"population projection: intercept {projection[0]:.4f}, slope {projection[1]:.4f}")
contrast_slope = (-2 * population[0] - population[1] + 3 * population[2]) / 14
print(f"slope as the fixed contrast of the three means: {contrast_slope:.4f}")
for name, target in zip(names, projection, strict=True):
low, high = trend_result[name].ci
print(f"{name}: CI=({low:.4f}, {high:.4f}) population projection {target:.4f}")
in the population:
contacts population mean population line line minus mean
low 1.0 -0.00 0.1486 0.1486
medium 2.0 0.60 0.4143 -0.1857
high 6.0 1.44 1.4771 0.0371
in the fits:
estimated mean ci_lower ci_upper estimated line line minus mean
low -0.0038 -0.0754 0.0678 0.1620 0.1658
medium 0.6399 0.5641 0.7156 0.4307 -0.2092
high 1.4642 1.3877 1.5408 1.5057 0.0415
absolute Step 7 miss at medium: 0.2092
medium: line minus mean, -0.2084 CI=(-0.2566, -0.1602)
absolute difference from the Step 7 miss: 0.0008
population projection: intercept -0.1171, slope 0.2657
slope as the fixed contrast of the three means: 0.2657
msm[(intercept)]: CI=(-0.1794, -0.0342) population projection -0.1171
msm[assigned contacts]: CI=(0.2519, 0.2856) population projection 0.2657
What this output tells you. In the population, the line misses medium by about 0.19, since
line minus mean is -0.1857. It overshoots low by 0.1486 and high by 0.0371.
In the fits, the absolute Step 7 miss at medium line reports 0.2092. The
medium: line minus mean row measures that miss with an interval.
part of the row |
value on this draw |
what it means |
|---|---|---|
estimate |
-0.2084 |
the line fitted to the three estimated means, minus the estimated |
interval |
(-0.2566, -0.1602) |
it excludes zero, so the data reject, at the 5% level, a line through all three means |
population miss |
-0.1857 |
the interval contains it |
With three cadences and two coefficients, a nonzero miss at one cadence is equivalent to a
nonzero miss at every cadence. The population misses stand in the ratio 4 : -5 : 1. The contrast call uses the joint influence curve of the three
means, so their correlation enters the interval. Its estimate differs from the Step 7 miss by 0.0008
on this draw, because the cadence fit and the MSM fit target separately.
check |
value on this draw |
|---|---|
population projection |
intercept -0.1171, slope 0.2657 |
slope as the fixed contrast of the means |
0.2657 |
slope interval from the fit |
(0.2519, 0.2856), which contains 0.2657 |
intercept interval from the fit |
(-0.1794, -0.0342), which contains -0.1171 |
Read the slope as “the best linear summary of the cadence response under a uniform weight”. Do not read it as “the causal effect of one more contact”. No patient was assigned more than six contacts, so the projection says nothing about a cadence with ten.
Step 10: the weight is part of the estimand#
The projection weight sets how much each cadence counts when the line is fitted. The code fits the
population line again, with each cadence weighted by its share from Step 3. It then declares a fixed
weight of 1, 10, and 1 for low, medium, and high. It prints the population slope under that
weight, and it fits the projection with the weight declared as weights_kind="known".
shares = observed["share"].to_numpy()
# Weighted least squares: solve (X' W X) b = X' W y, with W the diagonal matrix of weights.
share_projection = np.linalg.solve(
design.T @ (shares[:, None] * design), design.T @ (shares * population)
)
slope = trend_result["msm[assigned contacts]"]
print(
"cadence shares:",
", ".join(f"{arm} {share:.3f}" for arm, share in zip(ARMS, shares, strict=True)),
)
print(f"population slope, uniform weight: {projection[1]:.4f}")
print(f"population slope, share weight: {share_projection[1]:.4f}")
print(f"shift from the share weight: {abs(share_projection[1] - projection[1]):.4f}")
print(f"standard error of the Step 7 slope: {slope.std_error:.4f}")
print()
# A fixed weight that puts most of the projection on medium.
FIXED_WEIGHT = {"low": 1.0, "medium": 10.0, "high": 1.0}
fixed = np.array([FIXED_WEIGHT[arm] for arm in ARMS])
fixed_projection = np.linalg.solve(
design.T @ (fixed[:, None] * design), design.T @ (fixed * population)
)
weighted_trend = MSM(
design=contacts_design,
terms=trend.terms,
weights=lambda arm, data: np.full(len(data), FIXED_WEIGHT[arm]),
weights_kind="known",
design_kind="known",
)
weighted_result = study.identify(MSMProjection(weighted_trend)).estimate(method=method)
weighted_slope = weighted_result["msm[assigned contacts]"]
print(f"population slope, 1:10:1 weight: {fixed_projection[1]:.4f}")
for label, estimate in (("uniform", slope), ("1:10:1", weighted_slope)):
low, high = estimate.ci
print(f"fitted slope, {label} weight: {estimate.psi:.4f} CI=({low:.4f}, {high:.4f})")
cadence shares: low 0.272, medium 0.368, high 0.360
population slope, uniform weight: 0.2657
population slope, share weight: 0.2593
shift from the share weight: 0.0065
standard error of the Step 7 slope: 0.0086
population slope, 1:10:1 weight: 0.2400
fitted slope, uniform weight: 0.2688 CI=(0.2519, 0.2856)
fitted slope, 1:10:1 weight: 0.2398 CI=(0.2214, 0.2582)
What this output tells you. The uniform weight gives a population slope of 0.2657. The share weight gives 0.2593. The means are the same, so only the weight moved the slope.
The shift is 0.0065, and the standard error of the Step 7 slope is 0.0086. That standard error describes one slope, not the difference between two slopes. This illustration uses population means and sample shares. It does not test whether two fitted slopes differ.
The 1:10:1 weight moves the population slope to 0.2400. The fitted slope under that weight is 0.2398, with the interval (0.2214, 0.2582). On this draw, each interval contains its own population slope and not the other.
fitted slope |
interval |
contains 0.2657 |
contains 0.2400 |
|---|---|---|---|
uniform weight, 0.2688 |
(0.2519, 0.2856) |
yes |
no |
1:10:1 weight, 0.2398 |
(0.2214, 0.2582) |
no |
yes |
A change of weight or terms changes the estimand, not only the estimate. A misspecified working model still defines a parameter. Interval validity still needs the causal and nuisance conditions.
Declare a weights= function as known only when it does not depend on the data. The 1:10:1 weight
qualifies. The sample shares and a function of the fitted treatment mechanism do not. Such a weight,
declared as known, still fits. That fit omits the weight’s influence-curve term, so its standard
error can be too small or too large. The
technical entry names the term that an
estimated weight or design adds.
Step 11: the control, a saturated working model#
A saturated model has one free coefficient per cadence. It cannot be misspecified, so it represents
the three means exactly. The code fits it beside the average treatment effect (ATE) contrasts
against low, with the same method. Each row pairs one coefficient with its counterpart.
from cleverly import ATE
def cadence_indicators(arm, data):
"""One free coefficient per cadence: an intercept and two cadence indicators."""
n = len(data)
return np.column_stack(
[
np.ones(n),
np.full(n, float(arm == "medium")),
np.full(n, float(arm == "high")),
]
)
saturated = MSM(
design=cadence_indicators,
terms=("(intercept)", "medium vs low", "high vs low"),
design_kind="known",
)
saturated_result = study.identify(MSMProjection(saturated)).estimate(method=method)
arm_contrasts = study.identify(ATE(reference="low")).estimate(method=method)
pairs = (
("msm[(intercept)]", arm_result, "ey[low]"),
("msm[medium vs low]", arm_contrasts, "ate[medium vs low]"),
("msm[high vs low]", arm_contrasts, "ate[high vs low]"),
)
rows = []
for name, counterpart, key in pairs:
coefficient, match = saturated_result[name], counterpart[key]
rows.append(
{
"coefficient": name,
"psi": coefficient.psi,
"ci": f"({coefficient.ci[0]:.4f}, {coefficient.ci[1]:.4f})",
"counterpart": key,
"counterpart psi": match.psi,
"counterpart ci": f"({match.ci[0]:.4f}, {match.ci[1]:.4f})",
"influence curves agree within 1e-12": bool(
np.allclose(coefficient.influence_curve, match.influence_curve, rtol=0, atol=1e-12)
),
}
)
print(pd.DataFrame(rows).round(4).to_string(index=False))
coefficient psi ci counterpart counterpart psi counterpart ci influence curves agree within 1e-12
msm[(intercept)] -0.0038 (-0.0754, 0.0678) ey[low] -0.0038 (-0.0754, 0.0678) True
msm[medium vs low] 0.6437 (0.5562, 0.7312) ate[medium vs low] 0.6437 (0.5562, 0.7312) True
msm[high vs low] 1.4681 (1.3805, 1.5557) ate[high vs low] 1.4681 (1.3805, 1.5557) True
What this output tells you. The intercept is -0.0038, the same value as the low mean in
Step 6. The other coefficients are 0.6437 and 1.4681, the same as the medium versus low and
high versus low contrasts. Each pair of influence curves agrees within 1e-12 at every row, so
the intervals match too.
A saturated projection is a reparameterization of the per-cadence report, not a different analysis. It is also the fallback for a board that does not want to commit to a contact spacing.
A saturated check cannot see a component that vanishes when the model fits. The projection weight and a link’s curvature term are two examples. The technical entry describes the checks that do test them.
Step 12: diagnostics, what the fit can check#
include_retargets=True runs the truncation curve with the assessment. Truncation clips each fitted
cadence probability into a bound, so no row gets an extreme weight. The curve retargets each
coefficient at a range of bounds without refitting the nuisance models. The support report describes
positivity in the fitted treatment model. See
diagnostics.
assessment = trend_result.assess(include_retargets=True)
ledger = assessment.to_frame()
ran = ledger[~ledger["status"].isin(["unavailable", "not_applicable"])]
print(ran[["surface", "check", "status"]].to_string(index=False))
print("needs attention:", tuple(item.name for item in assessment.attention))
support = assessment.report("support")
scores = assessment.report("score_equations")
curve = assessment.report("truncation_curve")
print()
print(support.summary())
print()
print(scores.summary())
print()
slope_curve = curve.loc[
curve["estimand"] == "msm[assigned contacts]",
["bound", "psi", "delta_from_fitted", "ci_lower", "ci_upper", "truncated_fraction"],
]
print(slope_curve.round(4).to_string(index=False))
print(f"largest absolute slope movement: {slope_curve['delta_from_fitted'].abs().max():.4f}")
surface check status
validation score_equations passed
validation support warning
validation nuisance_models completed
diagnostics truncation_curve completed
diagnostics refute deferred
needs attention: ('support',)
Positivity / overlap diagnostics
--------------------------------
n = 3000; propensity truncated to [0.0114, 0.9886]
g(W) quantile 0% 1% 5% 25% 50% 75% 95% 99% 100%
------------- ------ ------ ------ ------ ------ ------ ------ ------ ------
g[high] 0.0006 0.0153 0.0478 0.1684 0.3291 0.5263 0.7641 0.8681 0.9546
g[low] 0.0148 0.0799 0.1332 0.2191 0.2755 0.3260 0.4022 0.4494 0.5720
g[medium] 0.0044 0.0168 0.0470 0.1703 0.3416 0.5430 0.7773 0.8897 0.9847
arm n effective n ESS / n top 1% weight top 5% weight
------ ---- ----------- ------- ------------- -------------
high 1079 514.9 0.477 0.086 0.218
low 816 727.2 0.891 0.032 0.105
medium 1105 655.3 0.593 0.068 0.190
threshold P(g < t) P(g > 1-t)
--------- -------- ----------
0.01 0.0033 0.0000
0.025 0.0136 0.0001
0.05 0.0371 0.0009
0.1 0.0963 0.0041
truncated: 36 unit(s) (1.20%); most extreme untruncated g(W) = 0.011537
group equation target rows Kish-equivalent rows Kish / target Kish / total top 1% load top 5% load max |w h| zero load g bound clipped
----- -------- ----------- -------------------- ------------- ------------ ----------- ----------- --------- --------- ---------------- ----------
msm h_msm1 3000/3000 1032.8 0.344 0.344 0.105 0.261 268.5 0 [0.0114, 0.9886] 36 (1.20%)
(absolute residual-multiplier load from the fitted equation; Kish values describe residual-multiplier concentration, not residual contributions or estimator information, influence, or precision; target rows are score-mask rows and include structural zeros)
max |clever covariate| (msm): 268.5
VERDICT: some truncation. 1.2% of units were clipped at the marginal treatment-mechanism bound [0.0114, 0.9886]. Report truncation_curve() alongside the estimate so readers can see how much the answer depends on it. The narrowest arm has a Kish-equivalent weight count of 48% of its nominal rows. This is concentration, not estimator information, and no threshold is applied because none is derived. Absolute-load concentration is greatest for group 'msm', equation 'h_msm1': 1032.8 Kish-equivalent rows out of 3000 score-mask rows (34%; 34% of 3000 total rows). This is a descriptive residual-multiplier concentration index. It excludes the residual and is not estimator effective sample size, information, actual influence, precision, or a positivity test.
Score-equation check
--------------------
target kind |score| before threshold ratio ok
---------------------- --------------- --------- --------- --------- -------- ---
msm fluctuation 0.000e+00 5.344e-03 6.760e-07 0.00e+00 yes
msm[(intercept)] influence curve 0.000e+00 - 6.760e-07 0.00e+00 yes
msm[assigned contacts] influence curve 0.000e+00 - 1.569e-07 0.00e+00 yes
PASS: the targeting step solved the estimated efficient score equation.
bound psi delta_from_fitted ci_lower ci_upper truncated_fraction
0.0010 0.2690 0.0002 0.2521 0.2858 0.0003
0.0050 0.2689 0.0001 0.2520 0.2857 0.0023
0.0100 0.2688 0.0000 0.2519 0.2856 0.0100
0.0114 0.2688 0.0000 0.2519 0.2856 0.0120
0.0250 0.2687 -0.0001 0.2519 0.2855 0.0403
0.0500 0.2700 0.0013 0.2536 0.2865 0.1080
0.1000 0.2699 0.0012 0.2546 0.2852 0.2677
0.2000 0.2654 -0.0033 0.2525 0.2784 0.6093
largest absolute slope movement: 0.0033
What this output tells you. Read the parts in order.
output part |
what it shows on this draw |
|---|---|
the status table and |
one warning, |
positivity and overlap |
36 units (1.20%) sit at the truncation bound. The support report warns when that share is above 1%. The narrowest cadence, |
the largest clever covariate, 268.5 |
the |
score-equation check |
one row for the fluctuation’s two-coefficient score, and one influence-curve row per coefficient |
truncation curve |
the slope stays between 0.2654 and 0.2700 across bounds from 0.0010 to 0.2000 |
The largest bound clips a fraction of 0.6093 of the rows, and the slope moves by at most 0.0033. On this draw, the slope depends little on the truncation bound. That does not verify positivity.
The support report describes the fitted treatment model. It cannot show how small the true
probabilities are. About one patient in a hundred has a true probability of high or
medium below 0.01. The probe script truth.py gives the true probabilities.
Step 13: sensitivity on the cadence contrasts#
Sensitivity analysis asks how strong an unmeasured confounder would need to be to change the
conclusion. Sensitivity analysis defines
it. cleverly implements the omitted-variable bound for arm-indexed estimands, not for an MSM
coefficient. The code reads the robustness value of each contrast of the ATE(reference="low") fit
from Step 11. Those contrasts have the same influence curves as the saturated coefficients.
robustness = {
name: arm_contrasts.sensitivity.robustness_value(estimand=name)
for name in ("ate[medium vs low]", "ate[high vs low]")
}
for name, values in robustness.items():
print(
f"{name}: robustness value {values['rv']:.3f} (confidence-limit value {values['rva']:.3f})"
)
ate[medium vs low]: robustness value 0.204 (confidence-limit value 0.181)
ate[high vs low]: robustness value 0.420 (confidence-limit value 0.394)
What this output tells you. Each robustness value assumes worst-case alignment (rho=1). It
is the equal outcome-side and treatment-side strength that moves the point estimate to zero. The
confidence-limit value is the strength at which the confidence limit reaches zero. Both strengths
are partial R² values between 0 and 1.
contrast |
robustness value |
confidence-limit value |
|---|---|---|
|
0.204 |
0.181 |
|
0.420 |
0.394 |
The medium versus low contrast reaches zero at the weaker confounding strength. These values
bound the cadence contrasts, not the slope. The slope is a fixed contrast of the three means
(Step 7), but this bound does not cover that combination. The
omitted-variable bounds
section defines each quantity.
The bound needs a finite second moment of the Riesz representer, so \(E[1/g]\) must be finite for
each cadence. On this law it is finite for all three. The review probe script truth.py computes it
in closed form from the cadence logits.
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 6, each cadence’s mean |
another law |
|
Step 7, the trend |
another law |
No registered study covers three cadences, fitted nuisances, or simultaneous bands for a projection.
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 analysis tells the board#
question |
answer from this analysis |
|---|---|
what the slope means |
the slope of the least-squares line fitted to the three counterfactual means, with equal weight per cadence: 0.2688 score units per assigned contact |
the population and the intervention |
adults with a discharge-home order, each assigned one cadence and analyzed as assigned |
the uncertainty |
the pointwise 95% interval (0.2519, 0.2856), from the influence curve. It is experimental. The review probe measured its coverage at 0.9405 on this law |
whether one line is adequate |
no. The miss at |
what supports the reading |
the score-equation check, the saturated control, and the stable truncation curve |
what the diagnostics flagged |
the support warning: the fit truncated 1.20% of units, and about 1 patient in 100 has a true probability below 0.01 |
what supports the cadence contrasts |
their robustness values, 0.204 and 0.420. These values do not cover the slope |
what remains unverified |
no unmeasured confounding, consistency, no interference, positivity beyond the fitted model, the handling of death, and, in a real program, the specification of Q and g |
Report the three cadence means or the two contrasts beside the slope. The gain per contact is not constant, and the slope hides that. The analysis does not support these conclusions:
conclusion |
why the analysis does not support it |
|---|---|
each extra contact raises the score by 0.2688 |
the line does not fit, so the slope is a summary, not a per-contact effect |
ten contacts would raise the score further |
no patient was assigned more than six contacts |
the board should choose the |
that choice needs the cadence contrasts, their costs, and the board’s own trade-offs |
the slope is robust to unmeasured confounding |
the omitted-variable bound covers the cadence contrasts, not the slope |
the interval covers at 95% in general |
one review probe on one law measured 0.9405, and no registered study covers this configuration |
Where to go next#
The same projection works over regimens and horizons in a longitudinal fit. Its design callable also receives the horizon, and it codes each regimen as a number in the same way as Step 8. A longitudinal MSM projection cross-fits by default, with one pooled update per node. Longitudinal MSM projections shows the call. Sequential offers and time-to-event outcomes estimate the per-regimen parameters that the projection summarizes.
The examples index lists every tutorial in the program.