Source code for skfolio.factor_exposure._one_hot_categorical_factors
"""One-hot categorical factor exposure."""
# 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.containers import MISSING_CATEGORY_CODE, AssetPanel
from skfolio.factor_exposure._base import BaseFactorExposure
from skfolio.typing import FloatArray, ObjArray
from skfolio.utils.validation import validate_asset_panel
[docs]
class OneHotCategoricalFactors(BaseFactorExposure, stateless=True):
r"""One-hot factor exposures from a categorical field.
Expands a categorical field into one factor per category level. The result is an
exposure tensor with shape `(n_observations, n_assets, n_factors)`, where
`n_factors` is the number of category levels.
For each observation :math:`t`, asset :math:`i` and category factor :math:`k`, the
exposure is:
.. math::
x_{t,i,k} =
\begin{cases}
1 & \text{if asset } i \text{ belongs to category } k \\
0 & \text{otherwise}
\end{cases}
Missing category codes produce NaN exposures for all category factors of that
asset-observation pair.
Parameters
----------
category : str
Name of the categorical field in the AssetPanel to one-hot encode. The field
must be a `FieldCategorical`.
family : str
The factor family this exposure belongs to (e.g., "industry", "country").
Factor families group related factors for basket-neutral constraints,
neutralization, attribution and reporting.
Attributes
----------
factor_names_ : ndarray of shape (n_factors,)
The category labels corresponding to each one-hot column.
n_assets_ : int
Number of assets seen during fitting.
asset_names_ : ndarray of shape (n_assets,)
Asset names seen during fitting.
"""
factor_names_: ObjArray
def __init__(self, category: str, *, family: str) -> None:
super().__init__(family=family)
self.category = category
[docs]
def fit_transform(self, X: AssetPanel, y=None, **fit_params) -> FloatArray:
"""One-hot encode the categorical field.
Parameters
----------
X : AssetPanel
Input panel containing the categorical field as integer codes.
y : None
Ignored. Present for compatibility with scikit-learn's API.
**fit_params : dict
Additional fit parameters. They are ignored.
Returns
-------
exposures : ndarray of shape (n_observations, n_assets, n_factors)
One-hot encoded exposures. Column order matches `X.fields[category].levels`.
Entries with missing codes (MISSING_CATEGORY_CODE == -1) are filled with
NaN.
Raises
------
IndexError
If any valid code is >= n_levels (indicates data corruption).
"""
validate_asset_panel(self, X, required_fields=[self.category])
try:
field = X.fields[self.category]
except KeyError as err:
raise ValueError(
f"Field '{self.category}' is not in the AssetPanel."
) from err
if not field.is_categorical:
raise ValueError(f"Field '{self.category}' must be a CategoricalField.")
codes = field.values
factor_names = field.levels
n_factors = len(factor_names)
n_observations, n_assets = codes.shape
# Flatten for vectorized one-hot encoding
flat_codes = codes.ravel()
# Identify missing values
valid_mask = flat_codes != MISSING_CATEGORY_CODE
# FieldCategorical validates that valid codes are in [0, n_factors)
exposures = np.full((flat_codes.size, n_factors), np.nan, dtype=np.float64)
valid_indices = np.nonzero(valid_mask)[0]
valid_codes = flat_codes[valid_mask]
exposures[valid_indices, :] = 0.0
exposures[valid_indices, valid_codes] = 1.0
# Reshape to (n_observations, n_assets, n_factors)
exposures = exposures.reshape(n_observations, n_assets, n_factors)
self.factor_names_ = factor_names
return exposures