1. 概述
行业中性化(Industry Neutralization)旨在从因子中剔除行业所带来的系统性偏差,使因子能够更真实地反映个股的特质(idiosyncratic characteristics)。许多因子天然地与特定行业相关联,比如市盈率因子在金融行业普遍较低,而在科技行业可能较高。
行业中性化通常通过分行业去均值或引入行业哑变量回归等方式实现,处理后因子值在行业间趋于均衡,从而避免策略因行业偏好而产生非预期的暴露。经过行业中性化处理的因子,更具普适性和解释力,在多因子模型、因子排序及回测分析中能有效提升选股信号的稳定性与有效性。
行业中性化是因子预处理中的关键步骤,尤其适用于希望控制行业风险暴露、专注于横截面选股的策略中。
2.行业中性化的计算
本文将采用行业哑变量回归的方法实现行业中性化,在计算之前,我们需要获取到股票的行业分类,一般采用申万的行业分类或者中信的行业分类。本文将采用申万2021年本版(31个一级分类,134个二级分类,346个三级分类),这里我们只需要使用一级分类即可,使用tushare的接口获取分类,并获取该分类下的所有股票,再保存到map中,给每一个股票都设置好相应的行业分类,参考代码如下:
def get_sw2021_industry_map(self):
"""获取申万2021行业分类映射"""
industry_df = self.pro.index_classify(level='L1', src='SW2021')
industry_map = {}
for _, row in tqdm(industry_df.iterrows(), total=len(industry_df), desc="获取行业分类"):
index_code = row['index_code']
industry_name = row['industry_name']
try:
self.rate_limiter.wait()
members = self.pro.index_member_all(l1_code=index_code)
for _, m in members.iterrows():
industry_map[m['ts_code']] = {
'industry_code': index_code,
'industry_name': industry_name,
'class_src': 'SW2021'
}
except Exception as e:
print(f"⚠️ 获取行业成员失败: {index_code} - {e}")
return industry_map
有了行业分类数据之后,就可以进行行业中性化了,这里先简单介绍一下什么是行业哑变量
。
行业哑变量(Industry Dummy Variables) 是将每只股票所属行业的分类信息,转换为适合建模使用的数值型变量的方式。它通过为每个行业生成一个二进制(0 或 1)的变量,表示该股票是否属于该行业。
这是一种常见的 独热编码(One-Hot Encoding) 方法,常用于线性回归、因子中性化等任务中。
下面给出一个示例:
假设我们有以下股票及其所属行业:
ts_code | industry_code |
---|---|
000001.SZ | Banking |
000002.SZ | RealEstate |
000003.SZ | Banking |
000004.SZ | Healthcare |
生成行业哑变量后:
ts_code | ind_Banking | ind_RealEstate | ind_Healthcare |
---|---|---|---|
000001.SZ | 1 | 0 | 0 |
000002.SZ | 0 | 1 | 0 |
000003.SZ | 1 | 0 | 0 |
000004.SZ | 0 | 0 | 1 |
有了以上基础之后,我们就可以参考上一篇市值中性化一样,通过线性回归来取残差作为中性化后的因子值,代码如下:
def neutralize_by_industry(self, df_factor, df_industry):
"""
向量化方式对因子做行业中性化(面板数据),返回添加残差列
"""
# 合并因子值和行业(行业是静态的,只需 merge 一次)
df = (
df_factor
.merge(df_industry, on="ts_code", how="left")
.copy()
)
# 构造行业哑变量(静态 one-hot 编码)
industry_dummies = pd.get_dummies(df["industry_code"], prefix="ind")
df = pd.concat([df, industry_dummies], axis=1)
# 要使用的自变量列名(只包括行业哑变量)
exog_cols = industry_dummies.columns.tolist()
# 定义单期中性化逻辑
def neutralize_group(g):
X = g[exog_cols]
y = g[self.factor_name]
valid = pd.concat([X, y], axis=1).dropna()
if valid.empty:
g[self.factor_name] = np.nan
return g[["ts_code", "trade_date", self.factor_name]]
X_valid = sm.add_constant(valid[exog_cols], has_constant='add').astype(float)
y_valid = valid[self.factor_name].astype(float)
try:
model = sm.OLS(y_valid, X_valid)
res = model.fit()
g[self.factor_name] = res.resid
except Exception as e:
print(f"行业中性化失败: {e},ts_code: {g['ts_code'].iloc[0]}, trade_date: {g['trade_date'].iloc[0]}")
g[self.factor_name] = np.nan
return g[["ts_code", "trade_date", self.factor_name]]
# 分组回归,按交易日横截面处理
df_resid = df.groupby("trade_date", group_keys=False).apply(neutralize_group)
return df_resid
3.实例展示
我们用上文提到的市盈率因子来展示采用了行业中性化处理与未采用行业中性化处理的区别。一般在处理时,通常采用市盈率的倒数来表达市盈率,E/P值越高,表示股票当前越便宜,越有吸引力。本文采用ep_ttm来计算,TTM(Trailing Twelve Months) 指的是过去连续12个月(通常为过去四个季度)的数据总和或平均值,用于评估公司最新一年的财务表现。
首先,不采用行业中性化,结果如下图。
其次采用行业中性化,结果如下图。
对比结果来看,行业中性化之后,最大回撤有所改善,夏普比率也有所提升。表明行业中性化有效剔除了行业轮动带来的干扰,更加突出因子的真实选股能力。本期就到这里,欢迎交流,下期间!