| """Chronicle: a multimodal (text + time series) decoder-only transformer. |
| |
| Reference inference implementation for the released checkpoints — see the |
| model card for a verified loading and generation example. |
| """ |
|
|
| """ |
| Standalone Multimodal GPT that handles both text and time-series data. |
| |
| Key features: |
| 1. Patch projection layer to project TS patches to embedding space |
| 2. Quantile prediction head for forecasting |
| 3. Support for mixed text/TS inputs |
| 4. InstanceNorm for per-series normalization (Chronos-style) |
| 5. SwiGLU activation with 8/3 ffn multiple |
| 6. Weight tying between embeddings and lm_head |
| 7. Learnable RMSNorm |
| 8. Group Query Attention (GQA) support |
| """ |
|
|
| import math |
| from functools import partial |
| from dataclasses import dataclass |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| PATCH_LEN = 32 |
|
|
|
|
| |
| |
| |
|
|
|
|
| class RMSNorm(nn.Module): |
| """RMSNorm with learnable scale parameter (no bias).""" |
|
|
| def __init__(self, size: int): |
| super().__init__() |
| self.weight = nn.Parameter(torch.ones(size)) |
|
|
| def forward(self, x): |
| |
| norm_x = x.float() |
| rms = torch.sqrt(torch.mean(norm_x**2, dim=-1, keepdim=True) + 1e-5) |
| x_normed = norm_x / rms |
| return (self.weight * x_normed).to(x.dtype) |
|
|
|
|
| def apply_rotary_emb(x, cos, sin): |
| """Apply rotary embeddings to queries or keys.""" |
| assert x.ndim == 4 |
| d = x.shape[3] // 2 |
| x1, x2 = x[..., :d], x[..., d:] |
| y1 = x1 * cos + x2 * sin |
| y2 = x1 * (-sin) + x2 * cos |
| out = torch.cat([y1, y2], 3) |
| out = out.to(x.dtype) |
| return out |
|
|
|
|
| def norm(x): |
| """Purely functional rmsnorm with no learnable params (for QK norm).""" |
| return F.rms_norm(x, (x.size(-1),)) |
|
|
|
|
| class CausalSelfAttention(nn.Module): |
| """Multi-head or Group Query Attention with rotary embeddings.""" |
|
|
| def __init__(self, config, layer_idx): |
| super().__init__() |
| self.layer_idx = layer_idx |
| self.n_head = config.n_head |
| self.n_kv_head = config.n_kv_head |
| self.n_embd = config.n_embd |
| self.head_dim = self.n_embd // self.n_head |
| assert self.n_embd % self.n_head == 0 |
| assert self.n_kv_head <= self.n_head and self.n_head % self.n_kv_head == 0 |
| self.c_q = nn.Linear(self.n_embd, self.n_head * self.head_dim, bias=False) |
| self.c_k = nn.Linear(self.n_embd, self.n_kv_head * self.head_dim, bias=False) |
| self.c_v = nn.Linear(self.n_embd, self.n_kv_head * self.head_dim, bias=False) |
| self.c_proj = nn.Linear(self.n_embd, self.n_embd, bias=False) |
|
|
| def forward(self, x, cos_sin, kv_cache): |
| B, T, C = x.size() |
|
|
| q = self.c_q(x).view(B, T, self.n_head, self.head_dim) |
| k = self.c_k(x).view(B, T, self.n_kv_head, self.head_dim) |
| v = self.c_v(x).view(B, T, self.n_kv_head, self.head_dim) |
|
|
| |
| cos, sin = cos_sin |
| q, k = apply_rotary_emb(q, cos, sin), apply_rotary_emb(k, cos, sin) |
| q, k = norm(q), norm(k) |
| q, k, v = q.transpose(1, 2), k.transpose(1, 2), v.transpose(1, 2) |
|
|
| |
| if kv_cache is not None: |
| k, v = kv_cache.insert_kv(self.layer_idx, k, v) |
| Tq = q.size(2) |
| Tk = k.size(2) |
|
|
| |
| enable_gqa = ( |
| self.n_head != self.n_kv_head |
| ) |
| if kv_cache is None or Tq == Tk: |
| |
| |
| y = F.scaled_dot_product_attention( |
| q, k, v, is_causal=True, enable_gqa=enable_gqa |
| ) |
| elif Tq == 1: |
| |
| |
| y = F.scaled_dot_product_attention( |
| q, k, v, is_causal=False, enable_gqa=enable_gqa |
| ) |
| else: |
| |
| |
| |
| attn_mask = torch.ones( |
| (Tq, Tk), dtype=torch.bool, device=q.device |
| ) |
| prefix_len = Tk - Tq |
| if prefix_len > 0: |
| attn_mask[:, :prefix_len] = False |
| |
| attn_mask[:, prefix_len:] = ~torch.tril( |
| torch.ones((Tq, Tq), dtype=torch.bool, device=q.device) |
| ) |
| y = F.scaled_dot_product_attention( |
| q, k, v, attn_mask=attn_mask, enable_gqa=enable_gqa |
| ) |
|
|
| |
| y = y.transpose(1, 2).contiguous().view(B, T, -1) |
| y = self.c_proj(y) |
| return y |
|
|
|
|
| class SwiGLU(nn.Module): |
| """SwiGLU activation function with 8/3 hidden dimension expansion.""" |
|
|
| def __init__(self, config): |
| super().__init__() |
| hidden_dim = int(8 * config.n_embd / 3) |
| |
| hidden_dim = ((hidden_dim + 255) // 256) * 256 |
| self.w1 = nn.Linear(config.n_embd, hidden_dim, bias=False) |
| self.w2 = nn.Linear(config.n_embd, hidden_dim, bias=False) |
| self.w3 = nn.Linear(hidden_dim, config.n_embd, bias=False) |
|
|
| def forward(self, x): |
| return self.w3(F.silu(self.w1(x)) * self.w2(x)) |
|
|
|
|
| class Block(nn.Module): |
| """Transformer block with attention and SwiGLU MLP.""" |
|
|
| def __init__(self, config, layer_idx): |
| super().__init__() |
| self.attn = CausalSelfAttention(config, layer_idx) |
| self.mlp = SwiGLU(config) |
| self.attn_norm = RMSNorm(config.n_embd) |
| self.mlp_norm = RMSNorm(config.n_embd) |
|
|
| def forward(self, x, cos_sin, kv_cache): |
| x = x + self.attn(self.attn_norm(x), cos_sin, kv_cache) |
| x = x + self.mlp(self.mlp_norm(x)) |
| return x |
|
|
|
|
| |
| |
| |
|
|
|
|
| class InstanceNorm(nn.Module): |
| """ |
| Per-series instance normalization (Chronos-style). |
| Computes mean/std per series over MASKED positions only. |
| """ |
|
|
| def __init__(self): |
| super().__init__() |
|
|
| def forward(self, x, mask=None, loc_scale=None): |
| """ |
| Args: |
| x: (B, L) - flattened time series per batch item |
| mask: (B, L) - 1 for valid, 0 for pad/nan |
| loc_scale: Optional (B, 2) tensor with [loc, scale] to reuse |
| |
| Returns: |
| x_norm: (B, L) - normalized series (masked positions only) |
| loc_scale: (B, 2) - [loc, scale] used for normalization |
| """ |
| if loc_scale is None: |
| |
| if mask is not None: |
| |
| x_masked = torch.where(mask > 0, x, torch.nan) |
| loc = torch.nanmean(x_masked, dim=1, keepdim=True) |
| demean = x_masked - loc |
| var = torch.nanmean(demean**2, dim=1, keepdim=True) |
| scale = torch.sqrt(var + 1e-8) |
| else: |
| |
| loc = x.mean(dim=1, keepdim=True) |
| scale = x.std(dim=1, keepdim=True) + 1e-8 |
|
|
| loc_scale = torch.cat([loc, scale], dim=1) |
| else: |
| loc = loc_scale[:, 0:1] |
| scale = loc_scale[:, 1:2] |
|
|
| |
| if mask is not None: |
| x_norm = torch.where(mask > 0, (x - loc) / scale, 0.0) |
| else: |
| x_norm = (x - loc) / scale |
|
|
| return x_norm, loc_scale |
|
|
| def inverse(self, x_norm, loc_scale): |
| """ |
| Inverse transform back to original scale. |
| |
| Args: |
| x_norm: (B, L) - normalized values |
| loc_scale: (B, 2) - [loc, scale] from forward pass |
| |
| Returns: |
| x: (B, L) - values in original scale |
| """ |
| loc = loc_scale[:, 0:1] |
| scale = loc_scale[:, 1:2] |
|
|
| |
| x = x_norm * scale + loc |
|
|
| return x |
|
|
|
|
| @dataclass |
| class ChronicleConfig: |
| """Chronicle architecture configuration.""" |
|
|
| sequence_len: int = 1024 |
| vocab_size: int = 50304 |
| n_layer: int = 12 |
| n_head: int = 6 |
| n_kv_head: int = 3 |
| n_embd: int = 768 |
| patch_len: int = PATCH_LEN |
| num_quantiles: int = 21 |
| tie_weights: bool = True |
|
|
|
|
| class PatchProjection(nn.Module): |
| """Projects [time_ramp | value_norm | mask] to embedding dimension.""" |
|
|
| def __init__(self, config): |
| super().__init__() |
| |
| |
| |
| self.proj = nn.Linear(4 * config.patch_len, config.n_embd) |
|
|
| def forward(self, patches_norm, mask, time_ramp): |
| """ |
| Args: |
| patches_norm: (B, T, P) - normalized values |
| mask: (B, T, P) - validity mask |
| time_ramp: (B, T, P) - time positions |
| |
| Returns: |
| (B, T, n_embd) |
| """ |
| |
| features = torch.cat( |
| [time_ramp, patches_norm, mask, torch.zeros_like(patches_norm)], dim=-1 |
| ) |
| return norm(self.proj(features)) |
|
|
|
|
| class QuantileHead(nn.Module): |
| """Predicts quantiles for next patch. Simple.""" |
|
|
| def __init__(self, config): |
| super().__init__() |
| self.patch_len = config.patch_len |
| self.num_quantiles = config.num_quantiles |
| self.proj = nn.Linear(config.n_embd, config.patch_len * config.num_quantiles) |
|
|
| def forward(self, x): |
| """x: (B, T, n_embd) -> (B, T, patch_len, num_quantiles)""" |
| h = norm(x) |
| out = self.proj(h) |
| B, T = out.shape[:2] |
| return out.view(B, T, self.patch_len, self.num_quantiles) |
|
|
|
|
| class Chronicle(nn.Module): |
| """ |
| Standalone Multimodal GPT that handles both text tokens and time series patches. |
| |
| Features: |
| - SwiGLU activation (8/3 ffn multiple) |
| - Weight tying between embeddings and lm_head |
| - Learnable RMSNorm (parametric, with scale but no bias) |
| - Group Query Attention (GQA) |
| """ |
|
|
| def __init__(self, config): |
| super().__init__() |
| self.config = config |
|
|
| |
| self.transformer = nn.ModuleDict( |
| { |
| "wte": nn.Embedding(config.vocab_size, config.n_embd), |
| "h": nn.ModuleList( |
| [Block(config, layer_idx) for layer_idx in range(config.n_layer)] |
| ), |
| } |
| ) |
| self.embed_norm = RMSNorm(config.n_embd) |
| self.final_norm = RMSNorm(config.n_embd) |
|
|
| |
| if config.tie_weights: |
| self.lm_head = None |
| else: |
| self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False) |
|
|
| |
| self.patch_proj = PatchProjection(config) |
| self.quantile_head = QuantileHead(config) |
| self.ts_instance_norm = InstanceNorm() |
|
|
| |
| self.rotary_seq_len = config.sequence_len * 10 |
| head_dim = config.n_embd // config.n_head |
| cos, sin = self._precompute_rotary_embeddings(self.rotary_seq_len, head_dim) |
| self.register_buffer("cos", cos, persistent=False) |
| self.register_buffer("sin", sin, persistent=False) |
|
|
| def _precompute_rotary_embeddings( |
| self, seq_len, head_dim, base=500000, device=None |
| ): |
| """Precompute rotary embeddings.""" |
| if device is None: |
| device = self.transformer.wte.weight.device |
| channel_range = torch.arange(0, head_dim, 2, dtype=torch.float32, device=device) |
| inv_freq = 1.0 / (base ** (channel_range / head_dim)) |
| t = torch.arange(seq_len, dtype=torch.float32, device=device) |
| freqs = torch.outer(t, inv_freq) |
| cos, sin = freqs.cos(), freqs.sin() |
| cos, sin = cos.bfloat16(), sin.bfloat16() |
| cos, sin = cos[None, :, None, :], sin[None, :, None, :] |
| return cos, sin |
|
|
| def get_device(self): |
| """Get the device of the model.""" |
| return self.transformer.wte.weight.device |
|
|
| def forward( |
| self, |
| idx, |
| targets=None, |
| ts_patches=None, |
| ts_targets=None, |
| ts_mask_in=None, |
| ts_mask_tgt=None, |
| kv_cache=None, |
| loss_reduction="mean", |
| text_loss_weight=1.0, |
| ts_loss_weight=1.0, |
| ): |
| """ |
| Unified forward pass for multimodal GPT. |
| |
| Every sample has text tokens (even if just BOS/EOS for pure TS). |
| Time-series is optional and appended to text embeddings when present. |
| |
| Args: |
| idx: (B, T_text) - text token IDs (REQUIRED) |
| targets: (B, T_text) - text targets for loss |
| ts_patches: (B, T_ts, P) - optional TS patches (raw values) |
| ts_targets: (B, T_ts, P) - optional TS targets |
| ts_mask_in: (B, T_ts, P) - TS input validity mask |
| ts_mask_tgt: (B, T_ts, P) - TS target validity mask |
| text_loss_weight: Weight for text cross-entropy loss |
| ts_loss_weight: Weight for time-series quantile loss |
| |
| Returns: |
| If training: combined loss (text + TS) |
| If inference: (text_logits, ts_quantiles) or just text_logits |
| """ |
| device = self.get_device() |
| B = idx.shape[0] |
|
|
| |
| text_embeds = self.transformer.wte(idx) |
|
|
| |
| if ts_patches is not None: |
| B_ts, T_ts, P = ts_patches.shape |
| assert B == B_ts, "Batch size mismatch" |
|
|
| |
| values_flat = ts_patches.view(B, -1) |
| mask_flat = ts_mask_in.view(B, -1) if ts_mask_in is not None else None |
| values_norm, loc_scale = self.ts_instance_norm(values_flat, mask_flat, None) |
| values_norm = values_norm.view(B, T_ts, P) |
|
|
| |
| L = T_ts * P |
| time_ramp = torch.arange(-L, 0, device=device, dtype=torch.float32) |
| time_ramp = (time_ramp / L).view(1, T_ts, P).expand(B, -1, -1) |
|
|
| |
| mask_reshaped = ( |
| ts_mask_in.view(B, T_ts, P) |
| if ts_mask_in is not None |
| else torch.ones_like(values_norm) |
| ) |
| ts_embeds = self.patch_proj(values_norm, mask_reshaped, time_ramp) |
|
|
| |
| embeddings = torch.cat([text_embeds, ts_embeds], dim=1) |
| T_text = text_embeds.shape[1] |
| else: |
| embeddings = text_embeds |
| T_text = embeddings.shape[1] |
| loc_scale = None |
|
|
| |
| seq_len = embeddings.shape[1] |
| assert seq_len <= self.cos.size( |
| 1 |
| ), f"Sequence length {seq_len} exceeds rotary cache {self.cos.size(1)}" |
| T0 = 0 if kv_cache is None else kv_cache.get_pos() |
| cos_sin = (self.cos[:, T0 : T0 + seq_len], self.sin[:, T0 : T0 + seq_len]) |
|
|
| x = self.embed_norm(embeddings) |
| for block in self.transformer.h: |
| x = block(x, cos_sin, kv_cache) |
|
|
| x = self.final_norm(x) |
|
|
| |
| text_out = x[:, :T_text, :] |
| ts_out = x[:, T_text:, :] if ts_patches is not None else None |
|
|
| |
| total_loss = 0.0 |
| num_losses = 0 |
| softcap = 15 |
|
|
| if targets is not None: |
| |
| if self.lm_head is not None: |
| logits = self.lm_head(text_out) |
| else: |
| |
| logits = F.linear(text_out, self.transformer.wte.weight) |
| logits = softcap * torch.tanh(logits / softcap) |
| logits = logits.float() |
| text_loss = F.cross_entropy( |
| logits.view(-1, logits.size(-1)), |
| targets.view(-1), |
| reduction=loss_reduction, |
| ) |
| total_loss = total_loss + text_loss_weight * text_loss |
| num_losses += 1 |
|
|
| if ts_targets is not None and ts_out is not None: |
| quantiles = self.quantile_head(ts_out) |
|
|
| |
| tgt_flat = ts_targets.view(B, -1) |
| tgt_mask_flat = ts_mask_tgt.view(B, -1) if ts_mask_tgt is not None else None |
| tgt_norm, _ = self.ts_instance_norm(tgt_flat, tgt_mask_flat, loc_scale) |
| tgt_norm = tgt_norm.view(B, ts_out.shape[1], P) |
|
|
| ts_loss = quantile_loss(quantiles, tgt_norm, mask=ts_mask_tgt) |
| total_loss = total_loss + ts_loss_weight * ts_loss |
| num_losses += 1 |
|
|
| |
| if num_losses > 0: |
| return total_loss |
|
|
| |
| if self.lm_head is not None: |
| logits = self.lm_head(text_out) |
| else: |
| logits = F.linear(text_out, self.transformer.wte.weight) |
| logits = softcap * torch.tanh(logits / softcap) |
|
|
| if ts_out is not None: |
| quantiles = self.quantile_head(ts_out) |
| |
| B, T_ts, P, Q = quantiles.shape |
| q_flat = quantiles.permute(0, 1, 3, 2).contiguous().view(B, -1) |
| q_inv = self.ts_instance_norm.inverse(q_flat, loc_scale) |
| q_inv = q_inv.view(B, T_ts, Q, P).permute(0, 1, 3, 2).contiguous() |
| return logits, q_inv |
| return logits |
|
|
| def quantile_loss(quantile_preds, targets, mask=None, quantiles=None, reduction="mean"): |
| """ |
| Quantile regression loss (pinball loss) with optional masking. |
| |
| Args: |
| quantile_preds: (B, T, P, Q) - predicted quantiles |
| targets: (B, T, P) - actual values |
| mask: (B, T, P) - validity mask (1=real, 0=pad) |
| quantiles: List of quantile levels (default: 21 quantiles from 0.05 to 0.95) |
| reduction: 'mean', 'none', or 'sum' |
| |
| Returns: |
| loss: Quantile loss |
| """ |
| if quantiles is None: |
| quantiles = torch.linspace(0.05, 0.95, 21, device=quantile_preds.device) |
|
|
| |
| targets_expanded = targets.unsqueeze(-1) |
|
|
| |
| errors = targets_expanded - quantile_preds |
|
|
| |
| quantiles = quantiles.view(1, 1, 1, -1) |
| loss = torch.where(errors >= 0, quantiles * errors, (quantiles - 1) * errors) |
|
|
| |
| if mask is not None: |
| mask_expanded = mask.unsqueeze(-1) |
| loss = loss * mask_expanded |
| if reduction == "mean": |
| return loss.sum() / (mask.sum() * quantile_preds.size(-1)).clamp(min=1) |
| elif reduction == "sum": |
| return loss.sum() |
| else: |
| return loss |
| else: |
| if reduction == "mean": |
| return loss.mean() |
| elif reduction == "sum": |
| return loss.sum() |
| else: |
| return loss |
|
|