| """ |
| evaluate.py — TriChronos-0.1B |
| Zero-shot MASE evaluation against the Monash Time Series Forecasting benchmark. |
| |
| Designed to run on a free CPU tier — no GPU required. |
| |
| Metrics |
| ------- |
| MASE = MAE(forecast, actual) / naïve_seasonal_MAE |
| where naïve_seasonal_MAE uses the last known value from the context |
| as the forecast (seasonal period = 1, i.e. random-walk naive baseline). |
| |
| For probabilistic forecasts we evaluate the **median quantile** (τ=0.50). |
| |
| Usage |
| ----- |
| python evaluate.py --checkpoint checkpoints/model_state.pt |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| from pathlib import Path |
| from typing import Dict, List, Optional, Tuple |
|
|
| import numpy as np |
| import torch |
| from torch.utils.data import DataLoader |
|
|
| from data_pipeline import ( |
| _silver_normalize, |
| _gold_patch, |
| PATCH_SIZE, |
| FORECAST_HORIZON, |
| ) |
| from model import TriChronos, QUANTILE_LEVELS |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| MONASH_REPO: str = "autogluon/chronos_datasets" |
|
|
| |
| MONASH_DATASETS: List[Tuple[str, Optional[str], int]] = [ |
| (MONASH_REPO, "monash_m1_monthly", 12), |
| (MONASH_REPO, "monash_m1_quarterly", 4), |
| (MONASH_REPO, "monash_m1_yearly", 1), |
| (MONASH_REPO, "monash_m3_monthly", 12), |
| (MONASH_REPO, "monash_m3_quarterly", 4), |
| (MONASH_REPO, "monash_m3_yearly", 1), |
| (MONASH_REPO, "m4_monthly", 12), |
| (MONASH_REPO, "m4_quarterly", 4), |
| (MONASH_REPO, "m4_yearly", 1), |
| (MONASH_REPO, "monash_tourism_monthly", 12), |
| (MONASH_REPO, "monash_tourism_quarterly", 4), |
| (MONASH_REPO, "monash_tourism_yearly", 1), |
| (MONASH_REPO, "monash_electricity_hourly", 24), |
| (MONASH_REPO, "monash_traffic", 24), |
| (MONASH_REPO, "monash_weather", 1), |
| ] |
|
|
| |
| MEDIAN_IDX: int = QUANTILE_LEVELS.index(0.5) |
|
|
|
|
| |
| |
| |
|
|
| def naive_mae(history: np.ndarray, horizon: int, period: int) -> float: |
| """ |
| MAE of the seasonal-naive forecast on the history (in-sample). |
| |
| For period=1 (random walk): forecast[t] = history[t-1]. |
| """ |
| if len(history) <= period: |
| return float(np.abs(history).mean()) or 1.0 |
| errors = np.abs(history[period:] - history[:-period]) |
| return float(errors.mean()) or 1.0 |
|
|
|
|
| |
| |
| |
|
|
| def evaluate_dataset( |
| model: TriChronos, |
| ds_name: str, |
| subset: Optional[str], |
| period: int, |
| device: torch.device, |
| max_series: int = 200, |
| ) -> Tuple[float, int]: |
| """ |
| Run zero-shot evaluation on one Monash dataset. |
| |
| Returns |
| ------- |
| mean_mase : float — mean MASE across all evaluated series |
| n_series : int — number of series evaluated |
| """ |
| from datasets import load_dataset |
|
|
| try: |
| ds_kwargs = dict( |
| path=ds_name, |
| split="train", |
| streaming=True, |
| ) |
| if subset: |
| ds_kwargs["name"] = subset |
| dataset = load_dataset(**ds_kwargs) |
| except Exception as exc: |
| print(f" [SKIP] {subset or ds_name}: {exc}") |
| return float("nan"), 0 |
|
|
| mase_scores: List[float] = [] |
| model.eval() |
|
|
| with torch.no_grad(): |
| for i, example in enumerate(dataset): |
| if i >= max_series: |
| break |
|
|
| values = example.get("target", None) |
| if values is None or len(values) < PATCH_SIZE * 2 + FORECAST_HORIZON: |
| continue |
| values = np.asarray(values, dtype=np.float32) |
|
|
| |
| normed, mean_, std_ = _silver_normalize(values) |
|
|
| |
| |
| if len(normed) < FORECAST_HORIZON * 2: |
| continue |
|
|
| |
| context = normed[: -FORECAST_HORIZON] |
| actual_normed = normed[-FORECAST_HORIZON:] |
|
|
| |
| n_full = len(context) // PATCH_SIZE |
| if n_full == 0: |
| continue |
| aligned = context[-n_full * PATCH_SIZE:] |
| patches = aligned.reshape(n_full, PATCH_SIZE) |
|
|
| |
| max_patches = 64 |
| if patches.shape[0] > max_patches: |
| patches = patches[-max_patches:] |
| elif patches.shape[0] < max_patches: |
| pad = np.zeros((max_patches - patches.shape[0], PATCH_SIZE), dtype=np.float32) |
| patches = np.concatenate([pad, patches], axis=0) |
|
|
| patches_t = torch.from_numpy(patches).unsqueeze(0).to(device) |
|
|
| |
| preds = model(patches_t) |
| |
| median_normed = preds[0, :FORECAST_HORIZON, MEDIAN_IDX].cpu().numpy() |
|
|
| |
| mae = float(np.abs(median_normed - actual_normed).mean()) |
|
|
| |
| n_mae = naive_mae(context, FORECAST_HORIZON, period) |
|
|
| mase = mae / max(n_mae, 1e-8) |
| mase_scores.append(mase) |
|
|
| if not mase_scores: |
| return float("nan"), 0 |
|
|
| return float(np.mean(mase_scores)), len(mase_scores) |
|
|
|
|
| |
| |
| |
|
|
| def evaluate(args: argparse.Namespace): |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| print(f"Device: {device}\n", flush=True) |
|
|
| |
| model = TriChronos(patch_size=PATCH_SIZE, horizon=FORECAST_HORIZON).to(device) |
| if args.checkpoint and Path(args.checkpoint).exists(): |
| state = torch.load(args.checkpoint, map_location=device) |
| model.load_state_dict(state) |
| print(f"Loaded checkpoint: {args.checkpoint}\n", flush=True) |
| else: |
| print("⚠️ No checkpoint loaded — evaluating with random weights (sanity check only).\n") |
|
|
| model.eval() |
|
|
| |
| results: Dict[str, Dict] = {} |
| all_mase: List[float] = [] |
|
|
| header = f"{'Dataset':<35} {'MASE':>8} {'N':>6}" |
| print(header) |
| print("-" * len(header)) |
|
|
| for ds_name, subset, period in MONASH_DATASETS: |
| label = subset or ds_name |
| mase, n = evaluate_dataset(model, ds_name, subset, period, device, max_series=args.max_series) |
| results[label] = {"mase": mase, "n": n} |
|
|
| if not np.isnan(mase): |
| all_mase.append(mase) |
| print(f"{label:<35} {mase:>8.4f} {n:>6}") |
| else: |
| print(f"{label:<35} {'N/A':>8} {n:>6}") |
|
|
| print("-" * len(header)) |
| if all_mase: |
| agg_mase = float(np.mean(all_mase)) |
| print(f"{'AGGREGATE (mean MASE)':<35} {agg_mase:>8.4f}") |
| else: |
| print("No datasets were successfully evaluated.") |
|
|
| return results |
|
|
|
|
| |
| |
| |
|
|
| def parse_args() -> argparse.Namespace: |
| p = argparse.ArgumentParser(description="Evaluate TriChronos-0.1B on Monash") |
| p.add_argument( |
| "--checkpoint", |
| type=str, |
| default="checkpoints/model_state.pt", |
| help="Path to model checkpoint", |
| ) |
| p.add_argument( |
| "--max-series", |
| type=int, |
| default=200, |
| help="Max series per dataset (limits CPU eval time)", |
| ) |
| return p.parse_args() |
|
|
|
|
| if __name__ == "__main__": |
| evaluate(parse_args()) |
|
|