Source code for qiskit_algorithms.time_evolvers.pvqd.pvqd

# This code is part of a Qiskit project.
#
# (C) Copyright IBM 2019, 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 projected Variational Quantum Dynamics Algorithm."""
from __future__ import annotations

import logging
from collections.abc import Callable

import numpy as np

from qiskit.circuit import Parameter, ParameterVector, QuantumCircuit
from qiskit.circuit.library import PauliEvolutionGate
from qiskit.primitives import BaseEstimator
from qiskit.quantum_info.operators.base_operator import BaseOperator
from qiskit.synthesis import EvolutionSynthesis, LieTrotter
from qiskit_algorithms.utils import algorithm_globals

from ...exceptions import AlgorithmError
from ...optimizers import Minimizer, Optimizer
from ...state_fidelities.base_state_fidelity import BaseStateFidelity
from ..real_time_evolver import RealTimeEvolver
from ..time_evolution_problem import TimeEvolutionProblem
from ..time_evolution_result import TimeEvolutionResult
from .pvqd_result import PVQDResult
from .utils import _get_observable_evaluator, _is_gradient_supported

logger = logging.getLogger(__name__)


[docs]class PVQD(RealTimeEvolver): """The projected Variational Quantum Dynamics (p-VQD) Algorithm. In each timestep, this algorithm computes the next state with a Trotter formula (specified by the ``evolution`` argument) and projects the timestep onto a variational form (``ansatz``). The projection is determined by maximizing the fidelity of the Trotter-evolved state and the ansatz, using a classical optimization routine. See Ref. [1] for details. The following attributes can be set via the initializer but can also be read and updated once the PVQD object has been constructed. Attributes: ansatz (QuantumCircuit): The parameterized circuit representing the time-evolved state. initial_parameters (np.ndarray): The parameters of the ansatz at time 0. optimizer (Optional[Union[Optimizer, Minimizer]]): The classical optimization routine used to maximize the fidelity of the Trotter step and ansatz. num_timesteps (Optional[int]): The number of timesteps to take. If None, it is automatically selected to achieve a timestep of approximately 0.01. evolution (Optional[EvolutionSynthesis]): The method to perform the Trotter step. Defaults to first-order Lie-Trotter evolution. use_parameter_shift (bool): If True, use the parameter shift rule for loss function gradients (if the ansatz supports). initial_guess (Optional[np.ndarray]): The starting point for the first classical optimization run, at time 0. Defaults to random values in :math:`[-0.01, 0.01]`. Example: This snippet computes the real time evolution of a quantum Ising model on two neighboring sites and keeps track of the magnetization. .. code-block:: python import numpy as np from qiskit_algorithms.state_fidelities import ComputeUncompute from qiskit_algorithms.time_evolvers import TimeEvolutionProblem, PVQD from qiskit.primitives import Estimator, Sampler from qiskit.circuit.library import EfficientSU2 from qiskit.quantum_info import SparsePauliOp, Pauli from qiskit_algorithms.optimizers import L_BFGS_B sampler = Sampler() fidelity = ComputeUncompute(sampler) estimator = Estimator() hamiltonian = 0.1 * SparsePauliOp(["ZZ", "IX", "XI"]) observable = Pauli("ZZ") ansatz = EfficientSU2(2, reps=1) initial_parameters = np.zeros(ansatz.num_parameters) time = 1 optimizer = L_BFGS_B() # setup the algorithm pvqd = PVQD( fidelity, ansatz, initial_parameters, estimator, num_timesteps=100, optimizer=optimizer, ) # specify the evolution problem problem = TimeEvolutionProblem( hamiltonian, time, aux_operators=[hamiltonian, observable] ) # and evolve! result = pvqd.evolve(problem) References: [1] Stefano Barison, Filippo Vicentini, and Giuseppe Carleo (2021), An efficient quantum algorithm for the time evolution of parameterized circuits, `Quantum 5, 512 <https://quantum-journal.org/papers/q-2021-07-28-512/>`_. """ # pylint: disable=too-many-positional-arguments def __init__( self, fidelity: BaseStateFidelity, ansatz: QuantumCircuit, initial_parameters: np.ndarray, estimator: BaseEstimator | None = None, optimizer: Optimizer | Minimizer | None = None, num_timesteps: int | None = None, evolution: EvolutionSynthesis | None = None, use_parameter_shift: bool = True, initial_guess: np.ndarray | None = None, ) -> None: """ Args: fidelity: A fidelity primitive used by the algorithm. ansatz: A parameterized circuit preparing the variational ansatz to model the time evolved quantum state. initial_parameters: The initial parameters for the ansatz. Together with the ansatz, these define the initial state of the time evolution. estimator: An estimator primitive used for calculating expected values of auxiliary operators (if provided via the problem). optimizer: The classical optimizers used to minimize the overlap between Trotterization and ansatz. Can be either a :class:`.Optimizer` or a callable using the :class:`.Minimizer` protocol. This argument is optional since it is not required for :meth:`get_loss`, but it has to be set before :meth:`evolve` is called. num_timesteps: The number of time steps. If ``None`` it will be set such that the timestep is close to 0.01. evolution: The evolution synthesis to use for the construction of the Trotter step. Defaults to first-order Lie-Trotter decomposition, see also :mod:`~qiskit.synthesis.evolution` for different options. use_parameter_shift: If True, use the parameter shift rule to compute gradients. If False, the optimizer will not be passed a gradient callable. In that case, Qiskit optimizers will use a finite difference rule to approximate the gradients. initial_guess: The initial guess for the first VQE optimization. Afterwards the previous iteration result is used as initial guess. If None, this is set to a random vector with elements in the interval :math:`[-0.01, 0.01]`. """ super().__init__() if evolution is None: evolution = LieTrotter() self.ansatz = ansatz self.initial_parameters = initial_parameters self.num_timesteps = num_timesteps self.optimizer = optimizer self.initial_guess = initial_guess self.estimator = estimator self.fidelity_primitive = fidelity self.evolution = evolution self.use_parameter_shift = use_parameter_shift # pylint: disable=too-many-positional-arguments
[docs] def step( self, hamiltonian: BaseOperator, ansatz: QuantumCircuit, theta: np.ndarray, dt: float, initial_guess: np.ndarray, ) -> tuple[np.ndarray, float]: """Perform a single time step. Args: hamiltonian: The Hamiltonian under which to evolve. ansatz: The parameterized quantum circuit which attempts to approximate the time-evolved state. theta: The current parameters. dt: The time step. initial_guess: The initial guess for the classical optimization of the fidelity between the next variational state and the Trotter-evolved last state. If None, this is set to a random vector with elements in the interval :math:`[-0.01, 0.01]`. Returns: A tuple consisting of the next parameters and the fidelity of the optimization. """ self._validate_setup() loss, gradient = self.get_loss(hamiltonian, ansatz, dt, theta) if initial_guess is None: initial_guess = algorithm_globals.random.random(self.initial_parameters.size) * 0.01 if isinstance(self.optimizer, Optimizer): optimizer_result = self.optimizer.minimize( loss, initial_guess, gradient # type: ignore[arg-type] ) else: optimizer_result = self.optimizer(loss, initial_guess, gradient) # type: ignore[call-arg] # clip the fidelity to [0, 1] fidelity = np.clip(1 - optimizer_result.fun, 0, 1) return theta + optimizer_result.x, fidelity
[docs] def get_loss( self, hamiltonian: BaseOperator, ansatz: QuantumCircuit, dt: float, current_parameters: np.ndarray, ) -> tuple[Callable[[np.ndarray], float], Callable[[np.ndarray], np.ndarray]] | None: """Get a function to evaluate the infidelity between Trotter step and ansatz. Args: hamiltonian: The Hamiltonian under which to evolve. ansatz: The parameterized quantum circuit which attempts to approximate the time-evolved state. dt: The time step. current_parameters: The current parameters. Returns: A callable to evaluate the infidelity and, if gradients are supported and required, a second callable to evaluate the gradient of the infidelity. """ self._validate_setup(skip={"optimizer"}) # use Trotterization to evolve the current state trotterized = ansatz.assign_parameters(current_parameters) evolution_gate = PauliEvolutionGate(hamiltonian, time=dt, synthesis=self.evolution) trotterized.append(evolution_gate, ansatz.qubits) # define the overlap of the Trotterized state and the ansatz x = ParameterVector("w", ansatz.num_parameters) shifted = ansatz.assign_parameters(current_parameters + x) def evaluate_loss(displacement: np.ndarray | list[np.ndarray]) -> float | np.ndarray: """Evaluate the overlap of the ansatz with the Trotterized evolution. Args: displacement: The parameters for the ansatz. Returns: The fidelity of the ansatz with parameters ``theta`` and the Trotterized evolution. Raises: AlgorithmError: If a primitive job fails. """ if isinstance(displacement, list): displacement = np.asarray(displacement) value_dict = {x_i: displacement[:, i].tolist() for i, x_i in enumerate(x)} else: value_dict = dict(zip(x, displacement)) param_dicts = self._transpose_param_dicts(value_dict) num_of_param_sets = len(param_dicts) states1 = [trotterized] * num_of_param_sets states2 = [shifted] * num_of_param_sets param_dicts2 = [list(param_dict.values()) for param_dict in param_dicts] # the first state does not have free parameters so values_1 will be None by default try: job = self.fidelity_primitive.run(states1, states2, values_2=param_dicts2) fidelities = np.array(job.result().fidelities) except Exception as exc: raise AlgorithmError("The primitive job failed!") from exc if len(fidelities) == 1: fidelities = fidelities[0] # in principle, we could add different loss functions here, but we're currently # not aware of a use-case for a different one than in the paper return 1 - fidelities if _is_gradient_supported(ansatz) and self.use_parameter_shift: def evaluate_gradient(displacement: np.ndarray) -> np.ndarray: """Evaluate the gradient with the parameter-shift rule. This is hard-coded here since the gradient framework does not support computing gradients for overlaps. Args: displacement: The parameters for the ansatz. Returns: The gradient. """ # construct lists where each element is shifted by plus (or minus) pi/2 dim = displacement.size plus_shifts = (displacement + np.pi / 2 * np.identity(dim)).tolist() minus_shifts = (displacement - np.pi / 2 * np.identity(dim)).tolist() evaluated = np.asarray(evaluate_loss(plus_shifts + minus_shifts)) gradient = (evaluated[:dim] - evaluated[dim:]) / 2 return gradient else: evaluate_gradient = None return evaluate_loss, evaluate_gradient # type: ignore[return-value]
def _transpose_param_dicts(self, params: dict) -> list[dict[Parameter, float]]: p_0 = list(params.values())[0] if isinstance(p_0, (list, np.ndarray)): num_parameterizations = len(p_0) param_bindings = [ {param: value_list[i] for param, value_list in params.items()} for i in range(num_parameterizations) ] else: param_bindings = [params] return param_bindings
[docs] def evolve(self, evolution_problem: TimeEvolutionProblem) -> TimeEvolutionResult: r"""Perform real time evolution :math:`\exp(-i t H)|\Psi\rangle`. Evolves an initial state :math:`|\Psi\rangle` for a time :math:`t` under a Hamiltonian :math:`H`, as provided in the ``evolution_problem``. Args: evolution_problem: The evolution problem containing the hamiltonian, total evolution time and observables to evaluate. Returns: A result object containing the evolution information and evaluated observables. Raises: ValueError: If ``aux_operators`` provided in the time evolution problem but no estimator provided to the algorithm. NotImplementedError: If the evolution problem contains an initial state. """ self._validate_setup() time = evolution_problem.time observables = evolution_problem.aux_operators hamiltonian = evolution_problem.hamiltonian # determine the number of timesteps and set the timestep num_timesteps = ( int(np.ceil(time / 0.01)) if self.num_timesteps is None else self.num_timesteps ) timestep = time / num_timesteps if evolution_problem.initial_state is not None: raise NotImplementedError( "Setting an initial state for the evolution is not yet supported for PVQD." ) # get the function to evaluate the observables for a given set of ansatz parameters if observables is not None: if self.estimator is None: raise ValueError( "The evolution problem contained aux_operators but no estimator was provided. " ) evaluate_observables = _get_observable_evaluator( self.ansatz, observables, self.estimator ) observable_values = [evaluate_observables(self.initial_parameters)] fidelities = [1.0] parameters = [self.initial_parameters] times = np.linspace(0, time, num_timesteps + 1).tolist() # +1 to include initial time 0 initial_guess = self.initial_guess for _ in range(num_timesteps): # perform VQE to find the next parameters next_parameters, fidelity = self.step( hamiltonian, self.ansatz, parameters[-1], timestep, initial_guess ) # set initial guess to last parameter update initial_guess = next_parameters - parameters[-1] parameters.append(next_parameters) fidelities.append(fidelity) if observables is not None: observable_values.append(evaluate_observables(next_parameters)) evolved_state = self.ansatz.assign_parameters(parameters[-1]) result = PVQDResult( evolved_state=evolved_state, times=times, parameters=parameters, fidelities=fidelities, estimated_error=1 - float(np.prod(fidelities)), ) if observables is not None: result.observables = observable_values # type: ignore[assignment] result.aux_ops_evaluated = observable_values[-1] # type: ignore[assignment] return result
def _validate_setup(self, skip=None): """Validate the current setup and raise an error if something misses to run.""" if skip is None: skip = {} required_attributes = {"optimizer"}.difference(skip) for attr in required_attributes: if getattr(self, attr, None) is None: raise ValueError(f"The {attr} cannot be None.") if self.num_timesteps is not None and self.num_timesteps <= 0: raise ValueError( f"The number of timesteps must be positive but is {self.num_timesteps}." ) if self.ansatz.num_parameters == 0: raise AlgorithmError( "The ansatz cannot have 0 parameters, otherwise it cannot be trained." ) if len(self.initial_parameters) != self.ansatz.num_parameters: raise AlgorithmError( f"Mismatching number of parameters in the ansatz ({self.ansatz.num_parameters}) " f"and the initial parameters ({len(self.initial_parameters)})." )