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 +78 -0
- alsgls-0.1.0/README.md +62 -0
- alsgls-0.1.0/alsgls/__init__.py +7 -0
- alsgls-0.1.0/alsgls/als.py +112 -0
- alsgls-0.1.0/alsgls/em.py +98 -0
- alsgls-0.1.0/alsgls/metrics.py +39 -0
- alsgls-0.1.0/alsgls/ops.py +87 -0
- alsgls-0.1.0/alsgls/sim.py +109 -0
- alsgls-0.1.0/alsgls.egg-info/PKG-INFO +78 -0
- alsgls-0.1.0/alsgls.egg-info/SOURCES.txt +18 -0
- alsgls-0.1.0/alsgls.egg-info/dependency_links.txt +1 -0
- alsgls-0.1.0/alsgls.egg-info/requires.txt +4 -0
- alsgls-0.1.0/alsgls.egg-info/top_level.txt +1 -0
- alsgls-0.1.0/pyproject.toml +27 -0
- alsgls-0.1.0/setup.cfg +4 -0
- alsgls-0.1.0/tests/test_als.py +41 -0
- alsgls-0.1.0/tests/test_lowrank_gls.py +121 -0
- alsgls-0.1.0/tests/test_metrics.py +25 -0
- alsgls-0.1.0/tests/test_ops.py +54 -0
- alsgls-0.1.0/tests/test_simulation.py +51 -0
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 @@
|
|
|
1
|
+
|
|
@@ -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,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)
|