How to Perform the Ljung-Box Test in R

When you fit a time series model, you're betting that your model captures all the systematic patterns in the data. The residuals—what's left after your model does its work—should be random noise. If...

Key Insights

  • The Ljung-Box test detects remaining autocorrelation in model residuals—a p-value below 0.05 indicates your model hasn’t captured all the time series structure and needs refinement.
  • Always set the fitdf parameter to match the number of estimated parameters in your model (p + q for ARIMA), or you’ll get inflated p-values and miss genuine autocorrelation.
  • Don’t rely on a single lag value; test multiple lags (commonly 10, 15, and 20) since autocorrelation might appear at seasonal periods your initial choice missed.

Introduction to the Ljung-Box Test

When you fit a time series model, you’re betting that your model captures all the systematic patterns in the data. The residuals—what’s left after your model does its work—should be random noise. If they’re not, your model is missing something.

The Ljung-Box test is your diagnostic tool for checking this assumption. It tests whether the residuals from your time series model exhibit significant autocorrelation at various lags. If autocorrelation remains in your residuals, your forecasts will be biased, and your confidence intervals will be wrong.

You’ll typically run this test after fitting ARIMA, SARIMA, or exponential smoothing models. It’s the standard validation step before you trust your model’s predictions.

Mathematical Background

The Ljung-Box test statistic follows this formula:

$$Q = n(n+2) \sum_{k=1}^{h} \frac{\hat{\rho}_k^2}{n-k}$$

Where:

  • n is the sample size
  • h is the number of lags being tested
  • ρ̂ₖ is the sample autocorrelation at lag k

The null hypothesis states that the data are independently distributed—meaning no autocorrelation exists up to lag h. Under the null, the test statistic follows a chi-squared distribution with degrees of freedom equal to h minus the number of estimated parameters.

Interpretation is straightforward:

  • p-value > 0.05: Fail to reject the null. Residuals appear random. Your model is adequate.
  • p-value ≤ 0.05: Reject the null. Significant autocorrelation exists. Your model needs work.

The Ljung-Box test improves on the older Box-Pierce test by providing better small-sample performance. Always use Ljung-Box unless you have a specific reason not to.

Preparing Your Data

Before running the test, you need residuals from a fitted model. Let’s walk through the preparation steps using R’s built-in AirPassengers dataset.

# Load required packages
library(forecast)
library(tseries)

# Load the AirPassengers dataset
data("AirPassengers")
ap <- AirPassengers

# Quick visual inspection
plot(ap, main = "Monthly Airline Passengers (1949-1960)",
     ylab = "Passengers (thousands)", xlab = "Year")

This dataset shows clear trend and seasonality—both need to be addressed before modeling. Let’s check for stationarity:

# Augmented Dickey-Fuller test for stationarity
adf.test(ap)

The data isn’t stationary, which is expected. Now fit an ARIMA model:

# Fit an ARIMA model using auto.arima for automatic order selection
fit <- auto.arima(ap, seasonal = TRUE, stepwise = FALSE, approximation = FALSE)
summary(fit)

# Extract residuals for testing
residuals <- residuals(fit)

# Quick check: residuals should look like white noise
plot(residuals, main = "Model Residuals")

The auto.arima() function selected the best model based on AIC. Note the order—you’ll need the number of AR (p) and MA (q) parameters for the Ljung-Box test.

Performing the Test with Box.test()

R’s built-in Box.test() function handles the Ljung-Box test. Here’s the basic syntax:

# Basic Ljung-Box test at lag 10
Box.test(residuals, lag = 10, type = "Ljung-Box")

But this isn’t quite right for model residuals. You need to account for estimated parameters using fitdf:

# Get the number of AR and MA parameters from your model
# For ARIMA(p,d,q)(P,D,Q)[s], fitdf = p + q + P + Q
model_order <- arimaorder(fit)
fitdf_value <- model_order[1] + model_order[3]  # p + q for non-seasonal

# If seasonal components exist, add those too
if (length(model_order) == 6) {
  fitdf_value <- fitdf_value + model_order[4] + model_order[6]  # + P + Q
}

# Correct Ljung-Box test
Box.test(residuals, lag = 10, type = "Ljung-Box", fitdf = fitdf_value)

The fitdf parameter adjusts the degrees of freedom in the chi-squared distribution. Omitting it when testing model residuals inflates your p-values, making you falsely confident that your model is adequate.

Key parameters explained:

  • lag: Number of autocorrelation lags to test. Common choices are 10, 15, 20, or the seasonal period.
  • type: Use “Ljung-Box” (not “Box-Pierce”).
  • fitdf: Number of parameters estimated in your model. Critical for correct inference.

Test at multiple lags to catch autocorrelation at different frequencies:

# Test at multiple lag values
lags_to_test <- c(10, 15, 20, 24)  # 24 for monthly seasonal data

for (lag in lags_to_test) {
  test_result <- Box.test(residuals, lag = lag, type = "Ljung-Box", fitdf = fitdf_value)
  cat(sprintf("Lag %d: X-squared = %.3f, p-value = %.4f\n", 
              lag, test_result$statistic, test_result$p.value))
}

Interpreting Results

The output from Box.test() includes three key pieces of information:

# Run the test
lb_test <- Box.test(residuals, lag = 20, type = "Ljung-Box", fitdf = fitdf_value)
print(lb_test)

Output interpretation:

  • X-squared: The test statistic value. Higher values suggest more autocorrelation.
  • df: Degrees of freedom (lag minus fitdf).
  • p-value: Your decision criterion.

Build interpretation logic into your workflow:

# Automated interpretation function
interpret_ljung_box <- function(residuals, lag, fitdf, alpha = 0.05) {
  test <- Box.test(residuals, lag = lag, type = "Ljung-Box", fitdf = fitdf)
  
  result <- list(
    lag = lag,
    statistic = test$statistic,
    p_value = test$p.value,
    passed = test$p.value > alpha
  )
  
  if (result$passed) {
    message(sprintf("Lag %d: PASS (p = %.4f) - No significant autocorrelation detected", 
                    lag, result$p_value))
  } else {
    warning(sprintf("Lag %d: FAIL (p = %.4f) - Significant autocorrelation detected", 
                    lag, result$p_value))
  }
  
  return(result)
}

# Use the function
interpret_ljung_box(residuals, lag = 20, fitdf = fitdf_value)

Common pitfalls to avoid:

  1. Testing at only one lag—autocorrelation might exist at lags you didn’t check.
  2. Forgetting fitdf—this is the most common mistake.
  3. Using too few lags—test at least up to your seasonal period.
  4. Ignoring borderline p-values—a p-value of 0.06 warrants investigation even if it “passes.”

Visualizing Autocorrelation

Numbers tell part of the story. ACF plots show you where autocorrelation exists:

# Base R ACF plot
acf(residuals, main = "ACF of Model Residuals", lag.max = 30)

For publication-quality graphics, use ggplot2:

library(ggplot2)

# Create ACF data manually
acf_data <- acf(residuals, lag.max = 30, plot = FALSE)
acf_df <- data.frame(
  lag = acf_data$lag[-1],  # Remove lag 0
  acf = acf_data$acf[-1]
)

# Calculate confidence bounds
n <- length(residuals)
conf_bound <- qnorm(0.975) / sqrt(n)

# Create ggplot
ggplot(acf_df, aes(x = lag, y = acf)) +
  geom_hline(yintercept = 0, color = "gray50") +
  geom_hline(yintercept = c(-conf_bound, conf_bound), 
             linetype = "dashed", color = "blue") +
  geom_segment(aes(xend = lag, yend = 0), linewidth = 0.8) +
  geom_point(size = 2) +
  labs(title = "Autocorrelation Function of Residuals",
       x = "Lag", y = "ACF") +
  theme_minimal() +
  theme(panel.grid.minor = element_blank())

Spikes outside the blue dashed lines indicate significant autocorrelation at those lags. The Ljung-Box test aggregates this information across multiple lags into a single p-value.

Practical Example: Full Workflow

Here’s a complete, copy-paste-ready script that demonstrates the entire process:

# Complete Ljung-Box Test Workflow
# ================================

# Load packages
library(forecast)
library(ggplot2)

# Step 1: Load and explore data
data("AirPassengers")
ap <- AirPassengers

cat("Data Summary:\n")
cat(sprintf("  Observations: %d\n", length(ap)))
cat(sprintf("  Frequency: %d\n", frequency(ap)))
cat(sprintf("  Start: %s\n", paste(start(ap), collapse = "-")))
cat(sprintf("  End: %s\n\n", paste(end(ap), collapse = "-")))

# Step 2: Fit ARIMA model
fit <- auto.arima(ap, seasonal = TRUE, stepwise = FALSE)
cat("Fitted Model:\n")
print(fit)
cat("\n")

# Step 3: Extract residuals and model parameters
residuals <- residuals(fit)
model_order <- arimaorder(fit)

# Calculate fitdf (sum of all AR and MA parameters)
fitdf_value <- sum(model_order[c(1, 3)])  # p + q
if (length(model_order) == 6) {
  fitdf_value <- fitdf_value + sum(model_order[c(4, 6)])  # + P + Q
}
cat(sprintf("Model parameters estimated (fitdf): %d\n\n", fitdf_value))

# Step 4: Run Ljung-Box tests at multiple lags
cat("Ljung-Box Test Results:\n")
cat(paste(rep("-", 50), collapse = ""), "\n")

test_lags <- c(12, 24, 36)  # Seasonal multiples for monthly data
results <- data.frame(lag = integer(), statistic = numeric(), 
                      p_value = numeric(), conclusion = character())

for (lag in test_lags) {
  lb <- Box.test(residuals, lag = lag, type = "Ljung-Box", fitdf = fitdf_value)
  conclusion <- ifelse(lb$p.value > 0.05, "PASS", "FAIL")
  
  results <- rbind(results, data.frame(
    lag = lag,
    statistic = round(lb$statistic, 3),
    p_value = round(lb$p.value, 4),
    conclusion = conclusion
  ))
  
  cat(sprintf("Lag %2d: Q = %7.3f, p-value = %.4f [%s]\n", 
              lag, lb$statistic, lb$p.value, conclusion))
}

cat(paste(rep("-", 50), collapse = ""), "\n\n")

# Step 5: Final verdict
all_passed <- all(results$conclusion == "PASS")
if (all_passed) {
  cat("CONCLUSION: Model residuals show no significant autocorrelation.\n")
  cat("The model adequately captures the time series structure.\n")
} else {
  cat("CONCLUSION: Significant autocorrelation detected in residuals.\n")
  cat("Consider refining the model or investigating specific lags.\n")
}

# Step 6: Visualize
par(mfrow = c(1, 2))
plot(residuals, main = "Residuals Over Time", ylab = "Residual")
abline(h = 0, col = "red", lty = 2)
acf(residuals, main = "ACF of Residuals", lag.max = 36)
par(mfrow = c(1, 1))

This script gives you everything: model fitting, proper parameter counting, multi-lag testing, clear output, and visualization. Adapt the lag values based on your data’s frequency—use multiples of 12 for monthly data, multiples of 4 for quarterly, and so on.

The Ljung-Box test is simple but essential. Run it every time you fit a time series model. When it fails, don’t ignore it—go back and improve your model until those residuals are truly random.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.