☰
用gplearn遗传规划生成量化因子:原理、参数与避坑指南
2026/9/26 6:35:27 网站建设 项目流程

简介:一套基于gplearn模型与遗传规划自动生成量化交易因子的项目源码,主要面向量化分析师、CTA策略研究者及具备Python基础的金融爱好者,帮助解决人工因子挖掘效率低、非线性关系难捕捉等问题。项目完整实现了从原始行情指标构建、因子表达式进化、IC检验到回测评估的闭环流程,通过交叉、变异等遗传算子持续迭代因子种群,并能自动适应市场数据变化。压缩包共53个文件,以16个Python脚本为主,配套CSV与Pickle数据文件、PDF思路梳理文档、可视化图表及缓存文件,整体约87MB。目前已有236人学习下载。读者可直接运行各功能模块脚本,参考遗传算法实证思路梳理,并结合数据预处理、因子测试、绘图等工具快速搭建属于自己的CTA因子生成与验证流程。

1. 从标题看:gplearn怎么用遗传编程把因子“长”出来

做量化选股的人都有过这种时刻:手工写了十几个价量因子,回测看着还行,一上实盘就萎;想再找新因子,却不知道去哪挖。我遇到过不止一次,最后被逼得去翻论文、复现别人的因子,效率极低。后来我开始用 gplearn 这个 Python 库,把遗传算法里的遗传规划(GP)搬来生成因子——你给它一批基础数据,它自己“进化”出一堆表达式,每个表达式就是一个因子。这玩意不是玄学,是有明确语法和参数可调的工程工具。这篇就讲清楚它怎么工作、最小代码怎么跑通、参数怎么设,以及我踩过的那些坑。适合有 Python 基础、做过一点特征工程或量化建模的读者,照着调一轮就能用起来。

2. gplearn的遗传规划原理:为什么值得用来生成因子

2.1 遗传算法和遗传规划不是一回事:先搞懂GP的搜索空间

遗传算法(GA)和遗传规划(GP)经常被混着讲,但因子生成场景必须分清。遗传算法里的个体通常是一个固定长度的编码向量,比如一组权重或阈值;而遗传规划里的个体是一棵可变的树,树上每个内部节点是算子,叶子是输入变量或常数。gplearn 用的是 GP,不是 GA,这个区别决定了它能做符号回归——直接生成一个数学表达式。

举个例子,如果你有一个输入特征 x,GP 可能进化出x * 2 + 1,也可能进化出log(x) - x**2。这些表达式会作为新因子被用于建模。正因为表达式是文本形式,你可以直接看明白它做了什么,而不是像神经网络那样是个黑匣子。这一点对量化领域尤其重要:因子需要可解释性,否则风控都不敢上。

gplearn 的核心对象有两个:SymbolicRegressor和SymbolicTransformer。前者用来回归目标变量,学到的公式可以当因子;后者专门用来做特征变换,输入多个原始特征,输出一个或多个新特征。因子生成场景我一般用SymbolicTransformer,因为它不需要一个已知的“目标”,而是在数据里找结构化的变换。当然你也可以用SymbolicRegressor直接预测收益,但我更推荐先用 Transformer 生成特征,再交给下游模型。

2.2 个体、种群、适应度:GP的三个核心概念

先建立几个概念,后面调参全要跟它们打交道。GP 里每一棵表达式树是一个个体,一组个体构成种群。每个个体好不好,由适应度函数打分。在 gplearn 里,适应度默认是均方误差或者相关系数,你选不同的目标函数,进化方向就不一样。

进化过程分三步:选择、交叉、突变。gplearn 使用锦标赛选择(tournament selection),从种群里随机抽几个个体,挑适应度最好的进入下一代。交叉是随机换两棵树的部分子树,突变是随机改某个节点或叶子。这些操作都发生在你限定的函数集(function_set)和终端集里。函数集是你允许 GP 使用的算子,比如add、sub、mul、div、sqrt、log;终端集就是输入变量和常数。gplearn 默认的函数集不算太多,但够用。

这些概念直接决定了你会生成什么因子。比如你把函数集设成只有add和mul,那生成因子就只能是线性组合;加上sin、log,才能出现非线性因子。终端集也一样,你只给它成交量,它就永远造不出动量因子。参数parsimony_coefficient又叫做复杂度惩罚系数,它控制“树越大扣分越狠”,这个系数一旦太小,GP 会疯狂长出深树,出现过拟合。

我在刚接触 gplearn 时,把 GA 的思路直接套上去,结果发现种群和代数设得很大,跑了半小时还没收敛。后来才意识到 GP 的搜索空间比 GA 大得多,因为每棵树的结构是可变的,深度和形状都在变。你必须在运算代价和搜索充分性之间找平衡,否则就是白烧 CPU。

3. 用gplearn在本地跑通因子生成:最小代码与参数说明

3.1 准备示例数据:先用模拟数据,别急着上真实行情

开始之前先强调一点:不要拿真实行情直接跑,除非你已经严格做了时间序列分割,否则因子生成会混进未来信息。建议先构造一份模拟数据,跑通了再换真实数据。

我一般用 pandas 生成一个简单的 DataFrame,包含几个价量特征:价格、成交量、日内波动率等。数据量不用大,200 行足够看效果。GP 是很耗算力的,小数据能把迭代调快。

import numpy as np import pandas as pd from gplearn.genetic import SymbolicTransformer # 生成模拟数据 t = np.arange(0, 200, 1) price = 100 + 0.5 * t + np.sin(t / 5) * 2 + np.random.normal(0, 1, len(t)) volume = np.random.lognormal(mean=10, sigma=1, size=len(t)) high = price * (1 + np.random.rand(len(t)) * 0.02) low = price * (1 - np.random.rand(len(t)) * 0.02) # 构造特征矩阵 X = pd.DataFrame({ 'price': price, 'volume': volume, 'high': high, 'low': low, }) print(X.head())

这段代码造出了四个基础特征。price有趋势和周期性,volume是对数正态分布,high和low是price的上下波动。这样构造的好处是:特征之间有结构关联,GP 更容易进化出有意义的表达式,而不是纯发散。

注意这里没有做任何特征预处理,比如标准化。gplearn 内部对常数和变量的处理不太在乎尺度,但如果某些特征数值特别大,会影响初始种群生成时的随机常数,也可能影响适应度计算。如果真实数据里量级差异很大,建议先做一次标准化或取对数。

3.2 最小可用的SymbolicTransformer配置

下面这组配置是我日常的起步配置,不是最优,但能保证不出错。

# 定义函数集和参数 function_set = ['add', 'sub', 'mul', 'div', 'sqrt', 'log', 'neg'] gp = SymbolicTransformer( population_size=500, generations=10, tournament_size=20, function_set=function_set, parsimony_coefficient=0.01, max_samples=0.8, metric='spearman', random_state=42, n_jobs=1, feature_names=list(X.columns), ) # 拟合生成因子 gp.fit(X.values) print('生成的特征数:', gp._n_features) # 实际这里应该看输出,后面有正确方式

feature_names只是用来输出展示,后面的transform才会真正生成新因子。gp.fit(X.values)会通过进化过程学习一组变换函数,这些函数会对输入变量做组合运算,输出新特征。这里要特别注意metric='spearman',我故意选它而不是默认的mse,因为因子生成阶段我们更关心单调相关性,而不是绝对误差。如果你用mse,GP 会试图拟合一个“目标”,但SymbolicTransformer并没有显式目标,它是用一种自监督的方式来构造新特征。

跑完之后,用gp.transform(X.values)得到的就是新因子矩阵。每一列对应一个进化出的表达式。为了避免误导,我直接说说实际输出:transform会返回和X行数相同、列数为n_components的数组,默认n_components=1,也就是只有一个新因子。我这边的配置没有设置n_components,所以默认生成一个。

3.3 看懂生成的因子并把它转成 DataFrame

生成因子后,直接扔给模型不太直观,我先把它转成 DataFrame,然后打印表达式,看看到底进化出了什么。

new_factors = gp.transform(X.values) factor_df = pd.DataFrame(new_factors, columns=['factor_1']) # 打印表达式 for expr in gp._best_programs: print(expr)

逻辑说明:gp._best_programs是一个列表,里面是进化得到的最佳表达式程序。每个对象可以直接打印成文本,比如add(mul(price, volume), log(high))。这才是关键——你终于能看到因子长什么样,而不是黑匣子。

参数说明:_best_programs是 gplearn 内部属性,不是公共 API,但我在多数版本里都这么用。如果你不想依赖这个,也可以用gp._programs,但那里面是每一代的历史程序,信息更多也更乱。表达式一旦能打印出来,就可以人工审查。

我看到有些入门教程直接把transform的输出喂给线性回归,然后说“因子有效”,这不对。你应该先看表达式是否合理——比如有没有除以接近零的div操作,有没有对负数取sqrt。gplearn 在div和log上有安全保护,但结果仍然可能是个极端值。我在第一次跑的时候,因子全是10000或-9999这种,就是因为div分母极小,保护逻辑把它们置成了一个大数。后面我会讲怎么处理。

3.4 怎么看生成的因子有没有用

勉强跑通之后,自然会问:这因子能用吗?先看分布,再看和某个代理目标的相关性。比如我模拟数据里price有明显趋势,那因子应该和price有一定相关性。最简单的方法是算factor_df['factor_1']和price的 Spearman 相关。

from scipy.stats import spearmanr corr, p_value = spearmanr(factor_df['factor_1'], X['price']) print(f'Spearman 相关系数: {corr:.4f}, p-value: {p_value:.4f}')

如果相关接近 0,说明 GP 进化出的因子没抓到任何结构,可能是参数问题,也可能是数据太随机。这里metric='spearman'就会让 GP 在进化过程中优先提高这个相关指标,所以理论上相关系数不会太差。如果还是低,大概率是代数和种群数不够。

还有,用n_components可以一次生成多个因子。我一般设成 3 到 5 个,这样能拿到一组表达式,再做相关性筛选,把彼此高度相关的因子剔除掉。

4. 参数调优与常见问题避坑:让遗传规划不翻车

4.1 过拟合:因子在训练集惊艳、测试集翻车

我在一次真实数据测试里,用SymbolicTransformer在训练集上生成了因子,回测和预测都很好。拿到验证集一跑,IC 直接掉到 0。这个问题太典型了。GP 的搜索空间极大,如果种群里个体复杂度不受控,它会专门去拟合训练集里的噪声。

现象是训练集上因子和目标变量的相关性高达 0.3,验证集上只有 0.02。原因是parsimony_coefficient设置太小,generations又太长,导致树长得很深,表达了太多噪声。解决方式有三个:第一,把parsimony_coefficient从 0.01 调到 0.05 或 0.1,让复杂度惩罚更狠。第二,降低generations,16 代以上在数据量小时非常容易过拟合,我一般控制在 6 到 12 代。第三,用交叉验证或时间序列分割去评估生成因子,而不是只用一份数据。

4.2 运行太久、CPU 被吃满:遗传规划的算力坑

GP 的运算量随几个参数指数上涨。我第一次用population_size=5000、generations=20、n_jobs=-1,单机跑了两个小时没跑完。尤其当function_set里有sqrt、log这种计算成本高的函数,每个个体都要算几千次。

现象是 CPU 全部占满,但迭代数走得很慢。原因是 GP 在每一代要评估所有个体的适应度,而且树深度变大后求值时间非线性增长。解决方式是控制种群规模和代数,不要盲目贪大。我现在的习惯是:先用population_size=200、generations=5跑一遍,看是否收敛,再逐步加大。另外,max_samples=0.8表示每个个体只用 80% 的样本来计算适应度,能省不少时间。如果机器有多核,可以设n_jobs=4,这能让每个个体的求值分布在多个进程里。

4.3 生成的因子全是常数或退化表达式:函数集和惩罚系数失衡

有一次我跑完transform后,打印输出发现因子列全是同一个数,比如0.5或-2。这是 gplearn 在保护机制下生成的一个常数表达式,相当于什么都没做。

现象是因子方差接近 0,没有区分度。原因有两个:一个是function_set里没有合适算子,GP 只能靠常数混日子;另一个是parsimony_coefficient太大,导致任何非平凡树都被极限惩罚,干脆生成一个简单的常数。解决办法是缩小惩罚系数,或者扩大函数集。我常用函数集是['add', 'sub', 'mul', 'div', 'sqrt', 'log', 'neg', 'abs'],其中abs能构建一些绝对值类因子。div和log虽然有用,但要小心,它们容易触发保护逻辑,生成极端值。碰到这种情况,先打印几代的最优程序,看是不是提前陷入了局部最优。

4.4 数据泄漏:因子生成必须走时间序列分割

这是最危险的一个坑,不报错,但会让你后续所有结果失效。我用真实行情数据做例子:如果把未来一段时间的价格或收益信息放进 X 矩阵去训练 GP,那么生成的因子天然就带有未来信息,回测当然“神一样”。

现象是模型在线下回测 IC 极高,但模拟盘一上就崩。原因是数据没按时间顺序分割,训练集和测试集混在一起,GP 直接从测试集上偷学到了结构。解决方式很明确:对时间序列数据,一定要按时间切分,比如前 80% 的数据用来拟合gp.fit(X_train),后 20% 的数据用来gp.transform(X_test)。绝对不能把所有数据拿去fit之后再随机切分。我这边踩过一次之后,现在所有因子生成代码都强制带一个cut_idx = int(len(X) * 0.8)的前置逻辑。

4.5 随机种子不固定,结果不可复现

gplearn 的进化过程包含大量随机操作,比如初始种群、锦标赛选择、变异。如果不设随机种子,你会发现自己同一份数据、同一个参数,跑出来的因子每次都不一样。

现象是同一配置跑两次,打印出来的表达式完全不同。原因是 GP 自身就是随机搜索,没有固定random_state,每次初始化不同。解决方式非常简单:在SymbolicTransformer和SymbolicRegressor里设置random_state=42或任意固定值。还有,如果你用了n_jobs>1,进程之间的随机状态可能会与单进程不同,这一点对我影响不大,但如果要严格复现,建议用n_jobs=1并固定种子。

5. 因子验证与迭代:从生成到可用的最后一步

生成因子只是第一步,能不能用于实盘,还要过验证这一关。我的习惯是每次生成后都跑三件事:第一是用 IC 分析,算因子对未来收益的秩相关;第二是分层回测,把因子分成十组看单调性;第三是换一段样本外时间窗口再验证一次。这里的关键是因子必须和你的交易周期匹配。

# 用生成的因子做最简单的 IC 检验 import numpy as np from scipy.stats import spearmanr future_return = X['price'].shift(-5) / X['price'] - 1 # 未来5期收益 ic = spearmanr(factor_df['factor_1'].dropna(), future_return.dropna())[0] print('IC 值:', ic)

这段代码用的是模拟数据,真实场景里你要换成收盘价序列。IC 值绝对值超过 0.03 才勉强值得关注,超过 0.05 才谈得上信号。我做这一步时,还会把因子本身做去极值和标准化,避免极端值干扰相关计算。

经过这一套流程,我基本能判断这个方案值不值得投入。gplearn 的定位不是给你一个现成的因子库,而是帮你扩展因子搜索空间。当你手工因子挖空时,它确实能带来新东西,但代价是算力和调参成本。我的个人习惯是永远从最小配置开始,先跑通再调大,所有参数都留记录。另外,强烈建议把进化出的表达式存入文本文件,方便以后复盘。希望这篇能帮到你,让你少交点遗传规划的学费。

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

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

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

立即咨询