Source code for fynance.models.quantile

#!/usr/bin/env python
# -*- coding: utf-8 -*-

""" Multi-quantile regression head.

:class:`QuantileModel` trains a feed-forward trunk with one output per
target quantile on :class:`~fynance.models.loss.PinballLoss`, giving a
**distributional** forecast instead of a single point estimate. It conforms
to the ``SignalModel`` protocol (``fit``/``predict``): ``predict`` returns
the median (or nearest-to-0.5) quantile column — the usual point-forecast
contract — while :meth:`~QuantileModel.predict_quantiles` exposes the full
quantile band.

Non-crossing quantiles
-----------------------
A net trained independently per quantile column has no structural guarantee
that ``q10 <= q50 <= q90`` at every row (:class:`~fynance.models.loss.PinballLoss`
is the plain per-column pinball loss, with no crossing penalty). Instead,
:class:`QuantileModel` enforces monotonicity **at predict time** by sorting
the raw net output along the quantile axis. ``taus`` are kept sorted
ascending internally (see
:func:`~fynance.models.loss.pinball._validate_taus`), so after this sort
column ``i`` still lines up with the ``i``-th requested quantile.

"""

# Built-in
from __future__ import annotations

from collections.abc import Sequence
from typing import Any

# Third-party
import numpy as np
import torch
from numpy.typing import NDArray

# Local
from fynance.models.loss.pinball import PinballLoss, _validate_taus

__all__ = ['QuantileModel']


def _quantile_trunk(
    n_features: int,
    n_taus: int,
    layers: list[int],
    activation: type[torch.nn.Module] | None,
    activation_kwargs: dict[str, Any],
    drop: float | None,
    bias: bool,
) -> torch.nn.Module:
    r""" Feed-forward trunk: ``Linear -> [Dropout] -> [Activation]`` hidden
    blocks with a **linear** (unbounded) ``n_taus``-wide output head.

    Mirrors the architecture kwargs exposed by
    :class:`~fynance.models.mlp.MultiLayerPerceptron` (``layers``,
    ``activation``, ``drop``, ``bias``, ``activation_kwargs``), but — unlike
    that class, whose ``forward`` applies the activation to *every* layer
    including the output — leaves the output head linear, as
    :func:`fynance.models.regime_model._mlp` and
    :func:`fynance.models.objective._default_net` already do: a quantile
    regression target is real-valued and unbounded, and an output-side
    activation such as the default ``ReLU`` would clip it (verified to
    collapse training to a constant-zero net on a symmetric-around-0
    target).
    """
    mods: list[torch.nn.Module] = []
    dim = n_features
    for h in layers:
        mods.append(torch.nn.Linear(dim, h, bias=bias))
        if drop is not None:
            mods.append(torch.nn.Dropout(p=drop))
        if activation is not None:
            mods.append(activation(**activation_kwargs))
        dim = h
    mods.append(torch.nn.Linear(dim, n_taus, bias=bias))  # linear output head

    return torch.nn.Sequential(*mods)


[docs] class QuantileModel: r""" Multi-quantile regression ``SignalModel``. Trains a feed-forward trunk with one output column per ``tau`` on :class:`~fynance.models.loss.PinballLoss`. Unlike :class:`~fynance.models.objective.ObjectiveModel` (trained on ``positions * returns``), ``fit(X, y)`` reads ``y`` as an ordinary supervised target (e.g. the next-bar return), not a returns series to be combined with positions. Parameters ---------- taus : float or sequence of float, optional Target quantile(s) in ``(0, 1)``. Default ``(0.1, 0.5, 0.9)``. layers : list of int, optional Hidden layer sizes of the trunk. Default ``[16, 8]`` (a small nonlinear trunk — matching the ``ObjectiveModel`` / ``RegimeMoE`` convention of a usable-out-of-the-box default). activation : type[torch.nn.Module] or None, optional Activation class applied after each **hidden** layer (never after the linear output head — see :func:`_quantile_trunk`). Default :class:`torch.nn.ReLU`; pass ``None`` for a purely linear trunk. drop : float, optional Dropout probability after each hidden layer. Default ``None`` (no dropout). bias : bool, optional Whether the trunk's linear layers use a bias term. Default True. activation_kwargs : dict, optional Extra keyword arguments for ``activation``. Default ``{}``. lr : float, optional Learning rate for the optimizer. Default ``1e-3``. epochs : int, optional Full-batch training steps. Default ``200``. optimizer : type[torch.optim.Optimizer], optional Optimizer class. Default :class:`torch.optim.Adam`. seed : int, optional Seed for reproducible net initialization. Default ``0``. Attributes ---------- net : torch.nn.Module or None The trunk, built lazily on the first :meth:`fit` call (``None`` before that). Examples -------- >>> import numpy as np >>> rng = np.random.default_rng(0) >>> x = rng.uniform(-2, 2, size=300).astype(np.float32) >>> sigma = 0.1 + 0.3 * np.abs(x) >>> y = (x + sigma * rng.standard_normal(300)).astype(np.float32) >>> X = x.reshape(-1, 1) >>> model = QuantileModel( ... taus=(0.1, 0.5, 0.9), layers=[16, 8], epochs=200, lr=1e-2, seed=0, ... ).fit(X, y) >>> point = model.predict(X) >>> point.shape (300,) >>> q = model.predict_quantiles(X) >>> q.shape (300, 3) >>> bool(np.all(np.diff(q, axis=1) >= 0)) # non-crossing, enforced at predict time True See Also -------- fynance.models.loss.PinballLoss, fynance.models.objective.ObjectiveModel, fynance.models.regime_model.RegimeMoE """ def __init__( self, taus: float | Sequence[float] = (0.1, 0.5, 0.9), layers: list[int] = [16, 8], activation: type[torch.nn.Module] | None = torch.nn.ReLU, drop: float | None = None, bias: bool = True, activation_kwargs: dict[str, Any] = {}, lr: float = 1e-3, epochs: int = 200, optimizer: type[torch.optim.Optimizer] = torch.optim.Adam, seed: int = 0, ): self.taus = _validate_taus(taus) self._median_idx = min( range(len(self.taus)), key=lambda i: abs(self.taus[i] - 0.5) ) self.layers = list(layers) self.activation = activation self.drop = drop self.bias = bias self.activation_kwargs = dict(activation_kwargs) self.lr = lr self.epochs = epochs self.optimizer_cls = optimizer self.seed = seed self._loss = PinballLoss(taus=self.taus) self.net: torch.nn.Module | None = None self._optim: torch.optim.Optimizer | None = None
[docs] def fit(self, X: NDArray, y: NDArray) -> QuantileModel: """ Train the trunk to minimize the multi-quantile pinball loss. Parameters ---------- X : array-like, shape (T, F) Feature matrix. y : array-like, shape (T,) (or any shape reshaping to it) Supervised target aligned with ``X`` (e.g. the realized next-bar return) — not a returns series to combine with positions. Returns ------- QuantileModel ``self``. Raises ------ ValueError If ``X`` is not 2-D, or ``X`` and ``y`` have different lengths. """ Xa = np.asarray(X, dtype=np.float32) ya = np.asarray(y, dtype=np.float32).reshape(-1) if Xa.ndim != 2: raise ValueError(f"X must be 2-D (T, F), got shape {Xa.shape}") if ya.shape[0] != Xa.shape[0]: raise ValueError( "X and y must have the same length, got " f"{Xa.shape[0]} and {ya.shape[0]}" ) # Seed before building the net for reproducible initialization. torch.manual_seed(self.seed) self.net = _quantile_trunk( Xa.shape[1], len(self.taus), self.layers, self.activation, self.activation_kwargs, self.drop, self.bias, ) self._optim = self.optimizer_cls( self.net.parameters(), lr=self.lr, # type: ignore[call-arg] ) Xt = torch.as_tensor(Xa) yt = torch.as_tensor(ya) self.net.train() for _ in range(self.epochs): self._optim.zero_grad() pred = self.net(Xt) loss = self._loss(pred, yt) loss.backward() self._optim.step() return self
[docs] @torch.no_grad() def predict_quantiles(self, X: NDArray) -> NDArray: """ Return the full quantile band, shape ``(T, n_taus)``. Columns follow ``self.taus`` (sorted ascending). Non-crossing is enforced here (not during training) by sorting the raw, independently trained columns along the quantile axis. Parameters ---------- X : array-like, shape (T, F) Feature matrix. Returns ------- numpy.ndarray Quantile predictions, shape ``(T, n_taus)``, non-decreasing along axis 1. Raises ------ RuntimeError If called before :meth:`fit`. """ if self.net is None: raise RuntimeError("QuantileModel must be fit before predict_quantiles") Xt = torch.as_tensor(np.asarray(X, dtype=np.float32)) was_training = self.net.training self.net.eval() try: raw = self.net(Xt) finally: self.net.train(was_training) sorted_q, _ = torch.sort(raw, dim=-1) return sorted_q.cpu().numpy()
[docs] def predict(self, X: NDArray) -> NDArray: """ Point forecast: the tau=0.5 (or nearest) quantile column. Parameters ---------- X : array-like, shape (T, F) Feature matrix. Returns ------- numpy.ndarray Shape ``(T,)``, the median (or nearest-to-0.5 ``tau``) column of :meth:`predict_quantiles`. """ return self.predict_quantiles(X)[:, self._median_idx]