Skip to content

Commit ec33784

Browse files
Fix fitness_criterion='min' ignored in best-genome tracking, stagnation, reproduction, crossover, and statistics
The fitness_criterion config parameter was only used for the termination check. All other fitness comparisons hardcoded "higher is better", making fitness_criterion='min' fundamentally broken (GitHub issue #187). Added is_better_fitness(), meets_threshold(), and worst_fitness() methods to Config as the single source of truth for fitness direction. Updated population.py, stagnation.py, reproduction.py, genome.py, and statistics.py to use direction-aware comparisons throughout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9dbea23 commit ec33784

File tree

7 files changed

+112
-37
lines changed

7 files changed

+112
-37
lines changed

neat/config.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,29 @@ class Config:
156156
ConfigParameter('no_fitness_termination', bool, False),
157157
ConfigParameter('seed', int, None, optional=True)]
158158

159+
def is_better_fitness(self, a, b):
160+
"""Return True if fitness value *a* is strictly better than *b*.
161+
162+
'Better' means higher when fitness_criterion is 'max' or 'mean',
163+
and lower when fitness_criterion is 'min'.
164+
"""
165+
if self.fitness_criterion == 'min':
166+
return a < b
167+
return a > b
168+
169+
def meets_threshold(self, fitness_value, threshold):
170+
"""Return True if *fitness_value* satisfies the termination threshold."""
171+
if self.fitness_criterion == 'min':
172+
return fitness_value <= threshold
173+
return fitness_value >= threshold
174+
175+
def worst_fitness(self):
176+
"""Return a sentinel value worse than any real fitness."""
177+
import sys
178+
if self.fitness_criterion == 'min':
179+
return sys.float_info.max
180+
return -sys.float_info.max
181+
159182
def __init__(self, genome_type, reproduction_type, species_set_type, stagnation_type, filename, config_information=None):
160183
# Check that the provided types have the required methods.
161184
assert hasattr(genome_type, 'parse_config')

neat/genome.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,17 +264,24 @@ def configure_new(self, config):
264264
sep='\n', file=sys.stderr)
265265
self.connect_partial_nodirect(config)
266266

267-
def configure_crossover(self, genome1, genome2, config):
267+
def configure_crossover(self, genome1, genome2, config, fitness_criterion=None):
268268
"""
269269
Configure a new genome by crossover from two parent genomes.
270-
270+
271271
Implements NEAT paper (Stanley & Miikkulainen, 2002, p. 108) crossover:
272272
"When crossing over, the genes in both genomes with the same innovation
273273
numbers are lined up. Genes are randomly chosen from either parent at
274274
matching genes, whereas all excess or disjoint genes are always included
275275
from the more fit parent."
276+
277+
*fitness_criterion* controls which parent is considered fitter.
278+
When 'min', lower fitness is better. Defaults to 'max' if not provided.
276279
"""
277-
if genome1.fitness > genome2.fitness:
280+
if fitness_criterion == 'min':
281+
better = genome1.fitness < genome2.fitness
282+
else:
283+
better = genome1.fitness > genome2.fitness
284+
if better:
278285
parent1, parent2 = genome1, genome2
279286
else:
280287
parent1, parent2 = genome2, genome1

neat/population.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,18 @@ def run(self, fitness_function, n=None):
109109
if g.fitness is None:
110110
raise RuntimeError(f"Fitness not assigned to genome {g.key}")
111111

112-
if best is None or g.fitness > best.fitness:
112+
if best is None or self.config.is_better_fitness(g.fitness, best.fitness):
113113
best = g
114114
self.reporters.post_evaluate(self.config, self.population, self.species, best)
115115

116116
# Track the best genome ever seen.
117-
if self.best_genome is None or best.fitness > self.best_genome.fitness:
117+
if self.best_genome is None or self.config.is_better_fitness(best.fitness, self.best_genome.fitness):
118118
self.best_genome = best
119119

120120
if not self.config.no_fitness_termination:
121121
# End if the fitness threshold is reached.
122122
fv = self.fitness_criterion(g.fitness for g in self.population.values())
123-
if fv >= self.config.fitness_threshold:
123+
if self.config.meets_threshold(fv, self.config.fitness_threshold):
124124
self.reporters.found_solution(self.config, self.generation, best)
125125
break
126126

neat/reproduction.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def reproduce(self, config, species, pop_size, generation):
200200
# interfering with the shared fitness scheme.
201201
all_fitnesses = []
202202
remaining_species = []
203-
for stag_sid, stag_s, stagnant in self.stagnation.update(species, generation):
203+
for stag_sid, stag_s, stagnant in self.stagnation.update(species, generation, config):
204204
if stagnant:
205205
self.reporters.species_stagnant(stag_sid, stag_s)
206206
else:
@@ -228,6 +228,9 @@ def reproduce(self, config, species, pop_size, generation):
228228
afs.adjusted_fitness += shift
229229
else:
230230
# Default 'normalized' behavior: normalize to [0, 1] based on population min/max.
231+
# Higher adjusted fitness always means more offspring, so when
232+
# fitness_criterion is 'min' the mapping is inverted: the species
233+
# with the lowest raw fitness gets the highest adjusted fitness.
231234
min_fitness = min(all_fitnesses)
232235
max_fitness = max(all_fitnesses)
233236
# Do not allow the fitness range to be zero, as we divide by it below.
@@ -236,6 +239,8 @@ def reproduce(self, config, species, pop_size, generation):
236239
for afs in remaining_species:
237240
msf = mean([m.fitness for m in afs.members.values()])
238241
af = (msf - min_fitness) / fitness_range
242+
if config.fitness_criterion == 'min':
243+
af = 1.0 - af
239244
afs.adjusted_fitness = af
240245

241246
adjusted_fitnesses = [s.adjusted_fitness for s in remaining_species]
@@ -272,10 +277,13 @@ def reproduce(self, config, species, pop_size, generation):
272277
s.members = {}
273278
species.species[s.key] = s
274279

275-
# Sort members in order of descending fitness, with genome id as a
276-
# deterministic tie-breaker so that ordering (and thus parent
277-
# selection) is reproducible across runs and checkpoint restores.
278-
old_members.sort(reverse=True, key=lambda x: (x[1].fitness, x[0]))
280+
# Sort members in order of descending fitness (best first), with
281+
# genome id as a deterministic tie-breaker so that ordering (and
282+
# thus parent selection) is reproducible across runs and checkpoint
283+
# restores. When fitness_criterion is 'min', lower fitness is
284+
# better, so we sort in ascending order instead.
285+
ascending = (config.fitness_criterion == 'min')
286+
old_members.sort(reverse=not ascending, key=lambda x: (x[1].fitness, x[0]))
279287

280288
# Transfer elites to new generation.
281289
if self.reproduction_config.elitism > 0:
@@ -315,7 +323,8 @@ def reproduce(self, config, species, pop_size, generation):
315323
# genetically identical clone of the parent (but with a different ID).
316324
gid = next(self.genome_indexer)
317325
child = config.genome_type(gid)
318-
child.configure_crossover(parent1, parent2, config.genome_config)
326+
child.configure_crossover(parent1, parent2, config.genome_config,
327+
fitness_criterion=config.fitness_criterion)
319328
child.mutate(config.genome_config)
320329
new_population[gid] = child
321330
self.ancestors[gid] = (parent1_id, parent2_id)

neat/stagnation.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,18 @@ def __init__(self, config, reporters):
3030

3131
self.reporters = reporters
3232

33-
def update(self, species_set, generation):
33+
def update(self, species_set, generation, config=None):
3434
"""
3535
Required interface method. Updates species fitness history information,
3636
checking for ones that have not improved in max_stagnation generations,
3737
and - unless it would result in the number of species dropping below the configured
3838
species_elitism parameter if they were removed,
3939
in which case the highest-fitness species are spared -
4040
returns a list with stagnant species marked for removal.
41+
42+
The *config* parameter (top-level Config object) is used to determine
43+
the fitness direction (max vs min). When omitted, higher fitness is
44+
assumed to be better (backward compatibility).
4145
"""
4246
species_data = []
4347
# Iterate species in a deterministic order (by species id) so that
@@ -46,20 +50,29 @@ def update(self, species_set, generation):
4650
for sid in sorted(species_set.species.keys()):
4751
s = species_set.species[sid]
4852
if s.fitness_history:
49-
prev_fitness = max(s.fitness_history)
53+
if config is not None:
54+
prev_fitness = (min if config.fitness_criterion == 'min' else max)(s.fitness_history)
55+
else:
56+
prev_fitness = max(s.fitness_history)
5057
else:
51-
prev_fitness = -sys.float_info.max
58+
prev_fitness = config.worst_fitness() if config is not None else -sys.float_info.max
5259

5360
s.fitness = self.species_fitness_func(s.get_fitnesses())
5461
s.fitness_history.append(s.fitness)
5562
s.adjusted_fitness = None
56-
if prev_fitness is None or s.fitness > prev_fitness:
63+
if config is not None:
64+
improved = config.is_better_fitness(s.fitness, prev_fitness)
65+
else:
66+
improved = s.fitness > prev_fitness
67+
if improved:
5768
s.last_improved = generation
5869

5970
species_data.append((sid, s))
6071

61-
# Sort in ascending fitness order.
62-
species_data.sort(key=lambda x: x[1].fitness)
72+
# Sort in ascending fitness order (least fit first, so they are
73+
# candidates for stagnation removal before the fittest species).
74+
reverse = (config is not None and config.fitness_criterion == 'min')
75+
species_data.sort(key=lambda x: x[1].fitness, reverse=reverse)
6376

6477
result = []
6578
species_fitnesses = []

neat/statistics.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@ def __init__(self):
2222
BaseReporter.__init__(self)
2323
self.most_fit_genomes = []
2424
self.generation_statistics = []
25+
self._fitness_criterion = None
2526

2627
def post_evaluate(self, config, population, species, best_genome):
28+
# Remember the fitness criterion so sorting methods can use it.
29+
if self._fitness_criterion is None:
30+
self._fitness_criterion = getattr(config, 'fitness_criterion', 'max')
2731
self.most_fit_genomes.append(copy.deepcopy(best_genome))
2832

2933
# Store the fitnesses of the members of each currently active species.
@@ -61,18 +65,14 @@ def best_unique_genomes(self, n):
6165
best_unique[g.key] = g
6266
best_unique_list = list(best_unique.values())
6367

64-
def key(genome):
65-
return genome.fitness
66-
67-
return sorted(best_unique_list, key=key, reverse=True)[:n]
68+
# When fitness_criterion is 'min', lower fitness is better.
69+
descending = (self._fitness_criterion != 'min')
70+
return sorted(best_unique_list, key=lambda g: g.fitness, reverse=descending)[:n]
6871

6972
def best_genomes(self, n):
7073
"""Returns the n most fit genomes ever seen."""
71-
72-
def key(g):
73-
return g.fitness
74-
75-
return sorted(self.most_fit_genomes, key=key, reverse=True)[:n]
74+
descending = (self._fitness_criterion != 'min')
75+
return sorted(self.most_fit_genomes, key=lambda g: g.fitness, reverse=descending)[:n]
7676

7777
def best_genome(self):
7878
"""Returns the most fit genome ever seen."""

tests/test_fitness_evaluation.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -124,26 +124,49 @@ def fitness_function(genomes, config):
124124

125125
def test_fitness_criterion_min(self):
126126
"""
127-
Test that 'min' fitness criterion selects minimum fitness.
128-
129-
Should terminate when min fitness reaches threshold.
127+
Test that 'min' fitness criterion treats lower fitness as better.
128+
129+
With fitness_criterion='min', termination occurs when the population's
130+
minimum fitness is at or below the threshold (i.e., the best genome
131+
has reached a sufficiently low fitness value).
130132
"""
131133
self.config.fitness_criterion = 'min'
132134
self.config.fitness_threshold = 0.5
133135
pop = neat.Population(self.config)
134-
136+
135137
generation_count = [0]
136-
138+
137139
def fitness_function(genomes, config):
138140
generation_count[0] += 1
139141
for i, (genome_id, genome) in enumerate(genomes):
140-
# All genomes get fitness >= 0.5
141-
genome.fitness = 0.6 + i * 0.1
142-
142+
# All genomes get fitness <= 0.5 (meeting the 'min' threshold)
143+
genome.fitness = 0.3 + i * 0.01
144+
143145
result = pop.run(fitness_function, 10)
144-
145-
# Should terminate after 1 generation (min = 0.6 >= 0.5)
146+
147+
# Should terminate after 1 generation (min = 0.3 <= 0.5)
146148
self.assertEqual(generation_count[0], 1)
149+
150+
def test_fitness_criterion_min_no_early_termination(self):
151+
"""
152+
Test that 'min' criterion does NOT terminate when fitness is above threshold.
153+
"""
154+
self.config.fitness_criterion = 'min'
155+
self.config.fitness_threshold = 0.5
156+
pop = neat.Population(self.config)
157+
158+
generation_count = [0]
159+
160+
def fitness_function(genomes, config):
161+
generation_count[0] += 1
162+
for i, (genome_id, genome) in enumerate(genomes):
163+
# All genomes have fitness above the threshold
164+
genome.fitness = 0.6 + i * 0.1
165+
166+
result = pop.run(fitness_function, 3)
167+
168+
# Should NOT terminate early — min fitness (0.6) > threshold (0.5)
169+
self.assertEqual(generation_count[0], 3)
147170

148171
def test_fitness_criterion_mean(self):
149172
"""

0 commit comments

Comments
 (0)