Source code for qiskit_algorithms.eigensolvers.vqd

# 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.

"""The Variational Quantum Deflation Algorithm for computing higher energy states.

See https://arxiv.org/abs/1805.08138.
"""

from __future__ import annotations

from collections.abc import Callable, Sequence, Iterable
from typing import Any, cast
import logging
from time import time

import numpy as np

from qiskit.circuit import QuantumCircuit
from qiskit.primitives import BaseEstimator
from qiskit.quantum_info.operators.base_operator import BaseOperator
from qiskit.quantum_info import SparsePauliOp

from qiskit_algorithms.state_fidelities import BaseStateFidelity

from ..list_or_dict import ListOrDict
from ..optimizers import Optimizer, Minimizer, OptimizerResult
from ..variational_algorithm import VariationalAlgorithm
from .eigensolver import Eigensolver, EigensolverResult
from ..utils import validate_bounds, validate_initial_point
from ..exceptions import AlgorithmError
from ..observables_evaluator import estimate_observables

# private function as we expect this to be updated in the next release
from ..utils.set_batching import _set_default_batchsize

logger = logging.getLogger(__name__)


[docs]class VQD(VariationalAlgorithm, Eigensolver): r"""The Variational Quantum Deflation algorithm. Implementation using primitives. `VQD <https://arxiv.org/abs/1805.08138>`__ is a quantum algorithm that uses a variational technique to find the k eigenvalues of the Hamiltonian :math:`H` of a given system. The algorithm computes excited state energies of generalised hamiltonians by optimizing over a modified cost function where each successive eigenvalue is calculated iteratively by introducing an overlap term with all the previously computed eigenstates that must be minimised, thus ensuring higher energy eigenstates are found. An instance of VQD requires defining three algorithmic subcomponents: an integer k denoting the number of eigenstates to calculate, a trial state (a.k.a. ansatz) which is a :class:`QuantumCircuit`, and one instance (or list of) classical :mod:`~qiskit_algorithms.optimizers`. The optimizer varies the circuit parameters The trial state :math:`|\psi(\vec\theta)\rangle` is varied by the optimizer, which modifies the set of ansatz parameters :math:`\vec\theta` such that the expectation value of the operator on the corresponding state approaches a minimum. The algorithm does this by iteratively refining each excited state to be orthogonal to all the previous excited states. An optional array of parameter values, via the *initial_point*, may be provided as the starting point for the search of the minimum eigenvalue. This feature is particularly useful when there are reasons to believe that the solution point is close to a particular point. The length of the *initial_point* list value must match the number of the parameters expected by the ansatz. If the *initial_point* is left at the default of ``None``, then VQD will look to the ansatz for a preferred value, based on its given initial state. If the ansatz returns ``None``, then a random point will be generated within the parameter bounds set, as per above. It is also possible to give a list of initial points, one for every kth eigenvalue. If the ansatz provides ``None`` as the lower bound, then VQD will default it to :math:`-2\pi`; similarly, if the ansatz returns ``None`` as the upper bound, the default value will be :math:`2\pi`. The following attributes can be set via the initializer but can also be read and updated once the VQD object has been constructed. Attributes: estimator (BaseEstimator): The primitive instance used to perform the expectation estimation as indicated in the VQD paper. fidelity (BaseStateFidelity): The fidelity class instance used to compute the overlap estimation as indicated in the VQD paper. ansatz (QuantumCircuit): A parameterized circuit used as ansatz for the wave function. optimizer(Optimizer | Sequence[Optimizer]): A classical optimizer or a list of optimizers, one for every k-th eigenvalue. Can either be a Qiskit optimizer or a callable that takes an array as input and returns a Qiskit or SciPy optimization result. k (int): the number of eigenvalues to return. Returns the lowest k eigenvalues. betas (list[float]): Beta parameters in the VQD paper. Should have length k - 1, with k the number of excited states. These hyper-parameters balance the contribution of each overlap term to the cost function and have a default value computed as the mean square sum of the coefficients of the observable. callback (Callable[[int, np.ndarray, float, dict[str, Any]], None] | None): A callback that can access the intermediate data during the optimization. Four parameter values are passed to the callback as follows during each evaluation by the optimizer: the evaluation count, the optimizer parameters for the ansatz, the estimated value, the estimation metadata, and the current step. """ def __init__( self, estimator: BaseEstimator, fidelity: BaseStateFidelity, ansatz: QuantumCircuit, optimizer: Optimizer | Minimizer | Sequence[Optimizer | Minimizer], *, k: int = 2, betas: np.ndarray | None = None, initial_point: np.ndarray | list[np.ndarray] | None = None, callback: Callable[[int, np.ndarray, float, dict[str, Any], int], None] | None = None, ) -> None: """ Args: estimator: The estimator primitive. fidelity: The fidelity class using primitives. ansatz: A parameterized circuit used as ansatz for the wave function. optimizer: A classical optimizer or a list of optimizers, one for every k-th eigenvalue. Can either be a Qiskit optimizer or a callable that takes an array as input and returns a Qiskit or SciPy optimization result. k: The number of eigenvalues to return. Returns the lowest k eigenvalues. betas: Beta parameters in the VQD paper. Should have length k - 1, with k the number of excited states. These hyperparameters balance the contribution of each overlap term to the cost function and have a default value computed as the mean square sum of the coefficients of the observable. initial_point: An optional initial point (i.e. initial parameter values) or a list of initial points (one for every k-th eigenvalue) for the optimizer. If ``None`` then VQD will look to the ansatz for a preferred point and if not will simply compute a random one. callback: A callback that can access the intermediate data during the optimization. Five parameter values are passed to the callback as follows during each evaluation by the optimizer: the evaluation count, the optimizer parameters for the ansatz, the estimated value, the estimation metadata, and the current step. """ super().__init__() self.estimator = estimator self.fidelity = fidelity self.ansatz = ansatz self.optimizer = optimizer self.k = k self.betas = betas # this has to go via getters and setters due to the VariationalAlgorithm interface self.initial_point = initial_point self.callback = callback self._eval_count = 0 @property def initial_point(self) -> np.ndarray | list[np.ndarray] | None: """Returns initial point.""" return self._initial_point @initial_point.setter def initial_point(self, initial_point: np.ndarray | list[np.ndarray] | None): """Sets initial point""" self._initial_point = initial_point def _check_operator_ansatz(self, operator: BaseOperator): """Check that the number of qubits of operator and ansatz match.""" if operator is not None and self.ansatz is not None: if operator.num_qubits != self.ansatz.num_qubits: # try to set the number of qubits on the ansatz, if possible try: self.ansatz.num_qubits = operator.num_qubits except AttributeError as exc: raise AlgorithmError( "The number of qubits of the ansatz does not match the " "operator, and the ansatz does not allow setting the " "number of qubits using `num_qubits`." ) from exc
[docs] @classmethod def supports_aux_operators(cls) -> bool: return True
[docs] def compute_eigenvalues( self, operator: BaseOperator, aux_operators: ListOrDict[BaseOperator] | None = None, ) -> VQDResult: super().compute_eigenvalues(operator, aux_operators) # this sets the size of the ansatz, so it must be called before the initial point # validation self._check_operator_ansatz(operator) bounds = validate_bounds(self.ansatz) # We need to handle the array entries being zero or Optional i.e. having value None if aux_operators: zero_op = SparsePauliOp.from_list([("I" * self.ansatz.num_qubits, 0)]) # Convert the None and zero values when aux_operators is a list. # Drop None and convert zero values when aux_operators is a dict. key_op_iterator: Iterable[tuple[str | int, BaseOperator]] if isinstance(aux_operators, list): key_op_iterator = enumerate(aux_operators) converted: ListOrDict[BaseOperator] = [zero_op] * len(aux_operators) else: key_op_iterator = aux_operators.items() converted = {} for key, op in key_op_iterator: if op is not None: converted[key] = zero_op if op == 0 else op # type: ignore[index] aux_operators = converted else: aux_operators = None betas = self.betas if self.betas is None: try: upper_bound = sum(np.abs(operator.coeffs)) except Exception as exc: raise NotImplementedError( r"Beta autoevaluation is not supported for operators" f"of type {type(operator)}." ) from exc betas = np.asarray([upper_bound * 10] * self.k) logger.info("beta autoevaluated to %s", betas[0]) result = self._build_vqd_result() if aux_operators is not None: aux_values = [] # We keep a list of the bound circuits with optimal parameters, to avoid re-binding # the same parameters to the ansatz if we do multiple steps prev_states = [] # These two variables are defined inside if statements and static analysis, e.g. lint can # see this as a potential error of them not being defined before use. Following the logic # they do end up being defined before use so the setting of these here, these values would # not be used in practice. initial_point = np.asarray([]) initial_points = np.asarray([]) num_initial_points = 0 if self.initial_point is not None: initial_points = np.reshape(self.initial_point, (-1, self.ansatz.num_parameters)) num_initial_points = len(initial_points) # 0 just means the initial point is ``None`` and ``validate_initial_point`` # will select a random point if num_initial_points <= 1: initial_point = validate_initial_point( self.initial_point, self.ansatz # type: ignore[arg-type] ) for step in range(1, self.k + 1): if num_initial_points > 1: initial_point = validate_initial_point(initial_points[step - 1], self.ansatz) if step > 1: prev_states.append(self.ansatz.assign_parameters(result.optimal_points[-1])) self._eval_count = 0 energy_evaluation = self._get_evaluate_energy( step, operator, betas, prev_states=prev_states ) start_time = time() # TODO: add gradient support after FidelityGradients are implemented if isinstance(self.optimizer, Sequence): optimizer = self.optimizer[step - 1] else: optimizer = self.optimizer # fall back to single optimizer if not list if callable(optimizer): opt_result = optimizer( # pylint: disable=not-callable fun=energy_evaluation, # type: ignore[arg-type] x0=initial_point, jac=None, bounds=bounds, ) else: # we always want to submit as many estimations per job as possible for minimal # overhead on the hardware was_updated = _set_default_batchsize(optimizer) opt_result = optimizer.minimize( fun=energy_evaluation, x0=initial_point, bounds=bounds # type: ignore[arg-type] ) # reset to original value if was_updated: optimizer.set_max_evals_grouped(None) eval_time = time() - start_time self._update_vqd_result(result, opt_result, eval_time, self.ansatz.copy()) if aux_operators is not None: aux_value = estimate_observables( self.estimator, self.ansatz, aux_operators, result.optimal_points[-1] ) aux_values.append(aux_value) if step == 1: logger.info( "Ground state optimization complete in %s seconds.\n" "Found opt_params %s in %s evals", eval_time, result.optimal_points, self._eval_count, ) else: logger.info( ( "%s excited state optimization complete in %s s.\n" "Found opt_params %s in %s evals" ), str(step - 1), eval_time, result.optimal_points, self._eval_count, ) # To match the signature of EigensolverResult result.eigenvalues = np.array(result.eigenvalues) if aux_operators is not None: result.aux_operators_evaluated = aux_values return result
def _get_evaluate_energy( self, step: int, operator: BaseOperator, betas: np.ndarray, prev_states: list[QuantumCircuit] | None = None, ) -> Callable[[np.ndarray], float | np.ndarray]: """Returns a function handle to evaluate the ansatz's energy for any given parameters. This is the objective function to be passed to the optimizer that is used for evaluation. Args: step: level of energy being calculated. 0 for ground, 1 for first excited state... operator: The operator whose energy to evaluate. betas: Beta parameters in the VQD paper. prev_states: List of optimal circuits from previous rounds of optimization. Returns: A callable that computes and returns the energy of the hamiltonian of each parameter. Raises: AlgorithmError: If the circuit is not parameterized (i.e. has 0 free parameters). AlgorithmError: If operator was not provided. RuntimeError: If the previous states array is of the wrong size. """ num_parameters = self.ansatz.num_parameters if num_parameters == 0: raise AlgorithmError("The ansatz must be parameterized, but has no free parameters.") if step > 1 and (len(prev_states) + 1) != step: raise RuntimeError( f"Passed previous states of the wrong size." f"Passed array has length {str(len(prev_states))}" ) self._check_operator_ansatz(operator) def evaluate_energy(parameters: np.ndarray) -> float | np.ndarray: # handle broadcasting: ensure parameters is of shape [array, array, ...] if len(parameters.shape) == 1: parameters = np.reshape(parameters, (-1, num_parameters)) batch_size = len(parameters) estimator_job = self.estimator.run( batch_size * [self.ansatz], batch_size * [operator], parameters ) total_cost = np.zeros(batch_size) if step > 1: # compute overlap cost batched_prev_states = [state for state in prev_states for _ in range(batch_size)] fidelity_job = self.fidelity.run( batch_size * [self.ansatz] * (step - 1), batched_prev_states, np.tile(parameters, (step - 1, 1)), # type: ignore[arg-type] ) costs = fidelity_job.result().fidelities costs = np.reshape(costs, (step - 1, -1)) for state, cost in enumerate(costs): total_cost += np.real(betas[state] * cost) try: estimator_result = estimator_job.result() except Exception as exc: raise AlgorithmError("The primitive job to evaluate the energy failed!") from exc values = estimator_result.values + total_cost if self.callback is not None: metadata = estimator_result.metadata for params, value, meta in zip(parameters, values, metadata): self._eval_count += 1 self.callback(self._eval_count, params, value, meta, step) else: self._eval_count += len(values) return values if len(values) > 1 else values[0] return evaluate_energy @staticmethod def _build_vqd_result() -> VQDResult: result = VQDResult() result.optimal_points = np.array([]) result.optimal_parameters = [] result.optimal_values = np.array([]) result.cost_function_evals = np.array([], dtype=int) result.optimizer_times = np.array([]) result.eigenvalues = [] # type: ignore[assignment] result.optimizer_results = [] result.optimal_circuits = [] return result @staticmethod def _update_vqd_result( result: VQDResult, opt_result: OptimizerResult, eval_time, ansatz ) -> VQDResult: result.optimal_points = ( np.concatenate([result.optimal_points, [opt_result.x]]) if len(result.optimal_points) > 0 else np.array([opt_result.x]) ) result.optimal_parameters.append( dict(zip(ansatz.parameters, cast(np.ndarray, opt_result.x))) ) result.optimal_values = np.concatenate([result.optimal_values, [opt_result.fun]]) result.cost_function_evals = np.concatenate([result.cost_function_evals, [opt_result.nfev]]) result.optimizer_times = np.concatenate([result.optimizer_times, [eval_time]]) result.eigenvalues.append(opt_result.fun + 0j) # type: ignore[attr-defined] result.optimizer_results.append(opt_result) result.optimal_circuits.append(ansatz) return result
[docs]class VQDResult(EigensolverResult): """VQD Result.""" def __init__(self) -> None: super().__init__() self._cost_function_evals: np.ndarray | None = None self._optimizer_times: np.ndarray | None = None self._optimal_values: np.ndarray | None = None self._optimal_points: np.ndarray | None = None self._optimal_parameters: list[dict] | None = None self._optimizer_results: list[OptimizerResult] | None = None self._optimal_circuits: list[QuantumCircuit] | None = None @property def cost_function_evals(self) -> np.ndarray | None: """Returns number of cost optimizer evaluations""" return self._cost_function_evals @cost_function_evals.setter def cost_function_evals(self, value: np.ndarray) -> None: """Sets number of cost function evaluations""" self._cost_function_evals = value @property def optimizer_times(self) -> np.ndarray | None: """Returns time taken for optimization for each step""" return self._optimizer_times @optimizer_times.setter def optimizer_times(self, value: np.ndarray) -> None: """Sets time taken for optimization for each step""" self._optimizer_times = value @property def optimal_values(self) -> np.ndarray | None: """Returns optimal value for each step""" return self._optimal_values @optimal_values.setter def optimal_values(self, value: np.ndarray) -> None: """Sets optimal values""" self._optimal_values = value @property def optimal_points(self) -> np.ndarray | None: """Returns optimal point for each step""" return self._optimal_points @optimal_points.setter def optimal_points(self, value: np.ndarray) -> None: """Sets optimal points""" self._optimal_points = value @property def optimal_parameters(self) -> list[dict] | None: """Returns the optimal parameters for each step""" return self._optimal_parameters @optimal_parameters.setter def optimal_parameters(self, value: list[dict]) -> None: """Sets optimal parameters""" self._optimal_parameters = value @property def optimizer_results(self) -> list[OptimizerResult] | None: """Returns the optimizer results for each step""" return self._optimizer_results @optimizer_results.setter def optimizer_results(self, value: list[OptimizerResult]) -> None: """Sets optimizer results""" self._optimizer_results = value @property def optimal_circuits(self) -> list[QuantumCircuit] | None: """The optimal circuits. Along with the optimal parameters, these can be used to retrieve the different eigenstates.""" return self._optimal_circuits @optimal_circuits.setter def optimal_circuits(self, optimal_circuits: list[QuantumCircuit]) -> None: self._optimal_circuits = optimal_circuits