Source code for skfolio.preprocessing._transformer._cross_sectional._cs_winsorizer

"""Cross-sectional winsorization."""

# 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
import sklearn.utils.validation as skv
from sklearn.utils.validation import FLOAT_DTYPES

from skfolio.preprocessing._transformer._cross_sectional._base import BaseCSTransformer
from skfolio.preprocessing._transformer._cross_sectional._utils import (
    _mask_non_estimation_values,
    _validate_cs_weights,
)
from skfolio.typing import ArrayLike, FloatArray

__all__ = ["CSWinsorizer"]


[docs] class CSWinsorizer(BaseCSTransformer): r"""Cross-sectional winsorization. Clips each finite value within an observation to the interval between the `low` and `high` percentiles of that observation's cross-section. NaNs are treated as missing values. They are ignored when computing cross-sectional percentiles and are preserved in the output. When `cs_weights` is provided, percentile boundaries are computed on the estimation universe, defined by `cs_weights > 0`. Assets outside the estimation universe still receive clipped values using those boundaries. For this estimator, `cs_weights` is used only to define the estimation universe; percentile estimation itself remains equal-weighted over the selected assets. This transformer is stateless. Parameters ---------- low : float, default=0.01 Lower percentile used for clipping. Must satisfy :math:`0 \le \text{low} < \text{high} \le 1`. high : float, default=0.99 Upper percentile used for clipping. Must satisfy :math:`0 \le \text{low} < \text{high} \le 1`. See Also -------- CSTanhShrinker : Smoothly shrinks extreme values. Examples -------- >>> import numpy as np >>> from skfolio.preprocessing import CSWinsorizer >>> >>> X = np.array([[1.0, np.nan, 3.0, 4.0], ... [4.0, 3.0, 2.0, 1.0], ... [10.0, 20.0, np.nan, 40.0]]) >>> >>> transformer = CSWinsorizer(low=0.1, high=0.9) >>> transformer.fit_transform(X) array([[ 1.4, nan, 3. , 3.8], [ 3.7, 3. , 2. , 1.3], [12. , 20. , nan, 36. ]]) >>> >>> # Use cs_weights for the estimation universe before computing the clip bounds. >>> cs_weights = np.array([[1.0, 0.0, 1.0, 1.0], ... [1.0, 0.0, 1.0, 1.0], ... [1.0, 1.0, 0.0, 1.0]]) >>> >>> transformer.fit_transform(X, cs_weights=cs_weights) array([[ 1.4, nan, 3. , 3.8], [ 3.6, 3. , 2. , 1.2], [12. , 20. , nan, 36. ]]) """ def __init__(self, *, low: float = 0.01, high: float = 0.99): self.low = low self.high = high def _validate_params(self) -> None: """Validate constructor parameters.""" if not (0.0 <= self.low < self.high <= 1.0): raise ValueError( "`low` and `high` must satisfy 0.0 <= low < high <= 1.0; " f"got low={self.low}, high={self.high}." )
[docs] def transform( self, X: ArrayLike, cs_weights: ArrayLike | None = None, cs_groups: ArrayLike | None = None, ) -> FloatArray: r"""Winsorize each observation to low/high percentiles. Parameters ---------- X : array-like of shape (n_observations, n_assets) Input matrix where each row is an observation and each column is an asset. NaNs are allowed and preserved. cs_weights : array-like of shape (n_observations, n_assets), optional Optional non-negative cross-sectional weights used only to define the estimation universe through the convention `cs_weights > 0`. Percentile boundaries are then estimated in an equal-weighted way over the selected assets. Non-estimation assets still receive clipped values using those boundaries. If `None`, all finite assets are used to compute percentiles. cs_groups : array-like of shape (n_observations, n_assets), optional Not used, present for API consistency by convention. Returns ------- X_clipped : ndarray of shape (n_observations, n_assets) Winsorized values. NaN values from the input are preserved. Raises ------ ValueError If `low` / `high` are invalid, `X` is not a non-empty 2D array, or `cs_weights` is invalid. """ self._validate_params() X = skv.validate_data( self, X, reset=False, dtype=FLOAT_DTYPES, copy=True, ensure_all_finite="allow-nan", ) cs_weights = _validate_cs_weights(X=X, cs_weights=cs_weights) X_estimation = _mask_non_estimation_values(X=X, cs_weights=cs_weights) any_finite = np.isfinite(X_estimation).any(axis=1) q_bounds = np.full((2, X.shape[0], 1), np.nan, dtype=float) if np.any(any_finite): q_bounds[:, any_finite] = np.nanpercentile( X_estimation[any_finite], [self.low * 100, self.high * 100], axis=1, keepdims=True, ) q_lo, q_hi = q_bounds # Numpy clip preserves NaNs from X np.clip(X, q_lo, q_hi, out=X) return X