# 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: Francois Gallard
# OTHER AUTHORS - MACROSCOPIC CHANGES
# :author: Benoît Pauwels - refactoring
"""Updates a trust parameter according to a decreases' ratio."""
from __future__ import annotations
import logging
from docstring_inheritance import GoogleDocstringInheritanceMeta
from numpy import divide
from numpy import maximum
from numpy import minimum
from numpy import multiply
from numpy import ndarray
LOGGER = logging.getLogger(__name__)
[docs]class TrustUpdater(metaclass=GoogleDocstringInheritanceMeta):
"""Updater of a trust parameter."""
def __init__(
self,
thresholds: tuple[float, float],
multipliers: tuple[float, float],
bound=None,
) -> None:
"""
Args:
thresholds: The thresholds for the decreases' ratio.
multipliers: The multipliers for the trust parameter.
bound: The absolute bound for the trust parameter.
"""
if not isinstance(thresholds, tuple):
raise ValueError(
"The thresholds must be input as a tuple; "
f"input of type {type(thresholds)} was provided."
)
self._ratio_thresholds = thresholds
if not isinstance(multipliers, tuple):
raise ValueError(
"The multipliers must be input as a tuple; "
f"input of type {type(multipliers)} was provided."
)
self._param_multipliers = multipliers
self._param_bound = bound
def _check(self) -> None:
"""Check the consistency of the attributes."""
raise NotImplementedError()
[docs] def update(self, ratio: float, parameter: float) -> tuple[float, bool]:
"""Update the trust parameter relative to the decrease ratio value.
Method to be overridden by subclasses.
Args:
ratio: The decrease ratio.
parameter: The trust parameter (radius or penalty).
Returns:
The new trust parameter, the iteration success.
"""
raise NotImplementedError()
[docs]class PenaltyUpdater(TrustUpdater):
"""Update the penalty parameter."""
def __init__(
self,
thresholds: tuple[float, float] = (0.0, 0.25),
multipliers: tuple[float, float] = (0.5, 2.0),
bound: float = 1e-6,
) -> None:
super().__init__(thresholds, multipliers, bound)
self._check()
def _check(self) -> None:
# Check the thresholds:
if len(self._ratio_thresholds) != 2:
raise ValueError(
"There must be exactly two thresholds for the "
f"decreases ratio; {len(self._ratio_thresholds)} were given."
)
update_thresh = self._ratio_thresholds[0]
nonexp_thresh = self._ratio_thresholds[1]
if update_thresh > nonexp_thresh:
raise ValueError(
f"The update threshold ({update_thresh}) must be lower than or equal "
f"to the non-expansion threshold ({nonexp_thresh})."
)
# Check the multipliers:
if len(self._param_multipliers) != 2:
raise ValueError(
"There must be exactly two multipliers for the "
f"penalty parameter; {len(self._ratio_thresholds)} were given."
)
contract_fact = self._param_multipliers[0]
expan_fact = self._param_multipliers[1]
if contract_fact >= 1.0:
raise ValueError(
f"The contraction factor ({contract_fact}) must be lower than one."
)
if expan_fact < 1.0:
raise ValueError(
f"The expansion factor ({expan_fact}) "
"must be greater than or equal to one."
)
[docs] def update(self, ratio: float, parameter: float) -> tuple[float, bool]:
# The iteration is declared successful if and only if the ratio is
# greater than or equal to the lower threshold.
success = ratio >= self._ratio_thresholds[0]
# If the ratio is greater than or equal to the upper threshold, the
# penalty parameter is not increased, otherwise it is increased.
if ratio >= self._ratio_thresholds[1]:
new_penalty = parameter * self._param_multipliers[0]
if self._param_bound is not None and new_penalty < self._param_bound:
new_penalty = 0.0
else:
if self._param_bound is not None and parameter == 0.0:
new_penalty = self._param_bound
else:
new_penalty = parameter * self._param_multipliers[1]
return new_penalty, success
[docs]class RadiusUpdater(TrustUpdater):
"""Update the radius of the trust region."""
def __init__(
self,
thresholds: tuple[float, float] = (0.0, 0.25),
multipliers: tuple[float, float] = (0.5, 2.0),
bound: float = 1000.0,
) -> None:
super().__init__(thresholds, multipliers, bound)
self._check()
def _check(self) -> None:
# Check the thresholds:
if len(self._ratio_thresholds) != 2:
raise ValueError(
"There must be exactly two thresholds for the "
f"decreases ratio; {len(self._ratio_thresholds)} were given."
)
update_thresh = self._ratio_thresholds[0]
noncontract_thresh = self._ratio_thresholds[1]
if update_thresh > noncontract_thresh:
raise ValueError(
f"The update threshold ({update_thresh}) must be lower than or equal "
f"to the non-contraction threshold ({noncontract_thresh})."
)
# Check the multipliers:
if len(self._param_multipliers) != 2:
raise ValueError(
"There must be exactly two multipliers for the region radius; "
f"{len(self._ratio_thresholds)} were given."
)
contract_fact = self._param_multipliers[0]
expan_fact = self._param_multipliers[1]
if contract_fact > 1.0:
raise ValueError(
f"The contraction factor ({contract_fact}) "
f"must be lower than or equal to one."
)
if expan_fact <= 1.0:
raise ValueError(
f"The expansion factor ({expan_fact}) must be greater than one."
)
[docs] def update(self, ratio: float, parameter: float) -> tuple[float, bool]:
# The iteration is declared successful if and only if the ratio is
# greater than or equal to the lower threshold.
success = ratio >= self._ratio_thresholds[0]
# If the ratio is greater than or equal to the upper threshold, the
# region radius is not decreased, otherwise it is decreased.
if ratio >= self._ratio_thresholds[1]:
new_radius = parameter * self._param_multipliers[1]
if self._param_bound is not None:
new_radius = min(new_radius, self._param_bound)
else:
new_radius = parameter * self._param_multipliers[0]
return new_radius, success
[docs]class BoundsUpdater:
"""Updater of the trust bounds, i.e. trust ball w.r.t.
the infinity norm.
"""
def __init__(
self, lower_bounds: ndarray, upper_bounds: ndarray, normalize: bool = False
) -> None:
"""
Args:
lower_bounds: The reference lower bounds.
upper_bounds: The reference upper bounds.
normalize: Whether to apply the radius to the normalized bounds.
"""
self._lower_bounds = lower_bounds
self._upper_bounds = upper_bounds
self._normalized_update = normalize
self.__bound_sum = lower_bounds + upper_bounds
self.__bound_diff = upper_bounds - lower_bounds
[docs] def update(self, radius: float, center: ndarray) -> tuple[ndarray, ndarray]:
"""Update the trust bounds.
Args:
radius: The region radius w.r.t. the infinity norm.
center: The center of the region
Returns:
The updated lower and upper bounds of the trust region.
"""
if self._normalized_update:
radius = radius * 0.5 * self.__bound_diff
return self._compute_trust_bounds(
self._lower_bounds, self._upper_bounds, center, radius
)
@staticmethod
def _compute_trust_bounds(
lower_bounds: ndarray,
upper_bounds: ndarray,
center: ndarray,
radius: float | ndarray,
) -> tuple[ndarray, ndarray]:
"""Update the bounds of the trust region.
Use a ball center and ball radius w.r.t. the infinity norm.
Args:
lower_bounds: The lower bounds to be updated.
upper_bounds: The upper bounds to be updated.
center: The center of the ball.
radius: The radius of the ball;
either the same for all coordinate or coordinate-specific.
Returns:
The updated lower and upper bounds.
"""
return (
minimum(maximum(center - radius, lower_bounds), upper_bounds),
maximum(minimum(center + radius, upper_bounds), lower_bounds),
)
def _normalize(self, x_vect: ndarray) -> ndarray:
"""Normalize the coordinates of a vector to [-1, 1].
Args:
x_vect: The vector to normalize.
Returns:
The normalized vector.
"""
return divide(2.0 * x_vect - self.__bound_sum, self.__bound_diff)
def _unnormalize(self, x_norm: ndarray) -> ndarray:
"""Unnormalize the coordinates of a vector to [-1, 1].
Args:
x_norm: The vector to unnormalize.
Returns:
The unnormalized vector.
"""
return (multiply(x_norm, self.__bound_diff) + self.__bound_sum) / 2.0