diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d243d81..400da3db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 change; the machine-precision hetero/cluster lock is deferred — needs an unbalanced-DGP golden). ### Added +- **`ImputationDiD` leave-one-out conservative variance** (`leave_one_out`, default `False`) — the + Borusyak-Jaravel-Spiess (2024) Supplementary Appendix A.9 finite-sample refinement. The non-LOO + auxiliary aggregate `tau_tilde_g` (eq. 8) is built from the fitted `tau_hat_it` and so partially + overfits to the noise `epsilon_it`, biasing the conservative variance downward; `leave_one_out=True` + recomputes each unit's group aggregate excluding that unit (implemented efficiently by rescaling each + treated auxiliary residual by `1/(1 - v_ig**2/sum_j v_jg**2)`, exactly equivalent to the direct + leave-one-out at the per-unit cluster sum), yielding a larger, less-downward-biased SE (Prop. A8: + unbiased for an upper bound at the default unit clustering). Point estimates are unchanged. A group + with a single positive-weight unit (LOO undefined, App. A.9 fn. 51) keeps the non-LOO residual with a + `UserWarning`. `leave_one_out` is recorded on `ImputationDiDResults` (and `to_dict()` / `summary()`); + replicate-weight survey designs raise `NotImplementedError` (their variance bypasses the + influence-function path). Default `False` preserves R `didimputation` parity. - **`ContinuousDiD` lowest-dose-as-control** (`control_group="lowest_dose"`, CGBS 2024 Remark 3.1) for settings with no untreated group (`P(D=0) = 0`): the lowest-dose group `d_L` becomes the comparison and the estimand is `ATT(d) − ATT(d_L)` (with `ATT(d_L) = 0` the omitted reference). It is a diff --git a/METHODOLOGY_REVIEW.md b/METHODOLOGY_REVIEW.md index 0dc56f17..998adabc 100644 --- a/METHODOLOGY_REVIEW.md +++ b/METHODOLOGY_REVIEW.md @@ -561,7 +561,7 @@ and covariate-adjusted specifications.) - Multiplier bootstrap on the Theorem-3 influence function (library extension, not in the paper). - Survey-design TSL variance on the influence function (library extension). - NaN inference for undefined statistics; Proposition-5 refuse-to-estimate (NaN + warning). -- Leave-one-out variance refinement (Supplementary Appendix A.9) not implemented (finite-sample refinement; tracked as future work). +- Leave-one-out variance refinement (Supplementary Appendix A.9) implemented as the opt-in `leave_one_out` parameter (default `False`, preserving R `didimputation` parity); efficient residual rescale `1/(1 - v_ig^2/sum_j v_jg^2)` exactly equivalent to direct leave-one-out (see REGISTRY ImputationDiD Note). **R Comparison Results** (`didimputation` v0.5.0, fixed-seed panel, `benchmarks/data/didimputation_golden.json`): diff --git a/TODO.md b/TODO.md index 6ca2f22d..17291ace 100644 --- a/TODO.md +++ b/TODO.md @@ -30,7 +30,6 @@ The `Origin` column (Actionable tables) and the `PR` column (Deferred tables) bo |-------|----------|--------|--------|----------| | `SyntheticControl` conformal (CWZ 2021) extensions: (a) one-sided / signed-`t` variants (§7); (b) covariates in the conformal proxy (`X_jt`, eqs 4/6 — current proxy is outcomes-only); (c) AR / innovation-permutation path (Lemmas 5-7) for time-series proxies. The joint test, pointwise CIs, and average-effect CI have landed. | `conformal.py`, `synthetic_control_results.py` | CWZ-2021 | Heavy | Low | | `ContinuousDiD` CGBS-2024 extensions. (a) `covariates=` kwarg — **DONE (reg/dr)**; (b) discrete-treatment saturated regression (`treatment_type="discrete"`) — **DONE**; (c) lowest-dose-as-control per Remark 3.1 when `P(D=0)=0` (`control_group="lowest_dose"`) — **DONE** (discrete + continuous mass-point, single-cohort; estimand `ATT(d)−ATT(d_L)`; see REGISTRY Note #7). Remaining (all deferred `NotImplementedError`, documented): `estimation_method="ipw"` on the dose curve (scalar-adjustment / degenerate); `covariates=` × `survey_design=` (weighted OR + weighted nuisance IF); multi-cohort **heterogeneous-support** discrete aggregation (support-aware: average each dose only over the cohorts that observe it); **multi-cohort `lowest_dose`** (within-cohort `d_L` reference + support-aware cross-cohort aggregation); and **`covariates=` × `lowest_dose`** (conditional-PT-relative-to-`d_L` estimand). Single-cohort / 2-period / shared-support multi-cohort are supported. | `continuous_did.py` | CGBS-2024 | Heavy | Low | -| `ImputationDiD` LOO conservative-variance refinement (BJS 2024 Supp. Appendix A.9) — a finite-sample improvement to the auxiliary-model residuals reducing overfit of `tau_tilde_g` to `epsilon`. Asymptotic Theorem-3 variance is implemented and matches R `didimputation` (which also omits LOO by default). | `imputation.py` | imputation-validation | Mid | Low | | `TwoWayFixedEffects(vcov_type in {hc2, hc2_bm})` with replicate-weight designs raises `NotImplementedError` (`twfe.py:~233`). The replicate path re-demeans per replicate, which doesn't compose with the full-dummy HC2/HC2-BM build — a correct impl needs per-replicate full-dummy refit. Workaround: `hc1` for replicate-weight CR1. | `twfe.py::fit` | follow-up | Heavy | Low | | `CallawaySantAnna` no-covariate ipw treats the propensity as unconditional (no correction term); R's `did` fits an intercept-only logit whose estimation effect is identically zero in the IF, so values match — decide whether to document-only (current REGISTRY note) or mirror R's code path for structural parity. | `staggered.py::_ipw_estimation` (no-cov branch) | CS-scaling | Quick | Low | | Fold the R `did` 2.5.1 ipw aggregation yardsticks (hardcoded with provenance in `test_golden_ipw_aggregation_se_vs_r_did_251`) into `csdid_golden_values.json` on the next fixture regeneration — the generator already emits the ipw `aggte` blocks; switch the test to read the JSON. | `benchmarks/R/generate_csdid_test_values.R`, `tests/test_csdid_ported.py` | CS-scaling | Quick | Low | diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 3a09b257..1f042836 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -392,6 +392,7 @@ ImputationDiD( horizon_max: int | None = None, # Max event-study horizon aux_partition: str = "cohort_horizon", # "cohort_horizon", "cohort", or "horizon" pretrends: bool = False, # Include pre-treatment horizons in event study + leave_one_out: bool = False, # BJS 2024 App. A.9 leave-one-out finite-sample variance (larger, less-downward-biased SE) ) ``` diff --git a/diff_diff/imputation.py b/diff_diff/imputation.py index 9e703073..9717378a 100644 --- a/diff_diff/imputation.py +++ b/diff_diff/imputation.py @@ -127,6 +127,27 @@ class ImputationDiD(ImputationDiDBootstrapMixin): pre-trends assessment. Pre-period effects should be ~0 under parallel trends. Only affects event_study aggregation; overall ATT and group aggregation are unchanged. + leave_one_out : bool, default=False + If True, apply the Borusyak-Jaravel-Spiess (2024) Supplementary + Appendix A.9 leave-one-out finite-sample refinement to the + conservative variance. The non-LOO auxiliary aggregate ``tau_tilde_g`` + is built from the fitted ``tau_hat_it`` and thus partially overfits to + the noise ``epsilon_it``, biasing the variance downward. LOO recomputes + each unit's group aggregate excluding that unit -- implemented + efficiently by rescaling each treated auxiliary residual by + ``1 / (1 - v_ig**2 / sum_j v_jg**2)`` (App. A.9), which is exactly + equivalent to the direct leave-one-out at the per-unit cluster sum. + Yields a larger, less-downward-biased SE (Prop. A8: unbiased for an + upper bound). Default False preserves R ``didimputation`` parity; the + refinement is an option in the authors' Stata ``did_imputation``. LOO + is undefined for a group with a single positive-weight unit (App. A.9 + footnote 51): such groups fall back to the non-LOO residual with a + UserWarning. The Prop. A8 direction (LOO >= non-LOO) is guaranteed at + the default unit clustering; coarser ``cluster=`` / analytical + ``survey_design=`` / ``n_bootstrap`` compositions apply the same rescale + but are a library extension beyond the paper's derivation. + Replicate-weight survey designs raise ``NotImplementedError`` (their + variance bypasses the influence-function path where the rescale lives). Attributes ---------- @@ -182,6 +203,7 @@ def __init__( horizon_max: Optional[int] = None, aux_partition: str = "cohort_horizon", pretrends: bool = False, + leave_one_out: bool = False, ): if rank_deficient_action not in ("warn", "error", "silent"): raise ValueError( @@ -199,6 +221,7 @@ def __init__( f"got '{aux_partition}'" ) self._validate_vcov_type(vcov_type) + self._validate_leave_one_out(leave_one_out) self.anticipation = anticipation self.alpha = alpha @@ -211,6 +234,7 @@ def __init__( self.horizon_max = horizon_max self.aux_partition = aux_partition self.pretrends = pretrends + self.leave_one_out = leave_one_out self.is_fitted_ = False self.results_: Optional[ImputationDiDResults] = None @@ -275,6 +299,7 @@ def fit( # mutations (e.g. set_params(vcov_type="classical")) are re-checked # at use rather than silently accepted by the parameter setter. self._validate_vcov_type(self.vcov_type) + self._validate_leave_one_out(self.leave_one_out) # Validate inputs required_cols = [outcome, unit, time, first_treat] @@ -345,6 +370,22 @@ def fit( "the design structure implicitly) or use a non-replicate " "survey design (with explicit strata/psu/fpc)." ) + # Reject replicate-weight + leave_one_out=: the BJS 2024 App. A.9 + # refinement rescales the conservative influence-function auxiliary + # residuals, but replicate-weight variance is computed by per-replicate + # point-estimate refits (not the IF path), so leave_one_out would + # silently have no effect. Fail-closed (no-silent-failures). + if _uses_replicate_imp and self.leave_one_out: + raise NotImplementedError( + "ImputationDiD(leave_one_out=True) is not supported with " + "replicate-weight survey designs. The leave-one-out refinement " + "(Borusyak, Jaravel & Spiess 2024, Supp. App. A.9) rescales the " + "conservative influence-function residuals, but replicate-weight " + "variance is computed by per-replicate refits and does not use " + "that path — leave_one_out would silently have no effect. Use a " + "non-replicate (Taylor-linearization) survey design, or " + "leave_one_out=False." + ) # Validate within-unit constancy for panel survey designs if resolved_survey is not None: _validate_unit_constant_survey(data, unit, survey_design) @@ -968,6 +1009,7 @@ def _refit_imp(w_r): vcov_type=self.vcov_type, cluster_name=_cluster_name_for_results, n_clusters=_n_clusters_for_results, + leave_one_out=self.leave_one_out, ) self.is_fitted_ = True @@ -1683,6 +1725,8 @@ def _compute_auxiliary_residuals_treated( # _compute_target_weights) poison its whole group via 0 * NaN = NaN. The # previous observation-level pandas sum relied on skipna to drop them. contrib = (v_treated != 0.0) & np.isfinite(tau_hat) + loo_factor: Optional[pd.Series] = None + n_single_loo = 0 if contrib.any(): inner = pd.DataFrame( { @@ -1702,6 +1746,12 @@ def _compute_auxiliary_residuals_treated( ) den_ok = per_group["den"].abs() >= 1e-15 tau_tilde_map = (per_group["num"] / per_group["den"]).where(den_ok) + # BJS 2024 App. A.9 leave-one-out refinement: rescale each treated + # residual by 1/(1 - v_ig^2 / sum_j v_jg^2) (== the direct-LOO tau_tilde + # exactly, at the per-unit cluster sum). Reuses a_{i,g} = per_unit['a'] + # and sum_j v_jg^2 = per_group['den']; applied to epsilon_treated below. + if self.leave_one_out: + loo_factor, n_single_loo = self._leave_one_out_factor(per_unit, per_group) else: tau_tilde_map = pd.Series(dtype=float) @@ -1720,6 +1770,28 @@ def _compute_auxiliary_residuals_treated( # Auxiliary residuals epsilon_treated = tau_hat - tau_tilde + # Leave-one-out rescale (BJS 2024 App. A.9): map each treated obs to its + # (group, unit) factor and inflate the residual. Non-contributing rows + # (v_it == 0, psi == 0 anyway) and single-positive-weight-unit groups + # (LOO undefined, fn. 51) keep factor 1.0. + if self.leave_one_out and loo_factor is not None: + obs_index = pd.MultiIndex.from_arrays( + [group_codes, df_1[unit].values], names=["g", "u"] + ) + factor_per_obs = loo_factor.reindex(obs_index).to_numpy(dtype=float) + factor_per_obs = np.where(np.isfinite(factor_per_obs), factor_per_obs, 1.0) + epsilon_treated = epsilon_treated * factor_per_obs + if n_single_loo > 0: + warnings.warn( + f"leave_one_out=True: {n_single_loo} auxiliary group(s) have a single " + f"positive-weight unit, where the leave-one-out variance is undefined " + f"(Borusyak, Jaravel & Spiess 2024, Supp. App. A.9 fn. 51); those groups " + f"keep the non-leave-out residual. A coarser aux_partition reduces " + f"singleton groups.", + UserWarning, + stacklevel=2, + ) + return epsilon_treated def _compute_residuals_untreated( @@ -2496,6 +2568,7 @@ def get_params(self) -> Dict[str, Any]: "horizon_max": self.horizon_max, "aux_partition": self.aux_partition, "pretrends": self.pretrends, + "leave_one_out": self.leave_one_out, } def set_params(self, **params) -> "ImputationDiD": @@ -2507,6 +2580,75 @@ def set_params(self, **params) -> "ImputationDiD": raise ValueError(f"Unknown parameter: {key}") return self + @staticmethod + def _validate_leave_one_out(leave_one_out: Any) -> None: + """Validate ``leave_one_out`` is a strict bool. + + Called from ``__init__`` AND ``fit()`` so sklearn-style + ``set_params(leave_one_out=...)`` mutations are re-checked at use + time -- the naive ``set_params`` setter would otherwise accept a + truthy string (e.g. "yes") and silently run the LOO refinement. + """ + if not isinstance(leave_one_out, bool): + raise TypeError(f"leave_one_out must be a bool, got {type(leave_one_out).__name__}") + + @staticmethod + def _leave_one_out_factor( + per_unit: pd.DataFrame, per_group: pd.DataFrame + ) -> Tuple[pd.Series, int]: + """Per-(group, unit) leave-one-out residual-rescale factor (BJS 2024 A.9). + + ``factor_{g,i} = 1 / (1 - v_ig**2 / sum_j v_jg**2)`` with + ``v_ig = per_unit['a']`` and ``sum_j v_jg**2 = per_group['den']``. This + rescale of ``epsilon_tilde_it`` reproduces the direct leave-one-out + aggregate ``tau_tilde_it^LO`` exactly at the per-unit cluster sum + ``psi_i = sum_t v_it * epsilon_tilde_it`` (App. A.9). A group with a + single positive-weight unit has ``v_ig**2 == sum_j v_jg**2`` so the + factor diverges (LOO undefined, App. A.9 fn. 51); those groups fall back + to ``1.0`` (non-LOO). A genuinely unit-dominated but >=2-unit group keeps + its large finite factor -- that is the paper's intended inflation. + + Returns + ------- + (factor : pd.Series indexed like ``per_unit`` (g, u), n_single_unit_groups : int) + """ + a = per_unit["a"].to_numpy(dtype=float) + sq = a**2 + g_level = per_unit.index.get_level_values("g") + u_level = per_unit.index.get_level_values("u") + den = per_group["den"].reindex(g_level).to_numpy(dtype=float) # D_g per (g,u) + + # A group is "singleton" for LOO (App. A.9 fn. 51) when fewer than two + # units carry positive squared weight -- covers a true 1-unit group AND + # the effective-singleton case (>=2 rows, only one with a_ig != 0). + pos_per_group = pd.Series(sq > 0.0, index=per_unit.index).groupby(level="g").sum() + single_groups = pos_per_group.index[pos_per_group < 2] + is_single = pd.Index(g_level).isin(single_groups) + + den_ok = np.abs(den) >= 1e-15 + # factor = D_g / (D_g - v_ig^2) = D_g / sum_{j!=i} v_jg^2. Compute the + # leave-one-out denominator as the sum of the OTHER units' squared + # weights -- NOT as D_g - v_ig^2 after forming the ratio: for a genuinely + # dominated (but >=2-unit) group the subtraction loses precision (and can + # cancel to 0/negative) in float64 -- a finite-but-wrong or silently + # non-LOO factor. The fast subtraction is accurate away from the + # near-cancellation boundary; wherever the leave-one-out mass is a tiny + # fraction of D (relative loss of >~1e-6), recompute it exactly as the + # drop-then-sum of the OTHER units' squared weights. At most one unit per + # group can be that dominant, so the recompute stays O(units). + other_mass = den - sq + suspect = (~is_single) & den_ok & (other_mass <= 1e-6 * den) + if suspect.any(): + sq_series = pd.Series(sq, index=per_unit.index) + for pos in np.nonzero(suspect)[0]: + grp = sq_series.xs(g_level[pos], level="g") + other_mass[pos] = float(grp.drop(u_level[pos]).sum()) + # Fall back to non-LOO (factor 1.0) only where LOO is genuinely undefined: + # a singleton group (fn. 51), a degenerate den, or no other positive mass. + fallback = is_single | ~den_ok | (other_mass <= 0.0) + factor = np.where(fallback, 1.0, den / np.where(fallback, 1.0, other_mass)) + return pd.Series(factor, index=per_unit.index), int(len(single_groups)) + @staticmethod def _validate_vcov_type(vcov_type: str) -> None: """Validate ``vcov_type`` membership against ImputationDiD's diff --git a/diff_diff/imputation_results.py b/diff_diff/imputation_results.py index 38f2884d..762619d7 100644 --- a/diff_diff/imputation_results.py +++ b/diff_diff/imputation_results.py @@ -150,6 +150,9 @@ class ImputationDiDResults: vcov_type: str = field(default="hc1") cluster_name: Optional[str] = field(default=None) n_clusters: Optional[int] = field(default=None) + # BJS 2024 Supp. App. A.9 leave-one-out finite-sample variance refinement + # (opt-in). Recorded here so reported SEs are self-describing. + leave_one_out: bool = field(default=False) # --- Inference-field aliases (balance/external-adapter compatibility) --- @property @@ -260,6 +263,8 @@ def summary(self, alpha: Optional[float] = None) -> str: lines.append(f"{'Variance estimator:':<30} {vcov_label:>15}") if self.n_clusters is not None and self.bootstrap_results is None: lines.append(f"{'Number of clusters:':<30} {self.n_clusters:>15}") + if self.leave_one_out: + lines.append(f"{'Leave-one-out variance:':<30} {'A.9 (BJS 2024)':>15}") lines.append("") @@ -504,6 +509,7 @@ def to_dict(self) -> Dict[str, Any]: "alpha": self.alpha, "anticipation": self.anticipation, "vcov_type": self.vcov_type, + "leave_one_out": self.leave_one_out, } if self.cluster_name is not None: result["cluster_name"] = self.cluster_name diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index d216ac96..393cbe01 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -1578,6 +1578,7 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only. - **Bootstrap inference:** Uses multiplier bootstrap on the Theorem 3 influence function: `psi_i = sum_t v_it * epsilon_tilde_it`. Cluster-level psi sums are pre-computed for each aggregation target (overall, per-horizon, per-group), then perturbed with multiplier weights (Rademacher by default; configurable via `bootstrap_weights` parameter to use Mammen or Webb weights, matching CallawaySantAnna). This is a library extension (not in the paper) consistent with CallawaySantAnna/SunAbraham bootstrap patterns. - **Auxiliary residuals (Equation 8):** Implements the paper's *unit-clustered* Equation 8 aggregator, `tau_tilde_g = sum_i (sum_{t in G_g,i} v_it)(sum_{t in G_g,i} v_it * tau_hat_it) / sum_i (sum_{t in G_g,i} v_it)^2` (Borusyak-Jaravel-Spiess 2024, eq. 8, p. 3272; minimal-excess-variance derivation in Supplementary Appendix A.8): for each unit form the within-unit weight sum `a_{i,g}` and weighted-effect sum `b_{i,g}` over the unit's observations in group `g`, then combine across units. Groups partition `Omega_1` via `aux_partition` (default `"cohort_horizon"` = cohort × event-time; also `"cohort"` / `"horizon"`). Unimputable (NaN `tau_hat`) and off-target observations carry `v_it = 0` and are excluded from the aggregation — exact for finite `tau_hat` (a zero-weight row adds 0 to both `a` and `b`) and NaN-safe; a group with no contributing observations falls back to the unweighted group mean (a variance no-op, since `psi_g = sum_t v_it * eps_tilde_it = 0` there). - **Note (deviation from R):** R `didimputation::did_imputation` computes the auxiliary aggregator as `sum(v_it^2 * tau_hat_it) / sum(v_it^2)` grouped by cohort × event-time only (no partition control is exposed). At that partition each unit contributes at most one observation per group, so the paper's unit-clustered Equation 8 reduces exactly to `sum(v^2 * tau)/sum(v^2)` — i.e. diff-diff matches R at the default `aux_partition="cohort_horizon"` (pinned in `tests/test_methodology_imputation.py::TestImputationDiDParityR`). diff-diff additionally offers the coarser `aux_partition="cohort"` / `"horizon"` groupings (where a unit may contribute several observations to a group), which have no R analogue and are validated by hand-calculation. Both implement the same paper Equation 8; only the available partition granularity differs. The earlier observation-level mean `sum(v*tau)/sum(v)` (pre-3.5.x) coincided with this only under uniform within-group weights; it was corrected to the exact unit-clustered form during the ImputationDiD methodology validation. +- **Note (leave-one-out variance refinement, Supp. App. A.9):** the opt-in `leave_one_out=True` applies the Borusyak-Jaravel-Spiess (2024) Supplementary Appendix A.9 finite-sample refinement. The non-LOO `tau_tilde_g` (eq. 8) is built from the fitted `tau_hat_it`, which contain the noise `epsilon_it`, so it partially overfits and the auxiliary residuals `epsilon_tilde_it` are too small, biasing the variance **downward**. LOO recomputes each unit's group aggregate excluding that unit, `tau_tilde_it^LO = sum_{j!=i} v_jg^2 T_jg / sum_{j!=i} v_jg^2`, implemented efficiently (A.9) by rescaling each treated residual `epsilon_tilde_it^LO = epsilon_tilde_it / (1 - v_ig^2 / sum_j v_jg^2)` — where `v_ig = sum_{t in G_g,i} v_it` and `sum_j v_jg^2` are already materialized in `_compute_auxiliary_residuals_treated` as `a_{i,g}` and `per_group['den']`. That rescale reproduces the direct-LOO per-unit cluster sum `psi_i = sum_t v_it * epsilon_tilde_it` **exactly** (`psi_i^rescale = a_{i,g}(T_ig - tau_tilde_g) D/(D - a^2) = psi_i^direct-LOO`; a machine-precision-verified identity — **paper-fidelity** to the source `tau_tilde_it^LO`, not merely internal consistency). At the **default unit clustering** LOO gives a larger, less-downward-biased SE (Prop. A8: unbiased for an upper bound on the true variance; an equal-weight K-unit group inflates residuals by exactly `K/(K-1)`). **Default `leave_one_out=False`** preserves R `didimputation` parity (which omits LOO). **Edge (App. A.9 fn. 51):** a group with a single positive-weight unit has an undefined LOO (rescale factor `1/0`); such groups keep the non-LOO residual and the fit emits one consolidated `UserWarning` (a coarser `aux_partition` reduces singletons); a genuinely unit-dominated `>=2`-unit group keeps its large finite factor (intended inflation). **Composition scope:** the rescale operates on `epsilon_tilde`, so it flows through the coarser-`cluster=` CR sum, the analytical survey PSU-TSL variance, and the multiplier bootstrap unchanged — but Prop. A8's upper-bound guarantee and the `LOO >= non-LOO` direction hold at the default unit clustering only (under a coarser `cluster=`, per-unit `psi` inflation can partially cancel), so those compositions are a documented library extension, not paper-derived. **Replicate-weight survey designs** (BRR / Fay / JK1 / JKn / SDR) raise `NotImplementedError` with `leave_one_out=True`: replicate variance is computed by per-replicate point-estimate refits, bypassing the conservative-IF residual path where the LOO rescale lives, so LOO would silently no-op (fail-closed, no-silent-failures). **Effective-singleton guard:** a group's singleton test counts units with *positive* squared weight (not raw rows), and the leave-one-out denominator is the exact sum of the *other* units' squared weights (not `D - v_ig^2`, which can cancel to `<= 0` in float64 for an extremely dominated `>=2`-unit group and would silently revert it to non-LOO). **Reference / validation:** the authors' Stata `did_imputation` ships the same option; there is no CI-runnable anchor (R `didimputation` omits LOO, Stata is not in CI), so validation is the exact psi-identity + hand-calc + MC coverage (`tests/test_methodology_imputation.py::TestB2024AppendixA9LeaveOneOut`). Source: arXiv:2108.12419v5 App. A.9 (the REStud Supplementary Material is canonical); the main-article review's A.9 GAP is now filled (see the `borusyak-jaravel-spiess-2024-review.md` provenance note). The stronger Prop. A8 variant that *also* leaves out for the `delta_hat` covariate estimation (exact-unbiased upper bound) is noted but not implemented — the Stata option is the `tau_tilde` residual rescale only. - **Note:** The Step-1 iterative FE solver (`_iterative_fe`) routes through the shared bincount Gauss-Seidel helper `diff_diff.utils._iterative_fe_solve`, and the covariate/pre-trend within-transformation routes through the shared MAP engine `diff_diff.utils.demean_by_groups` (factorize-once + `np.bincount`, optional Rust kernel; group order `[time, unit]` preserving the historical time-then-unit sweep) — the same convergence contract, accumulation-order numerics (~1e-10 vs the pre-3.7 pandas loops, not bit-for-bit), and `max_iter=10_000` budget documented under "Absorbed Fixed Effects with Survey Weights". Both surfaces emit `UserWarning` via `diff_diff.utils.warn_if_not_converged` when `max_iter` exhausts without reaching `tol` (the demean warning now carries the shared-engine label naming the affected variables rather than the estimator name). Silent return of the current iterate was classified as a silent failure under the Phase 2 audit and replaced with an explicit signal to match the logistic/Poisson IRLS pattern in `linalg.py`. - **Note:** Zero-total-weight groups (e.g. whole PSUs zeroed by JK1/BRR replicate weights, which reach Step 1 unmasked — `keep_mask` only drops always-treated units): a unit/period whose observations ALL carry zero weight has no identifying contribution and surfaces as `NaN` FE (key retained for the rank-condition membership check; matches the SpilloverDiD `_iterative_fe_subset` REGISTRY contract — never a silent finite `0.0`), and the shared demean engine's inert-row guard leaves those rows un-demeaned instead of NaN-poisoning the column. Before 3.7 the pandas loops divided 0/0 there: the covariate replicate path NaN-poisoned `y_dm`/`X_dm`, failed EVERY replicate refit inside `solve_ols(check_finite=True)`, and returned NaN SEs after a non-convergence warning storm; a main fit with zero-weight rows + covariates raised the same opaque `ValueError`. Both now produce finite results. `TwoStageDiD._mask_nan_ytilde`'s "non-finite imputed outcomes" `UserWarning` is suppressed (via `warn_nan=False`) ONLY inside the replicate-refit closures, where NaN FE for zeroed PSUs is expected mechanics — the main-fit warning is unchanged. - **Note:** `vcov_type` is permanently narrow to `{"hc1"}` per the Theorem 3 IF-based variance decomposition. Analytical-sandwich families `{classical, hc2, hc2_bm}` are rejected at `__init__` — the per-unit influence function aggregation has no equivalent single design matrix on which hat-matrix leverage or Bell-McCaffrey Satterthwaite DOF can be defined. `cluster=