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 isn_trials, passed to the loop, alongsideoptimize(timeout=…)for the wall-clock bound andStudy.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_floatis 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()?returnsOption<FrozenTrial>—Nonewhen no trial has completed.single_objective_value()returnsSome(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_trialis a property returningFrozenTrial | None, andbest.value/best.paramsare 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 |