Skip to content

Your first study

This page runs one optimisation end to end and explains every part of it: the function being minimised, the search space, the budget, the loop, and how to read what comes out. It is the same program in Rust and in Python, and the two give the same answer.

You need a clone and a working toolchain — Install covers both. Nothing here writes a file or opens a socket.

The problem

The objective is the 2-D Rastrigin function, the standard multimodal test problem: a quadratic bowl with a cosine ripple laid over it. Its minimum is 0 at the origin, and it is surrounded by local minima that a hill climber falls into and never leaves. It is a good first objective because the search does real work while each evaluation remains a small, self-contained numerical calculation. The example uses a fixed budget of 256 evaluations; its wall-clock time depends on the machine and build profile.

Here it is, taken unedited from the example file — every code block on this site is a region of a program that CI compiles and runs, so a listing here cannot have drifted from a listing that works:

/// The Rastrigin function, `f(x) = 10n + Σ xᵢ² − 10·cos(2π·xᵢ)`.
///
/// The `10n` term is folded in per dimension, which avoids counting the
/// dimensions through a lossy `usize`-to-`f64` cast. `x * x` is written out
/// rather than fused into `x.mul_add(x, …)` so that the Python arm computes the
/// identical expression (see the module header).
fn rastrigin(xs: &[f64]) -> f64 {
    xs.iter()
        .fold(0.0, |acc, &x| acc + 10.0 + (x * x - 10.0 * (TAU * x).cos()))
}
def rastrigin(xs: Sequence[float]) -> float:
    """The Rastrigin function, ``f(x) = 10n + Σ xᵢ² − 10·cos(2π·xᵢ)``.

    The ``10n`` term is folded in per dimension, and the accumulation is spelled
    out rather than written ``total += …`` so that it groups exactly as the Rust
    arm's ``fold`` does (see the module docstring).
    """
    total = 0.0
    for x in xs:
        total = total + 10.0 + (x * x - 10.0 * math.cos(TAU * x))
    return total

Two details in that function are not stylistic. The 10n term is folded in per dimension rather than multiplied at the end, and the accumulation is spelled out instead of using += — because floating-point addition is not associative, so the grouping is part of the answer. x * x is written out rather than fused into mul_add for the same reason. Determinism explains why an example goes to that trouble.

The study

A study is one optimisation: a name, a seed, a direction, a budget, and the loop that fills it with trials. A trial is one evaluation of one configuration. That is the whole vocabulary you need for this page; Studies and trials has the rest.

let study = Study::builder()
    .parallelism(THREADS)
    .budget(Budget::trials(TRIALS))
    .create(StudyConfig::new(STUDY).with_seed(SEED))?;

study.optimize(|ctx| {
    let x = ctx.suggest_f64("x", -5.12..=5.12, Scale::Linear)?;
    let y = ctx.suggest_f64("y", -5.12..=5.12, Scale::Linear)?;
    Ok(rastrigin(&[x, y]).into())
})?;
study = atune.create_study(
    direction="minimize",
    seed=SEED,
    name=STUDY,
)
study.optimize(objective, n_trials=TRIALS, n_jobs=JOBS)

Reading it piece by piece:

  • The direction. Both arms minimise. Rust minimises by default; Python is told direction="minimize" explicitly because the argument is optional and a reader should not have to know the default.
  • The seed. with_seed(42) / seed=42. This is the study seed, and it is the only seed either program sets. Every random choice the run makes descends from it.
  • The budget. 256 trials. In Rust a budget is a property of the study — Budget::trials(256) — because a study may also be bounded by wall clock or by total fidelity; in Python it is n_trials, passed to the loop, alongside optimize(timeout=…) for the wall-clock bound and Study.stop() for the decision to end a run from inside it.
  • The parallelism. Four workers. This changes how long the run takes and nothing else: with the default sampler the parameters of trial n do not depend on how many threads are running.
  • The search space. Neither program declares one up front. suggest_f64 / suggest_float is the declaration — the first call for a name creates that parameter's distribution and samples it, and later calls in the same trial replay the stored value. That is called define-by-run, and Search spaces covers it along with the declared alternative.

Note the shape difference in how a float range is given: Rust takes an inclusive range and an explicit Scale, Python takes low and high with log=False as the default. They describe the same distribution.

Run it

cargo run -p atune --example rastrigin for the Rust arm, and python examples/python/rastrigin.py for the Python one once maturin develop --release -m crates/atune_py/Cargo.toml has built the wheel.

Both print a short summary and then a machine-readable block. The summary is this:

Line Value
Trials, threads, seed 256 trials on 4 threads, seed 42
Best trial #151, f = 1.865055
Its parameters x = 1.0340826850426426, y = -0.05372798507453446

Run it again and you get trial 151 again. Run it on one thread, or on sixteen, and you still get trial 151. That is the point of the seed, and it is the one property of atune this tutorial would most like you to notice.

The value is not 0, and it should not be: 256 random configurations of a 2-D multimodal function land near a good local minimum, not on the global one. Random search is the default sampler because it is the honest baseline — a different sampler is the first thing to change when you want a better number for the same budget.

Reading the result

Both programs ask the study for its best trial and then take the value and the parameters off it:

  • Rust: study.best_trial()? returns Option<FrozenTrial> — None when no trial has completed. single_objective_value() returns Some(v) only for a study with exactly one objective, which keeps a multi-objective study from silently reporting its first metric as "the" value.
  • Python: study.best_trial is a property returning FrozenTrial | None, and best.value / best.params are the value and the parameter dictionary.

A FrozenTrial is an immutable snapshot: its number, its state, its parameters, the distributions they were drawn from, its objective values, and any intermediate values it reported along the way. It is what every reporting API in atune hands back.

The same answer, in both languages

Each program ends by printing a block that a test reads:

/// Prints the `atune-parity/1` block: the last thing this program writes, and
/// the only part `cargo dev check-parity` reads.
///
/// One `key: value` per line, one space after the colon, the fixed keys first
/// and the `best.param.*` lines last — **sorted here, by name**, rather than
/// left to the iteration order of whatever map holds the parameters. The
/// block's contract is the printer's job.
///
/// Floats print with `{:?}`, which is shortest-round-trip: the text parses back
/// to the same bits on the other side. A precision-limited format like `{:.6}`
/// would throw the comparison away, which is why the human-readable summary
/// above the block is *not* what the gate reads.
fn print_parity_block(best: &FrozenTrial, value: f64) {
    println!("atune-parity/1");
    println!("study: {STUDY}");
    println!("seed: {SEED}");
    println!("trials: {TRIALS}");
    println!("sampler: {SAMPLER}");
    println!("best.number: {}", best.number.get());
    println!("best.value: {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`, and
        // both are shortest-round-trip; `{:?}` is the spelling the two
        // languages share. This example's space is float-only, so the other
        // kinds are here for completeness rather than exercised by the gate.
        ParamValue::F64(v) => format!("{v:?}"),
        other => other.to_string(),
    }
}
def print_parity_block(best: atune.FrozenTrial, value: float) -> None:
    """Prints the ``atune-parity/1`` block.

    The last thing this program writes, and the only part ``cargo dev
    check-parity`` reads. One ``key: value`` per line, one space after the colon,
    the fixed keys first and the ``best.param.*`` lines last — **sorted here, by
    name**, rather than left to the iteration order of whatever map holds the
    parameters. The block's contract is the printer's job.

    Floats print with ``repr``, which is shortest-round-trip: the text parses
    back to the same bits on the other side. A precision-limited format like
    ``:.6f`` would throw the comparison away, which is why the human-readable
    summary above the block is *not* what the gate reads.
    """
    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}")
    print(f"best.value: {value!r}")

    params = best.params
    for name in sorted(params):
        print(f"best.param.{name}: {params[name]!r}")

cargo dev check-parity runs both arms and compares those blocks field by field — strings byte-equal, floats bit-equal after parsing. For this pair that means best.value: 1.8650553405295156 on both sides, not to six decimals but to the last bit of the mantissa.

That is why the printer sorts its keys and formats floats with Rust's {:?} and Python's repr rather than with a fixed precision: both are shortest-round-trip, so the printed text recovers the exact bits. The human-readable f = 1.865055 above the block is rounded and is not what the test reads.

What to change next

The example is a starting point; each of these is a one-line edit.

To Change
Spend a bigger budget TRIALS — the number is a constant in both arms
Search smarter than random Pass a sampler: Tpe, Qmc, Dehb, and others in the catalog
Add a parameter Add a suggest_* call; the space grows with it, no declaration to update
Search a learning rate Use a logarithmic scale — Scale::Log in Rust, log=True in Python
Keep the study after the process exits Give it a storage path; see Storage
Stop hopeless trials early Add a scheduler; see Prune and schedule

Where to go next

If you want to Go to
Tune something that behaves like a real workload Tuning an RL agent — the next page, and the last of the tutorial
Understand the objects you just used Studies and trials
Understand the parameter you just suggested Search spaces
Understand why trial 151 is trial 151 Determinism
Look up a method rather than read prose Python API, Rust API