Initial commit
Browse files- README.md +155 -0
- model.py +560 -0
- stage-1/config.json +10 -0
- stage-1/model.safetensors +3 -0
- stage-2/config.json +10 -0
- stage-2/model.safetensors +3 -0
- tokenizer.py +34 -0
- tokenizer/token_bytes.pt +3 -0
- tokenizer/tokenizer.pkl +3 -0
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
|