Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,32 @@

---

## Feature: Pareto selection over several objectives

This fork adds multi-objective selection. Set `database.objectives` to two or more metric names your evaluator
returns, and programs are compared by **Pareto dominance** instead of by one number: A beats B only if A is at least
as good on every objective and strictly better on one. When neither dominates, NSGA-II's secondary keys decide,
computed against the current population: lower **rank** first (rank 0 is the non-dominated front; rank k is what is
non-dominated once fronts 0..k-1 are removed), then larger **crowding distance** (the normalized gap to the nearest
neighbours on the front; boundary programs count as infinitely isolated), which keeps the front spread out. No
objective is weighted or ordered above another, and nothing is summed.

```yaml
database:
objectives: [primary_score, availability_robustness, changeover_robustness] # evaluator metrics, larger is better
objective_directions: [max, max, max] # optional; "min" flips an objective
best_selection: first_objective # what get_best_program() reports from the front (reporting only)
```

What changes with `objectives` set: MAP-Elites cell replacement, archive membership, parent and inspiration
sampling, island migration and population trimming all use dominance / rank / crowding (`openevolve/pareto.py`,
`ProgramDatabase.fitness`, `ProgramDatabase._is_better`); the prompt shows the LLM every objective's value for the
current and the top programs instead of a single score; each checkpoint writes `pareto_front.json` with the front
and its objective values. `get_best_program()` returns one front member under `best_selection` (`first_objective`, the default: the best
value of `objectives[0]` on the front, which is the program a single-objective run would report from the same
population; `knee`: objectives normalized to their range on the front, the member closest to the ideal point;
`crowding`: the most isolated member, an extreme), a reporting rule that selection never uses. With `objectives` empty, behaviour is identical to upstream OpenEvolve. Tests: `tests/test_pareto.py`.

## Why OpenEvolve?

<table>
Expand Down
5 changes: 5 additions & 0 deletions configs/default_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ database:
# - "diversity": Code structure diversity
#
# You can mix built-in features with custom metrics from your evaluator:
# Pareto selection (this fork): name two or more evaluator metrics here and programs are compared by dominance,
# then NSGA-II rank and crowding distance, with no weights and no order between objectives (see README).
# objectives: [primary_score, robustness_a, robustness_b]
# objective_directions: [max, max, max] # optional, "min" flips an objective
# best_selection: first_objective # reporting rule for the single "best": first_objective | knee | crowding
feature_dimensions: # Dimensions for MAP-Elites feature map (for diversity, NOT fitness)
- "complexity" # Code length (built-in)
- "diversity" # Code diversity (built-in)
Expand Down
20 changes: 20 additions & 0 deletions openevolve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,26 @@ class DatabaseConfig:
# CRITICAL: For custom dimensions, evaluators must return RAW VALUES, not bin indices
# Built-in: "complexity", "diversity", "score" (always available)
# Custom: Any metric from your evaluator (must be continuous values)
# Pareto selection over several objectives (openevolve/pareto.py). When `objectives` names two or more evaluator
# metrics, programs are compared by dominance (better on every objective, strictly on one) and, when neither
# dominates, by NSGA-II rank then crowding distance against the current population. No weights, no order between
# objectives. Empty (default): the usual single fitness (combined_score, else the average of non-feature metrics).
objectives: List[str] = field(
default_factory=list,
metadata={"help": "Metric names to optimise jointly by Pareto dominance; empty = single-fitness selection."},
)
# Per-objective direction, "max" (default) or "min"; shorter lists are padded with "max".
objective_directions: List[str] = field(default_factory=list)
# Which front member get_best_program() reports when objectives are set (reporting only: the search never uses it;
# the whole front is written to pareto_front.json at every checkpoint):
# "first_objective" (default): the best value of objectives[0] on the front. The primary-best program of a
# population is always on the front, so this reports the same program a single-objective run
# would report from the same population, which keeps arms comparable;
# "knee": objectives normalized to their range on the front, the member closest to the ideal point, i.e. the
# most balanced trade-off, with no objective preferred;
# "crowding": the most isolated member, which is a boundary point, i.e. an extreme.
best_selection: str = "first_objective"

feature_dimensions: List[str] = field(
default_factory=lambda: ["complexity", "diversity"],
metadata={
Expand Down
4 changes: 2 additions & 2 deletions openevolve/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ def __init__(
self.llm_ensemble = LLMEnsemble(self.config.llm.models)
self.llm_evaluator_ensemble = LLMEnsemble(self.config.llm.evaluator_models)

self.prompt_sampler = PromptSampler(self.config.prompt)
self.evaluator_prompt_sampler = PromptSampler(self.config.prompt)
self.prompt_sampler = PromptSampler(self.config.prompt, objectives=list(self.config.database.objectives or []))
self.evaluator_prompt_sampler = PromptSampler(self.config.prompt, objectives=list(self.config.database.objectives or []))
self.evaluator_prompt_sampler.set_templates("evaluator_system_message")

# Pass random seed to database if specified
Expand Down
136 changes: 121 additions & 15 deletions openevolve/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from openevolve.config import DatabaseConfig
from openevolve.utils.code_utils import calculate_edit_distance
from openevolve.utils.metrics_utils import safe_numeric_average, get_fitness_score
from openevolve.pareto import dominates, objective_values, pareto_keys, key_of

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -126,6 +127,9 @@ def __init__(self, config: DatabaseConfig):

# In-memory program storage
self.programs: Dict[str, Program] = {}
# Pareto selection (config.objectives): (rank, crowding, key) per program, recomputed when the population changes
self._pareto_cache: Dict[str, Tuple[int, float, float]] = {}
self._pareto_dirty: bool = True

# Per-island feature grids for MAP-Elites
self.island_feature_maps: List[Dict[str, str]] = [{} for _ in range(config.num_islands)]
Expand Down Expand Up @@ -232,6 +236,7 @@ def add(
self.last_iteration = max(self.last_iteration, iteration)

self.programs[program.id] = program
self._pareto_dirty = True

# Calculate feature coordinates for MAP-Elites
feature_coords = self._calculate_feature_coords(program)
Expand Down Expand Up @@ -323,10 +328,8 @@ def add(
existing_program_id = island_feature_map[feature_key]
if existing_program_id in self.programs:
existing_program = self.programs[existing_program_id]
new_fitness = get_fitness_score(program.metrics, self.config.feature_dimensions)
existing_fitness = get_fitness_score(
existing_program.metrics, self.config.feature_dimensions
)
new_fitness = self.fitness(program)
existing_fitness = self.fitness(existing_program)
logger.info(
"Island %d MAP-Elites cell improved: %s (fitness: %.3f -> %.3f)",
island_idx,
Expand Down Expand Up @@ -494,6 +497,15 @@ def get_best_program(self, metric: Optional[str] = None) -> Optional[Program]:
if not self.programs:
return None

# Pareto selection: the reported best is the front member chosen by config.best_selection
if metric is None and self.config.objectives:
best = self._best_on_front()
if best is not None:
if best.id != self.best_program_id:
logger.info(f"Updated best program tracking from {self.best_program_id} to {best.id} (Pareto front, {self.config.best_selection})")
self.best_program_id = best.id
return best

# If no specific metric and we have a tracked best program, return it
if metric is None and self.best_program_id:
if self.best_program_id in self.programs:
Expand All @@ -518,7 +530,7 @@ def get_best_program(self, metric: Optional[str] = None) -> Optional[Program]:
# Sort by fitness (excluding feature dimensions)
sorted_programs = sorted(
self.programs.values(),
key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions),
key=lambda p: self.fitness(p),
reverse=True,
)
if sorted_programs:
Expand Down Expand Up @@ -593,7 +605,7 @@ def get_top_programs(
# Sort by combined_score if available, otherwise by average of all numeric metrics
sorted_programs = sorted(
candidates,
key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions),
key=lambda p: self.fitness(p),
reverse=True,
)

Expand Down Expand Up @@ -646,6 +658,17 @@ def save(self, path: Optional[str] = None, iteration: int = 0) -> None:
with open(os.path.join(save_path, "metadata.json"), "w") as f:
json.dump(metadata, f)

if self.config.objectives:
front = self.pareto_front()
with open(os.path.join(save_path, "pareto_front.json"), "w") as f:
json.dump({
"objectives": list(self.config.objectives),
"best_selection": self.config.best_selection,
"best_program_id": self.best_program_id,
"front": [{"id": p.id, "objectives": {o: p.metrics.get(o) for o in self.config.objectives},
"crowding": self._pareto_cache[p.id][1]} for p in front],
}, f, indent=1)

logger.info(f"Saved database with {len(self.programs)} programs to {save_path}")

def load(self, path: str) -> None:
Expand Down Expand Up @@ -699,6 +722,7 @@ def load(self, path: str) -> None:

program = Program.from_dict(program_data)
self.programs[program.id] = program
self._pareto_dirty = True
except Exception as e:
logger.warning(f"Error loading program {program_file}: {str(e)}")

Expand Down Expand Up @@ -888,7 +912,7 @@ def _calculate_feature_coords(self, program: Program) -> List[int]:
bin_idx = 0
else:
# Use fitness score for "score" dimension (consistent with rest of system)
avg_score = get_fitness_score(program.metrics, self.config.feature_dimensions)
avg_score = self.fitness(program)
# Update stats and scale
self._update_feature_stats("score", avg_score)
scaled_value = self._scale_feature_value("score", avg_score)
Expand Down Expand Up @@ -1108,6 +1132,66 @@ def _is_novel(self, program_id: int, island_idx: int) -> bool:

return self._llm_judge_novelty(program, self.programs[max_smlty_pid])

# ---- Pareto selection -------------------------------------------------------------------------------------
def objective_values(self, program: Program):
"""The program's objective vector (larger is better on every coordinate), or None if it lacks an objective."""
if not self.config.objectives:
return None
return objective_values(program.metrics, self.config.objectives, self.config.objective_directions)

def _refresh_pareto(self) -> None:
self._pareto_cache = pareto_keys([(pid, self.objective_values(p)) for pid, p in self.programs.items()])
self._pareto_dirty = False

def pareto_rank(self, program: Program) -> Tuple[int, float]:
"""(rank, crowding distance) of a program against the current population."""
if self._pareto_dirty or program.id not in self._pareto_cache:
self._refresh_pareto()
if program.id in self._pareto_cache:
r, c, _ = self._pareto_cache[program.id]
return r, c
# not in the database (a candidate): rank it among the population without caching
items = [(pid, self.objective_values(p)) for pid, p in self.programs.items()] + [(program.id, self.objective_values(program))]
r, c, _ = pareto_keys(items)[program.id]
return r, c

def fitness(self, program: Program) -> float:
"""The scalar every ranking in this class sorts on: the single fitness (combined_score or the average of
non-feature metrics) or, with config.objectives, the Pareto key -rank + crowding term (openevolve/pareto.py)."""
if not self.config.objectives:
return get_fitness_score(program.metrics, self.config.feature_dimensions)
r, c = self.pareto_rank(program)
return key_of(r, c)

def pareto_front(self) -> List[Program]:
"""The rank-0 programs (empty without objectives)."""
if not self.config.objectives or not self.programs:
return []
if self._pareto_dirty:
self._refresh_pareto()
return [self.programs[pid] for pid, (r, _, _) in self._pareto_cache.items() if r == 0 and pid in self.programs]

def _best_on_front(self) -> Optional[Program]:
"""The reported best under config.best_selection; None without objectives or programs."""
front = self.pareto_front()
if not front:
return None
rule = self.config.best_selection
if rule == "crowding": # the most isolated member: a boundary point, i.e. an extreme
return max(front, key=lambda p: self._pareto_cache[p.id][1])
if rule == "first_objective": # orders the objectives, for reporting only
return max(front, key=lambda p: (self.objective_values(p) or (float("-inf"),))[0])
# knee (default): each objective normalized to its range on the front, the member closest to the ideal point
# (the best value of every objective at once). No objective is preferred; a front of one or two members or a
# degenerate range falls back to the member with the largest normalized sum.
vals = {p.id: self.objective_values(p) for p in front}
m = len(self.config.objectives)
lo = [min(v[j] for v in vals.values()) for j in range(m)]
hi = [max(v[j] for v in vals.values()) for j in range(m)]
def norm(v):
return [((v[j] - lo[j]) / (hi[j] - lo[j])) if hi[j] > lo[j] else 1.0 for j in range(m)]
return min(front, key=lambda p: sum((1.0 - x) ** 2 for x in norm(vals[p.id])))

def _is_better(self, program1: Program, program2: Program) -> bool:
"""
Determine if program1 has better FITNESS than program2
Expand All @@ -1132,9 +1216,22 @@ def _is_better(self, program1: Program, program2: Program) -> bool:
if not program1.metrics and program2.metrics:
return False

# Pareto: dominance decides when it can; otherwise rank, then crowding (both inside self.fitness)
if self.config.objectives:
v1, v2 = self.objective_values(program1), self.objective_values(program2)
if v1 is not None and v2 is not None:
if dominates(v1, v2):
return True
if dominates(v2, v1):
return False
elif v1 is not None:
return True
elif v2 is not None:
return False

# Compare fitness (excluding feature dimensions)
fitness1 = get_fitness_score(program1.metrics, self.config.feature_dimensions)
fitness2 = get_fitness_score(program2.metrics, self.config.feature_dimensions)
fitness1 = self.fitness(program1)
fitness2 = self.fitness(program2)

return fitness1 > fitness2

Expand Down Expand Up @@ -1174,7 +1271,7 @@ def _update_archive(self, program: Program) -> None:
if valid_archive_programs:
worst_program = min(
valid_archive_programs,
key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions),
key=lambda p: self.fitness(p),
)

# Replace if new program is better
Expand Down Expand Up @@ -1209,6 +1306,13 @@ def _update_best_program(self, program: Program) -> None:

current_best = self.programs[self.best_program_id]

if self.config.objectives:
best = self._best_on_front()
if best is not None and best.id != self.best_program_id:
logger.info(f"New best program {best.id} replaces {self.best_program_id} (Pareto front, {self.config.best_selection})")
self.best_program_id = best.id
return

# Update if the new program is better
if self._is_better(program, current_best):
old_id = self.best_program_id
Expand Down Expand Up @@ -1468,7 +1572,7 @@ def _sample_from_island_weighted(self, island_id: int) -> Program:
# Calculate weights based on fitness scores
weights = []
for prog in island_program_objects:
fitness = get_fitness_score(prog.metrics, self.config.feature_dimensions)
fitness = self.fitness(prog)
# Add small epsilon to avoid zero weights
weights.append(max(fitness, 0.001))

Expand Down Expand Up @@ -1724,6 +1828,7 @@ def _remove_program_if_orphaned(self, program_id: str) -> None:

# Fully orphaned - remove from all remaining structures.
del self.programs[program_id]
self._pareto_dirty = True
self.archive.discard(program_id)
self._cleanup_stale_island_bests()
logger.debug(f"Removed orphaned program {program_id} displaced from its cell")
Expand Down Expand Up @@ -1762,11 +1867,11 @@ def _enforce_population_limit(self, exclude_program_id: Optional[str] = None) ->
# fitness worst-first. Non-elite programs are removed before elite ones.
non_elite = sorted(
[p for p in all_programs if p.id not in elite_ids and p.id not in protected_ids],
key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions),
key=lambda p: self.fitness(p),
)
elite = sorted(
[p for p in all_programs if p.id in elite_ids and p.id not in protected_ids],
key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions),
key=lambda p: self.fitness(p),
)

# Remove non-elite programs first; only fall back to evicting elite cell
Expand All @@ -1783,6 +1888,7 @@ def _enforce_population_limit(self, exclude_program_id: Optional[str] = None) ->
# Remove from main programs dict
if program_id in self.programs:
del self.programs[program_id]
self._pareto_dirty = True

# Remove from island feature maps
for island_idx, island_map in enumerate(self.island_feature_maps):
Expand Down Expand Up @@ -1852,7 +1958,7 @@ def migrate_programs(self) -> None:

# Sort by fitness (using combined_score or average metrics)
island_programs.sort(
key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions),
key=lambda p: self.fitness(p),
reverse=True,
)

Expand Down Expand Up @@ -2033,7 +2139,7 @@ def get_island_stats(self) -> List[dict]:

if island_programs:
scores = [
get_fitness_score(p.metrics, self.config.feature_dimensions)
self.fitness(p)
for p in island_programs
]

Expand Down
Loading