refactor(skill): replace claude-specific dirs with agentskills.io standard

Replace AGENTS.md / claude-skill/ with a proper agentskills.io-compliant
skill directory. Any AI agent that supports the open Agent Skills standard
(Claude Code, OpenCode, Cursor, Codex, etc.) can now install and use this
skill generically.

Changes:
- Remove AGENTS.md (was Claude-specific convention)
- Remove claude-skill/ directory (was Claude-specific naming)
- Add timesfm-forecasting/SKILL.md with compliant frontmatter:
    name: timesfm-forecasting
    description: ...
    license: Apache-2.0
    metadata: author, version
- Rename claude-skill/examples/ → timesfm-forecasting/examples/
- Rename claude-skill/scripts/  → timesfm-forecasting/scripts/
- Rename claude-skill/references/ → timesfm-forecasting/references/
- Update .gitattributes paths to match new directory

Skill installs via:
  cp -r timesfm-forecasting/ ~/.claude/skills/
  cp -r timesfm-forecasting/ ~/.cursor/skills/
  # or any agent that supports agentskills.io

Spec: https://agentskills.io/specification
This commit is contained in:
Clayton Young
2026-02-22 13:35:05 -05:00
parent 6c44413b7f
commit a83dbf3f16
29 changed files with 451 additions and 765 deletions
@@ -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
1 store_id week split sales base_sales price price_effect promotion holiday day_of_week store_type region
2 store_A 0 context 1369.59 1012.19 11.6299 7.4 1 1 0 premium urban
3 store_A 1 context 973.53 973.04 11.9757 0.49 0 0 1 premium urban
4 store_A 2 context 1064.63 1059.16 11.7269 5.46 0 0 2 premium urban
5 store_A 3 context 1077.59 1080.99 12.1698 -3.4 0 0 3 premium urban
6 store_A 4 context 980.39 979.14 11.9372 1.26 0 0 4 premium urban
7 store_A 5 context 1011.7 1018.36 12.3327 -6.65 0 0 5 premium urban
8 store_A 6 context 1084.16 1088.16 12.2003 -4.01 0 0 6 premium urban
9 store_A 7 context 1085.98 1082.23 11.8124 3.75 0 0 0 premium urban
10 store_A 8 context 1098.52 1105.17 12.3323 -6.65 0 0 1 premium urban
11 store_A 9 context 1075.62 1081.71 12.3048 -6.1 0 0 2 premium urban
12 store_A 10 context 1312.23 1159.98 11.8875 2.25 1 0 3 premium urban
13 store_A 11 context 1368.02 1163.79 11.7883 4.23 0 1 4 premium urban
14 store_A 12 context 1138.41 1142.06 12.1825 -3.65 0 0 5 premium urban
15 store_A 13 context 1197.29 1190.09 11.6398 7.2 0 0 6 premium urban
16 store_A 14 context 1174.12 1168.12 11.6999 6.0 0 0 0 premium urban
17 store_A 15 context 1128.16 1118.3 11.5074 9.85 0 0 1 premium urban
18 store_A 16 context 1163.81 1169.55 12.2869 -5.74 0 0 2 premium urban
19 store_A 17 context 1114.18 1117.48 12.1649 -3.3 0 0 3 premium urban
20 store_A 18 context 1186.87 1190.98 12.2052 -4.1 0 0 4 premium urban
21 store_A 19 context 1147.27 1152.88 12.2807 -5.61 0 0 5 premium urban
22 store_A 20 context 1146.48 1145.66 11.9589 0.82 0 0 6 premium urban
23 store_A 21 context 1121.83 1123.21 12.0687 -1.37 0 0 0 premium urban
24 store_A 22 context 1203.28 1196.08 11.6398 7.2 0 0 1 premium urban
25 store_A 23 context 1344.9 1137.19 11.6145 7.71 0 1 2 premium urban
26 store_A 24 horizon 1118.64 1122.01 12.1684 -3.37 0 0 3 premium urban
27 store_A 25 horizon 1121.14 1120.56 11.9711 0.58 0 0 4 premium urban
28 store_A 26 horizon 1149.99 1151.29 12.0652 -1.3 0 0 5 premium urban
29 store_A 27 horizon 1284.67 1139.97 12.265 -5.3 1 0 6 premium urban
30 store_A 28 horizon 1284.67 1137.36 12.1347 -2.69 1 0 0 premium urban
31 store_A 29 horizon 1132.79 1133.86 12.0536 -1.07 0 0 1 premium urban
32 store_A 30 horizon 1197.3 1198.49 12.0592 -1.18 0 0 2 premium urban
33 store_A 31 horizon 1247.22 1093.3 11.804 3.92 1 0 3 premium urban
34 store_A 32 horizon 1095.84 1086.46 11.5308 9.38 0 0 4 premium urban
35 store_A 33 horizon 1073.83 1072.57 11.9367 1.27 0 0 5 premium urban
36 store_A 34 horizon 1134.51 1128.8 11.7146 5.71 0 0 6 premium urban
37 store_A 35 horizon 1351.15 1149.32 11.9085 1.83 0 1 0 premium urban
38 store_B 0 context 1062.53 712.0 9.9735 0.53 1 1 0 standard suburban
39 store_B 1 context 904.49 749.83 9.767 4.66 1 0 1 standard suburban
40 store_B 2 context 813.63 810.26 9.8316 3.37 0 0 2 standard suburban
41 store_B 3 context 720.11 720.53 10.0207 -0.41 0 0 3 standard suburban
42 store_B 4 context 820.78 819.55 9.9389 1.22 0 0 4 standard suburban
43 store_B 5 context 833.27 823.7 9.5216 9.57 0 0 5 standard suburban
44 store_B 6 context 795.26 801.78 10.3263 -6.53 0 0 6 standard suburban
45 store_B 7 context 770.37 778.29 10.3962 -7.92 0 0 0 standard suburban
46 store_B 8 context 855.92 848.72 9.6402 7.2 0 0 1 standard suburban
47 store_B 9 context 832.33 833.41 10.054 -1.08 0 0 2 standard suburban
48 store_B 10 context 1029.44 871.61 9.6086 7.83 1 0 3 standard suburban
49 store_B 11 context 1066.35 869.8 10.1722 -3.44 0 1 4 standard suburban
50 store_B 12 context 942.86 938.49 9.7812 4.38 0 0 5 standard suburban
51 store_B 13 context 1015.99 869.18 10.1594 -3.19 1 0 6 standard suburban
52 store_B 14 context 836.44 840.98 10.227 -4.54 0 0 0 standard suburban
53 store_B 15 context 885.72 891.1 10.2686 -5.37 0 0 1 standard suburban
54 store_B 16 context 901.45 893.6 9.6077 7.85 0 0 2 standard suburban
55 store_B 17 context 1080.63 938.95 10.416 -8.32 1 0 3 standard suburban
56 store_B 18 context 922.14 916.74 9.7302 5.4 0 0 4 standard suburban
57 store_B 19 context 904.66 895.41 9.5374 9.25 0 0 5 standard suburban
58 store_B 20 context 935.48 936.58 10.0549 -1.1 0 0 6 standard suburban
59 store_B 21 context 979.23 826.64 9.8709 2.58 1 0 0 standard suburban
60 store_B 22 context 837.49 844.09 10.3298 -6.6 0 0 1 standard suburban
61 store_B 23 context 1021.39 827.56 10.3083 -6.17 0 1 2 standard suburban
62 store_B 24 horizon 847.21 843.55 9.8171 3.66 0 0 3 standard suburban
63 store_B 25 horizon 789.27 798.33 10.4529 -9.06 0 0 4 standard suburban
64 store_B 26 horizon 877.09 872.91 9.7909 4.18 0 0 5 standard suburban
65 store_B 27 horizon 832.42 832.72 10.0151 -0.3 0 0 6 standard suburban
66 store_B 28 horizon 781.9 777.02 9.756 4.88 0 0 0 standard suburban
67 store_B 29 horizon 781.04 789.76 10.436 -8.72 0 0 1 standard suburban
68 store_B 30 horizon 844.57 837.86 9.6646 6.71 0 0 2 standard suburban
69 store_B 31 horizon 863.43 854.33 9.5449 9.1 0 0 3 standard suburban
70 store_B 32 horizon 898.12 896.82 9.9351 1.3 0 0 4 standard suburban
71 store_B 33 horizon 1070.58 930.42 10.4924 -9.85 1 0 5 standard suburban
72 store_B 34 horizon 820.4 828.24 10.3917 -7.83 0 0 6 standard suburban
73 store_B 35 horizon 965.86 770.83 10.2486 -4.97 0 1 0 standard suburban
74 store_C 0 context 709.12 501.23 7.1053 7.89 0 1 0 discount rural
75 store_C 1 context 651.44 492.78 7.0666 8.67 1 0 1 discount rural
76 store_C 2 context 659.15 511.04 7.5944 -1.89 1 0 2 discount rural
77 store_C 3 context 733.06 575.98 7.1462 7.08 1 0 3 discount rural
78 store_C 4 context 712.21 568.7 7.8247 -6.49 1 0 4 discount rural
79 store_C 5 context 615.23 611.44 7.3103 3.79 0 0 5 discount rural
80 store_C 6 context 568.99 561.87 7.1439 7.12 0 0 6 discount rural
81 store_C 7 context 541.12 549.54 7.921 -8.42 0 0 0 discount rural
82 store_C 8 context 583.57 576.88 7.1655 6.69 0 0 1 discount rural
83 store_C 9 context 607.34 603.04 7.2847 4.31 0 0 2 discount rural
84 store_C 10 context 613.79 606.86 7.1536 6.93 0 0 3 discount rural
85 store_C 11 context 919.49 561.8 7.1155 7.69 1 1 4 discount rural
86 store_C 12 context 622.61 613.04 7.0211 9.58 0 0 5 discount rural
87 store_C 13 context 630.52 621.63 7.0554 8.89 0 0 6 discount rural
88 store_C 14 context 721.62 715.12 7.1746 6.51 0 0 0 discount rural
89 store_C 15 context 699.18 690.25 7.0534 8.93 0 0 1 discount rural
90 store_C 16 context 578.85 580.67 7.5911 -1.82 0 0 2 discount rural
91 store_C 17 context 598.23 601.84 7.6807 -3.61 0 0 3 discount rural
92 store_C 18 context 554.43 552.3 7.3936 2.13 0 0 4 discount rural
93 store_C 19 context 587.39 583.75 7.318 3.64 0 0 5 discount rural
94 store_C 20 context 615.58 615.67 7.5045 -0.09 0 0 6 discount rural
95 store_C 21 context 638.68 646.18 7.875 -7.5 0 0 0 discount rural
96 store_C 22 context 555.99 563.01 7.8511 -7.02 0 0 1 discount rural
97 store_C 23 context 768.83 559.7 7.0435 9.13 0 1 2 discount rural
98 store_C 24 horizon 499.62 493.25 7.1815 6.37 0 0 3 discount rural
99 store_C 25 horizon 570.9 565.64 7.2367 5.27 0 0 4 discount rural
100 store_C 26 horizon 677.52 522.5 7.2494 5.01 1 0 5 discount rural
101 store_C 27 horizon 685.25 536.68 7.5712 -1.42 1 0 6 discount rural
102 store_C 28 horizon 517.46 515.78 7.4163 1.67 0 0 0 discount rural
103 store_C 29 horizon 549.38 540.36 7.0493 9.01 0 0 1 discount rural
104 store_C 30 horizon 470.04 467.51 7.3736 2.53 0 0 2 discount rural
105 store_C 31 horizon 622.9 473.37 7.5238 -0.48 1 0 3 discount rural
106 store_C 32 horizon 620.09 612.12 7.1017 7.97 0 0 4 discount rural
107 store_C 33 horizon 614.45 471.12 7.8335 -6.67 1 0 5 discount rural
108 store_C 34 horizon 484.25 475.29 7.052 8.96 0 0 6 discount rural
109 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
![Temperature Anomaly Forecast](forecast_visualization.png)
---
## 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
1 date point_forecast q10 q20 q30 q40 q50 q60 q70 q80 q90 q99
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
@@ -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
1 date anomaly_c
2 2022-01-01 0.89
3 2022-02-01 0.89
4 2022-03-01 1.02
5 2022-04-01 0.88
6 2022-05-01 0.85
7 2022-06-01 0.88
8 2022-07-01 0.88
9 2022-08-01 0.90
10 2022-09-01 0.88
11 2022-10-01 0.95
12 2022-11-01 0.77
13 2022-12-01 0.78
14 2023-01-01 0.87
15 2023-02-01 0.98
16 2023-03-01 1.21
17 2023-04-01 1.00
18 2023-05-01 0.94
19 2023-06-01 1.08
20 2023-07-01 1.18
21 2023-08-01 1.24
22 2023-09-01 1.47
23 2023-10-01 1.32
24 2023-11-01 1.18
25 2023-12-01 1.16
26 2024-01-01 1.22
27 2024-02-01 1.35
28 2024-03-01 1.34
29 2024-04-01 1.26
30 2024-05-01 1.15
31 2024-06-01 1.20
32 2024-07-01 1.24
33 2024-08-01 1.30
34 2024-09-01 1.28
35 2024-10-01 1.27
36 2024-11-01 1.22
37 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()