ARIMA and SARIMA Theory and Python Implementation: statsmodels.tsa.arima.model with ACF/PACF for Time-Series Forecasting

AR, MA, ARIMA, and SARIMA forecasting in Python with statsmodels.tsa.arima.model.ARIMA and statsmodels.tsa.stattools (adfuller, acf, pacf). Derives AR stationarity and MA invertibility from unit-circle roots and the conditional-MLE (CSS) log-likelihood. Measures real ADF statistics, Ljung-Box residual diagnostics, and forecast RMSE/MAE to expose the overdifferencing pitfall and auto_arima's limits within the Box-Jenkins framework.

Introduction

Time series forecasting is essential across domains including demand planning, financial analysis, and weather prediction. ARIMA (AutoRegressive Integrated Moving Average) is a classical yet powerful time series forecasting method.

This article derives the mathematics of AR, MA, ARIMA, and SARIMA from the roots of characteristic polynomials on the unit circle — rather than stating them by fiat — and walks through the Box-Jenkins method (identification → estimation → diagnostic checking) end to end in Python. The theoretical background of exact Kalman-filter maximum likelihood estimation is covered in depth in https://yuhi-sa.github.io/en/posts/20260715_arima_state_space_kalman/1/, and the detailed derivation of order identification via the PACF is covered in https://yuhi-sa.github.io/en/posts/20260710_pacf_ar_identification/1/. This article instead focuses on running the practical Box-Jenkins workflow (stationarity testing → order identification → conditional MLE → residual diagnostics → forecasting) through a single worked example from start to finish.

AR (AutoRegressive) Model

The AR(\(p\) ) model expresses the current value using the past \(p\) values:

\[X_t = c + \sum_{i=1}^{p} \phi_i X_{t-i} + \varepsilon_t \tag{1}\]

where \(\phi_i\) are autoregressive coefficients and \(\varepsilon_t \sim \mathcal{N}(0, \sigma^2)\) is white noise.

Deriving the stationarity condition

Take the AR(1) model \(X_t = \phi X_{t-1} + \varepsilon_t\) as a concrete case to see why the magnitude of the coefficient determines stationarity. Substituting backward \(k\) times gives

\[X_t = \phi^k X_{t-k} + \sum_{j=0}^{k-1} \phi^j \varepsilon_{t-j} \tag{2}\]

As \(k \to \infty\) , if \(|\phi| < 1\) then \(\phi^k X_{t-k} \to 0\) (mean-square convergence), leaving

\[X_t = \sum_{j=0}^{\infty} \phi^j \varepsilon_{t-j} \tag{3}\]

— a causal MA(∞) representation built purely from past shocks with finite variance. The variance is the sum of a geometric series,

\[ \mathrm{Var}(X_t) = \sigma^2 \sum_{j=0}^{\infty} \phi^{2j} = \frac{\sigma^2}{1 - \phi^2} < \infty \]

which is finite and well defined. Conversely, if \(|\phi| > 1\) the term \(\phi^k X_{t-k}\) in equation (2) diverges, and at \(|\phi| = 1\) (a unit root, i.e. a random walk) the variance grows without bound over time — neither case gives a process with a time-invariant, finite variance.

The characteristic equation of AR(1) is \(1 - \phi z = 0\) , with root \(z = 1/\phi\) . Therefore

\[|\phi| < 1 \iff |z| = \left|\frac{1}{\phi}\right| > 1\]

— stationarity is equivalent to the root lying outside the unit circle. This correspondence extends to the general AR(\(p\) ) case. Writing the characteristic equation as

\[1 - \phi_1 z - \phi_2 z^2 - \cdots - \phi_p z^p = 0\]

with roots \(z_1, \ldots, z_p\) , a partial-fraction expansion shows that \(1/\phi(z)\) can be written as a power series \(\sum_j \psi_j z^j\) in \(z\) , whose radius of convergence is exactly \(\min_i |z_i|\) . For the coefficients \(\{\psi_j\}\) to be absolutely summable (\(\sum_j |\psi_j| < \infty\) , i.e. for a causal MA(∞) representation to exist), the radius of convergence must exceed \(1\) :

\[\min_i |z_i| > 1\]

All roots must lie outside the unit circle — this is the necessary and sufficient condition for AR(\(p\) ) to admit a stationary MA(∞) representation.

Checking this against the AR(2) process used in the numerical experiment below (\(\phi_1=0.5, \phi_2=-0.3\) ), the characteristic equation \(1 - 0.5z + 0.3z^2 = 0\) has roots \(z = 0.8333 \pm 1.6245i\) with modulus \(|z| = 1.8257 > 1\) , confirming stationarity (we reproduce this numerically in the Python code below).

MA (Moving Average) Model

The MA(\(q\) ) model is a linear combination of past \(q\) noise terms:

\[X_t = \mu + \varepsilon_t + \sum_{j=1}^{q} \theta_j \varepsilon_{t-j} \tag{4}\]

Since this is a finite sum of stationary terms, the MA model is always stationary regardless of the \(\theta_j\) values.

Deriving invertibility

Stationarity is automatic, but a separate condition — invertibility — is needed to identify parameters uniquely from data. Consider MA(1): \(X_t = \varepsilon_t + \theta \varepsilon_{t-1}\) . Using the backshift operator \(B\) (\(B\varepsilon_t = \varepsilon_{t-1}\) ), we can write \(X_t = (1+\theta B)\varepsilon_t\) , and formally expanding the inverse operator gives

\[ \varepsilon_t = (1+\theta B)^{-1} X_t = \sum_{j=0}^{\infty} (-\theta)^j X_{t-j} \]

an AR(∞) representation. This series converges (i.e. the current shock \(\varepsilon_t\) can be recovered from past observations alone) exactly when \(|\theta| < 1\) . The characteristic equation \(1 + \theta z = 0\) has root \(z = -1/\theta\) , so this is the same unit-circle condition as AR stationarity, just applied to the MA polynomial.

The need for invertibility comes down to parameter non-uniqueness. The theoretical ACF of MA(1) is

\[\rho_1 = \frac{\theta}{1+\theta^2}\]

which is unchanged under \(\theta \to 1/\theta\) (for example, \(\theta=2\) and \(\theta=0.5\) produce identical autocorrelation structure). The ACF alone cannot distinguish \(\theta\) from \(1/\theta\) , so by convention we pick the root satisfying \(|\theta|<1\) to fix a unique representation. For a general MA(\(q\) ), invertibility requires all roots of \(\theta(z) = 1 + \theta_1 z + \cdots + \theta_q z^q\) to lie outside the unit circle.

ARMA Model

Combining AR and MA gives ARMA(\(p, q\) ):

\[X_t = c + \sum_{i=1}^{p} \phi_i X_{t-i} + \varepsilon_t + \sum_{j=1}^{q} \theta_j \varepsilon_{t-j} \tag{5}\]

Stationarity is judged from the roots of the AR characteristic polynomial and invertibility from the roots of the MA polynomial, independently of each other. Both must hold for the parameters to be uniquely identifiable in a stationary process.

ARIMA Model

For non-stationary series, apply \(d\) differences to achieve stationarity, then fit ARMA. This is ARIMA(\(p, d, q\) ):

\[\Delta^d X_t = c + \sum_{i=1}^{p} \phi_i \Delta^d X_{t-i} + \varepsilon_t + \sum_{j=1}^{q} \theta_j \varepsilon_{t-j} \tag{6}\]

where \(\Delta X_t = X_t - X_{t-1}\) is the first difference.

  • \(p\) : autoregressive order
  • \(d\) : differencing order (typically 0, 1, or 2)
  • \(q\) : moving average order

Parameter Estimation via Conditional Maximum Likelihood

The Box-Jenkins method is a three-stage, iterative framework: identification → estimation → diagnostic checking. Here we derive the estimation stage’s core tool, conditional sum-of-squares (CSS) maximum likelihood.

For an AR(\(p\) ) model with \(\varepsilon_t \sim \mathcal{N}(0, \sigma^2)\) , equation (1) implies the Markov property

\[ X_t \mid X_{t-1}, \ldots, X_{t-p} \sim \mathcal{N}\!\left(c + \sum_{i=1}^{p} \phi_i X_{t-i},\ \sigma^2\right) \]

Conditioning on the first \(p\) observations \(X_1, \ldots, X_p\) , the joint density factors as a product of these conditional distributions:

\[ f(x_{p+1}, \ldots, x_N \mid x_1, \ldots, x_p) = \prod_{t=p+1}^{N} f(x_t \mid x_{t-1}, \ldots, x_{t-p}) \]

Taking logarithms, the conditional log-likelihood is

\[ \ell_c(\phi, \sigma^2) = -\frac{N-p}{2}\ln(2\pi\sigma^2) - \frac{1}{2\sigma^2}\sum_{t=p+1}^{N}\left(x_t - c - \sum_{i=1}^{p}\phi_i x_{t-i}\right)^{2} \tag{7} \]

Maximizing equation (7) over \(\phi_i\) for fixed \(\sigma^2\) is equivalent to minimizing the residual sum of squares in the second term — that is, exactly ordinary least squares (OLS) regressing \(x_t\) on \(x_{t-1}, \ldots, x_{t-p}\) . This is why the method is called “conditional sum of squares” (CSS). Substituting \(\hat{\phi}\) and solving \(\partial \ell_c/\partial \sigma^2 = 0\) gives the MLE

\[\hat{\sigma}^2 = \frac{1}{N-p}\sum_{t=p+1}^{N}\left(x_t - \hat{c} - \sum_i \hat{\phi}_i x_{t-i}\right)^2\]

the residual sum of squares divided by the effective sample size. The same idea extends to general ARMA models, but since MA terms involve unobserved \(\varepsilon_{t-j}\) , one typically initializes \(\varepsilon_0 = \varepsilon_{-1} = \cdots = 0\) or backcasts before assembling the conditional likelihood.

CSS is computationally simple, but it is an approximation that discards the marginal-distribution information carried by the first \(p\) observations. An alternative incorporates the stationary distribution of \(X_1, \ldots, X_p\) as the initial condition and computes the exact (approximation-free) Gaussian log-likelihood for the entire observed sequence via the Kalman filter’s prediction-error decomposition — this is what statsmodels’ ARIMA class uses internally by default. The derivation of this exact likelihood, and a numerical comparison against CSS, is covered in depth in https://yuhi-sa.github.io/en/posts/20260715_arima_state_space_kalman/1/. Later in this article’s Python implementation, we compare a hand-rolled CSS estimator against statsmodels’ exact MLE numerically.

Parameter Selection (the identification step)

ACF and PACF

PatternACFPACFModel
AR(\(p\) )Gradual decaySharp cutoff at lag \(p\)Use PACF for p
MA(\(q\) )Sharp cutoff at lag \(q\)Gradual decayUse ACF for q
ARMA(\(p,q\) )Gradual decayGradual decayUse AIC/BIC

Why the PACF cuts off for an AR process (it is the correlation left after removing the linear effect of intermediate lags), and the concrete calculation via the Yule-Walker equations and the Levinson-Durbin recursion, are developed in full — with both derivations and numerical experiments — in https://yuhi-sa.github.io/en/posts/20260710_pacf_ar_identification/1/. Here we take this identification table as given and move directly to plotting and reading off orders in Python.

Information Criteria

Use AIC or BIC to select the optimal \((p, d, q)\) combination:

\[\text{AIC} = -2\ln(L) + 2k \tag{8}\]

where \(L\) is maximum likelihood and \(k\) is the number of parameters.

Python Implementation: From Stationarity Testing to Model Estimation

Data generation and the ADF test

We generate data from a true AR(2) process (\(\phi_1=0.5, \phi_2=-0.3\) ) plus a linear trend, and check stationarity with the Augmented Dickey-Fuller (ADF) test.

import numpy as np
import pandas as pd
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.stattools import adfuller

# --- Generate data (AR(2) process + trend) ---
np.random.seed(42)
n = 300
noise = np.random.normal(0, 1, n)
data = np.zeros(n)
for t in range(2, n):
    data[t] = 0.5 * data[t-1] - 0.3 * data[t-2] + noise[t]
data += np.linspace(0, 10, n)  # add a trend

ts = pd.Series(data, index=pd.date_range('2024-01-01', periods=n, freq='D'))

# --- Verify the AR(2) characteristic roots numerically ---
roots = np.roots([-(-0.3), -(0.5), 1])  # 1 - 0.5z + 0.3z^2 = 0
print("characteristic roots:", roots, "moduli:", np.abs(roots))

# --- Stationarity test (ADF test) ---
result = adfuller(ts)
print(f"ADF statistic: {result[0]:.4f}, p-value: {result[1]:.4f}")
print("critical values:", result[4])

ts_diff = ts.diff().dropna()
result_diff = adfuller(ts_diff)
print(f"After 1st diff - ADF: {result_diff[0]:.4f}, p-value: {result_diff[1]:.4f}")

Output:

characteristic roots: [0.83333333+1.62446572j 0.83333333-1.62446572j] moduli: [1.82574186 1.82574186]
ADF statistic: -0.1789, p-value: 0.9410
critical values: {'1%': -3.4532, '5%': -2.8716, '10%': -2.5721}
After 1st diff - ADF: -7.6752, p-value: 0.0000
critical values (diff): {'1%': -3.4538, '5%': -2.8718, '10%': -2.5723}

The root modulus \(1.8257\) matches the theoretical value derived above, confirming the AR(2) component is stationary. But because the full series includes a linear trend, the ADF statistic (\(-0.1789\) , above every critical value, \(p=0.9410\) ) fails to reject the unit-root null — the series appears non-stationary. After first-differencing, the ADF statistic (\(-7.6752\) , well below the 1% critical value of \(-3.4538\) , \(p \approx 0\) ) clearly indicates stationarity, numerically confirming \(d=1\) is appropriate.

Identifying orders from ACF/PACF plots

import matplotlib.pyplot as plt
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf

fig, axes = plt.subplots(1, 2, figsize=(12, 4))
plot_acf(ts_diff, ax=axes[0], lags=20, title='ACF (1st Difference)')
plot_pacf(ts_diff, ax=axes[1], lags=20, method='ywm', title='PACF (1st Difference)')
plt.tight_layout()
plt.show()

Side-by-side ACF and PACF plots of the first-differenced series. The ACF (left, blue) decays gradually across all lags — 0.53 at lag 1, -0.31 at lag 2, and so on — with no clear cutoff, while the PACF (right, orange) exceeds the 95% confidence band only at lags 1 and 2, with all subsequent lags falling inside the band, suggesting an AR(2) structure

The PACF cuts off at lag 2, suggesting an AR(2) component per the identification table (why AR structure survives in the differenced series is explained in the “overdifferencing” section below).

Numerical comparison: conditional MLE (CSS) vs. exact MLE

We implement the conditional least-squares estimator derived above by hand and compare it to statsmodels’ exact (Kalman-filter-based) estimation.

train = ts[:250]
test = ts[250:]

# --- Manual conditional MLE (CSS) ---
y = train.diff().dropna().values
X = np.column_stack([y[1:-1], y[0:-2]])  # lag 1, lag 2
yt = y[2:]
beta = np.linalg.lstsq(X, yt, rcond=None)[0]
resid_css = yt - X @ beta
sigma2_css = np.sum(resid_css**2) / len(resid_css)
print("[CSS] phi1, phi2:", beta, "sigma2:", sigma2_css)

# --- statsmodels exact MLE (Kalman-filter-based) ---
fitted = ARIMA(train, order=(2, 1, 0)).fit()
print("[exact MLE] phi1, phi2:", fitted.arparams, "sigma2:", fitted.params.iloc[-1])
print(fitted.summary())

Output:

[CSS] phi1, phi2: [-0.14256314 -0.3405605 ] sigma2: 1.3407 (n=247)
[exact MLE] phi1, phi2: [-0.14198861 -0.33822858] sigma2: 1.3316 (llf=-389.10, N=249)

                 coef    std err          z      P>|z|
ar.L1         -0.1420      0.067     -2.125      0.034
ar.L2         -0.3382      0.064     -5.307      0.000
sigma2         1.3316      0.120     11.059      0.000
AIC: 784.19   BIC: 794.74

CSS (\(\hat{\phi}_1=-0.1426, \hat{\phi}_2=-0.3406\) ) and statsmodels’ exact MLE (\(\hat{\phi}_1=-0.1420, \hat{\phi}_2=-0.3382\) ) agree to 2-3 decimal places. The small discrepancy comes from CSS conditioning away — and thus losing — the first two points (effective \(N=247\) ), whereas the exact MLE uses the stationary distribution as an initial condition and exploits all \(N=249\) points. The Kalman filter’s prediction-error decomposition, derived in https://yuhi-sa.github.io/en/posts/20260715_arima_state_space_kalman/1/, is precisely the mechanism that closes this gap.

Residual Diagnostics and a Model-Selection Pitfall: Overdifferencing

As the third Box-Jenkins stage, “diagnostic checking,” we run the Ljung-Box test to see whether residuals behave like white noise.

from statsmodels.stats.diagnostic import acorr_ljungbox

lb = acorr_ljungbox(fitted.resid, lags=[10, 20], return_df=True)
print(lb)

Output:

      lb_stat     lb_pvalue
10  48.436410  5.165530e-07
20  60.495144  5.968126e-06

Both lags are rejected at \(p < 0.001\) — significant autocorrelation remains in the residuals. Even though the ADF test supported stationarity after first-differencing, and the PACF showed a cutoff at lag 2, ARIMA(2,1,0) turns out to be misspecified.

The culprit is overdifferencing. This article’s data-generating process is “a stationary AR(2) plus a deterministic linear trend” — it has no stochastic unit root. Yet applying a first difference (\(d=1\) ) to an already-stationary AR(2) process artificially introduces a non-invertible MA component (for AR(1) as an illustration: \(x_t - x_{t-1} = \phi(x_{t-1}-x_{t-2}) + (\varepsilon_t - \varepsilon_{t-1})\) , and the right-hand term \(\varepsilon_t - \varepsilon_{t-1}\) generates an MA(1) component). Trying to model this artificial MA structure with ARIMA(2,1,0) — MA order 0 — leaves residual structure behind.

We fix this two ways and compare AIC, residual diagnostics, and forecast error.

# Fix 1: add an MA term to absorb the artificial MA component
m211 = ARIMA(train, order=(2, 1, 1)).fit()
lb211 = acorr_ljungbox(m211.resid, lags=[10, 20], return_df=True)

# Fix 2: skip differencing, model the deterministic trend explicitly (matches the true DGP)
m200t = ARIMA(train, order=(2, 0, 0), trend='t').fit()
lb200t = acorr_ljungbox(m200t.resid, lags=[10, 20], return_df=True)

for name, m, lb in [("ARIMA(2,1,0)", fitted, lb), ("ARIMA(2,1,1)", m211, lb211), ("ARIMA(2,0,0)+trend", m200t, lb200t)]:
    fc = m.forecast(steps=len(test))
    rmse = np.sqrt(np.mean((test.values - fc.values)**2))
    mae = np.mean(np.abs(test.values - fc.values))
    print(f"{name}: AIC={m.aic:.2f}, LB(10) p={lb['lb_pvalue'].iloc[0]:.3f}, RMSE={rmse:.4f}, MAE={mae:.4f}")

Output:

ModelAICLjung-Box(10) p-valueRMSEMAE
ARIMA(2,1,0) (misspecified)784.195.2×10⁻⁷ (rejected)1.24940.9762
ARIMA(2,1,1)725.460.211 (white noise)1.56521.2807
ARIMA(2,0,0)+trend (true specification)698.940.562 (white noise)1.11960.8848

The model matching the true data-generating process — ARIMA(2,0,0)+trend — is best on AIC, residual diagnostics, and forecast error alike. Yet if you looked at AIC alone, ARIMA(2,1,0) is worse than ARIMA(2,1,1), whereas on point-forecast RMSE alone, ARIMA(2,1,0) (1.2494) looks better than ARIMA(2,1,1) (1.5652). That’s a coincidence specific to this test window; once residuals fail the white-noise check, the reliability of the forecast intervals is no longer guaranteed. Never judge a model by AIC or point-forecast RMSE alone — always pair it with residual diagnostics (the Ljung-Box test): that is the essence of the Box-Jenkins method.

For reference, a naive “carry the last value forward” forecast gives RMSE \(= 1.4017\) and MAE \(= 1.1301\) . ARIMA(2,1,0) beats this naive baseline, but still falls short of the true specification, ARIMA(2,0,0)+trend.

Plot overlaying the training data (gray), the actual test-period values (black), the overdifferenced ARIMA(2,1,0) forecast (red dashed), and the correctly specified ARIMA(2,0,0)+trend forecast (blue dashed). The red line stays nearly flat and diverges noticeably from the rising trend, while the blue line tracks the actual upward trend closely

The overdifferenced ARIMA(2,1,0) (red dashed) fails to track the trend’s rise during the test period and stays nearly flat, while the correctly specified ARIMA(2,0,0)+trend (blue dashed) tracks the trend closely — a difference that is visually obvious as well.

SARIMA (Seasonal ARIMA)

For seasonal data, use SARIMA(\(p, d, q\) )(\(P, D, Q\) )\(_s\) :

\[\Phi_P(B^s) \phi_p(B) \Delta^d \Delta_s^D X_t = \Theta_Q(B^s) \theta_q(B) \varepsilon_t \tag{9}\]

where \(s\) is the seasonal period (12 for monthly data) and \(B\) is the backshift operator (\(BX_t = X_{t-1}\) ).

Numerical experiment: how much worse is it to ignore seasonality?

We generate 180 months of data from a true SARIMA(1,1,1)(1,1,1,12) process (via SARIMAX.simulate, with \(\phi=0.5, \theta=-0.4, \Phi=0.3, \Theta=-0.5\) ), run the ADF test after seasonal differencing, fit SARIMAX, and compare forecasts against a non-seasonal ARIMA(1,1,1) that ignores the seasonal structure.

from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.stattools import adfuller
from statsmodels.stats.diagnostic import acorr_ljungbox

n = 180
dummy = pd.Series(np.zeros(n), index=pd.date_range('2010-01-01', periods=n, freq='MS'))
mod = SARIMAX(dummy, order=(1, 1, 1), seasonal_order=(1, 1, 1, 12), trend='n')
true_params = [0.5, -0.4, 0.3, -0.5, 1.0]  # ar, ma, ar.S, ma.S, sigma2
sim = mod.simulate(true_params, n, initial_state=np.zeros(mod.k_states),
                    random_state=9) + 50  # random_state ensures reproducibility
ts_s = pd.Series(sim.values, index=dummy.index)
train_s, test_s = ts_s[:150], ts_s[150:]

# ADF test after seasonal differencing
res_sd = adfuller(ts_s.diff(12).dropna())
print(f"ADF after seasonal diff: {res_sd[0]:.4f}, p-value: {res_sd[1]:.4f}")

# SARIMA model
sarima_fit = SARIMAX(train_s, order=(1, 1, 1), seasonal_order=(1, 1, 1, 12)).fit(disp=False)
lb_s = acorr_ljungbox(sarima_fit.resid.iloc[13:], lags=[12, 24], return_df=True)
fc_s = sarima_fit.get_forecast(steps=len(test_s)).predicted_mean
rmse_s = np.sqrt(np.mean((test_s.values - fc_s.values)**2))
mae_s = np.mean(np.abs(test_s.values - fc_s.values))

# Non-seasonal ARIMA that ignores seasonality
nonseason_fit = ARIMA(train_s, order=(1, 1, 1)).fit()
fc_ns = nonseason_fit.forecast(steps=len(test_s))
rmse_ns = np.sqrt(np.mean((test_s.values - fc_ns.values)**2))
mae_ns = np.mean(np.abs(test_s.values - fc_ns.values))

print(f"SARIMA: AIC={sarima_fit.aic:.2f}, RMSE={rmse_s:.3f}, MAE={mae_s:.3f}")
print(f"Non-seasonal ARIMA: AIC={nonseason_fit.aic:.2f}, RMSE={rmse_ns:.3f}, MAE={mae_ns:.3f}")
print(lb_s)

Output:

ADF after seasonal diff: -3.4639, p-value: 0.0090

SARIMA(1,1,1)(1,1,1,12): AIC=384.24, RMSE=3.872, MAE=3.595
      lb_stat  lb_pvalue
12   8.024676   0.783199
24  14.797854   0.926534

Non-seasonal ARIMA(1,1,1): AIC=685.03, RMSE=7.720, MAE=6.421

The ADF statistic after seasonal differencing (\(-3.4639\) , \(p=0.0090\) ) confirms stationarity, and the SARIMA residuals pass the Ljung-Box white-noise test (\(p=0.783, 0.927\) ). The non-seasonal ARIMA(1,1,1), which ignores seasonality, is much worse: AIC \(685.03\) versus SARIMA’s \(384.24\) , and RMSE/MAE deteriorate by roughly 2.0×/1.8× (RMSE: \(3.872 \to 7.720\) ; MAE: \(3.595 \to 6.421\) ). This is direct numerical evidence for the value of modeling the seasonal period explicitly.

Note that SARIMAX.simulate maintains its own internal random state, so setting np.random.seed() globally alone does not make the results reproducible. Passing an explicit integer to the random_state argument, as in the code above, is what makes the generated series reproducible run to run — another easy-to-miss pitfall when working with seasonal models.

Automated Model Selection (auto_arima) and Its Limits

pmdarima’s auto_arima can search for optimal parameters automatically, but by nature of the search algorithm, it does not always find the best model.

import pmdarima as pm

# Default: stepwise search (Hyndman-Khandakar algorithm)
auto_stepwise = pm.auto_arima(train, seasonal=False, stepwise=True, suppress_warnings=True)
print("stepwise selection:", auto_stepwise.order, "AIC:", auto_stepwise.aic())

# Exhaustive search (stepwise=False)
auto_full = pm.auto_arima(train, seasonal=False, stepwise=False, suppress_warnings=True,
                           max_p=5, max_q=5, max_d=2, n_jobs=-1)
print("full-search selection:", auto_full.order, "AIC:", auto_full.aic())

Output:

stepwise selection: (0, 1, 0) AIC: 813.02
full-search selection: (5, 1, 0) AIC: 732.96

The default stepwise search picks ARIMA(0,1,0) (AIC \(813.02\) ) — essentially “do nothing” — which is worse than the manually compared ARIMA(2,1,0) (AIC \(784.19\) ) from earlier. Switching to stepwise=False for an exhaustive search improves things slightly to ARIMA(5,1,0) (AIC \(732.96\) ), but this still falls short of the true specification, ARIMA(2,0,0)+trend (AIC \(698.94\) ). This exposes two independent issues.

  • Stepwise search can get stuck in a local optimum: stepwise=True does not search the entire candidate space, and as we see here, it can land far from the optimum.
  • The search space itself does not contain the true model: auto_arima(seasonal=False) only searches the differencing order \(d\) and AR/MA orders — it never includes a deterministic trend term as a candidate. When the trend is deterministic rather than a stochastic unit root, as here, no amount of increasing the AR/MA order can reach the true specification; the only recourse is to over-parameterize (\(p=5\) ) as an approximation.

Automated tools are ultimately heuristic searches within a given candidate space — domain knowledge about the data-generating process (whether the trend is deterministic or stochastic, whether seasonality is present, etc.) is still required to design that search space.

Practical Notes

  • Watch for overdifferencing: relying solely on the ADF test and the PACF identification table can lead you to difference a stationary process that has a deterministic trend. Always confirm residual whiteness with the Ljung-Box test after differencing.
  • Watch the invertibility boundary: when an estimated MA coefficient lands near \(\pm 1\) (the non-invertible boundary), standard errors can blow up dramatically (in a preliminary experiment for this article, an overdifferenced case with both seasonal and non-seasonal differencing applied together showed ma.L1’s standard error inflate by several hundredfold). That is a sign to reconsider the order or the number of differences.
  • AIC and point-forecast RMSE are different metrics: in this article’s numerical experiments, a model rejected by residual diagnostics still looked better on RMSE for a particular test window. Check AIC, residual diagnostics, and forecast error together, as a set.
  • Automated tools are not a silver bullet: auto_arima’s stepwise search can get stuck in local optima, and designing the search space (whether to include a trend term, whether to search seasonality) remains the user’s responsibility.

Recent Research

The practical trade-off between the classical Box-Jenkins method (ARIMA) and machine learning approaches continues to be actively studied. Tjøstheim (2025), Selected Topics in Time Series Forecasting: Statistical Models vs. Machine Learning (Entropy 27(3), 279, DOI: 10.3390/e27030279), reviews results across the Makridakis forecasting competitions (M1-M6) and shows that classical linear parametric models such as ARIMA and exponential smoothing remain competitive with machine learning on low-frequency (“well-behaved,” low spectral-entropy) series such as monthly data, while machine learning gains a clear edge in domains dominated by nonlinearity and variance clustering, such as volatility forecasting and high-frequency financial data. The Theta method — an extension of exponential smoothing — won M3, and a hybrid of exponential smoothing and deep learning won M4, underscoring that the recent trend is “combine classical statistics with machine learning” rather than “statistics versus machine learning.” This reinforces the point that ARIMA is not a universal model but one tool to be chosen based on the properties of the data at hand — its linearity and entropy.

Python Implementation Summary

# The final model combining the above, close to the true specification
final_model = ARIMA(train, order=(2, 0, 0), trend='t').fit()
print(final_model.summary())
forecast = final_model.get_forecast(steps=len(test))
mean_forecast = forecast.predicted_mean
conf_int = forecast.conf_int()

References

  • Box, G. E. P., Jenkins, G. M., Reinsel, G. C., & Ljung, G. M. (2015). Time Series Analysis: Forecasting and Control (5th ed.). Wiley.
  • Hyndman, R. J., & Athanasopoulos, G. (2021). Forecasting: Principles and Practice (3rd ed.).
  • Tjøstheim, D. (2025). “Selected Topics in Time Series Forecasting: Statistical Models vs. Machine Learning.” Entropy, 27(3), 279. DOI: 10.3390/e27030279
  • statsmodels ARIMA documentation