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 +29 -0
- python_gls/_parametrization.py +137 -0
- python_gls/correlation/__init__.py +29 -0
- python_gls/correlation/ar1.py +67 -0
- python_gls/correlation/arma.py +118 -0
- python_gls/correlation/base.py +125 -0
- python_gls/correlation/car1.py +92 -0
- python_gls/correlation/comp_symm.py +69 -0
- python_gls/correlation/spatial.py +190 -0
- python_gls/correlation/symm.py +85 -0
- python_gls/likelihood.py +302 -0
- python_gls/model.py +511 -0
- python_gls/results.py +223 -0
- python_gls/variance/__init__.py +19 -0
- python_gls/variance/base.py +101 -0
- python_gls/variance/comb.py +82 -0
- python_gls/variance/const_power.py +50 -0
- python_gls/variance/exp.py +50 -0
- python_gls/variance/fixed.py +46 -0
- python_gls/variance/ident.py +84 -0
- python_gls/variance/power.py +52 -0
- python_gls-0.1.0.dist-info/METADATA +361 -0
- python_gls-0.1.0.dist-info/RECORD +26 -0
- python_gls-0.1.0.dist-info/WHEEL +5 -0
- python_gls-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_gls-0.1.0.dist-info/top_level.txt +1 -0
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])])
|