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,421 @@
1
+ """
2
+ Duplicate handling utilities for EvoGrad.
3
+
4
+ This module provides mechanisms to detect and repair duplicate individuals
5
+ in a population. It is intended to be used by algorithms *after* offspring
6
+ generation and boundary repair, and *before* the next objective evaluation.
7
+
8
+ Design Goals
9
+ ------------
10
+ - Torch-only, GPU-friendly operations
11
+ - Pluggable detection strategies (epsilon distance, hashing)
12
+ - Minimal, well-defined API
13
+ - Support for both differentiable and non-differentiable modes
14
+
15
+ Example
16
+ -------
17
+ >>> from evograd.utils.duplicates import DuplicateEliminator, DuplicateMethod
18
+ >>>
19
+ >>> eliminator = DuplicateEliminator(
20
+ ... method=DuplicateMethod.EPSILON_L2,
21
+ ... epsilon=1e-8,
22
+ ... )
23
+ >>>
24
+ >>> # Remove duplicates by resampling
25
+ >>> new_pop = eliminator(pop, lower_bounds, upper_bounds)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from enum import Enum, auto
31
+ from typing import Optional, Tuple, Union
32
+
33
+ import torch
34
+
35
+ from evograd.utils.device import ensure_tensor
36
+
37
+
38
+ class DuplicateMethod(Enum):
39
+ """Strategies for duplicate detection.
40
+
41
+ EPSILON_L2:
42
+ Two individuals are considered duplicates if their L2 (Euclidean)
43
+ distance is below `epsilon`.
44
+
45
+ EPSILON_LINF:
46
+ Two individuals are considered duplicates if their L-infinity
47
+ (max absolute coordinate difference) is below `epsilon`.
48
+
49
+ HASH:
50
+ Individuals are rounded to a fixed number of decimal places
51
+ and compared via row-wise uniqueness. Faster for high dimensions
52
+ but less precise.
53
+
54
+ NONE:
55
+ Do not perform any duplicate handling.
56
+ """
57
+
58
+ EPSILON_L2 = auto()
59
+ EPSILON_LINF = auto()
60
+ HASH = auto()
61
+ NONE = auto()
62
+
63
+
64
+ class DuplicateEliminator:
65
+ """Eliminate duplicates in a population by resampling them.
66
+
67
+ The class is callable:
68
+
69
+ >>> elim = DuplicateEliminator(method=DuplicateMethod.EPSILON_L2, epsilon=1e-8)
70
+ >>> new_pop = elim(pop, lower, upper)
71
+
72
+ It does **not** touch fitness values or call the objective function.
73
+ Algorithms should re-evaluate any individuals that were resampled (duplicates).
74
+ If you call this with `return_indices=True`, you can re-evaluate only those.
75
+
76
+ Parameters
77
+ ----------
78
+ method :
79
+ Detection strategy. See `DuplicateMethod` for options.
80
+ epsilon :
81
+ Distance threshold for EPSILON_L2 and EPSILON_LINF methods.
82
+ decimals :
83
+ Number of decimal places for rounding in HASH method.
84
+ max_resamples :
85
+ Maximum number of resampling attempts to resolve duplicates.
86
+ After this many attempts, remaining duplicates are accepted.
87
+
88
+ Attributes
89
+ ----------
90
+ n_duplicates_found : int
91
+ Number of duplicates found in the last call.
92
+ n_duplicates_resolved : int
93
+ Number of duplicates successfully resolved in the last call.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ method: DuplicateMethod = DuplicateMethod.EPSILON_L2,
99
+ epsilon: float = 1e-8,
100
+ decimals: int = 8,
101
+ max_resamples: int = 5,
102
+ ) -> None:
103
+ self.method = method
104
+ self.epsilon = float(epsilon)
105
+ self.decimals = int(decimals)
106
+ self.max_resamples = int(max_resamples)
107
+
108
+ # Statistics from last call
109
+ self.n_duplicates_found = 0
110
+ self.n_duplicates_resolved = 0
111
+
112
+ # ------------------------------------------------------------------
113
+ # Public API
114
+ # ------------------------------------------------------------------
115
+ def __call__(
116
+ self,
117
+ pop: torch.Tensor,
118
+ lower: Union[float, torch.Tensor],
119
+ upper: Union[float, torch.Tensor],
120
+ return_indices: bool = False,
121
+ ) -> Union[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
122
+ """Return a new population where duplicates have been resampled.
123
+
124
+ Parameters
125
+ ----------
126
+ pop :
127
+ Population tensor of shape (N, D).
128
+ lower :
129
+ Lower bounds (scalar or 1D tensor of length D).
130
+ upper :
131
+ Upper bounds (scalar or 1D tensor of length D).
132
+
133
+ Returns
134
+ -------
135
+ Union[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]
136
+ If `return_indices=False` (default), returns a tensor of shape (N, D).
137
+ If `return_indices=True`, returns (new_pop, changed_indices) where
138
+ `changed_indices` is a 1D int64 tensor containing the indices of
139
+ individuals that were resampled at least once.
140
+ """
141
+ # Reset statistics
142
+ self.n_duplicates_found = 0
143
+ self.n_duplicates_resolved = 0
144
+
145
+ if self.method == DuplicateMethod.NONE:
146
+ return (pop.clone(), torch.empty(0, device=pop.device, dtype=torch.long)) if return_indices else pop.clone()
147
+
148
+ if pop.ndim != 2:
149
+ raise ValueError(f"Expected population of shape (N, D), got {tuple(pop.shape)}")
150
+
151
+ N, D = pop.shape
152
+ device = pop.device
153
+ dtype = pop.dtype
154
+
155
+ # Ensure bounds are tensors
156
+ lb = ensure_tensor(lower, dim=D, device=device, dtype=dtype)
157
+ ub = ensure_tensor(upper, dim=D, device=device, dtype=dtype)
158
+
159
+ if torch.any(lb > ub):
160
+ raise ValueError("Lower bounds must be <= upper bounds elementwise.")
161
+
162
+ # Find which indices are duplicates
163
+ dup_mask = self._find_duplicates(pop)
164
+ initial_dups = dup_mask.sum().item()
165
+ self.n_duplicates_found = initial_dups
166
+
167
+ if initial_dups == 0:
168
+ return (pop.clone(), torch.empty(0, device=pop.device, dtype=torch.long)) if return_indices else pop.clone()
169
+
170
+ new_pop = pop.clone()
171
+ dup_indices = dup_mask.nonzero(as_tuple=False).view(-1)
172
+ changed_indices = dup_indices.clone()
173
+ span = ub - lb
174
+
175
+ # Resample duplicates with multiple attempts
176
+ for attempt in range(self.max_resamples):
177
+ if dup_indices.numel() == 0:
178
+ break
179
+
180
+ n_dup = dup_indices.numel()
181
+
182
+ # Generate random candidates within bounds
183
+ candidates = lb + span * torch.rand(
184
+ (n_dup, D), device=device, dtype=dtype
185
+ )
186
+
187
+ # Insert candidates
188
+ new_pop[dup_indices] = candidates
189
+
190
+ # Check which indices are still duplicates
191
+ dup_mask = self._find_duplicates(new_pop)
192
+ dup_indices = dup_mask.nonzero(as_tuple=False).view(-1)
193
+
194
+ # Calculate resolved count
195
+ final_dups = dup_indices.numel()
196
+ self.n_duplicates_resolved = initial_dups - final_dups
197
+
198
+ return (new_pop, changed_indices) if return_indices else new_pop
199
+
200
+ def find_duplicates(self, pop: torch.Tensor) -> torch.Tensor:
201
+ """Find duplicate individuals in a population.
202
+
203
+ This is a public wrapper for inspection purposes.
204
+
205
+ Parameters
206
+ ----------
207
+ pop :
208
+ Population tensor of shape (N, D).
209
+
210
+ Returns
211
+ -------
212
+ torch.Tensor
213
+ Boolean mask of shape (N,) where True indicates a duplicate.
214
+ The first occurrence of each unique individual is NOT marked.
215
+ """
216
+ return self._find_duplicates(pop)
217
+
218
+ # ------------------------------------------------------------------
219
+ # Duplicate detection strategies
220
+ # ------------------------------------------------------------------
221
+ def _find_duplicates(self, pop: torch.Tensor) -> torch.Tensor:
222
+ """Return a boolean mask of shape (N,) indicating duplicates.
223
+
224
+ The *first* occurrence of each unique individual is considered
225
+ non-duplicate; later occurrences are marked as duplicates.
226
+ """
227
+ if self.method == DuplicateMethod.EPSILON_L2:
228
+ return self._find_duplicates_epsilon(pop, p=2)
229
+ elif self.method == DuplicateMethod.EPSILON_LINF:
230
+ return self._find_duplicates_epsilon(pop, p=float("inf"))
231
+ elif self.method == DuplicateMethod.HASH:
232
+ return self._find_duplicates_hash(pop)
233
+ elif self.method == DuplicateMethod.NONE:
234
+ return torch.zeros(pop.shape[0], dtype=torch.bool, device=pop.device)
235
+ else:
236
+ raise ValueError(f"Unhandled duplicate method: {self.method}")
237
+
238
+ def _find_duplicates_epsilon(self, pop: torch.Tensor, p: float) -> torch.Tensor:
239
+ """Detect duplicates based on an epsilon distance threshold.
240
+
241
+ Parameters
242
+ ----------
243
+ pop :
244
+ Population tensor of shape (N, D).
245
+ p :
246
+ Norm order: 2 for L2 (Euclidean), float('inf') for L-infinity.
247
+
248
+ Returns
249
+ -------
250
+ torch.Tensor
251
+ Boolean mask (N,) where True marks a duplicate individual.
252
+ """
253
+ N = pop.shape[0]
254
+ device = pop.device
255
+
256
+ if N <= 1:
257
+ return torch.zeros(N, dtype=torch.bool, device=device)
258
+
259
+ # Compute pairwise distances
260
+ # For typical population sizes this is fine; can optimize later if needed
261
+ dist = torch.cdist(pop, pop, p=p)
262
+
263
+ # Set diagonal to infinity (ignore self-distance)
264
+ dist.fill_diagonal_(float("inf"))
265
+
266
+ dup_mask = torch.zeros(N, dtype=torch.bool, device=device)
267
+
268
+ # For each individual i, check if any previous j < i is within epsilon
269
+ for i in range(1, N):
270
+ if (dist[i, :i] <= self.epsilon).any():
271
+ dup_mask[i] = True
272
+
273
+ return dup_mask
274
+
275
+ def _find_duplicates_hash(self, pop: torch.Tensor) -> torch.Tensor:
276
+ """Detect duplicates by rounding and using row-wise uniqueness.
277
+
278
+ This is usually faster than epsilon-based distance when the
279
+ dimensionality is high, but depends on rounding precision.
280
+
281
+ Parameters
282
+ ----------
283
+ pop :
284
+ Population tensor of shape (N, D).
285
+
286
+ Returns
287
+ -------
288
+ torch.Tensor
289
+ Boolean mask (N,) where True marks a duplicate individual.
290
+ """
291
+ N = pop.shape[0]
292
+ device = pop.device
293
+
294
+ if N <= 1:
295
+ return torch.zeros(N, dtype=torch.bool, device=device)
296
+
297
+ # Round to specified decimal places
298
+ scale = 10.0 ** self.decimals
299
+ rounded = torch.round(pop * scale) / scale
300
+
301
+ # Find unique rows
302
+ _, inverse, counts = torch.unique(
303
+ rounded,
304
+ dim=0,
305
+ return_inverse=True,
306
+ return_counts=True,
307
+ )
308
+
309
+ dup_mask = torch.zeros(N, dtype=torch.bool, device=device)
310
+
311
+ # For each group with count > 1, mark all but the first as duplicates
312
+ for group_idx, count in enumerate(counts.tolist()):
313
+ if count <= 1:
314
+ continue
315
+
316
+ indices = (inverse == group_idx).nonzero(as_tuple=False).view(-1)
317
+
318
+ # Keep the first occurrence, mark the rest as duplicates
319
+ if indices.numel() > 1:
320
+ dup_mask[indices[1:]] = True
321
+
322
+ return dup_mask
323
+
324
+ # ------------------------------------------------------------------
325
+ # String representation
326
+ # ------------------------------------------------------------------
327
+ def __repr__(self) -> str:
328
+ return (
329
+ f"DuplicateEliminator("
330
+ f"method={self.method.name}, "
331
+ f"epsilon={self.epsilon}, "
332
+ f"decimals={self.decimals}, "
333
+ f"max_resamples={self.max_resamples})"
334
+ )
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Convenience functions
339
+ # ---------------------------------------------------------------------------
340
+
341
+ def eliminate_duplicates(
342
+ pop: torch.Tensor,
343
+ lower: Union[float, torch.Tensor],
344
+ upper: Union[float, torch.Tensor],
345
+ method: DuplicateMethod = DuplicateMethod.EPSILON_L2,
346
+ epsilon: float = 1e-8,
347
+ ) -> torch.Tensor:
348
+ """Convenience function to eliminate duplicates from a population.
349
+
350
+ Parameters
351
+ ----------
352
+ pop :
353
+ Population tensor of shape (N, D).
354
+ lower :
355
+ Lower bounds.
356
+ upper :
357
+ Upper bounds.
358
+ method :
359
+ Detection method.
360
+ epsilon :
361
+ Distance threshold.
362
+
363
+ Returns
364
+ -------
365
+ torch.Tensor
366
+ Population with duplicates replaced by random samples.
367
+ """
368
+ eliminator = DuplicateEliminator(method=method, epsilon=epsilon)
369
+ return eliminator(pop, lower, upper)
370
+
371
+
372
+ def has_duplicates(
373
+ pop: torch.Tensor,
374
+ method: DuplicateMethod = DuplicateMethod.EPSILON_L2,
375
+ epsilon: float = 1e-8,
376
+ ) -> bool:
377
+ """Check if a population contains duplicates.
378
+
379
+ Parameters
380
+ ----------
381
+ pop :
382
+ Population tensor of shape (N, D).
383
+ method :
384
+ Detection method.
385
+ epsilon :
386
+ Distance threshold.
387
+
388
+ Returns
389
+ -------
390
+ bool
391
+ True if duplicates are found.
392
+ """
393
+ eliminator = DuplicateEliminator(method=method, epsilon=epsilon)
394
+ mask = eliminator.find_duplicates(pop)
395
+ return mask.any().item()
396
+
397
+
398
+ def count_duplicates(
399
+ pop: torch.Tensor,
400
+ method: DuplicateMethod = DuplicateMethod.EPSILON_L2,
401
+ epsilon: float = 1e-8,
402
+ ) -> int:
403
+ """Count the number of duplicate individuals in a population.
404
+
405
+ Parameters
406
+ ----------
407
+ pop :
408
+ Population tensor of shape (N, D).
409
+ method :
410
+ Detection method.
411
+ epsilon :
412
+ Distance threshold.
413
+
414
+ Returns
415
+ -------
416
+ int
417
+ Number of duplicate individuals.
418
+ """
419
+ eliminator = DuplicateEliminator(method=method, epsilon=epsilon)
420
+ mask = eliminator.find_duplicates(pop)
421
+ return mask.sum().item()