Metropolis-Hastings over Execution Traces

MCMC by editing executions · independent and single-site proposals · addressing · the acceptance ratio

Javier Burroni

2026-06-24

MCMC and Metropolis-Hastings

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\):

  1. propose \(X' \sim q(X' \given X)\);
  2. accept (\(X \leftarrow X'\)) with probability \(\min(1, \alpha)\), otherwise keep \(X\), where \[\alpha = \frac{p(X' \given y)\, q(X \given X')}{p(X \given y)\, q(X' \given 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).

Single-site MH

An eight bits model

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.

The single-site sampler

Keep one trace \(\mathcal{X}\): each sample’s value and density, keyed by address. One step edits a single site:

  1. Pick one address \(x_0\), uniform over the trace’s sample sites.
  2. Propose: re-run, resample \(x_0\) from its distribution, reuse the rest by address.
  3. Score \(\alpha\): reused sites cancel where their density is unchanged, times \(|\mathcal{X}|/|\mathcal{X}'|\).
  4. Accept with probability \(\min(1, \alpha)\), else keep \(\mathcal{X}\).
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.

Question 1

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.

x = init_run(program)                    # first trace: a forward draw from the prior
chain = [x]
for _ in range(S):
    x0 = None                            # BUG: no site is ever picked to change
    x2 = propose(program, x0, reuse=x)   # resample x0, reuse every other site of x
    x  = x2 if accept(x2, x) else x
    chain.append(x)

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\)

Vote: https://pe.app/ppls

Question 2

A coin routes cost to one of two distributions. Current trace: route = false, with cost = -0.6 drawn from (normal 0 1).

(let [route (sample (bernoulli 0.5))
      cost  (if route
              (sample (gamma 2 1))      ; route A: cost > 0
              (sample (normal 0 1)))]   ; route B: cost may be negative
  (observe (normal cost 1) 0.8) route)

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.

Vote: https://pe.app/ppls

Question 3

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)))

Vote: https://pe.app/ppls

Question 4

(let [a (sample (normal 0 1))
      b (sample (normal a 1))]      ; b's distribution depends on a
  (observe (normal b 1) 2.0) b)

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.

Vote: https://pe.app/ppls

Question 5

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?

SSMH without source transformation

Use the path the evaluator took; it names a site the same way every run.

  • The path keeps a site’s key stable across re-runs and gives the two branches’ samples different keys. No program rewriting.
  • Accept on the collapsed ratio: reused sites cancel only when their density is unchanged, times \(|X|/|X'|\) when a flip changed the count.

Answer key (1/2)

Solutions

  • Q1: D. Every recorded sample is a deterministic copy of the first run, so the sample variance is exactly \(0\), not the prior’s (A) or the posterior’s (B), and not a count (C). The random site pick is the only source of new randomness in a single-site step; drop it and the chain is frozen.
  • Q2: A. Reusing \(-0.6\) in (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.

Answer key (2/2)

Solutions

  • Q3: C. Only there does a branch change the number of 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.
  • Q4: B. A term cancels only when its density factor is identical above and below. Here \(b\) depends on \(a\), so moving \(a\) changes \(\mathcal{P}(b)\) even though \(b\)’s value is reused: the term stays. A and C call it cancelled (by value, or by deferring to the observe), D wrongly forbids reuse (the value may be reused, it just must be re-scored).
  • Q5: Freeform.