Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Non-Stationary GEVD: Non-Parametric μ(t) via P-Splines

Open In Colab

The previous notebook fit a linear trend μ(t)=β0+β1t\mu(t) = \beta_0 + \beta_1 t. Real climate, hydrological, and structural-loading time-series rarely look that simple — trends accelerate, plateau, oscillate, or kink. We need a non-stationary GEVD with a flexible, non-parametric representation of μ(t)\mu(t).

This notebook uses penalized B-splines (P-splines), the workhorse non-parametric basis-expansion method:

μ(t)  =  ∑k=1Kθk Bk(t),\mu(t) \;=\; \sum_{k=1}^{K} \theta_k \, B_k(t),

where BkB_k are B-spline basis functions on a fixed knot grid and θk\theta_k are coefficients with a smoothness prior. The smoothness prior is the “P” in P-spline (Eilers & Marx, 1996): a random-walk prior on θ\theta that shrinks toward smooth fits but lets the data override that prior where the signal is strong.

What we cover

  1. Theory. B-spline basis construction; the random-walk smoothness prior; the equivalence with reduced-rank Gaussian process regression.

  2. Synthetic non-linear data. 80-yr series with a sigmoidal “tipping-point” trend in μ(t) — the kind of curve a linear fit cannot capture.

  3. Fit. NumPyro NUTS over the spline coefficients θ\theta, the smoothness τ\tau, and the stationary (σ,ξ)(\sigma, \xi).

  4. Comparison. Stationary, parametric-linear, and P-spline fits side-by-side on the same data.

  5. Smoothness sensitivity. Sweep the prior on τ\tau to show under-/over-smoothing.

Python implementation: CPython
Python version       : 3.12.3
IPython version      : 9.10.0

jax       : 0.10.1
numpy     : 2.4.4
matplotlib: 3.10.8
numpyro   : 0.21.0
scipy     : 1.17.1
xtremax   : 0.0.0

Compiler    : GCC 13.3.0
OS          : Linux
Release     : 6.18.5
Machine     : x86_64
Processor   : x86_64
CPU cores   : 4
Architecture: 64bit

1. P-splines in three formulas

B-spline basis. Given a sorted knot sequence κ=(κ1,…,κK+d+1)\boldsymbol\kappa = (\kappa_1, \ldots, \kappa_{K+d+1}) and a polynomial degree dd (typically d=3d = 3, cubic), the de Boor recurrence builds KK basis functions Bk(t;κ,d)B_k(t;\boldsymbol\kappa, d) each of which:

  • is a degree-dd polynomial on each knot interval,

  • has compact support on [κk,κk+d+1][\kappa_k, \kappa_{k+d+1}],

  • is non-negative and the basis sums to 1 everywhere (partition of unity).

We evaluate them on the data times {ti}\{t_i\} to build the design matrix B∈Rn×K\mathbf{B} \in \mathbb{R}^{n \times K} with Bik=Bk(ti)\mathbf{B}_{ik} = B_k(t_i). Then

μ(ti)  =  (B θ)i.\mu(t_i) \;=\; (\mathbf{B}\,\boldsymbol\theta)_i.

The smoothness prior (the “P” in P-spline). A flat Gaussian prior on θ\boldsymbol\theta would let the spline interpolate the data exactly when KK is large. Eilers & Marx (1996) put the prior on differences:

θk−θk−1  ∼  N(0, τ2),k=2,…,K,\theta_k - \theta_{k-1} \;\sim\; \mathcal{N}(0,\, \tau^2), \qquad k = 2, \ldots, K,

i.e. a Gaussian random walk on the coefficients. Equivalently, the prior on θ\boldsymbol\theta has precision matrix D⊤D/τ2\mathbf{D}^\top \mathbf{D} / \tau^2 where D\mathbf{D} is the first-difference operator. Under this prior, increasing KK does not increase model complexity — only decreasing τ\tau does. This decouples the resolution of the basis (set KK generously) from the effective smoothness (set τ\tau, or hyperprior over it).

The full hierarchical model.

τ∼HalfNormal(τ0),θ1∼N(zˉ,sz),θk−θk−1∼N(0,τ2),k≥2,σ∼HalfNormal(2sz),ξ∼N(−∞,0)(−0.1, 0.22),zi∣ti,θ,σ,ξ∼GEVD((Bθ)i, σ, ξ).\begin{aligned} \tau &\sim \mathrm{HalfNormal}(\tau_0), \\ \theta_1 &\sim \mathcal{N}(\bar z, s_z), \\ \theta_k - \theta_{k-1} &\sim \mathcal{N}(0, \tau^2), \quad k \ge 2, \\ \sigma &\sim \mathrm{HalfNormal}(2 s_z), \\ \xi &\sim \mathcal{N}_{(-\infty,0)}(-0.1,\, 0.2^2), \\ z_i \mid t_i, \boldsymbol\theta, \sigma, \xi &\sim \mathrm{GEVD}\bigl((\mathbf{B}\boldsymbol\theta)_i,\, \sigma,\, \xi\bigr). \end{aligned}

The parent series here is light-/bounded-tailed (Gaussian block maxima fall in the Weibull domain, ξ<0\xi<0), so we use an upper-truncated prior on ξ\xi that keeps ξ<0\xi<0. Besides encoding that domain knowledge, this removes the moving GEVD support boundary z<μ−σ/ξz<\mu-\sigma/\xi flipping on and off as ξ\xi crosses zero — the sharp likelihood cliff that otherwise drives NUTS divergences (see §4).

Bayesian-frequentist correspondence. Penalised likelihood with penalty λ∥Dθ∥2\lambda \|\mathbf{D}\boldsymbol\theta\|^2 is equivalent (up to a Bayes-frequentist correspondence) to MAP estimation under this prior with τ2=1/λ\tau^2 = 1/\lambda. Smaller τ\tau ↔ stronger smoothness penalty.

2. Synthetic data with non-linear trend

We plant a sigmoidal trend in μ(t)\mu(t) — a slow rise, a steeper transition in the middle, then a gentler late-record approach to a plateau. This is the textbook “tipping point” pattern seen in many climate diagnostics, and it is exactly the shape a linear fit cannot reproduce.

<Figure size 1000x400 with 1 Axes>

3. Build the cubic B-spline design matrix

We pick K=12K = 12 basis functions on a uniform knot grid spanning the year covariate. Cubic (d=3d = 3) gives a smooth C2C^2 fit. With K=12K = 12 and a P-spline penalty the effective number of parameters is far smaller than 12 — the smoothness prior shrinks redundant coefficients toward each other.

scipy.interpolate.BSpline.design_matrix gives the design matrix directly, given the data times and an interior knot sequence.

Design matrix shape: (80, 12)  (K = 12)
<Figure size 1000x360 with 1 Axes>

Each curve is a C2C^2 cubic with compact support over four adjacent knot intervals. The basis sums to 1 everywhere inside the knot range — that is the partition-of-unity property and ensures that a constant function is exactly representable.

4. NumPyro model with first-difference smoothness prior

NUTS settings. Hierarchical priors with a learned smoothness scale τ\tau are a textbook source of funnel geometry — the conditional posterior on θ\boldsymbol\theta given small τ\tau is much narrower than given large τ\tau, and HMC step sizes that work in one regime fail in the other. We tame it with three standard levers: (1) the non-centered parameterisation — sample δk∼N(0,1)\delta_k \sim \mathcal{N}(0,1) and form θk=θ0+τ⋅cumsum(δ)\theta_k = \theta_0 + \tau \cdot \mathrm{cumsum}(\delta); (2) an upper-truncated ξ<0\xi<0 prior, which removes the GEVD support-boundary cliff (the parent is bounded-tailed here, so this is also the physically correct domain); and (3) a moderately informative smoothness prior τ∼HalfNormal(0.5)\tau \sim \mathrm{HalfNormal}(0.5) — the same mid-range value the §6 sensitivity sweep shows recovers the sigmoid — together with target_accept_prob=0.999, max_tree_depth=13, and num_warmup=5000. With these the committed run reports 0 divergences and r_hat = 1.00 for every parameter, so the spline posterior and the return-level band below are trustworthy.

/tmp/claude-0/ipykernel_24897/3894782468.py:37: UserWarning: There are not enough devices to run parallel chains: expected 2 but got 1. Chains will be drawn sequentially. If you are running MCMC in CPU, consider using `numpyro.set_host_device_count(2)` at the beginning of your program. You can double-check how many devices are available in your system using `jax.local_device_count()`.
  mcmc = MCMC(kernel, num_warmup=5000, num_samples=2000, num_chains=2, progress_bar=False)

                mean       std    median      2.5%     97.5%     n_eff     r_hat
  delta[0]     -0.21      0.85     -0.22     -1.84      1.47   2993.10      1.00
  delta[1]     -0.86      0.79     -0.88     -2.39      0.68   3594.25      1.00
  delta[2]     -0.70      0.76     -0.70     -2.15      0.77   3214.05      1.00
  delta[3]     -0.06      0.73     -0.09     -1.49      1.36   2881.36      1.00
  delta[4]      2.11      0.73      2.11      0.70      3.61   3423.53      1.00
  delta[5]      1.28      0.74      1.26     -0.12      2.79   3325.77      1.00
  delta[6]      0.62      0.72      0.60     -0.90      1.98   2642.62      1.00
  delta[7]      0.62      0.71      0.62     -0.78      1.97   3161.37      1.00
  delta[8]      0.52      0.72      0.53     -0.89      1.94   3718.20      1.00
  delta[9]     -0.29      0.76     -0.27     -1.76      1.16   3724.26      1.00
 delta[10]     -0.78      0.85     -0.77     -2.44      0.90   4060.90      1.00
     sigma      1.57      0.15      1.56      1.28      1.88   3036.75      1.00
       tau      1.16      0.24      1.14      0.72      1.63   2461.63      1.00
  theta[0]     29.55      0.92     29.57     27.68     31.27   3008.71      1.00
  theta[1]     29.33      0.75     29.32     27.94     30.87   4400.90      1.00
  theta[2]     28.33      0.63     28.34     27.15     29.59   4095.57      1.00
  theta[3]     27.55      0.59     27.57     26.38     28.69   3642.76      1.00
  theta[4]     27.45      0.62     27.46     26.21     28.60   3177.42      1.00
  theta[5]     29.86      0.59     29.85     28.70     31.01   4047.90      1.00
  theta[6]     31.29      0.57     31.28     30.14     32.38   3734.37      1.00
  theta[7]     31.95      0.57     31.95     30.83     33.02   3414.03      1.00
  theta[8]     32.64      0.56     32.64     31.55     33.75   4105.25      1.00
  theta[9]     33.24      0.66     33.23     31.92     34.52   3865.98      1.00
 theta[10]     32.90      0.71     32.91     31.47     34.20   4209.75      1.00
 theta[11]     31.99      0.89     32.02     30.19     33.68   4630.98      1.00
    theta0     29.55      0.92     29.57     27.68     31.27   3008.72      1.00
        xi     -0.12      0.06     -0.11     -0.23     -0.00   2151.04      1.00

Number of divergences: 0
Number of divergences: 0

Posterior fit on μ(t)\mu(t)

The crucial picture: the posterior median of μ(t)\mu(t) overlaid on the data and the truth.

<Figure size 1000x420 with 1 Axes>

5. Comparison: stationary, linear, P-spline

A side-by-side: stationary GEVD, linear-trend non-stationary GEVD, and the P-spline above.

/tmp/claude-0/ipykernel_24897/2668165547.py:26: UserWarning: There are not enough devices to run parallel chains: expected 2 but got 1. Chains will be drawn sequentially. If you are running MCMC in CPU, consider using `numpyro.set_host_device_count(2)` at the beginning of your program. You can double-check how many devices are available in your system using `jax.local_device_count()`.
  mcmc_s = MCMC(
/tmp/claude-0/ipykernel_24897/2668165547.py:37: UserWarning: There are not enough devices to run parallel chains: expected 2 but got 1. Chains will be drawn sequentially. If you are running MCMC in CPU, consider using `numpyro.set_host_device_count(2)` at the beginning of your program. You can double-check how many devices are available in your system using `jax.local_device_count()`.
  mcmc_l = MCMC(
<Figure size 1100x500 with 1 Axes>

Reading this.

  • Stationary (red): a flat horizontal line through the data mean — bias the wrong way at both ends of the record.

  • Linear (orange): captures the average slope across 80 years but misses the sigmoidal shape — under-predicts the early plateau and over-predicts the late plateau.

  • P-spline (blue): smooth C2C^2 fit that tracks the truth.

The P-spline credible band is widest near the edges — that is generic for splines and reflects the lower number of data points there. In real climate work this is the honest picture.

6. Smoothness sensitivity

The fit depends on the prior tau_scale. Too small → over-smoothing (effectively a linear fit); too large → under-smoothing (wiggly fit). We sweep three values to make this concrete.

/tmp/claude-0/ipykernel_24897/1724363131.py:6: UserWarning: There are not enough devices to run parallel chains: expected 2 but got 1. Chains will be drawn sequentially. If you are running MCMC in CPU, consider using `numpyro.set_host_device_count(2)` at the beginning of your program. You can double-check how many devices are available in your system using `jax.local_device_count()`.
  mcmc_t = MCMC(
<Figure size 1100x450 with 1 Axes>

Reading this. The very-tight prior (τ0=0.05\tau_0 = 0.05) over-smooths into something close to a linear/constant fit. The very-loose prior (τ0=5\tau_0 = 5) lets the spline wiggle to interpolate noise. The middle choice (τ0=0.5\tau_0 = 0.5) reproduces the sigmoid. In practice, place a hyperprior on τ\tau so the data informs the smoothness — that is what we did in §4. The middle column from the sweep above is essentially the pre-posterior version of that.

7. Time-varying return level

As in the previous notebook, the year-TT return level is just μ(t)\mu(t) + a stationary excess:

zT(t)  =  μ(t)  +  σξ[(−log⁡(1−1/T))−ξ−1],z_T(t) \;=\; \mu(t) \;+\; \frac{\sigma}{\xi}\Bigl[(-\log(1-1/T))^{-\xi} - 1\Bigr],

but now μ(t)\mu(t) is the spline curve, so zT(t)z_T(t) inherits its sigmoidal shape.

<Figure size 1000x450 with 1 Axes>

Takeaways

  • P-splines fit non-linear μ(t)\mu(t) without picking a parametric form. Set KK generously; the smoothness prior controls effective complexity via τ\tau.

  • The first-difference random-walk prior is the right default. It penalises changes in θ\theta, so a constant function is in the prior null space (zero penalty) and any smooth shape can be reached given enough data.

  • Stationary and linear fits are nested specials cases. A P-spline with τ→0\tau \to 0 collapses to a constant; with K=2K = 2 and an identity basis it reduces to linear regression. So you can compare apples-to-apples by tightening / loosening the prior.

  • Honest credible bands. Spline uncertainty grows near the edges of the data range — visible in the figure above and a useful warning against over-extrapolation.

  • Generalisation. The same recipe applies to σ(t)\sigma(t) — fit log⁡σ(t)=Bθσ\log\sigma(t) = \mathbf{B}\boldsymbol{\theta}_\sigma with its own smoothness prior. Multivariate covariates: replace the 1D B-spline with a tensor-product basis or a Gaussian-process prior on μ\mu. Both are extensions in the same spirit.

The next notebook in the mini-series replaces the basis-expansion with a neural ODE: μ(t)\mu(t) is the solution of a learned dynamical system, which is even more flexible (and much harder to interpret).