alsgls 0.1.0__tar.gz

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.
alsgls-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: alsgls
3
+ Version: 0.1.0
4
+ Summary: Lightweight low-rank+diag GLS/SUR via ALS with EM baseline
5
+ Author-email: Gaurav Sood <contact@gsood.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/finite-sample/alsgls
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: numpy>=1.21
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.0; extra == "dev"
16
+
17
+ ## A Lightweight ALS Solver for Iterative GLS
18
+
19
+ When a GLS problem involves hundreds of equations, the $K × K$ covariance matrix becomes the computational bottleneck. A simple statistical remedy is to assume that most of the cross‑equation dependence can be captured by a *handful of latent factors* plus equation‑specific noise. This “low‑rank + diagonal” assumption slashes the number of unknowns from roughly $K^²$ to about $K×k$ parameters, where **k** (the latent factor rank) is much smaller than $K$. The model alone, however, does **not** guarantee speed: we still have to fit the parameters.
20
+
21
+ ### Installation
22
+
23
+ Install the library from PyPI:
24
+
25
+ ```bash
26
+ pip install alsgls
27
+ ```
28
+
29
+ For local development, clone the repo and use an editable install:
30
+
31
+ ```bash
32
+ pip install -e .
33
+ ```
34
+
35
+ ### Usage
36
+
37
+ ```python
38
+ from alsgls import als_gls, simulate_sur, nll_per_row, XB_from_Blist
39
+
40
+ Xs_tr, Y_tr, Xs_te, Y_te = simulate_sur(N_tr=240, N_te=120, K=60, p=3, k=4)
41
+ B, F, D, mem, _ = als_gls(Xs_tr, Y_tr, k=4)
42
+ Yhat_te = XB_from_Blist(Xs_te, B)
43
+ nll = nll_per_row(Y_te - Yhat_te, F, D)
44
+ ```
45
+
46
+ See `examples/compare_als_vs_em.py` for a complete ALS versus EM comparison.
47
+
48
+ ### Documentation and notebooks
49
+
50
+ Background material and reproducible experiments are available in the notebooks under [`als_sim/`](als_sim/), such as [`als_sim/als_comparison.ipynb`](als_sim/als_comparison.ipynb) and [`als_sim/als_sur.ipynb`](als_sim/als_sur.ipynb).
51
+
52
+ ### Solving low‑rank GLS: EM versus ALS
53
+
54
+ The classic EM algorithm alternates between updating the regression coefficients $\beta$ and updating the factor loadings $F$ and the diagonal noise $D$. Even though $\hat{\Sigma}$ is low‑rank, EM’s M‑step recreates the **full** $K × K$ inverse, wiping out the memory win.
55
+
56
+ An alternative is **Alternating‑Least‑Squares (ALS)**. The Woodbury identity reduces the expensive inverse to a tiny k × k system, and the β‑update can be written without explicitly forming the dense matrix at all. In practice, ALS converges in 5–6 sweeps and never allocates more than $O(K k)$ memory, while EM allocates $O(K^²)$.
57
+
58
+ **Rule of thumb:** if your GLS routine keeps looping between $\beta$ and a fresh $\hat{\Sigma}$, replacing the $\hat{\Sigma}$‑update by a factor‑ALS step yields the same statistical fit with an order‑of‑magnitude smaller memory footprint.
59
+
60
+ ### Beyond SUR: where the idea travels
61
+
62
+ Random‑effects models, feasible GLS with estimated heteroskedastic weights, optimal‑weight GMM, and spatial autoregressive GLS all iterate β ↔ Σ̂. Each can adopt the same ALS trick: treat the weight matrix as low‑rank + diagonal, invert only the k × k core, and avoid the dense K × K algebra. Memory savings in published examples range from 5× to 20×, depending on k.
63
+
64
+ ### A concrete case‑study: Seemingly‑Unrelated Regressions
65
+
66
+ To show the magnitude, we ran a Monte‑Carlo experiment with N = 300 observations, three regressors, rank‑3 factors, and K set to 50, 80, 120. EM was given 45 iterations; ALS, six sweeps. The largest array EM holds is the dense Σ⁻¹, whereas ALS’s largest is the skinny factor matrix F. The table summarises six replications:
67
+
68
+ | K | β‑RMSE EM | β‑RMSE ALS | Peak MB EM | Peak MB ALS | Memory ratio |
69
+ | --: | :-------: | :--------: | ---------: | ----------: | -----------: |
70
+ | 50 |  0.021  |  0.021  |  0.020  |  0.002  |  10×  |
71
+ | 80 |  0.020  |  0.020  |  0.051  |  0.003  |  17×  |
72
+ | 120 |  0.020  |  0.020  |  0.115  |  0.004  |  29×  |
73
+
74
+ Statistically, the two estimators are indistinguishable (paired‑test p ≥ 0.14). Computationally, ALS needs only a few megabytes whereas EM needs tens to hundreds.
75
+
76
+ ### 5  Choosing a solver in practice
77
+
78
+ For small systems ($K < 50$), dense GLS or even separate OLS is fine. Between 50 and 300 equations, a low‑rank **factor‑ALS** solver gives the same estimates at roughly one‑tenth the memory and runs happily on a GPU. Once K enters the hundreds, any dense inverse becomes prohibitive; structured approaches such as factor‑ALS or sparse/banded $\hat{\Sigma}$ are mandatory.
alsgls-0.1.0/README.md ADDED
@@ -0,0 +1,62 @@
1
+ ## A Lightweight ALS Solver for Iterative GLS
2
+
3
+ When a GLS problem involves hundreds of equations, the $K × K$ covariance matrix becomes the computational bottleneck. A simple statistical remedy is to assume that most of the cross‑equation dependence can be captured by a *handful of latent factors* plus equation‑specific noise. This “low‑rank + diagonal” assumption slashes the number of unknowns from roughly $K^²$ to about $K×k$ parameters, where **k** (the latent factor rank) is much smaller than $K$. The model alone, however, does **not** guarantee speed: we still have to fit the parameters.
4
+
5
+ ### Installation
6
+
7
+ Install the library from PyPI:
8
+
9
+ ```bash
10
+ pip install alsgls
11
+ ```
12
+
13
+ For local development, clone the repo and use an editable install:
14
+
15
+ ```bash
16
+ pip install -e .
17
+ ```
18
+
19
+ ### Usage
20
+
21
+ ```python
22
+ from alsgls import als_gls, simulate_sur, nll_per_row, XB_from_Blist
23
+
24
+ Xs_tr, Y_tr, Xs_te, Y_te = simulate_sur(N_tr=240, N_te=120, K=60, p=3, k=4)
25
+ B, F, D, mem, _ = als_gls(Xs_tr, Y_tr, k=4)
26
+ Yhat_te = XB_from_Blist(Xs_te, B)
27
+ nll = nll_per_row(Y_te - Yhat_te, F, D)
28
+ ```
29
+
30
+ See `examples/compare_als_vs_em.py` for a complete ALS versus EM comparison.
31
+
32
+ ### Documentation and notebooks
33
+
34
+ Background material and reproducible experiments are available in the notebooks under [`als_sim/`](als_sim/), such as [`als_sim/als_comparison.ipynb`](als_sim/als_comparison.ipynb) and [`als_sim/als_sur.ipynb`](als_sim/als_sur.ipynb).
35
+
36
+ ### Solving low‑rank GLS: EM versus ALS
37
+
38
+ The classic EM algorithm alternates between updating the regression coefficients $\beta$ and updating the factor loadings $F$ and the diagonal noise $D$. Even though $\hat{\Sigma}$ is low‑rank, EM’s M‑step recreates the **full** $K × K$ inverse, wiping out the memory win.
39
+
40
+ An alternative is **Alternating‑Least‑Squares (ALS)**. The Woodbury identity reduces the expensive inverse to a tiny k × k system, and the β‑update can be written without explicitly forming the dense matrix at all. In practice, ALS converges in 5–6 sweeps and never allocates more than $O(K k)$ memory, while EM allocates $O(K^²)$.
41
+
42
+ **Rule of thumb:** if your GLS routine keeps looping between $\beta$ and a fresh $\hat{\Sigma}$, replacing the $\hat{\Sigma}$‑update by a factor‑ALS step yields the same statistical fit with an order‑of‑magnitude smaller memory footprint.
43
+
44
+ ### Beyond SUR: where the idea travels
45
+
46
+ Random‑effects models, feasible GLS with estimated heteroskedastic weights, optimal‑weight GMM, and spatial autoregressive GLS all iterate β ↔ Σ̂. Each can adopt the same ALS trick: treat the weight matrix as low‑rank + diagonal, invert only the k × k core, and avoid the dense K × K algebra. Memory savings in published examples range from 5× to 20×, depending on k.
47
+
48
+ ### A concrete case‑study: Seemingly‑Unrelated Regressions
49
+
50
+ To show the magnitude, we ran a Monte‑Carlo experiment with N = 300 observations, three regressors, rank‑3 factors, and K set to 50, 80, 120. EM was given 45 iterations; ALS, six sweeps. The largest array EM holds is the dense Σ⁻¹, whereas ALS’s largest is the skinny factor matrix F. The table summarises six replications:
51
+
52
+ | K | β‑RMSE EM | β‑RMSE ALS | Peak MB EM | Peak MB ALS | Memory ratio |
53
+ | --: | :-------: | :--------: | ---------: | ----------: | -----------: |
54
+ | 50 |  0.021  |  0.021  |  0.020  |  0.002  |  10×  |
55
+ | 80 |  0.020  |  0.020  |  0.051  |  0.003  |  17×  |
56
+ | 120 |  0.020  |  0.020  |  0.115  |  0.004  |  29×  |
57
+
58
+ Statistically, the two estimators are indistinguishable (paired‑test p ≥ 0.14). Computationally, ALS needs only a few megabytes whereas EM needs tens to hundreds.
59
+
60
+ ### 5  Choosing a solver in practice
61
+
62
+ For small systems ($K < 50$), dense GLS or even separate OLS is fine. Between 50 and 300 equations, a low‑rank **factor‑ALS** solver gives the same estimates at roughly one‑tenth the memory and runs happily on a GPU. Once K enters the hundreds, any dense inverse becomes prohibitive; structured approaches such as factor‑ALS or sparse/banded $\hat{\Sigma}$ are mandatory.
@@ -0,0 +1,7 @@
1
+ from .als import als_gls
2
+ from .em import em_gls
3
+ from .metrics import mse, nll_per_row
4
+ from .sim import simulate_sur, simulate_gls
5
+ from .ops import XB_from_Blist
6
+
7
+ __all__ = ["als_gls", "em_gls", "mse", "nll_per_row", "simulate_sur", "simulate_gls", "XB_from_Blist"]
@@ -0,0 +1,112 @@
1
+ import numpy as np
2
+ from .ops import (
3
+ apply_siginv_to_matrix, woodbury_pieces,
4
+ stack_B_list, unstack_B_vec, XB_from_Blist, cg_solve
5
+ )
6
+
7
+ def als_gls(
8
+ Xs, Y, k,
9
+ lam_F=1e-3, lam_B=1e-3, sweeps=8, d_floor=1e-8,
10
+ cg_maxit=800, cg_tol=3e-7
11
+ ):
12
+ """
13
+ ALS for low-rank+diag GLS.
14
+ Returns (B_list, F, D, mem_MB_est, info)
15
+ """
16
+ # Input validation
17
+ if not isinstance(Xs, list) or len(Xs) == 0:
18
+ raise ValueError("Xs must be a non-empty list of arrays")
19
+ if Y.ndim != 2:
20
+ raise ValueError("Y must be a 2D array")
21
+ N, K = Y.shape
22
+ if len(Xs) != K:
23
+ raise ValueError(f"Number of X matrices ({len(Xs)}) must match Y columns ({K})")
24
+ for j, X in enumerate(Xs):
25
+ if X.ndim != 2 or X.shape[0] != N:
26
+ raise ValueError(f"X[{j}] must be 2D with {N} rows")
27
+ if not (1 <= k <= min(K, N)):
28
+ raise ValueError(f"k must be between 1 and min(K={K}, N={N})")
29
+ if lam_F < 0 or lam_B < 0:
30
+ raise ValueError("Regularization parameters must be non-negative")
31
+
32
+ p_list = [X.shape[1] for X in Xs]
33
+
34
+ # init B (OLS per equation)
35
+ B = []
36
+ for j, X in enumerate(Xs):
37
+ XtX = X.T @ X + lam_B * np.eye(X.shape[1])
38
+ Xty = X.T @ Y[:, [j]]
39
+ B.append(np.linalg.solve(XtX, Xty))
40
+
41
+ R = Y - XB_from_Blist(Xs, B)
42
+ # PCA-like init for F with relative threshold
43
+ U, s, Vt = np.linalg.svd(R, full_matrices=False)
44
+ s_thresh = max(s[0] * 1e-10, 1e-8) if len(s) > 0 else 1e-8
45
+ r = min(k, (s > s_thresh).sum() or 1)
46
+ F = Vt.T[:, :r] * np.sqrt(np.maximum(s[:r], 1e-12))
47
+ if r < k:
48
+ F = np.pad(F, ((0, 0), (0, k - r)))
49
+ D = np.maximum(np.var(R, axis=0), d_floor)
50
+
51
+ # main ALS loop
52
+ prev = None
53
+ cg_info = None
54
+ for _ in range(sweeps):
55
+ # Precompute Woodbury pieces once per sweep
56
+ Dinv, Cf = woodbury_pieces(F, D)
57
+
58
+ def A_mv(bvec):
59
+ """Matrix-free normal operator H(B) = X^T Σ^{-1} X · b + lam_B b"""
60
+ B_dir = unstack_B_vec(bvec, p_list)
61
+ M = XB_from_Blist(Xs, B_dir) # N x K
62
+ S = apply_siginv_to_matrix(M, F, D, Dinv=Dinv, Cf=Cf) # N x K
63
+ out_blocks = []
64
+ for j, X in enumerate(Xs):
65
+ out_blocks.append(X.T @ S[:, [j]])
66
+ out = np.concatenate(out_blocks, axis=0).ravel()
67
+ return out + lam_B * bvec
68
+
69
+ # simple diagonal preconditioner: approx diag of H
70
+ def M_pre(v):
71
+ diag_entries = []
72
+ # Rough diag: X_j^T (Σ^{-1} e_j e_j^T) X_j ≈ X_j^T (Dinv_j) X_j
73
+ for j, X in enumerate(Xs):
74
+ w = float(Dinv[j])
75
+ diag_entries.extend([w] * X.shape[1])
76
+ d = np.array(diag_entries) + lam_B
77
+ return v / np.maximum(d, 1e-8)
78
+
79
+ # β-step via CG
80
+ rhs_blocks = []
81
+ S_y = apply_siginv_to_matrix(Y, F, D, Dinv=Dinv, Cf=Cf)
82
+ for j, X in enumerate(Xs):
83
+ rhs_blocks.append(X.T @ S_y[:, [j]])
84
+ b = np.concatenate(rhs_blocks, axis=0).ravel()
85
+ bvec0 = stack_B_list(B)
86
+ bvec, cg_info = cg_solve(A_mv, b, x0=bvec0, maxit=cg_maxit, tol=cg_tol, M_pre=M_pre)
87
+ B = unstack_B_vec(bvec, p_list)
88
+
89
+ # factor step
90
+ R = Y - XB_from_Blist(Xs, B)
91
+ # update U (scores) and F (loadings) by two ridge solves
92
+ FtF = F.T @ F + lam_F * np.eye(F.shape[1])
93
+ U = R @ F @ np.linalg.inv(FtF)
94
+ UtU = U.T @ U + lam_F * np.eye(F.shape[1])
95
+ F = R.T @ U @ np.linalg.inv(UtU)
96
+
97
+ # diagonal noise
98
+ D = np.maximum(np.mean((R - U @ F.T) ** 2, axis=0), d_floor)
99
+
100
+ # cheap objective proxy: per-row NLL (using current F,D and R)
101
+ obj = 0.5 * np.mean((R * (1.0 / np.sqrt(np.maximum(D, 1e-12)))) ** 2) # weighted residuals
102
+ if np.isfinite(obj):
103
+ if prev is not None:
104
+ rel = (prev - obj) / max(1.0, abs(prev))
105
+ if rel < 1e-6:
106
+ break
107
+ prev = obj
108
+
109
+ # Memory estimate: F (K×k) + D (K) + U (N×k) + intermediate matrices
110
+ mem_mb_est = (K * F.shape[1] + K + N * F.shape[1] + K * F.shape[1]) * 8 / 1e6
111
+ info = {"p_list": p_list, "cg": cg_info}
112
+ return B, F, D, mem_mb_est, info
@@ -0,0 +1,98 @@
1
+ import numpy as np
2
+ from .ops import XB_from_Blist
3
+
4
+ def em_gls(Xs, Y, k, lam_F=1e-3, lam_B=1e-3, iters=30, d_floor=1e-8):
5
+ """
6
+ Dense-ish EM baseline for low-rank+diag GLS.
7
+ Builds Σ^{-1} explicitly (KxK) in the β-step to mimic O(K^2) memory.
8
+ Returns (B_list, F, D, mem_MB_est, info)
9
+ """
10
+ # Input validation
11
+ if not isinstance(Xs, list) or len(Xs) == 0:
12
+ raise ValueError("Xs must be a non-empty list of arrays")
13
+ if Y.ndim != 2:
14
+ raise ValueError("Y must be a 2D array")
15
+ N, K = Y.shape
16
+ if len(Xs) != K:
17
+ raise ValueError(f"Number of X matrices ({len(Xs)}) must match Y columns ({K})")
18
+ for j, X in enumerate(Xs):
19
+ if X.ndim != 2 or X.shape[0] != N:
20
+ raise ValueError(f"X[{j}] must be 2D with {N} rows")
21
+ if not (1 <= k <= min(K, N)):
22
+ raise ValueError(f"k must be between 1 and min(K={K}, N={N})")
23
+ if lam_F < 0 or lam_B < 0:
24
+ raise ValueError("Regularization parameters must be non-negative")
25
+
26
+ p_list = [X.shape[1] for X in Xs]
27
+
28
+ # init B (OLS per equation)
29
+ B = []
30
+ for j, X in enumerate(Xs):
31
+ XtX = X.T @ X + lam_B * np.eye(X.shape[1])
32
+ Xty = X.T @ Y[:, [j]]
33
+ B.append(np.linalg.solve(XtX, Xty))
34
+
35
+ R = Y - XB_from_Blist(Xs, B)
36
+ U, s, Vt = np.linalg.svd(R, full_matrices=False)
37
+ s_thresh = max(s[0] * 1e-10, 1e-8) if len(s) > 0 else 1e-8
38
+ r = min(k, (s > s_thresh).sum() or 1)
39
+ F = Vt.T[:, :r] * np.sqrt(np.maximum(s[:r], 1e-12))
40
+ if r < k:
41
+ F = np.pad(F, ((0, 0), (0, k - r)))
42
+ D = np.maximum(np.var(R, axis=0), d_floor)
43
+
44
+ # Precompute Gram blocks X_j^T X_l.
45
+ # Only upper-triangular blocks are formed and the lower triangle is
46
+ # recovered via symmetry when assembling the normal-equation matrix.
47
+ G = [[None] * K for _ in range(K)]
48
+ for j in range(K):
49
+ for l in range(j, K):
50
+ G[j][l] = Xs[j].T @ Xs[l]
51
+
52
+ for _ in range(iters):
53
+ # E-step-like: nothing explicit (we directly update F,D after β)
54
+
55
+ # β-step (dense normal equations using Σ^{-1})
56
+ Dinv = 1.0 / np.clip(D, 1e-12, None)
57
+ M = F.T @ (F * Dinv[:, None]) # k x k
58
+ Cf = np.linalg.inv(np.eye(k) + M)
59
+ # Build Σ^{-1} explicitly (KxK)
60
+ Sigma_inv = np.diag(Dinv) - (F * Dinv[:, None]) @ Cf @ (F.T * Dinv[None, :])
61
+
62
+ A = np.zeros((sum(p_list), sum(p_list)))
63
+ rhs = np.zeros((sum(p_list), 1))
64
+ p_offsets = np.cumsum([0] + p_list)
65
+ # Blocks A_{j,l} = Σ^{-1}_{l,j} * X_j^T X_l (symmetric in j,l)
66
+ for j in range(K):
67
+ Sj = Sigma_inv[:, j]
68
+ r0, r1 = p_offsets[j], p_offsets[j + 1]
69
+ rhs[r0:r1, :] = Xs[j].T @ (Y @ Sj.reshape(-1, 1))
70
+ for l in range(j, K):
71
+ c0, c1 = p_offsets[l], p_offsets[l + 1]
72
+ block = G[j][l]
73
+ scalar = Sj[l]
74
+ A[r0:r1, c0:c1] = scalar * block
75
+ if l != j:
76
+ # Mirror to the symmetric block to maintain A symmetric
77
+ A[c0:c1, r0:r1] = scalar * block.T
78
+ A += lam_B * np.eye(A.shape[0])
79
+ A = (A + A.T) * 0.5 # enforce symmetry
80
+ bvec = np.linalg.solve(A, rhs).ravel()
81
+ B = []
82
+ i = 0
83
+ for p in p_list:
84
+ B.append(bvec[i:i+p].reshape(p, 1))
85
+ i += p
86
+
87
+ # Update residuals and then F, D
88
+ R = Y - XB_from_Blist(Xs, B)
89
+ # Update scores/loadings by two ridge solves
90
+ FtF = F.T @ F + lam_F * np.eye(F.shape[1])
91
+ Uhat = R @ F @ np.linalg.inv(FtF)
92
+ UtU = Uhat.T @ Uhat + lam_F * np.eye(F.shape[1])
93
+ F = R.T @ Uhat @ np.linalg.inv(UtU)
94
+ D = np.maximum(np.mean((R - Uhat @ F.T) ** 2, axis=0), d_floor)
95
+
96
+ mem_mb_est = (K * K) * 8 / 1e6 # explicit Σ^{-1}
97
+ info = {"p_list": p_list}
98
+ return B, F, D, mem_mb_est, info
@@ -0,0 +1,39 @@
1
+ import numpy as np
2
+ from .ops import woodbury_pieces
3
+
4
+ def mse(Y, Yhat):
5
+ return float(np.mean((Y - Yhat) ** 2))
6
+
7
+ def nll_per_row(R, F, D):
8
+ """Negative log-likelihood per row for residual matrix ``R``.
9
+
10
+ Parameters
11
+ ----------
12
+ R : ndarray (N × K)
13
+ Residual matrix.
14
+ F : ndarray (K × k)
15
+ Factor loadings.
16
+ D : ndarray (K,)
17
+ Diagonal noise variances.
18
+
19
+ Returns
20
+ -------
21
+ float
22
+ ``0.5 * [tr(R Σ^{-1} R^T) + logdet(Σ) + K log(2π)]``
23
+ averaged over rows.
24
+ """
25
+ K = R.shape[1]
26
+ Dinv, Cf = woodbury_pieces(F, D)
27
+ # tr(R Σ^{-1} R^T) = sum over rows of r Σ^{-1} r^T
28
+ # Efficiently: R Σ^{-1} = apply_siginv_to_matrix(R, F, D), but avoid circular import.
29
+ # Inline Woodbury:
30
+ RDinv = R * Dinv[None, :]
31
+ T1 = RDinv @ F # N x k
32
+ T2 = T1 @ Cf # N x k
33
+ RSinv = RDinv - T2 @ (F.T * Dinv) # N x K
34
+ quad = float(np.sum(RSinv * R))
35
+ # logdet via matrix determinant lemma:
36
+ # det(FF^T + D) = det(D) det(I + F^T D^{-1} F)
37
+ logdet = float(np.sum(np.log(np.clip(D, 1e-12, None)))) \
38
+ + float(np.linalg.slogdet(np.eye(F.shape[1]) + F.T @ (F * Dinv[:, None]))[1])
39
+ return 0.5 * (quad / R.shape[0] + logdet + K * np.log(2 * np.pi))
@@ -0,0 +1,87 @@
1
+ import numpy as np
2
+
3
+ def woodbury_pieces(F: np.ndarray, D: np.ndarray):
4
+ """
5
+ Return Dinv, Cf used in Woodbury:
6
+ Σ = F F^T + diag(D)
7
+ Σ^{-1} = D^{-1} - D^{-1} F (I + F^T D^{-1} F)^{-1} F^T D^{-1}
8
+ """
9
+ D = np.asarray(D)
10
+ Dinv = 1.0 / np.clip(D, 1e-12, None)
11
+ FtDinv = (F.T * Dinv) # k x K (row-scale F.T by Dinv)
12
+ M = FtDinv @ F # k x k == F^T D^{-1} F (reuse FtDinv to avoid re-scaling F)
13
+ # solve small kxk: (I + M)^{-1}
14
+ Cf = np.linalg.inv(np.eye(F.shape[1]) + M)
15
+ return Dinv, Cf
16
+
17
+ def apply_siginv_to_matrix(M: np.ndarray, F: np.ndarray, D: np.ndarray, *, Dinv=None, Cf=None):
18
+ """Right-multiply an N×K matrix ``M`` by ``Σ^{-1}`` using Woodbury.
19
+
20
+ Parameters
21
+ ----------
22
+ M : np.ndarray
23
+ Matrix to be multiplied on the right by ``Σ^{-1}``.
24
+ F : np.ndarray
25
+ Low-rank factor matrix.
26
+ D : np.ndarray
27
+ Diagonal entries of the noise covariance.
28
+ Dinv : np.ndarray, optional
29
+ Precomputed ``1/D`` vector. If ``None`` (default), it will be
30
+ computed internally via :func:`woodbury_pieces`.
31
+ Cf : np.ndarray, optional
32
+ Precomputed ``(I + F^T D^{-1} F)^{-1}``. If ``None`` (default), it
33
+ will be computed internally via :func:`woodbury_pieces`.
34
+ """
35
+ if Dinv is None or Cf is None:
36
+ Dinv, Cf = woodbury_pieces(F, D)
37
+ # M * Dinv - M*(Dinv F) Cf (F^T Dinv)
38
+ MDinv = M * Dinv[None, :]
39
+ T1 = MDinv @ F # N x k
40
+ T2 = T1 @ Cf # N x k
41
+ T3 = T2 @ (F.T * Dinv) # N x K
42
+ return MDinv - T3
43
+
44
+ def stack_B_list(B_list):
45
+ """Stack list of (p_j,1) into flat vector."""
46
+ return np.concatenate([b.ravel() for b in B_list], axis=0)
47
+
48
+ def unstack_B_vec(bvec, p_list):
49
+ """Inverse of stack: vector -> list of (p_j,1)."""
50
+ out, i = [], 0
51
+ for p in p_list:
52
+ out.append(bvec[i:i+p].reshape(p, 1))
53
+ i += p
54
+ return out
55
+
56
+ def XB_from_Blist(Xs, B_list):
57
+ """Return N x K matrix of predictions."""
58
+ return np.column_stack([Xs[j] @ B_list[j] for j in range(len(Xs))])
59
+
60
+ def cg_solve(operator_mv, b, x0=None, maxit=500, tol=1e-7, M_pre=None):
61
+ """
62
+ Conjugate gradient for SPD operator A (matrix-free).
63
+ operator_mv(x) -> A x
64
+ M_pre(x) -> apply preconditioner M^{-1} x (optional)
65
+ """
66
+ x = np.zeros_like(b) if x0 is None else x0.copy()
67
+ r = b - operator_mv(x)
68
+ z = M_pre(r) if M_pre is not None else r
69
+ p = z.copy()
70
+ rz_old = float(r @ z)
71
+ iterations = 0
72
+ for _ in range(maxit):
73
+ iterations += 1
74
+ Ap = operator_mv(p)
75
+ alpha = rz_old / max(1e-30, float(p @ Ap))
76
+ x += alpha * p
77
+ r -= alpha * Ap
78
+ res_norm = np.linalg.norm(r)
79
+ if res_norm <= tol * (np.linalg.norm(b) + 1e-30):
80
+ break
81
+ z = M_pre(r) if M_pre is not None else r
82
+ rz_new = float(r @ z)
83
+ beta = rz_new / max(1e-30, rz_old)
84
+ p = z + beta * p
85
+ rz_old = rz_new
86
+ info = {"iterations": iterations, "residual": float(np.linalg.norm(r))}
87
+ return x, info
@@ -0,0 +1,109 @@
1
+ import numpy as np
2
+ from .ops import XB_from_Blist
3
+
4
+
5
+ def simulate_sur(N_tr, N_te, K, p, k, seed=0):
6
+ """Simulate a Seemingly Unrelated Regression (SUR) dataset.
7
+
8
+ Parameters
9
+ ----------
10
+ N_tr : int
11
+ Number of training samples.
12
+ N_te : int
13
+ Number of test samples.
14
+ K : int
15
+ Number of response equations.
16
+ p : int
17
+ Number of features per equation.
18
+ k : int
19
+ Latent factor dimension controlling correlated noise.
20
+ seed : int, optional
21
+ Seed for the NumPy random number generator. Defaults to ``0``.
22
+
23
+ Returns
24
+ -------
25
+ X_tr : list of ndarray
26
+ Feature matrices for the training set. Each element has shape ``(N_tr, p)``.
27
+ Y_tr : ndarray
28
+ Training responses of shape ``(N_tr, K)``.
29
+ X_te : list of ndarray
30
+ Feature matrices for the test set. Each element has shape ``(N_te, p)``.
31
+ Y_te : ndarray
32
+ Test responses of shape ``(N_te, K)``.
33
+
34
+ Notes
35
+ -----
36
+ Randomness is controlled via ``numpy.random.default_rng(seed)``; pass a
37
+ different ``seed`` for different simulations.
38
+
39
+ Examples
40
+ --------
41
+ >>> Xtr, Ytr, Xte, Yte = simulate_sur(100, 20, K=3, p=5, k=2, seed=42)
42
+ """
43
+ rng = np.random.default_rng(seed)
44
+ N = N_tr + N_te
45
+ base = rng.standard_normal((N, p))
46
+ Xs = [base + 0.5 * rng.standard_normal((N, p)) for _ in range(K)]
47
+ B = [rng.standard_normal((p, 1)) for _ in range(K)]
48
+ F0 = 1.0 * rng.standard_normal((K, k))
49
+ D0 = 0.05 + 0.20 * rng.random(K)
50
+ U = rng.standard_normal((N, k))
51
+ Y = XB_from_Blist(Xs, B) + U @ F0.T + rng.standard_normal((N, K)) * np.sqrt(D0)[None, :]
52
+ return [X[:N_tr] for X in Xs], Y[:N_tr], [X[N_tr:] for X in Xs], Y[N_tr:]
53
+
54
+
55
+ def simulate_gls(N_tr, N_te, p_list, k, seed=0):
56
+ """Simulate a generalized least squares (GLS) dataset.
57
+
58
+ This variant allows each response equation to have its own number of
59
+ features as specified by ``p_list``.
60
+
61
+ Parameters
62
+ ----------
63
+ N_tr : int
64
+ Number of training samples.
65
+ N_te : int
66
+ Number of test samples.
67
+ p_list : sequence of int
68
+ Number of features for each equation.
69
+ k : int
70
+ Latent factor dimension controlling correlated noise.
71
+ seed : int, optional
72
+ Seed for the NumPy random number generator. Defaults to ``0``.
73
+
74
+ Returns
75
+ -------
76
+ X_tr : list of ndarray
77
+ Feature matrices for the training set. ``X_tr[j]`` has shape
78
+ ``(N_tr, p_list[j])``.
79
+ Y_tr : ndarray
80
+ Training responses of shape ``(N_tr, K)`` where ``K = len(p_list)``.
81
+ X_te : list of ndarray
82
+ Feature matrices for the test set. ``X_te[j]`` has shape
83
+ ``(N_te, p_list[j])``.
84
+ Y_te : ndarray
85
+ Test responses of shape ``(N_te, K)``.
86
+
87
+ Notes
88
+ -----
89
+ Randomness is controlled via ``numpy.random.default_rng(seed)``; pass a
90
+ different ``seed`` for different simulations.
91
+
92
+ Examples
93
+ --------
94
+ >>> p_list = [3, 5, 2]
95
+ >>> Xtr, Ytr, Xte, Yte = simulate_gls(100, 20, p_list, k=2, seed=0)
96
+ """
97
+ rng = np.random.default_rng(seed)
98
+ K = len(p_list)
99
+ N = N_tr + N_te
100
+ Xs = []
101
+ for p in p_list:
102
+ base = rng.standard_normal((N, p))
103
+ Xs.append(base + 0.5 * rng.standard_normal((N, p)))
104
+ B = [rng.standard_normal((p, 1)) for p in p_list]
105
+ F0 = 1.0 * rng.standard_normal((K, k))
106
+ D0 = 0.05 + 0.20 * rng.random(K)
107
+ U = rng.standard_normal((N, k))
108
+ Y = XB_from_Blist(Xs, B) + U @ F0.T + rng.standard_normal((N, K)) * np.sqrt(D0)[None, :]
109
+ return [X[:N_tr] for X in Xs], Y[:N_tr], [X[N_tr:] for X in Xs], Y[N_tr:]
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: alsgls
3
+ Version: 0.1.0
4
+ Summary: Lightweight low-rank+diag GLS/SUR via ALS with EM baseline
5
+ Author-email: Gaurav Sood <contact@gsood.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/finite-sample/alsgls
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: numpy>=1.21
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.0; extra == "dev"
16
+
17
+ ## A Lightweight ALS Solver for Iterative GLS
18
+
19
+ When a GLS problem involves hundreds of equations, the $K × K$ covariance matrix becomes the computational bottleneck. A simple statistical remedy is to assume that most of the cross‑equation dependence can be captured by a *handful of latent factors* plus equation‑specific noise. This “low‑rank + diagonal” assumption slashes the number of unknowns from roughly $K^²$ to about $K×k$ parameters, where **k** (the latent factor rank) is much smaller than $K$. The model alone, however, does **not** guarantee speed: we still have to fit the parameters.
20
+
21
+ ### Installation
22
+
23
+ Install the library from PyPI:
24
+
25
+ ```bash
26
+ pip install alsgls
27
+ ```
28
+
29
+ For local development, clone the repo and use an editable install:
30
+
31
+ ```bash
32
+ pip install -e .
33
+ ```
34
+
35
+ ### Usage
36
+
37
+ ```python
38
+ from alsgls import als_gls, simulate_sur, nll_per_row, XB_from_Blist
39
+
40
+ Xs_tr, Y_tr, Xs_te, Y_te = simulate_sur(N_tr=240, N_te=120, K=60, p=3, k=4)
41
+ B, F, D, mem, _ = als_gls(Xs_tr, Y_tr, k=4)
42
+ Yhat_te = XB_from_Blist(Xs_te, B)
43
+ nll = nll_per_row(Y_te - Yhat_te, F, D)
44
+ ```
45
+
46
+ See `examples/compare_als_vs_em.py` for a complete ALS versus EM comparison.
47
+
48
+ ### Documentation and notebooks
49
+
50
+ Background material and reproducible experiments are available in the notebooks under [`als_sim/`](als_sim/), such as [`als_sim/als_comparison.ipynb`](als_sim/als_comparison.ipynb) and [`als_sim/als_sur.ipynb`](als_sim/als_sur.ipynb).
51
+
52
+ ### Solving low‑rank GLS: EM versus ALS
53
+
54
+ The classic EM algorithm alternates between updating the regression coefficients $\beta$ and updating the factor loadings $F$ and the diagonal noise $D$. Even though $\hat{\Sigma}$ is low‑rank, EM’s M‑step recreates the **full** $K × K$ inverse, wiping out the memory win.
55
+
56
+ An alternative is **Alternating‑Least‑Squares (ALS)**. The Woodbury identity reduces the expensive inverse to a tiny k × k system, and the β‑update can be written without explicitly forming the dense matrix at all. In practice, ALS converges in 5–6 sweeps and never allocates more than $O(K k)$ memory, while EM allocates $O(K^²)$.
57
+
58
+ **Rule of thumb:** if your GLS routine keeps looping between $\beta$ and a fresh $\hat{\Sigma}$, replacing the $\hat{\Sigma}$‑update by a factor‑ALS step yields the same statistical fit with an order‑of‑magnitude smaller memory footprint.
59
+
60
+ ### Beyond SUR: where the idea travels
61
+
62
+ Random‑effects models, feasible GLS with estimated heteroskedastic weights, optimal‑weight GMM, and spatial autoregressive GLS all iterate β ↔ Σ̂. Each can adopt the same ALS trick: treat the weight matrix as low‑rank + diagonal, invert only the k × k core, and avoid the dense K × K algebra. Memory savings in published examples range from 5× to 20×, depending on k.
63
+
64
+ ### A concrete case‑study: Seemingly‑Unrelated Regressions
65
+
66
+ To show the magnitude, we ran a Monte‑Carlo experiment with N = 300 observations, three regressors, rank‑3 factors, and K set to 50, 80, 120. EM was given 45 iterations; ALS, six sweeps. The largest array EM holds is the dense Σ⁻¹, whereas ALS’s largest is the skinny factor matrix F. The table summarises six replications:
67
+
68
+ | K | β‑RMSE EM | β‑RMSE ALS | Peak MB EM | Peak MB ALS | Memory ratio |
69
+ | --: | :-------: | :--------: | ---------: | ----------: | -----------: |
70
+ | 50 |  0.021  |  0.021  |  0.020  |  0.002  |  10×  |
71
+ | 80 |  0.020  |  0.020  |  0.051  |  0.003  |  17×  |
72
+ | 120 |  0.020  |  0.020  |  0.115  |  0.004  |  29×  |
73
+
74
+ Statistically, the two estimators are indistinguishable (paired‑test p ≥ 0.14). Computationally, ALS needs only a few megabytes whereas EM needs tens to hundreds.
75
+
76
+ ### 5  Choosing a solver in practice
77
+
78
+ For small systems ($K < 50$), dense GLS or even separate OLS is fine. Between 50 and 300 equations, a low‑rank **factor‑ALS** solver gives the same estimates at roughly one‑tenth the memory and runs happily on a GPU. Once K enters the hundreds, any dense inverse becomes prohibitive; structured approaches such as factor‑ALS or sparse/banded $\hat{\Sigma}$ are mandatory.
@@ -0,0 +1,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ alsgls/__init__.py
4
+ alsgls/als.py
5
+ alsgls/em.py
6
+ alsgls/metrics.py
7
+ alsgls/ops.py
8
+ alsgls/sim.py
9
+ alsgls.egg-info/PKG-INFO
10
+ alsgls.egg-info/SOURCES.txt
11
+ alsgls.egg-info/dependency_links.txt
12
+ alsgls.egg-info/requires.txt
13
+ alsgls.egg-info/top_level.txt
14
+ tests/test_als.py
15
+ tests/test_lowrank_gls.py
16
+ tests/test_metrics.py
17
+ tests/test_ops.py
18
+ tests/test_simulation.py
@@ -0,0 +1,4 @@
1
+ numpy>=1.21
2
+
3
+ [dev]
4
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ alsgls
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "alsgls"
7
+ version = "0.1.0"
8
+ description = "Lightweight low-rank+diag GLS/SUR via ALS with EM baseline"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [{ name = "Gaurav Sood", email = "contact@gsood.com" }]
12
+ license = { text = "MIT" }
13
+ urls = { "Homepage" = "https://github.com/finite-sample/alsgls" }
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "numpy>=1.21",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=7.0"]
25
+
26
+ [tool.setuptools]
27
+ packages = ["alsgls"]
alsgls-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,41 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+
6
+ # Ensure the package root is on the path when tests run from within the
7
+ # ``tests`` directory.
8
+ sys.path.append(str(Path(__file__).resolve().parents[1]))
9
+
10
+ from alsgls.sim import simulate_gls
11
+ from alsgls.als import als_gls
12
+ from alsgls.ops import XB_from_Blist
13
+
14
+
15
+ def test_als_shapes_and_mse_improvement():
16
+ # Synthesize a small GLS problem
17
+ p_list = [3, 5, 4]
18
+ X_tr, Y_tr, X_te, Y_te = simulate_gls(40, 40, p_list, k=2, seed=0)
19
+
20
+ # Baseline per-equation ridge OLS
21
+ lam_B = 1e-3
22
+ B_ols = []
23
+ for j, X in enumerate(X_tr):
24
+ XtX = X.T @ X + lam_B * np.eye(X.shape[1])
25
+ Xty = X.T @ Y_tr[:, [j]]
26
+ B_ols.append(np.linalg.solve(XtX, Xty))
27
+ baseline_mse = np.mean((Y_te - XB_from_Blist(X_te, B_ols)) ** 2)
28
+
29
+ # Run ALS GLS solver
30
+ B_list, F, D, _, _ = als_gls(X_tr, Y_tr, k=2, lam_B=lam_B, sweeps=8)
31
+
32
+ # Assert shapes
33
+ assert len(B_list) == len(p_list)
34
+ for j, Bj in enumerate(B_list):
35
+ assert Bj.shape == (p_list[j], 1)
36
+ assert F.shape == (len(p_list), 2)
37
+ assert D.shape == (len(p_list),)
38
+
39
+ # Compare test MSE to baseline
40
+ final_mse = np.mean((Y_te - XB_from_Blist(X_te, B_list)) ** 2)
41
+ assert final_mse < baseline_mse
@@ -0,0 +1,121 @@
1
+ import numpy as np
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ # Ensure package root is on sys.path
6
+ sys.path.append(str(Path(__file__).resolve().parents[1]))
7
+
8
+ from alsgls import als_gls, em_gls, simulate_sur, mse, nll_per_row
9
+
10
+
11
+ def test_als_vs_em_basic():
12
+ """Test that ALS and EM produce similar results on a small problem."""
13
+ rng = np.random.default_rng(1)
14
+ N_tr, N_te, K, p, k = 30, 10, 5, 3, 2
15
+
16
+ # Generate test data
17
+ Xs_tr, Y_tr, Xs_te, Y_te = simulate_sur(N_tr, N_te, K, p, k, seed=1)
18
+
19
+ # Run both algorithms
20
+ B_als, F_als, D_als, mem_als, info_als = als_gls(
21
+ Xs_tr, Y_tr, k=k, lam_F=1e-3, lam_B=1e-3, sweeps=6
22
+ )
23
+ B_em, F_em, D_em, mem_em, info_em = em_gls(
24
+ Xs_tr, Y_tr, k=k, lam_F=1e-3, lam_B=1e-3, iters=15
25
+ )
26
+
27
+ # Check output shapes and finiteness
28
+ assert F_als.shape == F_em.shape == (K, k)
29
+ assert D_als.shape == D_em.shape == (K,)
30
+ assert len(B_als) == len(B_em) == K
31
+
32
+ for arr in [F_als, D_als, F_em, D_em]:
33
+ assert np.isfinite(arr).all()
34
+ for b_list in [B_als, B_em]:
35
+ for b in b_list:
36
+ assert np.isfinite(b).all()
37
+ for v in [mem_als, mem_em]:
38
+ assert np.isfinite(v) and v > 0
39
+
40
+ # Check that both achieve similar test MSE
41
+ from alsgls import XB_from_Blist
42
+ Y_pred_als = XB_from_Blist(Xs_te, B_als)
43
+ Y_pred_em = XB_from_Blist(Xs_te, B_em)
44
+
45
+ mse_als = mse(Y_te, Y_pred_als)
46
+ mse_em = mse(Y_te, Y_pred_em)
47
+
48
+ assert np.isfinite(mse_als) and np.isfinite(mse_em)
49
+ # They should be reasonably close (within 10% relative difference)
50
+ rel_diff = abs(mse_als - mse_em) / max(mse_als, mse_em)
51
+ assert rel_diff < 0.1
52
+
53
+ # Check that NLL computation works
54
+ R_als = Y_tr - XB_from_Blist(Xs_tr, B_als)
55
+ R_em = Y_tr - XB_from_Blist(Xs_tr, B_em)
56
+
57
+ nll_als = nll_per_row(R_als, F_als, D_als)
58
+ nll_em = nll_per_row(R_em, F_em, D_em)
59
+
60
+ assert np.isfinite(nll_als) and np.isfinite(nll_em)
61
+
62
+
63
+ def test_input_validation():
64
+ """Test that input validation works correctly."""
65
+ rng = np.random.default_rng(42)
66
+ N, K, p = 10, 3, 2
67
+
68
+ # Valid inputs
69
+ Xs = [rng.normal(size=(N, p)) for _ in range(K)]
70
+ Y = rng.normal(size=(N, K))
71
+
72
+ # Should work fine
73
+ B, F, D, mem, info = als_gls(Xs, Y, k=2)
74
+ assert len(B) == K
75
+
76
+ # Test various invalid inputs
77
+ try:
78
+ als_gls([], Y, k=2) # Empty Xs
79
+ assert False, "Should have raised ValueError"
80
+ except ValueError:
81
+ pass
82
+
83
+ try:
84
+ als_gls(Xs, Y.ravel(), k=2) # 1D Y
85
+ assert False, "Should have raised ValueError"
86
+ except ValueError:
87
+ pass
88
+
89
+ try:
90
+ als_gls(Xs, Y, k=0) # Invalid k
91
+ assert False, "Should have raised ValueError"
92
+ except ValueError:
93
+ pass
94
+
95
+ try:
96
+ als_gls(Xs, Y, k=2, lam_F=-1) # Negative regularization
97
+ assert False, "Should have raised ValueError"
98
+ except ValueError:
99
+ pass
100
+
101
+
102
+ def test_memory_estimate_scaling():
103
+ """Test that memory estimates scale appropriately with problem size."""
104
+ rng = np.random.default_rng(123)
105
+ k = 3
106
+
107
+ # Small problem
108
+ N1, K1, p1 = 20, 10, 2
109
+ Xs1 = [rng.normal(size=(N1, p1)) for _ in range(K1)]
110
+ Y1 = rng.normal(size=(N1, K1))
111
+ _, _, _, mem1, _ = als_gls(Xs1, Y1, k=k, sweeps=2)
112
+
113
+ # Larger problem (double dimensions)
114
+ N2, K2, p2 = 40, 20, 4
115
+ Xs2 = [rng.normal(size=(N2, p2)) for _ in range(K2)]
116
+ Y2 = rng.normal(size=(N2, K2))
117
+ _, _, _, mem2, _ = als_gls(Xs2, Y2, k=k, sweeps=2)
118
+
119
+ # Memory should scale up (roughly 4x since K doubled and N doubled)
120
+ assert mem2 > mem1
121
+ assert mem2 < 10 * mem1 # Reasonable upper bound
@@ -0,0 +1,25 @@
1
+ import numpy as np
2
+ import os
3
+ import sys
4
+
5
+ # Ensure package root on path
6
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
7
+ from alsgls.metrics import nll_per_row
8
+
9
+
10
+ def test_nll_matches_explicit():
11
+ rng = np.random.default_rng(0)
12
+ N, K, k = 5, 4, 2
13
+ R = rng.normal(size=(N, K))
14
+ F = rng.normal(size=(K, k))
15
+ D = rng.uniform(0.5, 1.5, size=K)
16
+
17
+ nll_func = nll_per_row(R, F, D)
18
+
19
+ Sigma = F @ F.T + np.diag(D)
20
+ Sigma_inv = np.linalg.inv(Sigma)
21
+ logdet = np.linalg.slogdet(Sigma)[1]
22
+ quad = np.sum(R @ Sigma_inv * R)
23
+ nll_explicit = 0.5 * (quad / N + logdet + K * np.log(2 * np.pi))
24
+
25
+ assert np.allclose(nll_func, nll_explicit)
@@ -0,0 +1,54 @@
1
+ import numpy as np
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ # Ensure package root is on sys.path
6
+ sys.path.append(str(Path(__file__).resolve().parents[1]))
7
+
8
+ from alsgls.ops import (
9
+ woodbury_pieces,
10
+ apply_siginv_to_matrix,
11
+ stack_B_list,
12
+ unstack_B_vec,
13
+ )
14
+
15
+
16
+ def test_woodbury_pieces_and_apply_siginv_to_matrix():
17
+ rng = np.random.default_rng(0)
18
+ K, k = 5, 2
19
+ F = rng.standard_normal((K, k))
20
+ D = rng.uniform(0.5, 2.0, size=K)
21
+
22
+ Sigma = F @ F.T + np.diag(D)
23
+ Sigma_inv = np.linalg.inv(Sigma)
24
+
25
+ # Validate woodbury_pieces
26
+ Dinv, Cf = woodbury_pieces(F, D)
27
+ Sigma_inv_wb = np.diag(Dinv) - (F * Dinv[:, None]) @ Cf @ (F.T * Dinv)
28
+ assert np.allclose(Sigma_inv_wb, Sigma_inv, atol=1e-12, rtol=1e-12)
29
+
30
+ # Validate apply_siginv_to_matrix against explicit inverse
31
+ M = rng.standard_normal((3, K))
32
+ expected = M @ Sigma_inv
33
+ # without cached pieces
34
+ got = apply_siginv_to_matrix(M, F, D)
35
+ assert np.allclose(got, expected, atol=1e-12, rtol=1e-12)
36
+ # with cached pieces
37
+ got_cached = apply_siginv_to_matrix(M, F, D, Dinv=Dinv, Cf=Cf)
38
+ assert np.allclose(got_cached, expected, atol=1e-12, rtol=1e-12)
39
+
40
+
41
+ def test_stack_and_unstack_B_list():
42
+ """Stack heterogeneous B_j blocks and recover them."""
43
+ rng = np.random.default_rng(0)
44
+ # heterogeneous shapes for each B_j
45
+ p_list = [1, 3, 2]
46
+ B_list = [rng.standard_normal((p, 1)) for p in p_list]
47
+
48
+ # Stack into vector then unstack back to list
49
+ b_vec = stack_B_list(B_list)
50
+ recovered = unstack_B_vec(b_vec, p_list)
51
+
52
+ # Each recovered block should match the original exactly
53
+ for orig, rec in zip(B_list, recovered):
54
+ assert np.allclose(orig, rec)
@@ -0,0 +1,51 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+
6
+ # Ensure the package root is on the import path when tests are executed
7
+ sys.path.append(str(Path(__file__).resolve().parents[1]))
8
+ from alsgls import simulate_sur, simulate_gls
9
+
10
+
11
+ def test_simulate_sur_shapes_and_reproducibility():
12
+ N_tr, N_te, K, p, k, seed = 20, 10, 3, 5, 2, 42
13
+ X_tr, Y_tr, X_te, Y_te = simulate_sur(N_tr, N_te, K, p, k, seed=seed)
14
+
15
+ assert len(X_tr) == K == len(X_te)
16
+ for X in X_tr:
17
+ assert X.shape == (N_tr, p)
18
+ for X in X_te:
19
+ assert X.shape == (N_te, p)
20
+ assert Y_tr.shape == (N_tr, K)
21
+ assert Y_te.shape == (N_te, K)
22
+
23
+ X_tr2, Y_tr2, X_te2, Y_te2 = simulate_sur(N_tr, N_te, K, p, k, seed=seed)
24
+ for X1, X2 in zip(X_tr, X_tr2):
25
+ assert np.array_equal(X1, X2)
26
+ for X1, X2 in zip(X_te, X_te2):
27
+ assert np.array_equal(X1, X2)
28
+ assert np.array_equal(Y_tr, Y_tr2)
29
+ assert np.array_equal(Y_te, Y_te2)
30
+
31
+
32
+ def test_simulate_gls_shapes_and_reproducibility():
33
+ N_tr, N_te, p_list, k, seed = 15, 7, [4, 3, 5], 2, 123
34
+ X_tr, Y_tr, X_te, Y_te = simulate_gls(N_tr, N_te, p_list, k, seed=seed)
35
+ K = len(p_list)
36
+
37
+ assert len(X_tr) == K == len(X_te)
38
+ for X, p in zip(X_tr, p_list):
39
+ assert X.shape == (N_tr, p)
40
+ for X, p in zip(X_te, p_list):
41
+ assert X.shape == (N_te, p)
42
+ assert Y_tr.shape == (N_tr, K)
43
+ assert Y_te.shape == (N_te, K)
44
+
45
+ X_tr2, Y_tr2, X_te2, Y_te2 = simulate_gls(N_tr, N_te, p_list, k, seed=seed)
46
+ for X1, X2 in zip(X_tr, X_tr2):
47
+ assert np.array_equal(X1, X2)
48
+ for X1, X2 in zip(X_te, X_te2):
49
+ assert np.array_equal(X1, X2)
50
+ assert np.array_equal(Y_tr, Y_tr2)
51
+ assert np.array_equal(Y_te, Y_te2)