Source code for fynance.models.loss.pinball

#!/usr/bin/env python3
# coding: utf-8

""" Differentiable pinball (quantile) loss for PyTorch training loops. """

from __future__ import annotations

# Built-in packages
from collections.abc import Sequence

# Third-party packages
import torch

# Local packages
from ._base import BaseLoss

__all__ = ['PinballLoss']


def _validate_taus(taus: float | Sequence[float]) -> tuple[float, ...]:
    """ Validate ``taus`` and return them as a sorted tuple of floats in (0, 1).

    Accepts a single float or any sequence of floats; a single float is
    wrapped into a one-element tuple. The returned tuple is sorted
    ascending so every caller (:class:`PinballLoss`,
    :class:`~fynance.models.quantile.QuantileModel`) shares one canonical
    column order for the quantile axis, regardless of the order ``taus``
    was given in.
    """
    if isinstance(taus, (int, float)):
        taus = (float(taus),)

    values = tuple(float(t) for t in taus)

    if not values:
        raise ValueError("taus must be non-empty")

    if any(not (0. < t < 1.) for t in values):
        raise ValueError(f"each tau must be strictly in (0, 1), got {values}")

    return tuple(sorted(values))


[docs] class PinballLoss(BaseLoss): r""" Pinball (quantile / check) loss for multi-quantile regression. For a target quantile :math:`\tau \in (0, 1)` and error :math:`e = y - \hat{y}_\tau`: .. math:: \mathcal{L}_\tau(e) = \max(\tau e,\ (\tau - 1) e) an asymmetric penalty: under-prediction (:math:`e > 0`) is penalized by :math:`\tau` and over-prediction (:math:`e < 0`) by :math:`1 - \tau`. At :math:`\tau = 0.5` it reduces to :math:`0.5 |e|`, i.e. half the mean absolute error. Minimizing it over a batch drives ``pred`` toward the :math:`\tau`-th conditional quantile of ``target``. With several ``taus`` at once, ``pred`` carries one column per quantile and the reported loss is the mean over quantiles *and* samples of the per-quantile pinball loss — the standard way to train a single multi-quantile head such as :class:`~fynance.models.quantile.QuantileModel`. Notes ----- ``taus`` are sorted ascending internally (see :func:`_validate_taus`), so ``pred``'s trailing axis is expected in that same ascending order. This loss does **not** enforce non-crossing quantiles (``q10 <= q50 <= q90``) — it is the plain per-column pinball loss; monotonicity is a predict-time concern (see :meth:`~fynance.models.quantile.QuantileModel.predict_quantiles`). Parameters ---------- taus : float or sequence of float, optional Target quantile(s) in ``(0, 1)``. Default ``(0.1, 0.5, 0.9)``. **kwargs Forwarded to :class:`BaseLoss` (``rf``, ``period``, ``eps``) — not used by this loss (it is not a ratio/annualized objective), kept only for API consistency across :mod:`fynance.models.loss`. Raises ------ ValueError If ``taus`` is empty or any value is not strictly in ``(0, 1)``. Examples -------- >>> import torch >>> from fynance.models.loss import PinballLoss >>> target = torch.tensor([1.0, 2.0, 3.0]) >>> pred = torch.tensor([[1.0], [2.0], [3.0]]) # perfect single-tau fit >>> PinballLoss(taus=0.5)(pred, target).item() 0.0 >>> pred_off = pred + 1.0 >>> loss = PinballLoss(taus=0.5)(pred_off, target) >>> loss.item() == 0.5 # tau=0.5 -> half the (constant) absolute error True See Also -------- fynance.models.quantile.QuantileModel """ def __init__( self, taus: float | Sequence[float] = (0.1, 0.5, 0.9), **kwargs, ): super().__init__(**kwargs) self.taus = _validate_taus(taus)
[docs] def forward( self, pred: torch.Tensor, target: torch.Tensor, ) -> torch.Tensor: r""" Compute the mean pinball loss across quantiles and samples. Parameters ---------- pred : torch.Tensor Predicted quantiles, shape ``(..., n_taus)``. target : torch.Tensor True values, shape ``(...,)`` — ``pred`` without its trailing quantile axis — broadcast against every quantile column. Returns ------- torch.Tensor Scalar loss: the mean over quantiles and samples of :math:`\max(\tau e, (\tau - 1) e)`. Raises ------ TypeError If ``pred`` or ``target`` is not a :class:`torch.Tensor`. ValueError If ``pred``'s trailing dimension does not equal the number of ``taus``, or ``target``'s shape does not match ``pred``'s shape without that trailing dimension. """ self._check_tensor(pred) self._check_tensor(target) n_taus = len(self.taus) if pred.shape[-1] != n_taus: raise ValueError( f"pred's trailing dimension ({pred.shape[-1]}) must equal " f"the number of taus ({n_taus})" ) if tuple(target.shape) != tuple(pred.shape[:-1]): raise ValueError( f"target shape {tuple(target.shape)} must equal pred's " f"shape without the trailing quantile axis " f"{tuple(pred.shape[:-1])}" ) taus = torch.as_tensor(self.taus, dtype=pred.dtype, device=pred.device) e = target.unsqueeze(-1) - pred return torch.maximum(taus * e, (taus - 1.) * e).mean()