Source code for skfolio.attribution._predicted

"""Predicted (ex-ante) 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 warnings

import numpy as np

from skfolio.attribution._model import (
    AssetBreakdown,
    AssetByFactorContribution,
    Attribution,
    Component,
    FactorBreakdown,
    FamilyBreakdown,
)
from skfolio.attribution._utils import _validate_no_nan
from skfolio.typing import ArrayLike, FloatArray, StrArray
from skfolio.utils.stats import assert_is_square

__all__ = ["predicted_factor_attribution"]


[docs] def predicted_factor_attribution( weights: ArrayLike, loading_matrix: ArrayLike, factor_covariance: ArrayLike, idio_covariance: ArrayLike, asset_names: ArrayLike, factor_names: ArrayLike, factor_families: ArrayLike | None = None, factor_mu: ArrayLike | None = None, idio_mu: ArrayLike | None = None, annualization_factor: float = 252.0, compute_asset_breakdowns: bool = True, ) -> Attribution: r"""Compute predicted (ex-ante) factor volatility and return attribution. The volatility attribution follows the exposure-volatility-correlation framework (also called :math:`x-\sigma-\rho`). It decomposes portfolio volatility into systematic (factor) and idiosyncratic (specific) contributions. The return attribution decomposes portfolio expected return into spanned (factor-explained) and orthogonal expected return contributions. **Factor Model:** The asset covariance matrix is modeled as: .. math:: \Sigma = B F B^\top + D where :math:`B` is the asset-by-factor loading matrix, :math:`F` is the factor covariance matrix and :math:`D` is the idiosyncratic covariance matrix. The expected return vector is modeled as: .. math:: \mu = B \mu_f + \mu_\perp where :math:`\mu_f` contains the expected factor returns (factor premia), :math:`B \mu_f` is the factor-spanned expected return and :math:`\mu_\perp` is the factor-orthogonal expected return, also called orthogonal alpha. **Portfolio Variance Decomposition:** Let :math:`w` be portfolio weights and :math:`b = B^\top w` be the portfolio factor exposure vector. Then: .. math:: \sigma_P^2 = w^\top \Sigma w = b^\top F b + w^\top D w. **Portfolio Expected Return Decomposition:** .. math:: \mu_P = w^\top \mu = b^\top \mu_f + w^\top \mu_\perp. **Volatility Contributions:** The contribution of factor :math:`k` to portfolio volatility is defined as: .. math:: \operatorname{VolContrib}_k = \frac{b_k (F b)_k}{\sigma_P}. where :math:`\sigma_P = \sqrt{w^\top \Sigma w}` is total portfolio volatility. These contributions are additive: they sum to the systematic component of volatility. .. math:: \sum_k \operatorname{VolContrib}_k = \frac{b^\top F b}{\sigma_P}. The systematic vs. idiosyncratic vs. total component contributions are: .. math:: \operatorname{VolContrib}_{\mathrm{sys}} = \frac{b^\top F b}{\sigma_P}, \qquad \operatorname{VolContrib}_{\mathrm{idio}} = \frac{w^\top D w}{\sigma_P}, \qquad \operatorname{VolContrib}_{\mathrm{total}} = \sigma_P. and sum exactly: :math:`\operatorname{VolContrib}_{\mathrm{sys}} + \operatorname{VolContrib}_{\mathrm{idio}} = \sigma_P`. **Expected Return Contributions:** The contribution of each factor to spanned expected return is: .. math:: \operatorname{MuContrib}_k = b_k \mu_{f,k}. These are also additive: .. math:: \sum_k \operatorname{MuContrib}_k = b^\top \mu_f. **Correlation (x-sigma-rho framework):** Let :math:`\sigma_k = \sqrt{F_{kk}}` be factor :math:`k` standalone volatility. The correlation of factor :math:`k` with the portfolio return is: .. math:: \rho_{k,P} = \frac{(F b)_k}{\sigma_k \sigma_P}. The factor volatility contribution can then be written as: .. math:: \operatorname{VolContrib}_k = b_k \sigma_k \rho_{k,P}. **Percentage of Total Variance:** The variance share of each factor is: .. math:: \operatorname{PctTotalVariance}_k = \frac{\operatorname{VolContrib}_k}{\sigma_P}. **NaN handling**: `loading_matrix`, `idio_covariance` and `idio_mu` may contain NaN for non-investable assets (delisted, not-yet-listed, warm-up). For `idio_covariance`, inactive assets are identified by NaN diagonal entries, following the covariance estimator convention. When a non-zero weight falls on such an asset a warning is emitted and the asset's contribution is effectively zeroed out. `weights`, `factor_covariance` and `factor_mu` must be finite. Parameters ---------- weights : array-like of shape (n_assets,) Portfolio weights vector. loading_matrix : array-like of shape (n_assets, n_factors) Asset-by-factor loading (exposure) matrix.. NaN entries for non-investable assets are filled with 0 (requires corresponding weights to be zero). factor_covariance : array-like of shape (n_factors, n_factors) Covariance matrix of the factors. Must be per-period (e.g., daily covariance if using daily data). Use `annualization_factor` to scale to annualized values. idio_covariance : array-like of shape (n_assets,) or (n_assets, n_assets) Idiosyncratic (specific) covariance. If 1D, treated as diagonal variances. If 2D, used as full covariance matrix. Must be per-period, same as `factor_covariance`. NaN entries for non-investable assets are filled with 0. 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 DataFrame output. factor_mu : array-like of shape (n_factors,), optional Expected returns of each factor (factor premia), :math:`\mu_f`. Defaults to zeros if not provided. Must be per-period (e.g., daily expected returns if using daily data). All inputs (`factor_covariance`, `idio_covariance`, `factor_mu`, `idio_mu`) must share the same periodicity. Use `annualization_factor` to scale outputs to annualized values. idio_mu : array-like of shape (n_assets,), optional Factor-orthogonal expected return for each asset, :math:`\mu_\perp`. It is distinct from the time-series mean of `idio_returns`, which is not enforced to be factor-orthogonal. Defaults to zeros if not provided. Must be per-period, same as `factor_mu`. NaN entries for non-investable assets are filled with 0 (requires corresponding weights to be zero). .. note:: This vector **must already be orthogonal** to the column span of the loading matrix :math:`B` (with respect to your chosen regression metric, e.g., OLS or GLS). This function does **not** perform any orthogonalization. It assumes `idio_mu` satisfies the decomposition :math:`\mu = B \mu_f + \mu_\perp` where :math:`B^\top \mu_\perp = 0` (or the appropriate weighted inner product equals zero for GLS). Typically, this is the residual vector from regressing expected asset returns onto the factor loadings. 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. Returns ------- attribution : Attribution The :class:`Attribution` dataclass containing component-level, factor-level and optionally asset-level attribution results. Use `attribution.summary_df()`, `attribution.factors_df()`, `attribution.assets_df()` to convert to pandas DataFrames. Raises ------ ValueError If input dimensions are inconsistent, if `weights`,`factor_covariance` or `factor_mu` contain NaN, or if total variance is non-positive. Examples -------- >>> from skfolio.attribution import predicted_factor_attribution >>> import numpy as np >>> >>> # Volatility attribution only >>> attribution = predicted_factor_attribution( ... weights=np.array([0.4, 0.3, 0.3]), ... loading_matrix=loading_matrix, ... factor_covariance=factor_cov, ... idio_covariance=idio_cov, ... factor_names=["Momentum", "Value", "Size"], ... ) >>> print(f"Total volatility: {attribution.total.vol:.2%}") >>> print(f"Factor exposures: {attribution.factors.exposure}") >>> >>> # With families >>> attribution = predicted_factor_attribution( ... weights=np.array([0.4, 0.3, 0.3]), ... loading_matrix=loading_matrix, ... factor_covariance=factor_cov, ... idio_covariance=idio_cov, ... factor_names=["Momentum", "Value", "Size"], ... factor_families=["Style", "Style", "Size"], ... ) >>> print(f"Family names: {attribution.families.names}") >>> print(f"Family vol contribs: {attribution.families.vol_contrib}") >>> >>> # Volatility and return attribution >>> attribution = predicted_factor_attribution( ... weights=np.array([0.4, 0.3, 0.3]), ... loading_matrix=loading_matrix, ... factor_covariance=factor_cov, ... idio_covariance=idio_cov, ... factor_names=["Momentum", "Value", "Size"], ... factor_mu=np.array([0.05, 0.03, 0.02]), ... ) >>> attribution.summary_df() """ # Validate weights weights = np.asarray(weights, dtype=float) if weights.ndim != 1: raise ValueError(f"`weights` must be 1D, got {weights.ndim}D array.") n_assets = weights.shape[0] # Validate loading matrix loadings = np.asarray(loading_matrix, dtype=float) if loadings.ndim != 2: raise ValueError(f"`loading_matrix` must be 2D, got {loadings.ndim}D array.") if loadings.shape[0] != n_assets: raise ValueError( f"`loading_matrix` must have {n_assets} rows (n_assets), " f"got {loadings.shape[0]}." ) n_factors = loadings.shape[1] # Validate factor covariance factor_cov = np.asarray(factor_covariance, dtype=float) assert_is_square(factor_cov) if factor_cov.shape[0] != n_factors: raise ValueError( f"`factor_covariance` shape {factor_cov.shape} does not match " f"n_factors={n_factors} from loading_matrix." ) # Validate idiosyncratic covariance (1D or 2D) idio_cov = np.asarray(idio_covariance, dtype=float) if idio_cov.ndim == 1: if idio_cov.shape[0] != n_assets: raise ValueError( f"`idio_covariance` 1D length {idio_cov.shape[0]} " f"does not match n_assets={n_assets}." ) # Convert diagonal variances to full covariance matrix idio_cov = np.diag(idio_cov) elif idio_cov.ndim == 2: assert_is_square(idio_cov) if idio_cov.shape[0] != n_assets: raise ValueError( f"`idio_covariance` shape {idio_cov.shape} " f"does not match n_assets={n_assets}." ) else: raise ValueError(f"`idio_covariance` must be 1D or 2D, got {idio_cov.ndim}D.") # Validate factor names factor_names = np.asarray(factor_names) if factor_names.shape[0] != n_factors: raise ValueError( f"`factor_names` length {factor_names.shape[0]} " f"does not match n_factors={n_factors}." ) # Validate asset names asset_names = np.asarray(asset_names) if asset_names.shape[0] != n_assets: raise ValueError( f"`asset_names` length {asset_names.shape[0]} " f"does not match n_assets={n_assets}." ) # Validate factor families if factor_families is not None: factor_families = np.asarray(factor_families) if factor_families.shape[0] != n_factors: raise ValueError( f"`factor_families` length {factor_families.shape[0]} " f"does not match n_factors={n_factors}." ) # Default factor_mu to zeros if not provided if factor_mu is None: factor_mu = np.zeros(n_factors) else: factor_mu = np.asarray(factor_mu) if factor_mu.ndim != 1: raise ValueError(f"`factor_mu` must be 1D, got {factor_mu.ndim}D array.") if factor_mu.shape[0] != n_factors: raise ValueError( f"`factor_mu` length {factor_mu.shape[0]} " f"does not match n_factors={n_factors}." ) # Default idio_mu to zeros if not provided if idio_mu is None: idio_mu = np.zeros(n_assets) else: idio_mu = np.asarray(idio_mu) if idio_mu.ndim != 1: raise ValueError(f"`idio_mu` must be 1D, got {idio_mu.ndim}D array.") if idio_mu.shape[0] != n_assets: raise ValueError( f"`idio_mu` length {idio_mu.shape[0]} " f"does not match n_assets={n_assets}." ) # NaN handling # factor_covariance and factor_mu are per-factor quantities and must be finite. _validate_no_nan(weights, "weights") _validate_no_nan(factor_cov, "factor_covariance") _validate_no_nan(factor_mu, "factor_mu") # loading_matrix, idio_covariance and idio_mu may contain NaN for non-investable # assets (delistings, warm-up, not-yet-listed). A non-zero weight on such an asset # triggers a warning. NaN entries are then filled with 0 so that 0*NaN do not # propagate. non_investable = np.isnan(loadings).any(axis=1) non_investable |= np.isnan(np.diag(idio_cov)) non_investable |= np.isnan(idio_mu) if non_investable.any(): conflict = non_investable & (np.abs(weights) > 0) if conflict.any(): names = asset_names[conflict].tolist() warnings.warn( f"{int(conflict.sum())} asset(s) have non-zero weight but " f"NaN model estimates (delisted or non-investable): {names}. " f"Their contributions are zeroed out in the decomposition.", stacklevel=2, ) loadings = np.where(np.isnan(loadings), 0.0, loadings) idio_cov = np.where(np.isnan(idio_cov), 0.0, idio_cov) idio_mu = np.where(np.isnan(idio_mu), 0.0, idio_mu) # Apply annualization factor_cov = factor_cov * annualization_factor idio_cov = idio_cov * annualization_factor factor_mu = factor_mu * annualization_factor idio_mu = idio_mu * annualization_factor # Portfolio factor exposures factor = loadings.T @ weights # Variance decomposition factor_cov_with_ptf = factor_cov @ factor systematic_var = float(factor.T @ factor_cov_with_ptf) idio_var = float(weights.T @ idio_cov @ weights) total_var = systematic_var + idio_var if total_var <= 0: raise ValueError( f"Non-positive total variance ({total_var:.2e}). " "Check inputs for alignment and unit consistency." ) total_vol = float(np.sqrt(total_var)) systematic_vol = float(np.sqrt(systematic_var)) idio_vol = float(np.sqrt(idio_var)) # Volatility contributions (additive, sum to total_vol) systematic_vol_contrib = systematic_var / total_vol idio_vol_contrib = idio_var / total_vol systematic_pct_total_variance = systematic_var / total_var idio_pct_total_variance = idio_var / total_var # Component correlations with portfolio # Under factor model: Cov(component, ptf) = Var(component) since components # are uncorrelated. Therefore: Corr = Var / (vol * total_vol) = vol / total_vol systematic_corr_with_ptf = systematic_vol / total_vol idio_corr_with_ptf = idio_vol / total_vol # Per-factor risk statistics factor_vol = np.sqrt(np.maximum(np.diag(factor_cov), 0.0)) # Correlation of each factor with total portfolio factor_corr_with_ptf = np.full(n_factors, np.nan, dtype=float) nonzero_vol = factor_vol > 0 factor_corr_with_ptf[nonzero_vol] = factor_cov_with_ptf[nonzero_vol] / ( factor_vol[nonzero_vol] * total_vol ) # Volatility contribution per factor factor_vol_contrib = factor * factor_cov_with_ptf / total_vol factor_pct_total_variance = factor_vol_contrib / total_vol # Expected return contribution by factor factor_mu_contrib = factor * factor_mu # Spanned expected return systematic_mu = float(np.sum(factor_mu_contrib)) # Orthogonal expected return orthogonal_mu = float(weights.T @ idio_mu) # Total expected return total_mu = systematic_mu + orthogonal_mu # Factors breakdown factors = FactorBreakdown( names=factor_names, family=factor_families, exposure=factor, exposure_std=None, vol=factor_vol, corr_with_ptf=factor_corr_with_ptf, vol_contrib=factor_vol_contrib, pct_total_variance=factor_pct_total_variance, mu=factor_mu, mu_contrib=factor_mu_contrib, ) # Family breakdown if factor_families is not None: families = _compute_predicted_family_breakdown( factors=factors, factor_families=factor_families, ) else: families = None # Asset-level attribution if compute_asset_breakdowns: assets, asset_factor_contribs = _compute_predicted_assets( weights=weights, loadings=loadings, factor_cov=factor_cov, idio_cov=idio_cov, factor_mu=factor_mu, idio_mu=idio_mu, factor_cov_with_ptf=factor_cov_with_ptf, total_vol=total_vol, asset_names=asset_names, factor_names=factor_names, ) else: assets = None asset_factor_contribs = None return Attribution( systematic=Component( vol=systematic_vol, vol_contrib=systematic_vol_contrib, pct_total_variance=systematic_pct_total_variance, mu_contrib=systematic_mu, corr_with_ptf=systematic_corr_with_ptf, ), idio=Component( vol=idio_vol, vol_contrib=idio_vol_contrib, pct_total_variance=idio_pct_total_variance, mu_contrib=orthogonal_mu, corr_with_ptf=idio_corr_with_ptf, ), total=Component( vol=total_vol, vol_contrib=total_vol, pct_total_variance=1.0, mu_contrib=total_mu, corr_with_ptf=1.0, ), unattributed=None, factors=factors, families=families, assets=assets, asset_by_factor_contrib=asset_factor_contribs, )
def _compute_predicted_family_breakdown( factors: FactorBreakdown, factor_families: StrArray ) -> FamilyBreakdown: """Compute family-level breakdown for predicted attribution.""" unique_families, _ = np.unique(factor_families, return_inverse=True) n_families = len(unique_families) exposure = 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[i] = factors.exposure[indices].sum() 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[sort_order], exposure_std=None, vol_contrib=vol_contrib[sort_order], pct_total_variance=pct_total_variance[sort_order], mu_contrib=mu_contrib[sort_order], ) def _compute_predicted_assets( weights: FloatArray, loadings: FloatArray, factor_cov: FloatArray, idio_cov: FloatArray, factor_mu: FloatArray, idio_mu: FloatArray, factor_cov_with_ptf: FloatArray, total_vol: float, asset_names: StrArray, factor_names: StrArray, ) -> tuple[AssetBreakdown, AssetByFactorContribution]: """Compute asset-level attribution for predicted attribution.""" n_assets = len(weights) # Full covariance for asset-level stats systematic_cov = loadings @ factor_cov @ loadings.T full_cov = systematic_cov + idio_cov # Asset standalone volatility and expected return vol = np.sqrt(np.maximum(np.diag(full_cov), 0.0)) mu = loadings @ factor_mu + idio_mu # Asset covariance with portfolio cov_with_ptf = full_cov @ weights # Asset correlation with portfolio 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) # Asset total volatility contributions total_vol_contrib = weights * cov_with_ptf / total_vol # Systematic volatility contributions systematic_vol_contrib = weights * (systematic_cov @ weights) / total_vol # Idiosyncratic volatility contributions idio_vol_contrib = weights * (idio_cov @ weights) / total_vol # Percentage of total variance pct_total_variance = total_vol_contrib / total_vol # Asset return contributions systematic_mu_contrib = weights * (loadings @ factor_mu) idio_mu_contrib = weights * idio_mu total_mu_contrib = systematic_mu_contrib + idio_mu_contrib assets = AssetBreakdown( names=asset_names, weight=weights, weight_std=None, vol=vol, mu=mu, corr_with_ptf=corr_with_ptf, systematic_vol_contrib=systematic_vol_contrib, idio_vol_contrib=idio_vol_contrib, vol_contrib=total_vol_contrib, pct_total_variance=pct_total_variance, systematic_mu_contrib=systematic_mu_contrib, idio_mu_contrib=idio_mu_contrib, mu_contrib=total_mu_contrib, ) # Asset-factor contribs factor_vol_contrib = np.outer(weights, factor_cov_with_ptf) * loadings / total_vol factor_mu_contrib = np.outer(weights, factor_mu) * loadings asset_factor_contrib = AssetByFactorContribution( asset_names=asset_names, factor_names=factor_names, vol_contrib=factor_vol_contrib, mu_contrib=factor_mu_contrib, ) return assets, asset_factor_contrib