<a id="sphx-glr-auto-examples-factor-models-plot-characteristics-factor-model-py"></a>

<a id="characteristics-factor-model"></a>

# Characteristics Factor Model

This tutorial shows how to build a characteristics-based cross-sectional factor
model with [`CharacteristicsFactorModel`](https://skfolio.org/generated/skfolio.prior.CharacteristicsFactorModel.html.md#skfolio.prior.CharacteristicsFactorModel). The methodology
is covered in the [Factor Models](https://skfolio.org/user_guide/factor_models.html.md#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 <sup>[1](#id4)</sup> (see [Cross-Sectional Regression](https://skfolio.org/user_guide/factor_models.html.md#factor-model-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 <sup>[2](#id5)</sup>:

$$
\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.

<a id="data"></a>

## Data

We use a synthetic characteristics panel generated by
[`make_synthetic_characteristics`](https://skfolio.org/generated/skfolio.datasets.make_synthetic_characteristics.html.md#skfolio.datasets.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](https://skfolio.org/user_guide/factor_models.html.md#factor-models) user guide.

```Python
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`](https://skfolio.org/generated/skfolio.containers.AssetPanel.html.md#skfolio.containers.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](https://skfolio.org/user_guide/data_representation.html.md#asset-data-representation) user guide.

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

```Python
panel.to_dataframe(output_format="long").head()
```

[plotly figure stripped from llms output]
<br />
<br />

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.

<a id="regression-diagnostics"></a>

## Regression Diagnostics

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

```Python
factor_model.cs_regression_scores.mean()
```

```none
r2                0.608784
adjusted_r2       0.585589
aic           -4013.061835
bic           -3920.574304
dtype: float64
```

```Python
factor_model.plot_cs_regression_scores(score="adjusted_r2", window=20)
```

<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>[plotly figure stripped from llms output]
<br />
<br />

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](https://skfolio.org/user_guide/factor_models.html.md#factor-model-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:

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

<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>[plotly figure stripped from llms output]
<br />
<br />

<a id="factor-returns"></a>

## Factor Returns

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

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

[plotly figure stripped from llms output]<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>

<br/>

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.

<a id="idiosyncratic-risk-calibration"></a>

## 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:

```Python
factor_model.idio_calibration_summary()
```

```none
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
```

```Python
factor_model.plot_idio_calibration(window=20)
```

<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>[plotly figure stripped from llms output]
<br />
<br />

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:

```Python
factor_model.plot_idio_vol_ic()
```

<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>[plotly figure stripped from llms output]
<br />
<br />

<a id="information-coefficient"></a>

## 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:

```Python
factor_model.exposure_ic_summary(families=["market", "style"])
```

[plotly figure stripped from llms output]
<br />
<br />

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 <sup>[3](#id6)</sup>. 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.

<a id="covariance-forecast-evaluation"></a>

## Covariance Forecast Evaluation

We now evaluate the full covariance forecast out of sample with
[`online_covariance_forecast_evaluation`](https://skfolio.org/generated/skfolio.model_selection.online_covariance_forecast_evaluation.html.md#skfolio.model_selection.online_covariance_forecast_evaluation) (see
[Covariance Forecast Evaluation](https://skfolio.org/user_guide/factor_models.html.md#factor-model-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](https://skfolio.org/user_guide/factor_models.html.md#factor-model-warmup)):

```Python
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()
```

[plotly figure stripped from llms output]
<br />
<br />

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.

<a id="hyperparameter-tuning"></a>

## 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`](https://skfolio.org/generated/skfolio.model_selection.OnlineRandomizedSearch.html.md#skfolio.model_selection.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](https://skfolio.org/user_guide/factor_models.html.md#factor-model-hyper-parameter-tuning)):

```python
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`](https://skfolio.org/generated/skfolio.model_selection.OnlineGridSearch.html.md#skfolio.model_selection.OnlineGridSearch) is also available and
evaluates every parameter combination.

<a id="conclusion"></a>

## 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](https://skfolio.org/user_guide/factor_models.html.md#factor-models) user guide covers the
methodology in depth, including portfolio construction, attribution
and alpha integration with this model.

<a id="references"></a>

## References

* <a id='id4'>**[1]**</a> B. Rosenberg, “Extra-Market Components of Covariance in Security Returns”, *Journal of Financial and Quantitative Analysis*, vol. 9, no. 2, pp. 263-274 (1974). [doi:10.2307/2330104](https://doi.org/10.2307/2330104).
* <a id='id5'>**[2]**</a> G. A. Paleologo, *The Elements of Quantitative Investing*, Wiley Finance (2025).
* <a id='id6'>**[3]**</a> R. C. Grinold and R. N. Kahn, *Active Portfolio Management: A Quantitative Approach for Producing Superior Returns and Controlling Risk*, McGraw-Hill (1999).

**Total running time of the script:** (1 minutes 11.975 seconds)

<a id="sphx-glr-download-auto-examples-factor-models-plot-characteristics-factor-model-py"></a>
