Skip to content

Commit 8fe2d5b

Browse files
Add per-node time constants to CTRNN, bump version to 2.0.0
time_constant is now an evolvable per-node attribute of DefaultNodeGene instead of a fixed parameter passed to CTRNN.create(). This matches the Julia NeatEvolution implementation and removes the dominant bottleneck identified in the Lorenz CTRNN experiment. Breaking change: CTRNN.create(genome, config, time_constant) is now CTRNN.create(genome, config). Config defaults policy relaxed so that parameters with non-None defaults are used silently when missing from config, allowing feedforward/recurrent configs to work unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7435c8c commit 8fe2d5b

18 files changed

Lines changed: 128 additions & 50 deletions

docs/module_summaries.rst

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -653,16 +653,19 @@ ctrnn
653653
.. versionchanged:: 0.92
654654
Exception changed to more-specific RuntimeError.
655655

656-
.. py:staticmethod:: create(genome, config, time_constant)
656+
.. py:staticmethod:: create(genome, config)
657657
658658
Receives a genome and returns its phenotype (a :py:class:`CTRNN` with :py:class:`CTRNNNodeEval` :term:`nodes <node>`).
659+
Each node's time constant is read from the genome's node gene ``time_constant`` attribute.
659660

660661
:param genome: A :py:class:`genome.DefaultGenome` instance.
661662
:type genome: :datamodel:`instance <index-48>`
662663
:param config: A :py:class:`config.Config` instance.
663664
:type config: :datamodel:`instance <index-48>`
664-
:param time_constant: Used for the :py:class:`CTRNNNodeEval` initializations.
665-
:type time_constant: :pytypes:`float <typesnumeric>`
665+
666+
.. versionchanged:: 2.0.0
667+
The ``time_constant`` parameter was removed. Time constants are now per-node
668+
attributes evolved as part of the genome.
666669

667670

668671
.. py:module:: parallel

docs/network_export.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,8 @@ Export Different Network Types
332332
rnn = neat.nn.RecurrentNetwork.create(genome, config)
333333
export_network_json(rnn, 'recurrent.json')
334334
335-
# CTRNN
336-
ctrnn = neat.ctrnn.CTRNN.create(genome, config, time_constant=1.0)
335+
# CTRNN (time constants are per-node, read from genome)
336+
ctrnn = neat.ctrnn.CTRNN.create(genome, config)
337337
export_network_json(ctrnn, 'ctrnn.json')
338338
339339
# IZNN (requires IZGenome)

examples/lorenz-ctrnn/README.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,9 @@ Each CTRNN receives the normalized current state as input and predicts the norma
5151

5252
## Differences from the Julia version
5353

54-
The Julia NeatEvolution CTRNN implementation evolves **per-node time constants** as part of the genome, with each node having its own time constant in the range [0.01, 5.0]. This allows different nodes to operate at different timescales -- some responding quickly (small tau) and others integrating slowly (large tau).
54+
neat-python now evolves **per-node time constants** as part of the genome, matching the Julia NeatEvolution implementation. Each node has its own time constant (tau) in the range [0.01, 5.0], allowing different nodes to operate at different timescales.
5555

56-
neat-python's CTRNN implementation uses a **single fixed time constant** for all nodes, set at network creation time. This example uses tau=1.0 (matching the Julia version's initialization mean). This is a genuine capability difference: the Python CTRNN cannot learn different timescales for different nodes.
57-
58-
Additionally, the Julia implementation separates disjoint and excess gene coefficients for the genetic distance calculation, while neat-python combines them into a single `compatibility_disjoint_coefficient`.
56+
The Julia implementation separates disjoint and excess gene coefficients for the genetic distance calculation, while neat-python combines them into a single `compatibility_disjoint_coefficient`.
5957

6058
## Visualization (optional)
6159

@@ -73,7 +71,7 @@ pip install matplotlib
7371
- **3 or 6 inputs** (with `--mode products`), **3 or 1 outputs** (with `--z-only`)
7472
- **No initial hidden nodes** -- NEAT discovers the topology
7573
- **feed_forward = False** -- required for CTRNN recurrence
76-
- **time_constant = 1.0** -- fixed for all nodes (see differences above)
74+
- **per-node time constants** -- evolved, initialized from N(1.0, 0.5), range [0.01, 5.0]
7775
- **bias/weight range [-5, 5]** -- narrower bounds for tanh activation
7876
- **max_stagnation = 30** -- patient stagnation threshold for a harder task
7977
- **10x subsampling** -- ensures per-step state change is large enough that networks must learn dynamics, not just copy input

examples/lorenz-ctrnn/config-ctrnn

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@
44
# Output: predicted normalized (x, y, z) at time t+1
55
#
66
# Ported from the Julia NeatEvolution lorenz_ctrnn example.
7-
# Key difference: neat-python's CTRNN uses a single fixed time constant for
8-
# all nodes (set in the script), whereas the Julia version evolves per-node
9-
# time constants. The time_constant_* parameters from the Julia config are
10-
# therefore omitted here.
7+
# Per-node time constants are evolved as part of the genome, matching the
8+
# Julia implementation.
119

1210
[NEAT]
1311
fitness_criterion = max
@@ -65,6 +63,16 @@ response_mutate_power = 0.0
6563
response_mutate_rate = 0.0
6664
response_replace_rate = 0.0
6765

66+
# Per-node time constants (matching Julia NeatEvolution config)
67+
time_constant_init_mean = 1.0
68+
time_constant_init_stdev = 0.5
69+
time_constant_init_type = gaussian
70+
time_constant_max_value = 5.0
71+
time_constant_min_value = 0.01
72+
time_constant_mutate_power = 0.1
73+
time_constant_mutate_rate = 0.5
74+
time_constant_replace_rate = 0.1
75+
6876
# Weight parameters
6977
weight_init_mean = 0.0
7078
weight_init_stdev = 2.0

examples/lorenz-ctrnn/evolve_lorenz_ctrnn.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,6 @@
4646
N_GENERATIONS = 300
4747
PENALTY_FITNESS = -10.0
4848

49-
# neat-python's CTRNN uses a single fixed time constant for all nodes.
50-
# The Julia version evolves per-node time constants (mean=1.0, range [0.01, 5.0]).
51-
# We use the Julia mean as the fixed value.
52-
TIME_CONSTANT = 1.0
53-
5449
# ---------------------------------------------------------------------------
5550
# Module-level globals for parallel evaluation
5651
# Set by main() before the ParallelEvaluator pool is created so that forked
@@ -208,7 +203,7 @@ def eval_genome(genome, config):
208203
209204
Returns negative mean squared error (evolution maximizes toward zero).
210205
"""
211-
net = neat.ctrnn.CTRNN.create(genome, config, TIME_CONSTANT)
206+
net = neat.ctrnn.CTRNN.create(genome, config)
212207
net.reset()
213208

214209
n_steps = len(_train_inputs)
@@ -238,7 +233,7 @@ def evaluate_on_test(winner, config, test_input, target_rows):
238233
Returns (mse, predictions) where predictions is a list of output vectors
239234
in normalized space.
240235
"""
241-
net = neat.ctrnn.CTRNN.create(winner, config, TIME_CONSTANT)
236+
net = neat.ctrnn.CTRNN.create(winner, config)
242237
net.reset()
243238

244239
n_steps = len(test_input) - 1
@@ -452,7 +447,7 @@ def main():
452447
print(f'After subsampling: {TRAIN_STEPS // SUBSAMPLE} train points, '
453448
f'{TEST_STEPS // SUBSAMPLE} test points')
454449
print(f'Evolution: {N_GENERATIONS} generations, pop_size=150')
455-
print(f'CTRNN: time_constant={TIME_CONSTANT} (fixed, all nodes)')
450+
print(f'CTRNN: per-node time constants (evolved)')
456451
print(f'Workers: {num_workers}')
457452
print()
458453

examples/single-pole-balancing/config-ctrnn

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ response_mutate_power = 0.0
3939
response_max_value = 30.0
4040
response_min_value = -30.0
4141

42+
# Per-node time constants (preserves original fixed value as init_mean)
43+
time_constant_init_mean = 0.01
44+
time_constant_init_stdev = 0.0
45+
time_constant_init_type = gaussian
46+
time_constant_max_value = 1.0
47+
time_constant_min_value = 0.001
48+
time_constant_mutate_power = 0.005
49+
time_constant_mutate_rate = 0.3
50+
time_constant_replace_rate = 0.05
51+
4252
weight_max_value = 30
4353
weight_min_value = -30
4454
weight_init_mean = 0.0

examples/single-pole-balancing/evolve-ctrnn.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,11 @@
1212

1313
runs_per_net = 5
1414
simulation_seconds = 60.0
15-
time_const = cart_pole.CartPole.time_step
1615

1716

1817
# Use the CTRNN network phenotype and the discrete actuator force function.
1918
def eval_genome(genome, config):
20-
net = neat.ctrnn.CTRNN.create(genome, config, time_const)
19+
net = neat.ctrnn.CTRNN.create(genome, config)
2120

2221
fitnesses = []
2322
for runs in range(runs_per_net):
@@ -28,7 +27,7 @@ def eval_genome(genome, config):
2827
fitness = 0.0
2928
while sim.t < simulation_seconds:
3029
inputs = sim.get_scaled_state()
31-
action = net.advance(inputs, time_const, time_const)
30+
action = net.advance(inputs, sim.time_step, sim.time_step)
3231

3332
# Apply action to the simulated cart-pole
3433
force = cart_pole.discrete_actuator_force(action)

examples/single-pole-balancing/test-ctrnn.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
sim = CartPole()
2828

29-
net = neat.ctrnn.CTRNN.create(c, config, sim.time_step)
29+
net = neat.ctrnn.CTRNN.create(c, config)
3030

3131
print()
3232
print("Initial conditions:")

neat/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""A NEAT (NeuroEvolution of Augmenting Topologies) implementation"""
22

3-
__version__ = '1.1.0'
3+
__version__ = '2.0.0'
44

55
import neat.nn as nn
66
import neat.ctrnn as ctrnn

neat/config.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,7 @@ def interpret(self, config_dict: Mapping[str, Any], section_name: Optional[str]
7474
else:
7575
raise RuntimeError(f"Missing required configuration item: '{self.name}'")
7676
else:
77-
# For v1.0: Require all parameters to be explicitly specified
78-
section_info = f" in [{section_name}] section" if section_name else ""
79-
raise RuntimeError(
80-
f"Missing required configuration item{section_info}: '{self.name}'\n"
81-
f"This parameter must be explicitly specified in your configuration file.\n"
82-
f"Suggested value: {self.name} = {self.default}"
83-
)
77+
return self.default
8478

8579
try:
8680
if str == self.value_type:

0 commit comments

Comments
 (0)