之前对逐日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, index=idx, columns=cols, dtype=float) 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_first=True).values X = np.column_stack([np.ones(len(yy)), mm, dummies]) beta, *_ = np.linalg.lstsq(X, yy, rcond=None) 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([f"IND{i % n_inds}" for i in range(n)], index=codes) log_mcap = pd.Series(rng.normal(8.0, 1.0, n), index=codes) dates = pd.date_range("2020-01-02", periods=n_days, freq="B") # 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)), index=dates, columns=codes, ) 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_first=False).astype(float) X = np.column_stack([np.ones(len(y)), d.values]) beta, *_ = np.linalg.lstsq(X, y.values, rcond=None) 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(f"post-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_obs=20):
输入说明如下
- factor_df:date × stock的因子值,注释假设已经做过 winsorize 和 zscore。
- log_mcap:可以是 stock 市值索引的 Series,也可以是 date × stock的 DataFrame。
注意这里是log作为参数传递进来。
- industry:stock -> 行业标签 的静态分类。
- min_obs:当日有效样本太少时,不回归,直接保留原值。
输出:
- 同形状的date × stock残差因子。
2.2 初始化输出与行业对齐
然后是初始化输出与行业对齐。
out = pd.DataFrame(np.nan, index=idx, columns=cols, dtype=float)
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.values
continue
说明当天不做中性化,直接保留原因子。
这避免了小样本回归过拟合,但也会导致部分日期因子未中性化。
2.4 构造设计矩阵并回归
进一步构造设计矩阵并回归,代码示例如下。
dummies = pd.get_dummies(ind[valid].astype(str), drop_first=True).values
X = np.column_stack([np.ones(len(yy)), mm, dummies])
beta, *_ = np.linalg.lstsq(X, yy, rcond=None)
resid = yy - X @ beta
out.loc[date, valid] = resid
设计矩阵为:
其中:
- 1是截距。
- log MC控制市值。
-是行业哑变量,drop_first=True丢弃一个基准行业,避免与截距完全共线。
- 用np.linalg.lstsq做最小二乘,数值上比直接求逆稳健。
- 残差resid = y - Xβ就是中性化后的因子。
3 相关背景
3.1 横截面回归
代码是逐日横截面回归,而不是全样本时间序列回归。
这样做的好处:
- 允许每天的市值溢价、行业效应不同;
- 避免使用未来信息,减少前视偏差;
- 符合 Fama-MacBeth两步法的第一步:先做横截面回归,再在时间序列上汇总。
3.2 OLS 投影与正交化
OLS 残差本质上是把y投影到控制变量X的正交补空间。因此残差与X的列空间正交。
这就是为什么 smoke test 中:
corr(factor, log_mcap) ≈ 0
industry R² ≈ 0
3.3 虚拟变量与固定效应
行业哑变量等价于行业固定效应。drop_first=True是为了避免虚拟变量陷阱。
- 如果同时有截距和全部行业哑变量,设计矩阵列线性相关;
- 丢弃一个行业作为基准后,截距吸收基准行业效应,其余哑变量表示相对基准行业的差异。
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或标记。
3)log_mcap为DataFrame且日期缺失时的分支
如果 log_mcap是 DataFrame 但date不在其 index 中,代码会进入:
m = pd.Series(log_mcap).reindex(cols)
对 DataFrame 调 pd.Series可能报错。更稳妥的是显式处理缺失日期,例如返回全 NaN。
4)行业样本过少问题
如果某行业只有一两个股票,行业哑变量会几乎完全拟合这些点,残差可能变成 0 或极端值。
可考虑合并稀有行业,或设置每行业最小样本数。
5)min_obs应随行业数调整
设计矩阵列数约为1 + 1 + (行业数 - 1)。如果行业很多,min_obs=20可能不够。
可设:
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