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.
@@ -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
+ ]
@@ -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")