How optimization works
Source:vignettes/concepts-optimization-theory.Rmd
concepts-optimization-theory.RmdAn optimizer takes a module, labeled examples and usually a metric, and searches for a version of the module that scores higher. This page explains what that search can change, how the metric and the data splits steer it, why the winning score overstates the result, and where the model calls go. For the code, see Compile and optimize and Choose an optimizer.
What the search can change
A module’s interface stays fixed: the signature’s input and output fields and their types. Optimizers change what the module holds as data, mostly the demos and the instructions, and each has its own way of producing candidates:
| What changes | Where candidates come from | Optimizers |
|---|---|---|
| Demos | Labeled rows, copied as they are |
LabeledFewShot, GridSearchTeleprompter,
SIMBA, MIPROv2
|
| Demos | The module’s own outputs that passed the metric |
BootstrapFewShot,
BootstrapFewShotWithRandomSearch, MIPROv2
|
| Demos | Training rows retrieved for each input by similarity | KNNFewShot |
| Instructions or templates | Variants that you write |
GridSearchTeleprompter,
optimize_grid()
|
| Instructions | A model rewriting them after reading failed rows |
COPRO, GEPA, SIMBA (as
appended rules) |
| Instructions | A data summary and fixed tips appended | MIPROv2 |
| Decision thresholds and weights | Probabilities recorded in one extra pass | ReAnchor |
| Request settings, such as reasoning effort or temperature | A grid that you define | optimize_grid() |
| The program’s source | A model rewriting it |
GEPA on flex() modules,
AutoResearch(), MetaHarness()
|
The three kinds of demos behave differently. Labeled demos show the
answers you wrote, but each takes its output from a single column, so a
demo for a three-field output shows one field. Bootstrapped demos show
the module’s complete output, including fields your data does not label,
such as a chain_of_thought() reasoning field or the
intermediate steps of a pipeline. They passed the metric, which is not
the same as being right: if the metric checks one field, the others can
be wrong. Retrieved demos change with every input, so there is no fixed
prompt to inspect.
Instructions are the other main lever, and the right wording depends on the model. A rewrite that helps one model can hurt another, which is why a search run on a cheaper model should be confirmed on the model you will use.
Two targets go beyond the prompt text. ReAnchor changes
numbers that are never sent to the model: the threshold that turns a
probability into TRUE, or the weights that pick an option.
It records probabilities once and fits the numbers without further
calls. At the other end, GEPA can treat a whole flex()
program as one candidate and rewrite its source; see Flex: optimize a whole program.
Request settings, such as reasoning_effort or
temperature, are searched only by
optimize_grid(). Reasoning models such as gpt-6-luna reject
temperature while they reason, so sweep
reasoning_effort with them, or turn reasoning off
(reasoning_effort = "none") before sweeping
temperature.
How the metric steers the search
Each candidate ends up as one number: the metric’s mean over some rows. The optimizer sees nothing else, which has three consequences.
The search improves only what the metric measures. In Compile and optimize, a search scored on the category alone produced a module that got every category right and two of three priorities wrong.
Ties are common. A pass/fail metric on a few rows gives many
candidates the same score, and optimizers keep the candidate they saw
first, usually the unchanged module or the first variant. A graded
metric, such as metric_f1() or a custom metric that gives
partial credit, separates candidates better.
The metric can say more than a number. A metric built with
metric_with_feedback() also returns a sentence about each
failure, and GEPA passes those sentences to the model that rewrites the
instructions. metric_with_trace() lets a metric score the
steps a program took, not just its final output. Metrics and evaluation
covers how to choose and test a metric.
Training, validation and test rows
Optimizers use labeled rows for two jobs. The training rows are
material: demos are copied or bootstrapped from them, and COPRO, SIMBA
and GEPA show the model some of the training rows the module got wrong.
The validation rows are the yardstick that ranks candidates. A third
set, the test rows, is scored once at the end and never shown to an
optimizer. Metrics and
evaluation shows how to split one table three ways with
split_dataset().
When you pass no valset, optimizers fall back in
different ways:
| Optimizer | Without valset
|
|---|---|
GridSearchTeleprompter |
Scores on a random fifth of trainset and takes demos
from the rest |
BootstrapFewShotWithRandomSearch |
Stops with an error: valset is required |
BetterTogether, Omni
|
Hold out 10% of trainset
|
COPRO, MIPROv2, SIMBA,
GEPA
|
Score candidates on trainset itself |
ReAnchor |
Checks fitted settings on folds of trainset;
valset is only reported |
LabeledFewShot, KNNFewShot,
BootstrapFewShot
|
Do not rank candidates |
Scoring on trainset rewards candidates that suit the
training rows, which is exactly what demos copied from those rows do.
The gain then shrinks, or disappears, on new inputs.
Why the winning score is too high
Picking the best of several noisy scores favors the candidate that got lucky on the validation rows. Suppose every candidate has the same true accuracy, 70%, and each is scored on the same 20 validation rows. The expected score of the winner still grows with the number of candidates compared:
set.seed(1)
winner_score <- function(n_candidates, n_rows = 20, accuracy = 0.7) {
max(rbinom(n_candidates, n_rows, accuracy) / n_rows)
}
n_candidates <- c(1, 5, 20, 100)
expected_winner <- vapply(
n_candidates,
function(k) mean(replicate(5000, winner_score(k))),
numeric(1)
)
data.frame(n_candidates, expected_winner = round(expected_winner, 2))
#> n_candidates expected_winner
#> 1 1 0.70
#> 2 5 0.82
#> 3 20 0.88
#> 4 100 0.93None of these candidates is better than the others, yet the best of
20 looks far better than 70%. Optimizers that compare many candidates on
the same rows, such as MIPROv2, GEPA and
BootstrapFewShotWithRandomSearch, report this inflated
number as best_score. More validation rows shrink the
effect, and a score on test rows that no step has seen removes it. The
same holds for your own comparisons: if you try ten optimizers and keep
the best, score the winner again on fresh rows.
Two other effects push in the same direction. Training rows reach the
prompt, as demos, as examples shown to the rewriting model, and in
SIMBA’s fallback rules, so duplicates or near-duplicates
between the splits inflate every score. Keep the splits disjoint. And a
test set that you score after every change slowly turns into a
validation set; score it once per decision.
Where the model calls go
Almost all the calls in a search are spent scoring: the number of candidates times the rows each is scored on. Proposing candidates adds comparatively little, one call per rewrite. With 200 training rows and 50 validation rows, the scoring calls come to roughly:
| Optimizer and settings | Scoring calls |
|---|---|
GridSearchTeleprompter with 4 variants |
4 × 50 = 200 |
BootstrapFewShotWithRandomSearch, default 16
candidates |
16 × 50 = 800, plus bootstrapping |
MIPROv2 with auto = "light"
|
16 batches of 5 plus 4 passes over 50 = 280, plus bootstrapping |
COPRO with the defaults, breadth = 10L and
depth = 3L
|
Up to 30 × 50 = 1,500, plus 50 for the baseline and at least 200 to find failures |
GEPA with the defaults, 20 candidates for 10
generations |
20 × 10 × (50 + 200) = 50,000 |
GEPA’s defaults suit large budgets; the example in Choose an optimizer uses a population of 6 for 4 generations. That page gives a cost formula for each optimizer.
There are three ways to spend less. Score fewer candidates or fewer
rows, accepting noisier comparisons. Run the search on a cheaper model
and confirm the winner on the one you will deploy. Or, with the
optimizers that support it, cap the search with
optimizer_control(), which stops at a limit on calls,
tokens, cost or time and returns the best module found so far.