Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
11 changes: 4 additions & 7 deletions src/lonkit/lon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand Down
21 changes: 13 additions & 8 deletions src/lonkit/visualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,25 +103,31 @@ 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"])

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
Expand All @@ -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
Expand Down
38 changes: 37 additions & 1 deletion tests/test_visualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"]
Loading