sparsetrace commited on
Commit
5ff8ce1
·
verified ·
1 Parent(s): 3703e2e

Create ALSA.py

Browse files
Files changed (1) hide show
  1. ALSA.py +440 -0
ALSA.py ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ALSA.py
2
+ # ============================================================
3
+ # ALSA = NLSAEncoder + GPLM baseline + in-context attention residual mixing
4
+ #
5
+ # Key idea:
6
+ # ALSA = global predictor + analog/attention-like correction in diffusion coords ψ
7
+ #
8
+ # Behavior:
9
+ # - if prefix length ell == L: baseline-only AR rollout (NLSA+GPLM)
10
+ # - if ell > L: ALSA correction enabled:
11
+ # E_ctx = Y_true - Y_glob
12
+ # p(q,b) ∝ exp(-||ψ_q-ψ_b||^2 / (2 ell_attn^2))
13
+ # y = y_glob + gamma_ctx * Σ_b p(q,b) E_ctx[b]
14
+ #
15
+ # This is a "Markovian" IC method because p(q,·) is a probability distribution.
16
+ #
17
+ # Repo expectations:
18
+ # - NLSAEncoder provides:
19
+ # .L, .K_, .psi_, .mu_ and .encode_window(window_LD)->(r,)
20
+ # - GPLM provides:
21
+ # GPLM(X_latent, Y_ambient, center_X=False, ...) and callable predict
22
+ #
23
+ # Dependencies:
24
+ # numpy (scipy optional for pdist if you want it, but not required)
25
+ # ============================================================
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+ from typing import Any, Dict, Optional, Tuple, Union
31
+
32
+ import numpy as np
33
+ from numpy.lib.stride_tricks import sliding_window_view
34
+
35
+
36
+ # ------------------------------------------------------------
37
+ # Imports from your repo (robust fallbacks)
38
+ # ------------------------------------------------------------
39
+
40
+ # NLSA encoder
41
+ try:
42
+ from .nlsa_encoder import NLSAEncoder # type: ignore
43
+ except Exception:
44
+ try:
45
+ from nlsa_encoder import NLSAEncoder # type: ignore
46
+ except Exception:
47
+ # if you named the file NLSA.py and class NLSAEncoder lives there
48
+ from NLSA import NLSAEncoder # type: ignore
49
+
50
+ # GPLM decoder
51
+ try:
52
+ from .gplm import GPLM # type: ignore
53
+ except Exception:
54
+ try:
55
+ from gplm import GPLM # type: ignore
56
+ except Exception:
57
+ from GPLM import GPLM # type: ignore
58
+
59
+
60
+ Array = np.ndarray
61
+
62
+
63
+ # ============================================================
64
+ # Small utilities
65
+ # ============================================================
66
+
67
+ def _as_2d(X: Array) -> Array:
68
+ X = np.asarray(X, dtype=float)
69
+ if X.ndim == 1:
70
+ X = X[:, None]
71
+ return X
72
+
73
+
74
+ def _sliding_windows(F_tD: Array, L: int) -> Array:
75
+ """
76
+ Return windows W (K,L,D) from F (N,D) with K=N-L+1.
77
+ Handles numpy stride ordering differences.
78
+ """
79
+ F = _as_2d(F_tD)
80
+ W = sliding_window_view(F, window_shape=int(L), axis=0)
81
+
82
+ # W can be (K,L,D) or (K,D,L)
83
+ a, b = W.shape[1], W.shape[2]
84
+ if (a, b) == (L, F.shape[1]):
85
+ return np.ascontiguousarray(W)
86
+ if (a, b) == (F.shape[1], L):
87
+ return np.ascontiguousarray(np.transpose(W, (0, 2, 1)))
88
+ raise ValueError(f"Unexpected window shape {W.shape} for L={L}, D={F.shape[1]}.")
89
+
90
+
91
+ def _sample_pairwise_d2(X: Array, m: int = 1024, seed: int = 0) -> Array:
92
+ """
93
+ Sample pairwise squared distances for bandwidth estimation.
94
+ Returns 1D array of sampled d^2 values.
95
+ """
96
+ X = np.asarray(X, dtype=np.float64)
97
+ n = X.shape[0]
98
+ if n <= 1:
99
+ return np.array([1.0], dtype=np.float64)
100
+
101
+ rng = np.random.default_rng(int(seed))
102
+ idx = rng.choice(n, size=min(int(m), n), replace=False)
103
+ Xs = X[idx]
104
+
105
+ # Sample random pairs among Xs
106
+ ns = Xs.shape[0]
107
+ M = min(20000, ns * (ns - 1) // 2)
108
+ ii = rng.integers(0, ns, size=M)
109
+ jj = rng.integers(0, ns, size=M)
110
+ mask = ii != jj
111
+ ii, jj = ii[mask], jj[mask]
112
+ if ii.size == 0:
113
+ return np.array([1.0], dtype=np.float64)
114
+
115
+ diff = Xs[ii] - Xs[jj]
116
+ return np.sum(diff * diff, axis=1)
117
+
118
+
119
+ def _median_ell_from_d2(d2: Array, q: float = 0.5, eps: float = 1e-12) -> float:
120
+ """
121
+ If kernel is exp(-||x-y||^2/(2 ell^2)), heuristic:
122
+ ell^2 ≈ quantile(d^2)/2
123
+ """
124
+ d2 = np.asarray(d2, dtype=np.float64)
125
+ if d2.size == 0:
126
+ return 1.0
127
+ v = float(np.quantile(d2, float(q)))
128
+ return float(np.sqrt(max(v / 2.0, eps)))
129
+
130
+
131
+ def _softmax_weights_from_d2(d2: Array, ell: float, eps: float = 1e-18) -> Array:
132
+ """
133
+ p_i ∝ exp(-0.5 * d2_i / ell^2)
134
+ """
135
+ d2 = np.asarray(d2, dtype=np.float64)
136
+ ell2 = float(ell) * float(ell) + 1e-18
137
+ logits = -0.5 * d2 / ell2
138
+ logits -= float(np.max(logits))
139
+ w = np.exp(logits)
140
+ s = float(np.sum(w))
141
+ return w / (s + eps)
142
+
143
+
144
+ # ============================================================
145
+ # ALSA config (sister to LISAConfig)
146
+ # ============================================================
147
+
148
+ @dataclass
149
+ class ALSAConfig:
150
+ # Encoder (NLSA)
151
+ L: int = 128
152
+ rank: int = 32
153
+ beta: Optional[float] = None
154
+ alpha: float = 1.0
155
+ center: bool = True
156
+ drop_first: bool = True
157
+ max_K_dense: int = 6000
158
+ seed: int = 0
159
+
160
+ # Baseline GPLM decoder psi->next sample
161
+ gplm_kwargs: Optional[Dict[str, Any]] = None
162
+
163
+ # psi preprocessing
164
+ whiten_psi: bool = True
165
+
166
+ # In-context attention correction
167
+ min_ctx_windows: Optional[int] = None # default r+1
168
+ ctx_k0: float = 10.0 # gamma_ctx = K_ctx/(K_ctx + ctx_k0)
169
+
170
+ attn_topk: Optional[int] = 64 # None = dense over all context
171
+ attn_ell: Optional[float] = None # if None -> estimate from training ψ geometry
172
+ attn_ell_q: float = 0.5 # quantile used for attn_ell estimation
173
+
174
+
175
+ # ============================================================
176
+ # ALSA main class
177
+ # ============================================================
178
+
179
+ class ALSA:
180
+ """
181
+ ALSA = NLSAEncoder + GPLM baseline + attention-like (Markov) IC correction.
182
+
183
+ Training:
184
+ 1) Fit NLSAEncoder on training series
185
+ 2) Fit GPLM baseline on diffusion coords:
186
+ ψ_train(window) -> next sample
187
+
188
+ Inference:
189
+ - If prefix length ell == L: baseline-only AR rollout
190
+ - If ell > L:
191
+ build context windows within prefix
192
+ residual table E_ctx = Y_true - Y_glob
193
+ attention weights p(q,ctx) from ψ-distances
194
+ correction = Σ p * E_ctx
195
+ y = y_glob + gamma_ctx * correction
196
+ """
197
+
198
+ def __init__(self, F_train: Array, *, config: Optional[ALSAConfig] = None, **kwargs: Any):
199
+ if config is None:
200
+ config = ALSAConfig(**kwargs)
201
+ self.cfg = config
202
+
203
+ F_train = _as_2d(F_train)
204
+ self.D = int(F_train.shape[1])
205
+ self.L = int(self.cfg.L)
206
+
207
+ # ---- 1) Encoder ----
208
+ self.enc = NLSAEncoder(
209
+ F_train,
210
+ L=int(self.cfg.L),
211
+ rank=int(self.cfg.rank),
212
+ beta=self.cfg.beta,
213
+ alpha=float(self.cfg.alpha),
214
+ center=bool(self.cfg.center),
215
+ drop_first=bool(self.cfg.drop_first),
216
+ max_K_dense=int(self.cfg.max_K_dense),
217
+ seed=int(self.cfg.seed),
218
+ )
219
+
220
+ self.r = 0 if self.enc.psi_ is None else int(self.enc.psi_.shape[1])
221
+ if self.r <= 0:
222
+ raise ValueError("NLSAEncoder produced r=0 diffusion dims. Increase rank or set drop_first=False.")
223
+
224
+ # Output mean from encoder centering
225
+ self.center = bool(self.cfg.center)
226
+ self.mu_X = self.enc.mu_.reshape(-1).astype(np.float64) if self.center else np.zeros((self.D,), dtype=np.float64)
227
+
228
+ # ---- 2) Baseline GPLM: psi -> next sample (centered space) ----
229
+ K = int(self.enc.K_)
230
+ n_pairs = K - 1
231
+ Psi_tr = np.asarray(self.enc.psi_[:n_pairs, :], dtype=np.float64) # (K-1, r)
232
+
233
+ # Targets in centered space
234
+ F_centered = F_train - self.mu_X[None, :] if self.center else F_train
235
+ Y_tr = np.asarray(F_centered[self.L : self.L + n_pairs, :], dtype=np.float64) # (K-1, D)
236
+
237
+ # Optional whitening of psi for both:
238
+ # - baseline decoder geometry
239
+ # - attention geometry
240
+ self.whiten_psi = bool(self.cfg.whiten_psi)
241
+ if self.whiten_psi:
242
+ self.psi_mu = Psi_tr.mean(axis=0, keepdims=True)
243
+ self.psi_sd = Psi_tr.std(axis=0, keepdims=True) + 1e-12
244
+ Psi_tr_w = (Psi_tr - self.psi_mu) / self.psi_sd
245
+ else:
246
+ self.psi_mu = np.zeros((1, self.r), dtype=np.float64)
247
+ self.psi_sd = np.ones((1, self.r), dtype=np.float64)
248
+ Psi_tr_w = Psi_tr
249
+
250
+ gkw = {} if self.cfg.gplm_kwargs is None else dict(self.cfg.gplm_kwargs)
251
+ # Critical: outputs already centered -> don't recenter in GPLM
252
+ gkw.setdefault("center_X", False)
253
+ gkw.setdefault("seed", int(self.cfg.seed))
254
+ gkw.setdefault("sigma2", 1e-4)
255
+ gkw.setdefault("jitter", 1e-8)
256
+ gkw.setdefault("m", min(1024, n_pairs))
257
+ gkw.setdefault("inducing", "kmeans_medoids")
258
+
259
+ self.baseline = GPLM(Psi_tr_w, Y_tr, **gkw)
260
+
261
+ # Attention hyperparams
262
+ self.attn_topk = None if self.cfg.attn_topk is None else int(self.cfg.attn_topk)
263
+
264
+ if self.cfg.min_ctx_windows is None:
265
+ self.min_ctx_windows = max(1, self.r + 1)
266
+ else:
267
+ self.min_ctx_windows = int(self.cfg.min_ctx_windows)
268
+
269
+ self.ctx_k0 = float(self.cfg.ctx_k0)
270
+
271
+ # Pick attention ell if not provided (based on training psi geometry)
272
+ if self.cfg.attn_ell is None:
273
+ d2 = _sample_pairwise_d2(Psi_tr_w, m=1024, seed=int(self.cfg.seed) + 123)
274
+ self.attn_ell = _median_ell_from_d2(d2, q=float(self.cfg.attn_ell_q))
275
+ else:
276
+ self.attn_ell = float(self.cfg.attn_ell)
277
+
278
+ # -------------------------
279
+ # psi processing
280
+ # -------------------------
281
+
282
+ def _psi_whiten(self, psi: Array) -> Array:
283
+ psi = np.asarray(psi, dtype=np.float64)
284
+ if psi.ndim == 1:
285
+ psi = psi[None, :]
286
+ if self.whiten_psi:
287
+ return (psi - self.psi_mu) / self.psi_sd
288
+ return psi
289
+
290
+ # -------------------------
291
+ # baseline step
292
+ # -------------------------
293
+
294
+ def predict_one_step_baseline(self, W_LD: Array) -> Array:
295
+ """
296
+ Baseline one-step prediction:
297
+ window (L,D) -> psi -> GPLM -> next sample (D)
298
+ """
299
+ W = np.asarray(W_LD, dtype=float)
300
+ if W.ndim == 1:
301
+ W = W[:, None]
302
+ if W.shape != (self.L, self.D):
303
+ raise ValueError(f"Expected window shape {(self.L, self.D)}, got {W.shape}")
304
+
305
+ psi = self.enc.encode_window(W) # (r,)
306
+ psi_w = self._psi_whiten(psi)[0] # (r,)
307
+ y_c = np.asarray(self.baseline(psi_w), dtype=np.float64).reshape(-1) # centered
308
+ return (y_c + self.mu_X) if self.center else y_c
309
+
310
+ # -------------------------
311
+ # main IC rollout
312
+ # -------------------------
313
+
314
+ def __call__(self, prefix: Array, steps: int = 1) -> Array:
315
+ """
316
+ Forecast from prefix.
317
+
318
+ prefix: (ell,D), ell >= L
319
+ steps: horizon H
320
+
321
+ Returns:
322
+ (H,D) (or (D,) if H==1)
323
+ """
324
+ prefix = _as_2d(prefix)
325
+ ell, D = prefix.shape
326
+ if D != self.D:
327
+ raise ValueError(f"ALSA trained with D={self.D}, got prefix D={D}.")
328
+ if ell < self.L:
329
+ raise ValueError(f"Need prefix length ell >= L={self.L}.")
330
+
331
+ H = int(steps)
332
+ if H <= 0:
333
+ return np.zeros((0, self.D), dtype=np.float64)
334
+
335
+ # Seed window is always last L
336
+ cur = prefix[-self.L :, :].copy()
337
+
338
+ # ----------------------------------------------------
339
+ # If no context (ell == L), run baseline-only rollout
340
+ # ----------------------------------------------------
341
+ if ell == self.L:
342
+ out = self._rollout_baseline(cur, H)
343
+ return out[0] if H == 1 else out
344
+
345
+ # ----------------------------------------------------
346
+ # Build context windows and residual table from prefix
347
+ # ----------------------------------------------------
348
+ W_all = _sliding_windows(prefix, self.L) # (K_n, L, D)
349
+ K_n = int(W_all.shape[0])
350
+ K_ctx = K_n - 1
351
+
352
+ # If too little context, baseline-only
353
+ if K_ctx < self.min_ctx_windows:
354
+ out = self._rollout_baseline(cur, H)
355
+ return out[0] if H == 1 else out
356
+
357
+ W_ctx = np.ascontiguousarray(W_all[:K_ctx, :, :]) # (K_ctx,L,D)
358
+ Y_true = np.asarray(prefix[self.L : self.L + K_ctx, :], float) # (K_ctx,D)
359
+
360
+ # Center targets
361
+ Y_true_c = (Y_true - self.mu_X[None, :]) if self.center else Y_true
362
+
363
+ # Encode context psi
364
+ Psi_ctx = np.zeros((K_ctx, self.r), dtype=np.float64)
365
+ for i in range(K_ctx):
366
+ Psi_ctx[i] = self.enc.encode_window(W_ctx[i])
367
+ Psi_ctx_w = self._psi_whiten(Psi_ctx) # (K_ctx,r)
368
+
369
+ # Baseline on context
370
+ Y_glob_ctx_c = np.asarray(self.baseline(Psi_ctx_w), dtype=np.float64) # (K_ctx,D)
371
+
372
+ # Residual table
373
+ E_ctx = Y_true_c - Y_glob_ctx_c # (K_ctx,D)
374
+
375
+ # Context gate gamma in [0,1]
376
+ gamma_ctx = float(K_ctx) / float(K_ctx + self.ctx_k0) if self.ctx_k0 > 0 else 1.0
377
+
378
+ # ----------------------------------------------------
379
+ # Rollout with attention residual correction
380
+ # ----------------------------------------------------
381
+ out = np.zeros((H, self.D), dtype=np.float64)
382
+
383
+ for h in range(H):
384
+ psi_q = self.enc.encode_window(cur) # (r,)
385
+ psi_q_w = self._psi_whiten(psi_q)[0] # (r,)
386
+
387
+ # Baseline prediction
388
+ y_glob_c = np.asarray(self.baseline(psi_q_w), dtype=np.float64).reshape(-1) # (D,)
389
+
390
+ # Attention weights on psi distances (Markov weights)
391
+ diff = Psi_ctx_w - psi_q_w[None, :] # (K_ctx,r)
392
+ d2 = np.einsum("kr,kr->k", diff, diff, optimize=True)
393
+
394
+ if self.attn_topk is not None and self.attn_topk < K_ctx:
395
+ k = int(self.attn_topk)
396
+ idx = np.argpartition(d2, kth=k - 1)[:k]
397
+ w = _softmax_weights_from_d2(d2[idx], self.attn_ell)
398
+ e_hat = w @ E_ctx[idx] # (D,)
399
+ else:
400
+ w = _softmax_weights_from_d2(d2, self.attn_ell)
401
+ e_hat = w @ E_ctx # (D,)
402
+
403
+ # Combine
404
+ y_c = y_glob_c + gamma_ctx * e_hat
405
+ y = (y_c + self.mu_X) if self.center else y_c
406
+
407
+ out[h] = y
408
+
409
+ # Advance window
410
+ if self.L > 1:
411
+ cur[:-1] = cur[1:]
412
+ cur[-1] = y
413
+
414
+ return out[0] if H == 1 else out
415
+
416
+ def _rollout_baseline(self, seed_LD: Array, H: int) -> Array:
417
+ """
418
+ Baseline-only AR rollout (no IC correction).
419
+ """
420
+ cur = np.asarray(seed_LD, dtype=float).copy()
421
+ out = np.zeros((int(H), self.D), dtype=np.float64)
422
+
423
+ for h in range(int(H)):
424
+ y = self.predict_one_step_baseline(cur)
425
+ out[h] = y
426
+ if self.L > 1:
427
+ cur[:-1] = cur[1:]
428
+ cur[-1] = y
429
+
430
+ return out
431
+
432
+ def __repr__(self) -> str:
433
+ return (
434
+ f"ALSA(L={self.L}, D={self.D}, r={self.r}, "
435
+ f"center={self.center}, whiten_psi={self.whiten_psi}, "
436
+ f"attn_topk={self.attn_topk}, attn_ell={self.attn_ell:.4g})"
437
+ )
438
+
439
+
440
+ __all__ = ["ALSA", "ALSAConfig"]