"""Per-context-window min-max normalization used by the encoder path.""" from __future__ import annotations import torch import torch.nn as nn class WindowMinMax(nn.Module): """Per-context-window min-max normalization for the encoder path. Per-window statistics: x_min = x.min(1, keepdim=True)[0].detach() x_max = x.max(1, keepdim=True)[0].detach() x_range = (x_max - x_min).clamp(min=1e-5).detach() x_norm = (x - x_min) / x_range # → [0, 1] Stats are detached: gradients do NOT flow through normalization. Uses explicit ``transform`` / ``inverse_transform`` so the backbone can hold the (x_min, x_range) tuple across encoder + decoder and apply the inverse at loss / forecast time. """ def __init__(self, eps_clamp: float = 1e-5) -> None: super().__init__() self.eps_clamp = eps_clamp def transform( self, x: torch.Tensor ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """Normalize ``x: (B, L, 1)`` to [0, 1] per context window. Returns ``(x_normalized, x_min, x_range)`` with stats detached and ``x_range >= eps_clamp`` to avoid div-by-zero on constant series. Robust to NaN / +inf / -inf in ``x``: those positions are filled with 0 BEFORE computing min/max. """ x_filled = torch.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0) x_min = x_filled.min(dim=1, keepdim=True).values.detach() x_max = x_filled.max(dim=1, keepdim=True).values.detach() x_range = (x_max - x_min).clamp(min=self.eps_clamp).detach() x_normalized = (x_filled - x_min) / x_range return x_normalized, x_min, x_range @staticmethod def inverse_transform( y_pred_normalized: torch.Tensor, x_min: torch.Tensor, x_range: torch.Tensor, ) -> torch.Tensor: """Un-normalize ``(B, p)`` predictions back to raw magnitude. ``x_min`` / ``x_range`` come from a prior ``transform`` call and are ``(B, 1, 1)``; the trailing dim is squeezed for broadcasting against ``(B, p)``. """ return y_pred_normalized * x_range.squeeze(-1) + x_min.squeeze(-1)