Skip to content

fix(optimizers): make pruning actually prune - #828

Open
Maarmapa wants to merge 5 commits into
DashAISoftware:developfrom
Maarmapa:claude/fix-optuna-pruner
Open

fix(optimizers): make pruning actually prune#828
Maarmapa wants to merge 5 commits into
DashAISoftware:developfrom
Maarmapa:claude/fix-optuna-pruner

Conversation

@Maarmapa

Copy link
Copy Markdown
Contributor

Replaces #823, which was closed after review. Closes #821.

@Irozuku was right that instantiating the pruner wasn't enough. Looking into why, the reason went deeper than the schema string.

The pruner had nothing to decide with

optimize() trained the model without validation data:

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

while the normal 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 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_image_classifier, cnn_image_classifier, mlp_image_classifier, lenet5_image_classifier and scikit_learn/mlp_regression — and they share no ancestor below BaseModel. 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 nothing changes for them.

Two details that matter:

  • The reporter runs after metrics are persisted. 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, not failed.

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

Also included

Resolving the pruner name into an instance, from #823. create_study accepts any object without validating it, so the raw schema string produced a study whose pruner was a str, failing later as AttributeError: 'str' object has no attribute 'prune'. Only pruners Optuna can build with default arguments are supported; PatientPruner, PercentilePruner and ThresholdPruner need configuration the schema does not carry yet, and say so with a clear message.

Verification

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

11 new cases: each epoch reported with its step, a rejected trial raising TrialPruned, a missing metric tolerated, the reported metric being the one optimized, the hook firing for exactly epoch+validation and staying quiet for train metrics and trial-level summaries, a model without a reporter behaving as before, and an epoch's metrics 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.

Not covered

Pruning only does something for models that train in epochs. For everything else the parameter remains inert, just visibly so rather than as a latent AttributeError.

If you would still rather drop the pruner as a parameter for now, I am happy to send that instead — it touches the schema field, the frontend config step and a few test fixtures. This PR is the other branch of the choice you offered.

claude added 2 commits August 14, 2026 12:30
…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.
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

Ran the full suite locally on macOS: 812 passed, 1 failed. The failure is test_app_front (DashAI/front/build/index.html missing — production frontend never built on that machine), unrelated to this change. The 6 HuggingFace errors I reported earlier were network restrictions in my other environment and pass here.

claude added 3 commits August 18, 2026 14:26
The unit tests cover each piece — the reporter reports, the hook fires only for
epoch+validation, TrialPruned is raised. None of them would notice a pruner that
reports faithfully and never cuts anything short, which is the failure this whole
change exists to fix.

This wires the real parts together and asserts the outcome Optuna records:

  test_a_bad_trial_is_actually_pruned      at least one trial ends PRUNED
  test_pruning_stops_training_early        pruned trials cost fewer epochs than
                                           the same study with pruning disabled
  test_disabled_pruning_completes_every_trial   the control: with NopPruner every
                                           trial completes, so the two above are
                                           measuring pruning and not noise

Real, not stubbed: OptunaOptimizer.optimize, BaseModel.calculate_metrics (where
the hook lives), _report_epoch, and Optuna's own MedianPruner and trial
bookkeeping. Stubbed: _save_metrics, because persistence needs a database and is
not what this proves, and the training itself, replaced by a loop that improves
by a fixed amount per epoch and calls calculate_metrics exactly as the five
models that train in epochs do — same split, same level, same log_index.

Checked against the previous commit, with the source files reverted: the two
tests that assert pruning fail, and the control still passes. A test that passes
before and after the change it covers is not evidence.

Full suite: 809 passed. The 1 failure and 6 errors are the frontend build and the
HuggingFace downloads, identical on a clean develop checkout here.
… draw

These tests failed about one run in twenty. Measured on this branch: 2 failures
in 40 runs of the file, and one in a full-suite run.

The cause was that `SteppedModel`'s quality was the value Optuna sampled for
`rate`, drawn by an unseeded RandomSampler. MedianPruner prunes a trial whose
score falls below the median of the trials before it, so whether anything was
pruned depended on where those draws landed. When trials 5 to 9 all happened to
draw above the median, nothing was pruned and both pruning assertions failed --
reporting the pruner as broken when it was working.

Quality now decreases with each trial (`1 / (1 + trials_run)`), so every trial
after the pruner's startup window is below the median from its first epoch. The
sampled `rate` is still declared as the optimizable parameter, so the
optimizer's real path still runs; it just no longer decides the outcome.

With the outcome deterministic the assertions can be exact, and they are: 5
trials pruned of 10, and 77 epochs against the control's 132. Before, the test
only asserted that *something* was pruned and that one number was smaller than
another, which is what let a flake hide.

0 failures in 30 runs after the change. Full suite: 813 passed; the 1 failure
and 6 errors are the frontend build and the HuggingFace downloads, identical on
a clean develop checkout here.
The existing integration test asserts the pruning verdict with a stand-in model
whose training loop is three lines. That shape is right for asserting a verdict
deterministically, but it cannot answer the question this change is actually
about: does the number a pruner needs appear when a model that ships with
DashAI trains?

It did not. The epoch loops guard their validation metrics behind
`if x_validation is not None`, and `optimize` never passed validation data, so
`calculate_metrics(split=VALIDATION, level=EPOCH)` was skipped for every epoch
of every trial. Nothing was reported, so nothing could be pruned, whatever the
pruner was.

This uses `MLPImageClassifier` unmodified -- its real `train`, its real epoch
loop, its real `calculate_metrics`, and the real `Accuracy` metric -- on 24
synthetic 16x16 images. The only stub is `_save_metrics`, which needs a
database. Four tests: every trial carries one intermediate value per epoch; a
model trained without validation data reports nothing (the bug, as a negative
control); train-split and step-level metrics never reach the hook; and the hook
fires at epoch level for validation only.

Pruning is disabled here on purpose. Whether a given trial deserves to be cut
is the pruner's policy, asserted deterministically next door; what is under
test here is that a real model produces the evidence that policy runs on.

Checked with the source reverted: dropping the validation arguments from
`optimize` fails the first test (0 values reported instead of 3), and removing
the hook call from `calculate_metrics` fails three of the four.

Costs about 10 seconds. Full suite: 813 passed; the 1 failure and 6 errors are
the frontend build and the HuggingFace downloads, identical on a clean develop
checkout here.
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.

2 participants