qoro-divi 0.3.5__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.
- divi/backends/__init__.py +1 -1
- divi/backends/_circuit_runner.py +21 -0
- divi/backends/_parallel_simulator.py +14 -0
- divi/backends/_qoro_service.py +232 -70
- divi/backends/_qpu_system.py +77 -3
- divi/circuits/_core.py +24 -5
- divi/circuits/qasm.py +1 -3
- divi/extern/cirq/_validator.py +12 -3
- divi/qprog/__init__.py +1 -0
- divi/qprog/algorithms/_ansatze.py +20 -16
- divi/qprog/algorithms/_qaoa.py +152 -111
- divi/qprog/algorithms/_vqe.py +170 -79
- divi/qprog/batch.py +34 -1
- divi/qprog/optimizers.py +133 -50
- divi/qprog/quantum_program.py +131 -633
- divi/qprog/variational_quantum_algorithm.py +786 -0
- divi/qprog/workflows/_graph_partitioning.py +42 -6
- divi/qprog/workflows/_qubo_partitioning.py +1 -1
- divi/qprog/workflows/_vqe_sweep.py +40 -33
- divi/reporting/_reporter.py +3 -6
- divi/utils.py +65 -0
- {qoro_divi-0.3.5.dist-info → qoro_divi-0.4.0.dist-info}/METADATA +15 -1
- {qoro_divi-0.3.5.dist-info → qoro_divi-0.4.0.dist-info}/RECORD +27 -26
- {qoro_divi-0.3.5.dist-info → qoro_divi-0.4.0.dist-info}/LICENSE +0 -0
- {qoro_divi-0.3.5.dist-info → qoro_divi-0.4.0.dist-info}/LICENSES/.license-header +0 -0
- {qoro_divi-0.3.5.dist-info → qoro_divi-0.4.0.dist-info}/LICENSES/Apache-2.0.txt +0 -0
- {qoro_divi-0.3.5.dist-info → qoro_divi-0.4.0.dist-info}/WHEEL +0 -0
|
@@ -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
|