# Fit a Cyclic Smooth to Seasonal 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.


``` python
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.


``` python
# 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


``` python
wk.partial_effects(model)
```


<style>
  #altair-viz-d676442a5a504d6295ef4c41a14590a4.vega-embed {
    width: 100%;
    display: flex;
  }

  #altair-viz-d676442a5a504d6295ef4c41a14590a4.vega-embed details,
  #altair-viz-d676442a5a504d6295ef4c41a14590a4.vega-embed details summary {
    position: relative;
  }
</style>


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


``` python
wk.check(model)
```


<style>
  #altair-viz-e8c32801e4f8405db7070d3e6701a252.vega-embed {
    width: 100%;
    display: flex;
  }

  #altair-viz-e8c32801e4f8405db7070d3e6701a252.vega-embed details,
  #altair-viz-e8c32801e4f8405db7070d3e6701a252.vega-embed details summary {
    position: relative;
  }
</style>


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