Skip to content

Commit 44472d1

Browse files
authored
feat: added option to use fixed epsilon (#163)
* feat: fixed epsilon implemented in core * chore: added tests for constant epsilon
1 parent 37fcd4b commit 44472d1

File tree

8 files changed

+163
-17
lines changed

8 files changed

+163
-17
lines changed

docs/background.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ This implementation has the following features:
77

88
- We ensure that the sampling is scale-invariant and that the algorithm can deal with positive and negative objective values.
99

10+
- In contrast to the `original implementation by Zuluaga et al. <https://jmlr.org/papers/v17/15-047.html>`_ we do not assume that the range of the objectives is known a priori. In their implementation it is used to calculate fixed tolerance values :math:`\epsilon_i \cdot r_i` (where :math:`r_i` is the range of objective :math:`i`). We instead use by default :math:`\epsilon_i \cdot |\mu_i|`.
11+
1012
- Instead of using the predicted :math:`\hat{\mu}` and :math:`\hat{\sigma}` also for the sampled points we use the measured :math:`\mu` and :math:`\sigma`.
1113

1214
- This implementation is directly scalable to :math:`n`-dimensional problems.

pyepal/pal/core.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# -*- coding: utf-8 -*-
2+
# pylint:disable=anomalous-backslash-in-string
23
# Copyright 2020 PyePAL authors
34
#
45
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -147,15 +148,16 @@ def _union_one_dim(
147148
return np.array(out_lows), np.array(out_ups)
148149

149150

150-
def _pareto_classify( # pylint:disable=too-many-arguments, too-many-locals
151+
def _pareto_classify( # pylint:disable=too-many-arguments, too-many-locals, too-many-branches
151152
pareto_optimal_0: np.array,
152153
not_pareto_optimal_0: np.array,
153154
unclassified_0: np.array,
154155
rectangle_lows: np.array,
155156
rectangle_ups: np.array,
156157
epsilon: np.array,
158+
is_fixed_epsilon: bool = False,
157159
) -> Tuple[np.array, np.array, np.array]:
158-
"""Performs the classification part of the algorithm
160+
"""Performs the classification of the algorithm
159161
(p. 4 of the PAL paper, see algorithm 1/2 of the epsilon-PAL paper)
160162
161163
One core concept is that once a point is classified,
@@ -172,6 +174,13 @@ def _pareto_classify( # pylint:disable=too-many-arguments, too-many-locals
172174
rectangle_lows (np.array): lower uncertainty boundaries
173175
rectangle_ups (np.array): upper uncertainty boundaries
174176
epsilon (np.array): granularity parameter (one per dimension)
177+
is_fixed_epsilon (bool): If true it assumes that epsilon contains *absolute*
178+
tolerance values for every objective. These would typically be calculated as
179+
:math:`\epsilon_i = \varepsilon_i \cdot r_i`, where :math:`r_i` is the range
180+
of objective :math:`i`. By default this is False. This is, we will use
181+
:math:`\epsilon_i = \varepsilon_i \cdot y_i` to compute the objectives,
182+
which hence avoids the need of knowing the range of a objective before
183+
using the algorithm.
175184
176185
Returns:
177186
Tuple[list, list, list]: binary encoded list of Pareto optimal,
@@ -186,11 +195,17 @@ def _pareto_classify( # pylint:disable=too-many-arguments, too-many-locals
186195
if sum(pareto_optimal_0) > 0:
187196
pareto_indices = np.where(pareto_optimal_0)[0]
188197
pareto_pessimistic_lows = rectangle_lows[pareto_indices] # p_pess(P)
198+
199+
if is_fixed_epsilon:
200+
tolerances_0 = np.tile(epsilon, (len(pareto_pessimistic_lows), 1))
201+
else:
202+
tolerances_0 = np.abs(epsilon * pareto_pessimistic_lows)
203+
189204
for i in range(0, len(unclassified_0)):
190205
if unclassified_t[i] == 1:
191206
# discard if any lower-bound epsilon dominates the upper bound
192207
if dominance_check_jitted_2(
193-
pareto_pessimistic_lows + np.abs(epsilon * pareto_pessimistic_lows),
208+
pareto_pessimistic_lows + tolerances_0,
194209
rectangle_ups[i],
195210
):
196211
not_pareto_optimal_t[i] = True
@@ -207,14 +222,21 @@ def _pareto_classify( # pylint:disable=too-many-arguments, too-many-locals
207222
pareto_unclassified_pessimistic_points = pareto_unclassified_lows[
208223
pareto_unclassified_pessimistic_mask
209224
]
225+
226+
if is_fixed_epsilon:
227+
tolerances_1 = np.tile(
228+
epsilon, (len(pareto_unclassified_pessimistic_points), 1)
229+
)
230+
else:
231+
tolerances_1 = epsilon * np.abs(pareto_unclassified_pessimistic_points)
232+
210233
for i in range(0, len(unclassified_t)): # pylint:disable=consider-using-enumerate
211234
# We can only discard points that are unclassified so far
212235
# We cannot discard points that are part of p_pess(P \cup U)
213236
if unclassified_t[i] and (i not in original_indices):
214237
# discard if any lower-bound epsilon dominates the upper bound
215238
if dominance_check_jitted_2(
216-
epsilon * np.abs(pareto_unclassified_pessimistic_points)
217-
+ pareto_unclassified_pessimistic_points,
239+
tolerances_1 + pareto_unclassified_pessimistic_points,
218240
rectangle_ups[i],
219241
):
220242
not_pareto_optimal_t[i] = True
@@ -228,6 +250,11 @@ def _pareto_classify( # pylint:disable=too-many-arguments, too-many-locals
228250

229251
index_map = {index: i for i, index in enumerate(unclassified_indices)}
230252

253+
if is_fixed_epsilon:
254+
tolerances_2 = np.tile(epsilon, (len(rectangle_lows), 1))
255+
else:
256+
tolerances_2 = epsilon * np.abs(rectangle_lows)
257+
231258
# The index map helps us to mask the current point from the unclassified_ups list
232259
for i in range(0, len(unclassified_t)): # pylint:disable=consider-using-enumerate
233260
# again, we only care about unclassified points
@@ -237,7 +264,7 @@ def _pareto_classify( # pylint:disable=too-many-arguments, too-many-locals
237264
# the current point is epsilon-accurate Pareto optimal
238265
if not dominance_check_jitted_3(
239266
unclassified_ups,
240-
rectangle_lows[i] + epsilon * np.abs(rectangle_lows[i]),
267+
rectangle_lows[i] + tolerances_2[i],
241268
index_map[i],
242269
):
243270
pareto_optimal_t[i] = True

pyepal/pal/pal_base.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# -*- coding: utf-8 -*-
2+
# pylint:disable=anomalous-backslash-in-string
23
# Copyright 2020 PyePAL authors
34
#
45
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -40,6 +41,7 @@
4041
validate_epsilon,
4142
validate_goals,
4243
validate_ndim,
44+
validate_ranges,
4345
)
4446

4547
PAL_LOGGER = logging.getLogger("PALLogger")
@@ -53,7 +55,7 @@
5355
__all__ = ["PALBase", "PAL_LOGGER"]
5456

5557

56-
class PALBase: # pylint:disable=too-many-instance-attributes
58+
class PALBase: # pylint:disable=too-many-instance-attributes, too-many-public-methods
5759
"""PAL base class"""
5860

5961
def __init__( # pylint:disable=too-many-arguments
@@ -66,6 +68,7 @@ def __init__( # pylint:disable=too-many-arguments
6668
beta_scale: float = 1 / 9,
6769
goals: List[str] = None,
6870
coef_var_threshold: float = 3,
71+
ranges: Union[np.ndarray, None] = None,
6972
):
7073
"""Initialize the PAL instance
7174
@@ -87,6 +90,11 @@ def __init__( # pylint:disable=too-many-arguments
8790
coef_var_threshold (float, optional): Use only points with
8891
a coefficient of variation below this threshold
8992
in the classification step. Defaults to 3.
93+
ranges (np.ndarray, optional): Numpy array of length ndmin,
94+
where each element contains the value range of given objective.
95+
If this is provided, we will use :math:`\epsilon \cdot ranges`
96+
to computer the uncertainties of the hyperrectangles instead
97+
of the default behavior :math:`\epsilon \cdot |\mu|`
9098
9199
"""
92100
self.cross_val_points = 10 # maybe we make it an argument at some point
@@ -112,6 +120,7 @@ def __init__( # pylint:disable=too-many-arguments
112120
self.design_space = X_design
113121
self.beta = None
114122
self.goals = validate_goals(goals, ndim)
123+
self.ranges = validate_ranges(ranges, ndim)
115124

116125
# self.y is what needs to be used for train/predict
117126
# as there the data has been turned into maximization
@@ -128,6 +137,16 @@ def __repr__(self):
128137
{self.number_discarded_points} discarded points, \
129138
{self.number_unclassified_points} unclassified points."
130139

140+
def _uses_fixed_epsilon(self):
141+
if self.ranges is not None:
142+
return True
143+
return False
144+
145+
@property
146+
def uses_fixed_epsilon(self):
147+
"""True if it uses the fixed epsilon :math:`\epsilon \cdot ranges`"""
148+
return self._uses_fixed_epsilon()
149+
131150
def _reset(self):
132151
self.pareto_optimal = np.array([False] * self.number_design_points)
133152
self.discarded = np.array([False] * self.number_design_points)
@@ -373,15 +392,26 @@ def _update_coef_var_mask(self):
373392

374393
def _classify(self):
375394
self._update_coef_var_mask()
376-
pareto_optimal, discarded, unclassified = _pareto_classify(
377-
self.pareto_optimal[self.coef_var_mask],
378-
self.discarded[self.coef_var_mask],
379-
self.unclassified[self.coef_var_mask],
380-
self.rectangle_lows[self.coef_var_mask],
381-
self.rectangle_ups[self.coef_var_mask],
382-
self.epsilon,
383-
)
384-
395+
if self.uses_fixed_epsilon:
396+
pareto_optimal, discarded, unclassified = _pareto_classify(
397+
self.pareto_optimal[self.coef_var_mask],
398+
self.discarded[self.coef_var_mask],
399+
self.unclassified[self.coef_var_mask],
400+
self.rectangle_lows[self.coef_var_mask],
401+
self.rectangle_ups[self.coef_var_mask],
402+
self.epsilon * self.ranges,
403+
is_fixed_epsilon=True,
404+
)
405+
else:
406+
pareto_optimal, discarded, unclassified = _pareto_classify(
407+
self.pareto_optimal[self.coef_var_mask],
408+
self.discarded[self.coef_var_mask],
409+
self.unclassified[self.coef_var_mask],
410+
self.rectangle_lows[self.coef_var_mask],
411+
self.rectangle_ups[self.coef_var_mask],
412+
self.epsilon,
413+
is_fixed_epsilon=False,
414+
)
385415
self.pareto_optimal[self.coef_var_mask] = pareto_optimal
386416
self.discarded[self.coef_var_mask] = discarded
387417
self.unclassified[self.coef_var_mask] = unclassified

pyepal/pal/validate_inputs.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import collections
1919
import warnings
2020
from copy import deepcopy
21-
from typing import Any, Iterable, List, Sequence
21+
from typing import Any, Iterable, List, Sequence, Union
2222

2323
import numpy as np
2424
from sklearn.gaussian_process import GaussianProcessRegressor
@@ -446,3 +446,20 @@ def validate_positive_integer_list(
446446
raise ValueError("{} must be a positive integer".format(parameter_name))
447447

448448
return seq
449+
450+
451+
def validate_ranges(ranges: Any, ndim: int) -> Union[None, np.ndarray]:
452+
"""Make sure that it has the correct numnber of elements and that all
453+
elements are positive."""
454+
if not isinstance(ranges, (np.ndarray, list)):
455+
return None
456+
457+
if not len(ranges) == ndim:
458+
raise ValueError(
459+
"The number of elements in ranges must match the number of objectives."
460+
)
461+
for elem in ranges:
462+
if not elem > 0:
463+
raise ValueError("Ranges must be positive.")
464+
465+
return np.array(ranges)

tests/test_pal_base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ def test_pal_base(make_random_dataset):
5252
palinstance.sample()
5353

5454
assert palinstance.y.shape == (100, 3)
55+
assert not palinstance.uses_fixed_epsilon
56+
57+
palinstance = PALBase(make_random_dataset[0], ["model"], 3, ranges=[1, 1, 1])
58+
assert palinstance.uses_fixed_epsilon
5559

5660

5761
def test_update_train_set(make_random_dataset):

tests/test_pal_core.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,28 @@ def test_pareto_classify(binh_korn_points): # pylint:disable=too-many-locals
273273
== np.array([False, False, False, False, False, False, False, True])
274274
).all()
275275

276+
pareto_optimal_t, discarded_t, unclassified_t = _pareto_classify(
277+
is_pareto_optimal,
278+
is_discarded,
279+
is_unclassified,
280+
rectangle_lows,
281+
rectangle_ups,
282+
np.array([0.1, 0.1]),
283+
is_fixed_epsilon=True,
284+
)
285+
286+
assert (
287+
pareto_optimal_t
288+
== np.array([True, True, True, False, True, False, False, False])
289+
).all()
290+
assert (
291+
discarded_t == np.array([False, False, False, True, False, True, True, False])
292+
).all()
293+
assert (
294+
unclassified_t
295+
== np.array([False, False, False, False, False, False, False, True])
296+
).all()
297+
276298
# 3D arrays, but 3rd dimenension alsways 0
277299

278300
pareto_optimal_points = np.array([[0.5, 2, 0], [3, 1, 0], [4, 0.5, 0]])

tests/test_pal_sklearn.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,34 @@ def test_orchestration_run_one_step_batch( # pylint:disable=too-many-statements
277277
for model in palinstance.models:
278278
assert check_is_fitted(model) is None
279279

280+
# test using the "fixed" epsilon
281+
gpr_0 = GaussianProcessRegressor(
282+
RBF(), normalize_y=True, n_restarts_optimizer=6, random_state=10
283+
)
284+
gpr_1 = GaussianProcessRegressor(
285+
RBF(), normalize_y=True, n_restarts_optimizer=6, random_state=10
286+
)
287+
palinstance = PALSklearn(
288+
X_binh_korn,
289+
[gpr_0, gpr_1],
290+
2,
291+
beta_scale=1 / 9,
292+
ranges=np.ptp(y_binh_korn, axis=0),
293+
)
294+
assert palinstance.uses_fixed_epsilon
295+
palinstance.cross_val_points = 0
296+
sample_idx = np.array([1, 10, 20, 40, 70, 90])
297+
palinstance.update_train_set(sample_idx, y_binh_korn[sample_idx])
298+
idx = palinstance.run_one_step(batch_size=1)
299+
for index in idx:
300+
assert index not in [1, 10, 20, 40, 70, 90]
301+
assert palinstance.number_sampled_points > 0
302+
assert sum(palinstance.unclassified) > 0
303+
assert sum(palinstance.discarded) == 0
304+
305+
for model in palinstance.models:
306+
assert check_is_fitted(model) is None
307+
280308

281309
def test_orchestration_run_one_step_parallel(binh_korn_points):
282310
"""Test the parallel processing"""

tests/test_validate_inputs.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
validate_number_models,
4242
validate_optimizers,
4343
validate_positive_integer_list,
44+
validate_ranges,
4445
)
4546

4647

@@ -316,3 +317,18 @@ def test_validate_positive_integer_list():
316317
validate_positive_integer_list(-1, 2)
317318

318319
assert validate_positive_integer_list(1, 2) == [1, 1]
320+
321+
322+
def test_validate_ranges():
323+
"""Check that the range validation works"""
324+
arr = np.array([1, 1, 1])
325+
assert (arr == validate_ranges(arr, 3)).all()
326+
327+
with pytest.raises(ValueError):
328+
validate_ranges(arr, 2)
329+
330+
with pytest.raises(ValueError):
331+
validate_ranges(np.array([-0.1, 0.1, 1]), 2)
332+
333+
assert validate_ranges(None, 3) is None
334+
assert (validate_ranges([1, 1, 1], 3) == np.array([1, 1, 1])).all()

0 commit comments

Comments
 (0)