Characteristics Factor Model#

This tutorial shows how to build a characteristics-based cross-sectional factor model with CharacteristicsFactorModel. The methodology is covered in the Factor Models user guide.

A characteristics factor model builds factor exposures from point-in-time asset characteristics (e.g. industry, market capitalization, book equity, analyst estimates). It computes the exposures from the data and estimates the factor returns at each date by cross-sectional regression of asset returns on factor exposures [1] (see Cross-Sectional Regression):

\[r_t = B \, f_t + \epsilon_t\]

where \(B\) is the factor exposure matrix, \(f_t\) the factor returns and \(\epsilon_t\) the idiosyncratic returns.

The model then assembles the asset covariance forecast from the factor covariance \(F\), estimated on the factor return series, and the idiosyncratic covariance \(D\), estimated from the idiosyncratic returns [2]:

\[\Sigma = B \, F \, B^\top + D\]

We build a model with 24 factors (1 global market factor, 10 industry factors and 13 style factors from 29 descriptors), fit it on a synthetic panel, run its diagnostics, evaluate its covariance forecast out of sample and tune its hyperparameters.

Data#

We use a synthetic characteristics panel generated by make_synthetic_characteristics. It includes late listings, delistings, holidays and missing characteristics. Asset characteristics come from commercial point-in-time datasets whose licences prohibit redistribution, so the gallery uses synthetic data to present the full API. For results on real data, see the Factor Models user guide.

import numpy as np
from plotly.io import show

from skfolio.datasets import make_synthetic_characteristics

panel = make_synthetic_characteristics(
    n_assets=500, n_observations=1500, random_state=0
)

The data is stored in an AssetPanel, skfolio’s container for aligned cross-sectional asset data. The motivation for introducing this container and its practical benefits for portfolio workflows are covered in the Asset Data Representation user guide.

Let’s inspect the data with to_dataframe, which converts the panel to a pandas DataFrame:

panel.to_dataframe(output_format="long").head()
returns adj_close adj_volume adj_shares_outstanding market_cap ebitda_ttm enterprise_value net_income_ttm sales_ttm dividends_ttm net_buybacks_ttm book_equity operating_cash_flow_ttm total_debt total_assets cost_of_revenue_ttm capex_ttm short_interest eps_ntm dps_ntm eps_ntm_std industry estimation_mask
observation asset
2015-01-01 A00000 -0.023581 55.431355 3.048264e+06 577.542542 32013.964844 1396.505371 32013.964844 -304.867615 10306.179688 901.101868 -221.625778 30139.435547 1346.731445 0.000000 21506.078125 8602.247070 826.031616 4.154056e+07 2.260546 1.436233 NaN Commercial True
A00001 -0.004681 33.269730 3.952315e+05 147.477829 4906.547852 383.519653 5568.491211 147.217194 1782.052856 0.000000 -100.664780 788.497253 1012.667664 1230.327393 3611.748535 798.684265 126.468605 1.150566e+06 2.664619 0.000000 0.030275 Software True
A00002 -0.002183 56.557594 5.424788e+05 28.671783 1621.607056 110.951393 1938.065552 -90.332619 412.759552 21.788160 78.208153 652.925171 288.374573 486.183228 882.025696 275.775146 38.448761 6.845253e+05 6.742587 1.181923 NaN Real Estate True
A00004 0.001940 22.658016 1.985316e+06 294.775696 6679.032227 678.416077 6095.890625 -306.241791 2530.895020 0.000000 -19.279099 2577.400391 861.530334 0.000000 6047.070801 NaN 262.698578 1.500754e+07 1.688179 0.000000 0.380726 Tech Hardware True
A00006 0.000708 59.200443 1.277583e+06 411.770355 24376.988281 397.791931 24674.291016 1611.451660 2444.777100 0.000000 974.784668 3333.917969 2917.529053 581.578430 3840.822754 1225.923950 37.817062 1.326087e+07 0.741196 0.000000 NaN Capital Goods True


Next, we summarize the panel structure with the info method:

print(panel.info())
AssetPanel Info
============================================================
Observations  : 1,500  (2015-01-01 -> 2020-09-30)
Assets        : 500
Fields        : 22
Panel entries : 750,000  (observations x assets)
Missing       : 13.8% total, 4.5% in Active Mask

Active Mask
-----------
In mask              : 677,235 / 750,000 entries  (90.3%)
Assets per obs       : min=429, median=456, max=467
Assets in mask       : 500 / 500
  median duration    : 1,500 observations
  shortest / longest : 264 / 1,500 observations

Estimation Mask
---------------
In mask              : 645,011 / 750,000 entries  (86.0%)
Assets per obs       : min=410, median=434, max=445
Assets in mask       : 475 / 500
  median duration    : 1,500 observations
  shortest / longest : 264 / 1,500 observations

Field Coverage
------------------------------------------------------------
                           dtype   % missing   % missing    fully missing
                                       total   in Active Mask   assets (active)
                returns  float32        9.7%          0.0%                0
              adj_close  float32        9.7%          0.0%                0
             adj_volume  float32        9.7%          0.0%                0
 adj_shares_outstanding  float32        9.7%          0.0%                0
             market_cap  float32        9.7%          0.0%                0
             ebitda_ttm  float32       18.7%         10.0%                0
       enterprise_value  float32       18.7%         10.0%                0
         net_income_ttm  float32       10.6%          1.0%                0
              sales_ttm  float32       10.6%          1.0%                0
          dividends_ttm  float32       10.6%          1.0%                0
       net_buybacks_ttm  float32       10.6%          1.0%                0
            book_equity  float32       10.6%          1.0%                0
operating_cash_flow_ttm  float32       10.6%          1.0%                0
             total_debt  float32       10.6%          1.0%                0
           total_assets  float32       10.6%          1.0%                0
    cost_of_revenue_ttm  float32       18.8%         10.0%                0
              capex_ttm  float32       10.6%          1.0%                0
         short_interest  float32       10.6%          1.0%                0
                eps_ntm  float32       27.8%         20.0%                0
                dps_ntm  float32       27.8%         20.0%                0
            eps_ntm_std  float32       27.7%         19.9%                0
               industry    int32        9.7%          0.0%                0

Categorical Fields
------------------------------------------------------------
industry : 10 levels
  Min number of assets per level (over time):
       < 10 :  0 levels
    10 - 20 :  0 levels
    20 - 50 :  8 levels  (Software, Banks, Energy, Commercial, ... +4 more)
       > 50 :  2 levels  (Real Estate, Capital Goods)

active_mask records whether each asset belongs to the universe at each observation, distinguishing missing data for an active asset from periods outside the universe (e.g. before listing or after delisting). estimation_mask selects the active asset-observation pairs used to estimate cross-sectional statistics and factor returns. The model still computes exposures and forecasts for active pairs outside this subset. See Coverage, Estimation and Investment Universes for details.

In this panel:

  • 90.3% of asset-observation pairs are active.

  • 475 of the 500 assets contribute to model estimation.

  • Analyst-estimate fields are missing about 20% of the time and industry has 10 categories.

Factor Exposures#

We define each factor with a factor exposure estimator that transforms the panel fields into exposures (see Factor Exposures in the user guide). Estimators carry a family attribute ("market", "industry", "style") used for neutralization, zero-sum constraints and reporting.

Global Factor#

The global factor has unit exposure for every asset. With benchmark-weighted centering and the industry zero-sum constraint set below, its factor return captures the benchmark (market) return (see Global Factor and Benchmark Portfolio):

from skfolio.factor_exposure import GlobalFactor

global_factor = GlobalFactor(family="market")

Industry Factors#

Next, we derive the industry factor exposures by one-hot encoding the industry categorical field:

from skfolio.factor_exposure import OneHotCategoricalFactors

industry_factors = OneHotCategoricalFactors(category="industry", family="industry")

Style Factors#

Now we build the style factors with FixedWeightedFactor from one or more descriptors, each computed from panel fields (e.g. BookToPrice from book_equity and market_cap). The estimator winsorizes and z-scores each descriptor cross-sectionally, then combines the scores with the fixed weights (see Descriptors and Cross-Sectional Transformers). With transform_by_group="industry", scoring happens within each industry, which makes style exposures orthogonal to the industry factors (see Neutralization):

from skfolio.descriptor import (
    AssetsGrowthRate,
    AssetTurnover,
    BookLeverage,
    BookToPrice,
    CapexToAssetsChangeInIntensity,
    CashFlowToAssets,
    CashFlowToPrice,
    DebtToAssets,
    DividendToPrice,
    EarningsChangeToPrice,
    EarningsToPrice,
    EbitdaToEnterpriseValue,
    EWAmihudIlliquidity,
    EWMarketBeta,
    EWMomentum,
    EWResidualVolatility,
    EWShareTurnover,
    EWVolatility,
    ForwardEarningsToPrice,
    GrossMargin,
    GrossProfitability,
    IssuanceGrowthRate,
    LogMarketCap,
    MarketLeverage,
    ReturnOnAssets,
    ReturnOnEquity,
    SalesGrowthRate,
    SalesToPrice,
    ShareholderYield,
)
from skfolio.factor_exposure import DerivedFactor, FixedWeightedFactor

week = 5
month = 21
quarter = 3 * month
half_year = 6 * month
year = 12 * month

beta_factor = FixedWeightedFactor(
    descriptors=[("market_beta", EWMarketBeta(half_life=year))],
    transform_by_group="industry",
)

momentum_factor = FixedWeightedFactor(
    descriptors=[("momentum", EWMomentum(half_life=half_year, skip=month))],
    transform_by_group="industry",
)

size_factor = FixedWeightedFactor(
    descriptors=[("log_mcap", LogMarketCap())], transform_by_group="industry"
)

non_linear_size_factor = DerivedFactor(
    source="size", func=lambda x: x**3, transform_by_group="industry"
)

value_factor = FixedWeightedFactor(
    descriptors=[
        ("book_to_price", BookToPrice()),
        ("sales_to_price", SalesToPrice()),
        ("cash_flow_to_price", CashFlowToPrice()),
    ],
    weights=[0.8, 0.1, 0.1],
    transform_by_group="industry",
)

earnings_yield_factor = FixedWeightedFactor(
    descriptors=[
        ("fwd_earnings_to_price", ForwardEarningsToPrice()),
        ("earnings_to_price", EarningsToPrice()),
        ("enterprise_multiple", EbitdaToEnterpriseValue()),
    ],
    transform_by_group="industry",
)

growth_factor = FixedWeightedFactor(
    descriptors=[
        ("earnings_change_to_price", EarningsChangeToPrice(lag=year)),
        ("sales_growth", SalesGrowthRate(lag=year)),
    ],
    transform_by_group="industry",
)

profitability_factor = FixedWeightedFactor(
    descriptors=[
        ("asset_turnover", AssetTurnover()),
        ("gross_profitability", GrossProfitability()),
        ("gross_margin", GrossMargin()),
        ("return_on_assets", ReturnOnAssets()),
        ("return_on_equity", ReturnOnEquity()),
        ("cash_flow_to_assets", CashFlowToAssets()),
    ],
    transform_by_group="industry",
)

investment_factor = FixedWeightedFactor(
    descriptors=[
        ("asset_growth", AssetsGrowthRate(lag=year)),
        ("issuance_growth", IssuanceGrowthRate(lag=year)),
        ("capex_growth", CapexToAssetsChangeInIntensity(lag=year)),
    ],
    transform_by_group="industry",
)

dividend_yield_factor = FixedWeightedFactor(
    descriptors=[
        ("dividend_to_price", DividendToPrice()),
        ("shareholder_yield", ShareholderYield()),
    ],
    weights=[0.7, 0.3],
    transform_by_group="industry",
)

leverage_factor = FixedWeightedFactor(
    descriptors=[
        ("market_leverage", MarketLeverage()),
        ("debt_to_assets", DebtToAssets()),
        ("book_leverage", BookLeverage()),
    ],
    transform_by_group="industry",
)

liquidity_factor = FixedWeightedFactor(
    descriptors=[
        ("share_turnover", EWShareTurnover(half_life=quarter)),
        ("amihud_illiquidity", EWAmihudIlliquidity()),
    ],
    transform_by_group="industry",
)

volatility_factor = FixedWeightedFactor(
    descriptors=[
        ("vol", EWVolatility(half_life=quarter)),
        (
            "residual_vol",
            EWResidualVolatility(half_life=quarter, beta_half_life=quarter),
        ),
    ],
    transform_by_group="industry",
)

The non-linear size factor is a DerivedFactor computed as the cube of the size exposure, capturing return patterns that differ between mid-caps and the extremes of the size spectrum. The momentum descriptor EWMomentum skips the most recent month (skip=month) to separate medium-term momentum from short-term reversal.

Model Definition#

We now assemble the factors into the CharacteristicsFactorModel:

from skfolio.moments import EWMu, RegimeAdjustedEWCovariance
from skfolio.prior import CharacteristicsFactorModel, EmpiricalPrior

model = CharacteristicsFactorModel(
    factors=[
        ("market", global_factor),
        ("industry", industry_factors),
        ("beta", beta_factor),
        ("momentum", momentum_factor),
        ("size", size_factor),
        ("non_linear_size", non_linear_size_factor),
        ("value", value_factor),
        ("earnings_yield", earnings_yield_factor),
        ("growth", growth_factor),
        ("profitability", profitability_factor),
        ("investment", investment_factor),
        ("dividend_yield", dividend_yield_factor),
        ("leverage", leverage_factor),
        ("liquidity", liquidity_factor),
        ("volatility", volatility_factor),
    ],
    neutralize_against={
        "non_linear_size": ["size"],
        "volatility": ["beta"],
    },
    constrained_families=[("industry", None)],
    exposure_lag=1,
    inv_idio_variance_weight_shrinkage=0.5,
    factor_prior_estimator=EmpiricalPrior(
        covariance_estimator=RegimeAdjustedEWCovariance(
            half_life=half_year, corr_half_life=year, regime_half_life=month
        ),
        mu_estimator=EWMu(half_life=year),
    ),
    n_jobs=-1,
)

Let’s walk through the main parameters:

  • neutralize_against orthogonalizes the non-linear size exposure against size and the volatility exposure against beta, keeping only the component uncorrelated with the target factor (see Neutralization).

  • constrained_families applies a benchmark-weighted zero-sum constraint on the industry factor returns. The one-hot industry exposures sum to the market exposure, so without a constraint the regression design is rank-deficient (see Zero-Sum Constraints). With it, the market factor captures the benchmark return and industry factors capture relative effects around it.

  • exposure_lag=1 regresses the returns over \((t-1, t]\) on exposures measured at \(t-1\), avoiding look-ahead bias (see Time Alignment and Look-Ahead Bias).

  • inv_idio_variance_weight_shrinkage=0.5 blends square-root market-cap regression weights with inverse-idiosyncratic-variance weights in a two-pass feasible GLS (see Regression Weights).

  • The factor prior estimates expected factor returns with an exponentially weighted mean and the factor covariance with a regime-adjusted exponentially weighted estimator (see Factor Return Distribution).

Fitting#

Full-Panel Fitting#

We fit the model on the panel. The optional X argument defines the investment universe used by downstream portfolio optimization. When omitted, it defaults to the panel’s coverage universe. See Coverage, Estimation and Investment Universes.

model.fit(characteristics=panel)
CharacteristicsFactorModel(constrained_families=[('industry', None)],
                           factor_prior_estimator=EmpiricalPrior(covariance_estimator=RegimeAdjustedEWCovariance(corr_half_life=252,
                                                                                                                 half_life=126,
                                                                                                                 regime_half_life=21),
                                                                 mu_estimator=EWMu(half_life=252)),
                           factors=[('market', GlobalFactor()),
                                    ('industry',
                                     OneHotCategoricalFactors(category='industry',
                                                              family='industry...
                                                                       EWAmihudIlliquidity())],
                                                         transform_by_group='industry')),
                                    ('volatility',
                                     FixedWeightedFactor(descriptors=[('vol',
                                                                       EWVolatility(half_life=63)),
                                                                      ('residual_vol',
                                                                       EWResidualVolatility(beta_half_life=63,
                                                                                            half_life=63))],
                                                         transform_by_group='industry'))],
                           inv_idio_variance_weight_shrinkage=0.5, n_jobs=-1,
                           neutralize_against={'non_linear_size': ['size'],
                                               'volatility': ['beta']})
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.


Online Learning#

The model also supports online learning (see Online Learning). partial_fit appends new observations without refitting the history, and the result is identical to a full-panel fit on the concatenated data. We initialize the model with two years plus one month of data to cover the cumulative descriptor and estimator warmups (see Warmup Periods). Later chunks can be as small as one observation. The equivalent incremental fit is:

warmup = 2 * year + month
model.fit(characteristics=panel[:warmup])

for i in range(warmup, len(panel), month):
    model.partial_fit(characteristics=panel[i : i + month])

Model Outputs#

Now let’s look at what the fitted model produces. return_distribution_ holds the ReturnDistribution consumed by all skfolio optimizations: expected returns mu, asset covariance covariance and asset return scenarios returns. Assets that are not investable at the last observation (e.g. delisted or still in warmup) carry NaN moments. Compatible optimizers solve on the investable subset and assign zero weight to the rest:

distribution = model.return_distribution_
print(f"mu shape: {distribution.mu.shape}")
print(f"covariance shape: {distribution.covariance.shape}")
print(f"scenarios shape: {distribution.returns.shape}")
print(f"investable assets: {np.isfinite(distribution.mu).sum()}")
mu shape: (500,)
covariance shape: (500, 500)
scenarios shape: (1247, 500)
investable assets: 418

The scenario history contains 1,247 observations because the one-year descriptor warmup and one-observation exposure lag consume the first 253 observations of the 1,500-observation panel.

Next, we retrieve factor_model_, the FactorModel container holding the full decomposition: factor exposures, loading matrix, factor returns, expected factor returns and covariance, idiosyncratic returns, variances and covariance, regression and benchmark weights, plus the diagnostics used in the sections below. Its summary method reports per-factor statistics:

factor_model = model.factor_model_

factor_model.summary(families=["market", "style"])
annualized_mean annualized_vol annualized_sharpe autocorrelation mean_abs_t_stat t_stat_exceedance_rate mean_vif coverage stability
market 0.118195 0.194956 0.606265 -0.008250 18.195001 0.918941 1.847198 1.000000 1.000000
beta 0.042018 0.078258 0.536920 0.008127 7.897094 0.819422 1.620804 1.000000 0.999500
momentum 0.025129 0.014704 1.709021 -0.018806 1.261015 0.200642 1.713049 1.000000 0.896448
size 0.011861 0.057134 0.207598 -0.066135 3.227490 0.618780 3.577229 1.000000 0.999204
non_linear_size 0.000768 0.015438 0.049746 0.035992 0.980441 0.113965 1.871993 1.000000 0.997675
value -0.001265 0.017851 -0.070853 -0.034942 1.285646 0.211075 2.537361 1.000000 0.992009
earnings_yield 0.004669 0.015752 0.296391 0.016854 1.266512 0.200642 2.244883 0.999608 0.909185
growth 0.000838 0.018836 0.044510 0.017231 1.145437 0.165329 4.406341 0.999599 0.988547
profitability 0.004818 0.013489 0.357165 0.004826 1.533081 0.296148 1.389202 0.999967 0.961055
investment -0.007063 0.013288 -0.531516 -0.000587 0.961452 0.105136 3.235956 1.000000 0.832960
dividend_yield 0.009788 0.014326 0.683217 -0.027188 1.255086 0.217496 1.876719 0.989720 0.996619
leverage -0.002865 0.012578 -0.227754 0.032559 1.539806 0.306581 1.176350 0.990086 0.997328
liquidity 0.007646 0.030696 0.249106 -0.021072 2.003935 0.413323 2.381422 1.000000 0.999227
volatility 0.005929 0.030053 0.197272 0.075919 2.569087 0.531300 1.224776 1.000000 0.991616


These are pure-factor returns. Each factor return is the cross-sectional regression coefficient for one unit of exposure, the return of a factor-mimicking portfolio with unit exposure to that factor and zero exposure to all others.

Exposure Diagnostics#

Now we check the conditioning of the exposure matrix (see Exposure Diagnostics). Strong collinearity between exposures inflates the variance of the estimated factor returns and makes attribution unstable. We start with the time-average pairwise exposure correlations:

fig = factor_model.plot_exposure_correlation()
fig.update_layout(height=700)


The market exposure is constant (every asset has unit exposure), so its correlation row is uninformative and displays as zero. Market neutrality is instead guaranteed by benchmark-weighted centering: every exposure has a zero benchmark-weighted mean, so no factor carries net market exposure. The style-industry correlations are zero, the result of within-industry scoring (transform_by_group="industry"), and so are the volatility-beta and non-linear size-size correlations, the result of neutralize_against. Industry factors are slightly negatively correlated rather than uncorrelated. Each asset belongs to exactly one industry, so membership in one industry rules out membership in all others. Zero correlation would mean industry memberships are independent. The remaining style correlations reflect the correlated traits of the synthetic universe (e.g. value with leverage and earnings yield), with no redundant factors, consistent with the low mean variance inflation factors (mean_vif) reported in the summary table above.

Note

For two one-hot exposures \(x\) and \(y\) with benchmark weights \(p_x\) and \(p_y\), the product \(xy\) is always zero, so the covariance \(\mathbb{E}[xy] - \mathbb{E}[x]\mathbb{E}[y] = -p_x p_y\) is negative, giving a correlation of \(-\sqrt{p_x p_y / ((1-p_x)(1-p_y))}\), about -0.11 for ten industries of similar weight.

Regression Diagnostics#

Let’s inspect the fit of the cross-sectional regressions with cs_regression_scores, which reports per-observation fit statistics:

factor_model.cs_regression_scores.mean()
r2                0.608784
adjusted_r2       0.585589
aic           -4013.061835
bic           -3920.574305
dtype: float64
factor_model.plot_cs_regression_scores(score="adjusted_r2", window=20)


The mean \(R^2\) is around 60%, as expected for this synthetic dataset, where factor effects are deliberately easy to identify. On real daily US equity data, the mean \(R^2\) typically falls between 25% and 40% (see Regression Diagnostics in the user guide). The synthetic universe is intentionally smaller and cleaner than real-world datasets.

Next, plot_cs_regression_t_stat_exceedance_rate shows how often each factor’s t-statistic exceeds 2 in absolute value. A factor whose true coefficient is zero would exceed it about 5% of the time, so rates persistently above this reference identify factors that are repeatedly significant in the cross-section:

factor_model.plot_cs_regression_t_stat_exceedance_rate(families=["market", "style"])


Factor Returns#

Let’s plot the estimated factor returns accumulated through time:

fig = factor_model.plot_factor_cumulative_returns(families=["market", "style"])
show(fig)

The market factor tracks the cumulative benchmark return, as expected from the benchmark-weighted centering and the industry zero-sum constraint. Among the style factors, momentum shows the largest cumulative return and the highest annualized Sharpe ratio in the summary table above. The synthetic generator assigns momentum a persistent premium, and the model recovers it from the cross-section of returns.

Idiosyncratic Risk Calibration#

Now let’s test the idiosyncratic volatility forecasts. The diagnostics use the standardized idiosyncratic returns \(z_{it} = \epsilon_{it} / \hat\sigma_{it}\). Under correct calibration, \(z\) has cross-sectional standard deviation 1.0:

factor_model.idio_calibration_summary()
mean_cs_std                1.001165
median_cs_std              0.998134
mean_cs_excess_kurtosis    0.352186
mean_cs_skewness           0.003433
mean_tail_rate_3sigma      0.004793
Name: idio_calibration, dtype: float64
factor_model.plot_idio_calibration(window=20)


The series oscillates around 1.0, so the idiosyncratic risk forecasts are correctly scaled. The mean_tail_rate_3sigma of about 0.5%, above the 0.27% Gaussian reference, reflects the fat tails of the idiosyncratic returns, a feature of real equity data reproduced by the generator through Student-t shocks.

Let’s now separate ranking power from calibration with plot_idio_vol_ic, which displays the Spearman correlation between idiosyncratic volatility forecasts and next-period absolute idiosyncratic returns. High values mean the model tends to forecast higher volatility for assets that subsequently experience larger idiosyncratic moves. This tests the ordering of the forecasts, while the calibration series above tests their scale. Together, they show that the idiosyncratic risk forecasts are both well ordered and well scaled:

factor_model.plot_idio_vol_ic()


Information Coefficient#

Finally, we measure the return-predictive power of the exposures with exposure_ic_summary, the cross-sectional rank correlation between factor exposures at \(t\) and forward asset returns:

factor_model.exposure_ic_summary(families=["market", "style"])
mean_ic std_ic ic_ir hit_rate
market -0.001897 0.043586 -0.043527 0.474318
beta 0.012644 0.309943 0.040793 0.513644
momentum 0.001102 0.134755 0.008176 0.510433
size 0.002620 0.167429 0.015650 0.504815
non_linear_size -0.001098 0.114500 -0.009593 0.504013
value 0.004686 0.116558 0.040206 0.510433
earnings_yield 0.006154 0.096949 0.063474 0.530498
growth 0.004217 0.115778 0.036420 0.523274
profitability 0.001471 0.094142 0.015621 0.499197
investment 0.000832 0.095294 0.008733 0.509631
dividend_yield 0.005996 0.107887 0.055580 0.525682
leverage -0.002803 0.081984 -0.034192 0.467897
liquidity 0.001060 0.148860 0.007118 0.512039
volatility 0.000429 0.129313 0.003317 0.489567


factor_model.plot_cumulative_exposure_ic(families=["market", "style"])


Mean ICs are small, IC IRs remain low and hit rates remain around 50% across factors, so no factor stands out as a strong, consistent predictor of next-period returns.

The IC quantifies return-predictive power [3]. In a risk model, factors are designed to forecast covariance, not expected returns. A factor can therefore be an excellent risk factor with an IC near zero, and a low IC is not a reason to discard it. The IC is mainly useful for evaluating alpha signals and factor premia, not for deciding whether a factor should remain in a risk model.

Covariance Forecast Evaluation#

We now evaluate the full covariance forecast out of sample with online_covariance_forecast_evaluation (see Covariance Forecast Evaluation). It walks forward through the data, compares each covariance forecast with the subsequently realized returns and computes calibration diagnostics.

X are the asset returns in the standard skfolio format and warmup_size reserves two years plus one month for the estimator warmups (see Warmup Periods):

from skfolio.model_selection import online_covariance_forecast_evaluation

X = panel.to_dataframe(fields="returns")

evaluation = online_covariance_forecast_evaluation(
    model,
    X,
    params={"characteristics": panel},
    warmup_size= 2 * year + month,
    test_size=week,
)
evaluation.summary()
mean median std p5 p95 mad_from_target target
Mahalanobis ratio 1.048412 1.039179 0.107937 0.884534 1.235439 0.089036 1.0
Diagonal ratio 0.963684 0.701722 1.020057 0.352766 2.307218 0.532033 1.0
Portfolio standardized returns 0.065221 0.143687 0.98813 -1.582466 1.351917 0.74696 mean=0, std=1
Portfolio QLIKE -6.434565 -6.64241 1.038311 -7.378119 -4.608883 NaN lower is better


The Mahalanobis ratio tests the full covariance structure and the diagonal ratio the individual asset variances, both with a 1.0 target. Values above 1.0 indicate underestimated risk and below 1.0 overestimated risk. The portfolio standardized returns test calibration along a portfolio direction, with std as the bias statistic, and the portfolio QLIKE scores the portfolio variance forecasts, lower being better.

evaluation.plot_calibration(diagnostics=["bias"])


The bias statistic oscillates around its 1.0 target, and the summary table shows the Mahalanobis and diagonal calibration ratios close to 1.0 as well, so the risk forecasts are well calibrated across the sample.

Hyperparameter Tuning#

Model parameters follow the scikit-learn naming convention. Here we jointly tune the regression weights, factor covariance dynamics and idiosyncratic variance dynamics.

Because these parameters are continuous, we use OnlineRandomizedSearch. It evaluates each candidate in a single walk-forward pass using partial_fit. The search uses the first four years, preserving the remaining observations as an untouched holdout.

We score the risk model directly with a portfolio-variance QLIKE loss. In make_scorer, response_method=None indicates a non-predictor estimator and greater_is_better=False a loss to minimize. The complete search is shown without execution because fitting 100 candidates is computationally intensive (see Hyperparameter Tuning):

from scipy.stats import loguniform, uniform

from skfolio.metrics import make_scorer, portfolio_variance_qlike_loss
from skfolio.model_selection import OnlineRandomizedSearch

qlike_scorer = make_scorer(
    portfolio_variance_qlike_loss,
    greater_is_better=False,
    response_method=None,
)

search_size = 4 * year

search = OnlineRandomizedSearch(
    estimator=model,
    param_distributions={
        "inv_idio_variance_weight_shrinkage": uniform(0.0, 1.0),
        "factor_prior_estimator__covariance_estimator__half_life": (
            loguniform(quarter, half_year)
        ),
        "factor_prior_estimator__covariance_estimator__corr_half_life": (
            loguniform(half_year, year)
        ),
        "factor_prior_estimator__covariance_estimator__regime_half_life": (
            loguniform(week, quarter)
        ),
        "idio_variance_estimator__half_life": loguniform(
            month, half_year
        ),
        "idio_variance_estimator__regime_half_life": loguniform(
            week, quarter
        ),
    },
    n_iter=100,
    scoring=qlike_scorer,
    warmup_size=2 * year + month,
    test_size=week,
    random_state=0,
    n_jobs=-1,
)
search.fit(
    X.iloc[:search_size],
    characteristics=panel[:search_size],
)

print(search.best_params_)
print(search.best_score_)

best_score_ summarizes the walk-forward validation windows within the four-year search period. cv_results_ contains every sampled parameter set, score, rank and fit time. For a small set of discrete candidates, OnlineGridSearch is also available and evaluates every parameter combination.

Conclusion#

We built a 24-factor characteristics factor model, verified its exposures and regressions, evaluated its risk forecasts out of sample, showed how to tune them and updated the model online. The fitted model is a prior estimator, so we can pass it to any skfolio optimizer through prior_estimator. It supplies expected returns, covariance, scenarios and factor exposures that can be constrained.

The next tutorial builds a factor-constrained portfolio on top of this model and performs factor-level risk and performance attribution.

See also

The Factor Models user guide covers the methodology in depth, including portfolio construction, attribution and alpha integration with this model.

References#

Total running time of the script: (0 minutes 44.412 seconds)

Gallery generated by Sphinx-Gallery