forked from ahmedfgad/GeneticAlgorithmPython
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_nsga3.py
More file actions
398 lines (327 loc) · 15.5 KB
/
Copy pathtest_nsga3.py
File metadata and controls
398 lines (327 loc) · 15.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
import math
import warnings
import numpy
import pytest
import pygad
from pygad.utils.nsga3 import NSGA3
@pytest.fixture
def nsga3():
return NSGA3()
# Six solutions in PyGAD maximization form. The same numbers under the
# usual minimization convention would be (1, 6), (2, 4.5), (3, 3), (4.5, 2),
# (6, 1), (4, 4). Solutions s1 and s5 are the two axis extremes; s6 is a
# dominated interior point. The expected NSGA-III values below were
# derived by hand from this fitness pool.
GUIDE_FITNESS_NEGATED = numpy.array([
[-1.0, -6.0], # s1
[-2.0, -4.5], # s2
[-3.0, -3.0], # s3
[-4.5, -2.0], # s4
[-6.0, -1.0], # s5
[-4.0, -4.0], # s6
])
def test_generate_reference_points_count_matches_binomial_for_M2_p3(nsga3):
points = nsga3.nsga3_generate_reference_points(num_objectives=2, num_divisions=3)
assert points.shape == (math.comb(2 + 3 - 1, 3), 2)
def test_generate_reference_points_count_matches_binomial_for_M3_p4(nsga3):
points = nsga3.nsga3_generate_reference_points(num_objectives=3, num_divisions=4)
assert points.shape == (math.comb(3 + 4 - 1, 4), 3)
def test_generate_reference_points_count_matches_binomial_for_M5_p4(nsga3):
points = nsga3.nsga3_generate_reference_points(num_objectives=5, num_divisions=4)
assert points.shape == (math.comb(5 + 4 - 1, 4), 5)
def test_generate_reference_points_rows_sum_to_one(nsga3):
points = nsga3.nsga3_generate_reference_points(num_objectives=3, num_divisions=4)
numpy.testing.assert_allclose(points.sum(axis=1), 1.0, atol=1e-12)
def test_generate_reference_points_M2_p3_matches_expected_set(nsga3):
points = nsga3.nsga3_generate_reference_points(num_objectives=2, num_divisions=3)
expected = numpy.array([
[3 / 3, 0 / 3],
[2 / 3, 1 / 3],
[1 / 3, 2 / 3],
[0 / 3, 3 / 3],
])
sorted_actual = numpy.array(sorted(points.tolist(), reverse=True))
sorted_expected = numpy.array(sorted(expected.tolist(), reverse=True))
numpy.testing.assert_allclose(sorted_actual, sorted_expected, atol=1e-12)
def test_compute_ideal_point_takes_column_max(nsga3):
fitness = numpy.array([
[1.0, 5.0],
[3.0, 2.0],
[0.0, 4.0],
])
ideal = nsga3.nsga3_compute_ideal_point(fitness)
numpy.testing.assert_allclose(ideal, [3.0, 5.0])
def test_compute_ideal_point_on_negated_six_solution_set(nsga3):
ideal = nsga3.nsga3_compute_ideal_point(GUIDE_FITNESS_NEGATED)
numpy.testing.assert_allclose(ideal, [-1.0, -1.0])
def test_find_extreme_points_picks_s5_for_f1_axis(nsga3):
ideal = nsga3.nsga3_compute_ideal_point(GUIDE_FITNESS_NEGATED)
extremes = nsga3.nsga3_find_extreme_points(GUIDE_FITNESS_NEGATED, ideal)
numpy.testing.assert_allclose(extremes[0], [-6.0, -1.0])
def test_find_extreme_points_picks_s1_for_f2_axis(nsga3):
ideal = nsga3.nsga3_compute_ideal_point(GUIDE_FITNESS_NEGATED)
extremes = nsga3.nsga3_find_extreme_points(GUIDE_FITNESS_NEGATED, ideal)
numpy.testing.assert_allclose(extremes[1], [-1.0, -6.0])
def test_compute_intercepts_six_solution_set_returns_minus_six(nsga3):
# Intercept point sits at ideal + 1/b, where b solves
# (extremes - ideal) @ b = 1. For this dataset both axes give -6.
# The extreme rows then normalize to the simplex corners (1, 0) and
# (0, 1).
ideal = nsga3.nsga3_compute_ideal_point(GUIDE_FITNESS_NEGATED)
extremes = nsga3.nsga3_find_extreme_points(GUIDE_FITNESS_NEGATED, ideal)
intercepts = nsga3.nsga3_compute_intercepts(extremes, ideal, GUIDE_FITNESS_NEGATED)
numpy.testing.assert_allclose(intercepts, [-6.0, -6.0], atol=1e-9)
def test_compute_intercepts_falls_back_to_nadir_on_singular_extremes(nsga3):
ideal = numpy.array([0.0, 0.0])
duplicate_extremes = numpy.array([
[-3.0, -2.0],
[-3.0, -2.0],
])
pool = numpy.array([
[-3.0, -2.0],
[-1.5, -4.0],
])
intercepts = nsga3.nsga3_compute_intercepts(duplicate_extremes, ideal, pool)
numpy.testing.assert_allclose(intercepts, pool.min(axis=0))
def test_normalize_fitness_places_extremes_at_simplex_corners(nsga3):
# With intercepts = (-6, -6) and ideal = (-1, -1) the denominator
# (intercepts - ideal) is (-5, -5) and the formula
# (f - ideal) / (intercepts - ideal) maps each row to a point inside
# the unit simplex. The two axis extremes (s5 and s1) land exactly on
# the simplex corners.
ideal = nsga3.nsga3_compute_ideal_point(GUIDE_FITNESS_NEGATED)
extremes = nsga3.nsga3_find_extreme_points(GUIDE_FITNESS_NEGATED, ideal)
intercepts = nsga3.nsga3_compute_intercepts(extremes, ideal, GUIDE_FITNESS_NEGATED)
normalized = nsga3.nsga3_normalize_fitness(GUIDE_FITNESS_NEGATED, ideal, intercepts)
expected = numpy.array([
[0.0, 1.0], # s1 -> simplex corner on f2
[0.2, 0.7], # s2
[0.4, 0.4], # s3
[0.7, 0.2], # s4
[1.0, 0.0], # s5 -> simplex corner on f1
[0.6, 0.6], # s6 (dominated)
])
numpy.testing.assert_allclose(normalized, expected, atol=1e-9)
def test_normalize_fitness_clips_above_one_and_below_zero(nsga3):
# First row sits "above" the ideal under maximization (raw values
# bigger than the ideal) so the formula would produce a negative
# ratio. Second row sits below the intercept and would produce a
# ratio above 1. Both must be clipped back to [0, 1].
ideal = numpy.array([0.0, 0.0])
intercepts = numpy.array([-1.0, -1.0])
fitness = numpy.array([
[0.5, 0.5],
[-2.0, -2.0],
])
normalized = nsga3.nsga3_normalize_fitness(fitness, ideal, intercepts)
assert normalized.min() >= 0.0
assert normalized.max() <= 1.0
def test_normalize_fitness_handles_near_zero_negative_denominator(nsga3):
# Intercept sits within 1e-12 of the ideal so the denominator
# collapses to a tiny negative. The safeguard must keep the sign
# negative so (fitness - ideal) / denom comes out positive (and
# then clips to 1.0). A buggy safeguard that lets the denom flip
# to zero or positive would produce inf / nan or 0.0 instead.
ideal = numpy.array([0.0])
intercepts = numpy.array([-1e-15])
fitness = numpy.array([[-1.0]])
normalized = nsga3.nsga3_normalize_fitness(fitness, ideal, intercepts)
assert numpy.all(numpy.isfinite(normalized))
assert normalized[0, 0] == pytest.approx(1.0)
# Reference points for M=2, p=3 in the order
# nsga3_generate_reference_points emits them (stars-and-bars enumeration).
REFERENCE_POINTS_M2_P3 = numpy.array([
[1.0, 0.0 ], # ref 0
[2 / 3, 1 / 3], # ref 1
[1 / 3, 2 / 3], # ref 2
[0.0, 1.0 ], # ref 3
])
def test_associate_picks_nearest_reference_line(nsga3):
# The point (0, 1) lies on the f2 axis and is collinear with ref 3.
# Perpendicular distance is zero.
point = numpy.array([[0.0, 1.0]])
nearest, distance = nsga3.nsga3_associate_to_reference_points(point, REFERENCE_POINTS_M2_P3)
assert nearest[0] == 3
assert distance[0] == pytest.approx(0.0, abs=1e-12)
def test_associate_breaks_ties_by_lower_reference_index(nsga3):
# The point (0.6, 0.6) sits on the diagonal and is the same distance
# from ref 1 and ref 2. The lower index wins.
point = numpy.array([[0.6, 0.6]])
nearest, _ = nsga3.nsga3_associate_to_reference_points(point, REFERENCE_POINTS_M2_P3)
assert nearest[0] == 1
def test_associate_perpendicular_distance_for_diagonal_point(nsga3):
# Same diagonal point. Expected distance ~ 0.2683 computed by hand
# from the formula || x - (x . z_hat) z_hat ||.
point = numpy.array([[0.6, 0.6]])
_, distance = nsga3.nsga3_associate_to_reference_points(point, REFERENCE_POINTS_M2_P3)
assert distance[0] == pytest.approx(0.2683, abs=1e-3)
def test_niching_with_single_critical_front_candidate_returns_that_candidate(nsga3):
critical_front_indices = [42]
critical_front_associations = numpy.array([1])
critical_front_distances = numpy.array([0.224])
accepted_associations = numpy.array([3, 2, 1, 1, 0])
picked = nsga3.nsga3_niching_select(
critical_front_indices=critical_front_indices,
critical_front_associations=critical_front_associations,
critical_front_distances=critical_front_distances,
accepted_associations=accepted_associations,
num_reference_points=4,
num_to_select=1)
assert picked == [42]
def test_niching_picks_candidate_in_lower_niche_count(nsga3):
# Two candidates. The first is associated with ref 1 (niche count 2);
# the second with ref 2 (niche count 1). The lower niche count wins.
critical_front_indices = [60, 70]
critical_front_associations = numpy.array([1, 2])
critical_front_distances = numpy.array([0.224, 0.10])
accepted_associations = numpy.array([3, 2, 1, 1, 0])
picked = nsga3.nsga3_niching_select(
critical_front_indices=critical_front_indices,
critical_front_associations=critical_front_associations,
critical_front_distances=critical_front_distances,
accepted_associations=accepted_associations,
num_reference_points=4,
num_to_select=1)
assert picked == [70]
def test_niching_picks_smallest_distance_when_niche_count_is_zero(nsga3):
# Both candidates are at ref 1 with niche count 0 (empty niche). The
# closer candidate wins (distance 0.158 < 0.224).
critical_front_indices = [60, 70]
critical_front_associations = numpy.array([1, 1])
critical_front_distances = numpy.array([0.224, 0.158])
accepted_associations = numpy.array([3, 2, 0])
picked = nsga3.nsga3_niching_select(
critical_front_indices=critical_front_indices,
critical_front_associations=critical_front_associations,
critical_front_distances=critical_front_distances,
accepted_associations=accepted_associations,
num_reference_points=4,
num_to_select=1)
assert picked == [70]
def test_niching_picks_from_candidate_pool_when_niche_count_is_positive(nsga3):
# Both candidates are at ref 1 with niche count > 0, so the pick is
# random. Run 50 different seeds and verify that the chosen candidate
# always comes from {60, 70} and that both candidates show up over
# the run.
critical_front_indices = [60, 70]
critical_front_associations = numpy.array([1, 1])
critical_front_distances = numpy.array([0.224, 0.158])
accepted_associations = numpy.array([3, 2, 1, 1, 0])
seen = set()
rng_state = numpy.random.get_state()
try:
for seed in range(50):
numpy.random.seed(seed)
picked = nsga3.nsga3_niching_select(
critical_front_indices=critical_front_indices,
critical_front_associations=critical_front_associations,
critical_front_distances=critical_front_distances,
accepted_associations=accepted_associations,
num_reference_points=4,
num_to_select=1)
assert picked[0] in {60, 70}
seen.add(picked[0])
finally:
numpy.random.set_state(rng_state)
assert seen == {60, 70}
# Fitness helpers used by the integration tests below. The scalar one
# returns a single number so we can check that NSGA-III rejects it; the
# other two return a list of objectives.
def _scalar_fitness(ga, solution, sol_idx):
return float(numpy.sum(solution))
def _two_objective_fitness(ga, solution, sol_idx):
return [float(numpy.sum(solution)), -float(numpy.sum(solution ** 2))]
def _three_objective_fitness(ga, solution, sol_idx):
return [float(solution[0]), float(solution[1]), float(solution[2])]
def test_nsga3_requires_nsga3_num_divisions():
with pytest.raises(ValueError, match="nsga3_num_divisions"):
pygad.GA(num_generations=2,
num_parents_mating=3,
fitness_func=_two_objective_fitness,
sol_per_pop=8,
num_genes=4,
parent_selection_type='nsga3',
suppress_warnings=True)
def test_nsga3_rejects_non_positive_nsga3_num_divisions():
with pytest.raises(ValueError, match="nsga3_num_divisions"):
pygad.GA(num_generations=2,
num_parents_mating=3,
fitness_func=_two_objective_fitness,
sol_per_pop=8,
num_genes=4,
parent_selection_type='nsga3',
nsga3_num_divisions=0,
suppress_warnings=True)
def test_nsga3_rejects_single_objective_problem():
ga = pygad.GA(num_generations=2,
num_parents_mating=3,
fitness_func=_scalar_fitness,
sol_per_pop=8,
num_genes=4,
parent_selection_type='nsga3',
nsga3_num_divisions=4,
suppress_warnings=True)
with pytest.raises(TypeError, match="single-objective"):
ga.run()
def test_tournament_nsga3_rejects_single_objective_problem():
ga = pygad.GA(num_generations=2,
num_parents_mating=3,
fitness_func=_scalar_fitness,
sol_per_pop=8,
num_genes=4,
parent_selection_type='tournament_nsga3',
nsga3_num_divisions=4,
K_tournament=2,
suppress_warnings=True)
with pytest.raises(TypeError, match="single-objective"):
ga.run()
def test_nsga3_bootstrap_generates_reference_points_with_expected_shape():
ga = pygad.GA(num_generations=2,
num_parents_mating=5,
fitness_func=_three_objective_fitness,
sol_per_pop=15,
num_genes=4,
parent_selection_type='nsga3',
nsga3_num_divisions=4,
random_seed=1,
suppress_warnings=True)
ga.run()
assert ga.nsga3_reference_points.shape == (15, 3)
def test_sol_per_pop_below_reference_count_triggers_warning_and_grows_population():
# M=3, p=4 needs 15 reference points but sol_per_pop is only 8. The
# GA should warn once, grow the population to 15, and re-evaluate
# fitness before the generational loop starts.
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
ga = pygad.GA(num_generations=2,
num_parents_mating=3,
fitness_func=_three_objective_fitness,
sol_per_pop=8,
num_genes=4,
parent_selection_type='nsga3',
nsga3_num_divisions=4,
random_seed=1)
ga.run()
nsga3_warning_messages = [str(w.message) for w in caught
if "NSGA-III reference points" in str(w.message)]
assert len(nsga3_warning_messages) == 1
assert ga.sol_per_pop == 15
assert ga.population.shape[0] == 15
def test_sol_per_pop_auto_grow_also_fires_for_tournament_nsga3():
# Same scenario but using the tournament-based NSGA-III selection.
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
ga = pygad.GA(num_generations=2,
num_parents_mating=3,
fitness_func=_three_objective_fitness,
sol_per_pop=8,
num_genes=4,
parent_selection_type='tournament_nsga3',
nsga3_num_divisions=4,
K_tournament=2,
random_seed=2)
ga.run()
grown_warning_messages = [str(w.message) for w in caught
if "NSGA-III reference points" in str(w.message)]
assert len(grown_warning_messages) == 1
assert ga.sol_per_pop == 15