|
| 1 | +"""Adapter for native ``torch.nn`` loss modules in ptf-v2.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import copy |
| 6 | +from typing import Literal |
| 7 | + |
1 | 8 | import torch |
2 | 9 | import torch.nn as nn |
| 10 | +import torch.nn.functional as F |
| 11 | + |
| 12 | +_Mode = Literal["point", "class", "gaussian_nll"] |
| 13 | +_CLASS_LOSSES = (nn.CrossEntropyLoss, nn.NLLLoss) |
| 14 | +_GAUSSIAN_NLL_LOSSES = (nn.GaussianNLLLoss,) |
3 | 15 |
|
4 | 16 |
|
5 | 17 | class NNLossAdapter(nn.Module): |
6 | | - """Adapter to use PyTorch nn losses in ptf-v2. |
| 18 | + """Adapt a ``torch.nn`` loss module to the ptf-v2 loss API. |
7 | 19 |
|
8 | | - This class wraps a standard PyTorch loss (nn.Module) to handle the specific |
| 20 | + Wraps a standard PyTorch loss (nn.Module) to handle the specific |
9 | 21 | input formats used in pytorch-forecasting v2, such as (target, weight) tuples |
10 | 22 | and multi-target list of tensors. |
11 | 23 |
|
12 | | - Args: |
13 | | - loss (nn.Module): The PyTorch loss to wrap. |
| 24 | + The reshape mode is inferred automatically from the wrapped loss type: |
| 25 | +
|
| 26 | + * **point** — same-shape ``[B, T]`` after squeeze (default) |
| 27 | + * **class** — logits ``[B, T, C]`` vs labels ``[B, T]`` |
| 28 | + (``CrossEntropyLoss``, ``NLLLoss``) |
| 29 | + * **gaussian_nll** — mean/var head ``[B, T, 2]`` (``GaussianNLLLoss``) |
| 30 | +
|
| 31 | + Parameters |
| 32 | + ---------- |
| 33 | + loss : |
| 34 | + Native PyTorch loss, e.g. ``nn.MSELoss()``. |
14 | 35 | """ |
15 | 36 |
|
16 | 37 | def __init__(self, loss: nn.Module): |
17 | 38 | super().__init__() |
18 | | - self._loss = loss |
| 39 | + # deepcopy so we never mutate the caller's loss instance |
| 40 | + self._loss = copy.deepcopy(loss) |
| 41 | + self._reduction = getattr(loss, "reduction", "mean") |
| 42 | + self._mode = self._infer_mode(self._loss) |
| 43 | + |
| 44 | + @staticmethod |
| 45 | + def _infer_mode(loss: nn.Module) -> _Mode: |
| 46 | + if isinstance(loss, _CLASS_LOSSES): |
| 47 | + return "class" |
| 48 | + if isinstance(loss, _GAUSSIAN_NLL_LOSSES): |
| 49 | + return "gaussian_nll" |
| 50 | + return "point" |
19 | 51 |
|
20 | 52 | def forward( |
21 | 53 | self, |
22 | 54 | y_pred: torch.Tensor | list[torch.Tensor], |
23 | | - y_actual: torch.Tensor | tuple[torch.Tensor | list[torch.Tensor], torch.Tensor], |
| 55 | + y_actual: torch.Tensor |
| 56 | + | list[torch.Tensor] |
| 57 | + | tuple[torch.Tensor | list[torch.Tensor], torch.Tensor], |
24 | 58 | ) -> torch.Tensor: |
25 | 59 | """ |
26 | 60 | Forward pass of the adapter. |
27 | 61 |
|
28 | | - Args: |
29 | | - y_pred (torch.Tensor | list[torch.Tensor]): Model predictions. |
30 | | - Expected to be [B, T, N] for multi-target or |
31 | | - [B, T, 1] / [B, T] for single target. |
32 | | - y_actual (torch.Tensor | tuple): Actual values and optionally weights. |
33 | | - Can be a tensor, or a tuple (target, weight), where target |
34 | | - can be a list of tensors. |
| 62 | + Parameters |
| 63 | + ---------- |
| 64 | + y_pred : |
| 65 | + Model predictions. |
| 66 | +
|
| 67 | + * point: ``[B, T, 1]`` / ``[B, T]``, or ``[B, T, N]`` for multi-target |
| 68 | + * class: ``[B, T, C]`` logits |
| 69 | + * gaussian_nll: ``[B, T, 2]`` as ``(mean, raw_variance)`` |
| 70 | + y_actual : |
| 71 | + Targets, optionally as ``(target, weight)``. Multi-target uses a |
| 72 | + list of ``[B, T]`` tensors (point mode only). |
35 | 73 |
|
36 | | - Returns: |
37 | | - torch.Tensor: The computed and reduced loss. |
| 74 | + Returns |
| 75 | + ------- |
| 76 | + torch.Tensor |
| 77 | + Scalar (or unreduced) loss. |
38 | 78 | """ |
39 | | - # Handle y_actual as (target, weight) or just target |
40 | | - if isinstance(y_actual, (list, tuple)) and not isinstance( |
41 | | - y_actual, torch.Tensor |
42 | | - ): |
43 | | - if len(y_actual) == 2: |
44 | | - target, weight = y_actual |
45 | | - else: |
46 | | - target = y_actual[0] |
47 | | - weight = None |
48 | | - else: |
49 | | - target, weight = y_actual, None |
| 79 | + target, weight = self._unpack_y_actual(y_actual) |
| 80 | + mode = self._mode |
50 | 81 |
|
| 82 | + # multi-target scenario |
51 | 83 | if isinstance(target, list): |
52 | | - # Multi-target scenario |
| 84 | + if mode != "point": |
| 85 | + raise ValueError( |
| 86 | + f"Multi-target lists are only supported in point mode, " |
| 87 | + f"got mode={mode!r}." |
| 88 | + ) |
53 | 89 | if not isinstance(y_pred, torch.Tensor): |
54 | 90 | raise ValueError( |
55 | 91 | f"NNLossAdapter expected y_pred to be a torch.Tensor for " |
56 | 92 | f"multi-target, but got {type(y_pred)}. Standard multi-target " |
57 | 93 | f"in ptf-v2 expects y_pred of shape [B, T, N]." |
58 | 94 | ) |
59 | | - |
60 | 95 | # y_pred is [B, T, N], split along last dimension |
61 | | - y_preds = y_pred.split(1, dim=-1) |
62 | | - y_preds = [yp.squeeze(-1) for yp in y_preds] |
63 | | - |
| 96 | + y_preds = [yp.squeeze(-1) for yp in y_pred.split(1, dim=-1)] |
64 | 97 | if len(y_preds) != len(target): |
65 | 98 | raise ValueError( |
66 | 99 | f"Number of predictions ({len(y_preds)}) does not match " |
67 | 100 | f"number of targets ({len(target)})." |
68 | 101 | ) |
69 | 102 |
|
70 | | - total_loss = torch.tensor(0.0, device=y_pred.device) |
| 103 | + total_loss = torch.tensor(0.0, device=y_pred.device, dtype=y_pred.dtype) |
71 | 104 | for yp, t in zip(y_preds, target): |
72 | | - total_loss = total_loss + self._compute_loss(yp, t, weight) |
| 105 | + total_loss = total_loss + self._compute_loss(yp, t, weight, mode) |
73 | 106 | return total_loss |
74 | | - else: |
75 | | - # Single target scenario |
76 | | - if isinstance(y_pred, list): |
77 | | - # Error if list of predictions but single tensor target |
78 | | - raise ValueError( |
79 | | - "NNLossAdapter does not support list of predictions " |
80 | | - "with single target tensor." |
81 | | - ) |
82 | 107 |
|
| 108 | + # single-target scenario |
| 109 | + if isinstance(y_pred, list): |
| 110 | + raise ValueError( |
| 111 | + "NNLossAdapter does not support list of predictions " |
| 112 | + "with single target tensor." |
| 113 | + ) |
| 114 | + |
| 115 | + y_pred, target = self._prepare_inputs(y_pred, target, mode) |
| 116 | + return self._compute_loss(y_pred, target, weight, mode) |
| 117 | + |
| 118 | + @staticmethod |
| 119 | + def _unpack_y_actual( |
| 120 | + y_actual: torch.Tensor | list[torch.Tensor] | tuple, |
| 121 | + ) -> tuple[torch.Tensor | list[torch.Tensor], torch.Tensor | None]: |
| 122 | + if ( |
| 123 | + isinstance(y_actual, tuple) |
| 124 | + and len(y_actual) == 2 |
| 125 | + and torch.is_tensor(y_actual[1]) |
| 126 | + ): |
| 127 | + return y_actual[0], y_actual[1] |
| 128 | + # also allow (target, None) or len-2 list/tuple without weight tensor |
| 129 | + if isinstance(y_actual, (list, tuple)) and not isinstance( |
| 130 | + y_actual, torch.Tensor |
| 131 | + ): |
| 132 | + if len(y_actual) == 2 and ( |
| 133 | + y_actual[1] is None or torch.is_tensor(y_actual[1]) |
| 134 | + ): |
| 135 | + return y_actual[0], y_actual[1] |
| 136 | + if len(y_actual) == 1: |
| 137 | + return y_actual[0], None |
| 138 | + return y_actual, None |
| 139 | + |
| 140 | + def _prepare_inputs( |
| 141 | + self, |
| 142 | + y_pred: torch.Tensor, |
| 143 | + target: torch.Tensor, |
| 144 | + mode: _Mode, |
| 145 | + ) -> tuple[torch.Tensor, torch.Tensor]: |
| 146 | + if mode == "point": |
83 | 147 | if y_pred.ndim == 3: |
84 | 148 | if y_pred.size(-1) != 1: |
85 | 149 | raise ValueError( |
86 | | - f"NNLossAdapter only supports point predictions (H=1). " |
87 | | - f"Got y_pred shape {list(y_pred.shape)} with " |
88 | | - f"H={y_pred.size(-1)}. " |
89 | | - "For multi-horizon losses, use a ptf metrics loss instead." |
| 150 | + "NNLossAdapter only supports point predictions with " |
| 151 | + f"output_size=1 on the last dimension; got output_size=" |
| 152 | + f"{y_pred.size(-1)}. For classification logits use " |
| 153 | + "CrossEntropyLoss / NLLLoss. For Quantile / multi-output " |
| 154 | + "heads use a ptf metric." |
90 | 155 | ) |
91 | 156 | y_pred = y_pred.squeeze(-1) |
| 157 | + return y_pred, target |
92 | 158 |
|
93 | | - return self._compute_loss(y_pred, target, weight) |
| 159 | + if mode == "class": |
| 160 | + if y_pred.ndim != 3: |
| 161 | + raise ValueError( |
| 162 | + "Classification losses expect logits of shape " |
| 163 | + f"(batch, time, classes), got {tuple(y_pred.shape)}." |
| 164 | + ) |
| 165 | + if target.ndim != 2: |
| 166 | + raise ValueError( |
| 167 | + "Classification targets must have shape (batch, time), " |
| 168 | + f"got {tuple(target.shape)}." |
| 169 | + ) |
| 170 | + return y_pred.reshape(-1, y_pred.size(-1)), target.reshape(-1).long() |
| 171 | + |
| 172 | + # gaussian_nll |
| 173 | + if y_pred.ndim != 3 or y_pred.size(-1) != 2: |
| 174 | + raise ValueError( |
| 175 | + "GaussianNLLLoss expects predictions of shape " |
| 176 | + f"(batch, time, 2) as (mean, raw_variance); got {tuple(y_pred.shape)}." |
| 177 | + ) |
| 178 | + if target.ndim != 2: |
| 179 | + raise ValueError( |
| 180 | + "GaussianNLL targets must have shape (batch, time), " |
| 181 | + f"got {tuple(target.shape)}." |
| 182 | + ) |
| 183 | + return y_pred, target |
| 184 | + |
| 185 | + def _call_loss( |
| 186 | + self, |
| 187 | + y_pred: torch.Tensor, |
| 188 | + target: torch.Tensor, |
| 189 | + mode: _Mode, |
| 190 | + ) -> torch.Tensor: |
| 191 | + if mode == "gaussian_nll": |
| 192 | + mean = y_pred[..., 0] |
| 193 | + var = F.softplus(y_pred[..., 1]) + 1e-6 |
| 194 | + return self._loss(mean, target, var) |
| 195 | + if mode == "class" and isinstance(self._loss, nn.NLLLoss): |
| 196 | + # NLLLoss expects log-probabilities |
| 197 | + return self._loss(F.log_softmax(y_pred, dim=-1), target) |
| 198 | + return self._loss(y_pred, target) |
94 | 199 |
|
95 | 200 | def _compute_loss( |
96 | | - self, y_pred: torch.Tensor, target: torch.Tensor, weight: torch.Tensor | None |
| 201 | + self, |
| 202 | + y_pred: torch.Tensor, |
| 203 | + target: torch.Tensor, |
| 204 | + weight: torch.Tensor | None, |
| 205 | + mode: _Mode, |
97 | 206 | ) -> torch.Tensor: |
98 | | - """ |
99 | | - Compute the loss for a single target, applying weights if provided. |
100 | | - """ |
101 | 207 | if weight is None: |
102 | | - return self._loss(y_pred, target) |
| 208 | + return self._call_loss(y_pred, target, mode) |
103 | 209 |
|
104 | | - # Handle weighting |
105 | | - old_reduction = getattr(self._loss, "reduction", "mean") |
106 | | - self._loss.reduction = "none" |
| 210 | + old_reduction = getattr(self._loss, "reduction", None) |
| 211 | + if old_reduction is not None: |
| 212 | + self._loss.reduction = "none" |
107 | 213 | try: |
108 | | - loss = self._loss(y_pred, target) |
| 214 | + loss = self._call_loss(y_pred, target, mode) |
109 | 215 | finally: |
110 | | - self._loss.reduction = old_reduction |
| 216 | + if old_reduction is not None: |
| 217 | + self._loss.reduction = old_reduction |
| 218 | + |
| 219 | + # class mode flattens to (B*T,); flatten matching weights |
| 220 | + if mode == "class" and weight is not None: |
| 221 | + weight = weight.reshape(-1) |
| 222 | + elif mode == "gaussian_nll" and weight is not None and weight.ndim == 2: |
| 223 | + pass # already [B, T], matches per-element loss |
111 | 224 |
|
112 | 225 | # Ensure weight has same dimensions as loss for multiplication |
113 | 226 | if weight.ndim < loss.ndim: |
114 | 227 | weight = weight.unsqueeze(-1).expand_as(loss) |
115 | 228 | elif weight.ndim > loss.ndim: |
116 | | - # Squeeze weight if it has more dimensions (e.g. [B, T, 1] vs [B, T]) |
117 | 229 | weight = weight.squeeze(-1) |
118 | 230 |
|
119 | | - weighted_loss = loss * weight |
| 231 | + if weight.shape != loss.shape: |
| 232 | + raise ValueError( |
| 233 | + "Weight tensor must match per-element loss shape, " |
| 234 | + f"got loss {tuple(loss.shape)} and weight {tuple(weight.shape)}." |
| 235 | + ) |
120 | 236 |
|
121 | | - if old_reduction == "mean": |
122 | | - return weighted_loss.sum() / weight.sum() |
123 | | - elif old_reduction == "sum": |
| 237 | + weighted_loss = loss * weight |
| 238 | + if self._reduction == "mean": |
| 239 | + return weighted_loss.sum() / weight.sum().clamp(min=1e-8) |
| 240 | + if self._reduction == "sum": |
124 | 241 | return weighted_loss.sum() |
125 | | - else: |
126 | | - # 'none' or others |
127 | | - return weighted_loss |
| 242 | + # 'none' or others |
| 243 | + return weighted_loss |
128 | 244 |
|
129 | 245 | def to_prediction(self, y_pred: torch.Tensor, **kwargs) -> torch.Tensor: |
130 | | - """ |
131 | | - Convert network prediction into a point prediction. |
132 | | - """ |
133 | | - if y_pred.ndim == 3: |
134 | | - if y_pred.size(-1) == 1: |
135 | | - return y_pred.squeeze(-1) |
| 246 | + """Convert network prediction into a point prediction.""" |
| 247 | + del kwargs |
| 248 | + mode = self._mode |
| 249 | + if mode == "class" and y_pred.ndim == 3: |
| 250 | + return y_pred.argmax(dim=-1) |
| 251 | + if mode == "gaussian_nll" and y_pred.ndim == 3 and y_pred.size(-1) == 2: |
| 252 | + return y_pred[..., 0] |
| 253 | + if y_pred.ndim == 3 and y_pred.size(-1) == 1: |
| 254 | + return y_pred.squeeze(-1) |
136 | 255 | return y_pred |
137 | 256 |
|
138 | 257 | def to_quantiles(self, y_pred: torch.Tensor, **kwargs) -> torch.Tensor: |
139 | | - """ |
140 | | - Convert network prediction into a quantile prediction. |
141 | | - """ |
142 | | - if y_pred.ndim == 2: |
143 | | - return y_pred.unsqueeze(-1) |
144 | | - return y_pred |
| 258 | + """Convert network prediction into a quantile-shaped tensor.""" |
| 259 | + del kwargs |
| 260 | + point = self.to_prediction(y_pred) |
| 261 | + if point.ndim == 2: |
| 262 | + return point.unsqueeze(-1) |
| 263 | + return point |
0 commit comments