Skip to content

Commit 77dbca1

Browse files
Felipedinoclaude
andcommitted
Measure a nested run through the units too, and stop calling the strategy
FitModelOverNestedFoldsUnit is FitModelOverFoldsUnit plus one measurement taken before it. Inheritance rather than a shared mixin because that is the actual relationship: everything the sibling does still happens, and this adds a step in front. Two units and not one with a flag because its inner splitter is a required component field, and a component field cannot be made optional -- the front reads `parent` straight off the property, and an anyOf buries it where it does not look. What the nested loop is for, since the code alone does not say it: in an ordinary cross-validated search the same folds choose the hyperparameters and report the score, so the score is optimistic by however much the search managed to fit them. The nested loop measures that honestly -- for each outer fold a search runs on folds carved out of that fold's training rows alone, and what it chooses is scored on the outer fold's validation rows, which it never saw. What it does not do is choose the hyperparameters: each outer fold picks its own and they generally differ, so there is no single model to keep out of that loop. The ordinary search still runs afterwards and produces the model that gets saved. The nested numbers describe the procedure, not the artifact, which is why they are kept at their own level -- LAST_OUTER against LAST -- and why the inner trials record nothing at all. The two fold branches in the job became one, choosing a unit rather than repeating a body. **The evaluation strategies are no longer called.** The job reads SCORED_SPLITS and KIND off the class it resolves and never touches execute() on any path. The classes are now what they always were underneath -- a declaration of how a run is carved and what it records -- and emptying them of the code that is now unreachable is the last piece. 1014 passed across units, dag, spike, api and evaluation, both nets unchanged, plus thirteen contract tests for the fold unit built on a hand-made context: the end-to-end net runs it inside a real job, which cannot show what it reads, promises and refuses on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f8a3fc7 commit 77dbca1

6 files changed

Lines changed: 567 additions & 36 deletions

File tree

DashAI/back/initial_components.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,9 @@
525525
from DashAI.back.units.evaluate_model_unit import EvaluateModelUnit
526526
from DashAI.back.units.fit_converter_unit import FitConverterUnit
527527
from DashAI.back.units.fit_model_over_folds_unit import FitModelOverFoldsUnit
528+
from DashAI.back.units.fit_model_over_nested_folds_unit import (
529+
FitModelOverNestedFoldsUnit,
530+
)
528531
from DashAI.back.units.fit_model_unit import FitModelUnit
529532
from DashAI.back.units.generate_global_explanation_unit import (
530533
GenerateGlobalExplanationUnit,
@@ -734,6 +737,7 @@ def get_initial_components():
734737
BuildModelUnit,
735738
FitModelUnit,
736739
FitModelOverFoldsUnit,
740+
FitModelOverNestedFoldsUnit,
737741
EvaluateModelUnit,
738742
EvaluateModelToArtifactUnit,
739743
SaveModelUnit,

DashAI/back/job/model_job.py

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@
1212
ModelSession,
1313
Run,
1414
)
15-
from DashAI.back.evaluation.base_evaluation_strategy import BaseEvaluationStrategy
1615
from DashAI.back.job.base_job import BaseJob, JobError
1716
from DashAI.back.optimizers.base_optimizer import BaseOptimizer
1817
from DashAI.back.splitters.splits_payload import normalize_splits_payload
1918
from DashAI.back.units.build_model_unit import BuildModelUnit
2019
from DashAI.back.units.context import ExecutionContext
2120
from DashAI.back.units.evaluate_model_unit import EvaluateModelUnit
2221
from DashAI.back.units.fit_model_over_folds_unit import FitModelOverFoldsUnit
22+
from DashAI.back.units.fit_model_over_nested_folds_unit import (
23+
FitModelOverNestedFoldsUnit,
24+
)
2325
from DashAI.back.units.fit_model_unit import FitModelUnit
2426
from DashAI.back.units.load_dataset_unit import LoadDatasetUnit
2527
from DashAI.back.units.prepare_and_fold_unit import PrepareAndFoldUnit
@@ -164,8 +166,10 @@ def run(
164166
# the shape they publish for it.
165167
prepare(ctx)
166168

169+
# Only the partitions are needed here, and only to ask
170+
# whether the session reserved any rows: the units read
171+
# what they work on from the context themselves.
167172
x = ctx.get("x") if ctx.has("x") else ctx.require("x_folds")
168-
y = ctx.get("y") if ctx.has("y") else ctx.require("y_folds")
169173

170174
# save the obtained splits into the database
171175
run.split_indexes = json.dumps(ctx.require("split_indexes"))
@@ -231,19 +235,32 @@ def run(
231235

232236
self.report_progress(0.85, "Computing metrics")
233237
EvaluateModelUnit(run_id=run_id, splits=scored_splits)(ctx)
234-
elif not run.nested:
235-
fit_folds = FitModelOverFoldsUnit(
236-
optimizer={
238+
else:
239+
# Two units and not one with a flag: the nested one
240+
# takes a required component field for its inner
241+
# splitter, and a component field cannot be made
242+
# optional without leaving the user without a selector.
243+
fold_config = {
244+
"optimizer": {
237245
"component": run.optimizer_name,
238246
"params": run.optimizer_parameters,
239247
},
240-
goal_metric=run.goal_metric,
241-
run_id=run_id,
242-
artifact_prefix=str(run_id),
243-
scored_splits=[
248+
"goal_metric": run.goal_metric,
249+
"run_id": run_id,
250+
"artifact_prefix": str(run_id),
251+
"scored_splits": [
244252
name for name in scored_splits if name != "TEST"
245253
],
246-
)
254+
}
255+
if run.nested:
256+
fold_config["inner_splitter"] = {
257+
"component": run.nested.get("splitter_name"),
258+
"params": run.nested,
259+
}
260+
fit_folds = FitModelOverNestedFoldsUnit(**fold_config)
261+
else:
262+
fit_folds = FitModelOverFoldsUnit(**fold_config)
263+
247264
fit_folds(ctx)
248265

249266
plot_paths = ctx.require("plot_paths")
@@ -253,8 +270,20 @@ def run(
253270
db.commit()
254271

255272
self.report_progress(0.85, "Computing metrics")
273+
if ctx.has("outer_fold_metrics"):
274+
# Kept at its own level: it answers a different
275+
# question from the ordinary summary -- how the
276+
# procedure does, rather than how this model does --
277+
# and the two would be indistinguishable side by
278+
# side.
279+
self._aggregate_fold_metrics(
280+
db,
281+
run_id,
282+
ctx.get("outer_fold_metrics"),
283+
LevelEnum.LAST_OUTER,
284+
)
256285
self._aggregate_fold_metrics(
257-
db, run_id, ctx.require("fold_metrics")
286+
db, run_id, ctx.require("fold_metrics"), LevelEnum.LAST
258287
)
259288

260289
# The rows the session reserved are the only ones no
@@ -267,27 +296,6 @@ def run(
267296
# leaves that partition empty rather than absent.
268297
if len(x[-1]["test"]) > 0:
269298
EvaluateModelUnit(run_id=run_id, splits=["TEST"])(ctx)
270-
else:
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.
275-
evaluation_estrategy: BaseEvaluationStrategy = strategy_class(
276-
factory=ctx.require("factory"),
277-
optimizer=preparation_results["optimizer"],
278-
goal_metric=preparation_results["goal_metric"],
279-
)
280-
evaluation_estrategy.set_progress_reporter(self.report_progress)
281-
model, plot_paths = evaluation_estrategy.execute(
282-
x=x,
283-
y=y,
284-
run=run,
285-
db=db,
286-
)
287-
# The strategy hands the model back rather than leaving
288-
# it in the context, so the saving unit below serves
289-
# both paths.
290-
ctx.put("model", model)
291299
except Exception as e:
292300
log.exception(e)
293301
raise JobError(
@@ -340,7 +348,9 @@ def run(
340348
gc.collect()
341349

342350
@staticmethod
343-
def _aggregate_fold_metrics(db, run_id: int, fold_metrics: Dict[str, Any]) -> None:
351+
def _aggregate_fold_metrics(
352+
db, run_id: int, fold_metrics: Dict[str, Any], level: LevelEnum
353+
) -> None:
344354
"""Summarise the per-fold scores into one row per split and metric.
345355
346356
The unit that fitted the folds publishes their scores rather than
@@ -361,6 +371,11 @@ def _aggregate_fold_metrics(db, run_id: int, fold_metrics: Dict[str, Any]) -> No
361371
The run the rows belong to.
362372
fold_metrics : dict
363373
``{split name: {metric name: [one score per fold]}}``.
374+
level : LevelEnum
375+
Where the summary goes. The ordinary fold scores summarise to
376+
``LAST``; the outer folds of a nested run summarise to
377+
``LAST_OUTER``, because they answer a different question and would
378+
be indistinguishable from the first if they shared a level.
364379
"""
365380
import numpy as np
366381

@@ -373,7 +388,7 @@ def _aggregate_fold_metrics(db, run_id: int, fold_metrics: Dict[str, Any]) -> No
373388
.filter_by(
374389
run_id=run_id,
375390
split=SplitEnum[split_name],
376-
level=LevelEnum.LAST,
391+
level=level,
377392
name=metric_name,
378393
)
379394
.first()
@@ -389,7 +404,7 @@ def _aggregate_fold_metrics(db, run_id: int, fold_metrics: Dict[str, Any]) -> No
389404
Metric(
390405
run_id=run_id,
391406
split=SplitEnum[split_name],
392-
level=LevelEnum.LAST,
407+
level=level,
393408
name=metric_name,
394409
value=mean,
395410
std_value=deviation,

DashAI/back/units/fit_model_over_folds_unit.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ def execute(self, ctx: ExecutionContext) -> None:
136136
# ----------------------------------------------------------------- #
137137

138138
def _score_one_trial(self, model, x_folds, y_folds, metric) -> float:
139+
return self._score_folds(model, x_folds, y_folds, metric, record=True)
140+
141+
def _score_folds(self, model, x_folds, y_folds, metric, record: bool) -> float:
139142
"""Fit and score every fold, and return the mean, for one trial.
140143
141144
This is what the optimizer measures, and it is the whole difference
@@ -182,7 +185,8 @@ def _score_one_trial(self, model, x_folds, y_folds, metric) -> float:
182185
value
183186
)
184187

185-
self._record_trial(model, accumulated)
188+
if record:
189+
self._record_trial(model, accumulated)
186190
return float(np.mean(scores))
187191

188192
@staticmethod
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"""Unit that measures a fold run with a search of its own inside each fold."""
2+
3+
import logging
4+
5+
from DashAI.back.core.enums.metrics import LevelEnum, SplitEnum
6+
from DashAI.back.core.schema_fields import BaseSchema
7+
from DashAI.back.job.base_job import JobError
8+
from DashAI.back.units.context import ExecutionContext
9+
from DashAI.back.units.fit_model_over_folds_unit import FitModelOverFoldsUnit
10+
from DashAI.back.units.fit_scope import (
11+
TRIAL_SPLITS,
12+
goal_metric_field,
13+
optimizer_field,
14+
trial_splits_field,
15+
)
16+
from DashAI.back.units.splitter_scope import _splitter_field
17+
18+
log = logging.getLogger(__name__)
19+
20+
21+
def inner_splitter_field():
22+
"""The splitter that carves an outer fold into folds of its own.
23+
24+
Required rather than optional, which is what makes this a separate unit.
25+
A component field wrapped in ``none_type`` is emitted as ``anyOf`` and the
26+
front reads ``parent`` directly off the property, so an optional one leaves
27+
the user with no selector at all -- the same wall that made the two
28+
explainer units siblings.
29+
"""
30+
return _splitter_field(
31+
parent="FoldSplitter",
32+
placeholder={
33+
"component": "KFoldSplitter",
34+
"params": {"n_splits": 3, "shuffle": True, "random_state": 42},
35+
},
36+
)
37+
38+
39+
class FitModelOverNestedFoldsSchema(BaseSchema):
40+
optimizer: optimizer_field() # type: ignore
41+
goal_metric: goal_metric_field() # type: ignore
42+
scored_splits: trial_splits_field() # type: ignore
43+
inner_splitter: inner_splitter_field() # type: ignore
44+
45+
46+
class FitModelOverNestedFoldsUnit(FitModelOverFoldsUnit):
47+
"""Score every outer fold with a search that never saw it.
48+
49+
``FitModelOverFoldsUnit`` plus one measurement taken before it. The
50+
relationship is inheritance rather than a shared mixin because that is what
51+
it is: everything the sibling does still happens, and this adds a step in
52+
front of it.
53+
54+
**What the extra step is for.** In an ordinary cross-validated search, the
55+
same folds choose the hyperparameters and report the score, so the reported
56+
score is optimistic by however much the search managed to fit them. The
57+
nested loop measures that honestly: for each outer fold, a search is run
58+
from scratch on folds carved out of *that fold's training rows only*, and
59+
the chosen model is then scored on the outer fold's validation rows, which
60+
that search never saw. Those are the ``OUTER_FOLD`` rows.
61+
62+
What it does **not** do is choose the hyperparameters. Each outer fold
63+
picks its own, and they generally differ; there is no single model to keep
64+
out of that loop. So the ordinary search still runs afterwards, over all
65+
the folds, and it is what produces the model that gets saved. The nested
66+
numbers are a statement about the procedure, not about the artifact.
67+
68+
That also means the inner trials record nothing: their partitions belong to
69+
one outer fold, and rows from them would sit alongside rows describing the
70+
kept model as if they were comparable.
71+
"""
72+
73+
SCHEMA = FitModelOverNestedFoldsSchema
74+
75+
PROVIDES = ("model", "plot_paths", "fold_metrics", "outer_fold_metrics")
76+
77+
def __init__(self, **config) -> None:
78+
super().__init__(**config)
79+
self._inner_splitter = None
80+
81+
def _resolve_inner_splitter(self):
82+
"""Build the splitter that carves an outer fold, memoized on this unit."""
83+
if self._inner_splitter is not None:
84+
return self._inner_splitter
85+
86+
from kink import di
87+
88+
chosen = self.config["inner_splitter"]
89+
try:
90+
splitter_class = di["component_registry"][chosen["component"]]["class"]
91+
self._inner_splitter = splitter_class(splits_data=dict(chosen["params"]))
92+
except Exception as e:
93+
log.exception(e)
94+
raise JobError(
95+
f"Error configuring inner splitter for nested CV: {e}",
96+
) from e
97+
return self._inner_splitter
98+
99+
def execute(self, ctx: ExecutionContext) -> None:
100+
model = ctx.require("model")
101+
x_folds = ctx.require("x_folds")
102+
y_folds = ctx.require("y_folds")
103+
optimizable_parameters = ctx.require("optimizable_parameters")
104+
105+
# Resolved outside the wrapper below so a splitter that cannot be built
106+
# is reported as that, rather than as a training failure.
107+
inner_splitter = self._resolve_inner_splitter()
108+
109+
if optimizable_parameters:
110+
try:
111+
outer_fold_metrics = self._measure_every_outer_fold(
112+
model,
113+
x_folds,
114+
y_folds,
115+
inner_splitter,
116+
optimizable_parameters,
117+
)
118+
except Exception as e:
119+
log.exception(e)
120+
raise JobError(
121+
f"Model training failed {e}",
122+
) from e
123+
else:
124+
# Nothing to search means nothing for the nested loop to measure:
125+
# every outer fold would choose the same parameters, which is what
126+
# the ordinary loop already reports.
127+
outer_fold_metrics = {}
128+
129+
ctx.put_ref("outer_fold_metrics", outer_fold_metrics)
130+
131+
# And then the ordinary run, which is what produces the kept model.
132+
super().execute(ctx)
133+
134+
def _measure_every_outer_fold(
135+
self, model, x_folds, y_folds, inner_splitter, optimizable_parameters
136+
) -> dict:
137+
"""Search inside each outer fold, then score that fold with what it chose.
138+
139+
Returns
140+
-------
141+
dict
142+
``{split name: {metric name: [one score per outer fold]}}``, for
143+
whoever aggregates them -- the same shape and the same reason as
144+
the ordinary fold scores.
145+
"""
146+
optimizer, goal_metric = self._resolve_search()
147+
scored_splits = self.config.get("scored_splits", TRIAL_SPLITS)
148+
outer_fold_metrics: dict = {}
149+
150+
for index, (x_outer, y_outer) in enumerate(self._folds(x_folds, y_folds)):
151+
# Carved out of this fold's training rows alone. Its validation
152+
# rows are what the search will be judged on, so nothing drawn from
153+
# them may reach it.
154+
inner_x, inner_y, _ = inner_splitter.split(
155+
x_outer["train"], y_outer["train"]
156+
)
157+
158+
optimizer.optimize(
159+
model,
160+
inner_x,
161+
inner_y,
162+
optimizable_parameters,
163+
goal_metric,
164+
self._score_one_inner_trial,
165+
)
166+
outer_model = optimizer.get_model()
167+
168+
outer_model.x_data = x_outer
169+
outer_model.y_data = y_outer
170+
self._fit_kept_model(outer_model, x_outer, y_outer)
171+
172+
for name in scored_splits:
173+
split = SplitEnum[name]
174+
results = outer_model.calculate_metrics(
175+
split=split, level=LevelEnum.OUTER_FOLD, fold_index=index
176+
)
177+
if results is None:
178+
results = outer_model.compute_metrics(split=split)
179+
if not results:
180+
continue
181+
for metric_name, value in results.items():
182+
outer_fold_metrics.setdefault(name, {}).setdefault(
183+
metric_name, []
184+
).append(value)
185+
186+
return outer_fold_metrics
187+
188+
def _score_one_inner_trial(self, model, x_folds, y_folds, metric) -> float:
189+
"""The objective of an inner search: the fold loop, recording nothing.
190+
191+
The partitions of an inner trial belong to one outer fold. Rows written
192+
from them would sit in the same table as the rows describing the model
193+
that gets kept, indistinguishable from them and far more numerous.
194+
"""
195+
return self._score_folds(model, x_folds, y_folds, metric, record=False)

0 commit comments

Comments
 (0)