Skip to content

Commit ccba17d

Browse files
committed
Merge branch '114--learnerND-convex-hull' into 'master'
Resolve "(LearnerND) allow any convex hull as domain" Closes python-adaptive#114 See merge request qt/adaptive!127
2 parents 1ffe220 + 7020d46 commit ccba17d

File tree

3 files changed

+80
-18
lines changed

3 files changed

+80
-18
lines changed

adaptive/learner/learnerND.py

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,10 @@ class LearnerND(BaseLearner):
107107
func: callable
108108
The function to learn. Must take a tuple of N real
109109
parameters and return a real number or an arraylike of length M.
110-
bounds : list of 2-tuples
110+
bounds : list of 2-tuples or `scipy.spatial.ConvexHull`
111111
A list ``[(a_1, b_1), (a_2, b_2), ..., (a_n, b_n)]`` containing bounds,
112112
one pair per dimension.
113+
Or a ConvexHull that defines the boundary of the domain.
113114
loss_per_simplex : callable, optional
114115
A function that returns the loss for a simplex.
115116
If not provided, then a default is used, which uses
@@ -150,14 +151,21 @@ class LearnerND(BaseLearner):
150151
"""
151152

152153
def __init__(self, func, bounds, loss_per_simplex=None):
153-
self.ndim = len(bounds)
154154
self._vdim = None
155155
self.loss_per_simplex = loss_per_simplex or default_loss
156-
self.bounds = tuple(tuple(map(float, b)) for b in bounds)
157156
self.data = OrderedDict()
158157
self.pending_points = set()
159158

160-
self._bounds_points = list(map(tuple, itertools.product(*bounds)))
159+
if isinstance(bounds, scipy.spatial.ConvexHull):
160+
hull_points = bounds.points[bounds.vertices]
161+
self._bounds_points = sorted(list(map(tuple, hull_points)))
162+
self._bbox = tuple(zip(hull_points.min(axis=0), hull_points.max(axis=0)))
163+
self._interior = scipy.spatial.Delaunay(self._bounds_points)
164+
else:
165+
self._bounds_points = sorted(list(map(tuple, itertools.product(*bounds))))
166+
self._bbox = tuple(tuple(map(float, b)) for b in bounds)
167+
168+
self.ndim = len(self._bbox)
161169

162170
self.function = func
163171
self._tri = None
@@ -169,7 +177,7 @@ def __init__(self, func, bounds, loss_per_simplex=None):
169177
self._subtriangulations = dict() # simplex → triangulation
170178

171179
# scale to unit
172-
self._transform = np.linalg.inv(np.diag(np.diff(bounds).flat))
180+
self._transform = np.linalg.inv(np.diag(np.diff(self._bbox).flat))
173181

174182
# create a private random number generator with fixed seed
175183
self._random = random.Random(1)
@@ -275,7 +283,12 @@ def _simplex_exists(self, simplex):
275283

276284
def inside_bounds(self, point):
277285
"""Check whether a point is inside the bounds."""
278-
return all(mn <= p <= mx for p, (mn, mx) in zip(point, self.bounds))
286+
if hasattr(self, '_interior'):
287+
return self._interior.find_simplex(point, tol=1e-8) >= 0
288+
else:
289+
eps = 1e-8
290+
return all((mn - eps) <= p <= (mx + eps) for p, (mn, mx)
291+
in zip(point, self._bbox))
279292

280293
def tell_pending(self, point, *, simplex=None):
281294
point = tuple(point)
@@ -349,11 +362,13 @@ def _ask_point_without_known_simplices(self):
349362
assert not self._bounds_available
350363
# pick a random point inside the bounds
351364
# XXX: change this into picking a point based on volume loss
352-
a = np.diff(self.bounds).flat
353-
b = np.array(self.bounds)[:, 0]
354-
r = np.array([self._random.random() for _ in range(self.ndim)])
355-
p = r * a + b
356-
p = tuple(p)
365+
a = np.diff(self._bbox).flat
366+
b = np.array(self._bbox)[:, 0]
367+
p = None
368+
while p is None or not self.inside_bounds(p):
369+
r = np.array([self._random.random() for _ in range(self.ndim)])
370+
p = r * a + b
371+
p = tuple(p)
357372

358373
self.tell_pending(p)
359374
return p, np.inf
@@ -489,10 +504,10 @@ def plot(self, n=None, tri_alpha=0):
489504
if self.vdim > 1:
490505
raise NotImplementedError('holoviews currently does not support',
491506
'3D surface plots in bokeh.')
492-
if len(self.bounds) != 2:
507+
if len(self.ndim) != 2:
493508
raise NotImplementedError("Only 2D plots are implemented: You can "
494509
"plot a 2D slice with 'plot_slice'.")
495-
x, y = self.bounds
510+
x, y = self._bbox
496511
lbrt = x[0], y[0], x[1], y[1]
497512

498513
if len(self.data) >= 4:
@@ -549,7 +564,7 @@ def plot_slice(self, cut_mapping, n=None):
549564
raise NotImplementedError('multidimensional output not yet'
550565
' supported by `plot_slice`')
551566
n = n or 201
552-
values = [cut_mapping.get(i, np.linspace(*self.bounds[i], n))
567+
values = [cut_mapping.get(i, np.linspace(*self._bbox[i], n))
553568
for i in range(self.ndim)]
554569
ind = next(i for i in range(self.ndim) if i not in cut_mapping)
555570
x = values[ind]
@@ -574,9 +589,9 @@ def plot_slice(self, cut_mapping, n=None):
574589
xys = [xs[:, None], ys[None, :]]
575590
values = [cut_mapping[i] if i in cut_mapping
576591
else xys.pop(0) * (b[1] - b[0]) + b[0]
577-
for i, b in enumerate(self.bounds)]
592+
for i, b in enumerate(self._bbox)]
578593

579-
lbrt = [b for i, b in enumerate(self.bounds)
594+
lbrt = [b for i, b in enumerate(self._bbox)
580595
if i not in cut_mapping]
581596
lbrt = np.reshape(lbrt, (2, 2)).T.flatten().tolist()
582597

adaptive/tests/test_learnernd.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# -*- coding: utf-8 -*-
22

33
from ..learner import LearnerND
4-
from ..runner import replay_log
5-
4+
from ..runner import replay_log, simple
5+
from .test_learners import ring_of_fire, generate_random_parametrization
6+
import scipy.spatial
67

78
def test_faiure_case_LearnerND():
89
log = [
@@ -21,3 +22,15 @@ def test_faiure_case_LearnerND():
2122
]
2223
learner = LearnerND(lambda *x: x, bounds=[(-1, 1), (-1, 1), (-1, 1)])
2324
replay_log(learner, log)
25+
26+
def test_interior_vs_bbox_gives_same_result():
27+
f = generate_random_parametrization(ring_of_fire)
28+
29+
control = LearnerND(f, bounds=[(-1, 1), (-1, 1)])
30+
hull = scipy.spatial.ConvexHull(control._bounds_points)
31+
learner = LearnerND(f, bounds=hull)
32+
33+
simple(control, goal=lambda l: l.loss() < 0.1)
34+
simple(learner, goal=lambda l: l.loss() < 0.1)
35+
36+
assert learner.data == control.data

docs/source/tutorial/tutorial.LearnerND.rst

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,37 @@ is a result of the fact that the learner chooses points in 3 dimensions
9090
and the simplices are not in the same face as we try to interpolate our
9191
lines. However, as always, when you sample more points the graph will
9292
become gradually smoother.
93+
94+
Using any convex shape as domain
95+
--------------------------------
96+
97+
Suppose you do not simply want to sample your function on a square (in 2D) or in
98+
a cube (in 3D). The LearnerND supports using a `scipy.spatial.ConvexHull` as
99+
your domain. This is best illustrated in the following example.
100+
101+
Suppose you would like to sample you function in a cube split in half diagonally.
102+
You could use the following code as an example:
103+
104+
.. jupyter-execute::
105+
106+
import scipy
107+
108+
def f(xyz):
109+
x, y, z = xyz
110+
return x**4 + y**4 + z**4 - (x**2+y**2+z**2)**2
111+
112+
# set the bound points, you can change this to be any shape
113+
b = [(-1, -1, -1),
114+
(-1, 1, -1),
115+
(-1, -1, 1),
116+
(-1, 1, 1),
117+
( 1, 1, -1),
118+
( 1, -1, -1)]
119+
120+
# you have to convert the points into a scipy.spatial.ConvexHull
121+
hull = scipy.spatial.ConvexHull(b)
122+
123+
learner = adaptive.LearnerND(f, hull)
124+
adaptive.BlockingRunner(learner, goal=lambda l: l.npoints > 2000)
125+
126+
learner.plot_isosurface(-0.5)

0 commit comments

Comments
 (0)