MCMC by editing executions · independent and single-site proposals · addressing · the acceptance ratio
2026-06-24
\[ \newcommand\given{{\,\vert\,}} \]
MCMC builds a Markov chain whose stationary distribution is the posterior \(p(X \given y)\). We estimate any \(\mathbb{E}[r(X)]\) by averaging \(r\) along the chain, using only ratios of the unnormalized target \(p(y, X)\), never the evidence \(p(y)\).
Metropolis-Hastings is one recipe for such a chain. From the current state \(X\):
The state is an execution trace
In a PPL the chain’s state is the trace \(\mathcal{X}\): the random choices a run made. A proposal re-runs the program. Today we build two of them: independent (a whole fresh run from the prior) and single-site (change one choice, reuse the rest).
Eight bits: each bit is \(1\) when a fresh uniform(0,1) falls below \(0.5\), else \(0\). We observe their sum near \(7\), a \(\mathcal{N}(7, 1)\) likelihood.
(let [b1 (if (< (sample (uniform-continuous 0 1)) 0.5) 1 0)
b2 (if (< (sample (uniform-continuous 0 1)) 0.5) 1 0)
b3 (if (< (sample (uniform-continuous 0 1)) 0.5) 1 0)
b4 (if (< (sample (uniform-continuous 0 1)) 0.5) 1 0)
b5 (if (< (sample (uniform-continuous 0 1)) 0.5) 1 0)
b6 (if (< (sample (uniform-continuous 0 1)) 0.5) 1 0)
b7 (if (< (sample (uniform-continuous 0 1)) 0.5) 1 0)
b8 (if (< (sample (uniform-continuous 0 1)) 0.5) 1 0)
total (+ b1 b2 b3 b4 b5 b6 b7 b8)]
(observe (normal 7 1) total) ; soft evidence: the sum is near 7
total)Why single-site fits
Eight latent choices, one uniform per bit. A whole-run proposal must land all eight at once, and the prior rarely puts the sum near \(7\). Single-site MH instead flips one bit and reuses the other seven, so progress compounds bit by bit.
Keep one trace \(\mathcal{X}\): each sample’s value and density, keyed by address. One step edits a single site:
sample sites.def single_site_mh(program, rng, S=20000):
x, st = run(program, rng) # initial trace: a draw from the prior
chain = [float(x)]
for _ in range(S):
a0 = list(st.X)[int(rng.integers(len(st.X)))] # pick a sample address
x2, st2 = run(program, rng, x0=a0, cache=st.X) # resample it, reuse the rest
fwd = {a0} | (set(st2.X) - set(st.X)) # sites the proposal drew
rev = {a0} | (set(st.X) - set(st2.X))
num = sum(p for k,p in st2.logP_s.items() if k not in fwd) + sum(st2.logP_o.values())
den = sum(p for k,p in st.logP_s.items() if k not in rev) + sum(st.logP_o.values())
log_a = math.log(len(st.X)/len(st2.X)) + num - den # collapsed ratio
if math.log(rng.random()) < log_a: x, st = x2, st2
chain.append(float(x))
return np.array(chain)The driver needs one thing from the language: a stable address for each sample, so a site is reused or re-scored by name across re-runs.
Single-site MH changes one of the eight sites each step and reuses the rest. Running it on the bit model above, a teammate’s loop has a bug: the line that picks the site at random is missing, so x0 stays None.
What is the variance of the recorded samples?
A) The variance of the prior \(p(X)\)
B) The variance of the posterior \(p(X \given Y)\)
C) The number of samples, \(S\)
D) \(0\)
A coin routes cost to one of two distributions. Current trace: route = false, with cost = -0.6 drawn from (normal 0 1).
We pick \(x_0 =\) route, flip it to true, and re-run, matching draws by position.
What does the position-matched proposal do, and is it right?
A) \(-0.6\) is reused for (gamma 2 1), which has no support there: density \(-\infty\).
B) cost is redrawn from (gamma 2 1): matching sees the family changed.
C) \(-0.6\) is reused, and that is fine.
D) The RNG shifts, so cost reads route’s old value.
Single-site MH multiplies its acceptance ratio by \(|X|/|X'|\), where \(|X|\) counts the sample sites in the trace.
For which program can a single-site move ever make \(|X|/|X'|\) differ from \(1\)?
A) (let [coin (sample (bernoulli 0.5)) r (if coin (sample (exponential 1)) (sample (exponential 2)))] (observe (normal r 1) 0.7) r)
B) (let [a (sample (normal 0 1)) b (sample (normal 0 1))] (observe (normal (+ a b) 1) 0.0) a)
C) (let [z (sample (bernoulli 0.5)) e (if z (sample (normal 0 1)) 0)] (observe (normal e 1) 1.0) z)
D) (let [x (sample (normal 0 1))] (observe (normal x 1) 2.0) (if (> x 0) x (- x)))
We pick \(x_0 = a\), propose a new \(a\), and reuse \(b\), so \(\mathcal{X}'(b) = \mathcal{X}(b)\) (same value).
In the acceptance ratio, what happens to \(b\)’s density term?
A) It cancels: \(b\)’s value is unchanged.
B) It stays: \(b\)’s distribution moved with \(a\), so \(\mathcal{P}'(b) \neq \mathcal{P}(b)\).
C) It cancels: the observe already accounts for \(b\).
D) It stays: \(b\) may not be reused, it must be redrawn.
The book gets addresses with a source-to-source pass (Fig 4.2): rewrite (sample d) into (sample v d). But an address is just where you are in the execution, which the evaluator already knows. The state \(\sigma\) also holds the chosen site x0, a cache of the previous trace, and the maps X (values) and logP (densities).
Implement single-site MH with no source transformation: address each site inside the evaluator. First settle the design question: why is a counter that numbers samples \(0, 1, 2, \dots\) in execution order not a safe address?
Use the path the evaluator took; it names a site the same way every run.
Solutions
(gamma 2 1) lands outside the gamma’s support, density \(-\infty\): a normal draw misrouted into the gamma site. B thinks matching is family-aware (it is not), C thinks any number fits (the density is what matters), D invents an RNG shift. An address names each sample expression, so the value is never misrouted.Solutions
sample sites. A branches but draws one r on each side (\(|X| = 2\) always), B never branches, D’s if is in the return value with no sample inside.observe), D wrongly forbids reuse (the value may be reused, it just must be re-scored).Introduction to Probabilistic Programming