遗传规划选股因子挖掘:从公式进化到实盘验证的工程实践
2026/9/23 15:40:40 网站建设 项目流程

简介:这份华泰证券金工深度研究报告聚焦遗传规划在选股因子挖掘中的应用,面向量化投资研究者、因子开发人员及金融工程方向的学习者,帮助读者理解如何借助启发式公式演化技术突破人工构建因子的思维局限。资源为1个PDF文件,压缩包约3.32MB,内容完整呈现了遗传规划的原理、总体流程与公式树形表示、适应度计算、交叉与变异等进化方法,并深入讲解gplearn程序包的定制改进,包括扩充函数集、引入单因子测试与并行运算加速。报告还展示了以个股20个交易日后收益率为预测目标的实测流程,初步挖掘出6个具有增量信息且可解释性良好的选股因子,同时客观讨论了因子复杂度过高、可解释性下降等局限。目前已有929人学习,适合希望系统掌握遗传规划因子挖掘框架、并据此调整数据源、股票池与评价指标的读者参考。

1. 遗传规划选股因子挖掘:为什么它能从公式堆里捞出真正赚钱的信号

很多人做量化因子,第一步就卡在“人工想公式”上——动量、反转、波动率、换手率,翻来覆去就那几十个,IC 衰减得比谁都快。遗传规划(Genetic Programming,GP)换了个思路:不靠人写公式,让程序自己进化出因子表达式。华泰这份 2019 年的研报把 GP 用在选股因子上,核心就是用树形结构表示公式,通过选择、交叉、变异不断迭代,最后挑出 IC 稳定、换手可控的因子。它解决的是因子挖掘的“供给瓶颈”,适合已经有一批基础量价数据、想批量产出候选因子的从业者。Python 生态里gplearn是最常见的落地工具,配合 pandas 做数据对齐,能跑通从原始行情到因子表达式的完整链路。这一章先把 GP 选股因子的定位讲清楚,后面几章拆实现、参数和坑。

2. 遗传规划挖因子的底层逻辑:树、算子与适应度怎么定

2.1 用树形表达式表示一个选股因子

GP 和传统回归最大的区别在于,它搜索的不是系数,而是结构。一个因子在 GP 里就是一棵表达式树:叶子节点是特征变量(比如收盘价、成交量、换手率),内部节点是算子(加减乘除、rank、delay、corr 等)。比如rank(corr(close, volume, 20))这棵树,根是 rank,左子节点是 corr,corr 下面挂 close、volume 和窗口 20。这样表示的好处是任意复杂度的公式都能被编码,而且交叉、变异操作直接在树上做,不会破坏语法结构。

我一般会把特征分成三类:价格类(open/high/low/close/vwap)、量能类(volume/amount/turnover)、衍生类(收益率、振幅、换手率变化)。算子集也要控制规模,太多算子会让搜索空间爆炸,太少又挖不出非线性关系。常见做法是保留四则运算、rank、ts_rank、delay、delta、corr、std 这几类,基本能覆盖研报里那批因子的形态。

2.2 适应度函数:IC、ICIR 还是多目标

适应度决定进化方向,选错了后面全白干。最直接的是用因子值和未来一期收益的 Rank IC 绝对值,但单期 IC 噪声大,容易过拟合。更稳的做法是用 ICIR(IC 均值除以 IC 标准差),它同时看稳定性和强度。华泰研报里也提到用 IC 和换手率做联合筛选,因为高换手因子扣掉交易成本后可能不赚钱。

实操中我会把适应度写成加权形式:fitness = |IC_mean| * w1 - turnover * w2 - complexity * w3。复杂度惩罚项很关键,防止 GP 进化出几百个节点的怪物公式,那种因子样本外基本失效。权重怎么定没有标准答案,我一般先让 w1=1、w2=0.1、w3=0.001 跑一轮,看因子分布再调。

2.3 选择、交叉、变异:进化流程的最小实现

下面这段代码用gplearn搭一个最小可跑的 GP 因子挖掘流程,数据用模拟的量价面板,重点是让读者看清每一步在干什么。

import numpy as np import pandas as pd from gplearn.genetic import SymbolicTransformer from gplearn.functions import make_function from scipy.stats import spearmanr # 1. 构造模拟面板数据:500 只股票,300 个交易日 np.random.seed(42) n_stock, n_day = 500, 300 close = np.random.randn(n_stock, n_day).cumsum(axis=1) + 50 volume = np.random.rand(n_stock, n_day) * 1e6 + 1e5 turnover = volume / 1e6 # 2. 标签:未来 5 日收益的截面 rank future_ret = np.zeros_like(close) future_ret[:, :-5] = close[:, 5:] / close[:, :-5] - 1 y = np.apply_along_axis(lambda x: spearmanr(x, np.arange(len(x)))[0], 1, future_ret) # 3. 自定义算子:时序 rank 和相关系数 def _ts_rank(x, window): return pd.Series(x).rolling(window).apply( lambda s: spearmanr(s, np.arange(len(s)))[0], raw=True).values ts_rank = make_function(function=_ts_rank, name='ts_rank', arity=2) # 4. 特征矩阵:每行是一个样本(股票-日期),列是特征 X = np.column_stack([close.ravel(), volume.ravel(), turnover.ravel()]) y_flat = y.ravel() # 5. GP 进化:生成 20 个因子表达式 gp = SymbolicTransformer( generations=15, # 进化代数 population_size=2000, # 每代个体数 hall_of_fame=100, # 名人堂保留数 n_components=20, # 最终输出因子数 function_set=['add', 'sub', 'mul', 'div', ts_rank], parsimony_coefficient=0.001, # 复杂度惩罚 metric='spearman', # 适应度用 rank IC random_state=42, n_jobs=-1 ) gp.fit(X, y_flat) # 6. 输出因子表达式和对应 IC for i, prog in enumerate(gp._best_programs[:5]): print(f"因子{i}: {prog}") factor_val = prog.execute(X) ic = spearmanr(factor_val, y_flat)[0] print(f" IC = {ic:.4f}, 复杂度 = {prog.length_}")

这段代码的逻辑链条是:先造一份带截面标签的数据,再把特征拉平成 GP 能吃的矩阵,然后用SymbolicTransformer做进化,最后打印表达式和 IC。参数上,generationspopulation_size决定搜索强度,2000×15 在单机上大概跑十几分钟;parsimony_coefficient控制公式膨胀,设太大因子会变得极简但 IC 低,设太小会出怪物公式;metric='spearman'让适应度直接对齐 Rank IC,比默认的 MSE 更贴合选股场景。hall_of_famen_components配合使用,前者是候选池,后者是最终输出数量,一般名人堂留 100、输出 20 左右比较平衡。

跑完之后别急着上实盘,先看表达式里有没有明显未来函数,比如算子窗口写成了负数,或者用了当天收盘算当天因子。GP 不会主动避免这些,得靠数据对齐和算子实现来兜底。

3. 从原始行情到 GP 输入:特征工程与数据对齐的实操

3.1 量价特征的构造与标准化

GP 对输入特征的尺度和分布很敏感。原始收盘价是 50 块,成交量是百万级,直接丢进去,四则运算会被大量级特征主导,挖出来的因子全是围绕成交量的。常见做法是先做截面标准化:每个交易日对每个特征做 z-score 或 rank 归一化,让所有特征在同一量纲上。我一般用 rank 归一化,因为它对异常值不敏感,而且和 IC 的截面排序逻辑一致。

def cross_section_rank(df, feature_cols): """按日做截面 rank 归一化,输出 0~1""" for col in feature_cols: df[col + '_rank'] = df.groupby('date')[col].rank(pct=True) return df # 假设 df 有 date, stock, close, volume, turnover 列 feature_cols = ['close', 'volume', 'turnover'] df = cross_section_rank(df, feature_cols)

这段代码按date分组做rank(pct=True),把每个特征压到 0~1。参数上,pct=True输出百分位,比原始 rank 更适合做后续运算。注意别把标签也一起 rank 了,标签要保留原始收益用于计算 IC。

3.2 算子窗口与未来函数的边界

GP 挖因子最容易翻车的地方就是未来函数。比如ts_rank(close, 20)如果实现成包含当天收盘,而标签是未来 5 日收益,那当天收盘和未来收益之间没有直接泄露,但如果你把标签算成了close[t+1]/close[t],而因子用了close[t],这其实是正常的。真正危险的是算子窗口方向写反,或者用了shift(-1)这类前视操作。

我的习惯是在算子实现里强制加一层检查:所有时序算子只允许访问t及之前的数据。gplearn的自定义函数拿到的是一维数组,窗口滚动时默认是从左到右,只要不手动反转就没问题。另外,标签计算要用close[t+5]/close[t] - 1这种形式,确保因子和标签的时间戳对齐。

3.3 样本切分:时序切分而不是随机切分

GP 进化过程会反复在训练集上评估适应度,如果随机切分,同一只股票的不同日期会同时出现在训练和测试集,导致信息泄露。正确做法是按时间切:前 70% 交易日做训练,后 30% 做样本外验证。更严格一点,可以用滚动窗口,每滚一次重新进化一轮,看因子在多个样本外窗口的 IC 是否稳定。

dates = sorted(df['date'].unique()) split = int(len(dates) * 0.7) train_dates, test_dates = dates[:split], dates[split:] train_mask = df['date'].isin(train_dates) test_mask = df['date'].isin(test_dates) X_train, y_train = X[train_mask.values], y_flat[train_mask.values] X_test, y_test = X[test_mask.values], y_flat[test_mask.values]

切分后分别 fit 和评估,样本外 IC 如果只有训练集的一半不到,基本可以判定过拟合。我一般要求样本外 IC 至少保留 60%,否则回去调parsimony_coefficient或减少generations

4. 避坑与排查:GP 选股因子挖掘里最容易翻车的五件事

4.1 因子 IC 很高但换手率爆炸

现象:挖出来的因子样本内 IC 0.08,看着不错,但一算换手率每天 80%,扣掉手续费后收益归零。原因是 GP 为了追求 IC,进化出了大量短周期反转类公式,比如rank(delta(close, 1))这种,信号每天翻来覆去。解决:在适应度里加换手率惩罚项,或者直接在算子集里限制delta的窗口不小于 5。我一般还会在最终筛选时加一条硬规则:日均换手率超过 30% 的因子直接丢弃。

4.2 表达式里出现常数除法导致 NaN 扩散

现象:跑完 GP 发现一半因子值全是 NaN,回看表达式里有div(close, volume)这种,某些股票停牌时 volume 为 0,除法直接爆掉。原因是算子实现没做除零保护。解决:自定义div算子时加一个极小值兜底,比如x / (y + 1e-8),或者在数据预处理阶段把 volume 为 0 的样本剔除。gplearn自带的div有保护,但自定义算子容易忘。

4.3 训练集 IC 0.1,样本外 IC 0.01

现象:进化了 30 代,训练集 IC 冲到 0.1,样本外惨不忍睹。原因是种群多样性丧失,GP 早熟收敛到一组过拟合公式。解决:增大population_size,提高变异率(p_crossover降到 0.7,p_subtree_mutation提到 0.1),或者用多个随机种子跑多次取交集。我一般会跑 5 个种子,只保留在至少 3 个种子里都出现的因子结构。

4.4 因子之间高度相关,输出 20 个等于 1 个

现象:n_components=20输出 20 个因子,但两两相关系数 0.9 以上,本质上是同一个信号。原因是 GP 的名人堂里保留了大量相似个体。解决:在输出前做相关性过滤,贪心选择 IC 最高且与已选因子相关性低于 0.7 的。gplearnSymbolicTransformer本身有去相关逻辑,但阈值偏松,建议自己再筛一遍。

4.5 数据对齐错误导致因子用了未来信息

现象:样本外 IC 异常高,达到 0.15 以上,但实盘一上就亏。原因是特征矩阵和标签矩阵在 ravel 时顺序错位,或者日期索引没对齐。解决:在构造 X 和 y 时用同一个 DataFrame 的同一批行,ravel 前先 reset_index,确保第 i 行特征对应第 i 行标签。我习惯在 fit 之前打印前 5 行特征和标签的日期,肉眼确认对齐。

5. 让 GP 因子真正可用的进阶技巧:去相关、滚动进化与实盘验证

5.1 用贪心去相关把 20 个因子压到 5 个

GP 输出的因子池通常冗余严重,直接全上等于没分散。我一般用贪心法:按 IC 从高到低排序,依次选入因子,如果新因子与已选因子的最大相关系数超过 0.7 就跳过。这样能把 20 个压到 5 个左右,且彼此信息互补。

def greedy_select(factors, ic_values, corr_threshold=0.7): """factors: (n_samples, n_factors) 矩阵""" order = np.argsort(-np.abs(ic_values)) selected = [] for idx in order: if not selected: selected.append(idx) continue corr = np.abs(np.corrcoef(factors[:, idx].T, factors[:, selected].T)[0, 1:]) if corr.max() < corr_threshold: selected.append(idx) return selected selected_idx = greedy_select(gp.transform(X_train), [spearmanr(gp.transform(X_train)[:, i], y_train)[0] for i in range(20)]) print(f"去相关后保留 {len(selected_idx)} 个因子")

参数上,corr_threshold设 0.7 是经验值,设 0.5 会更分散但可能丢掉有用信号,设 0.9 则去相关效果不明显。选完后用这 5 个因子做等权合成,再算合成因子的 IC,通常比单因子高 20%~30%。

5.2 滚动进化:每季度重新挖一轮

市场风格会变,2019 年有效的因子到 2021 年可能就失效了。固定一批因子用到底不现实。我的做法是每季度用最近 3 年数据重新跑一次 GP,输出新因子池,然后和旧因子池合并去相关。这样既能捕捉新规律,又不会完全抛弃历史有效信号。滚动进化的计算量不小,单次 2000×15 大概十几分钟,一季度一次可以接受。

5.3 实盘前的三层验证

第一层是样本外 IC:滚动窗口下 IC 均值大于 0.03、ICIR 大于 0.3 才算及格。第二层是分组回测:按因子值分 5 组,看多空收益是否单调,单调性差说明因子和收益关系不稳定。第三层是交易成本模拟:按日均换手率扣 0.2% 双边成本,看净值曲线是否还向上。三层都过了,才考虑小仓位实盘。

验证层指标及格线不达标怎么办
样本外 ICIC 均值 / ICIR>0.03 / >0.3减少代数,加复杂度惩罚
分组回测多空单调性5 组收益单调检查因子是否非线性过强
成本模拟扣费后年化>0加换手率惩罚,拉长窗口

这三层里最容易被跳过的是成本模拟,但恰恰是它把大部分“纸面因子”打回原形。我自己的血泪经验是:一个样本外 IC 0.05 的因子,如果日均换手 50%,扣费后基本不赚钱;反而 IC 0.03、换手 10% 的因子更值得上。GP 挖出来的因子天然偏短周期,所以换手控制比 IC 高低更重要。

最后说个习惯:每次跑完 GP,我都会把表达式抄到一张表里,标注生成日期、样本外 IC、换手率和去相关后的保留情况。攒了两年之后回头看,哪些算子组合反复出现、哪些窗口参数稳定,一目了然。这比每次重新调参靠谱得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询