The trend extraction methods covered in the other vignettes return a
smooth trend. Often, though, the trend is exactly the part we want to
remove: in macroeconomics the object of interest is usually the
deviation from trend — the cycle, or the
output gap when the series measures aggregate activity.
detrend_series() removes the trend from a series and adds
the detrended series as a column to the original data frame.
The theme below is used throughout the vignette for consistent styling.
library(ggplot2)
theme_series <- theme_minimal(paper = "#fefefe") +
theme(
legend.position = "bottom",
panel.grid.minor = element_blank(),
strip.background = element_rect(fill = "#2c3e50"),
strip.text = element_text(color = "#fefefe"),
axis.ticks.x = element_line(color = "gray40", linewidth = 0.5),
axis.line.x = element_line(color = "gray40", linewidth = 0.5),
axis.title.x = element_blank(),
palette.colour.discrete = c(
"#2c3e50",
"#e74c3c",
"#f39c12",
"#1abc9c",
"#9b59b6"
)
)detrend_series() is the mirror image of
augment_trends().
augment_trends() returns the trend
(trend_* columns) and discards the fluctuations around
it.detrend_series() returns the
fluctuations (detrend_* columns): the trend is
fitted with the same methods and then subtracted from the series, so the
exact identity value = trend + detrend holds.Any of the 20 trend methods supported by
augment_trends() can be used for detrending. The default is
the Hodrick-Prescott filter ("hp"), the
most common detrending choice for economic data, with the smoothing
parameter set automatically from the frequency of the series. And unlike
decompose_series(), which needs a seasonal component to
isolate, detrending is well defined at any frequency.
Let’s start with the ibcbr dataset, a monthly index of
Brazilian economic activity (IBC-Br) compiled by the Central Bank.
ggplot(ibcbr, aes(date, index)) +
geom_line(lwd = 0.7) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(
title = "Brazilian economic activity (IBC-Br)",
y = "Index (2003 = 100)"
) +
theme_seriesPassing the data to detrend_series() adds a single new
column, detrend_hp, holding the deviation from the HP
trend. The frequency is detected automatically from the date column.
ibcbr_cycle <- ibcbr |>
detrend_series(value_col = "index")
ibcbr_cycle
#> # A tibble: 276 × 3
#> date index detrend_hp
#> <date> <dbl> <dbl>
#> 1 2003-01-01 67.1 -1.93
#> 2 2003-02-01 68.8 -0.482
#> 3 2003-03-01 72.2 2.53
#> 4 2003-04-01 71.3 1.37
#> 5 2003-05-01 70.0 -0.250
#> 6 2003-06-01 68.8 -1.75
#> 7 2003-07-01 71.9 1.07
#> 8 2003-08-01 70.8 -0.332
#> 9 2003-09-01 71.8 0.344
#> 10 2003-10-01 73.3 1.57
#> # ℹ 266 more rowsggplot(ibcbr_cycle, aes(date, detrend_hp)) +
geom_hline(yintercept = 0, color = "gray40", lty = 2) +
geom_line(lwd = 0.7) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(
title = "Detrended economic activity",
subtitle = "Deviation from the HP trend",
y = "Index points"
) +
theme_seriesThe big picture is right — the 2008–09 recession, the 2015–16 crisis, and the COVID collapse all show up as deep negative deviations. But the line is also covered in a regular saw-tooth pattern. That is not the business cycle: it is seasonality, and it points to an important caveat.
The IBC-Br index above is not seasonally adjusted, and detrending only removes the slow-moving part of the series. The seasonal swings are too fast for the trend to absorb, so they end up in the detrended series, where they can drown out — or be mistaken for — cyclical movements.
The fix is to remove the seasonal component first and detrend the
seasonally adjusted series. The two wrappers compose naturally:
deseason_series() adds a seasadj_stl column,
which detrend_series() can then take as its input.
ibcbr_sa_cycle <- ibcbr |>
deseason_series(value_col = "index") |>
detrend_series(value_col = "seasadj_stl")
ibcbr_sa_cycle
#> # A tibble: 276 × 4
#> date index seasadj_stl detrend_hp
#> <date> <dbl> <dbl> <dbl>
#> 1 2003-01-01 67.1 71.4 2.00
#> 2 2003-02-01 68.8 72.5 2.77
#> 3 2003-03-01 72.2 68.7 -1.30
#> 4 2003-04-01 71.3 71.1 0.918
#> 5 2003-05-01 70.0 70.5 -0.0433
#> 6 2003-06-01 68.8 69.6 -1.16
#> 7 2003-07-01 71.9 69.0 -2.06
#> 8 2003-08-01 70.8 68.1 -3.26
#> 9 2003-09-01 71.8 71.6 -0.0420
#> 10 2003-10-01 73.3 72.0 0.0601
#> # ℹ 266 more rowsWe can compare the approaches side by side.
cycles <- bind_rows(
list(
"Raw series" = ibcbr_cycle,
"Seasonally adjusted" = ibcbr_sa_cycle
),
.id = "input"
)
cycles <- cycles |>
rename(cycle = detrend_hp)ggplot(cycles, aes(date, cycle)) +
geom_hline(yintercept = 0, color = "gray40", lty = 2) +
geom_line(aes(color = input), lwd = 0.7, show.legend = FALSE) +
facet_wrap(vars(input), ncol = 1) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(
title = "Detrending before and after seasonal adjustment",
subtitle = "HP deviations from trend",
y = "Index points"
) +
theme_seriesThe seasonally adjusted cycle tells the same story more clearly. For seasonal data this two-step workflow — deseason, then detrend — should be the default.
The ibcbr series in measured in index points, which can
make it hard to comapre across series or different time periods. A
common solution in macroeconomics is to report deviations as a
percentage of the trend — this is how output gaps are usually
stated.
Setting transform = "log" fits the trend on the log
scale and returns the log deviation from trend,
log(value) - log(trend). Multiplied by 100, this is
approximately the percentage deviation. On the original scale the
identity becomes multiplicative:
value = trend * exp(detrend).
ibcbr_gap <- ibcbr |>
deseason_series(value_col = "index") |>
detrend_series(value_col = "seasadj_stl", transform = "log")ggplot(ibcbr_gap, aes(date, detrend_hp)) +
geom_hline(yintercept = 0, color = "gray40", lty = 2) +
geom_line(lwd = 0.7) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
scale_y_continuous(labels = scales::percent) +
labs(
title = "An HP-filter activity gap",
subtitle = "Log deviation from trend of the seasonally adjusted IBC-Br (x 100)",
y = "% of trend"
) +
theme_seriesThe COVID trough now reads directly as “activity was about 13% below trend”.
As with similar functions in trendseries, the
methods argument accepts multiple methods in a single
function call. Since the actual “cycle” is not observed, different
filters take different stances on what counts as “trend”.
Passing several methods adds one detrend_* column per
method, so the implied cycles can be compared side by side. Here we
contrast the HP filter with the Hamilton filter, a
regression-based alternative proposed as an alternative for the HP
filter.
ibcbr_methods <- ibcbr |>
deseason_series(value_col = "index") |>
detrend_series(
value_col = "seasadj_stl",
methods = c("hp", "hamilton"),
transform = "log"
)methods_long <- ibcbr_methods |>
pivot_longer(
cols = starts_with("detrend_"),
names_to = "method",
names_prefix = "detrend_",
values_to = "cycle"
)ggplot(methods_long, aes(date, 100 * cycle)) +
geom_hline(yintercept = 0, color = "gray40", lty = 2) +
geom_line(aes(color = method), lwd = 0.7) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(
title = "HP vs Hamilton detrending",
subtitle = "Log deviation from trend (x 100)",
y = "% of trend",
color = NULL
) +
theme_seriesThe two cycles agree on the major swings but differ in amplitude and
timing — a useful reminder that detrended series are estimates, not
data. Note also that the Hamilton filter projects the series two years
ahead from a year of lags (h = 24, p = 12 for
monthly data), so its first three years of detrended values are missing;
methods with boundary effects (such as "bk") behave
similarly at both ends.
The unified parameters of augment_trends() —
window, smoothing, band,
align, and params — all pass through
unchanged. For instance, the Baxter-King filter isolates fluctuations
between 1.5 and 8 years directly:
ibcbr |>
deseason_series(value_col = "index") |>
detrend_series(
value_col = "seasadj_stl",
methods = "bk",
band = c(18, 96) # periods in months
)The Econometric Filters vignette discusses what each of these filters does and how to choose between them.
By default only the detrended column is added. Setting
components = TRUE also keeps the fitted
trend_* columns, which is handy for plotting the trend
against the series or for verifying the identity.
ibcbr_parts <- ibcbr |>
detrend_series(value_col = "index", components = TRUE)
all.equal(ibcbr_parts$trend_hp + ibcbr_parts$detrend_hp, ibcbr_parts$index)
#> [1] TRUEggplot(ibcbr_parts, aes(date)) +
geom_line(aes(y = index, color = "Observed"), lwd = 0.7, alpha = 0.5) +
geom_line(aes(y = trend_hp, color = "Trend (HP)"), lwd = 1) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(
title = "Economic activity and its HP trend",
y = "Index (2003 = 100)",
color = NULL
) +
theme_seriesWith transform = "log" the trend is reported back in the
units of the series, so the same plot works unchanged; the identity is
then value = trend * exp(detrend).
Like the other functions in the package,
detrend_series() accepts a group_cols argument
to detrend several series at once. The full workflow — seasonal
adjustment followed by detrending, in percent of trend — carries over
group by group. Here we use the electricity dataset, which
records monthly electricity consumption for three sectors.
elec_cycles <- electricity |>
dplyr::filter(date >= as.Date("2003-01-01")) |>
deseason_series(group_cols = "name_series") |>
detrend_series(
value_col = "seasadj_stl",
group_cols = "name_series",
transform = "log"
)
glimpse(elec_cycles)
#> Rows: 828
#> Columns: 5
#> $ date <date> 2003-01-01, 2003-02-01, 2003-03-01, 2003-04-01, 2003-05-0…
#> $ name_series <chr> "electric_commercial", "electric_commercial", "electric_co…
#> $ value <dbl> 4182, 4153, 4221, 3948, 3938, 3711, 3667, 3720, 3686, 4001…
#> $ seasadj_stl <dbl> 3862.461, 3819.806, 3778.933, 3678.709, 4063.355, 4147.311…
#> $ detrend_hp <dbl> 0.004224489, -0.011316838, -0.026511318, -0.057827607, 0.0…ggplot(elec_cycles, aes(date, detrend_hp)) +
geom_hline(yintercept = 0, color = "gray40", lty = 2) +
geom_line(color = "#2c3e50", lwd = 0.7) +
facet_wrap(vars(name_series), ncol = 1) +
scale_x_date(date_breaks = "5 years", date_labels = "%Y") +
scale_y_continuous(labels = scales::percent) +
labs(
title = "Electricity consumption cycles by sector",
subtitle = "Log deviation from HP trend of the seasonally adjusted series (x 100)",
y = "% of trend"
) +
theme_seriesCommercial consumption swings the hardest — the collapse of in-person services during COVID stands out — while industrial demand stays closest to its trend.
detrend_series() removes the trend from a series,
adding a detrend_{method} column with the deviation from
trend (the cycle). The exact identity
value = trend + detrend holds.augment_trends() can be
used; the default is the HP filter with frequency-appropriate
smoothing.deseason_series() first and detrend the
seasadj_* column.transform = "log" for deviations in percent of
trend (the output-gap convention); the identity becomes
value = trend * exp(detrend).components = TRUE keeps the fitted trend columns
alongside the detrended series.