diff --git a/adaptive/learner/learnerND.py b/adaptive/learner/learnerND.py index 33bbbbb07..1e3e2f0d5 100644 --- a/adaptive/learner/learnerND.py +++ b/adaptive/learner/learnerND.py @@ -376,6 +376,8 @@ def __init__(self, func, bounds, loss_per_simplex=None): # been returned has not been deleted. This checking is done by # _pop_highest_existing_simplex self._simplex_queue = SortedKeyList(key=_simplex_evaluation_priority) + self._next_bound_idx = 0 + self._bound_match_tol = 1e-10 def new(self) -> LearnerND: """Create a new learner with the same function and bounds.""" @@ -488,6 +490,7 @@ def load_dataframe( # type: ignore[override] self.function = partial_function_from_dataframe( self.function, df, function_prefix ) + self._next_bound_idx = 0 @property def bounds_are_done(self): @@ -555,6 +558,29 @@ def _simplex_exists(self, simplex): simplex = tuple(sorted(simplex)) return simplex in self.tri.simplices + def _is_known_point(self, point): + point = tuple(map(float, point)) + if point in self.data or point in self.pending_points: + return True + + tolerances = [ + max(self._bound_match_tol, self._bound_match_tol * (hi - lo)) + for lo, hi in self._bbox + ] + + def _close(other): + return all( + abs(a - b) <= tol for (a, b, tol) in zip(point, other, tolerances) + ) + + for existing in self.data.keys(): + if _close(existing): + return True + for existing in self.pending_points: + if _close(existing): + return True + return False + def inside_bounds(self, point): """Check whether a point is inside the bounds.""" if self._interior is not None: @@ -602,7 +628,12 @@ def _try_adding_pending_point_to_simplex(self, point, simplex): self._subtriangulations[simplex] = Triangulation(vertices) self._pending_to_simplex[point] = simplex - return self._subtriangulations[simplex].add_point(point) + try: + return self._subtriangulations[simplex].add_point(point) + except ValueError as exc: + if str(exc) == "Point already in triangulation.": + self._pending_to_simplex.pop(point, None) + raise def _update_subsimplex_losses(self, simplex, new_subsimplices): loss = self._losses[simplex] @@ -627,13 +658,17 @@ def ask(self, n, tell_pending=True): def _ask_bound_point(self): # get the next bound point that is still available - new_point = next( - p - for p in self._bounds_points - if p not in self.data and p not in self.pending_points - ) - self.tell_pending(new_point) - return new_point, np.inf + while self._next_bound_idx < len(self._bounds_points): + new_point = self._bounds_points[self._next_bound_idx] + self._next_bound_idx += 1 + + if self._is_known_point(new_point): + continue + + self.tell_pending(new_point) + return new_point, np.inf + + raise StopIteration def _ask_point_without_known_simplices(self): assert not self._bounds_available @@ -699,8 +734,8 @@ def _ask_best_point(self): @property def _bounds_available(self): return any( - (p not in self.pending_points and p not in self.data) - for p in self._bounds_points + not self._is_known_point(p) + for p in self._bounds_points[self._next_bound_idx :] ) def _ask(self): diff --git a/adaptive/tests/unit/test_learnernd_integration.py b/adaptive/tests/unit/test_learnernd_integration.py index 939108377..fe7c64919 100644 --- a/adaptive/tests/unit/test_learnernd_integration.py +++ b/adaptive/tests/unit/test_learnernd_integration.py @@ -1,8 +1,11 @@ import math +import numpy as np import pytest +from scipy.spatial import ConvexHull from adaptive.learner import LearnerND +from adaptive.learner.learner1D import with_pandas from adaptive.learner.learnerND import curvature_loss_function from adaptive.runner import BlockingRunner from adaptive.runner import simple as SimpleRunner @@ -53,3 +56,76 @@ 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 + + +@pytest.mark.skipif(not with_pandas, reason="pandas is not installed") +def test_learnerND_resume_after_loading_dataframe_convex_hull(monkeypatch): + from types import MethodType + + import pandas + + hull_points = [ + (4.375872112626925, 8.917730007820797), + (4.236547993389047, 6.458941130666561), + (6.027633760716439, 5.448831829968968), + (9.636627605010293, 3.8344151882577773), + ] + + data_points = [ + (4.375872112626925, 8.917730007820797), + (4.236547993389047, 6.458941130666561), + (6.027633760716439, 5.448831829968968), + (9.636627605086398, 3.834415188269945), + (0.7103605819788694, 0.8712929970154071), + (0.2021839744032572, 8.32619845547938), + (7.781567509498505, 8.700121482468191), + ] + + df = pandas.DataFrame(data_points, columns=["x", "y"]) + df["value"] = df["x"] + df["y"] + + hull = ConvexHull(hull_points) + + def some_f(xy): + return xy[0] + xy[1] + + learner_old = LearnerND(some_f, hull) + learner_old.load_dataframe( + df, + with_default_function_args=False, + point_names=("x", "y"), + value_name="value", + ) + + def old_ask_bound_point(self): + new_point = next( + p + for p in self._bounds_points + if p not in self.data and p not in self.pending_points + ) + self.tell_pending(new_point) + return new_point, np.inf + + learner_old._ask_bound_point = MethodType(old_ask_bound_point, learner_old) + + def naive_is_known_point(self, point): + point = tuple(map(float, point)) + return point in self.data or point in self.pending_points + + learner_old._is_known_point = MethodType(naive_is_known_point, learner_old) + learner_old._bound_match_tol = 0.0 + + with pytest.raises(ValueError): + BlockingRunner(learner_old, npoints_goal=len(df) + 1) + + learner = LearnerND(some_f, hull) + learner.load_dataframe( + df, + with_default_function_args=False, + point_names=("x", "y"), + value_name="value", + ) + + target = len(df) + 1 + BlockingRunner(learner, npoints_goal=target) + assert learner.npoints >= target