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.

@@ -0,0 +1,786 @@
1
+ # SPDX-FileCopyrightText: 2025 Qoro Quantum Ltd <divi@qoroquantum.de>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import logging
6
+ from abc import abstractmethod
7
+ from functools import lru_cache, partial
8
+ from itertools import chain, groupby
9
+ from queue import Queue
10
+
11
+ import numpy as np
12
+ import pennylane as qml
13
+ from scipy.optimize import OptimizeResult
14
+
15
+ from divi.backends import CircuitRunner
16
+ from divi.circuits import Circuit, MetaCircuit
17
+ from divi.circuits.qem import _NoMitigation
18
+ from divi.qprog.exceptions import _CancelledError
19
+ from divi.qprog.optimizers import ScipyMethod, ScipyOptimizer
20
+ from divi.qprog.quantum_program import QuantumProgram
21
+ from divi.reporting import LoggingProgressReporter, QueueProgressReporter
22
+ from divi.utils import hamiltonian_to_pauli_string, reverse_dict_endianness
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _get_structural_key(obs: qml.operation.Operation) -> tuple[str, ...]:
28
+ """Generates a hashable, wire-independent key from an observable's structure.
29
+
30
+ This function is used to create a canonical representation of an observable
31
+ based on its constituent Pauli operators, ignoring the wires they act on.
32
+ This key is ideal for caching computed eigenvalues, as observables with the
33
+ same structure (e.g., PauliX(0) @ PauliZ(1) and PauliX(2) @ PauliZ(3))
34
+ share the same eigenvalues. It maps PauliX and PauliY to PauliZ because
35
+ they are all isospectral (have eigenvalues [1, -1]).
36
+
37
+ Args:
38
+ obs (qml.operation.Operation): A PennyLane observable (e.g., qml.PauliZ(0), qml.PauliX(0) @ qml.PauliY(1)).
39
+
40
+ Returns:
41
+ tuple[str, ...]: A tuple of strings representing the structure of the observable,
42
+ e.g., ('PauliZ',) or ('PauliZ', 'PauliZ').
43
+ """
44
+
45
+ # Pennylane returns the same eigenvalues for PauliX and PauliY
46
+ # since it handles diagonalizing gates internally anyway
47
+ name_map = {
48
+ "PauliY": "PauliZ",
49
+ "PauliX": "PauliZ",
50
+ "PauliZ": "PauliZ",
51
+ "Identity": "Identity",
52
+ }
53
+
54
+ if isinstance(obs, qml.ops.Prod):
55
+ # Recursively build a tuple of operator names
56
+ return tuple(name_map[o.name] for o in obs.operands)
57
+
58
+ # For single operators, return a single-element tuple
59
+ return (name_map[obs.name],)
60
+
61
+
62
+ @lru_cache(maxsize=512)
63
+ def _get_eigvals_from_key(key: tuple[str, ...]) -> np.ndarray:
64
+ """Computes and caches eigenvalues based on a structural key.
65
+
66
+ This function takes a key generated by `_get_structural_key` and computes
67
+ the eigenvalues of the corresponding tensor product of operators. The results
68
+ are memoized using @lru_cache to avoid redundant calculations.
69
+
70
+ Args:
71
+ key (tuple[str, ...]): A tuple of strings representing the observable's structure.
72
+
73
+ Returns:
74
+ np.ndarray: A NumPy array containing the eigenvalues of the observable.
75
+ """
76
+
77
+ # Define a mapping from name to the base eigenvalue array
78
+ eigvals_map = {
79
+ "PauliZ": np.array([1, -1], dtype=np.int8),
80
+ "Identity": np.array([1, 1], dtype=np.int8),
81
+ }
82
+
83
+ # Start with the eigenvalues of the first operator in the key
84
+ final_eigvals = eigvals_map[key[0]]
85
+
86
+ # Iteratively compute the kronecker product for the rest
87
+ for op_name in key[1:]:
88
+ final_eigvals = np.kron(final_eigvals, eigvals_map[op_name])
89
+
90
+ return final_eigvals
91
+
92
+
93
+ def _batched_expectation(shots_dicts, observables, wire_order):
94
+ """Efficiently calculates expectation values for multiple observables across multiple shot histograms.
95
+
96
+ This function is optimized to compute expectation values in a fully vectorized
97
+ manner, minimizing Python loops. It operates in four main steps:
98
+ 1. Aggregates all unique bitstrings measured across all histograms.
99
+ 2. Builds a "reduced" eigenvalue matrix corresponding only to the unique states.
100
+ 3. Builds a "reduced" probability matrix from the shot counts for each histogram.
101
+ 4. Computes all expectation values with a single matrix multiplication.
102
+
103
+ Args:
104
+ shots_dicts (list[dict[str, int]]): A list of shot dictionaries (histograms),
105
+ where each dictionary maps a measured bitstring to its count.
106
+ observables (list[qml.operation.Operation]): A list of PennyLane observables
107
+ for which to calculate expectation values.
108
+ wire_order (tuple[int, ...]): A tuple defining the order of wires, which maps
109
+ the bitstring to the qubits. Note: This is typically the reverse of the
110
+ qubit indices (e.g., (2, 1, 0) for a 3-qubit system).
111
+
112
+ Returns:
113
+ np.ndarray: A 2D NumPy array of shape (n_observables, n_shots) where
114
+ result[i, j] is the expectation value of observables[i] for the
115
+ histogram in shots_dicts[j].
116
+ """
117
+
118
+ n_histograms = len(shots_dicts)
119
+ n_total_wires = len(wire_order)
120
+ n_observables = len(observables)
121
+
122
+ # --- 1. Aggregate all unique measured states across all shots ---
123
+ all_measured_bitstrings = set()
124
+ for sd in shots_dicts:
125
+ all_measured_bitstrings.update(sd.keys())
126
+
127
+ unique_bitstrings = sorted(list(all_measured_bitstrings))
128
+ n_unique_states = len(unique_bitstrings)
129
+
130
+ bitstring_to_idx_map = {bs: i for i, bs in enumerate(unique_bitstrings)}
131
+
132
+ # --- 2. Build REDUCED Eigenvalue Matrix: (n_observables, n_unique_states) ---
133
+ unique_states_int = np.array(
134
+ [int(bs, 2) for bs in unique_bitstrings], dtype=np.uint64
135
+ )
136
+ reduced_eigvals_matrix = np.zeros((n_observables, n_unique_states))
137
+ wire_map = {w: i for i, w in enumerate(wire_order)}
138
+
139
+ powers_cache = {}
140
+
141
+ for obs_idx, observable in enumerate(observables):
142
+ obs_wires = observable.wires
143
+ n_obs_wires = len(obs_wires)
144
+
145
+ if n_obs_wires in powers_cache:
146
+ powers = powers_cache[n_obs_wires]
147
+ else:
148
+ powers = 2 ** np.arange(n_obs_wires - 1, -1, -1, dtype=np.intp)
149
+ powers_cache[n_obs_wires] = powers
150
+
151
+ obs_wire_indices = np.array([wire_map[w] for w in obs_wires], dtype=np.uint32)
152
+ eigvals = _get_eigvals_from_key(_get_structural_key(observable))
153
+
154
+ # Vectorized mapping, but on the *reduced* set of states
155
+ shifts = n_total_wires - 1 - obs_wire_indices
156
+ bits = ((unique_states_int[:, np.newaxis] >> shifts) & 1).astype(np.intp)
157
+ # powers = 2 ** np.arange(n_obs_wires - 1, -1, -1)
158
+
159
+ # obs_state_indices = (bits * powers).sum(axis=1).astype(np.intp)
160
+ obs_state_indices = np.dot(bits, powers)
161
+
162
+ reduced_eigvals_matrix[obs_idx, :] = eigvals[obs_state_indices]
163
+
164
+ # --- 3. Build REDUCED Probability Matrix: (n_shots, n_unique_states) ---
165
+ reduced_prob_matrix = np.zeros((n_histograms, n_unique_states), dtype=np.float32)
166
+ for i, shots_dict in enumerate(shots_dicts):
167
+ total = sum(shots_dict.values())
168
+
169
+ for bitstring, count in shots_dict.items():
170
+ col_idx = bitstring_to_idx_map[bitstring]
171
+ reduced_prob_matrix[i, col_idx] = count / total
172
+
173
+ # --- 4. Compute Final Expectation Values ---
174
+ # (n_shots, n_unique_states) @ (n_unique_states, n_observables)
175
+ result = reduced_prob_matrix @ reduced_eigvals_matrix.T
176
+
177
+ # Transpose to (n_observables, n_shots) as expected by the calling code
178
+ return result.T
179
+
180
+
181
+ def _compute_parameter_shift_mask(n_params):
182
+ """
183
+ Generate a binary matrix mask for the parameter shift rule.
184
+ This mask is used to determine the shifts to apply to each parameter
185
+ when computing gradients via the parameter shift rule in quantum algorithms.
186
+
187
+ Args:
188
+ n_params (int): The number of parameters in the quantum circuit.
189
+
190
+ Returns:
191
+ np.ndarray: A (2 * n_params, n_params) matrix where each row encodes
192
+ the shift to apply to each parameter for a single evaluation.
193
+ The values are multiples of 0.5 * pi, with alternating signs.
194
+ """
195
+ mask_arr = np.arange(0, 2 * n_params, 2)
196
+ mask_arr[0] = 1
197
+
198
+ binary_matrix = ((mask_arr[:, np.newaxis] & (1 << np.arange(n_params))) > 0).astype(
199
+ np.float64
200
+ )
201
+
202
+ binary_matrix = binary_matrix.repeat(2, axis=0)
203
+ binary_matrix[1::2] *= -1
204
+ binary_matrix *= 0.5 * np.pi
205
+
206
+ return binary_matrix
207
+
208
+
209
+ class VariationalQuantumAlgorithm(QuantumProgram):
210
+ """Base class for variational quantum algorithms.
211
+
212
+ This class provides the foundation for implementing variational quantum
213
+ algorithms in Divi. It handles circuit execution, parameter optimization,
214
+ and result management for algorithms that optimize parameterized quantum
215
+ circuits to minimize cost functions.
216
+
217
+ Variational algorithms work by:
218
+ 1. Generating parameterized quantum circuits
219
+ 2. Executing circuits on quantum hardware/simulators
220
+ 3. Computing expectation values of cost Hamiltonians
221
+ 4. Using classical optimizers to update parameters
222
+ 5. Iterating until convergence
223
+
224
+ Attributes:
225
+ _losses_history (list[dict]): History of loss values during optimization.
226
+ _final_params (np.ndarray): Final optimized parameters.
227
+ _best_params (np.ndarray): Parameters that achieved the best loss.
228
+ _best_loss (float): Best loss achieved during optimization.
229
+ _circuits (list[Circuit]): Generated quantum circuits.
230
+ _total_circuit_count (int): Total number of circuits executed.
231
+ _total_run_time (float): Total execution time in seconds.
232
+ _curr_params (np.ndarray): Current parameter values.
233
+ _seed (int | None): Random seed for parameter initialization.
234
+ _rng (np.random.Generator): Random number generator.
235
+ _grad_mode (bool): Whether currently computing gradients.
236
+ _grouping_strategy (str): Strategy for grouping quantum operations.
237
+ _qem_protocol (QEMProtocol): Quantum error mitigation protocol.
238
+ _cancellation_event (Event | None): Event for graceful termination.
239
+ _meta_circuit_factory (callable): Factory for creating MetaCircuit instances.
240
+ """
241
+
242
+ def __init__(
243
+ self,
244
+ backend: CircuitRunner,
245
+ seed: int | None = None,
246
+ progress_queue: Queue | None = None,
247
+ **kwargs,
248
+ ):
249
+ """Initialize the VariationalQuantumAlgorithm.
250
+
251
+ This constructor is specifically designed for hybrid quantum-classical
252
+ variational algorithms. The instance variables `n_layers` and `n_params`
253
+ must be set by subclasses, where:
254
+ - `n_layers` is the number of layers in the quantum circuit.
255
+ - `n_params` is the number of parameters per layer.
256
+
257
+ For exotic variational algorithms where these variables may not be applicable,
258
+ the `_initialize_params` method should be overridden to set the parameters.
259
+
260
+ Args:
261
+ backend (CircuitRunner): Quantum circuit execution backend.
262
+ seed (int | None): Random seed for parameter initialization. Defaults to None.
263
+ progress_queue (Queue | None): Queue for progress reporting. Defaults to None.
264
+
265
+ Keyword Args:
266
+ grouping_strategy (str): Strategy for grouping operations in Pennylane transforms.
267
+ Options: "default", "wires", "qwc". Defaults to "qwc".
268
+ qem_protocol (QEMProtocol | None): Quantum error mitigation protocol to apply. Defaults to None.
269
+ """
270
+
271
+ self._losses_history = []
272
+
273
+ self._best_params = None
274
+ self._final_params = None
275
+ self._best_loss = float("inf")
276
+ self._best_probs = {}
277
+
278
+ self._curr_params = None
279
+
280
+ self._seed = seed
281
+ self._rng = np.random.default_rng(self._seed)
282
+
283
+ # Lets child classes adapt their optimization
284
+ # step for grad calculation routine
285
+ self._grad_mode = False
286
+ self._is_compute_probabilites = False
287
+
288
+ super().__init__(
289
+ backend=backend, seed=seed, progress_queue=progress_queue, **kwargs
290
+ )
291
+
292
+ self.job_id = kwargs.get("job_id", None)
293
+ if progress_queue and self.job_id:
294
+ self.reporter = QueueProgressReporter(self.job_id, progress_queue)
295
+ else:
296
+ self.reporter = LoggingProgressReporter()
297
+
298
+ # Needed for Pennylane's transforms
299
+ self._grouping_strategy = kwargs.pop("grouping_strategy", "qwc")
300
+
301
+ self._qem_protocol = kwargs.pop("qem_protocol", None) or _NoMitigation()
302
+
303
+ self._cancellation_event = None
304
+
305
+ self._meta_circuit_factory = partial(
306
+ MetaCircuit,
307
+ # No grouping strategy for expectation value measurements
308
+ grouping_strategy=(
309
+ "_backend_expval"
310
+ if self.backend and self.backend.supports_expval
311
+ else self._grouping_strategy
312
+ ),
313
+ qem_protocol=self._qem_protocol,
314
+ )
315
+
316
+ @property
317
+ @abstractmethod
318
+ def cost_hamiltonian(self) -> qml.operation.Operator:
319
+ """The cost Hamiltonian for the variational problem."""
320
+ pass
321
+
322
+ @property
323
+ def total_circuit_count(self) -> int:
324
+ """Get the total number of circuits executed.
325
+
326
+ Returns:
327
+ int: Cumulative count of circuits submitted for execution.
328
+ """
329
+ return self._total_circuit_count
330
+
331
+ @property
332
+ def total_run_time(self) -> float:
333
+ """Get the total runtime across all circuit executions.
334
+
335
+ Returns:
336
+ float: Cumulative execution time in seconds.
337
+ """
338
+ return self._total_run_time
339
+
340
+ @property
341
+ def meta_circuits(self):
342
+ """Get the meta-circuit templates used by this program.
343
+
344
+ Returns:
345
+ dict[str, MetaCircuit]: Dictionary mapping circuit names to their
346
+ MetaCircuit templates.
347
+ """
348
+ return self._meta_circuits
349
+
350
+ @property
351
+ def n_params(self):
352
+ """Get the total number of parameters in the quantum circuit.
353
+
354
+ Returns:
355
+ int: Total number of trainable parameters (n_layers * n_params_per_layer).
356
+ """
357
+ return self._n_params
358
+
359
+ @property
360
+ def losses_history(self) -> list[dict]:
361
+ """Get a copy of the optimization loss history.
362
+
363
+ Each entry is a dictionary mapping parameter indices to loss values.
364
+
365
+ Returns:
366
+ list[dict]: Copy of the loss history. Modifications to this list
367
+ will not affect the internal state.
368
+ """
369
+ return self._losses_history.copy()
370
+
371
+ @property
372
+ def final_params(self) -> np.ndarray:
373
+ """Get a copy of the final optimized parameters.
374
+
375
+ Returns:
376
+ np.ndarray: Copy of the final parameters. Modifications to this array
377
+ will not affect the internal state.
378
+ """
379
+ return self._final_params.copy()
380
+
381
+ @property
382
+ def best_params(self) -> np.ndarray:
383
+ """Get a copy of the parameters that achieved the best (lowest) loss.
384
+
385
+ Returns:
386
+ np.ndarray: Copy of the best parameters. Modifications to this array
387
+ will not affect the internal state.
388
+ """
389
+ return self._best_params.copy()
390
+
391
+ @property
392
+ def best_loss(self) -> float:
393
+ """Get the best loss achieved so far.
394
+
395
+ Returns:
396
+ float: The best loss achieved so far.
397
+ """
398
+ return self._best_loss
399
+
400
+ @property
401
+ def best_probs(self):
402
+ """Get a copy of the probability distribution for the best parameters.
403
+
404
+ Returns:
405
+ dict: A copy of the best probability distribution.
406
+ """
407
+ return self._best_probs.copy()
408
+
409
+ def _convert_counts_to_probs(self, results):
410
+ """Convert raw counts to probability distributions."""
411
+ return {
412
+ outer_k: {
413
+ inner_k: inner_v / self.backend.shots
414
+ for inner_k, inner_v in outer_v.items()
415
+ }
416
+ for outer_k, outer_v in results.items()
417
+ }
418
+
419
+ def _process_probability_results(self, results):
420
+ """Convert raw counts to probabilities and fix endianness."""
421
+ probs = self._convert_counts_to_probs(results)
422
+ return reverse_dict_endianness(probs)
423
+
424
+ @property
425
+ def initial_params(self) -> np.ndarray:
426
+ """Get the current initial parameters.
427
+
428
+ Returns:
429
+ np.ndarray: Current initial parameters. If not yet initialized,
430
+ they will be generated automatically.
431
+ """
432
+ if self._curr_params is None:
433
+ self._initialize_params()
434
+ return self._curr_params.copy()
435
+
436
+ @initial_params.setter
437
+ def initial_params(self, value: np.ndarray | None):
438
+ """
439
+ Set initial parameters.
440
+
441
+ Args:
442
+ value (np.ndarray | None): Initial parameters with shape
443
+ (n_param_sets, n_layers * n_params), or None to reset
444
+ to uninitialized state.
445
+
446
+ Raises:
447
+ ValueError: If parameters have incorrect shape.
448
+ """
449
+ if value is not None:
450
+ self._validate_initial_params(value)
451
+ self._curr_params = value.copy()
452
+ else:
453
+ # Reset to uninitialized state
454
+ self._curr_params = None
455
+
456
+ @abstractmethod
457
+ def _create_meta_circuits_dict(self) -> dict[str, MetaCircuit]:
458
+ pass
459
+
460
+ @abstractmethod
461
+ def _generate_circuits(self, **kwargs) -> list[Circuit]:
462
+ """Generate quantum circuits for execution.
463
+
464
+ This method should generate and return a list of Circuit objects based on
465
+ the current algorithm state and parameters. The circuits will be executed
466
+ by the backend.
467
+
468
+ Args:
469
+ **kwargs: Additional keyword arguments for circuit generation.
470
+
471
+ Returns:
472
+ list[Circuit]: List of Circuit objects to be executed.
473
+ """
474
+ pass
475
+
476
+ def get_expected_param_shape(self) -> tuple[int, int]:
477
+ """
478
+ Get the expected shape for initial parameters.
479
+
480
+ Returns:
481
+ tuple[int, int]: Shape (n_param_sets, n_layers * n_params) that
482
+ initial parameters should have for this quantum program.
483
+ """
484
+ return (self.optimizer.n_param_sets, self.n_layers * self.n_params)
485
+
486
+ def _validate_initial_params(self, params: np.ndarray):
487
+ """
488
+ Validate user-provided initial parameters.
489
+
490
+ Args:
491
+ params (np.ndarray): Parameters to validate.
492
+
493
+ Raises:
494
+ ValueError: If parameters have incorrect shape.
495
+ """
496
+ expected_shape = self.get_expected_param_shape()
497
+
498
+ if params.shape != expected_shape:
499
+ raise ValueError(
500
+ f"Initial parameters must have shape {expected_shape}, "
501
+ f"got {params.shape}"
502
+ )
503
+
504
+ def _initialize_params(self):
505
+ """
506
+ Initialize the circuit parameters randomly.
507
+
508
+ Generates random parameters with values uniformly distributed between
509
+ 0 and 2π. The number of parameter sets depends on the optimizer being used.
510
+ """
511
+ total_params = self.n_layers * self.n_params
512
+ self._curr_params = self._rng.uniform(
513
+ 0, 2 * np.pi, (self.optimizer.n_param_sets, total_params)
514
+ )
515
+
516
+ def _run_optimization_circuits(self, data_file, **kwargs):
517
+ self._curr_circuits = self._generate_circuits(**kwargs)
518
+
519
+ if self.backend.supports_expval:
520
+ kwargs["ham_ops"] = hamiltonian_to_pauli_string(
521
+ self.cost_hamiltonian, self.n_qubits
522
+ )
523
+
524
+ losses = self._dispatch_circuits_and_process_results(
525
+ data_file=data_file, **kwargs
526
+ )
527
+
528
+ return losses
529
+
530
+ def _post_process_results(
531
+ self, results: dict[str, dict[str, int]], **kwargs
532
+ ) -> dict[int, float]:
533
+ """
534
+ Post-process the results of the quantum problem.
535
+
536
+ Args:
537
+ results (dict[str, dict[str, int]]): The shot histograms of the quantum execution step.
538
+ The keys should be strings of format {param_id}_*_{measurement_group_id}.
539
+ i.e. an underscore-separated bunch of metadata, starting always with
540
+ the index of some parameter and ending with the index of some measurement group.
541
+ Any extra piece of metadata that might be relevant to the specific
542
+ application can be kept in the middle.
543
+
544
+ Returns:
545
+ dict[int, float]: The energies for each parameter set grouping, where the dict keys
546
+ correspond to the parameter indices.
547
+ """
548
+ if not (self._cancellation_event and self._cancellation_event.is_set()):
549
+ self.reporter.info(
550
+ message="Post-processing output", iteration=self.current_iteration
551
+ )
552
+
553
+ losses = {}
554
+ measurement_groups = self._meta_circuits["cost_circuit"].measurement_groups
555
+
556
+ # Flatten measurement groups for expectation value backends
557
+ if self.backend.supports_expval:
558
+ flattened_measurement_groups = tuple(
559
+ chain.from_iterable(measurement_groups)
560
+ )
561
+ else:
562
+ flattened_measurement_groups = measurement_groups
563
+
564
+ # Define key functions for both levels of grouping
565
+ get_param_id = lambda item: int(item[0].split("_")[0])
566
+ get_qem_id = lambda item: int(item[0].split("_")[1].split(":")[1])
567
+
568
+ # Group the pre-sorted results by parameter ID.
569
+ for p, param_group_iterator in groupby(results.items(), key=get_param_id):
570
+ param_group_iterator = list(param_group_iterator)
571
+
572
+ shots_by_qem_idx = zip(
573
+ *{
574
+ gid: [value for _, value in group]
575
+ for gid, group in groupby(param_group_iterator, key=get_qem_id)
576
+ }.values()
577
+ )
578
+
579
+ marginal_results = []
580
+ for shots_dicts, curr_measurement_group in zip(
581
+ shots_by_qem_idx, flattened_measurement_groups
582
+ ):
583
+ if self.backend.supports_expval:
584
+ ham_ops = kwargs.get("ham_ops")
585
+ if ham_ops is None:
586
+ # Internal consistency check: ham_ops should be set by _run_optimization_circuits
587
+ # when backend supports expectation values
588
+ raise ValueError(
589
+ "Hamiltonian operators (ham_ops) are required when using a backend "
590
+ "that supports expectation values, but were not provided."
591
+ )
592
+
593
+ expectation_matrix = np.array(
594
+ [
595
+ [shot_dict[op_name] for op_name in ham_ops.split(";")]
596
+ for shot_dict in shots_dicts
597
+ ]
598
+ ).T
599
+ else:
600
+ wire_order = tuple(reversed(self.cost_hamiltonian.wires))
601
+
602
+ expectation_matrix = _batched_expectation(
603
+ shots_dicts, curr_measurement_group, wire_order
604
+ )
605
+
606
+ # expectation_matrix[i, j] = expectation value for observable i, histogram j
607
+ curr_marginal_results = []
608
+ for intermediate_exp_values in expectation_matrix:
609
+ mitigated_exp_value = self._qem_protocol.postprocess_results(
610
+ intermediate_exp_values
611
+ )
612
+ curr_marginal_results.append(mitigated_exp_value)
613
+
614
+ marginal_results.append(
615
+ curr_marginal_results
616
+ if len(curr_marginal_results) > 1
617
+ else curr_marginal_results[0]
618
+ )
619
+
620
+ if self.backend.supports_expval:
621
+ marginal_results = marginal_results[0]
622
+
623
+ pl_loss = (
624
+ self._meta_circuits["cost_circuit"]
625
+ .postprocessing_fn(marginal_results)[0]
626
+ .item()
627
+ )
628
+
629
+ losses[p] = pl_loss + self.loss_constant
630
+
631
+ return losses
632
+
633
+ def _perform_final_computation(self, **kwargs):
634
+ """
635
+ Perform final computations after optimization completes.
636
+
637
+ This is an optional hook method that subclasses can override to perform
638
+ any post-optimization processing, such as extracting solutions, running
639
+ final measurements, or computing additional metrics.
640
+
641
+ Args:
642
+ **kwargs: Additional keyword arguments for subclasses.
643
+
644
+ Note:
645
+ The default implementation does nothing. Subclasses should override
646
+ this method if they need post-optimization processing.
647
+ """
648
+ pass
649
+
650
+ def run(self, data_file=None, **kwargs):
651
+ """Run the variational quantum algorithm.
652
+
653
+ The outputs are stored in the algorithm object.
654
+ Optionally, the data can be stored in a file.
655
+
656
+ Args:
657
+ data_file (str | None): The file to store the data in. If None, no data is stored. Defaults to None.
658
+ **kwargs: Additional keyword arguments for subclasses.
659
+ """
660
+
661
+ def cost_fn(params):
662
+ self.reporter.info(
663
+ message="💸 Computing Cost 💸", iteration=self.current_iteration
664
+ )
665
+
666
+ self._curr_params = np.atleast_2d(params)
667
+
668
+ losses = self._run_optimization_circuits(data_file, **kwargs)
669
+
670
+ losses = np.fromiter(losses.values(), dtype=np.float64)
671
+
672
+ if params.ndim > 1:
673
+ return losses
674
+ else:
675
+ return losses.item()
676
+
677
+ self._grad_shift_mask = _compute_parameter_shift_mask(
678
+ self.n_layers * self.n_params
679
+ )
680
+
681
+ def grad_fn(params):
682
+ self._grad_mode = True
683
+
684
+ self.reporter.info(
685
+ message="📈 Computing Gradients 📈", iteration=self.current_iteration
686
+ )
687
+
688
+ self._curr_params = self._grad_shift_mask + params
689
+
690
+ exp_vals = self._run_optimization_circuits(data_file, **kwargs)
691
+ exp_vals_arr = np.fromiter(exp_vals.values(), dtype=np.float64)
692
+
693
+ pos_shifts = exp_vals_arr[::2]
694
+ neg_shifts = exp_vals_arr[1::2]
695
+ grads = 0.5 * (pos_shifts - neg_shifts)
696
+
697
+ self._grad_mode = False
698
+
699
+ return grads
700
+
701
+ def _iteration_counter(intermediate_result: OptimizeResult):
702
+
703
+ self._losses_history.append(
704
+ dict(
705
+ zip(
706
+ range(len(intermediate_result.x)),
707
+ intermediate_result.fun,
708
+ )
709
+ )
710
+ )
711
+
712
+ current_loss = np.min(intermediate_result.fun)
713
+ if current_loss < self._best_loss:
714
+ self._best_loss = current_loss
715
+ best_idx = np.argmin(intermediate_result.fun)
716
+
717
+ self._best_params = intermediate_result.x[best_idx].copy()
718
+
719
+ self.current_iteration += 1
720
+
721
+ self.reporter.update(iteration=self.current_iteration)
722
+
723
+ if self._cancellation_event and self._cancellation_event.is_set():
724
+ raise _CancelledError("Cancellation requested by batch.")
725
+
726
+ # The scipy implementation of COBYLA interprets the `maxiter` option
727
+ # as the maximum number of function evaluations, not iterations.
728
+ # To provide a consistent user experience, we disable `scipy`'s
729
+ # `maxiter` and manually stop the optimization from the callback
730
+ # when the desired number of iterations is reached.
731
+ if (
732
+ isinstance(self.optimizer, ScipyOptimizer)
733
+ and self.optimizer.method == ScipyMethod.COBYLA
734
+ and intermediate_result.nit + 1 == self.max_iterations
735
+ ):
736
+ raise StopIteration
737
+
738
+ self.reporter.info(message="Finished Setup")
739
+
740
+ # Only initialize if user hasn't already set initial_params
741
+ if self._curr_params is None:
742
+ self._initialize_params()
743
+
744
+ try:
745
+ self._minimize_res = self.optimizer.optimize(
746
+ cost_fn=cost_fn,
747
+ initial_params=self._curr_params,
748
+ callback_fn=_iteration_counter,
749
+ jac=grad_fn,
750
+ maxiter=self.max_iterations,
751
+ rng=self._rng,
752
+ )
753
+ except _CancelledError:
754
+ # The optimizer was stopped by our callback. This is not a real
755
+ # error, just a signal to exit this task cleanly.
756
+ return self._total_circuit_count, self._total_run_time
757
+
758
+ self._final_params = self._minimize_res.x
759
+
760
+ self._perform_final_computation(**kwargs)
761
+
762
+ self.reporter.info(message="Finished successfully!")
763
+
764
+ return self.total_circuit_count, self.total_run_time
765
+
766
+ def _run_solution_measurement(self):
767
+ """Execute measurement circuits to obtain probability distributions for solution extraction."""
768
+ if self._best_params is None:
769
+ raise RuntimeError(
770
+ "Optimization has not been run, no best parameters available."
771
+ )
772
+
773
+ if "meas_circuit" not in self._meta_circuits:
774
+ raise NotImplementedError(
775
+ f"{type(self).__name__} does not implement a 'meas_circuit'."
776
+ )
777
+
778
+ self._is_compute_probabilites = True
779
+
780
+ # Compute probabilities for best parameters (the ones that achieved best loss)
781
+ self._curr_params = np.atleast_2d(self._best_params)
782
+ self._curr_circuits = self._generate_circuits()
783
+ best_probs = self._dispatch_circuits_and_process_results()
784
+ self._best_probs.update(best_probs)
785
+
786
+ self._is_compute_probabilites = False