multivariate-probit 0.2.3__tar.gz → 0.3.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 (66) hide show
  1. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/CITATION.cff +2 -2
  2. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/PKG-INFO +21 -8
  3. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/README.md +20 -7
  4. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/benchmarks/bench.py +2 -2
  5. multivariate_probit-0.3.1/benchmarks/results.csv +65 -0
  6. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/api.md +15 -6
  7. multivariate_probit-0.3.1/docs/benchmarks.md +95 -0
  8. multivariate_probit-0.3.1/docs/fiml.md +96 -0
  9. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/limitations.md +2 -1
  10. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/studies/ghk.md +5 -0
  11. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/pyproject.toml +1 -1
  12. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/src/multivariate_probit/__init__.py +3 -2
  13. multivariate_probit-0.3.1/src/multivariate_probit/_remote.py +81 -0
  14. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/src/multivariate_probit/model.py +137 -6
  15. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/src/multivariate_probit/orthant/__init__.py +60 -11
  16. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/tests/test_evaluators.py +42 -0
  17. multivariate_probit-0.3.1/tests/test_modal_fit.py +192 -0
  18. multivariate_probit-0.2.3/benchmarks/results.csv +0 -55
  19. multivariate_probit-0.2.3/docs/benchmarks.md +0 -90
  20. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/.github/ISSUE_TEMPLATE/use-case.md +0 -0
  21. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/.gitignore +0 -0
  22. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/LICENSE +0 -0
  23. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/benchmarks/benchmarks.ipynb +0 -0
  24. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/benchmarks/ref_d12_n3000_100_rho0.4_s0.npz +0 -0
  25. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/benchmarks/ref_d16_n3000_100_rho0.4_s0.npz +0 -0
  26. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/benchmarks/ref_d3_n3000_100_rho0.4_s0.npz +0 -0
  27. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/benchmarks/ref_d5_n3000_100_rho0.4_s0.npz +0 -0
  28. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/benchmarks/ref_d8_n3000_100_rho0.4_s0.npz +0 -0
  29. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/benchmarks/ref_pairwise_d12_n3000_100_s0.npz +0 -0
  30. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/benchmarks/ref_pairwise_d16_n3000_100_s0.npz +0 -0
  31. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/benchmarks/ref_pairwise_d3_n3000_100_s0.npz +0 -0
  32. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/benchmarks/ref_pairwise_d5_n3000_100_s0.npz +0 -0
  33. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/benchmarks/ref_pairwise_d8_n3000_100_s0.npz +0 -0
  34. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/ifm.md +0 -0
  35. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/implementation.md +0 -0
  36. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/studies/README.md +0 -0
  37. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/studies/comparators.md +0 -0
  38. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/studies/index-correlation-shortcut.md +0 -0
  39. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/studies/log-score-evaluator.md +0 -0
  40. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/studies/margin-gaps.md +0 -0
  41. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/studies/mulan-multilabel.md +0 -0
  42. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/studies/multilabel-benchmarks.md +0 -0
  43. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/studies/sparse-label-transfer.md +0 -0
  44. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/studies/uci-credit.md +0 -0
  45. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/use-cases/README.md +0 -0
  46. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/use-cases/claims-fast-track.md +0 -0
  47. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/use-cases/content-moderation.md +0 -0
  48. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/use-cases/cyber.md +0 -0
  49. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/use-cases/fraud-vendor-fusion.md +0 -0
  50. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/use-cases/hcc-codes.md +0 -0
  51. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/docs/use-cases/property-imagery.md +0 -0
  52. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/hatch_build.py +0 -0
  53. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/src/multivariate_probit/_corr.py +0 -0
  54. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/src/multivariate_probit/_mvn.py +0 -0
  55. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/src/multivariate_probit/ifm.py +0 -0
  56. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/src/multivariate_probit/inner.py +0 -0
  57. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/src/multivariate_probit/linear.py +0 -0
  58. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/src/multivariate_probit/results.py +0 -0
  59. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/tests/conftest.py +0 -0
  60. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/tests/test_calibration.py +0 -0
  61. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/tests/test_conditional.py +0 -0
  62. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/tests/test_contract.py +0 -0
  63. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/tests/test_correlation_projection.py +0 -0
  64. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/tests/test_linear.py +0 -0
  65. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/tests/test_rf.py +0 -0
  66. {multivariate_probit-0.2.3 → multivariate_probit-0.3.1}/tests/test_xgboost.py +0 -0
@@ -6,8 +6,8 @@ authors:
6
6
  - family-names: Shipman
7
7
  given-names: Mark
8
8
  orcid: "https://orcid.org/0009-0008-7193-1263"
9
- version: 0.2.3
10
- # TODO(Mark): add date-released (YYYY-MM-DD) when 0.2.3 is published.
9
+ version: 0.3.0
10
+ date-released: 2026-10-06
11
11
  license: MIT
12
12
  repository-code: "https://github.com/sign-of-fourier/multivariate-probit"
13
13
  url: "https://quantecarlo.com/multivariate-probit"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: multivariate-probit
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: Joint probabilities for correlated binary outcomes: P(any), P(all), patterns and conditionals that agree. Any classifier per outcome; scikit-learn-style API.
5
5
  Project-URL: Homepage, https://quantecarlo.com/multivariate-probit
6
6
  Project-URL: Source, https://github.com/sign-of-fourier/multivariate-probit
@@ -101,7 +101,7 @@ Using this on a real problem?
101
101
  - Your **multilabel** or **multi-output** model treats labels as independent,
102
102
  and you need the **joint probability** of a label combination.
103
103
  - You've used R `mvProbit` or Stata `mvprobit` and want **multivariate probit in
104
- Python** for prediction at scale (no standard errors yet; see below).
104
+ Python**, with standard errors from a full-likelihood fit or prediction at scale.
105
105
  - You want to **combine several fraud or risk model scores** into one
106
106
  probability that "any of these happened."
107
107
  - You need **co-occurrence** or **label dependence** expressed as one
@@ -129,6 +129,9 @@ features it is the tetrachoric correlation conditional on x. Fitting is
129
129
  two-stage **Inference Functions for Margins** (IFM): each margin is fitted
130
130
  alone and cross-fitted, then Σ is estimated by maximum likelihood with the
131
131
  margins held fixed ([docs/ifm.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/ifm.md)).
132
+ For linear margins, `fitter="modal"` is the alternative: full maximum
133
+ likelihood, all margins and Σ at once, on a hosted GPU, with standard errors
134
+ ([docs/fiml.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/fiml.md)).
132
135
 
133
136
  ## Why not write it yourself
134
137
 
@@ -166,8 +169,11 @@ wrong; each is handled here and documented.
166
169
  explicit and readable in `correlation_`, but each margin is as much a black
167
170
  box as the classifier inside it. SHAP is the tool for explaining a single
168
171
  black-box model; this package does not make one explainable.
169
- - **No standard errors yet.** Coefficients and Σ are point estimates
170
- ([limitations](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/limitations.md)).
172
+ - **Standard errors with `fitter="modal"` only.** The IFM fit gives point
173
+ estimates
174
+ ([limitations](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/limitations.md));
175
+ the full-likelihood fit returns `stderr_` and `cov_params_` for the
176
+ coefficients and Σ.
171
177
 
172
178
  ## Bring your own model, or use ours
173
179
 
@@ -207,15 +213,21 @@ The backend is chosen with `evaluator=`:
207
213
  of magnitude faster than SciPy, at roughly the accuracy of 100-draw GHK
208
214
  simulation ([studies/ghk.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/studies/ghk.md)). It ships for CPython 3.11
209
215
  and 3.12 on x86-64 Linux (`pip install multivariate-probit[orthant]`); without
210
- a key it runs d ≤ 3 at `resolution="low"`. Key:
211
- [quantecarlo.com/orthant_key](https://quantecarlo.com/orthant_key).
216
+ a key it runs d ≤ 3 at `resolution="low"`. The key is read from `$ORTHANT_KEY` or
217
+ `~/.orthant/key` at import, or passed in code with
218
+ `from multivariate_probit import orthant; orthant.activate(key)` (on Spark, call it inside
219
+ the function the workers run). Key: [quantecarlo.com/orthant_key](https://quantecarlo.com/orthant_key).
212
220
 
213
221
  Benchmarks against other tools: [docs/benchmarks.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/benchmarks.md).
214
222
 
215
223
  ## Where your data goes
216
224
 
217
- Nowhere. Fitting and every `evaluator` run on your machine; the package makes
218
- no network calls, and the `"orthant"` key is checked offline.
225
+ Nowhere, by default. Fitting and every `evaluator` run on your machine; the
226
+ package makes no network calls, and the `"orthant"` key is checked offline.
227
+
228
+ The exception is opt-in: `fitter="modal"` uploads X, Y and `sample_weight` to
229
+ a GPU service for the full-likelihood fit and gets parameters back. Everything
230
+ after `fit` runs locally.
219
231
 
220
232
  The one hosted path is a separate package you call yourself:
221
233
  [`quantecarlo.orthant_cdf`](https://github.com/sign-of-fourier/quantecarlo#orthant-probabilities-orthant_cdf),
@@ -252,6 +264,7 @@ runnable example on synthetic data:
252
264
  ## Documentation
253
265
 
254
266
  - [docs/ifm.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/ifm.md): the estimation algorithm, and why IFM
267
+ - [docs/fiml.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/fiml.md): the full-likelihood GPU fit (`fitter="modal"`), with standard errors
255
268
  - [docs/api.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/api.md): parameters, evaluators, the inner-model contract
256
269
  - [docs/implementation.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/implementation.md): what is hand-rolled, numerical accuracy
257
270
  - [docs/limitations.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/limitations.md): known gaps
@@ -37,7 +37,7 @@ Using this on a real problem?
37
37
  - Your **multilabel** or **multi-output** model treats labels as independent,
38
38
  and you need the **joint probability** of a label combination.
39
39
  - You've used R `mvProbit` or Stata `mvprobit` and want **multivariate probit in
40
- Python** for prediction at scale (no standard errors yet; see below).
40
+ Python**, with standard errors from a full-likelihood fit or prediction at scale.
41
41
  - You want to **combine several fraud or risk model scores** into one
42
42
  probability that "any of these happened."
43
43
  - You need **co-occurrence** or **label dependence** expressed as one
@@ -65,6 +65,9 @@ features it is the tetrachoric correlation conditional on x. Fitting is
65
65
  two-stage **Inference Functions for Margins** (IFM): each margin is fitted
66
66
  alone and cross-fitted, then Σ is estimated by maximum likelihood with the
67
67
  margins held fixed ([docs/ifm.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/ifm.md)).
68
+ For linear margins, `fitter="modal"` is the alternative: full maximum
69
+ likelihood, all margins and Σ at once, on a hosted GPU, with standard errors
70
+ ([docs/fiml.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/fiml.md)).
68
71
 
69
72
  ## Why not write it yourself
70
73
 
@@ -102,8 +105,11 @@ wrong; each is handled here and documented.
102
105
  explicit and readable in `correlation_`, but each margin is as much a black
103
106
  box as the classifier inside it. SHAP is the tool for explaining a single
104
107
  black-box model; this package does not make one explainable.
105
- - **No standard errors yet.** Coefficients and Σ are point estimates
106
- ([limitations](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/limitations.md)).
108
+ - **Standard errors with `fitter="modal"` only.** The IFM fit gives point
109
+ estimates
110
+ ([limitations](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/limitations.md));
111
+ the full-likelihood fit returns `stderr_` and `cov_params_` for the
112
+ coefficients and Σ.
107
113
 
108
114
  ## Bring your own model, or use ours
109
115
 
@@ -143,15 +149,21 @@ The backend is chosen with `evaluator=`:
143
149
  of magnitude faster than SciPy, at roughly the accuracy of 100-draw GHK
144
150
  simulation ([studies/ghk.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/studies/ghk.md)). It ships for CPython 3.11
145
151
  and 3.12 on x86-64 Linux (`pip install multivariate-probit[orthant]`); without
146
- a key it runs d ≤ 3 at `resolution="low"`. Key:
147
- [quantecarlo.com/orthant_key](https://quantecarlo.com/orthant_key).
152
+ a key it runs d ≤ 3 at `resolution="low"`. The key is read from `$ORTHANT_KEY` or
153
+ `~/.orthant/key` at import, or passed in code with
154
+ `from multivariate_probit import orthant; orthant.activate(key)` (on Spark, call it inside
155
+ the function the workers run). Key: [quantecarlo.com/orthant_key](https://quantecarlo.com/orthant_key).
148
156
 
149
157
  Benchmarks against other tools: [docs/benchmarks.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/benchmarks.md).
150
158
 
151
159
  ## Where your data goes
152
160
 
153
- Nowhere. Fitting and every `evaluator` run on your machine; the package makes
154
- no network calls, and the `"orthant"` key is checked offline.
161
+ Nowhere, by default. Fitting and every `evaluator` run on your machine; the
162
+ package makes no network calls, and the `"orthant"` key is checked offline.
163
+
164
+ The exception is opt-in: `fitter="modal"` uploads X, Y and `sample_weight` to
165
+ a GPU service for the full-likelihood fit and gets parameters back. Everything
166
+ after `fit` runs locally.
155
167
 
156
168
  The one hosted path is a separate package you call yourself:
157
169
  [`quantecarlo.orthant_cdf`](https://github.com/sign-of-fourier/quantecarlo#orthant-probabilities-orthant_cdf),
@@ -188,6 +200,7 @@ runnable example on synthetic data:
188
200
  ## Documentation
189
201
 
190
202
  - [docs/ifm.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/ifm.md): the estimation algorithm, and why IFM
203
+ - [docs/fiml.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/fiml.md): the full-likelihood GPU fit (`fitter="modal"`), with standard errors
191
204
  - [docs/api.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/api.md): parameters, evaluators, the inner-model contract
192
205
  - [docs/implementation.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/implementation.md): what is hand-rolled, numerical accuracy
193
206
  - [docs/limitations.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/limitations.md): known gaps
@@ -104,8 +104,8 @@ def run_cell(d, n_train=3000, n_test=100, rho=0.4, seed=0, sigma="equi"):
104
104
  if d <= 6:
105
105
  res["mvp quadrature"] = timed(lambda: model.set_params(evaluator="quadrature").joint_proba(Xte, Yte))
106
106
  if orthant is not None and (ORTHANT_KEYED or d <= 3):
107
- r = "high" if ORTHANT_KEYED else "low"
108
- res[f"mvp orthant ({r})"] = timed(lambda: model.set_params(evaluator="orthant", resolution=r).joint_proba(Xte, Yte))
107
+ for r in (("high", "highest") if ORTHANT_KEYED else ("low",)):
108
+ res[f"mvp orthant ({r})"] = timed(lambda: model.set_params(evaluator="orthant", resolution=r).joint_proba(Xte, Yte))
109
109
  res["SciPy default"] = timed(lambda: model.set_params(evaluator="scipy").joint_proba(Xte, Yte))
110
110
  if mvncd_batch is not None:
111
111
  st = np.moveaxis(stack, 2, 0)
@@ -0,0 +1,65 @@
1
+ sigma,d,N,method,median_err,max_err,s_per_row
2
+ equi,3,100,mvp quadrature,1.3373382605444406e-05,5.564767189014663e-05,7.751391967758536e-05
3
+ equi,3,100,mvp orthant (high),5.190348623980261e-05,0.00019774969627306405,1.484813867136836e-05
4
+ equi,3,100,mvp orthant (highest),1.999845194528005e-06,7.989768001565034e-06,4.311794182285666e-05
5
+ equi,3,100,SciPy default,2.039637077871914e-06,1.1415786741186817e-05,0.005793280370999128
6
+ equi,3,100,pybhatlib ovus,0.00011229346488104974,0.0008524556947959311,8.469653083011508e-05
7
+ equi,3,100,statsmodels,0.028443441360488153,0.09919722660040897,6.613903678953648e-07
8
+ equi,3,100,reference,0.0,0.0,0.2536748156798421
9
+ equi,5,100,mvp quadrature,2.0314513939401896e-05,0.00011324180994054522,0.04333823672030121
10
+ equi,5,100,mvp orthant (high),8.080162918654848e-05,0.00030384074452138865,2.3601381108164788e-05
11
+ equi,5,100,mvp orthant (highest),3.4247200694226265e-06,2.3859122737224236e-05,7.270404836162925e-05
12
+ equi,5,100,SciPy default,1.3427915335623153e-06,7.990427117937626e-06,0.010302326420787721
13
+ equi,5,100,pybhatlib ovus,0.00039917378471633624,0.0019016346577496457,0.00011997280875220894
14
+ equi,5,100,statsmodels,0.024622620503874038,0.12870241315304662,5.677388980984688e-07
15
+ equi,5,100,reference,0.0,0.0,0.5339089114402304
16
+ equi,8,100,mvp orthant (high),7.013787736394328e-05,0.00045342350585386715,4.317081067711115e-05
17
+ equi,8,100,mvp orthant (highest),4.605751738051556e-06,2.9230892634346528e-05,9.955347049981356e-05
18
+ equi,8,100,SciPy default,1.0030036643121978e-06,8.134689687355756e-06,0.01642337770899758
19
+ equi,8,100,pybhatlib ovus,0.0003370945286850363,0.0023083975100191556,0.0001771104708313942
20
+ equi,8,100,statsmodels,0.014453125416131472,0.11538392212916126,9.872694499790668e-07
21
+ equi,8,100,reference,0.0,0.0,1.4400106923800196
22
+ equi,12,100,mvp orthant (high),6.245144142705012e-05,0.0007179989696481925,6.305043119937181e-05
23
+ equi,12,100,mvp orthant (highest),5.63448417025339e-06,4.668168500868153e-05,0.00014906150056049228
24
+ equi,12,100,SciPy default,3.440844550360589e-07,7.96331593577726e-06,0.02377823827089742
25
+ equi,12,100,pybhatlib ovus,0.0002047092197251764,0.0035788660943063266,0.0002432926092296839
26
+ equi,12,100,statsmodels,0.006303004648829415,0.07762653255283464,1.5006307512521743e-06
27
+ equi,12,100,reference,0.0,0.0,1.6423227695701643
28
+ equi,16,100,mvp orthant (high),1.630594965073808e-05,0.0006243813016577615,9.883297141641379e-05
29
+ equi,16,100,mvp orthant (highest),1.1074220600440065e-06,2.9310608489277468e-05,0.00022130653960630297
30
+ equi,16,100,SciPy default,6.853848620851782e-08,4.3920697129634645e-06,0.03263484545052051
31
+ equi,16,100,pybhatlib ovus,1.6800734753088424e-05,0.0011253768512156778,0.00031500173965469
32
+ equi,16,100,statsmodels,0.0014514737962008938,0.05355898580397629,1.4994502998888493e-06
33
+ equi,16,100,reference,0.0,0.0,1.1454083026899025
34
+ pairwise,3,100,mvp quadrature,4.975128970532983e-06,8.276622284841828e-05,7.341289892792702e-05
35
+ pairwise,3,100,mvp orthant (high),0.0006363036604397354,0.002893273763736187,2.0136469975113868e-05
36
+ pairwise,3,100,mvp orthant (highest),0.00011323696696274865,0.0007099285566469216,4.4253640808165074e-05
37
+ pairwise,3,100,SciPy default,1.3494787358958593e-06,2.586364471635072e-05,0.0073384556197561325
38
+ pairwise,3,100,pybhatlib ovus,0.00037654161082817383,0.004713394298326956,8.070134790614248e-05
39
+ pairwise,3,100,statsmodels,0.037060543222106224,0.15997644750429899,4.6383123844861987e-07
40
+ pairwise,3,100,reference,0.0,0.0,0.24052267621998907
41
+ pairwise,5,100,mvp quadrature,5.607947987679163e-06,6.266772635254991e-05,0.04593952419003472
42
+ pairwise,5,100,mvp orthant (high),0.0060661393002539865,0.03370856471106998,2.4112039245665073e-05
43
+ pairwise,5,100,mvp orthant (highest),0.003141591472459862,0.0325453719435822,6.538510089740157e-05
44
+ pairwise,5,100,SciPy default,1.2512113251161167e-06,2.1201235567813725e-05,0.14286554857157172
45
+ pairwise,5,100,pybhatlib ovus,0.0014663128072703105,0.02764332982515838,0.00012095558922737837
46
+ pairwise,5,100,statsmodels,0.052292238612919734,0.2549403651447862,5.610100924968719e-07
47
+ pairwise,5,100,reference,0.0,0.0,1.2560263323498657
48
+ pairwise,8,100,mvp orthant (high),0.0047540425543466,0.03786870554320598,3.998839994892478e-05
49
+ pairwise,8,100,mvp orthant (highest),0.0021531855423506108,0.013414597153998009,9.109209990128875e-05
50
+ pairwise,8,100,SciPy default,2.2860621589921443e-06,3.321167501391109e-05,1.132777205838356
51
+ pairwise,8,100,pybhatlib ovus,0.0015355099588129813,0.010313908094296734,0.00017519862158223986
52
+ pairwise,8,100,statsmodels,0.024908203618945243,0.1426044668035415,8.142902515828609e-07
53
+ pairwise,8,100,reference,0.0,0.0,4.434727118879964
54
+ pairwise,12,100,mvp orthant (high),0.003428055959417961,0.04309079684926198,6.395480129867793e-05
55
+ pairwise,12,100,mvp orthant (highest),0.002445889327158528,0.02153141541464762,0.00015708803897723555
56
+ pairwise,12,100,SciPy default,1.9157011284364617e-06,5.195613406111521e-05,1.5738827282516286
57
+ pairwise,12,100,pybhatlib ovus,0.0007431598443556342,0.01388904489935694,0.0002463792893104255
58
+ pairwise,12,100,statsmodels,0.014263734107914516,0.1854686635860365,1.2842798605561255e-06
59
+ pairwise,12,100,reference,0.0,0.0,9.518902401560045
60
+ pairwise,16,100,mvp orthant (high),0.0037733872849075424,0.04512852480876324,8.360587060451507e-05
61
+ pairwise,16,100,mvp orthant (highest),0.00293222915103126,0.045234964595115376,0.00021089559188112618
62
+ pairwise,16,100,SciPy default,1.7024296601283496e-06,1.0376357636343592e-05,1.7738397101382726
63
+ pairwise,16,100,pybhatlib ovus,0.0009786447220386783,0.015062755675490355,0.0003229634207673371
64
+ pairwise,16,100,statsmodels,0.010651175036494289,0.18479811475521019,1.8178089521825314e-06
65
+ pairwise,16,100,reference,0.0,0.0,19.51646036452992
@@ -1,7 +1,8 @@
1
1
  # API reference
2
2
 
3
3
  For the model and a quickstart see [../README.md](../README.md); for the
4
- estimation algorithm see [ifm.md](ifm.md).
4
+ estimation algorithm see [ifm.md](ifm.md), and for the full-likelihood GPU fit
5
+ see [fiml.md](fiml.md).
5
6
 
6
7
  ## MultivariateProbit
7
8
 
@@ -17,6 +18,8 @@ MultivariateProbit(
17
18
  random_state=None,
18
19
  evaluator="quadrature",
19
20
  resolution="high",
21
+ fitter="ifm",
22
+ n_draws=512,
20
23
  )
21
24
  ```
22
25
 
@@ -33,7 +36,9 @@ MultivariateProbit(
33
36
  | `project_correlation` | `True` | Project a pairwise estimate onto the nearest positive-definite correlation matrix. No effect when `dependence="joint"`. |
34
37
  | `random_state` | `None` | Controls the cross-fitting split and `sample`. |
35
38
  | `evaluator` | `"quadrature"` | Backend for the orthant probabilities behind `dependence="joint"` and every joint query: `"quadrature"`, `"scipy"` or `"orthant"`. See [Evaluators](#evaluators). |
36
- | `resolution` | `"high"` | Passed to `evaluator="orthant"`; ignored otherwise. |
39
+ | `resolution` | `"high"` | Passed to `evaluator="orthant"`; ignored otherwise. `"high"`, `"highest"` (most accurate, about 2–3× the time of `"high"`: median error under 0.00005 on an equicorrelated Σ against up to 0.0001 at `"high"`, and 0.0001–0.0031 on a near-singular Σ against 0.0006–0.0061; see [benchmarks.md](benchmarks.md)), `"smooth"` (the cost of `"highest"`, continuous in the parameters with no threshold where the method changes, for gradient-based fitting; not uniformly more accurate than `"high"`) or `"low"` (cheapest). |
40
+ | `fitter` | `"ifm"` | `"ifm"`: the local two-stage fit. `"modal"`: full maximum likelihood on a hosted GPU service ([fiml.md](fiml.md)) — `inner="linear"` only, `inner_params` may set only `alpha`; `dependence`, `cv`, `optimizer` and `project_correlation` do not apply. Uploads X, Y and `sample_weight`. An int `random_state` seeds its Sobol draws (default seed 0). |
41
+ | `n_draws` | `512` | Sobol draws per row for `fitter="modal"`; ignored otherwise. |
37
42
 
38
43
  > **Reading `calibration_`.** This is the Cox calibration slope, a deliberately
39
44
  > simple scale check: a probit of each outcome on its own fitted index. A slope
@@ -72,7 +77,7 @@ queries on a pairwise fit still use the chosen backend.
72
77
  | --- | --- | --- |
73
78
  | `"quadrature"` | Genz's recursive conditioning with a fixed Gauss-Legendre rule of order `n_quad` (see [implementation.md](implementation.md)). | Deterministic and accurate. Cost grows as `n_quad ** (d - 2)`, so it becomes impractical well before d = 10. |
74
79
  | `"scipy"` | `scipy.stats.multivariate_normal.cdf`, one row at a time. | Reaches any d, but is slow per row and randomised: a `dependence="joint"` fit is not exactly reproducible, and its noisy objective can stall the simplex. |
75
- | `"orthant"` | An optional compiled package bundled as `multivariate_probit.orthant`. Install with `pip install multivariate-probit[orthant]`. | Built for CPython 3.11 and 3.12 on x86-64 Linux only; elsewhere it raises `ImportError`. Without a key it accepts d ≤ 3 at `resolution="low"` only, and raises `ValueError` outside that. A key is read from `$ORTHANT_KEY` or `~/.orthant/key`; see https://quantecarlo.com/orthant_key. Accuracy and speed against GHK and SciPy: [studies/ghk.md](studies/ghk.md). |
80
+ | `"orthant"` | An optional compiled package bundled as `multivariate_probit.orthant`. Install with `pip install multivariate-probit[orthant]`. | Built for CPython 3.11 and 3.12 on x86-64 Linux only; elsewhere it raises `ImportError`. Without a key it accepts d ≤ 3 at `resolution="low"` only, and raises `ValueError` outside that. A key is read from `$ORTHANT_KEY` or `~/.orthant/key` at import, or passed at any time with `orthant.activate(key)` (returns `"paid"`; a wrong key raises `ValueError`). Each process needs its own key: on Spark, call `activate` inside the function the workers run, or set the variable in the cluster environment; see https://quantecarlo.com/orthant_key. Accuracy and speed against GHK and SciPy: [studies/ghk.md](studies/ghk.md). |
76
81
 
77
82
  For test sets too large for one machine, the same evaluator runs as a hosted
78
83
  GPU service, `quantecarlo.orthant_cdf`: about a second per million rows at
@@ -87,10 +92,14 @@ the
87
92
  | --- | --- | --- |
88
93
  | `inner_models_` | list, length d | The fitted margins, one per outcome. |
89
94
  | `correlation_` | (d, d) | The fitted Σ. |
90
- | `eta_` | (n, d) | The (cross-fitted) latent indices stage two was fitted on. |
95
+ | `eta_` | (n, d) | The (cross-fitted) latent indices stage two was fitted on; for `fitter="modal"`, the in-sample indices of the fitted model. |
91
96
  | `calibration_` | (d, 2) | Intercept and slope of a probit of each outcome on its own fitted index — the calibration slope. See the note below. |
92
- | `nll_` | float | Negative log-likelihood at the end of the dependence fit. Joint and pairwise fits optimise different objectives, so the values are not comparable across settings. |
93
- | `optimize_result_` | OptimizeResult or None | The SciPy result for `dependence="joint"`. |
97
+ | `nll_` | float | Negative log-likelihood at the end of the dependence fit. Joint and pairwise fits optimise different objectives, so the values are not comparable across settings. For `fitter="modal"`, the simulated joint negative log-likelihood of all margins and Σ. |
98
+ | `optimize_result_` | OptimizeResult or None | The SciPy result for `dependence="joint"`. `None` for `fitter="modal"`. |
99
+ | `stderr_`, `stderr_robust_` | dict | `fitter="modal"` only. Standard errors keyed `"coef"` (d, n_features), `"intercept"` (d,), `"correlation"` (d, d); inverse-Hessian and sandwich. |
100
+ | `cov_params_`, `cov_params_robust_` | (k, k) | `fitter="modal"` only. The matching covariance matrices, ordered as `param_names_`. |
101
+ | `param_names_` | list of str | `fitter="modal"` only. `coef[j,k]`, then `intercept[j]`, then `rho[j,k]` for j < k. |
102
+ | `fit_result_` | dict | `fitter="modal"` only. `n_iter`, `converged`, `grad_max`, `n_draws`, `seed`. |
94
103
  | `n_outcomes_`, `n_features_in_` | int | |
95
104
 
96
105
  ### Methods
@@ -0,0 +1,95 @@
1
+ # Benchmarks against other tools
2
+
3
+ **Task.** Score `P(Y = y | x)`, the probability of the pattern each held-out
4
+ row actually has, from one fitted model. Synthetic data: 6 features, linear
5
+ margins, 3,000 training rows, 100 scored rows per d. The model is
6
+ `MultivariateProbit(inner="linear", dependence="pairwise", random_state=0)`.
7
+ Every column scores that same fitted model (η̂, Σ̂), except statsmodels.
8
+
9
+ There are two shapes of true Σ:
10
+
11
+ - **Equicorrelated**, ρ = 0.4: the easy case.
12
+ - **Pairwise-shaped**: a rank-2 factor plus noise, projected to the nearest
13
+ correlation matrix (smallest eigenvalue about 1e-8), the same construction
14
+ as [studies/ghk.md](studies/ghk.md). A fitted pairwise Σ̂ lands on that PSD
15
+ boundary, which is hard for every approximation.
16
+
17
+ **Reference.** SciPy `multivariate_normal.cdf` with `abseps=1e-7`,
18
+ `releps=1e-6`, one row at a time. Error is the absolute difference from it in
19
+ probability: median over rows, maximum in parentheses. Time is wall-clock per
20
+ row after one warm-up call, on a two-core x86-64 Linux machine.
21
+
22
+ Reproduce with [benchmarks/benchmarks.ipynb](../benchmarks/benchmarks.ipynb)
23
+ (logic in `benchmarks/bench.py`; raw numbers in `benchmarks/results.csv`).
24
+ The reference values are cached in `benchmarks/ref_*.npz` together with the
25
+ problem they were computed for, so a rerun skips the slow part and a changed
26
+ problem recomputes. The `orthant` column needs a key in `$ORTHANT_KEY` or
27
+ `~/.orthant/key`.
28
+
29
+ ### Equicorrelated Σ (ρ = 0.4)
30
+
31
+ | d | median p | multivariate-probit `orthant` (`resolution="high"`) | multivariate-probit `orthant` (`resolution="highest"`) | multivariate-probit `quadrature` | SciPy, default tolerances | pybhatlib (Bhat OVUS) | statsmodels |
32
+ | --- | --- | --- | --- | --- | --- | --- | --- |
33
+ | 3 | 0.348 | 1.5e-05 s / 0.0001 (0.0002) | 4.3e-05 s / 0.0000 (0.0000) | 7.8e-05 s / 0.0000 (0.0001) | 5.8e-03 s / 0.0000 (0.0000) | 8.5e-05 s / 0.0001 (0.0009) | 6.6e-07 s / 0.0284 (0.0992) |
34
+ | 5 | 0.154 | 2.4e-05 s / 0.0001 (0.0003) | 7.3e-05 s / 0.0000 (0.0000) | 4.3e-02 s / 0.0000 (0.0001) | 1.0e-02 s / 0.0000 (0.0000) | 1.2e-04 s / 0.0004 (0.0019) | 5.7e-07 s / 0.0246 (0.1287) |
35
+ | 8 | 0.101 | 4.3e-05 s / 0.0001 (0.0005) | 1.0e-04 s / 0.0000 (0.0000) | impractical past d = 7 | 1.6e-02 s / 0.0000 (0.0000) | 1.8e-04 s / 0.0003 (0.0023) | 9.9e-07 s / 0.0145 (0.1154) |
36
+ | 12 | 0.022 | 6.3e-05 s / 0.0001 (0.0007) | 1.5e-04 s / 0.0000 (0.0000) | impractical past d = 7 | 2.4e-02 s / 0.0000 (0.0000) | 2.4e-04 s / 0.0002 (0.0036) | 1.5e-06 s / 0.0063 (0.0776) |
37
+ | 16 | 0.0024 | 9.9e-05 s / 0.0000 (0.0006) | 2.2e-04 s / 0.0000 (0.0000) | impractical past d = 7 | 3.3e-02 s / 0.0000 (0.0000) | 3.2e-04 s / 0.0000 (0.0011) | 1.5e-06 s / 0.0015 (0.0536) |
38
+
39
+ ### Pairwise-shaped Σ (near-singular)
40
+
41
+ | d | median p | multivariate-probit `orthant` (`resolution="high"`) | multivariate-probit `orthant` (`resolution="highest"`) | multivariate-probit `quadrature` | SciPy, default tolerances | pybhatlib (Bhat OVUS) | statsmodels |
42
+ | --- | --- | --- | --- | --- | --- | --- | --- |
43
+ | 3 | 0.431 | 2.0e-05 s / 0.0006 (0.0029) | 4.4e-05 s / 0.0001 (0.0007) | 7.3e-05 s / 0.0000 (0.0001) | 7.3e-03 s / 0.0000 (0.0000) | 8.1e-05 s / 0.0004 (0.0047) | 4.6e-07 s / 0.0371 (0.1600) |
44
+ | 5 | 0.171 | 2.4e-05 s / 0.0061 (0.0337) | 6.5e-05 s / 0.0031 (0.0325) | 4.6e-02 s / 0.0000 (0.0001) | 1.4e-01 s / 0.0000 (0.0000) | 1.2e-04 s / 0.0015 (0.0276) | 5.6e-07 s / 0.0523 (0.2549) |
45
+ | 8 | 0.097 | 4.0e-05 s / 0.0048 (0.0379) | 9.1e-05 s / 0.0022 (0.0134) | impractical past d = 7 | 1.1e+00 s / 0.0000 (0.0000) | 1.8e-04 s / 0.0015 (0.0103) | 8.1e-07 s / 0.0249 (0.1426) |
46
+ | 12 | 0.025 | 6.4e-05 s / 0.0034 (0.0431) | 1.6e-04 s / 0.0024 (0.0215) | impractical past d = 7 | 1.6e+00 s / 0.0000 (0.0001) | 2.5e-04 s / 0.0007 (0.0139) | 1.3e-06 s / 0.0143 (0.1855) |
47
+ | 16 | 0.013 | 8.4e-05 s / 0.0038 (0.0451) | 2.1e-04 s / 0.0029 (0.0452) | impractical past d = 7 | 1.8e+00 s / 0.0000 (0.0000) | 3.2e-04 s / 0.0010 (0.0151) | 1.8e-06 s / 0.0107 (0.1848) |
48
+
49
+ R `mvProbit` was not run (see below).
50
+
51
+ The reference itself took 0.24 to 1.6 s per row on the equicorrelated Σ and
52
+ 0.24 to 20 s per row on the pairwise-shaped one. At d = 16 on the
53
+ equicorrelated Σ most pattern probabilities are small (median 0.0024), so the
54
+ maximum error says more than the median there.
55
+
56
+ ## Reading the tables
57
+
58
+ - **statsmodels** fits one `Probit` per outcome and has no correlation matrix,
59
+ so its pattern probability is a product of marginals. Its error is the cost
60
+ of ignoring Σ, not of numerical integration, and it does not shrink with
61
+ more data. Its time covers prediction only.
62
+ - **`orthant`** scores all rows in one compiled call.
63
+ - **`resolution="high"`** was the fastest of the integrating methods at every
64
+ d on both Σ shapes: 3 to 6 times faster than pybhatlib, and 330 to 440 times
65
+ faster than SciPy on the equicorrelated Σ. Median error 0.0001 or less on the
66
+ equicorrelated Σ (maximum up to 0.0007, against pybhatlib's 0.0009 to
67
+ 0.0036). On the pairwise-shaped Σ, median error 0.0006 to 0.0061, maximum up
68
+ to 0.045, the largest of the integrating methods.
69
+ - **`resolution="highest"`** costs 2.2 to 3.1 times `"high"`'s time and is
70
+ still 1.4 to 2 times faster than pybhatlib. On the equicorrelated Σ its
71
+ median and maximum error are below 0.00005 at every d. On the
72
+ pairwise-shaped Σ its median error is 0.0001 to 0.0031 (`"high"`: 0.0006 to
73
+ 0.0061) and its maximum up to 0.045; pybhatlib's median there is 0.0004 to
74
+ 0.0015.
75
+ - **pybhatlib** scores with Bhat's analytic approximation (`mvncd_batch`,
76
+ `method="ovus"`), one row at a time in Python. It is 70 to 105 times faster
77
+ than SciPy on the equicorrelated Σ. On the pairwise-shaped Σ its median error
78
+ is 0.0004 to 0.0015 and its maximum 0.0047 to 0.028. pybhatlib also has a PyTorch backend that runs on
79
+ your own GPU, but in pybhatlib 0.4.0 `mvncd` converts its inputs to NumPy
80
+ and `mvncd_batch` loops over rows in Python, so the GPU does not speed up
81
+ this scoring.
82
+ - **SciPy** stays accurate on the pairwise-shaped Σ but slows sharply, to
83
+ 1.1 to 1.8 s per row from d = 8. That is 54 to 69 times its time on the
84
+ equicorrelated Σ at the same d.
85
+ - **`quadrature`** matched the reference on both shapes up to d = 5.
86
+ - **R `mvProbit`** was not run: R is not installed on the benchmark machine,
87
+ and the notebook has no R column yet.
88
+
89
+ ## What this does not establish
90
+
91
+ - Two Σ shapes, one seed, and 100 rows per d. The pairwise-shaped Σ is one
92
+ construction of a near-singular matrix; see [studies/ghk.md](studies/ghk.md)
93
+ and [studies/comparators.md](studies/comparators.md) for more.
94
+ - Scoring only. Fitting time against full-likelihood estimation is in
95
+ [studies/comparators.md](studies/comparators.md).
@@ -0,0 +1,96 @@
1
+ # Full maximum likelihood on a GPU (`fitter="modal"`)
2
+
3
+ An alternative to [IFM](ifm.md), not a stage of it. Every margin's
4
+ coefficients and the correlation matrix Σ are estimated together, by
5
+ maximising the joint likelihood
6
+
7
+ ```
8
+ L(B, Σ) = Σ_i w_i log P(Y = y_i | x_i), η_ij = b_j0 + x_i' b_j
9
+ ```
10
+
11
+ on a hosted GPU service. Only linear margins: the joint likelihood has to be
12
+ differentiated through every margin, which rules out boosted trees and forests.
13
+
14
+ ```python
15
+ model = MultivariateProbit(fitter="modal").fit(X, Y)
16
+ model.correlation_ # Σ
17
+ model.stderr_["coef"] # (d, n_features) standard errors
18
+ model.stderr_robust_ # sandwich standard errors
19
+ model.predict_proba(X_new) # runs locally, on any evaluator
20
+ ```
21
+
22
+ ## What changes, and what doesn't
23
+
24
+ Only `fitter="modal"` changes. After `fit`, the model is used exactly as an
25
+ IFM fit is: `transform`, `predict_proba` and its joint queries, `joint_proba`,
26
+ `score`, `sample`, `correlation_`, `inner_models_[j].coef_`, `get_params` /
27
+ `set_params`. Prediction runs locally on the chosen `evaluator`.
28
+
29
+ The differences:
30
+
31
+ - `inner` must be `"linear"`; `inner_params` may set only `alpha`. Anything
32
+ else raises.
33
+ - `dependence`, `cv`, `optimizer` and `project_correlation` do not apply.
34
+ - `fit` uploads X, Y and `sample_weight` and waits for the service (see
35
+ [Cost](#cost)).
36
+ - Extra attributes: `stderr_`, `stderr_robust_`, `cov_params_`,
37
+ `cov_params_robust_`, `param_names_`, `fit_result_`.
38
+ - `eta_` holds in-sample indices (there is no cross-fitting); `nll_` is the
39
+ simulated joint negative log-likelihood; `optimize_result_` is `None`.
40
+ - An int `random_state` seeds the Sobol draws; otherwise the seed is 0, so
41
+ repeated fits agree.
42
+
43
+ ## When to use it instead of IFM
44
+
45
+ - You need **standard errors** for the coefficients and for Σ. IFM does not
46
+ report them ([limitations.md](limitations.md)); this path does.
47
+ - You want the **efficient estimator**. IFM is consistent but discards the
48
+ information that the other outcomes carry about each margin; full ML does not.
49
+ - The margins are linear anyway. With flexible margins, IFM is the only option.
50
+
51
+ IFM stays the default: it runs locally, takes any inner model, and its cost
52
+ does not grow with the number of margin parameters.
53
+
54
+ ## How it works
55
+
56
+ - The probability of each observed pattern is a d-dimensional orthant integral,
57
+ simulated by GHK with randomised quasi-Monte Carlo: one scrambled Sobol set
58
+ of `n_draws` points, shifted per row. The draws are **fixed for the whole
59
+ fit**, so the simulated log-likelihood is a smooth deterministic function of
60
+ the parameters and a quasi-Newton optimiser (L-BFGS) with exact gradients
61
+ applies.
62
+ - Σ is parameterised so that every parameter value is a valid correlation
63
+ matrix: no projection or penalty is ever needed.
64
+ - The fit starts from the margins fitted with Σ = I (exact, no simulation),
65
+ then optimises everything jointly.
66
+ - X is standardised on the service; coefficients come back on the original
67
+ scale. `inner_params={"alpha": ...}` sets the same small ridge on the slopes
68
+ as `ProbitRegressor` (default 1e-6).
69
+
70
+ ## Standard errors
71
+
72
+ At the optimum the service computes the exact Hessian of the simulated
73
+ log-likelihood. Two covariance matrices come back, mapped to the reported
74
+ parameters (coefficients on the original X scale, intercepts, and the
75
+ correlations ρ_jk):
76
+
77
+ - `cov_params_` / `stderr_`: inverse Hessian, the classical ML standard errors.
78
+ - `cov_params_robust_` / `stderr_robust_`: the sandwich `H⁻¹ (Σ_i w_i² g_i g_i') H⁻¹`.
79
+ Use it with `sample_weight`, or when the model may be misspecified.
80
+
81
+ `param_names_` gives the order of both matrices. The standard errors treat the
82
+ Sobol draws as fixed: they do not include simulation error, which is small
83
+ next to sampling error at the default `n_draws=512`.
84
+
85
+ ## Where the data goes
86
+
87
+ X, Y and `sample_weight` are uploaded to the service. The response holds
88
+ parameters only; the service keeps the result for collection, and nothing
89
+ after `fit` makes a network call — prediction and scoring run locally with the
90
+ chosen `evaluator`.
91
+
92
+ ## Cost
93
+
94
+ A fit is submitted, then polled until done. On a T4: about 50 s end to end for
95
+ n = 50,000, 10 features and d = 6, standard errors included; small fits take
96
+ 15 to 20 s, plus up to a minute when the service is starting cold.
@@ -4,7 +4,8 @@ The gaps below are known and stated rather than discovered later.
4
4
 
5
5
  ## Statistical
6
6
 
7
- **No standard errors.** `correlation_` is a point estimate. Because stage two
7
+ **No standard errors from IFM.** (`fitter="modal"` reports them, for linear
8
+ margins; see [fiml.md](fiml.md).) Under IFM, `correlation_` is a point estimate. Because stage two
8
9
  conditions on estimated margins, inverse-Hessian standard errors would be
9
10
  wrong; correct inference needs a Godambe (sandwich) information matrix or a
10
11
  bootstrap that resamples and refits both stages. Neither is implemented, and
@@ -6,6 +6,11 @@ without measuring it. For *scoring* a fitted model, where that objection does
6
6
  not apply, how does GHK compare with the compiled `orthant` backend in
7
7
  accuracy and speed?
8
8
 
9
+ > **Version note (2026-10).** This study measured the backend shipped in 0.2.1. Later builds integrate
10
+ > `resolution="high"` on a finer rule and add `resolution="highest"`, which is more accurate at a few times the
11
+ > cost. The tables below were not rerun; current numbers for both settings are in
12
+ > [benchmarks.md](../benchmarks.md).
13
+
9
14
  ## Summary
10
15
 
11
16
  | | accuracy | speed (s per row) |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "multivariate-probit"
7
- version = "0.2.3"
7
+ version = "0.3.1"
8
8
  description = "Joint probabilities for correlated binary outcomes: P(any), P(all), patterns and conditionals that agree. Any classifier per outcome; scikit-learn-style API."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -4,7 +4,8 @@ A multivariate probit is a Gaussian squashing function wrapped around an
4
4
  arbitrary inner model. This package fits one by Inference Functions for Margins
5
5
  (IFM): margins first, dependence second. The inner model is pluggable -- linear
6
6
  by default, gradient boosting or random forests via named presets, anything
7
- scikit-learn-shaped via the adapter.
7
+ scikit-learn-shaped via the adapter. For linear margins, ``fitter="modal"``
8
+ fits by full maximum likelihood on a hosted GPU instead, with standard errors.
8
9
 
9
10
  >>> from multivariate_probit import MultivariateProbit
10
11
  >>> model = MultivariateProbit(inner="linear").fit(X, Y)
@@ -33,7 +34,7 @@ from .linear import ProbitRegressor
33
34
  from .model import MultivariateProbit
34
35
  from .results import MultivariateProbitProba
35
36
 
36
- __version__ = "0.2.3"
37
+ __version__ = "0.3.1"
37
38
 
38
39
  __all__ = [
39
40
  "MultivariateProbit",
@@ -0,0 +1,81 @@
1
+ """Client for the hosted full-likelihood fit (``fitter="modal"``).
2
+
3
+ The fit runs on a GPU service: the request carries X, Y and the weights; the
4
+ response carries parameters only -- coefficients, intercepts, the correlation
5
+ matrix and their covariance. Everything after that runs locally. The service
6
+ answers a submission with a call id and is then polled, because a fit can
7
+ outlast a single HTTP request.
8
+
9
+ Standard library only (``urllib``), so the package gains no dependency.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ import json
16
+ import time
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+
21
+ import numpy as np
22
+
23
+ __all__ = ["fit_remote", "FIT_URL", "RESULT_URL"]
24
+
25
+ FIT_URL = "https://info-29741--bo-gp-service-mvp-fit.modal.run"
26
+ RESULT_URL = "https://info-29741--bo-gp-service-mvp-fit-result.modal.run"
27
+
28
+ _REQUEST_TIMEOUT = 300 # seconds per HTTP request (the upload can be large)
29
+
30
+
31
+ def _error_text(exc):
32
+ try:
33
+ body = json.loads(exc.read())
34
+ return body.get("error", str(body))
35
+ except Exception:
36
+ return str(exc)
37
+
38
+
39
+ def fit_remote(X, Y, sample_weight=None, poll_interval=2.0, timeout=3600, **params):
40
+ """Submit a fit, wait for it, and return the result as a dict of arrays.
41
+
42
+ ``params`` are the service's fields: ``n_draws``, ``seed``, ``max_iter``,
43
+ ``tol``, ``alpha``, ``se``, ``dtype``. Raises ``ValueError`` if the service
44
+ rejects the input, ``RuntimeError`` if the fit fails or cannot be
45
+ collected, and ``TimeoutError`` after ``timeout`` seconds.
46
+ """
47
+ arrays = {"X": np.asarray(X, dtype=np.float64), "Y": np.asarray(Y, dtype=np.float64)}
48
+ if sample_weight is not None:
49
+ arrays["sample_weight"] = np.asarray(sample_weight, dtype=np.float64)
50
+ buf = io.BytesIO()
51
+ np.savez(buf, params=np.array(json.dumps(params)), **arrays)
52
+ request = urllib.request.Request(
53
+ FIT_URL,
54
+ data=buf.getvalue(),
55
+ headers={"Content-Type": "application/octet-stream"},
56
+ method="POST",
57
+ )
58
+ try:
59
+ with urllib.request.urlopen(request, timeout=_REQUEST_TIMEOUT) as resp:
60
+ call_id = json.loads(resp.read())["call_id"]
61
+ except urllib.error.HTTPError as exc:
62
+ if exc.code == 400:
63
+ raise ValueError(f"the fitting service rejected the input: {_error_text(exc)}") from None
64
+ raise RuntimeError(f"the fitting service failed ({exc.code}): {_error_text(exc)}") from None
65
+
66
+ url = RESULT_URL + "?" + urllib.parse.urlencode({"call_id": call_id, "format": "npz"})
67
+ deadline = time.monotonic() + timeout
68
+ while True:
69
+ try:
70
+ with urllib.request.urlopen(url, timeout=_REQUEST_TIMEOUT) as resp:
71
+ status, body = resp.status, resp.read()
72
+ except urllib.error.HTTPError as exc:
73
+ raise RuntimeError(
74
+ f"remote fit {call_id} failed ({exc.code}): {_error_text(exc)}"
75
+ ) from None
76
+ if status == 200:
77
+ with np.load(io.BytesIO(body), allow_pickle=False) as npz:
78
+ return {key: npz[key] for key in npz.files}
79
+ if time.monotonic() > deadline:
80
+ raise TimeoutError(f"remote fit {call_id} still running after {timeout} s")
81
+ time.sleep(poll_interval)