Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .github/workflows/python-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ on:

jobs:
build:
runs-on: ubuntu-latest
# ubuntu-latest (26.04) has no Python 3.9 build
runs-on: ubuntu-24.04

strategy:
fail-fast: false
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12']

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:

jobs:
release:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04 # ubuntu-latest (26.04) has no Python 3.9 build
permissions:
contents: write
id-token: write
Expand Down
12 changes: 10 additions & 2 deletions adalflow/adalflow/optim/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,16 @@ and OpenEvolve; independent implementation.
- `evolve` (`evolution/loop.py`): select parent -> propose -> evaluate -> archive,
under a budget of evaluator calls; JSONL event log; stops on score, budget or stalls.
- `Archive` (`evolution/archive.py`): per-instance Pareto parent selection (keeps
candidates that solve different cases), greedy `best`, or `resample` (always the
seed: a best-of-N baseline at the same budget).
candidates that solve different cases), greedy `best`, `map_elites` (one elite per
behavior cell from a user `descriptor`, parents uniform over cells), or `resample`
(always the seed: a best-of-N baseline at the same budget).
- `evolve(..., islands=k, migrate_every=m)`: sub-populations with periodic migration
of each island's best as the next island's inspiration.
- `evolve(..., confirm=n)` for noisy evaluators: a candidate that beats the best on
its first score is re-evaluated with the incumbent to `1 + n` samples each, and
only fully sampled candidates can be the best. In simulation this mainly makes
the reported best score honest; it improves the true quality found only when the
noise is large relative to the gains (`benchmarks/evolution/noise_sim.py`).
- `evolve(..., holdout=...)` scores the seed and the final best on held-out instances
after the search, so gains that do not generalize show up.
- `LLMProposer` (`evolution/proposer.py`): `Generator`-based, fenced-block output,
Expand Down
17 changes: 15 additions & 2 deletions adalflow/adalflow/optim/evolution/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
Independent implementation.
"""

from adalflow.optim.evolution.archive import BEST, PARETO, RESAMPLE, Archive
from adalflow.optim.evolution.archive import (
BEST,
MAP_ELITES,
PARETO,
RESAMPLE,
Archive,
)
from adalflow.optim.evolution.loop import EvolutionResult, evolve
from adalflow.optim.evolution.proposer import (
PROPOSE_TEMPLATE,
Expand All @@ -18,13 +24,19 @@
render_feedback,
)
from adalflow.optim.evolution.sandbox import PythonEvaluator, SandboxResult, run_python
from adalflow.optim.evolution.types import Candidate, Evaluation, to_evaluation
from adalflow.optim.evolution.types import (
Candidate,
Evaluation,
combine,
to_evaluation,
)

__all__ = [
"Archive",
"BEST",
"PARETO",
"RESAMPLE",
"MAP_ELITES",
"EvolutionResult",
"evolve",
"PROPOSE_TEMPLATE",
Expand All @@ -39,5 +51,6 @@
"run_python",
"Candidate",
"Evaluation",
"combine",
"to_evaluation",
]
116 changes: 88 additions & 28 deletions adalflow/adalflow/optim/evolution/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,29 @@

``RESAMPLE`` always returns the seed, so every proposal is an independent sample from
it: a best-of-N baseline at the same evaluation budget.

``MAP_ELITES`` keeps the best candidate per behavior cell (``Candidate.cell``, set from
a user descriptor) and samples parents uniformly over cells (after Mouret & Clune,
2015), so a weak but different approach still gets improved.

Every selection can be restricted to one island (``Candidate.island``); the seed
belongs to every island.
"""

from __future__ import annotations

from random import Random
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple

from adalflow.optim.evolution.types import Candidate

__all__ = ["Archive", "PARETO", "BEST", "RESAMPLE"]
__all__ = ["Archive", "PARETO", "BEST", "RESAMPLE", "MAP_ELITES"]

PARETO = "pareto"
BEST = "best"
RESAMPLE = "resample"
STRATEGIES = (PARETO, BEST, RESAMPLE)
MAP_ELITES = "map_elites"
STRATEGIES = (PARETO, BEST, RESAMPLE, MAP_ELITES)
_EPS = 1e-12


Expand All @@ -42,21 +50,41 @@ def add(self, candidate: Candidate) -> None:
def has_text(self, text: str) -> bool:
return text in self._texts

def evaluated(self) -> List[Candidate]:
return [c for c in self.candidates if c.ok]
@property
def seed(self) -> Candidate:
return self.candidates[0]

def members(self, island: Optional[int] = None) -> List[Candidate]:
"""Candidates of one island (the seed included), or all of them."""
if island is None:
return list(self.candidates)
return [
c for i, c in enumerate(self.candidates) if i == 0 or c.island == island
]

def evaluated(self, island: Optional[int] = None) -> List[Candidate]:
return [c for c in self.members(island) if c.ok]

def best(
self, island: Optional[int] = None, min_samples: int = 1
) -> Optional[Candidate]:
"""Highest mean score; the earliest candidate wins ties.

def best(self) -> Optional[Candidate]:
"""Highest score; the earliest candidate wins ties."""
ok = self.evaluated()
return max(ok, key=lambda c: c.score) if ok else None
With ``min_samples`` > 1, only candidates evaluated that many times compete
(falling back to all evaluated candidates when none has been).
"""
ok = self.evaluated(island)
confirmed = [c for c in ok if c.n_samples >= min_samples]
pool = confirmed or ok
return max(pool, key=lambda c: c.score) if pool else None

def _wins(self) -> Dict[str, int]:
@staticmethod
def _wins(pool: List[Candidate]) -> Dict[str, int]:
"""Candidate id -> number of instances where it has the (tied) best score."""
ok = self.evaluated()
keys = sorted({k for c in ok for k in c.evaluation.instance_scores})
keys = sorted({k for c in pool for k in c.evaluation.instance_scores})
wins: Dict[str, int] = {}
for key in keys:
scored = [c for c in ok if key in c.evaluation.instance_scores]
scored = [c for c in pool if key in c.evaluation.instance_scores]
top = max(c.evaluation.instance_scores[key] for c in scored)
for c in scored:
if c.evaluation.instance_scores[key] >= top - _EPS:
Expand All @@ -72,16 +100,17 @@ def _dominates(a: Candidate, b: Candidate) -> bool:
x > y + _EPS for x, y in zip(ra, rb)
)

def pareto_front(self) -> List[Candidate]:
def pareto_front(self, island: Optional[int] = None) -> List[Candidate]:
"""Non-dominated per-instance winners; ``[best]`` without instance scores.

Among candidates with identical instance scores only the earliest is kept.
"""
wins = self._wins()
pool = self.evaluated(island)
wins = self._wins(pool)
if not wins:
best = self.best()
best = self.best(island)
return [best] if best is not None else []
winners = [c for c in self.evaluated() if c.id in wins]
winners = [c for c in pool if c.id in wins]
front: List[Candidate] = []
for c in winners:
if any(self._dominates(o, c) for o in winners if o is not c):
Expand All @@ -94,27 +123,58 @@ def pareto_front(self) -> List[Candidate]:
front.append(c)
return front

def select_parent(self, rng: Random, strategy: str = PARETO) -> Candidate:
def elites(self, island: Optional[int] = None) -> Dict[Tuple, Candidate]:
"""Behavior cell -> its best candidate (candidates without a cell skipped)."""
cells: Dict[Tuple, Candidate] = {}
for c in self.evaluated(island):
if c.cell is None:
continue
if c.cell not in cells or c.score > cells[c.cell].score:
cells[c.cell] = c
return cells

def select_parent(
self, rng: Random, strategy: str = PARETO, island: Optional[int] = None
) -> Candidate:
"""A parent to improve. With nothing evaluated yet, the latest candidate."""
if strategy not in STRATEGIES:
raise ValueError(f"strategy must be one of {STRATEGIES}")
if not self.candidates:
raise ValueError("the archive is empty")
if strategy == RESAMPLE:
return self.candidates[0]
if not self.evaluated():
return self.candidates[-1] # e.g. a seed that crashed: fix it first
return self.seed
if not self.evaluated(island):
return self.members(island)[-1] # e.g. a seed that crashed: fix it first
if strategy == BEST:
return self.best()
front = self.pareto_front()
wins = self._wins()
return self.best(island)
if strategy == MAP_ELITES:
elites = self.elites(island)
if elites:
return elites[rng.choice(sorted(elites, key=repr))]
return self.best(island)
front = self.pareto_front(island)
wins = self._wins(self.evaluated(island))
weights = [max(wins.get(c.id, 0), 1) for c in front]
return rng.choices(front, weights=weights, k=1)[0]

def select_inspiration(self, rng: Random, parent: Candidate) -> Optional[Candidate]:
"""Another strong candidate to show next to the parent, if there is one."""
pool = [c for c in self.pareto_front() if c.id != parent.id]
def select_inspiration(
self,
rng: Random,
parent: Candidate,
island: Optional[int] = None,
strategy: str = PARETO,
) -> Optional[Candidate]:
"""Another strong candidate to show next to the parent, if there is one.

Under MAP-Elites it is the elite of another cell, so the proposer sees a
different approach rather than a near copy of the parent.
"""
if strategy == MAP_ELITES:
pool = [c for c in self.elites(island).values() if c.id != parent.id]
if pool:
return rng.choice(sorted(pool, key=lambda c: c.id))
pool = [c for c in self.pareto_front(island) if c.id != parent.id]
if not pool:
ranked = sorted(self.evaluated(), key=lambda c: -c.score)
ranked = sorted(self.evaluated(island), key=lambda c: -c.score)
pool = [c for c in ranked[:3] if c.id != parent.id]
return rng.choice(pool) if pool else None
Loading
Loading