Predicting the future of an ordered series: why a seasonal-naive baseline beats most deep models, what the 2024-2026 time-series foundation models actually changed, the metrics that survive scale differences, and runnable code that puts TimesFM zero-shot against a PatchTST trained from scratch on the same ETTh1 windows.
Author
Benedict Thekkel
1. What is Time Series Forecasting?
Time series forecasting maps a history of one or more ordered numeric series to their future values. The ordering is the whole point: rows are not exchangeable, the test set is always later than the training set, and the same value means something different depending on where it sits.
Input. A context window of \(L\) past observations, usually at a fixed frequency (hourly, daily), often multivariate (\(C\) channels observed together), and often with side information:
Past covariates - things you only know retrospectively (actual temperature, realised traffic).
Future covariates - things you know ahead of time (calendar, holidays, scheduled promotions, published price). These are usually the highest-value features in the whole model.
Static covariates - properties of the series itself (store size, product category, region).
Output. A horizon of \(H\) future values. The interesting question is what shape:
Output
What it is
When you need it
Point forecast
One number per future step
Simple reporting
Quantile forecast
e.g. q10 / q50 / q90 per step
Inventory, capacity, bidding - the usual real deliverable
Full distribution
Parametric or sample paths
Risk, simulation, anything that composes forecasts
Direct multi-step
All \(H\) steps in one forward pass
The modern default: no error accumulation
Recursive / autoregressive
Feed each prediction back in
Flexible horizon, but errors compound
Neighbouring tasks:
Task
What it does
Typical tool
Time series classification
Label a whole window (activity, fault type)
ROCKET, InceptionTime, PatchTST encoder
Anomaly detection
Flag windows that do not fit the pattern
Matrix profile, forecast residuals
Changepoint detection
Find where the regime shifted
ruptures, BOCPD
Imputation
Fill missing observations inside a series
PatchTST/TimesNet masked reconstruction
Tabular regression
Same target type, but rows are exchangeable
see 01_Tabular_Regression
Panel / hierarchical forecasting
Thousands of related series that must sum consistently
hierarchicalforecast, reconciliation
2. Real-World Use Cases
Use case
Domain
Consumes / produces
Dominant constraint
Retail demand planning
Retail, e-commerce (Amazon, Walmart)
Millions of SKU x store series + promotions -> weekly units
Scale (millions of series), intermittency (mostly zeros), hierarchy that must reconcile
Quantiles feed the bid; weather forecast error dominates; hard operational deadline
Capacity and autoscaling
Cloud infrastructure
Request rate, CPU, queue depth -> next 30-60 min
Asymmetric cost (under-provision is an outage); minutes-fresh retraining
Financial forecasting
Trading, treasury
Prices, volumes, order flow -> return or volatility
Signal-to-noise near zero; non-stationarity; a naive baseline is genuinely hard to beat
Inventory and supply chain
Manufacturing, logistics
Shipments, lead times, orders -> demand over lead time
The distribution is the product (safety stock is a quantile), not the mean
Healthcare capacity
Hospitals, public health
Admissions, ED arrivals, epidemic counts -> beds, staff
Regime changes (a new variant) break every model trained before them
Predictive maintenance
Industry, aviation
Sensor streams -> degradation trajectory
Very long horizons, few failure examples, multivariate
Web and app traffic
Any online product
Sessions per hour -> capacity, ad inventory
Multiple seasonalities at once (daily + weekly + annual) plus events
What the benchmark number hides.
A naive baseline is not a formality, it is the bar. Seasonal naive (repeat last week) beats a surprising number of published deep models on real data. The M4 and M5 competitions were both won by statistical/hybrid approaches, and the “are transformers effective for time series?” paper (Zeng et al., 2023) showed a single linear layer (DLinear) matching or beating a whole generation of transformer forecasters on the standard long-horizon benchmarks. Report the naive baseline or the number means nothing.
Non-stationarity is the actual problem. Real series shift level, variance and seasonality. A model validated on a fixed split silently assumes tomorrow resembles last year. Use rolling origin evaluation (backtesting), not a single split.
Leakage is easy and invisible. Scaling with statistics computed over the whole series, imputing with future values, or using a covariate that will not be available at forecast time - each of these produces a beautiful backtest and a broken deployment.
Point forecasts are usually the wrong deliverable. Safety stock is a quantile; capacity is a quantile; a bid curve is a distribution. A model that gives a good mean and no uncertainty cannot be used for the decisions people actually make.
Aggregation changes everything. Hourly is noisy, daily is manageable, weekly is easy. A lot of impressive accuracy comes from forecasting a more aggregated series than the decision needs.
Intermittency breaks the standard metrics. Retail demand is mostly zeros; MAPE is undefined, RMSE rewards forecasting zero, and specialist methods (Croston, Tweedie objectives) exist for precisely this.
3. How Modern Forecasting Works
1. Classical statistical models (1970-2010, still competitive). ARIMA (Box-Jenkins), exponential smoothing / ETS, and Theta. Per-series, interpretable, tiny, and genuinely hard to beat on short univariate series. ETS and Theta variants have won or nearly won competitions as recently as M4 (2018).
2. Gradient-boosted trees on lag features (2015-2020). Turn the series into a table (lags, rolling means, calendar dummies) and fit LightGBM. This won M5 (2020) and is still the default in most retail companies: it handles thousands of series in one model, absorbs covariates naturally, and trains in minutes. Its limit is that trees cannot extrapolate a trend, so the series usually has to be differenced or detrended first.
3. Deep global models (2017-2021). DeepAR (2017, autoregressive RNN with a probabilistic head), N-BEATS (2019, deep residual stack of basis expansions), TFT (2019, attention plus gating plus covariate selection). The change here was global training: one model over thousands of series instead of one model per series, which is where the deep approach starts paying.
4. Transformers, then the backlash, then patching (2021-2023). Informer, Autoformer and FEDformer attacked the quadratic attention cost for long horizons. Then Zeng et al. (2023) showed a one-layer linear model (DLinear) beat all of them on the same benchmarks, which was a genuine crisis. The resolution was patching: PatchTST (2023) splits the series into subsequence patches (as ViT does with images) and keeps channels independent, which fixed both the compute cost and the overfitting. PatchTSMixer (2023) replaced attention with MLP-mixing and matched it more cheaply. Both are transformers-native and used below.
5. Time series foundation models (2023 -> now), the current frontier. Pretrain one model on billions of time points from many domains, then forecast a series it has never seen zero-shot:
TimesFM (Google, 2024; v2.0 500M, v2.5 200M) - decoder-only patched transformer, 100B+ real-world time points, transformers-native. Used below.
Chronos / Chronos-Bolt (Amazon, 2024-2025) - tokenise scaled values and reuse a T5 language model; Bolt is the faster direct-multi-step successor.
Lag-Llama, TimeGPT (Nixtla, commercial API), Tiny Time Mixers (IBM, ~1M params and competitive, which is the interesting part).
Where that leaves you in 2026. A foundation model gives a strong forecast with no training at all, which is a real change for cold starts and for the long tail of series nobody was going to model by hand. It does not yet reliably beat a well-fit local model on a series you have plenty of history for, and it cannot use your covariates as naturally as LightGBM can. The honest default is: baseline first, then LightGBM on lags if you have covariates, then a foundation model zero-shot as a fast second opinion, then a trained PatchTST if you have many long series and the horizon is long.
4. Evaluation Metrics
Forecasting metrics exist mostly to solve one problem: series live on different scales, so an error of 5 is excellent for one and catastrophic for another.
Scale-dependent (fine within one series, meaningless across series):
MAPE breaks at \(y = 0\) and punishes over-prediction more than under-prediction.
sMAPE is bounded but still unstable near zero, and its asymmetry is subtler rather than gone.
Scaled (the ones the competitions settled on):
\[\text{MASE} = \frac{\text{MAE of the forecast}}{\text{MAE of a seasonal naive forecast on the training data}}\]
MASE < 1 means you beat seasonal naive; MASE > 1 means you did not. It is defined at zero, it is symmetric, and it has a built-in sanity check, which is why M4/M5 used it (with RMSSE, the squared version, in M5).
Probabilistic (for the deliverable that is usually wanted):
Pinball / quantile loss at each required quantile - the same loss used to fit them.
Weighted quantile loss (WQL) - the mean pinball loss over a quantile grid, normalised by total demand. The standard for foundation-model benchmarks.
CRPS - the continuous ranked probability score, the limit of averaged pinball loss over all quantiles. The right scoring rule for a full predictive distribution.
Coverage - do 80% intervals contain the truth 80% of the time?
Pitfalls. Average metrics across windows and series, not over a single window. Use rolling-origin backtesting rather than one split. Never compute normalisation statistics on the test period. And always print the seasonal-naive number next to yours.
import numpy as npdef mase(y_true, y_pred, y_train, season=24):"Mean absolute scaled error. < 1 beats seasonal naive on the training history." naive_mae = np.mean(np.abs(y_train[season:] - y_train[:-season]))returnfloat(np.mean(np.abs(y_true - y_pred)) / naive_mae)def smape(y_true, y_pred):"Symmetric MAPE, in percent. Still unstable when y is near zero." denom = (np.abs(y_true) + np.abs(y_pred)) /2returnfloat(np.mean(np.abs(y_true - y_pred) / np.maximum(denom, 1e-9)) *100)def pinball(y_true, y_pred, tau):"Quantile loss at level tau." d = np.asarray(y_true) - np.asarray(y_pred)returnfloat(np.mean(np.maximum(tau * d, (tau -1) * d)))def wql(y_true, quantile_preds, taus):"Weighted quantile loss: mean pinball over a quantile grid, normalised by total actual." total = np.sum(np.abs(y_true))returnfloat(2*sum(pinball(y_true, q, t) for q, t inzip(quantile_preds, taus))*len(y_true) / (len(taus) * total))# A toy daily series: weekly seasonality + trend + noise.rng = np.random.default_rng(0)t = np.arange(400)y =100+0.1* t +10* np.sin(2* np.pi * t /7) + rng.normal(0, 3, 400)train, test = y[:365], y[365:]seasonal_naive = np.tile(train[-7:], 5)[: len(test)]flat_naive = np.full(len(test), train[-1])for name, pred in [("seasonal naive", seasonal_naive), ("last value", flat_naive)]:print(f"{name:16s} MAE {np.mean(np.abs(test - pred)):6.2f} "f"sMAPE {smape(test, pred):5.2f}% MASE {mase(test, pred, train, season=7):5.3f}")print("\nMASE is the useful one: it says directly whether the model earned its complexity.")
seasonal naive MAE 3.25 sMAPE 2.37% MASE 0.921
last value MAE 6.68 sMAPE 4.85% MASE 1.892
MASE is the useful one: it says directly whether the model earned its complexity.
5. Datasets
Long-horizon forecasting research converged on a small set of tables, which is both convenient and a known weakness (they are few, they are all infrastructure/weather, and results on them do not always transfer).
This notebook uses ETTh1 - the OT (oil temperature) channel for the univariate sections and all 7 channels for the multivariate PatchTST. It is 2.5 MB of CSV, downloaded once into DL_tasks/datasets/ (gitignored), covering July 2016 to June 2018 hourly. It has daily and weekly seasonality, a visible trend, and enough non-stationarity to make the naive baseline respectable. Note the CC BY-NC license: research and teaching are fine, commercial use is not. Nothing here is gated.
6. The Model Landscape (mid-2026)
Model
Params
License
Trained per series?
Probabilistic
Covariates
Best for
Seasonal naive
0
-
-
no
no
The bar every model must clear
ETS / ARIMA (statsforecast)
~10
Apache 2.0
per series
yes
limited
Short univariate series, few of them
LightGBM on lag features
~100s of trees
MIT
one global model
via quantile objectives
excellent
Many series with rich covariates (won M5)
DeepAR
~1M
Apache 2.0 (GluonTS)
global
yes
yes
Probabilistic, many related series
N-BEATS / N-HiTS
~10-30M
Apache 2.0
global
via ensembling
limited
Pure univariate accuracy
DLinear
~1k
Apache 2.0
global
no
no
The baseline that embarrassed the transformers
PatchTST
~1-10M
Apache 2.0
global
via quantile head
limited
Long-horizon multivariate - trained below
PatchTSMixer
~0.5-5M
Apache 2.0
global
via quantile head
yes
Same, cheaper, no attention
TFT
~10M
Apache 2.0
global
yes
excellent
Interpretable, covariate-heavy
TimesFM 2.0
500M
Apache 2.0
zero-shot
quantiles
no (v2.0)
Cold start, no training - run below
Chronos-Bolt
9M-200M
Apache 2.0
zero-shot
quantiles
no
Fast zero-shot, small footprint
Moirai / Moirai-MoE
14M-311M
Apache 2.0
zero-shot
yes
past covariates
Any-variate zero-shot
Tiny Time Mixers (TTM)
~1M
Apache 2.0
zero-shot + fine-tune
no
yes
Extremely cheap zero-shot; needs tsfm_public
TimeGPT
undisclosed
commercial API
zero-shot
yes
yes
Managed service
Leaderboards: GIFT-Eval (the current foundation-model standard), the Monash archive, and the long-horizon ETT/Electricity/Traffic tables reported in the PatchTST line of papers.
What wins what. On zero-shot accuracy over unseen series, TimesFM 2.x and Chronos-Bolt lead. On a series you have years of history for, a trained local or global model still usually wins. On throughput, DLinear and TTM are orders of magnitude cheaper than anything else in the table and are frequently within a few percent. On covariates - which is what most business forecasting actually needs - LightGBM and TFT are still ahead of every foundation model.
Download budget.google/timesfm-2.0-500m-pytorch is a 4 GB repo (2 GB safetensors plus a 2 GB legacy .ckpt); only the safetensors are fetched, so about 2 GB lands on disk - inside the ~8 GB cap in CLAUDE.md. google/timesfm-2.5-200m-pytorch is smaller if you are tight on space.
7. Setup
Package roles:
transformers (>=5.13) + torch - TimesFM (zero-shot) and PatchTST (trained here), both transformers-native; no vendor forecasting packages
pandas / numpy - the series and the windowing
pyecharts - all charts (repo rule)
accelerate - device placement for the 500M checkpoint
The ETTh1 CSV (2.5 MB) and the Hugging Face cache both land in DL_tasks/datasets/, which is gitignored. TimesFM 2.0 in fp32 is about 2 GB of VRAM, and PatchTST as configured here is 0.1M parameters, so the whole notebook fits inside the 12 GB card with room to spare - but the models are still freed between sections, because that is the house rule.
# TimesFM and PatchTST are both transformers-native - no vendor forecasting packages.# %pip install -q torch transformers accelerate pandas pyecharts
import ctypesimport ctypes.utilimport gcimport timeimport urllib.requestfrom pathlib import Pathimport numpy as npimport pandas as pdimport psutilimport torchfrom dotenv import find_dotenv, load_dotenv# Knowledge/.env sets HF_TOKEN - authenticated Hub requests get higher rate limitsload_dotenv(find_dotenv(usecwd=True))device ="cuda:0"if torch.cuda.is_available() else"cpu"if device !="cpu":print(torch.cuda.get_device_name(0))print("device:", device)def vram(tag=""):"Report current GPU memory (allocated / reserved). No-op on CPU."if torch.cuda.is_available(): alloc = torch.cuda.memory_allocated() /1e9 reserved = torch.cuda.memory_reserved() /1e9print(f"VRAM {tag:18s}{alloc:5.2f} GB allocated / {reserved:5.2f} GB reserved")def free_memory(*objs):"Delete objects, run GC, empty the CUDA cache, and return freed RAM to the OS.\n\n Note the argument list only drops *this function's* references. A name bound in\n the notebook still holds the object, so the working idiom is `del model;\n free_memory()` at the call site - which is what every section below uses.\n "for o in objs:del o gc.collect()if torch.cuda.is_available(): torch.cuda.empty_cache() torch.cuda.ipc_collect()# glibc keeps freed CPU allocations in its arenas, so RSS ratchets upward across# sections; malloc_trim(0) hands them back. Not optional on a 20 GB box - see# dl-visualization-and-memory.instructions.md.try: ctypes.CDLL(ctypes.util.find_library("c") or"libc.so.6").malloc_trim(0)exceptException:passdef memory_report(tag=""):"Print current system RAM (and VRAM if a GPU is present), in GB." vm = psutil.virtual_memory()print(f"RAM {tag:18s}{(vm.total - vm.available) /1e9:5.2f} / {vm.total /1e9:5.2f} GB") vram(tag)DATA_DIR = Path("../../datasets") # gitignoredDATA_DIR.mkdir(exist_ok=True)HF_CACHE =str(DATA_DIR /"hf_cache")ETT_CSV = DATA_DIR /"ETTh1.csv"ifnot ETT_CSV.exists(): urllib.request.urlretrieve("https://raw.githubusercontent.com/zhouhaoyi/ETDataset/main/ETT-small/ETTh1.csv", ETT_CSV, )memory_report("baseline")
CHANNELS = ["HUFL", "HULL", "MUFL", "MULL", "LUFL", "LULL", "OT"]TARGET ="OT"# oil temperature, the channel the benchmarks forecastdf = pd.read_csv(ETT_CSV, parse_dates=["date"])series = df[CHANNELS].to_numpy(dtype="float32")y_all = df[TARGET].to_numpy(dtype="float32")n =len(df)# The canonical ETT split is chronological 60/20/20 - never random. Everything after# the boundary is the future and must stay untouched until evaluation.TRAIN_END, VAL_END =int(n *0.6), int(n *0.8)CTX, HORIZON, SEASON =512, 96, 24# 512 h of context, 96 h ahead, daily seasonalityprint(f"{n:,} hourly rows, {df.date.min().date()} to {df.date.max().date()}")print(f"train [0:{TRAIN_END}] val [{TRAIN_END}:{VAL_END}] test [{VAL_END}:{n}]")print(f"context {CTX} h ({CTX /24:.0f} days) -> horizon {HORIZON} h ({HORIZON /24:.0f} days)")print(f"\n{TARGET}: mean {y_all.mean():.2f} sd {y_all.std():.2f} "f"min {y_all.min():.2f} max {y_all.max():.2f}")df.head()
17,420 hourly rows, 2016-07-01 to 2018-06-26
train [0:10452] val [10452:13936] test [13936:17420]
context 512 h (21 days) -> horizon 96 h (4 days)
OT: mean 13.32 sd 8.57 min -4.08 max 46.01
date
HUFL
HULL
MUFL
MULL
LUFL
LULL
OT
0
2016-07-01 00:00:00
5.827
2.009
1.599
0.462
4.203
1.340
30.531000
1
2016-07-01 01:00:00
5.693
2.076
1.492
0.426
4.142
1.371
27.787001
2
2016-07-01 02:00:00
5.157
1.741
1.279
0.355
3.777
1.218
27.787001
3
2016-07-01 03:00:00
5.090
1.942
1.279
0.391
3.807
1.279
25.044001
4
2016-07-01 04:00:00
5.358
1.942
1.492
0.462
3.868
1.279
21.948000
from pyecharts import options as optsfrom pyecharts.charts import Line# The whole OT series, thinned to ~1,500 points. Look for what makes this hard:# a level shift around the middle, seasonality that changes amplitude, and a test# period (the last 20%) that does not look much like the training period.step =max(1, n //1500)idx = np.arange(0, n, step)dates = df["date"].dt.strftime("%Y-%m-%d").to_numpy()[idx]overview = ( Line() .add_xaxis(list(dates)) .add_yaxis("OT (oil temperature)", [round(float(v), 2) for v in y_all[idx]], is_smooth=True, symbol="none", label_opts=opts.LabelOpts(is_show=False)) .set_global_opts( title_opts=opts.TitleOpts(title="ETTh1: oil temperature, 2016-07 to 2018-06", subtitle="the last 20% (right) is the test period - it does not resemble the training period"), xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=45, font_size=9)), yaxis_opts=opts.AxisOpts(name="temperature"), tooltip_opts=opts.TooltipOpts(trigger="axis"), datazoom_opts=[opts.DataZoomOpts(range_start=0, range_end=100)], ))overview.render_notebook()
# The evaluation harness every model below is scored with: fixed test windows,# rolling origin, one week apart so they do not overlap much.STRIDE =168# one week between window startsSTARTS =list(range(VAL_END, n - CTX - HORIZON, STRIDE))print(f"{len(STARTS)} test windows, {CTX} h context -> {HORIZON} h horizon, {STRIDE} h apart")Y_TRUE = np.stack([y_all[s + CTX: s + CTX + HORIZON] for s in STARTS]) # (W, H)CONTEXTS = np.stack([y_all[s: s + CTX] for s in STARTS]) # (W, L)TRAIN_HIST = y_all[:TRAIN_END]# One scale factor for MASE, computed on the TRAINING history only - computing it on# the test period would be leakage of exactly the kind section 2 warns about.NAIVE_MAE =float(np.mean(np.abs(TRAIN_HIST[SEASON:] - TRAIN_HIST[:-SEASON])))print(f"seasonal-naive MAE on the training history = {NAIVE_MAE:.3f} (the MASE denominator)")def score(name, preds, seconds=None):"Score a (W, H) forecast array against Y_TRUE with the shared metrics." err = preds - Y_TRUE out =dict(model=name, mae=float(np.abs(err).mean()), rmse=float(np.sqrt((err **2).mean())), mase=float(np.abs(err).mean() / NAIVE_MAE), smape=float(np.mean(np.abs(err) / np.maximum( (np.abs(Y_TRUE) + np.abs(preds)) /2, 1e-9)) *100), seconds=seconds)print(f"{name:26s} MAE {out['mae']:6.3f} RMSE {out['rmse']:6.3f} "f"MASE {out['mase']:5.3f} sMAPE {out['smape']:6.2f}%"+ (f" {seconds:6.2f}s"if seconds isnotNoneelse""))return out
18 test windows, 512 h context -> 96 h horizon, 168 h apart
seasonal-naive MAE on the training history = 2.436 (the MASE denominator)
8. The Baselines You Have To Beat
Three models with no parameters, and on this dataset they are not a formality.
Last value (random walk). Repeat the final observation for all 96 hours. For a series with a strong trend and weak seasonality this is genuinely hard to beat at short horizons.
Seasonal naive. Repeat the last 24 hours, four times. This encodes the daily cycle for free.
Seasonal mean. Average the last 7 days hour-by-hour. Same seasonal structure, but the noise is averaged down.
Whichever of these wins is the number every trained model below is judged against, and it is what MASE is normalised by. If a 500M-parameter foundation model cannot beat “repeat last week”, the honest conclusion is to ship “repeat last week”.
Watch which one wins here. Oil temperature drifts more than it cycles over four days, so the non-seasonal baseline comes out ahead of both seasonal ones - a reminder that “seasonal naive” is not automatically the right naive, and that picking the baseline is itself a modelling decision about the series.
results = []t0 = time.perf_counter()pred_last = np.repeat(CONTEXTS[:, -1:], HORIZON, axis=1)results.append(score("naive (last value)", pred_last, time.perf_counter() - t0))t0 = time.perf_counter()pred_snaive = np.tile(CONTEXTS[:, -SEASON:], (1, HORIZON // SEASON +1))[:, :HORIZON]results.append(score("seasonal naive (24 h)", pred_snaive, time.perf_counter() - t0))t0 = time.perf_counter()last_week = CONTEXTS[:, -7* SEASON:].reshape(len(STARTS), 7, SEASON).mean(axis=1)pred_smean = np.tile(last_week, (1, HORIZON // SEASON +1))[:, :HORIZON]results.append(score("seasonal mean (7 days)", pred_smean, time.perf_counter() - t0))print(f"\nMASE is 1.0 by construction for a seasonal-naive forecast measured on the "f"training history;\nthe values above differ because the test period is harder "f"than the training period.")
naive (last value) MAE 2.306 RMSE 3.025 MASE 0.947 sMAPE 35.47% 0.00s
seasonal naive (24 h) MAE 2.545 RMSE 3.292 MASE 1.045 sMAPE 36.72% 0.00s
seasonal mean (7 days) MAE 2.524 RMSE 3.187 MASE 1.036 sMAPE 34.56% 0.00s
MASE is 1.0 by construction for a seasonal-naive forecast measured on the training history;
the values above differ because the test period is harder than the training period.
9. TimesFM 2.0: forecasting with no training at all
TimesFM is a decoder-only transformer pretrained on over 100 billion real-world time points. It takes a raw context window and emits the horizon zero-shot - no fitting, no validation split, no hyperparameters. The 500M checkpoint is transformers-native as TimesFmModelForPrediction, so it drops in with from_pretrained.
Three mechanics worth knowing:
It patches the input (32 steps per patch) and predicts 128 steps per output patch, so the 96-hour horizon comes out of a single forward pass rather than 96 autoregressive steps.
past_values takes a list of 1-D tensors, not a padded batch, so windows of different lengths batch together naturally. All 18 test windows go through in one call below.
freq is a coarse frequency hint: 0 for high frequency (hourly and finer), 1 for medium (weekly, monthly), 2 for low (quarterly, yearly). It is not the sampling rate.
It also returns full_predictions, which carries the quantile heads - the model is probabilistic, not just a point forecaster.
from transformers import TimesFmModelForPredictionTIMESFM_ID ="google/timesfm-2.0-500m-pytorch"# ~2 GB of safetensors fetchedtimesfm = TimesFmModelForPrediction.from_pretrained( TIMESFM_ID, cache_dir=HF_CACHE, dtype=torch.float32).to(device).eval()print(f"{TIMESFM_ID}: {sum(p.numel() for p in timesfm.parameters()) /1e6:.0f}M params, "f"context up to {timesfm.config.context_length}, "f"output patch {timesfm.config.horizon_length}")vram("timesfm loaded")contexts = [torch.tensor(c, device=device) for c in CONTEXTS]t0 = time.perf_counter()with torch.inference_mode(): out = timesfm(past_values=contexts, freq=[0] *len(contexts), return_dict=True)elapsed = time.perf_counter() - t0pred_tfm = out.mean_predictions[:, :HORIZON].float().cpu().numpy()results.append(score("TimesFM 2.0 (zero-shot)", pred_tfm, elapsed))print(f"\nall {len(contexts)} windows in one forward pass, {elapsed:.2f}s total "f"({elapsed /len(contexts) *1000:.0f} ms per window)")print(f"full_predictions carries the quantile heads: shape {tuple(out.full_predictions.shape)}")
google/timesfm-2.0-500m-pytorch: 499M params, context up to 2048, output patch 128
VRAM timesfm loaded 2.00 GB allocated / 2.14 GB reserved
TimesFM 2.0 (zero-shot) MAE 1.999 RMSE 2.665 MASE 0.821 sMAPE 26.42% 0.35s
all 18 windows in one forward pass, 0.35s total (20 ms per window)
full_predictions carries the quantile heads: shape (18, 128, 10)
# The quantile heads, on one window. full_predictions is (batch, horizon, 1 + n_quantiles):# channel 0 is the mean, channels 1..9 are deciles q10..q90.w =0fp = out.full_predictions[w, :HORIZON].float().cpu().numpy()q10, q50, q90 = fp[:, 1], fp[:, 5], fp[:, 9]hours =list(range(HORIZON))band = ( Line() .add_xaxis(hours) .add_yaxis("actual", [round(float(v), 3) for v in Y_TRUE[w]], is_smooth=True, symbol="none", label_opts=opts.LabelOpts(is_show=False)) .add_yaxis("TimesFM q50", [round(float(v), 3) for v in q50], is_smooth=True, symbol="none", label_opts=opts.LabelOpts(is_show=False)) .add_yaxis("q10", [round(float(v), 3) for v in q10], is_smooth=True, symbol="none", linestyle_opts=opts.LineStyleOpts(type_="dashed"), label_opts=opts.LabelOpts(is_show=False)) .add_yaxis("q90", [round(float(v), 3) for v in q90], is_smooth=True, symbol="none", linestyle_opts=opts.LineStyleOpts(type_="dashed"), label_opts=opts.LabelOpts(is_show=False)) .add_yaxis("seasonal naive", [round(float(v), 3) for v in pred_snaive[w]], is_smooth=True, symbol="none", linestyle_opts=opts.LineStyleOpts(type_="dotted"), label_opts=opts.LabelOpts(is_show=False)) .set_global_opts( title_opts=opts.TitleOpts(title="TimesFM zero-shot: 96 h ahead on one test window", subtitle="the q10-q90 band widens with horizon, which is the honest behaviour"), xaxis_opts=opts.AxisOpts(type_="value", name="hours ahead"), yaxis_opts=opts.AxisOpts(name="oil temperature", min_="dataMin"), tooltip_opts=opts.TooltipOpts(trigger="axis"), legend_opts=opts.LegendOpts(pos_top="8%"), ))cover =float(np.mean((Y_TRUE[w] >= q10) & (Y_TRUE[w] <= q90)))print(f"window 0: nominal 80% interval covers {cover:.0%} of the 96 actual hours, "f"mean width {np.mean(q90 - q10):.2f}")del timesfm, out, contextsfree_memory()vram("after timesfm")band.render_notebook()
window 0: nominal 80% interval covers 81% of the 96 actual hours, mean width 4.10
VRAM after timesfm 0.01 GB allocated / 0.02 GB reserved
10. PatchTST: training a transformer from scratch
PatchTST (Nie et al., ICLR 2023) is the design that made transformers work on long-horizon forecasting, and it did it by taking two things away:
Patching. Instead of one token per timestep, split the series into subsequence patches (16 steps here). A 336-step context becomes 21 tokens instead of 336, so attention cost drops by ~250x and each token carries local shape rather than a single scalar.
Channel independence. Every channel goes through the same backbone separately, with no cross-channel attention. Counter-intuitively this improves accuracy on these benchmarks - cross-channel attention mostly overfits with 7 channels and 10k rows.
It also uses RevIN-style instance normalisation (scaling="std"): each window is normalised by its own mean and standard deviation and denormalised on the way out, which is what lets one global model handle a non-stationary series whose level shifts.
The config below is deliberately small (0.11M parameters, d_model=64, 3 layers). It trains in under a minute on the RTX 3060 and gets most of the way to the published numbers; the paper’s configuration is roughly 10x larger and trains for 100 epochs.
from torch.utils.data import DataLoader, TensorDatasetfrom transformers import PatchTSTConfig, PatchTSTForPredictionPT_CTX =336# the context length the PatchTST paper uses on ETTh1# Normalise with TRAINING statistics only. Using the full-series mean here would leak# the test period into every window - the classic invisible forecasting bug.mu, sd = series[:TRAIN_END].mean(0), series[:TRAIN_END].std(0)Z = (series - mu) / sddef make_windows(lo, hi, stride=1):"Sliding (past, future) windows from Z[lo:hi]." starts =range(lo, hi - PT_CTX - HORIZON, stride) past = np.stack([Z[i: i + PT_CTX] for i in starts]) future = np.stack([Z[i + PT_CTX: i + PT_CTX + HORIZON] for i in starts])return torch.tensor(past), torch.tensor(future)past_tr, fut_tr = make_windows(0, TRAIN_END)past_va, fut_va = make_windows(TRAIN_END, VAL_END, stride=4)print(f"train windows {tuple(past_tr.shape)} val windows {tuple(past_va.shape)}")patchtst = PatchTSTForPrediction(PatchTSTConfig( num_input_channels=len(CHANNELS), context_length=PT_CTX, prediction_length=HORIZON, patch_length=16, patch_stride=16, # 336 / 16 = 21 tokens per channel d_model=64, num_hidden_layers=3, num_attention_heads=4, ffn_dim=128, dropout=0.2, head_dropout=0.2, scaling="std", # RevIN-style per-window normalisation loss="mse",)).to(device)print(f"PatchTST: {sum(p.numel() for p in patchtst.parameters()) /1e6:.3f}M params, "f"{PT_CTX //16} patches per channel")vram("patchtst init")
train windows (10020, 336, 7) val windows (763, 336, 7)
PatchTST: 0.109M params, 21 patches per channel
VRAM patchtst init 0.01 GB allocated / 0.02 GB reserved
EPOCHS =12opt = torch.optim.AdamW(patchtst.parameters(), lr=1e-3, weight_decay=1e-4)sched = torch.optim.lr_scheduler.OneCycleLR( opt, max_lr=1e-3, total_steps=EPOCHS * (len(past_tr) //64+1))# num_workers=0: only 4 vCPU, and these tensors are already in RAM.loader = DataLoader(TensorDataset(past_tr, fut_tr), batch_size=64, shuffle=True, num_workers=0)train_curve, val_curve = [], []best_val, best_state =float("inf"), Nonet0 = time.perf_counter()for epoch inrange(EPOCHS): patchtst.train() total =0.0for past, future in loader: loss = patchtst(past_values=past.to(device), future_values=future.to(device)).loss opt.zero_grad() loss.backward() opt.step() sched.step() total += loss.item() train_curve.append(total /len(loader)) patchtst.eval()with torch.inference_mode(): val_loss = patchtst(past_values=past_va.to(device), future_values=fut_va.to(device)).loss.item() val_curve.append(val_loss)# Validation bottoms out before training does, so keep the best state rather than# whatever the last epoch happens to leave behind - the standard early-stopping# discipline, done by hand because this loop is deliberately explicit.if val_loss < best_val: best_val, best_state = val_loss, {k: v.detach().clone()for k, v in patchtst.state_dict().items()}print(f"epoch {epoch +1:2d} train {train_curve[-1]:.4f} val {val_loss:.4f}"+ (" <- best"if val_loss == best_val else""))train_seconds = time.perf_counter() - t0patchtst.load_state_dict(best_state)print(f"\n{train_seconds:.0f}s total on {device}; restored the epoch with val {best_val:.4f}")del loader, past_tr, fut_tr, past_va, fut_va, best_state # keep the model, drop the restfree_memory()memory_report("after training")
epoch 1 train 0.6259 val 0.6265 <- best
epoch 2 train 0.5462 val 0.6042 <- best
epoch 3 train 0.5132 val 0.5344 <- best
epoch 4 train 0.4069 val 0.4069 <- best
epoch 5 train 0.3733 val 0.3973 <- best
epoch 6 train 0.3568 val 0.4017
epoch 7 train 0.3463 val 0.3915 <- best
epoch 8 train 0.3335 val 0.4056
epoch 9 train 0.3255 val 0.3967
epoch 10 train 0.3185 val 0.3991
epoch 11 train 0.3140 val 0.4021
epoch 12 train 0.3113 val 0.4027
18s total on cuda:0; restored the epoch with val 0.3915
RAM after training 16.08 / 20.97 GB
VRAM after training 0.02 GB allocated / 0.05 GB reserved
curve = ( Line() .add_xaxis([str(e) for e inrange(1, EPOCHS +1)]) .add_yaxis("train MSE", [round(v, 4) for v in train_curve], is_smooth=True, label_opts=opts.LabelOpts(is_show=False)) .add_yaxis("val MSE", [round(v, 4) for v in val_curve], is_smooth=True, label_opts=opts.LabelOpts(is_show=False)) .set_global_opts( title_opts=opts.TitleOpts(title="PatchTST training on ETTh1", subtitle="loss is on standardised units, so it is not comparable to the MAE table"), xaxis_opts=opts.AxisOpts(name="epoch"), yaxis_opts=opts.AxisOpts(name="MSE (standardised)"), tooltip_opts=opts.TooltipOpts(trigger="axis"), legend_opts=opts.LegendOpts(pos_top="8%"), ))curve.render_notebook()
# Score PatchTST on the SAME test windows as everything else. It needs PT_CTX=336# hours of context ending at the same point, so the windows line up exactly.past_test = torch.tensor(np.stack([Z[s + CTX - PT_CTX: s + CTX] for s in STARTS]))target_ch = CHANNELS.index(TARGET)patchtst.eval()t0 = time.perf_counter()with torch.inference_mode(): z_pred = patchtst(past_values=past_test.to(device)).prediction_outputs.float().cpu().numpy()elapsed = time.perf_counter() - t0# Undo the training-set standardisation to get back to degrees.pred_patchtst = z_pred[:, :, target_ch] * sd[target_ch] + mu[target_ch]results.append(score("PatchTST (trained here)", pred_patchtst, elapsed))print(f"\ntraining cost {train_seconds:.0f}s; inference {elapsed *1000:.0f}ms for "f"{len(STARTS)} windows")del patchtst, past_testfree_memory()vram("after patchtst")
PatchTST (trained here) MAE 2.274 RMSE 2.914 MASE 0.933 sMAPE 31.08% 0.00s
training cost 18s; inference 2ms for 18 windows
VRAM after patchtst 0.02 GB allocated / 0.05 GB reserved
11. Head-to-head Benchmark
Same 18 test windows, same 512-hour context ending at the same timestamp, same horizon, same metrics. Two caveats that matter for reading the table honestly:
The comparison is not apples to apples on training data. TimesFM never saw ETTh1 in this notebook (though ETT-like data may well be in its pretraining corpus - that is the standing caveat with every foundation model benchmark). PatchTST was trained on the first 60% of this exact series. That is the realistic choice you face, not a flaw in the experiment.
18 windows on one channel of one dataset is a smoke test. GIFT-Eval runs 24 datasets and 144k series for a reason. Treat the ordering here as indicative, and read MASE rather than MAE if you want to compare against anything outside this notebook.
And the result is exactly the warning from section 2. Measured on this box: TimesFM zero-shot lands at MASE 0.82, and the strongest naive baseline - repeat the last value - lands at 0.95. The small PatchTST trained here comes in at 0.94: it clears the baseline, by about one percent. Sixteen seconds of training, 10,020 windows, and a purpose-built long-horizon architecture, to beat “assume nothing changes” by one percent.
That is not an argument against PatchTST; it is an argument for always printing the baseline. The paper’s configuration is roughly 10x larger and trains for 100 epochs, and 12 epochs of a 0.11M-parameter model is not that. What the number does show is how easy it would be to report “we trained a transformer forecaster, MAE 2.28” as a success and never discover that repeating the last observation scored 2.31 - and that the 500M model nobody trained scored 2.00.
Hardware: knowledge-lab, RTX 3060 (12 GB) and 4 vCPU.
bench = pd.DataFrame(results).sort_values("mase").reset_index(drop=True)BASELINES = ["naive (last value)", "seasonal naive (24 h)", "seasonal mean (7 days)"]best_baseline = bench[bench["model"].isin(BASELINES)].iloc[0]bench["beats_baselines"] = bench["mase"] < best_baseline["mase"]print(f"best baseline: {best_baseline['model']} at MASE {best_baseline['mase']:.3f} - ""any model that does not clear this line has not earned its complexity")memory_report("after benchmark")bench.round(4)
best baseline: naive (last value) at MASE 0.947 - any model that does not clear this line has not earned its complexity
RAM after benchmark 16.08 / 20.97 GB
VRAM after benchmark 0.02 GB allocated / 0.05 GB reserved
model
mae
rmse
mase
smape
seconds
beats_baselines
0
TimesFM 2.0 (zero-shot)
1.9986
2.6654
0.8205
26.4215
0.3518
True
1
PatchTST (trained here)
2.2736
2.9138
0.9335
31.0761
0.0023
True
2
naive (last value)
2.3065
3.0250
0.9470
35.4684
0.0001
False
3
seasonal mean (7 days)
2.5243
3.1866
1.0364
34.5552
0.0001
False
4
seasonal naive (24 h)
2.5452
3.2917
1.0449
36.7166
0.0001
False
from pyecharts.charts import Barnames = bench["model"].tolist()metric_bar = ( Bar() .add_xaxis(names) .add_yaxis("MASE", [round(float(v), 3) for v in bench["mase"]]) .add_yaxis("MAE", [round(float(v), 3) for v in bench["mae"]]) .set_series_opts(label_opts=opts.LabelOpts(is_show=False)) .set_global_opts( title_opts=opts.TitleOpts( title="ETTh1 OT, 96 h horizon: error by model", subtitle=f"{len(STARTS)} rolling test windows - lower is better"), xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=20, font_size=10)), yaxis_opts=opts.AxisOpts(name="error"), tooltip_opts=opts.TooltipOpts(trigger="axis"), legend_opts=opts.LegendOpts(pos_top="8%"), ))metric_bar.render_notebook()
# Error against horizon: every model is good at hour 1 and the question is how fast# it decays. A flat curve means the model found real structure; a steep one means it# is mostly extrapolating the last observation.by_h = Line().add_xaxis([str(h +1) for h inrange(HORIZON)])for name, preds in [("naive (last value)", pred_last), ("seasonal naive (24 h)", pred_snaive), ("seasonal mean (7 days)", pred_smean), ("TimesFM 2.0 (zero-shot)", pred_tfm), ("PatchTST (trained here)", pred_patchtst)]: mae_h = np.abs(preds - Y_TRUE).mean(axis=0) by_h.add_yaxis(name, [round(float(v), 3) for v in mae_h], is_smooth=True, symbol="none", label_opts=opts.LabelOpts(is_show=False))by_h.set_global_opts( title_opts=opts.TitleOpts(title="MAE by forecast horizon", subtitle="how fast each model decays as it looks further ahead"), xaxis_opts=opts.AxisOpts(name="hours ahead", axislabel_opts=opts.LabelOpts(interval=11)), yaxis_opts=opts.AxisOpts(name="MAE"), tooltip_opts=opts.TooltipOpts(trigger="axis"), legend_opts=opts.LegendOpts(pos_top="8%"),)by_h.render_notebook()
# The forecasts themselves, on the window where the models disagree most.spread = np.array([np.abs(pred_tfm[i] - pred_patchtst[i]).mean() for i inrange(len(STARTS))])w =int(np.argmax(spread))compare = ( Line() .add_xaxis(list(range(-72, HORIZON))) .add_yaxis("context + actual", [round(float(v), 3) for v in np.concatenate([CONTEXTS[w, -72:], Y_TRUE[w]])], is_smooth=True, symbol="none", linestyle_opts=opts.LineStyleOpts(width=2), label_opts=opts.LabelOpts(is_show=False)))for name, preds in [("seasonal naive", pred_snaive), ("TimesFM 2.0", pred_tfm), ("PatchTST", pred_patchtst)]: compare.add_yaxis(name, [None] *72+ [round(float(v), 3) for v in preds[w]], is_smooth=True, symbol="none", label_opts=opts.LabelOpts(is_show=False))compare.set_global_opts( title_opts=opts.TitleOpts( title=f"Test window {w}: the 72 h before, then 96 h of forecast", subtitle="chosen as the window where TimesFM and PatchTST disagree most"), xaxis_opts=opts.AxisOpts(type_="value", name="hours (0 = forecast origin)"), yaxis_opts=opts.AxisOpts(name="oil temperature", min_="dataMin"), tooltip_opts=opts.TooltipOpts(trigger="axis"), legend_opts=opts.LegendOpts(pos_top="8%"),)compare.render_notebook()
12. Backtesting: one split is not an evaluation
Everything above used a fixed set of windows in the last 20% of the series. That is already better than a single train/test cut, but a real evaluation uses rolling-origin backtesting: advance the forecast origin through time, refit (or at least re-context) at each origin, and average the metric over origins. It is the only way to see that a model was excellent in spring and useless in autumn.
The cell measures the seasonal-naive and TimesFM error per month of test period, which is the cheapest version of the same idea. Watch how much the error moves between months - that spread is the honest uncertainty on any single number in the benchmark table above, and it is usually larger than the gap between the models.
months = pd.to_datetime(df["date"]).dt.to_period("M").to_numpy()window_month = np.array([str(months[s + CTX]) for s in STARTS])rows = []for name, preds in [("seasonal naive (24 h)", pred_snaive), ("TimesFM 2.0 (zero-shot)", pred_tfm), ("PatchTST (trained here)", pred_patchtst)]: per_window = np.abs(preds - Y_TRUE).mean(axis=1)for m insorted(set(window_month)): sel = window_month == m rows.append(dict(model=name, month=m, mae=float(per_window[sel].mean()), windows=int(sel.sum())))per_month = pd.DataFrame(rows)pivot = per_month.pivot(index="month", columns="model", values="mae")drift = Line().add_xaxis(list(pivot.index))for col in pivot.columns: drift.add_yaxis(col, [round(float(v), 3) for v in pivot[col]], is_smooth=True, symbol="circle", symbol_size=7, label_opts=opts.LabelOpts(is_show=False))drift.set_global_opts( title_opts=opts.TitleOpts(title="MAE by month of the test period", subtitle="the month-to-month spread is bigger than the model-to-model gap"), xaxis_opts=opts.AxisOpts(name="month", axislabel_opts=opts.LabelOpts(rotate=30)), yaxis_opts=opts.AxisOpts(name="MAE"), tooltip_opts=opts.TooltipOpts(trigger="axis"), legend_opts=opts.LegendOpts(pos_top="8%"),)print(pivot.round(3).to_string())print("\nspread within a model across months:")print((pivot.max() - pivot.min()).round(3).to_string())drift.render_notebook()
model PatchTST (trained here) TimesFM 2.0 (zero-shot) seasonal naive (24 h)
month
2018-02 1.915 1.412 0.947
2018-03 2.691 2.288 3.114
2018-04 2.327 1.756 2.565
2018-05 2.733 2.658 3.072
2018-06 1.328 1.366 1.687
spread within a model across months:
model
PatchTST (trained here) 1.405
TimesFM 2.0 (zero-shot) 1.292
seasonal naive (24 h) 2.167
13. Common Frameworks
Forecasting has two ecosystems that only recently started talking to each other. The established one - statsmodels, Nixtla, sktime, Darts - is built around fitting many series in parallel, covariates, hierarchies and rolling backtests, and it is what production forecasting actually runs on. The new one is foundation models in transformers. The second is more exciting; the first is where the accuracy on business data still comes from, mostly because it handles covariates and the other does not.
Rolling-origin evaluation, MASE, pinball loss and CRPS, averaged over origins and series
Apache 2.0
Always. Section 12 is the point: one split is not an evaluation
The 2026 default stack is statsforecast for the baselines, mlforecast with LightGBM once you have covariates, a fine-tuned foundation model from transformers when you have many related series and few covariates, reconciliation if there is a hierarchy, and rolling-origin backtesting deciding between them.
The common wrong turn is reaching for a foundation model on a business series with strong known drivers. No model in section 6 takes future covariates as gracefully as LightGBM or TFT, and for promotions, holidays, prices and weather the covariate is worth more than the architecture. The second is forecasting the mean: inventory, capacity and bidding all consume a quantile, so predict the distribution and score it with pinball loss or CRPS. For mostly-zero demand, handle intermittency explicitly - and stop using MAPE, which is undefined there.
14. Going Further
Use your covariates. No foundation model in section 6 takes future covariates as gracefully as LightGBM or TFT do, and for most business series (promotions, holidays, published prices, weather forecasts) the covariate is worth more than the architecture. If you have them, that is where the accuracy is.
Fine-tune the foundation model instead of training from scratch. TimesFM, Chronos and Moirai all fine-tune on a target dataset in far less compute than training a PatchTST, and usually beat both zero-shot and from-scratch. PatchTSTForPrediction.from_pretrained("ibm-granite/granite-timeseries-patchtst") is a transformers-native pretrained starting point if you want to stay inside transformers.
Forecast the distribution, not the mean. Set loss="nll" with a distribution_output on PatchTST, use the quantile heads TimesFM already returns, or fit LightGBM at several quantiles. Then score with pinball loss / CRPS and check coverage. Inventory, capacity and bidding all consume a quantile, never a mean.
Reconcile hierarchies. If store-level forecasts must sum to region and national totals, forecast every level and reconcile (MinT, hierarchicalforecast). Forecasting only the bottom level and adding up is measurably worse.
Handle intermittency separately. For mostly-zero demand, Croston’s method, ADIDA, or a Tweedie objective in LightGBM beat anything that assumes a continuous target - and MAPE/sMAPE stop being meaningful entirely.
Backtest properly before believing any of this. Rolling origin, multiple horizons, metrics averaged over origins and series, and the naive baseline printed next to every number.
Related notebooks in this repo:01_Tabular_Regression (the same target type when the rows are exchangeable), 00_Tabular_Classification (turning a forecast into a decision), and Audio/04_Audio_Classification (the other place sliding windows over a 1-D signal show up).