Skip to content

Commit

Permalink
Add gif animation of evolution process to README (#243)
Browse files Browse the repository at this point in the history
Add new visualization type `genealogical_path` available through `OptHistory.show(…)`

* Introduce means for graph evolution animation

* gif: Filter out frames if there are too much of them

* WIP(evo-animation): template for parent traversal

* WIP(evo-animation): implement parent traversal backwards through generations

* WIP(evo-animation): play with visualizations

* WIP(evo-animation): review extra visualizations

* WIP(evo-animation): Template for genealogical tree visualization

* WIP(evo-animation): Move animation code to `opt_viz_extra`, fix parent obtaining

* feat(evo-animation) Fully implement 3-axis plot: target graph, evolving graphs, fitness dynamics

* feat(evo-animation) Support genealogical path tracing without dist measure/target graph

* chore(evo-animation) Cleanup code

* feat(evo-animation) Add hold stage to the rendered gif

* feat(evo-animation) Add linage animation to README (both russian and english versions)

* fix(evo-animation) Don't save gif by default

* fix(evo-animation) Remove unnecessary imports

* refactor(evo-animation) Move implementation of genealogical path visualizer to a separate file, enable its usage from OptHistory.show

* refactor(evo-animation) Add time for evolution and hold as parameters, increase default values

* refactor(evo-animation) Fix phrasing in readme

* fix(evo-animation) Fix visualization tests

* fix(evo-animation) Restore the `simple_run` state, slow down animation in README

* fix(evo-animation) Make pep8 purist bot happy

* refactor(evo-animation) Rename Metric → Fitness
  • Loading branch information
donRumata03 authored Feb 15, 2024
1 parent 1baeaa9 commit 68706be
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 6 deletions.
9 changes: 8 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ GOLEM можно установить с помощью ``pip``:
Быстрый старт
=============

Следующий пример показывает поиск графа по графу-эталону с помощью метрики расстояния редактирования (Edit Distance). Оптимизатор настраивается с минимальным набором параметров и простыми одноточечными мутациями. Более подробные примеры можно найти в файлах `simple_run.py <https://github.com/aimclub/GOLEM/blob/main/examples/synthetic_graph_evolution/simple_run.py>`_, `graph_search.py <https://github.com/aimclub/GOLEM/blob/main/examples/synthetic_graph_evolution/graph_search.py>`_ и `tree_search.py <https://github.com/aimclub/GOLEM/blob/main/examples/synthetic_graph_evolution/tree_search.py>`_ в директории `examples/synthetic_graph_evolution <https://github.com/aimclub/GOLEM/tree/main/examples/synthetic_graph_evolution>`_.
Следующий пример показывает поиск графа по графу-эталону с помощью метрики редакционного расстояния (Edit Distance). Оптимизатор настраивается с минимальным набором параметров и простыми одноточечными мутациями. Более подробные примеры можно найти в файлах `simple_run.py <https://github.com/aimclub/GOLEM/blob/main/examples/synthetic_graph_evolution/simple_run.py>`_, `graph_search.py <https://github.com/aimclub/GOLEM/blob/main/examples/synthetic_graph_evolution/graph_search.py>`_ и `tree_search.py <https://github.com/aimclub/GOLEM/blob/main/examples/synthetic_graph_evolution/tree_search.py>`_ в директории `examples/synthetic_graph_evolution <https://github.com/aimclub/GOLEM/tree/main/examples/synthetic_graph_evolution>`_.

.. code-block:: python
Expand All @@ -106,6 +106,13 @@ GOLEM можно установить с помощью ``pip``:
optimiser.history.show.fitness_line()
return found_graph
Если проследить предков найденного графа, будет видно, как к нему один за другим применяются генетические операторы (мутации, скрещивания и т.д.), приводящие, в конечном итоге, к целевому графу:

.. image:: /docs/source/img/evolution_process.gif
:alt: Процесс эволюции
:align: center

Можно также заметить, что, несмотря на общее улучшение фитнеса вдоль генеалогического пути, оптимизатор иногда жертвует локальным уменьшением редакционного расстояния некоторых графов ради поддержания разнообразия и получения таким образом наилучшего решения в конце.

Структура проекта
=================
Expand Down
8 changes: 8 additions & 0 deletions README_en.rst
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ Following example demonstrates graph search using reference graph & edit distanc
return found_graph
Tracing the lineage of the found_graph reveals how genetic operators (mutations, crossovers, etc.) are applied to a random graph one after another, eventually leading to the target graph:

.. image:: /docs/source/img/evolution_process.gif
:alt: Evolution process
:align: center

One can also notice that despite the fact that the edit distance generally decreases along the genealogical path, the optimizer sometimes sacrifices local fitness gain of some graphs in order to achieve diversity and thus obtain the best possible solution at the end.

Project Structure
=================

Expand Down
Binary file added docs/source/img/evolution_process.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 10 additions & 2 deletions examples/synthetic_graph_evolution/simple_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ def run_graph_search(size=16, timeout=8, visualize=True):
MutationTypesEnum.single_change],
crossover_types=[CrossoverTypesEnum.subtree]
)
adapter = BaseNetworkxAdapter() # Example works with NetworkX graphs
graph_gen_params = GraphGenerationParams(
adapter=BaseNetworkxAdapter(), # Example works with NetworkX graphs
adapter=adapter,
rules_for_constraint=DEFAULT_DAG_RULES, # We don't want cycles in the graph
available_node_types=node_types # Node types that can appear in graphs
)
Expand All @@ -50,11 +51,18 @@ def run_graph_search(size=16, timeout=8, visualize=True):
# Build and run the optimizer
optimiser = EvoGraphOptimizer(objective, initial_graphs, *all_parameters)
found_graphs = optimiser.optimise(objective)

if visualize:
# Restore the NetworkX graph back from internal Graph representation
found_graph = graph_gen_params.adapter.restore(found_graphs[0])
found_graph = adapter.restore(found_graphs[0])
draw_graphs_subplots(target_graph, found_graph, titles=['Target Graph', 'Found Graph'])
optimiser.history.show.fitness_line()

# Animation of genealogical path of the best individual:
optimiser.history.show.genealogical_path(graph_dist=adapter.adapt_func(tree_edit_dist),
target_graph=adapter.adapt(target_graph),
show=True)

return found_graphs


Expand Down
131 changes: 131 additions & 0 deletions golem/visualisation/opt_history/genealogical_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import math
import os
from functools import partial
from typing import Callable, List, Union, Optional

from matplotlib import pyplot as plt, animation

from golem.core.dag.graph import Graph
from golem.core.optimisers.opt_history_objects.individual import Individual
from golem.visualisation.graph_viz import GraphVisualizer
from golem.visualisation.opt_history.history_visualization import HistoryVisualization


class GenealogicalPath(HistoryVisualization):
def visualize(self, graph_dist: Callable[[Graph, Graph], float] = None, target_graph: Graph = None,
evolution_time_s: float = 8., hold_result_time_s: float = 2.,
save_path: Optional[Union[os.PathLike, str]] = None, show: bool = False):
"""
Takes the best individual from the resultant generation and traces its genealogical path
taking the most similar parent each time (or the first parent if no similarity measure is provided).
That makes the picture more stable (and hence comprehensible) and the evolution process more apparent.
Saves the result as a GIF with the following layout:
- target graph (if provided) is displayed on the left,
- evolving graphs go as the next subplot, they evolve from the first generation to the last,
- and the fitness plot on the right shows fitness dynamics as the graphs evolve.
:param graph_dist: a function to measure the distance between two graphs. If not provided, all graphs are
treated as equally distant.
Works on optimization graphs, not domain graphs. If your distance metric works on domain graphs,
adapt it with `adapter.adapt_func(your_metric)`.
:param target_graph: the graph to compare the genealogical path with. Again, optimization graph is expected.
If provided, it will be displayed on the left throughout the animation.
:param save_path: path to save the visualization (won't be saved if it's None).
GIF of video extension is expected.
:param show: whether to show the visualization.
:param evolution_time_s: time in seconds for the part of the animation where the evolution process is shown.
:param hold_result_time_s: time in seconds for the part of the animation where the final result is shown.
"""
# Treating all graphs as equally distant if there's no reasonable way to compare them:
graph_dist = graph_dist or (lambda g1, g2: 1)

def draw_graph(graph: Graph, ax, title, highlight_title=False):
ax.clear()
ax.set_title(title, fontsize=22, color='green' if highlight_title else 'black')
GraphVisualizer(graph).draw_nx_dag(ax, node_names_placement='legend')

try:
last_internal_graph = self.history.archive_history[-1][0]
genealogical_path = trace_genealogical_path(last_internal_graph, graph_dist)
except Exception as e:
# At least `Individual.parents_from_prev_generation` my fail
self.log.error(f"Failed to trace genealogical path: {e}")
return

figure_width = 5
width_ratios = [1.3, 0.7]
if target_graph is not None:
width_ratios = [1.3] + width_ratios

fig, axes = plt.subplots(
1, len(width_ratios),
figsize=(figure_width * sum(width_ratios), figure_width),
gridspec_kw={'width_ratios': width_ratios}
)
evo_ax, fitness_ax = axes[-2:]
if target_graph is not None:
draw_graph(target_graph, axes[0], "Target graph") # Persists throughout the animation

fitnesses_along_path = list(map(lambda ind: ind.fitness.value, genealogical_path))
generations_along_path = list(map(lambda ind: ind.native_generation, genealogical_path))

def render_frame(frame_index):
path_index = min(frame_index, len(genealogical_path) - 1)
is_hold_stage = frame_index >= len(genealogical_path)

draw_graph(
genealogical_path[path_index].graph, evo_ax,
f"Evolution process,\ngeneration {generations_along_path[path_index]}/{generations_along_path[-1]}",
highlight_title=is_hold_stage
)
# Select only the genealogical path
fitness_ax.clear()
plot_fitness_with_axvline(
generations=generations_along_path,
fitnesses=fitnesses_along_path,
ax=fitness_ax,
axvline_x=generations_along_path[path_index],
current_fitness=fitnesses_along_path[path_index]
)
return evo_ax, fitness_ax

frames = len(genealogical_path) + int(
math.ceil(len(genealogical_path) * hold_result_time_s / (hold_result_time_s + evolution_time_s))
)
seconds_per_frame = (evolution_time_s + hold_result_time_s) / frames
fps = math.ceil(1 / seconds_per_frame)

anim = animation.FuncAnimation(fig, render_frame, repeat=False, frames=frames,
interval=1000 * seconds_per_frame)

try:
if save_path is not None:
anim.save(save_path, fps=fps)
if show:
plt.show()
except Exception as e:
self.log.error(f"Failed to render the genealogical path: {e}")


def trace_genealogical_path(individual: Individual, graph_dist: Callable[[Graph, Graph], float]) -> List[Individual]:
# Choose nearest parent each time:
genealogical_path: List[Individual] = [individual]
while genealogical_path[-1].parents_from_prev_generation:
genealogical_path.append(max(
genealogical_path[-1].parents_from_prev_generation,
key=partial(graph_dist, genealogical_path[-1])
))

return list(reversed(genealogical_path))


def plot_fitness_with_axvline(generations: List[int], fitnesses: List[float], ax: plt.Axes, current_fitness: float,
axvline_x: int = None):
ax.plot(generations, fitnesses)
ax.set_title(f'Fitness dynamic,\ncurrent: {current_fitness}', fontsize=22)
ax.set_xlabel('Generation')
ax.set_ylabel('Fitness')
if axvline_x is not None:
ax.axvline(x=axvline_x, color='black')
return ax
3 changes: 3 additions & 0 deletions golem/visualisation/opt_viz.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from golem.visualisation.opt_history.diversity import DiversityLine, DiversityPopulation
from golem.visualisation.opt_history.fitness_box import FitnessBox
from golem.visualisation.opt_history.fitness_line import FitnessLine, FitnessLineInteractive
from golem.visualisation.opt_history.genealogical_path import GenealogicalPath
from golem.visualisation.opt_history.operations_animated_bar import OperationsAnimatedBar
from golem.visualisation.opt_history.operations_kde import OperationsKDE

Expand All @@ -22,6 +23,7 @@ class PlotTypesEnum(Enum):
operations_animated_bar = OperationsAnimatedBar
diversity_line = DiversityLine
diversity_population = DiversityPopulation
genealogical_path = GenealogicalPath

@classmethod
def member_names(cls):
Expand Down Expand Up @@ -60,6 +62,7 @@ def __init__(self, history: OptHistory, visuals_params: Optional[Dict[str, Any]]
self.operations_animated_bar = OperationsAnimatedBar(history, self.visuals_params).visualize
self.diversity_line = DiversityLine(history, self.visuals_params).visualize
self.diversity_population = DiversityPopulation(history, self.visuals_params).visualize
self.genealogical_path = GenealogicalPath(history, self.visuals_params).visualize

self.log = default_log(self)

Expand Down
4 changes: 2 additions & 2 deletions golem/visualisation/opt_viz_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import numpy as np
import pandas as pd
import seaborn as sns
from PIL import Image
from imageio import get_writer, v2
from matplotlib import pyplot as plt

from golem.core.dag.graph import Graph
Expand All @@ -17,8 +19,6 @@
from golem.core.optimisers.opt_history_objects.opt_history import OptHistory
from golem.core.paths import default_data_dir
from golem.visualisation.graph_viz import GraphVisualizer
from PIL import Image
from imageio import get_writer, v2


class OptHistoryExtraVisualizer:
Expand Down
3 changes: 2 additions & 1 deletion test/unit/optimizers/test_composing_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,8 @@ def test_newly_generated_history(n_jobs: int):
def test_history_show_saving_plots(tmp_path, plot_type: PlotTypesEnum, generate_history):
save_path = Path(tmp_path, plot_type.name)
gif_plots = [PlotTypesEnum.operations_animated_bar,
PlotTypesEnum.diversity_population]
PlotTypesEnum.diversity_population,
PlotTypesEnum.genealogical_path]
save_path = save_path.with_suffix('.gif') if plot_type in gif_plots \
else save_path.with_suffix('.png')
history: OptHistory = generate_history
Expand Down

0 comments on commit 68706be

Please sign in to comment.