Skip to content

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