multivariate-probit 0.2.3__tar.gz → 0.3.0__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 (64) hide show
  1. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/CITATION.cff +2 -2
  2. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/PKG-INFO +17 -6
  3. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/README.md +16 -5
  4. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/api.md +12 -3
  5. multivariate_probit-0.3.0/docs/fiml.md +75 -0
  6. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/limitations.md +2 -1
  7. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/pyproject.toml +1 -1
  8. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/__init__.py +3 -2
  9. multivariate_probit-0.3.0/src/multivariate_probit/_remote.py +81 -0
  10. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/model.py +126 -4
  11. multivariate_probit-0.3.0/tests/test_modal_fit.py +192 -0
  12. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/.github/ISSUE_TEMPLATE/use-case.md +0 -0
  13. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/.gitignore +0 -0
  14. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/LICENSE +0 -0
  15. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/bench.py +0 -0
  16. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/benchmarks.ipynb +0 -0
  17. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d12_n3000_100_rho0.4_s0.npz +0 -0
  18. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d16_n3000_100_rho0.4_s0.npz +0 -0
  19. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d3_n3000_100_rho0.4_s0.npz +0 -0
  20. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d5_n3000_100_rho0.4_s0.npz +0 -0
  21. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d8_n3000_100_rho0.4_s0.npz +0 -0
  22. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d12_n3000_100_s0.npz +0 -0
  23. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d16_n3000_100_s0.npz +0 -0
  24. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d3_n3000_100_s0.npz +0 -0
  25. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d5_n3000_100_s0.npz +0 -0
  26. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d8_n3000_100_s0.npz +0 -0
  27. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/results.csv +0 -0
  28. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/benchmarks.md +0 -0
  29. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/ifm.md +0 -0
  30. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/implementation.md +0 -0
  31. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/README.md +0 -0
  32. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/comparators.md +0 -0
  33. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/ghk.md +0 -0
  34. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/index-correlation-shortcut.md +0 -0
  35. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/log-score-evaluator.md +0 -0
  36. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/margin-gaps.md +0 -0
  37. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/mulan-multilabel.md +0 -0
  38. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/multilabel-benchmarks.md +0 -0
  39. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/sparse-label-transfer.md +0 -0
  40. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/uci-credit.md +0 -0
  41. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/README.md +0 -0
  42. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/claims-fast-track.md +0 -0
  43. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/content-moderation.md +0 -0
  44. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/cyber.md +0 -0
  45. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/fraud-vendor-fusion.md +0 -0
  46. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/hcc-codes.md +0 -0
  47. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/property-imagery.md +0 -0
  48. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/hatch_build.py +0 -0
  49. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/_corr.py +0 -0
  50. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/_mvn.py +0 -0
  51. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/ifm.py +0 -0
  52. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/inner.py +0 -0
  53. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/linear.py +0 -0
  54. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/orthant/__init__.py +0 -0
  55. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/results.py +0 -0
  56. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/conftest.py +0 -0
  57. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_calibration.py +0 -0
  58. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_conditional.py +0 -0
  59. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_contract.py +0 -0
  60. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_correlation_projection.py +0 -0
  61. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_evaluators.py +0 -0
  62. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_linear.py +0 -0
  63. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_rf.py +0 -0
  64. {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/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-04
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.0
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
 
@@ -214,8 +220,12 @@ Benchmarks against other tools: [docs/benchmarks.md](https://github.com/sign-of-
214
220
 
215
221
  ## Where your data goes
216
222
 
217
- Nowhere. Fitting and every `evaluator` run on your machine; the package makes
218
- no network calls, and the `"orthant"` key is checked offline.
223
+ Nowhere, by default. Fitting and every `evaluator` run on your machine; the
224
+ package makes no network calls, and the `"orthant"` key is checked offline.
225
+
226
+ The exception is opt-in: `fitter="modal"` uploads X, Y and `sample_weight` to
227
+ a GPU service for the full-likelihood fit and gets parameters back. Everything
228
+ after `fit` runs locally.
219
229
 
220
230
  The one hosted path is a separate package you call yourself:
221
231
  [`quantecarlo.orthant_cdf`](https://github.com/sign-of-fourier/quantecarlo#orthant-probabilities-orthant_cdf),
@@ -252,6 +262,7 @@ runnable example on synthetic data:
252
262
  ## Documentation
253
263
 
254
264
  - [docs/ifm.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/ifm.md): the estimation algorithm, and why IFM
265
+ - [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
266
  - [docs/api.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/api.md): parameters, evaluators, the inner-model contract
256
267
  - [docs/implementation.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/implementation.md): what is hand-rolled, numerical accuracy
257
268
  - [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
 
@@ -150,8 +156,12 @@ Benchmarks against other tools: [docs/benchmarks.md](https://github.com/sign-of-
150
156
 
151
157
  ## Where your data goes
152
158
 
153
- Nowhere. Fitting and every `evaluator` run on your machine; the package makes
154
- no network calls, and the `"orthant"` key is checked offline.
159
+ Nowhere, by default. Fitting and every `evaluator` run on your machine; the
160
+ package makes no network calls, and the `"orthant"` key is checked offline.
161
+
162
+ The exception is opt-in: `fitter="modal"` uploads X, Y and `sample_weight` to
163
+ a GPU service for the full-likelihood fit and gets parameters back. Everything
164
+ after `fit` runs locally.
155
165
 
156
166
  The one hosted path is a separate package you call yourself:
157
167
  [`quantecarlo.orthant_cdf`](https://github.com/sign-of-fourier/quantecarlo#orthant-probabilities-orthant_cdf),
@@ -188,6 +198,7 @@ runnable example on synthetic data:
188
198
  ## Documentation
189
199
 
190
200
  - [docs/ifm.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/ifm.md): the estimation algorithm, and why IFM
201
+ - [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
202
  - [docs/api.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/api.md): parameters, evaluators, the inner-model contract
192
203
  - [docs/implementation.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/implementation.md): what is hand-rolled, numerical accuracy
193
204
  - [docs/limitations.md](https://github.com/sign-of-fourier/multivariate-probit/blob/main/docs/limitations.md): known gaps
@@ -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
 
@@ -34,6 +37,8 @@ MultivariateProbit(
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
39
  | `resolution` | `"high"` | Passed to `evaluator="orthant"`; ignored otherwise. |
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
@@ -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
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. |
93
- | `optimize_result_` | OptimizeResult or None | The SciPy result for `dependence="joint"`. |
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,75 @@
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
+ ## When to use it instead of IFM
23
+
24
+ - You need **standard errors** for the coefficients and for Σ. IFM does not
25
+ report them ([limitations.md](limitations.md)); this path does.
26
+ - You want the **efficient estimator**. IFM is consistent but discards the
27
+ information that the other outcomes carry about each margin; full ML does not.
28
+ - The margins are linear anyway. With flexible margins, IFM is the only option.
29
+
30
+ IFM stays the default: it runs locally, takes any inner model, and its cost
31
+ does not grow with the number of margin parameters.
32
+
33
+ ## How it works
34
+
35
+ - The probability of each observed pattern is a d-dimensional orthant integral,
36
+ simulated by GHK with randomised quasi-Monte Carlo: one scrambled Sobol set
37
+ of `n_draws` points, shifted per row. The draws are **fixed for the whole
38
+ fit**, so the simulated log-likelihood is a smooth deterministic function of
39
+ the parameters and a quasi-Newton optimiser (L-BFGS) with exact gradients
40
+ applies.
41
+ - Σ is parameterised so that every parameter value is a valid correlation
42
+ matrix: no projection or penalty is ever needed.
43
+ - The fit starts from the margins fitted with Σ = I (exact, no simulation),
44
+ then optimises everything jointly.
45
+ - X is standardised on the service; coefficients come back on the original
46
+ scale. `inner_params={"alpha": ...}` sets the same small ridge on the slopes
47
+ as `ProbitRegressor` (default 1e-6).
48
+
49
+ ## Standard errors
50
+
51
+ At the optimum the service computes the exact Hessian of the simulated
52
+ log-likelihood. Two covariance matrices come back, mapped to the reported
53
+ parameters (coefficients on the original X scale, intercepts, and the
54
+ correlations ρ_jk):
55
+
56
+ - `cov_params_` / `stderr_`: inverse Hessian, the classical ML standard errors.
57
+ - `cov_params_robust_` / `stderr_robust_`: the sandwich `H⁻¹ (Σ_i w_i² g_i g_i') H⁻¹`.
58
+ Use it with `sample_weight`, or when the model may be misspecified.
59
+
60
+ `param_names_` gives the order of both matrices. The standard errors treat the
61
+ Sobol draws as fixed: they do not include simulation error, which is small
62
+ next to sampling error at the default `n_draws=512`.
63
+
64
+ ## Where the data goes
65
+
66
+ X, Y and `sample_weight` are uploaded to the service. The response holds
67
+ parameters only; the service keeps the result for collection, and nothing
68
+ after `fit` makes a network call — prediction and scoring run locally with the
69
+ chosen `evaluator`.
70
+
71
+ ## Cost
72
+
73
+ A fit is submitted, then polled until done. On a T4: about 50 s end to end for
74
+ n = 50,000, 10 features and d = 6, standard errors included; small fits take
75
+ 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
@@ -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.0"
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.0"
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)
@@ -1,4 +1,4 @@
1
- """The estimator: a multivariate probit fitted by IFM."""
1
+ """The estimator: a multivariate probit fitted by IFM, or by full ML on a GPU service."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -8,6 +8,7 @@ import warnings
8
8
  import numpy as np
9
9
  from scipy.stats import norm
10
10
 
11
+ from . import _remote
11
12
  from ._corr import is_positive_definite
12
13
  from ._mvn import EVALUATORS, pattern_prob
13
14
  from .ifm import joint_correlation, pair_log_likelihood, pairwise_correlation
@@ -19,6 +20,8 @@ __all__ = ["MultivariateProbit"]
19
20
 
20
21
  _LL_EPS = 1e-12
21
22
 
23
+ FITTERS = ("ifm", "modal")
24
+
22
25
  # Slopes outside this band warn. Deliberately loose: the clinical literature
23
26
  # often flags below 0.8, which is noisier than is useful for a warning that
24
27
  # cannot be turned off per-outcome.
@@ -114,6 +117,11 @@ class MultivariateProbit:
114
117
 
115
118
  See ``docs/ifm.md`` for the derivation and the trade-offs.
116
119
 
120
+ ``fitter="modal"`` is the alternative: full-information maximum likelihood,
121
+ every margin and ``R`` estimated jointly, on a hosted GPU service. It is
122
+ limited to linear margins, sends X, Y and the weights to the service, and
123
+ returns standard errors. See ``docs/fiml.md``.
124
+
117
125
  Parameters
118
126
  ----------
119
127
  inner : str, estimator, callable or list, default="linear"
@@ -167,7 +175,20 @@ class MultivariateProbit:
167
175
  Project a pairwise estimate onto the nearest positive-definite
168
176
  correlation matrix (pairwise fits need not be jointly coherent).
169
177
  random_state : int or Generator, optional
170
- Controls the cross-fitting split and :meth:`sample`.
178
+ Controls the cross-fitting split and :meth:`sample`. With
179
+ ``fitter="modal"``, an int also seeds the Sobol draws (otherwise 0, so
180
+ remote fits are reproducible by default).
181
+ fitter : {"ifm", "modal"}, default="ifm"
182
+ ``"ifm"`` is the two-stage fit described above, run locally.
183
+ ``"modal"`` is full maximum likelihood on the hosted GPU service:
184
+ linear margins only (``inner="linear"``; ``inner_params`` may set
185
+ ``alpha``), ``R`` and every margin fitted jointly by a GHK simulated
186
+ likelihood with fixed scrambled Sobol draws. ``dependence``, ``cv``,
187
+ ``optimizer`` and ``project_correlation`` do not apply to it. X, Y and
188
+ ``sample_weight`` are uploaded; only parameters come back, and every
189
+ prediction afterwards runs locally on ``evaluator``.
190
+ n_draws : int, default=512
191
+ Sobol draws per row for ``fitter="modal"``; ignored otherwise.
171
192
 
172
193
  Attributes
173
194
  ----------
@@ -191,7 +212,17 @@ class MultivariateProbit:
191
212
  the slope is not identified. Computed unweighted, ignoring
192
213
  ``sample_weight``.
193
214
  nll_ : float
194
- Negative log-likelihood at the end of the dependence fit.
215
+ Negative log-likelihood at the end of the dependence fit (for
216
+ ``fitter="modal"``, the simulated joint negative log-likelihood).
217
+ stderr_, stderr_robust_ : dict, ``fitter="modal"`` only
218
+ Standard errors keyed ``"coef"`` (d, n_features), ``"intercept"`` (d,)
219
+ and ``"correlation"`` (d, d), from the inverse Hessian and from the
220
+ sandwich (robust; the one to use with ``sample_weight``).
221
+ cov_params_, cov_params_robust_ : ndarray, ``fitter="modal"`` only
222
+ The matching covariance matrices, in the order of ``param_names_``.
223
+ param_names_ : list of str, ``fitter="modal"`` only
224
+ fit_result_ : dict, ``fitter="modal"`` only
225
+ ``n_iter``, ``converged``, ``grad_max``, ``n_draws``, ``seed``.
195
226
  n_outcomes_ : int
196
227
  n_features_in_ : int
197
228
  """
@@ -208,6 +239,8 @@ class MultivariateProbit:
208
239
  random_state=None,
209
240
  evaluator="quadrature",
210
241
  resolution="high",
242
+ fitter="ifm",
243
+ n_draws=512,
211
244
  ):
212
245
  self.inner = inner
213
246
  self.inner_params = inner_params
@@ -219,6 +252,8 @@ class MultivariateProbit:
219
252
  self.optimizer = optimizer
220
253
  self.project_correlation = project_correlation
221
254
  self.random_state = random_state
255
+ self.fitter = fitter
256
+ self.n_draws = n_draws
222
257
 
223
258
  # ------------------------------------------------------------------ fit
224
259
 
@@ -235,11 +270,15 @@ class MultivariateProbit:
235
270
  raise ValueError("Y must contain only 0/1 values")
236
271
  if self.evaluator not in EVALUATORS:
237
272
  raise ValueError(f"evaluator must be one of {EVALUATORS}; got {self.evaluator!r}")
273
+ if self.fitter not in FITTERS:
274
+ raise ValueError(f"fitter must be one of {FITTERS}; got {self.fitter!r}")
238
275
  Y = Y.astype(float)
239
276
 
240
277
  n, d = Y.shape
241
278
  self.n_features_in_ = X.shape[1]
242
279
  self.n_outcomes_ = d
280
+ if self.fitter == "modal":
281
+ return self._fit_modal(X, Y, sample_weight)
243
282
  rng = np.random.default_rng(self.random_state)
244
283
  params = dict(self.inner_params or {})
245
284
 
@@ -296,6 +335,87 @@ class MultivariateProbit:
296
335
  _require_positive_definite(self.correlation_)
297
336
  return self
298
337
 
338
+ def _fit_modal(self, X, Y, sample_weight):
339
+ if not (isinstance(self.inner, str) and self.inner == "linear"):
340
+ raise ValueError(
341
+ 'fitter="modal" fits linear margins only; use inner="linear" '
342
+ '(or fitter="ifm" for other inner models)'
343
+ )
344
+ params = dict(self.inner_params or {})
345
+ alpha = float(params.pop("alpha", 1e-6))
346
+ if params.pop("fit_intercept", True) is not True or params:
347
+ raise ValueError(
348
+ 'fitter="modal" accepts only inner_params={"alpha": ...}; '
349
+ "the intercept is always fitted"
350
+ )
351
+ if isinstance(self.random_state, (int, np.integer)):
352
+ seed = int(self.random_state)
353
+ else:
354
+ seed = 0
355
+ out = _remote.fit_remote(
356
+ X, Y, sample_weight, n_draws=int(self.n_draws), seed=seed, alpha=alpha, se=True
357
+ )
358
+
359
+ n, d = Y.shape
360
+ p = X.shape[1]
361
+ coef = np.asarray(out["coef"], dtype=float).reshape(d, p)
362
+ intercept = np.asarray(out["intercept"], dtype=float).reshape(d)
363
+ self.inner_models_ = []
364
+ for j in range(d):
365
+ model = ProbitRegressor(alpha=alpha)
366
+ model.coef_ = coef[j].copy()
367
+ model.intercept_ = float(intercept[j])
368
+ model.classes_ = np.array([0, 1])
369
+ model.n_iter_ = int(out["n_iter"])
370
+ self.inner_models_.append(model)
371
+ corr = np.asarray(out["corr"], dtype=float).reshape(d, d)
372
+ self.correlation_ = (corr + corr.T) / 2.0
373
+ np.fill_diagonal(self.correlation_, 1.0)
374
+ self.nll_ = float(out["nll"])
375
+ self.optimize_result_ = None
376
+ self.fit_result_ = {
377
+ "n_iter": int(out["n_iter"]),
378
+ "converged": bool(out["converged"]),
379
+ "grad_max": float(out["grad_max"]),
380
+ "n_draws": int(out["n_draws"]),
381
+ "seed": int(out["seed"]),
382
+ }
383
+
384
+ iu = np.triu_indices(d, 1)
385
+ self.param_names_ = (
386
+ [f"coef[{j},{k}]" for j in range(d) for k in range(p)]
387
+ + [f"intercept[{j}]" for j in range(d)]
388
+ + [f"rho[{j},{k}]" for j, k in zip(*iu)]
389
+ )
390
+
391
+ def unpack(se):
392
+ se = np.asarray(se, dtype=float)
393
+ rho = np.zeros((d, d))
394
+ rho[iu] = se[d * p + d :]
395
+ return {
396
+ "coef": se[: d * p].reshape(d, p),
397
+ "intercept": se[d * p : d * p + d],
398
+ "correlation": rho + rho.T,
399
+ }
400
+
401
+ self.stderr_ = unpack(out["se_hessian"])
402
+ self.stderr_robust_ = unpack(out["se_sandwich"])
403
+ self.cov_params_ = np.asarray(out["cov_hessian"], dtype=float)
404
+ self.cov_params_robust_ = np.asarray(out["cov_sandwich"], dtype=float)
405
+
406
+ if not self.fit_result_["converged"]:
407
+ warnings.warn(
408
+ f"the remote fit stopped at its iteration limit "
409
+ f"(max |gradient| {self.fit_result_['grad_max']:.2e}); the estimates "
410
+ "may not be at the optimum",
411
+ UserWarning,
412
+ stacklevel=3,
413
+ )
414
+ self.eta_ = self.decision_function(X)
415
+ self.calibration_ = self._calibrate(self.eta_, Y)
416
+ _require_positive_definite(self.correlation_)
417
+ return self
418
+
299
419
  def _calibrate(self, eta, Y):
300
420
  calibration = np.array(
301
421
  [_calibration_slope(eta[:, j], Y[:, j]) for j in range(Y.shape[1])],
@@ -468,6 +588,8 @@ class MultivariateProbit:
468
588
  "optimizer": self.optimizer,
469
589
  "project_correlation": self.project_correlation,
470
590
  "random_state": self.random_state,
591
+ "fitter": self.fitter,
592
+ "n_draws": self.n_draws,
471
593
  }
472
594
 
473
595
  def set_params(self, **params):
@@ -481,5 +603,5 @@ class MultivariateProbit:
481
603
  def __repr__(self):
482
604
  return (
483
605
  f"MultivariateProbit(inner={self.inner!r}, dependence={self.dependence!r}, "
484
- f"cv={self.cv!r})"
606
+ f"cv={self.cv!r}, fitter={self.fitter!r})"
485
607
  )
@@ -0,0 +1,192 @@
1
+ """``fitter="modal"``: the hosted full-likelihood fit.
2
+
3
+ Offline: the HTTP client is faked, so these check the request, the polling
4
+ protocol and how a result becomes a fitted model. The live round trip runs
5
+ only with ``MVP_LIVE_MODAL=1``.
6
+ """
7
+
8
+ import io
9
+ import json
10
+ import os
11
+ import urllib.error
12
+
13
+ import numpy as np
14
+ import pytest
15
+
16
+ from multivariate_probit import MultivariateProbit, ProbitRegressor
17
+ from multivariate_probit import _remote
18
+
19
+ D, P = 3, 2
20
+ COEF = np.array([[0.5, -1.0], [1.0, 0.2], [-0.5, 0.3]])
21
+ INTERCEPT = np.array([0.2, -0.3, 0.1])
22
+ CORR = np.array([[1.0, 0.5, -0.3], [0.5, 1.0, 0.4], [-0.3, 0.4, 1.0]])
23
+ K = D * P + D + D * (D - 1) // 2
24
+
25
+
26
+ def _data(n=400, seed=0):
27
+ rng = np.random.default_rng(seed)
28
+ X = rng.normal(size=(n, P))
29
+ E = rng.multivariate_normal(np.zeros(D), CORR, size=n)
30
+ return X, (X @ COEF.T + INTERCEPT + E > 0).astype(int)
31
+
32
+
33
+ def _result(converged=True):
34
+ se = np.arange(1, K + 1) / 100.0
35
+ return {
36
+ "coef": COEF, "intercept": INTERCEPT, "corr": CORR, "nll": np.array(123.4),
37
+ "n_iter": np.array(17), "converged": np.array(converged), "grad_max": np.array(3e-6),
38
+ "n_draws": np.array(512), "seed": np.array(0),
39
+ "se_hessian": se, "se_sandwich": 2 * se,
40
+ "cov_hessian": np.diag(se**2), "cov_sandwich": np.diag(4 * se**2),
41
+ }
42
+
43
+
44
+ @pytest.fixture
45
+ def fake_fit(monkeypatch):
46
+ calls = []
47
+
48
+ def fit_remote(X, Y, sample_weight=None, **params):
49
+ calls.append({"X": X, "Y": Y, "sample_weight": sample_weight, **params})
50
+ return _result()
51
+
52
+ monkeypatch.setattr(_remote, "fit_remote", fit_remote)
53
+ return calls
54
+
55
+
56
+ def test_result_becomes_a_fitted_linear_model(fake_fit):
57
+ X, Y = _data()
58
+ model = MultivariateProbit(fitter="modal", random_state=7).fit(X, Y)
59
+ assert all(isinstance(m, ProbitRegressor) for m in model.inner_models_)
60
+ assert np.allclose(model.decision_function(X), X @ COEF.T + INTERCEPT)
61
+ assert np.allclose(model.correlation_, CORR)
62
+ assert model.nll_ == pytest.approx(123.4)
63
+ assert model.fit_result_["converged"] and model.fit_result_["n_iter"] == 17
64
+ assert model.eta_.shape == (len(X), D) and model.calibration_.shape == (D, 2)
65
+ proba = model.predict_proba(X[:5])
66
+ assert proba.shape == (5, D) and proba.all().shape == (5,)
67
+ assert fake_fit[0]["seed"] == 7 and fake_fit[0]["n_draws"] == 512
68
+
69
+
70
+ def test_standard_errors_are_unpacked_in_parameter_order(fake_fit):
71
+ X, Y = _data()
72
+ model = MultivariateProbit(fitter="modal").fit(X, Y)
73
+ se = np.arange(1, K + 1) / 100.0
74
+ assert np.allclose(model.stderr_["coef"], se[: D * P].reshape(D, P))
75
+ assert np.allclose(model.stderr_["intercept"], se[D * P : D * P + D])
76
+ rho = model.stderr_["correlation"]
77
+ assert rho[0, 1] == rho[1, 0] == se[-3] and rho[1, 2] == se[-1] and rho[0, 0] == 0
78
+ assert np.allclose(model.stderr_robust_["intercept"], 2 * se[D * P : D * P + D])
79
+ assert model.param_names_[0] == "coef[0,0]" and model.param_names_[-1] == "rho[1,2]"
80
+ assert model.cov_params_.shape == (K, K) == model.cov_params_robust_.shape
81
+
82
+
83
+ def test_alpha_weights_and_seed_default_reach_the_service(fake_fit):
84
+ X, Y = _data()
85
+ w = np.linspace(0.5, 2.0, len(X))
86
+ MultivariateProbit(fitter="modal", inner_params={"alpha": 0.1}, n_draws=128).fit(
87
+ X, Y, sample_weight=w
88
+ )
89
+ call = fake_fit[0]
90
+ assert call["alpha"] == 0.1 and call["n_draws"] == 128 and call["seed"] == 0
91
+ assert np.array_equal(call["sample_weight"], w)
92
+
93
+
94
+ def test_non_linear_inner_is_rejected():
95
+ X, Y = _data(50)
96
+ with pytest.raises(ValueError, match="linear margins only"):
97
+ MultivariateProbit(inner="rf", fitter="modal").fit(X, Y)
98
+ with pytest.raises(ValueError, match="inner_params"):
99
+ MultivariateProbit(fitter="modal", inner_params={"max_iter": 5}).fit(X, Y)
100
+
101
+
102
+ def test_unknown_fitter_is_rejected():
103
+ X, Y = _data(50)
104
+ with pytest.raises(ValueError, match="fitter must be one of"):
105
+ MultivariateProbit(fitter="gpu").fit(X, Y)
106
+
107
+
108
+ def test_unconverged_fit_warns(monkeypatch):
109
+ monkeypatch.setattr(_remote, "fit_remote", lambda *a, **k: _result(converged=False))
110
+ X, Y = _data()
111
+ with pytest.warns(UserWarning, match="iteration limit"):
112
+ MultivariateProbit(fitter="modal").fit(X, Y)
113
+
114
+
115
+ def test_params_round_trip():
116
+ model = MultivariateProbit(fitter="modal", n_draws=256)
117
+ params = model.get_params()
118
+ assert params["fitter"] == "modal" and params["n_draws"] == 256
119
+ assert MultivariateProbit(**params).get_params() == params
120
+
121
+
122
+ # ------------------------------------------------------------ HTTP protocol
123
+
124
+
125
+ class _Resp:
126
+ def __init__(self, status, body):
127
+ self.status, self._body = status, body
128
+
129
+ def read(self):
130
+ return self._body
131
+
132
+ def __enter__(self):
133
+ return self
134
+
135
+ def __exit__(self, *exc):
136
+ return False
137
+
138
+
139
+ def _npz(arrays):
140
+ buf = io.BytesIO()
141
+ np.savez(buf, **arrays)
142
+ return buf.getvalue()
143
+
144
+
145
+ def test_client_submits_npz_then_polls_until_done(monkeypatch):
146
+ seen = []
147
+ pending = [_Resp(202, b'{"status": "pending"}')] * 2
148
+
149
+ def urlopen(request, timeout=None):
150
+ if not isinstance(request, str): # the POST
151
+ with np.load(io.BytesIO(request.data)) as npz:
152
+ seen.append((npz["X"].shape, json.loads(str(npz["params"]))))
153
+ return _Resp(200, b'{"call_id": "fc-123"}')
154
+ assert "call_id=fc-123" in request and "format=npz" in request
155
+ return pending.pop() if pending else _Resp(200, _npz({"coef": np.ones((2, 1))}))
156
+
157
+ monkeypatch.setattr(_remote.urllib.request, "urlopen", urlopen)
158
+ out = _remote.fit_remote(np.zeros((4, 1)), np.zeros((4, 2)), poll_interval=0, n_draws=64)
159
+ assert out["coef"].shape == (2, 1)
160
+ assert seen == [((4, 1), {"n_draws": 64})]
161
+
162
+
163
+ @pytest.mark.parametrize("code, exc", [(400, ValueError), (500, RuntimeError)])
164
+ def test_client_maps_submit_errors(monkeypatch, code, exc):
165
+ def urlopen(request, timeout=None):
166
+ raise urllib.error.HTTPError(
167
+ _remote.FIT_URL, code, "x", {}, io.BytesIO(b'{"error": "bad Y"}')
168
+ )
169
+
170
+ monkeypatch.setattr(_remote.urllib.request, "urlopen", urlopen)
171
+ with pytest.raises(exc, match="bad Y"):
172
+ _remote.fit_remote(np.zeros((4, 1)), np.zeros((4, 2)))
173
+
174
+
175
+ def test_client_reports_a_failed_fit(monkeypatch):
176
+ def urlopen(request, timeout=None):
177
+ if not isinstance(request, str):
178
+ return _Resp(200, b'{"call_id": "fc-9"}')
179
+ raise urllib.error.HTTPError(request, 422, "x", {}, io.BytesIO(b'{"error": "boom"}'))
180
+
181
+ monkeypatch.setattr(_remote.urllib.request, "urlopen", urlopen)
182
+ with pytest.raises(RuntimeError, match="fc-9.*boom"):
183
+ _remote.fit_remote(np.zeros((4, 1)), np.zeros((4, 2)))
184
+
185
+
186
+ @pytest.mark.skipif(os.environ.get("MVP_LIVE_MODAL") != "1", reason="set MVP_LIVE_MODAL=1")
187
+ def test_live_round_trip():
188
+ X, Y = _data(3000)
189
+ model = MultivariateProbit(fitter="modal").fit(X, Y)
190
+ assert np.abs(np.vstack([m.coef_ for m in model.inner_models_]) - COEF).max() < 0.15
191
+ assert np.abs(model.correlation_ - CORR).max() < 0.15
192
+ assert np.all(model.stderr_["coef"] > 0)