From 7a2c2c690068e05a8a7fbece36b156d7d3fd4a5a Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 5 Apr 2026 01:29:35 -0700 Subject: [PATCH 01/12] Add 1D support to LearnerND and Triangulation Remove the dim==1 guard in Triangulation and add 1D initialization that sorts points and creates interval simplices. Fix simplex_volume_in_embedding for line segments, add interp1d-based interpolation for 1D LearnerND, and add 1D plot support. Also fix DataSaver's _to_key to handle LearnerND's tuple keys for 1D points, and fix positional indexing in load_dataframe (iloc). Includes comprehensive tests: 8 dedicated 1D triangulation tests, 6 unit LearnerND tests, 4 integration tests, dim=1 added to 8 existing parametrized tests, and a 1D function registered for cross-learner tests. --- REPORT.md | 90 ++++++++++++++ adaptive/learner/data_saver.py | 17 ++- adaptive/learner/learnerND.py | 25 +++- adaptive/learner/triangulation.py | 23 ++-- adaptive/tests/test_learnernd.py | 15 +++ adaptive/tests/test_learners.py | 7 ++ adaptive/tests/test_triangulation.py | 115 +++++++++++++++--- adaptive/tests/unit/test_learnernd.py | 76 ++++++++++++ .../tests/unit/test_learnernd_integration.py | 38 ++++++ adaptive/tests/unit/test_triangulation.py | 37 +++++- 10 files changed, 414 insertions(+), 29 deletions(-) create mode 100644 REPORT.md diff --git a/REPORT.md b/REPORT.md new file mode 100644 index 000000000..1f07129a0 --- /dev/null +++ b/REPORT.md @@ -0,0 +1,90 @@ +# LearnerND 1D Support — Python Implementation Report + +## Summary + +Added 1D support to `LearnerND` and the `Triangulation` class, allowing `LearnerND` to learn functions `f: R -> R^M` using the same adaptive triangulation infrastructure used for higher dimensions. + +## Changes Made + +### 1. `adaptive/learner/triangulation.py` + +- **Removed `dim == 1` guard** (was at line 329): The `Triangulation.__init__` no longer raises `ValueError` for 1D points. + +- **Added 1D initialization branch**: For `dim == 1`, sorts points by coordinate and creates interval simplices (pairs of consecutive sorted indices) instead of using `scipy.spatial.Delaunay` (which doesn't support 1D). + +- **Fixed `simplex_volume_in_embedding`**: Added a `len(vertices) == 2` check before the Heron formula branch. For line segments (1-simplices), returns the Euclidean distance between the two vertices. This works correctly for line segments embedded in any dimension. + +### 2. `adaptive/learner/learnerND.py` + +- **Fixed `_ip()` for `ndim == 1`**: Uses `scipy.interpolate.interp1d` instead of `LinearNDInterpolator` (which requires ≥2 dimensions). Points are sorted before interpolation, with `bounds_error=False, fill_value=np.nan`. + +- **Added 1D `plot()` support**: Before the `ndim != 2` guard, added a branch for `ndim == 1` that creates a holoviews `Path` for the interpolated curve and `Scatter` for known data points. + +- **Updated error message**: Changed "Only 2D plots are implemented" to "Only 1D and 2D plots are implemented". + +### 3. `adaptive/learner/data_saver.py` + +- **Fixed `_to_key` for 1D tuple keys**: Added `use_tuple` parameter to handle LearnerND's tuple key convention (`(-1.0,)`) vs Learner1D's scalar keys (`-1.0`) when converting DataFrame rows back to dict keys. + +- **Fixed `load_dataframe` indexing**: Changed `x[-1]` and `x[:-1]` to `x.iloc[-1]` and `x.iloc[:-1]` for correct positional indexing on pandas Series with string column labels. + +### 4. Test Changes + +#### `adaptive/tests/test_triangulation.py` +- Renamed `test_triangulation_raises_exception_for_1d_points` → `test_triangulation_supports_1d_points` (verifies 1D works instead of raising) +- Added `with_dimension_incl_1d` fixture (`[1, 2, 3, 4]`) for tests that are compatible with 1D +- Updated 8 parametrized tests to include `dim=1` +- Added 8 dedicated 1D tests: basic, multiple points, unsorted, add inside/outside left/right, locate point, opposing vertices + +#### `adaptive/tests/unit/test_triangulation.py` +- Extended `test_circumsphere` range to start from `dim=1` +- Added `test_simplex_volume_in_embedding_1d` (1D, 2D, 3D embeddings) +- Added `test_1d_triangulation_find_simplices` and `test_1d_triangulation_find_neighbors` + +#### `adaptive/tests/unit/test_learnernd.py` +- Added 6 tests: construction, tell/ask cycle, all loss functions, curvature loss, interpolation + +#### `adaptive/tests/unit/test_learnernd_integration.py` +- Added 4 tests: run to N points (simple + blocking), curvature loss, loss-decreases integration test + +#### `adaptive/tests/test_learnernd.py` +- Added 2 tests: basic 1D, 1D with loss goal + +#### `adaptive/tests/test_learners.py` +- Registered `peak_1d` function with `@learn_with(LearnerND, bounds=((-1, 1),))` for cross-learner tests + +## Why No Other Changes Were Needed + +The existing `Triangulation` methods (add_point, bowyer_watson, _extend_hull, circumsphere, orientation, volume, hull, faces, etc.) all work correctly for 1D without modification because: + +- **circumsphere**: The general N-dim formula correctly computes midpoint and half-length for 1D +- **point_in_simplex**: The linear algebra approach (`solve(vectors.T, ...)`) handles 1×1 systems +- **orientation**: `slogdet` of a 1×1 matrix correctly returns the sign +- **bowyer_watson**: Circumcircle-based point insertion naturally handles interval subdivision +- **hull**: Face counting correctly identifies endpoints of the 1D triangulation +- **volume**: `det` of a 1×1 matrix returns the interval length + +## Test Results + +All tests pass: + +``` +adaptive/tests/test_triangulation.py — 68 passed +adaptive/tests/unit/test_triangulation.py — 9 passed +adaptive/tests/unit/test_learnernd.py — 9 passed +adaptive/tests/unit/test_learnernd_integration.py — 21 passed +adaptive/tests/test_learnernd.py — 5 passed +adaptive/tests/test_learners.py (LearnerND) — 52 passed + Total: 164 passed +``` + +All existing 2D/3D/4D tests continue to pass unchanged. + +## Verified Functionality + +- All loss functions work for 1D: `default_loss`, `uniform_loss`, `std_loss`, `curvature_loss_function()` +- Interpolation produces correct values +- Loss decreases as more points are added +- `BlockingRunner` and `simple` runner both work +- DataFrame serialization/deserialization works (including DataSaver) +- Balancing learner works with 1D LearnerND diff --git a/adaptive/learner/data_saver.py b/adaptive/learner/data_saver.py index 2deafe2cb..732265fef 100644 --- a/adaptive/learner/data_saver.py +++ b/adaptive/learner/data_saver.py @@ -17,8 +17,10 @@ with_pandas = False -def _to_key(x): - return tuple(x.values) if x.values.size > 1 else x.item() +def _to_key(x, use_tuple=False): + if x.values.size > 1 or use_tuple: + return tuple(x.values) + return x.item() class DataSaver(BaseLearner): @@ -109,8 +111,11 @@ def to_dataframe( # type: ignore[override] **kwargs, ) + # Detect if the learner uses tuple keys even for single inputs (e.g., LearnerND 1D) + use_tuple = self.extra_data and isinstance(next(iter(self.extra_data)), tuple) df[extra_data_name] = [ - self.extra_data[_to_key(x)] for _, x in df[df.attrs["inputs"]].iterrows() + self.extra_data[_to_key(x, use_tuple=use_tuple)] + for _, x in df[df.attrs["inputs"]].iterrows() ] return df @@ -147,9 +152,11 @@ def load_dataframe( # type: ignore[override] **kwargs, ) keys = df.attrs.get("inputs", list(input_names)) + # Detect if the learner uses tuple keys even for single inputs + use_tuple = self.data and isinstance(next(iter(self.data)), tuple) for _, x in df[keys + [extra_data_name]].iterrows(): - key = _to_key(x[:-1]) - self.extra_data[key] = x[-1] + key = _to_key(x.iloc[:-1], use_tuple=use_tuple) + self.extra_data[key] = x.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..5ee959683 100644 --- a/adaptive/learner/learnerND.py +++ b/adaptive/learner/learnerND.py @@ -497,6 +497,15 @@ def _ip(self): """A `scipy.interpolate.LinearNDInterpolator` instance containing the learner's data.""" # XXX: take our own triangulation into account when generating the _ip + if self.ndim == 1: + points = self.points.ravel() + sorted_idx = np.argsort(points) + return interpolate.interp1d( + points[sorted_idx], + self.values[sorted_idx], + bounds_error=False, + fill_value=np.nan, + ) return interpolate.LinearNDInterpolator(self.points, self.values) @property @@ -895,9 +904,23 @@ def plot(self, n=None, tri_alpha=0): raise NotImplementedError( "holoviews currently does not support", "3D surface plots in bokeh." ) + if self.ndim == 1: + if len(self.data) >= 2: + (x,) = self._bbox + n = n or 201 + xs = np.linspace(x[0], x[1], n) + ys = self._ip()(xs) + path = hv.Path((xs, ys)) + points = np.array(sorted(self.data.items())) + scatter = hv.Scatter(points) + else: + path = hv.Path([]) + scatter = hv.Scatter([]) + return path * scatter.opts(size=5) + 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..0ad18f721 100644 --- a/adaptive/learner/triangulation.py +++ b/adaptive/learner/triangulation.py @@ -257,6 +257,10 @@ 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) + if len(vertices) == 2: + # 1-simplex (line segment): volume is the Euclidean distance + return float(norm(vertices[1] - vertices[0])) + dim = len(vertices[0]) if dim == 2: # Heron's formula @@ -326,9 +330,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,11 +345,17 @@ 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: - self.add_simplex(simplex) + if dim == 1: + # For 1D, sort points and create intervals as simplices + sorted_indices = sorted(range(len(coords)), key=lambda i: coords[i]) + for i in range(len(sorted_indices) - 1): + self.add_simplex((sorted_indices[i], sorted_indices[i + 1])) + else: + # 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: + self.add_simplex(simplex) def delete_simplex(self, simplex): simplex = tuple(sorted(simplex)) diff --git a/adaptive/tests/test_learnernd.py b/adaptive/tests/test_learnernd.py index 0884b7eeb..6a2766f66 100644 --- a/adaptive/tests/test_learnernd.py +++ b/adaptive/tests/test_learnernd.py @@ -48,3 +48,18 @@ 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) + + +def test_learnerND_1d_basic(): + """Test LearnerND works with 1D bounds.""" + learner = LearnerND(lambda x: x[0] ** 2, bounds=[(-1, 1)]) + simple(learner, npoints_goal=10) + assert learner.npoints == 10 + assert learner.loss() < float("inf") + + +def test_learnerND_1d_with_loss_goal(): + """Test LearnerND 1D converges with a loss goal.""" + learner = LearnerND(lambda x: x[0] ** 2, bounds=[(-1, 1)]) + simple(learner, loss_goal=0.1) + assert learner.loss() <= 0.1 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..56e46616d 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,14 @@ 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 +def test_triangulation_supports_1d_points(): pts = [(0,), (1,)] - with pytest.raises(ValueError): - Triangulation(pts) + t = Triangulation(pts) + assert t.simplices == {(0, 1)} + assert t.hull == {0, 1} -@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 +132,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 +166,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 +212,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 +230,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 +279,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 +289,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 +297,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 +317,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 +331,90 @@ def test_initialisation_accepts_more_than_one_simplex(dim): _check_triangulation_is_valid(tri) assert tri.simplices == {simplex1, simplex2} + + +# ---- 1D-specific triangulation tests ---- + + +def test_1d_triangulation_basic(): + """Test basic 1D triangulation with two points.""" + t = Triangulation([(0.0,), (1.0,)]) + assert t.simplices == {(0, 1)} + assert t.hull == {0, 1} + assert t.volume((0, 1)) == 1.0 + _check_triangulation_is_valid(t) + + +def test_1d_triangulation_multiple_points(): + """Test 1D triangulation with multiple initial points.""" + pts = [(0.0,), (0.5,), (1.0,)] + t = Triangulation(pts) + # Points 0=(0.0,), 1=(0.5,), 2=(1.0,) → sorted: 0, 1, 2 + assert len(t.simplices) == 2 + assert t.hull == {0, 2} # endpoints + _check_triangulation_is_valid(t) + assert np.isclose(sum(t.volumes()), 1.0) + + +def test_1d_triangulation_unsorted_points(): + """Test that 1D triangulation handles unsorted initial points.""" + pts = [(1.0,), (0.0,), (0.5,)] + t = Triangulation(pts) + assert len(t.simplices) == 2 + _check_triangulation_is_valid(t) + assert np.isclose(sum(t.volumes()), 1.0) + + +def test_1d_add_point_inside(): + """Test adding a point inside a 1D interval.""" + t = Triangulation([(0.0,), (1.0,)]) + _add_point_with_check(t, (0.5,)) + assert len(t.simplices) == 2 + assert t.hull == {0, 1} # original endpoints are still hull + _check_triangulation_is_valid(t) + assert np.isclose(sum(t.volumes()), 1.0) + + +def test_1d_add_point_outside_right(): + """Test adding a point to the right of a 1D triangulation.""" + t = Triangulation([(0.0,), (1.0,)]) + _add_point_with_check(t, (2.0,)) + assert t.simplices == {(0, 1), (1, 2)} + assert t.hull == {0, 2} + _check_triangulation_is_valid(t) + + +def test_1d_add_point_outside_left(): + """Test adding a point to the left of a 1D triangulation.""" + t = Triangulation([(0.0,), (1.0,)]) + _add_point_with_check(t, (-1.0,)) + assert t.simplices == {(0, 1), (0, 2)} + assert t.hull == {1, 2} + _check_triangulation_is_valid(t) + + +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 == () + + +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..4072994cb 100644 --- a/adaptive/tests/unit/test_learnernd.py +++ b/adaptive/tests/unit/test_learnernd.py @@ -42,3 +42,79 @@ 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 ---- + + +def f_1d(x): + """Simple 1D test function.""" + return x[0] ** 2 + + +def test_learnerND_1d_construction(): + """Test that LearnerND can be constructed with 1D bounds.""" + learner = LearnerND(f_1d, bounds=[(-1, 1)]) + assert learner.ndim == 1 + assert learner._bounds_points == [(-1,), (1,)] + assert learner._bbox == ((-1.0, 1.0),) + + +def test_learnerND_1d_tell_ask(): + """Test basic tell/ask cycle for 1D LearnerND.""" + learner = LearnerND(f_1d, bounds=[(-1, 1)]) + # Ask for bound points first + points, losses = learner.ask(2) + assert len(points) == 2 + # Tell the boundary values + for p in points: + learner.tell(p, f_1d(p)) + # Now we should have a triangulation + assert learner.tri is not None + # Ask for more points + points2, losses2 = learner.ask(3) + assert len(points2) == 3 + + +def test_learnerND_1d_loss_functions(): + """Test that all standard loss functions work for 1D.""" + from adaptive.learner.learnerND import ( + default_loss, + std_loss, + uniform_loss, + ) + + for loss_fn in [default_loss, uniform_loss, std_loss]: + learner = LearnerND(f_1d, bounds=[(-1, 1)], loss_per_simplex=loss_fn) + points, _ = learner.ask(2) + for p in points: + learner.tell(p, f_1d(p)) + points2, losses2 = learner.ask(3) + assert len(points2) == 3 + assert all(l > 0 for l in losses2) + + +def test_learnerND_1d_curvature_loss(): + """Test that curvature loss function works for 1D.""" + loss = curvature_loss_function() + learner = LearnerND(f_1d, bounds=[(-1, 1)], loss_per_simplex=loss) + assert learner.nth_neighbors == 1 + points, _ = learner.ask(2) + for p in points: + learner.tell(p, f_1d(p)) + points2, _ = learner.ask(3) + assert len(points2) == 3 + + +def test_learnerND_1d_interpolation(): + """Test that 1D interpolation works correctly.""" + learner = LearnerND(f_1d, bounds=[(-1, 1)]) + # Tell some points + for x in [-1.0, -0.5, 0.0, 0.5, 1.0]: + learner.tell((x,), x**2) + ip = learner._ip() + # Check interpolation at known points + assert np.isclose(ip(0.0), 0.0) + assert np.isclose(ip(1.0), 1.0) + # Check interpolation at midpoint (linear interpolation) + assert np.isclose(ip(0.25), 0.125) # linear between 0 and 0.5 diff --git a/adaptive/tests/unit/test_learnernd_integration.py b/adaptive/tests/unit/test_learnernd_integration.py index 939108377..bfa5b116e 100644 --- a/adaptive/tests/unit/test_learnernd_integration.py +++ b/adaptive/tests/unit/test_learnernd_integration.py @@ -53,3 +53,41 @@ 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 + + +# ---- 1D-specific integration tests ---- + + +def f_1d(x): + return math.sin(x[0] * 5) + + +def test_learnerND_1d_runs_to_10_points(): + learner = LearnerND(f_1d, bounds=[(-1, 1)]) + SimpleRunner(learner, npoints_goal=10) + assert learner.npoints == 10 + + +@pytest.mark.parametrize("execution_number", range(5)) +def test_learnerND_1d_runs_to_10_points_Blocking(execution_number): + learner = LearnerND(f_1d, bounds=[(-1, 1)]) + BlockingRunner(learner, npoints_goal=10) + assert learner.npoints >= 10 + + +def test_learnerND_1d_curvature_runs_to_10_points(): + loss = curvature_loss_function() + learner = LearnerND(f_1d, bounds=[(-1, 1)], loss_per_simplex=loss) + SimpleRunner(learner, npoints_goal=10) + assert learner.npoints == 10 + + +def test_learnerND_1d_loss_decreases(): + """Test that loss decreases as more points are added.""" + learner = LearnerND(f_1d, bounds=[(-1, 1)]) + 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..9715d9b2a 100644 --- a/adaptive/tests/unit/test_triangulation.py +++ b/adaptive/tests/unit/test_triangulation.py @@ -82,7 +82,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 +94,38 @@ def generate_random_sphere_points(dim, radius=0): ) if err_msg: raise AssertionError(err_msg) + + +def test_simplex_volume_in_embedding_1d(): + """Test simplex_volume_in_embedding for 1-simplices (line segments).""" + from adaptive.learner.triangulation import simplex_volume_in_embedding + + # 1D line segment + assert np.isclose(simplex_volume_in_embedding([(0.0,), (1.0,)]), 1.0) + assert np.isclose(simplex_volume_in_embedding([(0.0,), (3.0,)]), 3.0) + + # Line segment in 2D embedding + assert np.isclose(simplex_volume_in_embedding([(0.0, 0.0), (3.0, 4.0)]), 5.0) + + # Line segment in 3D embedding + assert np.isclose( + simplex_volume_in_embedding([(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)]), + np.sqrt(3), + ) + + +def test_1d_triangulation_find_simplices(): + """Test that 1D triangulation correctly identifies simplices.""" + pts = [(0.0,), (1.0,), (2.0,), (3.0,)] + tri = Triangulation(pts) + # Sorted: 0, 1, 2, 3 → intervals: (0,1), (1,2), (2,3) + assert tri.simplices == {(0, 1), (1, 2), (2, 3)} + + +def test_1d_triangulation_find_neighbors(): + """Test finding neighbors in 1D.""" + pts = [(0.0,), (1.0,), (2.0,)] + tri = Triangulation(pts) + # Simplices: (0,1) and (1,2) + assert tri.get_simplices_attached_to_points((0, 1)) == {(1, 2)} + assert tri.get_simplices_attached_to_points((1, 2)) == {(0, 1)} From 7d2f750f718a1dd8fc3e26ba5c1747985cac3c39 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 5 Apr 2026 01:57:22 -0700 Subject: [PATCH 02/12] fix: address review findings for 1D LearnerND support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Fix 1D plot() crash: flatten ((x,), y) tuples to (x, y) for np.array compatibility in Scatter data. 2. Fix 1D _ip() for vector outputs: pass axis=0 to interp1d so R^1 -> R^M functions interpolate correctly. 3. Fix duplicate 1D coordinates: skip adjacent duplicates after sorting to avoid degenerate zero-volume simplices. 4. Fix flaky test_circumsphere for dim=1: special-case dim==1 to generate center ± radius deterministically instead of Gaussian sampling that can place both points on the same side. Adds regression tests for all four issues. --- REPORT.md | 42 ++++++++++++++++++----- adaptive/learner/learnerND.py | 3 +- adaptive/learner/triangulation.py | 5 ++- adaptive/tests/test_triangulation.py | 14 ++++++++ adaptive/tests/unit/test_learnernd.py | 28 +++++++++++++++ adaptive/tests/unit/test_triangulation.py | 13 ++++++- 6 files changed, 94 insertions(+), 11 deletions(-) diff --git a/REPORT.md b/REPORT.md index 1f07129a0..aa6253eb1 100644 --- a/REPORT.md +++ b/REPORT.md @@ -53,6 +53,29 @@ Added 1D support to `LearnerND` and the `Triangulation` class, allowing `Learner #### `adaptive/tests/test_learners.py` - Registered `peak_1d` function with `@learn_with(LearnerND, bounds=((-1, 1),))` for cross-learner tests +## Review Round 1 — Fixes + +### Fix 1 (HIGH): 1D `plot()` crash +`np.array(sorted(self.data.items()))` failed because items are `((x,), y)` tuples +that can't be coerced into a homogeneous array. Fixed by flattening: +`[(x[0], y) for x, y in sorted(self.data.items())]`. +Regression test: `test_learnerND_1d_plot`. + +### Fix 2 (MEDIUM): 1D `_ip()` fails for vector outputs +`interp1d` defaults to `axis=-1`, which causes a length mismatch when values +have shape `(npoints, vdim)`. Fixed by passing `axis=0`. +Regression test: `test_learnerND_1d_vector_output_interpolation`. + +### Fix 3 (MEDIUM): Duplicate 1D coordinates unchecked +`Triangulation([(0.0,), (1.0,), (1.0,)])` created a degenerate zero-volume +simplex. Fixed by skipping adjacent duplicates after sorting in the 1D init. +Regression test: `test_1d_duplicate_coordinates_skipped`. + +### Fix 4 (MEDIUM): Flaky `test_circumsphere` for dim=1 +Gaussian sampling could place both boundary points on the same side of center, +producing `inf`. Fixed by special-casing `dim == 1` to generate `center ± radius` +deterministically. Verified stable over 20 consecutive runs. + ## Why No Other Changes Were Needed The existing `Triangulation` methods (add_point, bowyer_watson, _extend_hull, circumsphere, orientation, volume, hull, faces, etc.) all work correctly for 1D without modification because: @@ -69,13 +92,13 @@ The existing `Triangulation` methods (add_point, bowyer_watson, _extend_hull, ci All tests pass: ``` -adaptive/tests/test_triangulation.py — 68 passed -adaptive/tests/unit/test_triangulation.py — 9 passed -adaptive/tests/unit/test_learnernd.py — 9 passed -adaptive/tests/unit/test_learnernd_integration.py — 21 passed -adaptive/tests/test_learnernd.py — 5 passed -adaptive/tests/test_learners.py (LearnerND) — 52 passed - Total: 164 passed +adaptive/tests/test_triangulation.py — 69 passed +adaptive/tests/unit/test_triangulation.py — 11 passed +adaptive/tests/unit/test_learnernd.py — 11 passed +adaptive/tests/unit/test_learnernd_integration.py — 21 passed +adaptive/tests/test_learnernd.py — 5 passed +adaptive/tests/test_learners.py — 153 passed + Total: 270 passed ``` All existing 2D/3D/4D tests continue to pass unchanged. @@ -83,8 +106,11 @@ All existing 2D/3D/4D tests continue to pass unchanged. ## Verified Functionality - All loss functions work for 1D: `default_loss`, `uniform_loss`, `std_loss`, `curvature_loss_function()` -- Interpolation produces correct values +- Interpolation produces correct values (scalar and vector outputs) +- 1D `plot()` renders without crash - Loss decreases as more points are added - `BlockingRunner` and `simple` runner both work - DataFrame serialization/deserialization works (including DataSaver) - Balancing learner works with 1D LearnerND +- Duplicate 1D coordinates are handled gracefully +- `circumsphere` is deterministic for dim=1 diff --git a/adaptive/learner/learnerND.py b/adaptive/learner/learnerND.py index 5ee959683..cdf76889b 100644 --- a/adaptive/learner/learnerND.py +++ b/adaptive/learner/learnerND.py @@ -503,6 +503,7 @@ def _ip(self): return interpolate.interp1d( points[sorted_idx], self.values[sorted_idx], + axis=0, bounds_error=False, fill_value=np.nan, ) @@ -911,7 +912,7 @@ def plot(self, n=None, tri_alpha=0): xs = np.linspace(x[0], x[1], n) ys = self._ip()(xs) path = hv.Path((xs, ys)) - points = np.array(sorted(self.data.items())) + points = [(x[0], y) for x, y in sorted(self.data.items())] scatter = hv.Scatter(points) else: path = hv.Path([]) diff --git a/adaptive/learner/triangulation.py b/adaptive/learner/triangulation.py index 0ad18f721..6faf67800 100644 --- a/adaptive/learner/triangulation.py +++ b/adaptive/learner/triangulation.py @@ -346,9 +346,12 @@ def __init__(self, coords): self.vertex_to_simplices = [set() for _ in coords] if dim == 1: - # For 1D, sort points and create intervals as simplices + # For 1D, sort points and create intervals as simplices, + # skipping adjacent duplicates to avoid degenerate zero-volume simplices sorted_indices = sorted(range(len(coords)), key=lambda i: coords[i]) for i in range(len(sorted_indices) - 1): + if coords[sorted_indices[i]] == coords[sorted_indices[i + 1]]: + continue self.add_simplex((sorted_indices[i], sorted_indices[i + 1])) else: # find a Delaunay triangulation to start with, then we will throw it diff --git a/adaptive/tests/test_triangulation.py b/adaptive/tests/test_triangulation.py index 56e46616d..dbdb8f71b 100644 --- a/adaptive/tests/test_triangulation.py +++ b/adaptive/tests/test_triangulation.py @@ -407,6 +407,20 @@ def test_1d_locate_point(): assert simplex == () +def test_1d_duplicate_coordinates_skipped(): + """Test that duplicate 1D coordinates don't create degenerate simplices.""" + t = Triangulation([(0.0,), (1.0,), (1.0,)]) + # The duplicate (1.0,) should be skipped, leaving only one simplex + assert t.simplices == {(0, 1)} + assert all(v > 0 for v in t.volumes()) + _check_triangulation_is_valid(t) + + # Multiple duplicates + t2 = Triangulation([(0.0,), (0.0,), (0.5,), (1.0,), (1.0,)]) + assert all(v > 0 for v in t2.volumes()) + _check_triangulation_is_valid(t2) + + def test_1d_opposing_vertices(): """Test opposing vertices in 1D.""" pts = [(0.0,), (0.5,), (1.0,)] diff --git a/adaptive/tests/unit/test_learnernd.py b/adaptive/tests/unit/test_learnernd.py index 4072994cb..2f06b1100 100644 --- a/adaptive/tests/unit/test_learnernd.py +++ b/adaptive/tests/unit/test_learnernd.py @@ -118,3 +118,31 @@ def test_learnerND_1d_interpolation(): assert np.isclose(ip(1.0), 1.0) # Check interpolation at midpoint (linear interpolation) 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 = LearnerND(f_vec, bounds=[(-1, 1)]) + for x in [-1.0, -0.5, 0.0, 0.5, 1.0]: + learner.tell((x,), f_vec((x,))) + 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_1d_plot(): + """Test that 1D plot() does not crash.""" + import holoviews as hv + + hv.extension("bokeh") + learner = LearnerND(f_1d, bounds=[(-1, 1)]) + for x in [-1.0, -0.5, 0.0, 0.5, 1.0]: + learner.tell((x,), x**2) + plot = learner.plot() + assert plot is not None diff --git a/adaptive/tests/unit/test_triangulation.py b/adaptive/tests/unit/test_triangulation.py index 9715d9b2a..b536f6e9b 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) From 85a974421daae3bd6a43d438f1674229d6624628 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 18 Apr 2026 00:23:09 -0700 Subject: [PATCH 03/12] refactor: consolidate 1D cleanup tests --- adaptive/tests/test_learnernd.py | 26 ++-- adaptive/tests/test_triangulation.py | 114 +++++++---------- adaptive/tests/unit/test_learnernd.py | 115 ++++++++++-------- .../tests/unit/test_learnernd_integration.py | 66 +++++----- adaptive/tests/unit/test_triangulation.py | 48 ++++---- 5 files changed, 180 insertions(+), 189 deletions(-) diff --git a/adaptive/tests/test_learnernd.py b/adaptive/tests/test_learnernd.py index 6a2766f66..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 @@ -50,16 +51,21 @@ def test_vector_return_with_a_flat_layer(): simple(learner, loss_goal=0.1) -def test_learnerND_1d_basic(): +@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, npoints_goal=10) - assert learner.npoints == 10 - assert learner.loss() < float("inf") - + simple(learner, **run_kwargs) -def test_learnerND_1d_with_loss_goal(): - """Test LearnerND 1D converges with a loss goal.""" - learner = LearnerND(lambda x: x[0] ** 2, bounds=[(-1, 1)]) - simple(learner, loss_goal=0.1) - assert learner.loss() <= 0.1 + 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_triangulation.py b/adaptive/tests/test_triangulation.py index dbdb8f71b..dc254328c 100644 --- a/adaptive/tests/test_triangulation.py +++ b/adaptive/tests/test_triangulation.py @@ -76,14 +76,6 @@ def test_triangulation_raises_exception_for_1d_list(): with pytest.raises(TypeError): Triangulation(pts) - -def test_triangulation_supports_1d_points(): - pts = [(0,), (1,)] - t = Triangulation(pts) - assert t.simplices == {(0, 1)} - assert t.hull == {0, 1} - - @with_dimension_incl_1d def test_triangulation_of_standard_simplex(dim): t = Triangulation(_make_standard_simplex(dim)) @@ -336,61 +328,44 @@ def test_initialisation_accepts_more_than_one_simplex(dim): # ---- 1D-specific triangulation tests ---- -def test_1d_triangulation_basic(): - """Test basic 1D triangulation with two points.""" - t = Triangulation([(0.0,), (1.0,)]) - assert t.simplices == {(0, 1)} - assert t.hull == {0, 1} - assert t.volume((0, 1)) == 1.0 - _check_triangulation_is_valid(t) - - -def test_1d_triangulation_multiple_points(): - """Test 1D triangulation with multiple initial points.""" - pts = [(0.0,), (0.5,), (1.0,)] - t = Triangulation(pts) - # Points 0=(0.0,), 1=(0.5,), 2=(1.0,) → sorted: 0, 1, 2 - assert len(t.simplices) == 2 - assert t.hull == {0, 2} # endpoints - _check_triangulation_is_valid(t) - assert np.isclose(sum(t.volumes()), 1.0) - - -def test_1d_triangulation_unsorted_points(): - """Test that 1D triangulation handles unsorted initial points.""" - pts = [(1.0,), (0.0,), (0.5,)] - t = Triangulation(pts) - assert len(t.simplices) == 2 +@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()), 1.0) - - -def test_1d_add_point_inside(): - """Test adding a point inside a 1D interval.""" - t = Triangulation([(0.0,), (1.0,)]) - _add_point_with_check(t, (0.5,)) - assert len(t.simplices) == 2 - assert t.hull == {0, 1} # original endpoints are still hull - _check_triangulation_is_valid(t) - assert np.isclose(sum(t.volumes()), 1.0) - - -def test_1d_add_point_outside_right(): - """Test adding a point to the right of a 1D triangulation.""" + 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, (2.0,)) - assert t.simplices == {(0, 1), (1, 2)} - assert t.hull == {0, 2} - _check_triangulation_is_valid(t) + _add_point_with_check(t, point) -def test_1d_add_point_outside_left(): - """Test adding a point to the left of a 1D triangulation.""" - t = Triangulation([(0.0,), (1.0,)]) - _add_point_with_check(t, (-1.0,)) - assert t.simplices == {(0, 1), (0, 2)} - assert t.hull == {1, 2} + 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(): @@ -407,18 +382,23 @@ def test_1d_locate_point(): assert simplex == () -def test_1d_duplicate_coordinates_skipped(): +@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([(0.0,), (1.0,), (1.0,)]) - # The duplicate (1.0,) should be skipped, leaving only one simplex - assert t.simplices == {(0, 1)} + 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) - - # Multiple duplicates - t2 = Triangulation([(0.0,), (0.0,), (0.5,), (1.0,), (1.0,)]) - assert all(v > 0 for v in t2.volumes()) - _check_triangulation_is_valid(t2) + assert np.isclose(sum(t.volumes()), 1.0) def test_1d_opposing_vertices(): diff --git a/adaptive/tests/unit/test_learnernd.py b/adaptive/tests/unit/test_learnernd.py index 2f06b1100..26c6e1d99 100644 --- a/adaptive/tests/unit/test_learnernd.py +++ b/adaptive/tests/unit/test_learnernd.py @@ -5,7 +5,13 @@ from scipy.spatial import ConvexHull 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): @@ -47,76 +53,87 @@ def loss(*args): # ---- 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 = LearnerND(f_1d, bounds=[(-1, 1)]) + learner = make_1d_learner() assert learner.ndim == 1 assert learner._bounds_points == [(-1,), (1,)] assert learner._bbox == ((-1.0, 1.0),) -def test_learnerND_1d_tell_ask(): +@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.""" - learner = LearnerND(f_1d, bounds=[(-1, 1)]) - # Ask for bound points first - points, losses = learner.ask(2) - assert len(points) == 2 - # Tell the boundary values - for p in points: - learner.tell(p, f_1d(p)) - # Now we should have a triangulation + kwargs = {} if loss_fn is None else {"loss_per_simplex": loss_fn} + learner = initialize_1d_learner(**kwargs) + assert learner.tri is not None - # Ask for more points + assert learner.nth_neighbors == expected_nth_neighbors + points2, losses2 = learner.ask(3) + assert len(points2) == 3 + assert all(loss > 0 for loss in losses2) -def test_learnerND_1d_loss_functions(): +@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.""" - from adaptive.learner.learnerND import ( - default_loss, - std_loss, - uniform_loss, - ) - - for loss_fn in [default_loss, uniform_loss, std_loss]: - learner = LearnerND(f_1d, bounds=[(-1, 1)], loss_per_simplex=loss_fn) - points, _ = learner.ask(2) - for p in points: - learner.tell(p, f_1d(p)) - points2, losses2 = learner.ask(3) - assert len(points2) == 3 - assert all(l > 0 for l in losses2) - - -def test_learnerND_1d_curvature_loss(): - """Test that curvature loss function works for 1D.""" - loss = curvature_loss_function() - learner = LearnerND(f_1d, bounds=[(-1, 1)], loss_per_simplex=loss) - assert learner.nth_neighbors == 1 - points, _ = learner.ask(2) - for p in points: - learner.tell(p, f_1d(p)) - points2, _ = learner.ask(3) + 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 = LearnerND(f_1d, bounds=[(-1, 1)]) - # Tell some points - for x in [-1.0, -0.5, 0.0, 0.5, 1.0]: - learner.tell((x,), x**2) + learner = make_1d_learner() + tell_1d_points(learner) ip = learner._ip() - # Check interpolation at known points + assert np.isclose(ip(0.0), 0.0) assert np.isclose(ip(1.0), 1.0) - # Check interpolation at midpoint (linear interpolation) assert np.isclose(ip(0.25), 0.125) # linear between 0 and 0.5 @@ -126,9 +143,8 @@ def test_learnerND_1d_vector_output_interpolation(): def f_vec(x): return np.array([x[0] ** 2, np.sin(x[0])]) - learner = LearnerND(f_vec, bounds=[(-1, 1)]) - for x in [-1.0, -0.5, 0.0, 0.5, 1.0]: - learner.tell((x,), f_vec((x,))) + 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,) @@ -141,8 +157,7 @@ def test_learnerND_1d_plot(): import holoviews as hv hv.extension("bokeh") - learner = LearnerND(f_1d, bounds=[(-1, 1)]) - for x in [-1.0, -0.5, 0.0, 0.5, 1.0]: - learner.tell((x,), x**2) + 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 bfa5b116e..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 @@ -55,36 +78,9 @@ def test_learnerND_log_works(): # furthermore the last two points that were asked should be in this simplex -# ---- 1D-specific integration tests ---- - - -def f_1d(x): - return math.sin(x[0] * 5) - - -def test_learnerND_1d_runs_to_10_points(): - learner = LearnerND(f_1d, bounds=[(-1, 1)]) - SimpleRunner(learner, npoints_goal=10) - assert learner.npoints == 10 - - -@pytest.mark.parametrize("execution_number", range(5)) -def test_learnerND_1d_runs_to_10_points_Blocking(execution_number): - learner = LearnerND(f_1d, bounds=[(-1, 1)]) - BlockingRunner(learner, npoints_goal=10) - assert learner.npoints >= 10 - - -def test_learnerND_1d_curvature_runs_to_10_points(): - loss = curvature_loss_function() - learner = LearnerND(f_1d, bounds=[(-1, 1)], loss_per_simplex=loss) - SimpleRunner(learner, npoints_goal=10) - assert learner.npoints == 10 - - def test_learnerND_1d_loss_decreases(): """Test that loss decreases as more points are added.""" - learner = LearnerND(f_1d, bounds=[(-1, 1)]) + learner = LearnerND(wave_1d, bounds=ONE_D_BOUNDS) SimpleRunner(learner, npoints_goal=5) loss_5 = learner.loss() assert loss_5 != float("inf") diff --git a/adaptive/tests/unit/test_triangulation.py b/adaptive/tests/unit/test_triangulation.py index b536f6e9b..f62c1d6c8 100644 --- a/adaptive/tests/unit/test_triangulation.py +++ b/adaptive/tests/unit/test_triangulation.py @@ -107,36 +107,30 @@ def generate_random_sphere_points(dim, radius=0): raise AssertionError(err_msg) -def test_simplex_volume_in_embedding_1d(): +@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 - # 1D line segment - assert np.isclose(simplex_volume_in_embedding([(0.0,), (1.0,)]), 1.0) - assert np.isclose(simplex_volume_in_embedding([(0.0,), (3.0,)]), 3.0) + assert np.isclose(simplex_volume_in_embedding(vertices), expected) - # Line segment in 2D embedding - assert np.isclose(simplex_volume_in_embedding([(0.0, 0.0), (3.0, 4.0)]), 5.0) - # Line segment in 3D embedding - assert np.isclose( - simplex_volume_in_embedding([(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)]), - np.sqrt(3), - ) - - -def test_1d_triangulation_find_simplices(): - """Test that 1D triangulation correctly identifies simplices.""" - pts = [(0.0,), (1.0,), (2.0,), (3.0,)] - tri = Triangulation(pts) - # Sorted: 0, 1, 2, 3 → intervals: (0,1), (1,2), (2,3) - assert tri.simplices == {(0, 1), (1, 2), (2, 3)} - - -def test_1d_triangulation_find_neighbors(): +@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.""" - pts = [(0.0,), (1.0,), (2.0,)] - tri = Triangulation(pts) - # Simplices: (0,1) and (1,2) - assert tri.get_simplices_attached_to_points((0, 1)) == {(1, 2)} - assert tri.get_simplices_attached_to_points((1, 2)) == {(0, 1)} + tri = Triangulation([(0.0,), (1.0,), (2.0,)]) + assert tri.get_simplices_attached_to_points(simplex) == expected_neighbors From df5bbaf31df00883f76e0966714509f8ae8dcb5e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 18 Apr 2026 00:27:34 -0700 Subject: [PATCH 04/12] refactor: clarify 1D triangulation and interpolation --- adaptive/learner/learnerND.py | 63 +++++++++++++++++-------------- adaptive/learner/triangulation.py | 41 ++++++++++---------- 2 files changed, 57 insertions(+), 47 deletions(-) diff --git a/adaptive/learner/learnerND.py b/adaptive/learner/learnerND.py index cdf76889b..cd250afec 100644 --- a/adaptive/learner/learnerND.py +++ b/adaptive/learner/learnerND.py @@ -493,21 +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 - if self.ndim == 1: - points = self.points.ravel() - sorted_idx = np.argsort(points) - return interpolate.interp1d( - points[sorted_idx], - self.values[sorted_idx], - axis=0, - bounds_error=False, - fill_value=np.nan, - ) - 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): @@ -891,34 +909,23 @@ 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: - if len(self.data) >= 2: - (x,) = self._bbox - n = n or 201 - xs = np.linspace(x[0], x[1], n) - ys = self._ip()(xs) - path = hv.Path((xs, ys)) - points = [(x[0], y) for x, y in sorted(self.data.items())] - scatter = hv.Scatter(points) - else: - path = hv.Path([]) - scatter = hv.Scatter([]) - return path * scatter.opts(size=5) + return self._plot_1d(n) + hv = ensure_holoviews() if self.ndim != 2: raise NotImplementedError( "Only 1D and 2D plots are implemented: You can " diff --git a/adaptive/learner/triangulation.py b/adaptive/learner/triangulation.py index 6faf67800..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,12 +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) - if len(vertices) == 2: - # 1-simplex (line segment): volume is the Euclidean distance + num_vertices = len(vertices) + if num_vertices == 2: + # A 1-simplex is just a line segment. return float(norm(vertices[1] - vertices[0])) - dim = len(vertices[0]) - if dim == 2: + 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) @@ -272,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: @@ -346,19 +354,14 @@ def __init__(self, coords): self.vertex_to_simplices = [set() for _ in coords] if dim == 1: - # For 1D, sort points and create intervals as simplices, - # skipping adjacent duplicates to avoid degenerate zero-volume simplices - sorted_indices = sorted(range(len(coords)), key=lambda i: coords[i]) - for i in range(len(sorted_indices) - 1): - if coords[sorted_indices[i]] == coords[sorted_indices[i + 1]]: - continue - self.add_simplex((sorted_indices[i], sorted_indices[i + 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 - initial_tri = scipy.spatial.Delaunay(coords) - for simplex in initial_tri.simplices: - self.add_simplex(simplex) + # 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): simplex = tuple(sorted(simplex)) From efbafa9db7d0dfb622bcad1a3aa33f652aa5ec19 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 18 Apr 2026 00:29:38 -0700 Subject: [PATCH 05/12] refactor: simplify DataSaver key reconstruction --- adaptive/learner/data_saver.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/adaptive/learner/data_saver.py b/adaptive/learner/data_saver.py index 732265fef..b0ac57baf 100644 --- a/adaptive/learner/data_saver.py +++ b/adaptive/learner/data_saver.py @@ -17,10 +17,14 @@ with_pandas = False -def _to_key(x, use_tuple=False): - if x.values.size > 1 or use_tuple: - return tuple(x.values) - return 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): @@ -111,10 +115,9 @@ def to_dataframe( # type: ignore[override] **kwargs, ) - # Detect if the learner uses tuple keys even for single inputs (e.g., LearnerND 1D) - use_tuple = self.extra_data and isinstance(next(iter(self.extra_data)), tuple) + force_tuple = _mapping_uses_tuple_keys(self.extra_data) df[extra_data_name] = [ - self.extra_data[_to_key(x, use_tuple=use_tuple)] + self.extra_data[_row_to_key(x, force_tuple=force_tuple)] for _, x in df[df.attrs["inputs"]].iterrows() ] return df @@ -151,12 +154,11 @@ def load_dataframe( # type: ignore[override] function_prefix=function_prefix, **kwargs, ) - keys = df.attrs.get("inputs", list(input_names)) - # Detect if the learner uses tuple keys even for single inputs - use_tuple = self.data and isinstance(next(iter(self.data)), tuple) - for _, x in df[keys + [extra_data_name]].iterrows(): - key = _to_key(x.iloc[:-1], use_tuple=use_tuple) - self.extra_data[key] = x.iloc[-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 From 027332e1d4a901a5786e206a53c1454163d3151b Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 18 Apr 2026 00:31:46 -0700 Subject: [PATCH 06/12] docs: summarize issue-095 cleanup --- CLEANUP.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 CLEANUP.md diff --git a/CLEANUP.md b/CLEANUP.md new file mode 100644 index 000000000..28340605c --- /dev/null +++ b/CLEANUP.md @@ -0,0 +1,48 @@ +# ISSUE-095 cleanup summary + +This branch keeps the 1D `LearnerND` behavior from `issue-095-impl-py` and only +refactors the implementation and tests for clarity. + +## What changed + +### `refactor: consolidate 1D cleanup tests` + +- Folded repeated 1D triangulation cases into parametrized tests. +- Reused small helpers in the new 1D `LearnerND` unit tests instead of repeating + the same setup and tell/ask flow. +- Consolidated the 1D and 2D integration smoke tests so the shared runner + behavior is checked in one place. + +Why: the original 1D test additions covered the right behavior but repeated a +lot of setup and assertions, which made the cleanup surface harder to review. + +### `refactor: clarify 1D triangulation and interpolation` + +- Extracted `_flat_simplices()` so the 1D triangulation initialisation reads as + one explicit helper instead of an inline special-case loop. +- Renamed local variables in `simplex_volume_in_embedding()` so the 2-vertex + branch and the embedding-dimension branch are easier to read. +- Extracted `_sorted_line_data()` and `_plot_1d()` in `LearnerND` so the 1D + interpolation and plotting paths are short, named, and isolated. +- Updated the `plot()` docstring to match the current 1D+2D behavior. + +Why: the 1D support is still a real special case, but these helpers make the +special handling intentional and easier to follow without changing behavior. + +### `refactor: simplify DataSaver key reconstruction` + +- Replaced `_to_key(..., use_tuple=...)` with two small helpers: + `_mapping_uses_tuple_keys()` and `_row_to_key()`. +- Switched `load_dataframe()` to inspect `self.learner.data` directly instead of + relying on `__getattr__` indirection. +- Renamed `keys` to `input_columns` to match what the variable actually holds. + +Why: the 1D `LearnerND` DataFrame fix depends on whether the wrapped learner uses +tuple keys for single inputs, and the new helper names make that rule explicit. + +## Guardrails kept + +- No public API changes. +- No behavior changes intended. +- Full `python -m pytest adaptive/tests/ -x --no-cov` runs were used to verify + each cleanup slice before it was committed. From eb452c0d3a1921bf56fecf92c9411ca4e9f6695b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:43:31 +0000 Subject: [PATCH 07/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- adaptive/tests/test_triangulation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/adaptive/tests/test_triangulation.py b/adaptive/tests/test_triangulation.py index dc254328c..5926e54e9 100644 --- a/adaptive/tests/test_triangulation.py +++ b/adaptive/tests/test_triangulation.py @@ -76,6 +76,7 @@ def test_triangulation_raises_exception_for_1d_list(): with pytest.raises(TypeError): Triangulation(pts) + @with_dimension_incl_1d def test_triangulation_of_standard_simplex(dim): t = Triangulation(_make_standard_simplex(dim)) From 93c4178bf89161facb6b2f9681f607aa920e0e27 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 18 Apr 2026 09:52:52 -0700 Subject: [PATCH 08/12] Delete CLEANUP.md --- CLEANUP.md | 48 ------------------------------------------------ 1 file changed, 48 deletions(-) delete mode 100644 CLEANUP.md diff --git a/CLEANUP.md b/CLEANUP.md deleted file mode 100644 index 28340605c..000000000 --- a/CLEANUP.md +++ /dev/null @@ -1,48 +0,0 @@ -# ISSUE-095 cleanup summary - -This branch keeps the 1D `LearnerND` behavior from `issue-095-impl-py` and only -refactors the implementation and tests for clarity. - -## What changed - -### `refactor: consolidate 1D cleanup tests` - -- Folded repeated 1D triangulation cases into parametrized tests. -- Reused small helpers in the new 1D `LearnerND` unit tests instead of repeating - the same setup and tell/ask flow. -- Consolidated the 1D and 2D integration smoke tests so the shared runner - behavior is checked in one place. - -Why: the original 1D test additions covered the right behavior but repeated a -lot of setup and assertions, which made the cleanup surface harder to review. - -### `refactor: clarify 1D triangulation and interpolation` - -- Extracted `_flat_simplices()` so the 1D triangulation initialisation reads as - one explicit helper instead of an inline special-case loop. -- Renamed local variables in `simplex_volume_in_embedding()` so the 2-vertex - branch and the embedding-dimension branch are easier to read. -- Extracted `_sorted_line_data()` and `_plot_1d()` in `LearnerND` so the 1D - interpolation and plotting paths are short, named, and isolated. -- Updated the `plot()` docstring to match the current 1D+2D behavior. - -Why: the 1D support is still a real special case, but these helpers make the -special handling intentional and easier to follow without changing behavior. - -### `refactor: simplify DataSaver key reconstruction` - -- Replaced `_to_key(..., use_tuple=...)` with two small helpers: - `_mapping_uses_tuple_keys()` and `_row_to_key()`. -- Switched `load_dataframe()` to inspect `self.learner.data` directly instead of - relying on `__getattr__` indirection. -- Renamed `keys` to `input_columns` to match what the variable actually holds. - -Why: the 1D `LearnerND` DataFrame fix depends on whether the wrapped learner uses -tuple keys for single inputs, and the new helper names make that rule explicit. - -## Guardrails kept - -- No public API changes. -- No behavior changes intended. -- Full `python -m pytest adaptive/tests/ -x --no-cov` runs were used to verify - each cleanup slice before it was committed. From a5ee55b2901d28a801b81993360d1a1daa9b939b Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 18 Apr 2026 09:53:06 -0700 Subject: [PATCH 09/12] Delete REPORT.md --- REPORT.md | 116 ------------------------------------------------------ 1 file changed, 116 deletions(-) delete mode 100644 REPORT.md diff --git a/REPORT.md b/REPORT.md deleted file mode 100644 index aa6253eb1..000000000 --- a/REPORT.md +++ /dev/null @@ -1,116 +0,0 @@ -# LearnerND 1D Support — Python Implementation Report - -## Summary - -Added 1D support to `LearnerND` and the `Triangulation` class, allowing `LearnerND` to learn functions `f: R -> R^M` using the same adaptive triangulation infrastructure used for higher dimensions. - -## Changes Made - -### 1. `adaptive/learner/triangulation.py` - -- **Removed `dim == 1` guard** (was at line 329): The `Triangulation.__init__` no longer raises `ValueError` for 1D points. - -- **Added 1D initialization branch**: For `dim == 1`, sorts points by coordinate and creates interval simplices (pairs of consecutive sorted indices) instead of using `scipy.spatial.Delaunay` (which doesn't support 1D). - -- **Fixed `simplex_volume_in_embedding`**: Added a `len(vertices) == 2` check before the Heron formula branch. For line segments (1-simplices), returns the Euclidean distance between the two vertices. This works correctly for line segments embedded in any dimension. - -### 2. `adaptive/learner/learnerND.py` - -- **Fixed `_ip()` for `ndim == 1`**: Uses `scipy.interpolate.interp1d` instead of `LinearNDInterpolator` (which requires ≥2 dimensions). Points are sorted before interpolation, with `bounds_error=False, fill_value=np.nan`. - -- **Added 1D `plot()` support**: Before the `ndim != 2` guard, added a branch for `ndim == 1` that creates a holoviews `Path` for the interpolated curve and `Scatter` for known data points. - -- **Updated error message**: Changed "Only 2D plots are implemented" to "Only 1D and 2D plots are implemented". - -### 3. `adaptive/learner/data_saver.py` - -- **Fixed `_to_key` for 1D tuple keys**: Added `use_tuple` parameter to handle LearnerND's tuple key convention (`(-1.0,)`) vs Learner1D's scalar keys (`-1.0`) when converting DataFrame rows back to dict keys. - -- **Fixed `load_dataframe` indexing**: Changed `x[-1]` and `x[:-1]` to `x.iloc[-1]` and `x.iloc[:-1]` for correct positional indexing on pandas Series with string column labels. - -### 4. Test Changes - -#### `adaptive/tests/test_triangulation.py` -- Renamed `test_triangulation_raises_exception_for_1d_points` → `test_triangulation_supports_1d_points` (verifies 1D works instead of raising) -- Added `with_dimension_incl_1d` fixture (`[1, 2, 3, 4]`) for tests that are compatible with 1D -- Updated 8 parametrized tests to include `dim=1` -- Added 8 dedicated 1D tests: basic, multiple points, unsorted, add inside/outside left/right, locate point, opposing vertices - -#### `adaptive/tests/unit/test_triangulation.py` -- Extended `test_circumsphere` range to start from `dim=1` -- Added `test_simplex_volume_in_embedding_1d` (1D, 2D, 3D embeddings) -- Added `test_1d_triangulation_find_simplices` and `test_1d_triangulation_find_neighbors` - -#### `adaptive/tests/unit/test_learnernd.py` -- Added 6 tests: construction, tell/ask cycle, all loss functions, curvature loss, interpolation - -#### `adaptive/tests/unit/test_learnernd_integration.py` -- Added 4 tests: run to N points (simple + blocking), curvature loss, loss-decreases integration test - -#### `adaptive/tests/test_learnernd.py` -- Added 2 tests: basic 1D, 1D with loss goal - -#### `adaptive/tests/test_learners.py` -- Registered `peak_1d` function with `@learn_with(LearnerND, bounds=((-1, 1),))` for cross-learner tests - -## Review Round 1 — Fixes - -### Fix 1 (HIGH): 1D `plot()` crash -`np.array(sorted(self.data.items()))` failed because items are `((x,), y)` tuples -that can't be coerced into a homogeneous array. Fixed by flattening: -`[(x[0], y) for x, y in sorted(self.data.items())]`. -Regression test: `test_learnerND_1d_plot`. - -### Fix 2 (MEDIUM): 1D `_ip()` fails for vector outputs -`interp1d` defaults to `axis=-1`, which causes a length mismatch when values -have shape `(npoints, vdim)`. Fixed by passing `axis=0`. -Regression test: `test_learnerND_1d_vector_output_interpolation`. - -### Fix 3 (MEDIUM): Duplicate 1D coordinates unchecked -`Triangulation([(0.0,), (1.0,), (1.0,)])` created a degenerate zero-volume -simplex. Fixed by skipping adjacent duplicates after sorting in the 1D init. -Regression test: `test_1d_duplicate_coordinates_skipped`. - -### Fix 4 (MEDIUM): Flaky `test_circumsphere` for dim=1 -Gaussian sampling could place both boundary points on the same side of center, -producing `inf`. Fixed by special-casing `dim == 1` to generate `center ± radius` -deterministically. Verified stable over 20 consecutive runs. - -## Why No Other Changes Were Needed - -The existing `Triangulation` methods (add_point, bowyer_watson, _extend_hull, circumsphere, orientation, volume, hull, faces, etc.) all work correctly for 1D without modification because: - -- **circumsphere**: The general N-dim formula correctly computes midpoint and half-length for 1D -- **point_in_simplex**: The linear algebra approach (`solve(vectors.T, ...)`) handles 1×1 systems -- **orientation**: `slogdet` of a 1×1 matrix correctly returns the sign -- **bowyer_watson**: Circumcircle-based point insertion naturally handles interval subdivision -- **hull**: Face counting correctly identifies endpoints of the 1D triangulation -- **volume**: `det` of a 1×1 matrix returns the interval length - -## Test Results - -All tests pass: - -``` -adaptive/tests/test_triangulation.py — 69 passed -adaptive/tests/unit/test_triangulation.py — 11 passed -adaptive/tests/unit/test_learnernd.py — 11 passed -adaptive/tests/unit/test_learnernd_integration.py — 21 passed -adaptive/tests/test_learnernd.py — 5 passed -adaptive/tests/test_learners.py — 153 passed - Total: 270 passed -``` - -All existing 2D/3D/4D tests continue to pass unchanged. - -## Verified Functionality - -- All loss functions work for 1D: `default_loss`, `uniform_loss`, `std_loss`, `curvature_loss_function()` -- Interpolation produces correct values (scalar and vector outputs) -- 1D `plot()` renders without crash -- Loss decreases as more points are added -- `BlockingRunner` and `simple` runner both work -- DataFrame serialization/deserialization works (including DataSaver) -- Balancing learner works with 1D LearnerND -- Duplicate 1D coordinates are handled gracefully -- `circumsphere` is deterministic for dim=1 From 0cdf8e408c1b75d558492ffd1243c3665fef613a Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 18 Apr 2026 09:56:56 -0700 Subject: [PATCH 10/12] test: respect optional holoviews dependency in LearnerND plot test --- adaptive/tests/unit/test_learnernd.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/adaptive/tests/unit/test_learnernd.py b/adaptive/tests/unit/test_learnernd.py index 26c6e1d99..2dc24ce79 100644 --- a/adaptive/tests/unit/test_learnernd.py +++ b/adaptive/tests/unit/test_learnernd.py @@ -4,6 +4,7 @@ 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, @@ -152,9 +153,28 @@ def f_vec(x): assert np.isclose(result[1], 0.0) +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.""" - import holoviews as hv + hv = pytest.importorskip("holoviews") hv.extension("bokeh") learner = make_1d_learner() From 3c7a3b7ebf6f20d4572952f40f4f87f421130a6d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:57:17 +0000 Subject: [PATCH 11/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- adaptive/tests/unit/test_learnernd.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/adaptive/tests/unit/test_learnernd.py b/adaptive/tests/unit/test_learnernd.py index 2dc24ce79..d7078d14d 100644 --- a/adaptive/tests/unit/test_learnernd.py +++ b/adaptive/tests/unit/test_learnernd.py @@ -163,12 +163,16 @@ def missing_holoviews(name): raise ModuleNotFoundError return import_module(name) - monkeypatch.setattr(notebook_integration.importlib, "import_module", missing_holoviews) + 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."): + with pytest.raises( + RuntimeError, match="holoviews is not installed; plotting is disabled." + ): learner.plot() From 5bc216f626b5862f4658cc2a1ba64d6a3afbb0f7 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 18 Apr 2026 10:38:19 -0700 Subject: [PATCH 12/12] Fix simulated learner state restoration --- adaptive/learner/balancing_learner.py | 9 +++-- adaptive/learner/learnerND.py | 7 ++-- adaptive/tests/test_balancing_learner.py | 44 +++++++++++++++++++++++- adaptive/tests/unit/test_learnernd.py | 31 +++++++++++++++++ adaptive/utils.py | 3 +- 5 files changed, 88 insertions(+), 6 deletions(-) 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/learnerND.py b/adaptive/learner/learnerND.py index cd250afec..471b7ad50 100644 --- a/adaptive/learner/learnerND.py +++ b/adaptive/learner/learnerND.py @@ -862,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 @@ -885,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() 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/unit/test_learnernd.py b/adaptive/tests/unit/test_learnernd.py index d7078d14d..da9d147bf 100644 --- a/adaptive/tests/unit/test_learnernd.py +++ b/adaptive/tests/unit/test_learnernd.py @@ -153,6 +153,37 @@ def f_vec(x): 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.""" 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: