Skip to content

Commit 1d4dd50

Browse files
Merge branch 'main' into add-nbeats-v2
2 parents 148c477 + a4068ff commit 1d4dd50

6 files changed

Lines changed: 1077 additions & 87 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Module for pytorch-forecasting adapters"""
2+
3+
from pytorch_forecasting.adapters.scaler_adapters import ScalerAdapter
4+
5+
__all__ = ["ScalerAdapter"]
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import pandas as pd
2+
import torch
3+
4+
from pytorch_forecasting._registry import all_objects
5+
from pytorch_forecasting.adapters.scaler_strategy import (
6+
ScalerStrategy,
7+
)
8+
from pytorch_forecasting.adapters.utils import (
9+
ArrayLike,
10+
_to_numpy,
11+
_to_tensor,
12+
)
13+
from pytorch_forecasting.data.encoders import (
14+
MultiNormalizer,
15+
)
16+
17+
18+
def get_scaler_strategy(scaler) -> ScalerStrategy:
19+
"""Single dispatch point: the only place that inspects scaler type."""
20+
discovered = all_objects(
21+
object_types="scaler_strategy",
22+
return_names=False,
23+
)
24+
for strategy_cls in discovered:
25+
if strategy_cls._is_applicable(scaler):
26+
return strategy_cls()
27+
return ScalerStrategy()
28+
29+
30+
class ScalerAdapter:
31+
"""
32+
Unified array-in / tensor-out interface for single and multi-target scalers.
33+
34+
Accepts torch.Tensor, np.ndarray, or pd.Series as input. Output is always
35+
a torch.Tensor. Type-specific behavior (sklearn scalers, GroupNormalizer,
36+
NaNLabelEncoder, EncoderNormalizer, ...) is delegated to a strategy chosen
37+
once at construction time (see ``adapters/scaler_strategy.py``).
38+
39+
40+
Parameters
41+
----------
42+
scaler : object
43+
The underlying scaling/encoding instance. Accepted types, their expected
44+
origins, and assumed API contracts are:
45+
46+
* scikit-learn scalers (from ``sklearn.preprocessing``):
47+
Implements ``.fit(X)` and ``.transform(X)``. Expects 2D
48+
numpy arrays of shape ``(n_samples, 1)``. Outputs numpy arrays.
49+
50+
* ``TorchNormalizer`` (from ``pytorch_forecasting.data.encoders``):
51+
Implements `.fit(data)` and `.transform(data)`. Expects 1D
52+
tensors or numpy arrays. Output can be tensor or array.
53+
54+
*``EncoderNormalizer`` (from ``pytorch_forecasting.data.encoders``):
55+
Implements `.fit(data)` and `.transform(data)`. Expects 1D
56+
tensors or numpy arrays. Output can be tensor or array.
57+
`EncoderNormalizer` signals that it must be fit per-sequence.
58+
59+
* ``NaNLabelEncoder`` (from `pytorch_forecasting.data.encoders`):
60+
Implements ``.fit(data)`` and ``.transform(data)``. Expects a
61+
1D ``pd.Series`` (or 1D array). Used for categorical encoding.
62+
63+
* ``GroupNormalizer`` (from ``pytorch_forecasting.data.encoders``):
64+
Implements ``.fit(data, X)`` and ``.transform(data, X)``. Expects
65+
`data` as a 1D ``pd.Series`` and `X` as a ``pd.DataFrame`` containing
66+
required group columns to compute grouped statistics.
67+
68+
* ``MultiNormalizer`` (from ``pytorch_forecasting.data.encoders``):
69+
Implements ``.fit(data, X)`` and ``.transform(data.T, X)``.
70+
Expects 2D array-like inputs of shape ``(n_samples, n_targets)``.
71+
Must expose a ``.normalizers`` attribute (iterable) containing the
72+
individual sub-normalizers for each target.
73+
"""
74+
75+
def __init__(self, scaler):
76+
self._scaler = scaler
77+
self.is_multi = isinstance(scaler, MultiNormalizer)
78+
79+
if self.is_multi:
80+
self._sub_adapters = [ScalerAdapter(norm) for norm in scaler.normalizers]
81+
self._strategy = None
82+
self.is_label_encoder = False
83+
self.fit_per_sequence = any(a.fit_per_sequence for a in self._sub_adapters)
84+
else:
85+
self._strategy = get_scaler_strategy(scaler) if scaler is not None else None
86+
self.is_label_encoder = (
87+
self._strategy.get_tag("is_label_encoder", None)
88+
if self._strategy
89+
else False
90+
)
91+
self.fit_per_sequence = (
92+
self._strategy.get_tag("fit_per_sequence", None)
93+
if self._strategy
94+
else False
95+
)
96+
97+
@property
98+
def label_encoder_mask(self) -> list[bool]:
99+
"""Per-target bool list indicating which sub-normalizers are label encoders."""
100+
if self.is_multi:
101+
return [sub.is_label_encoder for sub in self._sub_adapters]
102+
return [self.is_label_encoder]
103+
104+
def _prepare_input(self, data: ArrayLike) -> ArrayLike:
105+
"""Coerce data to the type the underlying scaler expects."""
106+
if self.is_multi:
107+
arr = _to_numpy(data)
108+
return arr if arr.ndim == 2 else arr[:, None]
109+
return self._strategy.prepare_input(data)
110+
111+
def fit(self, data: ArrayLike, X: pd.DataFrame = None) -> "ScalerAdapter":
112+
"""Fit the scaler.
113+
114+
Parameters
115+
----------
116+
data : tensor, ndarray, or Series
117+
Shape ``(n_samples,)`` for single-target or
118+
``(n_samples, n_targets)`` for multi-target.
119+
X : pd.DataFrame, optional
120+
Group columns. Required when scaler is GroupNormalizer or
121+
when MultiNormalizer contains GroupNormalizer sub-normalizers.
122+
"""
123+
if self._scaler is None:
124+
return self
125+
126+
prepared = self._prepare_input(data)
127+
if self.is_multi:
128+
self._scaler.fit(prepared, X)
129+
return self
130+
131+
self._strategy.fit(self._scaler, prepared, X)
132+
return self
133+
134+
def transform(self, data: ArrayLike, X: pd.DataFrame = None) -> torch.Tensor:
135+
"""Transform data, always returning a torch.Tensor.
136+
137+
Parameters
138+
----------
139+
data : tensor, ndarray, or Series
140+
Shape ``(n_samples,)`` for single-target or
141+
``(n_samples, n_targets)`` for multi-target.
142+
X : pd.DataFrame, optional
143+
Group columns. Required when scaler is GroupNormalizer or
144+
when MultiNormalizer contains GroupNormalizer sub-normalizers.
145+
146+
Returns
147+
-------
148+
torch.Tensor
149+
Same shape as input.
150+
"""
151+
if self._scaler is None:
152+
return _to_tensor(data)
153+
prepared = self._prepare_input(data)
154+
155+
if self.is_multi:
156+
results = self._scaler.transform(prepared.T, X)
157+
return torch.stack([_to_tensor(r) for r in results], dim=-1)
158+
159+
return self._strategy.transform(self._scaler, prepared, data, X)
160+
161+
def fit_transform(self, data: ArrayLike, X: pd.DataFrame = None) -> torch.Tensor:
162+
return self.fit(data, X).transform(data, X)
163+
164+
def fit_transform_sequence(
165+
self, data: ArrayLike, X: pd.DataFrame = None
166+
) -> torch.Tensor:
167+
"""Fit-and-transform only per-sequence sub-normalizers; transform the rest.
168+
169+
Used at ``__getitem__`` time for encoder windows. Non-per-sequence
170+
normalizers use their already-fitted global state.
171+
172+
For single-target adapters this collapses to fit_transform
173+
(EncoderNormalizer) or transform (everything else).
174+
175+
Parameters
176+
----------
177+
data : tensor, ndarray, or Series
178+
Shape ``(enc_length,)`` or ``(enc_length, n_targets)``.
179+
180+
Returns
181+
-------
182+
torch.Tensor
183+
Same shape as input.
184+
"""
185+
if not self.is_multi:
186+
return (
187+
self.fit_transform(data, X)
188+
if self.fit_per_sequence
189+
else _to_tensor(data)
190+
)
191+
192+
t = _to_tensor(data)
193+
if t.ndim == 1:
194+
t = t.unsqueeze(-1)
195+
196+
columns = []
197+
for idx, sub in enumerate(self._sub_adapters):
198+
col = t[:, idx]
199+
col = sub.fit_transform(col, X) if sub.fit_per_sequence else col
200+
columns.append(col.unsqueeze(-1))
201+
return torch.cat(columns, dim=-1)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from abc import abstractmethod
2+
3+
import pandas as pd
4+
import torch
5+
6+
from pytorch_forecasting import EncoderNormalizer, GroupNormalizer, NaNLabelEncoder
7+
from pytorch_forecasting.adapters.utils import (
8+
ArrayLike,
9+
_is_sklearn_transformer,
10+
_series_from,
11+
_to_numpy,
12+
_to_tensor,
13+
_was_2d_singleton,
14+
)
15+
from pytorch_forecasting.base._base_object import _BaseObject
16+
17+
18+
class ScalerStrategy(_BaseObject):
19+
"""Default behavior for scalers."""
20+
21+
_tags = {
22+
"object_type": "scaler_strategy",
23+
"fit_per_sequence": False,
24+
"is_label_encoder": False,
25+
}
26+
27+
@staticmethod
28+
@abstractmethod
29+
def _is_applicable(scaler) -> bool:
30+
"""Whether the scaler follows the given strategy."""
31+
32+
def prepare_input(self, data: ArrayLike) -> ArrayLike:
33+
t = _to_tensor(data)
34+
return t.squeeze(-1) if (t.ndim == 2 and t.shape[1] == 1) else t
35+
36+
def fit(self, scaler, prepared: ArrayLike, X: pd.DataFrame = None) -> None:
37+
scaler.fit(prepared)
38+
39+
def transform(
40+
self, scaler, prepared: ArrayLike, data: ArrayLike, X: pd.DataFrame = None
41+
) -> torch.Tensor:
42+
result = _to_tensor(scaler.transform(prepared))
43+
return result.unsqueeze(-1) if _was_2d_singleton(data) else result
44+
45+
46+
class EncoderNormalizerStrategy(ScalerStrategy):
47+
"""EncoderNormalizer must be re-fit per encoder window."""
48+
49+
_tags = {
50+
"fit_per_sequence": True,
51+
}
52+
53+
@staticmethod
54+
def _is_applicable(scaler) -> bool:
55+
return isinstance(scaler, EncoderNormalizer)
56+
57+
58+
class SklearnStrategy(ScalerStrategy):
59+
"""sklearn scalers expect/return 2D numpy arrays."""
60+
61+
@staticmethod
62+
def _is_applicable(scaler) -> bool:
63+
return _is_sklearn_transformer(scaler)
64+
65+
def prepare_input(self, data: ArrayLike) -> ArrayLike:
66+
return _to_numpy(data).reshape(-1, 1)
67+
68+
def fit(self, scaler, prepared: ArrayLike, X: pd.DataFrame = None) -> None:
69+
scaler.fit(prepared)
70+
71+
def transform(
72+
self, scaler, prepared: ArrayLike, data: ArrayLike, X: pd.DataFrame = None
73+
) -> torch.Tensor:
74+
original_shape = _to_numpy(data).shape
75+
result = scaler.transform(prepared).reshape(original_shape)
76+
return torch.tensor(result, dtype=torch.float32)
77+
78+
79+
class LabelEncoderStrategy(ScalerStrategy):
80+
_tags = {
81+
"is_label_encoder": True,
82+
}
83+
84+
@staticmethod
85+
def _is_applicable(scaler) -> bool:
86+
return isinstance(scaler, NaNLabelEncoder)
87+
88+
def prepare_input(self, data: ArrayLike) -> ArrayLike:
89+
return _series_from(data)
90+
91+
def transform(
92+
self, scaler, prepared: ArrayLike, data: ArrayLike, X: pd.DataFrame = None
93+
) -> torch.Tensor:
94+
result = _to_tensor(scaler.transform(prepared))
95+
return result.unsqueeze(-1) if _was_2d_singleton(data) else result
96+
97+
98+
class GroupNormalizerStrategy(ScalerStrategy):
99+
@staticmethod
100+
def _is_applicable(scaler) -> bool:
101+
return isinstance(scaler, GroupNormalizer)
102+
103+
def prepare_input(self, data: ArrayLike) -> ArrayLike:
104+
return _series_from(data)
105+
106+
def fit(self, scaler, prepared: ArrayLike, X: pd.DataFrame = None) -> None:
107+
assert X is not None, (
108+
"GroupNormalizer requires X (DataFrame with group columns) "
109+
"to be passed to fit()."
110+
)
111+
scaler.fit(prepared, X)
112+
113+
def transform(
114+
self, scaler, prepared: ArrayLike, data: ArrayLike, X: pd.DataFrame = None
115+
) -> torch.Tensor:
116+
assert X is not None, (
117+
"GroupNormalizer requires X (DataFrame with group columns) "
118+
"to be passed to transform()."
119+
)
120+
input_was_2d = isinstance(data, torch.Tensor) and data.ndim == 2
121+
result = _to_tensor(scaler.transform(prepared, X))
122+
return result.unsqueeze(-1) if input_was_2d else result
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import numpy as np
2+
import pandas as pd
3+
from sklearn.base import TransformerMixin
4+
import torch
5+
6+
from pytorch_forecasting.data.encoders import TransformMixIn
7+
8+
ArrayLike = torch.Tensor | np.ndarray | pd.Series
9+
10+
11+
def _to_numpy(data: ArrayLike) -> np.ndarray:
12+
"""Convert any array-like to numpy."""
13+
if isinstance(data, torch.Tensor):
14+
return data.detach().numpy()
15+
elif isinstance(data, pd.Series):
16+
return data.to_numpy()
17+
return np.asarray(data)
18+
19+
20+
def _to_tensor(data: ArrayLike, dtype=torch.float32) -> torch.Tensor:
21+
"""Convert any array-like to a float32 tensor."""
22+
if isinstance(data, torch.Tensor):
23+
return data.to(dtype)
24+
elif isinstance(data, pd.Series):
25+
return torch.tensor(data.to_numpy(), dtype=dtype)
26+
return torch.tensor(np.asarray(data), dtype=dtype)
27+
28+
29+
def _is_sklearn_transformer(scaler):
30+
is_sklearn_transform = isinstance(scaler, TransformerMixin)
31+
is_ptf_transform = isinstance(scaler, TransformMixIn)
32+
33+
return is_sklearn_transform and not is_ptf_transform
34+
35+
36+
def _series_from(data: ArrayLike) -> pd.Series:
37+
"""Prep for scalers that want a pd.Series (label encoder, group normalizer)."""
38+
if isinstance(data, pd.Series):
39+
return data
40+
np_data = _to_numpy(data)
41+
return pd.Series(np_data.squeeze() if np_data.ndim == 2 else np_data)
42+
43+
44+
def _was_2d_singleton(data: ArrayLike) -> bool:
45+
"""True if `data` is a torch.Tensor of shape (n, 1)."""
46+
return isinstance(data, torch.Tensor) and data.ndim == 2 and data.shape[1] == 1

0 commit comments

Comments
 (0)