|
| 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. |
0 commit comments