Skip to content

bug(optimizers): with nested-component models, OptunaOptimizer saves the LAST trial's model, not the best one #809

Description

@Maarmapa

When an optimization run with OptunaOptimizer finishes on a model that has a
sub-component (component_field), the model that gets retrained, serialized to
disk and scored is not the best one found: it is the model from the last trial
executed
, which is essentially random.

Worse: the UI does show the best hyperparameters, so the discrepancy is
invisible.

Reproduced against dashAI 0.9.7.post1 from PyPI, and also verified on
develop
: the code is unchanged at the branch tip, so this is not something
already fixed and unreleased.

Where

DashAI/back/optimizers/optuna_optimizer.py:208-211, at the end of optimize():

best_params = study.best_params
best_model = self.model
for hyperparameter, value in best_params.items():
    setattr(best_model, hyperparameter, value)   # always the parent
best_model.train(...)

ModelFactory._process_param() (model_factory.py:197-199) returns optimizable
parameters as (obj, key, bounds, dtype) tuples, where obj is the object
that actually declares the parameter
— which may be a nested sub-component.
The file itself makes that intent explicit a few lines above
(model_factory.py:184):

# Attach submodel to the *real* parent object
setattr(obj, key, sub_model_instance)

Inside objective that intent is honoured (setattr(obj, key, value),
optuna_optimizer.py:188). In the final retrain, obj is ignored: the value is
written onto the parent wrapper, where nothing reads it, and the sub-component
keeps the last trial's value.

The same repository already gets this right

HyperOptOptimizer walks the exact same structure and does recover the owner
(hyperopt_optimizer.py:208-211 on develop):

for param_name, raw_value in best_params_raw.items():
    obj, key, dtype = param_mapping[param_name]
    setattr(obj, key, value)

That rules out this being a project convention.

Evidence — real run through the API

Dataset auditory-skillsTextClassificationTask
BagOfWordsTextClassificationModel with a nested SVC, C optimizable
(0.01–1000), OptunaOptimizer, 10 trials.

curl -s -X POST "http://localhost:8000/api/v1/job/" \
     -F 'job_type=ModelJob' -F 'kwargs={"run_id":8}'

It finished FINISHED, no errors, all four plots generated and metrics stored.
Opening the serialized model afterwards in ~/.DashAI/runs/8 with joblib.load:

C on the parent wrapper : 689.8012097772383   <- inert attribute nobody reads
C inside the actual SVC : 692.2353632019626   <- what it was fitted and saved with

And in that run's own slice_plot, the 10 values of C in execution order:

[689.801..., 145.523..., 220.487..., 941.512..., 577.435...,
  35.398..., 903.328..., 947.513..., 196.307..., 692.235...]

The best was trial 1 (689.80). The saved model has trial 10 (692.235).
Not the best: the last.

Control run with HyperOptOptimizer on the same configuration (everything
identical except the optimizer): all three values agree. The problem is Optuna's
path only.

Why it goes unnoticed

  • setattr on a Python object never fails, even when the attribute does not
    exist and nothing reads it. No exception, no log, no warning.
  • get_best_params() does return the correct values, and model_job.py
    writes them into run.parameters by walking the nested dict correctly. The UI
    shows the best value while the model on disk holds a different one.
  • The run ends FINISHED, plots are generated and metrics are stored.
  • On flat models the bug does not exist: there obj is the parent, so
    tabular and regression work correctly. It hides on exactly the path where it
    matters.

Proposed fix

Use the real owner, which is already in self.parameters:

best_params = study.best_params
for obj, key, _bounds, _dtype in self.parameters:
    if key in best_params:
        setattr(obj, key, best_params[key])
self.model.train(self.input_dataset["train"], self.output_dataset["train"])

Happy to send the PR to develop, with a test asserting that after optimize()
the sub-component holds the value from get_best_params() and not the last
trial's. There are no tests under DashAI/back/optimizers/ today, so it would be
the first for that package.

If you would rather review the full reproduction first, I can share it: a
self-contained script of about 20 lines that needs no dataset.

Note on work in flight

I saw that #804 moves this call site: model_job.py shrinks considerably and
the optimizer call lands in DashAI/back/units/fit_model_unit.py (optimize()
around line 183, get_model() around 191). The defect itself is untouched
there — optuna_optimizer.py:208-213 is byte-identical on that branch — so this
report stands either way, and a fix would not conflict with the atomization
work.

On scope

Today the only component in the package using component_field is
bow_text_classification_model.py. The immediate impact is narrow: on flat
models obj is the parent, so tabular and regression are unaffected.

I noticed the refactor/remove-text-classification branch deletes exactly that
file. Worth saying that this does not close the issue: the defect is in the
optimizer, not in the model. schema_fields/component_field.py survives on that
same branch, so the ability to nest components remains — and with it the bug,
waiting for the next composite model or any third-party plugin that uses it.
Removing the only consumer hides it, it does not fix it.

If that branch is landing soon, there is more reason to fix the optimizer first:
right now there is a concrete reproduction to verify the fix against; afterwards
one would have to build a composite test model to demonstrate it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions