|
| 1 | +import os |
| 2 | + |
1 | 3 | import neat |
| 4 | +import pytest |
2 | 5 | from neat.activations import sigmoid_activation |
| 6 | +from neat.genes import DefaultConnectionGene, DefaultNodeGene |
3 | 7 |
|
4 | 8 |
|
5 | | -def test_basic(): |
6 | | - # Create a fully-connected network of two neurons with no external inputs. |
| 9 | +def _create_two_neuron_ctrnn(): |
| 10 | + """Create the 2-neuron autonomous CTRNN used in the demo-ctrnn example.""" |
| 11 | + # Fully-connected 2-neuron network with no external inputs. |
7 | 12 | node1_inputs = [(1, 0.9), (2, 0.2)] |
8 | 13 | node2_inputs = [(1, -0.2), (2, 0.9)] |
9 | 14 |
|
10 | | - node_evals = {1: neat.ctrnn.CTRNNNodeEval(0.01, sigmoid_activation, sum, -2.75 / 5.0, 1.0, node1_inputs), |
11 | | - 2: neat.ctrnn.CTRNNNodeEval(0.01, sigmoid_activation, sum, -1.75 / 5.0, 1.0, node2_inputs)} |
| 15 | + node_evals = { |
| 16 | + 1: neat.ctrnn.CTRNNNodeEval(0.01, sigmoid_activation, sum, -2.75 / 5.0, 1.0, node1_inputs), |
| 17 | + 2: neat.ctrnn.CTRNNNodeEval(0.01, sigmoid_activation, sum, -1.75 / 5.0, 1.0, node2_inputs), |
| 18 | + } |
12 | 19 |
|
13 | 20 | net = neat.ctrnn.CTRNN([], [1, 2], node_evals) |
14 | 21 |
|
15 | | - init1 = 0.0 |
16 | | - init2 = 0.0 |
| 22 | + # Start both neurons from 0.0, matching the example script. |
| 23 | + net.set_node_value(1, 0.0) |
| 24 | + net.set_node_value(2, 0.0) |
| 25 | + |
| 26 | + return net |
| 27 | + |
17 | 28 |
|
18 | | - net.set_node_value(1, init1) |
19 | | - net.set_node_value(2, init2) |
| 29 | +def test_basic_two_neuron_dynamics(): |
| 30 | + """Basic numerical behavior test for a hand-constructed 2-neuron CTRNN.""" |
| 31 | + net = _create_two_neuron_ctrnn() |
20 | 32 |
|
21 | | - times = [0.0] |
22 | | - outputs = [[init1, init2]] |
23 | | - for i in range(1250): |
24 | | - output = net.advance([], 0.002, 0.002) |
25 | | - times.append(net.time_seconds) |
| 33 | + outputs = [] |
| 34 | + num_steps = 1250 |
| 35 | + dt = 0.002 |
| 36 | + |
| 37 | + for _ in range(num_steps): |
| 38 | + output = net.advance([], dt, dt) |
26 | 39 | outputs.append(output) |
27 | 40 |
|
| 41 | + # Total simulated time should be close to 2.5 seconds (1250 * 0.002). |
| 42 | + assert abs(net.time_seconds - 2.5) < 1e-9 |
| 43 | + |
| 44 | + # All outputs should remain in [0, 1] due to sigmoid activation. |
| 45 | + for o in outputs: |
| 46 | + assert 0.0 <= o[0] <= 1.0 |
| 47 | + assert 0.0 <= o[1] <= 1.0 |
| 48 | + |
| 49 | + # Check specific reference values at selected timesteps to guard against |
| 50 | + # regressions in the CTRNN integration behavior. |
| 51 | + reference = { |
| 52 | + 0: (0.0, 0.0), |
| 53 | + 100: (0.3746852284, 0.8115273872), |
| 54 | + 500: (0.5634208426, 0.1952743492), |
| 55 | + 1149: (0.4092483263, 0.8024978195), |
| 56 | + 1249: (0.7531862678, 0.3112381247), |
| 57 | + } |
| 58 | + tol = 1e-6 |
| 59 | + for idx, (exp0, exp1) in reference.items(): |
| 60 | + o = outputs[idx] |
| 61 | + assert abs(o[0] - exp0) < tol |
| 62 | + assert abs(o[1] - exp1) < tol |
| 63 | + |
| 64 | + |
| 65 | +def test_reset_and_deterministic_trajectory(): |
| 66 | + """CTRNN.reset should zero state and trajectories should be deterministic.""" |
| 67 | + net = _create_two_neuron_ctrnn() |
| 68 | + |
| 69 | + def run(num_steps): |
| 70 | + seq = [] |
| 71 | + for _ in range(num_steps): |
| 72 | + seq.append(net.advance([], 0.002, 0.002)) |
| 73 | + return seq |
| 74 | + |
| 75 | + first_outputs = run(200) |
| 76 | + |
| 77 | + # State and time should have advanced. |
| 78 | + assert net.time_seconds > 0.0 |
| 79 | + assert any(any(abs(v) > 0.0 for v in layer.values()) for layer in net.values) |
| 80 | + |
| 81 | + # Reset should restore time and all stored values to zero. |
| 82 | + net.reset() |
| 83 | + assert net.time_seconds == 0.0 |
| 84 | + assert all(all(value == 0.0 for value in layer.values()) for layer in net.values) |
| 85 | + |
| 86 | + second_outputs = run(200) |
| 87 | + |
| 88 | + # Trajectories from the same initial conditions should match. |
| 89 | + assert len(first_outputs) == len(second_outputs) |
| 90 | + for o1, o2 in zip(first_outputs, second_outputs): |
| 91 | + for v1, v2 in zip(o1, o2): |
| 92 | + assert abs(v1 - v2) < 1e-12 |
| 93 | + |
| 94 | + |
| 95 | +def test_advance_input_validation(): |
| 96 | + """advance should enforce input length and raise RuntimeError on mismatch.""" |
| 97 | + # Simple CTRNN with a single input node feeding a single output node. |
| 98 | + node_inputs = [(0, 1.0)] |
| 99 | + node_evals = { |
| 100 | + 1: neat.ctrnn.CTRNNNodeEval(1.0, sigmoid_activation, sum, 0.0, 1.0, node_inputs), |
| 101 | + } |
| 102 | + net = neat.ctrnn.CTRNN([0], [1], node_evals) |
| 103 | + |
| 104 | + # Sanity check: correct-length input works. |
| 105 | + net.advance([0.5], 0.1, 0.1) |
| 106 | + |
| 107 | + # Too few inputs. |
| 108 | + with pytest.raises(RuntimeError, match="Expected 1 inputs, got 0"): |
| 109 | + net.advance([], 0.1, 0.1) |
| 110 | + |
| 111 | + # Too many inputs. |
| 112 | + with pytest.raises(RuntimeError, match="Expected 1 inputs, got 2"): |
| 113 | + net.advance([0.1, 0.2], 0.1, 0.1) |
| 114 | + |
| 115 | + |
| 116 | +def test_ctrnn_create_from_genome_prunes_and_builds_expected_structure(): |
| 117 | + """CTRNN.create should respect required_for_output and build correct node_evals.""" |
| 118 | + local_dir = os.path.dirname(__file__) |
| 119 | + config_path = os.path.join(local_dir, "test_configuration") |
| 120 | + config = neat.Config( |
| 121 | + neat.DefaultGenome, |
| 122 | + neat.DefaultReproduction, |
| 123 | + neat.DefaultSpeciesSet, |
| 124 | + neat.DefaultStagnation, |
| 125 | + config_path, |
| 126 | + ) |
| 127 | + |
| 128 | + genome = neat.DefaultGenome(1) |
| 129 | + |
| 130 | + # Manually create nodes: one output (0), one used hidden (1), one unused hidden (2). |
| 131 | + node0 = DefaultNodeGene(0) |
| 132 | + node0.bias = 0.1 |
| 133 | + node0.response = 1.0 |
| 134 | + node0.activation = "sigmoid" |
| 135 | + node0.aggregation = "sum" |
| 136 | + |
| 137 | + node1 = DefaultNodeGene(1) |
| 138 | + node1.bias = -0.2 |
| 139 | + node1.response = 1.0 |
| 140 | + node1.activation = "sigmoid" |
| 141 | + node1.aggregation = "sum" |
| 142 | + |
| 143 | + node2 = DefaultNodeGene(2) |
| 144 | + node2.bias = 1.5 |
| 145 | + node2.response = 1.0 |
| 146 | + node2.activation = "sigmoid" |
| 147 | + node2.aggregation = "sum" |
| 148 | + |
| 149 | + genome.nodes[0] = node0 |
| 150 | + genome.nodes[1] = node1 |
| 151 | + genome.nodes[2] = node2 |
| 152 | + |
| 153 | + # Connections: input -1 -> hidden 1, hidden 1 -> output 0. |
| 154 | + conn1_key = (-1, 1) |
| 155 | + conn1 = DefaultConnectionGene(conn1_key, innovation=0) |
| 156 | + conn1.weight = 0.5 |
| 157 | + conn1.enabled = True |
| 158 | + |
| 159 | + conn2_key = (1, 0) |
| 160 | + conn2 = DefaultConnectionGene(conn2_key, innovation=1) |
| 161 | + conn2.weight = 1.5 |
| 162 | + conn2.enabled = True |
| 163 | + |
| 164 | + genome.connections[conn1_key] = conn1 |
| 165 | + genome.connections[conn2_key] = conn2 |
| 166 | + |
| 167 | + time_constant = 0.01 |
| 168 | + net = neat.ctrnn.CTRNN.create(genome, config, time_constant) |
| 169 | + |
| 170 | + genome_config = config.genome_config |
| 171 | + |
| 172 | + # Input and output node lists should come from the genome config. |
| 173 | + assert net.input_nodes == genome_config.input_keys |
| 174 | + assert net.output_nodes == genome_config.output_keys |
| 175 | + |
| 176 | + # Only nodes that are actually required for the outputs should have node_evals. |
| 177 | + assert set(net.node_evals.keys()) == {0, 1} |
| 178 | + |
| 179 | + ne_hidden = net.node_evals[1] |
| 180 | + assert ne_hidden.time_constant == time_constant |
| 181 | + assert ne_hidden.bias == node1.bias |
| 182 | + assert ne_hidden.response == node1.response |
| 183 | + assert ne_hidden.activation is genome_config.activation_defs.get(node1.activation) |
| 184 | + assert ne_hidden.aggregation is genome_config.aggregation_function_defs.get(node1.aggregation) |
| 185 | + assert ne_hidden.links == [(-1, 0.5)] |
| 186 | + |
| 187 | + ne_output = net.node_evals[0] |
| 188 | + assert ne_output.time_constant == time_constant |
| 189 | + assert ne_output.bias == node0.bias |
| 190 | + assert ne_output.response == node0.response |
| 191 | + assert ne_output.activation is genome_config.activation_defs.get(node0.activation) |
| 192 | + assert ne_output.aggregation is genome_config.aggregation_function_defs.get(node0.aggregation) |
| 193 | + assert ne_output.links == [(1, 1.5)] |
| 194 | + |
28 | 195 |
|
29 | | -# |
30 | | -# |
31 | | -# def create_simple(): |
32 | | -# neurons = [Neuron('INPUT', 1, 0.0, 5.0, 'sigmoid'), |
33 | | -# Neuron('HIDDEN', 2, 0.0, 5.0, 'sigmoid'), |
34 | | -# Neuron('OUTPUT', 3, 0.0, 5.0, 'sigmoid')] |
35 | | -# connections = [(1, 2, 0.5), (1, 3, 0.5), (2, 3, 0.5)] |
36 | | -# map(repr, neurons) |
37 | | -# |
38 | | -# return Network(neurons, connections, 1) |
39 | | -# |
40 | | -# |
41 | | -# def test_manual_network(): |
42 | | -# net = create_simple() |
43 | | -# repr(net) |
44 | | -# str(net) |
45 | | -# net.serial_activate([0.04]) |
46 | | -# net.parallel_activate([0.04]) |
47 | | -# repr(net) |
48 | | -# str(net) |
49 | | -# |
50 | | -# |
51 | | -# def test_evolve(): |
52 | | -# test_values = [random.random() for _ in range(10)] |
53 | | -# |
54 | | -# def evaluate_genome(genomes): |
55 | | -# for g in genomes: |
56 | | -# net = ctrnn.create_phenotype(g) |
57 | | -# |
58 | | -# fitness = 0.0 |
59 | | -# for t in test_values: |
60 | | -# net.reset() |
61 | | -# output = net.serial_activate([t]) |
62 | | -# |
63 | | -# expected = t ** 2 |
64 | | -# |
65 | | -# error = output[0] - expected |
66 | | -# fitness -= error ** 2 |
67 | | -# |
68 | | -# g.fitness = fitness |
69 | | -# |
70 | | -# # Load the config file, which is assumed to live in |
71 | | -# # the same directory as this script. |
72 | | -# local_dir = os.path.dirname(__file__) |
73 | | -# config = Config(os.path.join(local_dir, 'ctrnn_config')) |
74 | | -# config.node_gene_type = ctrnn.CTNodeGene |
75 | | -# config.prob_mutate_time_constant = 0.1 |
76 | | -# config.checkpoint_time_interval = 0.1 |
77 | | -# config.checkpoint_gen_interval = 1 |
78 | | -# |
79 | | -# pop = population.Population(config) |
80 | | -# pop.run(evaluate_genome, 10) |
81 | | -# |
82 | | -# # Save the winner. |
83 | | -# print('Number of evaluations: {0:d}'.format(pop.total_evaluations)) |
84 | | -# winner = pop.statistics.best_genome() |
85 | | -# with open('winner_genome', 'wb') as f: |
86 | | -# pickle.dump(winner, f) |
87 | | -# |
88 | | -# repr(winner) |
89 | | -# str(winner) |
90 | | -# |
91 | | -# for g in winner.node_genes: |
92 | | -# repr(g) |
93 | | -# str(g) |
94 | | -# for g in winner.conn_genes: |
95 | | -# repr(g) |
96 | | -# str(g) |
97 | | -# |
98 | | -# |
99 | | -if __name__ == '__main__': |
100 | | - test_basic() |
101 | | -# test_evolve() |
102 | | -# test_manual_network() |
| 196 | +if __name__ == "__main__": |
| 197 | + # Allow running this module directly for quick manual checks. |
| 198 | + test_basic_two_neuron_dynamics() |
| 199 | + test_reset_and_deterministic_trajectory() |
| 200 | + test_advance_input_validation() |
| 201 | + test_ctrnn_create_from_genome_prunes_and_builds_expected_structure() |
0 commit comments