Skip to content

Commit 3db2c11

Browse files
Reintroduce innovation tracking to match the original NEAT paper.
1 parent 16225f8 commit 3db2c11

15 files changed

+1110
-34
lines changed

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.0.0] - 2025-01-09
11+
12+
### Added
13+
- **Innovation Number Tracking**: Full implementation of innovation numbers as described in the original NEAT paper (Stanley & Miikkulainen, 2002, Section 3.2)
14+
- Global innovation counter that increments across all generations
15+
- Same-generation deduplication of identical mutations
16+
- Innovation-based gene matching during crossover
17+
- Proper historical marking of genes for speciation
18+
- New `InnovationTracker` class in `neat/innovation.py`
19+
- Comprehensive unit tests in `tests/test_innovation.py` (19 tests)
20+
- Integration tests in `tests/test_innovation_integration.py` (6 tests)
21+
- Innovation tracking documentation in `INNOVATION_TRACKING_IMPLEMENTATION.md`
22+
1023
### Changed
24+
- **BREAKING**: `DefaultConnectionGene.__init__()` now requires mandatory `innovation` parameter
25+
- **BREAKING**: All connection gene creation must include innovation numbers
26+
- **BREAKING**: Crossover now matches genes primarily by innovation number, not tuple keys
27+
- **BREAKING**: Old checkpoints from pre-1.0 versions are incompatible with 1.0.0+
28+
- `DefaultGenome.configure_crossover()` updated to match genes by innovation number per NEAT paper Figure 4
29+
- All genome initialization methods assign innovation numbers to connections
30+
- `DefaultReproduction` now creates and manages an `InnovationTracker` instance
31+
- Checkpoint format updated to preserve innovation tracker state
1132
- `ParallelEvaluator` now implements context manager protocol (`__enter__`/`__exit__`) for proper resource cleanup
1233
- Improved resource management in `ParallelEvaluator` to prevent multiprocessing pool leaks
1334
- Fixed `ParallelEvaluator.__del__()` to properly clean up resources without calling `terminate()` unnecessarily

examples/xor/config-feedforward

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
#--- parameters for the XOR-2 experiment ---#
22

3+
# NOTE: neat-python 1.0+ uses innovation number tracking as described in the
4+
# original NEAT paper (Stanley & Miikkulainen, 2002). This is now mandatory
5+
# and enables proper historical marking of genes during evolution.
6+
37
[NEAT]
48
fitness_criterion = max
59
fitness_threshold = 3.9

examples/xor/config-feedforward-partial

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
#--- parameters for the XOR-2 experiment ---#
22

3+
# NOTE: neat-python 1.0+ uses innovation number tracking as described in the
4+
# original NEAT paper (Stanley & Miikkulainen, 2002). This is now mandatory
5+
# and enables proper historical marking of genes during evolution.
6+
37
[NEAT]
48
fitness_criterion = max
59
fitness_threshold = 3.9

examples/xor/config-spiking

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
#--- parameters for the XOR-2 experiment ---#
22

3+
# NOTE: neat-python 1.0+ uses innovation number tracking as described in the
4+
# original NEAT paper (Stanley & Miikkulainen, 2002). This is now mandatory
5+
# and enables proper historical marking of genes during evolution.
6+
37
[NEAT]
48
fitness_criterion = max
59
fitness_threshold = 9.9

neat/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
"""A NEAT (NeuroEvolution of Augmenting Topologies) implementation"""
2+
3+
__version__ = '1.0.0'
4+
25
import neat.nn as nn
36
import neat.ctrnn as ctrnn
47
import neat.iznn as iznn
@@ -13,3 +16,5 @@
1316
from neat.statistics import StatisticsReporter
1417
from neat.parallel import ParallelEvaluator
1518
from neat.checkpoint import Checkpointer
19+
from neat.innovation import InnovationTracker
20+
from neat.genes import DefaultNodeGene, DefaultConnectionGene

neat/checkpoint.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,46 @@ def end_generation(self, config, population, species_set):
5757
self.last_time_checkpoint = time.time()
5858

5959
def save_checkpoint(self, config, population, species_set, generation):
60-
""" Save the current simulation state. """
60+
"""
61+
Save the current simulation state.
62+
63+
Note: This is called from Population via the reporter interface.
64+
We need to access the innovation tracker from the Population's reproduction object.
65+
However, since this is a reporter callback, we don't have direct access to Population.
66+
The innovation tracker will be saved as part of the config state when needed.
67+
"""
6168
filename = '{0}{1}'.format(self.filename_prefix, generation)
6269
print("Saving checkpoint to {0}".format(filename))
6370

6471
with gzip.open(filename, 'w', compresslevel=5) as f:
72+
# Note: innovation_tracker is stored in config.genome_config.innovation_tracker
73+
# and is automatically included via pickle
6574
data = (generation, config, population, species_set, random.getstate())
6675
pickle.dump(data, f, protocol=pickle.HIGHEST_PROTOCOL)
6776

6877
@staticmethod
6978
def restore_checkpoint(filename, new_config=None):
70-
"""Resumes the simulation from a previous saved point."""
79+
"""
80+
Resumes the simulation from a previous saved point.
81+
82+
The innovation tracker state is preserved through the Population's reproduction object,
83+
which is automatically pickled and restored. The global innovation counter will continue
84+
from where it left off, preventing innovation number collisions.
85+
"""
7186
with gzip.open(filename) as f:
7287
generation, config, population, species_set, rndstate = pickle.load(f)
7388
random.setstate(rndstate)
7489
if new_config is not None:
7590
config = new_config
76-
return Population(config, (population, species_set, generation))
91+
92+
# Create Population with restored state
93+
# The Population.__init__ will restore the reproduction object which contains
94+
# the innovation_tracker with its preserved state
95+
restored_pop = Population(config, (population, species_set, generation))
96+
97+
# The innovation tracker should already be restored via pickle, but we need to
98+
# ensure it's properly connected to the genome_config for the next generation
99+
if hasattr(restored_pop.reproduction, 'innovation_tracker'):
100+
config.genome_config.innovation_tracker = restored_pop.reproduction.innovation_tracker
101+
102+
return restored_pop

neat/genes.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ def __init__(self, key):
1919
self.key = key
2020

2121
def __str__(self):
22-
attrib = ['key'] + [a.name for a in self._gene_attributes]
22+
attrib = ['key']
23+
if hasattr(self, 'innovation'):
24+
attrib.append('innovation')
25+
attrib += [a.name for a in self._gene_attributes]
2326
attrib = [f'{a}={getattr(self, a)}' for a in attrib]
2427
return f'{self.__class__.__name__}({", ".join(attrib)})'
2528

@@ -58,7 +61,12 @@ def mutate(self, config):
5861
setattr(self, a.name, a.mutate_value(v, config))
5962

6063
def copy(self):
61-
new_gene = self.__class__(self.key)
64+
# Handle innovation number for connection genes
65+
if hasattr(self, 'innovation'):
66+
new_gene = self.__class__(self.key, innovation=self.innovation)
67+
else:
68+
new_gene = self.__class__(self.key)
69+
6270
for a in self._gene_attributes:
6371
setattr(new_gene, a.name, getattr(self, a.name))
6472

@@ -67,10 +75,23 @@ def copy(self):
6775
def crossover(self, gene2):
6876
""" Creates a new gene randomly inheriting attributes from its parents."""
6977
assert self.key == gene2.key
78+
79+
# For connection genes, verify innovation numbers match
80+
# (they should represent the same historical mutation)
81+
if hasattr(self, 'innovation'):
82+
assert hasattr(gene2, 'innovation'), "Both genes must have innovation numbers"
83+
assert self.innovation == gene2.innovation, (
84+
f"Genes with same key must have same innovation number: "
85+
f"{self.innovation} vs {gene2.innovation}"
86+
)
7087

7188
# Note: we use "a if random() > 0.5 else b" instead of choice((a, b))
7289
# here because `choice` is substantially slower.
73-
new_gene = self.__class__(self.key)
90+
if hasattr(self, 'innovation'):
91+
new_gene = self.__class__(self.key, innovation=self.innovation)
92+
else:
93+
new_gene = self.__class__(self.key)
94+
7495
for a in self._gene_attributes:
7596
if random() > 0.5:
7697
setattr(new_gene, a.name, getattr(self, a.name))
@@ -120,12 +141,25 @@ class DefaultConnectionGene(BaseGene):
120141
_gene_attributes = [FloatAttribute('weight'),
121142
BoolAttribute('enabled')]
122143

123-
def __init__(self, key):
144+
def __init__(self, key, innovation=None):
124145
assert isinstance(key, tuple), f"DefaultConnectionGene key must be a tuple, not {key!r}"
146+
assert innovation is not None, "Innovation number is required for DefaultConnectionGene"
147+
assert isinstance(innovation, int), f"Innovation must be an int, not {type(innovation)}"
125148
BaseGene.__init__(self, key)
149+
self.innovation = innovation
126150

127151
def distance(self, other, config):
128152
d = abs(self.weight - other.weight)
129153
if self.enabled != other.enabled:
130154
d += 1.0
131155
return d * config.compatibility_weight_coefficient
156+
157+
def __eq__(self, other):
158+
"""Compare genes by innovation number."""
159+
if not isinstance(other, DefaultConnectionGene):
160+
return False
161+
return self.innovation == other.innovation
162+
163+
def __hash__(self):
164+
"""Hash by innovation number for use in sets/dicts."""
165+
return hash(self.innovation)

0 commit comments

Comments
 (0)