←Back to the bookChapter 132 · Components of a Time Series
▶ Open in Colab⬇ Download notebookView on GitHub
Chapter 132 · Time Series & Forecasting
Components of a Time Series
Pull six years of monthly sales apart into trend, seasonality, cycle, and irregular noise with statsmodels; then test stationarity with ADF, difference the series to achieve it, and see how autocorrelation separates real signal from white noise.
Statistics, Data Science and AI: A Visual Handbook · John Fisher · 2026

Components of a Time Series¶

A time series is data indexed by time, one value per period, in order. The order is the whole point: today depends on yesterday. This notebook takes six years of monthly online-store sales and pulls it apart into the four pieces every time series is built from, trend, seasonality, cycle, and the irregular remainder, then asks the question every forecasting method starts with: is the series stationary? We lean on statsmodels, the standard time-series library.

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import seaborn as sns   # seaborn = high-level statistical plots (heatmaps, pairplots, count/bar plots)
from matplotlib.colors import ListedColormap
EM="#0284c7"; DEEP="#075985"; LIGHT="#bae6fd"; INK="#1a2138"; GRID="#e6e9f2"; RED="#ef4444"; AMBER="#d97706"; GREEN="#059669"; BLUE="#2563eb"; PUR="#9333ea"; GREY="#94a3b8"; SLATE="#475569"; ORG="#0284c7"; CYAN="#0891b2"
plt.rcParams.update({"figure.facecolor":"white","axes.facecolor":"white","figure.dpi":110,"font.size":11,
   "axes.edgecolor":GRID,"axes.grid":True,"grid.color":GRID,"axes.axisbelow":True,"axes.spines.top":False,
   "axes.spines.right":False,"axes.titlesize":12,"axes.titleweight":"bold","legend.frameon":False})
sns.set_style("whitegrid")
BASE="https://raw.githubusercontent.com/johnfisher-ai/Statistics-Data-Science-AI-Visual-Book/main/data/"
In [2]:
from statsmodels.tsa.seasonal import seasonal_decompose, STL
from statsmodels.tsa.stattools import adfuller, acf
from statsmodels.graphics.tsaplots import plot_acf
import warnings; warnings.filterwarnings('ignore')
WORKED EXAMPLE · A MONTHLY SALES SERIES
Six years of an online store
72 monthly observations, 2018 to 2023.
In [3]:
try: raw = pd.read_excel('../../data/components-of-a-time-series--monthly_retail_sales.xlsx', sheet_name='Data')
except FileNotFoundError: raw = pd.read_excel(BASE + 'components-of-a-time-series--monthly_retail_sales.xlsx', sheet_name='Data')
raw['month'] = pd.to_datetime(raw['month'])
s = raw.set_index('month')['sales'].asfreq('MS')     # a proper monthly time index
print('points:', len(s), '| from', s.index.min().date(), 'to', s.index.max().date())
s.head()
points: 72 | from 2018-01-01 to 2023-12-01
Out[3]:
month
2018-01-01    14405
2018-02-01    15019
2018-03-01    20594
2018-04-01    20760
2018-05-01    19937
Freq: MS, Name: sales, dtype: int64
In [4]:
fig,ax=plt.subplots(figsize=(10,4))
ax.plot(s.index, s.values, color=EM, lw=1.8, marker='o', ms=3)
ax.set(title='Monthly online-store sales', ylabel='sales ($)', xlabel='month')
plt.tight_layout(); plt.show()
print('first-year avg %d  ->  last-year avg %d  (the series is trending up)' % (s[:12].mean(), s[-12:].mean()))
No description has been provided for this image
first-year avg 22088  ->  last-year avg 37342  (the series is trending up)

Two things jump out of the raw plot: the level drifts upward year over year, and a repeating yearly wave peaks every December. Those are the first two components. Let us name all four.

DEMO 1 · THE FOUR COMPONENTS
Trend, seasonality, cycle, irregular
Every time series is a sum of these pieces.
  • Trend the long-run direction (here, steady growth).
  • Seasonality a pattern that repeats on a fixed calendar period (here, every 12 months).
  • Cyclical longer swings with no fixed length (business cycles, multi-year booms and busts).
  • Irregular the random leftover once the rest is removed.
In [5]:
ma12 = s.rolling(12, center=True).mean()     # a 12-month moving average smooths away the yearly wave
fig,ax=plt.subplots(figsize=(10,4))
ax.plot(s.index, s.values, color=LIGHT, lw=1.5, label='observed')
ax.plot(ma12.index, ma12.values, color=EM, lw=2.6, label='12-month moving average (trend+cycle)')
ax.set(title='A moving average reveals the underlying trend', ylabel='sales ($)'); ax.legend()
plt.tight_layout(); plt.show()
print('a 12-month moving average averages out one full seasonal cycle, leaving trend + slow cycle')
No description has been provided for this image
a 12-month moving average averages out one full seasonal cycle, leaving trend + slow cycle

A centered 12-month moving average cancels the yearly ups and downs (they sum to about zero over a year), leaving the smooth trend-plus-cycle underneath. That is the intuition behind formal decomposition.

DEMO 2 · DECOMPOSITION
Split the series in one line
statsmodels does the arithmetic for you.
In [6]:
dec = seasonal_decompose(s, model='additive', period=12)
fig = dec.plot(); fig.set_size_inches(10,7.5); [a.set_ylabel(l) for a,l in zip(fig.axes,['observed','trend','seasonal','resid'])]
plt.tight_layout(); plt.show()
No description has been provided for this image
In [7]:
slope = np.polyfit(np.arange(len(s)), s.values, 1)[0]
seas = dec.seasonal.groupby(dec.seasonal.index.month).mean()
print('trend: about $%.0f per month (~$%.0f per year)' % (slope, slope*12))
print('seasonal peak: month %d (+$%.0f)  |  trough: month %d ($%.0f)' % (seas.idxmax(), seas.max(), seas.idxmin(), seas.min()))
print('irregular (residual) std: about $%.0f' % np.nanstd(dec.resid))
trend: about $284 per month (~$3406 per year)
seasonal peak: month 12 (+$6259)  |  trough: month 1 ($-3826)
irregular (residual) std: about $1185

seasonal_decompose splits the series into observed = trend + seasonal + residual. The readout confirms the eye: the trend adds roughly 284 dollars a month (about 3,400 a year), the seasonal effect peaks in December (about +6,300) and bottoms in January (about -3,800), and the leftover irregular noise has a standard deviation near 1,200 dollars. The seasonal panel is a clean repeating shape, exactly what “seasonality” means.

DEMO 3 · ADDITIVE OR MULTIPLICATIVE?
Does the swing grow with the level?
Two ways the pieces combine.
In [8]:
mult = seasonal_decompose(s, model='multiplicative', period=12)
fig,ax=plt.subplots(1,2,figsize=(11,3.6))
ax[0].plot(dec.resid.index, dec.resid.values, color=EM); ax[0].axhline(0,color=GREY); ax[0].set(title='additive residual (flat band)')
ax[1].plot(mult.resid.index, mult.resid.values, color=PUR); ax[1].axhline(1,color=GREY); ax[1].set(title='multiplicative residual (around 1)')
plt.tight_layout(); plt.show()
print('additive: observed = trend + seasonal + resid  (seasonal swing is a fixed dollar amount)')
print('multiplicative: observed = trend x seasonal x resid  (swing grows in proportion to the level)')
No description has been provided for this image
additive: observed = trend + seasonal + resid  (seasonal swing is a fixed dollar amount)
multiplicative: observed = trend x seasonal x resid  (swing grows in proportion to the level)

In an additive series the seasonal swing is a roughly constant size; in a multiplicative one it grows as the series grows (think percentages, not dollars). A quick tell: if the yearly wave gets visibly taller as the trend rises, go multiplicative, or model log(sales), which turns a multiplicative series into an additive one. Here the additive residual sits in a flat band, so additive is a fair fit.

DEMO 4 · IS IT STATIONARY?
The question forecasting starts with
Constant mean and variance, or not?

A series is stationary when its statistical behavior does not depend on time: a roughly constant mean and variance, and no trend or seasonality. ARIMA-family models assume it (after differencing), and most raw series fail it. The Augmented Dickey-Fuller (ADF) test checks formally. Its null hypothesis is a unit root, a kind of non-stationarity, so a small p-value rejects the unit root and is evidence for stationarity, while a large one means the test cannot rule a unit root out.

In [9]:
def adf(x, label):
    p = adfuller(x.dropna())[1]
    print(f'{label:26s} ADF p = {p:.3f}  ->  {"unit root rejected" if p<0.05 else "unit root not rejected"}')
adf(s, 'raw series')
adf(s.diff(), 'first difference')
print('first difference ACF at lag 12 = %.2f  (the yearly pattern is still there)' % acf(s.diff().dropna(), nlags=12)[12])
raw series                 ADF p = 0.903  ->  unit root not rejected
first difference           ADF p = 0.021  ->  unit root rejected
first difference ACF at lag 12 = 0.46  (the yearly pattern is still there)
In [10]:
fig,ax=plt.subplots(1,2,figsize=(11,3.8))
ax[0].plot(s.index, s.values, color=EM); ax[0].plot(s.rolling(12).mean().index, s.rolling(12).mean().values, color=RED, lw=2, label='rolling mean')
ax[0].set(title='raw: rolling mean drifts up (non-stationary)'); ax[0].legend()
d1 = s.diff()
ax[1].plot(d1.index, d1.values, color=EM); ax[1].axhline(0,color=GREY); ax[1].plot(d1.rolling(12).mean().index, d1.rolling(12).mean().values, color=GREEN, lw=2, label='rolling mean')
ax[1].set(title='differenced: trend gone, rolling mean flat'); ax[1].legend()
plt.tight_layout(); plt.show()
No description has been provided for this image

The raw series fails the test (p about 0.90): its rolling mean climbs, so the mean depends on time. Differencing, replacing each value with its change from the month before, removes the trend, and the first difference rejects a unit root (p about 0.02). It does not remove the seasonality: the differenced series still drops every January, and its autocorrelation at lag 12 is 0.46. The green line looks flat because it is a 12-month rolling mean, which averages the season away, and a seasonal difference or seasonal terms in the model handle that part. Differencing is the standard move to make a series stationary, and it is exactly the “I” (integrated) in ARIMA, which the next chapter builds on.

DEMO 5 · WHITE NOISE & AUTOCORRELATION
The baseline a good model reduces to
If residuals are white noise, you have captured the signal.
In [11]:
wn = pd.Series(np.random.default_rng(0).normal(0, 1, 200))
print('white noise: mean %.2f, constant variance, no memory' % wn.mean())
print('ACF of raw series  lag-1 = %.2f (strong memory), lag-12 = %.2f (seasonal echo)' % (acf(s.dropna(),nlags=12)[1], acf(s.dropna(),nlags=12)[12]))
print('ACF of white noise lag-1 = %.2f (no memory)' % acf(wn,nlags=1)[1])
fig,ax=plt.subplots(1,2,figsize=(11,3.8))
plot_acf(s.dropna(), lags=24, ax=ax[0]); ax[0].set(title='ACF of the sales series (slow decay + spike at 12)')
plot_acf(wn, lags=24, ax=ax[1]); ax[1].set(title='ACF of white noise (all bars near zero)')
plt.tight_layout(); plt.show()
white noise: mean 0.02, constant variance, no memory
ACF of raw series  lag-1 = 0.79 (strong memory), lag-12 = 0.58 (seasonal echo)
ACF of white noise lag-1 = 0.04 (no memory)
No description has been provided for this image

White noise is a series with zero mean, constant variance, and no autocorrelation, no memory from one step to the next. It is the thing you cannot forecast, and the goal is for your model's residuals to look like it. The autocorrelation function (ACF) makes memory visible: the sales series correlates strongly with its recent past (lag-1 about 0.79) and shows a spike at lag 12 (the yearly echo), while white noise has every bar near zero. When residuals finally look like the right-hand plot, the signal has been captured.

The four components, in one view¶

  1. Trend the long-run direction; smooth it out with a moving average.
  2. Seasonality a fixed-period repeat (here every 12 months, peaking in December).
  3. Cyclical longer, irregular swings with no fixed length.
  4. Irregular the random remainder, ideally white noise.

The one idea to keep: a time series is trend plus season plus cycle plus noise, and the first job is always to check stationarity, because differencing a non-stationary series into a stationary one is the doorway to every forecasting model ahead.


Statistics, Data Science and AI: A Visual Handbook · © 2026 John Fisher
←Back to the bookChapter 132 · Components of a Time Series