# This code is part of a Qiskit project.
#
# (C) Copyright IBM 2022, 2024.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.
"""An implementation of the AdaptVQE algorithm."""
from __future__ import annotations
from enum import Enum
import re
import logging
import numpy as np
from qiskit.quantum_info.operators.base_operator import BaseOperator
from qiskit.circuit.library import EvolvedOperatorAnsatz
from qiskit_algorithms.utils.validation import validate_min
from qiskit_algorithms.exceptions import AlgorithmError
from qiskit_algorithms.list_or_dict import ListOrDict
from .minimum_eigensolver import MinimumEigensolver
from .vqe import VQE, VQEResult
from ..observables_evaluator import estimate_observables
from ..variational_algorithm import VariationalAlgorithm
logger = logging.getLogger(__name__)
class TerminationCriterion(Enum):
"""A class enumerating the various finishing criteria."""
CONVERGED = "Threshold converged"
CYCLICITY = "Aborted due to a cyclic selection of evolution operators"
MAXIMUM = "Maximum number of iterations reached"
[docs]class AdaptVQE(VariationalAlgorithm, MinimumEigensolver):
"""The Adaptive Variational Quantum Eigensolver algorithm.
`AdaptVQE <https://arxiv.org/abs/1812.11173>`__ is a quantum algorithm which creates a compact
ansatz from a set of evolution operators. It iteratively extends the ansatz circuit, by
selecting the building block that leads to the largest gradient from a set of candidates. In
chemistry, this is usually a list of orbital excitations. Thus, a common choice of ansatz to be
used with this algorithm is the Unitary Coupled Cluster ansatz implemented in Qiskit Nature.
This results in a wavefunction ansatz which is uniquely adapted to the operator whose minimum
eigenvalue is being determined. This class relies on a supplied instance of :class:`~.VQE` to
find the minimum eigenvalue. The performance of AdaptVQE significantly depends on the
minimization routine.
.. code-block:: python
from qiskit_algorithms.minimum_eigensolvers import AdaptVQE, VQE
from qiskit_algorithms.optimizers import SLSQP
from qiskit.primitives import Estimator
from qiskit.circuit.library import EvolvedOperatorAnsatz
# get your Hamiltonian
hamiltonian = ...
# construct your ansatz
ansatz = EvolvedOperatorAnsatz(...)
vqe = VQE(Estimator(), ansatz, SLSQP())
adapt_vqe = AdaptVQE(vqe)
eigenvalue, _ = adapt_vqe.compute_minimum_eigenvalue(hamiltonian)
The following attributes can be set via the initializer but can also be read and updated once
the AdaptVQE object has been constructed.
Attributes:
solver: a :class:`~.VQE` instance used internally to compute the minimum eigenvalues.
It is a requirement that the :attr:`~.VQE.ansatz` of this solver is of type
:class:`~qiskit.circuit.library.EvolvedOperatorAnsatz`.
gradient_threshold: once all gradients have an absolute value smaller than this threshold,
the algorithm has converged and terminates.
eigenvalue_threshold: once the eigenvalue has changed by less than this threshold from one
iteration to the next, the algorithm has converged and terminates. When this case
occurs, the excitation included in the final iteration did not result in a significant
improvement of the eigenvalue and, thus, the results from this iteration are not
considered.
max_iterations: the maximum number of iterations for the adaptive loop. If ``None``, the
algorithm is not bound in its number of iterations.
"""
def __init__(
self,
solver: VQE,
*,
gradient_threshold: float = 1e-5,
eigenvalue_threshold: float = 1e-5,
max_iterations: int | None = None,
) -> None:
"""
Args:
solver: a :class:`~.VQE` instance used internally to compute the minimum eigenvalues.
It is a requirement that the :attr:`~.VQE.ansatz` of this solver is of type
:class:`~qiskit.circuit.library.EvolvedOperatorAnsatz`.
gradient_threshold: once all gradients have an absolute value smaller than this
threshold, the algorithm has converged and terminates. Defaults to ``1e-5``.
eigenvalue_threshold: once the eigenvalue has changed by less than this threshold from
one iteration to the next, the algorithm has converged and terminates. When this
case occurs, the excitation included in the final iteration did not result in a
significant improvement of the eigenvalue and, thus, the results from this iteration
are not considered.
max_iterations: the maximum number of iterations for the adaptive loop. If ``None``, the
algorithm is not bound in its number of iterations.
"""
validate_min("gradient_threshold", gradient_threshold, 1e-15)
validate_min("eigenvalue_threshold", eigenvalue_threshold, 1e-15)
self.solver = solver
self.gradient_threshold = gradient_threshold
self.eigenvalue_threshold = eigenvalue_threshold
self.max_iterations = max_iterations
self._tmp_ansatz: EvolvedOperatorAnsatz | None = None
self._excitation_pool: list[BaseOperator] = []
self._excitation_list: list[BaseOperator] = []
@property
def initial_point(self) -> np.ndarray | None:
"""Returns the initial point of the internal :class:`~.VQE` solver."""
return self.solver.initial_point
@initial_point.setter
def initial_point(self, value: np.ndarray | None) -> None:
"""Sets the initial point of the internal :class:`~.VQE` solver."""
self.solver.initial_point = value
[docs] @classmethod
def supports_aux_operators(cls) -> bool:
return True
def _compute_gradients(
self,
theta: list[float],
operator: BaseOperator,
) -> ListOrDict[tuple[float, dict[str, BaseOperator]]]:
"""
Computes the gradients for all available excitation operators.
Args:
theta: List of (up to now) optimal parameters.
operator: operator whose gradient needs to be computed.
Returns:
List of pairs consisting of the computed gradient and excitation operator.
"""
# The excitations operators are applied later as exp(i*theta*excitation).
# For this commutator, we need to explicitly pull in the imaginary phase.
commutators = [1j * (operator @ exc - exc @ operator) for exc in self._excitation_pool]
res = estimate_observables(self.solver.estimator, self.solver.ansatz, commutators, theta)
return res
@staticmethod
def _check_cyclicity(indices: list[int]) -> bool:
"""
Auxiliary function to check for cycles in the indices of the selected excitations.
Args:
indices: The list of chosen gradient indices.
Returns:
Whether repeating sequences of indices have been detected.
"""
cycle_regex = re.compile(r"(\b.+ .+\b)( \b\1\b)+")
# reg-ex explanation:
# 1. (\b.+ .+\b) will match at least two numbers and try to match as many as possible. The
# word boundaries in the beginning and end ensure that now numbers are split into digits.
# 2. the match of this part is placed into capture group 1
# 3. ( \b\1\b)+ will match a space followed by the contents of capture group 1 (again
# delimited by word boundaries to avoid separation into digits).
# -> this results in any sequence of at least two numbers being detected
match = cycle_regex.search(" ".join(map(str, indices)))
logger.debug("Cycle detected: %s", match)
# Additionally we also need to check whether the last two numbers are identical, because the
# reg-ex above will only find cycles of at least two consecutive numbers.
# It is sufficient to assert that the last two numbers are different due to the iterative
# nature of the algorithm.
return match is not None or (len(indices) > 1 and indices[-2] == indices[-1])
[docs] def compute_minimum_eigenvalue(
self,
operator: BaseOperator,
aux_operators: ListOrDict[BaseOperator] | None = None,
) -> AdaptVQEResult:
"""Computes the minimum eigenvalue.
Args:
operator: Operator whose minimum eigenvalue we want to find.
aux_operators: Additional auxiliary operators to evaluate.
Raises:
TypeError: If an ansatz other than :class:`~.EvolvedOperatorAnsatz` is provided.
AlgorithmError: If all evaluated gradients lie below the convergence threshold in
the first iteration of the algorithm.
Returns:
An :class:`~.AdaptVQEResult` which is a :class:`~.VQEResult` but also but also
includes runtime information about the AdaptVQE algorithm like the number of iterations,
termination criterion, and the final maximum gradient.
"""
if not isinstance(self.solver.ansatz, EvolvedOperatorAnsatz):
raise TypeError("The AdaptVQE ansatz must be of the EvolvedOperatorAnsatz type.")
# Overwrite the solver's ansatz with the initial state
self._tmp_ansatz = self.solver.ansatz
self._excitation_pool = self._tmp_ansatz.operators
self.solver.ansatz = self._tmp_ansatz.initial_state
prev_op_indices: list[int] = []
prev_raw_vqe_result: VQEResult | None = None
raw_vqe_result: VQEResult | None = None
theta: list[float] = []
max_grad: tuple[float, dict[str, BaseOperator] | None] = (0.0, None)
self._excitation_list = []
history: list[complex] = []
iteration = 0
while self.max_iterations is None or iteration < self.max_iterations:
iteration += 1
logger.info("--- Iteration #%s ---", str(iteration))
# compute gradients
logger.debug("Computing gradients")
cur_grads = self._compute_gradients(theta, operator)
# pick maximum gradient
max_grad_index, max_grad = max( # type: ignore[assignment]
enumerate(cur_grads),
# mypy <= 1.10 needs call-overload, for 1.11 its arg-type to suppress the error
# below then the other is seen as unused hence the additional unused-ignore
key=lambda item: np.abs(
item[1][0] # type: ignore[arg-type, unused-ignore]
), # type: ignore[call-overload, unused-ignore]
)
logger.info(
"Found maximum gradient %s at index %s",
str(np.abs(max_grad[0])),
str(max_grad_index),
)
# log gradients
if np.abs(max_grad[0]) < self.gradient_threshold:
if iteration == 1:
raise AlgorithmError(
"All gradients have been evaluated to lie below the convergence threshold "
"during the first iteration of the algorithm. Try to either tighten the "
"convergence threshold or pick a different ansatz."
)
logger.info(
"AdaptVQE terminated successfully with a final maximum gradient: %s",
str(np.abs(max_grad[0])),
)
termination_criterion = TerminationCriterion.CONVERGED
break
# store maximum gradient's index for cycle detection
prev_op_indices.append(max_grad_index)
# check indices of picked gradients for cycles
if self._check_cyclicity(prev_op_indices):
logger.info("Alternating sequence found. Finishing.")
logger.info("Final maximum gradient: %s", str(np.abs(max_grad[0])))
termination_criterion = TerminationCriterion.CYCLICITY
break
# add new excitation to self._ansatz
logger.info(
"Adding new operator to the ansatz: %s", str(self._excitation_pool[max_grad_index])
)
self._excitation_list.append(self._excitation_pool[max_grad_index])
theta.append(0.0)
# setting up the ansatz for the VQE iteration
self._tmp_ansatz.operators = self._excitation_list
self.solver.ansatz = self._tmp_ansatz
self.solver.initial_point = np.asarray(theta)
# evaluating the eigenvalue with the internal VQE
prev_raw_vqe_result = raw_vqe_result
raw_vqe_result = self.solver.compute_minimum_eigenvalue(operator)
theta = raw_vqe_result.optimal_point.tolist()
# checking convergence based on the change in eigenvalue
if iteration > 1:
eigenvalue_diff = np.abs(raw_vqe_result.eigenvalue - history[-1])
if eigenvalue_diff < self.eigenvalue_threshold:
logger.info(
"AdaptVQE terminated successfully with a final change in eigenvalue: %s",
str(eigenvalue_diff),
)
termination_criterion = TerminationCriterion.CONVERGED
logger.debug(
"Reverting the addition of the last excitation to the ansatz since it "
"resulted in a change of the eigenvalue below the configured threshold."
)
self._excitation_list.pop()
theta.pop()
self._tmp_ansatz.operators = self._excitation_list
self.solver.ansatz = self._tmp_ansatz
self.solver.initial_point = np.asarray(theta)
raw_vqe_result = prev_raw_vqe_result
break
# appending the computed eigenvalue to the tracking history
history.append(raw_vqe_result.eigenvalue)
logger.info("Current eigenvalue: %s", str(raw_vqe_result.eigenvalue))
else:
# reached maximum number of iterations
termination_criterion = TerminationCriterion.MAXIMUM
logger.info("Maximum number of iterations reached. Finishing.")
logger.info("Final maximum gradient: %s", str(np.abs(max_grad[0])))
result = AdaptVQEResult()
result.combine(raw_vqe_result)
result.num_iterations = iteration
result.final_max_gradient = max_grad[0]
result.termination_criterion = termination_criterion # type: ignore[assignment]
result.eigenvalue_history = history
# once finished evaluate auxiliary operators if any
if aux_operators is not None:
aux_values = estimate_observables(
self.solver.estimator,
self.solver.ansatz,
aux_operators,
result.optimal_point, # type: ignore[arg-type]
)
result.aux_operators_evaluated = aux_values # type: ignore[assignment]
logger.info("The final eigenvalue is: %s", str(result.eigenvalue))
self.solver.ansatz.operators = self._excitation_pool
return result
[docs]class AdaptVQEResult(VQEResult):
"""AdaptVQE Result."""
def __init__(self) -> None:
super().__init__()
self._num_iterations: int | None = None
self._final_max_gradient: float | None = None
self._termination_criterion: str = ""
self._eigenvalue_history: list[complex] | None = None
@property
def num_iterations(self) -> int:
"""Returns the number of iterations."""
return self._num_iterations
@num_iterations.setter
def num_iterations(self, value: int) -> None:
"""Sets the number of iterations."""
self._num_iterations = value
@property
def final_max_gradient(self) -> float:
"""Returns the final maximum gradient."""
return self._final_max_gradient
@final_max_gradient.setter
def final_max_gradient(self, value: float) -> None:
"""Sets the final maximum gradient."""
self._final_max_gradient = value
@property
def termination_criterion(self) -> str:
"""Returns the termination criterion."""
return self._termination_criterion
@termination_criterion.setter
def termination_criterion(self, value: str) -> None:
"""Sets the termination criterion."""
self._termination_criterion = value
@property
def eigenvalue_history(self) -> list[complex]:
"""Returns the history of computed eigenvalues.
The history's length matches the number of iterations and includes the final computed value.
"""
return self._eigenvalue_history
@eigenvalue_history.setter
def eigenvalue_history(self, eigenvalue_history: list[complex]) -> None:
"""Sets the history of computed eigenvalues."""
self._eigenvalue_history = eigenvalue_history