evograd-diff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. evograd/__init__.py +67 -0
  2. evograd/algorithms/__init__.py +138 -0
  3. evograd/algorithms/cmaes.py +1365 -0
  4. evograd/algorithms/de.py +895 -0
  5. evograd/algorithms/ga.py +532 -0
  6. evograd/algorithms/pso.py +648 -0
  7. evograd/algorithms/shade.py +1165 -0
  8. evograd/benchmarks/functions/__init__.py +229 -0
  9. evograd/benchmarks/functions/base.py +217 -0
  10. evograd/benchmarks/functions/cec2017/__init__.py +250 -0
  11. evograd/benchmarks/functions/cec2017/basic.py +413 -0
  12. evograd/benchmarks/functions/cec2017/composition.py +580 -0
  13. evograd/benchmarks/functions/cec2017/data.pkl +0 -0
  14. evograd/benchmarks/functions/cec2017/data.py +350 -0
  15. evograd/benchmarks/functions/cec2017/hybrid.py +406 -0
  16. evograd/benchmarks/functions/cec2017/simple.py +326 -0
  17. evograd/benchmarks/functions/classical.py +649 -0
  18. evograd/benchmarks/functions/smoothed_funnel.py +476 -0
  19. evograd/benchmarks/functions/transforms.py +463 -0
  20. evograd/benchmarks/run_benchmark_functions.py +1208 -0
  21. evograd/core/__init__.py +73 -0
  22. evograd/core/algorithm.py +778 -0
  23. evograd/core/maximize.py +269 -0
  24. evograd/core/minimize.py +740 -0
  25. evograd/core/problem.py +444 -0
  26. evograd/core/result.py +571 -0
  27. evograd/core/termination.py +602 -0
  28. evograd/operators/__init__.py +178 -0
  29. evograd/operators/crossover.py +1117 -0
  30. evograd/operators/mutation.py +1098 -0
  31. evograd/operators/relaxations.py +175 -0
  32. evograd/operators/repair.py +601 -0
  33. evograd/operators/sampling.py +577 -0
  34. evograd/operators/selection.py +981 -0
  35. evograd/operators/survival.py +1000 -0
  36. evograd/tests/__init__.py +11 -0
  37. evograd/tests/run_all.py +78 -0
  38. evograd/tests/test_core.py +528 -0
  39. evograd/tests/test_ga.py +572 -0
  40. evograd/tests/test_operators.py +662 -0
  41. evograd/tests/test_per_individual.py +326 -0
  42. evograd/tests/test_utils.py +328 -0
  43. evograd/utils/__init__.py +97 -0
  44. evograd/utils/callbacks.py +926 -0
  45. evograd/utils/device.py +502 -0
  46. evograd/utils/duplicates.py +421 -0
  47. evograd_diff-0.1.0.dist-info/METADATA +439 -0
  48. evograd_diff-0.1.0.dist-info/RECORD +50 -0
  49. evograd_diff-0.1.0.dist-info/WHEEL +4 -0
  50. evograd_diff-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,326 @@
1
+ """
2
+ Test script for per-individual/per-gene parameter support in operators.
3
+
4
+ This script demonstrates the four parameter configurations:
5
+ 1. Fixed (scalar): Same value for all individuals and genes
6
+ 2. Per-gene [D]: Different value per gene, same across individuals
7
+ 3. Per-individual [N]: Different value per individual, same across genes
8
+ 4. Per-gene + Per-individual [N, D]: Full matrix
9
+
10
+ Run with:
11
+ python -m tests.test_per_individual
12
+ """
13
+
14
+ import torch
15
+ import sys
16
+ import os
17
+
18
+ # Add parent to path
19
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+
21
+ from evograd.operators.crossover import (
22
+ BinomialCrossover,
23
+ SBXCrossover,
24
+ BlendCrossover,
25
+ ArithmeticCrossover,
26
+ )
27
+ from evograd.operators.mutation import (
28
+ PolynomialMutation,
29
+ GaussianMutation,
30
+ UniformMutation,
31
+ )
32
+
33
+
34
+ def test_crossover_configurations():
35
+ """Test all four parameter configurations for crossover operators."""
36
+ print("\n" + "=" * 70)
37
+ print("Testing Crossover Per-Individual/Per-Gene Configurations")
38
+ print("=" * 70)
39
+
40
+ N, D = 50, 10 # 50 individuals, 10 variables
41
+ parent1 = torch.randn(N, D)
42
+ parent2 = torch.randn(N, D)
43
+
44
+ # ==========================================================================
45
+ # BinomialCrossover (DE-style) - Most important for SHADE
46
+ # ==========================================================================
47
+ print("\n1. BinomialCrossover configurations:")
48
+ crossover = BinomialCrossover(cr=0.9)
49
+
50
+ # Config 1: Fixed (default)
51
+ print(" a) Fixed CR (scalar) - default behavior")
52
+ trial = crossover(parent1, parent2)
53
+ assert trial.shape == (N, D), f"Expected ({N}, {D}), got {trial.shape}"
54
+ print(f" Shape: {trial.shape} ✓")
55
+
56
+ # Config 2: Per-gene [D]
57
+ print(" b) Per-gene CR [D]")
58
+ cr_per_gene = torch.linspace(0.5, 1.0, D) # Different CR per gene
59
+ trial = crossover(parent1, parent2, cr=cr_per_gene)
60
+ assert trial.shape == (N, D)
61
+ print(f" CR shape: {cr_per_gene.shape} -> Output: {trial.shape} ✓")
62
+
63
+ # Config 3: Per-individual [N] - SHADE needs this!
64
+ print(" c) Per-individual CR [N] - SHADE-style")
65
+ cr_per_ind = torch.rand(N) * 0.5 + 0.5 # CR in [0.5, 1.0] per individual
66
+ trial = crossover(parent1, parent2, cr=cr_per_ind)
67
+ assert trial.shape == (N, D)
68
+ print(f" CR shape: {cr_per_ind.shape} -> Output: {trial.shape} ✓")
69
+
70
+ # Config 4: Full matrix [N, D]
71
+ print(" d) Full matrix CR [N, D]")
72
+ cr_matrix = torch.rand(N, D)
73
+ trial = crossover(parent1, parent2, cr=cr_matrix)
74
+ assert trial.shape == (N, D)
75
+ print(f" CR shape: {cr_matrix.shape} -> Output: {trial.shape} ✓")
76
+
77
+ # ==========================================================================
78
+ # SBXCrossover - Test eta and prob overrides
79
+ # ==========================================================================
80
+ print("\n2. SBXCrossover configurations:")
81
+ sbx = SBXCrossover(eta=15, prob=0.9)
82
+
83
+ # Per-individual eta
84
+ print(" a) Per-individual eta [N]")
85
+ eta_per_ind = torch.rand(N) * 15 + 5 # eta in [5, 20] per individual
86
+ offspring = sbx(parent1, parent2, eta=eta_per_ind)
87
+ assert offspring.shape == (N, D)
88
+ print(f" Eta shape: {eta_per_ind.shape} -> Output: {offspring.shape} ✓")
89
+
90
+ # Per-gene prob
91
+ print(" b) Per-gene prob [D]")
92
+ prob_per_gene = torch.linspace(0.5, 1.0, D)
93
+ offspring = sbx(parent1, parent2, prob=prob_per_gene)
94
+ assert offspring.shape == (N, D)
95
+ print(f" Prob shape: {prob_per_gene.shape} -> Output: {offspring.shape} ✓")
96
+
97
+ # Both eta and prob as full matrices
98
+ print(" c) Full matrix eta and prob [N, D]")
99
+ eta_matrix = torch.rand(N, D) * 15 + 5
100
+ prob_matrix = torch.rand(N, D) * 0.5 + 0.5
101
+ offspring = sbx(parent1, parent2, eta=eta_matrix, prob=prob_matrix)
102
+ assert offspring.shape == (N, D)
103
+ print(f" Eta: {eta_matrix.shape}, Prob: {prob_matrix.shape} -> Output: {offspring.shape} ✓")
104
+
105
+ # ==========================================================================
106
+ # ArithmeticCrossover - Test alpha override
107
+ # ==========================================================================
108
+ print("\n3. ArithmeticCrossover configurations:")
109
+ arith = ArithmeticCrossover(alpha=0.5)
110
+
111
+ # Per-individual alpha
112
+ print(" a) Per-individual alpha [N]")
113
+ alpha_per_ind = torch.rand(N)
114
+ offspring = arith(parent1, parent2, alpha=alpha_per_ind)
115
+ assert offspring.shape == (N, D)
116
+ print(f" Alpha shape: {alpha_per_ind.shape} -> Output: {offspring.shape} ✓")
117
+
118
+ # Verify arithmetic crossover formula
119
+ print(" b) Verify formula: offspring = alpha * p1 + (1-alpha) * p2")
120
+ alpha_test = 0.3
121
+ offspring_test = arith(parent1, parent2, alpha=alpha_test)
122
+ expected = alpha_test * parent1 + (1 - alpha_test) * parent2
123
+ assert torch.allclose(offspring_test, expected, atol=1e-6)
124
+ print(f" Formula verified ✓")
125
+
126
+ print("\n✓ All crossover configurations passed!")
127
+
128
+
129
+ def test_mutation_configurations():
130
+ """Test all four parameter configurations for mutation operators."""
131
+ print("\n" + "=" * 70)
132
+ print("Testing Mutation Per-Individual/Per-Gene Configurations")
133
+ print("=" * 70)
134
+
135
+ N, D = 50, 10 # 50 individuals, 10 variables
136
+ population = torch.randn(N, D)
137
+ xl = torch.zeros(D)
138
+ xu = torch.ones(D)
139
+
140
+ # ==========================================================================
141
+ # PolynomialMutation - Test eta and prob overrides
142
+ # ==========================================================================
143
+ print("\n1. PolynomialMutation configurations:")
144
+ mutation = PolynomialMutation(eta=20, prob=0.1)
145
+
146
+ # Config 1: Fixed (default)
147
+ print(" a) Fixed eta and prob (scalar) - default behavior")
148
+ mutated = mutation(population, xl, xu)
149
+ assert mutated.shape == (N, D)
150
+ print(f" Shape: {mutated.shape} ✓")
151
+
152
+ # Config 2: Per-gene [D]
153
+ print(" b) Per-gene eta [D]")
154
+ eta_per_gene = torch.linspace(10, 30, D)
155
+ mutated = mutation(population, xl, xu, eta=eta_per_gene)
156
+ assert mutated.shape == (N, D)
157
+ print(f" Eta shape: {eta_per_gene.shape} -> Output: {mutated.shape} ✓")
158
+
159
+ # Config 3: Per-individual [N]
160
+ print(" c) Per-individual eta [N] - Self-adaptive GA style")
161
+ eta_per_ind = torch.rand(N) * 20 + 10 # eta in [10, 30] per individual
162
+ mutated = mutation(population, xl, xu, eta=eta_per_ind)
163
+ assert mutated.shape == (N, D)
164
+ print(f" Eta shape: {eta_per_ind.shape} -> Output: {mutated.shape} ✓")
165
+
166
+ # Config 4: Full matrix [N, D]
167
+ print(" d) Full matrix eta and prob [N, D]")
168
+ eta_matrix = torch.rand(N, D) * 20 + 10
169
+ prob_matrix = torch.rand(N, D) * 0.2
170
+ mutated = mutation(population, xl, xu, eta=eta_matrix, prob=prob_matrix)
171
+ assert mutated.shape == (N, D)
172
+ print(f" Eta: {eta_matrix.shape}, Prob: {prob_matrix.shape} -> Output: {mutated.shape} ✓")
173
+
174
+ # ==========================================================================
175
+ # GaussianMutation - Test sigma override (important for DE/SHADE)
176
+ # ==========================================================================
177
+ print("\n2. GaussianMutation configurations:")
178
+ gauss = GaussianMutation(sigma=0.1)
179
+
180
+ # Per-individual sigma (like F in DE/SHADE)
181
+ print(" a) Per-individual sigma [N] - SHADE F-style")
182
+ F_per_ind = torch.rand(N) * 0.5 + 0.5 # F in [0.5, 1.0] per individual
183
+ mutated = gauss(population, xl, xu, sigma=F_per_ind)
184
+ assert mutated.shape == (N, D)
185
+ print(f" Sigma shape: {F_per_ind.shape} -> Output: {mutated.shape} ✓")
186
+
187
+ # Per-gene sigma
188
+ print(" b) Per-gene sigma [D]")
189
+ sigma_per_gene = torch.linspace(0.05, 0.2, D)
190
+ mutated = gauss(population, xl, xu, sigma=sigma_per_gene)
191
+ assert mutated.shape == (N, D)
192
+ print(f" Sigma shape: {sigma_per_gene.shape} -> Output: {mutated.shape} ✓")
193
+
194
+ # ==========================================================================
195
+ # UniformMutation - Test prob override
196
+ # ==========================================================================
197
+ print("\n3. UniformMutation configurations:")
198
+ unif = UniformMutation(prob=0.05)
199
+
200
+ # Per-individual prob
201
+ print(" a) Per-individual prob [N]")
202
+ prob_per_ind = torch.rand(N) * 0.1
203
+ mutated = unif(population, xl, xu, prob=prob_per_ind)
204
+ assert mutated.shape == (N, D)
205
+ print(f" Prob shape: {prob_per_ind.shape} -> Output: {mutated.shape} ✓")
206
+
207
+ print("\n✓ All mutation configurations passed!")
208
+
209
+
210
+ def test_shade_style_usage():
211
+ """Demonstrate SHADE-style usage with per-individual F and CR."""
212
+ print("\n" + "=" * 70)
213
+ print("Demonstrating SHADE-style Usage")
214
+ print("=" * 70)
215
+
216
+ N, D = 100, 30
217
+
218
+ # Simulated SHADE setup
219
+ print("\n1. Setting up SHADE-style parameters:")
220
+
221
+ # Population
222
+ population = torch.rand(N, D)
223
+ xl = torch.zeros(D)
224
+ xu = torch.ones(D)
225
+
226
+ # Per-individual F and CR (sampled from historical memory in real SHADE)
227
+ F_per_ind = torch.randn(N).abs() * 0.3 + 0.5 # F ~ |N(0.5, 0.3)|
228
+ CR_per_ind = torch.randn(N) * 0.1 + 0.5 # CR ~ N(0.5, 0.1)
229
+ CR_per_ind = CR_per_ind.clamp(0, 1)
230
+
231
+ print(f" F per individual: shape={F_per_ind.shape}, range=[{F_per_ind.min():.3f}, {F_per_ind.max():.3f}]")
232
+ print(f" CR per individual: shape={CR_per_ind.shape}, range=[{CR_per_ind.min():.3f}, {CR_per_ind.max():.3f}]")
233
+
234
+ # Select random individuals for mutation (DE/rand/1)
235
+ print("\n2. Creating donor vectors (DE/rand/1 style):")
236
+ r1 = torch.randint(0, N, (N,))
237
+ r2 = torch.randint(0, N, (N,))
238
+ r3 = torch.randint(0, N, (N,))
239
+
240
+ # Mutation: donor = x_r1 + F * (x_r2 - x_r3)
241
+ # Here we use per-individual F
242
+ diff = population[r2] - population[r3]
243
+ donor = population[r1] + F_per_ind.unsqueeze(1) * diff
244
+ print(f" Donor vectors created: {donor.shape}")
245
+
246
+ # Binomial crossover with per-individual CR
247
+ print("\n3. Applying binomial crossover with per-individual CR:")
248
+ crossover = BinomialCrossover(cr=0.5) # Default CR, but we'll override
249
+ trial = crossover(population, donor, cr=CR_per_ind)
250
+ print(f" Trial vectors created: {trial.shape}")
251
+
252
+ # Verify different individuals have different number of genes crossed
253
+ genes_from_donor = (trial == donor).float().sum(dim=1)
254
+ print(f" Genes from donor (per individual): mean={genes_from_donor.mean():.1f}, std={genes_from_donor.std():.1f}")
255
+
256
+ print("\n✓ SHADE-style demonstration complete!")
257
+
258
+
259
+ def test_differentiable_mode():
260
+ """Test gradient flow with per-individual parameters."""
261
+ print("\n" + "=" * 70)
262
+ print("Testing Gradient Flow with Per-Individual Parameters")
263
+ print("=" * 70)
264
+
265
+ N, D = 20, 5
266
+
267
+ # Differentiable operators
268
+ print("\n1. SBXCrossover with gradient flow:")
269
+ sbx = SBXCrossover(eta=15, prob=0.9, differentiable=True, learn_eta=True)
270
+
271
+ p1 = torch.nn.Parameter(torch.randn(N, D))
272
+ p2 = torch.randn(N, D)
273
+ eta_per_ind = torch.rand(N) * 10 + 5 # Not learnable, just passed in
274
+
275
+ offspring = sbx(p1, p2, eta=eta_per_ind)
276
+ loss = offspring.sum()
277
+ loss.backward()
278
+
279
+ assert p1.grad is not None, "Gradients should flow to parent1"
280
+ print(f" Gradients flow through per-individual eta: ✓")
281
+ print(f" Parent1 grad norm: {p1.grad.norm():.4f}")
282
+
283
+ # PolynomialMutation with gradient flow
284
+ print("\n2. PolynomialMutation with gradient flow:")
285
+ mutation = PolynomialMutation(eta=20, prob=0.1, differentiable=True, learn_eta=True)
286
+
287
+ x = torch.nn.Parameter(torch.randn(N, D))
288
+ xl = torch.zeros(D)
289
+ xu = torch.ones(D)
290
+ eta_per_ind = torch.rand(N) * 20 + 10
291
+
292
+ mutated = mutation(x, xl, xu, eta=eta_per_ind)
293
+ loss = mutated.sum()
294
+ loss.backward()
295
+
296
+ assert x.grad is not None, "Gradients should flow to input"
297
+ print(f" Gradients flow through per-individual eta: ✓")
298
+ print(f" Input grad norm: {x.grad.norm():.4f}")
299
+
300
+ print("\n✓ All gradient tests passed!")
301
+
302
+
303
+ def main():
304
+ """Run all tests."""
305
+ print("\n" + "#" * 70)
306
+ print("# Per-Individual/Per-Gene Parameter Support Tests")
307
+ print("#" * 70)
308
+
309
+ test_crossover_configurations()
310
+ test_mutation_configurations()
311
+ test_shade_style_usage()
312
+ test_differentiable_mode()
313
+
314
+ print("\n" + "=" * 70)
315
+ print("ALL TESTS PASSED! ✓")
316
+ print("=" * 70)
317
+
318
+ print("\nSummary of Four Configurations:")
319
+ print(" 1. Fixed (scalar) - Same value for all")
320
+ print(" 2. Per-gene [D] - Different per variable")
321
+ print(" 3. Per-individual [N] - Different per individual (SHADE needs this!)")
322
+ print(" 4. Full matrix [N, D] - Maximum flexibility")
323
+
324
+
325
+ if __name__ == "__main__":
326
+ main()
@@ -0,0 +1,328 @@
1
+ """
2
+ Test script for EvoGrad utils module.
3
+
4
+ Tests:
5
+ - device.py: Device detection and tensor movement
6
+ - duplicates.py: Duplicate elimination
7
+ - callbacks.py: Callback system
8
+
9
+ Usage:
10
+ python -m evograd.tests.test_utils
11
+ """
12
+
13
+ import sys
14
+ import torch
15
+ import torch.nn as nn
16
+ import tempfile
17
+ import os
18
+
19
+ # Add parent of evograd to path for imports
20
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
21
+
22
+ from evograd.utils.device import get_device, to_device
23
+ from evograd.utils.duplicates import (
24
+ DuplicateMethod,
25
+ DuplicateEliminator,
26
+ eliminate_duplicates,
27
+ has_duplicates,
28
+ count_duplicates,
29
+ )
30
+ from evograd.utils.callbacks import (
31
+ Callback,
32
+ CallbackList,
33
+ CallbackState,
34
+ HistoryCallback,
35
+ PrintCallback,
36
+ EarlyStoppingCallback,
37
+ CheckpointCallback,
38
+ ConvergenceCallback,
39
+ )
40
+
41
+
42
+ def test_device():
43
+ """Test device detection and tensor movement."""
44
+ print("\n" + "="*60)
45
+ print("Testing device.py")
46
+ print("="*60)
47
+
48
+ # Test get_device
49
+ print("\n1. Testing get_device()...")
50
+ device = get_device()
51
+ print(f" Default device: {device}")
52
+ assert isinstance(device, torch.device)
53
+
54
+ # Test with specific device string
55
+ cpu_device = get_device("cpu")
56
+ print(f" CPU device: {cpu_device}")
57
+ assert cpu_device.type == "cpu"
58
+
59
+ # Test auto detection (None means auto)
60
+ auto_device = get_device(None)
61
+ print(f" Auto device: {auto_device}")
62
+
63
+ # Test to_device with tensors (keyword-only device argument)
64
+ print("\n2. Testing to_device() with tensors...")
65
+ x = torch.randn(10, 5)
66
+ y = torch.randn(3, 3)
67
+ x_moved, y_moved = to_device(x, y, device=cpu_device)
68
+ print(f" Moved tensors to {x_moved.device}")
69
+ assert x_moved.device == cpu_device
70
+ assert y_moved.device == cpu_device
71
+
72
+ # Test to_device with single tensor
73
+ print("\n3. Testing to_device() with single tensor...")
74
+ z = torch.randn(5)
75
+ (z_moved,) = to_device(z, device=cpu_device)
76
+ print(f" Moved single tensor to {z_moved.device}")
77
+ assert z_moved.device == cpu_device
78
+
79
+ print("\n✓ device.py tests passed!")
80
+
81
+
82
+ def test_duplicates():
83
+ """Test duplicate elimination strategies."""
84
+ print("\n" + "="*60)
85
+ print("Testing duplicates.py")
86
+ print("="*60)
87
+
88
+ # Create test population with duplicates
89
+ pop = torch.tensor([
90
+ [1.0, 2.0, 3.0],
91
+ [1.0, 2.0, 3.0], # Exact duplicate of row 0
92
+ [4.0, 5.0, 6.0],
93
+ [1.0001, 2.0001, 3.0001], # Near duplicate of row 0
94
+ [7.0, 8.0, 9.0],
95
+ ])
96
+
97
+ xl = torch.zeros(3)
98
+ xu = torch.ones(3) * 10
99
+
100
+ # Test DuplicateEliminator with EPSILON_L2
101
+ print("\n1. Testing DuplicateEliminator (EPSILON_L2)...")
102
+ eliminator = DuplicateEliminator(
103
+ method=DuplicateMethod.EPSILON_L2,
104
+ epsilon=0.01,
105
+ )
106
+
107
+ # Eliminate duplicates by calling the eliminator
108
+ new_pop = eliminator(pop, xl, xu)
109
+ print(f" Original population shape: {pop.shape}")
110
+ print(f" Population after elimination: {new_pop.shape}")
111
+ print(f" Duplicates found: {eliminator.n_duplicates_found}")
112
+ print(f" Duplicates resolved: {eliminator.n_duplicates_resolved}")
113
+
114
+ # Test has_duplicates function
115
+ print("\n2. Testing has_duplicates()...")
116
+ has_dups = has_duplicates(pop, epsilon=0.01)
117
+ print(f" Has duplicates: {has_dups}")
118
+ assert has_dups == True, "Population should have duplicates"
119
+
120
+ # Test count_duplicates function
121
+ print("\n3. Testing count_duplicates()...")
122
+ n_dups = count_duplicates(pop, epsilon=0.01)
123
+ print(f" Number of duplicates: {n_dups}")
124
+ assert n_dups >= 1, "Should find at least 1 duplicate"
125
+
126
+ # Test DuplicateEliminator with HASH method
127
+ print("\n4. Testing DuplicateEliminator (HASH)...")
128
+ hash_eliminator = DuplicateEliminator(
129
+ method=DuplicateMethod.HASH,
130
+ decimals=2,
131
+ )
132
+ new_pop_hash = hash_eliminator(pop, xl, xu)
133
+ print(f" Hash method duplicates found: {hash_eliminator.n_duplicates_found}")
134
+
135
+ # Test DuplicateEliminator with NONE method
136
+ print("\n5. Testing DuplicateEliminator (NONE)...")
137
+ no_elim = DuplicateEliminator(method=DuplicateMethod.NONE)
138
+ new_pop_none = no_elim(pop, xl, xu)
139
+ assert torch.allclose(new_pop_none, pop), "NONE method should not modify population"
140
+ print(" NONE method correctly leaves population unchanged")
141
+
142
+ # Test eliminate_duplicates convenience function
143
+ print("\n6. Testing eliminate_duplicates()...")
144
+ cleaned_pop = eliminate_duplicates(pop, xl, xu, epsilon=0.01)
145
+ print(f" Cleaned population shape: {cleaned_pop.shape}")
146
+
147
+ print("\n✓ duplicates.py tests passed!")
148
+
149
+
150
+ def test_callbacks():
151
+ """Test callback system."""
152
+ print("\n" + "="*60)
153
+ print("Testing callbacks.py")
154
+ print("="*60)
155
+
156
+ # Test HistoryCallback
157
+ print("\n1. Testing HistoryCallback...")
158
+ history_cb = HistoryCallback(
159
+ track_population=True,
160
+ track_hyperparams=True,
161
+ track_diversity=False,
162
+ track_fitness_stats=True,
163
+ )
164
+
165
+ # Create initial state
166
+ state = CallbackState(
167
+ generation=0,
168
+ n_evals=0,
169
+ best_fitness=float('inf'),
170
+ best_solution=torch.randn(5),
171
+ current_fitness=torch.randn(10),
172
+ current_population=torch.randn(10, 5),
173
+ )
174
+
175
+ history_cb.on_optimisation_start(state)
176
+
177
+ for gen in range(5):
178
+ state.generation = gen
179
+ state.n_evals = (gen + 1) * 10
180
+ state.best_fitness = 100.0 / (gen + 1)
181
+ state.current_fitness = torch.randn(10)
182
+ history_cb.on_generation_end(state)
183
+
184
+ print(f" Tracked generations: {len(history_cb.generations)}")
185
+ print(f" Best fitness history: {history_cb.best_fitness[:3]}...")
186
+ assert len(history_cb.generations) == 5
187
+ assert len(history_cb.best_fitness) == 5
188
+
189
+ # Test PrintCallback
190
+ print("\n2. Testing PrintCallback...")
191
+ print_cb = PrintCallback(every=2, show_time=True)
192
+
193
+ state = CallbackState(generation=0, n_evals=0, best_fitness=100.0)
194
+ print_cb.on_optimisation_start(state)
195
+
196
+ for gen in range(4):
197
+ state.generation = gen
198
+ state.best_fitness = 100.0 - gen * 10
199
+ state.n_evals = gen * 10
200
+ print_cb.on_generation_end(state)
201
+
202
+ print_cb.on_optimisation_end(state)
203
+ print(" PrintCallback executed without errors")
204
+
205
+ # Test EarlyStoppingCallback
206
+ print("\n3. Testing EarlyStoppingCallback...")
207
+ early_stop_cb = EarlyStoppingCallback(
208
+ patience=3,
209
+ min_delta=0.1,
210
+ )
211
+
212
+ state = CallbackState(generation=0, n_evals=0, best_fitness=100.0)
213
+ early_stop_cb.on_optimisation_start(state)
214
+
215
+ # Simulate improvement then stagnation
216
+ fitness_sequence = [100, 90, 80, 80, 80, 80] # Stagnates after 3rd
217
+ stopped_at = None
218
+ for gen, fit in enumerate(fitness_sequence):
219
+ state.generation = gen
220
+ state.best_fitness = fit
221
+ early_stop_cb.on_generation_end(state)
222
+ if state.stop_optimisation:
223
+ stopped_at = gen
224
+ break
225
+
226
+ print(f" Early stopping triggered at generation: {stopped_at}")
227
+ assert stopped_at is not None, "Early stopping should have triggered"
228
+
229
+ # Test CheckpointCallback
230
+ print("\n4. Testing CheckpointCallback...")
231
+ with tempfile.TemporaryDirectory() as tmpdir:
232
+ # Create a mock module for checkpointing
233
+ mock_module = nn.Linear(5, 1)
234
+
235
+ checkpoint_cb = CheckpointCallback(
236
+ directory=tmpdir,
237
+ every=2,
238
+ save_best_only=True,
239
+ )
240
+
241
+ state = CallbackState(
242
+ generation=0,
243
+ n_evals=0,
244
+ best_fitness=100.0,
245
+ algorithm=mock_module,
246
+ )
247
+ checkpoint_cb.on_optimisation_start(state)
248
+
249
+ for gen in range(5):
250
+ state.generation = gen
251
+ state.best_fitness = 100.0 - gen * 20
252
+ checkpoint_cb.on_generation_end(state)
253
+
254
+ checkpoint_cb.on_optimisation_end(state)
255
+
256
+ # Check files were created
257
+ files = os.listdir(tmpdir)
258
+ print(f" Checkpoint files created: {files}")
259
+ assert len(files) > 0, "Should have created checkpoint files"
260
+
261
+ # Test ConvergenceCallback
262
+ print("\n5. Testing ConvergenceCallback...")
263
+ conv_cb = ConvergenceCallback(
264
+ threshold=0.001,
265
+ window=3,
266
+ min_generations=0,
267
+ )
268
+
269
+ state = CallbackState(generation=0, n_evals=0, best_fitness=100.0)
270
+ conv_cb.on_optimisation_start(state)
271
+
272
+ # Simulate convergence
273
+ fitness_sequence = [100.0, 50.0, 25.0, 24.999, 24.998, 24.997]
274
+ stopped_at = None
275
+ for gen, fit in enumerate(fitness_sequence):
276
+ state.generation = gen
277
+ state.best_fitness = fit
278
+ conv_cb.on_generation_end(state)
279
+ if state.stop_optimisation:
280
+ stopped_at = gen
281
+ break
282
+
283
+ print(f" Convergence detected at generation: {stopped_at}")
284
+
285
+ # Test CallbackList
286
+ print("\n6. Testing CallbackList...")
287
+ cb_list = CallbackList([
288
+ HistoryCallback(),
289
+ PrintCallback(every=10),
290
+ ])
291
+
292
+ state = CallbackState(generation=0, n_evals=0, best_fitness=100.0)
293
+ cb_list.on_optimisation_start(state)
294
+ for gen in range(3):
295
+ state.generation = gen
296
+ state.best_fitness = 100.0 - gen
297
+ cb_list.on_generation_end(state)
298
+ cb_list.on_optimisation_end(state)
299
+ print(" CallbackList executed all callbacks")
300
+
301
+ print("\n✓ callbacks.py tests passed!")
302
+
303
+
304
+ def run_all_tests():
305
+ """Run all utils tests."""
306
+ print("\n" + "#"*60)
307
+ print("# EvoGrad Utils Module Tests")
308
+ print("#"*60)
309
+
310
+ try:
311
+ test_device()
312
+ test_duplicates()
313
+ test_callbacks()
314
+
315
+ print("\n" + "="*60)
316
+ print("✓ ALL UTILS TESTS PASSED!")
317
+ print("="*60)
318
+ return True
319
+ except Exception as e:
320
+ print(f"\n✗ TEST FAILED: {e}")
321
+ import traceback
322
+ traceback.print_exc()
323
+ return False
324
+
325
+
326
+ if __name__ == "__main__":
327
+ success = run_all_tests()
328
+ sys.exit(0 if success else 1)