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()