ProCreations's picture
Publish validated HiPPO Zoo reproduction nB0TrIRAs1
1cd8a52 verified
Raw
History Blame Contribute Delete
33 kB
"""
Multiscale HiPPO vs regular HiPPO
Explicit reconstruction of the past from 16 Legendre coefficients.
What we do:
- Sample OU-mixture signals.
- Roll out three multiscale systems + H regular HiPPO systems.
- For each lag L in `lags`:
* Multiscale systems: interpret their (n*m) state at final time T-1 as an OP over
the *normalized* time coordinate s \in [0,1] where s=0 is time T-1 (present) and
s=1 is time T-1-L (past endpoint).
We reconstruct x_hat(s) = \Sigma_{k=0}^{n-1} c_k L_k(s) using 128 s-samples, compare to the
true signal along that window (linear interpolation at fractional indices), and
accumulate mean MSE.
* Regular HiPPO (each base timescale \tau_h): its coefficients represent the fixed window
[T-1-\tau_h, T-1]. For each lag L we still evaluate the *last* L steps portion by
sampling s \in [0,1] over the whole \tau_h window and comparing on the last L-step segment.
(So if L > \tau_h, the earliest part is outside the window; we clamp to available region.)
- Plot average MSE vs lag (log x-axis), one curve per model; regular HiPPO gives H curves.
Important conventions:
- For multiscale: s=0 <-> time T (here T means last index T-1) and s=1 <-> time T-lag.
So time(s) = (T-1) - s*lag.
- For regular HiPPO with window \tau_h: time(s) = (T-1) - s*\tau_h.
We compare over the interval [T-1-lag, T-1] by restricting s to [0, lag/\tau_h] (if lag<=\tau_h),
otherwise we compare over the full window and treat the extra as outside (0 target).
"""
from dataclasses import dataclass
from typing import Tuple
import jax
import jax.numpy as jnp
import jax.random as jr
from jax import jit
from jax.lax import scan
from jax.scipy.linalg import expm
from scipy.special import iv
jax.config.update("jax_enable_x64", True)
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from tqdm import tqdm
from mpm import get_system_params
# ----------------------------
# 1) OU-mixture signal
# ----------------------------
def sample_ou_mixture_1d(
key: jax.Array,
T: int,
ou_components: int,
tau_min: float,
tau_max: float,
dt: float = 1.0,
taus: jax.Array | None = None,
) -> Tuple[jax.Array, jax.Array]:
if taus is None:
key_tau, key_init, key_noise = jr.split(key, 3)
log_tau = jr.uniform(
key_tau, (ou_components,), minval=jnp.log(tau_min), maxval=jnp.log(tau_max)
)
taus = jnp.exp(log_tau)
else:
key_init, key_noise = jr.split(key, 2)
a = jnp.exp(-dt / taus)
b = jnp.sqrt(jnp.maximum(0.0, 1.0 - a * a))
z0 = jr.normal(key_init, (ou_components,))
eps = jr.normal(key_noise, (T - 1, ou_components))
def step(z, e):
z_next = a * z + b * e
return z_next, z_next
_, zs = scan(step, z0, eps)
zs = jnp.concatenate([z0[None, :], zs], axis=0)
x = jnp.sum(zs, axis=1)
x = x / jnp.sqrt(float(ou_components))
return x.astype(jnp.float32), taus
# ----------------------------
# 2) Multiscale matrices: C, B and dense ZOH discretization
# ----------------------------
def vecT(S):
return jnp.reshape(S.T, (-1,))
def unvecT(v, n, m):
return jnp.reshape(v, (m, n)).T
def legendre_shifted_mult_by_c_matrix(m: int) -> jax.Array:
n = jnp.arange(m, dtype=jnp.float32)
a_up = (n[:-1] + 1.0) / jnp.sqrt((2.0 * n[:-1] + 1.0) * (2.0 * n[:-1] + 3.0))
a_dn = (n[1:]) / jnp.sqrt((2.0 * n[1:] + 1.0) * (2.0 * n[1:] - 1.0))
Mx = jnp.zeros((m, m), dtype=jnp.float32)
Mx = Mx.at[jnp.arange(m - 1), jnp.arange(1, m)].set(a_up)
Mx = Mx.at[jnp.arange(1, m), jnp.arange(m - 1)].set(a_dn)
C = 0.5 * jnp.eye(m, dtype=jnp.float32) + 0.5 * Mx
return C
def legendre_orthonormal_Jx(m: int, dtype=jnp.float64) -> jax.Array:
n = jnp.arange(m - 1, dtype=dtype)
off = (n + 1.0) / jnp.sqrt((2.0 * n + 1.0) * (2.0 * n + 3.0))
J = jnp.zeros((m, m), dtype=dtype)
J = J.at[jnp.arange(m - 1), jnp.arange(1, m)].set(off)
J = J.at[jnp.arange(1, m), jnp.arange(m - 1)].set(off)
return J
def build_G_log_g(m: int, eps: float, dtype=jnp.float64) -> jax.Array:
L = jnp.log(1.0 / jnp.asarray(eps, dtype=dtype))
kappa = 0.5 * L
Jx = legendre_orthonormal_Jx(m, dtype=dtype)
G = jnp.sqrt(jnp.asarray(eps, dtype=dtype)) * expm(kappa * Jx)
G = 0.5 * (G + G.T)
return G
def modified_spherical_in(n: jax.Array, z: jax.Array) -> jax.Array:
n = jnp.asarray(n)
z = jnp.asarray(z)
v = n + 0.5
return jnp.sqrt(jnp.pi / (2.0 * z)) * jnp.array(iv(np.array(v), np.array(z)))
def beta_for_exp_u_in_u_legendre(m: int, eps: float, dtype=jnp.float64) -> jax.Array:
L = jnp.log(1.0 / jnp.asarray(eps, dtype=dtype))
kappa = 0.5 * L
n = jnp.arange(m, dtype=dtype)
i_n = modified_spherical_in(n, kappa)
beta = jnp.sqrt(jnp.asarray(eps, dtype=dtype)) * jnp.sqrt((2.0 * n + 1.0) * L) * i_n
return beta
def build_B_from_b_log_g(b: jax.Array, m: int, eps: float, dtype=jnp.float64) -> jax.Array:
beta = beta_for_exp_u_in_u_legendre(m, eps, dtype=dtype)
return jnp.asarray(b, dtype=dtype)[:, None] * beta[None, :]
def build_B_from_b(b: jax.Array, m: int) -> jax.Array:
n = b.shape[0]
B = jnp.zeros((n, m), dtype=b.dtype)
B = B.at[:, 0].set(0.5 * b)
if m >= 2:
B = B.at[:, 1].set((0.5 / jnp.sqrt(3.0)) * b)
return B
def build_B_from_b_jeffreys(b: jax.Array, m: int, eps: float) -> jax.Array:
b = jnp.asarray(b)
mu0 = jnp.log(1.0 / jnp.asarray(eps, dtype=jnp.float64))
mu1 = (1.0 - eps)
mu2 = 0.5 * (1.0 - eps * eps)
beta0 = (mu1 / jnp.sqrt(mu0)).astype(b.dtype)
beta1 = jnp.sqrt(jnp.maximum(mu2 - (mu1 * mu1) / mu0, 0.0)).astype(b.dtype)
B = jnp.zeros((b.shape[0], m), dtype=b.dtype)
B = B.at[:, 0].set(beta0 * b)
if m >= 2:
B = B.at[:, 1].set(beta1 * b)
return B
def jeffreys_eval_basis(g: jax.Array, a: jax.Array, b: jax.Array, eps: float, m: int) -> jax.Array:
"""
Return Q(g) = [Q_0(g), ..., Q_{m-1}(g)] for Jeffreys-orthonormal polynomials.
Recurrence:
g Q_n = a_{n+1} Q_{n+1} + b_n Q_n + a_n Q_{n-1}, a_0=0.
"""
g = jnp.asarray(g)
mu0 = jnp.log(1.0 / jnp.asarray(eps, dtype=g.dtype))
Q0 = 1.0 / jnp.sqrt(mu0)
# Q_0
Q = [jnp.broadcast_to(Q0, g.shape)]
if m == 1:
return jnp.stack(Q, axis=-1)
# Q_1 = ((g - b0)/a1) Q0
Q1 = ((g - b[0]) / a[1]) * Q[0]
Q.append(Q1)
# Q_{n+1} = ((g - b_n) Q_n - a_n Q_{n-1}) / a_{n+1}
for n in range(1, m - 1):
Qnp1 = ((g - b[n]) * Q[n] - a[n] * Q[n - 1]) / a[n + 1]
Q.append(Qnp1)
return jnp.stack(Q, axis=-1) # (..., m)
def jeffreys_clenshaw(g: jax.Array, coeffs: jax.Array, a: jax.Array, b: jax.Array, eps: float) -> jax.Array:
"""
Evaluate sum_{k=0}^{m-1} coeffs[k] Q_k(g) using Clenshaw.
Here a has length >= m+2 (we return a_0..a_{m+2}), b has length >= m.
"""
g = jnp.asarray(g)
coeffs = jnp.asarray(coeffs)
m = coeffs.shape[-1]
mu0 = jnp.log(1.0 / jnp.asarray(eps, dtype=g.dtype))
Q0 = 1.0 / jnp.sqrt(mu0)
def one(x, c):
d1 = jnp.asarray(0.0, dtype=x.dtype)
d2 = jnp.asarray(0.0, dtype=x.dtype)
for k in range(m - 1, -1, -1):
d0 = c[k] + ((x - b[k]) / a[k + 1]) * d1 - (a[k + 1] / a[k + 2]) * d2
d2, d1 = d1, d0
return Q0 * d1
xflat = g.reshape(-1)
if coeffs.ndim == 1:
cflat = jnp.broadcast_to(coeffs, (xflat.shape[0], m))
else:
cflat = coeffs.reshape((-1, m))
out = jax.vmap(one)(xflat, cflat)
return out.reshape(g.shape)
def zoh_discretize_dense(K: jax.Array, g: jax.Array, dt: float) -> Tuple[jax.Array, jax.Array]:
D = K.shape[0]
Z = jnp.zeros((D + 1, D + 1), dtype=K.dtype)
Z = Z.at[:D, :D].set(K)
Z = Z.at[:D, D].set(g)
E = expm(dt * Z)
A_d = E[:D, :D]
B_d = E[:D, D]
return A_d.astype(jnp.float32), B_d.astype(jnp.float32)
def jeffreys_nodes_weights(eps: float, Q: int, dtype=jnp.float64):
eps = jnp.asarray(eps, dtype=dtype)
u0 = jnp.log(eps)
u1 = jnp.array(0.0, dtype=dtype)
i = jnp.arange(Q, dtype=dtype)
du = (u1 - u0) / Q
u = u0 + (i + 0.5) * du
g = jnp.exp(u)
w = jnp.full((Q,), du, dtype=dtype) # integral is \int f(exp(u)) du
return g, w
def jeffreys_recurrence_discrete(m: int, eps: float, Q: int = 8192, dtype=jnp.float64):
g, w = jeffreys_nodes_weights(eps, Q, dtype=dtype)
def inner(x, y):
return jnp.sum(w * x * y)
mu0 = jnp.log(1.0 / jnp.asarray(eps, dtype=dtype))
p_prev = jnp.zeros((Q,), dtype=dtype)
p = jnp.full((Q,), 1.0 / jnp.sqrt(mu0), dtype=dtype)
a = [jnp.array(0.0, dtype=dtype)]
b = []
a_n = jnp.array(0.0, dtype=dtype)
for _n in range(m + 1):
gp = g * p
b_n = inner(gp, p)
r = gp - b_n * p - a_n * p_prev
a_np1 = jnp.sqrt(jnp.maximum(inner(r, r), 0.0))
b.append(b_n)
a.append(a_np1)
p_next = jnp.where(a_np1 > 0, r / a_np1, jnp.zeros_like(r))
p_prev, p = p, p_next
a_n = a_np1
# one more for Clenshaw safety
gp = g * p
b_mp1 = inner(gp, p)
r = gp - b_mp1 * p - a_n * p_prev
a_mp2 = jnp.sqrt(jnp.maximum(inner(r, r), 0.0))
a.append(a_mp2)
return jnp.stack(a), jnp.stack(b)
def jeffreys_mult_by_g_c_matrix_discrete(
m: int, eps: float, Q: int = 8192, dtype=jnp.float64
):
a, b = jeffreys_recurrence_discrete(m=m, eps=eps, Q=Q, dtype=dtype)
diag = b[:m]
off = a[1:m]
C = jnp.zeros((m, m), dtype=dtype)
C = C.at[jnp.arange(m), jnp.arange(m)].set(diag)
if m >= 2:
C = C.at[jnp.arange(m - 1), jnp.arange(1, m)].set(off)
C = C.at[jnp.arange(1, m), jnp.arange(m - 1)].set(off)
return 0.5 * (C + C.T)
# ----------------------------
# 3) Orthonormal shifted Legendre evaluation on [0,1]
# ----------------------------
def shifted_legendre_orthonormal_vals(s: jax.Array, n: int) -> jax.Array:
"""
L_k(s) orthonormal on [0,1]:
L_k(s) = sqrt(2k+1) P_k(2s-1)
Returns shape (..., n)
"""
s = jnp.asarray(s)
x = 2.0 * s - 1.0
# P_0, P_1
P0 = jnp.ones_like(x)
if n == 1:
return P0[..., None] * jnp.sqrt(1.0)
P1 = x
Ps = [P0, P1]
for k in range(1, n - 1):
Pkp1 = ((2 * k + 1) * x * Ps[k] - k * Ps[k - 1]) / (k + 1)
Ps.append(Pkp1)
P = jnp.stack(Ps[:n], axis=-1) # (..., n)
scale = jnp.sqrt(2.0 * jnp.arange(n, dtype=s.dtype) + 1.0)
return P * scale
# ----------------------------
# 4) Config
# ----------------------------
@dataclass
class Config:
T: int = 30_000
dt: float = 1.0
n: int = 16
m: int = 128
measure: str = "legt"
base_timescale: float = 5.0
hippo_base_timescales: Tuple[float, ...] = (10.0, 100.0, 1000.0, 10000.0)
epsilon: float = 1e-4
num_iters: int = 64
num_lags: int = 20
num_samps: int = 128 # samples in [0,1] for reconstruction
lag_min: int = 10
lag_max: int = 30000
# Signal
ou_components: int = 20
tau_min: float = 2.0
tau_max: float = 2000.0
# ----------------------------
# 5) Run: roll out systems and evaluate MSE-vs-lag
# ----------------------------
def run(seed: int = 0):
cfg = Config()
key = jr.PRNGKey(seed)
# HiPPO continuous params
(A, b), _, _ = get_system_params(cfg.measure, cfg.n)
print("A", A.shape, "b", b.shape)
A = (A / cfg.base_timescale).astype(jnp.float32)
b = (b / cfg.base_timescale).astype(jnp.float32)
(A_nm, b_nm), _, _ = get_system_params(cfg.measure, cfg.n)
A_nm = A_nm.astype(jnp.float32)
b_nm = b_nm.astype(jnp.float32)
# Multiscale (c)
C = legendre_shifted_mult_by_c_matrix(cfg.m).astype(jnp.float32)
print("C", C.shape)
B = build_B_from_b(b, cfg.m).astype(jnp.float32)
K_ms = jnp.kron(C.T, A)
g_ms = vecT(B)
Ams_d, Bms_d = zoh_discretize_dense(K_ms, g_ms, dt=cfg.dt)
# Multiscale (Jeffreys)
C_jeff = jeffreys_mult_by_g_c_matrix_discrete(cfg.m, cfg.epsilon, Q=8192, dtype=jnp.float64).astype(jnp.float32)
B_jeff = build_B_from_b_jeffreys(b, cfg.m, cfg.epsilon).astype(jnp.float32)
K_ms_jeff = jnp.kron(C_jeff.T, A)
g_ms_jeff = vecT(B_jeff)
Amsj_d, Bmsj_d = zoh_discretize_dense(K_ms_jeff, g_ms_jeff, dt=cfg.dt)
# Multiscale (log-g)
G = build_G_log_g(cfg.m, cfg.epsilon, dtype=jnp.float64).astype(jnp.float32)
B_log = build_B_from_b_log_g(b, cfg.m, cfg.epsilon, dtype=jnp.float64).astype(jnp.float32)
K_ms_log = jnp.kron(G.T, A)
g_ms_log = vecT(B_log)
Amsg_d, Bmsg_d = zoh_discretize_dense(K_ms_log, g_ms_log, dt=cfg.dt)
# Regular HiPPO at multiple base timescales
hippo_base_timescales = jnp.array(cfg.hippo_base_timescales, dtype=jnp.float32)
def hippo_one(scale):
Kk = (A_nm / scale).astype(jnp.float32)
gk = (b_nm / scale).astype(jnp.float32)
return zoh_discretize_dense(Kk, gk, dt=cfg.dt)
Ahp_d, Bhp_d = jax.vmap(hippo_one)(hippo_base_timescales)
# Rollout step (stores states after ingesting u_t)
@jit
def step(carry, u_t):
s_ms, s_msj, s_msg, s_hp = carry
s_ms_next = Ams_d @ s_ms + Bms_d * u_t
s_msj_next = Amsj_d @ s_msj + Bmsj_d * u_t
s_msg_next = Amsg_d @ s_msg + Bmsg_d * u_t
s_hp_next = jnp.einsum("hnm,hm->hn", Ahp_d, s_hp) + Bhp_d * u_t
return (s_ms_next, s_msj_next, s_msg_next, s_hp_next), (s_ms_next, s_msj_next, s_msg_next, s_hp_next)
# Lags (log-spaced, unique, >= lag_min and <= lag_max)
lag_min = max(1, int(cfg.lag_min))
lag_max = min(int(cfg.lag_max), cfg.T - 1)
lags = jnp.exp(jnp.linspace(jnp.log(float(lag_min)), jnp.log(float(lag_max)), cfg.num_lags))
lags = jnp.unique(jnp.clip(jnp.round(lags).astype(jnp.int32), lag_min, lag_max))
ms_sum = jnp.zeros((lags.shape[0],), dtype=jnp.float64)
msj_sum = jnp.zeros((lags.shape[0],), dtype=jnp.float64)
msg_sum = jnp.zeros((lags.shape[0],), dtype=jnp.float64)
hp_sum = jnp.zeros((len(cfg.hippo_base_timescales), lags.shape[0]), dtype=jnp.float64)
ms_sum_sq = jnp.zeros((lags.shape[0],), dtype=jnp.float64)
msj_sum_sq = jnp.zeros((lags.shape[0],), dtype=jnp.float64)
msg_sum_sq = jnp.zeros((lags.shape[0],), dtype=jnp.float64)
hp_sum_sq = jnp.zeros((len(cfg.hippo_base_timescales), lags.shape[0]), dtype=jnp.float64)
oracle_sum = jnp.zeros((lags.shape[0],), dtype=jnp.float64)
oracle_sum_sq = jnp.zeros((lags.shape[0],), dtype=jnp.float64)
# Precompute s-grid and Legendre values on [0,1]
s_grid = jnp.linspace(0.0, 1.0, cfg.num_samps, dtype=jnp.float32) # (S,)
L_grid_flip = shifted_legendre_orthonormal_vals(1.0 - s_grid, cfg.n).astype(jnp.float32) # (S,n)
# Oracle projection matrix P = Phi @ (Phi.T @ Phi)^{-1} @ Phi.T shape (S, S)
Phi_oracle = L_grid_flip.astype(jnp.float64) # (S, n)
P_oracle = Phi_oracle @ jnp.linalg.solve(Phi_oracle.T @ Phi_oracle, Phi_oracle.T) # (S, S)
# Helper: linear interpolation of signal at fractional indices
def interp_signal(sig: jax.Array, t_float: jax.Array) -> jax.Array:
"""
sig: (T,)
t_float: (...,) float time index
Returns sig(t_float) via linear interpolation, with out-of-range -> 0.
"""
T = sig.shape[0]
t0 = jnp.floor(t_float).astype(jnp.int32)
t1 = t0 + 1
w = t_float - t0.astype(t_float.dtype)
in0 = (t0 >= 0) & (t0 < T)
in1 = (t1 >= 0) & (t1 < T)
t0c = jnp.clip(t0, 0, T - 1)
t1c = jnp.clip(t1, 0, T - 1)
v0 = sig[t0c] * in0.astype(sig.dtype)
v1 = sig[t1c] * in1.astype(sig.dtype)
return (1.0 - w) * v0 + w * v1
# Helper: compute per-lag MSE for one coefficient vector c (n,) representing [T..T-lag]
def mse_for_coeffs_over_lag(sig: jax.Array, coeff: jax.Array, lag: int) -> jax.Array:
"""
coeff: (n,) in shifted orthonormal Legendre basis over s\in[0,1]
s=0 at time T (index T-1), s=1 at time T-lag.
Compare on 128 s-grid points.
"""
# predicted curve on s_grid
yhat = L_grid_flip @ coeff # (S,)
# true curve: time(s) = (T-1) - s*lag
Tlast = sig.shape[0] - 1
t_float = Tlast - s_grid.astype(jnp.float32) * float(lag)
ytrue = interp_signal(sig, t_float) # (S,)
return jnp.mean((yhat.astype(jnp.float64) - ytrue.astype(jnp.float64)) ** 2)
def mse_for_coeffs_over_lag_regular(sig, coeff, tau, lag):
tau = jnp.asarray(tau, jnp.float32)
lagf = jnp.asarray(lag, jnp.float32)
Tlast = sig.shape[0] - 1
# Evaluate over the *lag* window
t_float = Tlast - s_grid * lagf
ytrue = interp_signal(sig, t_float)
# Map those times into the HiPPO window coordinate
s_tau = (Tlast - t_float) / tau # = s_grid * lag/tau
valid = (s_tau >= 0.0) & (s_tau <= 1.0)
s_eval = 1.0 - s_tau
L_hp = shifted_legendre_orthonormal_vals(jnp.clip(s_eval, 0.0, 1.0), cfg.n).astype(jnp.float32)
yhat_in = L_hp @ coeff # defined everywhere but only meaningful on valid
yhat = jnp.where(valid, yhat_in, 0.0) # zero prediction outside window
return jnp.mean((yhat.astype(jnp.float64) - ytrue.astype(jnp.float64))**2)
def oracle_mse_for_lag(sig: jax.Array, lag: int) -> jax.Array:
"""MSE of the best possible projection of the true signal onto the n-Legendre basis."""
Tlast = sig.shape[0] - 1
t_float = Tlast - s_grid.astype(jnp.float32) * float(lag)
ytrue = interp_signal(sig, t_float).astype(jnp.float64) # (S,)
yhat = P_oracle @ ytrue # (S,)
return jnp.mean((ytrue - yhat) ** 2)
# Main Monte Carlo loop
example = None
for _itr in tqdm(range(cfg.num_iters)):
key, subkey = jr.split(key)
signal, _taus = sample_ou_mixture_1d(
subkey,
cfg.T,
cfg.ou_components,
cfg.tau_min,
cfg.tau_max,
dt=cfg.dt,
)
# Roll out states across the whole signal
s_ms0 = jnp.zeros((cfg.n * cfg.m,), dtype=jnp.float32)
s_msj0 = jnp.zeros((cfg.n * cfg.m,), dtype=jnp.float32)
s_msg0 = jnp.zeros((cfg.n * cfg.m,), dtype=jnp.float32)
s_hp0 = jnp.zeros((len(cfg.hippo_base_timescales), cfg.n), dtype=jnp.float32)
(s_ms_T, s_msj_T, s_msg_T, s_hp_T), states = scan(
step, (s_ms0, s_msj0, s_msg0, s_hp0), signal
)
# Use final states at T-1
S_ms_T = unvecT(s_ms_T, cfg.n, cfg.m) # (n,m)
S_msj_T = unvecT(s_msj_T, cfg.n, cfg.m)
S_msg_T = unvecT(s_msg_T, cfg.n, cfg.m)
# s_hp_T: (H,n)
if _itr == 0:
example = {
"signal": np.array(signal),
"s_ms_T": np.array(s_ms_T),
"s_msj_T": np.array(s_msj_T),
"s_msg_T": np.array(s_msg_T),
"s_hp_T": np.array(s_hp_T),
}
# For each lag, extract coefficients and compute MSE
for li in range(lags.shape[0]):
lag = int(lags[li])
# Multiscale (c): interpret the *lag window* using timescale-axis coordinate c = base_timescale / lag,
# clipped into [0,1] since the c-basis is on [0,1].
c = float(cfg.base_timescale) / float(lag)
c = float(np.clip(c, 0.0, 1.0))
q_c = shifted_legendre_orthonormal_vals(jnp.array([c], dtype=jnp.float32), cfg.m)[0] # (m,)
coeff_ms = (unvecT(s_ms_T, cfg.n, cfg.m) @ q_c).astype(jnp.float32) # (n,)
# Multiscale (Jeffreys g): g = base_timescale / lag, clipped into [eps,1]
g = float(cfg.base_timescale) / float(lag)
g = float(np.clip(g, float(cfg.epsilon), 1.0))
# Evaluate Jeffreys basis via Clenshaw by feeding one-hot coeffs (cheap at m=16)
eye_m = jnp.eye(cfg.m, dtype=jnp.float32) # (m,m)
a_rec, b_rec = jeffreys_recurrence_discrete(cfg.m, cfg.epsilon, Q=8192, dtype=jnp.float64)
a_rec = a_rec.astype(jnp.float32)
b_rec = b_rec.astype(jnp.float32)
q_g = jeffreys_eval_basis(g=jnp.array(g, dtype=jnp.float32), a=a_rec, b=b_rec, eps=cfg.epsilon, m=cfg.m) # (m,)
coeff_msj = unvecT(s_msj_T, cfg.n, cfg.m) @ q_g # (n,)
# Multiscale (log-g): u = log(g) in [log eps, 0], use mapped Legendre on u
# We'll implement basis eval in u by mapping to x in [-1,1] and using orthonormal Legendre.
u0 = float(np.log(cfg.epsilon))
L = -u0
u_val = float(np.log(g))
x = (2.0 * u_val - u0) / L # in [-1,1]
# compute orthonormal Legendre phi_n(x)=sqrt((2n+1)/2)P_n(x), then scale by sqrt(2/L) for uniform-u
# reuse shifted_legendre_orthonormal_vals by mapping: shifted on [0,1] isn't convenient; do direct P_n.
# We'll do direct recurrence for P_n at scalar x.
xj = jnp.array(x, dtype=jnp.float32)
P = [jnp.array(1.0, dtype=jnp.float32)]
if cfg.m >= 2:
P.append(xj)
for k in range(1, cfg.m - 1):
Pkp1 = ((2 * k + 1) * xj * P[k] - k * P[k - 1]) / (k + 1)
P.append(Pkp1)
P = jnp.stack(P[:cfg.m], axis=0) # (m,)
phi_scale = jnp.sqrt((2.0 * jnp.arange(cfg.m, dtype=jnp.float32) + 1.0) / 2.0)
phi = P * phi_scale
q_u = jnp.sqrt(2.0 / L) * phi # (m,)
coeff_msg = (unvecT(s_msg_T, cfg.n, cfg.m) @ q_u).astype(jnp.float32)
# MSEs
mse_ms = mse_for_coeffs_over_lag(signal, coeff_ms, lag)
mse_msj = mse_for_coeffs_over_lag(signal, coeff_msj, lag)
mse_msg = mse_for_coeffs_over_lag(signal, coeff_msg, lag)
ms_sum = ms_sum.at[li].add(mse_ms)
msj_sum = msj_sum.at[li].add(mse_msj)
msg_sum = msg_sum.at[li].add(mse_msg)
ms_sum_sq = ms_sum_sq.at[li].add(mse_ms**2)
msj_sum_sq = msj_sum_sq.at[li].add(mse_msj**2)
msg_sum_sq = msg_sum_sq.at[li].add(mse_msg**2)
mse_oracle = oracle_mse_for_lag(signal, lag)
oracle_sum = oracle_sum.at[li].add(mse_oracle)
oracle_sum_sq = oracle_sum_sq.at[li].add(mse_oracle ** 2)
# Regular HiPPO systems
for hi in range(len(cfg.hippo_base_timescales)):
tau = float(cfg.hippo_base_timescales[hi])
coeff_hp = s_hp_T[hi].astype(jnp.float32) # (n,)
mse_hp = mse_for_coeffs_over_lag_regular(signal, coeff_hp, tau=tau, lag=lag)
hp_sum = hp_sum.at[hi, li].add(mse_hp)
hp_sum_sq = hp_sum_sq.at[hi, li].add(mse_hp**2)
num_iters = float(cfg.num_iters)
ms_avg_mses = ms_sum / num_iters
msj_avg_mses = msj_sum / num_iters
msg_avg_mses = msg_sum / num_iters
hp_avg_mses = hp_sum / num_iters
ms_sem = jnp.sqrt(jnp.maximum(ms_sum_sq / num_iters - ms_avg_mses**2, 0.0) / num_iters)
msj_sem = jnp.sqrt(jnp.maximum(msj_sum_sq / num_iters - msj_avg_mses**2, 0.0) / num_iters)
msg_sem = jnp.sqrt(jnp.maximum(msg_sum_sq / num_iters - msg_avg_mses**2, 0.0) / num_iters)
hp_sem = jnp.sqrt(jnp.maximum(hp_sum_sq / num_iters - hp_avg_mses**2, 0.0) / num_iters)
oracle_avg_mses = oracle_sum / num_iters
oracle_sem = jnp.sqrt(jnp.maximum(oracle_sum_sq / num_iters - oracle_avg_mses**2, 0.0) / num_iters)
return np.array(lags), (
np.array(ms_avg_mses),
np.array(msj_avg_mses),
np.array(msg_avg_mses),
np.array(hp_avg_mses),
np.array(ms_sem),
np.array(msj_sem),
np.array(msg_sem),
np.array(hp_sem),
np.array(oracle_avg_mses),
np.array(oracle_sem),
), example
# ----------------------------
# 6) Plot
# ----------------------------
def save_mse_npz(out, filepath: str = "mse_results.npz") -> None:
lags, (ms_mse, msj_mse, msg_mse, hp_mse, ms_sem, msj_sem, msg_sem, hp_sem, oracle_mse, oracle_sem), example = out
save_kwargs = dict(
lags=np.array(lags),
ms_mse=np.array(ms_mse),
msj_mse=np.array(msj_mse),
msg_mse=np.array(msg_mse),
hp_mse=np.array(hp_mse),
ms_sem=np.array(ms_sem),
msj_sem=np.array(msj_sem),
msg_sem=np.array(msg_sem),
hp_sem=np.array(hp_sem),
oracle_mse=np.array(oracle_mse),
oracle_sem=np.array(oracle_sem),
)
if example is not None:
for k, v in example.items():
save_kwargs[f"example_{k}"] = np.array(v)
np.savez(filepath, **save_kwargs)
print(f"Saved MSE data to {filepath}")
def load_mse_npz(filepath: str = "mse_results.npz"):
data = np.load(filepath)
lags = data["lags"]
ms_mse = data["ms_mse"]
msj_mse = data["msj_mse"]
msg_mse = data["msg_mse"]
hp_mse = data["hp_mse"]
ms_sem = data["ms_sem"]
msj_sem = data["msj_sem"]
msg_sem = data["msg_sem"]
hp_sem = data["hp_sem"]
oracle_mse = data["oracle_mse"]
oracle_sem = data["oracle_sem"]
example_keys = ["signal", "s_ms_T", "s_msj_T", "s_msg_T", "s_hp_T"]
if all(f"example_{k}" in data for k in example_keys):
example = {k: data[f"example_{k}"] for k in example_keys}
else:
example = None
out = (
lags,
(ms_mse, msj_mse, msg_mse, hp_mse,
ms_sem, msj_sem, msg_sem, hp_sem,
oracle_mse, oracle_sem),
example,
)
return out
def plot_combined(
out,
cfg: Config,
plot_reconstructions: bool = True,
plot_other_multiscale: bool = False,
filename: str = "multiscale_figure.png",
):
lags, (ms_mse, msj_mse, msg_mse, hp_mse, ms_sem, msj_sem, msg_sem, hp_sem, oracle_mse, oracle_sem), example = out
if plot_reconstructions and example is None:
raise ValueError("Expected example data for reconstruction plot.")
multiscale_colors = {
"c": "tab:orange",
"jeffreys": "tab:green",
"log_g": "tab:red",
}
hippo_colors = plt.cm.Blues(np.linspace(0.35, 0.85, len(cfg.hippo_base_timescales)))
if plot_reconstructions:
fig, (ax_example, ax_mse) = plt.subplots(2, 1, figsize=(5.5, 5))
else:
fig, ax_mse = plt.subplots(figsize=(8,3))
if plot_reconstructions:
signal = jnp.asarray(example["signal"])
s_msg_T = jnp.asarray(example["s_msg_T"])
s_hp_T = jnp.asarray(example["s_hp_T"])
Tlast = int(signal.shape[0] - 1)
horizons = [30, 100, 200, 300, 1000]
max_horizon = 300
t_plot = jnp.arange(Tlast - max_horizon, Tlast + 1, dtype=jnp.float32)
def s_from_time(t, horizon):
return (Tlast - t) / horizon
def log_g_coeffs_for_horizon(horizon: int) -> jax.Array:
g = float(cfg.base_timescale) / float(horizon)
g = float(np.clip(g, float(cfg.epsilon), 1.0))
u0 = float(np.log(cfg.epsilon))
L = -u0
u_val = float(np.log(g))
x = (2.0 * u_val - u0) / L
xj = jnp.array(x, dtype=jnp.float32)
P0 = jnp.array(1.0, dtype=jnp.float32)
Ps = [P0]
if cfg.m >= 2:
Ps.append(xj)
for k in range(1, cfg.m - 1):
Ps.append(((2 * k + 1) * xj * Ps[k] - k * Ps[k - 1]) / (k + 1))
P = jnp.stack(Ps[:cfg.m], axis=0)
phi_scale = jnp.sqrt((2.0 * jnp.arange(cfg.m, dtype=jnp.float32) + 1.0) / 2.0)
phi = P * phi_scale
q_u = jnp.sqrt(2.0 / L) * phi
return (unvecT(s_msg_T, cfg.n, cfg.m) @ q_u).astype(jnp.float32)
t_np = np.array(t_plot).astype(np.int32)
t_fig = t_np - np.max(t_np)
true_signal = np.array(signal)[t_np]
ax_example.plot(t_fig, true_signal, color="k", alpha=0.7, linewidth=1.5, label="True signal")
red_shades = plt.cm.Reds(np.linspace(0.35, 0.9, len(horizons)))
for horizon, color in zip(horizons, red_shades):
s_win = s_from_time(t_plot, float(horizon))
valid = (s_win >= 0.0) & (s_win <= 1.0)
# Make a safe s for basis evaluation (anything inside [0,1] works for invalid points)
s_safe = jnp.where(valid, s_win, 0.0)
L_plot = shifted_legendre_orthonormal_vals(1.0 - s_safe, cfg.n).astype(jnp.float32)
coeff_msg = log_g_coeffs_for_horizon(horizon)
y_msg = L_plot @ coeff_msg
# Now mask for plotting (NaNs break the line outside valid)
y_msg = jnp.where(valid, y_msg, jnp.nan)
label = "Multiscale HiPPO" if horizon == 200 else None
ax_example.plot(
t_fig,
y_msg,
color=color,
alpha=0.9,
lw=2.0,
label=label,
)
for hi, tau in enumerate(cfg.hippo_base_timescales):
if tau < 100 or tau > 1000:
continue
s_raw = (Tlast - t_plot) / tau
valid = (s_raw >= 0.0) & (s_raw <= 1.0)
s_eval = (1.0 - s_raw)
L_hp = shifted_legendre_orthonormal_vals(jnp.clip(s_eval, 0.0, 1.0), cfg.n)
y_hp = L_hp @ s_hp_T[hi]
y_hp = np.where(np.array(valid), np.array(y_hp), np.nan)
label = "Vanilla HiPPOs" if tau == 100 else None
ax_example.plot(
t_fig,
y_hp,
linestyle="--",
alpha=0.9,
lw=2.0,
color=hippo_colors[hi],
label=label,
)
ax_example.set_xlabel("Relative Time")
ax_example.set_ylabel("Signal")
ax_example.set_ylim(-1.3, 3)
ax_example.set_title("Signal Reconstruction")
ax_example.grid(True, alpha=0.25)
ax_example.legend(frameon=False, ncol=1)
if plot_other_multiscale:
ax_mse.plot(
lags,
ms_mse,
color=multiscale_colors["c"],
label="Multiscale (Basic)",
)
ax_mse.fill_between(
lags,
ms_mse - ms_sem,
ms_mse + ms_sem,
color=multiscale_colors["c"],
alpha=0.2,
linewidth=0,
)
ax_mse.plot(
lags,
msj_mse,
color=multiscale_colors["jeffreys"],
label="Multiscale (Jeffreys-style)",
)
ax_mse.fill_between(
lags,
msj_mse - msj_sem,
msj_mse + msj_sem,
color=multiscale_colors["jeffreys"],
alpha=0.2,
linewidth=0,
)
ax_mse.plot(
lags,
msg_mse,
color=multiscale_colors["log_g"],
# label="Multiscale (Log-Timescale)",
)
ax_mse.fill_between(
lags,
msg_mse - msg_sem,
msg_mse + msg_sem,
color=multiscale_colors["log_g"],
alpha=0.2,
linewidth=0,
)
if plot_other_multiscale:
ax_mse.legend(frameon=False, ncol=1)
for hi, tau in enumerate(cfg.hippo_base_timescales):
ax_mse.plot(
lags,
hp_mse[hi],
linestyle="--",
color=hippo_colors[hi],
# label=f"HiPPO (base={tau:g})",
)
ax_mse.fill_between(
lags,
hp_mse[hi] - hp_sem[hi],
hp_mse[hi] + hp_sem[hi],
color=hippo_colors[hi],
alpha=0.2,
linewidth=0,
)
ax_mse.plot(
lags,
oracle_mse,
color="gray",
linestyle="-",
linewidth=1.5,
)
ax_mse.fill_between(
lags,
oracle_mse - oracle_sem,
oracle_mse + oracle_sem,
color="gray",
alpha=0.2,
linewidth=0,
)
custom_lines = [
Line2D([0], [0], color="tab:blue", lw=2.0, linestyle="--"),
Line2D([0], [0], color="tab:red", lw=2.0),
Line2D([0], [0], color="gray", lw=1.5),
]
ax_mse.legend(
custom_lines,
["Vanilla HiPPOs", "Multiscale HiPPO", "Oracle Error"],
loc="lower right",
frameon=False,
)
ax_mse.set_xscale("log")
ax_mse.set_ylim(0, 1.06)
ax_mse.set_xlabel("Horizon")
ax_mse.set_ylabel("Average MSE")
ax_mse.set_title("Reconstruction error from 16 Legendre coeffs")
ax_mse.grid(True, which="both", alpha=0.25)
fig.tight_layout()
fig.savefig(filename, dpi=200)
fig.savefig("multiscale_figure.pdf")
print(f"Saved combined plot to {filename}")
if __name__ == "__main__":
# cfg = Config()
# out = run(seed=0)
# save_mse_npz(out)
# plot_combined(out, cfg, plot_reconstructions=True, plot_other_multiscale=False)
cfg = Config()
out = load_mse_npz()
plot_combined(out, cfg, plot_reconstructions=True, plot_other_multiscale=False)