Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Crossover for graphs with cycle #231

Merged
merged 8 commits into from
Jun 4, 2024
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
4 changes: 2 additions & 2 deletions examples/synthetic_graph_evolution/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import matplotlib as mpl
import networkx as nx
import numpy as np
from matplotlib import cm

from golem.core.adapter.nx_adapter import BaseNetworkxAdapter
from golem.core.optimisers.opt_history_objects.opt_history import OptHistory
Expand Down Expand Up @@ -137,7 +137,7 @@ def _get_node_colors_and_labels(graph: nx.Graph,
root_cm = max_degree
node_cm = max_degree // 2
src_cm = 0
colormap = cm.get_cmap(cmap_name, max_degree + 1)
colormap = mpl.colormaps[cmap_name].resampled(max_degree + 1)
colors = []
labels = {}

Expand Down
68 changes: 67 additions & 1 deletion golem/core/dag/graph_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Sequence, List, TYPE_CHECKING, Callable, Union
from typing import Sequence, List, TYPE_CHECKING, Callable, Union, Optional

from golem.utilities.data_structures import ensure_wrapped_in_sequence

Expand Down Expand Up @@ -200,3 +200,69 @@ def graph_has_cycle(graph: 'Graph') -> bool:
elif on_stack[parent.uid]:
return True
return False


def get_all_simple_paths(graph: 'Graph', source: 'GraphNode', target: 'GraphNode') \
-> List[List[List['GraphNode']]]:
""" Returns all simple paths from one node to another ignoring edge direction.
Args:
graph: graph in which to search for paths
source: the first node of the path
target: the last node of the path """
paths = []
nodes_children = {source.uid: graph.node_children(source)}
target = {target}
visited = dict.fromkeys([source])
node_neighbors = set(source.nodes_from).union(nodes_children[source.uid])
stack = [iter(node_neighbors)]

while stack:
neighbors = stack[-1]
neighbor = next(neighbors, None)
if neighbor is None: # current path does not contain target
stack.pop()
visited.popitem()
else:
if neighbor in visited: # path is not simple
continue
if neighbor in target: # target node was reached
path = list(visited) + [neighbor]
pairs_list = [[path[i], path[(i + 1)]] for i in range(len(path) - 1)]
paths.append(pairs_list)
else: # target node was not reached
visited[neighbor] = None
children = nodes_children[neighbor.uid] if neighbor.uid in nodes_children \
else nodes_children.setdefault(neighbor.uid, graph.node_children(neighbor)) # lazy setdefault
node_neighbors = set(neighbor.nodes_from).union(children)
stack.append(iter(node_neighbors))
return paths


def get_connected_components(graph: 'Graph', nodes: Optional[List['GraphNode']]) -> List[set]:
""" Returns list of connected components of the graph.
Each connected component is represented as a set of its nodes.
Args:
graph: graph to divide into connected components
nodes: if provided, only connected components containing these nodes are returned
Returns:
List of connected components"""
def _bfs(graph: 'Graph', source: 'GraphNode'):
seen = set()
nextlevel = {source}
while nextlevel:
thislevel = nextlevel
nextlevel = set()
for v in thislevel:
if v not in seen:
seen.add(v)
nextlevel.update(set(v.nodes_from).union(set(graph.node_children(v))))
return seen
visited = set()
nodes = nodes or graph.nodes
components = []
for node in nodes:
if node not in visited:
c = _bfs(graph, node)
visited.update(c)
components.append(c)
return components
64 changes: 61 additions & 3 deletions golem/core/optimisers/genetic/operators/crossover.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from copy import deepcopy
from itertools import chain
from math import ceil
from random import choice, random, sample
from random import choice, random, sample, randrange
from typing import Callable, Union, Iterable, Tuple, TYPE_CHECKING

from golem.core.adapter import register_native
from golem.core.dag.graph_utils import nodes_from_layer, node_depth
from golem.core.dag.graph_utils import nodes_from_layer, node_depth, get_all_simple_paths, get_connected_components
from golem.core.optimisers.genetic.gp_operators import equivalent_subtree, replace_subtrees
from golem.core.optimisers.genetic.operators.operator import PopulationT, Operator
from golem.core.optimisers.graph import OptGraph, OptNode
Expand All @@ -23,6 +23,7 @@ class CrossoverTypesEnum(Enum):
subtree = 'subtree'
one_point = "one_point"
none = 'none'
subgraph = 'subgraph_crossover'
exchange_edges = 'exchange_edges'
exchange_parents_one = 'exchange_parents_one'
exchange_parents_both = 'exchange_parents_both'
Expand Down Expand Up @@ -85,7 +86,8 @@ def _crossover_by_type(self, crossover_type: CrossoverTypesEnum) -> CrossoverCal
CrossoverTypesEnum.one_point: one_point_crossover,
CrossoverTypesEnum.exchange_edges: exchange_edges_crossover,
CrossoverTypesEnum.exchange_parents_one: exchange_parents_one_crossover,
CrossoverTypesEnum.exchange_parents_both: exchange_parents_both_crossover
CrossoverTypesEnum.exchange_parents_both: exchange_parents_both_crossover,
CrossoverTypesEnum.subgraph: subgraph_crossover
}
if crossover_type in crossovers:
return crossovers[crossover_type]
Expand Down Expand Up @@ -148,6 +150,62 @@ def one_point_crossover(graph_first: OptGraph, graph_second: OptGraph, max_depth
return graph_first, graph_second


@register_native
def subgraph_crossover(graph_first: OptGraph, graph_second: OptGraph, **kwargs) -> Tuple[OptGraph, OptGraph]:
""" A random edge is chosen and all paths between these nodes are disconnected.
This way each graph is divided into two subgraphs.
The subgraphs are exchanged between the graphs and connected randomly at the points of division.
Suitable for graphs with cycles. Does not guarantee not exceeding maximal depth. """
first_subgraphs, first_div_points = get_subgraphs(graph_first)
second_subgraphs, second_div_points = get_subgraphs(graph_second)
graph_first = connect_subgraphs(first_subgraphs[0], second_subgraphs[1], first_div_points, second_div_points)
graph_second = connect_subgraphs(first_subgraphs[1], second_subgraphs[0], first_div_points, second_div_points)

return graph_first, graph_second


def get_subgraphs(graph):
edges = graph.get_edges()
if not edges:
return deepcopy([graph.nodes, graph.nodes]), {*deepcopy(graph.nodes)}

target, source = choice(edges)
graph.disconnect_nodes(target, source)

simple_paths = get_all_simple_paths(graph, source, target)
simple_paths.sort(key=len)
division_points = {source, target}

while len(simple_paths) > 0:
node_first, node_second = choice(simple_paths[0])
graph.disconnect_nodes(node_first, node_second) if node_first in node_second.nodes_from \
else graph.disconnect_nodes(node_second, node_first)
division_points.union([node_first, node_second])

simple_paths = get_all_simple_paths(graph, source, target)
simple_paths.sort(key=len)

subgraphs = get_connected_components(graph, [source, target])
return subgraphs, division_points


def connect_subgraphs(first_subgraph, second_subgraph, first_div_points, second_div_points):
first_points = list(first_div_points.intersection(first_subgraph))
second_points = list(second_div_points.intersection(second_subgraph))
connections_num = min(len(first_points), len(second_points))
new_graph = OptGraph([*first_subgraph, *second_subgraph])

for _ in range(connections_num):
first_idx, second_idx = randrange(len(first_points)), randrange(len(second_points))
first_node, second_node = first_points.pop(first_idx), second_points.pop(second_idx)

if random() > 0.5:
new_graph.connect_nodes(first_node, second_node)
else:
new_graph.connect_nodes(second_node, first_node)
return new_graph


@register_native
def exchange_edges_crossover(graph_first: OptGraph, graph_second: OptGraph, max_depth):
"""Parents exchange a certain number of edges with each other. The number of
Expand Down
8 changes: 5 additions & 3 deletions golem/visualisation/opt_history/operations_animated_bar.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import os
from pathlib import Path
from typing import List, Optional, Sequence, Union
from numpy.typing import ArrayLike

import numpy as np
import seaborn as sns
import matplotlib as mpl
from matplotlib import cm, animation, pyplot as plt
from matplotlib.colors import Normalize

Expand Down Expand Up @@ -42,7 +44,7 @@ def interpolate_points(point_1, point_2, smoothness=18, power=4) -> List[np.arra
point_1, point_2 = np.array(point_1), np.array(point_2)
return [point_1 * (1 - t ** power) + point_2 * t ** power for t in t_interp]

def smoothen_frames_data(data: Sequence[Sequence['ArrayLike']], smoothness=18, power=4) -> List[np.array]:
def smoothen_frames_data(data: Sequence[Sequence[ArrayLike]], smoothness=18, power=4) -> List[np.array]:
final_frames = []
for initial_frame in range(len(data) - 1):
final_frames += interpolate_points(data[initial_frame], data[initial_frame + 1], smoothness, power)
Expand Down Expand Up @@ -70,7 +72,7 @@ def animate(frame_num):
animation_frames_per_step = 18
animation_interval_between_frames_ms = 40
animation_interpolation_power = 4
fitness_colormap = cm.get_cmap('YlOrRd')
fitness_colormap = mpl.colormaps['YlOrRd']

generation_column_name = 'Generation'
fitness_column_name = 'Fitness'
Expand Down Expand Up @@ -173,7 +175,7 @@ def animate(frame_num):
ax.tick_params(axis='y', which='major', labelsize=label_size)
ax.set_title(title)
ax.set_xlim(0, 1)
ax.set_xlabel(f'Fraction of graphs containing the operation')
ax.set_xlabel('Fraction of graphs containing the operation')
ax.xaxis.grid(True)
ax.set_ylabel(operation_column_name)
ax.invert_yaxis()
Expand Down
2 changes: 1 addition & 1 deletion test/integration/test_cycled_graph_evolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def test_cycled_graphs_evolution():
MutationTypesEnum.simple,
MutationTypesEnum.single_change
],
crossover_types=[CrossoverTypesEnum.none]
crossover_types=[CrossoverTypesEnum.subgraph]
)
graph_gen_params = GraphGenerationParams(
adapter=BaseNetworkxAdapter(),
Expand Down
2 changes: 1 addition & 1 deletion test/integration/test_external_history_visualize.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from golem.visualisation.opt_viz import PlotTypesEnum, OptHistoryVisualizer


@pytest.mark.parametrize('history_path', ['test/data/history_composite_bn_healthcare.json'])
@pytest.mark.parametrize('history_path', ['test/data/external_history_composite_bn_healthcare.json'])
@pytest.mark.parametrize('plot_type', PlotTypesEnum)
@pytest.mark.parametrize('is_light_history', [True, False])
def test_visualizations_for_external_history(tmp_path, history_path, plot_type, is_light_history):
Expand Down
2 changes: 1 addition & 1 deletion test/unit/serialization/test_external_serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@


@pytest.mark.parametrize('history_path', [
'test/data/history_composite_bn_healthcare.json',
'test/data/external_history_composite_bn_healthcare.json',
])
def test_external_history_load(history_path):
"""The idea is that external histories must be loadable by GOLEM.
Expand Down
Loading