|
| 1 | +# Devol |
| 2 | + |
| 3 | +**Diffusion Evolution** - What if evolution worked like image generation? |
| 4 | + |
| 5 | +## What Is This? |
| 6 | + |
| 7 | +Traditional evolutionary algorithms create new solutions by *copying and mutating* successful ones. Devol does something different: it starts with pure noise and *denoises* toward good solutions, guided by fitness. |
| 8 | + |
| 9 | +The core idea: instead of asking "what should the children of good solutions look like?", we ask "given this random noise, what good solution could it have come from?" |
| 10 | + |
| 11 | +This reframing gives us an algorithm that naturally transitions from broad exploration to precise optimization - without any special tuning. |
| 12 | + |
| 13 | +**The intuition**: Imagine you're in a foggy room full of people, each standing at a different elevation. You can only see your immediate neighbors through the fog. To find the highest point, you don't just copy the person next to you - you look at everyone nearby, weight them by height, and move toward the weighted average. As the fog clears (denoising), your steps become smaller and more precise. |
| 14 | + |
| 15 | +## Quick Start |
| 16 | + |
| 17 | +```python |
| 18 | +import numpy as np |
| 19 | +from devol import DiffusionEvolution, DiffusionConfig |
| 20 | + |
| 21 | +# Define what you're optimizing |
| 22 | +def sphere(x: np.ndarray) -> float: |
| 23 | + """Simple sphere function - maximum at origin.""" |
| 24 | + return -np.sum(x ** 2) |
| 25 | + |
| 26 | +# Configure the algorithm |
| 27 | +config = DiffusionConfig( |
| 28 | + population_size=128, |
| 29 | + num_steps=100, |
| 30 | + param_dim=10, |
| 31 | + sigma_m=0.5, |
| 32 | +) |
| 33 | + |
| 34 | +# Run evolution |
| 35 | +algo = DiffusionEvolution(config, sphere) |
| 36 | +algo.run(initial_population=None) |
| 37 | + |
| 38 | +# Get results |
| 39 | +best_solution, best_fitness = algo.get_best_individual() |
| 40 | +print(f"Best fitness: {best_fitness:.6f}") |
| 41 | +``` |
| 42 | + |
| 43 | +### Configuration Options |
| 44 | + |
| 45 | +| Parameter | Description | Default | |
| 46 | +|-----------|-------------|---------| |
| 47 | +| `population_size` | Number of candidate solutions | 512 | |
| 48 | +| `num_steps` | Denoising iterations | 50 | |
| 49 | +| `param_dim` | Dimensionality of search space | (required) | |
| 50 | +| `sigma_m` | Mutation scale [0, 1] | 1.0 | |
| 51 | +| `schedule.type` | `linear`, `cosine`, or `ddpm` | `cosine` | |
| 52 | +| `fitness.mapping` | How fitness converts to weights | `direct` | |
| 53 | +| `fitness.temperature` | Sharpness of fitness weighting | 1.0 | |
| 54 | + |
| 55 | +### Using YAML Configuration |
| 56 | + |
| 57 | +```python |
| 58 | +from devol import DiffusionEvolution |
| 59 | +from devol.config import DiffusionConfig |
| 60 | +from pydantic_yaml import parse_yaml_file_as |
| 61 | + |
| 62 | +config = parse_yaml_file_as(DiffusionConfig, "config.yaml") |
| 63 | +algo = DiffusionEvolution(config, your_fitness_function) |
| 64 | +``` |
| 65 | + |
| 66 | +Example `config.yaml`: |
| 67 | +```yaml |
| 68 | +population_size: 256 |
| 69 | +num_steps: 200 |
| 70 | +param_dim: 32 |
| 71 | +sigma_m: 0.5 |
| 72 | +schedule: |
| 73 | + type: cosine |
| 74 | + epsilon: 0.0001 |
| 75 | +fitness: |
| 76 | + mapping: exponential |
| 77 | + temperature: 2.0 |
| 78 | + normalize: min_max |
| 79 | +``` |
| 80 | +
|
| 81 | +## Benchmarks |
| 82 | +
|
| 83 | +The benchmark suite helps you understand how different configurations perform on challenging optimization landscapes. |
| 84 | +
|
| 85 | +### Why Benchmark? |
| 86 | +
|
| 87 | +Different problems favor different settings: |
| 88 | +- **Multimodal landscapes** (many local optima): May need higher `sigma_m` and more steps |
| 89 | +- **High-dimensional spaces**: May need larger populations |
| 90 | +- **Smooth landscapes**: Can often use fewer steps with aggressive schedules |
| 91 | + |
| 92 | +The benchmarks use the **Rastrigin function** - a notoriously difficult test case with a global optimum surrounded by a regular grid of local optima. If your configuration works on Rastrigin, it has a fighting chance on real problems. |
| 93 | + |
| 94 | +### Running Benchmarks |
| 95 | + |
| 96 | +```bash |
| 97 | +uv run -m benchmark.main |
| 98 | +``` |
| 99 | + |
| 100 | +This runs a grid search across: |
| 101 | +- **Schedule types**: linear, cosine, ddpm |
| 102 | +- **Population sizes**: 64, 128, 256 |
| 103 | +- **Steps**: 64, 128, 256, 512 |
| 104 | +- **Dimensions**: 8, 16, 32, 64 |
| 105 | +- **Mutation scales**: 0.2, 0.5, 0.8, 1.0 |
| 106 | + |
| 107 | +Results are saved to `benchmark_results/` with visualizations showing: |
| 108 | +- Best fitness achieved per configuration |
| 109 | +- How different schedules compare |
| 110 | +- The effect of population size vs. steps tradeoffs |
| 111 | + |
| 112 | +### Custom Benchmarks |
| 113 | + |
| 114 | +```python |
| 115 | +from benchmark import GridSearchRunner |
| 116 | +
|
| 117 | +def your_objective(x): |
| 118 | + # Your fitness function here |
| 119 | + return score |
| 120 | +
|
| 121 | +runner = GridSearchRunner( |
| 122 | + fitness_fn=your_objective, |
| 123 | + schedule_types=["cosine", "ddpm"], |
| 124 | + population_sizes=[128, 256], |
| 125 | + num_steps_list=[100, 200], |
| 126 | + param_dims=[16], |
| 127 | + sigma_m_values=[0.5, 0.8], |
| 128 | + seeds=[42, 123], |
| 129 | +) |
| 130 | +
|
| 131 | +results = runner.run(verbose=True) |
| 132 | +``` |
| 133 | + |
| 134 | +The runner uses multiprocessing to parallelize experiments across CPU cores. |
| 135 | + |
| 136 | +--- |
| 137 | + |
| 138 | +## How It Actually Works |
| 139 | + |
| 140 | +If you're curious about the mechanics, here's the full story. |
| 141 | + |
| 142 | +### The Diffusion Perspective |
| 143 | + |
| 144 | +The key insight: if you add enough random noise to any population of solutions, they all become indistinguishable - just random static. The magic is in *reversing* that process. |
| 145 | + |
| 146 | +- **Forward process**: Take good solutions and gradually add noise until they're unrecognizable |
| 147 | +- **Reverse process**: Start with pure noise and gradually remove it, guided by fitness |
| 148 | + |
| 149 | +The reverse process is where evolution happens. At each step, we ask: "Given this noisy solution, what did the *clean* solution probably look like?" And we answer using two signals: |
| 150 | + |
| 151 | +1. **Fitness**: Better solutions should be more likely origins |
| 152 | +2. **Proximity**: Solutions that are closer in parameter space are more relevant |
| 153 | + |
| 154 | +This is captured in a beautifully simple equation. To estimate what a noisy point `xβ` originally was, we compute a weighted average: |
| 155 | + |
| 156 | +``` |
| 157 | +xΜβ = Ξ£ (fitness_weight Γ proximity_weight Γ candidate) / normalization |
| 158 | +``` |
| 159 | + |
| 160 | +Each candidate solution contributes based on both how good it is *and* how close it is to the noisy observation. This creates a kind of "gravitational pull" toward high-fitness regions, but filtered through local structure. |
| 161 | + |
| 162 | +### Why Proximity Matters |
| 163 | + |
| 164 | +The proximity weighting is the secret sauce. In traditional evolution, a mutation in New York affects a solution in Tokyo with the same probability. In diffusion evolution, the influence is local - solutions only "see" their neighbors. |
| 165 | + |
| 166 | +This means: |
| 167 | +- **Early iterations** (high noise): Large-scale structure emerges, populations cluster toward promising regions |
| 168 | +- **Late iterations** (low noise): Fine-grained optimization, solutions converge precisely to peaks |
| 169 | + |
| 170 | +The algorithm naturally transitions from exploration to exploitation without any explicit scheduling. |
| 171 | + |
| 172 | +### The Algorithm Step by Step |
| 173 | + |
| 174 | +Here's what happens at each denoising step: |
| 175 | + |
| 176 | +**Step 1: Estimate the clean solution** |
| 177 | + |
| 178 | +For each noisy solution `xβ`, we estimate what it was before noise was added: |
| 179 | + |
| 180 | +``` |
| 181 | +xΜβ = (1/Z) Ξ£ g[f(x)] Γ N(xβ; βΞ±βΒ·x, 1-Ξ±β) Γ x |
| 182 | +``` |
| 183 | + |
| 184 | +where: |
| 185 | +- `g[f(x)]` is a fitness-based weight (fitter solutions contribute more) |
| 186 | +- `N(...)` is a Gaussian that weights by proximity |
| 187 | +- `Z` normalizes everything |
| 188 | + |
| 189 | +**Step 2: Compute the predicted noise** |
| 190 | + |
| 191 | +``` |
| 192 | +Ξ΅Μ = (xβ - βΞ±β Β· xΜβ) / β(1-Ξ±β) |
| 193 | +``` |
| 194 | +
|
| 195 | +This is the noise we think was added to get from `xΜβ` to `xβ`. |
| 196 | +
|
| 197 | +**Step 3: Take the evolution step** |
| 198 | +
|
| 199 | +``` |
| 200 | +xβββ = βΞ±βββ Β· xΜβ + direction_term Β· Ξ΅Μ + Οβ Β· noise |
| 201 | +``` |
| 202 | +
|
| 203 | +We move toward our estimate of the clean solution, partially preserving the predicted noise direction, and add fresh stochasticity controlled by `Οβ`. |
| 204 | +
|
| 205 | +### The Noise Schedule |
| 206 | +
|
| 207 | +The parameter `Ξ±β` controls how much "signal" remains at step `t`: |
| 208 | +- `Ξ±β = 1`: Pure signal, no noise |
| 209 | +- `Ξ±β = 0`: Pure noise, no signal |
| 210 | +
|
| 211 | +The schedule (linear, cosine, or DDPM) determines how quickly we transition. Cosine schedules spend more time in the middle range where interesting structure emerges. |
| 212 | +
|
| 213 | +## References |
| 214 | +
|
| 215 | +This implementation is based on: |
| 216 | +
|
| 217 | +> **Diffusion Models are Evolutionary Algorithms** |
| 218 | +> arXiv:2410.02543 |
| 219 | +> https://arxiv.org/abs/2410.02543 |
| 220 | +
|
| 221 | +The paper establishes the theoretical connection between diffusion models and evolutionary computation, showing that the iterative denoising process can be interpreted as fitness-guided evolution with proximity-aware selection. |
0 commit comments