feat(skill): add timesfm-forecasting Agent Skill (agentskills.io)
Add a self-contained AI agent skill for TimesFM that teaches coding agents (Claude Code, OpenCode, Cursor, Codex) how to use the TimesFM API correctly — safe model loading, zero-shot forecasting, covariate workflows, anomaly detection, and the most common pitfalls. Files added: - AGENTS.md — auto-loaded skill document (root of repo) - claude-skill/scripts/check_system.py — mandatory preflight RAM/GPU/disk checker - claude-skill/scripts/forecast_csv.py — CLI wrapper for CSV forecasting - claude-skill/references/ — ForecastConfig API ref, data prep, HW reqs - claude-skill/examples/global-temperature/ — basic forecast + PNG/GIF pipeline - claude-skill/examples/anomaly-detection/ — two-phase detrend+Z-score + quantile PI - claude-skill/examples/covariates-forecasting/ — forecast_with_covariates() XReg demo - .gitattributes — Git LFS rules for PNG/GIF binary outputs Contributed by Clayton Young / Superior Byte Works LLC (@borealBytes) Apache 2.0 — same license as this repository
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Git LFS tracking for binary outputs in claude-skill/
|
||||
claude-skill/**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
claude-skill/**/*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -0,0 +1,762 @@
|
||||
# TimesFM — AI Agent Skill
|
||||
|
||||
> Contributed by Clayton Young / Superior Byte Works, LLC ([@borealBytes](https://github.com/borealBytes))
|
||||
> Apache 2.0 — same license as this repository
|
||||
|
||||
This file is automatically loaded by AI coding agents (Claude Code / OpenCode / Cursor / Codex) when
|
||||
you open this repository. It teaches your agent how to use TimesFM correctly — safe model loading,
|
||||
the right API calls, covariate workflows, anomaly detection, and how to avoid the most common bugs.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
TimesFM (Time Series Foundation Model) is a pretrained decoder-only foundation model developed by
|
||||
Google Research for time-series forecasting. It works **zero-shot** — feed it any univariate time
|
||||
series and it returns point forecasts with calibrated quantile prediction intervals, no training
|
||||
required.
|
||||
|
||||
This skill includes a **mandatory preflight system checker** that verifies RAM, GPU memory, and
|
||||
disk space before the model is ever loaded so the agent never crashes the user's machine.
|
||||
|
||||
> **Key numbers**: TimesFM 2.5 uses 200M parameters (~800 MB on disk, ~1.5 GB in RAM on CPU,
|
||||
> ~1 GB VRAM on GPU). The archived v1/v2 500M-parameter model needs ~32 GB RAM. Always run the
|
||||
> system checker first.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
|
||||
- Forecasting **any univariate time series** (sales, demand, sensor, vitals, price, weather)
|
||||
- You need **zero-shot forecasting** without training a custom model
|
||||
- You want **probabilistic forecasts** with calibrated prediction intervals (quantiles)
|
||||
- You have time series of **any length** (the model handles 1–16,384 context points)
|
||||
- You need to **batch-forecast** hundreds or thousands of series efficiently
|
||||
- You want a **foundation model** approach instead of hand-tuning ARIMA/ETS parameters
|
||||
|
||||
Do **not** use this skill when:
|
||||
|
||||
- You need classical statistical models with coefficient interpretation → use `statsmodels`
|
||||
- You need time series classification or clustering → use `aeon`
|
||||
- You need multivariate vector autoregression or Granger causality → use `statsmodels`
|
||||
- Your data is tabular (not temporal) → use `scikit-learn`
|
||||
|
||||
> **Note on Anomaly Detection**: TimesFM does not have built-in anomaly detection, but you can
|
||||
> use the **quantile forecasts as prediction intervals** — values outside the 90% CI (q10–q90)
|
||||
> are statistically unusual. See `claude-skill/examples/anomaly-detection/` for a full example.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Mandatory Preflight: System Requirements Check
|
||||
|
||||
**CRITICAL — ALWAYS run the system checker before loading the model for the first time.**
|
||||
|
||||
```bash
|
||||
python claude-skill/scripts/check_system.py
|
||||
```
|
||||
|
||||
This script checks:
|
||||
|
||||
1. **Available RAM** — warns if below 4 GB, blocks if below 2 GB
|
||||
2. **GPU availability** — detects CUDA/MPS devices and VRAM
|
||||
3. **Disk space** — verifies room for the ~800 MB model download
|
||||
4. **Python version** — requires 3.10+
|
||||
5. **Existing installation** — checks if `timesfm` and `torch` are installed
|
||||
|
||||
> **Note:** Model weights are **NOT stored in this repository**. TimesFM weights (~800 MB)
|
||||
> download on-demand from HuggingFace on first use and cache in `~/.cache/huggingface/`.
|
||||
> The preflight checker ensures sufficient resources before any download begins.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
start["🚀 Run check_system.py"] --> ram{"RAM ≥ 4 GB?"}
|
||||
ram -->|"Yes"| gpu{"GPU available?"}
|
||||
ram -->|"No (2-4 GB)"| warn_ram["⚠️ Warning: tight RAM<br/>CPU-only, small batches"]
|
||||
ram -->|"No (< 2 GB)"| block["🛑 BLOCKED<br/>Insufficient memory"]
|
||||
warn_ram --> disk
|
||||
gpu -->|"CUDA / MPS"| vram{"VRAM ≥ 2 GB?"}
|
||||
gpu -->|"CPU only"| cpu_ok["✅ CPU mode<br/>Slower but works"]
|
||||
vram -->|"Yes"| gpu_ok["✅ GPU mode<br/>Fast inference"]
|
||||
vram -->|"No"| cpu_ok
|
||||
gpu_ok --> disk{"Disk ≥ 2 GB free?"}
|
||||
cpu_ok --> disk
|
||||
disk -->|"Yes"| ready["✅ READY<br/>Safe to load model"]
|
||||
disk -->|"No"| block_disk["🛑 BLOCKED<br/>Need space for weights"]
|
||||
|
||||
classDef ok fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d
|
||||
classDef warn fill:#fef9c3,stroke:#ca8a04,stroke-width:2px,color:#713f12
|
||||
classDef block fill:#fee2e2,stroke:#dc2626,stroke-width:2px,color:#7f1d1d
|
||||
classDef neutral fill:#f3f4f6,stroke:#6b7280,stroke-width:2px,color:#1f2937
|
||||
|
||||
class ready,gpu_ok,cpu_ok ok
|
||||
class warn_ram warn
|
||||
class block,block_disk block
|
||||
class start,ram,gpu,vram,disk neutral
|
||||
```
|
||||
|
||||
### Hardware Requirements by Model Version
|
||||
|
||||
| Model | Parameters | RAM (CPU) | VRAM (GPU) | Disk | Context |
|
||||
| ----- | ---------- | --------- | ---------- | ---- | ------- |
|
||||
| **TimesFM 2.5** (recommended) | 200M | ≥ 4 GB | ≥ 2 GB | ~800 MB | up to 16,384 |
|
||||
| TimesFM 2.0 (archived) | 500M | ≥ 16 GB | ≥ 8 GB | ~2 GB | up to 2,048 |
|
||||
| TimesFM 1.0 (archived) | 200M | ≥ 8 GB | ≥ 4 GB | ~800 MB | up to 2,048 |
|
||||
|
||||
> **Recommendation**: Always use TimesFM 2.5 unless you have a specific reason to use an
|
||||
> older checkpoint. It is smaller, faster, and supports 8× longer context.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Installation
|
||||
|
||||
### Step 1: Verify System (always first)
|
||||
|
||||
```bash
|
||||
python claude-skill/scripts/check_system.py
|
||||
```
|
||||
|
||||
### Step 2: Install TimesFM
|
||||
|
||||
```bash
|
||||
# Using uv (fast)
|
||||
uv pip install timesfm[torch]
|
||||
|
||||
# Or using pip
|
||||
pip install timesfm[torch]
|
||||
|
||||
# For JAX/Flax backend (faster on TPU/GPU)
|
||||
uv pip install timesfm[flax]
|
||||
```
|
||||
|
||||
### Step 3: Install PyTorch for Your Hardware
|
||||
|
||||
```bash
|
||||
# CUDA 12.1 (NVIDIA GPU)
|
||||
pip install torch>=2.0.0 --index-url https://download.pytorch.org/whl/cu121
|
||||
|
||||
# CPU only
|
||||
pip install torch>=2.0.0 --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
# Apple Silicon (MPS)
|
||||
pip install torch>=2.0.0 # MPS support is built-in
|
||||
```
|
||||
|
||||
### Step 4: Verify Installation
|
||||
|
||||
```python
|
||||
import timesfm
|
||||
import numpy as np
|
||||
print(f"TimesFM version: {timesfm.__version__}")
|
||||
print("Installation OK")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Start
|
||||
|
||||
### Minimal Example (5 Lines)
|
||||
|
||||
```python
|
||||
import torch, numpy as np, timesfm
|
||||
|
||||
torch.set_float32_matmul_precision("high")
|
||||
|
||||
model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
|
||||
"google/timesfm-2.5-200m-pytorch"
|
||||
)
|
||||
model.compile(timesfm.ForecastConfig(
|
||||
max_context=1024, max_horizon=256, normalize_inputs=True,
|
||||
use_continuous_quantile_head=True, force_flip_invariance=True,
|
||||
infer_is_positive=True, fix_quantile_crossing=True,
|
||||
))
|
||||
|
||||
point, quantiles = model.forecast(horizon=24, inputs=[
|
||||
np.sin(np.linspace(0, 20, 200)), # any 1-D array
|
||||
])
|
||||
# point.shape == (1, 24) — median forecast
|
||||
# quantiles.shape == (1, 24, 10) — 10th–90th percentile bands
|
||||
```
|
||||
|
||||
### Forecast from CSV
|
||||
|
||||
```python
|
||||
import pandas as pd, numpy as np
|
||||
|
||||
df = pd.read_csv("monthly_sales.csv", parse_dates=["date"], index_col="date")
|
||||
|
||||
# Convert each column to a list of arrays
|
||||
inputs = [df[col].dropna().values.astype(np.float32) for col in df.columns]
|
||||
|
||||
point, quantiles = model.forecast(horizon=12, inputs=inputs)
|
||||
|
||||
# Build a results DataFrame
|
||||
for i, col in enumerate(df.columns):
|
||||
last_date = df[col].dropna().index[-1]
|
||||
future_dates = pd.date_range(last_date, periods=13, freq="MS")[1:]
|
||||
forecast_df = pd.DataFrame({
|
||||
"date": future_dates,
|
||||
"forecast": point[i],
|
||||
"lower_80": quantiles[i, :, 2], # 20th percentile
|
||||
"upper_80": quantiles[i, :, 8], # 80th percentile
|
||||
})
|
||||
print(f"\n--- {col} ---")
|
||||
print(forecast_df.to_string(index=False))
|
||||
```
|
||||
|
||||
### Forecast with Covariates (XReg)
|
||||
|
||||
TimesFM 2.5+ supports exogenous variables through `forecast_with_covariates()`. Requires `timesfm[xreg]`.
|
||||
|
||||
```python
|
||||
# Requires: pip install timesfm[xreg]
|
||||
point, quantiles = model.forecast_with_covariates(
|
||||
inputs=inputs,
|
||||
dynamic_numerical_covariates={"price": price_arrays},
|
||||
dynamic_categorical_covariates={"holiday": holiday_arrays},
|
||||
static_categorical_covariates={"region": region_labels},
|
||||
xreg_mode="xreg + timesfm", # or "timesfm + xreg"
|
||||
)
|
||||
```
|
||||
|
||||
| Covariate Type | Description | Example |
|
||||
| -------------- | ----------- | ------- |
|
||||
| `dynamic_numerical` | Time-varying numeric | price, temperature, promotion spend |
|
||||
| `dynamic_categorical` | Time-varying categorical | holiday flag, day of week |
|
||||
| `static_numerical` | Per-series numeric | store size, account age |
|
||||
| `static_categorical` | Per-series categorical | store type, region, product category |
|
||||
|
||||
**XReg Modes:**
|
||||
- `"xreg + timesfm"` (default): TimesFM forecasts first, then XReg adjusts residuals
|
||||
- `"timesfm + xreg"`: XReg fits first, then TimesFM forecasts residuals
|
||||
|
||||
> See `claude-skill/examples/covariates-forecasting/` for a complete example with synthetic retail data.
|
||||
|
||||
### Anomaly Detection (via Quantile Intervals)
|
||||
|
||||
TimesFM does not have built-in anomaly detection, but the **quantile forecasts naturally provide
|
||||
prediction intervals** that can detect anomalies:
|
||||
|
||||
```python
|
||||
point, q = model.forecast(horizon=H, inputs=[values])
|
||||
|
||||
# 90% prediction interval
|
||||
lower_90 = q[0, :, 1] # 10th percentile
|
||||
upper_90 = q[0, :, 9] # 90th percentile
|
||||
|
||||
# Detect anomalies: values outside the 90% CI
|
||||
actual = test_values # your holdout data
|
||||
anomalies = (actual < lower_90) | (actual > upper_90)
|
||||
|
||||
# Severity levels
|
||||
is_warning = (actual < q[0, :, 2]) | (actual > q[0, :, 8]) # outside 80% CI
|
||||
is_critical = anomalies # outside 90% CI
|
||||
```
|
||||
|
||||
| Severity | Condition | Interpretation |
|
||||
| -------- | --------- | -------------- |
|
||||
| **Normal** | Inside 80% CI | Expected behavior |
|
||||
| **Warning** | Outside 80% CI | Unusual but possible |
|
||||
| **Critical** | Outside 90% CI | Statistically rare (< 10% probability) |
|
||||
|
||||
> See `claude-skill/examples/anomaly-detection/` for a complete example with visualization.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Understanding the Output
|
||||
|
||||
### Quantile Forecast Structure
|
||||
|
||||
TimesFM returns `(point_forecast, quantile_forecast)`:
|
||||
|
||||
- **`point_forecast`**: shape `(batch, horizon)` — the median (0.5 quantile)
|
||||
- **`quantile_forecast`**: shape `(batch, horizon, 10)` — ten slices:
|
||||
|
||||
| Index | Quantile | Use |
|
||||
| ----- | -------- | --- |
|
||||
| 0 | Mean | Average prediction |
|
||||
| 1 | 0.1 | Lower bound of 80% PI |
|
||||
| 2 | 0.2 | Lower bound of 60% PI |
|
||||
| 3 | 0.3 | — |
|
||||
| 4 | 0.4 | — |
|
||||
| **5** | **0.5** | **Median (= `point_forecast`)** |
|
||||
| 6 | 0.6 | — |
|
||||
| 7 | 0.7 | — |
|
||||
| 8 | 0.8 | Upper bound of 60% PI |
|
||||
| 9 | 0.9 | Upper bound of 80% PI |
|
||||
|
||||
### Extracting Prediction Intervals
|
||||
|
||||
```python
|
||||
point, q = model.forecast(horizon=H, inputs=data)
|
||||
|
||||
# 80% prediction interval (most common)
|
||||
lower_80 = q[:, :, 1] # 10th percentile
|
||||
upper_80 = q[:, :, 9] # 90th percentile
|
||||
|
||||
# 60% prediction interval (tighter)
|
||||
lower_60 = q[:, :, 2] # 20th percentile
|
||||
upper_60 = q[:, :, 8] # 80th percentile
|
||||
|
||||
# Median (same as point forecast)
|
||||
median = q[:, :, 5]
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
input["📈 Input Series<br/>1-D array"] --> model["🤖 TimesFM<br/>compile + forecast"]
|
||||
model --> point["📍 Point Forecast<br/>(batch, horizon)"]
|
||||
model --> quant["📊 Quantile Forecast<br/>(batch, horizon, 10)"]
|
||||
quant --> pi80["80% PI<br/>q[:,:,1] – q[:,:,9]"]
|
||||
quant --> pi60["60% PI<br/>q[:,:,2] – q[:,:,8]"]
|
||||
quant --> median["Median<br/>q[:,:,5]"]
|
||||
|
||||
classDef data fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
|
||||
classDef model fill:#f3e8ff,stroke:#9333ea,stroke-width:2px,color:#581c87
|
||||
classDef output fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d
|
||||
|
||||
class input data
|
||||
class model model
|
||||
class point,quant,pi80,pi60,median output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 ForecastConfig Reference
|
||||
|
||||
All forecasting behavior is controlled by `timesfm.ForecastConfig`:
|
||||
|
||||
```python
|
||||
timesfm.ForecastConfig(
|
||||
max_context=1024, # Max context window (truncates longer series)
|
||||
max_horizon=256, # Max forecast horizon
|
||||
normalize_inputs=True, # Normalize inputs (RECOMMENDED for stability)
|
||||
per_core_batch_size=32, # Batch size per device (tune for memory)
|
||||
use_continuous_quantile_head=True, # Better quantile accuracy for long horizons
|
||||
force_flip_invariance=True, # Ensures f(-x) = -f(x) (mathematical consistency)
|
||||
infer_is_positive=True, # Clamp forecasts ≥ 0 when all inputs > 0
|
||||
fix_quantile_crossing=True, # Ensure q10 ≤ q20 ≤ ... ≤ q90
|
||||
return_backcast=False, # Return backcast (for covariate workflows)
|
||||
)
|
||||
```
|
||||
|
||||
| Parameter | Default | When to Change |
|
||||
| --------- | ------- | -------------- |
|
||||
| `max_context` | 0 | Set to match your longest historical window (e.g., 512, 1024, 4096) |
|
||||
| `max_horizon` | 0 | Set to your maximum forecast length |
|
||||
| `normalize_inputs` | False | **Always set True** — prevents scale-dependent instability |
|
||||
| `per_core_batch_size` | 1 | Increase for throughput; decrease if OOM |
|
||||
| `use_continuous_quantile_head` | False | **Set True** for calibrated prediction intervals |
|
||||
| `force_flip_invariance` | True | Keep True unless profiling shows it hurts |
|
||||
| `infer_is_positive` | True | Set False for series that can be negative (temperature, returns) |
|
||||
| `fix_quantile_crossing` | False | **Set True** to guarantee monotonic quantiles |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Common Workflows
|
||||
|
||||
### Workflow 1: Single Series Forecast
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
check["1. Run check_system.py"] --> load["2. Load model<br/>from_pretrained()"]
|
||||
load --> compile["3. Compile with ForecastConfig"]
|
||||
compile --> prep["4. Prepare data<br/>pd.read_csv → np.array"]
|
||||
prep --> forecast["5. model.forecast()<br/>horizon=N"]
|
||||
forecast --> extract["6. Extract point + PI"]
|
||||
extract --> plot["7. Plot or export results"]
|
||||
|
||||
classDef step fill:#f3f4f6,stroke:#6b7280,stroke-width:2px,color:#1f2937
|
||||
class check,load,compile,prep,forecast,extract,plot step
|
||||
```
|
||||
|
||||
```python
|
||||
import torch, numpy as np, pandas as pd, timesfm
|
||||
|
||||
# 1. System check (run once)
|
||||
# python claude-skill/scripts/check_system.py
|
||||
|
||||
# 2-3. Load and compile
|
||||
torch.set_float32_matmul_precision("high")
|
||||
model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
|
||||
"google/timesfm-2.5-200m-pytorch"
|
||||
)
|
||||
model.compile(timesfm.ForecastConfig(
|
||||
max_context=512, max_horizon=52, normalize_inputs=True,
|
||||
use_continuous_quantile_head=True, fix_quantile_crossing=True,
|
||||
))
|
||||
|
||||
# 4. Prepare data
|
||||
df = pd.read_csv("weekly_demand.csv", parse_dates=["week"])
|
||||
values = df["demand"].values.astype(np.float32)
|
||||
|
||||
# 5. Forecast
|
||||
point, quantiles = model.forecast(horizon=52, inputs=[values])
|
||||
|
||||
# 6. Extract prediction intervals
|
||||
forecast_df = pd.DataFrame({
|
||||
"forecast": point[0],
|
||||
"lower_80": quantiles[0, :, 1],
|
||||
"upper_80": quantiles[0, :, 9],
|
||||
})
|
||||
|
||||
# 7. Plot
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
fig, ax = plt.subplots(figsize=(12, 5))
|
||||
ax.plot(values[-104:], label="Historical")
|
||||
x_fc = range(len(values[-104:]), len(values[-104:]) + 52)
|
||||
ax.plot(x_fc, forecast_df["forecast"], label="Forecast", color="tab:orange")
|
||||
ax.fill_between(x_fc, forecast_df["lower_80"], forecast_df["upper_80"],
|
||||
alpha=0.2, color="tab:orange", label="80% PI")
|
||||
ax.legend()
|
||||
ax.set_title("52-Week Demand Forecast")
|
||||
plt.tight_layout()
|
||||
plt.savefig("forecast.png", dpi=150)
|
||||
print("Saved forecast.png")
|
||||
```
|
||||
|
||||
### Workflow 2: Batch Forecasting (Many Series)
|
||||
|
||||
```python
|
||||
import pandas as pd, numpy as np
|
||||
|
||||
# Load wide-format CSV (one column per series)
|
||||
df = pd.read_csv("all_stores.csv", parse_dates=["date"], index_col="date")
|
||||
inputs = [df[col].dropna().values.astype(np.float32) for col in df.columns]
|
||||
|
||||
# Forecast all series at once (batched internally)
|
||||
point, quantiles = model.forecast(horizon=30, inputs=inputs)
|
||||
|
||||
# Collect results
|
||||
results = {}
|
||||
for i, col in enumerate(df.columns):
|
||||
results[col] = {
|
||||
"forecast": point[i].tolist(),
|
||||
"lower_80": quantiles[i, :, 1].tolist(),
|
||||
"upper_80": quantiles[i, :, 9].tolist(),
|
||||
}
|
||||
|
||||
# Export
|
||||
import json
|
||||
with open("batch_forecasts.json", "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"Forecasted {len(results)} series → batch_forecasts.json")
|
||||
```
|
||||
|
||||
### Workflow 3: Evaluate Forecast Accuracy
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
|
||||
# Hold out the last H points for evaluation
|
||||
H = 24
|
||||
train = values[:-H]
|
||||
actual = values[-H:]
|
||||
|
||||
point, quantiles = model.forecast(horizon=H, inputs=[train])
|
||||
pred = point[0]
|
||||
|
||||
# Metrics
|
||||
mae = np.mean(np.abs(actual - pred))
|
||||
rmse = np.sqrt(np.mean((actual - pred) ** 2))
|
||||
mape = np.mean(np.abs((actual - pred) / actual)) * 100
|
||||
|
||||
# Prediction interval coverage
|
||||
lower = quantiles[0, :, 1]
|
||||
upper = quantiles[0, :, 9]
|
||||
coverage = np.mean((actual >= lower) & (actual <= upper)) * 100
|
||||
|
||||
print(f"MAE: {mae:.2f}")
|
||||
print(f"RMSE: {rmse:.2f}")
|
||||
print(f"MAPE: {mape:.1f}%")
|
||||
print(f"80% PI Coverage: {coverage:.1f}% (target: 80%)")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Performance Tuning
|
||||
|
||||
### GPU Acceleration
|
||||
|
||||
```python
|
||||
import torch
|
||||
|
||||
# Check GPU availability
|
||||
if torch.cuda.is_available():
|
||||
print(f"GPU: {torch.cuda.get_device_name(0)}")
|
||||
print(f"VRAM: {torch.cuda.get_device_properties(0).total_mem / 1e9:.1f} GB")
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
print("Apple Silicon MPS available")
|
||||
else:
|
||||
print("CPU only — inference will be slower but still works")
|
||||
|
||||
# Always set this for Ampere+ GPUs (A100, RTX 3090, etc.)
|
||||
torch.set_float32_matmul_precision("high")
|
||||
```
|
||||
|
||||
### Batch Size Tuning
|
||||
|
||||
```python
|
||||
# Start conservative, increase until OOM
|
||||
# GPU with 8 GB VRAM: per_core_batch_size=64
|
||||
# GPU with 16 GB VRAM: per_core_batch_size=128
|
||||
# GPU with 24 GB VRAM: per_core_batch_size=256
|
||||
# CPU with 8 GB RAM: per_core_batch_size=8
|
||||
# CPU with 16 GB RAM: per_core_batch_size=32
|
||||
# CPU with 32 GB RAM: per_core_batch_size=64
|
||||
|
||||
model.compile(timesfm.ForecastConfig(
|
||||
max_context=1024,
|
||||
max_horizon=256,
|
||||
per_core_batch_size=32, # <-- tune this
|
||||
normalize_inputs=True,
|
||||
use_continuous_quantile_head=True,
|
||||
fix_quantile_crossing=True,
|
||||
))
|
||||
```
|
||||
|
||||
### Memory-Constrained Environments
|
||||
|
||||
```python
|
||||
import gc, torch
|
||||
|
||||
# Force garbage collection before loading
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
# Load model
|
||||
model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
|
||||
"google/timesfm-2.5-200m-pytorch"
|
||||
)
|
||||
|
||||
# Use small batch size on low-memory machines
|
||||
model.compile(timesfm.ForecastConfig(
|
||||
max_context=512, # Reduce context if needed
|
||||
max_horizon=128, # Reduce horizon if needed
|
||||
per_core_batch_size=4, # Small batches
|
||||
normalize_inputs=True,
|
||||
use_continuous_quantile_head=True,
|
||||
fix_quantile_crossing=True,
|
||||
))
|
||||
|
||||
# Process series in chunks to avoid OOM
|
||||
CHUNK = 50
|
||||
all_results = []
|
||||
for i in range(0, len(inputs), CHUNK):
|
||||
chunk = inputs[i:i+CHUNK]
|
||||
p, q = model.forecast(horizon=H, inputs=chunk)
|
||||
all_results.append((p, q))
|
||||
gc.collect() # Clean up between chunks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Available Scripts
|
||||
|
||||
### `claude-skill/scripts/check_system.py`
|
||||
|
||||
**Mandatory preflight checker.** Run before first model load.
|
||||
|
||||
```bash
|
||||
python claude-skill/scripts/check_system.py
|
||||
```
|
||||
|
||||
Output example:
|
||||
```
|
||||
=== TimesFM System Requirements Check ===
|
||||
|
||||
[RAM] Total: 32.0 GB | Available: 24.3 GB ✅ PASS
|
||||
[GPU] NVIDIA RTX 4090 | VRAM: 24.0 GB ✅ PASS
|
||||
[Disk] Free: 142.5 GB ✅ PASS
|
||||
[Python] 3.12.1 ✅ PASS
|
||||
[timesfm] Installed (2.5.0) ✅ PASS
|
||||
[torch] Installed (2.4.1+cu121) ✅ PASS
|
||||
|
||||
VERDICT: ✅ System is ready for TimesFM 2.5 (GPU mode)
|
||||
Recommended: per_core_batch_size=128
|
||||
```
|
||||
|
||||
### `claude-skill/scripts/forecast_csv.py`
|
||||
|
||||
End-to-end CSV forecasting with automatic system check.
|
||||
|
||||
```bash
|
||||
python claude-skill/scripts/forecast_csv.py input.csv \
|
||||
--horizon 24 \
|
||||
--date-col date \
|
||||
--value-cols sales,revenue \
|
||||
--output forecasts.csv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Reference Documentation
|
||||
|
||||
Detailed guides in `claude-skill/references/`:
|
||||
|
||||
| File | Contents |
|
||||
| ---- | -------- |
|
||||
| `references/system_requirements.md` | Hardware tiers, GPU/CPU selection, memory estimation formulas |
|
||||
| `references/api_reference.md` | Full `ForecastConfig` docs, `from_pretrained` options, output shapes |
|
||||
| `references/data_preparation.md` | Input formats, NaN handling, CSV loading, covariate setup |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Examples
|
||||
|
||||
Three fully-working reference examples live in `claude-skill/examples/`. Use them as ground truth
|
||||
for correct API usage and expected output shape.
|
||||
|
||||
| Example | Directory | What It Demonstrates | When To Use It |
|
||||
| ------- | --------- | -------------------- | -------------- |
|
||||
| **Global Temperature Forecast** | `examples/global-temperature/` | Basic `model.forecast()` call, CSV → PNG → GIF pipeline, 36-month NOAA context | Starting point; copy-paste baseline for any univariate series |
|
||||
| **Anomaly Detection** | `examples/anomaly-detection/` | Two-phase detection: linear detrend + Z-score on context, quantile PI on forecast; 2-panel viz | Any task requiring outlier detection on historical + forecasted data |
|
||||
| **Covariates (XReg)** | `examples/covariates-forecasting/` | `forecast_with_covariates()` API (TimesFM 2.5), covariate decomposition, 2×2 shared-axis viz | Retail, energy, or any series with known exogenous drivers |
|
||||
|
||||
### Running the Examples
|
||||
|
||||
```bash
|
||||
# Global temperature (basic forecast + visualization)
|
||||
cd claude-skill/examples/global-temperature && python run_forecast.py && python visualize_forecast.py
|
||||
|
||||
# Anomaly detection (two-phase: detrend + Z-score on context, quantile PI on forecast)
|
||||
cd claude-skill/examples/anomaly-detection && python detect_anomalies.py
|
||||
|
||||
# Covariates (API demo — requires timesfm[xreg] for real inference)
|
||||
cd claude-skill/examples/covariates-forecasting && python demo_covariates.py
|
||||
```
|
||||
|
||||
### Expected Outputs
|
||||
|
||||
| Example | Key output files | Acceptance criteria |
|
||||
| ------- | ---------------- | ------------------- |
|
||||
| global-temperature | `output/forecast_output.json`, `output/forecast_visualization.png` | `point_forecast` has 12 values; PNG shows context + forecast + PI bands |
|
||||
| anomaly-detection | `output/anomaly_detection.json`, `output/anomaly_detection.png` | Sep 2023 flagged CRITICAL (z >= 3.0); >= 2 forecast CRITICAL from injected anomalies |
|
||||
| covariates-forecasting | `output/sales_with_covariates.csv`, `output/covariates_data.png` | CSV has 108 rows (3 stores × 36 weeks); stores have **distinct** price arrays |
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Not running system check** → model load crashes on low-RAM machines. Always run `check_system.py` first.
|
||||
2. **Forgetting `model.compile()`** → `RuntimeError: Model is not compiled`. Must call `compile()` before `forecast()`.
|
||||
3. **Not setting `normalize_inputs=True`** → unstable forecasts for series with large values.
|
||||
4. **Using v1/v2 on machines with < 32 GB RAM** → use TimesFM 2.5 (200M params) instead.
|
||||
5. **Not setting `fix_quantile_crossing=True`** → quantiles may not be monotonic (q10 > q50).
|
||||
6. **Huge `per_core_batch_size` on small GPU** → CUDA OOM. Start small, increase.
|
||||
7. **Passing 2-D arrays** → TimesFM expects a **list of 1-D arrays**, not a 2-D matrix.
|
||||
8. **Forgetting `torch.set_float32_matmul_precision("high")`** → slower inference on Ampere+ GPUs.
|
||||
9. **Not handling NaN in output** → edge cases with very short series. Always check `np.isnan(point).any()`.
|
||||
10. **Using `infer_is_positive=True` for series that can be negative** → clamps forecasts at zero. Set False for temperature, returns, etc.
|
||||
|
||||
---
|
||||
|
||||
## Model Versions
|
||||
|
||||
```mermaid
|
||||
timeline
|
||||
section 2024
|
||||
TimesFM 1.0 : 200M params, 2K context, JAX only
|
||||
TimesFM 2.0 : 500M params, 2K context, PyTorch + JAX
|
||||
section 2025
|
||||
TimesFM 2.5 : 200M params, 16K context, quantile head, no frequency indicator
|
||||
```
|
||||
|
||||
| Version | Params | Context | Quantile Head | Frequency Flag | Status |
|
||||
| ------- | ------ | ------- | ------------- | -------------- | ------ |
|
||||
| **2.5** | 200M | 16,384 | ✅ Continuous (30M) | ❌ Removed | **Latest** |
|
||||
| 2.0 | 500M | 2,048 | ✅ Fixed buckets | ✅ Required | Archived |
|
||||
| 1.0 | 200M | 2,048 | ✅ Fixed buckets | ✅ Required | Archived |
|
||||
|
||||
**Hugging Face checkpoints:**
|
||||
|
||||
- `google/timesfm-2.5-200m-pytorch` (recommended)
|
||||
- `google/timesfm-2.5-200m-flax`
|
||||
- `google/timesfm-2.0-500m-pytorch` (archived)
|
||||
- `google/timesfm-1.0-200m-pytorch` (archived)
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Paper**: [A Decoder-Only Foundation Model for Time-Series Forecasting](https://arxiv.org/abs/2310.10688) (ICML 2024)
|
||||
- **Repository**: https://github.com/google-research/timesfm
|
||||
- **Hugging Face**: https://huggingface.co/collections/google/timesfm-release-66e4be5fdb56e960c1e482a6
|
||||
- **Google Blog**: https://research.google/blog/a-decoder-only-foundation-model-for-time-series-forecasting/
|
||||
- **BigQuery Integration**: https://cloud.google.com/bigquery/docs/timesfm-model
|
||||
|
||||
---
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
Run this checklist after every TimesFM task before declaring success:
|
||||
|
||||
- [ ] **Output shape correct** — `point_fc` shape is `(n_series, horizon)`, `quant_fc` is `(n_series, horizon, 10)`
|
||||
- [ ] **Quantile indices** — index 0 = mean, 1 = q10, 2 = q20 ... 9 = q90. **NOT** 0 = q0, 1 = q10.
|
||||
- [ ] **Frequency flag** — TimesFM 1.0/2.0: pass `freq=[0]` for monthly data. TimesFM 2.5: no freq flag.
|
||||
- [ ] **Series length** — context must be >= 32 data points (model minimum). Warn if shorter.
|
||||
- [ ] **No NaN** — `np.isnan(point_fc).any()` should be False. Check input series for gaps first.
|
||||
- [ ] **Visualization axes** — if multiple panels share data, use `sharex=True`. All time axes must cover the same span.
|
||||
- [ ] **Binary outputs tracked** — PNG and GIF files should be tracked via Git LFS (`.gitattributes`).
|
||||
- [ ] **No large datasets committed** — any real dataset > 1 MB should be downloaded to `tempfile.mkdtemp()` and annotated in code.
|
||||
- [ ] **`matplotlib.use('Agg')`** — must appear before any pyplot import when running headless.
|
||||
- [ ] **`infer_is_positive`** — set `False` for temperature anomalies, financial returns, or any series that can be negative.
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
These bugs appeared during development. Learn from them:
|
||||
|
||||
1. **Quantile index off-by-one** — The most common mistake. `quant_fc[..., 0]` is the **mean**, not q0. q10 = index 1, q90 = index 9. Always define named constants: `IDX_Q10, IDX_Q20, IDX_Q80, IDX_Q90 = 1, 2, 8, 9`.
|
||||
|
||||
2. **Variable shadowing in comprehensions** — If you build per-series covariate dicts inside a loop, do NOT use the loop variable as the comprehension variable. Accumulate into separate `dict[str, ndarray]` outside the loop, then assign.
|
||||
```python
|
||||
# WRONG — outer `store_id` gets shadowed:
|
||||
covariates = {store_id: arr[store_id] for store_id in stores} # inside outer loop over store_id
|
||||
# CORRECT — use a different name or accumulate beforehand:
|
||||
prices_by_store: dict[str, np.ndarray] = {}
|
||||
for store_id, config in stores.items():
|
||||
prices_by_store[store_id] = compute_price(config)
|
||||
```
|
||||
|
||||
3. **Wrong CSV column name** — The global-temperature CSV uses `anomaly_c`, not `anomaly`. Always `print(df.columns)` before accessing.
|
||||
|
||||
4. **`tight_layout()` warning with `sharex=True`** — Harmless; suppress with `plt.tight_layout(rect=[0, 0, 1, 0.97])` or ignore.
|
||||
|
||||
5. **TimesFM 2.5 required for `forecast_with_covariates()`** — TimesFM 1.0 does NOT have this method. Install `pip install timesfm[xreg]` and use checkpoint `google/timesfm-2.5-200m-pytorch`.
|
||||
|
||||
6. **Future covariates must span the full horizon** — Dynamic covariates (price, promotions, holidays) must have values for BOTH the context AND the forecast horizon. You cannot pass context-only arrays.
|
||||
|
||||
7. **Anomaly thresholds must be defined once** — Define `CRITICAL_Z = 3.0`, `WARNING_Z = 2.0` as module-level constants. Never hardcode `3` or `2` inline.
|
||||
|
||||
8. **Context anomaly detection uses residuals, not raw values** — Always detrend first (`np.polyfit` linear, or seasonal decomposition), then Z-score the residuals. Raw-value Z-scores are misleading on trending data.
|
||||
|
||||
---
|
||||
|
||||
## Validation & Verification
|
||||
|
||||
Use the example outputs as regression baselines. If you change forecasting logic, verify:
|
||||
|
||||
```bash
|
||||
# Anomaly detection regression check:
|
||||
python -c "
|
||||
import json
|
||||
d = json.load(open('claude-skill/examples/anomaly-detection/output/anomaly_detection.json'))
|
||||
ctx = d['context_summary']
|
||||
assert ctx['critical'] >= 1, 'Sep 2023 must be CRITICAL'
|
||||
assert any(r['date'] == '2023-09' and r['severity'] == 'CRITICAL'
|
||||
for r in d['context_detections']), 'Sep 2023 not found'
|
||||
print('Anomaly detection regression: PASS')"
|
||||
|
||||
# Covariates regression check:
|
||||
python -c "
|
||||
import pandas as pd
|
||||
df = pd.read_csv('claude-skill/examples/covariates-forecasting/output/sales_with_covariates.csv')
|
||||
assert len(df) == 108, f'Expected 108 rows, got {len(df)}'
|
||||
prices = df.groupby('store_id')['price'].mean()
|
||||
assert prices['store_A'] > prices['store_B'] > prices['store_C'], 'Store price ordering wrong'
|
||||
print('Covariates regression: PASS')"
|
||||
```
|
||||
@@ -0,0 +1,524 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
TimesFM Anomaly Detection Example — Two-Phase Method
|
||||
|
||||
Phase 1 (context): Linear detrend + Z-score on 36 months of real NOAA
|
||||
temperature anomaly data (2022-01 through 2024-12).
|
||||
Sep 2023 (1.47 C) is a known critical outlier.
|
||||
|
||||
Phase 2 (forecast): TimesFM quantile prediction intervals on a 12-month
|
||||
synthetic future with 3 injected anomalies.
|
||||
|
||||
Outputs:
|
||||
output/anomaly_detection.png -- 2-panel visualization
|
||||
output/anomaly_detection.json -- structured detection records
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.patches as mpatches
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
HORIZON = 12
|
||||
DATA_FILE = (
|
||||
Path(__file__).parent.parent / "global-temperature" / "temperature_anomaly.csv"
|
||||
)
|
||||
OUTPUT_DIR = Path(__file__).parent / "output"
|
||||
|
||||
CRITICAL_Z = 3.0
|
||||
WARNING_Z = 2.0
|
||||
|
||||
# quant_fc index mapping: 0=mean, 1=q10, 2=q20, ..., 9=q90
|
||||
IDX_Q10, IDX_Q20, IDX_Q80, IDX_Q90 = 1, 2, 8, 9
|
||||
|
||||
CLR = {"CRITICAL": "#e02020", "WARNING": "#f08030", "NORMAL": "#4a90d9"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1: context anomaly detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def detect_context_anomalies(
|
||||
values: np.ndarray,
|
||||
dates: list,
|
||||
) -> tuple[list[dict], np.ndarray, np.ndarray, float]:
|
||||
"""Linear detrend + Z-score anomaly detection on context period.
|
||||
|
||||
Returns
|
||||
-------
|
||||
records : list of dicts, one per month
|
||||
trend_line : fitted linear trend values (same length as values)
|
||||
residuals : actual - trend_line
|
||||
res_std : std of residuals (used as sigma for threshold bands)
|
||||
"""
|
||||
n = len(values)
|
||||
idx = np.arange(n, dtype=float)
|
||||
|
||||
coeffs = np.polyfit(idx, values, 1)
|
||||
trend_line = np.polyval(coeffs, idx)
|
||||
residuals = values - trend_line
|
||||
res_std = residuals.std()
|
||||
|
||||
records = []
|
||||
for i, (d, v, r) in enumerate(zip(dates, values, residuals)):
|
||||
z = r / res_std if res_std > 0 else 0.0
|
||||
if abs(z) >= CRITICAL_Z:
|
||||
severity = "CRITICAL"
|
||||
elif abs(z) >= WARNING_Z:
|
||||
severity = "WARNING"
|
||||
else:
|
||||
severity = "NORMAL"
|
||||
records.append(
|
||||
{
|
||||
"date": str(d)[:7],
|
||||
"value": round(float(v), 4),
|
||||
"trend": round(float(trend_line[i]), 4),
|
||||
"residual": round(float(r), 4),
|
||||
"z_score": round(float(z), 3),
|
||||
"severity": severity,
|
||||
}
|
||||
)
|
||||
return records, trend_line, residuals, res_std
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2: synthetic future + forecast anomaly detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_synthetic_future(
|
||||
context: np.ndarray,
|
||||
n: int,
|
||||
seed: int = 42,
|
||||
) -> tuple[np.ndarray, list[int]]:
|
||||
"""Build a plausible future with 3 injected anomalies.
|
||||
|
||||
Injected months: 3, 8, 11 (0-indexed within the 12-month horizon).
|
||||
Returns (future_values, injected_indices).
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
trend = np.linspace(context[-6:].mean(), context[-6:].mean() + 0.05, n)
|
||||
noise = rng.normal(0, 0.1, n)
|
||||
future = trend + noise
|
||||
|
||||
injected = [3, 8, 11]
|
||||
future[3] += 0.7 # CRITICAL spike
|
||||
future[8] -= 0.65 # CRITICAL dip
|
||||
future[11] += 0.45 # WARNING spike
|
||||
|
||||
return future.astype(np.float32), injected
|
||||
|
||||
|
||||
def detect_forecast_anomalies(
|
||||
future_values: np.ndarray,
|
||||
point: np.ndarray,
|
||||
quant_fc: np.ndarray,
|
||||
future_dates: list,
|
||||
injected_at: list[int],
|
||||
) -> list[dict]:
|
||||
"""Classify each forecast month by which PI band it falls outside.
|
||||
|
||||
CRITICAL = outside 80% PI (q10-q90)
|
||||
WARNING = outside 60% PI (q20-q80) but inside 80% PI
|
||||
NORMAL = inside 60% PI
|
||||
"""
|
||||
q10 = quant_fc[IDX_Q10]
|
||||
q20 = quant_fc[IDX_Q20]
|
||||
q80 = quant_fc[IDX_Q80]
|
||||
q90 = quant_fc[IDX_Q90]
|
||||
|
||||
records = []
|
||||
for i, (d, fv, pt) in enumerate(zip(future_dates, future_values, point)):
|
||||
outside_80 = fv < q10[i] or fv > q90[i]
|
||||
outside_60 = fv < q20[i] or fv > q80[i]
|
||||
|
||||
if outside_80:
|
||||
severity = "CRITICAL"
|
||||
elif outside_60:
|
||||
severity = "WARNING"
|
||||
else:
|
||||
severity = "NORMAL"
|
||||
|
||||
records.append(
|
||||
{
|
||||
"date": str(d)[:7],
|
||||
"actual": round(float(fv), 4),
|
||||
"forecast": round(float(pt), 4),
|
||||
"q10": round(float(q10[i]), 4),
|
||||
"q20": round(float(q20[i]), 4),
|
||||
"q80": round(float(q80[i]), 4),
|
||||
"q90": round(float(q90[i]), 4),
|
||||
"severity": severity,
|
||||
"was_injected": i in injected_at,
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Visualization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def plot_results(
|
||||
context_dates: list,
|
||||
context_values: np.ndarray,
|
||||
ctx_records: list[dict],
|
||||
trend_line: np.ndarray,
|
||||
residuals: np.ndarray,
|
||||
res_std: float,
|
||||
future_dates: list,
|
||||
future_values: np.ndarray,
|
||||
point_fc: np.ndarray,
|
||||
quant_fc: np.ndarray,
|
||||
fc_records: list[dict],
|
||||
) -> None:
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(15, 10), gridspec_kw={"hspace": 0.42})
|
||||
fig.suptitle(
|
||||
"TimesFM Anomaly Detection — Two-Phase Method", fontsize=14, fontweight="bold"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Panel 1 — full timeline
|
||||
# -----------------------------------------------------------------------
|
||||
ctx_x = [pd.Timestamp(d) for d in context_dates]
|
||||
fut_x = [pd.Timestamp(d) for d in future_dates]
|
||||
divider = ctx_x[-1]
|
||||
|
||||
# context: blue line + trend + 2sigma band
|
||||
ax1.plot(
|
||||
ctx_x,
|
||||
context_values,
|
||||
color=CLR["NORMAL"],
|
||||
lw=2,
|
||||
marker="o",
|
||||
ms=4,
|
||||
label="Observed (context)",
|
||||
)
|
||||
ax1.plot(ctx_x, trend_line, color="#aaaaaa", lw=1.5, ls="--", label="Linear trend")
|
||||
ax1.fill_between(
|
||||
ctx_x,
|
||||
trend_line - 2 * res_std,
|
||||
trend_line + 2 * res_std,
|
||||
alpha=0.15,
|
||||
color=CLR["NORMAL"],
|
||||
label="+/-2sigma band",
|
||||
)
|
||||
|
||||
# context anomaly markers
|
||||
seen_ctx: set[str] = set()
|
||||
for rec in ctx_records:
|
||||
if rec["severity"] == "NORMAL":
|
||||
continue
|
||||
d = pd.Timestamp(rec["date"])
|
||||
v = rec["value"]
|
||||
sev = rec["severity"]
|
||||
lbl = f"Context {sev}" if sev not in seen_ctx else None
|
||||
seen_ctx.add(sev)
|
||||
ax1.scatter(d, v, marker="D", s=90, color=CLR[sev], zorder=6, label=lbl)
|
||||
ax1.annotate(
|
||||
f"z={rec['z_score']:+.1f}",
|
||||
(d, v),
|
||||
textcoords="offset points",
|
||||
xytext=(0, 9),
|
||||
fontsize=7.5,
|
||||
ha="center",
|
||||
color=CLR[sev],
|
||||
)
|
||||
|
||||
# forecast section
|
||||
q10 = quant_fc[IDX_Q10]
|
||||
q20 = quant_fc[IDX_Q20]
|
||||
q80 = quant_fc[IDX_Q80]
|
||||
q90 = quant_fc[IDX_Q90]
|
||||
|
||||
ax1.plot(fut_x, future_values, "k--", lw=1.5, label="Synthetic future (truth)")
|
||||
ax1.plot(
|
||||
fut_x,
|
||||
point_fc,
|
||||
color=CLR["CRITICAL"],
|
||||
lw=2,
|
||||
marker="s",
|
||||
ms=4,
|
||||
label="TimesFM point forecast",
|
||||
)
|
||||
ax1.fill_between(fut_x, q10, q90, alpha=0.15, color=CLR["CRITICAL"], label="80% PI")
|
||||
ax1.fill_between(fut_x, q20, q80, alpha=0.25, color=CLR["CRITICAL"], label="60% PI")
|
||||
|
||||
seen_fc: set[str] = set()
|
||||
for i, rec in enumerate(fc_records):
|
||||
if rec["severity"] == "NORMAL":
|
||||
continue
|
||||
d = pd.Timestamp(rec["date"])
|
||||
v = rec["actual"]
|
||||
sev = rec["severity"]
|
||||
mk = "X" if sev == "CRITICAL" else "^"
|
||||
lbl = f"Forecast {sev}" if sev not in seen_fc else None
|
||||
seen_fc.add(sev)
|
||||
ax1.scatter(d, v, marker=mk, s=100, color=CLR[sev], zorder=6, label=lbl)
|
||||
|
||||
ax1.axvline(divider, color="#555555", lw=1.5, ls=":")
|
||||
ax1.text(
|
||||
divider,
|
||||
ax1.get_ylim()[1] if ax1.get_ylim()[1] != 0 else 1.5,
|
||||
" <- Context | Forecast ->",
|
||||
fontsize=8.5,
|
||||
color="#555555",
|
||||
style="italic",
|
||||
va="top",
|
||||
)
|
||||
|
||||
ax1.annotate(
|
||||
"Context: D = Z-score anomaly | Forecast: X = CRITICAL, ^ = WARNING",
|
||||
xy=(0.01, 0.04),
|
||||
xycoords="axes fraction",
|
||||
fontsize=8,
|
||||
bbox=dict(boxstyle="round", fc="white", ec="#cccccc", alpha=0.9),
|
||||
)
|
||||
|
||||
ax1.set_ylabel("Temperature Anomaly (C)", fontsize=10)
|
||||
ax1.legend(ncol=2, fontsize=7.5, loc="upper left")
|
||||
ax1.grid(True, alpha=0.22)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Panel 2 — deviation bars across all 48 months
|
||||
# -----------------------------------------------------------------------
|
||||
all_labels: list[str] = []
|
||||
bar_colors: list[str] = []
|
||||
bar_heights: list[float] = []
|
||||
|
||||
for rec in ctx_records:
|
||||
all_labels.append(rec["date"])
|
||||
bar_heights.append(rec["residual"])
|
||||
bar_colors.append(CLR[rec["severity"]])
|
||||
|
||||
fc_deviations: list[float] = []
|
||||
for rec in fc_records:
|
||||
all_labels.append(rec["date"])
|
||||
dev = rec["actual"] - rec["forecast"]
|
||||
fc_deviations.append(dev)
|
||||
bar_heights.append(dev)
|
||||
bar_colors.append(CLR[rec["severity"]])
|
||||
|
||||
xs = np.arange(len(all_labels))
|
||||
ax2.bar(xs[:36], bar_heights[:36], color=bar_colors[:36], alpha=0.8)
|
||||
ax2.bar(xs[36:], bar_heights[36:], color=bar_colors[36:], alpha=0.8)
|
||||
|
||||
# threshold lines for context section only
|
||||
ax2.hlines(
|
||||
[2 * res_std, -2 * res_std], -0.5, 35.5, colors=CLR["NORMAL"], lw=1.2, ls="--"
|
||||
)
|
||||
ax2.hlines(
|
||||
[3 * res_std, -3 * res_std], -0.5, 35.5, colors=CLR["NORMAL"], lw=1.0, ls=":"
|
||||
)
|
||||
|
||||
# PI bands for forecast section
|
||||
fc_xs = xs[36:]
|
||||
ax2.fill_between(
|
||||
fc_xs,
|
||||
q10 - point_fc,
|
||||
q90 - point_fc,
|
||||
alpha=0.12,
|
||||
color=CLR["CRITICAL"],
|
||||
step="mid",
|
||||
)
|
||||
ax2.fill_between(
|
||||
fc_xs,
|
||||
q20 - point_fc,
|
||||
q80 - point_fc,
|
||||
alpha=0.20,
|
||||
color=CLR["CRITICAL"],
|
||||
step="mid",
|
||||
)
|
||||
|
||||
ax2.axvline(35.5, color="#555555", lw=1.5, ls="--")
|
||||
ax2.axhline(0, color="black", lw=0.8, alpha=0.6)
|
||||
|
||||
ax2.text(
|
||||
10,
|
||||
ax2.get_ylim()[0] * 0.85 if ax2.get_ylim()[0] < 0 else -0.05,
|
||||
"<- Context: delta from linear trend",
|
||||
fontsize=8,
|
||||
style="italic",
|
||||
color="#555555",
|
||||
ha="center",
|
||||
)
|
||||
ax2.text(
|
||||
41,
|
||||
ax2.get_ylim()[0] * 0.85 if ax2.get_ylim()[0] < 0 else -0.05,
|
||||
"Forecast: delta from TimesFM ->",
|
||||
fontsize=8,
|
||||
style="italic",
|
||||
color="#555555",
|
||||
ha="center",
|
||||
)
|
||||
|
||||
tick_every = 3
|
||||
ax2.set_xticks(xs[::tick_every])
|
||||
ax2.set_xticklabels(all_labels[::tick_every], rotation=45, ha="right", fontsize=7)
|
||||
ax2.set_ylabel("Delta from expected (C)", fontsize=10)
|
||||
ax2.grid(True, alpha=0.22, axis="y")
|
||||
|
||||
legend_patches = [
|
||||
mpatches.Patch(color=CLR["CRITICAL"], label="CRITICAL"),
|
||||
mpatches.Patch(color=CLR["WARNING"], label="WARNING"),
|
||||
mpatches.Patch(color=CLR["NORMAL"], label="Normal"),
|
||||
]
|
||||
ax2.legend(handles=legend_patches, fontsize=8, loc="upper right")
|
||||
|
||||
output_path = OUTPUT_DIR / "anomaly_detection.png"
|
||||
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f"\n Saved: {output_path}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 68)
|
||||
print(" TIMESFM ANOMALY DETECTION — TWO-PHASE METHOD")
|
||||
print("=" * 68)
|
||||
|
||||
# --- Load context data ---------------------------------------------------
|
||||
df = pd.read_csv(DATA_FILE)
|
||||
df["date"] = pd.to_datetime(df["date"])
|
||||
df = df.sort_values("date").reset_index(drop=True)
|
||||
|
||||
context_values = df["anomaly_c"].values.astype(np.float32)
|
||||
context_dates = [pd.Timestamp(d) for d in df["date"].tolist()]
|
||||
start_str = context_dates[0].strftime('%Y-%m') if not pd.isnull(context_dates[0]) else '?'
|
||||
end_str = context_dates[-1].strftime('%Y-%m') if not pd.isnull(context_dates[-1]) else '?'
|
||||
print(f"\n Context: {len(context_values)} months ({start_str} - {end_str})")
|
||||
|
||||
# --- Phase 1: context anomaly detection ----------------------------------
|
||||
ctx_records, trend_line, residuals, res_std = detect_context_anomalies(
|
||||
context_values, context_dates
|
||||
)
|
||||
ctx_critical = [r for r in ctx_records if r["severity"] == "CRITICAL"]
|
||||
ctx_warning = [r for r in ctx_records if r["severity"] == "WARNING"]
|
||||
print(f"\n [Phase 1] Context anomalies (Z-score, sigma={res_std:.3f} C):")
|
||||
print(f" CRITICAL (|Z|>={CRITICAL_Z}): {len(ctx_critical)}")
|
||||
for r in ctx_critical:
|
||||
print(f" {r['date']} {r['value']:+.3f} C z={r['z_score']:+.2f}")
|
||||
print(f" WARNING (|Z|>={WARNING_Z}): {len(ctx_warning)}")
|
||||
for r in ctx_warning:
|
||||
print(f" {r['date']} {r['value']:+.3f} C z={r['z_score']:+.2f}")
|
||||
|
||||
# --- Load TimesFM --------------------------------------------------------
|
||||
print("\n Loading TimesFM 1.0 ...")
|
||||
import timesfm
|
||||
|
||||
hparams = timesfm.TimesFmHparams(horizon_len=HORIZON)
|
||||
checkpoint = timesfm.TimesFmCheckpoint(
|
||||
huggingface_repo_id="google/timesfm-1.0-200m-pytorch"
|
||||
)
|
||||
model = timesfm.TimesFm(hparams=hparams, checkpoint=checkpoint)
|
||||
|
||||
point_out, quant_out = model.forecast([context_values], freq=[0])
|
||||
point_fc = point_out[0] # shape (HORIZON,)
|
||||
quant_fc = quant_out[0].T # shape (10, HORIZON)
|
||||
|
||||
# --- Build synthetic future + Phase 2 detection --------------------------
|
||||
future_values, injected = build_synthetic_future(context_values, HORIZON)
|
||||
last_date = context_dates[-1]
|
||||
future_dates = [last_date + pd.DateOffset(months=i + 1) for i in range(HORIZON)]
|
||||
|
||||
fc_records = detect_forecast_anomalies(
|
||||
future_values, point_fc, quant_fc, future_dates, injected
|
||||
)
|
||||
fc_critical = [r for r in fc_records if r["severity"] == "CRITICAL"]
|
||||
fc_warning = [r for r in fc_records if r["severity"] == "WARNING"]
|
||||
|
||||
print(f"\n [Phase 2] Forecast anomalies (quantile PI, horizon={HORIZON} months):")
|
||||
print(f" CRITICAL (outside 80% PI): {len(fc_critical)}")
|
||||
for r in fc_critical:
|
||||
print(
|
||||
f" {r['date']} actual={r['actual']:+.3f} "
|
||||
f"fc={r['forecast']:+.3f} injected={r['was_injected']}"
|
||||
)
|
||||
print(f" WARNING (outside 60% PI): {len(fc_warning)}")
|
||||
for r in fc_warning:
|
||||
print(
|
||||
f" {r['date']} actual={r['actual']:+.3f} "
|
||||
f"fc={r['forecast']:+.3f} injected={r['was_injected']}"
|
||||
)
|
||||
|
||||
# --- Plot ----------------------------------------------------------------
|
||||
print("\n Generating 2-panel visualization...")
|
||||
plot_results(
|
||||
context_dates,
|
||||
context_values,
|
||||
ctx_records,
|
||||
trend_line,
|
||||
residuals,
|
||||
res_std,
|
||||
future_dates,
|
||||
future_values,
|
||||
point_fc,
|
||||
quant_fc,
|
||||
fc_records,
|
||||
)
|
||||
|
||||
# --- Save JSON -----------------------------------------------------------
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
out = {
|
||||
"method": "two_phase",
|
||||
"context_method": "linear_detrend_zscore",
|
||||
"forecast_method": "quantile_prediction_intervals",
|
||||
"thresholds": {
|
||||
"critical_z": CRITICAL_Z,
|
||||
"warning_z": WARNING_Z,
|
||||
"pi_critical_pct": 80,
|
||||
"pi_warning_pct": 60,
|
||||
},
|
||||
"context_summary": {
|
||||
"total": len(ctx_records),
|
||||
"critical": len(ctx_critical),
|
||||
"warning": len(ctx_warning),
|
||||
"normal": len([r for r in ctx_records if r["severity"] == "NORMAL"]),
|
||||
"res_std": round(float(res_std), 5),
|
||||
},
|
||||
"forecast_summary": {
|
||||
"total": len(fc_records),
|
||||
"critical": len(fc_critical),
|
||||
"warning": len(fc_warning),
|
||||
"normal": len([r for r in fc_records if r["severity"] == "NORMAL"]),
|
||||
},
|
||||
"context_detections": ctx_records,
|
||||
"forecast_detections": fc_records,
|
||||
}
|
||||
json_path = OUTPUT_DIR / "anomaly_detection.json"
|
||||
with open(json_path, "w") as f:
|
||||
json.dump(out, f, indent=2)
|
||||
print(f" Saved: {json_path}")
|
||||
|
||||
print("\n" + "=" * 68)
|
||||
print(" SUMMARY")
|
||||
print("=" * 68)
|
||||
print(
|
||||
f" Context ({len(ctx_records)} months): "
|
||||
f"{len(ctx_critical)} CRITICAL, {len(ctx_warning)} WARNING"
|
||||
)
|
||||
print(
|
||||
f" Forecast ({len(fc_records)} months): "
|
||||
f"{len(fc_critical)} CRITICAL, {len(fc_warning)} WARNING"
|
||||
)
|
||||
print("=" * 68)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,448 @@
|
||||
{
|
||||
"method": "two_phase",
|
||||
"context_method": "linear_detrend_zscore",
|
||||
"forecast_method": "quantile_prediction_intervals",
|
||||
"thresholds": {
|
||||
"critical_z": 3.0,
|
||||
"warning_z": 2.0,
|
||||
"pi_critical_pct": 80,
|
||||
"pi_warning_pct": 60
|
||||
},
|
||||
"context_summary": {
|
||||
"total": 36,
|
||||
"critical": 1,
|
||||
"warning": 0,
|
||||
"normal": 35,
|
||||
"res_std": 0.11362
|
||||
},
|
||||
"forecast_summary": {
|
||||
"total": 12,
|
||||
"critical": 4,
|
||||
"warning": 1,
|
||||
"normal": 7
|
||||
},
|
||||
"context_detections": [
|
||||
{
|
||||
"date": "2022-01",
|
||||
"value": 0.89,
|
||||
"trend": 0.837,
|
||||
"residual": 0.053,
|
||||
"z_score": 0.467,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2022-02",
|
||||
"value": 0.89,
|
||||
"trend": 0.8514,
|
||||
"residual": 0.0386,
|
||||
"z_score": 0.34,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2022-03",
|
||||
"value": 1.02,
|
||||
"trend": 0.8658,
|
||||
"residual": 0.1542,
|
||||
"z_score": 1.357,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2022-04",
|
||||
"value": 0.88,
|
||||
"trend": 0.8803,
|
||||
"residual": -0.0003,
|
||||
"z_score": -0.002,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2022-05",
|
||||
"value": 0.85,
|
||||
"trend": 0.8947,
|
||||
"residual": -0.0447,
|
||||
"z_score": -0.394,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2022-06",
|
||||
"value": 0.88,
|
||||
"trend": 0.9092,
|
||||
"residual": -0.0292,
|
||||
"z_score": -0.257,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2022-07",
|
||||
"value": 0.88,
|
||||
"trend": 0.9236,
|
||||
"residual": -0.0436,
|
||||
"z_score": -0.384,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2022-08",
|
||||
"value": 0.9,
|
||||
"trend": 0.9381,
|
||||
"residual": -0.0381,
|
||||
"z_score": -0.335,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2022-09",
|
||||
"value": 0.88,
|
||||
"trend": 0.9525,
|
||||
"residual": -0.0725,
|
||||
"z_score": -0.638,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2022-10",
|
||||
"value": 0.95,
|
||||
"trend": 0.9669,
|
||||
"residual": -0.0169,
|
||||
"z_score": -0.149,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2022-11",
|
||||
"value": 0.77,
|
||||
"trend": 0.9814,
|
||||
"residual": -0.2114,
|
||||
"z_score": -1.86,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2022-12",
|
||||
"value": 0.78,
|
||||
"trend": 0.9958,
|
||||
"residual": -0.2158,
|
||||
"z_score": -1.9,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2023-01",
|
||||
"value": 0.87,
|
||||
"trend": 1.0103,
|
||||
"residual": -0.1403,
|
||||
"z_score": -1.235,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2023-02",
|
||||
"value": 0.98,
|
||||
"trend": 1.0247,
|
||||
"residual": -0.0447,
|
||||
"z_score": -0.394,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2023-03",
|
||||
"value": 1.21,
|
||||
"trend": 1.0392,
|
||||
"residual": 0.1708,
|
||||
"z_score": 1.503,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2023-04",
|
||||
"value": 1.0,
|
||||
"trend": 1.0536,
|
||||
"residual": -0.0536,
|
||||
"z_score": -0.472,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2023-05",
|
||||
"value": 0.94,
|
||||
"trend": 1.0681,
|
||||
"residual": -0.1281,
|
||||
"z_score": -1.127,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2023-06",
|
||||
"value": 1.08,
|
||||
"trend": 1.0825,
|
||||
"residual": -0.0025,
|
||||
"z_score": -0.022,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2023-07",
|
||||
"value": 1.18,
|
||||
"trend": 1.0969,
|
||||
"residual": 0.0831,
|
||||
"z_score": 0.731,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2023-08",
|
||||
"value": 1.24,
|
||||
"trend": 1.1114,
|
||||
"residual": 0.1286,
|
||||
"z_score": 1.132,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2023-09",
|
||||
"value": 1.47,
|
||||
"trend": 1.1258,
|
||||
"residual": 0.3442,
|
||||
"z_score": 3.029,
|
||||
"severity": "CRITICAL"
|
||||
},
|
||||
{
|
||||
"date": "2023-10",
|
||||
"value": 1.32,
|
||||
"trend": 1.1403,
|
||||
"residual": 0.1797,
|
||||
"z_score": 1.582,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2023-11",
|
||||
"value": 1.18,
|
||||
"trend": 1.1547,
|
||||
"residual": 0.0253,
|
||||
"z_score": 0.222,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2023-12",
|
||||
"value": 1.16,
|
||||
"trend": 1.1692,
|
||||
"residual": -0.0092,
|
||||
"z_score": -0.081,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2024-01",
|
||||
"value": 1.22,
|
||||
"trend": 1.1836,
|
||||
"residual": 0.0364,
|
||||
"z_score": 0.32,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2024-02",
|
||||
"value": 1.35,
|
||||
"trend": 1.1981,
|
||||
"residual": 0.1519,
|
||||
"z_score": 1.337,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2024-03",
|
||||
"value": 1.34,
|
||||
"trend": 1.2125,
|
||||
"residual": 0.1275,
|
||||
"z_score": 1.122,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2024-04",
|
||||
"value": 1.26,
|
||||
"trend": 1.2269,
|
||||
"residual": 0.0331,
|
||||
"z_score": 0.291,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2024-05",
|
||||
"value": 1.15,
|
||||
"trend": 1.2414,
|
||||
"residual": -0.0914,
|
||||
"z_score": -0.804,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2024-06",
|
||||
"value": 1.2,
|
||||
"trend": 1.2558,
|
||||
"residual": -0.0558,
|
||||
"z_score": -0.491,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2024-07",
|
||||
"value": 1.24,
|
||||
"trend": 1.2703,
|
||||
"residual": -0.0303,
|
||||
"z_score": -0.266,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2024-08",
|
||||
"value": 1.3,
|
||||
"trend": 1.2847,
|
||||
"residual": 0.0153,
|
||||
"z_score": 0.135,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2024-09",
|
||||
"value": 1.28,
|
||||
"trend": 1.2992,
|
||||
"residual": -0.0192,
|
||||
"z_score": -0.169,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2024-10",
|
||||
"value": 1.27,
|
||||
"trend": 1.3136,
|
||||
"residual": -0.0436,
|
||||
"z_score": -0.384,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2024-11",
|
||||
"value": 1.22,
|
||||
"trend": 1.328,
|
||||
"residual": -0.108,
|
||||
"z_score": -0.951,
|
||||
"severity": "NORMAL"
|
||||
},
|
||||
{
|
||||
"date": "2024-12",
|
||||
"value": 1.2,
|
||||
"trend": 1.3425,
|
||||
"residual": -0.1425,
|
||||
"z_score": -1.254,
|
||||
"severity": "NORMAL"
|
||||
}
|
||||
],
|
||||
"forecast_detections": [
|
||||
{
|
||||
"date": "2025-01",
|
||||
"actual": 1.2821,
|
||||
"forecast": 1.2593,
|
||||
"q10": 1.1407,
|
||||
"q20": 1.1881,
|
||||
"q80": 1.324,
|
||||
"q90": 1.3679,
|
||||
"severity": "NORMAL",
|
||||
"was_injected": false
|
||||
},
|
||||
{
|
||||
"date": "2025-02",
|
||||
"actual": 1.1522,
|
||||
"forecast": 1.2857,
|
||||
"q10": 1.1406,
|
||||
"q20": 1.1961,
|
||||
"q80": 1.3751,
|
||||
"q90": 1.4254,
|
||||
"severity": "WARNING",
|
||||
"was_injected": false
|
||||
},
|
||||
{
|
||||
"date": "2025-03",
|
||||
"actual": 1.3358,
|
||||
"forecast": 1.295,
|
||||
"q10": 1.1269,
|
||||
"q20": 1.1876,
|
||||
"q80": 1.4035,
|
||||
"q90": 1.4643,
|
||||
"severity": "NORMAL",
|
||||
"was_injected": false
|
||||
},
|
||||
{
|
||||
"date": "2025-04",
|
||||
"actual": 2.0594,
|
||||
"forecast": 1.2208,
|
||||
"q10": 1.0353,
|
||||
"q20": 1.1042,
|
||||
"q80": 1.331,
|
||||
"q90": 1.4017,
|
||||
"severity": "CRITICAL",
|
||||
"was_injected": true
|
||||
},
|
||||
{
|
||||
"date": "2025-05",
|
||||
"actual": 1.0747,
|
||||
"forecast": 1.1703,
|
||||
"q10": 0.9691,
|
||||
"q20": 1.0431,
|
||||
"q80": 1.2892,
|
||||
"q90": 1.3632,
|
||||
"severity": "NORMAL",
|
||||
"was_injected": false
|
||||
},
|
||||
{
|
||||
"date": "2025-06",
|
||||
"actual": 1.1442,
|
||||
"forecast": 1.1456,
|
||||
"q10": 0.942,
|
||||
"q20": 1.0111,
|
||||
"q80": 1.2703,
|
||||
"q90": 1.3454,
|
||||
"severity": "NORMAL",
|
||||
"was_injected": false
|
||||
},
|
||||
{
|
||||
"date": "2025-07",
|
||||
"actual": 1.2917,
|
||||
"forecast": 1.1702,
|
||||
"q10": 0.9504,
|
||||
"q20": 1.0348,
|
||||
"q80": 1.2998,
|
||||
"q90": 1.3807,
|
||||
"severity": "NORMAL",
|
||||
"was_injected": false
|
||||
},
|
||||
{
|
||||
"date": "2025-08",
|
||||
"actual": 1.2519,
|
||||
"forecast": 1.2027,
|
||||
"q10": 0.9709,
|
||||
"q20": 1.0594,
|
||||
"q80": 1.3408,
|
||||
"q90": 1.4195,
|
||||
"severity": "NORMAL",
|
||||
"was_injected": false
|
||||
},
|
||||
{
|
||||
"date": "2025-09",
|
||||
"actual": 0.6364,
|
||||
"forecast": 1.191,
|
||||
"q10": 0.9594,
|
||||
"q20": 1.0404,
|
||||
"q80": 1.3355,
|
||||
"q90": 1.417,
|
||||
"severity": "CRITICAL",
|
||||
"was_injected": true
|
||||
},
|
||||
{
|
||||
"date": "2025-10",
|
||||
"actual": 1.2073,
|
||||
"forecast": 1.1491,
|
||||
"q10": 0.9079,
|
||||
"q20": 0.9953,
|
||||
"q80": 1.2869,
|
||||
"q90": 1.3775,
|
||||
"severity": "NORMAL",
|
||||
"was_injected": false
|
||||
},
|
||||
{
|
||||
"date": "2025-11",
|
||||
"actual": 1.3851,
|
||||
"forecast": 1.0805,
|
||||
"q10": 0.8361,
|
||||
"q20": 0.926,
|
||||
"q80": 1.2284,
|
||||
"q90": 1.3122,
|
||||
"severity": "CRITICAL",
|
||||
"was_injected": false
|
||||
},
|
||||
{
|
||||
"date": "2025-12",
|
||||
"actual": 1.8294,
|
||||
"forecast": 1.0613,
|
||||
"q10": 0.8022,
|
||||
"q20": 0.8952,
|
||||
"q80": 1.2169,
|
||||
"q90": 1.296,
|
||||
"severity": "CRITICAL",
|
||||
"was_injected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
@@ -0,0 +1,568 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
TimesFM Covariates (XReg) Example
|
||||
|
||||
Demonstrates the TimesFM covariate API using synthetic retail sales data.
|
||||
TimesFM 1.0 does NOT support forecast_with_covariates(); that requires
|
||||
TimesFM 2.5 + `pip install timesfm[xreg]`.
|
||||
|
||||
This script:
|
||||
1. Generates synthetic 3-store weekly retail data (24-week context, 12-week horizon)
|
||||
2. Produces a 2x2 visualization showing WHAT each covariate contributes
|
||||
and WHY knowing them improves forecasts -- all panels share the same
|
||||
week x-axis (0 = first context week, 35 = last horizon week)
|
||||
3. Exports a compact CSV (108 rows) and metadata JSON
|
||||
|
||||
NOTE ON REAL DATA:
|
||||
If you want to use a real retail dataset (e.g., Kaggle Rossmann Store Sales),
|
||||
download it to a TEMP location -- do NOT commit large CSVs to this repo.
|
||||
|
||||
import tempfile, urllib.request
|
||||
tmp = tempfile.mkdtemp(prefix="timesfm_retail_")
|
||||
# urllib.request.urlretrieve("https://...store_sales.csv", f"{tmp}/store_sales.csv")
|
||||
# df = pd.read_csv(f"{tmp}/store_sales.csv")
|
||||
|
||||
This skills directory intentionally keeps only tiny reference datasets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
EXAMPLE_DIR = Path(__file__).parent
|
||||
OUTPUT_DIR = EXAMPLE_DIR / "output"
|
||||
|
||||
N_STORES = 3
|
||||
CONTEXT_LEN = 24
|
||||
HORIZON_LEN = 12
|
||||
TOTAL_LEN = CONTEXT_LEN + HORIZON_LEN # 36
|
||||
|
||||
|
||||
def generate_sales_data() -> dict:
|
||||
"""Generate synthetic retail sales data with covariate components stored separately.
|
||||
|
||||
Returns a dict with:
|
||||
stores: {store_id: {sales, config}}
|
||||
covariates: {price, promotion, holiday, day_of_week, store_type, region}
|
||||
components: {store_id: {base, price_effect, promo_effect, holiday_effect}}
|
||||
|
||||
Components let us show 'what would sales look like without covariates?' --
|
||||
the gap between 'base' and 'sales' IS the covariate signal.
|
||||
|
||||
BUG FIX v3: Previous versions had variable-shadowing where inner dict
|
||||
comprehension `{store_id: ... for store_id in stores}` overwrote the outer
|
||||
loop variable causing all stores to get identical covariate arrays.
|
||||
Fixed by accumulating per-store arrays separately before building covariate dict.
|
||||
"""
|
||||
rng = np.random.default_rng(42)
|
||||
|
||||
stores = {
|
||||
"store_A": {"type": "premium", "region": "urban", "base_sales": 1000},
|
||||
"store_B": {"type": "standard", "region": "suburban", "base_sales": 750},
|
||||
"store_C": {"type": "discount", "region": "rural", "base_sales": 500},
|
||||
}
|
||||
base_prices = {"store_A": 12.0, "store_B": 10.0, "store_C": 7.5}
|
||||
|
||||
data: dict = {"stores": {}, "covariates": {}, "components": {}}
|
||||
|
||||
prices_by_store: dict[str, np.ndarray] = {}
|
||||
promos_by_store: dict[str, np.ndarray] = {}
|
||||
holidays_by_store: dict[str, np.ndarray] = {}
|
||||
dow_by_store: dict[str, np.ndarray] = {}
|
||||
|
||||
for store_id, config in stores.items():
|
||||
bp = base_prices[store_id]
|
||||
weeks = np.arange(TOTAL_LEN)
|
||||
|
||||
trend = config["base_sales"] * (1 + 0.005 * weeks)
|
||||
seasonality = 80 * np.sin(2 * np.pi * weeks / 52)
|
||||
noise = rng.normal(0, 40, TOTAL_LEN)
|
||||
base = (trend + seasonality + noise).astype(np.float32)
|
||||
|
||||
price = (bp + rng.uniform(-0.5, 0.5, TOTAL_LEN)).astype(np.float32)
|
||||
price_effect = (-20 * (price - bp)).astype(np.float32)
|
||||
|
||||
holidays = np.zeros(TOTAL_LEN, dtype=np.float32)
|
||||
for hw in [0, 11, 23, 35]:
|
||||
if hw < TOTAL_LEN:
|
||||
holidays[hw] = 1.0
|
||||
holiday_effect = (200 * holidays).astype(np.float32)
|
||||
|
||||
promotion = rng.choice([0.0, 1.0], TOTAL_LEN, p=[0.8, 0.2]).astype(np.float32)
|
||||
promo_effect = (150 * promotion).astype(np.float32)
|
||||
|
||||
day_of_week = np.tile(np.arange(7), TOTAL_LEN // 7 + 1)[:TOTAL_LEN].astype(
|
||||
np.int32
|
||||
)
|
||||
|
||||
sales = np.maximum(base + price_effect + holiday_effect + promo_effect, 50.0)
|
||||
|
||||
data["stores"][store_id] = {"sales": sales, "config": config}
|
||||
data["components"][store_id] = {
|
||||
"base": base,
|
||||
"price_effect": price_effect,
|
||||
"promo_effect": promo_effect,
|
||||
"holiday_effect": holiday_effect,
|
||||
}
|
||||
|
||||
prices_by_store[store_id] = price
|
||||
promos_by_store[store_id] = promotion
|
||||
holidays_by_store[store_id] = holidays
|
||||
dow_by_store[store_id] = day_of_week
|
||||
|
||||
data["covariates"] = {
|
||||
"price": prices_by_store,
|
||||
"promotion": promos_by_store,
|
||||
"holiday": holidays_by_store,
|
||||
"day_of_week": dow_by_store,
|
||||
"store_type": {sid: stores[sid]["type"] for sid in stores},
|
||||
"region": {sid: stores[sid]["region"] for sid in stores},
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
def create_visualization(data: dict) -> None:
|
||||
"""
|
||||
2x2 figure -- ALL panels share x-axis = weeks 0-35.
|
||||
|
||||
(0,0) Sales by store -- context solid, horizon dashed
|
||||
(0,1) Store A: actual vs baseline (no covariates), with event overlays showing uplift
|
||||
(1,0) Price covariate for all stores -- full 36 weeks including horizon
|
||||
(1,1) Covariate effect decomposition for Store A (stacked fill_between)
|
||||
|
||||
Each panel has a conclusion annotation box explaining what the data shows.
|
||||
"""
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
store_colors = {"store_A": "#1a56db", "store_B": "#057a55", "store_C": "#c03221"}
|
||||
weeks = np.arange(TOTAL_LEN)
|
||||
|
||||
fig, axes = plt.subplots(
|
||||
2,
|
||||
2,
|
||||
figsize=(16, 11),
|
||||
sharex=True,
|
||||
gridspec_kw={"hspace": 0.42, "wspace": 0.32},
|
||||
)
|
||||
fig.suptitle(
|
||||
"TimesFM Covariates (XReg) -- Retail Sales with Exogenous Variables\n"
|
||||
"Shared x-axis: Week 0-23 = context (observed) | Week 24-35 = forecast horizon",
|
||||
fontsize=13,
|
||||
fontweight="bold",
|
||||
y=1.01,
|
||||
)
|
||||
|
||||
def add_divider(ax, label_top=True):
|
||||
ax.axvline(CONTEXT_LEN - 0.5, color="#9ca3af", lw=1.3, ls="--", alpha=0.8)
|
||||
ax.axvspan(
|
||||
CONTEXT_LEN - 0.5, TOTAL_LEN - 0.5, alpha=0.06, color="grey", zorder=0
|
||||
)
|
||||
if label_top:
|
||||
ax.text(
|
||||
CONTEXT_LEN + 0.3,
|
||||
1.01,
|
||||
"<- horizon ->",
|
||||
transform=ax.get_xaxis_transform(),
|
||||
fontsize=7.5,
|
||||
color="#6b7280",
|
||||
style="italic",
|
||||
)
|
||||
|
||||
# -- (0,0): Sales by Store ---------------------------------------------------
|
||||
ax = axes[0, 0]
|
||||
base_price_labels = {"store_A": "$12", "store_B": "$10", "store_C": "$7.50"}
|
||||
for sid, store_data in data["stores"].items():
|
||||
sales = store_data["sales"]
|
||||
c = store_colors[sid]
|
||||
lbl = f"{sid} ({store_data['config']['type']}, {base_price_labels[sid]} base)"
|
||||
ax.plot(
|
||||
weeks[:CONTEXT_LEN],
|
||||
sales[:CONTEXT_LEN],
|
||||
color=c,
|
||||
lw=2,
|
||||
marker="o",
|
||||
ms=3,
|
||||
label=lbl,
|
||||
)
|
||||
ax.plot(
|
||||
weeks[CONTEXT_LEN:],
|
||||
sales[CONTEXT_LEN:],
|
||||
color=c,
|
||||
lw=1.5,
|
||||
ls="--",
|
||||
marker="o",
|
||||
ms=3,
|
||||
alpha=0.6,
|
||||
)
|
||||
add_divider(ax)
|
||||
ax.set_ylabel("Weekly Sales (units)", fontsize=10)
|
||||
ax.set_title("Sales by Store", fontsize=11, fontweight="bold")
|
||||
ax.legend(fontsize=7.5, loc="upper left")
|
||||
ax.grid(True, alpha=0.22)
|
||||
ratio = (
|
||||
data["stores"]["store_A"]["sales"][:CONTEXT_LEN].mean()
|
||||
/ data["stores"]["store_C"]["sales"][:CONTEXT_LEN].mean()
|
||||
)
|
||||
ax.annotate(
|
||||
f"Store A earns {ratio:.1f}x Store C\n(premium vs discount pricing)\n"
|
||||
f"-> store_type is a useful static covariate",
|
||||
xy=(0.97, 0.05),
|
||||
xycoords="axes fraction",
|
||||
ha="right",
|
||||
fontsize=8,
|
||||
bbox=dict(boxstyle="round", fc="#fffbe6", ec="#d4a017", alpha=0.95),
|
||||
)
|
||||
|
||||
# -- (0,1): Store A actual vs baseline ---------------------------------------
|
||||
ax = axes[0, 1]
|
||||
comp_A = data["components"]["store_A"]
|
||||
sales_A = data["stores"]["store_A"]["sales"]
|
||||
base_A = comp_A["base"]
|
||||
promo_A = data["covariates"]["promotion"]["store_A"]
|
||||
holiday_A = data["covariates"]["holiday"]["store_A"]
|
||||
|
||||
ax.plot(
|
||||
weeks[:CONTEXT_LEN],
|
||||
base_A[:CONTEXT_LEN],
|
||||
color="#9ca3af",
|
||||
lw=1.8,
|
||||
ls="--",
|
||||
label="Baseline (no covariates)",
|
||||
)
|
||||
ax.fill_between(
|
||||
weeks[:CONTEXT_LEN],
|
||||
base_A[:CONTEXT_LEN],
|
||||
sales_A[:CONTEXT_LEN],
|
||||
where=(sales_A[:CONTEXT_LEN] > base_A[:CONTEXT_LEN]),
|
||||
alpha=0.35,
|
||||
color="#22c55e",
|
||||
label="Covariate uplift",
|
||||
)
|
||||
ax.fill_between(
|
||||
weeks[:CONTEXT_LEN],
|
||||
sales_A[:CONTEXT_LEN],
|
||||
base_A[:CONTEXT_LEN],
|
||||
where=(sales_A[:CONTEXT_LEN] < base_A[:CONTEXT_LEN]),
|
||||
alpha=0.30,
|
||||
color="#ef4444",
|
||||
label="Price suppression",
|
||||
)
|
||||
ax.plot(
|
||||
weeks[:CONTEXT_LEN],
|
||||
sales_A[:CONTEXT_LEN],
|
||||
color=store_colors["store_A"],
|
||||
lw=2,
|
||||
label="Actual sales (Store A)",
|
||||
)
|
||||
|
||||
for w in range(CONTEXT_LEN):
|
||||
if holiday_A[w] > 0:
|
||||
ax.axvspan(w - 0.45, w + 0.45, alpha=0.22, color="darkorange", zorder=0)
|
||||
promo_weeks = [w for w in range(CONTEXT_LEN) if promo_A[w] > 0]
|
||||
if promo_weeks:
|
||||
ax.scatter(
|
||||
promo_weeks,
|
||||
sales_A[promo_weeks],
|
||||
marker="^",
|
||||
color="#16a34a",
|
||||
s=70,
|
||||
zorder=6,
|
||||
label="Promotion week",
|
||||
)
|
||||
|
||||
add_divider(ax)
|
||||
ax.set_ylabel("Weekly Sales (units)", fontsize=10)
|
||||
ax.set_title(
|
||||
"Store A -- Actual vs Baseline (No Covariates)", fontsize=11, fontweight="bold"
|
||||
)
|
||||
ax.legend(fontsize=7.5, loc="upper left", ncol=2)
|
||||
ax.grid(True, alpha=0.22)
|
||||
|
||||
hm = holiday_A[:CONTEXT_LEN] > 0
|
||||
pm = promo_A[:CONTEXT_LEN] > 0
|
||||
h_lift = (
|
||||
(sales_A[:CONTEXT_LEN][hm] - base_A[:CONTEXT_LEN][hm]).mean() if hm.any() else 0
|
||||
)
|
||||
p_lift = (
|
||||
(sales_A[:CONTEXT_LEN][pm] - base_A[:CONTEXT_LEN][pm]).mean() if pm.any() else 0
|
||||
)
|
||||
ax.annotate(
|
||||
f"Holiday weeks: +{h_lift:.0f} units avg\n"
|
||||
f"Promotion weeks: +{p_lift:.0f} units avg\n"
|
||||
f"Future event schedules must be known for XReg",
|
||||
xy=(0.97, 0.05),
|
||||
xycoords="axes fraction",
|
||||
ha="right",
|
||||
fontsize=8,
|
||||
bbox=dict(boxstyle="round", fc="#fffbe6", ec="#d4a017", alpha=0.95),
|
||||
)
|
||||
|
||||
# -- (1,0): Price covariate -- full 36 weeks ---------------------------------
|
||||
ax = axes[1, 0]
|
||||
for sid in data["stores"]:
|
||||
ax.plot(
|
||||
weeks,
|
||||
data["covariates"]["price"][sid],
|
||||
color=store_colors[sid],
|
||||
lw=2,
|
||||
label=sid,
|
||||
alpha=0.85,
|
||||
)
|
||||
add_divider(ax, label_top=False)
|
||||
ax.set_xlabel("Week", fontsize=10)
|
||||
ax.set_ylabel("Price ($)", fontsize=10)
|
||||
ax.set_title(
|
||||
"Price Covariate -- Context + Forecast Horizon", fontsize=11, fontweight="bold"
|
||||
)
|
||||
ax.legend(fontsize=8, loc="upper right")
|
||||
ax.grid(True, alpha=0.22)
|
||||
ax.annotate(
|
||||
"Prices are planned -- known for forecast horizon\n"
|
||||
"Price elasticity: -$1 increase -> -20 units sold\n"
|
||||
"Store A ($12) consistently more expensive than C ($7.50)",
|
||||
xy=(0.97, 0.05),
|
||||
xycoords="axes fraction",
|
||||
ha="right",
|
||||
fontsize=8,
|
||||
bbox=dict(boxstyle="round", fc="#fffbe6", ec="#d4a017", alpha=0.95),
|
||||
)
|
||||
|
||||
# -- (1,1): Covariate effect decomposition -----------------------------------
|
||||
ax = axes[1, 1]
|
||||
pe = comp_A["price_effect"]
|
||||
pre = comp_A["promo_effect"]
|
||||
he = comp_A["holiday_effect"]
|
||||
|
||||
ax.fill_between(
|
||||
weeks,
|
||||
0,
|
||||
pe,
|
||||
alpha=0.65,
|
||||
color="steelblue",
|
||||
step="mid",
|
||||
label=f"Price effect (max +/-{np.abs(pe).max():.0f} units)",
|
||||
)
|
||||
ax.fill_between(
|
||||
weeks,
|
||||
pe,
|
||||
pe + pre,
|
||||
alpha=0.70,
|
||||
color="#22c55e",
|
||||
step="mid",
|
||||
label="Promotion effect (+150 units)",
|
||||
)
|
||||
ax.fill_between(
|
||||
weeks,
|
||||
pe + pre,
|
||||
pe + pre + he,
|
||||
alpha=0.70,
|
||||
color="darkorange",
|
||||
step="mid",
|
||||
label="Holiday effect (+200 units)",
|
||||
)
|
||||
total = pe + pre + he
|
||||
ax.plot(weeks, total, "k-", lw=1.5, alpha=0.75, label="Total covariate effect")
|
||||
ax.axhline(0, color="black", lw=0.9, alpha=0.6)
|
||||
add_divider(ax, label_top=False)
|
||||
ax.set_xlabel("Week", fontsize=10)
|
||||
ax.set_ylabel("Effect on sales (units)", fontsize=10)
|
||||
ax.set_title(
|
||||
"Store A -- Covariate Effect Decomposition", fontsize=11, fontweight="bold"
|
||||
)
|
||||
ax.legend(fontsize=7.5, loc="upper right")
|
||||
ax.grid(True, alpha=0.22, axis="y")
|
||||
ax.annotate(
|
||||
f"Holidays (+200) and promotions (+150) dominate\n"
|
||||
f"Price effect (+/-{np.abs(pe).max():.0f} units) is minor by comparison\n"
|
||||
f"-> Time-varying covariates explain most sales spikes",
|
||||
xy=(0.97, 0.55),
|
||||
xycoords="axes fraction",
|
||||
ha="right",
|
||||
fontsize=8,
|
||||
bbox=dict(boxstyle="round", fc="#fffbe6", ec="#d4a017", alpha=0.95),
|
||||
)
|
||||
|
||||
tick_pos = list(range(0, TOTAL_LEN, 4))
|
||||
for row in [0, 1]:
|
||||
for col in [0, 1]:
|
||||
axes[row, col].set_xticks(tick_pos)
|
||||
|
||||
plt.tight_layout()
|
||||
output_path = OUTPUT_DIR / "covariates_data.png"
|
||||
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f"\n Saved visualization: {output_path}")
|
||||
|
||||
|
||||
def demonstrate_api() -> None:
|
||||
print("\n" + "=" * 70)
|
||||
print(" TIMESFM COVARIATES API (TimesFM 2.5)")
|
||||
print("=" * 70)
|
||||
print("""
|
||||
# Installation
|
||||
pip install timesfm[xreg]
|
||||
|
||||
import timesfm
|
||||
hparams = timesfm.TimesFmHparams(backend="cpu", per_core_batch_size=32, horizon_len=12)
|
||||
ckpt = timesfm.TimesFmCheckpoint(huggingface_repo_id="google/timesfm-2.5-200m-pytorch")
|
||||
model = timesfm.TimesFm(hparams=hparams, checkpoint=ckpt)
|
||||
|
||||
point_fc, quant_fc = model.forecast_with_covariates(
|
||||
inputs=[sales_a, sales_b, sales_c],
|
||||
dynamic_numerical_covariates={"price": [price_a, price_b, price_c]},
|
||||
dynamic_categorical_covariates={"holiday": [hol_a, hol_b, hol_c]},
|
||||
static_categorical_covariates={"store_type": ["premium","standard","discount"]},
|
||||
xreg_mode="xreg + timesfm",
|
||||
normalize_xreg_target_per_input=True,
|
||||
)
|
||||
# point_fc: (num_series, horizon_len)
|
||||
# quant_fc: (num_series, horizon_len, 10)
|
||||
""")
|
||||
|
||||
|
||||
def explain_xreg_modes() -> None:
|
||||
print("\n" + "=" * 70)
|
||||
print(" XREG MODES")
|
||||
print("=" * 70)
|
||||
print("""
|
||||
"xreg + timesfm" (DEFAULT)
|
||||
1. TimesFM makes baseline forecast
|
||||
2. Fit regression on residuals (actual - baseline) ~ covariates
|
||||
3. Final = TimesFM baseline + XReg adjustment
|
||||
Best when: covariates explain residual variation (e.g. promotions)
|
||||
|
||||
"timesfm + xreg"
|
||||
1. Fit regression: target ~ covariates
|
||||
2. TimesFM forecasts the residuals
|
||||
3. Final = XReg prediction + TimesFM residual forecast
|
||||
Best when: covariates explain the main signal (e.g. temperature)
|
||||
""")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 70)
|
||||
print(" TIMESFM COVARIATES (XREG) EXAMPLE")
|
||||
print("=" * 70)
|
||||
|
||||
print("\n Generating synthetic retail sales data...")
|
||||
data = generate_sales_data()
|
||||
|
||||
print(f" Stores: {list(data['stores'].keys())}")
|
||||
print(f" Context length: {CONTEXT_LEN} weeks")
|
||||
print(f" Horizon length: {HORIZON_LEN} weeks")
|
||||
print(f" Covariates: {list(data['covariates'].keys())}")
|
||||
|
||||
demonstrate_api()
|
||||
explain_xreg_modes()
|
||||
|
||||
print("\n Creating 2x2 visualization (shared x-axis)...")
|
||||
create_visualization(data)
|
||||
|
||||
print("\n Saving output data...")
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
records = []
|
||||
for store_id, store_data in data["stores"].items():
|
||||
for i in range(TOTAL_LEN):
|
||||
records.append(
|
||||
{
|
||||
"store_id": store_id,
|
||||
"week": i,
|
||||
"split": "context" if i < CONTEXT_LEN else "horizon",
|
||||
"sales": round(float(store_data["sales"][i]), 2),
|
||||
"base_sales": round(
|
||||
float(data["components"][store_id]["base"][i]), 2
|
||||
),
|
||||
"price": round(float(data["covariates"]["price"][store_id][i]), 4),
|
||||
"price_effect": round(
|
||||
float(data["components"][store_id]["price_effect"][i]), 2
|
||||
),
|
||||
"promotion": int(data["covariates"]["promotion"][store_id][i]),
|
||||
"holiday": int(data["covariates"]["holiday"][store_id][i]),
|
||||
"day_of_week": int(data["covariates"]["day_of_week"][store_id][i]),
|
||||
"store_type": data["covariates"]["store_type"][store_id],
|
||||
"region": data["covariates"]["region"][store_id],
|
||||
}
|
||||
)
|
||||
|
||||
df = pd.DataFrame(records)
|
||||
csv_path = OUTPUT_DIR / "sales_with_covariates.csv"
|
||||
df.to_csv(csv_path, index=False)
|
||||
print(f" Saved: {csv_path} ({len(df)} rows x {len(df.columns)} cols)")
|
||||
|
||||
metadata = {
|
||||
"description": "Synthetic retail sales data with covariates for TimesFM XReg demo",
|
||||
"note_on_real_data": (
|
||||
"For real datasets (e.g., Kaggle Rossmann Store Sales), download to "
|
||||
"tempfile.mkdtemp() -- do NOT commit to this repo."
|
||||
),
|
||||
"stores": {
|
||||
sid: {
|
||||
**sdata["config"],
|
||||
"mean_sales_context": round(
|
||||
float(sdata["sales"][:CONTEXT_LEN].mean()), 1
|
||||
),
|
||||
}
|
||||
for sid, sdata in data["stores"].items()
|
||||
},
|
||||
"dimensions": {
|
||||
"context_length": CONTEXT_LEN,
|
||||
"horizon_length": HORIZON_LEN,
|
||||
"total_length": TOTAL_LEN,
|
||||
"num_stores": N_STORES,
|
||||
"csv_rows": len(df),
|
||||
},
|
||||
"covariates": {
|
||||
"dynamic_numerical": ["price"],
|
||||
"dynamic_categorical": ["promotion", "holiday", "day_of_week"],
|
||||
"static_categorical": ["store_type", "region"],
|
||||
},
|
||||
"effect_magnitudes": {
|
||||
"holiday": "+200 units per holiday week",
|
||||
"promotion": "+150 units per promotion week",
|
||||
"price": "-20 units per $1 above base price",
|
||||
},
|
||||
"xreg_modes": {
|
||||
"xreg + timesfm": "Regression on TimesFM residuals (default)",
|
||||
"timesfm + xreg": "TimesFM on regression residuals",
|
||||
},
|
||||
"bug_fixes_history": [
|
||||
"v1: Variable-shadowing -- all stores had identical covariates",
|
||||
"v2: Fixed shadowing; CONTEXT_LEN 48->24",
|
||||
"v3: Added component decomposition (base, price/promo/holiday effects); 2x2 sharex viz",
|
||||
],
|
||||
}
|
||||
|
||||
meta_path = OUTPUT_DIR / "covariates_metadata.json"
|
||||
with open(meta_path, "w") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
print(f" Saved: {meta_path}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" COVARIATES EXAMPLE COMPLETE")
|
||||
print("=" * 70)
|
||||
print("""
|
||||
Key points:
|
||||
1. Requires timesfm[xreg] + TimesFM 2.5+ for actual inference
|
||||
2. Dynamic covariates need values for BOTH context AND horizon (future must be known!)
|
||||
3. Static covariates: one value per series (store_type, region)
|
||||
4. All 4 visualization panels share the same week x-axis (0-35)
|
||||
5. Effect decomposition shows holidays/promotions dominate over price variation
|
||||
|
||||
Output files:
|
||||
output/covariates_data.png -- 2x2 visualization with conclusions
|
||||
output/sales_with_covariates.csv -- 108-row compact dataset
|
||||
output/covariates_metadata.json -- metadata + effect magnitudes
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 448 KiB |
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"description": "Synthetic retail sales data with covariates for TimesFM XReg demo",
|
||||
"note_on_real_data": "For real datasets (e.g., Kaggle Rossmann Store Sales), download to tempfile.mkdtemp() -- do NOT commit to this repo.",
|
||||
"stores": {
|
||||
"store_A": {
|
||||
"type": "premium",
|
||||
"region": "urban",
|
||||
"base_sales": 1000,
|
||||
"mean_sales_context": 1148.7
|
||||
},
|
||||
"store_B": {
|
||||
"type": "standard",
|
||||
"region": "suburban",
|
||||
"base_sales": 750,
|
||||
"mean_sales_context": 907.0
|
||||
},
|
||||
"store_C": {
|
||||
"type": "discount",
|
||||
"region": "rural",
|
||||
"base_sales": 500,
|
||||
"mean_sales_context": 645.3
|
||||
}
|
||||
},
|
||||
"dimensions": {
|
||||
"context_length": 24,
|
||||
"horizon_length": 12,
|
||||
"total_length": 36,
|
||||
"num_stores": 3,
|
||||
"csv_rows": 108
|
||||
},
|
||||
"covariates": {
|
||||
"dynamic_numerical": [
|
||||
"price"
|
||||
],
|
||||
"dynamic_categorical": [
|
||||
"promotion",
|
||||
"holiday",
|
||||
"day_of_week"
|
||||
],
|
||||
"static_categorical": [
|
||||
"store_type",
|
||||
"region"
|
||||
]
|
||||
},
|
||||
"effect_magnitudes": {
|
||||
"holiday": "+200 units per holiday week",
|
||||
"promotion": "+150 units per promotion week",
|
||||
"price": "-20 units per $1 above base price"
|
||||
},
|
||||
"xreg_modes": {
|
||||
"xreg + timesfm": "Regression on TimesFM residuals (default)",
|
||||
"timesfm + xreg": "TimesFM on regression residuals"
|
||||
},
|
||||
"bug_fixes_history": [
|
||||
"v1: Variable-shadowing -- all stores had identical covariates",
|
||||
"v2: Fixed shadowing; CONTEXT_LEN 48->24",
|
||||
"v3: Added component decomposition (base, price/promo/holiday effects); 2x2 sharex viz"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
store_id,week,split,sales,base_sales,price,price_effect,promotion,holiday,day_of_week,store_type,region
|
||||
store_A,0,context,1369.59,1012.19,11.6299,7.4,1,1,0,premium,urban
|
||||
store_A,1,context,973.53,973.04,11.9757,0.49,0,0,1,premium,urban
|
||||
store_A,2,context,1064.63,1059.16,11.7269,5.46,0,0,2,premium,urban
|
||||
store_A,3,context,1077.59,1080.99,12.1698,-3.4,0,0,3,premium,urban
|
||||
store_A,4,context,980.39,979.14,11.9372,1.26,0,0,4,premium,urban
|
||||
store_A,5,context,1011.7,1018.36,12.3327,-6.65,0,0,5,premium,urban
|
||||
store_A,6,context,1084.16,1088.16,12.2003,-4.01,0,0,6,premium,urban
|
||||
store_A,7,context,1085.98,1082.23,11.8124,3.75,0,0,0,premium,urban
|
||||
store_A,8,context,1098.52,1105.17,12.3323,-6.65,0,0,1,premium,urban
|
||||
store_A,9,context,1075.62,1081.71,12.3048,-6.1,0,0,2,premium,urban
|
||||
store_A,10,context,1312.23,1159.98,11.8875,2.25,1,0,3,premium,urban
|
||||
store_A,11,context,1368.02,1163.79,11.7883,4.23,0,1,4,premium,urban
|
||||
store_A,12,context,1138.41,1142.06,12.1825,-3.65,0,0,5,premium,urban
|
||||
store_A,13,context,1197.29,1190.09,11.6398,7.2,0,0,6,premium,urban
|
||||
store_A,14,context,1174.12,1168.12,11.6999,6.0,0,0,0,premium,urban
|
||||
store_A,15,context,1128.16,1118.3,11.5074,9.85,0,0,1,premium,urban
|
||||
store_A,16,context,1163.81,1169.55,12.2869,-5.74,0,0,2,premium,urban
|
||||
store_A,17,context,1114.18,1117.48,12.1649,-3.3,0,0,3,premium,urban
|
||||
store_A,18,context,1186.87,1190.98,12.2052,-4.1,0,0,4,premium,urban
|
||||
store_A,19,context,1147.27,1152.88,12.2807,-5.61,0,0,5,premium,urban
|
||||
store_A,20,context,1146.48,1145.66,11.9589,0.82,0,0,6,premium,urban
|
||||
store_A,21,context,1121.83,1123.21,12.0687,-1.37,0,0,0,premium,urban
|
||||
store_A,22,context,1203.28,1196.08,11.6398,7.2,0,0,1,premium,urban
|
||||
store_A,23,context,1344.9,1137.19,11.6145,7.71,0,1,2,premium,urban
|
||||
store_A,24,horizon,1118.64,1122.01,12.1684,-3.37,0,0,3,premium,urban
|
||||
store_A,25,horizon,1121.14,1120.56,11.9711,0.58,0,0,4,premium,urban
|
||||
store_A,26,horizon,1149.99,1151.29,12.0652,-1.3,0,0,5,premium,urban
|
||||
store_A,27,horizon,1284.67,1139.97,12.265,-5.3,1,0,6,premium,urban
|
||||
store_A,28,horizon,1284.67,1137.36,12.1347,-2.69,1,0,0,premium,urban
|
||||
store_A,29,horizon,1132.79,1133.86,12.0536,-1.07,0,0,1,premium,urban
|
||||
store_A,30,horizon,1197.3,1198.49,12.0592,-1.18,0,0,2,premium,urban
|
||||
store_A,31,horizon,1247.22,1093.3,11.804,3.92,1,0,3,premium,urban
|
||||
store_A,32,horizon,1095.84,1086.46,11.5308,9.38,0,0,4,premium,urban
|
||||
store_A,33,horizon,1073.83,1072.57,11.9367,1.27,0,0,5,premium,urban
|
||||
store_A,34,horizon,1134.51,1128.8,11.7146,5.71,0,0,6,premium,urban
|
||||
store_A,35,horizon,1351.15,1149.32,11.9085,1.83,0,1,0,premium,urban
|
||||
store_B,0,context,1062.53,712.0,9.9735,0.53,1,1,0,standard,suburban
|
||||
store_B,1,context,904.49,749.83,9.767,4.66,1,0,1,standard,suburban
|
||||
store_B,2,context,813.63,810.26,9.8316,3.37,0,0,2,standard,suburban
|
||||
store_B,3,context,720.11,720.53,10.0207,-0.41,0,0,3,standard,suburban
|
||||
store_B,4,context,820.78,819.55,9.9389,1.22,0,0,4,standard,suburban
|
||||
store_B,5,context,833.27,823.7,9.5216,9.57,0,0,5,standard,suburban
|
||||
store_B,6,context,795.26,801.78,10.3263,-6.53,0,0,6,standard,suburban
|
||||
store_B,7,context,770.37,778.29,10.3962,-7.92,0,0,0,standard,suburban
|
||||
store_B,8,context,855.92,848.72,9.6402,7.2,0,0,1,standard,suburban
|
||||
store_B,9,context,832.33,833.41,10.054,-1.08,0,0,2,standard,suburban
|
||||
store_B,10,context,1029.44,871.61,9.6086,7.83,1,0,3,standard,suburban
|
||||
store_B,11,context,1066.35,869.8,10.1722,-3.44,0,1,4,standard,suburban
|
||||
store_B,12,context,942.86,938.49,9.7812,4.38,0,0,5,standard,suburban
|
||||
store_B,13,context,1015.99,869.18,10.1594,-3.19,1,0,6,standard,suburban
|
||||
store_B,14,context,836.44,840.98,10.227,-4.54,0,0,0,standard,suburban
|
||||
store_B,15,context,885.72,891.1,10.2686,-5.37,0,0,1,standard,suburban
|
||||
store_B,16,context,901.45,893.6,9.6077,7.85,0,0,2,standard,suburban
|
||||
store_B,17,context,1080.63,938.95,10.416,-8.32,1,0,3,standard,suburban
|
||||
store_B,18,context,922.14,916.74,9.7302,5.4,0,0,4,standard,suburban
|
||||
store_B,19,context,904.66,895.41,9.5374,9.25,0,0,5,standard,suburban
|
||||
store_B,20,context,935.48,936.58,10.0549,-1.1,0,0,6,standard,suburban
|
||||
store_B,21,context,979.23,826.64,9.8709,2.58,1,0,0,standard,suburban
|
||||
store_B,22,context,837.49,844.09,10.3298,-6.6,0,0,1,standard,suburban
|
||||
store_B,23,context,1021.39,827.56,10.3083,-6.17,0,1,2,standard,suburban
|
||||
store_B,24,horizon,847.21,843.55,9.8171,3.66,0,0,3,standard,suburban
|
||||
store_B,25,horizon,789.27,798.33,10.4529,-9.06,0,0,4,standard,suburban
|
||||
store_B,26,horizon,877.09,872.91,9.7909,4.18,0,0,5,standard,suburban
|
||||
store_B,27,horizon,832.42,832.72,10.0151,-0.3,0,0,6,standard,suburban
|
||||
store_B,28,horizon,781.9,777.02,9.756,4.88,0,0,0,standard,suburban
|
||||
store_B,29,horizon,781.04,789.76,10.436,-8.72,0,0,1,standard,suburban
|
||||
store_B,30,horizon,844.57,837.86,9.6646,6.71,0,0,2,standard,suburban
|
||||
store_B,31,horizon,863.43,854.33,9.5449,9.1,0,0,3,standard,suburban
|
||||
store_B,32,horizon,898.12,896.82,9.9351,1.3,0,0,4,standard,suburban
|
||||
store_B,33,horizon,1070.58,930.42,10.4924,-9.85,1,0,5,standard,suburban
|
||||
store_B,34,horizon,820.4,828.24,10.3917,-7.83,0,0,6,standard,suburban
|
||||
store_B,35,horizon,965.86,770.83,10.2486,-4.97,0,1,0,standard,suburban
|
||||
store_C,0,context,709.12,501.23,7.1053,7.89,0,1,0,discount,rural
|
||||
store_C,1,context,651.44,492.78,7.0666,8.67,1,0,1,discount,rural
|
||||
store_C,2,context,659.15,511.04,7.5944,-1.89,1,0,2,discount,rural
|
||||
store_C,3,context,733.06,575.98,7.1462,7.08,1,0,3,discount,rural
|
||||
store_C,4,context,712.21,568.7,7.8247,-6.49,1,0,4,discount,rural
|
||||
store_C,5,context,615.23,611.44,7.3103,3.79,0,0,5,discount,rural
|
||||
store_C,6,context,568.99,561.87,7.1439,7.12,0,0,6,discount,rural
|
||||
store_C,7,context,541.12,549.54,7.921,-8.42,0,0,0,discount,rural
|
||||
store_C,8,context,583.57,576.88,7.1655,6.69,0,0,1,discount,rural
|
||||
store_C,9,context,607.34,603.04,7.2847,4.31,0,0,2,discount,rural
|
||||
store_C,10,context,613.79,606.86,7.1536,6.93,0,0,3,discount,rural
|
||||
store_C,11,context,919.49,561.8,7.1155,7.69,1,1,4,discount,rural
|
||||
store_C,12,context,622.61,613.04,7.0211,9.58,0,0,5,discount,rural
|
||||
store_C,13,context,630.52,621.63,7.0554,8.89,0,0,6,discount,rural
|
||||
store_C,14,context,721.62,715.12,7.1746,6.51,0,0,0,discount,rural
|
||||
store_C,15,context,699.18,690.25,7.0534,8.93,0,0,1,discount,rural
|
||||
store_C,16,context,578.85,580.67,7.5911,-1.82,0,0,2,discount,rural
|
||||
store_C,17,context,598.23,601.84,7.6807,-3.61,0,0,3,discount,rural
|
||||
store_C,18,context,554.43,552.3,7.3936,2.13,0,0,4,discount,rural
|
||||
store_C,19,context,587.39,583.75,7.318,3.64,0,0,5,discount,rural
|
||||
store_C,20,context,615.58,615.67,7.5045,-0.09,0,0,6,discount,rural
|
||||
store_C,21,context,638.68,646.18,7.875,-7.5,0,0,0,discount,rural
|
||||
store_C,22,context,555.99,563.01,7.8511,-7.02,0,0,1,discount,rural
|
||||
store_C,23,context,768.83,559.7,7.0435,9.13,0,1,2,discount,rural
|
||||
store_C,24,horizon,499.62,493.25,7.1815,6.37,0,0,3,discount,rural
|
||||
store_C,25,horizon,570.9,565.64,7.2367,5.27,0,0,4,discount,rural
|
||||
store_C,26,horizon,677.52,522.5,7.2494,5.01,1,0,5,discount,rural
|
||||
store_C,27,horizon,685.25,536.68,7.5712,-1.42,1,0,6,discount,rural
|
||||
store_C,28,horizon,517.46,515.78,7.4163,1.67,0,0,0,discount,rural
|
||||
store_C,29,horizon,549.38,540.36,7.0493,9.01,0,0,1,discount,rural
|
||||
store_C,30,horizon,470.04,467.51,7.3736,2.53,0,0,2,discount,rural
|
||||
store_C,31,horizon,622.9,473.37,7.5238,-0.48,1,0,3,discount,rural
|
||||
store_C,32,horizon,620.09,612.12,7.1017,7.97,0,0,4,discount,rural
|
||||
store_C,33,horizon,614.45,471.12,7.8335,-6.67,1,0,5,discount,rural
|
||||
store_C,34,horizon,484.25,475.29,7.052,8.96,0,0,6,discount,rural
|
||||
store_C,35,horizon,781.64,590.14,7.9248,-8.5,0,1,0,discount,rural
|
||||
|
@@ -0,0 +1,178 @@
|
||||
# TimesFM Forecast Report: Global Temperature Anomaly (2025)
|
||||
|
||||
**Model:** TimesFM 1.0 (200M) PyTorch
|
||||
**Generated:** 2026-02-21
|
||||
**Source:** NOAA GISTEMP Global Land-Ocean Temperature Index
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
TimesFM forecasts a mean temperature anomaly of **1.19°C** for 2025, slightly below the 2024 average of 1.25°C. The model predicts continued elevated temperatures with a peak of 1.30°C in March 2025 and a minimum of 1.06°C in December 2025.
|
||||
|
||||
---
|
||||
|
||||
## Input Data
|
||||
|
||||
### Historical Temperature Anomalies (2022-2024)
|
||||
|
||||
| Date | Anomaly (°C) | Date | Anomaly (°C) | Date | Anomaly (°C) |
|
||||
|------|-------------|------|-------------|------|-------------|
|
||||
| 2022-01 | 0.89 | 2023-01 | 0.87 | 2024-01 | 1.22 |
|
||||
| 2022-02 | 0.89 | 2023-02 | 0.98 | 2024-02 | 1.35 |
|
||||
| 2022-03 | 1.02 | 2023-03 | 1.21 | 2024-03 | 1.34 |
|
||||
| 2022-04 | 0.88 | 2023-04 | 1.00 | 2024-04 | 1.26 |
|
||||
| 2022-05 | 0.85 | 2023-05 | 0.94 | 2024-05 | 1.15 |
|
||||
| 2022-06 | 0.88 | 2023-06 | 1.08 | 2024-06 | 1.20 |
|
||||
| 2022-07 | 0.88 | 2023-07 | 1.18 | 2024-07 | 1.24 |
|
||||
| 2022-08 | 0.90 | 2023-08 | 1.24 | 2024-08 | 1.30 |
|
||||
| 2022-09 | 0.88 | 2023-09 | 1.47 | 2024-09 | 1.28 |
|
||||
| 2022-10 | 0.95 | 2023-10 | 1.32 | 2024-10 | 1.27 |
|
||||
| 2022-11 | 0.77 | 2023-11 | 1.18 | 2024-11 | 1.22 |
|
||||
| 2022-12 | 0.78 | 2023-12 | 1.16 | 2024-12 | 1.20 |
|
||||
|
||||
**Statistics:**
|
||||
- Total observations: 36 months
|
||||
- Mean anomaly: 1.09°C
|
||||
- Trend (2022→2024): +0.37°C
|
||||
|
||||
---
|
||||
|
||||
## Raw Forecast Output
|
||||
|
||||
### Point Forecast and Confidence Intervals
|
||||
|
||||
| Month | Point | 80% CI | 90% CI |
|
||||
|-------|-------|--------|--------|
|
||||
| 2025-01 | 1.259 | [1.141, 1.297] | [1.248, 1.324] |
|
||||
| 2025-02 | 1.286 | [1.141, 1.340] | [1.277, 1.375] |
|
||||
| 2025-03 | 1.295 | [1.127, 1.355] | [1.287, 1.404] |
|
||||
| 2025-04 | 1.221 | [1.035, 1.290] | [1.208, 1.331] |
|
||||
| 2025-05 | 1.170 | [0.969, 1.239] | [1.153, 1.289] |
|
||||
| 2025-06 | 1.146 | [0.942, 1.218] | [1.128, 1.270] |
|
||||
| 2025-07 | 1.170 | [0.950, 1.248] | [1.151, 1.300] |
|
||||
| 2025-08 | 1.203 | [0.971, 1.284] | [1.186, 1.341] |
|
||||
| 2025-09 | 1.191 | [0.959, 1.283] | [1.178, 1.335] |
|
||||
| 2025-10 | 1.149 | [0.908, 1.240] | [1.126, 1.287] |
|
||||
| 2025-11 | 1.080 | [0.836, 1.176] | [1.062, 1.228] |
|
||||
| 2025-12 | 1.061 | [0.802, 1.153] | [1.037, 1.217] |
|
||||
|
||||
### JSON Output
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "TimesFM 1.0 (200M) PyTorch",
|
||||
"input": {
|
||||
"source": "NOAA GISTEMP Global Temperature Anomaly",
|
||||
"n_observations": 36,
|
||||
"date_range": "2022-01 to 2024-12",
|
||||
"mean_anomaly_c": 1.089
|
||||
},
|
||||
"forecast": {
|
||||
"horizon": 12,
|
||||
"dates": ["2025-01", "2025-02", "2025-03", "2025-04", "2025-05", "2025-06",
|
||||
"2025-07", "2025-08", "2025-09", "2025-10", "2025-11", "2025-12"],
|
||||
"point": [1.259, 1.286, 1.295, 1.221, 1.170, 1.146, 1.170, 1.203, 1.191, 1.149, 1.080, 1.061]
|
||||
},
|
||||
"summary": {
|
||||
"forecast_mean_c": 1.186,
|
||||
"forecast_max_c": 1.295,
|
||||
"forecast_min_c": 1.061,
|
||||
"vs_last_year_mean": -0.067
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visualization
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### Key Observations
|
||||
|
||||
1. **Slight cooling trend expected**: The model forecasts a mean anomaly 0.07°C below 2024 levels, suggesting a potential stabilization after the record-breaking temperatures of 2023-2024.
|
||||
|
||||
2. **Seasonal pattern preserved**: The forecast shows the expected seasonal variation with higher anomalies in late winter (Feb-Mar) and lower in late fall (Nov-Dec).
|
||||
|
||||
3. **Widening uncertainty**: The 90% CI expands from ±0.04°C in January to ±0.08°C in December, reflecting typical forecast uncertainty growth over time.
|
||||
|
||||
4. **Peak temperature**: March 2025 is predicted to have the highest anomaly at 1.30°C, potentially approaching the September 2023 record of 1.47°C.
|
||||
|
||||
### Limitations
|
||||
|
||||
- TimesFM is a zero-shot forecaster without physical climate model constraints
|
||||
- The 36-month training window may not capture multi-decadal climate trends
|
||||
- El Niño/La Niña cycles are not explicitly modeled
|
||||
|
||||
### Recommendations
|
||||
|
||||
- Use this forecast as a baseline comparison for physics-based climate models
|
||||
- Update forecast quarterly as new observations become available
|
||||
- Consider ensemble approaches combining TimesFM with other methods
|
||||
|
||||
---
|
||||
|
||||
## Reproducibility
|
||||
|
||||
### Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `temperature_anomaly.csv` | Input data (36 months) |
|
||||
| `forecast_output.csv` | Point forecast with quantiles |
|
||||
| `forecast_output.json` | Machine-readable forecast |
|
||||
| `forecast_visualization.png` | Fan chart visualization |
|
||||
| `run_forecast.py` | Forecasting script |
|
||||
| `visualize_forecast.py` | Visualization script |
|
||||
| `run_example.sh` | One-click runner |
|
||||
|
||||
### How to Reproduce
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv pip install "timesfm[torch]" matplotlib pandas numpy
|
||||
|
||||
# Run the complete example
|
||||
cd scientific-skills/timesfm-forecasting/examples/global-temperature
|
||||
./run_example.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### API Discovery
|
||||
|
||||
The TimesFM PyTorch API differs from the GitHub README documentation:
|
||||
|
||||
**Documented (GitHub README):**
|
||||
```python
|
||||
model = timesfm.TimesFm(
|
||||
context_len=512,
|
||||
horizon_len=128,
|
||||
backend="gpu",
|
||||
)
|
||||
model.load_from_google_repo("google/timesfm-2.5-200m-pytorch")
|
||||
```
|
||||
|
||||
**Actual Working API:**
|
||||
```python
|
||||
hparams = timesfm.TimesFmHparams(horizon_len=12)
|
||||
checkpoint = timesfm.TimesFmCheckpoint(
|
||||
huggingface_repo_id="google/timesfm-1.0-200m-pytorch"
|
||||
)
|
||||
model = timesfm.TimesFm(hparams=hparams, checkpoint=checkpoint)
|
||||
```
|
||||
|
||||
### TimesFM 2.5 PyTorch Issue
|
||||
|
||||
The `google/timesfm-2.5-200m-pytorch` checkpoint downloads as `model.safetensors`, but the TimesFM loader expects `torch_model.ckpt`. This causes a `FileNotFoundError` at model load time. Using TimesFM 1.0 PyTorch resolves this issue.
|
||||
|
||||
---
|
||||
|
||||
*Report generated by TimesFM Forecasting Skill (claude-scientific-skills)*
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate animation data for interactive forecast visualization.
|
||||
|
||||
This script runs TimesFM forecasts incrementally, starting with minimal data
|
||||
and adding one point at a time. Each forecast extends to the final date (2025-12).
|
||||
|
||||
Output: animation_data.json with all forecast steps
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import timesfm
|
||||
|
||||
# Configuration
|
||||
MIN_CONTEXT = 12 # Minimum points to start forecasting
|
||||
MAX_HORIZON = (
|
||||
36 # Max forecast length (when we have 12 points, forecast 36 months to 2025-12)
|
||||
)
|
||||
TOTAL_MONTHS = 48 # Total months from 2022-01 to 2025-12 (graph extent)
|
||||
INPUT_FILE = Path(__file__).parent / "temperature_anomaly.csv"
|
||||
OUTPUT_FILE = Path(__file__).parent / "output" / "animation_data.json"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 60)
|
||||
print(" TIMESFM ANIMATION DATA GENERATOR")
|
||||
print(" Dynamic horizon - forecasts always reach 2025-12")
|
||||
print("=" * 60)
|
||||
|
||||
# Load data
|
||||
df = pd.read_csv(INPUT_FILE, parse_dates=["date"])
|
||||
df = df.sort_values("date").reset_index(drop=True)
|
||||
|
||||
all_dates = df["date"].tolist()
|
||||
all_values = df["anomaly_c"].values.astype(np.float32)
|
||||
|
||||
print(f"\n📊 Total data: {len(all_values)} months")
|
||||
print(
|
||||
f" Date range: {all_dates[0].strftime('%Y-%m')} to {all_dates[-1].strftime('%Y-%m')}"
|
||||
)
|
||||
print(f" Animation steps: {len(all_values) - MIN_CONTEXT + 1}")
|
||||
|
||||
# Load TimesFM with max horizon (will truncate output for shorter forecasts)
|
||||
print(f"\n🤖 Loading TimesFM 1.0 (200M) PyTorch (horizon={MAX_HORIZON})...")
|
||||
hparams = timesfm.TimesFmHparams(horizon_len=MAX_HORIZON)
|
||||
checkpoint = timesfm.TimesFmCheckpoint(
|
||||
huggingface_repo_id="google/timesfm-1.0-200m-pytorch"
|
||||
)
|
||||
model = timesfm.TimesFm(hparams=hparams, checkpoint=checkpoint)
|
||||
|
||||
# Generate forecasts for each step
|
||||
animation_steps = []
|
||||
|
||||
for n_points in range(MIN_CONTEXT, len(all_values) + 1):
|
||||
step_num = n_points - MIN_CONTEXT + 1
|
||||
total_steps = len(all_values) - MIN_CONTEXT + 1
|
||||
|
||||
# Calculate dynamic horizon: forecast enough to reach 2025-12
|
||||
horizon = TOTAL_MONTHS - n_points
|
||||
|
||||
print(
|
||||
f"\n📈 Step {step_num}/{total_steps}: Using {n_points} points, forecasting {horizon} months..."
|
||||
)
|
||||
|
||||
# Get historical data up to this point
|
||||
historical_values = all_values[:n_points]
|
||||
historical_dates = all_dates[:n_points]
|
||||
|
||||
# Run forecast (model outputs MAX_HORIZON, we truncate to actual horizon)
|
||||
point, quantiles = model.forecast(
|
||||
[historical_values],
|
||||
freq=[0],
|
||||
)
|
||||
|
||||
# Truncate to actual horizon
|
||||
point = point[0][:horizon]
|
||||
quantiles = quantiles[0, :horizon, :]
|
||||
|
||||
# Determine forecast dates
|
||||
last_date = historical_dates[-1]
|
||||
forecast_dates = pd.date_range(
|
||||
start=last_date + pd.DateOffset(months=1),
|
||||
periods=horizon,
|
||||
freq="MS",
|
||||
)
|
||||
|
||||
# Store step data
|
||||
step_data = {
|
||||
"step": step_num,
|
||||
"n_points": n_points,
|
||||
"horizon": horizon,
|
||||
"last_historical_date": historical_dates[-1].strftime("%Y-%m"),
|
||||
"historical_dates": [d.strftime("%Y-%m") for d in historical_dates],
|
||||
"historical_values": historical_values.tolist(),
|
||||
"forecast_dates": [d.strftime("%Y-%m") for d in forecast_dates],
|
||||
"point_forecast": point.tolist(),
|
||||
"q10": quantiles[:, 0].tolist(),
|
||||
"q20": quantiles[:, 1].tolist(),
|
||||
"q80": quantiles[:, 7].tolist(),
|
||||
"q90": quantiles[:, 8].tolist(),
|
||||
}
|
||||
|
||||
animation_steps.append(step_data)
|
||||
|
||||
# Show summary
|
||||
print(f" Last date: {historical_dates[-1].strftime('%Y-%m')}")
|
||||
print(f" Forecast to: {forecast_dates[-1].strftime('%Y-%m')}")
|
||||
print(f" Forecast mean: {point.mean():.3f}°C")
|
||||
|
||||
# Create output
|
||||
output = {
|
||||
"metadata": {
|
||||
"model": "TimesFM 1.0 (200M) PyTorch",
|
||||
"total_steps": len(animation_steps),
|
||||
"min_context": MIN_CONTEXT,
|
||||
"max_horizon": MAX_HORIZON,
|
||||
"total_months": TOTAL_MONTHS,
|
||||
"data_source": "NOAA GISTEMP Global Temperature Anomaly",
|
||||
"full_date_range": f"{all_dates[0].strftime('%Y-%m')} to {all_dates[-1].strftime('%Y-%m')}",
|
||||
},
|
||||
"actual_data": {
|
||||
"dates": [d.strftime("%Y-%m") for d in all_dates],
|
||||
"values": all_values.tolist(),
|
||||
},
|
||||
"animation_steps": animation_steps,
|
||||
}
|
||||
|
||||
# Save
|
||||
with open(OUTPUT_FILE, "w") as f:
|
||||
json.dump(output, f, indent=2)
|
||||
|
||||
print(f"\n" + "=" * 60)
|
||||
print(" ✅ ANIMATION DATA COMPLETE")
|
||||
print("=" * 60)
|
||||
print(f"\n📁 Output: {OUTPUT_FILE}")
|
||||
print(f" Total steps: {len(animation_steps)}")
|
||||
print(f" Each forecast extends to 2025-12")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate animated GIF showing forecast evolution.
|
||||
|
||||
Creates a GIF animation showing how the TimesFM forecast changes
|
||||
as more historical data points are added. Shows the full actual data as a background layer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.dates as mdates
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from PIL import Image
|
||||
|
||||
# Configuration
|
||||
EXAMPLE_DIR = Path(__file__).parent
|
||||
DATA_FILE = EXAMPLE_DIR / "output" / "animation_data.json"
|
||||
OUTPUT_FILE = EXAMPLE_DIR / "output" / "forecast_animation.gif"
|
||||
DURATION_MS = 500 # Time per frame in milliseconds
|
||||
|
||||
|
||||
def create_frame(
|
||||
ax,
|
||||
step_data: dict,
|
||||
actual_data: dict,
|
||||
final_forecast: dict,
|
||||
total_steps: int,
|
||||
x_min,
|
||||
x_max,
|
||||
y_min,
|
||||
y_max,
|
||||
) -> None:
|
||||
"""Create a single frame of the animation with fixed axes."""
|
||||
ax.clear()
|
||||
|
||||
# Parse dates
|
||||
historical_dates = pd.to_datetime(step_data["historical_dates"])
|
||||
forecast_dates = pd.to_datetime(step_data["forecast_dates"])
|
||||
|
||||
# Get final forecast dates for full extent
|
||||
final_forecast_dates = pd.to_datetime(final_forecast["forecast_dates"])
|
||||
|
||||
# All actual dates for full background
|
||||
all_actual_dates = pd.to_datetime(actual_data["dates"])
|
||||
all_actual_values = np.array(actual_data["values"])
|
||||
|
||||
# ========== BACKGROUND LAYER: Full actual data (faded) ==========
|
||||
ax.plot(
|
||||
all_actual_dates,
|
||||
all_actual_values,
|
||||
color="#9ca3af",
|
||||
linewidth=1,
|
||||
marker="o",
|
||||
markersize=2,
|
||||
alpha=0.3,
|
||||
label="All observed data",
|
||||
zorder=1,
|
||||
)
|
||||
|
||||
# ========== BACKGROUND LAYER: Final forecast (faded) ==========
|
||||
ax.plot(
|
||||
final_forecast_dates,
|
||||
final_forecast["point_forecast"],
|
||||
color="#fca5a5",
|
||||
linewidth=1,
|
||||
linestyle="--",
|
||||
marker="s",
|
||||
markersize=2,
|
||||
alpha=0.3,
|
||||
label="Final forecast",
|
||||
zorder=2,
|
||||
)
|
||||
|
||||
# ========== FOREGROUND LAYER: Historical data used (bright) ==========
|
||||
ax.plot(
|
||||
historical_dates,
|
||||
step_data["historical_values"],
|
||||
color="#3b82f6",
|
||||
linewidth=2.5,
|
||||
marker="o",
|
||||
markersize=5,
|
||||
label="Data used",
|
||||
zorder=10,
|
||||
)
|
||||
|
||||
# ========== FOREGROUND LAYER: Current forecast (bright) ==========
|
||||
# 90% CI (outer)
|
||||
ax.fill_between(
|
||||
forecast_dates,
|
||||
step_data["q10"],
|
||||
step_data["q90"],
|
||||
alpha=0.15,
|
||||
color="#ef4444",
|
||||
zorder=5,
|
||||
)
|
||||
|
||||
# 80% CI (inner)
|
||||
ax.fill_between(
|
||||
forecast_dates,
|
||||
step_data["q20"],
|
||||
step_data["q80"],
|
||||
alpha=0.25,
|
||||
color="#ef4444",
|
||||
zorder=6,
|
||||
)
|
||||
|
||||
# Forecast line
|
||||
ax.plot(
|
||||
forecast_dates,
|
||||
step_data["point_forecast"],
|
||||
color="#ef4444",
|
||||
linewidth=2.5,
|
||||
marker="s",
|
||||
markersize=5,
|
||||
label="Forecast",
|
||||
zorder=7,
|
||||
)
|
||||
|
||||
# ========== Vertical line at forecast boundary ==========
|
||||
ax.axvline(
|
||||
x=historical_dates[-1],
|
||||
color="#6b7280",
|
||||
linestyle="--",
|
||||
linewidth=1.5,
|
||||
alpha=0.7,
|
||||
zorder=8,
|
||||
)
|
||||
|
||||
# ========== Formatting ==========
|
||||
ax.set_xlabel("Date", fontsize=11)
|
||||
ax.set_ylabel("Temperature Anomaly (°C)", fontsize=11)
|
||||
ax.set_title(
|
||||
f"TimesFM Forecast Evolution\n"
|
||||
f"Step {step_data['step']}/{total_steps}: {step_data['n_points']} points → "
|
||||
f"forecast from {step_data['last_historical_date']}",
|
||||
fontsize=13,
|
||||
fontweight="bold",
|
||||
)
|
||||
|
||||
ax.grid(True, alpha=0.3, zorder=0)
|
||||
ax.legend(loc="upper left", fontsize=8)
|
||||
|
||||
# FIXED AXES - same for all frames
|
||||
ax.set_xlim(x_min, x_max)
|
||||
ax.set_ylim(y_min, y_max)
|
||||
|
||||
# Format x-axis
|
||||
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
|
||||
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=4))
|
||||
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha="right")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 60)
|
||||
print(" GENERATING ANIMATED GIF")
|
||||
print("=" * 60)
|
||||
|
||||
# Load data
|
||||
with open(DATA_FILE) as f:
|
||||
data = json.load(f)
|
||||
|
||||
total_steps = len(data["animation_steps"])
|
||||
print(f"\n📊 Total frames: {total_steps}")
|
||||
|
||||
# Get the final forecast step for reference
|
||||
final_forecast = data["animation_steps"][-1]
|
||||
|
||||
# Calculate fixed axis extents from ALL data
|
||||
all_actual_dates = pd.to_datetime(data["actual_data"]["dates"])
|
||||
all_actual_values = np.array(data["actual_data"]["values"])
|
||||
|
||||
final_forecast_dates = pd.to_datetime(final_forecast["forecast_dates"])
|
||||
final_forecast_values = np.array(final_forecast["point_forecast"])
|
||||
|
||||
# X-axis: from first actual date to last forecast date
|
||||
x_min = all_actual_dates[0]
|
||||
x_max = final_forecast_dates[-1]
|
||||
|
||||
# Y-axis: min/max across all actual + all forecasts with CIs
|
||||
all_forecast_q10 = np.array(final_forecast["q10"])
|
||||
all_forecast_q90 = np.array(final_forecast["q90"])
|
||||
|
||||
all_values = np.concatenate([
|
||||
all_actual_values,
|
||||
final_forecast_values,
|
||||
all_forecast_q10,
|
||||
all_forecast_q90,
|
||||
])
|
||||
y_min = all_values.min() - 0.05
|
||||
y_max = all_values.max() + 0.05
|
||||
|
||||
print(f" X-axis: {x_min.strftime('%Y-%m')} to {x_max.strftime('%Y-%m')}")
|
||||
print(f" Y-axis: {y_min:.2f}°C to {y_max:.2f}°C")
|
||||
|
||||
# Create figure
|
||||
fig, ax = plt.subplots(figsize=(12, 6))
|
||||
|
||||
# Generate frames
|
||||
frames = []
|
||||
|
||||
for i, step in enumerate(data["animation_steps"]):
|
||||
print(f" Frame {i + 1}/{total_steps}...")
|
||||
|
||||
create_frame(
|
||||
ax,
|
||||
step,
|
||||
data["actual_data"],
|
||||
final_forecast,
|
||||
total_steps,
|
||||
x_min,
|
||||
x_max,
|
||||
y_min,
|
||||
y_max,
|
||||
)
|
||||
|
||||
# Save frame to buffer
|
||||
fig.canvas.draw()
|
||||
|
||||
# Convert to PIL Image
|
||||
buf = fig.canvas.buffer_rgba()
|
||||
width, height = fig.canvas.get_width_height()
|
||||
img = Image.frombytes("RGBA", (width, height), buf)
|
||||
frames.append(img.convert("RGB"))
|
||||
|
||||
plt.close()
|
||||
|
||||
# Save as GIF
|
||||
print(f"\n💾 Saving GIF: {OUTPUT_FILE}")
|
||||
frames[0].save(
|
||||
OUTPUT_FILE,
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
duration=DURATION_MS,
|
||||
loop=0, # Loop forever
|
||||
)
|
||||
|
||||
# Get file size
|
||||
size_kb = OUTPUT_FILE.stat().st_size / 1024
|
||||
print(f" File size: {size_kb:.1f} KB")
|
||||
print(f"\n✅ Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,544 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a self-contained HTML file with embedded animation data.
|
||||
|
||||
This creates a single HTML file that can be opened directly in any browser
|
||||
without needing a server or external JSON file (CORS-safe).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
EXAMPLE_DIR = Path(__file__).parent
|
||||
DATA_FILE = EXAMPLE_DIR / "output" / "animation_data.json"
|
||||
OUTPUT_FILE = EXAMPLE_DIR / "output" / "interactive_forecast.html"
|
||||
|
||||
|
||||
HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TimesFM Interactive Forecast Animation</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
color: #e0e0e0;
|
||||
padding: 20px;
|
||||
}}
|
||||
|
||||
.container {{ max-width: 1200px; margin: 0 auto; }}
|
||||
|
||||
header {{ text-align: center; margin-bottom: 30px; }}
|
||||
|
||||
h1 {{
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
background: linear-gradient(90deg, #60a5fa, #a78bfa);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}}
|
||||
|
||||
.subtitle {{ color: #9ca3af; font-size: 1.1rem; }}
|
||||
|
||||
.chart-container {{
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}}
|
||||
|
||||
#chart {{ width: 100% !important; height: 450px !important; }}
|
||||
|
||||
.controls {{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
}}
|
||||
|
||||
.slider-container {{ display: flex; flex-direction: column; gap: 10px; }}
|
||||
|
||||
.slider-label {{ display: flex; justify-content: space-between; align-items: center; }}
|
||||
.slider-label span {{ font-size: 0.9rem; color: #9ca3af; }}
|
||||
.slider-label .value {{ font-weight: 600; color: #60a5fa; font-size: 1.1rem; }}
|
||||
|
||||
input[type="range"] {{
|
||||
width: 100%; height: 8px; border-radius: 4px;
|
||||
background: #374151; outline: none; -webkit-appearance: none;
|
||||
}}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {{
|
||||
-webkit-appearance: none;
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #60a5fa, #a78bfa);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(96, 165, 250, 0.5);
|
||||
}}
|
||||
|
||||
.buttons {{ display: flex; gap: 10px; flex-wrap: wrap; }}
|
||||
|
||||
button {{
|
||||
flex: 1; min-width: 100px;
|
||||
padding: 12px 20px;
|
||||
border: none; border-radius: 8px;
|
||||
font-size: 1rem; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.2s ease;
|
||||
}}
|
||||
|
||||
.btn-primary {{
|
||||
background: linear-gradient(135deg, #60a5fa, #a78bfa);
|
||||
color: white;
|
||||
}}
|
||||
.btn-primary:hover {{ transform: translateY(-2px); box-shadow: 0 4px 15px rgba(96, 165, 250, 0.4); }}
|
||||
|
||||
.btn-secondary {{ background: #374151; color: #e0e0e0; }}
|
||||
.btn-secondary:hover {{ background: #4b5563; }}
|
||||
|
||||
.stats {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}}
|
||||
|
||||
.stat-card {{
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
}}
|
||||
.stat-card .label {{ font-size: 0.8rem; color: #9ca3af; margin-bottom: 5px; }}
|
||||
.stat-card .value {{ font-size: 1.3rem; font-weight: 600; color: #60a5fa; }}
|
||||
|
||||
.legend {{
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}}
|
||||
|
||||
.legend-item {{ display: flex; align-items: center; gap: 8px; font-size: 0.85rem; }}
|
||||
.legend-color {{ width: 16px; height: 16px; border-radius: 4px; }}
|
||||
|
||||
footer {{
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}}
|
||||
footer a {{ color: #60a5fa; text-decoration: none; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>TimesFM Forecast Evolution</h1>
|
||||
<p class="subtitle">Watch the forecast evolve as more data is added — forecasts extend to 2025-12</p>
|
||||
</header>
|
||||
|
||||
<div class="chart-container">
|
||||
<canvas id="chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="slider-container">
|
||||
<div class="slider-label">
|
||||
<span>Data Points Used</span>
|
||||
<span class="value" id="points-value">12 / 36</span>
|
||||
</div>
|
||||
<input type="range" id="slider" min="0" max="24" value="0" step="1">
|
||||
<div class="slider-label">
|
||||
<span>2022-01</span>
|
||||
<span id="date-end">Using data through 2022-12</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button class="btn-primary" id="play-btn">▶ Play</button>
|
||||
<button class="btn-secondary" id="reset-btn">↺ Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="label">Forecast Mean</div>
|
||||
<div class="value" id="stat-mean">0.86°C</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Forecast Horizon</div>
|
||||
<div class="value" id="stat-horizon">36 months</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Forecast Max</div>
|
||||
<div class="value" id="stat-max">--</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Forecast Min</div>
|
||||
<div class="value" id="stat-min">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #9ca3af;"></div>
|
||||
<span>All Observed Data</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #fca5a5;"></div>
|
||||
<span>Final Forecast (reference)</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #3b82f6;"></div>
|
||||
<span>Data Used</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #ef4444;"></div>
|
||||
<span>Current Forecast</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: rgba(239, 68, 68, 0.25);"></div>
|
||||
<span>80% CI</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>TimesFM 1.0 (200M) PyTorch • <a href="https://github.com/google-research/timesfm">Google Research</a></p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Embedded animation data (no external fetch needed)
|
||||
const animationData = {data_json};
|
||||
|
||||
let chart = null;
|
||||
let isPlaying = false;
|
||||
let playInterval = null;
|
||||
let currentStep = 0;
|
||||
|
||||
// Fixed axis extents
|
||||
let allDates = [];
|
||||
let yMin = 0.7;
|
||||
let yMax = 1.55;
|
||||
|
||||
function initChart() {{
|
||||
const ctx = document.getElementById('chart').getContext('2d');
|
||||
|
||||
// Calculate fixed extents
|
||||
const finalStep = animationData.animation_steps[animationData.animation_steps.length - 1];
|
||||
allDates = [
|
||||
...animationData.actual_data.dates,
|
||||
...finalStep.forecast_dates
|
||||
];
|
||||
|
||||
// Y extent from all values
|
||||
const allValues = [
|
||||
...animationData.actual_data.values,
|
||||
...finalStep.point_forecast,
|
||||
...finalStep.q10,
|
||||
...finalStep.q90
|
||||
];
|
||||
yMin = Math.min(...allValues) - 0.05;
|
||||
yMax = Math.max(...allValues) + 0.05;
|
||||
|
||||
chart = new Chart(ctx, {{
|
||||
type: 'line',
|
||||
data: {{
|
||||
labels: allDates,
|
||||
datasets: [
|
||||
{{
|
||||
label: 'All Observed',
|
||||
data: animationData.actual_data.values.map((v, i) => ({{x: animationData.actual_data.dates[i], y: v}})),
|
||||
borderColor: '#9ca3af',
|
||||
borderWidth: 1,
|
||||
pointRadius: 2,
|
||||
pointBackgroundColor: '#9ca3af',
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
order: 1,
|
||||
}},
|
||||
{{
|
||||
label: 'Final Forecast',
|
||||
data: [...Array(animationData.actual_data.dates.length).fill(null), ...finalStep.point_forecast],
|
||||
borderColor: '#fca5a5',
|
||||
borderWidth: 1,
|
||||
borderDash: [4, 4],
|
||||
pointRadius: 2,
|
||||
pointBackgroundColor: '#fca5a5',
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
order: 2,
|
||||
}},
|
||||
{{
|
||||
label: 'Data Used',
|
||||
data: [],
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#3b82f6',
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
order: 10,
|
||||
}},
|
||||
{{
|
||||
label: '90% CI Lower',
|
||||
data: [],
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||
fill: '+1',
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 5,
|
||||
}},
|
||||
{{
|
||||
label: '90% CI Upper',
|
||||
data: [],
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 5,
|
||||
}},
|
||||
{{
|
||||
label: '80% CI Lower',
|
||||
data: [],
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.2)',
|
||||
fill: '+1',
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 6,
|
||||
}},
|
||||
{{
|
||||
label: '80% CI Upper',
|
||||
data: [],
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.2)',
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 6,
|
||||
}},
|
||||
{{
|
||||
label: 'Forecast',
|
||||
data: [],
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#ef4444',
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
order: 7,
|
||||
}},
|
||||
]
|
||||
}},
|
||||
options: {{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {{ intersect: false, mode: 'index' }},
|
||||
plugins: {{
|
||||
legend: {{ display: false }},
|
||||
tooltip: {{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
padding: 12,
|
||||
}},
|
||||
}},
|
||||
scales: {{
|
||||
x: {{
|
||||
grid: {{ color: 'rgba(255, 255, 255, 0.05)' }},
|
||||
ticks: {{ color: '#9ca3af', maxRotation: 45, minRotation: 45 }},
|
||||
}},
|
||||
y: {{
|
||||
grid: {{ color: 'rgba(255, 255, 255, 0.05)' }},
|
||||
ticks: {{
|
||||
color: '#9ca3af',
|
||||
callback: v => v.toFixed(2) + '°C'
|
||||
}},
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
}},
|
||||
}},
|
||||
animation: {{ duration: 150 }},
|
||||
}},
|
||||
}});
|
||||
}}
|
||||
|
||||
function updateChart(stepIndex) {{
|
||||
if (!animationData || !chart) return;
|
||||
|
||||
const step = animationData.animation_steps[stepIndex];
|
||||
const finalStep = animationData.animation_steps[animationData.animation_steps.length - 1];
|
||||
const actual = animationData.actual_data;
|
||||
|
||||
// Build data arrays for each dataset
|
||||
const nHist = step.historical_dates.length;
|
||||
const nForecast = step.forecast_dates.length;
|
||||
const nActual = actual.dates.length;
|
||||
const nFinalForecast = finalStep.forecast_dates.length;
|
||||
const totalPoints = nActual + nFinalForecast;
|
||||
|
||||
// Dataset 0: All observed (always full)
|
||||
chart.data.datasets[0].data = actual.values.map((v, i) => ({{x: actual.dates[i], y: v}}));
|
||||
|
||||
// Dataset 1: Final forecast reference (always full)
|
||||
chart.data.datasets[1].data = [
|
||||
...Array(nActual).fill(null),
|
||||
...finalStep.point_forecast
|
||||
];
|
||||
|
||||
// Dataset 2: Data used (historical only)
|
||||
const dataUsed = [];
|
||||
for (let i = 0; i < totalPoints; i++) {{
|
||||
if (i < nHist) {{
|
||||
dataUsed.push(step.historical_values[i]);
|
||||
}} else {{
|
||||
dataUsed.push(null);
|
||||
}}
|
||||
}}
|
||||
chart.data.datasets[2].data = dataUsed;
|
||||
|
||||
// Datasets 3-6: CIs (forecast only)
|
||||
const forecastOffset = nActual;
|
||||
const q90Lower = [];
|
||||
const q90Upper = [];
|
||||
const q80Lower = [];
|
||||
const q80Upper = [];
|
||||
|
||||
for (let i = 0; i < totalPoints; i++) {{
|
||||
const forecastIdx = i - forecastOffset;
|
||||
if (forecastIdx >= 0 && forecastIdx < nForecast) {{
|
||||
q90Lower.push(step.q10[forecastIdx]);
|
||||
q90Upper.push(step.q90[forecastIdx]);
|
||||
q80Lower.push(step.q20[forecastIdx]);
|
||||
q80Upper.push(step.q80[forecastIdx]);
|
||||
}} else {{
|
||||
q90Lower.push(null);
|
||||
q90Upper.push(null);
|
||||
q80Lower.push(null);
|
||||
q80Upper.push(null);
|
||||
}}
|
||||
}}
|
||||
chart.data.datasets[3].data = q90Lower;
|
||||
chart.data.datasets[4].data = q90Upper;
|
||||
chart.data.datasets[5].data = q80Lower;
|
||||
chart.data.datasets[6].data = q80Upper;
|
||||
|
||||
// Dataset 7: Forecast line
|
||||
const forecastData = [];
|
||||
for (let i = 0; i < totalPoints; i++) {{
|
||||
const forecastIdx = i - forecastOffset;
|
||||
if (forecastIdx >= 0 && forecastIdx < nForecast) {{
|
||||
forecastData.push(step.point_forecast[forecastIdx]);
|
||||
}} else {{
|
||||
forecastData.push(null);
|
||||
}}
|
||||
}}
|
||||
chart.data.datasets[7].data = forecastData;
|
||||
|
||||
chart.update('none');
|
||||
|
||||
// Update UI
|
||||
document.getElementById('slider').value = stepIndex;
|
||||
document.getElementById('points-value').textContent = `${{step.n_points}} / 36`;
|
||||
document.getElementById('date-end').textContent = `Using data through ${{step.last_historical_date}}`;
|
||||
|
||||
// Stats
|
||||
const mean = (step.point_forecast.reduce((a, b) => a + b, 0) / step.point_forecast.length).toFixed(3);
|
||||
const max = Math.max(...step.point_forecast).toFixed(3);
|
||||
const min = Math.min(...step.point_forecast).toFixed(3);
|
||||
|
||||
document.getElementById('stat-mean').textContent = mean + '°C';
|
||||
document.getElementById('stat-horizon').textContent = step.horizon + ' months';
|
||||
document.getElementById('stat-max').textContent = max + '°C';
|
||||
document.getElementById('stat-min').textContent = min + '°C';
|
||||
|
||||
currentStep = stepIndex;
|
||||
}}
|
||||
|
||||
document.getElementById('slider').addEventListener('input', e => {{
|
||||
updateChart(parseInt(e.target.value));
|
||||
}});
|
||||
|
||||
document.getElementById('play-btn').addEventListener('click', () => {{
|
||||
const btn = document.getElementById('play-btn');
|
||||
if (isPlaying) {{
|
||||
clearInterval(playInterval);
|
||||
btn.textContent = '▶ Play';
|
||||
isPlaying = false;
|
||||
}} else {{
|
||||
btn.textContent = '⏸ Pause';
|
||||
isPlaying = true;
|
||||
if (currentStep >= animationData.animation_steps.length - 1) currentStep = 0;
|
||||
playInterval = setInterval(() => {{
|
||||
if (currentStep >= animationData.animation_steps.length - 1) {{
|
||||
clearInterval(playInterval);
|
||||
document.getElementById('play-btn').textContent = '▶ Play';
|
||||
isPlaying = false;
|
||||
}} else {{
|
||||
currentStep++;
|
||||
updateChart(currentStep);
|
||||
}}
|
||||
}}, 400);
|
||||
}}
|
||||
}});
|
||||
|
||||
document.getElementById('reset-btn').addEventListener('click', () => {{
|
||||
if (isPlaying) {{
|
||||
clearInterval(playInterval);
|
||||
document.getElementById('play-btn').textContent = '▶ Play';
|
||||
isPlaying = false;
|
||||
}}
|
||||
updateChart(0);
|
||||
}});
|
||||
|
||||
// Initialize on load
|
||||
initChart();
|
||||
updateChart(0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 60)
|
||||
print(" GENERATING SELF-CONTAINED HTML")
|
||||
print("=" * 60)
|
||||
|
||||
# Load animation data
|
||||
with open(DATA_FILE) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Generate HTML with embedded data
|
||||
html_content = HTML_TEMPLATE.format(data_json=json.dumps(data, indent=2))
|
||||
|
||||
# Write output
|
||||
with open(OUTPUT_FILE, "w") as f:
|
||||
f.write(html_content)
|
||||
|
||||
size_kb = OUTPUT_FILE.stat().st_size / 1024
|
||||
print(f"\n✅ Generated: {OUTPUT_FILE}")
|
||||
print(f" File size: {size_kb:.1f} KB")
|
||||
print(f" Fully self-contained — no external dependencies")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 776 KiB |
@@ -0,0 +1,13 @@
|
||||
date,point_forecast,q10,q20,q30,q40,q50,q60,q70,q80,q90,q99
|
||||
2025-01-01,1.2593384,1.248188,1.140702,1.1880752,1.2137158,1.2394564,1.2593384,1.2767732,1.297132,1.32396,1.367888
|
||||
2025-02-01,1.2856668,1.2773758,1.1406044,1.1960833,1.2322671,1.2593892,1.2856668,1.3110137,1.3400218,1.3751202,1.4253658
|
||||
2025-03-01,1.2950127,1.2869918,1.126852,1.1876173,1.234988,1.2675052,1.2950127,1.328448,1.354729,1.4035482,1.4642649
|
||||
2025-04-01,1.2207624,1.2084007,1.0352504,1.1041918,1.151865,1.1853008,1.2207624,1.256663,1.2898555,1.3310349,1.4016538
|
||||
2025-05-01,1.1702554,1.153313,0.9691495,1.0431063,1.0932612,1.1276176,1.1702554,1.201966,1.2390311,1.2891905,1.3632389
|
||||
2025-06-01,1.1455553,1.1275499,0.94203794,1.0110554,1.0658777,1.1061188,1.1455553,1.1806211,1.2180579,1.2702757,1.345366
|
||||
2025-07-01,1.1702348,1.1510556,0.9503718,1.0347577,1.0847733,1.1287677,1.1702348,1.2114835,1.2482276,1.2997853,1.3807325
|
||||
2025-08-01,1.2026825,1.1859496,0.9709255,1.0594383,1.1106675,1.1579902,1.2026825,1.2399211,1.2842004,1.3408126,1.419526
|
||||
2025-09-01,1.1909748,1.1784849,0.95943713,1.0403702,1.103606,1.1511956,1.1909748,1.2390201,1.2832941,1.3354731,1.416972
|
||||
2025-10-01,1.1490841,1.1264795,0.9079477,0.99529266,1.0548235,1.1052223,1.1490841,1.1897774,1.240414,1.2868769,1.3775467
|
||||
2025-11-01,1.0804785,1.0624356,0.8361266,0.9259792,0.9882403,1.0386353,1.0804785,1.1281581,1.1759715,1.228377,1.3122478
|
||||
2025-12-01,1.0613453,1.0366092,0.80220693,0.89521873,0.9593707,1.0152239,1.0613453,1.1032857,1.15315,1.216908,1.2959521
|
||||
|
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"model": "TimesFM 1.0 (200M) PyTorch",
|
||||
"input": {
|
||||
"source": "NOAA GISTEMP Global Temperature Anomaly",
|
||||
"n_observations": 36,
|
||||
"date_range": "2022-01 to 2024-12",
|
||||
"mean_anomaly_c": 1.09
|
||||
},
|
||||
"forecast": {
|
||||
"horizon": 12,
|
||||
"dates": [
|
||||
"2025-01",
|
||||
"2025-02",
|
||||
"2025-03",
|
||||
"2025-04",
|
||||
"2025-05",
|
||||
"2025-06",
|
||||
"2025-07",
|
||||
"2025-08",
|
||||
"2025-09",
|
||||
"2025-10",
|
||||
"2025-11",
|
||||
"2025-12"
|
||||
],
|
||||
"point": [
|
||||
1.25933837890625,
|
||||
1.285666823387146,
|
||||
1.2950127124786377,
|
||||
1.2207623720169067,
|
||||
1.170255422592163,
|
||||
1.1455552577972412,
|
||||
1.1702347993850708,
|
||||
1.2026824951171875,
|
||||
1.1909748315811157,
|
||||
1.1490840911865234,
|
||||
1.080478549003601,
|
||||
1.0613453388214111
|
||||
],
|
||||
"quantiles": {
|
||||
"10%": [
|
||||
1.2481880187988281,
|
||||
1.2773758172988892,
|
||||
1.286991834640503,
|
||||
1.2084007263183594,
|
||||
1.1533130407333374,
|
||||
1.1275498867034912,
|
||||
1.1510555744171143,
|
||||
1.1859495639801025,
|
||||
1.1784849166870117,
|
||||
1.1264795064926147,
|
||||
1.0624356269836426,
|
||||
1.036609172821045
|
||||
],
|
||||
"20%": [
|
||||
1.1407020092010498,
|
||||
1.1406043767929077,
|
||||
1.126852035522461,
|
||||
1.0352504253387451,
|
||||
0.9691494703292847,
|
||||
0.9420379400253296,
|
||||
0.9503718018531799,
|
||||
0.970925509929657,
|
||||
0.9594371318817139,
|
||||
0.9079477190971375,
|
||||
0.8361266255378723,
|
||||
0.8022069334983826
|
||||
],
|
||||
"30%": [
|
||||
1.1880751848220825,
|
||||
1.1960833072662354,
|
||||
1.187617301940918,
|
||||
1.104191780090332,
|
||||
1.0431063175201416,
|
||||
1.01105535030365,
|
||||
1.0347577333450317,
|
||||
1.0594383478164673,
|
||||
1.040370225906372,
|
||||
0.9952926635742188,
|
||||
0.9259791970252991,
|
||||
0.8952187299728394
|
||||
],
|
||||
"40%": [
|
||||
1.2137157917022705,
|
||||
1.232267141342163,
|
||||
1.2349879741668701,
|
||||
1.151865005493164,
|
||||
1.0932612419128418,
|
||||
1.0658776760101318,
|
||||
1.084773302078247,
|
||||
1.1106674671173096,
|
||||
1.1036059856414795,
|
||||
1.0548235177993774,
|
||||
0.9882403016090393,
|
||||
0.9593706727027893
|
||||
],
|
||||
"50%": [
|
||||
1.2394564151763916,
|
||||
1.2593891620635986,
|
||||
1.267505168914795,
|
||||
1.1853008270263672,
|
||||
1.127617597579956,
|
||||
1.1061187982559204,
|
||||
1.128767728805542,
|
||||
1.1579902172088623,
|
||||
1.1511956453323364,
|
||||
1.1052223443984985,
|
||||
1.03863525390625,
|
||||
1.0152238607406616
|
||||
],
|
||||
"60%": [
|
||||
1.25933837890625,
|
||||
1.285666823387146,
|
||||
1.2950127124786377,
|
||||
1.2207623720169067,
|
||||
1.170255422592163,
|
||||
1.1455552577972412,
|
||||
1.1702347993850708,
|
||||
1.2026824951171875,
|
||||
1.1909748315811157,
|
||||
1.1490840911865234,
|
||||
1.080478549003601,
|
||||
1.0613453388214111
|
||||
],
|
||||
"70%": [
|
||||
1.27677321434021,
|
||||
1.3110136985778809,
|
||||
1.3284480571746826,
|
||||
1.2566629648208618,
|
||||
1.2019660472869873,
|
||||
1.1806211471557617,
|
||||
1.2114834785461426,
|
||||
1.2399210929870605,
|
||||
1.2390201091766357,
|
||||
1.1897773742675781,
|
||||
1.1281580924987793,
|
||||
1.1032856702804565
|
||||
],
|
||||
"80%": [
|
||||
1.2971320152282715,
|
||||
1.3400218486785889,
|
||||
1.3547290563583374,
|
||||
1.2898554801940918,
|
||||
1.2390310764312744,
|
||||
1.2180578708648682,
|
||||
1.248227596282959,
|
||||
1.2842004299163818,
|
||||
1.2832940816879272,
|
||||
1.240414023399353,
|
||||
1.175971508026123,
|
||||
1.153149962425232
|
||||
],
|
||||
"90%": [
|
||||
1.3239599466323853,
|
||||
1.3751201629638672,
|
||||
1.403548240661621,
|
||||
1.3310348987579346,
|
||||
1.2891905307769775,
|
||||
1.2702757120132446,
|
||||
1.2997852563858032,
|
||||
1.3408125638961792,
|
||||
1.3354730606079102,
|
||||
1.286876916885376,
|
||||
1.2283769845962524,
|
||||
1.2169079780578613
|
||||
],
|
||||
"99%": [
|
||||
1.3678879737854004,
|
||||
1.4253658056259155,
|
||||
1.4642648696899414,
|
||||
1.40165376663208,
|
||||
1.3632389307022095,
|
||||
1.3453660011291504,
|
||||
1.380732536315918,
|
||||
1.4195259809494019,
|
||||
1.416972041130066,
|
||||
1.3775466680526733,
|
||||
1.3122477531433105,
|
||||
1.2959520816802979
|
||||
]
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"forecast_mean_c": 1.186,
|
||||
"forecast_max_c": 1.295,
|
||||
"forecast_min_c": 1.061,
|
||||
"vs_last_year_mean": -0.067
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
File diff suppressed because it is too large
Load Diff
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# run_example.sh - Run the TimesFM temperature anomaly forecasting example
|
||||
#
|
||||
# This script:
|
||||
# 1. Runs the preflight system check
|
||||
# 2. Runs the TimesFM forecast
|
||||
# 3. Generates the visualization
|
||||
#
|
||||
# Usage:
|
||||
# ./run_example.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Python 3.10+
|
||||
# - timesfm[torch] installed: uv pip install "timesfm[torch]"
|
||||
# - matplotlib, pandas, numpy
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILL_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
|
||||
echo "============================================================"
|
||||
echo " TimesFM Example: Global Temperature Anomaly Forecast"
|
||||
echo "============================================================"
|
||||
|
||||
# Step 1: Preflight check
|
||||
echo ""
|
||||
echo "🔍 Step 1: Running preflight system check..."
|
||||
python3 "$SKILL_ROOT/scripts/check_system.py" || {
|
||||
echo "❌ Preflight check failed. Please fix the issues above before continuing."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Step 2: Run forecast
|
||||
echo ""
|
||||
echo "📊 Step 2: Running TimesFM forecast..."
|
||||
cd "$SCRIPT_DIR"
|
||||
python3 run_forecast.py
|
||||
|
||||
# Step 3: Generate visualization
|
||||
echo ""
|
||||
echo "📈 Step 3: Generating visualization..."
|
||||
python3 visualize_forecast.py
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " ✅ Example complete!"
|
||||
echo "============================================================"
|
||||
echo ""
|
||||
echo "Output files:"
|
||||
echo " - $SCRIPT_DIR/output/forecast_output.csv"
|
||||
echo " - $SCRIPT_DIR/output/forecast_output.json"
|
||||
echo " - $SCRIPT_DIR/output/forecast_visualization.png"
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Run TimesFM forecast on global temperature anomaly data.
|
||||
Generates forecast output CSV and JSON for the example.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# Preflight check
|
||||
print("=" * 60)
|
||||
print(" TIMeSFM FORECAST - Global Temperature Anomaly Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Load data
|
||||
data_path = Path(__file__).parent / "temperature_anomaly.csv"
|
||||
df = pd.read_csv(data_path, parse_dates=["date"])
|
||||
df = df.sort_values("date").reset_index(drop=True)
|
||||
|
||||
print(f"\n📊 Input Data: {len(df)} months of temperature anomalies")
|
||||
print(
|
||||
f" Date range: {df['date'].min().strftime('%Y-%m')} to {df['date'].max().strftime('%Y-%m')}"
|
||||
)
|
||||
print(f" Mean anomaly: {df['anomaly_c'].mean():.2f}°C")
|
||||
print(
|
||||
f" Trend: {df['anomaly_c'].iloc[-12:].mean() - df['anomaly_c'].iloc[:12].mean():.2f}°C change (first to last year)"
|
||||
)
|
||||
|
||||
# Prepare input for TimesFM
|
||||
# TimesFM expects a list of 1D numpy arrays
|
||||
input_series = df["anomaly_c"].values.astype(np.float32)
|
||||
|
||||
# Load TimesFM 1.0 (PyTorch)
|
||||
# NOTE: TimesFM 2.5 PyTorch checkpoint has a file format issue at time of writing.
|
||||
# The model.safetensors file is not loadable via torch.load().
|
||||
# Using TimesFM 1.0 PyTorch which works correctly.
|
||||
print("\n🤖 Loading TimesFM 1.0 (200M) PyTorch...")
|
||||
import timesfm
|
||||
|
||||
hparams = timesfm.TimesFmHparams(horizon_len=12)
|
||||
checkpoint = timesfm.TimesFmCheckpoint(
|
||||
huggingface_repo_id="google/timesfm-1.0-200m-pytorch"
|
||||
)
|
||||
model = timesfm.TimesFm(hparams=hparams, checkpoint=checkpoint)
|
||||
|
||||
# Forecast
|
||||
print("\n📈 Running forecast (12 months ahead)...")
|
||||
forecast_input = [input_series]
|
||||
frequency_input = [0] # Monthly data
|
||||
|
||||
point_forecast, experimental_quantile_forecast = model.forecast(
|
||||
forecast_input,
|
||||
freq=frequency_input,
|
||||
)
|
||||
|
||||
print(f" Point forecast shape: {point_forecast.shape}")
|
||||
print(f" Quantile forecast shape: {experimental_quantile_forecast.shape}")
|
||||
|
||||
# Extract results
|
||||
point = point_forecast[0] # Shape: (horizon,)
|
||||
quantiles = experimental_quantile_forecast[0] # Shape: (horizon, num_quantiles)
|
||||
|
||||
# TimesFM quantiles: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.99]
|
||||
# Index mapping: 0=10%, 1=20%, ..., 4=50% (median), ..., 9=99%
|
||||
quantile_labels = ["10%", "20%", "30%", "40%", "50%", "60%", "70%", "80%", "90%", "99%"]
|
||||
|
||||
# Create forecast dates (2025 monthly)
|
||||
last_date = df["date"].max()
|
||||
forecast_dates = pd.date_range(
|
||||
start=last_date + pd.DateOffset(months=1), periods=12, freq="MS"
|
||||
)
|
||||
|
||||
# Build output DataFrame
|
||||
output_df = pd.DataFrame(
|
||||
{
|
||||
"date": forecast_dates.strftime("%Y-%m-%d"),
|
||||
"point_forecast": point,
|
||||
"q10": quantiles[:, 0],
|
||||
"q20": quantiles[:, 1],
|
||||
"q30": quantiles[:, 2],
|
||||
"q40": quantiles[:, 3],
|
||||
"q50": quantiles[:, 4], # Median
|
||||
"q60": quantiles[:, 5],
|
||||
"q70": quantiles[:, 6],
|
||||
"q80": quantiles[:, 7],
|
||||
"q90": quantiles[:, 8],
|
||||
"q99": quantiles[:, 9],
|
||||
}
|
||||
)
|
||||
|
||||
# Save outputs
|
||||
output_dir = Path(__file__).parent / "output"
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
output_df.to_csv(output_dir / "forecast_output.csv", index=False)
|
||||
|
||||
# JSON output for the report
|
||||
output_json = {
|
||||
"model": "TimesFM 1.0 (200M) PyTorch",
|
||||
"input": {
|
||||
"source": "NOAA GISTEMP Global Temperature Anomaly",
|
||||
"n_observations": len(df),
|
||||
"date_range": f"{df['date'].min().strftime('%Y-%m')} to {df['date'].max().strftime('%Y-%m')}",
|
||||
"mean_anomaly_c": round(df["anomaly_c"].mean(), 3),
|
||||
},
|
||||
"forecast": {
|
||||
"horizon": 12,
|
||||
"dates": forecast_dates.strftime("%Y-%m").tolist(),
|
||||
"point": point.tolist(),
|
||||
"quantiles": {
|
||||
label: quantiles[:, i].tolist() for i, label in enumerate(quantile_labels)
|
||||
},
|
||||
},
|
||||
"summary": {
|
||||
"forecast_mean_c": round(float(point.mean()), 3),
|
||||
"forecast_max_c": round(float(point.max()), 3),
|
||||
"forecast_min_c": round(float(point.min()), 3),
|
||||
"vs_last_year_mean": round(
|
||||
float(point.mean() - df["anomaly_c"].iloc[-12:].mean()), 3
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
with open(output_dir / "forecast_output.json", "w") as f:
|
||||
json.dump(output_json, f, indent=2)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "=" * 60)
|
||||
print(" FORECAST RESULTS")
|
||||
print("=" * 60)
|
||||
print(
|
||||
f"\n📅 Forecast period: {forecast_dates[0].strftime('%Y-%m')} to {forecast_dates[-1].strftime('%Y-%m')}"
|
||||
)
|
||||
print(f"\n🌡️ Temperature Anomaly Forecast (°C above 1951-1980 baseline):")
|
||||
print(f"\n {'Month':<10} {'Point':>8} {'80% CI':>15} {'90% CI':>15}")
|
||||
print(f" {'-' * 10} {'-' * 8} {'-' * 15} {'-' * 15}")
|
||||
for i, (date, pt, q10, q90, q05, q95) in enumerate(
|
||||
zip(
|
||||
forecast_dates.strftime("%Y-%m"),
|
||||
point,
|
||||
quantiles[:, 1], # 20%
|
||||
quantiles[:, 7], # 80%
|
||||
quantiles[:, 0], # 10%
|
||||
quantiles[:, 8], # 90%
|
||||
)
|
||||
):
|
||||
print(
|
||||
f" {date:<10} {pt:>8.3f} [{q10:>6.3f}, {q90:>6.3f}] [{q05:>6.3f}, {q95:>6.3f}]"
|
||||
)
|
||||
|
||||
print(f"\n📊 Summary Statistics:")
|
||||
print(f" Mean forecast: {point.mean():.3f}°C")
|
||||
print(
|
||||
f" Max forecast: {point.max():.3f}°C (Month: {forecast_dates[point.argmax()].strftime('%Y-%m')})"
|
||||
)
|
||||
print(
|
||||
f" Min forecast: {point.min():.3f}°C (Month: {forecast_dates[point.argmin()].strftime('%Y-%m')})"
|
||||
)
|
||||
print(f" vs 2024 mean: {point.mean() - df['anomaly_c'].iloc[-12:].mean():+.3f}°C")
|
||||
|
||||
print(f"\n✅ Output saved to:")
|
||||
print(f" {output_dir / 'forecast_output.csv'}")
|
||||
print(f" {output_dir / 'forecast_output.json'}")
|
||||
@@ -0,0 +1,37 @@
|
||||
date,anomaly_c
|
||||
2022-01-01,0.89
|
||||
2022-02-01,0.89
|
||||
2022-03-01,1.02
|
||||
2022-04-01,0.88
|
||||
2022-05-01,0.85
|
||||
2022-06-01,0.88
|
||||
2022-07-01,0.88
|
||||
2022-08-01,0.90
|
||||
2022-09-01,0.88
|
||||
2022-10-01,0.95
|
||||
2022-11-01,0.77
|
||||
2022-12-01,0.78
|
||||
2023-01-01,0.87
|
||||
2023-02-01,0.98
|
||||
2023-03-01,1.21
|
||||
2023-04-01,1.00
|
||||
2023-05-01,0.94
|
||||
2023-06-01,1.08
|
||||
2023-07-01,1.18
|
||||
2023-08-01,1.24
|
||||
2023-09-01,1.47
|
||||
2023-10-01,1.32
|
||||
2023-11-01,1.18
|
||||
2023-12-01,1.16
|
||||
2024-01-01,1.22
|
||||
2024-02-01,1.35
|
||||
2024-03-01,1.34
|
||||
2024-04-01,1.26
|
||||
2024-05-01,1.15
|
||||
2024-06-01,1.20
|
||||
2024-07-01,1.24
|
||||
2024-08-01,1.30
|
||||
2024-09-01,1.28
|
||||
2024-10-01,1.27
|
||||
2024-11-01,1.22
|
||||
2024-12-01,1.20
|
||||
|
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Visualize TimesFM forecast results for global temperature anomaly.
|
||||
|
||||
Generates a publication-quality figure showing:
|
||||
- Historical data (2022-2024)
|
||||
- Point forecast (2025)
|
||||
- 80% and 90% confidence intervals (fan chart)
|
||||
|
||||
Usage:
|
||||
python visualize_forecast.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# Configuration
|
||||
EXAMPLE_DIR = Path(__file__).parent
|
||||
INPUT_FILE = EXAMPLE_DIR / "temperature_anomaly.csv"
|
||||
FORECAST_FILE = EXAMPLE_DIR / "output" / "forecast_output.json"
|
||||
OUTPUT_FILE = EXAMPLE_DIR / "output" / "forecast_visualization.png"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Load historical data
|
||||
df = pd.read_csv(INPUT_FILE, parse_dates=["date"])
|
||||
|
||||
# Load forecast results
|
||||
with open(FORECAST_FILE) as f:
|
||||
forecast = json.load(f)
|
||||
|
||||
# Extract forecast data
|
||||
dates = pd.to_datetime(forecast["forecast"]["dates"])
|
||||
point = np.array(forecast["forecast"]["point"])
|
||||
q10 = np.array(forecast["forecast"]["quantiles"]["10%"])
|
||||
q20 = np.array(forecast["forecast"]["quantiles"]["20%"])
|
||||
q80 = np.array(forecast["forecast"]["quantiles"]["80%"])
|
||||
q90 = np.array(forecast["forecast"]["quantiles"]["90%"])
|
||||
|
||||
# Create figure
|
||||
fig, ax = plt.subplots(figsize=(12, 6))
|
||||
|
||||
# Plot historical data
|
||||
ax.plot(
|
||||
df["date"],
|
||||
df["anomaly_c"],
|
||||
color="#2563eb",
|
||||
linewidth=1.5,
|
||||
marker="o",
|
||||
markersize=3,
|
||||
label="Historical (NOAA GISTEMP)",
|
||||
)
|
||||
|
||||
# Plot 90% CI (outer band)
|
||||
ax.fill_between(dates, q10, q90, alpha=0.2, color="#dc2626", label="90% CI")
|
||||
|
||||
# Plot 80% CI (inner band)
|
||||
ax.fill_between(dates, q20, q80, alpha=0.3, color="#dc2626", label="80% CI")
|
||||
|
||||
# Plot point forecast
|
||||
ax.plot(
|
||||
dates,
|
||||
point,
|
||||
color="#dc2626",
|
||||
linewidth=2,
|
||||
marker="s",
|
||||
markersize=4,
|
||||
label="TimesFM Forecast",
|
||||
)
|
||||
|
||||
# Add vertical line at forecast boundary
|
||||
ax.axvline(
|
||||
x=df["date"].max(), color="#6b7280", linestyle="--", linewidth=1, alpha=0.7
|
||||
)
|
||||
|
||||
# Formatting
|
||||
ax.set_xlabel("Date", fontsize=12)
|
||||
ax.set_ylabel("Temperature Anomaly (°C)", fontsize=12)
|
||||
ax.set_title(
|
||||
"TimesFM Zero-Shot Forecast Example\n36-month Temperature Anomaly → 12-month Forecast",
|
||||
fontsize=14,
|
||||
fontweight="bold",
|
||||
)
|
||||
|
||||
# Add annotations
|
||||
ax.annotate(
|
||||
f"Mean forecast: {forecast['summary']['forecast_mean_c']:.2f}°C\n"
|
||||
f"vs 2024: {forecast['summary']['vs_last_year_mean']:+.2f}°C",
|
||||
xy=(dates[6], point[6]),
|
||||
xytext=(dates[6], point[6] + 0.15),
|
||||
fontsize=10,
|
||||
arrowprops=dict(arrowstyle="->", color="#6b7280", lw=1),
|
||||
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", edgecolor="#6b7280"),
|
||||
)
|
||||
|
||||
# Grid and legend
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend(loc="upper left", fontsize=10)
|
||||
|
||||
# Set y-axis limits
|
||||
ax.set_ylim(0.7, 1.5)
|
||||
|
||||
# Rotate x-axis labels
|
||||
plt.xticks(rotation=45, ha="right")
|
||||
|
||||
# Tight layout
|
||||
plt.tight_layout()
|
||||
|
||||
# Save
|
||||
fig.savefig(OUTPUT_FILE, dpi=150, bbox_inches="tight")
|
||||
print(f"✅ Saved visualization to: {OUTPUT_FILE}")
|
||||
|
||||
plt.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,231 @@
|
||||
# TimesFM API Reference
|
||||
|
||||
## Model Classes
|
||||
|
||||
### `timesfm.TimesFM_2p5_200M_torch`
|
||||
|
||||
The primary model class for TimesFM 2.5 (200M parameters, PyTorch backend).
|
||||
|
||||
#### `from_pretrained()`
|
||||
|
||||
```python
|
||||
model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
|
||||
"google/timesfm-2.5-200m-pytorch",
|
||||
cache_dir=None, # Optional: custom cache directory
|
||||
force_download=True, # Re-download even if cached
|
||||
)
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| --------- | ---- | ------- | ----------- |
|
||||
| `model_id` | str | `"google/timesfm-2.5-200m-pytorch"` | Hugging Face model ID |
|
||||
| `revision` | str \| None | None | Specific model revision |
|
||||
| `cache_dir` | str \| Path \| None | None | Custom cache directory |
|
||||
| `force_download` | bool | True | Force re-download of weights |
|
||||
|
||||
**Returns**: Initialized `TimesFM_2p5_200M_torch` instance (not yet compiled).
|
||||
|
||||
#### `compile()`
|
||||
|
||||
Compiles the model with the given forecast configuration. **Must be called before `forecast()`.**
|
||||
|
||||
```python
|
||||
model.compile(
|
||||
timesfm.ForecastConfig(
|
||||
max_context=1024,
|
||||
max_horizon=256,
|
||||
normalize_inputs=True,
|
||||
per_core_batch_size=32,
|
||||
use_continuous_quantile_head=True,
|
||||
force_flip_invariance=True,
|
||||
infer_is_positive=True,
|
||||
fix_quantile_crossing=True,
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Raises**: Nothing (but `forecast()` will raise `RuntimeError` if not compiled).
|
||||
|
||||
#### `forecast()`
|
||||
|
||||
Run inference on one or more time series.
|
||||
|
||||
```python
|
||||
point_forecast, quantile_forecast = model.forecast(
|
||||
horizon=24,
|
||||
inputs=[array1, array2, ...],
|
||||
)
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `horizon` | int | Number of future steps to forecast |
|
||||
| `inputs` | list[np.ndarray] | List of 1-D numpy arrays (each is a time series) |
|
||||
|
||||
**Returns**: `tuple[np.ndarray, np.ndarray]`
|
||||
|
||||
- `point_forecast`: shape `(batch_size, horizon)` — median (0.5 quantile)
|
||||
- `quantile_forecast`: shape `(batch_size, horizon, 10)` — [mean, q10, q20, ..., q90]
|
||||
|
||||
**Raises**: `RuntimeError` if model is not compiled.
|
||||
|
||||
**Key behaviors**:
|
||||
|
||||
- Leading NaN values are stripped automatically
|
||||
- Internal NaN values are linearly interpolated
|
||||
- Series longer than `max_context` are truncated (last `max_context` points used)
|
||||
- Series shorter than `max_context` are padded
|
||||
|
||||
#### `forecast_with_covariates()`
|
||||
|
||||
Run inference with exogenous variables (requires `timesfm[xreg]`).
|
||||
|
||||
```python
|
||||
point, quantiles = model.forecast_with_covariates(
|
||||
inputs=inputs,
|
||||
dynamic_numerical_covariates={"temp": [temp_array1, temp_array2]},
|
||||
dynamic_categorical_covariates={"dow": [dow_array1, dow_array2]},
|
||||
static_categorical_covariates={"region": ["east", "west"]},
|
||||
xreg_mode="xreg + timesfm",
|
||||
)
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `inputs` | list[np.ndarray] | Target time series |
|
||||
| `dynamic_numerical_covariates` | dict[str, list[np.ndarray]] | Time-varying numeric features |
|
||||
| `dynamic_categorical_covariates` | dict[str, list[np.ndarray]] | Time-varying categorical features |
|
||||
| `static_categorical_covariates` | dict[str, list[str]] | Fixed categorical features per series |
|
||||
| `xreg_mode` | str | `"xreg + timesfm"` or `"timesfm + xreg"` |
|
||||
|
||||
**Note**: Dynamic covariates must have length `context + horizon` for each series.
|
||||
|
||||
---
|
||||
|
||||
## `timesfm.ForecastConfig`
|
||||
|
||||
Immutable dataclass controlling all forecast behavior.
|
||||
|
||||
```python
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ForecastConfig:
|
||||
max_context: int = 0
|
||||
max_horizon: int = 0
|
||||
normalize_inputs: bool = False
|
||||
per_core_batch_size: int = 1
|
||||
use_continuous_quantile_head: bool = False
|
||||
force_flip_invariance: bool = True
|
||||
infer_is_positive: bool = True
|
||||
fix_quantile_crossing: bool = False
|
||||
return_backcast: bool = False
|
||||
quantiles: list[float] = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
|
||||
decode_index: int = 5
|
||||
```
|
||||
|
||||
### Parameter Details
|
||||
|
||||
#### `max_context` (int, default=0)
|
||||
|
||||
Maximum number of historical time points to use as context.
|
||||
|
||||
- **0**: Use the model's maximum supported context (16,384 for v2.5)
|
||||
- **N**: Truncate series to last N points
|
||||
- **Best practice**: Set to the length of your longest series, or 512–2048 for speed
|
||||
|
||||
#### `max_horizon` (int, default=0)
|
||||
|
||||
Maximum forecast horizon.
|
||||
|
||||
- **0**: Use the model's maximum
|
||||
- **N**: Forecasts up to N steps (can still call `forecast(horizon=M)` where M ≤ N)
|
||||
- **Best practice**: Set to your expected maximum forecast length
|
||||
|
||||
#### `normalize_inputs` (bool, default=False)
|
||||
|
||||
Whether to z-normalize each series before feeding to the model.
|
||||
|
||||
- **True** (RECOMMENDED): Normalizes each series to zero mean, unit variance
|
||||
- **False**: Raw values are passed directly
|
||||
- **When False is OK**: Only if your series are already normalized or very close to scale 1.0
|
||||
|
||||
#### `per_core_batch_size` (int, default=1)
|
||||
|
||||
Number of series processed per device in each batch.
|
||||
|
||||
- Increase for throughput, decrease if OOM
|
||||
- See `references/system_requirements.md` for recommended values by hardware
|
||||
|
||||
#### `use_continuous_quantile_head` (bool, default=False)
|
||||
|
||||
Use the 30M-parameter continuous quantile head for better interval calibration.
|
||||
|
||||
- **True** (RECOMMENDED): More accurate prediction intervals, especially for longer horizons
|
||||
- **False**: Uses fixed quantile buckets (faster but less accurate intervals)
|
||||
|
||||
#### `force_flip_invariance` (bool, default=True)
|
||||
|
||||
Ensures the model satisfies `f(-x) = -f(x)`.
|
||||
|
||||
- **True** (RECOMMENDED): Mathematical consistency — forecasts are invariant to sign flip
|
||||
- **False**: Slightly faster but may produce asymmetric forecasts
|
||||
|
||||
#### `infer_is_positive` (bool, default=True)
|
||||
|
||||
Automatically detect if all input values are positive and clamp forecasts ≥ 0.
|
||||
|
||||
- **True**: Safe for sales, demand, counts, prices, volumes
|
||||
- **False**: Required for temperature, returns, PnL, any series that can be negative
|
||||
|
||||
#### `fix_quantile_crossing` (bool, default=False)
|
||||
|
||||
Post-process quantiles to ensure monotonicity (q10 ≤ q20 ≤ ... ≤ q90).
|
||||
|
||||
- **True** (RECOMMENDED): Guarantees well-ordered quantiles
|
||||
- **False**: Slightly faster but quantiles may occasionally cross
|
||||
|
||||
#### `return_backcast` (bool, default=False)
|
||||
|
||||
Return the model's reconstruction of the input (backcast) in addition to forecast.
|
||||
|
||||
- **True**: Used for covariate workflows and diagnostics
|
||||
- **False**: Only return forecast
|
||||
|
||||
---
|
||||
|
||||
## Available Model Checkpoints
|
||||
|
||||
| Model ID | Version | Params | Backend | Context |
|
||||
| -------- | ------- | ------ | ------- | ------- |
|
||||
| `google/timesfm-2.5-200m-pytorch` | 2.5 | 200M | PyTorch | 16,384 |
|
||||
| `google/timesfm-2.5-200m-flax` | 2.5 | 200M | JAX/Flax | 16,384 |
|
||||
| `google/timesfm-2.5-200m-transformers` | 2.5 | 200M | Transformers | 16,384 |
|
||||
| `google/timesfm-2.0-500m-pytorch` | 2.0 | 500M | PyTorch | 2,048 |
|
||||
| `google/timesfm-2.0-500m-jax` | 2.0 | 500M | JAX | 2,048 |
|
||||
| `google/timesfm-1.0-200m-pytorch` | 1.0 | 200M | PyTorch | 2,048 |
|
||||
| `google/timesfm-1.0-200m` | 1.0 | 200M | JAX | 2,048 |
|
||||
|
||||
---
|
||||
|
||||
## Output Shape Reference
|
||||
|
||||
| Output | Shape | Description |
|
||||
| ------ | ----- | ----------- |
|
||||
| `point_forecast` | `(B, H)` | Median forecast for B series, H steps |
|
||||
| `quantile_forecast` | `(B, H, 10)` | Full quantile distribution |
|
||||
| `quantile_forecast[:,:,0]` | `(B, H)` | Mean |
|
||||
| `quantile_forecast[:,:,1]` | `(B, H)` | 10th percentile |
|
||||
| `quantile_forecast[:,:,5]` | `(B, H)` | 50th percentile (= point_forecast) |
|
||||
| `quantile_forecast[:,:,9]` | `(B, H)` | 90th percentile |
|
||||
|
||||
Where `B` = batch size (number of input series), `H` = forecast horizon.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Cause | Fix |
|
||||
| ----- | ----- | --- |
|
||||
| `RuntimeError: Model is not compiled` | Called `forecast()` before `compile()` | Call `model.compile(ForecastConfig(...))` first |
|
||||
| `torch.cuda.OutOfMemoryError` | Batch too large for GPU | Reduce `per_core_batch_size` |
|
||||
| `ValueError: inputs must be list` | Passed array instead of list | Wrap in list: `[array]` |
|
||||
| `HfHubHTTPError` | Download failed | Check internet, set `HF_HOME` to writable dir |
|
||||
@@ -0,0 +1,272 @@
|
||||
# Data Preparation for TimesFM
|
||||
|
||||
## Input Format
|
||||
|
||||
TimesFM accepts a **list of 1-D numpy arrays**. Each array represents one
|
||||
univariate time series.
|
||||
|
||||
```python
|
||||
inputs = [
|
||||
np.array([1.0, 2.0, 3.0, 4.0, 5.0]), # Series 1
|
||||
np.array([10.0, 20.0, 15.0, 25.0]), # Series 2 (different length)
|
||||
np.array([100.0, 110.0, 105.0, 115.0, 120.0, 130.0]), # Series 3
|
||||
]
|
||||
```
|
||||
|
||||
### Key Properties
|
||||
|
||||
- **Variable lengths**: Series in the same batch can have different lengths
|
||||
- **Float values**: Use `np.float32` or `np.float64`
|
||||
- **1-D only**: Each array must be 1-dimensional (not 2-D matrix rows)
|
||||
- **NaN handling**: Leading NaNs are stripped; internal NaNs are linearly interpolated
|
||||
|
||||
## Loading from Common Formats
|
||||
|
||||
### CSV — Single Series (Long Format)
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
df = pd.read_csv("data.csv", parse_dates=["date"])
|
||||
values = df["value"].values.astype(np.float32)
|
||||
inputs = [values]
|
||||
```
|
||||
|
||||
### CSV — Multiple Series (Wide Format)
|
||||
|
||||
```python
|
||||
df = pd.read_csv("data.csv", parse_dates=["date"], index_col="date")
|
||||
inputs = [df[col].dropna().values.astype(np.float32) for col in df.columns]
|
||||
```
|
||||
|
||||
### CSV — Long Format with ID Column
|
||||
|
||||
```python
|
||||
df = pd.read_csv("data.csv", parse_dates=["date"])
|
||||
inputs = []
|
||||
for series_id, group in df.groupby("series_id"):
|
||||
values = group.sort_values("date")["value"].values.astype(np.float32)
|
||||
inputs.append(values)
|
||||
```
|
||||
|
||||
### Pandas DataFrame
|
||||
|
||||
```python
|
||||
# Single column
|
||||
inputs = [df["temperature"].values.astype(np.float32)]
|
||||
|
||||
# Multiple columns
|
||||
inputs = [df[col].dropna().values.astype(np.float32) for col in numeric_cols]
|
||||
```
|
||||
|
||||
### Numpy Arrays
|
||||
|
||||
```python
|
||||
# 2-D array (rows = series, cols = time steps)
|
||||
data = np.load("timeseries.npy") # shape (N, T)
|
||||
inputs = [data[i] for i in range(data.shape[0])]
|
||||
|
||||
# Or from 1-D
|
||||
inputs = [np.sin(np.linspace(0, 10, 200))]
|
||||
```
|
||||
|
||||
### Excel
|
||||
|
||||
```python
|
||||
df = pd.read_excel("data.xlsx", sheet_name="Sheet1")
|
||||
inputs = [df[col].dropna().values.astype(np.float32) for col in df.select_dtypes(include=[np.number]).columns]
|
||||
```
|
||||
|
||||
### Parquet
|
||||
|
||||
```python
|
||||
df = pd.read_parquet("data.parquet")
|
||||
inputs = [df[col].dropna().values.astype(np.float32) for col in df.select_dtypes(include=[np.number]).columns]
|
||||
```
|
||||
|
||||
### JSON
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
with open("data.json") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Assumes {"series_name": [values...], ...}
|
||||
inputs = [np.array(values, dtype=np.float32) for values in data.values()]
|
||||
```
|
||||
|
||||
## NaN Handling
|
||||
|
||||
TimesFM handles NaN values automatically:
|
||||
|
||||
### Leading NaNs
|
||||
|
||||
Stripped before feeding to the model:
|
||||
|
||||
```python
|
||||
# Input: [NaN, NaN, 1.0, 2.0, 3.0]
|
||||
# Actual: [1.0, 2.0, 3.0]
|
||||
```
|
||||
|
||||
### Internal NaNs
|
||||
|
||||
Linearly interpolated:
|
||||
|
||||
```python
|
||||
# Input: [1.0, NaN, 3.0, NaN, NaN, 6.0]
|
||||
# Actual: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
|
||||
```
|
||||
|
||||
### Trailing NaNs
|
||||
|
||||
**Not handled** — drop them before passing to the model:
|
||||
|
||||
```python
|
||||
values = df["value"].values.astype(np.float32)
|
||||
# Remove trailing NaNs
|
||||
while len(values) > 0 and np.isnan(values[-1]):
|
||||
values = values[:-1]
|
||||
inputs = [values]
|
||||
```
|
||||
|
||||
### Best Practice
|
||||
|
||||
```python
|
||||
def clean_series(arr: np.ndarray) -> np.ndarray:
|
||||
"""Clean a time series for TimesFM input."""
|
||||
arr = np.asarray(arr, dtype=np.float32)
|
||||
# Remove trailing NaNs
|
||||
while len(arr) > 0 and np.isnan(arr[-1]):
|
||||
arr = arr[:-1]
|
||||
# Replace inf with NaN (will be interpolated)
|
||||
arr[np.isinf(arr)] = np.nan
|
||||
return arr
|
||||
|
||||
inputs = [clean_series(df[col].values) for col in cols]
|
||||
```
|
||||
|
||||
## Context Length Considerations
|
||||
|
||||
| Context Length | Use Case | Notes |
|
||||
| -------------- | -------- | ----- |
|
||||
| 64–256 | Quick prototyping | Minimal context, fast |
|
||||
| 256–512 | Daily data, ~1 year | Good balance |
|
||||
| 512–1024 | Daily data, ~2-3 years | Standard production |
|
||||
| 1024–4096 | Hourly data, weekly patterns | More context = better |
|
||||
| 4096–16384 | High-frequency, long patterns | TimesFM 2.5 maximum |
|
||||
|
||||
**Rule of thumb**: Provide at least 3–5 full cycles of the dominant pattern
|
||||
(e.g., for weekly seasonality with daily data, provide at least 21–35 days).
|
||||
|
||||
## Covariates (XReg)
|
||||
|
||||
TimesFM 2.5 supports exogenous variables through the `forecast_with_covariates()` API.
|
||||
|
||||
### Types of Covariates
|
||||
|
||||
| Type | Description | Example |
|
||||
| ---- | ----------- | ------- |
|
||||
| **Dynamic numerical** | Time-varying numeric features | Temperature, price, promotion spend |
|
||||
| **Dynamic categorical** | Time-varying categorical features | Day of week, holiday flag |
|
||||
| **Static categorical** | Fixed per-series features | Store ID, region, product category |
|
||||
|
||||
### Preparing Covariates
|
||||
|
||||
Each covariate must have length `context + horizon` for each series:
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
|
||||
context_len = 100 # length of historical data
|
||||
horizon = 24 # forecast horizon
|
||||
total_len = context_len + horizon
|
||||
|
||||
# Dynamic numerical: temperature forecast for each series
|
||||
temp = [
|
||||
np.random.randn(total_len).astype(np.float32), # Series 1
|
||||
np.random.randn(total_len).astype(np.float32), # Series 2
|
||||
]
|
||||
|
||||
# Dynamic categorical: day of week (0-6) for each series
|
||||
dow = [
|
||||
np.tile(np.arange(7), total_len // 7 + 1)[:total_len], # Series 1
|
||||
np.tile(np.arange(7), total_len // 7 + 1)[:total_len], # Series 2
|
||||
]
|
||||
|
||||
# Static categorical: one label per series
|
||||
regions = ["east", "west"]
|
||||
|
||||
# Forecast with covariates
|
||||
point, quantiles = model.forecast_with_covariates(
|
||||
inputs=[values1, values2],
|
||||
dynamic_numerical_covariates={"temperature": temp},
|
||||
dynamic_categorical_covariates={"day_of_week": dow},
|
||||
static_categorical_covariates={"region": regions},
|
||||
xreg_mode="xreg + timesfm",
|
||||
)
|
||||
```
|
||||
|
||||
### XReg Modes
|
||||
|
||||
| Mode | Description |
|
||||
| ---- | ----------- |
|
||||
| `"xreg + timesfm"` | Covariates processed first, then combined with TimesFM forecast |
|
||||
| `"timesfm + xreg"` | TimesFM forecast first, then adjusted by covariates |
|
||||
|
||||
## Common Data Issues
|
||||
|
||||
### Issue: Series too short
|
||||
|
||||
TimesFM needs at least 1 data point, but more context = better forecasts.
|
||||
|
||||
```python
|
||||
MIN_LENGTH = 32 # Practical minimum for meaningful forecasts
|
||||
|
||||
inputs = [
|
||||
arr for arr in raw_inputs
|
||||
if len(arr[~np.isnan(arr)]) >= MIN_LENGTH
|
||||
]
|
||||
```
|
||||
|
||||
### Issue: Series with constant values
|
||||
|
||||
Constant series may produce NaN or zero-width prediction intervals:
|
||||
|
||||
```python
|
||||
for i, arr in enumerate(inputs):
|
||||
if np.std(arr[~np.isnan(arr)]) < 1e-10:
|
||||
print(f"⚠️ Series {i} is constant — forecast will be flat")
|
||||
```
|
||||
|
||||
### Issue: Extreme outliers
|
||||
|
||||
Large outliers can destabilize forecasts even with normalization:
|
||||
|
||||
```python
|
||||
def clip_outliers(arr: np.ndarray, n_sigma: float = 5.0) -> np.ndarray:
|
||||
"""Clip values beyond n_sigma standard deviations."""
|
||||
mu = np.nanmean(arr)
|
||||
sigma = np.nanstd(arr)
|
||||
if sigma > 0:
|
||||
arr = np.clip(arr, mu - n_sigma * sigma, mu + n_sigma * sigma)
|
||||
return arr
|
||||
```
|
||||
|
||||
### Issue: Mixed frequencies in batch
|
||||
|
||||
TimesFM handles each series independently, so you can mix frequencies:
|
||||
|
||||
```python
|
||||
inputs = [
|
||||
daily_sales, # 365 points
|
||||
weekly_revenue, # 52 points
|
||||
monthly_users, # 24 points
|
||||
]
|
||||
# All forecasted in one batch — TimesFM handles different lengths
|
||||
point, q = model.forecast(horizon=12, inputs=inputs)
|
||||
```
|
||||
|
||||
However, the `horizon` is shared. If you need different horizons per series,
|
||||
forecast in separate calls.
|
||||
@@ -0,0 +1,201 @@
|
||||
# System Requirements for TimesFM
|
||||
|
||||
## Hardware Tiers
|
||||
|
||||
TimesFM can run on a variety of hardware configurations. This guide helps you
|
||||
choose the right setup and tune performance for your machine.
|
||||
|
||||
### Tier 1: Minimal (CPU-Only, 4–8 GB RAM)
|
||||
|
||||
- **Use case**: Light exploration, single-series forecasting, prototyping
|
||||
- **Model**: TimesFM 2.5 (200M) only
|
||||
- **Batch size**: `per_core_batch_size=4`
|
||||
- **Context**: Limit `max_context=512`
|
||||
- **Expected speed**: ~2–5 seconds per 100-point series
|
||||
|
||||
```python
|
||||
model.compile(timesfm.ForecastConfig(
|
||||
max_context=512,
|
||||
max_horizon=128,
|
||||
per_core_batch_size=4,
|
||||
normalize_inputs=True,
|
||||
use_continuous_quantile_head=True,
|
||||
fix_quantile_crossing=True,
|
||||
))
|
||||
```
|
||||
|
||||
### Tier 2: Standard (CPU 16 GB or GPU 4–8 GB VRAM)
|
||||
|
||||
- **Use case**: Batch forecasting (dozens of series), evaluation, production prototypes
|
||||
- **Model**: TimesFM 2.5 (200M)
|
||||
- **Batch size**: `per_core_batch_size=32` (CPU) or `64` (GPU)
|
||||
- **Context**: `max_context=1024`
|
||||
- **Expected speed**: ~0.5–1 second per 100-point series (GPU)
|
||||
|
||||
```python
|
||||
model.compile(timesfm.ForecastConfig(
|
||||
max_context=1024,
|
||||
max_horizon=256,
|
||||
per_core_batch_size=64,
|
||||
normalize_inputs=True,
|
||||
use_continuous_quantile_head=True,
|
||||
fix_quantile_crossing=True,
|
||||
))
|
||||
```
|
||||
|
||||
### Tier 3: Production (GPU 16+ GB VRAM or Apple Silicon 32+ GB)
|
||||
|
||||
- **Use case**: Large-scale batch forecasting (thousands of series), long context
|
||||
- **Model**: TimesFM 2.5 (200M)
|
||||
- **Batch size**: `per_core_batch_size=128–256`
|
||||
- **Context**: `max_context=4096` or higher
|
||||
- **Expected speed**: ~0.1–0.3 seconds per 100-point series
|
||||
|
||||
```python
|
||||
model.compile(timesfm.ForecastConfig(
|
||||
max_context=4096,
|
||||
max_horizon=256,
|
||||
per_core_batch_size=128,
|
||||
normalize_inputs=True,
|
||||
use_continuous_quantile_head=True,
|
||||
fix_quantile_crossing=True,
|
||||
))
|
||||
```
|
||||
|
||||
### Tier 4: Legacy Models (v1.0/v2.0 — 500M parameters)
|
||||
|
||||
- **⚠️ WARNING**: TimesFM v2.0 (500M) requires **≥ 16 GB RAM** (CPU) or **≥ 8 GB VRAM** (GPU)
|
||||
- **⚠️ WARNING**: TimesFM v1.0 legacy JAX version may require **≥ 32 GB RAM**
|
||||
- **Recommendation**: Unless you specifically need a legacy checkpoint, use TimesFM 2.5
|
||||
|
||||
## Memory Estimation
|
||||
|
||||
### CPU Memory (RAM)
|
||||
|
||||
Approximate RAM usage during inference:
|
||||
|
||||
| Component | TimesFM 2.5 (200M) | TimesFM 2.0 (500M) |
|
||||
| --------- | ------------------- | ------------------- |
|
||||
| Model weights | ~800 MB | ~2 GB |
|
||||
| Runtime overhead | ~500 MB | ~1 GB |
|
||||
| Input/output buffers | ~200 MB per 1000 series | ~500 MB per 1000 series |
|
||||
| **Total (small batch)** | **~1.5 GB** | **~3.5 GB** |
|
||||
| **Total (large batch)** | **~3 GB** | **~6 GB** |
|
||||
|
||||
**Formula**: `RAM ≈ model_weights + 0.5 GB + (0.2 MB × num_series × context_length / 1000)`
|
||||
|
||||
### GPU Memory (VRAM)
|
||||
|
||||
| Component | TimesFM 2.5 (200M) |
|
||||
| --------- | ------------------- |
|
||||
| Model weights | ~800 MB |
|
||||
| KV cache + activations | ~200–500 MB (scales with context) |
|
||||
| Batch buffers | ~100 MB per 100 series at context=1024 |
|
||||
| **Total (batch=32)** | **~1.2 GB** |
|
||||
| **Total (batch=128)** | **~1.8 GB** |
|
||||
| **Total (batch=256)** | **~2.5 GB** |
|
||||
|
||||
### Disk Space
|
||||
|
||||
| Item | Size |
|
||||
| ---- | ---- |
|
||||
| TimesFM 2.5 safetensors | ~800 MB |
|
||||
| Hugging Face cache overhead | ~200 MB |
|
||||
| **Total download** | **~1 GB** |
|
||||
|
||||
Model weights are downloaded once from Hugging Face Hub and cached in
|
||||
`~/.cache/huggingface/` (or `$HF_HOME`).
|
||||
|
||||
## GPU Selection Guide
|
||||
|
||||
### NVIDIA GPUs (CUDA)
|
||||
|
||||
| GPU | VRAM | Recommended batch | Notes |
|
||||
| --- | ---- | ----------------- | ----- |
|
||||
| RTX 3060 | 12 GB | 64 | Good entry-level |
|
||||
| RTX 3090 / 4090 | 24 GB | 256 | Excellent for production |
|
||||
| A100 (40 GB) | 40 GB | 512 | Cloud/HPC |
|
||||
| A100 (80 GB) | 80 GB | 1024 | Cloud/HPC |
|
||||
| T4 | 16 GB | 128 | Cloud (Colab, AWS) |
|
||||
| V100 | 16–32 GB | 128–256 | Cloud |
|
||||
|
||||
### Apple Silicon (MPS)
|
||||
|
||||
| Chip | Unified Memory | Recommended batch | Notes |
|
||||
| ---- | -------------- | ----------------- | ----- |
|
||||
| M1 | 8–16 GB | 16–32 | Works, slower than CUDA |
|
||||
| M1 Pro/Max | 16–64 GB | 32–128 | Good performance |
|
||||
| M2/M3/M4 Pro/Max | 18–128 GB | 64–256 | Excellent |
|
||||
|
||||
### CPU Only
|
||||
|
||||
Works on any CPU with sufficient RAM. Expect 5–20× slower than GPU.
|
||||
|
||||
## Python and Package Requirements
|
||||
|
||||
| Requirement | Minimum | Recommended |
|
||||
| ----------- | ------- | ----------- |
|
||||
| Python | 3.10 | 3.12+ |
|
||||
| numpy | 1.26.4 | latest |
|
||||
| torch | 2.0.0 | latest |
|
||||
| huggingface_hub | 0.23.0 | latest |
|
||||
| safetensors | 0.5.3 | latest |
|
||||
|
||||
### Optional Dependencies
|
||||
|
||||
| Package | Purpose | Install |
|
||||
| ------- | ------- | ------- |
|
||||
| jax | Flax backend | `pip install jax[cuda]` |
|
||||
| flax | Flax backend | `pip install flax` |
|
||||
| scikit-learn | XReg covariates | `pip install scikit-learn` |
|
||||
|
||||
## Operating System Compatibility
|
||||
|
||||
| OS | Status | Notes |
|
||||
| -- | ------ | ----- |
|
||||
| Linux (Ubuntu 20.04+) | ✅ Fully supported | Best performance with CUDA |
|
||||
| macOS 13+ (Ventura) | ✅ Fully supported | MPS acceleration on Apple Silicon |
|
||||
| Windows 11 + WSL2 | ✅ Supported | Use WSL2 for best experience |
|
||||
| Windows (native) | ⚠️ Partial | PyTorch works, some edge cases |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Out of Memory (OOM)
|
||||
|
||||
```python
|
||||
# Reduce batch size
|
||||
model.compile(timesfm.ForecastConfig(
|
||||
per_core_batch_size=4, # Start very small
|
||||
max_context=512, # Reduce context
|
||||
...
|
||||
))
|
||||
|
||||
# Process in chunks
|
||||
for i in range(0, len(inputs), 50):
|
||||
chunk = inputs[i:i+50]
|
||||
p, q = model.forecast(horizon=H, inputs=chunk)
|
||||
```
|
||||
|
||||
### Slow Inference on CPU
|
||||
|
||||
```python
|
||||
# Ensure matmul precision is set
|
||||
import torch
|
||||
torch.set_float32_matmul_precision("high")
|
||||
|
||||
# Use smaller context
|
||||
model.compile(timesfm.ForecastConfig(
|
||||
max_context=256, # Shorter context = faster
|
||||
...
|
||||
))
|
||||
```
|
||||
|
||||
### Model Download Fails
|
||||
|
||||
```bash
|
||||
# Set a different cache directory
|
||||
export HF_HOME=/path/with/more/space
|
||||
|
||||
# Or download manually
|
||||
huggingface-cli download google/timesfm-2.5-200m-pytorch
|
||||
```
|
||||
@@ -0,0 +1,520 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TimesFM System Requirements Preflight Checker.
|
||||
|
||||
MANDATORY: Run this script before loading TimesFM for the first time.
|
||||
It checks RAM, GPU/VRAM, disk space, Python version, and package
|
||||
installation so the agent never crashes a user's machine.
|
||||
|
||||
Usage:
|
||||
python check_system.py
|
||||
python check_system.py --model v2.5 # default
|
||||
python check_system.py --model v2.0 # archived 500M model
|
||||
python check_system.py --model v1.0 # archived 200M model
|
||||
python check_system.py --json # machine-readable output
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model requirement profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MODEL_PROFILES: dict[str, dict[str, Any]] = {
|
||||
"v2.5": {
|
||||
"name": "TimesFM 2.5 (200M)",
|
||||
"params": "200M",
|
||||
"min_ram_gb": 2.0,
|
||||
"recommended_ram_gb": 4.0,
|
||||
"min_vram_gb": 2.0,
|
||||
"recommended_vram_gb": 4.0,
|
||||
"disk_gb": 2.0, # model weights + overhead
|
||||
"hf_repo": "google/timesfm-2.5-200m-pytorch",
|
||||
},
|
||||
"v2.0": {
|
||||
"name": "TimesFM 2.0 (500M)",
|
||||
"params": "500M",
|
||||
"min_ram_gb": 8.0,
|
||||
"recommended_ram_gb": 16.0,
|
||||
"min_vram_gb": 4.0,
|
||||
"recommended_vram_gb": 8.0,
|
||||
"disk_gb": 4.0,
|
||||
"hf_repo": "google/timesfm-2.0-500m-pytorch",
|
||||
},
|
||||
"v1.0": {
|
||||
"name": "TimesFM 1.0 (200M)",
|
||||
"params": "200M",
|
||||
"min_ram_gb": 4.0,
|
||||
"recommended_ram_gb": 8.0,
|
||||
"min_vram_gb": 2.0,
|
||||
"recommended_vram_gb": 4.0,
|
||||
"disk_gb": 2.0,
|
||||
"hf_repo": "google/timesfm-1.0-200m-pytorch",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
name: str
|
||||
status: str # "pass", "warn", "fail"
|
||||
detail: str
|
||||
value: str = ""
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return {"pass": "✅", "warn": "⚠️", "fail": "🛑"}.get(self.status, "❓")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.name:<10}] {self.value:<40} {self.icon} {self.status.upper()}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemReport:
|
||||
model: str
|
||||
checks: list[CheckResult] = field(default_factory=list)
|
||||
verdict: str = ""
|
||||
verdict_detail: str = ""
|
||||
recommended_batch_size: int = 1
|
||||
mode: str = "cpu" # "cpu", "gpu", "mps"
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
return all(c.status != "fail" for c in self.checks)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"model": self.model,
|
||||
"passed": self.passed,
|
||||
"mode": self.mode,
|
||||
"recommended_batch_size": self.recommended_batch_size,
|
||||
"verdict": self.verdict,
|
||||
"verdict_detail": self.verdict_detail,
|
||||
"checks": [
|
||||
{
|
||||
"name": c.name,
|
||||
"status": c.status,
|
||||
"detail": c.detail,
|
||||
"value": c.value,
|
||||
}
|
||||
for c in self.checks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_total_ram_gb() -> float:
|
||||
"""Return total physical RAM in GB, cross-platform."""
|
||||
try:
|
||||
if sys.platform == "linux":
|
||||
with open("/proc/meminfo") as f:
|
||||
for line in f:
|
||||
if line.startswith("MemTotal"):
|
||||
return int(line.split()[1]) / (1024 * 1024)
|
||||
elif sys.platform == "darwin":
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
["sysctl", "-n", "hw.memsize"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return int(result.stdout.strip()) / (1024**3)
|
||||
elif sys.platform == "win32":
|
||||
import ctypes
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||||
|
||||
class MEMORYSTATUSEX(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwLength", ctypes.c_ulong),
|
||||
("dwMemoryLoad", ctypes.c_ulong),
|
||||
("ullTotalPhys", ctypes.c_ulonglong),
|
||||
("ullAvailPhys", ctypes.c_ulonglong),
|
||||
("ullTotalPageFile", ctypes.c_ulonglong),
|
||||
("ullAvailPageFile", ctypes.c_ulonglong),
|
||||
("ullTotalVirtual", ctypes.c_ulonglong),
|
||||
("ullAvailVirtual", ctypes.c_ulonglong),
|
||||
("sullAvailExtendedVirtual", ctypes.c_ulonglong),
|
||||
]
|
||||
|
||||
stat = MEMORYSTATUSEX()
|
||||
stat.dwLength = ctypes.sizeof(stat)
|
||||
kernel32.GlobalMemoryStatusEx(ctypes.byref(stat))
|
||||
return stat.ullTotalPhys / (1024**3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: use struct to estimate (unreliable)
|
||||
return struct.calcsize("P") * 8 / 8 # placeholder
|
||||
|
||||
|
||||
def _get_available_ram_gb() -> float:
|
||||
"""Return available RAM in GB."""
|
||||
try:
|
||||
if sys.platform == "linux":
|
||||
with open("/proc/meminfo") as f:
|
||||
for line in f:
|
||||
if line.startswith("MemAvailable"):
|
||||
return int(line.split()[1]) / (1024 * 1024)
|
||||
elif sys.platform == "darwin":
|
||||
import subprocess
|
||||
|
||||
# Use vm_stat for available memory on macOS
|
||||
result = subprocess.run(
|
||||
["vm_stat"], capture_output=True, text=True, check=True
|
||||
)
|
||||
free = 0
|
||||
page_size = 4096
|
||||
for line in result.stdout.split("\n"):
|
||||
if "Pages free" in line or "Pages inactive" in line:
|
||||
val = line.split(":")[1].strip().rstrip(".")
|
||||
free += int(val) * page_size
|
||||
return free / (1024**3)
|
||||
elif sys.platform == "win32":
|
||||
import ctypes
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||||
|
||||
class MEMORYSTATUSEX(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwLength", ctypes.c_ulong),
|
||||
("dwMemoryLoad", ctypes.c_ulong),
|
||||
("ullTotalPhys", ctypes.c_ulonglong),
|
||||
("ullAvailPhys", ctypes.c_ulonglong),
|
||||
("ullTotalPageFile", ctypes.c_ulonglong),
|
||||
("ullAvailPageFile", ctypes.c_ulonglong),
|
||||
("ullTotalVirtual", ctypes.c_ulonglong),
|
||||
("ullAvailVirtual", ctypes.c_ulonglong),
|
||||
("sullAvailExtendedVirtual", ctypes.c_ulonglong),
|
||||
]
|
||||
|
||||
stat = MEMORYSTATUSEX()
|
||||
stat.dwLength = ctypes.sizeof(stat)
|
||||
kernel32.GlobalMemoryStatusEx(ctypes.byref(stat))
|
||||
return stat.ullAvailPhys / (1024**3)
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def check_ram(profile: dict[str, Any]) -> CheckResult:
|
||||
"""Check if system has enough RAM."""
|
||||
total = _get_total_ram_gb()
|
||||
available = _get_available_ram_gb()
|
||||
min_ram = profile["min_ram_gb"]
|
||||
rec_ram = profile["recommended_ram_gb"]
|
||||
|
||||
value = f"Total: {total:.1f} GB | Available: {available:.1f} GB"
|
||||
|
||||
if total < min_ram:
|
||||
return CheckResult(
|
||||
name="RAM",
|
||||
status="fail",
|
||||
detail=(
|
||||
f"System has {total:.1f} GB RAM but {profile['name']} requires "
|
||||
f"at least {min_ram:.0f} GB. The model will likely fail to load "
|
||||
f"or cause the system to swap heavily and become unresponsive."
|
||||
),
|
||||
value=value,
|
||||
)
|
||||
elif total < rec_ram:
|
||||
return CheckResult(
|
||||
name="RAM",
|
||||
status="warn",
|
||||
detail=(
|
||||
f"System has {total:.1f} GB RAM. {profile['name']} recommends "
|
||||
f"{rec_ram:.0f} GB. It may work with small batch sizes but could "
|
||||
f"be tight. Use per_core_batch_size=4 or lower."
|
||||
),
|
||||
value=value,
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
name="RAM",
|
||||
status="pass",
|
||||
detail=f"System has {total:.1f} GB RAM, meets {rec_ram:.0f} GB recommendation.",
|
||||
value=value,
|
||||
)
|
||||
|
||||
|
||||
def check_gpu() -> CheckResult:
|
||||
"""Check GPU availability and VRAM."""
|
||||
# Try CUDA first
|
||||
try:
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
name = torch.cuda.get_device_name(0)
|
||||
vram = torch.cuda.get_device_properties(0).total_memory / (1024**3)
|
||||
return CheckResult(
|
||||
name="GPU",
|
||||
status="pass",
|
||||
detail=f"{name} with {vram:.1f} GB VRAM detected.",
|
||||
value=f"{name} | VRAM: {vram:.1f} GB",
|
||||
)
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return CheckResult(
|
||||
name="GPU",
|
||||
status="pass",
|
||||
detail="Apple Silicon MPS backend available. Uses unified memory.",
|
||||
value="Apple Silicon MPS",
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
name="GPU",
|
||||
status="warn",
|
||||
detail=(
|
||||
"No GPU detected. TimesFM will run on CPU (slower but functional). "
|
||||
"Install CUDA-enabled PyTorch for GPU acceleration."
|
||||
),
|
||||
value="None (CPU only)",
|
||||
)
|
||||
except ImportError:
|
||||
return CheckResult(
|
||||
name="GPU",
|
||||
status="warn",
|
||||
detail="PyTorch not installed — cannot check GPU. Install torch first.",
|
||||
value="Unknown (torch not installed)",
|
||||
)
|
||||
|
||||
|
||||
def check_disk(profile: dict[str, Any]) -> CheckResult:
|
||||
"""Check available disk space for model download."""
|
||||
# Check HuggingFace cache dir or home dir
|
||||
hf_cache = os.environ.get("HF_HOME", os.path.expanduser("~/.cache/huggingface"))
|
||||
cache_dir = Path(hf_cache)
|
||||
check_dir = cache_dir if cache_dir.exists() else Path.home()
|
||||
|
||||
usage = shutil.disk_usage(str(check_dir))
|
||||
free_gb = usage.free / (1024**3)
|
||||
required = profile["disk_gb"]
|
||||
|
||||
value = f"Free: {free_gb:.1f} GB (in {check_dir})"
|
||||
|
||||
if free_gb < required:
|
||||
return CheckResult(
|
||||
name="Disk",
|
||||
status="fail",
|
||||
detail=(
|
||||
f"Only {free_gb:.1f} GB free in {check_dir}. "
|
||||
f"Need at least {required:.0f} GB for model weights. "
|
||||
f"Free up space or set HF_HOME to a larger volume."
|
||||
),
|
||||
value=value,
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
name="Disk",
|
||||
status="pass",
|
||||
detail=f"{free_gb:.1f} GB available, exceeds {required:.0f} GB requirement.",
|
||||
value=value,
|
||||
)
|
||||
|
||||
|
||||
def check_python() -> CheckResult:
|
||||
"""Check Python version >= 3.10."""
|
||||
version = sys.version.split()[0]
|
||||
major, minor = sys.version_info[:2]
|
||||
|
||||
if (major, minor) < (3, 10):
|
||||
return CheckResult(
|
||||
name="Python",
|
||||
status="fail",
|
||||
detail=f"Python {version} detected. TimesFM requires Python >= 3.10.",
|
||||
value=version,
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
name="Python",
|
||||
status="pass",
|
||||
detail=f"Python {version} meets >= 3.10 requirement.",
|
||||
value=version,
|
||||
)
|
||||
|
||||
|
||||
def check_package(pkg_name: str, import_name: str | None = None) -> CheckResult:
|
||||
"""Check if a Python package is installed."""
|
||||
import_name = import_name or pkg_name
|
||||
try:
|
||||
mod = __import__(import_name)
|
||||
version = getattr(mod, "__version__", "unknown")
|
||||
return CheckResult(
|
||||
name=pkg_name,
|
||||
status="pass",
|
||||
detail=f"{pkg_name} {version} is installed.",
|
||||
value=f"Installed ({version})",
|
||||
)
|
||||
except ImportError:
|
||||
return CheckResult(
|
||||
name=pkg_name,
|
||||
status="warn",
|
||||
detail=f"{pkg_name} is not installed. Run: uv pip install {pkg_name}",
|
||||
value="Not installed",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch size recommendation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def recommend_batch_size(report: SystemReport) -> int:
|
||||
"""Recommend per_core_batch_size based on available resources."""
|
||||
total_ram = _get_total_ram_gb()
|
||||
|
||||
# Check if GPU is available
|
||||
gpu_check = next((c for c in report.checks if c.name == "GPU"), None)
|
||||
|
||||
if gpu_check and gpu_check.status == "pass" and "VRAM" in gpu_check.value:
|
||||
# Extract VRAM
|
||||
try:
|
||||
vram_str = gpu_check.value.split("VRAM:")[1].strip().split()[0]
|
||||
vram = float(vram_str)
|
||||
if vram >= 24:
|
||||
return 256
|
||||
elif vram >= 16:
|
||||
return 128
|
||||
elif vram >= 8:
|
||||
return 64
|
||||
elif vram >= 4:
|
||||
return 32
|
||||
else:
|
||||
return 16
|
||||
except (ValueError, IndexError):
|
||||
return 32
|
||||
elif gpu_check and "MPS" in gpu_check.value:
|
||||
# Apple Silicon — use unified memory heuristic
|
||||
if total_ram >= 32:
|
||||
return 64
|
||||
elif total_ram >= 16:
|
||||
return 32
|
||||
else:
|
||||
return 16
|
||||
else:
|
||||
# CPU only
|
||||
if total_ram >= 32:
|
||||
return 64
|
||||
elif total_ram >= 16:
|
||||
return 32
|
||||
elif total_ram >= 8:
|
||||
return 8
|
||||
else:
|
||||
return 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_checks(model_version: str = "v2.5") -> SystemReport:
|
||||
"""Run all system checks and return a report."""
|
||||
profile = MODEL_PROFILES[model_version]
|
||||
report = SystemReport(model=profile["name"])
|
||||
|
||||
# Run checks
|
||||
report.checks.append(check_ram(profile))
|
||||
report.checks.append(check_gpu())
|
||||
report.checks.append(check_disk(profile))
|
||||
report.checks.append(check_python())
|
||||
report.checks.append(check_package("timesfm"))
|
||||
report.checks.append(check_package("torch"))
|
||||
|
||||
# Determine mode
|
||||
gpu_check = next((c for c in report.checks if c.name == "GPU"), None)
|
||||
if gpu_check and gpu_check.status == "pass":
|
||||
if "MPS" in gpu_check.value:
|
||||
report.mode = "mps"
|
||||
else:
|
||||
report.mode = "gpu"
|
||||
else:
|
||||
report.mode = "cpu"
|
||||
|
||||
# Batch size
|
||||
report.recommended_batch_size = recommend_batch_size(report)
|
||||
|
||||
# Verdict
|
||||
if report.passed:
|
||||
report.verdict = (
|
||||
f"✅ System is ready for {profile['name']} ({report.mode.upper()} mode)"
|
||||
)
|
||||
report.verdict_detail = (
|
||||
f"Recommended: per_core_batch_size={report.recommended_batch_size}"
|
||||
)
|
||||
else:
|
||||
failed = [c for c in report.checks if c.status == "fail"]
|
||||
report.verdict = f"🛑 System does NOT meet requirements for {profile['name']}"
|
||||
report.verdict_detail = "; ".join(c.detail for c in failed)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def print_report(report: SystemReport) -> None:
|
||||
"""Print a human-readable report to stdout."""
|
||||
print(f"\n{'=' * 50}")
|
||||
print(f" TimesFM System Requirements Check")
|
||||
print(f" Model: {report.model}")
|
||||
print(f"{'=' * 50}\n")
|
||||
|
||||
for check in report.checks:
|
||||
print(f" {check}")
|
||||
print()
|
||||
|
||||
print(f" VERDICT: {report.verdict}")
|
||||
if report.verdict_detail:
|
||||
print(f" {report.verdict_detail}")
|
||||
print()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check system requirements for TimesFM."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
choices=list(MODEL_PROFILES.keys()),
|
||||
default="v2.5",
|
||||
help="Model version to check requirements for (default: v2.5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output results as JSON (machine-readable)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
report = run_checks(args.model)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(report.to_dict(), indent=2))
|
||||
else:
|
||||
print_report(report)
|
||||
|
||||
# Exit with non-zero if any check failed
|
||||
sys.exit(0 if report.passed else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end CSV forecasting with TimesFM.
|
||||
|
||||
Loads a CSV, runs the system preflight check, loads TimesFM, forecasts
|
||||
the requested columns, and writes results to a new CSV or JSON.
|
||||
|
||||
Usage:
|
||||
python forecast_csv.py input.csv --horizon 24
|
||||
python forecast_csv.py input.csv --horizon 12 --date-col date --value-cols sales,revenue
|
||||
python forecast_csv.py input.csv --horizon 52 --output forecasts.csv
|
||||
python forecast_csv.py input.csv --horizon 30 --output forecasts.json --format json
|
||||
|
||||
The script automatically:
|
||||
1. Runs the system preflight check (exits if it fails).
|
||||
2. Loads TimesFM 2.5 from Hugging Face.
|
||||
3. Reads the CSV and identifies time series columns.
|
||||
4. Forecasts each series with prediction intervals.
|
||||
5. Writes results to the specified output file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def run_preflight() -> dict:
|
||||
"""Run the system preflight check and return the report."""
|
||||
# Import the check_system module from the same directory
|
||||
script_dir = Path(__file__).parent
|
||||
sys.path.insert(0, str(script_dir))
|
||||
from check_system import run_checks
|
||||
|
||||
report = run_checks("v2.5")
|
||||
if not report.passed:
|
||||
print("\n🛑 System check FAILED. Cannot proceed with forecasting.")
|
||||
print(f" {report.verdict_detail}")
|
||||
print("\nRun 'python scripts/check_system.py' for details.")
|
||||
sys.exit(1)
|
||||
|
||||
return report.to_dict()
|
||||
|
||||
|
||||
def load_model(batch_size: int = 32):
|
||||
"""Load and compile the TimesFM model."""
|
||||
import torch
|
||||
import timesfm
|
||||
|
||||
torch.set_float32_matmul_precision("high")
|
||||
|
||||
print("Loading TimesFM 2.5 from Hugging Face...")
|
||||
model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
|
||||
"google/timesfm-2.5-200m-pytorch"
|
||||
)
|
||||
|
||||
print(f"Compiling with per_core_batch_size={batch_size}...")
|
||||
model.compile(
|
||||
timesfm.ForecastConfig(
|
||||
max_context=1024,
|
||||
max_horizon=256,
|
||||
normalize_inputs=True,
|
||||
use_continuous_quantile_head=True,
|
||||
force_flip_invariance=True,
|
||||
infer_is_positive=True,
|
||||
fix_quantile_crossing=True,
|
||||
per_core_batch_size=batch_size,
|
||||
)
|
||||
)
|
||||
|
||||
return model
|
||||
|
||||
|
||||
def load_csv(
|
||||
path: str,
|
||||
date_col: str | None = None,
|
||||
value_cols: list[str] | None = None,
|
||||
) -> tuple[pd.DataFrame, list[str], str | None]:
|
||||
"""Load CSV and identify time series columns.
|
||||
|
||||
Returns:
|
||||
(dataframe, value_column_names, date_column_name_or_none)
|
||||
"""
|
||||
df = pd.read_csv(path)
|
||||
|
||||
# Identify date column
|
||||
if date_col and date_col in df.columns:
|
||||
df[date_col] = pd.to_datetime(df[date_col])
|
||||
elif date_col:
|
||||
print(f"⚠️ Date column '{date_col}' not found. Available: {list(df.columns)}")
|
||||
date_col = None
|
||||
|
||||
# Identify value columns
|
||||
if value_cols:
|
||||
missing = [c for c in value_cols if c not in df.columns]
|
||||
if missing:
|
||||
print(f"⚠️ Columns not found: {missing}. Available: {list(df.columns)}")
|
||||
value_cols = [c for c in value_cols if c in df.columns]
|
||||
else:
|
||||
# Auto-detect numeric columns (exclude date)
|
||||
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
|
||||
if date_col and date_col in numeric_cols:
|
||||
numeric_cols.remove(date_col)
|
||||
value_cols = numeric_cols
|
||||
|
||||
if not value_cols:
|
||||
print("🛑 No numeric columns found to forecast.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Found {len(value_cols)} series to forecast: {value_cols}")
|
||||
return df, value_cols, date_col
|
||||
|
||||
|
||||
def forecast_series(
|
||||
model, df: pd.DataFrame, value_cols: list[str], horizon: int
|
||||
) -> dict[str, dict]:
|
||||
"""Forecast all series and return results dict."""
|
||||
inputs = []
|
||||
for col in value_cols:
|
||||
values = df[col].dropna().values.astype(np.float32)
|
||||
inputs.append(values)
|
||||
|
||||
print(f"Forecasting {len(inputs)} series with horizon={horizon}...")
|
||||
point, quantiles = model.forecast(horizon=horizon, inputs=inputs)
|
||||
|
||||
results = {}
|
||||
for i, col in enumerate(value_cols):
|
||||
results[col] = {
|
||||
"forecast": point[i].tolist(),
|
||||
"lower_90": quantiles[i, :, 1].tolist(), # 10th percentile
|
||||
"lower_80": quantiles[i, :, 2].tolist(), # 20th percentile
|
||||
"median": quantiles[i, :, 5].tolist(), # 50th percentile
|
||||
"upper_80": quantiles[i, :, 8].tolist(), # 80th percentile
|
||||
"upper_90": quantiles[i, :, 9].tolist(), # 90th percentile
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def write_csv_output(
|
||||
results: dict[str, dict],
|
||||
output_path: str,
|
||||
df: pd.DataFrame,
|
||||
date_col: str | None,
|
||||
horizon: int,
|
||||
) -> None:
|
||||
"""Write forecast results to CSV."""
|
||||
rows = []
|
||||
for col, data in results.items():
|
||||
# Try to generate future dates
|
||||
future_dates = list(range(1, horizon + 1))
|
||||
if date_col and date_col in df.columns:
|
||||
try:
|
||||
last_date = df[date_col].dropna().iloc[-1]
|
||||
freq = pd.infer_freq(df[date_col].dropna())
|
||||
if freq:
|
||||
future_dates = pd.date_range(
|
||||
last_date, periods=horizon + 1, freq=freq
|
||||
)[1:].tolist()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for h in range(horizon):
|
||||
row = {
|
||||
"series": col,
|
||||
"step": h + 1,
|
||||
"forecast": data["forecast"][h],
|
||||
"lower_90": data["lower_90"][h],
|
||||
"lower_80": data["lower_80"][h],
|
||||
"median": data["median"][h],
|
||||
"upper_80": data["upper_80"][h],
|
||||
"upper_90": data["upper_90"][h],
|
||||
}
|
||||
if isinstance(future_dates[0], (pd.Timestamp,)):
|
||||
row["date"] = future_dates[h]
|
||||
rows.append(row)
|
||||
|
||||
out_df = pd.DataFrame(rows)
|
||||
out_df.to_csv(output_path, index=False)
|
||||
print(f"✅ Wrote {len(rows)} forecast rows to {output_path}")
|
||||
|
||||
|
||||
def write_json_output(results: dict[str, dict], output_path: str) -> None:
|
||||
"""Write forecast results to JSON."""
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"✅ Wrote forecasts for {len(results)} series to {output_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Forecast time series from CSV using TimesFM."
|
||||
)
|
||||
parser.add_argument("input", help="Path to input CSV file")
|
||||
parser.add_argument(
|
||||
"--horizon", type=int, required=True, help="Number of steps to forecast"
|
||||
)
|
||||
parser.add_argument("--date-col", help="Name of the date/time column")
|
||||
parser.add_argument(
|
||||
"--value-cols",
|
||||
help="Comma-separated list of value columns to forecast (default: all numeric)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="forecasts.csv",
|
||||
help="Output file path (default: forecasts.csv)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["csv", "json"],
|
||||
default=None,
|
||||
help="Output format (inferred from --output extension if not set)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Override per_core_batch_size (auto-detected from system check if omitted)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-check",
|
||||
action="store_true",
|
||||
help="Skip system preflight check (not recommended)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Parse value columns
|
||||
value_cols = None
|
||||
if args.value_cols:
|
||||
value_cols = [c.strip() for c in args.value_cols.split(",")]
|
||||
|
||||
# Determine output format
|
||||
out_format = args.format
|
||||
if not out_format:
|
||||
out_format = "json" if args.output.endswith(".json") else "csv"
|
||||
|
||||
# 1. Preflight check
|
||||
if not args.skip_check:
|
||||
print("Running system preflight check...")
|
||||
report = run_preflight()
|
||||
batch_size = args.batch_size or report.get("recommended_batch_size", 32)
|
||||
else:
|
||||
print("⚠️ Skipping system check (--skip-check). Proceed with caution.")
|
||||
batch_size = args.batch_size or 32
|
||||
|
||||
# 2. Load model
|
||||
model = load_model(batch_size=batch_size)
|
||||
|
||||
# 3. Load CSV
|
||||
df, cols, date_col = load_csv(args.input, args.date_col, value_cols)
|
||||
|
||||
# 4. Forecast
|
||||
results = forecast_series(model, df, cols, args.horizon)
|
||||
|
||||
# 5. Write output
|
||||
if out_format == "json":
|
||||
write_json_output(results, args.output)
|
||||
else:
|
||||
write_csv_output(results, args.output, df, date_col, args.horizon)
|
||||
|
||||
print("\nDone! 🎉")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user