The implementation of a variational quantum eigensolver, VQE, is simple: Provided with an optimization routine define the loss function as the expectation value of your Hamiltonian with the ansatz and plug it into the optimizer. Our current implementation of the VQE doesn't necessarily reflect this.
For research applications it can be easier to re-implement it from scratch rather than use the existing implementation, mainly because we only interact with Qiskit's optimizers in a fixed manner and don't allow direct access to the loss function.
Furthermore, from a software perspective, the current VQE implementation does not perfectly fit to the other algorithms since it is stateful and uses a base-class that is only used for VQE.
The goal is to refactor VQE and the optimizers to
- allow re-use parts of the VQE for research
- allow custom optimizers and SciPy optimizers in the VQE without the overhead of wrapping them
as Qiskit
Optimizer
s - make the VQE implementation state-less (like the other algorithms)
The essential current structure is kept:
optimizer = SPSA()
vqe = VQE(ansatz, optimizer)
result = vqe.compute_minimum_eigenvalue(operator)
In several reseach applications we require access to the loss function of the VQE, e.g. to develop a new optimization method, or to investigate the loss landscape (or energy surface) of the ansatz model.
This loss function is only available internally in the VQE, meaning that as a user I have to reimplement the energy evaluation for the above two use cases. Since the VQE already contains the functionality to construct the energy evaluation we could expose access to it:
vqe = VQE(ansatz, expectation=expectation, quantum_instance=quantum_instance)
loss = vqe.get_energy_evaluation(operator)
This would allow re-using the VQE for different applications (and on top automatically removes some statefull-ness of the algorithm).
If we want to use an optimizer inside VQE, it currently has to implement the
qiskit.algorithms.optimizers.Optimizer
interface. This introduces an overhead of wrapping
every existing optimizer, whether they are user-written or come from an existing large library,
such as SciPy or scikit-quant.
Optimizers usually have a very common signature, where the objective function is the first argument, the intitial point (if it exists), followed by some more specific algorithmic settings like the number of iterations or a callback. If we specify an optimizer signature we can directly allow callables as optimizers and completely remove the overhead of deriving from Qiskit's optimizers. Then we could do
from scipy.optimize import minimize
from functools import partial
# could also be some custom optimizer or some optimizer from ongoing research
optimizer = partial(minimize, method='nelder-mead')
vqe = VQE(ansatz, optimizer)
result = vqe.compute_minimum_eigenvalue(operator)
SciPy' minimizers have the following interface
scipy.optimize.minimize(fun, x0, args=(), method=None, jac=None, hess=None, hessp=None, bounds=None, constraints=(), tol=None, callback=None, options=None)
Since SciPy seems to be the largest and most commonly used collection of optimizers, we would suggest to require a similar signature for optimizers that are passed to the VQE as plain callables, e.g.
minimize(f, x0, jac=None, hess=None, bounds=None, callback=None)
As a by-product, this would also further minimize the wrapping overhead the SciPy minizers.
Note: we don't suggest to remove any of the existing wrappers! If certain SciPy minimizers are frequently used, or there are compatibility issues, they should still have their own class.
If we want to use Qiskit's optimizers in context of the VQE runtime, they must be serializable.
Thus, we should add to_json
and from_json
(or to_dict/from_dict
) methods to the optimizers,
such that they can be sent along with the runtime job.
SciPy's optimizers can in principle easily be serialized, since we only need to store the
arguments to minimize
. This could be captured in a generic SciPyOptimizer
class, that holds
all arguments and implements the JSON conversions.
For execution on real backends it is crucial to group as many circuit evaluations as possible. The expecation value of VQE can in principle support an arbitrary number of grouped evaluations, the number is only limited by how many circuits the backend accepts. Hence, per default, the optimizer should try to batch as many evaluations as possible. Currently, the default value is to always batch no evaluations.
The optimizers currently return a tuple containing the optimal function value, the optimal parameters and the number of function evaluations. This is inconsistent with the return types of all algorithms (and even SciPy returns a results object) and restricts the information a optimizer may return.
For more consistency in the algorithms module and more flexibility we suggest returning a
OptimizerResult
object that contains the result.
Our current optimizers contain the entire optimization loop in a single function. It is common in machine learning packages (see e.g. PennyLane) to allow access to a step-wise optimization, i.e.
optimizer.minimize() # full optimization
optimizer.initialize()
for _ in range(maxiter):
optimizer.step(return_loss=False) # stepwise optimization
optimizer.initialize()
for _ in range(maxiter):
optimizer.step_and_loss() # stepwise optimization
This is (1) convenient to analyze intermediate states of the optimization without having to wrap logic into callbacks and integrating warm-start options, and (2) a required feature for some algorithms, e.g. QGANs, that currently implement patchy workarounds.
The above suggestions don't all need to be implemented simultaneously, rather there are two pillars of the refactoring
- refactoring VQE by making it state-less and more modular
- refactoring the optimizers to closer mimic SciPy's optimizers (including result return type)
and then there are small additional improvements on top
- serialize the optimizers
- attempt to batch-evaluate as many function evaluations as possible
- introduce step-optimizers where possible
Main changes included in this refactor
The base interface is leaned more towards SciPys optimizers.
class Optimizer:
def __init__(self, maxiter=None, callback=None):
@abstractmethod
def minimize(self, f, x0, jac=None, bounds=None) -> OptimizerResult:
where the optimization result at least contains
class OptimizerResult(AlgorithmResult): # not so sure about this inheritance?
# just listing the attributes, they can of course have getters and setters
x # optimal parameters
fun # optimal function value
nfev # number of function evaluations
Different optimizers can of course add more information and a custom result object.
Optional change 1: Serialization
Implementing serialization is straightforward. We only have to add a to_dict
method that
contains the name of the optimizer and it's settings. For instance
class Optimizer:
def to_dict(self) -> Dict[str, Any]:
return {'name': 'BFGS',
'maxiter': self.maxiter,
...}
To serialize iterators such as the learning rate (or the perturbation in SPSA) we can add a new family of serializable iterators.
class It:
def to_dict(self) -> Dict[str, Any]:
@classmethod
def from_dict(cls, settings: Dict[str, Any]) -> None:
def get_iterator(self) -> Iterator[float]:
Optional change 2: Steppable optimizers
The SteppableOptimizer
implements the minimize
method but requires a step
method to
be added.
class SteppableOptimizer(Optimizer):
@property
def optimizer_state(self) -> OptimizerState:
@abstractmethod
def step(self) -> np.ndarray: # params
def step_and_loss(self) -> Tuple[np.ndarray, float]: # params, loss
def initialize(self): # initialize stepwise optimization (needed for re-useability)
def minimize(self, f, x0, jac=None) -> OptimizerResult:
self.initialize()
for _ in range(maxiter):
x0 = self.step(f, x0)
# wrap into result and return
This is the current (public) interface of the VQE (without getters and setters):
class VQE(VariationalAlgorithm):
def __init__(self,
ansatz: Optional[QuantumCircuit] = None,
optimizer: Optional[Optimizer] = None,
initial_point: Optional[np.ndarray] = None,
gradient: Optional[Union[GradientBase, Callable]] = None,
expectation: Optional[ExpectationBase] = None,
include_custom: bool = False,
max_evals_grouped: int = 1,
callback: Optional[Callable[[int, np.ndarray, float, float], None]] = None,
quantum_instance: Optional[Union[QuantumInstance, BaseBackend, Backend]] = None) -> None:
@property
def setting(self):
def print_settings(self):
def construct_expectation(self,
parameter: Union[List[float], List[Parameter], np.ndarray],
operator: OperatorBase,
) -> OperatorBase:
def construct_circuit(self,
parameter: Union[List[float], List[Parameter], np.ndarray],
operator: OperatorBase,
) -> List[QuantumCircuit]:
@classmethod
def supports_aux_operators(cls) -> bool:
def compute_minimum_eigenvalue(
self,
operator: OperatorBase,
aux_operators: Optional[List[Optional[OperatorBase]]] = None
) -> MinimumEigensolverResult:
def get_optimal_cost(self) -> float:
def get_optimal_circuit(self) -> QuantumCircuit:
def get_optimal_vector(self) -> Union[List[float], Dict[str, int]]:
@property
def optimal_params(self) -> List[float]:
Main changes
- As
VQC
changed it's location, theVQE
is the only class deriving from theVQAlgorithm
. Therefore we suggest removing this class and merging the relevant parts into theVQE
itself. - Remove stateful arguments:
self._expect_op
: not needed if we change the energy evaluation from a private method to a getter for the energy evaluation functionget_optimal_*
: move to the results object
Optional changes
- Remove the
include_custom
in favor of just passingAerPauliExpectation
to clean up the arguments and exchange implicit actions for explicit settings - Strictly use numpy arrays for the initial points and not
List[float]
- Remove
print_settings
andsettings
(also no other algo has them) - Remove
construct_expectation
(make a private utility)
With these changes, the proposed interface is:
class VQE:
def __init__(self,
ansatz: Optional[QuantumCircuit] = None,
# MINIMIZER is a callable with proper signature
optimizer: Optional[Union[Optimizer, MINIMIZER]] = None,
initial_point: Optional[np.ndarray] = None,
gradient: Optional[Union[GradientBase, Callable]] = None,
expectation: Optional[ExpectationBase] = None,
callback: Optional[Callable[[int, np.ndarray, float, float], None]] = None,
quantum_instance: Optional[Union[QuantumInstance, BaseBackend, Backend]] = None):
# getters and setters for all the attributes
def get_energy_evaluation(self, operator) -> Callable[[np.ndarray], float]:
# construct expectations and evaluate with sampler
def supports_aux_ops(self) -> bool:
return True
def construct_circuit(self, operator):
# construct expectations and fetch circuits
# solve, minimize,
def compute_minimum_eigenvalue(self, operator):
loss = self.get_energy_evaluation(operator)
optimizer_result = self.optimizer.minimize(loss, self.initial_point)
# wrap into VQE result and return
There are some nice ideas here. Importantly we should push the refactoring of the optimizers. This does not only benefit VQE but many other locations in Qiskit such as Qiskit experiments. Here, steppable optimizers are absolutely needed since the workflow should look like:
Importantly, the user must be able to perform any action needed after the optimizer step. Can this be accommodated with the proposed refactor. All in all the steppable approach of many machine learning packages is better than the scipy black-box interface.