Skip to content

Commit f8a3fc7

Browse files
Felipedinoclaude
andcommitted
Train a cross-validated run through the units, unless it is nested
FitModelOverFoldsUnit is FitModelUnit's sibling: it takes a list of partition sets instead of one, which is a different REQUIRES and so a different unit. Everything around the fit is the shared mixin; what is written here is the objective the search measures -- the whole fold loop, so one trial costs k fits -- and what happens once the search is over. ModelJob composes it for a fold run that is not nested. Nested cross-validation still trains through the strategy: its inner splitter is a required component field, and a component field cannot be made optional without leaving the user without a selector, so it is a further sibling rather than a flag on this one. Three decisions worth naming. The per-fold scores are published rather than aggregated in the unit. A summary row carries a standard deviation, and a unit may not write domain rows -- the one sanctioned write in the domain layer has nowhere to put one. So the unit hands the numbers over and the job does the arithmetic and the writing, where every other row it persists is written. A single fold gets a deviation of zero rather than none, because none is what the reserved-rows measurement carries and the two say different things. A trial records one row per split holding the mean over its folds, not one per fold: the folds of a trial measure a hyperparameter setting rather than the model that gets kept, and recording each would bury the rows that describe it. That write is guarded on the run, which _save_metrics does not guard for itself -- a caller with no run would write rows against a foreign key pointing at nothing, and they insert without complaint because nothing enforces it. Scoring the reserved rows is not the unit's. It is an ordinary LAST metric, so it is EvaluateModelUnit, the same one a holdout run uses, and whether there is anything to score is the caller's to know: a session that reserved nothing leaves that partition empty rather than absent. calculate_metrics now returns what it wrote, so a caller that wants both the row and the number scores the split once instead of twice. The assertion that the optimizer gave back the model it was handed caught a real gap while this was written: with the data attached at fit time rather than at build time, nothing had pointed the model at anything during a fold search. It does now, per fold, the same as the scoring loop. 1001 passed across units, dag, spike, api and evaluation; both nets unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9c6ac25 commit f8a3fc7

5 files changed

Lines changed: 399 additions & 6 deletions

File tree

DashAI/back/initial_components.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,7 @@
524524
)
525525
from DashAI.back.units.evaluate_model_unit import EvaluateModelUnit
526526
from DashAI.back.units.fit_converter_unit import FitConverterUnit
527+
from DashAI.back.units.fit_model_over_folds_unit import FitModelOverFoldsUnit
527528
from DashAI.back.units.fit_model_unit import FitModelUnit
528529
from DashAI.back.units.generate_global_explanation_unit import (
529530
GenerateGlobalExplanationUnit,
@@ -732,6 +733,7 @@ def get_initial_components():
732733
PrepareAndFoldUnit,
733734
BuildModelUnit,
734735
FitModelUnit,
736+
FitModelOverFoldsUnit,
735737
EvaluateModelUnit,
736738
EvaluateModelToArtifactUnit,
737739
SaveModelUnit,

DashAI/back/job/model_job.py

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,21 @@
55
from sqlalchemy import exc
66
from sqlalchemy.orm.attributes import flag_modified
77

8-
from DashAI.back.dependencies.database.models import Dataset, ModelSession, Run
8+
from DashAI.back.core.enums.metrics import LevelEnum, SplitEnum
9+
from DashAI.back.dependencies.database.models import (
10+
Dataset,
11+
Metric,
12+
ModelSession,
13+
Run,
14+
)
915
from DashAI.back.evaluation.base_evaluation_strategy import BaseEvaluationStrategy
1016
from DashAI.back.job.base_job import BaseJob, JobError
1117
from DashAI.back.optimizers.base_optimizer import BaseOptimizer
1218
from DashAI.back.splitters.splits_payload import normalize_splits_payload
1319
from DashAI.back.units.build_model_unit import BuildModelUnit
1420
from DashAI.back.units.context import ExecutionContext
1521
from DashAI.back.units.evaluate_model_unit import EvaluateModelUnit
22+
from DashAI.back.units.fit_model_over_folds_unit import FitModelOverFoldsUnit
1623
from DashAI.back.units.fit_model_unit import FitModelUnit
1724
from DashAI.back.units.load_dataset_unit import LoadDatasetUnit
1825
from DashAI.back.units.prepare_and_fold_unit import PrepareAndFoldUnit
@@ -224,10 +231,47 @@ def run(
224231

225232
self.report_progress(0.85, "Computing metrics")
226233
EvaluateModelUnit(run_id=run_id, splits=scored_splits)(ctx)
234+
elif not run.nested:
235+
fit_folds = FitModelOverFoldsUnit(
236+
optimizer={
237+
"component": run.optimizer_name,
238+
"params": run.optimizer_parameters,
239+
},
240+
goal_metric=run.goal_metric,
241+
run_id=run_id,
242+
artifact_prefix=str(run_id),
243+
scored_splits=[
244+
name for name in scored_splits if name != "TEST"
245+
],
246+
)
247+
fit_folds(ctx)
248+
249+
plot_paths = ctx.require("plot_paths")
250+
if ctx.has("best_parameters"):
251+
run.parameters = ctx.get("best_parameters")
252+
flag_modified(run, "parameters")
253+
db.commit()
254+
255+
self.report_progress(0.85, "Computing metrics")
256+
self._aggregate_fold_metrics(
257+
db, run_id, ctx.require("fold_metrics")
258+
)
259+
260+
# The rows the session reserved are the only ones no
261+
# fold and no trial ever saw, so they are the only
262+
# honest estimate left once a model is picked out of a
263+
# comparison table -- and scoring them is an ordinary
264+
# LAST metric, so it is the same unit a holdout run
265+
# uses. Whether there is anything to score is the
266+
# caller's to know: a session that reserved nothing
267+
# leaves that partition empty rather than absent.
268+
if len(x[-1]["test"]) > 0:
269+
EvaluateModelUnit(run_id=run_id, splits=["TEST"])(ctx)
227270
else:
228-
# Fold runs still train through the strategy. Their loop
229-
# is the next piece to move; everything before and after
230-
# it is already the units'.
271+
# Nested cross-validation still trains through the
272+
# strategy: its inner splitter is a required component
273+
# field, so it is a further sibling unit rather than a
274+
# flag on the one above, and it is not written yet.
231275
evaluation_estrategy: BaseEvaluationStrategy = strategy_class(
232276
factory=ctx.require("factory"),
233277
optimizer=preparation_results["optimizer"],
@@ -295,6 +339,65 @@ def run(
295339
ctx.clear_cache()
296340
gc.collect()
297341

342+
@staticmethod
343+
def _aggregate_fold_metrics(db, run_id: int, fold_metrics: Dict[str, Any]) -> None:
344+
"""Summarise the per-fold scores into one row per split and metric.
345+
346+
The unit that fitted the folds publishes their scores rather than
347+
aggregating them, because a summary row carries a standard deviation
348+
and a unit may not write domain rows -- the one sanctioned write in the
349+
domain layer has nowhere to put one. So the arithmetic and the writing
350+
happen here, where every other row this job persists is written.
351+
352+
A single fold gets a deviation of zero rather than none: none is what
353+
the reserved-rows measurement carries, and the two say different
354+
things -- "one fold, so nothing varied" against "not a summary at all".
355+
356+
Parameters
357+
----------
358+
db : Session
359+
The session this job is already holding.
360+
run_id : int
361+
The run the rows belong to.
362+
fold_metrics : dict
363+
``{split name: {metric name: [one score per fold]}}``.
364+
"""
365+
import numpy as np
366+
367+
for split_name, by_metric in fold_metrics.items():
368+
for metric_name, values in by_metric.items():
369+
if not values:
370+
continue
371+
existing = (
372+
db.query(Metric)
373+
.filter_by(
374+
run_id=run_id,
375+
split=SplitEnum[split_name],
376+
level=LevelEnum.LAST,
377+
name=metric_name,
378+
)
379+
.first()
380+
)
381+
mean = float(np.mean(values))
382+
deviation = float(np.std(values)) if len(values) > 1 else 0.0
383+
384+
if existing:
385+
existing.value = mean
386+
existing.std_value = deviation
387+
else:
388+
db.add(
389+
Metric(
390+
run_id=run_id,
391+
split=SplitEnum[split_name],
392+
level=LevelEnum.LAST,
393+
name=metric_name,
394+
value=mean,
395+
std_value=deviation,
396+
step=0,
397+
)
398+
)
399+
db.commit()
400+
298401
def _prepare_dataset_and_components(
299402
self, run_id: int, db, component_registry
300403
) -> Dict[str, Any]:

DashAI/back/models/base_model.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,13 @@ def calculate_metrics(
349349
labels stored in the model for the given split are used.
350350
Defaults to None.
351351
352+
Returns
353+
-------
354+
Dict[str, float] or None
355+
What was written, so a caller that also wants the numbers does not
356+
have to score the same split twice. ``None`` when nothing was
357+
written: no run to write against, or nothing to score.
358+
352359
Notes
353360
-----
354361
A metric row is keyed by the run it belongs to, so a model with no run
@@ -366,11 +373,11 @@ def calculate_metrics(
366373
# checked for metrics first. Treating "no attribute" as "no run" keeps
367374
# that path working and is the same answer for any caller that has one.
368375
if not getattr(self, "run_id", None):
369-
return
376+
return None
370377

371378
results = self.compute_metrics(split=split, x_data=x_data, y_data=y_data)
372379
if results is None:
373-
return
380+
return None
374381

375382
# Save to database
376383
self._save_metrics(
@@ -392,6 +399,8 @@ def calculate_metrics(
392399
):
393400
self._epoch_reporter(results, log_index)
394401

402+
return results
403+
395404
def prepare_dataset(
396405
self, dataset: "DashAIDataset", is_fit: bool = False
397406
) -> "DashAIDataset":

0 commit comments

Comments
 (0)