Skip to content

Commit 841ff2f

Browse files
bnb_exact
1 parent f79bf7d commit 841ff2f

1 file changed

Lines changed: 80 additions & 57 deletions

File tree

Lines changed: 80 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@
2626
# h_bits, h_val, exact_bits, exact_val, certified = solve_qubo_exact(G_csr)
2727
#
2828
# Usage (as library — custom warm start):
29-
# from qubo_exact import qubo_branch_and_bound
30-
# exact_bits, exact_value, certified = qubo_branch_and_bound(Q, warm_bits)
29+
# from qubo_exact import maxcut_branch_and_bound
30+
# exact_bits, exact_value, certified = maxcut_branch_and_bound(Q, warm_bits)
3131
#
3232
# Q / G_csr convention:
3333
# PyQrackIsing expects a scipy CSR upper-triangular matrix with non-negative
3434
# edge weights (MAXCUT convention, no diagonal / self-loops).
35-
# qubo_branch_and_bound accepts a dense upper-triangular or symmetric numpy
35+
# maxcut_branch_and_bound accepts a dense upper-triangular or symmetric numpy
3636
# float64 matrix; diagonal terms are handled correctly throughout.
3737

3838
import argparse
@@ -51,10 +51,20 @@
5151
# QUBO objective (dense upper-triangular or symmetric Q)
5252
# ---------------------------------------------------------------------------
5353

54-
def qubo_value(Q, bits):
55-
"""Evaluate x^T Q x for a boolean assignment given as a bool/int array."""
56-
x = np.asarray(bits, dtype=np.float64)
57-
return float(x @ Q @ x)
54+
def cut_value(Q, bits):
55+
"""
56+
Evaluate the MAXCUT objective for a boolean assignment: sum of Q[i,j]
57+
for all edges (i,j) where bits[i] != bits[j] (i.e. opposite partitions).
58+
Q is upper-triangular; only entries with i < j are edge weights.
59+
"""
60+
bits = np.asarray(bits, dtype=np.bool_)
61+
n = len(bits)
62+
total = 0.0
63+
for i in range(n):
64+
for j in range(i + 1, n):
65+
if Q[i, j] != 0.0 and bits[i] != bits[j]:
66+
total += Q[i, j]
67+
return total
5868

5969

6070
# ---------------------------------------------------------------------------
@@ -63,31 +73,28 @@ def qubo_value(Q, bits):
6373

6474
def _lp_upper_bound(Q, fixed, n):
6575
"""
66-
Return an upper bound on the QUBO objective over the subproblem defined
76+
Return an upper bound on the MAXCUT objective over the subproblem defined
6777
by `fixed`: a dict mapping variable index -> {0, 1} for already-branched
6878
variables. Free variables are relaxed to [0, 1].
6979
70-
The objective is x^T Q x where Q may be upper-triangular (Q[j,i]=0 for
71-
j>i). We split the value into three additive parts that are computed
72-
independently to avoid double-counting:
80+
MAXCUT objective: sum_{i<j} Q[i,j] * (x_i + x_j - 2*x_i*x_j)
81+
i.e. edge (i,j) contributes Q[i,j] iff x_i != x_j.
7382
74-
fixed_fixed : sum_{i<j, both fixed} Q[i,j]*f_i*f_j
75-
+ sum_i Q[i,i]*f_i (diagonal of fixed vars)
76-
fixed_free : for each free var k, its linear coefficient is
77-
Q[k,k] + sum_{fj fixed, fj<k} Q[fj,k]*f_fj
78-
+ sum_{fj fixed, fj>k} Q[k,fj]*f_fj
79-
free_free : LP relaxation over free vars only, using auxiliaries
80-
w_ij for each free pair (i<j), with coefficient Q[i,j].
83+
We split into three parts:
84+
fixed_fixed : exact cut value among already-fixed variable pairs
85+
fixed_free : for free var k, contribution from edges to fixed vars is
86+
Q[fj,k]*|f_fj - x_k|, linearised as Q[fj,k]*(f_fj + x_k - 2*f_fj*x_k)
87+
free_free : LP relaxation using auxiliary y_ij = x_i*x_j, so the
88+
edge contribution is Q[i,j]*(x_i + x_j - 2*y_ij)
8189
8290
Falls back to a cheap bound if scipy is unavailable.
8391
"""
84-
# --- fixed-fixed contribution (exact, no relaxation) ---
92+
# --- fixed-fixed contribution (exact) ---
8593
fixed_fixed = 0.0
8694
for i in fixed:
87-
fixed_fixed += Q[i, i] * fixed[i] # diagonal
8895
for j in fixed:
89-
if i < j:
90-
fixed_fixed += Q[i, j] * fixed[i] * fixed[j]
96+
if i < j and Q[i, j] != 0.0 and fixed[i] != fixed[j]:
97+
fixed_fixed += Q[i, j]
9198

9299
free = [i for i in range(n) if i not in fixed]
93100
n_free = len(free)
@@ -96,45 +103,60 @@ def _lp_upper_bound(Q, fixed, n):
96103
return fixed_fixed
97104

98105
if not _HAVE_SCIPY:
99-
# Cheap fallback: assume each free var takes its most beneficial value
106+
# Cheap fallback: sum max possible contribution of each free edge
100107
bound = fixed_fixed
101108
for i in free:
102-
lin = Q[i, i]
103109
for fj, fval in fixed.items():
104-
lin += Q[min(i, fj), max(i, fj)] * fval
105-
bound += max(0.0, lin)
110+
a, b_ = min(i, fj), max(i, fj)
111+
bound += max(0.0, Q[a, b_]) # best case: edge is cut
106112
for j in free:
107113
if i < j:
108114
bound += max(0.0, Q[i, j])
109115
return bound
110116

111117
free_idx = {v: k for k, v in enumerate(free)}
112-
pairs = [(i, j) for idx, i in enumerate(free) for j in free[idx + 1:]]
118+
pairs = [(i, j) for idx, i in enumerate(free) for j in free[idx + 1:]
119+
if Q[i, j] != 0.0]
113120
n_pairs = len(pairs)
114-
total_vars = n_free + n_pairs
121+
total_vars = n_free + n_pairs # x_i for free vars, then y_ij for free pairs
115122

116-
# --- linear coefficients for free vars (fixed-free cross terms + diagonal) ---
123+
# Objective (negated for minimisation):
124+
# For each free var k: sum over fixed neighbours fj of Q[fj,k]*(fval + x_k - 2*fval*x_k)
125+
# The constant fval*Q[fj,k] goes into fixed_free_const; the x_k coefficient is
126+
# Q[fj,k]*(1 - 2*fval).
127+
# For each free pair (i,j): Q[i,j]*(x_i + x_j - 2*y_ij)
128+
# x_i and x_j coefficients are Q[i,j]; y_ij coefficient is -2*Q[i,j].
129+
130+
fixed_free_const = 0.0
117131
c = np.zeros(total_vars)
132+
118133
for k, i in enumerate(free):
119-
lin = Q[i, i]
120134
for fj, fval in fixed.items():
121135
a, b_ = min(i, fj), max(i, fj)
122-
lin += Q[a, b_] * fval
123-
c[k] = -lin # negate for minimisation
136+
w = Q[a, b_]
137+
if w == 0.0:
138+
continue
139+
fixed_free_const += w * fval
140+
c[k] -= w * (1.0 - 2.0 * fval) # negate: subtract from minimisation obj
124141

125-
# --- quadratic free-free terms via auxiliary variables ---
126142
for p, (i, j) in enumerate(pairs):
127-
c[n_free + p] = -Q[i, j] # Q is upper-tri so Q[i,j] is the full coeff (i<j)
143+
w = Q[i, j]
144+
c[free_idx[i]] -= w # x_i term
145+
c[free_idx[j]] -= w # x_j term
146+
c[n_free + p] += 2.0 * w # y_ij term (negated sign: -2w in max = +2w in min)
128147

129148
bounds = [(0.0, 1.0)] * total_vars
130149

131150
if not pairs:
132-
# No free-free interactions: LP is trivially solved by greedy
133-
lp_val = sum(max(0.0, -c[k]) for k in range(n_free))
134-
return fixed_fixed + lp_val
135-
136-
# Constraints: w_p = x_i * x_j linearisation
137-
# w_p <= x_i, w_p <= x_j, x_i + x_j - w_p <= 1
151+
# No free-free edges: LP trivially solved — each free var independently
152+
# contributes its linear term, clamped to [0,1]
153+
lp_val = sum(max(-c[k], 0.0) if c[k] < 0.0 else 0.0 for k in range(n_free))
154+
# also add terms where c[k] is negative (free var set to 1 helps)
155+
lp_val = sum((-c[k] if c[k] < 0.0 else 0.0) for k in range(n_free))
156+
return fixed_fixed + fixed_free_const + lp_val
157+
158+
# Constraints: y_ij = x_i * x_j linearisation
159+
# y_ij <= x_i, y_ij <= x_j, x_i + x_j - y_ij <= 1
138160
A_ub = []
139161
b_ub = []
140162
for p, (i, j) in enumerate(pairs):
@@ -155,7 +177,7 @@ def _lp_upper_bound(Q, fixed, n):
155177
)
156178

157179
if res.success:
158-
return fixed_fixed + (-res.fun)
180+
return fixed_fixed + fixed_free_const + (-res.fun)
159181
else:
160182
return float("inf")
161183

@@ -188,17 +210,18 @@ def _most_influential(Q, fixed, n):
188210
# Branch and bound
189211
# ---------------------------------------------------------------------------
190212

191-
def qubo_branch_and_bound(Q, warm_bits, verbose=True, time_limit=None):
213+
def maxcut_branch_and_bound(Q, warm_bits, verbose=True, time_limit=None):
192214
"""
193-
Exact QUBO maximiser via branch and bound with LP relaxation upper bounds.
215+
Exact MAXCUT solver via branch and bound with LP relaxation upper bounds.
194216
195217
Parameters
196218
----------
197219
Q : (n, n) float64 ndarray
198-
Dense upper-triangular or symmetric QUBO matrix.
199-
Objective: maximise x^T Q x over x in {0,1}^n.
220+
Dense upper-triangular edge weight matrix (no diagonal).
221+
Objective: maximise sum_{i<j} Q[i,j] * (x_i != x_j).
200222
warm_bits : array-like of bool/int, length n
201-
Warm-start incumbent (e.g. from PyQrackIsing via solve_qubo_exact).
223+
Warm-start incumbent from PyQrackIsing (via solve_qubo_exact) or any
224+
heuristic. The partition is defined by which nodes have bit=1 vs bit=0.
202225
verbose : bool
203226
time_limit : float or None
204227
Wall-clock seconds before returning best-so-far (not certified exact).
@@ -218,7 +241,7 @@ def qubo_branch_and_bound(Q, warm_bits, verbose=True, time_limit=None):
218241
assert len(warm_bits) == n, "warm_bits length must match Q dimension"
219242

220243
best_bits = warm_bits.copy()
221-
best_value = qubo_value(Q, best_bits)
244+
best_value = cut_value(Q, best_bits)
222245

223246
if verbose:
224247
print(f"Warm-start incumbent: {best_value:.6f}")
@@ -246,7 +269,7 @@ def qubo_branch_and_bound(Q, warm_bits, verbose=True, time_limit=None):
246269

247270
if len(fixed) == n:
248271
bits = np.array([fixed[i] for i in range(n)], dtype=np.bool_)
249-
val = qubo_value(Q, bits)
272+
val = cut_value(Q, bits)
250273
if val > best_value:
251274
best_value = val
252275
best_bits = bits.copy()
@@ -279,7 +302,7 @@ def qubo_branch_and_bound(Q, warm_bits, verbose=True, time_limit=None):
279302
def graph_to_dense_upper(G):
280303
"""
281304
Convert a NetworkX graph to a dense upper-triangular numpy matrix
282-
suitable for qubo_branch_and_bound. Node labels must be integers 0..n-1.
305+
suitable for maxcut_branch_and_bound. Node labels must be integers 0..n-1.
283306
Diagonal is left zero (PyQrackIsing MAXCUT graphs have no self-loops).
284307
"""
285308
n = G.number_of_nodes()
@@ -304,7 +327,7 @@ def solve_qubo_exact(G, is_spin_glass=False, pyqrackising_kwargs=None, verbose=T
304327
305328
If your problem has diagonal terms (linear penalties/bonuses per variable),
306329
add them to the dense Q returned by graph_to_dense_upper before calling
307-
qubo_branch_and_bound directly — the B&B handles diagonal correctly.
330+
maxcut_branch_and_bound directly — the B&B handles diagonal correctly.
308331
309332
Parameters
310333
----------
@@ -347,10 +370,10 @@ def solve_qubo_exact(G, is_spin_glass=False, pyqrackising_kwargs=None, verbose=T
347370
print("Starting branch and bound...")
348371

349372
# Convert graph to dense upper-tri for B&B. If the caller has diagonal terms,
350-
# add them to Q here before passing to qubo_branch_and_bound.
373+
# add them to Q here before passing to maxcut_branch_and_bound.
351374
Q = graph_to_dense_upper(G)
352375

353-
exact_bits, exact_value, certified = qubo_branch_and_bound(
376+
exact_bits, exact_value, certified = maxcut_branch_and_bound(
354377
Q, heuristic_bits, verbose=verbose, time_limit=time_limit
355378
)
356379

@@ -390,16 +413,16 @@ def _random_maxcut_graph(n, density, seed):
390413

391414

392415
def _brute_force(Q, n):
393-
"""Reference exact solver for small n to validate B&B."""
416+
"""Reference exact MAXCUT solver for small n to validate B&B."""
394417
best_val = -float("inf")
395418
best_bits = None
396419
for mask in range(1 << n):
397-
bits = np.array([(mask >> i) & 1 for i in range(n)], dtype=np.float64)
398-
val = float(bits @ Q @ bits)
420+
bits = np.array([(mask >> i) & 1 for i in range(n)], dtype=np.bool_)
421+
val = cut_value(Q, bits)
399422
if val > best_val:
400423
best_val = val
401424
best_bits = bits.copy()
402-
return best_bits.astype(np.bool_), best_val
425+
return best_bits, best_val
403426

404427

405428
# ---------------------------------------------------------------------------
@@ -418,7 +441,7 @@ def _brute_force(Q, n):
418441
n = args.n
419442
print(f"Random MAXCUT: n={n}, density={args.density}, seed={args.seed}")
420443
G = _random_maxcut_graph(n, args.density, args.seed)
421-
Q = graph_to_dense_upper(G)
444+
# Q = graph_to_dense_upper(G)
422445

423446
# Here we use a random warm start so the demo runs without PyQrackIsing.
424447
# rng = np.random.default_rng(args.seed + 1)

0 commit comments

Comments
 (0)