Fit a Cyclic Smooth to Seasonal Data

Use bs=‘cc’ to fit a smooth that joins at the endpoints, ideal for periodic data.

A cyclic cubic spline (bs='cc') is constrained so its value and derivatives match at the two endpoints of the range. This makes it ideal for seasonality: January connects smoothly to December without any artificial discontinuity. Combine it with a long-run trend smooth to decompose a time series into trend and seasonal components in a single model.

Load data

Load the co2 dataset, which contains monthly mean atmospheric CO2 concentrations with a decimal-year time index and a 1–12 month column.

import whittaker as wk

data = wk.load_dataset("co2", as_frame=True)
data.head()
t year month co2
0 1958.000000 1958.0 1.0 317.727088
1 1958.083333 1958.0 2.0 316.767705
2 1958.166667 1958.0 3.0 315.072684
3 1958.250000 1958.0 4.0 313.010324
4 1958.333333 1958.0 5.0 312.988797

The columns are co2 (ppm), t (decimal year, e.g. 1958.04), and month (integer 1–12).

Fit

Fit a model with two smooth terms: a TPRS for the long-run trend over t, and a cyclic cubic spline for the annual seasonal cycle over month. Setting k=12 for the cyclic term matches the natural frequency of the 12-month cycle.

# Fit trend and cyclic seasonal smooths
model = wk.GAM(
    "co2 ~ s(t) + s(month, bs='cc', k=12)"
).fit(data)

model.summary()
GAM fit summary
============================================================
Formula:    co2 ~ s(t) + s(month, bs='cc', k=12)
Family:     Gaussian(link='identity')
Inference:  GCV
Observations: 504
Coefficients: 20

Parametric coefficients:
  Term                       Estimate    Std.Err    t value    p-value
  ------------------------ ---------- ---------- ---------- ----------
  (Intercept)                349.3071     0.0171  20368.123    < 1e-16

Approximate significance of smooth terms:
  Term                        EDF Ref.df     Chi.sq    p-value
  ------------------------ ------ ------ ---------- ----------
  s(t)                       1.76      2 1413169.004    < 1e-16
  s(month, bs='cc', k=12)    8.02      9  17901.268    < 1e-16

Total EDF:  10.78
Scale est:  0.143047
Deviance:   70.5538
Null dev:   205200.9698
Dev. expl:  100.0%
GCV score:  0.146174
AIC:        461.00
BIC:        506.53

Both smooths should have meaningful EDF values. The trend smooth typically absorbs the rising baseline while the cyclic smooth captures the summer-winter oscillation. The deviance explained should be very high for this well-behaved time series.

Partial effects

wk.partial_effects(model)

The two panels show the decomposed components. The trend panel reveals the accelerating rise in CO2 over decades. The seasonal panel shows the annual cycle peaking in spring (Northern Hemisphere growing season not yet active) and troughing in late summer. Crucially, the curve closes smoothly at month 1/12 without a step discontinuity.

Diagnostics

wk.check(model)

Check that residuals show no remaining seasonal structure. If a seasonal pattern remains in the residuals, raise k for the cyclic term.