Skip to content

Commit d476cf0

Browse files
Felipedinoclaude
andcommitted
Leave the evaluation strategies declaring, and nothing else
They ran the training: execute took the run row and the database session and did the fitting, the search, the scoring, the aggregation and the persistence behind one method. Every piece of that is now a unit, and nothing has called execute since the fold paths moved -- the job reads SCORED_SPLITS and KIND off the class it resolves and never touches it otherwise. So the code goes. base_evaluation_strategy, cv and holdout drop from 881 lines to 153, and what is left is what was underneath all along: how a run is carved, and which partitions it records a score for. They stay registered. That is not deference to dead code -- the frontend reads these classes in four places, and only one is about metrics. The session wizard lists them so the user can choose one, and ModelSession.evaluation_strategy is NOT NULL, so without that listing a session cannot be created at all. It starts on the first one whose kind is holdout. `kind` decides the shape of the splits payload and which controls are shown. Only `scored_splits` is about the charts. Removing the classes would not have cost two screens; it would have cost the way sessions are made. There is precedent for a class here that declares and does not execute: BaseSplitter.PARTITIONING and explainable_partitions are read exactly this way, by the backend and by the frontend, and nothing calls them to do work. Five of the forecasting tests exercised behaviour rather than declarations -- the final fit, and which partitions a trial scores. That behaviour moved rather than disappeared, so they are pointed at the units that carry it out now. They stay in the same file, next to the declarations, because that is the pair that has to stay consistent: a strategy that says it does not score the training partition, and a fit that then does not. 1014 passed across units, dag, spike, api and evaluation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 77dbca1 commit d476cf0

4 files changed

Lines changed: 122 additions & 816 deletions

File tree

Lines changed: 41 additions & 216 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,56 @@
1-
import os
2-
import pickle
3-
from abc import ABCMeta, abstractmethod
4-
from typing import Callable, Final, List, Optional
1+
"""What a run records, declared per strategy.
52
6-
from kink import di
3+
These classes used to run the training too: ``execute`` took the run row and the
4+
database session and did the fitting, the search, the scoring, the aggregation
5+
and the persistence behind one method. That work is the units' now -- see
6+
``FitModelUnit``, ``FitModelOverFoldsUnit`` and their siblings -- and what is
7+
left here is what was underneath it all along: a declaration of how a run is
8+
carved and which partitions it records a score for.
9+
10+
**They stay registered even though they no longer do anything.** The frontend
11+
reads them in four places, and only one is about metrics:
12+
13+
- the session wizard lists them so the user can choose one, and
14+
``ModelSession.evaluation_strategy`` is NOT NULL, so without that listing a
15+
session cannot be created at all;
16+
- it starts on the first one whose ``kind`` is holdout;
17+
- ``kind`` decides the shape of the splits payload and which controls are shown;
18+
- ``scored_splits`` tells the metric charts which partitions exist to plot.
19+
20+
There is precedent for a class in this codebase that declares and does not
21+
execute: ``BaseSplitter.PARTITIONING`` and ``explainable_partitions`` are read
22+
the same way, by the backend and by the frontend, and nothing calls them to do
23+
work.
24+
"""
25+
26+
from typing import Final
727

8-
from DashAI.back.core.artifacts import normalize_artifacts
928
from DashAI.back.core.enums.metrics import SplitEnum
10-
from DashAI.back.dependencies.database.models import Run
11-
from DashAI.back.models.base_model import BaseModel
12-
from DashAI.back.models.model_factory import ModelFactory
13-
from DashAI.back.optimizers.base_optimizer import BaseOptimizer
1429

1530

16-
class BaseEvaluationStrategy(metaclass=ABCMeta):
17-
"""Abstract base class defining the interface for model evaluation strategies.
31+
class BaseEvaluationStrategy:
32+
"""How a run is carved, and what it records.
1833
19-
Concrete implementations (e.g., CrossValidationEvaluationStrategy,
20-
HoldoutEvaluationStrategy) inherit from this class and provide specific
21-
strategies for model evaluation.
34+
Subclasses declare; none of them execute.
2235
"""
2336

2437
TYPE: Final[str] = "EvaluationStrategy"
2538

39+
#: Whether this strategy splits the dataset once or into folds. It decides
40+
#: which unit prepares the data, because the two publish different shapes,
41+
#: and which controls the session wizard offers.
2642
KIND: str = "holdout"
43+
44+
#: The partitions a run records a score for. Not the same as which
45+
#: partitions have metrics configured: a forecaster has training metrics
46+
#: and still must not be judged on the dates it was fitted on, because an
47+
#: in-sample fit statistic is not comparable with a forecast. A screen that
48+
#: offers one control per partition reads this instead of assuming all
49+
#: three exist.
2750
SCORED_SPLITS: tuple = (SplitEnum.TRAIN, SplitEnum.VALIDATION, SplitEnum.TEST)
2851

52+
#: The partition the kept model was fitted on, which is what decides which
53+
#: partitions of a finished run can still be predicted.
2954
FINAL_FIT_PARTITIONS: tuple = ("train",)
3055

3156
@classmethod
@@ -37,209 +62,9 @@ def get_metadata(cls) -> dict:
3762
dict
3863
Mapping with ``kind``, which says whether this strategy splits the
3964
dataset once or into folds, and ``scored_splits``, the partitions
40-
it writes metrics for. A screen that offers one control per
41-
partition reads the latter instead of assuming all three exist:
42-
a forecasting strategy scores no training partition, so asking it
43-
for train metrics finds nothing.
65+
it writes metrics for.
4466
"""
4567
return {
4668
"kind": cls.KIND,
4769
"scored_splits": [split.value for split in cls.SCORED_SPLITS],
4870
}
49-
50-
def __init__(
51-
self,
52-
factory: ModelFactory,
53-
optimizer: BaseOptimizer,
54-
goal_metric,
55-
**kwargs,
56-
):
57-
"""Initialize the evaluation strategy with model and optimization configuration.
58-
59-
Parameters
60-
----------
61-
factory : ModelFactory
62-
Factory owning the model to be trained/evaluated and the
63-
hyperparameters that are eligible for optimization.
64-
optimizer : BaseOptimizer
65-
The hyperparameter optimizer instance. Can be None if no HPO is needed.
66-
goal_metric : dict (obtained from Metric component registry)
67-
The target metric to optimize during hyperparameter search.
68-
**kwargs
69-
Additional keyword arguments passed from subclasses (ignored).
70-
"""
71-
self.factory: ModelFactory = factory
72-
self.model: BaseModel = factory.model
73-
self.run_optimizable_parameters = factory.optimizable_parameters
74-
self.optimizer: BaseOptimizer = optimizer
75-
self.goal_metric = goal_metric
76-
self._progress_reporter: Optional[
77-
Callable[[Optional[float], Optional[str]], None]
78-
] = None
79-
80-
def set_progress_reporter(
81-
self,
82-
progress_reporter: Optional[Callable[[Optional[float], Optional[str]], None]],
83-
) -> None:
84-
"""Register a callback that will receive progress updates."""
85-
self._progress_reporter = progress_reporter
86-
87-
def _report_progress(
88-
self, fraction: Optional[float], message: Optional[str] = None
89-
):
90-
"""Emit progress updates when a reporter has been registered."""
91-
if self._progress_reporter is not None:
92-
self._progress_reporter(fraction, message)
93-
94-
@abstractmethod
95-
def execute(self, x, y, run: Run, db):
96-
"""Execute the evaluation strategy on the provided data.
97-
98-
This is the main entry point for the evaluation process. Subclasses implement
99-
strategy-specific logic for:
100-
- Model training across folds/splits
101-
- Metric computation and persistence
102-
- HPO execution and result handling
103-
104-
Parameters
105-
----------
106-
x : DatasetDict or list of DastasetDict
107-
Input features. Structure depends on the evaluation strategy:
108-
- For holdout: DatasetDict with train/validation/test splits
109-
- For CV: List of DatasetDicts, one per fold with train/test splits
110-
y : dict or list
111-
Target labels. Same structure as x.
112-
run : Run
113-
Database model representing the current experiment run.
114-
db : Session
115-
SQLAlchemy database session for persisting results.
116-
117-
Returns
118-
-------
119-
tuple
120-
(trained_model, plot_paths) where:
121-
- trained_model : BaseModel - The trained model after evaluation
122-
- plot_paths : list[str] - Paths to generated HPO visualization files
123-
"""
124-
raise NotImplementedError("Subclasses must implement this method")
125-
126-
@abstractmethod
127-
def evaluate(self, model: BaseModel, x, y, metric):
128-
"""Evaluate the model on the given data and return the score.
129-
130-
This method is called during hyperparameter optimization to compute
131-
the objective function value for a given set of hyperparameters.
132-
Different strategies may compute metrics differently (e.g., across CV folds
133-
or on a validation split).
134-
135-
Parameters
136-
----------
137-
model : BaseModel
138-
The model instance to evaluate.
139-
x : DatasetDict or list of DastasetDict
140-
Input features for evaluation (structure depends on strategy).
141-
y : DatasetDict or list of DastasetDict
142-
Target labels for evaluation (structure depends on strategy).
143-
metric : Metric
144-
The metric instance to compute.
145-
146-
Returns
147-
-------
148-
float
149-
The computed metric value used as the optimization objective.
150-
"""
151-
raise NotImplementedError("Subclasses must implement this method")
152-
153-
def _do_hpo(self, model: BaseModel, x, y, run: Run, db):
154-
"""Execute hyperparameter optimization using the configured optimizer.
155-
156-
The optimizer uses the self.evaluate method as the objective function,
157-
allowing each strategy to define its own evaluation logic.
158-
159-
Parameters
160-
----------
161-
model : BaseModel
162-
The model instance to optimize.
163-
x : DatasetDict or list of DatasetDict
164-
Training input features (structure varies by strategy).
165-
y : DatasetDict or list of DatasetDict
166-
Training target labels (structure varies by strategy).
167-
run : Run
168-
Database run instance to update with optimized parameters.
169-
db : Session
170-
SQLAlchemy database session for transactions.
171-
172-
Returns
173-
-------
174-
BaseModel
175-
The model with the best hyperparameters found during optimization.
176-
"""
177-
from sqlalchemy.orm.attributes import flag_modified
178-
179-
# Execute hyperparameter optimization and get best model with parameters
180-
self.optimizer.optimize(
181-
model,
182-
x,
183-
y,
184-
self.run_optimizable_parameters,
185-
self.goal_metric,
186-
strategy=self.evaluate,
187-
)
188-
model = self.optimizer.get_model()
189-
best_params = self.optimizer.get_best_params()
190-
191-
# Update the run's parameters with the optimized hyperparameters
192-
old_parameters = run.parameters.copy()
193-
updated_parameters = self.factory.update_parameters(old_parameters, best_params)
194-
195-
# Persist the updated parameters to the database
196-
run.parameters = updated_parameters
197-
flag_modified(run, "parameters")
198-
db.commit()
199-
200-
return model
201-
202-
def _generate_hpo_plots(self, run: Run) -> List[str]:
203-
"""Generate and pickle the hyperparameter optimization plots to disk.
204-
205-
Shared by every evaluation strategy that runs HPO, so the plot
206-
generation logic only needs to be maintained in one place.
207-
208-
Parameters
209-
----------
210-
run : Run
211-
The run the plots belong to (used for the plot filenames).
212-
213-
Returns
214-
-------
215-
list[str]
216-
Paths to the pickled plot files, in the order produced by the
217-
optimizer.
218-
"""
219-
config = di["config"]
220-
plot_paths: List[str] = []
221-
222-
# Retrieve optimization trial data from the optimizer
223-
trials = self.optimizer.get_trials_values()
224-
225-
# Generate plot visualizations from the trial data
226-
# Plots typically show parameter importance, optimization history, etc.
227-
plot_filenames, plots = self.optimizer.create_plots(
228-
trials,
229-
run.id,
230-
n_params=len(self.run_optimizable_parameters),
231-
goal_metric=self.goal_metric,
232-
)
233-
234-
# Convert plots to serializable format (handles special objects, arrays, etc.)
235-
normalized_plots = normalize_artifacts(plots)
236-
237-
# Serialize and persist each plot to disk
238-
for filename, plot in zip(plot_filenames, normalized_plots, strict=False):
239-
plot_path = os.path.join(config["RUNS_PATH"], filename)
240-
# Serialize the plot object using pickle and write to disk
241-
with open(plot_path, "wb") as file:
242-
pickle.dump(plot, file)
243-
plot_paths.append(plot_path)
244-
245-
return plot_paths

0 commit comments

Comments
 (0)