Browse code

move learners to seperate files

Bas Nijholt authored on 30/10/2017 16:48:13
Showing 10 changed files
1 1
deleted file mode 100644
... ...
@@ -1,775 +0,0 @@
1
-# -*- coding: utf-8 -*-
2
-import abc
3
-import collections
4
-from contextlib import contextmanager
5
-from copy import deepcopy as copy
6
-import functools
7
-import heapq
8
-import itertools
9
-from math import sqrt, hypot
10
-from operator import itemgetter
11
-
12
-import holoviews as hv
13
-import numpy as np
14
-from scipy import interpolate, optimize, special
15
-import sortedcontainers
16
-
17
-
18
-class BaseLearner(metaclass=abc.ABCMeta):
19
-    """Base class for algorithms for learning a function 'f: X → Y'.
20
-
21
-    Attributes
22
-    ----------
23
-    function : callable: X → Y
24
-        The function to learn.
25
-    data : dict: X → Y
26
-        'function' evaluated at certain points.
27
-        The values can be 'None', which indicates that the point
28
-        will be evaluated, but that we do not have the result yet.
29
-
30
-    Subclasses may define a 'plot' method that takes no parameters
31
-    and returns a holoviews plot.
32
-    """
33
-
34
-    def add_data(self, xvalues, yvalues):
35
-        """Add data to the learner.
36
-
37
-        Parameters
38
-        ----------
39
-        xvalues : value from the function domain, or iterable of such
40
-            Values from the domain of the learned function.
41
-        yvalues : value from the function image, or iterable of such
42
-            Values from the range of the learned function, or None.
43
-            If 'None', then it indicates that the value has not yet
44
-            been computed.
45
-        """
46
-        if all(isinstance(i, collections.Iterable) for i in [xvalues, yvalues]):
47
-            for x, y in zip(xvalues, yvalues):
48
-                self.add_point(x, y)
49
-        else:
50
-            self.add_point(xvalues, yvalues)
51
-
52
-    @abc.abstractmethod
53
-    def add_point(self, x, y):
54
-        """Add a single datapoint to the learner."""
55
-        pass
56
-
57
-    @abc.abstractmethod
58
-    def remove_unfinished(self):
59
-        """Remove uncomputed data from the learner."""
60
-        pass
61
-
62
-    @abc.abstractmethod
63
-    def loss(self, real=True):
64
-        """Return the loss for the current state of the learner.
65
-
66
-        Parameters
67
-        ----------
68
-        real : bool, default: True
69
-            If False, return the "expected" loss, i.e. the
70
-            loss including the as-yet unevaluated points
71
-            (possibly by interpolation).
72
-        """
73
-
74
-    @abc.abstractmethod
75
-    def choose_points(self, n, add_data=True):
76
-        """Choose the next 'n' points to evaluate.
77
-
78
-        Parameters
79
-        ----------
80
-        n : int
81
-            The number of points to choose.
82
-        add_data : bool, default: True
83
-            If True, add the chosen points to this
84
-            learner's 'data' with 'None' for the 'y'
85
-            values. Set this to False if you do not
86
-            want to modify the state of the learner.
87
-        """
88
-        pass
89
-
90
-    def __getstate__(self):
91
-        return copy(self.__dict__)
92
-
93
-    def __setstate__(self, state):
94
-        self.__dict__ = state
95
-
96
-
97
-class AverageLearner(BaseLearner):
98
-    """A naive implementation of adaptive computing of averages.
99
-
100
-    The learned function must depend on an integer input variable that
101
-    represents the source of randomness.
102
-
103
-    Parameters:
104
-    -----------
105
-    atol : float
106
-        Desired absolute tolerance
107
-    rtol : float
108
-        Desired relative tolerance
109
-    """
110
-
111
-    def __init__(self, function, atol=None, rtol=None):
112
-        if atol is None and rtol is None:
113
-            raise Exception('At least one of `atol` and `rtol` should be set.')
114
-        if atol is None:
115
-            atol = np.inf
116
-        if rtol is None:
117
-            rtol = np.inf
118
-
119
-        self.data = {}
120
-        self.function = function
121
-        self.atol = atol
122
-        self.rtol = rtol
123
-        self.n = 0
124
-        self.n_requested = 0
125
-        self.sum_f = 0
126
-        self.sum_f_sq = 0
127
-
128
-    def choose_points(self, n, add_data=True):
129
-        points = list(range(self.n_requested, self.n_requested + n))
130
-        loss_improvements = [self.loss()] * n
131
-        if add_data:
132
-            self.add_data(points, itertools.repeat(None))
133
-        return points, loss_improvements
134
-
135
-    def add_point(self, n, value):
136
-        self.data[n] = value
137
-        if value is None:
138
-            self.n_requested += 1
139
-            return
140
-        else:
141
-            self.n += 1
142
-            self.sum_f += value
143
-            self.sum_f_sq += value**2
144
-
145
-    @property
146
-    def mean(self):
147
-        return self.sum_f / self.n
148
-
149
-    @property
150
-    def std(self):
151
-        n = self.n
152
-        if n < 2:
153
-            return np.inf
154
-        return sqrt((self.sum_f_sq - n * self.mean**2) / (n - 1))
155
-
156
-    def loss(self, real=True):
157
-        n = self.n
158
-        if n < 2:
159
-            return np.inf
160
-        standard_error = self.std / sqrt(n if real else self.n_requested)
161
-        return max(standard_error / self.atol,
162
-                   standard_error / abs(self.mean) / self.rtol)
163
-
164
-    def remove_unfinished(self):
165
-        """Remove uncomputed data from the learner."""
166
-        pass
167
-
168
-    def plot(self):
169
-        vals = [v for v in self.data.values() if v is not None]
170
-        if not vals:
171
-            return hv.Histogram([[], []])
172
-        num_bins = int(max(5, sqrt(self.n)))
173
-        vals = hv.Points(vals)
174
-        return hv.operation.histogram(vals, num_bins=num_bins, dimension=1)
175
-
176
-
177
-class Learner1D(BaseLearner):
178
-    """Learns and predicts a function 'f:ℝ → ℝ'.
179
-
180
-    Parameters
181
-    ----------
182
-    function : callable
183
-        The function to learn. Must take a single real parameter and
184
-        return a real number.
185
-    bounds : pair of reals
186
-        The bounds of the interval on which to learn 'function'.
187
-    """
188
-
189
-    def __init__(self, function, bounds):
190
-        self.function = function
191
-
192
-        # A dict storing the loss function for each interval x_n.
193
-        self.losses = {}
194
-        self.losses_combined = {}
195
-
196
-        self.data = sortedcontainers.SortedDict()
197
-        self.data_interp = {}
198
-
199
-        # A dict {x_n: [x_{n-1}, x_{n+1}]} for quick checking of local
200
-        # properties.
201
-        self.neighbors = sortedcontainers.SortedDict()
202
-        self.neighbors_combined = sortedcontainers.SortedDict()
203
-
204
-        # Bounding box [[minx, maxx], [miny, maxy]].
205
-        self._bbox = [list(bounds), [np.inf, -np.inf]]
206
-
207
-        # Data scale (maxx - minx), (maxy - miny)
208
-        self._scale = [bounds[1] - bounds[0], 0]
209
-        self._oldscale = copy(self._scale)
210
-
211
-        self.bounds = list(bounds)
212
-
213
-    @property
214
-    def data_combined(self):
215
-        return {**self.data, **self.data_interp}
216
-
217
-    def interval_loss(self, x_left, x_right, data):
218
-        """Calculate loss in the interval x_left, x_right.
219
-
220
-        Currently returns the rescaled length of the interval. If one of the
221
-        y-values is missing, returns 0 (so the intervals with missing data are
222
-        never touched. This behavior should be improved later.
223
-        """
224
-        y_right, y_left = data[x_right], data[x_left]
225
-        if self._scale[1] == 0:
226
-            return sqrt(((x_right - x_left) / self._scale[0])**2)
227
-        else:
228
-            return sqrt(((x_right - x_left) / self._scale[0])**2 +
229
-                        ((y_right - y_left) / self._scale[1])**2)
230
-
231
-    def loss(self, real=True):
232
-        losses = self.losses if real else self.losses_combined
233
-        if len(losses) == 0:
234
-            return float('inf')
235
-        else:
236
-            return max(losses.values())
237
-
238
-    def update_losses(self, x, data, neighbors, losses):
239
-        x_lower, x_upper = neighbors[x]
240
-        if x_lower is not None:
241
-            losses[x_lower, x] = self.interval_loss(x_lower, x, data)
242
-        if x_upper is not None:
243
-            losses[x, x_upper] = self.interval_loss(x, x_upper, data)
244
-        try:
245
-            del losses[x_lower, x_upper]
246
-        except KeyError:
247
-            pass
248
-
249
-    def find_neighbors(self, x, neighbors):
250
-        pos = neighbors.bisect_left(x)
251
-        x_lower = neighbors.iloc[pos-1] if pos != 0 else None
252
-        x_upper = neighbors.iloc[pos] if pos != len(neighbors) else None
253
-        return x_lower, x_upper
254
-
255
-    def update_neighbors(self, x, neighbors):
256
-        if x not in neighbors:  # The point is new
257
-            x_lower, x_upper = self.find_neighbors(x, neighbors)
258
-            neighbors[x] = [x_lower, x_upper]
259
-            neighbors.get(x_lower, [None, None])[1] = x
260
-            neighbors.get(x_upper, [None, None])[0] = x
261
-
262
-    def update_scale(self, x, y):
263
-        self._bbox[0][0] = min(self._bbox[0][0], x)
264
-        self._bbox[0][1] = max(self._bbox[0][1], x)
265
-        if y is not None:
266
-            self._bbox[1][0] = min(self._bbox[1][0], y)
267
-            self._bbox[1][1] = max(self._bbox[1][1], y)
268
-
269
-        self._scale = [self._bbox[0][1] - self._bbox[0][0],
270
-                       self._bbox[1][1] - self._bbox[1][0]]
271
-
272
-    def add_point(self, x, y):
273
-        real = y is not None
274
-
275
-        if real:
276
-            # Add point to the real data dict and pop from the unfinished
277
-            # data_interp dict.
278
-            self.data[x] = y
279
-            try:
280
-                del self.data_interp[x]
281
-            except KeyError:
282
-                pass
283
-        else:
284
-            # The keys of data_interp are the unknown points
285
-            self.data_interp[x] = None
286
-
287
-        # Update the neighbors
288
-        self.update_neighbors(x, self.neighbors_combined)
289
-        if real:
290
-            self.update_neighbors(x, self.neighbors)
291
-
292
-        # Update the scale
293
-        self.update_scale(x, y)
294
-
295
-        # Interpolate
296
-        if not real:
297
-            self.data_interp = self.interpolate()
298
-
299
-        # Update the losses
300
-        self.update_losses(x, self.data_combined, self.neighbors_combined,
301
-                           self.losses_combined)
302
-        if real:
303
-            self.update_losses(x, self.data, self.neighbors, self.losses)
304
-
305
-        # If the scale has doubled, recompute all losses.
306
-        if self._scale > self._oldscale * 2:
307
-            self.losses = {xs: self.interval_loss(*xs, self.data)
308
-                           for xs in self.losses}
309
-            self.losses_combined = {x: self.interval_loss(*x,
310
-                                                          self.data_combined)
311
-                                    for x in self.losses_combined}
312
-            self._oldscale = self._scale
313
-
314
-    def choose_points(self, n, add_data=True):
315
-        """Return n points that are expected to maximally reduce the loss."""
316
-        # Find out how to divide the n points over the intervals
317
-        # by finding  positive integer n_i that minimize max(L_i / n_i) subject
318
-        # to a constraint that sum(n_i) = n + N, with N the total number of
319
-        # intervals.
320
-
321
-        # Return equally spaced points within each interval to which points
322
-        # will be added.
323
-        if n == 0:
324
-            return []
325
-
326
-        # If the bounds have not been chosen yet, we choose them first.
327
-        points = []
328
-        for bound in self.bounds:
329
-            if bound not in self.data and bound not in self.data_interp:
330
-                points.append(bound)
331
-
332
-        # Ensure we return exactly 'n' points.
333
-        if points:
334
-            loss_improvements = [float('inf')] * n
335
-            if n <= 2:
336
-                points = points[:n]
337
-            else:
338
-                points = np.linspace(*self.bounds, n)
339
-        else:
340
-            def xs(x, n):
341
-                if n == 1:
342
-                    return []
343
-                else:
344
-                    step = (x[1] - x[0]) / n
345
-                    return [x[0] + step * i for i in range(1, n)]
346
-
347
-            # Calculate how many points belong to each interval.
348
-            quals = [(-loss, x_range, 1) for (x_range, loss) in
349
-                     self.losses_combined.items()]
350
-
351
-            heapq.heapify(quals)
352
-
353
-            for point_number in range(n):
354
-                quality, x, n = quals[0]
355
-                heapq.heapreplace(quals, (quality * n / (n + 1), x, n + 1))
356
-
357
-            points = list(itertools.chain.from_iterable(xs(x, n)
358
-                          for quality, x, n in quals))
359
-
360
-            loss_improvements = list(itertools.chain.from_iterable(
361
-                                     itertools.repeat(-quality, n)
362
-                                     for quality, x, n in quals))
363
-
364
-        if add_data:
365
-            self.add_data(points, itertools.repeat(None))
366
-
367
-        return points, loss_improvements
368
-
369
-    def interpolate(self, extra_points=None):
370
-        xs = list(self.data.keys())
371
-        ys = list(self.data.values())
372
-        xs_unfinished = list(self.data_interp.keys())
373
-
374
-        if extra_points is not None:
375
-            xs_unfinished += extra_points
376
-
377
-        if len(ys) == 0:
378
-            interp_ys = (0,) * len(xs_unfinished)
379
-        else:
380
-            interp_ys = np.interp(xs_unfinished, xs, ys)
381
-
382
-        data_interp = {x: y for x, y in zip(xs_unfinished, interp_ys)}
383
-
384
-        return data_interp
385
-
386
-    def plot(self):
387
-            if self.data:
388
-                return hv.Scatter(self.data)
389
-            else:
390
-                return hv.Scatter([])
391
-
392
-    def remove_unfinished(self):
393
-        self.data_interp = {}
394
-        self.losses_combined = copy(self.losses)
395
-        self.neighbors_combined = copy(self.neighbors)
396
-
397
-
398
-def dispatch(child_functions, arg):
399
-    index, x = arg
400
-    return child_functions[index](x)
401
-
402
-
403
-class BalancingLearner(BaseLearner):
404
-    """Choose the optimal points from a set of learners.
405
-
406
-    Parameters
407
-    ----------
408
-    learners : sequence of BaseLearner
409
-        The learners from which to choose. These must all have the same type.
410
-
411
-    Notes
412
-    -----
413
-    This learner compares the 'loss' calculated from the "child" learners.
414
-    This requires that the 'loss' from different learners *can be meaningfully
415
-    compared*. For the moment we enforce this restriction by requiring that
416
-    all learners are the same type but (depending on the internals of the
417
-    learner) it may be that the loss cannot be compared *even between learners
418
-    of the same type*. In this case the BalancingLearner will behave in an
419
-    undefined way.
420
-    """
421
-
422
-    def __init__(self, learners):
423
-        self.learners = learners
424
-
425
-        # Naively we would make 'function' a method, but this causes problems
426
-        # when using executors from 'concurrent.futures' because we have to
427
-        # pickle the whole learner.
428
-        self.function = functools.partial(dispatch, [l.function for l
429
-                                                     in self.learners])
430
-
431
-        if len(set(learner.__class__ for learner in self.learners)) > 1:
432
-            raise TypeError('A BalacingLearner can handle only one type'
433
-                            'of learners.')
434
-
435
-    def _choose_and_add_points(self, n):
436
-        points = []
437
-        for _ in range(n):
438
-            loss_improvements = []
439
-            pairs = []
440
-            for index, learner in enumerate(self.learners):
441
-                point, loss_improvement = learner.choose_points(n=1,
442
-                                                                add_data=False)
443
-                loss_improvements.append(loss_improvement[0])
444
-                pairs.append((index, point[0]))
445
-            x, _ = max(zip(pairs, loss_improvements), key=itemgetter(1))
446
-            points.append(x)
447
-            self.add_point(x, None)
448
-        return points, None
449
-
450
-    def choose_points(self, n, add_data=True):
451
-        """Chose points for learners."""
452
-        if not add_data:
453
-            with restore(*self.learners):
454
-                return self._choose_and_add_points(n)
455
-        else:
456
-            return self._choose_and_add_points(n)
457
-
458
-    def add_point(self, x, y):
459
-        index, x = x
460
-        self.learners[index].add_point(x, y)
461
-
462
-    def loss(self, real=True):
463
-        return max(learner.loss(real) for learner in self.learners)
464
-
465
-    def plot(self, index):
466
-        return self.learners[index].plot()
467
-
468
-    def remove_unfinished(self):
469
-        """Remove uncomputed data from the learners."""
470
-        for learner in self.learners:
471
-            learner.remove_unfinished()
472
-
473
-
474
-# Learner2D and helper functions.
475
-
476
-def _losses_per_triangle(ip):
477
-    tri = ip.tri
478
-    vs = ip.values.ravel()
479
-
480
-    gradients = interpolate.interpnd.estimate_gradients_2d_global(
481
-        tri, vs, tol=1e-6)
482
-    p = tri.points[tri.vertices]
483
-    g = gradients[tri.vertices]
484
-    v = vs[tri.vertices]
485
-    n_points_per_triangle = p.shape[1]
486
-
487
-    dev = 0
488
-    for j in range(n_points_per_triangle):
489
-        vest = v[:, j, None] + ((p[:, :, :] - p[:, j, None, :]) *
490
-                                g[:, j, None, :]).sum(axis=-1)
491
-        dev += abs(vest - v).max(axis=1)
492
-
493
-    q = p[:, :-1, :] - p[:, -1, None, :]
494
-    areas = abs(q[:, 0, 0] * q[:, 1, 1] - q[:, 0, 1] * q[:, 1, 0])
495
-    areas /= special.gamma(n_points_per_triangle)
496
-    areas = np.sqrt(areas)
497
-
498
-    vs_scale = vs[tri.vertices].ptp()
499
-    if vs_scale != 0:
500
-        dev /= vs_scale
501
-
502
-    return dev * areas
503
-
504
-class Learner2D(BaseLearner):
505
-    """Learns and predicts a function 'f: ℝ^2 → ℝ'.
506
-
507
-    Parameters
508
-    ----------
509
-    function : callable
510
-        The function to learn. Must take a tuple of two real
511
-        parameters and return a real number.
512
-    bounds : list of 2-tuples
513
-        A list ``[(a1, b1), (a2, b2)]`` containing bounds,
514
-        one per dimension.
515
-
516
-    Attributes
517
-    ----------
518
-    points_combined
519
-        Sample points so far including the unknown interpolated ones.
520
-    values_combined
521
-        Sampled values so far including the unknown interpolated ones.
522
-    points
523
-        Sample points so far with real results.
524
-    values
525
-        Sampled values so far with real results.
526
-
527
-    Notes
528
-    -----
529
-    Adapted from an initial implementation by Pauli Virtanen.
530
-
531
-    The sample points are chosen by estimating the point where the
532
-    linear and cubic interpolants based on the existing points have
533
-    maximal disagreement. This point is then taken as the next point
534
-    to be sampled.
535
-
536
-    In practice, this sampling protocol results to sparser sampling of
537
-    smooth regions, and denser sampling of regions where the function
538
-    changes rapidly, which is useful if the function is expensive to
539
-    compute.
540
-
541
-    This sampling procedure is not extremely fast, so to benefit from
542
-    it, your function needs to be slow enough to compute.
543
-    """
544
-
545
-    def __init__(self, function, bounds):
546
-        self.ndim = len(bounds)
547
-        if self.ndim != 2:
548
-            raise ValueError("Only 2-D sampling supported.")
549
-        self.bounds = tuple((float(a), float(b)) for a, b in bounds)
550
-        self._points = np.zeros([100, self.ndim])
551
-        self._values = np.zeros([100], dtype=float)
552
-        self._stack = []
553
-        self._interp = {}
554
-
555
-        xy_mean = np.mean(self.bounds, axis=1)
556
-        xy_scale = np.ptp(self.bounds, axis=1)
557
-
558
-        def scale(points):
559
-            return (points - xy_mean) / xy_scale
560
-
561
-        def unscale(points):
562
-            return points * xy_scale + xy_mean
563
-
564
-        self.scale = scale
565
-        self.unscale = unscale
566
-
567
-        # Keeps track till which index _points and _values are filled
568
-        self.n = 0
569
-
570
-        self._bounds_points = list(itertools.product(*bounds))
571
-
572
-        # Add the loss improvement to the bounds in the stack
573
-        self._stack = [(*p, np.inf) for p in self._bounds_points]
574
-
575
-        self.function = function
576
-
577
-    @property
578
-    def points_combined(self):
579
-        return self._points[:self.n]
580
-
581
-    @property
582
-    def values_combined(self):
583
-        return self._values[:self.n]
584
-
585
-    @property
586
-    def points(self):
587
-        return np.delete(self.points_combined,
588
-                         list(self._interp.values()), axis=0)
589
-
590
-    @property
591
-    def values(self):
592
-        return np.delete(self.values_combined,
593
-                         list(self._interp.values()), axis=0)
594
-
595
-    def ip(self):
596
-        points = self.scale(self.points)
597
-        return interpolate.LinearNDInterpolator(points, self.values)
598
-
599
-    @property
600
-    def n_real(self):
601
-        return self.n - len(self._interp)
602
-
603
-    def ip_combined(self):
604
-        points = self.scale(self.points_combined)
605
-        values = self.values_combined
606
-
607
-        # Interpolate the unfinished points
608
-        if self._interp:
609
-            n_interp = list(self._interp.values())
610
-            bounds_are_done = not any(p in self._interp
611
-                                      for p in self._bounds_points)
612
-            if bounds_are_done:
613
-                values[n_interp] = self.ip()(points[n_interp])
614
-            else:
615
-                # It is important not to return exact zeros because
616
-                # otherwise the algo will try to add the same point
617
-                # to the stack each time.
618
-                values[n_interp] = np.random.rand(len(n_interp)) * 1e-15
619
-
620
-        return interpolate.LinearNDInterpolator(points, values)
621
-
622
-    def add_point(self, point, value):
623
-        nmax = self.values_combined.shape[0]
624
-        if self.n >= nmax:
625
-            self._values = np.resize(self._values, [2*nmax + 10])
626
-            self._points = np.resize(self._points, [2*nmax + 10, self.ndim])
627
-
628
-        point = tuple(point)
629
-
630
-        # When the point is not evaluated yet, add an entry to self._interp
631
-        # that saves the point and index.
632
-        if value is None:
633
-            self._interp[point] = self.n
634
-            old_point = False
635
-        else:
636
-            old_point = point in self._interp
637
-
638
-        # If the point is new add it a new value to _points and _values,
639
-        # otherwise get the index of the value that is being replaced.
640
-        if old_point:
641
-            n = self._interp.pop(point)
642
-        else:
643
-            n = self.n
644
-            self.n += 1
645
-
646
-        self._points[n] = point
647
-        self._values[n] = value
648
-
649
-        # Remove the point if in the stack.
650
-        for i, (*_point, _) in enumerate(self._stack):
651
-            if point == tuple(_point):
652
-                self._stack.pop(i)
653
-                break
654
-
655
-    def _fill_stack(self, stack_till=None):
656
-        if stack_till is None:
657
-            stack_till = 1
658
-
659
-        if self.values_combined.shape[0] < self.ndim + 1:
660
-            raise ValueError("too few points...")
661
-
662
-        # Interpolate
663
-        ip = self.ip_combined()
664
-        tri = ip.tri
665
-
666
-        losses = _losses_per_triangle(ip)
667
-
668
-        def point_exists(p):
669
-            eps = np.finfo(float).eps * self.points_combined.ptp() * 100
670
-            if abs(p - self.points_combined).sum(axis=1).min() < eps:
671
-                return True
672
-            if self._stack:
673
-                _stack_points, _ = self._split_stack()
674
-                if abs(p - np.asarray(_stack_points)).sum(axis=1).min() < eps:
675
-                    return True
676
-            return False
677
-
678
-        for j, _ in enumerate(losses):
679
-            # Estimate point of maximum curvature inside the simplex
680
-            jsimplex = np.argmax(losses)
681
-            p = tri.points[tri.vertices[jsimplex]]
682
-            point_new = self.unscale(p.mean(axis=-2))
683
-
684
-            # XXX: not sure whether this is necessary it was there
685
-            # originally.
686
-            point_new = np.clip(point_new, *zip(*self.bounds))
687
-
688
-            # Check if it is really new
689
-            if point_exists(point_new):
690
-                losses[jsimplex] = 0
691
-                continue
692
-
693
-            # Add to stack
694
-            self._stack.append((*point_new, losses[jsimplex]))
695
-
696
-            if len(self._stack) >= stack_till:
697
-                break
698
-            else:
699
-                losses[jsimplex] = 0
700
-
701
-    def _split_stack(self, n=None):
702
-        points = []
703
-        loss_improvements = []
704
-        for *point, loss_improvement in self._stack[:n]:
705
-            points.append(point)
706
-            loss_improvements.append(loss_improvement)
707
-        return points, loss_improvements
708
-
709
-    def _choose_and_add_points(self, n):
710
-        if n <= len(self._stack):
711
-            points, loss_improvements = self._split_stack(n)
712
-            self.add_data(points, itertools.repeat(None))
713
-        else:
714
-            points = []
715
-            loss_improvements = []
716
-            n_left = n
717
-            while n_left > 0:
718
-                # The while loop is needed because `stack_till` could be larger
719
-                # than the number of triangles between the points. Therefore
720
-                # it could fill up till a length smaller than `stack_till`.
721
-                if self.n >= 2**self.ndim:
722
-                    # Only fill the stack if no more bounds left in _stack
723
-                    self._fill_stack(stack_till=n_left)
724
-                new_points, new_loss_improvements = self._split_stack(n_left)
725
-                points += new_points
726
-                loss_improvements += new_loss_improvements
727
-                self.add_data(new_points, itertools.repeat(None))
728
-                n_left -= len(new_points)
729
-
730
-        return points, loss_improvements
731
-
732
-    def choose_points(self, n, add_data=True):
733
-        if not add_data:
734
-            with restore(self):
735
-                return self._choose_and_add_points(n)
736
-        else:
737
-            return self._choose_and_add_points(n)
738
-
739
-    def loss(self, real=True):
740
-        n = self.n_real if real else self.n
741
-        bounds_are_not_done = any(p in self._interp
742
-                                  for p in self._bounds_points)
743
-        if n <= 4 or bounds_are_not_done:
744
-            return np.inf
745
-        ip = self.ip() if real else self.ip_combined()
746
-        losses = _losses_per_triangle(ip)
747
-        return losses.max()
748
-
749
-    def remove_unfinished(self):
750
-        self._points = self.points.copy()
751
-        self._values = self.values.copy()
752
-        self.n -= len(self._interp)
753
-        self._interp = {}
754
-
755
-    def plot(self, n_x=201, n_y=201):
756
-        x, y = self.bounds
757
-        lbrt = x[0], y[0], x[1], y[1]
758
-        if self.n_real >= 4:
759
-            x = np.linspace(-0.5, 0.5, n_x)
760
-            y = np.linspace(-0.5, 0.5, n_y)
761
-            ip = self.ip()
762
-            z = ip(x[:, None], y[None, :])
763
-            return hv.Image(np.rot90(z), bounds=lbrt)
764
-        else:
765
-            return hv.Image(np.zeros((2, 2)), bounds=lbrt)
766
-
767
-
768
-@contextmanager
769
-def restore(*learners):
770
-    states = [learner.__getstate__() for learner in learners]
771
-    try:
772
-        yield
773
-    finally:
774
-        for state, learner in zip(states, learners):
775
-            learner.__setstate__(state)
776 0
new file mode 100644
... ...
@@ -0,0 +1,7 @@
1
+# -*- coding: utf-8 -*-
2
+from .average_learner import AverageLearner
3
+from .base_learner import BaseLearner
4
+from .balancing_learner import BalancingLearner
5
+from .learner1D import Learner1D
6
+from .learner2D import Learner2D
7
+from .integrator_learner import IntegratorLearner
0 8
new file mode 100644
... ...
@@ -0,0 +1,87 @@
1
+# -*- coding: utf-8 -*-
2
+import itertools
3
+from math import sqrt
4
+
5
+import holoviews as hv
6
+import numpy as np
7
+
8
+from .base_learner import BaseLearner
9
+
10
+class AverageLearner(BaseLearner):
11
+    """A naive implementation of adaptive computing of averages.
12
+
13
+    The learned function must depend on an integer input variable that
14
+    represents the source of randomness.
15
+
16
+    Parameters:
17
+    -----------
18
+    atol : float
19
+        Desired absolute tolerance
20
+    rtol : float
21
+        Desired relative tolerance
22
+    """
23
+
24
+    def __init__(self, function, atol=None, rtol=None):
25
+        if atol is None and rtol is None:
26
+            raise Exception('At least one of `atol` and `rtol` should be set.')
27
+        if atol is None:
28
+            atol = np.inf
29
+        if rtol is None:
30
+            rtol = np.inf
31
+
32
+        self.data = {}
33
+        self.function = function
34
+        self.atol = atol
35
+        self.rtol = rtol
36
+        self.n = 0
37
+        self.n_requested = 0
38
+        self.sum_f = 0
39
+        self.sum_f_sq = 0
40
+
41
+    def choose_points(self, n, add_data=True):
42
+        points = list(range(self.n_requested, self.n_requested + n))
43
+        loss_improvements = [self.loss()] * n
44
+        if add_data:
45
+            self.add_data(points, itertools.repeat(None))
46
+        return points, loss_improvements
47
+
48
+    def add_point(self, n, value):
49
+        self.data[n] = value
50
+        if value is None:
51
+            self.n_requested += 1
52
+            return
53
+        else:
54
+            self.n += 1
55
+            self.sum_f += value
56
+            self.sum_f_sq += value**2
57
+
58
+    @property
59
+    def mean(self):
60
+        return self.sum_f / self.n
61
+
62
+    @property
63
+    def std(self):
64
+        n = self.n
65
+        if n < 2:
66
+            return np.inf
67
+        return sqrt((self.sum_f_sq - n * self.mean**2) / (n - 1))
68
+
69
+    def loss(self, real=True):
70
+        n = self.n
71
+        if n < 2:
72
+            return np.inf
73
+        standard_error = self.std / sqrt(n if real else self.n_requested)
74
+        return max(standard_error / self.atol,
75
+                   standard_error / abs(self.mean) / self.rtol)
76
+
77
+    def remove_unfinished(self):
78
+        """Remove uncomputed data from the learner."""
79
+        pass
80
+
81
+    def plot(self):
82
+        vals = [v for v in self.data.values() if v is not None]
83
+        if not vals:
84
+            return hv.Histogram([[], []])
85
+        num_bins = int(max(5, sqrt(self.n)))
86
+        vals = hv.Points(vals)
87
+        return hv.operation.histogram(vals, num_bins=num_bins, dimension=1)
0 88
new file mode 100644
... ...
@@ -0,0 +1,82 @@
1
+# -*- coding: utf-8 -*-
2
+import functools
3
+from operator import itemgetter
4
+
5
+from .base_learner import BaseLearner
6
+from .utils import restore
7
+
8
+
9
+def dispatch(child_functions, arg):
10
+    index, x = arg
11
+    return child_functions[index](x)
12
+
13
+
14
+class BalancingLearner(BaseLearner):
15
+    """Choose the optimal points from a set of learners.
16
+
17
+    Parameters
18
+    ----------
19
+    learners : sequence of BaseLearner
20
+        The learners from which to choose. These must all have the same type.
21
+
22
+    Notes
23
+    -----
24
+    This learner compares the 'loss' calculated from the "child" learners.
25
+    This requires that the 'loss' from different learners *can be meaningfully
26
+    compared*. For the moment we enforce this restriction by requiring that
27
+    all learners are the same type but (depending on the internals of the
28
+    learner) it may be that the loss cannot be compared *even between learners
29
+    of the same type*. In this case the BalancingLearner will behave in an
30
+    undefined way.
31
+    """
32
+
33
+    def __init__(self, learners):
34
+        self.learners = learners
35
+
36
+        # Naively we would make 'function' a method, but this causes problems
37
+        # when using executors from 'concurrent.futures' because we have to
38
+        # pickle the whole learner.
39
+        self.function = functools.partial(dispatch, [l.function for l
40
+                                                     in self.learners])
41
+
42
+        if len(set(learner.__class__ for learner in self.learners)) > 1:
43
+            raise TypeError('A BalacingLearner can handle only one type'
44
+                            'of learners.')
45
+
46
+    def _choose_and_add_points(self, n):
47
+        points = []
48
+        for _ in range(n):
49
+            loss_improvements = []
50
+            pairs = []
51
+            for index, learner in enumerate(self.learners):
52
+                point, loss_improvement = learner.choose_points(n=1,
53
+                                                                add_data=False)
54
+                loss_improvements.append(loss_improvement[0])
55
+                pairs.append((index, point[0]))
56
+            x, _ = max(zip(pairs, loss_improvements), key=itemgetter(1))
57
+            points.append(x)
58
+            self.add_point(x, None)
59
+        return points, None
60
+
61
+    def choose_points(self, n, add_data=True):
62
+        """Chose points for learners."""
63
+        if not add_data:
64
+            with restore(*self.learners):
65
+                return self._choose_and_add_points(n)
66
+        else:
67
+            return self._choose_and_add_points(n)
68
+
69
+    def add_point(self, x, y):
70
+        index, x = x
71
+        self.learners[index].add_point(x, y)
72
+
73
+    def loss(self, real=True):
74
+        return max(learner.loss(real) for learner in self.learners)
75
+
76
+    def plot(self, index):
77
+        return self.learners[index].plot()
78
+
79
+    def remove_unfinished(self):
80
+        """Remove uncomputed data from the learners."""
81
+        for learner in self.learners:
82
+            learner.remove_unfinished()
0 83
new file mode 100644
... ...
@@ -0,0 +1,83 @@
1
+# -*- coding: utf-8 -*-
2
+import abc
3
+import collections
4
+from copy import deepcopy
5
+
6
+
7
+class BaseLearner(metaclass=abc.ABCMeta):
8
+    """Base class for algorithms for learning a function 'f: X → Y'.
9
+
10
+    Attributes
11
+    ----------
12
+    function : callable: X → Y
13
+        The function to learn.
14
+    data : dict: X → Y
15
+        'function' evaluated at certain points.
16
+        The values can be 'None', which indicates that the point
17
+        will be evaluated, but that we do not have the result yet.
18
+
19
+    Subclasses may define a 'plot' method that takes no parameters
20
+    and returns a holoviews plot.
21
+    """
22
+
23
+    def add_data(self, xvalues, yvalues):
24
+        """Add data to the learner.
25
+
26
+        Parameters
27
+        ----------
28
+        xvalues : value from the function domain, or iterable of such
29
+            Values from the domain of the learned function.
30
+        yvalues : value from the function image, or iterable of such
31
+            Values from the range of the learned function, or None.
32
+            If 'None', then it indicates that the value has not yet
33
+            been computed.
34
+        """
35
+        if all(isinstance(i, collections.Iterable) for i in [xvalues, yvalues]):
36
+            for x, y in zip(xvalues, yvalues):
37
+                self.add_point(x, y)
38
+        else:
39
+            self.add_point(xvalues, yvalues)
40
+
41
+    @abc.abstractmethod
42
+    def add_point(self, x, y):
43
+        """Add a single datapoint to the learner."""
44
+        pass
45
+
46
+    @abc.abstractmethod
47
+    def remove_unfinished(self):
48
+        """Remove uncomputed data from the learner."""
49
+        pass
50
+
51
+    @abc.abstractmethod
52
+    def loss(self, real=True):
53
+        """Return the loss for the current state of the learner.
54
+
55
+        Parameters
56
+        ----------
57
+        real : bool, default: True
58
+            If False, return the "expected" loss, i.e. the
59
+            loss including the as-yet unevaluated points
60
+            (possibly by interpolation).
61
+        """
62
+
63
+    @abc.abstractmethod
64
+    def choose_points(self, n, add_data=True):
65
+        """Choose the next 'n' points to evaluate.
66
+
67
+        Parameters
68
+        ----------
69
+        n : int
70
+            The number of points to choose.
71
+        add_data : bool, default: True
72
+            If True, add the chosen points to this
73
+            learner's 'data' with 'None' for the 'y'
74
+            values. Set this to False if you do not
75
+            want to modify the state of the learner.
76
+        """
77
+        pass
78
+
79
+    def __getstate__(self):
80
+        return deepcopy(self.__dict__)
81
+
82
+    def __setstate__(self, state):
83
+        self.__dict__ = state
0 84
similarity index 96%
1 85
rename from coeffs.py
2 86
rename to adaptive/learner/integrator_coeffs.py
... ...
@@ -1,4 +1,5 @@
1
-from fractions import Fraction as Frac
1
+# -*- coding: utf-8 -*-
2
+from fractions import Fraction
2 3
 from collections import defaultdict
3 4
 import numpy as np
4 5
 import scipy.linalg
... ...
@@ -11,12 +12,12 @@ def legendre(n):
11 12
 
12 13
     The return value is a list of list of fraction.Fraction instances.
13 14
     """
14
-    result = [[Frac(1)], [Frac(0), Frac(1)]]
15
+    result = [[Fraction(1)], [Fraction(0), Fraction(1)]]
15 16
     if n <= 2:
16 17
         return result[:n]
17 18
     for i in range(2, n):
18 19
         # Use Bonnet's recursion formula.
19
-        new = (i + 1) * [Frac(0)]
20
+        new = (i + 1) * [Fraction(0)]
20 21
         new[1:] = (r * (2*i - 1) for r in result[-1])
21 22
         new[:-2] = (n - r * (i - 1) for n, r in zip(new[:-2], result[-2]))
22 23
         new[:] = (n / i for n in new)
... ...
@@ -115,7 +116,7 @@ def calc_bdef(ns):
115 116
     result = []
116 117
     for n in ns:
117 118
         poly = []
118
-        a = list(map(Frac, newton(n)))
119
+        a = list(map(Fraction, newton(n)))
119 120
         for b in legs[:n + 1]:
120 121
             igral = scalar_product(a, b)
121 122
 
122 123
similarity index 98%
123 124
rename from cquad.py
124 125
rename to adaptive/learner/integrator_learner.py
... ...
@@ -1,3 +1,4 @@
1
+# -*- coding: utf-8 -*-
1 2
 # Copyright 2010 Pedro Gonnet
2 3
 # Copyright 2017 Christoph Groth
3 4
 # Copyright 2017 `adaptive` authors
... ...
@@ -11,8 +12,10 @@ import numpy as np
11 12
 from scipy.linalg import norm
12 13
 from sortedcontainers import SortedDict, SortedSet
13 14
 
14
-from adaptive.learner import BaseLearner
15
-from coeffs import b_def, T_left, T_right, ns, xi, V_inv, Vcond, alpha, gamma
15
+from .base_learner import BaseLearner
16
+from .integrator_coeffs import (b_def, T_left, T_right, ns,
17
+                                xi, V_inv, Vcond, alpha, gamma)
18
+
16 19
 
17 20
 eps = np.spacing(1)
18 21
 
... ...
@@ -273,7 +276,7 @@ class Interval:
273 276
         return all(same_slots)
274 277
 
275 278
 
276
-class Learner(BaseLearner):
279
+class IntegratorLearner(BaseLearner):
277 280
     def __init__(self, function, bounds, tol):
278 281
         self.function = function
279 282
         self.bounds = bounds
280 283
new file mode 100644
... ...
@@ -0,0 +1,231 @@
1
+# -*- coding: utf-8 -*-
2
+from copy import deepcopy
3
+import heapq
4
+import itertools
5
+from math import sqrt
6
+
7
+import holoviews as hv
8
+import numpy as np
9
+import sortedcontainers
10
+
11
+from .base_learner import BaseLearner
12
+
13
+class Learner1D(BaseLearner):
14
+    """Learns and predicts a function 'f:ℝ → ℝ'.
15
+
16
+    Parameters
17
+    ----------
18
+    function : callable
19
+        The function to learn. Must take a single real parameter and
20
+        return a real number.
21
+    bounds : pair of reals
22
+        The bounds of the interval on which to learn 'function'.
23
+    """
24
+
25
+    def __init__(self, function, bounds):
26
+        self.function = function
27
+
28
+        # A dict storing the loss function for each interval x_n.
29
+        self.losses = {}
30
+        self.losses_combined = {}
31
+
32
+        self.data = sortedcontainers.SortedDict()
33
+        self.data_interp = {}
34
+
35
+        # A dict {x_n: [x_{n-1}, x_{n+1}]} for quick checking of local
36
+        # properties.
37
+        self.neighbors = sortedcontainers.SortedDict()
38
+        self.neighbors_combined = sortedcontainers.SortedDict()
39
+
40
+        # Bounding box [[minx, maxx], [miny, maxy]].
41
+        self._bbox = [list(bounds), [np.inf, -np.inf]]
42
+
43
+        # Data scale (maxx - minx), (maxy - miny)
44
+        self._scale = [bounds[1] - bounds[0], 0]
45
+        self._oldscale = deepcopy(self._scale)
46
+
47
+        self.bounds = list(bounds)
48
+
49
+    @property
50
+    def data_combined(self):
51
+        return {**self.data, **self.data_interp}
52
+
53
+    def interval_loss(self, x_left, x_right, data):
54
+        """Calculate loss in the interval x_left, x_right.
55
+
56
+        Currently returns the rescaled length of the interval. If one of the
57
+        y-values is missing, returns 0 (so the intervals with missing data are
58
+        never touched. This behavior should be improved later.
59
+        """
60
+        y_right, y_left = data[x_right], data[x_left]
61
+        if self._scale[1] == 0:
62
+            return sqrt(((x_right - x_left) / self._scale[0])**2)
63
+        else:
64
+            return sqrt(((x_right - x_left) / self._scale[0])**2 +
65
+                        ((y_right - y_left) / self._scale[1])**2)
66
+
67
+    def loss(self, real=True):
68
+        losses = self.losses if real else self.losses_combined
69
+        if len(losses) == 0:
70
+            return float('inf')
71
+        else:
72
+            return max(losses.values())
73
+
74
+    def update_losses(self, x, data, neighbors, losses):
75
+        x_lower, x_upper = neighbors[x]
76
+        if x_lower is not None:
77
+            losses[x_lower, x] = self.interval_loss(x_lower, x, data)
78
+        if x_upper is not None:
79
+            losses[x, x_upper] = self.interval_loss(x, x_upper, data)
80
+        try:
81
+            del losses[x_lower, x_upper]
82
+        except KeyError:
83
+            pass
84
+
85
+    def find_neighbors(self, x, neighbors):
86
+        pos = neighbors.bisect_left(x)
87
+        x_lower = neighbors.iloc[pos-1] if pos != 0 else None
88
+        x_upper = neighbors.iloc[pos] if pos != len(neighbors) else None
89
+        return x_lower, x_upper
90
+
91
+    def update_neighbors(self, x, neighbors):
92
+        if x not in neighbors:  # The point is new
93
+            x_lower, x_upper = self.find_neighbors(x, neighbors)
94
+            neighbors[x] = [x_lower, x_upper]
95
+            neighbors.get(x_lower, [None, None])[1] = x
96
+            neighbors.get(x_upper, [None, None])[0] = x
97
+
98
+    def update_scale(self, x, y):
99
+        self._bbox[0][0] = min(self._bbox[0][0], x)
100
+        self._bbox[0][1] = max(self._bbox[0][1], x)
101
+        if y is not None:
102
+            self._bbox[1][0] = min(self._bbox[1][0], y)
103
+            self._bbox[1][1] = max(self._bbox[1][1], y)
104
+
105
+        self._scale = [self._bbox[0][1] - self._bbox[0][0],
106
+                       self._bbox[1][1] - self._bbox[1][0]]
107
+
108
+    def add_point(self, x, y):
109
+        real = y is not None
110
+
111
+        if real:
112
+            # Add point to the real data dict and pop from the unfinished
113
+            # data_interp dict.
114
+            self.data[x] = y
115
+            try:
116
+                del self.data_interp[x]
117
+            except KeyError:
118
+                pass
119
+        else:
120
+            # The keys of data_interp are the unknown points
121
+            self.data_interp[x] = None
122
+
123
+        # Update the neighbors
124
+        self.update_neighbors(x, self.neighbors_combined)
125
+        if real:
126
+            self.update_neighbors(x, self.neighbors)
127
+
128
+        # Update the scale
129
+        self.update_scale(x, y)
130
+
131
+        # Interpolate
132
+        if not real:
133
+            self.data_interp = self.interpolate()
134
+
135
+        # Update the losses
136
+        self.update_losses(x, self.data_combined, self.neighbors_combined,
137
+                           self.losses_combined)
138
+        if real:
139
+            self.update_losses(x, self.data, self.neighbors, self.losses)
140
+
141
+        # If the scale has doubled, recompute all losses.
142
+        if self._scale > self._oldscale * 2:
143
+            self.losses = {xs: self.interval_loss(*xs, self.data)
144
+                           for xs in self.losses}
145
+            self.losses_combined = {x: self.interval_loss(*x,
146
+                                                          self.data_combined)
147
+                                    for x in self.losses_combined}
148
+            self._oldscale = self._scale
149
+
150
+    def choose_points(self, n, add_data=True):
151
+        """Return n points that are expected to maximally reduce the loss."""
152
+        # Find out how to divide the n points over the intervals
153
+        # by finding  positive integer n_i that minimize max(L_i / n_i) subject
154
+        # to a constraint that sum(n_i) = n + N, with N the total number of
155
+        # intervals.
156
+
157
+        # Return equally spaced points within each interval to which points
158
+        # will be added.
159
+        if n == 0:
160
+            return []
161
+
162
+        # If the bounds have not been chosen yet, we choose them first.
163
+        points = []
164
+        for bound in self.bounds:
165
+            if bound not in self.data and bound not in self.data_interp:
166
+                points.append(bound)
167
+
168
+        # Ensure we return exactly 'n' points.
169
+        if points:
170
+            loss_improvements = [float('inf')] * n
171
+            if n <= 2:
172
+                points = points[:n]
173
+            else:
174
+                points = np.linspace(*self.bounds, n)
175
+        else:
176
+            def xs(x, n):
177
+                if n == 1:
178
+                    return []
179
+                else:
180
+                    step = (x[1] - x[0]) / n
181
+                    return [x[0] + step * i for i in range(1, n)]
182
+
183
+            # Calculate how many points belong to each interval.
184
+            quals = [(-loss, x_range, 1) for (x_range, loss) in
185
+                     self.losses_combined.items()]
186
+
187
+            heapq.heapify(quals)
188
+
189
+            for point_number in range(n):
190
+                quality, x, n = quals[0]
191
+                heapq.heapreplace(quals, (quality * n / (n + 1), x, n + 1))
192
+
193
+            points = list(itertools.chain.from_iterable(xs(x, n)
194
+                          for quality, x, n in quals))
195
+
196
+            loss_improvements = list(itertools.chain.from_iterable(
197
+                                     itertools.repeat(-quality, n)
198
+                                     for quality, x, n in quals))
199
+
200
+        if add_data:
201
+            self.add_data(points, itertools.repeat(None))
202
+
203
+        return points, loss_improvements
204
+
205
+    def interpolate(self, extra_points=None):
206
+        xs = list(self.data.keys())
207
+        ys = list(self.data.values())
208
+        xs_unfinished = list(self.data_interp.keys())
209
+
210
+        if extra_points is not None:
211
+            xs_unfinished += extra_points
212
+
213
+        if len(ys) == 0:
214
+            interp_ys = (0,) * len(xs_unfinished)
215
+        else:
216
+            interp_ys = np.interp(xs_unfinished, xs, ys)
217
+
218
+        data_interp = {x: y for x, y in zip(xs_unfinished, interp_ys)}
219
+
220
+        return data_interp
221
+
222
+    def plot(self):
223
+            if self.data:
224
+                return hv.Scatter(self.data)
225
+            else:
226
+                return hv.Scatter([])
227
+
228
+    def remove_unfinished(self):
229
+        self.data_interp = {}
230
+        self.losses_combined = deepcopy(self.losses)
231
+        self.neighbors_combined = deepcopy(self.neighbors)
0 232
new file mode 100644
... ...
@@ -0,0 +1,303 @@
1
+# -*- coding: utf-8 -*-
2
+import itertools
3
+
4
+import holoviews as hv
5
+import numpy as np
6
+from scipy import interpolate, special
7
+
8
+from .base_learner import BaseLearner
9
+from .utils import restore
10
+
11
+
12
+# Learner2D and helper functions.
13
+
14
+def _losses_per_triangle(ip):
15
+    tri = ip.tri
16
+    vs = ip.values.ravel()
17
+
18
+    gradients = interpolate.interpnd.estimate_gradients_2d_global(
19
+        tri, vs, tol=1e-6)
20
+    p = tri.points[tri.vertices]
21
+    g = gradients[tri.vertices]
22
+    v = vs[tri.vertices]
23
+    n_points_per_triangle = p.shape[1]
24
+
25
+    dev = 0
26
+    for j in range(n_points_per_triangle):
27
+        vest = v[:, j, None] + ((p[:, :, :] - p[:, j, None, :]) *
28
+                                g[:, j, None, :]).sum(axis=-1)
29
+        dev += abs(vest - v).max(axis=1)
30
+
31
+    q = p[:, :-1, :] - p[:, -1, None, :]
32
+    areas = abs(q[:, 0, 0] * q[:, 1, 1] - q[:, 0, 1] * q[:, 1, 0])
33
+    areas /= special.gamma(n_points_per_triangle)
34
+    areas = np.sqrt(areas)
35
+
36
+    vs_scale = vs[tri.vertices].ptp()
37
+    if vs_scale != 0:
38
+        dev /= vs_scale
39
+
40
+    return dev * areas
41
+
42
+class Learner2D(BaseLearner):
43
+    """Learns and predicts a function 'f: ℝ^2 → ℝ'.
44
+
45
+    Parameters
46
+    ----------
47
+    function : callable
48
+        The function to learn. Must take a tuple of two real
49
+        parameters and return a real number.
50
+    bounds : list of 2-tuples
51
+        A list ``[(a1, b1), (a2, b2)]`` containing bounds,
52
+        one per dimension.
53
+
54
+    Attributes
55
+    ----------
56
+    points_combined
57
+        Sample points so far including the unknown interpolated ones.
58
+    values_combined
59
+        Sampled values so far including the unknown interpolated ones.
60
+    points
61
+        Sample points so far with real results.
62
+    values
63
+        Sampled values so far with real results.
64
+
65
+    Notes
66
+    -----
67
+    Adapted from an initial implementation by Pauli Virtanen.
68
+
69
+    The sample points are chosen by estimating the point where the
70
+    linear and cubic interpolants based on the existing points have
71
+    maximal disagreement. This point is then taken as the next point
72
+    to be sampled.
73
+
74
+    In practice, this sampling protocol results to sparser sampling of
75
+    smooth regions, and denser sampling of regions where the function
76
+    changes rapidly, which is useful if the function is expensive to
77
+    compute.
78
+
79
+    This sampling procedure is not extremely fast, so to benefit from
80
+    it, your function needs to be slow enough to compute.
81
+    """
82
+
83
+    def __init__(self, function, bounds):
84
+        self.ndim = len(bounds)
85
+        if self.ndim != 2:
86
+            raise ValueError("Only 2-D sampling supported.")
87
+        self.bounds = tuple((float(a), float(b)) for a, b in bounds)
88
+        self._points = np.zeros([100, self.ndim])
89
+        self._values = np.zeros([100], dtype=float)
90
+        self._stack = []
91
+        self._interp = {}
92
+
93
+        xy_mean = np.mean(self.bounds, axis=1)
94
+        xy_scale = np.ptp(self.bounds, axis=1)
95
+
96
+        def scale(points):
97
+            return (points - xy_mean) / xy_scale
98
+
99
+        def unscale(points):
100
+            return points * xy_scale + xy_mean
101
+
102
+        self.scale = scale
103
+        self.unscale = unscale
104
+
105
+        # Keeps track till which index _points and _values are filled
106
+        self.n = 0
107
+
108
+        self._bounds_points = list(itertools.product(*bounds))
109
+
110
+        # Add the loss improvement to the bounds in the stack
111
+        self._stack = [(*p, np.inf) for p in self._bounds_points]
112
+
113
+        self.function = function
114
+
115
+    @property
116
+    def points_combined(self):
117
+        return self._points[:self.n]
118
+
119
+    @property
120
+    def values_combined(self):
121
+        return self._values[:self.n]
122
+
123
+    @property
124
+    def points(self):
125
+        return np.delete(self.points_combined,
126
+                         list(self._interp.values()), axis=0)
127
+
128
+    @property
129
+    def values(self):
130
+        return np.delete(self.values_combined,
131
+                         list(self._interp.values()), axis=0)
132
+
133
+    def ip(self):
134
+        points = self.scale(self.points)
135
+        return interpolate.LinearNDInterpolator(points, self.values)
136
+
137
+    @property
138
+    def n_real(self):
139
+        return self.n - len(self._interp)
140
+
141
+    def ip_combined(self):
142
+        points = self.scale(self.points_combined)
143
+        values = self.values_combined
144
+
145
+        # Interpolate the unfinished points
146
+        if self._interp:
147
+            n_interp = list(self._interp.values())
148
+            bounds_are_done = not any(p in self._interp
149
+                                      for p in self._bounds_points)
150
+            if bounds_are_done:
151
+                values[n_interp] = self.ip()(points[n_interp])
152
+            else:
153
+                # It is important not to return exact zeros because
154
+                # otherwise the algo will try to add the same point
155
+                # to the stack each time.
156
+                values[n_interp] = np.random.rand(len(n_interp)) * 1e-15
157
+
158
+        return interpolate.LinearNDInterpolator(points, values)
159
+
160
+    def add_point(self, point, value):
161
+        nmax = self.values_combined.shape[0]
162
+        if self.n >= nmax:
163
+            self._values = np.resize(self._values, [2*nmax + 10])
164
+            self._points = np.resize(self._points, [2*nmax + 10, self.ndim])
165
+
166
+        point = tuple(point)
167
+
168
+        # When the point is not evaluated yet, add an entry to self._interp
169
+        # that saves the point and index.
170
+        if value is None:
171
+            self._interp[point] = self.n
172
+            old_point = False
173
+        else:
174
+            old_point = point in self._interp
175
+
176
+        # If the point is new add it a new value to _points and _values,
177
+        # otherwise get the index of the value that is being replaced.
178
+        if old_point:
179
+            n = self._interp.pop(point)
180
+        else:
181
+            n = self.n
182
+            self.n += 1
183
+
184
+        self._points[n] = point
185
+        self._values[n] = value
186
+
187
+        # Remove the point if in the stack.
188
+        for i, (*_point, _) in enumerate(self._stack):
189
+            if point == tuple(_point):
190
+                self._stack.pop(i)
191
+                break
192
+
193
+    def _fill_stack(self, stack_till=None):
194
+        if stack_till is None:
195
+            stack_till = 1
196
+
197
+        if self.values_combined.shape[0] < self.ndim + 1:
198
+            raise ValueError("too few points...")
199
+
200
+        # Interpolate
201
+        ip = self.ip_combined()
202
+        tri = ip.tri
203
+
204
+        losses = _losses_per_triangle(ip)
205
+
206
+        def point_exists(p):
207
+            eps = np.finfo(float).eps * self.points_combined.ptp() * 100
208
+            if abs(p - self.points_combined).sum(axis=1).min() < eps:
209
+                return True
210
+            if self._stack:
211
+                _stack_points, _ = self._split_stack()
212
+                if abs(p - np.asarray(_stack_points)).sum(axis=1).min() < eps:
213
+                    return True
214
+            return False
215
+
216
+        for j, _ in enumerate(losses):
217
+            # Estimate point of maximum curvature inside the simplex
218
+            jsimplex = np.argmax(losses)
219
+            p = tri.points[tri.vertices[jsimplex]]
220
+            point_new = self.unscale(p.mean(axis=-2))
221
+
222
+            # XXX: not sure whether this is necessary it was there
223
+            # originally.
224
+            point_new = np.clip(point_new, *zip(*self.bounds))
225
+
226
+            # Check if it is really new
227
+            if point_exists(point_new):
228
+                losses[jsimplex] = 0
229
+                continue
230
+
231
+            # Add to stack
232
+            self._stack.append((*point_new, losses[jsimplex]))
233
+
234
+            if len(self._stack) >= stack_till:
235
+                break
236
+            else:
237
+                losses[jsimplex] = 0
238
+
239
+    def _split_stack(self, n=None):
240
+        points = []
241
+        loss_improvements = []
242
+        for *point, loss_improvement in self._stack[:n]:
243
+            points.append(point)
244
+            loss_improvements.append(loss_improvement)
245
+        return points, loss_improvements
246
+
247
+    def _choose_and_add_points(self, n):
248
+        if n <= len(self._stack):
249
+            points, loss_improvements = self._split_stack(n)
250
+            self.add_data(points, itertools.repeat(None))
251
+        else:
252
+            points = []
253
+            loss_improvements = []
254
+            n_left = n
255
+            while n_left > 0:
256
+                # The while loop is needed because `stack_till` could be larger
257
+                # than the number of triangles between the points. Therefore
258
+                # it could fill up till a length smaller than `stack_till`.
259
+                if self.n >= 2**self.ndim:
260
+                    # Only fill the stack if no more bounds left in _stack
261
+                    self._fill_stack(stack_till=n_left)
262
+                new_points, new_loss_improvements = self._split_stack(n_left)
263
+                points += new_points
264
+                loss_improvements += new_loss_improvements
265
+                self.add_data(new_points, itertools.repeat(None))
266
+                n_left -= len(new_points)
267
+
268
+        return points, loss_improvements
269
+
270
+    def choose_points(self, n, add_data=True):
271
+        if not add_data:
272
+            with restore(self):
273
+                return self._choose_and_add_points(n)
274
+        else:
275
+            return self._choose_and_add_points(n)
276
+
277
+    def loss(self, real=True):
278
+        n = self.n_real if real else self.n
279
+        bounds_are_not_done = any(p in self._interp
280
+                                  for p in self._bounds_points)
281
+        if n <= 4 or bounds_are_not_done:
282
+            return np.inf
283
+        ip = self.ip() if real else self.ip_combined()
284
+        losses = _losses_per_triangle(ip)
285
+        return losses.max()
286
+
287
+    def remove_unfinished(self):
288
+        self._points = self.points.copy()
289
+        self._values = self.values.copy()
290
+        self.n -= len(self._interp)
291
+        self._interp = {}
292
+
293
+    def plot(self, n_x=201, n_y=201):
294
+        x, y = self.bounds
295
+        lbrt = x[0], y[0], x[1], y[1]
296
+        if self.n_real >= 4:
297
+            x = np.linspace(-0.5, 0.5, n_x)
298
+            y = np.linspace(-0.5, 0.5, n_y)
299
+            ip = self.ip()
300
+            z = ip(x[:, None], y[None, :])
301
+            return hv.Image(np.rot90(z), bounds=lbrt)
302
+        else:
303
+            return hv.Image(np.zeros((2, 2)), bounds=lbrt)
0 304
new file mode 100644
... ...
@@ -0,0 +1,11 @@
1
+# -*- coding: utf-8 -*-
2
+from contextlib import contextmanager
3
+
4
+@contextmanager
5
+def restore(*learners):
6
+    states = [learner.__getstate__() for learner in learners]
7
+    try:
8
+        yield
9
+    finally:
10
+        for state, learner in zip(states, learners):
11
+            learner.__setstate__(state)