PaulQ1 commited on
Commit
a2a4a3b
·
verified ·
1 Parent(s): 2aa2fda

Initial commit

Browse files
README.md ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ tags:
6
+ - time-series
7
+ - multimodal
8
+ - forecasting
9
+ - foundation-model
10
+ - chronicle
11
+ library_name: pytorch
12
+ pipeline_tag: time-series-forecasting
13
+ ---
14
+
15
+ <p align="center">
16
+ <a href="https://www.inertialai.com">
17
+ <img src="https://www.inertialai.com/biglogo-v2.webp" alt="InertialAI" height="56">
18
+ </a>
19
+ </p>
20
+
21
+ # Chronicle
22
+
23
+ **A multimodal foundation model for joint language and time series understanding.**
24
+
25
+ Chronicle is a compact **324M-parameter decoder-only transformer trained from scratch on
26
+ natural language and time series** within a single unified architecture. Text tokens and
27
+ time-series patches share the same transformer blocks, attention mechanism, and residual
28
+ stream — cross-modal capability emerges from shared parameters rather than from bolting a
29
+ time-series encoder onto a pretrained LLM.
30
+
31
+ - 📄 **Paper:** [Chronicle: A Multimodal Foundation Model for Joint Language and Time
32
+ Series Understanding](https://arxiv.org/abs/2605.20268) (Quinlan, Levasseur, Li, Zhu)
33
+ - 💻 **Code & finetuning examples:** [github.com/InertialAI/Chronicle](https://github.com/InertialAI/Chronicle)
34
+ - ☁️ **Hosted API with self-serve finetuning:** [inertialai.com](https://www.inertialai.com/platform)
35
+
36
+ ## Checkpoints in this repo
37
+
38
+ | Path | Stage | Description |
39
+ | --- | --- | --- |
40
+ | `stage-1/` | **Stage 1** | Unimodal-batch pretraining (~92% text / 8% time series) — cross-modal ability from shared parameters alone. `model.safetensors` + `config.json` |
41
+ | `stage-2/` | **Stage 2** | Stage 1 + a short alignment stage that interleaves the two modalities (best multimodal results); context extended to 4096. `model.safetensors` + `config.json` |
42
+ | `tokenizer/` | — | 131k-vocabulary BPE tokenizer (trained from scratch) |
43
+ | `model.py` · `tokenizer.py` | — | Minimal inference implementation (`Chronicle`, `ChronicleConfig`, `ChronicleTokenizer`) |
44
+
45
+ ## Architecture
46
+
47
+ | | |
48
+ | --- | --- |
49
+ | Parameters | 324M, 16-layer decoder-only transformer |
50
+ | Width / heads | d=1024, 8 attention heads (4 KV, GQA) |
51
+ | Context | 2048 tokens (stage 1) / 4096 (stage 2) — text tokens + 32-step series patches, one shared stream |
52
+ | Series head | 21-quantile next-patch forecasting with instance normalization |
53
+ | Objective | causal next-token / next-patch prediction |
54
+
55
+ ## Usage
56
+
57
+ Weights ship as **safetensors**; `model.py` and `tokenizer.py` are a minimal,
58
+ dependency-light inference implementation (`torch`, `tiktoken`). Verified example (text generation,
59
+ CPU):
60
+
61
+ ```python
62
+ import json
63
+ import sys
64
+
65
+ import torch
66
+ from huggingface_hub import snapshot_download
67
+ from safetensors.torch import load_file
68
+
69
+ repo = snapshot_download("InertialAI/Chronicle")
70
+ sys.path.insert(0, repo)
71
+
72
+ from model import Chronicle, ChronicleConfig
73
+ from tokenizer import ChronicleTokenizer
74
+
75
+ config = ChronicleConfig(**json.load(open(f"{repo}/stage-2/config.json")))
76
+ model = Chronicle(config)
77
+ state = load_file(f"{repo}/stage-2/model.safetensors")
78
+ state = {k: v.float() if v.dtype is torch.bfloat16 else v for k, v in state.items()}
79
+ model.load_state_dict(state, strict=True)
80
+ model = model.float().eval()
81
+ model.cos, model.sin = model.cos.float(), model.sin.float() # fp32 rotary on CPU
82
+
83
+ tokenizer = ChronicleTokenizer.from_directory(f"{repo}/tokenizer")
84
+ ids = [tokenizer.get_bos_token_id()] + tokenizer.encode("Time series forecasting is")
85
+ with torch.no_grad():
86
+ for _ in range(16):
87
+ out = model(torch.tensor([ids]))
88
+ logits = out[0] if isinstance(out, tuple) else out
89
+ ids.append(int(logits[0, -1].argmax()))
90
+ print(tokenizer.decode(ids[1:]))
91
+ # -> "Time series forecasting is a technique used to forecast future values
92
+ # of a time series based on historical data."
93
+ ```
94
+
95
+ For the time-series pathway (quantile forecasting via `ts_patches`) and
96
+ `forecast()` / `embed()` conveniences, use the **transformers-native port (in
97
+ progress)** or the [hosted API](https://docs.inertialai.com), which serves these
98
+ models today. The finetuning recipes in the
99
+ [GitHub repo](https://github.com/InertialAI/Chronicle) show the downstream-task
100
+ protocols used in the paper.
101
+
102
+ ## Results
103
+
104
+ One backbone, evaluated against dedicated unimodal foundation models in *both*
105
+ domains — the core claim is breadth: strong language understanding, state-of-the-art
106
+ frozen time-series embeddings, and best-in-class multimodal forecasting, all from the
107
+ same weights. Numbers below are from the paper.
108
+
109
+ **Language understanding** (19-task NLU average) — parity with text-only models of the
110
+ same scale, trained on ~40× fewer text tokens:
111
+
112
+ | | GPT-2 (124M) | Gemma-3 (270M) | **Chronicle-1 (324M)** | **Chronicle-2 (324M)** | LFM-2 (350M) |
113
+ | --- | --- | --- | --- | --- | --- |
114
+ | NLU avg | 0.324 | 0.406 | **0.411** | **0.406** | 0.449 |
115
+
116
+ **Time series classification** (24 UCR/UEA datasets, linear probe on frozen
117
+ embeddings) — a new bar among TS foundation models:
118
+
119
+ | Dataset | Chronos-2 | TimesFM | Moirai-2 | **Chronicle-1** |
120
+ | --- | --- | --- | --- | --- |
121
+ | GunPoint | 0.528 | 0.712 | 0.931 | **0.919** |
122
+ | FaceFour | 0.236 | 0.609 | 0.582 | **0.864** |
123
+ | Trace | 0.288 | 0.630 | 0.802 | **0.936** |
124
+ | ECG200 | 0.672 | 0.840 | 0.820 | **0.846** |
125
+
126
+ **Multimodal forecasting** (Time-MMD, 9 domains, MAE ↓) — beats every supervised
127
+ fusion baseline and every frozen FM-fusion pairing (baseline columns show the best
128
+ baseline per metric):
129
+
130
+ | | Best MM-TSFlib | Best FM Fusion | **Chronicle-2 (LP)** |
131
+ | --- | --- | --- | --- |
132
+ | Avg NMAE | 0.621 | 0.588 | **0.514** |
133
+ | Avg rank | 6.78 | 6.11 | **2.56** |
134
+
135
+ **Multimodal classification** (TimeCAP: weather, finance, healthcare) — Chronicle-2
136
+ LoRA reaches **0.613 F1 / 0.757 AUC**, ahead of every MM-TSFlib and FM-fusion baseline.
137
+
138
+ See the [paper](https://arxiv.org/abs/2605.20268) for full tables, protocols, and
139
+ baselines, and the [GitHub repo](https://github.com/InertialAI/Chronicle) for
140
+ reproduction scripts on public data.
141
+
142
+ ## Citation
143
+
144
+ ```bibtex
145
+ @article{quinlan2026chronicle,
146
+ title={Chronicle: A Multimodal Foundation Model for Joint Language and Time Series Understanding},
147
+ author={Quinlan, Paul and Levasseur, Jeremy and Li, Qingguo and Zhu, Xiaodan},
148
+ journal={arXiv preprint arXiv:2605.20268},
149
+ year={2026}
150
+ }
151
+ ```
152
+
153
+ ## License
154
+
155
+ Apache 2.0.
model.py ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chronicle: a multimodal (text + time series) decoder-only transformer.
2
+
3
+ Reference inference implementation for the released checkpoints — see the
4
+ model card for a verified loading and generation example.
5
+ """
6
+
7
+ """
8
+ Standalone Multimodal GPT that handles both text and time-series data.
9
+
10
+ Key features:
11
+ 1. Patch projection layer to project TS patches to embedding space
12
+ 2. Quantile prediction head for forecasting
13
+ 3. Support for mixed text/TS inputs
14
+ 4. InstanceNorm for per-series normalization (Chronos-style)
15
+ 5. SwiGLU activation with 8/3 ffn multiple
16
+ 6. Weight tying between embeddings and lm_head
17
+ 7. Learnable RMSNorm
18
+ 8. Group Query Attention (GQA) support
19
+ """
20
+
21
+ import math
22
+ from functools import partial
23
+ from dataclasses import dataclass
24
+
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+
29
+ PATCH_LEN = 32 # length of one time-series patch
30
+
31
+
32
+ # -----------------------------------------------------------------------------
33
+ # Core transformer components (standalone, not dependent on gpt.py)
34
+ # -----------------------------------------------------------------------------
35
+
36
+
37
+ class RMSNorm(nn.Module):
38
+ """RMSNorm with learnable scale parameter (no bias)."""
39
+
40
+ def __init__(self, size: int):
41
+ super().__init__()
42
+ self.weight = nn.Parameter(torch.ones(size))
43
+
44
+ def forward(self, x):
45
+ # RMS normalization
46
+ norm_x = x.float()
47
+ rms = torch.sqrt(torch.mean(norm_x**2, dim=-1, keepdim=True) + 1e-5)
48
+ x_normed = norm_x / rms
49
+ return (self.weight * x_normed).to(x.dtype)
50
+
51
+
52
+ def apply_rotary_emb(x, cos, sin):
53
+ """Apply rotary embeddings to queries or keys."""
54
+ assert x.ndim == 4 # multihead attention
55
+ d = x.shape[3] // 2
56
+ x1, x2 = x[..., :d], x[..., d:]
57
+ y1 = x1 * cos + x2 * sin
58
+ y2 = x1 * (-sin) + x2 * cos
59
+ out = torch.cat([y1, y2], 3)
60
+ out = out.to(x.dtype)
61
+ return out
62
+
63
+
64
+ def norm(x):
65
+ """Purely functional rmsnorm with no learnable params (for QK norm)."""
66
+ return F.rms_norm(x, (x.size(-1),))
67
+
68
+
69
+ class CausalSelfAttention(nn.Module):
70
+ """Multi-head or Group Query Attention with rotary embeddings."""
71
+
72
+ def __init__(self, config, layer_idx):
73
+ super().__init__()
74
+ self.layer_idx = layer_idx
75
+ self.n_head = config.n_head
76
+ self.n_kv_head = config.n_kv_head
77
+ self.n_embd = config.n_embd
78
+ self.head_dim = self.n_embd // self.n_head
79
+ assert self.n_embd % self.n_head == 0
80
+ assert self.n_kv_head <= self.n_head and self.n_head % self.n_kv_head == 0
81
+ self.c_q = nn.Linear(self.n_embd, self.n_head * self.head_dim, bias=False)
82
+ self.c_k = nn.Linear(self.n_embd, self.n_kv_head * self.head_dim, bias=False)
83
+ self.c_v = nn.Linear(self.n_embd, self.n_kv_head * self.head_dim, bias=False)
84
+ self.c_proj = nn.Linear(self.n_embd, self.n_embd, bias=False)
85
+
86
+ def forward(self, x, cos_sin, kv_cache):
87
+ B, T, C = x.size()
88
+
89
+ q = self.c_q(x).view(B, T, self.n_head, self.head_dim)
90
+ k = self.c_k(x).view(B, T, self.n_kv_head, self.head_dim)
91
+ v = self.c_v(x).view(B, T, self.n_kv_head, self.head_dim)
92
+
93
+ # Apply rotary embeddings and QK norm
94
+ cos, sin = cos_sin
95
+ q, k = apply_rotary_emb(q, cos, sin), apply_rotary_emb(k, cos, sin)
96
+ q, k = norm(q), norm(k) # QK norm (functional, no params)
97
+ q, k, v = q.transpose(1, 2), k.transpose(1, 2), v.transpose(1, 2)
98
+
99
+ # Apply KV cache if present
100
+ if kv_cache is not None:
101
+ k, v = kv_cache.insert_kv(self.layer_idx, k, v)
102
+ Tq = q.size(2)
103
+ Tk = k.size(2)
104
+
105
+ # Attention: queries attend to keys/values autoregressively. A few cases to handle:
106
+ enable_gqa = (
107
+ self.n_head != self.n_kv_head
108
+ ) # Group Query Attention (GQA): duplicate key/value heads to match query heads if desired
109
+ if kv_cache is None or Tq == Tk:
110
+ # During training (no KV cache), attend as usual with causal attention
111
+ # And even if there is KV cache, we can still use this simple version when Tq == Tk
112
+ y = F.scaled_dot_product_attention(
113
+ q, k, v, is_causal=True, enable_gqa=enable_gqa
114
+ )
115
+ elif Tq == 1:
116
+ # During inference but with a single query in this forward pass:
117
+ # The query has to attend to all the keys/values in the cache
118
+ y = F.scaled_dot_product_attention(
119
+ q, k, v, is_causal=False, enable_gqa=enable_gqa
120
+ )
121
+ else:
122
+ # During inference AND we have a chunk of queries in this forward pass:
123
+ # Build attention mask: True = masked (blocked), False = keep
124
+ # First, each query attends to all the cached keys/values (i.e. full prefix)
125
+ attn_mask = torch.ones(
126
+ (Tq, Tk), dtype=torch.bool, device=q.device
127
+ ) # Start with all masked
128
+ prefix_len = Tk - Tq
129
+ if prefix_len > 0: # can't be negative but could be zero
130
+ attn_mask[:, :prefix_len] = False # Allow attending to prefix
131
+ # Then, causal attention within this chunk (lower triangular = allowed)
132
+ attn_mask[:, prefix_len:] = ~torch.tril(
133
+ torch.ones((Tq, Tq), dtype=torch.bool, device=q.device)
134
+ )
135
+ y = F.scaled_dot_product_attention(
136
+ q, k, v, attn_mask=attn_mask, enable_gqa=enable_gqa
137
+ )
138
+
139
+ # Re-assemble the heads side by side and project back to residual stream
140
+ y = y.transpose(1, 2).contiguous().view(B, T, -1)
141
+ y = self.c_proj(y)
142
+ return y
143
+
144
+
145
+ class SwiGLU(nn.Module):
146
+ """SwiGLU activation function with 8/3 hidden dimension expansion."""
147
+
148
+ def __init__(self, config):
149
+ super().__init__()
150
+ hidden_dim = int(8 * config.n_embd / 3)
151
+ # Round to nearest multiple of 256 for efficiency
152
+ hidden_dim = ((hidden_dim + 255) // 256) * 256
153
+ self.w1 = nn.Linear(config.n_embd, hidden_dim, bias=False)
154
+ self.w2 = nn.Linear(config.n_embd, hidden_dim, bias=False)
155
+ self.w3 = nn.Linear(hidden_dim, config.n_embd, bias=False)
156
+
157
+ def forward(self, x):
158
+ return self.w3(F.silu(self.w1(x)) * self.w2(x))
159
+
160
+
161
+ class Block(nn.Module):
162
+ """Transformer block with attention and SwiGLU MLP."""
163
+
164
+ def __init__(self, config, layer_idx):
165
+ super().__init__()
166
+ self.attn = CausalSelfAttention(config, layer_idx)
167
+ self.mlp = SwiGLU(config)
168
+ self.attn_norm = RMSNorm(config.n_embd)
169
+ self.mlp_norm = RMSNorm(config.n_embd)
170
+
171
+ def forward(self, x, cos_sin, kv_cache):
172
+ x = x + self.attn(self.attn_norm(x), cos_sin, kv_cache)
173
+ x = x + self.mlp(self.mlp_norm(x))
174
+ return x
175
+
176
+
177
+ # -----------------------------------------------------------------------------
178
+ # Time-series specific components
179
+ # -----------------------------------------------------------------------------
180
+
181
+
182
+ class InstanceNorm(nn.Module):
183
+ """
184
+ Per-series instance normalization (Chronos-style).
185
+ Computes mean/std per series over MASKED positions only.
186
+ """
187
+
188
+ def __init__(self):
189
+ super().__init__()
190
+
191
+ def forward(self, x, mask=None, loc_scale=None):
192
+ """
193
+ Args:
194
+ x: (B, L) - flattened time series per batch item
195
+ mask: (B, L) - 1 for valid, 0 for pad/nan
196
+ loc_scale: Optional (B, 2) tensor with [loc, scale] to reuse
197
+
198
+ Returns:
199
+ x_norm: (B, L) - normalized series (masked positions only)
200
+ loc_scale: (B, 2) - [loc, scale] used for normalization
201
+ """
202
+ if loc_scale is None:
203
+ # Compute loc/scale only over masked positions
204
+ if mask is not None:
205
+ # Set NaN where mask is 0, compute nanmean
206
+ x_masked = torch.where(mask > 0, x, torch.nan)
207
+ loc = torch.nanmean(x_masked, dim=1, keepdim=True) # (B, 1)
208
+ demean = x_masked - loc
209
+ var = torch.nanmean(demean**2, dim=1, keepdim=True)
210
+ scale = torch.sqrt(var + 1e-8)
211
+ else:
212
+ # No mask - use all values
213
+ loc = x.mean(dim=1, keepdim=True)
214
+ scale = x.std(dim=1, keepdim=True) + 1e-8
215
+
216
+ loc_scale = torch.cat([loc, scale], dim=1) # (B, 2)
217
+ else:
218
+ loc = loc_scale[:, 0:1]
219
+ scale = loc_scale[:, 1:2]
220
+
221
+ # Normalize - zero out masked positions
222
+ if mask is not None:
223
+ x_norm = torch.where(mask > 0, (x - loc) / scale, 0.0)
224
+ else:
225
+ x_norm = (x - loc) / scale
226
+
227
+ return x_norm, loc_scale
228
+
229
+ def inverse(self, x_norm, loc_scale):
230
+ """
231
+ Inverse transform back to original scale.
232
+
233
+ Args:
234
+ x_norm: (B, L) - normalized values
235
+ loc_scale: (B, 2) - [loc, scale] from forward pass
236
+
237
+ Returns:
238
+ x: (B, L) - values in original scale
239
+ """
240
+ loc = loc_scale[:, 0:1] # (B, 1)
241
+ scale = loc_scale[:, 1:2] # (B, 1)
242
+
243
+ # Denormalize
244
+ x = x_norm * scale + loc
245
+
246
+ return x
247
+
248
+
249
+ @dataclass
250
+ class ChronicleConfig:
251
+ """Chronicle architecture configuration."""
252
+
253
+ sequence_len: int = 1024
254
+ vocab_size: int = 50304
255
+ n_layer: int = 12
256
+ n_head: int = 6 # number of query heads
257
+ n_kv_head: int = 3 # number of key/value heads (for GQA) - default 2:1 ratio
258
+ n_embd: int = 768
259
+ patch_len: int = PATCH_LEN # Length of each time series patch
260
+ num_quantiles: int = 21 # Number of quantiles to predict
261
+ tie_weights: bool = True # Tie embedding and lm_head weights
262
+
263
+
264
+ class PatchProjection(nn.Module):
265
+ """Projects [time_ramp | value_norm | mask] to embedding dimension."""
266
+
267
+ def __init__(self, config):
268
+ super().__init__()
269
+ # Input: 4 * patch_len per step, as trained. The fourth channel is
270
+ # reserved; end-to-end series inference ships with the transformers
271
+ # port — the hosted API serves it today.
272
+ self.proj = nn.Linear(4 * config.patch_len, config.n_embd)
273
+
274
+ def forward(self, patches_norm, mask, time_ramp):
275
+ """
276
+ Args:
277
+ patches_norm: (B, T, P) - normalized values
278
+ mask: (B, T, P) - validity mask
279
+ time_ramp: (B, T, P) - time positions
280
+
281
+ Returns:
282
+ (B, T, n_embd)
283
+ """
284
+ # Concatenate features: [time | value | mask | reserved]
285
+ features = torch.cat(
286
+ [time_ramp, patches_norm, mask, torch.zeros_like(patches_norm)], dim=-1
287
+ ) # (B, T, 4*P)
288
+ return norm(self.proj(features))
289
+
290
+
291
+ class QuantileHead(nn.Module):
292
+ """Predicts quantiles for next patch. Simple."""
293
+
294
+ def __init__(self, config):
295
+ super().__init__()
296
+ self.patch_len = config.patch_len
297
+ self.num_quantiles = config.num_quantiles
298
+ self.proj = nn.Linear(config.n_embd, config.patch_len * config.num_quantiles)
299
+
300
+ def forward(self, x):
301
+ """x: (B, T, n_embd) -> (B, T, patch_len, num_quantiles)"""
302
+ h = norm(x)
303
+ out = self.proj(h) # (B, T, patch_len * num_quantiles)
304
+ B, T = out.shape[:2]
305
+ return out.view(B, T, self.patch_len, self.num_quantiles)
306
+
307
+
308
+ class Chronicle(nn.Module):
309
+ """
310
+ Standalone Multimodal GPT that handles both text tokens and time series patches.
311
+
312
+ Features:
313
+ - SwiGLU activation (8/3 ffn multiple)
314
+ - Weight tying between embeddings and lm_head
315
+ - Learnable RMSNorm (parametric, with scale but no bias)
316
+ - Group Query Attention (GQA)
317
+ """
318
+
319
+ def __init__(self, config):
320
+ super().__init__()
321
+ self.config = config
322
+
323
+ # Core transformer components
324
+ self.transformer = nn.ModuleDict(
325
+ {
326
+ "wte": nn.Embedding(config.vocab_size, config.n_embd),
327
+ "h": nn.ModuleList(
328
+ [Block(config, layer_idx) for layer_idx in range(config.n_layer)]
329
+ ),
330
+ }
331
+ )
332
+ self.embed_norm = RMSNorm(config.n_embd) # Normalize after embedding
333
+ self.final_norm = RMSNorm(config.n_embd)
334
+
335
+ # Output projection (tied or untied with embeddings)
336
+ if config.tie_weights:
337
+ self.lm_head = None # Will use tied weights
338
+ else:
339
+ self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
340
+
341
+ # Time-series specific components
342
+ self.patch_proj = PatchProjection(config)
343
+ self.quantile_head = QuantileHead(config)
344
+ self.ts_instance_norm = InstanceNorm()
345
+
346
+ # Rotary embeddings cache
347
+ self.rotary_seq_len = config.sequence_len * 10
348
+ head_dim = config.n_embd // config.n_head
349
+ cos, sin = self._precompute_rotary_embeddings(self.rotary_seq_len, head_dim)
350
+ self.register_buffer("cos", cos, persistent=False)
351
+ self.register_buffer("sin", sin, persistent=False)
352
+
353
+ def _precompute_rotary_embeddings(
354
+ self, seq_len, head_dim, base=500000, device=None
355
+ ):
356
+ """Precompute rotary embeddings."""
357
+ if device is None:
358
+ device = self.transformer.wte.weight.device
359
+ channel_range = torch.arange(0, head_dim, 2, dtype=torch.float32, device=device)
360
+ inv_freq = 1.0 / (base ** (channel_range / head_dim))
361
+ t = torch.arange(seq_len, dtype=torch.float32, device=device)
362
+ freqs = torch.outer(t, inv_freq)
363
+ cos, sin = freqs.cos(), freqs.sin()
364
+ cos, sin = cos.bfloat16(), sin.bfloat16()
365
+ cos, sin = cos[None, :, None, :], sin[None, :, None, :]
366
+ return cos, sin
367
+
368
+ def get_device(self):
369
+ """Get the device of the model."""
370
+ return self.transformer.wte.weight.device
371
+
372
+ def forward(
373
+ self,
374
+ idx,
375
+ targets=None,
376
+ ts_patches=None,
377
+ ts_targets=None,
378
+ ts_mask_in=None,
379
+ ts_mask_tgt=None,
380
+ kv_cache=None,
381
+ loss_reduction="mean",
382
+ text_loss_weight=1.0,
383
+ ts_loss_weight=1.0,
384
+ ):
385
+ """
386
+ Unified forward pass for multimodal GPT.
387
+
388
+ Every sample has text tokens (even if just BOS/EOS for pure TS).
389
+ Time-series is optional and appended to text embeddings when present.
390
+
391
+ Args:
392
+ idx: (B, T_text) - text token IDs (REQUIRED)
393
+ targets: (B, T_text) - text targets for loss
394
+ ts_patches: (B, T_ts, P) - optional TS patches (raw values)
395
+ ts_targets: (B, T_ts, P) - optional TS targets
396
+ ts_mask_in: (B, T_ts, P) - TS input validity mask
397
+ ts_mask_tgt: (B, T_ts, P) - TS target validity mask
398
+ text_loss_weight: Weight for text cross-entropy loss
399
+ ts_loss_weight: Weight for time-series quantile loss
400
+
401
+ Returns:
402
+ If training: combined loss (text + TS)
403
+ If inference: (text_logits, ts_quantiles) or just text_logits
404
+ """
405
+ device = self.get_device()
406
+ B = idx.shape[0]
407
+
408
+ # Embed text tokens
409
+ text_embeds = self.transformer.wte(idx) # (B, T_text, n_embd)
410
+
411
+ # Optionally append TS embeddings
412
+ if ts_patches is not None:
413
+ B_ts, T_ts, P = ts_patches.shape
414
+ assert B == B_ts, "Batch size mismatch"
415
+
416
+ # Instance normalization (mask-aware)
417
+ values_flat = ts_patches.view(B, -1)
418
+ mask_flat = ts_mask_in.view(B, -1) if ts_mask_in is not None else None
419
+ values_norm, loc_scale = self.ts_instance_norm(values_flat, mask_flat, None)
420
+ values_norm = values_norm.view(B, T_ts, P)
421
+
422
+ # Time ramp for positional info
423
+ L = T_ts * P
424
+ time_ramp = torch.arange(-L, 0, device=device, dtype=torch.float32)
425
+ time_ramp = (time_ramp / L).view(1, T_ts, P).expand(B, -1, -1)
426
+
427
+ # Project TS to embeddings
428
+ mask_reshaped = (
429
+ ts_mask_in.view(B, T_ts, P)
430
+ if ts_mask_in is not None
431
+ else torch.ones_like(values_norm)
432
+ )
433
+ ts_embeds = self.patch_proj(values_norm, mask_reshaped, time_ramp)
434
+
435
+ # Concatenate: [text | TS]
436
+ embeddings = torch.cat([text_embeds, ts_embeds], dim=1)
437
+ T_text = text_embeds.shape[1]
438
+ else:
439
+ embeddings = text_embeds
440
+ T_text = embeddings.shape[1]
441
+ loc_scale = None
442
+
443
+ # Transformer
444
+ seq_len = embeddings.shape[1]
445
+ assert seq_len <= self.cos.size(
446
+ 1
447
+ ), f"Sequence length {seq_len} exceeds rotary cache {self.cos.size(1)}"
448
+ T0 = 0 if kv_cache is None else kv_cache.get_pos()
449
+ cos_sin = (self.cos[:, T0 : T0 + seq_len], self.sin[:, T0 : T0 + seq_len])
450
+
451
+ x = self.embed_norm(embeddings) # Normalize after embedding (like base GPT)
452
+ for block in self.transformer.h:
453
+ x = block(x, cos_sin, kv_cache)
454
+
455
+ x = self.final_norm(x)
456
+
457
+ # Split outputs
458
+ text_out = x[:, :T_text, :]
459
+ ts_out = x[:, T_text:, :] if ts_patches is not None else None
460
+
461
+ # Compute losses
462
+ total_loss = 0.0
463
+ num_losses = 0
464
+ softcap = 15
465
+
466
+ if targets is not None:
467
+ # Use tied weights if configured
468
+ if self.lm_head is not None:
469
+ logits = self.lm_head(text_out)
470
+ else:
471
+ # Weight tying: use transposed embedding matrix
472
+ logits = F.linear(text_out, self.transformer.wte.weight)
473
+ logits = softcap * torch.tanh(logits / softcap) # logits softcap
474
+ logits = logits.float() # use tf32/fp32 for logits
475
+ text_loss = F.cross_entropy(
476
+ logits.view(-1, logits.size(-1)),
477
+ targets.view(-1),
478
+ reduction=loss_reduction,
479
+ )
480
+ total_loss = total_loss + text_loss_weight * text_loss
481
+ num_losses += 1
482
+
483
+ if ts_targets is not None and ts_out is not None:
484
+ quantiles = self.quantile_head(ts_out)
485
+
486
+ # Normalize targets
487
+ tgt_flat = ts_targets.view(B, -1)
488
+ tgt_mask_flat = ts_mask_tgt.view(B, -1) if ts_mask_tgt is not None else None
489
+ tgt_norm, _ = self.ts_instance_norm(tgt_flat, tgt_mask_flat, loc_scale)
490
+ tgt_norm = tgt_norm.view(B, ts_out.shape[1], P)
491
+
492
+ ts_loss = quantile_loss(quantiles, tgt_norm, mask=ts_mask_tgt)
493
+ total_loss = total_loss + ts_loss_weight * ts_loss
494
+ num_losses += 1
495
+
496
+ # Return loss or predictions
497
+ if num_losses > 0:
498
+ return total_loss
499
+
500
+ # Inference mode
501
+ if self.lm_head is not None:
502
+ logits = self.lm_head(text_out)
503
+ else:
504
+ logits = F.linear(text_out, self.transformer.wte.weight)
505
+ logits = softcap * torch.tanh(logits / softcap) # logits softcap
506
+
507
+ if ts_out is not None:
508
+ quantiles = self.quantile_head(ts_out)
509
+ # Denormalize
510
+ B, T_ts, P, Q = quantiles.shape
511
+ q_flat = quantiles.permute(0, 1, 3, 2).contiguous().view(B, -1)
512
+ q_inv = self.ts_instance_norm.inverse(q_flat, loc_scale)
513
+ q_inv = q_inv.view(B, T_ts, Q, P).permute(0, 1, 3, 2).contiguous()
514
+ return logits, q_inv
515
+ return logits
516
+
517
+ def quantile_loss(quantile_preds, targets, mask=None, quantiles=None, reduction="mean"):
518
+ """
519
+ Quantile regression loss (pinball loss) with optional masking.
520
+
521
+ Args:
522
+ quantile_preds: (B, T, P, Q) - predicted quantiles
523
+ targets: (B, T, P) - actual values
524
+ mask: (B, T, P) - validity mask (1=real, 0=pad)
525
+ quantiles: List of quantile levels (default: 21 quantiles from 0.05 to 0.95)
526
+ reduction: 'mean', 'none', or 'sum'
527
+
528
+ Returns:
529
+ loss: Quantile loss
530
+ """
531
+ if quantiles is None:
532
+ quantiles = torch.linspace(0.05, 0.95, 21, device=quantile_preds.device)
533
+
534
+ # Expand targets to match quantile predictions
535
+ targets_expanded = targets.unsqueeze(-1) # (B, T, P, 1)
536
+
537
+ # Compute errors
538
+ errors = targets_expanded - quantile_preds # (B, T, P, Q)
539
+
540
+ # Quantile loss (pinball loss)
541
+ quantiles = quantiles.view(1, 1, 1, -1) # Broadcast
542
+ loss = torch.where(errors >= 0, quantiles * errors, (quantiles - 1) * errors)
543
+
544
+ # Apply mask if provided
545
+ if mask is not None:
546
+ mask_expanded = mask.unsqueeze(-1) # (B, T, P, 1)
547
+ loss = loss * mask_expanded
548
+ if reduction == "mean":
549
+ return loss.sum() / (mask.sum() * quantile_preds.size(-1)).clamp(min=1)
550
+ elif reduction == "sum":
551
+ return loss.sum()
552
+ else:
553
+ return loss
554
+ else:
555
+ if reduction == "mean":
556
+ return loss.mean()
557
+ elif reduction == "sum":
558
+ return loss.sum()
559
+ else:
560
+ return loss
stage-1/config.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "sequence_len": 2048,
3
+ "vocab_size": 131072,
4
+ "n_layer": 16,
5
+ "n_head": 8,
6
+ "n_kv_head": 4,
7
+ "n_embd": 1024,
8
+ "patch_len": 32,
9
+ "num_quantiles": 21
10
+ }
stage-1/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b3088055cefc4714f06307211d0b4443a6fa874cc60b11a3cf2761c6177ffdb4
3
+ size 1026848872
stage-2/config.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "sequence_len": 4096,
3
+ "vocab_size": 131072,
4
+ "n_layer": 16,
5
+ "n_head": 8,
6
+ "n_kv_head": 4,
7
+ "n_embd": 1024,
8
+ "patch_len": 32,
9
+ "num_quantiles": 21
10
+ }
stage-2/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:852ac6725a5b590f842b322d6687dd1af96b9c58be598abe5fcd691b7e69408f
3
+ size 1026848872
tokenizer.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chronicle tokenizer: a thin wrapper over a pickled tiktoken encoding."""
2
+
3
+ import os
4
+ import pickle
5
+ from functools import lru_cache
6
+
7
+
8
+ class ChronicleTokenizer:
9
+ """131k-vocabulary BPE tokenizer (tiktoken encoding, trained from scratch)."""
10
+
11
+ def __init__(self, enc, bos_token="<|bos|>"):
12
+ self.enc = enc
13
+ self.bos_token_id = self.encode_special(bos_token)
14
+
15
+ @classmethod
16
+ def from_directory(cls, tokenizer_dir):
17
+ with open(os.path.join(tokenizer_dir, "tokenizer.pkl"), "rb") as f:
18
+ return cls(pickle.load(f))
19
+
20
+ def get_vocab_size(self):
21
+ return self.enc.n_vocab
22
+
23
+ @lru_cache(maxsize=32)
24
+ def encode_special(self, text):
25
+ return self.enc.encode_single_token(text)
26
+
27
+ def get_bos_token_id(self):
28
+ return self.bos_token_id
29
+
30
+ def encode(self, text):
31
+ return self.enc.encode_ordinary(text)
32
+
33
+ def decode(self, ids):
34
+ return self.enc.decode(ids)
tokenizer/token_bytes.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f2245c7eb1ad8b5f0af416fbfdf6160dfec2408562e0fef7c5ad9df3284ab52c
3
+ size 525865
tokenizer/tokenizer.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f825be66c8a987473b85ab925de64cd317839ce5c05571eedef0037564240ff6
3
+ size 1832885