| |
| """Zero-shot forecasting with MacroCast (or Forge) on arbitrary datasets. |
| |
| MacroCast is the fine-tuned ~1.2M-parameter GatedDeltaProduct linear-RNN model. It forecasts in a single |
| forward pass (no windowing): give it a history of shape [T, N] (T timesteps, N |
| channels/variables) and it returns quantile forecasts for the next H steps. |
| |
| Usage as a library |
| ------------------- |
| from forecast import MacroCastForecaster |
| f = MacroCastForecaster(checkpoint="model.pth", config="config.yaml") |
| point, quantiles = f.predict(history_array, horizon=12) # history: [T, N] |
| |
| Usage as a CLI |
| -------------- |
| python inference/forecast.py \ |
| --checkpoint /path/to/MacroCast/checkpoints/ckpt_2023-12.pth \ |
| --config forge/config_forge_1M.yaml \ |
| --csv my_series.csv \ |
| --horizon 12 \ |
| --output forecast.csv |
| |
| The input CSV should have one row per timestep; the first column may be a date |
| index (auto-detected) and every remaining numeric column is treated as a channel. |
| """ |
| import sys |
| import json |
| import argparse |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
| import torch |
| import yaml |
|
|
| |
| |
| |
| |
| |
| |
| |
| _HERE = Path(__file__).absolute().parent |
| for _cand in (_HERE, _HERE / "tempopfn", _HERE.parent, _HERE.parent / "tempopfn"): |
| if (_cand / "src").is_dir(): |
| sys.path.insert(0, str(_cand)) |
| break |
|
|
| if hasattr(torch, "compile"): |
| torch.compile = lambda fn=None, *a, **kw: (lambda inner: inner) if fn is None else fn |
|
|
| from src.data.containers import BatchTimeSeriesContainer |
| from src.data.frequency import Frequency |
| from src.models.model import TimeSeriesModel |
|
|
| |
| |
| _FREQ_ALIASES = { |
| "M": "M", "MS": "M", "ME": "M", |
| "D": "D", "W": "W", "H": "H", |
| "Q": "Q", "QS": "Q", |
| "A": "A", "Y": "A", "YS": "A", "AS": "A", |
| } |
|
|
|
|
| def _to_frequency(freq: str): |
| name = _FREQ_ALIASES.get(freq.upper(), freq.upper()) |
| return getattr(Frequency, name, Frequency.M) |
|
|
|
|
| def _fetch(source, filename): |
| """Return a local path to `filename` from a local dir or an HF repo id. |
| |
| For a local directory we just join the path. For a Hugging Face repo id we |
| download **only that single file** (cached) via `hf_hub_download` — never a |
| full-repo snapshot — so picking one vintage doesn't pull every checkpoint. |
| Raises FileNotFoundError if the file is absent. |
| """ |
| p = Path(source).expanduser() |
| if p.is_dir(): |
| local = p / filename |
| if not local.exists(): |
| raise FileNotFoundError(f"{filename} not found in {p}") |
| return local |
| from huggingface_hub import hf_hub_download |
| from huggingface_hub.utils import EntryNotFoundError, RepositoryNotFoundError |
| try: |
| return Path(hf_hub_download(repo_id=str(source), filename=filename)) |
| except (EntryNotFoundError, RepositoryNotFoundError) as e: |
| raise FileNotFoundError(f"{filename} not found in repo {source!r}") from e |
|
|
|
|
| def _pick_vintage(manifest, year): |
| """Pick the manifest vintage entry for `year` (exact, else nearest <=, else earliest).""" |
| vintages = [v for v in manifest.get("vintages", []) if v.get("year") is not None] |
| if not vintages: |
| return None |
| if year is None: |
| year = manifest.get("default_year") |
| for v in vintages: |
| if v.get("year") == year: |
| return v |
| le = [v for v in vintages if v["year"] <= (year if year is not None else 1 << 30)] |
| return max(le, key=lambda v: v["year"]) if le else min(vintages, key=lambda v: v["year"]) |
|
|
|
|
| class MacroCastForecaster: |
| """Load a MacroCast/Forge checkpoint and produce zero-shot forecasts.""" |
|
|
| @staticmethod |
| def available_years(source): |
| """List the vintage years available in a local dir or HF repo. |
| |
| Only `manifest.json` is read/downloaded — not the checkpoints. |
| """ |
| try: |
| manifest = _fetch(source, "manifest.json") |
| except FileNotFoundError: |
| return [] |
| m = json.loads(Path(manifest).read_text()) |
| return [v["year"] for v in m.get("vintages", []) if v.get("year") is not None] |
|
|
| @classmethod |
| def from_pretrained(cls, source, year=None, config=None, device=None): |
| """Load MacroCast for a chosen vintage `year` from a local dir or HF repo id. |
| |
| Pass the Hugging Face repo id directly, e.g.:: |
| |
| model = MacroCastForecaster.from_pretrained("user/MacroCast", year=2023) |
| |
| Reads `manifest.json` to map year -> checkpoint and downloads **only** the |
| matching vintage file (plus `config.yaml`) — not the whole repo. If `year` |
| is None, the latest (`default_year`) vintage is used. Falls back to a single |
| `model.pth` if the repo/dir has no manifest. |
| """ |
| try: |
| manifest_path = _fetch(source, "manifest.json") |
| except FileNotFoundError: |
| manifest_path = None |
|
|
| if manifest_path is not None: |
| m = json.loads(Path(manifest_path).read_text()) |
| chosen = _pick_vintage(m, year) |
| if chosen is None: |
| raise FileNotFoundError(f"No vintage models found for source={source!r}") |
| cfg = Path(config) if config else _fetch(source, m.get("config", "config.yaml")) |
| ckpt = _fetch(source, chosen["file"]) |
| print(f"Loading MacroCast vintage year={chosen.get('year')} " |
| f"(cutoff={chosen.get('cutoff')}, n_vars={chosen.get('n_vars')})") |
| return cls(checkpoint=ckpt, config=cfg, device=device) |
|
|
| |
| cfg = Path(config) if config else _fetch(source, "config.yaml") |
| single = _fetch(source, "model.pth") |
| return cls(checkpoint=single, config=cfg, device=device) |
|
|
| def __init__(self, checkpoint, config, device=None): |
| self.device = torch.device( |
| device or ("cuda" if torch.cuda.is_available() else "cpu")) |
| with open(config) as f: |
| cfg = yaml.safe_load(f)["TimeSeriesModel"] |
| self.model = TimeSeriesModel(**cfg).to(self.device) |
|
|
| ckpt = torch.load(checkpoint, map_location="cpu", weights_only=False) |
| state = ckpt["model_state_dict"] if isinstance(ckpt, dict) and \ |
| "model_state_dict" in ckpt else ckpt |
| |
| self.model.load_state_dict(state, strict=False) |
| self.model.eval() |
|
|
| |
| self.cols = ckpt.get("cols") if isinstance(ckpt, dict) else None |
| self.quantiles = list(self.model.quantiles) |
|
|
| @torch.no_grad() |
| def predict(self, history, horizon=12, freq="M", start="1970-01"): |
| """Forecast `horizon` steps ahead. |
| |
| Parameters |
| ---------- |
| history : np.ndarray, shape [T] or [T, N] |
| Observed history. N channels are forecast jointly. |
| horizon : int |
| freq : str, one of M, D, W, H, Q, Y |
| start : str, start timestamp of the history (for time features) |
| |
| Returns |
| ------- |
| point : np.ndarray [horizon, N] median (0.5-quantile) forecast |
| quantiles : np.ndarray [horizon, N, Q] all quantile forecasts |
| """ |
| hist = np.asarray(history, dtype=np.float32) |
| if hist.ndim == 1: |
| hist = hist[:, None] |
| T, N = hist.shape |
|
|
| fut = np.zeros((horizon, N), dtype=np.float32) |
| fr = _to_frequency(freq) |
| batch = BatchTimeSeriesContainer( |
| history_values=torch.tensor(hist[None], dtype=torch.float32, device=self.device), |
| future_values=torch.tensor(fut[None], dtype=torch.float32, device=self.device), |
| start=[np.datetime64(start)], |
| frequency=[fr], |
| ) |
| autocast = self.device.type == "cuda" |
| with torch.autocast(device_type=self.device.type, dtype=torch.bfloat16, |
| enabled=autocast): |
| out = self.model(batch) |
| pred = self.model.scaler.inverse_scale(out["result"].float(), |
| out["scale_statistics"]) |
| arr = pred.cpu().numpy()[0] |
| q_idx = self.quantiles.index(0.5) if 0.5 in self.quantiles else arr.shape[-1] // 2 |
| point = arr[:, :, q_idx] |
| return point.astype(np.float32), arr.astype(np.float32) |
|
|
|
|
| def _read_csv(path): |
| df = pd.read_csv(path) |
| |
| first = df.columns[0] |
| try: |
| idx = pd.to_datetime(df[first]) |
| df = df.drop(columns=[first]).set_index(idx) |
| except (ValueError, TypeError): |
| pass |
| df = df.select_dtypes(include=[np.number]) |
| return df |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser(description="Zero-shot forecasting with MacroCast.") |
| ap.add_argument("--checkpoint", required=True, help="MacroCast/Forge .pth checkpoint") |
| ap.add_argument("--config", required=True, help="Architecture YAML (forge/config_forge_1M.yaml)") |
| ap.add_argument("--csv", required=True, help="Input CSV: rows=time, cols=variables") |
| ap.add_argument("--horizon", type=int, default=12) |
| ap.add_argument("--freq", default="M") |
| ap.add_argument("--output", default="forecast.csv") |
| ap.add_argument("--device", default=None) |
| args = ap.parse_args() |
|
|
| df = _read_csv(args.csv) |
| print(f"Loaded {df.shape[0]} timesteps x {df.shape[1]} channels from {args.csv}") |
|
|
| f = MacroCastForecaster(args.checkpoint, args.config, device=args.device) |
| start = str(df.index[0].to_datetime64()) if isinstance(df.index, pd.DatetimeIndex) \ |
| else "1970-01" |
| point, _ = f.predict(df.to_numpy(np.float32), horizon=args.horizon, |
| freq=args.freq, start=start) |
|
|
| |
| if isinstance(df.index, pd.DatetimeIndex): |
| future_idx = pd.date_range(df.index[-1], periods=args.horizon + 1, |
| freq=args.freq)[1:] |
| else: |
| future_idx = range(args.horizon) |
| out = pd.DataFrame(point, columns=df.columns, index=future_idx) |
| out.to_csv(args.output) |
| print(f"Wrote {args.horizon}-step forecast for {df.shape[1]} channels → {args.output}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|