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-skills → TextClassificationTask →
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.
When an optimization run with
OptunaOptimizerfinishes on a model that has asub-component (
component_field), the model that gets retrained, serialized todisk 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 somethingalready fixed and unreleased.
Where
DashAI/back/optimizers/optuna_optimizer.py:208-211, at the end ofoptimize():ModelFactory._process_param()(model_factory.py:197-199) returns optimizableparameters as
(obj, key, bounds, dtype)tuples, whereobjis the objectthat 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):Inside
objectivethat intent is honoured (setattr(obj, key, value),optuna_optimizer.py:188). In the final retrain,objis ignored: the value iswritten 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
HyperOptOptimizerwalks the exact same structure and does recover the owner(
hyperopt_optimizer.py:208-211ondevelop):That rules out this being a project convention.
Evidence — real run through the API
Dataset
auditory-skills→TextClassificationTask→BagOfWordsTextClassificationModelwith a nestedSVC,Coptimizable(0.01–1000),
OptunaOptimizer, 10 trials.It finished
FINISHED, no errors, all four plots generated and metrics stored.Opening the serialized model afterwards in
~/.DashAI/runs/8withjoblib.load:And in that run's own
slice_plot, the 10 values ofCin execution order:The best was trial 1 (689.80). The saved model has trial 10 (692.235).
Not the best: the last.
Control run with
HyperOptOptimizeron the same configuration (everythingidentical except the optimizer): all three values agree. The problem is Optuna's
path only.
Why it goes unnoticed
setattron a Python object never fails, even when the attribute does notexist and nothing reads it. No exception, no log, no warning.
get_best_params()does return the correct values, andmodel_job.pywrites them into
run.parametersby walking the nested dict correctly. The UIshows the best value while the model on disk holds a different one.
FINISHED, plots are generated and metrics are stored.objis the parent, sotabular 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:Happy to send the PR to
develop, with a test asserting that afteroptimize()the sub-component holds the value from
get_best_params()and not the lasttrial's. There are no tests under
DashAI/back/optimizers/today, so it would bethe 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.pyshrinks considerably andthe optimizer call lands in
DashAI/back/units/fit_model_unit.py(optimize()around line 183,
get_model()around 191). The defect itself is untouchedthere —
optuna_optimizer.py:208-213is byte-identical on that branch — so thisreport stands either way, and a fix would not conflict with the atomization
work.
On scope
Today the only component in the package using
component_fieldisbow_text_classification_model.py. The immediate impact is narrow: on flatmodels
objis the parent, so tabular and regression are unaffected.I noticed the
refactor/remove-text-classificationbranch deletes exactly thatfile. Worth saying that this does not close the issue: the defect is in the
optimizer, not in the model.
schema_fields/component_field.pysurvives on thatsame 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.