☰
遗传规划选股因子挖掘:从gplearn到可复现的因子工厂
2026/10/5 7:08:01 网站建设 项目流程

简介:这份华泰证券2019年6月发布的深度研究报告,聚焦遗传规划在选股因子挖掘中的应用,面向量化投资研究者、因子开发人员及金融工程方向的学习者,帮助解决传统人工构建因子难以突破思维局限的问题。资源为单个PDF文件,压缩包约3.32MB,完整呈现25页研报内容,涵盖遗传规划简介、总体流程、公式树形表示、适应度计算、gplearn程序包改进及选股因子挖掘测试等核心章节。报告详细说明了公式表示、适应度、选择、交叉、变异、终止条件等步骤,并介绍对gplearn的深度定制,包括扩充函数集、引入单因子测试与并行运算加速。测试部分以个股20个交易日后的收益率为预测目标,初步挖掘出6个具有增量信息的选股因子,同时讨论了因子可解释性降低等局限。目前已有930人学习,适合希望了解“先有公式、后有逻辑”因子研究思路的读者参考。

1. 遗传规划选股因子挖掘:从 gplearn 到可复现的因子工厂

很多人做多因子研究,卡在同一个地方:手里有量价数据,也知道要挖因子,但翻来覆去就是那几十个经典公式,RankIC 做到 0.03 就上不去了。华泰这份 2019 年的深度报告给了一条不太一样的路——用遗传规划(genetic programming)让计算机自己“进化”出公式,而不是靠人脑去猜。它的核心思路是“先有公式、后有逻辑”:先让算法在海量量价数据里搜索出对 20 日未来收益有预测力的表达式,再去解释它为什么有效。这份 25 页的 PDF 不是纯理论,它把 gplearn 这个 Python 库做了深度定制,补了时序函数、加了中性化、上了并行,最后挖出 6 个在行业和风格中性后仍有稳定 RankIC 的因子。适合谁?有 Python 基础、做过单因子测试、想突破人工因子瓶颈的量化从业者。如果你还在用 Excel 拉因子,这篇可以先收藏,等环境搭好再动手。

2. 遗传规划怎么“进化”出因子:公式树、适应度与四种变异

2.1 公式为什么用树表示,而不是字符串

遗传规划里的公式不是写成X0*X0 - 3*X1 + 0.5这种字符串,而是表示成一棵二叉树。叶子节点是变量或常数,内部节点是函数。比如上面那个式子,根节点是加号,左子树是减法,右子树是常数 0.5,减法的左子树是乘法(X0 乘 X0),右子树是乘法(3 乘 X1)。这种表示的好处是:任意子树都可以被替换或修改,而不用重新解析整个表达式。gplearn 内部就是用这种树结构做交叉和变异的。你不需要自己实现树,但理解它才能看懂后面参数为什么那样设。

2.2 适应度:选股场景下该用什么指标

适应度衡量一个公式有多“好”。回归问题常用均方误差,分类问题用交叉熵。但选股因子挖掘不一样——我们关心的是因子值和未来收益的秩相关,不是拟合精度。所以这份报告里适应度用的是因子在回测区间内的平均 RankIC 或因子收益率。这一点很关键:如果你直接用 gplearn 默认的mean absolute error,挖出来的公式可能拟合了收益的绝对水平,而不是排序能力,RankIC 会很难看。常见做法是自定义metric参数,传入一个计算 RankIC 的函数。

2.3 四种进化方式:交叉、子树变异、点变异、Hoist 变异

交叉是最常用的:选两个公式树,各切一个子树,交换。子树变异更激进:随机生成一棵新子树,直接替换掉父代的一棵子树,目的是把已经被淘汰的公式重新引入种群,维持多样性。点变异是替换单个节点或叶子,比如把某个add换成sub,或者把常数 0.5 换成 0.3。Hoist 变异比较特殊,它是对抗公式膨胀的:从树里选一棵子树,再从那棵子树里选一棵更小的子树,“提升”到原来子树的位置,相当于把中间层砍掉,让公式变短。这四种变异的概率由p_crossover、p_subtree_mutation、p_point_mutation、p_hoist_mutation控制,默认值分别是 0.9、0.01、0.01、0.01。实际用的时候,交叉占绝对主导,其他三种是辅助。

2.4 gplearn 的关键参数怎么设

gplearn 的 API 像 scikit-learn,核心参数有一二十个。挑几个最容易翻车的说。population_size是每代公式数量,太小搜索不充分,太大跑得慢,报告里用的量级是几千。generations是进化多少代,一般几十代就收敛了,再多容易过拟合。function_set是函数集,gplearn 自带的基础函数只有加减乘除、开方、对数、绝对值、倒数,做选股因子远远不够——这是后面要重点改的地方。parsimony_coefficient是节俭系数,惩罚过于复杂的公式,设太小公式会膨胀成几百个节点,设太大又搜不到有效因子,需要试。const_range是常数范围,默认 (-1,1),如果设成 None 公式里就没有常数,表达力会下降。init_depth是初始树深度,二元组,控制第一代公式的复杂度。

from gplearn.genetic import SymbolicTransformer from gplearn.functions import make_function import numpy as np # 自定义 RankIC 适应度函数(简化示意) def rank_ic_metric(y, y_pred, w): # y: 真实未来收益, y_pred: 因子值 from scipy.stats import spearmanr ic, _ = spearmanr(y, y_pred) return -abs(ic) # gplearn 默认最小化 metric,所以取负 est = SymbolicTransformer( generations=20, # 进化 20 代 population_size=2000, # 每代 2000 个公式 hall_of_fame=100, # 备选池 100 n_components=10, # 最终输出 10 个因子 function_set=('add','sub','mul','div','sqrt','log','abs','inv'), parsimony_coefficient=0.001, # 节俭系数,抑制公式膨胀 metric=rank_ic_metric, # 自定义适应度 const_range=(-1, 1), init_depth=(2, 6), p_crossover=0.9, p_subtree_mutation=0.01, p_point_mutation=0.01, p_hoist_mutation=0.01, random_state=42, n_jobs=-1 # 并行 )

这段代码里metric传的是自定义函数,gplearn 会最小化它的返回值,所以 RankIC 取负。n_jobs=-1用满所有 CPU 核,遗传规划计算量大,不开并行会等到怀疑人生。parsimony_coefficient设 0.001 是经验值,太小公式会爆炸,太大搜不到东西,建议从 0.001 到 0.01 之间试。

3. 把 gplearn 改造成选股因子挖掘机:函数集扩充与中性化嵌入

3.1 为什么原生 gplearn 不能直接用

原生 gplearn 的函数集只有八个基础函数,全是截面运算,没有时序运算。但选股因子大量依赖时序信息:过去 d 天的收益率、波动率、换手率、最高最低价关系。没有delay、ts_rank、correlation这些函数,遗传规划只能组合出CLOSE/OPEN这种静态比值,挖不出“过去 20 日收益率标准差”这类因子。所以必须扩充函数集。报告里加了二十多个自定义函数,包括rank、delay、correlation、covariance、scale、delta、signedpower、decay_linear、indneutralize、ts_min、ts_max、ts_argmin、ts_argmax、ts_rank、ts_sum、ts_product、ts_stddev。这些名字很多来自 WorldQuant 的 Alpha101,做量化的人应该眼熟。

3.2 自定义时序函数怎么写进 gplearn

gplearn 支持通过make_function注册自定义函数。每个函数需要定义function(计算逻辑)和arity(参数个数)。下面以ts_rank和delay为例。

from gplearn.functions import make_function import numpy as np # delay(X, d): 返回 d 天前的 X 值 def _delay(X, d): d = int(abs(d[0])) + 1 # X shape: (n_samples, n_days),这里简化处理 return np.roll(X, d, axis=1) delay = make_function(function=_delay, name='delay', arity=2) # ts_rank(X, d): 当前值在过去 d 天中的分位数 def _ts_rank(X, d): d = int(abs(d[0])) + 1 from scipy.stats import rankdata result = np.zeros_like(X) for i in range(X.shape[0]): window = X[i, -d:] result[i, -1] = rankdata(window)[-1] / len(window) return result ts_rank = make_function(function=_ts_rank, name='ts_rank', arity=2) # 注册到 function_set custom_function_set = ('add','sub','mul','div','sqrt','log','abs','inv', delay, ts_rank)

这里arity=2表示函数接受两个参数,比如delay(X, 5)。make_function返回的对象可以直接放进function_set元组。注意_delay里用了np.roll,实际生产中要处理边界,避免用未来数据填充。_ts_rank只算了最后一个截面的分位数,真实场景需要对每个截面日滚动计算,这里为了展示结构做了简化。

3.3 把单因子测试嵌进适应度计算

原生 gplearn 的适应度只算预测误差,但选股因子必须做中性化和标准化。报告里的做法是:在计算适应度之前,先对因子向量做中位数去极值、行业+市值+20日收益率+20日换手率+20日波动率中性化、标准化,然后再算 RankIC。这一步是嵌入在metric函数里的。也就是说,每进化一代,每个公式生成的因子值都要走一遍完整的单因子预处理流程。计算量很大,但只有这样挖出来的因子才是“干净”的。

def _neutralize_and_rankic(y, y_pred, w): # y_pred: 原始因子值,shape (n_stocks,) # 1. 中位数去极值 med = np.median(y_pred) mad = np.median(np.abs(y_pred - med)) upper = med + 5 * mad lower = med - 5 * mad y_pred = np.clip(y_pred, lower, upper) # 2. 中性化(这里用简化版,实际需回归取残差) # 假设已有行业哑变量 industry_dummy 和风格因子 style_factors # residual = y_pred - X @ beta # 为简洁,此处省略回归代码 # 3. 标准化 y_pred = (y_pred - np.mean(y_pred)) / (np.std(y_pred) + 1e-8) # 4. 计算 RankIC from scipy.stats import spearmanr ic, _ = spearmanr(y, y_pred) return -abs(ic)

这段代码里去极值用的是中位数绝对偏差(MAD),比标准差更稳健。中性化那步实际要做横截面回归,取残差作为中性化后的因子值。标准化是为了让不同量纲的因子可比。最后算 Spearman 秩相关,取绝对值再取负,因为 gplearn 最小化 metric。

3.4 并行运算怎么开

遗传规划的计算瓶颈在因子矩阵运算:每代几千个公式,每个公式要在几百个截面、几千只股票上算值,再算 RankIC。串行跑一遍可能要几个小时。报告里用了 Python 的并行技术,gplearn 本身支持n_jobs参数,底层用 joblib 做多进程。设置n_jobs=-1就能用满所有核。但要注意:如果自定义函数里有全局变量或不可序列化的对象,多进程会报错。常见做法是把所有依赖都放在函数内部,或者用joblib.Parallel手动包一层。

4. 避坑与排查:遗传规划因子挖掘的五个血泪教训

4.1 公式膨胀到几百个节点,RankIC 反而下降

现象:跑完几十代,hall_of_fame里的公式长得像天书,节点数几百个,但样本外 RankIC 只有 0.01 甚至为负。
原因:parsimony_coefficient设得太小,或者根本没设。遗传规划天然倾向于生成复杂公式,因为复杂公式在训练集上更容易拟合噪声。
解决:把parsimony_coefficient调到 0.001~0.01,同时限制init_depth的 max_depth 不超过 6。另外可以在适应度里加一个复杂度惩罚项,节点数超过阈值就扣分。

4.2 自定义函数用了未来数据,回测虚高

现象:挖出来的因子在回测区间 RankIC 高达 0.1,实盘一上就废。
原因:delay、ts_rank这些函数如果实现时用了np.roll且方向搞反,或者窗口计算时包含了当前截面之后的数据,就会引入未来信息。
解决:所有时序函数必须严格只使用 t 时刻及之前的数据。写完自定义函数后,用一小段数据手动验证:取 t=100 的因子值,看它是否只依赖 t<=100 的数据。常见做法是在函数里加assert,检查输入矩阵的列索引。

4.3 中性化回归没对齐行业分类,残差全是噪声

现象:中性化后因子 RankIC 大幅下降,甚至变成负的。
原因:行业哑变量和因子值的股票顺序不一致,或者行业分类用了未来才调整的分类标准。
解决:确保行业分类是 point-in-time 的,每个截面日用的行业归属必须是当天已知的。股票顺序在回归前用pd.align对齐。常见做法是把因子值、行业哑变量、风格因子拼成一个 DataFrame,用statsmodels的 OLS 回归取残差。

4.4 并行跑满 CPU 但内存爆了

现象:n_jobs=-1之后程序被系统 kill,日志显示内存不足。
原因:每个进程都会复制一份数据,如果原始因子矩阵很大(几千只股票 × 几千个交易日),内存会成倍增长。
解决:把n_jobs设成 CPU 核数的一半,或者用joblib的mmap_mode做内存映射。更彻底的做法是把数据切成块,每块单独跑遗传规划,最后合并结果。

4.5 随机种子没固定,结果无法复现

现象:同样的代码跑两遍,挖出来的因子完全不一样。
原因:遗传规划大量依赖随机操作,random_state没设或者设了但并行时每个进程的种子不同。
解决:在SymbolicTransformer里设random_state=42,并且在自定义函数里避免使用全局的np.random。如果用了并行,确保每个进程的随机种子是确定的。常见做法是在脚本开头设np.random.seed(42),但 gplearn 内部有自己的随机数生成器,以random_state参数为准。

5. 从 6 个 Alpha 因子到自己的因子工厂:验证、衰减与相关性检查

报告最终挖出 6 个因子,命名为 Alpha1 到 Alpha6。它们在剔除行业、市值、过去 20 日收益率、过去 20 日平均换手率、过去 20 日波动率五个因子的影响后,依然有较稳定的 RankIC。这说明遗传规划确实能从有限的量价数据里挖出增量信息。但挖出来只是第一步,怎么验证它是不是真的能用,才是关键。

先看 IC 衰减。报告里画了 RankIC 半衰期图,Alpha1 到 Alpha6 的半衰期大多在 5 到 10 个交易日之间。这意味着因子的预测能力衰减较快,适合短周期调仓。如果你的策略是月度调仓,这些因子可能撑不到下个月。常见做法是计算不同持有期的 RankIC,画出衰减曲线,选择 IC 仍然显著的最长持有期。

再看因子间相关性。报告里算了 Alpha1 到 Alpha6 两两之间的相关系数均值,大部分不高,说明它们提供了不同的信息。但要注意:相关性低不代表可以无脑合成。如果两个因子在不同市场环境下表现差异很大,简单等权合成可能不如动态加权。我一般会先做分层回测,看每个因子的单调性,再决定是等权、IC 加权还是最大化 ICIR。

最后是样本外验证。报告的回测区间是 2010/1/4 到 2019/5/31,全 A 股,剔除 ST、PT 和停牌。这个区间包含了 2015 年股灾和 2018 年熊市,算是比较严苛的。但遗传规划挖因子有一个天然风险:它在训练集上搜索出来的公式,可能只是拟合了那段历史的特定模式。所以拿到因子后,我习惯做三件事:第一,把回测区间切成两段,前一段挖因子,后一段验证;第二,换一个股票池,比如沪深 300 或中证 500,看因子是否仍然有效;第三,把因子值做行业中性后,看多头组合的行业暴露是否可控。

# 样本外验证的简化流程 import pandas as pd from scipy.stats import spearmanr def out_of_sample_test(factor_values, forward_returns, split_date): # factor_values: DataFrame, index=date, columns=stock # forward_returns: 未来 20 日收益率 train_mask = factor_values.index < split_date test_mask = factor_values.index >= split_date # 训练集 RankIC train_ic = [] for dt in factor_values.index[train_mask]: ic, _ = spearmanr(factor_values.loc[dt], forward_returns.loc[dt]) train_ic.append(ic) # 测试集 RankIC test_ic = [] for dt in factor_values.index[test_mask]: ic, _ = spearmanr(factor_values.loc[dt], forward_returns.loc[dt]) test_ic.append(ic) print(f"训练集 RankIC 均值: {np.mean(train_ic):.4f}") print(f"测试集 RankIC 均值: {np.mean(test_ic):.4f}") print(f"ICIR: {np.mean(test_ic)/np.std(test_ic):.4f}") return np.mean(test_ic), np.mean(test_ic)/np.std(test_ic)

这段代码把回测区间按split_date切成训练和测试两段,分别算 RankIC 均值和 ICIR。如果测试集 RankIC 只有训练集的一半甚至更低,说明过拟合了。ICIR 低于 0.3 的话,因子稳定性存疑。我一般要求测试集 RankIC 绝对值大于 0.02,ICIR 大于 0.3,才考虑放进因子库。

还有一个容易被忽略的点:遗传规划挖出来的因子,公式往往很复杂,可解释性差。报告里也提示了“因子可能过于复杂,可解释性降低”。我的习惯是,对每个挖出来的因子,先看它的公式树,尝试简化。比如如果公式里有一棵子树是div(X, X),那它恒等于 1,可以直接砍掉。如果某个常数出现多次,可以合并。简化后的公式如果 RankIC 没有显著下降,就用简化版。这样既降低了过拟合风险,也方便向团队解释。

从那以后我每次跑遗传规划,都强制走一遍样本外验证和公式简化,不看到测试集 ICIR 和简化后的公式,绝不把因子放进实盘。希望帮到你。

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

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

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

立即咨询