Skip to content

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= 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.
  • 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 recorded failed, 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= 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

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'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``;
    * ``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.