firthmodels 0.8.0__tar.gz → 0.8.2__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.8.0 → firthmodels-0.8.2}/.pre-commit-config.yaml +1 -1
  2. {firthmodels-0.8.0 → firthmodels-0.8.2}/CITATION.cff +1 -1
  3. {firthmodels-0.8.0 → firthmodels-0.8.2}/PKG-INFO +2 -2
  4. firthmodels-0.8.2/benchmarks/README.md +191 -0
  5. {firthmodels-0.8.0 → firthmodels-0.8.2}/benchmarks/benchmark_cox.py +113 -43
  6. {firthmodels-0.8.0 → firthmodels-0.8.2}/benchmarks/benchmark_logistic.py +113 -49
  7. firthmodels-0.8.2/benchmarks/cox_results.json +908 -0
  8. firthmodels-0.8.2/benchmarks/cox_results.png +0 -0
  9. firthmodels-0.8.2/benchmarks/generate_report.py +908 -0
  10. firthmodels-0.8.2/benchmarks/logistic_results.json +2890 -0
  11. firthmodels-0.8.2/benchmarks/logistic_results.png +0 -0
  12. firthmodels-0.8.2/benchmarks/run_benchmarks.sh +26 -0
  13. {firthmodels-0.8.0 → firthmodels-0.8.2}/pyproject.toml +2 -2
  14. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/__init__.py +1 -1
  15. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/_numba/cox.py +75 -66
  16. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/_numba/linalg.py +23 -0
  17. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/_numba/logistic.py +37 -13
  18. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/_solvers.py +12 -1
  19. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/_utils.py +6 -0
  20. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/adapters/statsmodels.py +1 -2
  21. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/cox.py +150 -88
  22. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/logistic.py +5 -3
  23. {firthmodels-0.8.0 → firthmodels-0.8.2}/tests/test_adapters_statsmodels.py +2 -2
  24. {firthmodels-0.8.0 → firthmodels-0.8.2}/tests/test_cox.py +79 -0
  25. {firthmodels-0.8.0 → firthmodels-0.8.2}/tests/test_logistic.py +23 -0
  26. {firthmodels-0.8.0 → firthmodels-0.8.2}/tests/test_numba_cox.py +42 -9
  27. {firthmodels-0.8.0 → firthmodels-0.8.2}/tests/test_numba_linalg.py +1 -3
  28. {firthmodels-0.8.0 → firthmodels-0.8.2}/tests/test_numba_logistic.py +23 -6
  29. {firthmodels-0.8.0 → firthmodels-0.8.2}/uv.lock +4 -4
  30. firthmodels-0.8.0/benchmarks/README.md +0 -161
  31. firthmodels-0.8.0/benchmarks/cox_results.csv +0 -7
  32. firthmodels-0.8.0/benchmarks/cox_results.png +0 -0
  33. firthmodels-0.8.0/benchmarks/generate_report.py +0 -699
  34. firthmodels-0.8.0/benchmarks/logistic_results.csv +0 -11
  35. firthmodels-0.8.0/benchmarks/logistic_results.png +0 -0
  36. {firthmodels-0.8.0 → firthmodels-0.8.2}/.github/workflows/ci.yml +0 -0
  37. {firthmodels-0.8.0 → firthmodels-0.8.2}/.github/workflows/publish.yml +0 -0
  38. {firthmodels-0.8.0 → firthmodels-0.8.2}/.gitignore +0 -0
  39. {firthmodels-0.8.0 → firthmodels-0.8.2}/.python-version +0 -0
  40. {firthmodels-0.8.0 → firthmodels-0.8.2}/LICENSE +0 -0
  41. {firthmodels-0.8.0 → firthmodels-0.8.2}/README.md +0 -0
  42. {firthmodels-0.8.0 → firthmodels-0.8.2}/benchmarks/brglm2_bench.R +0 -0
  43. {firthmodels-0.8.0 → firthmodels-0.8.2}/benchmarks/coxphf_bench.R +0 -0
  44. {firthmodels-0.8.0 → firthmodels-0.8.2}/benchmarks/logistf_bench.R +0 -0
  45. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/_lrt.py +0 -0
  46. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/_numba/__init__.py +0 -0
  47. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/_numba/blas_abi.py +0 -0
  48. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/_profile_ci.py +0 -0
  49. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/adapters/__init__.py +0 -0
  50. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/py.typed +0 -0
  51. {firthmodels-0.8.0 → firthmodels-0.8.2}/src/firthmodels/separation.py +0 -0
  52. {firthmodels-0.8.0 → firthmodels-0.8.2}/tests/conftest.py +0 -0
  53. {firthmodels-0.8.0 → firthmodels-0.8.2}/tests/test_blas_abi.py +0 -0
  54. {firthmodels-0.8.0 → firthmodels-0.8.2}/tests/test_separation.py +0 -0
@@ -7,7 +7,7 @@ repos:
7
7
  - id: trailing-whitespace
8
8
  - repo: https://github.com/astral-sh/ruff-pre-commit
9
9
  # Ruff version.
10
- rev: v0.14.7
10
+ rev: v0.14.8
11
11
  hooks:
12
12
  # Run the linter.
13
13
  - id: ruff-check
@@ -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.8.0
5
+ version: 0.8.2
6
6
  doi: 10.5281/zenodo.17863280
7
7
  authors:
8
8
  - family-names: Luo
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: firthmodels
3
- Version: 0.8.0
3
+ Version: 0.8.2
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
@@ -0,0 +1,191 @@
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.5x
11
+ faster than logistf** (k=50) and **158x faster than
12
+ coxphf** (k=30). Without Numba, the pure NumPy backend is 7.3x
13
+ faster than logistf and 23.4x faster than coxphf.
14
+
15
+ | Workload | Baseline | Speedup at k=5 | Speedup at largest k |
16
+ |---|---|--:|--:|
17
+ | Logistic: fit + Wald | next-fastest package | 3.7x | 5.2x |
18
+ | Logistic: fit + LRT + profile CI | logistf | 2.5x | 10.5x |
19
+ | Cox: fit + Wald | coxphf | 11.2x | 130x |
20
+ | Cox: fit + LRT + profile CI | coxphf | 8.1x | 158x |
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.53** | 0.92 | <ins>2.0</ins> | 4.1 | 4.1 | 3.7x | 2.1x |
50
+ | 10 | **0.74** | 1.2 | <ins>3.5</ins> | 4.8 | 4.8 | 4.7x | 2.8x |
51
+ | 15 | **0.77** | 1.3 | <ins>5.4</ins> | 5.6 | 5.5 | 7.0x | 4.2x |
52
+ | 20 | **1.1** | 1.6 | 8.0 | 6.4 | <ins>6.4</ins> | 6.1x | 4.0x |
53
+ | 25 | **1.5** | 2.3 | 11.7 | 8.1 | <ins>8.0</ins> | 5.4x | 3.6x |
54
+ | 30 | **1.7** | 2.5 | 15.1 | <ins>8.8</ins> | 8.9 | 5.3x | 3.5x |
55
+ | 35 | **1.8** | 2.8 | 20.3 | 10.5 | <ins>10.5</ins> | 5.9x | 3.7x |
56
+ | 40 | **2.1** | 3.2 | 25.3 | 11.9 | <ins>11.8</ins> | 5.5x | 3.7x |
57
+ | 45 | **2.8** | 3.8 | 33.4 | 13.3 | <ins>13.2</ins> | 4.8x | 3.5x |
58
+ | 50 | **3.3** | 4.4 | 41.5 | 17.3 | <ins>17.1</ins> | 5.2x | 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.1% 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.8** | 11.3 | <ins>12.0</ins> | 2.5x | 1.1x |
72
+ | 10 | **11.9** | 25.3 | <ins>42.4</ins> | 3.5x | 1.7x |
73
+ | 15 | **19.1** | 40.3 | <ins>102.8</ins> | 5.4x | 2.6x |
74
+ | 20 | **36.4** | 64.6 | <ins>209.1</ins> | 5.7x | 3.2x |
75
+ | 25 | **68.5** | 120.3 | <ins>418.9</ins> | 6.1x | 3.5x |
76
+ | 30 | **95.4** | 165.7 | <ins>629.5</ins> | 6.6x | 3.8x |
77
+ | 35 | **115.4** | 208.8 | <ins>1,054</ins> | 9.1x | 5.0x |
78
+ | 40 | **162.8** | 269.2 | <ins>1,531</ins> | 9.4x | 5.7x |
79
+ | 45 | **222.1** | 334.8 | <ins>2,226</ins> | 10.0x | 6.6x |
80
+ | 50 | **299.2** | 429.5 | <ins>3,128</ins> | 10.5x | 7.3x |
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.30** | 1.5 | <ins>3.4</ins> | 11.2x | 2.2x |
100
+ | 10 | **0.41** | 2.3 | <ins>9.0</ins> | 22.0x | 4.0x |
101
+ | 15 | **0.57** | 3.3 | <ins>24.9</ins> | 43.4x | 7.5x |
102
+ | 20 | **0.86** | 5.7 | <ins>56.1</ins> | 65.4x | 9.8x |
103
+ | 25 | **1.2** | 7.9 | <ins>122.2</ins> | 98.7x | 15.6x |
104
+ | 30 | **1.6** | 10.5 | <ins>211.6</ins> | 130x | 20.2x |
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.2** | 12.8 | <ins>17.9</ins> | 8.1x | 1.4x |
114
+ | 10 | **7.1** | 45.6 | <ins>163.0</ins> | 22.9x | 3.6x |
115
+ | 15 | **17.4** | 114.1 | <ins>928.5</ins> | 53.4x | 8.1x |
116
+ | 20 | **30.8** | 227.2 | <ins>2,547</ins> | 82.6x | 11.2x |
117
+ | 25 | **59.4** | 404.1 | <ins>7,418</ins> | 125x | 18.4x |
118
+ | 30 | **98.1** | 660.6 | <ins>15,467</ins> | 158x | 23.4x |
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.4e-13 |
137
+ | Logistic full vs logistf | LRT p-values | 4.9e-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.6e-08 |
143
+ | Cox numba vs numpy | Coefficients | 2.8e-16 |
144
+
145
+ Note: the benchmark run(s) recorded firthmodels 0.8.1, while the currently installed version is 0.8.2. Timings describe the recorded version.
146
+
147
+ ---
148
+
149
+ ## Environment
150
+
151
+ Collected at report-generation time on the benchmark machine.
152
+
153
+ | Component | Version |
154
+ |---|---|
155
+ | OS | CachyOS |
156
+ | CPU | AMD Ryzen 5 5600X 6-Core Processor |
157
+ | Python | 3.12.13 |
158
+ | firthmodels | 0.8.2 |
159
+ | NumPy / SciPy / Numba | 2.3.5 / 1.16.3 / 0.64.0 |
160
+ | NumPy BLAS | /usr/lib/libopenblas.so.0.3 (openblas 0.3.34, runtime) |
161
+ | R | 4.6.1 |
162
+ | logistf / brglm2 / coxphf | 1.26.1 / 1.1.0 / 1.13.4 |
163
+ | R BLAS | /usr/lib/libopenblas.so.0.3 |
164
+
165
+ 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.
166
+ R packages are compiled from source with `-march=x86-64-v3 -mtune=haswell -O3 -flto=auto`,
167
+ so the R timings are not slowed by a conservative build.
168
+
169
+ <details>
170
+ <summary>Full R package compile flags (current configuration)</summary>
171
+
172
+ ```
173
+ 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
174
+ FCFLAGS: -O3 -march=x86-64-v3 -mtune=haswell
175
+ ```
176
+
177
+ </details>
178
+
179
+ ---
180
+
181
+ ## Reproducing these results
182
+
183
+ Requires R with the logistf, brglm2, coxphf, survival, microbenchmark, and
184
+ jsonlite packages installed.
185
+
186
+ ```bash
187
+ # Run both benchmarks (writes results JSON files holding the timings, R
188
+ # reference values, and run metadata), then regenerate the plots and this
189
+ # README
190
+ benchmarks/run_benchmarks.sh
191
+ ```
@@ -14,7 +14,7 @@ import platform as plat
14
14
  import subprocess
15
15
  import sys
16
16
  import tempfile
17
- import time
17
+ from datetime import datetime, timezone
18
18
  from pathlib import Path
19
19
  from typing import Literal
20
20
 
@@ -29,7 +29,7 @@ from firthmodels import FirthCoxPH
29
29
  N_SAMPLES = 500
30
30
  EVENT_RATE = 0.20
31
31
  K_VALUES = list(range(5, 35, 5))
32
- N_RUNS = 15
32
+ N_RUNS = 10
33
33
  BASE_SEED = 0
34
34
 
35
35
  # Solver parameters
@@ -43,8 +43,8 @@ COEF_TOL = 1e-6
43
43
  CI_TOL = 1e-6
44
44
  PVAL_TOL = 1e-6
45
45
 
46
- # Reduce coxphf benchmark runs for large k
47
- COXPHF_REDUCE_AFTER = 5
46
+ # Reduce coxphf benchmark runs for large k (0 disables reduction)
47
+ COXPHF_REDUCE_AFTER = 0
48
48
 
49
49
 
50
50
  # -----------------------------------------------------------------------------
@@ -279,26 +279,41 @@ def check_agreement(
279
279
  r_ci: np.ndarray | None = None,
280
280
  py_pval: np.ndarray | None = None,
281
281
  r_pval: np.ndarray | None = None,
282
+ deviations: dict[str, dict[str, float]] | None = None,
282
283
  ) -> None:
283
- """Check that Python and R results agree within tolerance."""
284
- # Check coefficients
284
+ """Check that Python and R results agree within tolerance.
285
+
286
+ If a deviations dict is provided, the observed max absolute differences
287
+ are merged into it under label, keyed by quantity, taking the max over
288
+ successive calls (i.e. over k). Recorded before any tolerance failure is
289
+ raised.
290
+ """
291
+ observed: dict[str, float] = {}
292
+
285
293
  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}")
294
+ observed["coef"] = float(coef_diff)
288
295
 
289
- # Check CIs if provided
290
296
  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}")
297
+ observed["ci"] = float(np.max(np.abs(py_ci - r_ci)))
294
298
 
295
- # Check p-values if provided
296
299
  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
- )
300
+ observed["pval"] = float(np.max(np.abs(py_pval - r_pval)))
301
+
302
+ if deviations is not None:
303
+ bucket = deviations.setdefault(label, {})
304
+ for quantity, value in observed.items():
305
+ bucket[quantity] = max(bucket.get(quantity, 0.0), value)
306
+
307
+ if coef_diff > COEF_TOL:
308
+ raise AssertionError(f"k={k} ({label}): coef diff {coef_diff:.2e} > {COEF_TOL}")
309
+ if observed.get("ci", 0.0) > CI_TOL:
310
+ raise AssertionError(
311
+ f"k={k} ({label}): CI diff {observed['ci']:.2e} > {CI_TOL}"
312
+ )
313
+ if observed.get("pval", 0.0) > PVAL_TOL:
314
+ raise AssertionError(
315
+ f"k={k} ({label}): p-value diff {observed['pval']:.2e} > {PVAL_TOL}"
316
+ )
302
317
 
303
318
 
304
319
  # -----------------------------------------------------------------------------
@@ -426,17 +441,17 @@ def compute_min(times: np.ndarray) -> float:
426
441
  # Saved results loading
427
442
  # -----------------------------------------------------------------------------
428
443
  def load_saved_results(
429
- saved_csv: str,
444
+ saved_path: str,
430
445
  k_values: list[int],
431
446
  load_firthmodels: bool = False,
432
447
  load_coxphf: bool = True,
433
448
  ) -> tuple[pd.DataFrame, dict[int, dict] | None]:
434
- """Load results from a saved CSV file.
449
+ """Load results from a saved results JSON file.
435
450
 
436
451
  Parameters
437
452
  ----------
438
- saved_csv : str
439
- Path to CSV with previous results.
453
+ saved_path : str
454
+ Path to results JSON with previous results.
440
455
  k_values : list of int
441
456
  Which k values to load.
442
457
  load_firthmodels : bool
@@ -451,7 +466,8 @@ def load_saved_results(
451
466
  coxphf_results : dict or None
452
467
  coxphf results if load_coxphf=True, else None.
453
468
  """
454
- saved_df = pd.read_csv(saved_csv)
469
+ with open(saved_path) as f:
470
+ saved_df = pd.DataFrame(json.load(f)["results"])
455
471
 
456
472
  # Determine required columns based on what we're loading
457
473
  required_cols = {"k", "n"}
@@ -473,20 +489,21 @@ def load_saved_results(
473
489
 
474
490
  missing_cols = required_cols - set(saved_df.columns)
475
491
  if missing_cols:
476
- raise ValueError(f"Saved CSV missing columns: {missing_cols}")
492
+ raise ValueError(f"Saved results missing columns: {missing_cols}")
477
493
 
478
494
  saved_n = int(saved_df["n"].iloc[0])
479
495
  if saved_n != N_SAMPLES:
480
496
  raise ValueError(
481
- f"Saved CSV was created with n={saved_n}, but current N_SAMPLES={N_SAMPLES}."
497
+ f"Saved results were created with n={saved_n}, "
498
+ f"but current N_SAMPLES={N_SAMPLES}."
482
499
  )
483
500
 
484
501
  saved_k = set(saved_df["k"].tolist())
485
502
  missing = set(k_values) - saved_k
486
503
  if missing:
487
- raise ValueError(f"k values {missing} not in saved CSV")
504
+ raise ValueError(f"k values {missing} not in saved results")
488
505
 
489
- # No need to parse JSON - we copy strings directly from saved_df rows
506
+ # Reference values are copied verbatim from saved_df rows, so no parsing
490
507
  return saved_df, None
491
508
 
492
509
 
@@ -501,8 +518,13 @@ def run_benchmarks(
501
518
  saved: str | None = None,
502
519
  run_firthmodels: bool = True,
503
520
  run_coxphf: bool = True,
504
- ) -> tuple[pd.DataFrame, dict[str, str]]:
505
- """Run all benchmarks and return results as DataFrame."""
521
+ ) -> tuple[pd.DataFrame, dict[str, str], dict]:
522
+ """Run all benchmarks and return results as DataFrame.
523
+
524
+ Also returns version_info and run_meta (run counts, verification status
525
+ and observed deviations, versions; saved into the results JSON by
526
+ main()).
527
+ """
506
528
  version_info = get_python_version_info()
507
529
 
508
530
  # Load saved results for libraries we're not running
@@ -580,7 +602,7 @@ def run_benchmarks(
580
602
  if coxphf_results is None:
581
603
  coxphf_results = {}
582
604
  if run_coxphf:
583
- if coxphf_reduce_after is not None:
605
+ if coxphf_reduce_after:
584
606
  k_low = [k for k in k_values if k <= coxphf_reduce_after]
585
607
  k_high = [k for k in k_values if k > coxphf_reduce_after]
586
608
 
@@ -612,6 +634,7 @@ def run_benchmarks(
612
634
  "Verifying results and computing statistics...", file=sys.stderr, flush=True
613
635
  )
614
636
  results = []
637
+ deviations: dict[str, dict[str, float]] = {}
615
638
 
616
639
  for k in k_values:
617
640
  X, surv_time, event = make_benchmark_data(
@@ -622,21 +645,32 @@ def run_benchmarks(
622
645
  if run_firthmodels and run_coxphf and not skip_verification:
623
646
  py = py_results["numba"][k]
624
647
  r = coxphf_results[k]
625
- check_agreement(k, py["fit_coef"], r["fit_coef"], "fit")
648
+ check_agreement(
649
+ k,
650
+ py["fit_coef"],
651
+ r["fit_coef"],
652
+ "fit vs coxphf",
653
+ deviations=deviations,
654
+ )
626
655
  check_agreement(
627
656
  k,
628
657
  py["full_coef"],
629
658
  r["full_coef"],
630
- "full",
659
+ "full vs coxphf",
631
660
  py["full_ci"],
632
661
  r["full_ci"],
633
662
  py["full_pval"],
634
663
  r["full_pval"],
664
+ deviations=deviations,
635
665
  )
636
666
  # Also check numba vs numpy
637
667
  py_numpy = py_results["numpy"][k]
638
668
  check_agreement(
639
- k, py["fit_coef"], py_numpy["fit_coef"], "numba vs numpy"
669
+ k,
670
+ py["fit_coef"],
671
+ py_numpy["fit_coef"],
672
+ "numba vs numpy",
673
+ deviations=deviations,
640
674
  )
641
675
 
642
676
  # Collect timing results
@@ -673,10 +707,10 @@ def run_benchmarks(
673
707
  row["coxphf_fit_ms"] = compute_min(r["fit_times"]) * 1000
674
708
  row["coxphf_full_ms"] = compute_min(r["full_times"]) * 1000
675
709
  # 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())
710
+ row["coxphf_fit_coef"] = np.asarray(r["fit_coef"]).tolist()
711
+ row["coxphf_full_coef"] = np.asarray(r["full_coef"]).tolist()
712
+ row["coxphf_full_ci"] = np.asarray(r["full_ci"]).tolist()
713
+ row["coxphf_full_pval"] = np.asarray(r["full_pval"]).tolist()
680
714
  else:
681
715
  assert saved_row is not None
682
716
  row["coxphf_fit_ms"] = saved_row["coxphf_fit_ms"]
@@ -688,7 +722,39 @@ def run_benchmarks(
688
722
 
689
723
  results.append(row)
690
724
 
691
- return pd.DataFrame(results), version_info
725
+ reduced = n_runs if n_runs <= 3 else max(3, n_runs // 3)
726
+ run_meta = {
727
+ "schema_version": 1,
728
+ "model": "cox",
729
+ "created_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
730
+ "n_runs": n_runs,
731
+ "r_reduced_runs": (
732
+ {
733
+ "libraries": ["coxphf"],
734
+ "for_k_above": coxphf_reduce_after,
735
+ "n_runs": reduced,
736
+ }
737
+ if coxphf_reduce_after
738
+ else None
739
+ ),
740
+ "k_values": list(k_values),
741
+ "libraries_run": [
742
+ name
743
+ for name, flag in [
744
+ ("firthmodels", run_firthmodels),
745
+ ("coxphf", run_coxphf),
746
+ ]
747
+ if flag
748
+ ],
749
+ "loaded_from_saved": saved,
750
+ "verification": {
751
+ "performed": bool(deviations),
752
+ "tolerances": {"coef": COEF_TOL, "ci": CI_TOL, "pval": PVAL_TOL},
753
+ "max_abs_deviations": deviations,
754
+ },
755
+ "versions": version_info,
756
+ }
757
+ return pd.DataFrame(results), version_info, run_meta
692
758
 
693
759
 
694
760
  def print_table(df: pd.DataFrame) -> None:
@@ -743,14 +809,14 @@ def main():
743
809
  "--out",
744
810
  type=str,
745
811
  default=None,
746
- help="Save results to CSV file",
812
+ help="Save results (timings, R references, run metadata) to JSON file",
747
813
  )
748
814
  parser.add_argument(
749
815
  "--saved",
750
816
  type=str,
751
817
  default=None,
752
- metavar="CSV",
753
- help="Load non-selected libraries from this CSV.",
818
+ metavar="JSON",
819
+ help="Load non-selected libraries from this results JSON.",
754
820
  )
755
821
  parser.add_argument(
756
822
  "--firthmodels",
@@ -807,7 +873,7 @@ def main():
807
873
  )
808
874
 
809
875
  # Run benchmarks
810
- df, version_info = run_benchmarks(
876
+ df, version_info, run_meta = run_benchmarks(
811
877
  k_values=k_values,
812
878
  n_runs=args.n_runs,
813
879
  skip_verification=args.skip_verification,
@@ -821,7 +887,11 @@ def main():
821
887
  print_table(df)
822
888
 
823
889
  if args.out:
824
- df.to_csv(args.out, index=False)
890
+ with open(args.out, "w") as f:
891
+ json.dump(
892
+ {"meta": run_meta, "results": df.to_dict(orient="records")}, f, indent=2
893
+ )
894
+ f.write("\n")
825
895
  print(f"\nResults saved to {args.out}", file=sys.stderr)
826
896
 
827
897