slipstream / src /baselines.py
ashaibani's picture
Slipstream: gr.Server + Preact SPA, MiniCPM-1B agent + TimesFM 2.5
16eaf84 verified
"""Forecasting baselines.
All forecasters share one contract:
forecast(obs_inc, horizon, *, bac=None, planned_periods=None) -> dict
obs_inc : 1D array of observed PER-PERIOD increments (not cumulative)
returns : {"q10": arr, "q50": arr, "q90": arr} incremental quantile forecasts
The caller re-integrates the increments onto the last observed cumulative value to
obtain cumulative bands (see app.py / evaluate.py). We forecast increments, not the
cumulative S-curve, because a monotone S-curve is trivially curve-fit and gives a
foundation model no edge - PLAN.md section 5.
"""
from __future__ import annotations
import numpy as np
_Z10 = 1.2816 # standard normal quantile for the 10th/90th percentile
def _bands(median, sigma) -> dict:
"""Symmetric P10/P90 around a point forecast, widening with horizon."""
median = np.asarray(median, float)
h = len(median)
s = np.maximum(float(sigma), 1e-9) * np.sqrt(1.0 + np.arange(h))
return {
"q10": np.clip(median - _Z10 * s, 0.0, None),
"q50": np.clip(median, 0.0, None),
"q90": median + _Z10 * s,
}
def last_value(obs_inc, horizon, **kw) -> dict:
obs_inc = np.asarray(obs_inc, float)
last = obs_inc[-1] if len(obs_inc) else 0.0
tail = obs_inc[-min(len(obs_inc), 6):]
sigma = np.std(tail) if len(tail) > 1 else abs(last) * 0.2 + 1e-9
return _bands(np.full(horizon, max(last, 0.0)), sigma)
def linear(obs_inc, horizon, **kw) -> dict:
obs_inc = np.asarray(obs_inc, float)
n = len(obs_inc)
if n < 2:
return last_value(obs_inc, horizon)
x = np.arange(n)
a, b = np.polyfit(x, obs_inc, 1)
fx = np.arange(n, n + horizon)
median = np.clip(a * fx + b, 0.0, None)
sigma = np.std(obs_inc - (a * x + b))
return _bands(median, sigma)
def logistic(obs_inc, horizon, *, bac=None, planned_periods=None, **kw) -> dict:
"""Fit a logistic curve to the cumulative observed series and extrapolate.
This is the strongest classical competitor on project S-curves - if TimesFM
cannot beat this on incremental error, reposition (PLAN.md, Risks).
"""
from scipy.optimize import curve_fit
obs_inc = np.asarray(obs_inc, float)
cum = np.cumsum(obs_inc)
n = len(cum)
if n < 4:
return linear(obs_inc, horizon)
x = np.arange(1, n + 1, dtype=float)
def f(t, L, k, t0):
return L / (1.0 + np.exp(-k * (t - t0)))
try:
L_hi = max(cum[-1] * 5.0 + 1.0, (bac or cum[-1]) * 2.0)
popt, _ = curve_fit(
f, x, cum,
p0=[max(cum[-1] * 1.2, (bac or cum[-1])), 0.3, n * 0.5],
bounds=([cum[-1] * 0.9, 1e-3, 0.0], [L_hi, 5.0, n * 5.0]),
maxfev=10000,
)
fx = np.arange(n + 1, n + horizon + 1, dtype=float)
cum_fore = f(fx, *popt)
resid = np.std(cum - f(x, *popt))
except Exception:
return linear(obs_inc, horizon)
inc = np.clip(np.diff(np.concatenate([[cum[-1]], cum_fore])), 0.0, None)
return _bands(inc, resid / max(np.sqrt(horizon), 1.0))
# --------------------------------------------------------------------------- #
# Foundation-model baseline (zero-shot). Skeleton - verify the import/API.
# --------------------------------------------------------------------------- #
_chronos = None
def _load_chronos(model: str):
global _chronos
if _chronos is None:
# TODO verify: `from chronos import BaseChronosPipeline` (chronos-forecasting)
from chronos import BaseChronosPipeline
_chronos = BaseChronosPipeline.from_pretrained(model)
return _chronos
def chronos(obs_inc, horizon, *, model="amazon/chronos-bolt-small", **kw) -> dict:
"""Zero-shot Chronos-Bolt baseline. Returns P10/P50/P90 increments."""
import torch
pipe = _load_chronos(model)
ctx = torch.tensor(np.asarray(obs_inc, float), dtype=torch.float32)
# TODO verify signature against chronos-forecasting docs.
q, _mean = pipe.predict_quantiles(
context=ctx, prediction_length=horizon, quantile_levels=[0.1, 0.5, 0.9]
)
q = q[0].cpu().numpy() # (horizon, 3)
return {"q10": np.clip(q[:, 0], 0, None), "q50": q[:, 1], "q90": q[:, 2]}
REGISTRY = {
"last_value": last_value,
"linear": linear,
"logistic": logistic,
"chronos": chronos,
}