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,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)
|