firthmodels 0.7.2__tar.gz → 0.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {firthmodels-0.7.2 → firthmodels-0.8.1}/CITATION.cff +1 -1
  2. {firthmodels-0.7.2 → firthmodels-0.8.1}/PKG-INFO +5 -1
  3. {firthmodels-0.7.2 → firthmodels-0.8.1}/README.md +4 -0
  4. firthmodels-0.8.1/benchmarks/README.md +189 -0
  5. {firthmodels-0.7.2 → firthmodels-0.8.1}/benchmarks/benchmark_cox.py +114 -43
  6. {firthmodels-0.7.2 → firthmodels-0.8.1}/benchmarks/benchmark_logistic.py +113 -49
  7. firthmodels-0.8.1/benchmarks/cox_results.json +908 -0
  8. firthmodels-0.8.1/benchmarks/cox_results.png +0 -0
  9. firthmodels-0.8.1/benchmarks/generate_report.py +911 -0
  10. firthmodels-0.8.1/benchmarks/logistic_results.json +2890 -0
  11. firthmodels-0.8.1/benchmarks/logistic_results.png +0 -0
  12. firthmodels-0.8.1/benchmarks/run_benchmarks.sh +26 -0
  13. {firthmodels-0.7.2 → firthmodels-0.8.1}/pyproject.toml +1 -1
  14. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/_lrt.py +84 -17
  15. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/_numba/cox.py +66 -61
  16. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/_numba/logistic.py +30 -6
  17. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/_profile_ci.py +4 -1
  18. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/_solvers.py +20 -8
  19. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/_utils.py +2 -1
  20. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/adapters/statsmodels.py +4 -2
  21. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/cox.py +179 -109
  22. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/logistic.py +185 -47
  23. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/separation.py +25 -0
  24. {firthmodels-0.7.2 → firthmodels-0.8.1}/tests/test_cox.py +198 -3
  25. {firthmodels-0.7.2 → firthmodels-0.8.1}/tests/test_logistic.py +236 -3
  26. {firthmodels-0.7.2 → firthmodels-0.8.1}/tests/test_numba_cox.py +83 -2
  27. {firthmodels-0.7.2 → firthmodels-0.8.1}/tests/test_numba_logistic.py +120 -0
  28. {firthmodels-0.7.2 → firthmodels-0.8.1}/tests/test_separation.py +20 -0
  29. {firthmodels-0.7.2 → firthmodels-0.8.1}/uv.lock +4 -4
  30. firthmodels-0.7.2/benchmarks/README.md +0 -161
  31. firthmodels-0.7.2/benchmarks/cox_results.csv +0 -7
  32. firthmodels-0.7.2/benchmarks/cox_results.png +0 -0
  33. firthmodels-0.7.2/benchmarks/generate_report.py +0 -699
  34. firthmodels-0.7.2/benchmarks/logistic_results.csv +0 -11
  35. firthmodels-0.7.2/benchmarks/logistic_results.png +0 -0
  36. {firthmodels-0.7.2 → firthmodels-0.8.1}/.github/workflows/ci.yml +0 -0
  37. {firthmodels-0.7.2 → firthmodels-0.8.1}/.github/workflows/publish.yml +0 -0
  38. {firthmodels-0.7.2 → firthmodels-0.8.1}/.gitignore +0 -0
  39. {firthmodels-0.7.2 → firthmodels-0.8.1}/.pre-commit-config.yaml +0 -0
  40. {firthmodels-0.7.2 → firthmodels-0.8.1}/.python-version +0 -0
  41. {firthmodels-0.7.2 → firthmodels-0.8.1}/LICENSE +0 -0
  42. {firthmodels-0.7.2 → firthmodels-0.8.1}/benchmarks/brglm2_bench.R +0 -0
  43. {firthmodels-0.7.2 → firthmodels-0.8.1}/benchmarks/coxphf_bench.R +0 -0
  44. {firthmodels-0.7.2 → firthmodels-0.8.1}/benchmarks/logistf_bench.R +0 -0
  45. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/__init__.py +0 -0
  46. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/_numba/__init__.py +0 -0
  47. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/_numba/blas_abi.py +0 -0
  48. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/_numba/linalg.py +0 -0
  49. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/adapters/__init__.py +0 -0
  50. {firthmodels-0.7.2 → firthmodels-0.8.1}/src/firthmodels/py.typed +0 -0
  51. {firthmodels-0.7.2 → firthmodels-0.8.1}/tests/conftest.py +0 -0
  52. {firthmodels-0.7.2 → firthmodels-0.8.1}/tests/test_adapters_statsmodels.py +0 -0
  53. {firthmodels-0.7.2 → firthmodels-0.8.1}/tests/test_blas_abi.py +0 -0
  54. {firthmodels-0.7.2 → firthmodels-0.8.1}/tests/test_numba_linalg.py +0 -0
@@ -2,7 +2,7 @@ cff-version: 1.2.0
2
2
  title: firthmodels
3
3
  message: "If you use this software, please cite it as below."
4
4
  type: software
5
- version: 0.7.2
5
+ version: 0.8.1
6
6
  doi: 10.5281/zenodo.17863280
7
7
  authors:
8
8
  - family-names: Luo
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firthmodels
3
- Version: 0.7.2
3
+ Version: 0.8.1
4
4
  Summary: Firth-penalized models in Python
5
5
  Project-URL: Homepage, https://github.com/jzluo/firthmodels
6
6
  Project-URL: Repository, https://github.com/jzluo/firthmodels
@@ -228,6 +228,10 @@ model.lrt_bse_ # Back-corrected standard errors (separate from Wald bse_
228
228
  Each feature requires a separate constrained model fit, so you can test selectively to
229
229
  avoid unnecessary computation. By default, LRT uses a warm start based on the full-model
230
230
  covariance to reduce Newton-Raphson iterations; pass `warm_start=False` to disable it.
231
+ If the full-model fit does not converge, its likelihood is non-finite, or a constrained
232
+ fit does not converge, a warning is emitted and the affected p-values and
233
+ back-corrected standard errors remain `NaN`. A later explicit `lrt()` call retries
234
+ failed constrained fits.
231
235
 
232
236
  ```python
233
237
  model.lrt(0) # Single feature by index
@@ -185,6 +185,10 @@ model.lrt_bse_ # Back-corrected standard errors (separate from Wald bse_
185
185
  Each feature requires a separate constrained model fit, so you can test selectively to
186
186
  avoid unnecessary computation. By default, LRT uses a warm start based on the full-model
187
187
  covariance to reduce Newton-Raphson iterations; pass `warm_start=False` to disable it.
188
+ If the full-model fit does not converge, its likelihood is non-finite, or a constrained
189
+ fit does not converge, a warning is emitted and the affected p-values and
190
+ back-corrected standard errors remain `NaN`. A later explicit `lrt()` call retries
191
+ failed constrained fits.
188
192
 
189
193
  ```python
190
194
  model.lrt(0) # Single feature by index
@@ -0,0 +1,189 @@
1
+ # Benchmarks
2
+
3
+ Benchmarking of [firthmodels](https://github.com/jzluo/firthmodels) against implementations
4
+ of Firth-penalized logistic regression (R [logistf](https://cran.r-project.org/package=logistf),
5
+ [brglm2](https://cran.r-project.org/package=brglm2)) and Cox regression
6
+ (R [coxphf](https://cran.r-project.org/package=coxphf)).
7
+
8
+ ## Summary
9
+
10
+ For the full workflow, firthmodels' Numba backend is **10.8x
11
+ faster than logistf** (k=50) and **159x faster than
12
+ coxphf** (k=30). Without Numba, the pure NumPy backend is 7.5x
13
+ faster than logistf and 23.7x faster than coxphf.
14
+
15
+ | Workload | Baseline | Speedup at k=5 | Speedup at largest k |
16
+ |---|---|--:|--:|
17
+ | Logistic: fit + Wald | next-fastest package | 3.8x | 5.3x |
18
+ | Logistic: fit + LRT + profile CI | logistf | 2.7x | 10.8x |
19
+ | Cox: fit + Wald | coxphf | 11.4x | 132x |
20
+ | Cox: fit + LRT + profile CI | coxphf | 8.4x | 159x |
21
+
22
+ The largest observed deviation across coefficients, profile CI bounds, and p-values is 8.0e-07 (see [Correctness](#correctness)).
23
+
24
+ Python is timed with time.perf_counter() around each call, after JIT-warming the Numba
25
+ backend. R packages are timed in-process with `microbenchmark` inside a single
26
+ R session, so R startup and data transfer are excluded, while formula parsing and
27
+ model-frame construction are included because logistf and coxphf only
28
+ offer formula interfaces. brglm2 is run with `check_aliasing=FALSE` to avoid
29
+ extra overhead from its default aliasing check. The reported value is the fastest of 10 runs.
30
+
31
+ ---
32
+
33
+ ## Firth logistic regression
34
+
35
+ Compared against R [logistf](https://cran.r-project.org/package=logistf) and
36
+ [brglm2](https://cran.r-project.org/package=brglm2) on simulated data with
37
+ n = 1,000 observations, a 20% target event rate, and
38
+ k = 5 to 50 features.
39
+
40
+ ![Logistic benchmark scaling, log time axis](logistic_results.png)
41
+
42
+ The time axis is log-scale, so a constant vertical gap means a constant speedup ratio.
43
+ All values are the minimum observed wall-clock time across repeated runs, in milliseconds.
44
+
45
+ ### Fit + Wald inference
46
+
47
+ | k | firthmodels<br>(numba) | firthmodels<br>(numpy) | logistf | brglm2<br>(AS-mean) | brglm2<br>(MPL-Jeffreys) | numba speedup<br>vs next fastest | numpy speedup<br>vs next fastest |
48
+ |--:|--:|--:|--:|--:|--:|--:|--:|
49
+ | 5 | **0.50** | 0.87 | <ins>1.9</ins> | 4.0 | 4.0 | 3.8x | 2.2x |
50
+ | 10 | **0.72** | 1.1 | <ins>3.4</ins> | 4.6 | 4.6 | 4.7x | 3.0x |
51
+ | 15 | **0.75** | 1.2 | <ins>5.3</ins> | 5.4 | 5.4 | 7.1x | 4.4x |
52
+ | 20 | **1.0** | 1.5 | 7.8 | 6.3 | <ins>6.2</ins> | 6.1x | 4.1x |
53
+ | 25 | **1.5** | 2.2 | 11.4 | 7.8 | <ins>7.8</ins> | 5.4x | 3.6x |
54
+ | 30 | **1.6** | 2.3 | 14.7 | <ins>8.5</ins> | 8.7 | 5.2x | 3.7x |
55
+ | 35 | **1.7** | 2.6 | 19.9 | <ins>10.2</ins> | 10.2 | 5.8x | 4.0x |
56
+ | 40 | **2.2** | 3.1 | 24.9 | 11.6 | <ins>11.5</ins> | 5.2x | 3.8x |
57
+ | 45 | **2.7** | 3.7 | 33.2 | 13.1 | <ins>13.0</ins> | 4.8x | 3.5x |
58
+ | 50 | **3.2** | 4.3 | 40.8 | 17.0 | <ins>16.7</ins> | 5.3x | 3.9x |
59
+
60
+ The fastest time at each k is **bolded**. The next-fastest package (not including the firthmodels
61
+ NumPy backend) is <ins>underlined</ins> and is the baseline for the speedup columns. The two brglm2
62
+ fitting methods have nearly identical timings (within 1.9% at every k).
63
+
64
+ ### Full workflow: fit + LRT + profile likelihood CI
65
+
66
+ brglm2 is not included here because it does not provide penalized LRT
67
+ p-values or profile likelihood CIs.
68
+
69
+ | k | firthmodels<br>(numba) | firthmodels<br>(numpy) | logistf | numba speedup<br>vs logistf | numpy speedup<br>vs logistf |
70
+ |--:|--:|--:|--:|--:|--:|
71
+ | 5 | **4.5** | 10.6 | <ins>11.9</ins> | 2.7x | 1.1x |
72
+ | 10 | **11.5** | 23.7 | <ins>41.9</ins> | 3.6x | 1.8x |
73
+ | 15 | **18.2** | 38.1 | <ins>101.0</ins> | 5.6x | 2.7x |
74
+ | 20 | **34.9** | 61.7 | <ins>206.2</ins> | 5.9x | 3.3x |
75
+ | 25 | **66.4** | 117.1 | <ins>418.7</ins> | 6.3x | 3.6x |
76
+ | 30 | **92.9** | 150.3 | <ins>624.0</ins> | 6.7x | 4.2x |
77
+ | 35 | **111.8** | 190.2 | <ins>1,059</ins> | 9.5x | 5.6x |
78
+ | 40 | **166.4** | 261.9 | <ins>1,533</ins> | 9.2x | 5.9x |
79
+ | 45 | **213.9** | 326.7 | <ins>2,230</ins> | 10.4x | 6.8x |
80
+ | 50 | **290.9** | 420.3 | <ins>3,135</ins> | 10.8x | 7.5x |
81
+
82
+ ---
83
+
84
+ ## Firth Cox proportional hazards
85
+
86
+ Compared against R [coxphf](https://cran.r-project.org/package=coxphf) on
87
+ simulated survival data with n = 500 observations, a 20% event rate,
88
+ and k = 5 to 30 features.
89
+
90
+ ![Cox benchmark scaling, log time axis](cox_results.png)
91
+
92
+ The time axis is log-scale, so a constant vertical gap means a constant speedup ratio.
93
+ All values are the minimum observed wall-clock time across repeated runs, in milliseconds.
94
+
95
+ ### Fit + Wald inference
96
+
97
+ | k | firthmodels<br>(numba) | firthmodels<br>(numpy) | coxphf | numba speedup<br>vs coxphf | numpy speedup<br>vs coxphf |
98
+ |--:|--:|--:|--:|--:|--:|
99
+ | 5 | **0.29** | 1.4 | <ins>3.3</ins> | 11.4x | 2.3x |
100
+ | 10 | **0.40** | 2.2 | <ins>8.7</ins> | 21.9x | 4.0x |
101
+ | 15 | **0.57** | 3.2 | <ins>24.1</ins> | 42.5x | 7.5x |
102
+ | 20 | **0.85** | 5.6 | <ins>54.8</ins> | 64.1x | 9.8x |
103
+ | 25 | **1.2** | 7.7 | <ins>119.6</ins> | 99.8x | 15.5x |
104
+ | 30 | **1.6** | 10.3 | <ins>210.3</ins> | 132x | 20.5x |
105
+
106
+ The fastest time at each k is **bolded**. The next-fastest package (not including the firthmodels
107
+ NumPy backend) is <ins>underlined</ins> and is the baseline for the speedup columns.
108
+
109
+ ### Full workflow: fit + LRT + profile likelihood CI
110
+
111
+ | k | firthmodels<br>(numba) | firthmodels<br>(numpy) | coxphf | numba speedup<br>vs coxphf | numpy speedup<br>vs coxphf |
112
+ |--:|--:|--:|--:|--:|--:|
113
+ | 5 | **2.1** | 11.9 | <ins>17.4</ins> | 8.4x | 1.5x |
114
+ | 10 | **6.8** | 43.6 | <ins>160.0</ins> | 23.4x | 3.7x |
115
+ | 15 | **17.2** | 111.3 | <ins>912.8</ins> | 53.0x | 8.2x |
116
+ | 20 | **31.4** | 221.8 | <ins>2,516</ins> | 80.2x | 11.3x |
117
+ | 25 | **58.5** | 398.4 | <ins>7,281</ins> | 124x | 18.3x |
118
+ | 30 | **97.1** | 651.4 | <ins>15,425</ins> | 159x | 23.7x |
119
+
120
+ ---
121
+
122
+ ## Correctness
123
+
124
+ The benchmark scripts abort if firthmodels disagrees with the R reference by more than
125
+ 1e-06 on coefficients, profile CI bounds, or p-values (and also cross-check
126
+ the numba backend against the numpy backend).
127
+
128
+ Maximum over all coefficients and all k during the benchmark run (recorded in the results file's run metadata):
129
+
130
+ | Comparison | Quantity | Max abs. deviation |
131
+ |---|---|--:|
132
+ | Logistic fit vs logistf | Coefficients | 2.2e-16 |
133
+ | Logistic fit vs brglm2 AS_mean | Coefficients | 7.3e-07 |
134
+ | Logistic fit vs brglm2 MPL_Jeffreys | Coefficients | 7.3e-07 |
135
+ | Logistic full vs logistf | Coefficients | 2.2e-16 |
136
+ | Logistic full vs logistf | Profile CI bounds | 5.3e-13 |
137
+ | Logistic full vs logistf | LRT p-values | 2.7e-10 |
138
+ | Logistic numba vs numpy | Coefficients | 2.2e-16 |
139
+ | Cox fit vs coxphf | Coefficients | 8.0e-07 |
140
+ | Cox full vs coxphf | Coefficients | 8.0e-07 |
141
+ | Cox full vs coxphf | Profile CI bounds | 4.3e-08 |
142
+ | Cox full vs coxphf | LRT p-values | 2.5e-08 |
143
+ | Cox numba vs numpy | Coefficients | 3.9e-16 |
144
+
145
+ ---
146
+
147
+ ## Environment
148
+
149
+ Collected at report-generation time on the benchmark machine.
150
+
151
+ | Component | Version |
152
+ |---|---|
153
+ | OS | CachyOS |
154
+ | CPU | AMD Ryzen 5 5600X 6-Core Processor |
155
+ | Python | 3.12.13 |
156
+ | firthmodels | 0.8.1 |
157
+ | NumPy / SciPy / Numba | 2.3.5 / 1.16.3 / 0.64.0 |
158
+ | NumPy BLAS | /usr/lib/libopenblas.so.0.3 (openblas 0.3.34, runtime) |
159
+ | R | 4.6.1 |
160
+ | logistf / brglm2 / coxphf | 1.26.1 / 1.1.0 / 1.13.4 |
161
+ | R BLAS | /usr/lib/libopenblas.so.0.3 |
162
+
163
+ NumPy and R link to the same BLAS library (`/usr/lib/libopenblas.so.0.3`). BLAS threading is left at library defaults for both stacks.
164
+ R packages are compiled from source with `-march=x86-64-v3 -mtune=haswell -O3 -flto=auto`,
165
+ so the R timings are not slowed by a conservative build.
166
+
167
+ <details>
168
+ <summary>Full R package compile flags (current configuration)</summary>
169
+
170
+ ```
171
+ CFLAGS: -march=x86-64-v3 -mtune=haswell -O3 -pipe -fno-plt -fexceptions -Wp,-D_FORTIFY_SOURCE=3 -Wformat -Werror=format-security -fstack-clash-protection -fcf-protection -mpclmul -g1 -ffile-prefix-map=/startdir/src=/usr/src/debug/r -flto=auto -ffat-lto-objects
172
+ FCFLAGS: -O3 -march=x86-64-v3 -mtune=haswell
173
+ ```
174
+
175
+ </details>
176
+
177
+ ---
178
+
179
+ ## Reproducing these results
180
+
181
+ Requires R with the logistf, brglm2, coxphf, survival, microbenchmark, and
182
+ jsonlite packages installed.
183
+
184
+ ```bash
185
+ # Run both benchmarks (writes results JSON files holding the timings, R
186
+ # reference values, and run metadata), then regenerate the plots and this
187
+ # README
188
+ benchmarks/run_benchmarks.sh
189
+ ```
@@ -15,6 +15,7 @@ import subprocess
15
15
  import sys
16
16
  import tempfile
17
17
  import time
18
+ from datetime import datetime, timezone
18
19
  from pathlib import Path
19
20
  from typing import Literal
20
21
 
@@ -29,7 +30,7 @@ from firthmodels import FirthCoxPH
29
30
  N_SAMPLES = 500
30
31
  EVENT_RATE = 0.20
31
32
  K_VALUES = list(range(5, 35, 5))
32
- N_RUNS = 15
33
+ N_RUNS = 10
33
34
  BASE_SEED = 0
34
35
 
35
36
  # Solver parameters
@@ -43,8 +44,8 @@ COEF_TOL = 1e-6
43
44
  CI_TOL = 1e-6
44
45
  PVAL_TOL = 1e-6
45
46
 
46
- # Reduce coxphf benchmark runs for large k
47
- COXPHF_REDUCE_AFTER = 5
47
+ # Reduce coxphf benchmark runs for large k (0 disables reduction)
48
+ COXPHF_REDUCE_AFTER = 0
48
49
 
49
50
 
50
51
  # -----------------------------------------------------------------------------
@@ -92,7 +93,7 @@ def make_benchmark_data(
92
93
  T = -np.log(U) / (baseline_hazard * np.exp(eta))
93
94
 
94
95
  # Censoring to achieve target event rate
95
- censor_quantile = 1 - event_rate
96
+ censor_quantile = event_rate
96
97
  censor_time = np.quantile(T, censor_quantile)
97
98
 
98
99
  time = np.minimum(T, censor_time)
@@ -279,26 +280,41 @@ def check_agreement(
279
280
  r_ci: np.ndarray | None = None,
280
281
  py_pval: np.ndarray | None = None,
281
282
  r_pval: np.ndarray | None = None,
283
+ deviations: dict[str, dict[str, float]] | None = None,
282
284
  ) -> None:
283
- """Check that Python and R results agree within tolerance."""
284
- # Check coefficients
285
+ """Check that Python and R results agree within tolerance.
286
+
287
+ If a deviations dict is provided, the observed max absolute differences
288
+ are merged into it under label, keyed by quantity, taking the max over
289
+ successive calls (i.e. over k). Recorded before any tolerance failure is
290
+ raised.
291
+ """
292
+ observed: dict[str, float] = {}
293
+
285
294
  coef_diff = np.max(np.abs(py_coef - r_coef))
286
- if coef_diff > COEF_TOL:
287
- raise AssertionError(f"k={k} ({label}): coef diff {coef_diff:.2e} > {COEF_TOL}")
295
+ observed["coef"] = float(coef_diff)
288
296
 
289
- # Check CIs if provided
290
297
  if py_ci is not None and r_ci is not None:
291
- ci_diff = np.max(np.abs(py_ci - r_ci))
292
- if ci_diff > CI_TOL:
293
- raise AssertionError(f"k={k} ({label}): CI diff {ci_diff:.2e} > {CI_TOL}")
298
+ observed["ci"] = float(np.max(np.abs(py_ci - r_ci)))
294
299
 
295
- # Check p-values if provided
296
300
  if py_pval is not None and r_pval is not None:
297
- pval_diff = np.max(np.abs(py_pval - r_pval))
298
- if pval_diff > PVAL_TOL:
299
- raise AssertionError(
300
- f"k={k} ({label}): p-value diff {pval_diff:.2e} > {PVAL_TOL}"
301
- )
301
+ observed["pval"] = float(np.max(np.abs(py_pval - r_pval)))
302
+
303
+ if deviations is not None:
304
+ bucket = deviations.setdefault(label, {})
305
+ for quantity, value in observed.items():
306
+ bucket[quantity] = max(bucket.get(quantity, 0.0), value)
307
+
308
+ if coef_diff > COEF_TOL:
309
+ raise AssertionError(f"k={k} ({label}): coef diff {coef_diff:.2e} > {COEF_TOL}")
310
+ if observed.get("ci", 0.0) > CI_TOL:
311
+ raise AssertionError(
312
+ f"k={k} ({label}): CI diff {observed['ci']:.2e} > {CI_TOL}"
313
+ )
314
+ if observed.get("pval", 0.0) > PVAL_TOL:
315
+ raise AssertionError(
316
+ f"k={k} ({label}): p-value diff {observed['pval']:.2e} > {PVAL_TOL}"
317
+ )
302
318
 
303
319
 
304
320
  # -----------------------------------------------------------------------------
@@ -426,17 +442,17 @@ def compute_min(times: np.ndarray) -> float:
426
442
  # Saved results loading
427
443
  # -----------------------------------------------------------------------------
428
444
  def load_saved_results(
429
- saved_csv: str,
445
+ saved_path: str,
430
446
  k_values: list[int],
431
447
  load_firthmodels: bool = False,
432
448
  load_coxphf: bool = True,
433
449
  ) -> tuple[pd.DataFrame, dict[int, dict] | None]:
434
- """Load results from a saved CSV file.
450
+ """Load results from a saved results JSON file.
435
451
 
436
452
  Parameters
437
453
  ----------
438
- saved_csv : str
439
- Path to CSV with previous results.
454
+ saved_path : str
455
+ Path to results JSON with previous results.
440
456
  k_values : list of int
441
457
  Which k values to load.
442
458
  load_firthmodels : bool
@@ -451,7 +467,8 @@ def load_saved_results(
451
467
  coxphf_results : dict or None
452
468
  coxphf results if load_coxphf=True, else None.
453
469
  """
454
- saved_df = pd.read_csv(saved_csv)
470
+ with open(saved_path) as f:
471
+ saved_df = pd.DataFrame(json.load(f)["results"])
455
472
 
456
473
  # Determine required columns based on what we're loading
457
474
  required_cols = {"k", "n"}
@@ -473,20 +490,21 @@ def load_saved_results(
473
490
 
474
491
  missing_cols = required_cols - set(saved_df.columns)
475
492
  if missing_cols:
476
- raise ValueError(f"Saved CSV missing columns: {missing_cols}")
493
+ raise ValueError(f"Saved results missing columns: {missing_cols}")
477
494
 
478
495
  saved_n = int(saved_df["n"].iloc[0])
479
496
  if saved_n != N_SAMPLES:
480
497
  raise ValueError(
481
- f"Saved CSV was created with n={saved_n}, but current N_SAMPLES={N_SAMPLES}."
498
+ f"Saved results were created with n={saved_n}, "
499
+ f"but current N_SAMPLES={N_SAMPLES}."
482
500
  )
483
501
 
484
502
  saved_k = set(saved_df["k"].tolist())
485
503
  missing = set(k_values) - saved_k
486
504
  if missing:
487
- raise ValueError(f"k values {missing} not in saved CSV")
505
+ raise ValueError(f"k values {missing} not in saved results")
488
506
 
489
- # No need to parse JSON - we copy strings directly from saved_df rows
507
+ # Reference values are copied verbatim from saved_df rows, so no parsing
490
508
  return saved_df, None
491
509
 
492
510
 
@@ -501,8 +519,13 @@ def run_benchmarks(
501
519
  saved: str | None = None,
502
520
  run_firthmodels: bool = True,
503
521
  run_coxphf: bool = True,
504
- ) -> tuple[pd.DataFrame, dict[str, str]]:
505
- """Run all benchmarks and return results as DataFrame."""
522
+ ) -> tuple[pd.DataFrame, dict[str, str], dict]:
523
+ """Run all benchmarks and return results as DataFrame.
524
+
525
+ Also returns version_info and run_meta (run counts, verification status
526
+ and observed deviations, versions; saved into the results JSON by
527
+ main()).
528
+ """
506
529
  version_info = get_python_version_info()
507
530
 
508
531
  # Load saved results for libraries we're not running
@@ -580,7 +603,7 @@ def run_benchmarks(
580
603
  if coxphf_results is None:
581
604
  coxphf_results = {}
582
605
  if run_coxphf:
583
- if coxphf_reduce_after is not None:
606
+ if coxphf_reduce_after:
584
607
  k_low = [k for k in k_values if k <= coxphf_reduce_after]
585
608
  k_high = [k for k in k_values if k > coxphf_reduce_after]
586
609
 
@@ -612,6 +635,7 @@ def run_benchmarks(
612
635
  "Verifying results and computing statistics...", file=sys.stderr, flush=True
613
636
  )
614
637
  results = []
638
+ deviations: dict[str, dict[str, float]] = {}
615
639
 
616
640
  for k in k_values:
617
641
  X, surv_time, event = make_benchmark_data(
@@ -622,21 +646,32 @@ def run_benchmarks(
622
646
  if run_firthmodels and run_coxphf and not skip_verification:
623
647
  py = py_results["numba"][k]
624
648
  r = coxphf_results[k]
625
- check_agreement(k, py["fit_coef"], r["fit_coef"], "fit")
649
+ check_agreement(
650
+ k,
651
+ py["fit_coef"],
652
+ r["fit_coef"],
653
+ "fit vs coxphf",
654
+ deviations=deviations,
655
+ )
626
656
  check_agreement(
627
657
  k,
628
658
  py["full_coef"],
629
659
  r["full_coef"],
630
- "full",
660
+ "full vs coxphf",
631
661
  py["full_ci"],
632
662
  r["full_ci"],
633
663
  py["full_pval"],
634
664
  r["full_pval"],
665
+ deviations=deviations,
635
666
  )
636
667
  # Also check numba vs numpy
637
668
  py_numpy = py_results["numpy"][k]
638
669
  check_agreement(
639
- k, py["fit_coef"], py_numpy["fit_coef"], "numba vs numpy"
670
+ k,
671
+ py["fit_coef"],
672
+ py_numpy["fit_coef"],
673
+ "numba vs numpy",
674
+ deviations=deviations,
640
675
  )
641
676
 
642
677
  # Collect timing results
@@ -673,10 +708,10 @@ def run_benchmarks(
673
708
  row["coxphf_fit_ms"] = compute_min(r["fit_times"]) * 1000
674
709
  row["coxphf_full_ms"] = compute_min(r["full_times"]) * 1000
675
710
  # Store for reproducibility
676
- row["coxphf_fit_coef"] = json.dumps(r["fit_coef"].tolist())
677
- row["coxphf_full_coef"] = json.dumps(r["full_coef"].tolist())
678
- row["coxphf_full_ci"] = json.dumps(r["full_ci"].tolist())
679
- row["coxphf_full_pval"] = json.dumps(r["full_pval"].tolist())
711
+ row["coxphf_fit_coef"] = np.asarray(r["fit_coef"]).tolist()
712
+ row["coxphf_full_coef"] = np.asarray(r["full_coef"]).tolist()
713
+ row["coxphf_full_ci"] = np.asarray(r["full_ci"]).tolist()
714
+ row["coxphf_full_pval"] = np.asarray(r["full_pval"]).tolist()
680
715
  else:
681
716
  assert saved_row is not None
682
717
  row["coxphf_fit_ms"] = saved_row["coxphf_fit_ms"]
@@ -688,7 +723,39 @@ def run_benchmarks(
688
723
 
689
724
  results.append(row)
690
725
 
691
- return pd.DataFrame(results), version_info
726
+ reduced = n_runs if n_runs <= 3 else max(3, n_runs // 3)
727
+ run_meta = {
728
+ "schema_version": 1,
729
+ "model": "cox",
730
+ "created_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
731
+ "n_runs": n_runs,
732
+ "r_reduced_runs": (
733
+ {
734
+ "libraries": ["coxphf"],
735
+ "for_k_above": coxphf_reduce_after,
736
+ "n_runs": reduced,
737
+ }
738
+ if coxphf_reduce_after
739
+ else None
740
+ ),
741
+ "k_values": list(k_values),
742
+ "libraries_run": [
743
+ name
744
+ for name, flag in [
745
+ ("firthmodels", run_firthmodels),
746
+ ("coxphf", run_coxphf),
747
+ ]
748
+ if flag
749
+ ],
750
+ "loaded_from_saved": saved,
751
+ "verification": {
752
+ "performed": bool(deviations),
753
+ "tolerances": {"coef": COEF_TOL, "ci": CI_TOL, "pval": PVAL_TOL},
754
+ "max_abs_deviations": deviations,
755
+ },
756
+ "versions": version_info,
757
+ }
758
+ return pd.DataFrame(results), version_info, run_meta
692
759
 
693
760
 
694
761
  def print_table(df: pd.DataFrame) -> None:
@@ -743,14 +810,14 @@ def main():
743
810
  "--out",
744
811
  type=str,
745
812
  default=None,
746
- help="Save results to CSV file",
813
+ help="Save results (timings, R references, run metadata) to JSON file",
747
814
  )
748
815
  parser.add_argument(
749
816
  "--saved",
750
817
  type=str,
751
818
  default=None,
752
- metavar="CSV",
753
- help="Load non-selected libraries from this CSV.",
819
+ metavar="JSON",
820
+ help="Load non-selected libraries from this results JSON.",
754
821
  )
755
822
  parser.add_argument(
756
823
  "--firthmodels",
@@ -807,7 +874,7 @@ def main():
807
874
  )
808
875
 
809
876
  # Run benchmarks
810
- df, version_info = run_benchmarks(
877
+ df, version_info, run_meta = run_benchmarks(
811
878
  k_values=k_values,
812
879
  n_runs=args.n_runs,
813
880
  skip_verification=args.skip_verification,
@@ -821,7 +888,11 @@ def main():
821
888
  print_table(df)
822
889
 
823
890
  if args.out:
824
- df.to_csv(args.out, index=False)
891
+ with open(args.out, "w") as f:
892
+ json.dump(
893
+ {"meta": run_meta, "results": df.to_dict(orient="records")}, f, indent=2
894
+ )
895
+ f.write("\n")
825
896
  print(f"\nResults saved to {args.out}", file=sys.stderr)
826
897
 
827
898