import numpy as np
import pandas as pd
import pathmc
rng = np.random.default_rng(0)
x = np.linspace(10.0, 20.0, 120)
true = np.sin((x - 10.0) * 0.9)
y = true + rng.normal(0.0, 0.2, size=x.size)
df = pd.DataFrame({"x": x, "y": y})Nonparametric Smooths with HSGP
Most pathmc terms are linear in their coefficients. Sometimes the relationship between a predictor and an outcome is smooth but not linear — a dose–response curve, a trend over a continuous index, a smooth confounder adjustment. The hsgp() term adds such a smooth via a Hilbert Space Gaussian Process approximation (riutort2023practical?), the same scalable construction exposed by Bambi’s hsgp().
An HSGP approximates a Gaussian process with a finite basis of Laplacian eigenfunctions, so it reduces to a linear model in a fixed basis matrix. That makes it fast, differentiable, and — because the basis is a deterministic function of the input pm.Data — fully compatible with do() interventions.
Simulated data
We draw a smooth signal and add observation noise. The input is deliberately not centered on zero to exercise the internal centering of the basis.
The model
hsgp(x, m=..., c=...) is a standalone term: m sets the number of basis vectors and c the boundary-expansion factor. The smooth carries its own basis weights, so it takes no coefficient prefix.
model = pathmc.model("y ~ hsgp(x, m=20, c=1.5)", data=df)
model.equations()\begin{aligned} \beta_{y} &\sim \text{Normal}(mu=0,\, sigma=10) \\ \sigma_{y} &\sim \text{HalfNormal}(sigma=1) \\ \ell_{y,x} &\sim \text{InverseGamma}(alpha=3,\, beta=1) \\ \eta_{y,x} &\sim \text{HalfNormal}(sigma=1) \\ \beta_{hsgp,y,x} &\sim \text{Normal}(mu=0,\, sigma=1) \\[6pt] \mu_{y} &= \beta_{0,\,y} + f_{\mathrm{hsgp}}(\mathrm{x}) \\ \mathrm{y} &\sim \text{Normal}(\mu_{y},\, \sigma_{y}) \end{aligned}
The default priors expose the kernel amplitude (eta_y_x), lengthscale (ell_y_x), and standardized basis weights (beta_hsgp_y_x):
model.priors()\begin{aligned} \beta_{y} &\sim \text{Normal}(mu=0,\, sigma=10) \\ \sigma_{y} &\sim \text{HalfNormal}(sigma=1) \\ \ell_{y,x} &\sim \text{InverseGamma}(alpha=3,\, beta=1) \\ \eta_{y,x} &\sim \text{HalfNormal}(sigma=1) \\ \beta_{hsgp,y,x} &\sim \text{Normal}(mu=0,\, sigma=1) \end{aligned}
The lengthscale prior is in raw input units: the default InverseGamma(3, 1) has mean 0.5, so an input spanning a wide range pulls against it and can produce divergences — standardize the input, override ell_y_x, or raise target_accept as we do below. A range-scaled default is tracked in issue #375.
Fit and recover the smooth
idata = model.fit(random_seed=0, target_accept=0.99)
f_mean = idata.posterior["f_y_x"].mean(("chain", "draw")).values
np.corrcoef(f_mean - f_mean.mean(), true - true.mean())[0, 1]NUTS[nutpie]: [eta_y_x, ell_y_x, beta_hsgp_y_x, beta_y, sigma_y]
np.float64(0.9968956933462689)
The posterior-mean smooth tracks the true function closely. To show that honestly we plot the posterior of mu_y — the full linear predictor, which already carries the intercept — as a mean line with a 94% highest-density interval, rather than recentering f_y_x by hand.
Code
import arviz as az
import matplotlib.pyplot as plt
mu = idata.posterior["mu_y"]
mu_mean = mu.mean(("chain", "draw")).values
hdi = az.hdi(mu, prob=0.94).values
fig, ax = plt.subplots(figsize=(8, 4))
ax.scatter(x, y, s=12, alpha=0.5, color="C0", label="observed")
ax.fill_between(x, hdi[:, 0], hdi[:, 1], color="C1", alpha=0.3, label="94% HDI")
ax.plot(x, mu_mean, "C1", lw=2, label="posterior mean")
ax.plot(x, true, "k--", lw=1.5, label="true smooth")
ax.set(xlabel="x", ylabel="y")
ax.legend()
plt.show()
Intervening on the smooth
Because the basis recomputes from the input data node, do() propagates through the smooth. Interventions must stay within the basis boundary [mid − L, mid + L] frozen from the fitted data — with c > 1 this is wider than the data range, so moderate extrapolation is fine. Beyond the boundary the sinusoidal eigenfunctions alias rather than extrapolate, so do() raises a ValueError there instead of returning a plausible-looking but meaningless number.
low = model.do(set={"x": 12.0}).mean("y")
high = model.do(set={"x": 18.0}).mean("y")
low, high(0.9417490558663085, 0.7755178003188019)
Scope
Phase 1 supports a single 1-D exogenous input on cross-sectional models. Multi-dimensional inputs, grouped (by=) GPs, and panel/scan models raise a clear error and are tracked as follow-ups.
The input must be exogenous because the basis is built by evaluating it: PyMC derives the centering midpoint and the boundary from the input’s realized values. Smoothing an endogenous variable (one that is itself the outcome of another equation) would freeze the basis at a random prior draw, so pathmc rejects it rather than fitting a model whose result depends on compilation-time RNG.