Migrate from Optuna¶
Goal: run the Optuna code you already have, and know the six places where it means something else.
Start with the reassuring part, because it is the true one: pruning propagates by
exception in both frameworks, and the idiom transfers unchanged.
atune.Trial.report raises atune.Pruned itself, atune.TrialPruned is
that same class object under Optuna's name, and a sentinel you raise yourself is
recorded as a pruned trial rather than a failed one. So
if trial.should_prune(): raise atune.TrialPruned() — Optuna's exact line — works
here, as does an objective that only reports and lets the exception fly.
def objective(trial: atune.Trial) -> float:
"""Optuna's pruning objective, unchanged.
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)
if trial.should_prune():
raise atune.TrialPruned()
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")
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 on every push. Each claim below is a call 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.
Four names accepted as they stand¶
The four mechanical edits a migrated script most often needs are not needed. Each Optuna spelling is the same argument, method or class object, not a second one, 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 — one object, so except either catches both |
trial.should_prune() |
atune.Trial.should_stop |
Identical, including answering True for a paused trial |
Six traps¶
report takes Optuna's order — but atune's own older code does not¶
Trial.report(value, step) is Optuna's order, unchanged, so a migrated
objective needs no edit here at all. The difference that remains is between
languages: 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 now points at atune's own earlier Python code, which took
(step, value). With a float value such a call now raises TypeError, which is
how most of it will be found. When both arguments are integral — a reward
total, a token count, a count of correct predictions — the old call still
type-checks and records the pair swapped, with no warning and a study that
looks fine. Nothing in the call can distinguish the two orders, so this is not
something the binding can detect for you: read every pre-flip report call
once, by hand.
def run_report_order() -> str:
"""``Trial.report`` takes ``(value, step)`` — Optuna's order, unchanged (D47).
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 migration hazard now points the other way, at code written against
atune's own older ``report(step, value)``. When both arguments are integral —
a reward total, a token count, a number of correct predictions — the old call
still type-checks and records the pair swapped. Nothing in the call
distinguishes the two, so this is undetectable by the binding: every
pre-flip ``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 now, and it is the old order's shape.
trial = study.ask()
trial.suggest_float("x", 0.0, 1.0)
try:
trial.report(4, 0.93) # the pre-flip 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, by the CLI's choice(a, b, c) space DSL, or
by an older atune — 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. Three 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.catch=only silences the re-raise. atune's default is already stronger than Optuna's fail-fast: the run always completes, every raising trial is recordedfailed, and the first exception is re-raised afterwards.catch=drops that final raise; it cannot make a study end sooner or later.timeout=starts no new trial after the deadline; a trial in flight always finishes and is recorded.gc_after_trial=andshow_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 |
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. 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``;
* ``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`` — 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.Dehb(1, 27), # no Optuna counterpart
]
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
]
# 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 and {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. Three differences:
study.param_importance() is a method where Optuna's is the free function
optuna.importance.get_param_importances(study); enqueue(params) is Optuna's
enqueue_trial; and atune.Study.trials_dataframe returns one numpy
column per field rather than a pandas.DataFrame, because atune does not depend on
pandas — pandas.DataFrame(study.trials_dataframe()) is the whole conversion.
Inspect a study covers the read
path properly.
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.
"""
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}")
# One numpy column per field, which `pandas.DataFrame(...)` takes 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}"
)
What atune does not have¶
| Optuna | Here instead |
|---|---|
GPSampler |
A Gaussian-process sampler (GpEi) exists in Rust (facade, behind the gp feature) but is not exposed to Python; from Python, Tpe is the model-based answer |
BruteForceSampler, NSGAIIISampler, PartialFixedSampler |
Nothing equivalent. The seven Python samplers, and the two Rust-only ones, are in the catalogue |
ThresholdPruner, a named NopPruner |
Nothing equivalent; omitting scheduler= is the no-op |
optuna.visualization |
atune report, atune top and the GUI — from outside Python, over the study file |
optuna.integration |
Nothing. A callback and atune.Trial's own accessors are the seam |
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 |
Constructing or mutating a FrozenTrial |
Read-only, and there is no constructor: a snapshot comes out of study.trials or study.best_trial and nothing else makes one |
suggest_uniform, suggest_loguniform, suggest_discrete_uniform |
Deliberately not carried over: suggest_float(log=True) and suggest_float(step=…) |
What you gain by moving¶
These have no Optuna counterpart and no Optuna name to match, and they are why the
project exists: the multi-seed CRN protocol, whose
atune.Study.reevaluate re-runs the best configurations on held-out
seeds (multi-seed RL protocol); schedulers that
pause and fork rather than only stopping, with the lineage recorded
(population-based training);
open search spaces — trial.suggest_open_float(...) declares a bound as a
guess and the range grows during the study when the evidence crowds it
(open search spaces); and a
determinism contract that is asserted across both
language bindings on every push.
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.