Skip to content

Commit 94b4258

Browse files
Add CTRNN changes writeup and move Lorenz docs to example
Write CTRNN-CHANGES.md explaining the per-node time constant change for academic users: v1.0 limitation, v2.0 fix, numerical stability analysis, quantitative results, and migration guide. Include PDF rendering via pandoc/xelatex. Move all Lorenz experiment documentation from repo root into examples/lorenz-ctrnn/docs/ to co-locate with the example code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 38a72f4 commit 94b4258

File tree

5 files changed

+204
-0
lines changed

5 files changed

+204
-0
lines changed
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Per-Node Time Constants in neat-python's CTRNN: What Changed and Why
2+
3+
## 1. Background
4+
5+
A Continuous-Time Recurrent Neural Network (CTRNN) models each node as a leaky integrator with state variable y_i and time constant tau_i. The state update under explicit Euler integration is:
6+
7+
y_i(t + dt) = y_i(t) + (dt / tau_i) * (-y_i(t) + f(bias_i + response_i * aggregation_j(w_ij * y_j)))
8+
9+
where f is the activation function, w_ij are connection weights, and dt is the integration timestep. The time constant tau_i controls how quickly node i responds to its inputs: small tau gives fast response (the node closely tracks its instantaneous input), while large tau gives slow response (the node integrates over a longer temporal window).
10+
11+
In the NEAT (NeuroEvolution of Augmenting Topologies) framework, the structure and parameters of the neural network are evolved simultaneously. For CTRNNs, the natural approach is to evolve tau_i as a per-node gene attribute alongside bias, response, activation function, and connection weights.
12+
13+
## 2. The v1.0 Limitation
14+
15+
In neat-python v1.0, the CTRNN implementation used a **single fixed time constant** shared by all nodes. The value was supplied as a parameter at network construction time:
16+
17+
```python
18+
# v1.0 API
19+
net = CTRNN.create(genome, config, time_constant)
20+
```
21+
22+
Every node in the resulting network used the same tau, regardless of its role in the network. This was an implementation simplification inherited from the original neat-python codebase, not a deliberate modeling choice.
23+
24+
The consequence is that the network cannot represent dynamics that require multiple timescales. Consider a system where some state variables change rapidly (period ~0.1 seconds) while others modulate slowly (period ~5 seconds). A CTRNN with heterogeneous time constants can assign fast tau to nodes tracking the rapid variables and slow tau to nodes integrating the slow ones. A CTRNN with a single fixed tau is forced to use the same temporal resolution everywhere, which is necessarily a compromise: too fast and the slow dynamics are not captured, too slow and the fast dynamics are smoothed out.
25+
26+
### 2.1 Quantitative Impact
27+
28+
The limitation was characterized using a Lorenz attractor prediction task, comparing neat-python v1.0 (fixed tau = 1.0) against the Julia NeatEvolution library (per-node evolved tau in [0.01, 5.0]) on the same NEAT configuration and fitness function. The task requires predicting the next state of the Lorenz system given the current state, using a CTRNN evolved by NEAT.
29+
30+
On the most diagnostic condition (single-output z prediction with pre-computed product inputs), the results were:
31+
32+
| Implementation | Pearson correlation (z) | Mean squared error |
33+
|---|---|---|
34+
| Julia (per-node tau) | 0.94 | 0.023 |
35+
| Python v1.0 (fixed tau = 1.0) | 0.51--0.61 | 0.15 |
36+
37+
The fixed time constant degraded correlation by approximately 40--45% in absolute terms and increased mean squared error by a factor of 6--7. This degradation was consistent across all experimental conditions tested: three input representations (base, pre-computed products, product aggregation) crossed with two output configurations (three-output and single-output), for a total of six conditions with six independent trials each. Full results are reported in `NEAT-PYTHON-LORENZ.md`.
38+
39+
The analysis concluded that the fixed time constant was the **dominant bottleneck** -- not a minor contributor, but the primary limiting factor -- and that the degradation was fundamental (not addressable by choosing a different fixed value of tau).
40+
41+
## 3. The v2.0 Change
42+
43+
### 3.1 Per-Node Evolvable Time Constant
44+
45+
In neat-python 2.0, `time_constant` is a per-node gene attribute of `DefaultNodeGene`, on the same footing as `bias`, `response`, `activation`, and `aggregation`. Each node in the genome carries its own tau_i, which is initialized from a configurable distribution and subject to mutation and crossover like any other numeric gene attribute.
46+
47+
The `CTRNN.create()` signature changed accordingly:
48+
49+
```python
50+
# v2.0 API
51+
net = CTRNN.create(genome, config)
52+
```
53+
54+
The time constant is no longer a construction parameter. Instead, the factory method reads `node.time_constant` from each node gene in the genome and assigns it to the corresponding `CTRNNNodeEval`. The CTRNN integration loop (the `advance` method) is unchanged; it already used `ne.time_constant` per node internally. The change is in where that value comes from: a caller-supplied scalar in v1.0, versus the evolved genome in v2.0.
55+
56+
### 3.2 Configuration
57+
58+
The time constant is configured through the standard NEAT config file using the same parameter naming convention as other `FloatAttribute` genes (bias, response, weight). For a CTRNN application, the relevant section of the config file would include:
59+
60+
```ini
61+
[DefaultGenome]
62+
time_constant_init_mean = 1.0
63+
time_constant_init_stdev = 0.5
64+
time_constant_init_type = gaussian
65+
time_constant_max_value = 5.0
66+
time_constant_min_value = 0.01
67+
time_constant_mutate_power = 0.1
68+
time_constant_mutate_rate = 0.5
69+
time_constant_replace_rate = 0.1
70+
```
71+
72+
### 3.3 Backward Compatibility for Non-CTRNN Configurations
73+
74+
The `time_constant` attribute is defined with **inert defaults**: `init_stdev = 0.0`, `mutate_rate = 0.0`, `mutate_power = 0.0`, `replace_rate = 0.0`. When no `time_constant_*` parameters appear in the config file, every node is initialized to `time_constant = init_mean = 1.0` and this value never changes. Feedforward and discrete-time recurrent networks do not read the time constant, so the attribute has no effect on their behavior.
75+
76+
To support this, the config parser was relaxed so that parameters with non-`None` defaults are used silently when absent from the config file (rather than raising an error, as in v1.0). This affects only the new `time_constant` parameters; all pre-existing parameters either have `default=None` (requiring explicit specification, as before) or are already present in existing config files.
77+
78+
### 3.4 Genetic Distance
79+
80+
The `DefaultNodeGene.distance()` method was updated to include the time constant:
81+
82+
d = |bias_1 - bias_2| + |response_1 - response_2| + |tau_1 - tau_2|
83+
84+
When time constants are not configured (all nodes have tau = 1.0 by default), this term contributes zero to the genetic distance, preserving the existing speciation behavior for non-CTRNN applications.
85+
86+
## 4. Numerical Stability Consideration
87+
88+
Evolving per-node time constants introduces a stability constraint that does not arise with a fixed time constant. The explicit Euler update:
89+
90+
y_i(t + dt) = y_i(t) + (dt / tau_i) * (-y_i(t) + z_i)
91+
92+
is stable only when dt / tau_i is of order 1 or smaller. When tau_i is much smaller than dt, the factor dt / tau_i becomes large and the integration oscillates with exponentially growing amplitude. For example, with dt = 0.1 and tau = 0.01, the factor dt / tau = 10. The state at each step is multiplied by approximately (1 - dt/tau) = -9, producing rapid divergence.
93+
94+
With a fixed time constant, the user chooses tau >= dt (or adjusts dt) and this issue does not arise. With evolved time constants, the evolutionary search explores the full range [tau_min, tau_max], and some genomes will inevitably have nodes where tau is small relative to the integration timestep.
95+
96+
The recommended approach is to handle this through the fitness function rather than by modifying the integration:
97+
98+
```python
99+
if any(math.isnan(v) or math.isinf(v) or abs(v) > 1e10 for v in output):
100+
return PENALTY_FITNESS
101+
```
102+
103+
This assigns a poor fitness to genomes that produce numerically unstable outputs, allowing natural selection to eliminate unstable time constant configurations. The alternative -- clamping tau or switching to an implicit integration scheme -- would either restrict the evolvable range or change the network dynamics in ways that may not be desirable.
104+
105+
In practice, selection pressure eliminates unstable configurations within the first few generations. The penalty is rarely triggered thereafter.
106+
107+
Users should be aware of this constraint when setting `time_constant_min_value`. A conservative rule of thumb is:
108+
109+
time_constant_min_value >= integration_timestep
110+
111+
though smaller values can work if the fitness function includes a stability guard as shown above.
112+
113+
## 5. Quantitative Improvement
114+
115+
The Lorenz attractor prediction task was repeated under the same conditions as the v1.0 baseline, with only the time constant treatment changed. Three independent trials per condition were run.
116+
117+
### 5.1 Single-Output Z Prediction
118+
119+
| Input mode | v1.0 z corr | v2.0 z corr | v1.0 MSE | v2.0 MSE |
120+
|---|---|---|---|---|
121+
| Base (x, y, z) | 0.32--0.44 | 0.64--0.66 | 0.16--0.17 | 0.107--0.109 |
122+
| Products (x, y, z, xy, xz, yz) | 0.51--0.61 | 0.78--0.84 | 0.15 | 0.056--0.073 |
123+
| Product aggregation | 0.29--0.40 | 0.57--0.65 | 0.16--0.17 | 0.108--0.127 |
124+
125+
Correlation improves by 50--80% (absolute) and mean squared error decreases by a factor of 1.3--2.7 across conditions.
126+
127+
### 5.2 Three-Output Prediction (x, y, z Simultaneously)
128+
129+
| Input mode | v1.0 best single-var corr | v2.0 best single-var corr | v1.0 MSE | v2.0 MSE |
130+
|---|---|---|---|---|
131+
| Base | x: 0.49--0.71 | x: 0.81--0.85 | 0.15--0.16 | 0.13--0.14 |
132+
| Products | x: 0.00--0.72 | x/z: 0.76--0.92 | 0.14--0.16 | 0.12--0.14 |
133+
| Product aggregation | x: 0.54--0.71 | x: 0.80--0.85 | 0.15--0.16 | 0.13--0.14 |
134+
135+
### 5.3 Comparison with Julia Reference Implementation
136+
137+
| Condition | Julia (per-node tau) | Python v2.0 (per-node tau) | Python v1.0 (fixed tau) |
138+
|---|---|---|---|
139+
| z-only base (correlation) | 0.83--0.86 | 0.64--0.66 | 0.32--0.44 |
140+
| z-only products (correlation) | 0.94 | 0.78--0.84 | 0.51--0.61 |
141+
| z-only products (MSE) | 0.023 | 0.056--0.073 | 0.15 |
142+
| 3-output x base (correlation) | 0.84--0.96 | 0.81--0.85 | 0.49--0.71 |
143+
144+
Version 2.0 closes approximately half the gap between v1.0 and the Julia reference in z-only mode, and nearly matches Julia for single-variable prediction in three-output mode. The remaining difference is attributable to other implementation differences between the two NEAT libraries (speciation algorithm details, crossover mechanics) rather than the time constant treatment.
145+
146+
Full trial-level results are reported in `NEAT-PYTHON2-LORENZ.md`.
147+
148+
## 6. Migration Guide
149+
150+
### 6.1 Code Changes Required
151+
152+
Any code that calls `CTRNN.create()` with a time constant argument must be updated:
153+
154+
```python
155+
# v1.0 (no longer valid)
156+
net = CTRNN.create(genome, config, time_constant=0.01)
157+
158+
# v2.0
159+
net = CTRNN.create(genome, config)
160+
```
161+
162+
The old call will produce: `TypeError: create() takes 2 positional arguments but 3 were given`.
163+
164+
### 6.2 Config File Changes Required
165+
166+
CTRNN config files should add `time_constant_*` parameters to the `[DefaultGenome]` section. To replicate the v1.0 behavior of a fixed time constant (for example, tau = 0.01 for all nodes), use:
167+
168+
```ini
169+
time_constant_init_mean = 0.01
170+
time_constant_init_stdev = 0.0
171+
time_constant_mutate_rate = 0.0
172+
time_constant_replace_rate = 0.0
173+
```
174+
175+
To enable evolution of per-node time constants, provide nonzero `init_stdev`, `mutate_rate`, and `mutate_power`, and set `min_value` and `max_value` to appropriate bounds for the problem's timescales.
176+
177+
### 6.3 Config Files That Do Not Use CTRNNs
178+
179+
No changes required. Feedforward and discrete-time recurrent network configurations work without modification. The time constant attribute uses inert defaults when not explicitly configured.
180+
181+
## 7. References
182+
183+
- Beer, R. D. (1995). On the dynamics of small continuous-time recurrent neural networks. *Adaptive Behavior*, 3(4), 469--509.
184+
- Stanley, K. O. & Miikkulainen, R. (2002). Evolving neural networks through augmenting topologies. *Evolutionary Computation*, 10(2), 99--127.
185+
- `NEAT-PYTHON-LORENZ.md` -- v1.0 experimental results with fixed time constant.
186+
- `NEAT-PYTHON2-LORENZ.md` -- v2.0 experimental results with per-node time constants.
66.5 KB
Binary file not shown.

NEAT-PYTHON-LORENZ.md renamed to examples/lorenz-ctrnn/docs/NEAT-PYTHON-LORENZ.md

File renamed without changes.

NEAT-PYTHON2-LORENZ.md renamed to examples/lorenz-ctrnn/docs/NEAT-PYTHON2-LORENZ.md

File renamed without changes.

progress-20260302.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,21 @@ Alternative considered: clamping tau to ensure dt/tau < 1 (i.e., tau_min >= 0.1)
147147
### Files Created/Modified
148148
- `NEAT-PYTHON2-LORENZ.md` — Full experimental writeup with all trial-level results, analysis, and cross-version comparison
149149
- `examples/lorenz-ctrnn/evolve_lorenz_ctrnn.py` — Added overflow guard for numerically unstable CTRNN outputs (large finite values from dt/tau >> 1)
150+
151+
---
152+
153+
## CTRNN changes writeup and docs reorganization
154+
155+
### Summary
156+
Wrote `CTRNN-CHANGES.md`, a formal document explaining the per-node time constant change for academic users: original v1.0 behavior, the problem it caused, the v2.0 fix, numerical stability considerations, quantitative improvement, and migration guide. Generated a PDF version (`CTRNN-CHANGES.pdf`) via pandoc + xelatex with LaTeX math, booktabs tables, syntax-highlighted code, and running headers.
157+
158+
Moved all Lorenz experiment documentation from the repo root into `examples/lorenz-ctrnn/docs/`:
159+
- `NEAT-PYTHON-LORENZ.md` — v1.0 experiment results (moved)
160+
- `NEAT-PYTHON2-LORENZ.md` — v2.0 experiment results (moved)
161+
- `CTRNN-CHANGES.md` — formal writeup of per-node time constant change (new)
162+
- `CTRNN-CHANGES.pdf` — PDF rendering of the above (new)
163+
164+
### Key Decisions
165+
- **Formal tone for CTRNN-CHANGES.md:** targeted at academic users who need to understand exactly what changed in the CTRNN implementation and why. Includes the state update equation in proper notation, the numerical stability constraint (dt/tau < ~1 for explicit Euler), and a migration guide with code examples.
166+
- **PDF via pandoc + xelatex:** used a separate LaTeX preamble file for fancyhdr, titlesec, booktabs styling rather than embedding complex multi-line strings in YAML front matter (which pandoc 3.1.3 struggles with).
167+
- **Docs directory under lorenz-ctrnn example:** keeps the experiment-specific documentation co-located with the example code rather than cluttering the repo root.

0 commit comments

Comments
 (0)