Skip to content

Commit cf4d89a

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

3 files changed

Lines changed: 332 additions & 85 deletions

File tree

Lines changed: 195 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,144 +1,263 @@
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"Multi-target lists are only supported in point mode, "
87+
f"got mode={mode!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(
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."
90155
)
91156
y_pred = y_pred.squeeze(-1)
157+
return y_pred, target
92158

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)
94199

95200
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,
97206
) -> torch.Tensor:
98-
"""
99-
Compute the loss for a single target, applying weights if provided.
100-
"""
101207
if weight is None:
102-
return self._loss(y_pred, target)
208+
return self._call_loss(y_pred, target, mode)
103209

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"
107213
try:
108-
loss = self._loss(y_pred, target)
214+
loss = self._call_loss(y_pred, target, mode)
109215
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
111224

112225
# Ensure weight has same dimensions as loss for multiplication
113226
if weight.ndim < loss.ndim:
114227
weight = weight.unsqueeze(-1).expand_as(loss)
115228
elif weight.ndim > loss.ndim:
116-
# Squeeze weight if it has more dimensions (e.g. [B, T, 1] vs [B, T])
117229
weight = weight.squeeze(-1)
118230

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+
)
120236

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":
124241
return weighted_loss.sum()
125-
else:
126-
# 'none' or others
127-
return weighted_loss
242+
# 'none' or others
243+
return weighted_loss
128244

129245
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)
136255
return y_pred
137256

138257
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

pytorch_forecasting/models/temporal_fusion_transformer/_tft_pkg_v2.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,21 @@ def get_test_train_params(cls):
5959
hidden_size=16,
6060
attention_head_size=4,
6161
),
62+
# non-point nn loss: mean/var head via free output_size
63+
dict(
64+
loss=nn.GaussianNLLLoss(),
65+
output_size=2,
66+
hidden_size=16,
67+
attention_head_size=2,
68+
),
69+
# class nn loss: logits head + discrete class_label target fixture
70+
dict(
71+
loss=nn.CrossEntropyLoss(),
72+
output_size=2,
73+
hidden_size=16,
74+
attention_head_size=2,
75+
datamodule_cfg=dict(target="class_label"),
76+
),
6277
dict(datamodule_cfg=dict(max_encoder_length=5, max_prediction_length=3)),
6378
dict(
6479
hidden_size=24,

0 commit comments

Comments
 (0)