Source code for skfolio.linear_model._cross_sectional._base

"""Base class for cross-sectional linear model estimators."""

# 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, abstractmethod

import numpy as np
from sklearn.base import BaseEstimator, RegressorMixin
from sklearn.utils.validation import check_is_fitted

from skfolio.typing import ArrayLike, BoolArray, FloatArray, IntArray
from skfolio.utils.validation import validate_cross_sectional_data


[docs] class BaseCSLinearModel(BaseEstimator, RegressorMixin, ABC): """Base class for all cross-sectional linear model estimators. This abstract base class defines the common interface for cross-sectional linear model estimators that fit one linear model per observation across a set of assets. Subclasses are responsible for implementing `fit` and for setting the fitted attributes used by `predict` and `score`. Parameters ---------- fit_intercept : bool, default=False Whether to calculate the intercept for each observation. If set to False, no intercept will be used in calculations. Attributes ---------- coef_ : ndarray of shape (n_observations, n_features) Estimated coefficients for each observation. intercept_ : ndarray of shape (n_observations,) intercept for each observation. Set to zeros if `fit_intercept=False`. n_features_in_ : int Number of features seen during `fit`. n_valid_assets_ : ndarray of shape (n_observations,) Number of assets that participated in estimation (those with positive weight) for each observation. """ coef_: FloatArray intercept_: FloatArray n_features_in_: int n_valid_assets_: IntArray def __init__(self, fit_intercept: bool = False) -> None: self.fit_intercept = fit_intercept
[docs] @abstractmethod def fit( self, X: ArrayLike, y: ArrayLike, cs_weights: ArrayLike | None = None, ): """Fit one cross-sectional linear model per observation. Parameters ---------- X : array-like of shape (n_observations, n_assets, n_features) Feature tensor. The first axis indexes observations, the second axis indexes assets, and the third axis indexes features. y : array-like of shape (n_observations, n_assets) Target values aligned with `X`. cs_weights : array-like of shape (n_observations, n_assets), optional Cross-sectional weights for each `(observation, asset)` pair. Returns ------- self : BaseCSLinearModel Fitted estimator. """ pass
[docs] def predict(self, X: ArrayLike) -> FloatArray: r"""Predict using the cross-sectional linear model. For each observation :math:`t` and asset :math:`i`, the prediction is the systematic part; realized outcomes satisfy :math:`y_{ti} = \hat{y}_{ti} + \epsilon_{ti}` with residual :math:`\epsilon_{ti}`. The prediction is .. math:: \hat{y}_{ti} = X_{ti}^{T} \beta_t + \beta_{t,0} Parameters ---------- X : array-like of shape (n_observations, n_assets, n_features) Feature tensor used for prediction. The observation and feature axes must match those seen during `fit`. The asset axis may differ. Returns ------- y_pred : ndarray of shape (n_observations, n_assets) Predicted values. """ check_is_fitted(self, attributes=["coef_", "intercept_", "n_features_in_"]) X = validate_cross_sectional_data(self, X=X, reset=False) n_observations = X.shape[0] expected_n_observations = self.coef_.shape[0] if n_observations != expected_n_observations: raise ValueError( f"X has {n_observations} observations but model was fitted with " f"{expected_n_observations} observations." ) return (X @ self.coef_[..., None]).squeeze(-1) + self.intercept_[:, None]
[docs] def score( self, X: ArrayLike, y: ArrayLike, cs_weights: ArrayLike | None = None, ) -> float: r"""Return the mean coefficient of determination across observations. The coefficient of determination :math:`R^2` is computed independently for each observation and then averaged. For observation :math:`t`: .. math:: R^2_t = 1 - \frac{\sum_i w_{ti}(y_{ti} - \hat{y}_{ti})^2} {\sum_i w_{ti}(y_{ti} - \bar{y}_t)^2} where :math:`\bar{y}_t` is the weighted mean of :math:`y` for observation :math:`t`. Parameters ---------- X : array-like of shape (n_observations, n_assets, n_features) Feature tensor on which to evaluate the model. y : array-like of shape (n_observations, n_assets) Target values aligned with `X`. cs_weights : array-like of shape (n_observations, n_assets), optional Asset weights for computing weighted :math:`R^2` scores. If None, all assets are given equal weight. Pairs with zero weight are excluded from the score. Pairs with positive weight must have finite `X` and finite `y`. Returns ------- score : float Mean :math:`R^2` across all observations with finite values. Returns NaN if no observations have valid :math:`R^2` values. """ check_is_fitted(self, "coef_") X, y, cs_weights = validate_cross_sectional_data( self, X=X, y=y, cs_weights=cs_weights, reset=False, ) positive_weight = _validate_positive_weight_pairs(X, y, cs_weights) y_pred = self.predict(X) is_valid = positive_weight & np.isfinite(y_pred) weights_masked = np.where(is_valid, cs_weights, 0.0) y_masked = np.where(is_valid, y, 0.0) y_pred_masked = np.where(is_valid, y_pred, 0.0) n_observations = y.shape[0] rss = (weights_masked * (y_masked - y_pred_masked) ** 2).sum(axis=1) y_mean = np.full(n_observations, np.nan, dtype=float) valid_obs = weights_masked.sum(axis=1) > 0 if np.any(valid_obs): y_mean[valid_obs] = np.average( y_masked[valid_obs], axis=1, weights=weights_masked[valid_obs] ) ss_tot = (weights_masked * (y_masked - y_mean[:, None]) ** 2).sum(axis=1) r2 = 1.0 - np.divide( rss, ss_tot, out=np.full(n_observations, np.nan, dtype=float), where=ss_tot > 0, ) return float(np.nanmean(r2))
def __sklearn_tags__(self): tags = super().__sklearn_tags__() tags.target_tags.required = True return tags
def _validate_positive_weight_pairs( X: FloatArray, y: FloatArray, cs_weights: FloatArray, ) -> BoolArray: """Validate positive-weight pairs and return the fit mask. Each `(observation, asset)` pair with positive `cs_weights` must have all features in `X` finite and `y` finite. """ positive_weight = cs_weights > 0 invalid_positive_weight = positive_weight & ( ~np.all(np.isfinite(X), axis=2) | ~np.isfinite(y) ) if np.any(invalid_positive_weight): raise ValueError( "Each `(observation, asset)` pair with positive `cs_weights` must " "have all features in `X` finite and `y` finite." ) return positive_weight