Belief Propagation Tutorial 03 of 10
Why another decoder?
In the previous chapter, minimum-weight perfect matching turned a syndrome into a graph and paired up the lit detectors. That trick only works when every possible error touches at most two detectors: each error is then an edge and decoding is graph matching.
But most codes aren't so tidy. A single fault can flip three, four, or a dozen parity checks at once. Now an error is no longer an edge: it's a node wired to all the checks it disturbs. Matching has nothing to match. We need a decoder that lives on the full bipartite graph of errors and checks.
The one-line intuition
Belief propagation is neighbours gossiping until they agree. Every possible error and every parity check holds an opinion ("I probably did/didn't fire", "my parity is/isn't satisfied") and they trade these opinions back and forth along the edges. After a few rounds, the opinions (hopefully) settle on the single most likely error.
This is what qliff's BpOsdDecoder does. It hands the code's check matrix to belief propagation and, when BP can't decide, to a cleanup step called OSD. By the end of this page you'll have run both, live, on an example where plain matching does not apply.
The Tanner graph
Everything BP does happens on one picture: the Tanner graph (a "factor graph"). It has two kinds of node:
- Variables (circles, top): one per error mechanism, a possible fault like "qubit 3 picked up an X."
- Checks (squares, bottom): one per detector, a parity measurement that lights up if an odd number of its neighbouring errors fired.
We draw an edge from error dem.check_matrix() returns
Click a detector to "light" it: the lit pattern is a syndrome, the only thing the decoder observes. The readout under the graph then does the decoder's whole job by brute force: it tests all
Tanner graph
Legend:
- circle = error mechanism
- square = detector
- lit square = syndrome bit 1
- edge = mechanism flips detector
- highlighted row = likeliest explanation
Two things to notice. Light all three detectors and the top of the list becomes a tie: two explanations with the same weight, hence the same probability. That is degeneracy, and it returns later as the thing that traps BP. Second, the brute-force ranking only works because this toy has 32 candidates; a real code has thousands of mechanisms and
Contrast with matching
Detector
Beliefs as log-odds
Before any gossip, each error mechanism has a prior: the channel probability
Why log-odds? Because along the way BP needs to combine independent evidence, and in log-space combining evidence is addition. The sign and size carry all the meaning:
: confident there's no error (small ). : a coin flip; the mechanism is undecided. : the evidence now favours an error.
Two lines of numpy compute it; note
import numpy as np
p = np.array([0.5, 0.1, 0.06, 0.01])
print(np.round(np.log((1 - p) / p), 3))
# -> [0. 2.197 2.752 4.595]Prior p -> LLR
Legend:
= favours error = favours no error
Posterior, the other direction
After gossiping, BP has a posterior LLR per mechanism. Convert back with
The message-passing loop
Now the gossip itself. A message is a single number: a log-odds opinion about one mechanism, sent along one edge of the Tanner graph. Each iteration has two half-steps. First every mechanism tells each of its detectors how likely it currently is to have fired; then every detector replies with what its measured parity says about that mechanism. One rule governs both directions: a node talking to a neighbour sums up everything it heard except what that neighbour itself said, so no opinion is echoed back to its source and double-counted as fresh evidence. Repeat until the numbers stop changing.
Here are the two half-steps as update rules; the demo underneath runs them live on a 7-bit repetition code where a true error sits on one bit (cyan ring) and only the lit detectors are observed.
Variable -> check. A mechanism tells each neighbouring detector its log-odds, using its prior plus what every other detector told it (never echoing a check's own message back):
Check -> variable is the harder update. A detector enforces a parity constraint, and the exact rule for combining log-odds through an XOR is the tanh rule:
where bp.ts evaluates:
This is bp_method="product_sum": sum-product BP in the log domain.
One check -> variable message by hand
Take an unlit detector
- Drop the back-edge: combine only the other two,
and . - Take
of each: , . - Multiply (the check is unlit, so the sign is
): . - Invert:
.
Result: The outgoing message is -0.60: negative, so this detector nudges neighbour 1 toward "you fired," but only weakly (small magnitude) because the -0.8 message was itself unsure. Had the detector been lit (
Message passing
Legend:
- dot = live message (size/heat = |LLR|)
- belief ring = posterior P(error)
- cyan ring = true error mechanism
- red ring/label = called an error
Iteration 0 shows the priors alone: every belief is the same positive number. As messages flow, the detectors next to the true error report strong evidence of a flip, and the beliefs of the mechanisms between two lit detectors collapse toward error.
Reading the answer
Once the messages stop changing, BP converged. Each mechanism's posterior LLR sums its prior and all incoming check messages (no back-edge dropped this time); thresholding at zero gives the hard decision
The self-consistency check: feed
One mechanism's posterior and hard decision
A mechanism with prior
- Sum prior + all incoming:
. - Convert to a probability:
. - Threshold:
, so the hard decision is .
Result: The two detectors outvoted the prior: a mechanism that started 94% safe is now called an error (
Posterior beliefs
Legend:
- bar left = error (
) - bar right = no error (
) - cyan label = true mechanism
The recovered mechanism vector then maps through the observable matrix (
The easy case
The repetition code is graphlike and loop-free near a single error, so BP settles on the answer without trouble. Next we construct a case where it cannot.
When BP gets stuck
Quantum codes are degenerate: distinct errors can have identical syndromes and identical likelihoods. When the graph also has short cycles, BP's "independent evidence" assumption breaks, and the beliefs can oscillate forever, never committing to an answer.
Here's a tiny hand-built code that traps it. Detector
Degenerate trap
Legend:
- lit check = syndrome bit 1
- unsatisfied check = residual 1
- oscillating belief ring = BP never decides
The symmetric trap
Notice that
OSD to the rescue
BP proposes, OSD disposes. Ordered-statistics decoding doesn't throw away BP's work: it uses BP's soft output to make a decisive guess. qliff runs osd_method="osd_cs" with osd_order=7. The recipe:
- Order by reliability. Sort the mechanisms by
, most confident first. Even when BP can't decide between and , it is sure that stayed quiet. - Solve a full-rank core exactly. Walk that ordered list and Gaussian-eliminate over
to pull out a most-reliable set of independent pivot columns , then solve that square subsystem (free columns set to 0). This forces a recovery that reproduces the syndrome; the oscillation is gone. - Search small corrections (order 7). Flip up to
of the least-reliable free columns, re-solve, and keep the lowest-weight consistent answer found.
Step 1: reliability order. Columns most-reliable-first; bar length is the posterior reliability |L| (heat-capped at 5). BP is sure e2, e3 stayed quiet but cannot break the e0/e1 tie.
Step 2: solved recovery. Solve the pivot core exactly (free columns = 0), then search order-7 flips of the least-reliable free columns.
order-0 (free = 0): 1000, weight 1
after order-7 search: 1000, weight 1
reproduces syndrome 110 BP + OSD recovers e0 where plain BP spun forever.
- pivot column (solved exactly)
- free column
- syndrome-matching recovery
Why qliff ships BP+OSD
OSD turns BP's almost-an-answer into a guaranteed syndrome-consistent, low-weight recovery. BP+OSD works on the general bipartite graph, including non-graphlike codes (colour codes, dense LDPC) where a detector touches more than two errors and MWPM has no graph to match. That generality is why it sits alongside matching in qliff's decoder set.
Implementation
The demos above run sum-product BP in the browser; the same algorithm fits in a page of Python. The tabs below give three levels: pseudocode for the message schedule, a numpy version you can modify, and the production decoder qliff ships.
Pseudocode. One BP decode is an initialise / iterate / read-out loop over the Tanner graph. Everything lives on the edges where
Std Python. The same loop in numpy, run first on the 7-bit repetition code from the message-passing demo (true error on bit 3), then on the degenerate trap. The trap's transcript shows the split beliefs: 1100 and 0000. An order-0 OSD step then breaks the tie as the figure above walks through, because the reliability ranking is stable even though the signs never settle.
Qliff. BpOsdDecoder wraps the ldpc library's BP+OSD (bp_method="product_sum", osd_method="osd_cs", osd_order=7) behind qliff's batch decoder interface; make_decoder("bposd", dem) builds the same object by name. The colour-code memory below has weight-6 checks, so single faults flip three detectors: MWPM refuses the model outright while BP+OSD decodes it.
BP(H, priors, syndrome, max_iter):
for each mechanism m: # initialise from the priors
llr[m] = log((1 - priors[m]) / priors[m])
msg_v2c[m -> c] = llr[m] for every check c with H[c, m] = 1
for iter in 1 .. max_iter:
for each check c: # check -> variable (tanh rule)
sign = -1 if syndrome[c] = 1 else +1
msg_c2v[c -> m] = 2 * atanh(sign * product over m' != m
of tanh(msg_v2c[m' -> c] / 2))
for each mechanism m: # variable -> check
msg_v2c[m -> c] = llr[m] + sum over c' != c of msg_c2v[c' -> m]
for each mechanism m: # marginals + hard decision
L[m] = llr[m] + sum over all c of msg_c2v[c -> m]
e_hat[m] = 1 if L[m] < 0 else 0
if H @ e_hat = syndrome (mod 2): # converged: stop
return e_hat
return e_hat # stuck: hand L to OSDimport numpy as np
def run_bp(H, priors, syndrome, iters):
"""Log-domain sum-product BP; returns the posterior LLRs after each iteration."""
n_checks, n_vars = H.shape
prior_llr = np.log((1.0 - priors) / priors)
# messages live on the edges of the Tanner graph (where H[c, m] == 1)
var_to_check = np.where(H == 1, prior_llr[np.newaxis, :], 0.0)
check_to_var = np.zeros((n_checks, n_vars))
history = []
for _ in range(iters):
# check -> variable: the tanh rule, sign flipped by lit detectors
for c in range(n_checks):
sign = -1.0 if syndrome[c] == 1 else 1.0
for m in range(n_vars):
if H[c, m] == 0:
continue
prod = sign
for m2 in range(n_vars):
if H[c, m2] == 1 and m2 != m:
prod = prod * np.tanh(var_to_check[c, m2] / 2.0)
prod = np.clip(prod, -1.0 + 1e-12, 1.0 - 1e-12)
check_to_var[c, m] = 2.0 * np.arctanh(prod)
# variable -> check: prior plus every OTHER check's message
posterior = prior_llr + check_to_var.sum(axis=0)
var_to_check = np.where(H == 1, posterior[np.newaxis, :] - check_to_var, 0.0)
history.append(posterior)
return history
# the 7-bit repetition code from the demo: check c compares bits c and c+1
n = 7
H_rep = np.zeros((n - 1, n), dtype=np.uint8)
for c in range(n - 1):
H_rep[c, c] = 1
H_rep[c, c + 1] = 1
priors = np.full(n, 0.06)
true_error = np.array([0, 0, 0, 1, 0, 0, 0], dtype=np.uint8)
syndrome = (H_rep @ true_error) % 2
print(syndrome) # -> [0 0 1 1 0 0]
history = run_bp(H_rep, priors, syndrome, iters=10)
decision = (history[-1] < 0).astype(np.uint8)
print(decision) # -> [0 0 0 1 0 0 0]
print(np.array_equal((H_rep @ decision) % 2, syndrome)) # -> True
# the degenerate trap: d0 touches all four mechanisms, d1/d2 pair them up
H_trap = np.array([
[1, 1, 1, 1],
[1, 1, 0, 0],
[0, 0, 1, 1],
], dtype=np.uint8)
trap_priors = np.full(4, 0.08)
trap_syndrome = np.array([1, 1, 0], dtype=np.uint8)
trap_history = run_bp(H_trap, trap_priors, trap_syndrome, iters=6)
for it, L in enumerate(trap_history, start=1):
hard = (L < 0).astype(np.uint8)
print(it, np.round(L, 2), hard)
# -> 1 [-1.36 -1.36 3.52 3.52] [1 1 0 0]
# -> 2 [1.36 1.36 3.52 3.52] [0 0 0 0]
# -> 3 [-1.17 -1.17 4.2 4.2 ] [1 1 0 0]
# -> 4 [1.17 1.17 4.2 4.2 ] [0 0 0 0]
# -> 5 [-1.09 -1.09 4.33 4.33] [1 1 0 0]
# -> 6 [1.09 1.09 4.33 4.33] [0 0 0 0]
def osd0(H, syndrome, posterior):
"""OSD order 0: sort columns by |LLR|, GF(2)-eliminate, solve pivots (free = 0)."""
n_checks, n_vars = H.shape
order = np.argsort(-np.abs(posterior), kind="stable")
rows = np.column_stack([H, syndrome]).astype(np.uint8)
pivot_row = {}
rank = 0
for col in order:
if rank == n_checks:
break
hits = [r for r in range(rank, n_checks) if rows[r, col] == 1]
if len(hits) == 0:
continue # dependent on earlier pivots: stays a free column
rows[[rank, hits[0]]] = rows[[hits[0], rank]]
for r in range(n_checks):
if r != rank and rows[r, col] == 1:
rows[r] ^= rows[rank]
pivot_row[col] = rank
rank += 1
recovery = np.zeros(n_vars, dtype=np.uint8)
for col, r in pivot_row.items():
recovery[col] = rows[r, n_vars]
return recovery
# reliability ranking is decisive even though the signs never settled
print(np.argsort(-np.abs(trap_history[-1]), kind="stable")) # -> [2 3 0 1]
recovery = osd0(H_trap, trap_syndrome, trap_history[-1])
print(recovery) # -> [1 0 0 0]
print((H_trap @ recovery) % 2) # -> [1 1 0]from qliff.qec import DetectorErrorModel, DetectorSampler
from qliff.qec import hex_color_code, logical_fidelity
from qliff.qec.decoder import BpOsdDecoder, make_decoder
# a colour-code memory: weight-6 checks, so single faults flip 3 detectors
circuit = hex_color_code(distance=2, rounds=2, p=0.01)
dem = DetectorErrorModel(circuit)
H, priors, obs_matrix = dem.check_matrix()
print(H.shape) # -> (27, 36)
print(dem.is_graphlike(), dem.max_degree()) # -> False 3
# matching refuses this DEM outright
try:
make_decoder("mwpm", dem)
except ValueError as err:
print(str(err)[:34]) # -> MWPM needs a graphlike error model
# BP+OSD decodes it: sample syndromes, decode, compare to the true flips
decoder = BpOsdDecoder(dem) # equivalently: make_decoder("bposd", dem)
detections, observed = DetectorSampler(circuit).sample(shots=200, seed=7)
predicted = decoder.decode_batch(detections)
print(logical_fidelity(predicted, observed)) # -> 0.99BP+OSD is fast and general, but it isn't optimal: OSD's combination search is a heuristic, not an exhaustive sum over every error. The next chapter contracts a tensor network to do that: exact maximum-likelihood decoding, weighing every consistent error at once.