"""Base class for descriptor transformers."""
# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
from abc import ABC
import numpy as np
from skfolio.base import BaseAssetPanelTransformer, BaseComposition
from skfolio.containers import AssetPanel
from skfolio.typing import BoolArray, FloatArray
from skfolio.utils.tools import (
_validate_non_negative_integer,
_validate_positive_integer,
)
from skfolio.utils.validation import validate_asset_panel
__all__ = ["BaseDescriptor"]
from sklearn import utils as sku
from sklearn.utils import metadata_routing as skm
[docs]
class BaseDescriptor(BaseAssetPanelTransformer, ABC):
"""Base class for all descriptor transformers.
A descriptor takes an :class:`~skfolio.containers.AssetPanel` and returns one raw
descriptor value per observation and asset, usually with shape
`(n_observations, n_assets)`. Descriptors are the inputs used by factor exposure
estimators such as :class:`~skfolio.factor_exposure.FixedWeightedFactor`.
Descriptors follow the :class:`~skfolio.base.BaseAssetPanelTransformer`
protocol:
- Batch-only descriptors implement only `fit_transform`.
- Stateless descriptors declare `stateless=True` and implement only
`fit_transform`. The base class adds `partial_fit_transform` by
delegating to `fit_transform`.
- Online descriptors implement both `fit_transform` and
`partial_fit_transform`. `fit_transform` starts from a clean state;
`partial_fit_transform` continues from the current state.
Examples include direct field access (:class:`Passthrough`), point-in-time
ratios (:class:`BookToPrice`), and history-dependent descriptors
(:class:`EWMomentum`).
See Also
--------
:class:`~skfolio.base.BaseAssetPanelTransformer` : Shared transformer contract.
:class:`~skfolio.factor_exposure.BaseFactorExposure` : Combines descriptors into
factor exposures.
"""
class BaseDescriptorComposition(BaseComposition, ABC):
"""Base class for all descriptor composition estimators in skfolio.
This mixin provides `get_params` / `set_params` / metadata routing
for the `descriptors` parameter, following the scikit-learn named
estimator convention (similar to `Pipeline` or `ColumnTransformer`).
Descriptors are specified as `(name, estimator)` tuples.
"""
descriptors: list[tuple[str, BaseDescriptor]]
@property
def named_descriptors(self):
"""Dictionary to access any fitted factors by name.
Returns
-------
:class:`~sklearn.utils.Bunch`
"""
return sku.Bunch(**dict(self.descriptors))
def set_params(self, **params):
"""Set the parameters of a factor from the ensemble.
Valid parameter keys can be listed with `get_params()`. Note that you
can directly set the parameters of the estimators contained in
`estimators`.
Parameters
----------
**params : keyword arguments
Specific parameters using e.g.
`set_params(parameter_name=new_value)`. In addition, to setting the
parameters of the estimator, the individual estimator of the
estimators can also be set, or can be removed by setting them to
'drop'.
Returns
-------
self : object
Estimator instance.
"""
super()._set_params("descriptors", **params)
return self
def get_params(self, deep=True):
"""Get the parameters of an estimator from the ensemble.
Returns the parameters given in the constructor as well as the
estimators contained within the `estimators` parameter.
Parameters
----------
deep : bool, default=True
Setting it to True gets the various estimators and the parameters
of the estimators as well.
Returns
-------
params : dict
Parameter and estimator names mapped to their values or parameter
names mapped to their values.
"""
return super()._get_params("descriptors", deep=deep)
def get_metadata_routing(self):
"""Return metadata routing for descriptor estimators."""
router = skm.MetadataRouter(owner=self.__class__.__name__)
names, descriptors = self._validate_descriptors()
for name, descriptor in zip(names, descriptors, strict=True):
router.add(
**{name: descriptor},
method_mapping=skm.MethodMapping()
.add(caller="fit", callee="fit")
.add(caller="partial_fit", callee="partial_fit"),
)
return router
def _validate_descriptors(self) -> tuple[list[str], list[BaseDescriptor]]:
"""Validate the `descriptors` parameter.
Returns
-------
names : list[str]
The list of descriptor names.
descriptors : list[BaseDescriptor]
The list of descriptor estimators.
"""
if self.descriptors is None or len(self.descriptors) == 0:
raise ValueError(
"Invalid 'descriptors' attribute, 'descriptors' should be a "
"list of (name, descriptor) tuples."
)
names, descriptors = zip(*self.descriptors, strict=True)
self._validate_names(names)
for descriptor in descriptors:
if not isinstance(descriptor, BaseDescriptor):
raise TypeError(
f"Expected descriptor to be a BaseDescriptor, got {type(descriptor)}"
)
return list(names), list(descriptors)
class _BaseRollingLogReturn(BaseDescriptor):
"""Private base class for fixed-window log-return descriptors.
Used by `RollingMomentum` and `Reversal`.
"""
_FITTED_ATTR: str
_TRANSFORM_SIGN: float = 1.0
def __init__(self, window: int, skip: int = 0, exponentiate: bool = False):
self.window = window
self.skip = skip
self.exponentiate = exponentiate
def fit_transform(self, X: AssetPanel, y=None, **fit_params) -> FloatArray:
"""Compute the rolling log-return descriptor from a clean state.
Parameters
----------
X : AssetPanel
Input panel containing `returns`.
y : None
Ignored. Present for compatibility with scikit-learn's API.
**fit_params : dict
Additional fit parameters. Ignored.
Returns
-------
descriptor : ndarray of shape (n_observations, n_assets)
Rolling log-return descriptor for each observation and asset.
"""
self._reset()
return self.partial_fit_transform(X, y, **fit_params)
def partial_fit_transform(self, X: AssetPanel, y=None, **fit_params) -> FloatArray:
"""Update state and compute the rolling log-return descriptor.
This method supports online updates by continuing from the current fitted state.
Use `fit_transform` to start from a clean state.
Parameters
----------
X : AssetPanel
Input panel containing `returns`.
y : None
Ignored. Present for compatibility with scikit-learn's API.
**fit_params : dict
Additional fit parameters. Ignored.
Returns
-------
descriptor : ndarray of shape (n_observations, n_assets)
Rolling log-return descriptor for each observation and asset.
"""
first_call = not hasattr(self, self._FITTED_ATTR)
validate_asset_panel(
self,
X,
required_fields=["returns"],
finite_or_nan=["returns"],
reset=first_call,
)
if first_call:
self._validate_params()
self._initialize()
n_observations, n_assets = X.n_observations, X.n_assets
returns = X["returns"]
active_mask = X.active_mask
buffer_length = self._rolling_buffer_length
first_valid = self.skip + self.window - 1
estimator_name = self.__class__.__name__
non_missing = ~np.isnan(returns)
if np.any(returns[non_missing] <= -1):
raise ValueError(
'Field "returns" contains values less than or equal to -1. '
f"{estimator_name} requires returns greater than -1 because it uses "
"log returns."
)
# Active assets with missing returns contribute zero to the log-return sum
contrib = np.zeros_like(returns, dtype=float)
contrib[non_missing] = np.log1p(returns[non_missing])
result = np.full((n_observations, n_assets), np.nan, dtype=float)
if self._rolling_n_seen == 0 and n_observations > first_valid:
# Vectorized batch path.
cumsum = np.zeros((n_observations + 1, n_assets), dtype=float)
cumsum[1:] = np.cumsum(contrib, axis=0)
active_cumsum = np.zeros((n_observations + 1, n_assets), dtype=int)
active_cumsum[1:] = np.cumsum(active_mask, axis=0)
valid_t = np.arange(first_valid, n_observations)
end = valid_t - self.skip + 1
start = end - self.window
rolling = cumsum[end] - cumsum[start]
active_count = active_cumsum[end] - active_cumsum[start]
transformed = self._transform_rolling_sum(rolling)
result[first_valid:] = np.where(
active_count == self.window, transformed, np.nan
)
self._sync_rolling_ring(contrib, active_mask, n_observations)
else:
# Online path: ring buffer with running sum and active count
for t in range(n_observations):
pos = self._rolling_n_seen % buffer_length
if self._rolling_n_seen >= first_valid:
if self._rolling_n_seen == first_valid:
self._rolling_contrib_buffer[pos] = contrib[t]
self._rolling_active_mask_buffer[pos] = active_mask[t]
self._rolling_sum[:] = np.sum(
self._rolling_contrib_buffer[: self.window], axis=0
)
self._rolling_active_count[:] = np.sum(
self._rolling_active_mask_buffer[: self.window], axis=0
)
else:
self._rolling_sum -= self._rolling_contrib_buffer[pos]
self._rolling_active_count -= self._rolling_active_mask_buffer[
pos
]
self._rolling_contrib_buffer[pos] = contrib[t]
self._rolling_active_mask_buffer[pos] = active_mask[t]
entering = (self._rolling_n_seen - self.skip) % buffer_length
self._rolling_sum += self._rolling_contrib_buffer[entering]
self._rolling_active_count += self._rolling_active_mask_buffer[
entering
]
transformed = self._transform_rolling_sum(self._rolling_sum)
result[t] = np.where(
self._rolling_active_count == self.window,
transformed,
np.nan,
)
else:
self._rolling_contrib_buffer[pos] = contrib[t]
self._rolling_active_mask_buffer[pos] = active_mask[t]
self._rolling_n_seen += 1
# Mask for inactive assets
result = np.where(X.active_mask, result, np.nan)
last = result[-1].copy() if n_observations > 1 else result[-1]
setattr(self, self._FITTED_ATTR, last)
return result
def _transform_rolling_sum(self, rolling_sum: FloatArray) -> FloatArray:
"""Apply the descriptor-specific sign and output scale."""
signed = self._TRANSFORM_SIGN * rolling_sum
if self.exponentiate:
return np.expm1(signed)
return signed
def _sync_rolling_ring(
self, contrib: FloatArray, active_mask: BoolArray, n_observations: int
) -> None:
"""Populate ring buffer and running aggregates after a vectorized batch."""
tail_start = max(0, n_observations - self._rolling_buffer_length)
indices = np.arange(tail_start, n_observations)
self._rolling_contrib_buffer[indices % self._rolling_buffer_length] = contrib[
indices
]
self._rolling_active_mask_buffer[indices % self._rolling_buffer_length] = (
active_mask[indices]
)
start = n_observations - self.skip - self.window
end = n_observations - self.skip
self._rolling_sum[:] = np.sum(contrib[start:end], axis=0)
self._rolling_active_count[:] = np.sum(active_mask[start:end], axis=0)
self._rolling_n_seen = n_observations
def _reset(self):
if hasattr(self, self._FITTED_ATTR):
delattr(self, self._FITTED_ATTR)
for attr in (
"_rolling_buffer_length",
"_rolling_contrib_buffer",
"_rolling_active_mask_buffer",
"_rolling_sum",
"_rolling_active_count",
"_rolling_n_seen",
):
if hasattr(self, attr):
delattr(self, attr)
def _validate_params(self) -> None:
_validate_positive_integer(self.window, "window")
_validate_non_negative_integer(self.skip, "skip")
def _initialize(self) -> None:
n_assets = self.n_assets_
buffer_length = self.skip + self.window
self._rolling_buffer_length = buffer_length
self._rolling_contrib_buffer = np.zeros((buffer_length, n_assets), dtype=float)
self._rolling_active_mask_buffer = np.zeros(
(buffer_length, n_assets), dtype=bool
)
self._rolling_sum = np.zeros(n_assets, dtype=float)
self._rolling_active_count = np.zeros(n_assets, dtype=int)
self._rolling_n_seen = 0