What it actually does
In an observational study the treated and untreated groups differ before treatment ever happens, so comparing their outcomes measures the difference between the groups as much as the effect of the treatment. Fit a model that predicts who got treated, then reweight everyone by the inverse of that probability. People who were unlikely to be treated but were count for more, and vice versa — the reweighted population looks as if treatment had been assigned at random.
- Conditional ignorability — all confounders affecting both treatment and outcome are measured and included
- Positivity — every unit had a non-zero chance of either treatment, so the weights are finite
- Consistency / SUTVA — one unit's treatment does not affect another's outcome, and the treatment is well defined
- The propensity model is correctly specified, or at least well calibrated where it matters
Likelihood
Fit the propensity model by ordinary maximum likelihood, then form the effect as a weighted difference of means. The weights are what does the work: dividing by the probability of the treatment you actually received upweights the people who look like the other group, which is how the two arms are made comparable.
- the propensity score — probability of treatment given covariates
- treatment indicator, 1 if treated
- observed outcome — only one potential outcome is ever seen
- the estimated average treatment effect
How it is minimized
Maximum likelihood for the propensity model, then a weighted mean difference — no joint optimization
- the inverse-probability weight for unit i
- blows up as the propensity approaches 0 — the central practical problem
This is a two-stage procedure, not one optimization, and the stages have different goals. Stage one is a plain classifier fit by maximum likelihood. Stage two is arithmetic. The crucial and counter-intuitive point is that the propensity model should be well *calibrated*, not accurate: a model that predicts treatment perfectly means some units had propensity near 0 or 1, their weights explode, and the estimator's variance goes with them. Near-perfect separation is a sign that positivity has failed, not that the model is good.
weight truncation / trimming
Caps extreme weights, trading a little bias for a lot of variance (trim at the 1st/99th percentile, or cap at 10)
stabilization
Multiplies weights by the marginal treatment probability, shrinking their variance (on by default)
propensity model class
Logistic regression by default; a flexible model risks extreme scores (logistic, or shallow GBM)
common-support window
Discards units with no counterpart in the other arm (drop e outside [0.05, 0.95])
The propensity fit is convex under logistic regression and converges reliably. The estimator itself has no convergence to speak of — it is a weighted mean. What it does have is a variance that can be enormous: a single unit with propensity 0.001 receives a weight of 1000 and can single-handedly determine the estimate. The failure is not that the number fails to appear; it is that a plausible-looking number appears with an interval so wide it is uninformative, or worse, an interval computed as if the weights were fixed rather than estimated, which understates it badly.
O(nd) per iteration for the logistic fit, then O(n) for the weighted means. Negligible cost — the expensive part of causal work is the study design, not the arithmetic.
How, where, and why it is used
Time-Series Forecasting
Not applicableThe estimand is a contrast between potential outcomes at a point in time, not a future value. Applying IPTW to a series would answer "what would this period have looked like under the other treatment", which is a counterfactual question, not a forecast — and the weighting machinery assumes exchangeable units, which consecutive observations of one series emphatically are not.
Anomaly Detection
Not applicableThere is no notion of an outlier here, only of a unit with an extreme propensity — and such a unit is a threat to the estimator rather than a finding to report. Trimming discards it precisely so it does not dominate; an anomaly detector would surface it as the answer.
Optimization
ViableSupplies the effect estimate a targeting policy optimizes over: once you know the treatment effect varies by segment, allocating a fixed budget to the highest-effect segments is a constrained optimization whose objective this estimator produces.
- Marketing budget allocation across segments with heterogeneous response
- Deciding which patients to enrol in a costly intervention under a capacity constraint
The link between causal estimation and optimization is direct and frequently missed: you cannot optimize an intervention you have only measured correlationally. A predictive churn model tells you who will leave; only an effect estimate tells you whom a retention offer would actually save, and that is the quantity the allocation problem needs.
- Estimate effects per segment, not just the population average — the allocation needs the variation
- Carry the standard error through; a large effect with a huge interval should not win the budget
Qini and uplift curves against a randomized holdout, comparing the policy against uniform allocation rather than against no allocation.
- Optimizing against a point estimate ignores that the ranking between segments may not be statistically distinguishable
- Effects estimated under one policy regime do not transfer once the policy changes them
Causal Inference & Uplift
Primary fitThe canonical workhorse. Fit the treatment model on pre-treatment covariates only, check the weighted covariate balance, trim to common support, then form the weighted contrast with a robust or bootstrapped variance.
- Observational health studies where randomization is unethical or impossible
- Post-hoc evaluation of a feature rollout that was not A/B tested
- Policy evaluation on administrative data where the treatment was self-selected
It is the default because it separates the design from the outcome cleanly: you can fit the propensity model, check balance, and finalize the whole specification without ever looking at the outcome — which removes the temptation to tune until the answer is the one you wanted. That property is procedural rather than statistical, and it is why practitioners trust it.
- Pre-treatment covariates ONLY — a post-treatment variable is a collider and induces bias
- Include anything affecting both treatment and outcome, even if it does not improve fit
- Check standardized mean differences after weighting; below 0.1 is the usual bar
Weighted covariate balance first, before any effect is read. Then sensitivity analysis for unmeasured confounding, and negative-control outcomes that should show no effect.
- Conditioning on a post-treatment variable or a collider creates bias rather than removing it
- Positivity violations show up as extreme weights, and trimming them silently changes the population you are estimating for
- Naive standard errors ignore that the weights were estimated, and are too narrow
Risk & Fraud
AdaptedEstimates what an intervention actually did rather than what correlates with it — the effect of a manual review, a step-up authentication, or a hold, on the outcome.
- Measuring whether a fraud-review queue reduced losses or just delayed transactions
- Evaluating a rule change that was rolled out without a control group
Necessary here because interventions are rarely randomized in fraud — you review the suspicious ones, which is the definition of confounded assignment. Without reweighting, reviewed transactions look worse than unreviewed ones and the review appears harmful.
- The risk score at decision time is the dominant confounder and must be included
- Time-varying treatment needs marginal structural models rather than a single weight
Balance on the pre-decision risk distribution, plus a negative-control period where no intervention occurred.
- The score that drove the decision is often not logged at decision time, only recomputed later
- Adversarial adaptation means the effect measured last quarter may not hold this one
What it costs to run
Seconds. The propensity model is a logistic regression on a modest covariate set.
Rarely "deployed" in the serving sense — this runs as an offline analysis producing an estimate and an interval, not a per-request prediction.
Re-estimated per analysis. Effects are not stable across policy regimes, so a stored estimate has a shelf life measured in policy changes, not months.
- Track the weight distribution across runs — a lengthening tail means positivity is degrading
- Re-check covariate balance every time; a new data source can silently break it
- Watch for the treated share shifting, which changes what the estimand even refers to
- Analysts routinely report naive standard errors that ignore the estimated weights, understating uncertainty
- Trimming changes the estimand from the population effect to the effect on the trimmed subpopulation — say which one you are reporting
- A propensity model with high AUC is bad news, not good; it means the arms barely overlap
Pros and cons, in context
Design and outcome analysis are cleanly separated
You can finalize the entire specification while blind to the outcome, which removes a whole class of p-hacking. This procedural guarantee is why practitioners trust it more than outcome-regression alternatives of equal statistical merit.
Covariate balance is directly checkable
Unlike a black-box adjustment, you can look at the weighted covariate distributions and see whether the method worked before trusting the effect. Diagnostic transparency matters more than accuracy in regulated and clinical settings.
One weighted dataset serves many outcomes
Fit weights once, then estimate effects on any number of outcomes — efficient when a single intervention has multiple endpoints. Irrelevant for a one-outcome study.
Trivially cheap to compute
The bottleneck in causal work is never the arithmetic. Being fast means iteration is free and sensitivity analyses are not rationed.
Extreme weights inflate variance without warning
A single unit with propensity 0.001 can dominate the estimate. The point estimate still looks reasonable, which is exactly why this is dangerous — always inspect the weight distribution.
Conditional ignorability is untestable
The central assumption cannot be checked from the data, only argued from domain knowledge. This is the honest ceiling on every observational method, and it is why a randomized experiment still wins whenever it is possible.
Sensitive to propensity model misspecification
A single-model method has one point of failure. This is precisely what doubly-robust methods and Double ML were built to fix, so reach for them when the specification is uncertain.
Estimates an average, hiding heterogeneity
A zero average effect is consistent with a large positive effect in one segment and a large negative one in another. When the decision is who to target rather than whether to act, you need a meta-learner instead.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""Propensity scores and IPTW - the two stages, written out.
Stage 1: a logistic regression predicting WHO GOT TREATED.
Stage 2: a difference of means, weighted by the inverse of that probability.
"""
import math
def fit_propensity(X, treatment, lr=0.05, epochs=2_000):
"""Plain logistic regression by gradient ascent on the log-likelihood."""
n = len(X)
d = len(X[0])
beta = [0.0] * d
for _ in range(epochs):
grad = [0.0] * d
for i in range(n):
z = sum(beta[j] * X[i][j] for j in range(d))
p = 1.0 / (1.0 + math.exp(-z))
residual = treatment[i] - p # ascent, so t - p not p - t
for j in range(d):
grad[j] += residual * X[i][j]
for j in range(d):
beta[j] += lr * grad[j] / n
return beta
def propensity(x, beta):
z = sum(b * v for b, v in zip(beta, x))
return 1.0 / (1.0 + math.exp(-z))
def iptw_effect(X, treatment, outcome, beta, clip=0.01):
"""Weighted difference of means.
Clipping is not optional tidying: a propensity of 0.001 produces a weight
of 1000, and that single unit would otherwise decide the answer.
"""
treated_num = treated_den = control_num = control_den = 0.0
for x, t, y in zip(X, treatment, outcome):
e = min(max(propensity(x, beta), clip), 1.0 - clip)
if t == 1:
w = 1.0 / e
treated_num += w * y
treated_den += w
else:
w = 1.0 / (1.0 - e)
control_num += w * y
control_den += w
return treated_num / treated_den - control_num / control_denO(n·d) per epoch in pure Python. Correct, and it silently hides the weight distribution.
How this language actually wants it written.
"""IPTW - typed, with the diagnostics that decide whether to trust the estimate."""
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
from sklearn.linear_model import LogisticRegression
Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
@dataclass(frozen=True)
class CausalEstimate:
"""An effect is never a bare number - it travels with its diagnostics."""
ate: float
std_error: float
max_weight: float
effective_sample_size: float
trimmed_fraction: float
@property
def is_trustworthy(self) -> bool:
"""Cheap guard against the two failures that matter in practice."""
return self.max_weight < 20.0 and self.trimmed_fraction < 0.10
def estimate_ate(
X: Matrix,
treatment: Vector,
outcome: Vector,
trim: tuple[float, float] = (0.05, 0.95),
) -> CausalEstimate:
if X.ndim != 2:
raise ValueError(f"X must be 2-D, got shape {X.shape}")
if not (X.shape[0] == treatment.shape[0] == outcome.shape[0]):
raise ValueError("X, treatment and outcome must describe the same units")
if not np.isin(treatment, (0, 1)).all():
raise ValueError("treatment must be binary 0/1")
model = LogisticRegression(max_iter=1_000)
model.fit(X, treatment)
scores = model.predict_proba(X)[:, 1]
# Common support: a unit with no counterpart in the other arm cannot be
# compared to anything, and keeping it just manufactures variance.
low, high = trim
keep = (scores > low) & (scores < high)
trimmed_fraction = 1.0 - float(keep.mean())
scores, t, y = scores[keep], treatment[keep], outcome[keep]
# Stabilized weights: scaling by the marginal treatment probability keeps
# the weights centred near 1, which materially reduces their variance.
marginal = t.mean()
weights = np.where(t == 1, marginal / scores, (1 - marginal) / (1 - scores))
treated_mean = np.average(y[t == 1], weights=weights[t == 1])
control_mean = np.average(y[t == 0], weights=weights[t == 0])
ate = float(treated_mean - control_mean)
# Kish effective sample size - how much data the weighting actually left.
ess = float(weights.sum() ** 2 / np.square(weights).sum())
std_error = float(np.sqrt(np.var(y) / ess))
return CausalEstimate(
ate=ate,
std_error=std_error,
max_weight=float(weights.max()),
effective_sample_size=ess,
trimmed_fraction=trimmed_fraction,
)
def standardized_mean_differences(X: Matrix, treatment: Vector, weights: Vector) -> Vector:
"""Balance check. Read this BEFORE the effect - if the covariates are not
balanced, the effect is measuring the imbalance."""
t = treatment == 1
diffs = np.average(X[t], axis=0, weights=weights[t]) - np.average(
X[~t], axis=0, weights=weights[~t]
)
pooled_sd = np.sqrt((X[t].var(axis=0) + X[~t].var(axis=0)) / 2.0)
return diffs / np.where(pooled_sd == 0, 1.0, pooled_sd)What changed: The estimate stops being a bare float and becomes a value object carrying the diagnostics that decide whether to believe it — max weight, effective sample size, trimmed fraction. Stabilized weights and explicit common-support trimming replace naive clipping, and a balance check is provided because the effect is meaningless until the covariates balance.
One vectorized logistic fit plus O(n) weighted reductions.
Faster, and the justification for each change.
"""IPTW - cross-fitted propensities and a bootstrap that reuses one draw matrix."""
import numpy as np
from numpy.typing import NDArray
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold
Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
def crossfit_propensity(X: Matrix, treatment: Vector, folds: int = 5) -> Vector:
"""Out-of-fold propensity scores.
A propensity fitted on the same rows it scores is overfitted toward the
observed assignment, which biases the weights toward 1 and understates the
correction. Cross-fitting removes that at the cost of one extra pass.
"""
scores = np.empty(X.shape[0], dtype=np.float64)
splitter = StratifiedKFold(n_splits=folds, shuffle=True, random_state=0)
for train_idx, test_idx in splitter.split(X, treatment):
model = LogisticRegression(max_iter=1_000)
model.fit(X[train_idx], treatment[train_idx])
scores[test_idx] = model.predict_proba(X[test_idx])[:, 1]
return scores
def bootstrap_ate(
scores: Vector,
treatment: Vector,
outcome: Vector,
draws: int = 1_000,
rng: np.random.Generator | None = None,
) -> tuple[float, float]:
"""Bootstrap CI over a resample index matrix rather than a Python loop.
Drawing all `draws` resamples at once turns 1000 sequential O(n) passes
into a handful of vectorized reductions over one (draws, n) matrix.
"""
generator = rng if rng is not None else np.random.default_rng(0)
n = outcome.shape[0]
marginal = treatment.mean()
weights = np.where(treatment == 1, marginal / scores, (1 - marginal) / (1 - scores))
# One (draws, n) index matrix; every resample is a row.
idx = generator.integers(0, n, size=(draws, n))
w = weights[idx]
t = treatment[idx]
y = outcome[idx]
# Masked weighted means, computed for all draws in two reductions.
wt = w * t
wc = w * (1.0 - t)
treated = (wt * y).sum(axis=1) / np.maximum(wt.sum(axis=1), 1e-12)
control = (wc * y).sum(axis=1) / np.maximum(wc.sum(axis=1), 1e-12)
effects = treated - control
return float(effects.mean()), float(effects.std(ddof=1))What changed: Two changes, both about correctness under speed. Propensities are cross-fitted, because a model scoring the rows it trained on produces weights biased toward no correction at all. And the bootstrap — which is the only honest way to get an interval that accounts for the weights being estimated — vectorizes over one resample-index matrix instead of looping.
Eliminate Python-level loops over samples
The bootstrap becomes reductions over a single (draws, n) matrix rather than `draws` sequential passes, which is the difference between seconds and minutes at 1000 draws.
Trade-off: Peak memory is O(draws · n); at a million rows and a thousand draws this will not fit, and the loop has to come back in chunks.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
The masked weighted means become two array reductions covering every bootstrap draw at once.
Trade-off: The masked form computes both arms over the full width including zeroed entries, so it does roughly twice the arithmetic of a compacted loop — still far faster because it is vectorized.
Batch work to amortize interpreter overhead
One generator call produces every resample index, avoiding 1000 separate RNG invocations.
Trade-off: All draws share one RNG state, so reproducing a single draw in isolation for debugging is no longer straightforward.
One cross-fitted logistic pass plus vectorized bootstrap. Illustrative, not a measured benchmark.