From 2e16473009176bc4f5a91cb58dbba179ef622a44 Mon Sep 17 00:00:00 2001 From: Neil Traft Date: Sat, 19 Nov 2022 22:32:48 -0500 Subject: [PATCH 1/8] Fix bug #255 and add corresponding unit tests. --- neat/graphs.py | 6 ++++++ tests/test_graphs.py | 28 +++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/neat/graphs.py b/neat/graphs.py index 0f3c6fc2..1b321fae 100644 --- a/neat/graphs.py +++ b/neat/graphs.py @@ -73,6 +73,12 @@ def feed_forward_layers(inputs, outputs, connections): layers = [] s = set(inputs) + # First, find any nodes which do not have inputs. + has_input = set(b for (a, b) in connections) # Get the set of all nodes with an incoming edge. + does_not_have_input = required - has_input # Get required nodes that do NOT have an incoming edge. + if does_not_have_input: + layers.append(does_not_have_input) + s = s.union(does_not_have_input) while 1: # Find candidate nodes c for the next layer. These nodes should connect # a node in s to a node not in s. diff --git a/tests/test_graphs.py b/tests/test_graphs.py index b8d1c9b2..94f10c2f 100644 --- a/tests/test_graphs.py +++ b/tests/test_graphs.py @@ -93,19 +93,26 @@ def test_feed_forward_layers(): outputs = [2] connections = [(0, 2), (1, 2)] layers = feed_forward_layers(inputs, outputs, connections) - assert [{2}] == layers + assert [{2}] == layers, f"actually got: {layers}" inputs = [0, 1] outputs = [3] connections = [(0, 2), (1, 2), (2, 3)] layers = feed_forward_layers(inputs, outputs, connections) - assert [{2}, {3}] == layers + assert [{2}, {3}] == layers, f"actually got: {layers}" inputs = [0, 1] outputs = [4] connections = [(0, 2), (1, 2), (1, 3), (2, 3), (2, 4), (3, 4)] layers = feed_forward_layers(inputs, outputs, connections) - assert [{2}, {3}, {4}] == layers + assert [{2}, {3}, {4}] == layers, f"actually got: {layers}" + + # A graph with dangling input nodes: node 2 has no dependencies, yet some other nodes depend upon it. + inputs = [0, 1] + outputs = [4] + connections = [(1, 3), (2, 3), (2, 4), (3, 4)] + layers = feed_forward_layers(inputs, outputs, connections) + assert [{2}, {3}, {4}] == layers, f"actually got: {layers}" inputs = [0, 1, 2, 3] outputs = [11, 12, 13] @@ -114,8 +121,19 @@ def test_feed_forward_layers(): (8, 11), (8, 12), (8, 9), (9, 10), (7, 10), (10, 12), (10, 13)] layers = feed_forward_layers(inputs, outputs, connections) - assert [{4, 5, 6}, {8, 7}, {9, 11}, {10}, {12, 13}] == layers + assert [{4, 5, 6}, {8, 7}, {9, 11}, {10}, {12, 13}] == layers, f"actually got: {layers}" + + # Another graph with dangling input nodes (node 5). + inputs = [0, 1, 2, 3] + outputs = [11, 12, 13] + connections = [(0, 4), (1, 4), (2, 6), (3, 6), (3, 7), + (4, 8), (5, 8), (5, 9), (5, 10), (6, 10), (6, 7), + (8, 11), (8, 12), (8, 9), (9, 10), (7, 10), + (10, 12), (10, 13)] + layers = feed_forward_layers(inputs, outputs, connections) + assert [{5}, {4, 6}, {8, 7}, {9, 11}, {10}, {12, 13}] == layers, f"actually got: {layers}" + # A graph with dangling output nodes. inputs = [0, 1, 2, 3] outputs = [11, 12, 13] connections = [(0, 4), (1, 4), (1, 5), (2, 5), (2, 6), (3, 6), (3, 7), @@ -124,7 +142,7 @@ def test_feed_forward_layers(): (10, 12), (10, 13), (3, 14), (14, 15), (5, 16), (10, 16)] layers = feed_forward_layers(inputs, outputs, connections) - assert [{4, 5, 6}, {8, 7}, {9, 11}, {10}, {12, 13}] == layers + assert [{4, 5, 6}, {8, 7}, {9, 11}, {10}, {12, 13}] == layers, f"actually got: {layers}" def test_fuzz_feed_forward_layers(): From ab9f7289452dfdbb87bb7c27ee6efeb78068975e Mon Sep 17 00:00:00 2001 From: Neil Traft Date: Sun, 20 Nov 2022 16:09:08 -0500 Subject: [PATCH 2/8] Add unit tests for FeedForwardNetwork. --- tests/test_feedforward_network.py | 127 +++++++++++++++++++++++------- 1 file changed, 100 insertions(+), 27 deletions(-) diff --git a/tests/test_feedforward_network.py b/tests/test_feedforward_network.py index 5f6a52e4..27c9a93f 100644 --- a/tests/test_feedforward_network.py +++ b/tests/test_feedforward_network.py @@ -1,4 +1,8 @@ -from neat import activations + +import os + +import neat +from neat import activations, Config, DefaultGenome from neat.nn import FeedForwardNetwork @@ -44,6 +48,22 @@ def test_basic(): assert result[0] == r.values[0] +def add_node(g, config, new_node_id=None, bias=None, response=None, aggregation=None, activation=None): + if new_node_id is None: + new_node_id = config.get_new_node_key(g.nodes) + ng = g.create_node(config, new_node_id) + g.nodes[new_node_id] = ng + if bias is not None: + ng.bias = bias + if response is not None: + ng.response = response + if aggregation is not None: + ng.aggregation = aggregation + if activation is not None: + ng.activation = activation + return new_node_id, ng + + # TODO: Update this test for the current implementation. # def test_simple_nohidden(): # config_params = { @@ -81,34 +101,87 @@ def test_basic(): # assert_almost_equal(v11[0], 0.0, 1e-3) -# TODO: Update this test for the current implementation. -# def test_simple_hidden(): -# config = Config() -# config.genome_config.set_input_output_sizes(2, 1) -# g = DefaultGenome(0, config) -# -# g.add_node(0, 0.0, 1.0, 'sum', 'identity') -# g.add_node(1, -0.5, 5.0, 'sum', 'sigmoid') -# g.add_node(2, -1.5, 5.0, 'sum', 'sigmoid') -# g.add_connection(-1, 1, 1.0, True) -# g.add_connection(-2, 2, 1.0, True) -# g.add_connection(1, 0, 1.0, True) -# g.add_connection(2, 0, -1.0, True) -# net = nn.create_feed_forward_phenotype(g, config) -# -# v00 = net.serial_activate([0.0, 0.0]) -# assert_almost_equal(v00[0], 0.195115, 1e-3) -# -# v01 = net.serial_activate([0.0, 1.0]) -# assert_almost_equal(v01[0], -0.593147, 1e-3) -# -# v10 = net.serial_activate([1.0, 0.0]) -# assert_almost_equal(v10[0], 0.806587, 1e-3) -# -# v11 = net.serial_activate([1.0, 1.0]) -# assert_almost_equal(v11[0], 0.018325, 1e-3) +def test_simple_hidden(): + # Get config path + local_dir = os.path.dirname(__file__) + config_path = os.path.join(local_dir, 'test_configuration2') + + # Load configuration from file + config = neat.Config(neat.DefaultGenome, neat.DefaultReproduction, + neat.DefaultSpeciesSet, neat.DefaultStagnation, config_path) + + # Construct test genome. + g = DefaultGenome(0) + g.configure_new(config.genome_config) + g.nodes.clear() + g.connections.clear() + # n0 = n1 - n2 + # n1 = sigmoid(5*i1 - 0.5) + # n2 = sigmoid(5*i2 - 1.5) + add_node(g, config.genome_config, 0, 0.0, 1.0, 'sum', 'identity') + add_node(g, config.genome_config, 1, -0.5, 5.0, 'sum', 'sigmoid') + add_node(g, config.genome_config, 2, -1.5, 5.0, 'sum', 'sigmoid') + g.add_connection(config.genome_config, -1, 1, 1.0, True) + g.add_connection(config.genome_config, -2, 2, 1.0, True) + g.add_connection(config.genome_config, 1, 0, 1.0, True) + g.add_connection(config.genome_config, 2, 0, -1.0, True) + net = FeedForwardNetwork.create(g, config) + + v00 = net.activate([0.0, 0.0]) + assert_almost_equal(v00[0], 0.075305, 1e-3) + + v01 = net.activate([0.0, 1.0]) + assert_almost_equal(v01[0], -0.924141, 1e-3) + + v10 = net.activate([1.0, 0.0]) + assert_almost_equal(v10[0], 0.999447, 1e-3) + + v11 = net.activate([1.0, 1.0]) + assert_almost_equal(v11[0], 2.494080e-8, 1e-3) + + +def test_dangling_input(): + # Get config path + local_dir = os.path.dirname(__file__) + config_path = os.path.join(local_dir, 'test_configuration2') + + # Load configuration from file + config = neat.Config(neat.DefaultGenome, neat.DefaultReproduction, + neat.DefaultSpeciesSet, neat.DefaultStagnation, config_path) + + # Construct test genome. + g = DefaultGenome(0) + g.configure_new(config.genome_config) + g.nodes.clear() + g.connections.clear() + # n0 = n1 - n2 + # n1 = sigmoid(-0.5) + # n2 = sigmoid(5*i2 - 1.5) + add_node(g, config.genome_config, 0, 0.0, 1.0, 'sum', 'identity') + add_node(g, config.genome_config, 1, -0.5, 5.0, 'sum', 'sigmoid') + add_node(g, config.genome_config, 2, -1.5, 5.0, 'sum', 'sigmoid') + # Node 1 has no inputs. + # g.add_connection(config.genome_config, -1, 1, 1.0, True) + g.add_connection(config.genome_config, -2, 2, 1.0, True) + g.add_connection(config.genome_config, 1, 0, 1.0, True) + g.add_connection(config.genome_config, 2, 0, -1.0, True) + net = FeedForwardNetwork.create(g, config) + + v00 = net.activate([0.0, 0.0]) + assert_almost_equal(v00[0], 0.075305, 1e-3) + + v01 = net.activate([0.0, 1.0]) + assert_almost_equal(v01[0], -0.924141, 1e-3) + + v10 = net.activate([1.0, 0.0]) + assert_almost_equal(v10[0], 0.075305, 1e-3) + + v11 = net.activate([1.0, 1.0]) + assert_almost_equal(v11[0], -0.924141, 1e-3) if __name__ == '__main__': test_unconnected() test_basic() + test_simple_hidden() + test_dangling_input() From f098ea09cff5651d8d62178729cac3e1ccf61d16 Mon Sep 17 00:00:00 2001 From: Neil Traft Date: Sat, 26 Nov 2022 18:03:05 -0500 Subject: [PATCH 3/8] Add test to show unsupported aggregations on dangling nodes. --- tests/test_feedforward_network.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_feedforward_network.py b/tests/test_feedforward_network.py index 27c9a93f..7403c41f 100644 --- a/tests/test_feedforward_network.py +++ b/tests/test_feedforward_network.py @@ -158,7 +158,7 @@ def test_dangling_input(): # n1 = sigmoid(-0.5) # n2 = sigmoid(5*i2 - 1.5) add_node(g, config.genome_config, 0, 0.0, 1.0, 'sum', 'identity') - add_node(g, config.genome_config, 1, -0.5, 5.0, 'sum', 'sigmoid') + add_node(g, config.genome_config, 1, -0.5, 5.0, 'max', 'sigmoid') add_node(g, config.genome_config, 2, -1.5, 5.0, 'sum', 'sigmoid') # Node 1 has no inputs. # g.add_connection(config.genome_config, -1, 1, 1.0, True) @@ -167,6 +167,16 @@ def test_dangling_input(): g.add_connection(config.genome_config, 2, 0, -1.0, True) net = FeedForwardNetwork.create(g, config) + # First, this network is invalid and should throw an error. + try: + net.activate([0.0, 0.0]) + assert False, "Max aggregation on a node with no inputs should throw an error." + except ValueError as e: + assert str(e) == "max() arg is an empty sequence" + + # Now change the network to be valid (replace "max" with "sum"). + add_node(g, config.genome_config, 1, -0.5, 5.0, 'sum', 'sigmoid') + net = FeedForwardNetwork.create(g, config) v00 = net.activate([0.0, 0.0]) assert_almost_equal(v00[0], 0.075305, 1e-3) From aa31a89b644d0d5ee916452627b4429e5d87bde9 Mon Sep 17 00:00:00 2001 From: Neil Traft Date: Sat, 26 Nov 2022 18:03:44 -0500 Subject: [PATCH 4/8] Add save() method as a less insane way of using DefaultClassConfig. --- neat/config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/neat/config.py b/neat/config.py index b616364a..7605e4a0 100644 --- a/neat/config.py +++ b/neat/config.py @@ -117,6 +117,10 @@ def write_config(cls, f, config): # pylint: disable=protected-access write_pretty_params(f, config, config._params) + def save(self, f): + # pylint: disable=protected-access + write_pretty_params(f, self, self._params) + class Config(object): """A container for user-configurable parameters of NEAT.""" From bb11fd50db9567e32d0b67b3586502c1322bff2d Mon Sep 17 00:00:00 2001 From: Neil Traft Date: Mon, 28 Nov 2022 11:22:00 -0500 Subject: [PATCH 5/8] Ignore all result folders. --- .gitignore | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index abd5ce18..f4b17699 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,8 @@ __pycache__/ # pycharm cache folder .idea/ -examples/openai-lander/results/ -examples/openai-walker/results/ +examples/openai-lander/results*/ +examples/openai-walker/results*/ examples/picture2d/*.bin examples/picture2d/*.png examples/picture2d/rendered/ @@ -28,4 +28,4 @@ venv *.svg build dist -neat_python.egg-info \ No newline at end of file +neat_python.egg-info From 8250e498b3b3ddc02a6665a150f64768073b8a3e Mon Sep 17 00:00:00 2001 From: Neil Traft Date: Sat, 3 Dec 2022 08:59:43 -0500 Subject: [PATCH 6/8] Allow genome configs to be extended with extra parameters. --- neat/genome.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/neat/genome.py b/neat/genome.py index 2d652650..3bbf12db 100644 --- a/neat/genome.py +++ b/neat/genome.py @@ -18,7 +18,7 @@ class DefaultGenomeConfig(object): 'full_nodirect', 'full', 'full_direct', 'partial_nodirect', 'partial', 'partial_direct'] - def __init__(self, params): + def __init__(self, params, extra_param_definitions=None): # Create full set of available activation functions. self.activation_defs = ActivationFunctionSet() # ditto for aggregation functions - name difference for backward compatibility @@ -38,6 +38,8 @@ def __init__(self, params): ConfigParameter('single_structural_mutation', bool, 'false'), ConfigParameter('structural_mutation_surer', str, 'default'), ConfigParameter('initial_connection', str, 'unconnected')] + if extra_param_definitions: + self._params.extend(extra_param_definitions) # Gather configuration data from the gene classes. self.node_gene_type = params['node_gene_type'] From 2b5b6643d7080a730df5509c876a61a114d4a99d Mon Sep 17 00:00:00 2001 From: Neil Traft Date: Sat, 3 Dec 2022 21:13:12 -0500 Subject: [PATCH 7/8] Split config lists on all whitespace, not just a single space. --- neat/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/neat/config.py b/neat/config.py index 7605e4a0..2e34040a 100644 --- a/neat/config.py +++ b/neat/config.py @@ -27,7 +27,7 @@ def parse(self, section, config_parser): return config_parser.getfloat(section, self.name) if list == self.value_type: v = config_parser.get(section, self.name) - return v.split(" ") + return v.split() if str == self.value_type: return config_parser.get(section, self.name) @@ -64,7 +64,7 @@ def interpret(self, config_dict): if float == self.value_type: return float(value) if list == self.value_type: - return value.split(" ") + return value.split() except Exception: raise RuntimeError( f"Error interpreting config item '{self.name}' with value {value!r} and type {self.value_type}") From 885517336f81cc98309c35255121cc0ad028f091 Mon Sep 17 00:00:00 2001 From: Neil Traft Date: Wed, 23 Aug 2023 16:55:24 -0400 Subject: [PATCH 8/8] Disallow deletion/disabling of incoming connections. Ensure that all nodes have at least one incoming connection. --- neat/genome.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/neat/genome.py b/neat/genome.py index 3bbf12db..30f62ed2 100644 --- a/neat/genome.py +++ b/neat/genome.py @@ -296,6 +296,16 @@ def mutate(self, config): # Mutate connection genes. for cg in self.connections.values(): cg.mutate(config) + # Validate that all nodes have at least one active incoming connection. + for ni, ng in self.nodes.items(): + in_conns = [cg for ci, cg in self.connections.items() if ci[1] == ni] + if not in_conns: + # This node has no incoming connections. + raise RuntimeError(f"Node {ni} does not have any incoming connections.") + if not any([cg.enabled for cg in in_conns]): + # All the incoming connections for this node are disabled. Randomly pick one to re-enable. + cg = choice(in_conns) + cg.enabled = True # Mutate node genes (bias, response, etc.). for ng in self.nodes.values(): @@ -368,6 +378,15 @@ def mutate_add_connection(self, config): cg = self.create_connection(config, in_node, out_node) self.connections[cg.key] = cg + def okay_to_remove_connection(self, key): + out_node = key[1] + for k in self.connections.keys(): + if k != key and k[1] == out_node: + # There is another connection feeding into this node, so we're okay to remove. + return True + # This is the only input to this node, so it can't be removed or disabled. + return False + def mutate_delete_node(self, config): # Do nothing if there are no non-output nodes. available_nodes = [k for k in self.nodes if k not in config.output_keys] @@ -382,6 +401,19 @@ def mutate_delete_node(self, config): connections_to_delete.add(v.key) for key in connections_to_delete: + if key[1] != del_key and not self.okay_to_remove_connection(key): + # We are about to delete the last incoming connection to some other node. It needs to be replaced with + # another connection. Randomly choose one of the predecessor nodes and create a new connection which + # skips the deleted node. + from_node = choice([ni for ni, _ in connections_to_delete if ni != del_key]) + to_node = key[1] + # We do not need to check for cycles b/c we are just bridging a connection that already existed. + # WARNING: This could in theory connect two output nodes, something which is disallowed in + # `mutate_add_connection()`. But I'm not sure why this is disallowed anyway, and it should be rare. + cg = self.create_connection(config, from_node, to_node) + cg.enabled = True + self.connections[cg.key] = cg + del self.connections[key] del self.nodes[del_key] @@ -391,7 +423,8 @@ def mutate_delete_node(self, config): def mutate_delete_connection(self): if self.connections: key = choice(list(self.connections.keys())) - del self.connections[key] + if self.okay_to_remove_connection(key): + del self.connections[key] def distance(self, other, config): """