From fde1774b903b5f4eb3b7667a7c419299d0d41c07 Mon Sep 17 00:00:00 2001
From: Jorn Hoofwijk <jornhoofwijk@gmail.com>
Date: Mon, 19 Nov 2018 16:04:24 +0100
Subject: [PATCH] change loss function signature

---
 adaptive/learner/learner1D.py                 | 196 +++++++++---------
 adaptive/tests/test_learner1d.py              |   6 +-
 .../reference/adaptive.learner.learner1D.rst  |   2 +-
 docs/source/tutorial/tutorial.Learner1D.rst   |   4 +-
 4 files changed, 107 insertions(+), 101 deletions(-)

diff --git a/adaptive/learner/learner1D.py b/adaptive/learner/learner1D.py
index 050c0e07..f32ab1f2 100644
--- a/adaptive/learner/learner1D.py
+++ b/adaptive/learner/learner1D.py
@@ -21,44 +21,42 @@ def uses_nth_neighbors(n):
     Wraps loss functions to indicate that they expect intervals together
     with ``n`` nearest neighbors
 
-    The loss function is then guaranteed to receive the data of at least the
-    N nearest neighbors (``nth_neighbors``) in a dict that tells you what the
-    neighboring points of these are. And the `~adaptive.Learner1D` will
-    then make sure that the loss is updated whenever one of the
-    ``nth_neighbors`` changes.
+    The loss function will then receive the data of the N nearest neighbors
+    (``nth_neighbors``) aling with the data of the interval itself in a dict.
+    The `~adaptive.Learner1D` will also make sure that the loss is updated
+    whenever one of the ``nth_neighbors`` changes.
 
     Examples
     --------
 
-    The next function is a part of the `get_curvature_loss` function.
+    The next function is a part of the `curvature_loss_function` function.
 
     >>> @uses_nth_neighbors(1)
-    ... def triangle_loss(interval, scale, data, neighbors):
-    ...     x_left, x_right = interval
-    ...     xs = [neighbors[x_left][0], x_left, x_right, neighbors[x_right][1]]
-    ...     # at the boundary, neighbors[<left boundary x>] is (None, <some other x>)
-    ...     xs = [x for x in xs if x is not None]
-    ...     if len(xs) <= 2:
-    ...         return (x_right - x_left) / scale[0]
+    ...def triangle_loss(xs, ys):
+    ...    xs = [x for x in xs if x is not None]
+    ...    ys = [y for y in ys if y is not None]
     ...
-    ...     y_scale = scale[1] or 1
-    ...     ys_scaled = [data[x] / y_scale for x in xs]
-    ...     xs_scaled = [x / scale[0] for x in xs]
-    ...     N = len(xs) - 2
-    ...     pts = [(x, y) for x, y in zip(xs_scaled, ys_scaled)]
-    ...     return sum(volume(pts[i:i+3]) for i in range(N)) / N
-
-    Or you may define a loss that favours the (local) minima of a function.
+    ...    if len(xs) == 2: # we do not have enough points for a triangle
+    ...        return xs[1] - xs[0]
+    ...
+    ...    N = len(xs) - 2 # number of constructed triangles
+    ...    if isinstance(ys[0], Iterable):
+    ...        pts = [(x, *y) for x, y in zip(xs, ys)]
+    ...        vol = simplex_volume_in_embedding
+    ...    else:
+    ...        pts = [(x, y) for x, y in zip(xs, ys)]
+    ...        vol = volume
+    ...    return sum(vol(pts[i:i+3]) for i in range(N)) / N
+
+    Or you may define a loss that favours the (local) minima of a function,
+    assuming that you know your function will have a single float as output.
 
     >>> @uses_nth_neighbors(1)
-    ... def local_minima_resolving_loss(interval, scale, data, neighbors):
-    ...     x_left, x_right = interval
-    ...     n_left = neighbors[x_left][0]
-    ...     n_right = neighbors[x_right][1]
-    ...     loss = (x_right - x_left) / scale[0]
+    ... def local_minima_resolving_loss(xs, ys):
+    ...     dx = xs[2] - xs[1] # the width of the interval of interest
     ...
-    ...     if not ((n_left is not None and data[x_left] > data[n_left])
-    ...         or (n_right is not None and data[x_right] > data[n_right])):
+    ...     if not ((ys[0] is not None and ys[0] > ys[1])
+    ...         or (ys[3] is not None and ys[3] > ys[2])):
     ...         return loss * 100
     ...
     ...     return loss
@@ -68,9 +66,8 @@ def uses_nth_neighbors(n):
         return loss_per_interval
     return _wrapped
 
-
 @uses_nth_neighbors(0)
-def uniform_loss(interval, scale, data, neighbors):
+def uniform_loss(xs, ys):
     """Loss function that samples the domain uniformly.
 
     Works with `~adaptive.Learner1D` only.
@@ -85,38 +82,36 @@ def uniform_loss(interval, scale, data, neighbors):
     ...                              loss_per_interval=uniform_sampling_1d)
     >>>
     """
-    x_left, x_right = interval
-    x_scale, _ = scale
-    dx = (x_right - x_left) / x_scale
+    dx = xs[1] - xs[0]
     return dx
 
 
 @uses_nth_neighbors(0)
-def default_loss(interval, scale, data, neighbors):
+def default_loss(xs, ys):
     """Calculate loss on a single interval.
 
     Currently returns the rescaled length of the interval. If one of the
     y-values is missing, returns 0 (so the intervals with missing data are
     never touched. This behavior should be improved later.
     """
-    x_left, x_right = interval
-    y_right, y_left = data[x_right], data[x_left]
-    x_scale, y_scale = scale
-    dx = (x_right - x_left) / x_scale
-    if y_scale == 0:
-        loss = dx
+    dx = xs[1] - xs[0]
+    if isinstance(ys[0], Iterable):
+        dy = [abs(a-b) for a, b in zip(*ys)]
+        return np.hypot(dx, dy).max()
     else:
-        dy = (y_right - y_left) / y_scale
-        try:
-            len(dy)
-            loss = np.hypot(dx, dy).max()
-        except TypeError:
-            loss = math.hypot(dx, dy)
-    return loss
+        dy = ys[1] - ys[0]
+        return np.hypot(dx, dy)
 
 
-def _loss_of_multi_interval(xs, ys):
-    N = len(xs) - 2
+@uses_nth_neighbors(1)
+def triangle_loss(xs, ys):
+    xs = [x for x in xs if x is not None]
+    ys = [y for y in ys if y is not None]
+
+    if len(xs) == 2: # we do not have enough points for a triangle
+        return xs[1] - xs[0]
+
+    N = len(xs) - 2 # number of constructed triangles
     if isinstance(ys[0], Iterable):
         pts = [(x, *y) for x, y in zip(xs, ys)]
         vol = simplex_volume_in_embedding
@@ -126,27 +121,15 @@ def _loss_of_multi_interval(xs, ys):
     return sum(vol(pts[i:i+3]) for i in range(N)) / N
 
 
-@uses_nth_neighbors(1)
-def triangle_loss(interval, scale, data, neighbors):
-    x_left, x_right = interval
-    xs = [neighbors[x_left][0], x_left, x_right, neighbors[x_right][1]]
-    xs = [x for x in xs if x is not None]
-
-    if len(xs) <= 2:
-        return (x_right - x_left) / scale[0]
-    else:
-        y_scale = scale[1] or 1
-        ys_scaled = [data[x] / y_scale for x in xs]
-        xs_scaled = [x / scale[0] for x in xs]
-        return _loss_of_multi_interval(xs_scaled, ys_scaled)
-
-
-def get_curvature_loss(area_factor=1, euclid_factor=0.02, horizontal_factor=0.02):
+def curvature_loss_function(area_factor=1, euclid_factor=0.02, horizontal_factor=0.02):
     @uses_nth_neighbors(1)
-    def curvature_loss(interval, scale, data, neighbors):
-        triangle_loss_ = triangle_loss(interval, scale, data, neighbors)
-        default_loss_ = default_loss(interval, scale, data, neighbors)
-        dx = (interval[1] - interval[0]) / scale[0]
+    def curvature_loss(xs, ys):
+        xs_middle = xs[1:3]
+        ys_middle = xs[1:3]
+
+        triangle_loss_ = triangle_loss(xs, ys)
+        default_loss_ = default_loss(xs_middle, ys_middle)
+        dx = xs_middle[0] - xs_middle[0]
         return (area_factor * (triangle_loss_**0.5)
                 + euclid_factor * default_loss_
                 + horizontal_factor * dx)
@@ -209,29 +192,24 @@ class Learner1D(BaseLearner):
 
     Notes
     -----
-    `loss_per_interval` takes 4 parameters: ``interval``,  ``scale``,
-    ``data``, and ``neighbors``, and returns a scalar; the loss over
-    the interval.
-    interval : (float, float)
-        The bounds of the interval.
-    scale : (float, float)
-        The x and y scale over all the intervals, useful for rescaling the
-        interval loss.
-    data : dict(float → float)
-        A map containing evaluated function values. It is guaranteed
-        to have values for both of the points in 'interval'.
-    neighbors : dict(float → (float, float))
-        A map containing points as keys to its neighbors as a tuple.
-        At the left ``x_left`` and right ``x_left`` most boundary it has
-        ``x_left: (None, float)`` and ``x_right: (float, None)``.
-
-    The `loss_per_interval` function should also have
-    an attribute `nth_neighbors` that indicates how many of the neighboring
-    intervals to `interval` are used. If `loss_per_interval` doesn't
-    have such an attribute, it's assumed that is uses **no** neighboring
-    intervals. Also see the `uses_nth_neighbors` decorator.
-    **WARNING**: When modifying the `data` and `neighbors` datastructures
-    the learner will behave in an undefined way.
+    `loss_per_interval` takes 2 parameters: ``xs`` and ``ys``, and returns a
+        scalar; the loss over the interval.
+    xs : tuple of floats
+        The x values of the interval, if `nth_neighbors` is greater than zero it
+        also contains the x-values of the neighbors of the interval, in ascending
+        order. The interval we want to know the loss of is then the middle
+        interval. If no neighbor is available (at the edges of the domain) then
+        `None` will take the place of the x-value of the neighbor.
+    ys : tuple of function values
+        The output values of the function when evaluated at the `xs`. This is
+        either a float or a tuple of floats in the case of vector output.
+
+
+    The `loss_per_interval` function may also have an attribute `nth_neighbors`
+    that indicates how many of the neighboring intervals to `interval` are used.
+    If `loss_per_interval` doesn't  have such an attribute, it's assumed that is
+    uses **no** neighboring intervals. Also see the `uses_nth_neighbors`
+    decorator for more information.
     """
 
     def __init__(self, function, bounds, loss_per_interval=None):
@@ -300,16 +278,41 @@ class Learner1D(BaseLearner):
         losses = self.losses if real else self.losses_combined
         return max(losses.values()) if len(losses) > 0 else float('inf')
 
+    def _scale_x(self, x):
+        if x is None:
+            return None
+        return x / self._scale[0]
+
+    def _scale_y(self, y):
+        if y is None:
+            return None
+        y_scale = self._scale[1] or 1
+        return y / y_scale
+
+    def _get_point_by_index(self, ind):
+        if ind < 0 or ind >= len(self.neighbors):
+            return None
+        return self.neighbors.keys()[ind]
+
     def _get_loss_in_interval(self, x_left, x_right):
         assert x_left is not None and x_right is not None
 
         if x_right - x_left < self._dx_eps:
             return 0
 
-        # we need to compute the loss for this interval
-        return self.loss_per_interval(
-            (x_left, x_right), self._scale, self.data, self.neighbors)
+        nn = self.nth_neighbors
+        i = self.neighbors.index(x_left)
+        start = i - nn
+        end = i + nn + 2
+
+        xs = [self._get_point_by_index(i) for i in range(start, end)]
+        ys = [self.data.get(x, None) for x in xs]
 
+        xs_scaled = tuple(self._scale_x(x) for x in xs)
+        ys_scaled = tuple(self._scale_y(y) for y in ys)
+
+        # we need to compute the loss for this interval
+        return self.loss_per_interval(xs_scaled, ys_scaled)
 
     def _update_interpolated_loss_in_interval(self, x_left, x_right):
         if x_left is None or x_right is None:
@@ -419,6 +422,9 @@ class Learner1D(BaseLearner):
         if x in self.data:
             # The point is already evaluated before
             return
+        if y is None:
+            raise TypeError("Y-value may not be None, use learner.tell_pending(x)"
+                "to indicate that this value is currently being calculated")
 
         # either it is a float/int, if not, try casting to a np.array
         if not isinstance(y, (float, int)):
diff --git a/adaptive/tests/test_learner1d.py b/adaptive/tests/test_learner1d.py
index bcca82b4..837ae682 100644
--- a/adaptive/tests/test_learner1d.py
+++ b/adaptive/tests/test_learner1d.py
@@ -4,7 +4,7 @@ import random
 import numpy as np
 
 from ..learner import Learner1D
-from ..learner.learner1D import get_curvature_loss
+from ..learner.learner1D import curvature_loss_function
 from ..runner import simple
 
 
@@ -347,7 +347,7 @@ def test_curvature_loss():
     def f(x):
         return np.tanh(20*x)
 
-    loss = get_curvature_loss()
+    loss = curvature_loss_function()
     assert loss.nth_neighbors == 1
     learner = Learner1D(f, (-1, 1), loss_per_interval=loss)
     simple(learner, goal=lambda l: l.npoints > 100)
@@ -358,7 +358,7 @@ def test_curvature_loss_vectors():
     def f(x):
         return np.tanh(20*x), np.tanh(20*(x-0.4))
 
-    loss = get_curvature_loss()
+    loss = curvature_loss_function()
     assert loss.nth_neighbors == 1
     learner = Learner1D(f, (-1, 1), loss_per_interval=loss)
     simple(learner, goal=lambda l: l.npoints > 100)
diff --git a/docs/source/reference/adaptive.learner.learner1D.rst b/docs/source/reference/adaptive.learner.learner1D.rst
index 8aed37d9..a7a15620 100644
--- a/docs/source/reference/adaptive.learner.learner1D.rst
+++ b/docs/source/reference/adaptive.learner.learner1D.rst
@@ -17,4 +17,4 @@ Custom loss functions
 
 .. autofunction:: adaptive.learner.learner1D.triangle_loss
 
-.. autofunction:: adaptive.learner.learner1D.get_curvature_loss
+.. autofunction:: adaptive.learner.learner1D.curvature_loss_function
diff --git a/docs/source/tutorial/tutorial.Learner1D.rst b/docs/source/tutorial/tutorial.Learner1D.rst
index bfc873a3..6109cfd5 100644
--- a/docs/source/tutorial/tutorial.Learner1D.rst
+++ b/docs/source/tutorial/tutorial.Learner1D.rst
@@ -150,10 +150,10 @@ by specifying ``loss_per_interval``.
 
 .. jupyter-execute::
 
-    from adaptive.learner.learner1D import (get_curvature_loss,
+    from adaptive.learner.learner1D import (curvature_loss_function,
                                             uniform_loss,
                                             default_loss)
-    curvature_loss = get_curvature_loss()
+    curvature_loss = curvature_loss_function()
     learner = adaptive.Learner1D(f, bounds=(-1, 1), loss_per_interval=curvature_loss)
     runner = adaptive.Runner(learner, goal=lambda l: l.loss() < 0.01)
 
-- 
GitLab