ANALYSIS TOOL BOX
intermediate30 min

Time Series Forecasting with ARIMA

Fit and forecast with ARIMA models using CreateARIMAModel — from stationarity checks to 6-step-ahead forecasts.

arimatime-seriesforecastingpredictive-analytics

Time series forecasting is fundamentally different from cross-sectional prediction. The observations are ordered in time and autocorrelated — what happened last month is correlated with what happens this month. ARIMA (Autoregressive Integrated Moving Average) is the workhorse model for this setting.

This tutorial uses CreateARIMAModel to build a monthly revenue forecast from stationarity diagnostics through to a 6-month-ahead prediction.

Prerequisites

  • Python 3.9+
  • analysistoolbox installed (pip install analysistoolbox)
  • Familiarity with the concept of autocorrelation

The three components of ARIMA

ARIMA(p, d, q) has three parameters:

| Parameter | Name | What it captures | |---|---|---| | p | AR order | Autocorrelation: how many past values predict the current value | | d | Integration | Trend: how many times to difference the series to make it stationary | | q | MA order | Moving average: how many past forecast errors predict the current value |

Note

A stationary time series has constant mean and variance over time. ARIMA requires stationarity — that's why the "I" (integration / differencing) is there. If your series has a trend (e.g., revenue growing every year), you difference it once (d=1) to remove the trend before fitting the AR and MA components.

Step 1: Build a synthetic time series

python
import pandas as pd
import numpy as np

np.random.seed(42)

# 48 months of monthly revenue with trend + seasonality + noise
months = pd.date_range(start='2020-01', periods=48, freq='MS')
trend = np.linspace(100_000, 180_000, 48)
seasonal = 20_000 * np.sin(2 * np.pi * np.arange(48) / 12)
noise = np.random.normal(0, 8_000, 48)

df = pd.DataFrame({
    'month': months,
    'revenue': (trend + seasonal + noise).round(-2),
})

print(df.tail())
print(f"\nRevenue range: ${df['revenue'].min():,.0f} – ${df['revenue'].max():,.0f}")

Step 2: Diagnostic run — check for stationarity

Before fitting, let CreateARIMAModel test whether your series needs differencing:

python
from analysistoolbox.predictive_analytics import CreateARIMAModel

# Diagnostic run: show ACF/PACF plots and stationarity test
model_diag = CreateARIMAModel(
    dataframe=df,
    outcome_column_name='revenue',
    time_column_name='month',
    test_for_stationarity=True,
    show_acf_pacf_plots=True,
    show_model_results=True,
    plot_training_and_test_performance=False,
    plot_residuals=False,
)
Teaching Note

The ADF (Augmented Dickey-Fuller) test has a null hypothesis that the series is non-stationary. If the p-value is above 0.05, you fail to reject non-stationarity — set differencing_periods=1 to difference the series. Look at the ACF plot: slow geometric decay in autocorrelations indicates a non-stationary series and confirms you need differencing.

Step 3: Read the ACF and PACF plots

These two plots tell you which ARIMA order to use:

  • PACF (Partial Autocorrelation Function): spikes at lag k suggest AR order p = k. If PACF has one significant spike at lag 1 and cuts off, use p=1.
  • ACF (Autocorrelation Function): spikes at lag k suggest MA order q = k. If ACF decays gradually, the MA component is handling it; if it cuts off at lag 2, use q=2.

For our synthetic data with trend, you'd expect:

  • Non-stationarity (ADF fails) → set differencing_periods=1
  • Seasonal pattern at lag 12 in the ACF

Step 4: Fit the model

python
# Fit ARIMA(1, 1, 1) — autoregressive, differenced once, moving average
model = CreateARIMAModel(
    dataframe=df,
    outcome_column_name='revenue',
    time_column_name='month',
    lookback_periods=1,         # AR(1)
    differencing_periods=1,     # I(1) — difference once for stationarity
    lag_periods=1,              # MA(1)
    test_size=0.20,             # hold out last ~10 months for evaluation
    test_for_stationarity=True,
    show_acf_pacf_plots=False,
    show_model_results=True,
    plot_residuals=True,
    plot_training_and_test_performance=True,
)

The plot_training_and_test_performance=True chart overlays actual vs. predicted values on the test set. Look for:

  • Do predictions track the trend direction?
  • Are errors random (good) or systematic (bad — indicates a missing component)?

The plot_residuals=True chart shows the residuals. They should look like white noise — no patterns, roughly normally distributed around zero.

Step 5: Forecast 6 months ahead

python
# Forecast the next 6 months
forecast = model.forecast(steps=6)

future_months = pd.date_range(
    start=df['month'].max() + pd.offsets.MonthBegin(),
    periods=6,
    freq='MS'
)

forecast_df = pd.DataFrame({
    'month': future_months,
    'forecast': forecast.round(-2),
})

print("\n=== 6-Month Revenue Forecast ===")
print(forecast_df.to_string(index=False))

Step 6: Add confidence intervals

The ARIMA model also provides prediction intervals, which show the range of plausible outcomes:

python
import matplotlib.pyplot as plt

forecast_result = model.get_forecast(steps=6)
ci = forecast_result.conf_int(alpha=0.10)  # 90% confidence interval

fig, ax = plt.subplots(figsize=(12, 5))

# Historical data
ax.plot(df['month'], df['revenue'], label='Actual', color='#2563EB')

# Forecast
ax.plot(future_months, forecast, label='Forecast', color='#D97706', linestyle='--')
ax.fill_between(
    future_months,
    ci.iloc[:, 0],
    ci.iloc[:, 1],
    alpha=0.2, color='#D97706', label='90% CI'
)

ax.set_title('Monthly Revenue: Actual and 6-Month Forecast')
ax.set_ylabel('Revenue ($)')
ax.legend()
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'${x/1000:.0f}K'))
plt.tight_layout()
plt.show()
Teaching Note

Confidence intervals widen as you forecast further into the future — this is correct behavior. Anyone who shows you a time series forecast without widening intervals is either using a model that doesn't support them or hiding uncertainty from you. The 90% CI means: if the model's assumptions hold, the true value should fall within this band 90% of the time. It does NOT mean the model is 90% accurate — it means the model is 90% calibrated about its own uncertainty.

Common mistakes to avoid

Mistake 1: Forgetting seasonality. Our data has a 12-month seasonal cycle. A plain ARIMA model won't capture it. Use SARIMA (Seasonal ARIMA) by setting lookback_periods, differencing_periods, and lag_periods for the seasonal components as well.

Mistake 2: Using test data to tune hyperparameters. If you pick p, d, q by trying different values and checking which minimizes test-set error, you've effectively trained on the test set. Use the ACF/PACF plots or AIC/BIC from show_model_results=True to select the order, then evaluate once on the held-out test set.

Mistake 3: Forecasting too far out. ARIMA uncertainty compounds exponentially. A 6-month horizon for monthly data is reasonable; a 24-month horizon usually isn't, unless you have strong seasonality anchoring the long-run behavior.

Full code

python
import pandas as pd
import numpy as np
from analysistoolbox.predictive_analytics import CreateARIMAModel

np.random.seed(42)
months = pd.date_range(start='2020-01', periods=48, freq='MS')
trend = np.linspace(100_000, 180_000, 48)
seasonal = 20_000 * np.sin(2 * np.pi * np.arange(48) / 12)
noise = np.random.normal(0, 8_000, 48)

df = pd.DataFrame({
    'month': months,
    'revenue': (trend + seasonal + noise).round(-2),
})

# Fit ARIMA(1, 1, 1)
model = CreateARIMAModel(
    dataframe=df,
    outcome_column_name='revenue',
    time_column_name='month',
    lookback_periods=1,
    differencing_periods=1,
    lag_periods=1,
    test_size=0.20,
    test_for_stationarity=True,
    show_acf_pacf_plots=True,
    show_model_results=True,
    plot_residuals=True,
    plot_training_and_test_performance=True,
)

# 6-month forecast
forecast = model.forecast(steps=6)
future_months = pd.date_range(
    start=df['month'].max() + pd.offsets.MonthBegin(),
    periods=6, freq='MS'
)

print(pd.DataFrame({'month': future_months, 'forecast': forecast.round(-2)}).to_string(index=False))

Functions used in this tutorial