Skip to content

Commit d021574

Browse files
Merge pull request #7 from esa/khan-boundaries
Tanh Khan boundaries
2 parents 47e6705 + 325a86b commit d021574

File tree

4 files changed

+266
-126
lines changed

4 files changed

+266
-126
lines changed

pyoptgra/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# file, you can obtain them at https://www.gnu.org/licenses/gpl-3.0.txt
1313
# and https://essr.esa.int/license/european-space-agency-community-license-v2-4-weak-copyleft
1414

15-
from .optgra import khan_function, optgra # noqa
15+
from .optgra import khan_function_sin, khan_function_tanh, optgra # noqa
1616

1717
try:
1818
from ._version import version as __version__ # noqa

pyoptgra/optgra.py

+186-63
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,23 @@
2727
)
2828

2929

30-
class khan_function:
31-
r"""Function to smothly enforce optimisation parameter bounds as Michal Khan used to do:
30+
class base_khan_function:
31+
r"""Base class for a function to smothly enforce optimisation parameter bounds as Michal Khan
32+
used to do:
3233
3334
.. math::
3435
35-
x = \frac{x_{max} + x_{min}}{2} + \frac{x_{max} - x_{min}}{2} \cdot \sin(x_{khan})
36+
x = \frac{x_{max} + x_{min}}{2} + \frac{x_{max} - x_{min}}{2} \cdot \f(x_{khan})
3637
3738
Where :math:`x` is the pagmo decision vector and :math:`x_{khan}` is the decision vector
3839
passed to OPTGRA. In this way parameter bounds are guaranteed to be satisfied, but the gradients
3940
near the bounds approach zero.
41+
42+
The child class needs to implement the methods `_eval`, `_eval_inv`, `_eval_grad` and
43+
`_eval_inv_grad`
4044
""" # noqa: W605
4145

42-
def __init__(self, lb: List[float], ub: List[float], unity_gradient: bool = True):
46+
def __init__(self, lb: List[float], ub: List[float]):
4347
"""Constructor
4448
4549
Parameters
@@ -48,11 +52,6 @@ def __init__(self, lb: List[float], ub: List[float], unity_gradient: bool = True
4852
Lower pagmo parameter bounds
4953
ub : List[float]
5054
Upper pagmo parameter bounds
51-
unity_gradient : bool, optional
52-
Uses an internal scaling that ensures that the derivative of pagmo parameters w.r.t.
53-
khan parameters are unity at (lb + ub)/2. By default True.
54-
Otherwise, the original Khan method is used that can result in strongly modified
55-
gradients
5655
"""
5756
self._lb = np.asarray(lb)
5857
self._ub = np.asarray(ub)
@@ -86,54 +85,6 @@ def _isfinite(a: np.ndarray):
8685
self._lb_masked = self._lb[self.mask]
8786
self._ub_masked = self._ub[self.mask]
8887

89-
# determine coefficients inside the sin function
90-
self._a = 2 / (self._ub_masked - self._lb_masked) if unity_gradient else 1.0
91-
self._b = (
92-
-(self._ub_masked + self._lb_masked) / (self._ub_masked - self._lb_masked)
93-
if unity_gradient
94-
else 0.0
95-
)
96-
97-
def _eval(self, x_khan_masked: np.ndarray) -> np.ndarray:
98-
return (self._ub_masked + self._lb_masked) / 2 + (
99-
self._ub_masked - self._lb_masked
100-
) / 2 * np.sin(x_khan_masked * self._a + self._b)
101-
102-
def _eval_inv(self, x_masked: np.ndarray) -> np.ndarray:
103-
arg = (2 * x_masked - self._ub_masked - self._lb_masked) / (
104-
self._ub_masked - self._lb_masked
105-
)
106-
107-
clip_value = 1.0 - 1e-8 # avoid boundaries
108-
if np.any((arg < -clip_value) | (arg > clip_value)):
109-
print(
110-
"WARNING: Numerical inaccuracies encountered during khan_function inverse.",
111-
"Clipping parameters to valid range.",
112-
)
113-
arg = np.clip(arg, -clip_value, clip_value)
114-
return (np.arcsin(arg) - self._b) / self._a
115-
116-
def _eval_grad(self, x_khan_masked: np.ndarray) -> np.ndarray:
117-
return (
118-
(self._ub_masked - self._lb_masked)
119-
/ 2
120-
* np.cos(self._a * x_khan_masked + self._b)
121-
* self._a
122-
)
123-
124-
def _eval_inv_grad(self, x_masked: np.ndarray) -> np.ndarray:
125-
return (
126-
-1
127-
/ self._a
128-
/ (
129-
(self._lb_masked - self._ub_masked)
130-
* np.sqrt(
131-
((self._lb_masked - x_masked) * (x_masked - self._ub_masked))
132-
/ (self._ub_masked - self._lb_masked) ** 2
133-
)
134-
)
135-
)
136-
13788
def _apply_to_subset(
13889
self, x: np.ndarray, func: Callable, default_result: Optional[np.ndarray] = None
13990
) -> np.ndarray:
@@ -144,6 +95,18 @@ def _apply_to_subset(
14495
result[self.mask] = func(x[self.mask])
14596
return result
14697

98+
def _eval(self, x_khan_masked: np.ndarray) -> np.ndarray:
99+
raise NotImplementedError
100+
101+
def _eval_inv(self, x_masked: np.ndarray) -> np.ndarray:
102+
raise NotImplementedError
103+
104+
def _eval_grad(self, x_khan_masked: np.ndarray) -> np.ndarray:
105+
raise NotImplementedError
106+
107+
def _eval_inv_grad(self, x_masked: np.ndarray) -> np.ndarray:
108+
raise NotImplementedError
109+
147110
def eval(self, x_khan: np.ndarray) -> np.ndarray:
148111
"""Convert :math:`x_{optgra}` to :math:`x`.
149112
@@ -208,6 +171,153 @@ def eval_inv_grad(self, x: np.ndarray) -> np.ndarray:
208171
return np.diag(self._apply_to_subset(np.asarray(x), self._eval_inv_grad, np.ones(self._nx)))
209172

210173

174+
class khan_function_sin(base_khan_function):
175+
r"""Function to smothly enforce optimisation parameter bounds as Michal Khan used to do:
176+
177+
.. math::
178+
179+
x = \frac{x_{max} + x_{min}}{2} + \frac{x_{max} - x_{min}}{2} \cdot \sin(x_{khan})
180+
181+
Where :math:`x` is the pagmo decision vector and :math:`x_{khan}` is the decision vector
182+
passed to OPTGRA. In this way parameter bounds are guaranteed to be satisfied, but the gradients
183+
near the bounds approach zero.
184+
""" # noqa: W605
185+
186+
def __init__(self, lb: List[float], ub: List[float], unity_gradient: bool = True):
187+
"""Constructor
188+
189+
Parameters
190+
----------
191+
lb : List[float]
192+
Lower pagmo parameter bounds
193+
ub : List[float]
194+
Upper pagmo parameter bounds
195+
unity_gradient : bool, optional
196+
Uses an internal scaling that ensures that the derivative of pagmo parameters w.r.t.
197+
Khan parameters are unity at (lb + ub)/2. By default True.
198+
Otherwise, the original Khan method is used that can result in strongly modified
199+
gradients
200+
"""
201+
# call parent class constructor
202+
super().__init__(lb, ub)
203+
204+
# determine coefficients inside the sin function
205+
self._a = 2 / (self._ub_masked - self._lb_masked) if unity_gradient else 1.0
206+
self._b = (
207+
-(self._ub_masked + self._lb_masked) / (self._ub_masked - self._lb_masked)
208+
if unity_gradient
209+
else 0.0
210+
)
211+
212+
def _eval(self, x_khan_masked: np.ndarray) -> np.ndarray:
213+
return (self._ub_masked + self._lb_masked) / 2 + (
214+
self._ub_masked - self._lb_masked
215+
) / 2 * np.sin(x_khan_masked * self._a + self._b)
216+
217+
def _eval_inv(self, x_masked: np.ndarray) -> np.ndarray:
218+
arg = (2 * x_masked - self._ub_masked - self._lb_masked) / (
219+
self._ub_masked - self._lb_masked
220+
)
221+
222+
clip_value = 1.0 - 1e-8 # avoid boundaries
223+
if np.any((arg < -clip_value) | (arg > clip_value)):
224+
print(
225+
"WARNING: Numerical inaccuracies encountered during khan_function inverse.",
226+
"Clipping parameters to valid range.",
227+
)
228+
arg = np.clip(arg, -clip_value, clip_value)
229+
return (np.arcsin(arg) - self._b) / self._a
230+
231+
def _eval_grad(self, x_khan_masked: np.ndarray) -> np.ndarray:
232+
return (
233+
(self._ub_masked - self._lb_masked)
234+
/ 2
235+
* np.cos(self._a * x_khan_masked + self._b)
236+
* self._a
237+
)
238+
239+
def _eval_inv_grad(self, x_masked: np.ndarray) -> np.ndarray:
240+
return (
241+
-1
242+
/ self._a
243+
/ (
244+
(self._lb_masked - self._ub_masked)
245+
* np.sqrt(
246+
((self._lb_masked - x_masked) * (x_masked - self._ub_masked))
247+
/ (self._ub_masked - self._lb_masked) ** 2
248+
)
249+
)
250+
)
251+
252+
253+
class khan_function_tanh(base_khan_function):
254+
r"""Function to smothly enforce optimisation parameter bounds using the hyperbolic tangent:
255+
256+
.. math::
257+
258+
x = \frac{x_{max} + x_{min}}{2} + \frac{x_{max} - x_{min}}{2} \cdot \tanh(x_{khan})
259+
260+
Where :math:`x` is the pagmo decision vector and :math:`x_{khan}` is the decision vector
261+
passed to OPTGRA. In this way parameter bounds are guaranteed to be satisfied, but the gradients
262+
near the bounds approach zero.
263+
""" # noqa: W605
264+
265+
def __init__(self, lb: List[float], ub: List[float], unity_gradient: bool = True):
266+
"""Constructor
267+
268+
Parameters
269+
----------
270+
lb : List[float]
271+
Lower pagmo parameter bounds
272+
ub : List[float]
273+
Upper pagmo parameter bounds
274+
unity_gradient : bool, optional
275+
Uses an internal scaling that ensures that the derivative of pagmo parameters w.r.t.
276+
khan parameters are unity at (lb + ub)/2. By default True.
277+
Otherwise, the original Khan method is used that can result in strongly modified
278+
gradients
279+
"""
280+
# call parent class constructor
281+
super().__init__(lb, ub)
282+
283+
# define amplification factor to avoid bounds to be only reached at +/- infinity
284+
amp = 1.0 + 1e-3
285+
286+
# define the clip value (we avoid the boundaries of the parameters by this much)
287+
self.clip_value = 1.0 - 1e-6
288+
289+
# determine coefficients inside the tanh function
290+
self._diff_masked = amp * (self._ub_masked - self._lb_masked)
291+
self._sum_masked = self._ub_masked + self._lb_masked
292+
self._a = 2 / self._diff_masked if unity_gradient else 1.0
293+
self._b = -self._sum_masked / self._diff_masked if unity_gradient else 0.0
294+
295+
def _eval(self, x_khan_masked: np.ndarray) -> np.ndarray:
296+
return self._sum_masked / 2 + self._diff_masked / 2 * np.tanh(
297+
x_khan_masked * self._a + self._b
298+
)
299+
300+
def _eval_inv(self, x_masked: np.ndarray) -> np.ndarray:
301+
arg = (2 * x_masked - self._sum_masked) / (self._diff_masked)
302+
303+
if np.any((arg < -self.clip_value) | (arg > self.clip_value)):
304+
print(
305+
"WARNING: Numerical inaccuracies encountered during khan_function inverse.",
306+
"Clipping parameters to valid range.",
307+
)
308+
arg = np.clip(arg, -self.clip_value, self.clip_value)
309+
return (np.arctanh(arg) - self._b) / self._a
310+
311+
def _eval_grad(self, x_khan_masked: np.ndarray) -> np.ndarray:
312+
return self._diff_masked / 2 / np.cosh(self._a * x_khan_masked + self._b) ** 2 * self._a
313+
314+
def _eval_inv_grad(self, x_masked: np.ndarray) -> np.ndarray:
315+
316+
return (2 * self._diff_masked) / (
317+
self._a * (self._diff_masked**2 - (2 * x_masked - self._sum_masked) ** 2)
318+
)
319+
320+
211321
class optgra:
212322
"""
213323
This class is a user defined algorithm (UDA) providing a wrapper around OPTGRA, which is written
@@ -247,7 +357,7 @@ def _wrap_fitness_func(
247357
problem,
248358
bounds_to_constraints: bool = True,
249359
force_bounds: bool = False,
250-
khanf: Optional[khan_function] = None,
360+
khanf: Optional[base_khan_function] = None,
251361
):
252362
# get problem parameters
253363
lb, ub = problem.get_bounds()
@@ -289,7 +399,7 @@ def _wrap_gradient_func(
289399
problem,
290400
bounds_to_constraints: bool = True,
291401
force_bounds=False,
292-
khanf: Optional[khan_function] = None,
402+
khanf: Optional[base_khan_function] = None,
293403
):
294404

295405
# get the sparsity pattern to index the sparse gradients
@@ -369,7 +479,7 @@ def __init__(
369479
merit_function_threshold: float = 1e-6,
370480
# bound_constraints_scalar: float = 1,
371481
force_bounds: bool = False,
372-
khan_bounds: bool = False,
482+
khan_bounds: Union[str, bool] = False,
373483
optimization_method: int = 2,
374484
log_level: int = 0,
375485
) -> None:
@@ -416,18 +526,21 @@ def __init__(
416526
If active, the gradients evaluated near the bounds will be inacurate potentially
417527
leading to convergence issues.
418528
khan_bounds: optional - whether to gracefully enforce bounds on the decision vector
419-
using Michael Khan's method:
529+
using Michael Khan's method, by default False.:
420530
421531
.. math::
422532
423533
x = \frac{x_{max} + x_{min}}{2} + \frac{x_{max} - x_{min}}{2} \cdot \sin(x_{Khan})
424534
425535
Where :math:`x` is the pagmo decision vector and :math:`x_{Khan}` is the decision
426536
vector passed to OPTGRA. In this way parameter bounds are guaranteed to be
427-
satisfied, but the gradients near the bounds approach zero. By default False.
537+
satisfied, but the gradients near the bounds approach zero.
428538
Pyoptgra uses a variant of the above method that additionally scales the
429539
argument of the :math:`\sin` function such that the derivatives
430540
:math:`\frac{d x_{Khan}}{d x}` are unity in the center of the box bounds.
541+
Alternatively, to a :math:`\sin` function, also a :math:`\tanh` can be
542+
used as a Khan function.
543+
Valid input values are: True (same as 'sin'),'sin', 'tanh' and False.
431544
optimization_method: select 0 for steepest descent, 1 for modified spectral conjugate
432545
gradient method, 2 for spectral conjugate gradient method and 3 for conjugate
433546
gradient method
@@ -609,7 +722,17 @@ def evolve(self, population):
609722
idx = list(population.get_ID()).index(selected[0][0])
610723

611724
# optional Khan function to enforce parameter bounds
612-
khanf = khan_function(*problem.get_bounds()) if self.khan_bounds else None
725+
if self.khan_bounds in ("sin", True):
726+
khanf = khan_function_sin(*problem.get_bounds())
727+
elif self.khan_bounds == "tanh":
728+
khanf = khan_function_tanh(*problem.get_bounds())
729+
elif self.khan_bounds:
730+
raise ValueError(
731+
f"Unrecognised option, {self.khan_bounds}, passed for 'khan_bounds'. "
732+
"Supported options are 'sin', 'tanh' or None."
733+
)
734+
else:
735+
khanf = None
613736

614737
fitness_func = optgra._wrap_fitness_func(
615738
problem, self.bounds_to_constraints, self.force_bounds, khanf

pyproject.toml

+12-9
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
[project]
22
authors = [
3-
{name = "Johannes Schoenmaekers", email = "[email protected]"},
4-
{name = "Moritz von Looz", email = "[email protected]"},
5-
]
6-
dependencies = [
7-
"pygmo >=2.16.0",
8-
"numpy<2.0.0",
3+
{ name = "Johannes Schoenmaekers", email = "[email protected]" },
4+
{ name = "Moritz von Looz", email = "[email protected]" },
95
]
6+
dependencies = ["pygmo >=2.16.0", "numpy<2.0.0"]
107
description = "A python-wrapped version of OPTGRA, an algorithm for constrained optimization"
11-
license = {text = "GPL-3.0 or ESCL-2.4"}
8+
license = { text = "GPL-3.0 or ESCL-2.4" }
129
name = "pyoptgra"
1310
readme = "README.rst"
1411
requires-python = ">=3.9"
15-
version = "1.0.1"
12+
version = "1.1.0"
1613

1714
[build-system]
1815
build-backend = "scikit_build_core.build"
19-
requires = ["setuptools", "wheel", "scikit-build-core", "ninja", "setuptools_scm"]
16+
requires = [
17+
"setuptools",
18+
"wheel",
19+
"scikit-build-core",
20+
"ninja",
21+
"setuptools_scm",
22+
]
2023

2124
[tool.scikit-build.wheel]
2225
install-dir = "pyoptgra/core"

0 commit comments

Comments
 (0)