File size: 13,267 Bytes
7885a28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Unit test for DIRECT optimization algorithm.
"""
from numpy.testing import (assert_allclose,
                           assert_array_less)
import pytest
import numpy as np
from scipy.optimize import direct, Bounds
import threading


class TestDIRECT:

    def setup_method(self):
        self.fun_calls = threading.local()
        self.bounds_sphere = 4*[(-2, 3)]
        self.optimum_sphere_pos = np.zeros((4, ))
        self.optimum_sphere = 0.0
        self.bounds_stylinski_tang = Bounds([-4., -4.], [4., 4.])
        self.maxiter = 1000

    # test functions
    def sphere(self, x):
        if not hasattr(self.fun_calls, 'c'):
            self.fun_calls.c = 0
        self.fun_calls.c += 1
        return np.square(x).sum()

    def inv(self, x):
        if np.sum(x) == 0:
            raise ZeroDivisionError()
        return 1/np.sum(x)

    def nan_fun(self, x):
        return np.nan

    def inf_fun(self, x):
        return np.inf

    def styblinski_tang(self, pos):
        x, y = pos
        return 0.5 * (x**4 - 16 * x**2 + 5 * x + y**4 - 16 * y**2 + 5 * y)

    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_direct(self, locally_biased):
        res = direct(self.sphere, self.bounds_sphere,
                     locally_biased=locally_biased)

        # test accuracy
        assert_allclose(res.x, self.optimum_sphere_pos,
                        rtol=1e-3, atol=1e-3)
        assert_allclose(res.fun, self.optimum_sphere, atol=1e-5, rtol=1e-5)

        # test that result lies within bounds
        _bounds = np.asarray(self.bounds_sphere)
        assert_array_less(_bounds[:, 0], res.x)
        assert_array_less(res.x, _bounds[:, 1])

        # test number of function evaluations. Original DIRECT overshoots by
        # up to 500 evaluations in last iteration
        assert res.nfev <= 1000 * (len(self.bounds_sphere) + 1)
        # test that number of function evaluations is correct
        assert res.nfev == self.fun_calls.c

        # test that number of iterations is below supplied maximum
        assert res.nit <= self.maxiter

    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_direct_callback(self, locally_biased):
        # test that callback does not change the result
        res = direct(self.sphere, self.bounds_sphere,
                     locally_biased=locally_biased)

        def callback(x):
            x = 2*x
            dummy = np.square(x)
            print("DIRECT minimization algorithm callback test")
            return dummy

        res_callback = direct(self.sphere, self.bounds_sphere,
                              locally_biased=locally_biased,
                              callback=callback)

        assert_allclose(res.x, res_callback.x)

        assert res.nit == res_callback.nit
        assert res.nfev == res_callback.nfev
        assert res.status == res_callback.status
        assert res.success == res_callback.success
        assert res.fun == res_callback.fun
        assert_allclose(res.x, res_callback.x)
        assert res.message == res_callback.message

        # test accuracy
        assert_allclose(res_callback.x, self.optimum_sphere_pos,
                        rtol=1e-3, atol=1e-3)
        assert_allclose(res_callback.fun, self.optimum_sphere,
                        atol=1e-5, rtol=1e-5)

    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_exception(self, locally_biased):
        bounds = 4*[(-10, 10)]
        with pytest.raises(ZeroDivisionError):
            direct(self.inv, bounds=bounds,
                   locally_biased=locally_biased)

    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_nan(self, locally_biased):
        bounds = 4*[(-10, 10)]
        direct(self.nan_fun, bounds=bounds,
               locally_biased=locally_biased)

    @pytest.mark.parametrize("len_tol", [1e-3, 1e-4])
    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_len_tol(self, len_tol, locally_biased):
        bounds = 4*[(-10., 10.)]
        res = direct(self.sphere, bounds=bounds, len_tol=len_tol,
                     vol_tol=1e-30, locally_biased=locally_biased)
        assert res.status == 5
        assert res.success
        assert_allclose(res.x, np.zeros((4, )))
        message = ("The side length measure of the hyperrectangle containing "
                   "the lowest function value found is below "
                   f"len_tol={len_tol}")
        assert res.message == message

    @pytest.mark.parametrize("vol_tol", [1e-6, 1e-8])
    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_vol_tol(self, vol_tol, locally_biased):
        bounds = 4*[(-10., 10.)]
        res = direct(self.sphere, bounds=bounds, vol_tol=vol_tol,
                     len_tol=0., locally_biased=locally_biased)
        assert res.status == 4
        assert res.success
        assert_allclose(res.x, np.zeros((4, )))
        message = ("The volume of the hyperrectangle containing the lowest "
                   f"function value found is below vol_tol={vol_tol}")
        assert res.message == message

    @pytest.mark.parametrize("f_min_rtol", [1e-3, 1e-5, 1e-7])
    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_f_min(self, f_min_rtol, locally_biased):
        # test that desired function value is reached within
        # relative tolerance of f_min_rtol
        f_min = 1.
        bounds = 4*[(-2., 10.)]
        res = direct(self.sphere, bounds=bounds, f_min=f_min,
                     f_min_rtol=f_min_rtol,
                     locally_biased=locally_biased)
        assert res.status == 3
        assert res.success
        assert res.fun < f_min * (1. + f_min_rtol)
        message = ("The best function value found is within a relative "
                   f"error={f_min_rtol} of the (known) global optimum f_min")
        assert res.message == message

    def circle_with_args(self, x, a, b):
        return np.square(x[0] - a) + np.square(x[1] - b).sum()

    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_f_circle_with_args(self, locally_biased):
        bounds = 2*[(-2.0, 2.0)]

        res = direct(self.circle_with_args, bounds, args=(1, 1), maxfun=1250,
                     locally_biased=locally_biased)
        assert_allclose(res.x, np.array([1., 1.]), rtol=1e-5)

    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_failure_maxfun(self, locally_biased):
        # test that if optimization runs for the maximal number of
        # evaluations, success = False is returned

        maxfun = 100
        result = direct(self.styblinski_tang, self.bounds_stylinski_tang,
                        maxfun=maxfun, locally_biased=locally_biased)
        assert result.success is False
        assert result.status == 1
        assert result.nfev >= maxfun
        message = ("Number of function evaluations done is "
                   f"larger than maxfun={maxfun}")
        assert result.message == message

    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_failure_maxiter(self, locally_biased):
        # test that if optimization runs for the maximal number of
        # iterations, success = False is returned

        maxiter = 10
        result = direct(self.styblinski_tang, self.bounds_stylinski_tang,
                        maxiter=maxiter, locally_biased=locally_biased)
        assert result.success is False
        assert result.status == 2
        assert result.nit >= maxiter
        message = f"Number of iterations is larger than maxiter={maxiter}"
        assert result.message == message

    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_bounds_variants(self, locally_biased):
        # test that new and old bounds yield same result

        lb = [-6., 1., -5.]
        ub = [-1., 3., 5.]
        x_opt = np.array([-1., 1., 0.])
        bounds_old = list(zip(lb, ub))
        bounds_new = Bounds(lb, ub)

        res_old_bounds = direct(self.sphere, bounds_old,
                                locally_biased=locally_biased)
        res_new_bounds = direct(self.sphere, bounds_new,
                                locally_biased=locally_biased)

        assert res_new_bounds.nfev == res_old_bounds.nfev
        assert res_new_bounds.message == res_old_bounds.message
        assert res_new_bounds.success == res_old_bounds.success
        assert res_new_bounds.nit == res_old_bounds.nit
        assert_allclose(res_new_bounds.x, res_old_bounds.x)
        assert_allclose(res_new_bounds.x, x_opt, rtol=1e-2)

    @pytest.mark.parametrize("locally_biased", [True, False])
    @pytest.mark.parametrize("eps", [1e-5, 1e-4, 1e-3])
    def test_epsilon(self, eps, locally_biased):
        result = direct(self.styblinski_tang, self.bounds_stylinski_tang,
                        eps=eps, vol_tol=1e-6,
                        locally_biased=locally_biased)
        assert result.status == 4
        assert result.success

    @pytest.mark.xslow
    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_no_segmentation_fault(self, locally_biased):
        # test that an excessive number of function evaluations
        # does not result in segmentation fault
        bounds = [(-5., 20.)] * 100
        result = direct(self.sphere, bounds, maxfun=10000000,
                        maxiter=1000000, locally_biased=locally_biased)
        assert result is not None

    @pytest.mark.parametrize("locally_biased", [True, False])
    def test_inf_fun(self, locally_biased):
        # test that an objective value of infinity does not crash DIRECT
        bounds = [(-5., 5.)] * 2
        result = direct(self.inf_fun, bounds,
                        locally_biased=locally_biased)
        assert result is not None

    @pytest.mark.parametrize("len_tol", [-1, 2])
    def test_len_tol_validation(self, len_tol):
        error_msg = "len_tol must be between 0 and 1."
        with pytest.raises(ValueError, match=error_msg):
            direct(self.styblinski_tang, self.bounds_stylinski_tang,
                   len_tol=len_tol)

    @pytest.mark.parametrize("vol_tol", [-1, 2])
    def test_vol_tol_validation(self, vol_tol):
        error_msg = "vol_tol must be between 0 and 1."
        with pytest.raises(ValueError, match=error_msg):
            direct(self.styblinski_tang, self.bounds_stylinski_tang,
                   vol_tol=vol_tol)

    @pytest.mark.parametrize("f_min_rtol", [-1, 2])
    def test_fmin_rtol_validation(self, f_min_rtol):
        error_msg = "f_min_rtol must be between 0 and 1."
        with pytest.raises(ValueError, match=error_msg):
            direct(self.styblinski_tang, self.bounds_stylinski_tang,
                   f_min_rtol=f_min_rtol, f_min=0.)

    @pytest.mark.parametrize("maxfun", [1.5, "string", (1, 2)])
    def test_maxfun_wrong_type(self, maxfun):
        error_msg = "maxfun must be of type int."
        with pytest.raises(ValueError, match=error_msg):
            direct(self.styblinski_tang, self.bounds_stylinski_tang,
                   maxfun=maxfun)

    @pytest.mark.parametrize("maxiter", [1.5, "string", (1, 2)])
    def test_maxiter_wrong_type(self, maxiter):
        error_msg = "maxiter must be of type int."
        with pytest.raises(ValueError, match=error_msg):
            direct(self.styblinski_tang, self.bounds_stylinski_tang,
                   maxiter=maxiter)

    def test_negative_maxiter(self):
        error_msg = "maxiter must be > 0."
        with pytest.raises(ValueError, match=error_msg):
            direct(self.styblinski_tang, self.bounds_stylinski_tang,
                   maxiter=-1)

    def test_negative_maxfun(self):
        error_msg = "maxfun must be > 0."
        with pytest.raises(ValueError, match=error_msg):
            direct(self.styblinski_tang, self.bounds_stylinski_tang,
                   maxfun=-1)

    @pytest.mark.parametrize("bounds", ["bounds", 2., 0])
    def test_invalid_bounds_type(self, bounds):
        error_msg = ("bounds must be a sequence or "
                     "instance of Bounds class")
        with pytest.raises(ValueError, match=error_msg):
            direct(self.styblinski_tang, bounds)

    @pytest.mark.parametrize("bounds",
                             [Bounds([-1., -1], [-2, 1]),
                              Bounds([-np.nan, -1], [-2, np.nan]),
                              ]
                             )
    def test_incorrect_bounds(self, bounds):
        error_msg = 'Bounds are not consistent min < max'
        with pytest.raises(ValueError, match=error_msg):
            direct(self.styblinski_tang, bounds)

    def test_inf_bounds(self):
        error_msg = 'Bounds must not be inf.'
        bounds = Bounds([-np.inf, -1], [-2, np.inf])
        with pytest.raises(ValueError, match=error_msg):
            direct(self.styblinski_tang, bounds)

    @pytest.mark.parametrize("locally_biased", ["bias", [0, 0], 2.])
    def test_locally_biased_validation(self, locally_biased):
        error_msg = 'locally_biased must be True or False.'
        with pytest.raises(ValueError, match=error_msg):
            direct(self.styblinski_tang, self.bounds_stylinski_tang,
                   locally_biased=locally_biased)