Files
2026-07-13 13:26:28 +08:00

93 lines
3.3 KiB
Python

"""Predictions cache for chapter teaching notebooks.
Cache long-form prediction frames keyed by a content-addressed spec hash so
re-running visualization / interpretation cells doesn't require re-training
the upstream stages. Cache files live under
{chapter_dir}/output/predictions/{notebook_id}/{spec_hash}.parquet
which is gitignored.
Frame schema (required columns):
date -- value identifying the prediction period
symbol -- value identifying the asset
y_pred -- float, model prediction
y_true -- float, realised forward return
Optional column:
forecaster -- string label, when one notebook stacks multiple forecasters
(e.g. Constant / AR(1) / EWMA) into a single frame.
Typical use::
from utils.predictions_cache import load_predictions, save_predictions
spec = {
"data": {"source": "etfs", "start": START_DATE, "end": END_DATE,
"max_symbols": MAX_SYMBOLS},
"model": {"name": "rp_pca", "n_factors": N_FACTORS,
"focus_gamma": focus_gamma},
"forecasters": ["Constant", "AR(1)", "EWMA"],
}
cached = load_predictions(chapter=14, notebook_id="rp_pca", spec=spec)
if cached is None:
# ... expensive Stage 1-3 work, build long-form `frame` ...
save_predictions(chapter=14, notebook_id="rp_pca", spec=spec, frame=frame)
cached = frame
# downstream code consumes `cached` for plotting / IC summaries.
The spec dict is the contract: any value that materially changes the
predictions must appear in it. Two runs that share a spec hash will share a
cache entry, so changing a hyperparameter without updating the spec will
silently reuse stale predictions.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import polars as pl
from utils.paths import get_chapter_dir
REQUIRED_COLUMNS = ("date", "symbol", "y_pred", "y_true")
KEY_LENGTH = 12
def predictions_cache_key(spec: dict) -> str:
"""SHA-1 hash of the canonicalised spec; first 12 hex digits."""
payload = json.dumps(spec, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha1(payload).hexdigest()[:KEY_LENGTH]
def predictions_cache_path(chapter: int | str, notebook_id: str, spec: dict) -> Path:
"""Deterministic parquet path; does not create the file or its parents."""
root = get_chapter_dir(chapter) / "output" / "predictions" / notebook_id
return root / f"{predictions_cache_key(spec)}.parquet"
def load_predictions(chapter: int | str, notebook_id: str, spec: dict) -> pl.DataFrame | None:
"""Return the cached predictions frame, or None if no cache entry exists."""
path = predictions_cache_path(chapter, notebook_id, spec)
if not path.exists():
return None
return pl.read_parquet(path)
def save_predictions(chapter: int | str, notebook_id: str, spec: dict, frame: pl.DataFrame) -> Path:
"""Write predictions frame to its content-addressed cache location."""
missing = [c for c in REQUIRED_COLUMNS if c not in frame.columns]
if missing:
raise ValueError(
f"predictions frame missing required columns {missing}; have {list(frame.columns)}"
)
path = predictions_cache_path(chapter, notebook_id, spec)
path.parent.mkdir(parents=True, exist_ok=True)
frame.write_parquet(path)
return path