The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.
This vignette provides a detailed explanation of the methodology
underlying the Modified Hodrick-Prescott (HP) filter as implemented in
the mhpfilter package. We cover the mathematical
foundations, the cross-validation approach for optimal parameter
selection, and empirical evidence supporting its use.
library(mhpfilter)
library(fastverse)
#> -- [1mAttaching packages[22m --------------------------------------- [38;5;33mfastverse[39m 0.3.4 --
#> [38;5;33mv[39m [38;5;198mmagrittr[39m 2.0.4 [38;5;33mv[39m [38;5;198mcollapse[39m 2.1.6
#> [38;5;33mv[39m [38;5;198mkit [39m 0.0.21
library(tidyverse)
#> ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
#> ✔ dplyr 1.2.0 ✔ readr 2.1.6
#> ✔ forcats 1.0.1 ✔ stringr 1.6.0
#> ✔ lubridate 1.9.5 ✔ tibble 3.3.1
#> ✔ purrr 1.2.1 ✔ tidyr 1.3.2
#> ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
#> ✖ dplyr::between() masks data.table::between()
#> ✖ dplyr::count() masks kit::count()
#> ✖ tidyr::extract() masks magrittr::extract()
#> ✖ dplyr::filter() masks stats::filter()
#> ✖ dplyr::first() masks data.table::first()
#> ✖ lubridate::hour() masks data.table::hour()
#> ✖ lubridate::isoweek() masks data.table::isoweek()
#> ✖ lubridate::isoyear() masks data.table::isoyear()
#> ✖ dplyr::lag() masks stats::lag()
#> ✖ dplyr::last() masks data.table::last()
#> ✖ lubridate::mday() masks data.table::mday()
#> ✖ lubridate::minute() masks data.table::minute()
#> ✖ lubridate::month() masks data.table::month()
#> ✖ lubridate::quarter() masks data.table::quarter()
#> ✖ tidyr::replace_na() masks collapse::replace_na()
#> ✖ lubridate::second() masks data.table::second()
#> ✖ purrr::set_names() masks magrittr::set_names()
#> ✖ purrr::transpose() masks data.table::transpose()
#> ✖ lubridate::wday() masks data.table::wday()
#> ✖ lubridate::week() masks data.table::week()
#> ✖ lubridate::yday() masks data.table::yday()
#> ✖ lubridate::year() masks data.table::year()
#> ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errorsThe Hodrick-Prescott (1997) filter decomposes a time series \(y_t\) into a trend component \(g_t\) and a cyclical component \(c_t\):
\[y_t = g_t + c_t, \quad t = 1, 2, \ldots, T\]
The filter estimates the trend by solving the optimization problem:
\[\min_{g_t} \left\{ \sum_{t=1}^{T}(y_t - g_t)^2 + \lambda \sum_{t=3}^{T}[(g_t - g_{t-1}) - (g_{t-1} - g_{t-2})]^2 \right\}\]
This objective function balances two competing goals:
The smoothing parameter \(\lambda\) controls the trade-off:
The solution can be expressed in matrix form. Define the second-difference operator matrix \(K\) of dimension \((T-2) \times T\):
\[K = \begin{pmatrix} 1 & -2 & 1 & 0 & \cdots & 0 \\ 0 & 1 & -2 & 1 & \cdots & 0 \\ \vdots & & \ddots & \ddots & \ddots & \vdots \\ 0 & \cdots & 0 & 1 & -2 & 1 \end{pmatrix}\]
Let \(A = K'K\). Then the HP filter solution is:
\[\hat{g} = (I + \lambda A)^{-1} y\]
where \(I\) is the \(T \times T\) identity matrix.
Hodrick and Prescott (1997) recommended \(\lambda = 1600\) for quarterly U.S. GDP data, based on the assumption that:
“A 5 percent cyclical component is moderately large, as is one-eighth of 1 percent change in the growth rate in a quarter.”
This led to \(\lambda = (5 / 0.125)^2 = 1600\).
However, this assumption may not hold for:
set.seed(100)
y <- cumsum(rnorm(80)) + 2*sin((1:80)*pi/20)
lambdas <- c(100, 400, 1600, 6400)
# Create data frame for all lambda values
plot_data <- data.frame()
for (lam in lambdas) {
result <- hp_filter(y, lambda = lam)
temp_data <- data.frame(
time = rep(1:length(y), 2),
value = c(y, result$trend),
series = rep(c("Original", "Trend"), each = length(y)),
lambda = paste("λ =", lam)
)
plot_data <- rbind(plot_data, temp_data)
}
# Create faceted plot with combined legend
ggplot(plot_data, aes(x = time, y = value, color = series, linewidth = series)) +
geom_line() +
scale_color_manual(values = c("Original" = "black", "Trend" = "blue")) +
scale_linewidth_manual(values = c("Original" = 0.7, "Trend" = 1.2)) +
facet_wrap(~ lambda, ncol = 2) +
labs(x = "Time", y = "Value", color = "Series", linewidth = "Series") +
theme_minimal() +
theme(legend.position = "bottom",
strip.text = element_text(size = 10, face = "bold"))The Modified HP filter, developed by McDermott (1997) and applied empirically by Choudhary, Hanif & Iqbal (2014), selects \(\lambda\) using generalized cross-validation (GCV).
The idea is based on leave-one-out cross-validation:
Let \(g_{T,\lambda}^{(k)}(t_k)\) denote the predicted value at time \(k\) from the spline fitted leaving out observation \(k\). The cross-validation function is:
\[CV(\lambda) = \frac{1}{T} \sum_{k=1}^{T} \left(y_k - g_{T,\lambda}^{(k)}(t_k)\right)^2\]
Computing the leave-one-out CV is computationally intensive. Craven & Wahba (1979) showed it can be approximated by the generalized cross-validation (GCV) function:
\[GCV(\lambda) = \frac{T^{-1} \sum_{k=1}^{T}(y_k - g_{t,k}^\lambda)^2}{\left(1 - \frac{1}{T}\text{tr}(B(\lambda))\right)^2}\]
where \(B(\lambda) = (I + \lambda A)^{-1}\) is the “hat matrix” and \(g_{t,k}^\lambda = \sum_{s=1}^{T} b_{ks}(\lambda) y_s\).
Using Silverman’s (1984) approximation for the trace:
\[\text{tr}(B(\lambda)) \approx \frac{T}{\lambda}\]
This yields the simplified GCV formula used in this package:
\[GCV(\lambda) = T^{-1} \left(1 + \frac{2T}{\lambda}\right) \sum_{k=1}^{T}(y_k - g_{t,k}^\lambda)^2\]
The algorithm implemented in mhpfilter is:
set.seed(42)
y <- cumsum(rnorm(100, 0.5, 0.3)) + arima.sim(list(ar = 0.8), 100)
# Compute GCV for range of lambdas
lambdas <- seq(100, 5000, by = 50)
gcv_values <- sapply(lambdas, function(lam) {
res <- hp_filter(y, lambda = lam, as_dt = FALSE)
T <- length(y)
resid_ss <- sum((y - res$trend)^2)
(1 + 2*T/lam) * resid_ss / T
})
# Create data frame for plotting
plot_data <- data.frame(
lambda = lambdas,
gcv = gcv_values
)
# Find minimum
opt_idx <- which.min(gcv_values)
opt_lambda <- lambdas[opt_idx]
opt_gcv <- gcv_values[opt_idx]
# Create ggplot
ggplot(plot_data, aes(x = lambda, y = gcv)) +
geom_line(linewidth = 1.2) +
geom_vline(xintercept = opt_lambda, color = "red", linetype = "dashed", linewidth = 0.8) +
geom_point(data = data.frame(lambda = opt_lambda, gcv = opt_gcv),
aes(x = lambda, y = gcv),
color = "red", size = 3) +
# Corrected: Use annotate() instead of geom_label() with aes()
annotate("label",
x = opt_lambda + 500,
y = max(gcv_values) * 0.95,
label = paste("Optimal λ =", opt_lambda),
color = "red",
fill = "white",
size = 4) +
labs(
x = expression(lambda),
y = "GCV",
title = "Generalized Cross-Validation Function"
) +
theme_minimal() +
theme(
plot.title = element_text(hjust = 0.5, size = 14, face = "bold"),
axis.title = element_text(size = 12),
panel.grid.minor = element_blank()
)Choudhary et al. (2014) conducted Monte Carlo simulations comparing the standard HP filter with the Modified HP filter. The data generating process was:
\[g_t = \mu + g_{t-1} + \varepsilon_t, \quad \varepsilon_t \sim N(0, \sigma_\varepsilon^2)\] \[c_t = \phi_1 c_{t-1} + \phi_2 c_{t-2} + \xi_t, \quad \xi_t \sim N(0, \sigma_\xi^2)\]
They varied:
Key finding: The Modified HP filter produced lower mean squared error in recovering the true cycle in nearly 100% of simulations.
set.seed(2024)
n <- 100
# Generate true components
trend_true <- cumsum(c(0, rnorm(n-1, 0.5, 0.2)))
cycle_true <- arima.sim(list(ar = c(1.2, -0.4)), n, sd = 1.5)
y <- trend_true + cycle_true
# Apply both filters
mhp_result <- mhp_filter(y, max_lambda = 10000)
hp_result <- hp_filter(y, lambda = 1600)
# Compute MSE
mse_mhp <- mean((mhp_result$cycle - cycle_true)^2)
mse_hp <- mean((hp_result$cycle - cycle_true)^2)
oldpar <- par(mfrow = c(1, 1))
plot(cycle_true, type = "l", lwd = 2, ylim = range(c(cycle_true, mhp_result$cycle, hp_result$cycle)),
main = "True vs Estimated Cycles", ylab = "Cycle")
lines(mhp_result$cycle, col = "blue", lwd = 2)
lines(hp_result$cycle, col = "red", lwd = 2, lty = 2)
legend("topright",
c(paste0("True Cycle"),
paste0("MHP (λ=", get_lambda(mhp_result), ", MSE=", round(mse_mhp, 2), ")"),
paste0("HP (λ=1600, MSE=", round(mse_hp, 2), ")")),
col = c("black", "blue", "red"), lty = c(1, 1, 2), lwd = 2)Choudhary et al. (2014) estimated optimal \(\lambda\) for 93 countries using annual data and 25 countries using quarterly data. Key findings:
| Statistic | Annual Data | Quarterly Data |
|---|---|---|
| Range of λ | 11 - 6,566 | 229 - 4,898 |
| Fixed λ | 100 | 1,600 |
Implications:
sd_diff)# Demonstrate cross-country variation
set.seed(999)
# Simulate countries with different characteristics
countries <- data.table(
name = c("Stable_Developed", "Volatile_Emerging", "Moderate"),
trend_sd = c(0.2, 0.5, 0.3),
cycle_sd = c(0.5, 2.0, 1.0),
cycle_ar = c(0.9, 0.7, 0.8)
)
results <- lapply(1:nrow(countries), function(i) {
trend <- cumsum(rnorm(80, 0.5, countries$trend_sd[i]))
cycle <- arima.sim(list(ar = countries$cycle_ar[i]), 80, sd = countries$cycle_sd[i])
y <- trend + cycle
res <- mhp_filter(y, max_lambda = 10000)
data.table(country = countries$name[i], lambda = get_lambda(res))
})
print(rbindlist(results))
#> country lambda
#> <char> <int>
#> 1: Stable_Developed 714
#> 2: Volatile_Emerging 604
#> 3: Moderate 401Cross-validation is optimal in the sense that it asymptotically minimizes the integrated squared error between the estimated trend and the true trend (Craven & Wahba, 1979).
The GCV function has several desirable properties:
The HP filter can be viewed as a Wiener-Kolmogorov filter for signal extraction. If:
Then \(\lambda = \sigma_c^2 / \sigma_\varepsilon^2\) is the optimal filter.
The GCV approach implicitly estimates this ratio from the data.
The HP filter is equivalent to a cubic smoothing spline. The GCV method for choosing \(\lambda\) is the same used in spline smoothing literature (Silverman, 1984).
Use Modified HP when:
Standard HP may be acceptable when:
The max_lambda parameter affects computation time and
search range:
| max_lambda | Use Case | Speed |
|---|---|---|
| 5,000 | Quick exploratory analysis | ~0.05s |
| 10,000 | Most quarterly macro series | ~0.1s |
| 50,000 | Conservative, unusual series | ~0.5s |
| 100,000 | Very smooth trends needed | ~1s |
# For most macro applications, 10000 is sufficient
result <- mhp_filter(y, max_lambda = 10000)
cat("Optimal lambda:", get_lambda(result), "\n")
#> Optimal lambda: 1400
# If optimal λ hits the upper bound, increase max_lambda
if (get_lambda(result) >= 9900) {
warning("Lambda near upper bound - consider increasing max_lambda")
}Key diagnostics when comparing HP vs Modified HP:
comp <- mhp_compare(y, frequency = "quarterly")
print(comp)
#> method lambda cycle_sd cycle_mean ar1 cycle_range gcv
#> <char> <num> <num> <num> <num> <num> <num>
#> 1: HP 1600 2.444616 1.890115e-12 0.7519842 12.29935 NA
#> 2: Modified HP 1400 2.424745 1.567173e-12 0.7477953 12.20010 6.652109
# Interpretation guide:
# 1. Large lambda difference: series-specific smoothing important
# 2. Positive sd_diff: HP under-estimates cycle volatility
# 3. Large ar1_diff: affects model calibrationThe first-order conditions of the minimization problem:
\[\frac{\partial}{\partial g} \left[ (y-g)'(y-g) + \lambda g'A g \right] = 0\]
\[-2(y-g) + 2\lambda A g = 0\]
\[(I + \lambda A)g = y\]
\[g = (I + \lambda A)^{-1} y\]
The matrix \(A = K'K\) is a symmetric, positive semi-definite band matrix:
\[A = \begin{pmatrix} 1 & -2 & 1 & 0 & 0 & \cdots \\ -2 & 5 & -4 & 1 & 0 & \cdots \\ 1 & -4 & 6 & -4 & 1 & \cdots \\ 0 & 1 & -4 & 6 & -4 & \cdots \\ \vdots & & & \ddots & & \\ \end{pmatrix}\]
This banded structure allows efficient computation using sparse matrix methods (not exploited in this implementation, but potential for future optimization).
Starting from:
\[GCV(\lambda) = \frac{T^{-1} \|y - \hat{g}\|^2}{(1 - T^{-1}\text{tr}(B))^2}\]
Using \((1-x)^{-2} \approx 1 + 2x\) for small \(x\):
\[GCV(\lambda) \approx T^{-1} \|y - \hat{g}\|^2 (1 + 2T^{-1}\text{tr}(B))\]
With Silverman’s approximation \(\text{tr}(B) \approx T/\lambda\):
\[GCV(\lambda) \approx T^{-1}(1 + 2T/\lambda) \|y - \hat{g}\|^2\]
Choudhary, M.A., Hanif, M.N., & Iqbal, J. (2014). On smoothing macroeconomic time series using the modified HP filter. Applied Economics, 46(19), 2205-2214.
Craven, P., & Wahba, G. (1979). Smoothing noisy data with spline functions. Numerische Mathematik, 31, 377-403.
Hodrick, R.J., & Prescott, E.C. (1997). Postwar US business cycles: An empirical investigation. Journal of Money, Credit and Banking, 29(1), 1-16.
Marcet, A., & Ravn, M.O. (2004). The HP-filter in cross-country comparisons. CEPR Discussion Paper No. 4244.
McDermott, C.J. (1997). An automatic method for choosing the smoothing parameter in the HP filter. Unpublished manuscript, IMF.
Ravn, M.O., & Uhlig, H. (2002). On adjusting the Hodrick-Prescott filter for the frequency of observations. Review of Economics and Statistics, 84(2), 371-376.
Silverman, B.W. (1984). A fast and efficient cross-validation method for smoothing parameter choice in spline regression. Journal of the American Statistical Association, 79, 584-589.
These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.