Time Series and Forecasting

Trend, seasonality and noise

Almost every time series is three things added together — a slow direction, a repeating pattern and leftover randomness — and separating them is the first thing you do.

On this page 10
  1. The short answer
  2. The analogy you have already lived
  3. The three parts, named properly
  4. Why splitting them up helps
  5. How it works
  6. Two flavours, and picking the wrong one hurts
  7. Where you have already seen it
  8. What is honestly hard here
  9. Remember this
  10. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

Almost every time series is three things stacked on top of each other: a slow direction, a repeating pattern, and leftover wobble.

The analogy you have already lived

Think about the traffic noise outside your window across a whole day.

There is a slow shape. Quiet at 4 in the morning, loud at 9, loud again at 7 in the evening. That shape repeats tomorrow and the day after. That is the repeating pattern.

Underneath it, this year is louder than last year, because the colony keeps growing. That is the slow direction.

On top of both, a truck horn at a random moment. That is the leftover wobble.

Your ear separates these without effort. You are annoyed by the horn and not by the 9 a.m. rush, because you expected one and not the other. A forecasting model has to learn to do the same.

The three parts, named properly

Trend is the long, slow direction. Your shop grows a little every year. Your electricity use creeps up as you buy more appliances. Trend answers: where is this heading over years?

Seasonality is a pattern that repeats on a fixed clock. Not only about seasons of the year — the name is historical. It covers:

  • Every hour of the day: office wifi is busy at 11 a.m., dead at 3 a.m.
  • Every day of the week: a restaurant is full on Saturday, empty on Tuesday.
  • Every month of the year: clothing shops peak before Diwali.

Residual is everything left over once you remove the other two. A one-off strike day. A viral post. A meter-reading mistake. Some of it is honest randomness and some of it is a real event you have not explained yet.

Why splitting them up helps

Look at a raw sales chart and you see one messy line. Two questions get tangled together.

"Sales fell 30 percent this month, are we in trouble?"

If November always falls 30 percent after the Diwali rush, nothing is wrong. If November usually falls 10 percent, something is wrong. You cannot tell those apart from the raw line.

Splitting the series answers it in one step. Remove the repeating pattern, and what remains either sits flat or shows you the problem.

How it works

   raw sales
      |
      v
  [ smooth it out over a whole year ]  ->  TREND      (the slow direction)
      |
      v
  [ what is left, averaged by month ]  ->  SEASONAL   (the repeating pattern)
      |
      v
  [ whatever still remains ]           ->  RESIDUAL   (the wobble)


   stack the three parts back together and you get
   the raw sales again, exactly

The last line is the honest bit. Nothing is thrown away. The three parts add back up to what you started with.

Two flavours, and picking the wrong one hurts

Ask one question about your data: when the business gets bigger, does the festival bump get bigger too?

Suppose the Diwali bump is always about the same size in rupees, however big the shop grew. Then the parts add together. That is called additive.

If the Diwali bump is always about the same size in percent, the bump grows as the shop grows. The parts multiply together. That is called multiplicative.

Real shops are almost always the second kind. A shop doing twice the business gets roughly twice the festival bump, not the same rupee bump.

Pick additive for a multiplicative series and your forecast goes wrong in a fixed way. Too low in busy years, too high in quiet ones.

Where you have already seen it

  • Weather forecasts report today against the "normal for this date". That normal is the seasonal part.
  • Government job numbers are published as "seasonally adjusted" — the repeating pattern was removed so real change is visible.
  • Your electricity bill's bar chart shows the summer bump plainly.
  • A ride-hailing app raising prices at 6 p.m. is pricing off a daily pattern.

What is honestly hard here

Splitting a series into three parts is a description, not an explanation.

The method has no idea why October is high. It notices that October is high, every year, and files that under seasonality. Suppose your shop's October rush is really caused by an annual bonus payout. If that payout is cancelled, the split still predicts a rush that never comes.

Also, more than one pattern can live in the same series. Website traffic has a daily pattern and a weekly pattern at the same time. Basic tools handle one repeating pattern at a time, and you need better ones for two.

Remember this

  • Every series is roughly trend plus seasonality plus residual.
  • Seasonality means "repeats on a fixed clock", not only "summer and winter".
  • Ask whether the bump is a fixed amount or a fixed percentage before you model anything.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install numpy pandas statsmodels

Everything below is generated inline. No download, no GPU, under two seconds.

Building a series, then pulling it apart

The trick for learning this is to construct a series whose true parts you already know, then check whether the tool recovers them. If a method cannot recover a pattern you planted yourself, it will not find one you did not.

decompose.py
import numpy as np
import pandas as pd
from statsmodels.tsa.seasonal import seasonal_decompose

rng = np.random.default_rng(3)

# Six years of monthly sales, in thousands of rupees, for a clothes shop.
months = pd.date_range("2019-01-01", periods=72, freq="MS")
trend = np.linspace(200, 380, 72)                     # the shop is slowly growing
festival = np.array([-20, -25, -10, 0, 5, -5, 0, 15, 45, 90, 30, 10])
sales = pd.Series(
    (trend + np.tile(festival, 6) + rng.normal(0, 6, 72)).round(1),
    index=months, name="sales",
)

parts = seasonal_decompose(sales, model="additive", period=12)

print(pd.DataFrame({
    "sales": sales, "trend": parts.trend.round(1),
    "seasonal": parts.seasonal.round(1), "residual": parts.resid.round(1),
}).loc["2021-08-01":"2021-12-01"])

print("\nmonthly pattern the decomposition recovered, against the truth:")
found = parts.seasonal["2021-01-01":"2021-12-01"].round(1).to_numpy()
for m, f, t in zip(months[:12].strftime("%b"), found, festival - festival.mean()):
    print(f"  {m}  found {f:6.1f}   true {t:6.1f}")
Output
            sales  trend  seasonal  residual
2021-08-01  291.8  290.5       1.1       0.2
2021-09-01  331.4  292.5      34.5       4.5
2021-10-01  377.1  293.9      82.6       0.6
2021-11-01  316.7  296.2      22.7      -2.2
2021-12-01  302.8  298.8       0.5       3.4

monthly pattern the decomposition recovered, against the truth:
  Jan  found  -36.5   true  -31.2
  Feb  found  -33.6   true  -36.2
  Mar  found  -22.5   true  -21.2
  Apr  found  -13.8   true  -11.2
  May  found   -6.7   true   -6.2
  Jun  found  -14.2   true  -16.2
  Jul  found  -14.0   true  -11.2
  Aug  found    1.1   true    3.8
  Sep  found   34.5   true   33.8
  Oct  found   82.6   true   78.8
  Nov  found   22.7   true   18.8
  Dec  found    0.5   true   -1.2

Reading that output

Check the first table adds up. For August 2021: 290.5 + 1.1 + 0.2 = 291.8, which is the sales figure. The decomposition is exact by construction — the residual is defined as whatever is left over.

The recovered pattern is close but not equal to the truth. October came back as 82.6 against a true 78.8. That gap is the noise leaking into the estimate. With six cycles, each month's seasonal value averages six noisy observations, so a few units of error is expected.

Why true is written as festival - festival.mean(). The seasonal component is forced to average zero across the twelve months. Any constant offset belongs to the trend instead. Comparing against the raw festival array would look wrong when the method is right.

The period=12 argument is not optional. It tells the tool the pattern repeats every twelve rows. Get it wrong and you get confident nonsense. For hourly data with a daily cycle it is 24; for daily data with a weekly cycle it is 7.

Trend is NaN for the first and last six months. The trend estimate is a centred twelve-month average, so it needs six months on each side. That is a real limitation, and it is why seasonal_decompose cannot forecast anything.

Deciding additive against multiplicative

This is the choice people skip, so here is the test in code. Two shops with the same growth: one gets a fixed festival bump, one gets a percentage bump.

which_flavour.py
import numpy as np
import pandas as pd

months = pd.date_range("2019-01-01", periods=72, freq="MS")
trend = np.linspace(100, 400, 72)
pattern = np.tile(np.array([-.10, -.12, -.05, 0, .02, -.02, 0, .07, .20, .40, .14, .05]), 6)

added = pd.Series(trend + pattern * 100, index=months)   # festival adds a FIXED amount
scaled = pd.Series(trend * (1 + pattern), index=months)  # festival adds a PERCENTAGE

def october_lift(s, year):
    year_data = s[f"{year}-01-01":f"{year}-12-01"]
    return year_data.max() - year_data.mean()

for name, s in [("additive", added), ("multiplicative", scaled)]:
    print(f"{name:16s} October lift over the year's average: "
          f"2019 = {october_lift(s, 2019):5.1f}   2024 = {october_lift(s, 2024):5.1f}")
Output
additive         October lift over the year's average: 2019 =  49.9   2024 =  49.9
multiplicative   October lift over the year's average: 2019 =  62.5   2024 = 151.4

Run this measurement on your own data. Compute the peak-over-average lift in your earliest full year and your latest full year.

If the lift is roughly the same number, use model="additive". If it grew along with the business, use model="multiplicative" — or take np.log of the series and stay additive, which is often easier because logs turn multiplication into addition.

One catch with logs: np.log is undefined at zero and negative values. A series with genuine zeros (a shop closed for two days) needs np.log1p or a different approach.

Where residuals earn their keep

The residual is not rubbish. It is your alarm system.

residual_alarm.py
import numpy as np
import pandas as pd
from statsmodels.tsa.seasonal import seasonal_decompose

rng = np.random.default_rng(3)
months = pd.date_range("2019-01-01", periods=72, freq="MS")
sales = pd.Series(
    np.linspace(200, 380, 72) + np.tile([-20, -25, -10, 0, 5, -5, 0, 15, 45, 90, 30, 10], 6)
    + rng.normal(0, 6, 72), index=months)
sales["2022-03-01"] -= 70          # a lockdown month nobody wrote down

resid = seasonal_decompose(sales, model="additive", period=12).resid.dropna()
limit = 3 * resid.std()
print("residual spread:", round(resid.std(), 2))
print("months outside three spreads:")
print(resid[resid.abs() > limit].round(1).to_string())
Output
residual spread: 9.78
months outside three spreads:
2022-03-01   -54.2
Freq: MS

The lockdown month sticks out at over five times the usual residual spread, while every ordinary month stays inside. That is the seed of anomaly detection, and it works because the boring, explainable structure was removed first.

Common mistakes

Guessing period. For daily retail data people often set period=365, which forces the tool to estimate 365 separate values from very few cycles. If the real pattern is weekly, use period=7. Look at the data before choosing.

Using seasonal_decompose to forecast. It cannot. The trend has NaN at both ends and no rule for continuing. For forecasting, use exponential smoothing or ARIMA.

Ignoring a moving festival. Diwali is in October some years and November others. A fixed twelve-month pattern smears the bump across both months and underestimates it in each. Handle moving dates with a holiday feature, which Prophet does directly.

Reaching for seasonal_decompose when STL exists. statsmodels.tsa.seasonal.STL allows the seasonal shape to change slowly over the years and copes far better with outliers. Use seasonal_decompose to learn the idea and STL for real work.

Try it yourself

Take the first script and change period=12 to period=6. Print the recovered seasonal pattern again.

Before running it, predict what happens to the October spike. Then explain in one sentence why forcing a six-month cycle onto a twelve-month pattern splits the bump in half.

What to learn next

Researcher — Mathematics and papers.

The classical decomposition model

The additive model assumes

$$ y_t = T_t + S_t + R_t $$

and the multiplicative model assumes

$$ y_t = T_t \cdot S_t \cdot R_t $$

  • $y_t$ — the observed value at time $t$.
  • $T_t$ — the trend-cycle component, a smooth low-frequency term.
  • $S_t$ — the seasonal component, with period $m$, constrained so that $\sum_{j=1}^{m} S_j = 0$ (additive) or $\prod_{j=1}^{m} S_j = 1$ (multiplicative).
  • $R_t$ — the remainder.

The identification constraint on $S_t$ is what makes the split unique. Without it, any constant could be moved between $T_t$ and $S_t$ with no change to $y_t$. Taking logs maps the multiplicative model to the additive one exactly: $\log y_t = \log T_t + \log S_t + \log R_t$, valid only for strictly positive $y_t$.

The classical algorithm and its defects

The seasonal_decompose procedure is:

  1. Estimate $T_t$ by a centred moving average of order $m$. For even $m$ this is the $2 \times m$ MA, with weights $\frac{1}{2m}, \frac{1}{m}, \dots, \frac{1}{m}, \frac{1}{2m}$ over $m+1$ terms, which avoids the half-period phase shift a plain even-order MA introduces.
  2. Detrend: $d_t = y_t - T_t$.
  3. Average $d_t$ over each seasonal index $j$, then centre so the values sum to zero, giving $\hat{S}_j$.
  4. $R_t = y_t - T_t - S_t$.

Four defects follow directly:

  • $T_t$ is undefined for the first and last $\lfloor m/2 \rfloor$ observations, so $R_t$ is too. This is fatal for real-time monitoring at the current edge.
  • $\hat{S}_j$ is constant across all years. A seasonal shape that evolves is forced into a single average.
  • A single outlier propagates into $\hat{S}_j$ for that seasonal index in every year, because step 3 uses a mean.
  • Rapid level shifts are smeared by the moving average across $m$ observations.

STL

Cleveland, Cleveland, McRae and Terpenning (1990), STL: A Seasonal-Trend Decomposition Procedure Based on Loess, fixes the first three. STL runs an inner loop alternating two loess smooths — a cycle-subseries smooth producing $S_t$ and a low-pass smooth producing $T_t$ — inside an outer loop that computes robustness weights

$$ \rho_t = B!\left(\frac{|R_t|}{6\,\mathrm{median}(|R|)}\right), \qquad B(u) = \begin{cases} (1-u^2)^2 & 0 \le u < 1 \ 0 & u \ge 1 \end{cases} $$

where $B$ is Tukey's bisquare. Observations with large remainders are downweighted in the next iteration, so an outlier is absorbed by $R_t$ rather than bent into $S_t$.

The key tuning parameters are seasonal (the loess span for the cycle-subseries smooth, controlling how fast $S_t$ may evolve) and trend (the span for $T_t$). Setting seasonal to a large odd number recovers a near-constant seasonal shape; small values track a changing one at the cost of variance. Both must be odd.

STL is additive and univariate, handles any integer $m$, and has $O(n)$ cost per iteration. It does not handle multiple seasonalities or calendar effects. MSTL (Bandara, Hyndman and Bergmeir, 2021) extends it to multiple periods by iterative extraction, and is available as statsmodels.tsa.seasonal.MSTL.

Official seasonal adjustment

National statistical agencies use X-13ARIMA-SEATS, maintained by the US Census Bureau, which combines the X-11 moving-average filters with a RegARIMA pre-adjustment step. The pre-adjustment handles trading-day effects, moving holidays, level shifts and additive outliers before decomposition, then forecast-extends the series so the filters are not truncated at the endpoints. The endpoint problem is the reason published seasonally adjusted figures get revised as new data arrives.

For a treatment of the filters and their frequency responses, see Ladiray and Quenneville (2001), Seasonal Adjustment with the X-11 Method.

Multiple and non-integer seasonality

Half-hourly electricity demand carries a daily period of 48, a weekly period of 336 and an annual period of 365.25 × 48, which is not an integer. Estimating a separate index per seasonal position is infeasible for the annual term.

The standard remedy is a Fourier representation: replace the seasonal indices with

$$ S_t = \sum_{k=1}^{K} \left[ \alpha_k \sin!\left(\frac{2\pi k t}{m}\right) + \beta_k \cos!\left(\frac{2\pi k t}{m}\right) \right] $$

  • $m$ — the period, which may be non-integer.
  • $K$ — the number of harmonics, chosen by information criterion; $K \le m/2$.
  • $\alpha_k, \beta_k$ — coefficients estimated by regression.

This costs $2K$ parameters instead of $m-1$, and handles several periods at once by summing terms with different $m$. It is the mechanism behind TBATS (De Livera, Hyndman and Snyder, 2011) and behind the seasonality module in Prophet.

Reading

  • Cleveland et al. (1990), STL: A Seasonal-Trend Decomposition Procedure Based on Loess, Journal of Official Statistics 6(1).
  • Hyndman and Athanasopoulos, Forecasting: Principles and Practice, 3rd ed., chapter 3 — otexts.com/fpp3/decomposition.html.
  • De Livera, Hyndman and Snyder (2011), Forecasting time series with complex seasonal patterns using exponential smoothing, JASA 106(496).
  • Bandara, Hyndman and Bergmeir (2021), MSTL: A Seasonal-Trend Decomposition Algorithm for Time Series with Multiple Seasonal Patterns — arxiv.org/abs/2107.13462.

What to learn next

What to learn next

These follow on from what you just read.

  • Time Series and Forecasting

    Stationarity

    A stationary series keeps the same average and the same wobble forever, and most forecasting models quietly assume yours does too.

  • Time Series and Forecasting

    Moving averages

    A moving average smooths a bumpy series by averaging a sliding window, and the window you choose decides how much noise you remove and how far behind you fall.

  • Time Series and Forecasting

    Exponential smoothing

    Exponential smoothing keeps one running estimate and nudges it toward each new observation, giving recent data more weight without ever forgetting the past completely.