Source code for fynance.models.tcn

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

""" Temporal Convolutional Network (TCN) model.

Defines :class:`TemporalConvNet`, a causal dilated 1-D convolutional
network built on :class:`~fynance.models._base.BaseNeuralNet`. TCNs are a
strong sequence baseline: a stack of residual blocks with exponentially
growing dilation gives a large receptive field while staying **strictly
causal** (output at ``t`` depends only on inputs up to ``t`` — no
lookahead), which is exactly the invariant financial backtesting needs.

Reference: Bai, Kolter & Koltun, *An Empirical Evaluation of Generic
Convolutional and Recurrent Networks for Sequence Modeling* (2018).

Main entry points
-----------------
- :class:`TemporalConvNet` — configurable causal dilated-convolution net.

"""

from __future__ import annotations

# Third-party packages
import polars as pl
import torch
import torch.nn as nn
from numpy.typing import NDArray

# Local packages
from fynance.models._base import BaseNeuralNet

__all__ = ['TemporalConvNet']


class _Chomp1d(nn.Module):
    """ Remove the ``chomp`` trailing time steps left by causal padding. """

    def __init__(self, chomp: int):
        super().__init__()
        self.chomp = chomp

    def forward(self, x):
        if self.chomp == 0:
            return x

        return x[:, :, :-self.chomp].contiguous()


class _TemporalBlock(nn.Module):
    """ Residual block: two causal dilated convolutions + skip connection. """

    def __init__(self, c_in, c_out, kernel_size, dilation, drop):
        super().__init__()
        pad = (kernel_size - 1) * dilation
        self.net = nn.Sequential(
            nn.Conv1d(c_in, c_out, kernel_size, padding=pad, dilation=dilation),
            _Chomp1d(pad),
            nn.ReLU(),
            nn.Dropout(drop),
            nn.Conv1d(c_out, c_out, kernel_size, padding=pad, dilation=dilation),
            _Chomp1d(pad),
            nn.ReLU(),
            nn.Dropout(drop),
        )
        # 1x1 conv to match channels on the residual path when they differ
        self.downsample = nn.Conv1d(c_in, c_out, 1) if c_in != c_out else None
        self.relu = nn.ReLU()

    def forward(self, x):
        out = self.net(x)
        res = x if self.downsample is None else self.downsample(x)

        return self.relu(out + res)


[docs] class TemporalConvNet(BaseNeuralNet): r""" Causal dilated Temporal Convolutional Network. A stack of residual blocks (:class:`_TemporalBlock`) with dilation doubling at each level (1, 2, 4, …) followed by a linear read-out to the output dimension. Padding is causal (left-only, trimmed by :class:`_Chomp1d`), so the prediction at time ``t`` never sees ``t + 1`` — preserving the library's no-lookahead invariant. Configure the optimizer with :meth:`BaseNeuralNet.set_optimizer` (e.g. with :class:`fynance.models.loss.SharpeLoss`). Parameters ---------- X, y : array-like or int - If array-like, respectively the input and output data. - If an integer, respectively the input and output dimension. channels : list of int, optional Number of channels of each residual block (the depth is ``len(channels)``; dilation of block ``i`` is ``2 ** i``). Default ``[16, 16]``. kernel_size : int, optional Convolution kernel size. Default ``2``. drop : float, optional Dropout probability inside each block. Default ``0.``. Attributes ---------- tcn : torch.nn.Sequential The stack of temporal blocks. linear : torch.nn.Linear Final read-out from the last channel size to ``M`` outputs. See Also -------- fynance.models._base.BaseNeuralNet, fynance.models.lstm.LongShortTermMemory Examples -------- >>> import torch >>> from fynance.models.tcn import TemporalConvNet >>> _ = torch.manual_seed(0) >>> X = torch.randn(50, 3) >>> y = torch.randn(50, 1) >>> model = TemporalConvNet(X, y, channels=[8, 8], kernel_size=2) >>> out = model(X) >>> out.shape torch.Size([50, 1]) """ def __init__( self, X: NDArray | torch.Tensor | pl.DataFrame | int, y: NDArray | torch.Tensor | pl.DataFrame | int, channels: list[int] | None = None, kernel_size: int = 2, drop: float = 0., x_type=None, y_type=None, ): """ Initialize object. """ BaseNeuralNet.__init__(self) if isinstance(X, int) and isinstance(y, int): self.N, self.M = X, y else: self.set_data(X=X, y=y, x_type=x_type, y_type=y_type) # type: ignore[arg-type] channels = [16, 16] if channels is None else channels blocks = [] c_in = self.N for i, c_out in enumerate(channels): blocks.append( _TemporalBlock(c_in, c_out, kernel_size, dilation=2 ** i, drop=drop) ) c_in = c_out self.tcn = nn.Sequential(*blocks) self.linear = nn.Linear(c_in, self.M)
[docs] def forward(self, x): """ Forward pass. Parameters ---------- x : torch.Tensor Input window, shape ``(L, N)`` (time steps × features). Returns ------- torch.Tensor Per-step output, shape ``(L, M)``. """ # (L, N) -> (batch=1, channels=N, length=L) for Conv1d z = x.transpose(0, 1).unsqueeze(0) z = self.tcn(z) # (1, C, L) -> (L, C) z = z.squeeze(0).transpose(0, 1) return self.linear(z)