File size: 10,923 Bytes
294e473 c64d9bf 294e473 bcb68fb 294e473 c64d9bf 294e473 c64d9bf 294e473 c64d9bf 294e473 c64d9bf 294e473 c64d9bf 294e473 c64d9bf 294e473 c64d9bf 294e473 c64d9bf 294e473 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 | #!/usr/bin/env python3
"""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
# Make the vendored TempoPFN package importable as `src.*`.
# Works both in the CODE-SHARE layout (inference/forecast.py + ../tempopfn) and
# in a flat HF repo (forecast.py + ./tempopfn at the same level).
#
# NOTE: use .absolute() and NOT .resolve(): on the Hugging Face Hub cache, files in
# snapshots/<commit>/ are *symlinks* into blobs/. .resolve() would follow the symlink
# into blobs/ (where `tempopfn/` does not sit beside the file), breaking the import.
_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 # noqa: E402
from src.data.frequency import Frequency # noqa: E402
from src.models.model import TimeSeriesModel # noqa: E402
# Map common pandas-style freq strings to the model's Frequency enum.
# (The enum uses "A" for annual; there is no "Y".)
_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)
# No manifest → single-model repo/dir.
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
# MacroCast checkpoints have no adapter weights; load non-strict to be safe.
self.model.load_state_dict(state, strict=False)
self.model.eval()
# Variable names the model was fine-tuned on (informational).
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] # [horizon, N, Q]
q_idx = self.quantiles.index(0.5) if 0.5 in self.quantiles else arr.shape[-1] // 2
point = arr[:, :, q_idx] # [horizon, N]
return point.astype(np.float32), arr.astype(np.float32)
def _read_csv(path):
df = pd.read_csv(path)
# Auto-detect a date column as the index.
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)
# Build a forward index if the input was dated.
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()
|