diff --git a/pytorch_forecasting/adapters/scaler_adapters.py b/pytorch_forecasting/adapters/scaler_adapters.py index a71126218..178a1aa75 100644 --- a/pytorch_forecasting/adapters/scaler_adapters.py +++ b/pytorch_forecasting/adapters/scaler_adapters.py @@ -199,3 +199,39 @@ def fit_transform_sequence( col = sub.fit_transform(col, X) if sub.fit_per_sequence else col columns.append(col.unsqueeze(-1)) return torch.cat(columns, dim=-1) + + def transform_sequence( + self, data: ArrayLike, X: pd.DataFrame = None + ) -> torch.Tensor: + """Transform with per-sequence sub-normalizers; leave the rest untouched. + + Counterpart to :meth:`fit_transform_sequence` for the decoder window, + which must reuse the state fitted on the encoder window rather than + refit. Non-per-sequence normalizers already applied their global state + during preprocessing, so their columns pass through unchanged. + + Parameters + ---------- + data : tensor, ndarray, or Series + Shape ``(pred_length,)`` or ``(pred_length, n_targets)``. + + Returns + ------- + torch.Tensor + Same shape as input. + """ + if not self.is_multi: + return ( + self.transform(data, X) if self.fit_per_sequence else _to_tensor(data) + ) + + t = _to_tensor(data) + if t.ndim == 1: + t = t.unsqueeze(-1) + + columns = [] + for idx, sub in enumerate(self._sub_adapters): + col = t[:, idx] + col = sub.transform(col, X) if sub.fit_per_sequence else col + columns.append(col.unsqueeze(-1)) + return torch.cat(columns, dim=-1) diff --git a/pytorch_forecasting/data/data_module/_encoder_decoder_data_module.py b/pytorch_forecasting/data/data_module/_encoder_decoder_data_module.py index c0fc21cc9..bb2ecc3ef 100644 --- a/pytorch_forecasting/data/data_module/_encoder_decoder_data_module.py +++ b/pytorch_forecasting/data/data_module/_encoder_decoder_data_module.py @@ -797,6 +797,10 @@ def __getitem__(self, idx): y = data["target"][decoder_indices] + # reuse the fit from the encoder window, do not refit on y + if normalizer is not None and normalizer.fit_per_sequence: + y = normalizer.transform_sequence(y) + if y.shape[-1] > 1: y = [y[:, i] for i in range(y.shape[-1])] else: diff --git a/tests/test_data/test_data_module.py b/tests/test_data/test_data_module.py index 739b63f71..50e761bcf 100644 --- a/tests/test_data/test_data_module.py +++ b/tests/test_data/test_data_module.py @@ -726,3 +726,33 @@ def test_group_normalizer_uses_groups(): mean1 = target1["target"].mean().abs() assert mean0 < 1.0, "Group 0 target should be normalized near 0" assert mean1 < 1.0, "Group 1 target should be normalized near 0" + + +def test_encoder_normalizer_normalizes_y(): + """`y` must be normalized by the encoder-fitted `EncoderNormalizer`.""" + n = 200 + df = pd.DataFrame( + { + "group": np.repeat([0, 1], n), + "time": np.tile(pd.date_range("2020-01-01", periods=n), 2), + "target": np.tile(np.arange(n, dtype=float) * 10.0 + 100.0, 2), + } + ) + ts = TimeSeries(data=df, time="time", target="target", group=["group"]) + dm = EncoderDecoderTimeSeriesDataModule( + time_series_dataset=ts, + max_encoder_length=12, + max_prediction_length=6, + batch_size=1, + target_normalizer=EncoderNormalizer(), + ) + dm.setup("fit") + + x, y = dm.train_dataset[0] + target_past = x["target_past"].squeeze(-1) + + assert y.abs().max() < 10.0, "y is still on the raw target scale" + + # series is linear, so y carries on from the encoder by one constant step + step = target_past[-1] - target_past[-2] + assert torch.allclose(y[0], target_past[-1] + step, atol=1e-4)