Skip to content

Commit d9db3db

Browse files
committed
feat: add a forecasting task that takes exogenous variables
ForecastingTask offers a model the date and nothing else, so a series that is driven by something measurable, a price, a promotion, the temperature, could only be forecast from its own history. ExogenousForecastingTask takes the same date column with one or more numeric variables beside it, which the per-group column contract can now express. It is a separate task rather than a wider ForecastingTask so that each stays honest about what it offers: a model reading only a date would silently drop the variables the user selected, and a model needing them cannot be fitted without them. What the two share, sorting the rows by date and reporting no labels, moves to TimeSeriesTask. The date column is now found by type rather than by position, since with variables alongside it need not come first. The temporal splitters, the forecasting evaluation strategies, the regression metrics and the Optuna optimizer serve the new task too.
1 parent 0479905 commit d9db3db

13 files changed

Lines changed: 443 additions & 146 deletions

DashAI/back/evaluation/forecasting_cv.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@ class ForecastingCrossValidationEvaluationStrategy(FoldEvaluationStrategy):
2121
origin forward through time.
2222
"""
2323

24-
COMPATIBLE_COMPONENTS = ["ForecastingTask"]
24+
COMPATIBLE_COMPONENTS = ["ForecastingTask", "ExogenousForecastingTask"]
2525
SCORED_SPLITS: tuple = (SplitEnum.VALIDATION, SplitEnum.TEST)

DashAI/back/evaluation/forecasting_holdout.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,5 @@ class ForecastingHoldoutEvaluationStrategy(SinglePartitionEvaluationStrategy):
4444
they must not be fitted on it.
4545
"""
4646

47-
COMPATIBLE_COMPONENTS = ["ForecastingTask"]
47+
COMPATIBLE_COMPONENTS = ["ForecastingTask", "ExogenousForecastingTask"]
4848
SCORED_SPLITS: tuple = (SplitEnum.VALIDATION, SplitEnum.TEST)

DashAI/back/initial_components.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,9 @@
459459
WilcoxonSRTest,
460460
)
461461
from DashAI.back.tasks.controlnet_task import ControlNetTask
462+
from DashAI.back.tasks.exogenous_forecasting_task import (
463+
ExogenousForecastingTask,
464+
)
462465
from DashAI.back.tasks.forecasting_task import ForecastingTask
463466
from DashAI.back.tasks.image_classification_task import ImageClassificationTask
464467

@@ -493,6 +496,7 @@ def get_initial_components():
493496
TranslationTask,
494497
RegressionTask,
495498
ForecastingTask,
499+
ExogenousForecastingTask,
496500
NaiveForecaster,
497501
SeasonalNaiveForecaster,
498502
ARIMA,

DashAI/back/metrics/regression_metric.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ class RegressionMetric(BaseMetric):
2525
# ForecastingTask measures the same thing: a continuous prediction against
2626
# a continuous truth. Listing it here is what makes MAE, RMSE and the rest
2727
# available to forecasting runs without reimplementing any of them.
28-
COMPATIBLE_COMPONENTS = ["RegressionTask", "ForecastingTask"]
28+
COMPATIBLE_COMPONENTS = [
29+
"RegressionTask",
30+
"ForecastingTask",
31+
"ExogenousForecastingTask",
32+
]
2933

3034

3135
def prepare_to_metric(

DashAI/back/optimizers/optuna_optimizer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ class OptunaOptimizer(BaseOptimizer):
225225
"TranslationTask",
226226
"RegressionTask",
227227
"ForecastingTask",
228+
"ExogenousForecastingTask",
228229
]
229230

230231
def __init__(self, n_trials=None, sampler=None, pruner=None):

DashAI/back/splitters/rolling_origin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ class RollingOriginSplitter(FoldSplitter):
151151

152152
SCHEMA = RollingOriginSplitterSchema
153153
TEST_SPLIT_STRATEGY: str = "temporal"
154-
COMPATIBLE_COMPONENTS = ["ForecastingTask"]
154+
COMPATIBLE_COMPONENTS = ["ForecastingTask", "ExogenousForecastingTask"]
155155
COMPATIBLE_INNER_SPLITTERS = ["RollingOriginSplitter"]
156156
DISPLAY_NAME: str = MultilingualString(
157157
en="Rolling Origin",

DashAI/back/splitters/temporal_holdout.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ class TemporalHoldoutSplitter(PartitionSplitter):
131131
"""
132132

133133
SCHEMA = TemporalHoldoutSplitterSchema
134-
COMPATIBLE_COMPONENTS = ["ForecastingTask"]
134+
COMPATIBLE_COMPONENTS = ["ForecastingTask", "ExogenousForecastingTask"]
135135
DISPLAY_NAME: str = MultilingualString(
136136
en="Temporal Holdout",
137137
es="Holdout Temporal",
@@ -150,9 +150,6 @@ def __init__(self, splits_data):
150150
proportions, and optionally previously computed indexes.
151151
"""
152152
super().__init__(splits_data)
153-
# The base class defaults these to values that would reintroduce
154-
# randomness, and the schema does not expose either, so they are
155-
# pinned here rather than trusted to arrive absent.
156153
self.shuffle = False
157154
self.stratify = False
158155

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from DashAI.back.core.utils import MultilingualString
2+
from DashAI.back.tasks.time_series_task import TimeSeriesTask
3+
from DashAI.back.types.value_types import Date, Float, Integer
4+
5+
6+
class ExogenousForecastingTask(TimeSeriesTask):
7+
"""Task for forecasting a time series with explanatory variables beside it.
8+
9+
The input is one ``Date`` column and at least one numeric column, and the
10+
output is one numeric column: the series to forecast. The extra numeric
11+
columns are the exogenous variables, the things measured alongside the
12+
series that are believed to move it. Price and a promotion flag against
13+
units sold, temperature against electricity demand, an advertising budget
14+
against enquiries.
15+
16+
Splitting this off from :class:`ForecastingTask` rather than widening it is
17+
what keeps each task honest about what it offers. A model that reads only
18+
a date cannot use a price column, so offering it here would silently drop
19+
the variables the user selected, and a model that needs explanatory
20+
variables cannot be fitted without them.
21+
22+
Forecasting with exogenous variables asks something of the data that
23+
forecasting from history alone does not: the variables have to be known
24+
for the periods being forecast. That is what makes the approach worth the
25+
trouble for a planned price or a published calendar, and what makes it a
26+
poor fit for anything that would itself have to be forecast first.
27+
"""
28+
29+
DESCRIPTION: str = MultilingualString(
30+
en=(
31+
"Predict the future values of a time series using explanatory "
32+
"variables measured alongside it. Takes one date column, one or "
33+
"more numeric variables, and the numeric series to forecast."
34+
),
35+
es=(
36+
"Predice los valores futuros de una serie temporal usando "
37+
"variables explicativas medidas junto a ella. Toma una columna de "
38+
"fecha, una o mas variables numericas y la serie numerica a "
39+
"pronosticar."
40+
),
41+
pt=(
42+
"Preve os valores futuros de uma serie temporal usando variaveis "
43+
"explicativas medidas ao lado dela. Recebe uma coluna de data, uma "
44+
"ou mais variaveis numericas e a serie numerica a prever."
45+
),
46+
de=(
47+
"Sagt die zukuenftigen Werte einer Zeitreihe mithilfe erklaerender "
48+
"Variablen voraus, die daneben gemessen werden. Nimmt eine "
49+
"Datumsspalte, eine oder mehrere numerische Variablen und die zu "
50+
"prognostizierende numerische Reihe."
51+
),
52+
zh=(
53+
"利用与时间序列一同测量的解释变量预测其未来值。"
54+
"接受一个日期列、一个或多个数值变量,以及要预测的数值序列。"
55+
),
56+
)
57+
DISPLAY_NAME: str = MultilingualString(
58+
en="Forecasting with Exogenous Variables",
59+
es="Pronostico con Variables Exogenas",
60+
pt="Previsao com Variaveis Exogenas",
61+
de="Zeitreihenprognose mit exogenen Variablen",
62+
zh="含外生变量的时间序列预测",
63+
)
64+
65+
metadata: dict = {
66+
"inputs": [
67+
{"types": [Date], "cardinality": 1},
68+
{"types": [Float, Integer], "cardinality": {"min": 1, "max": "n"}},
69+
],
70+
"outputs": [{"types": [Float, Integer], "cardinality": 1}],
71+
}
Lines changed: 7 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
1-
from typing import TYPE_CHECKING, List, Union
2-
31
from DashAI.back.core.utils import MultilingualString
4-
from DashAI.back.tasks.base_task import BaseTask
2+
from DashAI.back.tasks.time_series_task import TimeSeriesTask
53
from DashAI.back.types.value_types import Date, Float, Integer
64

7-
if TYPE_CHECKING:
8-
from datasets import DatasetDict
9-
from numpy import ndarray
10-
11-
from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset
12-
135

14-
class ForecastingTask(BaseTask):
6+
class ForecastingTask(TimeSeriesTask):
157
"""Task for predicting the future values of a single time series.
168
179
The input is one ``Date`` column and the output is one numeric column: the
@@ -20,7 +12,9 @@ class ForecastingTask(BaseTask):
2012
2113
That restriction is the point. Models that take a date and nothing more,
2214
such as ARIMA or exponential smoothing, are a different family from models
23-
that also take explanatory variables.
15+
that also take explanatory variables. Those belong to
16+
:class:`ExogenousForecastingTask`, which takes the same date column with
17+
any number of numeric variables beside it.
2418
2519
Two routes lead to a forecast in DashAI, and this is only one of them. The
2620
other is ``TimeSeriesWindowConverter``, which reshapes the same data into
@@ -29,8 +23,6 @@ class ForecastingTask(BaseTask):
2923
directly and cannot be expressed that way.
3024
"""
3125

32-
PREDICTS_FORWARD_ONLY: bool = True
33-
3426
DESCRIPTION: str = MultilingualString(
3527
en=(
3628
"Predict the future values of a time series from its own history. "
@@ -66,121 +58,6 @@ class ForecastingTask(BaseTask):
6658
)
6759

6860
metadata: dict = {
69-
"inputs_types": [Date],
70-
"outputs_types": [Float, Integer],
71-
"inputs_cardinality": 1,
72-
"outputs_cardinality": 1,
61+
"inputs": [{"types": [Date], "cardinality": 1}],
62+
"outputs": [{"types": [Float, Integer], "cardinality": 1}],
7363
}
74-
75-
def prepare_for_task(
76-
self,
77-
dataset: Union["DatasetDict", "DashAIDataset"],
78-
input_columns: List[str],
79-
output_columns: List[str],
80-
) -> "DashAIDataset":
81-
"""Convert the dataset to a DashAIDataset and validate its types.
82-
83-
Parameters
84-
----------
85-
dataset : DatasetDict or DashAIDataset
86-
Dataset to prepare.
87-
input_columns : list of str
88-
The single date column.
89-
output_columns : list of str
90-
The single numeric column holding the series.
91-
92-
Returns
93-
-------
94-
DashAIDataset
95-
Dataset with validated types, in date order.
96-
"""
97-
prepared = super().prepare_for_task(dataset, input_columns, output_columns)
98-
return self._sort_by_date(prepared, input_columns[0])
99-
100-
@staticmethod
101-
def _sort_by_date(dataset: "DashAIDataset", date_column: str) -> "DashAIDataset":
102-
"""Put the rows in date order.
103-
104-
Everything downstream reads row order as time order and none of it
105-
checks: the temporal splitter carves its partitions by position, and
106-
the models hand their values to statsmodels in the order they arrive.
107-
A file that is not sorted by its date column therefore produces
108-
partitions that are not periods of time and a model fitted on a
109-
scrambled series, with nothing reporting a problem.
110-
111-
This is the task's job rather than the splitter's. The splitter is
112-
handed the selected input columns, which on the windowed route through
113-
``TimeSeriesWindowConverter`` are lag columns with no date among them.
114-
115-
Sorting reads the format the column declares. Text order only matches
116-
time order for ISO layouts: as text, "01/02/2020" precedes
117-
"31/01/2020" while following it in time.
118-
119-
Parameters
120-
----------
121-
dataset : DashAIDataset
122-
The validated dataset.
123-
date_column : str
124-
The single input column, which the task has already checked is a
125-
``Date``.
126-
127-
Returns
128-
-------
129-
DashAIDataset
130-
The same rows and types, ordered by date.
131-
"""
132-
from DashAI.back.dataloaders.classes.dashai_dataset import to_dashai_dataset
133-
from DashAI.back.types.date_utils import DEFAULT_DATE_FORMAT, parse_date_column
134-
135-
date_format = (
136-
getattr(dataset.types[date_column], "format", None) or DEFAULT_DATE_FORMAT
137-
)
138-
frame = dataset.to_pandas()
139-
order = parse_date_column(frame[date_column], date_format).sort_values().index
140-
141-
if list(order) == list(frame.index):
142-
return dataset
143-
144-
return to_dashai_dataset(
145-
frame.loc[order].reset_index(drop=True), types=dict(dataset.types)
146-
)
147-
148-
def process_predictions(
149-
self, dataset: "DashAIDataset", predictions: "ndarray", output_column: str
150-
):
151-
"""Return the forecast values unchanged.
152-
153-
Parameters
154-
----------
155-
dataset : DashAIDataset
156-
Dataset used for training.
157-
predictions : np.ndarray
158-
Predictions from the model.
159-
output_column : str
160-
Output column.
161-
162-
Returns
163-
-------
164-
np.ndarray
165-
The predictions as they were produced. A forecast is already a
166-
number on the scale of the series, so there is nothing to decode.
167-
"""
168-
return predictions
169-
170-
def num_labels(self, dataset: "DashAIDataset", output_column: str) -> int | None:
171-
"""Report that this task has no labels.
172-
173-
Parameters
174-
----------
175-
dataset : DashAIDataset
176-
Dataset used for training.
177-
output_column : str
178-
Output column.
179-
180-
Returns
181-
-------
182-
int | None
183-
Always ``None``: the output is continuous, so there is no class
184-
count for a model to size itself against.
185-
"""
186-
return None

0 commit comments

Comments
 (0)