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.
Files changed (47) hide show
  1. stmmlib/__init__.py +30 -0
  2. stmmlib/_backend.py +153 -0
  3. stmmlib/_utils.py +175 -0
  4. stmmlib/beta/__init__.py +17 -0
  5. stmmlib/beta/config.py +108 -0
  6. stmmlib/beta/criteria.py +247 -0
  7. stmmlib/beta/design.py +222 -0
  8. stmmlib/beta/diagnose.py +168 -0
  9. stmmlib/beta/diagnostics.py +94 -0
  10. stmmlib/beta/fit.py +598 -0
  11. stmmlib/beta/gpu_solvers.py +169 -0
  12. stmmlib/beta/inference.py +403 -0
  13. stmmlib/beta/lrt_fixed.py +551 -0
  14. stmmlib/beta/moran_panel.py +733 -0
  15. stmmlib/beta/plot_data.py +252 -0
  16. stmmlib/beta/precision.py +98 -0
  17. stmmlib/beta/report.py +373 -0
  18. stmmlib/beta/result.py +696 -0
  19. stmmlib/beta/simulation.py +343 -0
  20. stmmlib/beta/solvers.py +2021 -0
  21. stmmlib/beta/structure.py +56 -0
  22. stmmlib/diagnostics_suite.py +663 -0
  23. stmmlib/gaussian/__init__.py +1 -0
  24. stmmlib/gaussian/blup.py +252 -0
  25. stmmlib/gaussian/covariance.py +599 -0
  26. stmmlib/gaussian/design.py +312 -0
  27. stmmlib/gaussian/diagnostics.py +308 -0
  28. stmmlib/gaussian/gls.py +285 -0
  29. stmmlib/gaussian/hessian.py +197 -0
  30. stmmlib/gaussian/lrt_fixed.py +289 -0
  31. stmmlib/gaussian/models.py +528 -0
  32. stmmlib/gaussian/optimization.py +361 -0
  33. stmmlib/gaussian/simulation.py +186 -0
  34. stmmlib/gaussian/summary.py +277 -0
  35. stmmlib/gaussian/transforms.py +419 -0
  36. stmmlib/gaussian/variance_decomp.py +191 -0
  37. stmmlib/lrt.py +74 -0
  38. stmmlib/output_format.py +148 -0
  39. stmmlib/output_format_beta.py +299 -0
  40. stmmlib/output_format_normal.py +118 -0
  41. stmmlib/registry.py +326 -0
  42. stmmlib/stmm.py +265 -0
  43. stmmlib-0.2.0.dist-info/METADATA +215 -0
  44. stmmlib-0.2.0.dist-info/RECORD +47 -0
  45. stmmlib-0.2.0.dist-info/WHEEL +5 -0
  46. stmmlib-0.2.0.dist-info/licenses/LICENSE +21 -0
  47. 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
+
@@ -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'.")