Tutorials

ARIMA Models in Python with Statsmodels

Many real-world decisions depend on how a metric evolves over time: demand, temperature, web traffic, or energy usage. ARIMA is one of the most widely used forecasting methods because it handles two key features of time series that ordinary regression cannot: **autocorrelation** (today's value is correlated with yesterday's) and **non-stationarity** (the mean or variance shifts over time). ARIMA stands for Autoregressive Integrated Moving Average — three components that work together to model the temporal structure. Once fit, the model produces point forecasts with confidence intervals that widen appropriately as you forecast further into the future. For data with strong seasonal patterns (weekly, monthly), [SARIMAX](/tutorials/statsmodels-sarimax-time-series-forecasting) adds a seasonal layer on top.

## What ARIMA means

ARIMA is parameterized by three integers `(p, d, q)`, each controlling a different part of the model.

- **AR (Autoregressive)** — `p`: the number of past values used to predict the next one. AR(2) means the model uses the two most recent values.
- **I (Integrated)** — `d`: the number of times the series is differenced to make it stationary. `d=1` computes `x_t - x_{t-1}`; `d=0` uses the raw series.
- **MA (Moving Average)** — `q`: the number of past forecast errors included in the prediction. This helps the model correct for recent mispredictions.

## Creating the dataset

The daily minimum temperatures dataset covers Melbourne from 1981 to 1990 — a long enough series to show autocorrelation and seasonality clearly.

import requests
import pandas as pd

url = "https://raw.githubusercontent.com/jbrownlee/Datasets/master/daily-min-temperatures.csv"
response = requests.get(url, timeout=30)
response.raise_for_status()

with open("daily-min-temperatures.csv", "w", encoding="utf-8") as f:
    f.write(response.text)

df = pd.read_csv("daily-min-temperatures.csv")
df["Date"] = pd.to_datetime(df["Date"])
df = df.set_index("Date")
series = df["Temp"].asfreq("D")

print(series.head())
print(series.index.freq)
Date
1981-01-01    20.7
1981-01-02    17.9
1981-01-03    18.8
1981-01-04    14.6
1981-01-05    15.8
Freq: D, Name: Temp, dtype: float64
<Day>
- `pd.to_datetime(df["Date"])` converts string dates to proper datetime objects — required for time-aware indexing and frequency setting.
- `.asfreq("D")` sets the frequency to daily so statsmodels can correctly interpret forecast steps and handle any gaps.
- Saving locally once means all later blocks can load from disk without repeating the network request.

## Visualizing the time series

Before choosing ARIMA parameters, always plot the series. The shape reveals whether differencing is needed, whether seasonality is present, and whether the variance is growing.

import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv("daily-min-temperatures.csv")
df["Date"] = pd.to_datetime(df["Date"])
df = df.set_index("Date")
series = df["Temp"].asfreq("D")

plt.figure(figsize=(10, 4))
plt.plot(series.index, series.values, linewidth=1)
plt.title("Daily Minimum Temperatures")
plt.xlabel("Date")
plt.ylabel("Temperature")
plt.tight_layout()
plt.show()
- A roughly stable mean level over time (no persistent upward or downward drift) suggests `d=0` may be sufficient — differencing would be needed if the mean drifts.
- A repeating annual cycle in the plot indicates seasonality — ARIMA alone won't capture it, and [SARIMAX](/tutorials/statsmodels-sarimax-time-series-forecasting) would be a better choice.
- A visual inspection takes seconds and can save you from fitting a model on fundamentally misspecified data.

## Differencing to reduce non-stationarity

ARIMA assumes the differenced series is stationary — meaning its statistical properties don't change over time. First differencing removes linear trends and is the most common transformation before fitting.

import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv("daily-min-temperatures.csv")
df["Date"] = pd.to_datetime(df["Date"])
df = df.set_index("Date")
series = df["Temp"].asfreq("D")

diff_series = series.diff().dropna()

plt.figure(figsize=(10, 4))
plt.plot(diff_series.index, diff_series.values, linewidth=1, color="tab:orange")
plt.title("First-Differenced Temperature Series")
plt.xlabel("Date")
plt.ylabel("Differenced Temperature")
plt.tight_layout()
plt.show()
- `series.diff()` computes `x_t - x_{t-1}` for each row — this removes a constant trend, making the mean roughly zero and more stable over time.
- `.dropna()` removes the first row, which has no prior value to difference against.
- If the differenced series still shows a trend, a second difference (`d=2`) may be needed — though `d>2` is rarely used in practice.

## Fitting an ARIMA model

With the data prepared and a rough sense of the series structure, fitting ARIMA is a two-line call.

import pandas as pd
from statsmodels.tsa.arima.model import ARIMA

df = pd.read_csv("daily-min-temperatures.csv")
df["Date"] = pd.to_datetime(df["Date"])
df = df.set_index("Date")
series = df["Temp"].asfreq("D")

model = ARIMA(series, order=(2, 1, 2))
results = model.fit()

print(results.summary())
                               SARIMAX Results                                
==============================================================================
Dep. Variable:                   Temp   No. Observations:                 3652
Model:                 ARIMA(2, 1, 2)   Log Likelihood               -8386.458
Date:                Fri, 10 Apr 2026   AIC                          16782.915
Time:                        12:30:11   BIC                          16813.929
Sample:                    01-01-1981   HQIC                         16793.960
                         - 12-31-1990                                         
Covariance Type:                  opg                                         
==============================================================================
                 coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
ar.L1          0.4852      0.134      3.620      0.000       0.222       0.748
ar.L2         -0.1243      0.067     -1.865      0.062      -0.255       0.006
ma.L1         -0.8895      0.136     -6.548      0.000      -1.156      -0.623
ma.L2         -0.0105      0.126     -0.083      0.934      -0.258       0.237
sigma2         5.8022      0.128     45.191      0.000       5.551       6.054
===================================================================================
Ljung-Box (L1) (Q):                   0.00   Jarque-Bera (JB):                14.49
Prob(Q):                              0.99   Prob(JB):                         0.00
Heteroskedasticity (H):               0.86   Skew:                             0.08
Prob(H) (two-sided):                  0.01   Kurtosis:                         3.27
===================================================================================

Warnings:
[1] Covariance matrix calculated using the outer product of gradients (complex-step).
- `order=(2, 1, 2)` means: use the 2 most recent values for AR, apply one difference for stationarity, and use the 2 most recent forecast errors for MA.
- The summary reports AIC and BIC for model comparison (lower is better), coefficient estimates, and a Ljung-Box test for whether residuals are white noise.
- Significant AR or MA coefficients confirm those terms are capturing real autocorrelation structure in the series.

## Train-test forecasting

Evaluating on a held-out test set gives an honest picture of how the model performs on unseen data.

import pandas as pd
from statsmodels.tsa.arima.model import ARIMA

df = pd.read_csv("daily-min-temperatures.csv")
df["Date"] = pd.to_datetime(df["Date"])
df = df.set_index("Date")
series = df["Temp"].asfreq("D")

train = series.iloc[:-365]
test = series.iloc[-365:]

model = ARIMA(train, order=(2, 1, 2))
results = model.fit()

forecast_res = results.get_forecast(steps=len(test))
forecast_df = forecast_res.summary_frame(alpha=0.05)
forecast_df.index = test.index
forecast_df["actual"] = test.values

print(forecast_df[["mean", "mean_ci_lower", "mean_ci_upper", "actual"]].head())
Temp             mean  mean_ci_lower  mean_ci_upper  actual
Date                                                       
1990-01-01  12.887473       8.135428      17.639518    14.8
1990-01-02  13.212491       7.685577      18.739405    13.3
1990-01-03  13.341927       7.671745      19.012109    15.6
1990-01-04  13.366339       7.645590      19.087088    14.5
1990-01-05  13.363559       7.600908      19.126209    14.3
- `series.iloc[:-365]` holds out the final year — the model is fit on all earlier data and then asked to forecast blind.
- `get_forecast(steps=365)` projects 365 steps ahead; `.summary_frame(alpha=0.05)` returns the point forecast and 95% confidence interval bounds.
- Adding `actual` as a column lets you compute forecast errors directly from the DataFrame for quantitative evaluation.

## Visualizing forecast vs actual

Overlaying the forecast on the actual test period shows immediately whether the model captures the level and trend, and whether the confidence intervals are appropriately sized.

import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.tsa.arima.model import ARIMA

df = pd.read_csv("daily-min-temperatures.csv")
df["Date"] = pd.to_datetime(df["Date"])
df = df.set_index("Date")
series = df["Temp"].asfreq("D")

train = series.iloc[:-365]
test = series.iloc[-365:]

model = ARIMA(train, order=(2, 1, 2))
results = model.fit()

forecast_res = results.get_forecast(steps=len(test))
forecast_df = forecast_res.summary_frame(alpha=0.05)
forecast_df.index = test.index

plt.figure(figsize=(11, 5))
plt.plot(train.index[-500:], train.values[-500:], label="Train (recent)", alpha=0.7)
plt.plot(test.index, test.values, label="Actual", color="black")
plt.plot(forecast_df.index, forecast_df["mean"], label="Forecast", color="tab:blue")
plt.fill_between(
    forecast_df.index,
    forecast_df["mean_ci_lower"],
    forecast_df["mean_ci_upper"],
    color="tab:blue",
    alpha=0.2,
    label="95% CI",
)
plt.title("ARIMA Forecast vs Actual")
plt.xlabel("Date")
plt.ylabel("Temperature")
plt.legend()
plt.tight_layout()
plt.show()
- `train.index[-500:]` shows only the most recent 500 training points — displaying the full 3,000+ day training history would compress the forecast period visually.
- Confidence bands that widen sharply over the forecast horizon indicate the model loses confidence quickly — expected for long-range forecasts of a highly variable daily series.
- If the forecast diverges from the actual series in a consistent direction (always above or always below), the model may be missing a trend or seasonal component.

### Conclusion

ARIMA is a strong baseline for time series with autocorrelation and trend. The `(p, d, q)` parameters control how much of the past the model uses — start with `(1, 1, 1)` as a baseline, inspect the summary, and adjust based on residual diagnostics. For monthly or quarterly data with clear seasonal patterns, upgrade to [SARIMAX](/tutorials/statsmodels-sarimax-time-series-forecasting).