Source code for optimization.optimizer.scipy.least_squares

"""SciPy least-squares optimizer wrapper."""

from __future__ import annotations

import warnings
from typing import TYPE_CHECKING, Any, Literal

import optiland.backend as be
from scipy import optimize

from ..live_plotter import LiveOptimizationPlotter
from .base import OptimizerGeneric

if TYPE_CHECKING:
    from numpy.typing import ArrayLike

    from ...problem import OptimizationProblem


[docs] class LeastSquares(OptimizerGeneric): """Optimizer using SciPy's least-squares solver.""" def __init__(self, problem: OptimizationProblem): super().__init__(problem) def _compute_residuals_vector(self, x_numpy_variables_from_scipy): """ Internal function to update variables and compute the vector of residuals. 'x_numpy_variables_from_scipy' contains the current values of the optimization variables as provided by the SciPy optimizer. These are typically scaled values if variable scaling is active. """ for i, optiland_var_wrapper in enumerate(self.problem.variables): optiland_var_wrapper.update(x_numpy_variables_from_scipy[i]) self.problem.update_optics() try: residuals_backend_array = be.array( [op.fun() for op in self.problem.operands] ) # Handle cases where ray tracing might fail and produce NaNs if be.any(be.isnan(residuals_backend_array)): num_operands = len(self.problem.operands) # Return a vector of large constant values to penalize this region # The magnitude should be large enough to indicate a poor solution. error_value = be.sqrt(1e10 / num_operands if num_operands > 0 else 1e10) return be.to_numpy(be.full(num_operands, error_value)) return be.to_numpy( residuals_backend_array ) # Convert to NumPy array for SciPy except Exception: # Catch any other exceptions during optical calculation # (e.g., critical ray failure) # This is a general fallback; more specific error handling # might be beneficial. num_operands = len(self.problem.operands) error_value = be.sqrt(1e10 / num_operands if num_operands > 0 else 1e10) # Return a vector of large constant values return be.to_numpy(be.full(num_operands, error_value))
[docs] def optimize( self, maxiter: int | None = None, disp: bool = False, plot: bool = False, tol: float = 1e-3, callback: Any = None, method_choice: str = "lm", # Default to 'lm' for DLS x_scale: ArrayLike | float | Literal["jac"] | None = None, ) -> optimize.OptimizeResult: """ Optimize the problem using a SciPy least squares method. Args: maxiter (int, optional): Maximum number of function evaluations. SciPy's least_squares uses max_nfev. disp (bool, optional): Whether to display optimization progress. plot (bool, optional): If True, update live plots during optimization. tol (float, optional): Tolerance for termination (ftol - tolerance for the change in the sum of squares). Defaults to 1e-3. callback (callable, optional): Called after each optimization iteration. method_choice (str, optional): Method for scipy.optimize.least_squares. 'lm': Levenberg-Marquardt (DLS, does not support bounds). 'trf': Trust Region Reflective (supports bounds). 'dogbox': Dogleg algorithm (supports bounds). Defaults to 'lm'. x_scale (array-like, float, "jac", or None, optional): Characteristic scale of each optimizer-space variable, after any Optiland variable scaling. If set to "jac", SciPy updates the scale using inverse Jacobian column norms. If None, the argument is omitted so the installed SciPy version uses its existing default. Returns: OptimizeResult: The SciPy optimization result. """ x0_scaled_values = [var.value for var in self.problem.variables] self._x.append(list(x0_scaled_values)) x0_numpy = be.to_numpy(x0_scaled_values) current_bounds_scaled = tuple([var.bounds for var in self.problem.variables]) lower_bounds_np = be.to_numpy( [b[0] if b[0] is not None else -be.inf for b in current_bounds_scaled] ) upper_bounds_np = be.to_numpy( [b[1] if b[1] is not None else be.inf for b in current_bounds_scaled] ) num_residuals = len(self.problem.operands) num_variables = len(x0_numpy) original_method_choice = method_choice # Store for warning message # Validate and adjust method_choice if method_choice == "lm": if num_residuals < num_variables: print( f"Warning: Method 'lm' (Levenberg-Marquardt) " f"chosen, but number of residuals ({num_residuals}) is less " f"than number of variables ({num_variables}). " "This is not supported by 'lm'. Switching to 'trf' method." ) method_choice = "trf" elif be.any(lower_bounds_np != -be.inf) or be.any( upper_bounds_np != be.inf ): # This warning is for 'lm' when bounds are present but m >= n print( "Warning: Method 'lm' (Levenberg-Marquardt) chosen, " "but variable bounds are set. " "SciPy's 'lm' method does not support bounds; bounds will " "be ignored." ) elif method_choice not in ["trf", "dogbox"]: print( f"Warning: Unknown method_choice '{original_method_choice}'. " "Defaulting to 'trf' method." ) method_choice = "trf" # Determine actual_bounds_for_scipy and adjust x0 if needed based on # the *final* method_choice if ( method_choice == "lm" ): # 'lm' was originally chosen and conditions for switching were not met actual_bounds_for_scipy = (-be.inf, be.inf) else: # method_choice is 'trf' or 'dogbox' (either originally or # after adjustment) actual_bounds_for_scipy = (lower_bounds_np, upper_bounds_np) # Ensure x0 is strictly within bounds eps = be.finfo(be.float64).eps for i in range(x0_numpy.shape[0]): if lower_bounds_np[i] != -be.inf and x0_numpy[i] <= lower_bounds_np[i]: x0_numpy[i] = lower_bounds_np[i] + eps if upper_bounds_np[i] != be.inf and x0_numpy[i] >= upper_bounds_np[i]: x0_numpy[i] = upper_bounds_np[i] - eps # Clip if adjustment pushed it out of the other bound # (e.g. narrow bound range) if x0_numpy[i] > upper_bounds_np[i] and upper_bounds_np[i] != be.inf: x0_numpy[i] = upper_bounds_np[i] if x0_numpy[i] < lower_bounds_np[i] and lower_bounds_np[i] != -be.inf: x0_numpy[i] = lower_bounds_np[i] scipy_verbose_level = 1 if disp else 0 live_plotter: LiveOptimizationPlotter | None = None if plot: live_plotter = LiveOptimizationPlotter(self) live_plotter.initialize() def _wrapped_callback(*args: Any, **kwargs: Any) -> None: if callback is not None: callback(*args, **kwargs) if live_plotter is not None: live_plotter.update() least_squares_kwargs: dict[str, Any] = {} if x_scale is not None: least_squares_kwargs["x_scale"] = x_scale with warnings.catch_warnings(): warnings.simplefilter("ignore", category=RuntimeWarning) result = optimize.least_squares( self._compute_residuals_vector, x0_numpy, method=method_choice, bounds=actual_bounds_for_scipy, max_nfev=maxiter, verbose=scipy_verbose_level, ftol=tol, callback=_wrapped_callback, **least_squares_kwargs, ) for i, optiland_var_wrapper in enumerate(self.problem.variables): optiland_var_wrapper.update(result.x[i]) self.problem.update_optics() # Final update to the optical system if live_plotter is not None: live_plotter.update() live_plotter.finalize() return result