Skip to content

第23章:行业中性化——别让行业特征骗了你

做因子挖掘的朋友,十有八九都踩过同一个坑:辛辛苦苦挖出来的因子,回测曲线漂亮得不行,结果一分析,全是行业暴露带来的收益。说白了,你的因子不是在选股,是在选行业。

我刚开始做量化那会儿,就犯过这个错。挖了个动量因子,夏普比率干到2.3,兴奋得不行。结果一检查,好家伙,因子重仓了白酒和新能源,纯粹是行业 beta 在贡献收益。去掉行业影响后,因子 alpha 几乎为零。嗯,从那以后,行业中性化就成了我因子处理流程里的标配步骤。

为什么需要行业中性化?

你想想看,A 股市场有个特点:行业分化极其严重。同一天,半导体可能涨5%,银行可能跌2%。如果你的因子恰好偏好半导体,那回测收益里有多少是因子本身的能力,多少是行业运气?

行业中性化的目的,就是把行业的影响剥离掉,让因子纯粹反映个股在行业内部的相对强弱。这样我们才能判断:这个因子到底有没有选股能力。

核心思想:在每个行业内对因子做标准化处理,使得因子在行业间的均值为0,方差一致。这样因子得分不再受行业整体水平的影响。

行业分类标准:申万 vs 中信

做行业中性化,第一步是选行业分类标准。国内主流就两家:申万中信

对比维度 申万行业分类 中信行业分类
更新频率 每年6月/12月调整 每年1月/7月调整
一级行业数量 31个(2021版) 30个
二级行业数量 134个 110个
市场认可度 最广泛使用 机构使用较多
数据获取难度 容易(Wind/Tushare) 较容易

我个人习惯用申万一级行业做中性化。原因很简单:31个行业,数量适中,既不会因为行业太少导致中性化效果差,也不会因为行业太多把样本切得太碎。我在项目中试过用申万二级行业(134个),结果有些小行业里只有三五只股票,中性化后因子方差被严重压缩,效果反而不好。

我的建议:做因子研究时先用申万一级行业。如果因子在行业内选股能力稳定,再尝试用二级行业做更精细的中性化。

行业中性化处理:实操代码

行业中性化的数学表达其实很简单:

因子_中性化 = 因子_原始 - 行业均值

或者更严谨的做法:因子_中性化 = (因子_原始 - 行业均值) / 行业标准差

下面是我常用的 Python 实现:

import pandas as pd
import numpy as np

def industry_neutralize(factor_df, industry_df, method='demean'):
    """
    行业中性化处理

    Parameters:
    -----------
    factor_df : DataFrame, index=date, columns=stock, values=factor
    industry_df : DataFrame, index=date, columns=stock, values=industry_code
    method : str, 'demean' 或 'zscore'

    Returns:
    --------
    neutralized_factor : DataFrame
    """
    result = factor_df.copy()

    for date in factor_df.index:
        # 获取当天的因子值和行业分类
        f = factor_df.loc[date]
        ind = industry_df.loc[date]

        # 合并数据
        df = pd.DataFrame({'factor': f, 'industry': ind})
        df = df.dropna()

        if method == 'demean':
            # 减去行业均值
            industry_mean = df.groupby('industry')['factor'].transform('mean')
            df['neutralized'] = df['factor'] - industry_mean

        elif method == 'zscore':
            # 行业内标准化
            industry_mean = df.groupby('industry')['factor'].transform('mean')
            industry_std = df.groupby('industry')['factor'].transform('std')
            df['neutralized'] = (df['factor'] - industry_mean) / industry_std

        result.loc[date, df.index] = df['neutralized']

    return result

注意:做行业中性化时,一定要按日期逐日处理。不能把所有日期的数据混在一起算行业均值,因为行业成分股会变,因子分布也会随时间变化。

市值中性化:大市值≠好因子

行业中性化搞定了,还有个更隐蔽的干扰因素——市值

A 股市场有个明显的规律:小市值股票长期跑赢大市值。如果你的因子天然偏好小盘股,那回测收益里很大一部分是市值因子贡献的,不是你的因子牛。

市值中性化的做法和行业中性化类似,但处理方式略有不同:

def market_cap_neutralize(factor_df, market_cap_df):
    """
    市值中性化:通过回归剥离市值影响

    我一般用对数市值做回归,因为市值分布太偏了
    """
    from sklearn.linear_model import LinearRegression

    result = factor_df.copy()

    for date in factor_df.index:
        f = factor_df.loc[date].dropna()
        cap = market_cap_df.loc[date, f.index].dropna()

        # 取对数市值
        log_cap = np.log(cap)

        # 回归:因子 ~ 对数市值
        X = log_cap.values.reshape(-1, 1)
        y = f.loc[cap.index].values

        model = LinearRegression()
        model.fit(X, y)

        # 残差就是市值中性化后的因子
        residual = y - model.predict(X)
        result.loc[date, cap.index] = residual

    return result

为什么用对数市值?因为市值分布是右偏的,茅台和工商银行市值几千亿,小票才几十亿,直接回归的话大市值股票会主导回归系数。取对数后分布更接近正态,回归结果更稳健。

双重中性化:行业+市值一起搞定

实际应用中,我们通常需要同时剥离行业和市值的影响。这就是双重中性化。

做法有两种:

  1. 分步法:先做行业中性化,再对结果做市值中性化
  2. 联合法:把行业哑变量和市值一起放进回归模型

我个人更推荐联合法,一步到位,逻辑更干净:

def double_neutralize(factor_df, industry_df, market_cap_df):
    """
    双重中性化:行业 + 市值

    用多元回归同时剥离两个因素的影响
    """
    from sklearn.linear_model import LinearRegression
    import pandas as pd

    result = factor_df.copy()

    for date in factor_df.index:
        f = factor_df.loc[date].dropna()
        ind = industry_df.loc[date, f.index].dropna()
        cap = market_cap_df.loc[date, f.index].dropna()

        # 取共同索引
        common_idx = f.index.intersection(ind.index).intersection(cap.index)
        if len(common_idx) < 50:  # 样本太少就跳过
            continue

        f = f[common_idx]
        ind = ind[common_idx]
        cap = cap[common_idx]

        # 构建行业哑变量
        ind_dummies = pd.get_dummies(ind)

        # 构建回归自变量:行业哑变量 + 对数市值
        log_cap = np.log(cap).values.reshape(-1, 1)
        X = np.hstack([ind_dummies.values, log_cap])

        # 回归
        model = LinearRegression()
        model.fit(X, f.values)

        # 残差就是双重中性化后的因子
        residual = f.values - model.predict(X)
        result.loc[date, common_idx] = residual

    return result

关键点:双重中性化后的因子,与行业和市值都不相关。这时候因子暴露出来的收益,才是真正的选股 alpha。

避坑指南:我踩过的几个坑

做中性化处理,有几个细节不注意,结果就会出问题:

  • 我曾经忘记剔除行业哑变量的共线性问题。31个行业,回归时用了31个哑变量,结果模型报错。记住:要留一个行业作为基准组,只放30个哑变量。
  • 我曾经在市值中性化时用了原始市值,结果回归残差里还有市值影响。后来换成对数市值,问题解决。
  • 注意:中性化处理要在训练集内进行,不能把未来数据的信息带进来。每次只使用当天的截面数据做中性化。

知识体系总览

下面这张图,帮你理清本章的核心逻辑:

flowchart TD
    A[原始因子值] --> B{需要哪种中性化?}
    B -->|单维度| C1[行业中性化<br/>减去行业均值]
    B -->|单维度| C3[市值中性化<br/>回归剥离市值影响]
    B -->|联合| C2[双重中性化<br/>行业哑变量 + 市值回归]
    C1 --> Out[中性化后因子值]
    C2 --> Out
    C3 --> Out
    Out --> Goal[因子与行业、市值不相关 → 纯alpha]

做中性化处理,说白了就是给因子做一次"净化"。把行业和市值这些干扰因素过滤掉,才能看清因子的真实选股能力。我见过太多人因子回测漂亮,实盘一塌糊涂,十有八九是没做中性化处理。

记住一句话:因子挖掘的终点不是高收益,而是纯粹的 alpha。行业中性化和市值中性化,就是你通往这个终点的必经之路。