| """Reusable Flax model classes for HiPPO-based sequence models.""" |
| from __future__ import annotations |
|
|
| import jax |
| import jax.numpy as jnp |
| import flax.linen as nn |
|
|
| __all__ = ["AssocMemHiPPO", "MLPHiPPO", "SalienceHiPPO", "VanillaHiPPO", "legendre_orthonormal_basis01"] |
|
|
|
|
| def legendre_orthonormal_basis01(x: jnp.ndarray, n: int) -> jnp.ndarray: |
| """ |
| Orthonormal Legendre basis on [0,1] with: |
| p_k(x) = sqrt(2k+1) * (-1)^k * P_k(2x-1) |
| |
| x: scalar or array in [0,1] |
| returns: array with shape x.shape + (n,) |
| """ |
| x = jnp.asarray(x) |
| t = 2.0 * x - 1.0 |
|
|
| out_shape = t.shape + (n,) |
| P = jnp.zeros(out_shape, dtype=t.dtype) |
|
|
| P = P.at[..., 0].set(jnp.ones_like(t)) |
|
|
| if n > 1: |
| P = P.at[..., 1].set(t) |
|
|
| def body(k, P): |
| kk = jnp.asarray(k, dtype=t.dtype) |
| Pkm1 = P[..., k - 1] |
| Pk = P[..., k] |
| Pkp1 = ((2.0 * kk + 1.0) * t * Pk - kk * Pkm1) / (kk + 1.0) |
| return P.at[..., k + 1].set(Pkp1) |
|
|
| P = jax.lax.fori_loop(1, n - 1, body, P) |
|
|
| |
| idx = jnp.arange(n, dtype=t.dtype) |
| scale = jnp.sqrt(2.0 * idx + 1.0) * ((-1.0) ** idx) |
| return P * scale |
|
|
|
|
| class VanillaHiPPO(nn.Module): |
| """ |
| Minimal HiPPO step model with fixed ZOH discretization. |
| |
| Each step: |
| x_proj = W_in @ x_in # (d_in,) -> (d_model,) |
| S_next[j] = A_d @ S[j] + b_d * x_proj[j] # per channel, vectorized |
| y_hat = W_out @ S_next.reshape(-1) # (d_model * n,) -> (d_in,) |
| |
| State layout: |
| S: (d_model, n) — one HiPPO state per model dimension |
| |
| Args: |
| d_in: input / output token dimension |
| d_model: number of parallel HiPPO channels |
| n: HiPPO state order (polynomial degree) |
| A_d: (n, n) pre-discretized state-transition matrix |
| b_d: (n,) pre-discretized input vector |
| |
| Construct A_d and b_d via ``get_system_params`` + a ZOH step, e.g.:: |
| |
| (A, b), _, _ = get_system_params("legs", n) |
| A_d = jnp.array(scipy.linalg.expm(A * dt)) |
| b_d = jnp.linalg.solve(A, (A_d - I) @ b) |
| """ |
| d_in: int |
| d_model: int |
| n: int |
|
|
| A_d: jnp.ndarray |
| b_d: jnp.ndarray |
|
|
| @nn.compact |
| def __call__(self, x_in: jnp.ndarray, S: jnp.ndarray): |
| """ |
| x_in: (d_in,) |
| S: (d_model, n) |
| returns: |
| S_next: (d_model, n) |
| y_hat: (d_in,) |
| """ |
| x_proj = nn.Dense(self.d_model, use_bias=False, name="W_in")(x_in) |
|
|
| def update_channel(s_j, u_j): |
| return self.A_d @ s_j + self.b_d * u_j |
|
|
| S_next = jax.vmap(update_channel, in_axes=(0, 0), out_axes=0)(S, x_proj) |
|
|
| y_hat = nn.Dense(self.d_in, use_bias=True, name="W_out")( |
| S_next.reshape((self.d_model * self.n,)) |
| ) |
| return S_next, y_hat |
|
|
|
|
| class MLPHiPPO(nn.Module): |
| """ |
| HiPPO step model identical to VanillaHiPPO but with a single-hidden-layer |
| MLP readout instead of a linear map. |
| |
| Each step: |
| x_proj = W_in @ x_in # (d_in,) -> (d_model,) |
| S_next[j] = A_d @ S[j] + b_d * x_proj[j] # per channel, vectorized |
| h = activation(W_h @ S_next.reshape(-1)) # (d_model * n,) -> (mlp_hidden,) |
| y_hat = W_out @ h # (mlp_hidden,) -> (d_in,) |
| |
| Args: |
| d_in: input / output token dimension |
| d_model: number of parallel HiPPO channels |
| n: HiPPO state order (polynomial degree) |
| A_d: (n, n) pre-discretized state-transition matrix |
| b_d: (n,) pre-discretized input vector |
| mlp_hidden: hidden layer width (default: d_model * n) |
| """ |
| d_in: int |
| d_model: int |
| n: int |
|
|
| A_d: jnp.ndarray |
| b_d: jnp.ndarray |
|
|
| mlp_hidden: int = 0 |
|
|
| @nn.compact |
| def __call__(self, x_in: jnp.ndarray, S: jnp.ndarray): |
| """ |
| x_in: (d_in,) |
| S: (d_model, n) |
| returns: |
| S_next: (d_model, n) |
| y_hat: (d_in,) |
| """ |
| x_proj = nn.Dense(self.d_model, use_bias=False, name="W_in")(x_in) |
|
|
| def update_channel(s_j, u_j): |
| return self.A_d @ s_j + self.b_d * u_j |
|
|
| S_next = jax.vmap(update_channel, in_axes=(0, 0), out_axes=0)(S, x_proj) |
|
|
| hidden = self.mlp_hidden if self.mlp_hidden > 0 else self.d_model * self.n |
| h = nn.Dense(hidden, use_bias=True, name="W_h")( |
| S_next.reshape((self.d_model * self.n,)) |
| ) |
| h = nn.softplus(h) |
| y_hat = nn.Dense(self.d_in, use_bias=True, name="W_out")(h) |
| return S_next, y_hat |
|
|
|
|
| class SalienceHiPPO(nn.Module): |
| """ |
| Single-example step model with ZOH discretization using: |
| expm(gA) ~= expm(g0 A) @ expm(r A) |
| |
| State layout: |
| S: (d_model, n) (one HiPPO state per model dimension) |
| |
| Notes: |
| A and b should be passed as JAX arrays (not numpy arrays) at construction |
| time, as Flax treats them as static module attributes. |
| """ |
| d_in: int |
| d_model: int |
| n: int |
|
|
| A: jnp.ndarray |
| b: jnp.ndarray |
|
|
| g_max: float = 5.0 |
| sal_hidden: int = 128 |
| mem_dim: int = 32 |
|
|
| outvec_hidden: int = 128 |
|
|
| num_grid: int = 256 |
| taylor_order: int = 4 |
|
|
| def setup(self): |
| self.I_n = jnp.eye(self.n, dtype=self.A.dtype) |
|
|
| self.dg = jnp.asarray(self.g_max / self.num_grid, dtype=self.A.dtype) |
| g_grid = jnp.linspace(0.0, self.g_max, self.num_grid + 1, dtype=self.A.dtype) |
|
|
| A_grid = jax.vmap(lambda gg: jax.scipy.linalg.expm(gg * self.A))(g_grid) |
|
|
| def B_from_A_d(A_d): |
| rhs = (A_d - self.I_n) @ self.b |
| return jnp.linalg.solve(self.A, rhs) |
|
|
| B_grid = jax.vmap(B_from_A_d)(A_grid) |
|
|
| self.A_grid = A_grid |
| self.B_grid = B_grid |
|
|
| def _expm_taylor(self, r: jnp.ndarray) -> jnp.ndarray: |
| rA = r * self.A |
| I = self.I_n |
|
|
| def body(k, carry): |
| E, term = carry |
| term = (term @ rA) / jnp.asarray(k, dtype=I.dtype) |
| E = E + term |
| return (E, term) |
|
|
| E0 = I |
| term0 = I |
| E, _ = jax.lax.fori_loop(1, self.taylor_order + 1, body, (E0, term0)) |
| return E |
|
|
| def _Brem_taylor(self, r: jnp.ndarray) -> jnp.ndarray: |
| dtype = self.b.dtype |
| b_rem = jnp.zeros((self.n,), dtype=dtype) |
| Akb = self.b |
|
|
| def body(k, carry): |
| b_rem, Akb = carry |
| kk = jnp.asarray(k, dtype=dtype) |
| |
| coeff = (r ** (kk + 1.0)) / jnp.exp(jax.scipy.special.gammaln(kk + 2.0)) |
| b_rem = b_rem + coeff * Akb |
| Akb = self.A @ Akb |
| return (b_rem, Akb) |
|
|
| b_rem, _ = jax.lax.fori_loop(0, self.taylor_order + 1, body, (b_rem, Akb)) |
| return b_rem |
|
|
| def _zoh_discretize(self, g: jnp.ndarray) -> tuple[jnp.ndarray, jnp.ndarray]: |
| g = jnp.clip(g, 0.0, self.g_max) |
| idx = jnp.minimum(jnp.floor(g / self.dg).astype(jnp.int32), jnp.int32(self.num_grid)) |
| g0 = self.dg * idx.astype(self.A.dtype) |
| r = g - g0 |
|
|
| A0 = self.A_grid[idx] |
| B0 = self.B_grid[idx] |
|
|
| E = self._expm_taylor(r) |
| B_r = self._Brem_taylor(r) |
|
|
| A_d = A0 @ E |
| b_d = B0 + A0 @ B_r |
| return A_d, b_d |
|
|
| @nn.compact |
| def __call__(self, x_in: jnp.ndarray, S: jnp.ndarray): |
| """ |
| x_in: (d_in,) |
| S: (d_model, n) |
| returns: |
| S_next: (d_model, n) |
| y_hat: (d_in,) |
| g: scalar |
| out_vec: (n,) |
| """ |
| |
| x_proj = nn.Dense(self.d_model, use_bias=False, name="W_in")(x_in) |
|
|
| |
| ms = nn.Dense(self.mem_dim, name="sal_pool")(S) |
| ms = nn.tanh(ms) |
| ms = jnp.mean(ms, axis=0) |
|
|
| sal_inp = jnp.concatenate([x_proj, ms], axis=0) |
| h = nn.Dense(self.sal_hidden, name="sal_fc1")(sal_inp) |
| h = nn.softplus(h) |
| h = nn.Dense(1, name="sal_fc3")(h) |
| g = self.g_max * nn.sigmoid(h[0]) |
|
|
| A_d, b_d = self._zoh_discretize(g) |
|
|
| def update_channel(s_n, u_j): |
| return (A_d @ s_n) + (b_d * u_j) |
|
|
| S_next = jax.vmap(update_channel, in_axes=(0, 0), out_axes=0)(S, x_proj) |
|
|
| S_flat = S_next.reshape((self.d_model * self.n,)) |
| h = nn.Dense(self.outvec_hidden, use_bias=True, name="W_z_1")(S_flat) |
| h = nn.softplus(h) |
| out_vec = nn.Dense(self.n, use_bias=True, name="W_z_2")(h) |
|
|
| y_mid = jnp.einsum("ij,j->i", S_next, out_vec) |
| y_hat = nn.Dense(self.d_in, use_bias=True, name="out_proj")(y_mid) |
| return S_next, y_hat, g, out_vec |
|
|
|
|
| class AssocMemHiPPO(nn.Module): |
| """ |
| Fixed-ZOH HiPPO + banked continuous-time associative memory with: |
| 1) Key/query vectors from the SAME linear map of per-channel HiPPO state |
| 2) Explicit write gate g_write in [0,1] (interpolates between no write and full write) |
| 3) Query-based readout: q -> r = C @ q (per bank) -> linear to d_in |
| |
| State: |
| S: (d_model, n_hippo) |
| C: (d_model, n_assoc) |
| |
| HiPPO step (fixed dt=1 ZOH): |
| S_next[j] = A_d @ S[j] + b_d * x_proj[j] |
| |
| Key/query maps (shared across channels): |
| k_j = W_k @ S_next[j] in R^{n_assoc} |
| q_j = W_q @ S_next[j] in R^{n_assoc} |
| |
| Memory write (per bank j), with dt=1 exact update scaled by gate: |
| proj_j = <k_j, C_j> |
| err_j = y_j - proj_j |
| C_j <- C_j + g_write / ||k_j||^2 * err_j * k_j |
| |
| where: |
| - g_write is scalar in [0,1] from a small MLP on global state |
| - y_j is value signal per bank from a small MLP on global state |
| |
| Readout: |
| r_j = <q_j, C_j> (scalar per bank) -> r in R^{d_model} |
| y_hat = W_out @ r in R^{d_in} |
| """ |
| d_in: int |
| d_model: int |
| n_hippo: int |
| n_assoc: int |
|
|
| A_d: jnp.ndarray |
| b_d: jnp.ndarray |
|
|
| write_hidden: int |
| out_hidden: int |
|
|
| @nn.compact |
| def __call__(self, x_in: jnp.ndarray, S: jnp.ndarray, C: jnp.ndarray): |
| """ |
| x_in: (d_in,) |
| S: (d_model, n_hippo) |
| C: (d_model, n_assoc) # OP coefficients per bank |
| |
| Returns: |
| S_next: (d_model, n_hippo) |
| C_next: (d_model, n_assoc) |
| y_hat: (d_in,) |
| aux: dict(g_write, g_out, y_vec, x_key, x_query) |
| """ |
| |
| x_proj = nn.Dense(self.d_model, use_bias=False, name="W_in")(x_in) |
|
|
| |
| def update_channel(s_n, u_j): |
| return self.A_d @ s_n + self.b_d * u_j |
|
|
| S_next = jax.vmap(update_channel, in_axes=(0, 0), out_axes=0)(S, x_proj) |
|
|
| |
| S_flat = S_next.reshape((self.d_model * self.n_hippo,)) |
|
|
| g_write = nn.Dense(self.write_hidden)(S_flat) |
| g_write = nn.tanh(g_write) |
| g_write = nn.sigmoid(nn.Dense(1)(g_write) + nn.Dense(1)(S_flat))[0] |
|
|
| g_out = nn.Dense(self.out_hidden)(S_flat) |
| g_out = nn.tanh(g_out) |
| g_out = nn.sigmoid(nn.Dense(1)(g_out) + nn.Dense(1)(S_flat))[0] |
|
|
| |
| |
| y_vec = nn.Dense(self.d_model, name="val_out")(x_proj) |
|
|
| |
| |
| x_key = nn.sigmoid(nn.Dense(1, name="x_key")(S_flat)[0]) |
| x_query = nn.sigmoid(nn.Dense(1, name="x_query")(S_flat)[0]) |
|
|
| |
| K = legendre_orthonormal_basis01(x_key, self.n_assoc) |
| Q = legendre_orthonormal_basis01(x_query, self.n_assoc) |
|
|
| |
| |
| proj = C @ K |
| err = y_vec - proj |
|
|
| |
| K_norm2 = jnp.sum(K * K) + 1e-8 |
| gain = g_write / K_norm2 |
|
|
| C_next = C + gain * err[:, None] * K[None, :] |
|
|
| |
| r = C_next @ Q |
| y_hat = nn.Dense(self.d_in, use_bias=True, name="out_proj")(r) |
| y_hat = y_hat * g_out |
|
|
| aux = dict( |
| g_write=g_write, |
| g_out=g_out, |
| y_vec=y_vec, |
| x_key=x_key, |
| x_query=x_query, |
| K_norm2=K_norm2, |
| ) |
| return S_next, C_next, y_hat, aux |
|
|