python-gls 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.
python_gls/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """python_gls: GLS with learned correlation and variance structures.
2
+
3
+ Python equivalent of R's nlme::gls(). Estimates Generalized Least Squares
4
+ models where the correlation and variance structures are learned from data
5
+ via ML/REML, not pre-specified.
6
+
7
+ Basic usage::
8
+
9
+ from python_gls import GLS
10
+ from python_gls.correlation import CorAR1
11
+ from python_gls.variance import VarIdent
12
+
13
+ result = GLS.from_formula(
14
+ "y ~ x1 + x2",
15
+ data=df,
16
+ correlation=CorAR1(),
17
+ variance=VarIdent("group"),
18
+ groups="subject",
19
+ ).fit()
20
+
21
+ print(result.summary())
22
+ """
23
+
24
+ from python_gls.model import GLS
25
+ from python_gls.results import GLSResults
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = ["GLS", "GLSResults"]
@@ -0,0 +1,137 @@
1
+ """Spherical parametrization for correlation matrices.
2
+
3
+ Based on Pinheiro & Bates (1996). Transforms between correlation matrices
4
+ and unconstrained angle parameters, ensuring positive-definiteness during
5
+ optimization without bound constraints.
6
+
7
+ A d×d correlation matrix has d(d-1)/2 free parameters. We parametrize via
8
+ angles θ ∈ (0, π), which map to a Cholesky factor L such that R = LL'.
9
+ The angles are unconstrained on the real line via logit-like transformation.
10
+ """
11
+
12
+ import numpy as np
13
+ from numpy.typing import NDArray
14
+
15
+
16
+ def angles_to_cholesky(angles: NDArray, d: int) -> NDArray:
17
+ """Convert angle parameters to lower-triangular Cholesky factor.
18
+
19
+ Parameters
20
+ ----------
21
+ angles : array of shape (d*(d-1)/2,)
22
+ Angle parameters in (0, pi).
23
+ d : int
24
+ Dimension of the correlation matrix.
25
+
26
+ Returns
27
+ -------
28
+ L : array of shape (d, d)
29
+ Lower-triangular Cholesky factor such that L @ L.T is a
30
+ correlation matrix.
31
+ """
32
+ L = np.zeros((d, d))
33
+ idx = 0
34
+ for i in range(d):
35
+ for j in range(i + 1):
36
+ if j == 0 and i == 0:
37
+ L[i, j] = 1.0
38
+ elif j == 0:
39
+ L[i, j] = np.cos(angles[idx])
40
+ idx += 1
41
+ elif j < i:
42
+ prod = np.prod([np.sin(angles[idx - k - 1]) for k in range(j)])
43
+ L[i, j] = prod * np.cos(angles[idx])
44
+ idx += 1
45
+ else: # j == i, last column
46
+ prod = np.prod([np.sin(angles[idx - k - 1]) for k in range(j)])
47
+ L[i, j] = prod
48
+ return L
49
+
50
+
51
+ def cholesky_to_corr(L: NDArray) -> NDArray:
52
+ """Convert Cholesky factor to correlation matrix."""
53
+ R = L @ L.T
54
+ # Ensure exact ones on diagonal (numerical stability)
55
+ d = np.sqrt(np.diag(R))
56
+ R = R / np.outer(d, d)
57
+ np.fill_diagonal(R, 1.0)
58
+ return R
59
+
60
+
61
+ def angles_to_corr(angles: NDArray, d: int) -> NDArray:
62
+ """Convert unconstrained angles to a correlation matrix.
63
+
64
+ Parameters
65
+ ----------
66
+ angles : array of shape (d*(d-1)/2,)
67
+ Angle parameters in (0, pi).
68
+ d : int
69
+ Dimension of the correlation matrix.
70
+
71
+ Returns
72
+ -------
73
+ R : array of shape (d, d)
74
+ Positive-definite correlation matrix.
75
+ """
76
+ L = angles_to_cholesky(angles, d)
77
+ return cholesky_to_corr(L)
78
+
79
+
80
+ def corr_to_angles(R: NDArray) -> NDArray:
81
+ """Convert a correlation matrix to angle parameters.
82
+
83
+ Parameters
84
+ ----------
85
+ R : array of shape (d, d)
86
+ Positive-definite correlation matrix.
87
+
88
+ Returns
89
+ -------
90
+ angles : array of shape (d*(d-1)/2,)
91
+ Angle parameters in (0, pi).
92
+ """
93
+ d = R.shape[0]
94
+ L = np.linalg.cholesky(R)
95
+ # Normalize rows to unit length
96
+ norms = np.sqrt(np.sum(L ** 2, axis=1))
97
+ L = L / norms[:, np.newaxis]
98
+
99
+ n_angles = d * (d - 1) // 2
100
+ angles = np.zeros(n_angles)
101
+ idx = 0
102
+ for i in range(1, d):
103
+ for j in range(i):
104
+ if j == 0:
105
+ angles[idx] = np.arccos(np.clip(L[i, 0], -1, 1))
106
+ else:
107
+ prod = np.prod([np.sin(angles[idx - k - 1]) for k in range(j)])
108
+ if abs(prod) < 1e-15:
109
+ angles[idx] = np.pi / 2
110
+ else:
111
+ angles[idx] = np.arccos(np.clip(L[i, j] / prod, -1, 1))
112
+ idx += 1
113
+ return angles
114
+
115
+
116
+ def unconstrained_to_angles(params: NDArray) -> NDArray:
117
+ """Map unconstrained parameters to (0, pi) via scaled sigmoid."""
118
+ return np.pi / (1 + np.exp(-params))
119
+
120
+
121
+ def angles_to_unconstrained(angles: NDArray) -> NDArray:
122
+ """Map angles in (0, pi) to unconstrained parameters."""
123
+ # Clip to avoid log(0)
124
+ ratio = np.clip(angles / np.pi, 1e-10, 1 - 1e-10)
125
+ return np.log(ratio / (1 - ratio))
126
+
127
+
128
+ def unconstrained_to_corr(params: NDArray, d: int) -> NDArray:
129
+ """Map unconstrained parameters directly to a correlation matrix."""
130
+ angles = unconstrained_to_angles(params)
131
+ return angles_to_corr(angles, d)
132
+
133
+
134
+ def corr_to_unconstrained(R: NDArray) -> NDArray:
135
+ """Map a correlation matrix to unconstrained parameters."""
136
+ angles = corr_to_angles(R)
137
+ return angles_to_unconstrained(angles)
@@ -0,0 +1,29 @@
1
+ """Correlation structures for GLS estimation."""
2
+
3
+ from python_gls.correlation.base import CorStruct
4
+ from python_gls.correlation.symm import CorSymm
5
+ from python_gls.correlation.comp_symm import CorCompSymm
6
+ from python_gls.correlation.ar1 import CorAR1
7
+ from python_gls.correlation.arma import CorARMA
8
+ from python_gls.correlation.car1 import CorCAR1
9
+ from python_gls.correlation.spatial import (
10
+ CorExp,
11
+ CorGaus,
12
+ CorLin,
13
+ CorRatio,
14
+ CorSpher,
15
+ )
16
+
17
+ __all__ = [
18
+ "CorStruct",
19
+ "CorSymm",
20
+ "CorCompSymm",
21
+ "CorAR1",
22
+ "CorARMA",
23
+ "CorCAR1",
24
+ "CorExp",
25
+ "CorGaus",
26
+ "CorLin",
27
+ "CorRatio",
28
+ "CorSpher",
29
+ ]
@@ -0,0 +1,67 @@
1
+ """AR(1) correlation structure."""
2
+
3
+ import numpy as np
4
+ from numpy.typing import NDArray
5
+
6
+ from python_gls.correlation.base import CorStruct
7
+
8
+
9
+ class CorAR1(CorStruct):
10
+ """First-order autoregressive correlation.
11
+
12
+ R[i,j] = phi^|i-j| for equally-spaced observations.
13
+
14
+ Equivalent to R's `corAR1()`.
15
+
16
+ Parameters
17
+ ----------
18
+ phi : float, optional
19
+ Autoregressive parameter, |phi| < 1.
20
+ """
21
+
22
+ def __init__(self, phi: float | None = None):
23
+ super().__init__()
24
+ if phi is not None:
25
+ if not isinstance(phi, (int, float)):
26
+ raise TypeError(f"phi must be a number, got {type(phi).__name__}")
27
+ if not -1 < phi < 1:
28
+ raise ValueError(
29
+ f"phi must be in (-1, 1) for stationarity, got {phi}"
30
+ )
31
+ self._params = np.array([float(phi)])
32
+
33
+ @property
34
+ def n_params(self) -> int:
35
+ return 1
36
+
37
+ def get_correlation_matrix(self, group_size: int, **kwargs) -> NDArray:
38
+ if self._params is None:
39
+ return np.eye(group_size)
40
+ phi = self._params[0]
41
+ indices = np.arange(group_size)
42
+ R = phi ** np.abs(indices[:, None] - indices[None, :])
43
+ return R
44
+
45
+ def _get_init_params(self, residuals_by_group: list[NDArray]) -> NDArray:
46
+ # Estimate phi from lag-1 autocorrelation
47
+ lag1_corrs = []
48
+ for r in residuals_by_group:
49
+ if len(r) < 2:
50
+ continue
51
+ r_centered = r - np.mean(r)
52
+ var = np.var(r_centered)
53
+ if var > 1e-10:
54
+ lag1 = np.sum(r_centered[:-1] * r_centered[1:]) / (len(r) * var)
55
+ lag1_corrs.append(lag1)
56
+ if lag1_corrs:
57
+ phi = np.clip(np.mean(lag1_corrs), -0.99, 0.99)
58
+ else:
59
+ phi = 0.0
60
+ return np.array([phi])
61
+
62
+ def _params_to_unconstrained(self, params: NDArray) -> NDArray:
63
+ phi = np.clip(params[0], -0.999, 0.999)
64
+ return np.array([np.arctanh(phi)])
65
+
66
+ def _unconstrained_to_params(self, uparams: NDArray) -> NDArray:
67
+ return np.array([np.tanh(uparams[0])])
@@ -0,0 +1,118 @@
1
+ """ARMA(p,q) correlation structure."""
2
+
3
+ import numpy as np
4
+ from numpy.typing import NDArray
5
+ from scipy.linalg import toeplitz
6
+
7
+ from python_gls.correlation.base import CorStruct
8
+
9
+
10
+ class CorARMA(CorStruct):
11
+ """ARMA(p,q) correlation structure.
12
+
13
+ Defines correlation via an autoregressive moving-average process.
14
+ The autocorrelation function is computed from AR and MA coefficients.
15
+
16
+ Equivalent to R's `corARMA(p=p, q=q)`.
17
+
18
+ Parameters
19
+ ----------
20
+ p : int
21
+ Order of the AR component.
22
+ q : int
23
+ Order of the MA component.
24
+ """
25
+
26
+ def __init__(self, p: int = 0, q: int = 0):
27
+ super().__init__()
28
+ if not isinstance(p, int) or not isinstance(q, int):
29
+ raise TypeError(
30
+ f"p and q must be integers, got p={type(p).__name__}, q={type(q).__name__}"
31
+ )
32
+ if p < 0 or q < 0:
33
+ raise ValueError(f"p and q must be non-negative, got p={p}, q={q}")
34
+ if p == 0 and q == 0:
35
+ raise ValueError("At least one of p or q must be > 0.")
36
+ self.p = p
37
+ self.q = q
38
+
39
+ @property
40
+ def n_params(self) -> int:
41
+ return self.p + self.q
42
+
43
+ def _compute_acf(self, max_lag: int) -> NDArray:
44
+ """Compute autocorrelation function from ARMA parameters."""
45
+ ar = self._params[:self.p] if self.p > 0 else np.array([])
46
+ ma = self._params[self.p:] if self.q > 0 else np.array([])
47
+
48
+ # Compute ACF of ARMA(p,q) process via Yule-Walker-like recursion
49
+ acf = np.zeros(max_lag + 1)
50
+ acf[0] = 1.0
51
+
52
+ # For pure AR
53
+ if self.q == 0 and self.p > 0:
54
+ # Yule-Walker: gamma(h) = sum_i phi_i * gamma(h-i)
55
+ for h in range(1, max_lag + 1):
56
+ for i in range(min(self.p, h)):
57
+ if h - i - 1 >= 0:
58
+ acf[h] += ar[i] * acf[abs(h - i - 1)]
59
+ return acf
60
+
61
+ # For pure MA
62
+ if self.p == 0 and self.q > 0:
63
+ theta = np.concatenate([[1.0], ma])
64
+ for h in range(min(self.q + 1, max_lag + 1)):
65
+ num = sum(
66
+ theta[j] * theta[j + h]
67
+ for j in range(self.q + 1 - h)
68
+ )
69
+ denom = sum(theta[j] ** 2 for j in range(self.q + 1))
70
+ acf[h] = num / denom
71
+ return acf
72
+
73
+ # General ARMA: use impulse response function
74
+ n_impulse = max(max_lag + 1, 100)
75
+ psi = np.zeros(n_impulse)
76
+ psi[0] = 1.0
77
+ ma_full = np.zeros(n_impulse)
78
+ ma_full[:self.q] = ma
79
+ ar_full = np.zeros(n_impulse)
80
+ ar_full[:self.p] = ar
81
+
82
+ for i in range(1, n_impulse):
83
+ if i <= self.q:
84
+ psi[i] = ma_full[i - 1]
85
+ for j in range(min(self.p, i)):
86
+ psi[i] += ar_full[j] * psi[i - j - 1]
87
+
88
+ # ACF from impulse response
89
+ for h in range(max_lag + 1):
90
+ num = sum(psi[j] * psi[j + h] for j in range(n_impulse - h))
91
+ denom = sum(psi[j] ** 2 for j in range(n_impulse))
92
+ acf[h] = num / denom
93
+
94
+ return acf
95
+
96
+ def get_correlation_matrix(self, group_size: int, **kwargs) -> NDArray:
97
+ if self._params is None:
98
+ return np.eye(group_size)
99
+ acf = self._compute_acf(group_size - 1)
100
+ R = toeplitz(acf)
101
+ # Ensure positive-definiteness
102
+ eigvals = np.linalg.eigvalsh(R)
103
+ if np.min(eigvals) < 1e-10:
104
+ R += (1e-10 - np.min(eigvals) + 1e-10) * np.eye(group_size)
105
+ d = np.sqrt(np.diag(R))
106
+ R = R / np.outer(d, d)
107
+ return R
108
+
109
+ def _get_init_params(self, residuals_by_group: list[NDArray]) -> NDArray:
110
+ # Small initial values
111
+ return np.zeros(self.n_params) + 0.1
112
+
113
+ def _params_to_unconstrained(self, params: NDArray) -> NDArray:
114
+ # Use tanh transform for stability
115
+ return np.arctanh(np.clip(params, -0.999, 0.999))
116
+
117
+ def _unconstrained_to_params(self, uparams: NDArray) -> NDArray:
118
+ return np.tanh(uparams)
@@ -0,0 +1,125 @@
1
+ """Base class for correlation structures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ import numpy as np
8
+ from numpy.typing import NDArray
9
+
10
+
11
+ class CorStruct(ABC):
12
+ """Abstract base class for correlation structures.
13
+
14
+ A correlation structure defines within-group correlations for GLS
15
+ estimation. Each group (e.g., subject, cluster) has its own
16
+ correlation matrix, but all groups share the same parameters.
17
+
18
+ Subclasses must implement:
19
+ - get_correlation_matrix(group_size, **kwargs)
20
+ - n_params (property)
21
+ - _get_init_params(residuals_by_group)
22
+ """
23
+
24
+ def __init__(self) -> None:
25
+ self._params: NDArray | None = None
26
+ self._unconstrained_params: NDArray | None = None
27
+
28
+ @abstractmethod
29
+ def get_correlation_matrix(self, group_size: int, **kwargs) -> NDArray:
30
+ """Return the correlation matrix for a group of given size.
31
+
32
+ Parameters
33
+ ----------
34
+ group_size : int
35
+ Number of observations in this group.
36
+ **kwargs
37
+ Additional context (e.g., time points, positions).
38
+
39
+ Returns
40
+ -------
41
+ R : (group_size, group_size) correlation matrix.
42
+ """
43
+
44
+ @property
45
+ @abstractmethod
46
+ def n_params(self) -> int:
47
+ """Number of correlation parameters."""
48
+
49
+ @abstractmethod
50
+ def _get_init_params(self, residuals_by_group: list[NDArray]) -> NDArray:
51
+ """Compute initial parameter values from OLS residuals.
52
+
53
+ Parameters
54
+ ----------
55
+ residuals_by_group : list of arrays
56
+ Residuals split by group.
57
+
58
+ Returns
59
+ -------
60
+ params : array of initial parameter values.
61
+ """
62
+
63
+ def get_params(self) -> NDArray:
64
+ """Get current parameter values."""
65
+ if self._params is None:
66
+ raise ValueError(
67
+ f"{type(self).__name__} parameters not yet initialized. "
68
+ f"Call initialize() first or fit the model."
69
+ )
70
+ return self._params.copy()
71
+
72
+ def set_params(self, params: NDArray) -> None:
73
+ """Set parameter values."""
74
+ params = np.asarray(params, dtype=float)
75
+ if params.ndim != 1:
76
+ raise ValueError(
77
+ f"params must be a 1-D array, got shape {params.shape}"
78
+ )
79
+ self._params = params
80
+
81
+ def get_unconstrained_params(self) -> NDArray:
82
+ """Get unconstrained (transformed) parameters for optimization."""
83
+ if self._unconstrained_params is None:
84
+ if self._params is None:
85
+ raise ValueError(
86
+ f"{type(self).__name__} parameters not yet initialized. "
87
+ f"Call initialize() first or fit the model."
88
+ )
89
+ return self._params_to_unconstrained(self._params)
90
+ return self._unconstrained_params.copy()
91
+
92
+ def set_unconstrained_params(self, uparams: NDArray) -> None:
93
+ """Set parameters from unconstrained (transformed) values."""
94
+ uparams = np.asarray(uparams, dtype=float)
95
+ self._unconstrained_params = uparams
96
+ self._params = self._unconstrained_to_params(uparams)
97
+
98
+ def _params_to_unconstrained(self, params: NDArray) -> NDArray:
99
+ """Transform natural parameters to unconstrained space.
100
+
101
+ Default: identity (override for constrained parameters).
102
+ """
103
+ return params.copy()
104
+
105
+ def _unconstrained_to_params(self, uparams: NDArray) -> NDArray:
106
+ """Transform unconstrained parameters to natural space.
107
+
108
+ Default: identity (override for constrained parameters).
109
+ """
110
+ return uparams.copy()
111
+
112
+ def initialize(self, residuals_by_group: list[NDArray]) -> None:
113
+ """Initialize parameters from OLS residuals.
114
+
115
+ Parameters
116
+ ----------
117
+ residuals_by_group : list of arrays
118
+ Residuals split by group.
119
+ """
120
+ if not residuals_by_group:
121
+ raise ValueError(
122
+ "residuals_by_group must be a non-empty list of arrays"
123
+ )
124
+ self._params = self._get_init_params(residuals_by_group)
125
+ self._unconstrained_params = self._params_to_unconstrained(self._params)
@@ -0,0 +1,92 @@
1
+ """Continuous-time AR(1) correlation structure."""
2
+
3
+ import numpy as np
4
+ from numpy.typing import NDArray
5
+
6
+ from python_gls.correlation.base import CorStruct
7
+
8
+
9
+ class CorCAR1(CorStruct):
10
+ """Continuous-time first-order autoregressive correlation.
11
+
12
+ R[i,j] = phi^|t_i - t_j| where t_i are (possibly irregular) time points.
13
+ Unlike CorAR1, this handles irregularly-spaced observations.
14
+
15
+ Equivalent to R's `corCAR1()`.
16
+
17
+ Parameters
18
+ ----------
19
+ phi : float, optional
20
+ Decay parameter, 0 < phi < 1.
21
+ """
22
+
23
+ def __init__(self, phi: float | None = None):
24
+ super().__init__()
25
+ if phi is not None:
26
+ if not isinstance(phi, (int, float)):
27
+ raise TypeError(f"phi must be a number, got {type(phi).__name__}")
28
+ if not 0 < phi < 1:
29
+ raise ValueError(
30
+ f"phi must be in (0, 1) for continuous-time AR(1), got {phi}"
31
+ )
32
+ self._params = np.array([float(phi)])
33
+ self._time_points: dict[int, NDArray] = {}
34
+
35
+ @property
36
+ def n_params(self) -> int:
37
+ return 1
38
+
39
+ def set_time_points(self, group_id: int, times: NDArray) -> None:
40
+ """Set time points for a specific group.
41
+
42
+ Parameters
43
+ ----------
44
+ group_id : int
45
+ Group index.
46
+ times : array
47
+ Time points for this group.
48
+ """
49
+ self._time_points[group_id] = np.asarray(times, dtype=float)
50
+
51
+ def get_correlation_matrix(self, group_size: int, **kwargs) -> NDArray:
52
+ if self._params is None:
53
+ return np.eye(group_size)
54
+
55
+ phi = self._params[0]
56
+ group_id = kwargs.get("group_id", None)
57
+
58
+ if group_id is not None and group_id in self._time_points:
59
+ times = self._time_points[group_id]
60
+ else:
61
+ # Default to equally-spaced
62
+ times = np.arange(group_size, dtype=float)
63
+
64
+ time_diffs = np.abs(times[:, None] - times[None, :])
65
+ R = phi ** time_diffs
66
+ return R
67
+
68
+ def _get_init_params(self, residuals_by_group: list[NDArray]) -> NDArray:
69
+ lag1_corrs = []
70
+ for r in residuals_by_group:
71
+ if len(r) < 2:
72
+ continue
73
+ r_centered = r - np.mean(r)
74
+ var = np.var(r_centered)
75
+ if var > 1e-10:
76
+ lag1 = np.sum(r_centered[:-1] * r_centered[1:]) / (len(r) * var)
77
+ lag1_corrs.append(lag1)
78
+ if lag1_corrs:
79
+ phi = np.clip(np.mean(lag1_corrs), 0.01, 0.99)
80
+ else:
81
+ phi = 0.5
82
+ return np.array([phi])
83
+
84
+ def _params_to_unconstrained(self, params: NDArray) -> NDArray:
85
+ # phi in (0, 1) -> logit
86
+ phi = np.clip(params[0], 1e-6, 1 - 1e-6)
87
+ return np.array([np.log(phi / (1 - phi))])
88
+
89
+ def _unconstrained_to_params(self, uparams: NDArray) -> NDArray:
90
+ # Clip to avoid overflow in exp for very large negative values
91
+ u = np.clip(uparams[0], -500, 500)
92
+ return np.array([1 / (1 + np.exp(-u))])
@@ -0,0 +1,69 @@
1
+ """Compound symmetry (exchangeable) correlation structure."""
2
+
3
+ import numpy as np
4
+ from numpy.typing import NDArray
5
+
6
+ from python_gls.correlation.base import CorStruct
7
+
8
+
9
+ class CorCompSymm(CorStruct):
10
+ """Compound symmetry (exchangeable) correlation.
11
+
12
+ All pairwise correlations are equal to rho. The correlation matrix is:
13
+ R[i,j] = rho for i != j, 1 for i == j.
14
+
15
+ Equivalent to R's `corCompSymm()`.
16
+
17
+ Parameters
18
+ ----------
19
+ rho : float, optional
20
+ Initial correlation value. Must be in (-1/(d-1), 1) for
21
+ positive-definiteness.
22
+ """
23
+
24
+ def __init__(self, rho: float | None = None):
25
+ super().__init__()
26
+ if rho is not None:
27
+ if not isinstance(rho, (int, float)):
28
+ raise TypeError(f"rho must be a number, got {type(rho).__name__}")
29
+ if not -1 < rho < 1:
30
+ raise ValueError(
31
+ f"rho must be in (-1, 1) for positive-definiteness, got {rho}"
32
+ )
33
+ self._params = np.array([float(rho)])
34
+
35
+ @property
36
+ def n_params(self) -> int:
37
+ return 1
38
+
39
+ def get_correlation_matrix(self, group_size: int, **kwargs) -> NDArray:
40
+ if self._params is None:
41
+ return np.eye(group_size)
42
+ rho = self._params[0]
43
+ R = np.full((group_size, group_size), rho)
44
+ np.fill_diagonal(R, 1.0)
45
+ return R
46
+
47
+ def _get_init_params(self, residuals_by_group: list[NDArray]) -> NDArray:
48
+ # Estimate rho from average pairwise correlation of residuals
49
+ corrs = []
50
+ for r in residuals_by_group:
51
+ d = len(r)
52
+ if d < 2:
53
+ continue
54
+ for i in range(d):
55
+ for j in range(i + 1, d):
56
+ corrs.append(r[i] * r[j] / (np.std(r) ** 2 + 1e-10))
57
+ if corrs:
58
+ rho = np.clip(np.mean(corrs), -0.9, 0.9)
59
+ else:
60
+ rho = 0.0
61
+ return np.array([rho])
62
+
63
+ def _params_to_unconstrained(self, params: NDArray) -> NDArray:
64
+ # Fisher z-transform: rho -> atanh(rho)
65
+ rho = np.clip(params[0], -0.999, 0.999)
66
+ return np.array([np.arctanh(rho)])
67
+
68
+ def _unconstrained_to_params(self, uparams: NDArray) -> NDArray:
69
+ return np.array([np.tanh(uparams[0])])