Source code for gemseo.algos.opt.base_optimization_library

# Copyright 2021 IRT Saint Exupéry, https://www.irt-saintexupery.com
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
# Contributors:
#    INITIAL AUTHORS - initial API and implementation and/or initial
#                           documentation
#        :author: Damien Guenot
#        :author: Francois Gallard, refactoring
#    OTHER AUTHORS   - MACROSCOPIC CHANGES
"""Base class for libraries of optimizers."""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Any
from typing import ClassVar
from typing import Final

import numpy
from numpy import isinf

from gemseo.algos._unsuitability_reason import _UnsuitabilityReason
from gemseo.algos.base_driver_library import BaseDriverLibrary
from gemseo.algos.base_driver_library import DriverDescription
from gemseo.algos.opt.base_optimizer_settings import BaseOptimizerSettings
from gemseo.algos.stop_criteria import DesignToleranceTester
from gemseo.algos.stop_criteria import KKTConditionsTester
from gemseo.algos.stop_criteria import ObjectiveToleranceTester
from gemseo.algos.stop_criteria import kkt_residual_computation

if TYPE_CHECKING:
    from numpy import ndarray

    from gemseo.algos.optimization_problem import OptimizationProblem
    from gemseo.core.mdo_functions.mdo_function import MDOFunction


[docs] @dataclass class OptimizationAlgorithmDescription(DriverDescription): """The description of an optimization algorithm.""" handle_equality_constraints: bool = False """Whether the optimization algorithm handles equality constraints.""" handle_inequality_constraints: bool = False """Whether the optimization algorithm handles inequality constraints.""" handle_multiobjective: bool = False """Whether the optimization algorithm handles multiple objectives.""" positive_constraints: bool = False """Whether the optimization algorithm requires positive constraints.""" for_linear_problems: bool = False """Whether the optimization algorithm is dedicated to linear problems.""" require_gradient: bool = False """Whether the optimization algorithm requires the gradient.""" Settings: type[BaseOptimizerSettings] = BaseOptimizerSettings """The settings validation model."""
[docs] class BaseOptimizationLibrary(BaseDriverLibrary): """Base class for libraries of optimizers. Typically used as: #. Instantiate an :class:`.BaseOptimizationLibrary`. #. Select the algorithm with :attr:`._algo_name`. #. Solve an :class:`.OptimizationProblem` with :meth:`.execute`. Notes: The missing current values of the :class:`.DesignSpace` attached to the :class:`.OptimizationProblem` are automatically initialized with the method :meth:`.DesignSpace.initialize_missing_current_values`. """ # Option names _F_TOL_REL: Final[str] = "ftol_rel" _F_TOL_ABS: Final[str] = "ftol_abs" _KKT_TOL_ABS: Final[str] = "kkt_tol_abs" _KKT_TOL_REL: Final[str] = "kkt_tol_rel" _MAX_ITER: Final[str] = "max_iter" _SCALING_THRESHOLD: Final[str] = "scaling_threshold" _STOP_CRIT_NX: Final[str] = "stop_crit_n_x" _X_TOL_REL: Final[str] = "xtol_rel" _X_TOL_ABS: Final[str] = "xtol_abs" _f_tol_tester: ObjectiveToleranceTester """A tester for the termination criterion associated to the objective function.""" _x_tol_tester: DesignToleranceTester """A tester for the termination criterion associated to the design variables.""" ALGORITHM_INFOS: ClassVar[dict[str, OptimizationAlgorithmDescription]] = {} """The description of the algorithms contained in the library.""" def __init__(self, algo_name: str) -> None: # noqa:D107 super().__init__(algo_name) self._f_tol_tester = ObjectiveToleranceTester() self._x_tol_tester = DesignToleranceTester() def _check_constraints_handling(self, problem: OptimizationProblem) -> None: """Check if problem and algorithm are consistent for constraints handling.""" algo_name = self._algo_name if ( tuple(problem.constraints.get_equality_constraints()) and not self.ALGORITHM_INFOS[algo_name].handle_equality_constraints ): msg = ( "Requested optimization algorithm " f"{algo_name} can not handle equality constraints." ) raise ValueError(msg) if ( tuple(problem.constraints.get_inequality_constraints()) and not self.ALGORITHM_INFOS[algo_name].handle_inequality_constraints ): msg = ( "Requested optimization algorithm " f"{algo_name} can not handle inequality constraints." ) raise ValueError(msg) def _get_right_sign_constraints(self, problem: OptimizationProblem): """Transform the problem constraints into their opposite sign counterpart. This is done if the algorithm requires positive constraints. Args: problem: The problem to be solved. Returns: The constraints with the right sign. """ if ( tuple(problem.constraints.get_inequality_constraints()) and self.ALGORITHM_INFOS[self._algo_name].positive_constraints ): return [-constraint for constraint in problem.constraints] return problem.constraints def _pre_run(self, problem: OptimizationProblem, **settings: Any) -> None: super()._pre_run(problem, **settings) self._check_constraints_handling(problem) n_points = settings[self._STOP_CRIT_NX] self._f_tol_tester = ObjectiveToleranceTester( absolute=settings[self._F_TOL_ABS], relative=settings[self._F_TOL_REL], n_last_iterations=n_points, ) self._x_tol_tester = DesignToleranceTester( absolute=settings[self._X_TOL_ABS], relative=settings[self._X_TOL_REL], n_last_iterations=n_points, ) self._init_iter_observer(problem, settings[self._MAX_ITER]) require_gradient = self.ALGORITHM_INFOS[self._algo_name].require_gradient if require_gradient: kkt_abs_tol = settings[self._KKT_TOL_ABS] kkt_rel_tol = settings[self._KKT_TOL_REL] if not isinf(kkt_abs_tol) or not isinf(kkt_rel_tol): problem.add_listener( _KKTChecker( problem, kkt_abs_tol, kkt_rel_tol, settings[self._INEQ_TOLERANCE], ), at_each_iteration=False, at_each_function_call=True, ) problem.design_space.initialize_missing_current_values() if problem.differentiation_method == self.DifferentiationMethod.COMPLEX_STEP: problem.design_space.to_complex() # First, evaluate all functions at x_0. Some algorithms don't do this output_functions, jacobian_functions = problem.get_functions( jacobian_names=() if require_gradient else None, observable_names=None, ) function_values, _ = problem.evaluate_functions( design_vector_is_normalized=self._normalize_ds, output_functions=output_functions or None, jacobian_functions=jacobian_functions or None, ) scaling_threshold = settings[self._SCALING_THRESHOLD] if scaling_threshold is not None: self._problem.objective = self.__scale( self._problem.objective, function_values[self._problem.objective.name], scaling_threshold, ) self._problem.constraints = [ self.__scale( constraint, function_values[constraint.name], scaling_threshold ) for constraint in self._problem.constraints ] @classmethod def _get_unsuitability_reason( cls, algorithm_description: OptimizationAlgorithmDescription, problem: OptimizationProblem, ) -> _UnsuitabilityReason: reason = super()._get_unsuitability_reason(algorithm_description, problem) if reason: return reason if ( tuple(problem.constraints.get_equality_constraints()) and not algorithm_description.handle_equality_constraints ): return _UnsuitabilityReason.EQUALITY_CONSTRAINTS if ( tuple(problem.constraints.get_inequality_constraints()) and not algorithm_description.handle_inequality_constraints ): return _UnsuitabilityReason.INEQUALITY_CONSTRAINTS if not problem.is_linear and algorithm_description.for_linear_problems: return _UnsuitabilityReason.NON_LINEAR_PROBLEM return reason def _new_iteration_callback(self, x_vect: ndarray) -> None: super()._new_iteration_callback(x_vect) self._f_tol_tester.check(self._problem, raise_exception=True) self._x_tol_tester.check(self._problem, raise_exception=True) @staticmethod def __scale( function: MDOFunction, function_value: ndarray, scaling_threshold: float, ) -> MDOFunction: """Scale a function based on its value on the current design values. Args: function: The function. function_value: The function value of reference for scaling. scaling_threshold: The threshold on the reference function value that triggers scaling. Returns: The scaled function. """ reference_values = numpy.absolute(function_value) threshold_reached = reference_values > scaling_threshold if not threshold_reached.any(): return function scaled_function = function / numpy.where( threshold_reached, reference_values, 1.0 ) # Use same function name for consistency with name used in database scaled_function.name = function.name return scaled_function
class _KKTChecker: """A functor to verify the KKT norm stopping criterion.""" def __init__( self, problem: OptimizationProblem, kkt_abs_tol: float, kkt_rel_tol: float, ineq_tolerance: float, ) -> None: """ Args: problem: The optimization problem. kkt_abs_tol: The absolute tolerance for the KKT conditions. kkt_rel_tol: The relative tolerance for the KKT conditions. ineq_tolerance: The absolute tolerance for the inequality constraints. """ # noqa: D205, D212 self.__problem = problem self.__kkt_tester = KKTConditionsTester( absolute=0.0 if isinf(kkt_abs_tol) else kkt_abs_tol, relative=0.0 if isinf(kkt_rel_tol) else kkt_rel_tol, ineq_tolerance=ineq_tolerance, ) def __call__(self, input_value: ndarray) -> None: """Verify the KKT norm stopping criterion. Args: input_value: The input value. Raises: KKTReached: If the absolute tolerance on the KKT residual is reached. """ check_kkt = True function_names = [ self.__problem.standardized_objective_name, *self.__problem.constraints.get_names(), ] database = self.__problem.database for function_name in function_names: if ( database.get_function_value( database.get_gradient_name(function_name), input_value ) is None ) or (database.get_function_value(function_name, input_value) is None): check_kkt = False break if check_kkt: if not self.__kkt_tester.kkt_norm: self.__kkt_tester.kkt_norm = kkt_residual_computation( self.__problem, input_value, self.__kkt_tester.ineq_tolerance ) self.__kkt_tester.check( self.__problem, raise_exception=True, input_vector=input_value )