About the data and findings in this tutorial All individuals, interventions, counterfactual outcomes, and numerical findings were simulated with fixed random seeds solely to demonstrate the methods. In real data, only one potential outcome is observed for each person, and the true causal effect cannot be looked up directly. This tutorial retains the “truth” only so that we can test whether each method recovers a known answer.
This tutorial centers on one question: If everyone in the target population participated in an intensive blood pressure management program, rather than everyone receiving usual care, how much would mean systolic blood pressure differ at 6 months?
The recommended sequence is: “define the question → emulate the target trial → draw the causal diagram → state the identification assumptions → choose an estimation method → examine diagnostics → conduct sensitivity analyses → report transparently.” Code is shown by default; you can run it section by section or collapse it with the page controls.
After completing this tutorial, you should be able to:
The same data and regression function can serve different goals, but they answer different questions:
| Task | Typical question | Primary basis for evaluation |
|---|---|---|
| Description | How much does mean blood pressure differ between program participants and nonparticipants? | Accuracy of the sample, measurements, and description |
| Prediction | Who is likely to still have high blood pressure after 6 months? | Out-of-sample calibration, discrimination, and error |
| Causation | What would happen to outcomes if the same target population participated in the program rather than receiving usual care? | Design, temporal ordering, identification assumptions, and estimation |
An “adjusted regression coefficient” may still be only a conditional
association. A causal interpretation is not granted by
lm(), glm(), or a statistically significant
p-value. It comes from a clearly defined intervention contrast, a
credible design, and assumptions sufficient to connect observed data to
counterfactual outcomes.
A target trial is not necessarily a trial that will actually be conducted. It is a protocol describing how an ideal randomized trial would answer the question if ethics, time, and resources allowed. An observational study can attempt to emulate this protocol.
| Protocol element | Target trial in this tutorial |
|---|---|
| Eligibility criteria | Members of the target population who begin management at baseline and meet prespecified clinical criteria |
| Treatment strategies | Enroll immediately in the intensive program or continue usual care |
| Assignment procedure | Random assignment in the ideal trial; adjustment for recorded factors in the observational cohort |
| Time zero | The common time at which eligibility is confirmed and the treatment strategy is assigned |
| Follow-up | From time zero through 6 months |
| Outcome | Systolic blood pressure at 6 months (mmHg) |
| Causal contrast | Mean difference if everyone participated in the program versus if everyone received usual care |
| Analysis principle | First estimate the population-average effect of assignment to a strategy |
The target trial makes visible many problems that software can hide. Were periods before and after treatment initiation mixed together? Did people who had to “survive until treatment” receive immortal time? Did the eligibility criteria use future information? Did follow-up begin at the same time in each group?
Time zero must be aligned If eligibility confirmation, treatment assignment, and the start of follow-up occur at different times, selection bias and immortal time bias may arise before any model is fit. Adding covariates usually cannot repair this design misalignment.
“Receive better care,” “exercise more,” and “control blood pressure” are not sufficiently well-defined interventions. An interpretable causal question must specify, at a minimum, who receives what and when, for how long, which co-interventions are allowed, and what the comparison strategy is.
Different versions of the “program” may involve different follow-up schedules, authority to adjust medications, and adherence support. If these versions affect outcomes differently but are combined into one binary variable, the consistency assumption becomes ambiguous. Whenever possible, write each strategy as a rule that another person could implement or audit.
Let denote participation in the program and denote usual care. For individual :
The individual causal effect is . In reality, only one of these outcomes can be observed:
The unobserved potential outcome is not an ordinary missing value; it cannot be recovered by measuring the same person again at the same time. This is the fundamental problem of causal inference. The task of research design and statistical methods is to construct credible counterfactual comparisons at the population level.
The most common estimand is the average treatment effect (ATE):
This tutorial uses the direction “program minus usual care,” so a negative value means that the program lowers blood pressure. Two other common estimands are:
| Estimand | Definition | Target population |
|---|---|---|
| ATE | Entire study target population | |
| ATT | People who actually participated in the program | |
| CATE | Subgroup with characteristics |
If the treatment effect varies with baseline blood pressure and program participants more often have high baseline blood pressure, the ATE and ATT may differ. The estimand must be chosen before selecting a weighting or matching method; the target population should not be changed after seeing which result is more statistically significant.
Because the simulation retains both otherwise unobservable potential
outcomes, we know the finite-sample truth for this realized simulated
cohort: the ATE is -5.06 mmHg, and the ATT among actual program
participants is -5.16 mmHg. Subsequent analyses provide the methods only
with the ordinarily observable causal_data.
For continuous outcomes, possible scales include mean differences, ratios, and quantile differences. Common scales for binary outcomes include:
The risk difference directly expresses how many fewer or additional events occur per 100 people, the risk ratio expresses relative change, and the odds ratio generally does not equal the risk ratio. Effect modification also depends on the scale: a variable may interact with treatment on the risk-difference scale but not on the risk-ratio scale.
Write the estimand in one sentence first Recommended template: In [target population], compare [strategy 1] with [strategy 0] starting at [time zero], with respect to the [marginal effect scale] for [outcome] through [time horizon].
Consistency requires that a person who actually receives has an observed outcome equal to the corresponding potential outcome: if , then . It also implies that treatment versions are defined with sufficient precision.
Potential threats to consistency include:
Randomized trials use randomization to make . An observational study can usually claim only that, conditional on a set of pretreatment common causes ,
This is often called “no unmeasured confounding.” It cannot be established from the data alone; it requires subject-matter knowledge, measurement quality, temporal ordering, and sensitivity analysis. Automatically placing many variables in a model does not guarantee that all important common causes were measured correctly.
For every that occurs with positive probability in the target population, we require:
A structural positivity violation occurs when a group of people cannot receive a strategy by design—for example, someone with an absolute contraindication cannot receive a medication. Changing the model cannot create the missing counterfactual information. Practical positivity problems arise when, in a finite sample, some covariate combinations almost always receive only one strategy. They appear as propensity scores near 0 or 1, extreme weights, and unstable estimates.
The usual framework also assumes that one person’s outcome is unaffected by other people’s treatment, an assumption known as no interference. Spillover effects are common, however, with vaccines, infectious disease control, peer interventions, and hospital workflows. In those settings, group coverage, network exposure, or cluster-level strategies must be defined; an individual binary treatment no longer describes the question adequately.
Under consistency, conditional exchangeability, and positivity, the mean potential outcome can be identified by the g-formula:
The right-hand side contains only observable distributions: compare treatment strategies within each value of , then average over the distribution of in the target population. Standardization, outcome regression, stratification, and many machine-learning g-computation methods all implement this logic.
A directed acyclic graph (DAG) uses arrows to represent assumed direct causal relationships. A DAG is not discovered automatically from the data; it is an explicit statement of the researcher’s understanding of the data-generating process.
draw_node <- function(x, y, label, fill = "white", width = 0.16) {
rect(x - width / 2, y - 0.055, x + width / 2, y + 0.055,
col = fill, border = palette_ci["navy"], lwd = 1.7)
text(x, y, label, cex = 0.88)
}
draw_arrow <- function(x0, y0, x1, y1, color = palette_ci["gray"]) {
arrows(x0, y0, x1, y1, length = 0.08, lwd = 1.8, col = color)
}
draw_path_arrow <- function(x, y, color = palette_ci["gray"]) {
lines(x[-length(x)], y[-length(y)], lwd = 1.8, col = color)
arrows(
x[length(x) - 1], y[length(y) - 1], x[length(x)], y[length(y)],
length = 0.08, lwd = 1.8, col = color
)
}
plot.new()
plot.window(xlim = c(0, 1), ylim = c(0, 1))
draw_node(0.13, 0.75, "L\nBaseline common causes", "#E8F2F1", 0.22)
draw_node(0.39, 0.75, "A\nProgram", "#E7F1FA")
draw_node(0.64, 0.75, "M\nEngagement", "#FFF3D8")
draw_node(0.87, 0.75, "Y\n6-month SBP", "#FCE8E4")
draw_arrow(0.24, 0.75, 0.30, 0.75)
draw_arrow(0.47, 0.75, 0.55, 0.75)
draw_arrow(0.72, 0.75, 0.79, 0.75)
draw_path_arrow(c(0.13, 0.13, 0.87, 0.87),
c(0.81, 0.89, 0.89, 0.81))
draw_path_arrow(c(0.39, 0.39, 0.87, 0.87),
c(0.69, 0.61, 0.61, 0.69), palette_ci["blue"])
draw_node(0.13, 0.27, "A\nProgram", "#E7F1FA")
draw_node(0.40, 0.27, "S\nPost-treatment visit", "#F5E7F2", 0.22)
draw_node(0.67, 0.27, "U\nUnmeasured health need", "#EEEEEE", 0.20)
draw_node(0.87, 0.27, "Y\n6-month SBP", "#FCE8E4")
draw_arrow(0.21, 0.27, 0.31, 0.27)
draw_arrow(0.59, 0.27, 0.49, 0.27)
draw_arrow(0.75, 0.27, 0.79, 0.27)
text(
0.50, 0.12,
"S is the collider A -> S <- U; conditioning on S opens a noncausal path",
cex = 0.78
)
box()Simplified causal diagrams of a pretreatment common cause, a mediator, and a collider.
In the first diagram, and , so is a backdoor path that must be blocked through design or analysis. lies on and represents part of the program’s effect; it generally should not be adjusted for when estimating the total effect.
In the second diagram, the post-treatment visit is affected by both the program and unmeasured health need . Restricting the sample to “people with at least one visit” or including in the model induces a conditional association between and , thereby opening .
An adjustment set must block every backdoor path from to . When estimating a total effect, a safe introductory rule is to exclude post-treatment mediators and colliders; more general adjustment criteria must be evaluated for each graph. A minimally sufficient set is usually preferable to “every available variable.” A reasonable set for the primary analysis in this tutorial includes the pretreatment variables age, baseline blood pressure, smoking, rural residence, and health literacy.
| Variable role | Structure in the graph | Usual treatment when estimating the total effect |
|---|---|---|
| Confounder | Adjust to block the backdoor path | |
| Pure outcome predictor | May improve precision but is not required to remove confounding | |
| Instrumental variable | Usually does not need adjustment in an ordinary outcome regression | |
| Mediator | Do not adjust when estimating the total effect | |
| Collider | Do not condition, stratify, or select the sample on it | |
| Exposure or outcome proxy | Measurement structure depends on the specific process | Cannot be handled based on association alone |
“Measured before treatment” is not a sufficient reason A variable occurring before treatment is not automatically a confounder. Common causes, instrumental variables, ancestors of colliders, and variables that affect only precision play different roles. Draw the graph based on time and subject-matter mechanisms before deciding how to use each variable.
In the simulated data, engagement_score is a
post-treatment mediator and clinic_contact is a collider.
The following comparison shows the program coefficient after reasonable
pretreatment adjustment, after adding the mediator, and after adding the
collider. These coefficients are not formal causal direct effects in
every setting; the purpose is to show how “adjusting for more variables”
can change the question or introduce bias.
model_pre_treatment <- lm(
six_month_sbp ~ program_num + age_c10 + baseline_sbp_c10 +
smoking_num + rural_num + health_literacy,
data = causal_data
)
model_with_mediator <- update(
model_pre_treatment,
. ~ . + engagement_score
)
model_with_collider <- update(
model_pre_treatment,
. ~ . + clinic_contact_num
)
bad_control_table <- data.frame(
Model = c(
"Pretreatment common causes only",
"Add the post-treatment mediator",
"Add the post-treatment collider"
),
`Program coefficient` = c(
coef(model_pre_treatment)["program_num"],
coef(model_with_mediator)["program_num"],
coef(model_with_collider)["program_num"]
),
`Question answered` = c(
"Adjusted total-effect projection; not generally the population ATE",
"Blocks part of the causal pathway; the question changes",
"May open a noncausal path"
),
check.names = FALSE
)
knitr::kable(
bad_control_table,
digits = 2,
caption = "Program coefficients under different adjustment strategies"
)| Model | Program coefficient | Question answered |
|---|---|---|
| Pretreatment common causes only | -5.06 | Adjusted total-effect projection; not generally the population ATE |
| Add the post-treatment mediator | -2.65 | Blocks part of the causal pathway; the question changes |
| Add the post-treatment collider | -5.30 | May open a noncausal path |
The finite-sample true ATE is approximately -5.06 mmHg. Because the pretreatment model omits the known treatment-by-baseline-SBP interaction, its program coefficient is an adjusted model projection rather than the population ATE; it is shown only as a baseline for the bad-control comparison. After engagement is added, the program coefficient primarily retains the portion of the effect that does not operate through this mediator. Adding the visit variable may induce an artificial association between the program and unmeasured health need. Calling either quantity a “direct effect” would additionally require a well-defined intervention on the mediator, further identification assumptions, and appropriate handling of treatment–mediator interaction.
If the program is randomly assigned with a fixed probability in the same target population, pretreatment characteristics will not systematically determine assignment on average. Randomization does not guarantee perfect balance in every finite sample, but it provides the design basis for quantifying random error and interpreting the contrast causally.
Below, the same simulated participants are reassigned at random. To avoid looking at unobservable counterfactuals, the analysis uses only the outcome that would be observed under each participant’s randomized assignment.
set.seed(20260811)
random_program <- rbinom(n, 1, 0.5)
random_outcome <- ifelse(
random_program == 1,
causal_truth$y1,
causal_truth$y0
)
trial_difference <- with(
data.frame(random_program, random_outcome),
mean(random_outcome[random_program == 1]) -
mean(random_outcome[random_program == 0])
)
trial_fit <- lm(random_outcome ~ random_program)
trial_ci <- confint(trial_fit)["random_program", ]
trial_result <- data.frame(
Measure = c(
"Randomized-trial mean difference",
"Lower 95% CI",
"Upper 95% CI",
"Finite-sample simulated ATE benchmark"
),
mmHg = c(trial_difference, trial_ci[1], trial_ci[2], true_ate)
)
knitr::kable(
trial_result,
digits = 2,
caption = "Intention-to-treat contrast in one simulated randomized trial"
)| Measure | mmHg |
|---|---|
| Randomized-trial mean difference | -5.61 |
| Lower 95% CI | -6.51 |
| Upper 95% CI | -4.71 |
| Finite-sample simulated ATE benchmark | -5.06 |
An estimate from one randomized trial need not equal the truth exactly; it is affected by random assignment and sampling variation. Adding strong pretreatment outcome predictors can improve precision, but randomization itself is the primary source of exchangeability. The interval shown here is an ordinary linear-model approximation. A formal trial should use design-consistent inference appropriate to its randomization scheme, heteroskedasticity, stratification, or cluster design.
An intention-to-treat (ITT) analysis compares participants according to their original randomized assignment, regardless of actual adherence. It preserves randomization and usually answers the question, “What is the effect of implementing the assignment strategy?”
A simple comparison by treatment actually received breaks randomization because adherence may be influenced by health status, preferences, and resources. If the target is a per-protocol effect under full adherence, deviations from the strategy must be defined clearly and appropriate methods must be used—for example, weighting for baseline and time-varying covariates, the g-formula, structural nested models, or, under additional assumptions, randomized assignment as an instrumental variable.
Randomization does not guarantee that:
Randomized trials must therefore still report allocation concealment, blinding, adherence, between-group co-interventions, reasons for loss to follow-up, missing outcomes, and the limits of generalizability.
stopifnot(
nrow(causal_data) == nrow(causal_truth),
all(causal_data$program_num %in% c(0, 1)),
!anyNA(causal_data),
all(causal_data$baseline_sbp > 0),
all(causal_data$six_month_sbp > 0)
)
preview <- head(causal_data[, c(
"participant_id", "age", "rural", "smoking", "baseline_sbp",
"health_literacy", "program", "six_month_sbp"
)])
knitr::kable(
preview,
col.names = c(
"Participant", "Age", "Residence", "Smoking", "Baseline SBP",
"Health literacy", "Management strategy", "6-month SBP"
),
digits = 1,
caption = "First six rows of the simulated observational cohort"
)| Participant | Age | Residence | Smoking | Baseline SBP | Health literacy | Management strategy | 6-month SBP |
|---|---|---|---|---|---|---|---|
| C0001 | 36 | Rural | Not current | 142 | -1.9 | Usual care | 132 |
| C0002 | 42 | Urban | Not current | 149 | 0.1 | Usual care | 135 |
| C0003 | 65 | Rural | Not current | 150 | 0.4 | Usual care | 148 |
| C0004 | 42 | Urban | Not current | 128 | 0.1 | Usual care | 116 |
| C0005 | 48 | Rural | Not current | 150 | -1.6 | Usual care | 147 |
| C0006 | 69 | Urban | Not current | 137 | 3.2 | Program | 116 |
The unit of analysis is the participant. Pretreatment variables are measured before the program begins, and the outcome is measured at 6 months. The mediator and post-treatment clinic contact remain in the data as cautionary examples, but they are not included in the confounding adjustment set for the primary analysis.
crude_summary <- aggregate(
cbind(baseline_sbp, six_month_sbp) ~ program,
data = causal_data,
FUN = mean
)
crude_difference <- with(
causal_data,
mean(six_month_sbp[program_num == 1]) -
mean(six_month_sbp[program_num == 0])
)
knitr::kable(
crude_summary,
col.names = c("Management strategy", "Mean baseline SBP", "Mean 6-month SBP"),
digits = 1,
caption = "Crude description by observed program participation"
)| Management strategy | Mean baseline SBP | Mean 6-month SBP |
|---|---|---|
| Usual care | 145 | 136 |
| Program | 150 | 136 |
The unadjusted mean outcome difference is -0.65 mmHg, whereas the true ATE is -5.06 mmHg. Participants already had higher baseline blood pressure before entering the program, so a higher outcome or a smaller apparent decrease in the program group does not by itself show that the program was ineffective. This is a classic example of confounding by indication: people with greater need for intervention are more likely to receive it.
The standardized mean difference (SMD) does not increase mechanically with sample size, making it useful for describing baseline differences between groups:
It is not a test for confounding, and no threshold is definitive. An absolute SMD below 0.10 is often used as a rough diagnostic reference, but distributional shape, extreme values, important interactions, and nonlinear terms should also be examined.
balance_variables <- list(
"Age" = causal_data$age,
"Baseline SBP" = causal_data$baseline_sbp,
"Current smoking" = causal_data$smoking_num,
"Rural residence" = causal_data$rural_num,
"Health literacy" = causal_data$health_literacy
)
unadjusted_smd <- vapply(
balance_variables,
standardized_difference,
numeric(1),
z = causal_data$program_num
)
balance_unadjusted <- data.frame(
Covariate = names(unadjusted_smd),
SMD = unname(unadjusted_smd),
Absolute_SMD = abs(unname(unadjusted_smd)),
check.names = FALSE
)
knitr::kable(
balance_unadjusted,
col.names = c("Covariate", "SMD", "Absolute SMD"),
digits = 2,
caption = "Unadjusted balance in pretreatment covariates"
)| Covariate | SMD | Absolute SMD |
|---|---|---|
| Age | 0.46 | 0.46 |
| Baseline SBP | 0.64 | 0.64 |
| Current smoking | 0.32 | 0.32 |
| Rural residence | 0.00 | 0.00 |
| Health literacy | 0.08 | 0.08 |
Do not use p-values for baseline variables to select adjustment covariates. In large samples, trivial differences can be statistically significant; in small samples, important imbalances may not be. What matters more is each variable’s role in the causal structure and the practical magnitude of the imbalance.
Standardization first estimates , then places every member of the target population under both and , and finally averages the individual predictions. It produces a marginal effect in the target population, rather than a regression coefficient for a single “average” person.
This example allows the program effect to vary linearly with baseline blood pressure because both the data-generating mechanism and the scientific question support this heterogeneity.
outcome_model <- lm(
six_month_sbp ~
program_num * baseline_sbp_c10 +
age_c10 + smoking_num + rural_num + health_literacy,
data = causal_data
)
data_program <- transform(causal_data, program_num = 1)
data_usual <- transform(causal_data, program_num = 0)
predicted_y1 <- predict(outcome_model, newdata = data_program)
predicted_y0 <- predict(outcome_model, newdata = data_usual)
gcomp_means <- c(
program = mean(predicted_y1),
usual_care = mean(predicted_y0)
)
gcomp_ate <- unname(gcomp_means["program"] - gcomp_means["usual_care"])
gcomp_table <- data.frame(
Quantity = c(
"Everyone participates in the program",
"Everyone receives usual care",
"ATE: program minus usual care"
),
Estimate = unname(c(gcomp_means, gcomp_ate)),
Unit = "mmHg",
row.names = NULL
)
knitr::kable(
gcomp_table,
col.names = c("Quantity", "Estimate", "Unit"),
digits = 2,
caption = "Standardized counterfactual population means and their ATE contrast"
)| Quantity | Estimate | Unit |
|---|---|---|
| Everyone participates in the program | 133.27 | mmHg |
| Everyone receives usual care | 138.32 | mmHg |
| ATE: program minus usual care | -5.05 | mmHg |
The standardized ATE is -5.05 mmHg, close to the finite-sample simulated truth of -5.06 mmHg. Each computational step is worth checking:
data_program and data_usual retain the
same participants and their values of
;
only the treatment strategy changes.The model contains the program_num * baseline_sbp_c10
interaction. The program_num coefficient therefore
represents the conditional contrast at a baseline SBP of 145 mmHg,
whereas the ATE averages predicted contrasts across the target
population’s distribution of baseline blood pressure.
Even without an interaction, conditional ratios from nonlinear models such as logistic or Cox regression generally do not equal marginal risks or population-average causal effects. A conditional OR, HR, or coefficient at a particular reference value should therefore not automatically be called an ATE.
conditional_program_coefficient <- coef(outcome_model)["program_num"]
coefficient_comparison <- data.frame(
Quantity = c("Program main-effect coefficient", "Standardized ATE"),
Estimate = unname(c(conditional_program_coefficient, gcomp_ate)),
Interpretation = c(
"Conditional effect at baseline SBP = 145 mmHg",
"Marginal average effect in the current target population"
)
)
knitr::kable(
coefficient_comparison,
digits = 2,
caption = "Conditional regression coefficient and marginal standardized effect"
)| Quantity | Estimate | Interpretation |
|---|---|---|
| Program main-effect coefficient | -5.03 | Conditional effect at baseline SBP = 145 mmHg |
| Standardized ATE | -5.05 | Marginal average effect in the current target population |
The standard error should reflect both outcome-model fitting and standardization. A nonparametric bootstrap provides an intuitive implementation by resampling participants, refitting the model, and repeating the standardization in every replicate.
estimate_gcomp <- function(data, index) {
d <- data[index, , drop = FALSE]
fit <- lm(
six_month_sbp ~
program_num * baseline_sbp_c10 +
age_c10 + smoking_num + rural_num + health_literacy,
data = d
)
d1 <- transform(d, program_num = 1)
d0 <- transform(d, program_num = 0)
individual_contrast <-
predict(fit, newdata = d1) - predict(fit, newdata = d0)
high_baseline <- d$baseline_sbp >= 150
c(
ATE = mean(individual_contrast),
low_baseline = mean(individual_contrast[!high_baseline]),
high_baseline = mean(individual_contrast[high_baseline])
)
}
set.seed(20260812)
n_boot <- 250
gcomp_bootstrap <- replicate(
n_boot,
estimate_gcomp(causal_data, sample.int(nrow(causal_data), replace = TRUE))
)
gcomp_ci <- unname(
quantile(gcomp_bootstrap["ATE", ], c(0.025, 0.975))
)
data.frame(
Method = "Standardization",
ATE = gcomp_ate,
CI_lower = gcomp_ci[1],
CI_upper = gcomp_ci[2],
Bootstrap_replicates = n_boot,
check.names = FALSE
) |>
knitr::kable(
col.names = c(
"Method", "ATE", "Lower 95% CI", "Upper 95% CI",
"Bootstrap replicates"
),
digits = 2,
caption = "Percentile bootstrap interval for the standardized ATE"
)| Method | ATE | Lower 95% CI | Upper 95% CI | Bootstrap replicates |
|---|---|---|---|---|
| Standardization | -5.05 | -5.63 | -4.43 | 250 |
To keep the tutorial practical to render, this example uses only 250 bootstrap replicates. A formal analysis would generally use at least 1,000 replicates and assess Monte Carlo error. The interval adopts a superpopulation interpretation under independent and identically distributed sampling of participants. It quantifies statistical uncertainty from the current sampling and modeling process, but does not include identification uncertainty arising from unmeasured confounding, an incorrect DAG, treatment misclassification, selection bias, or an ambiguously defined intervention.
Standardization depends on correctly estimating the conditional mean over the relevant data range. Check whether:
Good outcome-model fit does not establish exchangeability. The model can address only confounders that were measured and represented appropriately.
The propensity score is defined as:
It is the probability of treatment given pretreatment covariates, not an outcome risk or an individual treatment effect. Its principal uses are to construct a pseudo-population, matched sample, or strata with comparable covariate distributions.
propensity_model <- glm(
program_num ~
age_c10 + baseline_sbp_c10 + smoking_num + rural_num +
health_literacy,
family = binomial(),
data = causal_data
)
propensity_hat <- clamp_probability(
predict(propensity_model, type = "response"),
epsilon = 1e-6
)
propensity_summary <- aggregate(
propensity_hat,
by = list(Strategy = causal_data$program),
FUN = function(x) c(
min = min(x), q25 = quantile(x, 0.25),
median = median(x), q75 = quantile(x, 0.75), max = max(x)
)
)
propensity_quantiles <- propensity_summary[[2]]
propensity_summary <- data.frame(
Strategy = propensity_summary$Strategy,
propensity_quantiles,
row.names = NULL,
check.names = FALSE
)
knitr::kable(
propensity_summary,
digits = 2,
caption = "Distribution of estimated propensity scores in the two groups"
)| Strategy | min | q25.25% | median | q75.75% | max |
|---|---|---|---|---|---|
| Usual care | 0.05 | 0.24 | 0.34 | 0.46 | 0.88 |
| Program | 0.09 | 0.35 | 0.47 | 0.60 | 0.92 |
The goal of a propensity-score model is not to maximize classification accuracy or the AUC. A model that almost perfectly separates the treatment groups may instead signal poor overlap. Variable selection should follow the causal structure; after fitting, the focus should be on overlap and weighted balance.
hist(
propensity_hat[causal_data$program_num == 0],
breaks = seq(0, 1, by = 0.04), probability = TRUE,
col = grDevices::adjustcolor(palette_ci["orange"], alpha.f = 0.50),
border = "white", xlim = c(0, 1),
xlab = "Estimated propensity score", ylab = "Density",
main = "Propensity score overlap"
)
hist(
propensity_hat[causal_data$program_num == 1],
breaks = seq(0, 1, by = 0.04), probability = TRUE,
col = grDevices::adjustcolor(palette_ci["teal"], alpha.f = 0.50),
border = "white", add = TRUE
)
legend(
"topright",
legend = c("Usual care", "Program"),
fill = grDevices::adjustcolor(
c(palette_ci["orange"], palette_ci["teal"]), 0.50
),
border = NA, bty = "n"
)Distribution of estimated propensity scores by observed management strategy.
Within the region of common support, both groups contain participants with similar values of . If a region contains only one group, estimating the ATE requires model extrapolation. Appropriate responses include redefining the target population, restricting the analysis to common support, changing the estimand, or acknowledging that the data cannot answer the original question. Any restriction must be accompanied by a description of the new target population.
The stabilized inverse probability of treatment weight for the ATE is:
The denominator reweights treatment within covariate patterns, while the numerator stabilizes the scale of the weights. In the ideal weighted pseudo-population, treatment is approximately independent of the measured variables in .
treatment_prevalence <- mean(causal_data$program_num)
stabilized_weight <- ifelse(
causal_data$program_num == 1,
treatment_prevalence / propensity_hat,
(1 - treatment_prevalence) / (1 - propensity_hat)
)
effective_sample_size <- function(w) {
sum(w)^2 / sum(w^2)
}
weight_summary <- data.frame(
Metric = c(
"Minimum", "1st percentile", "Median", "99th percentile", "Maximum",
"Mean weight", "Overall effective sample size",
"Program-group effective sample size",
"Usual-care-group effective sample size"
),
Value = unname(c(
min(stabilized_weight),
quantile(stabilized_weight, 0.01),
median(stabilized_weight),
quantile(stabilized_weight, 0.99),
max(stabilized_weight),
mean(stabilized_weight),
effective_sample_size(stabilized_weight),
effective_sample_size(stabilized_weight[causal_data$program_num == 1]),
effective_sample_size(stabilized_weight[causal_data$program_num == 0])
))
)
knitr::kable(
weight_summary,
digits = 2,
caption = "Diagnostics for stabilized inverse probability weights"
)| Metric | Value |
|---|---|
| Minimum | 0.44 |
| 1st percentile | 0.50 |
| Median | 0.89 |
| 99th percentile | 2.78 |
| Maximum | 4.84 |
| Mean weight | 1.00 |
| Overall effective sample size | 1848.07 |
| Program-group effective sample size | 696.86 |
| Usual-care-group effective sample size | 1157.13 |
The mean of stabilized weights should generally be close to 1. The effective sample size (ESS) is:
An ESS substantially below the original sample size indicates that a small number of highly weighted observations dominate the estimate. ESS is a diagnostic summary, not the actual amount of independent information, and it does not replace a variance method appropriate for weighted estimation.
weighted_smd <- vapply(
balance_variables,
standardized_difference,
numeric(1),
z = causal_data$program_num,
w = stabilized_weight
)
balance_table <- data.frame(
Covariate = names(unadjusted_smd),
Unadjusted_SMD = unname(unadjusted_smd),
Weighted_SMD = unname(weighted_smd),
check.names = FALSE
)
knitr::kable(
balance_table,
col.names = c("Covariate", "Unadjusted SMD", "Weighted SMD"),
digits = 2,
caption = "Pretreatment covariate balance before and after inverse probability weighting"
)| Covariate | Unadjusted SMD | Weighted SMD |
|---|---|---|
| Age | 0.46 | -0.02 |
| Baseline SBP | 0.64 | -0.02 |
| Current smoking | 0.32 | 0.00 |
| Rural residence | 0.00 | -0.01 |
| Health literacy | 0.08 | 0.00 |
old_margin <- par("mar")
par(mar = c(5.1, 8.2, 4.1, 2.1))
plot(
abs(unadjusted_smd), seq_along(unadjusted_smd),
pch = 16, col = palette_ci["orange"],
xlim = c(0, max(0.35, abs(unadjusted_smd), abs(weighted_smd))),
ylim = c(0.5, length(unadjusted_smd) + 0.5),
yaxt = "n", xlab = "Absolute SMD", ylab = "",
main = "Covariate balance"
)
balance_plot_labels <- c(
"Age", "Baseline SBP", "Current smoking", "Rural residence",
"Health literacy"
)
axis(2, at = seq_along(unadjusted_smd), labels = balance_plot_labels, las = 1)
points(abs(weighted_smd), seq_along(weighted_smd),
pch = 17, col = palette_ci["teal"])
abline(v = 0.10, lty = 2, col = palette_ci["gray"])
legend(
"topright", legend = c("Unadjusted", "Weighted", "0.10 reference"),
pch = c(16, 17, NA), lty = c(NA, NA, 2),
col = c(palette_ci["orange"], palette_ci["teal"], palette_ci["gray"]),
bty = "n"
)Absolute standardized mean differences before and after inverse probability weighting.
The balance plot evaluates only variables that were measured and displayed. Excellent measured balance cannot rule out unmeasured confounding or repair an incorrect time zero, a problematic selection mechanism, or measurement error.
Diagnose propensity-score models by balance If important covariates remain imbalanced after weighting, revisit their coding, nonlinear terms, interactions, overlap, and the treatment assignment mechanism rather than first choosing the model that produces a more favorable outcome effect.
Normalizing the weighted means within treatment groups prevents finite-sample departures of the weight totals from their target sizes from carrying directly into the estimated means:
ipw_y1 <- weighted_mean(
causal_data$six_month_sbp[causal_data$program_num == 1],
stabilized_weight[causal_data$program_num == 1]
)
ipw_y0 <- weighted_mean(
causal_data$six_month_sbp[causal_data$program_num == 0],
stabilized_weight[causal_data$program_num == 0]
)
ipw_ate <- ipw_y1 - ipw_y0
ipw_table <- data.frame(
Quantity = c("Program-strategy weighted mean", "Usual-care-strategy weighted mean", "IPW ATE"),
Estimate = c(ipw_y1, ipw_y0, ipw_ate),
Unit = "mmHg"
)
knitr::kable(
ipw_table,
digits = 2,
caption = "Marginal mean outcomes estimated by inverse probability weighting"
)| Quantity | Estimate | Unit |
|---|---|---|
| Program-strategy weighted mean | 133.06 | mmHg |
| Usual-care-strategy weighted mean | 138.33 | mmHg |
| IPW ATE | -5.27 | mmHg |
estimate_ipw <- function(data, index) {
d <- data[index, , drop = FALSE]
ps_fit <- glm(
program_num ~
age_c10 + baseline_sbp_c10 + smoking_num + rural_num +
health_literacy,
family = binomial(),
data = d
)
ps <- clamp_probability(predict(ps_fit, type = "response"), 1e-6)
prevalence <- mean(d$program_num)
w <- ifelse(
d$program_num == 1,
prevalence / ps,
(1 - prevalence) / (1 - ps)
)
weighted_mean(d$six_month_sbp[d$program_num == 1], w[d$program_num == 1]) -
weighted_mean(d$six_month_sbp[d$program_num == 0], w[d$program_num == 0])
}
set.seed(20260813)
ipw_bootstrap <- replicate(
n_boot,
estimate_ipw(causal_data, sample.int(nrow(causal_data), replace = TRUE))
)
ipw_ci <- unname(quantile(ipw_bootstrap, c(0.025, 0.975)))
data.frame(
Method = "Stabilized IPW",
ATE = ipw_ate,
CI_lower = ipw_ci[1],
CI_upper = ipw_ci[2],
Bootstrap_replicates = n_boot,
check.names = FALSE
) |>
knitr::kable(
col.names = c(
"Method", "ATE", "Lower 95% CI", "Upper 95% CI",
"Bootstrap replicates"
),
digits = 2,
caption = "Percentile bootstrap interval for the IPW ATE"
)| Method | ATE | Lower 95% CI | Upper 95% CI | Bootstrap replicates |
|---|---|---|---|---|
| Stabilized IPW | -5.27 | -6.01 | -4.54 | 250 |
Each bootstrap replicate re-estimates both the propensity scores and
the weights. Treating weights from a single fitted model as fixed and
then using the default standard error from an ordinary weighted
lm() generally fails to capture the uncertainty introduced
by estimating those weights.
Matching finds controls whose pretreatment characteristics are similar to those of treated participants. Common 1:1 nearest-neighbor matching more naturally estimates the ATT among treated participants who were successfully matched, rather than the ATE in the original population. Once unmatched participants are excluded, the new analytic population must be described.
The following base R example demonstrates greedy matching without replacement on the logit of the propensity score. Applied analyses should use validated software so that processing order, ties, replacement, calipers, weights, and variance estimation are handled explicitly.
Continue with the complete PSM
workflow This section connects causal identification to the idea
of matching. For a complete MatchIt case study covering
sample flow, overlap, SMDs, Love plots, matching weights, ATT inference,
and design sensitivity, continue to
Propensity Score Matching in
Depth.
greedy_match <- function(z, logit_ps, caliper, seed = 1) {
treated <- which(z == 1)
available_controls <- which(z == 0)
set.seed(seed)
treated <- sample(treated)
matched_treated <- integer(0)
matched_control <- integer(0)
for (treated_id in treated) {
if (!length(available_controls)) break
distance <- abs(logit_ps[available_controls] - logit_ps[treated_id])
nearest_position <- which.min(distance)
if (distance[nearest_position] <= caliper) {
matched_treated <- c(matched_treated, treated_id)
matched_control <- c(
matched_control, available_controls[nearest_position]
)
available_controls <- available_controls[-nearest_position]
}
}
data.frame(treated = matched_treated, control = matched_control)
}
logit_propensity <- qlogis(propensity_hat)
match_caliper <- 0.20 * sd(logit_propensity)
matched_pairs <- greedy_match(
causal_data$program_num,
logit_propensity,
caliper = match_caliper,
seed = 20260814
)
pair_difference <-
causal_data$six_month_sbp[matched_pairs$treated] -
causal_data$six_month_sbp[matched_pairs$control]
matched_att_ci <- mean_ci(pair_difference)
true_matched_att <- mean(
causal_truth$individual_effect[matched_pairs$treated]
)
matched_result <- data.frame(
Metric = c(
"Number in the original program group",
"Number of successfully matched pairs",
"Number of unmatched program participants",
"Matched ATT (demonstration)",
"Lower bound of the illustrative 95% CI",
"Upper bound of the illustrative 95% CI",
"Finite-sample simulated ATT truth among successfully matched program participants",
"Finite-sample simulated ATT truth among all program participants"
),
Value = c(
as.character(sum(causal_data$program_num == 1)),
as.character(nrow(matched_pairs)),
as.character(sum(causal_data$program_num == 1) - nrow(matched_pairs)),
formatC(
unname(c(
matched_att_ci["estimate"], matched_att_ci["lower"],
matched_att_ci["upper"], true_matched_att, true_att
)),
digits = 2, format = "f"
)
),
Unit = c("participants", "pairs", "participants", rep("mmHg", 5))
)
knitr::kable(
matched_result,
align = c("l", "r", "l"),
caption = "Results of 1:1 nearest-neighbor propensity score matching"
)| Metric | Value | Unit |
|---|---|---|
| Number in the original program group | 888 | participants |
| Number of successfully matched pairs | 743 | pairs |
| Number of unmatched program participants | 145 | participants |
| Matched ATT (demonstration) | -5.08 | mmHg |
| Lower bound of the illustrative 95% CI | -5.92 | mmHg |
| Upper bound of the illustrative 95% CI | -4.24 | mmHg |
| Finite-sample simulated ATT truth among successfully matched program participants | -5.11 | mmHg |
| Finite-sample simulated ATT truth among all program participants | -5.16 | mmHg |
matched_indices <- c(matched_pairs$treated, matched_pairs$control)
matched_z <- c(
rep(1, nrow(matched_pairs)),
rep(0, nrow(matched_pairs))
)
matched_smd <- vapply(
balance_variables,
function(x) standardized_difference(x[matched_indices], matched_z),
numeric(1)
)
data.frame(
Covariate = names(matched_smd),
`SMD before matching` = unname(unadjusted_smd),
`SMD after matching` = unname(matched_smd),
check.names = FALSE
) |>
knitr::kable(
digits = 2,
caption = "Balance of pretreatment covariates before and after matching"
)| Covariate | SMD before matching | SMD after matching |
|---|---|---|
| Age | 0.46 | 0.03 |
| Baseline SBP | 0.64 | 0.00 |
| Current smoking | 0.32 | -0.03 |
| Rural residence | 0.00 | 0.03 |
| Health literacy | 0.08 | 0.02 |
The postmatching paired mean difference applies only to participants who were successfully matched. The paired t interval in the table treats the matched pairs as fixed and does not fully account for propensity score estimation or the discontinuity of the matching algorithm. It is therefore a teaching demonstration, not a general variance-estimation template for matched analyses. Different matching algorithms can produce different samples; investigators should prespecify the distance measure, caliper, use of replacement, matching ratio, processing order, number excluded, balance assessment, and a variance estimator appropriate for the design.
Augmented inverse probability weighting (AIPW) combines standardization with IPW. Define . The ATE estimator is:
The first component is the contrast in outcome-model predictions; the final two terms use weighted residuals to correct prediction errors. Under standard regularity conditions, the estimator is consistent if either the propensity score model or the outcome model is correctly specified, which gives the method its “doubly robust” designation.
Cross-fitting trains nuisance models in one subset of the data and generates propensity score and outcome predictions in a fold that was not used for fitting. This sample separation is especially important with complex machine-learning models; the example uses simple regressions to demonstrate the computational structure.
set.seed(20260815)
k_folds <- 5
fold_id <- integer(nrow(causal_data))
for (treatment_level in 0:1) {
level_index <- which(causal_data$program_num == treatment_level)
fold_id[level_index] <- sample(
rep(seq_len(k_folds), length.out = length(level_index))
)
}
crossfit_ps <- crossfit_m1 <- crossfit_m0 <- rep(NA_real_, nrow(causal_data))
for (fold in seq_len(k_folds)) {
test_index <- which(fold_id == fold)
train_data <- causal_data[fold_id != fold, , drop = FALSE]
test_data <- causal_data[test_index, , drop = FALSE]
ps_fit <- glm(
program_num ~
age_c10 + baseline_sbp_c10 + smoking_num + rural_num +
health_literacy,
family = binomial(),
data = train_data
)
outcome_fit_1 <- lm(
six_month_sbp ~
age_c10 + baseline_sbp_c10 + smoking_num + rural_num +
health_literacy,
data = train_data[train_data$program_num == 1, , drop = FALSE]
)
outcome_fit_0 <- lm(
six_month_sbp ~
age_c10 + baseline_sbp_c10 + smoking_num + rural_num +
health_literacy,
data = train_data[train_data$program_num == 0, , drop = FALSE]
)
crossfit_ps[test_index] <- clamp_probability(
predict(ps_fit, newdata = test_data, type = "response"),
1e-6
)
crossfit_m1[test_index] <- predict(outcome_fit_1, newdata = test_data)
crossfit_m0[test_index] <- predict(outcome_fit_0, newdata = test_data)
}
stopifnot(
!anyNA(crossfit_ps),
!anyNA(crossfit_m1),
!anyNA(crossfit_m0)
)
aipw_score <-
crossfit_m1 - crossfit_m0 +
causal_data$program_num *
(causal_data$six_month_sbp - crossfit_m1) / crossfit_ps -
(1 - causal_data$program_num) *
(causal_data$six_month_sbp - crossfit_m0) / (1 - crossfit_ps)
aipw_ate <- mean(aipw_score)
aipw_se <- sd(aipw_score) / sqrt(length(aipw_score))
aipw_ci <- aipw_ate + qnorm(c(0.025, 0.975)) * aipw_se
data.frame(
Method = "Treatment-stratified 5-fold cross-fitted AIPW",
ATE = aipw_ate,
SE = aipw_se,
`Lower 95% CI` = aipw_ci[1],
`Upper 95% CI` = aipw_ci[2],
check.names = FALSE
) |>
knitr::kable(
digits = 2,
caption = "AIPW estimate of the population average treatment effect"
)| Method | ATE | SE | Lower 95% CI | Upper 95% CI |
|---|---|---|---|---|
| Treatment-stratified 5-fold cross-fitted AIPW | -5.04 | 0.35 | -5.74 | -4.35 |
This interval is a normal-approximation interval based on the empirical influence function for the cross-fitted AIPW estimator, under independent and identically distributed sampling of participants and standard regularity conditions. Clustering, repeated measurements, or complex sampling requires a corresponding variance method.
Doubly robust does not mean “double insurance” AIPW cannot repair unmeasured confounding, incorrect temporal ordering, a failure of consistency, a serious positivity violation, or simultaneous misspecification of both models. Extreme propensity scores can also amplify the residual terms, and cross-fitting cannot create treatment contrasts that do not exist in the data.
crude_fit <- lm(six_month_sbp ~ program_num, data = causal_data)
crude_ci <- confint(crude_fit)["program_num", ]
ate_comparison <- data.frame(
Method = c(
"Crude mean difference", "Standardization", "Stabilized IPW",
"Cross-fitted AIPW", "Finite-sample simulated ATE benchmark"
),
Estimate = c(crude_difference, gcomp_ate, ipw_ate, aipw_ate, true_ate),
`Lower 95% CI` = c(
crude_ci[1], gcomp_ci[1], ipw_ci[1], aipw_ci[1], NA
),
`Upper 95% CI` = c(
crude_ci[2], gcomp_ci[2], ipw_ci[2], aipw_ci[2], NA
),
Estimand = c(
"Crude observed-group contrast (not a causal estimand)", rep("ATE", 4)
),
check.names = FALSE
)
ate_comparison_display <- ate_comparison
for (column in c("Estimate", "Lower 95% CI", "Upper 95% CI")) {
values <- ate_comparison_display[[column]]
ate_comparison_display[[column]] <- ifelse(
is.na(values), "—", formatC(values, digits = 2, format = "f")
)
}
knitr::kable(
ate_comparison_display,
caption = "Crude observed association and three confounding-adjusted ATE estimates"
)| Method | Estimate | Lower 95% CI | Upper 95% CI | Estimand |
|---|---|---|---|---|
| Crude mean difference | -0.65 | -1.55 | 0.25 | Crude observed-group contrast (not a causal estimand) |
| Standardization | -5.05 | -5.63 | -4.43 | ATE |
| Stabilized IPW | -5.27 | -6.01 | -4.54 | ATE |
| Cross-fitted AIPW | -5.04 | -5.74 | -4.35 | ATE |
| Finite-sample simulated ATE benchmark | -5.06 | — | — | ATE |
plot_rows <- 4:1
comparison_plot_labels <- c(
"Crude difference", "Standardization", "Stabilized IPW", "Cross-fitted AIPW"
)
old_margin <- par("mar")
par(mar = c(5.1, 8.2, 4.1, 2.1))
plot(
ate_comparison$Estimate[1:4], plot_rows,
pch = 16,
col = c(palette_ci["orange"], rep(palette_ci["teal"], 3)),
xlim = range(
ate_comparison[["Lower 95% CI"]][1:4],
ate_comparison[["Upper 95% CI"]][1:4],
true_ate
),
ylim = c(0.5, 4.5), yaxt = "n",
xlab = "Program minus usual care: mean SBP difference (mmHg)", ylab = "",
main = "Crude and adjusted estimates"
)
segments(
ate_comparison[["Lower 95% CI"]][1:4], plot_rows,
ate_comparison[["Upper 95% CI"]][1:4], plot_rows,
col = c(palette_ci["orange"], rep(palette_ci["teal"], 3)),
lwd = 2
)
axis(2, at = plot_rows, labels = comparison_plot_labels, las = 1)
abline(v = true_ate, lty = 2, lwd = 2, col = palette_ci["navy"])
abline(v = 0, lty = 3, col = palette_ci["gray"])
legend(
"bottomright", legend = "Finite-sample ATE benchmark",
lty = 2, lwd = 2, col = palette_ci["navy"], bty = "n"
)Crude and adjusted estimates with the finite-sample simulated ATE benchmark.
The three adjusted methods use different modeling strategies but rely on the same core identification assumptions. Similar results increase confidence in the implementations, but they do not prove that unmeasured confounding is absent. The crude estimate is biased toward zero because people with higher baseline blood pressure are more likely to participate in the program. The dashed benchmark is the finite-sample ATE in this realized simulated cohort, whereas the displayed intervals reflect the model-based, bootstrap, and influence-function sampling procedures described above; it is a teaching reference rather than the exact parameter those intervals are designed to cover.
The matching result is not included in this ATE plot because 1:1 matching targets successfully matched treated participants and is therefore closer to an ATT. Placing estimates for different target populations or effect scales in the same column would create a misleading appearance of disagreement among methods.
A confounder affects both treatment and outcome; an effect modifier makes the magnitude of the treatment effect differ across populations. A variable can play both roles or only one of them.
In the simulation mechanism, the program’s effect on blood pressure through engagement varies continuously with baseline SBP. Below, a prespecified 150 mmHg cutoff is used solely to demonstrate subgroup standardization. In an applied study, retaining the continuous information and presenting heterogeneity with curves and intervals would generally be preferable.
high_baseline <- causal_data$baseline_sbp >= 150
estimate_subgroup_effect <- function(index) {
mean(predicted_y1[index] - predicted_y0[index])
}
subgroup_ci <- t(apply(
gcomp_bootstrap[c("low_baseline", "high_baseline"), , drop = FALSE],
1,
quantile,
probs = c(0.025, 0.975)
))
subgroup_effects <- data.frame(
`Baseline group` = c("Baseline SBP < 150", "Baseline SBP ≥ 150"),
N = c(sum(!high_baseline), sum(high_baseline)),
`Standardized CATE` = c(
estimate_subgroup_effect(!high_baseline),
estimate_subgroup_effect(high_baseline)
),
`Lower 95% CI` = subgroup_ci[, 1],
`Upper 95% CI` = subgroup_ci[, 2],
`Finite-sample simulated truth` = c(
mean(causal_truth$individual_effect[!high_baseline]),
mean(causal_truth$individual_effect[high_baseline])
),
row.names = NULL,
check.names = FALSE
)
knitr::kable(
subgroup_effects,
digits = 2,
caption = "Conditional average treatment effects by baseline blood pressure group"
)| Baseline group | N | Standardized CATE | Lower 95% CI | Upper 95% CI | Finite-sample simulated truth |
|---|---|---|---|---|---|
| Baseline SBP < 150 | 1375 | -5.00 | -5.73 | -4.30 | -4.89 |
| Baseline SBP ≥ 150 | 825 | -5.12 | -5.88 | -4.31 | -5.34 |
Subgroup comparisons should report the number of participants, event counts or outcome distributions, effects, and uncertainty in each group. An interaction should not be claimed merely because one subgroup result is statistically significant and another is not. A formal test should directly evaluate the difference in effects, with the scale, functional form, and subgroups specified in advance.
A treatment may produce a larger absolute benefit for high-risk people on the risk-difference scale while remaining approximately constant on the risk-ratio scale. The population ATE also changes with the proportions of subgroups in the target population. When transporting results, ask:
A subgroup estimate is not automatically an individualized treatment rule. Individual treatment effects are generally unobservable, and using the same data to discover and report “best responders” is highly prone to overfitting. External validation and an evaluation of decision consequences are required.
Extreme weights increase variance and amplify measurement error in a small number of observations. Weight truncation can reduce variance but may reintroduce confounding bias; restricting the analysis to common support explicitly changes the target population. Both choices should be prespecified or transparently reported as sensitivity analyses.
truncation_limits <- quantile(stabilized_weight, c(0.01, 0.99))
truncated_weight <- pmin(
pmax(stabilized_weight, truncation_limits[1]),
truncation_limits[2]
)
truncated_ipw <-
weighted_mean(
causal_data$six_month_sbp[causal_data$program_num == 1],
truncated_weight[causal_data$program_num == 1]
) -
weighted_mean(
causal_data$six_month_sbp[causal_data$program_num == 0],
truncated_weight[causal_data$program_num == 0]
)
support_index <- propensity_hat >= 0.10 & propensity_hat <= 0.90
support_data <- causal_data[support_index, , drop = FALSE]
support_ps_model <- glm(
program_num ~
age_c10 + baseline_sbp_c10 + smoking_num + rural_num + health_literacy,
family = binomial(), data = support_data
)
support_ps <- clamp_probability(
predict(support_ps_model, type = "response"), 1e-6
)
support_prevalence <- mean(support_data$program_num)
support_weight <- ifelse(
support_data$program_num == 1,
support_prevalence / support_ps,
(1 - support_prevalence) / (1 - support_ps)
)
support_ipw <-
weighted_mean(
support_data$six_month_sbp[support_data$program_num == 1],
support_weight[support_data$program_num == 1]
) -
weighted_mean(
support_data$six_month_sbp[support_data$program_num == 0],
support_weight[support_data$program_num == 0]
)
outcome_model_no_interaction <- lm(
six_month_sbp ~
program_num + age_c10 + baseline_sbp_c10 + smoking_num +
rural_num + health_literacy,
data = causal_data
)
no_interaction_ate <- coef(outcome_model_no_interaction)["program_num"]
sensitivity_table <- data.frame(
Analysis = c(
"Primary analysis: stabilized IPW",
"Weight truncation at the 1st and 99th percentiles",
"Restricted to 0.10 ≤ estimated PS ≤ 0.90",
"Standardization with an outcome model without interactions"
),
`Effect estimate` = c(
ipw_ate, truncated_ipw, support_ipw, no_interaction_ate
),
`Analysis N` = c(
nrow(causal_data), nrow(causal_data), nrow(support_data),
nrow(causal_data)
),
`Target description` = c(
"ATE in the original target population",
"Same nominal ATE; modified weighting estimator and bias-variance tradeoff",
"ATE in a restricted empirical population defined by the current data",
"Misspecified-model sensitivity estimate for the original ATE"
),
check.names = FALSE
)
knitr::kable(
sensitivity_table,
digits = 2,
caption = "Sensitivity analyses for weights, propensity-score restriction, and outcome-model specification"
)| Analysis | Effect estimate | Analysis N | Target description |
|---|---|---|---|
| Primary analysis: stabilized IPW | -5.27 | 2200 | ATE in the original target population |
| Weight truncation at the 1st and 99th percentiles | -4.99 | 2200 | Same nominal ATE; modified weighting estimator and bias-variance tradeoff |
| Restricted to 0.10 ≤ estimated PS ≤ 0.90 | -5.21 | 2176 | ATE in a restricted empirical population defined by the current data |
| Standardization with an outcome model without interactions | -5.06 | 2200 | Misspecified-model sensitivity estimate for the original ATE |
Similar numerical results do not establish that every assumption is correct, but they do indicate whether an arbitrary analytic choice dominates the conclusion. Propensity score thresholds define a data-dependent, restricted empirical population, not a common-support population in a strict theoretical sense. If many people are excluded, describe them separately and do not extrapolate the result to them. When the true effect is heterogeneous, the no-interaction OLS coefficient is generally an overlap-related model projection rather than the ATE in the original population; the final row intentionally demonstrates sensitivity to model misspecification.
Suppose there is an unmeasured binary factor . After adequate adjustment for the measured variables, let be the conditional difference in the prevalence of between the treatment and control groups, and let be the additive mean difference in the outcome associated with . In a simplified linear setting without complex interactions, the bias from omitting is approximately , giving the following bias-corrected estimate:
delta_values <- c(-0.30, -0.15, 0, 0.15, 0.30)
gamma_values <- c(5, 10, 15)
bias_grid <- expand.grid(
prevalence_difference = delta_values,
outcome_mean_difference_for_U = gamma_values,
KEEP.OUT.ATTRS = FALSE
)
bias_grid$bias_corrected_ATE <-
aipw_ate -
bias_grid$prevalence_difference *
bias_grid$outcome_mean_difference_for_U
bias_display <- reshape(
bias_grid,
idvar = "prevalence_difference",
timevar = "outcome_mean_difference_for_U",
direction = "wide"
)
names(bias_display) <- c(
"Treatment-control difference in U prevalence",
"γ = 5", "γ = 10", "γ = 15"
)
knitr::kable(
bias_display,
digits = 2,
caption = "Bias-corrected ATE under simplified unmeasured-confounding parameters (mmHg)"
)| Treatment-control difference in U prevalence | γ = 5 | γ = 10 | γ = 15 |
|---|---|---|---|
| -0.30 | -3.54 | -2.04 | -0.54 |
| -0.15 | -4.29 | -3.54 | -2.79 |
| 0.00 | -5.04 | -5.04 | -5.04 |
| 0.15 | -5.79 | -6.54 | -7.29 |
| 0.30 | -6.54 | -8.04 | -9.54 |
This grid is neither a test for unmeasured confounding nor a general correction formula. It forces investigators to state how imbalanced an omitted factor would need to be, how strongly and in what direction it would need to relate to the outcome, and whether those values would materially change the conclusion. A more formal quantitative bias analysis should be tailored to the outcome type, effect scale, interactions, measurement error, and available external information.
A negative-control exposure should not, in theory, affect the target outcome; a negative-control outcome should not, in theory, be affected by the target treatment. An observed association may indicate shared confounding, selection, or measurement problems. Negative controls themselves, however, depend on an explicit assumption that the relevant effect should be absent and therefore cannot automatically quantify or remove bias.
A persuasive causal argument usually comes from multiple lines of evidence: different designs, biases expected to operate in different directions, prespecified sensitivity analyses, negative controls, natural experiments, mechanistic evidence, and external replication—not from the p value of a single model.
When conditional exchangeability given measured covariates is not credible, researchers may exploit policies, thresholds, changes over time, or external encouragement to form quasi-experimental comparisons. Each design replaces the assumptions of conventional covariate adjustment with a different set of strong assumptions.
| Method | Typical estimand | Core basis for identification | Key diagnostic or threat |
|---|---|---|---|
| Regression/standardization | ATE or CATE in the target population | No unmeasured confounding given | Functional form, overlap, adjustment set |
| IPW | Marginal effect in the target population | Correct treatment model plus the same identification assumptions | Balance, extreme weights, model error |
| AIPW | Marginal effect in the target population | Either the treatment or outcome model is correct, plus the same identification assumptions | Both models, extreme scores, cross-fitting |
| Matching | Often the ATT among matchable treated participants | Exchangeability on measured variables | Match quality, discarded observations, variance |
| Difference-in-differences | ATT for the treated group | Parallel trends in the absence of treatment | Pretrends, concurrent shocks, anticipation |
| Regression discontinuity | Local effect near a cutoff | Continuity of potential outcomes at the cutoff | Score manipulation, bandwidth, functional form |
| Instrumental variables | Often a local average treatment effect among compliers | Relevance, independence, exclusion restriction, and related assumptions | Weak instrument, direct pathways, monotonicity |
| Interrupted time series | Level or trend change at a policy date | No concurrent shock that also affects the outcome | Seasonality, historical events, autocorrelation |
Difference-in-differences (DiD) compares the pre-to-post change in the treated group with the corresponding change in a comparison group:
The key parallel-trends assumption states that, without the intervention, the two groups would have followed the same average outcome trend. A prepolicy trend plot can reveal some evidence against this assumption, but it cannot prove the unobserved postpolicy counterfactual trend. Changes in group composition, differential concurrent shocks, anticipation, spillovers, and changes in outcome coding also require attention.
With staggered adoption and treatment effects that vary across groups or time, a traditional two-way fixed-effects regression can combine inappropriate comparisons. Use modern group-time estimators designed for staggered treatment, and state whether not-yet-treated or never-treated groups form the comparison.
When a rule assigns treatment according to whether a continuous score crosses a cutoff , units immediately on either side of the cutoff may be compared:
This effect usually applies only near the cutoff. Identification requires potential outcomes and important baseline characteristics to be continuous at the cutoff, apart from the jump in treatment probability, and requires that individuals cannot precisely manipulate their score. Show raw observations or binned means, the score density, and covariate continuity. Report local-linear estimates across defensible bandwidths and polynomial orders; avoid high-order global polynomials.
An instrument affects treatment but influences the outcome only through treatment. With a binary instrument and binary treatment, the Wald ratio is:
Under instrument relevance, independence, the exclusion restriction, monotonicity, and related conditions, this ratio identifies the local average treatment effect among compliers. It is a complier mean difference for a continuous outcome and a complier risk difference for a binary outcome; no separate “linear risk-difference model” is required. A weak first stage produces instability and severe finite-sample bias, while strong relevance does not establish the exclusion restriction.
Clinician preference, distance, and policy eligibility are sometimes proposed as instruments, but each may affect outcomes directly through care quality, transportation, regional resources, or other services. An instrument is credible because of institutional and mechanistic knowledge, not because a variable has been entered into a two-stage regression.
During long-term treatment, prior treatment can affect subsequent health status, which then affects the next treatment decision. If a time-varying covariate is both a consequence of earlier treatment and a confounder of later treatment and outcome, ordinary regression adjustment can block part of the treatment pathway and introduce bias.
Marginal structural models with inverse-probability treatment and censoring weights, the longitudinal g-formula, g-estimation, and related g-methods address this structure. They require treatment strategies, covariate histories, time zero, follow-up, censoring, and positivity to be defined at each time point. Treating multiple longitudinal rows as independent cross-sectional observations does not solve the problem.
A total effect can be decomposed into pathways through and outside a mediator, but a treatment coefficient “adjusted for the mediator” is not automatically a causal direct effect. Mediation analysis must additionally address treatment-mediator interaction, unmeasured mediator-outcome confounding, mediator-outcome confounding affected by treatment, and whether an intervention on the mediator is well defined.
Rather than asking vaguely what percentage is mediated, ask a more explicit question: what would happen to the outcome if the mediator distribution were shifted by an implementable intervention while treatment strategy remained fixed? Natural, controlled, and interventional direct and indirect effects have different counterfactual definitions and assumptions.
If outcome missingness is affected by both treatment and prognosis, analyzing only participants with observed outcomes conditions on a potential collider. When pretreatment confounders are missing, a complete-case analysis can also change the target population and its covariate distribution.
Options to consider include:
Multiple imputation does not automatically remove unmeasured confounding, and it cannot reliably recreate a key construct that was never collected.
Collapsing smoking to current versus not current, using a single measurement to represent long-term blood pressure, or using billing codes as a proxy for disease severity can all leave adjustment incomplete. Error in a confounder, treatment misclassification, and outcome error have different consequences; differential error is particularly difficult to predict.
Report each variable’s source, timing, repeated measurements, validation evidence, and threshold rules. When external information on sensitivity, specificity, or reliability is available, use probabilistic bias analysis rather than mentioning measurement error only in the limitations.
Internal validity asks whether the causal contrast within the study is credible; external validity asks whether it applies to another population. If study participation is jointly affected by effect modifiers and outcome risk, the study-sample ATE may not represent the target-population ATE.
Generalizability or transportability methods can restandardize or reweight to the target population’s covariate distribution, but they require measurement of the important common causes of selection and outcome as well as effect modifiers, with support in the target population. When target-population data are unavailable, state the scope of applicability clearly.
Average effectiveness does not imply equitable access An average causal effect may hide group differences in benefit, harm, access, and measurement quality. Rurality, income, ethnicity, and disability often represent structures and resources rather than fixed biological attributes. When reporting heterogeneity, state the mechanistic assumptions, data support, and risk of stigmatization, and include “who can access the intervention?” in the decision.
Define the target population, eligibility criteria, strategies, assignment, time zero, follow-up, outcome, causal contrast, effect scale, and analysis principle. Record when each variable is measured so that future information cannot enter baseline definitions.
Invite subject-matter experts, data stewards, and representatives of the study population to review the DAG. List the minimally sufficient adjustment set, post-treatment variables that should not be adjusted for, potentially unmeasured common causes, selection mechanisms, and measurement proxies. Retain analysis plans for alternative credible DAGs.
Inspect treatment-group sizes, covariate distributions, missingness, propensity-score overlap, extreme combinations, follow-up, and outcome counts. If positivity clearly fails, redefine the question rather than hiding extrapolation with a more complex algorithm.
Choose a method aligned with the estimand. For standardization, diagnose the outcome model; for IPW, diagnose the treatment model, weights, and balance; for matching, inspect the matched sample; and for AIPW, inspect both nuisance models. Use variance methods appropriate for clustering, repeated measures, and estimated weights.
At minimum, consider defensible DAGs, nonlinearity and interactions, overlap restrictions, weight truncation, missing-data handling, treatment and outcome error, unmeasured confounding, negative controls, and alternative effect scales. Sensitivity parameters should have scientifically defensible ranges rather than being searched until a preferred answer appears.
Report the standardized outcome under each strategy, the effect contrast, uncertainty interval, target population, diagnostics, and limitations. Separate identification assumptions from statistical-model assumptions, and do not imply that a confidence interval contains every source of uncertainty.
We emulated a comparison of [strategy 1] versus [strategy 0], initiated at [a common time zero], among [the target population]. The outcome was [a precisely defined outcome over a stated period], and the primary estimand was [ATE/ATT/CATE on a stated scale]. Based on [the DAG, subject-matter knowledge, and measurement timing], we adjusted for [variables]; identification relied on [consistency, exchangeability, positivity, no interference, and relevant selection/missingness conditions]. We estimated the effect with [standardization/IPW/AIPW/design-based method]; diagnostics showed [balance, overlap, weights, model behavior, and sample support]. Standardized outcomes under strategies 1 and 0 were [values], for an effect of [estimate, 95% CI]. Results were [robust/sensitive] to [sensitivity analyses]; [specific limitations involving unmeasured confounding, measurement, or transportability] may still affect interpretation.
Among 2200 simulated participants, we compared the 6-month mean systolic blood pressure if everyone entered the intensive blood pressure management program with the mean if everyone received usual care. Based on the pretreatment causal structure, we adjusted for age, baseline blood pressure, smoking, rural residence, and health literacy; cross-fitted AIPW estimated an ATE of -5.04 mmHg (95% CI -5.74 to -4.35). The standardization estimate was -5.05 mmHg, stabilized IPW gave -5.27 mmHg, and the largest absolute SMD among measured covariates after weighting was 0.02. This interval does not include identification uncertainty from unmeasured confounding or related biases; all results come from a simulation with a known mechanism and are not evidence about a real program.
| Common statement or practice | Why it is a problem | Better practice |
|---|---|---|
| “It is significant after adjustment, so it is causal.” | Significance does not validate identification assumptions | Define the estimand, design, DAG, and identification conditions first |
| Select confounders by univariable p-values | A p-value does not determine a causal role | Use temporal order, mechanisms, and a DAG |
| Adjust for every baseline and post-treatment variable | Mediators and colliders can change the question or induce bias | State each variable’s role and adjust for an appropriate set |
| A high propensity-score AUC proves a good model | Strong separation may indicate poor overlap | Inspect common support, weights, and weighted balance |
| Skip covariate checks after matching | An algorithm does not guarantee balance in the realized sample | Report distributions and SMDs before and after matching |
| Drop unmatched participants but still call the result a population ATE | The analysis population has changed | Describe exclusions and name the ATT or overlap-population effect correctly |
| Truncate weights without reporting the threshold | Obscures how the estimator and its bias-variance tradeoff changed | Report thresholds, counts, balance, and sensitivity results |
| “Doubly robust” means either model may be arbitrary | Bias remains when both nuisance models are wrong | Diagnose both models and the shared identification assumptions |
| One subgroup is significant and another is not | This does not establish a difference between subgroup effects | Estimate the interaction or effect difference and its interval directly |
| Report only a relative effect | Omits baseline risk and absolute decision relevance | Also report strategy-specific outcomes and an absolute effect |
| A confidence interval covers every uncertainty | It usually reflects sampling under the specified procedure | Analyze identification and measurement uncertainty separately |
| Similar results across methods prove no confounding | Methods may share the same incorrect assumptions | Seek evidence with different bias structures and external replication |
What is missing from “Does the health program work?” Rewrite it as an estimable causal question.
Baseline disease severity affects treatment receipt and the outcome; post-treatment adherence is affected by treatment and affects the outcome; follow-up visits are affected by both treatment and symptom worsening. What role does each variable play, and how should each be handled when estimating the total effect?
A clinical rule prohibits a medication during pregnancy, but the study seeks a population ATE that includes pregnant people. Can collecting more data under the same rule solve the problem?
Why does standardization create predictions under both and for every participant rather than setting all continuous covariates to their sample means?
An IPW analysis has a maximum weight of 85, an ESS that falls from 3,000 to 420, and an absolute weighted SMD of 0.24 for an important covariate. Is the effect ready to report?
One-to-one matching without replacement excludes 35% of treated participants and 70% of controls. Can the matched mean difference still be called the original population ATE?
Both the propensity-score and outcome models in an AIPW analysis fit well. May an unmeasured disease-severity common cause now be ignored?
The effect has p = 0.03 in an urban subgroup and p = 0.20 in a rural subgroup. Can we conclude that only urban participants benefit?
The prepolicy trends appear parallel. Does this prove that the postpolicy counterfactual trends would also have been parallel?
The AIPW 95% CI excludes zero. Does this mean unmeasured confounding, selection bias, and misclassification cannot reverse the conclusion?
| Concept | Formula | Interpretation |
|---|---|---|
| Individual effect | Counterfactual difference between two strategies for one unit | |
| ATE | Average effect in the target population | |
| ATT | Average effect among treated participants | |
| Consistency | The observed strategy maps to a well-defined potential outcome | |
| Exchangeability | No uncontrolled common causes conditional on | |
| Positivity | Every target covariate pattern supports both strategies | |
| g-formula | Standardize conditional outcomes to the target population | |
| Propensity score | Probability of treatment given pretreatment covariates | |
| Stabilized weight | Create a pseudo-population balanced on measured variables | |
| ESS | Summary of weight concentration |
| Goal | Code pattern |
|---|---|
| Crude mean difference | with(d, mean(y[a == 1]) - mean(y[a == 0])) |
| Outcome model | lm(y ~ a * x1 + x2, data = d) |
| Two counterfactual worlds | transform(d, a = 1);
transform(d, a = 0) |
| Standardized ATE | mean(predict(fit, d1) - predict(fit, d0)) |
| Propensity score | glm(a ~ x1 + x2, family = binomial(), data = d) |
| ATE weights | ifelse(a == 1, mean(a)/ps, (1-mean(a))/(1-ps)) |
| Strategy-specific weighted mean | mu1 <- sum(w[a == 1] * y[a == 1]) / sum(w[a == 1]);
calculate mu0 analogously for a == 0 |
| IPW ATE | mu1 - mu0 |
| Weight ESS | sum(w)^2 / sum(w^2) |
| Nonparametric bootstrap | Resample with sample.int(nrow(d), replace = TRUE) and
refit |
| Linear-model coefficient interval | confint(fit) |
| Term | Plain-language meaning |
|---|---|
| Counterfactual/potential outcome | The outcome that would occur for the same unit under another strategy |
| Estimand | A quantity jointly defined by the target population, strategies, outcome, time, and effect scale |
| Confounding | Treatment and outcome share causes, so the observed groups are not directly comparable |
| Backdoor path | A noncausal path between treatment and outcome that begins with an arrow into treatment |
| Mediator | A variable on a causal pathway from treatment to outcome |
| Collider | A variable receiving two arrows; conditioning on it can open a path |
| Standardization | Average conditional counterfactual predictions over the target covariate distribution |
| Propensity score | Probability of treatment given pretreatment covariates |
| Positivity | Every type of target individual has some chance of receiving each strategy |
| Overlap | Both groups contain participants with similar pretreatment characteristics |
| Double robustness | The estimator remains consistent when either the treatment or outcome nuisance model is correct, under the shared identification assumptions |
| Cross-fitting | Train nuisance models and generate predictions in separate data folds to reduce overfitting bias |
| Local effect | An effect applying only near a threshold or to a subgroup such as compliers |
Before publishing a causal conclusion, answer each question in one sentence:
If any answer is only “the software handled it” or “because p < 0.05,” the analysis is not finished.
## R version 4.6.1 (2026-06-24)
## Platform: aarch64-apple-darwin23
## Running under: macOS Tahoe 26.5.1
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
##
## locale:
## [1] C.UTF-8/C.UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8
##
## time zone: America/Edmonton
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## loaded via a namespace (and not attached):
## [1] digest_0.6.39 R6_2.6.1 fastmap_1.2.0 xfun_0.60
## [5] cachem_1.1.0 knitr_1.51 htmltools_0.5.9 rmarkdown_2.31
## [9] lifecycle_1.0.5 cli_3.6.6 sass_0.4.10 jquerylib_0.1.4
## [13] compiler_4.6.1 tools_4.6.1 evaluate_1.0.5 bslib_0.12.0
## [17] yaml_2.3.12 rlang_1.3.0 jsonlite_2.0.0