from __future__ import annotations
from typing import Any, Callable, Collection, Optional, TypeVar, Union
import numpy as np
import numpy.typing as npt
from scipy.optimize import OptimizeResult
from scipy.optimize import minimize as scipy_minimize
from sympy import Expr
from typeguard import typechecked
from mpqp.core.circuit import QCircuit
from mpqp.execution.devices import AvailableDevice
from mpqp.execution.runner import _run_single # pyright: ignore[reportPrivateUsage]
from mpqp.execution.vqa.optimizer import Optimizer
T1 = TypeVar("T1")
T2 = TypeVar("T2")
OptimizerInput = Union[list[float], npt.NDArray[np.float32]]
OptimizableFunc = Callable[[OptimizerInput], float]
OptimizerCallable = Callable[
[OptimizableFunc, Optional[OptimizerInput]], tuple[float, OptimizerInput]
]
# TODO: all those functions with almost or exactly the same signature look like
# a code smell to me.
# TODO: test the minimizer options
def _maps(l1: Collection[T1], l2: Collection[T2]) -> dict[T1, T2]:
"""Does like zip, but with a dictionary instead of a list of tuples"""
assert len(l1) == len(l2)
return {e1: e2 for e1, e2 in zip(l1, l2)}
[docs]@typechecked
def minimize(
optimizable: QCircuit | OptimizableFunc,
method: Optimizer | OptimizerCallable,
device: Optional[AvailableDevice] = None,
init_params: Optional[OptimizerInput] = None,
nb_params: Optional[int] = None,
optimizer_options: Optional[dict[str, Any]] = None,
) -> tuple[float, OptimizerInput]:
"""This function runs an optimization on the parameters of the circuit, in order to
minimize the measured expectation value of observables associated with the given circuit.
Note that this means that the latter should contain an ``ExpectationMeasure``.
Args:
optimizable: Either the circuit, containing symbols and an expectation
measure, or the evaluation function.
method: The method used to optimize most of those methods come from
``scipy``. If the choices offered in this package are not
covering your needs, you can define your own optimizer. This should be
a function taking as input a function representing the circuit, with
as many inputs as the circuit has parameters, and any optional
initialization parameters, and returning the optimal value reached
and the parameters used to reach this value.
device: The device on which the circuit should be run.
init_params: The optional initialization parameters (the value
attributed to the symbols in the first loop of the optimizer).
nb_params: Number of variables to input in ``optimizable``. It is only
useful if ``optimizable`` is a Callable and if ``init_params`` was
not given. If not this argument is not taken into account.
optimizer_options: Options used to configure the VQA optimizer (maximum
iterations, convergence threshold, etc...). These options are passed
as is to the minimizer.
Returns:
The optimal value reached and the parameters corresponding to this value.
Examples:
>>> alpha, beta = symbols("α β")
>>> circuit = QCircuit([
... H(0),
... Rx(alpha, 1),
... CNOT(1,0),
... Rz(beta, 0),
... ExpectationMeasure(
... [0,1],
... observable=Observable(np.diag([1,2,-3,4])),
... shots=0,
... ),
... ])
>>> minimize(
... circuit,
... Optimizer.BFGS,
... ATOSDevice.MYQLM_PYLINALG,
... optimizer_options={"maxiter":50},
... )
(-0.9999999999999996, array([0., 0.]))
>>> def cost_func(params):
... run_res = run(
... circuit,
... ATOSDevice.MYQLM_PYLINALG,
... {alpha: params[0], beta: params[1]}
... )
... return 1 - run_res.expectation_value ** 2
>>> minimize(
... cost_func,
... Optimizer.BFGS,
... nb_params=2,
... optimizer_options={"maxiter":50},
... )
(8.881784197001252e-16, array([0., 0.]))
"""
if isinstance(optimizable, QCircuit):
if device is None:
raise ValueError("A device is needed to optimize a circuit")
optimizer = _minimize_remote if device.is_remote() else _minimize_local
return optimizer(optimizable, method, device, init_params, nb_params)
else:
# TODO: find a way to know if the job is remote or local from the function
return _minimize_local(
optimizable, method, device, init_params, nb_params, optimizer_options
)
@typechecked
def _minimize_remote(
optimizable: QCircuit | OptimizableFunc,
method: Optimizer | OptimizerCallable,
device: Optional[AvailableDevice] = None,
init_params: Optional[OptimizerInput] = None,
nb_params: Optional[int] = None,
optimizer_options: Optional[dict[str, Any]] = None,
) -> tuple[float, OptimizerInput]:
"""This function runs an optimization on the parameters of the circuit, to
minimize the expectation value of the measure of the circuit by it's
observables. Note that this means that the circuit should contain an
expectation measure.
Args:
optimizable: Either the circuit, containing symbols and an expectation
measure, or the evaluation function.
method: The method used to optimize most of those methods come from
either scipy or cma. If the choice offered in this package are not
covering your needs, you can define your own optimizer. It should be
a function taking as input a function representing the circuit, with
as many inputs as the circuit has parameters, as well as optional
initialization parameters, and returning the optimal value reached
and the parameters used to reach this value.
device: The device on which the circuit should be run.
init_params: The optional initialization parameters (the value
attributed to the symbols in the first loop of the optimizer).
nb_params: number of variables to input in ``optimizable``. It is only
useful if ``optimizable`` is a Callable and if ``init_params`` was
not given. If not this argument is not taken into account.
optimizer_options: Options used to configure the VQA optimizer (maximum
iterations, convergence threshold, etc...). These options are passed
as is to the minimizer.
Returns:
The optimal value reached and the parameters used to reach this value.
TODO to implement on QLM first
"""
raise NotImplementedError()
@typechecked
def _minimize_local(
optimizable: QCircuit | OptimizableFunc,
method: Optimizer | OptimizerCallable,
device: Optional[AvailableDevice] = None,
init_params: Optional[OptimizerInput] = None,
nb_params: Optional[int] = None,
optimizer_options: Optional[dict[str, Any]] = None,
) -> tuple[float, OptimizerInput]:
"""This function runs an optimization on the parameters of the circuit, to
minimize the expectation value of the measure of the circuit by it's
observables. Note that this means that the circuit should contain an
expectation measure.
Args:
optimizable: Either the circuit, containing symbols and an expectation
measure, or the evaluation function.
method: The method used to optimize most of those methods come from
either scipy or cma. If the choice offered in this package are not
covering your needs, you can define your own optimizer. It should be
a function taking as input a function representing the circuit, with
as many inputs as the circuit has parameters, as well as optional
initialization parameters, and returning the optimal value reached
and the parameters used to reach this value.
device: The device on which the circuit should be run.
init_params: The optional initialization parameters (the value
attributed to the symbols in the first loop of the optimizer).
nb_params: number of variables to input in ``optimizable``. It is only
useful if ``optimizable`` is a Callable and if ``init_params`` was
not given. If not this argument is not taken into account.
optimizer_options: Options used to configure the VQA optimizer (maximum
iterations, convergence threshold, etc...). These options are passed
as is to the minimizer.
Returns:
the optimal value reached and the parameters used to reach this value.
"""
if isinstance(optimizable, QCircuit):
if device is None:
raise ValueError("A device is needed to optimize a circuit")
return _minimize_local_circ(
optimizable, device, method, init_params, optimizer_options
)
else:
return _minimize_local_func(
optimizable, method, init_params, nb_params, optimizer_options
)
@typechecked
def _minimize_local_circ(
circ: QCircuit,
device: AvailableDevice,
method: Optimizer | OptimizerCallable,
init_params: Optional[OptimizerInput] = None,
optimizer_options: Optional[dict[str, Any]] = None,
) -> tuple[float, OptimizerInput]:
"""This function runs an optimization on the parameters of the circuit, to
minimize the expectation value of the measure of the circuit by it's
observables. Note that this means that the circuit should contain an
expectation measure!
Args:
circ: Either the circuit, containing symbols and an expectation measure.
method: The method used to optimize most of those methods come from
either scipy or cma. If the choice offered in this package are not
covering your needs, you can define your own optimizer. It should be
a function taking as input a function representing the circuit, with
as many inputs as the circuit has parameters, as well as optional
initialization parameters, and returning the optimal value reached
and the parameters used to reach this value.
device: The device on which the circuit should be run.
init_params: The optional initialization parameters (the value
attributed to the symbols in the first loop of the optimizer).
optimizer_options: Options used to configure the VQA optimizer (maximum
iterations, convergence threshold, etc...). These options are passed
as is to the minimizer.
Returns:
The optimal value reached and the parameters used to reach this value.
"""
# The sympy `free_symbols` method returns in fact sets of Basic, which
# are theoretically different from Expr, but in our case the difference
# is not relevant.
variables: set[Expr] = circ.variables() # type: ignore
def eval_circ(params: OptimizerInput):
# pyright is bad with abstract numeric types:
# "float" is incompatible with "Complex"
return _run_single(
circ,
device,
_maps(variables, params), # pyright: ignore[reportArgumentType]
).expectation_value
return _minimize_local_func(
eval_circ, method, init_params, len(variables), optimizer_options
)
@typechecked
def _minimize_local_func(
eval_func: OptimizableFunc,
method: Optimizer | OptimizerCallable,
init_params: Optional[OptimizerInput] = None,
nb_params: Optional[int] = None,
optimizer_options: Optional[dict[str, Any]] = None,
) -> tuple[float, OptimizerInput]:
"""This function runs an optimization on the parameters of the circuit, to
minimize the expectation value of the measure of the circuit by it's
observables. Note that this means that the circuit should contain an
expectation measure!
Args:
eval_func: Evaluation function.
method: The method used to optimize most of those methods come from
either scipy or cma. If the choice offered in this package are not
covering your needs, you can define your own optimizer. It should be
a function taking as input a function representing the circuit, with
as many inputs as the circuit has parameters, as well as optional
initialization parameters, and returning the optimal value reached
and the parameters used to reach this value.
init_params: The optional initialization parameters (the value
attributed to the symbols in the first loop of the optimizer).
nb_params: number of variables to input in ``optimizable``. It is only
useful if ``init_params`` was not given. If not this argument is not
taken into account.
optimizer_options: Options used to configure the VQA optimizer (maximum
iterations, convergence threshold, etc...). These options are passed
as is to the minimizer.
Returns:
The optimal value reached and the parameters used to reach this value.
"""
if init_params is None:
if nb_params is None:
raise ValueError(
"Please provide either a set of initialization parameters or "
"the number of parameters expected by the function."
)
else:
init_params = [0.0] * nb_params
if isinstance(method, Optimizer):
assert method is not None
res: OptimizeResult = scipy_minimize(
eval_func,
x0=np.array(init_params),
method=method.name.lower(),
options=optimizer_options,
)
return res.fun, res.x
else:
return method(eval_func, init_params)