Skip to content

Commit 8bd33ad

Browse files
run_evolution checkpoint params; propagate llm.provider (#472); background_blur example (#457)
Plumbs target_score/checkpoint_path through run_evolution(); fixes llm.provider not propagating to per-model configs (supersedes #472); adds the background_blur example (hot function behind a hard quality gate). Bumps version to 0.3.1.
1 parent e75ec66 commit 8bd33ad

11 files changed

Lines changed: 1216 additions & 4 deletions

File tree

examples/background_blur/README.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# Portrait background blur — evolving a hot function behind a hard quality gate
2+
3+
Optimise the per-frame background blur of a video pipeline. The score is the
4+
**speedup**, but fidelity is a **hard gate**: a candidate that is fast and wrong
5+
scores exactly zero.
6+
7+
This is the "optimise a real hot function" pattern. The interesting part is not the
8+
LLM — it is the **evaluator**. Getting the gate right *is* the job.
9+
10+
```
11+
speedup = baseline_time / candidate_time
12+
13+
reject if mean SSIM < 0.98 (overall fidelity)
14+
or worst-frame SSIM < 0.95 (no bad frames)
15+
or worst-REGION SSIM < 0.90 (no bad regions)
16+
otherwise score = speedup
17+
```
18+
19+
Everything is deterministic and self-contained: the "video" is synthesised from a
20+
fixed seed, so there is no webcam, no GPU, no ML model and no dataset to download.
21+
`numpy` is the only dependency.
22+
23+
## The task
24+
25+
For each frame, blur the background and composite the sharp person back on top,
26+
using a supplied segmentation mask:
27+
28+
```python
29+
blurred = gaussian_blur(frame, sigma)
30+
out = mask * frame + (1 - mask) * blurred
31+
```
32+
33+
The seed (`initial_program.py`) is **correct but slow**: it convolves with the full
34+
2D Gaussian directly — O(k²) passes per frame. It is the honest implementation you
35+
would write first, and it is the thing to beat.
36+
37+
## Run it
38+
39+
```bash
40+
export OPENAI_API_KEY=sk-or-... # your OpenRouter key
41+
42+
python openevolve-run.py \
43+
examples/background_blur/initial_program.py \
44+
examples/background_blur/evaluator.py \
45+
--config examples/background_blur/config.yaml \
46+
--iterations 100
47+
```
48+
49+
`config.yaml` uses OpenRouter with a cheap model (`google/gemini-2.5-flash-lite`); the
50+
key is read from `OPENAI_API_KEY`.
51+
Any OpenAI-compatible endpoint works — point `api_base` at a local optillm server to
52+
run with no hosted API and no key at all.
53+
54+
## Results
55+
56+
100 iterations. Timings are best-of-N on an idle machine (see the timing note below —
57+
this matters more than you would think).
58+
59+
| | ms/frame | speedup | mean SSIM | worst-region |
60+
|---|---:|---:|---:|---:|
61+
| seed — naive O(k²) convolution | 33.4 | 1.0x | 1.0000 | 1.0000 |
62+
| expert — hand-written separable + fp32 | 1.46 | 22.8x | 1.0000 | 1.0000 |
63+
| **evolved (OpenEvolve)** | **0.54** | **62x** | 0.9915 | 0.9723 |
64+
65+
**Read those two speedup columns carefully.** 62x is against a baseline that was
66+
*written to be bad on purpose*, so it flatters the result. The number that actually
67+
means something is the comparison against a competent implementation:
68+
69+
> **The evolved solution is 2.6x faster than a hand-written separable+fp32 blur**
70+
> and it gets there by spending part of the fidelity budget the gate allows
71+
> (SSIM 0.9915 vs the expert's exact 1.0000). It is a real win, not a free one.
72+
73+
28 of the 100 candidates were **rejected outright by the quality gate**. The gate is
74+
not decoration.
75+
76+
### What it discovered
77+
78+
The winning program stacks five distinct optimisations, one of which (blurring at
79+
reduced resolution) is the same class of trick reported for this problem elsewhere:
80+
81+
```python
82+
# 1. separable Gaussian: two 1D passes instead of k^2
83+
# 2. blur at HALF resolution, then upsample (quarter the pixels)
84+
downsample = sigma >= 1.0
85+
sigma_eff = sigma / 2.0 # rescale sigma for the small domain
86+
low = frames_arr[:, ::2, ::2, :]
87+
blurred_low = _separable_blur_batch(low, kernel)
88+
blurred = np.repeat(np.repeat(blurred_low, 2, axis=1), 2, axis=2)
89+
90+
# 3. float32 rather than float64 (halves memory traffic)
91+
# 4. BATCH the whole sequence into (N, H, W, C) and blur it in one vectorised pass
92+
frames_arr = np.stack(frames).astype(np.float32)
93+
94+
# 5. lerp composite: one multiply fewer than m*f + (1-m)*b
95+
composited = blurred + masks_arr[..., None] * (frames_arr - blurred)
96+
```
97+
98+
Nobody told it to batch across frames. It is search, not magic — but it is real search.
99+
100+
## Attack your own evaluator first
101+
102+
Before spending a single token, prove the cheats lose:
103+
104+
```bash
105+
python examples/background_blur/test_gaming.py
106+
```
107+
108+
Each test is a candidate that is **fast but wrong**, and must score 0:
109+
110+
| cheat | why it is tempting |
111+
|---|---|
112+
| return the input untouched | infinitely fast |
113+
| blur everything, ignore the mask | skips compositing |
114+
| under-blur with a tiny kernel | far cheaper than the real sigma |
115+
| blur frame 0's background, reuse it forever | ~50x faster |
116+
117+
...plus one honest optimisation (separable convolution) that **must** be accepted.
118+
119+
### The gate we would have shipped was broken
120+
121+
The first version of this evaluator used the obvious gate — mean SSIM ≥ 0.98 and
122+
worst-frame SSIM ≥ 0.95. The **stale-background** cheat sailed straight through and
123+
scored **47x**:
124+
125+
| candidate | mean | worst-frame | **worst-region** |
126+
|---|---:|---:|---:|
127+
| separable (exact) | 1.0000 | 1.0000 | 1.0000 |
128+
| **CHEAT: stale background** | 0.9871 | **0.9806** | **0.7445** |
129+
| half-res blur (legitimate) | 0.9915 | 0.9912 | 0.9723 |
130+
| 3× box blur (legitimate) | 0.9949 | 0.9944 | 0.9901 |
131+
132+
Reusing one blurred background leaves a **person-shaped ghost** where the person used
133+
to be. A human sees it instantly — but it damages a small patch, and whole-image SSIM
134+
averages it away. Both frame-level gates pass.
135+
136+
The fix is to grade the **worst 16×16 block**, not the whole frame. The ghost region
137+
scores 0.74 while genuine approximations stay above 0.97, so `worst_region ≥ 0.90`
138+
separates them cleanly.
139+
140+
**The lesson generalises:** an aggregate metric hides localised damage. If your quality
141+
bar is an average, the search will find the thing your average cannot see. Write the
142+
cheats yourself and make sure they lose.
143+
144+
## Timing as fitness is its own trap
145+
146+
`parallel_evaluations: 1` is necessary — concurrent evaluations contend for CPU and
147+
corrupt the measurement — but it is **not sufficient**.
148+
149+
The first version of this evaluator measured the baseline **once**, cached it, and
150+
compared every later candidate against it. That one measurement happened while the
151+
machine was busy, so the baseline was inflated (95.6 ms/frame vs a true 33.4) — and
152+
therefore *every speedup reported during the run was inflated too*. The winner was
153+
reported at **82x**; it is really **62x**.
154+
155+
The fix (`_benchmark()` in `evaluator.py`):
156+
157+
- measure baseline and candidate **interleaved, in the same call**, so slow drift
158+
cancels instead of accumulating into the ratio;
159+
- **warm up** before timing;
160+
- take the **minimum** of N runs — background load can only ever *add* time, so the
161+
minimum is the best estimate of the true cost, for both sides.
162+
163+
If your fitness is a wall-clock number, re-measure your final result on an idle
164+
machine before you believe it.
165+
166+
## Why OpenEvolve fits this problem
167+
168+
**Cascade evaluation — don't benchmark garbage.** Timing is the expensive part, so it
169+
is the last thing we do:
170+
171+
```
172+
stage 1 cheap smoke test (2 frames: shape, finite, did you blur at all?)
173+
stage 2 the quality gate (all frames: SSIM vs the reference)
174+
stage 3 the benchmark (only for candidates that earned it)
175+
```
176+
177+
**Artifacts — tell the model *why* it failed.** A scalar score says "0". OpenEvolve's
178+
artifact side-channel hands the next prompt the actual reason:
179+
180+
> QUALITY GATE FAILED: mean SSIM 0.9871 (needs >= 0.98), worst-frame SSIM 0.9806 on
181+
> frame 6, worst-REGION SSIM 0.7445 (needs >= 0.90). ... do not reuse a stale
182+
> background (it leaves a person-shaped ghost that wrecks one region while barely
183+
> moving the frame average).
184+
185+
**MAP-Elites — keep rival strategies alive.** The grid is `(complexity, ssim)`, so
186+
"exact and fast" (separable, SSIM 1.0) and "approximate and faster" (half-res, SSIM
187+
0.99) both survive instead of the population collapsing onto whichever appeared first.
188+
189+
## Files
190+
191+
| file | what it is |
192+
|---|---|
193+
| `initial_program.py` | the seed — correct, slow, `EVOLVE-BLOCK` markers |
194+
| `evaluator.py` | 3-stage cascade, the hard gate, interleaved timing, artifacts |
195+
| `fixtures.py` | deterministic scene, trusted reference, SSIM + worst-region SSIM |
196+
| `test_gaming.py` | adversarial tests — the cheats must lose |
197+
| `config.yaml` | OpenRouter endpoint, MAP-Elites grid, `parallel_evaluations: 1` |
198+
199+
## Adapting this to your own hot function
200+
201+
1. Wrap the function in `EVOLVE-BLOCK-START` / `EVOLVE-BLOCK-END`.
202+
2. Write a **reference** implementation the evaluator owns (never handed to the
203+
candidate) and a **fidelity metric** with a hard threshold.
204+
3. Write the cheats and prove they score zero. Then go looking for the *localised*
205+
cheat your aggregate metric cannot see.
206+
4. Put the expensive measurement in the last cascade stage, and measure it fairly.
207+
5. Return artifacts explaining every rejection.
208+
6. Compare against a **competent** implementation, not just your slow seed — that is
209+
the only number that means anything.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Portrait background blur - optimise a hot function behind a hard quality gate.
2+
#
3+
# Uses OpenRouter with a cheap, fast model. Put your OpenRouter key in the usual
4+
# environment variable before running:
5+
#
6+
# export OPENAI_API_KEY=sk-or-...
7+
#
8+
# Any OpenAI-compatible endpoint works. To run fully locally with no hosted API and
9+
# no key at all, point `api_base` at a local optillm server instead, e.g.:
10+
#
11+
# api_base: "http://localhost:8000/v1"
12+
# models: [{ name: "optillm", api_base: "http://localhost:8000/v1", weight: 1.0 }]
13+
14+
max_iterations: 100
15+
checkpoint_interval: 5
16+
diff_based_evolution: false # full rewrites: the win here is restructuring the blur
17+
language: python
18+
max_code_length: 40000
19+
20+
llm:
21+
api_base: "https://openrouter.ai/api/v1"
22+
max_tokens: 6000
23+
temperature: 0.9
24+
timeout: 600
25+
retries: 6
26+
retry_delay: 8
27+
models:
28+
- name: "google/gemini-2.5-flash-lite"
29+
api_base: "https://openrouter.ai/api/v1"
30+
weight: 1.0
31+
32+
prompt:
33+
system_message: |
34+
You are an expert at high-performance numerical Python (NumPy).
35+
36+
You are optimising the hot function of a real-time video pipeline: a portrait-mode
37+
background blur. For every frame it must blur the background with a Gaussian of the
38+
given sigma and composite the (sharp) person back on top using the supplied mask:
39+
40+
blurred = gaussian_blur(frame, sigma)
41+
out = mask * frame + (1 - mask) * blurred
42+
43+
The current implementation is correct but slow: it convolves with the full 2D
44+
Gaussian kernel directly, costing O(k^2) passes per frame.
45+
46+
YOUR SCORE IS THE SPEEDUP. But speed only counts if the output still matches the
47+
reference blur. Fidelity is a HARD GATE, not a trade-off:
48+
49+
mean SSIM >= 0.98
50+
worst-frame SSIM >= 0.95
51+
worst-REGION SSIM >= 0.90 (worst 16x16 block - catches localised artefacts)
52+
53+
Fail any of those and you score ZERO no matter how fast you are. So do NOT:
54+
- skip the blur, or under-blur with a kernel smaller than sigma demands
55+
- blur the person (always respect the mask)
56+
- reuse a stale background across frames (the background genuinely changes;
57+
a stale background leaves a person-shaped ghost that destroys one region
58+
while barely moving the frame average)
59+
60+
Legitimate ways to go faster include (but are not limited to): exploiting the
61+
separability of the Gaussian (two 1D passes instead of k^2), blurring at reduced
62+
resolution and upsampling, approximating the Gaussian with repeated box blurs via
63+
summed-area tables/cumsum, FFT convolution, avoiding needless float64 work and
64+
redundant copies, and vectorising across the sequence.
65+
66+
Use only numpy and the Python standard library. Keep the public entry point
67+
`process_sequence(frames, masks, sigma)` with the same signature and return type.
68+
69+
database:
70+
population_size: 30
71+
num_islands: 2
72+
# MAP-Elites grid: spread candidates across code complexity and how much fidelity
73+
# they spend, so we keep BOTH the exact-and-fast and the approximate-and-faster
74+
# strategies alive instead of collapsing onto one.
75+
feature_dimensions:
76+
- complexity
77+
- ssim
78+
79+
evaluator:
80+
timeout: 600
81+
cascade_evaluation: true
82+
# Stage 1 (smoke) and stage 2 (quality gate) each return combined_score 1.0 on pass
83+
# and 0.0 on reject, so a 0.5 threshold means "only benchmark what is already correct".
84+
cascade_thresholds: [0.5, 0.5]
85+
# IMPORTANT: fitness here is a TIMING measurement. Concurrent evaluations contend for
86+
# CPU and corrupt the benchmark, so this MUST stay at 1. Note that even serial
87+
# evaluation is not sufficient on its own - see the timing notes in the README.
88+
parallel_evaluations: 1

0 commit comments

Comments
 (0)