Source code for skfolio.containers._asset_panel._fields

"""Asset Panel Fields module."""

# 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
from dataclasses import dataclass, field
from dataclasses import fields as dataclass_fields
from enum import auto

import numpy as np

from skfolio.containers._utils import _as_pickle_safe_array, _validate_unique_labels
from skfolio.typing import AnyArray, ArrayLike, BoolArray, StrArray
from skfolio.utils.tools import AutoEnum

__all__ = [
    "MISSING_CATEGORY_CODE",
    "BaseField",
    "Field2D",
    "Field3D",
    "FieldCategorical",
    "InactivePolicy",
]

MISSING_CATEGORY_CODE: int = -1


[docs] class InactivePolicy(AutoEnum): """Validation policy for values outside an `AssetPanel` active universe.""" MISSING = auto() ZERO = auto() IGNORE = auto()
[docs] @dataclass(slots=True) class BaseField(ABC): """Base class for fields stored in an `AssetPanel`. A field stores a NumPy array for one named variable. The first two axes are always observations and assets with shape (n_observations, n_assets). Subclasses define any additional metadata required by the array layout. Parameters ---------- values : ndarray of shape (n_observations, n_assets) Field values. The first two axes must be observations and assets. """ values: AnyArray inactive_policy: InactivePolicy = field( default=InactivePolicy.MISSING, kw_only=True, ) def __post_init__(self) -> None: self.values = np.asarray(self.values) if not isinstance(self.inactive_policy, InactivePolicy): raise TypeError( "`inactive_policy` must be an InactivePolicy, " f"got {self.inactive_policy!r}." ) if self.values.ndim < 2: raise ValueError("Field values must have at least two dimensions.") @property def shape(self) -> tuple[int, ...]: """Shape of the underlying values.""" return self.values.shape @property def dtype(self) -> np.dtype: """Dtype of the underlying values.""" return self.values.dtype @property def ndim(self) -> int: """Number of dimensions of the underlying values.""" return self.values.ndim @property def n_observations(self) -> int: """Number of observations.""" return int(self.values.shape[0]) @property def n_assets(self) -> int: """Number of assets.""" return int(self.values.shape[1]) @property def is_categorical(self) -> bool: """Whether or not the field is a categorical field.""" return isinstance(self, FieldCategorical) @property def is_3d(self) -> bool: """Whether or not the field is a 3D field.""" return isinstance(self, Field3D) @property def missing_mask(self) -> BoolArray: """Return a boolean mask indicating missing entries. Returns ------- mask : ndarray of bool Boolean array with the same shape as `values`. Entries are `True` where `values` is NaN for floating-point fields and `False` elsewhere. Non-floating fields without a missing-value convention return an all-`False` mask; subclasses override this method when they define their own convention. """ if np.issubdtype(self.values.dtype, np.floating): return np.isnan(self.values) return np.zeros(self.values.shape, dtype=np.bool_)
[docs] def copy(self, *, deep: bool = False) -> BaseField: """Return a copy of the field. Parameters ---------- deep : bool, default=False If `True`, copy NumPy arrays stored by the field. If `False`, reuse the same array objects. Returns ------- field : BaseField Field instance of the same concrete class. """ kwargs = {} for f in dataclass_fields(self): value = getattr(self, f.name) if deep and isinstance(value, np.ndarray): value = value.copy() kwargs[f.name] = value return type(self)(**kwargs)
[docs] def with_values(self, values: ArrayLike) -> BaseField: """Return a field of the same type with replacement values. Metadata arrays are reused. The replacement values are validated by the concrete field class constructor. Parameters ---------- values : array-like Replacement field values. Returns ------- field : BaseField Field instance of the same concrete class. """ kwargs = { field.name: getattr(self, field.name) for field in dataclass_fields(self) if field.name != "values" } return type(self)(values=np.asarray(values), **kwargs)
[docs] @dataclass(slots=True) class Field2D(BaseField): """Numeric 2D field with axes (observations, assets). Parameters ---------- values : ndarray of shape (n_observations, n_assets) Numeric field values. Object dtype is rejected. Use `FieldCategorical` for categorical data. """ def __post_init__(self) -> None: BaseField.__post_init__(self) if self.values.ndim != 2: raise ValueError(f"Field2D values must be 2D, got ndim={self.values.ndim}.") if self.values.dtype == object: raise ValueError( "Field2D values cannot have dtype=object. Store categoricals as " "integer codes with FieldCategorical." )
[docs] @dataclass(slots=True) class FieldCategorical(Field2D): """Integer-coded categorical 2D field. Codes are stored as integers in a 2D array with axes (observations, assets). Code -1 is reserved for missing values. Code 0 selects `levels[0]`, code 1 selects `levels[1]` and so on. Parameters ---------- values : integers ndarray of shape (n_observations, n_assets) Integer category codes. Missing values must be encoded with `MISSING_CATEGORY_CODE`. levels : ndarray of shape (n_levels,) Category labels selected by codes 0, 1 and so on. Labels must be unique. """ levels: StrArray def __post_init__(self) -> None: Field2D.__post_init__(self) if self.inactive_policy == InactivePolicy.ZERO: raise ValueError( "`inactive_policy=InactivePolicy.ZERO` is not supported for " "FieldCategorical because 0 is a valid category code." ) self.levels = _as_pickle_safe_array(self.levels) if not np.issubdtype(self.values.dtype, np.integer): raise ValueError( "FieldCategorical values must have an integer dtype; " f"got {self.values.dtype}." ) if self.levels.ndim != 1: raise ValueError("FieldCategorical levels must be a 1D array.") _validate_unique_labels(self.levels, name="FieldCategorical levels") if self.values.size != 0: min_code = int(np.min(self.values)) max_code = int(np.max(self.values)) if min_code < MISSING_CATEGORY_CODE: raise ValueError( f"Categorical codes must be >= {MISSING_CATEGORY_CODE}; " f"got minimum code {min_code}." ) if max_code >= len(self.levels): raise ValueError( f"Categorical code {max_code} is out of bounds for " f"{len(self.levels)} levels." ) @property def missing_mask(self) -> BoolArray: """Return a boolean mask indicating missing categorical codes. Returns ------- mask : ndarray of bool Boolean array with the same shape as `values`. Entries are `True` where `values` equals `MISSING_CATEGORY_CODE`. """ return self.values == MISSING_CATEGORY_CODE
[docs] def decode(self, *, missing_label: str = "MISSING") -> StrArray: """Decode integer codes to level labels. Parameters ---------- missing_label : str, default="MISSING" Label assigned to missing or out-of-bound codes. Returns ------- decoded : ndarray Decoded labels with the same shape as `values`. """ codes = self.values levels = self.levels if codes.size == 0: return np.asarray([], dtype=levels.dtype).reshape(codes.shape) dtype = np.result_type(levels.dtype, np.asarray(missing_label).dtype) decoded = np.empty(codes.shape, dtype=dtype) valid = (codes >= 0) & (codes < len(levels)) decoded[valid] = levels[codes[valid]] decoded[~valid] = str(missing_label) return decoded
[docs] @dataclass(slots=True) class Field3D(BaseField): """Numeric 3D field with axes (observations, assets, third_axis). Use this field for homogeneous tensors (e.g. factor exposures). The third-axis metadata is stored on the field. The array is stored physically in 3D so that operations along any axis remain vectorized and avoid stacking many 2D arrays which is expensive for large panels. When you need repeated tensor operations, prefer a `Field3D` instead of multiple `Field2D`. Parameters ---------- values : ndarray of shape `(n_observations, n_assets, n_third_axis)` Numeric 3D values. Object dtype is rejected. third_axis_name : str Name describing what the third axis represents (e.g. `factor`). third_axis_labels : ndarray of shape (n_third_axis,) Labels for entries along the third axis such as factor names (`size`, `momentum`). Labels must be unique. third_axis_groups : ndarray of shape (n_third_axis,), optional Optional group label for each third-axis entry such as factor families (e.g. `style`, `industry`, `country`). """ third_axis_name: str third_axis_labels: StrArray third_axis_groups: StrArray | None = None def __post_init__(self) -> None: BaseField.__post_init__(self) self.third_axis_labels = _as_pickle_safe_array(self.third_axis_labels) if self.third_axis_groups is not None: self.third_axis_groups = _as_pickle_safe_array(self.third_axis_groups) if self.values.ndim != 3: raise ValueError(f"Field3D values must be 3D, got ndim={self.values.ndim}.") if self.values.dtype == object: raise ValueError("Field3D values cannot have dtype=object.") if not isinstance(self.third_axis_name, str) or not self.third_axis_name: raise ValueError("third_axis_name must be a non-empty string.") if self.third_axis_labels.ndim != 1: raise ValueError("third_axis_labels must be a 1D array.") _validate_unique_labels(self.third_axis_labels, name="third_axis_labels") if len(self.third_axis_labels) != self.values.shape[2]: raise ValueError( f"Field3D has {self.values.shape[2]} entries on the third axis but " f"third_axis_labels has length {len(self.third_axis_labels)}." ) if self.third_axis_groups is not None: if self.third_axis_groups.ndim != 1: raise ValueError("third_axis_groups must be a 1D array.") if len(self.third_axis_groups) != self.values.shape[2]: raise ValueError( "third_axis_groups length must match third_axis_labels length." )