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+
18import torch
29import 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
517class 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
0 commit comments