Skip to content

Commit 6138f59

Browse files
Fixed develop conflicts again
2 parents c5cfedf + 0785244 commit 6138f59

65 files changed

Lines changed: 5834 additions & 119 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

DashAI/back/api/api_v1/endpoints/datasets.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2157,13 +2157,16 @@ async def validate_type_changes(
21572157
filepath_or_buffer=tmp_file_path, params=parsed_params, n_rows=1000
21582158
)
21592159

2160-
all_valid, errors = validate_multiple_type_changes(
2160+
all_valid, errors, resolved_dtypes = validate_multiple_type_changes(
21612161
sample_df, parsed_type_changes
21622162
)
21632163

21642164
return {
21652165
"valid": all_valid,
21662166
"errors": errors,
2167+
# A Date column's strptime format is detected from the data, so
2168+
# the frontend learns it here rather than choosing it.
2169+
"resolved_dtypes": resolved_dtypes,
21672170
}
21682171

21692172
finally:

DashAI/back/converters/simple_converters/time_series_window.py

Lines changed: 369 additions & 0 deletions
Large diffs are not rendered by default.

DashAI/back/dataloaders/classes/dashai_dataset.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -817,8 +817,23 @@ def transform_dataset_with_schema(
817817
# we are saving them as strings to preserve the original format.
818818
# Can modify classes in value_types.py
819819
# if want to use PyArrow date, time or timestamp types.
820+
#
821+
# Two dict shapes reach this function. The one built by
822+
# type inference and by get_columns_spec carries the
823+
# strptime format in "dtype"; the one a column emits
824+
# through to_string() carries it in "format" and leaves
825+
# "dtype" as the arrow type. Reading only "dtype" turned
826+
# the second shape's format into the literal "string".
827+
_format = info.get("format") or dtype
828+
if not _format:
829+
raise ValueError(
830+
f"Column '{column_name}' is typed as {_type} but "
831+
"carries no format. A date, time or timestamp "
832+
"column is stored as text plus a strptime format, "
833+
"so the format has to be resolved before saving."
834+
)
820835
dashai_types[column_name] = arrow_to_dashai_types(
821-
arrow_type=_type, format=dtype
836+
arrow_type=_type, format=_format
822837
)
823838
pa_type = to_arrow_types("string")
824839
dai_table[column_name] = table.column(column_name)

DashAI/back/evaluation/base_evaluation_strategy.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from kink import di
77

88
from DashAI.back.core.artifacts import normalize_artifacts
9+
from DashAI.back.core.enums.metrics import SplitEnum
910
from DashAI.back.dependencies.database.models import Run
1011
from DashAI.back.models.base_model import BaseModel
1112
from DashAI.back.models.model_factory import ModelFactory
@@ -22,6 +23,28 @@ class BaseEvaluationStrategy(metaclass=ABCMeta):
2223

2324
TYPE: Final[str] = "EvaluationStrategy"
2425

26+
# How this strategy divides the dataset. The frontend renders holdout
27+
# controls or fold controls from this rather than comparing class names,
28+
# which is what previously made a new strategy unreachable from the UI.
29+
KIND: str = "holdout"
30+
31+
# Which partitions this strategy records metrics for. Scoring the training
32+
# partition means predicting on rows the model was fitted on, which is a
33+
# fit statistic; a forecaster has no such thing to report.
34+
SCORED_SPLITS: tuple = (SplitEnum.TRAIN, SplitEnum.VALIDATION, SplitEnum.TEST)
35+
36+
@classmethod
37+
def get_metadata(cls) -> dict:
38+
"""Describe the strategy for the frontend.
39+
40+
Returns
41+
-------
42+
dict
43+
Mapping with ``kind``, which says whether this strategy splits the
44+
dataset once or into folds.
45+
"""
46+
return {"kind": cls.KIND}
47+
2548
def __init__(
2649
self,
2750
factory: ModelFactory,

DashAI/back/evaluation/cv.py

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from DashAI.back.splitters.base_splitter import BaseSplitter
1010

1111

12-
class CrossValidationEvaluationStrategy(BaseEvaluationStrategy):
12+
class FoldEvaluationStrategy(BaseEvaluationStrategy):
1313
"""Evaluation strategy implementing k-fold cross-validation with optional
1414
nested CV and HPO.
1515
@@ -25,6 +25,8 @@ class CrossValidationEvaluationStrategy(BaseEvaluationStrategy):
2525
- LAST/LAST_OUTER: Aggregated metrics (mean and std) for simple/nested CV
2626
"""
2727

28+
KIND: str = "cv"
29+
2830
def execute(self, x, y, run: Run, db):
2931
"""Execute k-fold cross-validation with optional nested CV and HPO.
3032
@@ -107,9 +109,10 @@ def execute(self, x, y, run: Run, db):
107109
model.train(x_fold["train"], y_fold["train"])
108110

109111
# Compute and store metrics for this fold
110-
model.calculate_metrics(
111-
split=SplitEnum.TRAIN, level=LevelEnum.FOLD, fold_index=i
112-
)
112+
if SplitEnum.TRAIN in self.SCORED_SPLITS:
113+
model.calculate_metrics(
114+
split=SplitEnum.TRAIN, level=LevelEnum.FOLD, fold_index=i
115+
)
113116
model.calculate_metrics(
114117
split=SplitEnum.VALIDATION, level=LevelEnum.FOLD, fold_index=i
115118
)
@@ -200,7 +203,11 @@ def evaluate(self, model, input_dataset, output_dataset, metric, **kwargs):
200203
model.train(x_fold["train"], y_fold["train"])
201204

202205
# Compute metrics on both training and validation sets
203-
train_scores = model.compute_metrics(split=SplitEnum.TRAIN)
206+
train_scores = (
207+
model.compute_metrics(split=SplitEnum.TRAIN)
208+
if SplitEnum.TRAIN in self.SCORED_SPLITS
209+
else {}
210+
)
204211
validation_scores = model.compute_metrics(split=SplitEnum.VALIDATION)
205212

206213
# Collect the goal metric value from this fold
@@ -228,11 +235,12 @@ def evaluate(self, model, input_dataset, output_dataset, metric, **kwargs):
228235
}
229236

230237
# Persist averaged metrics as TRIAL level (intermediate HPO result)
231-
model._save_metrics(
232-
results=averaged_train_results,
233-
split=SplitEnum.TRAIN,
234-
level=LevelEnum.TRIAL,
235-
)
238+
if SplitEnum.TRAIN in self.SCORED_SPLITS:
239+
model._save_metrics(
240+
results=averaged_train_results,
241+
split=SplitEnum.TRAIN,
242+
level=LevelEnum.TRIAL,
243+
)
236244
model._save_metrics(
237245
results=averaged_validation_results,
238246
split=SplitEnum.VALIDATION,
@@ -313,9 +321,10 @@ def _nested_cv(self, run_id, model, input_dataset, output_dataset, db):
313321
outer_model.calculate_metrics(
314322
split=SplitEnum.VALIDATION, level=LevelEnum.OUTER_FOLD, fold_index=i
315323
)
316-
outer_model.calculate_metrics(
317-
split=SplitEnum.TRAIN, level=LevelEnum.OUTER_FOLD, fold_index=i
318-
)
324+
if SplitEnum.TRAIN in self.SCORED_SPLITS:
325+
outer_model.calculate_metrics(
326+
split=SplitEnum.TRAIN, level=LevelEnum.OUTER_FOLD, fold_index=i
327+
)
319328

320329
# Aggregate outer fold metrics
321330
# Compute mean and std of OUTER_FOLD metrics and store as LAST_OUTER level
@@ -406,3 +415,20 @@ def _aggregate_fold_metrics(
406415

407416
# Persist aggregated metrics to database
408417
db.commit()
418+
419+
420+
class CrossValidationEvaluationStrategy(FoldEvaluationStrategy):
421+
"""Score a model across folds, recording train and validation for each.
422+
423+
The ordinary cross-validation evaluation. Not offered for
424+
``ForecastingTask``, whose folds have no in-sample score to report;
425+
``ForecastingCrossValidationEvaluationStrategy`` handles that.
426+
"""
427+
428+
COMPATIBLE_COMPONENTS = [
429+
"TabularClassificationTask",
430+
"TextClassificationTask",
431+
"ImageClassificationTask",
432+
"TranslationTask",
433+
"RegressionTask",
434+
]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Cross-validation for models that forecast a series from its own history."""
2+
3+
from DashAI.back.core.enums.metrics import SplitEnum
4+
from DashAI.back.evaluation.cv import FoldEvaluationStrategy
5+
6+
7+
class ForecastingCrossValidationEvaluationStrategy(FoldEvaluationStrategy):
8+
"""Rolling origin cross-validation that records no in-sample metrics.
9+
10+
Only one thing separates this from the ordinary cross-validation
11+
strategy: the training partition of a fold is not scored. Scoring it would
12+
mean asking the model about dates it was fitted on, which is a fit
13+
statistic rather than a forecast and is not comparable with the validation
14+
score of the same fold.
15+
16+
Nothing else needs to change, and that is worth stating because it was not
17+
obvious. Each fold already trains on everything before its own validation
18+
window, and the final refit already uses the whole pool of rows outside
19+
the reserved tail, so this strategy never had the horizon problem that
20+
holdout did. Pair it with ``RollingOriginSplitter``, whose folds walk the
21+
origin forward through time.
22+
"""
23+
24+
COMPATIBLE_COMPONENTS = ["ForecastingTask"]
25+
SCORED_SPLITS: tuple = (SplitEnum.VALIDATION, SplitEnum.TEST)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Holdout evaluation for models that forecast a series from its own history."""
2+
3+
from DashAI.back.core.enums.metrics import SplitEnum
4+
from DashAI.back.evaluation.holdout import SinglePartitionEvaluationStrategy
5+
6+
7+
class ForecastingHoldoutEvaluationStrategy(SinglePartitionEvaluationStrategy):
8+
"""Holdout evaluation that treats validation as history rather than a sample.
9+
10+
Two things the ordinary holdout strategy assumes are wrong for a
11+
forecaster, and both of them are decisions about evaluation rather than
12+
about any model.
13+
14+
**The training partition is not scored.** Scoring it would mean asking the
15+
model about dates it was fitted on. That is an in-sample fit statistic,
16+
which is a real diagnostic but is not comparable with a forecast made
17+
several steps out; showing the two side by side in one results table
18+
invites exactly that comparison. Only validation and test are recorded.
19+
20+
**The kept model is fitted through validation.** For most tasks the
21+
validation partition is a held out sample that has to stay out of the fit.
22+
For a forecaster it is simply the most recent stretch of the series, and
23+
the stretch nearest to whatever comes next. Leaving it out makes the model
24+
reach across the whole validation window before arriving at the first test
25+
row, so the test metrics describe a longer horizon than the one being
26+
asked about.
27+
28+
The validation metrics are still measured on a model fitted on training
29+
data alone, which is what makes them honest: they are recorded before the
30+
refit. So the two columns in the results table answer different questions,
31+
and both answer them fairly.
32+
33+
validation metrics <- model fitted on train
34+
test metrics <- model fitted on train + validation
35+
36+
Hyperparameter search is untouched. Its trials are scored on validation,
37+
so they must not be fitted on it.
38+
"""
39+
40+
COMPATIBLE_COMPONENTS = ["ForecastingTask"]
41+
SCORED_SPLITS: tuple = (SplitEnum.VALIDATION, SplitEnum.TEST)
42+
43+
def execute(self, x, y, run, db):
44+
"""Score validation on a trial fit, then refit and score test.
45+
46+
Parameters
47+
----------
48+
x : DatasetDict
49+
Input partitions, keyed by split name.
50+
y : DatasetDict
51+
Target partitions, keyed by split name.
52+
run : Run
53+
Database model representing the current run.
54+
db : Session
55+
SQLAlchemy session used to persist metrics.
56+
57+
Returns
58+
-------
59+
tuple
60+
The trained model and the paths of any HPO plots.
61+
"""
62+
plot_paths = []
63+
model = self.model
64+
65+
model.x_data = x
66+
model.y_data = y
67+
68+
if self.optimizer and self.run_optimizable_parameters:
69+
self._report_progress(0.2, "Hyperparameter optimization")
70+
model = self._do_hpo(model, x, y, run, db)
71+
plot_paths = self._generate_hpo_plots(run)
72+
73+
# Fitted on training data only, so the validation score below measures
74+
# a model that has not seen the rows it is being scored on.
75+
self._report_progress(0.5, "Training")
76+
model.train(x["train"], y["train"])
77+
78+
self._report_progress(0.8, "Computing validation metrics")
79+
self._calculate_metrics_if_missing(model, run, db, SplitEnum.VALIDATION)
80+
81+
# Now the model that gets kept: the same configuration, refitted with
82+
# the validation rows included, since for a series they are history.
83+
self._report_progress(0.9, "Refitting on train and validation")
84+
self._fit_final_model(model, x, y)
85+
86+
self._report_progress(0.95, "Computing test metrics")
87+
self._calculate_metrics_if_missing(model, run, db, SplitEnum.TEST)
88+
89+
return model, plot_paths
90+
91+
def _fit_final_model(self, model, x, y):
92+
"""Fit the kept model on the training and validation rows together.
93+
94+
Parameters
95+
----------
96+
model : BaseModel
97+
The model to fit.
98+
x : DatasetDict
99+
Input partitions.
100+
y : DatasetDict
101+
Target partitions.
102+
"""
103+
validation_x = x.get("validation")
104+
validation_y = y.get("validation")
105+
106+
if validation_x is None or validation_y is None or len(validation_x) == 0:
107+
model.train(x["train"], y["train"])
108+
return
109+
110+
extend = type(model)._extend
111+
model.train(extend(x["train"], validation_x), extend(y["train"], validation_y))

0 commit comments

Comments
 (0)