Skip to content

Commit 7d9acae

Browse files
Add disable_rule config flag for 75% crossover rule (#292)
Adds a `disable_rule` parameter to `[DefaultGenome]` so users can opt into Stanley's reference NEAT C++ behavior alongside the current neat-python semantics. Default remains `neat-python` (no behavior change for existing configs). - `neat-python`: when either parent has the gene disabled, replace the randomly-inherited `enabled` with a fresh Bernoulli(0.25) — 75% disabled regardless of how many parents had it disabled. - `stanley`: random inherit, then 75% force-disable layered on top if either parent disabled — ~87.5% disabled with one parent disabled, 100% disabled with both. Threaded through `BaseGene.crossover()` as a keyword argument with a backwards-compatible default so direct callers (tests, etc.) keep working. Validated at config load. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1e4c828 commit 7d9acae

3 files changed

Lines changed: 154 additions & 12 deletions

File tree

neat/genes.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,24 @@ def copy(self):
7272

7373
return new_gene
7474

75-
def crossover(self, gene2):
76-
""" Creates a new gene randomly inheriting attributes from its parents."""
75+
def crossover(self, gene2, disable_rule='neat-python'):
76+
"""Create a new gene randomly inheriting attributes from its parents.
77+
78+
*disable_rule* selects how the NEAT paper's 75% disable rule is applied
79+
to the ``enabled`` attribute (connection genes only):
80+
81+
- ``'neat-python'`` (default): if either parent has the gene disabled,
82+
the randomly-inherited enabled value is REPLACED by a fresh
83+
Bernoulli(0.25): 75% disabled, 25% enabled.
84+
- ``'stanley'``: matches Stanley's reference NEAT C++ implementation.
85+
The enabled value is first inherited from a random parent like every
86+
other attribute, then if either parent has the gene disabled the
87+
inherited value is force-disabled with probability 0.75 (otherwise
88+
left alone). Yields ~87.5% disabled when exactly one parent is
89+
disabled and 100% disabled when both parents are disabled.
90+
"""
7791
assert self.key == gene2.key
78-
92+
7993
# For connection genes, verify innovation numbers match
8094
# (they should represent the same historical mutation)
8195
if hasattr(self, 'innovation'):
@@ -91,22 +105,29 @@ def crossover(self, gene2):
91105
new_gene = self.__class__(self.key, innovation=self.innovation)
92106
else:
93107
new_gene = self.__class__(self.key)
94-
108+
95109
for a in self._gene_attributes:
96110
if random() > 0.5:
97111
setattr(new_gene, a.name, getattr(self, a.name))
98112
else:
99113
setattr(new_gene, a.name, getattr(gene2, a.name))
100-
114+
101115
# 75% disable rule from NEAT paper (Stanley & Miikkulainen, 2002, p. 111):
102116
# "There was a 75% chance that an inherited gene was disabled if it was
103117
# disabled in either parent."
104-
# This rule REPLACES the randomly-inherited enabled attribute when either
105-
# parent has the gene disabled.
106-
if hasattr(new_gene, 'enabled'):
107-
if not self.enabled or not gene2.enabled:
118+
if hasattr(new_gene, 'enabled') and (not self.enabled or not gene2.enabled):
119+
if disable_rule == 'neat-python':
108120
# Override whatever was randomly inherited: 75% disabled, 25% enabled.
109121
new_gene.enabled = random() >= 0.75
122+
elif disable_rule == 'stanley':
123+
# Reference C++ behavior: 75% chance to force-disable the
124+
# value that was just inherited above; otherwise leave it.
125+
if random() < 0.75:
126+
new_gene.enabled = False
127+
else:
128+
raise ValueError(
129+
f"Unknown disable_rule {disable_rule!r}; "
130+
f"expected 'neat-python' or 'stanley'")
110131

111132
return new_gene
112133

neat/genome.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class DefaultGenomeConfig:
1717
allowed_connectivity = ['unconnected', 'fs_neat_nohidden', 'fs_neat', 'fs_neat_hidden',
1818
'full_nodirect', 'full', 'full_direct',
1919
'partial_nodirect', 'partial', 'partial_direct']
20+
allowed_disable_rules = ('neat-python', 'stanley')
2021

2122
def __init__(self, params, section_name='DefaultGenome'):
2223
# Create full set of available activation functions.
@@ -40,7 +41,8 @@ def __init__(self, params, section_name='DefaultGenome'):
4041
ConfigParameter('initial_connection', str, 'unconnected'),
4142
ConfigParameter('compatibility_excess_coefficient', str, 'auto'),
4243
ConfigParameter('compatibility_include_node_genes', bool, True),
43-
ConfigParameter('compatibility_enable_penalty', float, 1.0)]
44+
ConfigParameter('compatibility_enable_penalty', float, 1.0),
45+
ConfigParameter('disable_rule', str, 'neat-python')]
4446

4547
# Gather configuration data from the gene classes.
4648
self.node_gene_type = params['node_gene_type']
@@ -116,6 +118,22 @@ def __init__(self, params, section_name='DefaultGenome'):
116118
error_string = f"Invalid structural_mutation_surer {self.structural_mutation_surer!r}"
117119
raise RuntimeError(error_string)
118120

121+
# Verify disable_rule is valid. Two interpretations of the NEAT paper's
122+
# 75% disable rule are supported:
123+
# 'neat-python' (default): if either parent has the gene disabled, a
124+
# fresh Bernoulli(0.25) replaces the randomly-inherited enabled
125+
# value -> 75% disabled / 25% enabled regardless of how many
126+
# parents had it disabled.
127+
# 'stanley': the original NEAT C++ behavior. After randomly inheriting
128+
# the enabled attribute from a parent, if either parent has the
129+
# gene disabled, force-disable with probability 0.75; otherwise
130+
# keep the inherited value. Yields ~87.5% disabled when exactly
131+
# one parent is disabled and 100% disabled when both are.
132+
if self.disable_rule not in self.allowed_disable_rules:
133+
raise RuntimeError(
134+
f"Invalid disable_rule {self.disable_rule!r}; "
135+
f"expected one of {self.allowed_disable_rules}")
136+
119137
self.node_indexer = None
120138

121139
# Innovation tracker will be set by Population/Reproduction
@@ -347,7 +365,7 @@ def configure_crossover(self, genome1, genome2, config, fitness_criterion=None):
347365
continue
348366
self.connections[new_gene.key] = new_gene
349367
else:
350-
new_gene = cg1.crossover(cg2)
368+
new_gene = cg1.crossover(cg2, disable_rule=config.disable_rule)
351369
# For feed-forward networks, check if this connection would create a cycle
352370
if config.feed_forward and creates_cycle(list(self.connections), new_gene.key):
353371
continue
@@ -373,7 +391,9 @@ def configure_crossover(self, genome1, genome2, config, fitness_criterion=None):
373391
self.nodes[key] = ng1.copy()
374392
else:
375393
# Homologous gene: combine genes from both parents.
376-
self.nodes[key] = ng1.crossover(ng2)
394+
# Node genes have no ``enabled`` attribute, so the disable rule
395+
# has no effect; pass it through for consistency.
396+
self.nodes[key] = ng1.crossover(ng2, disable_rule=config.disable_rule)
377397

378398
def mutate(self, config):
379399
""" Mutates this genome. """

tests/test_disable_rule.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,5 +243,106 @@ def test_mathematical_model_accuracy(self):
243243
msg=f"Observed {observed_rate:.4f} should match model {expected_rate}")
244244

245245

246+
class TestStanleyDisableRule(unittest.TestCase):
247+
"""Tests for the 'stanley' disable_rule (reference C++ behavior).
248+
249+
Under the stanley rule, the enabled attribute is first randomly inherited
250+
from a parent like every other attribute, then if either parent has the
251+
gene disabled a 75% force-disable is layered on top. Expected rates:
252+
253+
- both disabled: 100% disabled (inherited value is always False; force-disable is a no-op)
254+
- one disabled: 87.5% disabled (= 0.5 + 0.5 * 0.75)
255+
- both enabled: 0% disabled (rule condition never triggers)
256+
"""
257+
258+
def _make_gene(self, weight, enabled, innovation=1):
259+
g = DefaultConnectionGene((0, 1), innovation=innovation)
260+
g.weight = weight
261+
g.enabled = enabled
262+
return g
263+
264+
def test_both_parents_disabled_is_always_disabled(self):
265+
g1 = self._make_gene(1.0, False)
266+
g2 = self._make_gene(2.0, False)
267+
for _ in range(1000):
268+
child = g1.crossover(g2, disable_rule='stanley')
269+
self.assertFalse(child.enabled,
270+
"stanley rule: both-disabled parents must always produce disabled child")
271+
272+
def test_one_parent_disabled_rate_is_87_5_percent(self):
273+
g1 = self._make_gene(1.0, False)
274+
g2 = self._make_gene(2.0, True)
275+
trials = 10000
276+
disabled = sum(1 for _ in range(trials)
277+
if not g1.crossover(g2, disable_rule='stanley').enabled)
278+
rate = disabled / trials
279+
self.assertAlmostEqual(rate, 0.875, delta=0.02,
280+
msg=f"stanley rule: expected ~87.5% disabled, got {rate:.3f}")
281+
282+
def test_one_parent_disabled_rate_is_symmetric(self):
283+
g_disabled = self._make_gene(1.0, False)
284+
g_enabled = self._make_gene(2.0, True)
285+
trials = 5000
286+
r1 = sum(1 for _ in range(trials)
287+
if not g_disabled.crossover(g_enabled, disable_rule='stanley').enabled) / trials
288+
r2 = sum(1 for _ in range(trials)
289+
if not g_enabled.crossover(g_disabled, disable_rule='stanley').enabled) / trials
290+
self.assertAlmostEqual(r1, r2, delta=0.04,
291+
msg=f"rates should be symmetric: {r1:.3f} vs {r2:.3f}")
292+
self.assertAlmostEqual(r1, 0.875, delta=0.03)
293+
294+
def test_both_parents_enabled_is_always_enabled(self):
295+
g1 = self._make_gene(1.0, True)
296+
g2 = self._make_gene(2.0, True)
297+
for _ in range(1000):
298+
child = g1.crossover(g2, disable_rule='stanley')
299+
self.assertTrue(child.enabled,
300+
"stanley rule: both-enabled parents must always produce enabled child")
301+
302+
def test_unknown_disable_rule_raises(self):
303+
g1 = self._make_gene(1.0, False)
304+
g2 = self._make_gene(2.0, True)
305+
with self.assertRaises(ValueError):
306+
g1.crossover(g2, disable_rule='bogus')
307+
308+
309+
class TestDisableRuleConfigFlag(unittest.TestCase):
310+
"""Tests for the ``disable_rule`` config flag in [DefaultGenome]."""
311+
312+
def _load_config(self, override=None):
313+
"""Load the standard test config, optionally injecting an override line
314+
for the ``disable_rule`` parameter."""
315+
local_dir = os.path.dirname(__file__)
316+
src_path = os.path.join(local_dir, 'test_configuration')
317+
with open(src_path) as f:
318+
text = f.read()
319+
if override is not None:
320+
# Inject the override into the [DefaultGenome] section header.
321+
text = text.replace('[DefaultGenome]',
322+
f'[DefaultGenome]\ndisable_rule = {override}')
323+
import tempfile
324+
with tempfile.NamedTemporaryFile('w', suffix='.cfg', delete=False) as tmp:
325+
tmp.write(text)
326+
path = tmp.name
327+
try:
328+
return neat.Config(neat.DefaultGenome, neat.DefaultReproduction,
329+
neat.DefaultSpeciesSet, neat.DefaultStagnation,
330+
path)
331+
finally:
332+
os.unlink(path)
333+
334+
def test_default_is_neat_python(self):
335+
cfg = self._load_config()
336+
self.assertEqual(cfg.genome_config.disable_rule, 'neat-python')
337+
338+
def test_stanley_value_accepted(self):
339+
cfg = self._load_config(override='stanley')
340+
self.assertEqual(cfg.genome_config.disable_rule, 'stanley')
341+
342+
def test_invalid_value_rejected(self):
343+
with self.assertRaises(RuntimeError):
344+
self._load_config(override='bogus')
345+
346+
246347
if __name__ == '__main__':
247348
unittest.main()

0 commit comments

Comments
 (0)