diff --git a/adaptive/learner/balancing_learner.py b/adaptive/learner/balancing_learner.py index 43f7dc1f3..651707177 100644 --- a/adaptive/learner/balancing_learner.py +++ b/adaptive/learner/balancing_learner.py @@ -261,8 +261,13 @@ def ask( return [], [] if not tell_pending: - with restore(*self.learners): - return self._ask_and_tell(n) + try: + with restore(*self.learners): + return self._ask_and_tell(n) + finally: + self._ask_cache.clear() + self._loss.clear() + self._pending_loss.clear() else: return self._ask_and_tell(n) diff --git a/adaptive/learner/data_saver.py b/adaptive/learner/data_saver.py index 2deafe2cb..b0ac57baf 100644 --- a/adaptive/learner/data_saver.py +++ b/adaptive/learner/data_saver.py @@ -17,8 +17,14 @@ with_pandas = False -def _to_key(x): - return tuple(x.values) if x.values.size > 1 else x.item() +def _mapping_uses_tuple_keys(mapping): + return bool(mapping) and isinstance(next(iter(mapping)), tuple) + + +def _row_to_key(row, force_tuple=False): + if row.values.size > 1 or force_tuple: + return tuple(row.values) + return row.item() class DataSaver(BaseLearner): @@ -109,8 +115,10 @@ def to_dataframe( # type: ignore[override] **kwargs, ) + force_tuple = _mapping_uses_tuple_keys(self.extra_data) df[extra_data_name] = [ - self.extra_data[_to_key(x)] for _, x in df[df.attrs["inputs"]].iterrows() + self.extra_data[_row_to_key(x, force_tuple=force_tuple)] + for _, x in df[df.attrs["inputs"]].iterrows() ] return df @@ -146,10 +154,11 @@ def load_dataframe( # type: ignore[override] function_prefix=function_prefix, **kwargs, ) - keys = df.attrs.get("inputs", list(input_names)) - for _, x in df[keys + [extra_data_name]].iterrows(): - key = _to_key(x[:-1]) - self.extra_data[key] = x[-1] + input_columns = df.attrs.get("inputs", list(input_names)) + force_tuple = _mapping_uses_tuple_keys(self.learner.data) + for _, row in df[input_columns + [extra_data_name]].iterrows(): + key = _row_to_key(row.iloc[:-1], force_tuple=force_tuple) + self.extra_data[key] = row.iloc[-1] def _get_data(self) -> tuple[Any, OrderedDict[Any, Any]]: return self.learner._get_data(), self.extra_data diff --git a/adaptive/learner/learnerND.py b/adaptive/learner/learnerND.py index 33bbbbb07..471b7ad50 100644 --- a/adaptive/learner/learnerND.py +++ b/adaptive/learner/learnerND.py @@ -493,11 +493,39 @@ def load_dataframe( # type: ignore[override] def bounds_are_done(self): return all(p in self.data for p in self._bounds_points) + def _sorted_line_data(self): + coordinates = self.points[:, 0] + sorted_indices = np.argsort(coordinates) + return coordinates[sorted_indices], self.values[sorted_indices] + def _ip(self): - """A `scipy.interpolate.LinearNDInterpolator` instance - containing the learner's data.""" + """A SciPy interpolator containing the learner's data.""" # XXX: take our own triangulation into account when generating the _ip - return interpolate.LinearNDInterpolator(self.points, self.values) + if self.ndim != 1: + return interpolate.LinearNDInterpolator(self.points, self.values) + + coordinates, values = self._sorted_line_data() + return interpolate.interp1d( + coordinates, + values, + axis=0, + bounds_error=False, + fill_value=np.nan, + ) + + def _plot_1d(self, n=None): + hv = ensure_holoviews() + if len(self.data) < 2: + return hv.Path([]) * hv.Scatter([]).opts(size=5) + + (x_bounds,) = self._bbox + n = n or 201 + xs = np.linspace(*x_bounds, n) + ys = self._ip()(xs) + scatter_points = [ + (point[0], value) for point, value in sorted(self.data.items()) + ] + return hv.Path((xs, ys)) * hv.Scatter(scatter_points).opts(size=5) @property def tri(self): @@ -834,7 +862,7 @@ def _update_range(self, new_output): # this is the first point, nothing to do, just set the range self._min_value = np.min(new_output) self._max_value = np.max(new_output) - self._old_scale = self._scale or 1 + self._old_scale = self._scale return False # if range in one or more directions is doubled, then update all losses @@ -857,7 +885,10 @@ def _update_range(self, new_output): self._output_multiplier = scale_multiplier - scale_factor = self._scale / self._old_scale + if self._old_scale == 0: + scale_factor = math.inf if self._scale > 0 else 1 + else: + scale_factor = self._scale / self._old_scale if scale_factor > self._recompute_losses_factor: self._old_scale = self._scale self._recompute_all_losses() @@ -881,23 +912,26 @@ def remove_unfinished(self): ########################## def plot(self, n=None, tri_alpha=0): - """Plot the function we want to learn, only works in 2D. + """Plot the function we want to learn in 1D or 2D. Parameters ---------- n : int - the number of boxes in the interpolation grid along each axis + The number of interpolation points per axis. tri_alpha : float (0 to 1) Opacity of triangulation lines """ - hv = ensure_holoviews() if self.vdim > 1: raise NotImplementedError( "holoviews currently does not support", "3D surface plots in bokeh." ) + if self.ndim == 1: + return self._plot_1d(n) + + hv = ensure_holoviews() if self.ndim != 2: raise NotImplementedError( - "Only 2D plots are implemented: You can " + "Only 1D and 2D plots are implemented: You can " "plot a 2D slice with 'plot_slice'." ) x, y = self._bbox diff --git a/adaptive/learner/triangulation.py b/adaptive/learner/triangulation.py index 26a5ebc2a..3709602b9 100644 --- a/adaptive/learner/triangulation.py +++ b/adaptive/learner/triangulation.py @@ -231,6 +231,14 @@ def is_iterable_and_sized(obj): return isinstance(obj, Iterable) and isinstance(obj, Sized) +def _flat_simplices(coords): + """Yield the intervals of a 1D triangulation in sorted coordinate order.""" + sorted_indices = sorted(range(len(coords)), key=coords.__getitem__) + for left, right in zip(sorted_indices, sorted_indices[1:]): + if coords[left] != coords[right]: + yield left, right + + def simplex_volume_in_embedding(vertices) -> float: """Calculate the volume of a simplex in a higher dimensional embedding. That is: dim > len(vertices) - 1. For example if you would like to know the @@ -257,8 +265,13 @@ def simplex_volume_in_embedding(vertices) -> float: # Modified from https://codereview.stackexchange.com/questions/77593/calculating-the-volume-of-a-tetrahedron vertices = asarray(vertices, dtype=float) - dim = len(vertices[0]) - if dim == 2: + num_vertices = len(vertices) + if num_vertices == 2: + # A 1-simplex is just a line segment. + return float(norm(vertices[1] - vertices[0])) + + embedding_dim = len(vertices[0]) + if embedding_dim == 2: # Heron's formula a, b, c = scipy.spatial.distance.pdist(vertices, metric="euclidean") s = 0.5 * (a + b + c) @@ -268,13 +281,12 @@ def simplex_volume_in_embedding(vertices) -> float: sq_dists = scipy.spatial.distance.pdist(vertices, metric="sqeuclidean") # Add border while compressed - num_verts = scipy.spatial.distance.num_obs_y(sq_dists) - bordered = concatenate((ones(num_verts), sq_dists)) + bordered = concatenate((ones(num_vertices), sq_dists)) # Make matrix and find volume sq_dists_mat = scipy.spatial.distance.squareform(bordered) - coeff = -((-2) ** (num_verts - 1)) * factorial(num_verts - 1) ** 2 + coeff = -((-2) ** (num_vertices - 1)) * factorial(num_vertices - 1) ** 2 vol_square = fast_det(sq_dists_mat) / coeff if vol_square < 0: @@ -326,9 +338,6 @@ def __init__(self, coords): if any(len(coord) != dim for coord in coords): raise ValueError("Coordinates dimension mismatch") - if dim == 1: - raise ValueError("Triangulation class only supports dim >= 2") - if len(coords) < dim + 1: raise ValueError("Please provide at least one simplex") @@ -344,10 +353,14 @@ def __init__(self, coords): # initialise empty set for each vertex self.vertex_to_simplices = [set() for _ in coords] - # find a Delaunay triangulation to start with, then we will throw it - # away and continue with our own algorithm - initial_tri = scipy.spatial.Delaunay(coords) - for simplex in initial_tri.simplices: + if dim == 1: + simplices = _flat_simplices(coords) + else: + # Find a Delaunay triangulation to start with, then we will throw it + # away and continue with our own algorithm. + simplices = scipy.spatial.Delaunay(coords).simplices + + for simplex in simplices: self.add_simplex(simplex) def delete_simplex(self, simplex): diff --git a/adaptive/tests/test_balancing_learner.py b/adaptive/tests/test_balancing_learner.py index c50b2105d..3212c473a 100644 --- a/adaptive/tests/test_balancing_learner.py +++ b/adaptive/tests/test_balancing_learner.py @@ -1,13 +1,24 @@ from __future__ import annotations +import functools as ft +import math +import random + +import numpy as np import pytest -from adaptive.learner import BalancingLearner, Learner1D +from adaptive.learner import BalancingLearner, Learner1D, Learner2D from adaptive.runner import simple strategies = ["loss", "loss_improvements", "npoints", "cycle"] +def ring_of_fire(xy, d): + a = 0.2 + x, y = xy + return x + math.exp(-((x**2 + y**2 - d**2) ** 2) / a**4) + + def test_balancing_learner_loss_cache(): learner = Learner1D(lambda x: x, bounds=(-1, 1)) learner.tell(-1, -1) @@ -64,3 +75,34 @@ def test_strategies(strategy, goal_type, goal): learners = [Learner1D(lambda x: x, bounds=(-1, 1)) for i in range(10)] learner = BalancingLearner(learners, strategy=strategy) simple(learner, **{goal_type: goal}) + + +def test_loss_improvements_strategy_with_tell_pending_false_reserves_child_points(): + random.seed(3104322362) + np.random.seed(3104322362 % 2**32) + + learners = [ + Learner2D( + ft.partial(ring_of_fire, d=random.uniform(0.2, 1)), + bounds=((-1, 1), (-1, 1)), + ) + for _ in range(4) + ] + learner = BalancingLearner(learners, strategy="loss_improvements") + + stash = [] + for n, m in [(1, 1), (4, 4), (2, 0), (4, 4), (8, 6)]: + xs, _ = learner.ask(n, tell_pending=False) + random.shuffle(xs) + for _ in range(m): + stash.append(xs.pop()) + + for x in xs: + learner.tell(x, learner.function(x)) + + random.shuffle(stash) + for _ in range(m): + x = stash.pop() + learner.tell(x, learner.function(x)) + + assert all(not child.pending_points for child in learners) diff --git a/adaptive/tests/test_learnernd.py b/adaptive/tests/test_learnernd.py index 0884b7eeb..244fd89bc 100644 --- a/adaptive/tests/test_learnernd.py +++ b/adaptive/tests/test_learnernd.py @@ -1,4 +1,5 @@ import numpy as np +import pytest import scipy.spatial from adaptive.learner import LearnerND @@ -48,3 +49,23 @@ def test_vector_return_with_a_flat_layer(): for function in [h1, h2, h3]: learner = LearnerND(function, bounds=[(-1, 1), (-1, 1)]) simple(learner, loss_goal=0.1) + + +@pytest.mark.parametrize( + ("run_kwargs", "expected_npoints"), + [ + ({"npoints_goal": 10}, 10), + ({"loss_goal": 0.1}, None), + ], + ids=["npoints-goal", "loss-goal"], +) +def test_learnerND_1d(run_kwargs, expected_npoints): + """Test LearnerND works with 1D bounds.""" + learner = LearnerND(lambda x: x[0] ** 2, bounds=[(-1, 1)]) + simple(learner, **run_kwargs) + + if expected_npoints is not None: + assert learner.npoints == expected_npoints + assert learner.loss() < float("inf") + if "loss_goal" in run_kwargs: + assert learner.loss() <= run_kwargs["loss_goal"] diff --git a/adaptive/tests/test_learners.py b/adaptive/tests/test_learners.py index d8cb2eaf7..139715b6d 100644 --- a/adaptive/tests/test_learners.py +++ b/adaptive/tests/test_learners.py @@ -137,6 +137,13 @@ def linear_with_peak(x, d: uniform(-1, 1)): # type: ignore[valid-type] return x + a**2 / (a**2 + (x - d) ** 2) +@learn_with(LearnerND, bounds=((-1, 1),)) +def peak_1d(x, d: uniform(-0.5, 0.5)): # type: ignore[valid-type] + a = 0.01 + (x,) = x + return x + a**2 / (a**2 + (x - d) ** 2) + + @learn_with(LearnerND, bounds=((-1, 1), (-1, 1))) @learn_with(Learner2D, bounds=((-1, 1), (-1, 1))) @learn_with(SequenceLearner, sequence=np.random.rand(1000, 2)) diff --git a/adaptive/tests/test_triangulation.py b/adaptive/tests/test_triangulation.py index c67b10146..5926e54e9 100644 --- a/adaptive/tests/test_triangulation.py +++ b/adaptive/tests/test_triangulation.py @@ -8,6 +8,7 @@ from adaptive.learner.triangulation import Triangulation with_dimension = pytest.mark.parametrize("dim", [2, 3, 4]) +with_dimension_incl_1d = pytest.mark.parametrize("dim", [1, 2, 3, 4]) def _make_triangulation(points): @@ -76,15 +77,7 @@ def test_triangulation_raises_exception_for_1d_list(): Triangulation(pts) -def test_triangulation_raises_exception_for_1d_points(): - # We could support 1d, but we don't for now, because it is not relevant - # so a user has to be aware - pts = [(0,), (1,)] - with pytest.raises(ValueError): - Triangulation(pts) - - -@with_dimension +@with_dimension_incl_1d def test_triangulation_of_standard_simplex(dim): t = Triangulation(_make_standard_simplex(dim)) expected_simplex = tuple(range(dim + 1)) @@ -132,7 +125,7 @@ def test_adding_point_outside_circumscribed_hypersphere_in_positive_orthant(dim) ) -@with_dimension +@with_dimension_incl_1d def test_adding_point_outside_standard_simplex_in_negative_orthant(dim): t = Triangulation(_make_standard_simplex(dim)) new_point = list(range(-dim, 0)) @@ -166,7 +159,7 @@ def test_adding_point_outside_standard_simplex_in_negative_orthant(dim): assert extra_simplices | {initial_simplex} == t.simplices -@with_dimension +@with_dimension_incl_1d @pytest.mark.parametrize("provide_simplex", [True, False]) def test_adding_point_inside_standard_simplex(dim, provide_simplex): t = Triangulation(_make_standard_simplex(dim)) @@ -212,7 +205,7 @@ def test_adding_point_on_standard_simplex_face(dim): assert np.isclose(np.sum(t.volumes()), _standard_simplex_volume(dim)) -@with_dimension +@with_dimension_incl_1d def test_adding_point_on_standard_simplex_edge(dim): pts = _make_standard_simplex(dim) t = Triangulation(pts) @@ -230,7 +223,7 @@ def test_adding_point_on_standard_simplex_edge(dim): assert np.isclose(np.sum(t.volumes()), _standard_simplex_volume(dim)) -@with_dimension +@with_dimension_incl_1d def test_adding_point_colinear_with_first_edge(dim): pts = _make_standard_simplex(dim) t = Triangulation(pts) @@ -279,7 +272,7 @@ def test_adding_point_inside_circumscribed_circle(dim): assert new_simplices == t.simplices -@with_dimension +@with_dimension_incl_1d def test_triangulation_volume_is_less_than_bounding_box(dim): eps = 1e-8 points = np.random.random((10, dim)) # all within the unit hypercube @@ -289,7 +282,7 @@ def test_triangulation_volume_is_less_than_bounding_box(dim): assert np.sum(t.volumes()) < 1 + eps -@with_dimension +@with_dimension_incl_1d def test_triangulation_is_deterministic(dim): points = np.random.random((10, dim)) t1 = _make_triangulation(points) @@ -297,7 +290,7 @@ def test_triangulation_is_deterministic(dim): assert t1.simplices == t2.simplices -@with_dimension +@with_dimension_incl_1d def test_initialisation_raises_when_not_enough_points(dim): deficient_simplex = _make_standard_simplex(dim)[:-1] @@ -317,7 +310,7 @@ def test_initialisation_raises_when_points_coplanar(dim): Triangulation(zero_volume_simplex) -@with_dimension +@with_dimension_incl_1d def test_initialisation_accepts_more_than_one_simplex(dim): points = _make_standard_simplex(dim) new_point = [1.1] * dim # Point oposing the origin but outside circumsphere @@ -331,3 +324,92 @@ def test_initialisation_accepts_more_than_one_simplex(dim): _check_triangulation_is_valid(tri) assert tri.simplices == {simplex1, simplex2} + + +# ---- 1D-specific triangulation tests ---- + + +@pytest.mark.parametrize( + ("points", "expected_simplices", "expected_hull", "expected_total_volume"), + [ + ([(0.0,), (1.0,)], {(0, 1)}, {0, 1}, 1.0), + ([(0.0,), (0.5,), (1.0,)], {(0, 1), (1, 2)}, {0, 2}, 1.0), + ([(1.0,), (0.0,), (0.5,)], {(0, 2), (1, 2)}, {0, 1}, 1.0), + ], + ids=["two-points", "sorted", "unsorted"], +) +def test_1d_triangulation_initialisation( + points, expected_simplices, expected_hull, expected_total_volume +): + t = Triangulation(points) + + assert t.simplices == expected_simplices + assert t.hull == expected_hull + _check_triangulation_is_valid(t) + assert np.isclose(sum(t.volumes()), expected_total_volume) + + +@pytest.mark.parametrize( + ("point", "expected_simplices", "expected_hull", "expected_total_volume"), + [ + ((0.5,), {(0, 2), (1, 2)}, {0, 1}, 1.0), + ((2.0,), {(0, 1), (1, 2)}, {0, 2}, 2.0), + ((-1.0,), {(0, 1), (0, 2)}, {1, 2}, 2.0), + ], + ids=["inside", "outside-right", "outside-left"], +) +def test_1d_add_point(point, expected_simplices, expected_hull, expected_total_volume): + t = Triangulation([(0.0,), (1.0,)]) + + _add_point_with_check(t, point) + + assert t.simplices == expected_simplices + assert t.hull == expected_hull + _check_triangulation_is_valid(t) + assert np.isclose(sum(t.volumes()), expected_total_volume) + + +def test_1d_locate_point(): + """Test locating a point in a 1D triangulation.""" + t = Triangulation([(0.0,), (0.5,), (1.0,)]) + # Point in first interval + simplex = t.locate_point((0.25,)) + assert simplex # should find a simplex + # Point in second interval + simplex = t.locate_point((0.75,)) + assert simplex + # Point outside + simplex = t.locate_point((1.5,)) + assert simplex == () + + +@pytest.mark.parametrize( + ("points", "expected_simplices"), + [ + ([(0.0,), (1.0,), (1.0,)], {(0, 1)}), + ([(0.0,), (0.0,), (0.5,), (1.0,), (1.0,)], None), + ], + ids=["single-duplicate", "multiple-duplicates"], +) +def test_1d_duplicate_coordinates_skipped(points, expected_simplices): + """Test that duplicate 1D coordinates don't create degenerate simplices.""" + t = Triangulation(points) + + if expected_simplices is not None: + assert t.simplices == expected_simplices + assert all(v > 0 for v in t.volumes()) + _check_triangulation_is_valid(t) + assert np.isclose(sum(t.volumes()), 1.0) + + +def test_1d_opposing_vertices(): + """Test opposing vertices in 1D.""" + pts = [(0.0,), (0.5,), (1.0,)] + t = Triangulation(pts) + # sorted: 0=(0.0,), 1=(0.5,), 2=(1.0,) → simplices: (0,1), (1,2) + # For simplex (0,1): opposing 0 is 2 (from simplex (1,2)), opposing 1 is None (0 is hull) + opp = t.get_opposing_vertices((0, 1)) + # vertex 0 is opposed by the vertex in neighbor of face (1,) not containing 0 + # vertex 1 is opposed by the vertex in neighbor of face (0,) not containing 1 + assert opp[1] is None # face (0,) is hull, no neighbor + assert opp[0] == 2 # face (1,) connects to simplex (1,2), opposing vertex is 2 diff --git a/adaptive/tests/unit/test_learnernd.py b/adaptive/tests/unit/test_learnernd.py index ecd00d6da..da9d147bf 100644 --- a/adaptive/tests/unit/test_learnernd.py +++ b/adaptive/tests/unit/test_learnernd.py @@ -4,8 +4,15 @@ import pytest from scipy.spatial import ConvexHull +import adaptive.notebook_integration as notebook_integration from adaptive.learner.base_learner import uses_nth_neighbors -from adaptive.learner.learnerND import LearnerND, curvature_loss_function +from adaptive.learner.learnerND import ( + LearnerND, + curvature_loss_function, + default_loss, + std_loss, + uniform_loss, +) def ring_of_fire(xy): @@ -42,3 +49,170 @@ def loss(*args): assert loss.nth_neighbors == 2 with pytest.raises(NotImplementedError): LearnerND(ring_of_fire, bounds=[(-1, 1), (-1, 1)], loss_per_simplex=loss) + + +# ---- 1D-specific LearnerND tests ---- + + +ONE_D_BOUNDS = [(-1, 1)] +ONE_D_POINTS = (-1.0, -0.5, 0.0, 0.5, 1.0) + + +def f_1d(x): + """Simple 1D test function.""" + return x[0] ** 2 + + +def make_1d_learner(function=f_1d, **kwargs): + return LearnerND(function, bounds=ONE_D_BOUNDS, **kwargs) + + +def tell_1d_points(learner, function=None, points=ONE_D_POINTS): + function = learner.function if function is None else function + for x in points: + learner.tell((x,), function((x,))) + + +def initialize_1d_learner(**kwargs): + learner = make_1d_learner(**kwargs) + points, _ = learner.ask(2) + for point in points: + learner.tell(point, learner.function(point)) + return learner + + +def test_learnerND_1d_construction(): + """Test that LearnerND can be constructed with 1D bounds.""" + learner = make_1d_learner() + assert learner.ndim == 1 + assert learner._bounds_points == [(-1,), (1,)] + assert learner._bbox == ((-1.0, 1.0),) + + +@pytest.mark.parametrize( + ("loss_fn", "expected_nth_neighbors"), + [ + (None, 0), + (curvature_loss_function(), 1), + ], + ids=["default", "curvature"], +) +def test_learnerND_1d_tell_ask(loss_fn, expected_nth_neighbors): + """Test basic tell/ask cycle for 1D LearnerND.""" + kwargs = {} if loss_fn is None else {"loss_per_simplex": loss_fn} + learner = initialize_1d_learner(**kwargs) + + assert learner.tri is not None + assert learner.nth_neighbors == expected_nth_neighbors + + points2, losses2 = learner.ask(3) + + assert len(points2) == 3 + assert all(loss > 0 for loss in losses2) + + +@pytest.mark.parametrize( + "loss_fn", + [ + pytest.param(loss_fn, id=loss_fn.__name__) + for loss_fn in (default_loss, uniform_loss, std_loss) + ], +) +def test_learnerND_1d_loss_functions(loss_fn): + """Test that all standard loss functions work for 1D.""" + learner = initialize_1d_learner(loss_per_simplex=loss_fn) + points2, losses2 = learner.ask(3) + + assert len(points2) == 3 + assert all(loss > 0 for loss in losses2) + + +def test_learnerND_1d_interpolation(): + """Test that 1D interpolation works correctly.""" + learner = make_1d_learner() + tell_1d_points(learner) + ip = learner._ip() + + assert np.isclose(ip(0.0), 0.0) + assert np.isclose(ip(1.0), 1.0) + assert np.isclose(ip(0.25), 0.125) # linear between 0 and 0.5 + + +def test_learnerND_1d_vector_output_interpolation(): + """Test that 1D interpolation works for R^1 -> R^M functions.""" + + def f_vec(x): + return np.array([x[0] ** 2, np.sin(x[0])]) + + learner = make_1d_learner(function=f_vec) + tell_1d_points(learner, function=f_vec) + ip = learner._ip() + result = ip(0.0) + assert result.shape == (2,) + assert np.isclose(result[0], 0.0) + assert np.isclose(result[1], 0.0) + + +def test_learnerND_recomputes_losses_for_small_scale_updates(): + learner = make_1d_learner() + learner._recompute_losses_factor = 1 + + for point, value in [((-1,), 0.0), ((0.0,), 0.45), ((1.0,), 0.45)]: + learner.tell(point, value) + + simplex = next( + simplex + for simplex in learner.tri.simplices + if {tuple(vertex) for vertex in learner.tri.get_vertices(simplex)} + == {(-1.0,), (0.0,)} + ) + cached_before = learner._losses[simplex] + assert np.isclose(cached_before, learner._compute_loss(simplex)) + + learner.tell((0.5,), 0.67) + + simplex = next( + simplex + for simplex in learner.tri.simplices + if {tuple(vertex) for vertex in learner.tri.get_vertices(simplex)} + == {(-1.0,), (0.0,)} + ) + cached_after = learner._losses[simplex] + + assert learner._old_scale == pytest.approx(0.67) + assert np.isclose(cached_after, learner._compute_loss(simplex)) + assert not np.isclose(cached_after, cached_before) + + +def test_learnerND_1d_plot_requires_holoviews(monkeypatch): + """Test that plotting fails with a clear error without holoviews.""" + + import_module = notebook_integration.importlib.import_module + + def missing_holoviews(name): + if name == "holoviews": + raise ModuleNotFoundError + return import_module(name) + + monkeypatch.setattr( + notebook_integration.importlib, "import_module", missing_holoviews + ) + + learner = make_1d_learner() + tell_1d_points(learner) + + with pytest.raises( + RuntimeError, match="holoviews is not installed; plotting is disabled." + ): + learner.plot() + + +def test_learnerND_1d_plot(): + """Test that 1D plot() does not crash.""" + hv = pytest.importorskip("holoviews") + + hv.extension("bokeh") + learner = make_1d_learner() + tell_1d_points(learner) + plot = learner.plot() + assert plot is not None diff --git a/adaptive/tests/unit/test_learnernd_integration.py b/adaptive/tests/unit/test_learnernd_integration.py index 939108377..b434ee42e 100644 --- a/adaptive/tests/unit/test_learnernd_integration.py +++ b/adaptive/tests/unit/test_learnernd_integration.py @@ -14,22 +14,45 @@ def ring_of_fire(xy, d=0.75): return x + math.exp(-((x**2 + y**2 - d**2) ** 2) / a**4) -def test_learnerND_runs_to_10_points(): - learner = LearnerND(ring_of_fire, bounds=[(-1, 1), (-1, 1)]) +def wave_1d(x): + return math.sin(x[0] * 5) + + +ONE_D_BOUNDS = [(-1, 1)] +TWO_D_BOUNDS = [(-1, 1), (-1, 1)] + + +@pytest.mark.parametrize( + ("function", "bounds"), + [(ring_of_fire, TWO_D_BOUNDS), (wave_1d, ONE_D_BOUNDS)], + ids=["2d", "1d"], +) +def test_learnerND_runs_to_10_points(function, bounds): + learner = LearnerND(function, bounds=bounds) SimpleRunner(learner, npoints_goal=10) assert learner.npoints == 10 +@pytest.mark.parametrize( + ("function", "bounds"), + [(ring_of_fire, TWO_D_BOUNDS), (wave_1d, ONE_D_BOUNDS)], + ids=["2d", "1d"], +) @pytest.mark.parametrize("execution_number", range(5)) -def test_learnerND_runs_to_10_points_Blocking(execution_number): - learner = LearnerND(ring_of_fire, bounds=[(-1, 1), (-1, 1)]) +def test_learnerND_runs_to_10_points_Blocking(function, bounds, execution_number): + learner = LearnerND(function, bounds=bounds) BlockingRunner(learner, npoints_goal=10) assert learner.npoints >= 10 -def test_learnerND_curvature_runs_to_10_points(): +@pytest.mark.parametrize( + ("function", "bounds"), + [(ring_of_fire, TWO_D_BOUNDS), (wave_1d, ONE_D_BOUNDS)], + ids=["2d", "1d"], +) +def test_learnerND_curvature_runs_to_10_points(function, bounds): loss = curvature_loss_function() - learner = LearnerND(ring_of_fire, bounds=[(-1, 1), (-1, 1)], loss_per_simplex=loss) + learner = LearnerND(function, bounds=bounds, loss_per_simplex=loss) SimpleRunner(learner, npoints_goal=10) assert learner.npoints == 10 @@ -37,7 +60,7 @@ def test_learnerND_curvature_runs_to_10_points(): @pytest.mark.parametrize("execution_number", range(5)) def test_learnerND_curvature_runs_to_10_points_Blocking(execution_number): loss = curvature_loss_function() - learner = LearnerND(ring_of_fire, bounds=[(-1, 1), (-1, 1)], loss_per_simplex=loss) + learner = LearnerND(ring_of_fire, bounds=TWO_D_BOUNDS, loss_per_simplex=loss) BlockingRunner(learner, npoints_goal=10) assert learner.npoints >= 10 @@ -53,3 +76,14 @@ def test_learnerND_log_works(): learner.ask(2) # At this point, there should! be one simplex in the triangulation, # furthermore the last two points that were asked should be in this simplex + + +def test_learnerND_1d_loss_decreases(): + """Test that loss decreases as more points are added.""" + learner = LearnerND(wave_1d, bounds=ONE_D_BOUNDS) + SimpleRunner(learner, npoints_goal=5) + loss_5 = learner.loss() + assert loss_5 != float("inf") + SimpleRunner(learner, npoints_goal=20) + loss_20 = learner.loss() + assert loss_20 <= loss_5 diff --git a/adaptive/tests/unit/test_triangulation.py b/adaptive/tests/unit/test_triangulation.py index 4aa48a9f0..f62c1d6c8 100644 --- a/adaptive/tests/unit/test_triangulation.py +++ b/adaptive/tests/unit/test_triangulation.py @@ -71,9 +71,20 @@ def test_circumsphere(): def generate_random_sphere_points(dim, radius=0): """https://math.stackexchange.com/a/1585996""" - vec = [None] * (dim + 1) center = uniform(-100, 100, dim) radius = uniform(1.0, 100.0) if radius == 0 else radius + + if dim == 1: + # For 1D, place the two points exactly at center ± radius + # to avoid the Gaussian sampling flakiness where both points + # can land on the same side of center. + vec = [ + tuple(center - radius), + tuple(center + radius), + ] + return radius, center, vec + + vec = [None] * (dim + 1) for i in range(dim + 1): points = normal(0, size=dim) x = fast_norm(points) @@ -82,7 +93,7 @@ def generate_random_sphere_points(dim, radius=0): return radius, center, vec - for dim in range(2, 10): + for dim in range(1, 10): radius, center, points = generate_random_sphere_points(dim) circ_center, circ_radius = circumsphere(points) err_msg = "" @@ -94,3 +105,32 @@ def generate_random_sphere_points(dim, radius=0): ) if err_msg: raise AssertionError(err_msg) + + +@pytest.mark.parametrize( + ("vertices", "expected"), + [ + ([(0.0,), (1.0,)], 1.0), + ([(0.0,), (3.0,)], 3.0), + ([(0.0, 0.0), (3.0, 4.0)], 5.0), + ([(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)], np.sqrt(3)), + ], +) +def test_simplex_volume_in_embedding_1d(vertices, expected): + """Test simplex_volume_in_embedding for 1-simplices (line segments).""" + from adaptive.learner.triangulation import simplex_volume_in_embedding + + assert np.isclose(simplex_volume_in_embedding(vertices), expected) + + +@pytest.mark.parametrize( + ("simplex", "expected_neighbors"), + [ + ((0, 1), {(1, 2)}), + ((1, 2), {(0, 1)}), + ], +) +def test_1d_triangulation_find_neighbors(simplex, expected_neighbors): + """Test finding neighbors in 1D.""" + tri = Triangulation([(0.0,), (1.0,), (2.0,)]) + assert tri.get_simplices_attached_to_points(simplex) == expected_neighbors diff --git a/adaptive/utils.py b/adaptive/utils.py index 2a1680cac..05b7f5a73 100644 --- a/adaptive/utils.py +++ b/adaptive/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import concurrent.futures as concurrent +import copy import functools import gzip import inspect @@ -27,7 +28,7 @@ def named_product(**items: Sequence[Any]): @contextmanager def restore(*learners) -> Iterator[None]: - states = [learner.__getstate__() for learner in learners] + states = [copy.deepcopy(learner.__getstate__()) for learner in learners] try: yield finally: