Skip to content

Commit b5aab7c

Browse files
Merge pull request #3 from vm6502q/belief_propagation
Belief propagation (by Anthropic Claude)
2 parents 0ac48e1 + 4bb8f13 commit b5aab7c

5 files changed

Lines changed: 566 additions & 35 deletions

File tree

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,23 @@ def generate_spin_glass_graph(n_nodes=16, degree=3, seed=None):
7373

7474

7575
G = generate_spin_glass_graph(n_nodes=64, seed=42)
76-
solution_bit_string, cut_value, node_groups, energy = spin_glass_solver(G, quality=6, shots=None, anneal_t=8.0, anneal_h=8.0, is_spin_glass=True, best_guess=None, is_maxcut_gpu=True, gray_iterations=None, gray_seed_multiple=None)
76+
solution_bit_string, cut_value, node_groups, energy = spin_glass_solver(
77+
G,
78+
quality=6,
79+
shots=None,
80+
anneal_t=8.0,
81+
anneal_h=8.0,
82+
is_spin_glass=True,
83+
best_guess=None,
84+
is_maxcut_gpu=True,
85+
gray_iterations=None,
86+
gray_seed_multiple=None,
87+
bp_scale=None,
88+
bp_damping=0.5
89+
)
7790
# solution_bit_string, cut_value, node_groups, energy = spin_glass_solver(G, best_guess=maxcut_tfim(G, quality=6)[0])
7891
```
79-
We also provide `spin_glass_solver_sparse(G)` and `spin_glass_solver_streaming(G_func, nodes)`. `best_guess` gives the option to seed the algorithm with a best guess as to the maximal cut (as an integer, binary string, or list of booleans). By default, `spin_glass_solver()` uses `maxcut_tfim(G)` with passed-through `quality` as `best_guess`, which typically works well, but it could be seeded with higher `maxcut_tfim()` `quality` or Goemans-Williamson, for example. `is_spin_glass` controls whether the solver optimizes for cut value or spin-glass energy. This function is designed with a sign convention for weights such that it can immediately be used as a MAXCUT solver itself: you might need to reverse the sign convention on your weights for spin glass graphs, but this is only convention. `gray_iterations` gives manual control over how many iterations are carried out of a parallel Gray-code search on `best_guess`. `gray_seed_multiple` controls how many parallel search seeds (as a multiple of your CPU thread count) are tested for the best parallel seeds, and a value of `1` will perfectly cover the search space without collision if your node count is a power of 2.
92+
We also provide `spin_glass_solver_sparse(G)` and `spin_glass_solver_streaming(G_func, nodes)`. `best_guess` gives the option to seed the algorithm with a best guess as to the maximal cut (as an integer, binary string, or list of booleans). By default, `spin_glass_solver()` uses `maxcut_tfim(G)` with passed-through `quality` as `best_guess`, which typically works well, but it could be seeded with higher `maxcut_tfim()` `quality` or Goemans-Williamson, for example. `is_spin_glass` controls whether the solver optimizes for cut value or spin-glass energy. This function is designed with a sign convention for weights such that it can immediately be used as a MAXCUT solver itself: you might need to reverse the sign convention on your weights for spin glass graphs, but this is only convention. `gray_iterations` gives manual control over how many iterations are carried out of a parallel Gray-code search on `best_guess`. `gray_seed_multiple` controls how many parallel search seeds (as a multiple of your CPU thread count) are tested for the best parallel seeds, and a value of `1` will perfectly cover the search space without collision if your node count is a power of 2. `bp_scale` controls opt-in and time complexity scaling for _belief propagation_ as a competing "warm start," with `None` disabling this option completely, `1.0` representing a budget of exactly `O(n^3)` time complexity, and lesser or greater values representing a linear multiplier on that budget. `bp_damping` similarly controls belief propagation damping.
8093

8194
These solvers above can be used as a "warm state" for _MaxSAT-based_ solvers or _branch-and-bound_ solvers, potentially to reach _exact_ solutions to MAXCUT:
8295

pyqrackising/spin_glass_solver.py

Lines changed: 173 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,120 @@ def run_gray_search_opencl(
307307
return energy, theta
308308

309309

310+
# Belief propagation was added by (Anthropic) Claude
311+
def belief_propagation_marginals_dense(G_m, n, bp_scale=1.0, damping=0.5):
312+
"""
313+
Run loopy belief propagation on the dense (upper-triangular) adjacency
314+
matrix to produce per-node marginals for warm-starting the partition.
315+
316+
The dense solver stores G_m as a full n×n matrix; we read only the
317+
upper triangle (j > i) to match the sparse convention, so behaviour
318+
is consistent across all three solver variants.
319+
320+
Parameters
321+
----------
322+
G_m : ndarray, shape (n, n)
323+
Dense adjacency/weight matrix. May be full or upper-triangular.
324+
n : int
325+
Number of nodes.
326+
bp_scale : float
327+
Iteration cap = int(bp_scale * n). Gives O(n * m) total work,
328+
<= O(n^3) for any graph density. User-controlled.
329+
damping : float
330+
Message damping factor in [0, 1). Stabilises oscillating marginals
331+
on dense or heavily frustrated graphs.
332+
333+
Returns
334+
-------
335+
marginals : ndarray, shape (n,)
336+
Soft assignment in (-1, +1). Positive values favour partition 1,
337+
negative values favour partition 0.
338+
"""
339+
max_iterations = max(1, int(bp_scale * n))
340+
341+
# Collect undirected edges from upper triangle
342+
us = []
343+
vs = []
344+
ws = []
345+
for i in range(n):
346+
for j in range(i + 1, n):
347+
w = G_m[i, j]
348+
if abs(w) > 1e-12:
349+
us.append(i)
350+
vs.append(j)
351+
ws.append(w)
352+
353+
m = len(us)
354+
if m == 0:
355+
return np.zeros(n, dtype=np.float64)
356+
357+
us = np.array(us, dtype=np.int32)
358+
vs = np.array(vs, dtype=np.int32)
359+
ws = np.array(ws, dtype=np.float64)
360+
361+
# msg_fwd[k] = message from us[k] -> vs[k]
362+
# msg_bwd[k] = message from vs[k] -> us[k]
363+
msg_fwd = np.zeros(m, dtype=np.float64)
364+
msg_bwd = np.zeros(m, dtype=np.float64)
365+
366+
# Build neighbour index: node -> list of (edge_idx, direction)
367+
# direction=0: node is 'u' end, incoming = msg_bwd[k]
368+
# direction=1: node is 'v' end, incoming = msg_fwd[k]
369+
node_edges = [[] for _ in range(n)]
370+
for k in range(m):
371+
node_edges[us[k]].append((k, 0))
372+
node_edges[vs[k]].append((k, 1))
373+
374+
for _ in range(max_iterations):
375+
new_fwd = np.empty(m, dtype=np.float64)
376+
new_bwd = np.empty(m, dtype=np.float64)
377+
378+
for k in range(m):
379+
u = us[k]
380+
v = vs[k]
381+
w = ws[k]
382+
383+
# Message u -> v: aggregate incoming to u excluding v
384+
h_u = 0.0
385+
for (ek, direction) in node_edges[u]:
386+
if ek == k:
387+
continue
388+
h_u += msg_bwd[ek] if direction == 0 else msg_fwd[ek]
389+
390+
tanh_w = np.tanh(w)
391+
product = tanh_w * np.tanh(h_u)
392+
product = np.clip(product, -1.0 + 1e-9, 1.0 - 1e-9)
393+
new_fwd[k] = damping * msg_fwd[k] + (1.0 - damping) * np.arctanh(product)
394+
395+
# Message v -> u: aggregate incoming to v excluding u
396+
h_v = 0.0
397+
for (ek, direction) in node_edges[v]:
398+
if ek == k:
399+
continue
400+
h_v += msg_bwd[ek] if direction == 0 else msg_fwd[ek]
401+
402+
product_v = tanh_w * np.tanh(h_v)
403+
product_v = np.clip(product_v, -1.0 + 1e-9, 1.0 - 1e-9)
404+
new_bwd[k] = damping * msg_bwd[k] + (1.0 - damping) * np.arctanh(product_v)
405+
406+
msg_fwd = new_fwd
407+
msg_bwd = new_bwd
408+
409+
# Marginals: sum all incoming messages per node
410+
marginals = np.zeros(n, dtype=np.float64)
411+
for k in range(m):
412+
marginals[us[k]] += msg_bwd[k]
413+
marginals[vs[k]] += msg_fwd[k]
414+
415+
return np.tanh(marginals)
416+
417+
418+
def bp_warm_start_dense(G_m, n, bp_scale=1.0, damping=0.5):
419+
"""Partition bitstring from BP marginals over the dense adjacency matrix."""
420+
marginals = belief_propagation_marginals_dense(G_m, n, bp_scale, damping)
421+
return marginals > 0.0
422+
423+
310424
def spin_glass_solver(
311425
G,
312426
quality=None,
@@ -320,7 +434,23 @@ def spin_glass_solver(
320434
is_log=False,
321435
gray_iterations=None,
322436
gray_seed_multiple=None,
437+
bp_scale=None,
438+
bp_damping=0.5,
323439
):
440+
"""
441+
Dense spin glass / MaxCut solver with optional belief propagation warm-start.
442+
443+
Parameters
444+
----------
445+
bp_scale : float or None
446+
Controls BP iteration count as int(bp_scale * n_qubits).
447+
Default None disables BP. Set to 1.0 to enable with O(n*m) cost
448+
(<=O(n^3) for any graph density). Increase for more BP iterations
449+
at proportionally higher cost.
450+
bp_damping : float
451+
Message damping in [0, 1). Default 0.5. Higher values help
452+
convergence on dense or heavily frustrated graphs.
453+
"""
324454
dtype = opencl_context.dtype
325455
nodes = None
326456
n_qubits = 0
@@ -360,17 +490,49 @@ def spin_glass_solver(
360490
elif isinstance(best_guess, list):
361491
bitstring = "".join(["1" if b else "0" for b in best_guess])
362492
else:
363-
bitstring, cut_value, _ = maxcut_tfim(
364-
G_m,
365-
quality=quality,
366-
shots=shots,
367-
is_spin_glass=is_spin_glass,
368-
anneal_t=anneal_t,
369-
anneal_h=anneal_h,
370-
repulsion_base=repulsion_base,
371-
is_maxcut_gpu=is_maxcut_gpu,
372-
is_nested=True,
373-
)
493+
# BP warm-start: run before the sampling heuristic if bp_scale is set,
494+
# so that the cascade local search begins from a sign-aware partition
495+
# rather than the TFIM prior alone.
496+
if bp_scale is not None and n_qubits >= heuristic_threshold:
497+
bp_theta = bp_warm_start_dense(G_m, n_qubits, bp_scale, bp_damping)
498+
bitstring, cut_value, _ = maxcut_tfim(
499+
G_m,
500+
quality=quality,
501+
shots=shots,
502+
is_spin_glass=is_spin_glass,
503+
anneal_t=anneal_t,
504+
anneal_h=anneal_h,
505+
repulsion_base=repulsion_base,
506+
is_maxcut_gpu=is_maxcut_gpu,
507+
is_nested=True,
508+
)
509+
# Keep whichever of BP or sampling gave the better cut
510+
bp_energy = (
511+
compute_energy(bp_theta, G_m, n_qubits)
512+
if is_spin_glass
513+
else compute_cut(bp_theta, G_m, n_qubits)
514+
)
515+
sample_theta = np.array([b == "1" for b in list(bitstring)], dtype=np.bool_)
516+
sample_energy = (
517+
compute_energy(sample_theta, G_m, n_qubits)
518+
if is_spin_glass
519+
else compute_cut(sample_theta, G_m, n_qubits)
520+
)
521+
if bp_energy > sample_energy:
522+
bitstring = "".join(["1" if b else "0" for b in bp_theta])
523+
cut_value = bp_energy if not is_spin_glass else None
524+
else:
525+
bitstring, cut_value, _ = maxcut_tfim(
526+
G_m,
527+
quality=quality,
528+
shots=shots,
529+
is_spin_glass=is_spin_glass,
530+
anneal_t=anneal_t,
531+
anneal_h=anneal_h,
532+
repulsion_base=repulsion_base,
533+
is_maxcut_gpu=is_maxcut_gpu,
534+
is_nested=True,
535+
)
374536

375537
best_theta = np.array([b == "1" for b in list(bitstring)], dtype=np.bool_)
376538
if is_spin_glass:

0 commit comments

Comments
 (0)