Skip to content

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= requires n_jobs=1 and raises otherwise. A callback runs on the thread that finished the trial and the Study handle 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 recorded failed, 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's n_startup_steps steps;
  • 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 infers min_resource="auto", and defaults the factor to 3 where Optuna defaults to 4. Pass reduction_factor=4 for 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.