Source code for fynance.models._base

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

""" Base classes for PyTorch neural network models.

Defines :class:`BaseNeuralNet`, a thin wrapper around
``torch.nn.Module`` that adds higher-level training, prediction and
serialization helpers, and the internal :func:`_type_convert` utility.

These classes are the foundation for all models in :mod:`fynance.models`:
the feed-forward :class:`~fynance.models.mlp.MultiLayerPerceptron`, the
recurrent variants in :mod:`fynance.models.rnn`,
:mod:`fynance.models.gru`, :mod:`fynance.models.lstm`, and the
walk-forward wrappers in :mod:`fynance.models.rolling`.

Main entry points
-----------------
- :class:`BaseNeuralNet` — base class with ``set_optimizer``,
  ``train_on``, ``predict``, ``set_data``, ``save_model``,
  ``load_model`` helpers.

"""

from __future__ import annotations

# Built-in packages
# Third-party packages
import numpy as np
import pandas as pd
import torch
import torch.nn
from numpy.typing import NDArray

__all__ = ['BaseNeuralNet']


_TYPE_HANDLER = {
    "int": torch.int64,
    "int64": torch.int64,
    "int32": torch.int32,
    "int16": torch.int16,
    "int8": torch.int8,
    "float": torch.float64,
    "float64": torch.float64,
    "float32": torch.float32,
    "float16": torch.float16,
}


[docs] class BaseNeuralNet(torch.nn.Module): """ Base object for neural network model with PyTorch. Thin wrapper around ``torch.nn.Module`` that bundles the boilerplate of training a financial model: criterion + optimizer setup, a one-batch ``train_on`` step, gradient-free ``predict``, data coercion from NumPy / pandas / tensor, and weight serialization. Subclass it (or one of the higher-level subclasses such as :class:`~fynance.models.mlp.MultiLayerPerceptron`) and implement the ``forward`` method to define a new architecture; everything else is inherited. Inherits of torch.nn.Module object with some higher level methods. **Public API contract (stable for the 1.x series)** - **Shapes** — feed-forward subclasses (e.g. :class:`~fynance.models.mlp.MultiLayerPerceptron`) expect ``X`` of shape ``(T, N)`` and ``y`` of shape ``(T, M)``, where ``T`` is the number of observations, ``N`` the number of input features and ``M`` the number of targets. Recurrent subclasses in :mod:`fynance.models.rnn`, :mod:`fynance.models.gru`, :mod:`fynance.models.lstm` consume ``X`` of shape ``(T, S, N)``, where ``S`` is the sequence length. - **Dtypes** — :meth:`set_data` coerces inputs through :meth:`_set_data`. The expected default for both ``X`` and ``y`` is a floating tensor (``torch.float32`` is the de-facto convention in the project doctests). Pass ``x_type`` / ``y_type`` explicitly to override; mismatched dtypes between ``X``, ``y`` and the model parameters will raise at the first ``forward`` pass. - **Device** — the wrapper does **not** move tensors automatically. Models live on CPU unless the caller explicitly calls ``.to(device)`` on both the module and the data tensors before training. - **State invariants** — typical lifecycle: :meth:`set_optimizer` → :meth:`set_data` (optional) → :meth:`train_on` (loops) → :meth:`predict`. ``train_on`` requires ``criterion`` and ``optimizer`` to be set; calling it before :meth:`set_optimizer` raises ``AttributeError``. - **Serialization** — :meth:`save_model` / :meth:`load_model` persist the module ``state_dict`` and, when ``save_optimizer`` / ``load_optimizer`` is True, the optimizer ``state_dict``. Random seeds, learning-rate schedulers and the cached training data (``self.X``, ``self.y``) are **not** serialized. Attributes ---------- criterion : torch.nn.modules.loss.Loss A loss function. optimizer : torch.optim.Optimizer An optimizer algorithm. N, M : int Respectively input and output dimension. Methods ------- set_optimizer train_on predict set_data save_model load_model See Also -------- fynance.models.mlp.MultiLayerPerceptron, fynance.models.rolling.RollMultiLayerPerceptron """ lr_scheduler = None optimizer = None seed_torch = None seed_numpy = None def __init__(self): """ Initialize. """ torch.nn.Module.__init__(self)
[docs] def set_optimizer(self, criterion, optimizer, params=None, **kwargs): """ Set the optimizer object. Set optimizer object with specified `criterion` as loss function and any `kwargs` as optional parameters. Parameters ---------- criterion : Callabletorch.nn.modules.loss A loss function. optimizer : torch.optim.Optimizer An optimizer algorithm. params : object or iterable object Layer of parameters to optimize or dicts defining parameter groups. If set to None then all parameters of model will be optimized. Default is None. **kwargs Keyword arguments of ``optimizer``, cf PyTorch documentation [1]_. Returns ------- BaseNeuralNet Self object model. References ---------- .. [1] https://pytorch.org/docs/stable/optim.html """ if params is None: params = self.parameters() elif isinstance(params, list): params = [{'params': p.parameters()} for p in params] else: params = params.parameters() self.criterion = criterion() self.optimizer = optimizer(params, **kwargs) return self
[docs] def set_lr_scheduler(self, lr_scheduler, **kwargs): """ Set dynamic learning rate. Parameters ---------- lr_scheduler : torch.optim.lr_scheduler._LRScheduler Method from ``torch.optim.lr_scheduler`` to wrap ``self.optimizer``, cf module ``torch.optim.lr_scheduler`` in PyTorch documentation [2]_. **kwargs Keyword arguments to pass to the learning rate scheduler. References ---------- .. [2] https://pytorch.org/docs/stable/optim.html#how-to-adjust-learning-rate """ if self.optimizer: self.lr_scheduler = lr_scheduler(self.optimizer, **kwargs) else: raise ValueError('You should specify an optimizer object, ' 'see `set_optimizer` method.') return self
[docs] @torch.enable_grad() def train_on(self, X: torch.Tensor, y: torch.Tensor) -> torch.Tensor: """ Trains the neural network model on a single batch. Runs one forward / backward / optimizer-step cycle on the batch ``(X, y)``. As a side effect, gradients of all parameters are zeroed before the forward pass and the optimizer state is advanced afterwards. If a learning-rate scheduler has been registered via :meth:`set_lr_scheduler`, its ``step`` is also called. Parameters ---------- X, y : torch.Tensor Respectively inputs and outputs to train model. Shapes must match what ``self.forward`` expects (see the class-level "Public API contract" section). Returns ------- torch.Tensor The loss tensor produced by ``self.criterion(self(X), y)``, with gradient already consumed by ``loss.backward()``. Raises ------ AttributeError If :meth:`set_optimizer` has not been called yet. """ self.optimizer.zero_grad() outputs = self(X) loss = self.criterion(outputs, y) loss.backward() self.optimizer.step() if self.lr_scheduler: self.lr_scheduler.step() return loss
[docs] @torch.no_grad() def predict(self, X: torch.Tensor) -> torch.Tensor: """ Predicts outputs of neural network model. Runs ``self.forward(X)`` under :func:`torch.no_grad`, so no autograd graph is built. The returned tensor is detached and lives on the same device as the model parameters. Parameters ---------- X : torch.Tensor Inputs to compute prediction. Same shape and dtype contract as :meth:`train_on`. Returns ------- torch.Tensor Outputs prediction (detached, gradient-free). """ return self(X).detach()
[docs] def set_data(self, X: NDArray | torch.Tensor | pd.DataFrame, y: NDArray | torch.Tensor | pd.DataFrame, x_type=None, y_type=None): """ Set data inputs and outputs. Coerces ``X`` and ``y`` to :class:`torch.Tensor` and caches them as ``self.X`` / ``self.y``. After the call the attributes ``self.T`` (number of observations), ``self.N`` (input columns) and ``self.M`` (output columns) are set. Parameters ---------- X, y : array-like Respectively input and output data. Accepted types: :class:`numpy.ndarray`, :class:`torch.Tensor`, :class:`pandas.DataFrame`. Shapes must be ``(T, N)`` and ``(T, M)`` respectively. x_type, y_type : torch.dtype, optional Target dtypes for the resulting tensors. Default is `None`, which preserves the input dtype. Returns ------- BaseNeuralNet ``self``, to allow chaining. Raises ------ ValueError If ``self.N`` / ``self.M`` were already set and ``X`` / ``y`` do not match, or if ``X`` and ``y`` have different lengths. """ if hasattr(self, 'N') and self.N != X.size(1): raise ValueError('X must have {} input columns'.format(self.N)) if hasattr(self, 'M') and self.M != y.size(1): raise ValueError('y must have {} output columns'.format(self.M)) self.X = self._set_data(X, dtype=x_type) self.y = self._set_data(y, dtype=y_type) self.T, self.N = self.X.size() T_veri, self.M = self.y.size() if self.T != T_veri: raise ValueError('{} time periods in X differents of {} time \ periods in y'.format(self.T, T_veri)) return self
[docs] def set_seed(self, seed_torch=None, seed_numpy=None): r""" Set seed for PyTorch and NumPy random number generator. Parameters ---------- seed_torch, seed_numpy : bool or int, optional If `seed` is an int :math:`0 < seed < 2^32` set respectively PyTorch and NumPy seed with the number. Otherwise if is True then choose a random number, else doesn't set seed. """ self.seed_torch = self._set_seed(seed_torch) self.seed_numpy = self._set_seed(seed_numpy) torch.manual_seed(self.seed_torch) np.random.seed(self.seed_numpy)
def _set_seed(self, seed): if isinstance(seed, int) and 0 <= seed < 2 ** 32: return seed return np.random.randint(0, 2 ** 32) def _set_data(self, X, dtype=None): """ Convert array-like data to tensor. """ # TODO : Verify dtype of data torch tensor if isinstance(X, np.ndarray): return torch.from_numpy(X) elif isinstance(X, pd.DataFrame): # TODO : Verify memory efficiancy return torch.from_numpy(X.values) elif isinstance(X, torch.Tensor): return X else: raise ValueError('Unkwnown data type: {}'.format(type(X)))
[docs] def save_model(self, path, save_optimizer=False): """ Save the model with this weights and parameters. Parameters ---------- path : str or os.PathLike object Path to save the model. save_optimizer : bool, optional If True, then save also the optimizer. """ state_dict = {"model": self.state_dict()} if save_optimizer: state_dict["optimizer"] = self.optimizer.state_dict() torch.save(state_dict, path)
[docs] def load_model(self, path, load_optimizer=False): """ Save the model with this weights and parameters. Parameters ---------- path : str or os.PathLike object Path to load the model. load_optimizer : bool, optional If True, then load also the optimizer. """ state_dict = torch.load(path) self.load_state_dict(state_dict['model']) if load_optimizer: if 'optimizer' not in state_dict: raise ValueError('No optimizer available, set `load_optimizer`' ' to False') elif getattr(self, 'optimizer', None) is None: raise ValueError('You should specify an optimizer object, ' 'see `set_optimizer` method.') self.optimizer.load_state_dict(state_dict['optimizer'])
def _type_convert(dtype): if dtype is np.float64: return torch.float64 elif dtype is np.float32: return torch.float32 elif dtype is np.float16: return torch.float16 elif dtype is np.uint8: return torch.uint8 elif dtype is np.int8: return torch.int8 elif dtype is np.int16: return torch.int16 elif dtype is np.int32: return torch.int32 elif dtype is np.int64: return torch.int64 else: raise ValueError('Unkwnown type: {}'.format(str(dtype)))