| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| from __future__ import annotations |
|
|
| import math |
| from dataclasses import dataclass |
| from typing import Optional, Tuple |
|
|
| import numpy as np |
| import jax |
| import jax.numpy as jnp |
| import flax.linen as nn |
| import optax |
| from flax.training import train_state |
|
|
|
|
| |
| |
| |
|
|
| def zscore(x: np.ndarray, eps: float = 1e-6) -> Tuple[np.ndarray, Tuple[np.ndarray, np.ndarray]]: |
| x = np.asarray(x, dtype=np.float32) |
| mu = x.mean(axis=0, keepdims=True) |
| sd = x.std(axis=0, keepdims=True) + eps |
| return (x - mu) / sd, (mu, sd) |
|
|
|
|
| def _as_2d(X: np.ndarray) -> np.ndarray: |
| X = np.asarray(X, dtype=np.float32) |
| if X.ndim == 1: |
| X = X[:, None] |
| return X |
|
|
|
|
| def choose_heads(d_model: int, target_head_dim: int = 32, max_heads: int = 8) -> int: |
| """Pick a reasonable number of heads given d_model.""" |
| n_heads = max(1, min(max_heads, d_model // target_head_dim)) |
| while d_model % n_heads != 0 and n_heads > 1: |
| n_heads -= 1 |
| return int(max(1, n_heads)) |
|
|
| def make_windows_multihorizon(X: np.ndarray, L: int, H: int): |
| """ |
| Build multihorizon dataset: |
| X_in[i] = X[i : i+L] (L, D) |
| Y_out[i] = X[i+L : i+L+H] (H, D) |
| |
| Returns: |
| X_in : (N, L, D) |
| Y_out: (N, H, D) |
| """ |
| X = _as_2d(X) |
| T, D = X.shape |
| if T < L + H + 1: |
| raise ValueError(f"Need T >= L+H+1. Got T={T}, L={L}, H={H}.") |
| N = T - (L + H) + 1 |
|
|
| X_in = np.stack([X[i : i + L] for i in range(N)], axis=0) |
| Y_out = np.stack([X[i + L : i + L + H] for i in range(N)], axis=0) |
| return np.ascontiguousarray(X_in), np.ascontiguousarray(Y_out) |
|
|
| |
| |
| |
|
|
| class MultiHeadSelfAttentionX(nn.Module): |
| d_model: int |
| n_heads: int |
| dropout: float = 0.0 |
|
|
| @nn.compact |
| def __call__(self, x, deterministic: bool): |
| """ |
| x: (B, T, d_model) |
| output: (B, T, d_model) |
| """ |
| B, T, Dm = x.shape |
| assert Dm == self.d_model |
| assert self.d_model % self.n_heads == 0 |
| head_dim = self.d_model // self.n_heads |
|
|
| qkv = nn.Dense(3 * self.d_model, use_bias=False, name="qkv")(x) |
| qkv = qkv.reshape(B, T, 3, self.n_heads, head_dim) |
| qkv = qkv.transpose(2, 0, 3, 1, 4) |
| q, k, v = qkv[0], qkv[1], qkv[2] |
|
|
| scale = 1.0 / math.sqrt(head_dim) |
| attn_logits = jnp.einsum("bhqd,bhkd->bhqk", q, k) * scale |
| attn = nn.softmax(attn_logits, axis=-1) |
| attn = nn.Dropout(rate=self.dropout)(attn, deterministic=deterministic) |
|
|
| out = jnp.einsum("bhqk,bhkd->bhqd", attn, v) |
| out = out.transpose(0, 2, 1, 3).reshape(B, T, self.d_model) |
| out = nn.Dense(self.d_model, name="out_proj")(out) |
| out = nn.Dropout(rate=self.dropout)(out, deterministic=deterministic) |
| return out |
|
|
| class MultiHeadSelfAttentionXX(nn.Module): |
| d_model: int |
| n_heads: int |
| dropout: float = 0.0 |
|
|
| |
| use_ctx_cam: bool = False |
| cam_tau: float = 1.0 |
| cam_init_scale: float = 0.0 |
|
|
| @nn.compact |
| def __call__(self, x, deterministic: bool): |
| """ |
| x: (B, T, d_model) |
| """ |
| B, T, Dm = x.shape |
| assert Dm == self.d_model |
| assert self.d_model % self.n_heads == 0 |
| hd = self.d_model // self.n_heads |
|
|
| qkv = nn.Dense(3 * self.d_model, use_bias=False, name="qkv")(x) |
| qkv = qkv.reshape(B, T, 3, self.n_heads, hd).transpose(2, 0, 3, 1, 4) |
| q, k, v = qkv[0], qkv[1], qkv[2] |
|
|
| scale = 1.0 / math.sqrt(hd) |
| attn_logits = jnp.einsum("bhid,bhjd->bhij", q, k) * scale |
| A_plus = nn.softmax(attn_logits, axis=-1) |
| A_plus = nn.Dropout(rate=self.dropout)(A_plus, deterministic=deterministic) |
|
|
| |
| ctx = jnp.einsum("bhij,bhjd->bhid", A_plus, v) |
|
|
| |
| out = ctx.transpose(0, 2, 1, 3).reshape(B, T, self.d_model) |
|
|
| |
| if self.use_ctx_cam: |
| |
| S = jnp.einsum("btx,bty->bxy", out, out) / jnp.maximum(T, 1) |
|
|
| |
| C_minus = nn.softmax(S / self.cam_tau, axis=-2) |
|
|
| mixed = jnp.einsum("btx,bxy->bty", out, C_minus) |
|
|
| |
| alpha = self.param( |
| "ctx_cam_scale", |
| lambda k, s: jnp.array(self.cam_init_scale, jnp.float32), |
| (), |
| ) |
| out = out + alpha * (mixed - out) |
|
|
| out = nn.Dense(self.d_model, name="out_proj")(out) |
| out = nn.Dropout(rate=self.dropout)(out, deterministic=deterministic) |
| return out |
|
|
| class MultiHeadSelfAttention(nn.Module): |
| d_model: int |
| n_heads: int |
| dropout: float = 0.0 |
|
|
| |
| use_ctx_cam: bool = False |
| cam_tau: float = 1.0 |
| cam_init_scale: float = 0.0 |
|
|
| @nn.compact |
| def __call__(self, x, deterministic: bool): |
| """ |
| x: (B, T, d_model) |
| """ |
| B, T, Dm = x.shape |
| assert Dm == self.d_model |
| assert self.d_model % self.n_heads == 0 |
| hd = self.d_model // self.n_heads |
|
|
| qkv = nn.Dense(3 * self.d_model, use_bias=False, name="qkv")(x) |
| qkv = qkv.reshape(B, T, 3, self.n_heads, hd).transpose(2, 0, 3, 1, 4) |
| q, k, v = qkv[0], qkv[1], qkv[2] |
|
|
| scale = 1.0 / math.sqrt(hd) |
| attn_logits = jnp.einsum("bhid,bhjd->bhij", q, k) * scale |
| A_plus = nn.softmax(attn_logits, axis=-1) |
| A_plus = nn.Dropout(rate=self.dropout)(A_plus, deterministic=deterministic) |
|
|
| |
| ctx = jnp.einsum("bhij,bhjd->bhid", A_plus, v) |
|
|
| |
| out = ctx.transpose(0, 2, 1, 3).reshape(B, T, self.d_model) |
|
|
| |
| if self.use_ctx_cam: |
| |
| S = jnp.einsum("btx,bty->bxy", out, out) / jnp.maximum(T, 1) |
| S2 = jnp.einsum("bii->bi", S) |
| S = S2[:,None,:] + S2[:,:,None] - 2*S |
| S = -1.*S |
|
|
| |
| C_minus = nn.softmax(S / self.cam_tau, axis=-2) |
|
|
| |
| |
| |
| |
| out = jnp.einsum("btx,bxy->bty", out, C_minus) |
|
|
| out = nn.Dense(self.d_model, name="out_proj")(out) |
| out = nn.Dropout(rate=self.dropout)(out, deterministic=deterministic) |
| return out |
|
|
| class ChannelSelfAttention(nn.Module): |
| d_model: int |
| tau: float = 1.0 |
| col_norm: bool = True |
| init_scale: float = 0.0 |
|
|
| @nn.compact |
| def __call__(self, x, deterministic: bool): |
| B, T, D = x.shape |
|
|
| S = jnp.einsum("btd,bte->bde", x, x) / jnp.maximum(T, 1) |
| C_row = nn.softmax(S / self.tau, axis=-1) |
| C = jnp.swapaxes(C_row, -1, -2) if self.col_norm else C_row |
|
|
| scale = self.param( |
| "scale", |
| lambda k, s: jnp.array(self.init_scale, jnp.float32), |
| (), |
| ) |
|
|
| |
| return scale * ((x @ C) - x) |
|
|
| class FeedForward(nn.Module): |
| d_model: int |
| mlp_ratio: float = 4.0 |
| dropout: float = 0.0 |
|
|
| @nn.compact |
| def __call__(self, x, deterministic: bool): |
| hidden = int(self.d_model * self.mlp_ratio) |
| x = nn.Dense(hidden)(x) |
| x = nn.gelu(x) |
| x = nn.Dropout(rate=self.dropout)(x, deterministic=deterministic) |
| x = nn.Dense(self.d_model)(x) |
| x = nn.Dropout(rate=self.dropout)(x, deterministic=deterministic) |
| return x |
|
|
| class TransformerEncoderBlock(nn.Module): |
| d_model: int |
| n_heads: int |
| dropout: float = 0.0 |
| mlp_ratio: float = 4.0 |
|
|
| |
| use_ctx_cam: bool = False |
| use_ffn: bool = True |
|
|
| |
| cam_tau: float = 1.0 |
| cam_init_scale: float = 0.0 |
|
|
| @nn.compact |
| def __call__(self, x, deterministic: bool): |
| |
| h = nn.LayerNorm(name="attn_ln")(x) |
| h = MultiHeadSelfAttention( |
| d_model=self.d_model, |
| n_heads=self.n_heads, |
| dropout=self.dropout, |
| use_ctx_cam=self.use_ctx_cam, |
| cam_tau=self.cam_tau, |
| cam_init_scale=self.cam_init_scale, |
| name="mhsa", |
| )(h, deterministic=deterministic) |
| x = x + h |
|
|
| |
| if self.use_ffn: |
| h2 = nn.LayerNorm(name="ffn_ln")(x) |
| h2 = FeedForward( |
| d_model=self.d_model, mlp_ratio=self.mlp_ratio, dropout=self.dropout |
| )(h2, deterministic=deterministic) |
| x = x + h2 |
|
|
| return x |
|
|
| class PTSTBackbone(nn.Module): |
| """ |
| PatchTST-style backbone for univariate series (channel-independent training). |
| |
| Input: |
| x: (B, L_eff, 1) |
| |
| Output: |
| yhat: (B, pred_len) |
| |
| Options: |
| - use_cam: enable context-derived channel mixing C^- inside MHSA (default False) |
| - use_ffn: enable FeedForward inside each encoder block (default True) |
| """ |
| L_eff: int |
| patch_len: int |
| d_model: int |
| depth: int |
| n_heads: int |
| pred_len: int |
| dropout: float = 0.0 |
| use_learned_pos: bool = True |
|
|
| |
| use_cam: bool = False |
| use_ffn: bool = True |
|
|
| |
| cam_tau: float = 1.0 |
| cam_init_scale: float = 0.0 |
|
|
| @nn.compact |
| def __call__(self, x, deterministic: bool = True): |
| B, L, C = x.shape |
| assert C == 1 |
| assert L == self.L_eff |
| assert self.L_eff % self.patch_len == 0 |
|
|
| n_patches = self.L_eff // self.patch_len |
|
|
| |
| patches = x.reshape(B, n_patches, self.patch_len) |
|
|
| |
| h = nn.Dense(self.d_model, name="patch_embed")(patches) |
|
|
| |
| if self.use_learned_pos: |
| pos = self.param( |
| "pos_emb", |
| nn.initializers.normal(stddev=0.02), |
| (n_patches, self.d_model), |
| ) |
| h = h + pos[None, :, :] |
|
|
| |
| for i in range(self.depth): |
| h = TransformerEncoderBlock( |
| d_model=self.d_model, |
| n_heads=self.n_heads, |
| dropout=self.dropout, |
| mlp_ratio=4.0, |
|
|
| use_ctx_cam=self.use_cam, |
| use_ffn=self.use_ffn, |
| cam_tau=self.cam_tau, |
| cam_init_scale=self.cam_init_scale, |
|
|
| name=f"enc_block_{i}", |
| )(h, deterministic=deterministic) |
|
|
| h = nn.LayerNorm(name="final_ln")(h) |
|
|
| |
| h_flat = h.reshape(B, -1) |
| yhat = nn.Dense(self.pred_len, name="head")(h_flat) |
| return yhat |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class PTSTConfig: |
| |
| L: int = 128 |
| patch_len: int = 16 |
| pred_len: int = 96 |
|
|
| |
| d_model: int = 128 |
| depth: int = 4 |
| n_heads: Optional[int] = None |
| dropout: float = 0.0 |
| use_learned_pos: bool = True |
|
|
| |
| use_ffn: bool = True |
| use_cam: bool = False |
|
|
| |
| cam_tau: float = 1.0 |
| cam_init_scale: float = 0.0 |
|
|
| |
| seed: int = 0 |
| val_split: float = 0.1 |
| batch_size: int = 128 |
| max_epochs: int = 50 |
| init_lr: float = 3e-4 |
| min_lr: float = 1e-5 |
| lr_decay: float = 0.5 |
| tol_rel_improve: float = 1e-3 |
| patience: int = 5 |
|
|
| class PTST: |
| """ |
| PTST = PatchTST-style forecaster with TART-like API. |
| |
| Usage: |
| model = PTST(F_train, L=160, patch_len=16, pred_len=120, d_model=128, depth=4) |
| preds = model(prefix, steps=120) |
| |
| Notes: |
| - Default is channel-independent: one univariate backbone shared across all dims. |
| - Trains multi-horizon (pred_len) directly. |
| - For steps > pred_len, it rolls forward in pred_len chunks. |
| """ |
|
|
| def __init__(self, R_tX: np.ndarray, **kwargs): |
| cfg = PTSTConfig(**kwargs) |
| self.cfg = cfg |
|
|
| R_tX = _as_2d(R_tX) |
| T, D = R_tX.shape |
| self.D = int(D) |
|
|
| |
| L = int(cfg.L) |
| P = int(cfg.patch_len) |
| if L < P: |
| raise ValueError(f"L={L} must be >= patch_len={P}") |
| L_eff = (L // P) * P |
| if L_eff != L: |
| |
| |
| print(f"[PTST] warning: L={L} not divisible by patch_len={P}. Using L_eff={L_eff}.") |
| self.L = int(L) |
| self.L_eff = int(L_eff) |
| self.patch_len = int(P) |
|
|
| pred_len = int(cfg.pred_len) |
| if T <= self.L_eff + pred_len + 1: |
| raise ValueError(f"Need T > L_eff + pred_len. Got T={T}, L_eff={self.L_eff}, pred_len={pred_len}.") |
|
|
| |
| F_norm, (mu, sd) = zscore(R_tX) |
| self._mu = mu |
| self._sd = sd |
|
|
| |
| X_in, Y_out = make_windows_multihorizon(F_norm, L=self.L_eff, H=pred_len) |
| N = X_in.shape[0] |
|
|
| |
| |
| |
| |
| if X_in.shape[1] != self.L_eff: |
| raise ValueError( |
| f"[PTST] X_in has wrong time length {X_in.shape[1]} (expected {self.L_eff}). " |
| f"This usually means window construction is wrong." |
| ) |
| |
| |
| |
| |
| X_ci = np.transpose(X_in, (0, 2, 1)) |
| X_ci = X_ci.reshape(N * D, self.L_eff)[:, :, None] |
| |
| Y_ci = np.transpose(Y_out, (0, 2, 1)) |
| Y_ci = Y_ci.reshape(N * D, pred_len) |
|
|
|
|
| |
| n_val = max(1, int(cfg.val_split * (N * D))) |
| n_tr = (N * D) - n_val |
| Xtr, Ytr = X_ci[:n_tr], Y_ci[:n_tr] |
| Xva, Yva = X_ci[n_tr:], Y_ci[n_tr:] |
|
|
| |
| d_model = int(max(32, cfg.d_model)) |
| n_heads = cfg.n_heads if cfg.n_heads is not None else choose_heads(d_model) |
| self.d_model = int(d_model) |
| self.n_heads = int(n_heads) |
| self.depth = int(cfg.depth) |
| self.pred_len = int(pred_len) |
|
|
| self.model = PTSTBackbone( |
| L_eff=self.L_eff, |
| patch_len=self.patch_len, |
| d_model=self.d_model, |
| depth=self.depth, |
| n_heads=self.n_heads, |
| pred_len=self.pred_len, |
| dropout=float(cfg.dropout), |
| use_learned_pos=bool(cfg.use_learned_pos), |
| |
| use_cam=bool(cfg.use_cam), |
| use_ffn=bool(cfg.use_ffn), |
| cam_tau=float(cfg.cam_tau), |
| cam_init_scale=float(cfg.cam_init_scale), |
|
|
| ) |
|
|
| print( |
| f"[PTST] Train samples: {N} windows -> {N*D} CI samples | " |
| f"L_eff={self.L_eff} patch_len={self.patch_len} n_patches={self.L_eff//self.patch_len} | " |
| f"D={self.D} pred_len={self.pred_len} | d_model={self.d_model} heads={self.n_heads} depth={self.depth}" |
| ) |
|
|
| rng = jax.random.PRNGKey(int(cfg.seed)) |
| dummy_x = jnp.zeros((1, self.L_eff, 1), dtype=jnp.float32) |
| params = self.model.init(rng, dummy_x, deterministic=True)["params"] |
|
|
| def create_state(lr: float): |
| tx = optax.adamw(learning_rate=lr, weight_decay=0.0) |
| return train_state.TrainState.create(apply_fn=self.model.apply, params=params, tx=tx) |
|
|
| state = create_state(float(cfg.init_lr)) |
| self._rng = rng |
|
|
| @jax.jit |
| def train_step(state, xb, yb, rng): |
| dropout_rng, new_rng = jax.random.split(rng) |
|
|
| def loss_fn(p): |
| yhat = state.apply_fn({"params": p}, xb, deterministic=False, rngs={"dropout": dropout_rng}) |
| loss = jnp.mean((yhat - yb) ** 2) |
| return loss |
|
|
| loss, grads = jax.value_and_grad(loss_fn)(state.params) |
| state = state.apply_gradients(grads=grads) |
| return state, loss, new_rng |
|
|
| @jax.jit |
| def eval_step(state, xb, yb): |
| yhat = state.apply_fn({"params": state.params}, xb, deterministic=True) |
| return jnp.mean((yhat - yb) ** 2) |
|
|
| |
| Xtr_j = jnp.asarray(Xtr) |
| Ytr_j = jnp.asarray(Ytr) |
| Xva_j = jnp.asarray(Xva) |
| Yva_j = jnp.asarray(Yva) |
|
|
| best_params = state.params |
| best_val = float("inf") |
| curr_lr = float(cfg.init_lr) |
| epochs_no_gain = 0 |
|
|
| def num_batches(n: int) -> int: |
| return int(np.ceil(n / cfg.batch_size)) |
|
|
| |
| for epoch in range(1, int(cfg.max_epochs) + 1): |
| idx = np.arange(n_tr) |
| np.random.default_rng(epoch + cfg.seed).shuffle(idx) |
|
|
| train_losses = [] |
| rng = self._rng |
|
|
| for bi in range(num_batches(n_tr)): |
| s = bi * cfg.batch_size |
| e = min((bi + 1) * cfg.batch_size, n_tr) |
| bidx = idx[s:e] |
| xb = Xtr_j[bidx] |
| yb = Ytr_j[bidx] |
| state, loss, rng = train_step(state, xb, yb, rng) |
| train_losses.append(float(loss)) |
|
|
| self._rng = rng |
| tr_loss = float(np.mean(train_losses)) |
|
|
| val_losses = [] |
| for bi in range(num_batches(n_val)): |
| s = bi * cfg.batch_size |
| e = min((bi + 1) * cfg.batch_size, n_val) |
| xb = Xva_j[s:e] |
| yb = Yva_j[s:e] |
| val_losses.append(float(eval_step(state, xb, yb))) |
| va_loss = float(np.mean(val_losses)) |
|
|
| print(f"[PTST] epoch {epoch:03d} | train {tr_loss:.6e} | val {va_loss:.6e} | lr {curr_lr:.2e}") |
|
|
| if va_loss + 1e-8 < best_val: |
| best_val = va_loss |
| best_params = state.params |
| epochs_no_gain = 0 |
| else: |
| epochs_no_gain += 1 |
|
|
| |
| if epochs_no_gain >= int(cfg.patience): |
| if curr_lr > float(cfg.min_lr) * (1.0 + 1e-9): |
| curr_lr = max(float(cfg.min_lr), curr_lr * float(cfg.lr_decay)) |
| print(f"[PTST] plateau → lowering LR to {curr_lr:.2e}") |
| params_now = state.params |
| state = train_state.TrainState.create( |
| apply_fn=state.apply_fn, |
| params=params_now, |
| tx=optax.adamw(learning_rate=curr_lr, weight_decay=0.0), |
| ) |
| epochs_no_gain = 0 |
| else: |
| print(f"[PTST] early stop: lr at min and no improvement (best val {best_val:.6e})") |
| break |
|
|
| |
| self.state = state.replace(params=best_params) |
| self.params = self.state.params |
|
|
| |
| self.param_count = int(sum(p.size for p in jax.tree_util.tree_leaves(self.params))) |
| print(f"[PTST] params: {self.param_count:,}") |
|
|
| |
| |
| |
| def _predict_block_norm(self, ctx_norm_BLD: jnp.ndarray) -> jnp.ndarray: |
| """ |
| ctx_norm_BLD: (B, L_eff, D) normalized |
| returns: (B, pred_len, D) normalized |
| """ |
| B, L, D = ctx_norm_BLD.shape |
| assert L == self.L_eff |
| assert D == self.D |
|
|
| |
| |
| x = ctx_norm_BLD.transpose(0, 2, 1)[:, :, :, None] |
| x = x.reshape(B * D, L, 1) |
| yhat = self.model.apply({"params": self.params}, x, deterministic=True) |
| yhat = yhat.reshape(B, D, self.pred_len) |
| yhat = yhat.transpose(0, 2, 1) |
| return yhat |
|
|
| |
| |
| |
| def __call__(self, prefix: np.ndarray, steps: int) -> np.ndarray: |
| """ |
| Forecast `steps` time points given prefix ending at forecast start. |
| |
| prefix: (ell, D) |
| returns: (steps, D) |
| """ |
| X = _as_2d(prefix) |
| ell, D = X.shape |
| if D != self.D: |
| raise ValueError(f"PTST expected D={self.D}, got {D}.") |
|
|
| H = int(steps) |
| if H <= 0: |
| return np.zeros((0, self.D), dtype=np.float32) |
|
|
| |
| if ell < self.L_eff: |
| pad_len = self.L_eff - ell |
| pad = np.repeat(X[:1], repeats=pad_len, axis=0) |
| ctx = np.concatenate([pad, X], axis=0) |
| else: |
| ctx = X[-self.L_eff:, :] |
|
|
| |
| ctx_n = (ctx - self._mu) / self._sd |
|
|
| preds_n_all = [] |
| n_done = 0 |
| |
| while n_done < H: |
| ctx_n_j = jnp.asarray(ctx_n[None, :, :]) |
| block_n = np.array(self._predict_block_norm(ctx_n_j)[0]) |
| |
| remaining = H - n_done |
| take = min(self.pred_len, remaining) |
| |
| preds_n_all.append(block_n[:take]) |
| n_done += take |
| |
| |
| ctx_n = np.concatenate([ctx_n, block_n[:take]], axis=0) |
| ctx_n = ctx_n[-self.L_eff:, :] |
|
|
|
|
| preds_n = np.concatenate(preds_n_all, axis=0) |
| preds = preds_n * self._sd + self._mu |
| return preds |
|
|
|
|
| __all__ = ["PTST", "PTSTConfig"] |
|
|