From a102f16b0fe775bc723ebc48aef3d203ebe4b01f Mon Sep 17 00:00:00 2001 From: ronyw7 Date: Thu, 17 Sep 2026 17:21:29 -0500 Subject: [PATCH] Implement pareto selection for multiple metrics --- README.md | 26 +++++++ configs/default_config.yaml | 5 ++ openevolve/config.py | 20 +++++ openevolve/controller.py | 4 +- openevolve/database.py | 136 +++++++++++++++++++++++++++++---- openevolve/pareto.py | 109 ++++++++++++++++++++++++++ openevolve/process_parallel.py | 20 +++-- openevolve/prompt/sampler.py | 35 ++++++--- tests/test_pareto.py | 111 +++++++++++++++++++++++++++ 9 files changed, 431 insertions(+), 35 deletions(-) create mode 100644 openevolve/pareto.py create mode 100644 tests/test_pareto.py diff --git a/README.md b/README.md index 785a1d5804..e749c77d39 100644 --- a/README.md +++ b/README.md @@ -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? diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 14ae54556b..d9f3c06082 100644 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -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) diff --git a/openevolve/config.py b/openevolve/config.py index c19ab4ca1d..f040fa4fae 100644 --- a/openevolve/config.py +++ b/openevolve/config.py @@ -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={ diff --git a/openevolve/controller.py b/openevolve/controller.py index a3f096bf8b..d367b04a1b 100644 --- a/openevolve/controller.py +++ b/openevolve/controller.py @@ -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 diff --git a/openevolve/database.py b/openevolve/database.py index 8abe2bdc0a..e4d28defaa 100644 --- a/openevolve/database.py +++ b/openevolve/database.py @@ -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__) @@ -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)] @@ -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) @@ -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, @@ -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: @@ -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: @@ -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, ) @@ -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: @@ -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)}") @@ -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) @@ -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 @@ -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 @@ -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 @@ -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 @@ -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)) @@ -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") @@ -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 @@ -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): @@ -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, ) @@ -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 ] diff --git a/openevolve/pareto.py b/openevolve/pareto.py new file mode 100644 index 0000000000..cf9efdb835 --- /dev/null +++ b/openevolve/pareto.py @@ -0,0 +1,109 @@ +"""Pareto (non-dominated) selection over several objectives. + +With ``database.objectives`` set, programs are compared by dominance instead of by one scalar: A is better than B if A +is at least as good on every objective and strictly better on one. Programs that neither dominates are ordered by +NSGA-II's two secondary keys, computed against the current population: + +* **rank**: peel the non-dominated set (rank 0), remove it, peel again (rank 1), and so on; lower is better. A rank-k + program is dominated only by programs of lower rank. +* **crowding distance**: within a front, the sum over objectives of the normalized gap between a program's two + nearest neighbours on that objective; boundary programs (the best on some objective) get infinity. Larger is better, + because it prefers programs in sparsely populated parts of the front and keeps the front spread out instead of + letting near-duplicates accumulate. + +These are the rules of Deb et al., NSGA-II (2002). No objective is weighted or ordered above another. + +The database turns (rank, crowding) into one float, ``key = -rank + 0.999 * crowding / (1 + crowding)`` (infinity -> +0.999), so that every existing "sort by fitness" call can keep working: rank always dominates, crowding only orders +programs of equal rank. The key is relative to the population and is recomputed whenever the population changes. +""" +from __future__ import annotations + +import math +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +Values = Tuple[float, ...] + + +def objective_values(metrics: Dict[str, Any], objectives: Sequence[str], directions: Optional[Sequence[str]] = None) -> Optional[Values]: + """The objective vector of a metrics dict, oriented so that larger is better on every coordinate + (``directions`` entries are "max" (default) or "min"). None if any objective is missing or not a finite number.""" + directions = list(directions or []) + directions += ["max"] * (len(objectives) - len(directions)) + out = [] + for name, direction in zip(objectives, directions): + v = metrics.get(name) + if isinstance(v, bool) or not isinstance(v, (int, float)): + return None + v = float(v) + if not math.isfinite(v): + return None + out.append(v if direction == "max" else -v) + return tuple(out) + + +def dominates(a: Values, b: Values) -> bool: + """a is at least as good as b everywhere and strictly better somewhere.""" + return all(x >= y for x, y in zip(a, b)) and any(x > y for x, y in zip(a, b)) + + +def non_dominated_sort(points: Sequence[Values]) -> List[List[int]]: + """Fronts as lists of indices into ``points``; fronts[0] is the non-dominated set.""" + n = len(points) + if n == 0: + return [] + P = np.asarray(points, dtype=float) + ge = (P[:, None, :] >= P[None, :, :]).all(axis=2) + gt = (P[:, None, :] > P[None, :, :]).any(axis=2) + dom = ge & gt # dom[i, j]: i dominates j + remaining = np.ones(n, dtype=bool) + fronts: List[List[int]] = [] + while remaining.any(): + dominated_by_remaining = (dom & remaining[:, None]).any(axis=0) # j is dominated by some remaining i + front = np.where(remaining & ~dominated_by_remaining)[0] + fronts.append(front.tolist()) + remaining[front] = False + return fronts + + +def crowding_distance(points: Sequence[Values]) -> List[float]: + """NSGA-II crowding distance of each point within one front (boundary points: infinity).""" + n = len(points) + if n == 0: + return [] + if n <= 2: + return [math.inf] * n + P = np.asarray(points, dtype=float) + d = np.zeros(n) + for j in range(P.shape[1]): + order = np.argsort(P[:, j], kind="stable") + lo, hi = P[order[0], j], P[order[-1], j] + d[order[0]] = d[order[-1]] = math.inf + if hi > lo: + gaps = (P[order[2:], j] - P[order[:-2], j]) / (hi - lo) + d[order[1:-1]] += gaps + return d.tolist() + + +def key_of(rank: int, crowding: float) -> float: + c = 0.999 if math.isinf(crowding) else 0.999 * crowding / (1.0 + crowding) + return -float(rank) + c + + +def pareto_keys(items: Sequence[Tuple[str, Optional[Values]]]) -> Dict[str, Tuple[int, float, float]]: + """{id: (rank, crowding, key)} for a population; items whose values are None (missing objectives) get the rank + below the last front and no crowding.""" + valid = [(i, v) for i, v in items if v is not None] + out: Dict[str, Tuple[int, float, float]] = {} + fronts = non_dominated_sort([v for _, v in valid]) + for rank, front in enumerate(fronts): + cd = crowding_distance([valid[i][1] for i in front]) + for i, c in zip(front, cd): + out[valid[i][0]] = (rank, c, key_of(rank, c)) + worst = len(fronts) + for i, v in items: + if v is None: + out[i] = (worst, 0.0, key_of(worst, 0.0)) + return out diff --git a/openevolve/process_parallel.py b/openevolve/process_parallel.py index b2cfeab788..a7c2b0dec6 100644 --- a/openevolve/process_parallel.py +++ b/openevolve/process_parallel.py @@ -109,7 +109,7 @@ def _lazy_init_worker_components(): if _worker_prompt_sampler is None: from openevolve.prompt.sampler import PromptSampler - _worker_prompt_sampler = PromptSampler(_worker_config.prompt) + _worker_prompt_sampler = PromptSampler(_worker_config.prompt, objectives=list(_worker_config.database.objectives or [])) if _worker_evaluator is None: from openevolve.evaluator import Evaluator @@ -118,7 +118,7 @@ def _lazy_init_worker_components(): # Create evaluator-specific components evaluator_llm = LLMEnsemble(_worker_config.llm.evaluator_models) - evaluator_prompt = PromptSampler(_worker_config.prompt) + evaluator_prompt = PromptSampler(_worker_config.prompt, objectives=list(_worker_config.database.objectives or [])) evaluator_prompt.set_templates("evaluator_system_message") _worker_evaluator = Evaluator( @@ -154,11 +154,17 @@ def _run_iteration_worker( programs[pid] for pid in db_snapshot["islands"][parent_island] if pid in programs ] - # Sort by metrics for top programs - island_programs.sort( - key=lambda p: p.metrics.get("combined_score", safe_numeric_average(p.metrics)), - reverse=True, - ) + # Sort by metrics for top programs (Pareto key against the island when objectives are configured) + objectives = list(getattr(_worker_config.database, "objectives", []) or []) + if objectives: + from openevolve.pareto import objective_values, pareto_keys + keys = pareto_keys([(p.id, objective_values(p.metrics, objectives, _worker_config.database.objective_directions)) for p in island_programs]) + island_programs.sort(key=lambda p: keys[p.id][2], reverse=True) + else: + island_programs.sort( + key=lambda p: p.metrics.get("combined_score", safe_numeric_average(p.metrics)), + reverse=True, + ) # Use config values for limits instead of hardcoding # Programs for LLM display (includes both top and diverse for inspiration) diff --git a/openevolve/prompt/sampler.py b/openevolve/prompt/sampler.py index 0febbe5fd4..0f59ac74e1 100644 --- a/openevolve/prompt/sampler.py +++ b/openevolve/prompt/sampler.py @@ -10,9 +10,9 @@ from openevolve.prompt.templates import TemplateManager from openevolve.utils.format_utils import format_metrics_safe from openevolve.utils.metrics_utils import ( - safe_numeric_average, - get_fitness_score, format_feature_coordinates, + get_fitness_score, + safe_numeric_average, ) logger = logging.getLogger(__name__) @@ -21,8 +21,10 @@ class PromptSampler: """Generates prompts for code evolution""" - def __init__(self, config: PromptConfig): + def __init__(self, config: PromptConfig, objectives: Optional[List[str]] = None): self.config = config + # Pareto selection (database.objectives): the prompt shows every objective's value instead of one fitness + self.objectives = list(objectives or []) self.template_manager = TemplateManager(custom_template_dir=config.template_dir) # Store custom template mappings @@ -150,12 +152,13 @@ def build_prompt( # Calculate fitness and feature coordinates for the new template format feature_dimensions = feature_dimensions or [] fitness_score = get_fitness_score(program_metrics, feature_dimensions) + fitness_str = self._score_str(program_metrics, feature_dimensions) feature_coords = format_feature_coordinates(program_metrics, feature_dimensions) # Format the final user message user_message = user_template.format( metrics=metrics_str, - fitness_score=f"{fitness_score:.4f}", + fitness_score=fitness_str, feature_coords=feature_coords, feature_dimensions=", ".join(feature_dimensions) if feature_dimensions else "None", improvement_areas=improvement_areas, @@ -179,6 +182,16 @@ def build_prompt( "user": user_message, } + def _score_str(self, metrics: Dict[str, Any], feature_dimensions: Optional[List[str]] = None) -> str: + """One fitness as text; with Pareto objectives, every objective's value (no single number exists).""" + if self.objectives: + parts = [] + for o in self.objectives: + v = metrics.get(o) + parts.append(f"{o}={v:.4f}" if isinstance(v, (int, float)) and not isinstance(v, bool) else f"{o}=n/a") + return ", ".join(parts) + return f"{get_fitness_score(metrics, feature_dimensions or []):.4f}" + def _format_metrics(self, metrics: Dict[str, float]) -> str: """Format metrics for the prompt using safe formatting""" # Use safe formatting to handle mixed numeric and string values @@ -345,7 +358,7 @@ def _format_evolution_history( program_code = "" if use_changes else "" # Calculate fitness score (prefers combined_score, excludes feature dimensions) - score = get_fitness_score(program.get("metrics", {}), feature_dimensions or []) + score = self._score_str(program.get("metrics", {}), feature_dimensions or []) # Extract key features (this could be more sophisticated) key_features = program.get("key_features", []) @@ -374,7 +387,7 @@ def _format_evolution_history( top_programs_str += ( top_program_template.format( program_number=i + 1, - score=f"{score:.4f}", + score=score, language=("text" if self.config.programs_as_changes_description else language), program_snippet=program_code, key_features=key_features_str, @@ -415,7 +428,7 @@ def _format_evolution_history( program_code = "" if use_changes else "" # Calculate fitness score (prefers combined_score, excludes feature dimensions) - score = get_fitness_score(program.get("metrics", {}), feature_dimensions or []) + score = self._score_str(program.get("metrics", {}), feature_dimensions or []) # Extract key features key_features = program.get("key_features", []) @@ -433,7 +446,7 @@ def _format_evolution_history( diverse_programs_str += ( top_program_template.format( program_number=f"D{i + 1}", - score=f"{score:.4f}", + score=score, language=( "text" if self.config.programs_as_changes_description else language ), @@ -500,7 +513,7 @@ def _format_inspirations_section( program_code = "" if use_changes else "" # Calculate fitness score (prefers combined_score, excludes feature dimensions) - score = get_fitness_score(program.get("metrics", {}), feature_dimensions or []) + score = self._score_str(program.get("metrics", {}), feature_dimensions or []) # Determine program type based on metadata and score program_type = self._determine_program_type(program, feature_dimensions or []) @@ -511,7 +524,7 @@ def _format_inspirations_section( inspiration_programs_str += ( inspiration_program_template.format( program_number=i + 1, - score=f"{score:.4f}", + score=score, program_type=program_type, language=("text" if self.config.programs_as_changes_description else language), program_snippet=program_code, @@ -537,7 +550,7 @@ def _determine_program_type( String describing the program type """ metadata = program.get("metadata", {}) - score = get_fitness_score(program.get("metrics", {}), feature_dimensions or []) + score = get_fitness_score(program.get("metrics", {}), feature_dimensions or []) # numeric: classified below # Check metadata for explicit type markers if metadata.get("diverse", False): diff --git a/tests/test_pareto.py b/tests/test_pareto.py new file mode 100644 index 0000000000..b426cfe651 --- /dev/null +++ b/tests/test_pareto.py @@ -0,0 +1,111 @@ +"""Pareto selection (config.database.objectives): dominance, fronts, crowding, and the database's use of them.""" +import math +import tempfile +import unittest + +from openevolve.config import Config +from openevolve.database import Program, ProgramDatabase +from openevolve.pareto import crowding_distance, dominates, non_dominated_sort, objective_values, pareto_keys + + +class TestParetoModule(unittest.TestCase): + def test_dominance(self): + self.assertTrue(dominates((2, 2), (1, 2))) + self.assertFalse(dominates((2, 1), (1, 2))) # trade-off: neither dominates + self.assertFalse(dominates((1, 2), (2, 1))) + self.assertFalse(dominates((1, 1), (1, 1))) # equal: not strictly better anywhere + + def test_fronts(self): + pts = [(1, 1), (2, 2), (3, 1), (1, 3), (0, 0)] + fronts = non_dominated_sort(pts) + self.assertEqual(sorted(fronts[0]), [1, 2, 3]) # (2,2), (3,1), (1,3) are mutually non-dominated + self.assertEqual(fronts[1], [0]) # (1,1) is dominated by (2,2) only + self.assertEqual(fronts[2], [4]) + + def test_crowding_boundaries_and_interior(self): + cd = crowding_distance([(0, 3), (1, 2), (2, 1), (3, 0)]) + self.assertTrue(math.isinf(cd[0]) and math.isinf(cd[3])) + self.assertAlmostEqual(cd[1], (2 - 0) / 3 + (3 - 1) / 3) + self.assertAlmostEqual(cd[1], cd[2]) + self.assertEqual(crowding_distance([(1, 1)]), [math.inf]) + + def test_directions_and_missing(self): + self.assertEqual(objective_values({"a": 1.0, "b": 2.0}, ["a", "b"], ["max", "min"]), (1.0, -2.0)) + self.assertIsNone(objective_values({"a": 1.0}, ["a", "b"])) + self.assertIsNone(objective_values({"a": True, "b": 1.0}, ["a", "b"])) + + def test_keys_order_rank_before_crowding(self): + keys = pareto_keys([("p", (2, 2)), ("q", (3, 1)), ("r", (1, 1)), ("s", None)]) + self.assertEqual(keys["p"][0], 0); self.assertEqual(keys["q"][0], 0); self.assertEqual(keys["r"][0], 1) + self.assertGreater(min(keys["p"][2], keys["q"][2]), keys["r"][2]) # any rank-0 key beats any rank-1 key + self.assertGreater(keys["r"][2], keys["s"][2]) # missing objectives rank last + + +def _db(objectives=None, **kw): + config = Config() + config.database.in_memory = True + config.database.num_islands = 1 + config.database.feature_dimensions = ["complexity"] + if objectives: + config.database.objectives = objectives + for k, v in kw.items(): + setattr(config.database, k, v) + return ProgramDatabase(config.database) + + +def _prog(pid, **metrics): + return Program(id=pid, code=f"def f_{pid}(): pass # " + "x" * len(pid), language="python", metrics=metrics) + + +class TestParetoDatabase(unittest.TestCase): + def test_single_fitness_unchanged_without_objectives(self): + db = _db() + a, b = _prog("a", combined_score=1.0), _prog("b", combined_score=2.0) + db.add(a); db.add(b) + self.assertTrue(db._is_better(b, a)) + self.assertEqual(db.get_best_program().id, "b") + self.assertEqual(db.pareto_front(), []) + + def test_dominance_decides(self): + db = _db(["primary", "robust"]) + a, b = _prog("a", primary=1.0, robust=1.0), _prog("b", primary=2.0, robust=1.0) + db.add(a); db.add(b) + self.assertTrue(db._is_better(b, a)); self.assertFalse(db._is_better(a, b)) + + def test_tradeoff_uses_rank_then_crowding(self): + db = _db(["primary", "robust"]) + for pid, p, r in [("a", 3.0, 0.0), ("b", 2.0, 2.0), ("c", 2.1, 1.9), ("d", 0.0, 3.0), ("e", 1.0, 1.0)]: + db.add(_prog(pid, primary=p, robust=r)) + front = {p.id for p in db.pareto_front()} + self.assertEqual(front, {"a", "b", "c", "d"}) + self.assertEqual(db.pareto_rank(db.programs["e"])[0], 1) + # boundary members (best on one objective) are the least crowded: they outrank the interior pair + self.assertGreater(db.fitness(db.programs["a"]), db.fitness(db.programs["b"])) + self.assertTrue(db._is_better(db.programs["a"], db.programs["e"])) # rank 0 beats rank 1 without dominance + self.assertFalse(db._is_better(db.programs["e"], db.programs["a"])) + + def test_best_selection_rules_and_front_file(self): + db = _db(["primary", "robust"]) + db.add(_prog("a", primary=3.0, robust=0.0)); db.add(_prog("b", primary=0.0, robust=3.0)); db.add(_prog("c", primary=1.0, robust=1.0)) + db.add(_prog("d", primary=0.5, robust=0.5)) # dominated by c + self.assertEqual(db.get_best_program().id, "a") # first_objective (default): best primary on the front + db.config.best_selection = "knee" + self.assertEqual(db.get_best_program().id, "c") # knee: the balanced member (1,1), closest to the ideal (3,3) after normalization + db.config.best_selection = "crowding" + self.assertIn(db.get_best_program().id, {"a", "b"}) # both boundary points have infinite crowding + with tempfile.TemporaryDirectory() as d: + db.save(d) + import json, os + front = json.load(open(os.path.join(d, "pareto_front.json"))) + self.assertEqual({f["id"] for f in front["front"]}, {"a", "b", "c"}) # (1,1) is dominated by neither boundary point + self.assertEqual(front["objectives"], ["primary", "robust"]) + + def test_missing_objective_ranks_last(self): + db = _db(["primary", "robust"]) + db.add(_prog("a", primary=1.0, robust=1.0)); db.add(_prog("b", primary=5.0)) + self.assertTrue(db._is_better(db.programs["a"], db.programs["b"])) + self.assertEqual(db.get_best_program().id, "a") + + +if __name__ == "__main__": + unittest.main()