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,56 @@
1
+ """Multivariate probit models for correlated binary outcomes.
2
+
3
+ A multivariate probit is a Gaussian squashing function wrapped around an
4
+ arbitrary inner model. This package fits one by Inference Functions for Margins
5
+ (IFM): margins first, dependence second. The inner model is pluggable -- linear
6
+ by default, gradient boosting or random forests via named presets, anything
7
+ scikit-learn-shaped via the adapter.
8
+
9
+ >>> from multivariate_probit import MultivariateProbit
10
+ >>> model = MultivariateProbit(inner="linear").fit(X, Y)
11
+ >>> proba = model.predict_proba(X)
12
+ >>> proba.marginal # P(Y_j = 1 | x)
13
+ >>> proba.all() # P(every outcome = 1 | x)
14
+ >>> proba.joint([1, 0, 1]) # P(Y = (1, 0, 1) | x)
15
+ >>> model.correlation_
16
+ """
17
+
18
+ from ._mvn import bvn_cdf, mvn_orthant, orthant_prob, pattern_prob
19
+ from .ifm import (
20
+ joint_correlation,
21
+ joint_log_likelihood,
22
+ pair_log_likelihood,
23
+ pairwise_correlation,
24
+ )
25
+ from .inner import (
26
+ ProbitCalibrated,
27
+ as_inner,
28
+ available_inners,
29
+ make_inner,
30
+ register_inner,
31
+ )
32
+ from .linear import ProbitRegressor
33
+ from .model import MultivariateProbit
34
+ from .results import MultivariateProbitProba
35
+
36
+ __version__ = "0.1.0"
37
+
38
+ __all__ = [
39
+ "MultivariateProbit",
40
+ "MultivariateProbitProba",
41
+ "ProbitRegressor",
42
+ "ProbitCalibrated",
43
+ "make_inner",
44
+ "register_inner",
45
+ "available_inners",
46
+ "as_inner",
47
+ "joint_correlation",
48
+ "joint_log_likelihood",
49
+ "pairwise_correlation",
50
+ "pair_log_likelihood",
51
+ "bvn_cdf",
52
+ "mvn_orthant",
53
+ "orthant_prob",
54
+ "pattern_prob",
55
+ "__version__",
56
+ ]
@@ -0,0 +1,51 @@
1
+ """Correlation-matrix utilities for the second IFM stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ __all__ = ["nearest_correlation", "is_positive_definite"]
8
+
9
+
10
+ def is_positive_definite(mat, tol=0.0):
11
+ try:
12
+ np.linalg.cholesky(mat - tol * np.eye(mat.shape[0]))
13
+ return True
14
+ except np.linalg.LinAlgError:
15
+ return False
16
+
17
+
18
+ def nearest_correlation(mat, eps=1e-8, max_iter=100):
19
+ """Project a symmetric matrix onto the nearest correlation matrix.
20
+
21
+ Pairwise IFM estimates each off-diagonal element separately, so the
22
+ assembled matrix is symmetric but not guaranteed positive definite. This is
23
+ Higham's alternating-projections algorithm, with a final eigenvalue floor so
24
+ the result is strictly PD and safe to Cholesky-factor.
25
+ """
26
+ mat = np.asarray(mat, dtype=float)
27
+ x = (mat + mat.T) / 2.0
28
+ np.fill_diagonal(x, 1.0)
29
+ if is_positive_definite(x, tol=eps):
30
+ return x
31
+
32
+ dykstra = np.zeros_like(x)
33
+ y = x.copy()
34
+ for _ in range(max_iter):
35
+ r = y - dykstra
36
+ # Projection onto the positive semi-definite cone.
37
+ vals, vecs = np.linalg.eigh((r + r.T) / 2.0)
38
+ s = (vecs * np.maximum(vals, eps)) @ vecs.T
39
+ dykstra = s - r
40
+ # Projection onto the unit-diagonal set.
41
+ y = s.copy()
42
+ np.fill_diagonal(y, 1.0)
43
+ if is_positive_definite(y, tol=eps):
44
+ break
45
+
46
+ vals, vecs = np.linalg.eigh((y + y.T) / 2.0)
47
+ y = (vecs * np.maximum(vals, eps)) @ vecs.T
48
+ scale = np.sqrt(np.diag(y))
49
+ y = y / np.outer(scale, scale)
50
+ np.fill_diagonal(y, 1.0)
51
+ return (y + y.T) / 2.0
@@ -0,0 +1,155 @@
1
+ """Normal-CDF machinery: the only place multivariate integration happens.
2
+
3
+ Everything here works on the *latent* scale. The inner models produce a
4
+ real-valued index ``eta`` on (-inf, inf) and the Gaussian CDF is the only
5
+ squashing function applied to it.
6
+
7
+ The evaluator is deterministic and vectorised over observations, and it allows
8
+ the correlation matrix to vary by row -- which is what makes the sign trick
9
+ below (one expression for all 2^d outcome patterns) practical.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from functools import lru_cache
15
+
16
+ import numpy as np
17
+ from scipy.stats import norm
18
+
19
+ __all__ = ["bvn_cdf", "mvn_orthant", "orthant_prob", "pattern_prob", "signed_corr_stack", "RHO_MAX"]
20
+
21
+ # Correlations are kept strictly inside the unit interval: the quadrature below
22
+ # degenerates at |rho| = 1, and a boundary correlation is never a defensible
23
+ # estimate from finite data anyway.
24
+ RHO_MAX = 0.999
25
+ _EPS = 1e-12
26
+
27
+
28
+ @lru_cache(maxsize=8)
29
+ def _gl_nodes(n_quad):
30
+ """Gauss-Legendre nodes/weights on [-1, 1], cached per requested order."""
31
+ return np.polynomial.legendre.leggauss(n_quad)
32
+
33
+
34
+ def bvn_cdf(a, b, rho, n_quad=24):
35
+ """P(Z1 <= a, Z2 <= b) for a standard bivariate normal with correlation rho.
36
+
37
+ Vectorised over ``a``, ``b`` and ``rho``, which broadcast against each
38
+ other -- so correlations may vary by row.
39
+
40
+ Uses the Drezner-Wesolowsky form of Plackett's identity,
41
+
42
+ Phi2(a, b, r) = Phi(a) Phi(b)
43
+ + 1/(2 pi) * int_0^asin(r)
44
+ exp(-(a^2 + b^2 - 2 a b sin t) / (2 cos^2 t)) dt
45
+
46
+ whose integrand is smooth and bounded, so a small fixed Gauss-Legendre rule
47
+ is accurate to ~1e-12 over |r| <= 0.999.
48
+ """
49
+ a, b, rho = np.broadcast_arrays(
50
+ np.asarray(a, dtype=float), np.asarray(b, dtype=float), np.asarray(rho, dtype=float)
51
+ )
52
+ rho = np.clip(rho, -RHO_MAX, RHO_MAX)
53
+ nodes, weights = _gl_nodes(n_quad)
54
+
55
+ # Map the quadrature nodes from [-1, 1] onto [0, asin(rho)].
56
+ half = np.arcsin(rho)[..., None] / 2.0
57
+ sin_t = np.sin(half * (nodes + 1.0))
58
+ cos2 = 1.0 - sin_t**2
59
+
60
+ aa, bb = a[..., None], b[..., None]
61
+ integrand = np.exp(-(aa**2 + bb**2 - 2.0 * aa * bb * sin_t) / (2.0 * cos2))
62
+ integral = np.sum(integrand * weights, axis=-1) * half[..., 0]
63
+
64
+ return np.clip(norm.cdf(a) * norm.cdf(b) + integral / (2.0 * np.pi), 0.0, 1.0)
65
+
66
+
67
+ def mvn_orthant(A, corr_stack, n_quad=24):
68
+ """P(Z_1 <= A[:, 0], ..., Z_d <= A[:, d-1]) row by row, Z ~ N(0, corr_stack).
69
+
70
+ Parameters
71
+ ----------
72
+ A : array of shape (n, d)
73
+ Upper limits, one row per observation.
74
+ corr_stack : array of shape (d, d, n)
75
+ Correlation matrix per row (see :func:`signed_corr_stack`).
76
+ n_quad : int
77
+ Gauss-Legendre order.
78
+
79
+ Genz's (1992) recursive conditioning: peel off the last variable under a
80
+ fixed one-dimensional quadrature, reduce to a (d-1)-dimensional problem
81
+ with the conditional correlation matrix, and recurse. Bottoms out at the
82
+ closed-form :func:`bvn_cdf`, so cost is roughly ``n_quad**(d - 2)``
83
+ bivariate evaluations -- comfortable for d = 3-5, slow well before d = 10.
84
+ """
85
+ A = np.atleast_2d(np.asarray(A, dtype=float))
86
+ d = A.shape[1]
87
+ if d == 1:
88
+ return norm.cdf(A[:, 0])
89
+ if d == 2:
90
+ return bvn_cdf(A[:, 0], A[:, 1], corr_stack[0, 1], n_quad=n_quad)
91
+
92
+ rho_last = corr_stack[:-1, -1, :] # (d-1, n)
93
+ denom = np.sqrt(np.clip(1.0 - rho_last**2, 1e-10, None))
94
+ outer = rho_last[:, None, :] * rho_last[None, :, :]
95
+ cond = (corr_stack[:-1, :-1, :] - outer) / (denom[:, None, :] * denom[None, :, :])
96
+ diag = np.arange(d - 1)
97
+ cond[diag, diag, :] = 1.0
98
+ np.clip(cond, -RHO_MAX, RHO_MAX, out=cond)
99
+
100
+ phi_last = np.clip(norm.cdf(A[:, -1]), _EPS, 1.0 - _EPS)
101
+ nodes, weights = _gl_nodes(n_quad)
102
+ acc = np.zeros(A.shape[0])
103
+ for node, weight in zip(nodes, weights):
104
+ # Substituting u = Phi(z) / Phi(A_last) maps the conditioning integral
105
+ # onto [0, 1], where the fixed rule applies.
106
+ u = np.clip(0.5 * (node + 1.0) * phi_last, _EPS, 1.0 - _EPS)
107
+ z = norm.ppf(u)
108
+ reduced = (A[:, :-1] - (z[None, :] * rho_last).T) / denom.T
109
+ acc += 0.5 * weight * mvn_orthant(reduced, cond, n_quad=n_quad)
110
+ return np.clip(phi_last * acc, 0.0, 1.0)
111
+
112
+
113
+ def signed_corr_stack(corr, signs):
114
+ """Stack ``corr_stack[i, j, row] = signs[row, i] * signs[row, j] * corr[i, j]``.
115
+
116
+ ``signs`` holds +-1. Flipping the sign of a latent variable flips the sign
117
+ of its correlations, which is what turns every outcome pattern into a plain
118
+ lower orthant.
119
+ """
120
+ corr = np.asarray(corr, dtype=float)
121
+ signs = np.atleast_2d(np.asarray(signs, dtype=float))
122
+ d = corr.shape[0]
123
+ stack = np.empty((d, d, signs.shape[0]))
124
+ for i in range(d):
125
+ stack[i, i, :] = 1.0
126
+ for j in range(i + 1, d):
127
+ value = signs[:, i] * signs[:, j] * corr[i, j]
128
+ stack[i, j, :] = value
129
+ stack[j, i, :] = value
130
+ return stack
131
+
132
+
133
+ def orthant_prob(upper, corr, n_quad=24):
134
+ """P(Z_j <= upper[i, j] for all j) for a single shared correlation matrix."""
135
+ upper = np.atleast_2d(np.asarray(upper, dtype=float))
136
+ stack = signed_corr_stack(corr, np.ones_like(upper))
137
+ return mvn_orthant(upper, stack, n_quad=n_quad)
138
+
139
+
140
+ def pattern_prob(eta, Y, corr, n_quad=24):
141
+ """``P(Y = y | x)`` for the multivariate probit, row by row.
142
+
143
+ With ``s = 2y - 1`` the event ``{Y = y}`` is the orthant
144
+ ``{s_j Z_j <= s_j eta_j for all j}``, so one signed expression covers all
145
+ ``2**d`` patterns -- no separate upper/lower-tail bookkeeping.
146
+ """
147
+ eta = np.atleast_2d(np.asarray(eta, dtype=float))
148
+ Y = np.atleast_2d(np.asarray(Y, dtype=float))
149
+ if Y.shape[0] == 1 and eta.shape[0] > 1:
150
+ Y = np.repeat(Y, eta.shape[0], axis=0)
151
+ if eta.shape != Y.shape:
152
+ raise ValueError(f"eta {eta.shape} and Y {Y.shape} must have the same shape")
153
+
154
+ signs = 2.0 * Y - 1.0
155
+ return mvn_orthant(signs * eta, signed_corr_stack(corr, signs), n_quad=n_quad)
@@ -0,0 +1,177 @@
1
+ """Stage two of IFM: the dependence parameters, given fixed margins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from scipy.optimize import minimize, minimize_scalar
7
+
8
+ from ._corr import nearest_correlation
9
+ from ._mvn import RHO_MAX, bvn_cdf, pattern_prob
10
+
11
+ __all__ = [
12
+ "joint_correlation",
13
+ "pairwise_correlation",
14
+ "pair_log_likelihood",
15
+ "joint_log_likelihood",
16
+ "corr_from_free",
17
+ "free_from_corr",
18
+ ]
19
+
20
+ _LL_EPS = 1e-12
21
+
22
+
23
+ def pair_log_likelihood(rho, eta_j, eta_k, y_j, y_k, weights=None):
24
+ """Bivariate probit log-likelihood in ``rho`` with the margins held fixed.
25
+
26
+ With ``s = 2y - 1``, the probability of an observed pair is
27
+ ``Phi2(s_j eta_j, s_k eta_k, s_j s_k rho)`` -- one expression covering all
28
+ four cells of the 2x2 table.
29
+ """
30
+ s_j = 2.0 * np.asarray(y_j, dtype=float) - 1.0
31
+ s_k = 2.0 * np.asarray(y_k, dtype=float) - 1.0
32
+ prob = bvn_cdf(s_j * eta_j, s_k * eta_k, s_j * s_k * rho)
33
+ ll = np.log(np.clip(prob, _LL_EPS, None))
34
+ if weights is None:
35
+ return float(np.sum(ll))
36
+ return float(np.sum(np.asarray(weights, dtype=float) * ll))
37
+
38
+
39
+ def _fit_pair(eta_j, eta_k, y_j, y_k, weights, tol):
40
+ finite = np.isfinite(eta_j) & np.isfinite(eta_k)
41
+ if not np.all(finite):
42
+ eta_j, eta_k, y_j, y_k = eta_j[finite], eta_k[finite], y_j[finite], y_k[finite]
43
+ weights = None if weights is None else np.asarray(weights)[finite]
44
+
45
+ # A margin with no variation carries no information about dependence.
46
+ if eta_j.size == 0 or np.ptp(y_j) == 0 or np.ptp(y_k) == 0:
47
+ return 0.0
48
+
49
+ def neg_ll(rho):
50
+ return -pair_log_likelihood(rho, eta_j, eta_k, y_j, y_k, weights)
51
+
52
+ result = minimize_scalar(
53
+ neg_ll, bounds=(-RHO_MAX, RHO_MAX), method="bounded", options={"xatol": tol}
54
+ )
55
+ return float(np.clip(result.x, -RHO_MAX, RHO_MAX))
56
+
57
+
58
+ def pairwise_correlation(eta, Y, weights=None, tol=1e-6, project=True):
59
+ """Estimate the latent correlation matrix one pair at a time.
60
+
61
+ Parameters
62
+ ----------
63
+ eta : array of shape (n, d)
64
+ Latent indices from the stage-one margins. Out-of-fold indices are
65
+ strongly preferred for flexible inner models -- see
66
+ :class:`~multivariate_probit.model.MultivariateProbit`.
67
+ Y : array of shape (n, d)
68
+ Observed binary outcomes.
69
+ weights : array of shape (n,), optional
70
+ tol : float
71
+ Tolerance of the 1-D search for each rho.
72
+ project : bool
73
+ Project the assembled matrix onto the nearest positive-definite
74
+ correlation matrix. Pairwise estimates need not be jointly coherent, so
75
+ this is on by default.
76
+
77
+ Returns
78
+ -------
79
+ corr : array of shape (d, d)
80
+ """
81
+ eta = np.atleast_2d(np.asarray(eta, dtype=float))
82
+ Y = np.atleast_2d(np.asarray(Y, dtype=float))
83
+ if eta.shape != Y.shape:
84
+ raise ValueError(f"eta {eta.shape} and Y {Y.shape} must have the same shape")
85
+ d = eta.shape[1]
86
+
87
+ corr = np.eye(d)
88
+ for j in range(d):
89
+ for k in range(j + 1, d):
90
+ rho = _fit_pair(eta[:, j], eta[:, k], Y[:, j], Y[:, k], weights, tol)
91
+ corr[j, k] = corr[k, j] = rho
92
+
93
+ if project and d > 2:
94
+ corr = nearest_correlation(corr)
95
+ return corr
96
+
97
+
98
+ # ---------------------------------------------------------------- joint MLE
99
+
100
+
101
+ def corr_from_free(free, d):
102
+ """Build a symmetric unit-diagonal matrix from its d(d-1)/2 free entries."""
103
+ corr = np.eye(d)
104
+ upper = np.triu_indices(d, k=1)
105
+ corr[upper] = free
106
+ corr[(upper[1], upper[0])] = free
107
+ return corr
108
+
109
+
110
+ def free_from_corr(corr):
111
+ """The free (strictly upper-triangular) entries of a correlation matrix."""
112
+ corr = np.asarray(corr, dtype=float)
113
+ return corr[np.triu_indices(corr.shape[0], k=1)]
114
+
115
+
116
+ def joint_log_likelihood(corr, eta, Y, weights=None, n_quad=24):
117
+ """Full d-variate log-likelihood in ``corr``, with the margins held fixed."""
118
+ prob = np.clip(pattern_prob(eta, Y, corr, n_quad=n_quad), _LL_EPS, None)
119
+ ll = np.log(prob)
120
+ if weights is None:
121
+ return float(np.sum(ll))
122
+ return float(np.sum(np.asarray(weights, dtype=float) * ll))
123
+
124
+
125
+ def joint_correlation(
126
+ eta,
127
+ Y,
128
+ weights=None,
129
+ n_quad=24,
130
+ optimizer="Nelder-Mead",
131
+ init=None,
132
+ min_eigenvalue=1e-8,
133
+ options=None,
134
+ ):
135
+ """Estimate the latent correlation matrix by full-information ML in ``R``.
136
+
137
+ This is the second IFM stage done exactly: with the margins frozen, the
138
+ d-variate orthant likelihood is maximised over the d(d-1)/2 free
139
+ correlations. The search is derivative-free -- the orthant probability has
140
+ no convenient closed-form gradient here -- and each evaluation costs an
141
+ orthant integral per observation, so the price grows quickly with ``d``.
142
+ For many outcomes prefer :func:`pairwise_correlation`.
143
+
144
+ Returns
145
+ -------
146
+ corr : ndarray of shape (d, d)
147
+ result : scipy.optimize.OptimizeResult
148
+ """
149
+ eta = np.atleast_2d(np.asarray(eta, dtype=float))
150
+ Y = np.atleast_2d(np.asarray(Y, dtype=float))
151
+ if eta.shape != Y.shape:
152
+ raise ValueError(f"eta {eta.shape} and Y {Y.shape} must have the same shape")
153
+ d = eta.shape[1]
154
+
155
+ if d == 1:
156
+ return np.ones((1, 1)), None
157
+
158
+ if init is None:
159
+ # The Pearson correlation of the latent indices is always PSD and lands
160
+ # close enough to start; pairwise IFM is a better but pricier start.
161
+ with np.errstate(invalid="ignore"):
162
+ start = np.corrcoef(eta, rowvar=False)
163
+ start = np.nan_to_num(start, nan=0.0)
164
+ else:
165
+ start = np.asarray(init, dtype=float)
166
+
167
+ def neg_ll(free):
168
+ corr = corr_from_free(np.clip(free, -RHO_MAX, RHO_MAX), d)
169
+ if np.linalg.eigvalsh(corr).min() <= min_eigenvalue:
170
+ return 1e10 # not positive definite -- push the optimizer away
171
+ return -joint_log_likelihood(corr, eta, Y, weights=weights, n_quad=n_quad)
172
+
173
+ result = minimize(neg_ll, free_from_corr(start), method=optimizer, options=options)
174
+ corr = corr_from_free(np.clip(result.x, -RHO_MAX, RHO_MAX), d)
175
+ if np.linalg.eigvalsh(corr).min() <= min_eigenvalue:
176
+ corr = nearest_correlation(corr)
177
+ return corr, result
@@ -0,0 +1,187 @@
1
+ """Inner models and the named-preset registry.
2
+
3
+ The multivariate probit here is a *squashing function around an arbitrary inner
4
+ model*: each margin j owns a model that maps features to a real-valued index
5
+ ``eta_j(x)`` on (-inf, inf), and the Gaussian CDF turns that index into a
6
+ marginal probability. Nothing about the fit cares how ``eta_j`` was produced,
7
+ so any estimator that can be coaxed into emitting a real-valued score is a
8
+ legal inner model.
9
+
10
+ Two shapes are accepted:
11
+
12
+ * Anything exposing ``fit(X, y)`` and ``latent(X) -> (n,)`` is used directly
13
+ (:class:`~multivariate_probit.linear.ProbitRegressor` is the canonical one).
14
+ * Any scikit-learn-style classifier is wrapped by :class:`ProbitCalibrated`,
15
+ which converts ``predict_proba`` back onto the probit scale with the probit
16
+ quantile function. Estimators offering only ``decision_function`` have that
17
+ score used as the index as-is.
18
+
19
+ The registry is deliberately thin. IFM has no per-family estimation logic --
20
+ "linear", "xgboost" and "rf" differ only in which pre-wired estimator instance
21
+ they hand back -- so a preset is a factory function and nothing more.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import copy
27
+
28
+ import numpy as np
29
+ from scipy.stats import norm
30
+
31
+ from .linear import ProbitRegressor
32
+
33
+ __all__ = [
34
+ "ProbitCalibrated",
35
+ "make_inner",
36
+ "register_inner",
37
+ "available_inners",
38
+ "as_inner",
39
+ ]
40
+
41
+ _P_EPS = 1e-6
42
+
43
+
44
+ class ProbitCalibrated:
45
+ """Adapt a probability-emitting classifier to the latent probit scale.
46
+
47
+ ``latent(X)`` returns ``Phi^-1(p_hat)``, clipped away from 0 and 1 so a
48
+ saturated tree ensemble cannot emit infinite indices.
49
+ """
50
+
51
+ def __init__(self, estimator, clip=_P_EPS):
52
+ self.estimator = estimator
53
+ self.clip = clip
54
+
55
+ def fit(self, X, y):
56
+ self.estimator_ = copy.deepcopy(self.estimator)
57
+ self.estimator_.fit(X, y)
58
+ return self
59
+
60
+ def latent(self, X):
61
+ est = getattr(self, "estimator_", self.estimator)
62
+ if hasattr(est, "predict_proba"):
63
+ proba = np.asarray(est.predict_proba(X), dtype=float)
64
+ p = proba[:, 1] if proba.ndim == 2 and proba.shape[1] == 2 else proba.ravel()
65
+ return norm.ppf(np.clip(p, self.clip, 1.0 - self.clip))
66
+ if hasattr(est, "decision_function"):
67
+ return np.asarray(est.decision_function(X), dtype=float).ravel()
68
+ raise TypeError(
69
+ f"{type(est).__name__} exposes neither predict_proba nor decision_function; "
70
+ "it cannot serve as an inner model."
71
+ )
72
+
73
+ def predict_proba(self, X):
74
+ p = norm.cdf(self.latent(X))
75
+ return np.column_stack([1.0 - p, p])
76
+
77
+ def __repr__(self):
78
+ return f"ProbitCalibrated({self.estimator!r})"
79
+
80
+
81
+ def _linear(**kwargs):
82
+ return ProbitRegressor(**kwargs)
83
+
84
+
85
+ def _xgboost(**kwargs):
86
+ try:
87
+ from xgboost import XGBClassifier
88
+ except ImportError as exc: # pragma: no cover - exercised only without xgboost
89
+ raise ImportError(
90
+ "The 'xgboost' preset requires xgboost. Install it with "
91
+ "`pip install multivariate-probit[xgboost]`."
92
+ ) from exc
93
+
94
+ # Defaults tuned for probability calibration rather than ranking: shallow
95
+ # trees, plenty of shrinkage, and a logistic objective whose output maps
96
+ # cleanly back through Phi^-1.
97
+ params = dict(
98
+ n_estimators=300,
99
+ learning_rate=0.05,
100
+ max_depth=3,
101
+ subsample=0.8,
102
+ colsample_bytree=0.8,
103
+ min_child_weight=5.0,
104
+ reg_lambda=1.0,
105
+ objective="binary:logistic",
106
+ eval_metric="logloss",
107
+ tree_method="hist",
108
+ n_jobs=1,
109
+ )
110
+ params.update(kwargs)
111
+ return ProbitCalibrated(XGBClassifier(**params))
112
+
113
+
114
+ def _random_forest(**kwargs):
115
+ try:
116
+ from sklearn.ensemble import RandomForestClassifier
117
+ except ImportError as exc: # pragma: no cover - exercised only without sklearn
118
+ raise ImportError(
119
+ "The 'rf' preset requires scikit-learn. Install it with "
120
+ "`pip install multivariate-probit[sklearn]`."
121
+ ) from exc
122
+
123
+ params = dict(
124
+ n_estimators=500,
125
+ min_samples_leaf=5,
126
+ max_features="sqrt",
127
+ n_jobs=1,
128
+ )
129
+ params.update(kwargs)
130
+ return ProbitCalibrated(RandomForestClassifier(**params))
131
+
132
+
133
+ _REGISTRY = {
134
+ "linear": _linear,
135
+ "probit": _linear,
136
+ "xgboost": _xgboost,
137
+ "xgb": _xgboost,
138
+ "rf": _random_forest,
139
+ "random_forest": _random_forest,
140
+ }
141
+
142
+
143
+ def register_inner(name, factory, overwrite=False):
144
+ """Register a named preset. ``factory(**kwargs)`` returns a fresh estimator."""
145
+ if not callable(factory):
146
+ raise TypeError("factory must be callable")
147
+ if name in _REGISTRY and not overwrite:
148
+ raise ValueError(f"preset {name!r} already registered; pass overwrite=True to replace it")
149
+ _REGISTRY[name] = factory
150
+ return factory
151
+
152
+
153
+ def available_inners():
154
+ """Sorted names of the registered presets."""
155
+ return sorted(_REGISTRY)
156
+
157
+
158
+ def make_inner(name, **kwargs):
159
+ """Instantiate a preset by name, forwarding ``kwargs`` to the estimator."""
160
+ try:
161
+ factory = _REGISTRY[name]
162
+ except KeyError:
163
+ raise ValueError(
164
+ f"unknown inner model {name!r}; available presets: {available_inners()}"
165
+ ) from None
166
+ return factory(**kwargs)
167
+
168
+
169
+ def as_inner(spec, **kwargs):
170
+ """Coerce ``spec`` into an unfitted inner model.
171
+
172
+ ``spec`` may be a preset name, a callable factory, or an estimator instance
173
+ (which is deep-copied, then wrapped in :class:`ProbitCalibrated` unless it
174
+ already exposes ``latent``).
175
+ """
176
+ if isinstance(spec, str):
177
+ return make_inner(spec, **kwargs)
178
+ if isinstance(spec, type):
179
+ return as_inner(spec(**kwargs))
180
+ if callable(spec) and not hasattr(spec, "fit"):
181
+ return as_inner(spec(**kwargs))
182
+ if not hasattr(spec, "fit"):
183
+ raise TypeError(f"{spec!r} is not a valid inner model: no fit method")
184
+ est = copy.deepcopy(spec)
185
+ if hasattr(est, "latent"):
186
+ return est
187
+ return ProbitCalibrated(est)