|
| 1 | +# Evolving CTRNNs for Lorenz System Prediction Using neat-python 2.0 |
| 2 | + |
| 3 | +## 1. Overview |
| 4 | + |
| 5 | +This document reports the results of repeating the Lorenz CTRNN experiment with neat-python 2.0, which introduces **per-node evolvable time constants**. The v1.0 experiment (`NEAT-PYTHON-LORENZ.md`) identified the fixed time constant as the dominant bottleneck, degrading correlation by ~2x and MSE by 2--3x compared to the Julia NeatEvolution implementation. Version 2.0 removes this limitation. |
| 6 | + |
| 7 | +The same 6-condition protocol is used: base/products/product-agg x 3-output/z-only, with 3 independent trials per condition. All other NEAT parameters are unchanged from v1.0. |
| 8 | + |
| 9 | +## 2. What Changed in v2.0 |
| 10 | + |
| 11 | +### 2.1 Per-Node Time Constants |
| 12 | + |
| 13 | +`time_constant` is now a per-node evolvable attribute of `DefaultNodeGene`. Each node has its own tau, initialized from a Gaussian distribution and subject to mutation. The `CTRNN.create()` method no longer accepts a fixed time constant parameter; it reads tau from each node's genome. |
| 14 | + |
| 15 | +For this experiment, the time constant configuration matches the Julia NeatEvolution settings: |
| 16 | + |
| 17 | +| Parameter | Value | |
| 18 | +|-----------|-------| |
| 19 | +| `time_constant_init_mean` | 1.0 | |
| 20 | +| `time_constant_init_stdev` | 0.5 | |
| 21 | +| `time_constant_min_value` | 0.01 | |
| 22 | +| `time_constant_max_value` | 5.0 | |
| 23 | +| `time_constant_mutate_rate` | 0.5 | |
| 24 | +| `time_constant_mutate_power` | 0.1 | |
| 25 | +| `time_constant_replace_rate` | 0.1 | |
| 26 | + |
| 27 | +### 2.2 Numerical Stability Guard |
| 28 | + |
| 29 | +With per-node time constants, some nodes can have small tau values (near 0.01). When the data timestep dt = 0.1 and tau = 0.01, the Euler update factor dt/tau = 10, which drives the explicit Euler integration into instability: state values oscillate with exponentially growing amplitude, producing large finite values that overflow float64 when squared in the fitness function. |
| 30 | + |
| 31 | +The v1.0 NaN/Inf guard was extended to also catch large finite values: |
| 32 | + |
| 33 | +```python |
| 34 | +if any(math.isnan(v) or math.isinf(v) or abs(v) > 1e10 for v in output): |
| 35 | + return PENALTY_FITNESS |
| 36 | +``` |
| 37 | + |
| 38 | +Genomes that produce numerically unstable outputs are assigned the penalty fitness (-10.0), allowing evolution to select against unstable time constant configurations. This is the correct evolutionary approach: rather than clamping or modifying the integration, let selection pressure eliminate configurations where dt/tau is too large. |
| 39 | + |
| 40 | +### 2.3 All Other Parameters Unchanged |
| 41 | + |
| 42 | +Population size (150), generations (300), activation functions (tanh, sigmoid, relu), weight/bias ranges ([-5, 5]), speciation parameters, and all other NEAT settings are identical to v1.0. |
| 43 | + |
| 44 | +## 3. Results |
| 45 | + |
| 46 | +### 3.1 Three-Output Mode (predicting x, y, z simultaneously) |
| 47 | + |
| 48 | +Individual trial results: |
| 49 | + |
| 50 | +| Mode | Trial | x corr | y corr | z corr | Test MSE | Nodes | Time | |
| 51 | +|------|-------|--------|--------|--------|----------|-------|------| |
| 52 | +| base | 1 | 0.8181 | 0.0000 | 0.0000 | 0.135 | 5 | 25.8s | |
| 53 | +| base | 2 | 0.8469 | 0.0000 | 0.0000 | 0.132 | 9 | 36.0s | |
| 54 | +| base | 3 | 0.8102 | 0.0000 | 0.0000 | 0.136 | 6 | 32.9s | |
| 55 | +| products | 1 | 0.0000 | 0.0000 | 0.7597 | 0.142 | 8 | 35.6s | |
| 56 | +| products | 2 | 0.0000 | 0.0000 | 0.7833 | 0.139 | 9 | 37.2s | |
| 57 | +| products | 3 | 0.9158 | 0.0000 | 0.0000 | 0.124 | 6 | 33.6s | |
| 58 | +| product-agg | 1 | 0.7952 | 0.0000 | 0.0000 | 0.137 | 3 | 26.3s | |
| 59 | +| product-agg | 2 | 0.8488 | 0.0000 | 0.0000 | 0.132 | 11 | 33.5s | |
| 60 | +| product-agg | 3 | 0.8309 | 0.0000 | 0.0000 | 0.136 | 6 | 31.0s | |
| 61 | + |
| 62 | +Summary ranges: |
| 63 | + |
| 64 | +| Mode | x corr | y corr | z corr | Test MSE | Nodes | Time | |
| 65 | +|------|--------|--------|--------|----------|-------|------| |
| 66 | +| base | 0.81--0.85 | 0.00 | 0.00 | 0.13--0.14 | 5--9 | 26--36s | |
| 67 | +| products | 0.00--0.92 | 0.00 | 0.00--0.78 | 0.12--0.14 | 6--9 | 34--37s | |
| 68 | +| product-agg | 0.80--0.85 | 0.00 | 0.00 | 0.13--0.14 | 3--11 | 26--34s | |
| 69 | + |
| 70 | +### 3.2 Z-Only Mode (predicting z alone) |
| 71 | + |
| 72 | +Individual trial results: |
| 73 | + |
| 74 | +| Mode | Trial | z corr | Test MSE | Nodes | Time | |
| 75 | +|------|-------|--------|----------|-------|------| |
| 76 | +| base | 1 | 0.6557 | 0.107 | 9 | 31.6s | |
| 77 | +| base | 2 | 0.6495 | 0.108 | 6 | 32.4s | |
| 78 | +| base | 3 | 0.6404 | 0.109 | 10 | 29.0s | |
| 79 | +| products | 1 | 0.7790 | 0.073 | 1 | 25.5s | |
| 80 | +| products | 2 | 0.7846 | 0.071 | 1 | 29.5s | |
| 81 | +| products | 3 | 0.8359 | 0.056 | 2 | 29.0s | |
| 82 | +| product-agg | 1 | 0.6536 | 0.111 | 4 | 28.5s | |
| 83 | +| product-agg | 2 | 0.6515 | 0.108 | 4 | 30.7s | |
| 84 | +| product-agg | 3 | 0.5696 | 0.127 | 6 | 31.8s | |
| 85 | + |
| 86 | +Summary ranges: |
| 87 | + |
| 88 | +| Mode | z corr | Test MSE | Nodes | Time | |
| 89 | +|------|--------|----------|-------|------| |
| 90 | +| base | 0.64--0.66 | 0.107--0.109 | 6--10 | 29--32s | |
| 91 | +| products | 0.78--0.84 | 0.056--0.073 | 1--2 | 26--30s | |
| 92 | +| product-agg | 0.57--0.65 | 0.108--0.127 | 4--6 | 29--32s | |
| 93 | + |
| 94 | +## 4. Comparison with v1.0 (Fixed Time Constant) |
| 95 | + |
| 96 | +### 4.1 Z-Only Mode |
| 97 | + |
| 98 | +| Mode | v1.0 z corr | v2.0 z corr | v1.0 MSE | v2.0 MSE | |
| 99 | +|------|-------------|-------------|----------|----------| |
| 100 | +| base | 0.32--0.44 | 0.64--0.66 | 0.16--0.17 | 0.107--0.109 | |
| 101 | +| products | 0.51--0.61 | 0.78--0.84 | 0.15 | 0.056--0.073 | |
| 102 | +| product-agg | 0.29--0.40 | 0.57--0.65 | 0.16--0.17 | 0.108--0.127 | |
| 103 | + |
| 104 | +Per-node time constants improve z-only correlation by 50--80% across all conditions, and reduce MSE by 30--60%. |
| 105 | + |
| 106 | +### 4.2 Three-Output Mode |
| 107 | + |
| 108 | +| Mode | v1.0 best single corr | v2.0 best single corr | v1.0 MSE | v2.0 MSE | |
| 109 | +|------|----------------------|----------------------|----------|----------| |
| 110 | +| base | x: 0.49--0.71 | x: 0.81--0.85 | 0.15--0.16 | 0.13--0.14 | |
| 111 | +| products | x: 0.00--0.72 | x/z: 0.76--0.92 | 0.14--0.16 | 0.12--0.14 | |
| 112 | +| product-agg | x: 0.54--0.71 | x: 0.80--0.85 | 0.15--0.16 | 0.13--0.14 | |
| 113 | + |
| 114 | +The best single-variable correlation in 3-output mode improves substantially. In v1.0, evolution typically learned a weak approximation of x (0.49--0.72); in v2.0, the learned variable reaches 0.80--0.92 correlation. |
| 115 | + |
| 116 | +### 4.3 Comparison with Julia NeatEvolution |
| 117 | + |
| 118 | +| Condition | Julia | Python v1.0 | Python v2.0 | |
| 119 | +|-----------|-------|-------------|-------------| |
| 120 | +| z-only base | 0.83--0.86 | 0.32--0.44 | 0.64--0.66 | |
| 121 | +| z-only products | 0.94 | 0.51--0.61 | 0.78--0.84 | |
| 122 | +| z-only product-agg | 0.94 | 0.29--0.40 | 0.57--0.65 | |
| 123 | +| 3-out x (base) | 0.84--0.96 | 0.49--0.71 | 0.81--0.85 | |
| 124 | +| 3-out x (products) | 0.84--0.96 | 0.00--0.72 | 0.00--0.92 | |
| 125 | + |
| 126 | +Python v2.0 closes roughly half the gap to Julia in z-only mode, and nearly matches Julia for x prediction in 3-output mode. The remaining gap is discussed in Section 6. |
| 127 | + |
| 128 | +## 5. Analysis |
| 129 | + |
| 130 | +### 5.1 Per-Node Time Constants Provide Major Improvement |
| 131 | + |
| 132 | +The hypothesis from the v1.0 writeup -- that the fixed time constant was the dominant bottleneck -- is confirmed. Every condition improves substantially: |
| 133 | + |
| 134 | +- **z-only base**: correlation 0.32--0.44 -> 0.64--0.66 (approximately +70%) |
| 135 | +- **z-only products**: correlation 0.51--0.61 -> 0.78--0.84 (approximately +50%), MSE 0.15 -> 0.056--0.073 (approximately 2--3x improvement) |
| 136 | +- **z-only product-agg**: correlation 0.29--0.40 -> 0.57--0.65 (approximately +65%) |
| 137 | + |
| 138 | +The z-only products condition shows the most dramatic MSE improvement: the best v2.0 trial achieves MSE 0.056, which is within the range of Julia's base condition (0.055--0.069), despite Julia's products condition achieving 0.023. |
| 139 | + |
| 140 | +### 5.2 Fitness Dilution Remains the Dominant Effect in 3-Output Mode |
| 141 | + |
| 142 | +In 3-output mode, every v2.0 trial learns exactly one variable well (correlation 0.76--0.92) while the other two remain at zero. This is the same fitness dilution pattern seen in v1.0 and Julia: averaging MSE across three outputs means that evolution can reduce total error by improving one output to track x's oscillations (or occasionally z's), while the other two outputs learn the unconditional mean. |
| 143 | + |
| 144 | +The pattern of which variable gets learned varies: |
| 145 | +- **base and product-agg**: consistently learn x (all 6 trials) |
| 146 | +- **products**: learns z in 2/3 trials, x in 1/3 trial |
| 147 | + |
| 148 | +This makes sense. In base mode, x is the easiest variable (largest relative variance in the normalized data, most autocorrelated). Products mode provides xy and xz as additional inputs, which are directly informative about z (since dz/dt = xy - bz), making z suddenly accessible. |
| 149 | + |
| 150 | +### 5.3 Product Inputs Help, Product Aggregation Partially Recovers |
| 151 | + |
| 152 | +In z-only mode, the ranking is: products > base approximately product-agg, the same qualitative ordering as v1.0. However, the gap between product-agg and base has narrowed: |
| 153 | + |
| 154 | +| v1.0 | v2.0 | |
| 155 | +|------|------| |
| 156 | +| products: 0.51--0.61 | products: 0.78--0.84 | |
| 157 | +| base: 0.32--0.44 | base: 0.64--0.66 | |
| 158 | +| product-agg: 0.29--0.40 | product-agg: 0.57--0.65 | |
| 159 | + |
| 160 | +In v1.0, product-agg performed *worse* than base (0.29--0.40 vs 0.32--0.44). In v2.0, product-agg performs comparably to base (0.57--0.65 vs 0.64--0.66), though still worse. Per-node time constants partially rehabilitate product aggregation by providing the compensating temporal expressiveness that multiplicative nodes need (as hypothesized in the v1.0 analysis), but the benefit is modest. |
| 161 | + |
| 162 | +In Julia, product-agg matches products exactly (both 0.94). The fact that Python v2.0 product-agg (0.57--0.65) still substantially underperforms products (0.78--0.84) indicates that other differences between the Python and Julia implementations affect product-agg more than they affect the other modes. |
| 163 | + |
| 164 | +### 5.4 Network Complexity |
| 165 | + |
| 166 | +Evolved network sizes are comparable to v1.0: |
| 167 | + |
| 168 | +| Condition | v1.0 nodes | v2.0 nodes | |
| 169 | +|-----------|-----------|-----------| |
| 170 | +| z-only base | 2--10 | 6--10 | |
| 171 | +| z-only products | 1--5 | 1--2 | |
| 172 | +| z-only product-agg | 4--8 | 4--6 | |
| 173 | +| 3-out base | 3--6 | 5--9 | |
| 174 | + |
| 175 | +The z-only products networks are notably minimal: 1--2 nodes in v2.0 (matching Julia's 1--3 nodes). With the bilinear input xy provided directly, the z prediction problem is essentially linear, and evolution discovers this. |
| 176 | + |
| 177 | +### 5.5 Numerical Stability and the dt/tau Constraint |
| 178 | + |
| 179 | +The overflow guard (Section 2.2) was essential. Without it, all 6 initial runs crashed. The root cause is fundamental: explicit Euler integration of the CTRNN state update requires dt/tau < ~1 for stability, but with tau_min = 0.01 and dt = 0.1, dt/tau can reach 10. |
| 180 | + |
| 181 | +The evolutionary approach (penalize unstable genomes) is preferable to engineering fixes (clamping tau or using implicit integration) because: |
| 182 | +1. It lets evolution discover the useful range of time constants naturally. |
| 183 | +2. It avoids introducing bias into the dynamics. |
| 184 | +3. It adds no computational overhead to stable genomes. |
| 185 | + |
| 186 | +In practice, the penalty is applied rarely after the first few generations, as selection pressure quickly eliminates genomes with very small time constants relative to the data timestep. |
| 187 | + |
| 188 | +## 6. Remaining Gap to Julia |
| 189 | + |
| 190 | +Python v2.0 closes roughly half the gap to Julia. The remaining differences likely stem from: |
| 191 | + |
| 192 | +1. **Speciation and reproduction details**: Julia NeatEvolution and neat-python implement NEAT's speciation algorithm slightly differently (separate disjoint/excess coefficients vs combined, different stagnation handling). These affect the evolutionary dynamics and population diversity. |
| 193 | + |
| 194 | +2. **300 generations may be insufficient**: The Julia results were also at 300 generations, but Julia's faster per-evaluation throughput means the evolutionary search may traverse more of the fitness landscape per generation (larger effective population due to less stagnation). |
| 195 | + |
| 196 | +3. **Product-agg gap**: The largest remaining gap is in product-agg (v2.0: 0.57--0.65 vs Julia: 0.94). This suggests that neat-python's evolutionary operators have difficulty optimizing multiplicative nodes even with per-node time constants. The crossover and mutation dynamics for product-aggregation nodes may interact unfavorably with Python's speciation scheme. |
| 197 | + |
| 198 | +4. **Integration accuracy**: Both implementations use explicit Euler, but the penalty guard in the Python version may eliminate some genome configurations that the Julia version can evaluate (if Julia handles the numerical instability differently). |
| 199 | + |
| 200 | +## 7. Summary |
| 201 | + |
| 202 | +### 7.1 Key Findings |
| 203 | + |
| 204 | +1. **Per-node time constants improve all conditions substantially**, confirming the v1.0 hypothesis. Z-only correlation improves 50--80%, MSE improves 30--60%. |
| 205 | + |
| 206 | +2. **The improvement is largest for products z-only** (MSE: 0.15 -> 0.056--0.073), which approaches Julia's base-condition performance. |
| 207 | + |
| 208 | +3. **Fitness dilution remains the primary obstacle** in 3-output mode. Per-node time constants do not help here because the problem is in the fitness signal, not the network's expressiveness. |
| 209 | + |
| 210 | +4. **Product aggregation is partially rehabilitated** by per-node time constants (no longer worse than base, as in v1.0) but still substantially underperforms pre-computed product inputs. |
| 211 | + |
| 212 | +5. **3-output x correlation now matches Julia** (v2.0: 0.81--0.92 vs Julia: 0.84--0.96), demonstrating that per-node time constants were the primary bottleneck for single-variable prediction accuracy. |
| 213 | + |
| 214 | +### 7.2 Cross-Version Summary Table |
| 215 | + |
| 216 | +**Z-only mode (best condition: products):** |
| 217 | + |
| 218 | +| Implementation | z correlation | z MSE | |
| 219 | +|---------------|--------------|-------| |
| 220 | +| Julia NeatEvolution | 0.94 | 0.023 | |
| 221 | +| Python v2.0 (per-node tau) | 0.78--0.84 | 0.056--0.073 | |
| 222 | +| Python v1.0 (fixed tau=1.0) | 0.51--0.61 | 0.15 | |
| 223 | + |
| 224 | +**3-output mode (best single-variable correlation):** |
| 225 | + |
| 226 | +| Implementation | Best corr | MSE | |
| 227 | +|---------------|-----------|-----| |
| 228 | +| Julia NeatEvolution | x: 0.84--0.96 | 0.10--0.12 | |
| 229 | +| Python v2.0 (per-node tau) | x: 0.81--0.92 | 0.12--0.14 | |
| 230 | +| Python v1.0 (fixed tau=1.0) | x: 0.49--0.72 | 0.14--0.16 | |
| 231 | + |
| 232 | +### 7.3 Recommendation Update |
| 233 | + |
| 234 | +The v1.0 recommendation to "consider using RecurrentNetwork instead of CTRNN" is no longer necessary. With per-node time constants, CTRNN is the appropriate choice for dynamical systems prediction. Users should configure the `time_constant_*` parameters in their config file to match the timescales of their problem, and ensure `time_constant_min_value` is not too small relative to the data timestep to avoid numerical instability. |
0 commit comments