SECTION 1: LEARNING OBJECTIVES

By the end of this lesson, you will be able to:

  • Understand the limitations of traditional regression when applied to time series data and why specialised models are required.

  • Define and apply the ARIMA (AutoRegressive Integrated Moving Average) framework – one of the most widely used classical time series forecasting methods in finance.

  • Understand the three components of ARIMA: AR (AutoRegressive), I (Integrated/Differencing), and MA (Moving Average).

  • Use the ACF and PACF plots to identify the orders of AR and MA components (p and q).

  • Apply the Augmented Dickey‑Fuller (ADF) test to determine the required order of differencing (d).

  • Extend ARIMA to SARIMA to handle seasonality in financial data (e.g., quarterly earnings, holiday effects).

  • Apply Facebook Prophet – a modern, scalable forecasting tool designed for business time series with strong seasonal patterns and holiday effects.

  • Evaluate forecast accuracy using metrics such as MAE, RMSE, MAPE, and MASE.

  • Understand the business applications of time series forecasting in banking: loan demand forecasting, deposit growth, interest rate predictions, and economic scenario analysis.


SECTION 2: WHY TIME SERIES MODELS ARE DIFFERENT

In Lesson 6 of Module 3, we introduced time series concepts. Now we build predictive models that explicitly account for temporal dependence.

Key challenges in time series forecasting:

 
 
Challenge Description Financial Example
Autocorrelation Observations are correlated with their past values. Today’s stock return is correlated with yesterday’s.
Trend Long‑term upward or downward movement. Bank deposits grow over decades.
Seasonality Regular patterns within a fixed period. Credit card spending peaks in December.
Non‑stationarity Statistical properties change over time. Volatility changes during crises.
External shocks Unexpected events that permanently alter the series. COVID‑19 impact on loan defaults.

Why linear regression fails:

  • Violates the independence assumption (autocorrelation leads to inefficient estimates).

  • Cannot capture time‑dependent dynamics.

  • Standard errors are underestimated (spurious significance).


SECTION 3: THE ARIMA FRAMEWORK – MATHEMATICAL FOUNDATION

ARIMA stands for AutoRegressive Integrated Moving Average. It is denoted as ARIMA(p, d, q) where:

  • p = order of the AutoRegressive (AR) component

  • d = degree of differencing (to achieve stationarity)

  • q = order of the Moving Average (MA) component

3.1 The AR Component (AutoRegressive)

An AR model of order p assumes that the current value depends linearly on its previous p values:

yt=c+ϕ1yt−1+ϕ2yt−2+⋯+ϕpyt−p+εt

  • ϕ1,ϕ2,…,ϕp are the AR coefficients.

  • εt is white noise (random error).

Interpretation: If ϕ1=0.5, then 50% of yesterday’s value persists into today.

3.2 The MA Component (Moving Average)

An MA model of order q assumes that the current value depends linearly on the previous q forecast errors:

yt=μ+θ1εt−1+θ2εt−2+⋯+θqεt−q+εt

  • θ1,θ2,…,θq are the MA coefficients.

  • μ is the mean of the series.

Interpretation: MA models capture “shock” effects that persist for a short period (e.g., a one‑off market event).

3.3 The I Component (Integration/Differencing)

Differencing removes trend and makes the series stationary. The first difference is:

yt′=yt−yt−1

If the series has a linear trend, first differencing d=1 is sufficient. If it has a quadratic trend, d=2 may be needed.

The complete ARIMA equation:

(1−∑i=1pϕiLi)(1−L)dyt=(1+∑j=1qθjLj)εt

where L is the lag operator (Lyt=yt−1).


SECTION 4: IDENTIFYING ARIMA ORDERS – ACF AND PACF

The Autocorrelation Function (ACF) and Partial Autocorrelation Function (PACF) are the primary tools for identifying p and q.

 
 
Component ACF Pattern PACF Pattern Order
AR(p) Decays exponentially or sinusoidally Cuts off after lag p p = lag where PACF cuts off
MA(q) Cuts off after lag q Decays exponentially q = lag where ACF cuts off
ARMA(p, q) Decays after lag q−p Decays after lag p−q More complex; use AIC/BIC

Additional rules of thumb:

  • The ADF test determines d (the minimum number of differences for stationarity).

  • AIC (Akaike Information Criterion) and BIC (Bayesian Information Criterion) help choose the best model among candidates:

    • AIC = -2 log(L) + 2k (penalises complexity lightly)

    • BIC = -2 log(L) + k log(n) (penalises complexity more strongly)


SECTION 5: SARIMA – HANDLING SEASONALITY

SARIMA (Seasonal ARIMA) extends ARIMA by adding seasonal components. It is denoted SARIMA(p, d, q)(P, D, Q)_m where:

  • P = seasonal AR order

  • D = seasonal differencing order

  • Q = seasonal MA order

  • m = number of periods per season (e.g., 12 for monthly data, 4 for quarterly)

Financial examples of seasonality:

  • Monthly retail sales (holiday peaks)

  • Quarterly earnings (end‑of‑year accruals)

  • Daily transaction volumes (weekend vs. weekday)

  • Weekly stock returns (Monday effect)

The SARIMA equation extends ARIMA with seasonal lags:

ΦP(Lm)ϕp(L)(1−Lm)D(1−L)dyt=ΘQ(Lm)θq(L)εt

  • ΦP is the seasonal AR polynomial.

  • ΘQ is the seasonal MA polynomial.


SECTION 6: FACEBOOK PROPHET – A MODERN ALTERNATIVE

Prophet, developed by Facebook, is designed for business forecasting where:

  • Strong seasonality exists (daily, weekly, yearly).

  • Holidays and special events impact the series.

  • There are missing data points and outliers.

  • The forecast needs to be interpretable and adjustable by non‑experts.

Prophet decomposes the time series into three components:

y(t)=g(t)+s(t)+h(t)+εt

  • g(t) = Trend (piecewise linear or logistic growth with changepoints).

  • s(t) = Seasonality (Fourier series for yearly, weekly, daily patterns).

  • h(t) = Holiday effects (user‑specified special days).

  • εt = Error term.

Advantages of Prophet:

  • Handles missing data and outliers robustly.

  • Fast fitting and forecasting.

  • Intuitive hyperparameters (changepoint_prior_scale, seasonality_prior_scale).

  • Built‑in uncertainty intervals.

Limitations in finance:

  • Assumes relatively clean, regular time series (less suitable for high‑frequency financial data).

  • May not capture complex autoregressive dynamics as well as ARIMA/SARIMA.

  • Less interpretable from a statistical perspective (but easier for business users).


SECTION 7: IMPLEMENTATION IN PYTHON – ARIMA, SARIMA, AND PROPHET

We’ll apply these methods to forecast a bank’s monthly deposit volume.

python
# ===================================================================
# MODULE 4, LESSON 5: ADVANCED TIME SERIES FORECASTING
# ===================================================================

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from statsmodels.tsa.stattools import adfuller, acf, pacf
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.statespace.sarimax import SARIMAX
from sklearn.metrics import mean_absolute_error, mean_squared_error
from prophet import Prophet
import warnings
warnings.filterwarnings('ignore')

# Set style and reproducibility
np.random.seed(42)
sns.set_style("whitegrid")

print("="*70)
print("ADVANCED TIME SERIES FORECASTING – BANK DEPOSIT VOLUME")
print("="*70)

# ----------------------------------------------------------------
# PART A: GENERATE SYNTHETIC BANK DEPOSIT DATA
# ----------------------------------------------------------------

# Generate 5 years of monthly data (60 months)
n_months = 60
dates = pd.date_range(start='2019-01-01', periods=n_months, freq='MS')

# Components:
# 1. Trend: gradual growth of 0.5% per month
trend = np.linspace(100, 100 * (1.005)**n_months, n_months)

# 2. Seasonality: annual pattern (higher in Dec, lower in Jan)
seasonal = 20 * np.sin(2 * np.pi * np.arange(n_months) / 12 + 0.5)

# 3. Cyclical: economic cycle (2-year cycle)
cycle = 15 * np.sin(2 * np.pi * np.arange(n_months) / 24 + 1)

# 4. Random noise
noise = np.random.normal(0, 10, n_months)

# Combine
deposits = trend + seasonal + cycle + noise + 100
deposits = np.maximum(deposits, 50)  # No negative values

# Create DataFrame
df = pd.DataFrame({
    'date': dates,
    'deposits': deposits
})
df.set_index('date', inplace=True)

print("\nDataset shape:", df.shape)
print("\nFirst 12 months:")
print(df.head(12).round(2))
print("\nSummary Statistics:")
print(df.describe().round(2))

# Visualise the series
fig, axes = plt.subplots(2, 1, figsize=(14, 10))

ax = axes[0]
ax.plot(df.index, df['deposits'], 'b-', linewidth=1.5)
ax.set_title('Bank Deposits – Monthly Time Series', fontsize=14)
ax.set_ylabel('Deposits ($ millions)')
ax.grid(True, alpha=0.3)

# Decompose components (visual)
ax = axes[1]
components = pd.DataFrame({
    'Trend': trend,
    'Seasonal': seasonal,
    'Cycle': cycle,
    'Noise': noise
}, index=df.index)
components.plot(ax=ax)
ax.set_title('Time Series Components', fontsize=14)
ax.set_ylabel('Contribution')
ax.legend()
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('time_series_data.png', dpi=300)
plt.show()

# Split into train (first 48 months) and test (last 12 months)
train = df.iloc[:-12]
test = df.iloc[-12:]

print(f"\nTraining set: {len(train)} months")
print(f"Test set: {len(test)} months")

# ----------------------------------------------------------------
# PART B: STATIONARITY CHECK (ADF Test)
# ----------------------------------------------------------------

print("\n" + "-"*60)
print("PART B: STATIONARITY CHECK (Augmented Dickey-Fuller Test)")
print("-"*60)

def adf_test(series, title=''):
    """Perform ADF test and print results."""
    result = adfuller(series, autolag='AIC')
    print(f"\nADF Test for {title}:")
    print(f"  Test Statistic: {result[0]:.4f}")
    print(f"  P-Value: {result[1]:.4f}")
    print(f"  Critical Values:")
    for key, value in result[4].items():
        print(f"    {key}: {value:.4f}")
    if result[1] < 0.05:
        print(f"  Conclusion: Series is STATIONARY (reject H0).")
    else:
        print(f"  Conclusion: Series is NON-STATIONARY (fail to reject H0).")
    return result

# Test original series
adf_test(train['deposits'], 'Original Deposits')

# First differencing
train_diff = train['deposits'].diff().dropna()
adf_test(train_diff, 'First Difference')

# Determine d
d = 1  # First difference makes it stationary
print(f"\nChosen order of differencing (d): {d}")

# ----------------------------------------------------------------
# PART C: ACF and PACF Plots (to determine p and q)
# ----------------------------------------------------------------

print("\n" + "-"*60)
print("PART C: ACF and PACF Analysis")
print("-"*60)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# ACF
plot_acf(train_diff, ax=axes[0], lags=20, alpha=0.05)
axes[0].set_title('ACF – First Difference', fontsize=12)

# PACF
plot_pacf(train_diff, ax=axes[1], lags=20, alpha=0.05)
axes[1].set_title('PACF – First Difference', fontsize=12)

plt.tight_layout()
plt.savefig('acf_pacf_analysis.png', dpi=300)
plt.show()

print("\nInterpretation:")
print("  - ACF cuts off after lag 1? → MA(1) or MA(q)")
print("  - PACF cuts off after lag 1? → AR(1) or AR(p)")
print("  - For this series, we see: ACF decays slowly, PACF cuts off at lag 1.")
print("  - Suggests AR(1) or ARMA(1,1) model.")

# ----------------------------------------------------------------
# PART D: ARIMA MODEL FITTING AND FORECASTING
# ----------------------------------------------------------------

print("\n" + "-"*60)
print("PART D: ARIMA Model Fitting")
print("-"*60)

# Based on ACF/PACF, try ARIMA(1,1,0) - AR model
model_arima = ARIMA(train['deposits'], order=(1, 1, 0))
fitted_arima = model_arima.fit()

print("\nARIMA(1,1,0) Model Summary:")
print(fitted_arima.summary())

# Diagnostics
print("\nModel Diagnostics:")
print(f"  AIC: {fitted_arima.aic:.2f}")
print(f"  BIC: {fitted_arima.bic:.2f}")
print(f"  Log-Likelihood: {fitted_arima.llf:.2f}")

# Check residuals
residuals = fitted_arima.resid
print(f"\nResidual Diagnostics:")
print(f"  Mean of residuals: {residuals.mean():.4f}")
print(f"  Std of residuals: {residuals.std():.4f}")

# Ljung-Box test for autocorrelation in residuals
from statsmodels.stats.diagnostic import acorr_ljungbox
lb_test = acorr_ljungbox(residuals, lags=10, return_df=True)
print(f"\nLjung-Box Test (p-values):")
print(lb_test['lb_pvalue'].tail(1))

# ----------------------------------------------------------------
# PART E: SARIMA WITH SEASONALITY
# ----------------------------------------------------------------

print("\n" + "-"*60)
print("PART E: SARIMA (with Seasonality)")
print("-"*60)

# SARIMA(1,1,1)(1,0,1,12) – monthly data with yearly seasonality
model_sarima = SARIMAX(
    train['deposits'],
    order=(1, 1, 1),
    seasonal_order=(1, 0, 1, 12),
    enforce_stationarity=False,
    enforce_invertibility=False
)
fitted_sarima = model_sarima.fit(disp=False)

print("\nSARIMA(1,1,1)(1,0,1,12) Model Summary:")
print(fitted_sarima.summary())

print(f"\nModel Comparison:")
print(f"  ARIMA(1,1,0) AIC: {fitted_arima.aic:.2f}")
print(f"  SARIMA AIC:       {fitted_sarima.aic:.2f}")
print(f"  Lower AIC indicates better model (SARIMA is better).")

# ----------------------------------------------------------------
# PART F: FORECASTING AND EVALUATION
# ----------------------------------------------------------------

print("\n" + "-"*60)
print("PART F: Forecasting and Evaluation")
print("-"*60)

# Forecast horizon (12 months)
forecast_steps = len(test)

# ARIMA forecast
forecast_arima = fitted_arima.forecast(steps=forecast_steps)
forecast_arima_index = test.index

# SARIMA forecast
forecast_sarima = fitted_sarima.forecast(steps=forecast_steps)

# Evaluate
def evaluate_forecast(actual, forecast, name):
    """Calculate forecast error metrics."""
    actual = np.array(actual)
    forecast = np.array(forecast)
    mae = mean_absolute_error(actual, forecast)
    rmse = np.sqrt(mean_squared_error(actual, forecast))
    mape = np.mean(np.abs((actual - forecast) / actual)) * 100
    
    print(f"\n{name} Forecast Evaluation:")
    print(f"  MAE:  {mae:.2f}")
    print(f"  RMSE: {rmse:.2f}")
    print(f"  MAPE: {mape:.2f}%")
    return {'MAE': mae, 'RMSE': rmse, 'MAPE': mape}

results_arima = evaluate_forecast(test['deposits'], forecast_arima, "ARIMA(1,1,0)")
results_sarima = evaluate_forecast(test['deposits'], forecast_sarima, "SARIMA")

# Visualise forecasts
fig, ax = plt.subplots(figsize=(14, 7))

# Plot historical data
ax.plot(df.index, df['deposits'], 'b-', linewidth=1.5, label='Historical')

# Plot training/test split
ax.axvline(train.index[-1], color='black', linestyle='--', alpha=0.7, label='Train/Test Split')

# Plot forecasts
ax.plot(forecast_arima_index, forecast_arima, 'r--', linewidth=2, label='ARIMA Forecast')
ax.plot(forecast_arima_index, forecast_sarima, 'g--', linewidth=2, label='SARIMA Forecast')
ax.plot(test.index, test['deposits'], 'bo', markersize=8, label='Actual (Test)')

ax.set_title('Time Series Forecast Comparison', fontsize=14)
ax.set_xlabel('Date')
ax.set_ylabel('Deposits ($ millions)')
ax.legend()
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('forecast_comparison.png', dpi=300)
plt.show()

# ----------------------------------------------------------------
# PART G: FACEBOOK PROPHET
# ----------------------------------------------------------------

print("\n" + "-"*60)
print("PART G: Facebook Prophet Forecast")
print("-"*60)

# Prophet requires columns 'ds' (date) and 'y' (value)
prophet_df = df.reset_index()
prophet_df.columns = ['ds', 'y']

# Split into train and test
prophet_train = prophet_df.iloc[:-12]
prophet_test = prophet_df.iloc[-12:]

# Initialise and fit Prophet model
model_prophet = Prophet(
    yearly_seasonality=True,
    weekly_seasonality=False,
    daily_seasonality=False,
    changepoint_prior_scale=0.05,
    seasonality_prior_scale=10.0,
    interval_width=0.95
)
model_prophet.fit(prophet_train)

# Create future dataframe
future = model_prophet.make_future_dataframe(periods=12, freq='MS')
forecast_prophet = model_prophet.predict(future)

# Extract forecast
prophet_forecast = forecast_prophet[['ds', 'yhat', 'yhat_lower', 'yhat_upper']].tail(12)
prophet_forecast.set_index('ds', inplace=True)

# Evaluate
results_prophet = evaluate_forecast(test['deposits'], prophet_forecast['yhat'], "Prophet")

# Visualise Prophet forecast
fig = model_prophet.plot(forecast_prophet)
plt.title('Prophet Forecast – Bank Deposits', fontsize=14)
plt.xlabel('Date')
plt.ylabel('Deposits ($ millions)')
plt.savefig('prophet_forecast.png', dpi=300)
plt.show()

# Plot components
fig2 = model_prophet.plot_components(forecast_prophet)
plt.savefig('prophet_components.png', dpi=300)
plt.show()

# ----------------------------------------------------------------
# PART H: COMPREHENSIVE MODEL COMPARISON
# ----------------------------------------------------------------

print("\n" + "="*70)
print("PART H: MODEL COMPARISON SUMMARY")
print("="*70)

comparison_results = pd.DataFrame({
    'Model': ['ARIMA(1,1,0)', 'SARIMA', 'Prophet'],
    'MAE': [results_arima['MAE'], results_sarima['MAE'], results_prophet['MAE']],
    'RMSE': [results_arima['RMSE'], results_sarima['RMSE'], results_prophet['RMSE']],
    'MAPE (%)': [results_arima['MAPE'], results_sarima['MAPE'], results_prophet['MAPE']],
    'Interpretability': ['High', 'High', 'Medium'],
    'Computational Speed': ['Fast', 'Medium', 'Fast'],
    'Seasonality Handling': ['Manual', 'Built-in', 'Built-in']
})
print(comparison_results.to_string(index=False))

print("\n Business Recommendation:")
print("  - Use ARIMA for simple, non-seasonal series where interpretability is key.")
print("  - Use SARIMA for seasonal series (e.g., monthly retail, quarterly earnings).")
print("  - Use Prophet when you have holidays, missing data, or need an intuitive interface.")
print("  - In banking, SARIMA is preferred for regulatory submissions due to its statistical rigour.")

SECTION 8: BUSINESS APPLICATIONS IN BANKING

 
 
Application Recommended Model Rationale
Deposit Growth Forecasting SARIMA Monthly data with strong year‑end seasonality.
Loan Demand Prediction SARIMA / Prophet Seasonal patterns (e.g., higher demand in spring for mortgages).
Non-Performing Loan (NPL) Ratio ARIMA Tends to be less seasonal; focus on autoregressive dynamics.
Net Interest Margin (NIM) Forecasting SARIMA Quarterly data with clear seasonal patterns.
Transaction Volume (ATM/POS) Prophet Daily data with weekly seasonality; holiday effects (Christmas, Ramadan).
Regulatory Capital Forecasting ARIMA Conservative approach; focus on trend rather than seasonality.
Stress Testing (CCAR/DFAST) Multiple models Combine ARIMA, SARIMA, and Prophet for scenario analysis.

SECTION 9: SUMMARY FOR THE DATA PRACTITIONER

  • ARIMA is the classical workhorse for time series forecasting, combining AR, I (differencing), and MA components.

  • ACF and PACF identify the orders p and qADF test determines d.

  • SARIMA extends ARIMA with seasonal components – essential for financial data with annual, quarterly, or monthly patterns.

  • Prophet is a modern, user‑friendly alternative that handles holidays, missing data, and business‑specific seasonality.

  • Evaluation metrics (MAE, RMSE, MAPE) guide model selection.

  • In banking, SARIMA is the preferred regulatory‑friendly approach, while Prophet is excellent for internal business forecasting.


SECTION 10: RECOMMENDED NEXT STEPS

  1. Apply ARIMA/SARIMA to a real‑world dataset (e.g., Federal Reserve economic data, stock prices).

  2. Experiment with auto‑ARIMA (pmdarima) for automatic order selection.

  3. Learn about GARCH models for volatility forecasting (essential for risk management).

  4. Explore Deep Learning for Time Series (LSTM, Transformer) – covered in later modules.

  5. Understand the regulatory requirements for forecast validation in CCAR/DFAST.


[END OF LESSON 5 – MODULE 4]