Skip to content

Commit d5dd799

Browse files
Merge pull request #122 from TheHiddenObserver/agent/panel-p1-stage-b-diagnostics
feat: add Panel Tier-1 Stage B diagnostics
2 parents e9e0ec4 + cdff571 commit d5dd799

63 files changed

Lines changed: 17521 additions & 300 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.

.github/workflows/benchmark-frontend.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ on:
1212
- 'dev/tests/test_benchmark_cv_source.py'
1313
- 'dev/tests/test_cv_runner_instrumentation.py'
1414
- 'dev/tests/test_frontend_contracts.py'
15+
- 'dev/tests/test_frontend_cv_determinism.py'
1516
- 'dev/tests/test_frontend_domain_coverage.py'
17+
- 'dev/tests/test_panel_stage_b_frontend_source.py'
18+
- 'dev/tests/test_panel_stage_b_applicable_hausman_parser.py'
1619
- 'dev/tests/fixtures/benchmark_frontend/**'
1720
- 'frontend/**'
1821
- 'docs/assets/benchmarks/**'
@@ -28,7 +31,10 @@ on:
2831
- 'dev/tests/test_benchmark_cv_source.py'
2932
- 'dev/tests/test_cv_runner_instrumentation.py'
3033
- 'dev/tests/test_frontend_contracts.py'
34+
- 'dev/tests/test_frontend_cv_determinism.py'
3135
- 'dev/tests/test_frontend_domain_coverage.py'
36+
- 'dev/tests/test_panel_stage_b_frontend_source.py'
37+
- 'dev/tests/test_panel_stage_b_applicable_hausman_parser.py'
3238
- 'dev/tests/fixtures/benchmark_frontend/**'
3339
- 'frontend/**'
3440
- 'docs/assets/benchmarks/**'
@@ -61,7 +67,10 @@ jobs:
6167
dev/tests/test_benchmark_cv_source.py \
6268
dev/tests/test_cv_runner_instrumentation.py \
6369
dev/tests/test_frontend_contracts.py \
64-
dev/tests/test_frontend_domain_coverage.py -v
70+
dev/tests/test_frontend_cv_determinism.py \
71+
dev/tests/test_frontend_domain_coverage.py \
72+
dev/tests/test_panel_stage_b_frontend_source.py \
73+
dev/tests/test_panel_stage_b_applicable_hausman_parser.py -v
6574
- name: Validate generator output
6675
run: python dev/benchmarks/generate_benchmark_data.py --check --strict-sources
6776

.github/workflows/test.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,31 @@ jobs:
183183
python -m pytest \
184184
dev/tests/test_logistic_cv_torch_dtype.py \
185185
dev/tests/test_panel_stage_a_torch_cpu.py \
186+
dev/tests/test_panel_stage_b_torch_cpu.py \
186187
-q --tb=short
187188
189+
panel-stage-b-linearmodels:
190+
runs-on: ubuntu-latest
191+
timeout-minutes: 15
192+
steps:
193+
- uses: actions/checkout@v4
194+
- uses: actions/setup-python@v5
195+
with:
196+
python-version: '3.11'
197+
- name: Install current external panel reference
198+
run: |
199+
python -m pip install --upgrade pip
200+
python -m pip install -e . "linearmodels==7.0" pytest
201+
- name: Confirm linearmodels reference version
202+
run: |
203+
python - <<'PY'
204+
import linearmodels
205+
assert linearmodels.__version__ == "7.0"
206+
print("linearmodels", linearmodels.__version__)
207+
PY
208+
- name: Run Panel Stage B external alignment
209+
run: python -m pytest dev/tests/test_panel_stage_b_linearmodels.py -q --tb=short
210+
188211
static-contracts:
189212
runs-on: ubuntu-latest
190213
steps:

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to statgpu are documented here, organized by release and dat
44

55
## 2026-08-08
66

7+
### PR #122 — Panel Tier-1 diagnostics Stage B
8+
9+
- Added structured Panel `fit_statistics_` with parameter-based within/between/overall R², adjusted R², and classical model F statistics while preserving Stage-A inference and legacy df/R² attributes.
10+
- Added classical pooling F, one-way entity Breusch-Pagan LM (including Baltagi-Li unbalanced panels), and one-way classical FE-vs-RE Hausman with explicit applicability diagnostics.
11+
- Added NumPy/CuPy/Torch coverage, formula-row alignment, maintained Torch 2.0 CPU tests, executable linearmodels 7.0 definition alignment, and an exact-head physical GPU acceptance runner that rechecks coefficient inference as well as Stage-B diagnostics.
12+
713
### PR #121 — CuPy inverse-quantile LUT correctness
814

915
- Fixed CuPy `betaincinv` and `gammaincinv` LUT cache tuple ordering so inverse quantiles no longer collapse to boundary values and downstream confidence intervals retain their correct width.

RELEASING.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ contracts, documentation contracts, and the Python 3.9–3.12 regression matrix.
8080
For changes affecting CuPy, Torch, inference, device routing, or performance,
8181
record physical-GPU acceptance on the exact release source commit.
8282

83+
Physical evidence is tied to both the numerical implementation and the validator
84+
that defines the acceptance matrix. If a physical-validation runner changes after
85+
an artifact has been accepted—for example, because review adds a previously
86+
uncovered inference branch—the old artifact remains useful historical evidence
87+
but no longer proves the new acceptance contract. Return the affected pull
88+
request to a pending/draft state, rerun the changed validator on an exact clean
89+
candidate head, and only then promote new canonical evidence or restore a
90+
Ready/merge-ready conclusion.
91+
8392
### Package validation
8493

8594
The `Release package validation` workflow automatically:

dev/benchmarks/benchmark_coverage_matrix.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,18 +82,22 @@
8282
},
8383
{
8484
"capability_id": "panel-estimation",
85-
"label": "Panel estimation coverage",
85+
"label": "Panel estimation and diagnostics coverage",
8686
"status": "partial_canonical",
8787
"source_ids": [
88-
"new-modules-20260624-bcbdb676223b"
88+
"new-modules-20260624-bcbdb676223b",
89+
"panel-stage-b-pr122-20260809-2056f836bfe2"
8990
],
9091
"representative_dimensions": [
9192
"estimator",
9293
"backend",
93-
"aligned_scale"
94+
"aligned_scale",
95+
"physical_validation",
96+
"diagnostics",
97+
"inference_regression"
9498
],
9599
"issue": "#108",
96-
"disposition": "Aligned PanelOLS and RandomEffects evidence exists; additional estimators and covariance variants remain open."
100+
"disposition": "June timing rows cover aligned PanelOLS and RandomEffects. PR #122 adds canonical validation-only CuPy/Torch evidence for the 17-case Stage-B estimator matrix, five Hausman diagnostics per backend (including a physically applicable nonzero-effect statistic/p-value/df path), backend provenance, Stage-A inference regression, and the disconnected two-way FE df=1 physical boundary; broader performance/covariance timing remains open."
97101
},
98102
{
99103
"capability_id": "gam-nonparametric",
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
#!/usr/bin/env python3
2+
"""Measure Stage-B full-content identity overhead on physical GPU backends.
3+
4+
This benchmark isolates the cost of the collision-resistant X/y SHA-256 used by
5+
Hausman sample identity. It compares ordinary PanelOLS/RandomEffects fit time
6+
against the same fit with only ``_full_content_digest`` replaced by a constant
7+
stub. Numerical work, low-order audit reductions, estimator setup, and all other
8+
Stage-B code remain unchanged in the baseline.
9+
10+
The script is intentionally separate from ``validate_panel_stage_b_gpu.py``:
11+
that runner remains correctness/provenance-only and its frontend source must not
12+
acquire inferred timing or speedup fields.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import argparse
18+
import importlib.metadata
19+
import json
20+
import platform
21+
import subprocess
22+
import time
23+
from pathlib import Path
24+
25+
import numpy as np
26+
27+
import statgpu.panel._diagnostics as diagnostics
28+
from statgpu.panel import PanelOLS, RandomEffects
29+
30+
31+
def _git_sha() -> str:
32+
return subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
33+
34+
35+
def _git_status_porcelain() -> str:
36+
return subprocess.check_output(["git", "status", "--porcelain"], text=True)
37+
38+
39+
def _version(name: str):
40+
try:
41+
return importlib.metadata.version(name)
42+
except importlib.metadata.PackageNotFoundError:
43+
return None
44+
45+
46+
def _parse_scales(value: str):
47+
out = []
48+
for item in value.split(","):
49+
n_text, k_text = item.strip().lower().split("x", 1)
50+
n, k = int(n_text), int(k_text)
51+
if n <= 0 or k <= 0:
52+
raise ValueError("benchmark scales must be positive NxK pairs")
53+
out.append((n, k))
54+
return out
55+
56+
57+
def _sync(backend: str):
58+
if backend == "cupy":
59+
import cupy as cp
60+
61+
cp.cuda.Stream.null.synchronize()
62+
elif backend == "torch":
63+
import torch
64+
65+
torch.cuda.synchronize()
66+
67+
68+
def _to_backend(X, y, entity, backend: str):
69+
if backend == "cupy":
70+
import cupy as cp
71+
72+
return (
73+
cp.asarray(X),
74+
cp.asarray(y),
75+
cp.asarray(entity, dtype=cp.int64),
76+
)
77+
if backend == "torch":
78+
import torch
79+
80+
return (
81+
torch.as_tensor(X, dtype=torch.float64, device="cuda"),
82+
torch.as_tensor(y, dtype=torch.float64, device="cuda"),
83+
torch.as_tensor(entity, dtype=torch.int64, device="cuda"),
84+
)
85+
raise ValueError(backend)
86+
87+
88+
def _device_arg(backend: str):
89+
return {"cupy": "cuda", "torch": "torch"}[backend]
90+
91+
92+
def _dataset(n: int, k: int, seed: int):
93+
rng = np.random.default_rng(seed)
94+
X = rng.normal(size=(n, k)).astype(np.float64)
95+
beta = np.linspace(0.2, 0.8, k, dtype=np.float64)
96+
entity = np.arange(n, dtype=np.int64) // 20
97+
n_entities = int(entity.max()) + 1
98+
alpha = np.linspace(-0.5, 0.5, n_entities, dtype=np.float64)[entity]
99+
y = X @ beta + alpha + rng.normal(scale=0.2, size=n)
100+
return X, y.astype(np.float64), entity
101+
102+
103+
def _fit(model_name: str, X, y, entity, backend: str):
104+
device = _device_arg(backend)
105+
if model_name == "PanelOLS":
106+
model = PanelOLS(entity_effects=True, cov_type="nonrobust", device=device)
107+
elif model_name == "RandomEffects":
108+
model = RandomEffects(device=device)
109+
else:
110+
raise ValueError(model_name)
111+
model.fit(X, y, entity_ids=entity)
112+
return model
113+
114+
115+
def _timed_fit(model_name, X, y, entity, backend, *, disable_digest: bool):
116+
original = diagnostics._full_content_digest
117+
if disable_digest:
118+
diagnostics._full_content_digest = lambda _X, _y: "0" * 64
119+
try:
120+
_sync(backend)
121+
start = time.perf_counter()
122+
_fit(model_name, X, y, entity, backend)
123+
_sync(backend)
124+
return time.perf_counter() - start
125+
finally:
126+
diagnostics._full_content_digest = original
127+
128+
129+
def _median(values):
130+
return float(np.median(np.asarray(values, dtype=np.float64)))
131+
132+
133+
def main():
134+
parser = argparse.ArgumentParser(description=__doc__)
135+
parser.add_argument("--out", type=Path, required=True)
136+
parser.add_argument("--expected-sha", required=True)
137+
parser.add_argument("--backends", default="cupy,torch")
138+
parser.add_argument(
139+
"--scales",
140+
default="10000x2,100000x2,100000x10,500000x2",
141+
help="comma-separated NxK pairs",
142+
)
143+
parser.add_argument("--repeats", type=int, default=3)
144+
args = parser.parse_args()
145+
146+
sha = _git_sha()
147+
if sha != args.expected_sha:
148+
raise RuntimeError(f"wrong source head: {sha} != {args.expected_sha}")
149+
dirty = _git_status_porcelain()
150+
if dirty.strip():
151+
raise RuntimeError(
152+
"identity benchmark requires a clean working tree; uncommitted changes:\n"
153+
+ dirty
154+
)
155+
if args.repeats < 1:
156+
raise ValueError("--repeats must be positive")
157+
158+
backends = [x.strip() for x in args.backends.split(",") if x.strip()]
159+
if not backends or any(x not in {"cupy", "torch"} for x in backends):
160+
raise ValueError("--backends must contain cupy and/or torch")
161+
scales = _parse_scales(args.scales)
162+
163+
rows = []
164+
for scale_index, (n, k) in enumerate(scales):
165+
X_np, y_np, entity_np = _dataset(n, k, seed=20260808 + scale_index)
166+
for backend in backends:
167+
X, y, entity = _to_backend(X_np, y_np, entity_np, backend)
168+
for model_name in ("PanelOLS", "RandomEffects"):
169+
# Warm both paths before measurement to avoid one-time import/
170+
# allocator effects being attributed to the digest.
171+
_timed_fit(
172+
model_name, X, y, entity, backend, disable_digest=False
173+
)
174+
_timed_fit(
175+
model_name, X, y, entity, backend, disable_digest=True
176+
)
177+
178+
with_digest = []
179+
without_digest = []
180+
for _ in range(args.repeats):
181+
with_digest.append(
182+
_timed_fit(
183+
model_name,
184+
X,
185+
y,
186+
entity,
187+
backend,
188+
disable_digest=False,
189+
)
190+
)
191+
without_digest.append(
192+
_timed_fit(
193+
model_name,
194+
X,
195+
y,
196+
entity,
197+
backend,
198+
disable_digest=True,
199+
)
200+
)
201+
202+
normal = _median(with_digest)
203+
baseline = _median(without_digest)
204+
overhead = normal - baseline
205+
ratio = normal / baseline if baseline > 0.0 else None
206+
rows.append(
207+
{
208+
"backend": backend,
209+
"model": model_name,
210+
"n_samples": n,
211+
"n_features": k,
212+
"repeats": args.repeats,
213+
"with_digest_seconds": normal,
214+
"without_digest_seconds": baseline,
215+
"digest_overhead_seconds": overhead,
216+
"with_over_without_ratio": ratio,
217+
"with_digest_samples": with_digest,
218+
"without_digest_samples": without_digest,
219+
}
220+
)
221+
222+
payload = {
223+
"schema_version": 1,
224+
"git_sha": sha,
225+
"working_tree_clean": True,
226+
"benchmark": "panel_stage_b_full_content_identity_overhead",
227+
"timing_scope": "end-to-end estimator fit with vs without only the SHA-256 full-content digest",
228+
"target_scale_source": "PR122 fresh-review performance finding",
229+
"environment": {
230+
"python": platform.python_version(),
231+
"platform": platform.platform(),
232+
"packages": {
233+
name: _version(name)
234+
for name in ("statgpu", "numpy", "cupy", "torch")
235+
},
236+
},
237+
"rows": rows,
238+
}
239+
args.out.parent.mkdir(parents=True, exist_ok=True)
240+
args.out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
241+
print(json.dumps(payload, indent=2))
242+
print(f"PASS — identity-overhead benchmark recorded: {args.out}")
243+
244+
245+
if __name__ == "__main__":
246+
main()

0 commit comments

Comments
 (0)