Source code for skfolio.linear_model._cross_sectional._cs_linear_regression

"""Cross-sectional weighted least squares regression."""

# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

import numpy as np

from skfolio.linear_model._cross_sectional._base import (
    BaseCSLinearModel,
    _validate_positive_weight_pairs,
)
from skfolio.typing import ArrayLike
from skfolio.utils.validation import validate_cross_sectional_data


[docs] class CSLinearRegression(BaseCSLinearModel): r"""Cross-sectional weighted least squares regression. This estimator fits one weighted least squares regression per observation across the asset cross-section. The implementation is fully vectorized and is designed for panel data whose asset universe may vary over time. The model solves the weighted least squares problem independently for each observation: .. math:: \beta_t = \arg\min_{\beta} \sum_{i=1}^{n_{\text{assets}}} w_{ti} (y_{ti} - X_{ti}^T \beta)^2 where :math:`t` denotes the observation, :math:`i` denotes the asset, :math:`w_{ti}` are the cross-section weights, and :math:`X_{ti}` is the feature vector for asset :math:`i` at observation :math:`t`. The cross-sectional weights must be finite and non-negative. A pair with zero weight is excluded from estimation for that observation. This is the intended way to represent inactive pairs such as assets outside the estimation universe, listed or delisted assets, or pairs with missing (NaNs) data. For each `(observation, asset)` pair: - If `cs_weights > 0`, all features in `X` and `y` must be finite. - If `cs_weights == 0`, the pair is excluded from estimation and `X` and `y` may be finite or missing (NaNs). 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. Examples -------- >>> import numpy as np >>> from skfolio.linear_model import CSLinearRegression >>> >>> rng = np.random.RandomState(42) >>> X = rng.randn(3, 5, 2) >>> y = rng.randn(3, 5) >>> >>> model = CSLinearRegression() >>> model.fit(X, y) CSLinearRegression() >>> >>> model.intercept_.shape (3,) >>> model.coef_.shape (3, 2) >>> model.predict(X).shape (3, 5) >>> model.score(X, y) 0.6353... """ def __init__(self, fit_intercept: bool = False) -> None: super().__init__(fit_intercept=fit_intercept)
[docs] def fit( self, X: ArrayLike, y: ArrayLike, cs_weights: ArrayLike | None = None, ) -> CSLinearRegression: """Fit the cross-sectional regression model. Estimates regression coefficients independently for each observation by solving weighted least squares problems across assets. Parameters ---------- X : array-like of shape (n_observations, n_assets, n_features) Training data. 3D array where 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. cs_weights : array-like of shape (n_observations, n_assets), optional Cross-sectional weights for each `(observation, asset)` pair. - Must be finite and non-negative. - Pairs with zero weight are excluded from estimation. - If None, all pairs receive unit weight. Returns ------- self : CSLinearRegression Fitted estimator. Notes ----- Each `(observation, asset)` pair with positive `cs_weights` must have finite `X` and finite `y`. Pairs with zero weight are excluded from estimation and may contain missing values. """ X, y, cs_weights = validate_cross_sectional_data( self, X=X, y=y, cs_weights=cs_weights, reset=True, copy=False, # arrays are copied in np.where below ) n_observations, _, n_features = X.shape is_valid = _validate_positive_weight_pairs(X, y, cs_weights) X = np.where(is_valid[..., None], X, 0.0) y = np.where(is_valid, y, 0.0) cs_weights = np.where(is_valid, cs_weights, 0.0) if self.fit_intercept: w_sums = cs_weights.sum(axis=1, keepdims=True) y_mean = np.zeros(n_observations, dtype=X.dtype) np.einsum("tn,tn->t", y, cs_weights, out=y_mean, optimize=True) np.divide(y_mean, w_sums[:, 0], out=y_mean, where=w_sums[:, 0] > 0) y -= y_mean[:, None] X_mean = np.zeros((n_observations, n_features), dtype=X.dtype) np.einsum("tni,tn->ti", X, cs_weights, out=X_mean, optimize=True) np.divide(X_mean, w_sums, out=X_mean, where=w_sums > 0) X -= X_mean[:, None, :] np.sqrt(cs_weights, out=cs_weights) X *= cs_weights[..., None] y *= cs_weights XtWX = X.transpose(0, 2, 1) @ X XtWy = (X.transpose(0, 2, 1) @ y[..., None]).squeeze(-1) try: coef = np.linalg.solve(XtWX, XtWy[:, :, None]).squeeze(-1) except np.linalg.LinAlgError: coef = np.einsum("tij,tj->ti", np.linalg.pinv(XtWX), XtWy) if self.fit_intercept: intercept = y_mean - np.einsum("tk,tk->t", coef, X_mean) else: intercept = np.zeros(n_observations, dtype=float) self.coef_ = coef self.intercept_ = intercept self.n_valid_assets_ = is_valid.sum(axis=1).astype(int) return self