Executive Summary
Where #Passengers is heading over the next 24 months
The short answer
Airline passengers are projected to rise 17.4% over the next 24 months, from 432 to 507.06, with an underlying upward trend of +3.13 per month. The model was 2.5% accurate on a 12-month holdout test, confirming high reliability for planning.
The detail
Projected change: 17.38%. Forecast endpoint: 507.06 passengers. Last observed value: 432. Underlying trend: +3.13 per month. Repeating seasonal pattern: 240.7 peak-to-trough swing within each year. Holdout MAPE (12-month backtest): 2.52%. This error rate places the model in the "excellent" range for business forecasting (under 5% is excellent; under 15% is workable).
What this can't tell you
The 2.5% error rate reflects the model's performance on the holdout window; actual future error may differ if the coming 24 months contain patterns not represented in the historical 144 months.
Analysis Overview
Monthly forecast of #Passengers over 24 months.
The short answer
A Holt-Winters seasonal model was chosen to forecast 24 months of monthly airline passengers because the 144-month history (12 full years) is long enough to reliably estimate a repeating seasonal pattern. The model splits passenger movement into trend, level, and seasonal swing.
The detail
The dataset contains 144 monthly observations of #Passengers, parsed and regularized into a regular monthly series with no gaps, duplicates, or interpolation needed. Holt-Winters exponential smoothing (level + trend + additive seasonality) was selected because the data spans sufficient full years to estimate a stable seasonal pattern. The model produces 24-month forecasts with 80% and 95% prediction intervals; the 95% band quantifies uncertainty and widens as the horizon extends. The method assumes historical patterns persist and cannot anticipate one-off events.
What this can't tell you
The forecast cannot account for structural breaks, policy changes, or external shocks that fall outside the patterns observed in the 144-month window. Forecasts beyond 24 months would require explicit extrapolation logic not present in the model.
Data Preparation
How the raw rows became a regular time series.
The short answer
All 144 rows loaded cleanly: each date appeared exactly once, no gaps existed, and the series was already regular at monthly frequency. No aggregation, interpolation, or row dropping was needed.
The detail
Initial rows: 144. Duplicate rows: 0. Interpolated points: 0. Dropped rows: 0. Final series: 144 monthly observations. The cadence of Month was inferred as monthly from the median gap between consecutive dates. The series required no cleaning or transformation before modeling.
What this can't tell you
This export contains only the aggregated monthly totals; transaction-level or daily data would reveal whether seasonal patterns are driven by specific day-of-week or booking-window effects, but that granularity is not present here.
Forecast
#passengers history and 24-month projection with 95% prediction band.
The short answer
The forecast projects 507.06 passengers at the 24-month horizon, with a 95% prediction band spanning from 451 to 563.13 — a width of 112.13 passengers. This wide band reflects real uncertainty compounding over two years; plan against the range, not the point estimate alone.
The detail
The observed history runs through the data series shown; the 24-month projection extends from the last observed month. At the horizon, the forecast point estimate is 507.06 passengers, with lower bound 451 and upper bound 563.13, giving a band width of 112.13 passengers. The prediction interval widens as the forecast extends, a standard feature of exponential smoothing models where uncertainty grows with distance from the training data.
What this can't tell you
The band width reflects statistical uncertainty in the model's parameters and error variance, not external shocks (economic downturns, fuel price spikes, regulatory changes) that could shift demand outside these bounds. The forecast assumes the seasonal and trend patterns observed in the history persist; structural breaks in the market would require retraining.
Trend & Seasonality
The series decomposed into level, trend, and seasonal swing.
| Component | Description | Magnitude |
|---|---|---|
| Level | Estimated current level of #Passengers | 477.8 |
| Trend | Average change in #Passengers per month (upward) | 3.128 |
| Seasonality | Peak-to-trough swing of the repeating monthly pattern within a year | 240.7 |
The short answer
The current level is 477.83 passengers, rising at 3.13 per month, but the seasonal swing of 240.66 peak-to-trough dominates the metric's movement. Which month you forecast matters far more than how much time passes.
The detail
The decomposition yields: Level 477.828, Trend +3.1276 per month, and Seasonality 240.661 peak-to-trough. The seasonal swing (240.661) is substantially larger than the per-month trend (3.1276), meaning the repeating annual pattern — peaks in summer, troughs in winter — accounts for most of the month-to-month variation. Over a full year, trend adds roughly 37.5 passengers; within that same year, seasonality swings 240.661 passengers from low to high.
What this can't tell you
The decomposition does not project how the trend or seasonality may evolve; it reports their current estimated magnitudes. If travel patterns shift (e.g., reduced seasonality due to remote work adoption, or a steeper trend from capacity expansion), these components would need to be reestimated on new data.
Seasonal Pattern
Within-year seasonal effects on #Passengers.
The short answer
July is the strongest season (+152.89 passengers above trend) and November is the weakest (−87.77 below trend), a total swing of 240.66. Summer months (June through August) run well above trend; winter months (January, February, November, December) run below it.
The detail
Seasonal effects by month: January −27.46, February −54.69, March −20.18, April +12.92, May +18.87, June +75.29, July +152.89, August +134.61, September +33.78, October −18.38, November −87.77, December −45.83. The strongest season is July at +152.89 and the weakest is November at −87.77, yielding a peak-to-trough gap of 240.66 passengers. These seasonal effects are already embedded in the forward forecast.
What this can't tell you
The seasonal pattern reflects the historical average for each month; individual years may deviate if holidays shift or special events occur outside the typical seasonal window.
Forecast Accuracy
Backtested holdout error — how far off this model tends to be.
| Metric | Value | Interpretation |
|---|---|---|
| Model | — | Holt-Winters exponential smoothing (level + trend + additive seasonality) |
| History points | 144 | Regularized monthly observations of #Passengers used to fit the model |
| Forecast horizon | 24 | Months projected beyond the last observed Month |
| Holdout points | 12 | Most recent points withheld, then predicted by a model that never saw them |
| Holdout MAPE (%) | 2.52 | Average prediction error of 2.5% on unseen data — highly reliable |
| Holdout MAE | 11.56 | Average absolute error in units of #Passengers |
The short answer
The model achieved 2.52% MAPE on held-out recent months (11.564 passengers MAE), placing it in the highly reliable range for business forecasting. This level of accuracy supports both directional and quantitative planning.
The detail
The model was Holt-Winters exponential smoothing (level + trend + additive seasonality), fitted on 144 history points. The last 12 months were withheld and predicted without ever seeing them; on those 12 holdout points, the model achieved MAPE of 2.52% and MAE of 11.564 passengers. The forecast horizon is 24 months. By standard business thresholds, MAPE under 5% is excellent; this result comfortably meets that bar.
What this can't tell you
Holdout error on the 12 most recent months does not guarantee accuracy beyond 24 months; the prediction band widens to reflect this growing uncertainty. The test assumes the future resembles the past; if structural changes occur (new routes, fleet changes, demand shocks), the historical error rate may not hold.
Time Series Forecast — See Where Your Numbers Are Heading
Forecasts a single metric from its own history: parses and regularizes the dates, infers the frequency (daily/weekly/monthly), fits a Holt-Winters exponential smoothing model (additive seasonal when the history supports it, non-seasonal or ARIMA(1,1,1) otherwise), projects forward with 80%/95% prediction intervals, and backtests itself on a holdout window (MAPE/MAE).
Why This Method?
Holt-Winters is the workhorse of business forecasting: it learns level, trend, and a repeating seasonal pattern directly from the series, needs no external drivers, and degrades gracefully — when the history is too short for seasonality the model simply drops that component. Base R only (stats::HoltWinters + stats::arima); no forecast/prophet packages.
What This Analysis Covers
- Forecast with 95% prediction band (chart)
- Trend + seasonality decomposition
- Within-cycle seasonal pattern
- Backtested holdout accuracy (MAPE / MAE)
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {date, value}. All narrative is derived from the user's own column names and computed values.
suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))Core Analysis Pipeline
Step 1: Parse dates robustly (init() may already have Date-coerced)
raw_date <- df$date
non_blank <- !(is.na(raw_date) | !nzchar(trimws(as.character(raw_date))))
d <- parse_dates_robust(raw_date)
n_nonblank <- sum(non_blank)
if (n_nonblank == 0) {
stop(sprintf("The '%s' column is empty — no dates to forecast from.", date_name))
}
n_unparsed <- sum(non_blank & is.na(d))
if (n_unparsed / n_nonblank > 0.05) {
stop(sprintf(
"%d of %d values in '%s' (%.0f%%) could not be read as dates. Please use a recognizable date format (e.g. 2024-01-31 or 01/31/2024).",
n_unparsed, n_nonblank, date_name, 100 * n_unparsed / n_nonblank))
}Step 2: Coerce the metric (95% rule)
v <- df$value
if (!is.numeric(v)) {
conv <- suppressWarnings(as.numeric(as.character(v)))
n_orig <- sum(!is.na(v) & nzchar(as.character(v)))
if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) {
v <- conv
} else {
stop(sprintf("The '%s' column is not numeric — a forecast needs a numeric metric.",
value_name))
}
}
v <- as.numeric(v)
keep <- !is.na(d) & !is.na(v)
n_dropped_rows <- initial_rows - sum(keep)
work <- data.frame(date = d[keep], value = v[keep])Step 4: Infer frequency from the median date gap
gaps <- as.numeric(diff(agg$date))
med_gap <- median(gaps)
if (med_gap <= 1.5) {
freq_label <- "daily"; step_days <- 1; freq <- 7; period_word <- "day"; cycle_word <- "week"
} else if (med_gap >= 5.5 && med_gap <= 8.5) {
freq_label <- "weekly"; step_days <- 7; freq <- 52; period_word <- "week"; cycle_word <- "year"
} else if (med_gap >= 26 && med_gap <= 35) {
freq_label <- "monthly"; step_days <- NA; freq <- 12; period_word <- "month"; cycle_word <- "year"
} else {
freq_label <- "irregular"; step_days <- max(1, round(med_gap)); freq <- 1
period_word <- sprintf("%d-day period", step_days); cycle_word <- "cycle"
}Step 5: Regular grid + linear interpolation of small gaps
if (freq_label == "monthly") {
snapped <- as.Date(format(agg$date, "%Y-%m-01"))
agg <- aggregate(value ~ date, data = data.frame(date = snapped, value = agg$value),
FUN = sum)
agg <- agg[order(agg$date), , drop = FALSE]
grid <- seq(min(agg$date), max(agg$date), by = "month")
} else {
grid <- seq(min(agg$date), max(agg$date), by = step_days)
}
interp <- approx(x = as.numeric(agg$date), y = agg$value,
xout = as.numeric(grid), rule = 2)
values <- interp$y
n_interp <- sum(!(as.numeric(grid) %in% as.numeric(agg$date)))
if (freq_label != "irregular" && n_interp / length(grid) > 0.5) {
stop(sprintf(
"More than half of the %s periods between the first and last '%s' are missing — too sparse to forecast reliably.",
freq_label, date_name))
}
n_points <- length(values)
final_rows <- n_points
rows_removed <- max(0, initial_rows - sum(keep))Step 6: Fit (seasonal HW -> HW trend -> ARIMA) + forecast horizon
horizon <- max(2, min(floor(0.25 * n_points), 2 * max(freq, 3), 30))
fitres <- fit_chain(values, freq, horizon)
method <- fitres$method
method_label <- fitres$method_label
seasonal_fit <- isTRUE(fitres$seasonal)
if (freq_label == "monthly") {
future_dates <- seq(grid[length(grid)], by = "month", length.out = horizon + 1)[-1]
} else {
future_dates <- grid[length(grid)] + step_days * seq_len(horizon)
}Step 7: Backtest on a holdout window (skipped gracefully if too short)
mape <- NA_real_; mae <- NA_real_; holdout_n <- 0L
backtest_note <- NULL
holdout_target <- min(max(freq, 6), floor(n_points / 3))
if (holdout_target >= 4 && (n_points - holdout_target) >= 10) {
bt <- tryCatch({
tr <- values[seq_len(n_points - holdout_target)]
fit_chain(tr, freq, holdout_target)
}, error = function(e) NULL)
if (!is.null(bt)) {
act <- values[(n_points - holdout_target + 1):n_points]
pred <- bt$mean
mae <- mean(abs(act - pred))
nz <- abs(act) > 1e-9
if (any(nz)) mape <- mean(abs((act[nz] - pred[nz]) / act[nz])) * 100
holdout_n <- holdout_target
} else {
backtest_note <- "Backtest skipped — the model could not be refitted on the shortened training window."
}
} else {
backtest_note <- "Backtest skipped — the series is too short to hold out a meaningful test window."
}Step 8: Headline numbers
last_actual <- values[n_points]
fc_last <- fitres$mean[horizon]
pct_change <- if (abs(last_actual) > 1e-9) {
(fc_last - last_actual) / abs(last_actual) * 100
} else NA_real_
direction_word <- if (is.na(pct_change)) "move" else if (pct_change > 2) "rise" else if (pct_change < -2) "fall" else "hold roughly steady"Step 9: Components (level / trend / seasonality)
idx <- seq_len(n_points)
trend_slope <- as.numeric(coef(lm(values ~ idx))[2])
level_now <- last_actual
seasonal_amplitude <- 0
seasonal_coefs <- numeric(0)
if (seasonal_fit && !is.null(fitres$fit)) {
co <- coef(fitres$fit)
if ("a" %in% names(co)) level_now <- as.numeric(co["a"])
if ("b" %in% names(co)) trend_slope <- as.numeric(co["b"])
seasonal_coefs <- as.numeric(co[grep("^s[0-9]+$", names(co))])
if (length(seasonal_coefs) > 0) {
seasonal_amplitude <- max(seasonal_coefs) - min(seasonal_coefs)
}
} else if (method == "hw_trend" && !is.null(fitres$fit)) {
co <- coef(fitres$fit)
if ("a" %in% names(co)) level_now <- as.numeric(co["a"])
if ("b" %in% names(co)) trend_slope <- as.numeric(co["b"])
}