
Time Series Forecasting
Time Series Forecasting
When the order of observations carries information
Chapter 16
Polla Fattah
By the end of today you can
- explain dependence over time, and why forecasts are ranges;
- name the parts of a series: trend, seasonality, noise;
- store a series in pandas with a datetime index, and plot it;
- read time plots, seasonal plots, and autocorrelation;
- decompose a series with STL;
- forecast with benchmarks, ETS, and ARIMA;
- evaluate forecasts on a test period, and report prediction intervals.
A different way of thinking
| Survey data | Time series |
|---|---|
| observations independent | this week relates to last week |
| order does not matter | order carries information |
| one value per case | trend, season, and noise together |
A forecast of the future is honestly a range, never a single number.
The counselling service’s question
Five years of weekly visit counts: overwhelmed before exams, quiet in summer, staffing planned by guesswork.
How many visits should the service expect next year, week by week?
An internal analysis, done for the service rather than for publication.
A pandas series knows which column is time
clinic = pd.DataFrame({
'day': range(1, 15),
'visits': [12, 10, 9, 11, 7, 3, 2, 14, 11, 10, 12, 8, 4, 2]
}).set_index('day')Two weeks of daily visits to a small clinic, from a Monday.
The index keeps observations in chronological order. Busy early in the week, quiet at the weekend.
Three parts of a pattern
weeks = np.arange(1, 157)
trend = 30 + 0.05 * weeks
seasonal = 12 * np.sin(2 * np.pi * weeks / 52)
noise = np.random.normal(0, 3, 156)
series = trend + seasonal + noiseAn artificial weekly series for three years, built from its parts.
Only the sum is observed
Forecasting extends trend and season. Noise cannot be forecast: the main reason every forecast has a range.
The counselling data in pandas
counselling_visits = dt.load_counselling_visits()
counselling_visits['week_start'] = pd.to_datetime(counselling_visits['week_start'])
visits = counselling_visits.set_index('week_start').sort_index()Five academic years, September 2020 to August 2025: 260 weeks.
week_start as DatetimeIndex aligns each observation to regular weekly Monday intervals.
Plot the series first

A seasonal pattern each academic year, a gentle upward trend, noise, and one unusual week in autumn 2023.
A seasonal plot

Every year has the same shape; later years lie slightly higher. The period is 52 weeks.
Busy weeks follow busy weeks

Knowing last week’s visits says a good deal about this week’s.
Autocorrelation
The correlation between the series and itself \(k\) steps earlier.
| Lag | 1 | 26 | 52 |
|---|---|---|---|
| autocorrelation | 0.69 | −0.24 | 0.68 |
A week resembles the same week a year earlier. Half a year apart, a busy term week often meets a quiet summer week.
The autocorrelation function

A peak at the seasonal lag is the signature of seasonality. No autocorrelation would mean nothing to forecast beyond the average.
Decomposition with STL
| Component | Is |
|---|---|
| trend | the slow movement |
| seasonal | the repeating academic-year pattern |
| remainder | what is left: the noise |
robust=True stops unusual weeks from distorting trend and season.
The recovered parts

The trend rises from about 35 to 41 visits a week, levelling off in the last year.
The unusual week
decomp_df = pd.DataFrame({
'week': visits.index, 'visits': visits['visits'],
'trend': visits_stl.trend, 'seasonal': visits_stl.seasonal,
'remainder': visits_stl.resid
})
top_odd = decomp_df.iloc[decomp_df['remainder'].abs().argsort()[::-1][:1]]The week of 23 October 2023: 71 visits, about 30 more than trend and season predict.
It was the mental health awareness week. A real week, not an error: kept, and the robust fit stops it distorting forecasts.
Growing seasonal swings
The peaks get higher as the trend rises, while the summer lows stay low.
When the season grows with the level, model the logarithm of the series:
\[ \log(\text{visits}) = \text{trend} + \text{seasonal} + \text{noise} \]
Forecast on the log scale, then exponentiate back with np.exp().
Benchmarks first
| Method | Forecasts |
|---|---|
| mean | the average of all past observations |
| naive | the last observed value |
| seasonal naive | the value from the same season last cycle |
Every forecasting method must beat these, as every model in Chapters 11–13 had to beat a baseline.
Benchmarks on the clinic
fc_mean = np.repeat(np.mean(clinic['visits']), 7)
fc_naive = np.repeat(clinic['visits'].iloc[-1], 7)
fc_snaive = clinic['visits'].iloc[-7:].values| Method | Forecast for next week |
|---|---|
| mean | 8.4 every day |
| naive | 2 every day: a quiet Sunday repeated |
| seasonal naive | last week’s pattern repeated |
Two families of models
| Family | Forecasts with |
|---|---|
| ETS (exponential smoothing) | weighted averages, recent weeks counting most; tracks level, trend, season |
| ARIMA | how each value depends on earlier values and shocks |
Both work best with short seasons: 4 quarters, 7 days, 12 months. A 52-week season is long.
Two strategies for weekly data
flowchart LR
A["Weekly series"] --> B["STL: remove<br>the season"] --> C["ETS on the<br>adjusted series"] --> D["Add the<br>season back"]
A --> E["ARIMA with<br>Fourier waves"]
Strategy 1: decompose with STL, fit ETS on adjusted data, add season back. Strategy 2: ARIMA with six pairs of harmonic waves.
Training and test periods
The test period must come after the training period: a forecast can only use the past.
Train on four academic years, test on the fifth.
Five models compared
# STL + ETS
stl_tr = STL(np.log(y_tr), period=52, robust=True).fit()
sa_tr = np.log(y_tr) - stl_tr.seasonal
ets_fit = Holt(sa_tr, initialization_method='estimated').fit()
fc_stl_ets = np.exp(ets_fit.forecast(52) + stl_tr.seasonal[-52:])Fit benchmarks, STL + ETS, and Fourier ARIMA.
Accuracy on the test year
| Model | RMSE | MAE |
|---|---|---|
| STL + ETS | 9.3 | 6.8 |
| Seasonal Naive | 9.2 | 7.0 |
| Fourier ARIMA | 11.4 | 8.9 |
| Mean | 18.7 | 16.3 |
| Naive | 27.5 | 23.5 |
STL with ETS is best. The Fourier model struggles with sharp steps, such as the sudden drop into summer.
The two best, against reality

The seasonal naive copies last year’s noise, even the awareness-week spike. STL with ETS is smoother and closer.
Forecasting next year
stl_all = STL(np.log(y_all), period=52, robust=True).fit()
sa_all = np.log(y_all) - stl_all.seasonal
ets_all = Holt(sa_all, initialization_method='estimated').fit()
fc_log_next = ets_all.forecast(52) + stl_all.seasonal[-52:]
fc_next = np.exp(fc_log_next)Refitted on all five years, so the forecasts use the most recent information.
Prediction intervals

First week: about 24 visits, anywhere from 17 to 33 unsurprising.
A total for the year needs simulation
np.random.seed(2026)
resid = stl_all.resid
futures = []
for _ in range(1000):
sim_noise = np.random.choice(resid, size=52, replace=True)
sim_path = np.exp(fc_log_next + sim_noise)
futures.append(np.sum(sim_path))
q = np.quantile(futures, [0.025, 0.5, 0.975]).round()Weeks do not vary independently, so weekly intervals cannot simply be added.
Residual bootstrap simulation produces 1,000 possible futures; their totals give the range.
The report to the service
- about 2,177 visits expected in 2025/26 (95% interval 2,050 to 2,464), against 2,056 in 2024/25;
- the busiest weeks will again be just before and during the exam periods;
- an awareness campaign can raise demand sharply for a week: plan extra capacity.
Forecasts assume the future resembles the past
A new booking system, a changed exam calendar, or a crisis like COVID-19 can make any forecast wrong.
- report forecasts with their intervals;
- say what they assume;
- update them as new data arrives.
In your field: environmental science
co2_m = co2_raw['co2'].resample('MS').mean().ffill()['1959-01-01':'1997-12-01']
# Train through 1990, forecast 1991-1997 (7 years)| Model | MAE (ppm) |
|---|---|
| Seasonal Naive | 5.1 |
| ETS | 1.0 |
| SARIMAX | 1.1 |
Mauna Loa CO₂: a strong trend and a 12-month cycle that ETS and ARIMA model directly.
Practical lab: the Chapter 16 playground
Work through the playground exercises in your browser, with hints and solutions.
Run interactive Python code with Pyodide; download the complete offline project.
Practical exercises 1–4: parts and models
- Which benchmark suits a trend without seasonality? Test it on the clinic.
- Plot the seasonally adjusted series: what does it reveal?
- Fourier terms with \(K = 2\) and \(K = 12\).
- Train on three years, test on the fourth.
Practical exercises 5–7: forecasts and communication
- Expected visits in the four weeks before the first exams, with an interval.
- Explain a 95% prediction interval to the service.
- Double the noise in the artificial series: what happens to forecast intervals?
Try this yourself
Find a series in your own field.
- plot it, and name its trend, season, and noise;
- choose the seasonal period;
- pick a benchmark it must beat;
- decide what event could make a forecast wrong.
Troubleshooting guide (Part 1)
| Symptom | Likely cause |
|---|---|
| “duplicated index” error | the same timestamp appears twice |
| ETS or ARIMA struggles with weekly data | the 52-week season is long; decompose first |
| forecasts copy last year’s spikes | seasonal naive repeats the noise |
| one week dominates the trend | use robust=True |
Troubleshooting guide (Part 2)
| Symptom | Likely cause |
|---|---|
| test error suspiciously low | test period not after training period |
| seasonal swings grow with the level | model np.log() of the series |
| a yearly total without an interval | weekly intervals cannot be added; simulate |
| forecasts wrong after a change | the past no longer resembles the future |
Completion checklist
Misconceptions to leave behind (Part 1)
| Misconception | Better mental model |
|---|---|
| a forecast predicts the exact value | a forecast is a range |
| more history always helps | only if old patterns still hold |
Misconceptions to leave behind (Part 2)
| Misconception | Better mental model |
|---|---|
| any random subset can be the test set | the test period comes after training |
| an unusual week should be removed | keep it; use robust methods |
The chapter in one sentence
Separate trend, season, and noise, beat the simple benchmarks on a later test period, and always report a forecast as a range with its assumptions.
Next: Chapter 17
The next chapter makes research reproducible:
- reproducibility and credibility;
- documents that contain their own analysis;
- a results chapter in Quarto;
- fixed package versions;
- interactive dashboards and open science.
Questions
What regular measurement in your field has a seasonal pattern?
What event could break its forecast?