3、单因子构建:以市盈率(PE)为例构建价值因子、计算因子暴露度

好,我们正式开始动手了。

前面两章聊了多因子模型的整体框架和数据清洗,算是把地基打好了。这一章我们干点实在的——拿市盈率(PE)这个最经典的价值因子,完整走一遍单因子构建的流程。从原始数据到因子暴露度,每一步我都会把我在实战中踩过的坑、总结的经验揉进去。

3.1 为什么选市盈率作为第一个因子?

你想想看,市盈率是A股市场里认知度最高的指标之一。散户看PE,机构也看PE。但恰恰因为太常见,很多人反而用错了。

我个人习惯,在搭建多因子模型时,第一个因子一定选一个逻辑清晰、数据可得性高的。PE完美符合这两个条件:

  • 逻辑清晰:低PE代表相对便宜,高PE代表相对贵。价值投资的核心就是买便宜货。
  • 数据可得:日频PE数据在Wind、聚宽、Tushare上都能直接拿到,不需要复杂计算。

核心认知:单因子构建不是简单地把PE值拉出来排序。我们要做的是把原始PE转化为「因子暴露度」——也就是每只股票在这个因子上的标准化得分。

3.2 原始PE数据的获取与预处理

先看数据长什么样。我从聚宽API拉了一天的截面数据,大概长这样:

# 伪代码示例:获取某交易日全部A股的PE数据
import pandas as pd
import numpy as np

# 假设从数据源获取了原始数据
raw_data = {
    'stock_code': ['000001.XSHE', '000002.XSHE', '600519.XSHG', ...],
    'pe_ttm': [8.5, 12.3, 35.6, ...],  # 滚动市盈率
    'market_cap': [1.2e10, 2.5e10, 2.1e11, ...]
}
df = pd.DataFrame(raw_data)
print(df.head())

拿到数据后,第一件事不是算因子,而是做质量检查。我在项目中遇到过好几次,数据源里混着PE为负值或极端值的股票,如果不处理,后面整个因子暴露度都会偏掉。

3.3 异常值处理:别让极端值毁了你的因子

原始PE数据有几个典型问题:

  • 负PE:亏损企业的PE是负的,这类股票在价值因子里怎么处理?
  • 极端大值:有些微利企业PE能到几千倍,这种值放进模型就是噪音。
  • 缺失值:新股或停牌股可能没有PE数据。

我的处理方案是这样的:

# 第一步:剔除PE为负的股票(亏损企业单独处理)
df = df[df['pe_ttm'] > 0].copy()

# 第二步:MAD法去极值(中位数绝对偏差法)
median_pe = df['pe_ttm'].median()
mad = np.median(np.abs(df['pe_ttm'] - median_pe))
upper_limit = median_pe + 5 * mad
lower_limit = median_pe - 5 * mad
df['pe_trimmed'] = df['pe_ttm'].clip(lower_limit, upper_limit)

# 第三步:缺失值用行业均值填充
# 这里省略行业分类代码,实际项目中按申万一级行业填充

注意:我曾经犯过一个错误——直接用3倍标准差去极值。后来发现PE分布严重右偏,用标准差法会把大量正常值误判为异常。改用MAD法后,因子稳定性明显提升。

3.4 因子暴露度的计算:标准化是关键

处理完异常值,接下来就是核心步骤——计算因子暴露度。说白了,就是把PE值映射到一个可比较的尺度上。

我常用的标准化方法有三种,各有适用场景:

方法 公式 适用场景 我的经验
Z-score标准化 (x - μ) / σ 因子值近似正态分布 PE不太适用,分布偏态严重
排序百分位 rank(x) / N 任何分布,稳健性高 我最常用的方法,推荐
分位数映射 映射到[0,1]区间 需要控制极端值影响 与排序法类似,但更精细

我个人习惯用排序百分位法。原因很简单——它不依赖分布假设,而且天然把因子暴露度控制在0到1之间,方便后续与其他因子合成。

# 排序百分位法计算因子暴露度
df['pe_rank'] = df['pe_trimmed'].rank()
df['pe_exposure'] = df['pe_rank'] / len(df)

# 注意:价值因子是低PE好,所以暴露度要取反
# 即PE越低,价值因子暴露度越高
df['value_exposure'] = 1 - df['pe_exposure']

print(df[['stock_code', 'pe_ttm', 'pe_trimmed', 'value_exposure']].head(10))

一个小技巧:如果你希望因子暴露度更平滑,可以在排序后做一次正态化变换(把百分位映射到标准正态分布的分位数上)。这样既保留了排序的稳健性,又让暴露度分布更接近正态。我在做风险模型时常用这个变种。

3.5 行业与市值中性化:别让风格干扰因子纯度

嗯,这里要注意。直接算出来的价值因子暴露度,其实混杂了行业和市值的影响。比如银行股普遍PE低,科技股普遍PE高。如果不做中性化处理,你的价值因子本质上是在选行业,而不是选股票。

中性化的做法很简单——对因子暴露度做回归,把行业和市值的贡献剥离掉:

# 行业市值中性化(伪代码)
# 假设已有行业哑变量和市值对数
import statsmodels.api as sm

X = pd.get_dummies(df['industry'], prefix='ind')
X['log_market_cap'] = np.log(df['market_cap'])
X = sm.add_constant(X)

y = df['value_exposure']
model = sm.OLS(y, X).fit()
df['value_exposure_neutral'] = model.resid

# 最后再做一次标准化,让中性化后的暴露度均值为0,标准差为1
df['value_exposure_neutral'] = (df['value_exposure_neutral'] - 
                                df['value_exposure_neutral'].mean()) / df['value_exposure_neutral'].std()

做完中性化后,你再去看因子暴露度最高的股票,会发现它们不再是清一色的银行股,而是各个行业里相对便宜的股票。这才是我们想要的价值因子。

3.6 本章核心流程总览

说了这么多,我画了一张流程图,把整个单因子构建的步骤串起来。你照着这个流程走,基本不会出大问题。

单因子构建流程(以市盈率PE为例) 原始PE数据获取 数据清洗(负值/缺失/极值) 排序百分位标准化 方向调整(低PE→高暴露) 行业市值中性化 最终因子暴露度 关键注意点 • 负PE股票单独处理 • 极值用MAD法而非标准差法 • 中性化后需再次标准化

3.7 实战中的几个坑

最后,分享几个我在实战中踩过的坑,希望能帮你省点时间:

  • 坑一:PE数据源不一致。不同数据商对PE的计算口径不同(有的用静态PE,有的用TTM PE)。我建议统一用TTM PE,因为它能反映最新盈利情况。
  • 坑二:忘记做行业中性化。我曾经有一个策略回测表现很好,结果实盘就崩了。后来发现是因为因子暴露度里全是行业偏好,市场风格一转就完蛋。
  • 坑三:暴露度更新频率。PE每天都会变,但因子暴露度不需要每天重算。我个人习惯每周更新一次,既保证时效性,又避免过度交易。

总结一下:单因子构建的核心就三步——数据清洗、标准化、中性化。每一步都有细节,但框架是固定的。你把这个流程跑通一遍,后面构建任何因子(成长因子、动量因子、质量因子)都是换汤不换药。