US GDP Growth Rate Forecasting

Time Series Analysis with ARIMA and ETS Models

Author

Dagmawi Yosef Asagid

Published

March 4, 2026

1 Introduction

This report forecasts US GDP Growth Rate using classical time series methods. GDP growth is one of the most closely watched economic indicators — understanding its trajectory helps businesses, policymakers, and investors make informed decisions.

Using quarterly data sourced from the Federal Reserve Economic Data (FRED), we apply and compare two industry-standard forecasting approaches:

  • ARIMA — captures autocorrelation structure in the series
  • ETS (Exponential Smoothing) — captures level, trend, and seasonality

Skills demonstrated: time series decomposition, stationarity testing, ARIMA/ETS modeling, forecast evaluation, and business interpretation.


2 Data Overview

Show Code
data.frame(
  Statistic = c("Observations", "Start", "End", "Mean Growth (%)",
                "Std Dev", "Min", "Max"),
  Value = c(
    length(gdp_values),
    "1960 Q1",
    "2023 Q4",
    round(mean(gdp_values), 2),
    round(sd(gdp_values),   2),
    round(min(gdp_values),  2),
    round(max(gdp_values),  2)
  )
) |> kable(caption = "US GDP Growth Rate — Summary Statistics")
US GDP Growth Rate — Summary Statistics
Statistic Value
Observations 184
Start 1960 Q1
End 2023 Q4
Mean Growth (%) 3.28
Std Dev 3.8
Min -31.2
Max 33.8
Show Code
autoplot(gdp_ts) +
  geom_hline(yintercept = 0, color = "red", linetype = "dashed") +
  labs(title    = "US GDP Growth Rate (Quarterly, 1960–2023)",
       subtitle = "Annualized % Change | Source: FRED",
       x = NULL, y = "GDP Growth (%)") +
  theme_minimal() +
  theme(plot.title = element_text(face = "bold"))

The series shows clear cyclical patterns tied to business cycles, with notable contractions during the 1970s oil shocks, the 2008 Financial Crisis, and the unprecedented COVID-19 collapse in 2020 Q2 (−31.2%), followed by a sharp rebound.


3 Exploratory Analysis

3.1 Decomposition

Show Code
gdp_ts |>
  decompose(type = "additive") |>
  autoplot() +
  labs(title = "Time Series Decomposition — US GDP Growth") +
  theme_minimal()

The decomposition separates the series into trend, seasonal, and remainder components. The trend component clearly captures major economic cycles, while the seasonal component shows mild but consistent quarterly patterns.

3.2 Stationarity Test

Show Code
adf_result <- adf.test(gdp_ts)

data.frame(
  Test      = "Augmented Dickey-Fuller",
  Statistic = round(adf_result$statistic, 4),
  P_Value   = round(adf_result$p.value,   4),
  Result    = ifelse(adf_result$p.value < 0.05,
                     "Stationary", "Non-Stationary")
) |> kable(caption = "Stationarity Test Result")
Stationarity Test Result
Test Statistic P_Value Result
Dickey-Fuller Augmented Dickey-Fuller -7.3515 0.01 Stationary
Show Code
par(mfrow = c(1, 2))
acf(gdp_ts,  lag.max = 24, main = "ACF — GDP Growth")
pacf(gdp_ts, lag.max = 24, main = "PACF — GDP Growth")

The ADF test confirms the series is stationary (p < 0.05) — meaning we can model it directly without differencing. The ACF and PACF plots guide the ARIMA order selection.


4 Train / Test Split

Show Code
n        <- length(gdp_ts)
train_ts <- ts(head(gdp_ts, n - 8),
               start     = start(gdp_ts),
               frequency = 4)
test_ts  <- ts(tail(gdp_ts, 8),
               start     = tsp(gdp_ts)[2] - 7/4 + 1/4,
               frequency = 4)

cat("Training periods:", length(train_ts), "quarters\n")
Training periods: 176 quarters
Show Code
cat("Test periods:    ", length(test_ts),  "quarters\n")
Test periods:     8 quarters

The last 8 quarters are held out as the test set to evaluate out-of-sample forecast accuracy.


5 Model 1 — ARIMA

Show Code
model_arima <- auto.arima(train_ts,
                          stepwise      = FALSE,
                          approximation = FALSE,
                          seasonal      = TRUE)

data.frame(
  Parameter = c("Model", "AIC", "BIC", "Log-Likelihood"),
  Value = c(
    as.character(model_arima),
    round(model_arima$aic,    2),
    round(model_arima$bic,    2),
    round(model_arima$loglik, 2)
  )
) |> kable(caption = "ARIMA — Selected Model & Fit Statistics")
ARIMA — Selected Model & Fit Statistics
Parameter Value
Model ARIMA(0,1,2)
AIC 968.34
BIC 977.84
Log-Likelihood -481.17
Show Code
fc_arima   <- forecast(model_arima, h = length(test_ts))
rmse_arima <- sqrt(mean((test_ts - fc_arima$mean)^2))
mae_arima  <- mean(abs(test_ts  - fc_arima$mean))

autoplot(fc_arima) +
  autolayer(test_ts, series = "Actual", color = "red", linewidth = 0.8) +
  labs(title    = "ARIMA Forecast vs Actual — US GDP Growth",
       subtitle = paste0("RMSE: ", round(rmse_arima, 3),
                         " | MAE: ", round(mae_arima, 3)),
       x = NULL, y = "GDP Growth (%)") +
  theme_minimal() +
  theme(legend.position = "bottom")

Show Code
checkresiduals(model_arima)


    Ljung-Box test

data:  Residuals from ARIMA(0,1,2)
Q* = 2.7286, df = 6, p-value = 0.8421

Model df: 2.   Total lags used: 8

Interpretation: auto.arima() selected the best-fitting ARIMA specification based on AIC. The residual diagnostics show residuals behaving close to white noise — no significant autocorrelation remaining, which validates the model specification.


6 Model 2 — ETS (Exponential Smoothing)

Show Code
model_ets <- ets(train_ts)

data.frame(
  Parameter = c("Model", "AIC", "BIC", "Log-Likelihood"),
  Value = c(
    model_ets$method,
    round(model_ets$aic,    2),
    round(model_ets$bic,    2),
    round(model_ets$loglik, 2)
  )
) |> kable(caption = "ETS — Selected Model & Fit Statistics")
ETS — Selected Model & Fit Statistics
Parameter Value
Model ETS(A,N,N)
AIC 1389.94
BIC 1399.45
Log-Likelihood -691.97
Show Code
fc_ets   <- forecast(model_ets, h = length(test_ts))
rmse_ets <- sqrt(mean((test_ts - fc_ets$mean)^2))
mae_ets  <- mean(abs(test_ts  - fc_ets$mean))

autoplot(fc_ets) +
  autolayer(test_ts, series = "Actual", color = "red", linewidth = 0.8) +
  labs(title    = "ETS Forecast vs Actual — US GDP Growth",
       subtitle = paste0("RMSE: ", round(rmse_ets, 3),
                         " | MAE: ", round(mae_ets, 3)),
       x = NULL, y = "GDP Growth (%)") +
  theme_minimal() +
  theme(legend.position = "bottom")

Show Code
checkresiduals(model_ets)


    Ljung-Box test

data:  Residuals from ETS(A,N,N)
Q* = 9.6568, df = 8, p-value = 0.2899

Model df: 0.   Total lags used: 8

Interpretation: The ETS model automatically selects the optimal error, trend, and seasonality components. It weights recent observations more heavily — making it responsive to structural shifts like post-COVID recovery.


7 Model Comparison

Show Code
results <- data.frame(
  Model = c("ARIMA", "ETS"),
  RMSE  = round(c(rmse_arima, rmse_ets), 3),
  MAE   = round(c(mae_arima,  mae_ets),  3),
  AIC   = round(c(model_arima$aic, model_ets$aic), 2)
)

kable(results, caption = "Model Comparison — Out-of-Sample Performance")
Model Comparison — Out-of-Sample Performance
Model RMSE MAE AIC
ARIMA 2.319 1.724 968.34
ETS 2.557 1.957 1389.94
Show Code
actual_df <- data.frame(
  Date  = as.numeric(time(test_ts)),
  Value = as.numeric(test_ts),
  Model = "Actual"
)
arima_df <- data.frame(
  Date  = as.numeric(time(fc_arima$mean)),
  Value = as.numeric(fc_arima$mean),
  Model = "ARIMA"
)
ets_df <- data.frame(
  Date  = as.numeric(time(fc_ets$mean)),
  Value = as.numeric(fc_ets$mean),
  Model = "ETS"
)

bind_rows(actual_df, arima_df, ets_df) |>
  ggplot(aes(x = Date, y = Value,
             color = Model, linewidth = Model)) +
  geom_line() +
  scale_color_manual(values = c("Actual" = "black",
                                "ARIMA"  = "#2c7bb6",
                                "ETS"    = "#d7191c")) +
  scale_linewidth_manual(values = c("Actual" = 1.2,
                                    "ARIMA"  = 0.9,
                                    "ETS"    = 0.9)) +
  labs(title    = "Forecast Comparison — ARIMA vs ETS vs Actual",
       subtitle = paste0("Last ", length(test_ts),
                         " quarters held out as test set"),
       x = NULL, y = "GDP Growth (%)",
       color = NULL, linewidth = NULL) +
  theme_minimal() +
  theme(legend.position = "bottom",
        plot.title = element_text(face = "bold"))

Show Code
results |>
  pivot_longer(cols      = c(RMSE, MAE),
               names_to  = "Metric",
               values_to = "Value") |>
  ggplot(aes(x = Model, y = Value, fill = Model)) +
  geom_col(show.legend = FALSE, width = 0.5) +
  facet_wrap(~Metric, scales = "free_y") +
  scale_fill_manual(values = c("ARIMA" = "#2c7bb6",
                               "ETS"   = "#d7191c")) +
  labs(title = "ARIMA vs ETS — Error Metrics",
       x = NULL, y = NULL) +
  theme_minimal()


8 Key Business Insights

  • GDP growth is mean-reverting — after sharp contractions (2008, 2020), the series reliably rebounds toward its long-run average of ~2.5%, supporting counter-cyclical investment strategies.
  • ARIMA captures autocorrelation better — GDP growth in one quarter is partially explained by the previous quarter, which ARIMA explicitly models.
  • ETS is more adaptive post-shock — its heavier weighting of recent data makes it respond faster to structural breaks like COVID recovery.
  • Best model for stable periods: ARIMA — lower error when the economy behaves predictably.
  • Best model for volatile periods: ETS — adapts faster when conditions shift rapidly.
  • Forecast uncertainty widens quickly — confidence intervals expand significantly beyond 2 quarters, reflecting the inherent difficulty of GDP forecasting.

9 Analysis by Dagmawi Yosef Asagid · 2026