Skip to content

Feat: Add held-out set evaluation for final candidate selection#308

Open
UgurKap wants to merge 19 commits into
gepa-ai:mainfrom
UgurKap:feat/held-out-set
Open

Feat: Add held-out set evaluation for final candidate selection#308
UgurKap wants to merge 19 commits into
gepa-ai:mainfrom
UgurKap:feat/held-out-set

Conversation

@UgurKap

@UgurKap UgurKap commented Apr 4, 2026

Copy link
Copy Markdown

Summary

This PR adds held-out-set support to gepa.optimize() so final candidate selection can happen on a split that did not drive candidate survival during optimization.

The motivation is that valset already participates in the optimization loop through Pareto tracking and candidate selection. Re-using that same split for final selection can bias selection toward candidates that survived search pressure on the Pareto/validation set, even if they generalize worse on unseen data.

Changes

  • add held_out support to gepa.optimize()
  • add HeldOutSetEvaluationPolicy
  • evaluate the current valset leader on the held-out set
  • select the final best candidate by held-out score when configured
  • track held-out scores and held-out evaluation counts in GEPAResult
  • add held-out callback/event coverage
  • add tests for held-out evaluation flow, callbacks, persistence, warnings, logging semantics, and result semantics
  • add documentation for the held-out selection policy
  • Unrelated, but was a bug: fix GEPAResult JSON round-trip deserialization for integer-keyed maps such as held_out_scores (i.e. reading the candidate_ids back from the disk turns them into strings)

Key Decisions / Assumptions

  • scope is limited to gepa.optimize(); optimize_anything was left unchanged
  • if held_out is provided and val_evaluation_policy is None, GEPA auto-selects HeldOutSetEvaluationPolicy
  • "full_eval" still means FullEvaluationPolicy()
  • if "full_eval" or FullEvaluationPolicy() is used together with held_out, GEPA raises an error
  • "heldout_eval" is available as an explicit held-out-aware option
  • "heldout_eval" without a held_out split is allowed but emits a warning and falls back to valset-only semantics
  • best_score_on_valset remains the true best valset score
  • policy_selected_valset_score is logged only when held_out is configured
  • best_score_on_held_out is the held-out score of the policy-selected candidate
  • aggregate-valset-best reporting is preserved separately from policy-selected-best reporting
  • valset callback semantics remain valset-focused even under held-out selection

Validation

  • uv run pytest tests/test_held_out_eval_policy.py
  • uv run pyright src/
  • smoke-tested full_eval and heldout_eval behavior locally on a short AIME slice

UgurKap added 3 commits April 4, 2026 03:24
Introduces a 4th data split (held-out) alongside the existing feedback/pareto/test
splits. The valset leader is evaluated on the held-out set whenever it changes;
the final best candidate is selected by held-out score rather than valset score,
reducing overfitting to the pareto/valset examples.

Key changes:
- GEPAState: new prog_candidate_held_out_subscores and num_held_out_evals fields
  (separate budget counter so held-out evals don't consume max_metric_calls)
- HeldOutEvaluation dataclass mirroring ValsetEvaluation
- HeldOutSetEvaluationPolicy: reads held-out scores from state, exposes
  get_valset_leader() for the engine to determine who to evaluate
- GEPAEngine: _evaluate_on_held_out_set(), held_out_evaluator closure,
  seed held-out eval at initialization (skipped on resume), held-out check
  in _run_full_eval_and_add() before get_best_program()
- on_held_out_evaluated callback event with symmetric seed firing at iteration 0
- GEPAResult: held_out_scores, num_held_out_evals, valset_best_idx;
  best_idx uses held-out when available
- Logging/metrics cleaned up: removed stale variable refs, consistent key names,
  best_program_held_out_score in experiment tracker
- api.py: held_out parameter, auto-selects HeldOutSetEvaluationPolicy,
  validates policy/held_out combination up front
- Cache bypass for held-out evaluation to avoid id-space collisions with valset
@semanticdiff-com

semanticdiff-com Bot commented Apr 4, 2026

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  src/gepa/core/engine.py  29% smaller
  src/gepa/api.py  27% smaller
  src/gepa/core/result.py  16% smaller
  src/gepa/logging/utils.py  9% smaller
  docs/docs/api/strategies/HeldOutSetEvaluationPolicy.md Unsupported file format
  docs/docs/guides/experiment-tracking.md Unsupported file format
  docs/docs/guides/faq.md Unsupported file format
  docs/docs/guides/held-out-selection.md Unsupported file format
  docs/docs/guides/index.md Unsupported file format
  docs/docs/guides/quickstart.md Unsupported file format
  docs/mkdocs.yml  0% smaller
  src/gepa/core/callbacks.py  0% smaller
  src/gepa/core/state.py  0% smaller
  src/gepa/proposer/reflective_mutation/reflective_mutation.py  0% smaller
  src/gepa/strategies/eval_policy.py  0% smaller
  tests/test_callbacks.py  0% smaller
  tests/test_candidate_selector.py  0% smaller
  tests/test_held_out_eval_policy.py  0% smaller

@UgurKap UgurKap marked this pull request as draft April 4, 2026 03:04
@UgurKap UgurKap marked this pull request as ready for review April 4, 2026 21:43
@Shangyint

Copy link
Copy Markdown
Collaborator

Thanks for the PR. However, this does not serve a good purpose for the following reasons:

  1. This is already achievable currently via callbacks
  2. Valset is unseen data in the way that the reflection proposer will not see any of them, unless we set trainset == valset
  3. Empirically, not using another held-out set works well for multiple domains; this PR could be doing premature optimization (that also at a large cost)

@UgurKap

UgurKap commented Apr 7, 2026

Copy link
Copy Markdown
Author

Thanks for the PR. However, this does not serve a good purpose for the following reasons:

1. This is already achievable currently via callbacks

2. Valset _is_ unseen data in the way that the reflection proposer will not see any of them, unless we set `trainset == valset`

3. Empirically, not using another held-out set works well for multiple domains; this PR could be doing premature optimization (that also at a large cost)

Hello @Shangyint , thank you for the comment. Sorry, about not putting in more context. I had previously talked with @LakshyAAAgrawal , and showed why this approach could be good here: https://uguralikaplan.com/blog/gepa-is-better-than-you-think/ (Essentially showing pareto set cannot measure generalization capability at least for AIME-2026). I didn't consider if this would be possible with the callbacks though.

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