Skip to content

Commit

Permalink
Fix mutation operator (#238)
Browse files Browse the repository at this point in the history
* Correct condition

* Fix min mutation proba

* Review fixes

* Fix typing

* Fix mutation retries

* Fix random graph factory

* change `mutation_type` to `mutation_name` in `parent_operator`

---------

Co-authored-by: kasyanovse <[email protected]>
  • Loading branch information
YamLyubov and kasyanovse authored Dec 27, 2023
1 parent 4d71d55 commit ee2e56c
Show file tree
Hide file tree
Showing 6 changed files with 168 additions and 144 deletions.
184 changes: 104 additions & 80 deletions golem/core/optimisers/genetic/operators/base_mutations.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from copy import deepcopy
from functools import partial
from random import choice, randint, random, sample
from random import choice, randint, random, sample, shuffle
from typing import TYPE_CHECKING, Optional

import numpy as np

from golem.core.adapter import register_native
from golem.core.dag.graph import ReconnectType
from golem.core.dag.graph_node import GraphNode
Expand Down Expand Up @@ -69,7 +72,6 @@ def simple_mutation(graph: OptGraph,
:param graph: graph to mutate
"""

exchange_node = graph_gen_params.node_factory.exchange_node
visited_nodes = set()

Expand Down Expand Up @@ -138,56 +140,67 @@ def nodes_not_cycling(source_node: OptNode, target_node: OptNode):

@register_native
def add_intermediate_node(graph: OptGraph,
node_to_mutate: OptNode,
node_factory: OptNodeFactory) -> OptGraph:
# add between node and parent
new_node = node_factory.get_parent_node(node_to_mutate, is_primary=False)
if not new_node:
return graph

# rewire old children to new parent
new_node.nodes_from = node_to_mutate.nodes_from
node_to_mutate.nodes_from = [new_node]

# add new node to graph
graph.add_node(new_node)
nodes_with_parents = [node for node in graph.nodes if node.nodes_from]
if len(nodes_with_parents) > 0:
shuffle(nodes_with_parents)
for node_to_mutate in nodes_with_parents:
# add between node and parent
new_node = node_factory.get_parent_node(node_to_mutate, is_primary=False)
if not new_node:
continue

# rewire old children to new parent
new_node.nodes_from = node_to_mutate.nodes_from
node_to_mutate.nodes_from = [new_node]

# add new node to graph
graph.add_node(new_node)
break
return graph


@register_native
def add_separate_parent_node(graph: OptGraph,
node_to_mutate: OptNode,
node_factory: OptNodeFactory) -> OptGraph:
# add as separate parent
new_node = node_factory.get_parent_node(node_to_mutate, is_primary=True)
if not new_node:
# there is no possible operators
return graph
if node_to_mutate.nodes_from:
node_to_mutate.nodes_from.append(new_node)
else:
node_to_mutate.nodes_from = [new_node]
graph.nodes.append(new_node)
node_idx = np.arange(len(graph.nodes))
shuffle(node_idx)
for idx in node_idx:
node_to_mutate = graph.nodes[idx]
# add as separate parent
new_node = node_factory.get_parent_node(node_to_mutate, is_primary=True)
if not new_node:
# there is no possible operators
continue
if node_to_mutate.nodes_from:
node_to_mutate.nodes_from.append(new_node)
else:
node_to_mutate.nodes_from = [new_node]
graph.nodes.append(new_node)
break
return graph


@register_native
def add_as_child(graph: OptGraph,
node_to_mutate: OptNode,
node_factory: OptNodeFactory) -> OptGraph:
# add as child
old_node_children = graph.node_children(node_to_mutate)
new_node_child = choice(old_node_children) if old_node_children else None
new_node = node_factory.get_node(is_primary=False)
if not new_node:
return graph
graph.add_node(new_node)
graph.connect_nodes(node_parent=node_to_mutate, node_child=new_node)
if new_node_child:
graph.connect_nodes(node_parent=new_node, node_child=new_node_child)
graph.disconnect_nodes(node_parent=node_to_mutate, node_child=new_node_child,
clean_up_leftovers=True)

node_idx = np.arange(len(graph.nodes))
shuffle(node_idx)
for idx in node_idx:
node_to_mutate = graph.nodes[idx]
# add as child
old_node_children = graph.node_children(node_to_mutate)
new_node_child = choice(old_node_children) if old_node_children else None
new_node = node_factory.get_node(is_primary=False)
if not new_node:
continue
graph.add_node(new_node)
graph.connect_nodes(node_parent=node_to_mutate, node_child=new_node)
if new_node_child:
graph.connect_nodes(node_parent=new_node, node_child=new_node_child)
graph.disconnect_nodes(node_parent=node_to_mutate, node_child=new_node_child,
clean_up_leftovers=True)
break
return graph


Expand All @@ -202,20 +215,20 @@ def single_add_mutation(graph: OptGraph,
:param graph: graph to mutate
"""

if graph.depth >= requirements.max_depth:
# add mutation is not possible
return graph

node_to_mutate = choice(graph.nodes)

single_add_strategies = [add_as_child, add_separate_parent_node]
if node_to_mutate.nodes_from:
single_add_strategies.append(add_intermediate_node)
strategy = choice(single_add_strategies)

result = strategy(graph, node_to_mutate, graph_gen_params.node_factory)
return result
new_graph = deepcopy(graph)
single_add_strategies = [add_as_child, add_separate_parent_node, add_intermediate_node]
shuffle(single_add_strategies)
for strategy in single_add_strategies:
new_graph = strategy(new_graph, graph_gen_params.node_factory)
# maximum three equality check
if new_graph == graph:
continue
break
return new_graph


@register_native
Expand All @@ -229,11 +242,15 @@ def single_change_mutation(graph: OptGraph,
:param graph: graph to mutate
"""
node = choice(graph.nodes)
new_node = graph_gen_params.node_factory.exchange_node(node)
if not new_node:
return graph
graph.update_node(node, new_node)
node_idx = np.arange(len(graph.nodes))
shuffle(node_idx)
for idx in node_idx:
node = graph.nodes[idx]
new_node = graph_gen_params.node_factory.exchange_node(node)
if not new_node:
continue
graph.update_node(node, new_node)
break
return graph


Expand Down Expand Up @@ -289,22 +306,27 @@ def tree_growth(graph: OptGraph,
selected random node, if false then previous depth of selected node doesn't affect to
new subtree depth, maximal depth of new subtree just should satisfy depth constraint in parent tree
"""
node_from_graph = choice(graph.nodes)
if local_growth:
max_depth = distance_to_primary_level(node_from_graph)
is_primary_node_selected = (not node_from_graph.nodes_from) or (node_from_graph != graph.root_node and
randint(0, 1))
else:
max_depth = requirements.max_depth - distance_to_root_level(graph, node_from_graph)
is_primary_node_selected = \
distance_to_root_level(graph, node_from_graph) >= requirements.max_depth and randint(0, 1)
if is_primary_node_selected:
new_subtree = graph_gen_params.node_factory.get_node(is_primary=True)
if not new_subtree:
return graph
else:
new_subtree = graph_gen_params.random_graph_factory(requirements, max_depth).root_node
graph.update_subtree(node_from_graph, new_subtree)
node_idx = np.arange(len(graph.nodes))
shuffle(node_idx)
for idx in node_idx:
node_from_graph = graph.nodes[idx]
if local_growth:
max_depth = distance_to_primary_level(node_from_graph)
is_primary_node_selected = (not node_from_graph.nodes_from) or (node_from_graph != graph.root_node and
randint(0, 1))
else:
max_depth = requirements.max_depth - distance_to_root_level(graph, node_from_graph)
is_primary_node_selected = \
distance_to_root_level(graph, node_from_graph) >= requirements.max_depth and randint(0, 1)
if is_primary_node_selected:
new_subtree = graph_gen_params.node_factory.get_node(is_primary=True)
if not new_subtree:
continue
else:
new_subtree = graph_gen_params.random_graph_factory(requirements, max_depth).root_node

graph.update_subtree(node_from_graph, new_subtree)
break
return graph


Expand Down Expand Up @@ -349,16 +371,18 @@ def reduce_mutation(graph: OptGraph,
return graph

nodes = [node for node in graph.nodes if node is not graph.root_node]
node_to_del = choice(nodes)
children = graph.node_children(node_to_del)
is_possible_to_delete = all([len(child.nodes_from) - 1 >= requirements.min_arity for child in children])
if is_possible_to_delete:
graph.delete_subtree(node_to_del)
else:
primary_node = graph_gen_params.node_factory.get_node(is_primary=True)
if not primary_node:
return graph
graph.update_subtree(node_to_del, primary_node)
shuffle(nodes)
for node_to_del in nodes:
children = graph.node_children(node_to_del)
is_possible_to_delete = all([len(child.nodes_from) - 1 >= requirements.min_arity for child in children])
if is_possible_to_delete:
graph.delete_subtree(node_to_del)
else:
primary_node = graph_gen_params.node_factory.get_node(is_primary=True)
if not primary_node:
continue
graph.update_subtree(node_to_del, primary_node)
break
return graph


Expand Down
77 changes: 33 additions & 44 deletions golem/core/optimisers/genetic/operators/mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,74 +81,63 @@ def __call__(self, population: Union[Individual, PopulationT]) -> Union[Individu
if isinstance(population, Individual):
population = [population]

final_population, mutations_applied, application_attempts = tuple(zip(*map(self._mutation, population)))
final_population, application_attempts = tuple(zip(*map(self._mutation, population)))

# drop individuals to which mutations could not be applied
final_population = [ind for ind, init_ind, attempt in zip(final_population, population, application_attempts)
if not attempt or ind.graph != init_ind.graph]
if not(attempt and ind.graph == init_ind.graph)]

if len(population) == 1:
return final_population[0] if final_population else final_population

return final_population

def _mutation(self, individual: Individual) -> Tuple[Individual, Optional[MutationIdType], bool]:
def _mutation(self, individual: Individual) -> Tuple[Individual, bool]:
""" Function applies mutation operator to graph """
application_attempt = False
mutation_applied = None
for _ in range(self.parameters.max_num_of_operator_attempts):
new_graph = deepcopy(individual.graph)

new_graph, mutation_applied = self._apply_mutations(new_graph)
if mutation_applied is None:
continue
application_attempt = True
is_correct_graph = self.graph_generation_params.verifier(new_graph)
if is_correct_graph:
parent_operator = ParentOperator(type_='mutation',
operators=mutation_applied,
parent_individuals=individual)
individual = Individual(new_graph, parent_operator,
metadata=self.requirements.static_individual_metadata)
break
mutation_type = self._operator_agent.choose_action(individual.graph)
is_applied = self._will_mutation_be_applied(mutation_type)
if is_applied:
for _ in range(self.parameters.max_num_of_operator_attempts):
new_graph = deepcopy(individual.graph)

new_graph = self._apply_mutations(new_graph, mutation_type)
is_correct_graph = self.graph_generation_params.verifier(new_graph)
if is_correct_graph:
if isinstance(mutation_type, MutationTypesEnum):
mutation_name = mutation_type.name
else:
mutation_name = mutation_type.__name__
parent_operator = ParentOperator(type_='mutation',
operators=mutation_name,
parent_individuals=individual)
individual = Individual(new_graph, parent_operator,
metadata=self.requirements.static_individual_metadata)
break
else:
# Collect invalid actions
self.agent_experience.collect_experience(individual, mutation_applied, reward=-1.0)
else:
self.log.debug('Number of mutation attempts exceeded. '
'Please check optimization parameters for correctness.')
return individual, mutation_applied, application_attempt
self.agent_experience.collect_experience(individual, mutation_type, reward=-1.0)

self.log.debug(f'Number of attempts for {mutation_type} mutation application exceeded. '
'Please check optimization parameters for correctness.')
return individual, is_applied

def _sample_num_of_mutations(self) -> int:
def _sample_num_of_mutations(self, mutation_type: Union[MutationTypesEnum, Callable]) -> int:
# most of the time returns 1 or rarely several mutations
if self.parameters.variable_mutation_num:
is_custom_mutation = isinstance(mutation_type, Callable)
if self.parameters.variable_mutation_num and not is_custom_mutation:
num_mut = max(int(round(np.random.lognormal(0, sigma=0.5))), 1)
else:
num_mut = 1
return num_mut

def _apply_mutations(self, new_graph: Graph) -> Tuple[Graph, Optional[MutationIdType]]:
def _apply_mutations(self, new_graph: Graph, mutation_type: Union[MutationTypesEnum, Callable]) -> Graph:
"""Apply mutation 1 or few times iteratively"""
mutation_type = self._operator_agent.choose_action(new_graph)
mutation_applied = None
for _ in range(self._sample_num_of_mutations()):
new_graph, applied = self._adapt_and_apply_mutation(new_graph, mutation_type)
if applied:
mutation_applied = mutation_type
is_custom_mutation = isinstance(mutation_type, Callable)
if is_custom_mutation: # custom mutation occurs once
break
return new_graph, mutation_applied

def _adapt_and_apply_mutation(self, new_graph: Graph, mutation_type) -> Tuple[Graph, bool]:
applied = self._will_mutation_be_applied(mutation_type)
if applied:
# get the mutation function and adapt it
for _ in range(self._sample_num_of_mutations(mutation_type)):
mutation_func = self._get_mutation_func(mutation_type)
new_graph = mutation_func(new_graph, requirements=self.requirements,
graph_gen_params=self.graph_generation_params,
parameters=self.parameters)
return new_graph, applied
return new_graph

def _will_mutation_be_applied(self, mutation_type: Union[MutationTypesEnum, Callable]) -> bool:
return random() <= self.parameters.mutation_prob and mutation_type is not MutationTypesEnum.none
Expand Down
2 changes: 1 addition & 1 deletion golem/core/optimisers/genetic/parameters/mutation_prob.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class AdaptiveMutationProb(AdaptiveParameter[float]):
def __init__(self, default_prob: float = 0.5):
self._current_std = 0.
self._max_std = 0.
self._min_proba = 0.05
self._min_proba = 0.1
self._default_prob = default_prob

@property
Expand Down
Loading

0 comments on commit ee2e56c

Please sign in to comment.