qoro-divi 0.3.4__py3-none-any.whl → 0.4.0__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.

Potentially problematic release.


This version of qoro-divi might be problematic. Click here for more details.

@@ -21,9 +21,9 @@ from qiskit_optimization import QuadraticProgram
21
21
  from qiskit_optimization.converters import QuadraticProgramToQubo
22
22
  from qiskit_optimization.problems import VarType
23
23
 
24
- from divi.circuits import MetaCircuit
25
- from divi.qprog import QuantumProgram
24
+ from divi.circuits import Circuit, MetaCircuit
26
25
  from divi.qprog.optimizers import MonteCarloOptimizer, Optimizer
26
+ from divi.qprog.variational_quantum_algorithm import VariationalQuantumAlgorithm
27
27
  from divi.utils import convert_qubo_matrix_to_pennylane_ising
28
28
 
29
29
  logger = logging.getLogger(__name__)
@@ -32,7 +32,16 @@ GraphProblemTypes = nx.Graph | rx.PyGraph
32
32
  QUBOProblemTypes = list | np.ndarray | sps.spmatrix | QuadraticProgram
33
33
 
34
34
 
35
- def draw_graph_solution_nodes(main_graph, partition_nodes):
35
+ def draw_graph_solution_nodes(main_graph: nx.Graph, partition_nodes):
36
+ """Visualize a graph with solution nodes highlighted.
37
+
38
+ Draws the graph with nodes colored to distinguish solution nodes (red) from
39
+ other nodes (light blue).
40
+
41
+ Args:
42
+ main_graph (nx.Graph): NetworkX graph to visualize.
43
+ partition_nodes: Collection of node indices that are part of the solution.
44
+ """
36
45
  # Create a dictionary for node colors
37
46
  node_colors = [
38
47
  "red" if node in partition_nodes else "lightblue" for node in main_graph.nodes()
@@ -55,6 +64,14 @@ def draw_graph_solution_nodes(main_graph, partition_nodes):
55
64
 
56
65
 
57
66
  class GraphProblem(Enum):
67
+ """Enumeration of supported graph problems for QAOA.
68
+
69
+ Each problem type defines:
70
+ - pl_string: The corresponding PennyLane function name
71
+ - constrained_initial_state: Recommended initial state for constrained problems
72
+ - unconstrained_initial_state: Recommended initial state for unconstrained problems
73
+ """
74
+
58
75
  MAX_CLIQUE = ("max_clique", "Zeros", "Superposition")
59
76
  MAX_INDEPENDENT_SET = ("max_independent_set", "Zeros", "Superposition")
60
77
  MAX_WEIGHT_CYCLE = ("max_weight_cycle", "Superposition", "Superposition")
@@ -70,6 +87,13 @@ class GraphProblem(Enum):
70
87
  constrained_initial_state: str,
71
88
  unconstrained_initial_state: str,
72
89
  ):
90
+ """Initialize the GraphProblem enum value.
91
+
92
+ Args:
93
+ pl_string (str): The corresponding PennyLane function name.
94
+ constrained_initial_state (str): Recommended initial state for constrained problems.
95
+ unconstrained_initial_state (str): Recommended initial state for unconstrained problems.
96
+ """
73
97
  self.pl_string = pl_string
74
98
 
75
99
  # Recommended initial state as per Pennylane's documentation.
@@ -84,6 +108,17 @@ _SUPPORTED_INITIAL_STATES_LITERAL = Literal[
84
108
 
85
109
 
86
110
  def _convert_quadratic_program_to_pennylane_ising(qp: QuadraticProgram):
111
+ """Convert a Qiskit QuadraticProgram to a PennyLane Ising Hamiltonian.
112
+
113
+ Args:
114
+ qp (QuadraticProgram): Qiskit QuadraticProgram to convert.
115
+
116
+ Returns:
117
+ tuple[qml.Hamiltonian, float, int]: (pennylane_ising, constant, n_qubits) where:
118
+ - pennylane_ising: The Ising Hamiltonian in PennyLane format
119
+ - constant: The constant term
120
+ - n_qubits: Number of qubits required
121
+ """
87
122
  qiskit_sparse_op, constant = qp.to_ising()
88
123
 
89
124
  pauli_list = qiskit_sparse_op.paulis
@@ -108,9 +143,16 @@ def _convert_quadratic_program_to_pennylane_ising(qp: QuadraticProgram):
108
143
  def _resolve_circuit_layers(
109
144
  initial_state, problem, graph_problem, **kwargs
110
145
  ) -> tuple[qml.operation.Operator, qml.operation.Operator, dict | None, str]:
111
- """
112
- Generates the cost and mixer hamiltonians for a given problem, in addition to
113
- optional metadata returned by Pennylane if applicable
146
+ """Generate the cost and mixer Hamiltonians for a given problem.
147
+
148
+ Args:
149
+ initial_state (str): The initial state specification.
150
+ problem (GraphProblemTypes | QUBOProblemTypes): The problem to solve (graph or QUBO).
151
+ graph_problem (GraphProblem | None): The graph problem type (if applicable).
152
+ **kwargs: Additional keyword arguments.
153
+
154
+ Returns:
155
+ tuple[qml.operation.Operator, qml.operation.Operator, dict | None, str]: (cost_hamiltonian, mixer_hamiltonian, metadata, resolved_initial_state)
114
156
  """
115
157
 
116
158
  if isinstance(problem, GraphProblemTypes):
@@ -149,7 +191,36 @@ def _resolve_circuit_layers(
149
191
  )
150
192
 
151
193
 
152
- class QAOA(QuantumProgram):
194
+ class QAOA(VariationalQuantumAlgorithm):
195
+ """Quantum Approximate Optimization Algorithm (QAOA) implementation.
196
+
197
+ QAOA is a hybrid quantum-classical algorithm designed to solve combinatorial
198
+ optimization problems. It alternates between applying a cost Hamiltonian
199
+ (encoding the problem) and a mixer Hamiltonian (enabling exploration).
200
+
201
+ The algorithm can solve:
202
+ - Graph problems (MaxCut, Max Clique, etc.)
203
+ - QUBO (Quadratic Unconstrained Binary Optimization) problems
204
+ - Quadratic programs (converted to QUBO)
205
+
206
+ Attributes:
207
+ problem (GraphProblemTypes | QUBOProblemTypes): The problem instance to solve.
208
+ graph_problem (GraphProblem | None): The graph problem type (if applicable).
209
+ n_layers (int): Number of QAOA layers.
210
+ n_qubits (int): Number of qubits required.
211
+ cost_hamiltonian (qml.Hamiltonian): The cost Hamiltonian encoding the problem.
212
+ mixer_hamiltonian (qml.Hamiltonian): The mixer Hamiltonian for exploration.
213
+ initial_state (str): The initial quantum state.
214
+ problem_metadata (dict | None): Additional metadata from problem setup.
215
+ loss_constant (float): Constant term from the problem.
216
+ optimizer (Optimizer): Classical optimizer for parameter updates.
217
+ max_iterations (int): Maximum number of optimization iterations.
218
+ current_iteration (int): Current optimization iteration.
219
+ _n_params (int): Number of parameters per layer (always 2 for QAOA).
220
+ _solution_nodes (list[int] | None): Solution nodes for graph problems.
221
+ _solution_bitstring (np.ndarray | None): Solution bitstring for QUBO problems.
222
+ """
223
+
153
224
  def __init__(
154
225
  self,
155
226
  problem: GraphProblemTypes | QUBOProblemTypes,
@@ -161,16 +232,18 @@ class QAOA(QuantumProgram):
161
232
  max_iterations: int = 10,
162
233
  **kwargs,
163
234
  ):
164
- """
165
- Initialize the QAOA problem.
235
+ """Initialize the QAOA problem.
166
236
 
167
237
  Args:
168
- problem: The problem to solve, can either be a graph or a QUBO.
238
+ problem (GraphProblemTypes | QUBOProblemTypes): The problem to solve, can either be a graph or a QUBO.
169
239
  For graph inputs, the graph problem to solve must be provided
170
240
  through the `graph_problem` variable.
171
- graph_problem (str): The graph problem to solve.
172
- n_layers (int): number of QAOA layers
173
- initial_state (str): The initial state of the circuit
241
+ graph_problem (GraphProblem | None): The graph problem to solve. Defaults to None.
242
+ n_layers (int): Number of QAOA layers. Defaults to 1.
243
+ initial_state (_SUPPORTED_INITIAL_STATES_LITERAL): The initial state of the circuit. Defaults to "Recommended".
244
+ optimizer (Optimizer | None): The optimizer to use. Defaults to MonteCarloOptimizer.
245
+ max_iterations (int): Maximum number of optimization iterations. Defaults to 10.
246
+ **kwargs: Additional keyword arguments passed to the parent class.
174
247
  """
175
248
 
176
249
  if isinstance(problem, QUBOProblemTypes):
@@ -225,17 +298,14 @@ class QAOA(QuantumProgram):
225
298
  self.max_iterations = max_iterations
226
299
  self.current_iteration = 0
227
300
  self._n_params = 2
228
- self._is_compute_probabilites = False
229
301
  self.optimizer = optimizer if optimizer is not None else MonteCarloOptimizer()
230
302
 
231
- # Shared Variables
232
- self.probs = kwargs.pop("probs", {})
233
- self._solution_nodes = kwargs.pop("solution_nodes", [])
234
- self._solution_bitstring = kwargs.pop("solution_bitstring", [])
303
+ self._solution_nodes = []
304
+ self._solution_bitstring = []
235
305
 
236
306
  (
237
- self.cost_hamiltonian,
238
- self.mixer_hamiltonian,
307
+ self._cost_hamiltonian,
308
+ self._mixer_hamiltonian,
239
309
  *problem_metadata,
240
310
  self.initial_state,
241
311
  ) = _resolve_circuit_layers(
@@ -256,12 +326,28 @@ class QAOA(QuantumProgram):
256
326
  self.loss_constant = 0.0
257
327
 
258
328
  kwargs.pop("is_constrained", None)
259
- super().__init__(has_final_computation=True, **kwargs)
329
+ super().__init__(**kwargs)
260
330
 
261
331
  self._meta_circuits = self._create_meta_circuits_dict()
262
332
 
333
+ @property
334
+ def cost_hamiltonian(self) -> qml.operation.Operator:
335
+ """The cost Hamiltonian for the QAOA problem."""
336
+ return self._cost_hamiltonian
337
+
338
+ @property
339
+ def mixer_hamiltonian(self) -> qml.operation.Operator:
340
+ """The mixer Hamiltonian for the QAOA problem."""
341
+ return self._mixer_hamiltonian
342
+
263
343
  @property
264
344
  def solution(self):
345
+ """Get the solution found by QAOA optimization.
346
+
347
+ Returns:
348
+ list[int] | np.ndarray: For graph problems, returns a list of selected node indices.
349
+ For QUBO problems, returns a list/array of binary values.
350
+ """
265
351
  return (
266
352
  self._solution_nodes
267
353
  if self.graph_problem is not None
@@ -269,11 +355,14 @@ class QAOA(QuantumProgram):
269
355
  )
270
356
 
271
357
  def _create_meta_circuits_dict(self) -> dict[str, MetaCircuit]:
272
- """
273
- Generate the meta circuits for the QAOA problem.
358
+ """Generate the meta circuits for the QAOA problem.
359
+
360
+ Creates both cost and measurement circuits for the QAOA algorithm.
361
+ The cost circuit is used during optimization, while the measurement
362
+ circuit is used for final solution extraction.
274
363
 
275
- In this method, we generate the scaffolding for the circuits that will be
276
- executed during optimization.
364
+ Returns:
365
+ dict[str, MetaCircuit]: Dictionary containing cost_circuit and meas_circuit.
277
366
  """
278
367
 
279
368
  betas = sp.symarray("β", self.n_layers)
@@ -283,14 +372,16 @@ class QAOA(QuantumProgram):
283
372
 
284
373
  def _qaoa_layer(params):
285
374
  gamma, beta = params
286
- pqaoa.cost_layer(gamma, self.cost_hamiltonian)
287
- pqaoa.mixer_layer(beta, self.mixer_hamiltonian)
375
+ pqaoa.cost_layer(gamma, self._cost_hamiltonian)
376
+ pqaoa.mixer_layer(beta, self._mixer_hamiltonian)
288
377
 
289
378
  def _prepare_circuit(hamiltonian, params, final_measurement):
290
- """
291
- Prepare the circuit for the QAOA problem.
379
+ """Prepare the circuit for the QAOA problem.
380
+
292
381
  Args:
293
- hamiltonian (qml.Hamiltonian): The Hamiltonian term to measure
382
+ hamiltonian (qml.Hamiltonian): The Hamiltonian term to measure.
383
+ params (np.ndarray): The QAOA parameters (betas and gammas).
384
+ final_measurement (bool): Whether to perform final measurement.
294
385
  """
295
386
 
296
387
  # Note: could've been done as qml.[Insert Gate](wires=range(self.n_qubits))
@@ -313,121 +404,86 @@ class QAOA(QuantumProgram):
313
404
  return {
314
405
  "cost_circuit": self._meta_circuit_factory(
315
406
  qml.tape.make_qscript(_prepare_circuit)(
316
- self.cost_hamiltonian, sym_params, final_measurement=False
407
+ self._cost_hamiltonian, sym_params, final_measurement=False
317
408
  ),
318
409
  symbols=sym_params.flatten(),
319
410
  ),
320
411
  "meas_circuit": self._meta_circuit_factory(
321
412
  qml.tape.make_qscript(_prepare_circuit)(
322
- self.cost_hamiltonian, sym_params, final_measurement=True
413
+ self._cost_hamiltonian, sym_params, final_measurement=True
323
414
  ),
324
415
  symbols=sym_params.flatten(),
416
+ grouping_strategy="wires",
325
417
  ),
326
418
  }
327
419
 
328
- def _generate_circuits(self):
329
- """
330
- Generate the circuits for the QAOA problem.
420
+ def _generate_circuits(self) -> list[Circuit]:
421
+ """Generate the circuits for the QAOA problem.
331
422
 
332
- In this method, we generate bulk circuits based on the selected parameters.
333
- """
423
+ Generates circuits for each parameter set in the current parameters.
424
+ The circuit type depends on whether we're computing probabilities
425
+ (for final solution extraction) or just expectation values (for optimization).
334
426
 
427
+ Returns:
428
+ list[Circuit]: List of Circuit objects for execution.
429
+ """
335
430
  circuit_type = (
336
431
  "cost_circuit" if not self._is_compute_probabilites else "meas_circuit"
337
432
  )
338
433
 
339
- for p, params_group in enumerate(self._curr_params):
340
- circuit = self._meta_circuits[circuit_type].initialize_circuit_from_params(
434
+ return [
435
+ self._meta_circuits[circuit_type].initialize_circuit_from_params(
341
436
  params_group, tag_prefix=f"{p}"
342
437
  )
438
+ for p, params_group in enumerate(self._curr_params)
439
+ ]
343
440
 
344
- self.circuits.append(circuit)
441
+ def _post_process_results(self, results, **kwargs):
442
+ """Post-process the results of the QAOA problem.
345
443
 
346
- def _post_process_results(self, results):
347
- """
348
- Post-process the results of the QAOA problem.
444
+ Args:
445
+ results (dict[str, dict[str, int]]): Raw results from circuit execution.
446
+ **kwargs: Additional keyword arguments.
447
+ ham_ops (str): The Hamiltonian operators to measure, semicolon-separated.
448
+ Only needed when the backend supports expval.
349
449
 
350
450
  Returns:
351
- (dict) The losses for each parameter set grouping.
451
+ dict[str, dict[str, float]] | dict[int, float]: The losses for each parameter set grouping, or probability
452
+ distributions if computing probabilities.
352
453
  """
353
454
 
354
455
  if self._is_compute_probabilites:
355
- return {
356
- outer_k: {
357
- inner_k: inner_v / self.backend.shots
358
- for inner_k, inner_v in outer_v.items()
359
- }
360
- for outer_k, outer_v in results.items()
361
- }
362
-
363
- losses = super()._post_process_results(results)
456
+ return self._process_probability_results(results)
364
457
 
458
+ losses = super()._post_process_results(results, **kwargs)
365
459
  return losses
366
460
 
367
- def _run_final_measurement(self):
368
- self._is_compute_probabilites = True
369
-
370
- self._curr_params = np.array(self.final_params)
371
-
372
- self.circuits[:] = []
373
-
374
- self._generate_circuits()
375
-
376
- self.probs.update(self._dispatch_circuits_and_process_results())
461
+ def _perform_final_computation(self, **kwargs):
462
+ """Extract the optimal solution from the QAOA optimization process.
377
463
 
378
- self._is_compute_probabilites = False
379
-
380
- def compute_final_solution(self):
381
- """
382
- Computes and extracts the final solution from the QAOA optimization process.
383
464
  This method performs the following steps:
384
- 1. Identifies the best solution index based on the lowest loss value from the last optimization step.
385
- 2. Executes the final measurement circuit to obtain the probability distributions of solutions.
386
- 3. Retrieves the bitstring representing the best solution, correcting for endianness.
387
- 4. Depending on the problem type:
388
- - For QUBO problems, stores the solution as a NumPy array of bits.
389
- - For graph problems, stores the solution as a list of node indices corresponding to '1's in the bitstring.
390
- 5. Returns the total circuit count and total runtime for the optimization process.
465
+ 1. Executes measurement circuits with the best parameters (those that achieved the lowest loss).
466
+ 2. Retrieves the bitstring representing the best solution, correcting for endianness.
467
+ 3. Depending on the problem type:
468
+ - For QUBO problems, stores the solution as a NumPy array of bits.
469
+ - For graph problems, stores the solution as a list of node indices corresponding to '1's in the bitstring.
391
470
 
392
471
  Returns:
393
- tuple: A tuple containing:
394
- - int: The total number of circuits executed.
395
- - float: The total runtime of the optimization process.
472
+ tuple[int, float]: A tuple containing:
473
+ - int: The total number of circuits executed.
474
+ - float: The total runtime of the optimization process.
396
475
  """
397
476
 
398
- self.reporter.info(message="🏁 Computing Final Solution 🏁")
399
-
400
- # Convert losses dict to list to apply ordinal operations
401
- final_losses_list = list(self.losses[-1].values())
402
-
403
- # Get the index of the smallest loss in the last operation
404
- best_solution_idx = min(
405
- range(len(final_losses_list)),
406
- key=lambda x: final_losses_list.__getitem__(x),
407
- )
408
-
409
- # Insert the measurement circuit here
410
- self._run_final_measurement()
411
-
412
- # Find the key matching the best_solution_idx with possible metadata in between
413
- pattern = re.compile(rf"^{best_solution_idx}(?:_[^_]*)*_0$")
414
- matching_keys = [k for k in self.probs.keys() if pattern.match(k)]
477
+ self.reporter.info(message="🏁 Computing Final Solution 🏁\r")
415
478
 
416
- # Some minor sanity checks
417
- if len(matching_keys) == 0:
418
- raise RuntimeError("No matching key found.")
419
- if len(matching_keys) > 1:
420
- raise RuntimeError(f"More than one matching key found.")
479
+ self._run_solution_measurement()
421
480
 
422
- best_solution_key = matching_keys[0]
423
- # Retrieve the probability distribution dictionary of the best solution
424
- best_solution_probs = self.probs[best_solution_key]
481
+ best_measurement_probs = next(iter(self._best_probs.values()))
425
482
 
426
- # Retrieve the bitstring with the actual best solution
427
- # Reverse to account for the endianness difference
428
- best_solution_bitstring = max(best_solution_probs, key=best_solution_probs.get)[
429
- ::-1
430
- ]
483
+ # Endianness is corrected in _post_process_results
484
+ best_solution_bitstring = max(
485
+ best_measurement_probs, key=best_measurement_probs.get
486
+ )
431
487
 
432
488
  if isinstance(self.problem, QUBOProblemTypes):
433
489
  self._solution_bitstring[:] = np.fromiter(
@@ -439,17 +495,30 @@ class QAOA(QuantumProgram):
439
495
  m.start() for m in re.finditer("1", best_solution_bitstring)
440
496
  ]
441
497
 
442
- self.reporter.info(message="Computed Final Solution!")
498
+ self.reporter.info(message="🏁 Computed Final Solution! 🏁\r\n")
443
499
 
444
500
  return self._total_circuit_count, self._total_run_time
445
501
 
446
502
  def draw_solution(self):
503
+ """Visualize the solution found by QAOA for graph problems.
504
+
505
+ Draws the graph with solution nodes highlighted in red and other nodes
506
+ in light blue. If the solution hasn't been computed yet, it will be
507
+ calculated first.
508
+
509
+ Raises:
510
+ RuntimeError: If called on a QUBO problem instead of a graph problem.
511
+
512
+ Note:
513
+ This method only works for graph problems. For QUBO problems, access
514
+ the solution directly via the `solution` property.
515
+ """
447
516
  if self.graph_problem is None:
448
517
  raise RuntimeError(
449
518
  "The problem is not a graph problem. Cannot draw solution."
450
519
  )
451
520
 
452
521
  if not self._solution_nodes:
453
- self.compute_final_solution()
522
+ self._perform_final_computation()
454
523
 
455
524
  draw_graph_solution_nodes(self.problem, self._solution_nodes)