Skip to content

Commit de4912c

Browse files
committed
support broader losses and prameterize tests
Signed-off-by: Faakhir30 <zahidfaakhir@gmail.com>
1 parent 9c4b92f commit de4912c

3 files changed

Lines changed: 355 additions & 70 deletions

File tree

Lines changed: 179 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,149 @@
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+
18
import torch
29
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,)
315

416

517
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.
719
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
921
input formats used in pytorch-forecasting v2, such as (target, weight) tuples
1022
and multi-target list of tensors.
1123
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()``.
1435
"""
1536

1637
def __init__(self, loss: nn.Module):
1738
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"
1951

2052
def forward(
2153
self,
2254
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],
2458
) -> torch.Tensor:
2559
"""
2660
Forward pass of the adapter.
2761
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).
3573
36-
Returns:
37-
torch.Tensor: The computed and reduced loss.
74+
Returns
75+
-------
76+
torch.Tensor
77+
Scalar (or unreduced) loss.
3878
"""
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
5081

82+
# multi-target scenario
5183
if isinstance(target, list):
52-
# Multi-target scenario
84+
if mode != "point":
85+
raise ValueError(
86+
f"Error in NNLossAdapter: Multi-target lists are only supported"
87+
f"for point losses, got {self._loss.__class__.__name__!r}."
88+
)
5389
if not isinstance(y_pred, torch.Tensor):
5490
raise ValueError(
5591
f"NNLossAdapter expected y_pred to be a torch.Tensor for "
5692
f"multi-target, but got {type(y_pred)}. Standard multi-target "
5793
f"in ptf-v2 expects y_pred of shape [B, T, N]."
5894
)
59-
6095
# 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)]
6497
if len(y_preds) != len(target):
6598
raise ValueError(
6699
f"Number of predictions ({len(y_preds)}) does not match "
67100
f"number of targets ({len(target)})."
68101
)
69102

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)
71104
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)
73106
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-
)
82107

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":
83147
if y_pred.ndim == 3:
84148
if y_pred.size(-1) != 1:
85149
raise ValueError(
@@ -89,25 +153,73 @@ def forward(
89153
"For multi-horizon losses, use a ptf metrics loss instead."
90154
)
91155
y_pred = y_pred.squeeze(-1)
156+
return y_pred, target
92157

93-
return self._compute_loss(y_pred, target, weight)
158+
if mode == "class":
159+
if y_pred.ndim != 3:
160+
raise ValueError(
161+
"Classification losses expect logits of shape "
162+
f"(batch, time, classes), got {tuple(y_pred.shape)}."
163+
)
164+
if target.ndim != 2:
165+
raise ValueError(
166+
"Classification targets must have shape (batch, time), "
167+
f"got {tuple(target.shape)}."
168+
)
169+
return y_pred.reshape(-1, y_pred.size(-1)), target.reshape(-1).long()
170+
171+
# gaussian_nll
172+
if y_pred.ndim != 3 or y_pred.size(-1) != 2:
173+
raise ValueError(
174+
"GaussianNLLLoss expects predictions of shape "
175+
f"(batch, time, 2) as (mean, raw_variance); got {tuple(y_pred.shape)}."
176+
)
177+
if target.ndim != 2:
178+
raise ValueError(
179+
"GaussianNLL targets must have shape (batch, time), "
180+
f"got {tuple(target.shape)}."
181+
)
182+
return y_pred, target
183+
184+
def _call_loss(
185+
self,
186+
y_pred: torch.Tensor,
187+
target: torch.Tensor,
188+
mode: _Mode,
189+
) -> torch.Tensor:
190+
if mode == "gaussian_nll":
191+
mean = y_pred[..., 0]
192+
var = F.softplus(y_pred[..., 1]) + 1e-6
193+
return self._loss(mean, target, var)
194+
if mode == "class" and isinstance(self._loss, nn.NLLLoss):
195+
# NLLLoss expects log-probabilities
196+
return self._loss(F.log_softmax(y_pred, dim=-1), target)
197+
return self._loss(y_pred, target)
94198

95199
def _compute_loss(
96-
self, y_pred: torch.Tensor, target: torch.Tensor, weight: torch.Tensor | None
200+
self,
201+
y_pred: torch.Tensor,
202+
target: torch.Tensor,
203+
weight: torch.Tensor | None,
204+
mode: _Mode,
97205
) -> torch.Tensor:
98-
"""
99-
Compute the loss for a single target, applying weights if provided.
100-
"""
101206
if weight is None:
102-
return self._loss(y_pred, target)
207+
return self._call_loss(y_pred, target, mode)
103208

104-
# Handle weighting
105-
old_reduction = getattr(self._loss, "reduction", "mean")
106-
self._loss.reduction = "none"
209+
old_reduction = getattr(self._loss, "reduction", None)
210+
if old_reduction is not None:
211+
self._loss.reduction = "none"
107212
try:
108-
loss = self._loss(y_pred, target)
213+
loss = self._call_loss(y_pred, target, mode)
109214
finally:
110-
self._loss.reduction = old_reduction
215+
if old_reduction is not None:
216+
self._loss.reduction = old_reduction
217+
218+
# class mode flattens to (B*T,); flatten matching weights
219+
if mode == "class" and weight is not None:
220+
weight = weight.reshape(-1)
221+
elif mode == "gaussian_nll" and weight is not None and weight.ndim == 2:
222+
pass # already [B, T], matches per-element loss
111223

112224
# Ensure weight has same dimensions as loss for multiplication
113225
if weight.ndim < loss.ndim:
@@ -122,23 +234,29 @@ def _compute_loss(
122234
return weighted_loss.sum() / weight.sum()
123235
elif old_reduction == "sum":
124236
return weighted_loss.sum()
125-
else:
126-
# 'none' or others
127-
return weighted_loss
237+
# 'none' or others
238+
return weighted_loss
128239

129240
def to_prediction(self, y_pred: torch.Tensor, **kwargs) -> torch.Tensor:
130241
"""
131242
Convert network prediction into a point prediction.
132243
"""
133-
if y_pred.ndim == 3:
134-
if y_pred.size(-1) == 1:
135-
return y_pred.squeeze(-1)
244+
del kwargs
245+
mode = self._mode
246+
if mode == "class" and y_pred.ndim == 3:
247+
return y_pred.argmax(dim=-1)
248+
if mode == "gaussian_nll" and y_pred.ndim == 3 and y_pred.size(-1) == 2:
249+
return y_pred[..., 0]
250+
if y_pred.ndim == 3 and y_pred.size(-1) == 1:
251+
return y_pred.squeeze(-1)
136252
return y_pred
137253

138254
def to_quantiles(self, y_pred: torch.Tensor, **kwargs) -> torch.Tensor:
139255
"""
140256
Convert network prediction into a quantile prediction.
141257
"""
142-
if y_pred.ndim == 2:
143-
return y_pred.unsqueeze(-1)
144-
return y_pred
258+
del kwargs
259+
point = self.to_prediction(y_pred)
260+
if point.ndim == 2:
261+
return point.unsqueeze(-1)
262+
return point

pytorch_forecasting/models/temporal_fusion_transformer/_tft_pkg_v2.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ def get_test_train_params(cls):
5959
hidden_size=16,
6060
attention_head_size=4,
6161
),
62+
dict(
63+
loss=nn.GaussianNLLLoss(),
64+
output_size=2,
65+
hidden_size=16,
66+
attention_head_size=2,
67+
),
6268
dict(datamodule_cfg=dict(max_encoder_length=5, max_prediction_length=3)),
6369
dict(
6470
hidden_size=24,

0 commit comments

Comments
 (0)