diff --git a/CHANGELOG.md b/CHANGELOG.md index 4930ac1..6e5a5ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Bug Fixes +- `LONVisualizer.compute_lon_colors()` and `compute_cmlon_colors()`, and therefore all plots, now classify global optima using the network's `eq_atol`, like the network metrics, instead of exact equality. `CMLON.get_local_sinks()` now returns exactly the sinks that are not global sinks, so a sink within `eq_atol` of the best fitness is no longer both a global and a local sink. Previously, such sinks counted towards `n_global_funnels` but were drawn as local sinks. Plots of networks with rounded fitness (`fitness_precision`) and the default `eq_atol` are unchanged. - Raised the minimum Plotly version to 6.1.1 and Kaleido to 1.0.0 to exclude incompatible image-export dependencies. Documented the Chrome/Chromium requirement and added real PNG and rotation GIF export tests, with Chrome installation in CI. `plot_3d()` now passes image dimensions explicitly to the exporter so the minimum supported versions preserve the requested size. - `LONVisualizer.get_layout()` with a `seed`, and therefore `plot_2d()`, `plot_3d()`, `create_rotation_gif()` and `visualize_all()`, no longer reseeds the global NumPy RNG or leaves a seeded RNG installed in igraph. The seed now applies only to the layout, and igraph's default RNG is restored afterwards. Seeded layouts are unchanged (#53). - Continuous sampling now handles failed local searches explicitly. A local search fails when `minimize` raises `ValueError` or returns a non-finite fitness. A failed initial local search fails its run, and a failed search after a perturbation is rejected and counts as a non-improving iteration. Previously, runs starting at a `nan` minimum silently vanished, and an `inf` minimum made `LON.from_trace_data()` fail after all sampling had finished. Results with `success=False` and a finite fitness are still used as local optima. Each failed local search emits a warning with its context and reason (#44). diff --git a/src/lonkit/lon.py b/src/lonkit/lon.py index 2d6a14a..71ecfe5 100644 --- a/src/lonkit/lon.py +++ b/src/lonkit/lon.py @@ -542,20 +542,17 @@ def get_sinks(self) -> list[int]: return [i for i, d in enumerate(out_degrees) if d == 0] def get_global_sinks(self) -> list[int]: - """Get indices of global sinks (sinks at best fitness).""" + """Get indices of global sinks (sinks at best fitness within `eq_atol`).""" sinks = self.get_sinks() fits = self.vertex_fitness return [s for s in sinks if self._allclose(fits[s], self.best_fitness)] def get_local_sinks(self) -> list[int]: - """Get indices of local sinks (sinks not at best fitness).""" - sinks = self.get_sinks() - fits = self.vertex_fitness + """Get indices of local sinks (sinks not at best fitness within `eq_atol`).""" if self.best_fitness is None: return [] - if self.minimize: - return [s for s in sinks if fits[s] > self.best_fitness] - return [s for s in sinks if fits[s] < self.best_fitness] + global_sinks = set(self.get_global_sinks()) + return [s for s in self.get_sinks() if s not in global_sinks] def compute_network_metrics(self, known_best: float | None = None) -> dict[str, Any]: """ diff --git a/src/lonkit/visualization.py b/src/lonkit/visualization.py index 1583d12..46fd918 100644 --- a/src/lonkit/visualization.py +++ b/src/lonkit/visualization.py @@ -103,13 +103,16 @@ def compute_node_sizes(self, graph) -> list[float]: return sizes def compute_lon_colors(self, lon: LON) -> list[str]: - """Compute node colors for LON visualization.""" + """Compute node colors for LON visualization. + + Nodes within `lon.eq_atol` of the best fitness are colored as global optima. + """ colors = [] fits = lon.vertex_fitness best = lon.best_fitness for f in fits: - if f == best: + if lon._allclose(f, best): colors.append(COLORS["lon_global"]) else: colors.append(COLORS["lon_local"]) @@ -117,11 +120,14 @@ def compute_lon_colors(self, lon: LON) -> list[str]: return colors def compute_cmlon_colors(self, cmlon: CMLON) -> list[str]: - """Compute node colors for CMLON visualization.""" + """Compute node colors for CMLON visualization. + + Nodes within `cmlon.eq_atol` of the best fitness are colored as global optima, + consistently with `get_global_sinks()` and the network metrics. + """ n_vertices = cmlon.n_vertices colors = [COLORS["local_basin"]] * n_vertices - sinks = cmlon.get_sinks() global_sinks = cmlon.get_global_sinks() fits = cmlon.vertex_fitness best = cmlon.best_fitness @@ -133,13 +139,12 @@ def compute_cmlon_colors(self, cmlon: CMLON) -> list[str]: colors[v] = COLORS["global_basin"] # Color suboptimal sinks - for s in sinks: - if fits[s] != best: - colors[s] = COLORS["local_sink"] + for s in cmlon.get_local_sinks(): + colors[s] = COLORS["local_sink"] # Color global optima (overrides basin color) for i, f in enumerate(fits): - if f == best: + if cmlon._allclose(f, best): colors[i] = COLORS["global_optimum"] return colors diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 8c3fedf..974a457 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -4,10 +4,12 @@ import igraph as ig import matplotlib.pyplot as plt import numpy as np +import pandas as pd import pytest from PIL import Image -from lonkit import LON, LONVisualizer +from lonkit import LON, LONConfig, LONVisualizer +from lonkit.visualization import COLORS from tests.conftest import SEED @@ -114,3 +116,37 @@ def test_plot_2d_saves_to_file( assert output_path.exists() assert output_path.stat().st_size > 0 + + +class TestColorsRespectTolerance: + """Colors classify global optima with `eq_atol`, consistently with the metrics.""" + + @staticmethod + def _two_sink_lon() -> LON: + trace = pd.DataFrame( + { + "run": [1, 2], + "fit1": [5.0, 5.0], + "node1": ["x", "y"], + "fit2": [0.0, 0.05], + "node2": ["a", "b"], + } + ) + return LON.from_trace_data(trace, LONConfig(eq_atol=0.1)) + + def test_cmlon_sinks_within_tolerance_are_global(self, visualizer: LONVisualizer) -> None: + cmlon = self._two_sink_lon().to_cmlon() + sinks = cmlon.get_sinks() + colors = visualizer.compute_cmlon_colors(cmlon) + + assert sorted(cmlon.get_global_sinks()) == sorted(sinks) + assert cmlon.get_local_sinks() == [] + assert [colors[s] for s in sinks] == [COLORS["global_optimum"]] * len(sinks) + assert cmlon.compute_network_metrics()["n_global_funnels"] == len(sinks) + + def test_lon_nodes_within_tolerance_are_global(self, visualizer: LONVisualizer) -> None: + lon = self._two_sink_lon() + colors = dict(zip(lon.vertex_names, visualizer.compute_lon_colors(lon))) + + assert colors["a"] == colors["b"] == COLORS["lon_global"] + assert colors["x"] == colors["y"] == COLORS["lon_local"]