Skip to content

Instantly share code, notes, and snippets.

@Cryoris
Created May 10, 2021 14:29
Show Gist options
  • Save Cryoris/da034f29d76ced79f3f3e9d3d9e0af6c to your computer and use it in GitHub Desktop.
Save Cryoris/da034f29d76ced79f3f3e9d3d9e0af6c to your computer and use it in GitHub Desktop.
VQE and Optimizer refactor

Streamlining VQE and Optimizers

Summary

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

  1. allow re-use parts of the VQE for research
  2. allow custom optimizers and SciPy optimizers in the VQE without the overhead of wrapping them as Qiskit Optimizers
  3. make the VQE implementation state-less (like the other algorithms)

Detailed discussion

The essential current structure is kept:

optimizer = SPSA()
vqe = VQE(ansatz, optimizer)
result = vqe.compute_minimum_eigenvalue(operator)

Re-usability

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

Removing optimizer overhead

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.

Serialization of optimizers

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.

Batched evaluation

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.

Return an OptimizerResult

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.

Step-optimizations

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.

Proposed design

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

Optimizers

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

VQE

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, the VQE is the only class deriving from the VQAlgorithm. Therefore we suggest removing this class and merging the relevant parts into the VQE 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 function
    • get_optimal_*: move to the results object

Optional changes

  • Remove the include_custom in favor of just passing AerPauliExpectation 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 and settings (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
@eggerdj
Copy link

eggerdj commented Mar 10, 2022

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:

1. Setup some complex optimization task (e.g. optimize a pulse while learning a model of the system at each step)
2. Do one step of the optimizer
3. Run some experiment management (save results to a service, update a model based on the result of the 
   optimizer step, update pulses in the Calibrations, adapt number of shots, etc...)
4. Check stopping conditions and start from 2.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment