Table of Contents¶
- Exploration Data Analysis - EDA
1.1 Libraries
1.2 Data Loading via API
1.3 Descriptive and Univariate Analysis
1.4 Data Preparation
1.5 Feature Understanding and Historical Charts
1.6 Seasonal Decomposition
1.7 Outliers Analysis
1.8 Relational Analysis - Stationarity Testing - ADF Test
- Baseline Models
- Model 1 - ARIMA
4.1 Setup A - With COVID
4.2 Setup B - COVID Excluded - Model 2 - SARIMA
5.1 Setup A - With COVID
5.2 Setup B - COVID Excluded + Cross-Validation - Model 3 - Holt-Winters Exponential Smoothing
6.1 Setup A - With COVID
6.2 Setup B - Post-COVID Only (2022+) - Model Comparison - Setup A vs Setup B
- 12-Month Forecast
- Model Interpretability
1. Exploration Data Analysis - EDA¶
1.1 Libraries¶
import warnings
warnings.filterwarnings('ignore')
import logging
logging.captureWarnings(True)
logging.getLogger('py.warnings').setLevel(logging.ERROR)
from statsmodels.tools.sm_exceptions import ConvergenceWarning, ValueWarning
warnings.simplefilter('ignore', ConvergenceWarning)
warnings.simplefilter('ignore', ValueWarning)
import pandas as pd
import numpy as np
from collections import OrderedDict
import matplotlib.pyplot as plt
import seaborn as sns
from statsmodels.tsa.stattools import adfuller
from statsmodels.tsa.seasonal import seasonal_decompose
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tsa.holtwinters import ExponentialSmoothing as HWES
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
1.2 Data Loading via API¶
The dataset ASM01 - Air and Sea Travel is published by the Central Statistics Office (CSO) of Ireland. It is accessed via a public REST API and returns data in JSON-stat format, covering monthly arrivals and departures across all Irish airports and seaports since January 2010.
import requests
from pyjstat import pyjstat
url = "https://ws.cso.ie/public/api.restful/PxStat.Data.Cube_API.ReadDataset/ASM01/JSON-stat/2.0/en"
r = requests.get(url)
r.raise_for_status()
json_stat_obj = r.json(object_pairs_hook=OrderedDict)
df_list = pyjstat.from_json_stat(json_stat_obj)
df = pd.DataFrame(df_list[0])
df
| STATISTIC | Month | Direction | Mode | value | |
|---|---|---|---|---|---|
| 0 | Air and Sea Travel | 2010 January | Arrivals | All modes of transport | 773.2 |
| 1 | Air and Sea Travel | 2010 January | Arrivals | Air | 728.4 |
| 2 | Air and Sea Travel | 2010 January | Arrivals | Sea | 44.8 |
| 3 | Air and Sea Travel | 2010 January | Departures | All modes of transport | 791.7 |
| 4 | Air and Sea Travel | 2010 January | Departures | Air | 732.4 |
| ... | ... | ... | ... | ... | ... |
| 1165 | Air and Sea Travel | 2026 March | Arrivals | Air | 1690.2 |
| 1166 | Air and Sea Travel | 2026 March | Arrivals | Sea | 58.2 |
| 1167 | Air and Sea Travel | 2026 March | Departures | All modes of transport | 1758.9 |
| 1168 | Air and Sea Travel | 2026 March | Departures | Air | 1706.5 |
| 1169 | Air and Sea Travel | 2026 March | Departures | Sea | 52.4 |
1170 rows × 5 columns
1.3 Descriptive and Univariate Analysis¶
print(f"Total Variables : {df.shape[1]}")
print(f"Total Observations: {df.shape[0]}")
Total Variables : 5 Total Observations: 1170
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1170 entries, 0 to 1169 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 STATISTIC 1170 non-null object 1 Month 1170 non-null object 2 Direction 1170 non-null object 3 Mode 1170 non-null object 4 value 1170 non-null float64 dtypes: float64(1), object(4) memory usage: 45.8+ KB
df.describe()
| value | |
|---|---|
| count | 1170.000000 |
| mean | 872.161026 |
| std | 695.686830 |
| min | 4.000000 |
| 25% | 109.600000 |
| 50% | 942.100000 |
| 75% | 1425.000000 |
| max | 2513.900000 |
df.describe(include=object)
| STATISTIC | Month | Direction | Mode | |
|---|---|---|---|---|
| count | 1170 | 1170 | 1170 | 1170 |
| unique | 1 | 195 | 2 | 3 |
| top | Air and Sea Travel | 2010 January | Arrivals | All modes of transport |
| freq | 1170 | 6 | 585 | 390 |
df.duplicated().sum()
0
df.isnull().sum()
STATISTIC 0 Month 0 Direction 0 Mode 0 value 0 dtype: int64
df.columns
Index(['STATISTIC', 'Month', 'Direction', 'Mode', 'value'], dtype='object')
# Initial chart to inspect the overall passenger volume across all modes and directions
plt.figure(figsize=(20, 6))
sns.lineplot(x=df['Month'], y=df['value'], color='steelblue')
plt.xlabel('Date')
plt.ylabel('Passengers (thousands)')
plt.title('Total Passenger Movements - Ireland (Air + Sea, 2010-2024)')
plt.xticks(rotation=45)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Identify the temporal range of the dataset
first = pd.to_datetime(df['Month']).dt.date.min()
last = pd.to_datetime(df['Month']).dt.date.max()
print(f"First observation : {first}")
print(f"Last observation : {last}")
First observation : 2010-01-01 Last observation : 2026-03-01
1.4 Data Preparation¶
# The STATISTIC column contains a single constant value and adds no analytical value
df = df.drop(columns=['STATISTIC'])
df.head()
| Month | Direction | Mode | value | |
|---|---|---|---|---|
| 0 | 2010 January | Arrivals | All modes of transport | 773.2 |
| 1 | 2010 January | Arrivals | Air | 728.4 |
| 2 | 2010 January | Arrivals | Sea | 44.8 |
| 3 | 2010 January | Departures | All modes of transport | 791.7 |
| 4 | 2010 January | Departures | Air | 732.4 |
# Filter for air transport only - the focus of this project is Dublin Airport
df_air = df[df["Mode"] == "Air"].copy()
# Convert the Month string to a proper datetime and extract year and month components
df_air['Date'] = pd.to_datetime(df_air['Month'])
df_air['Year'] = pd.DatetimeIndex(df_air['Date']).year
df_air['Month_num'] = pd.DatetimeIndex(df_air['Date']).month
df_air.head()
| Month | Direction | Mode | value | Date | Year | Month_num | |
|---|---|---|---|---|---|---|---|
| 1 | 2010 January | Arrivals | Air | 728.4 | 2010-01-01 | 2010 | 1 |
| 4 | 2010 January | Departures | Air | 732.4 | 2010-01-01 | 2010 | 1 |
| 7 | 2010 February | Arrivals | Air | 757.2 | 2010-02-01 | 2010 | 2 |
| 10 | 2010 February | Departures | Air | 757.2 | 2010-02-01 | 2010 | 2 |
| 13 | 2010 March | Arrivals | Air | 906.6 | 2010-03-01 | 2010 | 3 |
# Reorganise columns and derive the Dublin Airport volume
# Dublin Airport handles approximately 83% of all Irish air passengers (CSO, 2025)
df_air = df_air[['Direction', 'Date', 'Year', 'Month_num', 'value']]
df_air['Dublin_airport'] = df_air['value'] * 0.83
df_air = df_air.sort_values('Date').reset_index(drop=True)
df_air.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 390 entries, 0 to 389 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Direction 390 non-null object 1 Date 390 non-null datetime64[ns] 2 Year 390 non-null int32 3 Month_num 390 non-null int32 4 value 390 non-null float64 5 Dublin_airport 390 non-null float64 dtypes: datetime64[ns](1), float64(2), int32(2), object(1) memory usage: 15.4+ KB
# Visualise annual passenger volume to observe trend and the COVID disruption
plt.figure(figsize=(18, 5))
plt.bar(df_air['Year'], df_air['Dublin_airport'], color='steelblue', alpha=0.8)
plt.grid(True, axis='y', linestyle='--', alpha=0.4)
plt.xlabel('Year')
plt.ylabel('Passengers (thousands)')
plt.title('Total Passengers per Year - Dublin Airport')
plt.tight_layout()
plt.show()
# Calculate month-over-month growth rate per direction
df_air['Growth_rate'] = df_air.groupby('Direction')['Dublin_airport'].pct_change()
# Separate arrivals and departures for independent analysis
df_arr = df_air[df_air["Direction"] == "Arrivals"].drop(columns=["Direction"]).reset_index(drop=True)
df_dep = df_air[df_air["Direction"] == "Departures"].drop(columns=["Direction"]).reset_index(drop=True)
print(f"Arrivals shape: {df_arr.shape}")
print(f"Departures shape: {df_dep.shape}")
Arrivals shape: (195, 6) Departures shape: (195, 6)
display(df_arr.style.format({'Growth_rate': '{:.1%}'}))
| Date | Year | Month_num | value | Dublin_airport | Growth_rate | |
|---|---|---|---|---|---|---|
| 0 | 2010-01-01 00:00:00 | 2010 | 1 | 728.400000 | 604.572000 | nan% |
| 1 | 2010-02-01 00:00:00 | 2010 | 2 | 757.200000 | 628.476000 | 4.0% |
| 2 | 2010-03-01 00:00:00 | 2010 | 3 | 906.600000 | 752.478000 | 19.7% |
| 3 | 2010-04-01 00:00:00 | 2010 | 4 | 735.800000 | 610.714000 | -18.8% |
| 4 | 2010-05-01 00:00:00 | 2010 | 5 | 968.500000 | 803.855000 | 31.6% |
| 5 | 2010-06-01 00:00:00 | 2010 | 6 | 1087.800000 | 902.874000 | 12.3% |
| 6 | 2010-07-01 00:00:00 | 2010 | 7 | 1286.800000 | 1068.044000 | 18.3% |
| 7 | 2010-08-01 00:00:00 | 2010 | 8 | 1300.400000 | 1079.332000 | 1.1% |
| 8 | 2010-09-01 00:00:00 | 2010 | 9 | 1091.200000 | 905.696000 | -16.1% |
| 9 | 2010-10-01 00:00:00 | 2010 | 10 | 1059.000000 | 878.970000 | -3.0% |
| 10 | 2010-11-01 00:00:00 | 2010 | 11 | 792.200000 | 657.526000 | -25.2% |
| 11 | 2010-12-01 00:00:00 | 2010 | 12 | 686.700000 | 569.961000 | -13.3% |
| 12 | 2011-01-01 00:00:00 | 2011 | 1 | 715.800000 | 594.114000 | 4.2% |
| 13 | 2011-02-01 00:00:00 | 2011 | 2 | 722.100000 | 599.343000 | 0.9% |
| 14 | 2011-03-01 00:00:00 | 2011 | 3 | 880.000000 | 730.400000 | 21.9% |
| 15 | 2011-04-01 00:00:00 | 2011 | 4 | 963.200000 | 799.456000 | 9.5% |
| 16 | 2011-05-01 00:00:00 | 2011 | 5 | 1080.900000 | 897.147000 | 12.2% |
| 17 | 2011-06-01 00:00:00 | 2011 | 6 | 1114.200000 | 924.786000 | 3.1% |
| 18 | 2011-07-01 00:00:00 | 2011 | 7 | 1302.300000 | 1080.909000 | 16.9% |
| 19 | 2011-08-01 00:00:00 | 2011 | 8 | 1285.400000 | 1066.882000 | -1.3% |
| 20 | 2011-09-01 00:00:00 | 2011 | 9 | 1073.900000 | 891.337000 | -16.5% |
| 21 | 2011-10-01 00:00:00 | 2011 | 10 | 971.200000 | 806.096000 | -9.6% |
| 22 | 2011-11-01 00:00:00 | 2011 | 11 | 785.800000 | 652.214000 | -19.1% |
| 23 | 2011-12-01 00:00:00 | 2011 | 12 | 752.600000 | 624.658000 | -4.2% |
| 24 | 2012-01-01 00:00:00 | 2012 | 1 | 683.300000 | 567.139000 | -9.2% |
| 25 | 2012-02-01 00:00:00 | 2012 | 2 | 714.100000 | 592.703000 | 4.5% |
| 26 | 2012-03-01 00:00:00 | 2012 | 3 | 867.600000 | 720.108000 | 21.5% |
| 27 | 2012-04-01 00:00:00 | 2012 | 4 | 996.000000 | 826.680000 | 14.8% |
| 28 | 2012-05-01 00:00:00 | 2012 | 5 | 1039.900000 | 863.117000 | 4.4% |
| 29 | 2012-06-01 00:00:00 | 2012 | 6 | 1149.100000 | 953.753000 | 10.5% |
| 30 | 2012-07-01 00:00:00 | 2012 | 7 | 1283.800000 | 1065.554000 | 11.7% |
| 31 | 2012-08-01 00:00:00 | 2012 | 8 | 1309.300000 | 1086.719000 | 2.0% |
| 32 | 2012-09-01 00:00:00 | 2012 | 9 | 1120.500000 | 930.015000 | -14.4% |
| 33 | 2012-10-01 00:00:00 | 2012 | 10 | 1035.100000 | 859.133000 | -7.6% |
| 34 | 2012-11-01 00:00:00 | 2012 | 11 | 812.400000 | 674.292000 | -21.5% |
| 35 | 2012-12-01 00:00:00 | 2012 | 12 | 795.500000 | 660.265000 | -2.1% |
| 36 | 2013-01-01 00:00:00 | 2013 | 1 | 684.400000 | 568.052000 | -14.0% |
| 37 | 2013-02-01 00:00:00 | 2013 | 2 | 704.600000 | 584.818000 | 3.0% |
| 38 | 2013-03-01 00:00:00 | 2013 | 3 | 923.200000 | 766.256000 | 31.0% |
| 39 | 2013-04-01 00:00:00 | 2013 | 4 | 996.500000 | 827.095000 | 7.9% |
| 40 | 2013-05-01 00:00:00 | 2013 | 5 | 1112.900000 | 923.707000 | 11.7% |
| 41 | 2013-06-01 00:00:00 | 2013 | 6 | 1194.400000 | 991.352000 | 7.3% |
| 42 | 2013-07-01 00:00:00 | 2013 | 7 | 1374.900000 | 1141.167000 | 15.1% |
| 43 | 2013-08-01 00:00:00 | 2013 | 8 | 1366.900000 | 1134.527000 | -0.6% |
| 44 | 2013-09-01 00:00:00 | 2013 | 9 | 1156.500000 | 959.895000 | -15.4% |
| 45 | 2013-10-01 00:00:00 | 2013 | 10 | 1082.600000 | 898.558000 | -6.4% |
| 46 | 2013-11-01 00:00:00 | 2013 | 11 | 844.900000 | 701.267000 | -22.0% |
| 47 | 2013-12-01 00:00:00 | 2013 | 12 | 846.000000 | 702.180000 | 0.1% |
| 48 | 2014-01-01 00:00:00 | 2014 | 1 | 738.500000 | 612.955000 | -12.7% |
| 49 | 2014-02-01 00:00:00 | 2014 | 2 | 749.500000 | 622.085000 | 1.5% |
| 50 | 2014-03-01 00:00:00 | 2014 | 3 | 905.400000 | 751.482000 | 20.8% |
| 51 | 2014-04-01 00:00:00 | 2014 | 4 | 1105.800000 | 917.814000 | 22.1% |
| 52 | 2014-05-01 00:00:00 | 2014 | 5 | 1188.200000 | 986.206000 | 7.5% |
| 53 | 2014-06-01 00:00:00 | 2014 | 6 | 1268.600000 | 1052.938000 | 6.8% |
| 54 | 2014-07-01 00:00:00 | 2014 | 7 | 1451.000000 | 1204.330000 | 14.4% |
| 55 | 2014-08-01 00:00:00 | 2014 | 8 | 1457.600000 | 1209.808000 | 0.5% |
| 56 | 2014-09-01 00:00:00 | 2014 | 9 | 1229.300000 | 1020.319000 | -15.7% |
| 57 | 2014-10-01 00:00:00 | 2014 | 10 | 1185.700000 | 984.131000 | -3.5% |
| 58 | 2014-11-01 00:00:00 | 2014 | 11 | 934.400000 | 775.552000 | -21.2% |
| 59 | 2014-12-01 00:00:00 | 2014 | 12 | 924.800000 | 767.584000 | -1.0% |
| 60 | 2015-01-01 00:00:00 | 2015 | 1 | 833.600000 | 691.888000 | -9.9% |
| 61 | 2015-02-01 00:00:00 | 2015 | 2 | 863.500000 | 716.705000 | 3.6% |
| 62 | 2015-03-01 00:00:00 | 2015 | 3 | 1048.500000 | 870.255000 | 21.4% |
| 63 | 2015-04-01 00:00:00 | 2015 | 4 | 1208.900000 | 1003.387000 | 15.3% |
| 64 | 2015-05-01 00:00:00 | 2015 | 5 | 1308.500000 | 1086.055000 | 8.2% |
| 65 | 2015-06-01 00:00:00 | 2015 | 6 | 1450.300000 | 1203.749000 | 10.8% |
| 66 | 2015-07-01 00:00:00 | 2015 | 7 | 1637.800000 | 1359.374000 | 12.9% |
| 67 | 2015-08-01 00:00:00 | 2015 | 8 | 1597.900000 | 1326.257000 | -2.4% |
| 68 | 2015-09-01 00:00:00 | 2015 | 9 | 1368.500000 | 1135.855000 | -14.4% |
| 69 | 2015-10-01 00:00:00 | 2015 | 10 | 1346.300000 | 1117.429000 | -1.6% |
| 70 | 2015-11-01 00:00:00 | 2015 | 11 | 1060.700000 | 880.381000 | -21.2% |
| 71 | 2015-12-01 00:00:00 | 2015 | 12 | 1020.200000 | 846.766000 | -3.8% |
| 72 | 2016-01-01 00:00:00 | 2016 | 1 | 963.800000 | 799.954000 | -5.5% |
| 73 | 2016-02-01 00:00:00 | 2016 | 2 | 992.000000 | 823.360000 | 2.9% |
| 74 | 2016-03-01 00:00:00 | 2016 | 3 | 1222.800000 | 1014.924000 | 23.3% |
| 75 | 2016-04-01 00:00:00 | 2016 | 4 | 1300.100000 | 1079.083000 | 6.3% |
| 76 | 2016-05-01 00:00:00 | 2016 | 5 | 1453.300000 | 1206.239000 | 11.8% |
| 77 | 2016-06-01 00:00:00 | 2016 | 6 | 1565.100000 | 1299.033000 | 7.7% |
| 78 | 2016-07-01 00:00:00 | 2016 | 7 | 1764.000000 | 1464.120000 | 12.7% |
| 79 | 2016-08-01 00:00:00 | 2016 | 8 | 1741.500000 | 1445.445000 | -1.3% |
| 80 | 2016-09-01 00:00:00 | 2016 | 9 | 1502.300000 | 1246.909000 | -13.7% |
| 81 | 2016-10-01 00:00:00 | 2016 | 10 | 1434.000000 | 1190.220000 | -4.5% |
| 82 | 2016-11-01 00:00:00 | 2016 | 11 | 1163.100000 | 965.373000 | -18.9% |
| 83 | 2016-12-01 00:00:00 | 2016 | 12 | 1130.400000 | 938.232000 | -2.8% |
| 84 | 2017-01-01 00:00:00 | 2017 | 1 | 1050.900000 | 872.247000 | -7.0% |
| 85 | 2017-02-01 00:00:00 | 2017 | 2 | 1023.100000 | 849.173000 | -2.6% |
| 86 | 2017-03-01 00:00:00 | 2017 | 3 | 1253.300000 | 1040.239000 | 22.5% |
| 87 | 2017-04-01 00:00:00 | 2017 | 4 | 1430.500000 | 1187.315000 | 14.1% |
| 88 | 2017-05-01 00:00:00 | 2017 | 5 | 1500.600000 | 1245.498000 | 4.9% |
| 89 | 2017-06-01 00:00:00 | 2017 | 6 | 1646.300000 | 1366.429000 | 9.7% |
| 90 | 2017-07-01 00:00:00 | 2017 | 7 | 1845.200000 | 1531.516000 | 12.1% |
| 91 | 2017-08-01 00:00:00 | 2017 | 8 | 1831.900000 | 1520.477000 | -0.7% |
| 92 | 2017-09-01 00:00:00 | 2017 | 9 | 1587.000000 | 1317.210000 | -13.4% |
| 93 | 2017-10-01 00:00:00 | 2017 | 10 | 1482.800000 | 1230.724000 | -6.6% |
| 94 | 2017-11-01 00:00:00 | 2017 | 11 | 1234.200000 | 1024.386000 | -16.8% |
| 95 | 2017-12-01 00:00:00 | 2017 | 12 | 1190.300000 | 987.949000 | -3.6% |
| 96 | 2018-01-01 00:00:00 | 2018 | 1 | 1110.500000 | 921.715000 | -6.7% |
| 97 | 2018-02-01 00:00:00 | 2018 | 2 | 1057.900000 | 878.057000 | -4.7% |
| 98 | 2018-03-01 00:00:00 | 2018 | 3 | 1262.400000 | 1047.792000 | 19.3% |
| 99 | 2018-04-01 00:00:00 | 2018 | 4 | 1531.300000 | 1270.979000 | 21.3% |
| 100 | 2018-05-01 00:00:00 | 2018 | 5 | 1632.200000 | 1354.726000 | 6.6% |
| 101 | 2018-06-01 00:00:00 | 2018 | 6 | 1762.500000 | 1462.875000 | 8.0% |
| 102 | 2018-07-01 00:00:00 | 2018 | 7 | 1953.000000 | 1620.990000 | 10.8% |
| 103 | 2018-08-01 00:00:00 | 2018 | 8 | 1927.300000 | 1599.659000 | -1.3% |
| 104 | 2018-09-01 00:00:00 | 2018 | 9 | 1696.500000 | 1408.095000 | -12.0% |
| 105 | 2018-10-01 00:00:00 | 2018 | 10 | 1598.500000 | 1326.755000 | -5.8% |
| 106 | 2018-11-01 00:00:00 | 2018 | 11 | 1305.300000 | 1083.399000 | -18.3% |
| 107 | 2018-12-01 00:00:00 | 2018 | 12 | 1264.800000 | 1049.784000 | -3.1% |
| 108 | 2019-01-01 00:00:00 | 2019 | 1 | 1174.600000 | 974.918000 | -7.1% |
| 109 | 2019-02-01 00:00:00 | 2019 | 2 | 1142.400000 | 948.192000 | -2.7% |
| 110 | 2019-03-01 00:00:00 | 2019 | 3 | 1400.500000 | 1162.415000 | 22.6% |
| 111 | 2019-04-01 00:00:00 | 2019 | 4 | 1596.900000 | 1325.427000 | 14.0% |
| 112 | 2019-05-01 00:00:00 | 2019 | 5 | 1712.000000 | 1420.960000 | 7.2% |
| 113 | 2019-06-01 00:00:00 | 2019 | 6 | 1823.700000 | 1513.671000 | 6.5% |
| 114 | 2019-07-01 00:00:00 | 2019 | 7 | 2017.700000 | 1674.691000 | 10.6% |
| 115 | 2019-08-01 00:00:00 | 2019 | 8 | 2033.600000 | 1687.888000 | 0.8% |
| 116 | 2019-09-01 00:00:00 | 2019 | 9 | 1764.400000 | 1464.452000 | -13.2% |
| 117 | 2019-10-01 00:00:00 | 2019 | 10 | 1646.300000 | 1366.429000 | -6.7% |
| 118 | 2019-11-01 00:00:00 | 2019 | 11 | 1305.100000 | 1083.233000 | -20.7% |
| 119 | 2019-12-01 00:00:00 | 2019 | 12 | 1290.400000 | 1071.032000 | -1.1% |
| 120 | 2020-01-01 00:00:00 | 2020 | 1 | 1195.200000 | 992.016000 | -7.4% |
| 121 | 2020-02-01 00:00:00 | 2020 | 2 | 1171.700000 | 972.511000 | -2.0% |
| 122 | 2020-03-01 00:00:00 | 2020 | 3 | 603.600000 | 500.988000 | -48.5% |
| 123 | 2020-04-01 00:00:00 | 2020 | 4 | 12.100000 | 10.043000 | -98.0% |
| 124 | 2020-05-01 00:00:00 | 2020 | 5 | 19.900000 | 16.517000 | 64.5% |
| 125 | 2020-06-01 00:00:00 | 2020 | 6 | 40.200000 | 33.366000 | 102.0% |
| 126 | 2020-07-01 00:00:00 | 2020 | 7 | 188.100000 | 156.123000 | 367.9% |
| 127 | 2020-08-01 00:00:00 | 2020 | 8 | 311.600000 | 258.628000 | 65.7% |
| 128 | 2020-09-01 00:00:00 | 2020 | 9 | 224.000000 | 185.920000 | -28.1% |
| 129 | 2020-10-01 00:00:00 | 2020 | 10 | 142.500000 | 118.275000 | -36.4% |
| 130 | 2020-11-01 00:00:00 | 2020 | 11 | 90.900000 | 75.447000 | -36.2% |
| 131 | 2020-12-01 00:00:00 | 2020 | 12 | 135.900000 | 112.797000 | 49.5% |
| 132 | 2021-01-01 00:00:00 | 2021 | 1 | 101.100000 | 83.913000 | -25.6% |
| 133 | 2021-02-01 00:00:00 | 2021 | 2 | 50.500000 | 41.915000 | -50.0% |
| 134 | 2021-03-01 00:00:00 | 2021 | 3 | 55.100000 | 45.733000 | 9.1% |
| 135 | 2021-04-01 00:00:00 | 2021 | 4 | 61.400000 | 50.962000 | 11.4% |
| 136 | 2021-05-01 00:00:00 | 2021 | 5 | 73.200000 | 60.756000 | 19.2% |
| 137 | 2021-06-01 00:00:00 | 2021 | 6 | 136.100000 | 112.963000 | 85.9% |
| 138 | 2021-07-01 00:00:00 | 2021 | 7 | 326.600000 | 271.078000 | 140.0% |
| 139 | 2021-08-01 00:00:00 | 2021 | 8 | 720.400000 | 597.932000 | 120.6% |
| 140 | 2021-09-01 00:00:00 | 2021 | 9 | 743.000000 | 616.690000 | 3.1% |
| 141 | 2021-10-01 00:00:00 | 2021 | 10 | 853.200000 | 708.156000 | 14.8% |
| 142 | 2021-11-01 00:00:00 | 2021 | 11 | 754.500000 | 626.235000 | -11.6% |
| 143 | 2021-12-01 00:00:00 | 2021 | 12 | 641.500000 | 532.445000 | -15.0% |
| 144 | 2022-01-01 00:00:00 | 2022 | 1 | 563.400000 | 467.622000 | -12.2% |
| 145 | 2022-02-01 00:00:00 | 2022 | 2 | 754.600000 | 626.318000 | 33.9% |
| 146 | 2022-03-01 00:00:00 | 2022 | 3 | 1056.800000 | 877.144000 | 40.0% |
| 147 | 2022-04-01 00:00:00 | 2022 | 4 | 1377.100000 | 1142.993000 | 30.3% |
| 148 | 2022-05-01 00:00:00 | 2022 | 5 | 1501.900000 | 1246.577000 | 9.1% |
| 149 | 2022-06-01 00:00:00 | 2022 | 6 | 1605.700000 | 1332.731000 | 6.9% |
| 150 | 2022-07-01 00:00:00 | 2022 | 7 | 1783.600000 | 1480.388000 | 11.1% |
| 151 | 2022-08-01 00:00:00 | 2022 | 8 | 1836.400000 | 1524.212000 | 3.0% |
| 152 | 2022-09-01 00:00:00 | 2022 | 9 | 1626.800000 | 1350.244000 | -11.4% |
| 153 | 2022-10-01 00:00:00 | 2022 | 10 | 1557.200000 | 1292.476000 | -4.3% |
| 154 | 2022-11-01 00:00:00 | 2022 | 11 | 1292.800000 | 1073.024000 | -17.0% |
| 155 | 2022-12-01 00:00:00 | 2022 | 12 | 1237.000000 | 1026.710000 | -4.3% |
| 156 | 2023-01-01 00:00:00 | 2023 | 1 | 1216.600000 | 1009.778000 | -1.6% |
| 157 | 2023-02-01 00:00:00 | 2023 | 2 | 1184.200000 | 982.886000 | -2.7% |
| 158 | 2023-03-01 00:00:00 | 2023 | 3 | 1414.000000 | 1173.620000 | 19.4% |
| 159 | 2023-04-01 00:00:00 | 2023 | 4 | 1676.600000 | 1391.578000 | 18.6% |
| 160 | 2023-05-01 00:00:00 | 2023 | 5 | 1770.500000 | 1469.515000 | 5.6% |
| 161 | 2023-06-01 00:00:00 | 2023 | 6 | 1846.200000 | 1532.346000 | 4.3% |
| 162 | 2023-07-01 00:00:00 | 2023 | 7 | 2030.800000 | 1685.564000 | 10.0% |
| 163 | 2023-08-01 00:00:00 | 2023 | 8 | 2070.200000 | 1718.266000 | 1.9% |
| 164 | 2023-09-01 00:00:00 | 2023 | 9 | 1814.800000 | 1506.284000 | -12.3% |
| 165 | 2023-10-01 00:00:00 | 2023 | 10 | 1742.100000 | 1445.943000 | -4.0% |
| 166 | 2023-11-01 00:00:00 | 2023 | 11 | 1352.800000 | 1122.824000 | -22.3% |
| 167 | 2023-12-01 00:00:00 | 2023 | 12 | 1362.300000 | 1130.709000 | 0.7% |
| 168 | 2024-01-01 00:00:00 | 2024 | 1 | 1271.300000 | 1055.179000 | -6.7% |
| 169 | 2024-02-01 00:00:00 | 2024 | 2 | 1268.400000 | 1052.772000 | -0.2% |
| 170 | 2024-03-01 00:00:00 | 2024 | 3 | 1550.200000 | 1286.666000 | 22.2% |
| 171 | 2024-04-01 00:00:00 | 2024 | 4 | 1712.200000 | 1421.126000 | 10.5% |
| 172 | 2024-05-01 00:00:00 | 2024 | 5 | 1889.000000 | 1567.870000 | 10.3% |
| 173 | 2024-06-01 00:00:00 | 2024 | 6 | 1938.600000 | 1609.038000 | 2.6% |
| 174 | 2024-07-01 00:00:00 | 2024 | 7 | 2078.500000 | 1725.155000 | 7.2% |
| 175 | 2024-08-01 00:00:00 | 2024 | 8 | 2205.500000 | 1830.565000 | 6.1% |
| 176 | 2024-09-01 00:00:00 | 2024 | 9 | 1905.900000 | 1581.897000 | -13.6% |
| 177 | 2024-10-01 00:00:00 | 2024 | 10 | 1764.200000 | 1464.286000 | -7.4% |
| 178 | 2024-11-01 00:00:00 | 2024 | 11 | 1387.700000 | 1151.791000 | -21.3% |
| 179 | 2024-12-01 00:00:00 | 2024 | 12 | 1371.600000 | 1138.428000 | -1.2% |
| 180 | 2025-01-01 00:00:00 | 2025 | 1 | 1283.100000 | 1064.973000 | -6.5% |
| 181 | 2025-02-01 00:00:00 | 2025 | 2 | 1276.600000 | 1059.578000 | -0.5% |
| 182 | 2025-03-01 00:00:00 | 2025 | 3 | 1550.600000 | 1286.998000 | 21.5% |
| 183 | 2025-04-01 00:00:00 | 2025 | 4 | 1830.200000 | 1519.066000 | 18.0% |
| 184 | 2025-05-01 00:00:00 | 2025 | 5 | 1996.500000 | 1657.095000 | 9.1% |
| 185 | 2025-06-01 00:00:00 | 2025 | 6 | 2072.900000 | 1720.507000 | 3.8% |
| 186 | 2025-07-01 00:00:00 | 2025 | 7 | 2261.800000 | 1877.294000 | 9.1% |
| 187 | 2025-08-01 00:00:00 | 2025 | 8 | 2318.200000 | 1924.106000 | 2.5% |
| 188 | 2025-09-01 00:00:00 | 2025 | 9 | 2001.400000 | 1661.162000 | -13.7% |
| 189 | 2025-10-01 00:00:00 | 2025 | 10 | 1920.400000 | 1593.932000 | -4.0% |
| 190 | 2025-11-01 00:00:00 | 2025 | 11 | 1541.300000 | 1279.279000 | -19.7% |
| 191 | 2025-12-01 00:00:00 | 2025 | 12 | 1538.000000 | 1276.540000 | -0.2% |
| 192 | 2026-01-01 00:00:00 | 2026 | 1 | 1442.300000 | 1197.109000 | -6.2% |
| 193 | 2026-02-01 00:00:00 | 2026 | 2 | 1388.600000 | 1152.538000 | -3.7% |
| 194 | 2026-03-01 00:00:00 | 2026 | 3 | 1690.200000 | 1402.866000 | 21.7% |
1.5 Feature Understanding and Historical Charts¶
# Arrivals time series - the primary series used for modelling
plt.figure(figsize=(20, 6))
sns.lineplot(x=df_arr['Date'], y=df_arr['Dublin_airport'], color='crimson', linestyle='--')
plt.xlabel('Date')
plt.ylabel('Passengers (thousands)')
plt.title('Monthly Arrivals - Dublin Airport (2010-2024)')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Departures time series for comparison
plt.figure(figsize=(20, 6))
sns.lineplot(x=df_dep['Date'], y=df_dep['Dublin_airport'], color='steelblue')
plt.xlabel('Date')
plt.ylabel('Passengers (thousands)')
plt.title('Monthly Departures - Dublin Airport (2010-2024)')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Overlay arrivals and departures to observe relative magnitude and the COVID impact
plt.figure(figsize=(20, 6))
sns.lineplot(x=df_arr['Date'], y=df_arr['Dublin_airport'],
color='crimson', linestyle='--', label='Arrivals')
sns.lineplot(x=df_dep['Date'], y=df_dep['Dublin_airport'],
color='steelblue', label='Departures')
plt.axvspan(pd.Timestamp('2020-01-01'), pd.Timestamp('2022-01-31'),
alpha=0.12, color='grey', label='COVID-19 Period (excluded from models)')
plt.xlabel('Date')
plt.ylabel('Passengers (thousands)')
plt.title('Arrivals vs Departures - Dublin Airport (2010-2024)')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Average monthly passengers post-COVID (2022-2024)
# This confirms the strong and consistent seasonal pattern driven by summer travel
df_post = df_arr[df_arr['Year'] >= 2022].copy()
monthly_avg = df_post.groupby('Month_num')['Dublin_airport'].mean()
months_lbl = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
plt.figure(figsize=(12, 5))
plt.bar(monthly_avg.index, monthly_avg.values, color='steelblue', alpha=0.85)
plt.xticks(range(1, 13), months_lbl)
plt.ylabel('Average Passengers (thousands)')
plt.title('Average Monthly Passengers - Post-COVID Recovery (2022-2024)')
plt.grid(True, alpha=0.3, axis='y')
plt.tight_layout()
plt.show()
1.6 Seasonal Decomposition¶
The time series is decomposed into three components: Trend, Seasonality, and Residuals. A logarithmic transformation is applied before decomposition to stabilise the variance, which grows alongside the level of the series.
# Build the full time-indexed series for decomposition
ts_full = df_arr.set_index('Date')['Dublin_airport'].copy()
ts_full = ts_full.asfreq('MS')
# Log transformation stabilises variance; additive decomposition on log scale
log_ts = np.log(ts_full)
log_ts.interpolate(inplace=True)
decomposition = seasonal_decompose(log_ts, model='additive')
fig = decomposition.plot()
fig.set_size_inches(16, 10)
plt.suptitle('Seasonal Decomposition of Log(Arrivals) - Dublin Airport', y=1.01)
plt.tight_layout()
plt.show()
1.7 Outliers Analysis¶
The COVID-19 pandemic generated extreme low values between January 2020 and January 2022. These observations do not reflect the structural behaviour of the series and would significantly bias any forecasting model. For this reason, this period is excluded from all modelling datasets. The full series is preserved only for contextual visualisation.
fig, ax = plt.subplots(figsize=(7, 5))
sns.boxplot(y=df_air['Dublin_airport'], ax=ax, color='steelblue')
ax.set_title('Boxplot - Dublin Airport Monthly Passengers (All Years)')
ax.set_ylabel('Passengers (thousands)')
plt.tight_layout()
plt.show()
# Scatter plot to visually isolate the COVID anomaly period
plt.figure(figsize=(18, 5))
sns.scatterplot(x='Date', y='Dublin_airport', data=df_arr, hue='Year', legend=False)
plt.axvspan(pd.Timestamp('2020-01-01'), pd.Timestamp('2022-01-31'),
alpha=0.12, color='red', label='COVID Outlier Period')
plt.legend()
plt.title('Monthly Arrivals Scatter - COVID Period Highlighted')
plt.xlabel('Date')
plt.ylabel('Passengers (thousands)')
plt.tight_layout()
plt.show()
# Monthly distribution post-recovery to confirm consistent seasonal structure
df_post2 = df_air[df_air['Year'] >= 2022].copy()
df_post2['Month_name'] = pd.Categorical(
df_post2['Date'].dt.strftime('%b'),
categories=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
ordered=True)
plt.figure(figsize=(14, 5))
sns.boxplot(data=df_post2, x='Month_name', y='Dublin_airport', palette='Blues')
plt.title('Passenger Distribution by Month - Post-COVID (2022-2024)')
plt.xlabel('Month')
plt.ylabel('Passengers (thousands)')
plt.grid(True, alpha=0.3, axis='y')
plt.tight_layout()
plt.show()
1.8 Relational Analysis¶
# Correlation between year, month number and passenger volume
df_corr = df_arr[['Year', 'Month_num', 'Dublin_airport']].corr()
plt.figure(figsize=(7, 5))
sns.heatmap(df_corr, annot=True, fmt='.2f', cmap='coolwarm', linewidths=0.5)
plt.title('Correlation Matrix - Arrivals DataFrame')
plt.tight_layout()
plt.show()
2. Stationarity Testing - Augmented Dickey-Fuller (ADF)¶
ARIMA and SARIMA require a stationary input series. The Augmented Dickey-Fuller (ADF) test determines whether differencing is needed. The null hypothesis states the series contains a unit root (non-stationary); a p-value below 0.05 allows us to reject it.
Note on data scope: The COVID period (January 2020 to January 2022) is excluded from all modelling. This decision is motivated by the extreme distortion caused by travel restrictions - a structural break that does not represent the long-term passenger dynamics of Dublin Airport.
# Build the clean modelling series: Arrivals with COVID period removed
ts_full = df_arr.set_index('Date')['Dublin_airport'].copy()
ts_full = ts_full.asfreq('MS')
# Exclude January 2020 to January 2022 - anomalous COVID disruption
ts = ts_full[(ts_full.index < '2020-01-01') | (ts_full.index > '2022-02-01')].copy()
print(f"Modelling series: {len(ts)} monthly observations")
print(f" Pre-COVID : {ts.index[0].date()} to 2019-12-01")
print(f" Post-COVID: 2022-02-01 to {ts.index[-1].date()}")
print(f" Removed : {len(ts_full) - len(ts)} months (COVID disruption)")
Modelling series: 169 monthly observations Pre-COVID : 2010-01-01 to 2019-12-01 Post-COVID: 2022-02-01 to 2026-03-01 Removed : 26 months (COVID disruption)
# ADF test on the raw clean series
ADF_result = adfuller(ts)
print(f"ADF Statistic : {ADF_result[0]:.4f}")
print(f"p-value : {ADF_result[1]:.4f}")
print(f"Critical Values: {ADF_result[4]}")
if ADF_result[1] < 0.05:
print("\n Stationary series (p < 0.05)")
else:
print("\n Non-stationary series - first-order differencing required")
ADF Statistic : 0.1719
p-value : 0.9706
Critical Values: {'1%': -3.4729792729247793, '5%': -2.8802520918255534, '10%': -2.5767469641683105}
Non-stationary series - first-order differencing required
# Apply first-order differencing and re-test
ts_diff = ts.diff(1).dropna()
ADF_diff = adfuller(ts_diff)
print(f"ADF Statistic (1st diff): {ADF_diff[0]:.4f}")
print(f"p-value (1st diff) : {ADF_diff[1]:.4f}")
if ADF_diff[1] < 0.05:
print("\n Differenced series is STATIONARY - d=1 is appropriate")
ADF Statistic (1st diff): -4.9172 p-value (1st diff) : 0.0000 Differenced series is STATIONARY - d=1 is appropriate
# Plot raw vs differenced series
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(16, 8), sharex=False)
ax1.plot(ts, color='steelblue')
ax1.set_title('Original Arrivals Series - Dublin Airport (COVID period excluded)')
ax1.set_ylabel('Passengers (thousands)')
ax1.grid(True, alpha=0.3)
ax2.plot(ts_diff, color='crimson')
ax2.set_title('First-Order Differenced Series')
ax2.set_ylabel('Delta Passengers (thousands)')
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# ACF guides the MA(q) order selection; PACF guides the AR(p) order selection
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8))
plot_acf(ts_diff, lags=36, ax=ax1)
ax1.set_title('Autocorrelation Function (ACF) - Differenced Series')
plot_pacf(ts_diff, lags=36, ax=ax2)
ax2.set_title('Partial Autocorrelation Function (PACF) - Differenced Series')
plt.tight_layout()
plt.show()
3. Baseline Models¶
Four simple forecasting baselines are established before applying statistical models. These provide a performance floor - any model that cannot beat them offers no real predictive value. MAPE (Mean Absolute Percentage Error) is the primary evaluation metric here.
# Train / Test split - last 12 months reserved for testing
train = ts[:-12]
test = ts[-12:]
print(f"Train: {len(train)} months ({train.index[0].date()} to {train.index[-1].date()})")
print(f"Test : {len(test)} months ({test.index[0].date()} to {test.index[-1].date()})")
Train: 157 months (2010-01-01 to 2025-03-01) Test : 12 months (2025-04-01 to 2026-03-01)
def mape(y_true, y_pred):
"""Mean Absolute Percentage Error"""
return np.mean(np.abs((np.array(y_true) - np.array(y_pred)) / np.array(y_true))) * 100
# Baseline 1 - Historical mean of the entire training period
hist_mean = np.mean(train)
test_df = test.to_frame(name='Actual')
test_df['pred_mean'] = hist_mean
mape_hist = mape(test_df['Actual'], test_df['pred_mean'])
print(f"Historical Mean = {hist_mean:.1f}k | MAPE = {mape_hist:.2f}%")
Historical Mean = 1079.3k | MAPE = 27.10%
# Baseline 2 - Mean of the last 12 months in the training set
last_yr_mean = np.mean(train[-12:])
test_df['pred_last_yr_mean'] = last_yr_mean
mape_last_yr = mape(test_df['Actual'], test_df['pred_last_yr_mean'])
print(f"Last Year Mean = {last_yr_mean:.1f}k | MAPE = {mape_last_yr:.2f}%")
Last Year Mean = 1408.5k | MAPE = 14.98%
# Baseline 3 - Last known observation repeated for all test periods
last_val = train.iloc[-1]
test_df['pred_last'] = last_val
mape_last = mape(test_df['Actual'], test_df['pred_last'])
print(f"Last Known Value = {last_val:.1f}k | MAPE = {mape_last:.2f}%")
Last Known Value = 1287.0k | MAPE = 16.50%
# Baseline 4 - Naive seasonal: repeat the last full 12-month seasonal cycle
test_df['pred_naive_seasonal'] = train[-12:].values
mape_naive = mape(test_df['Actual'], test_df['pred_naive_seasonal'])
print(f"Naive Seasonal MAPE = {mape_naive:.2f}%")
Naive Seasonal MAPE = 7.69%
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(train.index[-24:], train[-24:], 'g-.', label='Train (last 24 months)')
ax.plot(test_df.index, test_df['Actual'], 'b-', label='Actual')
ax.plot(test_df.index, test_df['pred_mean'], 'r--', label=f'Hist. Mean (MAPE={mape_hist:.1f}%)')
ax.plot(test_df.index, test_df['pred_last_yr_mean'], 'm-.', label=f'Last Yr Mean (MAPE={mape_last_yr:.1f}%)')
ax.plot(test_df.index, test_df['pred_last'], 'y:', label=f'Last Value (MAPE={mape_last:.1f}%)')
ax.plot(test_df.index, test_df['pred_naive_seasonal'], 'k--', label=f'Naive Seasonal (MAPE={mape_naive:.1f}%)')
ax.set_xlabel('Date')
ax.set_ylabel('Passengers (thousands)')
ax.set_title('Baseline Forecasts vs Actual - Dublin Airport Arrivals')
ax.legend(loc='upper left')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
fig, ax = plt.subplots(figsize=(9, 4))
labels = ['Hist. Mean', 'Last Yr Mean', 'Last Value', 'Naive Seasonal']
mapes = [mape_hist, mape_last_yr, mape_last, mape_naive]
bars = ax.bar(labels, mapes, width=0.5, color=['#e74c3c','#e67e22','#f1c40f','#2ecc71'])
for b, v in zip(bars, mapes):
ax.text(b.get_x()+b.get_width()/2, v+0.5, f'{v:.1f}%', ha='center', fontsize=10)
ax.set_ylabel('MAPE (%)')
ax.set_title('Baseline Model Comparison - MAPE')
ax.set_ylim(0, max(mapes)*1.25)
ax.grid(True, alpha=0.3, axis='y')
plt.tight_layout()
plt.show()
print(f"\nBest baseline: Naive Seasonal MAPE = {mape_naive:.2f}%")
Best baseline: Naive Seasonal MAPE = 7.69%
Setup A vs Setup B – Methodology Note¶
To measure the impact of the COVID-19 pandemic on model accuracy, each model is evaluated under two configurations:
- Setup A – Full Series: January 2010 to February 2026, including the pandemic period. Included for reference to demonstrate the impact of the structural break.
- Setup B – COVID Excluded (Primary): Pandemic period removed. Primary setup for business forecasting.
- Holt-Winters exception: Setup B uses only post-COVID data (2022+) because the model's exponential smoothing mechanism is heavily influenced by the recovery gradient. Training on the full clean series (2010-2019 + 2022-2026) causes the model to project an unrealistic upward trend.
All metrics are computed against the same 12-month test set: March 2025 – February 2026.
4. Model 1 - ARIMA¶
ARIMA (Autoregressive Integrated Moving Average) combines three components: p (autoregressive order), d (degree of differencing), and q (moving average order). A grid search over (p,d,q) combinations is performed, selecting the model with the lowest AIC (Akaike Information Criterion).
4.1 Setup A – ARIMA With COVID¶
Reference only. ARIMA trained on the full series including the pandemic structural break.
# Grid search over (p, d, q) to find the combination with the lowest AIC
aicVal = []
for d in range(1, 3):
for p in range(0, 4):
for q in range(0, 4):
try:
model = ARIMA(train, order=(p, d, q))
fitted = model.fit()
aicVal.append([p, d, q, fitted.aic])
except Exception:
pass
aic_df = pd.DataFrame(aicVal, columns=['p','d','q','AIC']).sort_values('AIC').reset_index(drop=True)
print('Top 10 ARIMA models by AIC:');
print(aic_df.head(10).to_string(index=False));
Top 10 ARIMA models by AIC: p d q AIC 2 1 3 1804.901514 3 1 3 1807.080379 3 1 2 1816.398922 2 1 2 1837.634554 3 1 1 1871.016888 1 1 3 1914.353393 1 2 3 1927.351011 0 2 3 1930.480630 3 1 0 1934.736349 3 2 2 1934.784497
best = aic_df.iloc[0]
p_best = int(best['p'])
d_best = int(best['d'])
q_best = int(best['q'])
print(f"Selected model: ARIMA({p_best}, {d_best}, {q_best}) AIC = {best['AIC']:.2f}")
Selected model: ARIMA(2, 1, 3) AIC = 1804.90
arima_model = ARIMA(train, order=(p_best, d_best, q_best)).fit()
print(arima_model.summary())
SARIMAX Results
==============================================================================
Dep. Variable: Dublin_airport No. Observations: 157
Model: ARIMA(2, 1, 3) Log Likelihood -896.451
Date: Mon, 11 May 2026 AIC 1804.902
Time: 13:52:04 BIC 1823.201
Sample: 0 HQIC 1812.334
- 157
Covariance Type: opg
==============================================================================
coef std err z P>|z| [0.025 0.975]
------------------------------------------------------------------------------
ar.L1 1.7171 0.007 237.677 0.000 1.703 1.731
ar.L2 -0.9948 0.007 -134.327 0.000 -1.009 -0.980
ma.L1 -2.1676 0.070 -31.020 0.000 -2.305 -2.031
ma.L2 1.7917 0.127 14.161 0.000 1.544 2.040
ma.L3 -0.5445 0.073 -7.460 0.000 -0.688 -0.401
sigma2 5487.0562 682.822 8.036 0.000 4148.750 6825.363
===================================================================================
Ljung-Box (L1) (Q): 0.09 Jarque-Bera (JB): 0.42
Prob(Q): 0.76 Prob(JB): 0.81
Heteroskedasticity (H): 1.92 Skew: -0.12
Prob(H) (two-sided): 0.02 Kurtosis: 2.89
===================================================================================
Warnings:
[1] Covariance matrix calculated using the outer product of gradients (complex-step).
# Forecast the 12-month test period and compute performance metrics
arima_forecast = arima_model.get_forecast(steps=12)
arima_pred_mean = arima_forecast.predicted_mean
arima_ci = arima_forecast.conf_int(alpha=0.05)
mae_arima = mean_absolute_error(test, arima_pred_mean)
rmse_arima = np.sqrt(mean_squared_error(test, arima_pred_mean))
mape_arima = mape(test, arima_pred_mean)
r2_arima = r2_score(test, arima_pred_mean)
print(f"ARIMA({p_best},{d_best},{q_best}) Performance:")
print(f" MAE = {mae_arima:.1f}k")
print(f" RMSE = {rmse_arima:.1f}k")
print(f" MAPE = {mape_arima:.2f}%")
print(f" R2 = {r2_arima:.4f}")
ARIMA(2,1,3) Performance: MAE = 110.1k RMSE = 130.5k MAPE = 7.22% R2 = 0.7258
# Residual diagnostics - random residuals confirm the model captured the series structure
fig, axes = plt.subplots(1, 2, figsize=(14, 4))
residuals = arima_model.resid
axes[0].plot(residuals, color='steelblue')
axes[0].axhline(0, color='red', linestyle='--')
axes[0].set_title('ARIMA - Residuals Over Time')
axes[0].grid(True, alpha=0.3)
axes[1].hist(residuals, bins=25, color='steelblue', edgecolor='white')
axes[1].set_title('ARIMA - Residual Distribution')
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(train.index[-36:], train[-36:], 'b-', label='Train', lw=1.5)
ax.plot(test.index, test, 'ko-', label='Actual', lw=2, ms=5)
ax.plot(test.index, arima_pred_mean, 'r--', label=f'ARIMA Forecast (MAPE={mape_arima:.1f}%)', lw=2)
ax.fill_between(test.index, arima_ci.iloc[:,0], arima_ci.iloc[:,1],
color='salmon', alpha=0.25, label='95% Confidence Interval')
ax.set_xlabel('Date')
ax.set_ylabel('Passengers (thousands)')
ax.set_title(f'ARIMA({p_best},{d_best},{q_best}) - Forecast vs Actual')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# ── Setup A: ARIMA trained on full series (with COVID) ───────────────────
ts_full_A = df_arr.set_index('Date')['Dublin_airport'].copy().asfreq('MS')
train_A = ts_full_A[:-12]
test_A = ts_full_A[-12:]
arima_A = ARIMA(train_A, order=(p_best, d_best, q_best)).fit()
pred_A = arima_A.get_forecast(steps=12).predicted_mean
ci_A = arima_A.get_forecast(steps=12).conf_int(alpha=0.05)
mae_arima_A = mean_absolute_error(test_A, pred_A)
rmse_arima_A = np.sqrt(mean_squared_error(test_A, pred_A))
mape_arima_A = mape(test_A, pred_A)
r2_arima_A = r2_score(test_A, pred_A)
print("ARIMA Setup A (with COVID):")
print(f" MAE = {mae_arima_A:.1f}k RMSE = {rmse_arima_A:.1f}k MAPE = {mape_arima_A:.2f}% R2 = {r2_arima_A:.4f}")
ARIMA Setup A (with COVID): MAE = 94.7k RMSE = 109.5k MAPE = 6.18% R2 = 0.8071
5. Model 2 - SARIMA¶
SARIMA extends ARIMA by adding seasonal components (P, D, Q) with period m=12 for monthly data.
5.1 Setup A – SARIMA With COVID¶
# ── Setup A: SARIMA trained on full series (with COVID) ─────────────────
sarima_A = SARIMAX(train_A, order=(1,1,1), seasonal_order=(1,1,1,12)).fit(disp=False)
pred_sarima_A = sarima_A.get_forecast(steps=12).predicted_mean
ci_sarima_A = sarima_A.get_forecast(steps=12).conf_int(alpha=0.05)
mae_sarima_A = mean_absolute_error(test_A, pred_sarima_A)
rmse_sarima_A = np.sqrt(mean_squared_error(test_A, pred_sarima_A))
mape_sarima_A = mape(test_A, pred_sarima_A)
r2_sarima_A = r2_score(test_A, pred_sarima_A)
print("SARIMA Setup A (with COVID):")
print(f" MAE = {mae_sarima_A:.1f}k RMSE = {rmse_sarima_A:.1f}k MAPE = {mape_sarima_A:.2f}% R2 = {r2_sarima_A:.4f}")
SARIMA Setup A (with COVID): MAE = 76.2k RMSE = 89.9k MAPE = 4.74% R2 = 0.8698
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(train_A.index[-24:], train_A[-24:], 'b-', label='Train', lw=1.5)
ax.plot(test_A.index, test_A, 'ko-', label='Actual', lw=2, ms=5)
ax.plot(test_A.index, pred_sarima_A, 'm--', label=f'SARIMA Setup A (MAPE={mape_sarima_A:.1f}%)', lw=2)
ax.fill_between(test_A.index, ci_sarima_A.iloc[:,0], ci_sarima_A.iloc[:,1], color='plum', alpha=0.25, label='95% CI')
ax.axvspan(pd.Timestamp('2020-01-01'), pd.Timestamp('2022-02-01'),
alpha=0.08, color='red', label='COVID Period')
ax.set_title('SARIMA(1,1,1)(1,1,1,12) Setup A (with COVID) – Forecast vs Actual')
ax.set_xlabel('Date'); ax.set_ylabel('Passengers (thousands)')
ax.legend(); ax.grid(True, alpha=0.3); plt.tight_layout(); plt.show()
# SARIMA(1,1,1)(1,1,1,12)
# Non-seasonal: AR=1, differencing=1, MA=1
# Seasonal: AR=1, differencing=1, MA=1, period=12 (monthly)
sarima_model = SARIMAX(train,
order=(1, 1, 1),
seasonal_order=(1, 1, 1, 12)).fit(disp=False)
print(sarima_model.summary())
SARIMAX Results
==========================================================================================
Dep. Variable: Dublin_airport No. Observations: 157
Model: SARIMAX(1, 1, 1)x(1, 1, 1, 12) Log Likelihood -810.607
Date: Mon, 11 May 2026 AIC 1631.214
Time: 13:52:05 BIC 1646.063
Sample: 0 HQIC 1637.248
- 157
Covariance Type: opg
==============================================================================
coef std err z P>|z| [0.025 0.975]
------------------------------------------------------------------------------
ar.L1 -0.3888 0.372 -1.044 0.297 -1.119 0.341
ma.L1 0.2344 0.403 0.582 0.560 -0.555 1.023
ar.S.L12 0.7962 0.478 1.665 0.096 -0.141 1.734
ma.S.L12 -0.9025 0.475 -1.902 0.057 -1.833 0.028
sigma2 4485.4529 366.474 12.239 0.000 3767.177 5203.728
===================================================================================
Ljung-Box (L1) (Q): 0.01 Jarque-Bera (JB): 897.63
Prob(Q): 0.94 Prob(JB): 0.00
Heteroskedasticity (H): 4.48 Skew: 1.09
Prob(H) (two-sided): 0.00 Kurtosis: 15.04
===================================================================================
Warnings:
[1] Covariance matrix calculated using the outer product of gradients (complex-step).
# Four-panel diagnostics plot to validate residual assumptions
sarima_model.plot_diagnostics(figsize=(14, 10))
plt.suptitle('SARIMA(1,1,1)(1,1,1,12) - Residual Diagnostics', y=1.01)
plt.tight_layout()
plt.show()
sarima_forecast = sarima_model.get_forecast(steps=12)
sarima_pred_mean = sarima_forecast.predicted_mean
sarima_ci = sarima_forecast.conf_int(alpha=0.05)
mae_sarima = mean_absolute_error(test, sarima_pred_mean)
rmse_sarima = np.sqrt(mean_squared_error(test, sarima_pred_mean))
mape_sarima = mape(test, sarima_pred_mean)
r2_sarima = r2_score(test, sarima_pred_mean)
print('SARIMA(1,1,1)(1,1,1,12) Performance:')
print(f" MAE = {mae_sarima:.1f}k")
print(f" RMSE = {rmse_sarima:.1f}k")
print(f" MAPE = {mape_sarima:.2f}%")
print(f" R2 = {r2_sarima:.4f}")
SARIMA(1,1,1)(1,1,1,12) Performance: MAE = 100.1k RMSE = 103.4k MAPE = 6.63% R2 = 0.8280
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(train.index[-36:], train[-36:], 'b-', label='Train', lw=1.5)
ax.plot(test.index, test, 'ko-', label='Actual', lw=2, ms=5)
ax.plot(test.index, sarima_pred_mean, 'm--', label=f'SARIMA Forecast (MAPE={mape_sarima:.1f}%)', lw=2)
ax.fill_between(test.index, sarima_ci.iloc[:,0], sarima_ci.iloc[:,1],
color='plum', alpha=0.25, label='95% Confidence Interval')
ax.set_xlabel('Date')
ax.set_ylabel('Passengers (thousands)')
ax.set_title('SARIMA(1,1,1)(1,1,1,12) - Forecast vs Actual')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
5.1 Model 2 - SARIMA CROSS-VALIDATION AND FORECASTING¶
from sklearn.model_selection import TimeSeriesSplit
# Time Series Cross-Validation para SARIMA
# Usa expansão da janela de treino (expanding window) – adequado para séries temporais
# O dado mais antigo sempre fica no treino; apenas o horizonte de teste avança
tscv = TimeSeriesSplit(n_splits=5)
cv_maes = []
cv_mapes= []
cv_r2s = []
print("SARIMA – Time Series Cross-Validation (5 folds)\n")
print(f"{'Fold':<6} {'Train size':<12} {'Test size':<11} {'MAE':<10} {'MAPE%':<10} {'R²'}")
print("-" * 58)
for fold, (train_idx, test_idx) in enumerate(tscv.split(ts), 1):
cv_train = ts.iloc[train_idx]
cv_test = ts.iloc[test_idx]
try:
cv_model = SARIMAX(cv_train,
order=(1, 1, 1),
seasonal_order=(1, 1, 1, 12)).fit(disp=False)
cv_pred = cv_model.get_forecast(steps=len(cv_test)).predicted_mean
fold_mae = mean_absolute_error(cv_test, cv_pred)
fold_mape = mape(cv_test, cv_pred)
fold_r2 = r2_score(cv_test, cv_pred)
cv_maes.append(fold_mae)
cv_mapes.append(fold_mape)
cv_r2s.append(fold_r2)
print(f"{fold:<6} {len(cv_train):<12} {len(cv_test):<11} {fold_mae:<10.1f} {fold_mape:<10.2f} {fold_r2:.4f}")
except Exception as e:
print(f"{fold:<6} ERROR: {e}")
print("-" * 58)
(f"{'Mean':<6} {'':<12} {'':<11} {np.mean(cv_maes):<10.1f} {np.mean(cv_mapes):<10.2f} {np.mean(cv_r2s):.4f}")
print(f"{'Std':<6} {'':<12} {'':<11} {np.std(cv_maes):<10.1f} {np.std(cv_mapes):<10.2f} {np.std(cv_r2s):.4f}")
SARIMA – Time Series Cross-Validation (5 folds) Fold Train size Test size MAE MAPE% R² ---------------------------------------------------------- 1 29 28 56.4 6.01 0.8726 2 57 28 97.5 9.22 0.7605 3 85 28 53.2 4.89 0.9122 4 113 28 172.0 14.20 0.0004 5 141 28 129.7 8.70 0.6560 ---------------------------------------------------------- Std 45.1 3.23 0.3323
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
folds = [f'Fold {i}' for i in range(1, len(cv_maes)+1)]
for ax, vals, ylabel, title in zip(
axes,
[cv_maes, cv_mapes, cv_r2s],
['MAE', 'MAPE (%)', 'R²'],
['MAE per Fold', 'MAPE% per Fold', 'R² per Fold']
):
bars = ax.bar(folds, vals, color='steelblue')
ax.axhline(np.mean(vals), color='red', linestyle='--', label=f'Mean={np.mean(vals):.2f}')
ax.set_title(title)
ax.set_ylabel(ylabel)
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3, axis='y')
for b, v in zip(bars, vals):
ax.text(b.get_x()+b.get_width()/2, v*1.01, f'{v:.2f}', ha='center', fontsize=8)
plt.suptitle('SARIMA – 5-Fold Time Series Cross-Validation', fontweight='bold')
plt.tight_layout()
plt.show()
fig, ax = plt.subplots(figsize=(16, 6))
# Paleta de cores para cada fold
colors = ['#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9b59b6']
# Plota o histórico
ax.plot(ts.index, ts, 'k-', lw=1.5, label='Historical', alpha=0.6)
# Para cada fold, plota o período de treino e a previsão sobre o teste
for fold, (train_idx, test_idx) in enumerate(tscv.split(ts), 1):
cv_train = ts.iloc[train_idx]
cv_test = ts.iloc[test_idx]
cv_model = SARIMAX(cv_train,
order=(1, 1, 1),
seasonal_order=(1, 1, 1, 12)).fit(disp=False)
cv_pred = cv_model.get_forecast(steps=len(cv_test)).predicted_mean
cv_ci = cv_model.get_forecast(steps=len(cv_test)).conf_int(alpha=0.05)
# Previsão de cada fold
ax.plot(cv_test.index, cv_pred,
color=colors[fold-1], lw=2, linestyle='--',
label=f'Fold {fold} Forecast (MAPE={cv_mapes[fold-1]:.1f}%)')
# Banda de confiança 95%
ax.fill_between(cv_test.index,
cv_ci.iloc[:, 0], cv_ci.iloc[:, 1],
color=colors[fold-1], alpha=0.08)
# Linha vertical separando treino e teste de cada fold
ax.axvline(x=cv_test.index[0], color=colors[fold-1],
linestyle=':', alpha=0.5, lw=1)
ax.set_xlabel('Date')
ax.set_ylabel('Passengers (thousands)')
ax.set_title('SARIMA(1,1,1)(1,1,1,12) – Cross-Validation Forecasts per Fold')
ax.legend(loc='upper left', fontsize=9)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
6. Model 3 - Holt-Winters Exponential Smoothing (Triple ES)¶
Holt-Winters captures level, trend, and seasonality simultaneously. Both additive and multiplicative configurations are tested and compared by AIC.
Unlike ARIMA and SARIMA, Holt-Winters is tested in two distinct setups that differ not only in COVID inclusion but also in the training window scope, because the model's smoothing mechanism is heavily influenced by the recovery trajectory.
6.1 Setup A – Holt-Winters With COVID¶
Reference only. Full series including the pandemic structural break.
# ── Setup A: Holt-Winters on full series (with COVID) ───────────────────
hw_add_A = HWES(train_A, trend='add', seasonal='add', seasonal_periods=12).fit(optimized=True)
hw_mul_A = HWES(train_A, trend='mul', seasonal='mul', seasonal_periods=12).fit(optimized=True)
print(f"HW Additive AIC (Setup A) = {hw_add_A.aic:.2f}")
print(f"HW Multiplicative AIC (Setup A) = {hw_mul_A.aic:.2f}")
hw_model_A = hw_add_A if hw_add_A.aic < hw_mul_A.aic else hw_mul_A
hw_type_A = 'Additive' if hw_add_A.aic < hw_mul_A.aic else 'Multiplicative'
hw_pred_A = hw_model_A.forecast(12)
mae_hw_A = mean_absolute_error(test_A, hw_pred_A)
rmse_hw_A = np.sqrt(mean_squared_error(test_A, hw_pred_A))
mape_hw_A = mape(test_A, hw_pred_A)
r2_hw_A = r2_score(test_A, hw_pred_A)
print(f"\nHolt-Winters {hw_type_A} Setup A (with COVID):")
print(f" MAE = {mae_hw_A:.1f}k RMSE = {rmse_hw_A:.1f}k MAPE = {mape_hw_A:.2f}% R2 = {r2_hw_A:.4f}")
HW Additive AIC (Setup A) = 1654.64 HW Multiplicative AIC (Setup A) = 1639.17 Holt-Winters Multiplicative Setup A (with COVID): MAE = 54.7k RMSE = 58.5k MAPE = 3.74% R2 = 0.9449
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(train_A.index[-24:], train_A[-24:], 'g-.', label='Train', lw=1.5)
ax.plot(test_A.index, test_A, 'ko-', label='Actual', lw=2, ms=5)
ax.plot(test_A.index, hw_pred_A, 'r--', label=f'HW {hw_type_A} Setup A (MAPE={mape_hw_A:.1f}%)', lw=2)
ax.axvspan(pd.Timestamp('2020-01-01'), pd.Timestamp('2022-02-01'),
alpha=0.08, color='red', label='COVID Period')
ax.set_title(f'Holt-Winters {hw_type_A} Setup A (with COVID) – Forecast vs Actual')
ax.set_xlabel('Date'); ax.set_ylabel('Passengers (thousands)')
ax.legend(); ax.grid(True, alpha=0.3); plt.tight_layout(); plt.show()
6.2 Setup B – Holt-Winters Post-COVID Only (2022+)¶
Post-COVID data only is used because the steep recovery gradient between 2022 and 2024 causes the model to project unrealistic growth when trained on the full historical series.
# Treinar apenas com dados pós-COVID para o Holt-Winters
ts_post = ts[ts.index >= '2022-01-01']
train_hw = ts_post[:-12]
test_hw = ts_post[-12:]
# Additive: suitable when seasonal variation is stable in absolute terms
hw_add = HWES(train_hw, trend='add', seasonal='add', seasonal_periods=12).fit(optimized=True)
print(f"Holt-Winters Additive AIC = {hw_add.aic:.2f}")
# Multiplicative: suitable when seasonal amplitude grows proportionally with the level
hw_mul = HWES(train_hw, trend='mul', seasonal='mul', seasonal_periods=12).fit(optimized=True)
print(f"Holt-Winters Multiplicative AIC = {hw_mul.aic:.2f}")
# Triple Smoothing com alpha alto – peso elevado nas observações recentes
# Beta baixo evita amplificar o gradiente de recuperação pós-COVID como tendência permanente
hw_triple = HWES(train_hw, trend='add', seasonal='add', seasonal_periods=12).fit(
smoothing_level=0.9,
smoothing_trend=0.1,
smoothing_seasonal=0.3,
optimized=False
)
print(f"Holt-Winters Triple (α=0.9) AIC = {hw_triple.aic:.2f}")
Holt-Winters Additive AIC = 280.29 Holt-Winters Multiplicative AIC = 279.69 Holt-Winters Triple (α=0.9) AIC = 325.80
# Select the configuration with the lower AIC
if hw_add.aic < hw_mul.aic:
hw_model = hw_add
hw_type = "Additive"
else:
hw_model = hw_mul
hw_type = "Multiplicative"
print(f"Selected: Holt-Winters {hw_type}")
print(hw_model.summary())
Selected: Holt-Winters Multiplicative
ExponentialSmoothing Model Results
================================================================================
Dep. Variable: Dublin_airport No. Observations: 37
Model: ExponentialSmoothing SSE 29892.300
Optimized: True AIC 279.694
Trend: Multiplicative BIC 305.469
Seasonal: Multiplicative AICC 317.694
Seasonal Periods: 12 Date: Mon, 11 May 2026
Box-Cox: False Time: 13:52:07
Box-Cox Coeff.: None
=================================================================================
coeff code optimized
---------------------------------------------------------------------------------
smoothing_level 0.3843291 alpha True
smoothing_trend 0.3843291 beta True
smoothing_seasonal 0.000000 gamma True
initial_level 1202.1330 l.0 True
initial_trend 1.0362919 b.0 True
initial_seasons.0 0.7682506 s.0 True
initial_seasons.1 0.8879332 s.1 True
initial_seasons.2 0.9525901 s.2 True
initial_seasons.3 0.9844377 s.3 True
initial_seasons.4 1.0650395 s.4 True
initial_seasons.5 1.0948763 s.5 True
initial_seasons.6 0.9499782 s.6 True
initial_seasons.7 0.8923226 s.7 True
initial_seasons.8 0.7071498 s.8 True
initial_seasons.9 0.6929817 s.9 True
initial_seasons.10 0.6557772 s.10 True
initial_seasons.11 0.6469945 s.11 True
---------------------------------------------------------------------------------
hw_pred = hw_model.forecast(steps=12)
mae_hw = mean_absolute_error(test, hw_pred)
rmse_hw = np.sqrt(mean_squared_error(test, hw_pred))
mape_hw = mape(test, hw_pred)
r2_hw = r2_score(test, hw_pred)
print(f"Holt-Winters ({hw_type}) Performance:")
print(f" MAE = {mae_hw:.1f}k")
print(f" RMSE = {rmse_hw:.1f}k")
print(f" MAPE = {mape_hw:.2f}%")
print(f" R2 = {r2_hw:.4f}")
Holt-Winters (Multiplicative) Performance: MAE = 91.3k RMSE = 93.1k MAPE = 6.14% R2 = 0.8606
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(train.index[-36:], train[-36:], 'g-.', label='Train', lw=1.5)
ax.plot(test.index, test, 'b-', label='Actual Test', lw=2)
ax.plot(test.index, hw_pred, 'r--', label=f'HW {hw_type} Forecast (MAPE={mape_hw:.1f}%)', lw=2)
ax.set_xlabel('Date')
ax.set_ylabel('Passengers (thousands)')
ax.set_title(f'Holt-Winters {hw_type} - Forecast vs Actual')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Compare additive vs multiplicative to show selection reasoning
hw_add_pred = hw_add.forecast(12)
hw_mul_pred = hw_mul.forecast(12)
hw_triple_pred = hw_triple.forecast(12)
mae_hw_triple = mean_absolute_error(test, hw_triple_pred)
rmse_hw_triple = np.sqrt(mean_squared_error(test, hw_triple_pred))
mape_hw_triple = mape(test, hw_triple_pred)
r2_hw_triple = r2_score(test, hw_triple_pred)
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(train.index[-24:], train[-24:], 'g-.', label='Train')
ax.plot(test.index, test, 'b-', label='Actual')
ax.plot(test.index, hw_add_pred, 'r--', label=f'HW Additive (MAPE={mape(test, hw_add_pred):.1f}%)')
ax.plot(test.index, hw_mul_pred, 'm:', label=f'HW Multiplicative (MAPE={mape(test, hw_mul_pred):.1f}%)')
ax.plot(test.index, hw_triple_pred, 'c-', label=f'HW Triple α=0.9 (MAPE={mape_hw_triple:.1f}%)')
ax.set_xlabel('Date')
ax.set_ylabel('Passengers (thousands)')
ax.set_title('Holt-Winters: Additive vs Multiplicative')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
7. Model Comparison – Setup A vs Setup B¶
All three models evaluated under both setups against the same 12-month test set (March 2025 – February 2026).
# ── Setup A metrics ──────────────────────────────────────────────────────
comp_A = pd.DataFrame({
'Model' : ['ARIMA', 'SARIMA', 'Holt-Winters'],
'MAE' : [mae_arima_A, mae_sarima_A, mae_hw_A],
'RMSE' : [rmse_arima_A, rmse_sarima_A, rmse_hw_A],
'MAPE%' : [mape_arima_A, mape_sarima_A, mape_hw_A],
'R2' : [r2_arima_A, r2_sarima_A, r2_hw_A],
'Setup' : ['With COVID'] * 3
})
# ── Setup B metrics ──────────────────────────────────────────────────────
comp_B = pd.DataFrame({
'Model' : ['ARIMA', 'SARIMA', 'Holt-Winters'],
'MAE' : [mae_arima, mae_sarima, mae_hw],
'RMSE' : [rmse_arima, rmse_sarima, rmse_hw],
'MAPE%' : [mape_arima, mape_sarima, mape_hw],
'R2' : [r2_arima, r2_sarima, r2_hw],
'Setup' : ['COVID Excluded'] * 3
})
comp_AB = pd.concat([comp_A, comp_B]).sort_values(['Model','Setup']).reset_index(drop=True)
print('Full Comparison – Setup A vs Setup B:')
display(comp_AB.round(4).style
.highlight_min(color='green', axis=0, subset=['MAE','RMSE','MAPE%'])
.highlight_max(color='green', axis=0, subset=['R2'])
.format({'MAE':'{:.1f}','RMSE':'{:.1f}','MAPE%':'{:.2f}','R2':'{:.4f}'}))
Full Comparison – Setup A vs Setup B:
| Model | MAE | RMSE | MAPE% | R2 | Setup | |
|---|---|---|---|---|---|---|
| 0 | ARIMA | 110.1 | 130.5 | 7.22 | 0.7258 | COVID Excluded |
| 1 | ARIMA | 94.7 | 109.5 | 6.18 | 0.8071 | With COVID |
| 2 | Holt-Winters | 91.3 | 93.1 | 6.14 | 0.8606 | COVID Excluded |
| 3 | Holt-Winters | 54.7 | 58.5 | 3.74 | 0.9449 | With COVID |
| 4 | SARIMA | 100.1 | 103.4 | 6.63 | 0.8280 | COVID Excluded |
| 5 | SARIMA | 76.2 | 89.9 | 4.74 | 0.8698 | With COVID |
# Grouped bar chart – MAPE Setup A vs Setup B
models = ['ARIMA', 'SARIMA', 'Holt-Winters']
mapes_A = [mape_arima_A, mape_sarima_A, mape_hw_A]
mapes_B = [mape_arima, mape_sarima, mape_hw]
x, w = np.arange(3), 0.35
fig, ax = plt.subplots(figsize=(11, 5))
b1 = ax.bar(x-w/2, mapes_A, w, label='Setup A – with COVID', color='#e74c3c', alpha=0.85)
b2 = ax.bar(x+w/2, mapes_B, w, label='Setup B – COVID excluded', color='#2ecc71', alpha=0.85)
for b in list(b1)+list(b2):
ax.text(b.get_x()+b.get_width()/2, b.get_height()+0.2,
f'{b.get_height():.1f}%', ha='center', fontsize=9)
ax.axhline(7.01, color='grey', linestyle=':', alpha=0.7, label='Naive Seasonal baseline (7.01%)')
ax.set_xticks(x); ax.set_xticklabels(models)
ax.set_ylabel('MAPE (%)'); ax.set_title('MAPE – Setup A vs Setup B')
ax.legend(); ax.grid(True, alpha=0.3, axis='y')
plt.tight_layout(); plt.show()
# Line chart – all models forecast vs actual (Setup B)
fig, ax = plt.subplots(figsize=(14, 7))
ax.plot(train.index[-24:], train[-24:], 'g-', alpha=0.5, label='Train', lw=1.5)
ax.plot(test.index, test, 'ko-', label='Actual', lw=2, ms=6)
ax.plot(test.index, arima_pred_mean, 'b--', label=f'ARIMA (MAPE={mape_arima:.1f}%)', lw=1.8)
ax.plot(test.index, sarima_pred_mean, 'm-.', label=f'SARIMA (MAPE={mape_sarima:.1f}%)', lw=1.8)
ax.plot(test.index, hw_pred, 'r:', label=f'HW (MAPE={mape_hw:.1f}%)', lw=2)
ax.set_xlabel('Date'); ax.set_ylabel('Passengers (thousands)')
ax.set_title('All Models Setup B – Forecast vs Actual (Test Set)')
ax.legend(loc='upper left'); ax.grid(True, alpha=0.3)
plt.tight_layout(); plt.show()
comparison = pd.DataFrame({
'Model': ['ARIMA', 'SARIMA', 'Holt-Winters'],
'MAE' : [mae_arima, mae_sarima, mae_hw],
'RMSE' : [rmse_arima, rmse_sarima, rmse_hw],
'MAPE%': [mape_arima, mape_sarima, mape_hw],
'R2' : [r2_arima, r2_sarima, r2_hw]
}).set_index('Model').round(4)
display(comparison.style
.highlight_min(color='green', axis=0, subset=['MAE','RMSE','MAPE%'])
.highlight_max(color='green', axis=0, subset=['R2'])
.format({'MAE':'{:.1f}','RMSE':'{:.1f}','MAPE%':'{:.2f}','R2':'{:.4f}'}))
| MAE | RMSE | MAPE% | R2 | |
|---|---|---|---|---|
| Model | ||||
| ARIMA | 110.1 | 130.5 | 7.22 | 0.7258 |
| SARIMA | 100.1 | 103.4 | 6.63 | 0.8280 |
| Holt-Winters | 91.3 | 93.1 | 6.14 | 0.8606 |
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
models = ['ARIMA', 'SARIMA', 'Holt-Winters']
colors = ['#3498db', '#9b59b6', '#2ecc71']
for ax, metric, vals, title in zip(
axes,
['MAE', 'RMSE', 'MAPE (%)'],
[[mae_arima, mae_sarima, mae_hw],
[rmse_arima, rmse_sarima, rmse_hw],
[mape_arima, mape_sarima, mape_hw]],
['MAE (lower = better)', 'RMSE (lower = better)', 'MAPE % (lower = better)']
):
bars = ax.bar(models, vals, color=colors, alpha=0.85)
ax.set_title(title, fontsize=10)
ax.set_ylabel(metric)
for b, v in zip(bars, vals):
ax.text(b.get_x()+b.get_width()/2, v+max(vals)*0.01, f'{v:.1f}', ha='center', fontsize=9)
ax.grid(True, alpha=0.3, axis='y')
plt.suptitle('Model Performance Comparison', fontsize=13, fontweight='bold')
plt.tight_layout()
plt.show()
# All models on one chart for direct visual comparison
fig, ax = plt.subplots(figsize=(14, 7))
ax.plot(train.index[-24:], train[-24:], 'g-', alpha=0.5, label='Train', lw=1.5)
ax.plot(test.index, test, 'ko-', label='Actual', lw=2, ms=6)
ax.plot(test.index, arima_pred_mean, 'b--', label=f'ARIMA (MAPE={mape_arima:.1f}%)', lw=1.8)
ax.plot(test.index, sarima_pred_mean, 'm-.', label=f'SARIMA (MAPE={mape_sarima:.1f}%)', lw=1.8)
ax.plot(test.index, hw_pred, 'r:', label=f'Holt-Winters (MAPE={mape_hw:.1f}%)', lw=2)
ax.set_xlabel('Date')
ax.set_ylabel('Passengers (thousands)')
ax.set_title('All Models - Forecast vs Actual (Test Set)')
ax.legend(loc='upper left')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
8. Final 12-Month Forecast¶
The best-performing model is retrained on the complete clean dataset to generate the 12-month passenger arrival forecast with 80% and 95% confidence intervals.
best_model_name = comparison['MAPE%'].idxmin()
print(f"Best model by MAPE: {best_model_name}")
Best model by MAPE: Holt-Winters
# Retrain all models on the full clean series (COVID excluded)
ts_clean_full = ts_full[(ts_full.index < '2020-01-01') | (ts_full.index > '2022-01-31')].copy()
# Treinar apenas com dados pós-COVID para o Holt-Winters
ts_post = ts[ts.index >= '2022-01-01']
train_hw = ts_post[:-12]
test_hw = ts_post[-12:]
hw_add = HWES(train_hw, trend='add', seasonal='add', seasonal_periods=12).fit(optimized=True)
hw_mul = HWES(train_hw, trend='mul', seasonal='mul', seasonal_periods=12).fit(optimized=True)
final_arima = ARIMA(ts_clean_full, order=(p_best, d_best, q_best)).fit()
final_sarima = SARIMAX(ts_clean_full, order=(1,1,1), seasonal_order=(1,1,1,12)).fit(disp=False)
final_hw = HWES(ts_clean_full, trend='add', seasonal='add', seasonal_periods=12).fit(optimized=True)
future_dates = pd.date_range(
start=ts_clean_full.index[-1] + pd.DateOffset(months=1), periods=12, freq='M')
print(f"Forecast period: {future_dates[0].date()} to {future_dates[-1].date()}")
Forecast period: 2026-04-30 to 2027-03-31
fc_arima = final_arima.get_forecast(steps=12)
fc_sarima = final_sarima.get_forecast(steps=12)
fc_hw = final_hw.forecast(steps=12)
fc_arima_mean = fc_arima.predicted_mean
fc_arima_ci80 = fc_arima.conf_int(alpha=0.20)
fc_arima_ci95 = fc_arima.conf_int(alpha=0.05)
fc_sarima_mean = fc_sarima.predicted_mean
fc_sarima_ci95 = fc_sarima.conf_int(alpha=0.05)
# Forecast summary table
fc_table = pd.DataFrame({
'Month' : [d.strftime('%B %Y') for d in future_dates],
'ARIMA' : fc_arima_mean.values.round(0).astype(int),
'SARIMA' : fc_sarima_mean.values.round(0).astype(int),
'Holt-Winters': fc_hw.values.round(0).astype(int)
}).set_index('Month')
display(fc_table)
| ARIMA | SARIMA | Holt-Winters | |
|---|---|---|---|
| Month | |||
| April 2026 | 1470 | 1609 | 1518 |
| May 2026 | 1512 | 1747 | 1612 |
| June 2026 | 1555 | 1808 | 1654 |
| July 2026 | 1560 | 1957 | 1783 |
| August 2026 | 1547 | 2015 | 1840 |
| September 2026 | 1538 | 1768 | 1641 |
| October 2026 | 1539 | 1690 | 1603 |
| November 2026 | 1542 | 1386 | 1354 |
| December 2026 | 1544 | 1373 | 1372 |
| January 2027 | 1543 | 1298 | 1307 |
| February 2027 | 1542 | 1256 | 1260 |
| March 2027 | 1542 | 1496 | 1417 |
fig, ax = plt.subplots(figsize=(16, 7))
ax.plot(ts_clean_full.index[-36:], ts_clean_full[-36:], 'b-', label='Historical', lw=1.8)
ax.plot(future_dates, fc_arima_mean, 'b--', label='ARIMA Forecast', lw=2)
ax.fill_between(future_dates, fc_arima_ci80.iloc[:,0], fc_arima_ci80.iloc[:,1],
color='steelblue', alpha=0.20, label='80% CI (ARIMA)')
ax.fill_between(future_dates, fc_arima_ci95.iloc[:,0], fc_arima_ci95.iloc[:,1],
color='steelblue', alpha=0.08, label='95% CI (ARIMA)')
ax.plot(future_dates, fc_sarima_mean, 'm-.', label='SARIMA Forecast', lw=2)
ax.plot(future_dates, fc_hw, 'r:', label='Holt-Winters Forecast', lw=2)
ax.axvline(x=ts_clean_full.index[-1], color='grey', linestyle=':', alpha=0.7)
ax.set_xlabel('Date')
ax.set_ylabel('Passengers (thousands)')
ax.set_title('12-Month Passenger Arrival Forecast - Dublin Airport')
ax.legend(loc='upper left')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print('=' * 50)
print(' 12-Month Arrival Forecast - Dublin Airport')
print(f" Best model: {best_model_name}")
print('=' * 50)
fc_vals = fc_sarima_mean if best_model_name == 'SARIMA' else fc_arima_mean
fc_lo = fc_sarima_ci95.iloc[:,0] if best_model_name == 'SARIMA' else fc_arima_ci95.iloc[:,0]
fc_hi = fc_sarima_ci95.iloc[:,1] if best_model_name == 'SARIMA' else fc_arima_ci95.iloc[:,1]
for date, val, lo, hi in zip(future_dates, fc_vals.values, fc_lo.values, fc_hi.values):
print(f" {date.strftime('%b %Y')}: {val:7,.0f}k (95% CI: {lo:,.0f}k - {hi:,.0f}k)")
print('=' * 50)
================================================== 12-Month Arrival Forecast - Dublin Airport Best model: Holt-Winters ================================================== Apr 2026: 1,470k (95% CI: 1,249k - 1,691k) May 2026: 1,512k (95% CI: 1,168k - 1,856k) Jun 2026: 1,555k (95% CI: 1,051k - 2,059k) Jul 2026: 1,560k (95% CI: 871k - 2,250k) Aug 2026: 1,547k (95% CI: 706k - 2,387k) Sep 2026: 1,538k (95% CI: 586k - 2,490k) Oct 2026: 1,539k (95% CI: 495k - 2,583k) Nov 2026: 1,542k (95% CI: 413k - 2,672k) Dec 2026: 1,544k (95% CI: 332k - 2,756k) Jan 2027: 1,543k (95% CI: 254k - 2,833k) Feb 2027: 1,542k (95% CI: 179k - 2,905k) Mar 2027: 1,542k (95% CI: 110k - 2,974k) ==================================================
9. Model Interpretability¶
For forecasts to be useful in a business context, stakeholders must understand why the model predicts what it does. This section explains the structural components driving predictions and validates model assumptions.
# Decompose the clean series to show the components the models are learning from
log_ts_clean = np.log(ts_post)
log_ts_clean.interpolate(inplace=True)
decomp = seasonal_decompose(log_ts_clean, model='additive')
fig = decomp.plot()
fig.set_size_inches(16, 10)
plt.suptitle('Seasonal Decomposition - Clean Modelling Series (Log scale)', y=1.01)
plt.tight_layout()
plt.show()
print('Decomposition components:')
print(' Trend : Steady growth 2010-2019; strong post-COVID recovery from 2022.')
print(' Seasonal : Consistent annual cycle - peak Jul/Aug, trough Jan/Feb.')
print(' Residuals: Random noise with no systematic pattern - model captures key structure.')
Decomposition components: Trend : Steady growth 2010-2019; strong post-COVID recovery from 2022. Seasonal : Consistent annual cycle - peak Jul/Aug, trough Jan/Feb. Residuals: Random noise with no systematic pattern - model captures key structure.