pysmartcor 1.0.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.
- pysmartcor/LICENSES/semopy.txt +30 -0
- pysmartcor/__init__.py +54 -0
- pysmartcor/_polycorr.py +418 -0
- pysmartcor/compare.py +237 -0
- pysmartcor/compute.py +959 -0
- pysmartcor/core.py +730 -0
- pysmartcor/data/gss_2024_casestudy.csv +3001 -0
- pysmartcor/detect_type.py +109 -0
- pysmartcor/inference.py +411 -0
- pysmartcor/methods.py +127 -0
- pysmartcor/normality.py +379 -0
- pysmartcor/results.py +195 -0
- pysmartcor/viz.py +342 -0
- pysmartcor-1.0.0.dist-info/METADATA +170 -0
- pysmartcor-1.0.0.dist-info/RECORD +18 -0
- pysmartcor-1.0.0.dist-info/WHEEL +5 -0
- pysmartcor-1.0.0.dist-info/licenses/LICENSE +674 -0
- pysmartcor-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
smartcor's `_polycorr.polychoric_corr` and `_polycorr.polyserial_corr_mle`
|
|
2
|
+
are derived from semopy 2.3.11 (`semopy/polycorr.py`), distributed under the
|
|
3
|
+
MIT licence below. The vendored functions are modified to replace the removed
|
|
4
|
+
`scipy.stats.mvn.mvnun` helper with `scipy.stats.multivariate_normal.cdf` and
|
|
5
|
+
to correct a scale-vs-variance bug in the polyserial MLE (semopy passes the
|
|
6
|
+
variance to `scipy.stats.norm.logpdf`'s `scale` argument, which expects an SD).
|
|
7
|
+
|
|
8
|
+
----- semopy LICENSE (verbatim) -----
|
|
9
|
+
|
|
10
|
+
MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2021 Georgy Andreyevich Meshcheryakov
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
pysmartcor/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""smartcor: correlation methods matched to variable types.
|
|
2
|
+
|
|
3
|
+
Detects each variable's type (continuous, count, binary, ordinal,
|
|
4
|
+
categorical) and picks a defensible correlation method for every pair,
|
|
5
|
+
with the reasoning attached. Fourteen methods: Pearson, Spearman, Kendall,
|
|
6
|
+
point-biserial, phi, tetrachoric, polychoric, polyserial, Cramer's V,
|
|
7
|
+
Theil's U, Tschuprow's T, Yule's Q, Goodman-Kruskal gamma, and
|
|
8
|
+
rank-biserial.
|
|
9
|
+
|
|
10
|
+
Each pairwise result carries the full inference payload (estimate, test
|
|
11
|
+
statistic, p-value, confidence interval, citation for the CI source) in a
|
|
12
|
+
20-column tidy DataFrame matching the R package's ``tidy.smartcor()``
|
|
13
|
+
output.
|
|
14
|
+
|
|
15
|
+
Quick start::
|
|
16
|
+
|
|
17
|
+
from pysmartcor import smart_cor, smart_cormat
|
|
18
|
+
|
|
19
|
+
res = smart_cor(x, y, verbose=False)
|
|
20
|
+
res.estimate # point estimate
|
|
21
|
+
res.ci_lower, res.ci_upper
|
|
22
|
+
res.tidy() # 20-column DataFrame
|
|
23
|
+
|
|
24
|
+
mat = smart_cormat(df, verbose=False)
|
|
25
|
+
mat.correlations
|
|
26
|
+
mat.to_long() # tidy long-format DataFrame
|
|
27
|
+
|
|
28
|
+
For visualization, install with ``pip install smartcor[viz]`` and use::
|
|
29
|
+
|
|
30
|
+
from pysmartcor.viz import cor_heatmap, method_heatmap
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from pysmartcor.compare import compare_methods
|
|
34
|
+
from pysmartcor.core import smart_cor, smart_cor_df, smart_cormat
|
|
35
|
+
from pysmartcor.detect_type import detect_type
|
|
36
|
+
from pysmartcor.methods import available_methods
|
|
37
|
+
from pysmartcor.normality import NormalityTestResult, test_bivariate_normality
|
|
38
|
+
from pysmartcor.results import SmartCorMatResult, SmartCorResult, TIDY_COLUMNS
|
|
39
|
+
|
|
40
|
+
__version__ = "1.0.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"detect_type",
|
|
44
|
+
"smart_cor",
|
|
45
|
+
"smart_cormat",
|
|
46
|
+
"smart_cor_df",
|
|
47
|
+
"available_methods",
|
|
48
|
+
"compare_methods",
|
|
49
|
+
"test_bivariate_normality",
|
|
50
|
+
"SmartCorResult",
|
|
51
|
+
"SmartCorMatResult",
|
|
52
|
+
"NormalityTestResult",
|
|
53
|
+
"TIDY_COLUMNS",
|
|
54
|
+
]
|
pysmartcor/_polycorr.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Polychoric and polyserial correlation: self-contained MLE implementations.
|
|
2
|
+
|
|
3
|
+
The polychoric MLE here is a near-verbatim port of ``semopy.polycorr`` from
|
|
4
|
+
semopy 2.3.11 (MIT-licensed, copyright (c) 2021 Georgy A. Meshcheryakov --
|
|
5
|
+
see ``smartcor/LICENSES/semopy.txt``). We vendor it for two reasons:
|
|
6
|
+
|
|
7
|
+
1. semopy 2.3.11 imports ``scipy.stats.mvn`` and calls ``mvn.mvnun(...)`` to
|
|
8
|
+
evaluate bivariate-normal rectangle probabilities. ``scipy.stats.mvn`` was
|
|
9
|
+
removed in scipy 1.14, so semopy's polychoric/polyserial routines raise
|
|
10
|
+
``AttributeError`` on any recent scipy install. We replace those calls
|
|
11
|
+
with ``scipy.stats.multivariate_normal.cdf`` via inclusion-exclusion.
|
|
12
|
+
|
|
13
|
+
2. semopy's polyserial MLE has a unit bug: the standardisation step is
|
|
14
|
+
``z = (x - mean) / variance`` (should divide by SD), and
|
|
15
|
+
``norm.logpdf(x, mean, variance)`` passes the variance to scipy's
|
|
16
|
+
``scale`` argument (which expects an SD). This biases the rho estimate.
|
|
17
|
+
The vendored ``polyserial_corr_mle`` below fixes both.
|
|
18
|
+
|
|
19
|
+
``polyserial_corr_mle`` is the public polyserial path; the two-step
|
|
20
|
+
Olsson-Drasgow-Dorans estimator (``polyserial_corr``) is kept as a
|
|
21
|
+
closed-form fallback for when the optimiser fails.
|
|
22
|
+
|
|
23
|
+
``polychoric_se`` and ``polyserial_se`` give the asymptotic standard error
|
|
24
|
+
of rho from a central-difference Hessian of the joint negative
|
|
25
|
+
log-likelihood, evaluated at the estimate and inverted. This reproduces the
|
|
26
|
+
SE that R's ``polycor`` derives from the ML variance matrix.
|
|
27
|
+
|
|
28
|
+
References
|
|
29
|
+
----------
|
|
30
|
+
* Olsson, U. (1979). Maximum likelihood estimation of the polychoric correlation
|
|
31
|
+
coefficient. *Psychometrika*, 44(4), 443-460.
|
|
32
|
+
* Drasgow, F. (1986). Polychoric and polyserial correlations. In *Encyclopedia
|
|
33
|
+
of Statistical Sciences*, vol. 7, 68-74.
|
|
34
|
+
* Olsson, U., Drasgow, F., & Dorans, N. J. (1982). The polyserial correlation
|
|
35
|
+
coefficient. *Psychometrika*, 47(3), 337-347.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from itertools import chain
|
|
41
|
+
from typing import Sequence
|
|
42
|
+
|
|
43
|
+
import numpy as np
|
|
44
|
+
import pandas as pd
|
|
45
|
+
from scipy.optimize import minimize_scalar
|
|
46
|
+
from scipy.stats import multivariate_normal, norm
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"estimate_intervals",
|
|
51
|
+
"polychoric_corr",
|
|
52
|
+
"polychoric_se",
|
|
53
|
+
"polyserial_corr",
|
|
54
|
+
"polyserial_corr_mle",
|
|
55
|
+
"polyserial_se",
|
|
56
|
+
"bivariate_cdf",
|
|
57
|
+
"univariate_cdf",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def bivariate_cdf(
|
|
62
|
+
lower: Sequence[float],
|
|
63
|
+
upper: Sequence[float],
|
|
64
|
+
corr: float,
|
|
65
|
+
means: Sequence[float] = (0.0, 0.0),
|
|
66
|
+
var: Sequence[float] = (1.0, 1.0),
|
|
67
|
+
) -> float:
|
|
68
|
+
"""``P(lower < (X, Y) < upper)`` for ``(X, Y) ~ N(means, Sigma)``.
|
|
69
|
+
|
|
70
|
+
Replaces ``scipy.stats.mvn.mvnun`` (removed in scipy 1.14). Uses the
|
|
71
|
+
inclusion-exclusion identity on the 2D CDF::
|
|
72
|
+
|
|
73
|
+
P(rect) = F(u1, u2) - F(l1, u2) - F(u1, l2) + F(l1, l2)
|
|
74
|
+
|
|
75
|
+
``-inf`` / ``+inf`` bounds are supported natively by scipy's CDF.
|
|
76
|
+
"""
|
|
77
|
+
cov = np.array([[float(var[0]), float(corr)], [float(corr), float(var[1])]])
|
|
78
|
+
mu = np.asarray(means, dtype=float)
|
|
79
|
+
# cap the correlation strictly inside (-1, 1); the optimiser sometimes probes the boundary
|
|
80
|
+
eps = 1e-9
|
|
81
|
+
if abs(cov[0, 1]) >= np.sqrt(cov[0, 0] * cov[1, 1]) - eps:
|
|
82
|
+
cov[0, 1] = np.sign(cov[0, 1]) * (np.sqrt(cov[0, 0] * cov[1, 1]) - eps)
|
|
83
|
+
cov[1, 0] = cov[0, 1]
|
|
84
|
+
rv = multivariate_normal(mean=mu, cov=cov, allow_singular=True)
|
|
85
|
+
u = np.asarray(upper, dtype=float)
|
|
86
|
+
l = np.asarray(lower, dtype=float)
|
|
87
|
+
return float(
|
|
88
|
+
rv.cdf([u[0], u[1]])
|
|
89
|
+
- rv.cdf([l[0], u[1]])
|
|
90
|
+
- rv.cdf([u[0], l[1]])
|
|
91
|
+
+ rv.cdf([l[0], l[1]])
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def univariate_cdf(
|
|
96
|
+
lower: float, upper: float, mean: float = 0.0, var: float = 1.0
|
|
97
|
+
) -> float:
|
|
98
|
+
"""``P(lower < X < upper)`` for ``X ~ N(mean, var)`` via ``norm.cdf``."""
|
|
99
|
+
sd = np.sqrt(var)
|
|
100
|
+
return float(norm.cdf(upper, mean, sd) - norm.cdf(lower, mean, sd))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def estimate_intervals(x, inf: float = 10.0):
|
|
104
|
+
"""Estimate latent-variable thresholds for an ordinal variable.
|
|
105
|
+
|
|
106
|
+
Returns ``(thresholds, indices)`` where ``thresholds`` is the list
|
|
107
|
+
``[-inf, tau_1, ..., tau_{K-1}, +inf]`` and ``indices[i]`` is the
|
|
108
|
+
1-based category index of observation ``x[i]``. NA entries get NaN
|
|
109
|
+
indices.
|
|
110
|
+
"""
|
|
111
|
+
x = np.asarray(x)
|
|
112
|
+
x_f = x[~pd.isna(x)]
|
|
113
|
+
u, counts = np.unique(x_f, return_counts=True)
|
|
114
|
+
sz = len(x_f)
|
|
115
|
+
if sz == 0 or len(u) < 2:
|
|
116
|
+
return [-inf, inf], [np.nan] * len(x)
|
|
117
|
+
cumcounts = np.cumsum(counts[:-1])
|
|
118
|
+
cat_to_idx = {v: i + 1 for i, v in enumerate(u)}
|
|
119
|
+
indices = [np.nan if pd.isna(v) else cat_to_idx.get(v, np.nan) for v in x]
|
|
120
|
+
thresholds = list(chain([-inf], (norm.ppf(n / sz) for n in cumcounts), [inf]))
|
|
121
|
+
return thresholds, indices
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def polychoric_corr(x, y, *, x_ints=None, y_ints=None, tol: float = 1e-7) -> float:
|
|
125
|
+
"""Polychoric correlation between two ordinal variables (Olsson 1979).
|
|
126
|
+
|
|
127
|
+
Two-step MLE: estimate the marginal thresholds from each variable's
|
|
128
|
+
empirical frequencies, then maximise the bivariate-normal log-likelihood
|
|
129
|
+
over the contingency table.
|
|
130
|
+
|
|
131
|
+
Adapted from ``semopy.polycorr.polychoric_corr`` (MIT), with
|
|
132
|
+
:func:`bivariate_cdf` used for current SciPy compatibility.
|
|
133
|
+
"""
|
|
134
|
+
if x_ints is None:
|
|
135
|
+
x_ints = estimate_intervals(x)
|
|
136
|
+
if y_ints is None:
|
|
137
|
+
y_ints = estimate_intervals(y)
|
|
138
|
+
x_thr, x_inds = x_ints
|
|
139
|
+
y_thr, y_inds = y_ints
|
|
140
|
+
p, m = len(x_thr) - 1, len(y_thr) - 1
|
|
141
|
+
if p < 2 or m < 2:
|
|
142
|
+
return float("nan")
|
|
143
|
+
n = np.zeros((p, m))
|
|
144
|
+
for a, b in zip(x_inds, y_inds):
|
|
145
|
+
if not (np.isnan(a) or np.isnan(b)):
|
|
146
|
+
n[int(a) - 1, int(b) - 1] += 1
|
|
147
|
+
|
|
148
|
+
def calc_neg_loglik(r: float) -> float:
|
|
149
|
+
total = 0.0
|
|
150
|
+
for i in range(p):
|
|
151
|
+
for j in range(m):
|
|
152
|
+
nij = n[i, j]
|
|
153
|
+
if nij == 0:
|
|
154
|
+
continue
|
|
155
|
+
pij = bivariate_cdf(
|
|
156
|
+
[x_thr[i], y_thr[j]],
|
|
157
|
+
[x_thr[i + 1], y_thr[j + 1]],
|
|
158
|
+
r,
|
|
159
|
+
)
|
|
160
|
+
if pij <= 0:
|
|
161
|
+
return 1e20
|
|
162
|
+
total -= np.log(pij) * nij
|
|
163
|
+
return total
|
|
164
|
+
|
|
165
|
+
res = minimize_scalar(
|
|
166
|
+
calc_neg_loglik, bounds=(-0.999, 0.999), method="bounded",
|
|
167
|
+
options={"xatol": tol},
|
|
168
|
+
)
|
|
169
|
+
return float(res.x)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def polyserial_corr(x_continuous, x_ordinal) -> float:
|
|
173
|
+
"""Two-step polyserial estimator (Olsson, Drasgow & Dorans 1982).
|
|
174
|
+
|
|
175
|
+
Closed form::
|
|
176
|
+
|
|
177
|
+
rho_hat = r_pb * sd(y_int) / sum_k phi(tau_k)
|
|
178
|
+
|
|
179
|
+
where ``r_pb`` is the Pearson correlation of the continuous variable
|
|
180
|
+
with the integer-coded ordinal, ``sd(y_int)`` is the population SD of
|
|
181
|
+
the integer codes, and ``tau_k`` are the cumulative z-thresholds
|
|
182
|
+
estimated from the ordinal's empirical marginal. No optimiser required.
|
|
183
|
+
|
|
184
|
+
R's ``polycor::polyserial(ML=FALSE)`` computes
|
|
185
|
+
``sqrt((n-1)/n) * sd(y) * r / sum(dnorm(tau))``, which is the same
|
|
186
|
+
population-SD formula, so the two agree exactly when the ordinal codes
|
|
187
|
+
are equally spaced.
|
|
188
|
+
"""
|
|
189
|
+
x = np.asarray(x_continuous, dtype=float)
|
|
190
|
+
y_raw = np.asarray(x_ordinal)
|
|
191
|
+
mask = ~(np.isnan(x) | pd.isna(y_raw))
|
|
192
|
+
x = x[mask]
|
|
193
|
+
y_raw = y_raw[mask]
|
|
194
|
+
cats, y = np.unique(y_raw, return_inverse=True)
|
|
195
|
+
K = len(cats)
|
|
196
|
+
if K < 2 or len(x) < 3:
|
|
197
|
+
return float("nan")
|
|
198
|
+
n = len(x)
|
|
199
|
+
counts = np.bincount(y, minlength=K)
|
|
200
|
+
cum = np.cumsum(counts[:-1]) / n
|
|
201
|
+
tau = norm.ppf(cum)
|
|
202
|
+
phi_sum = float(norm.pdf(tau).sum())
|
|
203
|
+
if phi_sum <= 0:
|
|
204
|
+
return float("nan")
|
|
205
|
+
r_pb = float(np.corrcoef(x, y)[0, 1])
|
|
206
|
+
s_y = float(np.std(y, ddof=0))
|
|
207
|
+
if s_y <= 0:
|
|
208
|
+
return float("nan")
|
|
209
|
+
rho = r_pb * s_y / phi_sum
|
|
210
|
+
return float(np.clip(rho, -0.999, 0.999))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def polyserial_corr_mle(
|
|
214
|
+
x_continuous, x_ordinal, *, y_ints=None, tol: float = 1e-7
|
|
215
|
+
) -> float:
|
|
216
|
+
"""Polyserial MLE (Drasgow 1986), vendored from semopy with the SD fix.
|
|
217
|
+
|
|
218
|
+
Two-step: estimate the ordinal's thresholds from marginal frequencies,
|
|
219
|
+
then maximise the conditional log-likelihood
|
|
220
|
+
``sum_i log f(x_i) + log P(y_i = c_i | x_i)`` over rho.
|
|
221
|
+
|
|
222
|
+
Notes
|
|
223
|
+
-----
|
|
224
|
+
The vendored semopy implementation standardised by the variance instead
|
|
225
|
+
of the SD and passed the variance to ``norm.logpdf``'s ``scale`` argument.
|
|
226
|
+
Both are fixed here.
|
|
227
|
+
"""
|
|
228
|
+
x = np.asarray(x_continuous, dtype=float)
|
|
229
|
+
mask = ~np.isnan(x)
|
|
230
|
+
x = x[mask]
|
|
231
|
+
y_raw = np.asarray(x_ordinal)[mask]
|
|
232
|
+
if y_ints is None:
|
|
233
|
+
y_ints = estimate_intervals(y_raw)
|
|
234
|
+
thr, inds = y_ints
|
|
235
|
+
if len(thr) < 3 or len(x) < 3:
|
|
236
|
+
return float("nan")
|
|
237
|
+
|
|
238
|
+
x_mean = float(np.mean(x))
|
|
239
|
+
x_sd = float(np.std(x, ddof=0))
|
|
240
|
+
if x_sd <= 0:
|
|
241
|
+
return float("nan")
|
|
242
|
+
x_z = (x - x_mean) / x_sd
|
|
243
|
+
x_logpdf = norm.logpdf(x, x_mean, x_sd)
|
|
244
|
+
|
|
245
|
+
def transform_tau(tau, rho, z):
|
|
246
|
+
return (tau - rho * z) / np.sqrt(max(1 - rho ** 2, 1e-12))
|
|
247
|
+
|
|
248
|
+
def sub_pr(k, rho, z):
|
|
249
|
+
i = transform_tau(thr[k], rho, z)
|
|
250
|
+
j = transform_tau(thr[k - 1], rho, z)
|
|
251
|
+
return univariate_cdf(j, i)
|
|
252
|
+
|
|
253
|
+
def neg_loglik(rho: float) -> float:
|
|
254
|
+
total = 0.0
|
|
255
|
+
for z, ind, pdf in zip(x_z, inds, x_logpdf):
|
|
256
|
+
if np.isnan(ind):
|
|
257
|
+
continue
|
|
258
|
+
pr = sub_pr(int(ind), rho, z)
|
|
259
|
+
if pr <= 0:
|
|
260
|
+
return 1e20
|
|
261
|
+
total -= pdf + np.log(pr)
|
|
262
|
+
return total
|
|
263
|
+
|
|
264
|
+
res = minimize_scalar(
|
|
265
|
+
neg_loglik, bounds=(-0.999, 0.999), method="bounded",
|
|
266
|
+
options={"xatol": tol},
|
|
267
|
+
)
|
|
268
|
+
return float(res.x)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def polychoric_se(x, y, rho: float) -> float:
|
|
272
|
+
"""Asymptotic SE of a polychoric (or tetrachoric) correlation.
|
|
273
|
+
|
|
274
|
+
Central-difference Hessian of the joint negative log-likelihood in
|
|
275
|
+
``(rho, row thresholds, column thresholds)``, evaluated at the estimate,
|
|
276
|
+
inverted; the SE is the square root of the rho diagonal entry. This is
|
|
277
|
+
the same variance matrix R's ``polycor::polychor(std.err=TRUE)`` uses
|
|
278
|
+
(Olsson 1979), reproduced numerically.
|
|
279
|
+
|
|
280
|
+
Parameters
|
|
281
|
+
----------
|
|
282
|
+
x, y : array-like
|
|
283
|
+
The ordinal variables the estimate was computed from.
|
|
284
|
+
rho : float
|
|
285
|
+
The polychoric point estimate.
|
|
286
|
+
|
|
287
|
+
Returns
|
|
288
|
+
-------
|
|
289
|
+
float
|
|
290
|
+
The standard error, or ``NaN`` when the Hessian is singular or the
|
|
291
|
+
inputs are degenerate.
|
|
292
|
+
"""
|
|
293
|
+
if np.isnan(rho) or abs(rho) >= 0.999:
|
|
294
|
+
return float("nan")
|
|
295
|
+
x_thr, x_inds = estimate_intervals(x)
|
|
296
|
+
y_thr, y_inds = estimate_intervals(y)
|
|
297
|
+
p, m = len(x_thr) - 1, len(y_thr) - 1
|
|
298
|
+
if p < 2 or m < 2:
|
|
299
|
+
return float("nan")
|
|
300
|
+
n = np.zeros((p, m))
|
|
301
|
+
for a, b in zip(x_inds, y_inds):
|
|
302
|
+
if not (np.isnan(a) or np.isnan(b)):
|
|
303
|
+
n[int(a) - 1, int(b) - 1] += 1
|
|
304
|
+
a0 = np.asarray(x_thr[1:-1], dtype=float)
|
|
305
|
+
b0 = np.asarray(y_thr[1:-1], dtype=float)
|
|
306
|
+
|
|
307
|
+
def nll(theta: np.ndarray) -> float:
|
|
308
|
+
r = theta[0]
|
|
309
|
+
a = theta[1:p]
|
|
310
|
+
b = theta[p:]
|
|
311
|
+
if abs(r) >= 0.9995 or np.any(np.diff(a) <= 0) or np.any(np.diff(b) <= 0):
|
|
312
|
+
return 1e12
|
|
313
|
+
probs = _bvn_cell_probs(
|
|
314
|
+
np.concatenate([[-np.inf], a, [np.inf]]),
|
|
315
|
+
np.concatenate([[-np.inf], b, [np.inf]]),
|
|
316
|
+
r,
|
|
317
|
+
)
|
|
318
|
+
if np.any(probs[n > 0] <= 0):
|
|
319
|
+
return 1e12
|
|
320
|
+
return float(-np.sum(n[n > 0] * np.log(probs[n > 0])))
|
|
321
|
+
|
|
322
|
+
return _se_from_hessian(nll, np.concatenate([[rho], a0, b0]))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def polyserial_se(x_continuous, x_ordinal, rho: float) -> float:
|
|
326
|
+
"""Asymptotic SE of a polyserial correlation.
|
|
327
|
+
|
|
328
|
+
Central-difference Hessian of the negative log-likelihood in
|
|
329
|
+
``(rho, thresholds)`` with the continuous variable standardised, matching
|
|
330
|
+
the parameterisation R's ``polycor::polyserial(std.err=TRUE)`` inverts
|
|
331
|
+
(Olsson, Drasgow & Dorans 1982). Returns ``NaN`` on failure.
|
|
332
|
+
"""
|
|
333
|
+
if np.isnan(rho) or abs(rho) >= 0.999:
|
|
334
|
+
return float("nan")
|
|
335
|
+
xc = np.asarray(x_continuous, dtype=float)
|
|
336
|
+
thr, inds = estimate_intervals(np.asarray(x_ordinal))
|
|
337
|
+
tau0 = np.asarray(thr[1:-1], dtype=float)
|
|
338
|
+
if len(tau0) < 1:
|
|
339
|
+
return float("nan")
|
|
340
|
+
k_idx = np.asarray(inds, dtype=float)
|
|
341
|
+
ok = ~(np.isnan(k_idx) | np.isnan(xc))
|
|
342
|
+
xc = xc[ok]
|
|
343
|
+
k_idx = k_idx[ok].astype(int)
|
|
344
|
+
if len(xc) < 4:
|
|
345
|
+
return float("nan")
|
|
346
|
+
sd = xc.std(ddof=0)
|
|
347
|
+
if sd <= 0:
|
|
348
|
+
return float("nan")
|
|
349
|
+
z = (xc - xc.mean()) / sd
|
|
350
|
+
|
|
351
|
+
def nll(theta: np.ndarray) -> float:
|
|
352
|
+
r = theta[0]
|
|
353
|
+
tau = theta[1:]
|
|
354
|
+
if abs(r) >= 0.9995 or np.any(np.diff(tau) <= 0):
|
|
355
|
+
return 1e12
|
|
356
|
+
denom = np.sqrt(max(1 - r ** 2, 1e-12))
|
|
357
|
+
tf = np.concatenate([[-np.inf], tau, [np.inf]])
|
|
358
|
+
pr = norm.cdf((tf[k_idx] - r * z) / denom) - norm.cdf(
|
|
359
|
+
(tf[k_idx - 1] - r * z) / denom
|
|
360
|
+
)
|
|
361
|
+
if np.any(pr <= 0):
|
|
362
|
+
return 1e12
|
|
363
|
+
return float(-np.sum(norm.logpdf(z) + np.log(pr)))
|
|
364
|
+
|
|
365
|
+
return _se_from_hessian(nll, np.concatenate([[rho], tau0]))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _bvn_cell_probs(x_thr: np.ndarray, y_thr: np.ndarray, rho: float) -> np.ndarray:
|
|
369
|
+
"""Rectangle probabilities for every cell of a threshold grid.
|
|
370
|
+
|
|
371
|
+
One vectorised bivariate-normal CDF call over the full grid of threshold
|
|
372
|
+
crossings, then the inclusion-exclusion differences. Infinite thresholds
|
|
373
|
+
are clipped to +/-12 (beyond any representable tail mass).
|
|
374
|
+
"""
|
|
375
|
+
xt = np.clip(np.asarray(x_thr, dtype=float), -12.0, 12.0)
|
|
376
|
+
yt = np.clip(np.asarray(y_thr, dtype=float), -12.0, 12.0)
|
|
377
|
+
gx, gy = np.meshgrid(xt, yt, indexing="ij")
|
|
378
|
+
pts = np.column_stack([gx.ravel(), gy.ravel()])
|
|
379
|
+
r = float(np.clip(rho, -1 + 1e-9, 1 - 1e-9))
|
|
380
|
+
rv = multivariate_normal(mean=[0.0, 0.0], cov=[[1.0, r], [r, 1.0]])
|
|
381
|
+
cdf = np.asarray(rv.cdf(pts)).reshape(len(xt), len(yt))
|
|
382
|
+
return cdf[1:, 1:] - cdf[:-1, 1:] - cdf[1:, :-1] + cdf[:-1, :-1]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _hessian(f, theta: np.ndarray) -> np.ndarray:
|
|
386
|
+
"""Central-difference Hessian of ``f`` at ``theta``."""
|
|
387
|
+
theta = np.asarray(theta, dtype=float)
|
|
388
|
+
k = len(theta)
|
|
389
|
+
h = np.finfo(float).eps ** 0.25 * np.maximum(1.0, np.abs(theta))
|
|
390
|
+
H = np.empty((k, k))
|
|
391
|
+
f0 = f(theta)
|
|
392
|
+
for i in range(k):
|
|
393
|
+
ei = np.zeros(k)
|
|
394
|
+
ei[i] = h[i]
|
|
395
|
+
H[i, i] = (f(theta + ei) - 2 * f0 + f(theta - ei)) / h[i] ** 2
|
|
396
|
+
for j in range(i + 1, k):
|
|
397
|
+
ej = np.zeros(k)
|
|
398
|
+
ej[j] = h[j]
|
|
399
|
+
H[i, j] = H[j, i] = (
|
|
400
|
+
f(theta + ei + ej)
|
|
401
|
+
- f(theta + ei - ej)
|
|
402
|
+
- f(theta - ei + ej)
|
|
403
|
+
+ f(theta - ei - ej)
|
|
404
|
+
) / (4 * h[i] * h[j])
|
|
405
|
+
return H
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _se_from_hessian(nll, theta0: np.ndarray) -> float:
|
|
409
|
+
"""SE of the first parameter from the inverse Hessian of ``nll``."""
|
|
410
|
+
try:
|
|
411
|
+
H = _hessian(nll, theta0)
|
|
412
|
+
if not np.all(np.isfinite(H)):
|
|
413
|
+
return float("nan")
|
|
414
|
+
cov = np.linalg.inv(H)
|
|
415
|
+
except np.linalg.LinAlgError:
|
|
416
|
+
return float("nan")
|
|
417
|
+
v = cov[0, 0]
|
|
418
|
+
return float(np.sqrt(v)) if v > 0 else float("nan")
|