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,577 @@
1
+ """
2
+ Sampling operators for population initialisation.
3
+
4
+ This module provides strategies for initialising populations
5
+ in the search space. Samplers are used by algorithms to create
6
+ the initial population.
7
+
8
+ Available samplers:
9
+ - UniformSampling: Uniform random sampling (default)
10
+ - LatinHypercubeSampling: Better space coverage via LHS
11
+ - NormalSampling: Gaussian sampling around center
12
+ - LogUniformSampling: Log-scale uniform sampling
13
+
14
+ Example:
15
+ >>> from evograd.operators import UniformSampling
16
+ >>> from evograd.core import Problem
17
+ >>>
18
+ >>> sampler = UniformSampling()
19
+ >>> problem = Problem(n_var=10, xl=-5.0, xu=5.0)
20
+ >>>
21
+ >>> # Sample 100 individuals
22
+ >>> population = sampler(100, problem)
23
+ >>> print(population.shape) # torch.Size([100, 10])
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from abc import ABC, abstractmethod
29
+ from typing import TYPE_CHECKING, Optional, Union
30
+
31
+ import torch
32
+ import torch.nn as nn
33
+ from torch import Tensor
34
+
35
+ if TYPE_CHECKING:
36
+ from evograd.core.problem import Problem
37
+
38
+ __all__ = [
39
+ "Sampling",
40
+ "UniformSampling",
41
+ "LatinHypercubeSampling",
42
+ "NormalSampling",
43
+ "LogUniformSampling",
44
+ ]
45
+
46
+
47
+ # =============================================================================
48
+ # Base Sampling Class
49
+ # =============================================================================
50
+
51
+ class Sampling(nn.Module, ABC):
52
+ """
53
+ Abstract base class for population sampling strategies.
54
+
55
+ Subclasses must implement:
56
+ - _sample(): Generate samples within [0, 1]^d
57
+
58
+ The base class handles:
59
+ - Scaling samples to problem bounds
60
+ - Device/dtype management
61
+ - Seeding for reproducibility
62
+
63
+ Args:
64
+ seed: Random seed for reproducibility.
65
+ """
66
+
67
+ def __init__(self, seed: Optional[int] = None) -> None:
68
+ super().__init__()
69
+ self.seed = seed
70
+ self._generator: Optional[torch.Generator] = None
71
+
72
+ def _get_generator(self, device: torch.device) -> Optional[torch.Generator]:
73
+ """Get or create random generator for reproducibility."""
74
+ if self._generator is None and self.seed is not None:
75
+ # Handle MPS device - use CPU generator since MPS has limited generator support
76
+ # Also handle the 'mps' vs 'mps:0' issue
77
+ if device.type == 'mps':
78
+ # MPS generators have issues - use CPU generator instead
79
+ # The results will still be on MPS, just seeded from CPU
80
+ gen_device = torch.device('cpu')
81
+ else:
82
+ gen_device = device
83
+
84
+ self._generator = torch.Generator(device=gen_device)
85
+ self._generator.manual_seed(self.seed)
86
+ return self._generator
87
+
88
+ @abstractmethod
89
+ def _sample(
90
+ self,
91
+ n_samples: int,
92
+ n_var: int,
93
+ device: torch.device,
94
+ dtype: torch.dtype,
95
+ ) -> Tensor:
96
+ """
97
+ Generate samples in [0, 1]^d.
98
+
99
+ Args:
100
+ n_samples: Number of samples to generate.
101
+ n_var: Number of variables (dimensions).
102
+ device: Target device.
103
+ dtype: Target dtype.
104
+
105
+ Returns:
106
+ Tensor of shape [n_samples, n_var] with values in [0, 1].
107
+ """
108
+ pass
109
+
110
+ def forward(
111
+ self,
112
+ n_samples: int,
113
+ problem: Problem,
114
+ ) -> Tensor:
115
+ """
116
+ Sample population for a problem.
117
+
118
+ Args:
119
+ n_samples: Number of individuals to sample.
120
+ problem: Problem instance with bounds.
121
+
122
+ Returns:
123
+ Population tensor of shape [n_samples, n_var].
124
+ """
125
+ # Get bounds from problem
126
+ xl = problem.xl # [n_var] or scalar
127
+ xu = problem.xu # [n_var] or scalar
128
+ device = xl.device
129
+ dtype = xl.dtype
130
+ n_var = problem.n_var
131
+
132
+ # Sample in [0, 1]^d
133
+ samples = self._sample(n_samples, n_var, device, dtype)
134
+
135
+ # Scale to problem bounds: x = xl + samples * (xu - xl)
136
+ population = xl + samples * (xu - xl)
137
+
138
+ return population
139
+
140
+ def __call__(
141
+ self,
142
+ n_samples: int,
143
+ problem: Problem,
144
+ ) -> Tensor:
145
+ """Sample population (alias for forward)."""
146
+ return self.forward(n_samples, problem)
147
+
148
+ def __repr__(self) -> str:
149
+ seed_str = f", seed={self.seed}" if self.seed is not None else ""
150
+ return f"{self.__class__.__name__}({seed_str})"
151
+
152
+
153
+ # =============================================================================
154
+ # Uniform Random Sampling
155
+ # =============================================================================
156
+
157
+ class UniformSampling(Sampling):
158
+ """
159
+ Uniform random sampling in the search space.
160
+
161
+ The simplest and most common initialisation strategy.
162
+ Samples are drawn uniformly from [xl, xu] for each variable.
163
+
164
+ Args:
165
+ seed: Random seed for reproducibility.
166
+
167
+ Example:
168
+ >>> sampler = UniformSampling()
169
+ >>> population = sampler(100, problem)
170
+ """
171
+
172
+ def _sample(
173
+ self,
174
+ n_samples: int,
175
+ n_var: int,
176
+ device: torch.device,
177
+ dtype: torch.dtype,
178
+ ) -> Tensor:
179
+ generator = self._get_generator(device)
180
+
181
+ if generator is not None:
182
+ # Generator may be on CPU for MPS devices
183
+ gen_device = generator.device
184
+ samples = torch.rand(
185
+ n_samples, n_var,
186
+ device=gen_device,
187
+ dtype=dtype,
188
+ generator=generator,
189
+ )
190
+ # Move to target device if needed
191
+ if gen_device != device:
192
+ samples = samples.to(device)
193
+ return samples
194
+ else:
195
+ return torch.rand(n_samples, n_var, device=device, dtype=dtype)
196
+
197
+
198
+ # =============================================================================
199
+ # Latin Hypercube Sampling
200
+ # =============================================================================
201
+
202
+ class LatinHypercubeSampling(Sampling):
203
+ """
204
+ Latin Hypercube Sampling (LHS) for better space coverage.
205
+
206
+ LHS ensures that samples are well-distributed across each
207
+ dimension by dividing each dimension into n equal intervals
208
+ and sampling exactly once from each interval.
209
+
210
+ This provides better coverage of the search space compared
211
+ to pure random sampling, especially for small sample sizes.
212
+
213
+ Args:
214
+ smooth: If True, add jitter within each stratum (default).
215
+ If False, sample at stratum centers.
216
+ seed: Random seed for reproducibility.
217
+
218
+ Example:
219
+ >>> sampler = LatinHypercubeSampling()
220
+ >>> population = sampler(100, problem)
221
+
222
+ Note:
223
+ LHS is particularly useful for:
224
+ - Small population sizes
225
+ - High-dimensional problems
226
+ - When initial coverage is important
227
+ """
228
+
229
+ def __init__(
230
+ self,
231
+ smooth: bool = True,
232
+ seed: Optional[int] = None,
233
+ ) -> None:
234
+ super().__init__(seed=seed)
235
+ self.smooth = smooth
236
+
237
+ def _sample(
238
+ self,
239
+ n_samples: int,
240
+ n_var: int,
241
+ device: torch.device,
242
+ dtype: torch.dtype,
243
+ ) -> Tensor:
244
+ generator = self._get_generator(device)
245
+
246
+ # Determine generator device (may be CPU for MPS)
247
+ gen_device = generator.device if generator is not None else device
248
+
249
+ # Create intervals: [0, 1/n), [1/n, 2/n), ..., [(n-1)/n, 1)
250
+ # Sample one point in each interval for each dimension
251
+
252
+ # Base positions (left edge of each stratum)
253
+ indices = torch.arange(n_samples, device=device, dtype=dtype)
254
+
255
+ # Randomly permute indices for each dimension
256
+ samples = torch.zeros(n_samples, n_var, device=device, dtype=dtype)
257
+
258
+ for j in range(n_var):
259
+ # Random permutation (generate on generator device, then move if needed)
260
+ if generator is not None:
261
+ perm = torch.randperm(n_samples, device=gen_device, generator=generator)
262
+ if gen_device != device:
263
+ perm = perm.to(device)
264
+ else:
265
+ perm = torch.randperm(n_samples, device=device)
266
+
267
+ if self.smooth:
268
+ # Add random jitter within each stratum
269
+ if generator is not None:
270
+ jitter = torch.rand(n_samples, device=gen_device, dtype=dtype, generator=generator)
271
+ if gen_device != device:
272
+ jitter = jitter.to(device)
273
+ else:
274
+ jitter = torch.rand(n_samples, device=device, dtype=dtype)
275
+ samples[:, j] = (perm.to(dtype) + jitter) / n_samples
276
+ else:
277
+ # Sample at stratum centers
278
+ samples[:, j] = (perm.to(dtype) + 0.5) / n_samples
279
+
280
+ return samples
281
+
282
+ def __repr__(self) -> str:
283
+ seed_str = f", seed={self.seed}" if self.seed is not None else ""
284
+ return f"LatinHypercubeSampling(smooth={self.smooth}{seed_str})"
285
+
286
+
287
+ # =============================================================================
288
+ # Normal (Gaussian) Sampling
289
+ # =============================================================================
290
+
291
+ class NormalSampling(Sampling):
292
+ """
293
+ Gaussian sampling around the center of the search space.
294
+
295
+ Samples are drawn from a normal distribution centered at
296
+ the midpoint of the bounds, with standard deviation scaled
297
+ to fit within the bounds.
298
+
299
+ This is useful when:
300
+ - Good solutions are expected near the center
301
+ - A focused initial search is desired
302
+
303
+ Args:
304
+ sigma_factor: Standard deviation as fraction of range.
305
+ Default 1/3 means 99.7% of samples within bounds
306
+ (before clipping).
307
+ clip_to_bounds: If True, clip samples to [xl, xu].
308
+ seed: Random seed for reproducibility.
309
+
310
+ Example:
311
+ >>> sampler = NormalSampling(sigma_factor=0.25)
312
+ >>> population = sampler(100, problem)
313
+ """
314
+
315
+ def __init__(
316
+ self,
317
+ sigma_factor: float = 1.0 / 3.0,
318
+ clip_to_bounds: bool = True,
319
+ seed: Optional[int] = None,
320
+ ) -> None:
321
+ super().__init__(seed=seed)
322
+ self.sigma_factor = sigma_factor
323
+ self.clip_to_bounds = clip_to_bounds
324
+
325
+ def _sample(
326
+ self,
327
+ n_samples: int,
328
+ n_var: int,
329
+ device: torch.device,
330
+ dtype: torch.dtype,
331
+ ) -> Tensor:
332
+ generator = self._get_generator(device)
333
+
334
+ # Sample from standard normal
335
+ if generator is not None:
336
+ # Generator may be on CPU for MPS devices
337
+ gen_device = generator.device
338
+ z = torch.randn(
339
+ n_samples, n_var,
340
+ device=gen_device,
341
+ dtype=dtype,
342
+ generator=generator,
343
+ )
344
+ if gen_device != device:
345
+ z = z.to(device)
346
+ else:
347
+ z = torch.randn(n_samples, n_var, device=device, dtype=dtype)
348
+
349
+ # Transform to [0, 1] centered at 0.5 with scaled std
350
+ # mean=0.5, std=sigma_factor * 0.5
351
+ samples = 0.5 + self.sigma_factor * 0.5 * z
352
+
353
+ if self.clip_to_bounds:
354
+ samples = torch.clamp(samples, 0.0, 1.0)
355
+
356
+ return samples
357
+
358
+ def __repr__(self) -> str:
359
+ seed_str = f", seed={self.seed}" if self.seed is not None else ""
360
+ return f"NormalSampling(sigma_factor={self.sigma_factor}{seed_str})"
361
+
362
+
363
+ # =============================================================================
364
+ # Log-Uniform Sampling
365
+ # =============================================================================
366
+
367
+ class LogUniformSampling(Sampling):
368
+ """
369
+ Log-uniform sampling for problems with log-scale parameters.
370
+
371
+ Samples are uniformly distributed in log-space, useful for
372
+ parameters that span multiple orders of magnitude (e.g.,
373
+ learning rates, regularisation coefficients).
374
+
375
+ Note: Bounds must be strictly positive!
376
+
377
+ Args:
378
+ base: Deprecated, kept for backwards compatibility.
379
+ The implementation now uses natural log internally.
380
+ seed: Random seed for reproducibility.
381
+
382
+ Example:
383
+ >>> # Sample learning rates from [1e-5, 1e-1]
384
+ >>> problem = Problem(n_var=1, xl=1e-5, xu=1e-1)
385
+ >>> sampler = LogUniformSampling()
386
+ >>> population = sampler(100, problem)
387
+
388
+ Warning:
389
+ Both xl and xu must be > 0 for all variables.
390
+ """
391
+
392
+ def __init__(
393
+ self,
394
+ base: float = 10.0, # Kept for backwards compatibility, not used
395
+ seed: Optional[int] = None,
396
+ ) -> None:
397
+ super().__init__(seed=seed)
398
+ self.base = base # Kept for repr, not used in computation
399
+
400
+ def forward(
401
+ self,
402
+ n_samples: int,
403
+ problem: Problem,
404
+ ) -> Tensor:
405
+ """
406
+ Sample in log-space and transform back.
407
+
408
+ Overrides base forward to handle log transformation.
409
+ """
410
+ xl = problem.xl
411
+ xu = problem.xu
412
+ device = xl.device
413
+ dtype = xl.dtype
414
+ n_var = problem.n_var
415
+
416
+ # Validate positive bounds
417
+ if (xl <= 0).any() or (xu <= 0).any():
418
+ raise ValueError(
419
+ "LogUniformSampling requires strictly positive bounds. "
420
+ f"Got xl={xl}, xu={xu}"
421
+ )
422
+
423
+ # Work in natural log space (avoids torch.pow(scalar, tensor) which isn't supported on MPS)
424
+ # Mathematically equivalent: uniform in log-space => log-uniform in original space
425
+ log_xl = torch.log(xl)
426
+ log_xu = torch.log(xu)
427
+
428
+ # Sample uniformly in [0, 1]
429
+ samples = self._sample(n_samples, n_var, device, dtype)
430
+
431
+ # Scale to log-space bounds (natural log)
432
+ log_samples = log_xl + samples * (log_xu - log_xl)
433
+
434
+ # Transform back to original space using exp (works on all devices)
435
+ population = torch.exp(log_samples)
436
+
437
+ return population
438
+
439
+ def _sample(
440
+ self,
441
+ n_samples: int,
442
+ n_var: int,
443
+ device: torch.device,
444
+ dtype: torch.dtype,
445
+ ) -> Tensor:
446
+ generator = self._get_generator(device)
447
+
448
+ if generator is not None:
449
+ # Generator may be on CPU for MPS devices
450
+ gen_device = generator.device
451
+ samples = torch.rand(
452
+ n_samples, n_var,
453
+ device=gen_device,
454
+ dtype=dtype,
455
+ generator=generator,
456
+ )
457
+ if gen_device != device:
458
+ samples = samples.to(device)
459
+ return samples
460
+ else:
461
+ return torch.rand(n_samples, n_var, device=device, dtype=dtype)
462
+
463
+ def __repr__(self) -> str:
464
+ seed_str = f", seed={self.seed}" if self.seed is not None else ""
465
+ return f"LogUniformSampling(base={self.base}{seed_str})"
466
+
467
+
468
+ # =============================================================================
469
+ # Halton Sequence Sampling (Quasi-Random)
470
+ # =============================================================================
471
+
472
+ class HaltonSampling(Sampling):
473
+ """
474
+ Halton sequence quasi-random sampling.
475
+
476
+ Generates low-discrepancy sequences that fill the space
477
+ more uniformly than random sampling. Each dimension uses
478
+ a different prime base.
479
+
480
+ Particularly useful for:
481
+ - Integration/Monte Carlo methods
482
+ - Surrogate model initialisation
483
+ - When uniform coverage is critical
484
+
485
+ Args:
486
+ scramble: If True, apply random scrambling to reduce
487
+ correlation in high dimensions.
488
+ seed: Random seed for scrambling.
489
+
490
+ Example:
491
+ >>> sampler = HaltonSampling()
492
+ >>> population = sampler(100, problem)
493
+ """
494
+
495
+ # First 100 primes for high-dimensional problems
496
+ _PRIMES = [
497
+ 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47,
498
+ 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113,
499
+ 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197,
500
+ 199, 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281,
501
+ 283, 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 379,
502
+ 383, 389, 397, 401, 409, 419, 421, 431, 433, 439, 443, 449, 457, 461, 463,
503
+ 467, 479, 487, 491, 499, 503, 509, 521, 523, 541,
504
+ ]
505
+
506
+ def __init__(
507
+ self,
508
+ scramble: bool = True,
509
+ seed: Optional[int] = None,
510
+ ) -> None:
511
+ super().__init__(seed=seed)
512
+ self.scramble = scramble
513
+
514
+ def _halton_sequence(
515
+ self,
516
+ n_samples: int,
517
+ base: int,
518
+ device: torch.device,
519
+ dtype: torch.dtype,
520
+ ) -> Tensor:
521
+ """Generate Halton sequence for a single dimension."""
522
+ result = torch.zeros(n_samples, device=device, dtype=dtype)
523
+
524
+ for i in range(n_samples):
525
+ f = 1.0
526
+ r = 0.0
527
+ idx = i + 1 # Start from 1
528
+
529
+ while idx > 0:
530
+ f = f / base
531
+ r = r + f * (idx % base)
532
+ idx = idx // base
533
+
534
+ result[i] = r
535
+
536
+ return result
537
+
538
+ def _sample(
539
+ self,
540
+ n_samples: int,
541
+ n_var: int,
542
+ device: torch.device,
543
+ dtype: torch.dtype,
544
+ ) -> Tensor:
545
+ if n_var > len(self._PRIMES):
546
+ raise ValueError(
547
+ f"HaltonSampling supports up to {len(self._PRIMES)} dimensions, "
548
+ f"got n_var={n_var}"
549
+ )
550
+
551
+ samples = torch.zeros(n_samples, n_var, device=device, dtype=dtype)
552
+
553
+ for j in range(n_var):
554
+ base = self._PRIMES[j]
555
+ samples[:, j] = self._halton_sequence(n_samples, base, device, dtype)
556
+
557
+ # Optional scrambling
558
+ if self.scramble:
559
+ generator = self._get_generator(device)
560
+
561
+ for j in range(n_var):
562
+ if generator is not None:
563
+ # Generator may be on CPU for MPS devices
564
+ gen_device = generator.device
565
+ shift = torch.rand(1, device=gen_device, dtype=dtype, generator=generator)
566
+ if gen_device != device:
567
+ shift = shift.to(device)
568
+ else:
569
+ shift = torch.rand(1, device=device, dtype=dtype)
570
+
571
+ samples[:, j] = (samples[:, j] + shift) % 1.0
572
+
573
+ return samples
574
+
575
+ def __repr__(self) -> str:
576
+ seed_str = f", seed={self.seed}" if self.seed is not None else ""
577
+ return f"HaltonSampling(scramble={self.scramble}{seed_str})"