multivariate-probit 0.1.0__py3-none-any.whl
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/__init__.py +56 -0
- multivariate_probit/_corr.py +51 -0
- multivariate_probit/_mvn.py +155 -0
- multivariate_probit/ifm.py +177 -0
- multivariate_probit/inner.py +187 -0
- multivariate_probit/linear.py +118 -0
- multivariate_probit/model.py +365 -0
- multivariate_probit/results.py +72 -0
- multivariate_probit-0.1.0.dist-info/METADATA +175 -0
- multivariate_probit-0.1.0.dist-info/RECORD +12 -0
- multivariate_probit-0.1.0.dist-info/WHEEL +4 -0
- multivariate_probit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""A native linear probit margin -- the default inner model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.stats import norm
|
|
7
|
+
|
|
8
|
+
__all__ = ["ProbitRegressor"]
|
|
9
|
+
|
|
10
|
+
_P_EPS = 1e-10
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProbitRegressor:
|
|
14
|
+
"""Binary probit regression fitted by Newton-Raphson (IRLS).
|
|
15
|
+
|
|
16
|
+
This is the one inner model that already lives on the probit scale, so no
|
|
17
|
+
calibration is needed: ``latent`` is literally ``X @ coef_ + intercept_``.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
alpha : float, default=1e-6
|
|
22
|
+
L2 penalty on the slopes (never on the intercept). The default is small
|
|
23
|
+
enough to be statistically negligible and large enough to keep
|
|
24
|
+
separated or collinear designs from blowing up.
|
|
25
|
+
fit_intercept : bool, default=True
|
|
26
|
+
max_iter : int, default=100
|
|
27
|
+
tol : float, default=1e-8
|
|
28
|
+
Convergence threshold on the max absolute coefficient update.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, alpha=1e-6, fit_intercept=True, max_iter=100, tol=1e-8):
|
|
32
|
+
self.alpha = alpha
|
|
33
|
+
self.fit_intercept = fit_intercept
|
|
34
|
+
self.max_iter = max_iter
|
|
35
|
+
self.tol = tol
|
|
36
|
+
|
|
37
|
+
def _design(self, X):
|
|
38
|
+
X = np.asarray(X, dtype=float)
|
|
39
|
+
if X.ndim == 1:
|
|
40
|
+
X = X[:, None]
|
|
41
|
+
if self.fit_intercept:
|
|
42
|
+
return np.hstack([np.ones((X.shape[0], 1)), X])
|
|
43
|
+
return X
|
|
44
|
+
|
|
45
|
+
def fit(self, X, y):
|
|
46
|
+
Z = self._design(X)
|
|
47
|
+
y = np.asarray(y, dtype=float).ravel()
|
|
48
|
+
n, p = Z.shape
|
|
49
|
+
|
|
50
|
+
penalty = np.full(p, float(self.alpha))
|
|
51
|
+
if self.fit_intercept:
|
|
52
|
+
penalty[0] = 0.0
|
|
53
|
+
ridge = np.diag(penalty)
|
|
54
|
+
|
|
55
|
+
beta = np.zeros(p)
|
|
56
|
+
n_iter = 0
|
|
57
|
+
for n_iter in range(1, self.max_iter + 1):
|
|
58
|
+
eta = Z @ beta
|
|
59
|
+
prob = np.clip(norm.cdf(eta), _P_EPS, 1.0 - _P_EPS)
|
|
60
|
+
dens = np.maximum(norm.pdf(eta), _P_EPS)
|
|
61
|
+
|
|
62
|
+
# IRLS weights and working response for the probit link.
|
|
63
|
+
w = dens**2 / (prob * (1.0 - prob))
|
|
64
|
+
working = eta + (y - prob) / dens
|
|
65
|
+
|
|
66
|
+
lhs = Z.T @ (Z * w[:, None]) + ridge
|
|
67
|
+
rhs = Z.T @ (w * working)
|
|
68
|
+
try:
|
|
69
|
+
new_beta = np.linalg.solve(lhs, rhs)
|
|
70
|
+
except np.linalg.LinAlgError:
|
|
71
|
+
new_beta = np.linalg.lstsq(lhs, rhs, rcond=None)[0]
|
|
72
|
+
|
|
73
|
+
step = np.max(np.abs(new_beta - beta))
|
|
74
|
+
beta = new_beta
|
|
75
|
+
if step < self.tol:
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
self.n_iter_ = n_iter
|
|
79
|
+
if self.fit_intercept:
|
|
80
|
+
self.intercept_ = float(beta[0])
|
|
81
|
+
self.coef_ = beta[1:]
|
|
82
|
+
else:
|
|
83
|
+
self.intercept_ = 0.0
|
|
84
|
+
self.coef_ = beta
|
|
85
|
+
self.classes_ = np.array([0, 1])
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def latent(self, X):
|
|
89
|
+
"""The probit index eta on (-inf, inf)."""
|
|
90
|
+
X = np.asarray(X, dtype=float)
|
|
91
|
+
if X.ndim == 1:
|
|
92
|
+
X = X[:, None]
|
|
93
|
+
return X @ self.coef_ + self.intercept_
|
|
94
|
+
|
|
95
|
+
decision_function = latent
|
|
96
|
+
|
|
97
|
+
def predict_proba(self, X):
|
|
98
|
+
p = norm.cdf(self.latent(X))
|
|
99
|
+
return np.column_stack([1.0 - p, p])
|
|
100
|
+
|
|
101
|
+
def predict(self, X, threshold=0.5):
|
|
102
|
+
return (norm.cdf(self.latent(X)) >= threshold).astype(int)
|
|
103
|
+
|
|
104
|
+
def get_params(self, deep=True):
|
|
105
|
+
return {
|
|
106
|
+
"alpha": self.alpha,
|
|
107
|
+
"fit_intercept": self.fit_intercept,
|
|
108
|
+
"max_iter": self.max_iter,
|
|
109
|
+
"tol": self.tol,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def set_params(self, **params):
|
|
113
|
+
for key, value in params.items():
|
|
114
|
+
setattr(self, key, value)
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __repr__(self):
|
|
118
|
+
return f"ProbitRegressor(alpha={self.alpha!r}, fit_intercept={self.fit_intercept!r})"
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""The estimator: a multivariate probit fitted by IFM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy.stats import norm
|
|
9
|
+
|
|
10
|
+
from ._corr import is_positive_definite
|
|
11
|
+
from ._mvn import pattern_prob
|
|
12
|
+
from .ifm import joint_correlation, pair_log_likelihood, pairwise_correlation
|
|
13
|
+
from .inner import as_inner
|
|
14
|
+
from .results import MultivariateProbitProba
|
|
15
|
+
|
|
16
|
+
__all__ = ["MultivariateProbit"]
|
|
17
|
+
|
|
18
|
+
_LL_EPS = 1e-12
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _kfold_indices(n, n_splits, rng):
|
|
22
|
+
idx = np.arange(n)
|
|
23
|
+
rng.shuffle(idx)
|
|
24
|
+
for fold in np.array_split(idx, n_splits):
|
|
25
|
+
mask = np.zeros(n, dtype=bool)
|
|
26
|
+
mask[fold] = True
|
|
27
|
+
yield ~mask, mask
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _require_positive_definite(corr):
|
|
31
|
+
"""Reject a correlation matrix that would silently corrupt joint queries.
|
|
32
|
+
|
|
33
|
+
Only reachable with ``project_correlation=False``: pairwise estimates are
|
|
34
|
+
fitted one pair at a time and need not be jointly coherent. An indefinite
|
|
35
|
+
matrix makes ``sample`` fail at the Cholesky and -- worse -- makes
|
|
36
|
+
``joint_proba`` / ``.all()`` / ``.any()`` return numbers that are not
|
|
37
|
+
probabilities of anything, with no error at all.
|
|
38
|
+
"""
|
|
39
|
+
if not is_positive_definite(corr):
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"the fitted correlation matrix is not positive definite (smallest "
|
|
42
|
+
f"eigenvalue {np.linalg.eigvalsh(corr).min():.4g}). Pairwise estimates "
|
|
43
|
+
"are fitted independently and need not be jointly coherent, so this is "
|
|
44
|
+
"expected with project_correlation=False. Set project_correlation=True "
|
|
45
|
+
"to project onto the nearest valid correlation matrix, or call "
|
|
46
|
+
"pairwise_correlation(..., project=False) directly if you want the raw "
|
|
47
|
+
"estimates for inspection."
|
|
48
|
+
)
|
|
49
|
+
return corr
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _supports_sample_weight(estimator):
|
|
53
|
+
try:
|
|
54
|
+
return "sample_weight" in inspect.signature(estimator.fit).parameters
|
|
55
|
+
except (TypeError, ValueError): # pragma: no cover - exotic callables
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MultivariateProbit:
|
|
60
|
+
"""Multivariate probit for correlated binary outcomes, fitted by IFM.
|
|
61
|
+
|
|
62
|
+
The model is a Gaussian squashing function wrapped around an arbitrary
|
|
63
|
+
inner model. For outcome ``j``::
|
|
64
|
+
|
|
65
|
+
Y_j = 1[eta_j(x) + e_j > 0], e ~ N(0, R)
|
|
66
|
+
|
|
67
|
+
where ``eta_j`` is any real-valued function of the features -- linear by
|
|
68
|
+
default, a gradient-boosted ensemble if you ask for one -- and ``R`` is a
|
|
69
|
+
correlation matrix carrying the dependence between outcomes. Marginally,
|
|
70
|
+
``P(Y_j = 1 | x) = Phi(eta_j(x))``.
|
|
71
|
+
|
|
72
|
+
Fitting is two-stage IFM (Inference Functions for Margins):
|
|
73
|
+
|
|
74
|
+
1. Fit each margin independently, ignoring the other outcomes. The fitted
|
|
75
|
+
margins are cross-fitted (out-of-fold) so that stage two never sees an
|
|
76
|
+
in-sample, over-confident prediction.
|
|
77
|
+
2. Hold the margins fixed and estimate ``R`` by maximum likelihood --
|
|
78
|
+
either the full d-variate orthant likelihood (``dependence="joint"``)
|
|
79
|
+
or the sum of bivariate ones (``dependence="pairwise"``).
|
|
80
|
+
|
|
81
|
+
IFM was chosen over full joint MLE (FIML) and over a direct joint
|
|
82
|
+
classifier: it leaves the inner model a black box, and its cost advantage
|
|
83
|
+
over FIML widens with the number of outcomes, since the orthant probability
|
|
84
|
+
IFM pays for once is paid by FIML on every boosting round of every margin.
|
|
85
|
+
|
|
86
|
+
See ``docs/ifm.md`` for the derivation and the trade-offs.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
inner : str, estimator, callable or list, default="linear"
|
|
91
|
+
The inner model. A preset name (``"linear"``, ``"xgboost"``, ``"rf"``),
|
|
92
|
+
an unfitted estimator instance, a factory, or a list of length ``d``
|
|
93
|
+
giving a different inner model per outcome. Instances are deep-copied,
|
|
94
|
+
so one instance can safely seed every margin.
|
|
95
|
+
inner_params : dict, optional
|
|
96
|
+
Keyword arguments forwarded to the preset factory. Ignored when
|
|
97
|
+
``inner`` is already an instance.
|
|
98
|
+
dependence : {"joint", "pairwise"}, default="joint"
|
|
99
|
+
How stage two estimates ``R``. ``"joint"`` maximises the full
|
|
100
|
+
d-variate likelihood -- exact, and the default. ``"pairwise"``
|
|
101
|
+
maximises each pair's bivariate likelihood separately (a composite
|
|
102
|
+
likelihood): consistent, far cheaper, and the practical choice once
|
|
103
|
+
``d`` is large enough that orthant integration bites.
|
|
104
|
+
cv : int or None, default=5
|
|
105
|
+
Number of folds used to cross-fit the latent indices that stage two
|
|
106
|
+
consumes. In-sample indices from a flexible learner have already
|
|
107
|
+
absorbed part of the noise, which attenuates the estimated
|
|
108
|
+
correlations toward zero. ``None`` skips cross-fitting -- reasonable
|
|
109
|
+
for the linear default, risky for anything that can overfit.
|
|
110
|
+
n_quad : int, default=24
|
|
111
|
+
Gauss-Legendre order for the orthant-probability evaluator. Lower it
|
|
112
|
+
if fitting with many outcomes gets slow.
|
|
113
|
+
optimizer : str, default="Nelder-Mead"
|
|
114
|
+
Passed to ``scipy.optimize.minimize`` for ``dependence="joint"``.
|
|
115
|
+
Derivative-free by design: the orthant likelihood has no convenient
|
|
116
|
+
closed-form gradient here.
|
|
117
|
+
project_correlation : bool, default=True
|
|
118
|
+
Project a pairwise estimate onto the nearest positive-definite
|
|
119
|
+
correlation matrix (pairwise fits need not be jointly coherent).
|
|
120
|
+
random_state : int or Generator, optional
|
|
121
|
+
Controls the cross-fitting split and :meth:`sample`.
|
|
122
|
+
|
|
123
|
+
Attributes
|
|
124
|
+
----------
|
|
125
|
+
inner_models_ : list
|
|
126
|
+
The fitted margins, one per outcome.
|
|
127
|
+
correlation_ : ndarray of shape (d, d)
|
|
128
|
+
eta_ : ndarray of shape (n, d)
|
|
129
|
+
The (cross-fitted) latent indices stage two was fitted on.
|
|
130
|
+
nll_ : float
|
|
131
|
+
Negative log-likelihood at the end of the dependence fit.
|
|
132
|
+
n_outcomes_ : int
|
|
133
|
+
n_features_in_ : int
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
inner="linear",
|
|
139
|
+
inner_params=None,
|
|
140
|
+
dependence="joint",
|
|
141
|
+
cv=5,
|
|
142
|
+
n_quad=24,
|
|
143
|
+
optimizer="Nelder-Mead",
|
|
144
|
+
project_correlation=True,
|
|
145
|
+
random_state=None,
|
|
146
|
+
):
|
|
147
|
+
self.inner = inner
|
|
148
|
+
self.inner_params = inner_params
|
|
149
|
+
self.dependence = dependence
|
|
150
|
+
self.cv = cv
|
|
151
|
+
self.n_quad = n_quad
|
|
152
|
+
self.optimizer = optimizer
|
|
153
|
+
self.project_correlation = project_correlation
|
|
154
|
+
self.random_state = random_state
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------ fit
|
|
157
|
+
|
|
158
|
+
def fit(self, X, Y, sample_weight=None):
|
|
159
|
+
X = np.asarray(X, dtype=float)
|
|
160
|
+
if X.ndim == 1:
|
|
161
|
+
X = X[:, None]
|
|
162
|
+
Y = np.asarray(Y)
|
|
163
|
+
if Y.ndim == 1:
|
|
164
|
+
Y = Y[:, None]
|
|
165
|
+
if Y.shape[0] != X.shape[0]:
|
|
166
|
+
raise ValueError(f"X has {X.shape[0]} rows but Y has {Y.shape[0]}")
|
|
167
|
+
if not np.isin(np.unique(Y), (0, 1)).all():
|
|
168
|
+
raise ValueError("Y must contain only 0/1 values")
|
|
169
|
+
Y = Y.astype(float)
|
|
170
|
+
|
|
171
|
+
n, d = Y.shape
|
|
172
|
+
self.n_features_in_ = X.shape[1]
|
|
173
|
+
self.n_outcomes_ = d
|
|
174
|
+
rng = np.random.default_rng(self.random_state)
|
|
175
|
+
params = dict(self.inner_params or {})
|
|
176
|
+
|
|
177
|
+
specs = self.inner if isinstance(self.inner, (list, tuple)) else [self.inner] * d
|
|
178
|
+
if len(specs) != d:
|
|
179
|
+
raise ValueError(f"inner has length {len(specs)} but Y has {d} columns")
|
|
180
|
+
|
|
181
|
+
# ---- stage 1: independent margins
|
|
182
|
+
self.inner_models_ = []
|
|
183
|
+
for j, spec in enumerate(specs):
|
|
184
|
+
model = as_inner(spec, **params)
|
|
185
|
+
self._fit_margin(model, X, Y[:, j], sample_weight)
|
|
186
|
+
self.inner_models_.append(model)
|
|
187
|
+
|
|
188
|
+
# ---- latent indices for stage 2
|
|
189
|
+
if self.cv is None:
|
|
190
|
+
eta = self.decision_function(X)
|
|
191
|
+
else:
|
|
192
|
+
eta = self._oof_decision_function(X, Y, specs, params, sample_weight, rng)
|
|
193
|
+
self.eta_ = eta
|
|
194
|
+
|
|
195
|
+
# ---- stage 2: dependence
|
|
196
|
+
if self.dependence == "joint":
|
|
197
|
+
self.correlation_, self.optimize_result_ = joint_correlation(
|
|
198
|
+
eta,
|
|
199
|
+
Y,
|
|
200
|
+
weights=sample_weight,
|
|
201
|
+
n_quad=self.n_quad,
|
|
202
|
+
optimizer=self.optimizer,
|
|
203
|
+
)
|
|
204
|
+
self.nll_ = None if self.optimize_result_ is None else float(self.optimize_result_.fun)
|
|
205
|
+
elif self.dependence == "pairwise":
|
|
206
|
+
self.correlation_ = pairwise_correlation(
|
|
207
|
+
eta, Y, weights=sample_weight, project=self.project_correlation
|
|
208
|
+
)
|
|
209
|
+
self.optimize_result_ = None
|
|
210
|
+
# For the composite fit, report the objective that was actually
|
|
211
|
+
# optimised: the summed pairwise log-likelihood, not the joint one.
|
|
212
|
+
self.nll_ = -sum(
|
|
213
|
+
pair_log_likelihood(
|
|
214
|
+
self.correlation_[j, k], eta[:, j], eta[:, k], Y[:, j], Y[:, k], sample_weight
|
|
215
|
+
)
|
|
216
|
+
for j in range(d)
|
|
217
|
+
for k in range(j + 1, d)
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"dependence must be 'joint' or 'pairwise'; got {self.dependence!r}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
_require_positive_definite(self.correlation_)
|
|
225
|
+
return self
|
|
226
|
+
|
|
227
|
+
def _fit_margin(self, model, X, y, sample_weight):
|
|
228
|
+
if sample_weight is not None and _supports_sample_weight(model):
|
|
229
|
+
model.fit(X, y, sample_weight=sample_weight)
|
|
230
|
+
else:
|
|
231
|
+
model.fit(X, y)
|
|
232
|
+
return model
|
|
233
|
+
|
|
234
|
+
def _oof_decision_function(self, X, Y, specs, params, sample_weight, rng):
|
|
235
|
+
n, d = Y.shape
|
|
236
|
+
n_splits = int(self.cv)
|
|
237
|
+
if not 2 <= n_splits <= n:
|
|
238
|
+
raise ValueError(f"cv must be between 2 and n_samples ({n}); got {self.cv}")
|
|
239
|
+
|
|
240
|
+
eta = np.empty((n, d))
|
|
241
|
+
for train, test in _kfold_indices(n, n_splits, rng):
|
|
242
|
+
weight = None if sample_weight is None else np.asarray(sample_weight)[train]
|
|
243
|
+
for j, spec in enumerate(specs):
|
|
244
|
+
fold_model = as_inner(spec, **params)
|
|
245
|
+
self._fit_margin(fold_model, X[train], Y[train, j], weight)
|
|
246
|
+
eta[test, j] = fold_model.latent(X[test])
|
|
247
|
+
return eta
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------- predict
|
|
250
|
+
|
|
251
|
+
def _check_fitted(self):
|
|
252
|
+
if not hasattr(self, "inner_models_"):
|
|
253
|
+
raise RuntimeError("this MultivariateProbit is not fitted yet; call fit first")
|
|
254
|
+
|
|
255
|
+
def _check_X(self, X):
|
|
256
|
+
self._check_fitted()
|
|
257
|
+
X = np.asarray(X, dtype=float)
|
|
258
|
+
if X.ndim == 1:
|
|
259
|
+
X = X[:, None]
|
|
260
|
+
if X.shape[1] != self.n_features_in_:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
f"X has {X.shape[1]} features but the model was fitted with {self.n_features_in_}"
|
|
263
|
+
)
|
|
264
|
+
return X
|
|
265
|
+
|
|
266
|
+
def decision_function(self, X):
|
|
267
|
+
"""Latent indices ``eta``, shape (n, d), on (-inf, inf)."""
|
|
268
|
+
X = self._check_X(X)
|
|
269
|
+
return np.column_stack([m.latent(X) for m in self.inner_models_])
|
|
270
|
+
|
|
271
|
+
def transform(self, X):
|
|
272
|
+
"""Alias of :meth:`decision_function`: features to latent scores."""
|
|
273
|
+
return self.decision_function(X)
|
|
274
|
+
|
|
275
|
+
def fit_transform(self, X, Y, **fit_params):
|
|
276
|
+
return self.fit(X, Y, **fit_params).transform(X)
|
|
277
|
+
|
|
278
|
+
def predict_proba(self, X):
|
|
279
|
+
"""Marginal probabilities, plus joint queries.
|
|
280
|
+
|
|
281
|
+
Returns a :class:`~multivariate_probit.results.MultivariateProbitProba`,
|
|
282
|
+
which behaves like the ``(n, d)`` array of marginal probabilities and
|
|
283
|
+
also answers ``.joint(pattern)``, ``.all()``, ``.any()`` and
|
|
284
|
+
``.none()``.
|
|
285
|
+
"""
|
|
286
|
+
return MultivariateProbitProba(
|
|
287
|
+
self.decision_function(X), self.correlation_, n_quad=self.n_quad
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def predict_marginal_proba(self, X):
|
|
291
|
+
"""Marginal probabilities ``P(Y_j = 1 | x)`` as a plain (n, d) array."""
|
|
292
|
+
return norm.cdf(self.decision_function(X))
|
|
293
|
+
|
|
294
|
+
def predict(self, X, threshold=0.5):
|
|
295
|
+
"""Per-outcome 0/1 predictions at a marginal threshold, shape (n, d).
|
|
296
|
+
|
|
297
|
+
For a joint decision -- thresholding ``P(all outcomes = 1)``, say --
|
|
298
|
+
use ``predict_proba(X).all()`` / ``.any()`` / ``.joint(...)``.
|
|
299
|
+
"""
|
|
300
|
+
return (self.predict_marginal_proba(X) >= threshold).astype(int)
|
|
301
|
+
|
|
302
|
+
def joint_proba(self, X, Y):
|
|
303
|
+
"""``P(Y = y | x)`` for the given outcome pattern(s), shape (n,).
|
|
304
|
+
|
|
305
|
+
``Y`` may be one pattern of length ``d`` (broadcast over all rows) or an
|
|
306
|
+
array of shape (n, d).
|
|
307
|
+
"""
|
|
308
|
+
eta = self.decision_function(X)
|
|
309
|
+
Y = np.asarray(Y, dtype=float)
|
|
310
|
+
if Y.ndim == 1:
|
|
311
|
+
Y = Y[None, :]
|
|
312
|
+
return pattern_prob(eta, Y, self.correlation_, n_quad=self.n_quad)
|
|
313
|
+
|
|
314
|
+
def joint_log_proba(self, X, Y):
|
|
315
|
+
return np.log(np.clip(self.joint_proba(X, Y), _LL_EPS, None))
|
|
316
|
+
|
|
317
|
+
def score(self, X, Y, sample_weight=None):
|
|
318
|
+
"""Mean joint log-likelihood -- higher is better."""
|
|
319
|
+
ll = self.joint_log_proba(X, Y)
|
|
320
|
+
if sample_weight is None:
|
|
321
|
+
return float(np.mean(ll))
|
|
322
|
+
w = np.asarray(sample_weight, dtype=float)
|
|
323
|
+
return float(np.sum(w * ll) / np.sum(w))
|
|
324
|
+
|
|
325
|
+
def sample(self, X, n_samples=1, random_state=None):
|
|
326
|
+
"""Draw outcome patterns from the fitted model.
|
|
327
|
+
|
|
328
|
+
Returns shape (n, d) when ``n_samples == 1``, else (n_samples, n, d).
|
|
329
|
+
"""
|
|
330
|
+
X = self._check_X(X)
|
|
331
|
+
eta = self.decision_function(X)
|
|
332
|
+
rng = np.random.default_rng(self.random_state if random_state is None else random_state)
|
|
333
|
+
chol = np.linalg.cholesky(self.correlation_)
|
|
334
|
+
n, d = eta.shape
|
|
335
|
+
noise = rng.standard_normal((n_samples, n, d)) @ chol.T
|
|
336
|
+
draws = (eta[None, :, :] + noise > 0).astype(int)
|
|
337
|
+
return draws[0] if n_samples == 1 else draws
|
|
338
|
+
|
|
339
|
+
# -------------------------------------------------------------- params
|
|
340
|
+
|
|
341
|
+
def get_params(self, deep=True):
|
|
342
|
+
return {
|
|
343
|
+
"inner": self.inner,
|
|
344
|
+
"inner_params": self.inner_params,
|
|
345
|
+
"dependence": self.dependence,
|
|
346
|
+
"cv": self.cv,
|
|
347
|
+
"n_quad": self.n_quad,
|
|
348
|
+
"optimizer": self.optimizer,
|
|
349
|
+
"project_correlation": self.project_correlation,
|
|
350
|
+
"random_state": self.random_state,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
def set_params(self, **params):
|
|
354
|
+
valid = self.get_params()
|
|
355
|
+
for key, value in params.items():
|
|
356
|
+
if key not in valid:
|
|
357
|
+
raise ValueError(f"invalid parameter {key!r} for MultivariateProbit")
|
|
358
|
+
setattr(self, key, value)
|
|
359
|
+
return self
|
|
360
|
+
|
|
361
|
+
def __repr__(self):
|
|
362
|
+
return (
|
|
363
|
+
f"MultivariateProbit(inner={self.inner!r}, dependence={self.dependence!r}, "
|
|
364
|
+
f"cv={self.cv!r})"
|
|
365
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The object returned by :meth:`MultivariateProbit.predict_proba`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.stats import norm
|
|
7
|
+
|
|
8
|
+
from ._mvn import mvn_orthant, pattern_prob, signed_corr_stack
|
|
9
|
+
|
|
10
|
+
__all__ = ["MultivariateProbitProba"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MultivariateProbitProba:
|
|
14
|
+
"""Marginal probabilities, plus joint queries against the fitted copula.
|
|
15
|
+
|
|
16
|
+
Behaves like the ``(n_samples, n_outcomes)`` array of marginal
|
|
17
|
+
probabilities in most contexts -- indexing, ``np.asarray(...)``, ``.shape``
|
|
18
|
+
-- and additionally answers joint questions, which are the reason the
|
|
19
|
+
correlation matrix was estimated in the first place.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, eta, corr, n_quad=24):
|
|
23
|
+
self.eta = np.atleast_2d(np.asarray(eta, dtype=float))
|
|
24
|
+
self.marginal = norm.cdf(self.eta)
|
|
25
|
+
self.corr = np.asarray(corr, dtype=float)
|
|
26
|
+
self.n_quad = n_quad
|
|
27
|
+
|
|
28
|
+
# ------------------------------------------------------ array behaviour
|
|
29
|
+
|
|
30
|
+
def __array__(self, dtype=None):
|
|
31
|
+
return np.asarray(self.marginal, dtype=dtype)
|
|
32
|
+
|
|
33
|
+
def __getitem__(self, idx):
|
|
34
|
+
return self.marginal[idx]
|
|
35
|
+
|
|
36
|
+
def __len__(self):
|
|
37
|
+
return len(self.marginal)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def shape(self):
|
|
41
|
+
return self.marginal.shape
|
|
42
|
+
|
|
43
|
+
def __repr__(self):
|
|
44
|
+
n, d = self.shape
|
|
45
|
+
return f"MultivariateProbitProba(n_samples={n}, n_outcomes={d})"
|
|
46
|
+
|
|
47
|
+
# --------------------------------------------------------- joint queries
|
|
48
|
+
|
|
49
|
+
def _subset(self, outcomes):
|
|
50
|
+
idx = np.arange(self.shape[1]) if outcomes is None else np.asarray(outcomes)
|
|
51
|
+
return self.eta[:, idx], self.corr[np.ix_(idx, idx)]
|
|
52
|
+
|
|
53
|
+
def joint(self, pattern):
|
|
54
|
+
"""``P(Y = pattern | x)`` for a fully specified 0/1 pattern, per row."""
|
|
55
|
+
pattern = np.asarray(pattern, dtype=float)
|
|
56
|
+
if pattern.ndim == 1:
|
|
57
|
+
pattern = pattern[None, :]
|
|
58
|
+
return pattern_prob(self.eta, pattern, self.corr, n_quad=self.n_quad)
|
|
59
|
+
|
|
60
|
+
def all(self, outcomes=None):
|
|
61
|
+
"""``P(every selected outcome = 1 | x)``. Defaults to all outcomes."""
|
|
62
|
+
eta, corr = self._subset(outcomes)
|
|
63
|
+
return mvn_orthant(eta, signed_corr_stack(corr, np.ones_like(eta)), n_quad=self.n_quad)
|
|
64
|
+
|
|
65
|
+
def any(self, outcomes=None):
|
|
66
|
+
"""``P(at least one selected outcome = 1 | x)``. Defaults to all outcomes."""
|
|
67
|
+
return 1.0 - self.none(outcomes)
|
|
68
|
+
|
|
69
|
+
def none(self, outcomes=None):
|
|
70
|
+
"""``P(no selected outcome = 1 | x)``. Defaults to all outcomes."""
|
|
71
|
+
eta, corr = self._subset(outcomes)
|
|
72
|
+
return mvn_orthant(-eta, signed_corr_stack(corr, np.ones_like(eta)), n_quad=self.n_quad)
|