Article / Forecasting

Statistical forecasting: from baselines to ETS, SARIMA, and Theta

Build classical forecasting from first principles, from baselines and exponential smoothing to ETS, SARIMA, Theta, and Croston, then fit and backtest them with statsforecast.

Aayush Raj Regmi27 min read
In this article · 13 sections

Statistical forecasting models are small. A simple exponential smoothing model has one parameter you tune and one number it remembers. A seasonal ARIMA model that fits a famous airline dataset well has two coefficients. These models are not glamorous, yet they remain the first thing to try on a new series. They train in milliseconds, they produce honest prediction intervals, and when a larger model loses to them in a backtest, you have learned something important about your data.

This guide builds the classical toolbox from first principles: baselines, exponential smoothing and its state-space form (ETS), stationarity and the ARIMA family, the Theta method, and Croston's method for intermittent demand. Each idea starts with what the symbols mean and a tiny numeric example before the general equation. Then we fit all of them with Nixtla's statsforecast on the AirPassengers series, check them with rolling-origin backtests, and hand-write the smoothing recursions in NumPy so nothing stays a black box. If forecast origins, rolling backtests, or MASE are new to you, read the mathematical intuition guide first. This guide assumes those ideas and goes deeper into the models.

The running example and four honest baselines

AirPassengers records monthly totals of international airline passengers, in thousands, from January 1949 to December 1960: 144 observations. It has a clear upward trend, a strong 12-month seasonal pattern with peaks in July and August, and one more feature that will matter a great deal later: the seasonal swings get larger as the level rises. In 1949 the gap between the busiest and quietest month is about 50 thousand passengers; by 1960 it is over 200 thousand.

Throughout the series of guides we hold out the last 12 months (1960) as a test set, forecast horizon h=12h = 12, and season length m=12m = 12. We report MAE, RMSE and MASE, where MASE divides the test MAE by the in-sample MAE of a seasonal naive forecast. A MASE below 1 means the model beat "repeat last year" on average over the test year, and above 1 means it lost.

Write y1,…,yTy_1, \dots, y_T for the training observations and y^T+h∣T\hat{y}_{T+h \mid T} for the forecast of yT+hy_{T+h} made with data up to time TT. Four baselines need no fitting beyond simple arithmetic.

Naive. Tomorrow looks like today:

y^T+h∣T=yT.\hat{y}_{T+h \mid T} = y_T.

Seasonal naive. This month looks like the same month last year. With k=⌊(h−1)/m⌋k = \lfloor (h-1)/m \rfloor counting how many full seasons ahead we are:

y^T+h∣T=yT+h−m(k+1).\hat{y}_{T+h \mid T} = y_{T+h-m(k+1)}.

For h≤mh \le m this is simply yT+h−my_{T+h-m}: the January 1960 forecast is the January 1959 value.

Mean. Everything is noise around one constant:

y^T+h∣T=yˉ=1T∑t=1Tyt.\hat{y}_{T+h \mid T} = \bar{y} = \frac{1}{T}\sum_{t=1}^{T} y_t.

Drift. Draw a straight line from the first observation to the last and extend it:

y^T+h∣T=yT+h yT−y1T−1.\hat{y}_{T+h \mid T} = y_T + h\,\frac{y_T - y_1}{T-1}.

A tiny example: if y1=100y_1 = 100, yT=160y_T = 160 and T=61T = 61, the average step is 60/60=160/60 = 1, so the 12-step drift forecast is 172172. The drift method is a random walk whose average step is estimated from the whole history.

Each baseline encodes one assumption: no change (naive), repeat the season (seasonal naive), no structure (mean), constant growth (drift). Any model you fit should be compared against the one whose assumption best fits your series. For monthly airline traffic, that is seasonal naive.

Simple exponential smoothing and Holt's trend

The naive forecast trusts only the last observation; the mean trusts all observations equally. Simple exponential smoothing (SES) sits between them. It keeps a running level ℓt\ell_t, an estimate of where the series currently sits, and updates it with each new observation:

ℓt=αyt+(1−α)ℓt−1,y^T+h∣T=ℓT,0≤α≤1.\ell_t = \alpha y_t + (1-\alpha)\ell_{t-1}, \qquad \hat{y}_{T+h \mid T} = \ell_T, \qquad 0 \le \alpha \le 1.

Rearranged, the same update reads ℓt=ℓt−1+α(yt−ℓt−1)\ell_t = \ell_{t-1} + \alpha(y_t - \ell_{t-1}): move the level a fraction α\alpha of the way toward the surprise. The quantity yt−ℓt−1y_t - \ell_{t-1} is the one-step forecast error, so SES is an error-correction rule.

Deriving the geometric weights

Substitute the update into itself. One step back:

ℓt=αyt+(1−α)[αyt−1+(1−α)ℓt−2]=αyt+α(1−α)yt−1+(1−α)2ℓt−2.\ell_t = \alpha y_t + (1-\alpha)\bigl[\alpha y_{t-1} + (1-\alpha)\ell_{t-2}\bigr] = \alpha y_t + \alpha(1-\alpha)y_{t-1} + (1-\alpha)^2 \ell_{t-2}.

Keep going until you reach the initial level ℓ0\ell_0:

ℓt=∑j=0t−1α(1−α)j yt−j+(1−α)tℓ0.\ell_t = \sum_{j=0}^{t-1} \alpha(1-\alpha)^j\, y_{t-j} + (1-\alpha)^t \ell_0.

The weight on an observation jj steps old is α(1−α)j\alpha(1-\alpha)^j, a geometric sequence. Do these weights form a proper average? The geometric sum gives ∑j=0t−1α(1−α)j=1−(1−α)t\sum_{j=0}^{t-1}\alpha(1-\alpha)^j = 1-(1-\alpha)^t, and adding the weight (1−α)t(1-\alpha)^t on ℓ0\ell_0 gives exactly 1. So the level is a weighted average of all past data and the starting value, with weights that decay exponentially with age. That is where the name comes from.

With α=0.3\alpha = 0.3 the weights on the most recent six observations are 0.3,0.21,0.147,0.103,0.072,0.0500.3, 0.21, 0.147, 0.103, 0.072, 0.050, which together account for about 0.880.88 of the total. With α=0.9\alpha = 0.9 the newest point alone gets 0.90.9 and the model behaves almost like naive. With α=0.05\alpha = 0.05 the memory is long and the model behaves more like a slowly moving mean. Fitting SES means choosing α\alpha (and ℓ0\ell_0) to minimise the sum of squared one-step errors, or equivalently maximising a likelihood, which we will meet in the ETS section.

The forecast is flat: every horizon gets ℓT\ell_T. SES has no notion of direction, which is fine for a series that wanders without trend and wrong for one that grows.

Holt's linear trend

Holt added a second state, the trend (slope) btb_t, an estimate of how much the level changes per period:

ℓt=αyt+(1−α)(ℓt−1+bt−1),bt=β(ℓt−ℓt−1)+(1−β) bt−1,y^T+h∣T=ℓT+h bT.\begin{aligned} \ell_t &= \alpha y_t + (1-\alpha)(\ell_{t-1} + b_{t-1}), \\ b_t &= \beta(\ell_t - \ell_{t-1}) + (1-\beta)\, b_{t-1}, \\ \hat{y}_{T+h \mid T} &= \ell_T + h\, b_T. \end{aligned}

Read the level equation as: blend the new observation with where last period's level plus slope said we would be. Read the trend equation as SES applied to the observed level changes. Example: ℓt−1=200\ell_{t-1} = 200, bt−1=2b_{t-1} = 2, yt=206y_t = 206, α=0.5\alpha = 0.5, β=0.2\beta = 0.2. The prediction was 202202, so ℓt=0.5⋅206+0.5⋅202=204\ell_t = 0.5 \cdot 206 + 0.5 \cdot 202 = 204. The level rose by 44, so bt=0.2⋅4+0.8⋅2=2.4b_t = 0.2 \cdot 4 + 0.8 \cdot 2 = 2.4. The model nudged its growth estimate up because growth surprised it.

Damped trend

Extending a straight line 24 or 36 steps ahead usually overshoots, because real growth rarely continues at a constant rate forever. A damping parameter 0<ϕ<10 \lt \phi \lt 1 shrinks each future increment:

y^T+h∣T=ℓT+(ϕ+ϕ2+⋯+ϕh) bT.\hat{y}_{T+h \mid T} = \ell_T + (\phi + \phi^2 + \cdots + \phi^h)\, b_T.

As h→∞h \to \infty the sum approaches ϕ/(1−ϕ)\phi/(1-\phi), so forecasts flatten to ℓT+bT ϕ/(1−ϕ)\ell_T + b_T\,\phi/(1-\phi). With ϕ=0.9\phi = 0.9 the long-run extra growth is at most 9bT9 b_T. The state recursions also gain a ϕ\phi: ℓt=αyt+(1−α)(ℓt−1+ϕbt−1)\ell_t = \alpha y_t + (1-\alpha)(\ell_{t-1} + \phi b_{t-1}) and bt=β(ℓt−ℓt−1)+(1−β)ϕ bt−1b_t = \beta(\ell_t - \ell_{t-1}) + (1-\beta)\phi\, b_{t-1}. Values of ϕ\phi between 0.80.8 and 0.980.98 are typical; ϕ=1\phi = 1 recovers Holt.

Holt-Winters: additive and multiplicative seasonality

Holt-Winters adds a third state: a seasonal index sts_t for each position in the cycle. With 12 months there are 12 seasonal values, and each one is updated only when its month comes around.

Additive seasonality

The additive version says the July effect is a fixed number of passengers added on top of the level:

ℓt=α(yt−st−m)+(1−α)(ℓt−1+bt−1),bt=β(ℓt−ℓt−1)+(1−β) bt−1,st=γ(yt−ℓt−1−bt−1)+(1−γ) st−m,y^T+h∣T=ℓT+h bT+sT+h−m(k+1).\begin{aligned} \ell_t &= \alpha(y_t - s_{t-m}) + (1-\alpha)(\ell_{t-1} + b_{t-1}), \\ b_t &= \beta(\ell_t - \ell_{t-1}) + (1-\beta)\, b_{t-1}, \\ s_t &= \gamma(y_t - \ell_{t-1} - b_{t-1}) + (1-\gamma)\, s_{t-m}, \\ \hat{y}_{T+h \mid T} &= \ell_T + h\, b_T + s_{T+h-m(k+1)}. \end{aligned}

The level update first removes last year's seasonal effect for this month (yt−st−my_t - s_{t-m}, a seasonally adjusted observation), then smooths as before. The seasonal update compares the observation with the non-seasonal prediction and smooths that deviation into the month's index. The usual constraint is 0≤γ≤1−α0 \le \gamma \le 1 - \alpha.

Multiplicative seasonality

The multiplicative version says July is a fixed percentage above the level, so the seasonal swing scales with the level:

ℓt=α ytst−m+(1−α)(ℓt−1+bt−1),st=γ ytℓt−1+bt−1+(1−γ) st−m,y^T+h∣T=(ℓT+h bT) sT+h−m(k+1).\begin{aligned} \ell_t &= \alpha\,\frac{y_t}{s_{t-m}} + (1-\alpha)(\ell_{t-1} + b_{t-1}), \\ s_t &= \gamma\,\frac{y_t}{\ell_{t-1} + b_{t-1}} + (1-\gamma)\, s_{t-m}, \\ \hat{y}_{T+h \mid T} &= (\ell_T + h\, b_T)\, s_{T+h-m(k+1)}. \end{aligned}

Here the seasonal indices hover around 1: a July index of 1.221.22 means July runs 22% above the deseasonalised level. The trend equation is unchanged.

Which one fits AirPassengers? The seasonal swing grows from about 50 to over 200 thousand while the level roughly quadruples, so the effect is proportional and multiplicative seasonality is the natural choice. There is a second route: take logarithms. Since log⁡(ℓ⋅s)=log⁡ℓ+log⁡s\log(\ell \cdot s) = \log \ell + \log s, a multiplicative pattern becomes additive on the log scale, and any additive model can be used there. We will try both routes.

ETS: the state-space view and model selection

The smoothing recursions above are methods: rules that produce point forecasts. They become models once we say how randomness enters, and that step unlocks likelihoods, automatic model selection, and prediction intervals.

The simplest case

Let εt\varepsilon_t be independent noise with mean zero and variance σ2\sigma^2. The model ETS(A,N,N) reads:

yt=ℓt−1+εt(observation equation)ℓt=ℓt−1+α εt(state equation)\begin{aligned} y_t &= \ell_{t-1} + \varepsilon_t \qquad &\text{(observation equation)} \\ \ell_t &= \ell_{t-1} + \alpha\, \varepsilon_t \qquad &\text{(state equation)} \end{aligned}

Since εt=yt−ℓt−1\varepsilon_t = y_t - \ell_{t-1} is the one-step error, the state equation is exactly SES in error-correction form. The same single noise term drives both the observation and the state, which is why these are called innovations state-space models.

The multiplicative-error version ETS(M,N,N) uses relative errors: yt=ℓt−1(1+εt)y_t = \ell_{t-1}(1 + \varepsilon_t) and ℓt=ℓt−1(1+αεt)\ell_t = \ell_{t-1}(1 + \alpha\varepsilon_t). Its point forecasts are identical to ETS(A,N,N) but its noise grows with the level, so its intervals differ.

The taxonomy

ETS stands for Error, Trend, Seasonal. Each letter takes a value:

ComponentOptionsMeaning
ErrorA, Madditive or multiplicative noise
TrendN, A, Adnone, linear, damped linear
SeasonalN, A, Mnone, additive, multiplicative

So SES is ETS(A,N,N) or ETS(M,N,N), Holt is ETS(A,A,N), damped Holt is ETS(A,Ad,N), and additive Holt-Winters is ETS(A,A,A). In general, with a state vector xtx_t holding level, trend and seasonal values:

yt=w(xt−1)+r(xt−1) εt,xt=f(xt−1)+g(xt−1) εt,y_t = w(x_{t-1}) + r(x_{t-1})\,\varepsilon_t, \qquad x_t = f(x_{t-1}) + g(x_{t-1})\,\varepsilon_t,

where ww maps states to a one-step prediction, ff says how states evolve without noise, and rr, gg say how noise enters. For additive-error models r=1r = 1; for multiplicative errors r(xt−1)=w(xt−1)r(x_{t-1}) = w(x_{t-1}).

flowchart LR
  S0["State at t-1<br/>level, trend, seasons"] --> W["Predict y_t from states"]
  W --> Y["Observe y_t"]
  Y --> E["Innovation<br/>error = actual minus prediction"]
  E --> U["Update states<br/>using alpha, beta, gamma"]
  S0 --> U
  U --> S1["State at t"]
  S1 -.->|"next period"| S0

Likelihood and AICc

Because the model specifies a distribution for each one-step error, the parameters (smoothing weights, ϕ\phi, and the initial states) can be estimated by maximum likelihood. For additive Gaussian errors, maximising the likelihood is equivalent to minimising the sum of squared one-step errors.

To choose among the candidate ETS models, fit each one and compare the corrected Akaike information criterion:

AIC=−2log⁡L+2k,AICc=AIC+2k(k+1)n−k−1,\text{AIC} = -2\log L + 2k, \qquad \text{AICc} = \text{AIC} + \frac{2k(k+1)}{n-k-1},

where LL is the maximised likelihood, kk the number of estimated parameters including initial states, and nn the number of observations. AIC rewards fit and charges a price per parameter; the correction term matters when nn is small relative to kk. A seasonal model with m=12m = 12 has around 15 or more parameters, so the correction is not negligible on 132 points. AutoETS performs exactly this search. Two warnings: AICc is an in-sample criterion, so it can prefer a model that forecasts poorly, and AICc values are only comparable between models fitted to the same data on the same scale.

Stationarity, differencing and autocorrelation

ARIMA models take a different view. Instead of describing level, trend and season as states, they describe how each value correlates with its own past. That only makes sense if those correlations are stable over time.

A series is (weakly) stationary if its mean, its variance, and its covariance between values kk steps apart do not depend on tt. AirPassengers is not: its mean rises, its variance rises, and the seasonal pattern repeats. White noise, independent draws with constant mean and variance, is the simplest stationary series.

Making a series stationary

Three standard moves, applied in order:

  1. Stabilise the variance. When fluctuations scale with the level, take log⁡yt\log y_t. For AirPassengers this turns growing seasonal swings into roughly constant ones.
  2. Remove the trend with a first difference: yt′=yt−yt−1y'_t = y_t - y_{t-1}. The differenced series records monthly changes rather than levels.
  3. Remove the season with a seasonal difference: yt−yt−my_t - y_{t-m}, the change from the same month last year.

ACF and PACF

The sample autocorrelation at lag kk measures how strongly the series correlates with itself shifted by kk:

rk=∑t=k+1T(yt−yˉ)(yt−k−yˉ)∑t=1T(yt−yˉ)2.r_k = \frac{\sum_{t=k+1}^{T}(y_t - \bar{y})(y_{t-k} - \bar{y})}{\sum_{t=1}^{T}(y_t - \bar{y})^2}.

The partial autocorrelation at lag kk is the correlation between yty_t and yt−ky_{t-k} after removing the part explained by the lags in between. A useful mental picture: ACF counts all paths of influence from kk steps back; PACF counts only the direct one.

Here are actual values on the log training data (computed with the formula above):

Serieslag 1lag 2lag 3lag 12
log⁡yt\log y_t0.950.890.840.75
first difference0.19-0.13-0.150.83
first and seasonal difference-0.320.11-0.22-0.41

Slowly decaying correlations near 1 are the signature of a trend: the series is not stationary. After one difference the trend is gone, but lag 12 at 0.830.83 shows the season is still there. After both differences the remaining structure is a negative spike at lag 1 and another at lag 12, with little else. Keep that pattern in mind; it points directly at a model.

Rules of thumb for identifying orders on a stationary series: an AR(p) process has a PACF that cuts off after lag pp and an ACF that decays gradually; an MA(q) process has an ACF that cuts off after lag qq and a PACF that decays. Real data rarely looks this clean, which is why automatic search exists.

AR, MA, ARIMA and SARIMA

Backshift notation

The backshift operator BB moves a series one step into the past: Byt=yt−1B y_t = y_{t-1}, and Bkyt=yt−kB^k y_t = y_{t-k}. It turns differencing into algebra: the first difference is (1−B)yt(1-B)y_t, a second difference is (1−B)2yt(1-B)^2 y_t, and a seasonal difference is (1−Bm)yt(1-B^m)y_t. Operators multiply like polynomials, which is what makes the seasonal model compact.

Autoregressive and moving-average pieces

An AR(p) model regresses the value on its own past pp values:

yt=c+ϕ1yt−1+⋯+ϕpyt−p+εt⟺ϕ(B) yt=c+εt,y_t = c + \phi_1 y_{t-1} + \cdots + \phi_p y_{t-p} + \varepsilon_t \quad\Longleftrightarrow\quad \phi(B)\,y_t = c + \varepsilon_t,

with ϕ(B)=1−ϕ1B−⋯−ϕpBp\phi(B) = 1 - \phi_1 B - \cdots - \phi_p B^p. The intuition guide works through an AR(1) where a shock decays by a factor ϕ\phi each step.

An MA(q) model uses past forecast errors, not past values:

yt=μ+εt+θ1εt−1+⋯+θqεt−q⟺yt=μ+θ(B) εt.y_t = \mu + \varepsilon_t + \theta_1\varepsilon_{t-1} + \cdots + \theta_q\varepsilon_{t-q} \quad\Longleftrightarrow\quad y_t = \mu + \theta(B)\,\varepsilon_t.

Example with MA(1), μ=50\mu = 50, θ1=−0.4\theta_1 = -0.4: if last month's surprise was εt−1=+10\varepsilon_{t-1} = +10, this month's expected value is 50−4=4650 - 4 = 46. Part of a surprise is reversed one step later and then forgotten entirely. An MA(1) has an ACF with a single spike at lag 1 and nothing after it, exactly as the rule of thumb said.

An ARMA(p,q) model combines both: ϕ(B)yt=c+θ(B)εt\phi(B)y_t = c + \theta(B)\varepsilon_t.

ARIMA

ARIMA(p,d,q) applies ARMA to the series after dd differences:

ϕ(B)(1−B)d yt=c+θ(B) εt.\phi(B)(1-B)^d\, y_t = c + \theta(B)\,\varepsilon_t.

The I stands for integrated: forecasts are built on the differenced scale and summed back up. A useful fact links this to the previous sections: ARIMA(0,1,1) produces the same point forecasts as SES, and ARIMA(0,2,2) matches Holt's linear method. The two families overlap; they are not rivals from different worlds.

Seasonal ARIMA

SARIMA adds seasonal AR, differencing and MA terms that operate at lag mm, written SARIMA(p,d,q)(P,D,Q)m\text{SARIMA}(p,d,q)(P,D,Q)_m:

Φ(Bm) ϕ(B) (1−Bm)D(1−B)d yt=c+Θ(Bm) θ(B) εt.\Phi(B^m)\,\phi(B)\,(1-B^m)^D (1-B)^d\, y_t = c + \Theta(B^m)\,\theta(B)\,\varepsilon_t.

Capital letters are seasonal polynomials in BmB^m. Return to the ACF table: after d=1d = 1 and D=1D = 1 on the log scale, we saw negative spikes at lag 1 and lag 12. A non-seasonal MA(1) explains the lag-1 spike and a seasonal MA(1) explains the lag-12 spike. That is ARIMA(0,1,1)(0,1,1)12\text{ARIMA}(0,1,1)(0,1,1)_{12} on log⁡yt\log y_t, so famous it is called the airline model:

(1−B)(1−B12)log⁡yt=(1+θ1B)(1+Θ1B12) εt.(1-B)(1-B^{12})\log y_t = (1 + \theta_1 B)(1 + \Theta_1 B^{12})\,\varepsilon_t.

When we run AutoARIMA on the log training data below, it selects exactly this model, with θ1≈−0.33\theta_1 \approx -0.33 and Θ1≈−0.58\Theta_1 \approx -0.58. On the raw scale it picks a different model, and the log version forecasts noticeably better. The log transform is not cosmetic: it makes the constant-variance assumption roughly true, and every interval and likelihood calculation leans on that assumption.

How AutoARIMA searches

Hyndman and Khandakar's algorithm, which statsforecast reimplements, works in three steps. First choose DD with a seasonal strength test and dd with repeated KPSS unit-root tests, because AICc cannot compare models fitted to differently differenced data. Then fit a few starting models and take the best by AICc. Finally, do a stepwise search: vary pp, qq, PP, QQ by plus or minus one, toggle the constant, keep any improvement, and stop when no neighbour improves. This visits a few dozen models instead of hundreds.

That automates the classical Box-Jenkins loop:

flowchart TD
  A["Plot the series"] --> B{"Variance grows with level?"}
  B -->|"yes"| C["Log or Box-Cox transform"]
  B -->|"no"| D["Check stationarity"]
  C --> D
  D --> E["Difference: d and seasonal D"]
  E --> F["Identify p, q, P, Q from ACF and PACF"]
  F --> G["Estimate by maximum likelihood"]
  G --> H{"Residuals look like white noise?"}
  H -->|"no"| F
  H -->|"yes"| I["Forecast and backtest"]

The Theta method and Croston for intermittent demand

Theta

The Theta method was introduced by Assimakopoulos and Nikolopoulos and became well known after performing strongly in the M3 forecasting competition. The original description decomposes a series into "theta lines" that exaggerate or flatten its local curvature: the θ=0\theta = 0 line is a straight regression line through the data (pure long-run trend), and the θ=2\theta = 2 line doubles the short-term wiggles. The forecast extrapolates the θ=0\theta = 0 line, forecasts the θ=2\theta = 2 line with SES, and averages the two.

Hyndman and Billah later showed that, for the standard version, this is equivalent to SES with drift, where the drift equals half the slope of a linear trend fitted to the data:

y^T+h∣T≈ℓT+12b0(h−1+1α)+a small term that fades with T,\hat{y}_{T+h \mid T} \approx \ell_T + \tfrac{1}{2} b_0 \Bigl(h - 1 + \frac{1}{\alpha}\Bigr) + \text{a small term that fades with } T,

where ℓT\ell_T is the SES level and b0b_0 the regression slope. In plain words: a smoothed level plus a conservative, half-strength trend. Seasonality is handled by deseasonalising first (classically with multiplicative decomposition) and reseasonalising the forecast. AutoTheta chooses among standard, optimised and dynamic variants by in-sample fit; on our training data it chose the optimised Theta model with multiplicative decomposition.

Croston and intermittent demand

Spare parts, slow-moving retail items and many inventory series are mostly zeros with occasional non-zero values. SES on such a series is pulled down after every zero and spikes after every sale, so its forecast immediately after a sale is biased high. Croston's method splits the series into two parts:

  • zz: the size of each non-zero demand,
  • pp: the number of periods between non-zero demands.

Both are smoothed with SES, updated only when a demand occurs, and the forecast per period is their ratio:

y^T+h∣T=z^Tp^T.\hat{y}_{T+h \mid T} = \frac{\hat{z}_T}{\hat{p}_T}.

Example: typical order size 55, typical gap 44 weeks, forecast 1.251.25 units per week. That is a rate for planning stock, not a prediction that next week will have 1.251.25 sales. The Syntetos-Boylan approximation (SBA) multiplies by (1−α/2)(1 - \alpha/2) to correct a known upward bias, and TSB smooths the probability of a demand occurring each period instead of the interval, so it can decay toward zero for an item that has become obsolete. All three are available in statsforecast as CrostonClassic, CrostonSBA and TSB.

Prediction intervals from statistical models

A prediction interval states a range that should contain the future value with a given probability, if the model's assumptions hold. With Gaussian errors and hh-step forecast standard deviation σh\sigma_h:

y^T+h∣T±c σh,c=1.28 for 80%,c=1.96 for 95%.\hat{y}_{T+h \mid T} \pm c\,\sigma_h, \qquad c = 1.28 \text{ for } 80\%, \quad c = 1.96 \text{ for } 95\%.

The models differ in how σh\sigma_h grows with the horizon. With σ\sigma the standard deviation of one-step residuals:

Methodσh\sigma_h
Meanσ1+1/T\sigma\sqrt{1 + 1/T}
Naiveσh\sigma\sqrt{h}
Seasonal naiveσk+1\sigma\sqrt{k+1} with k=⌊(h−1)/m⌋k = \lfloor (h-1)/m \rfloor
Driftσh(1+h/T)\sigma\sqrt{h(1 + h/T)}
SES, i.e. ETS(A,N,N)σ1+α2(h−1)\sigma\sqrt{1 + \alpha^2(h-1)}

Read the SES row slowly. Each future step the level absorbs a fraction α\alpha of a new shock, so uncertainty accumulates at rate α2\alpha^2. With α\alpha near 1 it grows almost like the naive random walk; with small α\alpha the intervals stay narrow because the level barely moves. For ARIMA models σh\sigma_h comes from writing the model as an infinite MA and summing the squared weights. For multiplicative ETS models there is often no closed form, and software uses approximations or simulation.

Two practical consequences. First, if you fit on the log scale, transform interval endpoints back with exp⁡\exp. Quantiles survive monotone transforms, so this gives valid intervals, and exp⁡\exp of the point forecast is the median on the original scale, not the mean. The mean is larger, approximately exp⁡(μ+σ2/2)\exp(\mu + \sigma^2/2) for log-normal errors. Second, these intervals ignore parameter and model-selection uncertainty, so they tend to be too narrow in practice. Always check empirical coverage in a backtest, as we do below.

Implement it: every model in statsforecast

Install into a fresh virtual environment. The results below were produced with Python 3.14, statsforecast 2.1.1, utilsforecast 0.2.17, NumPy 2.5.4 and pandas 2.3.3 on CPU.

python3 -m venv .venv && source .venv/bin/activate
pip install statsforecast utilsforecast

Load and split

The data comes in Nixtla's long format: one row per series and timestamp, with columns unique_id, ds and y. Many series can live in one frame; here there is just one. The timestamps are month ends, so the frequency is "ME".

from functools import partial

import numpy as np
from statsforecast import StatsForecast
from statsforecast.models import (
    Naive, SeasonalNaive, HistoricAverage, RandomWalkWithDrift,
    AutoETS, HoltWinters, AutoARIMA, AutoTheta,
)
from statsforecast.utils import AirPassengersDF
from utilsforecast.evaluation import evaluate
from utilsforecast.losses import mae, rmse, mase

df = AirPassengersDF.copy()
h, m = 12, 12
train = df.iloc[:-h]          # Jan 1949 .. Dec 1959, 132 rows
test = df.iloc[-h:]           # Jan 1960 .. Dec 1960, 12 rows
print(df.ds.min(), df.ds.max(), len(train), len(test))

Fit and forecast with intervals

StatsForecast takes a list of model objects and a frequency. forecast fits every model on every series and returns point forecasts plus interval columns named like AutoETS-lo-95 and AutoETS-hi-95. Use alias to run two configurations of the same class side by side.

models = [
    Naive(),
    SeasonalNaive(season_length=m),
    HistoricAverage(),
    RandomWalkWithDrift(),
    HoltWinters(season_length=m, error_type="A", alias="HW_add"),   # ETS(A,A,A)
    HoltWinters(season_length=m, error_type="M", alias="HW_mult"),  # ETS(M,A,M)
    AutoETS(season_length=m),    # searches the ETS taxonomy by AICc
    AutoARIMA(season_length=m),  # stepwise SARIMA search by AICc
    AutoTheta(season_length=m),
]
sf = StatsForecast(models=models, freq="ME", n_jobs=1)
fcst = sf.forecast(df=train, h=h, level=[80, 95])

The log-scale SARIMA

This statsforecast version raises NotImplementedError if you pass a Box-Cox blambda to AutoARIMA, so do the transform by hand: fit on log⁡y\log y, forecast, and exponentiate every column. The exponentiated point forecast is a median.

log_train = train.assign(y=np.log(train["y"]))
sf_log = StatsForecast(
    models=[AutoARIMA(season_length=m, alias="AutoARIMA_log")], freq="ME"
)
fl = sf_log.forecast(df=log_train, h=h, level=[80, 95])
for col in fl.columns:
    if col.startswith("AutoARIMA_log"):
        fl[col] = np.exp(fl[col])
fcst = fcst.merge(fl, on=["unique_id", "ds"])

Score on the test year

utilsforecast.evaluate computes metrics per series and model. MASE needs the training frame for its seasonal-naive scale.

res = test.merge(fcst, on=["unique_id", "ds"])
names = [c for c in fcst.columns
         if c not in ("unique_id", "ds") and "-lo-" not in c and "-hi-" not in c]
scores = evaluate(
    res,
    metrics=[mae, rmse, partial(mase, seasonality=m)],
    models=names,
    train_df=train,
)
table = (scores.drop(columns="unique_id").set_index("metric").T
         .round(3).sort_values("mase"))
print(table)

See what the automatic models chose

forecast does not keep the fitted models, so call fit to inspect them. In this version each fitted model stores a dictionary in model_. For ARIMA, arma is ordered (p, q, P, Q, m, d, D).

sf_fit = StatsForecast(
    models=[AutoETS(season_length=m), AutoARIMA(season_length=m),
            AutoTheta(season_length=m)],
    freq="ME",
).fit(train)
for fitted in sf_fit.fitted_[0]:
    info = fitted.model_
    print(fitted.alias, info.get("method"), info.get("arma"), info.get("modeltype"))

sf_log_fit = StatsForecast(models=[AutoARIMA(season_length=m)], freq="ME").fit(log_train)
print("log scale:", sf_log_fit.fitted_[0][0].model_["arma"],
      sf_log_fit.fitted_[0][0].model_["coef"])

On this run: AutoETS chose ETS(M,N,M), multiplicative error and season with no trend; raw-scale AutoARIMA chose ARIMA(1,1,0)(0,1,0)12\text{ARIMA}(1,1,0)(0,1,0)_{12}; log-scale AutoARIMA chose the airline model ARIMA(0,1,1)(0,1,1)12\text{ARIMA}(0,1,1)(0,1,1)_{12} with MA coefficients −0.327-0.327 and seasonal MA −0.578-0.578; AutoTheta chose the optimised Theta model.

Check interval coverage

for name in ["SeasonalNaive", "AutoETS", "AutoARIMA", "AutoARIMA_log", "HW_mult"]:
    for level in (80, 95):
        lo, hi = res[f"{name}-lo-{level}"], res[f"{name}-hi-{level}"]
        covered = ((res.y >= lo) & (res.y <= hi)).mean()
        print(f"{name:14s} {level}%  coverage={covered:.2f}  width={(hi - lo).mean():.0f}")

Rolling-origin cross-validation

One test year is one draw. cross_validation refits at several forecast origins inside the training data. With n_windows=3 and step_size=12, the cutoffs are December 1956, 1957 and 1958, each followed by a 12-month forecast. Only training data is used, so the 1960 test year stays untouched.

cv_models = [SeasonalNaive(season_length=m), AutoETS(season_length=m),
             AutoARIMA(season_length=m), AutoTheta(season_length=m)]
cv = StatsForecast(models=cv_models, freq="ME").cross_validation(
    df=train, h=h, n_windows=3, step_size=h
)
cv_names = [c for c in cv.columns if c not in ("unique_id", "ds", "cutoff", "y")]
cv_scores = evaluate(cv, metrics=[mae, rmse], models=cv_names)
print(cv_scores.round(2))                                   # one row per cutoff
print(cv_scores.drop(columns=["unique_id", "cutoff"])
      .groupby("metric").mean().round(2))                   # averaged over windows
flowchart LR
  D["Training history"] --> C1["Cutoff 1: fit on data up to cutoff"]
  C1 --> F1["Forecast next h steps"]
  F1 --> S1["Score against actuals"]
  S1 --> N{"More cutoffs?"}
  N -->|"yes, move cutoff forward by step_size"| C1
  N -->|"no"| A["Average errors by model and horizon"]
  A --> P["Pick model, then refit on all training data"]
  P --> T["Score once on the untouched test year"]

Implement it: the recursions in NumPy

Libraries hide the most instructive part: the loop. Here are SES and damped Holt in a few lines each. Every value in fitted is computed before the observation it predicts is read, which is the information boundary from the intuition guide made concrete.

import numpy as np
from statsforecast.utils import AirPassengersDF


def ses(y, alpha, level0):
    """Simple exponential smoothing: one-step fitted values and final level."""
    level = level0
    fitted = np.empty(len(y))
    for t, obs in enumerate(y):
        fitted[t] = level                        # forecast of y_t made at t-1
        level = alpha * obs + (1 - alpha) * level
    return fitted, level


def holt(y, alpha, beta, level0, trend0, phi=1.0):
    """Holt's linear trend method, damped when phi < 1."""
    level, trend = level0, trend0
    fitted = np.empty(len(y))
    for t, obs in enumerate(y):
        fitted[t] = level + phi * trend
        prev_level = level
        level = alpha * obs + (1 - alpha) * (prev_level + phi * trend)
        trend = beta * (level - prev_level) + (1 - beta) * phi * trend
    return fitted, level, trend


def holt_forecast(level, trend, h, phi=1.0):
    steps = np.cumsum(phi ** np.arange(1, h + 1))   # phi + phi^2 + ... + phi^k
    return level + steps * trend


y = AirPassengersDF["y"].to_numpy()[:-12]

# SES: choose alpha by the sum of squared one-step errors
alphas = np.linspace(0.01, 0.99, 99)
sse = lambda a: np.sum((y - ses(y, a, y[0])[0]) ** 2)
a_best = min(alphas, key=sse)
_, last_level = ses(y, a_best, y[0])
print(f"SES alpha={a_best:.2f}  flat forecast={last_level:.1f}")

# Damped Holt with phi fixed at 0.98
grid = [(a, b) for a in np.linspace(0.05, 0.95, 19) for b in np.linspace(0.01, 0.5, 50)]
def holt_sse(p, phi=0.98):
    fitted, *_ = holt(y, p[0], p[1], y[0], y[1] - y[0], phi)
    return np.sum((y - fitted) ** 2)
a_h, b_h = min(grid, key=holt_sse)
_, lv, tr = holt(y, a_h, b_h, y[0], y[1] - y[0], phi=0.98)
print(f"Holt alpha={a_h:.2f} beta={b_h:.2f} level={lv:.1f} trend={tr:.2f}")
print(np.round(holt_forecast(lv, tr, 12, phi=0.98), 1))

# Geometric weights for alpha = 0.3
print(np.round(0.3 * 0.7 ** np.arange(6), 4))

Output from this run: SES chose α=0.99\alpha = 0.99 with a flat forecast of 404.6404.6; damped Holt chose α=0.95\alpha = 0.95, β=0.01\beta = 0.01 and forecasts that creep from 403.6403.6 to 409.7409.7. A cross-check with statsforecast's SimpleExponentialSmoothing(alpha=0.99) gives the same 404.6404.6.

That α=0.99\alpha = 0.99 is the lesson. SES has no seasonal state, so the only way it can reduce one-step errors on a strongly seasonal series is to chase the last observation: it collapses into the naive method. A smoothing parameter near 1 is often a symptom of missing structure rather than a sign that the data is genuinely that volatile.

So add the seasonal state, and use the log transform to make the seasonality additive:

import itertools

import numpy as np
from statsforecast.utils import AirPassengersDF


def holt_winters_additive(y, alpha, beta, gamma, m, phi=1.0):
    """Additive Holt-Winters: one-step fitted values and final states."""
    level = y[:m].mean()
    trend = (y[m:2 * m].mean() - y[:m].mean()) / m
    season = list(y[:m] - level)               # season[t % m] holds s_(t-m)
    fitted = np.empty(len(y))
    for t, obs in enumerate(y):
        s = season[t % m]
        fitted[t] = level + phi * trend + s
        prev_level, prev_trend = level, trend
        level = alpha * (obs - s) + (1 - alpha) * (prev_level + phi * trend)
        trend = beta * (level - prev_level) + (1 - beta) * phi * trend
        season[t % m] = gamma * (obs - prev_level - phi * prev_trend) + (1 - gamma) * s
    return fitted, level, trend, season


def hw_forecast(level, trend, season, n_obs, h, m, phi=1.0):
    steps = np.cumsum(phi ** np.arange(1, h + 1))
    seas = np.array([season[(n_obs + k) % m] for k in range(h)])
    return level + steps * trend + seas


y_all = AirPassengersDF["y"].to_numpy().astype(float)
train, test = y_all[:-12], y_all[-12:]
z = np.log(train)
m = 12

grid = np.linspace(0.05, 0.95, 19)
def sse(p):
    fitted, *_ = holt_winters_additive(z, *p, m)
    return np.sum((z[m:] - fitted[m:]) ** 2)    # skip the initialisation year

candidates = [p for p in itertools.product(grid, [0.01, 0.02, 0.05, 0.1, 0.2], grid)
              if p[2] <= 1 - p[0]]              # usual constraint gamma <= 1 - alpha
best = min(candidates, key=sse)
_, lv, tr, se = holt_winters_additive(z, *best, m)
pred = np.exp(hw_forecast(lv, tr, se, len(z), 12, m))

mae_val = np.mean(np.abs(test - pred))
scale = np.mean(np.abs(train[m:] - train[:-m]))   # in-sample seasonal-naive MAE
print("alpha, beta, gamma =", np.round(best, 2))
print("MAE", round(mae_val, 2),
      "RMSE", round(np.sqrt(np.mean((test - pred) ** 2)), 2),
      "MASE", round(mae_val / scale, 3))

On this run the grid search picks α=0.35\alpha = 0.35, β=0.01\beta = 0.01, γ=0.55\gamma = 0.55, and the test-year scores are MAE 17.4417.44, RMSE 22.3522.35, MASE 0.5730.573. Forty lines of NumPy with a crude grid beat every untransformed model in statsforecast, which says more about the value of the log transform than about the code.

For completeness, here is Croston on a synthetic spare-parts series where roughly one week in four has a sale:

import numpy as np
import pandas as pd
from statsforecast import StatsForecast
from statsforecast.models import CrostonClassic, CrostonSBA, TSB, Naive

rng = np.random.default_rng(0)
n = 104
demand = np.where(rng.random(n) < 0.25, rng.poisson(4, n) + 1, 0)
spare = pd.DataFrame({"unique_id": "part_17",
                      "ds": pd.date_range("2023-01-01", periods=n, freq="W"),
                      "y": demand.astype(float)})
sf = StatsForecast(models=[CrostonClassic(), CrostonSBA(),
                           TSB(alpha_d=0.2, alpha_p=0.2), Naive()], freq="W")
print(sf.forecast(df=spare, h=4).round(2))

With 78% zero weeks in this sample, the run gives flat rates of 1.071.07 (Croston), 1.011.01 (SBA) and 0.980.98 (TSB) units per week, while naive forecasts 00 because the last week happened to be empty: a forecast that would never reorder.

Reading the results

Test-year scores (January to December 1960) from the statsforecast run above and the NumPy Holt-Winters. Numbers come from one run with the library versions listed earlier; they can shift slightly across versions because optimisers and search defaults change.

ModelSelected formMAERMSEMASE
AutoARIMA on log yARIMA(0,1,1)(0,1,1)12\text{ARIMA}(0,1,1)(0,1,1)_{12}13.2318.590.434
NumPy Holt-Winters on log yadditive, grid search17.4422.350.573
AutoARIMAARIMA(1,1,0)(0,1,0)12\text{ARIMA}(1,1,0)(0,1,0)_{12}18.5223.920.608
AutoThetaoptimised Theta, multiplicative decomposition19.3624.990.636
HoltWinters multiplicativeETS(M,A,M)19.7225.720.648
HoltWinters additiveETS(A,A,A)23.4926.230.771
AutoETSETS(M,N,M)35.6140.081.170
Seasonal naivebaseline47.8350.711.571
Random walk with driftbaseline66.3192.672.178
Naivebaseline76.00102.982.496
Meanbaseline213.67226.277.017

What to take from this:

  • Seasonal naive is the bar. Every model that captures both trend and season clears it comfortably. Naive, drift and mean each miss one of the two patterns and lose badly.
  • The log transform helps the most. The same AutoARIMA search improves MAE from 18.5 to 13.2 when the variance is stabilised first. Multiplicative Holt-Winters beats additive Holt-Winters for the same reason.
  • AICc chose a model without a trend. AutoETS picked ETS(M,N,M), which had the lowest AICc in-sample but cannot extrapolate growth, so it underforecasts 1960. It still beats the seasonal naive forecast for the test year, but its MASE above 1 and its large gap to the trended models show the cost. In-sample criteria select models; backtests validate them.
  • Interval coverage is uneven. At the nominal 95% level, AutoARIMA, log AutoARIMA and seasonal naive each covered 11 of 12 test months; AutoETS and multiplicative Holt-Winters covered 10 of 12. At 80%, seasonal naive covered only 3 of 12, because its intervals are centred on last year's level and 1960 was higher. Twelve points is far too few to judge calibration; this is a sanity check, not a verdict.

The rolling-origin backtest on the training data tells a more cautious story than the single test year. Averaged over the three 12-month windows (cutoffs December 1956, 1957 and 1958), MAE was 33.36 for seasonal naive, 31.92 for AutoETS, 25.67 for AutoARIMA and 25.08 for AutoTheta. In the window after the 1957 cutoff, seasonal naive had the lowest MAE of the four (12.58), because 1958 grew unusually little. A ranking from one window can flip in the next, which is why you look at several origins before you trust a choice.

Pitfalls, and how to choose a model

Pitfalls

  • Comparing AICc across transformations or differencing orders. A model on log⁡y\log y and a model on yy have likelihoods on different scales. Compare them with out-of-sample errors on the original scale.
  • Treating the exponentiated forecast as a mean. It is a median. If you are forecasting a total that will be summed or budgeted, apply a bias adjustment.
  • Forgetting the seasonal period. Every seasonal model in statsforecast defaults to season_length=1, which silently means no seasonality. Pass the period explicitly.
  • Wrong frequency string. AirPassengers stamps are month ends. Using a month-start frequency produces forecast dates such as 1960-01-01 that do not match the test frame's 1960-01-31, so a merge on ds finds no rows to score.
  • Fitting on the full series before evaluation. Any parameter, transformation choice or model order picked after looking at the test year is tuned on it. Make choices with cross_validation on training data.
  • Over-reading a smoothing parameter near 0 or 1. As the SES run showed, α\alpha near 1 often means the model is missing a component.
  • Long-horizon straight lines. An undamped trend extrapolated far ahead tends to overshoot. Damped trends usually behave better beyond one season.
  • Croston on smooth demand, or SES on intermittent demand. Check the share of zeros before choosing.

Choosing

flowchart TD
  S["New series"] --> Z{"Mostly zeros?"}
  Z -->|"yes"| CR["Croston, SBA or TSB"]
  Z -->|"no"| V{"Seasonal swings grow with level?"}
  V -->|"yes"| LG["Log transform, or use multiplicative seasonality"]
  V -->|"no"| SE{"Clear seasonality?"}
  LG --> SE
  SE -->|"yes"| SM["Candidates: SeasonalNaive, AutoETS,<br/>AutoARIMA with season_length, AutoTheta"]
  SE -->|"no"| TR{"Trend?"}
  TR -->|"yes"| TM["Candidates: Drift, damped Holt,<br/>AutoARIMA, Theta"]
  TR -->|"no"| LV["Candidates: Naive, SES, Mean"]
  SM --> BT["Rolling-origin backtest, keep what beats the baseline"]
  TM --> BT
  LV --> BT
  CR --> BT

Statistical models are the right first choice when you have one or a few series, short histories, a need for intervals and explanations, or thousands of series where each needs a cheap, robust fit. ETS is natural when you think in terms of level, trend and season; ARIMA when autocorrelation structure is the main signal; Theta as a robust middle ground; and an ensemble that averages ETS, ARIMA and Theta is often hard to beat. Reach for neural models or transformer models when you have many related series to learn across, rich covariates, or nonlinear patterns that a few states cannot express. Consider pretrained foundation models for zero-shot forecasts on new series. In every case, keep a fitted statistical model in the comparison: if a larger model cannot beat it in a backtest, the larger model has not earned its place.

Practice exercises

  1. Derive the damped limit. Show that ∑j=1hϕj=ϕ(1−ϕh)/(1−ϕ)\sum_{j=1}^{h}\phi^j = \phi(1-\phi^h)/(1-\phi) and compute the long-run forecast increment for ϕ=0.95\phi = 0.95 and bT=2b_T = 2.
  2. SES equals ARIMA(0,1,1). Start from yt=yt−1+εt+θεt−1y_t = y_{t-1} + \varepsilon_t + \theta\varepsilon_{t-1} and show that its one-step forecast satisfies the SES recursion with α=1+θ\alpha = 1 + \theta.
  3. Damped trend in practice. Rerun AutoETS with damped=True and compare it with the undamped search. Does damping fix the missing-trend choice?
  4. Bias adjustment. Estimate the residual variance of the log airline model and multiply its forecasts by exp⁡(σ2/2)\exp(\sigma^2/2). Does the MAE improve, worsen, or stay the same? Why might MAE prefer the median?
  5. More origins. Increase n_windows to 6 with step_size=6 and report the error by horizon, from 1 to 12 months. Which model degrades fastest with horizon?
  6. Ensemble. Average the AutoETS, log AutoARIMA and AutoTheta forecasts and score the average. Compare it with each member across the cross-validation windows, not just on 1960.
  7. Intermittent demand. Increase the zero share in the Croston example to 90% and compare Croston, SBA, TSB and SES with a rolling backtest using MAE and the mean error (bias).

Further reading

Sources & further reading

The explanations and worked examples in this article are original. These papers and official references support the technical background and provide further detail.

  1. Forecasting: Principles and Practice, 3rd edition ↗
  2. exponential smoothing ↗
  3. ARIMA models ↗
  4. Automatic time series forecasting: the forecast package for R ↗
  5. statsforecast documentation ↗
Back to all writing