Skip to content

Commit 54b54f1

Browse files
authored
Adding a convergence threshold to VQD to filter non-sensible values (#203)
1 parent 8709f00 commit 54b54f1

File tree

3 files changed

+116
-31
lines changed

3 files changed

+116
-31
lines changed

docs/tutorials/04_vqd.ipynb

+5-5
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"source": [
1616
"## Introduction\n",
1717
"\n",
18-
"VQD is a quantum algorithm that uses a variational technique to find the *k* eigenvalues of the Hamiltonian *H* of a given system.\n",
18+
"VQD is a quantum algorithm that uses a variational technique to find the *k* lowest eigenvalues of the Hamiltonian *H* of a given system.\n",
1919
"\n",
2020
"The algorithm computes excited state energies of generalized hamiltonians by optimizing over a modified cost function. Each successive eigenvalue is calculated iteratively by introducing an overlap term with all the previously computed eigenstates that must be minimized. This ensures that higher energy eigenstates are found."
2121
]
@@ -83,11 +83,11 @@
8383
],
8484
"source": [
8585
"from qiskit.circuit.library import TwoLocal\n",
86-
"from qiskit_algorithms.optimizers import SLSQP\n",
86+
"from qiskit_algorithms.optimizers import COBYLA\n",
8787
"\n",
8888
"ansatz = TwoLocal(2, rotation_blocks=[\"ry\", \"rz\"], entanglement_blocks=\"cz\", reps=1)\n",
8989
"\n",
90-
"optimizer = SLSQP()\n",
90+
"optimizer = COBYLA()\n",
9191
"ansatz.decompose().draw(\"mpl\")"
9292
]
9393
},
@@ -128,7 +128,7 @@
128128
"outputs": [],
129129
"source": [
130130
"k = 3\n",
131-
"betas = [33, 33, 33]"
131+
"betas = [3, 3, 3]"
132132
]
133133
},
134134
{
@@ -360,7 +360,7 @@
360360
"name": "python",
361361
"nbconvert_exporter": "python",
362362
"pygments_lexer": "ipython3",
363-
"version": "3.10.0"
363+
"version": "3.12.6"
364364
}
365365
},
366366
"nbformat": 4,

qiskit_algorithms/eigensolvers/vqd.py

+70-13
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class VQD(VariationalAlgorithm, Eigensolver):
5050
5151
`VQD <https://arxiv.org/abs/1805.08138>`__ is a quantum algorithm that uses a
5252
variational technique to find
53-
the k eigenvalues of the Hamiltonian :math:`H` of a given system.
53+
the k lowest eigenvalues of the Hamiltonian :math:`H` of a given system.
5454
5555
The algorithm computes excited state energies of generalised hamiltonians
5656
by optimizing over a modified cost function where each successive eigenvalue
@@ -108,6 +108,9 @@ class VQD(VariationalAlgorithm, Eigensolver):
108108
follows during each evaluation by the optimizer: the evaluation count,
109109
the optimizer parameters for the ansatz, the estimated value, the estimation
110110
metadata, and the current step.
111+
convergence_threshold: A threshold under which the algorithm is considered to have
112+
converged. It corresponds to the maximal average fidelity an eigenstate is allowed
113+
to have with the previous eigenstates. If set to None, no check is performed.
111114
"""
112115

113116
def __init__(
@@ -121,6 +124,7 @@ def __init__(
121124
betas: np.ndarray | None = None,
122125
initial_point: np.ndarray | list[np.ndarray] | None = None,
123126
callback: Callable[[int, np.ndarray, float, dict[str, Any], int], None] | None = None,
127+
convergence_threshold: float | None = None,
124128
) -> None:
125129
"""
126130
@@ -147,6 +151,9 @@ def __init__(
147151
follows during each evaluation by the optimizer: the evaluation count,
148152
the optimizer parameters for the ansatz, the estimated value,
149153
the estimation metadata, and the current step.
154+
convergence_threshold: A threshold under which the algorithm is considered to have
155+
converged. It corresponds to the maximal average fidelity an eigenstate is allowed
156+
to have with the previous eigenstates. If set to None, no check is performed.
150157
"""
151158
super().__init__()
152159

@@ -159,6 +166,7 @@ def __init__(
159166
# this has to go via getters and setters due to the VariationalAlgorithm interface
160167
self.initial_point = initial_point
161168
self.callback = callback
169+
self.convergence_threshold = convergence_threshold
162170

163171
self._eval_count = 0
164172

@@ -267,16 +275,24 @@ def compute_eigenvalues(
267275
self.initial_point, self.ansatz # type: ignore[arg-type]
268276
)
269277

278+
current_optimal_point: dict[str, Any] = {"optimal_value": float("inf")}
279+
270280
for step in range(1, self.k + 1):
281+
current_optimal_point["optimal_value"] = float("inf")
282+
271283
if num_initial_points > 1:
272284
initial_point = validate_initial_point(initial_points[step - 1], self.ansatz)
273285

274286
if step > 1:
275-
prev_states.append(self.ansatz.assign_parameters(result.optimal_points[-1]))
287+
prev_states.append(self.ansatz.assign_parameters(current_optimal_point["x"]))
276288

277289
self._eval_count = 0
278290
energy_evaluation = self._get_evaluate_energy(
279-
step, operator, betas, prev_states=prev_states
291+
step,
292+
operator,
293+
betas,
294+
prev_states=prev_states,
295+
current_optimal_point=current_optimal_point,
280296
)
281297

282298
start_time = time()
@@ -309,11 +325,13 @@ def compute_eigenvalues(
309325

310326
eval_time = time() - start_time
311327

312-
self._update_vqd_result(result, opt_result, eval_time, self.ansatz.copy())
328+
self._update_vqd_result(
329+
result, opt_result, eval_time, self.ansatz.copy(), current_optimal_point
330+
)
313331

314332
if aux_operators is not None:
315333
aux_value = estimate_observables(
316-
self.estimator, self.ansatz, aux_operators, result.optimal_points[-1]
334+
self.estimator, self.ansatz, aux_operators, current_optimal_point["x"]
317335
)
318336
aux_values.append(aux_value)
319337

@@ -326,6 +344,29 @@ def compute_eigenvalues(
326344
self._eval_count,
327345
)
328346
else:
347+
average_fidelity = current_optimal_point["total_fidelity"][0] / (step - 1)
348+
349+
if (
350+
self.convergence_threshold is not None
351+
and average_fidelity > self.convergence_threshold
352+
):
353+
last_digit = step % 10
354+
355+
if last_digit == 1 and step % 100 != 11:
356+
suffix = "st"
357+
elif last_digit == 2:
358+
suffix = "nd"
359+
elif last_digit == 3:
360+
suffix = "rd"
361+
else:
362+
suffix = "th"
363+
364+
raise AlgorithmError(
365+
f"Convergence threshold is set to {self.convergence_threshold} but an "
366+
f"average fidelity of {average_fidelity:.5f} with the previous eigenstates"
367+
f"has been observed during the evaluation of the {step}{suffix} lowest"
368+
f"eigenvalue."
369+
)
329370
logger.info(
330371
(
331372
"%s excited state optimization complete in %s s.\n"
@@ -345,21 +386,24 @@ def compute_eigenvalues(
345386

346387
return result
347388

348-
def _get_evaluate_energy(
389+
def _get_evaluate_energy( # pylint: disable=too-many-positional-arguments
349390
self,
350391
step: int,
351392
operator: BaseOperator,
352393
betas: np.ndarray,
394+
current_optimal_point: dict["str", Any],
353395
prev_states: list[QuantumCircuit] | None = None,
354396
) -> Callable[[np.ndarray], float | np.ndarray]:
355397
"""Returns a function handle to evaluate the ansatz's energy for any given parameters.
356398
This is the objective function to be passed to the optimizer that is used for evaluation.
357399
358400
Args:
359-
step: level of energy being calculated. 0 for ground, 1 for first excited state...
401+
step: level of energy being calculated. 1 for ground, 2 for first excited state...
360402
operator: The operator whose energy to evaluate.
361403
betas: Beta parameters in the VQD paper.
362404
prev_states: List of optimal circuits from previous rounds of optimization.
405+
current_optimal_point: A dict to keep track of the current optimal point, which is used
406+
to check the algorithm's convergence.
363407
364408
Returns:
365409
A callable that computes and returns the energy of the hamiltonian
@@ -425,6 +469,17 @@ def evaluate_energy(parameters: np.ndarray) -> float | np.ndarray:
425469
else:
426470
self._eval_count += len(values)
427471

472+
for param, value in zip(parameters, values):
473+
if value < current_optimal_point["optimal_value"]:
474+
current_optimal_point["optimal_value"] = value
475+
current_optimal_point["x"] = param
476+
477+
if step > 1:
478+
current_optimal_point["total_fidelity"] = total_cost
479+
current_optimal_point["eigenvalue"] = (value - total_cost)[0]
480+
else:
481+
current_optimal_point["eigenvalue"] = value
482+
428483
return values if len(values) > 1 else values[0]
429484

430485
return evaluate_energy
@@ -444,20 +499,22 @@ def _build_vqd_result() -> VQDResult:
444499

445500
@staticmethod
446501
def _update_vqd_result(
447-
result: VQDResult, opt_result: OptimizerResult, eval_time, ansatz
502+
result: VQDResult, opt_result: OptimizerResult, eval_time, ansatz, optimal_point
448503
) -> VQDResult:
449504
result.optimal_points = (
450-
np.concatenate([result.optimal_points, [opt_result.x]])
505+
np.concatenate([result.optimal_points, [optimal_point["x"]]])
451506
if len(result.optimal_points) > 0
452-
else np.array([opt_result.x])
507+
else np.array([optimal_point["x"]])
453508
)
454509
result.optimal_parameters.append(
455-
dict(zip(ansatz.parameters, cast(np.ndarray, opt_result.x)))
510+
dict(zip(ansatz.parameters, cast(np.ndarray, optimal_point["x"])))
511+
)
512+
result.optimal_values = np.concatenate(
513+
[result.optimal_values, [optimal_point["optimal_value"]]]
456514
)
457-
result.optimal_values = np.concatenate([result.optimal_values, [opt_result.fun]])
458515
result.cost_function_evals = np.concatenate([result.cost_function_evals, [opt_result.nfev]])
459516
result.optimizer_times = np.concatenate([result.optimizer_times, [eval_time]])
460-
result.eigenvalues.append(opt_result.fun + 0j) # type: ignore[attr-defined]
517+
result.eigenvalues.append(optimal_point["eigenvalue"] + 0j) # type: ignore[attr-defined]
461518
result.optimizer_results.append(opt_result)
462519
result.optimal_circuits.append(ansatz)
463520
return result

test/eigensolvers/test_vqd.py

+41-13
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ def setUp(self):
5959

6060
self.estimator = Estimator()
6161
self.estimator_shots = Estimator(options={"shots": 1024, "seed": self.seed})
62-
self.fidelity = ComputeUncompute(Sampler())
63-
self.betas = [50, 50]
62+
self.fidelity = ComputeUncompute(Sampler(options={"shots": 100_000, "seed": self.seed}))
63+
self.betas = [3]
6464

6565
@data(H2_SPARSE_PAULI)
6666
def test_basic_operator(self, op):
@@ -105,7 +105,14 @@ def test_basic_operator(self, op):
105105

106106
def test_full_spectrum(self):
107107
"""Test obtaining all eigenvalues."""
108-
vqd = VQD(self.estimator, self.fidelity, self.ryrz_wavefunction, optimizer=L_BFGS_B(), k=4)
108+
vqd = VQD(
109+
self.estimator,
110+
self.fidelity,
111+
self.ryrz_wavefunction,
112+
optimizer=COBYLA(),
113+
k=4,
114+
betas=[3, 3, 3],
115+
)
109116
result = vqd.compute_eigenvalues(H2_SPARSE_PAULI)
110117
np.testing.assert_array_almost_equal(
111118
result.eigenvalues.real, self.h2_energy_excited, decimal=2
@@ -190,9 +197,8 @@ def store_intermediate_result(eval_count, parameters, mean, metadata, step):
190197
self.assertTrue(all(isinstance(param, float) for param in params))
191198

192199
ref_eval_count = [1, 2, 3, 1, 2, 3]
193-
ref_mean = [-1.07, -1.45, -1.37, 37.43, 48.55, 28.94]
194-
# new ref_mean for statevector simulator. The old unit test was on qasm
195-
# and the ref_mean values were slightly different.
200+
ref_mean = [-1.07, -1.45, -1.36, 1.24, 1.55, 1.07]
201+
# new ref_mean since the betas were changed
196202

197203
ref_step = [1, 1, 1, 2, 2, 2]
198204

@@ -208,15 +214,15 @@ def test_vqd_optimizer(self, op):
208214
estimator=self.estimator,
209215
fidelity=self.fidelity,
210216
ansatz=RealAmplitudes(),
211-
optimizer=SLSQP(),
217+
optimizer=COBYLA(),
212218
k=2,
213219
betas=self.betas,
214220
)
215221

216222
def run_check():
217223
result = vqd.compute_eigenvalues(operator=op)
218224
np.testing.assert_array_almost_equal(
219-
result.eigenvalues.real, self.h2_energy_excited[:2], decimal=3
225+
result.eigenvalues.real, self.h2_energy_excited[:2], decimal=2
220226
)
221227

222228
run_check()
@@ -225,11 +231,11 @@ def run_check():
225231
run_check()
226232

227233
with self.subTest("Optimizer replace"):
228-
vqd.optimizer = L_BFGS_B()
234+
vqd.optimizer = SPSA()
229235
run_check()
230236

231237
with self.subTest("Batched optimizer replace"):
232-
vqd.optimizer = SLSQP(maxiter=60, max_evals_grouped=10)
238+
vqd.optimizer = COBYLA(maxiter=60, max_evals_grouped=10)
233239
run_check()
234240

235241
with self.subTest("SPSA replace"):
@@ -243,7 +249,7 @@ def run_check():
243249
def test_optimizer_list(self, op):
244250
"""Test sending an optimizer list"""
245251

246-
optimizers = [SLSQP(), L_BFGS_B()]
252+
optimizers = [COBYLA(), SPSA()]
247253
initial_point_1 = [
248254
1.70256666,
249255
-5.34843975,
@@ -287,7 +293,7 @@ def test_aux_operators_list(self, op):
287293
estimator=self.estimator,
288294
fidelity=self.fidelity,
289295
ansatz=wavefunction,
290-
optimizer=SLSQP(),
296+
optimizer=COBYLA(),
291297
k=2,
292298
betas=self.betas,
293299
)
@@ -340,7 +346,7 @@ def test_aux_operators_dict(self, op):
340346
estimator=self.estimator,
341347
fidelity=self.fidelity,
342348
ansatz=wavefunction,
343-
optimizer=SLSQP(),
349+
optimizer=COBYLA(),
344350
betas=self.betas,
345351
)
346352

@@ -440,6 +446,28 @@ def test_aux_operator_std_dev(self, op):
440446
self.assertIsInstance(result.aux_operators_evaluated[0][2][1], dict)
441447
self.assertIsInstance(result.aux_operators_evaluated[0][3][1], dict)
442448

449+
def test_convergence_threshold(self):
450+
"""Test the convergence threshold raises an error if and only if too high"""
451+
vqd = VQD(
452+
self.estimator,
453+
self.fidelity,
454+
RealAmplitudes(),
455+
SLSQP(),
456+
k=2,
457+
betas=self.betas,
458+
convergence_threshold=1e-3,
459+
)
460+
with self.subTest("Failed convergence"):
461+
with self.assertRaises(AlgorithmError):
462+
vqd.compute_eigenvalues(operator=H2_SPARSE_PAULI)
463+
464+
with self.subTest("Convergence accepted"):
465+
vqd.convergence_threshold = 1e-1
466+
result = vqd.compute_eigenvalues(operator=H2_SPARSE_PAULI)
467+
np.testing.assert_array_almost_equal(
468+
result.eigenvalues.real, self.h2_energy_excited[:2], decimal=1
469+
)
470+
443471

444472
if __name__ == "__main__":
445473
unittest.main()

0 commit comments

Comments
 (0)