A Laplace library of spline building blocks for Stan — B-spline, M-spline, I-spline, natural cubic and tensor-product bases, difference and derivative penalties, non-centred P-spline priors, and helpers for hierarchical (per-group) splines. Import it into any .laplace model and call it with namespaced calls (splines::function_name(...)).
Like all Laplace libraries, splines compiles down to plain, readable Stan functions. Nothing about how you use it hides what actually ends up in your .stan file.
Every equation in the spline literature answers one question: does this symbol depend on a parameter? The answer decides which Stan block it lives in, and the library is organised around that split.
| Symbol | Depends on | Stan block |
|---|---|---|
|
|
nothing | data |
|
|
data only | transformed data |
|
|
sampled | parameters |
| both | transformed parameters |
|
| the penalty on |
parameters | model |
So the library has two halves, and they never mix:
-
Bases produce
$\mathbf{B}$ . They are pure data computations, run once. Boundary conditions, monotonicity and positivity constraints — anything expressed about the function — get absorbed here and leave no trace anywhere else in the model. -
Penalties constrain
$\beta$ . They never touch$\mathbf{B}$ . They appear either as a generative recursion intransformed parametersor as a quadratic form inmodel.
This is why two things both called "splines" look nothing alike in code. A natural cubic spline is entirely a basis construction: once
Everything reduces to
with the spline types differing only in what the
Every basis except ns_basis needs a knot vector, and there are two conventions. They are not interchangeable, and choosing the wrong one silently breaks the P-spline penalty rather than raising an error.
| Function | Convention | Length | |
|---|---|---|---|
bspline_knots_clamped(a, b, xi, degree) |
A — clamped | ||
bspline_knots_uniform(a, b, n_seg, degree) |
B — uniform extended |
Convention A repeats each boundary knot splines::bs() produces. Use it for regression splines, M-splines and I-splines.
Convention B lays knots at equal spacing and extends
In both cases
| Function | Returns | What it gives you |
|---|---|---|
bspline_basis(x, t, degree) |
|
|
mspline_basis(x, t, order) |
Same bumps rescaled to unit integral | |
ispline_basis(x, t, order) |
Monotone columns rising |
|
ns_basis(x, xi) |
Cubic inside, linear outside the boundary knots | |
tensor_basis(Bx, Bz) |
A surface over two covariates | |
bspline_deriv_basis(x, t, degree, q) |
The |
|
center_basis(B) |
Any basis, made orthogonal to an intercept |
mspline_basis and ispline_basis take order
The foundation of everything else. Built bottom-up by the Cox–de Boor recursion:
Each basis function is nonzero on only
An M-spline is a B-spline rescaled to integrate to 1 rather than sum to 1:
Since columns are non-negative, a simplex[K] on
An I-spline is the integral of an M-spline. Each column rises monotonically from 0 to 1, so
is non-decreasing by construction. The shape constraint lives entirely in the sign of
The implementation uses the identity
A cubic spline forced to be linear outside the boundary knots, i.e.
Beyond B * beta and nothing else.
ns_basis takes interior knots directly, not a knot vector.
This is the row-wise Kronecker product (Khatri–Rao): the row index is shared between the two factors, so you get one row per observation rather than tensor_penalty_x / tensor_penalty_z.
| Function | Returns | Use it when |
|---|---|---|
difference_matrix(K, d) |
You need |
|
penalty_matrix(K, d) |
The centred form, |
|
gram_matrix(t, degree, q) |
Unequal knots, or penalising ns_basis
|
|
tensor_penalty_x(Kx, Kz, dx) |
|
Smoothing a surface along |
tensor_penalty_z(Kx, Kz, dz) |
|
Smoothing a surface along |
pspline(head, z, sigma, order) |
The default. Generative, non-centred | |
pspline_adaptive(head, z, sigma, order) |
Smoothness varies along the covariate |
The P-spline prior has an equivalent penalty form and generative form:
Non-centring the second and solving for the newest element gives a forward recursion, which is what pspline returns:
Prefer pspline over the quadratic form. The quadratic form is the centred parameterisation; it is closer to the published formula and samples considerably worse, typically producing divergences with pspline is implemented as
| Null space | head |
z |
|
|---|---|---|---|
| 1 | constant | 1 element | |
| 2 | constant + linear | 2 elements | |
| 3 | + quadratic | 3 elements |
So head is not a set of arbitrary starting values. It spans what the penalty leaves unconstrained, and it needs its own priors — the penalty will not supply them.
gram_matrix penalises the integrated roughness of the fitted function rather than differences of coefficients:
It is exact, not approximate: the integrand is piecewise polynomial of degree
The trade in one line: difference penalties are cheaper and simpler; derivative penalties are correct for unequal knots, and are the only option for ns_basis.
Standard P-splines assume the function is equally wiggly everywhere, which is often false — a dose–response curve may be flat at low doses and turn sharply at high ones. pspline_adaptive lets the scale vary:
The
For hierarchical splines, ncp_offset collapses the per-group assembly loop into a single broadcast. Four overloads, selected by the type of omega:
omega |
Effect | |
|---|---|---|
real |
One scale for everything | |
vector[K] |
Scales rows — per basis function | |
row_vector[J] |
Scales columns — per group | |
matrix[K, J] |
Fully general |
These know nothing about splines. ncp_offset is "non-centred offset of a matrix of group deviations from a shared vector", and serves GP coefficients or plain random effects equally well.
Every function carries @brief, @param, @return, @math, and (where useful) @example documentation, so you can read it from the terminal without leaving your model:
laplace doc splines::pspline
splines is distributed as a git-hosted Laplace library — there's no published registry entry yet, so it's added by pointing laplace (or cmdlaplacer, if you're working from R) directly at the repository. The package lives in the repository's laplace/ subdirectory, so pass it as the subdir.
From inside a Laplace project (a directory with its own laplace.toml):
laplace add splines --git https://github.com/mlatinov/laplace-splines --tag 0.1.0 --subdir laplace
library(cmdlaplacer)
laplace_install_git(
"splines",
"https://github.com/mlatinov/laplace-splines",
tag = "0.1.0",
subdir = "laplace"
)Either way, this pins the dependency in your project's laplace.toml/laplace.lock at tag 0.1.0. Check the tags for newer versions as they become available.
Import the library in a library { } block and call its functions with the splines:: namespace prefix.
The basic case: one smooth term, no grouping. The basis is built once in transformed data, and the coefficients are assembled in one line.
library {
import splines
}
data {
int<lower=1> N;
vector[N] x;
vector[N] y;
int<lower=1> n_seg; // number of knot segments, e.g. 20
}
transformed data {
int degree = 3;
int K = n_seg + degree;
vector[n_seg + 2 * degree + 1] t = splines::bspline_knots_uniform(min(x), max(x), n_seg, degree);
matrix[N, K] B = splines::bspline_basis(x, t, degree);
}
parameters {
vector[2] head; // null space of D_2: level and slope
vector[K - 2] z; // innovations
real<lower=0> sd_smooth; // how wiggly the curve may be
real<lower=0> sigma;
}
transformed parameters {
vector[K] beta = splines::pspline(head, z, sd_smooth, 2);
}
model {
z ~ std_normal(); // structural: part of the parameterisation
head ~ normal(0, 5); // yours: the penalty says nothing here
sd_smooth ~ normal(0, 1); // yours: controls smoothness
sigma ~ std_normal();
y ~ normal(B * beta, sigma);
}
generated quantities {
vector[N] f = B * beta;
}There is no separate intercept: the spline absorbs the level, and adding one would leave the model unidentified.
A population curve plus one deviation per group. pspline builds the shared coefficients, ncp_offset broadcasts the deviations.
library {
import splines
}
data {
int<lower=1> N;
int<lower=1> J; // number of groups
vector[N] x;
array[N] int<lower=1, upper=J> group;
vector[N] y;
int<lower=1> n_seg;
}
transformed data {
int degree = 3;
int K = n_seg + degree;
vector[n_seg + 2 * degree + 1] t = splines::bspline_knots_uniform(min(x), max(x), n_seg, degree);
matrix[N, K] B = splines::bspline_basis(x, t, degree);
}
parameters {
vector[2] head;
vector[K - 2] z;
real<lower=0> sd_smooth;
matrix[K, J] z_group; // group deviations, non-centred
real<lower=0> omega; // how far groups may depart
real<lower=0> sigma;
}
transformed parameters {
vector[K] beta_pop = splines::pspline(head, z, sd_smooth, 2);
matrix[K, J] beta_j = splines::ncp_offset(beta_pop, z_group, omega);
}
model {
z ~ std_normal();
to_vector(z_group) ~ std_normal();
head ~ normal(0, 5);
sd_smooth ~ normal(0, 1);
omega ~ normal(0, 0.5);
sigma ~ std_normal();
y ~ normal(rows_dot_product(B, beta_j[, group]'), sigma);
}With omega a real and z_group iid, the deviations are white noise in coefficient space — each group's curve is a smooth population curve plus a rough perturbation. If you want the deviations themselves to be smooth, build them with pspline per group instead:
transformed parameters {
vector[K] beta_pop = splines::pspline(head, z, sd_smooth, 2);
matrix[K, J] beta_j;
for (j in 1:J)
beta_j[, j] = beta_pop + splines::pspline(head_g[, j], z_group[, j], omega, 2);
}That distinction is the most consequential choice in a hierarchical spline, and it is invisible in the code either way. Decide it deliberately.
I-splines give monotonicity through the sign of
library {
import splines
}
data {
int<lower=1> N;
vector[N] dose;
vector[N] response;
int<lower=1> n_knot;
}
transformed data {
int degree = 3;
int order = degree + 1;
vector[n_knot] xi;
for (i in 1:n_knot)
xi[i] = min(dose) + i * (max(dose) - min(dose)) / (n_knot + 1);
vector[n_knot + 2 * degree + 2] t = splines::bspline_knots_clamped(min(dose), max(dose), xi, degree);
int K = (n_knot + 2 * degree + 2) - order;
matrix[N, K] I = splines::ispline_basis(dose, t, order);
}
parameters {
real beta0; // baseline response
vector<lower=0>[K] beta; // non-negativity IS the constraint
real<lower=0> sigma;
}
model {
beta0 ~ normal(0, 5);
beta ~ exponential(1); // mass near zero, so the fit can stay flat
sigma ~ std_normal();
response ~ normal(beta0 + I * beta, sigma);
}With cmdlaplacer, the .laplace file compiles straight to a cmdstanr model, and the generated .stan file stays on disk next to it:
library(cmdlaplacer)
mod <- laplace_model("pspline_smooth.laplace")
fit <- mod$sample(
data = list(N = length(x), x = x, y = y, n_seg = 20)
)
fit$draws("f")-
P-splines need Convention B knots. Use
bspline_knots_uniform, neverbspline_knots_clamped, withpspline. The difference penalty assumes basis functions are identical translates; clamped knots break that and the penalty quietly means something different at the edges. Nothing errors. -
Never put a difference penalty on
ns_basiscoefficients.difference_matrixworks because B-spline coefficients are local and ordered along$x$ . The natural cubic basis is a global linear part plus truncated cubics, so differencing those coefficients is meaningless. Usegram_matrixinstead. -
Build bases in
transformed data, nottransformed parameters. They depend only on data, so they should be computed once. Everything intransformed parametersis also written to the output on every draw, which makes enormous CSV files for an$N \times K$ matrix. -
A spline plus an intercept is not identified. Because the basis sums to one, adding a constant to every coefficient and subtracting it from the intercept leaves the fit unchanged; NUTS reports poor
$\hat{R}$ on both. Drop the intercept, usesum_to_zero_vector, or applycenter_basis. With more than one smooth term, centring every one is not optional. -
z ~ std_normal()is structural, not a prior choice. It is part of the non-centred parameterisation.head,sigmaandomegaare the real prior decisions, and the library deliberately leaves them to you. -
vectorandrow_vectoromegado different things, and when$K = J$ both are legal.vector[K]scales rows (per basis function),row_vector[J]scales columns (per group). With equal dimensions the wrong one compiles, runs, and is wrong. Check the declared type at the call site. -
Test bases with partition of unity. Row sums of
bspline_basisoutput must equal 1 for every interior$x$ . This one assertion catches nearly every knot-padding and indexing error, and it is a single line ingenerated quantities. -
ns_basisis ill-conditioned for large$K$ . The truncated power basis has nearly collinear columns as knots multiply. Rescale$x$ to roughly$[0,1]$ and stay below about$K = 15$ . R'sns()avoids this with a QR construction that is stable but much harder to write. -
I-spline coefficients need a prior with mass at zero.
vector<lower=0>[K]makes the fit monotone, but without shrinkage toward zero it cannot stay flat where the data are flat. Half-normal or exponential. -
Order versus degree.
mspline_basisandispline_basistake order$=$ degree$+,1$ , because the literature does. Everything else takes degree. Order 4 is cubic. -
Tensor stacking order is load-bearing.
tensor_basisis$x$ slow,$z$ fast, andtensor_penalty_x/tensor_penalty_zassume the same. Change it in one place and you smooth the wrong direction with no error raised. Note also that$K_xK_z$ grows fast —$20 \times 20$ is 400 parameters. -
No thin-plate splines. They need an
$N \times N$ eigendecomposition,$O(N^3)$ , which is impractical inside Stan above a few thousand observations. Precompute the basis and penalty in R (mgcv::smoothCon) and pass them as data.
See LICENSE.