Time Series Decomposition

Author

Rami Krispin

Published

July 3, 2025

This section focuses on time series decomposition. It is a technique used to break down a time series into its constituent components:

The irregular component

On ideal time series, the trend and seasonal components explain the total variation of the series. Therefore, the irregular component should be white noise - hench a time series with no correlation or any patterns. The reality is that often there are other patterns in the series that cannot be explained by the trend and seasonal components alone. In this case, the irregular component can contain valuable information about the series.

There are mainly two common techniques for time series decomposition:

We will use the

Required Libraries

library(feasts)
library(fabletools)
library(tsibble)
library(dplyr)
library(plotly)

Load Data

load(file = "./data/ts.RData")

Let’s look at our data. For this section we will use the US demand for natural gas (ts2):

head(ts2)
# A tsibble: 6 x 4 [1M]
     index date             y series_id
     <mth> <date>       <int> <chr>    
1 2001 Jan 2001-01-01 2505011 NUS      
2 2001 Feb 2001-02-01 2156873 NUS      
3 2001 Mar 2001-03-01 2086568 NUS      
4 2001 Apr 2001-04-01 1663832 NUS      
5 2001 May 2001-05-01 1385163 NUS      
6 2001 Jun 2001-06-01 1313119 NUS      

Let’s plot the series with Plotly:

ts2 |> plot_ly() |> 
add_lines(x = ~ date, 
    y = ~ y) |>
    layout(title = "Natural Gas Delivered to Consumers in the U.S.",
    xaxis = list(title = "Source: EIA Webstie"),
    yaxis = list(title = "MMCF")) 

Classical Decomposition

The classical decomposition method is simple and widely used in time series analysis to decompose a time series into its three components - trend, seasonal and irregular components. The decompose function from the stats library provides an implementation of this method. For this demonstration, we will use the classical_decomposition function from the feasts library.

The calculation of the classical decomposition is straightforward and includes the following steps:

  • Step 1 - Estimate the trend component: Use smoothing method to remove the seasonal component from the series. This typically involve a moving average or a weighted moving average method. The rule of thumb is to use a centered moving average, where the number of periods to be averaged is equal to the series frequency. For example, for a monthly time series we can use a centered 12-month moving average: \[ \hat{X_t} = \frac{Y_{t-6} + Y_{t-5} + ... + Y_{t+5}}{12} \]

    Where \(X_t\) represents the smoothed value of the time series at time t. The smoothing removes (or reduce significantly) the seasonal and irregular component from the time series and leaves the trend estimate.

  • Step 2 - Detrend the series: Subtract the estimated trend component from the original time series to obtain a detrended series, which represents the seasonal and irregular components for additive model: \[ \hat{D_t} = Y_t - \hat{X_t} \] And in the case of multiplicative model: \[ \hat{D_t} = \frac{Y_t}{\hat{X_t}} \] Where \(D_t\) is the detrended value of the time series at time t.

  • Step 3 - Estimate the seasonal component: Group the detrend series by the frequency period and compute the mean for each group. For example, if the data has a monthly frequency, then you will group all the values for the month of January together to get the average value for that month. This represents by the following notation:

    \[ \hat{S_m} = \frac{1}{n} \sum_{i=0}^{n} \hat{D}_{m+i \times k} \] Where \(S_m\) is the seasonal component of the time series at month m, and \(k\) represents the number of periodic cycles (in this case years).

  • Step 4 - Estimate the irregular component: Subtract from the series the trend and seasonal estimates to extract the irregular estimation for additive model: \[ \hat{I_t} = Y_t - \hat{T_t} - \hat{S_t} \] And in the case of multiplicative model: \[ \hat{I_t} = \frac{Y_t}{\hat{D_t} \times \hat{S_t}} \]

Let’s use the classical_decomposition function to decompose the series:

ts_classic_d <- ts2 |>
  model(
    classical_decomposition(y, type = "additive"),
  ) |>
  components()


head(ts_classic_d, 10)
# A dable: 10 x 7 [1M]
# Key:     .model [1]
# :        y = trend + seasonal + random
   .model                    index      y   trend seasonal  random season_adjust
   <chr>                     <mth>  <int>   <dbl>    <dbl>   <dbl>         <dbl>
 1 "classical_decomposit… 2001 Jan 2.51e6 NA       751772.     NA       1753239.
 2 "classical_decomposit… 2001 Feb 2.16e6 NA       435854.     NA       1721019.
 3 "classical_decomposit… 2001 Mar 2.09e6 NA       237156.     NA       1849412.
 4 "classical_decomposit… 2001 Apr 1.66e6 NA      -197766.     NA       1861598.
 5 "classical_decomposit… 2001 May 1.39e6 NA      -361885.     NA       1747048.
 6 "classical_decomposit… 2001 Jun 1.31e6 NA      -351039.     NA       1664158.
 7 "classical_decomposit… 2001 Jul 1.46e6  1.70e6 -166527. -73741.      1626446.
 8 "classical_decomposit… 2001 Aug 1.53e6  1.69e6 -158210.  -3001.      1686693.
 9 "classical_decomposit… 2001 Sep 1.36e6  1.69e6 -368944.  42443.      1729815.
10 "classical_decomposit… 2001 Oct 1.51e6  1.69e6 -286644. 103323.      1794072.

As you can notice, the trend component is missing its first 6 observations as the function is using a centered moving average method which requires the previous and future 6 observations for a monthly time series (i.e., frequency of 12). Let’s plot the output:

d <- ts_classic_d |>
dplyr::mutate(date = as.Date(index))
color <- "#0072B5"

dec_attr <- attributes(ts_classic_d)

series <- d |> plotly::plot_ly(x = ~date, y = ~y, type = "scatter", mode = "lines", line = list(color = color), name = "Actual", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Actial"))

trend <- d |> plotly::plot_ly(x = ~date, y = ~trend, type = "scatter", mode = "lines", line = list(color = color), name = "Trend", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Trend"))

seasonal <- d |> plotly::plot_ly(x = ~date, y = ~seasonal, type = "scatter", mode = "lines", line = list(color = color), name = "Seasonal", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Seasonal"))

seasonal_adj <- d |> plotly::plot_ly(x = ~date, y = ~season_adjust, type = "scatter", mode = "lines", line = list(color = color), name = "Seasonal Adjusted", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Seasonal Adjusted"))


irregular <- d |> plotly::plot_ly(x = ~date, y = ~random, 
type = "scatter", mode = "lines", 
line = list(color = color), name = "Irregular", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Irregular"))

plotly::subplot(series, trend, seasonal, seasonal_adj, irregular, 
nrows = 5, titleY = TRUE, shareX = TRUE) |>
plotly::layout(xaxis = list(title = paste("Decomposition Method: ", dec_attr$method, sep = "")))

The downside of the classical method that it cannot handle well fluctuations in the seasonal component over time as it average across all periods. The next method - STL decomposition has more robust approach for handling changes in the seasonal component over time.

STL Decomposition

The Seasonal and Trend decomposition using Loess (STL) method is an alternative and robust approach for decomposing a time series into its components. Unlike the classical method, the STL method uses a locally weighted regression to calculate the seasonal component. The advantages of this approach over the classical method are: - Can handle changes in the seasonal component over time - Can handle time series with multiple seasonality components - Provides the user with control over the trend smoothing and the seasonal window length - No loss of observations during the trend estimate process

Like the classical method, both the stats and feasts libraries provide an implementation of the STL method with the stl and STL functions, respectively. In the following example, we will use the STL function:

ts_stl_d <- ts2 |>
  model(
    STL(y ~ trend(window = 11) +
                   season(window = 7),
    robust = TRUE),
  ) |>
  components()


head(ts_stl_d, 10)
# A dable: 10 x 7 [1M]
# Key:     .model [1]
# :        y = trend + season_year + remainder
   .model                index      y  trend season_year remainder season_adjust
   <chr>                 <mth>  <int>  <dbl>       <dbl>     <dbl>         <dbl>
 1 STL(y ~ trend(win… 2001 Jan 2.51e6 1.77e6     772173.   -37408.      1732838.
 2 STL(y ~ trend(win… 2001 Feb 2.16e6 1.76e6     362937.    34995.      1793936.
 3 STL(y ~ trend(win… 2001 Mar 2.09e6 1.75e6     316142.    22791.      1770426.
 4 STL(y ~ trend(win… 2001 Apr 1.66e6 1.74e6     -86486.    12262.      1750318.
 5 STL(y ~ trend(win… 2001 May 1.39e6 1.73e6    -321116.   -22197.      1706279.
 6 STL(y ~ trend(win… 2001 Jun 1.31e6 1.72e6    -361195.   -47107.      1674314.
 7 STL(y ~ trend(win… 2001 Jul 1.46e6 1.71e6    -219906.   -34540.      1679825.
 8 STL(y ~ trend(win… 2001 Aug 1.53e6 1.72e6    -192096.      443.      1720579.
 9 STL(y ~ trend(win… 2001 Sep 1.36e6 1.73e6    -373660.     8623.      1734531.
10 STL(y ~ trend(win… 2001 Oct 1.51e6 1.73e6    -263392.    40871.      1770820.
d <- ts_stl_d |>
dplyr::mutate(date = as.Date(index))
color <- "#0072B5"

dec_attr <- attributes(ts_stl_d)

series <- d |> plotly::plot_ly(x = ~date, y = ~y, type = "scatter", mode = "lines", line = list(color = color), name = "Actual", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Actial"))

trend <- d |> plotly::plot_ly(x = ~date, y = ~trend, type = "scatter", mode = "lines", line = list(color = color), name = "Trend", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Trend"))

seasonal <- d |> plotly::plot_ly(x = ~date, y = ~season_year, type = "scatter", mode = "lines", line = list(color = color), name = "Seasonal", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Seasonal"))

seasonal_adj <- d |> plotly::plot_ly(x = ~date, y = ~season_adjust, type = "scatter", mode = "lines", line = list(color = color), name = "Seasonal Adjusted", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Seasonal Adjusted"))


irregular <- d |> plotly::plot_ly(x = ~date, y = ~remainder, 
type = "scatter", mode = "lines", 
line = list(color = color), name = "Irregular", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Irregular"))

plotly::subplot(series, trend, seasonal, seasonal_adj, irregular, 
nrows = 5, titleY = TRUE, shareX = TRUE) |>
plotly::layout(xaxis = list(title = paste("Decomposition Method: ", dec_attr$method, sep = "")))

The Irregular Component

Unfortunately, most of us are not that lucky to get the “perfect” time series data that can be explained solely by its trend and seasonal components. In reality, some of the variation and patterns of time series are related to outliers, non-seasonal recurring events, structural breaks, and similar patterns. In this case, the irregular can help us reveal some patterns. Here is a useful trick I love adding to the decomposition plot - identify outliers in the irregular components and map them back to the series.

To do so, we will calculate the irregular component standard deviation and classify it between 2 and 3 SD and above:

sdv <- sd(ts_stl_d$remainder)

ts_stl_d <- ts_stl_d |>
 dplyr::mutate(sd3 = ifelse(remainder >= 3 * sdv | remainder <= -3 * sdv, y, NA ),
                sd2 = ifelse(remainder >= 2 * sdv & remainder < 3 * sdv |  remainder <= -2 * sdv &  remainder >  -3 * sdv, y, NA))
 
 
table(!is.na(ts_stl_d$sd2))

FALSE  TRUE 
  270    22 
table(!is.na(ts_stl_d$sd3))  

FALSE  TRUE 
  286     6 

Let’s now add it to the plot:

d <- ts_stl_d |>
dplyr::mutate(date = as.Date(index))
color <- "#0072B5"

dec_attr <- attributes(ts_stl_d)
 
series <- d |> plotly::plot_ly(x = ~date, y = ~y, type = "scatter", mode = "lines", line = list(color = color), name = "Actual", showlegend = FALSE) |>
plotly::add_trace(x = ~ date, y = ~ sd2, marker = list(color = "orange")) |>
plotly::add_trace(x = ~ date, y = ~ sd3, marker = list(color = "red")) |>
plotly::layout(yaxis = list(title = "Actial"))

trend <- d |> plotly::plot_ly(x = ~date, y = ~trend, type = "scatter", mode = "lines", line = list(color = color), name = "Trend", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Trend"))

seasonal <- d |> plotly::plot_ly(x = ~date, y = ~season_year, type = "scatter", mode = "lines", line = list(color = color), name = "Seasonal", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Seasonal"))

seasonal_adj <- d |> plotly::plot_ly(x = ~date, y = ~season_adjust, type = "scatter", mode = "lines", line = list(color = color), name = "Seasonal Adjusted", showlegend = FALSE) |>
plotly::layout(yaxis = list(title = "Seasonal Adjusted"))


irregular <- d |> plotly::plot_ly(x = ~date, y = ~remainder, 
type = "scatter", mode = "lines", 
line = list(color = color), name = "Irregular", showlegend = FALSE) |>
plotly::add_segments(x = min(d$date), 
xend = max(d$date), 
y = 2 * sdv, 
yend = 2 * sdv, 
name = "2SD",
line = list(color = "orange", dash = "dash")) |>
plotly::add_segments(x = min(d$date), 
xend = max(d$date), 
y = - 2 * sdv, 
yend = - 2 * sdv, 
name = "-2SD",
line = list(color = "orange", dash = "dash")) |>
plotly::add_segments(x = min(d$date), 
xend = max(d$date), 
y = 3 * sdv, 
yend = 3 * sdv, 
name = "3SD",
line = list(color = "red", dash = "dash")) |>
plotly::add_segments(x = min(d$date), 
xend = max(d$date), 
y = -3 * sdv, 
yend = -3 * sdv, 
name = "-3SD",
line = list(color = "red", dash = "dash")) |>
plotly::layout(yaxis = list(title = "Irregular"))

plotly::subplot(series, trend, seasonal, seasonal_adj, irregular, 
nrows = 5, titleY = TRUE, shareX = TRUE) |>
plotly::layout(xaxis = list(title = paste("Decomposition Method: ", dec_attr$method, sep = "")))