Source code for fynance.models.uncertainty

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

""" Predictive-uncertainty wrappers around SignalModel-conforming nets.

Two complementary ways to turn a point-prediction net into a model that also
reports how *uncertain* it is about that prediction, both exposing the same
extra API (``predict_std``) on top of the usual ``SignalModel`` ``fit`` /
``predict``:

- :class:`DeepEnsemble` trains several independently initialized copies of a
  net and uses their disagreement as the uncertainty signal (Lakshminarayanan
  et al., 2017).
- :class:`MCDropout` wraps a *single* dropout-bearing net and keeps dropout
  active at inference, averaging several stochastic forward passes (Gal &
  Ghahramani, 2016, "Dropout as a Bayesian Approximation").

Both report an **epistemic** uncertainty proxy (how much the fit itself could
plausibly have differed), not the aleatoric noise inherent to the data. Neither
changes the training objective: they wrap an already-trained (or trainable)
``SignalModel`` and add ``predict_std`` / ``predict_members`` on top.

"""

from __future__ import annotations

# Built-in packages
import warnings
from typing import Any, Callable

# Third-party packages
import numpy as np
import torch
from numpy.typing import NDArray
from torch.nn.modules.dropout import _DropoutNd

__all__ = ['DeepEnsemble', 'MCDropout']


def _flatten(pred: Any) -> NDArray:
    """ Coerce a model prediction (tensor or array-like) to a flat 1-D array. """
    arr = pred.numpy() if hasattr(pred, 'numpy') else np.asarray(pred)

    return np.asarray(arr, dtype=np.float64).reshape(-1)


[docs] class DeepEnsemble: r""" Epistemic-uncertainty proxy from an ensemble of independent nets. Trains ``n_members`` independent copies of a ``SignalModel``-conforming net (built fresh by ``factory`` for each member) and aggregates their predictions. The **member-to-member spread** — :meth:`predict_std` — is a proxy for *epistemic* uncertainty (how much the fit itself could have differed had training gone slightly differently), as distinct from the *aleatoric* noise in the data itself. See Lakshminarayanan, Pritzel & Blundell (2017), "Simple and Scalable Predictive Uncertainty Estimation using Deep Ensembles". Conforms to the :class:`~fynance.core.protocols.SignalModel` protocol (``fit``/``predict``): it drops into the harness like any other model, with :meth:`predict_std` / :meth:`predict_members` as the extra uncertainty API. Parameters ---------- factory : callable No-argument callable returning a **fresh**, unfit ``SignalModel``-conforming net (e.g. a closure building a :class:`~fynance.models.mlp.MultiLayerPerceptron` and calling ``set_optimizer`` on it, or any object with ``fit(X, y)`` / ``predict(X)``). Called once per member, per :meth:`fit` call. n_members : int Number of independently trained members. Default 5. seed : int Base seed. Member ``i`` is built and trained under ``torch.manual_seed(seed + i)`` (and ``np.random.seed(seed + i)``), so the ensemble as a whole is reproducible while members differ from one another. Notes ----- **Determinism limits.** :meth:`fit` reseeds the *global* PyTorch and NumPy generators immediately before building and training each member. This reproducibly diversifies members whose randomness (parameter initialization, dropout masks, data shuffling) is drawn from those global generators — which covers :class:`~fynance.models.mlp.MultiLayerPerceptron` and the other :class:`~fynance.models._base.BaseNeuralNet` subclasses. A member that instead draws from its **own private** ``torch.Generator`` (for example :class:`~fynance.models.objective.ObjectiveModel`'s mini-batch shuffling, seeded from its own constructor ``seed`` argument rather than the global RNG) is unaffected by this reseed step: give such a ``factory`` a distinct explicit per-member seed if independent draws from that private generator are also required. Two full :meth:`fit` runs with the same ``DeepEnsemble.seed`` (and the same ``factory``/data) are otherwise reproducible. Examples -------- >>> import numpy as np >>> import torch >>> from fynance.models.mlp import MultiLayerPerceptron >>> from fynance.models.uncertainty import DeepEnsemble >>> rng = np.random.default_rng(0) >>> X = rng.standard_normal((40, 2)).astype(np.float32) >>> y = np.sin(X[:, :1]).astype(np.float32) >>> def factory(): ... net = MultiLayerPerceptron(2, 1, layers=[8], activation=torch.nn.Tanh) ... net.set_optimizer(torch.nn.MSELoss, torch.optim.Adam, lr=1e-2) ... return net >>> ens = DeepEnsemble(factory, n_members=3, seed=0).fit(X, y) >>> ens.predict(X).shape (40,) >>> ens.predict_members(X).shape (3, 40) See Also -------- fynance.models.uncertainty.MCDropout, fynance.models.ensemble.StackingEnsemble """ def __init__( self, factory: Callable[[], Any], n_members: int = 5, seed: int = 0, ): self.factory = factory self.n_members = n_members self.seed = seed self.members: list[Any] = []
[docs] def fit(self, X: NDArray, y: NDArray) -> DeepEnsemble: """ Build and train ``n_members`` independent copies of ``factory()``. Parameters ---------- X, y : array-like Training data forwarded verbatim to each member's ``fit(X, y)``. Returns ------- DeepEnsemble ``self``. """ self.members = [] for i in range(self.n_members): seed_i = self.seed + i torch.manual_seed(seed_i) np.random.seed(seed_i) member = self.factory() member.fit(X, y) self.members.append(member) return self
[docs] def predict_members(self, X: NDArray) -> NDArray: """ Per-member predictions, shape ``(n_members, T)``. Parameters ---------- X : array-like Inputs, shape ``(T, N)``. Returns ------- numpy.ndarray Stacked per-member predictions, shape ``(n_members, T)``. Raises ------ RuntimeError If called before :meth:`fit`. """ if not self.members: raise RuntimeError('DeepEnsemble must be fit before predict') return np.stack([_flatten(member.predict(X)) for member in self.members])
[docs] def predict(self, X: NDArray) -> NDArray: """ Ensemble mean prediction, shape ``(T,)``. """ return self.predict_members(X).mean(axis=0)
[docs] def predict_std(self, X: NDArray) -> NDArray: """ Cross-member standard deviation, shape ``(T,)`` — epistemic proxy. """ return self.predict_members(X).std(axis=0)
[docs] class MCDropout: r""" Epistemic-uncertainty proxy via Monte Carlo Dropout at inference. Wraps **one** dropout-bearing :class:`torch.nn.Module` (typically a :class:`~fynance.models._base.BaseNeuralNet` subclass such as :class:`~fynance.models.mlp.MultiLayerPerceptron` built with ``drop > 0``) and approximates a Bayesian posterior by keeping dropout **active at inference** and averaging ``n_samples`` stochastic forward passes (Gal & Ghahramani, 2016, "Dropout as a Bayesian Approximation"). The spread across those passes — :meth:`predict_std` — is the epistemic-uncertainty proxy. Technique --------- A plain ``model.eval()`` freezes dropout too (no stochasticity left to sample from). :meth:`predict` / :meth:`predict_std` instead first put the *whole* model in eval mode (so batch-norm and similar layers behave as at inference) and then explicitly switch back to train mode **only the ``torch.nn.Dropout*`` submodules**, so dropout masks are still resampled on every forward pass while everything else stays deterministic. The model's original mode is restored before returning (or raising). Conforms to the :class:`~fynance.core.protocols.SignalModel` protocol (``fit``/``predict``). Parameters ---------- model : torch.nn.Module A ``SignalModel``-conforming net (``fit(X, y)`` / ``predict(X)``) with at least one ``torch.nn.Dropout*`` submodule. A model with **no** dropout submodule still works but is pointless (every one of the ``n_samples`` passes is then identical, so :meth:`predict_std` is ``~0``): a ``UserWarning`` is raised at construction in that case. n_samples : int Number of stochastic forward passes averaged by :meth:`predict` / :meth:`predict_std`. Default 50. seed : int Seed (``torch.manual_seed``) applied before drawing the ``n_samples`` dropout masks, so both methods are reproducible for a given instance. Notes ----- Increasing ``n_samples`` reduces the variance of the **mean** estimate returned by :meth:`predict` (the mean of ``n`` i.i.d. stochastic passes has variance :math:`\sigma^2 / n` by the law of large numbers) but does not shrink :meth:`predict_std` itself, which converges to the model's intrinsic dropout-induced spread rather than to zero. Examples -------- >>> import numpy as np >>> import torch >>> from fynance.models.mlp import MultiLayerPerceptron >>> from fynance.models.uncertainty import MCDropout >>> rng = np.random.default_rng(0) >>> X = rng.standard_normal((40, 2)).astype(np.float32) >>> y = np.sin(X[:, :1]).astype(np.float32) >>> net = MultiLayerPerceptron(2, 1, layers=[8], activation=torch.nn.Tanh, ... drop=0.2) >>> _ = net.set_optimizer(torch.nn.MSELoss, torch.optim.Adam, lr=1e-2) >>> mc = MCDropout(net, n_samples=10, seed=0).fit(X, y) >>> mc.predict(X).shape (40,) >>> bool((mc.predict_std(X) >= 0).all()) True See Also -------- fynance.models.uncertainty.DeepEnsemble """ def __init__( self, model: torch.nn.Module, n_samples: int = 50, seed: int = 0, ): self.model = model self.n_samples = n_samples self.seed = seed if not any(isinstance(m, _DropoutNd) for m in model.modules()): warnings.warn( 'MCDropout wraps a model with no torch.nn.Dropout* submodule; ' 'every stochastic pass will be identical so predict_std will ' 'be ~0. Build the wrapped model with a nonzero `drop` ' 'probability for a meaningful uncertainty estimate.', stacklevel=2, )
[docs] def fit(self, X: NDArray, y: NDArray) -> MCDropout: """ Fit the wrapped model normally (dropout trains as usual). Parameters ---------- X, y : array-like Training data forwarded to the wrapped model's ``fit(X, y)``. Returns ------- MCDropout ``self``. """ self.model.fit(X, y) # type: ignore[operator] return self
def _to_tensor(self, X: NDArray | torch.Tensor) -> torch.Tensor: if isinstance(X, torch.Tensor): tensor = X else: tensor = torch.from_numpy(np.asarray(X)).clone() if tensor.is_floating_point() and tensor.dtype != torch.get_default_dtype(): return tensor.to(torch.get_default_dtype()) return tensor def _samples(self, X: NDArray) -> NDArray: """ Stack ``n_samples`` stochastic forward passes, shape ``(n_samples, T)``. """ Xt = self._to_tensor(X) was_training = self.model.training self.model.eval() dropout_modules = [m for m in self.model.modules() if isinstance(m, _DropoutNd)] for module in dropout_modules: module.train() torch.manual_seed(self.seed) samples = [] try: with torch.no_grad(): for _ in range(self.n_samples): out = self.model(Xt) samples.append(_flatten(out)) finally: self.model.train(was_training) return np.stack(samples)
[docs] def predict(self, X: NDArray) -> NDArray: """ Mean over ``n_samples`` stochastic forward passes, shape ``(T,)``. """ return self._samples(X).mean(axis=0)
[docs] def predict_std(self, X: NDArray) -> NDArray: """ Std over ``n_samples`` stochastic forward passes — epistemic proxy. """ return self._samples(X).std(axis=0)