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.
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/CITATION.cff +2 -2
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/PKG-INFO +17 -6
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/README.md +16 -5
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/api.md +12 -3
- multivariate_probit-0.3.0/docs/fiml.md +75 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/limitations.md +2 -1
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/pyproject.toml +1 -1
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/__init__.py +3 -2
- multivariate_probit-0.3.0/src/multivariate_probit/_remote.py +81 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/model.py +126 -4
- multivariate_probit-0.3.0/tests/test_modal_fit.py +192 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/.github/ISSUE_TEMPLATE/use-case.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/.gitignore +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/LICENSE +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/bench.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/benchmarks.ipynb +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d12_n3000_100_rho0.4_s0.npz +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d16_n3000_100_rho0.4_s0.npz +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d3_n3000_100_rho0.4_s0.npz +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d5_n3000_100_rho0.4_s0.npz +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d8_n3000_100_rho0.4_s0.npz +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d12_n3000_100_s0.npz +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d16_n3000_100_s0.npz +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d3_n3000_100_s0.npz +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d5_n3000_100_s0.npz +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d8_n3000_100_s0.npz +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/results.csv +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/benchmarks.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/ifm.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/implementation.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/README.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/comparators.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/ghk.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/index-correlation-shortcut.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/log-score-evaluator.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/margin-gaps.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/mulan-multilabel.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/multilabel-benchmarks.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/sparse-label-transfer.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/uci-credit.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/README.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/claims-fast-track.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/content-moderation.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/cyber.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/fraud-vendor-fusion.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/hcc-codes.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/property-imagery.md +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/hatch_build.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/_corr.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/_mvn.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/ifm.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/inner.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/linear.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/orthant/__init__.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/results.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/conftest.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_calibration.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_conditional.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_contract.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_correlation_projection.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_evaluators.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_linear.py +0 -0
- {multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_rf.py +0 -0
- {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.
|
|
10
|
-
|
|
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.
|
|
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
|
|
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
|
-
- **
|
|
170
|
-
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
106
|
-
|
|
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
|
|
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.** `
|
|
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.
|
|
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.
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d12_n3000_100_rho0.4_s0.npz
RENAMED
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d16_n3000_100_rho0.4_s0.npz
RENAMED
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d3_n3000_100_rho0.4_s0.npz
RENAMED
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d5_n3000_100_rho0.4_s0.npz
RENAMED
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_d8_n3000_100_rho0.4_s0.npz
RENAMED
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d12_n3000_100_s0.npz
RENAMED
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d16_n3000_100_s0.npz
RENAMED
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d3_n3000_100_s0.npz
RENAMED
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d5_n3000_100_s0.npz
RENAMED
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/benchmarks/ref_pairwise_d8_n3000_100_s0.npz
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/index-correlation-shortcut.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/multilabel-benchmarks.md
RENAMED
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/studies/sparse-label-transfer.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/content-moderation.md
RENAMED
|
File without changes
|
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/docs/use-cases/fraud-vendor-fusion.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/src/multivariate_probit/orthant/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{multivariate_probit-0.2.3 → multivariate_probit-0.3.0}/tests/test_correlation_projection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|