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,895 @@
1
+ """
2
+ Differential Evolution (DE) implementation for EvoGrad.
3
+
4
+ This module provides a fully differentiable Differential Evolution algorithm
5
+ that supports both classical and gradient-enabled optimisation modes.
6
+
7
+ DE evolves a population through:
8
+ 1. Mutation: Create donor vectors using difference of population members
9
+ 2. Crossover: Combine target and donor to create trial vectors
10
+ 3. Selection: Greedy one-to-one replacement
11
+
12
+ All operators are pluggable via dependency injection (pymoo-style). The
13
+ crossover operator uses the existing BinomialCrossover or ExponentialCrossover
14
+ from the operators module.
15
+
16
+ Variants:
17
+ The variant string (e.g., "DE/rand/1/bin") specifies:
18
+ - Mutation base: rand, best, current-to-best, current-to-rand
19
+ - Number of difference vectors: 1 or 2
20
+ - Crossover type: bin (binomial) or exp (exponential)
21
+
22
+ Modes:
23
+ - adaptive=False, differentiable=False: Classical DE
24
+ - adaptive=True, differentiable=False: Operators are differentiable,
25
+ hyperparameters (F, CR, temperatures) learned via backprop
26
+ - adaptive=False, differentiable=True: Population is differentiable,
27
+ learned via backprop
28
+ - adaptive=True, differentiable=True: Both operators and population
29
+ are differentiable
30
+
31
+ Example:
32
+ >>> from evograd.algorithms import DE
33
+ >>> from evograd.core import Problem, minimize
34
+ >>>
35
+ >>> problem = Problem(
36
+ ... objective=lambda x: (x**2).sum(dim=-1),
37
+ ... n_var=30,
38
+ ... xl=-100.0,
39
+ ... xu=100.0,
40
+ ... )
41
+ >>>
42
+ >>> # Classical DE
43
+ >>> de = DE(pop_size=100, variant="DE/rand/1/bin", F=0.5, CR=0.9)
44
+ >>> result = minimize(problem, de, max_evals=10000)
45
+ >>>
46
+ >>> # Adaptive DE with learnable hyperparameters
47
+ >>> de = DE(pop_size=100, variant="DE/best/1/bin", adaptive=True)
48
+ >>> result = minimize(problem, de, max_evals=10000)
49
+
50
+ Reference:
51
+ Storn, R. & Price, K. (1997). Differential Evolution - A Simple and
52
+ Efficient Heuristic for Global Optimization over Continuous Spaces.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import re
58
+ from dataclasses import dataclass
59
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
60
+
61
+ import torch
62
+ import torch.nn as nn
63
+ from torch import Tensor
64
+
65
+ from evograd.core.algorithm import Algorithm
66
+
67
+ if TYPE_CHECKING:
68
+ from evograd.core.problem import Problem
69
+
70
+ __all__ = ["DE", "DEVariant", "de_default", "de_rand_1_bin", "de_best_1_bin", "de_current_to_best_1_bin"]
71
+
72
+ # =============================================================================
73
+ # DE Variant Parser
74
+ # =============================================================================
75
+
76
+ @dataclass
77
+ class DEVariant:
78
+ """
79
+ Parsed DE variant specification.
80
+
81
+ Attributes:
82
+ mutation: Mutation strategy (rand, best, current-to-best, current-to-rand)
83
+ n_diff: Number of difference vectors (1 or 2)
84
+ crossover: Crossover type (bin, exp, or None for current-to-rand)
85
+ """
86
+ mutation: str
87
+ n_diff: int
88
+ crossover: Optional[str]
89
+
90
+ # Pattern: DE/mutation/n_diff/crossover
91
+ _PATTERN = re.compile(
92
+ r"^DE/(RAND|BEST|CURRENT-TO-BEST|CURRENT-TO-RAND)/([12])(?:/(BIN|EXP))?$",
93
+ re.IGNORECASE
94
+ )
95
+
96
+ @classmethod
97
+ def parse(cls, variant: str) -> "DEVariant":
98
+ """
99
+ Parse a DE variant string.
100
+
101
+ Args:
102
+ variant: Variant string like "DE/rand/1/bin"
103
+
104
+ Returns:
105
+ Parsed DEVariant instance.
106
+
107
+ Raises:
108
+ ValueError: If variant string is invalid.
109
+ """
110
+ # Normalise: replace underscores with hyphens
111
+ normalised = variant.replace("_", "-").upper()
112
+
113
+ match = cls._PATTERN.match(normalised)
114
+ if not match:
115
+ valid = [
116
+ "DE/rand/1/bin", "DE/rand/1/exp", "DE/rand/2/bin", "DE/rand/2/exp",
117
+ "DE/best/1/bin", "DE/best/1/exp", "DE/best/2/bin", "DE/best/2/exp",
118
+ "DE/current-to-best/1/bin", "DE/current-to-best/1/exp",
119
+ "DE/current-to-rand/1"
120
+ ]
121
+ raise ValueError(
122
+ f"Invalid DE variant '{variant}'. "
123
+ f"Valid variants: {', '.join(valid)}"
124
+ )
125
+
126
+ mutation = match.group(1).lower().replace("-", "_")
127
+ n_diff = int(match.group(2))
128
+ crossover = match.group(3).lower() if match.group(3) else None
129
+
130
+ # current-to-rand doesn't use crossover
131
+ if mutation == "current_to_rand" and crossover is not None:
132
+ raise ValueError(
133
+ f"DE/current-to-rand does not use crossover. "
134
+ f"Use 'DE/current-to-rand/1' without crossover suffix."
135
+ )
136
+
137
+ # Other variants require crossover
138
+ if mutation != "current_to_rand" and crossover is None:
139
+ raise ValueError(
140
+ f"Variant '{variant}' requires crossover type. "
141
+ f"Use 'DE/{mutation}/{n_diff}/bin' or 'DE/{mutation}/{n_diff}/exp'."
142
+ )
143
+
144
+ return cls(mutation=mutation, n_diff=n_diff, crossover=crossover)
145
+
146
+ def __str__(self) -> str:
147
+ mutation_str = self.mutation.replace("_", "-")
148
+ if self.crossover:
149
+ return f"DE/{mutation_str}/{self.n_diff}/{self.crossover}"
150
+ return f"DE/{mutation_str}/{self.n_diff}"
151
+
152
+
153
+ # =============================================================================
154
+ # Differential Evolution Algorithm
155
+ # =============================================================================
156
+
157
+ class DE(Algorithm):
158
+ """
159
+ Differential Evolution (DE) for continuous optimisation.
160
+
161
+ DE evolves a population through mutation (using difference vectors),
162
+ crossover, and greedy selection. Supports multiple mutation strategies
163
+ and both binomial and exponential crossover.
164
+
165
+ Args:
166
+ pop_size: Population size.
167
+ variant: DE variant string (e.g., "DE/rand/1/bin").
168
+ See DEVariant for valid options.
169
+ F: Mutation scale factor in (0, 2]. Default: 0.5.
170
+ CR: Crossover rate in [0, 1]. Default: 0.9.
171
+ sampling: Operator for initial population generation.
172
+ crossover: Crossover operator. If None, created from variant.
173
+ repair: Repair operator for constraint handling.
174
+ dither: F randomisation strategy (classical mode only):
175
+ - None: Fixed F
176
+ - "scalar": Randomise F once per generation
177
+ - "vector": Randomise F per individual
178
+ jitter: If True, add small per-dimension noise to F (classical only).
179
+ per_individual_coeffs: If True, sample F and CR independently for
180
+ each individual. In classical mode, sampled from Uniform(0.5, 1.0).
181
+ In adaptive mode, sampled around the learned base values using
182
+ reparameterization (gradients flow to learned parameters).
183
+ adaptive: If True, operators are differentiable and hyperparameters
184
+ (F, CR, temperatures) are learned via backpropagation.
185
+ differentiable: If True, population is differentiable and
186
+ learned via backpropagation.
187
+ selection_temperature: Initial temperature for Gumbel-Softmax selection.
188
+ dtype: Tensor dtype.
189
+
190
+ Attributes:
191
+ variant: Parsed DEVariant.
192
+ F: Current mutation scale factor.
193
+ CR: Current crossover rate.
194
+
195
+ Example:
196
+ >>> # Classical DE/rand/1/bin
197
+ >>> de = DE(pop_size=100, variant="DE/rand/1/bin")
198
+ >>>
199
+ >>> # Adaptive DE with learnable hyperparameters
200
+ >>> de = DE(variant="DE/best/1/bin", adaptive=True)
201
+ >>>
202
+ >>> # Differentiable population
203
+ >>> de = DE(variant="DE/rand/1/bin", differentiable=True)
204
+ >>>
205
+ >>> # Both adaptive and differentiable
206
+ >>> de = DE(variant="DE/current-to-best/1/bin", adaptive=True, differentiable=True)
207
+ >>>
208
+ >>> # Per-individual F and CR (jDE-style)
209
+ >>> de = DE(variant="DE/rand/1/bin", per_individual_coeffs=True)
210
+ """
211
+
212
+ def __init__(
213
+ self,
214
+ pop_size: int = 100,
215
+ variant: str = "DE/rand/1/bin",
216
+ F: float = 0.5,
217
+ CR: float = 0.9,
218
+ sampling: Optional[nn.Module] = None,
219
+ crossover: Optional[nn.Module] = None,
220
+ repair: Optional[nn.Module] = None,
221
+ dither: Optional[str] = None,
222
+ jitter: bool = False,
223
+ per_individual_coeffs: bool = False,
224
+ adaptive: bool = False,
225
+ differentiable: bool = False,
226
+ selection_temperature: float = 1.0,
227
+ dtype: torch.dtype = torch.float32,
228
+ ) -> None:
229
+ # Parse variant
230
+ self.variant = DEVariant.parse(variant)
231
+ self.dither = dither
232
+ self.jitter = jitter
233
+ self.per_individual_coeffs = per_individual_coeffs
234
+ self.adaptive = adaptive
235
+ self._init_F = F
236
+ self._init_CR = CR
237
+ self._selection_temperature = selection_temperature
238
+
239
+ # Create crossover operator if not provided
240
+ if crossover is None and self.variant.crossover is not None:
241
+ crossover = self._create_crossover(CR, adaptive)
242
+
243
+ # Create selection operator for parent selection in mutation
244
+ # Selection is differentiable when adaptive=True
245
+ selection = self._create_random_selection(adaptive, selection_temperature)
246
+
247
+ # Call base class
248
+ super().__init__(
249
+ pop_size=pop_size,
250
+ sampling=sampling,
251
+ selection=selection,
252
+ crossover=crossover,
253
+ mutation=None, # DE mutation is handled internally
254
+ survival=None, # DE uses greedy one-to-one selection
255
+ repair=repair,
256
+ eliminate_duplicates=False, # DE doesn't eliminate duplicates
257
+ n_offsprings=pop_size, # DE creates one trial per individual
258
+ differentiable=differentiable,
259
+ adaptive=adaptive,
260
+ dtype=dtype,
261
+ )
262
+
263
+ def _create_crossover(
264
+ self,
265
+ CR: float,
266
+ adaptive: bool,
267
+ ) -> nn.Module:
268
+ """
269
+ Create the appropriate crossover operator.
270
+
271
+ When adaptive=True, crossover is differentiable with learnable CR.
272
+ """
273
+ if self.variant.crossover == "bin":
274
+ from evograd.operators.crossover import BinomialCrossover
275
+ return BinomialCrossover(
276
+ cr=CR,
277
+ adaptive=adaptive, # Differentiable when adaptive
278
+ learn_cr=adaptive, # Learn CR when adaptive
279
+ )
280
+ elif self.variant.crossover == "exp":
281
+ from evograd.operators.crossover import ExponentialCrossover
282
+ return ExponentialCrossover(
283
+ cr=CR,
284
+ adaptive=adaptive, # Differentiable when adaptive
285
+ learn_cr=adaptive, # Learn CR when adaptive
286
+ )
287
+ return None
288
+
289
+ def _create_random_selection(self, adaptive: bool, temperature: float) -> nn.Module:
290
+ """
291
+ Create selection operator for parent selection in mutation.
292
+ When adaptive=True, selection is differentiable with learnable temperature.
293
+ """
294
+ from evograd.operators.selection import RandomSelection
295
+ return RandomSelection(replacement=True,
296
+ adaptive=adaptive,
297
+ temperature=temperature,
298
+ )
299
+
300
+ # =========================================================================
301
+ # Setup and Hyperparameters
302
+ # =========================================================================
303
+
304
+ def _setup(self) -> None:
305
+ """DE-specific setup after initialization."""
306
+ n_var = self.problem.n_var
307
+
308
+ # Setup F parameter
309
+ if self.adaptive:
310
+ # Learnable F stored as log(F) for positivity
311
+ self._log_F = nn.Parameter(
312
+ torch.tensor(self._init_F, device=self.device, dtype=self.dtype).log()
313
+ )
314
+ else:
315
+ self.register_buffer(
316
+ "_F_buffer",
317
+ torch.tensor(self._init_F, device=self.device, dtype=self.dtype)
318
+ )
319
+
320
+ @property
321
+ def F(self) -> Tensor:
322
+ """Current mutation scale factor."""
323
+ if self.adaptive:
324
+ return self._log_F.exp()
325
+ return self._F_buffer
326
+
327
+ @property
328
+ def CR(self) -> Tensor:
329
+ """Current crossover rate."""
330
+ if self.crossover is not None and hasattr(self.crossover, 'cr'):
331
+ return self.crossover.cr
332
+ return torch.tensor(self._init_CR, device=self.device)
333
+
334
+ # =========================================================================
335
+ # Core DE Methods
336
+ # =========================================================================
337
+
338
+ def _get_F_values(self, n: int) -> Tensor:
339
+ """
340
+ Get F values, optionally with dither/jitter/per_individual.
341
+
342
+ In adaptive mode, noise is added around the learned base_F using
343
+ reparameterization so gradients flow to the learnable parameter.
344
+
345
+ Args:
346
+ n: Number of F values needed.
347
+
348
+ Returns:
349
+ F values tensor of shape [n] or [n, n_var].
350
+ """
351
+ base_F = self.F
352
+
353
+ # Per-individual coefficients: sample F around base (or Uniform if classical)
354
+ if self.per_individual_coeffs:
355
+ if self.adaptive:
356
+ # Reparameterized: noise around learned base_F (gradients flow)
357
+ # F_i = base_F + 0.25 * (2u - 1), u ~ Uniform(0,1) -> F_i ~ Uniform(base_F-0.25, base_F+0.25)
358
+ noise = 0.25 * (2 * torch.rand(n, device=self.device, dtype=self.dtype) - 1)
359
+ F_val = base_F + noise
360
+ else:
361
+ # Classical: F ~ Uniform(0.5, 1.0)
362
+ F_val = 0.5 + 0.5 * torch.rand(n, device=self.device, dtype=self.dtype)
363
+
364
+ F_val = F_val.clamp(0.01, 2.0)
365
+
366
+ if self.jitter:
367
+ n_var = self.n_var
368
+ jitter_noise = 0.001 * (2 * torch.rand(n, n_var, device=self.device, dtype=self.dtype) - 1)
369
+ F_val = F_val.unsqueeze(-1) + jitter_noise
370
+
371
+ return F_val
372
+
373
+ # Dither: randomize F per-generation or per-individual
374
+ if self.dither == "scalar":
375
+ # Same random F for all individuals this generation
376
+ if self.adaptive:
377
+ # Noise around learned base_F
378
+ noise = 0.1 * (2 * torch.rand(1, device=self.device, dtype=self.dtype) - 1)
379
+ F_val = (base_F + noise).expand(n)
380
+ else:
381
+ F_val = base_F + 0.1 * (2 * torch.rand(1, device=self.device, dtype=self.dtype) - 1)
382
+ F_val = F_val.expand(n)
383
+ elif self.dither == "vector":
384
+ # Different random F for each individual
385
+ if self.adaptive:
386
+ # Noise around learned base_F
387
+ noise = 0.25 * (2 * torch.rand(n, device=self.device, dtype=self.dtype) - 1)
388
+ F_val = base_F + noise
389
+ else:
390
+ F_val = 0.5 + 0.5 * torch.rand(n, device=self.device, dtype=self.dtype)
391
+ else:
392
+ # No dither: use base_F directly
393
+ F_val = base_F.expand(n)
394
+
395
+ F_val = F_val.clamp(0.01, 2.0)
396
+
397
+ # Jitter: add small per-dimension noise
398
+ if self.jitter:
399
+ n_var = self.n_var
400
+ jitter_noise = 0.001 * (2 * torch.rand(n, n_var, device=self.device, dtype=self.dtype) - 1)
401
+ if F_val.dim() == 1:
402
+ F_val = F_val.unsqueeze(-1) + jitter_noise
403
+ else:
404
+ F_val = F_val + jitter_noise
405
+
406
+ return F_val
407
+
408
+ def _get_CR_values(self, n: int) -> Optional[Tensor]:
409
+ """
410
+ Get CR values for per-individual crossover.
411
+
412
+ In adaptive mode, noise is added around the learned CR using
413
+ reparameterization so gradients flow to the learnable parameter.
414
+
415
+ Args:
416
+ n: Number of CR values needed.
417
+
418
+ Returns:
419
+ CR values tensor of shape [n], or None if not using per_individual_coeffs.
420
+ """
421
+ if not self.per_individual_coeffs:
422
+ return None
423
+
424
+ if self.adaptive:
425
+ # Reparameterized: noise around learned CR (gradients flow)
426
+ # CR_i = base_CR + 0.25 * (2u - 1), u ~ Uniform(0,1)
427
+ base_CR = self.CR
428
+ noise = 0.25 * (2 * torch.rand(n, device=self.device, dtype=self.dtype) - 1)
429
+ CR_val = base_CR + noise
430
+ else:
431
+ # Classical: CR ~ Uniform(0.5, 1.0)
432
+ CR_val = 0.5 + 0.5 * torch.rand(n, device=self.device, dtype=self.dtype)
433
+
434
+ return CR_val.clamp(0.0, 1.0)
435
+
436
+ def _select_parents(
437
+ self,
438
+ n_select: int,
439
+ ) -> Tensor:
440
+ """
441
+ Select parents for mutation using the selection operator.
442
+
443
+ In adaptive (differentiable) mode, uses the soft Gumbel-Softmax
444
+ selection operator so that gradients flow through parent selection.
445
+
446
+ Args:
447
+ n_select: Number of parents to select.
448
+
449
+ Returns:
450
+ Selected individuals [n_select, n_var].
451
+ """
452
+ return self.selection(self.population, self.fitness, n_select=n_select)
453
+
454
+ @staticmethod
455
+ def _sample_distinct_indices(
456
+ N: int,
457
+ n_needed: int,
458
+ device: torch.device,
459
+ exclude: Optional[Tensor] = None,
460
+ ) -> List[Tensor]:
461
+ """
462
+ Sample ``n_needed`` mutually exclusive random index vectors of
463
+ length ``N``, each different from the optional ``exclude`` indices.
464
+
465
+ This is the canonical DE requirement: for each target *i* the
466
+ selected donor indices r1, r2, … must be distinct from each other
467
+ and from *i*.
468
+
469
+ Args:
470
+ N: Population size.
471
+ n_needed: How many distinct index vectors to draw (e.g. 3
472
+ for DE/rand/1).
473
+ device: Target device.
474
+ exclude: Optional ``[N]`` tensor of indices to avoid
475
+ (typically ``torch.arange(N)`` for the target vector).
476
+
477
+ Returns:
478
+ List of ``n_needed`` tensors, each of shape ``[N]``.
479
+ """
480
+ # Build a pool of candidate indices for each target
481
+ # For each row i we need n_needed indices from {0..N-1} \ {exclude[i]}
482
+ all_indices: List[Tensor] = []
483
+ for _ in range(n_needed):
484
+ idx = torch.randint(0, N, (N,), device=device)
485
+ all_indices.append(idx)
486
+
487
+ # Rejection-resample collisions (vectorised, one pass per pair)
488
+ targets = exclude if exclude is not None else torch.full((N,), -1, device=device)
489
+
490
+ for k in range(len(all_indices)):
491
+ # Avoid target index
492
+ collides = all_indices[k] == targets
493
+ while collides.any():
494
+ all_indices[k][collides] = torch.randint(0, N, (int(collides.sum()),), device=device)
495
+ collides = all_indices[k] == targets
496
+
497
+ # Avoid previously selected indices
498
+ for j in range(k):
499
+ collides = all_indices[k] == all_indices[j]
500
+ while collides.any():
501
+ all_indices[k][collides] = torch.randint(0, N, (int(collides.sum()),), device=device)
502
+ # Re-check all constraints for the resampled positions
503
+ collides = all_indices[k] == targets
504
+ for jj in range(k):
505
+ collides = collides | (all_indices[k] == all_indices[jj])
506
+
507
+ return all_indices
508
+
509
+ def _mutate(self) -> Tensor:
510
+ """
511
+ Generate donor vectors using the mutation strategy.
512
+
513
+ In classical mode, parent indices are sampled to be mutually
514
+ exclusive and different from the target index (canonical DE).
515
+ In adaptive mode, the soft selection operator is used instead
516
+ so that gradients can flow through the selection process; the
517
+ exclusion constraint is relaxed in that case.
518
+
519
+ Returns:
520
+ Donor vectors [pop_size, n_var].
521
+ """
522
+ N = self.pop_size
523
+ F = self._get_F_values(N)
524
+
525
+ # Ensure F has correct shape for broadcasting
526
+ if F.dim() == 1:
527
+ F = F.unsqueeze(-1) # [N, 1] for broadcasting
528
+
529
+ mutation_type = self.variant.mutation
530
+
531
+ # -----------------------------------------------------------------
532
+ # Helper: pick parents (hard distinct indices or soft selection)
533
+ # -----------------------------------------------------------------
534
+ def _hard_parents(n_needed: int, exclude: Optional[Tensor] = None) -> List[Tensor]:
535
+ """Return list of n_needed parent tensors [N, n_var] via hard distinct sampling."""
536
+ idx_list = self._sample_distinct_indices(N, n_needed, self.device, exclude=exclude)
537
+ return [self.population[idx] for idx in idx_list]
538
+
539
+ def _soft_parents(n_needed: int) -> List[Tensor]:
540
+ """Return list of n_needed parent tensors [N, n_var] via soft selection."""
541
+ return [self._select_parents(N) for _ in range(n_needed)]
542
+
543
+ use_soft = self.adaptive
544
+ target_idx = torch.arange(N, device=self.device)
545
+
546
+ if mutation_type == "rand":
547
+ # DE/rand: v = x_r1 + F * (x_r2 - x_r3)
548
+ n_parents = 3 if self.variant.n_diff == 1 else 5
549
+ if use_soft:
550
+ parents = _soft_parents(n_parents)
551
+ else:
552
+ parents = _hard_parents(n_parents, exclude=target_idx)
553
+
554
+ if self.variant.n_diff == 1:
555
+ donor = parents[0] + F * (parents[1] - parents[2])
556
+ else:
557
+ donor = parents[0] + F * (parents[1] - parents[2]) + F * (parents[3] - parents[4])
558
+
559
+ elif mutation_type == "best":
560
+ # DE/best: v = x_best + F * (x_r1 - x_r2)
561
+ best_idx = torch.argmin(self.fitness)
562
+ x_best = self.population[best_idx].unsqueeze(0).expand(N, -1)
563
+
564
+ n_parents = 2 if self.variant.n_diff == 1 else 4
565
+ if use_soft:
566
+ parents = _soft_parents(n_parents)
567
+ else:
568
+ parents = _hard_parents(n_parents, exclude=target_idx)
569
+
570
+ if self.variant.n_diff == 1:
571
+ donor = x_best + F * (parents[0] - parents[1])
572
+ else:
573
+ donor = x_best + F * (parents[0] - parents[1]) + F * (parents[2] - parents[3])
574
+
575
+ elif mutation_type == "current_to_best":
576
+ # DE/current-to-best: v = x_i + F * (x_best - x_i) + F * (x_r1 - x_r2)
577
+ best_idx = torch.argmin(self.fitness)
578
+ x_best = self.population[best_idx].unsqueeze(0).expand(N, -1)
579
+
580
+ if use_soft:
581
+ parents = _soft_parents(2)
582
+ else:
583
+ parents = _hard_parents(2, exclude=target_idx)
584
+
585
+ donor = self.population + F * (x_best - self.population) + F * (parents[0] - parents[1])
586
+
587
+ elif mutation_type == "current_to_rand":
588
+ # DE/current-to-rand: v = x_i + K * (x_r1 - x_i) + F * (x_r2 - x_r3)
589
+ K = torch.rand(N, 1, device=self.device, dtype=self.dtype)
590
+
591
+ if use_soft:
592
+ parents = _soft_parents(3)
593
+ else:
594
+ parents = _hard_parents(3, exclude=target_idx)
595
+
596
+ donor = self.population + K * (parents[0] - self.population) + F * (parents[1] - parents[2])
597
+
598
+ else:
599
+ raise ValueError(f"Unknown mutation type: {mutation_type}")
600
+
601
+ return donor
602
+
603
+ def _infill(self) -> Tensor:
604
+ """
605
+ Generate trial vectors through mutation and crossover.
606
+
607
+ Returns:
608
+ Trial vectors [pop_size, n_var].
609
+ """
610
+ # 1. Mutation: create donor vectors
611
+ donor = self._mutate()
612
+
613
+ # 2. Crossover: combine target (population) and donor
614
+ if self.crossover is not None:
615
+ # Get per-individual CR if enabled
616
+ cr_values = self._get_CR_values(self.pop_size)
617
+ if cr_values is not None:
618
+ trial = self.crossover(self.population, donor, cr=cr_values)
619
+ else:
620
+ trial = self.crossover(self.population, donor)
621
+ else:
622
+ # current-to-rand: no crossover, donor is the trial
623
+ trial = donor
624
+
625
+ # 3. Repair bounds
626
+ if self.repair is not None:
627
+ trial = self.repair(trial, self.xl, self.xu)
628
+ else:
629
+ # Default: clamp to bounds
630
+ trial = torch.clamp(trial, self.xl, self.xu)
631
+
632
+ return trial
633
+
634
+ def _advance(self, offspring: Tensor, offspring_fitness: Tensor) -> None:
635
+ """
636
+ Apply greedy one-to-one selection.
637
+
638
+ Each trial vector replaces the corresponding target if it has
639
+ better (lower for minimisation) fitness.
640
+
641
+ Args:
642
+ offspring: Trial vectors [pop_size, n_var].
643
+ offspring_fitness: Fitness of trial vectors [pop_size].
644
+ """
645
+ # Greedy selection: trial replaces target if better
646
+ improved = offspring_fitness < self.fitness
647
+
648
+ # Update population
649
+ new_pop = torch.where(
650
+ improved.unsqueeze(-1),
651
+ offspring,
652
+ self.population
653
+ )
654
+ new_fitness = torch.where(improved, offspring_fitness, self.fitness)
655
+
656
+ # Update internal state
657
+ self._update_population(new_pop, new_fitness)
658
+
659
+ # Update best solution tracking
660
+ self.state.update_best(self.population, self.state.fitness)
661
+
662
+ def _update_population(self, new_pop: Tensor, new_fitness: Tensor) -> None:
663
+ """Update population and fitness tensors."""
664
+ with torch.no_grad():
665
+ self._population.copy_(new_pop)
666
+ self.state.fitness = new_fitness
667
+ self.state.population = self._population
668
+
669
+ # =========================================================================
670
+ # Properties
671
+ # =========================================================================
672
+
673
+ @property
674
+ def population(self) -> Tensor:
675
+ """Current population."""
676
+ return self._population
677
+
678
+ @property
679
+ def fitness(self) -> Tensor:
680
+ """Current fitness values."""
681
+ return self.state.fitness
682
+
683
+ # =========================================================================
684
+ # Hyperparameter Access
685
+ # =========================================================================
686
+
687
+ def _get_hyperparams(self) -> Dict[str, Any]:
688
+ """Return current hyperparameter values."""
689
+ params = {
690
+ 'pop_size': self.pop_size,
691
+ 'variant': str(self.variant),
692
+ 'F': float(self.F.item()),
693
+ 'per_individual_coeffs': self.per_individual_coeffs,
694
+ 'adaptive': self.adaptive,
695
+ 'differentiable': self.differentiable,
696
+ }
697
+
698
+ # Add CR from crossover operator
699
+ if self.crossover is not None and hasattr(self.crossover, 'cr'):
700
+ cr = self.crossover.cr
701
+ if isinstance(cr, Tensor):
702
+ params['CR'] = float(cr.mean().item())
703
+ else:
704
+ params['CR'] = float(cr)
705
+
706
+ # Add selection temperature
707
+ if hasattr(self.selection, 'temperature'):
708
+ params['selection_temperature'] = float(self.selection.temperature.item())
709
+
710
+ # Add crossover temperature
711
+ if self.crossover is not None and hasattr(self.crossover, 'temperature'):
712
+ params['crossover_temperature'] = float(self.crossover.temperature.item())
713
+
714
+ return params
715
+
716
+ # =========================================================================
717
+ # State Management for Adaptive Mode
718
+ # =========================================================================
719
+
720
+ @torch.no_grad()
721
+ def _clamp_hyperparams(self) -> None:
722
+ """Clamp learnable hyperparameters to valid ranges."""
723
+ if self.adaptive:
724
+ # F in (0.01, 2.0) -> log(F) in (log(0.01), log(2.0))
725
+ self._log_F.clamp_(min=-4.6, max=0.7)
726
+
727
+ def update_state(self) -> None:
728
+ """Commit pending changes and clamp hyperparameters."""
729
+ super().update_state()
730
+ self._clamp_hyperparams()
731
+
732
+ # =========================================================================
733
+ # String Representation
734
+ # =========================================================================
735
+
736
+ def __repr__(self) -> str:
737
+ return (
738
+ f"DE(pop_size={self.pop_size}, "
739
+ f"variant='{self.variant}', "
740
+ f"F={float(self.F.item()):.3f}, "
741
+ f"per_individual_coeffs={self.per_individual_coeffs}, "
742
+ f"adaptive={self.adaptive}, "
743
+ f"differentiable={self.differentiable})"
744
+ )
745
+
746
+
747
+ # =============================================================================
748
+ # Convenience Factory Functions
749
+ # =============================================================================
750
+
751
+
752
+ def de_default(
753
+ pop_size: int = 100,
754
+ F: float = 0.5,
755
+ CR: float = 0.9,
756
+ per_individual_coeffs: bool = False,
757
+ adaptive: bool = False,
758
+ differentiable: bool = False,
759
+ **kwargs,
760
+ ) -> "DE":
761
+ """
762
+ Create a default Differential Evolution instance (DE/rand/1/bin).
763
+
764
+ This is the canonical DE configuration and the recommended starting point.
765
+
766
+ Args:
767
+ pop_size: Population size.
768
+ F: Mutation scale factor.
769
+ CR: Crossover rate.
770
+ per_individual_coeffs: If True, sample F and CR per individual.
771
+ adaptive: If True, operators are differentiable with learnable hyperparams.
772
+ differentiable: If True, population is learnable.
773
+ **kwargs: Additional arguments passed to DE.
774
+
775
+ Returns:
776
+ Configured DE instance.
777
+ """
778
+ return DE(
779
+ pop_size=pop_size,
780
+ variant="DE/rand/1/bin",
781
+ F=F,
782
+ CR=CR,
783
+ per_individual_coeffs=per_individual_coeffs,
784
+ adaptive=adaptive,
785
+ differentiable=differentiable,
786
+ **kwargs,
787
+ )
788
+
789
+
790
+ def de_rand_1_bin(
791
+ pop_size: int = 100,
792
+ F: float = 0.5,
793
+ CR: float = 0.9,
794
+ per_individual_coeffs: bool = False,
795
+ adaptive: bool = False,
796
+ differentiable: bool = False,
797
+ **kwargs,
798
+ ) -> DE:
799
+ """
800
+ Create DE/rand/1/bin - the classic DE variant.
801
+
802
+ Args:
803
+ pop_size: Population size.
804
+ F: Mutation scale factor.
805
+ CR: Crossover rate.
806
+ per_individual_coeffs: If True, sample F and CR per individual.
807
+ adaptive: If True, operators are differentiable with learnable hyperparams.
808
+ differentiable: If True, population is learnable.
809
+ **kwargs: Additional arguments passed to DE.
810
+
811
+ Returns:
812
+ Configured DE instance.
813
+ """
814
+ return DE(
815
+ pop_size=pop_size,
816
+ variant="DE/rand/1/bin",
817
+ F=F,
818
+ CR=CR,
819
+ per_individual_coeffs=per_individual_coeffs,
820
+ adaptive=adaptive,
821
+ differentiable=differentiable,
822
+ **kwargs,
823
+ )
824
+
825
+
826
+ def de_best_1_bin(
827
+ pop_size: int = 100,
828
+ F: float = 0.5,
829
+ CR: float = 0.9,
830
+ per_individual_coeffs: bool = False,
831
+ adaptive: bool = False,
832
+ differentiable: bool = False,
833
+ **kwargs,
834
+ ) -> DE:
835
+ """
836
+ Create DE/best/1/bin - greedy variant using best individual.
837
+
838
+ Args:
839
+ pop_size: Population size.
840
+ F: Mutation scale factor.
841
+ CR: Crossover rate.
842
+ per_individual_coeffs: If True, sample F and CR per individual.
843
+ adaptive: If True, operators are differentiable with learnable hyperparams.
844
+ differentiable: If True, population is learnable.
845
+ **kwargs: Additional arguments passed to DE.
846
+
847
+ Returns:
848
+ Configured DE instance.
849
+ """
850
+ return DE(
851
+ pop_size=pop_size,
852
+ variant="DE/best/1/bin",
853
+ F=F,
854
+ CR=CR,
855
+ per_individual_coeffs=per_individual_coeffs,
856
+ adaptive=adaptive,
857
+ differentiable=differentiable,
858
+ **kwargs,
859
+ )
860
+
861
+
862
+ def de_current_to_best_1_bin(
863
+ pop_size: int = 100,
864
+ F: float = 0.5,
865
+ CR: float = 0.9,
866
+ per_individual_coeffs: bool = False,
867
+ adaptive: bool = False,
868
+ differentiable: bool = False,
869
+ **kwargs,
870
+ ) -> DE:
871
+ """
872
+ Create DE/current-to-best/1/bin - balances exploration and exploitation.
873
+
874
+ Args:
875
+ pop_size: Population size.
876
+ F: Mutation scale factor.
877
+ CR: Crossover rate.
878
+ per_individual_coeffs: If True, sample F and CR per individual.
879
+ adaptive: If True, operators are differentiable with learnable hyperparams.
880
+ differentiable: If True, population is learnable.
881
+ **kwargs: Additional arguments passed to DE.
882
+
883
+ Returns:
884
+ Configured DE instance.
885
+ """
886
+ return DE(
887
+ pop_size=pop_size,
888
+ variant="DE/current-to-best/1/bin",
889
+ F=F,
890
+ CR=CR,
891
+ per_individual_coeffs=per_individual_coeffs,
892
+ adaptive=adaptive,
893
+ differentiable=differentiable,
894
+ **kwargs,
895
+ )