Optimise several objectives, under constraints¶
Goal: tune for two or more things that genuinely conflict, and get the set of sensible trade-offs rather than one arbitrary compromise.
Accuracy against latency. Quality against cost. Reward against energy. When objectives conflict there is no single best configuration, and collapsing them into one number — a weighted sum — decides the trade-off before you have seen it. A multi-objective study defers that decision to you by returning the Pareto front: every configuration that nothing else beats on all objectives at once.
Declare a vector objective¶
let study = Study::builder()
.parallelism(THREADS)
.budget(Budget::trials(TRIALS))
.sampler(Arc::new(Nsga2::with_population_size(POPULATION)?))
.create(
StudyConfig::new(STUDY)
.with_directions([Direction::Minimize, Direction::Minimize])?
.with_metric_names(METRICS)?
.with_seed(SEED),
)?;
study.optimize(evaluate)?;
// The answer: every configuration nothing else beats on both objectives.
let mut front = study.pareto_front()?;
front.sort_by_key(|trial| trial.number);
study = atune.create_study(
direction=["minimize", "minimize"],
metric_names=METRICS,
sampler=atune.samplers.Nsga2(population_size=POPULATION),
seed=SEED,
name=STUDY,
)
study.optimize(objective, n_trials=TRIALS, n_jobs=JOBS)
# The answer: every configuration nothing else beats on both objectives.
front = sorted(study.pareto_front(), key=lambda trial: trial.number)
The direction is per objective, so Minimize error alongside Minimize cost, or
maximise one and minimise the other. Nsga2 does the ranking — non-dominated
sorting for who survives, crowding distance for keeping the front spread out
rather than clustered.
Set the population size deliberately. Nsga2::with_population_size(16) against a
48-trial budget gives three generations; the default of 50 would mean the
population never fills, so nothing is ever bred and the study degenerates into
random search wearing a genetic algorithm's name.
Read the front, not the best¶
study.best_trial() returns None for a multi-objective study, on purpose:
there is no single best, and returning an arbitrary member of the front would be a
lie the type system can prevent. Use the front instead: pareto_front() in
Rust, the best_trials property in Python.
The front is what you present. Its size is also the first diagnostic: a front holding every trial in the study means your objectives do not actually conflict.
Make sure your objectives conflict¶
This is the trap, and it is easy to walk into with a plausible-looking objective.
If one objective is a strictly monotone function of the other, then no
configuration can dominate any other — every trial is non-dominated and the
"front" is the whole study. Measured on an earlier version of the example above,
where error was 1 / (1 + capacity) against a cost equal to capacity: 48 of 48
trials sat on the front, and the page would have shown a beautifully useless
result.
The fix is a dimension along which a configuration can be simply worse — wasteful rather than merely expensive. Adding a learning rate, which affects error and costs nothing, made waste dominable: the front is now 9 of 48. Before trusting a multi-objective study, check that its front is a fraction of the trials rather than all of them.
Constraints¶
A constraint is different from an objective: you do not want to trade it off, you want it respected. Two ways, and the choice depends on whether a violation is knowable up front.
In a Rust objective, a post-evaluation constraint belongs in the canonical
constraint side-channel: call TrialCtx::record_constraints(&[...]). Values
≤ 0 are satisfied and values > 0 are violated. These values are typed trial
state, not another objective column, so constrained dominance puts declared
feasible trials first, ranks declared infeasible trials by total violation, and
does not treat an undeclared trial as proof of feasibility. A multi-seed fan
records one vector per replicate and carries the componentwise worst vector, so
feasibility must hold for every seed.
If it is knowable from the parameters alone, express it in the search space so infeasible configurations are never proposed. A width that must be a multiple of eight is a stepped distribution, not a penalty.
If it is only knowable after evaluating, report the violation as an objective
to be minimised toward zero, or fail the trial. A failed trial is recorded as
Failed and excluded from the front, which is usually what you want: a
configuration that violated a hard limit is not a trade-off, it is out.
Resist the third option — a large penalty added to another objective. It makes the objective discontinuous exactly where the sampler most needs to model it, and it turns "infeasible" into "very slightly worse than feasible" at whatever scale you picked for the penalty.
The Python binding currently has no Trial.record_constraints declaration
method. It can read canonical values produced by another producer through
FrozenTrial.constraints (None means no declaration), but a Python objective
cannot currently write this side-channel. For Python, use a search-space
restriction, fail a trial for a hard post-evaluation limit, or model the
violation as another objective with the understanding that this is not the
canonical feasibility-first constraint semantics. Python declaration parity is a
separate product follow-up.
What the parity gate does and does not cover here¶
/// Prints the `atune-parity/1` block, in its multi-objective form.
///
/// `best.value.<metric>` in place of `best.value`, **sorted here by metric
/// name** exactly as the parameters are sorted by parameter name: the block's
/// order is the printer's job, not the study's.
///
/// Floats print with `{:?}`, which is shortest-round-trip. An integer parameter
/// prints as an integer — `ParamValue::I64`'s `Display` is `5`, and Python's
/// `repr(5)` is the same, where a float-formatted `5.0` would not match.
fn print_parity_block(best: &FrozenTrial, values: &[f64]) {
println!("atune-parity/1");
println!("study: {STUDY}");
println!("seed: {SEED}");
println!("trials: {TRIALS}");
println!("sampler: {SAMPLER}");
println!("best.number: {}", best.number.get());
let mut metrics: Vec<(&str, f64)> = METRICS
.iter()
.copied()
.zip(values.iter().copied())
.collect();
metrics.sort_by_key(|(name, _)| *name);
for (name, value) in metrics {
println!("best.value.{name}: {value:?}");
}
let mut params: Vec<(&String, &ParamValue)> = best.params.iter().collect();
params.sort_by_key(|(name, _)| *name);
for (name, param) in params {
println!("best.param.{name}: {}", parity_value(*param));
}
}
/// One parameter value, spelled so that Python's `repr` of the same value is
/// byte-identical.
fn parity_value(value: ParamValue) -> String {
match value {
// `{}` on an `f64` prints `1` where Python's `repr` prints `1.0`; `{:?}`
// is the shortest-round-trip spelling the two languages share. An `I64`
// needs neither — `Display` already prints what `repr` does.
ParamValue::F64(v) => format!("{v:?}"),
other => other.to_string(),
}
}
def print_parity_block(best: atune.FrozenTrial, values: list[float]) -> None:
"""Prints the ``atune-parity/1`` block, in its multi-objective form.
``best.value.<metric>`` in place of ``best.value``, **sorted here by metric
name** exactly as the parameters are sorted by parameter name: the block's
order is the printer's job, not the study's.
Floats print with ``repr``, which is shortest-round-trip. An integer
parameter prints as an integer — ``repr(5)`` is ``5``, and the Rust arm's
``ParamValue::I64`` prints the same, where a float-formatted ``5.0`` would
not match.
"""
print("atune-parity/1")
print(f"study: {STUDY}")
print(f"seed: {SEED}")
print(f"trials: {TRIALS}")
print(f"sampler: {SAMPLER}")
print(f"best.number: {best.number}")
for name, value in sorted(zip(METRICS, values, strict=True)):
print(f"best.value.{name}: {value!r}")
params = best.params
for name in sorted(params):
print(f"best.param.{name}: {params[name]!r}")
A multi-objective parity block prints best.value.<metric> per objective, sorted
by metric name, so this pair compares ten fields — more than any other. But it
compares them for one trial: the front's lowest-error member, ties broken by
trial number.
That is an honest limitation rather than a hidden one. The front's shape is not compared, so a change that added or removed members while leaving the lowest-error one alone would pass the gate. The trial it does compare is downstream of the non-dominated sort and the crowding distance, so it is not a weak check — but if you are changing NSGA-II's selection, this gate is not what will catch you.
Python and Rust differ here¶
Study::hypervolume and nadir_point exist on the Rust Study and are not
exposed in the Python binding. The standard scalar summary of a multi-objective
run is therefore available in Rust only; from Python, present the front itself.
Next¶
- Search spaces — expressing a constraint as a distribution.
- Study and trial — why
best_trial()is anOption. - Samplers and schedulers — where
Nsga2sits among the samplers.