learn-timeseries-forecasting-with-phoebe / Builder session 2 of 10
Learn Timeseries Forecasting with Phoebe · Builder track · Session 2 of 10

Decomposition & baselines: pull the series apart, then set the bar

Before any model, you do two things. First you decompose the series into trend, seasonal, and residual with STL, so you can see what is actually in it. Then you build the family of baselines - naive, drift, and seasonal-naive - because those are the lines every clever model in this course has to beat. We do both in real pandas on the Lumen daily-sessions series, and by the end seasonal-naive wins on our weekly data - exactly the humility lesson the M-competitions keep teaching.

🟢 Builder track Practitioners: analysts · data scientists · engineers Python 3.10+ · a notebook ~45 min
0-3 · Welcome 3-17 · STL decomposition 17-42 · Build-along: STL + baselines 42-45 · Q&A
Part 0

Look before you model

Last session we built the Lumen series, split it by time, and shipped one baseline. Tonight we earn the right to model. Decomposition pulls the observed line into its trend, its repeating seasonal shape, and the residual left over - so you stop guessing what is in the data and start seeing it. Then we build the full baseline family: naive, drift, and seasonal-naive. These are not throwaway models - they are the scoreboard. Anything you build later has to beat them on a backtest, or it does not ship.

Live - presented in session Self-study - read after class ★ Run it now (Python) Sources covered (cited)
★ What you walk out with today An STL decomposition of the Lumen series with trend, seasonal, and residual you can read; a one-number strength-of-seasonality score; and three working baselines - naive, drift, and seasonal-naive - scored side by side with MAPE on the holdout, so you know the exact bar every model in this course has to clear.
Part 1 · covers FPP3 decomposition (STL)

STL splits a series into three readable parts 7 min live

STL - Seasonal-Trend decomposition using Loess - takes the observed line and separates it into a smooth trend, a repeating seasonal component (here on a weekly period, m=7), and a residual - whatever is left. Read together, the three panels tell you which method on the ladder to reach for: strong seasonal shape says a seasonal method, a wandering trend says smoothing, and a fat residual says do not over-promise.

STL: observed = trend + seasonal + residual Observed the messy real line Trend the slow drift Seasonal repeats every 7 days Residual what is left - noise Strength of seasonality = 1 - Var(residual) / Var(seasonal + residual). Near 1 means the weekly shape dominates. A tight residual means the trend and seasonal explain most of the movement - good news for a seasonal model.
🔍 Click to zoom - STL separates observed into trend + seasonal + residual
LiveReading the three components3 min▶

Each panel answers a different modelling question:

  • Trend - is the level drifting up or down, and is it straight or curved? A clear trend rules out a flat naive forecast.
  • Seasonal - how big and how stable is the repeating shape? A strong, steady weekly pattern is a gift: seasonal-naive alone will already be hard to beat.
  • Residual - what is left after trend and seasonal are removed? If it is small and patternless, you have captured the signal. If it still has structure, there is more to model.
Real world

STL is additive by default, and robust. Lumen's weekend dips are a fixed weekly shape, so an additive STL fits cleanly. If the seasonal swings grew with the level (bigger sales, bigger weekend dip), you would log-transform first or use a multiplicative view. STL's robust=True option also down-weights one-off spikes like a launch day, so they do not distort the trend.

Self-studySTL vs classical decomposition2 min read▶

FPP3 favours STL over the older classical decomposition for three reasons:

  • The seasonal component is allowed to change slowly over time, not frozen for the whole series.
  • It is robust to outliers - a bad day does not bend the trend.
  • You control the smoothness of trend and season directly via the Loess windows.

The tradeoff: STL handles only additive decomposition and one seasonal period at a time. For our weekly Lumen series that is exactly right.

Part 2 · covers FPP3 simple forecasting methods

The baseline family - the lines to beat 6 min live

Three baselines cover almost every simple case, and each is one idea. Naive: tomorrow equals today's last value, held flat. Drift: naive plus the average slope of the history, so the forecast keeps rising or falling. Seasonal-naive: tomorrow equals the same period one cycle ago. On a weekly series like Lumen, seasonal-naive usually wins - and that is the whole point of measuring against it.

Naive - hold the last value flat flat - no trend, no season forecast Drift - last value + average slope follows the overall slope Seasonal-naive - repeat the last cycle copies last week's shape history forecast On a weekly-seasonal series, seasonal-naive carries the pattern forward - which is why it is the honest bar.
🔍 Click to zoom - naive flat, drift sloped, seasonal-naive repeats the last week
LiveWhich baseline fits which series3 min▶

Match the baseline to the shape you saw in the decomposition:

  • No trend, no season - naive is the right bar. A random-walk-like series is genuinely hard to beat.
  • Clear trend, weak season - drift, so the forecast keeps moving in the trend's direction.
  • Strong season (our case) - seasonal-naive, which carries the weekly shape forward for free.
Baselines also give you free prediction intervals Each baseline has a standard-error formula, so you can attach an interval that widens with the horizon - the honest way to forecast. We build point forecasts today and add intervals properly with backtesting in b8.
Demo 1 of 2

Decompose the Lumen series with STL ★ 12 min · everyone builds

Fit STL on the daily series, pull out the three components, and score how seasonal the series really is.

Rebuild the Lumen daily series from b1 so this notebook runs standalone.

Fit STL(s, period=7) and unpack trend, seasonal, and residual.

Print the seven-day seasonal shape and compute the strength-of-seasonality score.

Plot res.plot() and read the three panels before moving on.

★ Run it now (Python) - STL decompositionimport pandas as pd, numpy as np from statsmodels.tsa.seasonal import STL # rebuild the Lumen daily sessions series (from b1) idx = pd.date_range("2026-01-01", periods=112, freq="D") season = np.array([40,55,60,50,30,-120,-140]) # weekday shape i = np.arange(112) y = 1000 + 2.5*i + season[i % 7] + 14*np.sin(i*1.7) + 9*np.cos(i*0.6) s = pd.Series(np.round(y), index=idx, name="sessions") res = STL(s, period=7, robust=True).fit() # weekly seasonality trend, seasonal, resid = res.trend, res.seasonal, res.resid print(seasonal.iloc[:7].round(1).to_list()) # the weekly shape # strength of seasonality (FPP3): 1 - Var(resid) / Var(seasonal + resid) Fs = max(0.0, 1 - resid.var() / (seasonal + resid).var()) print(f"strength of seasonality: {Fs:.2f} (near 1 = strongly seasonal)") res.plot() # trend / seasonal / resid panels
Read the residual panel hardest The residual is your honesty check. If it still shows a wave or a drift, STL has not captured everything and a richer model may help. If it looks like patternless noise, the trend and seasonal already explain the series - and a simple seasonal method will be tough to beat.
Demo 2 of 2

Score the baseline family on the holdout ★ 10 min · build your own

Build naive, drift, and seasonal-naive for the horizon H, score each with MAPE on the same holdout, and print the table. On this weekly series, seasonal-naive should come out on top.

Reuse the time split from b1: last H=14 days as test, the rest as train.

Build all three baselines as pandas Series aligned to the test index.

Score each with MAPE and sort the table - the winner is your bar for the rest of the course.

★ Run it now (Python) - naive, drift, seasonal-naive + MAPE tableH, m = 14, 7 # horizon, weekly period train, test = s.iloc[:-H], s.iloc[-H:] # naive: last observed value, held flat naive = pd.Series(train.iloc[-1], index=test.index) # drift: last value + average slope of the history * step slope = (train.iloc[-1] - train.iloc[0]) / (len(train) - 1) drift = pd.Series(train.iloc[-1] + slope * np.arange(1, H + 1), index=test.index) # seasonal-naive: same weekday one cycle (7 days) ago snaive = pd.Series([train.iloc[-m + (k % m)] for k in range(H)], index=test.index) def mape(actual, fc): return (np.abs(actual.values - fc.values) / actual.values).mean() * 100 table = pd.DataFrame( {"MAPE %": [mape(test, naive), mape(test, drift), mape(test, snaive)]}, index=["naive", "drift", "seasonal-naive"]).round(2) print(table.sort_values("MAPE %")) # seasonal-naive should win here
Real world

This table is your scoreboard for the whole course. In b3 you will watch Holt-Winters try to beat the seasonal-naive line; in b4 SARIMA; in b6 gradient boosting. The M-competitions - the field's largest empirical bake-offs - found that simple methods and their combinations are remarkably hard to beat, so a complex model only earns its keep if it clears this bar on a proper backtest (b8). If it cannot, the baseline ships, and it is also your fallback when a fancy model fails in production.

Homework

Try it yourself - this week ◐ 30-45 min total

Source material

Sources covered (cited)

Taught from the forecasting canon and the published empirical evidence. This session covers:

✓Forecasting: Principles and Practice, 3rd ed (Hyndman & Athanasopoulos, FPP3)Ch 3 (STL decomposition) · Ch 5.2 (naive, drift, seasonal-naive)
◐M-competitions (Makridakis, M4/M5)Demo 2 · simple methods and combinations are hard to beat
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What three parts does STL split a series into?

STL - Seasonal-Trend decomposition using Loess - separates the observed line into a smooth trend, a repeating seasonal component, and the residual left over. Reading the three tells you which method to reach for.

2 · How does the drift baseline differ from the naive baseline?

Naive holds the last value flat. Drift extends the line by the average slope between the first and last points, so a trending series keeps moving in that direction.

3 · Why score all three baselines before building a real model?

The winning baseline is your scoreboard. The M-competitions show simple methods are hard to beat, so a complex model only earns its complexity if it clears that bar - and the baseline is your production fallback.

Builder session 2 cheat sheet · pin this

STLSeasonal-Trend decomposition using Loess: observed = trend + seasonal + residual. STL(s, period=7).
Read the residualpatternless noise = signal captured; leftover structure = more to model.
Strength of seasonality1 - Var(resid) / Var(seasonal + resid). Near 1 = strongly seasonal.
Naivetomorrow = today's last value, held flat. The bar for no-trend, no-season series.
Driftlast value + average slope. The bar when there is a clear trend.
Seasonal-naivetomorrow = same weekday last week. The bar for seasonal series - usually the winner.
Score with MAPEbuild all three, sort the table, keep the winner as your scoreboard.
Next sessionb3 - exponential smoothing / ETS (SES, Holt, Holt-Winters) + the live forecaster.