Skip to content

feat: add LLM-as-World candidate selection strategy#295

Open
Benzhang2004 wants to merge 4 commits into
gepa-ai:mainfrom
Benzhang2004:main
Open

feat: add LLM-as-World candidate selection strategy#295
Benzhang2004 wants to merge 4 commits into
gepa-ai:mainfrom
Benzhang2004:main

Conversation

@Benzhang2004

@Benzhang2004 Benzhang2004 commented Mar 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add LLMCandidateSelector that uses an LLM to evaluate and select candidates by comparing their texts, scores, and lineage side-by-side — treating the LLM as the evolutionary "world" (selection pressure / world model)
  • Supports "best" (single selection) and "diverse" (identify diverse set, sample from it) modes
  • Mode-aware default fallback: CurrentBestCandidateSelector for best, ParetoCandidateSelector for diverse
  • Register "llm" and "llm_diverse" as string strategies in optimize_anything and api

Files changed

  • src/gepa/strategies/llm_candidate_selector.py (new) — CandidateSelectionSignature + LLMCandidateSelector
  • tests/test_llm_candidate_selector.py (new) — 27 tests
  • src/gepa/optimize_anything.py — register new strategies
  • src/gepa/api.py — register new strategies

Evaluation on HotpotQA

Single-component prompt optimization with GPT-4.1-mini (solver) and GPT-4.1 (reflection/selection). Aggregated over 3 random seeds, 2000 metric calls per run, 20 train / 20 val / 100 test examples.

Strategy Val (mean) Test (mean) Test (std) Avg #Candidates
pareto 68.33% 39.00% 0.0374 34
current_best 66.67% 36.33% 0.0189 16
epsilon_greedy 66.67% 37.33% 0.0094 25
top_k_pareto 66.67% 37.00% 0.0163 21
llm 66.67% 37.67% 0.0249 16
llm_diverse 66.67% 40.00% 0.0141 28

Key findings:

  • llm_diverse achieves the highest test accuracy (40.00%) with the lowest variance (0.0141), suggesting the LLM's qualitative diversity selection generalizes better than pure score-based strategies
  • Val scores are tightly clustered (66–68%), but test scores reveal clearer differentiation between strategies
  • The llm (best mode) selector performs comparably to epsilon_greedy, while llm_diverse outperforms all baselines including pareto
  • Population sizes remain moderate (16–34 candidates) — the advantage of LLM-based selection is expected to grow with larger candidate pools and multi-component optimization

Test plan

  • uv run pytest tests/test_llm_candidate_selector.py -v — 27 passed
  • uv run pytest — 397 passed, 1 skipped
  • uv run ruff check — clean on changed files
  • uv run pyright — 0 errors on changed files

Introduce LLMCandidateSelector that uses an LLM to evaluate and select
candidates by comparing their texts, scores, and lineage side-by-side,
rather than relying solely on absolute numeric scores. Supports "best"
(single selection) and "pareto_front" (identify Pareto-optimal set, then
sample) modes with graceful fallback to baseline selectors on LLM failure.

- Add CandidateSelectionSignature for prompt rendering and output parsing
- Add LLMCandidateSelector implementing CandidateSelector protocol
- Register "llm" and "llm_pareto" string strategies in optimize_anything and api
- Mode-aware default fallback (CurrentBest for best, Pareto for pareto_front)
@semanticdiff-com

semanticdiff-com Bot commented Mar 28, 2026

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  src/gepa/api.py  0% smaller
  src/gepa/optimize_anything.py  0% smaller
  src/gepa/strategies/llm_candidate_selector.py  0% smaller
  tests/test_llm_candidate_selector.py  0% smaller

@LakshyAAAgrawal LakshyAAAgrawal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good implementation overall — clean design, robust fallback, solid test coverage. A few issues to address:


1. The LLM will just pick the highest score

The prompt includes Aggregate Score: 0.8000 for each candidate. Any LLM will overwhelmingly select the candidate with the highest numeric score, making this functionally equivalent to CurrentBestCandidateSelector. The value of LLM-as-judge is in evaluating qualitative differences that scores don't capture, but showing the scores gives the LLM the answer on a silver platter.

Consider hiding scores from the prompt (or at least de-emphasizing them: "scores are approximate and may not reflect true quality — focus on the text content and approach diversity").


2. pareto_front mode doesn't use actual Pareto dominance

The prompt asks the LLM to identify "Pareto-optimal" candidates, but the LLM only sees aggregate scores and objective scores — it has no access to per-example scores. Real Pareto dominance requires per-instance comparison (candidate A beats B on example 1, but B beats A on example 3). The LLM is doing "diverse subset selection," not Pareto identification. The naming "llm_pareto" could mislead users into thinking it replaces the real Pareto selector. Consider renaming to "llm_diverse" or documenting the distinction clearly.


3. output_extractor bare-integer regex fallback picks up score digits

numbers = re.findall(r"\b(\d+)\b", lm_out)

If the LLM responds "Based on the scores of 0.8000, Candidate 2 is best", this finds ["0", "8000", "2"] and returns index 0 (from the "0" in "0.8000"), not index 2. The "Candidate N" pattern match above would catch this specific phrasing, but any response where score digits precede the candidate number and don't use the "Candidate N" pattern will silently return the wrong index.

Fix: either remove the bare-integer fallback entirely (the "Candidate N" pattern + comma-list + single-digit paths already cover well-formatted responses, and the fallback selector handles the rest), or filter out numbers that look like they're part of decimals (e.g., exclude matches adjacent to .).


4. _make_llm_selector uses reflection_lm before string→callable conversion

In optimize_anything.py, the selector is built at:

"llm": lambda: _make_llm_selector(config, rng, mode="best"),

At this point config.reflection.reflection_lm may still be a string (e.g., "openai/gpt-5"). LLMCandidateSelector.__init__ handles this via LM(lm) if isinstance(lm, str), so it works — but later in optimize_anything, the same string is converted to an LM callable again:

config.reflection.reflection_lm = make_litellm_lm(config.reflection.reflection_lm)

This creates two separate LM instances for the same model — one in the selector, one in the proposer. They don't share state (retry config, etc.). Consider either (a) building the selector after the string→callable conversion, or (b) passing the already-converted callable to the selector.

- De-emphasize scores in prompt so LLM focuses on text quality
- Rename "pareto_front" mode to "diverse" (not actual Pareto dominance)
- Remove bare-integer regex fallback that could extract score digits
- Fix api.py to pass already-converted reflection_lm_callable
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants