Use when projecting history forward — sales, demand, units, revenue, signups, traffic — into a defensible number with an error band: method by data shape, rolling-origin backtest, MASE vs the naive baseline. NOT an assumption-driven P&L or runway model (that is `financial-model`), NOT sizing reorder points or safety stock (that is `inventory`).
npx skills add https://github.com/ericrisco/rsc-harness --skill forecasting
A forecast that cannot beat "repeat last period" is noise. Baseline first, fancy second. The naive forecast is free, instant, and the bar every model must clear — if your AutoARIMA loses to last-quarter-repeated, ship the repeat and say so.
You are not done when a model produces a number. You are done when you can defend the number: which method, why that method for this data, how it scored against the naive baseline in a backtest, and the interval around the point. A point estimate with no error band is a guess wearing a lab coat.
Every forecast you ship is a reproducible artifact, not a number pasted in chat:
ds, forecast, lo, hi — timestamp, point, interval bounds.If you cannot produce all three, you have not forecast — you have guessed. scripts/verify.sh checks the artifact has these columns, the right row count, and an accuracy line.
Run these in order. Skipping step 3 is the most common failure.
h (how many periods forward), the granularity (daily / weekly / monthly), and exactly what is being predicted (units? revenue? per-SKU or aggregate?). Forecast at the level you will *act* on — if you reorder per SKU, forecast per SKU, then sanity-check against the aggregate.data-cleaning before modeling. Garbage history, garbage forecast.inventory, financial-model).Match the method to the *shape* of the history, not to what sounds sophisticated. statsforecast (Nixtla, v2.0.3) provides all of these with built-in intervals.
| Data shape | Method | statsforecast call | Why |
|---|---|---|---|
| Flat, no trend or season | Moving average or SES | AutoCES() / 3-period MA | Nothing to model; a mean is honest. |
| Trend, with or without season | ETS | AutoETS(season_length=m) | ETS captures level+trend+season cleanly, no manual order. |
| Strong known seasonality / autocorrelation | ARIMA | AutoARIMA(season_length=m) | Handles autocorrelated errors; ~20x faster than pmdarima. |
| Many zeros (intermittent / lumpy demand) | Croston / SBA | CrostonOptimized() | SES is *provably* wrong on sporadic demand (Croston 1972); SBA debiases it. |
| < 2 full seasonal cycles of history | SeasonalNaive only | SeasonalNaive(season_length=m) | Too little data to fit a model. Do not fit one. Full stop. |
When in doubt between two, fit both plus the baseline in one StatsForecast run and let the backtest decide. Theta (AutoTheta) is a strong, cheap default that often wins on monthly business series.
The metrics are not decoration — they decide what you ship.
cross_validation(h=…, n_windows=…).level=[80] or [95]. The interval is half the deliverable, not optional polish.Formulas (WAPE, MASE, bias, pinball, coverage), rolling-origin mechanics, and how to read a backtest table are in references/accuracy-and-backtesting.md. Per-method when-to-use and the exact statsforecast one-liner for each are in references/methods-cheatsheet.md.
The full pipeline: long-format dataframe, fit competing methods + baseline, backtest, forecast with an interval, write the artifact.
# pip install statsforecast (Nixtla, v2.0.3)
import pandas as pd
from statsforecast import StatsForecast
from statsforecast.models import SeasonalNaive, AutoETS, AutoARIMA
# long format: unique_id, ds, y (one row per series per period)
df = pd.read_csv("history.csv", parse_dates=["ds"])
m, h = 12, 12 # monthly seasonality; forecast 12 periods ahead
sf = StatsForecast(
models=[SeasonalNaive(season_length=m), AutoETS(season_length=m), AutoARIMA(season_length=m)],
freq="MS",
)
# rolling-origin backtest BEFORE trusting any forecast
cv = sf.cross_validation(df=df, h=h, n_windows=3, step_size=h)
def wape(a, f): return (a - f).abs().sum() / a.abs().sum()
for col in ["SeasonalNaive", "AutoETS", "AutoARIMA"]:
print(col, "WAPE", round(wape(cv["y"], cv[col]), 4)) # pick the lowest that beats SeasonalNaive
# refit on full history, forecast with an 80% interval
fc = sf.forecast(df=df, h=h, level=[80])
# choose the winning model column from the backtest; here AutoETS as example
out = fc.rename(columns={"AutoETS": "forecast", "AutoETS-lo-80": "lo", "AutoETS-hi-80": "hi"})
out[["ds", "forecast", "lo", "hi"]].to_csv("forecast.csv", index=False)
Zero-dependency fallback when you cannot install statsforecast — a seasonal-naive baseline in pure pandas. This is also the thing every model must beat, so it is always worth computing:
import pandas as pd
def seasonal_naive(y: pd.Series, m: int, h: int) -> pd.Series:
"""Repeat the last full season forward h periods."""
last_season = y.iloc[-m:].to_numpy()
return pd.Series([last_season[i % m] for i in range(h)])
s = pd.read_csv("history.csv", parse_dates=["ds"]).set_index("ds")["y"]
fc = seasonal_naive(s, m=12, h=12)
# crude interval from historical residual spread; honest is better than absent
resid_std = (s - s.shift(12)).dropna().std()
out = pd.DataFrame({"forecast": fc, "lo": fc - 1.28 * resid_std, "hi": fc + 1.28 * resid_std})
out.to_csv("forecast.csv", index=False)
references/methods-cheatsheet.md.| Anti-pattern | Why it bites | Do instead |
|---|---|---|
| Report a single point number | A point hides uncertainty the reader needs to plan around. | Report point + 80/95% interval |
| Tune ARIMA orders before any baseline | If you cannot beat the free baseline, the tuning was wasted. | Compute naive/seasonal-naive first |
| Score with MAPE on intermittent demand | MAPE explodes near zero actuals and lies about accuracy. | Use WAPE + bias |
| Single train/test holdout | One split is one sample; CV estimates real out-of-sample error. | Rolling-origin CV (n_windows≥3) |
| Fit a model on 8 months of monthly data | Too few points; the model overfits and underreports its own error. | SeasonalNaive only under 2 cycles |
| "The model picked it, so it's right" | A forecast you cannot defend is worse than no forecast. | State method + MASE vs naive |
| Trust SKU forecasts without checking the sum | Per-SKU errors compound; the total exposes nonsense fast. | Sanity-check vs the aggregate |
../inventory/SKILL.md — feed it the demand number; it sizes reorder points and safety stock. Forecasting produces the demand; it does not size the stock.../financial-model/SKILL.md — when the projection is driven by *assumptions and drivers* (pricing, hiring), not history, that is a model, not a forecast.../unit-economics/SKILL.md — contribution margin, CAC/LTV, payback. No time series, route there.../data-cleaning/SKILL.md — dirty input (dupes, missing rows, mixed units) goes here *before* you model.../analyze/SKILL.md — when the question is "why" or a backward-looking metric/aggregation rather than forward extrapolation.Take ericrisco/forecasting from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference pip.
Without those the skill loads but fails at the first command.