Repository: Qiskit/qiskit

Issue:
Allow callables as optimizers in ``VQE``
### What is the expected enhancement

After #6382, allow callables with the same signature as `Optimizer.minimize` as `optimizer` in the VQE. This allows a much more flexible usage of the algorithm and get's rid of the overhead of implementing a new class each time users want to try their/other optimizers we don't yet have implemented.


Discussion:

Resolving patch:
diff --git a/qiskit/algorithms/minimum_eigen_solvers/qaoa.py b/qiskit/algorithms/minimum_eigen_solvers/qaoa.py
--- a/qiskit/algorithms/minimum_eigen_solvers/qaoa.py
+++ b/qiskit/algorithms/minimum_eigen_solvers/qaoa.py
@@ -24,7 +24,7 @@
 from qiskit.utils.quantum_instance import QuantumInstance
 from qiskit.utils.validation import validate_min
 from qiskit.circuit.library.n_local.qaoa_ansatz import QAOAAnsatz
-from qiskit.algorithms.minimum_eigen_solvers.vqe import VQE
+from qiskit.algorithms.minimum_eigen_solvers.vqe import VQE, MINIMIZER
 
 
 class QAOA(VQE):
@@ -55,7 +55,7 @@ class QAOA(VQE):
 
     def __init__(
         self,
-        optimizer: Optimizer = None,
+        optimizer: Optional[Union[Optimizer, MINIMIZER]] = None,
         reps: int = 1,
         initial_state: Optional[QuantumCircuit] = None,
         mixer: Union[QuantumCircuit, OperatorBase] = None,
@@ -69,7 +69,8 @@ def __init__(
     ) -> None:
         """
         Args:
-            optimizer: A classical optimizer.
+            optimizer: A classical optimizer, see also :class:`~qiskit.algorithms.VQE` for
+                more details on the possible types.
             reps: the integer parameter :math:`p` as specified in https://arxiv.org/abs/1411.4028,
                 Has a minimum valid value of 1.
             initial_state: An optional initial state to prepend the QAOA circuit with
diff --git a/qiskit/algorithms/minimum_eigen_solvers/vqe.py b/qiskit/algorithms/minimum_eigen_solvers/vqe.py
--- a/qiskit/algorithms/minimum_eigen_solvers/vqe.py
+++ b/qiskit/algorithms/minimum_eigen_solvers/vqe.py
@@ -20,6 +20,7 @@
 import warnings
 from time import time
 import numpy as np
+import scipy
 
 from qiskit.circuit import QuantumCircuit, Parameter
 from qiskit.circuit.library import RealAmplitudes
@@ -40,7 +41,7 @@
 from qiskit.utils.backend_utils import is_aer_provider
 from qiskit.utils import QuantumInstance, algorithm_globals
 from ..list_or_dict import ListOrDict
-from ..optimizers import Optimizer, SLSQP
+from ..optimizers import Optimizer, SLSQP, OptimizerResult
 from ..variational_algorithm import VariationalAlgorithm, VariationalResult
 from .minimum_eigen_solver import MinimumEigensolver, MinimumEigensolverResult
 from ..exceptions import AlgorithmError
@@ -48,6 +49,22 @@
 logger = logging.getLogger(__name__)
 
 
+OBJECTIVE = Callable[[np.ndarray], float]
+GRADIENT = Callable[[np.ndarray], np.ndarray]
+RESULT = Union[scipy.optimize.OptimizeResult, OptimizerResult]
+BOUNDS = List[Tuple[float, float]]
+
+MINIMIZER = Callable[
+    [
+        OBJECTIVE,  # the objective function to minimize (the energy in the case of the VQE)
+        np.ndarray,  # the initial point for the optimization
+        Optional[GRADIENT],  # the gradient of the objective function
+        Optional[BOUNDS],  # parameters bounds for the optimization
+    ],
+    RESULT,  # a result object (either SciPy's or Qiskit's)
+]
+
+
 class VQE(VariationalAlgorithm, MinimumEigensolver):
     r"""The Variational Quantum Eigensolver algorithm.
 
@@ -81,12 +98,46 @@ class VQE(VariationalAlgorithm, MinimumEigensolver):
     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 optimizer can either be one of Qiskit's optimizers, such as
+    :class:`~qiskit.algorithms.optimizers.SPSA` or a callable with the following signature:
+
+    .. note::
+
+        The callable _must_ have the argument names ``fun, x0, jac, bounds`` as indicated
+        in the following code block.
+
+    .. code-block::python
+
+        from qiskit.algorithms.optimizers import OptimizerResult
+
+        def my_minimizer(fun, x0, jac=None, bounds=None) -> OptimizerResult:
+            # Note that the callable *must* have these argument names!
+            # Args:
+            #     fun (callable): the function to minimize
+            #     x0 (np.ndarray): the initial point for the optimization
+            #     jac (callable, optional): the gradient of the objective function
+            #     bounds (list, optional): a list of tuples specifying the parameter bounds
+
+            result = OptimizerResult()
+            result.x = # optimal parameters
+            result.fun = # optimal function value
+            return result
+
+    The above signature also allows to directly pass any SciPy minimizer, for instance as
+
+    .. code-block::python
+
+        from functools import partial
+        from scipy.optimize import minimize
+
+        optimizer = partial(minimize, method="L-BFGS-B")
+
     """
 
     def __init__(
         self,
         ansatz: Optional[QuantumCircuit] = None,
-        optimizer: Optional[Optimizer] = None,
+        optimizer: Optional[Union[Optimizer, MINIMIZER]] = None,
         initial_point: Optional[np.ndarray] = None,
         gradient: Optional[Union[GradientBase, Callable]] = None,
         expectation: Optional[ExpectationBase] = None,
@@ -99,7 +150,8 @@ def __init__(
 
         Args:
             ansatz: A parameterized circuit used as Ansatz for the wave function.
-            optimizer: A classical optimizer.
+            optimizer: A classical optimizer. Can either be a Qiskit optimizer or a callable
+                that takes an array as input and returns a Qiskit or SciPy optimization result.
             initial_point: An optional initial point (i.e. initial parameter values)
                 for the optimizer. If ``None`` then VQE will look to the ansatz for a preferred
                 point and if not will simply compute a random one.
@@ -298,7 +350,9 @@ def optimizer(self, optimizer: Optional[Optimizer]):
         if optimizer is None:
             optimizer = SLSQP()
 
-        optimizer.set_max_evals_grouped(self.max_evals_grouped)
+        if isinstance(optimizer, Optimizer):
+            optimizer.set_max_evals_grouped(self.max_evals_grouped)
+
         self._optimizer = optimizer
 
     @property
@@ -333,7 +387,10 @@ def print_settings(self):
         else:
             ret += "ansatz has not been set"
         ret += "===============================================================\n"
-        ret += f"{self._optimizer.setting}"
+        if callable(self.optimizer):
+            ret += "Optimizer is custom callable\n"
+        else:
+            ret += f"{self._optimizer.setting}"
         ret += "===============================================================\n"
         return ret
 
@@ -534,26 +591,31 @@ def compute_minimum_eigenvalue(
 
         start_time = time()
 
-        # keep this until Optimizer.optimize is removed
-        try:
-            opt_result = self.optimizer.minimize(
+        if callable(self.optimizer):
+            opt_result = self.optimizer(  # pylint: disable=not-callable
                 fun=energy_evaluation, x0=initial_point, jac=gradient, bounds=bounds
             )
-        except AttributeError:
-            # self.optimizer is an optimizer with the deprecated interface that uses
-            # ``optimize`` instead of ``minimize```
-            warnings.warn(
-                "Using an optimizer that is run with the ``optimize`` method is "
-                "deprecated as of Qiskit Terra 0.19.0 and will be unsupported no "
-                "sooner than 3 months after the release date. Instead use an optimizer "
-                "providing ``minimize`` (see qiskit.algorithms.optimizers.Optimizer).",
-                DeprecationWarning,
-                stacklevel=2,
-            )
-
-            opt_result = self.optimizer.optimize(
-                len(initial_point), energy_evaluation, gradient, bounds, initial_point
-            )
+        else:
+            # keep this until Optimizer.optimize is removed
+            try:
+                opt_result = self.optimizer.minimize(
+                    fun=energy_evaluation, x0=initial_point, jac=gradient, bounds=bounds
+                )
+            except AttributeError:
+                # self.optimizer is an optimizer with the deprecated interface that uses
+                # ``optimize`` instead of ``minimize```
+                warnings.warn(
+                    "Using an optimizer that is run with the ``optimize`` method is "
+                    "deprecated as of Qiskit Terra 0.19.0 and will be unsupported no "
+                    "sooner than 3 months after the release date. Instead use an optimizer "
+                    "providing ``minimize`` (see qiskit.algorithms.optimizers.Optimizer).",
+                    DeprecationWarning,
+                    stacklevel=2,
+                )
+
+                opt_result = self.optimizer.optimize(
+                    len(initial_point), energy_evaluation, gradient, bounds, initial_point
+                )
 
         eval_time = time() - start_time
 


Test patch:
diff --git a/test/python/algorithms/test_qaoa.py b/test/python/algorithms/test_qaoa.py
--- a/test/python/algorithms/test_qaoa.py
+++ b/test/python/algorithms/test_qaoa.py
@@ -15,10 +15,12 @@
 import unittest
 from test.python.algorithms import QiskitAlgorithmsTestCase
 
+from functools import partial
 import math
 import numpy as np
-import retworkx as rx
+from scipy.optimize import minimize as scipy_minimize
 from ddt import ddt, idata, unpack
+import retworkx as rx
 
 from qiskit.algorithms import QAOA
 from qiskit.algorithms.optimizers import COBYLA, NELDER_MEAD
@@ -309,6 +311,15 @@ def test_qaoa_construct_circuit_update(self):
         circ4 = qaoa.construct_circuit([0, 0], I ^ Z)[0]
         self.assertEqual(circ4, ref)
 
+    def test_optimizer_scipy_callable(self):
+        """Test passing a SciPy optimizer directly as callable."""
+        qaoa = QAOA(
+            optimizer=partial(scipy_minimize, method="Nelder-Mead", options={"maxiter": 2}),
+            quantum_instance=self.statevector_simulator,
+        )
+        result = qaoa.compute_minimum_eigenvalue(Z)
+        self.assertEqual(result.cost_function_evals, 4)
+
     def _get_operator(self, weight_matrix):
         """Generate Hamiltonian for the max-cut problem of a graph.
 
diff --git a/test/python/algorithms/test_vqe.py b/test/python/algorithms/test_vqe.py
--- a/test/python/algorithms/test_vqe.py
+++ b/test/python/algorithms/test_vqe.py
@@ -18,6 +18,7 @@
 
 from functools import partial
 import numpy as np
+from scipy.optimize import minimize as scipy_minimize
 from ddt import data, ddt, unpack
 
 from qiskit import BasicAer, QuantumCircuit
@@ -31,6 +32,7 @@
     SLSQP,
     SPSA,
     TNC,
+    OptimizerResult,
 )
 from qiskit.circuit.library import EfficientSU2, RealAmplitudes, TwoLocal
 from qiskit.exceptions import MissingOptionalLibraryError
@@ -69,6 +71,16 @@ def run(self, dag):
         logging.getLogger(logger).info(self.message)
 
 
+# pylint: disable=invalid-name, unused-argument
+def _mock_optimizer(fun, x0, jac=None, bounds=None) -> OptimizerResult:
+    """A mock of a callable that can be used as minimizer in the VQE."""
+    result = OptimizerResult()
+    result.x = np.zeros_like(x0)
+    result.fun = fun(result.x)
+    result.nit = 0
+    return result
+
+
 @ddt
 class TestVQE(QiskitAlgorithmsTestCase):
     """Test VQE"""
@@ -489,6 +501,24 @@ def test_set_optimizer_to_none(self):
         vqe.optimizer = None
         self.assertIsInstance(vqe.optimizer, SLSQP)
 
+    def test_optimizer_scipy_callable(self):
+        """Test passing a SciPy optimizer directly as callable."""
+        vqe = VQE(
+            optimizer=partial(scipy_minimize, method="L-BFGS-B", options={"maxiter": 2}),
+            quantum_instance=self.statevector_simulator,
+        )
+        result = vqe.compute_minimum_eigenvalue(Z)
+        self.assertEqual(result.cost_function_evals, 20)
+
+    def test_optimizer_callable(self):
+        """Test passing a optimizer directly as callable."""
+        ansatz = RealAmplitudes(1, reps=1)
+        vqe = VQE(
+            ansatz=ansatz, optimizer=_mock_optimizer, quantum_instance=self.statevector_simulator
+        )
+        result = vqe.compute_minimum_eigenvalue(Z)
+        self.assertTrue(np.all(result.optimal_point == np.zeros(ansatz.num_parameters)))
+
     def test_aux_operators_list(self):
         """Test list-based aux_operators."""
         wavefunction = self.ry_wavefunction