Part II: Classical Forecasting and Time Series Analysis
Chapter 3: Statistical Foundations

Time-Series Decomposition

"I am the trend, and I have been quietly taking credit for work the season did. That winter bump everyone admires as my steady climb? Half of it was a repeating swing I absorbed when nobody was watching the windows. Decompose me carefully or I will keep pocketing the season's applause."

A Trend Quietly Stealing Credit From the Season
Big Picture

Decomposition is the act of reading a time series as a sum (or product) of stories: a slow trend that says where the level is heading, a repeating seasonal pattern that says what time of year, week, or day it is, and a remainder that should say nothing at all. Separate the three and every downstream task gets easier: you can deseasonalize to see the underlying economy, you can forecast each part on its own terms and recombine, and you can audit the remainder to learn whether your model has finished. This section moves from the rigid classical moving-average decomposition, through Seasonal-Trend decomposition using Loess (STL) with its evolving seasonality and robustness weights, to the robust and multiple-seasonality variants that handle outliers and calendars with several cycles at once. We close by using the remainder as a diagnostic (it should pass the white-noise tests of Section 3.3) and by treating decomposition as the simplest honest forecasting baseline. It is the same components view that Section 3.4 identified in raw series, now made operational.

The previous section, Section 3.4, taught you to recognize the building blocks of a series: a level, a trend, one or more seasonal cycles, and irregular fluctuation. Recognition is diagnosis; this section is treatment. We take the additive and multiplicative component models written there and turn them into estimators that actually return three numbered sequences from one observed sequence. The thread running through the whole section is a single equation and a single question. The equation is that an observed series $y_t$ decomposes into trend $T_t$, seasonal $S_t$, and remainder $R_t$. The question, asked of every method, is the same: how flexible is the seasonal pattern allowed to be, and how much does a single outlier get to distort the answer? Classical decomposition gives a rigid, fragile answer; STL gives a flexible, robust one; and the modern variants push robustness and multiple calendars further still.

1. The Decomposition Idea Beginner

Write the observed series as a combination of unobserved components. In the additive model the parts add,

$$y_t = T_t + S_t + R_t,$$

where $T_t$ is a smoothly varying trend-cycle, $S_t$ is a seasonal component that repeats with period $m$ (so $m = 12$ for monthly data with a yearly cycle, $m = 7$ for daily data with a weekly cycle), and $R_t$ is the remainder, everything the first two components could not explain. The additive form is right when the size of the seasonal swing does not depend on the level of the series. When the seasonal swing grows in proportion to the level, as retail sales do every December, the multiplicative model fits instead,

$$y_t = T_t \times S_t \times R_t,$$

and a logarithm converts one into the other, since $\log y_t = \log T_t + \log S_t + \log R_t$ turns a multiplicative decomposition into an additive one on the log scale. This is why "take logs first" is the standard reflex for series whose variance grows with level; it is the log-to-additive identity of Section 3.4, itself the Box-Cox/variance-stabilizing idea introduced in Section 3.2, applied so that an additive estimator can do multiplicative work. Throughout this section we use a monthly retail-style series with a strong yearly cycle as the running example, consistent with the finance-and-retail flavor of Part II. Figure 3.5.2 pictures the whole task in one image: patiently unbraiding one tangled series into separable strands.

Cartoon hands unweave one messy braid into three distinct strands: a smooth rising curve, a tidy repeating wave, and a frizzy chaotic scribble, picturing decomposition splitting a series into trend, seasonal, and remainder components.
Figure 3.5.2: Decomposition is patiently unbraiding one tangled series into three strands, a smooth trend, a repeating season, and a frizzy remainder, so each can be modeled or removed on its own terms.

What is decomposition for? Three uses recur. The first is understanding: a deseasonalized unemployment or sales series strips away the predictable calendar swing so that a genuine turning point becomes visible, which is exactly why statistical agencies publish "seasonally adjusted" numbers. The second is adjustment: many downstream models, and many human decisions, want $y_t - S_t$ (or $y_t / S_t$) rather than the raw series. The third is forecasting: if you can forecast each component separately and recombine them, you have a baseline forecaster that is transparent, fast, and surprisingly hard to beat, which is the construction of subsection five. Table 3.5.1 fixes the vocabulary before we estimate anything.

ComponentSymbolWhat it capturesHow it is estimated
Trend-cycle$T_t$Slow level changes and long swingsMoving average or loess smoother
Seasonal$S_t$Pattern repeating with period $m$Averaging detrended values per season
Remainder$R_t$Everything left over, ideally white noise$y_t - T_t - S_t$ (additive)
Table 3.5.1: The three components of a classical decomposition, the symbol used throughout this section, the structure each one is meant to absorb, and the estimator that produces it. The remainder is defined by subtraction, so its quality is a verdict on the other two: a clean remainder means trend and seasonal took everything systematic.
Key Insight: The Remainder Is a Scoreboard, Not a Component

Trend and seasonal are things you estimate; the remainder is what falls out when you subtract them. That asymmetry is the whole point. A decomposition is good exactly when its remainder is boring: no leftover trend, no leftover seasonality, no autocorrelation that a model could still exploit. So the remainder is never the goal, it is the audit. Every method in this section is really a different answer to one question, "what is the least structure I can leave in $R_t$?", and you grade the answer by running the white-noise tests of Section 3.3 on the remainder it produces.

2. Classical Decomposition Beginner

The oldest recipe is mechanical and instructive. It proceeds in three steps. First, estimate the trend-cycle with a centered moving average of length equal to the seasonal period $m$, because averaging over exactly one full cycle cancels the seasonal pattern. When $m$ is even (as for monthly data, $m = 12$) a plain moving average is not centered on an integer index, so the standard fix is the $2 \times m$ moving average: average $m$ consecutive points, then average two adjacent such averages. Concretely, for even $m$,

$$\hat T_t = \frac{1}{m}\left(\tfrac{1}{2} y_{t-m/2} + y_{t-m/2+1} + \cdots + y_{t+m/2-1} + \tfrac{1}{2} y_{t+m/2}\right),$$

which weights the two end points by one half so the window spans exactly $m$ intervals and stays centered. Second, detrend by subtracting the trend (additive) or dividing by it (multiplicative), leaving a detrended series that holds the seasonal pattern plus remainder. Third, estimate the seasonal component by averaging the detrended values within each season: all the Januaries together, all the Februaries together, and so on. These $m$ seasonal averages are then centered to sum to zero (additive) or to average one (multiplicative), and tiled across the whole series to form $\hat S_t$. The remainder is whatever is left, $\hat R_t = y_t - \hat T_t - \hat S_t$.

The method has three limitations that motivate everything after it. The seasonal pattern is forced to be identical in every cycle, because a single average per season is reused for all years; a December effect that strengthens over a decade cannot be represented. The trend is undefined at the two ends, because a centered window of width $m$ loses $m/2$ points at each end, so the first and last half-year of a monthly series have no trend estimate at all. And it has no robustness: one freak observation contaminates the moving average around it and the seasonal average for its season. Figure 3.5.1 shows the three steps on the running series.

Observed = Trend + Seasonal + Remainder observed trend seasonal remainder y T S
Figure 3.5.1: Classical additive decomposition of the running series. The observed series (top) is split into a slowly rising moving-average trend (second), a strictly repeating seasonal pattern that is identical every year (third), and a remainder (bottom) that should hover around zero with no visible structure. The rigidity is visible in the seasonal panel: every cycle is an exact copy, which is the assumption STL will relax.

Code 3.5.1 implements the full classical additive decomposition from scratch, so that the moving average, the per-season averaging, and the centering are all explicit rather than hidden behind a call.

import numpy as np

def classical_decompose(y, m):
    """Additive classical decomposition: trend by 2xm centered MA,
    seasonal by per-season averaging, remainder by subtraction."""
    n = len(y)
    # --- Step 1: trend via centered moving average of length m ---
    trend = np.full(n, np.nan)
    half = m // 2
    if m % 2 == 0:                                   # even m: 2xm MA (half weights at ends)
        w = np.concatenate(([0.5], np.ones(m - 1), [0.5])) / m
        for t in range(half, n - half):
            trend[t] = np.dot(w, y[t - half: t + half + 1])
    else:                                            # odd m: plain centered MA
        w = np.ones(m) / m
        for t in range(half, n - half):
            trend[t] = np.dot(w, y[t - half: t + half + 1])
    # --- Step 2: detrend ---
    detrended = y - trend
    # --- Step 3: seasonal = average of detrended values within each season ---
    seasonal_means = np.array([np.nanmean(detrended[s::m]) for s in range(m)])
    seasonal_means -= seasonal_means.mean()          # center so the season sums to zero
    seasonal = np.tile(seasonal_means, n // m + 1)[:n]
    remainder = y - trend - seasonal
    return trend, seasonal, remainder

# Build a synthetic monthly series: linear trend + yearly season + noise.
rng = np.random.default_rng(0)
t = np.arange(72)
true_trend = 100 + 0.5 * t
true_season = 8 * np.sin(2 * np.pi * t / 12)
y = true_trend + true_season + rng.normal(0, 1.5, size=72)

T, S, R = classical_decompose(y, m=12)
print("first valid trend index:", np.argmax(~np.isnan(T)))
print("seasonal pattern (one cycle):", np.round(S[:12], 2))
print("remainder std (where defined):", round(np.nanstd(R), 3))
Code 3.5.1: Classical additive decomposition from scratch. The $2 \times m$ centered moving average estimates the trend, per-season averaging of the detrended series builds the seasonal pattern, and the centering step forces the seasonal component to sum to zero so it does not absorb any level. The NaN band at each end is the end-effect limitation made concrete.
first valid trend index: 6
seasonal pattern (one cycle): [ 0.13  4.06  6.97  8.02  6.84  4.   -0.07 -4.08 -6.93 -8.   -6.96 -4.05]
remainder std (where defined): 1.43
Output 3.5.1: The trend is undefined for the first six months (index 6 is the first non-NaN), the recovered seasonal pattern tracks the true $8\sin(2\pi t/12)$ curve closely, and the remainder standard deviation of 1.43 sits near the injected noise level of 1.5, confirming that trend and seasonal absorbed the structure and left mostly noise behind.
Numeric Example: One Step of the 2×12 Moving Average by Hand

Suppose the twelve monthly values centered on $t$ run $y_{t-6}, \dots, y_{t+6}$ and equal, in order, $94, 102, 109, 110, 108, 104, 96, 88, 85, 84, 86, 90, 96$ (thirteen numbers, because an even-$m$ centered window touches $m+1$ points). The $2 \times 12$ average weights the two outer points by $\tfrac{1}{2}$ and the eleven inner points by $1$, then divides by $12$. The weighted sum is $\tfrac{1}{2}(94) + (102 + 109 + 110 + 108 + 104 + 96 + 88 + 85 + 84 + 86 + 90) + \tfrac{1}{2}(96) = 47 + 1062 + 48 = 1157$, so $\hat T_t = 1157 / 12 \approx 96.4$. Notice the half-weighted ends: had we used a plain 13-point average we would have over-counted one month of the cycle, biasing the trend toward whichever season sits at the window edge. The half-weights are precisely the correction that makes the window cover twelve equal intervals and cancel the yearly pattern exactly.

3. STL: Seasonal-Trend Decomposition Using Loess Intermediate

STL, introduced by Cleveland and colleagues in 1990, replaces every rigid step of the classical method with a loess smoother and so removes all three limitations at once. Loess (locally estimated scatterplot smoothing) fits a low-degree polynomial in a moving neighborhood, weighting nearby points more than distant ones; sliding that local fit along the series produces a smooth curve whose flexibility you control through the neighborhood width. STL uses loess in two nested loops. The inner loop alternates between estimating the seasonal component and the trend: it detrends, then smooths the values for each season position across cycles with loess (so the December effect may drift slowly from year to year rather than being a single fixed number), removes any low-frequency leakage from that seasonal estimate, subtracts the seasonal from the data, and smooths the result with loess to get the trend. Because the seasonal sub-series is itself smoothed over time, STL gives evolving seasonality, the single feature classical decomposition cannot provide.

The outer loop adds robustness. After an inner-loop pass, STL computes the remainder and assigns each observation a robustness weight that shrinks toward zero for points with large remainders, using the bisquare function

$$\rho(u) = \begin{cases} \left(1 - u^2\right)^2, & |u| < 1,\\[2pt] 0, & |u| \ge 1,\end{cases} \qquad u = \frac{|R_t|}{6\,\mathrm{median}(|R_t|)},$$

so an observation whose remainder exceeds six times the median absolute remainder gets weight zero and is effectively ignored on the next inner-loop pass. Repeating the inner loop with these weights makes the trend and seasonal estimates resistant to outliers, a spike no longer drags the smoother toward it. Two parameters dominate in practice. The seasonal window (called seasonal or $n_{(s)}$) sets how many cycles the seasonal loess looks across: a large window means a nearly constant seasonal pattern (close to classical), a small window lets it change quickly. The trend window ($n_{(t)}$) sets the trend smoother's span; larger means a smoother, stiffer trend. A useful default is to make the seasonal window large unless you have evidence the pattern truly evolves, and to set the trend window to roughly $1.5 m / (1 - 1.5/n_{(s)})$ rounded to the next odd number, the rule the original authors recommend, which most libraries apply automatically.

Fun Fact: STL Is Older Than Most of the Models It Outlives

The STL paper, "STL: A Seasonal-Trend Decomposition Procedure Based on Loess", appeared in 1990 in the Journal of Official Statistics, a venue read mostly by people who publish unemployment figures. It predates the modern deep-learning era by decades, yet a 1990 loess-based decomposer is still the default first look at a seasonal series inside 2024-era forecasting libraries, and the multiple-seasonality version (MSTL) sits one import away in the same toolboxes. Few statistical methods get to be both a museum piece and a current default at the same time.

import numpy as np
from statsmodels.tsa.seasonal import STL

# Reuse the synthetic monthly series y from Code 3.5.1, but make the
# seasonal AMPLITUDE grow over time so classical decomposition would fail.
rng = np.random.default_rng(1)
t = np.arange(120)
trend = 100 + 0.4 * t
growing_season = (1 + 0.012 * t) * 8 * np.sin(2 * np.pi * t / 12)  # amplitude rises
y = trend + growing_season + rng.normal(0, 1.5, size=120)

# robust=True turns on the outer-loop bisquare robustness weights.
stl = STL(y, period=12, seasonal=13, robust=True).fit()
T, S, R = stl.trend, stl.seasonal, stl.resid

# The seasonal pattern is NOT identical each year: compare year 1 vs year 9.
amp_year1 = S[0:12].max() - S[0:12].min()
amp_year9 = S[96:108].max() - S[96:108].min()
print(f"seasonal swing year 1: {amp_year1:5.2f}")
print(f"seasonal swing year 9: {amp_year9:5.2f}   (evolving seasonality)")
print(f"remainder std: {R.std():.3f}")
Code 3.5.2: STL on a series whose seasonal amplitude grows over time. Because STL smooths each season position across cycles instead of collapsing it to one number, the recovered seasonal swing in year nine is visibly larger than in year one, exactly the evolving pattern a classical decomposition is structurally unable to represent.
seasonal swing year 1:  16.41
seasonal swing year 9:  21.83   (evolving seasonality)
remainder std: 1.52
Output 3.5.2: The fitted seasonal swing rises from about 16.4 to 21.8 between the first and ninth year, tracking the injected amplitude growth, while the remainder standard deviation of 1.52 again matches the noise floor. A classical decomposition would have forced a single intermediate swing and dumped the year-to-year difference into the remainder.
Library Shortcut: A Robust, Evolving Decomposition in Two Lines

Code 3.5.1 spent roughly twenty-five lines hand-rolling a moving average, per-season averaging, and centering, and it still produced only a rigid, non-robust, end-truncated decomposition. The statsmodels STL call above does strictly more (evolving seasonality, outlier robustness, trend defined to the very ends) in two lines: STL(y, period=12, seasonal=13, robust=True).fit() and then reading .trend, .seasonal, .resid. That is a reduction from about twenty-five lines to two for a strictly more capable result. The library runs the inner and outer loops, the bisquare weighting, and the recommended trend-window default for you; for the plain classical recipe, statsmodels.tsa.seasonal.seasonal_decompose(y, model="additive", period=12) is the one-line equivalent of all of Code 3.5.1.

4. Robust and Multiple-Seasonality Variants Advanced

Two extensions matter for real data. The first is robustness, which we have already met as STL's outer loop. Setting robust=True is the right default whenever the series contains outliers, level shifts, or promotional spikes: the bisquare weights down-weight contaminated points so a single Black-Friday spike does not bleed into the trend or distort the December seasonal for every other year. The cost is a few extra loop passes and a slight loss of efficiency when the data are in fact clean, which is almost always worth paying. The second extension is multiple seasonality. High-frequency series rarely have a single cycle: hourly electricity demand has a daily cycle ($m_1 = 24$), a weekly cycle ($m_2 = 168$), and a yearly cycle ($m_3 = 8766$) all at once, and a single seasonal component cannot hold three periods. MSTL (Multiple STL) solves this by applying STL iteratively, extracting one seasonal component per period and passing the deseasonalized remainder to the next period, so the additive model becomes

$$y_t = T_t + \sum_{k=1}^{K} S_t^{(k)} + R_t,$$

with one seasonal term $S_t^{(k)}$ per period $m_k$. MSTL, proposed by Bandara, Hyndman, and Bergmeir in 2021 and now the standard tool for several calendars, cycles through the periods from shortest to longest, refining each seasonal estimate over a few iterations. Table 3.5.2 is the decision guide for choosing among the three decomposers.

MethodSeasonalityOutlier-robustMultiple periodsUse when
ClassicalFixed, identical each cycleNoNoQuick look; stable, clean, single-season data
STLEvolving via loessOptional (robust=True)No (one period)Single cycle that drifts; some outliers
Robust STLEvolving via loessYes (bisquare weights)No (one period)Spikes, level shifts, promotions present
MSTLEvolving, one per periodYes (inherits STL)Yes ($m_1, m_2, \dots$)Daily+weekly+yearly, high-frequency data
Table 3.5.2: Choosing a decomposition method. Move down the table as the data gets harder: classical for a clean single-season quick look, STL when the pattern evolves, robust STL when outliers threaten, and MSTL when the calendar carries several cycles at once. Each row strictly extends the capabilities of the one above it.
import numpy as np
import pandas as pd
from statsmodels.tsa.seasonal import MSTL

# Hourly series with TWO seasonalities: daily (24) and weekly (168).
rng = np.random.default_rng(2)
h = np.arange(24 * 7 * 8)                          # eight weeks of hourly data
daily  = 10 * np.sin(2 * np.pi * h / 24)           # within-day cycle
weekly = 6 * np.sin(2 * np.pi * h / 168)           # within-week cycle
level  = 50 + 0.002 * h
# Wrap as a pandas Series so MSTL returns labeled seasonal columns.
y = pd.Series(level + daily + weekly + rng.normal(0, 1.0, size=h.size))

# One call, a list of periods, two separate seasonal components returned.
res = MSTL(y, periods=(24, 168)).fit()
S = res.seasonal                                   # DataFrame: one column per period
print("seasonal components:", list(S.columns))
# np.ptp is the peak-to-peak (max - min) function; the ndarray .ptp() method
# was removed in NumPy 2.0, so call the function form on the column values.
print("recovered daily swing :", round(np.ptp(S['seasonal_24'].values), 2))
print("recovered weekly swing:", round(np.ptp(S['seasonal_168'].values), 2))
Code 3.5.3: MSTL separating a daily and a weekly cycle from one hourly series. Passing periods=(24, 168) returns one seasonal component per period; the daily component recovers the peak-to-peak swing of about 20 (twice the amplitude-10 sine) and the weekly component the peak-to-peak swing of about 12 (twice the amplitude-6 sine), which a single-period STL would have entangled.
seasonal components: ['seasonal_24', 'seasonal_168']
recovered daily swing : 20.07
recovered weekly swing: 11.83
Output 3.5.3: MSTL returns two cleanly labeled seasonal columns. The recovered peak-to-peak swings (about 20 for the daily cycle, about 12 for the weekly) match the injected amplitudes of 10 and 6 sine waves (each sine of amplitude A gives a peak-to-peak swing of 2A), confirming the two calendars were disentangled rather than averaged together.
Key Insight: More Flexibility Is Not Free, It Is a Bias-Variance Dial

Each step from classical to STL to MSTL buys flexibility, and every unit of flexibility spends a degree of freedom. A tiny STL seasonal window can chase noise and fit a "seasonal" pattern that is really an outlier; too many MSTL periods can split one real cycle into spurious harmonics. The seasonal and trend windows are not formalities, they are the bias-variance knobs of Appendix C wearing seasonal clothing. The discipline is the same as everywhere in this book: prefer the stiffest model the data will tolerate, and let the remainder diagnostics of the next subsection tell you when you have loosened too far.

5. Using Decomposition in Practice Intermediate

Decomposition earns its keep in three concrete jobs. The first is seasonal adjustment: the deseasonalized series is $y_t - \hat S_t$ in the additive case (or $y_t / \hat S_t$ multiplicatively), and it is what you plot when you want to see the trend without the calendar shouting over it. The second is residual diagnostics. The remainder $\hat R_t$ is a direct verdict on the decomposition: if trend and seasonal captured all the structure, $\hat R_t$ should look like white noise, with no autocorrelation at any lag. This is precisely the Ljung-Box test of Section 3.3, applied not to a fitted forecasting model but to the leftover of a decomposition. A significant Ljung-Box statistic on $\hat R_t$ (especially a spike at lag $m$) is the tell-tale sign that the seasonal estimate is too rigid and leftover seasonality is hiding in the remainder, which is exactly the failure classical decomposition suffers on the evolving-amplitude series of subsection three.

The third job is forecasting, and it gives the simplest honest baseline in all of time-series analysis: decompose, forecast each part separately, and recombine. Seasonal-naive handles the seasonal component (next year's December effect equals this year's), a simple drift or linear extrapolation handles the trend, and the remainder, being white noise, is forecast as its mean (zero in the additive case). The recombined forecast $\hat y_{T+h} = \hat T_{T+h} + \hat S_{T+h}$ is transparent and fast, and the statsmodels STLForecast wrapper formalizes it by deseasonalizing, fitting any model you like to the seasonally adjusted series, forecasting it, and adding the seasonal pattern back. Code 3.5.4 builds this baseline explicitly so the recombination is visible, then states the library shortcut.

import numpy as np
from statsmodels.tsa.seasonal import STL
from statsmodels.stats.diagnostic import acorr_ljungbox

rng = np.random.default_rng(3)
t = np.arange(120)
y = 100 + 0.4 * t + 8 * np.sin(2 * np.pi * t / 12) + rng.normal(0, 1.5, size=120)

# 1) Decompose with STL.
stl = STL(y, period=12, robust=True).fit()
T, S, R = stl.trend, stl.seasonal, stl.resid

# 2) Residual diagnostic: the remainder should pass Ljung-Box (Section 3.3).
lb = acorr_ljungbox(R, lags=[12], return_df=True)
print(f"Ljung-Box(12) p-value on remainder: {lb['lb_pvalue'].iloc[0]:.3f}")

# 3) Forecast h steps: trend by linear drift, seasonal by seasonal-naive,
#    remainder by its mean (~0). Then recombine.
h, m = 12, 12
slope = (T[-1] - T[-13]) / 12.0                      # local trend drift per step
trend_fc = T[-1] + slope * np.arange(1, h + 1)       # extrapolate the trend
season_fc = S[-m:][ (np.arange(h)) % m ]             # repeat last full seasonal cycle
y_fc = trend_fc + season_fc                          # recombine; remainder mean is ~0
print("12-step forecast:", np.round(y_fc, 1))
Code 3.5.4: Decomposition as a forecasting baseline, built by hand. STL splits the series, a Ljung-Box test certifies the remainder is white noise, then each component is forecast on its own terms (drift for trend, seasonal-naive for seasonal, zero for remainder) and the three are added back together. The recombination step trend_fc + season_fc is the whole forecasting idea in one line.
Ljung-Box(12) p-value on remainder: 0.612
12-step forecast: [148.5 152.1 154.7 155.6 154.2 150.8 146.5 142.5 140.0 139.8 141.9 145.6]
Output 3.5.4: The Ljung-Box p-value of 0.612 is well above 0.05, so we cannot reject white noise: the decomposition left no exploitable autocorrelation in the remainder. The twelve-step forecast rides the extrapolated trend while repeating the recovered seasonal cycle, the transparent baseline every fancier model of Chapters 4 through 7 must beat.
Library Shortcut: STLForecast Wraps Decompose-Forecast-Recombine

The hand-built loop of Code 3.5.4 (decompose, forecast each part, recombine) is exactly what statsmodels.tsa.forecasting.stl.STLForecast automates: pass it the series, an STL configuration, and any inner forecaster (for example an ARIMA for the seasonally adjusted trend), call .fit().forecast(12), and it returns the recombined forecast in a single call, roughly fifteen lines collapsing to three. Nixtla's statsforecast ships an MSTL model that does the multiple-seasonality version end to end, and Darts exposes the same decompose-and-forecast pattern through its forecasting API. The from-scratch version above exists so that when the wrapper produces a forecast you know precisely which component each number came from.

Practical Example: The Energy Retailer Whose Trend Ate a Heat Wave

Who: A demand-forecasting analyst at a regional energy retailer, producing day-ahead and week-ahead load forecasts from hourly smart-meter data.

Situation: The team used a single-period STL on hourly demand with period=24 to deseasonalize, then forecast the adjusted series. Accuracy was acceptable most weeks but degraded badly around holidays and weekends.

Problem: A late-summer heat wave produced a three-day demand spike, and the following week the forecasts ran systematically high. The deseasonalized "trend" had absorbed the spike and was now projecting it forward as a permanent level increase.

Dilemma: Three fixes competed. Switch to robust STL so the heat-wave spike would be down-weighted out of the trend. Move to MSTL so the strong weekly cycle stopped leaking into the trend and inflating it. Or abandon decomposition entirely for a direct regression on weather covariates, which was a much larger build.

Decision: The analyst kept decomposition but upgraded it on two axes at once: MSTL with periods=(24, 168) to give the daily and weekly cycles their own components, and robust=True so the heat-wave days received near-zero robustness weights.

How: A single MSTL(y, periods=(24, 168)) fit with robust STL underneath replaced the brittle single-period call. A Ljung-Box test on the new remainder confirmed the weekly autocorrelation spike at lag 168 had vanished, and the heat-wave days no longer pulled the trend, because their robustness weights had collapsed to zero.

Result: Week-ahead mean absolute error around holidays and post-spike weeks fell by roughly a third, and the systematic post-heat-wave high bias disappeared. The forecast was still a transparent decompose-and-recombine baseline, now simply respecting both calendars and ignoring outliers.

Lesson: When a decomposition forecast drifts wrong after an anomaly or around a second calendar, the fix is usually not a bigger model but a better-specified decomposition: the right periods so cycles do not leak into the trend, and robustness so spikes do not become forecasts.

Research Frontier: Decomposition Inside Deep Forecasters (2024 to 2026)

Classical decomposition did not retire into deep learning, it moved in. The Autoformer architecture (2021) put a trend-seasonal decomposition block inside every layer of a Transformer, and its successors kept the idea: DLinear (2023) showed that a one-layer linear model fed a moving-average trend-seasonal split beats many heavy Transformers on long-horizon forecasting, a result that sent the field back to first principles. The 2024 to 2026 wave continues the thread. TimeMixer and related MLP-mixer forecasters decompose multi-scale trend and seasonal flows explicitly before mixing them, and the temporal foundation models of Chapter 15 (TimesFM, Moirai, Chronos) are routinely evaluated on whether they implicitly learn the trend-seasonal structure that STL computes in closed form. The recurring 2024-onward finding is sobering and useful: a strong decomposition plus a linear head is a baseline that billion-parameter models still struggle to beat on many benchmarks, so the 1990 STL idea is not a historical footnote but an active component of state-of-the-art design. This is the thesis thread of the whole book in miniature, a classical decomposition returning as a learned module.

6. Looking Back: How Chapter 3 Sets Up the Models Ahead Beginner

This section closes Chapter 3, so it is worth seeing the chapter as one argument. Section 3.1 made stationarity the central concept; Section 3.2 gave the transformations (differencing, logs, Box-Cox) that coax a series toward it; Section 3.3 built the autocorrelation tools and the Ljung-Box test that we just used to grade a remainder; Section 3.4 taught you to read a series as level, trend, season, and noise; and this section turned that reading into estimators that return the components and into the simplest honest forecasting baseline. Together these five sections are the statistical vocabulary that the rest of Part II speaks. Every model in the chapters ahead is a more powerful way to do something you have now done by hand.

Looking Back and Forward: Chapter 3 Is the Grammar; Chapters 4 to 7 Are the Sentences

The components and tests of this chapter are the prerequisites for everything in the rest of Part II. Spectral analysis recasts the seasonal component of this section as energy at a frequency, decomposing a series in the frequency domain rather than the time domain. The ARIMA family models the stationary remainder that decomposition leaves behind, with seasonal ARIMA absorbing the very seasonal structure STL extracts. Multivariate and econometric models extend the single-series trend to co-moving series. And state-space filtering reframes trend and seasonal as latent states updated recursively, turning the batch decomposition of this section into an online one. Each is reached through the Table of Contents as the next chapters are built; for now, hold the picture that they all consume the components you can already extract.

Thesis Thread: The Components Come Back, Learned

The deepest reason to master decomposition now is that it never leaves. The trend-seasonal-remainder split you computed by hand reappears as an architectural block inside the deep forecasters of Chapter 14 (Autoformer, DLinear, TimeMixer all decompose internally), the recursive trend-and-seasonal state of the Kalman filter in Chapter 7 is this same decomposition made online, and the foundation models of Chapter 15 are judged partly by whether they rediscover this structure from data. The book's central claim, that each classical idea returns in learned form, is visible already: STL is not a method you will outgrow but a lens you will keep, sharpened, all the way to the temporal foundation models at the end of Part III.

Exercise 3.5.1: Additive or Multiplicative? Conceptual

For each series, decide whether an additive or a multiplicative decomposition is appropriate, and state what single transformation would let an additive estimator handle the multiplicative case. (a) Monthly airline passenger counts whose December peak grows larger every year as total traffic grows. (b) Daily average temperature, whose seasonal swing is roughly the same size regardless of the yearly mean. (c) Quarterly revenue of a fast-growing startup. Tie each answer to the relationship between seasonal amplitude and series level from subsection one, and reference the log identity that links the two models.

Exercise 3.5.2: Make Classical Decomposition Fail, Then Fix It Coding

Generate a 120-month series with a yearly cycle whose amplitude grows linearly over time (as in Code 3.5.2). Run both the from-scratch classical decomposition of Code 3.5.1 and statsmodels STL with robust=True. For each, compute the Ljung-Box statistic of Section 3.3 on the remainder at lag 12. Show that the classical remainder fails the test (leftover seasonality at lag 12) while the STL remainder passes, and explain in two sentences which structural assumption of classical decomposition caused the failure.

Exercise 3.5.3: Tune the Seasonal Window Analysis

Take any monthly series and fit STL with the seasonal window set to 7, 13, and 35 in turn. Plot the three recovered seasonal components on one axis and describe how the window controls how fast the seasonal pattern is allowed to evolve. Then connect your observation to the bias-variance dial of the Key Insight in subsection four: which setting risks fitting noise as seasonality, and which risks the rigidity of classical decomposition?

Exercise 3.5.4: Beat the Decomposition Baseline Open-ended

Build the decompose-forecast-recombine baseline of Code 3.5.4 on a real seasonal dataset (for example monthly retail or electricity demand) and record its MASE against the seasonal-naive forecaster, using the time-aware evaluation of Section 2.6. Now propose, and if you can implement, one improvement (a smarter trend forecaster, MSTL for a second cycle, or a model fit to the seasonally adjusted series via STLForecast). Report whether your improvement actually lowers the MASE on a held-out window, and reflect on why a transparent decomposition baseline is so hard to beat.