Moving averages and classical decomposition

Today let's understand how a time series gets pulled apart into a trend and a repeating seasonal pattern, using one real series.

AirPassengers is a classic dataset built into R: the monthly totals of international airline passengers worldwide, from January 1949 to December 1960, 144 months in all. Monthly totals range from 104 in the quietest month to 622 in the busiest.

Here is the whole series, plotted in the order the months actually happened.

The line climbs across all twelve years, and every summer it rises to a peak, each one higher than the summer before. That climbing, repeating peak is the whole puzzle this lesson solves: how do you pull the steady climb apart from the repeating summer bump, so each can be looked at on its own?

What a centred moving average is, and how to compute one by hand

The trend in a series is its slow-moving overall level, with the season and the noise averaged out. The simplest way to estimate it is a moving average: replace each raw value with the average of the values around it.

A centred moving average of order m, written m-MA, averages the m raw values sitting symmetrically around a given month t. For m = 3, the 3-MA at month t averages month t-1, month t itself, and month t+1, one month on each side.

Take July 1949. Its raw value is 148. June 1949 is 135 and August 1949 is 148. Average those three and you pull the value down to about 143.67, a little below the raw 148 because June drags it lower.

Build this in R. First turn AirPassengers into a tsibble, the data structure feasts and tsibble expect, then compute the 3-MA by hand for the months around July 1949.

RInteractive R
# Compute a 3-month centred moving average by hand and compare it with the raw series library(tsibble) library(dplyr) ap <- AirPassengers air <- as_tsibble(ap) |> rename(Month = index, Passengers = value) raw <- air$Passengers n <- length(raw) ma3 <- rep(NA, n) for (t in 2:(n - 1)) { ma3[t] <- mean(raw[(t - 1):(t + 1)]) } air <- air |> mutate(MA3 = round(ma3, 2)) air |> filter(Month >= yearmonth("1949 May"), Month <= yearmonth("1949 Sep")) #> # A tsibble: 5 x 3 [1M] #> Month Passengers MA3 #> <mth> <dbl> <dbl> #> 1 1949 May 121 128. #> 2 1949 Jun 135 135. #> 3 1949 Jul 148 144. #> 4 1949 Aug 148 144 #> 5 1949 Sep 136 134. air$MA3[7] #> [1] 143.67

  

The table rounds its display to fit the column, so July's exact value shows up as 144. Pull that one number out on its own and the full figure comes back: 143.67. The loop above only runs from month 2 to month 143, one short of each end, because month 1 has no month before it to average with, and month 144 has no month after.

How much smoothing is too much?

A longer window pulls in more neighbouring months, so it smooths harder. But it also needs more real months on each side to compute, so it leaves more months without a value at either end of the series.

Compute the 7-month centred average at the same July 1949 point, and compare all three numbers side by side.

RInteractive R
# Compute a 7-month centred moving average at July 1949 and compare window widths ma7_july <- mean(raw[4:10]) round(ma7_july, 2) #> [1] 133.71 c(raw_july = raw[7], ma3_july = air$MA3[7], ma7_july = round(ma7_july, 2)) #> raw_july ma3_july ma7_july #> 148.00 143.67 133.71

  

The 7-MA pulls July down to 133.71, further from the raw 148 than the 3-MA's 143.67, because it now reaches three months out on each side instead of one. That is more smoothing. The cost sits at the edges: the 3-MA above only loses 1 month at the start and 1 at the end (it runs from month 2 to month 143 out of 144), while a 7-MA would lose 3 months at each end, because it needs 3 real months on each side to compute.

Window length in a moving average works exactly like the smoothness setting in another common smoothing tool, a spline. The widget below is a different, generic example, not AirPassengers itself, but it carries exactly the same trade-off you just computed by hand: too small a smoothing setting chases every wiggle in the data, too large a one flattens out the real bends underneath.

Move the slider from one end to the other. At the stiff end the fitted curve barely bends, the way a very long moving-average window would flatten a real turn in AirPassengers' level. At the flexible end it chases every wiggle, the way too short a window would. Somewhere in between it settles close to the true curve, the same middle ground a well-chosen window length gives a moving average.

Why an even order needs a second pass: the 2x12-MA

AirPassengers repeats its shape every 12 months, so the natural window for its trend is a 12-month average. But 12 is even, and that creates a problem odd windows like 3 and 7 never had.

Average months 1 through 12, all of 1949, and the result sits at time 6.5, halfway between June and July. It is not attached to any real month at all.

RInteractive R
# Average the first 12 months and see where the result actually sits ma12_1 <- mean(raw[1:12]) round(ma12_1, 3) #> [1] 126.667

  

Slide the window forward by one month, months 2 through 13, and you get a second 12-month average, this one sitting at time 7.5, halfway between July and August.

RInteractive R
# Slide the window one month forward ma12_2 <- mean(raw[2:13]) round(ma12_2, 3) #> [1] 126.917

  

Neither average lands on July. But average those two 12-month averages together, and the two half-months cancel out: 6.5 and 7.5 average to exactly 7, July itself.

RInteractive R
# Average the two 12-month averages to recentre the result on July trend_july <- (ma12_1 + ma12_2) / 2 round(trend_july, 3) #> [1] 126.792

  

This two-pass average is called a 2x12-MA: a 12-month average, taken twice and averaged together. It is the standard way to estimate a trend whenever the season repeats over an even number of periods, 12 months here (you would see a 2x4-MA for quarterly data, where the season repeats every 4 quarters).

The same 126.792 comes out of one 13-month weighted average instead, with the two end months getting half the weight of every month in between them.

\[ trend_t = \frac{1}{24}y_{t-6} + \frac{1}{12}\left(y_{t-5} + y_{t-4} + \cdots + y_{t+5}\right) + \frac{1}{24}y_{t+6} \]

RInteractive R
# Confirm the same number as one 13-month weighted average w <- c(1 / 24, rep(1 / 12, 11), 1 / 24) sum(w * raw[1:13]) #> [1] 126.7917

  

And this is not just a coincidence of arithmetic. It is exactly what base R's own decompose() function computes for a trend.

RInteractive R
# Compare with base R's own decompose() trend at July 1949 decompose(ap)$trend[7] #> [1] 126.7917

  

Same number, to four decimal places. Whatever computes a classical trend, feasts included, is doing this same two-pass average underneath.

Quick check: why average two 12-month averages instead of one?

Right. mean(raw[1:12]) sits at time 6.5, between June and July; mean(raw[2:13]) sits at time 7.5, between July and August. Average those two and the half-months cancel, landing exactly on July.
None of these explain why 12 needs two passes. A single 12-month average always lands on a half-month, between two real months, because 12 is even. Averaging two consecutive 12-month averages is what shifts the result back onto one actual month, nothing about smoothing, package requirements or day counts.

Running classical_decomposition() and reading trend, seasonal and random

That two-pass average is exactly what feasts' classical_decomposition() computes for you, along with the season and whatever is left over. Fit it on the tsibble you already built, with type = "additive" telling it to assume the season adds a fixed number of passengers to the trend every month, rather than scaling it.

RInteractive R
# Fit the classical additive decomposition and inspect the first months library(feasts) library(fabletools) fit_add <- air |> model(classical_decomposition(Passengers, type = "additive")) comp_add <- fit_add |> components() |> as_tibble() |> select(Month, Passengers, trend, seasonal, random, season_adjust) |> mutate(across(trend:season_adjust, ~ round(.x, 1))) comp_add #> # A tibble: 144 x 6 #> Month Passengers trend seasonal random season_adjust #> <mth> <dbl> <dbl> <dbl> <dbl> <dbl> #> 1 1949 Jan 112 NA -24.7 NA 137. #> 2 1949 Feb 118 NA -36.2 NA 154. #> 3 1949 Mar 132 NA -2.2 NA 134. #> 4 1949 Apr 129 NA -8 NA 137 #> 5 1949 May 121 NA -4.5 NA 126. #> 6 1949 Jun 135 NA 35.4 NA 99.6 #> 7 1949 Jul 148 127. 63.8 -42.6 84.2 #> 8 1949 Aug 148 127. 62.8 -42.1 85.2 #> 9 1949 Sep 136 128 16.5 -8.5 120. #> 10 1949 Oct 119 129. -20.6 11.1 140. #> # i 134 more rows

  

components() hands back four columns beyond the ones you already have. trend and seasonal you have just built by hand. random is whatever is left once trend and seasonal are taken out of the observed value. season_adjust is the observed series with only the seasonal part removed, trend and random still inside it.

Row 7, July 1949: trend reads 127 (rounded to one decimal for display; it is the same 126.792 you computed by hand). Its seasonal value is 63.8. Add trend and seasonal and subtract the small random leftover, 127 + 63.8 - 42.6, and you land back close to the raw 148.

Notice the first six rows show NA for trend and random. The 2x12-MA needs 6 real months on each side of a point to compute (it is really a 13-month window), so the first month it can reach is July 1949, row 7. The same thing happens at the other end of the series, on its last 6 months.

Choosing additive or multiplicative from how the season behaves

The additive decomposition you just ran assumes the summer bump adds a fixed number of passengers to the trend every year. Check whether that is actually true by comparing each year's range, its tallest month minus its shortest, against its average level.

RInteractive R
# Compare each year's range (max minus min) against its mean level yr <- rep(1949:1960, each = 12) yearly_range <- tapply(raw, yr, function(x) max(x) - min(x)) yearly_mean <- tapply(raw, yr, mean) round(c(range_1949 = unname(yearly_range["1949"]), range_1960 = unname(yearly_range["1960"]), mean_1949 = unname(yearly_mean["1949"]), mean_1960 = unname(yearly_mean["1960"]))) #> range_1949 range_1960 mean_1949 mean_1960 #> 44 232 127 476

  

In 1949 the range between the busiest and quietest month was 44, against a mean level of 127. By 1960 the mean level had grown to 476, and the range had grown right along with it, to 232. The summer bump is not a fixed number of extra passengers. It scales with however big the series has grown: about a third of the mean level in 1949, and closing in on half of it by 1960.

That is the sign to use a multiplicative decomposition instead: Passengers = trend x seasonal x random, where the seasonal term scales the trend rather than adding to it. Refit with type = "multiplicative" and compare July's seasonal value against the additive one you already have.

RInteractive R
# Refit with type = "multiplicative" and compare July's seasonal value both ways fit_mul <- air |> model(classical_decomposition(Passengers, type = "multiplicative")) comp_mul <- fit_mul |> components() |> as_tibble() |> mutate(across(trend:season_adjust, ~ round(.x, 3))) comp_add$seasonal[7] #> [1] 63.8 comp_mul$seasonal[7] #> [1] 1.227

  

Read those two numbers in their own terms. The additive value, 63.8, says July adds 63.8 to the trend, every year, no matter how big the trend has grown. The multiplicative value, 1.227, says July multiplies the trend by 1.227, a 22.7% lift, and that lift applies whether the trend is 127 or 476. For a series whose seasonal swings grow with its level, the multiplicative number is the one that stays true from 1949 to 1960; the additive number would understate the swing in 1960 and overstate it in 1949.

Seeing the four components side by side

Passengers, trend, seasonal and random are the same four columns you have just been reading one row at a time. Seeing all 144 months of each one at once, side by side, makes clear what each is actually doing.

The widget below plots the multiplicative fit's four columns, either combined onto one chart or split into four small multiples, one panel per component.

In the combined view, Passengers sits in the hundreds while Seasonal sits between about 0.8 and 1.23, so the two are hard to read against each other on one scale. Switch to facet_wrap(~Component) and each gets its own panel with its own scale. Trend climbs smoothly with no summer bumps left in it, because the moving average already averaged them away. Seasonal repeats the identical 12-month shape every single year, in effect one flat curve traced twelve times over. Random sits close to 1 the whole way through, the small multiplicative leftover once trend and seasonal are divided back out of Passengers.

What each component means, and where classical decomposition falls short

Put a name to each of the four columns you have now computed, plotted and compared.

  • Trend is the smoothed level of the series, with season and noise averaged away by the 2x12-MA. It has no value for the first 6 or the last 6 months of any series, because the 2x12-MA needs 6 real months on each side of a point to compute.
  • Seasonal is the repeating calendar effect. For AirPassengers it comes out to the exact same number every July, the exact same number every August, and so on, unchanged from 1949 all the way to 1960.
  • Random is whatever trend and seasonal cannot explain, whatever is left over once both are divided (or subtracted) out.
  • season_adjust is the observed series with only the seasonal part taken out, trend and random still inside it.

That fixed, repeating seasonal number is also the method's real limit. AirPassengers' summer bump keeping exactly the same shape for twelve straight years is a convenient property of a textbook series. A real series's season can drift, a slightly earlier peak one year, a slightly later one the next, and classical decomposition has no way to show that drift, because it assumes one fixed repeating shape for the whole series. And because the 2x12-MA needs 6 months on each side to compute, classical decomposition can never estimate a trend at either end of any series, however long that series runs.

Quick check: additive or multiplicative, and what each component says

Right. The range went from 44 (a mean of 127) to 232 (a mean of 476), growing from about a third of the mean to close to half of it, so the season scales with the level rather than staying fixed. That is the multiplicative case, and random is always whatever is left over once trend and seasonal are accounted for.
Multiplicative is right, the range grew in step with the mean rather than staying fixed. But random is never the seasonal pattern; seasonal already is the repeating calendar effect. Random is whatever trend and seasonal leave unexplained.

Your turn: decompose the series and read one value

air still holds the AirPassengers tsibble, with columns Month and Passengers. Fit a multiplicative decomposition on it yourself, the same way you did two steps ago, then pull its components out and read off the trend value at row 7, July 1949. Check it against the 126.792 you already worked out by hand.

Right: components()$trend at row 7 reads 126.7917, the same 2x12-MA you worked out by hand, and components()$seasonal at row 7 reads about 1.227, a 22.7% July lift.Two calls: model(air, classical_decomposition(Passengers, type = "multiplicative")) to fit it, then components() on that fit to pull out trend, seasonal, random and season_adjust.
Show answer
# Fit the multiplicative decomposition and read off trend and seasonal at July 1949
fit_check <- air |> model(classical_decomposition(Passengers, type = "multiplicative"))
comp_check <- fit_check |> components()

comp_check$trend[7]
#> [1] 126.7917
comp_check$seasonal[7]
#> [1] 1.226556

References

Quick recap

You pulled AirPassengers apart into a trend, a season and a random leftover, and you know exactly how each number in that decomposition came to be.

  • A centred moving average replaces each value with the average of its neighbours; a longer window smooths harder but loses more months at each end.
  • An even season, 12 months here, needs two passes: two 12-month averages, themselves averaged, to land the result back on a real month. That 2x12-MA is exactly what decompose() and classical_decomposition() compute for a trend.
  • Additive assumes the season adds a fixed number every year; multiplicative assumes it scales the trend by a fixed proportion. AirPassengers' seasonal swing grew from 44 to 232 right alongside its mean, so multiplicative was the right call, a 22.7% July lift that held from 1949 to 1960.
  • trend, seasonal, random and season_adjust are the four columns components() returns, and you can now name what each one measures.
  • Classical decomposition's season never changes shape year to year, and it cannot estimate a trend at either end of a series. The next part covers STL, which relaxes both of those limits.