Seasonal Decomposition: Understanding Time Series Components

Author

AS

Published

September 29, 2025

Seasonal Decomposition

What is Seasonal Decomposition?

Seasonal decomposition is a fundamental technique in time series analysis that breaks down a complex time series into its underlying components. Think of it as separating the “signal from the noise” to understand what’s really driving your data.

The Three Core Components

Trend (T): The long-term underlying direction of your data

  • Shows whether values are generally increasing, decreasing, or stable over time
  • Reveals the “big picture” movement stripped of short-term fluctuations
  • Example: Overall growth in company sales over several years

Seasonal (S): Predictable, recurring patterns that repeat at fixed intervals

  • Daily patterns (rush hour traffic), weekly patterns (weekend sales spikes), monthly patterns (holiday shopping), quarterly patterns (business cycles)
  • These patterns are systematic and predictable
  • Example: Ice cream sales consistently peak every summer

Remainder/Irregular (R): Everything else - the unpredictable component

  • Random fluctuations, one-off events, measurement errors
  • What’s left after removing trend and seasonal effects
  • Contains both true randomness and patterns we haven’t captured
  • Example: Sales spike due to viral social media post

Why Decompose? The Business Value

1. Clarity Through Separation

Raw time series data is often noisy and hard to interpret. Decomposition reveals the true underlying patterns by isolating each component.

Real-world insight: If your monthly revenue is declining, is it due to a genuine business problem (trend) or just normal seasonal variation? Decomposition tells you.

2. Superior Forecasting

Instead of trying to predict the complex original series, you can: - Model each component separately using appropriate techniques - Combine forecasts for more accurate predictions - Apply domain knowledge to each component

Example: Use linear regression for trend, seasonal naïve for seasonal patterns, and ARIMA for remainder.

3. Intelligent Anomaly Detection

Unusual values in the remainder component indicate genuine anomalies, not just seasonal highs/lows.

Business application: A retail company can detect if a sales dip is abnormal (remainder spike) versus expected (seasonal pattern).

4. Strategic Business Insights

  • Seasonally adjusted data shows true business performance
  • Separate seasonal effects from genuine growth/decline
  • Make fair comparisons across different time periods

The Two Fundamental Models

Additive Model: Y(t) = T(t) + S(t) + R(t)

When to use: Seasonal fluctuations remain constant in absolute terms regardless of the trend level.

Visual clue: The seasonal “waves” have the same height throughout the series, even as the overall level changes.

Example: Monthly temperature variations (always varies by ~20°C) or daily website traffic patterns (consistent patterns regardless of growth).

# Seasonal variation stays constant (~same amplitude)
# Jan always ~1000 units below average, July always ~1000 above

Multiplicative Model: Y(t) = T(t) × S(t) × R(t)

When to use: Seasonal fluctuations are proportional to the current trend level.

Visual clue: Seasonal “waves” get bigger as the trend increases, smaller as it decreases.

Example: Retail sales where holiday peaks grow proportionally with business size, or stock returns where volatility scales with price level.

# Seasonal variation scales with trend
# When baseline is 1000: Jan -10%, July +15%  
# When baseline is 5000: Jan -10% (500 units), July +15% (750 units)

Practical R Implementation

Registered S3 method overwritten by 'tsibble':
  method               from 
  as_tibble.grouped_df dplyr
── Attaching packages ──────────────────────────────────────────── fpp3 1.0.2 ──
✔ tibble      3.3.0     ✔ tsibble     1.1.6
✔ dplyr       1.1.4     ✔ tsibbledata 0.4.1
✔ tidyr       1.3.1     ✔ feasts      0.4.2
✔ lubridate   1.9.4     ✔ fable       0.4.1
✔ ggplot2     3.5.2     
── Conflicts ───────────────────────────────────────────────── fpp3_conflicts ──
✖ lubridate::date()    masks base::date()
✖ dplyr::filter()      masks stats::filter()
✖ tsibble::intersect() masks base::intersect()
✖ tsibble::interval()  masks lubridate::interval()
✖ dplyr::lag()         masks stats::lag()
✖ tsibble::setdiff()   masks base::setdiff()
✖ tsibble::union()     masks base::union()
library(dplyr)

# Load and prepare Australian tourism data
tourism <- tourism |> 
  summarise(Trips = sum(Trips)) |>
  mutate(Quarter = yearquarter(Quarter))

glimpse(tourism)
Rows: 80
Columns: 2
$ Quarter <qtr> 1998 Q1, 1998 Q2, 1998 Q3, 1998 Q4, 1999 Q1, 1999 Q2, 1999 Q3,…
$ Trips   <dbl> 23182.20, 20323.38, 19826.64, 20830.13, 22087.35, 21458.37, 19…
# First, always visualize your raw data
tourism |> 
  autoplot(Trips) +
  labs(title = "Australian Tourism: Raw Time Series",
       subtitle = "Look for trend and seasonal patterns",
       y = "Total Trips")

Method 1: Classical Decomposition

Classical decomposition is the traditional approach - simple but has limitations.

# Additive decomposition
tourism_add <- tourism |> 
  model(classical_decomposition(Trips, type = "additive")) |>
  components()

# Multiplicative decomposition  
tourism_mult <- tourism |> 
  model(classical_decomposition(Trips, type = "multiplicative")) |>
  components()

# Compare both models

# Additive decomposition
tourism |> 
  model(classical_decomposition(Trips, type = "additive")) |>
  components() |> 
  autoplot() +
  labs(title = "Classical Additive Decomposition")
Warning: Removed 2 rows containing missing values or values outside the scale range
(`geom_line()`).

# Multiplicative decomposition
tourism |> 
  model(classical_decomposition(Trips, type = "multiplicative")) |>
  components() |> 
  autoplot() +
  labs(title = "Classical Multiplicative Decomposition")
Warning: Removed 2 rows containing missing values or values outside the scale range
(`geom_line()`).

Classical Method Limitations: - Only handles additive or multiplicative models - Cannot handle missing values - Trend estimates poor at series endpoints - Assumes seasonal component is perfectly regular - Less robust to outliers

Classical Decomposition

Classical decomposition is one of the oldest methods for decomposing time series.

Moving Averages

The trend-cycle component is estimated using moving averages:

# Calculate moving averages for tourism data (quaterly data)

tourism_ma <- tourism |>
  mutate(
    `4-MA` = slider::slide_dbl(Trips, mean, .before = 1, .after = 2, .complete = TRUE),
    `2x4-MA` = slider::slide_dbl(`4-MA`, mean, .before = 1, .after = 0, .complete = TRUE)
  )

tourism_ma |>
  autoplot(Trips, colour = "gray") +
  geom_line(aes(y = `2x4-MA`), colour = "#D55E00", size = 1) +
  labs(y = "Trips (millions)",
       title = "Australian tourism data",
       subtitle = "with 2×4 moving average overlay")
Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
ℹ Please use `linewidth` instead.
Warning: Removed 4 rows containing missing values or values outside the scale range
(`geom_line()`).

Important

Average of Q1, Q2, Q3, Q4 → Where does this go? Between Q2 and Q3?

We want it to sit exactly on Q2 or Q3.

Solution (2×4-MA):

  • Calculate a 4-quarter average

  • Calculate another 4-quarter average (shifted by 1)

  • Average those two averages

Result: The final number sits exactly on a quarter, not between quarters.

Think of it like this:

  • Simple 4-average = finding the middle of 4 people standing in a line

  • 2×4-average = adjusting so the “middle” lands exactly on one person

We still use 4 quarters total, just arranged to center properly on each time period.

Classical Decomposition Method

# Apply classical decomposition
tourism |>
  model(
    classical_decomposition(Trips, type = "additive")
  ) |>
  components() |>
  autoplot() +
  labs(title = "Classical additive decomposition of Australian tourism")
Warning: Removed 2 rows containing missing values or values outside the scale range
(`geom_line()`).

Pros:

  • Simple and intuitive
  • Fast computation
  • Easy to understand

Cons:

  • Cannot handle missing values
  • Poor trend estimates at endpoints
  • Assumes seasonal component is constant over time
  • Not robust to outliers

X-11 and SEATS Decomposition

More sophisticated methods include X-111 and SEATS (Signal extraction in ARIMA time series) decomposition:

# X-11 decomposition
x11_dcmp <- tourism |>
  model(x11 = X_13ARIMA_SEATS(Trips ~ x11())) |>
  components()

x11_dcmp |>
  autoplot() +
  labs(title = "X-11 decomposition of Australian tourism")

X-11 Pros:

  • Better trend estimation than classical
  • Handles irregularities well
  • Industry standard for official statistics
  • More sophisticated outlier detection

X-11 Cons:

  • Complex algorithm
  • Requires specialized software
  • Less transparent than simpler methods
  • Many parameters to tune

# SEATS decomposition
seats_dcmp <- tourism |>
  model(seats = X_13ARIMA_SEATS(Trips ~ seats())) |>
  components()

seats_dcmp |>
  autoplot() +
  labs(title = "SEATS decomposition of Australian tourism")

SEATS Pros:

  • Model-based approach using ARIMA
  • Theoretically sound
  • Good for forecasting
  • Handles complex seasonal patterns

SEATS Cons:

  • Complex parameter selection
  • Requires ARIMA modeling expertise
  • May be overfitted for simple series
  • Computationally intensive

STL Decomposition

STL (Seasonal and Trend decomposition using Loess) is a versatile and robust method:

# STL decomposition
tourism |>
  model(
    STL(Trips ~ trend(window = 7) + 
                season(window = "periodic"),
        robust = TRUE)) |>
  components() |>
  autoplot() +
  labs(title = "STL decomposition of Australian tourism")

STL Features

# Varying STL parameters
stl_models <- tourism |>
  model(
    `STL(13)` = STL(Trips ~ trend(window = 13) + season(window = 13)),
    `STL(49)` = STL(Trips ~ trend(window = 49) + season(window = 13)),
    `STL(periodic)` = STL(Trips ~ season(window = "periodic"))
  ) |>
  components()

# Plot the different STL decompositions
stl_models |>
  autoplot() +
  facet_wrap(~ .model, ncol = 1) +
  labs(title = "STL decomposition with different parameters")

STL Window Size: Controlling Smoothness vs Flexibility

Trend window

  • Small (13) → Flexible, follows data closely (≈ 3 years of quarterly data)
  • Large (49) → Very smooth, ignores short-term bumps (≈ 12 years of quarterly data)

Season window

  • Small (13) → Seasonal pattern can evolve over time
  • periodic → Seasonality is fixed, same every year (baseline case)

Why these numbers?

  • STL(13): ≈ 3 years → captures local trend and seasonal changes
  • STL(49): ≈ 12 years → highlights only long-term trend
  • STL(periodic): assumes seasonal pattern never changes

Rule of thumb

  • Smaller windows → More flexible, captures short-term variation
  • Larger windows → Smoother, only long-term patterns remain
  • periodic → Seasonality is fixed across the whole sample

STL Pros:

  • Can handle any type of seasonality

  • Seasonal component can vary over time

  • Robust to outliers

  • Can be used with any frequency of data

  • Flexible parameter control

  • Handles missing values well

STL Cons:

  • Only provides additive decomposition

  • Can be sensitive to parameter choices

  • More complex than classical methods

  • Requires understanding of loess smoothing

Comparison of All Methods

# Apply all four methods to the same data
all_decomp <- tourism |>
  model(
    Classical = classical_decomposition(Trips, type = "additive"),
    X11 = X_13ARIMA_SEATS(Trips ~ x11()),
    SEATS = X_13ARIMA_SEATS(Trips ~ seats()),
    STL = STL(Trips)
  ) |>
  components()

# Create individual plots
p1 <- tourism |>
  model(classical_decomposition(Trips, type = "additive")) |>
  components() |>
  autoplot() +
  labs(title = "Classical Decomposition")

p2 <- tourism |>
  model(X_13ARIMA_SEATS(Trips ~ x11())) |>
  components() |>
  autoplot() +
  labs(title = "X-11 Decomposition")

p3 <- tourism |>
  model(X_13ARIMA_SEATS(Trips ~ seats())) |>
  components() |>
  autoplot() +
  labs(title = "SEATS Decomposition")

p4 <- tourism |>
  model(STL(Trips)) |>
  components() |>
  autoplot() +
  labs(title = "STL Decomposition")

# Arrange in 2x2 grid
library(patchwork)
(p1 | p2) / (p3 | p4)
Warning: Removed 2 rows containing missing values or values outside the scale range
(`geom_line()`).

Method Selection Guidelines

Use Classical when:

  • You need a simple, quick decomposition

  • Data has no missing values

  • Teaching/explaining concepts

Use X-11 when:

  • Working with official statistics

  • Need industry-standard results

  • Have complex irregular patterns

Use SEATS when:

  • Model-based approach is preferred

  • Forecasting is the main goal

  • Have ARIMA modeling experience

Use STL when:

  • Need flexible, robust decomposition

  • Seasonal patterns change over time

  • Data has outliers or missing values

  • General-purpose analysis

Advanced Analysis: Component Inspection

# First create the STL decomposition
tourism_stl <- tourism |>
  model(STL(Trips)) |>
  components()

# Examine the strength of seasonal vs trend components
tourism_stl |> 
  as_tibble() |> 
  summarise(
    trend_strength = 1 - var(remainder) / var(trend + remainder),
    seasonal_strength = 1 - var(remainder) / var(season_year + remainder)
  )
# Plot remainder to identify outliers/anomalies
tourism_stl |> 
  autoplot(remainder) +
  labs(title = "Remainder Component: Anomaly Detection",
       subtitle = "Large deviations indicate unusual events")

# Seasonal subseries plot - understand seasonal patterns
tourism_stl |> 
  gg_subseries(season_year) +
  labs(title = "Seasonal Patterns by Quarter",
       subtitle = "Shows consistency of seasonal effects")
Warning: `gg_subseries()` was deprecated in feasts 0.4.2.
ℹ Please use `ggtime::gg_subseries()` instead.

Choosing the Right Model: Decision Framework

Statistical Tests (Less Common)

Advanced Insights and Best Practices

1. The Seasonally Adjusted Series

This is often what business stakeholders really want to see:

Seasonally_Adjusted = Original - Seasonal_Component  # (additive)
Seasonally_Adjusted = Original / Seasonal_Component  # (multiplicative)

It shows the true underlying business performance stripped of predictable seasonal effects.

Caution

Why Seasonal Adjustment Matters

  • The raw series mixes true growth/decline with seasonal ups and downs.

  • If you don’t adjust, comparing (say) December vs January is apples to oranges because December always has a holiday spike.

  • Seasonal adjustment strips out the expected seasonal effect → so you’re comparing December vs January on equal footing (apples to apples).

2. Multiple Seasonal Patterns

Some data has multiple seasonal patterns (daily + weekly, monthly + yearly):

# Multiple seasonality example - electricity demand
# Daily data with both daily and weekly patterns
vic_elec_sample <- vic_elec |>
  filter(year(Time) == 2014, month(Time) == 7) |>  # July 2014 only
  select(Time, Demand)

# Plot showing multiple seasonal patterns
vic_elec_sample |>
  autoplot(Demand) +
  labs(title = "Electricity Demand: Multiple Seasonal Patterns",
       subtitle = "Daily patterns within weekly patterns - July 2014",
       y = "Demand (MW)", x = "Time")

# Aggregate to daily level to show weekly patterns
vic_elec_daily <- vic_elec |>
  filter(year(Time) == 2014) |>
  index_by(Date = date(Time)) |>
  summarise(
    Demand = sum(Demand)/1e3,
    Temperature = max(Temperature)
  )

vic_elec_daily |>
  autoplot(Demand) +
  labs(title = "Daily Electricity Demand: Weekly Seasonal Patterns",
       subtitle = "Lower demand on weekends - Victoria, Australia 2014",
       y = "Daily Demand (GWh)", x = "Date")

# STL decomposition capturing multiple seasonalities (if using mstl)
# Note: This requires daily data with multiple seasonal periods
vic_elec_sample |>
  model(
    STL(Demand ~ trend(window = 21) + season(window = "periodic"))
  ) |>
  components() |>
  autoplot() +
  labs(title = "STL Decomposition of Half-Hourly Electricity Demand",
       subtitle = "Captures both daily and weekly seasonal patterns")

This shows:

  1. Half-hourly data with daily cycles (48 periods per day)
  2. Daily data with weekly cycles (7 periods per week)
  3. STL decomposition that can handle these multiple seasonal patterns

The multiple seasonality comes from electricity demand having both daily patterns (peak usage times) and weekly patterns (different weekday vs weekend usage).

3. Forecasting after Decomposition

STL is for decomposition only - not forecasting. However, we can use decomposition insights to improve forecasting

# Method 1: Forecast seasonally adjusted data
tourism_sa <- tourism |>
  model(STL(Trips)) |>
  components() |>
  select(Quarter, Trips, season_year) |>
  mutate(seasonally_adjusted = Trips - season_year)

# Forecast the seasonally adjusted series
sa_forecast <- tourism_sa |>
  model(ETS(seasonally_adjusted)) |>
  forecast(h = 8)  # 2 years = 8 quarters

sa_forecast |>
  autoplot(tourism_sa) +
  labs(title = "Forecast of Seasonally Adjusted Tourism",
       y = "Seasonally Adjusted Trips")

# Method 2: Use ARIMA on original data (handles seasonality automatically)
arima_forecast <- tourism |>
  model(ARIMA(Trips)) |>
  forecast(h = 8)

arima_forecast |>
  autoplot(tourism) +
  labs(title = "Tourism Forecast Using ARIMA",
       subtitle = "ARIMA automatically handles seasonal patterns",
       y = "Trips")

Key Takeaways and Action Items

Quick Reference Decision Tree:

  • Seasonal variation constant? → Additive model
  • Seasonal variation proportional to level? → Multiplicative model
  • Want robust, flexible method? → Always use STL
  • Need to remove seasonality? → Use seasonally adjusted series
  • Looking for anomalies? → Examine remainder component

Common Pitfalls to Avoid:

  1. Not plotting first: Always visualize before choosing a method
  2. Ignoring domain knowledge: Your business understanding should guide model choice
  3. Over-interpreting remainder: Some “noise” is just random - don’t chase every spike
  4. Wrong seasonal period: Ensure you specify the correct seasonal frequency

Next Steps:

  1. Apply decomposition to your own data
  2. Create seasonally adjusted versions for reporting
  3. Use components for targeted forecasting
  4. Monitor remainder for anomaly detection

Remember: Decomposition is not just a statistical exercise - it’s a powerful tool for understanding your business and making data-driven decisions.

Appendix

  1. https://link.springer.com/book/10.1007/978-3-319-31822-6

  2. https://jdemetradocumentation.github.io/JDemetra-documentation/pages/theory/SA_X11.html

  3. https://www.bundesbank.de/resource/blob/621580/2b7fafe37e0ecf8469d083dbbd3e402c/mL/1999-09-census-x-12-arima-data.pdf

Footnotes

  1. The original X-11 method has been enhanced and is now available in more sophisticated versions, such as X-12-ARIMA and X-13ARIMA-SEATS, which add modeling capabilities to handle calendar effects and outliers.↩︎