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
3838import argparse
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
6474def _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):
279302def 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
392415def _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