-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgp_opt.py
More file actions
executable file
·1194 lines (964 loc) · 42.1 KB
/
Copy pathgp_opt.py
File metadata and controls
executable file
·1194 lines (964 loc) · 42.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Classes that implement SafeOpt.
Authors: - Felix Berkenkamp (befelix at inf dot ethz dot ch)
- Nicolas Carion (carion dot nicolas at gmail dot com)
"""
from __future__ import print_function, absolute_import, division
from collections import Sequence
from functools import partial
import numpy as np
from scipy.spatial.distance import cdist
from scipy.special import expit
from scipy.stats import norm
from builtins import range
from .utilities import (plot_2d_gp, plot_3d_gp, plot_contour_gp,
linearly_spaced_combinations)
from .swarm import SwarmOptimization
import logging
__all__ = ['SafeOpt', 'SafeOptSwarm']
class GaussianProcessOptimization(object):
"""
Base class for GP optimization.
Handles common functionality.
Parameters
----------
gp: GPy Gaussian process
fmin : float or list of floats
Safety threshold for the function value. If multiple safety constraints
are used this can also be a list of floats (the first one is always
the one for the values, can be set to None if not wanted).
beta: float or callable
A constant or a function of the time step that scales the confidence
interval of the acquisition function.
threshold: float or list of floats
The algorithm will not try to expand any points that are below this
threshold. This makes the algorithm stop expanding points eventually.
If a list, this represents the stopping criterion for all the gps.
This ignores the scaling factor.
scaling: list of floats or "auto"
A list used to scale the GP uncertainties to compensate for
different input sizes. This should be set to the maximal variance of
each kernel. You should probably leave this to "auto" unless your
kernel is non-stationary.
"""
def __init__(self, gp, fmin, beta=2, num_contexts=0, threshold=0,
scaling='auto'):
"""Initialization, see `GaussianProcessOptimization`."""
super(GaussianProcessOptimization, self).__init__()
if isinstance(gp, list):
self.gps = gp
else:
self.gps = [gp]
self.gp = self.gps[0]
self.fmin = fmin
if not isinstance(self.fmin, list):
self.fmin = [self.fmin] * len(self.gps)
self.fmin = np.atleast_1d(np.asarray(self.fmin).squeeze())
if hasattr(beta, '__call__'):
# Beta is a function of t
self.beta = beta
else:
# Assume that beta is a constant
self.beta = lambda t: beta
if scaling == 'auto':
dummy_point = np.zeros((1, self.gps[0].input_dim))
self.scaling = [gpm.kern.Kdiag(dummy_point)[0] for gpm in self.gps]
self.scaling = np.sqrt(np.asarray(self.scaling))
else:
self.scaling = np.asarray(scaling)
if self.scaling.shape[0] != len(self.gps):
raise ValueError("The number of scaling values should be "
"equal to the number of GPs")
self.threshold = threshold
self._parameter_set = None
self.bounds = None
self.num_samples = 0
self.num_contexts = num_contexts
self._x = None
self._y = None
self._get_initial_xy()
@property
def x(self):
return self._x
@property
def y(self):
return self._y
@property
def data(self):
"""Return the data within the GP models."""
return self._x, self._y
@property
def t(self):
"""Return the time step (number of measurements)."""
return self._x.shape[0]
def _get_initial_xy(self):
"""Get the initial x/y data from the GPs."""
self._x = self.gp.X
y = [self.gp.Y]
for gp in self.gps[1:]:
if np.allclose(self._x, gp.X):
y.append(gp.Y)
else:
raise NotImplemented('The GPs have different measurements.')
self._y = np.concatenate(y, axis=1)
def plot(self, n_samples, axis=None, figure=None, plot_3d=False,
**kwargs):
"""
Plot the current state of the optimization.
Parameters
----------
n_samples: int
How many samples to use for plotting
axis: matplotlib axis
The axis on which to draw (does not get cleared first)
figure: matplotlib figure
Ignored if axis is already defined
plot_3d: boolean
If set to true shows a 3D plot for 2 dimensional data
"""
# Fix contexts to their current values
if self.num_contexts > 0 and 'fixed_inputs' not in kwargs:
kwargs.update(fixed_inputs=self.context_fixed_inputs)
true_input_dim = self.gp.kern.input_dim - self.num_contexts
if true_input_dim == 1 or plot_3d:
inputs = np.zeros((n_samples ** true_input_dim, self.gp.input_dim))
inputs[:, :true_input_dim] = linearly_spaced_combinations(
self.bounds[:true_input_dim],
n_samples)
if not isinstance(n_samples, Sequence):
n_samples = [n_samples] * len(self.bounds)
axes = []
if self.gp.input_dim - self.num_contexts == 1:
# 2D plots with uncertainty
for gp, fmin in zip(self.gps, self.fmin):
if fmin == -np.inf:
fmin = None
ax = plot_2d_gp(gp, inputs, figure=figure, axis=axis,
fmin=fmin, **kwargs)
axes.append(ax)
else:
if plot_3d:
for gp in self.gps:
plot_3d_gp(gp, inputs, figure=figure, axis=axis, **kwargs)
else:
for gp in self.gps:
plot_contour_gp(gp,
[np.linspace(self.bounds[0][0],
self.bounds[0][1],
n_samples[0]),
np.linspace(self.bounds[1][0],
self.bounds[1][1],
n_samples[1])],
figure=figure,
axis=axis)
def _add_context(self, x, context):
"""Add the context to a vector.
Parameters
----------
x : ndarray
context : ndarray
Returns
-------
x_extended : ndarray
"""
context = np.atleast_2d(context)
num_contexts = context.shape[1]
x2 = np.empty((x.shape[0], x.shape[1] + num_contexts), dtype=float)
x2[:, :x.shape[1]] = x
x2[:, x.shape[1]:] = context
return x2
def _add_data_point(self, gp, x, y, context=None):
"""Add a data point to a particular GP.
This should only be called on its own if you know what you're doing.
This does not update the global data stores self.x and self.y.
Parameters
----------
x: 2d-array
y: 2d-array
context: array_like
The context(s) used for the data points
gp: instance of GPy.model.GPRegression
If specified, determines the GP to which we add the data point
to. Note that this should only be used if that data point is going
to be removed again.
"""
if context is not None:
x = self._add_context(x, context)
gp.set_XY(np.vstack([gp.X, x]),
np.vstack([gp.Y, y]))
def add_new_data_point(self, x, y, context=None):
"""
Add a new function observation to the GPs.
Parameters
----------
x: 2d-array
y: 2d-array
context: array_like
The context(s) used for the data points.
"""
x = np.atleast_2d(x)
y = np.atleast_2d(y)
if self.num_contexts:
x = self._add_context(x, context)
for i, gp in enumerate(self.gps):
not_nan = ~np.isnan(y[:, i])
if np.any(not_nan):
# Add data to GP (context already included in x)
self._add_data_point(gp, x[not_nan, :], y[not_nan, [i]])
# Update global data stores
self._x = np.concatenate((self._x, x), axis=0)
self._y = np.concatenate((self._y, y), axis=0)
def _remove_last_data_point(self, gp):
"""Remove the last data point of a specific GP.
This does not update global data stores, self.x and self.y.
Parameters
----------
gp: Instance of GPy.models.GPRegression
The gp that the last data point should be removed from
"""
gp.set_XY(gp.X[:-1, :], gp.Y[:-1, :])
def remove_last_data_point(self):
"""Remove the data point that was last added to the GP."""
last_y = self._y[-1]
for gp, yi in zip(self.gps, last_y):
if not np.isnan(yi):
gp.set_XY(gp.X[:-1, :], gp.Y[:-1, :])
self._x = self._x[:-1, :]
self._y = self._y[:-1, :]
class SafeOpt(GaussianProcessOptimization):
"""A class for Safe Bayesian Optimization.
This class implements the `SafeOpt` algorithm. It uses a Gaussian
process model in order to determine parameter combinations that are safe
with high probability. Based on these, it aims to both expand the set of
safe parameters and to find the optimal parameters within the safe set.
Parameters
----------
gp: GPy Gaussian process
A Gaussian process which is initialized with safe, initial data points.
If a list of GPs then the first one is the value, while all the
other ones are safety constraints.
parameter_set: 2d-array
List of parameters
fmin: list of floats
Safety threshold for the function value. If multiple safety constraints
are used this can also be a list of floats (the first one is always
the one for the values, can be set to None if not wanted)
lipschitz: list of floats
The Lipschitz constant of the system, if None the GP confidence
intervals are used directly.
beta: float or callable
A constant or a function of the time step that scales the confidence
interval of the acquisition function.
threshold: float or list of floats
The algorithm will not try to expand any points that are below this
threshold. This makes the algorithm stop expanding points eventually.
If a list, this represents the stopping criterion for all the gps.
This ignores the scaling factor.
scaling: list of floats or "auto"
A list used to scale the GP uncertainties to compensate for
different input sizes. This should be set to the maximal variance of
each kernel. You should probably leave this to "auto" unless your
kernel is non-stationary.
Examples
--------
# >>> from safeopt import SafeOpt
# >>> from safeopt import linearly_spaced_combinations
# >>> import GPy
# >>> import numpy as np
Define a Gaussian process prior over the performance
#
# >>> x = np.array([[0.]])
# >>> y = np.array([[1.]])
# >>> gp = GPy.models.GPRegression(x, y, noise_var=0.01**2)
# >>> bounds = [[-1., 1.]]
# >>> parameter_set = linearly_spaced_combinations([[-1., 1.]],
# ... num_samples=100)
Initialize the Bayesian optimization and get new parameters to evaluate
# >>> opt = SafeOpt(gp, parameter_set, fmin=[0.])
# >>> next_parameters = opt.optimize()
Add a new data point with the parameters and the performance to the GP. The
performance has normally be determined through an external function call.
# >>> performance = np.array([[1.]])
# >>> opt.add_new_data_point(next_parameters, performance)
"""
def __init__(self, gp, parameter_set, fmin, lipschitz=None, beta=2,
num_contexts=0, threshold=0, scaling='auto'):
"""Initialization, see `SafeOpt`."""
super(SafeOpt, self).__init__(gp,
fmin=fmin,
beta=beta,
num_contexts=num_contexts,
threshold=threshold,
scaling=scaling)
if self.num_contexts > 0:
context_shape = (parameter_set.shape[0], self.num_contexts)
self.inputs = np.hstack((parameter_set,
np.zeros(context_shape,
dtype=parameter_set.dtype)))
self.parameter_set = self.inputs[:, :-self.num_contexts]
else:
self.inputs = self.parameter_set = parameter_set
self.liptschitz = lipschitz
if self.liptschitz is not None:
if not isinstance(self.liptschitz, list):
self.liptschitz = [self.liptschitz] * len(self.gps)
self.liptschitz = np.atleast_1d(
np.asarray(self.liptschitz).squeeze())
# Value intervals
self.Q = np.empty((self.inputs.shape[0], 2 * len(self.gps)),
dtype=np.float)
# Safe set
self.S = np.zeros(self.inputs.shape[0], dtype=np.bool)
# Switch to use confidence intervals for safety
if lipschitz is None:
self._use_lipschitz = False
else:
self._use_lipschitz = True
# Set of expanders and maximizers
self.G = self.S.copy()
self.M = self.S.copy()
@property
def use_lipschitz(self):
"""
Boolean that determines whether to use the Lipschitz constant.
By default this is set to False, which means the adapted SafeOpt
algorithm is used, that uses the GP confidence intervals directly.
If set to True, the `self.lipschitz` parameter is used to compute
the safe and expanders sets.
"""
return self._use_lipschitz
@use_lipschitz.setter
def use_lipschitz(self, value):
if value and self.liptschitz is None:
raise ValueError('Lipschitz constant not defined')
self._use_lipschitz = value
@property
def parameter_set(self):
"""Discrete parameter samples for Bayesian optimization."""
return self._parameter_set
@parameter_set.setter
def parameter_set(self, parameter_set):
self._parameter_set = parameter_set
# Plotting bounds (min, max value
self.bounds = list(zip(np.min(self._parameter_set, axis=0),
np.max(self._parameter_set, axis=0)))
self.num_samples = [len(np.unique(self._parameter_set[:, i]))
for i in range(self._parameter_set.shape[1])]
@property
def context_fixed_inputs(self):
"""Return the fixed inputs for the current context."""
n = self.gp.input_dim - 1
nc = self.num_contexts
if nc > 0:
contexts = self.inputs[0, -self.num_contexts:]
return list(zip(range(n, n - nc, -1), contexts))
@property
def context(self):
"""Return the current context variables."""
if self.num_contexts:
return self.inputs[0, -self.num_contexts:]
@context.setter
def context(self, context):
"""Set the current context and update confidence intervals.
Parameters
----------
context: ndarray
New context that should be applied to the input parameters
"""
if self.num_contexts:
if context is None:
raise ValueError('Need to provide value for context.')
self.inputs[:, -self.num_contexts:] = context
def update_confidence_intervals(self, context=None):
"""Recompute the confidence intervals form the GP.
Parameters
----------
context: ndarray
Array that contains the context used to compute the sets
"""
beta = self.beta(self.t)
# Update context to current setting
self.context = context
# Iterate over all functions
for i in range(len(self.gps)):
# Evaluate acquisition function
mean, var = self.gps[i].predict_noiseless(self.inputs)
mean = mean.squeeze()
std_dev = np.sqrt(var.squeeze())
# Update confidence intervals
self.Q[:, 2 * i] = mean - beta * std_dev
self.Q[:, 2 * i + 1] = mean + beta * std_dev
def compute_safe_set(self):
"""Compute only the safe set based on the current confidence bounds."""
# Update safe set
self.S[:] = np.all(self.Q[:, ::2] > self.fmin, axis=1)
def compute_sets(self, full_sets=False):
"""
Compute the safe set of points, based on current confidence bounds.
Parameters
----------
context: ndarray
Array that contains the context used to compute the sets
full_sets: boolean
Whether to compute the full set of expanders or whether to omit
computations that are not relevant for running SafeOpt
(This option is only useful for plotting purposes)
"""
beta = self.beta(self.t)
# Update safe set
self.compute_safe_set()
# Reference to confidence intervals
l, u = self.Q[:, :2].T
if not np.any(self.S):
self.M[:] = False
self.G[:] = False
return
# Set of possible maximisers
# Maximizers: safe upper bound above best, safe lower bound
self.M[:] = False
self.M[self.S] = u[self.S] >= np.max(l[self.S])
max_var = np.max(u[self.M] - l[self.M]) / self.scaling[0]
# Optimistic set of possible expanders
l = self.Q[:, ::2]
u = self.Q[:, 1::2]
self.G[:] = False
# For the run of the algorithm we do not need to calculate the
# full set of potential expanders:
# We can skip the ones already in M and ones that have lower
# variance than the maximum variance in M, max_var or the threshold.
# Amongst the remaining ones we only need to find the
# potential expander with maximum variance
if full_sets:
s = self.S
else:
# skip points in M, they will already be evaluated
s = np.logical_and(self.S, ~self.M)
# Remove points with a variance that is too small
s[s] = (np.max((u[s, :] - l[s, :]) / self.scaling, axis=1) >
max_var)
s[s] = np.any(u[s, :] - l[s, :] > self.threshold * beta, axis=1)
if not np.any(s):
# no need to evaluate any points as expanders in G, exit
return
def sort_generator(array):
"""Return the sorted array, largest element first."""
return array.argsort()[::-1]
# set of safe expanders
G_safe = np.zeros(np.count_nonzero(s), dtype=np.bool)
if not full_sets:
# Sort, element with largest variance first
sort_index = sort_generator(np.max(u[s, :] - l[s, :],
axis=1))
else:
# Sort index is just an enumeration of all safe states
sort_index = range(len(G_safe))
for index in sort_index:
if self.use_lipschitz:
# Distance between current index point and all other unsafe
# points
d = cdist(self.inputs[s, :][[index], :],
self.inputs[~self.S, :])
# Check if expander for all GPs
for i in range(len(self.gps)):
# Skip evaluation if 'no' safety constraint
if self.fmin[i] == -np.inf:
continue
# Safety: u - L * d >= fmin
G_safe[index] =\
np.any(u[s, i][index] - self.liptschitz[i] * d >=
self.fmin[i])
# Stop evaluating if not expander according to one
# safety constraint
if not G_safe[index]:
break
else:
# Check if expander for all GPs
for i, gp in enumerate(self.gps):
# Skip evlauation if 'no' safety constraint
if self.fmin[i] == -np.inf:
continue
# Add safe point with its max possible value to the gp
self._add_data_point(gp=gp,
x=self.parameter_set[s, :][index, :],
y=u[s, i][index],
context=self.context)
# Prediction of previously unsafe points based on that
mean2, var2 = gp.predict_noiseless(self.inputs[~self.S])
# Remove the fake data point from the GP again
self._remove_last_data_point(gp=gp)
mean2 = mean2.squeeze()
var2 = var2.squeeze()
l2 = mean2 - beta * np.sqrt(var2)
# If any unsafe lower bound is suddenly above fmin then
# the point is an expander
G_safe[index] = np.any(l2 >= self.fmin[i])
# Break if one safety GP is not an expander
if not G_safe[index]:
break
# Since we sorted by uncertainty and only the most
# uncertain element gets picked by SafeOpt anyways, we can
# stop after we found the first one
if G_safe[index] and not full_sets:
break
# Update safe set (if full_sets is False this is at most one point
self.G[s] = G_safe
def get_new_query_point(self, ucb=False):
"""
Compute a new point at which to evaluate the function.
Parameters
----------
ucb: bool
If True the safe-ucb criteria is used instead.
Returns
-------
x: np.array
The next parameters that should be evaluated.
"""
if not np.any(self.S):
raise EnvironmentError('There are no safe points to evaluate.')
if ucb:
max_id = np.argmax(self.Q[self.S, 1])
x = self.inputs[self.S, :][max_id, :]
else:
# Get lower and upper bounds
l = self.Q[:, ::2]
u = self.Q[:, 1::2]
MG = np.logical_or(self.M, self.G)
value = np.max((u[MG] - l[MG]) / self.scaling, axis=1)
x = self.inputs[MG, :][np.argmax(value), :]
if self.num_contexts:
return x[:-self.num_contexts]
else:
return x
def optimize(self, context=None, ucb=False):
"""Run Safe Bayesian optimization and get the next parameters.
Parameters
----------
context: ndarray
A vector containing the current context
ucb: bool
If True the safe-ucb criteria is used instead.
Returns
-------
x: np.array
The next parameters that should be evaluated.
"""
# Update confidence intervals based on current estimate
self.update_confidence_intervals(context=context)
# Update the sets
if ucb:
self.compute_safe_set()
else:
self.compute_sets()
return self.get_new_query_point(ucb=ucb)
def get_maximum(self, context=None):
"""
Return the current estimate for the maximum.
Parameters
----------
context: ndarray
A vector containing the current context
Returns
-------
x - ndarray
Location of the maximum
y - 0darray
Maximum value
Notes
-----
Uses the current context and confidence intervals!
Run update_confidence_intervals first if you recently added a new data
point.
"""
self.update_confidence_intervals(context=context)
# Compute the safe set (that's cheap anyways)
self.compute_safe_set()
# Return nothing if there are no safe points
if not np.any(self.S):
return None
l = self.Q[self.S, 0]
max_id = np.argmax(l)
return (self.inputs[self.S, :][max_id, :-self.num_contexts or None],
l[max_id])
class SafeOptSwarm(GaussianProcessOptimization):
"""SafeOpt for larger dimensions using a Swarm Optimization heuristic.
Note that it doesn't support the use of a Lipschitz constant nor contextual
optimization.
You can set your logging level to INFO to get more insights on the
optimization process.
Parameters
----------
gp: GPy Gaussian process
A Gaussian process which is initialized with safe, initial data points.
If a list of GPs then the first one is the value, while all the
other ones are safety constraints.
fmin: list of floats
Safety threshold for the function value. If multiple safety constraints
are used this can also be a list of floats (the first one is always
the one for the values, can be set to None if not wanted)
bounds: pair of floats or list of pairs of floats
If a list is given, then each pair represents the lower/upper bound in
each dimension. Otherwise, we assume the same bounds for all
dimensions. This is mostly important for plotting or to restrict
particles to a certain domain.
beta: float or callable
A constant or a function of the time step that scales the confidence
interval of the acquisition function.
threshold: float or list of floats
The algorithm will not try to expand any points that are below this
threshold. This makes the algorithm stop expanding points eventually.
If a list, this represents the stopping criterion for all the gps.
This ignores the scaling factor.
scaling: list of floats or "auto"
A list used to scale the GP uncertainties to compensate for
different input sizes. This should be set to the maximal variance of
each kernel. You should probably set this to "auto" unless your kernel
is non-stationary
swarm_size: int
The number of particles in each of the optimization swarms
Examples
--------
# >>> from safeopt import SafeOptSwarm
# >>> import GPy
# >>> import numpy as np
Define a Gaussian process prior over the performance
# >>> x = np.array([[0.]])
# >>> y = np.array([[1.]])
# >>> gp = GPy.models.GPRegression(x, y, noise_var=0.01**2)
Initialize the Bayesian optimization and get new parameters to evaluate
# >>> opt = SafeOptSwarm(gp, fmin=[0.], bounds=[[-1., 1.]])
# >>> next_parameters = opt.optimize()
Add a new data point with the parameters and the performance to the GP. The
performance has normally be determined through an external function call.
# >>> performance = np.array([[1.]])
# >>> opt.add_new_data_point(next_parameters, performance)
"""
def __init__(self, gp, fmin, bounds, beta=2, scaling='auto', threshold=0,
swarm_size=20):
"""Initialization, see `SafeOptSwarm`."""
super(SafeOptSwarm, self).__init__(gp,
fmin=fmin,
beta=beta,
num_contexts=0,
threshold=threshold,
scaling=scaling)
# Safe set
self.S = np.asarray(self.gps[0].X)
self.swarm_size = swarm_size
self.max_iters = 100 # number of swarm iterations
if not isinstance(bounds, list):
self.bounds = [bounds] * self.S.shape[1]
else:
self.bounds = bounds
# These are estimates of the best lower bound, and its location
self.best_lower_bound = -np.inf
self.greedy_point = self.S[0, :]
self.optimal_velocities = self.optimize_particle_velocity()
swarm_types = ['greedy', 'maximizers', 'expanders']
self.swarms = {swarm_type:
SwarmOptimization(
swarm_size,
self.optimal_velocities,
partial(self._compute_particle_fitness,
swarm_type),
bounds=self.bounds)
for swarm_type in swarm_types}
def optimize_particle_velocity(self):
"""Optimize the velocities of the particles.
Note that this only works well for stationary kernels and constant mean
functions. Otherwise the velocity depends on the position!
Returns
-------
velocities: ndarray
The estimated optimal velocities in each direction.
"""
parameters = np.zeros((1, self.gp.input_dim), dtype=np.float)
velocities = np.empty((len(self.gps), self.gp.input_dim),
dtype=np.float)
for i, gp in enumerate(self.gps):
for j in range(self.gp.input_dim):
tmp_velocities = np.zeros((1, self.gp.input_dim),
dtype=np.float)
# lower and upper bounds on velocities
upper_velocity = 1000.
lower_velocity = 0.
# Binary search over optimal velocities
while True:
mid = (upper_velocity + lower_velocity) / 2
tmp_velocities[0, j] = mid
kernel_matrix = gp.kern.K(parameters, tmp_velocities)
covariance = kernel_matrix.squeeze() / self.scaling[i] ** 2
# Make sure the correlation is in the sweet spot
velocity_enough = covariance > 0.94
not_too_fast = covariance < 0.95
if not_too_fast:
upper_velocity = mid
elif velocity_enough:
lower_velocity = mid
if ((not_too_fast and velocity_enough) or
upper_velocity - lower_velocity < 1e-5):
break
# Store optimal velocity
velocities[i, j] = mid
# Select the minimal velocity (for the toughest safety constraint)
velocities = np.min(velocities, axis=0)
# Scale for number of parameters (this might not be so clever if they
# are all independent, additive kernels).
velocities /= np.sqrt(self.gp.input_dim)
return velocities
def _compute_penalty(self, slack):
"""Return the penalty associated to a constraint violation.
The penalty is a piecewise linear function that is nonzero only if the
safety constraints are violated. This penalty encourages particles to
stay within the safe set.
Parameters
----------
slack: ndarray
A vector corresponding to how much the constraint was violated.
Returns
-------
penalties - ndarray
The value of the penalties
"""
penalties = np.atleast_1d(np.clip(slack, None, 0))
penalties[(slack < 0) & (slack > -0.001)] *= 2
penalties[(slack <= -0.001) & (slack > -0.1)] *= 5
penalties[(slack <= -0.1) & (slack > -1)] *= 10
slack_id = slack < -1
penalties[slack_id] = -300 * penalties[slack_id] ** 2
return penalties
def _compute_particle_fitness(self, swarm_type, particles):
"""
Return the value of the particles and the safety information.
Parameters
----------
particles : ndarray
A vector containing the coordinates of the particles
swarm_type : string
A string corresponding to the swarm type. It can be any of the
following strings:
* 'greedy' : Optimal value(best lower bound).
* 'expander' : Expanders (lower bound close to constraint)
* 'maximizer' : Maximizers (Upper bound better than best l)
* 'safe_set' : Only check the safety of the particles
Returns
-------
values : ndarray
The values of the particles
global_safe : ndarray
A boolean mask indicating safety status of all particles
(note that in the case of a greedy swarm, this is not computed and
we return a True mask)
"""
beta = self.beta(self.t)
# classify the particle's function values
mean, var = self.gps[0].predict_noiseless(particles)
mean = mean.squeeze()
std_dev = np.sqrt(var.squeeze())
# compute the confidence interval
lower_bound = np.atleast_1d(mean - beta * std_dev)
upper_bound = np.atleast_1d(mean + beta * std_dev)
# the greedy swarm optimizes for the lower bound
if swarm_type == 'greedy':
return lower_bound, np.broadcast_to(True, len(lower_bound))
# value we are optimizing for. Expanders and maximizers seek high
# variance points
values = std_dev / self.scaling[0]
#
is_safe = swarm_type == 'safe_set'
is_expander = swarm_type == 'expanders'
is_maximizer = swarm_type == 'maximizers'
if is_safe:
interest_function = None
else:
if is_expander:
# For expanders, the interest function is updated depending on
# the lower bounds
interest_function = (len(self.gps) *
np.ones(np.shape(values), dtype=np.float))
elif is_maximizer:
improvement = upper_bound - self.best_lower_bound
interest_function = expit(10 * improvement / self.scaling[0])
else:
# unknown particle type (shouldn't happen)
raise AssertionError("Invalid swarm type")
# boolean mask that tell if the particles are safe according to all gps
global_safe = np.ones(particles.shape[0], dtype=np.bool)
total_penalty = np.zeros(particles.shape[0], dtype=np.float)
for i, (gp, scaling) in enumerate(zip(self.gps, self.scaling)):
# Only recompute confidence intervals for constraints
if i > 0:
# classify using the current GP
mean, var = gp.predict_noiseless(particles)
mean = mean.squeeze()
std_dev = np.sqrt(var.squeeze())
lower_bound = mean - beta * std_dev
values = np.maximum(values, std_dev / scaling)
# if the current GP has no safety constrain, we skip it
if self.fmin[i] == -np.inf:
continue
slack = np.atleast_1d(lower_bound - self.fmin[i])
# computing penalties
global_safe &= slack >= 0
# Skip cost update for safety evaluation
if is_safe:
continue
# Normalize the slack somewhat
slack /= scaling
total_penalty += self._compute_penalty(slack)
if is_expander: