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.
- evograd/__init__.py +67 -0
- evograd/algorithms/__init__.py +138 -0
- evograd/algorithms/cmaes.py +1365 -0
- evograd/algorithms/de.py +895 -0
- evograd/algorithms/ga.py +532 -0
- evograd/algorithms/pso.py +648 -0
- evograd/algorithms/shade.py +1165 -0
- evograd/benchmarks/functions/__init__.py +229 -0
- evograd/benchmarks/functions/base.py +217 -0
- evograd/benchmarks/functions/cec2017/__init__.py +250 -0
- evograd/benchmarks/functions/cec2017/basic.py +413 -0
- evograd/benchmarks/functions/cec2017/composition.py +580 -0
- evograd/benchmarks/functions/cec2017/data.pkl +0 -0
- evograd/benchmarks/functions/cec2017/data.py +350 -0
- evograd/benchmarks/functions/cec2017/hybrid.py +406 -0
- evograd/benchmarks/functions/cec2017/simple.py +326 -0
- evograd/benchmarks/functions/classical.py +649 -0
- evograd/benchmarks/functions/smoothed_funnel.py +476 -0
- evograd/benchmarks/functions/transforms.py +463 -0
- evograd/benchmarks/run_benchmark_functions.py +1208 -0
- evograd/core/__init__.py +73 -0
- evograd/core/algorithm.py +778 -0
- evograd/core/maximize.py +269 -0
- evograd/core/minimize.py +740 -0
- evograd/core/problem.py +444 -0
- evograd/core/result.py +571 -0
- evograd/core/termination.py +602 -0
- evograd/operators/__init__.py +178 -0
- evograd/operators/crossover.py +1117 -0
- evograd/operators/mutation.py +1098 -0
- evograd/operators/relaxations.py +175 -0
- evograd/operators/repair.py +601 -0
- evograd/operators/sampling.py +577 -0
- evograd/operators/selection.py +981 -0
- evograd/operators/survival.py +1000 -0
- evograd/tests/__init__.py +11 -0
- evograd/tests/run_all.py +78 -0
- evograd/tests/test_core.py +528 -0
- evograd/tests/test_ga.py +572 -0
- evograd/tests/test_operators.py +662 -0
- evograd/tests/test_per_individual.py +326 -0
- evograd/tests/test_utils.py +328 -0
- evograd/utils/__init__.py +97 -0
- evograd/utils/callbacks.py +926 -0
- evograd/utils/device.py +502 -0
- evograd/utils/duplicates.py +421 -0
- evograd_diff-0.1.0.dist-info/METADATA +439 -0
- evograd_diff-0.1.0.dist-info/RECORD +50 -0
- evograd_diff-0.1.0.dist-info/WHEEL +4 -0
- 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()
|