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.
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/"
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')
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
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
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()))
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.
- 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.
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')
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.
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()
slope = np.polyfit(np.arange(len(s)), s.values, 1)[0]
seas = dec.seasonal.groupby(dec.seasonal.index.month).mean()
print('trend: about $%d per month (~$%d per year)' % (slope, slope*12))
print('seasonal peak: month %d (+$%d) | trough: month %d ($%d)' % (seas.idxmax(), seas.max(), seas.idxmin(), seas.min()))
print('irregular (residual) std: about $%d' % np.nanstd(dec.resid))
trend: about $283 per month (~$3406 per year) seasonal peak: month 12 (+$6258) | trough: month 1 ($-3825) irregular (residual) std: about $1184
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.
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)')
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.
A series is stationary when its statistical behavior does not depend on time: a roughly constant mean and variance, and no trend or seasonality. Most models assume it, and most raw series fail it. The Augmented Dickey-Fuller (ADF) test checks formally, a small p-value means stationary.
def adf(x, label):
p = adfuller(x.dropna())[1]
print(f'{label:26s} ADF p = {p:.3f} -> {"stationary" if p<0.05 else "NON-stationary"}')
adf(s, 'raw series')
adf(s.diff(), 'first difference')
raw series ADF p = 0.903 -> NON-stationary first difference ADF p = 0.021 -> stationary
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: mean is flat (stationary)'); ax[1].legend()
plt.tight_layout(); plt.show()
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 passes (p about 0.02). 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.
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)
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¶
- Trend the long-run direction; smooth it out with a moving average.
- Seasonality a fixed-period repeat (here every 12 months, peaking in December).
- Cyclical longer, irregular swings with no fixed length.
- 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.