因子残差化隔离市值和行业的示例和分析

📅 发布时间:2026/9/17 1:52:30
因子残差化隔离市值和行业的示例和分析
之前对逐日OLS中性化进行了比较详细的探索https://blog.csdn.net/liliang199/article/details/165179615这里进一步探索如何在每个交易日做横截面 OLS 中性化。具体为把因子值对log(市值)和行业哑变量回归取残差作为新的因子值。1 功能描述1.1 因子残差化背景因子残差化在这里指因子中心化或指风险暴露控制。多因子选股中原始 fundamental 因子往往和市值、行业高度相关。例如小盘股可能在某些财务指标上系统性偏高不同行业的估值、盈利、杠杆水平天然不同。如果直接排序组合可能意外暴露于size或sector。残差化的目的就是去掉因子中由市值和行业线性解释的部分只保留纯因子部分。这与Barra类风险模型的思想一致收益或因子暴露可以分解为风险因子部分和特质部分。1.2 因子残差化数学形式因子残差化即为在每个交易日做横截面 OLS 中性化。具体为即把因子值对log(市值), 行业哑变量进行回归取残差作为新的因子值。这样处理后因子在当日横截面上与市值、行业线性正交排序时不再系统性暴露于size和sector。对每个交易日模型为取残差OLS 的一阶条件给出在有效样本内也就是说残差因子在当日横截面上与- 截距正交因此均值为 0- log 市值正交因此与size的样本协方差为 0- 行业哑变量正交因此对行业回归的为 0。从几何上看OLS 残差是其中是到控制变量空间上的投影矩阵。残差是因子中不能被市值和行业线性解释的部分。2 代码示例因子残差化的代码示例如下即在每个交易日做横截面 OLS 中性化把因子值对log(市值)和行业哑变量回归取残差作为新的因子值。Cross-sectional residualization for fundamental factors (P5, M2). Fundamental factors are heavily confounded by size and industry. After winsorize zscore, regress the cross-section on log(market cap) and industry dummies and keep the OLS residual, so the ranking no longer sorts on size or sector. Operates date-by-date; NaN inputs stay NaN; columns with too few valid observations are returned as-is (unresidualized). from __future__ import annotations import numpy as np import pandas as pd def residualize_size_industry( factor_df: pd.DataFrame, log_mcap, industry, min_obs: int 20, ) - pd.DataFrame: OLS residual of factor_df on log market cap industry dummies. Parameters ---------- factor_df : date x stock raw (winsorized) factor values log_mcap : Series indexed by stock, or date x stock DataFrame industry : Series indexed by stock - industry label (static classification) min_obs : cross-sections with fewer valid names are passed through Returns date x stock residuals. idx factor_df.index cols factor_df.columns out pd.DataFrame(np.nan, indexidx, columnscols, dtypefloat) ind pd.Series(industry).reindex(cols) for date in idx: y factor_df.loc[date] if isinstance(log_mcap, pd.DataFrame) and date in log_mcap.index: m log_mcap.loc[date].reindex(cols) else: m pd.Series(log_mcap).reindex(cols) valid y.notna() m.notna() ind.notna() if valid.sum() min_obs: out.loc[date] y.values continue yy y[valid].values.astype(float) mm m[valid].values.astype(float) dummies pd.get_dummies(ind[valid].astype(str), drop_firstTrue).values X np.column_stack([np.ones(len(yy)), mm, dummies]) beta, *_ np.linalg.lstsq(X, yy, rcondNone) resid yy - X beta out.loc[date, valid] resid return out def main() - None: rng np.random.default_rng(0) n, n_inds, n_days 240, 6, 30 codes [f{i:06d}.SZ for i in range(n)] industry pd.Series([fIND{i % n_inds} for i in range(n)], indexcodes) log_mcap pd.Series(rng.normal(8.0, 1.0, n), indexcodes) dates pd.date_range(2020-01-02, periodsn_days, freqB) # factor with HEAVY size industry loading, plus light noise and drift base ( 3.0 * log_mcap.values 2.0 * np.array([i % n_inds for i in range(n)]) rng.normal(0.0, 0.5, n) ) raw pd.DataFrame( np.tile(base, (n_days, 1)) rng.normal(0.0, 0.05, (n_days, n)), indexdates, columnscodes, ) resid residualize_size_industry(raw, log_mcap, industry) audit_t dates[-1] y resid.loc[audit_t].reindex(codes) corr np.corrcoef(y.values, log_mcap.values)[0, 1] d pd.get_dummies(industry.reindex(codes), drop_firstFalse).astype(float) X np.column_stack([np.ones(len(y)), d.values]) beta, *_ np.linalg.lstsq(X, y.values, rcondNone) pred X beta ss_res float(((y.values - pred) ** 2).sum()) ss_tot float(((y.values - y.mean()) ** 2).sum()) ind_r2 1.0 - ss_res / ss_tot print(fpost-resid corr(factor, log_mcap) {corr:.2e} industry R² {ind_r2:.2e}) assert abs(corr) 1e-6, residual still loads on size assert abs(ind_r2) 1e-6, residual still loads on industry print(M2 SMOKE OK: residual is orthogonal to size and industry) if __name__ __main__: main()2.1 函数目标def residualize_size_industry(factor_df, log_mcap, industry, min_obs20):输入说明如下- factor_dfdate × stock的因子值注释假设已经做过 winsorize 和 zscore。- log_mcap可以是 stock 市值索引的 Series也可以是 date × stock的 DataFrame。注意这里是log作为参数传递进来。- industrystock - 行业标签 的静态分类。- min_obs当日有效样本太少时不回归直接保留原值。输出- 同形状的date × stock残差因子。2.2 初始化输出与行业对齐然后是初始化输出与行业对齐。out pd.DataFrame(np.nan, indexidx, columnscols, dtypefloat)ind pd.Series(industry).reindex(cols)在初始化阶段输出默认全是 NaN。行业标签被 reindex 到因子列保证股票代码对齐。2.3 逐日横截面回归然后是逐日横截面回归。for date in idx:y factor_df.loc[date]...valid y.notna() m.notna() ind.notna()对每一天- y是当天所有股票的因子值。- m是当天股票的 log 市值。- ind是股票行业。- valid要求三者都非空。如果有效样本数 min_obs则跳过处理。out.loc[date] y.valuescontinue说明当天不做中性化直接保留原因子。这避免了小样本回归过拟合但也会导致部分日期因子未中性化。2.4 构造设计矩阵并回归进一步构造设计矩阵并回归代码示例如下。dummies pd.get_dummies(ind[valid].astype(str), drop_firstTrue).valuesX np.column_stack([np.ones(len(yy)), mm, dummies])beta, *_ np.linalg.lstsq(X, yy, rcondNone)resid yy - X betaout.loc[date, valid] resid设计矩阵为其中- 1是截距。- log MC控制市值。-是行业哑变量drop_firstTrue丢弃一个基准行业避免与截距完全共线。- 用np.linalg.lstsq做最小二乘数值上比直接求逆稳健。- 残差resid y - Xβ就是中性化后的因子。3 相关背景3.1 横截面回归代码是逐日横截面回归而不是全样本时间序列回归。这样做的好处- 允许每天的市值溢价、行业效应不同- 避免使用未来信息减少前视偏差- 符合 Fama-MacBeth两步法的第一步先做横截面回归再在时间序列上汇总。3.2 OLS 投影与正交化OLS 残差本质上是把y投影到控制变量X的正交补空间。因此残差与X的列空间正交。这就是为什么 smoke test 中corr(factor, log_mcap) ≈ 0industry R² ≈ 03.3 虚拟变量与固定效应行业哑变量等价于行业固定效应。drop_firstTrue是为了避免虚拟变量陷阱。- 如果同时有截距和全部行业哑变量设计矩阵列线性相关- 丢弃一个行业作为基准后截距吸收基准行业效应其余哑变量表示相对基准行业的差异。3.4 log 市值市值分布通常右偏直接用市值线性回归可能受极端值影响。取 log 后- 分布更接近对称- 线性关系更合理- 控制size暴露更稳定。3.5 smoke test验证逻辑main()中模拟了一个强烈依赖 size 和 industry 的因子base 3.0 * log_mcap 2.0 * industry_index noise然后做残差化。最后一天检查- 残差与 log 市值相关系数应接近 0- 残差对行业哑变量回归的应接近 0。这验证了残差在样本内与 size、industry 线性正交。4 可能存在问题1只移除线性关系残差化只保证线性正交。如果因子与市值、行业存在非线性关系残差仍可能相关。2样本不足时保留原值valid.sum() min_obs时直接返回原因子会导致部分日期未中性化。更一致的做法可能是返回NaN或标记。3log_mcap为DataFrame且日期缺失时的分支如果 log_mcap是 DataFrame 但date不在其 index 中代码会进入m pd.Series(log_mcap).reindex(cols)对 DataFrame 调 pd.Series可能报错。更稳妥的是显式处理缺失日期例如返回全 NaN。4行业样本过少问题如果某行业只有一两个股票行业哑变量会几乎完全拟合这些点残差可能变成 0 或极端值。可考虑合并稀有行业或设置每行业最小样本数。5min_obs应随行业数调整设计矩阵列数约为1 1 (行业数 - 1)。如果行业很多min_obs20可能不够。可设min_obs max(min_obs, n_industries 2)6残差后未再标准化残差每日方差可能不同。若后续需要跨日合成因子建议每日再 zscore。7静态行业分类代码假设行业不随时间变化。如果行业分类会变需要传入date × stock的行业矩阵。8未考虑交互项当前模型假设不同行业的市值斜率相同。若不同行业中size对因子的影响不同可加入log_mcap × industry交互项。reference---逐日OLS中性化代码解析https://blog.csdn.net/liliang199/article/details/165179615对行业市值中性化的解析和探索https://blog.csdn.net/liliang199/article/details/164995599