qoro-divi 0.2.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- divi/__init__.py +8 -0
- divi/_pbar.py +73 -0
- divi/circuits.py +139 -0
- divi/exp/cirq/__init__.py +7 -0
- divi/exp/cirq/_lexer.py +126 -0
- divi/exp/cirq/_parser.py +889 -0
- divi/exp/cirq/_qasm_export.py +37 -0
- divi/exp/cirq/_qasm_import.py +35 -0
- divi/exp/cirq/exception.py +21 -0
- divi/exp/scipy/_cobyla.py +342 -0
- divi/exp/scipy/pyprima/LICENCE.txt +28 -0
- divi/exp/scipy/pyprima/__init__.py +263 -0
- divi/exp/scipy/pyprima/cobyla/__init__.py +0 -0
- divi/exp/scipy/pyprima/cobyla/cobyla.py +599 -0
- divi/exp/scipy/pyprima/cobyla/cobylb.py +849 -0
- divi/exp/scipy/pyprima/cobyla/geometry.py +240 -0
- divi/exp/scipy/pyprima/cobyla/initialize.py +269 -0
- divi/exp/scipy/pyprima/cobyla/trustregion.py +540 -0
- divi/exp/scipy/pyprima/cobyla/update.py +331 -0
- divi/exp/scipy/pyprima/common/__init__.py +0 -0
- divi/exp/scipy/pyprima/common/_bounds.py +41 -0
- divi/exp/scipy/pyprima/common/_linear_constraints.py +46 -0
- divi/exp/scipy/pyprima/common/_nonlinear_constraints.py +64 -0
- divi/exp/scipy/pyprima/common/_project.py +224 -0
- divi/exp/scipy/pyprima/common/checkbreak.py +107 -0
- divi/exp/scipy/pyprima/common/consts.py +48 -0
- divi/exp/scipy/pyprima/common/evaluate.py +101 -0
- divi/exp/scipy/pyprima/common/history.py +39 -0
- divi/exp/scipy/pyprima/common/infos.py +30 -0
- divi/exp/scipy/pyprima/common/linalg.py +452 -0
- divi/exp/scipy/pyprima/common/message.py +336 -0
- divi/exp/scipy/pyprima/common/powalg.py +131 -0
- divi/exp/scipy/pyprima/common/preproc.py +393 -0
- divi/exp/scipy/pyprima/common/present.py +5 -0
- divi/exp/scipy/pyprima/common/ratio.py +56 -0
- divi/exp/scipy/pyprima/common/redrho.py +49 -0
- divi/exp/scipy/pyprima/common/selectx.py +346 -0
- divi/interfaces.py +25 -0
- divi/parallel_simulator.py +258 -0
- divi/qasm.py +220 -0
- divi/qem.py +191 -0
- divi/qlogger.py +119 -0
- divi/qoro_service.py +343 -0
- divi/qprog/__init__.py +13 -0
- divi/qprog/_graph_partitioning.py +619 -0
- divi/qprog/_mlae.py +182 -0
- divi/qprog/_qaoa.py +440 -0
- divi/qprog/_vqe.py +275 -0
- divi/qprog/_vqe_sweep.py +144 -0
- divi/qprog/batch.py +235 -0
- divi/qprog/optimizers.py +75 -0
- divi/qprog/quantum_program.py +493 -0
- divi/utils.py +116 -0
- qoro_divi-0.2.0b1.dist-info/LICENSE +190 -0
- qoro_divi-0.2.0b1.dist-info/LICENSES/Apache-2.0.txt +73 -0
- qoro_divi-0.2.0b1.dist-info/METADATA +57 -0
- qoro_divi-0.2.0b1.dist-info/RECORD +58 -0
- qoro_divi-0.2.0b1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Qoro Quantum Ltd <divi@qoroquantum.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import heapq
|
|
6
|
+
import re
|
|
7
|
+
import string
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from functools import partial
|
|
12
|
+
from typing import Literal, Optional
|
|
13
|
+
from warnings import warn
|
|
14
|
+
|
|
15
|
+
import matplotlib.cm as cm
|
|
16
|
+
import matplotlib.pyplot as plt
|
|
17
|
+
import networkx as nx
|
|
18
|
+
import numpy as np
|
|
19
|
+
import rustworkx as rx
|
|
20
|
+
import scipy.sparse.linalg as spla
|
|
21
|
+
from pymetis import part_graph
|
|
22
|
+
from sklearn.cluster import SpectralClustering
|
|
23
|
+
|
|
24
|
+
from divi.interfaces import CircuitRunner
|
|
25
|
+
from divi.qprog import QAOA, ProgramBatch
|
|
26
|
+
from divi.qprog._qaoa import (
|
|
27
|
+
_SUPPORTED_INITIAL_STATES_LITERAL,
|
|
28
|
+
GraphProblem,
|
|
29
|
+
draw_graph_solution_nodes,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from .optimizers import Optimizer
|
|
33
|
+
|
|
34
|
+
AggregateFn = Callable[
|
|
35
|
+
[list[int], str, nx.Graph | rx.PyGraph, dict[int, int]], list[int]
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# TODO: Make this dynamic through an interaction with usher
|
|
39
|
+
# once a proper endpoint is exposed
|
|
40
|
+
_MAXIMUM_AVAILABLE_QUBITS = 30
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, eq=True)
|
|
44
|
+
class PartitioningConfig:
|
|
45
|
+
max_n_nodes_per_cluster: Optional[int] = None
|
|
46
|
+
minimum_n_clusters: Optional[int] = None
|
|
47
|
+
partitioning_algorithm: Literal["spectral", "metis", "kernighan_lin"] = "spectral"
|
|
48
|
+
|
|
49
|
+
def __post_init__(self):
|
|
50
|
+
if self.max_n_nodes_per_cluster is None and self.minimum_n_clusters is None:
|
|
51
|
+
raise ValueError("At least one constraint must be specified.")
|
|
52
|
+
|
|
53
|
+
if self.minimum_n_clusters is not None and self.minimum_n_clusters < 1:
|
|
54
|
+
raise ValueError("'minimum_n_clusters' must be a positive integer.")
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
self.max_n_nodes_per_cluster is not None
|
|
58
|
+
and self.max_n_nodes_per_cluster < 1
|
|
59
|
+
):
|
|
60
|
+
raise ValueError("'max_n_nodes_per_cluster' must be a positive number.")
|
|
61
|
+
|
|
62
|
+
if self.partitioning_algorithm not in ("spectral", "metis", "kernighan_lin"):
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"Unsupported partitioning algorithm: {self.partitioning_algorithm}. "
|
|
65
|
+
"Use 'spectral' or 'metis'."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _divide_edges(
|
|
70
|
+
graph: nx.DiGraph, edge_selection_predicate: Callable
|
|
71
|
+
) -> tuple[nx.DiGraph, nx.DiGraph]:
|
|
72
|
+
"""
|
|
73
|
+
Divides a graph into two subgraphs based on the provided edge selection criteria.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
graph (nx.DiGraph): The input graph to be divided.
|
|
77
|
+
edge_selection_predicate (Callable): A function which decides if an edge should be
|
|
78
|
+
included in the selected subgraph.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
tuple[nx.DiGraph, nx.DiGraph]: A tuple containing two DiGraphs: the selected subgraph
|
|
82
|
+
and the rest of the graph.
|
|
83
|
+
"""
|
|
84
|
+
selected_edges = [
|
|
85
|
+
(u, v)
|
|
86
|
+
for u, v in graph.edges(data=False)
|
|
87
|
+
if edge_selection_predicate(graph, u, v)
|
|
88
|
+
]
|
|
89
|
+
rest_edges = [
|
|
90
|
+
(u, v) for u, v in graph.edges(data=False) if (u, v) not in selected_edges
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
selected_subgraph = graph.edge_subgraph(selected_edges).copy()
|
|
94
|
+
rest_of_graph = graph.edge_subgraph(rest_edges).copy()
|
|
95
|
+
|
|
96
|
+
rest_of_graph.remove_edges_from(selected_edges) # to avoid overlap
|
|
97
|
+
|
|
98
|
+
return selected_subgraph, rest_of_graph
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fielder_laplacian_predicate(
|
|
102
|
+
growing_graph: nx.DiGraph, src: int, dest: int
|
|
103
|
+
) -> bool:
|
|
104
|
+
"""
|
|
105
|
+
Determines if an edge should be included in the selected subgraph based on spectral partitioning.
|
|
106
|
+
|
|
107
|
+
This function uses the Fiedler vector of the graph's Laplacian matrix to divide
|
|
108
|
+
the nodes into two partitions. An edge is included in the selected subgraph
|
|
109
|
+
if both its source and destination nodes belong to the same partition.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
growing_graph (nx.DiGraph): The graph containing the currently selected edges.
|
|
113
|
+
src (int): The source node of the edge.
|
|
114
|
+
dest (int): The destination node of the edge.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
bool: True if the edge should be included in the selected subgraph, False otherwise.
|
|
118
|
+
"""
|
|
119
|
+
if growing_graph.number_of_edges() == 0:
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
L = nx.laplacian_matrix(growing_graph).astype(float)
|
|
123
|
+
|
|
124
|
+
# Create an initial random guess for the eigenvectors
|
|
125
|
+
n = L.shape[0]
|
|
126
|
+
X = np.random.rand(n, 2)
|
|
127
|
+
X, _ = np.linalg.qr(X) # Orthonormalize initial guess
|
|
128
|
+
|
|
129
|
+
# Use LOBPCG to compute the two smallest eigenvalues and corresponding eigenvectors
|
|
130
|
+
_, eigenvectors = spla.lobpcg(L, X, largest=False)
|
|
131
|
+
|
|
132
|
+
fiedler_vector = eigenvectors[:, 1].real
|
|
133
|
+
partition = set(i for i, v in enumerate(fiedler_vector) if v > 0)
|
|
134
|
+
|
|
135
|
+
return (src in partition) == (dest in partition)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _edge_partition_graph(
|
|
139
|
+
graph: nx.DiGraph, n_max_nodes_per_cluster: int
|
|
140
|
+
) -> list[nx.DiGraph]:
|
|
141
|
+
"""
|
|
142
|
+
Partitions a directed graph into smaller subgraphs using recursive bipartite spectral partitioning.
|
|
143
|
+
|
|
144
|
+
The function repeatedly divides the input graph into two subgraphs based on the
|
|
145
|
+
Fiedler vector of the graph's Laplacian matrix. This process is repeated
|
|
146
|
+
until each of the subgraphs' no. of edges does not exceed the no. of qubits.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
graph (nx.DiGraph): The input directed graph to be partitioned.
|
|
150
|
+
n_max_nodes_per_cluster (int, optional): The maximum number of nodes per subgraph.
|
|
151
|
+
Defaults to 8.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
list[nx.DiGraph]: A list of subgraphs resulting from the partitioning process.
|
|
155
|
+
"""
|
|
156
|
+
subgraphs = [graph]
|
|
157
|
+
|
|
158
|
+
while any(g.number_of_edges() > n_max_nodes_per_cluster for g in subgraphs):
|
|
159
|
+
large_subgraphs = [
|
|
160
|
+
g for g in subgraphs if g.number_of_edges() > n_max_nodes_per_cluster
|
|
161
|
+
]
|
|
162
|
+
subgraphs = [
|
|
163
|
+
g for g in subgraphs if g.number_of_edges() <= n_max_nodes_per_cluster
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
if not large_subgraphs:
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
for large_subgraph in large_subgraphs:
|
|
170
|
+
selected_subgraph, rest_of_graph = _divide_edges(
|
|
171
|
+
large_subgraph, _fielder_laplacian_predicate
|
|
172
|
+
)
|
|
173
|
+
subgraphs.extend([selected_subgraph, rest_of_graph])
|
|
174
|
+
|
|
175
|
+
return subgraphs
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _apply_split_with_relabel(
|
|
179
|
+
graph: nx.Graph, algorithm: Literal["spectral", "metis"], n_clusters: int
|
|
180
|
+
) -> tuple[nx.Graph, nx.Graph]:
|
|
181
|
+
"""
|
|
182
|
+
Relabels nodes of a graph to (0, ..., N-1) for algorithms that
|
|
183
|
+
require this input/has output of this format and requires mapping
|
|
184
|
+
back to original labels.
|
|
185
|
+
"""
|
|
186
|
+
int_graph = nx.convert_node_labels_to_integers(graph, label_attribute="orig_label")
|
|
187
|
+
|
|
188
|
+
if algorithm == "spectral":
|
|
189
|
+
adj_matrix = nx.to_scipy_sparse_array(graph, format="csr")
|
|
190
|
+
|
|
191
|
+
adj_matrix.indptr = adj_matrix.indptr.astype(np.int32)
|
|
192
|
+
adj_matrix.indices = adj_matrix.indices.astype(np.int32)
|
|
193
|
+
|
|
194
|
+
sc = SpectralClustering(
|
|
195
|
+
n_clusters=n_clusters,
|
|
196
|
+
affinity="precomputed",
|
|
197
|
+
n_init=100,
|
|
198
|
+
assign_labels="discretize",
|
|
199
|
+
)
|
|
200
|
+
parts = sc.fit_predict(adj_matrix)
|
|
201
|
+
elif algorithm == "metis":
|
|
202
|
+
adj_list = list(nx.to_dict_of_lists(int_graph).values())
|
|
203
|
+
_, parts = part_graph(n_clusters, adjacency=adj_list)
|
|
204
|
+
else:
|
|
205
|
+
raise RuntimeError("Relabeling only needed for `spectral` and `metis`.")
|
|
206
|
+
|
|
207
|
+
clusters = [[] for _ in range(n_clusters)]
|
|
208
|
+
for idx, part in enumerate(parts):
|
|
209
|
+
orig_label = int_graph.nodes[idx]["orig_label"]
|
|
210
|
+
clusters[part].append(orig_label)
|
|
211
|
+
|
|
212
|
+
return tuple(graph.subgraph(clstr) for clstr in clusters)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _split_graph(
|
|
216
|
+
graph: nx.Graph, partitioning_config: PartitioningConfig
|
|
217
|
+
) -> Sequence[nx.Graph]:
|
|
218
|
+
"""
|
|
219
|
+
Splits a graph.
|
|
220
|
+
|
|
221
|
+
If the requested partitioning algorithm is either "spectral" or "metis",
|
|
222
|
+
then the requested `min_n_clusters` will be returned.
|
|
223
|
+
For "kernighan_lin", a bisection will be returned
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
graph (nx.Graph): The input graph to be partitioned.
|
|
227
|
+
partitioning_config (PartitioningConfig): The configuration to follow.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
subgraphs: a sequence of the generated partitions.
|
|
231
|
+
"""
|
|
232
|
+
if (algorithm := partitioning_config.partitioning_algorithm) in (
|
|
233
|
+
"spectral",
|
|
234
|
+
"metis",
|
|
235
|
+
):
|
|
236
|
+
return _apply_split_with_relabel(
|
|
237
|
+
graph,
|
|
238
|
+
algorithm,
|
|
239
|
+
# If minimum clusters isn't a constraint, then default to bisection
|
|
240
|
+
partitioning_config.minimum_n_clusters or 2,
|
|
241
|
+
)
|
|
242
|
+
elif partitioning_config.partitioning_algorithm == "kernighan_lin":
|
|
243
|
+
part_1, part_2 = nx.algorithms.community.kernighan_lin_bisection(graph)
|
|
244
|
+
return graph.subgraph(part_1), graph.subgraph(part_2)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _bisect_with_predicate(
|
|
248
|
+
initial_partitions: Sequence[nx.Graph],
|
|
249
|
+
predicate: Callable[[nx.Graph | None, Sequence[nx.Graph] | None], bool],
|
|
250
|
+
partitioning_config: PartitioningConfig,
|
|
251
|
+
) -> Sequence[nx.Graph]:
|
|
252
|
+
"""
|
|
253
|
+
Recursively bisects a list of graph partitions based on a user-defined predicate.
|
|
254
|
+
|
|
255
|
+
This helper function repeatedly applies a partitioning strategy to a sequence of graph
|
|
256
|
+
subgraphs. At each iteration, it evaluates a predicate to determine whether a subgraph
|
|
257
|
+
should be further split. The process continues until no subgraphs satisfy the predicate,
|
|
258
|
+
at which point the resulting collection of subgraphs is returned.
|
|
259
|
+
|
|
260
|
+
The predicate is expected to accept two arguments:
|
|
261
|
+
- The current subgraph under consideration.
|
|
262
|
+
- A list of other subgraphs in the current iteration (both previously processed
|
|
263
|
+
and yet to be processed), serving as the context for the decision.
|
|
264
|
+
|
|
265
|
+
Returns the final list of subgraphs as a heapified sequence, ordered by descending
|
|
266
|
+
node count.
|
|
267
|
+
"""
|
|
268
|
+
subgraphs = initial_partitions
|
|
269
|
+
heapq.heapify(subgraphs)
|
|
270
|
+
|
|
271
|
+
while True:
|
|
272
|
+
new_subgraphs = []
|
|
273
|
+
changed = False
|
|
274
|
+
|
|
275
|
+
while subgraphs:
|
|
276
|
+
(_, _, subgraph) = heapq.heappop(subgraphs)
|
|
277
|
+
|
|
278
|
+
if predicate(subgraph, new_subgraphs + subgraphs):
|
|
279
|
+
new_subgraphs.extend(_split_graph(subgraph, partitioning_config))
|
|
280
|
+
changed = True
|
|
281
|
+
else:
|
|
282
|
+
new_subgraphs.append(subgraph)
|
|
283
|
+
|
|
284
|
+
subgraphs = [
|
|
285
|
+
(-sg.number_of_nodes(), i, sg) for (i, sg) in enumerate(new_subgraphs)
|
|
286
|
+
]
|
|
287
|
+
heapq.heapify(subgraphs)
|
|
288
|
+
|
|
289
|
+
if not changed:
|
|
290
|
+
break
|
|
291
|
+
|
|
292
|
+
return subgraphs
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _node_partition_graph(
|
|
296
|
+
graph: nx.Graph, partitioning_config: PartitioningConfig
|
|
297
|
+
) -> list[nx.Graph]:
|
|
298
|
+
|
|
299
|
+
subgraphs = [(-graph.number_of_nodes(), 0, graph)]
|
|
300
|
+
|
|
301
|
+
# First generate the minimum number of clusters, requested by user
|
|
302
|
+
# Initialize the graph as the initial subgraph
|
|
303
|
+
# Add generic ID to break ties in heap
|
|
304
|
+
if partitioning_config.minimum_n_clusters:
|
|
305
|
+
if partitioning_config.minimum_n_clusters > graph.number_of_nodes():
|
|
306
|
+
raise ValueError(
|
|
307
|
+
"Number of requested clusters larger than the size of the graph."
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
subgraphs = _bisect_with_predicate(
|
|
311
|
+
[(-graph.number_of_nodes(), 0, graph)],
|
|
312
|
+
lambda _, subgraphs: len(subgraphs)
|
|
313
|
+
< partitioning_config.minimum_n_clusters - 1,
|
|
314
|
+
partitioning_config,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Split oversized clusters
|
|
318
|
+
if partitioning_config.max_n_nodes_per_cluster:
|
|
319
|
+
subgraphs = _bisect_with_predicate(
|
|
320
|
+
subgraphs,
|
|
321
|
+
lambda subgraph, _: (
|
|
322
|
+
subgraph.number_of_nodes() > partitioning_config.max_n_nodes_per_cluster
|
|
323
|
+
),
|
|
324
|
+
partitioning_config,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if any(-sg[0] > _MAXIMUM_AVAILABLE_QUBITS for sg in subgraphs):
|
|
328
|
+
warn(
|
|
329
|
+
"At least one cluster has more nodes than what can be executed on "
|
|
330
|
+
f"the available backends: {_MAXIMUM_AVAILABLE_QUBITS} qubits."
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Clean up on aisle 3
|
|
334
|
+
return tuple(graph for (_, _, graph) in subgraphs)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def linear_aggregation(curr_solution, solution_bitstring, graph, reverse_index_maps):
|
|
338
|
+
for node in graph.nodes():
|
|
339
|
+
solution_index = reverse_index_maps[node]
|
|
340
|
+
curr_solution[solution_index] = int(solution_bitstring[node])
|
|
341
|
+
|
|
342
|
+
return curr_solution
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def domninance_aggregation(
|
|
346
|
+
curr_solution, solution_bitstring, graph, reverse_index_maps
|
|
347
|
+
):
|
|
348
|
+
for node in graph.nodes():
|
|
349
|
+
solution_index = reverse_index_maps[node]
|
|
350
|
+
|
|
351
|
+
# Use existing assignment if dominant in previous solutions
|
|
352
|
+
# (e.g., more 0s than 1s or vice versa)
|
|
353
|
+
count_0 = curr_solution.count(0)
|
|
354
|
+
count_1 = curr_solution.count(1)
|
|
355
|
+
|
|
356
|
+
if (
|
|
357
|
+
(count_0 > count_1 and curr_solution[node] == 0)
|
|
358
|
+
or (count_1 > count_0 and curr_solution[node] == 1)
|
|
359
|
+
or (count_0 == count_1)
|
|
360
|
+
):
|
|
361
|
+
# Assign based on QAOA if tie
|
|
362
|
+
curr_solution[solution_index] = int(solution_bitstring[node])
|
|
363
|
+
|
|
364
|
+
return curr_solution
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class GraphPartitioningQAOA(ProgramBatch):
|
|
368
|
+
def __init__(
|
|
369
|
+
self,
|
|
370
|
+
graph: nx.Graph | rx.PyGraph,
|
|
371
|
+
graph_problem: GraphProblem,
|
|
372
|
+
n_layers: int,
|
|
373
|
+
backend: CircuitRunner,
|
|
374
|
+
partitioning_config: PartitioningConfig,
|
|
375
|
+
initial_state: _SUPPORTED_INITIAL_STATES_LITERAL = "Recommended",
|
|
376
|
+
aggregate_fn: AggregateFn = linear_aggregation,
|
|
377
|
+
optimizer=Optimizer.MONTE_CARLO,
|
|
378
|
+
max_iterations=10,
|
|
379
|
+
**kwargs,
|
|
380
|
+
):
|
|
381
|
+
"""
|
|
382
|
+
Initializes the graph partitioning class.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
graph (nx.Graph | rx.PyGraph): The input graph to be partitioned.
|
|
386
|
+
graph_problem (GraphProblem): The type of graph partitioning problem (e.g., EDGE_PARTITIONING).
|
|
387
|
+
n_layers (int): Number of layers for the QAOA circuit.
|
|
388
|
+
backend (CircuitRunner): Backend used to run quantum/classical circuits.
|
|
389
|
+
partitioning_config (PartitioningConfig): the configuration of the partitioning as to the algorithm and
|
|
390
|
+
expected output.
|
|
391
|
+
initial_state ("Zeros", "Ones", "Superposition", "Recommended", optional): Initial state for the QAOA algorithm. Defaults to "Recommended".
|
|
392
|
+
aggregate_fn (optional): Aggregation function to combine results. Defaults to `linear_aggregation`.
|
|
393
|
+
optimizer (optional): Optimizer to use for QAOA. Defaults to `Optimizers.MONTE_CARLO`.
|
|
394
|
+
max_iterations (int, optional): Maximum number of optimization iterations. Defaults to 10.
|
|
395
|
+
**kwargs: Additional keyword arguments passed to the QAOA constructor.
|
|
396
|
+
|
|
397
|
+
"""
|
|
398
|
+
super().__init__(backend=backend)
|
|
399
|
+
|
|
400
|
+
self.main_graph = graph
|
|
401
|
+
self.is_edge_problem = graph_problem == GraphProblem.EDGE_PARTITIONING
|
|
402
|
+
|
|
403
|
+
check_fn = (
|
|
404
|
+
nx.is_connected if not self.is_edge_problem else nx.is_weakly_connected
|
|
405
|
+
)
|
|
406
|
+
if not check_fn(self.main_graph):
|
|
407
|
+
raise ValueError("Provided graph is not fully connected.")
|
|
408
|
+
|
|
409
|
+
self.partitioning_config = partitioning_config
|
|
410
|
+
self.max_iterations = max_iterations
|
|
411
|
+
|
|
412
|
+
self.aggregate_fn = aggregate_fn
|
|
413
|
+
|
|
414
|
+
self._solution_nodes = None
|
|
415
|
+
|
|
416
|
+
self._constructor = partial(
|
|
417
|
+
QAOA,
|
|
418
|
+
initial_state=initial_state,
|
|
419
|
+
graph_problem=graph_problem,
|
|
420
|
+
optimizer=optimizer,
|
|
421
|
+
max_iterations=self.max_iterations,
|
|
422
|
+
backend=self.backend,
|
|
423
|
+
n_layers=n_layers,
|
|
424
|
+
**kwargs,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def create_programs(self):
|
|
428
|
+
if len(self.programs) > 0:
|
|
429
|
+
raise RuntimeError(
|
|
430
|
+
"Some programs already exist. "
|
|
431
|
+
"Clear the program dictionary before creating new ones by using batch.reset()."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
super().create_programs()
|
|
435
|
+
|
|
436
|
+
if self.is_edge_problem:
|
|
437
|
+
subgraphs = _edge_partition_graph(
|
|
438
|
+
self.main_graph,
|
|
439
|
+
n_max_nodes_per_cluster=self.partitioning_config.max_n_nodes_per_cluster,
|
|
440
|
+
)
|
|
441
|
+
cleaned_subgraphs = list(filter(lambda x: x.size() > 0, subgraphs))
|
|
442
|
+
else:
|
|
443
|
+
subgraphs = _node_partition_graph(
|
|
444
|
+
self.main_graph,
|
|
445
|
+
partitioning_config=self.partitioning_config,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
self._bitstring_solution = [0] * self.main_graph.number_of_nodes()
|
|
449
|
+
self.reverse_index_maps = {}
|
|
450
|
+
|
|
451
|
+
for i, subgraph in enumerate(subgraphs):
|
|
452
|
+
index_map = {node: idx for idx, node in enumerate(subgraph.nodes())}
|
|
453
|
+
self.reverse_index_maps[i] = {v: k for k, v in index_map.items()}
|
|
454
|
+
_subgraph = nx.relabel_nodes(subgraph, index_map)
|
|
455
|
+
|
|
456
|
+
prog_id = (string.ascii_uppercase[i], subgraph.number_of_nodes())
|
|
457
|
+
|
|
458
|
+
self.programs[prog_id] = self._constructor(
|
|
459
|
+
job_id=prog_id,
|
|
460
|
+
problem=_subgraph,
|
|
461
|
+
losses=self._manager.list(),
|
|
462
|
+
probs=self._manager.dict(),
|
|
463
|
+
final_params=self._manager.list(),
|
|
464
|
+
progress_queue=self._queue,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def compute_final_solutions(self):
|
|
468
|
+
if self._executor is not None:
|
|
469
|
+
self.wait_for_all()
|
|
470
|
+
|
|
471
|
+
if self._executor is not None:
|
|
472
|
+
raise RuntimeError("A batch is already being run.")
|
|
473
|
+
|
|
474
|
+
if len(self.programs) == 0:
|
|
475
|
+
raise RuntimeError("No programs to run.")
|
|
476
|
+
|
|
477
|
+
self._executor = ProcessPoolExecutor()
|
|
478
|
+
|
|
479
|
+
self.futures = [
|
|
480
|
+
self._executor.submit(program.compute_final_solution)
|
|
481
|
+
for program in self.programs.values()
|
|
482
|
+
]
|
|
483
|
+
|
|
484
|
+
def aggregate_results(self):
|
|
485
|
+
if len(self.programs) == 0:
|
|
486
|
+
raise RuntimeError("No programs to aggregate. Run create_programs() first.")
|
|
487
|
+
|
|
488
|
+
if self._executor is not None:
|
|
489
|
+
self.wait_for_all()
|
|
490
|
+
|
|
491
|
+
if any(len(program.losses) == 0 for program in self.programs.values()):
|
|
492
|
+
raise RuntimeError(
|
|
493
|
+
"Some/All programs have empty losses. Did you call run()?"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
if any(len(program.probs) == 0 for program in self.programs.values()):
|
|
497
|
+
raise RuntimeError(
|
|
498
|
+
"Not all final probabilities computed yet. Please call `compute_final_solutions()` first."
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# Extract the solutions from each program
|
|
502
|
+
for program, reverse_index_maps in zip(
|
|
503
|
+
self.programs.values(), self.reverse_index_maps.values()
|
|
504
|
+
):
|
|
505
|
+
# Extract the final probabilities of the lowest energy
|
|
506
|
+
last_iteration_losses = program.losses[-1]
|
|
507
|
+
minimum_key = min(last_iteration_losses, key=last_iteration_losses.get)
|
|
508
|
+
|
|
509
|
+
# Find the key matching the best_solution_idx with possible metadata in between
|
|
510
|
+
pattern = re.compile(rf"^{minimum_key}(?:_[^_]*)*_0$")
|
|
511
|
+
matching_keys = [k for k in program.probs.keys() if pattern.match(k)]
|
|
512
|
+
|
|
513
|
+
if len(matching_keys) > 1:
|
|
514
|
+
raise RuntimeError(f"More than one matching key found.")
|
|
515
|
+
|
|
516
|
+
best_solution_key = matching_keys[0]
|
|
517
|
+
|
|
518
|
+
minimum_probabilities = program.probs[best_solution_key]
|
|
519
|
+
|
|
520
|
+
# The bitstring corresponding to the solution, with flip for correct endianness
|
|
521
|
+
max_prob_key = max(minimum_probabilities, key=minimum_probabilities.get)[
|
|
522
|
+
::-1
|
|
523
|
+
]
|
|
524
|
+
|
|
525
|
+
self._bitstring_solution = self.aggregate_fn(
|
|
526
|
+
self._bitstring_solution,
|
|
527
|
+
max_prob_key,
|
|
528
|
+
program.problem,
|
|
529
|
+
reverse_index_maps,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
self.solution = list(np.where(self._bitstring_solution)[0])
|
|
533
|
+
|
|
534
|
+
return self.solution
|
|
535
|
+
|
|
536
|
+
def draw_partitions(
|
|
537
|
+
self,
|
|
538
|
+
pos: dict | None = None,
|
|
539
|
+
figsize: tuple[int, int] | None = (10, 8),
|
|
540
|
+
node_size: int | None = 300,
|
|
541
|
+
):
|
|
542
|
+
"""
|
|
543
|
+
Draw a NetworkX graph with nodes colored by partition.
|
|
544
|
+
|
|
545
|
+
Parameters:
|
|
546
|
+
-----------
|
|
547
|
+
pos : dict, optional
|
|
548
|
+
Node positions. If None, uses spring layout
|
|
549
|
+
figsize : tuple, optional
|
|
550
|
+
Figure size (width, height)
|
|
551
|
+
node_size : int, optional
|
|
552
|
+
Size of nodes
|
|
553
|
+
"""
|
|
554
|
+
|
|
555
|
+
if len(self.programs) == 0:
|
|
556
|
+
raise RuntimeError(
|
|
557
|
+
"There are no partitions to draw. Did you run create_programs()?"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
# Convert partitions to node-to-partition mapping
|
|
561
|
+
node_to_partition = {}
|
|
562
|
+
for partition_id, mapping in self.reverse_index_maps.items():
|
|
563
|
+
for node in mapping.values():
|
|
564
|
+
node_to_partition[node] = string.ascii_uppercase[partition_id]
|
|
565
|
+
|
|
566
|
+
# Get unique partition IDs and create color map
|
|
567
|
+
unique_partitions = sorted(list(set(node_to_partition.values())))
|
|
568
|
+
n_partitions = len(unique_partitions)
|
|
569
|
+
colors = cm.Set3(np.linspace(0, 1, n_partitions))
|
|
570
|
+
partition_colors = {pid: colors[i] for i, pid in enumerate(unique_partitions)}
|
|
571
|
+
|
|
572
|
+
# Create node color list
|
|
573
|
+
node_colors = [
|
|
574
|
+
partition_colors[node_to_partition.get(node, 0)]
|
|
575
|
+
for node in self.main_graph.nodes()
|
|
576
|
+
]
|
|
577
|
+
|
|
578
|
+
# Set positions
|
|
579
|
+
if pos is None:
|
|
580
|
+
pos = nx.spring_layout(self.main_graph, seed=42)
|
|
581
|
+
|
|
582
|
+
# Draw the graph
|
|
583
|
+
plt.figure(figsize=figsize)
|
|
584
|
+
nx.draw(
|
|
585
|
+
self.main_graph,
|
|
586
|
+
pos,
|
|
587
|
+
node_color=node_colors,
|
|
588
|
+
node_size=node_size,
|
|
589
|
+
with_labels=True,
|
|
590
|
+
font_size=8,
|
|
591
|
+
font_weight="bold",
|
|
592
|
+
edge_color="gray",
|
|
593
|
+
alpha=0.8,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# Add legend
|
|
597
|
+
legend_elements = [
|
|
598
|
+
plt.Line2D(
|
|
599
|
+
[0],
|
|
600
|
+
[0],
|
|
601
|
+
marker="o",
|
|
602
|
+
color="w",
|
|
603
|
+
markerfacecolor=partition_colors[pid],
|
|
604
|
+
markersize=10,
|
|
605
|
+
label=f"Partition {pid}",
|
|
606
|
+
)
|
|
607
|
+
for pid in unique_partitions
|
|
608
|
+
]
|
|
609
|
+
plt.legend(handles=legend_elements, loc="best")
|
|
610
|
+
|
|
611
|
+
plt.title("Graph Partitions Visualization")
|
|
612
|
+
plt.axis("off")
|
|
613
|
+
plt.show()
|
|
614
|
+
|
|
615
|
+
def draw_solution(self):
|
|
616
|
+
if self._solution_nodes is None:
|
|
617
|
+
self.aggregate_results()
|
|
618
|
+
|
|
619
|
+
draw_graph_solution_nodes(self.main_graph, self.solution)
|