那是我刚学会造因子的时候。
第77篇讲完数据存储之后,我花了一个月,疯狂造因子。
动量因子、波动率因子、流动性因子、估值因子......
一口气造了三十多个。每个都跑了IC分析,看起来都挺显著。
我信心爆棚。
心想:这次模型总该起飞了吧?
结果------
夏普0.5。
还不如之前PCA压缩成5个主成分时的1.4。
我懵了。
"不可能啊。每个因子的IC都显著,怎么合在一起就不行了?"
导师看了一眼我的因子分布。
"你做过箱线图没有?"
"什么箱线图?"
"就是看每个因子的数据分布。你把那个'换手率偏离度'因子的分布画出来给我看看。"
我画了。
屏幕上出现了一个奇怪的分布------
大部分数据集中在-2到2之间。
但是------
有一个值,是487。
"看到了吗?"
"看到了。这是一个极端值。"
"你知道这个极端值对你因子的影响有多大吗?"
我摇头。
他打开Excel,算了一下:
"你这个因子的均值是0.3。去掉这一个极端值,均值变成0.01。"
"......差了30倍?"
"对。你整个因子的'有效性',被这一个值撑起来了。模型看到的'显著IC',不是因为它真的能预测------是因为这一个异常值碰巧和收益率高度相关。"
"那去掉这个值呢?"
"去掉之后,你这个因子的IC直接从0.08掉到0.003。完全不显著。"
我盯着那个487看了很久。
后来查出来------是数据源的问题。某天的成交量数据少了一个零,导致换手率计算出来是正常值的十倍。
一个数据错误。一个极端值。
让我以为发现了一个好因子,实际上------
整个因子都是假的。

WHY:为什么一个极端值能毁掉一切?
一个生活中的比喻
想象你在统计一个小区住户的收入水平。
小区有100户人家。
大部分家庭年收入在10万到50万之间。
但是------
有一户,是做直播的,年收入2个亿。
你算一下平均值:
100户平均年收入 = (99户总和 + 2亿) / 100 ≈ 250万
250万?
你觉得这个小区是"富人区"吗?
不是。99户人家 average 才30万出头。
是那一个2亿把整个平均值拉高了20倍。
这就是极端值的威力。
它不需要多。只需要一个。就能把你所有统计量------均值、方差、相关系数、回归系数------全部带偏。
量化因子为什么特别容易受影响?
金融数据天然有"肥尾"特征------极端值出现的概率远高于正态分布的预期。
一只股票某天成交量突然放大100倍------停牌复牌、重大公告、乌龙指------都有可能。
一只股票的日收益率突然涨停或跌停------A股10%的限制还算温和的,港股、美股一天能跌50%。
一个财务数据出现录入错误------小数点点错一位,PE从15变成150。
这些极端值------
如果不处理,它们会像病毒一样感染你的因子计算、模型训练、回测结果。
你算均值------被带偏。
你算标准差------被放大。
你算相关系数------被一个点拉高或拉低。
你训练模型------模型拼命去拟合那个异常点,忽略了99%的正常数据。
一个极端值,毁掉一个因子。
一个因子,毁掉一个模型。
一个模型,毁掉你的实盘。
WHAT:三种主流去极值方法
方法一:3σ法------最简单,也最粗糙
核心思想:
假设数据服从正态分布。超过均值±3个标准差的数据,视为异常值,做截断处理。
// python
import numpy as np
def winsorize_3sigma(series):
mean = series.mean()
std = series.std()
upper = mean + 3 * std
lower = mean - 3 * std
return series.clip(lower, upper)
使用
df'factor_clean' = winsorize_3sigma(df'factor_raw')
优点: 简单,计算快。
缺点: 金融数据不服从正态分布。金融数据的尾部远厚于正态分布。用3σ法------要么处理得太少(因为标准差本身就被极端值放大了),要么处理得太多。
而且------均值和标准差本身就会被极端值影响。
这就像------你用一个被污染的尺子去测量污染程度。
尺子本身就不准。
方法二:MAD法------稳健之王
MAD = Median Absolute Deviation(中位数绝对偏差)。
核心思想:
用中位数代替均值------中位数不受极端值影响。
用绝对偏差的中位数代替标准差------同样不受极端值影响。
// python
def winsorize_mad(series, n=5):
median = series.median()
计算每个值到中位数的绝对偏差
mad = (series - median).abs().median()
MAD换算成标准差的等价量
mad_e = 1.4826 * mad
upper = median + n * mad_e
lower = median - n * mad_e
return series.clip(lower, upper)
使用
df'factor_clean' = winsorize_mad(df'factor_raw', n=5)
为什么乘1.4826?
因为对于正态分布,MAD ≈ 0.6745 × 标准差。所以 1/0.6745 ≈ 1.4826,用来把MAD换算成和标准差可比较的尺度。
优点:
• 中位数天然抗极端值------哪怕数据里有一个值是487,中位数完全不受影响
• 比3σ法稳健得多------金融数据肥尾也没关系
• 业界主流方法------大多数量化私募用这个
缺点:
• 计算比3σ法略慢(但差异可忽略)
• 阈值n的选择有一定主观性------通常取3到5
方法三:百分位法------最直觉
核心思想:
直接看数据的分布。把最极端的1%(或5%)截断到百分位数上。
// python
def winsorize_percentile(series, lower_pct=0.01, upper_pct=0.99):
lower = series.quantile(lower_pct)
upper = series.quantile(upper_pct)
return series.clip(lower, upper)
使用
df'factor_clean' = winsorize_percentile(df'factor_raw', lower_pct=0.01, upper_pct=0.99)
优点:
• 直觉清晰------"去掉最极端的1%"
• 不依赖任何分布假设
• 计算简单
缺点:
• 截断比例是固定的------不管数据分布什么样,都切1%
• 可能切掉有用信息------如果数据确实有合理的厚尾
• 也可能切不干净------如果极端值太多
HOW:完整的去极值流水线
把三种方法串起来,做一个完整的因子去极值+标准化流水线:
// python
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
============ 第一步:加载因子数据 ============
假设有30个因子的原始数据
df = pd.read_parquet("raw_factors.parquet")
factor_cols = col for col in df.columns if col not in \['date', 'code']
============ 第二步:去极值(MAD法) ============
def winsorize_mad(series, n=5):
median = series.median()
mad = (series - median).abs().median()
mad_e = 1.4826 * mad
upper = median + n * mad_e
lower = median - n * mad_e
return series.clip(lower, upper)
对每个因子、每个截面(每天)做去极值
def cross_sectional_winsorize(df, factor_cols, date_col='date', method='mad', n=5):
df_clean = df.copy()
for col in factor_cols:
if method == 'mad':
df_cleancol = df.groupby(date_col)col.transform(
lambda x: winsorize_mad(x, n=n)
)
elif method == '3sigma':
df_cleancol = df.groupby(date_col)col.transform(
lambda x: x.clip(x.mean() - n*x.std(), x.mean() + n*x.std())
)
elif method == 'percentile':
df_cleancol = df.groupby(date_col)col.transform(
lambda x: x.clip(x.quantile(0.01), x.quantile(0.99))
)
return df_clean
去极值
df_clean = cross_sectional_winsorize(df, factor_cols, method='mad', n=5)
============ 第三步:标准化 ============
def cross_sectional_standardize(df, factor_cols, date_col='date'):
df_std = df.copy()
for col in factor_cols:
df_stdcol = df.groupby(date_col)col.transform(
lambda x: (x - x.mean()) / x.std()
)
return df_std
标准化
df_std = cross_sectional_standardize(df_clean, factor_cols)
============ 第四步:对比检查 ============
对比去极值前后的分布
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
原始分布
axes0.hist(df'换手率偏离度'.dropna(), bins=100, color='red', alpha=0.7)
axes0.set_title('去极值前:换手率偏离度分布')
axes0.set_xlabel('因子值')
axes0.axvline(df'换手率偏离度'.mean(), color='black', linestyle='--', label=f'均值={df"换手率偏离度".mean():.2f}')
axes0.legend()
处理后分布
axes1.hist(df_clean'换手率偏离度'.dropna(), bins=100, color='green', alpha=0.7)
axes1.set_title('去极值后:换手率偏离度分布')
axes1.set_xlabel('因子值')
axes1.axvline(df_clean'换手率偏离度'.mean(), color='black', linestyle='--', label=f'均值={df_clean"换手率偏离度".mean():.2f}')
axes1.legend()
plt.tight_layout()
plt.savefig("winsorize_comparison.png", dpi=150)
plt.show()
============ 第五步:检查IC变化 ============
def calc_ic(df, factor_col, return_col='next_return', date_col='date'):
"""计算因子的时间序列IC"""
ic_series = df.groupby(date_col).apply(
lambda x: xfactor_col.corr(xreturn_col)
)
return ic_series
去极值前的IC
ic_before = calc_ic(df, '换手率偏离度')
print(f"去极值前 IC均值: {ic_before.mean():.4f}, IC_IR: {ic_before.mean()/ic_before.std():.4f}")
去极值后的IC
ic_after = calc_ic(df_clean, '换手率偏离度')
print(f"去极值后 IC均值: {ic_after.mean():.4f}, IC_IR: {ic_after.mean()/ic_after.std():.4f}")
逐行讲解
第一步:加载因子数据。 把你的原始因子数据读进来。
第二步:去极值。 对每个因子、每个截面(每天)独立做MAD去极值。注意------一定要在截面上做,不是在整个时间序列上做。因为每天的因子分布不同,需要用当天数据的中位数和MAD。
第三步:标准化。 去极值之后,做截面标准化。均值=0,标准差=1。这样不同因子之间可以直接比较和合成。
第四步:对比检查。 画图看分布变化。去极值前------可能有一个长长的尾巴,尾巴末端就是极端值。去极值后------尾巴被截断,分布变得紧凑对称。
第五步:检查IC变化。 这是最关键的一步。去极值之后,因子的IC可能变低(因为之前被极端值"虚撑"的显著性没了),也可能变高(因为噪声被过滤,真实信号更清晰了)。
通常------如果去极值后IC下降了很多,说明原来的因子"显著"是假的,是被极端值带出来的。如果IC基本不变或上升,说明因子确实有预测力。
实战经验:什么时候用什么方法
MAD法------日常标配
90%的场景,用MAD就够了。
• 截面去极值,n取5(保守)或3(激进)
• 行业惯例,大多数量化私募的因子处理流水线都用这个
• 稳健、快速、不需要调参
3σ法------数据质量好的时候用
如果你的数据已经很干净(比如经过数据源清洗、去重、校验),极端值很少------用3σ也差不多。
但在A股数据里,3σ法通常不如MAD。因为金融数据肥尾严重,3σ的"标准差"本身就被极端值撑大了,反而处理不干净。
百分位法------快速验证的时候用
想快速看一眼"去掉最极端的几个点之后,因子还显不显著"------百分位法最直觉。
但它不如MAD精确------MAD是根据数据本身的离散程度动态确定阈值的,而百分位法是固定切1%。
踩坑清单
坑一:在时间序列上做去极值
去极值必须在截面 上做------每天独立处理。
如果你在整个时间序列上算均值和标准差------不同日期之间的差异(比如牛市和熊市成交量差异巨大)会干扰去极值效果。
// python
错误做法
mean = df'factor'.mean() # 所有日期的均值
std = df'factor'.std()
df'factor_clean' = df'factor'.clip(mean - 3*std, mean + 3*std)
正确做法
df'factor_clean' = df.groupby('date')'factor'.transform(
lambda x: x.clip(x.median() - 5*1.4826*(x-x.median()).abs().median(),
x.median() + 5*1.4826*(x-x.median()).abs().median())
)
坑二:去极值之后不做标准化
去极值只是把极端值截断。截断之后,因子的量纲可能还是不一致。
比如一个因子范围是0到1,另一个是0到100。去极值之后还是这样。
必须再做标准化,让所有因子在同一尺度上。
坑三:去极值太激进
n取3------意味着你会截掉很多数据。如果因子本身就有合理的厚尾分布(比如小盘股的换手率),过于激进的截断会把有用信息也切掉。
建议先用n=5保守处理,看IC变化。如果IC变化不大,可以逐步收紧到n=3。
坑四:只看去极值后的IC提升
去极值的目的是"还原因子的真实表现",不是"提升IC"。
如果去极值后IC下降了------恭喜你,你发现了一个"假因子"。这是好事,不是坏事。
因为至少你不会拿着一个假因子上实盘。
坑五:忘了检查数据源
去极值只能处理"已经出现在数据里的极端值"。
但有些极端值的根源------在数据源。
比如:
• 复权方式不对(前复权 vs 后复权 vs 不复权)
• 停牌数据没处理好
• 财务数据的单位不统一(万元 vs 元)
• 数据源的时间戳错误
去极值是最后一道防线。但最好的防线------是从数据源头就保证质量。
回到故事
那天之后,我把30个因子全部做了MAD去极值。
结果------
有5个因子,去极值后IC直接归零。
它们就是被极端值"虚撑"出来的假因子。
剩下25个因子里,有8个IC明显下降(极端值贡献了一部分"虚假显著性"),17个IC基本不变甚至上升。
最终------我用这17个因子重新建模。
夏普从0.5------涨到1.6。
不是因为用了更复杂的模型。
是因为------去掉了5个假因子,纠正了8个被污染的因子。
导师后来说:
"量化这行,最贵的不是模型。是数据。"
"而数据最贵的部分------不是获取,是清洗。"
"你花80%的时间在数据清洗上,不丢人。那20%才花在策略上------这才是正确的比例。"
总结
这一篇讲了去极值处理。
核心要点:
-
为什么需要 :一个极端值就能毁掉整个因子------均值、方差、IC全部被带偏
-
三种方法 :3σ法(简单但粗糙)、MAD法(稳健之王,业界标配)、百分位法(直觉但粗糙)
-
怎么做 :截面去极值 → 截面标准化 → 检查IC变化
-
常见坑 :不在时间序列上做、不去标准化、太激进、误解IC变化、忽略数据源
去极值是数据清洗的关键一步。但清洗不止于此------接下来还要处理缺失值、行业中性化、因子正交化......
下一篇,我们聊聊缺失值处理------你的因子数据里,那些"空值"到底该怎么填?直接扔掉?用均值填充?还是用更聪明的方法?