multimodalart HF Staff commited on
Commit
bdd9175
·
verified ·
1 Parent(s): 21ccefe

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,13 +1,37 @@
1
  ---
2
- title: Tinycast Forecaster
3
- emoji: 🏆
4
- colorFrom: green
5
  colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.25.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: TinyCast Forecaster
3
+ emoji: 📈
4
+ colorFrom: gray
5
  colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.25.0
 
8
  app_file: app.py
9
+ short_description: TinyCast zero-shot probabilistic time-series forecasting
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 30m
12
  ---
13
 
14
+ # TinyCast Forecaster
15
+
16
+ A Gradio demo for [TinyCast](https://huggingface.co/raws-labs/tinycast), an attention-free,
17
+ 146,505-parameter time-series foundation model that forecasts unseen series zero-shot and
18
+ returns nine quantile forecasts.
19
+
20
+ ## How it works
21
+
22
+ 1. Upload a CSV file with a value column (or paste numeric values directly)
23
+ 2. Select the frequency/domain of your time series
24
+ 3. Choose a forecast horizon (1–512 steps)
25
+ 4. Get a probabilistic forecast plot with quantile confidence bands
26
+
27
+ The model replaces self-attention with dilated causal convolutions and a zero-parameter
28
+ normalized-periodogram phase prior, so periodicity is computed from the context instead
29
+ of learned. Every learned operation is a convolution, a matrix multiplication or a
30
+ normalization, so the model streams in constant memory and runs on CPU.
31
+
32
+ ## Model
33
+
34
+ - **Weights**: [raws-labs/tinycast](https://huggingface.co/raws-labs/tinycast)
35
+ - **Code**: [github.com/raws-labs/tinycast](https://github.com/raws-labs/tinycast)
36
+ - **Paper**: [arXiv:2608.15767](https://arxiv.org/abs/2608.15767)
37
+ - **License**: Apache-2.0
app.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyCast: Probabilistic Zero-Shot Forecasting — Gradio demo.
2
+
3
+ TinyCast is a 146K-parameter attention-free time-series foundation model that
4
+ forecasts unseen series zero-shot and returns nine quantile forecasts.
5
+ https://huggingface.co/raws-labs/tinycast
6
+ """
7
+
8
+ import os
9
+ import io
10
+ import time
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ import gradio as gr
16
+ import matplotlib
17
+
18
+ matplotlib.use("Agg")
19
+ import matplotlib.pyplot as plt
20
+
21
+ import torch
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Model loading — at module scope, CPU (the model is 146K params, ~0.6 MB).
25
+ # ---------------------------------------------------------------------------
26
+ from huggingface_hub import hf_hub_download
27
+
28
+ HF_REPO = "raws-labs/tinycast"
29
+
30
+ # Download config.json and model.safetensors (config.json lands beside weights)
31
+ _hf_config = hf_hub_download(HF_REPO, "config.json")
32
+ WEIGHTS_PATH = hf_hub_download(HF_REPO, "model.safetensors")
33
+
34
+ # The tinycast package ships on GitHub; we vendor it into the Space repo.
35
+ import sys
36
+ sys.path.insert(0, str(Path(__file__).parent))
37
+
38
+ from tinycast import TinyCastPredictor
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Frequency → (freq_label, domain) mapping for the seasonal scale factor.
43
+ # The model needs a pandas frequency string and a domain for the scale factor.
44
+ # ---------------------------------------------------------------------------
45
+ FREQ_MAP = {
46
+ "Hourly (H)": ("H", "Energy"),
47
+ "Daily (D)": ("D", "Sales"),
48
+ "Weekly (W)": ("W", "Sales"),
49
+ "Monthly (M)": ("M", "Sales"),
50
+ "Quarterly (Q)": ("Q", "Economic"),
51
+ "4-hourly (4H)": ("4H", "Energy"),
52
+ "10-minute (10T)": ("10T", "Energy"),
53
+ "Minute (T)": ("T", "Energy"),
54
+ }
55
+
56
+
57
+ def parse_csv(file_obj):
58
+ """Parse an uploaded CSV file into a numpy array of values."""
59
+ if file_obj is None:
60
+ return None, None
61
+ try:
62
+ df = pd.read_csv(file_obj.name)
63
+ except Exception:
64
+ df = pd.read_csv(file_obj)
65
+
66
+ # Try to find a value column: look for common names, else take last column
67
+ value_cols = [c for c in df.columns if c.lower() in ("value", "y", "target", "v", "measurement")]
68
+ if value_cols:
69
+ values = df[value_cols[0]].values
70
+ else:
71
+ values = df.iloc[:, -1].values
72
+
73
+ values = pd.to_numeric(pd.Series(values), errors="coerce").values.astype("float32")
74
+ return values, df
75
+
76
+
77
+ def parse_text_input(text):
78
+ """Parse pasted numeric values (comma or newline separated)."""
79
+ if not text or not text.strip():
80
+ return None
81
+ parts = text.replace(",", " ").replace("\n", " ").split()
82
+ vals = []
83
+ for p in parts:
84
+ try:
85
+ vals.append(float(p))
86
+ except ValueError:
87
+ pass
88
+ if not vals:
89
+ return None
90
+ return np.array(vals, dtype="float32")
91
+
92
+
93
+ def forecast(
94
+ csv_file,
95
+ text_input,
96
+ freq_choice,
97
+ prediction_length,
98
+ flip_invariance,
99
+ ):
100
+ """Run TinyCast zero-shot forecast and return a quantile plot."""
101
+ # Get the time series values
102
+ if csv_file is not None:
103
+ values, _ = parse_csv(csv_file)
104
+ else:
105
+ values = parse_text_input(text_input)
106
+
107
+ if values is None or len(values) < 10:
108
+ return None, "Please provide at least 10 data points (via CSV upload or text input)."
109
+
110
+ freq_str, domain = FREQ_MAP.get(freq_choice, ("H", "Energy"))
111
+
112
+ # Build gluonts-style entry
113
+ try:
114
+ start = pd.Period("2020-01-01 00", freq=freq_str.lower())
115
+ except Exception:
116
+ start = pd.Period("2020-01-01", freq="D")
117
+
118
+ entry = {"target": values, "start": start, "item_id": "input"}
119
+
120
+ # Build the predictor
121
+ t0 = time.perf_counter()
122
+ predictor = TinyCastPredictor(
123
+ prediction_length=int(prediction_length),
124
+ checkpoint_path=WEIGHTS_PATH,
125
+ freq=freq_str,
126
+ domain=domain,
127
+ device="cpu",
128
+ force_flip_invariance=bool(flip_invariance),
129
+ )
130
+ forecasts = predictor.predict([entry])
131
+ elapsed = time.perf_counter() - t0
132
+
133
+ fc = forecasts[0]
134
+ # forecast_array is (Q, prediction_length) with quantile levels ascending
135
+ arr = fc.forecast_array # (Q, pl)
136
+ quantile_levels = [float(k) for k in fc.forecast_keys]
137
+ pl = arr.shape[1]
138
+
139
+ # Build the plot
140
+ fig, ax = plt.subplots(figsize=(12, 5))
141
+
142
+ # Plot context (last 200 points for readability)
143
+ context_show = min(200, len(values))
144
+ ctx_x = np.arange(-context_show, 0)
145
+ ax.plot(ctx_x, values[-context_show:], color="black", linewidth=1.0, label="Context (observed)")
146
+
147
+ # Plot forecast quantiles
148
+ fut_x = np.arange(0, pl)
149
+ q_colors = plt.cm.viridis(np.linspace(0.15, 0.85, len(quantile_levels)))
150
+
151
+ # Fill between outer quantiles for the confidence band
152
+ if len(quantile_levels) >= 2:
153
+ ax.fill_between(
154
+ fut_x, arr[0, :], arr[-1, :],
155
+ color="steelblue", alpha=0.15, label=f"{int(float(quantile_levels[0])*100)}%–{int(float(quantile_levels[-1])*100)}% interval",
156
+ )
157
+ if len(quantile_levels) >= 4:
158
+ mid_lo = len(quantile_levels) // 4
159
+ mid_hi = 3 * len(quantile_levels) // 4
160
+ ax.fill_between(
161
+ fut_x, arr[mid_lo, :], arr[mid_hi, :],
162
+ color="steelblue", alpha=0.25, label=f"{int(float(quantile_levels[mid_lo])*100)}%–{int(float(quantile_levels[mid_hi])*100)}% interval",
163
+ )
164
+
165
+ # Median line
166
+ qm = len(quantile_levels) // 2
167
+ ax.plot(fut_x, arr[qm, :], color="crimson", linewidth=2.0, label="Median forecast")
168
+
169
+ # Outer quantile lines
170
+ for i in [0, -1]:
171
+ ax.plot(fut_x, arr[i, :], color=q_colors[i], linewidth=0.8, linestyle="--", alpha=0.5)
172
+
173
+ ax.axvline(x=0, color="gray", linestyle=":", linewidth=0.8)
174
+ ax.set_xlabel("Time (relative)")
175
+ ax.set_ylabel("Value")
176
+ ax.set_title(f"TinyCast {int(prediction_length)}-step probabilistic forecast ({freq_choice})")
177
+ ax.legend(loc="upper left", fontsize=8)
178
+ fig.tight_layout()
179
+
180
+ # Summary text
181
+ info = (
182
+ f"Context: {len(values)} points | Frequency: {freq_str} | Domain: {domain} | "
183
+ f"Horizon: {pl} steps | Quantiles: {len(quantile_levels)} | "
184
+ f"Flip-invariance: {'on' if flip_invariance else 'off'} | "
185
+ f"Inference: {elapsed:.2f}s"
186
+ )
187
+
188
+ plt.close(fig)
189
+ return fig, info
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Build the Gradio UI
194
+ # ---------------------------------------------------------------------------
195
+ CSS = """
196
+ #main-col { max-width: 1200px; margin: 0 auto; }
197
+ .dark .gradio-container { color: var(--body-text-color); }
198
+ """
199
+
200
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="TinyCast Forecaster") as demo:
201
+ gr.Markdown(
202
+ "# TinyCast: Probabilistic Zero-Shot Forecasting\n\n"
203
+ "An attention-free, 146K-parameter time-series foundation model that forecasts "
204
+ "unseen series zero-shot and returns nine quantile forecasts. "
205
+ "[Model](https://huggingface.co/raws-labs/tinycast) · "
206
+ "[Paper](https://arxiv.org/abs/2608.15767) · "
207
+ "[Code](https://github.com/raws-labs/tinycast)"
208
+ )
209
+
210
+ with gr.Column(elem_id="main-col"):
211
+ with gr.Row():
212
+ with gr.Column(scale=1):
213
+ gr.Markdown("### Input")
214
+ csv_input = gr.File(label="Upload CSV (value column or last column used)", file_types=[".csv"])
215
+ gr.Markdown("**— or —**")
216
+ text_input = gr.Textbox(
217
+ label="Paste values (comma or newline separated)",
218
+ placeholder="10.0, 12.3, 9.8, 11.1, ...",
219
+ lines=3,
220
+ )
221
+ freq_choice = gr.Dropdown(
222
+ choices=list(FREQ_MAP.keys()),
223
+ value="Hourly (H)",
224
+ label="Frequency / Domain",
225
+ )
226
+ prediction_length = gr.Slider(
227
+ minimum=1, maximum=512, value=48, step=1,
228
+ label="Forecast horizon (steps)",
229
+ )
230
+ flip_inv = gr.Checkbox(
231
+ value=True,
232
+ label="Flip-invariance symmetrization (improves accuracy)",
233
+ )
234
+ run_btn = gr.Button("Forecast", variant="primary")
235
+
236
+ with gr.Column(scale=2):
237
+ gr.Markdown("### Forecast")
238
+ plot_output = gr.Plot(label="Probabilistic forecast")
239
+ info_output = gr.Textbox(label="Summary", interactive=False)
240
+
241
+ gr.Markdown("### Examples")
242
+ gr.Examples(
243
+ examples=[
244
+ ["example_hourly_energy.csv", None, "Hourly (H)", 48, True],
245
+ ["example_daily_sales.csv", None, "Daily (D)", 30, True],
246
+ ["example_monthly_temp.csv", None, "Monthly (M)", 24, True],
247
+ ],
248
+ inputs=[csv_input, text_input, freq_choice, prediction_length, flip_inv],
249
+ outputs=[plot_output, info_output],
250
+ fn=forecast,
251
+ cache_examples=True,
252
+ cache_mode="lazy",
253
+ )
254
+
255
+ gr.Markdown(
256
+ "\n\n---\n"
257
+ "TinyCast replaces self-attention with dilated causal convolutions and a "
258
+ "zero-parameter normalized-periodogram phase prior. The model is 146,505 "
259
+ "parameters (~0.6 MB) and runs on CPU. License: Apache-2.0."
260
+ )
261
+
262
+
263
+ if __name__ == "__main__":
264
+ demo.launch(mcp_server=True)
example_daily_sales.csv ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ timestamp,value
2
+ 2023-01-01,50.91415
3
+ 2023-01-02,54.748363
4
+ 2023-01-03,62.10063
5
+ 2023-01-04,57.31053
6
+ 2023-01-05,40.008057
7
+ 2023-01-06,36.59418
8
+ 2023-01-07,42.865208
9
+ 2023-01-08,49.40127
10
+ 2023-01-09,58.16791
11
+ 2023-01-10,57.64015
12
+ 2023-01-11,57.47703
13
+ 2023-01-12,48.544537
14
+ 2023-01-13,41.048813
15
+ 2023-01-14,46.21341
16
+ 2023-01-15,52.102528
17
+ 2023-01-16,55.990437
18
+ 2023-01-17,61.655533
19
+ 2023-01-18,52.31219
20
+ 2023-01-19,49.196514
21
+ 2023-01-20,41.05094
22
+ 2023-01-21,42.627098
23
+ 2023-01-22,49.00721
24
+ 2023-01-23,62.585938
25
+ 2023-01-24,60.43569
26
+ 2023-01-25,54.253853
27
+ 2023-01-26,45.854763
28
+ 2023-01-27,43.147648
29
+ 2023-01-28,44.628017
30
+ 2023-01-29,52.6382
31
+ 2023-01-30,60.56078
32
+ 2023-01-31,67.674225
33
+ 2023-02-01,54.669594
34
+ 2023-02-02,45.724434
35
+ 2023-02-03,39.459404
36
+ 2023-02-04,45.72962
37
+ 2023-02-05,55.136917
38
+ 2023-02-06,59.276474
39
+ 2023-02-07,59.078808
40
+ 2023-02-08,53.765392
41
+ 2023-02-09,49.562943
42
+ 2023-02-10,44.480484
43
+ 2023-02-11,45.86115
44
+ 2023-02-12,50.10347
45
+ 2023-02-13,60.6648
46
+ 2023-02-14,62.299335
47
+ 2023-02-15,57.244904
48
+ 2023-02-16,50.575447
49
+ 2023-02-17,43.271507
50
+ 2023-02-18,46.618427
51
+ 2023-02-19,52.652737
52
+ 2023-02-20,61.185673
53
+ 2023-02-21,64.193146
54
+ 2023-02-22,52.56737
55
+ 2023-02-23,47.35215
56
+ 2023-02-24,41.539604
57
+ 2023-02-25,43.015053
58
+ 2023-02-26,51.974575
59
+ 2023-02-27,65.15314
60
+ 2023-02-28,60.051785
61
+ 2023-03-01,60.193672
62
+ 2023-03-02,43.612553
63
+ 2023-03-03,42.296066
64
+ 2023-03-04,45.769943
65
+ 2023-03-05,54.90867
66
+ 2023-03-06,63.151993
67
+ 2023-03-07,65.37932
68
+ 2023-03-08,56.592663
69
+ 2023-03-09,47.624107
70
+ 2023-03-10,46.224648
71
+ 2023-03-11,45.057774
72
+ 2023-03-12,49.67294
73
+ 2023-03-13,57.968452
74
+ 2023-03-14,60.590923
75
+ 2023-03-15,59.48032
76
+ 2023-03-16,49.78844
77
+ 2023-03-17,46.072178
78
+ 2023-03-18,44.69993
79
+ 2023-03-19,54.32562
80
+ 2023-03-20,63.595085
81
+ 2023-03-21,62.77124
82
+ 2023-03-22,59.709164
83
+ 2023-03-23,47.725384
84
+ 2023-03-24,43.26156
85
+ 2023-03-25,45.18647
86
+ 2023-03-26,50.61248
87
+ 2023-03-27,63.529232
88
+ 2023-03-28,62.64107
89
+ 2023-03-29,58.72632
90
+ 2023-03-30,51.503403
91
+ 2023-03-31,46.040314
92
+ 2023-04-01,48.67784
93
+ 2023-04-02,54.254543
94
+ 2023-04-03,61.14842
95
+ 2023-04-04,64.160126
96
+ 2023-04-05,53.976833
97
+ 2023-04-06,46.069824
98
+ 2023-04-07,41.082623
99
+ 2023-04-08,44.039944
100
+ 2023-04-09,56.099323
101
+ 2023-04-10,60.051876
102
+ 2023-04-11,63.61479
103
+ 2023-04-12,63.286522
104
+ 2023-04-13,49.69237
105
+ 2023-04-14,47.613266
106
+ 2023-04-15,44.580833
107
+ 2023-04-16,54.633686
108
+ 2023-04-17,60.26825
109
+ 2023-04-18,64.08218
110
+ 2023-04-19,62.25976
111
+ 2023-04-20,45.929203
112
+ 2023-04-21,47.053993
113
+ 2023-04-22,48.444893
114
+ 2023-04-23,53.81755
115
+ 2023-04-24,59.130142
116
+ 2023-04-25,65.665665
117
+ 2023-04-26,58.50036
118
+ 2023-04-27,52.15919
119
+ 2023-04-28,46.16628
120
+ 2023-04-29,52.88702
121
+ 2023-04-30,55.231934
122
+ 2023-05-01,60.74782
123
+ 2023-05-02,66.337105
124
+ 2023-05-03,61.098827
125
+ 2023-05-04,55.888725
126
+ 2023-05-05,48.956055
127
+ 2023-05-06,49.502296
128
+ 2023-05-07,60.689907
129
+ 2023-05-08,60.602024
130
+ 2023-05-09,64.23003
131
+ 2023-05-10,58.00911
132
+ 2023-05-11,50.991734
133
+ 2023-05-12,42.670662
134
+ 2023-05-13,50.687138
135
+ 2023-05-14,55.983334
136
+ 2023-05-15,60.105896
137
+ 2023-05-16,63.45254
138
+ 2023-05-17,62.07938
139
+ 2023-05-18,55.025543
140
+ 2023-05-19,53.140915
141
+ 2023-05-20,57.873272
142
+ 2023-05-21,58.24323
143
+ 2023-05-22,61.8997
144
+ 2023-05-23,60.45314
145
+ 2023-05-24,62.291973
146
+ 2023-05-25,50.42234
147
+ 2023-05-26,46.25465
148
+ 2023-05-27,47.645393
149
+ 2023-05-28,56.927628
150
+ 2023-05-29,68.41625
151
+ 2023-05-30,67.670425
152
+ 2023-05-31,61.362934
153
+ 2023-06-01,50.104202
154
+ 2023-06-02,42.82667
155
+ 2023-06-03,48.37276
156
+ 2023-06-04,57.53865
157
+ 2023-06-05,70.8721
158
+ 2023-06-06,67.9401
159
+ 2023-06-07,65.137054
160
+ 2023-06-08,52.063274
161
+ 2023-06-09,44.64589
162
+ 2023-06-10,47.286335
163
+ 2023-06-11,55.87432
164
+ 2023-06-12,72.30373
165
+ 2023-06-13,65.43512
166
+ 2023-06-14,65.054306
167
+ 2023-06-15,51.20238
168
+ 2023-06-16,51.34544
169
+ 2023-06-17,51.68654
170
+ 2023-06-18,57.930088
171
+ 2023-06-19,66.14603
172
+ 2023-06-20,66.28492
173
+ 2023-06-21,64.22705
174
+ 2023-06-22,52.896214
175
+ 2023-06-23,45.223904
176
+ 2023-06-24,47.04787
177
+ 2023-06-25,59.267765
178
+ 2023-06-26,71.35559
179
+ 2023-06-27,69.079254
180
+ 2023-06-28,62.882923
181
+ 2023-06-29,55.46864
182
+ 2023-06-30,53.168728
183
+ 2023-07-01,51.88983
184
+ 2023-07-02,57.867218
185
+ 2023-07-03,70.28718
186
+ 2023-07-04,70.23555
187
+ 2023-07-05,68.196106
188
+ 2023-07-06,55.510864
189
+ 2023-07-07,45.927315
190
+ 2023-07-08,47.477207
191
+ 2023-07-09,64.40279
192
+ 2023-07-10,72.48931
193
+ 2023-07-11,68.76072
194
+ 2023-07-12,62.789276
195
+ 2023-07-13,59.695496
196
+ 2023-07-14,46.629585
197
+ 2023-07-15,49.247505
198
+ 2023-07-16,61.72998
199
+ 2023-07-17,66.4845
200
+ 2023-07-18,69.63391
201
+ 2023-07-19,63.798508
202
+ 2023-07-20,56.673885
203
+ 2023-07-21,54.523167
204
+ 2023-07-22,52.55344
205
+ 2023-07-23,62.081818
206
+ 2023-07-24,61.867798
207
+ 2023-07-25,69.85313
208
+ 2023-07-26,62.109146
209
+ 2023-07-27,52.354725
210
+ 2023-07-28,48.016262
211
+ 2023-07-29,51.629314
212
+ 2023-07-30,63.247707
213
+ 2023-07-31,64.38914
214
+ 2023-08-01,70.44118
215
+ 2023-08-02,63.536327
216
+ 2023-08-03,55.378143
217
+ 2023-08-04,54.008995
218
+ 2023-08-05,54.59603
219
+ 2023-08-06,64.8622
220
+ 2023-08-07,68.2548
221
+ 2023-08-08,68.61145
222
+ 2023-08-09,64.66726
223
+ 2023-08-10,57.438652
224
+ 2023-08-11,51.88044
225
+ 2023-08-12,50.07852
226
+ 2023-08-13,61.47147
227
+ 2023-08-14,69.753
228
+ 2023-08-15,78.6017
229
+ 2023-08-16,71.319374
230
+ 2023-08-17,54.501434
231
+ 2023-08-18,50.83857
232
+ 2023-08-19,49.29136
233
+ 2023-08-20,59.777878
234
+ 2023-08-21,70.36513
235
+ 2023-08-22,75.01684
236
+ 2023-08-23,63.851585
237
+ 2023-08-24,55.448723
238
+ 2023-08-25,45.608852
239
+ 2023-08-26,53.543686
240
+ 2023-08-27,58.712757
241
+ 2023-08-28,68.18
242
+ 2023-08-29,69.1187
243
+ 2023-08-30,66.10605
244
+ 2023-08-31,52.487976
245
+ 2023-09-01,47.999584
246
+ 2023-09-02,60.76943
247
+ 2023-09-03,58.387733
248
+ 2023-09-04,66.82796
249
+ 2023-09-05,77.61002
250
+ 2023-09-06,75.45404
251
+ 2023-09-07,54.596462
252
+ 2023-09-08,51.645973
253
+ 2023-09-09,55.75635
254
+ 2023-09-10,67.786095
255
+ 2023-09-11,67.507744
256
+ 2023-09-12,71.71345
257
+ 2023-09-13,69.42085
258
+ 2023-09-14,59.76546
259
+ 2023-09-15,51.97225
260
+ 2023-09-16,54.680218
261
+ 2023-09-17,58.825314
262
+ 2023-09-18,70.10379
263
+ 2023-09-19,72.000114
264
+ 2023-09-20,68.135345
265
+ 2023-09-21,57.14518
266
+ 2023-09-22,54.865337
267
+ 2023-09-23,58.469833
268
+ 2023-09-24,63.76629
269
+ 2023-09-25,72.22359
270
+ 2023-09-26,73.30875
271
+ 2023-09-27,67.78909
272
+ 2023-09-28,56.996487
273
+ 2023-09-29,54.750202
274
+ 2023-09-30,55.489826
275
+ 2023-10-01,69.929504
276
+ 2023-10-02,76.23838
277
+ 2023-10-03,74.65682
278
+ 2023-10-04,65.84966
279
+ 2023-10-05,56.173927
280
+ 2023-10-06,57.72415
281
+ 2023-10-07,56.919933
282
+ 2023-10-08,65.44043
283
+ 2023-10-09,66.63456
284
+ 2023-10-10,76.63159
285
+ 2023-10-11,69.8521
286
+ 2023-10-12,56.52987
287
+ 2023-10-13,53.086147
288
+ 2023-10-14,57.27284
289
+ 2023-10-15,64.5074
290
+ 2023-10-16,71.341805
291
+ 2023-10-17,73.88882
292
+ 2023-10-18,68.08291
293
+ 2023-10-19,60.66885
294
+ 2023-10-20,59.265198
295
+ 2023-10-21,49.13171
296
+ 2023-10-22,63.98945
297
+ 2023-10-23,73.097855
298
+ 2023-10-24,75.43726
299
+ 2023-10-25,68.0731
300
+ 2023-10-26,55.290997
301
+ 2023-10-27,56.184708
302
+ 2023-10-28,62.363735
303
+ 2023-10-29,60.448418
304
+ 2023-10-30,75.509796
305
+ 2023-10-31,73.913704
306
+ 2023-11-01,69.354866
307
+ 2023-11-02,57.75247
308
+ 2023-11-03,54.54735
309
+ 2023-11-04,61.43182
310
+ 2023-11-05,67.147964
311
+ 2023-11-06,78.46525
312
+ 2023-11-07,78.78152
313
+ 2023-11-08,71.2061
314
+ 2023-11-09,66.492966
315
+ 2023-11-10,57.2177
316
+ 2023-11-11,60.36565
317
+ 2023-11-12,64.86029
318
+ 2023-11-13,73.817955
319
+ 2023-11-14,73.50701
320
+ 2023-11-15,73.20759
321
+ 2023-11-16,58.076252
322
+ 2023-11-17,58.59777
323
+ 2023-11-18,57.659733
324
+ 2023-11-19,69.61374
325
+ 2023-11-20,76.220924
326
+ 2023-11-21,81.41122
327
+ 2023-11-22,72.78116
328
+ 2023-11-23,57.24504
329
+ 2023-11-24,56.39986
330
+ 2023-11-25,55.065662
331
+ 2023-11-26,64.89516
332
+ 2023-11-27,78.852
333
+ 2023-11-28,78.21188
334
+ 2023-11-29,68.84205
335
+ 2023-11-30,59.27001
336
+ 2023-12-01,57.04907
337
+ 2023-12-02,55.282005
338
+ 2023-12-03,64.78658
339
+ 2023-12-04,75.60434
340
+ 2023-12-05,80.11521
341
+ 2023-12-06,73.11512
342
+ 2023-12-07,55.787292
343
+ 2023-12-08,58.21382
344
+ 2023-12-09,59.497787
345
+ 2023-12-10,68.39167
346
+ 2023-12-11,79.86694
347
+ 2023-12-12,70.80956
348
+ 2023-12-13,69.865524
349
+ 2023-12-14,64.78388
350
+ 2023-12-15,52.905937
351
+ 2023-12-16,64.05953
352
+ 2023-12-17,68.60507
353
+ 2023-12-18,77.908066
354
+ 2023-12-19,75.63645
355
+ 2023-12-20,74.43013
356
+ 2023-12-21,66.566574
357
+ 2023-12-22,58.699356
358
+ 2023-12-23,60.684887
359
+ 2023-12-24,68.661026
360
+ 2023-12-25,73.12828
361
+ 2023-12-26,77.25669
362
+ 2023-12-27,71.88127
363
+ 2023-12-28,64.86134
364
+ 2023-12-29,61.350193
365
+ 2023-12-30,57.15608
366
+ 2023-12-31,67.824974
example_hourly_energy.csv ADDED
@@ -0,0 +1,1025 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ timestamp,value
2
+ 2020-01-01 00:00:00,10.0251465
3
+ 2020-01-01 01:00:00,10.768732
4
+ 2020-01-01 02:00:00,11.665449
5
+ 2020-01-01 03:00:00,12.198282
6
+ 2020-01-01 04:00:00,12.565463
7
+ 2020-01-01 05:00:00,13.063052
8
+ 2020-01-01 06:00:00,13.372061
9
+ 2020-01-01 07:00:00,13.216603
10
+ 2020-01-01 08:00:00,12.604707
11
+ 2020-01-01 09:00:00,12.033376
12
+ 2020-01-01 10:00:00,11.558016
13
+ 2020-01-01 11:00:00,10.984669
14
+ 2020-01-01 12:00:00,9.751936
15
+ 2020-01-01 13:00:00,9.413419
16
+ 2020-01-01 14:00:00,8.500818
17
+ 2020-01-01 15:00:00,7.9982424
18
+ 2020-01-01 16:00:00,7.574732
19
+ 2020-01-01 17:00:00,7.3358727
20
+ 2020-01-01 18:00:00,7.394071
21
+ 2020-01-01 19:00:00,7.636869
22
+ 2020-01-01 20:00:00,7.7163033
23
+ 2020-01-01 21:00:00,8.505526
24
+ 2020-01-01 22:00:00,8.733487
25
+ 2020-01-01 23:00:00,9.672831
26
+ 2020-01-02 00:00:00,10.5716095
27
+ 2020-01-02 01:00:00,11.197558
28
+ 2020-01-02 02:00:00,11.76442
29
+ 2020-01-02 03:00:00,12.360337
30
+ 2020-01-02 04:00:00,12.939544
31
+ 2020-01-02 05:00:00,13.383874
32
+ 2020-01-02 06:00:00,13.248561
33
+ 2020-01-02 07:00:00,13.314223
34
+ 2020-01-02 08:00:00,13.031668
35
+ 2020-01-02 09:00:00,12.701431
36
+ 2020-01-02 10:00:00,12.020719
37
+ 2020-01-02 11:00:00,11.330495
38
+ 2020-01-02 12:00:00,10.356698
39
+ 2020-01-02 13:00:00,9.688904
40
+ 2020-01-02 14:00:00,9.151211
41
+ 2020-01-02 15:00:00,8.674222
42
+ 2020-01-02 16:00:00,7.6487126
43
+ 2020-01-02 17:00:00,7.904658
44
+ 2020-01-02 18:00:00,7.769175
45
+ 2020-01-02 19:00:00,7.7581353
46
+ 2020-01-02 20:00:00,7.953417
47
+ 2020-01-02 21:00:00,8.312751
48
+ 2020-01-02 22:00:00,9.286019
49
+ 2020-01-02 23:00:00,10.106877
50
+ 2020-01-03 00:00:00,10.847791
51
+ 2020-01-03 01:00:00,11.522441
52
+ 2020-01-03 02:00:00,12.049262
53
+ 2020-01-03 03:00:00,12.351599
54
+ 2020-01-03 04:00:00,13.062622
55
+ 2020-01-03 05:00:00,13.487353
56
+ 2020-01-03 06:00:00,13.192812
57
+ 2020-01-03 07:00:00,13.4188595
58
+ 2020-01-03 08:00:00,13.117062
59
+ 2020-01-03 09:00:00,12.683891
60
+ 2020-01-03 10:00:00,11.676296
61
+ 2020-01-03 11:00:00,11.046415
62
+ 2020-01-03 12:00:00,10.303629
63
+ 2020-01-03 13:00:00,9.368568
64
+ 2020-01-03 14:00:00,9.214399
65
+ 2020-01-03 15:00:00,8.133051
66
+ 2020-01-03 16:00:00,7.807804
67
+ 2020-01-03 17:00:00,7.376652
68
+ 2020-01-03 18:00:00,7.6284394
69
+ 2020-01-03 19:00:00,7.6632047
70
+ 2020-01-03 20:00:00,7.8102546
71
+ 2020-01-03 21:00:00,7.703994
72
+ 2020-01-03 22:00:00,8.760406
73
+ 2020-01-03 23:00:00,9.593914
74
+ 2020-01-04 00:00:00,10.417734
75
+ 2020-01-04 01:00:00,10.852821
76
+ 2020-01-04 02:00:00,12.047072
77
+ 2020-01-04 03:00:00,12.022374
78
+ 2020-01-04 04:00:00,12.613148
79
+ 2020-01-04 05:00:00,13.214197
80
+ 2020-01-04 06:00:00,13.121072
81
+ 2020-01-04 07:00:00,13.3912115
82
+ 2020-01-04 08:00:00,12.710301
83
+ 2020-01-04 09:00:00,12.050664
84
+ 2020-01-04 10:00:00,11.461852
85
+ 2020-01-04 11:00:00,10.576923
86
+ 2020-01-04 12:00:00,9.744464
87
+ 2020-01-04 13:00:00,9.33093
88
+ 2020-01-04 14:00:00,8.578868
89
+ 2020-01-04 15:00:00,8.081609
90
+ 2020-01-04 16:00:00,7.1764817
91
+ 2020-01-04 17:00:00,7.3470883
92
+ 2020-01-04 18:00:00,6.831262
93
+ 2020-01-04 19:00:00,7.2876945
94
+ 2020-01-04 20:00:00,7.1679893
95
+ 2020-01-04 21:00:00,7.5664434
96
+ 2020-01-04 22:00:00,8.367287
97
+ 2020-01-04 23:00:00,9.229887
98
+ 2020-01-05 00:00:00,9.81526
99
+ 2020-01-05 01:00:00,10.425717
100
+ 2020-01-05 02:00:00,10.981756
101
+ 2020-01-05 03:00:00,11.575
102
+ 2020-01-05 04:00:00,12.416953
103
+ 2020-01-05 05:00:00,12.79881
104
+ 2020-01-05 06:00:00,12.655396
105
+ 2020-01-05 07:00:00,12.356761
106
+ 2020-01-05 08:00:00,12.432598
107
+ 2020-01-05 09:00:00,11.511688
108
+ 2020-01-05 10:00:00,10.99086
109
+ 2020-01-05 11:00:00,10.521675
110
+ 2020-01-05 12:00:00,9.159056
111
+ 2020-01-05 13:00:00,8.898518
112
+ 2020-01-05 14:00:00,7.9705524
113
+ 2020-01-05 15:00:00,7.4771733
114
+ 2020-01-05 16:00:00,6.9537706
115
+ 2020-01-05 17:00:00,6.7005877
116
+ 2020-01-05 18:00:00,6.6883497
117
+ 2020-01-05 19:00:00,6.4922676
118
+ 2020-01-05 20:00:00,7.220683
119
+ 2020-01-05 21:00:00,7.5519567
120
+ 2020-01-05 22:00:00,8.19096
121
+ 2020-01-05 23:00:00,8.973553
122
+ 2020-01-06 00:00:00,9.6700535
123
+ 2020-01-06 01:00:00,10.45399
124
+ 2020-01-06 02:00:00,11.020703
125
+ 2020-01-06 03:00:00,11.339109
126
+ 2020-01-06 04:00:00,12.072465
127
+ 2020-01-06 05:00:00,12.244225
128
+ 2020-01-06 06:00:00,12.215451
129
+ 2020-01-06 07:00:00,12.449818
130
+ 2020-01-06 08:00:00,11.9857645
131
+ 2020-01-06 09:00:00,11.418504
132
+ 2020-01-06 10:00:00,10.796985
133
+ 2020-01-06 11:00:00,10.338858
134
+ 2020-01-06 12:00:00,9.5842705
135
+ 2020-01-06 13:00:00,9.005072
136
+ 2020-01-06 14:00:00,8.019431
137
+ 2020-01-06 15:00:00,7.615106
138
+ 2020-01-06 16:00:00,7.21694
139
+ 2020-01-06 17:00:00,6.8739743
140
+ 2020-01-06 18:00:00,6.0764546
141
+ 2020-01-06 19:00:00,6.9059014
142
+ 2020-01-06 20:00:00,7.036835
143
+ 2020-01-06 21:00:00,7.540072
144
+ 2020-01-06 22:00:00,8.161126
145
+ 2020-01-06 23:00:00,8.897796
146
+ 2020-01-07 00:00:00,9.672967
147
+ 2020-01-07 01:00:00,10.325688
148
+ 2020-01-07 02:00:00,10.753147
149
+ 2020-01-07 03:00:00,11.745984
150
+ 2020-01-07 04:00:00,12.097243
151
+ 2020-01-07 05:00:00,12.787666
152
+ 2020-01-07 06:00:00,12.630502
153
+ 2020-01-07 07:00:00,12.617562
154
+ 2020-01-07 08:00:00,12.146495
155
+ 2020-01-07 09:00:00,11.75318
156
+ 2020-01-07 10:00:00,11.247693
157
+ 2020-01-07 11:00:00,10.245748
158
+ 2020-01-07 12:00:00,9.843195
159
+ 2020-01-07 13:00:00,9.002382
160
+ 2020-01-07 14:00:00,8.080186
161
+ 2020-01-07 15:00:00,7.2338934
162
+ 2020-01-07 16:00:00,7.3571568
163
+ 2020-01-07 17:00:00,6.913296
164
+ 2020-01-07 18:00:00,6.7827377
165
+ 2020-01-07 19:00:00,6.9620357
166
+ 2020-01-07 20:00:00,7.6906977
167
+ 2020-01-07 21:00:00,7.812737
168
+ 2020-01-07 22:00:00,8.479959
169
+ 2020-01-07 23:00:00,8.907433
170
+ 2020-01-08 00:00:00,10.329468
171
+ 2020-01-08 01:00:00,10.97865
172
+ 2020-01-08 02:00:00,11.750752
173
+ 2020-01-08 03:00:00,12.186837
174
+ 2020-01-08 04:00:00,12.855928
175
+ 2020-01-08 05:00:00,13.064922
176
+ 2020-01-08 06:00:00,13.233898
177
+ 2020-01-08 07:00:00,12.996748
178
+ 2020-01-08 08:00:00,12.450676
179
+ 2020-01-08 09:00:00,12.49223
180
+ 2020-01-08 10:00:00,11.295678
181
+ 2020-01-08 11:00:00,10.928416
182
+ 2020-01-08 12:00:00,10.176038
183
+ 2020-01-08 13:00:00,9.248605
184
+ 2020-01-08 14:00:00,8.872624
185
+ 2020-01-08 15:00:00,8.1046295
186
+ 2020-01-08 16:00:00,7.59621
187
+ 2020-01-08 17:00:00,7.503101
188
+ 2020-01-08 18:00:00,7.216429
189
+ 2020-01-08 19:00:00,7.7061625
190
+ 2020-01-08 20:00:00,7.812301
191
+ 2020-01-08 21:00:00,8.137366
192
+ 2020-01-08 22:00:00,8.477673
193
+ 2020-01-08 23:00:00,9.340978
194
+ 2020-01-09 00:00:00,10.608282
195
+ 2020-01-09 01:00:00,11.168635
196
+ 2020-01-09 02:00:00,11.856494
197
+ 2020-01-09 03:00:00,12.873333
198
+ 2020-01-09 04:00:00,12.774559
199
+ 2020-01-09 05:00:00,13.222704
200
+ 2020-01-09 06:00:00,13.355967
201
+ 2020-01-09 07:00:00,13.473326
202
+ 2020-01-09 08:00:00,12.930806
203
+ 2020-01-09 09:00:00,12.470578
204
+ 2020-01-09 10:00:00,11.656756
205
+ 2020-01-09 11:00:00,11.40529
206
+ 2020-01-09 12:00:00,10.648692
207
+ 2020-01-09 13:00:00,9.619551
208
+ 2020-01-09 14:00:00,9.027083
209
+ 2020-01-09 15:00:00,8.117006
210
+ 2020-01-09 16:00:00,7.806163
211
+ 2020-01-09 17:00:00,7.877463
212
+ 2020-01-09 18:00:00,7.5271463
213
+ 2020-01-09 19:00:00,8.063946
214
+ 2020-01-09 20:00:00,7.7430873
215
+ 2020-01-09 21:00:00,8.491592
216
+ 2020-01-09 22:00:00,8.955315
217
+ 2020-01-09 23:00:00,9.82799
218
+ 2020-01-10 00:00:00,10.486022
219
+ 2020-01-10 01:00:00,11.147181
220
+ 2020-01-10 02:00:00,11.804263
221
+ 2020-01-10 03:00:00,13.20647
222
+ 2020-01-10 04:00:00,13.048044
223
+ 2020-01-10 05:00:00,12.952726
224
+ 2020-01-10 06:00:00,13.320765
225
+ 2020-01-10 07:00:00,13.475443
226
+ 2020-01-10 08:00:00,12.9310875
227
+ 2020-01-10 09:00:00,12.8167715
228
+ 2020-01-10 10:00:00,12.113599
229
+ 2020-01-10 11:00:00,11.148289
230
+ 2020-01-10 12:00:00,10.296473
231
+ 2020-01-10 13:00:00,9.401568
232
+ 2020-01-10 14:00:00,8.726533
233
+ 2020-01-10 15:00:00,7.9376044
234
+ 2020-01-10 16:00:00,7.9828897
235
+ 2020-01-10 17:00:00,7.746506
236
+ 2020-01-10 18:00:00,7.0605173
237
+ 2020-01-10 19:00:00,7.162796
238
+ 2020-01-10 20:00:00,7.3298817
239
+ 2020-01-10 21:00:00,7.951925
240
+ 2020-01-10 22:00:00,8.128733
241
+ 2020-01-10 23:00:00,9.228722
242
+ 2020-01-11 00:00:00,10.476325
243
+ 2020-01-11 01:00:00,10.907269
244
+ 2020-01-11 02:00:00,11.853587
245
+ 2020-01-11 03:00:00,12.188666
246
+ 2020-01-11 04:00:00,13.097588
247
+ 2020-01-11 05:00:00,13.067031
248
+ 2020-01-11 06:00:00,13.03486
249
+ 2020-01-11 07:00:00,13.501218
250
+ 2020-01-11 08:00:00,12.607703
251
+ 2020-01-11 09:00:00,11.933058
252
+ 2020-01-11 10:00:00,11.577747
253
+ 2020-01-11 11:00:00,10.787386
254
+ 2020-01-11 12:00:00,10.213264
255
+ 2020-01-11 13:00:00,9.02052
256
+ 2020-01-11 14:00:00,8.623578
257
+ 2020-01-11 15:00:00,7.993247
258
+ 2020-01-11 16:00:00,7.1938653
259
+ 2020-01-11 17:00:00,7.0419154
260
+ 2020-01-11 18:00:00,6.722589
261
+ 2020-01-11 19:00:00,7.4419746
262
+ 2020-01-11 20:00:00,7.1137185
263
+ 2020-01-11 21:00:00,7.6229253
264
+ 2020-01-11 22:00:00,8.104162
265
+ 2020-01-11 23:00:00,8.954372
266
+ 2020-01-12 00:00:00,9.781883
267
+ 2020-01-12 01:00:00,10.696381
268
+ 2020-01-12 02:00:00,11.127903
269
+ 2020-01-12 03:00:00,11.81815
270
+ 2020-01-12 04:00:00,12.033118
271
+ 2020-01-12 05:00:00,12.435387
272
+ 2020-01-12 06:00:00,13.239417
273
+ 2020-01-12 07:00:00,12.779882
274
+ 2020-01-12 08:00:00,12.101705
275
+ 2020-01-12 09:00:00,11.500287
276
+ 2020-01-12 10:00:00,10.938357
277
+ 2020-01-12 11:00:00,10.393133
278
+ 2020-01-12 12:00:00,9.61603
279
+ 2020-01-12 13:00:00,8.672372
280
+ 2020-01-12 14:00:00,7.8295655
281
+ 2020-01-12 15:00:00,7.7397933
282
+ 2020-01-12 16:00:00,7.059248
283
+ 2020-01-12 17:00:00,6.5852513
284
+ 2020-01-12 18:00:00,6.5053835
285
+ 2020-01-12 19:00:00,6.5380354
286
+ 2020-01-12 20:00:00,6.349278
287
+ 2020-01-12 21:00:00,7.42987
288
+ 2020-01-12 22:00:00,7.8081045
289
+ 2020-01-12 23:00:00,8.540043
290
+ 2020-01-13 00:00:00,9.384483
291
+ 2020-01-13 01:00:00,10.431634
292
+ 2020-01-13 02:00:00,10.771479
293
+ 2020-01-13 03:00:00,11.337608
294
+ 2020-01-13 04:00:00,12.227445
295
+ 2020-01-13 05:00:00,12.549001
296
+ 2020-01-13 06:00:00,12.308213
297
+ 2020-01-13 07:00:00,12.510607
298
+ 2020-01-13 08:00:00,12.041148
299
+ 2020-01-13 09:00:00,11.684723
300
+ 2020-01-13 10:00:00,10.753392
301
+ 2020-01-13 11:00:00,10.451753
302
+ 2020-01-13 12:00:00,9.753188
303
+ 2020-01-13 13:00:00,8.867994
304
+ 2020-01-13 14:00:00,8.133882
305
+ 2020-01-13 15:00:00,6.652283
306
+ 2020-01-13 16:00:00,6.9886127
307
+ 2020-01-13 17:00:00,6.638852
308
+ 2020-01-13 18:00:00,6.5201063
309
+ 2020-01-13 19:00:00,6.534049
310
+ 2020-01-13 20:00:00,6.9799843
311
+ 2020-01-13 21:00:00,7.537741
312
+ 2020-01-13 22:00:00,8.034123
313
+ 2020-01-13 23:00:00,8.7285795
314
+ 2020-01-14 00:00:00,9.855035
315
+ 2020-01-14 01:00:00,10.176398
316
+ 2020-01-14 02:00:00,11.339505
317
+ 2020-01-14 03:00:00,11.803129
318
+ 2020-01-14 04:00:00,12.097129
319
+ 2020-01-14 05:00:00,12.513638
320
+ 2020-01-14 06:00:00,12.504256
321
+ 2020-01-14 07:00:00,12.73588
322
+ 2020-01-14 08:00:00,12.385997
323
+ 2020-01-14 09:00:00,11.743945
324
+ 2020-01-14 10:00:00,11.029556
325
+ 2020-01-14 11:00:00,10.603166
326
+ 2020-01-14 12:00:00,9.974535
327
+ 2020-01-14 13:00:00,9.00083
328
+ 2020-01-14 14:00:00,8.401
329
+ 2020-01-14 15:00:00,7.6383348
330
+ 2020-01-14 16:00:00,7.26806
331
+ 2020-01-14 17:00:00,6.9145565
332
+ 2020-01-14 18:00:00,6.947548
333
+ 2020-01-14 19:00:00,6.707312
334
+ 2020-01-14 20:00:00,7.4562407
335
+ 2020-01-14 21:00:00,7.776711
336
+ 2020-01-14 22:00:00,8.534356
337
+ 2020-01-14 23:00:00,9.1367855
338
+ 2020-01-15 00:00:00,10.064073
339
+ 2020-01-15 01:00:00,10.58064
340
+ 2020-01-15 02:00:00,11.775196
341
+ 2020-01-15 03:00:00,11.836594
342
+ 2020-01-15 04:00:00,12.464749
343
+ 2020-01-15 05:00:00,13.037867
344
+ 2020-01-15 06:00:00,13.403829
345
+ 2020-01-15 07:00:00,13.082812
346
+ 2020-01-15 08:00:00,12.695872
347
+ 2020-01-15 09:00:00,12.001442
348
+ 2020-01-15 10:00:00,11.644415
349
+ 2020-01-15 11:00:00,10.972427
350
+ 2020-01-15 12:00:00,10.555056
351
+ 2020-01-15 13:00:00,9.581602
352
+ 2020-01-15 14:00:00,8.444181
353
+ 2020-01-15 15:00:00,8.550052
354
+ 2020-01-15 16:00:00,7.604582
355
+ 2020-01-15 17:00:00,7.223241
356
+ 2020-01-15 18:00:00,7.6067095
357
+ 2020-01-15 19:00:00,7.418415
358
+ 2020-01-15 20:00:00,7.6685295
359
+ 2020-01-15 21:00:00,8.2759905
360
+ 2020-01-15 22:00:00,9.035503
361
+ 2020-01-15 23:00:00,9.801196
362
+ 2020-01-16 00:00:00,10.115875
363
+ 2020-01-16 01:00:00,11.578452
364
+ 2020-01-16 02:00:00,12.102491
365
+ 2020-01-16 03:00:00,12.4688425
366
+ 2020-01-16 04:00:00,12.867357
367
+ 2020-01-16 05:00:00,13.146032
368
+ 2020-01-16 06:00:00,13.47516
369
+ 2020-01-16 07:00:00,13.226456
370
+ 2020-01-16 08:00:00,12.910539
371
+ 2020-01-16 09:00:00,12.755513
372
+ 2020-01-16 10:00:00,12.0507
373
+ 2020-01-16 11:00:00,11.180506
374
+ 2020-01-16 12:00:00,10.634314
375
+ 2020-01-16 13:00:00,9.988302
376
+ 2020-01-16 14:00:00,8.7755165
377
+ 2020-01-16 15:00:00,8.254877
378
+ 2020-01-16 16:00:00,8.08905
379
+ 2020-01-16 17:00:00,7.745661
380
+ 2020-01-16 18:00:00,7.5453396
381
+ 2020-01-16 19:00:00,7.8343573
382
+ 2020-01-16 20:00:00,7.6828833
383
+ 2020-01-16 21:00:00,8.079708
384
+ 2020-01-16 22:00:00,8.821114
385
+ 2020-01-16 23:00:00,9.739335
386
+ 2020-01-17 00:00:00,10.328247
387
+ 2020-01-17 01:00:00,11.161977
388
+ 2020-01-17 02:00:00,11.782786
389
+ 2020-01-17 03:00:00,12.469176
390
+ 2020-01-17 04:00:00,12.8625555
391
+ 2020-01-17 05:00:00,13.429552
392
+ 2020-01-17 06:00:00,13.609463
393
+ 2020-01-17 07:00:00,13.243746
394
+ 2020-01-17 08:00:00,12.989635
395
+ 2020-01-17 09:00:00,12.428476
396
+ 2020-01-17 10:00:00,12.01937
397
+ 2020-01-17 11:00:00,11.196568
398
+ 2020-01-17 12:00:00,10.709747
399
+ 2020-01-17 13:00:00,9.383434
400
+ 2020-01-17 14:00:00,8.939027
401
+ 2020-01-17 15:00:00,8.321032
402
+ 2020-01-17 16:00:00,7.6699224
403
+ 2020-01-17 17:00:00,7.545073
404
+ 2020-01-17 18:00:00,7.02404
405
+ 2020-01-17 19:00:00,7.822893
406
+ 2020-01-17 20:00:00,7.415175
407
+ 2020-01-17 21:00:00,8.328657
408
+ 2020-01-17 22:00:00,8.525776
409
+ 2020-01-17 23:00:00,9.687354
410
+ 2020-01-18 00:00:00,10.139987
411
+ 2020-01-18 01:00:00,11.008088
412
+ 2020-01-18 02:00:00,11.693339
413
+ 2020-01-18 03:00:00,12.506607
414
+ 2020-01-18 04:00:00,12.681145
415
+ 2020-01-18 05:00:00,12.43375
416
+ 2020-01-18 06:00:00,12.959249
417
+ 2020-01-18 07:00:00,13.027475
418
+ 2020-01-18 08:00:00,12.584504
419
+ 2020-01-18 09:00:00,12.331266
420
+ 2020-01-18 10:00:00,11.740431
421
+ 2020-01-18 11:00:00,10.765598
422
+ 2020-01-18 12:00:00,9.702071
423
+ 2020-01-18 13:00:00,9.48168
424
+ 2020-01-18 14:00:00,8.679246
425
+ 2020-01-18 15:00:00,7.7628593
426
+ 2020-01-18 16:00:00,7.74906
427
+ 2020-01-18 17:00:00,6.9394917
428
+ 2020-01-18 18:00:00,6.6613154
429
+ 2020-01-18 19:00:00,6.9416385
430
+ 2020-01-18 20:00:00,7.470113
431
+ 2020-01-18 21:00:00,7.5260496
432
+ 2020-01-18 22:00:00,8.707575
433
+ 2020-01-18 23:00:00,8.844292
434
+ 2020-01-19 00:00:00,9.973943
435
+ 2020-01-19 01:00:00,10.651715
436
+ 2020-01-19 02:00:00,11.219189
437
+ 2020-01-19 03:00:00,12.071411
438
+ 2020-01-19 04:00:00,12.016457
439
+ 2020-01-19 05:00:00,12.872382
440
+ 2020-01-19 06:00:00,12.675518
441
+ 2020-01-19 07:00:00,12.463147
442
+ 2020-01-19 08:00:00,12.4078245
443
+ 2020-01-19 09:00:00,11.979747
444
+ 2020-01-19 10:00:00,11.287434
445
+ 2020-01-19 11:00:00,10.797372
446
+ 2020-01-19 12:00:00,9.82523
447
+ 2020-01-19 13:00:00,9.077946
448
+ 2020-01-19 14:00:00,7.978908
449
+ 2020-01-19 15:00:00,7.476622
450
+ 2020-01-19 16:00:00,7.0815077
451
+ 2020-01-19 17:00:00,6.656616
452
+ 2020-01-19 18:00:00,6.609843
453
+ 2020-01-19 19:00:00,6.729162
454
+ 2020-01-19 20:00:00,7.1053357
455
+ 2020-01-19 21:00:00,7.3864164
456
+ 2020-01-19 22:00:00,7.952254
457
+ 2020-01-19 23:00:00,8.574917
458
+ 2020-01-20 00:00:00,9.334189
459
+ 2020-01-20 01:00:00,10.519664
460
+ 2020-01-20 02:00:00,10.98868
461
+ 2020-01-20 03:00:00,11.781853
462
+ 2020-01-20 04:00:00,11.840001
463
+ 2020-01-20 05:00:00,12.010493
464
+ 2020-01-20 06:00:00,12.290182
465
+ 2020-01-20 07:00:00,12.627453
466
+ 2020-01-20 08:00:00,12.313089
467
+ 2020-01-20 09:00:00,11.690872
468
+ 2020-01-20 10:00:00,10.845151
469
+ 2020-01-20 11:00:00,10.259013
470
+ 2020-01-20 12:00:00,9.452984
471
+ 2020-01-20 13:00:00,8.671585
472
+ 2020-01-20 14:00:00,7.5209994
473
+ 2020-01-20 15:00:00,7.2351527
474
+ 2020-01-20 16:00:00,6.898465
475
+ 2020-01-20 17:00:00,6.947227
476
+ 2020-01-20 18:00:00,6.581664
477
+ 2020-01-20 19:00:00,6.9403424
478
+ 2020-01-20 20:00:00,6.8901696
479
+ 2020-01-20 21:00:00,7.40478
480
+ 2020-01-20 22:00:00,7.3069963
481
+ 2020-01-20 23:00:00,8.91391
482
+ 2020-01-21 00:00:00,9.718503
483
+ 2020-01-21 01:00:00,10.750223
484
+ 2020-01-21 02:00:00,11.036121
485
+ 2020-01-21 03:00:00,11.786598
486
+ 2020-01-21 04:00:00,12.116884
487
+ 2020-01-21 05:00:00,12.336382
488
+ 2020-01-21 06:00:00,12.545633
489
+ 2020-01-21 07:00:00,12.531868
490
+ 2020-01-21 08:00:00,12.587503
491
+ 2020-01-21 09:00:00,11.855746
492
+ 2020-01-21 10:00:00,11.091891
493
+ 2020-01-21 11:00:00,10.571198
494
+ 2020-01-21 12:00:00,9.826572
495
+ 2020-01-21 13:00:00,8.8883505
496
+ 2020-01-21 14:00:00,8.545977
497
+ 2020-01-21 15:00:00,7.3358703
498
+ 2020-01-21 16:00:00,7.2118373
499
+ 2020-01-21 17:00:00,7.1058264
500
+ 2020-01-21 18:00:00,6.621053
501
+ 2020-01-21 19:00:00,7.0815177
502
+ 2020-01-21 20:00:00,7.5859814
503
+ 2020-01-21 21:00:00,7.9134316
504
+ 2020-01-21 22:00:00,8.124603
505
+ 2020-01-21 23:00:00,9.059208
506
+ 2020-01-22 00:00:00,10.246461
507
+ 2020-01-22 01:00:00,10.85482
508
+ 2020-01-22 02:00:00,11.535394
509
+ 2020-01-22 03:00:00,12.265543
510
+ 2020-01-22 04:00:00,12.816807
511
+ 2020-01-22 05:00:00,12.84904
512
+ 2020-01-22 06:00:00,13.053181
513
+ 2020-01-22 07:00:00,13.05577
514
+ 2020-01-22 08:00:00,12.6366625
515
+ 2020-01-22 09:00:00,12.259769
516
+ 2020-01-22 10:00:00,11.942245
517
+ 2020-01-22 11:00:00,10.782839
518
+ 2020-01-22 12:00:00,10.602274
519
+ 2020-01-22 13:00:00,9.833046
520
+ 2020-01-22 14:00:00,8.407312
521
+ 2020-01-22 15:00:00,8.116491
522
+ 2020-01-22 16:00:00,7.752122
523
+ 2020-01-22 17:00:00,7.2469583
524
+ 2020-01-22 18:00:00,7.163529
525
+ 2020-01-22 19:00:00,7.380883
526
+ 2020-01-22 20:00:00,7.889846
527
+ 2020-01-22 21:00:00,8.129967
528
+ 2020-01-22 22:00:00,9.231865
529
+ 2020-01-22 23:00:00,9.660523
530
+ 2020-01-23 00:00:00,10.3704
531
+ 2020-01-23 01:00:00,11.468608
532
+ 2020-01-23 02:00:00,12.038376
533
+ 2020-01-23 03:00:00,12.61854
534
+ 2020-01-23 04:00:00,12.964861
535
+ 2020-01-23 05:00:00,13.702632
536
+ 2020-01-23 06:00:00,13.612848
537
+ 2020-01-23 07:00:00,13.315376
538
+ 2020-01-23 08:00:00,12.748074
539
+ 2020-01-23 09:00:00,12.667852
540
+ 2020-01-23 10:00:00,11.749039
541
+ 2020-01-23 11:00:00,10.916231
542
+ 2020-01-23 12:00:00,10.431634
543
+ 2020-01-23 13:00:00,9.77112
544
+ 2020-01-23 14:00:00,9.250912
545
+ 2020-01-23 15:00:00,8.432026
546
+ 2020-01-23 16:00:00,8.061729
547
+ 2020-01-23 17:00:00,7.356696
548
+ 2020-01-23 18:00:00,7.4954934
549
+ 2020-01-23 19:00:00,7.626715
550
+ 2020-01-23 20:00:00,8.072975
551
+ 2020-01-23 21:00:00,8.398747
552
+ 2020-01-23 22:00:00,9.155231
553
+ 2020-01-23 23:00:00,9.613986
554
+ 2020-01-24 00:00:00,10.559055
555
+ 2020-01-24 01:00:00,11.342393
556
+ 2020-01-24 02:00:00,11.727831
557
+ 2020-01-24 03:00:00,12.628369
558
+ 2020-01-24 04:00:00,12.99944
559
+ 2020-01-24 05:00:00,12.975241
560
+ 2020-01-24 06:00:00,13.642166
561
+ 2020-01-24 07:00:00,13.267464
562
+ 2020-01-24 08:00:00,12.860611
563
+ 2020-01-24 09:00:00,12.469219
564
+ 2020-01-24 10:00:00,11.94076
565
+ 2020-01-24 11:00:00,11.480338
566
+ 2020-01-24 12:00:00,10.357729
567
+ 2020-01-24 13:00:00,9.69701
568
+ 2020-01-24 14:00:00,9.141242
569
+ 2020-01-24 15:00:00,8.3389435
570
+ 2020-01-24 16:00:00,7.955723
571
+ 2020-01-24 17:00:00,7.333064
572
+ 2020-01-24 18:00:00,7.465955
573
+ 2020-01-24 19:00:00,7.3875375
574
+ 2020-01-24 20:00:00,7.898466
575
+ 2020-01-24 21:00:00,7.9439836
576
+ 2020-01-24 22:00:00,8.594089
577
+ 2020-01-24 23:00:00,9.710923
578
+ 2020-01-25 00:00:00,10.177725
579
+ 2020-01-25 01:00:00,10.904611
580
+ 2020-01-25 02:00:00,11.698185
581
+ 2020-01-25 03:00:00,12.148561
582
+ 2020-01-25 04:00:00,13.011842
583
+ 2020-01-25 05:00:00,12.777354
584
+ 2020-01-25 06:00:00,13.0811405
585
+ 2020-01-25 07:00:00,13.060123
586
+ 2020-01-25 08:00:00,12.651708
587
+ 2020-01-25 09:00:00,12.016845
588
+ 2020-01-25 10:00:00,11.3636875
589
+ 2020-01-25 11:00:00,10.880232
590
+ 2020-01-25 12:00:00,9.793904
591
+ 2020-01-25 13:00:00,9.334096
592
+ 2020-01-25 14:00:00,8.157805
593
+ 2020-01-25 15:00:00,7.711701
594
+ 2020-01-25 16:00:00,7.334664
595
+ 2020-01-25 17:00:00,6.7588363
596
+ 2020-01-25 18:00:00,7.0191417
597
+ 2020-01-25 19:00:00,6.9691052
598
+ 2020-01-25 20:00:00,7.0473313
599
+ 2020-01-25 21:00:00,7.4097624
600
+ 2020-01-25 22:00:00,8.004211
601
+ 2020-01-25 23:00:00,9.033804
602
+ 2020-01-26 00:00:00,9.551692
603
+ 2020-01-26 01:00:00,10.269847
604
+ 2020-01-26 02:00:00,11.203754
605
+ 2020-01-26 03:00:00,12.310804
606
+ 2020-01-26 04:00:00,12.371924
607
+ 2020-01-26 05:00:00,12.753451
608
+ 2020-01-26 06:00:00,12.730799
609
+ 2020-01-26 07:00:00,12.72823
610
+ 2020-01-26 08:00:00,11.989372
611
+ 2020-01-26 09:00:00,11.681664
612
+ 2020-01-26 10:00:00,11.185744
613
+ 2020-01-26 11:00:00,10.3938875
614
+ 2020-01-26 12:00:00,9.570685
615
+ 2020-01-26 13:00:00,8.688068
616
+ 2020-01-26 14:00:00,8.035204
617
+ 2020-01-26 15:00:00,7.300478
618
+ 2020-01-26 16:00:00,6.4845443
619
+ 2020-01-26 17:00:00,6.421263
620
+ 2020-01-26 18:00:00,6.644646
621
+ 2020-01-26 19:00:00,6.955357
622
+ 2020-01-26 20:00:00,7.299203
623
+ 2020-01-26 21:00:00,7.4260883
624
+ 2020-01-26 22:00:00,8.200891
625
+ 2020-01-26 23:00:00,8.922175
626
+ 2020-01-27 00:00:00,9.374084
627
+ 2020-01-27 01:00:00,9.945586
628
+ 2020-01-27 02:00:00,11.011762
629
+ 2020-01-27 03:00:00,11.272024
630
+ 2020-01-27 04:00:00,12.035488
631
+ 2020-01-27 05:00:00,12.519751
632
+ 2020-01-27 06:00:00,12.215986
633
+ 2020-01-27 07:00:00,12.404586
634
+ 2020-01-27 08:00:00,12.347478
635
+ 2020-01-27 09:00:00,11.696767
636
+ 2020-01-27 10:00:00,11.110049
637
+ 2020-01-27 11:00:00,10.466522
638
+ 2020-01-27 12:00:00,9.859129
639
+ 2020-01-27 13:00:00,8.7708645
640
+ 2020-01-27 14:00:00,8.268114
641
+ 2020-01-27 15:00:00,7.393904
642
+ 2020-01-27 16:00:00,6.8272533
643
+ 2020-01-27 17:00:00,6.7069054
644
+ 2020-01-27 18:00:00,6.428353
645
+ 2020-01-27 19:00:00,6.545539
646
+ 2020-01-27 20:00:00,6.847363
647
+ 2020-01-27 21:00:00,6.9962325
648
+ 2020-01-27 22:00:00,8.107981
649
+ 2020-01-27 23:00:00,8.568477
650
+ 2020-01-28 00:00:00,9.58763
651
+ 2020-01-28 01:00:00,10.687465
652
+ 2020-01-28 02:00:00,11.029274
653
+ 2020-01-28 03:00:00,11.659365
654
+ 2020-01-28 04:00:00,12.530771
655
+ 2020-01-28 05:00:00,12.680895
656
+ 2020-01-28 06:00:00,12.88356
657
+ 2020-01-28 07:00:00,12.529809
658
+ 2020-01-28 08:00:00,12.466033
659
+ 2020-01-28 09:00:00,11.718071
660
+ 2020-01-28 10:00:00,11.1147995
661
+ 2020-01-28 11:00:00,10.662071
662
+ 2020-01-28 12:00:00,9.6633835
663
+ 2020-01-28 13:00:00,9.177021
664
+ 2020-01-28 14:00:00,8.795667
665
+ 2020-01-28 15:00:00,7.3763094
666
+ 2020-01-28 16:00:00,7.1041055
667
+ 2020-01-28 17:00:00,7.196842
668
+ 2020-01-28 18:00:00,6.859724
669
+ 2020-01-28 19:00:00,7.2414846
670
+ 2020-01-28 20:00:00,7.1253896
671
+ 2020-01-28 21:00:00,7.888966
672
+ 2020-01-28 22:00:00,8.432524
673
+ 2020-01-28 23:00:00,9.232922
674
+ 2020-01-29 00:00:00,10.066223
675
+ 2020-01-29 01:00:00,10.551151
676
+ 2020-01-29 02:00:00,11.322537
677
+ 2020-01-29 03:00:00,12.457151
678
+ 2020-01-29 04:00:00,12.73124
679
+ 2020-01-29 05:00:00,13.012003
680
+ 2020-01-29 06:00:00,13.102446
681
+ 2020-01-29 07:00:00,13.0984955
682
+ 2020-01-29 08:00:00,12.514266
683
+ 2020-01-29 09:00:00,12.086655
684
+ 2020-01-29 10:00:00,11.943572
685
+ 2020-01-29 11:00:00,11.006659
686
+ 2020-01-29 12:00:00,10.387002
687
+ 2020-01-29 13:00:00,9.336044
688
+ 2020-01-29 14:00:00,9.025345
689
+ 2020-01-29 15:00:00,8.213752
690
+ 2020-01-29 16:00:00,7.779833
691
+ 2020-01-29 17:00:00,7.5088787
692
+ 2020-01-29 18:00:00,7.1523433
693
+ 2020-01-29 19:00:00,7.055222
694
+ 2020-01-29 20:00:00,7.5270495
695
+ 2020-01-29 21:00:00,8.558337
696
+ 2020-01-29 22:00:00,9.126662
697
+ 2020-01-29 23:00:00,9.533144
698
+ 2020-01-30 00:00:00,10.330581
699
+ 2020-01-30 01:00:00,11.386077
700
+ 2020-01-30 02:00:00,11.879437
701
+ 2020-01-30 03:00:00,12.28483
702
+ 2020-01-30 04:00:00,13.284198
703
+ 2020-01-30 05:00:00,13.435271
704
+ 2020-01-30 06:00:00,12.947216
705
+ 2020-01-30 07:00:00,13.293421
706
+ 2020-01-30 08:00:00,13.092247
707
+ 2020-01-30 09:00:00,12.689511
708
+ 2020-01-30 10:00:00,12.008217
709
+ 2020-01-30 11:00:00,11.132272
710
+ 2020-01-30 12:00:00,10.464313
711
+ 2020-01-30 13:00:00,9.773787
712
+ 2020-01-30 14:00:00,8.940815
713
+ 2020-01-30 15:00:00,8.301156
714
+ 2020-01-30 16:00:00,8.150943
715
+ 2020-01-30 17:00:00,7.4125514
716
+ 2020-01-30 18:00:00,7.430107
717
+ 2020-01-30 19:00:00,7.195619
718
+ 2020-01-30 20:00:00,8.008719
719
+ 2020-01-30 21:00:00,8.541188
720
+ 2020-01-30 22:00:00,9.104113
721
+ 2020-01-30 23:00:00,9.89836
722
+ 2020-01-31 00:00:00,10.575605
723
+ 2020-01-31 01:00:00,11.327923
724
+ 2020-01-31 02:00:00,12.072576
725
+ 2020-01-31 03:00:00,12.539801
726
+ 2020-01-31 04:00:00,13.301179
727
+ 2020-01-31 05:00:00,13.286345
728
+ 2020-01-31 06:00:00,13.158014
729
+ 2020-01-31 07:00:00,13.509792
730
+ 2020-01-31 08:00:00,13.40123
731
+ 2020-01-31 09:00:00,12.352653
732
+ 2020-01-31 10:00:00,11.892793
733
+ 2020-01-31 11:00:00,11.041668
734
+ 2020-01-31 12:00:00,10.314799
735
+ 2020-01-31 13:00:00,9.611748
736
+ 2020-01-31 14:00:00,8.618164
737
+ 2020-01-31 15:00:00,8.1767025
738
+ 2020-01-31 16:00:00,7.448817
739
+ 2020-01-31 17:00:00,7.3147206
740
+ 2020-01-31 18:00:00,7.074531
741
+ 2020-01-31 19:00:00,7.1873207
742
+ 2020-01-31 20:00:00,7.3396015
743
+ 2020-01-31 21:00:00,8.388567
744
+ 2020-01-31 22:00:00,8.851811
745
+ 2020-01-31 23:00:00,9.073685
746
+ 2020-02-01 00:00:00,10.097576
747
+ 2020-02-01 01:00:00,10.842316
748
+ 2020-02-01 02:00:00,11.544481
749
+ 2020-02-01 03:00:00,11.997083
750
+ 2020-02-01 04:00:00,12.896331
751
+ 2020-02-01 05:00:00,12.948014
752
+ 2020-02-01 06:00:00,13.20489
753
+ 2020-02-01 07:00:00,13.096085
754
+ 2020-02-01 08:00:00,12.947686
755
+ 2020-02-01 09:00:00,11.814328
756
+ 2020-02-01 10:00:00,11.885085
757
+ 2020-02-01 11:00:00,11.048916
758
+ 2020-02-01 12:00:00,10.114614
759
+ 2020-02-01 13:00:00,9.681565
760
+ 2020-02-01 14:00:00,8.503631
761
+ 2020-02-01 15:00:00,7.9869933
762
+ 2020-02-01 16:00:00,7.17972
763
+ 2020-02-01 17:00:00,7.2361383
764
+ 2020-02-01 18:00:00,6.9223046
765
+ 2020-02-01 19:00:00,6.882573
766
+ 2020-02-01 20:00:00,7.677934
767
+ 2020-02-01 21:00:00,7.6525803
768
+ 2020-02-01 22:00:00,8.319101
769
+ 2020-02-01 23:00:00,8.98414
770
+ 2020-02-02 00:00:00,9.631924
771
+ 2020-02-02 01:00:00,10.649077
772
+ 2020-02-02 02:00:00,11.397682
773
+ 2020-02-02 03:00:00,11.926179
774
+ 2020-02-02 04:00:00,11.8443
775
+ 2020-02-02 05:00:00,12.802466
776
+ 2020-02-02 06:00:00,12.618207
777
+ 2020-02-02 07:00:00,12.328369
778
+ 2020-02-02 08:00:00,12.378646
779
+ 2020-02-02 09:00:00,11.880337
780
+ 2020-02-02 10:00:00,10.924859
781
+ 2020-02-02 11:00:00,10.891958
782
+ 2020-02-02 12:00:00,9.367159
783
+ 2020-02-02 13:00:00,8.474644
784
+ 2020-02-02 14:00:00,7.856046
785
+ 2020-02-02 15:00:00,7.7394776
786
+ 2020-02-02 16:00:00,6.9337397
787
+ 2020-02-02 17:00:00,6.5857534
788
+ 2020-02-02 18:00:00,6.537084
789
+ 2020-02-02 19:00:00,6.5246186
790
+ 2020-02-02 20:00:00,6.7984343
791
+ 2020-02-02 21:00:00,7.278495
792
+ 2020-02-02 22:00:00,8.163805
793
+ 2020-02-02 23:00:00,8.944671
794
+ 2020-02-03 00:00:00,9.3015175
795
+ 2020-02-03 01:00:00,10.333019
796
+ 2020-02-03 02:00:00,11.163226
797
+ 2020-02-03 03:00:00,11.4081135
798
+ 2020-02-03 04:00:00,11.998961
799
+ 2020-02-03 05:00:00,12.190515
800
+ 2020-02-03 06:00:00,12.241795
801
+ 2020-02-03 07:00:00,12.4185
802
+ 2020-02-03 08:00:00,11.952248
803
+ 2020-02-03 09:00:00,11.75069
804
+ 2020-02-03 10:00:00,10.999701
805
+ 2020-02-03 11:00:00,10.3677
806
+ 2020-02-03 12:00:00,9.45408
807
+ 2020-02-03 13:00:00,8.613581
808
+ 2020-02-03 14:00:00,8.004079
809
+ 2020-02-03 15:00:00,7.405739
810
+ 2020-02-03 16:00:00,6.7953157
811
+ 2020-02-03 17:00:00,6.7292604
812
+ 2020-02-03 18:00:00,6.6988344
813
+ 2020-02-03 19:00:00,6.6917815
814
+ 2020-02-03 20:00:00,7.3116093
815
+ 2020-02-03 21:00:00,7.32885
816
+ 2020-02-03 22:00:00,8.191
817
+ 2020-02-03 23:00:00,8.739497
818
+ 2020-02-04 00:00:00,9.656011
819
+ 2020-02-04 01:00:00,10.231482
820
+ 2020-02-04 02:00:00,11.356499
821
+ 2020-02-04 03:00:00,11.802971
822
+ 2020-02-04 04:00:00,12.496439
823
+ 2020-02-04 05:00:00,12.318415
824
+ 2020-02-04 06:00:00,12.589552
825
+ 2020-02-04 07:00:00,12.422355
826
+ 2020-02-04 08:00:00,12.210028
827
+ 2020-02-04 09:00:00,11.716783
828
+ 2020-02-04 10:00:00,11.225892
829
+ 2020-02-04 11:00:00,10.539373
830
+ 2020-02-04 12:00:00,9.774861
831
+ 2020-02-04 13:00:00,8.912492
832
+ 2020-02-04 14:00:00,8.354815
833
+ 2020-02-04 15:00:00,7.8886633
834
+ 2020-02-04 16:00:00,7.074091
835
+ 2020-02-04 17:00:00,6.9729905
836
+ 2020-02-04 18:00:00,6.873925
837
+ 2020-02-04 19:00:00,7.1029377
838
+ 2020-02-04 20:00:00,7.314631
839
+ 2020-02-04 21:00:00,7.8195643
840
+ 2020-02-04 22:00:00,8.264416
841
+ 2020-02-04 23:00:00,9.209188
842
+ 2020-02-05 00:00:00,9.815623
843
+ 2020-02-05 01:00:00,10.8982935
844
+ 2020-02-05 02:00:00,11.516722
845
+ 2020-02-05 03:00:00,12.185274
846
+ 2020-02-05 04:00:00,12.494359
847
+ 2020-02-05 05:00:00,13.1514845
848
+ 2020-02-05 06:00:00,13.249723
849
+ 2020-02-05 07:00:00,13.1994915
850
+ 2020-02-05 08:00:00,13.190931
851
+ 2020-02-05 09:00:00,12.275956
852
+ 2020-02-05 10:00:00,11.923351
853
+ 2020-02-05 11:00:00,10.951637
854
+ 2020-02-05 12:00:00,10.295759
855
+ 2020-02-05 13:00:00,9.530245
856
+ 2020-02-05 14:00:00,8.802918
857
+ 2020-02-05 15:00:00,8.276347
858
+ 2020-02-05 16:00:00,7.6227965
859
+ 2020-02-05 17:00:00,7.407319
860
+ 2020-02-05 18:00:00,7.418807
861
+ 2020-02-05 19:00:00,7.777937
862
+ 2020-02-05 20:00:00,7.573539
863
+ 2020-02-05 21:00:00,7.8710065
864
+ 2020-02-05 22:00:00,8.769709
865
+ 2020-02-05 23:00:00,9.620515
866
+ 2020-02-06 00:00:00,10.425911
867
+ 2020-02-06 01:00:00,11.1972
868
+ 2020-02-06 02:00:00,12.149677
869
+ 2020-02-06 03:00:00,12.739892
870
+ 2020-02-06 04:00:00,13.021235
871
+ 2020-02-06 05:00:00,13.245926
872
+ 2020-02-06 06:00:00,13.381283
873
+ 2020-02-06 07:00:00,13.284798
874
+ 2020-02-06 08:00:00,13.0183935
875
+ 2020-02-06 09:00:00,12.278732
876
+ 2020-02-06 10:00:00,11.885578
877
+ 2020-02-06 11:00:00,11.174444
878
+ 2020-02-06 12:00:00,10.449688
879
+ 2020-02-06 13:00:00,9.663966
880
+ 2020-02-06 14:00:00,9.128817
881
+ 2020-02-06 15:00:00,8.269147
882
+ 2020-02-06 16:00:00,7.81281
883
+ 2020-02-06 17:00:00,7.7102604
884
+ 2020-02-06 18:00:00,7.452937
885
+ 2020-02-06 19:00:00,7.645164
886
+ 2020-02-06 20:00:00,8.034392
887
+ 2020-02-06 21:00:00,8.459929
888
+ 2020-02-06 22:00:00,9.044448
889
+ 2020-02-06 23:00:00,9.675538
890
+ 2020-02-07 00:00:00,10.623843
891
+ 2020-02-07 01:00:00,11.29453
892
+ 2020-02-07 02:00:00,11.876816
893
+ 2020-02-07 03:00:00,12.5631
894
+ 2020-02-07 04:00:00,12.819887
895
+ 2020-02-07 05:00:00,13.163748
896
+ 2020-02-07 06:00:00,13.073903
897
+ 2020-02-07 07:00:00,13.203849
898
+ 2020-02-07 08:00:00,13.298198
899
+ 2020-02-07 09:00:00,12.433385
900
+ 2020-02-07 10:00:00,12.070627
901
+ 2020-02-07 11:00:00,11.178066
902
+ 2020-02-07 12:00:00,10.250806
903
+ 2020-02-07 13:00:00,9.870185
904
+ 2020-02-07 14:00:00,8.982967
905
+ 2020-02-07 15:00:00,7.881873
906
+ 2020-02-07 16:00:00,7.9502907
907
+ 2020-02-07 17:00:00,7.213408
908
+ 2020-02-07 18:00:00,7.276162
909
+ 2020-02-07 19:00:00,7.5327554
910
+ 2020-02-07 20:00:00,7.6236134
911
+ 2020-02-07 21:00:00,8.3684845
912
+ 2020-02-07 22:00:00,8.902253
913
+ 2020-02-07 23:00:00,9.141802
914
+ 2020-02-08 00:00:00,10.122455
915
+ 2020-02-08 01:00:00,11.032818
916
+ 2020-02-08 02:00:00,11.567671
917
+ 2020-02-08 03:00:00,12.243204
918
+ 2020-02-08 04:00:00,12.905411
919
+ 2020-02-08 05:00:00,13.090543
920
+ 2020-02-08 06:00:00,12.927136
921
+ 2020-02-08 07:00:00,13.025655
922
+ 2020-02-08 08:00:00,12.550143
923
+ 2020-02-08 09:00:00,11.934264
924
+ 2020-02-08 10:00:00,11.311354
925
+ 2020-02-08 11:00:00,10.852544
926
+ 2020-02-08 12:00:00,9.994377
927
+ 2020-02-08 13:00:00,9.205934
928
+ 2020-02-08 14:00:00,8.233393
929
+ 2020-02-08 15:00:00,7.7859116
930
+ 2020-02-08 16:00:00,7.1199694
931
+ 2020-02-08 17:00:00,6.823988
932
+ 2020-02-08 18:00:00,6.8557143
933
+ 2020-02-08 19:00:00,6.696135
934
+ 2020-02-08 20:00:00,7.3883996
935
+ 2020-02-08 21:00:00,8.2109165
936
+ 2020-02-08 22:00:00,8.409094
937
+ 2020-02-08 23:00:00,8.815016
938
+ 2020-02-09 00:00:00,9.728821
939
+ 2020-02-09 01:00:00,10.231735
940
+ 2020-02-09 02:00:00,11.174477
941
+ 2020-02-09 03:00:00,11.956767
942
+ 2020-02-09 04:00:00,12.434293
943
+ 2020-02-09 05:00:00,12.394519
944
+ 2020-02-09 06:00:00,12.748084
945
+ 2020-02-09 07:00:00,12.802916
946
+ 2020-02-09 08:00:00,12.609008
947
+ 2020-02-09 09:00:00,11.627608
948
+ 2020-02-09 10:00:00,10.960634
949
+ 2020-02-09 11:00:00,10.408355
950
+ 2020-02-09 12:00:00,9.023466
951
+ 2020-02-09 13:00:00,8.715037
952
+ 2020-02-09 14:00:00,8.032685
953
+ 2020-02-09 15:00:00,7.363449
954
+ 2020-02-09 16:00:00,6.6521363
955
+ 2020-02-09 17:00:00,6.6110544
956
+ 2020-02-09 18:00:00,6.3941936
957
+ 2020-02-09 19:00:00,6.795438
958
+ 2020-02-09 20:00:00,6.752792
959
+ 2020-02-09 21:00:00,7.350269
960
+ 2020-02-09 22:00:00,7.975624
961
+ 2020-02-09 23:00:00,8.8540945
962
+ 2020-02-10 00:00:00,9.003355
963
+ 2020-02-10 01:00:00,10.217049
964
+ 2020-02-10 02:00:00,11.157508
965
+ 2020-02-10 03:00:00,11.552127
966
+ 2020-02-10 04:00:00,11.794086
967
+ 2020-02-10 05:00:00,12.463447
968
+ 2020-02-10 06:00:00,12.567337
969
+ 2020-02-10 07:00:00,12.344029
970
+ 2020-02-10 08:00:00,11.867516
971
+ 2020-02-10 09:00:00,11.476329
972
+ 2020-02-10 10:00:00,10.942833
973
+ 2020-02-10 11:00:00,10.110067
974
+ 2020-02-10 12:00:00,9.1285
975
+ 2020-02-10 13:00:00,8.586722
976
+ 2020-02-10 14:00:00,8.009865
977
+ 2020-02-10 15:00:00,7.305326
978
+ 2020-02-10 16:00:00,6.9213724
979
+ 2020-02-10 17:00:00,6.660673
980
+ 2020-02-10 18:00:00,6.728831
981
+ 2020-02-10 19:00:00,7.1009297
982
+ 2020-02-10 20:00:00,7.115267
983
+ 2020-02-10 21:00:00,7.174452
984
+ 2020-02-10 22:00:00,7.5554914
985
+ 2020-02-10 23:00:00,8.802352
986
+ 2020-02-11 00:00:00,9.623362
987
+ 2020-02-11 01:00:00,10.165096
988
+ 2020-02-11 02:00:00,11.1878805
989
+ 2020-02-11 03:00:00,11.614379
990
+ 2020-02-11 04:00:00,12.338524
991
+ 2020-02-11 05:00:00,12.50653
992
+ 2020-02-11 06:00:00,12.767748
993
+ 2020-02-11 07:00:00,12.252229
994
+ 2020-02-11 08:00:00,12.228723
995
+ 2020-02-11 09:00:00,11.825585
996
+ 2020-02-11 10:00:00,10.965025
997
+ 2020-02-11 11:00:00,10.919291
998
+ 2020-02-11 12:00:00,9.674904
999
+ 2020-02-11 13:00:00,9.301591
1000
+ 2020-02-11 14:00:00,8.184461
1001
+ 2020-02-11 15:00:00,7.667546
1002
+ 2020-02-11 16:00:00,7.491327
1003
+ 2020-02-11 17:00:00,7.0335784
1004
+ 2020-02-11 18:00:00,6.9271502
1005
+ 2020-02-11 19:00:00,7.062455
1006
+ 2020-02-11 20:00:00,7.054171
1007
+ 2020-02-11 21:00:00,7.7447867
1008
+ 2020-02-11 22:00:00,8.271346
1009
+ 2020-02-11 23:00:00,9.24433
1010
+ 2020-02-12 00:00:00,9.8912
1011
+ 2020-02-12 01:00:00,10.786341
1012
+ 2020-02-12 02:00:00,11.521907
1013
+ 2020-02-12 03:00:00,12.170029
1014
+ 2020-02-12 04:00:00,12.6656475
1015
+ 2020-02-12 05:00:00,12.860258
1016
+ 2020-02-12 06:00:00,12.90046
1017
+ 2020-02-12 07:00:00,12.894316
1018
+ 2020-02-12 08:00:00,12.959762
1019
+ 2020-02-12 09:00:00,12.361292
1020
+ 2020-02-12 10:00:00,11.800061
1021
+ 2020-02-12 11:00:00,11.252397
1022
+ 2020-02-12 12:00:00,9.981055
1023
+ 2020-02-12 13:00:00,9.559168
1024
+ 2020-02-12 14:00:00,8.534986
1025
+ 2020-02-12 15:00:00,8.077829
example_monthly_temp.csv ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ timestamp,value
2
+ 2015-01-01,13.516318
3
+ 2015-02-01,19.44832
4
+ 2015-03-01,25.592142
5
+ 2015-04-01,25.290962
6
+ 2015-05-01,25.0406
7
+ 2015-06-01,20.865656
8
+ 2015-07-01,14.045304
9
+ 2015-08-01,10.812928
10
+ 2015-09-01,5.864853
11
+ 2015-10-01,4.5164165
12
+ 2015-11-01,6.485497
13
+ 2015-12-01,7.7111044
14
+ 2016-01-01,16.78825
15
+ 2016-02-01,18.993366
16
+ 2016-03-01,25.160658
17
+ 2016-04-01,25.204481
18
+ 2016-05-01,25.958303
19
+ 2016-06-01,19.010046
20
+ 2016-07-01,14.532308
21
+ 2016-08-01,10.506654
22
+ 2016-09-01,3.0285394
23
+ 2016-10-01,6.2418823
24
+ 2016-11-01,8.652191
25
+ 2016-12-01,11.69021
26
+ 2017-01-01,16.132154
27
+ 2017-02-01,19.781033
28
+ 2017-03-01,25.583107
29
+ 2017-04-01,26.611046
30
+ 2017-05-01,24.249186
31
+ 2017-06-01,20.007671
32
+ 2017-07-01,14.45735
33
+ 2017-08-01,8.154652
34
+ 2017-09-01,8.17909
35
+ 2017-10-01,1.7419342
36
+ 2017-11-01,5.784525
37
+ 2017-12-01,10.24657
38
+ 2018-01-01,16.289822
39
+ 2018-02-01,22.642492
40
+ 2018-03-01,25.15024
41
+ 2018-04-01,24.562717
42
+ 2018-05-01,24.752445
43
+ 2018-06-01,18.1076
44
+ 2018-07-01,17.144907
45
+ 2018-08-01,9.765287
46
+ 2018-09-01,5.3291073
47
+ 2018-10-01,4.04141
48
+ 2018-11-01,6.247704
49
+ 2018-12-01,9.410823
50
+ 2019-01-01,18.434864
51
+ 2019-02-01,18.922728
52
+ 2019-03-01,23.709166
53
+ 2019-04-01,25.042074
54
+ 2019-05-01,23.702662
55
+ 2019-06-01,20.08302
56
+ 2019-07-01,14.277656
57
+ 2019-08-01,9.124888
58
+ 2019-09-01,5.046505
59
+ 2019-10-01,2.767738
60
+ 2019-11-01,6.664206
61
+ 2019-12-01,11.476564
62
+ 2020-01-01,14.185374
63
+ 2020-02-01,19.162077
64
+ 2020-03-01,23.18553
65
+ 2020-04-01,24.30904
66
+ 2020-05-01,21.50585
67
+ 2020-06-01,22.047663
68
+ 2020-07-01,15.6585
69
+ 2020-08-01,8.932458
70
+ 2020-09-01,6.7855034
71
+ 2020-10-01,4.3423142
72
+ 2020-11-01,6.0222898
73
+ 2020-12-01,10.545946
74
+ 2021-01-01,16.429447
75
+ 2021-02-01,22.279285
76
+ 2021-03-01,26.216118
77
+ 2021-04-01,24.626713
78
+ 2021-05-01,22.910631
79
+ 2021-06-01,20.149397
80
+ 2021-07-01,15.192514
81
+ 2021-08-01,8.898667
82
+ 2021-09-01,5.409033
83
+ 2021-10-01,6.2199106
84
+ 2021-11-01,8.802447
85
+ 2021-12-01,9.660249
86
+ 2022-01-01,14.028052
87
+ 2022-02-01,19.574944
88
+ 2022-03-01,22.167557
89
+ 2022-04-01,24.590693
90
+ 2022-05-01,24.29392
91
+ 2022-06-01,19.877985
92
+ 2022-07-01,16.851866
93
+ 2022-08-01,10.226332
94
+ 2022-09-01,7.061425
95
+ 2022-10-01,4.7768636
96
+ 2022-11-01,8.313245
97
+ 2022-12-01,8.166482
98
+ 2023-01-01,14.544613
99
+ 2023-02-01,18.239468
100
+ 2023-03-01,24.899664
101
+ 2023-04-01,26.275484
102
+ 2023-05-01,22.886602
103
+ 2023-06-01,22.48717
104
+ 2023-07-01,14.554106
105
+ 2023-08-01,7.9249344
106
+ 2023-09-01,5.917939
107
+ 2023-10-01,5.540031
108
+ 2023-11-01,5.9881577
109
+ 2023-12-01,13.398281
110
+ 2024-01-01,16.28308
111
+ 2024-02-01,22.59692
112
+ 2024-03-01,25.739082
113
+ 2024-04-01,22.471323
114
+ 2024-05-01,23.093616
115
+ 2024-06-01,15.907271
116
+ 2024-07-01,14.030405
117
+ 2024-08-01,11.672656
118
+ 2024-09-01,5.074929
119
+ 2024-10-01,4.044967
120
+ 2024-11-01,6.828947
121
+ 2024-12-01,11.180975
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ numpy
2
+ pandas
3
+ matplotlib
4
+ gluonts
tinycast/__init__.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyCast: an attention-free, 146,505-parameter dilated-convolution
2
+ time-series foundation model.
3
+
4
+ Forecast:
5
+ >>> from tinycast import TinyCastPredictor, load_checkpoint
6
+ >>> predictor = TinyCastPredictor(
7
+ ... prediction_length=48,
8
+ ... checkpoint_path="model.safetensors",
9
+ ... freq="H", domain="Energy", device="cpu",
10
+ ... force_flip_invariance=True,
11
+ ... )
12
+ >>> forecasts = predictor.predict(gluonts_test_input)
13
+
14
+ Evaluate:
15
+ >>> from tinycast import summarize_by_freq_bin
16
+ >>> summarize_by_freq_bin("all_results.csv")["overall"]["ncrps"]
17
+
18
+ Train, then release:
19
+ >>> from tinycast import (TinyCastConfig, train, average_checkpoints,
20
+ ... export_safetensors)
21
+ >>> result = train(TinyCastConfig(), data=windows, max_steps=1000,
22
+ ... output_dir="run/", batch_size=32, checkpoint_every=100)
23
+ >>> weights = average_checkpoints(result.checkpoints[-8:])
24
+ >>> export_safetensors(weights, "release/", expect_parameters=146_505)
25
+
26
+ Rebuild the synthetic corpus (needs CUDA):
27
+ >>> from tinycast import build_shard, verify_shard
28
+ >>> build_shard() # published shard 0
29
+ >>> verify_shard("synth4096_0", shard=0)
30
+
31
+ ``tinycast.train`` is the training function, not the module: the two share a
32
+ name and the function wins. Module-level recipe constants are reachable as
33
+ ``from tinycast.train import AR_CHUNKS``. A submodule the package does not
34
+ import itself, ``tinycast.backbone`` and ``tinycast.periodogram`` among them,
35
+ becomes an attribute only after ``import tinycast.backbone``.
36
+ """
37
+
38
+ from .config import TinyCastConfig
39
+ from .model import TinyCastForPrediction, TinyCastBackbone, PredictionOutput
40
+ from .checkpoint import load_checkpoint, load_model
41
+ from .predictor import TinyCastPredictor, ARRolloutPredictor
42
+
43
+ # Training and the objectives it optimizes.
44
+ from .losses import committing_loss, pinball_loss, seasonal_copy_baseline
45
+ from .train import TrainResult, train, training_window_width
46
+
47
+ # The synthetic pretraining corpus generators.
48
+ from .synth import generate_gp, generate_spikes, generate_tsi
49
+
50
+ # eval, export and corpus each carry a ``python -m`` entry point, so the package
51
+ # must not import them eagerly: that puts them in sys.modules before runpy runs
52
+ # them as __main__, which warns and executes the module body twice. Resolving
53
+ # them on first attribute access (PEP 562) keeps ``from tinycast import
54
+ # evaluate`` working and leaves the command line quiet.
55
+ _LAZY_MODULES = ("eval", "export", "corpus")
56
+ _LAZY_EXPORTS = {
57
+ "evaluate": "eval",
58
+ "summarize_by_freq_bin": "eval",
59
+ "export_safetensors": "export",
60
+ "average_checkpoints": "export",
61
+ "check_export_roundtrip": "export",
62
+ "ExportError": "export",
63
+ "build_shard": "corpus",
64
+ "verify_shard": "corpus",
65
+ "iter_shard_series": "corpus",
66
+ }
67
+
68
+
69
+ def __getattr__(name: str):
70
+ from importlib import import_module
71
+
72
+ if name in _LAZY_MODULES:
73
+ value = import_module(f".{name}", __name__)
74
+ else:
75
+ module = _LAZY_EXPORTS.get(name)
76
+ if module is None:
77
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
78
+ value = getattr(import_module(f".{module}", __name__), name)
79
+ globals()[name] = value # resolve once, then it is a plain global
80
+ return value
81
+
82
+
83
+ def __dir__() -> list:
84
+ return sorted(set(globals()) | set(_LAZY_EXPORTS) | set(_LAZY_MODULES))
85
+
86
+
87
+ __all__ = [
88
+ # model and inference
89
+ "TinyCastConfig",
90
+ "TinyCastForPrediction",
91
+ "TinyCastBackbone",
92
+ "PredictionOutput",
93
+ "load_checkpoint",
94
+ "load_model",
95
+ "TinyCastPredictor",
96
+ "ARRolloutPredictor",
97
+ # training
98
+ "train",
99
+ "TrainResult",
100
+ "training_window_width",
101
+ "pinball_loss",
102
+ "committing_loss",
103
+ "seasonal_copy_baseline",
104
+ # export
105
+ "export_safetensors",
106
+ "average_checkpoints",
107
+ "check_export_roundtrip",
108
+ "ExportError",
109
+ # evaluation
110
+ "evaluate",
111
+ "summarize_by_freq_bin",
112
+ # synthetic corpus
113
+ "build_shard",
114
+ "verify_shard",
115
+ "iter_shard_series",
116
+ "generate_gp",
117
+ "generate_spikes",
118
+ "generate_tsi",
119
+ ]
120
+
121
+ __version__ = "1.0.0"
tinycast/backbone.py ADDED
@@ -0,0 +1,1070 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dilated-convolution forecasting backbone.
2
+
3
+ An attention-free encoder: a stack of causal dilated convolutions. With
4
+ kernel K=3 and dilations d_i = 2^(i-1) for i=1..N the receptive field is
5
+
6
+ RF = 1 + 2 * sum_i d_i = 1 + 2 * (2^N - 1)
7
+
8
+ so N=10 layers cover RF=2047, sufficient for the L=2048 context with zero
9
+ downsampling and no information loss at any time scale. Native multi-scale
10
+ via the dilation schedule; deployment-friendly (no L^2 attention matrix, no
11
+ softmax, pure matmul + element-wise; quantizes cleanly to INT8); streaming-
12
+ friendly (left-only causal padding).
13
+
14
+ Structural priors (zero-parameter):
15
+ - a normalized-periodogram period detector driving a phase encoding
16
+ - bounded recency basis (signed-linear/log, multi-scale exp decay)
17
+ - position-parameterized decoder queries (single-shot arbitrary horizon)
18
+ - no autoregressive rollout inside the backbone
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+
29
+ from .periodogram import significant_periods
30
+ from .encoding import (
31
+ N_RECENCY_CHANNELS,
32
+ _norm_fp32,
33
+ _phase_encoding,
34
+ _positional_encoding,
35
+ )
36
+
37
+
38
+ class _SwiGLU(nn.Module):
39
+ """Standard SwiGLU FFN."""
40
+
41
+ def __init__(self, d: int, d_hidden: int) -> None:
42
+ super().__init__()
43
+ self.up = nn.Linear(d, 2 * d_hidden)
44
+ self.down = nn.Linear(d_hidden, d)
45
+
46
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
47
+ gate, val = self.up(x).chunk(2, dim=-1)
48
+ return self.down(F.silu(gate) * val)
49
+
50
+
51
+ class _DilatedConvBlock(nn.Module):
52
+ """Dilated Conv1d → RMSNorm → SwiGLU → RMSNorm with residuals.
53
+
54
+ ``causal=True``, which is what the released config sets, pads all (K-1)*d
55
+ timesteps on the left, so each output position sees only its own past. With
56
+ ``causal=False`` the padding is centered instead: length is still preserved
57
+ and each position gets a symmetric view of (K-1)*d/2 timesteps on either
58
+ side, at the cost of future-side context. Under both, the dilation scales
59
+ the per-layer receptive field without adding parameters.
60
+ """
61
+
62
+ def __init__(
63
+ self, d: int, kernel: int = 3, dilation: int = 1,
64
+ ffn_mult: float = 1.5, causal: bool = False, gated: bool = False,
65
+ separable: bool = False,
66
+ ) -> None:
67
+ super().__init__()
68
+ self.k = int(kernel)
69
+ self.dilation = int(dilation)
70
+ self.causal = bool(causal)
71
+ self.gated = bool(gated)
72
+ self.separable = bool(separable)
73
+ # Conv with dilation; padding handled in forward. Centered padding
74
+ # gives each position a symmetric view but feeds the right edge
75
+ # FUTURE-side zeros: at the deepest layer the last (most recent)
76
+ # position's representation is dominated by padding standing in for
77
+ # the unknown forecast, and the model learns a train/inference
78
+ # mismatch. Causal (all-left) padding removes both pathologies and
79
+ # is a prerequisite for honest streaming inference.
80
+ if self.separable:
81
+ # Depthwise-separable factorization: depthwise (per-channel, dilated,
82
+ # padding consumed in forward) + pointwise 1x1 (channel mix). Params
83
+ # D*K + D*D against a full conv's D*D*K: the receptive field is
84
+ # preserved at a fraction of the parameters per block. Padding
85
+ # before self.conv feeds the depthwise stage.
86
+ self.conv = nn.Sequential(
87
+ nn.Conv1d(d, d, kernel_size=self.k, dilation=self.dilation, groups=d),
88
+ nn.Conv1d(d, d, kernel_size=1),
89
+ )
90
+ else:
91
+ self.conv = nn.Conv1d(d, d, kernel_size=self.k, dilation=self.dilation)
92
+ # Lightweight gated conv (multiplicative gating): a cheap
93
+ # DEPTHWISE gate conv produces a sigmoid mask over the main conv output,
94
+ # conv_out * σ(gate). Adds data-dependent gating to the encoder at ~D·k
95
+ # params/block (vs doubling the full conv). Off by default.
96
+ self.gate = (
97
+ nn.Conv1d(d, d, kernel_size=self.k, dilation=self.dilation, groups=d)
98
+ if self.gated else None
99
+ )
100
+ self.norm1 = nn.RMSNorm(d)
101
+ d_hidden = int(d * ffn_mult)
102
+ self.ffn = _SwiGLU(d, d_hidden)
103
+ self.norm2 = nn.RMSNorm(d)
104
+
105
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
106
+ # x: (B, L, D). Conv1d takes (B, D, L).
107
+ x_t = x.transpose(1, 2)
108
+ # Effective kernel span = (K-1)*d + 1.
109
+ pad_total = (self.k - 1) * self.dilation
110
+ if self.causal:
111
+ left, right = pad_total, 0
112
+ else:
113
+ left = pad_total // 2
114
+ right = pad_total - left
115
+ x_t = F.pad(x_t, (left, right))
116
+ if self.separable and self.gate is None:
117
+ # Depthwise (B,D,L); then the pointwise 1x1 conv == a per-position
118
+ # Linear, run as F.linear (cublas GEMM) instead of an im2col/cudnn
119
+ # 1x1-conv kernel. Bit-identical channel contraction; params stay as
120
+ # conv.0/conv.1 so existing checkpoints load unchanged.
121
+ dw = self.conv[0](x_t).transpose(1, 2) # (B, L, D)
122
+ pw = self.conv[1]
123
+ conv_out = F.linear(dw, pw.weight.squeeze(-1), pw.bias) # (B, L, D)
124
+ else:
125
+ conv_out = self.conv(x_t)
126
+ if self.gate is not None:
127
+ conv_out = conv_out * torch.sigmoid(self.gate(x_t))
128
+ conv_out = conv_out.transpose(1, 2)
129
+ x = _norm_fp32(self.norm1, x + conv_out)
130
+ x = _norm_fp32(self.norm2, x + self.ffn(x))
131
+ return x
132
+
133
+
134
+ class DilatedConvBackbone(nn.Module):
135
+ """Dilated-conv encoder with a phase-conditioned, position-parameterized decoder.
136
+
137
+ Args:
138
+ seq_len: L, the context length.
139
+ p_out: H, the single-shot output length.
140
+ n_quantiles: Q, the number of output channels.
141
+ d: channel dimension.
142
+ n_layers: number of dilated-conv blocks (10 → RF 2047 ≥ L=2048).
143
+ kernel: conv kernel size (default 3).
144
+ ffn_mult: SwiGLU hidden multiplier (default 1.5).
145
+ dilations: explicit dilation schedule; if None, uses 2^(i-1).
146
+ top_k_periods: the periodogram detector's top-K (default 4).
147
+ significance_alpha: the periodogram detector's Bonferroni alpha (default 0.05).
148
+ n_harmonics: Fourier harmonics per detected period (default 1).
149
+ pool_kind: context-summary pooling: "mean_last" (default),
150
+ "mean", "last". Concat the chosen pool(s) into the
151
+ per-horizon query before query_proj.
152
+ causal: if True, all conv padding is left-only (no future
153
+ leakage, clean right edge, streaming-honest).
154
+ phase_bins: if > 0, augment the global pool with a period-folded
155
+ seasonal profile: for each period the periodogram
156
+ detector returns, fold the encoder output into this many
157
+ phase bins and let each decoder query gather the bin
158
+ matching its own phase. 0 disables phase folding and
159
+ leaves the plain global pool, which is the control the
160
+ paper's phase-folding ablation is measured against.
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ seq_len: int,
166
+ p_out: int,
167
+ n_quantiles: int = 1,
168
+ d: int = 80,
169
+ n_layers: int = 10,
170
+ kernel: int = 3,
171
+ ffn_mult: float = 1.5,
172
+ dilations: list[int] | None = None,
173
+ top_k_periods: int = 4,
174
+ significance_alpha: float = 0.05,
175
+ n_harmonics: int = 1,
176
+ pool_kind: str = "mean_last",
177
+ causal: bool = False,
178
+ phase_bins: int = 0,
179
+ phase_stats: str = "mean",
180
+ phase_recency_tau: float = 0.0,
181
+ recency_bins: int = 0,
182
+ sig_gate: bool = False,
183
+ cross_cycle: bool = False,
184
+ decoder_depth: int = 1,
185
+ horizon_kernel: int = 0,
186
+ horizon_recurrence: bool = False,
187
+ min_cycles: int = 0,
188
+ period_trust: str = "off",
189
+ gated_conv: bool = False,
190
+ residual_naive: bool = False,
191
+ residual_multi: bool = False,
192
+ residual_trend: bool = False,
193
+ decompose_kernel: int = 0,
194
+ periodogram_off: bool = False,
195
+ res_adaptive: bool = False,
196
+ res_period_target: int = 64,
197
+ res_r_max: float = 32.0,
198
+ with_missing: bool = False,
199
+ missing_channel: bool = False,
200
+ separable_conv: bool = False,
201
+ share_ffn: bool = False,
202
+ future_conv: bool = False,
203
+ future_conv_layers: int = 6,
204
+ future_conv_seed: int = 128,
205
+ base_seasonality: float = 24.0,
206
+ local_anchor: bool = False,
207
+ ) -> None:
208
+ super().__init__()
209
+ self.L = int(seq_len)
210
+ self.p_out = int(p_out)
211
+ self.n_quantiles = int(n_quantiles)
212
+ self.D = int(d)
213
+ self.K = int(top_k_periods)
214
+ self.significance_alpha = float(significance_alpha)
215
+ self.n_harmonics = int(n_harmonics)
216
+ self.pool_kind = str(pool_kind)
217
+ self.causal = bool(causal)
218
+ self.phase_bins = int(phase_bins)
219
+ if phase_stats not in ("mean", "mean_var"):
220
+ raise ValueError(f"phase_stats={phase_stats!r}; expected 'mean'|'mean_var'.")
221
+ self.phase_stats = str(phase_stats)
222
+ self.stat_mult = 2 if self.phase_stats == "mean_var" else 1
223
+ self.phase_recency_tau = float(phase_recency_tau)
224
+ self.recency_bins = int(recency_bins)
225
+ self.sig_gate = bool(sig_gate)
226
+ self.cross_cycle = bool(cross_cycle)
227
+ self.decoder_depth = max(1, int(decoder_depth))
228
+ self.horizon_kernel = int(horizon_kernel)
229
+ self.horizon_recurrence = bool(horizon_recurrence)
230
+ self.min_cycles = int(min_cycles)
231
+ if period_trust not in ("off", "coverage", "full"):
232
+ raise ValueError(f"period_trust={period_trust!r}; expected off|coverage|full")
233
+ self.period_trust = str(period_trust)
234
+ # Per-period reliability weight w_k = sigmoid(linear([margin, ln
235
+ # coverage])): a continuous down-weighting of long or weakly-supported
236
+ # periods, whose crossover is learned rather than set. It covers the
237
+ # same ground as the integer min_cycles cutoff, which stays available
238
+ # and independent; both are off in the released config. "coverage" uses
239
+ # ln(L/period) alone (min_cycles is its hard-threshold limit); "full"
240
+ # adds the significance margin ln(s_k/t_alpha) from the detector's
241
+ # periodogram scores.
242
+ if self.period_trust != "off":
243
+ n_feat = 1 if self.period_trust == "coverage" else 2
244
+ self.pt = nn.Linear(n_feat, 1)
245
+ with torch.no_grad():
246
+ self.pt.weight.zero_(); self.pt.weight[0, 0] = 1.0 # cov (or margin) coeff = 1
247
+ self.pt.bias.zero_()
248
+ # data-determined Bonferroni threshold t_alpha (no tuned knob)
249
+ n_fft = 1 << int(math.ceil(math.log2(max(2, self.L))))
250
+ self._n_bins = max(2, n_fft // 2)
251
+ self._t_alpha = math.log(self._n_bins / max(self.significance_alpha, 1e-12)) / self._n_bins
252
+ else:
253
+ self.pt = None
254
+ self.gated_conv = bool(gated_conv)
255
+ self.residual_naive = bool(residual_naive)
256
+ self.residual_multi = bool(residual_multi)
257
+ self.residual_trend = bool(residual_trend)
258
+ self.periodogram_off = bool(periodogram_off)
259
+ self.res_adaptive = bool(res_adaptive)
260
+ self.res_period_target = int(res_period_target)
261
+ self.res_r_max = float(res_r_max)
262
+ # Series decomposition: moving-avg trend / seasonal split fed
263
+ # as two input channels. 0 disables. Even kernel → +1 for centered.
264
+ self.decompose_kernel = int(decompose_kernel)
265
+ self.with_missing = bool(with_missing)
266
+ # Missing-value channel: feed the encoder a binary observed-mask so it
267
+ # can distinguish a genuinely-unobserved position from a real value (the
268
+ # faithful treatment, vs mean-fill which conflates the two).
269
+ self.missing_channel = bool(missing_channel)
270
+ if self.missing_channel and bool(res_adaptive):
271
+ raise NotImplementedError(
272
+ "missing_channel + res_adaptive: the observed-mask is not warped "
273
+ "through _resolution_adapt; not supported together."
274
+ )
275
+ # Recent-anchor channels: gradient-connected causal local level/scale so the
276
+ # encoder can re-anchor amplitude under non-stationarity (the denorm origin
277
+ # x_min is a frozen detached GLOBAL min, with no learned path to the
278
+ # recent regime).
279
+ self.local_anchor = bool(local_anchor)
280
+
281
+ if self.n_harmonics < 1:
282
+ raise ValueError(f"n_harmonics must be >= 1; got {self.n_harmonics}")
283
+ if pool_kind not in ("mean_last", "mean", "last"):
284
+ raise ValueError(
285
+ f"pool_kind={pool_kind!r}; expected 'mean_last' | 'mean' | 'last'."
286
+ )
287
+
288
+ # Positional encoding: phase channels + bounded recency channels.
289
+ n_phase = 2 * self.K * self.n_harmonics
290
+ n_pe = n_phase + N_RECENCY_CHANNELS
291
+ # Input channels: raw value, or [trend, seasonal] if decomposing.
292
+ n_value_ch = 2 if self.decompose_kernel > 0 else 1
293
+ if self.missing_channel:
294
+ n_value_ch += 1 # +observed-mask
295
+ if self.local_anchor:
296
+ n_value_ch += 2 # +[local-scale residual, log local-scale]
297
+ in_channels = n_value_ch + n_pe
298
+ self.in_proj = nn.Linear(in_channels, self.D)
299
+ if self.local_anchor:
300
+ # zero-init the 2 anchor columns (last of the value channels) so the
301
+ # model is baseline-equivalent at init and learns the anchor from zero.
302
+ with torch.no_grad():
303
+ self.in_proj.weight[:, n_value_ch - 2:n_value_ch].zero_()
304
+
305
+ # Dilation schedule.
306
+ if dilations is None:
307
+ dilations = [2**i for i in range(int(n_layers))]
308
+ if len(dilations) != int(n_layers):
309
+ raise ValueError(
310
+ f"dilations length {len(dilations)} != n_layers {n_layers}"
311
+ )
312
+ self.dilations = list(dilations)
313
+
314
+ # Receptive field sanity (informational only; fails soft if RF < L).
315
+ rf = 1 + (kernel - 1) * sum(self.dilations)
316
+ self.receptive_field = rf
317
+
318
+ self.encoder = nn.ModuleList([
319
+ _DilatedConvBlock(
320
+ self.D, kernel=int(kernel), dilation=int(d_i),
321
+ ffn_mult=float(ffn_mult), causal=self.causal,
322
+ gated=self.gated_conv, separable=bool(separable_conv),
323
+ )
324
+ for d_i in self.dilations
325
+ ])
326
+ # Cross-layer FFN weight sharing (weight-tied): the SwiGLU FFN is the
327
+ # largest param bucket and is dilation-independent, so one shared FFN
328
+ # across all blocks recovers ~(n_layers-1)/n_layers of FFN params. The
329
+ # per-block dilated convs (which carry the receptive field) stay distinct.
330
+ self.share_ffn = bool(share_ffn)
331
+ if self.share_ffn and len(self.encoder) > 1:
332
+ shared_ffn = self.encoder[0].ffn
333
+ for blk in self.encoder[1:]:
334
+ blk.ffn = shared_ffn
335
+
336
+ # Pooled context summary dim depends on pool_kind.
337
+ pool_dim = {"mean_last": 2 * self.D, "mean": self.D, "last": self.D}[
338
+ self.pool_kind
339
+ ]
340
+
341
+ # Phase-binned seasonal profile: K period-folded profiles, each
342
+ # gathered by the decoder query's own phase, then mixed to D. A global
343
+ # mean pool averages every phase of a cycle into one vector, so nothing
344
+ # that varies with phase survives it; folding by phase keeps the
345
+ # per-cycle waveform and hands each query the part of the cycle it is
346
+ # forecasting.
347
+ # Phase profile mixer: K periods × n_bins × (mean[,var]) → D.
348
+ if self.phase_bins > 0:
349
+ self.phase_mix = nn.Linear(self.K * self.stat_mult * self.D, self.D)
350
+ else:
351
+ self.phase_mix = None
352
+
353
+ # Recency profile mixer: rb log-distance bins × (mean[,var])
354
+ # → D. Always-valid aperiodic content path. Flattened (not gathered).
355
+ if self.recency_bins > 0:
356
+ self.recency_mix = nn.Linear(
357
+ self.recency_bins * self.stat_mult * self.D, self.D,
358
+ )
359
+ else:
360
+ self.recency_mix = None
361
+
362
+ # Cross-cycle conv branch: a depthwise conv across cycles
363
+ # at fixed phase, applied to the dominant period's [n_cycles × n_bins]
364
+ # fold. Adds one D-dim feature to the query. See _cross_cycle_profile.
365
+ if self.cross_cycle:
366
+ self.cc_bins = self.phase_bins if self.phase_bins > 0 else 16
367
+ self.cc_cycles = 8 # most-recent N cycles folded; older clamped
368
+ # Depthwise conv ACROSS the cycle axis (length cc_cycles) at fixed
369
+ # phase: models how each phase evolves cycle-to-cycle.
370
+ self.cc_conv = nn.Conv1d(
371
+ self.D, self.D, kernel_size=3, padding=1, groups=self.D,
372
+ )
373
+ self.cc_mix = nn.Linear(self.D, self.D)
374
+ else:
375
+ self.cc_conv = None
376
+
377
+ # Decoder query input: PE + pool [+ phase D] [+ recency D] [+ cc D].
378
+ # With sig_gate, phase & recency are blended into a single D (not
379
+ # concatenated), so they contribute D once, not 2·D.
380
+ query_in = n_pe + pool_dim
381
+ if self.sig_gate and self.phase_mix is not None and self.recency_mix is not None:
382
+ query_in += self.D
383
+ else:
384
+ query_in += self.D if self.phase_mix is not None else 0
385
+ query_in += self.D if self.recency_mix is not None else 0
386
+ query_in += self.D if self.cross_cycle else 0
387
+ self.query_proj = nn.Linear(query_in, self.D)
388
+ d_hidden = int(self.D * float(ffn_mult))
389
+ # Decoder: `decoder_depth` residual SwiGLU blocks (depth 1 is a single
390
+ # block). The decoder's inputs are rich (phase/recency profiles), and
391
+ # depth lets it process them.
392
+ self.decoder_ffns = nn.ModuleList(
393
+ [_SwiGLU(self.D, d_hidden) for _ in range(self.decoder_depth)]
394
+ )
395
+ self.decoder_norms = nn.ModuleList(
396
+ [nn.RMSNorm(self.D) for _ in range(self.decoder_depth)]
397
+ )
398
+ # Cross-horizon coherence: a causal depthwise conv across the
399
+ # horizon axis couples adjacent forecast steps (the cross-step mixing
400
+ # lost when attention was dropped). Causal + fixed kernel preserves the
401
+ # single-shot arbitrary-horizon property. horizon_kernel=0 disables.
402
+ if self.horizon_kernel > 0:
403
+ self.horizon_conv = nn.Conv1d(
404
+ self.D, self.D, kernel_size=self.horizon_kernel, groups=self.D,
405
+ )
406
+ self.horizon_norm = nn.RMSNorm(self.D)
407
+ else:
408
+ self.horizon_conv = None
409
+ # Horizon-recurrent decode-state: a gated diagonal recurrence over the
410
+ # SHORT horizon axis, scanning the precomputed query features. It never
411
+ # re-feeds predicted values, so the decoder stays single-shot rather
412
+ # than autoregressive. An unbounded carried state couples step h to ALL
413
+ # earlier steps (vs the fixed-span horizon_conv), the property that
414
+ # makes a recurrent decoder horizon-invariant. hr_o is zero-init so the
415
+ # block is identity at start and cannot regress the baseline.
416
+ if self.horizon_recurrence:
417
+ self.hr_z = nn.Linear(self.D, self.D) # update gate
418
+ self.hr_c = nn.Linear(self.D, self.D) # candidate
419
+ self.hr_o = nn.Linear(self.D, self.D) # output proj (zero-init)
420
+ nn.init.zeros_(self.hr_o.weight); nn.init.zeros_(self.hr_o.bias)
421
+ self.hr_norm = nn.RMSNorm(self.D)
422
+ else:
423
+ self.hr_z = None
424
+ self.out_proj = nn.Linear(self.D, self.n_quantiles)
425
+
426
+
427
+ # Future-conv decoder (horizon-axis state evolution; the conv-native
428
+ # analog of a missing-token decoder). The rest of the decoder queries a
429
+ # STATIC pooled summary at every horizon position, which is why error
430
+ # grows with horizon. future_conv runs a CAUSAL dilated conv over
431
+ # [context-tail seed ++ seasonal-naive future fill], producing
432
+ # per-future-position hidden states that EVOLVE along the horizon (each
433
+ # future position is a causal-conv function of recent context + earlier
434
+ # future), and injects them additively into the decoder query. The fill
435
+ # is the dominant-period seasonal-naive continuation (it carries
436
+ # periodic structure, so the conv evolves a real waveform forward
437
+ # rather than zeros), which leaves the decoder predicting the residual
438
+ # over a copy. ``fc_out`` is zero-init => EXACT baseline at start
439
+ # (zero-init additive idiom), and disabling it restores that baseline.
440
+ # This differs from the two cheaper readouts in the same position: a
441
+ # recurrence over queries derived from the static summary adds no new
442
+ # dynamics, and a phase gather only COPIES context profiles, whereas
443
+ # this path RUNS the conv forward.
444
+ self.future_conv = bool(future_conv)
445
+ if self.future_conv:
446
+ if res_adaptive:
447
+ raise ValueError("future_conv is incompatible with res_adaptive")
448
+ self.fc_seed = int(future_conv_seed)
449
+ fc_in = 1 + n_pe # fill value + the same PE layout
450
+ self.fc_in_proj = nn.Linear(fc_in, self.D)
451
+ fc_dils = [2 ** i for i in range(int(future_conv_layers))]
452
+ self.fc_blocks = nn.ModuleList([
453
+ _DilatedConvBlock(
454
+ self.D, kernel=int(kernel), dilation=int(d_i),
455
+ ffn_mult=float(ffn_mult), causal=True,
456
+ separable=True, # auxiliary module: keep it light (~51K add)
457
+ )
458
+ for d_i in fc_dils
459
+ ])
460
+ # Weight-tie the FFN across fc blocks: the convs carry the horizon
461
+ # dynamics, and one shared FFN keeps the param add modest.
462
+ shared = self.fc_blocks[0].ffn
463
+ for blk in self.fc_blocks[1:]:
464
+ blk.ffn = shared
465
+ self.fc_out = nn.Linear(self.D, self.D) # zero-init => exact baseline
466
+ nn.init.zeros_(self.fc_out.weight)
467
+ nn.init.zeros_(self.fc_out.bias)
468
+
469
+ self.base_seasonality = float(base_seasonality)
470
+
471
+ # ---- helpers ----------------------------------------------------------
472
+
473
+ def _future_conv_states(
474
+ self, h: torch.Tensor, fut_pe: torch.Tensor, fill: torch.Tensor,
475
+ ) -> torch.Tensor:
476
+ """Causal-conv continuation states at the H future positions.
477
+
478
+ h: (B, L, D) encoder output; fut_pe: (B, H, n_pe); fill: (B, H) the
479
+ seasonal-naive future continuation. Returns (B, H, D).
480
+ """
481
+ # self.L is static (asserted == L in forward), so using it instead of
482
+ # the traced h.shape[1] keeps dynamo from graph-splitting on a symint
483
+ # bound.
484
+ ft = self.fc_in_proj(torch.cat([fill.unsqueeze(-1), fut_pe], dim=-1)) # (B,H,D)
485
+ seed = h[:, -min(self.fc_seed, self.L):, :] # (B, seed, D)
486
+ z = torch.cat([seed, ft], dim=1) # (B, seed+H, D)
487
+ for blk in self.fc_blocks: # causal: no future leak
488
+ z = blk(z)
489
+ return z[:, -ft.shape[1]:, :] # (B, H, D)
490
+
491
+ @torch.compiler.disable()
492
+ def _detect_periods(
493
+ self, x_fp32: torch.Tensor,
494
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
495
+ """Run the significance-filtered periodogram in fp32 (no grad).
496
+
497
+ @torch.compiler.disable: the periodogram uses a complex rfft that
498
+ Inductor cannot codegen: left inside the compiled graph it forces an
499
+ eager fallback + graph break every forward, early in `forward`, blocking
500
+ fusion of the whole conv encoder/decoder downstream. Disabling compile on
501
+ this (no_grad, fp32, produces integer periods the rest only reads) makes a
502
+ clean eager boundary: the FFT runs eager, everything after fuses. Output
503
+ bit-identical (only where it compiles changes).
504
+
505
+ Returns ``(periods, n_valid, scores)``: the integer periods (0 =
506
+ rejected), the count of significant periods per sample, and the
507
+ per-period periodogram scores. ``n_valid`` is the periodicity-strength
508
+ signal the significance gate reads.
509
+ """
510
+ B, L = x_fp32.shape
511
+ if self.periodogram_off:
512
+ # Control: no period detection → phase encoding zeros out, phase
513
+ # machinery is inert. Tests whether the conv backbone matches with
514
+ # a phase-free (recency-only) decoder.
515
+ z = torch.zeros(B, self.K, dtype=torch.long, device=x_fp32.device)
516
+ return (z, torch.zeros(B, dtype=torch.long, device=x_fp32.device),
517
+ torch.zeros(B, self.K, device=x_fp32.device))
518
+ with torch.no_grad():
519
+ periods, scores, n_valid = significant_periods(
520
+ x_fp32,
521
+ min_period=2,
522
+ max_period=L // 2,
523
+ top_k=self.K,
524
+ significance_alpha=self.significance_alpha,
525
+ )
526
+ periods = periods.long()
527
+ if self.min_cycles > 0:
528
+ # "Do no harm": only TRUST a period with >= min_cycles full
529
+ # cycles in the window (period <= L/min_cycles). Periods too long
530
+ # to be reliably estimated (e.g. an 8640-sample daily cycle in a
531
+ # 2048 window) are zeroed → the significance gate routes those
532
+ # series to the recency/local path instead of mis-locking phase.
533
+ max_p = L // self.min_cycles
534
+ keep = (periods > 0) & (periods <= max_p)
535
+ periods = torch.where(keep, periods, torch.zeros_like(periods))
536
+ n_valid = keep.sum(dim=1).long()
537
+ return periods, n_valid.long(), scores.float()
538
+
539
+ def _pool(self, h: torch.Tensor) -> torch.Tensor:
540
+ """Pool encoder output to a fixed-size summary.
541
+
542
+ h: (B, L, D)
543
+ Returns: (B, pool_dim)
544
+ """
545
+ if self.pool_kind == "mean":
546
+ return h.mean(dim=1)
547
+ if self.pool_kind == "last":
548
+ return h[:, -1, :]
549
+ # mean_last:
550
+ return torch.cat([h.mean(dim=1), h[:, -1, :]], dim=-1)
551
+
552
+ def _scatter_profile(
553
+ self, h: torch.Tensor, bins: torch.Tensor, nb: int,
554
+ weight: torch.Tensor | None = None,
555
+ ) -> torch.Tensor:
556
+ """Weighted scatter-mean (+ optional per-bin variance) of h into nb bins.
557
+
558
+ h: (B, L, D)
559
+ bins: (B, L) int in [0, nb)
560
+ weight: (B, L) non-negative, or None for uniform.
561
+ Returns (B, nb, stat_mult·D): per-bin mean, then per-bin variance if
562
+ ``phase_stats == 'mean_var'``. Empty bins → global mean
563
+ (and zero variance).
564
+ """
565
+ B, L, D = h.shape
566
+ oh = F.one_hot(bins, nb).to(h.dtype) # (B,L,nb)
567
+ if weight is not None:
568
+ oh = oh * weight.unsqueeze(-1)
569
+ cnt = oh.sum(dim=1).unsqueeze(-1) # (B,nb,1)
570
+ denom = cnt.clamp(min=1e-6)
571
+ mean = torch.bmm(oh.transpose(1, 2), h) / denom # (B,nb,D)
572
+ gmean = h.mean(dim=1, keepdim=True) # (B,1,D)
573
+ empty = cnt <= 0
574
+ mean = torch.where(empty, gmean.expand(B, nb, D), mean)
575
+ if self.phase_stats == "mean_var":
576
+ sq = torch.bmm(oh.transpose(1, 2), h * h) / denom # E[h²]
577
+ var = (sq - mean * mean).clamp(min=0.0)
578
+ var = torch.where(empty, torch.zeros_like(var), var)
579
+ return torch.cat([mean, var], dim=-1) # (B,nb,2D)
580
+ return mean # (B,nb,D)
581
+
582
+ def _recency_weight(self, L: int, device) -> torch.Tensor | None:
583
+ """exp recency weight over context positions: recent positions weigh
584
+ more, so the folded profile tracks the CURRENT regime's waveform rather
585
+ than the window average. None if tau<=0 (uniform)."""
586
+ if self.phase_recency_tau <= 0.0:
587
+ return None
588
+ t = torch.arange(L, device=device).float()
589
+ dist = (L - 1 - t) / L # 0 at now
590
+ return torch.exp(-dist / self.phase_recency_tau).view(1, L)
591
+
592
+ def _phase_profile(
593
+ self, h: torch.Tensor, periods: torch.Tensor,
594
+ ) -> torch.Tensor:
595
+ """Period-fold the encoder output into per-phase profiles.
596
+
597
+ Returns (B, K, n_bins, stat_mult·D). Each detected period p_k folds
598
+ the sequence into n_bins phase bins; per bin we keep the (recency-
599
+ weighted) mean [and variance]. Empty bins → global mean.
600
+ """
601
+ B, L, D = h.shape
602
+ K, nb = self.K, self.phase_bins
603
+ t = torch.arange(L, device=h.device).view(1, L).float() # (1,L)
604
+ p_safe = periods.clamp(min=1).float() # (B,K)
605
+ weight = self._recency_weight(L, h.device)
606
+ if weight is not None:
607
+ weight = weight.expand(B, L)
608
+ profs = []
609
+ for k in range(K):
610
+ frac = (t % p_safe[:, k:k + 1]) / p_safe[:, k:k + 1] # (B,L)
611
+ bins = torch.clamp((frac * nb).long(), max=nb - 1) # (B,L)
612
+ profs.append(self._scatter_profile(h, bins, nb, weight))
613
+ return torch.stack(profs, dim=1) # (B,K,nb,S·D)
614
+
615
+ def _gather_phase(
616
+ self, prof: torch.Tensor, fut_pos: torch.Tensor,
617
+ periods: torch.Tensor, weight: torch.Tensor | None = None,
618
+ ) -> torch.Tensor:
619
+ """Gather each future query's matching phase bin per period, mix → D.
620
+
621
+ prof: (B, K, n_bins, S·D); fut_pos: (B, H); periods: (B, K).
622
+ weight: optional (B,K) per-period reliability weight (period_trust).
623
+ Returns (B, H, D).
624
+ """
625
+ B, K, nb, SD = prof.shape
626
+ H = fut_pos.shape[1]
627
+ p_safe = periods.clamp(min=1).float().view(B, 1, K) # (B,1,K)
628
+ frac = (fut_pos.unsqueeze(-1).float() % p_safe) / p_safe # (B,H,K)
629
+ fbins = torch.clamp((frac * nb).long(), max=nb - 1) # (B,H,K)
630
+ idx = fbins.permute(0, 2, 1).unsqueeze(-1).expand(B, K, H, SD)
631
+ gathered = torch.gather(prof, 2, idx) # (B,K,H,S·D)
632
+ if weight is not None:
633
+ gathered = gathered * weight.view(B, K, 1, 1).to(gathered.dtype)
634
+ gathered = gathered.permute(0, 2, 1, 3).reshape(B, H, K * SD)
635
+ return self.phase_mix(gathered) # (B,H,D)
636
+
637
+ def _period_trust_weights(
638
+ self, periods: torch.Tensor, scores: torch.Tensor,
639
+ ) -> torch.Tensor:
640
+ """Hyperparameter-free per-period reliability weight w_k∈[0,1] (B,K).
641
+
642
+ ln-coverage = ln(L/period) (data/structure-determined); for 'full' also
643
+ the significance margin ln(s_k/t_alpha) (s_k = the periodogram score,
644
+ t_alpha = data-determined Bonferroni threshold). The sigmoid crossover
645
+ is LEARNED (the linear layer's weights and bias), not a hand-set
646
+ threshold. 0 on rejected slots.
647
+ """
648
+ valid = periods > 0
649
+ logcov = torch.log(float(self.L) / periods.clamp(min=1).float()) # (B,K)
650
+ if self.period_trust == "coverage":
651
+ feat = logcov.unsqueeze(-1) # (B,K,1)
652
+ else:
653
+ margin = torch.log(scores.clamp(min=1e-12) / self._t_alpha) # >=0 for survivors
654
+ feat = torch.stack([margin, logcov], dim=-1) # (B,K,2)
655
+ w = torch.sigmoid(self.pt(feat.to(self.pt.weight.dtype))).squeeze(-1)
656
+ return torch.where(valid, w, torch.zeros_like(w))
657
+
658
+ def _recency_feat(self, h: torch.Tensor) -> torch.Tensor:
659
+ """Recency-binned profile: bin context positions by
660
+ log-distance-from-now and pool. Always valid (no period needed);
661
+ the aperiodic content path. Flattened to a single (B, D) descriptor
662
+ (broadcast to all horizons; the query's own PE carries how-far-ahead).
663
+ """
664
+ B, L, D = h.shape
665
+ rb = self.recency_bins
666
+ t = torch.arange(L, device=h.device).view(1, L).float().expand(B, L)
667
+ dist = (L - 1 - t).clamp(min=0.0) # 0=now
668
+ frac = torch.log1p(dist) / math.log1p(float(L - 1) + 1e-9)
669
+ bins = torch.clamp((frac * rb).long(), max=rb - 1) # (B,L)
670
+ prof = self._scatter_profile(h, bins, rb, None) # (B,rb,S·D)
671
+ return self.recency_mix(prof.reshape(B, rb * self.stat_mult * D))
672
+
673
+ def _cross_cycle_profile(
674
+ self, h: torch.Tensor, periods: torch.Tensor,
675
+ ) -> torch.Tensor:
676
+ """Cross-cycle conv, true ragged form.
677
+
678
+ For the dominant period p0, fold the sequence into a
679
+ [cycles-back-from-now × phase] grid (B, nc, nb, D) by scatter-mean,
680
+ then convolve ACROSS the cycle axis at fixed phase (depthwise conv1d
681
+ over nc), modelling how each phase evolves cycle-to-cycle ("every
682
+ Monday 9am, trending up"). Read out the most-recent cycle (post-conv,
683
+ so it has seen the trend). Returns a (B, nb, D) phase profile the
684
+ decoder gathers by its own phase.
685
+
686
+ Per-sample period handled like phase-binning: phase resampled to nb
687
+ fixed bins; cycles-back clamped to nc (older cycles fold into the
688
+ oldest slot). Attention-free, fixed-shape, batchable.
689
+ """
690
+ B, L, D = h.shape
691
+ nb, nc = self.cc_bins, self.cc_cycles
692
+ t = torch.arange(L, device=h.device).view(1, L).float() # (1,L)
693
+ p0 = periods[:, :1].clamp(min=1).float() # (B,1)
694
+ pbin = torch.clamp(((t % p0) / p0 * nb).long(), max=nb - 1) # (B,L)
695
+ cyc = torch.clamp(((L - 1 - t) // p0).long(), max=nc - 1) # (B,L) 0=now
696
+ comb = (cyc * nb + pbin).clamp(min=0, max=nc * nb - 1) # (B,L)
697
+ oh = F.one_hot(comb, nc * nb).to(h.dtype) # (B,L,nc·nb)
698
+ cnt = oh.sum(dim=1).unsqueeze(-1).clamp(min=1e-6)
699
+ grid = (torch.bmm(oh.transpose(1, 2), h) / cnt).view(B, nc, nb, D)
700
+ # conv across cycles (nc) at fixed phase, per channel.
701
+ x = grid.permute(0, 2, 3, 1).reshape(B * nb, D, nc) # (B·nb, D, nc)
702
+ x = self.cc_conv(x).reshape(B, nb, D, nc)
703
+ return x[..., 0] # most-recent cycle (B,nb,D)
704
+
705
+ @staticmethod
706
+ def _prefix_integral(
707
+ f: torch.Tensor, Csum: torch.Tensor, xpad: torch.Tensor, L: int,
708
+ ) -> torch.Tensor:
709
+ """Integral of piecewise-constant x from 0 to fractional position f.
710
+ f: (B,M) in native units. Csum: (B,L+1) prefix sums; xpad: (B,L+1)."""
711
+ fc = f.clamp(0.0, float(L))
712
+ k = torch.floor(fc).long()
713
+ rem = (fc - k.float()).to(Csum.dtype)
714
+ return torch.gather(Csum, 1, k) + rem * torch.gather(xpad, 1, k)
715
+
716
+ def _resolution_adapt(
717
+ self, x: torch.Tensor, periods: torch.Tensor, H: int,
718
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
719
+ """Resolution adaptation (the TinyCast premise; zero params).
720
+
721
+ Resample the context onto a canonical-period grid so the fixed dilation
722
+ schedule spans consistent CYCLE-fractions across all sampling rates: the
723
+ dominant detected period is warped to ``res_period_target`` samples/cycle.
724
+ All detected periods scale by the same ratio; the future native horizon
725
+ is queried at its canonical-mapped position (decoder is position-
726
+ parameterized, so outputs are native values and no output resampling
727
+ is needed).
728
+
729
+ Returns (x_canon (B,L), periods_canon (B,K), fut_pos_canon (B,H) float).
730
+ Aperiodic series (dominant period 0) pass through unchanged (r=1).
731
+ """
732
+ B, L = x.shape
733
+ pt = float(self.res_period_target)
734
+ p0 = periods[:, 0].float() # (B,) dominant
735
+ r = torch.where(p0 > 0, pt / p0.clamp(min=1.0), torch.ones_like(p0))
736
+ # res_r_max=1.0 → downsample-only (high-freq squeezed to canonical;
737
+ # low-freq left at native, no history truncation / no Δ blow-up).
738
+ r = r.clamp(0.05, self.res_r_max).view(B, 1) # canonical per native
739
+ j = torch.arange(L, device=x.device).view(1, L).float() # canonical idx 0..L-1
740
+ # native time (center) for canonical index j; now = most recent canonical.
741
+ t = (L - 1) - ((L - 1) - j) / r # (B,L), <0 = pre-context
742
+ # Upsample/identity (r>=1): linear interpolation (true pass-through at
743
+ # r=1). Downsample (r<1): area-average over the native window w=1/r,
744
+ # ANTI-ALIASED (averages the whole window, not 2 endpoints). Zero params.
745
+ t0 = torch.floor(t)
746
+ frac = (t - t0).to(x.dtype)
747
+ x_lin = (torch.gather(x, 1, t0.clamp(0, L - 1).long()) * (1.0 - frac)
748
+ + torch.gather(x, 1, (t0 + 1).clamp(0, L - 1).long()) * frac)
749
+ w = 1.0 / r # (B,1) native window width
750
+ lo, hi = t - w / 2.0, t + w / 2.0
751
+ Csum = F.pad(x.cumsum(dim=1), (1, 0)) # (B,L+1): Csum[k]=Σ x[:k]
752
+ xpad = F.pad(x, (0, 1)) # (B,L+1): x[L]=0
753
+ denom = (hi.clamp(0.0, L) - lo.clamp(0.0, L)).clamp(min=1e-6)
754
+ x_area = (self._prefix_integral(hi, Csum, xpad, L)
755
+ - self._prefix_integral(lo, Csum, xpad, L)) / denom
756
+ x_canon = torch.where(r < 1.0, x_area, x_lin) # anti-alias only on downsample
757
+ x_canon = x_canon * (t >= 0).to(x.dtype) # mask pre-context → 0
758
+ periods_canon = torch.round(periods.float() * r).long()
759
+ periods_canon = torch.where(
760
+ periods > 0, periods_canon.clamp(min=2), torch.zeros_like(periods),
761
+ )
762
+ h_steps = torch.arange(1, H + 1, device=x.device).view(1, H).float()
763
+ fut_pos_canon = (L - 1) + h_steps * r # (B,H) canonical
764
+ return x_canon, periods_canon, fut_pos_canon
765
+
766
+ def _seasonal_naive(
767
+ self, x: torch.Tensor, fut_pos: torch.Tensor, periods: torch.Tensor,
768
+ ) -> torch.Tensor:
769
+ """Value-space seasonal-naive baseline (zero params).
770
+
771
+ Fold the (normalized) input x by the dominant period into phase bins,
772
+ take the per-phase mean VALUE, and gather the bin matching each future
773
+ query's phase. The network then learns only the residual on top of this
774
+ baseline: a target reframe, not added model complexity. Aperiodic /
775
+ empty-bin → fall back to the context mean (persistence-of-level).
776
+
777
+ x: (B, L) fut_pos: (B, H) periods: (B, K) → (B, H)
778
+ """
779
+ B, L = x.shape
780
+ nb = self.phase_bins if self.phase_bins > 0 else 16
781
+ t = torch.arange(L, device=x.device).view(1, L).float()
782
+ p0 = periods[:, :1].clamp(min=1).float() # (B,1) dominant
783
+ pbin = torch.clamp(((t % p0) / p0 * nb).long(), max=nb - 1) # (B,L)
784
+ oh = F.one_hot(pbin, nb).to(x.dtype) # (B,L,nb)
785
+ cnt = oh.sum(dim=1) # (B,nb)
786
+ base = torch.bmm(oh.transpose(1, 2), x.unsqueeze(-1)).squeeze(-1) # (B,nb)
787
+ gmean = x.mean(dim=1, keepdim=True) # (B,1)
788
+ base = torch.where(cnt > 0, base / cnt.clamp(min=1.0), gmean.expand(B, nb))
789
+ fb = torch.clamp((fut_pos.float() % p0) / p0 * nb, max=nb - 1).long() # (B,H)
790
+ return torch.gather(base, 1, fb) # (B,H)
791
+
792
+ def _super_naive(
793
+ self, x: torch.Tensor, fut_pos: torch.Tensor, periods: torch.Tensor,
794
+ ) -> torch.Tensor:
795
+ """Multi-seasonal "super-naive" baseline (zero params).
796
+
797
+ Greedy additive decomposition over ALL significant periods: start from
798
+ the context mean, then for each significant period (strongest first)
799
+ fold the running residual into per-phase means, subtract it (deflate),
800
+ and accumulate that component's value at the future phase. Result:
801
+ baseline(L+h) = mean + Σ_k s_k[phase_k(L+h)], the genuine multi-period
802
+ seasonal-naive forecast. Non-significant periods (p=0) contribute zero.
803
+
804
+ x: (B, L) fut_pos: (B, H) periods: (B, K) → (B, H)
805
+ """
806
+ B, L = x.shape
807
+ H = fut_pos.shape[1]
808
+ nb = self.phase_bins if self.phase_bins > 0 else 16
809
+ t = torch.arange(L, device=x.device).view(1, L).float()
810
+ if self.residual_trend:
811
+ # Level term = linear trend (closed-form LS), extrapolated forward.
812
+ # baseline = trend + seasonal, the classical decomposition.
813
+ tc = t - t.mean() # centered (1,L)
814
+ xc = x - x.mean(dim=1, keepdim=True) # (B,L)
815
+ slope = (tc * xc).sum(1, keepdim=True) / (tc * tc).sum().clamp(min=1.0)
816
+ intercept = x.mean(dim=1, keepdim=True) # value at centered t=0
817
+ tmean = t.mean()
818
+ trend_ctx = intercept + slope * (t - tmean) # (B,L)
819
+ r = x - trend_ctx # de-trended residual
820
+ baseline = intercept + slope * (fut_pos.float() - tmean) # (B,H) trend extrap
821
+ else:
822
+ mean = x.mean(dim=1, keepdim=True) # (B,1)
823
+ r = x - mean # residual
824
+ baseline = mean.expand(B, H).clone() # (B,H)
825
+ for k in range(self.K):
826
+ pk = periods[:, k:k + 1].float() # (B,1), 0 if not sig
827
+ sig = (pk > 0).to(x.dtype) # (B,1)
828
+ pks = pk.clamp(min=1.0)
829
+ pbin = torch.clamp((t % pks) / pks * nb, max=nb - 1).long() # (B,L)
830
+ oh = F.one_hot(pbin, nb).to(x.dtype) # (B,L,nb)
831
+ cnt = oh.sum(dim=1).clamp(min=1.0) # (B,nb)
832
+ s_k = torch.bmm(oh.transpose(1, 2), r.unsqueeze(-1)).squeeze(-1) / cnt
833
+ s_k = s_k * sig # (B,nb), zero if not sig
834
+ r = r - torch.gather(s_k, 1, pbin) # deflate
835
+ fb = torch.clamp((fut_pos.float() % pks) / pks * nb, max=nb - 1).long()
836
+ baseline = baseline + torch.gather(s_k, 1, fb) # (B,H)
837
+ return baseline
838
+
839
+ def _gather_cc(
840
+ self, cc_prof: torch.Tensor, fut_pos: torch.Tensor,
841
+ periods: torch.Tensor,
842
+ ) -> torch.Tensor:
843
+ """Gather each future query's matching phase bin from the cross-cycle
844
+ profile (dominant period), mix → D. cc_prof: (B,nb,D)."""
845
+ B, nb, D = cc_prof.shape
846
+ H = fut_pos.shape[1]
847
+ p0 = periods[:, :1].clamp(min=1).float() # (B,1)
848
+ fb = torch.clamp((fut_pos.float() % p0) / p0 * nb, max=nb - 1).long()
849
+ gathered = torch.gather(cc_prof, 1, fb.unsqueeze(-1).expand(B, H, D))
850
+ return self.cc_mix(gathered) # (B,H,D)
851
+
852
+ # ---- forward ----------------------------------------------------------
853
+
854
+ def _local_anchor_channels(
855
+ self, x: torch.Tensor, scale_factor: torch.Tensor | float | None,
856
+ ) -> torch.Tensor:
857
+ """Two causal local-statistics channels exposing the recent level/scale to
858
+ the encoder (the gradient-connected re-anchoring signal WindowMinMax lacks):
859
+ ch1 = (x_t - m_t) / (s_t + eps) local-scale residual (a causal z-score)
860
+ ch2 = log(s_t + eps) log local scale (global normed range ~= 1)
861
+ m_t, s_t = causal boxcar mean / std over a trailing window w ~ one canonical
862
+ period round(base_seasonality / scale_factor), clamped [8, L//4], fallback 64.
863
+ Vectorized via cumsum + per-sample-window gather (O(L), no python loop).
864
+ """
865
+ B, L = x.shape
866
+ device = x.device
867
+ if scale_factor is not None:
868
+ sf = (scale_factor if torch.is_tensor(scale_factor)
869
+ else x.new_tensor(scale_factor)).reshape(-1).float()
870
+ if sf.numel() == 1:
871
+ sf = sf.expand(B)
872
+ w = (self.base_seasonality / sf.clamp(min=1e-3)).round().long()
873
+ w = w.clamp(min=8, max=max(8, L // 4))
874
+ else:
875
+ w = torch.full((B,), 64, device=device, dtype=torch.long)
876
+ # Center by the per-series mean before a FP32 cumsum. The two-pass variance
877
+ # (E[x^2]-E[x]^2) over a length-L cumsum otherwise suffers catastrophic
878
+ # cancellation on long flat/sparse regions; variance is shift-invariant, so
879
+ # centering changes nothing but keeps the cumsum magnitudes small enough that
880
+ # fp32 stays accurate there, at no extra memory.
881
+ xf = x.float()
882
+ xc = xf - xf.mean(dim=1, keepdim=True)
883
+ cs = F.pad(torch.cumsum(xc, dim=1), (1, 0)) # (B, L+1), cs[:,0]=0
884
+ cs2 = F.pad(torch.cumsum(xc * xc, dim=1), (1, 0))
885
+ t = torch.arange(L, device=device).view(1, L).expand(B, L)
886
+ lo = (t - w.view(B, 1) + 1).clamp(min=0) # trailing-window start
887
+ cnt = (t - lo + 1).float() # window length (>= 1)
888
+ sum_x = cs.gather(1, t + 1) - cs.gather(1, lo)
889
+ sum_x2 = cs2.gather(1, t + 1) - cs2.gather(1, lo)
890
+ m = sum_x / cnt # centered local mean
891
+ s = (sum_x2 / cnt - m * m).clamp(min=0.0).sqrt() # local std (shift-invariant)
892
+ eps = 1e-4 # floor vs the unit normed range -> flat regions give ch1 ~ 0, no blowup
893
+ ch1 = (xc - m) / (s + eps) # = (x - local mean)/(s+eps)
894
+ ch2 = torch.log(s + eps)
895
+ return torch.stack([ch1, ch2], dim=-1).to(x.dtype) # (B, L, 2)
896
+
897
+ def forward(
898
+ self,
899
+ x_normed: torch.Tensor,
900
+ nan_mask: torch.Tensor | None = None,
901
+ scale_factor: torch.Tensor | float | None = None,
902
+ horizon: int | None = None,
903
+ ) -> torch.Tensor:
904
+ # observed-mask (1=observed, 0=missing) for the missing-value channel.
905
+ # res_adaptive is rejected with missing_channel (see __init__), so this
906
+ # mask stays aligned with x throughout.
907
+ obs_mask = None
908
+ if self.missing_channel and nan_mask is not None:
909
+ obs_mask = nan_mask[..., 0] if nan_mask.dim() == 3 else nan_mask # (B,L)
910
+
911
+ if x_normed.dim() == 3 and x_normed.shape[-1] > 1:
912
+ x = x_normed[..., 0]
913
+ elif x_normed.dim() == 3:
914
+ x = x_normed.squeeze(-1)
915
+ else:
916
+ x = x_normed # (B, L)
917
+
918
+ x = torch.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
919
+ B, L = x.shape
920
+ assert L == self.L, f"context length mismatch: got {L}, expected {self.L}"
921
+ H = self.p_out if horizon is None else int(horizon)
922
+ device = x.device
923
+
924
+ # Period detection in fp32 (no grad).
925
+ periods, n_valid, scores = self._detect_periods(x.float()) # (B,K),(B,),(B,K)
926
+
927
+ # Resolution adaptation: warp context to a canonical cycle-resolution so
928
+ # the fixed dilations span consistent cycle-fractions across rates.
929
+ if self.res_adaptive:
930
+ x, periods, fut_pos = self._resolution_adapt(x, periods, H)
931
+ ctx_pos = torch.arange(L, device=device).view(1, L).expand(B, L)
932
+ else:
933
+ ctx_pos = torch.arange(L, device=device).view(1, L).expand(B, L)
934
+ fut_pos = torch.arange(L, L + H, device=device).view(1, H).expand(B, H)
935
+ with torch.amp.autocast(
936
+ device_type=device.type if x.is_cuda else "cpu", enabled=False,
937
+ ):
938
+ ctx_pe = _positional_encoding(
939
+ ctx_pos, periods, L, n_harmonics=self.n_harmonics,
940
+ )
941
+ fut_pe = _positional_encoding(
942
+ fut_pos, periods, L, n_harmonics=self.n_harmonics,
943
+ )
944
+ ctx_pe = ctx_pe.to(x.dtype)
945
+ fut_pe = fut_pe.to(x.dtype)
946
+
947
+ # Per-period reliability weight (period_trust; off in the released config).
948
+ # Down-weight unreliable/spurious periods continuously. Applied to the
949
+ # phase-encoding channels here, and to the phase-binning gather + gate below.
950
+ ptw = None
951
+ if self.pt is not None:
952
+ ptw = self._period_trust_weights(periods, scores).to(x.dtype) # (B,K)
953
+ # phase channels are the first n_phase cols, laid out per period as
954
+ # n_harmonics*2 consecutive channels → repeat each w_k that many times.
955
+ rep = self.n_harmonics * 2
956
+ chan_w = ptw.repeat_interleave(rep, dim=1).view(B, 1, -1) # (B,1,n_phase)
957
+ np_ = chan_w.shape[-1]
958
+ ctx_pe = torch.cat([ctx_pe[..., :np_] * chan_w, ctx_pe[..., np_:]], dim=-1)
959
+ fut_pe = torch.cat([fut_pe[..., :np_] * chan_w, fut_pe[..., np_:]], dim=-1)
960
+
961
+ # Embed context.
962
+ if self.decompose_kernel > 0:
963
+ # moving-average series decomposition: moving-average trend +
964
+ # seasonal residual, fed as two channels.
965
+ k = self.decompose_kernel
966
+ pad = k // 2
967
+ xp = F.pad(x.unsqueeze(1), (pad, pad), mode="replicate") # (B,1,L+2pad)
968
+ trend = F.avg_pool1d(xp, kernel_size=k, stride=1)[..., :L].squeeze(1)
969
+ seasonal = x - trend
970
+ value_ch = torch.stack([trend, seasonal], dim=-1) # (B,L,2)
971
+ else:
972
+ value_ch = x.unsqueeze(-1) # (B,L,1)
973
+ if self.missing_channel and obs_mask is not None:
974
+ value_ch = torch.cat(
975
+ [value_ch, obs_mask.unsqueeze(-1).to(value_ch.dtype)], dim=-1
976
+ ) # (B,L,nv+1)
977
+ if self.local_anchor:
978
+ value_ch = torch.cat(
979
+ [value_ch, self._local_anchor_channels(x, scale_factor)], dim=-1
980
+ ) # (B,L,nv+2)
981
+ ctx_in = torch.cat([value_ch, ctx_pe], dim=-1) # (B,L,nv+n_pe)
982
+ h = self.in_proj(ctx_in) # (B, L, D)
983
+
984
+ # Dilated-conv encoder.
985
+ for block in self.encoder:
986
+ h = block(h)
987
+
988
+ # Per-horizon context feature: static pooled summary, broadcast to all H.
989
+ summary = self._pool(h) # (B, pool_dim)
990
+ ctx_feat = summary.unsqueeze(1).expand(B, H, -1) # (B, H, pool_dim)
991
+
992
+ # Decoder: per-horizon query from PE(L+h) + context feature
993
+ # [+ phase profile] [+ recency profile] [+ cross-cycle feat].
994
+ q_parts = [fut_pe, ctx_feat]
995
+
996
+ phase_feat = None
997
+ if self.phase_mix is not None:
998
+ prof = self._phase_profile(h, periods) # (B,K,nb,S·D)
999
+ phase_feat = self._gather_phase(prof, fut_pos, periods, weight=ptw) # (B,H,D)
1000
+
1001
+ rec_feat = None
1002
+ if self.recency_mix is not None:
1003
+ rec_feat = self._recency_feat(h).unsqueeze(1).expand(B, H, self.D)
1004
+
1005
+ if self.sig_gate and phase_feat is not None and rec_feat is not None:
1006
+ # Blend by periodicity strength: many significant periods → trust
1007
+ # the phase profile; few/none → lean on the recency profile. With
1008
+ # period_trust, use the soft Σw instead of the integer n_valid.
1009
+ strength = ptw.sum(dim=1) if ptw is not None else n_valid.float()
1010
+ g = (strength / float(self.K)).clamp(0.0, 1.0).view(B, 1, 1)
1011
+ q_parts.append(g * phase_feat + (1.0 - g) * rec_feat)
1012
+ else:
1013
+ if phase_feat is not None:
1014
+ q_parts.append(phase_feat)
1015
+ if rec_feat is not None:
1016
+ q_parts.append(rec_feat)
1017
+
1018
+ if self.cross_cycle:
1019
+ cc_prof = self._cross_cycle_profile(h, periods) # (B,nb,D)
1020
+ q_parts.append(self._gather_cc(cc_prof, fut_pos, periods)) # (B,H,D)
1021
+
1022
+ q_in = torch.cat(q_parts, dim=-1)
1023
+ q = self.query_proj(q_in) # (B, H, D)
1024
+
1025
+ if self.future_conv:
1026
+ # Horizon-axis evolving states from a causal conv over context-tail
1027
+ # + seasonal-naive fill, injected additively (fc_out zero-init =>
1028
+ # exact baseline at init). Gives the static-summary decoder the
1029
+ # per-horizon dynamics it lacks.
1030
+ fill = self._seasonal_naive(x, fut_pos, periods) # (B, H)
1031
+ fut_states = self._future_conv_states(h, fut_pe, fill) # (B, H, D)
1032
+ q = q + self.fc_out(fut_states)
1033
+
1034
+ for ffn, norm in zip(self.decoder_ffns, self.decoder_norms):
1035
+ q = _norm_fp32(norm, q + ffn(q))
1036
+
1037
+ if self.horizon_conv is not None:
1038
+ # Causal conv over the horizon axis: pad (k-1) on the left so step
1039
+ # h sees only h, h-1, …, h-(k-1): no future leakage, any H.
1040
+ qt = F.pad(q.transpose(1, 2), (self.horizon_kernel - 1, 0))
1041
+ hc = self.horizon_conv(qt).transpose(1, 2) # (B,H,D)
1042
+ q = _norm_fp32(self.horizon_norm, q + hc)
1043
+
1044
+ if self.hr_z is not None:
1045
+ # gated-recurrence decode-state: gated diagonal recurrence over the H axis.
1046
+ # Scans the query FEATURES only (no value feedback). Sequential
1047
+ # scan: the horizon is short, so this is cheap and stable.
1048
+ z = torch.sigmoid(self.hr_z(q)) # (B,H,D) update gate
1049
+ c = torch.tanh(self.hr_c(q)) # (B,H,D) candidate
1050
+ s = torch.zeros(B, self.D, dtype=q.dtype, device=q.device)
1051
+ states = []
1052
+ for t in range(q.shape[1]):
1053
+ s = (1.0 - z[:, t]) * s + z[:, t] * c[:, t]
1054
+ states.append(s)
1055
+ hstate = torch.stack(states, dim=1) # (B,H,D)
1056
+ q = _norm_fp32(self.hr_norm, q + self.hr_o(hstate)) # hr_o zero-init
1057
+
1058
+ y = self.out_proj(q) # (B, H, Q)
1059
+
1060
+
1061
+ if self.residual_naive:
1062
+ # Learn the residual over a (multi-)seasonal-naive baseline.
1063
+ if self.residual_multi:
1064
+ baseline = self._super_naive(x, fut_pos, periods) # (B, H)
1065
+ else:
1066
+ baseline = self._seasonal_naive(x, fut_pos, periods) # (B, H)
1067
+ y = y + baseline.unsqueeze(-1)
1068
+
1069
+ return y
1070
+
tinycast/checkpoint.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Weight loading for TinyCast.
2
+
3
+ The released weights are a ``model.safetensors`` + a ``config.json`` (the
4
+ architecture config). ``load_checkpoint`` (aliased ``load_model``) handles the weight-tied
5
+ FFN sharing: the file stores each unique parameter storage once (the true
6
+ 146,505-parameter footprint, ~0.6 MB) and restores the sharing on load.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Optional, Tuple
14
+
15
+ from .config import TinyCastConfig
16
+ from .model import TinyCastForPrediction
17
+
18
+
19
+ def load_checkpoint(
20
+ weights_path: str,
21
+ config_path: Optional[str] = None,
22
+ ) -> Tuple[TinyCastForPrediction, TinyCastConfig]:
23
+ """Load TinyCastForPrediction from ``model.safetensors`` + ``config.json``.
24
+
25
+ ``config_path`` defaults to a sibling ``config.json`` next to the weights.
26
+ """
27
+ from safetensors.torch import load_model
28
+
29
+ st = Path(weights_path)
30
+ if config_path is None:
31
+ config_path = st.parent / "config.json"
32
+ with open(config_path) as f:
33
+ cfg_dict = json.load(f)
34
+ cfg = TinyCastConfig(**{
35
+ k: v for k, v in cfg_dict.items()
36
+ if k in TinyCastConfig.__dataclass_fields__
37
+ })
38
+ model = TinyCastForPrediction(cfg)
39
+ load_model(model, str(st))
40
+ model.eval()
41
+ return model, cfg
42
+
43
+
44
+ # Explicit alias used by the notebook / README examples.
45
+ load_model = load_checkpoint
tinycast/config.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyCast model configuration.
2
+
3
+ Only the fields that shape the deployed model's construction / forward are
4
+ kept. Field defaults are the released model's values, so ``TinyCastConfig()`` alone
5
+ reconstructs the deployed architecture; loading from ``config.json`` overrides
6
+ them.
7
+ """
8
+
9
+ from dataclasses import asdict, dataclass, field
10
+ from typing import List
11
+
12
+
13
+ @dataclass
14
+ class TinyCastConfig:
15
+ """Configuration for the deployed TinyCast (dilated-conv) model."""
16
+
17
+ # --- input / output geometry -------------------------------------------
18
+ quantiles: List[float] = field(
19
+ default_factory=lambda: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
20
+ )
21
+ seq_len: int = 2048 # L: encoder context window
22
+ output_token_len: int = 48 # p: single-shot / AR-chunk horizon
23
+
24
+ # --- period detector (shared structural prior) -------------------------
25
+ top_k_periods: int = 4
26
+ significance_alpha: float = 0.05
27
+ n_harmonics: int = 1
28
+
29
+ # --- dilated-conv backbone ---------------------------------------------
30
+ conv_dim: int = 64
31
+ n_layers: int = 10
32
+ kernel_size: int = 3
33
+ ffn_mult: float = 1.0
34
+ pool_kind: str = "mean_last"
35
+ causal: bool = True
36
+ phase_bins: int = 16
37
+ decoder_depth: int = 1
38
+ separable_conv: bool = True
39
+ share_ffn: bool = True
40
+ future_conv: bool = True
41
+ future_conv_layers: int = 6
42
+ future_conv_seed: int = 128
43
+
44
+ @property
45
+ def num_quantiles(self) -> int:
46
+ return len(self.quantiles)
47
+
48
+ def to_dict(self) -> dict:
49
+ return asdict(self)
tinycast/corpus.py ADDED
@@ -0,0 +1,661 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The canonical synthetic corpus: recipe, shard builder and verifier.
2
+
3
+ TinyCast's pretraining mix is GIFT-Eval-Pretrain and Chronos KernelSynth, taken
4
+ from their publishers, plus four synthetic shards. This module is the record of
5
+ how those four shards were made: four shards of 62,500 series at length 4,096,
6
+ families mixed 70/15/15 over the generators in :mod:`tinycast.synth`.
7
+
8
+ Everything that changes the generated bytes is a default in
9
+ :data:`CANONICAL_RECIPE` rather than an argument, so ``build_shard()`` with no
10
+ arguments reproduces published shard 0 and there is nothing to tune. Treat the
11
+ values in that dictionary as versioned data: changing one produces a different
12
+ corpus, not a differently configured build of the same one.
13
+
14
+ WHAT REPRODUCIBILITY MEANS HERE. The shards are published as files, so the
15
+ corpus can be downloaded and read without regenerating anything. Regeneration
16
+ reproduces the published float16 payload exactly on the stack it was generated
17
+ on: 0 of 327,680 values differ across 4 shards by 20 rows on an RTX 3090 with
18
+ torch 2.10.0+cu128 and driver 580.159.03. Reproduction on other GPUs, CUDA
19
+ versions or torch builds is untested, and the GP family runs through cuSOLVER,
20
+ so it is not safe to assume it carries over. :func:`verify_shard` is how you
21
+ find out for a given stack: it regenerates a prefix and reports how many
22
+ float16 values differ.
23
+
24
+ Verify against ``series.f16``, which is the payload, and not against
25
+ ``series_mean.f32``. That sidecar is a float32 sum with heavy cancellation on
26
+ near-zero-mean rows, so its last bit moves with the numpy build while the f16
27
+ payload it feeds is unaffected. ``series_stdev.f32`` does not cancel.
28
+
29
+ CUDA IS REQUIRED, and is enforced rather than detected. The GP family samples
30
+ by dense Cholesky, and off CUDA that factorization and the normal draws feeding
31
+ it come from different generators, so the same seed gives different series: the
32
+ CPU path moves up to 182 of the 4,096 float32 values in a row. The published
33
+ shards came from the CUDA branch. A build with no GPU therefore fails here
34
+ instead of falling back and quietly producing a different corpus.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import io
39
+ import json
40
+ import math
41
+ import os
42
+ import time
43
+ from pathlib import Path
44
+ from typing import Iterator, Optional, Tuple, Union
45
+
46
+ import numpy as np
47
+
48
+ from .synth import generate_gp, generate_spikes, generate_tsi
49
+
50
+ _F16_BYTES = 2
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # The canonical recipe.
54
+ #
55
+ # Every entry is a default. A reviewer runs the build with no flags and gets the
56
+ # published corpus.
57
+ # ---------------------------------------------------------------------------
58
+ CANONICAL_RECIPE = {
59
+ "version": "tinycast-synth-4096/v1",
60
+ "n_shards": 4,
61
+ "series_per_shard": 62_500,
62
+ "length": 4096,
63
+ "mix": (0.70, 0.15, 0.15), # gp, spikes, tsi
64
+ "chunk": 2048, # series generated per call
65
+ "device": "cuda",
66
+ # Part of the data, not a memory knob. cuSOLVER factorizes a batch of one on
67
+ # a different code path from a real batch, and these covariances are close
68
+ # to singular (periodic and linear kernels with only 1e-6 jitter), so the
69
+ # factors differ by around 1e-8 and the published rows do not reproduce at
70
+ # batch=1. Batches of 2, 4, 8 and 32 all agree with each other and with the
71
+ # published data. Lowering this to fit a smaller GPU changes the corpus.
72
+ "gp_batch": 32,
73
+ "scale_factor_range": (0.05, 6.0), # log-uniform, per series
74
+ "verified_stack": "RTX 3090 / torch 2.10.0+cu128 / driver 580.159.03",
75
+ # Per-shard seed and family salts. The seed is 50,000 x shard, confirmed by
76
+ # reproducing all 62,500 scale factors of each shard bit for bit.
77
+ #
78
+ # The salts cannot be computed, which is why they are carried as data. They
79
+ # were a per-family hash of the family name taken under CPython's randomized
80
+ # string hashing, so every build process drew its own set and no two shards
81
+ # share a triple. Replacing them with a stable hash would make future builds
82
+ # reproducible while making the published shards unreproducible. They were
83
+ # recovered by brute force against the published bytes (997 candidates per
84
+ # family) and frozen here.
85
+ "shards": {
86
+ 0: {"seed": 0, "salt": {"gp": 724, "spikes": 351, "tsi": 900}},
87
+ 1: {"seed": 50_000, "salt": {"gp": 23, "spikes": 431, "tsi": 840}},
88
+ 2: {"seed": 100_000, "salt": {"gp": 762, "spikes": 505, "tsi": 641}},
89
+ 3: {"seed": 150_000, "salt": {"gp": 691, "spikes": 491, "tsi": 745}},
90
+ },
91
+ }
92
+
93
+ FAMILIES = ("gp", "spikes", "tsi")
94
+
95
+ # Files a shard directory holds. The first six are the payload and its indices;
96
+ # the last two attribute each row to a family.
97
+ CACHE_FILES = (
98
+ "series.f16", "offsets.npy", "lengths.npy", "scale_factors.f32",
99
+ "series_mean.f32", "series_stdev.f32",
100
+ )
101
+ FAMILY_FILES = ("dataset_id.u16", "dataset_names.json")
102
+
103
+ DEFAULT_OUT_DIR = "synth4096_{shard}"
104
+
105
+ # Rows compared by verify_shard. Twenty is what the published fidelity statement
106
+ # was measured over, and at 4,096 samples a row it is already 81,920 values.
107
+ DEFAULT_VERIFY_ROWS = 20
108
+ DEFAULT_VERIFY_SCALE_FACTORS = 4096
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Recipe lookups
113
+ # ---------------------------------------------------------------------------
114
+
115
+ def _shard_record(shard: int) -> dict:
116
+ try:
117
+ return CANONICAL_RECIPE["shards"][int(shard)]
118
+ except KeyError:
119
+ raise KeyError(
120
+ f"shard {shard} is not in the published corpus, which covers shards "
121
+ f"{sorted(CANONICAL_RECIPE['shards'])}."
122
+ ) from None
123
+
124
+
125
+ def shard_seed(shard: int) -> int:
126
+ """Base seed for a shard. The scale-factor stream is drawn from it."""
127
+ return int(_shard_record(shard)["seed"])
128
+
129
+
130
+ def family_salt(family: str, shard: int) -> int:
131
+ """Per-shard, per-family seed offset, looked up rather than computed.
132
+
133
+ See :data:`CANONICAL_RECIPE` for why these are data. A missing entry means
134
+ the salt has not been recovered for that shard, and generation refuses
135
+ rather than substituting a computed one: a computed salt would produce a
136
+ different corpus while appearing to succeed.
137
+ """
138
+ salt = _shard_record(shard)["salt"].get(family)
139
+ if salt is None:
140
+ raise RuntimeError(
141
+ f"the salt for family {family!r} on shard {shard} is not recorded, so "
142
+ f"that shard cannot be reproduced. It is a single integer in [0, 997) "
143
+ f"and is recoverable by brute force against the published bytes. "
144
+ f"Refusing to substitute a computed value."
145
+ )
146
+ return int(salt)
147
+
148
+
149
+ def plan_counts(
150
+ n_series: Optional[int] = None, mix: Optional[tuple] = None,
151
+ ) -> Tuple[int, int, int]:
152
+ """Series per family, in emission order (gp, spikes, tsi).
153
+
154
+ This is also how a published shard's family boundaries are read. The
155
+ per-row family sidecars of the published shards were overwritten by a
156
+ post-processing step that collapsed them to a single label with all ids
157
+ zero, so the attribution cannot be recovered from the files themselves; the
158
+ payload, indices and scale factors were not touched. A shard that ends up
159
+ one row short lost a GP row to a covariance that failed to factorize (see
160
+ :func:`iter_shard_series`), which shifts the boundaries down by that many.
161
+ """
162
+ n_series = CANONICAL_RECIPE["series_per_shard"] if n_series is None else int(n_series)
163
+ mix = CANONICAL_RECIPE["mix"] if mix is None else tuple(mix)
164
+ n_gp = int(round(n_series * mix[0]))
165
+ n_spikes = int(round(n_series * mix[1]))
166
+ return n_gp, n_spikes, n_series - n_gp - n_spikes
167
+
168
+
169
+ def _sample_scale_factor(rng: np.random.Generator) -> float:
170
+ """Log-uniform scale factor, one draw per emitted series.
171
+
172
+ Synthetic series carry no sampling frequency, so each gets a scale factor
173
+ drawn from the range that the real corpora's frequency-derived factors span.
174
+ """
175
+ low, high = CANONICAL_RECIPE["scale_factor_range"]
176
+ return float(np.exp(rng.uniform(math.log(low), math.log(high))))
177
+
178
+
179
+ def _require_cuda(device: Optional[str]) -> str:
180
+ """Resolve and check the device, refusing anything but the canonical one."""
181
+ canonical = CANONICAL_RECIPE["device"]
182
+ device = canonical if device is None else str(device)
183
+ if device != canonical:
184
+ raise ValueError(
185
+ f"device={device!r} is not the device this corpus was generated on "
186
+ f"({canonical!r}). The GP family draws from a different generator on "
187
+ f"each branch, so the same seed gives different series and overriding "
188
+ f"the device does not reproduce the published shards."
189
+ )
190
+ try:
191
+ import torch
192
+ except ImportError as exc:
193
+ raise RuntimeError(
194
+ "torch is required: this corpus was generated on the CUDA branch."
195
+ ) from exc
196
+ if not torch.cuda.is_available():
197
+ raise RuntimeError(
198
+ "no CUDA device is available. This corpus was generated on the CUDA "
199
+ "branch, and a CPU run would produce different data rather than "
200
+ "reproducing it, so this fails instead of falling back."
201
+ )
202
+ return device
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Generation
207
+ # ---------------------------------------------------------------------------
208
+
209
+ def iter_shard_series(
210
+ shard: int = 0,
211
+ *,
212
+ max_series: Optional[int] = None,
213
+ device: Optional[str] = None,
214
+ ) -> Iterator[Tuple[np.ndarray, float, str]]:
215
+ """Yield ``(series, scale_factor, family_label)`` for one published shard.
216
+
217
+ Families are emitted in order, GP first, each generated in chunks of
218
+ ``CANONICAL_RECIPE["chunk"]`` series under a seed of
219
+ ``shard_seed + 1000 * chunk_index + family_salt``. GP rows whose covariance
220
+ failed to factorize come back non-finite and are dropped here, which is the
221
+ only way a shard ends up holding fewer rows than planned.
222
+
223
+ ``max_series`` truncates the emitted stream and leaves the plan alone, so
224
+ what it yields is the published prefix. Generation still runs in canonical
225
+ chunks, so the first chunk is produced in full however early the truncation
226
+ falls; :func:`verify_shard` is the cheap way to check a stack.
227
+ ``device`` exists only so that asking for a non-canonical one fails loudly.
228
+
229
+ Validation runs before the first row is generated. The refusals above are
230
+ worth nothing if they wait for the caller to start iterating, which is what
231
+ happens when a generator function does its own argument checking.
232
+ """
233
+ device = _require_cuda(device)
234
+ seed = shard_seed(shard)
235
+ salts = {f: family_salt(f, shard) for f in FAMILIES}
236
+ if max_series is not None and int(max_series) < 0:
237
+ raise ValueError("max_series must be non-negative")
238
+ return _iter_shard_series(shard, seed, salts, max_series, device)
239
+
240
+
241
+ def _iter_shard_series(
242
+ shard: int,
243
+ seed: int,
244
+ salts: dict,
245
+ max_series: Optional[int],
246
+ device: str,
247
+ ) -> Iterator[Tuple[np.ndarray, float, str]]:
248
+ """Generator body for :func:`iter_shard_series`, validation already done."""
249
+ length = CANONICAL_RECIPE["length"]
250
+ chunk = CANONICAL_RECIPE["chunk"]
251
+ gp_batch = CANONICAL_RECIPE["gp_batch"]
252
+ counts = dict(zip(FAMILIES, plan_counts()))
253
+ generators = {
254
+ "gp": lambda n, s: generate_gp(n, length, seed=s, device=device,
255
+ batch=gp_batch),
256
+ "spikes": lambda n, s: generate_spikes(n, length, seed=s),
257
+ "tsi": lambda n, s: generate_tsi(n, length, seed=s),
258
+ }
259
+
260
+ rng = np.random.default_rng(seed)
261
+ emitted_total = 0
262
+ dropped = 0
263
+ for family in FAMILIES:
264
+ total = counts[family]
265
+ label = f"{family}_{length}"
266
+ generated = 0
267
+ chunk_index = 0
268
+ while generated < total:
269
+ cur = min(chunk, total - generated)
270
+ block = generators[family](cur, seed + 1000 * chunk_index + salts[family])
271
+ chunk_index += 1
272
+ generated += cur
273
+ for row in block:
274
+ if not np.isfinite(row).all():
275
+ dropped += 1
276
+ continue
277
+ yield row, _sample_scale_factor(rng), label
278
+ emitted_total += 1
279
+ if max_series is not None and emitted_total >= int(max_series):
280
+ return
281
+ print(f" {family}: {total} generated, {dropped} dropped so far", flush=True)
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Writing a shard
286
+ # ---------------------------------------------------------------------------
287
+
288
+ def normalize_for_f16(series: np.ndarray) -> Tuple[np.ndarray, float, float]:
289
+ """Per-series z-normalization ahead of the float16 cast.
290
+
291
+ float16 stores the payload at half the size of float32 but caps magnitudes
292
+ at 65,504, which raw series exceed. Storing a z-normalized payload with the
293
+ mean and standard deviation alongside it keeps any realistic magnitude in
294
+ range, and the read path is ``x = payload * stdev + mean`` with no
295
+ branching: a constant series is stored with ``stdev = 1`` so its payload is
296
+ identically zero and denormalizes exactly.
297
+ """
298
+ series = np.asarray(series, dtype=np.float32)
299
+ clean = np.where(np.isfinite(series), series, np.nan)
300
+ finite = clean[~np.isnan(clean)]
301
+ if finite.size == 0:
302
+ return clean, 0.0, 1.0
303
+ mu = float(finite.mean())
304
+ sd = float(finite.std())
305
+ sd_safe = sd if sd > 0.0 else 1.0
306
+ return ((clean - mu) / sd_safe).astype(np.float32, copy=False), mu, sd_safe
307
+
308
+
309
+ def build_shard(
310
+ out_dir: Union[str, Path, None] = None,
311
+ shard: int = 0,
312
+ *,
313
+ max_series: Optional[int] = None,
314
+ overwrite: bool = False,
315
+ ) -> int:
316
+ """Generate one published shard and write it as a cache directory.
317
+
318
+ Called with no arguments this reproduces shard 0 into ``synth4096_0``. The
319
+ written files are the six payload and index files, the two family sidecars,
320
+ and ``recipe.json``, which records the recipe and the stack that produced
321
+ the directory. Returns the number of series written.
322
+
323
+ The payload is streamed to a temporary file and renamed on success, so an
324
+ interrupted build leaves no directory that looks complete.
325
+ """
326
+ _require_cuda(None)
327
+ for family in FAMILIES: # refuse an unreproducible shard first
328
+ family_salt(family, shard)
329
+ out_dir = Path(DEFAULT_OUT_DIR.format(shard=shard) if out_dir is None else out_dir)
330
+ if all((out_dir / f).exists() for f in CACHE_FILES) and not overwrite:
331
+ raise RuntimeError(
332
+ f"{out_dir} already holds a shard. Pass overwrite=True to rebuild it, "
333
+ f"or build into a new directory."
334
+ )
335
+ out_dir.mkdir(parents=True, exist_ok=True)
336
+
337
+ length = CANONICAL_RECIPE["length"]
338
+ n_gp, n_spikes, n_tsi = plan_counts()
339
+ print(f"building shard {shard} into {out_dir}: gp={n_gp} spikes={n_spikes} "
340
+ f"tsi={n_tsi} length={length} device={CANONICAL_RECIPE['device']}",
341
+ flush=True)
342
+
343
+ offsets: list[int] = []
344
+ lengths: list[int] = []
345
+ scale_factors: list[float] = []
346
+ means: list[float] = []
347
+ stdevs: list[float] = []
348
+ labels: list[str] = []
349
+ label_ids: dict[str, int] = {}
350
+ ids: list[int] = []
351
+
352
+ tmp_payload = out_dir / "series.f16.tmp"
353
+ cursor = 0
354
+ n_written = 0
355
+ t0 = time.time()
356
+ try:
357
+ with open(tmp_payload, "wb") as fp:
358
+ for row, sf, label in iter_shard_series(shard, max_series=max_series):
359
+ normalized, mu, sd = normalize_for_f16(row)
360
+ payload = normalized.astype(np.float16, copy=False)
361
+ fp.write(payload.tobytes())
362
+ offsets.append(cursor)
363
+ lengths.append(int(payload.size))
364
+ scale_factors.append(float(sf))
365
+ means.append(mu)
366
+ stdevs.append(sd)
367
+ if label not in label_ids:
368
+ label_ids[label] = len(labels)
369
+ labels.append(label)
370
+ ids.append(label_ids[label])
371
+ cursor += int(payload.size) * _F16_BYTES
372
+ n_written += 1
373
+ if n_written % 10_000 == 0:
374
+ rate = n_written / max(time.time() - t0, 1e-6)
375
+ print(f" {n_written:,} written, {rate:.0f} series/s, "
376
+ f"{cursor / 1e9:.2f} GB", flush=True)
377
+ fp.flush()
378
+ os.fsync(fp.fileno())
379
+ except BaseException:
380
+ tmp_payload.unlink(missing_ok=True)
381
+ raise
382
+
383
+ if n_written == 0:
384
+ tmp_payload.unlink(missing_ok=True)
385
+ raise RuntimeError("no series were generated, so nothing was written")
386
+
387
+ os.replace(tmp_payload, out_dir / "series.f16")
388
+ np.save(out_dir / "offsets.npy", np.asarray(offsets, dtype=np.int64))
389
+ np.save(out_dir / "lengths.npy", np.asarray(lengths, dtype=np.int32))
390
+ _write_bytes(out_dir / "scale_factors.f32",
391
+ np.asarray(scale_factors, dtype=np.float32).tobytes())
392
+ _write_bytes(out_dir / "series_mean.f32",
393
+ np.asarray(means, dtype=np.float32).tobytes())
394
+ _write_bytes(out_dir / "series_stdev.f32",
395
+ np.asarray(stdevs, dtype=np.float32).tobytes())
396
+ _write_bytes(out_dir / "dataset_id.u16",
397
+ np.asarray(ids, dtype=np.uint16).tobytes())
398
+ _write_bytes(out_dir / "dataset_names.json",
399
+ json.dumps({"version": 2, "datasets": labels}).encode("utf-8"))
400
+ _write_bytes(out_dir / "recipe.json",
401
+ json.dumps(_provenance(shard, n_written), indent=2).encode("utf-8"))
402
+
403
+ print(f"wrote {n_written:,} series in {time.time() - t0:.0f}s "
404
+ f"({cursor / 1e9:.2f} GB) to {out_dir}", flush=True)
405
+ return n_written
406
+
407
+
408
+ def _write_bytes(path: Path, data: bytes) -> None:
409
+ tmp = path.with_suffix(path.suffix + ".tmp")
410
+ with open(tmp, "wb") as fp:
411
+ fp.write(data)
412
+ fp.flush()
413
+ os.fsync(fp.fileno())
414
+ os.replace(tmp, path)
415
+
416
+
417
+ def _provenance(shard: int, n_written: int) -> dict:
418
+ record = {
419
+ "version": CANONICAL_RECIPE["version"],
420
+ "shard": int(shard),
421
+ "seed": shard_seed(shard),
422
+ "salt": dict(_shard_record(shard)["salt"]),
423
+ "series_written": int(n_written),
424
+ "series_planned": CANONICAL_RECIPE["series_per_shard"],
425
+ "length": CANONICAL_RECIPE["length"],
426
+ "mix": list(CANONICAL_RECIPE["mix"]),
427
+ "chunk": CANONICAL_RECIPE["chunk"],
428
+ "gp_batch": CANONICAL_RECIPE["gp_batch"],
429
+ "device": CANONICAL_RECIPE["device"],
430
+ "verified_stack": CANONICAL_RECIPE["verified_stack"],
431
+ }
432
+ try:
433
+ import torch
434
+ record["torch"] = torch.__version__
435
+ if torch.cuda.is_available():
436
+ record["gpu"] = torch.cuda.get_device_name(0)
437
+ except Exception: # provenance is best effort
438
+ pass
439
+ record["numpy"] = np.__version__
440
+ return record
441
+
442
+
443
+ # ---------------------------------------------------------------------------
444
+ # Verifying a shard against the published one
445
+ # ---------------------------------------------------------------------------
446
+
447
+ def _resolve_source(path_or_url: Union[str, Path]) -> Union[Path, str]:
448
+ """A local directory, or an https prefix ending in a slash.
449
+
450
+ ``hf://<owner>/<repo>[/<subdir>]`` is rewritten to the dataset's resolve
451
+ URL, which serves byte ranges. That matters: checking twenty rows pulls
452
+ about 160 KB rather than the half gigabyte a shard payload occupies.
453
+ """
454
+ text = str(path_or_url)
455
+ if text.startswith("hf://"):
456
+ parts = [p for p in text[len("hf://"):].split("/") if p]
457
+ if len(parts) < 2:
458
+ raise ValueError("hf:// source must be hf://<owner>/<repo>[/<subdir>]")
459
+ repo = "/".join(parts[:2])
460
+ subdir = "/".join(parts[2:])
461
+ url = f"https://huggingface.co/datasets/{repo}/resolve/main/"
462
+ return url + (subdir + "/" if subdir else "")
463
+ if text.startswith("http://") or text.startswith("https://"):
464
+ return text if text.endswith("/") else text + "/"
465
+ return Path(text)
466
+
467
+
468
+ def _read_file(source: Union[Path, str], name: str) -> bytes:
469
+ if isinstance(source, Path):
470
+ return (source / name).read_bytes()
471
+ import urllib.request
472
+ with urllib.request.urlopen(source + name) as response:
473
+ return response.read()
474
+
475
+
476
+ def _read_range(source: Union[Path, str], name: str, start: int, count: int) -> bytes:
477
+ if isinstance(source, Path):
478
+ with open(source / name, "rb") as fp:
479
+ fp.seek(start)
480
+ return fp.read(count)
481
+ import urllib.request
482
+ request = urllib.request.Request(
483
+ source + name, headers={"Range": f"bytes={start}-{start + count - 1}"})
484
+ with urllib.request.urlopen(request) as response:
485
+ data = response.read()
486
+ if len(data) != count: # server ignored the range header
487
+ data = data[start:start + count]
488
+ return data
489
+
490
+
491
+ def verify_shard(
492
+ path_or_url: Union[str, Path],
493
+ shard: int = 0,
494
+ *,
495
+ n_rows: int = DEFAULT_VERIFY_ROWS,
496
+ n_scale_factors: int = DEFAULT_VERIFY_SCALE_FACTORS,
497
+ verbose: bool = True,
498
+ ) -> dict:
499
+ """Regenerate a prefix of a published shard and report how far it agrees.
500
+
501
+ ``path_or_url`` is a shard directory, an https prefix, or
502
+ ``hf://<owner>/<repo>[/<subdir>]``. The comparison is against
503
+ ``series.f16``, the payload, cast the same way the builder casts it. The
504
+ returned report gives ``values_differing`` out of ``values_compared``, which
505
+ is the number this module's fidelity claim is stated in.
506
+
507
+ ``n_rows`` rows are regenerated from the GP family, which is what a shard
508
+ opens with, rounded up to a whole number of GP batches so the batch shape
509
+ matches the published run. It must not exceed one chunk. Set it to 0 to skip
510
+ the payload check, which is the only part that needs a GPU; the scale-factor
511
+ check runs anywhere and still exercises the seed scheme.
512
+
513
+ A shard that holds fewer rows than planned lost GP rows to covariances that
514
+ failed to factorize. Those rows are dropped here too, so the compared prefix
515
+ stays aligned, but the scale-factor stream shifts by one draw per drop, and
516
+ a mismatch after the drop index is expected rather than a fidelity failure.
517
+ """
518
+ source = _resolve_source(path_or_url)
519
+ length = CANONICAL_RECIPE["length"]
520
+ chunk = CANONICAL_RECIPE["chunk"]
521
+ gp_batch = CANONICAL_RECIPE["gp_batch"]
522
+ n_rows = int(n_rows)
523
+ if n_rows > chunk:
524
+ raise ValueError(
525
+ f"n_rows={n_rows} exceeds one chunk ({chunk}); rows beyond the first "
526
+ f"chunk are generated under a different seed and would need the whole "
527
+ f"shard regenerated."
528
+ )
529
+
530
+ published_lengths = np.load(io.BytesIO(_read_file(source, "lengths.npy")))
531
+ published_offsets = np.load(io.BytesIO(_read_file(source, "offsets.npy")))
532
+ published_rows = int(published_lengths.size)
533
+ planned_rows = CANONICAL_RECIPE["series_per_shard"]
534
+
535
+ report = {
536
+ "source": str(path_or_url),
537
+ "shard": int(shard),
538
+ "published_rows": published_rows,
539
+ "planned_rows": planned_rows,
540
+ "rows_short_of_plan": max(planned_rows - published_rows, 0),
541
+ "rows_compared": 0,
542
+ "values_compared": 0,
543
+ "values_differing": None,
544
+ "scale_factors_compared": 0,
545
+ "scale_factors_differing": None,
546
+ "payload_checked": False,
547
+ "verified_stack": CANONICAL_RECIPE["verified_stack"],
548
+ }
549
+
550
+ if n_scale_factors:
551
+ published_sf = np.frombuffer(
552
+ _read_file(source, "scale_factors.f32"), dtype=np.float32)
553
+ n_sf = min(int(n_scale_factors), published_sf.size)
554
+ rng = np.random.default_rng(shard_seed(shard))
555
+ ours = np.asarray([_sample_scale_factor(rng) for _ in range(n_sf)],
556
+ dtype=np.float32)
557
+ differing = int(np.count_nonzero(ours != published_sf[:n_sf]))
558
+ report["scale_factors_compared"] = n_sf
559
+ report["scale_factors_differing"] = differing
560
+
561
+ if n_rows > 0:
562
+ _require_cuda(None)
563
+ n_generate = int(math.ceil(n_rows / gp_batch) * gp_batch)
564
+ block = generate_gp(
565
+ n_generate, length,
566
+ seed=shard_seed(shard) + family_salt("gp", shard),
567
+ device=CANONICAL_RECIPE["device"], batch=gp_batch,
568
+ )
569
+ kept = [row for row in block if np.isfinite(row).all()]
570
+ if len(kept) < n_rows:
571
+ raise RuntimeError(
572
+ f"regeneration produced only {len(kept)} usable rows of the "
573
+ f"{n_rows} requested: {n_generate - len(kept)} covariances failed "
574
+ f"to factorize. Investigate that before reading anything into a "
575
+ f"comparison, since it is far above the published failure rate."
576
+ )
577
+ differing = 0
578
+ compared = 0
579
+ max_abs_diff = 0.0
580
+ for i in range(n_rows):
581
+ n = int(published_lengths[i])
582
+ if n != length:
583
+ raise RuntimeError(
584
+ f"published row {i} has length {n}, not {length}: this is not "
585
+ f"a shard of this corpus."
586
+ )
587
+ raw = _read_range(source, "series.f16",
588
+ int(published_offsets[i]), n * _F16_BYTES)
589
+ theirs = np.frombuffer(raw, dtype=np.float16, count=n)
590
+ normalized, _, _ = normalize_for_f16(kept[i])
591
+ ours = normalized.astype(np.float16, copy=False)
592
+ delta = ours.astype(np.float32) - theirs.astype(np.float32)
593
+ differing += int(np.count_nonzero(delta))
594
+ max_abs_diff = max(max_abs_diff, float(np.abs(delta).max()))
595
+ compared += n
596
+ report.update({
597
+ "rows_compared": n_rows,
598
+ "values_compared": compared,
599
+ "values_differing": differing,
600
+ "max_abs_diff": max_abs_diff,
601
+ "payload_checked": True,
602
+ })
603
+
604
+ if verbose:
605
+ print(f"shard {shard} at {path_or_url}")
606
+ print(f" published rows: {published_rows:,} of {planned_rows:,} planned")
607
+ if report["scale_factors_compared"]:
608
+ print(f" scale factors: {report['scale_factors_differing']} of "
609
+ f"{report['scale_factors_compared']:,} differ")
610
+ if report["payload_checked"]:
611
+ print(f" payload: {report['values_differing']} of "
612
+ f"{report['values_compared']:,} float16 values differ "
613
+ f"across {report['rows_compared']} rows "
614
+ f"(max abs difference {report['max_abs_diff']:g})")
615
+ else:
616
+ print(" payload: not checked (n_rows=0)")
617
+ print(f" the published fidelity statement was measured on "
618
+ f"{CANONICAL_RECIPE['verified_stack']}")
619
+ return report
620
+
621
+
622
+ # ---------------------------------------------------------------------------
623
+ # Command line
624
+ # ---------------------------------------------------------------------------
625
+
626
+ def main(argv: Optional[list] = None) -> None:
627
+ import argparse
628
+
629
+ parser = argparse.ArgumentParser(
630
+ prog="python -m tinycast.corpus",
631
+ description="Build or verify a shard of TinyCast's synthetic corpus.")
632
+ sub = parser.add_subparsers(dest="command", required=True)
633
+
634
+ build = sub.add_parser("build", help="generate a shard (needs a CUDA GPU)")
635
+ build.add_argument("--shard", type=int, default=0)
636
+ build.add_argument("--out", default=None,
637
+ help=f"output directory (default {DEFAULT_OUT_DIR})")
638
+ build.add_argument("--max-series", type=int, default=None,
639
+ help="stop after this many series; the prefix is unchanged")
640
+ build.add_argument("--overwrite", action="store_true")
641
+
642
+ verify = sub.add_parser("verify", help="compare a published shard to a rebuild")
643
+ verify.add_argument("source",
644
+ help="shard directory, https prefix, or hf://<owner>/<repo>")
645
+ verify.add_argument("--shard", type=int, default=0)
646
+ verify.add_argument("--rows", type=int, default=DEFAULT_VERIFY_ROWS,
647
+ help="rows to regenerate and compare; 0 skips the payload")
648
+ verify.add_argument("--scale-factors", type=int,
649
+ default=DEFAULT_VERIFY_SCALE_FACTORS)
650
+
651
+ args = parser.parse_args(argv)
652
+ if args.command == "build":
653
+ build_shard(args.out, args.shard, max_series=args.max_series,
654
+ overwrite=args.overwrite)
655
+ else:
656
+ verify_shard(args.source, args.shard, n_rows=args.rows,
657
+ n_scale_factors=args.scale_factors)
658
+
659
+
660
+ if __name__ == "__main__":
661
+ main()
tinycast/downsample.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Eval-time period-alignment downsampling rule.
2
+
3
+ Some datasets carry their dominant seasonality at a clean integer multiple of
4
+ the canonical (samples-per-day) period for their sampling frequency, with the
5
+ canonical period itself absent. For example, bizitobs_l2c at 5T has no daily
6
+ cycle at all, only weekly (2016 samples = 7x the canonical 288). Models calibrated
7
+ around the canonical period grid systematically mis-handle such series;
8
+ downsampling the context by the multiple aliases the dominant period back onto
9
+ the grid (weekly-at-5T becomes daily-at-35T) and restores in-distribution
10
+ behaviour. This module derives the factor from the CONTEXT data (a k=7 alias
11
+ on bizitobs_l2c/5T medium+long), so no per-dataset hand-tuning and no
12
+ information beyond the model's own inputs is used.
13
+
14
+ Fire conditions (all required, and deliberately narrow: downsampling a config
15
+ whose canonical peak is intact makes MASE strictly worse, m4_hourly's strong
16
+ weekly harmonic included):
17
+ 1. sub-daily frequency (canonical period >= MIN_CANONICAL samples/day);
18
+ 2. a dominant spectral peak at period P with P/canonical within
19
+ ``REL_TOL`` of an integer k in [2, MAX_K];
20
+ 3. the canonical-period peak is ABSENT: power near the canonical period is
21
+ below ``CANONICAL_ABSENT`` x the dominant peak's power;
22
+ 4. the dominant peak is significant: >= ``PEAK_SIG`` x median spectral power;
23
+ 5. a >= ``QUORUM`` fraction of sampled series agree on the same k;
24
+ 6. the downsampled context still fills the model window
25
+ (median len / k >= ``context_window``, else the aliasing starves the
26
+ encoder: on bizitobs_l2c/H, where T/k ~ 358, k=7 turns from a win into a
27
+ loss at long horizons);
28
+ 7. the horizon spans at least one canonical day (H >= canonical, else the
29
+ coarse forecast's interpolation loses more than the aliasing gains: on
30
+ bizitobs_l2c/5T/short, H=48 against a canonical 288, by +0.05 MASE).
31
+ """
32
+ from __future__ import annotations
33
+
34
+ from typing import Iterable, Optional
35
+
36
+ import numpy as np
37
+
38
+ MIN_CANONICAL = 8 # rule inactive for daily-or-coarser frequencies
39
+ MAX_K = 16
40
+ REL_TOL = 0.06 # |P/canonical - k| <= REL_TOL * k (after sub-bin refinement)
41
+ CANONICAL_ABSENT = 0.10 # canonical peak power < 10% of dominant peak power
42
+ PEAK_SIG = 20.0 # dominant peak >= 20x median spectral power
43
+ QUORUM = 0.7
44
+ MIN_CYCLES = 3 # dominant period must repeat >= 3x in the analysed tail
45
+ TAIL = 16384 # analyse at most this many trailing samples
46
+ N_SAMPLE = 64 # series sampled per config
47
+
48
+
49
+ def _series_factor(x: np.ndarray, canonical: int) -> int:
50
+ x = np.asarray(x, dtype=np.float64)
51
+ x = x[np.isfinite(x)]
52
+ if x.size < MIN_CYCLES * 2 * canonical:
53
+ return 1
54
+ x = x[-TAIL:]
55
+ n = x.size
56
+ x = x - x.mean()
57
+ p = np.abs(np.fft.rfft(x)) ** 2
58
+ p[0] = 0.0
59
+ med = np.median(p[1:])
60
+ if med <= 0.0:
61
+ return 1
62
+
63
+ periods = np.full(p.shape, np.inf)
64
+ periods[1:] = n / np.arange(1, p.shape[0], dtype=np.float64)
65
+
66
+ # Power near the canonical period (max over a +-10% band).
67
+ canon_band = (periods >= 0.9 * canonical) & (periods <= 1.1 * canonical)
68
+ canon_pow = p[canon_band].max() if canon_band.any() else 0.0
69
+
70
+ # Dominant peak among periods that are >= 1.5x canonical and repeat
71
+ # >= MIN_CYCLES times in the analysed tail.
72
+ cand = (periods >= 1.5 * canonical) & (periods <= n / MIN_CYCLES)
73
+ if not cand.any():
74
+ return 1
75
+ idx = int(np.flatnonzero(cand)[np.argmax(p[cand])])
76
+ peak_pow = p[idx]
77
+ if peak_pow < PEAK_SIG * med:
78
+ return 1
79
+ if canon_pow > CANONICAL_ABSENT * peak_pow:
80
+ return 1 # canonical period present: do not alias
81
+
82
+ # Sub-bin peak refinement (parabolic on log-power): the raw frequency
83
+ # grid is coarse at long periods (spacing ~P^2/n, i.e. ~12% of P for
84
+ # weekly-at-5T in a 16k tail), which would let non-integer multiples
85
+ # masquerade as clean ones under any workable tolerance.
86
+ delta = 0.0
87
+ if 1 <= idx < p.shape[0] - 1 and p[idx - 1] > 0 and p[idx + 1] > 0:
88
+ lp = np.log(p[idx - 1:idx + 2])
89
+ denom = lp[0] - 2.0 * lp[1] + lp[2]
90
+ if denom < 0:
91
+ delta = float(np.clip(0.5 * (lp[0] - lp[2]) / denom, -0.5, 0.5))
92
+ refined_period = n / (idx + delta)
93
+
94
+ r = refined_period / canonical
95
+ k = int(round(r))
96
+ if k < 2 or k > MAX_K or abs(r - k) > REL_TOL * k:
97
+ return 1
98
+ return k
99
+
100
+
101
+ def period_alignment_factor(
102
+ contexts: Iterable[np.ndarray],
103
+ freq_seconds: float,
104
+ horizon: int,
105
+ context_window: int = 2048,
106
+ n_sample: int = N_SAMPLE,
107
+ ) -> int:
108
+ """Downsample factor for one eval config, from context windows only.
109
+
110
+ ``contexts`` are the test INPUT series (the model's own inputs);
111
+ ``freq_seconds`` is the sampling interval; ``horizon`` the prediction
112
+ length; ``context_window`` the model's encoder window. Returns 1 unless
113
+ the config's series agree (>= QUORUM) on the same aliasing multiple k >= 2
114
+ and the horizon/window guards pass.
115
+ """
116
+ canonical = int(round(86400.0 / float(freq_seconds)))
117
+ if canonical < MIN_CANONICAL:
118
+ return 1
119
+ if int(horizon) < canonical: # guard 7: >= one canonical day
120
+ return 1
121
+ ks = []
122
+ lens = []
123
+ for x in contexts:
124
+ x = np.asarray(x, dtype=np.float64)
125
+ ks.append(_series_factor(x, canonical))
126
+ lens.append(x.size)
127
+ if len(ks) >= n_sample:
128
+ break
129
+ if not ks:
130
+ return 1
131
+ vals, counts = np.unique(ks, return_counts=True)
132
+ best = int(vals[np.argmax(counts)])
133
+ if best == 1 or counts.max() / len(ks) < QUORUM:
134
+ return 1
135
+ if float(np.median(lens)) / best < context_window: # guard 6
136
+ return 1
137
+ return best
138
+
139
+
140
+ def freq_to_seconds(freq: str) -> Optional[float]:
141
+ """Sampling interval in seconds for a pandas-style freq string, or None."""
142
+ import pandas as pd
143
+
144
+ try:
145
+ off = pd.tseries.frequencies.to_offset(freq)
146
+ return pd.Timedelta(off).total_seconds()
147
+ except (ValueError, TypeError):
148
+ return None
tinycast/encoding.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared structural positional-encoding helpers (phase + bounded recency).
2
+
3
+ A normalized-periodogram detector supplies top-K periods per sample. Those
4
+ periods drive a structural positional encoding (sin/cos of phase) shared
5
+ between context tokens and decoder horizon queries, plus a bounded
6
+ recency/trend basis. Also holds the fp32 RMSNorm helper.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+
13
+ import torch
14
+ import torch.nn as nn
15
+
16
+
17
+ def _phase_encoding(
18
+ positions: torch.Tensor, periods: torch.Tensor,
19
+ n_harmonics: int = 1,
20
+ ) -> torch.Tensor:
21
+ """Compute sin/cos phase encoding for each position under each period.
22
+
23
+ For each detected period ``p_k``, emit Fourier-series channels up to
24
+ ``n_harmonics``: at harmonic m, the channels are sin(2π·m·t/p_k) and
25
+ cos(2π·m·t/p_k). The fundamental (m=1) is the base behavior; m=2,3,...
26
+ let the model represent non-sinusoidal periodic shapes (square-wave
27
+ traffic, sawtooth load) that the fundamental alone cannot.
28
+
29
+ Output channel ordering, per (period, harmonic):
30
+ [sin(1·φ_1), cos(1·φ_1), ..., sin(H·φ_1), cos(H·φ_1),
31
+ sin(1·φ_2), cos(1·φ_2), ..., sin(H·φ_K), cos(H·φ_K)]
32
+
33
+ Args:
34
+ positions: (B, T) int tensor of absolute positions.
35
+ periods: (B, K) int tensor of per-sample detected periods. Zero
36
+ means "rejected by significance test" → all harmonics
37
+ of that period come back zeroed.
38
+ n_harmonics: number of Fourier harmonics per period (default 1).
39
+ Returns:
40
+ (B, T, 2·K·n_harmonics) fp32 tensor.
41
+ """
42
+ B, T = positions.shape
43
+ K = periods.shape[1]
44
+ H = int(n_harmonics)
45
+ if H < 1:
46
+ raise ValueError(f"n_harmonics must be >= 1; got {H}")
47
+ valid = (periods > 0).view(B, 1, K).float() # (B,1,K)
48
+ p_safe = periods.clamp(min=1).view(B, 1, K).float()
49
+ pos = positions.view(B, T, 1).float()
50
+ phase_base = 2.0 * math.pi * pos / p_safe # (B,T,K)
51
+ # Build (B,T,K,H,2): for each (period k, harmonic m), [sin(m·φ_k), cos(m·φ_k)]
52
+ multipliers = torch.arange(1, H + 1, device=positions.device, dtype=phase_base.dtype)
53
+ phase_m = phase_base.unsqueeze(-1) * multipliers # (B,T,K,H)
54
+ sin_m = torch.sin(phase_m) * valid.unsqueeze(-1) # (B,T,K,H)
55
+ cos_m = torch.cos(phase_m) * valid.unsqueeze(-1)
56
+ pair = torch.stack([sin_m, cos_m], dim=-1) # (B,T,K,H,2)
57
+ return pair.reshape(B, T, K * H * 2)
58
+
59
+
60
+ # Number of recency/trend channels added by the bounded-basis extension.
61
+ N_RECENCY_CHANNELS = 5
62
+
63
+
64
+ def _recency_encoding(
65
+ positions: torch.Tensor, L: int,
66
+ ) -> torch.Tensor:
67
+ """Bounded recency/trend channels for the shared positional encoding.
68
+
69
+ "Now" is anchored at position ``L-1`` (end of context). ``Δ = (t - (L-1)) / L``
70
+ is signed: negative for past, zero at "now", positive for future. All
71
+ channels are bounded so they're safe to evaluate at arbitrary future
72
+ horizons (the parameterized-query path goes well beyond training H).
73
+
74
+ Channels (5):
75
+ rec_lin: Δ (signed linear distance from now)
76
+ rec_log: sign(Δ) · log1p(|Δ|)/log(2) (signed log-compressed distance)
77
+ rec_e05: exp(-0.5 · |Δ|) (long-memory decay)
78
+ rec_e2: exp(-2.0 · |Δ|) (medium-memory decay)
79
+ rec_e8: exp(-8.0 · |Δ|) (short-memory / locality kernel)
80
+ """
81
+ B, T = positions.shape
82
+ delta = (positions.float() - float(L - 1)) / float(L) # (B, T)
83
+ abs_d = delta.abs()
84
+ rec_lin = delta
85
+ rec_log = torch.sign(delta) * torch.log1p(abs_d) / math.log(2.0)
86
+ rec_e05 = torch.exp(-0.5 * abs_d)
87
+ rec_e2 = torch.exp(-2.0 * abs_d)
88
+ rec_e8 = torch.exp(-8.0 * abs_d)
89
+ return torch.stack([rec_lin, rec_log, rec_e05, rec_e2, rec_e8], dim=-1)
90
+
91
+
92
+ def _positional_encoding(
93
+ positions: torch.Tensor, periods: torch.Tensor, L: int,
94
+ n_harmonics: int = 1,
95
+ ) -> torch.Tensor:
96
+ """Full shared positional encoding (phase + bounded recency basis).
97
+
98
+ Returns (B, T, 2·K·n_harmonics + 5).
99
+ """
100
+ return torch.cat(
101
+ [
102
+ _phase_encoding(positions, periods, n_harmonics=n_harmonics),
103
+ _recency_encoding(positions, L),
104
+ ],
105
+ dim=-1,
106
+ )
107
+
108
+
109
+ def _norm_fp32(norm: nn.Module, x: torch.Tensor) -> torch.Tensor:
110
+ """Apply RMSNorm in fp32 and cast the result back to the input's dtype.
111
+
112
+ Under bf16-mixed AMP, RMSNorm receives bf16 input but holds fp32 weights.
113
+ PyTorch's fused RMSNorm kernel falls back to a slow non-fused path on
114
+ dtype mismatch. Explicit fp32 promotion matches the weight dtype and lets
115
+ the fused kernel engage. This matters most under compile, where the unfused
116
+ dispatch breaks the graph and prevents downstream fusions.
117
+ """
118
+ if x.dtype == torch.float32:
119
+ return norm(x)
120
+ with torch.amp.autocast(
121
+ device_type=x.device.type if x.is_cuda else "cpu", enabled=False,
122
+ ):
123
+ return norm(x.float()).to(x.dtype)
tinycast/eval.py ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyCast GIFT-Eval driver.
2
+
3
+ Runs the deployed TinyCast predictor against the GIFT-Eval benchmark and writes
4
+ a results CSV in the leaderboard column order (98 lines incl. header, 15
5
+ columns), plus a per-frequency-bin summary sidecar carrying the three
6
+ normalized aggregates (nGMASE, nCRPS, nMSIS).
7
+
8
+ ``summarize_by_freq_bin`` also runs standalone on any leaderboard-format CSV, so
9
+ the published aggregates can be re-derived offline from the pinned per-config
10
+ results in ``reference/gift_eval_tinycast.csv`` without a GPU or benchmark data.
11
+ Called that way it only returns and prints the summary; pass
12
+ ``write_sidecar=True`` to also write it beside the CSV.
13
+
14
+ Usage:
15
+ python -m tinycast.eval --ckpt model.safetensors --flip \\
16
+ --output all_results.csv # full 97 configs
17
+ python -m tinycast.eval --ckpt model.safetensors --flip \\
18
+ --configs "m4_hourly/H/short" --output smoke.csv
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import csv
24
+ import json
25
+ import math
26
+ import os
27
+ from pathlib import Path
28
+ from typing import Iterable, List, Optional, Sequence
29
+
30
+ import numpy as np
31
+
32
+ MODEL_NAME_DEFAULT = "TinyCast"
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Dataset constants: the GIFT-Eval protocol's dataset / term layout.
36
+ # ---------------------------------------------------------------------------
37
+ PRETTY_NAMES = {
38
+ "saugeenday": "saugeen",
39
+ "temperature_rain_with_missing": "temperature_rain",
40
+ "kdd_cup_2018_with_missing": "kdd_cup_2018",
41
+ "car_parts_with_missing": "car_parts",
42
+ }
43
+
44
+ SHORT_DATASETS = (
45
+ "m4_yearly m4_quarterly m4_monthly m4_weekly m4_daily m4_hourly "
46
+ "electricity/15T electricity/H electricity/D electricity/W "
47
+ "solar/10T solar/H solar/D solar/W "
48
+ "hospital covid_deaths "
49
+ "us_births/D us_births/M us_births/W "
50
+ "saugeenday/D saugeenday/M saugeenday/W "
51
+ "temperature_rain_with_missing "
52
+ "kdd_cup_2018_with_missing/H kdd_cup_2018_with_missing/D "
53
+ "car_parts_with_missing restaurant "
54
+ "hierarchical_sales/D hierarchical_sales/W "
55
+ "LOOP_SEATTLE/5T LOOP_SEATTLE/H LOOP_SEATTLE/D "
56
+ "SZ_TAXI/15T SZ_TAXI/H "
57
+ "M_DENSE/H M_DENSE/D "
58
+ "ett1/15T ett1/H ett1/D ett1/W ett2/W ett2/D "
59
+ "jena_weather/10T jena_weather/H jena_weather/D "
60
+ "bitbrains_fast_storage/5T bitbrains_fast_storage/H "
61
+ "bitbrains_rnd/5T bitbrains_rnd/H "
62
+ "bizitobs_application bizitobs_service "
63
+ "bizitobs_l2c/5T bizitobs_l2c/H"
64
+ )
65
+
66
+ MED_LONG_DATASETS = (
67
+ "electricity/15T electricity/H "
68
+ "solar/10T solar/H "
69
+ "kdd_cup_2018_with_missing/H "
70
+ "LOOP_SEATTLE/5T LOOP_SEATTLE/H "
71
+ "SZ_TAXI/15T M_DENSE/H "
72
+ "ett1/15T ett1/H ett2/15T ett2/H "
73
+ "jena_weather/10T jena_weather/H "
74
+ "bitbrains_fast_storage/5T bitbrains_rnd/5T "
75
+ "bizitobs_application bizitobs_service "
76
+ "bizitobs_l2c/5T bizitobs_l2c/H"
77
+ )
78
+
79
+ # Leaderboard CSV column order (byte-identical for leaderboard submission).
80
+ LEADERBOARD_COLUMNS = (
81
+ "dataset", "model",
82
+ "eval_metrics/MSE[mean]", "eval_metrics/MSE[0.5]",
83
+ "eval_metrics/MAE[0.5]", "eval_metrics/MASE[0.5]",
84
+ "eval_metrics/MAPE[0.5]", "eval_metrics/sMAPE[0.5]",
85
+ "eval_metrics/MSIS", "eval_metrics/RMSE[mean]",
86
+ "eval_metrics/NRMSE[mean]", "eval_metrics/ND[0.5]",
87
+ "eval_metrics/mean_weighted_sum_quantile_loss",
88
+ "domain", "num_variates",
89
+ )
90
+
91
+ REFERENCE_DIR = Path(__file__).parent / "reference"
92
+
93
+
94
+ def _build_metrics():
95
+ from gluonts.ev.metrics import (
96
+ MAE, MAPE, MASE, MSE, MSIS, ND, NRMSE, RMSE, SMAPE,
97
+ MeanWeightedSumQuantileLoss,
98
+ )
99
+ return [
100
+ MSE(forecast_type="mean"),
101
+ MSE(forecast_type=0.5),
102
+ MAE(),
103
+ MASE(),
104
+ MAPE(),
105
+ SMAPE(),
106
+ MSIS(),
107
+ RMSE(),
108
+ NRMSE(),
109
+ ND(),
110
+ MeanWeightedSumQuantileLoss(
111
+ quantile_levels=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
112
+ ),
113
+ ]
114
+
115
+
116
+ def _load_dataset_properties() -> dict:
117
+ with open(REFERENCE_DIR / "dataset_properties.json") as f:
118
+ return json.load(f)
119
+
120
+
121
+ def iter_configs(
122
+ configs_filter: Optional[Sequence[str]] = None,
123
+ term_filter: Optional[str] = None,
124
+ ) -> Iterable[tuple]:
125
+ """Yield ``(ds_name, term, ds_config_key, ds_key, ds_freq)`` per (dataset, term).
126
+
127
+ ``configs_filter`` matches by full ``ds_config_key`` (e.g.
128
+ ``ett1/15T/long``); if ``None`` (default), all 97 configs are yielded.
129
+ """
130
+ dataset_properties = _load_dataset_properties()
131
+ all_datasets = sorted(set(SHORT_DATASETS.split() + MED_LONG_DATASETS.split()))
132
+ med_long_set = set(MED_LONG_DATASETS.split())
133
+ all_terms = ["short", "medium", "long"] if term_filter is None else [term_filter]
134
+ wanted = set(configs_filter) if configs_filter is not None else None
135
+
136
+ for ds_name in all_datasets:
137
+ if "/" in ds_name:
138
+ ds_key = PRETTY_NAMES.get(ds_name.split("/")[0].lower(), ds_name.split("/")[0].lower())
139
+ ds_freq = ds_name.split("/")[1]
140
+ else:
141
+ ds_key = PRETTY_NAMES.get(ds_name.lower(), ds_name.lower())
142
+ ds_freq = dataset_properties[ds_key]["frequency"]
143
+
144
+ for term in all_terms:
145
+ if term in ("medium", "long") and ds_name not in med_long_set:
146
+ continue
147
+ ds_config_key = f"{ds_key}/{ds_freq}/{term}"
148
+ if wanted is not None and ds_config_key not in wanted:
149
+ continue
150
+ yield ds_name, term, ds_config_key, ds_key, ds_freq
151
+
152
+
153
+ def evaluate(
154
+ ckpt_path: str,
155
+ output_csv: os.PathLike,
156
+ *,
157
+ model_name: str = MODEL_NAME_DEFAULT,
158
+ configs_filter: Optional[Sequence[str]] = None,
159
+ term_filter: Optional[str] = None,
160
+ force_flip_invariance: bool = True,
161
+ device: Optional[str] = None,
162
+ use_amp: int = 1,
163
+ period_align: bool = True,
164
+ batch_size: int = 64,
165
+ predictor_batch_size: Optional[int] = None,
166
+ strict_summary: bool = True,
167
+ ) -> dict:
168
+ """Run GIFT-Eval for TinyCast; stream rows to ``output_csv``.
169
+
170
+ ``device`` defaults to ``None``, which is the predictor's request to choose:
171
+ CUDA when it is present, CPU otherwise. Naming a device instead makes it a
172
+ requirement, and an absent one raises rather than being substituted.
173
+
174
+ Returns the ``summarize_by_freq_bin`` summary for the run it just wrote, so
175
+ a caller can assert on the aggregates without re-reading the CSV. The row
176
+ CSV is complete on disk before the summary runs, so a ``strict_summary``
177
+ failure costs no evaluation work: fix the reference and re-summarize.
178
+ """
179
+ from gluonts.model import evaluate_model
180
+ from gluonts.time_feature import get_seasonality
181
+ from gift_eval.data import Dataset
182
+
183
+ from .predictor import TinyCastPredictor
184
+
185
+ metrics = _build_metrics()
186
+ dataset_properties = _load_dataset_properties()
187
+
188
+ output_csv = Path(output_csv)
189
+ output_csv.parent.mkdir(parents=True, exist_ok=True)
190
+ with open(output_csv, "w", newline="") as f:
191
+ csv.writer(f).writerow(LEADERBOARD_COLUMNS)
192
+
193
+ configs = list(iter_configs(configs_filter, term_filter))
194
+ print(f"Evaluating {model_name} on {len(configs)} configs.")
195
+ print(f"Output: {output_csv}")
196
+
197
+ for i, (ds_name, term, ds_config, ds_key, ds_freq) in enumerate(configs):
198
+ print(f"[{i + 1}/{len(configs)}] {ds_config}", flush=True)
199
+
200
+ probe = Dataset(name=ds_name, term=term, to_univariate=False)
201
+ to_uni = probe.target_dim != 1
202
+ dataset = Dataset(name=ds_name, term=term, to_univariate=to_uni)
203
+ season_length = get_seasonality(dataset.freq)
204
+
205
+ per_cfg_kwargs = dict(
206
+ device=device,
207
+ force_flip_invariance=bool(force_flip_invariance),
208
+ use_amp=use_amp,
209
+ )
210
+ if predictor_batch_size is not None:
211
+ per_cfg_kwargs["batch_size"] = predictor_batch_size
212
+ # bizitobs_l2c has no daily cycle, so scale_factor /= 7.
213
+ if "l2c" in ds_name.lower():
214
+ per_cfg_kwargs["no_daily"] = True
215
+ # Period-alignment downsampling: derive the aliasing factor from the
216
+ # test INPUT contexts only (fires k=7 on bizitobs_l2c/5T med+long).
217
+ if period_align:
218
+ from .downsample import freq_to_seconds, period_alignment_factor
219
+ fs = freq_to_seconds(ds_freq)
220
+ if fs is not None:
221
+ k_align = period_alignment_factor(
222
+ (np.asarray(e["target"], dtype=np.float64)
223
+ for e in dataset.test_data.input),
224
+ fs,
225
+ horizon=dataset.prediction_length,
226
+ )
227
+ if k_align > 1:
228
+ per_cfg_kwargs["downsample_factor"] = k_align
229
+ print(f" period-align: downsample_factor={k_align}", flush=True)
230
+
231
+ predictor = TinyCastPredictor(
232
+ prediction_length=dataset.prediction_length,
233
+ checkpoint_path=ckpt_path,
234
+ freq=ds_freq,
235
+ domain=dataset_properties[ds_key]["domain"],
236
+ **per_cfg_kwargs,
237
+ )
238
+
239
+ res = evaluate_model(
240
+ predictor,
241
+ test_data=dataset.test_data,
242
+ metrics=metrics,
243
+ batch_size=batch_size,
244
+ axis=None,
245
+ mask_invalid_label=True,
246
+ allow_nan_forecast=False,
247
+ seasonality=season_length,
248
+ )
249
+
250
+ with open(output_csv, "a", newline="") as f:
251
+ csv.writer(f).writerow([
252
+ ds_config, model_name,
253
+ res["MSE[mean]"].iloc[0], res["MSE[0.5]"].iloc[0],
254
+ res["MAE[0.5]"].iloc[0], res["MASE[0.5]"].iloc[0],
255
+ res["MAPE[0.5]"].iloc[0], res["sMAPE[0.5]"].iloc[0],
256
+ res["MSIS"].iloc[0], res["RMSE[mean]"].iloc[0],
257
+ res["NRMSE[mean]"].iloc[0], res["ND[0.5]"].iloc[0],
258
+ res["mean_weighted_sum_quantile_loss"].iloc[0],
259
+ dataset_properties[ds_key]["domain"],
260
+ dataset_properties[ds_key]["num_variates"],
261
+ ])
262
+
263
+ import gc
264
+ del predictor, res, dataset, probe
265
+ gc.collect()
266
+
267
+ print(f"Done. Results: {output_csv}")
268
+ # A partial run (--configs / --term) covers a subset of the 97 leaderboard
269
+ # configurations, but every configuration it does cover must be scored, so
270
+ # the strict reference check stays on.
271
+ return summarize_by_freq_bin(
272
+ output_csv, strict=strict_summary, write_sidecar=True,
273
+ )
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Per-frequency-bin normalized summary (sidecar to the leaderboard CSV)
278
+ # ---------------------------------------------------------------------------
279
+ _FREQ_BIN_ORDER = ("sub_hourly", "hourly", "daily_or_coarser")
280
+
281
+ # The three reported GIFT-Eval aggregates, each the geometric mean of the
282
+ # per-configuration ratio of the column below to the same column of the
283
+ # seasonal-naive reference. One construction, three columns.
284
+ _NORMALIZED_METRICS = (
285
+ ("ngmase", "eval_metrics/MASE[0.5]"),
286
+ ("ncrps", "eval_metrics/mean_weighted_sum_quantile_loss"),
287
+ ("nmsis", "eval_metrics/MSIS"),
288
+ )
289
+
290
+ # The GIFT-Eval leaderboard calls the quantile-loss aggregate nWQL; the paper
291
+ # calls it nCRPS. Same column, so the summary carries both spellings.
292
+ _METRIC_ALIASES = (("nwql", "ncrps"),)
293
+
294
+
295
+ def _freq_bin(freq: str) -> str:
296
+ f = freq.strip().upper()
297
+ if f == "H":
298
+ return "hourly"
299
+ if f.endswith("S") or f.endswith("T") or f == "MIN":
300
+ return "sub_hourly"
301
+ return "daily_or_coarser"
302
+
303
+
304
+ def _positive_float(value) -> Optional[float]:
305
+ """Parse ``value``, returning ``None`` unless it is finite and positive.
306
+
307
+ Every aggregate here is a geometric mean, so a zero, a negative or a NaN is
308
+ not a small contribution: it is undefined.
309
+ """
310
+ try:
311
+ out = float(value)
312
+ except (ValueError, TypeError):
313
+ return None
314
+ return out if math.isfinite(out) and out > 0 else None
315
+
316
+
317
+ def _read_reference_metrics(reference_csv: os.PathLike) -> dict:
318
+ """Map ``dataset`` to its seasonal-naive denominator per normalized metric."""
319
+ out: dict = {}
320
+ with open(reference_csv, newline="") as f:
321
+ for row in csv.DictReader(f):
322
+ ds = row.get("dataset")
323
+ if not ds:
324
+ continue
325
+ denominators = {}
326
+ for _, column in _NORMALIZED_METRICS:
327
+ den = _positive_float(row.get(column))
328
+ if den is not None:
329
+ denominators[column] = den
330
+ out[ds] = denominators
331
+ return out
332
+
333
+
334
+ def _geometric_mean(values: Sequence[float]) -> Optional[float]:
335
+ if not values:
336
+ return None
337
+ return math.exp(sum(math.log(v) for v in values) / len(values))
338
+
339
+
340
+ def summarize_by_freq_bin(
341
+ output_csv: os.PathLike,
342
+ *,
343
+ seasonal_naive_csv: "os.PathLike | None" = None,
344
+ strict: bool = True,
345
+ write_sidecar: bool = False,
346
+ ) -> dict:
347
+ """Read ``output_csv``, print the per-frequency-bin summary and return it.
348
+
349
+ Three aggregates are reported, all built the same way: the geometric mean
350
+ over configurations of the ratio between a column and the same column of the
351
+ seasonal-naive reference. ``ngmase`` normalizes MASE (point accuracy),
352
+ ``ncrps`` normalizes the mean weighted sum quantile loss (probabilistic
353
+ accuracy, the leaderboard's CRPS column, carried under both keys),
354
+ and ``nmsis`` normalizes MSIS (interval score).
355
+
356
+ ``write_sidecar`` (default off) additionally writes the returned summary to
357
+ ``<stem>.bins.json`` beside ``output_csv``. It is off by default because the
358
+ common call re-derives the published aggregates from the pinned results
359
+ inside the installed package, where the sidecar path lands in
360
+ ``site-packages``: reading a file must not write one. ``evaluate`` turns it
361
+ on, since there the CSV is the caller's own output path.
362
+
363
+ ``strict`` (default on) raises when any configuration in ``output_csv`` is
364
+ left out of an aggregate, whether because its dataset key is unparseable,
365
+ because the reference file does not cover it, or because a value is missing
366
+ or non-positive. A geometric mean says nothing about how many terms it has,
367
+ so a reference file that has drifted away from the run would otherwise
368
+ quietly shrink the sample and return a number that looks publishable. The
369
+ dropped configurations are recorded under ``summary["dropped"]`` either way,
370
+ so a caller running with ``strict=False`` can still assert on the count.
371
+ """
372
+ output_csv = Path(output_csv)
373
+ if seasonal_naive_csv is None:
374
+ seasonal_naive_csv = REFERENCE_DIR / "seasonal_naive.csv"
375
+ seasonal_naive_csv = Path(seasonal_naive_csv)
376
+ reference = (
377
+ _read_reference_metrics(seasonal_naive_csv)
378
+ if seasonal_naive_csv.exists()
379
+ else {}
380
+ )
381
+
382
+ mase_per_bin = {b: [] for b in _FREQ_BIN_ORDER}
383
+ ratios = {key: {b: [] for b in _FREQ_BIN_ORDER}
384
+ for key, _ in _NORMALIZED_METRICS}
385
+ rows_per_bin = {b: 0 for b in _FREQ_BIN_ORDER}
386
+ dropped_reasons: dict = {}
387
+ n_rows = 0
388
+
389
+ def drop(dataset: str, reason: str) -> None:
390
+ dropped_reasons.setdefault(reason, []).append(dataset)
391
+
392
+ with open(output_csv, newline="") as f:
393
+ for row in csv.DictReader(f):
394
+ n_rows += 1
395
+ ds = row.get("dataset") or ""
396
+ parts = ds.split("/")
397
+ if len(parts) < 3:
398
+ drop(ds, "unparseable_dataset_key")
399
+ continue
400
+ bin_name = _freq_bin(parts[1])
401
+ rows_per_bin[bin_name] += 1
402
+
403
+ mase = _positive_float(row.get("eval_metrics/MASE[0.5]"))
404
+ if mase is not None:
405
+ mase_per_bin[bin_name].append(mase)
406
+
407
+ denominators = reference.get(ds)
408
+ if denominators is None:
409
+ drop(ds, "no_seasonal_naive_reference")
410
+ continue
411
+ for key, column in _NORMALIZED_METRICS:
412
+ numerator = _positive_float(row.get(column))
413
+ if numerator is None:
414
+ drop(ds, f"no_value_{key}")
415
+ elif column not in denominators:
416
+ drop(ds, f"no_reference_value_{key}")
417
+ else:
418
+ ratios[key][bin_name].append(numerator / denominators[column])
419
+
420
+ summary: dict = {}
421
+ for bin_name in list(_FREQ_BIN_ORDER) + ["overall"]:
422
+ bins = _FREQ_BIN_ORDER if bin_name == "overall" else (bin_name,)
423
+ mase_vals = [v for b in bins for v in mase_per_bin[b]]
424
+ entry = {
425
+ "n": sum(rows_per_bin[b] for b in bins),
426
+ "gmean_mase": _geometric_mean(mase_vals),
427
+ }
428
+ for key, _ in _NORMALIZED_METRICS:
429
+ vals = [v for b in bins for v in ratios[key][b]]
430
+ entry[key] = _geometric_mean(vals)
431
+ entry[f"n_{key}"] = len(vals)
432
+ for alias, key in _METRIC_ALIASES:
433
+ entry[alias] = entry[key]
434
+ entry[f"n_{alias}"] = entry[f"n_{key}"]
435
+ summary[bin_name] = entry
436
+
437
+ n_dropped = sum(len(v) for v in dropped_reasons.values())
438
+ summary["dropped"] = {
439
+ "n_rows": n_rows,
440
+ "n_dropped": n_dropped,
441
+ "by_reason": {k: sorted(set(v)) for k, v in sorted(dropped_reasons.items())},
442
+ }
443
+ summary["reference"] = str(seasonal_naive_csv)
444
+
445
+ if write_sidecar:
446
+ sidecar = output_csv.with_suffix(".bins.json")
447
+ sidecar.write_text(json.dumps(summary, indent=2))
448
+
449
+ def fmt(value) -> str:
450
+ return f"{value:.4f}" if value is not None else "n/a"
451
+
452
+ header = "Per-frequency-bin GIFT-Eval aggregates"
453
+ if write_sidecar:
454
+ header += f" (sidecar: {sidecar.name})"
455
+ print(header)
456
+ print(f" {'bin':<18} {'n':>3} {'nGMASE':>8} {'nCRPS':>8} {'nMSIS':>8}"
457
+ f" {'gmean MASE':>11}")
458
+ for bin_name in list(_FREQ_BIN_ORDER) + ["overall"]:
459
+ s = summary[bin_name]
460
+ print(f" {bin_name:<18} {s['n']:>3} {fmt(s['ngmase']):>8}"
461
+ f" {fmt(s['ncrps']):>8} {fmt(s['nmsis']):>8}"
462
+ f" {fmt(s['gmean_mase']):>11}")
463
+
464
+ if n_dropped:
465
+ detail = ", ".join(f"{reason}: {len(set(datasets))}"
466
+ for reason, datasets in sorted(dropped_reasons.items()))
467
+ message = (
468
+ f"{n_dropped} of {n_rows} configurations in {output_csv} were left "
469
+ f"out of an aggregate ({detail}). The reference "
470
+ f"{seasonal_naive_csv} does not match this run, so the aggregates "
471
+ f"above are geometric means over an incomplete sample."
472
+ )
473
+ if strict:
474
+ raise ValueError(message)
475
+ print(f" WARNING: {message}")
476
+
477
+ return summary
478
+
479
+
480
+ def _parse_configs_arg(value: Optional[str]) -> Optional[List[str]]:
481
+ if value is None:
482
+ return None
483
+ return [c.strip() for c in value.split(",") if c.strip()]
484
+
485
+
486
+ def main(argv: Optional[Sequence[str]] = None) -> None:
487
+ parser = argparse.ArgumentParser(
488
+ description="Run GIFT-Eval against the deployed TinyCast model."
489
+ )
490
+ parser.add_argument("--ckpt", required=True,
491
+ help="Path to model.safetensors (config.json sibling).")
492
+ parser.add_argument("--configs", default=None,
493
+ help="Comma-separated ds_config_keys (e.g. "
494
+ "'ett1/15T/long,m4_hourly/H/short'). Default: all 97.")
495
+ parser.add_argument("--term", default=None, choices=["short", "medium", "long"])
496
+ parser.add_argument("--flip", dest="force_flip_invariance",
497
+ action="store_true", default=True,
498
+ help="Flip-invariance symmetrization (deployed default ON).")
499
+ parser.add_argument("--no-flip", dest="force_flip_invariance",
500
+ action="store_false")
501
+ parser.add_argument("--no-period-align", action="store_true",
502
+ help="Disable period-alignment downsampling (default ON).")
503
+ parser.add_argument("--dtype", default="bf16", choices=["bf16", "fp32-strict"],
504
+ help="bf16 autocast at compute (CUDA only) or strict fp32.")
505
+ parser.add_argument("--device", default=None,
506
+ help="cuda | cpu. Default: whichever is present "
507
+ "(CUDA when available, else CPU). Naming one "
508
+ "makes it a requirement: an absent device is an "
509
+ "error, never a silent substitution.")
510
+ parser.add_argument("--batch-size", type=int, default=64,
511
+ help="gluonts.evaluate_model aggregation batch size.")
512
+ parser.add_argument("--predictor-batch-size", type=int, default=None,
513
+ help="Internal predictor batch size (default 256).")
514
+ parser.add_argument("--model-name", default=MODEL_NAME_DEFAULT,
515
+ help="Value written to the 'model' CSV column.")
516
+ parser.add_argument("--output", default="all_results.csv",
517
+ help="Output CSV path (leaderboard format).")
518
+ parser.add_argument("--no-strict-summary", action="store_true",
519
+ help="Warn instead of failing when the seasonal-naive "
520
+ "reference does not cover every evaluated config.")
521
+ args = parser.parse_args(argv)
522
+
523
+ evaluate(
524
+ ckpt_path=args.ckpt,
525
+ output_csv=args.output,
526
+ model_name=args.model_name,
527
+ configs_filter=_parse_configs_arg(args.configs),
528
+ term_filter=args.term,
529
+ force_flip_invariance=bool(args.force_flip_invariance),
530
+ device=args.device,
531
+ use_amp=0 if args.dtype == "fp32-strict" else 1,
532
+ period_align=not args.no_period_align,
533
+ batch_size=args.batch_size,
534
+ predictor_batch_size=args.predictor_batch_size,
535
+ strict_summary=not args.no_strict_summary,
536
+ )
537
+
538
+
539
+ if __name__ == "__main__":
540
+ main()
tinycast/export.py ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training checkpoint to released artifact.
2
+
3
+ The released model is a ``model.safetensors`` + ``config.json`` pair; this module
4
+ is the bridge that produces it from whatever a training run left on disk.
5
+
6
+ Two things make that bridge non-trivial:
7
+
8
+ * **Weight tying.** Both FFN stacks are shared, so a naive ``state_dict()`` has 177
9
+ entries summing to 321,225 values while the model has only 121 distinct
10
+ parameter tensors holding 146,505 values. The export writes each storage once and
11
+ records the aliases in the safetensors metadata, which is what
12
+ :func:`tinycast.checkpoint.load_checkpoint` reads back. A parameter count is only
13
+ meaningful when taken from a model instantiated from its config; never sum a
14
+ checkpoint's tensors.
15
+ * **Checkpoint averaging.** The released weights are the uniform mean of the last
16
+ eight periodic checkpoints of the training run, so exporting the final checkpoint
17
+ alone does not reproduce them. :func:`average_checkpoints` is that mean.
18
+
19
+ Loading is deliberately restricted: torch-serialized files are read with
20
+ ``weights_only=True`` and safetensors files with the safetensors reader, so no path
21
+ handed to this module can execute code.
22
+
23
+ Usage:
24
+ # the last eight periodic checkpoints, in training order
25
+ python -m tinycast.export --out-dir release/ --average CKPT [CKPT ...]
26
+
27
+ >>> from tinycast.export import export_safetensors, check_export_roundtrip
28
+ >>> export_safetensors(model, "release/")
29
+ >>> check_export_roundtrip(model, expect_parameters=146_505)
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import math
35
+ import tempfile
36
+ from contextlib import nullcontext
37
+ from pathlib import Path
38
+ from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
39
+
40
+ import torch
41
+ import torch.nn as nn
42
+
43
+ from .config import TinyCastConfig
44
+ from .model import TinyCastForPrediction
45
+
46
+ WEIGHTS_NAME = "model.safetensors"
47
+ CONFIG_NAME = "config.json"
48
+
49
+ #: Number of periodic checkpoints averaged to produce the released weights.
50
+ RELEASE_AVERAGE_N = 8
51
+
52
+ #: Keys a training checkpoint may nest its tensors under, most specific first.
53
+ _STATE_DICT_KEYS = ("state_dict", "model_state_dict", "model", "weights")
54
+
55
+ StateDict = Dict[str, torch.Tensor]
56
+
57
+
58
+ class ExportError(RuntimeError):
59
+ """A checkpoint could not be read, averaged, or exported faithfully."""
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Safe loading
64
+ # ---------------------------------------------------------------------------
65
+ def load_state_dict_safely(path: Union[str, Path]) -> StateDict:
66
+ """Read a state dict from ``path`` without ever executing code from it.
67
+
68
+ ``.safetensors`` goes through the safetensors reader; everything else through
69
+ ``torch.load(..., weights_only=True)``. A training checkpoint that nests its
70
+ tensors under ``state_dict`` (or ``model_state_dict`` / ``model`` / ``weights``)
71
+ is unwrapped; the optimizer state, schedulers and step counters around it are
72
+ discarded, since none of them survive into the released artifact.
73
+ """
74
+ p = Path(path)
75
+ if not p.is_file():
76
+ raise ExportError(f"no such checkpoint: {p}")
77
+
78
+ if p.suffix == ".safetensors":
79
+ from safetensors.torch import load_file
80
+
81
+ return dict(load_file(str(p), device="cpu"))
82
+
83
+ obj = torch.load(str(p), map_location="cpu", weights_only=True)
84
+ return _unwrap_state_dict(obj, p)
85
+
86
+
87
+ def _unwrap_state_dict(obj: object, origin: Path) -> StateDict:
88
+ if not isinstance(obj, Mapping):
89
+ raise ExportError(
90
+ f"{origin}: expected a state dict or a checkpoint mapping, got "
91
+ f"{type(obj).__name__}"
92
+ )
93
+ if _is_tensor_mapping(obj):
94
+ return dict(obj)
95
+ for key in _STATE_DICT_KEYS:
96
+ inner = obj.get(key)
97
+ if isinstance(inner, Mapping) and _is_tensor_mapping(inner):
98
+ return dict(inner)
99
+ raise ExportError(
100
+ f"{origin}: no tensor mapping found at the top level or under any of "
101
+ f"{_STATE_DICT_KEYS}"
102
+ )
103
+
104
+
105
+ def _is_tensor_mapping(obj: Mapping) -> bool:
106
+ return bool(obj) and all(
107
+ isinstance(k, str) and isinstance(v, torch.Tensor) for k, v in obj.items()
108
+ )
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Checkpoint averaging
113
+ # ---------------------------------------------------------------------------
114
+ def average_checkpoints(paths: Sequence[Union[str, Path]]) -> StateDict:
115
+ """Uniform mean of several checkpoints, returned as a state dict.
116
+
117
+ This is how the released weights were made: the last
118
+ :data:`RELEASE_AVERAGE_N` periodic checkpoints of the training run, averaged
119
+ with equal weight. Floating-point tensors are accumulated in float32 in the
120
+ order given and cast back to the source dtype; integer and boolean entries (step
121
+ counters and the like) are taken from the last checkpoint in ``paths``, since a
122
+ mean of those is meaningless. Pass the checkpoints in training order so "last"
123
+ is the most recent one, and keep that order to reproduce the released weights
124
+ exactly: an eight-term float32 sum is not associative, so reordering ``paths``
125
+ moves the result by about one ulp.
126
+
127
+ All checkpoints must carry the same keys with the same shapes; a mismatch is an
128
+ error rather than a silent intersection, because a quietly dropped tensor would
129
+ export as a randomly initialized one.
130
+ """
131
+ paths = [Path(p) for p in paths]
132
+ if not paths:
133
+ raise ExportError("average_checkpoints needs at least one checkpoint")
134
+
135
+ states = [load_state_dict_safely(p) for p in paths]
136
+ reference = states[0]
137
+ for path, state in zip(paths[1:], states[1:]):
138
+ _assert_same_keys(reference, state, paths[0], path)
139
+
140
+ n = len(states)
141
+ averaged: StateDict = {}
142
+ for key, ref in reference.items():
143
+ tensors = [state[key] for state in states]
144
+ if ref.is_floating_point():
145
+ acc = tensors[0].to(torch.float32).clone()
146
+ for t in tensors[1:]:
147
+ acc += t.to(torch.float32)
148
+ averaged[key] = (acc / n).to(ref.dtype)
149
+ else:
150
+ averaged[key] = tensors[-1].clone()
151
+ return averaged
152
+
153
+
154
+ def _assert_same_keys(a: StateDict, b: StateDict, path_a: Path, path_b: Path) -> None:
155
+ if a.keys() != b.keys():
156
+ only_a = sorted(set(a) - set(b))[:5]
157
+ only_b = sorted(set(b) - set(a))[:5]
158
+ raise ExportError(
159
+ f"checkpoint key mismatch between {path_a.name} and {path_b.name}: "
160
+ f"only in the first {only_a}, only in the second {only_b}"
161
+ )
162
+ for key in a:
163
+ if a[key].shape != b[key].shape:
164
+ raise ExportError(
165
+ f"shape mismatch for {key!r}: {tuple(a[key].shape)} in "
166
+ f"{path_a.name} vs {tuple(b[key].shape)} in {path_b.name}"
167
+ )
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Weight tying
172
+ # ---------------------------------------------------------------------------
173
+ def tied_parameter_groups(model: nn.Module) -> Dict[str, List[str]]:
174
+ """Map each shared parameter's canonical name to the names aliasing it.
175
+
176
+ Two names alias when they resolve to the same ``nn.Parameter`` object, which is
177
+ what ``share_ffn`` produces. The canonical name is the one ``named_parameters``
178
+ keeps, i.e. the first in registration order; only that one is written to the
179
+ safetensors file.
180
+ """
181
+ canonical = {id(p): name for name, p in model.named_parameters()}
182
+ groups: Dict[str, List[str]] = {name: [] for name in canonical.values()}
183
+ for name, param in model.named_parameters(remove_duplicate=False):
184
+ head = canonical[id(param)]
185
+ if name != head:
186
+ groups[head].append(name)
187
+ return {head: alias for head, alias in groups.items() if alias}
188
+
189
+
190
+ def _assert_tied_values_agree(model: nn.Module, state: Mapping[str, torch.Tensor]) -> None:
191
+ """Refuse a state dict whose tied entries disagree.
192
+
193
+ Loading such a dict would keep whichever copy happened to be written last and
194
+ discard the others without a word.
195
+ """
196
+ for head, aliases in tied_parameter_groups(model).items():
197
+ if head not in state:
198
+ continue
199
+ for alias in aliases:
200
+ if alias in state and not torch.equal(state[head], state[alias]):
201
+ raise ExportError(
202
+ f"tied parameters disagree in the checkpoint: {alias!r} differs "
203
+ f"from {head!r}; the checkpoint does not come from this "
204
+ f"architecture"
205
+ )
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Export
210
+ # ---------------------------------------------------------------------------
211
+ def export_safetensors(
212
+ model_or_state_dict: Union[nn.Module, Mapping[str, torch.Tensor]],
213
+ out_dir: Union[str, Path],
214
+ config: Optional[TinyCastConfig] = None,
215
+ expect_parameters: Optional[int] = None,
216
+ ) -> Dict[str, object]:
217
+ """Write the released ``model.safetensors`` + ``config.json`` pair to ``out_dir``.
218
+
219
+ Accepts either a live :class:`~tinycast.model.TinyCastForPrediction` or a state
220
+ dict from a training run (in which case ``config`` says which architecture to
221
+ rebuild; it defaults to the released one). Tied FFN weights are written once
222
+ each and their aliases recorded in the safetensors metadata, so the file holds
223
+ the model's true 146,505-value footprint rather than the 321,225 values a
224
+ ``state_dict`` reports.
225
+
226
+ Pass ``expect_parameters`` to pin the count (146,505 for the released model);
227
+ the check is against a model instantiated from ``config``, never against a sum
228
+ of checkpoint tensors.
229
+
230
+ Re-exporting the released weights reproduces the published ``config.json`` and
231
+ the published ``model.safetensors`` tensor payload and tensor index byte for
232
+ byte. The header's metadata block is the one exception: the safetensors writer
233
+ emits those keys in an order of its own that varies between runs. The mapping
234
+ they encode is stable and is what the loader reads, so a byte diff of the header
235
+ is not a defect.
236
+
237
+ Returns a report with the two paths, the tensor and parameter counts, and the
238
+ number of aliases carried in the metadata.
239
+ """
240
+ from safetensors.torch import save_model
241
+
242
+ model, config = _as_model(model_or_state_dict, config)
243
+ model.eval()
244
+
245
+ n_parameters = sum(p.numel() for p in model.parameters())
246
+ if expect_parameters is not None and n_parameters != expect_parameters:
247
+ raise ExportError(
248
+ f"the model instantiated from this config has {n_parameters:,} "
249
+ f"parameters, not the expected {expect_parameters:,}"
250
+ )
251
+
252
+ out = Path(out_dir)
253
+ out.mkdir(parents=True, exist_ok=True)
254
+ weights_path = out / WEIGHTS_NAME
255
+ config_path = out / CONFIG_NAME
256
+
257
+ # save_model can derive the alias map itself, but its choice of which name in a
258
+ # sharing group to keep is its own. Seeding the map (save_model preserves
259
+ # entries already present) makes the canonical name the one named_parameters
260
+ # reports, and the map is verified against the written file below.
261
+ alias_map = {
262
+ alias: head
263
+ for head, aliases in tied_parameter_groups(model).items()
264
+ for alias in aliases
265
+ }
266
+ save_model(model, str(weights_path), metadata=dict(sorted(alias_map.items())))
267
+ # No trailing newline: the released config.json has none, and a byte-for-byte
268
+ # match lets a reader diff this export against the published artifact.
269
+ with open(config_path, "w") as f:
270
+ json.dump(config.to_dict(), f, indent=2)
271
+
272
+ n_tensors, file_sum, written_aliases = _inspect_safetensors(weights_path)
273
+ if file_sum != n_parameters:
274
+ raise ExportError(
275
+ f"{weights_path.name} holds {file_sum:,} values but the model has "
276
+ f"{n_parameters:,} parameters; the tie deduplication is wrong"
277
+ )
278
+ if written_aliases != alias_map:
279
+ raise ExportError(
280
+ f"{weights_path.name} records {len(written_aliases)} tied aliases, "
281
+ f"expected the {len(alias_map)} sharing groups of the model; loading it "
282
+ f"would leave weights uninitialized"
283
+ )
284
+
285
+ return {
286
+ "weights_path": weights_path,
287
+ "config_path": config_path,
288
+ "num_tensors": n_tensors,
289
+ "num_parameters": n_parameters,
290
+ "num_tied_aliases": len(written_aliases),
291
+ }
292
+
293
+
294
+ def _as_model(
295
+ model_or_state_dict: Union[nn.Module, Mapping[str, torch.Tensor]],
296
+ config: Optional[TinyCastConfig],
297
+ ) -> Tuple[TinyCastForPrediction, TinyCastConfig]:
298
+ if isinstance(model_or_state_dict, nn.Module):
299
+ model = model_or_state_dict
300
+ config = config or getattr(model, "config", None)
301
+ if config is None:
302
+ raise ExportError("the model carries no config; pass config=...")
303
+ return model, config
304
+
305
+ if not isinstance(model_or_state_dict, Mapping):
306
+ raise ExportError(
307
+ "expected a TinyCast model or a state dict, got "
308
+ f"{type(model_or_state_dict).__name__}"
309
+ )
310
+
311
+ config = config or TinyCastConfig()
312
+ model = TinyCastForPrediction(config)
313
+ state = dict(model_or_state_dict)
314
+ _assert_tied_values_agree(model, state)
315
+ missing, unexpected = model.load_state_dict(state, strict=False)
316
+ if missing or unexpected:
317
+ raise ExportError(
318
+ f"state dict does not match the architecture: {len(missing)} missing "
319
+ f"{sorted(missing)[:5]}, {len(unexpected)} unexpected "
320
+ f"{sorted(unexpected)[:5]}"
321
+ )
322
+ return model, config
323
+
324
+
325
+ def _inspect_safetensors(path: Path) -> Tuple[int, int, Dict[str, str]]:
326
+ """Return (tensor count, summed values, alias map) read back from the header."""
327
+ import struct
328
+
329
+ with open(path, "rb") as f:
330
+ header_len = struct.unpack("<Q", f.read(8))[0]
331
+ header = json.loads(f.read(header_len))
332
+ metadata = header.pop("__metadata__", {}) or {}
333
+ total = 0
334
+ for spec in header.values():
335
+ n = 1
336
+ for dim in spec["shape"]:
337
+ n *= dim
338
+ total += n
339
+ return len(header), total, metadata
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Round-trip verification
344
+ # ---------------------------------------------------------------------------
345
+ def fixed_batch(
346
+ batch_size: int = 2, seq_len: int = 2048, seed: int = 0
347
+ ) -> torch.Tensor:
348
+ """A deterministic context batch: two seasonal components plus fixed noise.
349
+
350
+ Seasonality is present on purpose, so the round trip exercises the period
351
+ detector and the phase-folding path rather than the convolutions alone.
352
+ """
353
+ generator = torch.Generator().manual_seed(seed)
354
+ t = torch.arange(seq_len, dtype=torch.float32).unsqueeze(0)
355
+ offsets = torch.arange(batch_size, dtype=torch.float32).unsqueeze(1)
356
+ daily = torch.sin(2 * math.pi * (t + 7 * offsets) / 24.0)
357
+ weekly = 0.3 * torch.sin(2 * math.pi * t / 168.0)
358
+ noise = 0.05 * torch.randn(batch_size, seq_len, generator=generator)
359
+ return 10.0 + 3.0 * daily + weekly + noise
360
+
361
+
362
+ @torch.no_grad()
363
+ def check_export_roundtrip(
364
+ model: nn.Module,
365
+ out_dir: Optional[Union[str, Path]] = None,
366
+ config: Optional[TinyCastConfig] = None,
367
+ expect_parameters: Optional[int] = None,
368
+ prediction_length: int = 48,
369
+ ) -> Dict[str, object]:
370
+ """Export ``model``, load it back, and verify nothing was lost.
371
+
372
+ Three things are checked. The reloaded model must produce bitwise-identical
373
+ quantiles on a fixed batch. The FFN tie must be restored as object identity and
374
+ not merely as equal values, since equal-but-separate tensors would triple the
375
+ deployed footprint. And the file's tensors must sum to the instantiated
376
+ parameter count rather than to the inflated ``state_dict`` sum.
377
+
378
+ ``out_dir`` defaults to a temporary directory. Pass ``expect_parameters``
379
+ (146,505 for the released model) to pin the count. Returns a report; every
380
+ failure raises :class:`ExportError`.
381
+ """
382
+ from .checkpoint import load_checkpoint
383
+
384
+ scratch = tempfile.TemporaryDirectory() if out_dir is None else nullcontext(out_dir)
385
+ with scratch as tmp:
386
+ target = Path(tmp)
387
+ report = export_safetensors(
388
+ model, target, config=config, expect_parameters=expect_parameters
389
+ )
390
+
391
+ model.eval()
392
+ x = fixed_batch(seq_len=int(model.config.seq_len))
393
+ reference = model(
394
+ past_values=x, scale_factor=1.0, prediction_length=prediction_length,
395
+ batch_first=True,
396
+ ).quantile_outputs
397
+
398
+ reloaded, _ = load_checkpoint(str(report["weights_path"]))
399
+ reloaded.eval()
400
+ replayed = reloaded(
401
+ past_values=x, scale_factor=1.0, prediction_length=prediction_length,
402
+ batch_first=True,
403
+ ).quantile_outputs
404
+
405
+ if replayed.shape != reference.shape:
406
+ raise ExportError(
407
+ f"reloaded model returns {tuple(replayed.shape)}, expected "
408
+ f"{tuple(reference.shape)}"
409
+ )
410
+ if not torch.equal(reference, replayed):
411
+ gap = (reference - replayed).abs().max().item()
412
+ raise ExportError(
413
+ f"reloaded model is not bitwise identical: max absolute difference {gap}"
414
+ )
415
+
416
+ expected_groups = tied_parameter_groups(model)
417
+ restored_groups = tied_parameter_groups(reloaded)
418
+ if restored_groups != expected_groups:
419
+ raise ExportError(
420
+ "the FFN weight tie was not restored on load: "
421
+ f"{sum(len(a) for a in restored_groups.values())} aliases share storage, "
422
+ f"expected {sum(len(a) for a in expected_groups.values())}"
423
+ )
424
+
425
+ naive_sum = sum(t.numel() for t in reloaded.state_dict().values())
426
+ if report["num_parameters"] >= naive_sum:
427
+ raise ExportError(
428
+ f"the export did not deduplicate: {report['num_parameters']:,} values "
429
+ f"written against a state dict sum of {naive_sum:,}"
430
+ )
431
+
432
+ report = dict(report)
433
+ report["state_dict_sum"] = naive_sum
434
+ report["bitwise_identical"] = True
435
+ return report
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # CLI
440
+ # ---------------------------------------------------------------------------
441
+ def main(argv: Optional[Iterable[str]] = None) -> int:
442
+ import argparse
443
+
444
+ parser = argparse.ArgumentParser(
445
+ prog="python -m tinycast.export",
446
+ description="Convert training checkpoints into the released "
447
+ "model.safetensors + config.json pair.",
448
+ )
449
+ parser.add_argument(
450
+ "--average", nargs="+", metavar="CKPT", required=True,
451
+ help=f"checkpoints to average, in training order (the release used "
452
+ f"the last {RELEASE_AVERAGE_N})",
453
+ )
454
+ parser.add_argument("--out-dir", required=True, help="directory to write into")
455
+ parser.add_argument(
456
+ "--config", default=None,
457
+ help="config.json to build the architecture from; defaults to the "
458
+ "released configuration",
459
+ )
460
+ parser.add_argument(
461
+ "--expect-parameters", type=int, default=None,
462
+ help="fail unless the instantiated model has this many parameters "
463
+ "(146505 for the released model)",
464
+ )
465
+ parser.add_argument(
466
+ "--no-check", action="store_true",
467
+ help="skip the reload / bitwise-identity round trip",
468
+ )
469
+ args = parser.parse_args(list(argv) if argv is not None else None)
470
+
471
+ config = TinyCastConfig()
472
+ if args.config:
473
+ with open(args.config) as f:
474
+ raw = json.load(f)
475
+ config = TinyCastConfig(**{
476
+ k: v for k, v in raw.items() if k in TinyCastConfig.__dataclass_fields__
477
+ })
478
+
479
+ if len(args.average) != RELEASE_AVERAGE_N:
480
+ print(
481
+ f"[export] averaging {len(args.average)} checkpoints; the released "
482
+ f"weights used {RELEASE_AVERAGE_N}",
483
+ flush=True,
484
+ )
485
+ state = average_checkpoints(args.average)
486
+ model, config = _as_model(state, config)
487
+
488
+ if args.no_check:
489
+ report = export_safetensors(
490
+ model, args.out_dir, config=config,
491
+ expect_parameters=args.expect_parameters,
492
+ )
493
+ else:
494
+ report = check_export_roundtrip(
495
+ model, args.out_dir, config=config,
496
+ expect_parameters=args.expect_parameters,
497
+ )
498
+
499
+ print(
500
+ f"[export] wrote {report['weights_path']} "
501
+ f"({report['num_tensors']} tensors, {report['num_parameters']:,} "
502
+ f"parameters, {report['num_tied_aliases']} tied aliases)",
503
+ flush=True,
504
+ )
505
+ print(f"[export] wrote {report['config_path']}", flush=True)
506
+ if not args.no_check:
507
+ print(
508
+ f"[export] round trip: bitwise identical, tie restored, "
509
+ f"{report['num_parameters']:,} values written against a state dict "
510
+ f"sum of {report['state_dict_sum']:,}",
511
+ flush=True,
512
+ )
513
+ return 0
514
+
515
+
516
+ if __name__ == "__main__": # pragma: no cover
517
+ raise SystemExit(main())
tinycast/losses.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training objectives for TinyCast.
2
+
3
+ Two objectives supervise the model. The nine-quantile pinball loss trains the
4
+ whole predictive distribution; the gated committing term acts on the median
5
+ alone and is the paper's own addition. ``seasonal_copy_baseline`` builds the
6
+ reference forecast the committing term measures the median against.
7
+
8
+ All three work in the model's normalized output space. ``TinyCastBackbone.encode``
9
+ returns ``(y_norm, x_min, x_range)``; normalize the target with those same
10
+ statistics before calling anything here, so predictions, targets and the
11
+ seasonal copy sit on one scale.
12
+
13
+ Each function is self-contained and takes plain tensors, so it can be exercised
14
+ without a model or a training loop.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Optional, Sequence, Union
19
+
20
+ import torch
21
+
22
+ # Cycles per canonical day, the constant the scale factor is expressed against:
23
+ # a sample's seasonal lag is BASE_SEASONALITY divided by its scale factor. It is
24
+ # defined once, in tinycast.scale, and re-exported here so the lag this module
25
+ # folds at and the factor the model is conditioned on cannot drift apart.
26
+ from .scale import BASE_SEASONALITY
27
+
28
+ # Weight of the committing term in the shipped recipe.
29
+ COMMIT_WEIGHT = 0.3
30
+
31
+ _Reduction = str
32
+
33
+
34
+ def _reduce(per_sample: torch.Tensor, reduction: _Reduction) -> torch.Tensor:
35
+ if reduction == "none":
36
+ return per_sample
37
+ if reduction == "mean":
38
+ return per_sample.mean()
39
+ if reduction == "sum":
40
+ return per_sample.sum()
41
+ raise ValueError(
42
+ f"Unknown reduction: {reduction!r}; expected 'mean', 'sum' or 'none'."
43
+ )
44
+
45
+
46
+ def _as_bh(target: torch.Tensor, name: str) -> torch.Tensor:
47
+ """Accept ``(B, H)`` or ``(B, H, 1)`` and return ``(B, H)``."""
48
+ if target.dim() == 3 and target.shape[-1] == 1:
49
+ return target.squeeze(-1)
50
+ if target.dim() != 2:
51
+ raise ValueError(
52
+ f"{name} must be (B, H) or (B, H, 1), got {tuple(target.shape)}."
53
+ )
54
+ return target
55
+
56
+
57
+ def _mask_like(mask: Optional[torch.Tensor], ref: torch.Tensor) -> torch.Tensor:
58
+ if mask is None:
59
+ return torch.ones_like(ref)
60
+ mask = _as_bh(mask, "mask").to(ref.dtype)
61
+ if mask.shape != ref.shape:
62
+ raise ValueError(
63
+ f"mask shape {tuple(mask.shape)} does not match "
64
+ f"{tuple(ref.shape)}."
65
+ )
66
+ return mask
67
+
68
+
69
+ def pinball_loss(
70
+ pred: torch.Tensor,
71
+ target: torch.Tensor,
72
+ quantiles: Union[Sequence[float], torch.Tensor],
73
+ mask: Optional[torch.Tensor] = None,
74
+ *,
75
+ reduction: _Reduction = "mean",
76
+ ) -> torch.Tensor:
77
+ """Quantile (pinball) loss over all quantile levels.
78
+
79
+ Args:
80
+ pred: ``(B, H, Q)`` normalized quantile forecasts, ordered to match
81
+ ``quantiles``.
82
+ target: ``(B, H)`` or ``(B, H, 1)`` normalized targets.
83
+ quantiles: the ``Q`` levels, e.g. ``config.quantiles``.
84
+ mask: ``(B, H)`` observedness, 1 for an observed target position and 0
85
+ otherwise. ``None`` treats every position as observed.
86
+ reduction: ``"mean"`` (default) averages over the batch, ``"sum"`` adds,
87
+ ``"none"`` returns the ``(B,)`` per-sample losses.
88
+
89
+ The per-sample loss divides by the observed count times ``Q``, so a sample
90
+ with few observed positions is not down-weighted against a full one, and a
91
+ sample with none contributes zero rather than a division by zero.
92
+
93
+ A non-finite target position is replaced by the detached median forecast, so
94
+ it contributes no loss and no gradient. Mask such positions out as well: the
95
+ substitution is a guard, not the mechanism.
96
+ """
97
+ if pred.dim() != 3:
98
+ raise ValueError(f"pred must be (B, H, Q), got {tuple(pred.shape)}.")
99
+ target = _as_bh(target, "target")
100
+ if pred.shape[:2] != target.shape:
101
+ raise ValueError(
102
+ f"pred {tuple(pred.shape)} and target {tuple(target.shape)} "
103
+ "disagree on batch or horizon."
104
+ )
105
+
106
+ q = torch.as_tensor(quantiles, dtype=pred.dtype, device=pred.device)
107
+ q = q.reshape(-1)
108
+ n_q = pred.shape[-1]
109
+ if q.numel() != n_q:
110
+ raise ValueError(
111
+ f"pred carries {n_q} quantile channels but {q.numel()} levels "
112
+ "were given."
113
+ )
114
+
115
+ q_mid = n_q // 2
116
+ target_b = target.unsqueeze(-1)
117
+ fill = pred[..., q_mid: q_mid + 1].detach()
118
+ target_safe = torch.where(torch.isfinite(target_b), target_b, fill)
119
+
120
+ err = target_safe - pred
121
+ q = q.view(1, 1, -1)
122
+ loss = torch.maximum(q * err, (q - 1.0) * err) # (B, H, Q)
123
+
124
+ obs = _mask_like(mask, target)
125
+ denom = obs.sum(dim=1).clamp(min=1.0) * float(n_q)
126
+ per_sample = (loss * obs.unsqueeze(-1)).sum(dim=(1, 2)) / denom
127
+ return _reduce(per_sample, reduction)
128
+
129
+
130
+ def seasonal_copy_baseline(
131
+ context: torch.Tensor,
132
+ horizon: int,
133
+ scale_factor: Union[torch.Tensor, float],
134
+ *,
135
+ base_seasonality: float = BASE_SEASONALITY,
136
+ ) -> torch.Tensor:
137
+ """Repeat the last seasonal cycle of the context over the horizon.
138
+
139
+ Args:
140
+ context: ``(B, L)`` or ``(B, L, 1)`` context values, in whatever space
141
+ the caller wants the copy in (raw or normalized).
142
+ horizon: ``H``, how many steps to emit.
143
+ scale_factor: per-sample or scalar seasonal scale factor ``s``, the
144
+ value ``tinycast.scale.seasonal_scale_factor`` returns for the
145
+ sample's frequency.
146
+ base_seasonality: numerator of the lag, 24 by convention.
147
+
148
+ The lag is ``round(base_seasonality / s)`` clipped to ``[2, L // 2]``, and
149
+ position ``h`` of the copy is context position ``L - lag + (h mod lag)``.
150
+ Returns ``(B, H)``.
151
+ """
152
+ if context.dim() == 3 and context.shape[-1] == 1:
153
+ context = context.squeeze(-1)
154
+ if context.dim() != 2:
155
+ raise ValueError(
156
+ f"context must be (B, L) or (B, L, 1), got {tuple(context.shape)}."
157
+ )
158
+ b, length = context.shape
159
+ horizon = int(horizon)
160
+ if horizon < 1:
161
+ raise ValueError(f"horizon must be positive, got {horizon}.")
162
+ if length < 4:
163
+ raise ValueError(f"context is too short to fold a cycle: L={length}.")
164
+
165
+ sf = torch.as_tensor(scale_factor, dtype=torch.float32, device=context.device)
166
+ sf = sf.reshape(-1)
167
+ if sf.numel() == 1:
168
+ sf = sf.expand(b)
169
+ elif sf.numel() != b:
170
+ raise ValueError(
171
+ f"scale_factor carries {sf.numel()} entries for a batch of {b}."
172
+ )
173
+
174
+ lag = (base_seasonality / sf.clamp(min=1e-3)).round().long()
175
+ lag = lag.clamp(2, max(2, length // 2)).view(-1, 1) # (B, 1)
176
+
177
+ h = torch.arange(horizon, device=context.device).view(1, horizon)
178
+ src = (length - lag + (h % lag)).clamp(0, length - 1) # (B, H)
179
+ return torch.gather(context, 1, src)
180
+
181
+
182
+ def committing_loss(
183
+ median: torch.Tensor,
184
+ target: torch.Tensor,
185
+ seasonal_copy: torch.Tensor,
186
+ mask: Optional[torch.Tensor] = None,
187
+ *,
188
+ weight: float = COMMIT_WEIGHT,
189
+ gated: bool = True,
190
+ reduction: _Reduction = "mean",
191
+ ) -> torch.Tensor:
192
+ """Gated committing term: penalize a median that hedges below the copy.
193
+
194
+ Args:
195
+ median: ``(B, H)`` normalized median forecast.
196
+ target: ``(B, H)`` normalized targets.
197
+ seasonal_copy: ``(B, H)`` the reference from
198
+ ``seasonal_copy_baseline``, in the same space as ``median``.
199
+ mask: ``(B, H)`` observedness; ``None`` treats every position as
200
+ observed.
201
+ weight: the multiplier the trainer applies, 0.3 in the shipped recipe.
202
+ gated: apply the window-level gate. With ``False`` the hinge is scored
203
+ on every window.
204
+ reduction: as in :func:`pinball_loss`.
205
+
206
+ The hinge ``relu(|median - target| - |copy - target|)`` is zero wherever the
207
+ median is already at least as close as the copy, so the term stops as soon
208
+ as the median reaches it. The gate multiplies the whole window by zero
209
+ unless the copy beats the median summed over the observed positions, which
210
+ keeps the term silent on windows the model already wins.
211
+
212
+ Positions where any of the three inputs is non-finite contribute nothing,
213
+ to the hinge and to the gate alike. The average is still taken over the
214
+ observed count, so masking is what sets the denominator.
215
+ """
216
+ median = _as_bh(median, "median")
217
+ target = _as_bh(target, "target")
218
+ seasonal_copy = _as_bh(seasonal_copy, "seasonal_copy")
219
+ if median.shape != target.shape or median.shape != seasonal_copy.shape:
220
+ raise ValueError(
221
+ f"median {tuple(median.shape)}, target {tuple(target.shape)} and "
222
+ f"seasonal_copy {tuple(seasonal_copy.shape)} must agree."
223
+ )
224
+
225
+ obs = _mask_like(mask, target)
226
+ med_err = (median - target).abs()
227
+ copy_err = (seasonal_copy - target).abs()
228
+
229
+ finite = (
230
+ torch.isfinite(med_err) & torch.isfinite(copy_err) & torch.isfinite(target)
231
+ ).to(obs.dtype)
232
+ scored = obs * finite
233
+
234
+ hinge = torch.relu(med_err - copy_err)
235
+ hinge = torch.where(torch.isfinite(hinge), hinge, torch.zeros_like(hinge))
236
+ denom = obs.sum(dim=1).clamp(min=1.0)
237
+ per_sample = (hinge * scored).sum(dim=1) / denom
238
+
239
+ if gated:
240
+ med_total = (torch.nan_to_num(med_err, 0.0, 0.0, 0.0) * scored).sum(dim=1)
241
+ copy_total = (torch.nan_to_num(copy_err, 0.0, 0.0, 0.0) * scored).sum(dim=1)
242
+ per_sample = per_sample * (copy_total < med_total).to(per_sample.dtype)
243
+
244
+ return _reduce(per_sample * float(weight), reduction)
245
+
246
+
247
+ __all__ = [
248
+ "BASE_SEASONALITY",
249
+ "COMMIT_WEIGHT",
250
+ "pinball_loss",
251
+ "seasonal_copy_baseline",
252
+ "committing_loss",
253
+ ]
tinycast/model.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyCast model assembly.
2
+
3
+ model(past_values, scale_factor, prediction_length, batch_first).quantile_outputs
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+
8
+ import torch
9
+ import torch.nn as nn
10
+
11
+ from .config import TinyCastConfig
12
+
13
+
14
+ @dataclass
15
+ class PredictionOutput:
16
+ quantile_outputs: torch.Tensor
17
+ prediction_outputs: torch.Tensor = None
18
+
19
+
20
+ class TinyCastBackbone(nn.Module):
21
+ """Inner model: per-window min-max norm -> dilated-conv core -> denorm."""
22
+
23
+ def __init__(self, config: TinyCastConfig):
24
+ super().__init__()
25
+ self.config = config
26
+ from .backbone import DilatedConvBackbone
27
+ from .normalization import WindowMinMax
28
+
29
+ self.core = DilatedConvBackbone(
30
+ seq_len=int(config.seq_len),
31
+ p_out=int(config.output_token_len),
32
+ n_quantiles=int(config.num_quantiles),
33
+ d=int(config.conv_dim),
34
+ n_layers=int(config.n_layers),
35
+ kernel=int(config.kernel_size),
36
+ ffn_mult=float(config.ffn_mult),
37
+ top_k_periods=int(config.top_k_periods),
38
+ significance_alpha=float(config.significance_alpha),
39
+ n_harmonics=int(config.n_harmonics),
40
+ pool_kind=str(config.pool_kind),
41
+ causal=bool(config.causal),
42
+ phase_bins=int(config.phase_bins),
43
+ decoder_depth=int(config.decoder_depth),
44
+ separable_conv=bool(config.separable_conv),
45
+ share_ffn=bool(config.share_ffn),
46
+ future_conv=bool(config.future_conv),
47
+ future_conv_layers=int(config.future_conv_layers),
48
+ future_conv_seed=int(config.future_conv_seed),
49
+ )
50
+ self.norm = WindowMinMax(eps_clamp=1e-5)
51
+
52
+ def encode(
53
+ self,
54
+ past_values: torch.Tensor,
55
+ batch_first: bool = True,
56
+ scale_factor: "torch.Tensor | float | None" = None,
57
+ horizon: "int | None" = None,
58
+ ) -> "tuple[torch.Tensor, torch.Tensor, torch.Tensor]":
59
+ """per-window min-max norm -> dilated-conv core -> normalized y."""
60
+ x = past_values
61
+ if not batch_first:
62
+ x = x.transpose(0, 1)
63
+ if x.dim() == 2:
64
+ x = x.unsqueeze(-1)
65
+
66
+ x_normed, x_min, x_range = self.norm.transform(x)
67
+ nan_mask = (~torch.isnan(x)).to(x.dtype)
68
+ y_norm = self.core(
69
+ x_normed, nan_mask=nan_mask, scale_factor=scale_factor,
70
+ horizon=horizon,
71
+ )
72
+ return y_norm, x_min, x_range
73
+
74
+
75
+ class TinyCastForPrediction(nn.Module):
76
+ """Top-level model container.
77
+
78
+ Usage:
79
+ model = TinyCastForPrediction(TinyCastConfig())
80
+ out = model(past_values=x, scale_factor=sf, prediction_length=pl,
81
+ batch_first=False)
82
+ quantiles = out.quantile_outputs # (B, Q, pred_len, 1)
83
+
84
+ NOTE: this ``forward`` is the SINGLE-SHOT arbitrary-horizon path. The
85
+ deployed GIFT-Eval inference uses the AR-rollout predictor
86
+ (``tinycast.predictor.TinyCastPredictor``), which calls ``self.model.encode``
87
+ in 48-step chunks. For horizons <= 48 the two paths coincide.
88
+ """
89
+
90
+ def __init__(self, config: TinyCastConfig):
91
+ super().__init__()
92
+ self.config = config
93
+ self.model = TinyCastBackbone(config)
94
+
95
+ def forward(self, past_values, scale_factor=None, prediction_length=None,
96
+ batch_first=None):
97
+ if scale_factor is None:
98
+ scale_factor = 1.0
99
+ if batch_first is None:
100
+ batch_first = True
101
+ ctx = past_values
102
+ if not batch_first:
103
+ ctx = ctx.transpose(0, 1)
104
+ if ctx.dim() == 2:
105
+ ctx = ctx.unsqueeze(-1)
106
+ ctx_len_max = int(self.config.seq_len)
107
+ ctx_in = ctx[:, -ctx_len_max:, :]
108
+ p_out_native = int(self.model.core.p_out)
109
+ pred_len = int(prediction_length) if prediction_length else p_out_native
110
+ y_norm, x_min, x_range = self.model.encode(
111
+ ctx_in, batch_first=True, scale_factor=scale_factor,
112
+ horizon=pred_len,
113
+ )
114
+ if y_norm.dim() == 2:
115
+ y_norm = y_norm.unsqueeze(-1)
116
+ y_pred = y_norm * x_range + x_min
117
+ quantile_outputs = y_pred.permute(0, 2, 1).unsqueeze(-1)
118
+ return PredictionOutput(quantile_outputs=quantile_outputs)
tinycast/normalization.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-context-window min-max normalization used by the encoder path."""
2
+ from __future__ import annotations
3
+
4
+ import torch
5
+ import torch.nn as nn
6
+
7
+
8
+ class WindowMinMax(nn.Module):
9
+ """Per-context-window min-max normalization for the encoder path.
10
+
11
+ Per-window statistics:
12
+
13
+ x_min = x.min(1, keepdim=True)[0].detach()
14
+ x_max = x.max(1, keepdim=True)[0].detach()
15
+ x_range = (x_max - x_min).clamp(min=1e-5).detach()
16
+ x_norm = (x - x_min) / x_range # → [0, 1]
17
+
18
+ Stats are detached: gradients do NOT flow through normalization.
19
+ Uses explicit ``transform`` / ``inverse_transform`` so the backbone can
20
+ hold the (x_min, x_range) tuple across encoder + decoder and apply the
21
+ inverse at loss / forecast time.
22
+ """
23
+
24
+ def __init__(self, eps_clamp: float = 1e-5) -> None:
25
+ super().__init__()
26
+ self.eps_clamp = eps_clamp
27
+
28
+ def transform(
29
+ self, x: torch.Tensor
30
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
31
+ """Normalize ``x: (B, L, 1)`` to [0, 1] per context window.
32
+
33
+ Returns ``(x_normalized, x_min, x_range)`` with stats detached and
34
+ ``x_range >= eps_clamp`` to avoid div-by-zero on constant series.
35
+
36
+ Robust to NaN / +inf / -inf in ``x``: those positions are filled
37
+ with 0 BEFORE computing min/max.
38
+ """
39
+ x_filled = torch.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
40
+ x_min = x_filled.min(dim=1, keepdim=True).values.detach()
41
+ x_max = x_filled.max(dim=1, keepdim=True).values.detach()
42
+ x_range = (x_max - x_min).clamp(min=self.eps_clamp).detach()
43
+ x_normalized = (x_filled - x_min) / x_range
44
+ return x_normalized, x_min, x_range
45
+
46
+ @staticmethod
47
+ def inverse_transform(
48
+ y_pred_normalized: torch.Tensor,
49
+ x_min: torch.Tensor,
50
+ x_range: torch.Tensor,
51
+ ) -> torch.Tensor:
52
+ """Un-normalize ``(B, p)`` predictions back to raw magnitude.
53
+
54
+ ``x_min`` / ``x_range`` come from a prior ``transform`` call and are
55
+ ``(B, 1, 1)``; the trailing dim is squeezed for broadcasting against
56
+ ``(B, p)``.
57
+ """
58
+ return y_pred_normalized * x_range.squeeze(-1) + x_min.squeeze(-1)
tinycast/periodogram.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Normalized-periodogram period detector.
2
+
3
+ A zero-parameter structural detector: it identifies the dominant seasonal
4
+ periods of each series via a significance-filtered normalized periodogram.
5
+ The dilated-conv encoder uses it to build its phase positional encoding.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ import torch
13
+ import torch.nn.functional as F
14
+
15
+
16
+ def significant_periods(
17
+ x: torch.Tensor,
18
+ *,
19
+ min_period: int = 2,
20
+ max_period: int | None = None,
21
+ top_k: int = 16,
22
+ significance_alpha: float = 0.05,
23
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
24
+ """Identify candidate periods via the normalized periodogram.
25
+
26
+ Score per frequency bin k:
27
+ I_norm[k] = |X[k]|² / sum_k' |X[k']|²
28
+
29
+ Under H_0 (white Gaussian noise), max(I_norm) follows an extreme-value
30
+ distribution. Peaks are filtered by a Bonferroni-corrected significance
31
+ threshold:
32
+
33
+ t_α = ln(N_bins / α) / N_bins
34
+
35
+ where N_bins is the number of valid frequency bins (data-determined,
36
+ not a knob) and α is the significance level (default 0.05). Peaks below
37
+ t_α are excluded by setting their score to -inf, so ``n_valid`` reflects
38
+ only periods that pass.
39
+
40
+ α is exposed for completeness but should normally stay at 0.05; smaller
41
+ (e.g. 0.01) is stricter, larger (0.10) laxer. Set α=1.0 to disable
42
+ filtering entirely.
43
+
44
+ Returns ``(periods, scores, n_valid)``: integer periods (0 = rejected),
45
+ per-slot scores, and the count of significant periods per sample.
46
+ """
47
+ B, L = x.shape
48
+ device = x.device
49
+ if max_period is None:
50
+ max_period = L // 2
51
+
52
+ with torch.amp.autocast(device_type=x.device.type if x.is_cuda else "cpu",
53
+ enabled=False):
54
+ x_f = x.float()
55
+ x_c = x_f - x_f.mean(dim=1, keepdim=True)
56
+ n_fft = 1 << int(math.ceil(math.log2(max(2, L))))
57
+ X = torch.fft.rfft(x_c, n=n_fft)
58
+ power = (X * X.conj()).real
59
+ power = power[:, 1:] # skip DC
60
+ n_bins = power.shape[1]
61
+ total = power.sum(dim=1, keepdim=True).clamp(min=1e-12)
62
+ I_norm = power / total
63
+
64
+ k_lo = max(0, (n_fft // max_period) - 1)
65
+ k_hi = min(n_bins - 1, max(0, (n_fft // max(2, min_period)) - 1))
66
+
67
+ I_left = I_norm[:, :-2]
68
+ I_mid = I_norm[:, 1:-1]
69
+ I_right = I_norm[:, 2:]
70
+ is_local_max = (I_mid > I_left) & (I_mid > I_right)
71
+ is_local_max_padded = F.pad(is_local_max, (1, 1), value=False)
72
+
73
+ valid = torch.zeros(n_bins, device=device, dtype=torch.bool)
74
+ if k_hi > k_lo:
75
+ valid[k_lo:k_hi + 1] = True
76
+ is_peak = is_local_max_padded & valid.unsqueeze(0)
77
+
78
+ neg_inf = torch.full_like(I_norm, float("-inf"))
79
+ scored = torch.where(is_peak, I_norm, neg_inf)
80
+
81
+ # Bonferroni-corrected significance threshold. N_bins = number of
82
+ # frequency bins eligible to compete; approximated by the full
83
+ # periodogram length (the local-max filter doesn't change the
84
+ # multiple-testing count substantively).
85
+ N_bins_eff = max(2, int(n_bins))
86
+ alpha = max(min(float(significance_alpha), 1.0), 1e-12)
87
+ sig_threshold = math.log(N_bins_eff / alpha) / N_bins_eff
88
+ scored = torch.where(
89
+ scored >= sig_threshold, scored, neg_inf,
90
+ )
91
+
92
+ scores, k_top = scored.topk(k=min(top_k, n_bins), dim=1)
93
+ freq_bins = k_top + 1 # undo the DC skip
94
+ # Round, don't truncate. The peak bin k for true period S has
95
+ # k_exact = n_fft / S. Truncating ``n_fft // k`` flips period 7 <-> 6
96
+ # for S=7 at L=2048 (depending on which side of the half-integer the
97
+ # peak lands), and that 14% period error becomes a full-cycle phase
98
+ # drift over a 48-step horizon.
99
+ periods_raw = torch.round(n_fft / freq_bins.clamp(min=1).float()).long()
100
+ finite = torch.isfinite(scores)
101
+ n_valid = finite.sum(dim=1).long()
102
+ periods = torch.where(finite, periods_raw, torch.zeros_like(periods_raw))
103
+
104
+ return periods.long(), scores, n_valid
tinycast/predictor.py ADDED
@@ -0,0 +1,518 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GIFT-Eval gluonts predictor for TinyCast.
2
+
3
+ ``TinyCastPredictor`` is the deployed predictor. It wraps the model in the
4
+ gluonts Predictor protocol and drives autoregressive-rollout decoding
5
+ (48-step chunks), flip-invariance symmetrization, NaN-imputation and
6
+ optional period-alignment downsampling.
7
+
8
+ Anything that changes what the predictor emits says so:
9
+
10
+ ``device`` a named device must exist. ``device=None`` is the only
11
+ request that selects one for you (CUDA when present, else
12
+ CPU); the resolved device is ``predictor.device``.
13
+ ``TINYCAST_INT8`` ``w8`` or ``w8a8`` post-training fake quantization, off by
14
+ default. Any other non-empty value is an error, not an
15
+ inert setting.
16
+ ``TINYCAST_TILT_K`` an eval-only probe that moves the emitted median off the
17
+ quantile grid, off (``0``) by default, with
18
+ ``TINYCAST_TILT_MODE`` in {``adaptive``, ``fixed``}.
19
+
20
+ The environment variables are read once, when the predictor is constructed, and
21
+ an engaged one prints a line naming itself. Reproducing the published numbers
22
+ means leaving them unset.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ import os
28
+ from typing import List, Optional, Tuple
29
+
30
+ import numpy as np
31
+ import torch
32
+ import torch.nn as nn
33
+
34
+ from .checkpoint import load_checkpoint
35
+ from .normalization import WindowMinMax
36
+ from .scale import seasonal_scale_factor
37
+
38
+ try:
39
+ from torch.amp import autocast as _autocast_fp
40
+ except Exception: # pragma: no cover
41
+ _autocast_fp = None
42
+
43
+
44
+ def _resolve_device(requested: Optional[str]) -> torch.device:
45
+ """Return the requested device, or say why it is unavailable.
46
+
47
+ ``None`` is the request to choose: CUDA when it is available, CPU otherwise.
48
+ Every other value is a requirement. Falling back to CPU behind the caller's
49
+ back drops bf16 autocast for fp32 and moves every forecast by roughly 1e-3,
50
+ which is small enough to read as a regression and large enough to fail an
51
+ assertion against the published aggregates, so an absent accelerator is an
52
+ error rather than a substitution.
53
+ """
54
+ if requested is None:
55
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
56
+ dev = torch.device(requested)
57
+ if dev.type == "cuda":
58
+ if not torch.cuda.is_available():
59
+ raise RuntimeError(
60
+ f"device={requested!r} was requested but "
61
+ "torch.cuda.is_available() is False. Pass device='cpu' to run "
62
+ "on CPU (fp32, and about 1e-3 off the published numbers), or "
63
+ "device=None to take whichever device is present."
64
+ )
65
+ if dev.index is not None and dev.index >= torch.cuda.device_count():
66
+ raise RuntimeError(
67
+ f"device={requested!r} was requested but only "
68
+ f"{torch.cuda.device_count()} CUDA device(s) are visible."
69
+ )
70
+ if dev.type == "mps" and not torch.backends.mps.is_available():
71
+ raise RuntimeError(
72
+ f"device={requested!r} was requested but MPS is unavailable. "
73
+ "Pass device='cpu' to run on CPU."
74
+ )
75
+ return dev
76
+
77
+
78
+ _INT8_MODES = ("w8", "w8a8")
79
+ _INT8_OFF = ("", "0", "off", "no", "false", "none")
80
+ _TILT_MODES = ("adaptive", "fixed")
81
+
82
+
83
+ def _resolve_int8() -> Optional[str]:
84
+ """Read ``TINYCAST_INT8``: the quantization mode, or ``None`` when off.
85
+
86
+ Only ``w8`` and ``w8a8`` name a scheme. A truthy value such as ``1`` names
87
+ no scheme, and picking one for the caller would quantize a run that asked
88
+ for something else, so an unrecognized value raises. The alternative is what
89
+ this used to do: leave the run in fp32 while the caller believes they are
90
+ measuring INT8.
91
+ """
92
+ raw = os.environ.get("TINYCAST_INT8", "").strip().lower()
93
+ if raw in _INT8_OFF:
94
+ return None
95
+ if raw not in _INT8_MODES:
96
+ raise ValueError(
97
+ f"TINYCAST_INT8={raw!r} is not a quantization mode. Use 'w8' "
98
+ "(per-channel INT8 weights) or 'w8a8' (+ per-tensor dynamic INT8 "
99
+ "activations), or unset it to run in floating point."
100
+ )
101
+ return raw
102
+
103
+
104
+ def _resolve_tilt() -> Tuple[float, str]:
105
+ """Read the tilt probe's variables, announcing an engaged one.
106
+
107
+ ``TINYCAST_INT8`` prints a line when it engages; this does the same, so
108
+ neither switch can invalidate a golden array without appearing in the log.
109
+ ``TINYCAST_TILT_MODE`` is checked only when the tilt is on, which keeps it
110
+ inert at ``K=0`` as documented.
111
+ """
112
+ raw = os.environ.get("TINYCAST_TILT_K", "").strip()
113
+ try:
114
+ k = float(raw) if raw else 0.0
115
+ except ValueError:
116
+ raise ValueError(
117
+ f"TINYCAST_TILT_K={raw!r} is not a number. Unset it, or set 0, to "
118
+ "emit the model's own median."
119
+ ) from None
120
+ mode = os.environ.get("TINYCAST_TILT_MODE", "adaptive").strip().lower()
121
+ if k != 0.0:
122
+ if mode not in _TILT_MODES:
123
+ raise ValueError(
124
+ f"TINYCAST_TILT_MODE={mode!r} is not a tilt rule. Use "
125
+ "'adaptive' (skew-following) or 'fixed'."
126
+ )
127
+ print(
128
+ f"[tilt] TINYCAST_TILT_K={k:g} {mode}: the emitted median is "
129
+ "re-interpolated off the quantile grid, so this is not the "
130
+ "published model's forecast (inert below 3 quantiles)",
131
+ flush=True,
132
+ )
133
+ return k, mode
134
+
135
+
136
+ def _numpy_fill(arr: np.ndarray) -> np.ndarray:
137
+ mask = np.isnan(arr)
138
+ idx = np.where(~mask, np.arange(mask.shape[1]), 0)
139
+ np.maximum.accumulate(idx, axis=1, out=idx)
140
+ return arr[np.arange(idx.shape[0])[:, None], idx]
141
+
142
+
143
+ class ARRolloutPredictor:
144
+ """Base predictor: AR rollout + flip + NaN-imputation + period downsample.
145
+
146
+ Subclasses install ``self.model`` (a callable ``(x, x_mark, y_mark)``).
147
+ ``device=None`` selects one; a named device must exist. Either way the
148
+ device that ran is ``self.device``.
149
+
150
+ Every setting that changes a forecast is a named parameter, so a keyword
151
+ that is not one raises. Absorbing unknown keywords would let a misspelling
152
+ such as ``force_flip_invarience=True`` construct a predictor with flip
153
+ symmetrization off and return a forecast that looks right and cannot be
154
+ reproduced.
155
+ """
156
+
157
+ def __init__(
158
+ self,
159
+ prediction_length: int,
160
+ device: Optional[str] = None,
161
+ seq_len: int = 2048,
162
+ input_token_len: int = 2048,
163
+ output_token_len: int = 48,
164
+ num_samples: int = 100,
165
+ batch_size: int = 256,
166
+ use_amp: int = 1,
167
+ downsample_factor: int = 1,
168
+ force_flip_invariance: bool = False,
169
+ adaptive_ar_rollout: bool = False,
170
+ ):
171
+ self.device = _resolve_device(device)
172
+ self.prediction_length = int(prediction_length)
173
+ self.num_samples = int(num_samples)
174
+ self.batch_size = int(batch_size)
175
+ self.seq_len = int(seq_len)
176
+ self.input_token_len = int(input_token_len)
177
+ self.output_token_len = int(output_token_len)
178
+ self.use_amp = int(use_amp)
179
+ self.downsample_factor = int(downsample_factor)
180
+ self.force_flip_invariance = bool(force_flip_invariance)
181
+ self.adaptive_ar_rollout = bool(adaptive_ar_rollout)
182
+ # Quantile levels emitted by the model (set by subclasses from cfg).
183
+ self.quantiles = [0.5]
184
+ self.model = None
185
+
186
+ def _downsample_if_needed(
187
+ self, series: torch.Tensor
188
+ ) -> Tuple[torch.Tensor, int]:
189
+ cur = series
190
+ if self.downsample_factor > 1:
191
+ cur = cur[::self.downsample_factor]
192
+ return cur, self.downsample_factor
193
+
194
+ def _left_pad_to_len(
195
+ self, arr: np.ndarray, target_len: int
196
+ ) -> Tuple[np.ndarray, int]:
197
+ if arr.shape[0] >= target_len:
198
+ return arr[-target_len:], 0
199
+ pad_len = target_len - arr.shape[0]
200
+ fill_value = arr[0] if arr.shape[0] > 0 else 0.0
201
+ padding = np.full((pad_len,), fill_value, dtype=arr.dtype)
202
+ return np.concatenate([padding, arr], axis=0), pad_len
203
+
204
+ def _prepare_context_matrix(
205
+ self, context: List[torch.Tensor]
206
+ ) -> Tuple[torch.Tensor, List[int]]:
207
+ xs = []
208
+ downsample_factors = []
209
+ for c in context:
210
+ cur, df = self._downsample_if_needed(c)
211
+ downsample_factors.append(df)
212
+
213
+ cur_np = cur.detach().cpu().float().numpy()
214
+ cur_np, _ = self._left_pad_to_len(cur_np, self.seq_len)
215
+
216
+ x2d = cur_np[None, :]
217
+ x_interp = np.copy(x2d)
218
+ series = x2d[0]
219
+ if np.any(np.isnan(series)):
220
+ valid_mask = ~np.isnan(series)
221
+ if np.sum(valid_mask) >= 2:
222
+ valid_idx = np.where(valid_mask)[0]
223
+ valid_val = series[valid_mask]
224
+ x_interp[0] = np.interp(
225
+ np.arange(len(series)), valid_idx, valid_val
226
+ )
227
+ else:
228
+ x_interp = _numpy_fill(x2d)
229
+ ff = _numpy_fill(x_interp)
230
+ bf = np.flip(_numpy_fill(np.flip(x_interp, axis=1)), axis=1)
231
+ x_imp = np.where(np.isnan(ff), bf, ff)
232
+ x_imp = np.where(np.isnan(x_imp), 0.0, x_imp)
233
+ xs.append(x_imp[0])
234
+
235
+ x = torch.tensor(
236
+ np.stack(xs), device=self.device, dtype=torch.float32
237
+ ).unsqueeze(-1)
238
+ return x, downsample_factors
239
+
240
+ def _decode_autoregressive(
241
+ self,
242
+ init_ctx: torch.Tensor,
243
+ use_bf16: bool,
244
+ downsample_factors: List[int],
245
+ ) -> torch.Tensor:
246
+ B, _, C = init_ctx.shape
247
+ roll_len = int(self.output_token_len)
248
+
249
+ if self.adaptive_ar_rollout:
250
+ try:
251
+ from .periodogram import significant_periods
252
+ periods, _s, _nv = significant_periods(
253
+ init_ctx[:, -self.seq_len:, 0].float(),
254
+ min_period=2, max_period=self.seq_len // 2, top_k=1,
255
+ )
256
+ pos = periods[periods > 0]
257
+ if pos.numel() > 0:
258
+ p0 = int(pos.float().median().item())
259
+ if 2 <= p0 < roll_len:
260
+ roll_len = max(4, p0)
261
+ except Exception:
262
+ pass
263
+
264
+ target_pred_lens = [
265
+ int(self.prediction_length) // int(max(1, df))
266
+ for df in downsample_factors
267
+ ]
268
+ max_target_pred_len = max(target_pred_lens)
269
+ steps = math.ceil(max_target_pred_len / roll_len)
270
+ preds: List[torch.Tensor] = []
271
+ batch_ctx = init_ctx
272
+
273
+ y_mark = torch.zeros(
274
+ B, self.output_token_len, C,
275
+ device=self.device, dtype=init_ctx.dtype,
276
+ )
277
+
278
+ for _ in range(steps):
279
+ x_in = batch_ctx[:, -self.seq_len:, :]
280
+ x_mark = torch.zeros_like(x_in)
281
+ if _autocast_fp is not None and self.use_amp and use_bf16:
282
+ try:
283
+ with _autocast_fp("cuda", dtype=torch.bfloat16):
284
+ out = self.model(x_in, x_mark, y_mark)
285
+ except Exception:
286
+ out = self.model(x_in, x_mark, y_mark)
287
+ else:
288
+ out = self.model(x_in, x_mark, y_mark)
289
+ chunk = out[:, -self.output_token_len:, :][:, :roll_len, :] # (B, roll, Q)
290
+ preds.append(chunk)
291
+ # Feed only the MEDIAN quantile back into the context (the AR state
292
+ # is a univariate series); keep all Q in preds for the forecast.
293
+ q_mid = chunk.shape[-1] // 2
294
+ batch_ctx = torch.cat([batch_ctx, chunk[:, :, q_mid:q_mid + 1]], dim=1)
295
+
296
+ return torch.cat(preds, dim=1) # (B, pl, Q)
297
+
298
+ @torch.no_grad()
299
+ def predict(self, test_data_input, use_bf16_if_available: bool = True):
300
+ from gluonts.itertools import batcher
301
+ from gluonts.model.forecast import SampleForecast, QuantileForecast
302
+
303
+ forecasts: List = []
304
+ use_bf16 = bool(
305
+ use_bf16_if_available
306
+ and self.device.type == "cuda"
307
+ and torch.cuda.is_available()
308
+ and torch.cuda.is_bf16_supported()
309
+ )
310
+
311
+ for batch in batcher(test_data_input, batch_size=self.batch_size):
312
+ targets = [
313
+ torch.tensor(entry["target"], dtype=torch.float32)
314
+ for entry in batch
315
+ ]
316
+ batch_ctx, dfs = self._prepare_context_matrix(targets)
317
+ pred_pos = self._decode_autoregressive(batch_ctx, use_bf16, dfs) # (B,pl,Q)
318
+ if self.force_flip_invariance:
319
+ pred_neg = self._decode_autoregressive(-batch_ctx, use_bf16, dfs)
320
+ # Flip-symmetrize. For quantiles the tau-quantile of -y is
321
+ # -(the (1-tau)-quantile of y), so reverse the quantile axis on
322
+ # the negated branch. Q=1 (median) reverse is a no-op.
323
+ pred = 0.5 * (pred_pos - pred_neg.flip(dims=[-1]))
324
+ else:
325
+ pred = pred_pos
326
+
327
+ Q = pred.shape[-1]
328
+ pred_np = pred.float().detach().cpu().numpy() # (B, pl, Q)
329
+ if not np.isfinite(pred_np).all():
330
+ for qi in range(Q):
331
+ pred_np[:, :, qi] = _numpy_fill(pred_np[:, :, qi])
332
+
333
+ for i, ts in enumerate(batch):
334
+ df = int(max(1, dfs[i]))
335
+ target_pl = int(self.prediction_length) // df
336
+ arr = pred_np[i, :target_pl, :] # (target_pl, Q)
337
+ if df > 1:
338
+ new_len = int(self.prediction_length)
339
+ src = np.linspace(0, 1, arr.shape[0])
340
+ dst = np.linspace(0, 1, new_len)
341
+ arr = np.stack([np.interp(dst, src, arr[:, qi])
342
+ for qi in range(Q)], axis=1) # (new_len, Q)
343
+ start_date = ts["start"] + len(ts["target"])
344
+ if Q > 1:
345
+ # Sort across quantiles to guarantee non-crossing, then a
346
+ # QuantileForecast so gluonts scores WQL/CRPS over the deciles.
347
+ arr = np.sort(arr, axis=1)
348
+ forecasts.append(QuantileForecast(
349
+ forecast_arrays=arr.T, # (Q, pl)
350
+ start_date=start_date,
351
+ forecast_keys=[str(q) for q in self.quantiles],
352
+ ))
353
+ else:
354
+ samples = np.repeat(arr[:, 0][None, :], self.num_samples, axis=0)
355
+ forecasts.append(
356
+ SampleForecast(samples=samples, start_date=start_date)
357
+ )
358
+
359
+ return forecasts
360
+
361
+
362
+ class _BackboneAdapter(nn.Module):
363
+ """Adapts the model wrapper to the ``(x, x_mark, y_mark) -> (B, p, Q)`` contract."""
364
+
365
+ def __init__(self, backbone: nn.Module, scale_factor: float = 1.0):
366
+ super().__init__()
367
+ self.backbone = backbone
368
+ self.scale_factor = float(scale_factor)
369
+ # single-shot counterfactual: emit this many steps in ONE forward.
370
+ self.pred_len_override = None
371
+ # Resolved once, and announced when on: a forecast must not change
372
+ # because a variable was exported after the predictor was built.
373
+ self.tilt_k, self.tilt_mode = _resolve_tilt()
374
+
375
+ def forward(self, x, x_mark=None, y_mark=None, **kwargs):
376
+ y_norm, x_min, x_range = self.backbone.encode(
377
+ x, batch_first=True, scale_factor=self.scale_factor,
378
+ horizon=self.pred_len_override,
379
+ )
380
+ # Match training: the chunk loss clamps y_norm to [-5,5] before the
381
+ # pinball loss, so the model is never optimized outside that band.
382
+ # Clamp at inference too: otherwise an un-penalized overshoot feeds
383
+ # back into the AR rollout context and compounds over chunks.
384
+ y_norm = y_norm.clamp(-5.0, 5.0)
385
+
386
+ # num_quantiles > 1 => y_norm is (B, p, Q). Manual inverse since
387
+ # WindowMinMax.inverse_transform squeezes x_min/x_range for the (B, p)
388
+ # point case; x_min/x_range are (B, 1, 1) so they broadcast directly.
389
+ if y_norm.dim() == 3:
390
+ tk = self.tilt_k
391
+ Q = y_norm.shape[-1]
392
+ if tk != 0.0 and Q >= 3:
393
+ # EVAL-ONLY de-hedge probe (no training); default off (tk=0).
394
+ qm = Q // 2
395
+ lo = y_norm[..., 0]; hi = y_norm[..., -1]; med = y_norm[..., qm]
396
+ if self.tilt_mode == "fixed":
397
+ tau = torch.full_like(med, 0.5 + tk)
398
+ else: # skew-adaptive
399
+ asym = (hi + lo - 2 * med) / (hi - lo).abs().clamp(min=1e-6)
400
+ tau = 0.5 + tk * torch.tanh(asym) # >0 right-skew
401
+ tau = tau.clamp(qm / (Q + 1.0), (qm + 2) / (Q + 1.0))
402
+ idxf = (tau * (Q + 1) - 1).clamp(0, Q - 1 - 1e-4)
403
+ ilo = idxf.floor().long().clamp(0, Q - 2)
404
+ frac = (idxf - ilo.to(idxf.dtype)).clamp(0, 1)
405
+ qlo = torch.gather(y_norm, -1, ilo.unsqueeze(-1)).squeeze(-1)
406
+ qhi = torch.gather(y_norm, -1, (ilo + 1).unsqueeze(-1)).squeeze(-1)
407
+ y_norm = y_norm.clone()
408
+ y_norm[..., qm] = qlo * (1 - frac) + qhi * frac
409
+ return y_norm * x_range + x_min # (B, p, Q)
410
+ y_pred = WindowMinMax.inverse_transform(y_norm, x_min, x_range)
411
+ return y_pred.unsqueeze(-1) # (B, p, 1)
412
+
413
+
414
+ class TinyCastPredictor(ARRolloutPredictor):
415
+ """The deployed predictor: AR-rollout decoding around the TinyCast model."""
416
+
417
+ def __init__(
418
+ self,
419
+ prediction_length: int,
420
+ checkpoint_path: str,
421
+ device: Optional[str] = None,
422
+ num_samples: int = 100,
423
+ batch_size: int = 256,
424
+ use_amp: int = 1,
425
+ downsample_factor: int = 1,
426
+ force_flip_invariance: bool = False,
427
+ freq: Optional[str] = None,
428
+ domain: Optional[str] = None,
429
+ no_daily: bool = False,
430
+ single_shot: bool = False,
431
+ adaptive_ar_rollout: bool = False,
432
+ config_path: Optional[str] = None,
433
+ ):
434
+ self.single_shot = bool(single_shot)
435
+ # 1. Load the weights (safetensors + config.json).
436
+ model, cfg = load_checkpoint(checkpoint_path, config_path)
437
+
438
+ # 2. Eval-time scale_factor from (freq, domain). bizitobs_l2c has no
439
+ # daily cycle -> /7.
440
+ if freq is not None and domain is not None:
441
+ sf_eval = seasonal_scale_factor(freq, domain)
442
+ if no_daily:
443
+ sf_eval /= 7
444
+ else:
445
+ sf_eval = 1.0
446
+ self._eval_scale_factor = float(sf_eval)
447
+
448
+ # 3. Base predictor bookkeeping (drives AR-rollout / batching / flip).
449
+ super().__init__(
450
+ prediction_length=prediction_length,
451
+ device=device,
452
+ seq_len=int(cfg.seq_len),
453
+ input_token_len=int(cfg.seq_len),
454
+ output_token_len=int(cfg.output_token_len),
455
+ num_samples=num_samples,
456
+ batch_size=batch_size,
457
+ use_amp=use_amp,
458
+ downsample_factor=downsample_factor,
459
+ force_flip_invariance=force_flip_invariance,
460
+ adaptive_ar_rollout=adaptive_ar_rollout,
461
+ )
462
+ self.quantiles = list(getattr(cfg, "quantiles", [0.5])) or [0.5]
463
+ self._missing_aware = bool(getattr(cfg, "missing_channel", False))
464
+
465
+ # 4. Install the model wrapped in the adapter interface.
466
+ backbone = model.model # TinyCastForPrediction -> TinyCastBackbone
467
+ backbone.to(self.device).eval()
468
+ # Optional INT8 post-training fake-quant. Off by default;
469
+ # TINYCAST_INT8=w8 (weights) or w8a8 (+ dynamic activations).
470
+ # quantize_int8_ prints the mode it applied.
471
+ _int8 = _resolve_int8()
472
+ if _int8 is not None:
473
+ from .quant import quantize_int8_
474
+ quantize_int8_(backbone, _int8)
475
+ self.model = _BackboneAdapter(
476
+ backbone, scale_factor=self._eval_scale_factor,
477
+ ).to(self.device).eval()
478
+ if self.single_shot:
479
+ self.model.pred_len_override = int(prediction_length)
480
+
481
+ def _decode_autoregressive(self, init_ctx, use_bf16, downsample_factors):
482
+ """Single-shot override: one forward for the full horizon (no AR loop).
483
+ Default deployed behavior (single_shot=False) falls back to the base
484
+ AR rollout."""
485
+ if not self.single_shot:
486
+ return super()._decode_autoregressive(init_ctx, use_bf16, downsample_factors)
487
+ x_in = init_ctx[:, -self.seq_len:, :]
488
+ x_mark = torch.zeros_like(x_in)
489
+ use_cuda = bool(self.use_amp and use_bf16 and str(self.device).startswith("cuda"))
490
+ if use_cuda:
491
+ with torch.autocast("cuda", dtype=torch.bfloat16):
492
+ out = self.model(x_in, x_mark, None)
493
+ else:
494
+ out = self.model(x_in, x_mark, None)
495
+ return out # (B, prediction_length, 1)
496
+
497
+ def _prepare_context_matrix(self, context):
498
+ """Missing-aware override: keep genuine gaps as NaN so the observed-mask
499
+ + observed-only normalization see true missingness. Falls back to the
500
+ base (interpolating) behavior when the model is not missing-aware
501
+ (the deployed model is not)."""
502
+ if not getattr(self, "_missing_aware", False):
503
+ return super()._prepare_context_matrix(context)
504
+ xs, dfs = [], []
505
+ for c in context:
506
+ cur, df = self._downsample_if_needed(c)
507
+ dfs.append(df)
508
+ a = cur.detach().cpu().float().numpy()
509
+ if a.shape[0] >= self.seq_len:
510
+ a = a[-self.seq_len:]
511
+ else:
512
+ pad = np.full((self.seq_len - a.shape[0],), np.nan, dtype=a.dtype)
513
+ a = np.concatenate([pad, a], axis=0)
514
+ xs.append(a)
515
+ x = torch.tensor(
516
+ np.stack(xs), device=self.device, dtype=torch.float32
517
+ ).unsqueeze(-1)
518
+ return x, dfs
tinycast/quant.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Post-training INT8 quantization (fake-quant) for TinyCast.
2
+
3
+ Measures the GIFT-Eval accuracy of an INT8-deployed model by simulating INT8 arithmetic in
4
+ floating point. Per-output-channel symmetric INT8 weights on every Linear and Conv1d; optional
5
+ per-tensor dynamic INT8 activations. RMSNorm, the SiLU gate, the min-max (de)normalization,
6
+ and the rFFT period detector stay in full precision: they are off the convolutional mixing path
7
+ and run as fp/LUT ops on the target runtime.
8
+
9
+ Modes (env ``TINYCAST_INT8``):
10
+ ``w8`` per-channel INT8 weights, fp activations. The ~145 KB weight footprint; isolates the
11
+ weight-quantization error.
12
+ ``w8a8`` + per-tensor dynamic INT8 activations (scale from each tensor's own range, so no
13
+ calibration set is needed; an optimistic but faithful estimate of full INT8 compute).
14
+
15
+ The weight quant is applied in-place to the parameter tensors, so it is correct regardless of
16
+ whether a module is invoked via ``__call__`` or functionally (the separable pointwise conv is run
17
+ as ``F.linear(weight.squeeze(-1))``). Activation quant (``w8a8``) uses a forward-pre-hook on every
18
+ Linear/Conv1d to quantize inputs, plus a forward-hook on Conv1d to quantize the depthwise output
19
+ that feeds the functional pointwise, covering every activation site on the mixing path.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import torch
24
+ import torch.nn as nn
25
+
26
+ _QMIN, _QMAX = -128, 127 # int8 symmetric (zero-point 0)
27
+
28
+
29
+ @torch.no_grad()
30
+ def _fq_weight_per_outchannel(w: torch.Tensor) -> torch.Tensor:
31
+ """Symmetric per-output-channel (axis 0) int8 fake-quant of a weight tensor.
32
+
33
+ Linear weight is (out, in); Conv1d weight is (out, in/groups, k). Axis 0 is the output
34
+ channel in both, so a per-axis-0 scale is the standard per-channel weight scheme.
35
+ """
36
+ red = tuple(d for d in range(w.dim()) if d != 0)
37
+ amax = w.abs().amax(dim=red, keepdim=True).clamp_(min=1e-12)
38
+ scale = amax / _QMAX
39
+ return (torch.round(w / scale).clamp_(_QMIN, _QMAX) * scale).to(w.dtype)
40
+
41
+
42
+ def _fq_act_dynamic(x: torch.Tensor) -> torch.Tensor:
43
+ """Per-tensor symmetric int8 fake-quant with a dynamic (this-tensor) scale."""
44
+ if not torch.is_floating_point(x):
45
+ return x
46
+ amax = x.detach().abs().amax().clamp(min=1e-12)
47
+ scale = amax / _QMAX
48
+ return torch.round(x / scale).clamp(_QMIN, _QMAX) * scale
49
+
50
+
51
+ def _pre_hook(_mod, inp):
52
+ if not inp:
53
+ return None
54
+ return (_fq_act_dynamic(inp[0]),) + tuple(inp[1:])
55
+
56
+
57
+ def _post_hook(_mod, _inp, out):
58
+ return _fq_act_dynamic(out)
59
+
60
+
61
+ def quantize_int8_(model: nn.Module, mode: str = "w8") -> nn.Module:
62
+ """In-place INT8 fake-quant of ``model``. ``mode`` in {"w8", "w8a8"}. Returns ``model``."""
63
+ mode = mode.strip().lower()
64
+ if mode not in ("w8", "w8a8"):
65
+ raise ValueError(f"unknown INT8 mode {mode!r} (expected 'w8' or 'w8a8')")
66
+ n_w = 0
67
+ with torch.no_grad():
68
+ for m in model.modules():
69
+ if isinstance(m, (nn.Linear, nn.Conv1d)):
70
+ m.weight.data.copy_(_fq_weight_per_outchannel(m.weight.data))
71
+ n_w += 1
72
+ if mode == "w8a8":
73
+ m.register_forward_pre_hook(_pre_hook)
74
+ if isinstance(m, nn.Conv1d):
75
+ m.register_forward_hook(_post_hook)
76
+ print(
77
+ f"[quant] INT8 {mode}: fake-quantized {n_w} Linear/Conv1d weight tensors"
78
+ + (" + per-tensor dynamic activation quant" if mode == "w8a8" else ""),
79
+ flush=True,
80
+ )
81
+ return model
tinycast/reference/dataset_properties.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"m4_yearly": {"domain": "Econ/Fin", "frequency": "A", "num_variates": 1}, "m4_quarterly": {"domain": "Econ/Fin", "frequency": "Q", "num_variates": 1}, "m4_monthly": {"domain": "Econ/Fin", "frequency": "M", "num_variates": 1}, "m4_weekly": {"domain": "Econ/Fin", "frequency": "W", "num_variates": 1}, "m4_daily": {"domain": "Econ/Fin", "frequency": "D", "num_variates": 1}, "m4_hourly": {"domain": "Econ/Fin", "frequency": "H", "num_variates": 1}, "electricity": {"domain": "Energy", "frequency": "W", "num_variates": 1}, "ett1": {"domain": "Energy", "frequency": "W", "num_variates": 7}, "ett2": {"domain": "Energy", "frequency": "W", "num_variates": 7}, "solar": {"domain": "Energy", "frequency": "W", "num_variates": 1}, "hospital": {"domain": "Healthcare", "frequency": "M", "num_variates": 1}, "covid_deaths": {"domain": "Healthcare", "frequency": "D", "num_variates": 1}, "us_births": {"domain": "Healthcare", "frequency": "M", "num_variates": 1}, "saugeen": {"domain": "Nature", "frequency": "M", "num_variates": 1}, "temperature_rain": {"domain": "Nature", "frequency": "D", "num_variates": 1}, "kdd_cup_2018": {"domain": "Nature", "frequency": "D", "num_variates": 1}, "jena_weather": {"domain": "Nature", "frequency": "D", "num_variates": 21}, "car_parts": {"domain": "Sales", "frequency": "M", "num_variates": 1}, "restaurant": {"domain": "Sales", "frequency": "D", "num_variates": 1}, "hierarchical_sales": {"domain": "Sales", "frequency": "W-WED", "num_variates": 1}, "loop_seattle": {"domain": "Transport", "frequency": "D", "num_variates": 1}, "sz_taxi": {"domain": "Transport", "frequency": "H", "num_variates": 1}, "m_dense": {"domain": "Transport", "frequency": "D", "num_variates": 1}, "bitbrains_fast_storage": {"domain": "Web/CloudOps", "frequency": "H", "num_variates": 2}, "bitbrains_rnd": {"domain": "Web/CloudOps", "frequency": "H", "num_variates": 2}, "bizitobs_application": {"domain": "Web/CloudOps", "frequency": "10S", "num_variates": 2}, "bizitobs_service": {"domain": "Web/CloudOps", "frequency": "10S", "num_variates": 2}, "bizitobs_l2c": {"domain": "Web/CloudOps", "frequency": "H", "num_variates": 7}}
tinycast/reference/gift_eval_tinycast.csv ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ dataset,model,eval_metrics/MSE[mean],eval_metrics/MSE[0.5],eval_metrics/MAE[0.5],eval_metrics/MASE[0.5],eval_metrics/MAPE[0.5],eval_metrics/sMAPE[0.5],eval_metrics/MSIS,eval_metrics/RMSE[mean],eval_metrics/NRMSE[mean],eval_metrics/ND[0.5],eval_metrics/mean_weighted_sum_quantile_loss,domain,num_variates
2
+ loop_seattle/5T/short,TinyCast,44.24408692791981,44.24408692791981,3.7410070822826973,0.5934177626553161,0.10333592953204616,0.0790137582765152,4.834083147652098,6.651622879261858,0.11406064670352252,0.06415001194039043,0.05139483459546802,Transport,1
3
+ loop_seattle/5T/medium,TinyCast,98.20614629692338,98.20614629692338,5.956920582994711,0.9436984543863005,0.20160873373718577,0.132715571219461,9.629173740418173,9.909901427205186,0.17631750976206734,0.10598585775643184,0.08725979064788297,Transport,1
4
+ loop_seattle/5T/long,TinyCast,113.45040647216489,113.45040647216489,6.401010942068713,1.0107825740241352,0.21699007191552494,0.14103020212133877,12.857768236014797,10.65131008243422,0.1883173522634331,0.1131711895617032,0.09563315974138765,Transport,1
5
+ loop_seattle/D/short,TinyCast,18.663410946557644,18.663410946557644,2.926714380065477,0.883553479359932,0.054177088301986365,0.05405887580631441,7.380008644327176,4.32011700611889,0.07720626099359222,0.0523042949903881,0.042698347325786395,Transport,1
6
+ loop_seattle/H/short,TinyCast,63.06633140725992,63.06633140725992,4.349558852172275,0.8845625595564358,0.11818351171152795,0.09950442329946693,6.687267308602856,7.941431319810046,0.1405714099768551,0.07699161473599359,0.06068601926596376,Transport,1
7
+ loop_seattle/H/medium,TinyCast,72.35113097073739,72.35113097073739,4.853164616162563,1.0019077460097932,0.13148091333938458,0.10949005741083954,8.615747092001905,8.505946800370749,0.15034165879301567,0.08577914204185315,0.06962011250734562,Transport,1
8
+ loop_seattle/H/long,TinyCast,65.8910875654401,65.8910875654401,4.646866349678578,0.9738467480194081,0.14407944582482157,0.10641190073192427,10.483142999498524,8.11733254003063,0.14506757706093826,0.08304570977590309,0.06843967546853323,Transport,1
9
+ m_dense/D/short,TinyCast,10480.7,10480.7,53.51029947916667,0.7748143251363052,0.1382961343888268,0.11246461091218171,7.494234233556761,102.37528998737928,0.17722676315524236,0.09263423989645812,0.07786557125473008,Transport,1
10
+ m_dense/H/short,TinyCast,51430.0996875,51430.0996875,102.29150770399306,0.8921151875559064,0.3215289556467253,0.24313662423027887,7.9984136474443925,226.78205327472455,0.4022548907535992,0.18143966271724662,0.1437088438495966,Transport,1
11
+ m_dense/H/medium,TinyCast,42422.50555555556,42422.50555555556,98.20399739583333,0.8434814150427642,0.3026363512886733,0.23359158833821614,8.648207997321895,205.96724388978836,0.3583569939963727,0.17086255387302987,0.1376271248660661,Transport,1
12
+ m_dense/H/long,TinyCast,45079.36987654321,45079.36987654321,103.38788966049383,0.8902011815198739,0.3177976149730835,0.24438393675250772,9.89889499669728,212.31902853146067,0.36755564448629796,0.1789797300745008,0.1456864229304492,Transport,1
13
+ sz_taxi/15T/short,TinyCast,17.307643725030733,17.307643725030733,2.8093365743774488,0.56131924208872,1298855927179.874,0.41046200974665864,4.0379503080516095,4.160245632775874,0.3890007772564103,0.2626849968660404,0.206862466845479,Transport,1
14
+ sz_taxi/15T/medium,TinyCast,17.176065872061965,17.176065872061965,2.8675911667000533,0.5599120350379014,11519178918165.262,0.4170552441197583,5.753307664775564,4.144401750803361,0.38570572924704843,0.26687720173847523,0.22147479893996128,Transport,1
15
+ sz_taxi/15T/long,TinyCast,16.577307302795585,16.577307302795585,2.84828851161859,0.537775482581525,8185874683847.322,0.41623861198751333,7.003358529588487,4.0715239533613925,0.376852965628887,0.2636324837740379,0.22561013457503432,Transport,1
16
+ sz_taxi/H/short,TinyCast,7.586486685989249,7.586486685989249,1.9103956956129808,0.5823192162139363,1.3190546361809103,0.3059674409719614,4.111199385015859,2.754357762889427,0.2565854224458191,0.17796514788380788,0.13980384104042456,Transport,1
17
+ bitbrains_fast_storage/5T/short,TinyCast,1900821.8495638215,1900821.8495638215,180.9161150882152,0.8707491108197133,2.5999596170141994,0.6328290414944234,14.628815830083955,1378.7029591481341,4.328786309811298,0.5680318570593322,0.4382953022346899,Web/CloudOps,2
18
+ bitbrains_fast_storage/5T/medium,TinyCast,3698363.988009589,3698363.988009589,306.51702595059857,1.0739030132087344,5.425270140752215,0.7169551569543965,25.616906164417372,1923.1130980807106,5.842948631816825,0.9312833650782235,0.7348087900238709,Web/CloudOps,2
19
+ bitbrains_fast_storage/5T/long,TinyCast,3801015.314638277,3801015.314638277,320.72388129341397,0.9810565674831482,3.724999236613369,0.7443551005073823,22.721673451081248,1949.6192742785133,5.152261640601248,0.8475774591551065,0.7107125865949239,Web/CloudOps,2
20
+ bitbrains_fast_storage/H/short,TinyCast,3350219.742214611,3350219.742214611,342.13923596997927,1.035867798928794,4.369458581754894,0.5601783613841338,18.596723121472916,1830.360549786465,5.217122071810725,0.9752079500509765,0.7268432335817985,Web/CloudOps,2
21
+ bitbrains_rnd/5T/short,TinyCast,1716487.2211356303,1716487.2211356303,127.88738486392651,1.727615235380808,1.348279409098898,0.6111930074239366,54.26260705883576,1310.1477859904319,5.360746534769076,0.5232782611098349,0.4328036309064437,Web/CloudOps,2
22
+ bitbrains_rnd/5T/medium,TinyCast,2263940.9115963113,2263940.9115963113,148.47080556987703,4.488258259100947,0.4486479054342338,0.7040026541179003,164.2567076945487,1504.6397946340219,6.330786610708255,0.6246923624743488,0.5911119097203699,Web/CloudOps,2
23
+ bitbrains_rnd/5T/long,TinyCast,2859365.748948195,2859365.748948195,212.117391142277,3.450989893916647,3.026372416404876,0.6839630832295709,122.2642737201257,1690.9659218766637,6.477709497487906,0.8125739386041303,0.7324299246088727,Web/CloudOps,2
24
+ bitbrains_rnd/H/short,TinyCast,1951804.6616925949,1951804.6616925949,190.79642492003018,5.873774881374325,3.0833461830771283,0.5758552192726804,193.56051832268145,1397.0700274834453,6.114262106732073,0.83501852307995,0.6768112378253903,Web/CloudOps,2
25
+ bizitobs_application/10S/short,TinyCast,1202011.5911111112,1202011.5911111112,511.65680555555554,1.4365212207493263,0.020941043429904514,0.02126424577501085,16.39675337008677,1096.3628920713759,0.04228233445947083,0.019732557839577083,0.015573763327225789,Web/CloudOps,2
26
+ bizitobs_application/10S/medium,TinyCast,3821721.1733333333,3821721.1733333333,1116.08125,2.6158345668367957,0.03760443687438965,0.03825534502665202,41.57801287990864,1954.9222934258366,0.07676404478273681,0.043825225864110855,0.038683657904042684,Web/CloudOps,2
27
+ bizitobs_application/10S/long,TinyCast,6377225.102222222,6377225.102222222,1519.8002777777779,3.2712700300710433,0.04648904588487413,0.047585703531901044,74.82748279411558,2525.3168320474606,0.09739137623221442,0.0586126218985649,0.05436535009286809,Web/CloudOps,2
28
+ bizitobs_l2c/5T/short,TinyCast,20.910473051525297,20.910473051525297,2.7368809836251393,0.2856036998160157,0.14710997590632013,0.21205160390763056,2.9522126094428813,4.572797070888375,0.15704835442944587,0.09399556728286393,0.0764864945634414,Web/CloudOps,7
29
+ bizitobs_l2c/5T/medium,TinyCast,57.513790521775746,57.513790521775746,4.788768947678435,0.4874476313438473,0.3732310604071342,0.6381389022576076,3.4790807741267313,7.583784709614043,0.39901860010963586,0.2519596685450198,0.19883974174097674,Web/CloudOps,7
30
+ bizitobs_l2c/5T/long,TinyCast,58.49988337926971,58.49988337926971,4.703327181899804,0.481824424146925,0.3912872161892713,0.7234287770552564,3.577103266965183,7.648521646649744,0.4294530558989807,0.2640847897769059,0.2079641779499675,Web/CloudOps,7
31
+ bizitobs_l2c/H/short,TinyCast,74.67544022817461,74.67544022817461,5.2198379758804565,0.5344963489050996,0.4788500010538444,0.6654215010385665,3.6846044295181293,8.641495254189207,0.46579872545263284,0.28136263513605253,0.22171674144342682,Web/CloudOps,7
32
+ bizitobs_l2c/H/medium,TinyCast,100.58119419642857,100.58119419642857,5.87127685546875,0.6149229551869348,0.5341694058527017,0.8577732631138393,5.103138751198932,10.029017608740578,0.6072722494122171,0.35551473155605917,0.28786509557482653,Web/CloudOps,7
33
+ bizitobs_l2c/H/long,TinyCast,116.8093501984127,116.8093501984127,6.622200520833333,0.7065034860463142,0.7182541012578003,0.8494622124565973,10.697783398926884,10.807837443189674,0.660173904369807,0.4045031206601936,0.3487624586820504,Web/CloudOps,7
34
+ bizitobs_service/10S/short,TinyCast,4698.85648354828,4698.85648354828,24.49354755704365,0.9619385844153198,0.06322367875033585,0.05459318695875703,9.685720597059765,68.54820554579295,0.050782703545633356,0.018145603586067316,0.015078402238691998,Web/CloudOps,2
35
+ bizitobs_service/10S/medium,TinyCast,28334.413174603174,28334.413174603174,45.6440091765873,1.2868080102589707,0.09811119200691344,0.08665448264470176,15.65923175791177,168.3282898820135,0.126687833856165,0.034352755886404615,0.02937165679843956,Web/CloudOps,2
36
+ bizitobs_service/10S/long,TinyCast,134291.53185185185,134291.53185185185,80.87890873015873,1.5712950980940312,0.12657031508349867,0.1140683257895172,27.175007100786228,366.4580901711024,0.2714838685694251,0.05991768122102279,0.05634743240727026,Web/CloudOps,2
37
+ car_parts/M/short,TinyCast,1.3387428151669534,1.3387428151669534,0.5610735126585066,1.0046851599906685,0.6434972493761926,1.7877842143111389,15.150192550816168,1.1570405417127583,2.774464529299755,1.3453967281875052,1.1563300613220318,Sales,1
38
+ covid_deaths/D/short,TinyCast,717588.6957393484,717588.6957393484,197.66975054824562,42.86037270685185,0.12282604843133045,0.16853826186236212,757.6436637266253,847.1060711264844,0.3187277667256405,0.07437420210868682,0.06930300504974816,Healthcare,1
39
+ electricity/15T/short,TinyCast,165728.66631018836,165728.66631018836,61.24668899398666,1.1455489449171512,0.17541189296660817,0.1813955271685565,11.767695984551851,407.09785839548255,0.8024701351167887,0.12072929832196289,0.09967619680692709,Energy,1
40
+ electricity/15T/medium,TinyCast,265624.58404385735,265624.58404385735,60.785754127296244,0.9553742544827218,0.15863709329530282,0.1492815988739177,8.627838373799541,515.3877996653174,0.8880650716880526,0.10473997469812892,0.08532420164947832,Energy,1
41
+ electricity/15T/long,TinyCast,329539.0969178984,329539.0969178984,65.92105753654045,1.0000069893761259,0.16357317447017727,0.15153064711775138,9.484122201829592,574.0549598408661,0.9056420994568655,0.10399855261656309,0.0851238115030191,Energy,1
42
+ electricity/D/short,TinyCast,1781630419.9057298,1781630419.9057298,4758.490560810811,1.520750366833196,0.45952525278329504,0.10174649267798072,12.724417587455866,42209.364125816086,0.693640034801828,0.0781978034155274,0.060897143284970534,Energy,1
43
+ electricity/H/short,TinyCast,2078730.2604737647,2078730.2604737647,195.37475414929088,1.0434622509358347,0.18948337904655863,0.13323683262732833,10.922461867297288,1441.7802400066955,0.6806232349328453,0.09223083622832991,0.07469545370451673,Energy,1
44
+ electricity/H/medium,TinyCast,6897401.130664415,6897401.130664415,270.0492746164133,1.1544809599512351,0.22157058136251614,0.13467502826597633,12.652023545681958,2626.290374399681,1.0251276580896305,0.10540912884379984,0.08775565669684998,Energy,1
45
+ electricity/H/long,TinyCast,10335719.401981981,10335719.401981981,313.20569266141143,1.311547092732918,0.3145948344492027,0.14959712349266505,17.97017321294264,3214.921367931412,1.26796404037728,0.12352823291340928,0.10533341183862409,Energy,1
46
+ electricity/W/short,TinyCast,128317784322.07567,128317784322.07567,38548.00271677928,1.7224971059223015,0.2566139873716758,0.10849626687195923,13.930672069612124,358214.71818181296,0.8173277752266295,0.08795382127192254,0.06694613568802286,Energy,1
47
+ ett1/15T/short,TinyCast,5.361727387564523,5.361727387564523,1.1755770910353887,0.7562283281562213,0.4924726026124937,0.2589161691211519,5.162023400363836,2.315540409400044,0.4377335221089301,0.2222330037862829,0.17693710721247993,Energy,7
48
+ ett1/15T/medium,TinyCast,9.402190212673611,9.402190212673611,1.6372881014384921,1.0455030489866155,0.7463840016001188,0.4031215413411458,9.76289238797673,3.0662991068507344,0.5844396070118025,0.31206871255055085,0.2555580983731971,Energy,7
49
+ ett1/15T/long,TinyCast,9.244584573412698,9.244584573412698,1.6541104077535962,1.074562293727344,0.846863059927027,0.4056002807617187,12.002302210050333,3.0404908441586693,0.5795205611595534,0.31527507920730885,0.26303683899141084,Energy,7
50
+ ett1/D/short,TinyCast,42389.02222222222,42389.02222222222,136.02098214285715,1.6996648224499773,1.2227787078373016,0.4813387916201637,11.203052479321421,205.88594469322626,0.5407662630433157,0.3572636214602873,0.2856896872972474,Energy,7
51
+ ett1/H/short,TinyCast,103.01610834030878,103.01610834030878,5.070075225830078,0.8571495009030993,0.4806339899698893,0.26966083163306825,6.066873579858822,10.149685135032946,0.4737273357601031,0.23664115653652637,0.18454077882381037,Energy,7
52
+ ett1/H/medium,TinyCast,133.77725074404762,133.77725074404762,6.994163876488095,1.3686390900698875,2738232925694.1333,0.4431784130278088,10.267276553788607,11.56621159861982,0.5539941179660003,0.335003870076789,0.26499877408295125,Energy,7
53
+ ett1/H/long,TinyCast,160.18366402116402,160.18366402116402,7.428542493386243,1.41610917545092,3416827299278.7944,0.47630111452132934,17.1122354162061,12.656368516330584,0.6060860764559786,0.35573681090224185,0.29712143678186426,Energy,7
54
+ ett1/W/short,TinyCast,2011626.0,2011626.0,1035.6922433035713,1.6248616254535604,0.8583596774509975,0.5207679952893939,14.300083010139824,1418.318017935329,0.5643825913948318,0.4121266632529299,0.33461569724082,Energy,7
55
+ ett2/15T/short,TinyCast,8.542245556059338,8.542245556059338,1.828979274204799,0.7977637055045982,0.11675881246605886,0.13770481972467333,5.960209582023153,2.922712020719684,0.13875154087160105,0.08682815505567654,0.06987016453124961,Energy,7
56
+ ett2/15T/medium,TinyCast,13.07299045138889,13.07299045138889,2.295947808159722,0.9498484354639674,0.144159166678213,0.17323478577628967,9.902986035407816,3.6156590618293767,0.19268501622242679,0.12235521466375968,0.10234915555751511,Energy,7
57
+ ett2/15T/long,TinyCast,12.420850694444445,12.420850694444445,2.2458406187996034,0.9552525869265864,0.14638871073557067,0.17425280131990947,12.429491781394304,3.524322728474855,0.18781753148114935,0.11968490788741563,0.10269056593429093,Energy,7
58
+ ett2/D/short,TinyCast,119729.95555555556,119729.95555555556,209.34871031746033,1.391076627845421,0.4334957062251984,0.14021989126054069,14.639593650144755,346.02016640010385,0.19803647066492555,0.11981579039409214,0.09908310643497366,Energy,7
59
+ ett2/H/short,TinyCast,105.86847446986607,105.86847446986607,6.4301946730840776,0.7491574015472658,0.12146225350857989,0.11259141308920724,5.280887719007807,10.289240713962624,0.13049619529847512,0.08155275624247994,0.06474864313547667,Energy,7
60
+ ett2/H/medium,TinyCast,262.78627232142856,262.78627232142856,9.951085844494047,1.0705651377694565,0.18129692458733945,0.17153523763020834,8.362985316753157,16.210683894315764,0.21844939017662102,0.13409728105839946,0.10807732772305327,Energy,7
61
+ ett2/H/long,TinyCast,216.7968253968254,216.7968253968254,9.040730406746032,1.0091326427196337,0.17643990593463876,0.1707823253813244,11.704296859619719,14.724022052307086,0.20163414956203768,0.12380584466037664,0.1061356610020145,Energy,7
62
+ ett2/W/short,TinyCast,7161788.571428572,7161788.571428572,1640.3597935267858,1.036383892254434,0.17148779119764054,0.18681137902396067,14.537499283465852,2676.151821445968,0.22448810991134696,0.13760103842854057,0.12450411098056532,Energy,7
63
+ hierarchical_sales/D/short,TinyCast,29.19706801138125,29.19706801138125,2.3333297571794906,0.7575841824952811,0.6457101283071776,1.0286102534621744,7.07832206053394,5.403431133213529,1.6577128322798675,0.7158397294343857,0.5878786597175552,Sales,1
64
+ hierarchical_sales/W/short,TinyCast,480.91189833818856,480.91189833818856,9.262271881103516,0.7521108764594379,0.5638248297323311,0.4720336764545764,6.837358953125831,21.929703562478643,1.0068034171692506,0.42523543257561824,0.36087737376157075,Sales,1
65
+ hospital/M/short,TinyCast,2635.1076773685354,2635.1076773685354,18.3890812163869,0.7787874084304148,0.19600348739922435,0.1764316086352571,5.629575597124316,51.33329988777787,0.18635179891223488,0.066756635020707,0.05370179299767255,Healthcare,1
66
+ jena_weather/10T/short,TinyCast,737.4657753051273,737.4657753051273,5.181869942801339,0.2830066840085954,0.36903646614834207,0.552891525011214,3.213335159674428,27.156321092981784,0.1686747894840554,0.0321859068738754,0.02797473539661994,Nature,21
67
+ jena_weather/10T/medium,TinyCast,1496.4421852453102,1496.4421852453102,9.826428064123377,0.599973524454999,0.6914869268521763,0.6612252459725604,9.462120068850538,38.68387500296875,0.23740228286464188,0.060304621877954157,0.05215981565067656,Nature,21
68
+ jena_weather/10T/long,TinyCast,1347.9896732390873,1347.9896732390873,9.352307839368386,0.6377870157231208,0.7125753689503994,0.6615759794043485,11.832725243656402,36.714978867474336,0.22466525410274407,0.05722837604681763,0.05001671925210481,Nature,21
69
+ jena_weather/D/short,TinyCast,360.4092757936508,360.4092757936508,10.167755611359127,1.2438429713883608,0.8665065087642766,0.44703078497023807,9.15185872707675,18.98444826150212,0.1143174071761791,0.06122650720635964,0.053621856678926466,Nature,21
70
+ jena_weather/H/short,TinyCast,1122.6431671626983,1122.6431671626983,8.337321486190248,0.5583927058292222,1.16205769443803,0.6162634532254442,4.862676662693756,33.50586765273656,0.20538883687596202,0.05110725024217434,0.042352005732441994,Nature,21
71
+ jena_weather/H/medium,TinyCast,1559.271626984127,1559.271626984127,11.34192863343254,0.82201669463664,2.19126262226046,0.6926077222067212,8.148551897694308,39.48761358937922,0.24163171080387966,0.0694032728341155,0.05921602679031589,Nature,21
72
+ jena_weather/H/long,TinyCast,1451.1585978835978,1451.1585978835978,12.337256117724868,1.2372013048116568,2.376308798348106,0.7116374343791336,21.033421879854583,38.094075627105035,0.22936854067949444,0.0742839505914733,0.06403979708580165,Nature,21
73
+ kdd_cup_2018/D/short,TinyCast,2911.8811459995677,2911.8811459995677,21.00633677958142,1.1915066063457622,0.509937226768345,0.4632225368907377,9.645711885756073,53.96184898610839,1.2088027347454506,0.47056425647458555,0.37499475175037367,Nature,1
74
+ kdd_cup_2018/H/short,TinyCast,4428.40544507181,4428.40544507181,24.484380819274286,1.0194315529770632,1.0062891958892606,0.5246448799650837,7.7420535492651315,66.54626544797094,1.392933853478974,0.5125024326316768,0.3995833092562999,Nature,1
75
+ kdd_cup_2018/H/medium,TinyCast,5057.902963232746,5057.902963232746,25.82511224428789,1.0797880779211075,1.123762793111469,0.5598105568251877,10.435659593711573,71.11893533534332,1.4886481232339814,0.5405663722817384,0.44898669999971397,Nature,1
76
+ kdd_cup_2018/H/long,TinyCast,4177.368478330922,4177.368478330922,24.350837097055326,1.042253138917662,0.9913156556511691,0.6197239900842068,14.638124597812372,64.63256515357348,1.516541164902919,0.5713690423671467,0.4937515620723734,Nature,1
77
+ m4_daily/D/short,TinyCast,386967.5754799081,386967.5754799081,185.35799083511822,3.4841999666237293,0.0395577275268352,0.0319446626195895,35.48001408762702,622.0671792338092,0.09609218717022147,0.028632686859902296,0.023237907721219753,Econ/Fin,1
78
+ m4_hourly/H/short,TinyCast,2435120.026100816,2435120.026100816,284.8097814661293,1.0222185676333535,0.10936638276166194,0.10435770348842209,11.960363456937134,1560.4871118022143,0.21304096324626956,0.038882826860023555,0.028535365688984397,Econ/Fin,1
79
+ m4_monthly/M/short,TinyCast,1975177.037971065,1975177.037971065,595.8976114818432,1.03344105749455,0.1709086054806356,0.14037018246562394,10.415824918873035,1405.409918127471,0.2920924274906528,0.12384798031418648,0.10211410097321605,Econ/Fin,1
80
+ m4_quarterly/Q/short,TinyCast,1998109.2978125,1998109.2978125,628.8970325927735,1.376682755781824,0.12536339755853018,0.11501970611512662,12.067188115733634,1413.54494014605,0.23660253103085468,0.10526628863590991,0.08453829758462092,Econ/Fin,1
81
+ m4_weekly/W/short,TinyCast,347017.66530962073,347017.66530962073,292.05628615813157,2.375093592561412,0.07180270041136153,0.07393041340574759,31.16335772623592,589.0820531213124,0.1073216119717094,0.053208124828930614,0.04581422383855976,Econ/Fin,1
82
+ m4_yearly/A/short,TinyCast,4131320.0302225705,4131320.0302225705,983.0706571491143,3.703042215816235,0.17711929984939676,0.16428084468396,30.828436436413924,2032.564889547827,0.32593119996973857,0.15763993591905975,0.12227082973056771,Econ/Fin,1
83
+ restaurant/D/short,TinyCast,149.795676893621,149.795676893621,7.48103590324233,0.7140542511073307,0.7216503356722676,0.4078460662006675,4.798367435523933,12.23910441550447,0.5632719429215224,0.34429460565324277,0.26898689146401417,Sales,1
84
+ saugeen/D/short,TinyCast,1027.6305208333333,1027.6305208333333,12.28921875,2.729262467797945,0.3095691426595052,0.3312169138590495,24.78162106669607,32.056676696646726,1.0383233962018728,0.3980507234083472,0.32716491124571656,Nature,1
85
+ saugeen/M/short,TinyCast,447.1129092261905,447.1129092261905,12.983006068638392,0.7607681767579312,0.36929300853184294,0.3627051852998279,5.014860167518044,21.145044554840513,0.6348376720758496,0.3897887908339379,0.29732434590218304,Nature,1
86
+ saugeen/W/short,TinyCast,1091.752734375,1091.752734375,16.127630615234374,1.3084226666206766,0.43306527137756345,0.4202584266662598,10.76846426338361,33.041681772800246,0.9973906393072427,0.48682594065418355,0.39494308918125093,Nature,1
87
+ solar/10T/short,TinyCast,35.38759293382185,35.38759293382185,2.7478948774128935,1.2092061910074965,5.3764949077865465,1.5784135191980069,11.732245439375333,5.948747173466178,1.73048987304251,0.7993623058580365,0.6564243312663933,Energy,1
88
+ solar/10T/medium,TinyCast,23.771390239645545,23.771390239645545,2.0070813147585573,0.8818089939306053,3.4706532129371834,1.4721230927952096,12.145282983871581,4.8755912707737865,1.0819130604153002,0.44537931240658873,0.3800340063104551,Energy,1
89
+ solar/10T/long,TinyCast,21.99100210123302,21.99100210123302,1.920834464439027,0.8426367678835721,2.7411416188370343,1.47795006596549,11.225711751477654,4.689456482496987,1.0135510118891753,0.4151576461729174,0.35299910057498773,Energy,1
90
+ solar/D/short,TinyCast,134638.40486618006,134638.40486618006,244.66284215328466,0.9544751702039223,1.096048649326148,0.4143624967032105,5.675164959046651,366.9310628254033,0.5300904365706345,0.3534544930892344,0.28338941469586704,Energy,1
91
+ solar/H/short,TinyCast,737.9774478966577,737.9774478966577,11.11688431729915,0.8602663353314536,3.558273438428383,1.4107715080574847,5.32781661862047,27.16574033404313,1.001338234099665,0.40977205752882306,0.3184018809689805,Energy,1
92
+ solar/H/medium,TinyCast,714.6127889294404,714.6127889294404,11.019490916305505,0.8432656365847866,4.550756358452363,1.4003983546347514,8.402250632335997,26.73224249720626,0.9629577598666192,0.39694777902546247,0.33288960205553647,Energy,1
93
+ solar/H/long,TinyCast,851.7509783049472,851.7509783049472,13.291783252230333,1.0149629411795078,4.150918191030254,1.440622485331762,9.849022779881642,29.18477305556696,1.0118894140680637,0.46085041474919114,0.37578656900544266,Energy,1
94
+ solar/W/short,TinyCast,1670333.9124087591,1670333.9124087591,1046.5890810333028,1.145065110150808,0.23824922185744682,0.2054090378058218,9.42922974300857,1292.4139864643832,0.26381400658172083,0.21363499745728415,0.1759532259048021,Energy,1
95
+ temperature_rain/D/short,TinyCast,181.9258203608153,181.9258203608153,6.207237948262678,1.4557875160071272,62.43454723924855,1.457818859810271,18.557788723998502,13.487988002693927,1.5878927988640386,0.7307560206098415,0.5897024269021723,Nature,1
96
+ us_births/D/short,TinyCast,236075.30666666667,236075.30666666667,317.48578125,0.46719668126881475,0.03038674036661784,0.029927581151326498,4.298056948804611,485.87581403756525,0.04554691491119974,0.02976171574366844,0.024416986371726027,Healthcare,1
97
+ us_births/M/short,TinyCast,84781968.0,84781968.0,8055.583333333333,0.9120966855713007,0.024705658356348675,0.02509211003780365,9.086494946974335,9207.712419488349,0.028599091997540623,0.025020586911060013,0.021844573174920206,Healthcare,1
98
+ us_births/W/short,TinyCast,4135229.1428571427,4135229.1428571427,1572.802734375,1.4293763597578706,0.02104850539139339,0.021356597542762756,9.32450229299691,2033.526282804612,0.027604919892820882,0.021350642898871827,0.015937398238737275,Healthcare,1
tinycast/reference/seasonal_naive.csv ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ dataset,model,eval_metrics/MSE[mean],eval_metrics/MSE[0.5],eval_metrics/MAE[0.5],eval_metrics/MASE[0.5],eval_metrics/MAPE[0.5],eval_metrics/sMAPE[0.5],eval_metrics/MSIS,eval_metrics/RMSE[mean],eval_metrics/NRMSE[mean],eval_metrics/ND[0.5],eval_metrics/mean_weighted_sum_quantile_loss,domain,num_variates
2
+ bitbrains_fast_storage/H/short,Seasonal_Naive,4198734.112364942,4198734.112364942,323.91137144521844,1.2985124569638047,4.122732886534151,0.31268171558395724,34.57780677446709,2049.0812849579547,5.840547085975178,0.9232525964178199,1.0222395520114795,Web/CloudOps,2
3
+ solar/H/short,Seasonal_Naive,1009.7531694199001,1009.7531694199001,12.29965959124688,0.9519362458316395,3.8670855712527974,0.6200499536903793,16.15202026601857,31.77661356123242,1.171296544389828,0.4533695432534923,0.5916559631947232,Energy,1
4
+ solar/H/medium,Seasonal_Naive,993.4109489051095,993.4109489051095,12.224457306873479,0.9349685908749716,5.243116882202785,0.568037363492515,29.963349764914504,31.518422373353484,1.1353671382460326,0.4403534842165714,0.9459874672135314,Energy,1
5
+ solar/H/long,Seasonal_Naive,1160.9764801297647,1160.9764801297647,13.995331508515815,1.0711966477927781,5.3301362264546865,0.7115105305381197,36.28762062617156,34.073104938202576,1.1813766446766685,0.48524364916713286,1.0779764182077312,Energy,1
6
+ m_dense/D/short,Seasonal_Naive,39929.671111111114,39929.671111111114,118.38299768518519,1.6693368812171987,0.2532523956185716,0.2557375985604745,23.291872240434824,199.82410042612756,0.3459250756939904,0.20493848013227253,0.22686565739428607,Transport,1
7
+ ett2/H/short,Seasonal_Naive,151.78024553571427,151.78024553571427,7.9655081612723215,0.9231678277377344,0.1445715792364967,0.13716111864362443,9.954323499249957,12.319912562015782,0.1562507702523094,0.10102480674150278,0.08895289185095483,Energy,7
8
+ ett2/H/medium,Seasonal_Naive,338.06856398809526,338.06856398809526,11.509515671502976,1.2384249730532626,0.20117125149055434,0.19754707699730284,25.271323860489556,18.3866409109466,0.24777181028131637,0.15509812516606425,0.1862050814081793,Energy,7
9
+ ett2/H/long,Seasonal_Naive,258.848330026455,258.848330026455,10.011796254960318,1.1284016697207502,0.19021284402568,0.18827246610449735,30.708493118791996,16.08876409257265,0.2203232414204263,0.1371038440641976,0.20790633225768584,Energy,7
10
+ ett2/D/short,Seasonal_Naive,122878.94603174603,122878.94603174603,211.56051587301587,1.3901143157307532,0.4713712661985367,0.14168287004743305,32.70861808020462,350.5409334610525,0.20062382493032205,0.12108166507005774,0.15331100213050436,Energy,7
11
+ electricity/15T/short,Seasonal_Naive,366722.87168918917,366722.87168918917,94.21436127533784,1.7170601412372184,0.30235384997180187,0.23004003312303262,26.97516311288429,605.5764788110492,1.193710612408421,0.18571507783204827,0.16489261500071847,Energy,1
12
+ electricity/15T/medium,Seasonal_Naive,373592.9609594595,373592.9609594595,70.78214646677928,1.1507882041044137,0.21527551573127818,0.162019429539667,12.499327683031675,611.2225134592635,1.0531979612070823,0.12196476848793535,0.11275485280084273,Energy,1
13
+ electricity/15T/long,Seasonal_Naive,432425.0750750751,432425.0750750751,73.83208042417418,1.1635317075319347,0.19999027586553747,0.1567721679648986,14.199582505532762,657.5903550654275,1.0374294291948993,0.11647916132088339,0.11255332426055248,Energy,1
14
+ bitbrains_rnd/H/short,Seasonal_Naive,3710112.576957504,3710112.576957504,254.47180959890883,6.0373618687533925,4.792930625938438,0.36119250186151963,222.24992066836998,1926.1652517262125,8.429841528920612,1.1136931405932016,1.2432170953390527,Web/CloudOps,2
15
+ loop_seattle/H/short,Seasonal_Naive,125.56935646488512,125.56935646488512,6.351615330169464,1.2928412613913995,0.1710389719335216,0.14238265652237242,14.224911524098493,11.205773354163698,0.1983535851600468,0.11243005123144872,0.10423508438727377,Transport,1
16
+ loop_seattle/H/medium,Seasonal_Naive,153.75436661506708,153.75436661506708,7.21920260497291,1.4805126192799751,0.20086050437084785,0.16072294158463138,25.49530583293511,12.399772845301122,0.21916459102759323,0.1275985944423001,0.16208090743299983,Transport,1
17
+ loop_seattle/H/long,Seasonal_Naive,167.0609584623323,167.0609584623323,7.511452824002408,1.5460268753677606,0.23476947145360338,0.16369716459260514,30.628061299733826,12.925206321847721,0.23099074580000042,0.13423972094936726,0.18709450197084374,Transport,1
18
+ solar/W/short,Seasonal_Naive,2813150.8321167883,2813150.8321167883,1341.8134124087592,1.4703506784640206,0.3086572076282362,0.2534797557079009,11.583228430599936,1677.245012547895,0.3423676402558515,0.2738976644635694,0.20974762813186765,Energy,1
19
+ bizitobs_application/10S/short,Seasonal_Naive,3938171.448888889,3938171.448888889,1054.6502083333332,2.2423302386755415,0.0340565554300944,0.03481890360514323,26.67150527524178,1984.4826653031992,0.07653356419683467,0.04067364297805646,0.03483868247005456,Web/CloudOps,2
20
+ bizitobs_application/10S/medium,Seasonal_Naive,5143417.173333333,5143417.173333333,1276.9247916666666,2.6914183385868085,0.03906738917032877,0.03992526690165202,28.10250665325578,2267.9103098079813,0.08905416300729038,0.05014107835453231,0.04268912394422428,Web/CloudOps,2
21
+ bizitobs_application/10S/long,Seasonal_Naive,5953087.1466666665,5953087.1466666665,1470.5443055555556,3.2063450561622804,0.04643516540527344,0.04741138034396701,21.30987046900455,2439.894904840507,0.09409699394104339,0.05671301593170149,0.04573090228155046,Web/CloudOps,2
22
+ bizitobs_service/10S/short,Seasonal_Naive,73535.24306878306,73535.24306878306,55.138688409391534,1.225305042620374,0.08240041742879878,0.07553190362516535,16.350613928029045,271.1738244535838,0.2008942442703866,0.04084850431410673,0.03998233993351736,Web/CloudOps,2
23
+ bizitobs_service/10S/medium,Seasonal_Naive,96480.73142857142,96480.73142857142,63.760550595238094,1.3205790727650968,0.0796314687577505,0.07705034528459821,17.448776648119132,310.6134759288003,0.23377502162466005,0.04798769290234763,0.04755831292581585,Web/CloudOps,2
24
+ bizitobs_service/10S/long,Seasonal_Naive,130947.25079365079,130947.25079365079,75.32497354497355,1.367193469742303,0.08491984049479166,0.08144445348668981,17.936180702599792,361.8663438255218,0.26808215608217806,0.055803148480992645,0.05345299584740217,Web/CloudOps,2
25
+ bizitobs_l2c/5T/short,Seasonal_Naive,174.3096912202381,174.3096912202381,9.685235305059523,0.9860210805885202,0.42617953698061745,0.5068802606491816,7.245192912077175,13.202639555037399,0.45343208257188783,0.3326301832497065,0.26206838623738704,Web/CloudOps,7
26
+ bizitobs_l2c/5T/medium,Seasonal_Naive,294.66615646258504,294.66615646258504,12.864580676020408,1.2435997989335994,0.9360730119589105,1.1668089007914848,7.637297246345627,17.16584272509174,0.9031757619892758,0.6768661254104057,0.5203917750796179,Web/CloudOps,7
27
+ bizitobs_l2c/5T/long,Seasonal_Naive,416.33936507936505,416.33936507936505,15.353609871031747,1.4542830442631411,1.0210265852224123,1.298652753589897,8.166517613570452,20.404395729336485,1.1456763155758307,0.862082240570442,0.6484921267253538,Web/CloudOps,7
28
+ ett2/W/short,Seasonal_Naive,4688738.857142857,4688738.857142857,1323.111607142857,0.7785209533197451,0.12217171703066144,0.1269862311226981,16.565488704288857,2165.3495923621335,0.1816396339666693,0.11098877930205708,0.13367758870767996,Energy,7
29
+ loop_seattle/5T/short,Seasonal_Naive,76.27992181453173,76.27992181453173,4.806023281794214,0.7623397118337274,0.13014939133965933,0.10246611478035911,9.641956549599044,8.733837748351622,0.14976603568936095,0.08241268902447142,0.08083202568463041,Transport,1
30
+ loop_seattle/5T/medium,Seasonal_Naive,149.91421294504644,149.91421294504644,7.272513948013416,1.1532851467900647,0.22437167928437823,0.16228546394529234,12.161708217874148,12.243945971174751,0.217844961125775,0.12939296873911593,0.11727937998585615,Transport,1
31
+ loop_seattle/5T/long,Seasonal_Naive,172.4441060371517,172.4441060371517,7.891544207229676,1.2506857542807588,0.23036973349395495,0.1761461930635284,12.853493858763652,13.131797517367975,0.2321728668857043,0.13952411620149754,0.12716656087996273,Transport,1
32
+ loop_seattle/D/short,Seasonal_Naive,58.65786974329205,58.65786974329205,5.940060831075852,1.7323957056549062,0.11095823557630288,0.10706747427075271,21.844122635807963,7.658842585096788,0.13687375626545378,0.10615682844516516,0.1032547596878987,Transport,1
33
+ bitbrains_rnd/5T/short,Seasonal_Naive,3407833.9580161185,3407833.9580161185,210.5518366560826,1.9708126376716957,3.046095137251105,0.38408369415249266,74.37164472655921,1846.0319493486884,7.553429809324837,0.8615173317922609,1.1015904440824371,Web/CloudOps,2
34
+ bitbrains_rnd/5T/medium,Seasonal_Naive,3267017.897300195,3267017.897300195,204.8128727866788,4.542392314855541,2.750306364851926,0.3918429569214854,178.58186300111112,1807.489390646649,7.605029622998441,0.8617522032352579,1.169291300089368,Web/CloudOps,2
35
+ bitbrains_rnd/5T/long,Seasonal_Naive,3851065.095876048,3851065.095876048,237.4491928177016,3.501257904408163,3.179405138911208,0.40348581728614835,137.13909859136817,1962.413079826989,7.517562382830161,0.909614361068727,1.175220506770106,Web/CloudOps,2
36
+ electricity/W/short,Seasonal_Naive,317380739662.4144,317380739662.4144,56604.72342342342,2.0896995064573947,0.285636731125347,0.1196364761812733,15.938528356933686,563365.5471027798,1.2854142672489681,0.12915329923232946,0.0993032024444414,Energy,1
37
+ solar/D/short,Seasonal_Naive,181869.82773722627,181869.82773722627,295.1889902676399,1.155861190610263,1.1535581565541362,0.49778203291324513,22.3811319807538,426.46198861941525,0.6160923187357285,0.4264475482750424,0.5590651164917229,Energy,1
38
+ ett1/H/short,Seasonal_Naive,141.21642485119048,141.21642485119048,5.822230747767857,0.9773176243434534,0.5700293404715402,0.28737351553780693,9.542335004679739,11.883451722929264,0.5546493091237623,0.271747328730894,0.24036844107833774,Energy,7
39
+ ett1/H/medium,Seasonal_Naive,204.51871279761906,204.51871279761906,8.163152785528274,1.5678007461285395,2345799664153.64,0.45697086879185267,22.421278808802057,14.301003908733787,0.6849841868181539,0.3909956677413786,0.4349118298516912,Energy,7
40
+ ett1/H/long,Seasonal_Naive,199.49484126984126,199.49484126984126,7.749353091931217,1.4787012870437326,2646635889605.55,0.47721551158440806,25.35082572229849,14.124264273576916,0.6763804250290198,0.37109973563904386,0.47106248989542887,Energy,7
41
+ temperature_rain/D/short,Seasonal_Naive,310.64911445557556,310.64911445557556,7.890313322375261,2.0115357653222166,97.02872693676676,1.3339967533230714,47.47823470729249,17.625240833973745,2.0749568489172328,0.9288984940737532,1.2679455200160903,Nature,1
42
+ saugeen/W/short,Seasonal_Naive,1800.4892578125,1800.4892578125,24.552409362792968,1.990658004828632,0.824161148071289,0.557866096496582,23.291068065474015,42.43217243805106,1.2808504084686672,0.7411348925661494,0.7340352210434381,Nature,1
43
+ covid_deaths/D/short,Seasonal_Naive,4435194.482205514,4435194.482205514,353.7093984962406,46.91239825526407,0.132724402667158,0.17781104711702989,1472.7784117419058,2105.990142950701,0.792389040211866,0.13308488253209907,0.12672327831223676,Healthcare,1
44
+ us_births/D/short,Seasonal_Naive,2855120.2133333334,2855120.2133333334,1266.88,1.8648391912491094,0.12472813924153646,0.1233361307779948,22.730582398368462,1689.7100974230264,0.15839661042681866,0.11875971986174946,0.11952686561898423,Healthcare,1
45
+ m4_weekly/W/short,Seasonal_Naive,453525.1459181487,453525.1459181487,347.99148275123207,2.777295047362158,0.08937289522218837,0.09161286714732429,26.631225199626535,673.442756229621,0.1226908336142798,0.06339865521526268,0.06087039452311706,Econ/Fin,1
46
+ car_parts/M/short,Seasonal_Naive,2.5046831406935035,2.5046831406935035,0.6672313006509898,1.2014638390969912,0.8541762945076465,1.6763314144736843,20.368070639282948,1.5826190763078472,3.7949581992256025,1.5999522140809175,1.7217438941460104,Sales,1
47
+ sz_taxi/H/short,Seasonal_Naive,12.029984433426817,12.029984433426817,2.4300792726696048,0.7381672994945998,1.552363729884482,0.38466275859082866,8.057108467274903,3.468426795166191,0.3231053592422672,0.22637687999566908,0.21382494112013678,Transport,1
48
+ ett1/15T/short,Seasonal_Naive,8.776201520647321,8.776201520647321,1.4945350283668155,0.9341549587802876,0.6328330752819759,0.30603092738560267,8.439802226867357,2.962465446321243,0.5600292824430536,0.2825293306160358,0.24136394938567346,Energy,7
49
+ ett1/15T/medium,Seasonal_Naive,12.883658234126985,12.883658234126985,1.8389095052083333,1.188305978388322,0.9080556087099148,0.42800251405065676,12.40964763458674,3.5893813163450585,0.6841396278696877,0.3504979699009272,0.32155116949786394,Energy,7
50
+ ett1/15T/long,Seasonal_Naive,13.023148561507936,13.023148561507936,1.8557530381944445,1.190885705867336,0.9840622608053838,0.4204419926758006,13.893107103740524,3.608759975602137,0.6878332194845934,0.35370836394199906,0.3401793468576481,Energy,7
51
+ saugeen/M/short,Seasonal_Naive,713.7325613839286,713.7325613839286,16.662892659505207,0.9763741730697157,0.4971456073579334,0.4422340847197033,10.586458554994822,26.71577364374703,0.8020876713555654,0.500270025848136,0.4450846286544009,Nature,1
52
+ us_births/M/short,Seasonal_Naive,74172117.33333333,74172117.33333333,6704.75,0.7605310792868888,0.020606640726327896,0.020953829089800518,5.646371868808973,8612.323573422756,0.02674981830097076,0.020824907787592,0.016825596814792485,Healthcare,1
53
+ m4_quarterly/Q/short,Seasonal_Naive,2487884.428,2487884.428,708.8467662760416,1.602247174616002,0.14192582162221273,0.12521366802851358,12.540135359281015,1577.302896719587,0.26401272904907985,0.11864846608179633,0.09805397548466407,Econ/Fin,1
54
+ ett1/W/short,Seasonal_Naive,1571455.142857143,1571455.142857143,970.0256696428571,1.7689207877683926,0.6150428227015904,0.6251754760742188,12.799615221235317,1253.5769393448265,0.4988281842249676,0.38599636628003803,0.3118251468740881,Energy,7
55
+ saugeen/D/short,Seasonal_Naive,994.7777083333333,994.7777083333333,15.3725,3.413049305768806,0.47135218302408854,0.4242374928792318,58.71112805300772,31.540096834558597,1.0215912513857213,0.49791893773514423,0.5850141736335629,Nature,1
56
+ electricity/H/short,Seasonal_Naive,2887718.36009009,2887718.36009009,247.69792300112613,1.3577726051465433,0.3631860614314555,0.16452154722835458,18.287059022037134,1699.3287969342748,0.8022045515456071,0.11693110926995137,0.10566499518474065,Energy,1
57
+ electricity/H/medium,Seasonal_Naive,8353587.978378379,8353587.978378379,308.2015061936937,1.3924537851702354,0.3866618745283321,0.15667597765115268,20.69150990156008,2890.2574242406813,1.128162669425473,0.1203011991361897,0.12738701179892217,Energy,1
58
+ electricity/H/long,Seasonal_Naive,13526606.943327328,13526606.943327328,357.2064234234234,1.5240786163496851,0.36051114996741657,0.16771684918985819,24.628137038027603,3677.8535782882013,1.4505443840804357,0.14088210974822626,0.15341839756227935,Energy,1
59
+ ett1/D/short,Seasonal_Naive,57976.62857142857,57976.62857142857,154.97894345238095,1.7783697877928102,1.1623038155691965,0.5164825923859127,23.47582377746543,240.7833643992636,0.6324254934604606,0.4070573356817528,0.40843357791503654,Energy,7
60
+ bizitobs_l2c/H/short,Seasonal_Naive,281.8430679563492,281.8430679563492,12.53165302579365,1.2140641267600039,1.3605904339028776,1.138373051002047,7.486930567002142,16.788182389894065,0.9049260260934667,0.675488192208351,0.5211675771895117,Web/CloudOps,7
61
+ bizitobs_l2c/H/medium,Seasonal_Naive,456.3732886904762,456.3732886904762,15.667392113095238,1.5102861226255864,1.6912910179529201,1.4024095456148358,18.533653530822832,21.362895138311103,1.293555748999092,0.9486843898499615,0.9042051354742671,Web/CloudOps,7
62
+ bizitobs_l2c/H/long,Seasonal_Naive,309.27222222222224,309.27222222222224,13.635487971230159,1.4260541525613812,2.4383105011700468,0.9168538411458333,22.036198194504,17.586137217201003,1.0742120179443015,0.8328949597245134,0.9410651242377539,Web/CloudOps,7
63
+ hospital/M/short,Seasonal_Naive,3464.0771403737504,3464.0771403737504,20.005975662755322,0.9205278266364826,0.23307140471364352,0.21025354682958836,6.599875528051222,58.85641120875236,0.21366243998041978,0.07262633732682543,0.06248817651596496,Healthcare,1
64
+ m4_daily/D/short,Seasonal_Naive,497624.29605596676,497624.29605596676,180.83018393659805,3.278424296610481,0.04217648847004251,0.03045251709590828,32.19152086932198,705.4249046184624,0.10896865245350737,0.02793326594720746,0.024356147648337902,Econ/Fin,1
65
+ bitbrains_fast_storage/5T/short,Seasonal_Naive,5404398.959598898,5404398.959598898,346.43736615760264,1.1360241737534567,5.932190463200192,0.4761820977820179,31.06038796757978,2324.736320445589,7.299097101583847,1.087727650212227,1.210386425305063,Web/CloudOps,2
66
+ bitbrains_fast_storage/5T/medium,Seasonal_Naive,5191990.375993042,5191990.375993042,332.75293038959035,1.2202713595882653,5.768419487236171,0.4516151765437165,36.13464652226377,2278.593947150971,6.92299777327362,1.0109953109538277,1.1979128978088514,Web/CloudOps,2
67
+ bitbrains_fast_storage/5T/long,Seasonal_Naive,6473012.871295428,6473012.871295428,404.5292260549034,1.1366422318753115,6.462525817893773,0.5033018991430218,32.16149564789198,2544.2116404291974,6.723591850916995,1.0690499817466734,1.1773077032683998,Web/CloudOps,2
68
+ ett2/15T/short,Seasonal_Naive,14.617721121651785,14.617721121651785,2.534710984002976,1.067314800011479,0.15340707960692476,0.17031889997991706,8.528224043296179,3.8233128464267456,0.181506262086668,0.12033174753313121,0.09638438775638664,Energy,7
69
+ ett2/15T/medium,Seasonal_Naive,14.948670634920635,14.948670634920635,2.4787872023809525,1.0512391501712754,0.1650384327009311,0.1899925877490716,13.312453415228259,3.8663510749698657,0.20604484738813625,0.1320990569451144,0.12412877798161984,Energy,7
70
+ ett2/15T/long,Seasonal_Naive,13.674988839285714,13.674988839285714,2.362206101190476,1.0126577751121104,0.1609817984920091,0.1894470412273871,15.633896791936913,3.697970908388236,0.19707156352079597,0.12588623903557683,0.13282444765695306,Energy,7
71
+ jena_weather/10T/short,Seasonal_Naive,1986.203373015873,1986.203373015873,10.556470114087302,0.7428874675800381,0.8774809708937492,0.39362146904346257,20.65580527060839,44.566841631597285,0.2768159431081858,0.06556891005829443,0.15520092375681044,Nature,21
72
+ jena_weather/10T/medium,Seasonal_Naive,2572.8219336219336,2572.8219336219336,12.447531114718615,0.7160100391723763,0.8990536291309329,0.4023956803535614,27.512189615220795,50.72299215959104,0.311286135757249,0.07639028565640756,0.21183609008512827,Nature,21
73
+ jena_weather/10T/long,Seasonal_Naive,2044.5376984126983,2044.5376984126983,11.441627397486773,0.7614923703210493,0.7718160693153647,0.4043349656668781,32.10701219708694,45.21656442513847,0.2766879110680792,0.0700132799578619,0.2371949131202996,Nature,21
74
+ jena_weather/D/short,Seasonal_Naive,633.5864087301587,633.5864087301587,13.136656746031745,1.5734140297468562,1.7707135350571066,0.4854136849221913,31.00640730511845,25.17114238031637,0.15157141745411598,0.07910414448099358,0.2105908566035542,Nature,21
75
+ us_births/W/short,Seasonal_Naive,5288246.857142857,5288246.857142857,1720.3214285714287,1.5634204337929731,0.023174324205943515,0.023229679891041348,13.279190377688378,2299.6188504060533,0.031217100406456557,0.023353194707727513,0.01929679613291714,Healthcare,1
76
+ m_dense/H/short,Seasonal_Naive,145691.3111111111,145691.3111111111,180.18139756944444,1.4875444172240992,0.5399732053067908,0.3908195287297072,14.354127446705878,381.69531187992226,0.6770323708995337,0.31959690096169935,0.27479786933969136,Transport,1
77
+ m_dense/H/medium,Seasonal_Naive,158402.70222222223,158402.70222222223,189.1006423611111,1.569946511898904,0.5428334697930458,0.397995368109809,22.89829990230752,397.99836962256796,0.6924668813918432,0.32901122738058514,0.3773835994986822,Transport,1
78
+ m_dense/H/long,Seasonal_Naive,137584.05530864198,137584.05530864198,175.8238888888889,1.4779178744488217,0.4978060829089136,0.38833718102796383,27.64819460133164,370.92324719359664,0.6421229733186591,0.3043771431636812,0.4189507904607682,Transport,1
79
+ kdd_cup_2018/D/short,Seasonal_Naive,3183.23772462431,3183.23772462431,23.884613481510574,1.4970343738270926,0.5893064466306873,0.5721879846611051,24.580612052672397,56.42018898075679,1.263872161836989,0.5350407118596068,0.674506129145155,Nature,1
80
+ electricity/D/short,Seasonal_Naive,4108036968.189694,4108036968.189694,6964.321117117117,1.987041399842847,0.6052061423957089,0.13582226488305205,24.595303547998157,64093.9698270414,1.0532768308357283,0.11444692995228782,0.10405987098536745,Energy,1
81
+ restaurant/D/short,Seasonal_Naive,269.8283833006444,269.8283833006444,10.66129634818343,1.0060757450046092,0.9434227416570351,0.5561225187815214,20.74904381007386,16.426453765211907,0.7559834619860213,0.49065755991884735,0.6770164746085788,Sales,1
82
+ jena_weather/H/short,Seasonal_Naive,1941.9557226399331,1941.9557226399331,11.035693889672096,0.7229102185557323,1.1349936220434893,0.40077710759656054,17.382581140637207,44.06762669624872,0.2701317147149199,0.06764809310095782,0.15420788787522166,Nature,21
83
+ jena_weather/H/medium,Seasonal_Naive,1951.8111111111111,1951.8111111111111,12.670400080605159,0.8886133410507664,1.7017065286708326,0.45474790719102887,42.286346185217376,44.1793063674738,0.27034101099927343,0.077532425227887,0.3428055217231077,Nature,21
84
+ jena_weather/H/long,Seasonal_Naive,2714.1007936507935,2714.1007936507935,15.67983630952381,1.2677178473920059,1.8661278391921836,0.5020780815368567,51.127769997760254,52.097032484113655,0.31368185519406616,0.09440998667650671,0.41914340130808575,Nature,21
85
+ m4_monthly/M/short,Seasonal_Naive,2652970.8577407408,2652970.8577407408,700.2370131655092,1.259717038974429,0.19222692431343927,0.15988281532570167,10.588031340884587,1628.794295711015,0.33851937015163663,0.14553329004027185,0.12192063664829357,Econ/Fin,1
86
+ hierarchical_sales/D/short,Seasonal_Naive,58.96940884663084,58.96940884663084,3.4495245969408845,1.1348033996861053,1.219942090035255,1.1912773542022284,28.8672586949405,7.679154175209066,2.3558794594517027,1.058275945161004,1.7364572065613715,Sales,1
87
+ m4_hourly/H/short,Seasonal_Naive,3614355.993558776,3614355.993558776,353.8562298711755,1.1932101877276544,0.1561203125786282,0.1391227341312525,9.229226853352637,1901.1459685039379,0.25954838815818215,0.04830918594592099,0.03757255147212195,Econ/Fin,1
88
+ m4_yearly/A/short,Seasonal_Naive,4070174.893125562,4070174.893125562,1007.6444123429384,3.965954931783283,0.17520083696448324,0.16353434447586868,52.037766411775884,2017.4674453694568,0.323510256896443,0.1615804524853844,0.13714565740983498,Econ/Fin,1
89
+ hierarchical_sales/W/short,Seasonal_Naive,1045.4721927966102,1045.4721927966102,13.569120762711865,1.0250133777288337,0.9503837463030324,0.5798107884238987,15.297190661198302,32.33376242871544,1.4844588487218926,0.6229649713667368,0.8322267526416594,Sales,1
90
+ kdd_cup_2018/H/short,Seasonal_Naive,5615.976023835748,5615.976023835748,32.23957254610716,1.340425278558367,1.4218929148819215,0.6653979848821596,11.487358350395635,74.9398160114885,1.5686260624453057,0.6748326381021144,0.547536580422314,Nature,1
91
+ kdd_cup_2018/H/medium,Seasonal_Naive,5785.5976861948375,5785.5976861948375,31.3213270934715,1.4289997072062277,1.6378374272295781,0.6550327560864638,21.848163370875977,76.06311646386071,1.592138770600474,0.6556120959870002,0.7587892026846513,Nature,1
92
+ kdd_cup_2018/H/long,Seasonal_Naive,4863.8870949314805,4863.8870949314805,29.603438898808303,1.3355336658089667,1.3504101339311876,0.8098963463700346,25.694140506460055,69.74157364822995,1.6364191009705393,0.694616286015704,0.9362833347539716,Nature,1
93
+ sz_taxi/15T/short,Seasonal_Naive,31.23822759271978,31.23822759271978,3.8183243114578564,0.7644167735543712,1312692316403.7466,0.538321787329966,7.055930253561324,5.58911688844667,0.5226063327326282,0.3570296534180041,0.3087068505119876,Transport,1
94
+ sz_taxi/15T/medium,Seasonal_Naive,28.930445379273504,28.930445379273504,3.6711446647970085,0.7134840540400068,18917890105834.12,0.5172677325387286,10.816848306969383,5.378702945810774,0.5005780877467839,0.34166128798341444,0.379172158612487,Transport,1
95
+ sz_taxi/15T/long,Seasonal_Naive,28.48211582977208,28.48211582977208,3.6789685719373217,0.6909431344941248,3284113398767.079,0.5217545990251068,12.685376303466118,5.336863857151696,0.4939706119705816,0.3405187776085254,0.4279704063043717,Transport,1
96
+ solar/10T/short,Seasonal_Naive,37.917036857512166,37.917036857512166,2.5271684706936206,1.1057028063509833,4.40491207343912,0.925039670208932,18.785745549933484,6.157681126650857,1.7912687729072838,0.7351530344494263,0.8595386998005952,Energy,1
97
+ solar/10T/medium,Seasonal_Naive,29.228987641008626,29.228987641008626,2.118083017100752,0.9270005404761702,3.219568147534267,0.6587355148950821,15.807425762449464,5.406383970918883,1.1996980737201242,0.4700110331163171,0.6550635536405177,Energy,1
98
+ solar/10T/long,Seasonal_Naive,26.90307906275345,26.90307906275345,1.995840144718167,0.8709317964102903,2.310040346896705,0.6597688060895561,18.202191205836492,5.18681781661487,1.1210476934837867,0.4313689182665159,0.6738494837655387,Energy,1
tinycast/scale.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seasonal discretization scale factor.
2
+
3
+ Maps a pandas frequency string to the model's seasonal scale factor
4
+ ``s = base_seasonality / (samples per natural period)``, i.e. how many
5
+ context samples fall in one canonical seasonal cycle at that sampling rate.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ # Cycles per canonical day: the unit every scale factor is expressed against.
12
+ # ``tinycast.losses`` reads this so the seasonal lag the committing term folds
13
+ # at and the scale factor the model is conditioned on cannot drift apart.
14
+ BASE_SEASONALITY = 24.0
15
+
16
+
17
+ def seasonal_scale_factor(freq: str, domain: Optional[str] = None) -> float:
18
+ """Seasonal scale factor for a pandas frequency string."""
19
+ has_weekly = domain in ["Transport", "Healthcare", "Sales"]
20
+
21
+ if freq == "4S":
22
+ factor = BASE_SEASONALITY / (3600.0 / 4)
23
+ elif freq == "10S":
24
+ factor = BASE_SEASONALITY / 360
25
+ elif freq == "T":
26
+ factor = BASE_SEASONALITY / (24.0 * 60)
27
+ elif freq[-1] == "T":
28
+ n_min = int(freq[:-1])
29
+ factor = BASE_SEASONALITY / (24 * 60 / n_min)
30
+ elif freq == "H":
31
+ factor = BASE_SEASONALITY / 24
32
+ elif freq == "6H":
33
+ factor = BASE_SEASONALITY / 4
34
+ elif freq == "D":
35
+ factor = BASE_SEASONALITY / 7 if has_weekly else BASE_SEASONALITY / 365
36
+ elif freq[-1] == "D" and "WED" not in freq:
37
+ n = int(freq[:-1])
38
+ factor = BASE_SEASONALITY / 7 if has_weekly else BASE_SEASONALITY / 365
39
+ factor *= n
40
+ elif freq == "W" or "W-" in freq:
41
+ factor = BASE_SEASONALITY / (365.0 / 7)
42
+ elif freq == "M" or "M-" in freq or freq == "MS":
43
+ factor = BASE_SEASONALITY / 12
44
+ elif "Q" in freq:
45
+ factor = BASE_SEASONALITY / 4.0
46
+ elif "A" in freq:
47
+ factor = BASE_SEASONALITY / 4.0
48
+ else:
49
+ raise NotImplementedError(
50
+ f"{freq} not implemented. Add {freq} option to seasonal_scale_factor."
51
+ )
52
+ return factor
tinycast/synth.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic series generators: Gaussian process, spike trains, TSI.
2
+
3
+ These are the three families in TinyCast's synthetic pretraining shards. They
4
+ follow the recipe published by Reverso (arXiv 2602.17634, Appendix A),
5
+ implemented from its Algorithms 1 to 3 and Table 8:
6
+
7
+ * :func:`generate_gp` is Algorithm 1: a Gaussian process over a 38-entry kernel
8
+ bank (Constant; Linear with sigma in {0, 1, 10}; RBF with l in {0.1, 1, 10};
9
+ RationalQuadratic with alpha in {0.1, 1, 10}; Matern with nu in
10
+ {0.5, 1.5, 2.5} crossed with l in {0.1, 1, 10}; Periodic over a 19-period set
11
+ normalized by series length), with J ~ U{1,5} kernels composed by random sum
12
+ or product, and a mean function that is a linear trend with probability 1/2.
13
+ * :func:`generate_spikes` is Algorithm 2: trapezoid pulse trains (a quarter of
14
+ the pulse ramping up, half flat, the remainder ramping down) tiled at a fixed
15
+ period over a baseline, in an upward or downward variant, plus white noise.
16
+ * :func:`generate_tsi` is Algorithm 3, the trend-seasonal-impulse process: an
17
+ optional trend, K ~ U{1,3} seasonal components (sine, sawtooth or square),
18
+ noise, sparse outliers and level shifts.
19
+
20
+ All three return float32 with no normalization applied. The corpus builder
21
+ z-normalizes per series on write, which is why the absolute scale of any
22
+ parameter below does not matter and the shape does.
23
+
24
+ DOCUMENTED ASSUMPTIONS. The source paper leaves the following symbolic, and
25
+ neither Kairos (arXiv 2509.25826) nor Chronos-2 (arXiv 2510.15821), which the
26
+ respective algorithms defer to, publishes them. We record the choices we made
27
+ rather than presenting the families as an exact replication:
28
+
29
+ A1 Algorithm 1 trend units. The slope m ~ U[-0.01, 0.01] is applied per
30
+ index step (mu_t = m*t + c with t = 0..L-1), not per unit of the [0,1]
31
+ kernel grid. On the [0,1] grid the sampled trend would be at most 1% of
32
+ the GP's unit scale, which makes the probability-1/2 branch pointless.
33
+ Index units give trend-dominated series about half the time, which is a
34
+ realistic class, and the per-series z-normalization on write removes the
35
+ magnitude difference.
36
+ A2 Algorithm 2 numeric ranges: baseline U[-1, 1], period U{16, L//8}, pulse
37
+ width U{4, p}, amplitude U[0.5, 3], noise sigma U[0.01, 0.3]. Under
38
+ z-normalization the levels are immaterial; shape, sparsity and period are
39
+ what the model sees.
40
+ A3 Algorithm 3 probabilities and ranges: P_trend = 0.5, P_seasonal = 0.8,
41
+ P_noise = 0.8, P_outlier = 0.2, P_shift = 0.2; trend types linear,
42
+ exponential, quadratic and piecewise linear; periods taken from the
43
+ Algorithm 1 period set in index steps, filtered to at most L/2; amplitude
44
+ U[0.5, 2]; noise sigma U[0.05, 0.5], normal or Laplace; U{1, L//100}
45
+ outliers at +/- U[3, 8] standard deviations; U{1,3} level shifts of
46
+ magnitude +/- U[0.5, 3].
47
+ A4 The family mix. The paper gives a total series count but not the
48
+ proportions, and describes spikes and TSI as additions to a GP majority.
49
+ :mod:`tinycast.corpus` uses 70/15/15.
50
+
51
+ DEVICE. :func:`generate_gp` draws from numpy on the CPU path and from torch on
52
+ the CUDA path, so the same seed produces different series on the two branches.
53
+ The published shards came from the CUDA branch, and :mod:`tinycast.corpus`
54
+ refuses anything else for that reason. The CPU branch is kept here because it
55
+ is useful at small scale and because spikes and TSI are numpy either way.
56
+ """
57
+ from __future__ import annotations
58
+
59
+ import math
60
+
61
+ import numpy as np
62
+
63
+ # Algorithm 1 / Table 8 period set. Table 8 specifies the periodic kernel's p
64
+ # as a fraction of the series length, so these enter the bank divided by L.
65
+ PERIODS = (24, 48, 96, 168, 336, 672, 7, 14, 30, 60,
66
+ 365, 730, 4, 26, 52, 6, 12, 40, 10)
67
+
68
+ # Jitter added to the diagonal before factorization, and the larger retry value
69
+ # the CPU path falls back to. The CUDA path does not retry: see generate_gp.
70
+ GP_JITTER = 1e-6
71
+ GP_JITTER_RETRY = 1e-4
72
+
73
+
74
+ def kernel_bank(length: int) -> list[tuple[str, tuple]]:
75
+ """The 38 Table-8 kernels as (tag, params) pairs, for a series length.
76
+
77
+ Only the periodic entries depend on ``length``, and they depend on it
78
+ because Table 8 normalizes the period by the series length.
79
+ """
80
+ bank: list[tuple[str, tuple]] = [("const", (1.0,))]
81
+ bank += [("linear", (s,)) for s in (0.0, 1.0, 10.0)]
82
+ bank += [("rbf", (l,)) for l in (0.1, 1.0, 10.0)]
83
+ bank += [("rq", (a,)) for a in (0.1, 1.0, 10.0)]
84
+ bank += [("matern", (nu, l)) for nu in (0.5, 1.5, 2.5) for l in (0.1, 1.0, 10.0)]
85
+ bank += [("periodic", (p / length,)) for p in PERIODS]
86
+ return bank
87
+
88
+
89
+ def _matern(d: np.ndarray, nu: float, l: float) -> np.ndarray:
90
+ """Matern closed forms for nu in {0.5, 1.5, 2.5}, so no Bessel call."""
91
+ if nu == 0.5:
92
+ return np.exp(-d / l)
93
+ if nu == 1.5:
94
+ a = math.sqrt(3.0) * d / l
95
+ return (1.0 + a) * np.exp(-a)
96
+ if nu == 2.5:
97
+ a = math.sqrt(5.0) * d / l
98
+ return (1.0 + a + a * a / 3.0) * np.exp(-a)
99
+ raise ValueError(f"unsupported Matern nu={nu}")
100
+
101
+
102
+ def _dense_kernel(tag: str, params: tuple, t: np.ndarray) -> np.ndarray:
103
+ d = np.abs(t[:, None] - t[None, :])
104
+ if tag == "const":
105
+ return np.full_like(d, params[0])
106
+ if tag == "linear": # sigma^2 + x.x'
107
+ return params[0] ** 2 + np.outer(t, t)
108
+ if tag == "rbf":
109
+ return np.exp(-(d ** 2) / (2.0 * params[0] ** 2))
110
+ if tag == "rq": # Table 8's form carries no lengthscale (l=1)
111
+ return (1.0 + d ** 2 / (2.0 * params[0])) ** (-params[0])
112
+ if tag == "matern":
113
+ return _matern(d, *params)
114
+ if tag == "periodic": # Table 8: exp(-2 sin^2(pi d / p)), l=1
115
+ return np.exp(-2.0 * np.sin(np.pi * d / params[0]) ** 2)
116
+ raise ValueError(tag)
117
+
118
+
119
+ def generate_gp(
120
+ num_series: int,
121
+ length: int,
122
+ seed: int | None = None,
123
+ max_kernels: int = 5,
124
+ device: str | None = None,
125
+ batch: int = 32,
126
+ ) -> np.ndarray:
127
+ """Algorithm 1: GP samples with composed Table-8 kernels and a trend mean.
128
+
129
+ Sampling is by dense jittered Cholesky. A circulant or FFT sampler is not
130
+ an option here because the composed bank is not stationary: the linear and
131
+ periodic entries break both stationarity and circularity.
132
+
133
+ ``device='cuda'`` batches the covariance construction and the factorization
134
+ on the GPU, which is what production volume at length 4096 needs. Both
135
+ paths work in float64; float32 loses the high-frequency modes of the
136
+ periodic entries outright. The CUDA path uses ``cholesky_ex`` and writes
137
+ NaN for any row whose covariance failed to factorize, leaving the caller to
138
+ drop it, while the CPU path retries once at a larger jitter and raises if
139
+ that also fails.
140
+
141
+ ``device=None`` selects the numpy path; any device string selects the torch
142
+ path on that device, so ``'cpu'`` runs the batched code on the CPU and is a
143
+ third result rather than a cheap stand-in for either. The paths do not agree
144
+ row by row. ``batch`` is part of the data on the torch path rather than a
145
+ memory knob: see :mod:`tinycast.corpus`.
146
+
147
+ Returns float32 of shape ``(num_series, length)``, unnormalized.
148
+ """
149
+ rng = np.random.default_rng(seed)
150
+ bank = kernel_bank(length)
151
+ t = np.linspace(0.0, 1.0, length)
152
+ idx = np.arange(length, dtype=np.float64)
153
+ out = np.empty((num_series, length), dtype=np.float32)
154
+
155
+ def compose_indices():
156
+ n_k = int(rng.integers(1, max_kernels + 1))
157
+ picks = [int(rng.integers(0, len(bank))) for _ in range(n_k)]
158
+ ops = [int(rng.integers(0, 2)) for _ in range(n_k - 1)]
159
+ return picks, ops
160
+
161
+ def mean_fn():
162
+ # ASSUMPTION A1: the trend is in index units.
163
+ if rng.uniform() < 0.5:
164
+ m = rng.uniform(-0.01, 0.01)
165
+ c = rng.uniform(-0.1, 0.1)
166
+ return m * idx + c
167
+ return np.zeros(length)
168
+
169
+ if device is None:
170
+ for i in range(num_series):
171
+ picks, ops = compose_indices()
172
+ K = _dense_kernel(*bank[picks[0]], t)
173
+ for op, pi in zip(ops, picks[1:]):
174
+ Kn = _dense_kernel(*bank[pi], t)
175
+ K = K + Kn if op == 0 else K * Kn
176
+ K = K + GP_JITTER * np.eye(length)
177
+ try:
178
+ Lc = np.linalg.cholesky(K)
179
+ except np.linalg.LinAlgError:
180
+ K += (GP_JITTER_RETRY - GP_JITTER) * np.eye(length)
181
+ Lc = np.linalg.cholesky(K)
182
+ out[i] = (Lc @ rng.standard_normal(length) + mean_fn()).astype(np.float32)
183
+ return out
184
+
185
+ import torch
186
+
187
+ dev = torch.device(device)
188
+ g = torch.Generator(device=dev)
189
+ g.manual_seed(int(seed) if seed is not None else 0)
190
+ tt = torch.linspace(0.0, 1.0, length, device=dev, dtype=torch.float64)
191
+ dd = (tt[:, None] - tt[None, :]).abs()
192
+ d2 = dd * dd
193
+ outer = tt[:, None] * tt[None, :]
194
+ eye = torch.eye(length, device=dev, dtype=torch.float64)
195
+
196
+ def _k(tag: str, params: tuple) -> "torch.Tensor":
197
+ if tag == "const":
198
+ return torch.full_like(dd, params[0])
199
+ if tag == "linear":
200
+ return params[0] ** 2 + outer
201
+ if tag == "rbf":
202
+ return torch.exp(-d2 / (2.0 * params[0] ** 2))
203
+ if tag == "rq":
204
+ return (1.0 + d2 / (2.0 * params[0])).pow(-params[0])
205
+ if tag == "matern":
206
+ nu, l = params
207
+ if nu == 0.5:
208
+ return torch.exp(-dd / l)
209
+ if nu == 1.5:
210
+ a = math.sqrt(3.0) * dd / l
211
+ return (1.0 + a) * torch.exp(-a)
212
+ a = math.sqrt(5.0) * dd / l
213
+ return (1.0 + a + a * a / 3.0) * torch.exp(-a)
214
+ return torch.exp(-2.0 * torch.sin(math.pi * dd / params[0]) ** 2)
215
+
216
+ Ks = torch.empty((batch, length, length), device=dev, dtype=torch.float64)
217
+ done = 0
218
+ while done < num_series:
219
+ b = min(batch, num_series - done)
220
+ means = np.empty((b, length), dtype=np.float64)
221
+ for i in range(b):
222
+ picks, ops = compose_indices()
223
+ K = _k(*bank[picks[0]])
224
+ for op, pi in zip(ops, picks[1:]):
225
+ Kn = _k(*bank[pi])
226
+ K = K + Kn if op == 0 else K * Kn
227
+ Ks[i] = K
228
+ means[i] = mean_fn()
229
+ Kb = Ks[:b] + GP_JITTER * eye
230
+ Lc, info = torch.linalg.cholesky_ex(Kb)
231
+ z = torch.randn(b, length, 1, generator=g, device=dev, dtype=torch.float64)
232
+ s = torch.matmul(Lc, z).squeeze(-1)
233
+ bad = info > 0
234
+ if bool(bad.any()):
235
+ s[bad] = float("nan")
236
+ out[done:done + b] = (s.cpu().numpy() + means).astype(np.float32)
237
+ done += b
238
+ return out
239
+
240
+
241
+ def generate_spikes(
242
+ num_series: int, length: int, seed: int | None = None,
243
+ ) -> np.ndarray:
244
+ """Algorithm 2: trapezoid pulse trains over a baseline, plus white noise.
245
+
246
+ The numeric ranges are ASSUMPTION A2. Levels wash out under the per-series
247
+ z-normalization the corpus applies on write, so what this family
248
+ contributes is the pulse shape and its spacing.
249
+ """
250
+ rng = np.random.default_rng(seed)
251
+ out = np.empty((num_series, length), dtype=np.float32)
252
+ for i in range(num_series):
253
+ b = rng.uniform(-1.0, 1.0)
254
+ p = int(rng.integers(16, max(17, length // 8) + 1))
255
+ w = int(rng.integers(4, p + 1))
256
+ a = rng.uniform(0.5, 3.0)
257
+ sigma = rng.uniform(0.01, 0.3)
258
+ sign = -1.0 if rng.integers(0, 2) == 0 else 1.0 # downward or upward
259
+ up = w // 4
260
+ flat = w // 2
261
+ down = w - up - flat
262
+ pulse = np.concatenate([
263
+ np.linspace(0.0, a, max(up, 1)),
264
+ np.full(max(flat, 1), a),
265
+ np.linspace(a, 0.0, max(down, 1)),
266
+ ])[:w]
267
+ x = np.full(length, b)
268
+ for start in range(0, length, p):
269
+ seg = min(w, length - start)
270
+ x[start:start + seg] += sign * pulse[:seg]
271
+ x += rng.normal(0.0, sigma, length)
272
+ out[i] = x.astype(np.float32)
273
+ return out
274
+
275
+
276
+ def generate_tsi(
277
+ num_series: int, length: int, seed: int | None = None,
278
+ p_trend: float = 0.5, p_seas: float = 0.8, p_noise: float = 0.8,
279
+ p_out: float = 0.2, p_shift: float = 0.2,
280
+ ) -> np.ndarray:
281
+ """Algorithm 3: trend, seasonal components, noise, outliers, level shifts.
282
+
283
+ The probabilities and ranges are ASSUMPTION A3: the source paper states the
284
+ structure and defers the numbers, and the work it defers to does not
285
+ publish them either.
286
+ """
287
+ rng = np.random.default_rng(seed)
288
+ t = np.arange(length, dtype=np.float64)
289
+ tn = t / max(length - 1, 1)
290
+ periods = [p for p in PERIODS if p <= length // 2]
291
+ out = np.empty((num_series, length), dtype=np.float32)
292
+ for i in range(num_series):
293
+ x = np.zeros(length)
294
+ if rng.uniform() < p_trend:
295
+ kind = rng.integers(0, 4)
296
+ if kind == 0: # linear
297
+ x += rng.uniform(-2.0, 2.0) * tn
298
+ elif kind == 1: # exponential
299
+ x += np.exp(rng.uniform(0.5, 2.0) * tn) - 1.0
300
+ elif kind == 2: # quadratic
301
+ x += rng.uniform(-2.0, 2.0) * tn ** 2
302
+ else: # piecewise linear
303
+ cp = int(rng.integers(1, length - 1))
304
+ s1, s2 = rng.uniform(-2.0, 2.0, 2)
305
+ x[:cp] += s1 * tn[:cp]
306
+ x[cp:] += s1 * tn[cp] + s2 * (tn[cp:] - tn[cp])
307
+ if rng.uniform() < p_seas and periods:
308
+ n_comp = int(rng.integers(1, 4))
309
+ chosen = rng.choice(periods, size=min(n_comp, len(periods)), replace=False)
310
+ for p in chosen:
311
+ amp = rng.uniform(0.5, 2.0)
312
+ phi = rng.uniform(0.0, 2.0 * np.pi)
313
+ arg = 2.0 * np.pi * t / p + phi
314
+ wave = rng.integers(0, 3)
315
+ if wave == 0:
316
+ x += amp * np.sin(arg)
317
+ elif wave == 1: # sawtooth
318
+ x += amp * (2.0 * ((arg / (2.0 * np.pi)) % 1.0) - 1.0)
319
+ else: # square
320
+ x += amp * np.sign(np.sin(arg))
321
+ if rng.uniform() < p_noise:
322
+ sigma = rng.uniform(0.05, 0.5)
323
+ if rng.integers(0, 2) == 0:
324
+ x += rng.normal(0.0, sigma, length)
325
+ else:
326
+ x += rng.laplace(0.0, sigma / math.sqrt(2.0), length)
327
+ if rng.uniform() < p_out:
328
+ n = int(rng.integers(1, max(2, length // 100)))
329
+ pos = rng.integers(0, length, n)
330
+ mag = rng.uniform(3.0, 8.0, n) * max(x.std(), 0.1)
331
+ x[pos] += mag * rng.choice([-1.0, 1.0], n)
332
+ if rng.uniform() < p_shift:
333
+ n = int(rng.integers(1, 4))
334
+ for _ in range(n):
335
+ pos = int(rng.integers(1, length))
336
+ x[pos:] += rng.uniform(0.5, 3.0) * (1.0 if rng.integers(0, 2) else -1.0)
337
+ out[i] = x.astype(np.float32)
338
+ return out
tinycast/train.py ADDED
@@ -0,0 +1,714 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training entry point for TinyCast.
2
+
3
+ ``train`` runs the shipped recipe: a four-block autoregressive rollout under
4
+ scheduled sampling, the nine-quantile pinball loss plus the gated committing
5
+ term, AdamW, and a warmup-stable-decay learning-rate schedule. It runs
6
+ single-process, and CPU is a first-class device.
7
+
8
+ What a short run does and does not reproduce. The released 146,505-parameter
9
+ checkpoint is 36,621 optimizer steps at an effective batch of 4096, in
10
+ bf16-mixed across eight GPUs, over GIFT-Eval-Pretrain plus Chronos KernelSynth
11
+ plus four synthetic shards, with the final weights the uniform mean of the last
12
+ eight periodic checkpoints. A short run on a small corpus reproduces the
13
+ mechanics: the rollout, the objective, the schedule shape, the optimizer. It
14
+ does not reproduce the checkpoint or its numbers.
15
+
16
+ ``max_steps`` reshapes the run rather than truncating it. Warmup, stable and
17
+ decay are fractions of ``max_steps``, and the scheduled-sampling feedback
18
+ probability ramps over its first half, so a 200-step run warms up, holds and
19
+ decays inside 200 steps and reaches full feedback by step 100. A 200-step run
20
+ is therefore not a prefix of a 36,621-step run, and their step-100 states are
21
+ not comparable.
22
+
23
+ Two further departures from the released run, both deliberate. This entry point
24
+ trains in the model's own precision instead of bf16-mixed, and it writes
25
+ periodic checkpoints but does not average them.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import collections.abc
30
+ import json
31
+ import math
32
+ import os
33
+ import random
34
+ from dataclasses import dataclass, field
35
+ from pathlib import Path
36
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
37
+
38
+ import torch
39
+
40
+ from .config import TinyCastConfig
41
+ from .losses import (
42
+ COMMIT_WEIGHT,
43
+ committing_loss,
44
+ pinball_loss,
45
+ seasonal_copy_baseline,
46
+ )
47
+ from .model import TinyCastForPrediction
48
+
49
+ # --- the shipped recipe ----------------------------------------------------
50
+ # Every value below can be overridden by an attribute of the same name on the
51
+ # config object passed to ``train``; the defaults are what the released
52
+ # checkpoint was trained with.
53
+ AR_CHUNKS = 4 # K: blocks rolled out per training window
54
+ SCHEDULED_SAMPLING_MAX = 0.5 # peak probability of feeding our own median
55
+ LEARNING_RATE = 3e-3
56
+ MIN_LEARNING_RATE = 1e-5
57
+ WARMUP_FRACTION = 0.05
58
+ DECAY_FRACTION = 0.35
59
+ WEIGHT_DECAY = 0.01
60
+ GRAD_CLIP = 1.0
61
+ ADAM_BETAS = (0.9, 0.95)
62
+ COMMIT_GATED = True
63
+
64
+ # Guards carried over from the released run: predictions are optimized inside a
65
+ # fixed normalized band, and a window whose context is flat is not scored.
66
+ _PRED_CLAMP = 5.0
67
+ _TARGET_CLAMP = 10.0
68
+ _MIN_RANGE = 1e-4
69
+
70
+
71
+ @dataclass
72
+ class TrainResult:
73
+ """What a finished run leaves behind.
74
+
75
+ Attributes:
76
+ checkpoint_path: the final ``model.safetensors``, next to a
77
+ ``config.json``, so ``tinycast.load_checkpoint`` reads it directly.
78
+ steps: optimizer steps actually run.
79
+ losses: one training loss per optimizer step, in order.
80
+ learning_rates: the learning rate each of those steps used.
81
+ checkpoints: the periodic checkpoints written along the way, oldest
82
+ first. The released weights are the uniform mean of the last eight
83
+ of these; ``train`` writes them but does not average them.
84
+ window_width: the sample width the run required, ``L + K * p``.
85
+ """
86
+
87
+ checkpoint_path: str
88
+ steps: int
89
+ losses: List[float] = field(default_factory=list)
90
+ learning_rates: List[float] = field(default_factory=list)
91
+ checkpoints: List[str] = field(default_factory=list)
92
+ window_width: int = 0
93
+
94
+
95
+ def training_window_width(
96
+ config: TinyCastConfig, chunks: Optional[int] = None
97
+ ) -> int:
98
+ """Sample width the rollout consumes: ``L + K * p``.
99
+
100
+ ``L`` is the encoder context, ``p`` the horizon unit and ``K`` the number of
101
+ autoregressive blocks. Each block re-normalizes a context that has slid
102
+ forward by ``p``, so the last block still needs a full ``L`` of history
103
+ behind it. Call this instead of hard-coding the width.
104
+ """
105
+ k = int(_setting(config, "ar_chunks", AR_CHUNKS) if chunks is None else chunks)
106
+ if k < 1:
107
+ raise ValueError(f"ar_chunks must be at least 1, got {k}.")
108
+ return int(config.seq_len) + k * int(config.output_token_len)
109
+
110
+
111
+ def _setting(config: Any, name: str, default: Any) -> Any:
112
+ value = getattr(config, name, None)
113
+ return default if value is None else value
114
+
115
+
116
+ # --- reproducibility -------------------------------------------------------
117
+
118
+ def _seed_everything(seed: int) -> None:
119
+ random.seed(seed)
120
+ try:
121
+ import numpy as np
122
+
123
+ np.random.seed(seed % (2 ** 32))
124
+ except ImportError:
125
+ pass
126
+ torch.manual_seed(seed)
127
+ if torch.cuda.is_available():
128
+ torch.cuda.manual_seed_all(seed)
129
+
130
+
131
+ class _Determinism:
132
+ """Turn deterministic kernels on, then put the process back as it was.
133
+
134
+ The flags are process-global, so a library function has no business leaving
135
+ them changed. Entered only when the caller asks for ``deterministic=True``.
136
+ """
137
+
138
+ def __init__(self, enabled: bool, device: torch.device) -> None:
139
+ self.enabled = bool(enabled)
140
+ self.device = device
141
+ self._saved: Dict[str, Any] = {}
142
+
143
+ def __enter__(self) -> "_Determinism":
144
+ if not self.enabled:
145
+ return self
146
+ self._saved["algorithms"] = torch.are_deterministic_algorithms_enabled()
147
+ self._saved["cudnn_deterministic"] = torch.backends.cudnn.deterministic
148
+ self._saved["cudnn_benchmark"] = torch.backends.cudnn.benchmark
149
+ self._saved["cublas"] = os.environ.get("CUBLAS_WORKSPACE_CONFIG")
150
+ if self.device.type == "cuda" and self._saved["cublas"] is None:
151
+ # cuBLAS needs this set before its handle is created for the
152
+ # deterministic reduction path to be available.
153
+ os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
154
+ torch.backends.cudnn.deterministic = True
155
+ torch.backends.cudnn.benchmark = False
156
+ torch.use_deterministic_algorithms(True, warn_only=True)
157
+ return self
158
+
159
+ def __exit__(self, *exc: Any) -> None:
160
+ if not self.enabled:
161
+ return
162
+ torch.use_deterministic_algorithms(self._saved["algorithms"])
163
+ torch.backends.cudnn.deterministic = self._saved["cudnn_deterministic"]
164
+ torch.backends.cudnn.benchmark = self._saved["cudnn_benchmark"]
165
+ if self._saved["cublas"] is None:
166
+ os.environ.pop("CUBLAS_WORKSPACE_CONFIG", None)
167
+ else:
168
+ os.environ["CUBLAS_WORKSPACE_CONFIG"] = self._saved["cublas"]
169
+
170
+
171
+ def _resolve_device(device: str) -> torch.device:
172
+ """Return the requested device, or say why it is unavailable.
173
+
174
+ A silent fall back to CPU would change the numbers with no signal, so a
175
+ request for an absent accelerator is an error.
176
+ """
177
+ dev = torch.device(device)
178
+ if dev.type == "cuda" and not torch.cuda.is_available():
179
+ raise RuntimeError(
180
+ "device='cuda' was requested but torch.cuda.is_available() is "
181
+ "False. Pass device='cpu' to train on CPU."
182
+ )
183
+ if dev.type == "mps" and not torch.backends.mps.is_available():
184
+ raise RuntimeError(
185
+ "device='mps' was requested but MPS is unavailable. Pass "
186
+ "device='cpu' to train on CPU."
187
+ )
188
+ return dev
189
+
190
+
191
+ # --- data ------------------------------------------------------------------
192
+
193
+ class _SampleBatcher:
194
+ """Group a plain iterable of samples into collated batches.
195
+
196
+ Re-iterated once per epoch, so a list, a sequence or any object with a
197
+ fresh ``__iter__`` works. A one-shot generator is exhausted after its first
198
+ pass and ``_epochs`` reports that rather than looping on nothing.
199
+ """
200
+
201
+ def __init__(self, samples: Iterable[Any], batch_size: int) -> None:
202
+ self.samples = samples
203
+ self.batch_size = int(batch_size)
204
+
205
+ def __iter__(self):
206
+ from torch.utils.data import default_collate
207
+
208
+ buffer: List[Any] = []
209
+ for sample in self.samples:
210
+ buffer.append(sample)
211
+ if len(buffer) == self.batch_size:
212
+ yield default_collate(buffer)
213
+ buffer = []
214
+ # A short tail is dropped, matching drop_last on the DataLoader path:
215
+ # a partial batch changes the effective batch size mid-run.
216
+
217
+
218
+ def _build_loader(
219
+ data: Any,
220
+ batch_size: int,
221
+ num_workers: int,
222
+ device: torch.device,
223
+ seed: int,
224
+ ) -> Iterable[Any]:
225
+ from torch.utils.data import DataLoader, Dataset, IterableDataset
226
+
227
+ if isinstance(data, DataLoader):
228
+ # The caller has already made every batching decision; respect it.
229
+ return data
230
+
231
+ pin_memory = device.type == "cuda"
232
+
233
+ def worker_init(worker_id: int) -> None:
234
+ _seed_everything(seed + 1 + worker_id)
235
+
236
+ if isinstance(data, IterableDataset):
237
+ return DataLoader(
238
+ data,
239
+ batch_size=batch_size,
240
+ num_workers=num_workers,
241
+ pin_memory=pin_memory,
242
+ worker_init_fn=worker_init,
243
+ )
244
+ if isinstance(data, Dataset):
245
+ generator = torch.Generator().manual_seed(seed)
246
+ return DataLoader(
247
+ data,
248
+ batch_size=batch_size,
249
+ shuffle=True,
250
+ drop_last=True,
251
+ num_workers=num_workers,
252
+ pin_memory=pin_memory,
253
+ generator=generator,
254
+ worker_init_fn=worker_init,
255
+ )
256
+ if isinstance(data, collections.abc.Iterable):
257
+ if num_workers:
258
+ raise ValueError(
259
+ "num_workers > 0 needs a Dataset or a DataLoader; a plain "
260
+ "iterable is batched in this process."
261
+ )
262
+ return _SampleBatcher(data, batch_size)
263
+ raise TypeError(
264
+ "data must be a Dataset, an IterableDataset, a DataLoader or an "
265
+ f"iterable of samples, got {type(data).__name__}."
266
+ )
267
+
268
+
269
+ def _epochs(loader: Iterable[Any]):
270
+ """Yield batches forever, restarting the loader between passes."""
271
+ while True:
272
+ produced = False
273
+ for batch in loader:
274
+ produced = True
275
+ yield batch
276
+ if not produced:
277
+ raise ValueError(
278
+ "the training data yielded no batches. A one-shot generator is "
279
+ "exhausted after one pass: pass a Dataset, a DataLoader or a "
280
+ "re-iterable sequence, and check that it holds at least one "
281
+ "full batch."
282
+ )
283
+
284
+
285
+ def _unpack_batch(
286
+ batch: Any, width: int, device: torch.device, dtype: torch.dtype
287
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
288
+ """Normalize a batch to ``(window, mask, scale_factor)`` on ``device``.
289
+
290
+ Accepts a mapping with a ``window`` key (plus optional ``mask`` and
291
+ ``scale_factor``), a bare tensor of windows, or a positional
292
+ ``(window, mask, scale_factor)`` sequence.
293
+ """
294
+ mask: Any = None
295
+ scale: Any = None
296
+ if isinstance(batch, dict):
297
+ if "window" not in batch:
298
+ raise KeyError(
299
+ f"batch dict needs a 'window' key, got {sorted(batch)}."
300
+ )
301
+ window = batch["window"]
302
+ mask = batch.get("mask")
303
+ scale = batch.get("scale_factor")
304
+ elif torch.is_tensor(batch):
305
+ window = batch
306
+ elif isinstance(batch, (tuple, list)):
307
+ window = batch[0]
308
+ mask = batch[1] if len(batch) > 1 else None
309
+ scale = batch[2] if len(batch) > 2 else None
310
+ else:
311
+ raise TypeError(
312
+ "a batch must be a dict, a tensor or a sequence, got "
313
+ f"{type(batch).__name__}."
314
+ )
315
+
316
+ window = torch.as_tensor(window).to(device=device, dtype=dtype)
317
+ if window.dim() == 3 and window.shape[-1] == 1:
318
+ window = window.squeeze(-1)
319
+ if window.dim() != 2:
320
+ raise ValueError(
321
+ f"window must be (B, L + K*p), got {tuple(window.shape)}."
322
+ )
323
+ if window.shape[1] != width:
324
+ raise ValueError(
325
+ f"window is {window.shape[1]} long but the rollout needs "
326
+ f"{width} = seq_len + ar_chunks * output_token_len. Use "
327
+ "tinycast.train.training_window_width(config) to size the dataset."
328
+ )
329
+
330
+ if mask is None:
331
+ observed = torch.isfinite(window).to(dtype)
332
+ else:
333
+ observed = torch.as_tensor(mask).to(device=device, dtype=dtype)
334
+ if observed.dim() == 3 and observed.shape[-1] == 1:
335
+ observed = observed.squeeze(-1)
336
+ if observed.shape != window.shape:
337
+ raise ValueError(
338
+ f"mask {tuple(observed.shape)} does not match window "
339
+ f"{tuple(window.shape)}."
340
+ )
341
+ observed = observed * torch.isfinite(window).to(dtype)
342
+ window = torch.nan_to_num(window, nan=0.0, posinf=0.0, neginf=0.0)
343
+
344
+ if scale is None:
345
+ # No metadata: one sample per canonical cycle. Datasets that carry a
346
+ # frequency should supply tinycast.scale.seasonal_scale_factor instead,
347
+ # since the local anchor channels and the seasonal copy both read it.
348
+ scale_factor = torch.ones(
349
+ window.shape[0], device=device, dtype=torch.float32
350
+ )
351
+ else:
352
+ scale_factor = torch.as_tensor(scale).to(
353
+ device=device, dtype=torch.float32
354
+ ).reshape(-1)
355
+ if scale_factor.numel() == 1:
356
+ scale_factor = scale_factor.expand(window.shape[0])
357
+ elif scale_factor.numel() != window.shape[0]:
358
+ raise ValueError(
359
+ f"scale_factor carries {scale_factor.numel()} entries for a "
360
+ f"batch of {window.shape[0]}."
361
+ )
362
+ return window, observed, scale_factor
363
+
364
+
365
+ # --- schedule --------------------------------------------------------------
366
+
367
+ def _wsd_lambda(
368
+ max_steps: int,
369
+ warmup_fraction: float,
370
+ decay_fraction: float,
371
+ min_ratio: float,
372
+ ) -> Callable[[int], float]:
373
+ """Warmup, stable, decay multiplier.
374
+
375
+ Linear to the peak over the warmup span, held there, then down to the floor
376
+ as ``1 - sqrt(progress)``: a sharp initial drop and a shallow tail. All
377
+ three spans are fractions of ``max_steps``, which is why ``max_steps``
378
+ reshapes a run instead of truncating one.
379
+ """
380
+ warmup_steps = max(1, int(max_steps * warmup_fraction))
381
+ decay_steps = max(1, int(max_steps * decay_fraction))
382
+ decay_start = max(warmup_steps + 1, max_steps - decay_steps)
383
+
384
+ def lr_lambda(step: int) -> float:
385
+ if step < warmup_steps:
386
+ return step / warmup_steps
387
+ if step < decay_start:
388
+ return 1.0
389
+ span = max(1, max_steps - decay_start)
390
+ progress = min(1.0, (step - decay_start) / span)
391
+ return min_ratio + (1.0 - min_ratio) * (1.0 - math.sqrt(progress))
392
+
393
+ return lr_lambda
394
+
395
+
396
+ # --- the rollout -----------------------------------------------------------
397
+
398
+ def _chunk_loss(
399
+ backbone: torch.nn.Module,
400
+ context: torch.Tensor,
401
+ target: torch.Tensor,
402
+ target_mask: torch.Tensor,
403
+ scale_factor: torch.Tensor,
404
+ quantiles: torch.Tensor,
405
+ commit_weight: float,
406
+ commit_gated: bool,
407
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
408
+ """One block: encode the context, score the block, hand back its median.
409
+
410
+ Returns the scalar block loss and the raw-magnitude median forecast the
411
+ next block feeds on.
412
+ """
413
+ horizon = target.shape[1]
414
+ y_norm, x_min, x_range = backbone.encode(
415
+ context, batch_first=True, scale_factor=scale_factor, horizon=horizon,
416
+ )
417
+ y_norm = y_norm.clamp(-_PRED_CLAMP, _PRED_CLAMP)
418
+ x_min_b = x_min.squeeze(-1) # (B, 1)
419
+ x_range_b = x_range.squeeze(-1) # (B, 1)
420
+ target_norm = ((target - x_min_b) / x_range_b).clamp(
421
+ -_TARGET_CLAMP, _TARGET_CLAMP
422
+ )
423
+
424
+ # A sample is scored only if its context has range to normalize by, its
425
+ # forecast is finite, and it has an observed target position.
426
+ non_batch = tuple(range(1, y_norm.dim()))
427
+ valid = (
428
+ (x_range_b.squeeze(-1) > _MIN_RANGE)
429
+ & torch.isfinite(y_norm).all(dim=non_batch)
430
+ & (target_mask.sum(dim=1) > 0)
431
+ ).to(y_norm.dtype)
432
+
433
+ per_sample = pinball_loss(
434
+ y_norm, target_norm, quantiles, target_mask, reduction="none",
435
+ )
436
+ per_sample = torch.nan_to_num(per_sample, 0.0, 0.0, 0.0) * valid
437
+
438
+ q_mid = y_norm.shape[-1] // 2
439
+ median_norm = y_norm[..., q_mid] # (B, H)
440
+
441
+ if commit_weight > 0.0:
442
+ copy_raw = seasonal_copy_baseline(context, horizon, scale_factor)
443
+ copy_norm = (copy_raw - x_min_b) / x_range_b
444
+ commit = committing_loss(
445
+ median_norm, target_norm, copy_norm, target_mask,
446
+ weight=commit_weight, gated=commit_gated, reduction="none",
447
+ )
448
+ per_sample = per_sample + torch.nan_to_num(commit, 0.0, 0.0, 0.0) * valid
449
+
450
+ n_valid = valid.sum()
451
+ if float(n_valid) < 1.0:
452
+ loss = per_sample.sum() * 0.0
453
+ else:
454
+ loss = per_sample.sum() / n_valid
455
+ if not torch.isfinite(loss):
456
+ loss = per_sample.sum() * 0.0
457
+
458
+ median_raw = median_norm * x_range_b + x_min_b
459
+ return loss, median_raw
460
+
461
+
462
+ def _rollout_loss(
463
+ backbone: torch.nn.Module,
464
+ window: torch.Tensor,
465
+ observed: torch.Tensor,
466
+ scale_factor: torch.Tensor,
467
+ *,
468
+ seq_len: int,
469
+ horizon_unit: int,
470
+ chunks: int,
471
+ epsilon: float,
472
+ quantiles: torch.Tensor,
473
+ commit_weight: float,
474
+ commit_gated: bool,
475
+ ) -> torch.Tensor:
476
+ """Roll out ``chunks`` blocks under scheduled sampling and average them.
477
+
478
+ Each block re-normalizes its own context window. Between blocks the model's
479
+ own median replaces the true values with probability ``epsilon``, so the
480
+ context the model sees late in the rollout is built the way it will be at
481
+ inference. Fed values are detached: they are inputs, not a gradient path.
482
+
483
+ An unobserved target position is always replaced by the median, whatever
484
+ ``epsilon`` says, rather than by the zero its mask stands for. On gap-free
485
+ data this makes no difference; on gappy data it keeps a filler value out of
486
+ the next context.
487
+ """
488
+ length, unit = int(seq_len), int(horizon_unit)
489
+ series = window
490
+ total = window.new_zeros(())
491
+ for k in range(chunks):
492
+ context = series[:, k * unit: k * unit + length]
493
+ start = length + k * unit
494
+ stop = start + unit
495
+ target = window[:, start:stop]
496
+ target_mask = observed[:, start:stop]
497
+ loss_k, median_raw = _chunk_loss(
498
+ backbone, context, target, target_mask, scale_factor,
499
+ quantiles, commit_weight, commit_gated,
500
+ )
501
+ total = total + loss_k
502
+ if k < chunks - 1:
503
+ use_pred = (
504
+ torch.rand(window.shape[0], 1, device=window.device) < epsilon
505
+ ) | (target_mask < 0.5)
506
+ fed = torch.where(use_pred, median_raw, target)
507
+ series = series.clone()
508
+ series[:, start:stop] = fed.detach()
509
+ return total / float(chunks)
510
+
511
+
512
+ # --- checkpoints -----------------------------------------------------------
513
+
514
+ def _save(model: torch.nn.Module, path: Path) -> None:
515
+ """Write the model in the released format.
516
+
517
+ ``safetensors`` stores each unique storage once, so the two weight-tied FFN
518
+ stacks are written once and ``tinycast.load_checkpoint`` restores the
519
+ sharing on load.
520
+ """
521
+ from safetensors.torch import save_model
522
+
523
+ save_model(model, str(path))
524
+
525
+
526
+ # --- entry point -----------------------------------------------------------
527
+
528
+ def train(
529
+ config: TinyCastConfig,
530
+ *,
531
+ data: Any,
532
+ max_steps: int,
533
+ output_dir: str,
534
+ seed: int = 42,
535
+ device: str = "cpu",
536
+ batch_size: int,
537
+ accumulate_grad_batches: int = 1,
538
+ num_workers: int = 0,
539
+ checkpoint_every: Optional[int] = None,
540
+ callbacks: Sequence[Callable[[Dict[str, Any]], None]] = (),
541
+ deterministic: bool = False,
542
+ ) -> TrainResult:
543
+ """Train a TinyCast model and return where it landed.
544
+
545
+ Args:
546
+ config: the architecture. Optional attributes named after the module
547
+ constants (``ar_chunks``, ``learning_rate``, ``commit_weight`` and
548
+ so on) override the shipped recipe; absent ones take it.
549
+ data: a ``Dataset``, an ``IterableDataset``, a ``DataLoader``, or a
550
+ plain iterable of samples. A sample is a mapping with a ``window``
551
+ of ``L + K * p`` values and optionally a ``mask`` and a
552
+ ``scale_factor``; a bare tensor or a positional triple works too.
553
+ Use :func:`training_window_width` to size the window.
554
+ max_steps: optimizer steps. This RESHAPES the run: the warmup, stable
555
+ and decay spans of the schedule are fractions of it, and the
556
+ scheduled-sampling probability ramps over its first half. A short
557
+ run is not a prefix of a long one.
558
+ output_dir: created if absent. Receives ``model.safetensors``,
559
+ ``config.json`` and any periodic checkpoints.
560
+ seed: seeds Python, NumPy, torch, the sampler and the workers, inside
561
+ this call.
562
+ device: ``"cpu"``, ``"cuda"``, ``"cuda:1"``, ``"mps"``. An unavailable
563
+ accelerator raises rather than falling back.
564
+ batch_size: samples per micro-batch. The effective batch is this times
565
+ ``accumulate_grad_batches``.
566
+ accumulate_grad_batches: micro-batches per optimizer step.
567
+ num_workers: DataLoader workers. Zero, single-process, by default.
568
+ checkpoint_every: write a checkpoint every this many optimizer steps.
569
+ callbacks: called after each optimizer step with a dict carrying
570
+ ``step``, ``loss``, ``lr``, ``epsilon`` and ``grad_norm``.
571
+ deterministic: request deterministic kernels for the duration of the
572
+ call, then restore the process flags. An op with no deterministic
573
+ implementation warns rather than raising, so this tightens
574
+ reproducibility without ruling a device out.
575
+
576
+ Returns:
577
+ A :class:`TrainResult`.
578
+
579
+ Nothing here reaches the network and nothing is uploaded.
580
+ """
581
+ if int(max_steps) < 1:
582
+ raise ValueError(f"max_steps must be at least 1, got {max_steps}.")
583
+ if int(batch_size) < 1:
584
+ raise ValueError(f"batch_size must be at least 1, got {batch_size}.")
585
+ if int(accumulate_grad_batches) < 1:
586
+ raise ValueError(
587
+ "accumulate_grad_batches must be at least 1, got "
588
+ f"{accumulate_grad_batches}."
589
+ )
590
+ max_steps = int(max_steps)
591
+ accumulate_grad_batches = int(accumulate_grad_batches)
592
+
593
+ chunks = int(_setting(config, "ar_chunks", AR_CHUNKS))
594
+ width = training_window_width(config, chunks)
595
+ seq_len = int(config.seq_len)
596
+ horizon_unit = int(config.output_token_len)
597
+ eps_max = float(_setting(config, "scheduled_sampling_max", SCHEDULED_SAMPLING_MAX))
598
+ peak_lr = float(_setting(config, "learning_rate", LEARNING_RATE))
599
+ min_lr = float(_setting(config, "min_learning_rate", MIN_LEARNING_RATE))
600
+ warmup_fraction = float(_setting(config, "warmup_fraction", WARMUP_FRACTION))
601
+ decay_fraction = float(_setting(config, "decay_fraction", DECAY_FRACTION))
602
+ weight_decay = float(_setting(config, "weight_decay", WEIGHT_DECAY))
603
+ grad_clip = float(_setting(config, "grad_clip", GRAD_CLIP))
604
+ commit_weight = float(_setting(config, "commit_weight", COMMIT_WEIGHT))
605
+ commit_gated = bool(_setting(config, "commit_gated", COMMIT_GATED))
606
+ if warmup_fraction + decay_fraction > 1.0:
607
+ raise ValueError(
608
+ "warmup_fraction + decay_fraction must not exceed 1.0, got "
609
+ f"{warmup_fraction} + {decay_fraction}."
610
+ )
611
+
612
+ torch_device = _resolve_device(device)
613
+ out = Path(output_dir)
614
+ out.mkdir(parents=True, exist_ok=True)
615
+
616
+ with _Determinism(deterministic, torch_device):
617
+ _seed_everything(int(seed))
618
+
619
+ model = TinyCastForPrediction(config).to(torch_device)
620
+ model.train()
621
+ backbone = model.model
622
+ param_dtype = next(model.parameters()).dtype
623
+ quantiles = torch.tensor(
624
+ list(config.quantiles), dtype=param_dtype, device=torch_device,
625
+ )
626
+
627
+ optimizer = torch.optim.AdamW(
628
+ model.parameters(),
629
+ lr=peak_lr,
630
+ betas=ADAM_BETAS,
631
+ weight_decay=weight_decay,
632
+ )
633
+ scheduler = torch.optim.lr_scheduler.LambdaLR(
634
+ optimizer,
635
+ _wsd_lambda(max_steps, warmup_fraction, decay_fraction,
636
+ min_lr / peak_lr),
637
+ )
638
+
639
+ loader = _build_loader(
640
+ data, int(batch_size), int(num_workers), torch_device, int(seed),
641
+ )
642
+ batches = _epochs(loader)
643
+
644
+ losses: List[float] = []
645
+ learning_rates: List[float] = []
646
+ written: List[str] = []
647
+
648
+ for step in range(max_steps):
649
+ # The feedback probability ramps to its maximum over the first half
650
+ # of the run, so it too is a function of max_steps.
651
+ epsilon = eps_max * min(1.0, step / max(1.0, 0.5 * max_steps))
652
+ lr = float(scheduler.get_last_lr()[0])
653
+
654
+ optimizer.zero_grad(set_to_none=True)
655
+ step_loss = 0.0
656
+ for _ in range(accumulate_grad_batches):
657
+ window, observed, scale_factor = _unpack_batch(
658
+ next(batches), width, torch_device, param_dtype,
659
+ )
660
+ loss = _rollout_loss(
661
+ backbone, window, observed, scale_factor,
662
+ seq_len=seq_len,
663
+ horizon_unit=horizon_unit,
664
+ chunks=chunks,
665
+ epsilon=epsilon,
666
+ quantiles=quantiles,
667
+ commit_weight=commit_weight,
668
+ commit_gated=commit_gated,
669
+ )
670
+ (loss / accumulate_grad_batches).backward()
671
+ step_loss += float(loss.detach()) / accumulate_grad_batches
672
+
673
+ if grad_clip > 0.0:
674
+ grad_norm = float(
675
+ torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
676
+ )
677
+ else:
678
+ grad_norm = float("nan")
679
+ optimizer.step()
680
+ scheduler.step()
681
+
682
+ losses.append(step_loss)
683
+ learning_rates.append(lr)
684
+ record = {
685
+ "step": step + 1,
686
+ "loss": step_loss,
687
+ "lr": lr,
688
+ "epsilon": epsilon,
689
+ "grad_norm": grad_norm,
690
+ }
691
+ for callback in callbacks:
692
+ callback(record)
693
+
694
+ if checkpoint_every and (step + 1) % int(checkpoint_every) == 0:
695
+ path = out / f"step-{step + 1:06d}.safetensors"
696
+ _save(model, path)
697
+ written.append(str(path))
698
+
699
+ final = out / "model.safetensors"
700
+ _save(model, final)
701
+ with open(out / "config.json", "w") as handle:
702
+ json.dump(config.to_dict(), handle, indent=2)
703
+
704
+ return TrainResult(
705
+ checkpoint_path=str(final),
706
+ steps=max_steps,
707
+ losses=losses,
708
+ learning_rates=learning_rates,
709
+ checkpoints=written,
710
+ window_width=width,
711
+ )
712
+
713
+
714
+ __all__ = ["TrainResult", "train", "training_window_width"]