feat(skill): add timesfm-forecasting Agent Skill (agentskills.io)
Add a self-contained AI agent skill for TimesFM that teaches coding agents (Claude Code, OpenCode, Cursor, Codex) how to use the TimesFM API correctly — safe model loading, zero-shot forecasting, covariate workflows, anomaly detection, and the most common pitfalls. Files added: - AGENTS.md — auto-loaded skill document (root of repo) - claude-skill/scripts/check_system.py — mandatory preflight RAM/GPU/disk checker - claude-skill/scripts/forecast_csv.py — CLI wrapper for CSV forecasting - claude-skill/references/ — ForecastConfig API ref, data prep, HW reqs - claude-skill/examples/global-temperature/ — basic forecast + PNG/GIF pipeline - claude-skill/examples/anomaly-detection/ — two-phase detrend+Z-score + quantile PI - claude-skill/examples/covariates-forecasting/ — forecast_with_covariates() XReg demo - .gitattributes — Git LFS rules for PNG/GIF binary outputs Contributed by Clayton Young / Superior Byte Works LLC (@borealBytes) Apache 2.0 — same license as this repository
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
# TimesFM Forecast Report: Global Temperature Anomaly (2025)
|
||||
|
||||
**Model:** TimesFM 1.0 (200M) PyTorch
|
||||
**Generated:** 2026-02-21
|
||||
**Source:** NOAA GISTEMP Global Land-Ocean Temperature Index
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
TimesFM forecasts a mean temperature anomaly of **1.19°C** for 2025, slightly below the 2024 average of 1.25°C. The model predicts continued elevated temperatures with a peak of 1.30°C in March 2025 and a minimum of 1.06°C in December 2025.
|
||||
|
||||
---
|
||||
|
||||
## Input Data
|
||||
|
||||
### Historical Temperature Anomalies (2022-2024)
|
||||
|
||||
| Date | Anomaly (°C) | Date | Anomaly (°C) | Date | Anomaly (°C) |
|
||||
|------|-------------|------|-------------|------|-------------|
|
||||
| 2022-01 | 0.89 | 2023-01 | 0.87 | 2024-01 | 1.22 |
|
||||
| 2022-02 | 0.89 | 2023-02 | 0.98 | 2024-02 | 1.35 |
|
||||
| 2022-03 | 1.02 | 2023-03 | 1.21 | 2024-03 | 1.34 |
|
||||
| 2022-04 | 0.88 | 2023-04 | 1.00 | 2024-04 | 1.26 |
|
||||
| 2022-05 | 0.85 | 2023-05 | 0.94 | 2024-05 | 1.15 |
|
||||
| 2022-06 | 0.88 | 2023-06 | 1.08 | 2024-06 | 1.20 |
|
||||
| 2022-07 | 0.88 | 2023-07 | 1.18 | 2024-07 | 1.24 |
|
||||
| 2022-08 | 0.90 | 2023-08 | 1.24 | 2024-08 | 1.30 |
|
||||
| 2022-09 | 0.88 | 2023-09 | 1.47 | 2024-09 | 1.28 |
|
||||
| 2022-10 | 0.95 | 2023-10 | 1.32 | 2024-10 | 1.27 |
|
||||
| 2022-11 | 0.77 | 2023-11 | 1.18 | 2024-11 | 1.22 |
|
||||
| 2022-12 | 0.78 | 2023-12 | 1.16 | 2024-12 | 1.20 |
|
||||
|
||||
**Statistics:**
|
||||
- Total observations: 36 months
|
||||
- Mean anomaly: 1.09°C
|
||||
- Trend (2022→2024): +0.37°C
|
||||
|
||||
---
|
||||
|
||||
## Raw Forecast Output
|
||||
|
||||
### Point Forecast and Confidence Intervals
|
||||
|
||||
| Month | Point | 80% CI | 90% CI |
|
||||
|-------|-------|--------|--------|
|
||||
| 2025-01 | 1.259 | [1.141, 1.297] | [1.248, 1.324] |
|
||||
| 2025-02 | 1.286 | [1.141, 1.340] | [1.277, 1.375] |
|
||||
| 2025-03 | 1.295 | [1.127, 1.355] | [1.287, 1.404] |
|
||||
| 2025-04 | 1.221 | [1.035, 1.290] | [1.208, 1.331] |
|
||||
| 2025-05 | 1.170 | [0.969, 1.239] | [1.153, 1.289] |
|
||||
| 2025-06 | 1.146 | [0.942, 1.218] | [1.128, 1.270] |
|
||||
| 2025-07 | 1.170 | [0.950, 1.248] | [1.151, 1.300] |
|
||||
| 2025-08 | 1.203 | [0.971, 1.284] | [1.186, 1.341] |
|
||||
| 2025-09 | 1.191 | [0.959, 1.283] | [1.178, 1.335] |
|
||||
| 2025-10 | 1.149 | [0.908, 1.240] | [1.126, 1.287] |
|
||||
| 2025-11 | 1.080 | [0.836, 1.176] | [1.062, 1.228] |
|
||||
| 2025-12 | 1.061 | [0.802, 1.153] | [1.037, 1.217] |
|
||||
|
||||
### JSON Output
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "TimesFM 1.0 (200M) PyTorch",
|
||||
"input": {
|
||||
"source": "NOAA GISTEMP Global Temperature Anomaly",
|
||||
"n_observations": 36,
|
||||
"date_range": "2022-01 to 2024-12",
|
||||
"mean_anomaly_c": 1.089
|
||||
},
|
||||
"forecast": {
|
||||
"horizon": 12,
|
||||
"dates": ["2025-01", "2025-02", "2025-03", "2025-04", "2025-05", "2025-06",
|
||||
"2025-07", "2025-08", "2025-09", "2025-10", "2025-11", "2025-12"],
|
||||
"point": [1.259, 1.286, 1.295, 1.221, 1.170, 1.146, 1.170, 1.203, 1.191, 1.149, 1.080, 1.061]
|
||||
},
|
||||
"summary": {
|
||||
"forecast_mean_c": 1.186,
|
||||
"forecast_max_c": 1.295,
|
||||
"forecast_min_c": 1.061,
|
||||
"vs_last_year_mean": -0.067
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visualization
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### Key Observations
|
||||
|
||||
1. **Slight cooling trend expected**: The model forecasts a mean anomaly 0.07°C below 2024 levels, suggesting a potential stabilization after the record-breaking temperatures of 2023-2024.
|
||||
|
||||
2. **Seasonal pattern preserved**: The forecast shows the expected seasonal variation with higher anomalies in late winter (Feb-Mar) and lower in late fall (Nov-Dec).
|
||||
|
||||
3. **Widening uncertainty**: The 90% CI expands from ±0.04°C in January to ±0.08°C in December, reflecting typical forecast uncertainty growth over time.
|
||||
|
||||
4. **Peak temperature**: March 2025 is predicted to have the highest anomaly at 1.30°C, potentially approaching the September 2023 record of 1.47°C.
|
||||
|
||||
### Limitations
|
||||
|
||||
- TimesFM is a zero-shot forecaster without physical climate model constraints
|
||||
- The 36-month training window may not capture multi-decadal climate trends
|
||||
- El Niño/La Niña cycles are not explicitly modeled
|
||||
|
||||
### Recommendations
|
||||
|
||||
- Use this forecast as a baseline comparison for physics-based climate models
|
||||
- Update forecast quarterly as new observations become available
|
||||
- Consider ensemble approaches combining TimesFM with other methods
|
||||
|
||||
---
|
||||
|
||||
## Reproducibility
|
||||
|
||||
### Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `temperature_anomaly.csv` | Input data (36 months) |
|
||||
| `forecast_output.csv` | Point forecast with quantiles |
|
||||
| `forecast_output.json` | Machine-readable forecast |
|
||||
| `forecast_visualization.png` | Fan chart visualization |
|
||||
| `run_forecast.py` | Forecasting script |
|
||||
| `visualize_forecast.py` | Visualization script |
|
||||
| `run_example.sh` | One-click runner |
|
||||
|
||||
### How to Reproduce
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv pip install "timesfm[torch]" matplotlib pandas numpy
|
||||
|
||||
# Run the complete example
|
||||
cd scientific-skills/timesfm-forecasting/examples/global-temperature
|
||||
./run_example.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### API Discovery
|
||||
|
||||
The TimesFM PyTorch API differs from the GitHub README documentation:
|
||||
|
||||
**Documented (GitHub README):**
|
||||
```python
|
||||
model = timesfm.TimesFm(
|
||||
context_len=512,
|
||||
horizon_len=128,
|
||||
backend="gpu",
|
||||
)
|
||||
model.load_from_google_repo("google/timesfm-2.5-200m-pytorch")
|
||||
```
|
||||
|
||||
**Actual Working API:**
|
||||
```python
|
||||
hparams = timesfm.TimesFmHparams(horizon_len=12)
|
||||
checkpoint = timesfm.TimesFmCheckpoint(
|
||||
huggingface_repo_id="google/timesfm-1.0-200m-pytorch"
|
||||
)
|
||||
model = timesfm.TimesFm(hparams=hparams, checkpoint=checkpoint)
|
||||
```
|
||||
|
||||
### TimesFM 2.5 PyTorch Issue
|
||||
|
||||
The `google/timesfm-2.5-200m-pytorch` checkpoint downloads as `model.safetensors`, but the TimesFM loader expects `torch_model.ckpt`. This causes a `FileNotFoundError` at model load time. Using TimesFM 1.0 PyTorch resolves this issue.
|
||||
|
||||
---
|
||||
|
||||
*Report generated by TimesFM Forecasting Skill (claude-scientific-skills)*
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate animation data for interactive forecast visualization.
|
||||
|
||||
This script runs TimesFM forecasts incrementally, starting with minimal data
|
||||
and adding one point at a time. Each forecast extends to the final date (2025-12).
|
||||
|
||||
Output: animation_data.json with all forecast steps
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import timesfm
|
||||
|
||||
# Configuration
|
||||
MIN_CONTEXT = 12 # Minimum points to start forecasting
|
||||
MAX_HORIZON = (
|
||||
36 # Max forecast length (when we have 12 points, forecast 36 months to 2025-12)
|
||||
)
|
||||
TOTAL_MONTHS = 48 # Total months from 2022-01 to 2025-12 (graph extent)
|
||||
INPUT_FILE = Path(__file__).parent / "temperature_anomaly.csv"
|
||||
OUTPUT_FILE = Path(__file__).parent / "output" / "animation_data.json"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 60)
|
||||
print(" TIMESFM ANIMATION DATA GENERATOR")
|
||||
print(" Dynamic horizon - forecasts always reach 2025-12")
|
||||
print("=" * 60)
|
||||
|
||||
# Load data
|
||||
df = pd.read_csv(INPUT_FILE, parse_dates=["date"])
|
||||
df = df.sort_values("date").reset_index(drop=True)
|
||||
|
||||
all_dates = df["date"].tolist()
|
||||
all_values = df["anomaly_c"].values.astype(np.float32)
|
||||
|
||||
print(f"\n📊 Total data: {len(all_values)} months")
|
||||
print(
|
||||
f" Date range: {all_dates[0].strftime('%Y-%m')} to {all_dates[-1].strftime('%Y-%m')}"
|
||||
)
|
||||
print(f" Animation steps: {len(all_values) - MIN_CONTEXT + 1}")
|
||||
|
||||
# Load TimesFM with max horizon (will truncate output for shorter forecasts)
|
||||
print(f"\n🤖 Loading TimesFM 1.0 (200M) PyTorch (horizon={MAX_HORIZON})...")
|
||||
hparams = timesfm.TimesFmHparams(horizon_len=MAX_HORIZON)
|
||||
checkpoint = timesfm.TimesFmCheckpoint(
|
||||
huggingface_repo_id="google/timesfm-1.0-200m-pytorch"
|
||||
)
|
||||
model = timesfm.TimesFm(hparams=hparams, checkpoint=checkpoint)
|
||||
|
||||
# Generate forecasts for each step
|
||||
animation_steps = []
|
||||
|
||||
for n_points in range(MIN_CONTEXT, len(all_values) + 1):
|
||||
step_num = n_points - MIN_CONTEXT + 1
|
||||
total_steps = len(all_values) - MIN_CONTEXT + 1
|
||||
|
||||
# Calculate dynamic horizon: forecast enough to reach 2025-12
|
||||
horizon = TOTAL_MONTHS - n_points
|
||||
|
||||
print(
|
||||
f"\n📈 Step {step_num}/{total_steps}: Using {n_points} points, forecasting {horizon} months..."
|
||||
)
|
||||
|
||||
# Get historical data up to this point
|
||||
historical_values = all_values[:n_points]
|
||||
historical_dates = all_dates[:n_points]
|
||||
|
||||
# Run forecast (model outputs MAX_HORIZON, we truncate to actual horizon)
|
||||
point, quantiles = model.forecast(
|
||||
[historical_values],
|
||||
freq=[0],
|
||||
)
|
||||
|
||||
# Truncate to actual horizon
|
||||
point = point[0][:horizon]
|
||||
quantiles = quantiles[0, :horizon, :]
|
||||
|
||||
# Determine forecast dates
|
||||
last_date = historical_dates[-1]
|
||||
forecast_dates = pd.date_range(
|
||||
start=last_date + pd.DateOffset(months=1),
|
||||
periods=horizon,
|
||||
freq="MS",
|
||||
)
|
||||
|
||||
# Store step data
|
||||
step_data = {
|
||||
"step": step_num,
|
||||
"n_points": n_points,
|
||||
"horizon": horizon,
|
||||
"last_historical_date": historical_dates[-1].strftime("%Y-%m"),
|
||||
"historical_dates": [d.strftime("%Y-%m") for d in historical_dates],
|
||||
"historical_values": historical_values.tolist(),
|
||||
"forecast_dates": [d.strftime("%Y-%m") for d in forecast_dates],
|
||||
"point_forecast": point.tolist(),
|
||||
"q10": quantiles[:, 0].tolist(),
|
||||
"q20": quantiles[:, 1].tolist(),
|
||||
"q80": quantiles[:, 7].tolist(),
|
||||
"q90": quantiles[:, 8].tolist(),
|
||||
}
|
||||
|
||||
animation_steps.append(step_data)
|
||||
|
||||
# Show summary
|
||||
print(f" Last date: {historical_dates[-1].strftime('%Y-%m')}")
|
||||
print(f" Forecast to: {forecast_dates[-1].strftime('%Y-%m')}")
|
||||
print(f" Forecast mean: {point.mean():.3f}°C")
|
||||
|
||||
# Create output
|
||||
output = {
|
||||
"metadata": {
|
||||
"model": "TimesFM 1.0 (200M) PyTorch",
|
||||
"total_steps": len(animation_steps),
|
||||
"min_context": MIN_CONTEXT,
|
||||
"max_horizon": MAX_HORIZON,
|
||||
"total_months": TOTAL_MONTHS,
|
||||
"data_source": "NOAA GISTEMP Global Temperature Anomaly",
|
||||
"full_date_range": f"{all_dates[0].strftime('%Y-%m')} to {all_dates[-1].strftime('%Y-%m')}",
|
||||
},
|
||||
"actual_data": {
|
||||
"dates": [d.strftime("%Y-%m") for d in all_dates],
|
||||
"values": all_values.tolist(),
|
||||
},
|
||||
"animation_steps": animation_steps,
|
||||
}
|
||||
|
||||
# Save
|
||||
with open(OUTPUT_FILE, "w") as f:
|
||||
json.dump(output, f, indent=2)
|
||||
|
||||
print(f"\n" + "=" * 60)
|
||||
print(" ✅ ANIMATION DATA COMPLETE")
|
||||
print("=" * 60)
|
||||
print(f"\n📁 Output: {OUTPUT_FILE}")
|
||||
print(f" Total steps: {len(animation_steps)}")
|
||||
print(f" Each forecast extends to 2025-12")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate animated GIF showing forecast evolution.
|
||||
|
||||
Creates a GIF animation showing how the TimesFM forecast changes
|
||||
as more historical data points are added. Shows the full actual data as a background layer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.dates as mdates
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from PIL import Image
|
||||
|
||||
# Configuration
|
||||
EXAMPLE_DIR = Path(__file__).parent
|
||||
DATA_FILE = EXAMPLE_DIR / "output" / "animation_data.json"
|
||||
OUTPUT_FILE = EXAMPLE_DIR / "output" / "forecast_animation.gif"
|
||||
DURATION_MS = 500 # Time per frame in milliseconds
|
||||
|
||||
|
||||
def create_frame(
|
||||
ax,
|
||||
step_data: dict,
|
||||
actual_data: dict,
|
||||
final_forecast: dict,
|
||||
total_steps: int,
|
||||
x_min,
|
||||
x_max,
|
||||
y_min,
|
||||
y_max,
|
||||
) -> None:
|
||||
"""Create a single frame of the animation with fixed axes."""
|
||||
ax.clear()
|
||||
|
||||
# Parse dates
|
||||
historical_dates = pd.to_datetime(step_data["historical_dates"])
|
||||
forecast_dates = pd.to_datetime(step_data["forecast_dates"])
|
||||
|
||||
# Get final forecast dates for full extent
|
||||
final_forecast_dates = pd.to_datetime(final_forecast["forecast_dates"])
|
||||
|
||||
# All actual dates for full background
|
||||
all_actual_dates = pd.to_datetime(actual_data["dates"])
|
||||
all_actual_values = np.array(actual_data["values"])
|
||||
|
||||
# ========== BACKGROUND LAYER: Full actual data (faded) ==========
|
||||
ax.plot(
|
||||
all_actual_dates,
|
||||
all_actual_values,
|
||||
color="#9ca3af",
|
||||
linewidth=1,
|
||||
marker="o",
|
||||
markersize=2,
|
||||
alpha=0.3,
|
||||
label="All observed data",
|
||||
zorder=1,
|
||||
)
|
||||
|
||||
# ========== BACKGROUND LAYER: Final forecast (faded) ==========
|
||||
ax.plot(
|
||||
final_forecast_dates,
|
||||
final_forecast["point_forecast"],
|
||||
color="#fca5a5",
|
||||
linewidth=1,
|
||||
linestyle="--",
|
||||
marker="s",
|
||||
markersize=2,
|
||||
alpha=0.3,
|
||||
label="Final forecast",
|
||||
zorder=2,
|
||||
)
|
||||
|
||||
# ========== FOREGROUND LAYER: Historical data used (bright) ==========
|
||||
ax.plot(
|
||||
historical_dates,
|
||||
step_data["historical_values"],
|
||||
color="#3b82f6",
|
||||
linewidth=2.5,
|
||||
marker="o",
|
||||
markersize=5,
|
||||
label="Data used",
|
||||
zorder=10,
|
||||
)
|
||||
|
||||
# ========== FOREGROUND LAYER: Current forecast (bright) ==========
|
||||
# 90% CI (outer)
|
||||
ax.fill_between(
|
||||
forecast_dates,
|
||||
step_data["q10"],
|
||||
step_data["q90"],
|
||||
alpha=0.15,
|
||||
color="#ef4444",
|
||||
zorder=5,
|
||||
)
|
||||
|
||||
# 80% CI (inner)
|
||||
ax.fill_between(
|
||||
forecast_dates,
|
||||
step_data["q20"],
|
||||
step_data["q80"],
|
||||
alpha=0.25,
|
||||
color="#ef4444",
|
||||
zorder=6,
|
||||
)
|
||||
|
||||
# Forecast line
|
||||
ax.plot(
|
||||
forecast_dates,
|
||||
step_data["point_forecast"],
|
||||
color="#ef4444",
|
||||
linewidth=2.5,
|
||||
marker="s",
|
||||
markersize=5,
|
||||
label="Forecast",
|
||||
zorder=7,
|
||||
)
|
||||
|
||||
# ========== Vertical line at forecast boundary ==========
|
||||
ax.axvline(
|
||||
x=historical_dates[-1],
|
||||
color="#6b7280",
|
||||
linestyle="--",
|
||||
linewidth=1.5,
|
||||
alpha=0.7,
|
||||
zorder=8,
|
||||
)
|
||||
|
||||
# ========== Formatting ==========
|
||||
ax.set_xlabel("Date", fontsize=11)
|
||||
ax.set_ylabel("Temperature Anomaly (°C)", fontsize=11)
|
||||
ax.set_title(
|
||||
f"TimesFM Forecast Evolution\n"
|
||||
f"Step {step_data['step']}/{total_steps}: {step_data['n_points']} points → "
|
||||
f"forecast from {step_data['last_historical_date']}",
|
||||
fontsize=13,
|
||||
fontweight="bold",
|
||||
)
|
||||
|
||||
ax.grid(True, alpha=0.3, zorder=0)
|
||||
ax.legend(loc="upper left", fontsize=8)
|
||||
|
||||
# FIXED AXES - same for all frames
|
||||
ax.set_xlim(x_min, x_max)
|
||||
ax.set_ylim(y_min, y_max)
|
||||
|
||||
# Format x-axis
|
||||
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
|
||||
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=4))
|
||||
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha="right")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 60)
|
||||
print(" GENERATING ANIMATED GIF")
|
||||
print("=" * 60)
|
||||
|
||||
# Load data
|
||||
with open(DATA_FILE) as f:
|
||||
data = json.load(f)
|
||||
|
||||
total_steps = len(data["animation_steps"])
|
||||
print(f"\n📊 Total frames: {total_steps}")
|
||||
|
||||
# Get the final forecast step for reference
|
||||
final_forecast = data["animation_steps"][-1]
|
||||
|
||||
# Calculate fixed axis extents from ALL data
|
||||
all_actual_dates = pd.to_datetime(data["actual_data"]["dates"])
|
||||
all_actual_values = np.array(data["actual_data"]["values"])
|
||||
|
||||
final_forecast_dates = pd.to_datetime(final_forecast["forecast_dates"])
|
||||
final_forecast_values = np.array(final_forecast["point_forecast"])
|
||||
|
||||
# X-axis: from first actual date to last forecast date
|
||||
x_min = all_actual_dates[0]
|
||||
x_max = final_forecast_dates[-1]
|
||||
|
||||
# Y-axis: min/max across all actual + all forecasts with CIs
|
||||
all_forecast_q10 = np.array(final_forecast["q10"])
|
||||
all_forecast_q90 = np.array(final_forecast["q90"])
|
||||
|
||||
all_values = np.concatenate([
|
||||
all_actual_values,
|
||||
final_forecast_values,
|
||||
all_forecast_q10,
|
||||
all_forecast_q90,
|
||||
])
|
||||
y_min = all_values.min() - 0.05
|
||||
y_max = all_values.max() + 0.05
|
||||
|
||||
print(f" X-axis: {x_min.strftime('%Y-%m')} to {x_max.strftime('%Y-%m')}")
|
||||
print(f" Y-axis: {y_min:.2f}°C to {y_max:.2f}°C")
|
||||
|
||||
# Create figure
|
||||
fig, ax = plt.subplots(figsize=(12, 6))
|
||||
|
||||
# Generate frames
|
||||
frames = []
|
||||
|
||||
for i, step in enumerate(data["animation_steps"]):
|
||||
print(f" Frame {i + 1}/{total_steps}...")
|
||||
|
||||
create_frame(
|
||||
ax,
|
||||
step,
|
||||
data["actual_data"],
|
||||
final_forecast,
|
||||
total_steps,
|
||||
x_min,
|
||||
x_max,
|
||||
y_min,
|
||||
y_max,
|
||||
)
|
||||
|
||||
# Save frame to buffer
|
||||
fig.canvas.draw()
|
||||
|
||||
# Convert to PIL Image
|
||||
buf = fig.canvas.buffer_rgba()
|
||||
width, height = fig.canvas.get_width_height()
|
||||
img = Image.frombytes("RGBA", (width, height), buf)
|
||||
frames.append(img.convert("RGB"))
|
||||
|
||||
plt.close()
|
||||
|
||||
# Save as GIF
|
||||
print(f"\n💾 Saving GIF: {OUTPUT_FILE}")
|
||||
frames[0].save(
|
||||
OUTPUT_FILE,
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
duration=DURATION_MS,
|
||||
loop=0, # Loop forever
|
||||
)
|
||||
|
||||
# Get file size
|
||||
size_kb = OUTPUT_FILE.stat().st_size / 1024
|
||||
print(f" File size: {size_kb:.1f} KB")
|
||||
print(f"\n✅ Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,544 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a self-contained HTML file with embedded animation data.
|
||||
|
||||
This creates a single HTML file that can be opened directly in any browser
|
||||
without needing a server or external JSON file (CORS-safe).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
EXAMPLE_DIR = Path(__file__).parent
|
||||
DATA_FILE = EXAMPLE_DIR / "output" / "animation_data.json"
|
||||
OUTPUT_FILE = EXAMPLE_DIR / "output" / "interactive_forecast.html"
|
||||
|
||||
|
||||
HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TimesFM Interactive Forecast Animation</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
color: #e0e0e0;
|
||||
padding: 20px;
|
||||
}}
|
||||
|
||||
.container {{ max-width: 1200px; margin: 0 auto; }}
|
||||
|
||||
header {{ text-align: center; margin-bottom: 30px; }}
|
||||
|
||||
h1 {{
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
background: linear-gradient(90deg, #60a5fa, #a78bfa);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}}
|
||||
|
||||
.subtitle {{ color: #9ca3af; font-size: 1.1rem; }}
|
||||
|
||||
.chart-container {{
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}}
|
||||
|
||||
#chart {{ width: 100% !important; height: 450px !important; }}
|
||||
|
||||
.controls {{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
}}
|
||||
|
||||
.slider-container {{ display: flex; flex-direction: column; gap: 10px; }}
|
||||
|
||||
.slider-label {{ display: flex; justify-content: space-between; align-items: center; }}
|
||||
.slider-label span {{ font-size: 0.9rem; color: #9ca3af; }}
|
||||
.slider-label .value {{ font-weight: 600; color: #60a5fa; font-size: 1.1rem; }}
|
||||
|
||||
input[type="range"] {{
|
||||
width: 100%; height: 8px; border-radius: 4px;
|
||||
background: #374151; outline: none; -webkit-appearance: none;
|
||||
}}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {{
|
||||
-webkit-appearance: none;
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #60a5fa, #a78bfa);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(96, 165, 250, 0.5);
|
||||
}}
|
||||
|
||||
.buttons {{ display: flex; gap: 10px; flex-wrap: wrap; }}
|
||||
|
||||
button {{
|
||||
flex: 1; min-width: 100px;
|
||||
padding: 12px 20px;
|
||||
border: none; border-radius: 8px;
|
||||
font-size: 1rem; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.2s ease;
|
||||
}}
|
||||
|
||||
.btn-primary {{
|
||||
background: linear-gradient(135deg, #60a5fa, #a78bfa);
|
||||
color: white;
|
||||
}}
|
||||
.btn-primary:hover {{ transform: translateY(-2px); box-shadow: 0 4px 15px rgba(96, 165, 250, 0.4); }}
|
||||
|
||||
.btn-secondary {{ background: #374151; color: #e0e0e0; }}
|
||||
.btn-secondary:hover {{ background: #4b5563; }}
|
||||
|
||||
.stats {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}}
|
||||
|
||||
.stat-card {{
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
}}
|
||||
.stat-card .label {{ font-size: 0.8rem; color: #9ca3af; margin-bottom: 5px; }}
|
||||
.stat-card .value {{ font-size: 1.3rem; font-weight: 600; color: #60a5fa; }}
|
||||
|
||||
.legend {{
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}}
|
||||
|
||||
.legend-item {{ display: flex; align-items: center; gap: 8px; font-size: 0.85rem; }}
|
||||
.legend-color {{ width: 16px; height: 16px; border-radius: 4px; }}
|
||||
|
||||
footer {{
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}}
|
||||
footer a {{ color: #60a5fa; text-decoration: none; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>TimesFM Forecast Evolution</h1>
|
||||
<p class="subtitle">Watch the forecast evolve as more data is added — forecasts extend to 2025-12</p>
|
||||
</header>
|
||||
|
||||
<div class="chart-container">
|
||||
<canvas id="chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="slider-container">
|
||||
<div class="slider-label">
|
||||
<span>Data Points Used</span>
|
||||
<span class="value" id="points-value">12 / 36</span>
|
||||
</div>
|
||||
<input type="range" id="slider" min="0" max="24" value="0" step="1">
|
||||
<div class="slider-label">
|
||||
<span>2022-01</span>
|
||||
<span id="date-end">Using data through 2022-12</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button class="btn-primary" id="play-btn">▶ Play</button>
|
||||
<button class="btn-secondary" id="reset-btn">↺ Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="label">Forecast Mean</div>
|
||||
<div class="value" id="stat-mean">0.86°C</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Forecast Horizon</div>
|
||||
<div class="value" id="stat-horizon">36 months</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Forecast Max</div>
|
||||
<div class="value" id="stat-max">--</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Forecast Min</div>
|
||||
<div class="value" id="stat-min">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #9ca3af;"></div>
|
||||
<span>All Observed Data</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #fca5a5;"></div>
|
||||
<span>Final Forecast (reference)</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #3b82f6;"></div>
|
||||
<span>Data Used</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #ef4444;"></div>
|
||||
<span>Current Forecast</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: rgba(239, 68, 68, 0.25);"></div>
|
||||
<span>80% CI</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>TimesFM 1.0 (200M) PyTorch • <a href="https://github.com/google-research/timesfm">Google Research</a></p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Embedded animation data (no external fetch needed)
|
||||
const animationData = {data_json};
|
||||
|
||||
let chart = null;
|
||||
let isPlaying = false;
|
||||
let playInterval = null;
|
||||
let currentStep = 0;
|
||||
|
||||
// Fixed axis extents
|
||||
let allDates = [];
|
||||
let yMin = 0.7;
|
||||
let yMax = 1.55;
|
||||
|
||||
function initChart() {{
|
||||
const ctx = document.getElementById('chart').getContext('2d');
|
||||
|
||||
// Calculate fixed extents
|
||||
const finalStep = animationData.animation_steps[animationData.animation_steps.length - 1];
|
||||
allDates = [
|
||||
...animationData.actual_data.dates,
|
||||
...finalStep.forecast_dates
|
||||
];
|
||||
|
||||
// Y extent from all values
|
||||
const allValues = [
|
||||
...animationData.actual_data.values,
|
||||
...finalStep.point_forecast,
|
||||
...finalStep.q10,
|
||||
...finalStep.q90
|
||||
];
|
||||
yMin = Math.min(...allValues) - 0.05;
|
||||
yMax = Math.max(...allValues) + 0.05;
|
||||
|
||||
chart = new Chart(ctx, {{
|
||||
type: 'line',
|
||||
data: {{
|
||||
labels: allDates,
|
||||
datasets: [
|
||||
{{
|
||||
label: 'All Observed',
|
||||
data: animationData.actual_data.values.map((v, i) => ({{x: animationData.actual_data.dates[i], y: v}})),
|
||||
borderColor: '#9ca3af',
|
||||
borderWidth: 1,
|
||||
pointRadius: 2,
|
||||
pointBackgroundColor: '#9ca3af',
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
order: 1,
|
||||
}},
|
||||
{{
|
||||
label: 'Final Forecast',
|
||||
data: [...Array(animationData.actual_data.dates.length).fill(null), ...finalStep.point_forecast],
|
||||
borderColor: '#fca5a5',
|
||||
borderWidth: 1,
|
||||
borderDash: [4, 4],
|
||||
pointRadius: 2,
|
||||
pointBackgroundColor: '#fca5a5',
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
order: 2,
|
||||
}},
|
||||
{{
|
||||
label: 'Data Used',
|
||||
data: [],
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#3b82f6',
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
order: 10,
|
||||
}},
|
||||
{{
|
||||
label: '90% CI Lower',
|
||||
data: [],
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||
fill: '+1',
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 5,
|
||||
}},
|
||||
{{
|
||||
label: '90% CI Upper',
|
||||
data: [],
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 5,
|
||||
}},
|
||||
{{
|
||||
label: '80% CI Lower',
|
||||
data: [],
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.2)',
|
||||
fill: '+1',
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 6,
|
||||
}},
|
||||
{{
|
||||
label: '80% CI Upper',
|
||||
data: [],
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.2)',
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 6,
|
||||
}},
|
||||
{{
|
||||
label: 'Forecast',
|
||||
data: [],
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#ef4444',
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
order: 7,
|
||||
}},
|
||||
]
|
||||
}},
|
||||
options: {{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {{ intersect: false, mode: 'index' }},
|
||||
plugins: {{
|
||||
legend: {{ display: false }},
|
||||
tooltip: {{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
padding: 12,
|
||||
}},
|
||||
}},
|
||||
scales: {{
|
||||
x: {{
|
||||
grid: {{ color: 'rgba(255, 255, 255, 0.05)' }},
|
||||
ticks: {{ color: '#9ca3af', maxRotation: 45, minRotation: 45 }},
|
||||
}},
|
||||
y: {{
|
||||
grid: {{ color: 'rgba(255, 255, 255, 0.05)' }},
|
||||
ticks: {{
|
||||
color: '#9ca3af',
|
||||
callback: v => v.toFixed(2) + '°C'
|
||||
}},
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
}},
|
||||
}},
|
||||
animation: {{ duration: 150 }},
|
||||
}},
|
||||
}});
|
||||
}}
|
||||
|
||||
function updateChart(stepIndex) {{
|
||||
if (!animationData || !chart) return;
|
||||
|
||||
const step = animationData.animation_steps[stepIndex];
|
||||
const finalStep = animationData.animation_steps[animationData.animation_steps.length - 1];
|
||||
const actual = animationData.actual_data;
|
||||
|
||||
// Build data arrays for each dataset
|
||||
const nHist = step.historical_dates.length;
|
||||
const nForecast = step.forecast_dates.length;
|
||||
const nActual = actual.dates.length;
|
||||
const nFinalForecast = finalStep.forecast_dates.length;
|
||||
const totalPoints = nActual + nFinalForecast;
|
||||
|
||||
// Dataset 0: All observed (always full)
|
||||
chart.data.datasets[0].data = actual.values.map((v, i) => ({{x: actual.dates[i], y: v}}));
|
||||
|
||||
// Dataset 1: Final forecast reference (always full)
|
||||
chart.data.datasets[1].data = [
|
||||
...Array(nActual).fill(null),
|
||||
...finalStep.point_forecast
|
||||
];
|
||||
|
||||
// Dataset 2: Data used (historical only)
|
||||
const dataUsed = [];
|
||||
for (let i = 0; i < totalPoints; i++) {{
|
||||
if (i < nHist) {{
|
||||
dataUsed.push(step.historical_values[i]);
|
||||
}} else {{
|
||||
dataUsed.push(null);
|
||||
}}
|
||||
}}
|
||||
chart.data.datasets[2].data = dataUsed;
|
||||
|
||||
// Datasets 3-6: CIs (forecast only)
|
||||
const forecastOffset = nActual;
|
||||
const q90Lower = [];
|
||||
const q90Upper = [];
|
||||
const q80Lower = [];
|
||||
const q80Upper = [];
|
||||
|
||||
for (let i = 0; i < totalPoints; i++) {{
|
||||
const forecastIdx = i - forecastOffset;
|
||||
if (forecastIdx >= 0 && forecastIdx < nForecast) {{
|
||||
q90Lower.push(step.q10[forecastIdx]);
|
||||
q90Upper.push(step.q90[forecastIdx]);
|
||||
q80Lower.push(step.q20[forecastIdx]);
|
||||
q80Upper.push(step.q80[forecastIdx]);
|
||||
}} else {{
|
||||
q90Lower.push(null);
|
||||
q90Upper.push(null);
|
||||
q80Lower.push(null);
|
||||
q80Upper.push(null);
|
||||
}}
|
||||
}}
|
||||
chart.data.datasets[3].data = q90Lower;
|
||||
chart.data.datasets[4].data = q90Upper;
|
||||
chart.data.datasets[5].data = q80Lower;
|
||||
chart.data.datasets[6].data = q80Upper;
|
||||
|
||||
// Dataset 7: Forecast line
|
||||
const forecastData = [];
|
||||
for (let i = 0; i < totalPoints; i++) {{
|
||||
const forecastIdx = i - forecastOffset;
|
||||
if (forecastIdx >= 0 && forecastIdx < nForecast) {{
|
||||
forecastData.push(step.point_forecast[forecastIdx]);
|
||||
}} else {{
|
||||
forecastData.push(null);
|
||||
}}
|
||||
}}
|
||||
chart.data.datasets[7].data = forecastData;
|
||||
|
||||
chart.update('none');
|
||||
|
||||
// Update UI
|
||||
document.getElementById('slider').value = stepIndex;
|
||||
document.getElementById('points-value').textContent = `${{step.n_points}} / 36`;
|
||||
document.getElementById('date-end').textContent = `Using data through ${{step.last_historical_date}}`;
|
||||
|
||||
// Stats
|
||||
const mean = (step.point_forecast.reduce((a, b) => a + b, 0) / step.point_forecast.length).toFixed(3);
|
||||
const max = Math.max(...step.point_forecast).toFixed(3);
|
||||
const min = Math.min(...step.point_forecast).toFixed(3);
|
||||
|
||||
document.getElementById('stat-mean').textContent = mean + '°C';
|
||||
document.getElementById('stat-horizon').textContent = step.horizon + ' months';
|
||||
document.getElementById('stat-max').textContent = max + '°C';
|
||||
document.getElementById('stat-min').textContent = min + '°C';
|
||||
|
||||
currentStep = stepIndex;
|
||||
}}
|
||||
|
||||
document.getElementById('slider').addEventListener('input', e => {{
|
||||
updateChart(parseInt(e.target.value));
|
||||
}});
|
||||
|
||||
document.getElementById('play-btn').addEventListener('click', () => {{
|
||||
const btn = document.getElementById('play-btn');
|
||||
if (isPlaying) {{
|
||||
clearInterval(playInterval);
|
||||
btn.textContent = '▶ Play';
|
||||
isPlaying = false;
|
||||
}} else {{
|
||||
btn.textContent = '⏸ Pause';
|
||||
isPlaying = true;
|
||||
if (currentStep >= animationData.animation_steps.length - 1) currentStep = 0;
|
||||
playInterval = setInterval(() => {{
|
||||
if (currentStep >= animationData.animation_steps.length - 1) {{
|
||||
clearInterval(playInterval);
|
||||
document.getElementById('play-btn').textContent = '▶ Play';
|
||||
isPlaying = false;
|
||||
}} else {{
|
||||
currentStep++;
|
||||
updateChart(currentStep);
|
||||
}}
|
||||
}}, 400);
|
||||
}}
|
||||
}});
|
||||
|
||||
document.getElementById('reset-btn').addEventListener('click', () => {{
|
||||
if (isPlaying) {{
|
||||
clearInterval(playInterval);
|
||||
document.getElementById('play-btn').textContent = '▶ Play';
|
||||
isPlaying = false;
|
||||
}}
|
||||
updateChart(0);
|
||||
}});
|
||||
|
||||
// Initialize on load
|
||||
initChart();
|
||||
updateChart(0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 60)
|
||||
print(" GENERATING SELF-CONTAINED HTML")
|
||||
print("=" * 60)
|
||||
|
||||
# Load animation data
|
||||
with open(DATA_FILE) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Generate HTML with embedded data
|
||||
html_content = HTML_TEMPLATE.format(data_json=json.dumps(data, indent=2))
|
||||
|
||||
# Write output
|
||||
with open(OUTPUT_FILE, "w") as f:
|
||||
f.write(html_content)
|
||||
|
||||
size_kb = OUTPUT_FILE.stat().st_size / 1024
|
||||
print(f"\n✅ Generated: {OUTPUT_FILE}")
|
||||
print(f" File size: {size_kb:.1f} KB")
|
||||
print(f" Fully self-contained — no external dependencies")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 776 KiB |
@@ -0,0 +1,13 @@
|
||||
date,point_forecast,q10,q20,q30,q40,q50,q60,q70,q80,q90,q99
|
||||
2025-01-01,1.2593384,1.248188,1.140702,1.1880752,1.2137158,1.2394564,1.2593384,1.2767732,1.297132,1.32396,1.367888
|
||||
2025-02-01,1.2856668,1.2773758,1.1406044,1.1960833,1.2322671,1.2593892,1.2856668,1.3110137,1.3400218,1.3751202,1.4253658
|
||||
2025-03-01,1.2950127,1.2869918,1.126852,1.1876173,1.234988,1.2675052,1.2950127,1.328448,1.354729,1.4035482,1.4642649
|
||||
2025-04-01,1.2207624,1.2084007,1.0352504,1.1041918,1.151865,1.1853008,1.2207624,1.256663,1.2898555,1.3310349,1.4016538
|
||||
2025-05-01,1.1702554,1.153313,0.9691495,1.0431063,1.0932612,1.1276176,1.1702554,1.201966,1.2390311,1.2891905,1.3632389
|
||||
2025-06-01,1.1455553,1.1275499,0.94203794,1.0110554,1.0658777,1.1061188,1.1455553,1.1806211,1.2180579,1.2702757,1.345366
|
||||
2025-07-01,1.1702348,1.1510556,0.9503718,1.0347577,1.0847733,1.1287677,1.1702348,1.2114835,1.2482276,1.2997853,1.3807325
|
||||
2025-08-01,1.2026825,1.1859496,0.9709255,1.0594383,1.1106675,1.1579902,1.2026825,1.2399211,1.2842004,1.3408126,1.419526
|
||||
2025-09-01,1.1909748,1.1784849,0.95943713,1.0403702,1.103606,1.1511956,1.1909748,1.2390201,1.2832941,1.3354731,1.416972
|
||||
2025-10-01,1.1490841,1.1264795,0.9079477,0.99529266,1.0548235,1.1052223,1.1490841,1.1897774,1.240414,1.2868769,1.3775467
|
||||
2025-11-01,1.0804785,1.0624356,0.8361266,0.9259792,0.9882403,1.0386353,1.0804785,1.1281581,1.1759715,1.228377,1.3122478
|
||||
2025-12-01,1.0613453,1.0366092,0.80220693,0.89521873,0.9593707,1.0152239,1.0613453,1.1032857,1.15315,1.216908,1.2959521
|
||||
|
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"model": "TimesFM 1.0 (200M) PyTorch",
|
||||
"input": {
|
||||
"source": "NOAA GISTEMP Global Temperature Anomaly",
|
||||
"n_observations": 36,
|
||||
"date_range": "2022-01 to 2024-12",
|
||||
"mean_anomaly_c": 1.09
|
||||
},
|
||||
"forecast": {
|
||||
"horizon": 12,
|
||||
"dates": [
|
||||
"2025-01",
|
||||
"2025-02",
|
||||
"2025-03",
|
||||
"2025-04",
|
||||
"2025-05",
|
||||
"2025-06",
|
||||
"2025-07",
|
||||
"2025-08",
|
||||
"2025-09",
|
||||
"2025-10",
|
||||
"2025-11",
|
||||
"2025-12"
|
||||
],
|
||||
"point": [
|
||||
1.25933837890625,
|
||||
1.285666823387146,
|
||||
1.2950127124786377,
|
||||
1.2207623720169067,
|
||||
1.170255422592163,
|
||||
1.1455552577972412,
|
||||
1.1702347993850708,
|
||||
1.2026824951171875,
|
||||
1.1909748315811157,
|
||||
1.1490840911865234,
|
||||
1.080478549003601,
|
||||
1.0613453388214111
|
||||
],
|
||||
"quantiles": {
|
||||
"10%": [
|
||||
1.2481880187988281,
|
||||
1.2773758172988892,
|
||||
1.286991834640503,
|
||||
1.2084007263183594,
|
||||
1.1533130407333374,
|
||||
1.1275498867034912,
|
||||
1.1510555744171143,
|
||||
1.1859495639801025,
|
||||
1.1784849166870117,
|
||||
1.1264795064926147,
|
||||
1.0624356269836426,
|
||||
1.036609172821045
|
||||
],
|
||||
"20%": [
|
||||
1.1407020092010498,
|
||||
1.1406043767929077,
|
||||
1.126852035522461,
|
||||
1.0352504253387451,
|
||||
0.9691494703292847,
|
||||
0.9420379400253296,
|
||||
0.9503718018531799,
|
||||
0.970925509929657,
|
||||
0.9594371318817139,
|
||||
0.9079477190971375,
|
||||
0.8361266255378723,
|
||||
0.8022069334983826
|
||||
],
|
||||
"30%": [
|
||||
1.1880751848220825,
|
||||
1.1960833072662354,
|
||||
1.187617301940918,
|
||||
1.104191780090332,
|
||||
1.0431063175201416,
|
||||
1.01105535030365,
|
||||
1.0347577333450317,
|
||||
1.0594383478164673,
|
||||
1.040370225906372,
|
||||
0.9952926635742188,
|
||||
0.9259791970252991,
|
||||
0.8952187299728394
|
||||
],
|
||||
"40%": [
|
||||
1.2137157917022705,
|
||||
1.232267141342163,
|
||||
1.2349879741668701,
|
||||
1.151865005493164,
|
||||
1.0932612419128418,
|
||||
1.0658776760101318,
|
||||
1.084773302078247,
|
||||
1.1106674671173096,
|
||||
1.1036059856414795,
|
||||
1.0548235177993774,
|
||||
0.9882403016090393,
|
||||
0.9593706727027893
|
||||
],
|
||||
"50%": [
|
||||
1.2394564151763916,
|
||||
1.2593891620635986,
|
||||
1.267505168914795,
|
||||
1.1853008270263672,
|
||||
1.127617597579956,
|
||||
1.1061187982559204,
|
||||
1.128767728805542,
|
||||
1.1579902172088623,
|
||||
1.1511956453323364,
|
||||
1.1052223443984985,
|
||||
1.03863525390625,
|
||||
1.0152238607406616
|
||||
],
|
||||
"60%": [
|
||||
1.25933837890625,
|
||||
1.285666823387146,
|
||||
1.2950127124786377,
|
||||
1.2207623720169067,
|
||||
1.170255422592163,
|
||||
1.1455552577972412,
|
||||
1.1702347993850708,
|
||||
1.2026824951171875,
|
||||
1.1909748315811157,
|
||||
1.1490840911865234,
|
||||
1.080478549003601,
|
||||
1.0613453388214111
|
||||
],
|
||||
"70%": [
|
||||
1.27677321434021,
|
||||
1.3110136985778809,
|
||||
1.3284480571746826,
|
||||
1.2566629648208618,
|
||||
1.2019660472869873,
|
||||
1.1806211471557617,
|
||||
1.2114834785461426,
|
||||
1.2399210929870605,
|
||||
1.2390201091766357,
|
||||
1.1897773742675781,
|
||||
1.1281580924987793,
|
||||
1.1032856702804565
|
||||
],
|
||||
"80%": [
|
||||
1.2971320152282715,
|
||||
1.3400218486785889,
|
||||
1.3547290563583374,
|
||||
1.2898554801940918,
|
||||
1.2390310764312744,
|
||||
1.2180578708648682,
|
||||
1.248227596282959,
|
||||
1.2842004299163818,
|
||||
1.2832940816879272,
|
||||
1.240414023399353,
|
||||
1.175971508026123,
|
||||
1.153149962425232
|
||||
],
|
||||
"90%": [
|
||||
1.3239599466323853,
|
||||
1.3751201629638672,
|
||||
1.403548240661621,
|
||||
1.3310348987579346,
|
||||
1.2891905307769775,
|
||||
1.2702757120132446,
|
||||
1.2997852563858032,
|
||||
1.3408125638961792,
|
||||
1.3354730606079102,
|
||||
1.286876916885376,
|
||||
1.2283769845962524,
|
||||
1.2169079780578613
|
||||
],
|
||||
"99%": [
|
||||
1.3678879737854004,
|
||||
1.4253658056259155,
|
||||
1.4642648696899414,
|
||||
1.40165376663208,
|
||||
1.3632389307022095,
|
||||
1.3453660011291504,
|
||||
1.380732536315918,
|
||||
1.4195259809494019,
|
||||
1.416972041130066,
|
||||
1.3775466680526733,
|
||||
1.3122477531433105,
|
||||
1.2959520816802979
|
||||
]
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"forecast_mean_c": 1.186,
|
||||
"forecast_max_c": 1.295,
|
||||
"forecast_min_c": 1.061,
|
||||
"vs_last_year_mean": -0.067
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
File diff suppressed because it is too large
Load Diff
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# run_example.sh - Run the TimesFM temperature anomaly forecasting example
|
||||
#
|
||||
# This script:
|
||||
# 1. Runs the preflight system check
|
||||
# 2. Runs the TimesFM forecast
|
||||
# 3. Generates the visualization
|
||||
#
|
||||
# Usage:
|
||||
# ./run_example.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Python 3.10+
|
||||
# - timesfm[torch] installed: uv pip install "timesfm[torch]"
|
||||
# - matplotlib, pandas, numpy
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILL_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
|
||||
echo "============================================================"
|
||||
echo " TimesFM Example: Global Temperature Anomaly Forecast"
|
||||
echo "============================================================"
|
||||
|
||||
# Step 1: Preflight check
|
||||
echo ""
|
||||
echo "🔍 Step 1: Running preflight system check..."
|
||||
python3 "$SKILL_ROOT/scripts/check_system.py" || {
|
||||
echo "❌ Preflight check failed. Please fix the issues above before continuing."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Step 2: Run forecast
|
||||
echo ""
|
||||
echo "📊 Step 2: Running TimesFM forecast..."
|
||||
cd "$SCRIPT_DIR"
|
||||
python3 run_forecast.py
|
||||
|
||||
# Step 3: Generate visualization
|
||||
echo ""
|
||||
echo "📈 Step 3: Generating visualization..."
|
||||
python3 visualize_forecast.py
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " ✅ Example complete!"
|
||||
echo "============================================================"
|
||||
echo ""
|
||||
echo "Output files:"
|
||||
echo " - $SCRIPT_DIR/output/forecast_output.csv"
|
||||
echo " - $SCRIPT_DIR/output/forecast_output.json"
|
||||
echo " - $SCRIPT_DIR/output/forecast_visualization.png"
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Run TimesFM forecast on global temperature anomaly data.
|
||||
Generates forecast output CSV and JSON for the example.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# Preflight check
|
||||
print("=" * 60)
|
||||
print(" TIMeSFM FORECAST - Global Temperature Anomaly Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Load data
|
||||
data_path = Path(__file__).parent / "temperature_anomaly.csv"
|
||||
df = pd.read_csv(data_path, parse_dates=["date"])
|
||||
df = df.sort_values("date").reset_index(drop=True)
|
||||
|
||||
print(f"\n📊 Input Data: {len(df)} months of temperature anomalies")
|
||||
print(
|
||||
f" Date range: {df['date'].min().strftime('%Y-%m')} to {df['date'].max().strftime('%Y-%m')}"
|
||||
)
|
||||
print(f" Mean anomaly: {df['anomaly_c'].mean():.2f}°C")
|
||||
print(
|
||||
f" Trend: {df['anomaly_c'].iloc[-12:].mean() - df['anomaly_c'].iloc[:12].mean():.2f}°C change (first to last year)"
|
||||
)
|
||||
|
||||
# Prepare input for TimesFM
|
||||
# TimesFM expects a list of 1D numpy arrays
|
||||
input_series = df["anomaly_c"].values.astype(np.float32)
|
||||
|
||||
# Load TimesFM 1.0 (PyTorch)
|
||||
# NOTE: TimesFM 2.5 PyTorch checkpoint has a file format issue at time of writing.
|
||||
# The model.safetensors file is not loadable via torch.load().
|
||||
# Using TimesFM 1.0 PyTorch which works correctly.
|
||||
print("\n🤖 Loading TimesFM 1.0 (200M) PyTorch...")
|
||||
import timesfm
|
||||
|
||||
hparams = timesfm.TimesFmHparams(horizon_len=12)
|
||||
checkpoint = timesfm.TimesFmCheckpoint(
|
||||
huggingface_repo_id="google/timesfm-1.0-200m-pytorch"
|
||||
)
|
||||
model = timesfm.TimesFm(hparams=hparams, checkpoint=checkpoint)
|
||||
|
||||
# Forecast
|
||||
print("\n📈 Running forecast (12 months ahead)...")
|
||||
forecast_input = [input_series]
|
||||
frequency_input = [0] # Monthly data
|
||||
|
||||
point_forecast, experimental_quantile_forecast = model.forecast(
|
||||
forecast_input,
|
||||
freq=frequency_input,
|
||||
)
|
||||
|
||||
print(f" Point forecast shape: {point_forecast.shape}")
|
||||
print(f" Quantile forecast shape: {experimental_quantile_forecast.shape}")
|
||||
|
||||
# Extract results
|
||||
point = point_forecast[0] # Shape: (horizon,)
|
||||
quantiles = experimental_quantile_forecast[0] # Shape: (horizon, num_quantiles)
|
||||
|
||||
# TimesFM quantiles: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.99]
|
||||
# Index mapping: 0=10%, 1=20%, ..., 4=50% (median), ..., 9=99%
|
||||
quantile_labels = ["10%", "20%", "30%", "40%", "50%", "60%", "70%", "80%", "90%", "99%"]
|
||||
|
||||
# Create forecast dates (2025 monthly)
|
||||
last_date = df["date"].max()
|
||||
forecast_dates = pd.date_range(
|
||||
start=last_date + pd.DateOffset(months=1), periods=12, freq="MS"
|
||||
)
|
||||
|
||||
# Build output DataFrame
|
||||
output_df = pd.DataFrame(
|
||||
{
|
||||
"date": forecast_dates.strftime("%Y-%m-%d"),
|
||||
"point_forecast": point,
|
||||
"q10": quantiles[:, 0],
|
||||
"q20": quantiles[:, 1],
|
||||
"q30": quantiles[:, 2],
|
||||
"q40": quantiles[:, 3],
|
||||
"q50": quantiles[:, 4], # Median
|
||||
"q60": quantiles[:, 5],
|
||||
"q70": quantiles[:, 6],
|
||||
"q80": quantiles[:, 7],
|
||||
"q90": quantiles[:, 8],
|
||||
"q99": quantiles[:, 9],
|
||||
}
|
||||
)
|
||||
|
||||
# Save outputs
|
||||
output_dir = Path(__file__).parent / "output"
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
output_df.to_csv(output_dir / "forecast_output.csv", index=False)
|
||||
|
||||
# JSON output for the report
|
||||
output_json = {
|
||||
"model": "TimesFM 1.0 (200M) PyTorch",
|
||||
"input": {
|
||||
"source": "NOAA GISTEMP Global Temperature Anomaly",
|
||||
"n_observations": len(df),
|
||||
"date_range": f"{df['date'].min().strftime('%Y-%m')} to {df['date'].max().strftime('%Y-%m')}",
|
||||
"mean_anomaly_c": round(df["anomaly_c"].mean(), 3),
|
||||
},
|
||||
"forecast": {
|
||||
"horizon": 12,
|
||||
"dates": forecast_dates.strftime("%Y-%m").tolist(),
|
||||
"point": point.tolist(),
|
||||
"quantiles": {
|
||||
label: quantiles[:, i].tolist() for i, label in enumerate(quantile_labels)
|
||||
},
|
||||
},
|
||||
"summary": {
|
||||
"forecast_mean_c": round(float(point.mean()), 3),
|
||||
"forecast_max_c": round(float(point.max()), 3),
|
||||
"forecast_min_c": round(float(point.min()), 3),
|
||||
"vs_last_year_mean": round(
|
||||
float(point.mean() - df["anomaly_c"].iloc[-12:].mean()), 3
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
with open(output_dir / "forecast_output.json", "w") as f:
|
||||
json.dump(output_json, f, indent=2)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "=" * 60)
|
||||
print(" FORECAST RESULTS")
|
||||
print("=" * 60)
|
||||
print(
|
||||
f"\n📅 Forecast period: {forecast_dates[0].strftime('%Y-%m')} to {forecast_dates[-1].strftime('%Y-%m')}"
|
||||
)
|
||||
print(f"\n🌡️ Temperature Anomaly Forecast (°C above 1951-1980 baseline):")
|
||||
print(f"\n {'Month':<10} {'Point':>8} {'80% CI':>15} {'90% CI':>15}")
|
||||
print(f" {'-' * 10} {'-' * 8} {'-' * 15} {'-' * 15}")
|
||||
for i, (date, pt, q10, q90, q05, q95) in enumerate(
|
||||
zip(
|
||||
forecast_dates.strftime("%Y-%m"),
|
||||
point,
|
||||
quantiles[:, 1], # 20%
|
||||
quantiles[:, 7], # 80%
|
||||
quantiles[:, 0], # 10%
|
||||
quantiles[:, 8], # 90%
|
||||
)
|
||||
):
|
||||
print(
|
||||
f" {date:<10} {pt:>8.3f} [{q10:>6.3f}, {q90:>6.3f}] [{q05:>6.3f}, {q95:>6.3f}]"
|
||||
)
|
||||
|
||||
print(f"\n📊 Summary Statistics:")
|
||||
print(f" Mean forecast: {point.mean():.3f}°C")
|
||||
print(
|
||||
f" Max forecast: {point.max():.3f}°C (Month: {forecast_dates[point.argmax()].strftime('%Y-%m')})"
|
||||
)
|
||||
print(
|
||||
f" Min forecast: {point.min():.3f}°C (Month: {forecast_dates[point.argmin()].strftime('%Y-%m')})"
|
||||
)
|
||||
print(f" vs 2024 mean: {point.mean() - df['anomaly_c'].iloc[-12:].mean():+.3f}°C")
|
||||
|
||||
print(f"\n✅ Output saved to:")
|
||||
print(f" {output_dir / 'forecast_output.csv'}")
|
||||
print(f" {output_dir / 'forecast_output.json'}")
|
||||
@@ -0,0 +1,37 @@
|
||||
date,anomaly_c
|
||||
2022-01-01,0.89
|
||||
2022-02-01,0.89
|
||||
2022-03-01,1.02
|
||||
2022-04-01,0.88
|
||||
2022-05-01,0.85
|
||||
2022-06-01,0.88
|
||||
2022-07-01,0.88
|
||||
2022-08-01,0.90
|
||||
2022-09-01,0.88
|
||||
2022-10-01,0.95
|
||||
2022-11-01,0.77
|
||||
2022-12-01,0.78
|
||||
2023-01-01,0.87
|
||||
2023-02-01,0.98
|
||||
2023-03-01,1.21
|
||||
2023-04-01,1.00
|
||||
2023-05-01,0.94
|
||||
2023-06-01,1.08
|
||||
2023-07-01,1.18
|
||||
2023-08-01,1.24
|
||||
2023-09-01,1.47
|
||||
2023-10-01,1.32
|
||||
2023-11-01,1.18
|
||||
2023-12-01,1.16
|
||||
2024-01-01,1.22
|
||||
2024-02-01,1.35
|
||||
2024-03-01,1.34
|
||||
2024-04-01,1.26
|
||||
2024-05-01,1.15
|
||||
2024-06-01,1.20
|
||||
2024-07-01,1.24
|
||||
2024-08-01,1.30
|
||||
2024-09-01,1.28
|
||||
2024-10-01,1.27
|
||||
2024-11-01,1.22
|
||||
2024-12-01,1.20
|
||||
|
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Visualize TimesFM forecast results for global temperature anomaly.
|
||||
|
||||
Generates a publication-quality figure showing:
|
||||
- Historical data (2022-2024)
|
||||
- Point forecast (2025)
|
||||
- 80% and 90% confidence intervals (fan chart)
|
||||
|
||||
Usage:
|
||||
python visualize_forecast.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# Configuration
|
||||
EXAMPLE_DIR = Path(__file__).parent
|
||||
INPUT_FILE = EXAMPLE_DIR / "temperature_anomaly.csv"
|
||||
FORECAST_FILE = EXAMPLE_DIR / "output" / "forecast_output.json"
|
||||
OUTPUT_FILE = EXAMPLE_DIR / "output" / "forecast_visualization.png"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Load historical data
|
||||
df = pd.read_csv(INPUT_FILE, parse_dates=["date"])
|
||||
|
||||
# Load forecast results
|
||||
with open(FORECAST_FILE) as f:
|
||||
forecast = json.load(f)
|
||||
|
||||
# Extract forecast data
|
||||
dates = pd.to_datetime(forecast["forecast"]["dates"])
|
||||
point = np.array(forecast["forecast"]["point"])
|
||||
q10 = np.array(forecast["forecast"]["quantiles"]["10%"])
|
||||
q20 = np.array(forecast["forecast"]["quantiles"]["20%"])
|
||||
q80 = np.array(forecast["forecast"]["quantiles"]["80%"])
|
||||
q90 = np.array(forecast["forecast"]["quantiles"]["90%"])
|
||||
|
||||
# Create figure
|
||||
fig, ax = plt.subplots(figsize=(12, 6))
|
||||
|
||||
# Plot historical data
|
||||
ax.plot(
|
||||
df["date"],
|
||||
df["anomaly_c"],
|
||||
color="#2563eb",
|
||||
linewidth=1.5,
|
||||
marker="o",
|
||||
markersize=3,
|
||||
label="Historical (NOAA GISTEMP)",
|
||||
)
|
||||
|
||||
# Plot 90% CI (outer band)
|
||||
ax.fill_between(dates, q10, q90, alpha=0.2, color="#dc2626", label="90% CI")
|
||||
|
||||
# Plot 80% CI (inner band)
|
||||
ax.fill_between(dates, q20, q80, alpha=0.3, color="#dc2626", label="80% CI")
|
||||
|
||||
# Plot point forecast
|
||||
ax.plot(
|
||||
dates,
|
||||
point,
|
||||
color="#dc2626",
|
||||
linewidth=2,
|
||||
marker="s",
|
||||
markersize=4,
|
||||
label="TimesFM Forecast",
|
||||
)
|
||||
|
||||
# Add vertical line at forecast boundary
|
||||
ax.axvline(
|
||||
x=df["date"].max(), color="#6b7280", linestyle="--", linewidth=1, alpha=0.7
|
||||
)
|
||||
|
||||
# Formatting
|
||||
ax.set_xlabel("Date", fontsize=12)
|
||||
ax.set_ylabel("Temperature Anomaly (°C)", fontsize=12)
|
||||
ax.set_title(
|
||||
"TimesFM Zero-Shot Forecast Example\n36-month Temperature Anomaly → 12-month Forecast",
|
||||
fontsize=14,
|
||||
fontweight="bold",
|
||||
)
|
||||
|
||||
# Add annotations
|
||||
ax.annotate(
|
||||
f"Mean forecast: {forecast['summary']['forecast_mean_c']:.2f}°C\n"
|
||||
f"vs 2024: {forecast['summary']['vs_last_year_mean']:+.2f}°C",
|
||||
xy=(dates[6], point[6]),
|
||||
xytext=(dates[6], point[6] + 0.15),
|
||||
fontsize=10,
|
||||
arrowprops=dict(arrowstyle="->", color="#6b7280", lw=1),
|
||||
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", edgecolor="#6b7280"),
|
||||
)
|
||||
|
||||
# Grid and legend
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend(loc="upper left", fontsize=10)
|
||||
|
||||
# Set y-axis limits
|
||||
ax.set_ylim(0.7, 1.5)
|
||||
|
||||
# Rotate x-axis labels
|
||||
plt.xticks(rotation=45, ha="right")
|
||||
|
||||
# Tight layout
|
||||
plt.tight_layout()
|
||||
|
||||
# Save
|
||||
fig.savefig(OUTPUT_FILE, dpi=150, bbox_inches="tight")
|
||||
print(f"✅ Saved visualization to: {OUTPUT_FILE}")
|
||||
|
||||
plt.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user