from typing import Any, Callable, Iterable, List, Optional, Union
import numpy as np
from scipy.optimize import OptimizeResult
from ._optimizer import Optimizer
def lr_schedule(
t: int,
lr: float,
T: int,
schedule: Optional[str] = None,
lr_min: float = 0.0,
gamma: float = 0.99,
power: int = 2,
):
"""
A learning rate scheduler for Rotosolve.
Args:
t: current step or epoch (0-based).
schedule: one of {"linear", "exponential", "polynomial", "cosine"}.
lr: initial learning rate.
T: total steps/epochs for schedules that need horizon
(linear, polynomial, cosine).
lr_min: minimum LR floor for linear/polynomial/cosine (default 0).
gamma: exponential decay factor per step for 'exponential' (default 0.99).
power: polynomial decay power (default 2).
Returns:
float: learning rate at step t.
"""
if schedule is None:
return lr
else:
schedule = schedule.lower()
if schedule == "exponential":
return float(lr * (gamma**t))
x = max(0.0, min(1.0, t / T))
if schedule == "linear":
return float(lr_min + (lr - lr_min) * (1.0 - x))
if schedule == "polynomial":
return float(lr_min + (lr - lr_min) * ((1.0 - x) ** power))
if schedule == "cosine":
return float(lr_min + 0.5 * (lr - lr_min) * (1.0 + np.cos(np.pi * x)))
raise ValueError("schedule must be one of {'linear','exponential','polynomial','cosine'}")
[docs]
class RotosolveOptimizer(Optimizer):
"""
Rotosolve optimizer.
See Ostaszewski et al., arXiv: 1905.09692 (2021)
This optimizer is designed for optimizing parameters in variational circuits and assumes that the
objective function is periodic, with a period of 2*pi, with respect to each parameter.
Args:
maxiter: maximum number of Rotosolve macroiterations
tol: convergence criterion
verbose: print verbose output every macroiteration and every microiteration
"""
supports_bounds = False
def __init__(
self,
maxiter=100,
tol=1e-7,
lr=1,
schedule=None,
gamma=0.99,
power=2,
verbose=False,
flat_tol=1e-10,
max_restarts=3,
kick_size=0.1,
):
self.maxiter = maxiter
self.tol = tol
self.verbose = verbose
# Coordinate descent can converge to a coordinate-wise saddle: a point
# where every single parameter is individually optimal but a joint move
# lowers the cost (the all-zero start of a hardware-efficient ansatz is
# the canonical example — there ``m_φ1 == m_φ2`` exactly by symmetry, so
# rotosolve keeps every coordinate put). On convergence we apply up to
# ``max_restarts`` deterministic symmetry-breaking kicks of magnitude
# ``kick_size`` (rad) from the best point seen and re-optimise, keeping
# the best result. A genuine minimum is recovered unchanged (the kick is
# rejected); a saddle is escaped.
self.max_restarts = max_restarts
self.kick_size = kick_size
# A parameter is "flat" when both arguments of the arctan2 update are
# below this threshold — i.e. the three sampled costs are equal to
# within numerical noise, so the analytic update direction is decided
# by floating-point rounding rather than signal. Such updates are
# skipped (see ``minimize``). Chosen well above statevector roundoff
# (~1e-16) and well below any real energy variation (~1e-2).
self.flat_tol = flat_tol
# qarpx parametric gates use radians (Rz(θ) rotates by θ rad).
# The shift / constant terms in the update formula are scaled by
# this angle so the optimizer steps over a full 2π period.
self.PI_ANGLE = np.pi
self.schedule = lambda t: lr_schedule(
t, lr=lr, T=self.maxiter, schedule=schedule, lr_min=1e-5, gamma=gamma, power=power
)
[docs]
def minimize(
self,
objective_function: Callable,
initial_parameters: Union[List, np.ndarray],
callback: Optional[Callable] = None,
gradient: Optional[Callable] = None,
tol: Optional[float] = None,
bounds: Optional[Iterable] = None,
) -> Any:
"""
Minimize the objective function provided, starting at the initial parameters.
Args:
objective_function: Function to minimize.
initial_parameters: Initial values of parameters. It is usually best to start from a zero vector.
callback: An optional callable to call with the parameters after each update (as in scipy optimizers).
gradient: Must be None.
tol: Must be None — the convergence criterion is the constructor's ``tol``.
bounds: Must be None — the update rule assumes an unbounded 2π-periodic domain.
Returns:
A SciPy Result object.
"""
if tol is not None:
raise ValueError(
f"{type(self).__name__} does not support the tol argument; "
"set the convergence criterion via the constructor's tol."
)
if bounds is not None:
raise ValueError(f"{type(self).__name__} does not support the bounds argument.")
x = np.asarray(initial_parameters, dtype=float).copy()
num_params = x.shape[0]
f = objective_function
results = OptimizeResult()
results.success = False
results.x = x
prev_val = None
best_val: Optional[float] = None
best_x = np.copy(x)
restarts = 0
if self.verbose:
print("Rotosolve")
print(f"Number of parameters: {num_params}")
print(" Iter\tMicroIter\tEnergy\t\tDifference")
for num_it in range(self.maxiter):
lr_t = self.schedule(num_it)
for d in range(num_params):
phi = x[d]
x_temp = np.copy(x)
x_temp[d] = phi
m_phi = f(x_temp)
if prev_val is None:
prev_val = m_phi
if self.verbose:
print(f"{num_it:6}\t\t{d:6}\t\t{m_phi:.6f}\t")
x_temp[d] = phi + self.PI_ANGLE / 2
m_phi1 = f(x_temp)
x_temp[d] = phi - self.PI_ANGLE / 2
m_phi2 = f(x_temp)
# eq. (1) from arXiv: 1905.09692
numerator = 2 * m_phi - m_phi1 - m_phi2
denominator = m_phi1 - m_phi2
# Skip parameters the cost is (numerically) flat in: there both
# arctan2 arguments are pure rounding noise, so the analytic
# update points in a random direction. At symmetric starts
# (e.g. all-zero parameters, where a layer's phase/Rz gates leave
# |0…0⟩ unchanged) this stalls the optimizer. Leaving the
# parameter put is exact — a flat direction has no preferred
# angle — and it becomes informative once a neighbour moves.
if max(abs(numerator), abs(denominator)) < self.flat_tol:
continue
x[d] = phi - lr_t * (
self.PI_ANGLE / 2 + np.arctan2(numerator, denominator) * self.PI_ANGLE / np.pi
)
new_val = f(x)
results.nit = num_it
if best_val is None or new_val < best_val:
best_val = new_val
best_x = np.copy(x)
if callback is not None:
callback(x)
if prev_val is not None:
if self.verbose:
print(f"{num_it:6}\t \t\t{new_val:.6f}\t\t{abs(new_val - prev_val):.6f}")
if abs(new_val - prev_val) < self.tol:
if restarts < self.max_restarts:
# Possible coordinate-wise saddle — kick the best point
# along a deterministic direction and re-optimise.
restarts += 1
x = best_x + self._symmetry_break(num_params, restarts)
prev_val = f(x)
continue
results.success = True
break
prev_val = new_val
# Return the best point seen across all restarts (a kick that failed to
# improve leaves the genuine minimum as the best; one that escaped a
# saddle becomes the new best).
results.x = best_x
results.fun = best_val if best_val is not None else results.fun
return results
def _symmetry_break(self, num_params: int, restart: int) -> np.ndarray:
"""Deterministic small perturbation to break coordinate-wise symmetry.
Varies per coordinate and per restart (no RNG, so runs are
reproducible). Magnitude is bounded by ``kick_size``.
"""
k = np.arange(num_params)
return self.kick_size * np.cos(k + 1.0 + restart * (np.pi / 3.0))