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.
@@ -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)