atune¶
Rust-native hyperparameter optimization, built for reinforcement learning.
atune searches for the configuration that makes a program do better. You describe the knobs — a learning rate, a batch size, a network width — and atune proposes values, watches the results, and spends the remaining budget where the results are best. It runs as a Rust library, as a Python package, and as a command-line tool that can tune a program it knows nothing about.
Nothing is released yet
atune is under construction, no version has been published, and no API is stable.
What it is¶
- A library first. The objective is a function in your own process — no
subprocess, no interpreter in the loop. Trials run on real threads, and the
core compiles to
wasm32. - Reproducible by construction. With a stateless sampler the parameters of trial n are a pure function of the study seed, n and the search space, so changing the thread count changes the wall clock and nothing else. That is a contract, asserted by tests, not a hope.
- Built around the methods RL needs. Multi-fidelity search (DEHB) and population-based training (PBT), the pruners that stop a losing run early (median, ASHA, Hyperband, Wilcoxon), and multi-seed evaluation protocols — because a single seed does not settle whether one configuration beats another.
- A search space that can admit it was wrong. A range declared
open grows while the study
runs when the best trials crowd its edge — bounded, journaled, and printed
as it happens — and
atune doctorreports the same evidence as advice when you would rather move the bound yourself. - Interoperable. A Python package via PyO3, Optuna journal import and export so existing dashboards keep working, and a CLI that tunes any program that reads environment variables and prints a number.
A 60-second quickstart¶
1. Install¶
For Rust, cargo add atune. For Python, pip install atune. For the
command-line tool, cargo install atune_cli, which installs a binary called
atune. Neither package index has anything to serve yet — see
Install for how to build from a clone in the meantime.
2. Minimise the Rastrigin function¶
Rastrigin is the standard multimodal test problem: a bowl with a cosine ripple
laid over it, minimised at the origin, and littered with local minima that a
hill climber falls into. Below is the same problem in each language. Neither
listing was typed into this page: both are included, whole and unedited, from
examples/ in the
repository, so what you read here is the file you would run.
//! Tunes the 2-D Rastrigin function — the M1 exit criterion, as a program.
//!
//! Rastrigin is the standard multimodal benchmark: a quadratic bowl with a
//! cosine ripple laid over it, minimised at the origin with `f(0, 0) = 0` and
//! littered with local minima that a hill climber falls into. Random search
//! does not solve it; it does get close enough to show that the loop works.
//!
//! Run it with:
//!
//! ```text
//! cargo run -p atune --example rastrigin
//! ```
//!
//! The seed is fixed, so the printed best trial is the same on every run and
//! on any number of threads: for the stateless samplers the parameters of
//! trial number *n* are a pure function of `(study seed, n, space)`.
//!
//! # The parity block
//!
//! `examples/python/rastrigin.py` is this same program in Python, and both
//! versions end by printing the same `atune-parity/1` block — the trial number,
//! the objective value and every parameter, at full precision.
//! `cargo dev check-parity` runs both and compares the two blocks field by
//! field, floats **bit for bit**. "The same seed gives the same answer in both
//! languages" is therefore a test, not a claim in a README.
//!
//! Two things about the arithmetic follow from that, and neither is obvious
//! from reading the objective:
//!
//! - **No fused multiply-add.** `x.mul_add(x, c)` is the spelling a Rust author
//! reaches for, and it is a *different computation* from `x * x + c`: the
//! fused instruction rounds once where the separate multiply and add round
//! twice. Python has no `math.fma` before 3.13, so the Python arm cannot
//! spell the fused form at all. The two spellings happen to agree at these
//! inputs — that is a property of these numbers, not of the expression, so
//! this example does not lean on it.
//! - **The sum is grouped left to right in both arms**, `(acc + 10.0) + …`.
//! Floating-point addition is not associative, so the grouping is part of the
//! answer and not a matter of taste. In Rust that is what `fold` does; the
//! Python arm spells the same accumulation out rather than using `+=`, which
//! would group the other way.
//!
//! `cos` is still the platform's libm, which is not required to be correctly
//! rounded, so the two arms are compared **on one machine**, and the committed
//! Rust golden names the platform it was blessed on.
use atune::prelude::*;
/// The study's name, which is also the parity block's `study` field.
const STUDY: &str = "rastrigin";
/// How many configurations to evaluate.
const TRIALS: u64 = 256;
/// How many of them to evaluate at a time.
const THREADS: usize = 4;
/// The seed that makes this run reproducible.
const SEED: u64 = 42;
/// The sampler this study runs under, for the parity block.
///
/// Written out rather than queried: building the study *without* `.sampler(…)`
/// is what selects the default `Random`, and the `Sampler` trait exposes no name
/// for a printer to ask for.
const SAMPLER: &str = "Random";
/// 2π — the same `f64` the Python arm gets from `math.tau`.
const TAU: f64 = std::f64::consts::TAU;
/// 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()))
}
/// 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(),
}
}
fn main() -> Result<()> {
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())
})?;
let best = study
.best_trial()?
.ok_or_else(|| Error::NotFound("no trial completed".to_owned()))?;
let value = best
.single_objective_value()
.ok_or_else(|| Error::NotFound("the best trial has no objective value".to_owned()))?;
println!("{TRIALS} trials on {THREADS} threads, seed {SEED} (minimum: 0 at x = y = 0)");
println!("best trial #{}: f = {value:.6}", best.number.get());
for (name, param) in &best.params {
println!(" {name} = {param}");
}
// Last, always: the comparator starts at the header line and ignores
// everything a reader-friendly example prints above it.
print_parity_block(&best, value);
Ok(())
}
"""Tune the 2-D Rastrigin function with atune.
Rastrigin is the standard multimodal benchmark: a quadratic bowl with a cosine
ripple laid over it, minimised at the origin with ``f(0, 0) = 0`` and littered
with local minima that a hill climber falls into. Random search does not solve
it; it does get close enough to show that the loop works.
Build and install the wheel first, e.g. from ``crates/atune_py``::
maturin develop --release
then run::
python examples/python/rastrigin.py
The seed is fixed, so the printed best trial is the same on every run and on any
number of workers: for the stateless samplers the parameters of trial number *n*
are a pure function of ``(study seed, n, space)``.
The parity block
----------------
``examples/rust/rastrigin.rs`` is this same program in Rust, and both versions
end by printing the same ``atune-parity/1`` block — the trial number, the
objective value and every parameter, at full precision. ``cargo dev
check-parity`` runs both and compares the two blocks field by field, floats
**bit for bit**. "The same seed gives the same answer in both languages" is
therefore a test, not a claim in a README.
Two things about the arithmetic follow from that, and neither is obvious from
reading the objective:
* **No fused multiply-add.** ``x.mul_add(x, c)`` is the spelling a Rust author
reaches for, and it is a *different computation* from ``x * x + c``: the fused
instruction rounds once where the separate multiply and add round twice.
Python has no ``math.fma`` before 3.13, so this arm could not spell the fused
form at all — the Rust arm therefore spells the unfused one. The two happen to
agree at these inputs, but that is a property of these numbers rather than of
the expression, so neither arm leans on it.
* **The sum is grouped left to right in both arms**, ``(total + 10.0) + …``.
Floating-point addition is not associative, so the grouping is part of the
answer and not a matter of taste. That is why the loop below assigns instead
of using ``+=``, which would group the other way.
``cos`` is still the platform's libm, which is not required to be correctly
rounded, so the two arms are compared **on one machine**.
"""
import math
from collections.abc import Sequence
import atune
#: The study's name, which is also the parity block's ``study`` field.
STUDY = "rastrigin"
#: How many configurations to evaluate.
TRIALS = 256
#: How many of them to evaluate at a time.
JOBS = 4
#: The seed that makes this run reproducible.
SEED = 42
#: The sampler this study runs under, for the parity block.
#:
#: Written out rather than queried: creating the study *without* a ``sampler``
#: argument is what selects the default ``Random``, and a sampler handle exposes
#: no name for a printer to ask for.
SAMPLER = "Random"
#: 2π — the same ``float`` the Rust arm gets from ``std::f64::consts::TAU``.
TAU = math.tau
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
def objective(trial: atune.Trial) -> float:
"""Evaluates one configuration."""
x = trial.suggest_float("x", -5.12, 5.12)
y = trial.suggest_float("y", -5.12, 5.12)
return rastrigin([x, y])
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}")
def main() -> None:
"""Runs the study and prints its result."""
study = atune.create_study(
direction="minimize",
seed=SEED,
name=STUDY,
)
study.optimize(objective, n_trials=TRIALS, n_jobs=JOBS)
best = study.best_trial
assert best is not None, "a completed study has a best trial"
value = best.value
assert value is not None, "a single-objective best trial has a value"
print(f"{TRIALS} trials on {JOBS} workers, seed {SEED} (minimum: 0 at x = y = 0)")
print(f"best trial #{best.number}: f = {value:.6f}")
for name, param in sorted(best.params.items()):
print(f" {name} = {param}")
# Last, always: the comparator starts at the header line and ignores
# everything a reader-friendly example prints above it.
print_parity_block(best, value)
if __name__ == "__main__":
main()
The two arms are each idiomatic rather than line-for-line identical, but they run the same study: 256 trials with the default sampler, in both languages — and a parity gate compares their results float for float.
3. Run it¶
From a clone, cargo run -p atune --example rastrigin for the Rust arm, and
python examples/python/rastrigin.py for the Python one after
maturin develop --release -m crates/atune_py/Cargo.toml. Each prints the best
trial it found, its objective value, and the parameters that produced it. The
Rust arm prints the same trial number every time, on any number of threads.
Where to go next¶
The site is organised by what you are trying to do, not by module.
| If you want to… | Go to |
|---|---|
| Learn atune from nothing, in order | Tutorial — install, a first study, then a real RL agent |
| Get one specific job done | How-to — self-contained recipes, in any order |
| Understand what a study, a sampler or a seed is | Concepts — the shared vocabulary, language-neutral |
| Plug in your own sampler, scheduler or storage | Extending — the traits, and how to publish one as a crate |
| Look up a flag, a feature or an error | Reference — generated from the code, so it cannot describe something that no longer exists |
| Know why atune is built the way it is | Explanation — the reasoning, the evidence and the measurements |
| Read the API, symbol by symbol | Python API and Rust API |
| Watch a study run without installing anything | the live demo — Random, TPE and CMA-ES minimizing the Rastrigin function in your browser, one seeded trial at a time |
| Look at a study without installing anything | the study viewer — the atune_gui views compiled to WebAssembly, over bundled deterministic fixture studies |
| See how the samplers measure up | the benchmark page — the kurobako suite's own interactive output, and what it does and does not measure |
The live demo runs its studies in memory in the page. The browser viewer renders bundled deterministic fixture studies; neither can open a caller's storage. The native viewer accepts an explicit storage spec, reports an explicit load failure instead of silently switching to demo data, and remains read-only.
Licence¶
Dual-licensed under Apache-2.0 or MIT, at your option.