中性化:把不想要的暴露剔掉,剩下的才是你的本事
你折腾出了一个选股规则,历史上跑得挺好。你很高兴,觉得自己找到了一条别人没注意到的线索。
然后有人问了你一句:你选出来的这批股票,是不是大部分都在同一个行业?
你回去一看,果然。前二十只里有十四只是同一类公司。而那几年,正好是这类公司集体上涨的年份。
这一下就尴尬了。因为如果是这样,你那套规则可能压根没在「选股」——它只是用一种很绕的方式,替你押了一把行业。押对了,赚了钱,但这钱和你以为的来源完全是两回事。这篇要讲的中性化,处理的就是这件事:在下判断之前,先把这些搭便车的成分从你的数值里扣出去。
先说人话:跟谁比,决定了你能看出什么
想象一家公司有两个销售,年底都涨了 40% 的业绩,老板要评谁更厉害。
A 卖的是当年最火的那条产品线,全公司卖这条线的人平均涨了 55%。B 卖的是一条不温不火的老产品线,同组人平均涨了 12%。
跟「全公司平均」比,两个人一样好。跟「同组的人」比,A 其实落后了 15 个点,B 领先了 28 个点,结论完全反过来。
这里发生的事情,就是中性化的全部要点:你要衡量的是「个人的本事」,可你手上的数字里混着「他所在那条产品线的运气」。想把本事看清楚,就得先想办法把产品线的影响扣掉。 最土也最有效的办法,是不跟全公司比,只跟同组的人比。
再换一层。假设 B 那一组里有人管的是大客户、有人管的是小客户,大客户天然基数大、增速慢。那么「只跟同组比」还不够,你还得再按客户规模分一次。每多一个「不是本事、但会影响数字」的因素,你就得多扣一层。
股票世界里,这些「不是选股本事、但会影响数字」的因素有名字。最常见的三个是:市场(大盘今天整体涨还是跌)、行业(这只股票属于哪一类公司)、市值(公司体量是大是小)。把它们从你的数字里扣掉的动作,分别叫市场中性化、行业中性化、市值中性化。
「暴露」这个词也顺手解释一下:它指的是你的组合对某个因素有多敏感。全仓压在一个行业上,就是对这个行业暴露很高;大盘一动你就跟着动很多,就是对市场暴露很高。中性化,字面意思就是把这些暴露拉回到接近零。
投资层:不做中性化,你的因子可能只是一次行业押注
先把上面那件事说透。
假设你造了一个因子——因子就是把「这票好不好」压成一个每天可以给每只股票算出来的具体数值,细节可以回 因子 那篇看。你这个因子的定义是「研发投入占收入的比例越高越好」。
你拿它去排序、选前 50 只、回测,成绩很漂亮。
问题在于:研发投入占比高的公司,天然扎堆在少数几类行业里。传统制造、公用事业这类公司很难在这个指标上排到前面,不是因为它们差,而是它们的生意本来就不靠研发投入驱动。于是你这个因子每天选出来的,几乎是同一批行业的公司。
那么它的历史成绩里到底有多少是「选股」,多少是「押行业」?在没做拆分之前,你没法回答。而这两者的区别不是学术洁癖——它决定了这套东西明年还灵不灵。 如果赚的是选股的钱,只要你的判断依据还成立,换个行业环境它照样能选出好的;如果赚的是押行业的钱,那么等这个行业不涨了,你的因子会一夜之间失效,而你会以为是「市场风格变了」。
同样的故事在市值上再演一遍。很多因子天然偏向小公司——小公司波动大、财务数字容易出现极端值、涨跌幅也大。如果你回测的那段时间正好是小公司整体跑赢的时期,你会得到一个非常漂亮的成绩,而它本质上只是一句「买小的」。
这跟 beta 与 alpha 那篇讲的是同一个病,只是换了个维度。那篇处理的是「跟大盘的部分要不要算你的本事」,这篇把这个问题从「大盘」推广到「行业」「市值」以及任何一个你不想要的共同成分上。
两条常见的做法
第一条:分组内比较。 简单粗暴,也最好理解——不跟全市场比,只在同一个行业内部排序打分。某只公司在全市场排第 300 名,但在它自己那个行业里排第 2,那它就是好的。这条路的好处是几乎不会出错,坏处是行业里股票太少的时候,排名会很不稳定,而且它只能处理行业这种有明确分类的因素,处理不了市值这种连续变化的量。
第二条:回归取残差。 这是更常见的工业做法。「回归」在这里可以先理解成一句大白话:用行业和市值去尽力解释你的因子值,能解释掉的部分扔掉,解释不掉的那部分留下。 那个解释不掉的剩余量叫「残差」,它就是中性化之后的因子。行业这种分类信息进不了公式,得先翻译成一堆只有 0 和 1 的列——属于这个行业记 1,不属于记 0,这种列有个专门的名字叫哑变量。
两条路的共同点是:中性化后的因子,数值本身不再有直接含义了。它不再是「研发占比」,而是「在扣掉行业和市值之后,这家公司的研发占比比同类应有的水平高出多少」。这一层意思上的转变,是很多人用着用着就忘掉的。
在因子上做,还是在组合上做
还有一个容易混淆的分岔。中性化可以发生在两个位置:
一是在因子层做——按上面两条路,把因子数值本身洗一遍,再拿洗过的数值去排序选股。 二是在组合层做——因子不动,但在决定每只股票买多少的时候加上约束,比如「组合里每个行业的权重必须和基准指数的行业权重一致」。基准指数就是你用来比较的那把尺子,通常是某个宽基指数。
两者不等价。因子层中性化之后,选出来的股票依然可能扎堆——因为排名靠前的恰好都在一个行业。组合层约束才是真正锁死行业暴露的那道闸。实盘里两者常常一起用,但它们解决的是不同环节的问题,别拿一个当另一个。
系统层:qlib 里能看到的三层,和看不到的那一层
qlib 把数据的预处理拆成了一堆可以自由拼装的小组件,叫 Processor,配置里一行一个,按顺序执行。中性化相关的东西就藏在这里面——但要先说清楚它藏了多少,没藏多少。
第一层:横截面标准化,把「今天全市场一起动」扣掉
这是 qlib 提供的最基础、也用得最多的一层。看 CSRankNorm 这个处理器的核心,只有四行:
cols = get_group_columns(df, self.fields_group)
t = df[cols].groupby("datetime", group_keys=False).rank(pct=True)
t -= 0.5
t *= 3.46 # NOTE: towards unit std
关键全在第二行的 groupby("datetime") 上。它按「日期」把数据分组,然后在每一天内部、跨所有股票做排名。 这个「同一天、横着跨所有股票」的动作,量化里叫横截面操作——qlib 自己在这个类的注释里就是这么解释「Cross Sectional」的:跨不同股票的操作,通常被称作横截面操作。
为什么这一步就已经是一种中性化?因为排名是相对的。今天大盘整体跌了 3%,所有股票的原始数值可能一起往下走,但排名不受影响——第一名还是第一名。这等于把「今天全市场共同的那一份涨跌」从数据里彻底剔了出去。 这就是最朴素的市场中性化。
后面那两个常数值得多说一句,因为它们不是拍脑袋定的。rank(pct=True) 得到的是 0 到 1 之间的百分位排名,这东西是均匀分布,均值恰好是 0.5、标准差的倒数约等于 3.46。所以减 0.5 是把中心挪到零,乘 3.46 是把幅度拉到标准差约等于 1。qlib 在类的文档串里把这段推导完整写了出来,这是数学结论,不是可调参数。
同一个文件里还有个 CSZScoreNorm,做的是同一件事的另一种口味:每天在横截面上减均值、除以标准差,而不是转成排名。它还支持一个 robust 模式,用中位数和绝对中位差代替均值和标准差——这是为了对付极端值,因为均值很容易被一两只离谱的股票拽偏。
一个必须看清的对照:横着标准化 vs 纵着标准化
同一个文件里还有 RobustZScoreNorm。名字长得很像,性质完全不同,混用是真会出事的。
df = fetch_df_by_index(df, slice(self.fit_start_time, self.fit_end_time), level="datetime")
X = df[self.cols].values
self.mean_train = np.nanmedian(X, axis=0)
self.std_train = np.nanmedian(np.abs(X - self.mean_train), axis=0)
它没有 groupby("datetime")。它是在一段指定的训练时间区间里,把这个字段的整段历史拿来算一个中位数和一个离散度,然后用这一对固定的数去缩放所有数据。这是纵向的、跨时间的标准化,目的是把不同量纲的字段拉到可比的尺度上,它完全没有去掉市场共同成分——大盘一起涨的那天,所有股票的值还是会一起变大。
顺带一提,这个类的源码注释里专门用三个感叹号强调了一句:训练区间的结束时间绝对不能包含测试数据的信息。这是个独立的坑,属于 前视偏差 的范畴。它默认还会把超出正负三倍的值截断(这是当前的默认行为,参数可配置),这一步会顺手改变分布形态,用之前值得知道。
横着做的才是中性化,纵着做的是量纲统一。 两个都叫「标准化」,一个字之差,性质天壤之别。
第二层:qlib 的 Processor 里没有现成的行业中性
这一点得诚实说。把 qlib 这份快照全仓搜一遍「neutral」,结果是零。processor.py 里那一串处理器——丢空值、丢列、筛列、填空、极值处理、几种标准化——没有任何一个是「按行业分组做中性化」的。
这不是 qlib 的缺陷,而是一个值得记住的普遍事实:框架给你的是通用的数据处理积木,行业中性这类和具体市场、具体行业分类体系强绑定的东西,通常要你自己接。 它甚至连行业分类数据本身都不在默认数据集里。
对读者的意义在于:如果你看到一份配置里写了 CSZScoreNorm 或 CSRankNorm,别以为「中性化已经做了」。它只做掉了市场那一层,行业和市值这两层原封不动地还在你的因子里。
第三层:事后归因,验一验你到底赚的哪份钱
qlib 在回测端提供了 brinson_pa——Brinson 业绩归因。它的输出是三列,函数文档里写得很直白:RAA 是资产配置带来的超额收益,RSS 是选股带来的超额收益,RTotal 是两者合计的总超额。
它的参数签名把本篇的主题几乎明说了:
def brinson_pa(
positions,
bench="SH000905",
group_field="industry",
group_method="category",
group_n=None,
...
group_field 的文档注释写着:用来划分资产配置分组的字段,industry 和 market_value 是常用的两个。行业和市值——正是中性化最常处理的那两个维度。 这不是巧合:同一组东西,你在事前处理它叫中性化,在事后拆解它叫归因。
分组方式有两种,也很说明问题。行业是离散的分类,直接按类别分组(category)。市值是连续的数值,分不了类,于是走 bins 这条路——把值切成若干档,每档算一组。切档的方式尤其值得看:
split_points = np.percentile(bench_values[~bench_values.isna()], np.linspace(0, 100, group_n + 1))
split_points[0], split_points[-1] = -np.inf, np.inf
切点是从基准指数成分股的市值分布里取的分位数,不是从你自己的持仓里取的。这个细节很关键:分组的尺子必须来自你要比较的对象,否则「我的组合在大市值档超配了」这句话就没有参照物。两端还被硬撑到正负无穷,防止你持仓里有比基准里任何一只都大或都小的票掉到分组外面去。至于切成几档,那是调用时传进去的参数,没有默认答案。
把这三层连起来看,一条完整的路径就出来了:横截面标准化扣掉市场 → 自己动手扣掉行业和市值 → 事后用归因验一验,超额收益到底记在配置头上还是选股头上。 中间那一步框架不替你做,最后那一步大多数人不做。
为什么中性化也会骗人
到这里你可能觉得,那全都中性化掉不就完了。这一节讲的就是为什么不行。
你扣掉的,可能正是真信号。 行业轮动本身可以是一种有效的判断能力。如果你的判断确实是「今年这类公司会好」,那么把行业成分中性掉,等于亲手把自己的收益来源删了。中性化是一个价值判断——它假定你不想要那部分暴露。这个假定必须由你来做,工具不会替你做。
中性化之后 IC 掉了,通常不是变差。 IC 是衡量因子和未来收益相关性的指标,详见 IC 与 IR。一个因子中性化之后 IC 往往会明显下降,很多人看到这个数字就慌了,回头把中性化撤掉。但正确的解读是反过来的:原来那个高 IC 里,有一部分本来就是行业带来的,中性化只是把它显影了。 你现在看到的才是真实水平。撤掉中性化不会让因子变好,只会让你重新看不见问题。
行业分类本身是个人为规则,而且会变。 不同的分类体系对同一家公司的归属可能不同;一家公司的主营业务变了,会被重新归类;分类表还会定期调整。更麻烦的是时点问题——如果你用今天这份分类表去处理三年前的数据,而某家公司是去年才被划进现在这个行业的,那你就把未来的信息塞进了历史。这是标准的前视偏差,只不过入口藏在一张看起来人畜无害的分类表里。
线性方法只能剔掉线性的部分。 回归取残差背后有一个假定:因子和行业、市值之间的关系是线性的。真实关系往往不是。做完之后残差里通常还留着弯曲的那一块,你以为扣干净了,其实没有。
中性了这几个,别的维度还敞着。 你把行业和市值都中性掉之后,很可能发现组合整体压在「低波动」或者「高流动性」上——这些同样是暴露,只是你没去看。中性化永远是「针对你列出来的那几个维度」,没列进去的维度它管不着。
过度中性化会把因子磨成噪音。 每扣一层,剩下的信号就少一点。扣到最后可能只剩下随机波动,而这些残余的随机波动会让每天的排名剧烈跳动,进而让你频繁调仓。换手上去了,交易成本假设 那篇讲的那些开销就会一口一口把剩下那点收益吃掉。回测里这部分很容易被低估。
最后一条,也是最要命的:中性不等于没风险。 「市场中性」这个词特别容易让人误读成「稳赚不赔」。它只意味着组合对大盘方向不敏感,不意味着不会亏。在流动性紧张的时候,中性组合的两边可能同时朝不利方向走——你做多的那批被恐慌抛售,你做空的那批被逼空——两头一起亏,杠杆还会把这个过程放大。中性化处理的是「暴露」,不是「亏损」。
看到「已做行业中性」四个字,先问三句话。 用的是哪套行业分类?分类表是不是当时那一版?中性化发生在因子层还是组合层?这三个问题任何一个答不上来,「已做中性」这四个字就只是一句话,不是一个可核对的事实。
失效边界与常见误用
把标准化当成中性化。 前面那对 RobustZScoreNorm 和 CSZScoreNorm 就是活例子。名字里都有 Norm,一个横着一个纵着,性质完全不同。判断方法很简单:看它有没有按日期分组。没有按日期分组的,一律不是中性化。
中性化之后还拿原始数值的含义去解读。 中性化后的因子值是残差,不是原来那个量了。「这只票中性化后的估值因子是 1.8」这句话不代表它便宜,只代表它比同行业同规模的公司应有的水平高出这么多。
只在因子上中性,然后声称组合是中性的。 前面说过,这两步不等价。因子中性之后组合照样可能扎堆。要看组合真实的暴露,得去看权重,或者干脆用归因拆一遍。
用今天的分类表处理历史数据。 上面已经说了,这是把前视偏差从后门放进来。任何随时间变化的辅助数据——行业分类、指数成分、市值分档的边界——都得问一句「这份表是哪一天的」。
因为中性化后成绩变难看就放弃它。 这是本篇最想拦住的一个动作。中性化让成绩变难看,通常说明它起作用了。你要做的是接受这个更低但更真实的数字,而不是回去找那个让你舒服的版本。
在没搞清自己想赚什么钱之前就动手中性。 中性化是手段不是目的。先回答「我这套东西打算靠什么赚钱」,再决定扣掉什么。顺序反了,你会一边扣一边把自己真正的优势也扣光。
小结
- 中性化 = 在判断之前,先把「不是你本事、但会影响数字」的共同成分从因子里扣出去。最常处理的三层是市场、行业、市值。
- 不做中性化,一个看起来能选股的因子,很可能只是在用一种绕弯的方式押行业或押规模。押对了照样赚钱,但换个环境会一夜失效,而你会误以为是市场风格变了。
- 两条常见做法:分组内比较(只跟同行业的比,简单稳妥但处理不了连续量),回归取残差(用行业哑变量和市值去解释因子,留下解释不掉的部分)。另外还要分清是在因子层做还是在组合权重上做,两者不等价。
- qlib 的 Processor 提供的横截面标准化(按日期分组做排名或 z-score)已经是市场那一层的中性化;但整个仓里没有现成的行业中性处理器,那一层要自己接。别把「配了 CSRankNorm」当成「中性化做完了」。
- 同一组维度,事前处理叫中性化,事后拆解叫归因——qlib 的 Brinson 归因把超额收益拆成配置贡献和选股贡献,用的分组字段正是行业和市值。
- 代价是真实存在的:可能扣掉真信号、IC 会下降、行业分类表带前视风险、线性方法剔不干净非线性关系、扣过头会推高换手成本。中性也不等于不会亏。
一句话记住这篇:别人夸你会挑,先看看你是不是只是站对了队。
本文所引源码均来自上述快照版本,是阅读代码得出的机制说明,不是运行结果;具体行为请以你手上那个版本为准。回 量化与 AI 投研概念库 看其它概念各自在说什么;本站内容为投资教育,不构成任何投资建议,边界见 免责声明。
本文为投资教育整理,关键数据与结论请结合下列权威来源验证。
- qlib 源码快照 79633dd(2026-07-23):qlib/data/dataset/processor.py(CSZScoreNorm、CSRankNorm、RobustZScoreNorm、CSZFillna) ↗
- qlib 源码快照 79633dd(2026-07-23):qlib/backtest/profit_attribution.py(brinson_pa、get_stock_group、get_daily_bin_group、decompose_portofolio) ↗
- qlib 源码快照 79633dd(2026-07-23):examples/benchmarks 下多份 workflow 配置中 learn_processors 的写法 ↗
- 资产定价通识:Brinson 业绩归因框架;行业、市值作为常见风险暴露维度的行业惯例 ↗