Back to Classical ML
AI/ML · Classical ML

Propensity Scores & IPTW

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.

SupervisedClassificationRegression

Supervised in mechanism only. The fitted model predicts treatment assignment, which is a means to an end — the estimand is an effect, and a propensity model with suspiciously good accuracy is a warning sign, not a success.

Also known as: Inverse probability of treatment weighting, Propensity score matching

Intuition

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.

Assumptions it makes
  • 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
Objective

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
Optimization

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.

Hyperparameters
  • 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])

Convergence & cost

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.

Applications

How, where, and why it is used

Time-Series Forecasting

Not applicable

The 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 applicable

There 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

Viable
How

Supplies 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.

Where
  • Marketing budget allocation across segments with heterogeneous response
  • Deciding which patients to enrol in a costly intervention under a capacity constraint
Why

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.

Featurization
  • 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
Evaluation

Qini and uplift curves against a randomized holdout, comparing the policy against uniform allocation rather than against no allocation.

Pitfalls
  • 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
Also applied in

Causal Inference & Uplift

Primary fit
How

The 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.

Where
  • 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
Why

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.

Featurization
  • 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
Evaluation

Weighted covariate balance first, before any effect is read. Then sensitivity analysis for unmeasured confounding, and negative-control outcomes that should show no effect.

Pitfalls
  • 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

Adapted
How

Estimates 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.

Where
  • Measuring whether a fraud-review queue reduced losses or just delayed transactions
  • Evaluating a rule change that was rolled out without a control group
Why

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.

Featurization
  • 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
Evaluation

Balance on the pre-decision risk distribution, plus a negative-control period where no intervention occurred.

Pitfalls
  • 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
Deployment

What it costs to run

Training

Seconds. The propensity model is a logistic regression on a modest covariate set.

Inference

Rarely "deployed" in the serving sense — this runs as an offline analysis producing an estimate and an interval, not a per-request prediction.

Retraining

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.

Drift & monitoring
  • 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
Production gotchas
  • 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
Trade-offs

Pros and cons, in context

Strengths
  • 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.

Limitations
  • 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.

Implementation

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.

1. Intuitive

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_den

O(n·d) per epoch in pure Python. Correct, and it silently hides the weight distribution.

2. Idiomaticscikit-learn / NumPy

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.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionals

One vectorized logistic fit plus O(n) weighted reductions.

3. OptimizedNumPy / scikit-learn

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.