Skip to content

fix(optimizers): build a pruner instance instead of passing the schema string - #823

Closed
Maarmapa wants to merge 1 commit into
DashAISoftware:developfrom
Maarmapa:claude/fix-optuna-pruner
Closed

fix(optimizers): build a pruner instance instead of passing the schema string#823
Maarmapa wants to merge 1 commit into
DashAISoftware:developfrom
Maarmapa:claude/fix-optuna-pruner

Conversation

@Maarmapa

Copy link
Copy Markdown
Contributor

Closes #821

The problem

OptunaSchema declares pruner as an enum of strings, and optimize() forwarded that string straight to optuna.create_study(). The sampler on the line above is resolved and instantiated; the pruner was not.

create_study() does not validate the argument, so nothing fails at study creation. Checked against Optuna 4.9.0:

>>> study = optuna.create_study(direction="maximize", pruner="MedianPruner")
>>> study.pruner
'MedianPruner'

Two consequences follow:

  1. Today the setting is inert. objective() never calls trial.report() or trial.should_prune(), so the pruner is never consulted — selecting MedianPruner and selecting None produce identical runs.
  2. It breaks as soon as pruning is used. should_prune() calls self.study.pruner.prune(...), which raises AttributeError: 'str' object has no attribute 'prune'.

The change

_build_pruner() resolves the name the same way the sampler is already resolved.

"None" — the string the schema sends when pruning is disabled — maps to NopPruner rather than to Python's None. A bare None makes Optuna fall back to its own default (MedianPruner), which is not what the user picked. Happy to switch to None if you prefer that behaviour.

Pruners that cannot be built without arguments (PatientPruner, PercentilePruner, ThresholdPruner) raise an error naming the ones that can. Exposing them properly means adding their configuration to the schema first, which felt out of scope here.

Tests

tests/back/optimizers/test_optuna_pruner.py — 10 cases covering instance resolution, the "None" mapping, the unknown-name error and the needs-configuration error. ruff check and ruff format --check pass.

Out of scope

Whether objective() should report intermediate values so pruning can actually take effect is a separate decision — it needs a meaningful per-step metric, which not every model exposes. Left out on purpose.

…a string

`OptunaSchema` declares `pruner` as an enum of strings, and `optimize()`
forwarded that string straight to `optuna.create_study()`. The sampler right
above it is resolved and instantiated; the pruner was not.

`create_study()` does not validate the argument, so nothing fails at study
creation — the study is simply built with `pruner` set to a `str`. Checked
against Optuna 4.9.0:

    >>> study = optuna.create_study(direction="maximize", pruner="MedianPruner")
    >>> study.pruner
    'MedianPruner'

Two consequences follow. Today the setting is inert: `objective()` never calls
`trial.report()` or `trial.should_prune()`, so the pruner is never consulted and
selecting "MedianPruner" or "None" produces identical runs. And it breaks as
soon as pruning is wired up, since `should_prune()` calls
`self.study.pruner.prune(...)` and raises
`AttributeError: 'str' object has no attribute 'prune'`.

`_build_pruner()` resolves the name the same way the sampler is resolved.
"None" — the string the schema sends when pruning is disabled — maps to
`NopPruner` rather than to Python's `None`, because a bare `None` makes Optuna
fall back to its own default (`MedianPruner`), which is not what the user
picked. Pruners that cannot be built without arguments (`PatientPruner`,
`PercentilePruner`, `ThresholdPruner`) raise a message naming the ones that can;
exposing them means adding their configuration to the schema first.

Adds `tests/back/optimizers/test_optuna_pruner.py` — 10 cases covering instance
resolution, the "None" mapping, the unknown-name error and the
needs-configuration error.

Whether `objective()` should report intermediate values so pruning can actually
take effect is a separate decision: it needs a meaningful per-step metric, which
not every model exposes. That is deliberately left out of this change.
@Irozuku
Irozuku self-requested a review August 18, 2026 11:12
@Irozuku

Irozuku commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

This approach doesn't seem to address the main issue, since the pruner still won't work. Considering that most of the models currently available don't use epoch/step based training, removing the pruner as a parameter seems more reasonable for now. Feel free to try to adapt the optimizer so that it actually works with models such as MLP/CNN/Transformer based models, which can actually benefit from early stopping per trial.

@Irozuku Irozuku closed this Aug 18, 2026
Maarmapa pushed a commit to Maarmapa/dashAI that referenced this pull request Aug 18, 2026
Follow-up to the review on DashAISoftware#823. Resolving the pruner into an instance was
necessary but not sufficient: a pruner only ever acts if the trial is told how
it is doing while it still runs, and nothing was telling it.

The deeper reason it could not work

`optimize` called the model without validation data:

    self.model.train(input_dataset["train"], output_dataset["train"])

while the non-optimizer path in model_job.py passes all four:

    model.train(x["train"], y["train"], x["validation"], y["validation"])

Every epoch loop guards its validation metrics behind
`if x_validation is not None`, so during optimization the per-epoch validation
score was never computed at all. The number a pruner needs to decide did not
exist — independently of whether the pruner was an instance or a string.

Where the hook lives

Five models train in epochs (base_torchvision, cnn, mlp, lenet5, and
scikit_learn/mlp_regression) and they share no ancestor below `BaseModel`, so
hooking each loop would mean five edits and would miss whatever is added next.

But all five already route their per-epoch metrics through
`BaseModel.calculate_metrics`, and so does the HuggingFace path via its own
callback. That is the seam: one optional `_epoch_reporter` on the base class,
invoked only for `level=EPOCH` and `split=VALIDATION`. Models that train in a
single shot never reach that branch, so for them nothing changes.

The reporter is invoked AFTER metrics are persisted, because it is allowed to
raise — that is how Optuna prunes — and the epoch that triggered the stop should
survive it. `TrialPruned` travels from inside the loop up to `study.optimize`
with nothing in between catching it, so the trial is recorded as pruned rather
than failed.

A metric missing from an epoch is not an error: `calculate_metrics` drops
metrics that return a non-finite value (one class present in a split, say), so
the trial simply continues unpruned.

Tests

11 new cases: that each epoch is reported with its step, that a rejected trial
raises `TrialPruned`, that a missing metric is tolerated, that the reported
metric is the one being optimized, that the hook fires for exactly
epoch+validation and stays quiet for train metrics and trial-level summaries,
that a model with no reporter behaves as before, and that an epoch's metrics are
saved before the hook can abort.

The three doubles in test_optuna_best_params.py now mirror the real
`BaseModel.train` signature. They declared `train(self, x, y)`, which no actual
model does — every one of them, across the torch, scikit-learn and HuggingFace
families, already accepts `x_validation=None, y_validation=None`, because the
normal training path has always passed them.

Full suite: 806 passed. The remaining 1 failure and 6 errors reproduce
identically on a clean develop checkout — the frontend build and the HuggingFace
model downloads, neither reachable from this environment.
@Maarmapa

Copy link
Copy Markdown
Contributor Author

"Reworked and reopened as #828 ".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants