Skip to content

Commit 13df20f

Browse files
neuralsorcerermeta-codesync[bot]
authored andcommitted
Enforce the AIPW weight normalization contract (#551)
Summary: Pull Request resolved: #551 Differential Revision: D114518701 Pulled By: talgalili fbshipit-source-id: 57d5926d0eab905a50b6c07483d21893c1452f24
1 parent 9cd2168 commit 13df20f

6 files changed

Lines changed: 215 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
- **Breaking:** the previously-inert `predicted_outcome_columns` parameter (`Sample.from_frame` / `SampleFrame.from_frame` / `SampleFrame._create`) and the `SampleFrame.predicted_outcome_columns` property are renamed to `outcomes_hat_columns` (internal `_column_roles` key `"predicted"``"outcomes_hat"`). The old names are removed outright with no alias — the role was reserved scaffolding, never populated or consumed, so no migration is provided.
66

7+
- **Breaking:** `BalanceFrame.aipw()` now requires responder weights produced by
8+
`adjust()`, validates both weight vectors as non-empty, one-dimensional,
9+
finite, and non-negative with positive finite totals, and verifies that the
10+
responder total matches the target-weight total to a relative tolerance of
11+
`1e-6`. This enforces the estimator's same-population-scale normalization
12+
contract instead of accepting arbitrary, uncalibrated weights; call
13+
`adjust(...)` before `aipw()` and do not rescale its output.
14+
715
## New Features
816

917
- **Outcome-model input and replay validation is hardened.** `fit_outcome_model(..., sample_weight=...)` rejects incorrectly shaped arrays and zero, negative, NaN, infinite, non-real, or non-numeric weights with actionable `ValueError` messages; accepted weights must be one-dimensional, finite, strictly positive real numbers aligned to the covariates. Replaying categorical covariates now explicitly maps novel levels to missing before constructing the frozen categorical dtype, avoiding the pandas deprecation warning while preserving the existing novel-level behavior.
@@ -74,7 +82,7 @@
7482
scored.outcomes_hat().mean() # μ̂_OM on the holdout target via train_bf's fitted model
7583
```
7684

77-
- **`BalanceFrame.aipw()` — doubly-robust (AIPW) estimate `μ̂_DR`.** New `BalanceFrame.aipw()` (and, via the MRO, `Sample.aipw()`) returns the augmented / one-sample AIPW estimate of the target-population mean, per outcome column, combining the fitted outcome model `ĝ` with the balance weights `w`: `μ̂_DR = wmean(ĝ(X_T), w_T) + wmean(Y − ĝ(X_S), w)` (the augmentation runs over responders with an observed `Y`). It is **doubly robust** — consistent if *either* the outcome model *or* the weighting model is correct — and completes the estimator trio alongside `outcomes().mean()` (`μ̂_IPW`) and `outcomes_hat().mean()` (`μ̂_OM`); equivalently it is a GREG (model-assisted) estimator with the balance weights as the design weights. It requires a fitted outcome model (`fit_outcome_model(...)`) **and** a target (`set_target(...)`), accepts any balance weights, and **warns** when the responder weights are constant (no weighting fitted → `μ̂_DR` reduces to `μ̂_OM`). The point-estimate arithmetic lives in the pure `balance.outcome_models.aipw_point_estimate(...)`. **This is the point estimate only** — no confidence interval yet; an honest AIPW interval must jointly capture the weighting- and outcome-model uncertainty (see the TODOs in `balance/outcome_models/aipw.py`: cross-fitting, an analytic influence-function / sandwich SE, and an end-to-end joint bootstrap).
85+
- **`BalanceFrame.aipw()` — doubly-robust (AIPW) estimate `μ̂_DR`.** New `BalanceFrame.aipw()` (and, via the MRO, `Sample.aipw()`) returns the augmented / one-sample AIPW estimate of the target-population mean, per outcome column, combining the fitted outcome model `ĝ` with the balance weights `w`: `μ̂_DR = wmean(ĝ(X_T), w_T) + wmean(Y − ĝ(X_S), w)` (the augmentation runs over responders with an observed `Y`). It is **doubly robust** — consistent if *either* the outcome model *or* the weighting model is correct — and completes the estimator trio alongside `outcomes().mean()` (`μ̂_IPW`) and `outcomes_hat().mean()` (`μ̂_OM`); equivalently it is a GREG (model-assisted) estimator with the balance weights as the design weights. It requires a fitted outcome model (`fit_outcome_model(...)`), a target (`set_target(...)`), and `adjust()`-calibrated responder weights whose total matches the target-weight total within relative tolerance `1e-6`; this same-population-scale requirement makes the doubly-robust claim asymptotic under the Hájek normalization used by balance (ratio bias is `O(1/n)`). It **warns** when the calibrated responder weights are constant (`μ̂_DR` reduces to `μ̂_OM`). The point-estimate arithmetic lives in the pure `balance.outcome_models.aipw_point_estimate(...)`. **This is the point estimate only** — no confidence interval yet; an honest AIPW interval must jointly capture the weighting- and outcome-model uncertainty (see the TODOs in `balance/outcome_models/aipw.py`: cross-fitting, an analytic influence-function / sandwich SE, and an end-to-end joint bootstrap).
7886

7987
```python
8088
bf = sample.adjust(method="ipw").set_target(target) # or any balance weights

balance/balance_frame.py

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3540,9 +3540,12 @@ def aipw(self) -> pd.Series:
35403540
``outcomes().mean()`` gives ``μ̂_IPW`` and ``outcomes_hat().mean()`` gives
35413541
``μ̂_OM``; this gives ``μ̂_DR``.
35423542
3543-
Requires a fitted outcome model **and** a target (:meth:`set_target`).
3544-
Uses whatever weight columns are present (any balance weights). With
3545-
constant responder weights it warns and reduces to ``μ̂_OM``.
3543+
Requires a fitted outcome model, a target (:meth:`set_target`), and
3544+
responder weights produced by :meth:`adjust`. The adjusted responder
3545+
weights and target weights must have matching totals (within a relative
3546+
tolerance of ``1e-6``), ensuring that both terms use weights on the
3547+
same target-population scale. With constant responder weights it warns
3548+
and reduces to ``μ̂_OM``.
35463549
35473550
Point estimate only -- no confidence interval (an honest AIPW interval
35483551
must jointly capture the weighting- and outcome-model uncertainty; see
@@ -3553,7 +3556,9 @@ def aipw(self) -> pd.Series:
35533556
35543557
Raises:
35553558
ValueError: If no outcome model has been fit, if no target is set,
3556-
or if the responders carry no observed outcomes.
3559+
if the responders have not been adjusted, if the responder and
3560+
target weight totals are not on the same scale, or if the
3561+
responders carry no observed outcomes.
35573562
35583563
Examples:
35593564
>>> import pandas as pd
@@ -3569,12 +3574,15 @@ def aipw(self) -> pd.Series:
35693574
>>> tgt = SampleFrame.from_frame(
35703575
... pd.DataFrame({"id": [5, 6], "x": [15.0, 35.0],
35713576
... "weight": [1.0, 1.0]}))
3572-
>>> bf = BalanceFrame(sample=resp, target=tgt)
3577+
>>> bf = BalanceFrame(sample=resp, target=tgt).adjust(method="ipw")
35733578
>>> _ = bf.fit_outcome_model(model=LinearRegression())
35743579
>>> bf.aipw().index.tolist()
35753580
['y']
35763581
"""
3577-
from balance.outcome_models.aipw import aipw_point_estimate
3582+
from balance.outcome_models.aipw import (
3583+
_validate_aipw_weight_scale,
3584+
aipw_point_estimate,
3585+
)
35783586

35793587
model = self.outcome_model
35803588
if model is None:
@@ -3586,6 +3594,12 @@ def aipw(self) -> pd.Series:
35863594
raise ValueError(
35873595
"aipw() requires a target population; call set_target(...) first."
35883596
)
3597+
if not self.is_adjusted:
3598+
raise ValueError(
3599+
"aipw() requires adjust()-calibrated responder weights; call "
3600+
"adjust(...) before aipw() so responder and target weights are "
3601+
"on the same population scale."
3602+
)
35893603
observed_outcomes = self._outcome_columns
35903604
if observed_outcomes is None:
35913605
raise ValueError(
@@ -3595,24 +3609,25 @@ def aipw(self) -> pd.Series:
35953609

35963610
target = _assert_type(self._sf_target)
35973611
sample_weight = self.weight_series
3598-
if (
3599-
sample_weight is not None
3600-
and len(sample_weight) > 1
3601-
and sample_weight.nunique() == 1
3602-
):
3612+
target_weight = target.weight_series
3613+
if sample_weight is None or target_weight is None:
3614+
raise ValueError(
3615+
"aipw() requires responder and target weight columns on the same "
3616+
"population scale."
3617+
)
3618+
_validate_aipw_weight_scale(sample_weight, target_weight)
3619+
if len(sample_weight) > 1 and sample_weight.nunique() == 1:
36033620
logger.warning(
3604-
"aipw(): responder weights are constant -- it appears no "
3605-
"weighting model was fit (adjust() was not run, or it produced "
3606-
"uniform weights); the AIPW estimate reduces to the "
3607-
"outcome-model estimate mu_OM."
3621+
"aipw(): adjusted responder weights are constant; the AIPW "
3622+
"estimate reduces to the outcome-model estimate mu_OM."
36083623
)
36093624

36103625
estimates = aipw_point_estimate(
36113626
self._sf_sample.df_covars,
36123627
observed_outcomes,
36133628
sample_weight,
36143629
target.df_covars,
3615-
target.weight_series,
3630+
target_weight,
36163631
model,
36173632
)
36183633
return pd.Series(estimates, dtype=float)

balance/outcome_models/aipw.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,18 @@
3030
3131
This module provides the **point estimate only** (see the TODOs below for
3232
cross-fitting and honest variance/CI).
33+
34+
The public :meth:`balance.balance_frame.BalanceFrame.aipw` entry point enforces
35+
the estimator's normalization contract: responder weights must come from
36+
``adjust()`` and their total must match the target-weight total. Direct callers
37+
of :func:`aipw_point_estimate` are responsible for supplying weights on that
38+
same target-population scale.
3339
"""
3440

3541
from __future__ import annotations
3642

3743
import logging
44+
import math
3845
from typing import Any, Dict, List
3946

4047
import numpy as np
@@ -44,6 +51,8 @@
4451

4552
logger: logging.Logger = logging.getLogger(__package__)
4653

54+
_AIPW_WEIGHT_SUM_RTOL: float = 1e-6
55+
4756
# TODO (cross-fitting): the augmentation uses in-sample ĝ(X_S) — the model was
4857
# fit on these same responders — which is optimistic for flexible learners. Add
4958
# K-fold cross-fitted (out-of-fold) predictions for the residual term, and
@@ -68,6 +77,70 @@
6877
# harness; it is a prerequisite for a fully honest .summary() interval.
6978

7079

80+
def _validate_aipw_weight_scale(
81+
sample_weight: pd.Series | np.ndarray,
82+
target_weight: pd.Series | np.ndarray,
83+
) -> None:
84+
"""Validate the same-population-scale contract for public AIPW estimates.
85+
86+
Zero-valued row weights are valid (for example, uncovered cells can receive
87+
zero weight), but both vectors must be non-empty, one-dimensional, finite,
88+
non-negative, and have positive totals. Their totals must differ by less
89+
than the internal ``1e-6`` tolerance, relative to the target total.
90+
91+
Args:
92+
sample_weight: Adjusted responder weights.
93+
target_weight: Target design weights.
94+
95+
Raises:
96+
ValueError: If either vector or the relationship between their totals
97+
violates the AIPW normalization contract.
98+
"""
99+
100+
arrays: dict[str, np.ndarray] = {}
101+
for name, weight in (
102+
("responder", sample_weight),
103+
("target", target_weight),
104+
):
105+
try:
106+
array = np.asarray(weight, dtype=float)
107+
except (TypeError, ValueError) as exc:
108+
raise ValueError(f"aipw() requires numeric {name} weights.") from exc
109+
if array.ndim != 1 or array.size == 0:
110+
raise ValueError(
111+
f"aipw() requires a non-empty, one-dimensional {name} weight vector."
112+
)
113+
if not np.isfinite(array).all():
114+
raise ValueError(f"aipw() requires finite {name} weights.")
115+
if (array < 0).any():
116+
raise ValueError(f"aipw() requires non-negative {name} weights.")
117+
arrays[name] = array
118+
119+
try:
120+
sample_weight_total = math.fsum(arrays["responder"])
121+
target_weight_total = math.fsum(arrays["target"])
122+
except OverflowError as exc:
123+
raise ValueError(
124+
"aipw() requires finite responder and target weight totals."
125+
) from exc
126+
if not math.isfinite(sample_weight_total) or not math.isfinite(target_weight_total):
127+
raise ValueError("aipw() requires finite responder and target weight totals.")
128+
if sample_weight_total <= 0 or target_weight_total <= 0:
129+
raise ValueError("aipw() requires positive responder and target weight totals.")
130+
131+
relative_difference = (
132+
abs(sample_weight_total - target_weight_total) / target_weight_total
133+
)
134+
if relative_difference >= _AIPW_WEIGHT_SUM_RTOL:
135+
raise ValueError(
136+
"aipw() requires adjust()-calibrated responder and target weights "
137+
"on the same population scale: the relative weight-total "
138+
f"difference is {relative_difference:.6g}, which must be less than "
139+
f"{_AIPW_WEIGHT_SUM_RTOL:g}. Re-run adjust(...) without changing "
140+
"the resulting weights."
141+
)
142+
143+
71144
def aipw_point_estimate(
72145
sample_covars: pd.DataFrame,
73146
outcomes: pd.DataFrame,
@@ -91,7 +164,8 @@ def aipw_point_estimate(
91164
``model["outcome_columns"]``. ``NaN`` rows are dropped from the
92165
residual term (weights realigned), matching ``fit_outcome_model``.
93166
sample_weight: Responder (balance) weights ``w``, or ``None`` for an
94-
unweighted augmentation.
167+
unweighted augmentation. Direct callers must ensure that these are
168+
on the same population scale as ``target_weight``.
95169
target_covars: Target covariates ``X_T``.
96170
target_weight: Target weights ``w_T``, or ``None`` for a simple mean.
97171
model: A fitted model dict from :func:`fit_outcome_model`.

docs/architecture/architecture_0_23_0.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
| Date | Decision | Rationale |
2020
|------|----------|-----------|
21+
| 2026-08-01 | **AIPW normalization contract: implemented.** The augmentation is valid only when `w_R` and `w_T` are on the same population scale; Hájek self-normalization gives *asymptotic* DR (ratio bias `O(1/n)`). `aipw()` requires valid, non-negative `adjust()`-calibrated responder weights and asserts `|Σw_R−Σw_T|/Σw_T < 1e-6`. | Enforces the correctness precondition at the estimator boundary and reports an actionable error for malformed, arbitrary, or subsequently rescaled weights. |
2122
| 2026-07-15 | **`outcomes_hat` is the canonical spelling everywhere — public and internal — as a clean rename (no alias, no `FutureWarning`, no migration).** Public: `outcomes_hat()`, `df_outcomes_hat`, `outcomes_hat_columns`. Internal: `_column_roles` key `"predicted"` → `"outcomes_hat"`, the `_create()`/`from_frame()` parameter `predicted_outcome_columns` → `outcomes_hat_columns`, internal locals (`predicted_list` → `outcomes_hat_list`, etc.), the overlap-validation dict key, and the protocol member `_outcomes_hat_columns`. The old `predicted_outcome_columns` param/property and the `"predicted"` role key are **removed outright**. | One vocabulary end-to-end; removes the overload with predicted-*weights* (`predict_weights`); distinct from `.outcomes()` (observed Y). No deprecation needed — the `predicted` role was reserved scaffolding **unused by any code or user** (only storage/validation/tests referenced it) and was never populated, so there is nothing to keep back-compatible and nothing to migrate. |
2223
| 2026-07-15 | **Scope: outcome model now, AIPW later.** Ship IPW/Hájek (exists) + outcome-model / g-computation estimate (`μ̂_OM`) in 0.23.0; defer explicit AIPW/DR to a follow-up. Phase 1 delivers **no general DR** — only the linear-WLS special case (§1). | Smaller, reviewable diff stack that delivers the core ask (average `outcomes_hat` on the target). AIPW variance/CI + the normalization contract get their own design pass. (superseded 2026-07-18: explicit AIPW shipped as `bf.aipw()` — general for any learner, not only the linear-WLS special case; only AIPW variance/CI + cross-fitting remain deferred.) |
2324
| 2026-07-15 | **Estimate lives on the `outcomes_hat()` view:** `μ̂_OM = bf.outcomes_hat().mean()` (target row). Not under `.outcomes()`, no separate `outcome_estimate(method=)` dispatcher for phase 1. | Keeps the estimate where the predicted outcomes live; reuses the existing weighted-mean/CI machinery for free. |
@@ -813,7 +814,7 @@ absolute GitHub URLs; the tutorial notebook (created in diff 7, extended in 8–
813814
end-to-end in CI.
814815

815816
**Phase 2 (separate later stack):**
816-
- **`[balance] Add doubly-robust AIPW estimate bf.aipw() + R oracles`** (D112679814) — **shipped** the AIPW point estimate (combine `outcomes_hat` + IPW weights). Still deferred on top of it: the same-scale `w_R`/`w_T` normalization **assert**, **cross-fitted** (out-of-fold) responder residuals, and AIPW variance/CI.
817+
- **`[balance] Add doubly-robust AIPW estimate bf.aipw() + R oracles`** (D112679814) — **shipped** the AIPW point estimate (combine `outcomes_hat` + IPW weights) and its same-scale `w_R`/`w_T` normalization assertion. Still deferred on top of it: **cross-fitted** (out-of-fold) responder residuals and AIPW variance/CI.
817818
- Optional follow-ons: weighted/Bayesian bootstrap; BCa intervals.
818819

819820
---
@@ -845,7 +846,7 @@ guard; CLI out-of-scope; expanded tests/errors/docs (§12).
845846
default — `summary()` reports the fit-weights and *scopes* any DR statement to them; `outcomes_hat`
846847
columns use the `<outcome>_hat` convention + a `from_frame` leak-warning; `mean_with_ci` defaults to
847848
bootstrap and **raises on a lone/target-less view**; the AIPW normalization contract (same-scale
848-
weights; asymptotic Hájek DR) is documented for phase 2; and lifecycle fixes — deepcopy preserves
849+
weights; asymptotic Hájek DR) is enforced; and lifecycle fixes — deepcopy preserves
849850
the model (**reference-sharing** the estimator), `set_target` preserves it, re-fit drops stale Ŷ,
850851
and the bootstrap refits with the stored fit-weighting. Plus coherence fixes: Hájek sweep, demoted
851852
the special case, marked `mean_with_ci` overridden, disambiguated `w_R^fit` vs `w_R`, and added a
@@ -863,9 +864,9 @@ Deferred to implementation / later phases (not blocking this design):
863864
- **AIPW / doubly-robust** — the point estimate has **shipped** as `bf.aipw()` (BalanceFrame/Sample;
864865
pure `aipw_point_estimate`), general for any learner; only its **variance/CI** and **cross-fitting**
865866
remain deferred (each needs its own design).
866-
- **AIPW normalization contract****not yet enforced**: the shipped `aipw()` uses the present
867-
balance weights and only warns on constant responder weights; the same-scale `w_R`/`w_T`
868-
requirement + the `|Σw_R−Σw_T|/Σw_T < tol` tolerance assert remain a TODO (treat DR as asymptotic, Hájek `O(1/n)`).
867+
- **AIPW normalization contract****implemented**: `aipw()` requires an adjusted frame and
868+
rejects responder/target weight totals when `|Σw_R−Σw_T|/Σw_T >= 1e-6`, enforcing same-scale
869+
`w_R`/`w_T` (with DR asymptotic under Hájek normalization and ratio bias `O(1/n)`).
869870
- **Weighted / Bayesian bootstrap** — a robustness upgrade over the nonparametric bootstrap.
870871
- **BCa intervals** — an accuracy upgrade over percentile CIs if warranted.
871872
- **Cross-fitting** — to reduce own-observation bias when the same responders fit `ĝ` and enter

0 commit comments

Comments
 (0)