Skip to content

Commit 6fa2a59

Browse files
committed
feat: add unsplittable mcf with two solvers, one classical, one via qubo
1 parent deb9a8a commit 6fa2a59

22 files changed

Lines changed: 3279 additions & 2 deletions

‎.gitignore‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ __pycache__/
5252
# Software license key for GAMS, used for a docker container with GAMS included
5353
gamslice.txt
5454

55-
# Listing files compiled from our GAMS scripts
55+
# Don't include GAMS Data eXchange (GDX)
56+
*.gdx
57+
58+
# Listing files compiled from our GAMS scripts files, which are generated when running GAMS solvers
5659
*.lst
5760
*.op2
5861
solvers/gams/**/225a
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from gamspy_qubo.qubo import Qubo
2+
3+
__all__ = ["Qubo"]
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
from __future__ import annotations
2+
3+
import re
4+
5+
import numpy as np
6+
import pandas as pd
7+
from gamspy import SolveStatus
8+
from gamspy.exceptions import GamspyException, ValidationError
9+
10+
11+
def validate_value(value: int, allowed_values: list[int], param_name: str) -> int:
12+
if value not in allowed_values:
13+
raise ValueError(f"{param_name} must be one of {allowed_values}")
14+
return value
15+
16+
17+
def check_row_entries(df: pd.DataFrame) -> pd.DataFrame:
18+
"""
19+
Helper function to filter DataFrame having either 0 or 1 entries in each row.
20+
Args:
21+
df: A Pandas DataFrame.
22+
23+
Returns:
24+
Filtered DataFrame with rows having either 0 or 1
25+
"""
26+
row_contains_only_0s_or_1s = df.isin([0, 1]).all(axis=1)
27+
return df[row_contains_only_0s_or_1s]
28+
29+
30+
def fetch_quadratic_coeff(raw_df: pd.DataFrame, bin_vars: list) -> np.ndarray:
31+
"""
32+
Helper function to convert the original Q matrix of the problem to a symmetric matrix
33+
34+
Args:
35+
raw_df: Original problem Q data in a pd.DataFrame
36+
37+
Returns:
38+
Numpy Q matrix
39+
"""
40+
raw_df["value"] /= 2
41+
mask = raw_df["j_1"].astype(str) == raw_df["j_2"].astype(str)
42+
filtered_quad = raw_df.loc[mask, :].copy()
43+
raw_df = raw_df.loc[~mask].copy()
44+
diag_quad = filtered_quad.reset_index(drop=True)
45+
quad = raw_df.copy(deep=True)
46+
quad["j_1"], quad["j_2"] = raw_df["j_2"], raw_df["j_1"]
47+
quad = pd.concat([raw_df, quad, diag_quad], axis=0)
48+
quad = quad.pivot(index="j_1", columns="j_2", values="value").fillna(0)
49+
quad = quad.reindex(labels=bin_vars, axis="index")
50+
quad = quad.reindex(labels=bin_vars, axis="columns")
51+
return quad.to_numpy()
52+
53+
54+
def var_contribution(
55+
A: pd.DataFrame, vars: dict, cons: list | None = None
56+
) -> np.ndarray:
57+
"""
58+
Helper function to calculate the contribution of given variables
59+
in a constraint or set of constraints
60+
61+
Args:
62+
A: df of coefficients
63+
vars: contributing variables
64+
cons: participating constraints
65+
66+
Returns:
67+
np.ndarray of Total contribution of all variables for that constraint
68+
"""
69+
cons = slice(None) if cons is None else cons # type: ignore
70+
coeffs_of_vars_in_constraint = A.loc[cons, vars.keys()].to_numpy() # type: ignore
71+
lb_var_levels = np.array(list(vars.values())).reshape((len(vars), 1))
72+
if coeffs_of_vars_in_constraint.size > 0:
73+
return coeffs_of_vars_in_constraint @ lb_var_levels
74+
75+
return np.array([0])
76+
77+
78+
def modify_matrix(
79+
b_vec: np.ndarray,
80+
rhs: float,
81+
slacks: np.ndarray,
82+
A_coeff: pd.DataFrame,
83+
ele: pd.Series,
84+
nslacks: int,
85+
) -> tuple[np.ndarray, pd.DataFrame, int]:
86+
"""
87+
Helper function to update the original "A" matrix of coeffs
88+
89+
Args:
90+
b_vec: The n*1 vector
91+
rhs : The Right hand side of a constraint
92+
slacks: result of gen_slacks()
93+
A_coeff: "A" matrix
94+
ele: constraint
95+
nslacks: number of slacks
96+
97+
Returns:
98+
updated b_vec, A_coeff and number of slacks
99+
"""
100+
con_index = ele.i
101+
slack_names = [
102+
f"slack_{con_index}_{i}" for i in range(nslacks + 1, nslacks + len(slacks) + 1)
103+
]
104+
new_cols = pd.DataFrame(
105+
0, index=A_coeff.index, columns=slack_names, dtype=slacks.dtype
106+
)
107+
new_cols.loc[con_index, slack_names] = slacks
108+
A_coeff = pd.concat([A_coeff, new_cols], axis=1)
109+
nslacks += len(slacks)
110+
111+
return np.append(b_vec, [rhs]), A_coeff, nslacks
112+
113+
114+
def gen_slacks(var_range: float) -> np.ndarray:
115+
"""
116+
Helper function to generate slacks depending on the range of variables or rhs.
117+
118+
Note: `var_range` cannot be more than 1e4. This hard limit prevents the creation of
119+
excessive auxiliary binary variables and maintains model performance
120+
during the binarization of slacks or integer variables.
121+
122+
Args:
123+
var_range: upper bound of variable
124+
125+
Returns:
126+
Numpy array containing slack coefficients
127+
128+
example:
129+
if var_range=5, then gen_slacks(5) returns [1, 2, 2]
130+
"""
131+
if var_range >= 1e4:
132+
raise ValidationError(
133+
"The Upper bound is greater than or equal to 1e+4, Quitting!"
134+
)
135+
136+
power = int(np.log2(var_range)) if var_range > 0 else 0
137+
bounded_coef = var_range - (2**power - 1)
138+
D_val = [2**i for i in range(power)] + [bounded_coef]
139+
return np.array(D_val)
140+
141+
142+
def get_lhs_bounds(ele: pd.DataFrame) -> tuple[float, float]:
143+
"""
144+
Helper function to find the bounds of a constraint
145+
146+
Args:
147+
ele: The coefficients of the constraint
148+
149+
Returns:
150+
lower_bound, upper_bound
151+
"""
152+
return float(ele[ele < 0].sum()), float(ele[ele > 0].sum())
153+
154+
155+
def check_convexity(Q: np.ndarray) -> str:
156+
eigenvalues = np.linalg.eigvals(Q)
157+
158+
if np.all(eigenvalues > 0):
159+
return "Function is strictly convex."
160+
elif np.all(eigenvalues >= 0):
161+
return "Function is convex."
162+
else:
163+
return "Function is not convex."
164+
165+
166+
def qubo_to_ising(Q: dict, offset: float = 0.0) -> tuple[dict, dict, float]:
167+
h = {} # type: ignore
168+
J = {}
169+
linear_offset = 0.0
170+
quadratic_offset = 0.0
171+
172+
for (u, v), bias in Q.items():
173+
if u == v:
174+
if u in h:
175+
h[u] += 0.5 * bias
176+
else:
177+
h[u] = 0.5 * bias
178+
linear_offset += bias
179+
else:
180+
if bias != 0.0:
181+
J[(u, v)] = 0.25 * bias
182+
183+
if u in h:
184+
h[u] += 0.25 * bias
185+
else:
186+
h[u] = 0.25 * bias
187+
188+
if v in h:
189+
h[v] += 0.25 * bias
190+
else:
191+
h[v] = 0.25 * bias
192+
193+
quadratic_offset += bias
194+
195+
offset += 0.5 * linear_offset + 0.25 * quadratic_offset
196+
197+
return h, J, offset
198+
199+
200+
def qubo_to_maxcut(Q: np.ndarray) -> np.ndarray:
201+
return -1 * np.sum(Q, axis=1)
202+
203+
204+
def triu(Q_matrix: np.ndarray):
205+
upper_mask = np.triu(np.ones_like(Q_matrix), k=1) # 1 above diagonal, 0 elsewhere
206+
scaled_upper = Q_matrix * (1 + upper_mask) # Double above diagonal
207+
return np.triu(scaled_upper)
208+
209+
210+
def check_classical_solve(solveStatus: SolveStatus | None):
211+
if solveStatus is None:
212+
raise GamspyException("Solver status is None. Solve the model first.")
213+
214+
if solveStatus in [
215+
SolveStatus.IterationInterrupt,
216+
SolveStatus.ResourceInterrupt,
217+
SolveStatus.UserInterrupt,
218+
]:
219+
# Continue mapping incumbent solution if solve_status is one of
220+
# [UserInterrupt, ResourceInterrupt, IterationInterrupt].
221+
pass
222+
223+
elif solveStatus != SolveStatus.NormalCompletion:
224+
raise GamspyException(
225+
f"Solver did not yield NormalCompletion. Solver status = {solveStatus}"
226+
)
227+
228+
229+
def parse_examiner_output(text):
230+
if "Primal constraints satisfied" in text:
231+
return {"feasible": True, "message": "Solution is feasible."}
232+
233+
match = re.search(
234+
r"Primal infeasible.*?Max violation:\s+(.*?):\s+(.*)", text, re.DOTALL
235+
)
236+
237+
if match:
238+
constraint_name = match.group(1).strip()
239+
violation_details = match.group(2).split("\n")[0].strip()
240+
return {
241+
"feasible": False,
242+
"message": f"Infeasible: {constraint_name} violates bounds ({violation_details})",
243+
}
244+
245+
return {
246+
"feasible": False,
247+
"message": "Examiner failed or returned unexpected format.",
248+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from gamspy_qubo.backend.base import baseBackend
2+
from gamspy_qubo.backend.dwave import DwaveBackend
3+
from gamspy_qubo.backend.kipu import KipuBackend
4+
from gamspy_qubo.backend.load_json import JsonBackend
5+
6+
__all__ = ["baseBackend", "DwaveBackend", "KipuBackend", "JsonBackend"]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from __future__ import annotations
2+
3+
import importlib
4+
from abc import ABC, abstractmethod
5+
from typing import TYPE_CHECKING
6+
7+
if TYPE_CHECKING:
8+
import numpy as np
9+
import pandas as pd
10+
from gamspy import Sense
11+
12+
13+
class baseBackend(ABC):
14+
"""Abstract base class that provides a structure to different quantum backends."""
15+
16+
def __init__(
17+
self,
18+
q_matrix: np.ndarray,
19+
q_variables: pd.Series,
20+
q_constant: float | int,
21+
sense: Sense,
22+
):
23+
"""
24+
:param q_matrix: QUBO Matrix
25+
:type q_matrix: np.ndarray
26+
:param q_variables: List of variables that are associated with the QUBO
27+
:type q_variables: list[str]
28+
:param q_constant: the offset penalty term
29+
:type q_constant: float | int
30+
:param sense: direction of optimization, i.e, Min. or Max.
31+
:type sense: Sense
32+
"""
33+
self.q_matrix = q_matrix
34+
self.q_variables = q_variables
35+
self.q_constant = q_constant
36+
self.sense = sense
37+
38+
@abstractmethod
39+
def solve(self, *args, **kwargs) -> pd.DataFrame:
40+
"""Solve the problem using quantum backend
41+
42+
Returns:
43+
A pandas.DataFrame that must have the following structure,
44+
`columns = ["i", "level"]`, where `i` contains variables and
45+
`level` is the corresponding value of the variable after solving.
46+
"""
47+
raise NotImplementedError
48+
49+
@staticmethod
50+
def check_dependencies(solver_name: str, packages: dict):
51+
for pkg_name, import_path in packages.items():
52+
try:
53+
importlib.import_module(import_path)
54+
except ImportError:
55+
raise ImportError(
56+
f"The {solver_name} solver requires the '{pkg_name}' package. "
57+
f"Install it with: pip install {pkg_name}"
58+
) from None
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from __future__ import annotations
2+
3+
import importlib
4+
5+
import pandas as pd
6+
7+
from gamspy_qubo._utils import triu
8+
from gamspy_qubo.backend import baseBackend
9+
10+
11+
class DwaveBackend(baseBackend):
12+
"""
13+
DWave Backend based on `dimod.BinaryQuadraticModel` and `SimulatedAnnealingSampler`.
14+
"""
15+
16+
def __init__(self, q_matrix, q_variables, q_constant, sense):
17+
super().__init__(q_matrix, q_variables, q_constant, sense)
18+
self.check_dependencies(
19+
"DWAVE", {"dwave-ocean-sdk": "dwave.samplers", "dimod": "dimod"}
20+
)
21+
22+
def solve(self, *args, **kwargs) -> pd.DataFrame:
23+
"""
24+
Solve the model using Dwave `SimulatedAnnealingSampler` and map the solution back
25+
to original problem.
26+
27+
Users can provide keyword arguments as expected by `SimulatedAnnealingSample.sample()`
28+
29+
Note: Follows the general return structure defined in `baseBackend.solve`
30+
"""
31+
print("\n--- Starting D-Wave (Ocean) Solve ---")
32+
multiplier = -1 if self.sense.value == "MAX" else 1
33+
ut_mat = triu(self.q_matrix)
34+
ut_mat *= multiplier
35+
matrix_dict = {}
36+
rows, cols = ut_mat.nonzero()
37+
for i, j in zip(rows, cols, strict=True):
38+
matrix_dict[(self.q_variables[i], self.q_variables[j])] = ut_mat[i, j]
39+
40+
dimod = importlib.import_module("dimod")
41+
dwave_sampler = importlib.import_module("dwave.samplers")
42+
bqm = dimod.BinaryQuadraticModel.from_qubo(
43+
matrix_dict, offset=float(self.q_constant)
44+
)
45+
sampler = dwave_sampler.SimulatedAnnealingSampler()
46+
kwargs.setdefault("num_reads", 100)
47+
response = sampler.sample(bqm, **kwargs)
48+
best_sample = response.first.sample
49+
sol = pd.DataFrame(best_sample.items(), columns=["i", "level"])
50+
51+
return sol

0 commit comments

Comments
 (0)