Migrate from Optuna¶
Goal: run the Optuna code you already have, and know the six places where it means something else.
Trial.report(value, step) takes Optuna's argument order, but raises
atune.Pruned itself when the scheduler stops a trial. Optuna's report
never raises TrialPruned. Let atune's exception propagate; a subsequent
should_prune check is unnecessary. The alias remains useful independently.
def objective(trial: atune.Trial) -> float:
"""Pruning with atune's raising report.
Both halves of the idiom work, and they are two spellings of one mechanism:
* ``trial.report(value, step)`` raises ``atune.Pruned`` **itself** when the
scheduler decides to stop the trial, so an objective that only reports is
already prunable;
* ``atune.TrialPruned`` **is** ``atune.Pruned`` — one class object under two
names — so ``if trial.should_prune(): raise atune.TrialPruned()`` propagates
the same sentinel by hand, and ``optimize`` records the trial **pruned**
rather than failed.
``should_prune()`` is Optuna's name for ``should_stop()``; both answer ``True``
for a *paused* trial too, which is why atune's own spelling is the wider one.
"""
x = trial.suggest_float("x", 0.0, 10.0)
for step in range(1, 4):
trial.report(x * step, step)
return x
def run_pruning() -> str:
"""Runs the objective above under a scheduler that actually prunes."""
study = atune.create_study(
direction="minimize",
# `pruner=` is accepted for `scheduler=`; `Median` is atune's name for
# Optuna's `MedianPruner`, and `n_min_trials=` is Optuna's own keyword.
pruner=atune.schedulers.Median(n_startup_trials=1, n_min_trials=1),
study_name="pruning", # Optuna's spelling of `name=`
seed=SEED,
)
study.optimize(objective, n_trials=TRIALS)
states = [trial.state for trial in study.trials]
require(
atune.TrialState.PRUNED in states,
f"nothing was pruned; states were {[state.value for state in states]}",
)
require(
atune.TrialState.FAIL not in states,
"a raised TrialPruned was recorded as a failure, not as a prune",
)
require(atune.TrialPruned is atune.Pruned, "TrialPruned is not Pruned")
# Exercise the alias independently: it is reached before a report can raise.
manual = atune.create_study()
live = manual.ask()
require(live.should_prune() == live.should_stop(), "should_prune alias differs")
manual.tell(live, state="pruned")
sentinel = atune.create_study()
def raise_sentinel(trial: atune.Trial) -> float:
raise atune.TrialPruned()
sentinel.optimize(raise_sentinel, n_trials=1)
require(sentinel.trials[0].state == "pruned", "explicit sentinel failed")
pruned = states.count(atune.TrialState.PRUNED)
return f"{pruned}/{len(states)} trials pruned by the Optuna idiom, none failed"
Everything on this page is that snippet's file,
examples/python/migrate.py,
which pytest examples/python runs in the documentation workflow (pushes to
main and pull requests). Most claims below are calls in
it and each refusal is caught there by type, so this page cannot drift from the
binding without a red gate. It is the source-level half of migration; the
storage-level half — moving a study you already have — is
Optuna interop.
Familiar names and the raising report¶
These Optuna spellings name the same argument, method or class object, and atune's name is the canonical one the reference and the error messages use. Passing both spellings of one argument raises rather than one of them silently winning.
| Optuna | atune | Note |
|---|---|---|
create_study(pruner=…) |
atune.create_study(scheduler=…) |
A scheduler is the superset: it can pause and fork as well as stop |
create_study(study_name=…) |
create_study(name=…) |
Accepted by atune.load_study too |
optuna.TrialPruned |
atune.Pruned |
atune.TrialPruned is atune.Pruned; an already-imported Optuna class is also recognized (optional example) |
create_study(directions=[…]) |
create_study(direction=[…]) |
Same meaning; passing both raises (surface example) |
trial.report(value, step) |
atune.Trial.report |
atune raises Pruned itself; Optuna only records (pruning example) |
trial.should_prune() |
atune.Trial.should_stop |
Identical, including answering True for a paused trial |
Six traps¶
report takes Optuna's order; Rust takes (step, value)¶
Trial.report(value, step) is Optuna's order, unchanged, so a migrated
objective keeps its argument order. atune also raises on a stop, as the first
table states. Rust's TrialCtx::report takes (step, value), because that is
what its own callers already write. Both forms appear side by side below.
The trap is a call ported from Rust. In Python, (step, value) with a float
value raises TypeError; when both arguments are integral — a reward total, a
token count — it type-checks and records the pair swapped, with no warning.
Read every ported report call once, by hand.
def run_report_order() -> str:
"""``Trial.report`` takes ``(value, step)`` — Optuna's order, unchanged.
A migrated objective needs no edit here. The difference that remains is
*between languages*, not between libraries: Rust's ``TrialCtx::report`` takes
``(step, value)``, because that is what its own callers already write. Both
forms are exercised by the paired Rust example next to this one.
The hazard is a call ported from Rust's ``(step, value)``. When both
arguments are integral — a reward total, a token count — it still
type-checks and records the pair swapped. Nothing in the call distinguishes
the two, so every ported ``report`` call has to be read once by a human.
"""
study = atune.create_study(direction="maximize", seed=SEED)
trial = study.ask()
trial.suggest_float("x", 0.0, 1.0)
trial.report(1.0, 2) # value 1.0 at step 2 — Optuna's spelling, verbatim
study.tell(trial, 1.0)
require(
study.trials[0].intermediate_values == {2: 1.0},
f"report(value, step) is no longer the order: {study.trials[0].intermediates}",
)
# A float *step* is the loud case: the Rust order with a float value.
trial = study.ask()
trial.suggest_float("x", 0.0, 1.0)
try:
trial.report(4, 0.93) # the Rust order, with a float step
except TypeError as error:
study.tell(trial, 0.0)
return f"report(step, value) with a float step raises: {error}"
raise AssertionError("report(4, 0.93) was accepted; the argument order changed")
One storage spec holds one study¶
Optuna's storage holds many studies keyed by name, so rerunning a script against
the same file resumes it. atune's spec holds exactly one study, so the same rerun
raises — a second study would renumber from zero and look like a resume until
you counted the trials. The reopen is load_study(spec), or
create_study(..., load_if_exists=True) for a script that both creates and
resumes. See Storage for the
rule and what else follows from it.
def run_one_study(path: str) -> str:
"""One storage spec holds exactly one study, and the resume is ``load_study``.
Optuna's storage holds many studies keyed by name, so rerunning a script
against the same file resumes. atune's spec holds **one** study, so the same
rerun is an error rather than a second study that renumbers from zero and looks
like a resume until the trials are counted. Two reopens are offered instead.
"""
study = atune.create_study(
direction="minimize", storage=path, name="one", seed=SEED
)
study.optimize(objective, n_trials=TRIALS)
del study
try:
atune.create_study(direction="minimize", storage=path, name="one")
except atune.AtuneError as error:
# The message names both reopens rather than only refusing.
require(
"load_study" in str(error) and "load_if_exists" in str(error),
f"the refusal does not name the reopen to use: {error}",
)
else:
raise AssertionError(f"a second create_study on {path!r} was accepted")
# Either reopen continues the trial numbering; neither takes a study id.
resumed = atune.load_study(path, study_name="one")
reopened = atune.create_study(
direction="minimize", storage=path, name="one", load_if_exists=True
)
for name, handle in (("load_study", resumed), ("load_if_exists", reopened)):
require(
len(handle.trials) == TRIALS,
f"{name} saw {len(handle.trials)} trials, not the {TRIALS} on disk",
)
# `name=` is a check, not a lookup: there is nothing to look up.
try:
atune.load_study(path, name="a-different-name")
except atune.AtuneError:
pass
else:
raise AssertionError("load_study accepted a name the storage does not hold")
return f"second create_study raised, both reopens saw {TRIALS} trials"
Determinism is the study seed¶
TPESampler(seed=7) is a line thousands of Optuna scripts contain, and here it
raises, naming the knob that works. Every per-trial sampler, scheduler and
objective seed is derived from create_study(seed=…), so a per-sampler seed has
nothing left to govern — and being accepted-and-ignored is exactly the outcome
Determinism exists to prevent.
def run_seed() -> str:
"""Determinism is ``create_study(seed=)``, and a sampler ``seed=`` raises.
``TPESampler(seed=7)`` is a line thousands of Optuna scripts contain. atune
derives every per-trial sampler, scheduler and objective seed from the study
seed, so a per-sampler seed has nothing left to govern — and it raises rather
than being accepted and ignored.
"""
try:
atune.samplers.Tpe(seed=SEED)
except atune.AtuneError as error:
refusal = str(error)
else:
raise AssertionError("Tpe(seed=...) was accepted, so it means nothing")
best = []
for _ in range(2):
study = atune.create_study(
direction="minimize",
sampler=atune.samplers.Tpe(n_startup_trials=2),
seed=SEED,
)
study.optimize(lambda trial: trial.suggest_float("x", -5.0, 5.0) ** 2, TRIALS)
best.append(study.best_params)
require(best[0] == best[1], f"the study seed did not reproduce: {best}")
return f"Tpe(seed=) raised, create_study(seed={SEED}) reproduced: {refusal}"
A categorical reads back as its own type¶
Model(**study.best_params) is Optuna's most-copied final line, so the type a
categorical has inside the objective and the type it has when read back must agree.
They do: each choice records a stable label and its exact
bool / int / finite-float / string payload, and no label is ever parsed. Two
consequences: a numeric-looking string stays a string, and a choice that is none of
those four scalars is refused at suggest_categorical time where Optuna accepts any
object and warns.
The one wrong case, stated plainly. A categorical recorded as labels only —
by atune import from an Optuna study or by the CLI's choice(a, b, c) space
DSL — reads back as str, because the source type was never written
down and cannot be recovered.
def run_categorical() -> str:
"""A categorical reads back as the type it was suggested with.
Optuna's most-copied final line is ``Model(**study.best_params)``, so the type
a categorical has *inside* the objective and the type it has when read back out
of storage had better be the same one. It is: each choice records a stable
label **and** its exact bool / int / finite-float / string payload, and the
read side resolves the payload rather than parsing the label.
Two consequences worth knowing before they surprise you. A numeric-looking
*string* stays a string, because no label is ever parsed. And a choice that is
none of those four scalars is refused at ``suggest_categorical`` time rather
than stringified — Optuna accepts any object and warns.
"""
study = atune.create_study(direction="minimize", seed=SEED)
def mixed(trial: atune.Trial) -> float:
units = trial.suggest_categorical("units", [16, 32, 64])
norm = trial.suggest_categorical("norm", [True, False])
opt = trial.suggest_categorical("opt", ["adam", "sgd"])
decay = trial.suggest_categorical("decay", [0.1, 0.5])
label = trial.suggest_categorical("label", ["32", "64"])
require(isinstance(label, str), "a string choice arrived as something else")
return float(units) + float(norm) + len(opt) + decay
study.optimize(mixed, n_trials=TRIALS)
expected = {"units": int, "norm": bool, "opt": str, "decay": float, "label": str}
params = study.best_params
require(params is not None, "the study has no best trial")
for name, kind in expected.items():
# `bool` before `int`: `isinstance(True, int)` is true in Python.
require(
type(params[name]) is kind,
f"best_params[{name!r}] is {type(params[name]).__name__}, not {kind.__name__}",
)
for choice in ((1, 2), None, float("nan")):
try:
atune.create_study(seed=SEED).optimize(
lambda trial: float(bool(trial.suggest_categorical("k", [choice, 1]))),
n_trials=1,
)
except atune.AtuneError:
continue
raise AssertionError(f"{choice!r} was accepted as a categorical choice")
return "best_params types: " + ", ".join(
f"{name}={type(params[name]).__name__}" for name in sorted(expected)
)
optimize's fourth argument is n_jobs¶
The positional order is Optuna's — objective, n_trials, timeout, n_jobs,
catch, callbacks — so pass n_jobs by keyword: optimize(objective, 100, 4)
sets a four-second timeout, not four workers. Two of those arguments differ in
meaning rather than in spelling:
callbacks=requiresn_jobs=1and raises otherwise. A callback runs on the thread that finished the trial and theStudyhandle is not thread-safe, so the combination is refused instead of failing inside your callback. If a callback raises, later callbacks for that trial are skipped, the run continues, and atune re-raises the first Python exception after the run; the already recorded trial state does not change.catch=only silences the re-raise. atune's default is already stronger than Optuna's fail-fast: the run always completes, every trial whose Python objective raises an exception is recordedfailed, and the first exception is re-raised afterwards.catch=drops that final raise; it cannot make a study end sooner or later.
gc_after_trial= and show_progress_bar= do not exist.
def run_loop() -> str:
"""``optimize``'s Optuna-shaped arguments, and the one that needs ``n_jobs=1``.
The positional order is Optuna's — ``(objective, n_trials, timeout, n_jobs,
catch, callbacks)`` — so a migrated call needs no edit, and ``n_jobs`` is the
*fourth* argument rather than the third. Pass it by keyword.
"""
seen: list[int] = []
study = atune.create_study(direction="minimize", seed=SEED)
def watch(current: atune.Study, trial: atune.FrozenTrial) -> None:
"""A callback with Optuna's signature and Optuna's call site."""
seen.append(trial.number)
if len(seen) == 2:
current.stop() # ends the run at the trial that decides it has seen enough
study.optimize(
lambda trial: trial.suggest_float("x", 0.0, 1.0),
n_trials=TRIALS,
n_jobs=1, # required by `callbacks=`, and keyword because it is 4th
catch=(RuntimeError,),
callbacks=[watch],
)
require(seen == [0, 1], f"callbacks saw {seen}, not every terminal trial in order")
require(
len(study.trials) == 2,
f"stop() left {len(study.trials)} trials, not the 2 it was called at",
)
# A callback runs on the thread that finished the trial and this handle is not
# thread-safe, so the combination is refused rather than failing inside your
# callback.
try:
atune.create_study(seed=SEED).optimize(
lambda trial: trial.suggest_float("x", 0.0, 1.0),
n_trials=2,
n_jobs=2,
callbacks=[watch],
)
except atune.AtuneError:
pass
else:
raise AssertionError("callbacks= with n_jobs=2 was accepted")
# `catch=` swallows the *re-raise*; the trial is recorded failed either way,
# and the run always finishes — atune's contract is already stronger than
# Optuna's fail-fast default.
failing = atune.create_study(direction="minimize", seed=SEED)
def boom(trial: atune.Trial) -> float:
raise RuntimeError("one bad configuration")
failing.optimize(boom, n_trials=2, catch=(RuntimeError,))
states = [trial.state for trial in failing.trials]
require(
states == [atune.TrialState.FAIL, atune.TrialState.FAIL],
f"caught failures were recorded as {[state.value for state in states]}",
)
return f"callbacks saw {seen}, stop() ended the run, catch= swallowed 2 failures"
A storage spec is a path, not a URL¶
sqlite:///study.db is Optuna's SQLAlchemy URL; atune takes the plain path
study.db. postgresql://… and mysql://… name a server atune has no backend
for. Both are refused with a message naming what to do instead — for the RDB case
that is Optuna interop, which moves the study rather than
serving it.
def run_storage_specs() -> str:
"""Optuna's storage URLs, and what each is answered with.
Python storage specs are filesystem paths, never SQLAlchemy URLs or
``atune://`` addresses. The remote locator is available through Rust and
the CLI only. Both SQLAlchemy refusals name the thing to do instead, because
"unable to open database file: sqlite:///study.db" is loud and teaches
nothing.
"""
answers = {}
for spec in ("sqlite:///study.db", "postgresql://localhost/optuna"):
try:
atune.create_study(storage=spec)
except atune.AtuneError as error:
answers[spec] = str(error)
else:
raise AssertionError(f"{spec!r} was accepted as a storage spec")
require(
"study.db" in answers["sqlite:///study.db"],
"the SQLite URL refusal does not name the plain path to use",
)
require(
"export" in answers["postgresql://localhost/optuna"],
"the RDB refusal does not point at the export path",
)
return " | ".join(answers[spec] for spec in sorted(answers))
Class names are not aliased¶
One name per concept, so the catalogue stays readable.
The samplers live in atune.samplers and the pruning/scheduling policies in
atune.schedulers (Optuna's optuna.pruners).
| Optuna | atune | Optuna | atune |
|---|---|---|---|
RandomSampler |
Random |
MedianPruner |
Median |
TPESampler |
Tpe |
PercentilePruner |
Percentile |
QMCSampler |
Qmc |
SuccessiveHalvingPruner |
Asha |
GridSampler |
Grid |
HyperbandPruner |
Hyperband |
CmaEsSampler |
CmaEs |
PatientPruner |
Patient |
NSGAIISampler |
Nsga2 |
WilcoxonPruner |
Wilcoxon |
NSGAIIISampler |
Nsga3 |
— | — |
GPSampler |
GpEi (own GP implementation) |
— | — |
Where a keyword means exactly what Optuna's means it is spelled Optuna's way —
sigma0, popsize, mutation_prob, population_size, n_startup_trials,
n_warmup_steps, n_min_trials, n_ei_candidates, Tpe(multivariate=),
CmaEs(use_separable_cma=, with_margin=, source_trials=), Qmc(qmc_type=, scramble=, seed=)
and Nsga3(dividing_parameter=). Tpe(multivariate=) is on by default here
(multivariate=False gives the univariate model); the other settings are off by
default, and a scrambled Qmc needs its own seed. Three places keep atune's
spelling or defaults on purpose, because the quantity is not the same one:
Wilcoxon(min_pairs=)counts paired observations, not Optuna'sn_startup_stepssteps;Nsga2(crossover_eta=, mutation_eta=)are keywords where Optuna configures SBX and polynomial mutation through operator objects;Asha(min_resource, max_resource, reduction_factor=3)requires both bounds positionally, where Optuna infersmin_resource="auto", and defaults the factor to 3 where Optuna defaults to 4. Passreduction_factor=4for Optuna's ladder.
def run_names() -> str:
"""Constructs every sampler and scheduler that has an Optuna counterpart.
Class names are **not** aliased — one name per concept — so this is the row
order of the migration page's name table, in code:
* ``Random`` / ``RandomSampler``, ``Tpe`` / ``TPESampler``,
``Qmc`` / ``QMCSampler``, ``Grid`` / ``GridSampler``,
``CmaEs`` / ``CmaEsSampler``, ``Nsga2`` / ``NSGAIISampler``,
``Nsga3`` / ``NSGAIIISampler``;
* ``Median`` / ``MedianPruner``, ``Percentile`` / ``PercentilePruner``,
``Asha`` / ``SuccessiveHalvingPruner``,
``Hyperband`` / ``HyperbandPruner``, ``Patient`` / ``PatientPruner``,
``Wilcoxon`` / ``WilcoxonPruner``.
Where a *keyword* means exactly what Optuna's means it is spelled Optuna's
way — ``sigma0``, ``popsize``, ``mutation_prob``, ``n_min_trials``,
``n_startup_trials``, ``n_warmup_steps``, ``n_ei_candidates``, TPE's
``multivariate``, CMA-ES's ``use_separable_cma`` and ``with_margin``, QMC's
``qmc_type``, ``scramble`` and ``seed``, NSGA-III's ``dividing_parameter`` —
and where the
quantity is different it keeps atune's name, because an identical spelling for
a different number is the one outcome worse than a rename.
"""
samplers = [
atune.samplers.Random(),
atune.samplers.Tpe(n_startup_trials=5, n_ei_candidates=24),
atune.samplers.Qmc(),
atune.samplers.Grid({"opt": ["adam", "sgd"]}),
atune.samplers.CmaEs(sigma0=0.2, popsize=8),
atune.samplers.Nsga2(population_size=8, mutation_prob=0.1),
atune.samplers.Nsga3(population_size=8, dividing_parameter=3),
atune.samplers.Dehb(1, 27), # no Optuna counterpart
atune.samplers.GpEi(n_startup_trials=2),
atune.samplers.Carbs(),
atune.samplers.Auto({"x": {"kind": "float", "low": 0, "high": 1}}, n_trials=20),
]
schedulers = [
atune.schedulers.Median(n_startup_trials=5, n_warmup_steps=0, n_min_trials=1),
atune.schedulers.Percentile(25.0, n_startup_trials=5),
# Both bounds are required, and the factor defaults to 3 where Optuna's
# `SuccessiveHalvingPruner` defaults to 4. Pass it for Optuna's ladder.
atune.schedulers.Asha(1, 27, reduction_factor=4),
atune.schedulers.Hyperband(1, 27, reduction_factor=3),
atune.schedulers.Patient(2, atune.schedulers.Median()),
# `min_pairs` is a count of paired observations, not Optuna's
# `n_startup_steps` step count, so the spelling stays atune's.
atune.schedulers.Wilcoxon(alpha=0.05, min_pairs=5),
atune.schedulers.Pbt(4), # no Optuna counterpart
]
# Optuna-spelled settings. TPE is multivariate by default, so its keyword
# here selects the univariate model; the others are off by default. A
# scrambled QMC sequence needs its own `seed`: an atune sampler does not see
# the study seed.
options = [
atune.samplers.Tpe(multivariate=False),
atune.samplers.CmaEs(use_separable_cma=True),
atune.samplers.CmaEs(with_margin=True),
atune.samplers.Qmc(qmc_type="halton", scramble=True, seed=0),
]
# Two keywords that are deliberately *not* Optuna's, checked as refusals so
# the page's "do not alias these" rows cannot quietly become aliases.
for factory, keyword in (
(atune.schedulers.Wilcoxon, "p_threshold"),
(atune.samplers.CmaEs, "initial_sigma"),
):
try:
factory(**{keyword: 0.1})
except TypeError:
continue
raise AssertionError(f"{factory.__name__}({keyword}=) is now accepted")
return (
f"{len(samplers)} samplers, {len(options)} sampler options and "
f"{len(schedulers)} schedulers constructed"
)
Reading the study back¶
study.trials is a property as Optuna's is, get_trials(states=…) takes the
atune.TrialState members or their lowercase strings,
intermediate_values is the same dict[int, float], and set_user_attr /
user_attrs work on both a study and a trial. A FrozenTrial is a value as
Optuna's is — two reads of one trial compare equal, hash alike, and work as a
dict key — and it has a short repr. Two differences:
study.param_importance() is a method where Optuna's is the free function
optuna.importance.get_param_importances(study), and it uses the original
PED-ANOVA unless you pass conditional=True (Optuna 5's default evaluator);
enqueue(params) is Optuna's enqueue_trial.
def run_read_model() -> str:
"""The accessors a migrated read-back path reaches for.
``study.trials`` is a property here as it is in Optuna, ``get_trials(states=)``
takes the ``TrialState`` members *or* their lowercase strings, and
``intermediate_values`` is the ``dict[int, float]`` Optuna's is. The one that
moved is importance: Optuna's free function
``optuna.importance.get_param_importances(study)`` is a **method** here,
``study.param_importance()``, returning the same most-important-first dict;
``conditional=True`` selects Optuna 5's conditional PED-ANOVA.
"""
study = atune.create_study(direction="minimize", name="read", seed=SEED)
study.set_user_attr("dataset", "cifar10/v3") # study-level, JSON-valued
def annotated(trial: atune.Trial) -> float:
trial.set_user_attr("fold", trial.number % 3) # trial-level
x = trial.suggest_float("x", -5.0, 5.0)
trial.report(x * x, 1)
return x * x
study.optimize(annotated, n_trials=TRIALS)
require(study.best_value is not None, "best_value is None after a complete run")
require(study.best_params is not None, "best_params is None after a complete run")
require(len(study.trials) == TRIALS, "study.trials is not the whole study")
complete = study.get_trials(states=[atune.TrialState.COMPLETE])
# Compared trial by trial: `FrozenTrial` has value equality as Optuna's does,
# so two reads of one trial are `==` rather than two distinct objects.
require(
complete == study.get_trials(states=["complete"]),
"a TrialState member and its string selected different trials",
)
require(
len({trial for trial in study.trials} | {trial for trial in study.trials})
== TRIALS,
"a FrozenTrial does not hash consistently with its equality",
)
best = study.best_trial
require(best is not None and best.intermediate_values, "no intermediate values")
require(best in study.trials, "best_trial is not a member of study.trials")
require(study.user_attrs["dataset"] == "cifar10/v3", "study user attrs lost")
require(
all("fold" in trial.user_attrs for trial in study.trials),
"trial user attrs lost",
)
require(
(study.name, study.direction) == ("read", atune.StudyDirection.MINIMIZE),
"a study cannot answer questions about itself",
)
importance = study.param_importance()
require(set(importance) == {"x"}, f"param_importance returned {importance}")
# Optuna 5's default evaluator is conditional PED-ANOVA; here it is opt-in.
conditional = study.param_importance(conditional=True)
require(set(conditional) == {"x"}, f"conditional importance returned {conditional}")
# NumPy arrays for numeric columns, lists for state and categorical ones;
# `pandas.DataFrame(...)` takes it as it is.
columns = sorted(study.trials_dataframe())
require("param_x" in columns, f"trials_dataframe has no param column: {columns}")
return (
f"{len(complete)} complete trials, columns {columns}, importance {importance}"
)
Constraints, history, and trial-count stopping¶
| Optuna | atune | Executable example |
|---|---|---|
trial.set_constraint(key, value) / constraints |
Same names; finite float-castable values, duplicate keys warn; all declarations share one key set. FrozenTrial.constraints stays a canonical list, ordered numeric keys first then lexical names |
surface example |
| Constraint-aware TPE / NSGA-II | Tpe(constraints=True); Nsga2 uses recorded constraints with a fixed create_study(search_space=…) |
surface example |
create_trial, study.add_trial(s) |
Terminal immutable history; atune distribution dicts, new destination numbers/timestamps. Pruned values derive from the last intermediate; failed values are absent. add_trials is sequential, not atomic |
surface example |
MaxTrialsCallback(n_trials, states=…) |
Same count semantics, including existing history; states=None counts all, default counts complete. Callbacks require n_jobs=1 |
surface example |
| GP sampler; automatic selection; cost-aware search | GpEi, Auto(search_space, n_trials=…, directions=…), Carbs. Declare create_study(search_space=…) for GP-EI, CMA-ES and population models; Auto carries its explicit space and selects once, and its opt-in gp_cost_budget= hands GP-EI's asks to TPE once a deterministic per-ask cost estimate exceeds it. Carbs requires performance and an explicitly returned positive minimize cost |
surface example |
| Optional analysis dependencies | NumPy loads only for trials_dataframe; install atune[dataframe]. Optuna loads when the snapshot adapter is called or your code imports it |
optional example |
| Optuna reading tools and plots | atune.optuna.to_optuna_study(study) returns an independent in-memory optuna.Study; install atune[optuna], plus Plotly for plots. Nothing written to the snapshot flows back |
snapshot example |
| Lightning / Stable-Baselines3 / single-file training callbacks | Report metrics through trial.report and let pruning leave the objective; CPU callback guide |
Lightning, Stable-Baselines3, single-file loop |
def run_python_surface() -> str:
"""Named constraints, terminal history, sampler factories, and stopping."""
space = {"x": {"kind": "float", "low": 0.0, "high": 1.0}}
study = atune.create_study(
sampler=atune.samplers.Tpe(n_startup_trials=2, constraints=True), seed=SEED
)
prior = atune.create_trial(
value=0.25,
params={"x": 0.5},
distributions=space,
constraints={"limit": -0.5},
)
require(prior.number == -1, "detached trial has a destination number")
study.add_trial(prior)
study.add_trials([atune.create_trial(state="failed")])
def constrained(trial: atune.Trial) -> float:
x = trial.suggest_float("x", 0.0, 1.0)
trial.set_constraint("limit", x - 0.8)
require(trial.constraints == {"limit": x - 0.8}, "named constraints lost")
return x * x
study.optimize(
constrained,
callbacks=[atune.MaxTrialsCallback(3, states=[atune.TrialState.COMPLETE])],
)
require(len(study.trials) == 4, "callback did not count prior complete history")
require(study.trials[0].constraints == [-0.5], "canonical constraints lost")
multi = atune.create_study(
directions=["minimize", "minimize"],
sampler=atune.samplers.Nsga2(population_size=2),
search_space=space,
seed=SEED,
)
def two_values(trial: atune.Trial) -> list[float]:
x = constrained(trial)
return [x, 1.0 - x]
multi.optimize(two_values, n_trials=3)
require(bool(multi.best_trials), "NSGA-II has no feasible front")
for factory in (
atune.samplers.GpEi,
lambda: atune.samplers.Auto(space, n_trials=TRIALS),
):
current = atune.create_study(sampler=factory(), search_space=space, seed=SEED)
current.optimize(lambda t: t.suggest_float("x", 0.0, 1.0), n_trials=3)
require(len(current.trials) == 3, "sampler factory failed to optimize")
cost = atune.create_study(
directions=["minimize", "minimize"],
sampler=atune.samplers.Carbs(),
search_space=space,
seed=SEED,
)
cost.optimize(lambda t: [t.suggest_float("x", 0.0, 1.0), 1.0], n_trials=3)
try:
atune.create_study(direction="minimize", directions=["minimize"])
except atune.AtuneError:
pass
else:
raise AssertionError("both direction spellings were accepted")
return (
"constraints, directions, GpEi/Auto/Carbs, history, MaxTrialsCallback checked"
)
def run_optional_dependencies() -> str:
"""Optimization loads neither NumPy nor Optuna; optional operations do."""
# A fresh process makes this check independent of other examples or tests
# that have already loaded an optional dependency.
subprocess.run(
[
sys.executable,
"-c",
"import atune, sys; s = atune.create_study(); "
"s.optimize(lambda t: t.suggest_float('x', 0, 1), n_trials=3); "
"assert 'numpy' not in sys.modules; assert 'optuna' not in sys.modules",
],
check=True,
)
study = atune.create_study(seed=SEED)
if importlib.util.find_spec("optuna") is None:
return "optimization did not import NumPy or Optuna; Optuna absent"
import optuna # explicitly imported by this example, never by atune
def prune(trial: atune.Trial) -> float:
raise optuna.TrialPruned()
study.optimize(prune, n_trials=1)
require(study.trials[-1].state == "pruned", "Optuna TrialPruned recorded failed")
from atune.optuna import to_optuna_study
study.optimize(lambda trial: trial.suggest_float("x", 0.0, 1.0), n_trials=3)
snapshot = to_optuna_study(study)
require(isinstance(snapshot, optuna.Study), "snapshot is not an Optuna Study")
require(len(snapshot.trials) == len(study.trials), "snapshot lost trials")
require(snapshot.trials[0].state.name == "PRUNED", "snapshot lost pruning")
snapshot.set_user_attr("copy_only", True)
require("copy_only" not in study.user_attrs, "snapshot wrote back")
return "optimization imported neither dependency; Optuna sentinel and isolated snapshot checked"
What atune does not have¶
| Optuna | Here instead |
|---|---|
BruteForceSampler, PartialFixedSampler |
Nothing equivalent. The Python samplers and Rust-only schedulers are in the catalogue |
ThresholdPruner, a named NopPruner |
Nothing equivalent; omitting scheduler= is the no-op |
optuna.logging.set_verbosity |
Nothing. The library emits tracing events; the binaries choose what to print |
RDB-server storage (postgresql://, mysql://) |
A journal or SQLite file, moved with atune export; or atune serve for many machines |
delete_study, copy_study, get_all_study_names, get_all_study_summaries |
Nothing to enumerate: one spec, one study. Delete the file |
Mutating a FrozenTrial |
Read-only; construct terminal history with create_trial instead (surface example) |
suggest_uniform, suggest_loguniform, suggest_discrete_uniform |
Deliberately not carried over: suggest_float(log=True) and suggest_float(step=…) |
What you gain by moving¶
The capabilities with no Optuna counterpart (the multi-seed protocol with held-out re-evaluation, pause-and-fork schedulers, open search spaces, and the determinism contract) are in Why atune.
Next¶
- Optuna interop — move the study itself, in either direction.
- Python API — every signature, generated from the module.
- Prune and schedule — the schedulers, and the one curve mistake that silently ruins a pruning setup.