Skip to content

Commit 070176f

Browse files
Merge pull request #116 from TheHiddenObserver/agent/fix-logistic-cv-torch-p100
fix: repair Torch mixed-precision LogisticRegressionCV
2 parents 37f643a + 4c74830 commit 070176f

27 files changed

Lines changed: 10009 additions & 851 deletions

.github/workflows/test.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,31 @@ jobs:
156156
- name: Run complete CPU test tree
157157
run: python -m pytest dev/tests -q --tb=short
158158

159+
torch-cpu-logistic-cv:
160+
runs-on: ubuntu-latest
161+
timeout-minutes: 15
162+
steps:
163+
- uses: actions/checkout@v4
164+
- uses: actions/setup-python@v5
165+
with:
166+
python-version: '3.9'
167+
- name: Install Torch 2.0 CPU regression environment
168+
run: |
169+
python -m pip install --upgrade pip
170+
python -m pip install "numpy<2" pytest
171+
python -m pip install "torch==2.0.1+cpu" --extra-index-url https://download.pytorch.org/whl/cpu
172+
python -m pip install -e .
173+
- name: Confirm Torch regression environment
174+
run: |
175+
python - <<'PY'
176+
import torch
177+
assert torch.__version__.startswith("2.0.1")
178+
assert not torch.cuda.is_available()
179+
print("torch", torch.__version__, "device=cpu")
180+
PY
181+
- name: Run Issue 112 Torch CPU regressions
182+
run: python -m pytest dev/tests/test_logistic_cv_torch_dtype.py -q --tb=short
183+
159184
static-contracts:
160185
runs-on: ubuntu-latest
161186
steps:

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
All notable changes to statgpu are documented here, organized by release and date.
44

5+
## 2026-08-07
6+
7+
### PR #116 — Torch LogisticRegressionCV strict-CUDA repair
8+
9+
- Fixed the mixed-precision Torch strict-CUDA `LogisticRegressionCV` failure by allocating batched IRLS parameters and ridge diagonals in the active CV working dtype and keeping candidate path outputs backend-native through validation scoring.
10+
- Added maintained regression coverage for float32/float64 CV, weighted and unweighted fitting, intercept/no-intercept paths, and the full CV selector, plus a Python 3.9 + Torch 2.0 CPU CI gate so optional-Torch coverage cannot silently skip.
11+
- Validated the unchanged numerical implementation head `e6e4846b06604ed53e65fc9afd9054bd5777098f` on Tesla P100 with PyTorch 2.0.0+cu117/CUDA 11.7 and CuPy 13.6.0: all 18 statgpu canonical CV backend runs succeeded without CPU fallback, including `LogisticRegressionCV` on NumPy, CuPy, and Torch.
12+
- Retained the historical pre-fix P100 failure source unchanged and registered the exact-head post-fix source under `results/pr116_p100/`; focused physical validation evidence is retained separately from dashboard timing data.
13+
514
## 0.2.4 — 2026-08-06
615

716
### Logistic regression and GLM correctness

dev/benchmarks/RESULTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
GPU performance data for statgpu. Results are from remote GPU testing unless noted otherwise.
44

5+
## Current canonical CV repair evidence — PR #116
6+
7+
The exact numerical implementation head `e6e4846b06604ed53e65fc9afd9054bd5777098f` was physically validated on Tesla P100-SXM2-16GB with Python 3.9.16, PyTorch 2.0.0+cu117 / CUDA 11.7, and CuPy 13.6.0.
8+
9+
- focused Torch CUDA validation: 4/4 cases passed, covering mixed-precision float32, float64, analytic weights, and `fit_intercept=False`;
10+
- canonical six-family CV rerun: all 18 statgpu NumPy/CuPy/Torch rows succeeded, with zero failed candidates/folds and converged final refits;
11+
- `LogisticRegressionCV` selected `C=0.1` on NumPy, CuPy, Torch, and sklearn;
12+
- raw exact-head evidence is retained under `results/pr116_p100/`;
13+
- the historical pre-fix P100 failure source remains registered separately and is not rewritten.
14+
515
## Test Coverage (v23c — Latest)
616

717
**1043/1043 ALL PASS (100%)**

dev/benchmarks/benchmark_coverage_matrix.json

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,17 @@
201201
{
202202
"capability_id": "logistic-regression-cv",
203203
"label": "LogisticRegressionCV",
204-
"status": "partial_canonical",
204+
"status": "canonical_current",
205205
"source_ids": [
206-
"cv-benchmark-20260807-1347184c988d"
206+
"cv-benchmark-20260807-1347184c988d",
207+
"cv-benchmark-pr116-20260807-bd8d512adced"
207208
],
208-
"issue": "#112",
209-
"disposition": "Current canonical P100 evidence records NumPy and CuPy success and the explicit Torch strict-CUDA failure without CPU fallback. The production Torch defect is tracked by #112."
209+
"representative_dimensions": [
210+
"backend",
211+
"pre_fix_failure",
212+
"post_fix_success",
213+
"physical_gpu"
214+
]
210215
},
211216
{
212217
"capability_id": "penalized-glm-cv",

dev/benchmarks/benchmark_source_catalog.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@
119119
"issue": "#91",
120120
"reason": "Immutable P100 candidate retained for audit; its byte-identical SHA-protected canonical copy is registered in frontend_sources.json."
121121
},
122+
{
123+
"rule_id": "pr116-logistic-cv-focused-validation",
124+
"priority": 19,
125+
"path": "results/pr116_p100/focused_validation.json",
126+
"classification": "not_canonical_ready",
127+
"canonical_eligible": false,
128+
"provenance_status": "validation_evidence",
129+
"timing_protocol_status": "not_applicable",
130+
"statistical_alignment_status": "accepted",
131+
"issue": "#112",
132+
"reason": "Exact-head P100 focused correctness evidence for PR #116 is retained for audit but is intentionally not a dashboard timing source."
133+
},
122134
{
123135
"rule_id": "distribution-current-needs-audit",
124136
"priority": 30,

dev/benchmarks/frontend_sources.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
"gpu": "Tesla P100-SXM2-16GB",
1414
"cpu": "x86_64",
1515
"host": "wE6lDe"
16+
},
17+
"remote-p100-pr116-20260807": {
18+
"label": "Tesla P100 PR #116 CV validation — 2026-08-07",
19+
"gpu": "Tesla P100-SXM2-16GB",
20+
"cpu": "x86_64",
21+
"host": "wE6lDe"
1622
}
1723
},
1824
"frameworks": {
@@ -83,6 +89,10 @@
8389
"cv-benchmark-20260807": {
8490
"label": "Cross-validation benchmark — 2026-08-07",
8591
"env_id": "remote-p100-cv-20260807"
92+
},
93+
"cv-benchmark-pr116-20260807": {
94+
"label": "Cross-validation benchmark after PR #116 repair — 2026-08-07",
95+
"env_id": "remote-p100-pr116-20260807"
8696
}
8797
},
8898
"sources": [
@@ -205,6 +215,21 @@
205215
"measurement_git_sha": "ad2cf88d1d443a53eeb5207c33c4ee4f25de2400",
206216
"raw_git_sha": "unknown",
207217
"provenance_note": "The immutable P100 runner output could not resolve VCS metadata and therefore records git_sha=unknown. The rerun was executed from the ad2cf88 benchmark implementation tree; repository comparison confirms no file-level tree change across the intervening diagnostic add/remove commits."
218+
},
219+
{
220+
"source_id": "cv-benchmark-pr116-20260807-bd8d512adced",
221+
"comparison_id": "cv-benchmark-pr116-20260807",
222+
"path": "results/pr116_p100/cv_benchmark_pr116_p100.json",
223+
"sha256": "bd8d512adced442b066de20fb2c31f3f50b19f271d6d0de6bd0d82e9b4cd9be8",
224+
"parser": "cv_benchmark",
225+
"parser_version": "1.1",
226+
"env_id": "remote-p100-pr116-20260807",
227+
"required": true,
228+
"allowed_issue_codes": [],
229+
"source_date": "2026-08-07",
230+
"measurement_git_sha": "e6e4846b06604ed53e65fc9afd9054bd5777098f",
231+
"raw_git_sha": "e6e4846b06604ed53e65fc9afd9054bd5777098f",
232+
"provenance_note": "Physical PR #116 validation was executed on Tesla P100 from exact numerical implementation head e6e4846b06604ed53e65fc9afd9054bd5777098f. The artifact records that SHA directly; the historical pre-fix P100 source remains registered separately."
208233
}
209234
]
210235
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# LogisticRegressionCV Torch strict-CUDA repair plan
2+
3+
Issue: #112
4+
Baseline: `master` at `37f643a68ded71a33f8eea5ed8217aab42c650e1`
5+
Scope: correctness repair before the next roadmap feature package (#93)
6+
7+
## 1. Why this is the next stage
8+
9+
The benchmark/dashboard sequence through PR #115 is complete. The roadmap normally moves next to Panel P1 (#93), but its own priority rules put correctness and public-contract risk ahead of feature breadth. Canonical CV evidence currently records one maintained backend defect: `LogisticRegressionCV` fails on Torch strict CUDA while the corresponding NumPy, CuPy, and sklearn rows succeed. Therefore #112 is treated as a bounded correctness blocker before starting #93.
10+
11+
This change does not reopen dashboard performance work and does not add a new statistical feature.
12+
13+
## 2. Impact classification
14+
15+
Active gates:
16+
17+
- backend/device/dtype locality;
18+
- cross-validation;
19+
- numerical correctness/convergence;
20+
- tests and compatibility;
21+
- user-facing documentation/changelog;
22+
- physical-GPU validation.
23+
24+
Inactive gates:
25+
26+
- public constructor/API shape: no public arguments or return types should change;
27+
- formula semantics: no formula/model-matrix path changes are planned;
28+
- inference definition: final-refit inference contract is unchanged;
29+
- benchmark/performance claims: no speedup claim is planned.
30+
31+
Capability decision:
32+
33+
- backend: `three-backend` (existing capability, Torch path repaired);
34+
- CV: `supported`;
35+
- inference: `supported` for the final refit exactly as before;
36+
- formula: unchanged;
37+
- benchmark: correctness evidence required, performance claim not required.
38+
39+
## 3. Root cause established from current source
40+
41+
`_select_logistic_c_cv()` defaults to `gpu_cv_mixed_precision=True`, so the GPU CV working design is `float32`.
42+
43+
In `_solve_logistic_path_gpu_from_batch()` the current Torch path then creates:
44+
45+
- `params = backend.zeros(...)` without a dtype, and
46+
- `reg_diag = backend.full(...)` without a dtype.
47+
48+
`TorchBackend.zeros/full` default to `torch.float64`. The first matrix multiply therefore combines a `float32` design matrix with `float64` parameters. Torch requires matching matrix-multiplication dtypes, so strict Torch CUDA fails before completing the first candidate. CuPy is more permissive about mixed-dtype arithmetic, which explains the backend-specific disposition seen in the canonical benchmark.
49+
50+
The same helper also converts every fitted coefficient vector/intercept to NumPy inside the candidate/fold loop and then converts them back to the GPU backend for scoring. This does not cause the reported failure, but it is an unnecessary device round trip in the exact code being repaired and increases the risk of dtype drift.
51+
52+
A repository-wide call-site search found exactly one caller of `_solve_logistic_path_gpu_from_batch()`: `_select_logistic_c_cv()` in the same module. Therefore changing this private helper to keep its return batches backend-native is a bounded internal refactor, not a public API change. Both `TorchBackend` and `CuPyBackend` provide the required `stack`, `copy`, `zeros`, and dtype-aware array-creation primitives.
53+
54+
## 4. Implementation plan
55+
56+
### A. Make the batched IRLS working dtype explicit
57+
58+
In `statgpu/linear_model/cv/_logistic_cv.py`:
59+
60+
- create `params` with `dtype=X_design.dtype`;
61+
- create regularization diagonals with `dtype=XtWX.dtype`;
62+
- keep coefficient/intercept batches in backend-native arrays through scoring;
63+
- use backend-native stacking so the returned path batch preserves dtype and device;
64+
- only transfer the per-fold loss vector to NumPy when populating the public `loss_path` result;
65+
- preserve the current objective, intercept non-penalization rule, folds, C grid, sample-weight semantics, convergence tolerance, and strict no-CPU-fallback behavior.
66+
67+
No statistical definition changes are allowed in this repair.
68+
69+
### B. Add deterministic regression coverage that does not require hosted CUDA
70+
71+
Add focused tests using `TorchBackend(device="cpu")` to exercise the internal batched helper on both working-dtype modes:
72+
73+
- default mixed precision: `float32`;
74+
- `gpu_cv_mixed_precision=False` analogue: `float64`.
75+
76+
The tests should prove:
77+
78+
- the helper no longer raises a Float/Double dtype mismatch;
79+
- returned coefficient/intercept batches remain Torch tensors on the selected backend and keep the requested working dtype;
80+
- candidate outputs are finite and have the expected shapes;
81+
- the path solver itself performs no `backend.to_numpy()` conversion;
82+
- float32 and float64 paths agree to a documented mixed-precision tolerance on the same deterministic problem.
83+
84+
Add/extend public CV tests to preserve:
85+
86+
- CPU/CuPy/Torch selection semantics where the backend is available;
87+
- explicit-device no-fallback behavior;
88+
- sample-weight behavior;
89+
- deterministic candidate selection and transactional final refit.
90+
91+
The private return-type change is safe only while the single-caller invariant holds; the regression should exercise that consuming path rather than expose the helper publicly.
92+
93+
### C. Check compatibility and numerical parity
94+
95+
Run the strongest locally available evidence:
96+
97+
- targeted LogisticRegressionCV tests;
98+
- Torch CPU internal float32/float64 regressions;
99+
- maintenance/static tests covering CV backend routing;
100+
- full hosted CPU/compatibility workflows on the PR head.
101+
102+
Numerical checks should compare the same C grid/folds and ensure the repaired path does not alter the declared loss or selection semantics beyond normal mixed-precision tolerance.
103+
104+
### D. Documentation and issue state
105+
106+
Update the root/EN/CN changelogs with the repaired strict-Torch CV behavior once implementation is validated.
107+
108+
Do **not** edit the canonical `2026-08-07` benchmark source from `failed` to `success` without a new physical-GPU measurement. Historical measured evidence remains immutable.
109+
110+
Issue #112 should close only after a current-source physical Torch CUDA reproduction succeeds. If physical GPU access is unavailable in this run, finish with `PARTIAL_REMOTE_PENDING` and leave #112 open with the exact rerun command/evidence needed.
111+
112+
## 5. Physical-GPU acceptance
113+
114+
Required for `COMPLETE`:
115+
116+
- Torch CUDA on a real NVIDIA GPU, preferably the original Tesla P100 / Torch 2.0.x compatibility environment or an equally strict supported environment;
117+
- run the canonical CV reproduction for `LogisticRegressionCV` with default `gpu_cv_mixed_precision=True`;
118+
- also smoke the `gpu_cv_mixed_precision=False` path on the same source head;
119+
- confirm strict Torch execution succeeds without CPU fallback;
120+
- compare selected C / CV loss against NumPy or CuPy within a documented mixed-precision tolerance;
121+
- record software/hardware/source SHA and the exact command.
122+
123+
A fresh canonical benchmark artifact may be added only from an actual rerun. The old failed artifact remains historical evidence.
124+
125+
## 6. Plan review/fix closure criteria
126+
127+
Review the plan before implementation for:
128+
129+
1. whether the proposed fix changes any statistical definition;
130+
2. whether it accidentally weakens strict device semantics;
131+
3. whether it leaves another Torch mixed-dtype boundary in fit or scoring;
132+
4. whether tests can catch the original failure on CPU-only CI;
133+
5. whether both float32 and float64 working modes are covered;
134+
6. whether the private backend-native return change has any caller outside this module;
135+
7. whether the plan improperly rewrites historical benchmark evidence;
136+
8. whether the change should remain bounded to #112 rather than becoming a general CV refactor.
137+
138+
First plan review findings and fixes:
139+
140+
- **MEDIUM / TEST — fixed:** the initial plan tested only the default float32 path. It now requires both float32 and float64 working modes plus cross-dtype numerical parity.
141+
- **MEDIUM / MAINT — fixed:** the initial plan changed the private helper return representation without establishing call-site scope. Repository search confirms exactly one caller in the same module, and CuPy/Torch backend primitives support the bounded backend-native stacking change.
142+
143+
Implementation may begin only after a fresh re-review finds no new in-scope plan issue.
144+
145+
After implementation, run `.claude/skills/code-review.md` in auto-fix mode and repeat targeted validation/re-review until no new CRITICAL/HIGH or in-scope MEDIUM issue is found.
146+
147+
## 7. Non-goals
148+
149+
- no Panel #93 implementation in the same PR;
150+
- no general `_penalized_cv.py` decomposition;
151+
- no solver rewrite;
152+
- no new penalty, formula, or inference behavior;
153+
- no dashboard optimization or schema change;
154+
- no fabricated or reconstructed benchmark success row;
155+
- no performance claim without a new measured artifact.
156+
157+
## 8. Expected exit
158+
159+
- `COMPLETE` only if local/hosted gates and physical Torch CUDA validation all pass;
160+
- otherwise `PARTIAL_REMOTE_PENDING` is acceptable when the implementation/review is clean and only physical-GPU evidence is missing.

dev/tests/test_benchmark_catalog.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ def test_catalog_retains_distinct_noncanonical_dispositions(entries):
7676
assert distribution["classification"] == "not_canonical_ready"
7777
assert distribution["issue"] == "#101"
7878

79+
focused = next(
80+
entry for entry in entries
81+
if entry["path"] == "results/pr116_p100/focused_validation.json"
82+
)
83+
assert focused["classification"] == "not_canonical_ready"
84+
assert focused["provenance_status"] == "validation_evidence"
85+
assert focused["issue"] == "#112"
86+
7987

8088
def test_coverage_matrix_is_referentially_complete(coverage_matrix, manifest):
8189
from dev.benchmarks.frontend_data.catalog import validate_coverage_matrix
@@ -84,8 +92,11 @@ def test_coverage_matrix_is_referentially_complete(coverage_matrix, manifest):
8492
rows = {row["capability_id"]: row for row in coverage_matrix["capabilities"]}
8593
assert rows["ridge-cv"]["status"] == "canonical_current"
8694
assert rows["ridge-cv"]["source_ids"] == ["cv-benchmark-20260807-1347184c988d"]
87-
assert rows["logistic-regression-cv"]["status"] == "partial_canonical"
88-
assert rows["logistic-regression-cv"]["issue"] == "#112"
95+
assert rows["logistic-regression-cv"]["status"] == "canonical_current"
96+
assert rows["logistic-regression-cv"]["source_ids"] == [
97+
"cv-benchmark-20260807-1347184c988d",
98+
"cv-benchmark-pr116-20260807-bd8d512adced",
99+
]
89100
assert rows["distribution-api"]["issue"] == "#101"
90101
assert rows["feature-selection-knockoff"]["issue"] == "#103"
91102
assert rows["penalized-coxph"]["issue"] == "#107"
@@ -108,9 +119,9 @@ def test_inventory_v2_reconciles_literal_counts(
108119
assert inventory["inventory_version"] == "2.0"
109120
assert inventory["discovered_json_artifacts"] == len(entries)
110121
assert inventory["classified_candidate_sources"] == len(entries)
111-
assert inventory["registered_sources"] == len(manifest["sources"]) == 9
112-
assert inventory["available_registered_sources"] == 9
113-
assert inventory["parsed_registered_sources"] == 9
122+
assert inventory["registered_sources"] == len(manifest["sources"]) == 10
123+
assert inventory["available_registered_sources"] == 10
124+
assert inventory["parsed_registered_sources"] == 10
114125
assert inventory["eligible_sources"] == (
115126
inventory["registered_sources"]
116127
+ inventory["eligible_unregistered_sources"]

dev/tests/test_benchmark_frontend_data.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ class TestManifestMode:
159159
def test_manifest_loads_with_exact_current_sources(self, manifest):
160160
assert manifest is not None
161161
assert manifest["minimum_source_date"] == "2026-06-01"
162-
assert len(manifest["sources"]) == 9
162+
assert len(manifest["sources"]) == 10
163163
assert all(source.get("source_date") for source in manifest["sources"])
164164

165165
def test_canonical_generate(self, generator, manifest, results_dir):
@@ -174,11 +174,11 @@ def test_canonical_generate(self, generator, manifest, results_dir):
174174
assert output["frameworks"]
175175
assert output["comparisons"]
176176
assert output["meta"]["generation_id"]
177-
assert report["files_seen"] == 9
178-
assert report["files_parsed"] == 9
179-
assert inventory["registered_sources"] == 9
180-
assert inventory["available_sources"] == 9
181-
assert inventory["parsed_sources"] == 9
177+
assert report["files_seen"] == 10
178+
assert report["files_parsed"] == 10
179+
assert inventory["registered_sources"] == 10
180+
assert inventory["available_sources"] == 10
181+
assert inventory["parsed_sources"] == 10
182182
assert not any(
183183
run["source"]["source_id"].startswith("transitional:")
184184
for run in output["runs"]

0 commit comments

Comments
 (0)