In our previous posts, we both discussed splines and periodic patterns, we will now discuss how to use splines to model periodic patterns. We have already seen that a $p$ order spline is continuous and its first $p-1$ derivatives are continuous. We can easily make it periodic by constraining the last $p$ coefficients, as we will see in the next few lines. In order to show this, we downloaded the average daily temperature from open-meteo.

import arviz as az
import arviz_plots as azp
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pymc as pm
import seaborn as sns

rng = sum(map(ord,'temperature'))

df = pd.read_csv('open-meteo-45.10N7.70E239m.csv', skiprows=3)

df.head()
  time temperature_2m_mean (°C)
0 2010-01-01 3.5
1 2010-01-02 -1.4
2 2010-01-03 -0.6
3 2010-01-04 -2.3
4 2010-01-05 -1.3

We will here combine ordinary B-splines with periodic B-spline, so that we will be able to model local trends as well as the clear periodic component of the temperature.

The two functions to evaluate the spline weights are the following ones:

def bspline(t: np.array, x:np.array, i:int, p:int) -> float:
    """
    Returns a B-spline, defined as https://en.wikipedia.org/wiki/B-spline.

    Parameters:
    -----------
    t: np.array
    x: np.array
    i: int
    p: int

    Returns:
    np.array
    
    Raises:
    ------
    ValueError
       if i is not an integer between 0 and len(x)-p-1 (both included)
    """
    if i>=0 and i<len(x)-p-1:
        if p==0:
            return np.heaviside(t-x[i], 1)*np.heaviside(x[i+1]-t, 0)
        else:
            fac0 = (t-x[i])/(x[i+p]-x[i])
            fac1 = (x[i+p+1]-t)/(x[i+p+1]-x[i+1])
            return (fac0*bspline(t, x, i, p-1)+fac1*bspline(t, x, i+1, p-1))
    else:
        raise ValueError(f'Got i={i}, i must be an integer between 0 and len(x)-p-1={len(x)-p-1}')

def cyclic_bspline(t: np.array, knots: np.array, i: int, p: int) -> float:
    """
    Periodic (cyclic) B-spline basis function.

    Parameters
    ----------
    t : float or np.array
        Parameter value(s) where the basis is evaluated.
    knots : np.array
        Uniform knot vector (must be periodic-compatible).
    i : int
        Basis function index.
    p : int
        Degree.

    Returns
    -------
    float or np.array
    """
    n = len(knots) - p - 1   # number of B-spline basis functions

    # Wrap index cyclically
    i_mod = i % n

    # Base case
    if p == 0:
        return np.heaviside(t - knots[i_mod], 1) * np.heaviside(knots[i_mod + 1] - t, 0)

    # Recurrence (Cox–de Boor) with wrapped indices
    denom0 = knots[i_mod + p] - knots[i_mod]
    denom1 = knots[i_mod + p + 1] - knots[i_mod + 1]

    term0 = 0.0
    term1 = 0.0

    if denom0 != 0:
        term0 = (t - knots[i_mod]) / denom0 * cyclic_bspline(t, knots, i_mod, p - 1)

    if denom1 != 0:
        term1 = (knots[i_mod + p + 1] - t) / denom1 * cyclic_bspline(t, knots, i_mod + 1, p - 1)

    return term0 + term1

We will use order-2 splines


p_fit = 2

We can now start with the preprocessing

df['time'] = pd.to_datetime(df['time'])

df['day'] = df['time'].dt.dayofyear  # for the periodic part

df = df[['time', 'day', 'temperature_2m_mean (°C)']]

fig, ax = plt.subplots(figsize=(12, 6))
ax.scatter(df['time'], df['temperature_2m_mean (°C)'],  marker='x')

As expected, we can see a strong periodic component, as well as some local trend behavior. Since the spline is always zero at the boundaries, we will both include an constant and a trend component.

Let us now implement the splines. First of all, we will implement the periodic component.

Xday = np.minimum(df['day'], [365]*len(df))/365  # for leap years

knots_day = np.linspace(0, 1, 15)

splines_dim_day = len(knots_day)-p_fit-1

basis_day = np.array([cyclic_bspline(Xday, knots_day, i, 2) for i in range(splines_dim_day)])

We can now implement the non-periodic ones

Xdate = np.arange(len(df))/len(df)

knots_date = np.linspace(0, 1, 40)

splines_dim_date = len(knots_date)-p_fit-1

basis_date = np.array([bspline(Xdate, knots_date, i, 2) for i in range(splines_dim_date)])

# the last p basis vectors are equal to the first p ones
# in order to ensure smoothness at the boundaries

basis_day_full = np.concatenate([basis_day,  basis_day[:p_fit+1]])

We can now build and fit the model

kwargs = dict(draws=2000, tune=2000, nuts_sampler='nutpie', target_accept=0.9, random_seed=rng)

coords = {'obs': df.index, 'basis_day': range(np.shape(basis_day)[0]), 'basis_date': range(np.shape(basis_date)[0])}

with pm.Model(coords=coords) as model:
    eps = pm.Exponential('eps', 1)
    alpha = pm.Normal('alpha', mu=0, sigma=100)
    beta = pm.Normal('beta', mu=0, sigma=50, dims=('basis_day'))
    beta_full = pm.Deterministic('beta_full', pm.math.concatenate([beta, beta[:p_fit+1]]))
    gamma = pm.Normal('gamma', mu=0, sigma=50, dims=('basis_date'))
    delta = pm.Normal('delta', mu=0, sigma=50)
    mu = pm.Deterministic('mu', alpha + pm.math.dot(beta_full, basis_day_full) + pm.math.dot(gamma, basis_date) + delta*Xday)
    sigma = pm.HalfNormal('sigma', sigma=50)
    y = pm.Normal('y', mu=mu, sigma=sigma, observed=df['temperature_2m_mean (°C)'], dims=('obs'))

with model:
    idata = pm.sample(**kwargs)

fig, ax = plt.subplots(figsize=(15, 6))
az.plot_hdi(x=df['time'], y=idata.posterior['mu'], ax=ax, smooth=False)
fig.tight_layout()

fig, ax = plt.subplots(figsize=(15, 6))
az.plot_hdi(x=df['time'], y=idata.posterior['mu'], ax=ax, smooth=False)
sns.scatterplot(df, x='time', y='temperature_2m_mean (°C)', color='lightgray', alpha=0.4, ax=ax, marker='x')
fig.tight_layout()

The predicted pattern clearly reproduces the observed one.

Conclusions

Spline can be adapted to account for periodic data, and in this way they are a powerful alternative to periodic GP, if you know the period of your periodic component.

%load_ext watermark

%watermark -n -u -v -iv -w -p xarray,numpyro,jax,jaxlib,pytensor
Last updated: Fri Nov 21 2025

Python implementation: CPython
Python version : 3.13.9
IPython version : 9.7.0

xarray : 2025.1.2
numpyro : 0.19.0
jax : 0.8.0
jaxlib : 0.8.0
pytensor: 2.35.1

arviz_plots: 0.6.0
matplotlib : 3.10.7
pandas : 2.3.3
arviz : 0.23.0.dev0
seaborn : 0.13.2
numpy : 2.3.4
pymc : 5.26.1

Watermark: 2.5.0