Skip to content

Commit 78560c8

Browse files
authored
feat(public-health): NHEFS g-methods certification + full R EValue parity (#21)
Squash-merge of #21 (qijiang3). Adds NHEFS dataset + What If g-methods parity, full R EValue package parity (HR rare/common, OLS measure, exact RD grid, CI clamp), competing risks (Aalen-Johansen/Fine-Gray), epi study-design power, summary-data meta-analysis, and epi guides. Registry 1029->1031. Pre-merge verification: new-feature suites green locally (108 passed); the 8 CI 'failures' verified as environment-sensitive pins / pre-existing budget drift, none a regression introduced by this PR (see follow-up commits for tolerance hardening + the required evalue CHANGELOG/MIGRATION correctness note).
1 parent 929b977 commit 78560c8

87 files changed

Lines changed: 11075 additions & 458 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,11 @@ MORNING_REPORT_2026-04-29.md
215215
.cov_decomp/*
216216
!.cov_decomp/DECOMP_CAMPAIGN.md
217217

218+
# NHEFS / What If parity dumps: regenerable byte-for-byte from the bundled
219+
# sp.datasets.nhefs() by running the Python side of each paired script, so we
220+
# do not commit ~3.4 MB of duplicated copies of the already-bundled CSV.
221+
tests/orig_parity/data/*_nhefs_*.csv
222+
218223
# Tier-D/Tier-B validation campaign working dir (regenerable artifacts ignored; tracker + worklist kept)
219224
.tierd_campaign/*
220225
!.tierd_campaign/CAMPAIGN.md

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ StatsPAI's focus is **causal inference**. The grid below summarizes method-famil
138138

139139
**Legend**: B = broad API coverage within this comparison table; Y = implemented entry points; P = partial, scattered, or single-algorithm support; N = no first-class entry point. These are API-breadth labels, not validation tiers.
140140

141-
**StatsPAI at a glance**: 1,020 registered functions in the live agent registry · 81 submodules · 269k LOC (core) + 114k LOC (tests). All four numbers are reproducible from the canonical generator (`python scripts/registry_stats.py`); the per-module table in [`docs/stats.md`](docs/stats.md) is regenerated from the same script. For the API-breadth matrix (23 method families) and cross-ecosystem line-count comparison, see [`docs/stats.md`](docs/stats.md).
141+
**StatsPAI at a glance**: 1,031 registered functions in the live agent registry · 81 submodules · 271k LOC (core) + 134k LOC (tests). All four numbers are reproducible from the canonical generator (`python scripts/registry_stats.py`); the per-module table in [`docs/stats.md`](docs/stats.md) is regenerated from the same script. For the API-breadth matrix (23 method families) and cross-ecosystem line-count comparison, see [`docs/stats.md`](docs/stats.md).
142142

143143
**Validation tiers matter**: `stability="stable"` means the public API is SemVer-stable; it does not by itself mean R/Stata/paper parity. Use `sp.list_functions(validation_status="certified")` for cross-language or published-reference evidence, and inspect `sp.describe_function(name)["limitations"]` before production use. See [`docs/guides/stability.md`](docs/guides/stability.md).
144144

README_CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ StatsPAI 聚焦**因果推断**。下表描述方法家族层面的 API 覆盖
4646

4747
**图例**:B = 本表范围内 API 覆盖较宽;Y = 有已实现入口;P = 部分、分散或单算法支持;N = 无一等入口。这些只是 API 广度标签,不是 validation tier。
4848

49-
**StatsPAI 一句话概览**:live agent registry 中有 1,020 个注册函数 · 81 个子模块 · 269k 行核心代码 + 114k 行测试。这四个数字都可以由唯一的生成器 (`python scripts/registry_stats.py`) 现场复算;[`docs/stats.md`](docs/stats.md) 中的按模块拆分表也由同一个脚本回写。23 个方法家族的 API 广度矩阵以及跨生态行数对比,详见 [`docs/stats.md`](docs/stats.md)。这些覆盖数字描述 API 广度,不等同于每个函数都有 R/Stata 数值验证;生产使用请查看 `validation_status`
49+
**StatsPAI 一句话概览**:live agent registry 中有 1,031 个注册函数 · 81 个子模块 · 271k 行核心代码 + 134k 行测试。这四个数字都可以由唯一的生成器 (`python scripts/registry_stats.py`) 现场复算;[`docs/stats.md`](docs/stats.md) 中的按模块拆分表也由同一个脚本回写。23 个方法家族的 API 广度矩阵以及跨生态行数对比,详见 [`docs/stats.md`](docs/stats.md)。这些覆盖数字描述 API 广度,不等同于每个函数都有 R/Stata 数值验证;生产使用请查看 `validation_status`
5050

5151
**📦 v1.16.0(2026-05-29)— 正确性修复与跨语言对齐扩展**
5252

docs/guides/competing_risks.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Competing risks — cumulative incidence & Fine-Gray
2+
3+
> **When a subject can fail from more than one cause, Kaplan-Meier lies.**
4+
> Treating "death from other causes" as ordinary censoring makes the
5+
> naïve `1 - KM` curve over-state the risk of the cause you care about,
6+
> because it implicitly assumes the censored subjects could still have
7+
> had the event. StatsPAI ships the two standard competing-risks tools:
8+
> the **Aalen-Johansen cumulative incidence function** (`sp.cuminc`) for
9+
> description and Gray's test, and the **Fine-Gray subdistribution
10+
> hazards model** (`sp.finegray`) for regression
11+
> [@aalen1978nonparametric; @gray1988class; @fine1999proportional].
12+
13+
**Event coding.** Throughout, the `event` column is an integer:
14+
`0` = right-censored, and `1, 2, ...` = the competing causes. This
15+
matches R's `cmprsk` and `survival` conventions.
16+
17+
Scope note: these estimators are validated internally (CIF
18+
self-consistency, analytic-vs-bootstrap variance agreement, Fine-Gray
19+
recovery on simulated data, Gray's-test calibration) but are **not yet
20+
parity-certified** against R's `cmprsk` / `survival`. Validate your
21+
headline number before publication and read
22+
`sp.describe_function("cuminc")["limitations"]`.
23+
24+
---
25+
26+
## 1. Why not Kaplan-Meier?
27+
28+
In a competing-risks setting the quantity you almost always want is the
29+
**cumulative incidence function** (CIF) — the probability of failing from
30+
cause *k* by time *t*, accounting for the fact that a competing event
31+
removes a subject from ever experiencing cause *k*. The Aalen-Johansen
32+
estimator weights each cause-specific increment by the overall (all-cause)
33+
survival:
34+
35+
$$
36+
\hat F_k(t) = \sum_{t_i \le t} \hat S(t_{i-1}) \, \frac{d_{ki}}{n_i}
37+
$$
38+
39+
so that the CIFs of all causes plus the overall survival sum to one at
40+
every time — a property `1 - KM_k` does not have.
41+
42+
---
43+
44+
## 2. Cumulative incidence — `sp.cuminc`
45+
46+
```python
47+
import statspai as sp
48+
49+
ci = sp.cuminc(df, duration="time", event="status")
50+
print(ci.summary())
51+
print(ci.cif_table.head()) # group / cause / time / cif / se / ci_lower / ci_upper
52+
ci.plot(cause=1) # step CIF curve with the other causes overlaid
53+
```
54+
55+
Each cause's CIF comes with a delta-method standard error and a
56+
confidence band (Marubini-Valsecchi / Klein-Moeschberger variance). To
57+
read off the cumulative incidence at a specific horizon:
58+
59+
```python
60+
ci.cif_at(time=5.0, cause=1) # CIF and CI at t = 5
61+
```
62+
63+
### Comparing groups — Gray's test
64+
65+
Pass `group=` to estimate per-group CIFs and get Gray's (1988) K-sample
66+
test for equality of cumulative incidence, reported per cause:
67+
68+
```python
69+
ci = sp.cuminc(df, duration="time", event="status", group="arm")
70+
print(ci.gray_test[1]) # {'statistic', 'df', 'p_value'} for cause 1
71+
ci.plot(cause=1) # one CIF curve per arm
72+
```
73+
74+
Gray's test targets the **subdistribution** hazard, so it answers the
75+
clinically relevant question "do the groups differ in cumulative
76+
incidence of cause 1?" rather than the cause-specific-hazard question.
77+
78+
---
79+
80+
## 3. Regression — `sp.finegray`
81+
82+
The Fine-Gray model puts covariates on the subdistribution hazard, so its
83+
coefficients exponentiate to **subdistribution hazard ratios (sHR)** that
84+
map monotonically to the cumulative incidence — a covariate with sHR > 1
85+
*increases* the cause-of-interest CIF. (Cause-specific Cox coefficients do
86+
not have this property, which is why they are easy to misread in a
87+
competing-risks setting.)
88+
89+
```python
90+
import statspai as sp
91+
92+
fg = sp.finegray(df, duration="time", event="status",
93+
x=["treatment", "age", "stage"], cause=1)
94+
print(fg.summary())
95+
fg.tidy() # term / coef / shr / std_err / z / p_value / shr_lower / shr_upper
96+
```
97+
98+
Subjects who fail from a competing cause are kept in the risk set with
99+
time-decaying inverse-probability-of-censoring weights
100+
`Ĝ(t)/Ĝ(T_i)` (Fine & Gray 1999); the weighted partial likelihood is
101+
maximised by Newton-Raphson.
102+
103+
---
104+
105+
## 4. Cause-specific vs. subdistribution — which to report?
106+
107+
Both are legitimate and answer **different** questions:
108+
109+
| Question | Tool |
110+
| --- | --- |
111+
| "What is the probability of failing from cause 1 by time *t*?" | `sp.cuminc` (CIF) |
112+
| "Does treatment change the **cumulative incidence** of cause 1?" | `sp.finegray` (sHR) or Gray's test |
113+
| "Does treatment change the **rate** of cause 1 among those still event-free?" | cause-specific `sp.cox` (censor competing events) |
114+
115+
A common, defensible reporting choice is to present the CIF curves
116+
descriptively and **both** a cause-specific Cox model and a Fine-Gray
117+
model, since they decompose the effect on the rate vs. the effect on the
118+
cumulative incidence.
119+
120+
---
121+
122+
## 5. Limitations (read before you publish)
123+
124+
- **Standard errors for `sp.finegray` are model-based** (inverse
125+
information). A fully robust sandwich variance that accounts for
126+
estimating the censoring distribution Ĝ is not yet implemented; for
127+
small samples or heavy censoring, validate the SEs against R's
128+
`cmprsk::crr`.
129+
- **No time-varying covariates** in `sp.finegray` yet.
130+
- **Parity is not yet certified** against `cmprsk` / `survival`.
131+
132+
## Where to next
133+
134+
- [Survival analysis for public health](survival_ph.md)
135+
- [G-methods for time-varying confounding](g_methods_ph.md)

docs/guides/epi_measures.md

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Epidemiological measures — the `sp.epi` family
2+
3+
> **The primitives epidemiologists reach for first.** Before any model,
4+
> public-health analysis starts with measures of association from
5+
> contingency tables, rate standardization, and screening-test accuracy.
6+
> StatsPAI ships these as top-level functions modelled after R's
7+
> `epiR`, `epitools`, and `fmsb`, with the standard small-sample
8+
> conventions (Woolf and exact CIs, Haldane–Anscombe continuity
9+
> correction, exact Poisson rate intervals) built in
10+
> [@rothman2008modern].
11+
12+
All functions return a small typed result with `.estimate`, `.ci`, and
13+
(where defined) `.p_value`, so they compose with the rest of StatsPAI's
14+
reporting layer.
15+
16+
---
17+
18+
## 1. Measures of association (2×2 tables)
19+
20+
The 2×2 layout throughout is `(a, b, c, d)` =
21+
`(exposed cases, exposed non-cases, unexposed cases, unexposed non-cases)`.
22+
23+
```python
24+
import statspai as sp
25+
26+
# Cohort study
27+
rr = sp.relative_risk(50, 950, 10, 990)
28+
print(f"RR = {rr.estimate:.2f} 95% CI {rr.ci}")
29+
print(rr.risk_exposed, rr.risk_unexposed)
30+
31+
rd = sp.risk_difference(50, 950, 10, 990)
32+
print(f"RD = {rd.estimate:.4f} 95% CI {rd.ci}")
33+
34+
nnt = sp.number_needed_to_treat(50, 950, 10, 990)
35+
print(f"NNT = {nnt.estimate:.1f}")
36+
37+
# Case-control study (odds ratio)
38+
orr = sp.odds_ratio(50, 20, 30, 40, method="woolf")
39+
print(f"OR = {orr.estimate:.2f} 95% CI {orr.ci}")
40+
41+
# Population attributable fraction (Levin)
42+
ar = sp.attributable_risk(50, 950, 10, 990)
43+
print(ar.estimate)
44+
```
45+
46+
**Person-time / rates.** When the denominator is person-time rather than
47+
people, use the incidence-rate ratio with an exact Poisson interval:
48+
49+
```python
50+
irr = sp.incidence_rate_ratio(
51+
events_exposed=30, pt_exposed=1000,
52+
events_unexposed=15, pt_unexposed=1200,
53+
)
54+
print(f"IRR = {irr.estimate:.2f} 95% CI {irr.ci}")
55+
```
56+
57+
**Cross-sectional prevalence.** For prevalent (not incident) outcomes the
58+
prevalence ratio is preferred over the OR; `sp.prevalence_ratio` uses the
59+
modified-Poisson / log-binomial approach [@zou2004modified].
60+
61+
**Zero cells** are handled automatically with the Haldane–Anscombe 0.5
62+
continuity correction on the log-scale standard error, so a single empty
63+
cell does not blow up the interval.
64+
65+
---
66+
67+
## 2. Stratified analysis — Mantel–Haenszel
68+
69+
When a categorical variable confounds the exposure–outcome relationship,
70+
pool stratum-specific tables with Mantel–Haenszel and test whether the
71+
effect is homogeneous across strata [@mantel1959statistical]:
72+
73+
```python
74+
import numpy as np
75+
import statspai as sp
76+
77+
# K = 2 strata; each is a 2x2 [[a, b], [c, d]]
78+
tables = np.array([
79+
[[10, 5], [8, 12]],
80+
[[20, 15], [6, 9]],
81+
])
82+
83+
mh = sp.mantel_haenszel(tables, measure="OR") # or measure="RR"
84+
print(f"MH pooled OR = {mh.estimate:.3f} 95% CI {mh.ci}")
85+
print(f"stratum-specific: {mh.stratum_estimates}")
86+
print(f"Breslow–Day homogeneity p = {mh.homogeneity_p:.3f}")
87+
```
88+
89+
A small homogeneity p-value warns that a single pooled estimate hides
90+
effect-measure modification — report stratum-specific estimates instead.
91+
`sp.breslow_day_test` exposes the test directly (with the Tarone
92+
correction by default).
93+
94+
---
95+
96+
## 3. Rate standardization
97+
98+
Crude rates are not comparable across populations with different age
99+
structures. Standardize directly (when you have stratum-specific rates in
100+
the study population) or indirectly (SMR, when study strata are small):
101+
102+
```python
103+
import statspai as sp
104+
105+
# Direct standardization to a reference population structure
106+
std = sp.direct_standardize(
107+
events=[50, 60, 40], # events per age band
108+
population=[1000, 2000, 1500], # population/person-time per band
109+
standard_weights=[0.3, 0.45, 0.25],
110+
)
111+
print(f"Age-standardized rate = {std.rate:.5f} 95% CI {std.ci}")
112+
113+
# Indirect standardization → standardized mortality/morbidity ratio (SMR)
114+
smr = sp.indirect_standardize(
115+
observed=120,
116+
events_reference=[50, 60, 40],
117+
population_reference=[1000, 2000, 1500],
118+
population_study=[800, 1500, 1000],
119+
)
120+
print(f"SMR = {smr.estimate:.3f} 95% CI {smr.ci}")
121+
```
122+
123+
---
124+
125+
## 4. Screening and diagnostic-test accuracy
126+
127+
```python
128+
import statspai as sp
129+
130+
# From a confusion matrix
131+
acc = sp.sensitivity_specificity(tp=80, fn=20, fp=10, tn=90)
132+
print(acc.summary()) # sensitivity, specificity, PPV, NPV, LR+/LR- with Wilson CIs
133+
134+
# From scores + labels
135+
roc = sp.roc_curve(y_true, scores)
136+
print(roc.auc)
137+
138+
# Inter-rater agreement
139+
kappa = sp.cohen_kappa(rater_a, rater_b, weights="linear")
140+
print(kappa.estimate)
141+
```
142+
143+
---
144+
145+
## 5. Weighing the evidence — Bradford–Hill
146+
147+
`sp.bradford_hill` turns the nine Bradford–Hill viewpoints into a
148+
structured, transparent scoring object — useful for causal-narrative
149+
sections and for forcing each viewpoint to be argued explicitly rather
150+
than asserted:
151+
152+
```python
153+
import statspai as sp
154+
155+
bh = sp.bradford_hill(
156+
strength=1.0, temporality=1.0, consistency=0.5,
157+
biological_gradient=0.8, plausibility=0.7,
158+
)
159+
print(bh.summary())
160+
```
161+
162+
It is a structuring aid, **not** a causal test — the score does not
163+
establish causation, it documents how each viewpoint was judged.
164+
165+
---
166+
167+
## Where to next
168+
169+
- [Public health & epidemiology overview](public_health.md)
170+
- [G-methods for time-varying confounding](g_methods_ph.md)
171+
- [Survival analysis](survival_ph.md)

0 commit comments

Comments
 (0)