Skip to content

Commit c43abec

Browse files
Add tests for the 75% disable rule.
1 parent dcfc167 commit c43abec

File tree

1 file changed

+264
-0
lines changed

1 file changed

+264
-0
lines changed

tests/test_disable_rule.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
"""
2+
Tests for the 75% disable rule from the NEAT paper.
3+
4+
From Stanley & Miikkulainen (2002), p. 111:
5+
"There was a 75% chance that an inherited gene was disabled if it was disabled in either parent."
6+
7+
Implementation Note:
8+
The neat-python implementation applies the 75% rule AFTER random attribute inheritance.
9+
For genes with one parent disabled and one enabled:
10+
- The 'enabled' attribute is first randomly inherited (50/50)
11+
- Then, if EITHER parent was disabled, there's a 75% chance to disable
12+
- Effective rate: 50% (inherited disabled) + 50% * 75% (inherited enabled then disabled) = 87.5%
13+
14+
This implementation validates that the 75% rule is correctly applied as an additional
15+
mechanism on top of standard attribute inheritance.
16+
"""
17+
import os
18+
import unittest
19+
20+
import neat
21+
from neat.genes import DefaultConnectionGene
22+
23+
24+
class Test75PercentDisableRule(unittest.TestCase):
25+
"""Tests for the 75% disable rule during crossover."""
26+
27+
def setUp(self):
28+
"""Set up test configuration."""
29+
local_dir = os.path.dirname(__file__)
30+
config_path = os.path.join(local_dir, 'test_configuration')
31+
self.config = neat.Config(neat.DefaultGenome, neat.DefaultReproduction,
32+
neat.DefaultSpeciesSet, neat.DefaultStagnation,
33+
config_path)
34+
35+
def test_disable_rule_one_parent_disabled(self):
36+
"""Test effective disable rate when one parent is disabled (~87.5%)."""
37+
gene1 = DefaultConnectionGene((0, 1), innovation=1)
38+
gene1.weight = 1.0
39+
gene1.enabled = False # Disabled
40+
41+
gene2 = DefaultConnectionGene((0, 1), innovation=1)
42+
gene2.weight = 1.0
43+
gene2.enabled = True # Enabled
44+
45+
trials = 2000
46+
disabled_count = sum(1 for _ in range(trials)
47+
if not gene1.crossover(gene2).enabled)
48+
49+
disable_rate = disabled_count / trials
50+
51+
# Effective rate should be ~87.5% (50% + 37.5%)
52+
self.assertGreater(disable_rate, 0.84,
53+
f"Expected ~87.5% effective disable rate, got {disable_rate:.2%}")
54+
self.assertLess(disable_rate, 0.91,
55+
f"Expected ~87.5% effective disable rate, got {disable_rate:.2%}")
56+
57+
def test_disable_rule_both_parents_disabled(self):
58+
"""Test that offspring is always disabled when both parents are disabled."""
59+
gene1 = DefaultConnectionGene((0, 1), innovation=1)
60+
gene1.weight = 1.0
61+
gene1.enabled = False
62+
63+
gene2 = DefaultConnectionGene((0, 1), innovation=1)
64+
gene2.weight = 2.0
65+
gene2.enabled = False
66+
67+
trials = 1000
68+
disabled_count = sum(1 for _ in range(trials)
69+
if not gene1.crossover(gene2).enabled)
70+
71+
disable_rate = disabled_count / trials
72+
73+
# Both disabled: 100% inherit disabled (since both parents are disabled)
74+
# The 75% rule doesn't change anything since the gene is already disabled
75+
self.assertEqual(disabled_count, trials,
76+
f"Expected 100% disable rate with both parents disabled, got {disable_rate:.2%}")
77+
78+
def test_disable_rule_both_parents_enabled(self):
79+
"""Test that offspring is always enabled when both parents are enabled."""
80+
gene1 = DefaultConnectionGene((0, 1), innovation=1)
81+
gene1.weight = 1.0
82+
gene1.enabled = True
83+
84+
gene2 = DefaultConnectionGene((0, 1), innovation=1)
85+
gene2.weight = 2.0
86+
gene2.enabled = True
87+
88+
trials = 1000
89+
disabled_count = sum(1 for _ in range(trials)
90+
if not gene1.crossover(gene2).enabled)
91+
92+
# Should be 0% disabled (all enabled) since neither parent is disabled
93+
self.assertEqual(disabled_count, 0,
94+
f"Expected 0% disable rate with both parents enabled, got {disabled_count/trials:.2%}")
95+
96+
def test_disable_rule_is_applied_correctly(self):
97+
"""Test that the 75% probability is correctly applied in the implementation."""
98+
# The key insight: enabled is first inherited randomly,
99+
# THEN 75% rule is applied if either parent was disabled
100+
101+
gene1 = DefaultConnectionGene((0, 1), innovation=1)
102+
gene1.weight = 1.0
103+
gene1.enabled = False
104+
105+
gene2 = DefaultConnectionGene((0, 1), innovation=1)
106+
gene2.weight = 1.0
107+
gene2.enabled = True
108+
109+
trials = 5000
110+
disabled_count = sum(1 for _ in range(trials)
111+
if not gene1.crossover(gene2).enabled)
112+
113+
disable_rate = disabled_count / trials
114+
115+
# Should NOT be exactly 75%
116+
self.assertNotAlmostEqual(disable_rate, 0.75, delta=0.03,
117+
msg="Rate should not be exactly 75% due to layered inheritance")
118+
119+
# Should be close to 87.5%
120+
self.assertAlmostEqual(disable_rate, 0.875, delta=0.03,
121+
msg=f"Expected ~87.5% from layered inheritance, got {disable_rate:.3f}")
122+
123+
def test_disable_rule_preserves_other_attributes(self):
124+
"""Test that the disable rule doesn't affect other attribute inheritance."""
125+
gene1 = DefaultConnectionGene((0, 1), innovation=1)
126+
gene1.weight = 1.0
127+
gene1.enabled = False
128+
129+
gene2 = DefaultConnectionGene((0, 1), innovation=1)
130+
gene2.weight = 2.0
131+
gene2.enabled = True
132+
133+
for _ in range(100):
134+
offspring = gene1.crossover(gene2)
135+
136+
# Weight should be from one of the parents
137+
self.assertIn(offspring.weight, [1.0, 2.0],
138+
"Offspring weight should be from one of the parents")
139+
140+
# Innovation number should be preserved
141+
self.assertEqual(offspring.innovation, 1,
142+
"Innovation number should be preserved")
143+
144+
def test_disable_rule_symmetry(self):
145+
"""Test that disable rule works the same regardless of parent order."""
146+
gene1_disabled = DefaultConnectionGene((0, 1), innovation=1)
147+
gene1_disabled.weight = 1.0
148+
gene1_disabled.enabled = False
149+
150+
gene2_enabled = DefaultConnectionGene((0, 1), innovation=1)
151+
gene2_enabled.weight = 1.0
152+
gene2_enabled.enabled = True
153+
154+
trials = 2000
155+
156+
# Crossover both ways
157+
disabled_count_1 = sum(1 for _ in range(trials)
158+
if not gene1_disabled.crossover(gene2_enabled).enabled)
159+
disabled_count_2 = sum(1 for _ in range(trials)
160+
if not gene2_enabled.crossover(gene1_disabled).enabled)
161+
162+
rate1 = disabled_count_1 / trials
163+
rate2 = disabled_count_2 / trials
164+
165+
# Both should be approximately the same (~87.5%)
166+
self.assertAlmostEqual(rate1, rate2, delta=0.05,
167+
msg=f"Rates should be similar: {rate1:.2%} vs {rate2:.2%}")
168+
169+
170+
class TestDisableRuleImplementation(unittest.TestCase):
171+
"""Tests that verify the implementation details of the disable rule."""
172+
173+
def test_rule_applied_after_attribute_inheritance(self):
174+
"""Verify that the 75% rule is applied AFTER random attribute inheritance."""
175+
# This is the key implementation detail
176+
gene1 = DefaultConnectionGene((0, 1), innovation=1)
177+
gene1.weight = 1.0
178+
gene1.enabled = False
179+
180+
gene2 = DefaultConnectionGene((0, 1), innovation=1)
181+
gene2.weight = 2.0
182+
gene2.enabled = True
183+
184+
trials = 10000
185+
disabled_count = sum(1 for _ in range(trials)
186+
if not gene1.crossover(gene2).enabled)
187+
188+
rate = disabled_count / trials
189+
190+
# If rule was applied INSTEAD of inheritance, we'd get exactly 75%
191+
# Since it's applied AFTER, we get 87.5%
192+
self.assertGreater(rate, 0.85,
193+
"Rate should be higher than 75% due to layered application")
194+
self.assertLess(rate, 0.90,
195+
"Rate should be close to 87.5%")
196+
197+
def test_enabled_offspring_probability(self):
198+
"""Test that ~12.5% of offspring remain enabled (complement of 87.5%)."""
199+
gene1 = DefaultConnectionGene((0, 1), innovation=1)
200+
gene1.weight = 1.0
201+
gene1.enabled = False
202+
203+
gene2 = DefaultConnectionGene((0, 1), innovation=1)
204+
gene2.weight = 1.0
205+
gene2.enabled = True
206+
207+
trials = 2000
208+
enabled_count = sum(1 for _ in range(trials)
209+
if gene1.crossover(gene2).enabled)
210+
211+
enabled_rate = enabled_count / trials
212+
213+
# Should have ~12.5% enabled (50% inherit enabled * 25% stay enabled)
214+
self.assertGreater(enabled_rate, 0.09,
215+
f"Expected ~12.5% enabled, got {enabled_rate:.2%}")
216+
self.assertLess(enabled_rate, 0.16,
217+
f"Expected ~12.5% enabled, got {enabled_rate:.2%}")
218+
219+
def test_no_false_enabling(self):
220+
"""Test that the rule never RE-enables an already disabled gene."""
221+
gene1 = DefaultConnectionGene((0, 1), innovation=1)
222+
gene1.weight = 1.0
223+
gene1.enabled = True
224+
225+
gene2 = DefaultConnectionGene((0, 1), innovation=1)
226+
gene2.weight = 1.0
227+
gene2.enabled = True
228+
229+
# When both enabled, should never get disabled
230+
for _ in range(1000):
231+
offspring = gene1.crossover(gene2)
232+
self.assertTrue(offspring.enabled,
233+
"Should never disable when both parents enabled")
234+
235+
def test_mathematical_model_accuracy(self):
236+
"""Test that observed rates match the mathematical model."""
237+
gene1 = DefaultConnectionGene((0, 1), innovation=1)
238+
gene1.weight = 1.0
239+
gene1.enabled = False
240+
241+
gene2 = DefaultConnectionGene((0, 1), innovation=1)
242+
gene2.weight = 1.0
243+
gene2.enabled = True
244+
245+
trials = 10000
246+
disabled_count = sum(1 for _ in range(trials)
247+
if not gene1.crossover(gene2).enabled)
248+
249+
observed_rate = disabled_count / trials
250+
251+
# Mathematical model:
252+
# P(disabled) = P(inherit disabled) + P(inherit enabled AND then disabled)
253+
# = 0.5 + (0.5 * 0.75)
254+
# = 0.5 + 0.375
255+
# = 0.875
256+
expected_rate = 0.875
257+
258+
# Should be within 1% of expected
259+
self.assertAlmostEqual(observed_rate, expected_rate, delta=0.01,
260+
msg=f"Observed {observed_rate:.4f} should match model {expected_rate}")
261+
262+
263+
if __name__ == '__main__':
264+
unittest.main()

0 commit comments

Comments
 (0)