Source code for skfolio.attribution._realized

"""Realized (ex-post) factor model attribution."""

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

from __future__ import annotations

import math
from dataclasses import fields
from typing import TYPE_CHECKING

import numpy as np

from skfolio._constants import _CURRENCY
from skfolio.attribution._model import (
    AssetBreakdown,
    AssetByFactorContribution,
    Attribution,
    Component,
    FactorBreakdown,
    FamilyBreakdown,
)
from skfolio.attribution._utils import _cov_with_centered, _validate_no_nan
from skfolio.typing import ArrayLike, BoolArray, FloatArray, StrArray
from skfolio.utils.stats import safe_divide

if TYPE_CHECKING:
    from skfolio.prior._model._family_constraint_basis import FamilyConstraintBasis

__all__ = ["realized_factor_attribution", "rolling_realized_factor_attribution"]


[docs] def realized_factor_attribution( *, asset_names: ArrayLike, factor_names: ArrayLike, factor_families: ArrayLike | None = None, weights: ArrayLike, factor_returns: ArrayLike, portfolio_returns: ArrayLike, exposures: ArrayLike, exposure_lag: int = 1, idio_returns: ArrayLike, idio_variances: ArrayLike | None = None, regression_weights: ArrayLike | None = None, family_constraint_basis: FamilyConstraintBasis | None = None, annualization_factor: float = 252.0, compute_asset_breakdowns: bool = True, compute_uncertainty: bool = False, ) -> Attribution: r"""Compute realized (ex-post) factor volatility and return attribution. This function decomposes realized portfolio volatility and return into systematic (factors), idiosyncratic and unattributed contributions. **Time convention (as-of indexing):** Under this convention, all time-varying inputs at observation :math:`t` reflect information available up to and including the end of period :math:`t`. Point-in-time fields and derived values store the latest available value for observation :math:`t`. Returns stored at observation :math:`t` cover the period ending at :math:`t`, namely :math:`(t-1, t]`. For time-varying exposures, attribution uses exposures from before the return interval. When `exposure_lag > 0`, the function aligns :math:`B_{t-\ell}` with returns at :math:`t`; the first :math:`\ell` return observations are discarded. For 2D static exposures, no trimming is needed. .. math:: R_{P,t} = \sum_{k=1}^{K} x_{k,t} f_{k,t} + \varepsilon_{P,t} + \eta_{P,t} where :math:`x_{k,t} = B_{:,k,t-\ell}^\top w_t`, :math:`\varepsilon_{P,t}` is the portfolio idiosyncratic return, :math:`\eta_{P,t}` is the unattributed portfolio return, and :math:`\ell` is `exposure_lag`. **Unattributed component:** The unattributed return :math:\eta_{P,t} is the difference between the observed portfolio return and its systematic-plus-idiosyncratic reconstruction. It captures effects outside that reconstruction, such as costs, cash, intra-period trading and the time-series regression intercept. **Volatility Attribution (Variance Decomposition):** Using the covariance identity, the total portfolio variance decomposes as: .. math:: \operatorname{Var}(R_P) = \sum_{k=1}^{K} \operatorname{Cov}(x_k f_k, R_P) + \operatorname{Cov}(\varepsilon_P, R_P) + \operatorname{Cov}(\eta_P, R_P) Each factor's variance contribution is :math:`\operatorname{Cov}(x_k f_k, R_P)`, which captures both the exposure magnitude and the factor's correlation with portfolio returns. These contributions are additive and sum exactly to total variance. **Volatility Contribution:** The volatility contribution divides the variance contribution by portfolio volatility: .. math:: \operatorname{VolContrib}_k = \frac{\operatorname{Cov}(x_k f_k, R_P)}{\sigma_P} This also satisfies the :math:`\sigma \cdot \rho` identity: .. math:: \operatorname{VolContrib}_k = \operatorname{std}(x_k f_k) \cdot \operatorname{corr}(x_k f_k, R_P) **Return Attribution:** The mean return contribution of each factor is the average of the exposure-weighted factor returns: .. math:: \operatorname{MuContrib}_k = \overline{x_k f_k} Parameters ---------- asset_names : array-like of shape (n_assets,) Names for each asset (e.g., ["AAPL", "GOOGL", "MSFT"]). factor_names : array-like of shape (n_factors,) Names for each factor (e.g., ["Momentum", "Value", "Size"]). factor_families : array-like of shape (n_factors,), optional Family/category for each factor (e.g., "Style", "Industry"). If provided, enables family-level aggregation in the output. weights : array-like of shape (n_assets,) or (n_observations, n_assets) Portfolio weights. If 1D, the same weights are used for all observations (static). If 2D, time-varying weights are used. factor_returns : array-like of shape (n_observations, n_factors) Factor return time series. portfolio_returns : array-like of shape (n_observations,) Portfolio return time series. exposures : array-like of shape (n_assets, n_factors) or (n_observations, n_assets, n_factors) Asset-by-factor exposure (loading) values. If 2D, this is the static loading matrix used for all observations. If 3D, this is a time series of loading matrices following the as-of time-indexing convention (the function applies `exposure_lag` internally and trims the returns and weights series accordingly). exposure_lag : int, default=1 Lag applied to time-varying exposures under the as-of time-indexing convention. The default value of `1` aligns exposures at :math:`t-1` with returns over :math:`(t-1, t]`. Only affects 3D (time-varying) exposures. idio_returns : array-like of shape (n_observations, n_assets) Idiosyncratic returns from the factor model regression. These are the residuals :math:`\varepsilon_{i,t}` from the cross-sectional regression. idio_variances : array-like of shape (n_observations, n_assets) or None, optional Per-asset idiosyncratic (specific) variances :math:`\sigma^2_{\varepsilon,i,t}`. Required when `compute_uncertainty=True`. NaN values are allowed and exclude the corresponding asset-observation pair from the uncertainty estimate. regression_weights : array-like of shape (n_observations, n_assets) or None, optional Per-asset cross-sectional regression weights :math:`q_{i,t}` used when estimating factor returns. Required when `compute_uncertainty=True`. Must not contain NaN. family_constraint_basis : FamilyConstraintBasis or None, optional When provided, the uncertainty estimator is computed in the reduced (full-rank) basis defined by the family-constraint change of coordinates. This avoids the singular Gram matrix that arises from collinear constrained families and produces well-conditioned standard errors. Only used when `compute_uncertainty=True`. annualization_factor : float, default=252.0 Used to annualize expected returns, variances and volatilities. Use 1.0 to disable annualization. Common values: 252 for daily data, 12 for monthly data. compute_asset_breakdowns : bool, default=True If True, compute asset-level attribution (systematic/idiosyncratic decomposition). Set to False to skip asset attribution for faster computation. compute_uncertainty : bool, default=False If `True`, compute attribution uncertainty (standard errors on the factor/idiosyncratic return split). Requires both `regression_weights` and `idio_variances`; raises `ValueError` if either is missing. If `False` (default), uncertainty is not computed. Returns ------- attribution : Attribution The :class:`Attribution` dataclass containing component-level, factor-level and optionally asset-level attribution results. See Also -------- predicted_factor_attribution : Predicted (ex-ante) factor model attribution. Notes ----- When exposures are time-varying, `vol_contrib` cannot be exactly reproduced as `exposure_mean * sigma(f) * rho(f, R_P)` because the actual contribution is computed from the covariance of the exposure-weighted factor return series. The displayed statistics provide intuitive factor-level information while the contributions reflect the true realized attribution. **NaN handling:** `exposures` and `idio_returns` may contain NaN entries for assets that are inactive at a given date (delistings, not-yet-listed securities, trading holidays). These NaN values are replaced with 0 before any computation: portfolio weight for an inactive asset is zero, so its return contribution is economically zero. When `compute_uncertainty=True`, NaN values in `idio_variances` exclude the corresponding asset-observation pair from the uncertainty estimate by setting its effective regression weight to zero. This handles per-asset variance-estimator warmup, inactive assets and sparse histories without changing the attribution sample. `factor_returns`, `portfolio_returns`, and `weights` must not contain NaN; a `ValueError` is raised otherwise. Examples -------- >>> from skfolio.attribution import realized_factor_attribution >>> import numpy as np >>> >>> # Static exposures and weights >>> attribution = realized_factor_attribution( ... factor_returns=factor_returns, # (252, 3) ... portfolio_returns=portfolio_returns, # (252,) ... exposures=loading_matrix, # (10, 3) ... weights=weights, # (10,) ... idio_returns=residuals, # (252, 10) ... factor_names=["Momentum", "Value", "Size"], ... ) >>> print(f"Total volatility: {attribution.total.vol:.2%}") >>> print(f"Factor contributions: {attribution.factors.vol_contrib}") >>> >>> # Time-varying weights (e.g., from rebalancing) >>> attribution = realized_factor_attribution( ... factor_returns=factor_returns, ... portfolio_returns=portfolio_returns, ... exposures=loading_matrix, ... weights=daily_weights, # (252, 10) ... idio_returns=residuals, ... factor_names=["Momentum", "Value", "Size"], ... ) >>> print(f"Exposure std (shows position dynamism): {attribution.factors.exposure_std}") """ factor_returns = np.asarray(factor_returns, dtype=float) portfolio_returns = np.asarray(portfolio_returns, dtype=float) exposures = np.asarray(exposures, dtype=float) weights = np.asarray(weights, dtype=float) idio_returns = np.asarray(idio_returns, dtype=float) factor_names = np.asarray(factor_names) asset_names = np.asarray(asset_names) _validate_no_nan(factor_returns, "factor_returns") _validate_no_nan(portfolio_returns, "portfolio_returns") _validate_no_nan(weights, "weights") if factor_families is not None: factor_families = np.asarray(factor_families) if compute_uncertainty: if regression_weights is None or idio_variances is None: raise ValueError( "`compute_uncertainty=True` requires both `regression_weights` " "and `idio_variances` to be provided." ) regression_weights = np.asarray(regression_weights, dtype=float) idio_variances = np.asarray(idio_variances, dtype=float) _validate_no_nan(regression_weights, "regression_weights") else: regression_weights = None idio_variances = None _validate_attribution_inputs( factor_returns=factor_returns, portfolio_returns=portfolio_returns, exposures=exposures, weights=weights, idio_returns=idio_returns, factor_names=factor_names, asset_names=asset_names, factor_families=factor_families, ) # Apply exposure lag for 3D (time-varying) exposures. # Under the as-of time-indexing convention, exposures[t] = B_t and returns[t] = R_t. # The cross-sectional identity is R_t = B_{t-l} f_t + epsilon_t, so we # align by keeping exposures[:-lag] with returns[lag:]. exposure_is_static = exposures.ndim == 2 if not exposure_is_static and exposure_lag > 0: exposures = exposures[:-exposure_lag] factor_returns = factor_returns[exposure_lag:] portfolio_returns = portfolio_returns[exposure_lag:] idio_returns = idio_returns[exposure_lag:] if weights.ndim == 2: weights = weights[exposure_lag:] if regression_weights is not None: regression_weights = regression_weights[exposure_lag:] if idio_variances is not None: idio_variances = idio_variances[exposure_lag:] if family_constraint_basis is not None: family_constraint_basis = family_constraint_basis[:-exposure_lag] # NaN in exposures or idio_returns marks inactive (date, asset) entries: # delistings, not-yet-listed securities or trading holidays. Both arrays are zeroed # at these positions so that systematic and idiosyncratic contributions are zero for # inactive entries, preserving the additive identity (systematic + idiosyncratic + # unattributed = total) at every time step. This runs after lag alignment so that # each exposure row is paired with its corresponding idio row. exposures, idio_returns, inactive_mask = _zero_inactive_entries( exposures, idio_returns, exposure_is_static ) if idio_variances is not None: regression_weights, idio_variances = _prepare_uncertainty_inputs( regression_weights=regression_weights, idio_variances=idio_variances, inactive_mask=inactive_mask, ) return _realized_factor_attribution_core( factor_returns=factor_returns, portfolio_returns=portfolio_returns, exposures=exposures, weights=weights, idio_returns=idio_returns, asset_names=asset_names, factor_names=factor_names, factor_families=factor_families, annualization_factor=annualization_factor, compute_asset_breakdowns=compute_asset_breakdowns, regression_weights=regression_weights, idio_variances=idio_variances, family_constraint_basis=family_constraint_basis, )
[docs] def rolling_realized_factor_attribution( *, observations: ArrayLike, window_size: int = 60, step: int = 21, asset_names: ArrayLike, factor_names: ArrayLike, factor_families: ArrayLike | None = None, weights: ArrayLike, factor_returns: ArrayLike, portfolio_returns: ArrayLike, exposures: ArrayLike, exposure_lag: int = 1, idio_returns: ArrayLike, idio_variances: ArrayLike | None = None, regression_weights: ArrayLike | None = None, family_constraint_basis: FamilyConstraintBasis | None = None, annualization_factor: float = 252.0, compute_asset_breakdowns: bool = True, compute_asset_factor_contribs: bool = False, compute_uncertainty: bool = False, ) -> Attribution: r"""Compute rolling realized (ex-post) factor volatility and return attribution. This function computes :func:`realized_factor_attribution` over rolling windows, returning an :class:`Attribution` object where all numeric fields are arrays with an additional leading dimension corresponding to the number of windows. Parameters ---------- observations : array-like of shape (n_observations,) Observation labels (e.g., dates) corresponding to each row of the input data. The output `Attribution.observations` will contain the labels for the last observation of each window. window_size : int, default=60 Number of observations in each rolling window. step : int, default=21 Number of observations to advance between consecutive windows. The default of 21 produces approximately monthly output for daily data. Use `step=1` for fully overlapping windows (daily updates), or `step=window_size` for non-overlapping windows. asset_names : array-like of shape (n_assets,) Names for each asset (e.g., ["AAPL", "GOOGL", "MSFT"]). factor_names : array-like of shape (n_factors,) Names for each factor (e.g., ["Momentum", "Value", "Size"]). factor_families : array-like of shape (n_factors,), optional Family/category for each factor (e.g., "Style", "Industry"). If provided, enables family-level aggregation in the output. weights : array-like of shape (n_assets,) or (n_observations, n_assets) Portfolio weights. If 1D, the same weights are used for all observations (static). If 2D, time-varying weights are used. factor_returns : array-like of shape (n_observations, n_factors) Factor return time series. portfolio_returns : array-like of shape (n_observations,) Portfolio return time series. exposures : array-like of shape (n_assets, n_factors) or (n_observations, n_assets, n_factors) Asset-by-factor exposure (loading) values. If 2D, this is the static loading matrix used for all observations. If 3D, this is a time series of loading matrices following the as-of time-indexing convention (the function applies `exposure_lag` internally and trims the returns and weights series accordingly). exposure_lag : int, default=1 Lag applied to time-varying exposures under the as-of time-indexing convention. The default value of `1` aligns exposures at :math:`t-1` with returns over :math:`(t-1, t]`. Only affects 3D (time-varying) exposures. idio_returns : array-like of shape (n_observations, n_assets) Idiosyncratic returns from the factor model regression. These are the residuals :math:`\varepsilon_{i,t}` from the cross-sectional regression. idio_variances : array-like of shape (n_observations, n_assets) or None, optional Per-asset idiosyncratic (specific) variances :math:`\sigma^2_{\varepsilon,i,t}`. Required when `compute_uncertainty=True`. NaN values are allowed and exclude the corresponding asset-observation pair from the uncertainty estimate. regression_weights : array-like of shape (n_observations, n_assets) or None, optional Per-asset cross-sectional regression weights :math:`q_{i,t}` used when estimating factor returns. Required when `compute_uncertainty=True`. Must not contain NaN. family_constraint_basis : FamilyConstraintBasis or None, optional When provided, the uncertainty estimator is computed in the reduced (full-rank) basis defined by the family-constraint change of coordinates. This avoids the singular Gram matrix that arises from collinear constrained families and produces well-conditioned standard errors. Only used when `compute_uncertainty=True`. annualization_factor : float, default=252.0 Used to annualize expected returns, variances and volatilities. Use 1.0 to disable annualization. Common values: 252 for daily data, 12 for monthly data. compute_asset_breakdowns : bool, default=True If True, compute asset-level attribution for each window. Results in 2D arrays of shape `(n_windows, n_assets)` in AssetBreakdown. Set to False to skip asset attribution for faster computation. compute_asset_factor_contribs : bool, default=False If True, compute asset-by-factor contributions for each window. Results in 3D arrays of shape `(n_windows, n_assets, n_factors)`. Disabled by default for faster computation. compute_uncertainty : bool, default=False If `True`, compute per-window attribution uncertainty (standard errors on the factor/idiosyncratic return split). Requires both `regression_weights` and `idio_variances`; raises `ValueError` if either is missing. If `False` (default), uncertainty is not computed. Returns ------- attribution : Attribution The :class:`Attribution` dataclass with rolling results. All numeric fields in :class:`Component` are 1D arrays of shape `(n_windows,)`. All numeric fields in :class:`Breakdown` are 2D arrays of shape `(n_windows, n_factors)` or `(n_windows, n_families)`. If `compute_asset_breakdowns=True`, asset attribution has shape `(n_windows, n_assets)`. The `observations` field contains the window end labels. See Also -------- realized_factor_attribution : Single-point realized factor attribution. Examples -------- >>> from skfolio.attribution import rolling_realized_factor_attribution >>> import numpy as np >>> import pandas as pd >>> >>> # Rolling attribution with 60-day windows, advancing 21 days (monthly) >>> dates = pd.bdate_range("2023-01-01", periods=252) >>> attribution = rolling_realized_factor_attribution( ... factor_returns=factor_returns, # (252, 3) ... portfolio_returns=portfolio_returns, # (252,) ... exposures=loading_matrix, # (10, 3) ... weights=weights, # (10,) ... idio_returns=residuals, # (252, 10) ... factor_names=["Momentum", "Value", "Size"], ... observations=dates, ... window_size=60, ... step=21, ... ) >>> print(f"Number of windows: {len(attribution.observations)}") >>> print(f"Total vol over time: {attribution.total.vol}") >>> >>> # Get MultiIndex DataFrame of factor attribution over time >>> df = attribution.factors_df(formatted=False) >>> print(df.head()) """ factor_returns = np.asarray(factor_returns, dtype=float) portfolio_returns = np.asarray(portfolio_returns, dtype=float) exposures = np.asarray(exposures, dtype=float) weights = np.asarray(weights, dtype=float) idio_returns = np.asarray(idio_returns, dtype=float) factor_names = np.asarray(factor_names) asset_names = np.asarray(asset_names) observations = np.asarray(observations) if factor_families is not None: factor_families = np.asarray(factor_families) if compute_uncertainty: if regression_weights is None or idio_variances is None: raise ValueError( "`compute_uncertainty=True` requires both `regression_weights` " "and `idio_variances` to be provided." ) regression_weights = np.asarray(regression_weights, dtype=float) idio_variances = np.asarray(idio_variances, dtype=float) _validate_no_nan(regression_weights, "regression_weights") else: regression_weights = None idio_variances = None _validate_no_nan(factor_returns, "factor_returns") _validate_no_nan(portfolio_returns, "portfolio_returns") _validate_no_nan(weights, "weights") if factor_returns.ndim != 2: raise ValueError( f"`factor_returns` must be 2D (n_observations, n_factors), got {factor_returns.ndim}D." ) # Apply exposure lag globally before windowing so that window_size consistently # refers to the number of effective return periods exposure_is_static = exposures.ndim == 2 if not exposure_is_static and exposure_lag > 0: exposures = exposures[:-exposure_lag] factor_returns = factor_returns[exposure_lag:] portfolio_returns = portfolio_returns[exposure_lag:] idio_returns = idio_returns[exposure_lag:] observations = observations[exposure_lag:] if weights.ndim == 2: weights = weights[exposure_lag:] if regression_weights is not None: regression_weights = regression_weights[exposure_lag:] if idio_variances is not None: idio_variances = idio_variances[exposure_lag:] if family_constraint_basis is not None: family_constraint_basis = family_constraint_basis[:-exposure_lag] exposures, idio_returns, inactive_mask = _zero_inactive_entries( exposures, idio_returns, exposure_is_static ) if idio_variances is not None: regression_weights, idio_variances = _prepare_uncertainty_inputs( regression_weights=regression_weights, idio_variances=idio_variances, inactive_mask=inactive_mask, ) n_observations, _ = factor_returns.shape if len(observations) != n_observations: raise ValueError( f"`observations` length {len(observations)} does not match n_observations={n_observations}." ) if window_size > n_observations: raise ValueError( f"`window_size` ({window_size}) exceeds n_observations ({n_observations})." ) if window_size < 2: raise ValueError(f"`window_size` must be >= 2, got {window_size}.") if step < 1: raise ValueError(f"`step` must be >= 1, got {step}.") # Compute rolling windows: each window spans [start, start + window_size) window_starts = np.arange(0, n_observations - window_size + 1, step) n_windows = len(window_starts) if n_windows == 0: raise ValueError( f"No valid windows: n_observations={n_observations}, window_size={window_size}, step={step}." ) # Label each window by the last observation it contains window_labels = observations[window_starts + window_size - 1] weights_is_static = weights.ndim == 1 has_uncertainty = regression_weights is not None # Compute attribution for each window results = [] for start in window_starts: end = start + window_size attr = _realized_factor_attribution_core( factor_returns=factor_returns[start:end], portfolio_returns=portfolio_returns[start:end], exposures=exposures if exposure_is_static else exposures[start:end], weights=weights if weights_is_static else weights[start:end], idio_returns=idio_returns[start:end], factor_names=factor_names, asset_names=asset_names, factor_families=factor_families, annualization_factor=annualization_factor, compute_asset_breakdowns=compute_asset_breakdowns, regression_weights=( regression_weights[start:end] if has_uncertainty else None ), idio_variances=(idio_variances[start:end] if has_uncertainty else None), family_constraint_basis=( family_constraint_basis[start:end] if family_constraint_basis is not None else None ), ) results.append(attr) systematic = _stack_dataclass([r.systematic for r in results]) idio = _stack_dataclass([r.idio for r in results]) unattributed = _stack_dataclass([r.unattributed for r in results]) total = _stack_dataclass([r.total for r in results]) factors = _stack_dataclass([r.factors for r in results]) if factor_families is not None: families = _stack_dataclass([r.families for r in results]) else: families = None # Stack asset breakdowns if compute_asset_breakdowns and results[0].assets is not None: assets = _stack_dataclass([r.assets for r in results]) else: assets = None # Stack asset-by-factor contributions if compute_asset_factor_contribs and results[0].asset_by_factor_contrib is not None: asset_factor_contribs = _stack_dataclass( [r.asset_by_factor_contrib for r in results] ) else: asset_factor_contribs = None return Attribution( systematic=systematic, idio=idio, unattributed=unattributed, total=total, factors=factors, families=families, assets=assets, asset_by_factor_contrib=asset_factor_contribs, is_realized=True, observations=window_labels, )
def _validate_attribution_inputs( factor_returns: FloatArray, portfolio_returns: FloatArray, exposures: FloatArray, weights: FloatArray, idio_returns: FloatArray, factor_names: StrArray, asset_names: StrArray, factor_families: StrArray | None, ) -> None: """Validate shapes and consistency of attribution inputs.""" if factor_returns.ndim != 2: raise ValueError( f"`factor_returns` must be 2D (n_observations, n_factors), got {factor_returns.ndim}D." ) n_observations, n_factors = factor_returns.shape if portfolio_returns.ndim != 1: raise ValueError( f"`portfolio_returns` must be 1D (n_observations,), got {portfolio_returns.ndim}D." ) if portfolio_returns.shape[0] != n_observations: raise ValueError( f"`portfolio_returns` length {portfolio_returns.shape[0]} does not match " f"n_observations={n_observations} from factor_returns." ) if exposures.ndim == 2: n_assets = exposures.shape[0] if exposures.shape[1] != n_factors: raise ValueError( f"`exposures` has {exposures.shape[1]} factors, expected {n_factors}." ) elif exposures.ndim == 3: if exposures.shape[0] != n_observations: raise ValueError( f"`exposures` has {exposures.shape[0]} observations, expected {n_observations}." ) n_assets = exposures.shape[1] if exposures.shape[2] != n_factors: raise ValueError( f"`exposures` has {exposures.shape[2]} factors, expected {n_factors}." ) else: raise ValueError( f"`exposures` must be 2D (n_assets, n_factors) or " f"3D (n_observations, n_assets, n_factors), got {exposures.ndim}D." ) if weights.ndim == 1: if weights.shape[0] != n_assets: raise ValueError( f"`weights` length {weights.shape[0]} does not match n_assets={n_assets}." ) elif weights.ndim == 2: if weights.shape[0] != n_observations: raise ValueError( f"`weights` has {weights.shape[0]} observations, expected {n_observations}." ) if weights.shape[1] != n_assets: raise ValueError( f"`weights` has {weights.shape[1]} assets, expected {n_assets}." ) else: raise ValueError( f"`weights` must be 1D (n_assets,) or 2D (n_observations, n_assets), " f"got {weights.ndim}D." ) if idio_returns.ndim != 2: raise ValueError( f"`idio_returns` must be 2D (n_observations, n_assets), got {idio_returns.ndim}D." ) if idio_returns.shape[0] != n_observations: raise ValueError( f"`idio_returns` has {idio_returns.shape[0]} observations, expected {n_observations}." ) if idio_returns.shape[1] != n_assets: raise ValueError( f"`idio_returns` has {idio_returns.shape[1]} assets, expected {n_assets}." ) if factor_names.shape[0] != n_factors: raise ValueError( f"`factor_names` length {factor_names.shape[0]} does not match " f"n_factors={n_factors}." ) if asset_names.shape[0] != n_assets: raise ValueError( f"`asset_names` length {asset_names.shape[0]} does not match " f"n_assets={n_assets}." ) if factor_families is not None and factor_families.shape[0] != n_factors: raise ValueError( f"`factor_families` length {factor_families.shape[0]} does not match " f"n_factors={n_factors}." ) def _zero_inactive_entries( exposures: FloatArray, idio_returns: FloatArray, exposure_is_static: bool ) -> tuple[FloatArray, FloatArray, BoolArray]: """Replace NaN with zero in exposures and idio_returns. An entry is considered inactive when either its idiosyncratic return or its exposure is NaN. Both are zeroed at inactive positions to preserve the additive identity across systematic, idiosyncratic, and unattributed components. """ nan_idio = np.isnan(idio_returns) nan_exposures = np.isnan(exposures) if exposure_is_static: inactive_assets = nan_exposures.any(axis=1) inactive_mask = nan_idio | inactive_assets[np.newaxis, :] exposure_inactive = inactive_assets[:, np.newaxis] else: inactive_mask = nan_idio | nan_exposures.any(axis=2) exposure_inactive = inactive_mask[:, :, np.newaxis] if inactive_mask.any(): idio_returns = np.where(inactive_mask, 0.0, idio_returns) exposures = np.where(exposure_inactive, 0.0, exposures) return exposures, idio_returns, inactive_mask def _prepare_uncertainty_inputs( regression_weights: FloatArray, idio_variances: FloatArray, inactive_mask: BoolArray, ) -> tuple[FloatArray, FloatArray]: """Zero unavailable asset-observation pairs in uncertainty inputs.""" unavailable_mask = inactive_mask | np.isnan(idio_variances) regression_weights = np.where(unavailable_mask, 0.0, regression_weights) idio_variances = np.where(unavailable_mask, 0.0, idio_variances) return regression_weights, idio_variances def _realized_factor_attribution_core( factor_returns: FloatArray, portfolio_returns: FloatArray, exposures: FloatArray, weights: FloatArray, idio_returns: FloatArray, asset_names: StrArray, factor_names: StrArray, factor_families: StrArray | None, annualization_factor: float, compute_asset_breakdowns: bool, regression_weights: FloatArray | None = None, idio_variances: FloatArray | None = None, family_constraint_basis: FamilyConstraintBasis | None = None, ) -> Attribution: """Core computation for realized factor attribution. Assumes all inputs are validated numpy arrays with NaN already zeroed and exposure lag already applied. """ _, n_factors = factor_returns.shape exposure_is_static = exposures.ndim == 2 weights_is_static = weights.ndim == 1 has_uncertainty = regression_weights is not None and idio_variances is not None # Portfolio total_mu = float(np.mean(portfolio_returns)) total_vol = float(np.std(portfolio_returns, ddof=1)) if total_vol <= 0: raise ValueError( f"Non-positive total volatility ({total_vol:.2e}). Check Portfolio Returns." ) ptf_ret_centered = portfolio_returns - np.mean(portfolio_returns) # Pure factor statistics factor_mu = np.mean(factor_returns, axis=0) factor_vol = np.std(factor_returns, axis=0, ddof=1) factor_cov_with_ptf = _cov_with_centered(factor_returns, ptf_ret_centered) factor_corr_with_ptf = np.full(n_factors, np.nan) valid_corr = factor_vol > 0 factor_corr_with_ptf[valid_corr] = factor_cov_with_ptf[valid_corr] / ( factor_vol[valid_corr] * total_vol ) # Portfolio factor exposures if exposure_is_static and weights_is_static: ptf_factor = weights @ exposures exposure_mean = ptf_factor exposure_std = np.zeros(n_factors) else: if exposure_is_static: ptf_factor = weights @ exposures elif weights_is_static: ptf_factor = exposures.transpose(0, 2, 1) @ weights else: ptf_factor = (weights[:, np.newaxis, :] @ exposures).squeeze(1) exposure_mean = np.mean(ptf_factor, axis=0) exposure_std = np.std(ptf_factor, axis=0, ddof=1) # Attribution uncertainty if has_uncertainty: systematic_uncertainty, per_factor_uncertainty, per_family_uncertainty = ( _compute_attribution_uncertainty( exposures=exposures, ptf_factor=ptf_factor, regression_weights=regression_weights, idio_variances=idio_variances, factor_families=factor_families, annualization_factor=annualization_factor, family_constraint_basis=family_constraint_basis, ) ) idio_uncertainty = systematic_uncertainty else: systematic_uncertainty = None idio_uncertainty = None per_factor_uncertainty = None per_family_uncertainty = None # Factor contributions factor_pnl = ptf_factor * factor_returns factor_mu_contrib = np.mean(factor_pnl, axis=0) factor_var_contrib = _cov_with_centered(factor_pnl, ptf_ret_centered) factor_vol_contrib = factor_var_contrib / total_vol factor_pct_total_variance = factor_vol_contrib / total_vol # Systematic systematic_pnl = np.sum(factor_pnl, axis=1) systematic_mu = float(np.sum(factor_mu_contrib)) systematic_vol = float(np.std(systematic_pnl, ddof=1)) systematic_cov = float(_cov_with_centered(systematic_pnl, ptf_ret_centered)) systematic_corr = safe_divide( systematic_cov, systematic_vol * total_vol, np.nan, atol=1e-12 ) systematic_variance = float(np.sum(factor_var_contrib)) systematic_vol_contrib = systematic_variance / total_vol systematic_pct_total_variance = systematic_vol_contrib / total_vol # Idiosyncratic idio_pnl = np.sum(weights * idio_returns, axis=1) idio_mu = float(np.mean(idio_pnl)) idio_vol = float(np.std(idio_pnl, ddof=1)) idio_cov = float(_cov_with_centered(idio_pnl, ptf_ret_centered)) idio_corr = safe_divide(idio_cov, idio_vol * total_vol, np.nan, atol=1e-12) idio_vol_contrib = idio_cov / total_vol idio_pct_total_variance = idio_vol_contrib / total_vol # Unattributed: difference between observed portfolio returns and the model # reconstruction (systematic + idio). Captures fees, cash, slippage, # intra-period trading, coverage gaps and the time-series regression # intercept. unattributed_pnl = portfolio_returns - systematic_pnl - idio_pnl unattributed_mu = float(np.mean(unattributed_pnl)) unattributed_vol = float(np.std(unattributed_pnl, ddof=1)) unattributed_cov = float(_cov_with_centered(unattributed_pnl, ptf_ret_centered)) unattributed_corr = safe_divide( unattributed_cov, unattributed_vol * total_vol, np.nan, atol=1e-12 ) unattributed_vol_contrib = unattributed_cov / total_vol unattributed_pct_total_variance = unattributed_vol_contrib / total_vol ann_sqrt = math.sqrt(annualization_factor) factors = FactorBreakdown( names=factor_names, family=factor_families, exposure=exposure_mean, vol=factor_vol * ann_sqrt, corr_with_ptf=factor_corr_with_ptf, vol_contrib=factor_vol_contrib * ann_sqrt, pct_total_variance=factor_pct_total_variance, mu=factor_mu * annualization_factor, mu_contrib=factor_mu_contrib * annualization_factor, exposure_std=exposure_std, mu_contrib_uncertainty=per_factor_uncertainty, ) # Family breakdown if factor_families is not None: families = _compute_realized_family_breakdown( factors=factors, factor_families=factor_families, ptf_factor=ptf_factor, is_static=exposure_is_static and weights_is_static, per_family_uncertainty=per_family_uncertainty, ) else: families = None # Asset-level attribution if compute_asset_breakdowns: assets, asset_factor_contribs = _compute_realized_assets( weights=weights, exposures=exposures, factor_returns=factor_returns, idio_returns=idio_returns, ptf_ret_centered=ptf_ret_centered, total_vol=total_vol, asset_names=asset_names, factor_names=factor_names, exposure_is_static=exposure_is_static, weights_is_static=weights_is_static, annualization_factor=annualization_factor, ) else: assets = None asset_factor_contribs = None return Attribution( systematic=Component( vol=systematic_vol * ann_sqrt, vol_contrib=systematic_vol_contrib * ann_sqrt, pct_total_variance=systematic_pct_total_variance, mu_contrib=systematic_mu * annualization_factor, corr_with_ptf=systematic_corr, mu_uncertainty=systematic_uncertainty, ), idio=Component( vol=idio_vol * ann_sqrt, vol_contrib=idio_vol_contrib * ann_sqrt, pct_total_variance=idio_pct_total_variance, mu_contrib=idio_mu * annualization_factor, corr_with_ptf=idio_corr, mu_uncertainty=idio_uncertainty, ), unattributed=Component( vol=unattributed_vol * ann_sqrt, vol_contrib=unattributed_vol_contrib * ann_sqrt, pct_total_variance=unattributed_pct_total_variance, mu_contrib=unattributed_mu * annualization_factor, corr_with_ptf=unattributed_corr, ), total=Component( vol=total_vol * ann_sqrt, vol_contrib=total_vol * ann_sqrt, pct_total_variance=1.0, mu_contrib=total_mu * annualization_factor, corr_with_ptf=1.0, ), factors=factors, families=families, assets=assets, asset_by_factor_contrib=asset_factor_contribs, is_realized=True, ) def _compute_realized_family_breakdown( factors: FactorBreakdown, factor_families: StrArray, ptf_factor: FloatArray, is_static: bool, per_family_uncertainty: FloatArray | None = None, ) -> FamilyBreakdown: """Compute family-level breakdown for realized attribution.""" unique_families, _ = np.unique(factor_families, return_inverse=True) n_families = len(unique_families) exposure_mean = np.zeros(n_families) exposure_std = np.zeros(n_families) vol_contrib = np.zeros(n_families) pct_total_variance = np.zeros(n_families) mu_contrib = np.zeros(n_families) for i, family in enumerate(unique_families): indices = np.where(factor_families == family)[0] exposure_mean[i] = factors.exposure[indices].sum() # Family exposure std if is_static: exposure_std[i] = 0.0 else: family_exposure = np.sum(ptf_factor[:, indices], axis=1) exposure_std[i] = np.std(family_exposure, ddof=1) vol_contrib[i] = factors.vol_contrib[indices].sum() pct_total_variance[i] = factors.pct_total_variance[indices].sum() mu_contrib[i] = factors.mu_contrib[indices].sum() # Sort by absolute pct_total_variance (descending) sort_order = np.argsort(-np.abs(pct_total_variance)) return FamilyBreakdown( names=unique_families[sort_order], exposure=exposure_mean[sort_order], exposure_std=exposure_std[sort_order], vol_contrib=vol_contrib[sort_order], pct_total_variance=pct_total_variance[sort_order], mu_contrib=mu_contrib[sort_order], mu_contrib_uncertainty=( per_family_uncertainty[sort_order] if per_family_uncertainty is not None else None ), ) def _compute_realized_assets( weights: FloatArray, exposures: FloatArray, factor_returns: FloatArray, idio_returns: FloatArray, ptf_ret_centered: FloatArray, total_vol: float, asset_names: StrArray, factor_names: StrArray, exposure_is_static: bool, weights_is_static: bool, annualization_factor: float, ) -> tuple[AssetBreakdown, AssetByFactorContribution]: """Compute asset-level attribution for realized attribution.""" ann_sqrt = math.sqrt(annualization_factor) n_observations = factor_returns.shape[0] n_assets = idio_returns.shape[1] # Asset systematic returns: (n_observations, n_assets) if exposure_is_static: systematic_returns = factor_returns @ exposures.T else: systematic_returns = (exposures @ factor_returns[:, :, np.newaxis]).squeeze(-1) # Asset returns (model): (n_observations, n_assets) asset_returns = systematic_returns + idio_returns # Standalone asset volatility and mean return vol = np.std(asset_returns, axis=0, ddof=1) mu = np.mean(asset_returns, axis=0) # Compute systematic and idio covariances. Derive total by linearity # Cov(systematic + idio, R_P) = Cov(systematic, R_P) + Cov(idio, R_P) if weights_is_static: weight_mean = weights weight_std = np.zeros(n_assets) systematic_cov = _cov_with_centered(systematic_returns, ptf_ret_centered) idio_cov = _cov_with_centered(idio_returns, ptf_ret_centered) cov_with_ptf = systematic_cov + idio_cov systematic_vol_contrib = weights * systematic_cov / total_vol idio_vol_contrib = weights * idio_cov / total_vol total_vol_contrib = systematic_vol_contrib + idio_vol_contrib systematic_mu_contrib = weights * np.mean(systematic_returns, axis=0) idio_mu_contrib = weights * np.mean(idio_returns, axis=0) else: weight_mean = np.mean(weights, axis=0) weight_std = np.std(weights, axis=0) systematic_pnl = weights * systematic_returns idio_pnl = weights * idio_returns systematic_cov = _cov_with_centered(systematic_returns, ptf_ret_centered) idio_cov = _cov_with_centered(idio_returns, ptf_ret_centered) cov_with_ptf = systematic_cov + idio_cov systematic_vol_contrib = ( _cov_with_centered(systematic_pnl, ptf_ret_centered) / total_vol ) idio_vol_contrib = _cov_with_centered(idio_pnl, ptf_ret_centered) / total_vol total_vol_contrib = systematic_vol_contrib + idio_vol_contrib systematic_mu_contrib = np.mean(systematic_pnl, axis=0) idio_mu_contrib = np.mean(idio_pnl, axis=0) total_mu_contrib = systematic_mu_contrib + idio_mu_contrib # Asset correlation with portfolio (uses total covariance from linearity) corr_with_ptf = np.full(n_assets, np.nan) valid_vol = vol > 0 corr_with_ptf[valid_vol] = cov_with_ptf[valid_vol] / (vol[valid_vol] * total_vol) pct_total_variance = total_vol_contrib / total_vol assets = AssetBreakdown( names=asset_names, weight=weight_mean, weight_std=weight_std, vol=vol * ann_sqrt, mu=mu * annualization_factor, corr_with_ptf=corr_with_ptf, systematic_vol_contrib=systematic_vol_contrib * ann_sqrt, idio_vol_contrib=idio_vol_contrib * ann_sqrt, vol_contrib=total_vol_contrib * ann_sqrt, pct_total_variance=pct_total_variance, systematic_mu_contrib=systematic_mu_contrib * annualization_factor, idio_mu_contrib=idio_mu_contrib * annualization_factor, mu_contrib=total_mu_contrib * annualization_factor, ) # Asset-factor contributions (vectorized over factors) if exposure_is_static and weights_is_static: factor_pnl = weights[:, None] * exposures * factor_returns[:, None, :] elif exposure_is_static: factor_pnl = ( weights[:, :, None] * exposures[None, :, :] * factor_returns[:, None, :] ) elif weights_is_static: factor_pnl = weights[None, :, None] * exposures * factor_returns[:, None, :] else: factor_pnl = weights[:, :, None] * exposures * factor_returns[:, None, :] factor_mu_contrib = np.mean(factor_pnl, axis=0) # Vectorized covariance: center once factor_pnl_centered = factor_pnl - np.mean(factor_pnl, axis=0, keepdims=True) factor_vol_contrib = (factor_pnl_centered.transpose(1, 2, 0) @ ptf_ret_centered) / ( (n_observations - 1) * total_vol ) asset_factor_contribs = AssetByFactorContribution( asset_names=asset_names, factor_names=factor_names, vol_contrib=factor_vol_contrib * ann_sqrt, mu_contrib=factor_mu_contrib * annualization_factor, ) return assets, asset_factor_contribs def _compute_attribution_uncertainty( exposures: FloatArray, ptf_factor: FloatArray, regression_weights: FloatArray, idio_variances: FloatArray, factor_families: StrArray | None, annualization_factor: float, family_constraint_basis: FamilyConstraintBasis | None = None, ) -> tuple[float, FloatArray, FloatArray | None]: r"""Compute standard errors for realized factor attribution. Propagate cross-sectional factor-return estimation uncertainty to mean return contributions. At each observation :math:`t`, the estimated factor return vector :math:`\hat{f}_t` has sandwich covariance .. math:: \operatorname{Var}(\hat{f}_t) = (B_t^\top W_t B_t)^{-1} \; B_t^\top W_t \, \Omega_{\varepsilon,t} \, W_t B_t \; (B_t^\top W_t B_t)^{-1} where :math:`W_t = \operatorname{diag}(q_{i,t})` and :math:`\Omega_{\varepsilon,t} = \operatorname{diag}(\sigma^2_{\varepsilon,i,t})`. When `family_constraint_basis` is provided, the sandwich covariance is computed in the reduced full-rank basis and mapped back to the full factor basis for per-factor and per-family reporting. The systematic mean return standard error is .. math:: \operatorname{SE} = \frac{\text{ann}}{T} \sqrt{\sum_{t=1}^{T} g_t^\top \operatorname{Var}(\hat{f}_t) \, g_t} where :math:`g_t = B_t^\top w_t` is the portfolio factor exposure vector. Parameters ---------- exposures : ndarray of shape (n_assets, n_factors) or (n_observations, n_assets, n_factors) Factor exposures in the full basis (already lag-aligned and NaN-zeroed). ptf_factor : ndarray of shape (n_factors,) or (n_observations, n_factors) Portfolio factor exposure :math:`g_t = B_t^\top w_t` in the full basis. regression_weights : ndarray of shape (n_observations, n_assets) Per-asset cross-sectional regression weights :math:`q_{i,t}`. idio_variances : ndarray of shape (n_observations, n_assets) Per-asset idiosyncratic return variances :math:`\sigma^2_{\varepsilon,i,t}`. factor_families : ndarray of shape (n_factors,) or None Family labels for per-family uncertainty. annualization_factor : float Annualization factor. family_constraint_basis : FamilyConstraintBasis or None Reduced full-rank basis used for constrained factor families. Returns ------- systematic_uncertainty : float Standard error (SE) of the systematic (factor) mean return attribution. Equals the idiosyncratic SE because their estimation errors sum to zero (total portfolio return is observed). per_factor_uncertainty : ndarray of shape (n_factors,) Per-factor attribution standard errors. per_family_uncertainty : ndarray of shape (n_families,) or None Per-family attribution standard errors or None if `factor_families` is not provided. """ n_observations, n_assets = regression_weights.shape if idio_variances.shape != regression_weights.shape: raise ValueError( "`idio_variances` must have the same shape as `regression_weights`." ) exposure_is_static = exposures.ndim == 2 full_exposures = exposures full_portfolio_exposure = ptf_factor if full_portfolio_exposure.ndim == 1: full_portfolio_exposure = np.broadcast_to( full_portfolio_exposure[np.newaxis], (n_observations, full_portfolio_exposure.shape[0]), ) n_factors = full_exposures.shape[-1] if factor_families is None: factor_is_currency = np.zeros(n_factors, dtype=bool) else: factor_is_currency = factor_families == _CURRENCY reg_weights = regression_weights idio_var = idio_variances # When a family-constraint basis is present, work in the reduced (full-rank) space # to avoid the singular Gram matrix caused by collinear constrained families if family_constraint_basis is not None: if exposure_is_static: full_exposures_for_basis = np.broadcast_to( full_exposures[np.newaxis], (n_observations, n_assets, full_exposures.shape[1]), ) else: full_exposures_for_basis = full_exposures regression_exposures = family_constraint_basis.reduce_exposures( full_exposures_for_basis ) regression_portfolio_exposure = ( family_constraint_basis.project_factor_coordinates(full_portfolio_exposure) ) reduced_factor_families = ( family_constraint_basis.reduced_factor_names(factor_families) if factor_families is not None else None ) reduced_factor_is_currency = ( reduced_factor_families == _CURRENCY if reduced_factor_families is not None else np.zeros(regression_exposures.shape[2], dtype=bool) ) regression_factor_mask = ~reduced_factor_is_currency else: regression_exposures = full_exposures regression_portfolio_exposure = full_portfolio_exposure regression_factor_mask = ~factor_is_currency if not np.all(regression_factor_mask): if regression_exposures.ndim == 2: regression_exposures = regression_exposures[:, regression_factor_mask] else: regression_exposures = regression_exposures[:, :, regression_factor_mask] regression_portfolio_exposure = regression_portfolio_exposure[ :, regression_factor_mask ] # Gram matrix if regression_exposures.ndim == 2: gram = np.einsum( "tn,nk,nl->tkl", reg_weights, regression_exposures, regression_exposures, optimize=True, ) else: gram = np.einsum( "tn,tnk,tnl->tkl", reg_weights, regression_exposures, regression_exposures, optimize=True, ) # Middle term sandwich_weights = reg_weights * reg_weights * idio_var if regression_exposures.ndim == 2: sandwich_middle_term = np.einsum( "tn,nk,nl->tkl", sandwich_weights, regression_exposures, regression_exposures, optimize=True, ) else: sandwich_middle_term = np.einsum( "tn,tnk,tnl->tkl", sandwich_weights, regression_exposures, regression_exposures, optimize=True, ) # Covariance via solve (avoids explicit inverse): try: left_solved_term = np.linalg.solve(gram, sandwich_middle_term) factor_covariance_regression = np.linalg.solve( gram, left_solved_term.transpose(0, 2, 1) ).transpose(0, 2, 1) except np.linalg.LinAlgError: gram_inv = np.linalg.pinv(gram) factor_covariance_regression = gram_inv @ sandwich_middle_term @ gram_inv scale = annualization_factor / n_observations # Total systematic SE (invariant under basis change: g' Var g is the same # in full or reduced basis) covariance_times_exposure = ( factor_covariance_regression @ regression_portfolio_exposure[:, :, np.newaxis] ) per_observation_variance = ( regression_portfolio_exposure[:, np.newaxis, :] @ covariance_times_exposure ).ravel() systematic_uncertainty = scale * math.sqrt( max(0.0, float(np.sum(per_observation_variance))) ) # Map covariance back to full basis for per-factor and per-family SEs. Currency # factors are direct factor returns, so their regression-estimation uncertainty is # reported as NaN. if family_constraint_basis is not None: n_reduced = family_constraint_basis.n_reduced_factors factor_covariance_reduced = np.zeros( (n_observations, n_reduced, n_reduced), dtype=float ) regression_idx = np.flatnonzero(regression_factor_mask) factor_covariance_reduced[:, regression_idx[:, np.newaxis], regression_idx] = ( factor_covariance_regression ) factor_covariance = family_constraint_basis.expand_factor_covariance( factor_covariance_reduced ) else: factor_covariance = np.zeros( (n_observations, n_factors, n_factors), dtype=float ) regression_idx = np.flatnonzero(regression_factor_mask) factor_covariance[:, regression_idx[:, np.newaxis], regression_idx] = ( factor_covariance_regression ) # Per-factor SE (diagonal elements only) factor_variances = np.diagonal(factor_covariance, axis1=1, axis2=2) per_factor_uncertainty = scale * np.sqrt( np.maximum(0.0, np.sum(full_portfolio_exposure**2 * factor_variances, axis=0)) ) per_factor_uncertainty[factor_is_currency] = np.nan # Per-family SE (full covariance sub-block per family) if factor_families is not None: unique_families = np.unique(factor_families) n_families = len(unique_families) per_family_uncertainty = np.empty(n_families) for i, fam in enumerate(unique_families): family_idx = np.where(factor_families == fam)[0] if fam == _CURRENCY: per_family_uncertainty[i] = np.nan else: family_exposure = full_portfolio_exposure[:, family_idx] family_covariance = factor_covariance[:, family_idx][:, :, family_idx] family_cov_exposure = ( family_covariance @ family_exposure[:, :, np.newaxis] ) family_variance = ( family_exposure[:, np.newaxis, :] @ family_cov_exposure ).ravel() per_family_uncertainty[i] = scale * math.sqrt( max(0.0, float(np.sum(family_variance))) ) else: per_family_uncertainty = None return systematic_uncertainty, per_factor_uncertainty, per_family_uncertainty def _stack_dataclass(items: list): """Stack a list of dataclass instances into one with arrays stacked along axis 0.""" first_item = items[0] cls = first_item.__class__ kwargs = {} for field in fields(cls): fname = field.name first_val = getattr(first_item, fname) if first_val is None: kwargs[fname] = None elif isinstance(first_val, (int, float)): # Scalar float: stack into 1D array kwargs[fname] = np.array([getattr(item, fname) for item in items]) elif isinstance(first_val, np.ndarray) and np.issubdtype( first_val.dtype, np.floating ): if first_val.ndim == 1: # 1D FloatArray: stack into 2D array kwargs[fname] = np.vstack([getattr(item, fname) for item in items]) else: # 2D FloatArray: stack into 3D array kwargs[fname] = np.stack( [getattr(item, fname) for item in items], axis=0 ) else: # StrArray (names, family) or other: take from first element kwargs[fname] = first_val return cls(**kwargs)