Source code for divi.qprog.optimizers._base
# SPDX-FileCopyrightText: 2025-2026 Qoro Quantum Ltd <divi@qoroquantum.de>
#
# SPDX-License-Identifier: Apache-2.0
import copy
from abc import ABC, abstractmethod
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
import numpy as np
import numpy.typing as npt
from scipy.optimize import OptimizeResult
if TYPE_CHECKING:
from divi.qprog.variational_quantum_algorithm import VariationalQuantumAlgorithm
[docs]
class Optimizer(ABC):
"""
Abstract base class for all optimizers.
.. warning::
**Thread Safety**: Optimizer instances are **not thread-safe**. They maintain
internal state (e.g., current population, iteration count, RNG state) that changes
during optimization.
Do **not** share a single `Optimizer` instance across multiple `QuantumProgram`
instances or threads running in parallel. Doing so will lead to race conditions,
corrupted state, and potential crashes.
If you need to use the same optimizer configuration for multiple programs,
create a separate instance for each program. You can call
:meth:`copy` to create a fresh copy with the same configuration.
"""
@property
@abstractmethod
def n_param_sets(self) -> int:
"""
Returns the number of parameter sets the optimizer can handle per optimization run.
Returns:
int: Number of parameter sets.
"""
raise NotImplementedError("This method should be implemented by subclasses.")
[docs]
@abstractmethod
def optimize(
self,
cost_fn: Callable[[npt.NDArray[np.float64]], float | npt.NDArray[np.float64]],
initial_params: npt.NDArray[np.float64] | None = None,
callback_fn: Callable[[OptimizeResult], Any] | None = None,
**kwargs,
) -> OptimizeResult:
"""Optimize the given cost function starting from initial parameters.
Parameters:
cost_fn: The cost function to minimize.
initial_params: Initial parameters for the optimization.
callback_fn: Function called after each iteration with an OptimizeResult object.
**kwargs: Additional keyword arguments for the optimizer:
- max_iterations (int, optional): Total desired number of iterations.
When resuming from a checkpoint, this represents the total iterations
desired across all runs. The optimizer will automatically calculate
and run only the remaining iterations needed.
Defaults vary by optimizer (e.g., 5 for population-based optimizers,
None for some scipy methods).
- rng (np.random.Generator, optional): Random number generator for
stochastic optimizers (PymooOptimizer, MonteCarloOptimizer).
Defaults to a new generator if not provided.
- jac (Callable, optional): Gradient/Jacobian function for
gradient-based optimizers (only used by ScipyOptimizer with
L_BFGS_B method). Defaults to None.
Returns:
OptimizeResult whose ``x`` is the single best parameter set as a 1-D
array of shape ``(n_params,)``. (The per-iteration ``callback_fn``
receives a 2-D ``x`` of shape ``(n_param_sets, n_params)``; only the
final result is 1-D.)
"""
raise NotImplementedError("This method should be implemented by subclasses.")
[docs]
@abstractmethod
def get_config(self) -> dict[str, Any]:
"""Get optimizer configuration for checkpoint reconstruction.
Returns:
dict[str, Any]: Dictionary containing optimizer type and configuration parameters.
Raises:
NotImplementedError: If the optimizer does not support checkpointing.
"""
raise NotImplementedError("This method should be implemented by subclasses.")
[docs]
@abstractmethod
def save_state(self, checkpoint_dir: Path | str) -> None:
"""Save the optimizer's internal state to a checkpoint directory.
Args:
checkpoint_dir: Directory path where the optimizer state will be saved.
"""
raise NotImplementedError("This method should be implemented by subclasses.")
[docs]
@classmethod
@abstractmethod
def load_state(cls, checkpoint_dir: Path | str) -> "Optimizer":
"""Load the optimizer's internal state from a checkpoint directory.
Creates a new optimizer instance with the state restored from the checkpoint.
Args:
checkpoint_dir: Directory path where the optimizer state is saved.
Returns:
Optimizer: A new optimizer instance with restored state.
"""
raise NotImplementedError("This method should be implemented by subclasses.")
[docs]
@abstractmethod
def reset(self) -> None:
"""Reset the optimizer's internal state to allow fresh optimization runs.
Clears any state accumulated during previous optimization runs, allowing
the optimizer to be reused for new optimization problems without creating
a new instance.
"""
raise NotImplementedError("This method should be implemented by subclasses.")
@property
def supports_checkpointing(self) -> bool:
"""Whether this optimizer can persist and restore its state mid-run.
Programs guard on this before checkpointing so an optimizer that cannot
save state fails upfront rather than mid-optimization. Optimizers whose
:meth:`save_state` raises :class:`NotImplementedError` override this to
return ``False``.
"""
return True
[docs]
def validate_program(self, program: "VariationalQuantumAlgorithm") -> None:
"""Check that this optimizer can be applied to ``program``.
Called at the start of
:meth:`~divi.qprog.VariationalQuantumAlgorithm.run`, before
any optimization, so an incompatible optimizer/program pairing fails
loudly and early. The base implementation accepts any program; override
to raise when the optimizer's requirements are not met.
"""
[docs]
def build_evaluators(
self, program: "VariationalQuantumAlgorithm"
) -> dict[str, Callable[[npt.NDArray[np.float64]], Any]]:
"""Extra per-run evaluators this optimizer needs from the program.
Called once by the variational algorithm before optimization. The
returned mapping may override ``"jac"`` and/or add ``"metric_fn"``;
keys absent from the mapping fall back to the algorithm's defaults.
The base implementation needs nothing extra and returns ``{}``.
"""
return {}
[docs]
def copy(self) -> "Optimizer":
"""Return a fresh instance with the same configuration and no accumulated
run state.
The default deep-copies the optimizer, which is correct for the stateless
optimizers whose only attributes are configuration; optimizers that
accumulate per-run state override this to rebuild from configuration alone.
.. tip::
Use this when preparing a batch of programs that will run in parallel.
Optimizer instances are not thread-safe (see the class warning); give
each program its own ``optimizer.copy()`` to avoid state contamination.
"""
return copy.deepcopy(self)
def _resolve_max_iterations(self, kwargs: dict[str, Any]) -> int:
"""Pop ``max_iterations`` (default 50); reject values < 1.
For step-based optimizers with no resume: zero steps run the loop never,
which would otherwise return a successful result with an infinite loss.
"""
max_iterations = kwargs.pop("max_iterations", None)
if max_iterations is None:
return 50
if max_iterations < 1:
raise ValueError(
f"max_iterations must be >= 1, got {max_iterations}; "
"the optimization loop performs no evaluation with zero steps."
)
return max_iterations