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.

Reparameterizing the RW2 Prior

A technical memo on a faster parameterization of the same model

Reparameterizing the RW2 prior

The period–cohort model in this project fits two second-order random walks (RW2): one over birth years and one over survey years. Fitted with NUTS in its usual form, almost every draw needs about 1,000 leapfrog steps. This memo describes a change of variables that leaves the model unchanged but cuts the time per fit by 6–20×:

  1. write the RW2 prior in the eigenbasis of its covariance matrix;

  2. split the walk’s linear trend out as a separate coordinate.

The prior, the likelihood, and the posterior over everything the model reports stay the same. Only the coordinates the sampler moves in change.

The code is rw2(..., form="trend") in overton/apc.py. The check that both forms are the same model is overton/rw2_trend.py, and the experiments are in docs/lab.md.

The model

The model is CultureWar’s model10 as ported to overton/apc.py. Each cohort × year cell has a weighted mean score yˉct\bar y_{ct}, observed with standard error σtresid/ncteff\sigma^{\text{resid}}_t / \sqrt{n^{\text{eff}}_{ct}}, around

logit⁡−1(α+fccohort+ftperiod+ϵt),\operatorname{logit}^{-1}\left(\alpha + f^{\text{cohort}}_c + f^{\text{period}}_t + \epsilon_t\right),

where ϵ\epsilon is zero-sum year noise. With the p48 question set, there are 2,442 cells, 124 birth years, 53 survey years, and 284 free parameters.

Each walk ff of length nn is a mean-centered RW2, built by double cumulative sums with a free initial slope:

z∼N(0,In−2),v0∼N(0,0.22),f=σ PLz+v0 r.z \sim \mathcal N(0, I_{n-2}), \qquad v_0 \sim \mathcal N(0, 0.2^2), \qquad f = \sigma\, P L z + v_0\, r .

This is the standard non-centered form. The sampler moves in (z,v0,log⁡σ)(z, v_0, \log\sigma).

Why it is slow

NUTS with a diagonal mass matrix can rescale each coordinate, but it can’t rotate them. When the posterior is strongly correlated in the sampler’s coordinates, trajectories need many small steps.

On the saved draws of the decided p48 fit, the posterior’s correlation matrix in the sampler’s coordinates has a condition number of about 9,200. Its smallest-variance directions are smooth shapes of the cohort curve over the data-rich birth years:

As a result, the production fit (nutpie, 4 chains × (4000 + 6000), target_accept 0.99) reached the maximum tree depth of 10 on every draw. That’s 1,023 gradient evaluations per draw.

Step 1: the eigenbasis

Given σ\sigma, the walk without the slope term, σPLz\sigma P L z, is Gaussian with covariance σ2PLL⊤P\sigma^2 P L L^\top P. Take its eigendecomposition and keep the n−2n-2 nonzero eigenvalues:

PLL⊤P=UΛU⊤,M=UΛ1/2(n×(n−2)).P L L^\top P = U \Lambda U^\top, \qquad M = U \Lambda^{1/2} \quad (n \times (n-2)).

Now define

w=Qz,Q=M+PL,w = Q z, \qquad Q = M^{+} P L ,

where M+M^{+} is the pseudoinverse. Since MM has full column rank and (PL)(PL)⊤=MM⊤(PL)(PL)^\top = M M^\top, QQ is square and orthogonal, and PLz=MwPLz = Mw. So:

Each column of MM is one shape of the walk, from smoothest (largest λ\lambda) to roughest. A smooth shape the data pin down is now a single coordinate wkw_k, and the mass matrix can scale it.

On its own, this roughly doubled the ESS per minute (with TyMC and PyMC-style adaptation). But with nutpie-style adaptation, a few coordinates mixed badly (ESS 10–20). Those were the smoothest shapes and v0v_0.

Step 2: split out the trend

The smoothest columns of MM are close to the ramp rr, so v0v_0 and the leading wkw_k explain nearly the same thing: the walk’s overall linear trend. The fix is to make that trend a coordinate of its own. Decompose MM into its projection on rr and the rest:

a=M⊤rr⊤r,M⊥=(I−rr⊤r⊤r)M,M=ra⊤+M⊥.a = \frac{M^\top r}{r^\top r}, \qquad M_\perp = \left(I - \frac{r r^\top}{r^\top r}\right) M, \qquad M = r a^\top + M_\perp .

Then

f=σM⊥w+β r,β=v0+σ a⊤w.f = \sigma M_\perp w + \beta\, r, \qquad \beta = v_0 + \sigma\, a^\top w .

β\beta is the walk’s total linear trend, and M⊥wM_\perp w has no linear component. The map (v0,w)↦(β,w)(v_0, w) \mapsto (\beta, w) is a shear with Jacobian 1. So the same model is:

The data identify β\beta directly, as the trend of the curve, and in these coordinates it no longer competes with the smooth shapes.

Code

The basis is computed once in numpy. In PyMC (overton/apc.py):

def rw2_trend_basis(n):
    L = np.zeros((n, n - 2))
    for k in range(2, n):
        L[k, : k - 1] = np.arange(k - 1, 0, -1)
    P = np.eye(n) - 1 / n
    lam, U = np.linalg.eigh(P @ L @ L.T @ P)
    keep = lam > 1e-9 * lam.max()
    M = U[:, keep] * np.sqrt(lam[keep])
    r = P @ np.arange(n, dtype=float)
    a = M.T @ r / (r @ r)
    return M - np.outer(r, a), r, a


# inside rw2(name, n, sigma, slope_init_scale, dims, form="trend")
M_perp, r, a = rw2_trend_basis(n)
w = pm.Normal(f"{name}_w", 0.0, 1.0, shape=n - 2)
beta = pm.Flat(f"{name}_trend")
v0 = beta - sigma * pm.math.dot(a, w)
pm.Potential(f"{name}_slope_init_prior", pm.logp(pm.Normal.dist(0.0, slope_init_scale), v0))
f = pm.Deterministic(name, sigma * pm.math.dot(M_perp, w) + beta * r, dims=dims)

The TyMC version, used by the browser page, is rw2_trend in overtron/spike/model.ts.

Checking that it is the same model

python -m overton.rw2_trend builds the p48 period–cohort and period models both ways. It draws random points in the current coordinates and maps them to the new ones with w=Qzw = Qz and β=v0+σa⊤w\beta = v_0 + \sigma a^\top w. Then it compares the log densities, including the Jacobians of the transforms. They agree to about 10-9. The TyMC port agrees with PyMC to about 2.5×10−72.5 \times 10^{-7}, the precision of PyMC’s float32 constants.

Results

nutpie (PyMC), p48, production settings: 4 chains × (4000 + 6000), target_accept 0.99, diagonal mass matrix, on one machine.

ModelFormTimeLeapfrog steps per drawDraws at max depthESS bulk minESS per second
Period–cohortcurrent1,026 s1,023100%1,4161.4
Period–cohorttrend159 s1210%1,4349.0
Periodcurrent183 s1,02361%3,06416.8
Periodtrend20 s650%1,32765

Subgroups. These have fewer cells, so they’re a harder test. Same settings, same machine:

FitFormTimeSteps per drawDraws at max depthDivergencesESS bulk minESS per secondHeadline (90% CI)
Period–cohort, liberalscurrent908 s1,02339%01,7852.0within-cohort slope −2.82 [−3.08, −2.57]
trend86 s720%01,10712.9−2.82 [−3.08, −2.57]
Period–cohort, born 1946–1964current358 s1,0028%05,18614.5within-cohort slope −0.12 [−0.34, 0.09]
trend31 s630%02,12068−0.12 [−0.34, 0.09]
Age, born 1946–1964current54 s4530.01%43,92473slope −0.16 [−0.42, 0.10]
trend16 s630%04,777306−0.16 [−0.42, 0.10]
Period, extremely liberalcurrent42 s3541.1%112,29954slope −3.37 [−4.12, −2.62]
trend14 s560%04,787339−3.37 [−4.14, −2.61]

In every fit the two forms agree within Monte Carlo error. The trend form has 4–7× the ESS per second, and the divergences in the two smallest fits go away.

p48 results. These also agree within Monte Carlo error:

CurrentTrend
Within-cohort slope per decade (90% CI)−0.843 [−1.006, −0.680]−0.843 [−1.009, −0.679]
Generational replacement−12.04−12.04
Changing minds−4.92−4.94

TyMC (the browser library), period–cohort model, diagonal mass matrix, target_accept 0.95, two cores:

FormAdaptationChains × (tune + draws)TimeSteps per drawR-hat maxESS bulk min
CurrentPyMC-style4 × (1000 + 1000)638 sabout 1,0001.009387
EigenbasisPyMC-style4 × (1000 + 1000)569 s9971.006785
Eigenbasisnutpie-style4 × (1000 + 1000)63 s871.08440
Eigenbasis + trendnutpie-style4 × (1000 + 1000)38 s501.021167
Eigenbasis + trendnutpie-style2 × (1000 + 2000)30 s481.008138

It took both steps, together with nutpie-style adaptation, to get the full speed-up.

What didn’t help

Caveats