Challenges · C08 · ★★★★☆

Mauna Loa CO2: a forecast that knows what it does not know

Difficulty
★★★★☆
Time
4-5 hours
Data
NOAA Global Monitoring Laboratory, monthly mean CO2 at Mauna Loa, March 1958 to the present
Skills
Structural time-series models from GP building blocks · pm.gp.HSGP as a model component · why stationary kernels cannot extrapolate a trend · identifiability between additive components and how priors restore it · honest hold-out evaluation (RMSE, interval coverage, baselines) · turning a posterior predictive into a "when will it cross" answer

The brief

A climate-policy analyst is preparing a briefing. Two numbers will go on the first slide:

  1. When will the monthly mean CO2 concentration at Mauna Loa first exceed 450 ppm? Not a single date - a distribution she can quote as "most likely X, almost certainly by Y".
  2. How fast is CO2 rising now, compared with the 1960s? In ppm per year, with uncertainty.

She has been burned before by forecasts with error bars that turned out to be decorative. So before she trusts your answer you must show that your model, fitted without the last ten years, would have forecast those ten years well - and that its intervals meant what they said.

This is the most famous time series in climate science and it looks easy: a smooth rise with a seasonal wiggle. The obvious GP model for it has a flaw that a ten-year hold-out barely exposes, and the model that fixes the flaw will not sample until you think hard about which component is allowed to explain what.

How this notebook works

  • Each task states what to deliver, not how. Write your code in the YOUR CODE HERE cells.
  • Stuck? h.hint("task3") reveals hints one level at a time: nudge → approach → code skeleton.
  • h.check("task5", rmse=...) compares your numbers with the reference solution.
  • A full worked solution lives in notebooks/solutions/. Open it only when you are done.
  • Assumes you have worked through E05 (GPs and HSGP).
import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import preliz as pz
import pymc as pm
import pytensor.tensor as pt
from pymc.model.transform.optimization import freeze_dims_and_data

from pymc_challenges import Hints, data

RANDOM_SEED = 1958
rng = np.random.default_rng(RANDOM_SEED)
az.style.use("arviz-variat")

h = Hints("C08")
h.tasks()

C08 - Mauna Loa CO2: a forecast that knows what it does not know

  • task0 - Decompose it by eye (3 hints, 2 check(s))
  • task1 - Hold out ten years and set the bar (3 hints, 2 check(s))
  • task2 - The obvious GP (3 hints, 2 check(s))
  • task3 - A structural model, first attempt (3 hints)
  • task4 - Tame it with priors (3 hints, 2 check(s))
  • task5 - Hold-out evaluation (3 hints, 2 check(s))
  • task6 - Answer the analyst (3 hints, 3 check(s))

The data

One row per month. average is the monthly mean in ppm and decimal date is the middle of the month as a fractional year. deseasonalized is NOAA's own seasonal adjustment - you may look at it, but your model must work from average. NOAA files use negative numbers as missing-value codes; find out which columns are affected before you trust any of them.

data.describe("co2")
co2 = data.load("co2").rename(columns={"decimal date": "t", "average": "ppm"})
co2.tail()
co2: Monthly mean atmospheric CO2 (ppm) from March 1958 to the present.
  source: NOAA Global Monitoring Laboratory, Mauna Loa monthly mean CO2.
  url:    https://gml.noaa.gov/webdata/ccgg/trends/co2/co2_mm_mlo.csv
year month t ppm deseasonalized ndays sdev unc
817 2026 4 2026.2917 431.12 428.61 23 1.16 0.46
818 2026 5 2026.3750 432.34 429.06 19 0.64 0.28
819 2026 6 2026.4583 431.43 428.99 19 0.27 0.12
820 2026 7 2026.5417 429.13 428.78 21 0.61 0.26
821 2026 8 2026.6250 427.55 429.51 17 0.36 0.17
THRESHOLD = 450.0  # ppm
N_TEST = 120  # months held out: the last ten years

Task 0 · Decompose it by eye

Deliver

  1. A check for missing-value codes. Is ppm affected?
  2. A plot of the full series, and a close-up of a few years that shows the seasonal cycle.
  3. The average seasonal profile (12 numbers) after removing the trend in a simple, model-free way. In which month does CO2 peak, and what is the peak-to-trough range? Is that range the same in the 1960s and the 2010s?
  4. A rough growth rate (ppm/yr) per decade from annual means. This is the sanity check for everything your model says later.
Your turn Work this task in the notebook.

Task 1 · Hold out ten years and set the bar

Split off the last N_TEST months as a test set. You may not touch it for fitting until Task 6.

A model is only interesting if it beats something trivial. Deliver the hold-out RMSE of two baselines:

  • seasonal naive: every future month equals the same calendar month in the last training year;
  • seasonal naive with drift: the same, plus k times the average annual increase over the last ten training years for a month that lies k years (1, 2, ...) after its source month. Measure the increase as the change in the 12-month mean between the last training year and the year ending ten years before it, divided by ten.
train, test = co2.iloc[:-N_TEST], co2.iloc[-N_TEST:]
print(f"train: {train.t.iloc[0]:.2f} - {train.t.iloc[-1]:.2f} ({len(train)} months)")
print(f"test:  {test.t.iloc[0]:.2f} - {test.t.iloc[-1]:.2f} ({len(test)} months)")
train: 1958.20 - 2016.62 (702 months)
test:  2016.71 - 2026.62 (120 months)
Your turn Work this task in the notebook.

Task 2 · The obvious GP

E05 showed that a GP with a stationary kernel is a wonderful smoother. Use one as a forecaster: a single stationary kernel plus Gaussian noise, fitted to the training data, nothing else. Use pm.gp.HSGP and make sure the approximation is valid over the whole range you will predict on.

Performance note (not a modelling hint). When the inputs of an HSGP are a pm.Data container, the sine basis is recomputed at every gradient evaluation. Sample inside with freeze_dims_and_data(model): (imported above) to turn the data into constants for sampling; keep using the original model with pm.set_data for prediction. In this notebook it is the difference between one minute and half an hour.

Deliver

  1. The fit, with diagnostics. Compare the posterior lengthscale with the length of the record. What is the model trying to tell you?
  2. A forecast plot over the hold-out period with a 90% predictive band - and the same forecast extended to 2060.
  3. Hold-out RMSE and 90% coverage, next to the baselines.
  4. A short paragraph: what does the forecast do far from the data, and why would any stationary kernel do the same, whatever its hyperparameters? What does the noise term sigma represent in this model?
Your turn Work this task in the notebook.

Task 3 · A structural model, first attempt

Build an additive model for the training period in which every component has one job:

  • a trend that keeps rising when extrapolated, plus slow, smooth departures from it (decades) - nobody promised that the future follows a textbook curve exactly;
  • a seasonal cycle that repeats every year but whose size may change slowly;
  • medium-term irregular variation (a few years: El Niño, volcanic eruptions, recessions);
  • observation noise.

Work in sensible units (centre CO2, shift and scale time) so that priors can be read.

For this first attempt stay generic: give every GP component the same weakly informative priors for lengthscale and amplitude, as you would if you knew nothing about which component should explain what.

Deliver

  1. The fit and its diagnostics - all of them.
  2. Evidence (a table or a plot of the hyperparameter posterior) showing what the data cannot decide. Where do the divergences live?
  3. One paragraph: why is this posterior hard? Think about what happens to the likelihood if one component hands some of its signal to another.
Your turn Work this task in the notebook.

Task 4 · Tame it with priors

Without removing a component, and without heroic sampler settings (a moderate target_accept is fine), make the decomposition identifiable through priors alone. Every prior should be one you can defend to the analyst in physical units (years, ppm).

Deliver

  1. Your priors and the argument for each.
  2. Clean diagnostics.
  3. A plot of the fitted components over the training period.
  4. Is the seasonal cycle growing? Give the peak-to-trough range at the start and at the end of the training period, with uncertainty.
Your turn Work this task in the notebook.

Task 5 · Would it have worked? Hold-out evaluation

Deliver

  1. A forecast plot for the hold-out period with a 90% predictive band, against the truth.
  2. A table with RMSE for both baselines, the stationary GP and the structural model, plus coverage of the 50% and 90% predictive intervals for the two Bayesian models.
  3. An honest paragraph for the analyst: is the model accurate? Is it calibrated? How much can 120 months of hold-out data tell you about calibration, given how forecast errors behave over time?
Your turn Work this task in the notebook.

Task 6 · Answer the analyst

Refit your structural model on all the data.

Deliver

  1. The posterior predictive distribution of the first month in which the monthly mean exceeds 450 ppm: a plot, the median, and statements of the form "with probability p by the end of year Y". Use predictive draws of the observable monthly mean, not the trend.
  2. The growth rate of the trend (no seasonal, no irregular component), averaged over the last ten years of data, against the same for January 1960 - January 1970, in ppm/yr, and their ratio - all with 94% intervals.
  3. Three sentences for the slide, and one caveat the analyst must not leave out.
Your turn Work this task in the notebook.

Going further

  • Does the slow-departure component earn its place? Drop it, refit on the training period and repeat Task 5. What happens to the RMSE and to the coverage of the 90% interval, and why?
  • Rolling-origin evaluation. Refit with training data ending in 1986, 1996 and 2006 and forecast ten years each time. Does the 90% interval cover about 90% of the time across origins? Would you widen or narrow the prior on the amplitude of the slow departures?
  • Marginalise the coefficients. Given the hyperparameters, this model is linear and Gaussian in all ~130 coefficients, so they can be integrated out analytically, leaving NUTS a handful of hyperparameters. Implement the marginal likelihood with the Woodbury identity (cost $O(nm^2)$) and compare speed and results.
  • A physical covariate. The irregular component should correlate with El Niño. Find an ENSO index (e.g. NOAA's ONI), lag it by a few months, add it as a regressor and see how much of your irregular component it absorbs.
h.progress()

C08: 0/21 hints used, 13/13 checks passed.

Reference solution

A complete, executed solution with commentary on every task — including the ways the obvious model fails. Try the hint ladder first.

Open the worked solution →