stmmlib 0.2.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.
- stmmlib/__init__.py +30 -0
- stmmlib/_backend.py +153 -0
- stmmlib/_utils.py +175 -0
- stmmlib/beta/__init__.py +17 -0
- stmmlib/beta/config.py +108 -0
- stmmlib/beta/criteria.py +247 -0
- stmmlib/beta/design.py +222 -0
- stmmlib/beta/diagnose.py +168 -0
- stmmlib/beta/diagnostics.py +94 -0
- stmmlib/beta/fit.py +598 -0
- stmmlib/beta/gpu_solvers.py +169 -0
- stmmlib/beta/inference.py +403 -0
- stmmlib/beta/lrt_fixed.py +551 -0
- stmmlib/beta/moran_panel.py +733 -0
- stmmlib/beta/plot_data.py +252 -0
- stmmlib/beta/precision.py +98 -0
- stmmlib/beta/report.py +373 -0
- stmmlib/beta/result.py +696 -0
- stmmlib/beta/simulation.py +343 -0
- stmmlib/beta/solvers.py +2021 -0
- stmmlib/beta/structure.py +56 -0
- stmmlib/diagnostics_suite.py +663 -0
- stmmlib/gaussian/__init__.py +1 -0
- stmmlib/gaussian/blup.py +252 -0
- stmmlib/gaussian/covariance.py +599 -0
- stmmlib/gaussian/design.py +312 -0
- stmmlib/gaussian/diagnostics.py +308 -0
- stmmlib/gaussian/gls.py +285 -0
- stmmlib/gaussian/hessian.py +197 -0
- stmmlib/gaussian/lrt_fixed.py +289 -0
- stmmlib/gaussian/models.py +528 -0
- stmmlib/gaussian/optimization.py +361 -0
- stmmlib/gaussian/simulation.py +186 -0
- stmmlib/gaussian/summary.py +277 -0
- stmmlib/gaussian/transforms.py +419 -0
- stmmlib/gaussian/variance_decomp.py +191 -0
- stmmlib/lrt.py +74 -0
- stmmlib/output_format.py +148 -0
- stmmlib/output_format_beta.py +299 -0
- stmmlib/output_format_normal.py +118 -0
- stmmlib/registry.py +326 -0
- stmmlib/stmm.py +265 -0
- stmmlib-0.2.0.dist-info/METADATA +215 -0
- stmmlib-0.2.0.dist-info/RECORD +47 -0
- stmmlib-0.2.0.dist-info/WHEEL +5 -0
- stmmlib-0.2.0.dist-info/licenses/LICENSE +21 -0
- stmmlib-0.2.0.dist-info/top_level.txt +1 -0
stmmlib/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""stmmlib — Family-aware spatio-temporal mixed models.
|
|
2
|
+
|
|
3
|
+
Supports 14 model structures (M1-M14) for Gaussian and Beta families,
|
|
4
|
+
with optional CUDA/GPU acceleration via CuPy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from stmmlib.registry import (
|
|
8
|
+
stmm_available_models,
|
|
9
|
+
stmm_model_label,
|
|
10
|
+
stmm_parse_model,
|
|
11
|
+
)
|
|
12
|
+
from stmmlib.stmm import stmm, stmm_normal, stmm_beta
|
|
13
|
+
from stmmlib.output_format import format_output
|
|
14
|
+
from stmmlib._backend import Backend
|
|
15
|
+
|
|
16
|
+
try: # single source of truth for the version: the installed package metadata
|
|
17
|
+
from importlib.metadata import version as _pkg_version
|
|
18
|
+
__version__ = _pkg_version("stmmlib")
|
|
19
|
+
except Exception: # source checkout without an install
|
|
20
|
+
__version__ = "0.2.0"
|
|
21
|
+
__all__ = [
|
|
22
|
+
"stmm",
|
|
23
|
+
"stmm_normal",
|
|
24
|
+
"stmm_beta",
|
|
25
|
+
"format_output",
|
|
26
|
+
"stmm_available_models",
|
|
27
|
+
"stmm_model_label",
|
|
28
|
+
"stmm_parse_model",
|
|
29
|
+
"Backend",
|
|
30
|
+
]
|
stmmlib/_backend.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""GPU/CPU backend abstraction with auto-detection.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
backend = Backend(gpu="auto") # auto-detect GPU
|
|
5
|
+
backend = Backend(gpu=True) # force GPU (error if unavailable)
|
|
6
|
+
backend = Backend(gpu=False) # force CPU
|
|
7
|
+
|
|
8
|
+
All array operations go through backend.xp (numpy or cupy).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import warnings
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Backend:
|
|
18
|
+
"""Array backend that routes computation to CPU or GPU.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
xp: Array namespace (numpy or cupy).
|
|
22
|
+
sparse: Sparse matrix namespace (scipy.sparse or cupyx.scipy.sparse).
|
|
23
|
+
device: "cpu" or "gpu".
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, gpu: bool | Literal["auto"] = "auto") -> None:
|
|
27
|
+
self.xp = None
|
|
28
|
+
self.sparse = None
|
|
29
|
+
self.device: Literal["cpu", "gpu"] = "cpu"
|
|
30
|
+
|
|
31
|
+
use_gpu = self._resolve_gpu(gpu)
|
|
32
|
+
|
|
33
|
+
if use_gpu:
|
|
34
|
+
try:
|
|
35
|
+
import cupy as cp # type: ignore[import-untyped]
|
|
36
|
+
import cupyx.scipy.sparse as csp # type: ignore[import-untyped]
|
|
37
|
+
|
|
38
|
+
cp.zeros(1, dtype=cp.float64) # test allocation
|
|
39
|
+
self.xp = cp # type: ignore[assignment]
|
|
40
|
+
self.sparse = csp # type: ignore[assignment]
|
|
41
|
+
self.device = "gpu"
|
|
42
|
+
return
|
|
43
|
+
except Exception as e:
|
|
44
|
+
if gpu is True:
|
|
45
|
+
raise RuntimeError(f"GPU requested but unavailable: {e}") from e
|
|
46
|
+
warnings.warn(
|
|
47
|
+
f"GPU unavailable ({e}), falling back to CPU.",
|
|
48
|
+
stacklevel=2,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
import numpy as np
|
|
52
|
+
import scipy.sparse as sp
|
|
53
|
+
|
|
54
|
+
self.xp = np # type: ignore[assignment]
|
|
55
|
+
self.sparse = sp # type: ignore[assignment]
|
|
56
|
+
self.device = "cpu"
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _resolve_gpu(gpu: bool | Literal["auto"]) -> bool:
|
|
60
|
+
if isinstance(gpu, str):
|
|
61
|
+
g = gpu.strip().lower()
|
|
62
|
+
if g == "auto":
|
|
63
|
+
try:
|
|
64
|
+
import cupy as cp # type: ignore[import-untyped]
|
|
65
|
+
|
|
66
|
+
cp.zeros(1, dtype=cp.float64)
|
|
67
|
+
return True
|
|
68
|
+
except Exception:
|
|
69
|
+
return False
|
|
70
|
+
if g in ("no", "false", "0", "off", "cpu"):
|
|
71
|
+
return False
|
|
72
|
+
if g in ("yes", "true", "1", "on", "gpu"):
|
|
73
|
+
return True
|
|
74
|
+
if gpu == "auto":
|
|
75
|
+
try:
|
|
76
|
+
import cupy as cp # type: ignore[import-untyped]
|
|
77
|
+
|
|
78
|
+
cp.zeros(1, dtype=cp.float64)
|
|
79
|
+
return True
|
|
80
|
+
except Exception:
|
|
81
|
+
return False
|
|
82
|
+
return bool(gpu)
|
|
83
|
+
|
|
84
|
+
def to_cpu(self, arr):
|
|
85
|
+
"""Move array to CPU. No-op if already on CPU."""
|
|
86
|
+
if self.device == "gpu":
|
|
87
|
+
return self.xp.asnumpy(arr)
|
|
88
|
+
return arr
|
|
89
|
+
|
|
90
|
+
def to_gpu(self, arr):
|
|
91
|
+
"""Move array to GPU. No-op if already on CPU."""
|
|
92
|
+
if self.device == "gpu":
|
|
93
|
+
return self.xp.asarray(arr)
|
|
94
|
+
return arr
|
|
95
|
+
|
|
96
|
+
def is_sparse(self, m) -> bool:
|
|
97
|
+
"""Check if matrix is a sparse matrix."""
|
|
98
|
+
if self.device == "gpu":
|
|
99
|
+
return self.sparse.issparse(m)
|
|
100
|
+
import scipy.sparse as sp
|
|
101
|
+
|
|
102
|
+
return sp.issparse(m)
|
|
103
|
+
|
|
104
|
+
def to_sparse(self, m):
|
|
105
|
+
"""Convert dense array to sparse CSR."""
|
|
106
|
+
if self.is_sparse(m):
|
|
107
|
+
return m
|
|
108
|
+
if self.device == "gpu":
|
|
109
|
+
return self.sparse.csr_matrix(m)
|
|
110
|
+
import scipy.sparse as sp
|
|
111
|
+
|
|
112
|
+
return sp.csr_matrix(m)
|
|
113
|
+
|
|
114
|
+
def sparse_kron(self, A, B, format="csr"):
|
|
115
|
+
"""Kronecker product of two (sparse) matrices."""
|
|
116
|
+
return self.sparse.kron(A, B, format=format)
|
|
117
|
+
|
|
118
|
+
def sparse_block_diag(self, mats):
|
|
119
|
+
"""Block-diagonal from list of matrices."""
|
|
120
|
+
if self.device == "gpu":
|
|
121
|
+
return self.sparse.block_diag(mats, format="csr")
|
|
122
|
+
import scipy.sparse as sp
|
|
123
|
+
|
|
124
|
+
return sp.block_diag(mats, format="csr")
|
|
125
|
+
|
|
126
|
+
def sparse_eye(self, n, dtype=None):
|
|
127
|
+
"""Identity matrix (sparse)."""
|
|
128
|
+
if dtype is None:
|
|
129
|
+
dtype = self.xp.float64
|
|
130
|
+
return self.sparse.eye(n, dtype=dtype, format="csr")
|
|
131
|
+
|
|
132
|
+
def solve(self, A, b):
|
|
133
|
+
"""Solve linear system A @ x = b."""
|
|
134
|
+
return self.xp.linalg.solve(A, b)
|
|
135
|
+
|
|
136
|
+
def cholesky(self, A):
|
|
137
|
+
"""Cholesky decomposition. Returns lower-triangular L where A = L @ L.T."""
|
|
138
|
+
return self.xp.linalg.cholesky(A)
|
|
139
|
+
|
|
140
|
+
def slogdet(self, A):
|
|
141
|
+
"""Log-determinant via sign and log-abs-determinant."""
|
|
142
|
+
return self.xp.linalg.slogdet(A)
|
|
143
|
+
|
|
144
|
+
def eigh(self, A):
|
|
145
|
+
"""Eigenvalues and eigenvectors of symmetric matrix."""
|
|
146
|
+
return self.xp.linalg.eigh(A)
|
|
147
|
+
|
|
148
|
+
def pinv(self, A):
|
|
149
|
+
"""Moore-Penrose pseudo-inverse."""
|
|
150
|
+
return self.xp.linalg.pinv(A)
|
|
151
|
+
|
|
152
|
+
def __repr__(self) -> str:
|
|
153
|
+
return f"Backend(device={self.device!r})"
|
stmmlib/_utils.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Shared matrix utilities used by both Gaussian and Beta backends.
|
|
2
|
+
|
|
3
|
+
All functions accept a Backend instance and use backend.xp for array ops.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import warnings
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from stmmlib._backend import Backend
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Positive-definiteness checks
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_pd(M, backend: Backend, tol: float = 1e-8) -> bool:
|
|
21
|
+
"""Test if matrix M is symmetric positive-definite via Cholesky attempt."""
|
|
22
|
+
xp = backend.xp
|
|
23
|
+
if backend.is_sparse(M):
|
|
24
|
+
M = M.toarray()
|
|
25
|
+
M = xp.asarray(M, dtype=xp.float64)
|
|
26
|
+
# Check approximate symmetry
|
|
27
|
+
if not xp.allclose(M, M.T, atol=tol):
|
|
28
|
+
return False
|
|
29
|
+
try:
|
|
30
|
+
backend.cholesky(M)
|
|
31
|
+
return True
|
|
32
|
+
except Exception:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def near_pd(M, backend: Backend, eps: float = 1e-6):
|
|
37
|
+
"""Project matrix to nearest positive-definite if needed.
|
|
38
|
+
|
|
39
|
+
If M is already PD, returns M unchanged. Otherwise adds small ridge
|
|
40
|
+
to the diagonal and retries.
|
|
41
|
+
"""
|
|
42
|
+
xp = backend.xp
|
|
43
|
+
if backend.is_sparse(M):
|
|
44
|
+
M = M.toarray()
|
|
45
|
+
M = xp.asarray(M, dtype=xp.float64)
|
|
46
|
+
|
|
47
|
+
if is_pd(M, backend, tol=eps):
|
|
48
|
+
return M
|
|
49
|
+
|
|
50
|
+
# Symmetrize
|
|
51
|
+
M = 0.5 * (M + M.T)
|
|
52
|
+
|
|
53
|
+
# Add increasing ridge until PD
|
|
54
|
+
n = M.shape[0]
|
|
55
|
+
for k in range(50):
|
|
56
|
+
shift = eps * (10.0**k) * xp.eye(n, dtype=xp.float64)
|
|
57
|
+
M_try = M + shift
|
|
58
|
+
if is_pd(M_try, backend, tol=eps):
|
|
59
|
+
return M_try
|
|
60
|
+
|
|
61
|
+
warnings.warn(
|
|
62
|
+
f"Matrix not PD after max ridge iterations. Returning with ridge={eps * 10**49}.",
|
|
63
|
+
stacklevel=2,
|
|
64
|
+
)
|
|
65
|
+
return M + eps * 1e49 * xp.eye(n, dtype=xp.float64)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def safe_cholesky(A, backend: Backend):
|
|
69
|
+
"""Cholesky decomposition with PD fallback.
|
|
70
|
+
|
|
71
|
+
Returns lower-triangular L such that A = L @ L.T.
|
|
72
|
+
"""
|
|
73
|
+
xp = backend.xp
|
|
74
|
+
if backend.is_sparse(A):
|
|
75
|
+
A = A.toarray()
|
|
76
|
+
A = xp.asarray(A, dtype=xp.float64)
|
|
77
|
+
try:
|
|
78
|
+
return backend.cholesky(A)
|
|
79
|
+
except Exception:
|
|
80
|
+
A_pd = near_pd(A, backend)
|
|
81
|
+
return backend.cholesky(A_pd)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def safe_solve(A, b, backend: Backend):
|
|
85
|
+
"""Solve A @ x = b with fallback to pseudo-inverse."""
|
|
86
|
+
xp = backend.xp
|
|
87
|
+
if backend.is_sparse(A):
|
|
88
|
+
A = A.toarray()
|
|
89
|
+
A = xp.asarray(A, dtype=xp.float64)
|
|
90
|
+
b = xp.asarray(b, dtype=xp.float64)
|
|
91
|
+
try:
|
|
92
|
+
return backend.solve(A, b)
|
|
93
|
+
except Exception:
|
|
94
|
+
return backend.pinv(A) @ b
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def logdet_matrix(M, backend: Backend):
|
|
98
|
+
"""Log-determinant of a matrix. Uses slogdet for numerical stability."""
|
|
99
|
+
xp = backend.xp
|
|
100
|
+
if backend.is_sparse(M):
|
|
101
|
+
M = M.toarray()
|
|
102
|
+
M = xp.asarray(M, dtype=xp.float64)
|
|
103
|
+
sign, logabsdet = backend.slogdet(M)
|
|
104
|
+
if float(sign) < 0:
|
|
105
|
+
warnings.warn("Negative determinant encountered.", stacklevel=2)
|
|
106
|
+
return float(logabsdet)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Interaction helpers
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def interaction_parse(interaction: str) -> str:
|
|
115
|
+
"""Normalize interaction type string."""
|
|
116
|
+
mapping = {
|
|
117
|
+
"none": "none",
|
|
118
|
+
"iid": "iid",
|
|
119
|
+
"spatial": "spatial",
|
|
120
|
+
"temporal": "temporal",
|
|
121
|
+
"st": "st",
|
|
122
|
+
"Type I": "iid",
|
|
123
|
+
"Type II": "spatial",
|
|
124
|
+
"Type III": "temporal",
|
|
125
|
+
"Type IV": "st",
|
|
126
|
+
}
|
|
127
|
+
return mapping.get(str(interaction).strip().lower(), "none")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def interaction_active(interaction: str) -> bool:
|
|
131
|
+
"""True if interaction is not 'none'."""
|
|
132
|
+
return interaction_parse(interaction) != "none"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def row_standardize(W, safe: bool = True):
|
|
136
|
+
"""Row-standardize a spatial weight matrix, island-safe.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
W: (m, m) array-like weight matrix (numpy or scipy sparse).
|
|
140
|
+
safe: True (default) -> rows with zero sum (islands, no neighbors)
|
|
141
|
+
are LEFT AS ZERO (no NaN); False -> plain W/row_sum (0/0 -> NaN,
|
|
142
|
+
the classic island trap).
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
numpy (m, m) row-standardized matrix. Island rows stay zero so that
|
|
146
|
+
A = I - rho W keeps them iid under the SAR prior (valid: no
|
|
147
|
+
neighbors -> no spatial shrinkage for that area).
|
|
148
|
+
"""
|
|
149
|
+
import numpy as np
|
|
150
|
+
from scipy import sparse as _sp
|
|
151
|
+
if _sp.issparse(W):
|
|
152
|
+
W = W.toarray()
|
|
153
|
+
W = np.asarray(W, dtype=np.float64)
|
|
154
|
+
rs = W.sum(axis=1, keepdims=True)
|
|
155
|
+
if safe:
|
|
156
|
+
return W / np.maximum(rs, 1.0)
|
|
157
|
+
return W / rs
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def interaction_param_names(interaction: str) -> list[str]:
|
|
161
|
+
"""Extra variance parameter names for interaction type."""
|
|
162
|
+
itype = interaction_parse(interaction)
|
|
163
|
+
if itype == "none":
|
|
164
|
+
return []
|
|
165
|
+
if itype == "iid":
|
|
166
|
+
return ["sigma2_gamma"]
|
|
167
|
+
if itype == "spatial":
|
|
168
|
+
return ["sigma2_gamma", "rho_gamma"]
|
|
169
|
+
if itype == "temporal":
|
|
170
|
+
return ["sigma2_gamma", "phi_gamma"]
|
|
171
|
+
if itype == "st":
|
|
172
|
+
return ["sigma2_gamma", "rho_gamma", "phi_gamma"]
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
|
stmmlib/beta/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Beta family backend for spatio-temporal mixed models.
|
|
2
|
+
|
|
3
|
+
Modules:
|
|
4
|
+
structure: M1-M14 model registry
|
|
5
|
+
precision: AR(1) precision, rho/phi bounds, effective phi
|
|
6
|
+
design: Z matrices, contrast matrices, W_gamma
|
|
7
|
+
solvers: Newton/BFGS mode solver, Laplace marginal likelihood engine
|
|
8
|
+
inference: Hessian-based SEs, Wald tests, confidence intervals
|
|
9
|
+
result: BetastmmResult container
|
|
10
|
+
fit: stmm_beta(), betaml() top-level fitting
|
|
11
|
+
diagnostics: Residuals, ACF, Dunn-Smyth, Moran panel, Ljung-Box
|
|
12
|
+
criteria: conditional AIC / conditional R-squared for flat models
|
|
13
|
+
report: diagnostics report and convergence certificate
|
|
14
|
+
lrt_fixed: restricted-refit likelihood ratio test for fixed effects
|
|
15
|
+
config: numerical bounds and tolerances (single source of truth)
|
|
16
|
+
moran_panel, plot_data, gpu_solvers, simulation, diagnose: helpers
|
|
17
|
+
"""
|
stmmlib/beta/config.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Central numeric constants for Beta STMM fitting.
|
|
2
|
+
|
|
3
|
+
Single source of truth for bounds, tolerances, and accuracy presets
|
|
4
|
+
previously scattered as literals across solvers/precision/design/criteria.
|
|
5
|
+
All defaults reproduce the historical behavior exactly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
# Auto weak-prior constants (blme-style, Chung et al. 2013): center 6.9 ~
|
|
13
|
+
# phi 1000. These do NOT move the box bounds, only the prior center/spread.
|
|
14
|
+
# With two coupled directions (phi + dep) the weak-prior path can collapse
|
|
15
|
+
# into a degenerate well, so the dep part of the auto prior is off by default.
|
|
16
|
+
PHI_PRIOR_AUTO_MEAN = 6.9
|
|
17
|
+
PHI_PRIOR_AUTO_SD = 2.0
|
|
18
|
+
DEP_PRIOR_AUTO_SD = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class BetaFitConfig:
|
|
23
|
+
"""Numeric config for Beta STMM fitting (all defaults = legacy behavior).
|
|
24
|
+
|
|
25
|
+
Weak-prior penalty (opt-in MAP, default OFF):
|
|
26
|
+
phi_prior_mean / phi_prior_sd: Gaussian prior on logphi, penalty
|
|
27
|
+
0.5*((logphi-mu)/sd)^2. dep_prior_sd: Gaussian prior N(0, sd^2)
|
|
28
|
+
on raw_dep, penalty 0.5*sum(raw_dep^2)/sd^2. All default None
|
|
29
|
+
(= unpenalized ML; behavior identical to before).
|
|
30
|
+
MAP with weak Gaussian prior; precedent Chung et al. 2013 blme
|
|
31
|
+
(Psychometrika) and brglm2 MPL Jeffreys. Always report penalized
|
|
32
|
+
alongside the unpenalized solution.
|
|
33
|
+
Note: passing an explicit config (even a default BetaFitConfig())
|
|
34
|
+
disables the automatic boundary refit in stmm_beta(); leaving
|
|
35
|
+
config=None lets stmm_beta auto-refit with a weak prior
|
|
36
|
+
(PHI_PRIOR_AUTO_MEAN/SD, DEP_PRIOR_AUTO_SD) when the unpenalized
|
|
37
|
+
fit hits the log_phi/raw_dep boundary.
|
|
38
|
+
hessian_time_budget 3600.0 = cap 1 jam untuk Hessian (default; cegah
|
|
39
|
+
hang). None = tanpa batas waktu. Set angka detik lain untuk mengubah cap.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
phi_bound: float = 0.95
|
|
43
|
+
# 0.95 is a conventional stationarity margin for the dependence
|
|
44
|
+
# coefficients: rho = 0.95 corresponds to a half-life of ~13.5 periods.
|
|
45
|
+
# Do not raise the bound just to escape the boundary.
|
|
46
|
+
#
|
|
47
|
+
# Two correlation bounds, not one:
|
|
48
|
+
# phi_bound: the AR(1) coefficient bound (phi_T, phi_gamma).
|
|
49
|
+
# rho_bound: the spatial correlation bound (SAR rho, gamma rho).
|
|
50
|
+
# Neither is the Beta precision: that one is logphi_bounds.
|
|
51
|
+
rho_bound: float = 0.95
|
|
52
|
+
beta_bounds: tuple = (-20.0, 20.0)
|
|
53
|
+
logphi_bounds: tuple = (-10.0, 10.0)
|
|
54
|
+
logs_bounds: tuple = (-10.0, 3.0)
|
|
55
|
+
dep_margin: float = 0.95
|
|
56
|
+
grad_tol: float = 1.0
|
|
57
|
+
grad_slack: float = 100.0
|
|
58
|
+
# Inner-solve budget for the analytic-gradient path on large panels.
|
|
59
|
+
# Set from one large panel (the fit jumps inner modes and returns a
|
|
60
|
+
# garbage gradient with a full budget); it has not been swept across
|
|
61
|
+
# panel sizes. A smaller cap trades accuracy for stability; the deep
|
|
62
|
+
# gradient solve below is unaffected. Re-tune before relying on it for
|
|
63
|
+
# a new model family or a much larger panel.
|
|
64
|
+
inner_probe_cap: int = 15
|
|
65
|
+
# Auto weak-prior refit. None keeps the historical rule (the refit runs
|
|
66
|
+
# only when no explicit config was passed); True always runs it, False
|
|
67
|
+
# never runs it.
|
|
68
|
+
auto_prior: bool | None = None
|
|
69
|
+
# Interim dataset-calibrated prior; always report alongside unpenalized ML (Chung et al. 2013).
|
|
70
|
+
phi_prior_mean: float | None = None
|
|
71
|
+
phi_prior_sd: float | None = None
|
|
72
|
+
dep_prior_sd: float | None = None
|
|
73
|
+
hessian_time_budget: float | None = 3600.0
|
|
74
|
+
# Laplace posterior curvature of the random effects at the final mode
|
|
75
|
+
# (H_b = Z'diag(w)Z + Q), stored as its Cholesky factor for EBP draws.
|
|
76
|
+
# Opt-in: dense q x q precision Cholesky, one per fit; large for big
|
|
77
|
+
# random-effect designs. Default off.
|
|
78
|
+
compute_posterior: bool = False
|
|
79
|
+
|
|
80
|
+
def __post_init__(self):
|
|
81
|
+
if (self.phi_prior_mean is None) != (self.phi_prior_sd is None):
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"phi_prior_mean and phi_prior_sd must both be set or both None.")
|
|
84
|
+
if self.phi_prior_sd is not None and self.phi_prior_sd <= 0:
|
|
85
|
+
raise ValueError("phi_prior_sd must be > 0.")
|
|
86
|
+
if self.dep_prior_sd is not None and self.dep_prior_sd <= 0:
|
|
87
|
+
raise ValueError("dep_prior_sd must be > 0.")
|
|
88
|
+
if self.hessian_time_budget is not None and self.hessian_time_budget <= 0:
|
|
89
|
+
raise ValueError("hessian_time_budget must be > 0 or None.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
PRESET_ACCURACY: dict = {
|
|
93
|
+
"fast": (1e-5, 1e-5, False, 500),
|
|
94
|
+
"balanced": (1e-8, 1e-6, True, 500),
|
|
95
|
+
"medium": (1e-8, 1e-6, False, None),
|
|
96
|
+
"full": (1e-12, 1e-6, True, None),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def resolve_preset(preset: str | None) -> tuple:
|
|
101
|
+
"""Resolve accuracy preset name to (ftol, gtol, hess, cap)."""
|
|
102
|
+
if preset is None:
|
|
103
|
+
preset = "full"
|
|
104
|
+
try:
|
|
105
|
+
return PRESET_ACCURACY[preset]
|
|
106
|
+
except KeyError:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"Unknown preset: {preset!r}. Use 'fast', 'balanced', 'medium' or 'full'.")
|