Skip to content

Commit 6ba08e7

Browse files
test(tierd): add P2 QTE distributional coverage
1 parent 25d96fd commit 6ba08e7

2 files changed

Lines changed: 136 additions & 0 deletions

File tree

.tierd_campaign/CAMPAIGN.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,18 @@ noisy/forest-dependent — anchored on β₁ + null structure instead.
321321
old broken values.
322322
- **Two real bugs now found + fixed by the Tier D campaign: `sp.blp`
323323
(functionality) and `sp.granger_causality` (correctness).**
324+
325+
### 2026-06-09 (cont.) — P2 qte/multi-treatment/distributional batch
326+
- `test_tierD_p2_qte_multitreat_analytic.py` (7): `qte` (location shift τ=2 →
327+
constant QTE 2.0 at all quantiles; ate recovers shift; no-effect → 0),
328+
`multi_treatment` (AIPW recovers 3-arm effects 1.0/2.5 vs ref; reference
329+
excluded; ordering), `distributional_te` (upward shift → treated CDF
330+
dominates + ks_stat>0.3; no-effect ks_stat<0.1).
331+
- Anchored on `ks_stat`+`dte` not `ks_pvalue` (see finding #5).
332+
- **More edge findings → `.tierd_campaign/FINDINGS_minor_edge_cases.md`** (5):
333+
cic/qte n_boot=0 IndexError; dose_response/mice empty-array warnings; and
334+
**`distributional_te.ks_pvalue` unreliable** (ks_stat=0.69 ↔ p=0.70 vs scipy
335+
KS p≈1e-170) — flagged as a potential reported-p-value correctness bug, like
336+
granger; needs a look at the DTE permutation-null path.
337+
- **P2 tally: 8 batches, 52 tests, 19 estimators.** Reduced batch runtime
338+
6.5min → 58s (n_boot 50→20; point estimates don't need heavy bootstrap).
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Tier D P2 known-truth upgrades — QTE, multi-valued treatment, distributional TE.
2+
3+
Part of the P1/P2 "Tier D analytic special-cases" campaign (see
4+
``.tierd_campaign/CAMPAIGN.md``). All three were graded ``weak`` by
5+
``scripts/tierd_classify.py``. Each anchors to a known-DGP recovery:
6+
7+
sp.qte a pure location shift Y(1)=Y(0)+tau gives a constant
8+
quantile treatment effect tau at every quantile.
9+
sp.multi_treatment AIPW recovers each arm's known effect vs the reference.
10+
sp.distributional_te an upward location shift makes the treated CDF lie
11+
below the control CDF (stochastic dominance) and the
12+
KS statistic is large; under no effect it is ~0.
13+
14+
Purely additive — no estimator numerics changed (campaign red line).
15+
16+
NB (Tier D edge findings, reported not fixed):
17+
- ``sp.qte(n_boot=0)`` raises (np.percentile over an empty bootstrap array),
18+
same edge as ``sp.cic``; tests pass a small positive bootstrap count.
19+
- ``sp.distributional_te.ks_pvalue`` is unreliable (ks_stat=0.69 reported with
20+
ks_pvalue=0.70, where scipy's KS p-value is ~1e-170). Tests anchor on the
21+
correct ``ks_stat`` and the CDF-dominance ``dte`` instead. See
22+
``.tierd_campaign/FINDINGS_minor_edge_cases.md``.
23+
"""
24+
25+
import numpy as np
26+
import pandas as pd
27+
import pytest
28+
29+
import statspai as sp
30+
31+
# Bootstrap only sizes the CIs; every assertion below is on a point estimate /
32+
# statistic, so a small n_boot keeps these fast without weakening the anchor.
33+
N = 2000
34+
NBOOT = 20
35+
36+
37+
# ---------------------------------------------------------------------------
38+
# sp.qte — quantile treatment effects
39+
# ---------------------------------------------------------------------------
40+
class TestQTEAnalytic:
41+
42+
def test_location_shift_is_constant_across_quantiles(self):
43+
rng = np.random.default_rng(0)
44+
t = rng.integers(0, 2, N)
45+
y = rng.normal(0, 1, N) + 2.0 * t # constant shift tau = 2
46+
res = sp.qte(pd.DataFrame({"y": y, "t": t}), y="y", treatment="t",
47+
quantiles=[0.25, 0.5, 0.75], n_boot=NBOOT)
48+
np.testing.assert_allclose(res.effects, 2.0, atol=0.25)
49+
50+
def test_ate_recovers_mean_shift(self):
51+
rng = np.random.default_rng(1)
52+
t = rng.integers(0, 2, N)
53+
y = rng.normal(0, 1, N) + 2.0 * t
54+
res = sp.qte(pd.DataFrame({"y": y, "t": t}), y="y", treatment="t",
55+
quantiles=[0.5], n_boot=NBOOT)
56+
assert res.ate == pytest.approx(2.0, abs=0.2)
57+
58+
def test_no_effect_gives_zero_qte(self):
59+
rng = np.random.default_rng(2)
60+
t = rng.integers(0, 2, N)
61+
y = rng.normal(0, 1, N) # treatment unrelated to outcome
62+
res = sp.qte(pd.DataFrame({"y": y, "t": t}), y="y", treatment="t",
63+
quantiles=[0.25, 0.5, 0.75], n_boot=NBOOT)
64+
np.testing.assert_allclose(res.effects, 0.0, atol=0.25)
65+
66+
67+
# ---------------------------------------------------------------------------
68+
# sp.multi_treatment — multi-valued treatment via AIPW
69+
# ---------------------------------------------------------------------------
70+
class TestMultiTreatmentAnalytic:
71+
72+
@staticmethod
73+
def _three_arm_dgp(seed=0, n=N):
74+
rng = np.random.default_rng(seed)
75+
T = rng.integers(0, 3, n)
76+
x = rng.normal(0, 1, n)
77+
# Known effects vs arm 0: arm 1 = +1.0, arm 2 = +2.5.
78+
y = 1.0 * (T == 1) + 2.5 * (T == 2) + 0.5 * x + rng.normal(0, 1, n)
79+
return pd.DataFrame({"y": y, "T": T, "x": x})
80+
81+
def test_recovers_per_arm_effects(self):
82+
df = self._three_arm_dgp()
83+
res = sp.multi_treatment(df, y="y", treat="T", covariates=["x"],
84+
reference=0, n_bootstrap=NBOOT)
85+
eff = res.detail.set_index("treatment")["estimate"]
86+
assert eff[1] == pytest.approx(1.0, abs=0.25)
87+
assert eff[2] == pytest.approx(2.5, abs=0.25)
88+
89+
def test_reference_arm_excluded_and_ordering(self):
90+
df = self._three_arm_dgp()
91+
res = sp.multi_treatment(df, y="y", treat="T", covariates=["x"],
92+
reference=0, n_bootstrap=NBOOT)
93+
assert 0 not in set(res.detail["treatment"])
94+
eff = res.detail.set_index("treatment")["estimate"]
95+
assert eff[2] > eff[1] # arm 2 effect exceeds arm 1
96+
97+
98+
# ---------------------------------------------------------------------------
99+
# sp.distributional_te — distributional treatment effects
100+
# ---------------------------------------------------------------------------
101+
class TestDistributionalTEAnalytic:
102+
103+
def test_upward_shift_gives_stochastic_dominance(self):
104+
# Treated distribution shifted up by 2 -> treated CDF lies below the
105+
# control CDF (DTE = F_treated - F_control <= 0 on average), and the KS
106+
# distance between the two distributions is large.
107+
rng = np.random.default_rng(0)
108+
t = rng.integers(0, 2, N)
109+
y = rng.normal(0, 1, N) + 2.0 * t
110+
res = sp.distributional_te(pd.DataFrame({"y": y, "t": t}), y="y",
111+
treatment="t", n_grid=40, n_boot=NBOOT)
112+
assert np.nanmean(res.dte) < 0 # treated stochastically dominates
113+
assert res.ks_stat > 0.3 # distributions clearly differ
114+
115+
def test_no_effect_has_small_ks_distance(self):
116+
rng = np.random.default_rng(3)
117+
t = rng.integers(0, 2, N)
118+
y = rng.normal(0, 1, N) # identical distributions across arms
119+
res = sp.distributional_te(pd.DataFrame({"y": y, "t": t}), y="y",
120+
treatment="t", n_grid=40, n_boot=NBOOT)
121+
assert res.ks_stat < 0.1

0 commit comments

Comments
 (0)