Skip to content

Commit 19040e2

Browse files
Enforce population size to be exactly the number of genomes specified in config.
1 parent fba3d9a commit 19040e2

File tree

3 files changed

+116
-0
lines changed

3 files changed

+116
-0
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4242
- Full documentation in example README with usage tips
4343

4444
### Fixed
45+
- **Population Size Drift**: Fixed small mismatches between actual population size and configured `pop_size`
46+
- `DefaultReproduction.reproduce()` now strictly enforces `len(population) == config.pop_size` for every non-extinction generation
47+
- New `_adjust_spawn_exact` helper adjusts per-species spawn counts after `compute_spawn()` to correct rounding/clamping drift
48+
- When adding individuals, extra genomes are allocated to smaller species first; when removing, genomes are taken from larger species first
49+
- Per-species minima (`min_species_size` and elitism) are always respected; invalid configurations (e.g., `pop_size < num_species * min_species_size`) raise a clear error
50+
- New tests in `tests/test_reproduction.py` ensure `DefaultReproduction.reproduce()` preserves exact population size over multiple generations
4551
- **Orphaned Nodes Bug**: Fixed silent failure when nodes have no incoming connections after deletion mutations
4652
- `feed_forward_layers()` now correctly handles orphaned nodes (nodes with no incoming connections)
4753
- Orphaned nodes are treated as "bias neurons" that output `activation(bias)` independent of inputs

neat/reproduction.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,69 @@ def compute_spawn(adjusted_fitness, previous_sizes, pop_size, min_species_size):
9191

9292
return spawn_amounts
9393

94+
def _adjust_spawn_exact(self, spawn_amounts, pop_size, min_species_size):
95+
"""Adjust per-species spawn counts so that their sum matches pop_size exactly.
96+
97+
This adjustment preserves the per-species minimum (min_species_size) and biases
98+
changes as follows:
99+
- When adding individuals (total < pop_size), smaller species are incremented first.
100+
- When removing individuals (total > pop_size), larger species are decremented first.
101+
"""
102+
total_spawn = sum(spawn_amounts)
103+
if total_spawn == pop_size:
104+
return spawn_amounts
105+
106+
num_species = len(spawn_amounts)
107+
min_total = num_species * min_species_size
108+
if min_total > pop_size:
109+
raise RuntimeError(
110+
"Configuration conflict: population size {0} is less than "
111+
"num_species * min_species_size {1} ({2} * {3}). Cannot satisfy per-species minima.".format(
112+
pop_size, min_total, num_species, min_species_size
113+
)
114+
)
115+
116+
diff = pop_size - total_spawn
117+
indexed = list(enumerate(spawn_amounts))
118+
119+
if diff > 0:
120+
# Too few genomes overall: give extras to smaller species first.
121+
indexed.sort(key=lambda x: x[1]) # ascending by current spawn size
122+
i = 0
123+
while diff > 0 and indexed:
124+
idx, val = indexed[i]
125+
val += 1
126+
spawn_amounts[idx] = val
127+
indexed[i] = (idx, val)
128+
diff -= 1
129+
i = (i + 1) % len(indexed)
130+
else:
131+
# Too many genomes overall: remove from larger species first.
132+
remaining = -diff
133+
indexed.sort(key=lambda x: x[1], reverse=True) # descending by current spawn size
134+
i = 0
135+
# We know a solution should exist whenever min_total <= pop_size, but
136+
# guard against pathological rounding behaviour with a safety break.
137+
while remaining > 0 and indexed:
138+
idx, val = indexed[i]
139+
if val > min_species_size:
140+
val -= 1
141+
spawn_amounts[idx] = val
142+
indexed[i] = (idx, val)
143+
remaining -= 1
144+
i = (i + 1) % len(indexed)
145+
if i == 0 and remaining > 0:
146+
# Could not adjust further without violating the per-species minimum.
147+
break
148+
149+
if sum(spawn_amounts) != pop_size:
150+
raise RuntimeError(
151+
"Internal error adjusting spawn counts: could not match pop_size={0} "
152+
"with min_species_size={1}".format(pop_size, min_species_size)
153+
)
154+
155+
return spawn_amounts
156+
94157
def reproduce(self, config, species, pop_size, generation):
95158
"""
96159
Handles creation of genomes, either from scratch or by sexual or
@@ -156,6 +219,9 @@ def reproduce(self, config, species, pop_size, generation):
156219
min_species_size = max(min_species_size, self.reproduction_config.elitism)
157220
spawn_amounts = self.compute_spawn(adjusted_fitnesses, previous_sizes,
158221
pop_size, min_species_size)
222+
# Adjust spawn counts so that the total exactly matches the requested
223+
# population size while respecting the per-species minimum.
224+
spawn_amounts = self._adjust_spawn_exact(spawn_amounts, pop_size, min_species_size)
159225

160226
new_population = {}
161227
species.species = {}

tests/test_reproduction.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import os
2+
import random
13
import unittest
24

5+
import neat
6+
from neat.reporting import ReporterSet
37
from neat.reproduction import DefaultReproduction
48

59

@@ -53,5 +57,45 @@ def test_spawn_adjust3(self):
5357
self.assertEqual(spawn, [20, 20])
5458

5559

60+
class TestReproducePopulationSize(unittest.TestCase):
61+
def test_reproduce_respects_pop_size(self):
62+
"""DefaultReproduction.reproduce must always create exactly pop_size genomes."""
63+
# Load configuration.
64+
local_dir = os.path.dirname(__file__)
65+
config_path = os.path.join(local_dir, 'test_configuration')
66+
config = neat.Config(neat.DefaultGenome, neat.DefaultReproduction,
67+
neat.DefaultSpeciesSet, neat.DefaultStagnation,
68+
config_path)
69+
70+
pop_size = config.pop_size
71+
72+
# Set up the objects similarly to Population.__init__.
73+
reporters = ReporterSet()
74+
stagnation = config.stagnation_type(config.stagnation_config, reporters)
75+
reproduction = config.reproduction_type(config.reproduction_config,
76+
reporters, stagnation)
77+
species_set = config.species_set_type(config.species_set_config, reporters)
78+
79+
# Create an initial population and speciate it.
80+
population = reproduction.create_new(config.genome_type,
81+
config.genome_config,
82+
pop_size)
83+
generation = 0
84+
species_set.speciate(config, population, generation)
85+
86+
# Run several generations and ensure the population size is invariant.
87+
random.seed(123)
88+
for generation in range(5):
89+
# Assign some non-degenerate fitness values.
90+
for genome in population.values():
91+
genome.fitness = random.random()
92+
93+
population = reproduction.reproduce(config, species_set, pop_size, generation)
94+
self.assertEqual(len(population), pop_size)
95+
96+
# Prepare species for the next generation.
97+
species_set.speciate(config, population, generation + 1)
98+
99+
56100
if __name__ == '__main__':
57101
unittest.main()

0 commit comments

Comments
 (0)