Source code for gemseo.algos.lagrange_multipliers

# 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 - API and implementation and/or documentation
#       :author: Pierre-Jean Barjhoux
#       :author: Francois Gallard, integration and cleanup
#       :author: Simone Coniglio
#    OTHER AUTHORS   - MACROSCOPIC CHANGES
"""Implementation of the Lagrange multipliers."""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING
from typing import ClassVar

from numpy import abs as np_abs
from numpy import arange
from numpy import array
from numpy import atleast_2d
from numpy import concatenate
from numpy import full
from numpy import inf
from numpy import isinf
from numpy import ndarray
from numpy import zeros
from numpy.linalg import LinAlgError
from numpy.linalg import norm
from prettytable import PrettyTable
from scipy.optimize import lsq_linear
from scipy.optimize import nnls

from gemseo.utils.string_tools import repr_variable

if TYPE_CHECKING:
    from gemseo.algos.optimization_problem import OptimizationProblem

LOGGER = logging.getLogger(__name__)


[docs] class LagrangeMultipliers: r"""Class that implements the computation of Lagrange Multipliers. Denote :math:`x^\ast` an optimal solution of the optimization problem below. .. math:: \begin{aligned} & \text{Minimize} & & f(x) \\ & \text{relative to} & & x \\ & \text{subject to} & & \left\{\begin{aligned} & g(x)\le0, \\ & h(x)=0, \\ & \ell\le x\le u. \end{aligned}\right. \end{aligned} If the constraints are qualified at :math:`x^\ast` then the Lagrange multipliers of :math:`x^\ast` are the vectors :math:`\lambda_g`, :math:`\lambda_h`, :math:`\lambda_\ell` and :math:`\lambda_u` satisfying .. math:: \left\{\begin{aligned} &\frac{\partial f}{\partial x}(x^\ast) +\lambda_g^\top\frac{\partial g}{\partial x}(x^\ast) +\lambda_h^\top\frac{\partial h}{\partial x}(x^\ast) +\sum_j\lambda_{\ell,j}+\sum_j\lambda_{u,j} =0,\\ &\lambda_{g,i}\ge0\text{ if }g_i(x^\ast)=0, \text{ otherwise }\lambda_{g,i}=0,\\ &\lambda_{\ell,j}\le0\text{ if }x^\ast_j=\ell_j, \text{ otherwise }\lambda_{\ell,j}=0,\\ &\lambda_{u,j}\ge0\text{ if }x^\ast_j=u_j, \text{ otherwise }\lambda_{u,j}=0. \end{aligned}\right. """ kkt_residual: float | None """The residual of the KKT conditions, ``None`` if not computed.""" constraint_violation: float | None """The maximum constraint violation (taking tolerances into account), ``None`` if not computed.""" LOWER_BOUNDS = "lower_bounds" UPPER_BOUNDS = "upper_bounds" INEQUALITY = "inequality" EQUALITY = "equality" CSTR_LABELS: ClassVar[list[str]] = [ LOWER_BOUNDS, UPPER_BOUNDS, INEQUALITY, EQUALITY, ] def __init__(self, opt_problem: OptimizationProblem) -> None: """ Args: opt_problem: The optimization problem on which Lagrange multipliers shall be computed. """ # noqa: D205, D212, D415 self.optimization_problem = opt_problem self.optimization_problem.reset( database=False, design_space=False, preprocessing=False ) self.active_lb_names = [] self.active_ub_names = [] self.active_ineq_names = [] self.active_eq_names = [] self.lagrange_multipliers = None self.__normalized = opt_problem.objective.expects_normalized_inputs self.kkt_residual = None self.constraint_violation = None
[docs] def compute( self, x_vect: ndarray, ineq_tolerance: float = 1e-6, rcond: float = -1 ) -> dict[str, tuple[list[str], ndarray]]: """Compute the Lagrange multipliers, as a post-processing of the optimal point. This solves: (d ActiveConstraints)' d Objective (-------------------) . Lambda = - ----------- (d X ) d X Args: x_vect: The optimal point on which the multipliers shall be computed. ineq_tolerance: The tolerance on inequality constraints. rcond: The cut-off ratio for small singular values of the Jacobian (see scipy.linalg.lsq). Returns: The Lagrange multipliers. """ LOGGER.info("Computation of Lagrange multipliers") # Check feasibility self._check_feasibility(x_vect) # get jacobian of objective rhs = -self.get_objective_jacobian(x_vect).T # get jacobian of all active constraints, and an # ordered list of their name self.__compute_constraint_violation(x_vect) jac_act, _ = self._get_jac_act(x_vect, ineq_tolerance) if jac_act is None: # There is no active constraint multipliers = [] self.kkt_residual = norm(rhs) self._store_multipliers(multipliers) return self.lagrange_multipliers lhs = jac_act.T # Compute the Lagrange multipliers as a feasible solution of a # linear optimization problem act_eq_constr_nb = len(self.active_eq_names) if act_eq_constr_nb == 0: # If the linear optimization failed then obtain the Lagrange # multipliers as a solution of a least-square problem try: mul, residuals = nnls(lhs, rhs) except LinAlgError as error: if str(error) == "Matrix is singular.": # NNLS has crashed on a singular submatrix mul = self.__compute_bounded_least_squares_solution(lhs, rhs) else: raise except RuntimeError as error: if str(error) == "Maximum number of iterations reached.": # NNLS has not converged mul = self.__compute_bounded_least_squares_solution(lhs, rhs) else: raise else: self.kkt_residual = norm(residuals) else: mul = self.__compute_bounded_least_squares_solution(lhs, rhs) LOGGER.info("Residuals norm = %s", self.kkt_residual) # stores multipliers in a dictionary self._store_multipliers(mul) return self.lagrange_multipliers
def __compute_bounded_least_squares_solution( self, lhs: ndarray, rhs: ndarray ) -> ndarray: """Compute the Lagrange multipliers by bounded Least Squares minimization. Args: lhs: The left-hand side of the linear system. rhs: The right-hand side of the linear system. Returns: The Lagrange multipliers. """ n_active_constraints = lhs.shape[1] n_active_equalities = len(self.active_eq_names) lower_bound = concatenate([ zeros(n_active_constraints - n_active_equalities), full(n_active_equalities, -inf), ]) upper_bound = full(n_active_constraints, inf) solution = lsq_linear(lhs, rhs, (lower_bound, upper_bound)) self.kkt_residual = solution.cost return solution.x def _check_feasibility(self, x_vect: ndarray) -> None: """Check that a point is in the design space and satisfies all the constraints. Args: x_vect: The point at which the Lagrange multipliers are to be computed. """ problem = self.optimization_problem problem.design_space.check_membership(x_vect) # Check that the point satisfies other constraints output_functions, jacobian_functions = problem.get_functions( evaluate_objective=False, observable_names=None ) values, _ = self.optimization_problem.evaluate_functions( design_vector=x_vect, design_vector_is_normalized=False, output_functions=output_functions or None, jacobian_functions=jacobian_functions or None, ) if not self.optimization_problem.constraints.is_point_feasible(values): LOGGER.warning("Infeasible point, Lagrange multipliers may not exist.") def _get_act_bound_jac(self, act_bounds: dict[str, ndarray]): """Return the Jacobian of the active bounds. The constraints sign is not taken into account (matrix is made of 0 and 1). Args: act_bounds: The active bounds. Returns: The Jacobian of the active bounds and the name of each component of each function. """ dspace = self.optimization_problem.design_space x_dim = dspace.dimension dim_act = sum(len(bnd.nonzero()[0]) for bnd in act_bounds.values()) if dim_act == 0: return None, [] act_array = concatenate([act_bounds[var] for var in dspace]) bnd_jac = zeros((dim_act, x_dim)) if self.__normalized: norm_factor = dspace.get_upper_bounds() - dspace.get_lower_bounds() norm_factor[isinf(norm_factor)] = 1.0 act_jac = norm_factor[act_array] else: act_jac = 1.0 bnd_jac[arange(dim_act), act_array] = act_jac indexed_varnames = array(dspace.get_indexed_variable_names()) act_b_names = indexed_varnames[act_array].tolist() return bnd_jac, act_b_names def __get_act_ineq_jac( self, x_vect: ndarray, ineq_tolerance: float = 1e-6 ) -> tuple[ndarray, list[str]]: """Return the Jacobian of the active inequality constraints. Args: x_vect: The point at which the Jacobian is computed. ineq_tolerance: The tolerance for the inequality constraints. Returns: The Jacobian of the active inequality constraints and the name of each component of each function. """ # retrieves the active functions and the indices : # a function is active if at least # one of its component (in case of multidimensional constraints) is # active problem = self.optimization_problem act_constraints = problem.constraints.get_active(x_vect, ineq_tolerance) dspace = problem.design_space if self.__normalized: x_vect = dspace.normalize_vect(x_vect) jac = [] names = [] for func, act_set in act_constraints.items(): if act_set.any(): ineq_jac = func.jac(x_vect) if len(ineq_jac.shape) == 1: # Make sure the Jacobian is a 2-dimensional array ineq_jac = ineq_jac.reshape((1, x_vect.size)) else: ineq_jac = ineq_jac[act_set, :] jac.append(ineq_jac) if func.dim == 1: names.append(repr_variable(func.name, 0, func.dim)) else: names += [ repr_variable(func.name, i, func.dim) for i, active in enumerate(act_set) if active ] jac = concatenate(jac) if jac else None return jac, names def __compute_constraint_violation(self, x_vect: ndarray) -> None: """Compute the maximum constraint violation. Args: x_vect: The point where the maximum constraint violation is to be computed. """ self.constraint_violation = 0.0 if self.__normalized: x_vect = self.optimization_problem.design_space.normalize_vect(x_vect) for constraint in self.optimization_problem.constraints: value = constraint.evaluate(x_vect) if constraint.f_type == constraint.ConstraintType.EQ: value = np_abs(value) - self.optimization_problem.tolerances.equality else: value = value - self.optimization_problem.tolerances.inequality if isinstance(value, ndarray): value = value.max() self.constraint_violation = max(self.constraint_violation, value) self.constraint_violation = max(self.constraint_violation, 0.0) def _get_act_eq_jac(self, x_vect: ndarray) -> tuple[ndarray, list[str]]: """Return The Jacobian of the active equality constraints. Args: x_vect: The point at which the Jacobian is computed. Returns: The Jacobian of the active equality constraints and the name of each component of each function. """ eq_functions = self.optimization_problem.constraints.get_equality_constraints() # loop on equality functions # NB: as the solution (x_vect) is supposed to be feasible, # all functions (on all dimensions) are supposed to be active jac = [] names = [] dspace = self.optimization_problem.design_space if self.__normalized: x_vect = dspace.normalize_vect(x_vect) for eq_function in eq_functions: eq_jac = atleast_2d(eq_function.jac(x_vect)) jac.append(eq_jac) if eq_function.dim == 1: names.append(repr_variable(eq_function.name, 0, eq_function.dim)) else: names += [ repr_variable(eq_function.name, i, eq_function.dim) for i in range(eq_jac.shape[0]) ] jac = concatenate(jac) if jac else None return jac, names
[docs] def get_objective_jacobian(self, x_vect: ndarray) -> ndarray: """Return the Jacobian of the objective. Args: x_vect: The point at which the Jacobian is computed. Returns: The Jacobian of the objective. """ if self.__normalized: x_vect = self.optimization_problem.design_space.normalize_vect(x_vect) return self.optimization_problem.objective.jac(x_vect)
def _get_jac_act( self, x_vect: ndarray, ineq_tolerance: float = 1e-6 ) -> tuple[ndarray | None, list[str]]: """Return the Jacobian of the active constraints. Args: x_vect: The point at which the Jacobian is computed. ineq_tolerance: The tolerance for the inequality constraints. Returns: The Jacobian of the active constraints and the name of each component of each function. """ # Bounds jacobian dspace = self.optimization_problem.design_space act_lb, act_ub = dspace.get_active_bounds(x_vect, tol=ineq_tolerance) lb_jac_act, self.active_lb_names = self._get_act_bound_jac(act_lb) if lb_jac_act is not None: lb_jac_act *= -1 ub_jac_act, self.active_ub_names = self._get_act_bound_jac(act_ub) # inequality names tol = ineq_tolerance ineq_jac, self.active_ineq_names = self.__get_act_ineq_jac(x_vect, tol) # equality names eq_jac, eq_names_act = self._get_act_eq_jac(x_vect) self.active_eq_names = eq_names_act names = ( self.active_lb_names + self.active_ub_names + self.active_ineq_names + eq_names_act ) jacobians = [ jacobian for jacobian in [lb_jac_act, ub_jac_act, ineq_jac, eq_jac] if jacobian is not None ] if jacobians: return concatenate(jacobians, axis=0), names # There no active constraint return None, names def _store_multipliers(self, multipliers: ndarray) -> None: """Store the Lagrange multipliers in the attribute :attr:`lagrange_multipliers`. Args: multipliers: The Lagrange multipliers. """ lag = {} i_min = 0 n_act = len(self.active_lb_names) if n_act > 0: l_b_mult = multipliers[i_min : i_min + n_act] lag[self.LOWER_BOUNDS] = (self.active_lb_names, l_b_mult) i_min += n_act wrong_inds = (l_b_mult < 0.0).nonzero()[0] if wrong_inds.size > 0: names_neg = array(self.active_lb_names)[wrong_inds] LOGGER.warning( "Negative Lagrange multipliers for lower bounds on variables%s !", str(names_neg), ) n_act = len(self.active_ub_names) if n_act > 0: u_b_mult = multipliers[i_min : i_min + n_act] lag[self.UPPER_BOUNDS] = (self.active_ub_names, u_b_mult) i_min += n_act wrong_inds = (u_b_mult < 0.0).nonzero()[0] if wrong_inds.size > 0: names_neg = array(self.active_ub_names)[wrong_inds] LOGGER.warning( "Negative Lagrange multipliers for upper bounds on variables%s !", str(names_neg), ) n_act = len(self.active_ineq_names) if n_act > 0: ineq_mult = multipliers[i_min : i_min + n_act] lag[self.INEQUALITY] = (self.active_ineq_names, ineq_mult) i_min += n_act wrong_inds = (ineq_mult < 0.0).nonzero()[0] if wrong_inds.size > 0: names_neg = array(self.active_ineq_names)[wrong_inds] LOGGER.warning( "Negative Lagrange multipliers for inequality constraints%s !", str(names_neg), ) n_act = len(self.active_eq_names) if n_act > 0: lag[self.EQUALITY] = ( self.active_eq_names, multipliers[i_min : i_min + n_act], ) i_min += n_act self.lagrange_multipliers = lag def _initialize_multipliers(self) -> dict[str, dict[str, ndarray]]: """Initialize the Lagrange multipliers with zeros. Returns: The Lagrange multipliers. """ problem = self.optimization_problem multipliers = {} # Bound-constraints indexed_varnames = problem.design_space.get_indexed_variable_names() multipliers[self.LOWER_BOUNDS] = dict.fromkeys(indexed_varnames, 0.0) multipliers[self.UPPER_BOUNDS] = dict.fromkeys(indexed_varnames, 0.0) # Inequality-constraints multipliers[self.INEQUALITY] = { repr_variable(func.name, i, func.dim): 0.0 for func in problem.constraints.get_inequality_constraints() for i in range(func.dim) } # Equality-constraints multipliers[self.EQUALITY] = { repr_variable(func.name, i, func.dim): 0.0 for func in problem.constraints.get_equality_constraints() for i in range(func.dim) } return multipliers
[docs] def get_multipliers_arrays(self) -> dict[str, dict[str, ndarray]]: """Return the Lagrange multipliers (zero and nonzero) as arrays. Returns: The Lagrange multipliers. """ problem = self.optimization_problem design_space = problem.design_space # Convert to dictionaries multipliers = {} for label in self.CSTR_LABELS: names, mults = self.lagrange_multipliers.get(label, ([], array([]))) multipliers[label] = dict(zip(names, mults)) # Add the Lagrange multipliers equal to zero multipliers_init = self._initialize_multipliers() for label in self.CSTR_LABELS: multipliers_init[label].update(multipliers[label]) # Cast the multipliers as arrays mult_arrays = {} # Bound-constraints multipliers mult_arrays[self.LOWER_BOUNDS] = {} mult_arrays[self.UPPER_BOUNDS] = {} for name in design_space: indexed_varnames = design_space.get_indexed_variable_names() var_low_mult = array([ multipliers_init[self.LOWER_BOUNDS][comp_name] for comp_name in indexed_varnames ]) mult_arrays[self.LOWER_BOUNDS][name] = var_low_mult var_upp_mult = array([ multipliers_init[self.UPPER_BOUNDS][comp_name] for comp_name in indexed_varnames ]) mult_arrays[self.UPPER_BOUNDS][name] = var_upp_mult # Inequality-constraints multipliers ineq_mult = multipliers_init[self.INEQUALITY] mult_arrays[self.INEQUALITY] = {} for func in problem.constraints.get_inequality_constraints(): func_mult = array([ ineq_mult[repr_variable(func.name, index, func.dim)] for index in range(func.dim) ]) mult_arrays[self.INEQUALITY][func.name] = func_mult # Equality-constraints multipliers eq_mult = multipliers_init[self.EQUALITY] mult_arrays[self.EQUALITY] = {} for func in problem.constraints.get_equality_constraints(): func_mult = array([ eq_mult[repr_variable(func.name, index, func.dim)] for index in range(func.dim) ]) mult_arrays[self.EQUALITY][func.name] = func_mult return mult_arrays
def _get_pretty_table(self) -> PrettyTable: """Display the Lagrange Multipliers.""" table = PrettyTable([ "Constraint type", "Constraint name", "Lagrange Multiplier", ]) for cstr_type, nam_val in self.lagrange_multipliers.items(): for name, value in zip(nam_val[0], nam_val[1]): table.add_row([cstr_type, name, value]) return table def __str__(self, *args, **kwargs) -> str: return f"Lagrange multipliers:\n{self._get_pretty_table().get_string()}"