What it actually does
Three ideas stacked, and the acronym is in the wrong order for how they are applied. First integrate: difference the series until it is stationary, because everything that follows assumes the statistical behaviour does not drift. Then autoregress: predict the differenced value from its own recent values. Then add moving average: also predict it from the recent forecast ERRORS, which is how the model absorbs shocks that persist for a few periods without permanently changing the level. The AR part remembers where the series was; the MA part remembers where the model was wrong. That second memory is what distinguishes ARIMA from a regression on lag features, and it is why its intervals are calibrated rather than optimistic.
- The differenced series is stationary — the mean, variance and autocorrelation do not change over time, which is what differencing is for and what a structural break destroys
- Innovations are white noise: independent, mean zero, constant variance. The intervals rest entirely on this and it is the assumption most often violated
- Innovations are Gaussian, if the intervals or any quantile are to be believed
- The series is regularly sampled with no gaps, since the model is defined on an index rather than on a timestamp
- The relationship is linear in the lags; a series whose dynamics change with its level needs a transform or a different model
Likelihood
Under Gaussian innovations, maximizing the likelihood is minimizing the sum of squared one-step-ahead forecast errors. The recursion beneath it is where the difficulty lives: epsilon_t depends on epsilon_{t-1}, which depends on epsilon_{t-2}, so the errors are not observed quantities but consequences of the parameters. That makes the objective non-linear in theta and rules out a closed form, which is why ARIMA needs a numerical optimizer while a plain autoregression does not. It also means the first few residuals depend on assumed starting values, and the difference between the conditional and exact likelihood is precisely how those are handled.
- the differenced series — the original after d differences, which is what the model is actually fitted to
- autoregressive coefficients: how much of the last p values carries forward
- moving-average coefficients: how much of the last q ERRORS carries forward
- the innovation at t — not observed, computed recursively, which is what makes the likelihood non-linear in theta
How it is minimized
Maximum likelihood via the Kalman filter (exact) or conditional sum of squares (approximate), with order selection by AIC
- the backshift operator: B y_t = y_{t-1}, which turns the recursion into polynomial algebra
- differencing d times — the I in ARIMA, applied before anything else
- the AR and MA polynomials; their roots must lie outside the unit circle for stationarity and invertibility
- white noise — and the diagnostic that decides whether the model is adequate is whether the residuals look like it
The likelihood has no closed form because the innovations are recursive in the parameters, so estimation is numerical — usually L-BFGS over a handful of coefficients, which is cheap because there are rarely more than half a dozen. Two implementation choices carry real consequences. The exact likelihood is computed by casting the model into state-space form and running a Kalman filter, which handles the unknown pre-sample values properly; conditional sum of squares just assumes them zero, which is faster and biased on short series. And the parameters are constrained: the AR and MA polynomial roots must sit outside the unit circle, or the model is non-stationary or non-invertible and the fitted coefficients are meaningless even though the optimizer was perfectly happy. Order selection is a separate search, and AIC over a grid — what auto.arima does — has almost entirely replaced reading ACF and PACF plots by hand.
(p, d, q)
AR order, differencing order, MA order. The modelling decision, now usually chosen by an AIC search rather than by inspection (p, q in 0-5; d in 0-2, and d above 2 almost always means something else is wrong)
(P, D, Q, m)
Seasonal orders at period m. The seasonal difference D is usually the single most consequential setting on a seasonal series
trend / drift
Whether to include a constant. With d = 1 a constant becomes a linear drift in the forecast, which is a large modelling commitment disguised as a flag
exogenous regressors (the X)
External drivers entering linearly. This is what makes SARIMAX a regression with ARIMA errors, and it is how holidays and promotions are handled properly
estimation method
Exact MLE via Kalman filter, or conditional sum of squares. CSS is faster and noticeably biased on short series
The optimizer converges reliably over a handful of parameters, but the surface has real pathologies. Near-cancelling AR and MA roots make the likelihood almost flat — an ARMA(2,2) whose polynomials nearly share a factor is effectively over-parameterized, the optimizer wanders, and the fitted coefficients are unstable while the forecast is fine. Over-differencing is the other named failure: differencing a stationary series does not break the forecast but inflates the variance and induces spurious MA structure, so the model becomes more complicated for no gain. And the diagnostic that actually matters is not convergence at all but whether the residuals are white noise — a Ljung-Box test on the residual autocorrelations, which is what tells you the model has extracted the structure rather than merely fitted it.
Estimation: O(n) per likelihood evaluation for CSS, or O(n·r^2) for the Kalman filter where r is the state dimension, times however many evaluations the optimizer needs — typically tens to low hundreds. An AIC grid over orders multiplies that by the grid size, which is what makes auto.arima the expensive part. Memory O(n). Forecasting is O(h) recursion. The binding constraint is not any of these but that the model is fitted per series, so a million series is a million fits.
How, where, and why it is used
Time-Series Forecasting
Primary fitThis is the domain’s reference model. Difference until stationary, choose orders by AIC over a grid, estimate by maximum likelihood, then check the residuals are white noise before believing anything. The forecast is generated by iterating the recursion forward, with the intervals following analytically from the accumulated innovation variance — which is why they widen with horizon in a way a bootstrapped interval only approximates.
- Univariate forecasting where the autocorrelation structure carries most of the signal and the history is long enough to estimate it
- Macroeconomic and financial series, where the Box-Jenkins framework originated and remains the standard vocabulary
- SARIMAX with exogenous drivers, which is how price, promotion and holiday effects are handled without breaking the error structure
- The benchmark any newer forecaster is expected to beat, and frequently does not
It models serial dependence explicitly rather than approximating it with lag features, which is what makes the prediction intervals calibrated rather than optimistic. That distinction matters more than point accuracy in most operational settings, because the decision downstream is usually about a quantile. The reasons against it are equally concrete: it needs a reasonably long, reasonably stable history; it handles one seasonality and not several; and it is fitted one series at a time, so a catalogue of a million SKUs is a million optimizations. The M-competition results are worth internalizing here — simple exponential smoothing frequently beats a well-fitted ARIMA on real business data, and the field has largely accepted that.
- Determine d by a unit-root test rather than by eye, and stop as soon as the series is stationary — over-differencing costs variance for nothing
- Seasonal differencing before regular differencing on a strongly seasonal series; the order of operations changes the result
- Stabilize the variance first with a log or Box-Cox transform when the amplitude grows with the level, which the additive model cannot represent
- Put holidays, promotions and known interventions in as exogenous regressors rather than letting them contaminate the error structure
Rolling-origin backtesting scored with MASE against a seasonal-naive baseline, plus interval coverage — an 80% interval should contain about 80% of held-out points, and if it does not the error assumptions are wrong rather than the orders. Run Ljung-Box on the residuals: leftover autocorrelation means structure the model has not captured, which no accuracy metric will surface.
- Selecting orders on the full series before splitting, which is a leak that looks like skill
- Over-differencing, which inflates variance and induces spurious MA terms while nothing looks obviously wrong
- Trusting intervals when the residuals are heteroscedastic or fat-tailed, which on financial data they always are
- Long horizons: with d = 0 the forecast reverts to the mean and with d = 1 it extends a straight line, and neither is a statement about the world beyond a few periods
Anomaly Detection
AdaptedFit on a clean period, then score each new observation by its standardized one-step-ahead forecast error. The model supplies the expectation and, unlike a plain regression on lags, an innovation variance that is estimated as part of the fit — so the threshold can be expressed in units the model itself defines rather than in an arbitrary multiple of a residual standard deviation.
- Monitoring business and infrastructure metrics that have genuine trend and seasonality, where a static threshold alarms every Monday morning
- Intervention detection: a permanent level shift shows up as a run of same-signed innovations rather than as one large one
- Data-quality checks on reported series, where a missed or duplicated period breaks the autocorrelation structure
It is the right detector when normal behaviour is itself time-varying, because it removes trend and seasonality before scoring rather than trying to threshold around them. The distinction between an outlier and a level shift also falls out of the residual pattern, which most detectors cannot express. Against it: one model per series does not scale to a large fleet, refits are needed as the process drifts, and the Gaussian innovation assumption is what the threshold rests on — on a heavy-tailed series it fires far more than the nominal rate.
- Fit on a confirmed-clean window; a large outlier in the training period contaminates the coefficient estimates as well as the variance
- Difference and deseasonalize through the model rather than beforehand, so the innovation variance reflects what the model actually does not know
- Score standardized innovations, not raw residuals, so the threshold is comparable across series
Precision@k against confirmed incidents, plus a calibration check on clean data — the standardized innovations should look standard normal, and if they do not, the model is mis-specified and any threshold on them is arbitrary.
- A single large outlier absorbed into the fit, inflating the innovation variance until nothing is anomalous afterwards
- Level shifts scored as one anomaly and then treated as normal, when the informative signal is the run of residuals that follows
- Refit cadence: the model that defines normal drifts with the process, and nobody schedules a review of a forecaster
Optimization
AdaptedUpstream rather than as a solver. Inventory, staffing and capacity decisions are constrained optimizations whose objective coefficients are demand forecasts, and the quantity those optimizers actually consume is a quantile rather than a mean — safety stock is set from the forecast distribution at a service level, not from the point forecast. ARIMA supplies that distribution analytically, which is why it sits underneath so many planning systems.
- Safety-stock and reorder-point calculation, where the service level maps directly onto a forecast quantile
- Staffing and capacity planning, where the cost of under-provisioning differs sharply from over-provisioning
- Any newsvendor-shaped decision, whose optimal order quantity is a quantile of the demand distribution
The reason to care about the interval rather than the point is that the downstream cost is asymmetric: running out and holding excess are not equally expensive, so the optimal decision is a quantile whose position depends on that ratio. A forecaster that reports only a mean forces the planner to invent an uncertainty estimate, and they usually invent a bad one. What ARIMA cannot do is optimize anything itself — it is an input, and a decision built on it inherits every one of its assumptions, including that the innovations are Gaussian.
- Forecast at the aggregation level the decision is made at; forecasting daily and summing to weekly understates the variance the planner needs
- Propagate the multi-step forecast variance rather than scaling the one-step variance by the horizon, which is wrong for any non-trivial model
- Include lead time in the horizon — the relevant distribution is demand over the replenishment window, not over one period
Evaluate the decision, not the forecast: realized service level and holding cost under the plan. A forecast that improves MASE and worsens the achieved service level has optimized the wrong quantity, and this happens.
- Feeding a point forecast into a quantile-shaped decision and adding an arbitrary safety factor
- Assuming Gaussian innovations when computing a high quantile, where the tail assumption dominates the answer
- Optimizing against a forecast whose intervals were never validated for coverage
Control & Operations
Primary fitThe forecasting layer inside operational planning. Demand, arrival rates, load and throughput are forecast per series with seasonal orders capturing the weekly and annual cycles, exogenous regressors capturing known events, and the resulting distribution feeding whatever schedules, orders or provisions capacity. It is also the standard model for the disturbance process in classical process control.
- Demand planning and replenishment across retail and distribution
- Call-centre and service-desk arrival forecasting for staffing
- Utility load forecasting, where the daily and annual seasonalities are strong and stable
- Modelling the disturbance in a control loop, where minimum-variance control is derived directly from an ARIMA disturbance model
Operational series are exactly what the method assumes: regularly sampled, strongly autocorrelated, seasonal, and long enough to estimate a handful of parameters. The intervals are the deliverable more often than the means, because the decisions are quantile-shaped. The limits show up at scale and at complexity — one fit per series makes a large catalogue expensive, and a series with multiple overlapping seasonalities, irregular holidays or frequent promotions needs either a large exogenous block or a different model entirely.
- Seasonal orders at the dominant period, with additional cycles handled by Fourier regressors rather than by a second seasonal term the model does not support
- Holiday and promotion indicators as exogenous regressors, including leads and lags around the event
- Aggregate very sparse or intermittent series, where the Gaussian innovation assumption fails outright and Croston-type methods are the right tool
- Log or Box-Cox transform when seasonal amplitude grows with the level
Rolling-origin MASE against seasonal-naive, plus interval coverage at the service level the operation actually runs at. Report accuracy at the aggregation level decisions are made at, since errors cancel on aggregation and a good SKU-level number can hide a bad category-level one.
- One model per series becoming an operational burden nobody owns once the catalogue grows
- Promotions and stockouts treated as demand rather than as interventions, so the model learns the intervention as seasonality
- Multiple seasonalities forced into a single seasonal order, which fits neither
What it costs to run
A single fit is trivial — tens to hundreds of likelihood evaluations over a handful of parameters, milliseconds to seconds. The cost is multiplicative in two places: the AIC grid over orders, and the fact that every series needs its own model. A million series is a million optimizations, which is an orchestration problem rather than a compute one.
Forecasting is a short recursion — O(h) for horizon h — and the model is a handful of coefficients. Trivial to serve; the awkward part is that generating a forecast requires the recent history and the fitted state, so the serving path needs the series, not just the parameters.
Periodic and per series, typically weekly or monthly. There is no incremental update that preserves the exact likelihood, though the state can be carried forward and the coefficients held fixed between refits — which is what most production systems do, refitting orders far less often than coefficients.
- Run Ljung-Box on recent residuals — returning autocorrelation is the earliest signal that the fitted orders no longer describe the process
- Track interval coverage rather than point error, since a model can keep its MASE while its intervals become badly wrong
- Watch the innovation variance across refits; a rising estimate usually means unmodelled structure rather than a genuinely noisier process
- Alert when the AIC-selected orders change on refit, because that is a change of model, not of parameters, and anything downstream assuming a fixed form is now wrong
- Differencing is part of the model: forecasts come back on the differenced scale and must be integrated, and getting the initial values wrong shifts every forecast by a constant nobody notices
- Missing periods break the recursion — the model assumes regular sampling, so a gap must be imputed or the series re-indexed, and silently dropping the row is wrong
- Exogenous regressors must be known for the whole forecast horizon; a SARIMAX with a driver you cannot forecast is a model you cannot use
- Fitted coefficients near the stationarity boundary produce forecasts that explode over long horizons, and the optimizer will happily return them
- A log transform means forecasts and intervals come back on the log scale; exponentiating the mean gives the median, not the mean, and the correction is routinely forgotten
Pros and cons, in context
Prediction intervals that follow from the model rather than from a bootstrap
The innovation variance is estimated as part of the fit and propagates analytically with horizon, which is what makes quantile-shaped downstream decisions defensible. Worth little if only the point forecast is ever used.
Models serial dependence explicitly, including the error process
The MA terms let a shock persist and decay, which a regression on lag features cannot represent without a great many lags. Decisive on series where shocks matter; irrelevant where the drivers are exogenous and the errors are near-independent.
A mature, well-understood diagnostic framework
Unit-root tests, residual autocorrelation, information criteria — a complete toolkit for deciding whether the model is adequate, which most newer forecasters simply do not have. That matters in settings where a forecast has to be defended.
Exogenous regressors integrate cleanly
SARIMAX is a regression with ARIMA errors, so drivers can be added without breaking the error structure or the intervals. This is the correct way to handle promotions and holidays, and it is why the X exists.
One model per series, fitted independently
A catalogue of a million series is a million optimizations with no sharing of information between them. This is the practical reason global models — one model over all series — took over at scale, and no amount of tuning changes it.
Handles exactly one seasonality
A series with daily, weekly and annual cycles cannot be expressed with a single seasonal order. Fourier regressors help and are a workaround, not a fix, and this is where structural and state-space models are simply better suited.
Requires stationarity, and differencing is a blunt instrument
A structural break is not removed by differencing, and over-differencing inflates variance while looking fine. Judging d correctly is genuine expertise that the automated search only partly replaces.
Frequently beaten by much simpler methods
The M-competitions repeatedly found exponential smoothing and the theta method competitive with or better than fitted ARIMA on real business data. The framework is more general; generality is not accuracy, and this is the clearest case of that in the section.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""ARIMA by conditional sum of squares - the recursion, transcribed.
The likelihood reduces to the sum of squared one-step errors, and those errors
are produced by a recursion in the parameters rather than observed. That is the
whole difficulty: epsilon_t depends on epsilon_{t-1}, so there is no closed
form and the fit has to be numerical.
"""
def difference(series, d):
"""Apply (1 - B)^d. The I in ARIMA, and it happens before anything else."""
result = list(series)
for _ in range(d):
result = [result[t] - result[t - 1] for t in range(1, len(result))]
return result
def residuals(w, phi, theta):
"""epsilon_t = w_t - sum phi_i w_{t-i} - sum theta_j epsilon_{t-j}
'Conditional' means the pre-sample values are assumed zero. That is the
approximation this stage makes; the exact likelihood estimates them via a
Kalman filter instead, which matters on short series.
"""
p = len(phi)
q = len(theta)
errors = [0.0] * len(w)
for t in range(len(w)):
prediction = 0.0
for i in range(p):
if t - i - 1 >= 0:
prediction += phi[i] * w[t - i - 1]
for j in range(q):
if t - j - 1 >= 0:
prediction += theta[j] * errors[t - j - 1]
errors[t] = w[t] - prediction
return errors
def sum_of_squares(w, phi, theta):
return sum(error * error for error in residuals(w, phi, theta))
def fit(series, p, d, q, lr=1e-4, iterations=2000, epsilon=1e-5):
"""Fit by gradient descent with numerical gradients.
A real implementation uses L-BFGS with analytic derivatives; finite
differences are used here because they show that the objective is just a
function of the coefficients, with no structure the optimizer exploits.
"""
w = difference(series, d)
phi = [0.0] * p
theta = [0.0] * q
for _ in range(iterations):
base = sum_of_squares(w, phi, theta)
for i in range(p):
phi[i] += epsilon
gradient = (sum_of_squares(w, phi, theta) - base) / epsilon
phi[i] -= epsilon
phi[i] -= lr * gradient
for j in range(q):
theta[j] += epsilon
gradient = (sum_of_squares(w, phi, theta) - base) / epsilon
theta[j] -= epsilon
theta[j] -= lr * gradient
return phi, theta
def forecast(series, phi, theta, d, horizon):
"""Iterate the recursion forward, then undo the differencing."""
w = difference(series, d)
errors = residuals(w, phi, theta)
history = list(w)
future_errors = list(errors)
for _ in range(horizon):
prediction = 0.0
for i in range(len(phi)):
prediction += phi[i] * history[len(history) - i - 1]
for j in range(len(theta)):
prediction += theta[j] * future_errors[len(future_errors) - j - 1]
history.append(prediction)
# Future errors are zero in expectation - which is exactly why the
# forecast reverts to the mean once the MA memory runs out.
future_errors.append(0.0)
# Undo the differencing by cumulative summation from the last observed
# levels. Getting these starting values wrong shifts every forecast by a
# constant that nobody notices.
forecasts = history[len(w):]
for _ in range(d):
anchor = series[-1]
forecasts = [anchor := anchor + step for step in forecasts]
return forecastsO(n * (p + q)) per objective evaluation, and finite-difference gradients cost p + q + 1 evaluations per step — thousands of passes over the series.
How this language actually wants it written.
"""ARIMA - typed, constrained, fitted by L-BFGS on the exact CSS objective."""
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
from scipy.optimize import minimize
Vector = NDArray[np.float64]
@dataclass(frozen=True)
class ArimaOrder:
p: int
d: int
q: int
def __post_init__(self) -> None:
if min(self.p, self.d, self.q) < 0:
raise ValueError(f"orders must be non-negative, got {self}")
if self.d > 2:
raise ValueError(
f"d={self.d}: differencing more than twice almost always means a "
"transform is needed rather than another difference"
)
@dataclass(frozen=True)
class ArimaModel:
order: ArimaOrder
phi: Vector
theta: Vector
sigma2: float
anchors: Vector # last d levels, needed to undo the differencing
aic: float
@property
def is_stationary(self) -> bool:
"""AR polynomial roots must lie OUTSIDE the unit circle.
A fitted model that fails this is not merely inaccurate - its
forecasts diverge with horizon, and the optimizer will happily return
one because the objective does not know about the constraint.
"""
return _roots_outside_unit_circle(self.phi)
@property
def is_invertible(self) -> bool:
"""Same condition on the MA polynomial. A non-invertible model has an
equivalent invertible representation, so the coefficients are not
identified even though the fit is."""
return _roots_outside_unit_circle(-self.theta)
def _roots_outside_unit_circle(coefficients: Vector) -> bool:
if coefficients.size == 0:
return True
# 1 - c1 B - c2 B^2 ... ; numpy wants highest power first.
polynomial = np.concatenate([[1.0], -coefficients])[::-1]
return bool(np.all(np.abs(np.roots(polynomial)) > 1.0))
def _difference(series: Vector, d: int) -> Vector:
return series if d == 0 else _difference(np.diff(series), d - 1)
def _residuals(w: Vector, phi: Vector, theta: Vector) -> Vector:
"""One pass of the recursion. Sequential in t by construction: the MA term
reads an error the same loop is producing, so this cannot be vectorized
over time no matter how it is written."""
p, q = phi.size, theta.size
errors = np.zeros(w.size, dtype=np.float64)
for t in range(w.size):
prediction = 0.0
if p:
back = min(p, t)
prediction += float(phi[:back] @ w[t - 1 : t - back - 1 : -1]) if back else 0.0
if q:
back = min(q, t)
prediction += float(theta[:back] @ errors[t - 1 : t - back - 1 : -1]) if back else 0.0
errors[t] = w[t] - prediction
return errors
def fit(series: Vector, order: ArimaOrder) -> ArimaModel:
"""Fit by conditional maximum likelihood. Raises ValueError on bad input."""
if series.ndim != 1:
raise ValueError(f"series must be 1-D, got shape {series.shape}")
if series.size <= order.d + order.p + order.q + 1:
raise ValueError(f"series of length {series.size} is too short for {order}")
if not np.isfinite(series).all():
raise ValueError("series contains NaN or inf; the recursion has no notion of missing")
w = _difference(series, order.d)
def objective(packed: Vector) -> float:
phi, theta = packed[: order.p], packed[order.p :]
return float(np.sum(_residuals(w, phi, theta) ** 2))
start = np.zeros(order.p + order.q, dtype=np.float64)
result = minimize(objective, start, method="L-BFGS-B",
bounds=[(-0.99, 0.99)] * start.size)
phi, theta = result.x[: order.p], result.x[order.p :]
errors = _residuals(w, phi, theta)
sigma2 = float(np.mean(errors**2))
# AIC over the DIFFERENCED series length: comparing models with different
# d on different sample sizes is a common and invalid comparison.
n_params = order.p + order.q + 1
aic = w.size * np.log(sigma2) + 2 * n_params
return ArimaModel(
order=order,
phi=phi,
theta=theta,
sigma2=sigma2,
anchors=series[-max(order.d, 1) :],
aic=float(aic),
)What changed: Finite-difference gradient descent is replaced by L-BFGS with box constraints, which converges in tens of evaluations rather than thousands and is what an actual implementation uses. More importantly, the model now checks what the objective cannot: the stationarity and invertibility conditions on the polynomial roots. A fitted model violating them is not merely inaccurate — its forecasts diverge with horizon — and the optimizer returns one happily because those constraints are not in the objective. The order becomes a validated value object, input is checked for the NaN case the recursion has no way to represent, and the AIC is computed on the differenced length so that models with different d are not compared on different sample sizes.
O(n * (p + q)) per evaluation, tens of evaluations under L-BFGS instead of thousands under finite differences.
Faster, and the justification for each change.
"""ARIMA - fused AR term, reused buffers, order grid in one pass."""
import numpy as np
from numpy.typing import NDArray
from scipy.optimize import minimize
Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
class ConditionalArima:
"""The AR contribution is a convolution; only the MA part is sequential.
The recursion cannot be vectorized over time - the MA term reads an error
the loop is still producing. But the AR term reads only OBSERVED values,
so the whole AR contribution for every t is one matrix-vector product
against a lag matrix built once. That leaves a scalar accumulation for the
MA part, which is genuinely irreducible.
The other saving is larger and less obvious: L-BFGS calls the objective
tens of times per fit and hundreds across an order grid, so the residual
buffer and the lag matrix are built ONCE per series rather than per call.
"""
def __init__(self, series: Vector, max_lag: int) -> None:
# One contiguous float64 block; every objective evaluation walks it.
self._series = np.ascontiguousarray(series, dtype=np.float64)
self._max_lag = max_lag
self._cache: dict[int, tuple[Vector, Matrix]] = {}
self._errors: Vector | None = None
def _prepared(self, d: int) -> tuple[Vector, Matrix]:
"""Differenced series and its lag matrix, memoized per d.
An order grid evaluates many (p, q) at the same d, so differencing and
building the lag matrix once per d rather than once per candidate is
most of the grid's cost removed.
"""
if d not in self._cache:
w = self._series
for _ in range(d):
w = np.diff(w)
w = np.ascontiguousarray(w)
lags = np.zeros((w.size, self._max_lag), dtype=np.float64)
for lag in range(1, self._max_lag + 1):
lags[lag:, lag - 1] = w[:-lag]
self._cache[d] = (w, lags)
return self._cache[d]
def _sum_of_squares(self, w: Vector, lags: Matrix, phi: Vector, theta: Vector) -> float:
# The entire AR contribution, for every t at once: one GEMV.
ar_term = lags[:, : phi.size] @ phi if phi.size else np.zeros(w.size)
if self._errors is None or self._errors.size != w.size:
self._errors = np.empty(w.size, dtype=np.float64) # allocated once
errors = self._errors
q = theta.size
if q == 0:
np.subtract(w, ar_term, out=errors)
return float(errors @ errors)
# Only the MA recursion remains sequential, and it is O(q) per step
# rather than O(p + q).
for t in range(w.size):
ma_term = 0.0
for j in range(min(q, t)):
ma_term += theta[j] * errors[t - j - 1]
errors[t] = w[t] - ar_term[t] - ma_term
return float(errors @ errors)
def fit_order(self, p: int, d: int, q: int) -> tuple[Vector, float]:
w, lags = self._prepared(d)
def objective(packed: Vector) -> float:
return self._sum_of_squares(w, lags, packed[:p], packed[p:])
result = minimize(
objective,
np.zeros(p + q),
method="L-BFGS-B",
bounds=[(-0.99, 0.99)] * (p + q),
)
sigma2 = result.fun / w.size
aic = w.size * np.log(sigma2) + 2 * (p + q + 1)
return result.x, float(aic)
def select(self, max_p: int, max_d: int, max_q: int) -> tuple[tuple[int, int, int], Vector]:
"""AIC grid search. The shared per-d preparation is what makes this
affordable - without it, the grid re-differences the series and
rebuilds the lag matrix for every candidate."""
best_order = (0, 0, 0)
best_params = np.zeros(0)
best_aic = np.inf
for d in range(max_d + 1):
for p in range(max_p + 1):
for q in range(max_q + 1):
if p == 0 and q == 0:
continue
params, aic = self.fit_order(p, d, q)
if aic < best_aic:
best_aic, best_order, best_params = aic, (p, d, q), params
return best_order, best_paramsWhat changed: The recursion is genuinely sequential in the MA term — the loop reads an error it is still producing — so no rewrite vectorizes it over time, and pretending otherwise would be the wrong claim. What can be lifted out is the AR contribution, which reads only observed values and is therefore one matrix-vector product against a lag matrix built once. The larger saving is structural rather than arithmetic: L-BFGS calls the objective tens of times per fit and hundreds across an order grid, so differencing, the lag matrix, and the residual buffer are all built once per series and reused across every call — which is most of the grid search’s cost removed.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
The AR contribution for every timestep is a single GEMV against a precomputed lag matrix, leaving only the irreducible MA accumulation in the interpreter.
Trade-off: The lag matrix is n-by-max_lag, so it costs memory proportional to the longest AR order considered even when the selected model uses far fewer lags.
Batch work to amortize interpreter overhead
An order grid evaluates many (p, q) pairs at the same d, so differencing and lag construction are memoized per d rather than repeated per candidate.
Trade-off: The cache holds one differenced copy and lag matrix per d for the object’s lifetime, which is fine for a grid over one series and wasteful if the object outlives the search.
Pre-allocate output arrays and use in-place operations
The residual buffer is allocated once and rewritten on every objective evaluation, rather than allocating an n-length array on each of the hundreds of calls the optimizer makes.
Trade-off: The buffer is shared mutable state on the object, so the class is not reentrant — two concurrent fits through one instance would corrupt each other silently.
O(n * q) per evaluation after the AR term is lifted out, with per-d preparation shared across the grid. Illustrative, not a measured benchmark.