☰
gplearn实战:用遗传规划自动挖掘量化因子
2026/9/26 6:35:25 网站建设 项目流程

简介:基于gplearn模型的量化交易因子自动生成完整项目,利用遗传规划中的选择、交叉与变异操作,自动挖掘能预测价格变动的数学表达式,面向量化分析师、金融工程人员及Python开发者,弥补传统手工因子提取的局限。压缩包共包含53个文件,整体大小约87MB,其中Python源码脚本16个、pyc编译文件13个、CSV数据文件5个、pickle持久化数据4个,并附有3个PDF文档、可视化图片及Markdown说明,目录按数据、算法与工具模块分层组织,便于系统性学习与二次开发。

目前已有236人下载学习,适合希望系统掌握遗传规划因子生成流程的读者。项目以simple-ba_use-gplearn-to-generate-CTA-factor为基础,完整覆盖数据预处理、IC测试、回测与可视化等环节,同时提供遗传编程核心模块封装及实证思路梳理文档,帮助读者从数据到策略快速构建自己的CTA因子挖掘框架。

1. 用gplearn做因子生成:先分清遗传算法与遗传规划

用gplearn做因子生成,很多人第一反应是遗传算法,但gplearn真正落地用的是遗传规划(Genetic Programming)。遗传算法把解编码成定长向量,遗传规划把解编码成表达式树,直接进化出公式。量化挖因子时,我一般只用gplearn的SymbolicTransformer:基础量价特征进去,带未来收益标签的因子出来,表达式可读、能复盘。它训练时资源消耗不小,好处是Python生态成熟,几千行股票截面数据几分钟能跑完。这篇文章写给打算用遗传规划自动合成因子、又不想自己从零写进化框架的人,也写给那些在用Python写遗传算法代码但始终生成不出可解释因子的同路人。先别急着上强化学习,遗传规划生成的公式因子,在A股和期货里仍然是最容易解释、最容易跟踪失效原因的一类。

2. 遗传规划在gplearn里的实现:树结构、函数集与训练对象

2.1 从GA遗传算法到GP遗传规划:为什么树结构更适合造因子

要理解gplearn,先要区分两个词。常规的GA遗传算法(Genetic Algorithm)里,个体是一条定长编码,二进制串或浮点数向量,交叉是切割拼接编码串,变异是随机改写某个基因位。这种结构适合参数优化,比如给一个已有的多因子模型找权重,但它表达不出“A除以B再取对数”这种公式嵌套。

遗传规划(Genetic Programming, GP)把个体换成树。叶子节点是变量X0、X1或常数,内部节点是运算符add、sub、mul、div,整棵树就是一个表达式。交叉换的是某个子树,变异换的是某个节点。这样搜索空间从“参数值”变成了“函数结构”,正好对因子挖掘的口味:你给gplearn一堆基础特征,它负责把这些特征拼成新的复合因子。

我常拿一个例子给团队讲:假设X0是5日动量,X1是20日波动率,传统遗传算法要调的是这两个变量的权重;遗传规划则能进化出div(X0, sqrt(X1))这类动量除以波动的结构,本质上是一个风险调整动量因子。这个公式用嵌套结构天然表达了“高动量低波动”的逻辑,而权重调参做不到这种结构创新。

很多讲遗传算法python代码详解的文章里,交叉变异写起来很顺手,但一旦要造因子,定长编码方案就傻了。老老实实用定长编码去搜权重,搜到顶也就是线性模型,非线性关系全靠你预先设计特征。gplearn把进化算子封装在Cython加速的Program对象里,用户只需要调fit,树的生成、评估、选择、遗传操作都由库完成。

2.2 SymbolicTransformer、Regressor、Classifier到底选谁

gplearn主要提供三个类。SymbolicRegressor适合直接学一个回归模型,输入X输出y的预测值,它最后得到的是一个最终预测公式;SymbolicClassifier类似,做分类。但在因子挖掘场景,我要的不是那个预测值,而是“一组新特征”,所以我几乎固定用SymbolicTransformer。

SymbolicTransformer的fit(X, y)也是监督训练,但transform(X)输出的是n_components个新特征。它的内部逻辑是:每一代进化出若干棵树,每棵树代表一个因子表达式,用这些表达式对X做变换,再计算变换结果与y的相关性,相关性高的树被保留。也就是说,y的作用是给因子打分,而不是让你最终用predict去预测。这样很契合因子挖掘思路:先用现有特征合成候选因子,再用y筛选。

有人问我为什么不用SymbolicRegressor直接预测收益再反转成因子。我试过,问题在于Regressor对整体MSE负责,出来的公式偏向拟合噪声,而且它只输出一个表达式,因子的单调性基本不可控。Transformer则每个输出都明确对着响应变量优化,后续做IC分析、分层回测都更方便。

Classifier我基本不用。因子挖掘的响应变量是连续的未来收益,把它离散化成涨跌标签会丢掉极值信息;而且分类目标对树结构的影响不如秩相关那么直接。除非你在做事件驱动,比如预测停牌或者涨跌停,否则别把连续收益切掉。

2.3 数据形态与第一个能跑通的最小脚本

gplearn的fit接口和sklearn一致,X是二维数组,y是一维数组。但要注意,量化里的“一行”不是一个时间点一只股票的普通行,而是一个(股票, 日期)样本,且X里不能混入未来信息。常见做法是把截面展平:每行填过去5日的量价统计量,y填该股票未来N日收益。

下面这个脚本用随机数据演示API,确认训练流程能跑通:

import numpy as np import pandas as pd from gplearn.genetic import SymbolicTransformer # 模拟两天的股票截面数据,每行是一个(股票, 日期)样本 # 真实场景里X必须只用t-1及之前的数据构造,y用t+1到t+5的收益 rng = np.random.default_rng(42) n_samples = 2000 X = pd.DataFrame({ 'momentum_5d': rng.normal(0, 1, n_samples), 'volatility_5d': rng.lognormal(0, 0.5, n_samples), 'turnover_5d': rng.uniform(0, 0.1, n_samples), 'dist_ma10': rng.normal(0, 0.05, n_samples), }) y = rng.normal(0, 0.02, n_samples) gp = SymbolicTransformer( population_size=500, generations=2, function_set=['add', 'sub', 'mul', 'div', 'abs', 'log', 'sqrt', 'max', 'min'], parsimony_coefficient=0.01, metric='spearman', random_state=0, n_jobs=1, verbose=1, ) gp.fit(X, y) new_factor = gp.transform(X) print(new_factor.shape) # 得到n_samples行, n_components列

这里有几个初次上手就会看花眼的参数。population_size是每一代保留多少棵候选树,generations是进化多少代,两者共同决定搜索规模。function_set是允许使用的运算符,我故意把sin、cos去掉,它们在价格类序列上很容易过拟合。parsimony_coefficient是复杂度惩罚系数,越大越偏向短公式,后面会专门讲。metric='spearman'表示因子与y用秩相关打分,这比mse更符合IC口径。random_state固定住,复现结果才可靠。n_jobs先设1,并发问题放到第5章说。

这段代码跑完,new_factor就是gplearn替你生成的因子矩阵。注意随机数据上它没有意义,重要的是API调用方式。把它换成真实量价数据后,输出就是候选因子。有人第一次跑完看到遗传规划python代码比想象中短,就开始往shuffle后的数据上套,这是最容易翻车的地方,第3章讲数据切分时继续说。

2.4 真实量价特征怎么进模型:shift是唯一底线

接上面,我实际构造X时通常不直接用原始价格,而是把原始行情先做滞后,再算衍生统计量。原因很简单:t日收盘价本身要到t日收盘后才知道,而你的因子如果要能在t日收盘后计算并用于t+1日交易,就必须保证X里的所有数据都只用到t日及以前的数据。但gplearn的每一代树可以对X做任意组合,比如div(X0, X1)里X0是当日动量,X1是当日成交量,它们本身都不含未来,可如果树里出现未来N日成交量的特征,泄漏就会直接混进IC。

我的底线是:所有原始行情先按股票做shift(1),再用shift后的数据计算滚动统计量。比如5日动量,用的是close.shift(1)再除以close.shift(6),而不是直接用当天的close。这样做出来的特征天然落后一个交易日,y用未来5日收益,两者不会撞车。

panel = panel.sort_values(['date', 'stock']).groupby('stock').apply( lambda g: g.assign( momentum_5d=g['close'].shift(1) / g['close'].shift(6) - 1.0, volatility_5d=g['close'].shift(1).pct_change().rolling(5).std().shift(1) ) ).reset_index(drop=True)

这段代码两个shift要看清:第一个shift让当日数据变成昨日及以前,第二个shift保证滚动窗口的最后一天在t-1日,不包含t日。很多用gplearn挖因子的人翻车,不是进化算法写错,而是X里的未来函数没处理干净。gplearn本身不会帮你做这个事,它只会把“能预测y”的树选出来,而那些“能预测y”的树里,有不少正是用了不该出现的特征。

3. gplearn生成因子的最小流程:从数据对齐到参数设置

3.1 样本切分:时间序列必须按时间切,不能按行随机切

gplearn进化时会在训练集上拼命找“能解释y的X组合”,如果你用sklearn默认的train_test_split随机切分,同一时刻的截面样本会被切进训练和验证两侧,因子中只要有一点点用到未来统计量,验证IC都会假性偏高。我一般按日期排序,取前70%时间做训练,后30%做验证,y的窗口也要保证没有重叠泄漏。

切分之后,fit只在训练集上做,transform在验证集上做。有人图省事,把全样本交给gplearn,然后再看IC,那等于把考试答案提前给了学生。gplearn的进化过程会在选出的因子上继续迭代,最终选出的树几乎必然和全样本响应变量相关。所以最小可用流程至少是三段:训练集进化、验证集做IC、测试集做最终确认。

还有一个常见问题是训练集里包含极端行情。2020年疫情那波大跌,2024年年初的alpha对冲危机,这些时段如果混进训练集,gplearn会专门进化出针对极端跳空的公式。这些公式在正常市况下表现平庸。我会在做gplearn之前先把涨跌停和一字板样本剔除,或者给极端收益样本降权。遗传规划不擅长区分“规律”和“偶发事件”,你要替它把偶发事件先抹掉。

3.2 一个完整的“训练并评估RankIC”示例

下面给一套可以直接改的代码。假设你已经构造好了面板数据panel,包含date列、stock列、特征列和future_ret列。

# 按时间排序后切分 panel = panel.sort_values(['date', 'stock']).reset_index(drop=True) dates = panel['date'].unique() train_dates = dates[: int(len(dates) * 0.7)] val_dates = dates[int(len(dates) * 0.7):] feature_cols = [c for c in panel.columns if c.startswith('f_')] X_train = panel.loc[panel['date'].isin(train_dates), feature_cols].to_numpy() y_train = panel.loc[panel['date'].isin(train_dates), 'future_ret'].to_numpy() X_val = panel.loc[panel['date'].isin(val_dates), feature_cols].to_numpy() y_val = panel.loc[panel['date'].isin(val_dates), 'future_ret'].to_numpy() gp = SymbolicTransformer( n_components=5, population_size=2000, generations=5, function_set=['add', 'sub', 'mul', 'div', 'abs', 'log', 'sqrt', 'max', 'min'], parsimony_coefficient=0.01, tournament_size=20, metric='spearman', max_samples=10000, random_state=1, n_jobs=2, verbose=1, ) gp.fit(X_train, y_train) factor_train = gp.transform(X_train) factor_val = gp.transform(X_val)

这里fit完成之后,不要急着看factor_val的IC,先看verbose输出。gplearn的verbose会显示每一代的最好适应度和平均适应度。如果最好适应度一直在涨,但第3代以后平均适应度开始下降,说明种群在收敛,可能出现一个公式统治全局的情况。这时候生成的因子多样性很差,后面第5章会细说。

接着用按日期截面的方法计算RankIC:

import numpy as np from scipy.stats import spearmanr def rank_ic_stats(panel_, factor_df, y_col, date_col): ic_list = {i: [] for i in range(factor_df.shape[1])} for dt in np.unique(panel_[date_col]): mask = panel_[date_col] == dt y = panel_.loc[mask, y_col].to_numpy().ravel() for col in range(factor_df.shape[1]): f = factor_df.loc[mask, col].to_numpy().ravel() ic, _ = spearmanr(f, y) ic_list[col].append(ic) return {f'factor_{i}': {'IC': np.mean(v), 'ICIR': np.mean(v) / (np.std(v) + 1e-8)} for i, v in ic_list.items()} rank_ic_stats(panel, factor_val, 'future_ret', 'date')

逻辑说明:n_components=5表示要生成5个候选因子;fit之后transform返回的列数与之一致。这里特意把切分放在进化之前,验证集的IC才是真实水平。rank_ic_stats对每个交易日做一次截面秩相关,再平均,得到常见的IC和ICIR两个指标。

参数说明:tournament_size控制锦标赛选择的参赛个体数量,太大选择压力过强,种群容易过早收敛;20是起步值。max_samples=10000控制每次评估随机抽取的样本数,应对超大面板时能大幅降内存,代价是评估有一定噪声。随机抽样的种子由random_state决定,复用同一个random_state才会让max_samples的抽样结果稳定。

3.3 核心参数推荐区间与调参顺序

把常用参数整理成一张表,后面改参数时就照着这张表来。

参数起始值推荐范围什么时候动它
population_size30001000~8000因子太杂或IC不稳时加大;训练太慢时减小
generations53~20验证IC还在涨就增大;训练集IC已经远高于验证集就减小
function_setadd/sub/mul/div/abs/log/sqrt/max/min不加sin/cos公式里全是除和log时删掉除,避免nan
parsimony_coefficient0.010.001~0.1公式超过10层且验证IC下滑时调大
tournament_size2010~50早熟收敛时调大,多样性太差时调小
max_samples100005000~全量内存爆时减小,样本量不大就设1.0
n_components51~10要多组备选因子时加大,但相关性检查成本也会加
metricspearmanspearman/mse想直接用回归误差评估时改mse

我的调参顺序是固定的:先跑一组小规模population_size=500、generations=2,确认数据没有泄漏、程序不崩;然后用上述参数跑主搜;最后只动parsimony_coefficient和generations,这两项对过拟合影响最直接。function_set原则上一次定好,中途改等于重跑。

有一点值得专门提醒:gplearn的进化随机性很强。同一份X和y,只改random_state,得到的因子完全可能不同。所以我不会只跑一次就拍板,而是跑5到10个random_state,把每个状态下的验证集IC稳定在0.03以上才进入下一步。这个习惯帮我挡住了不少纯靠运气冒出来的“假因子”。

3.4 从遗传算法python代码迁移到gplearn的三个陷阱

如果你以前写过遗传算法python代码,第一次用gplearn会顺手,但有三处习惯要改。第一,遗传算法里种群初始化通常是随机生成,而gplearn的初始种群也会从function_set里随机拼树,这导致每次结果完全不同,必须用random_state固定;第二,遗传算法里适应度函数常常是越小越好,gplearn的个体适应度是metric值,spearman是越大越好,mse是越小越好,同一个符号在不同metric下含义不同;第三,遗传算法里你可以随时打印当代最优解,gplearn的verbose只能看适应度,想看公式要等训练完再遍历_programs,中期监控能力有限。

这三点不是bug,是库的设计取舍。记住:gplearn不是给你做在线学习的,它适合离线批量挖掘,然后把稳定公式固化下来。别拿它去做日频滚动训练,一天跑一次几十代进化,算力成本不划算,也容易在盘中资源被拉爆。

4. 把gplearn公式落地成可用因子:清洗、去极值与中性化四步

4.1 导出表达式而不是直接pickle模型

gplearn训练完的SymbolicTransformer可以被pickle保存,但随着种群增大,模型文件里会带上每一代所有个体,文件可能上百MB,加载也慢。更稳妥的做法是把最终选出的表达式文本导出,在独立的py模块里重写一遍。

如果是SymbolicTransformer,最终表达式存在gp._programs[-1]里,每个元素是一棵树。可以通过遍历打印出来:

for i, prog in enumerate(gp._programs[-1]): print(i, str(prog))

我一般会把这些字符串逐条过目,看到超过15层的复杂公式直接放弃。表达式文本没有太多可读性,但至少让你知道它是怎么由X0、X1拼出来的。还有一个筛选技巧:看变量使用次数。如果某个输出树里X2从头到尾只用了一次,而X0反复出现,这个因子基本就是一个藏在X2外衣下的X0变形,进入因子池后会和已有因子高度共线,意义不大。

表达式要入库时,我会把它改写成SQL或极简Python函数。注意不要在线上环境每行都跑一遍解析器,而是把表达式转成固定几个函数,用numpy向量化计算。否则一个gplearn因子可以在回测里跑,上实盘却因为逐行循环慢得没法用。

4.2 用numpy重写公式,处理除零和log负数

gplearn里有些函数自带保护,但自写函数时还是会踩坑。假设你得到公式div(X0, X1),当X1恰好等于0,结果就是inf或nan;log(X0)遇到X0<=0也一样。这类异常值进入IC统计会直接污染整个截面。

我重写公式时固定加一层保护:

def safe_div(a, b, fill=0.0): with np.errstate(divide='ignore', invalid='ignore'): res = np.where(np.abs(b) > 1e-8, a / np.where(b == 0, np.nan, b), np.nan) return np.where(np.isnan(res), fill, res) def safe_log(a, fill=0.0): with np.errstate(divide='ignore', invalid='ignore'): res = np.where(a > 0, np.log(np.where(a <= 0, np.nan, a)), np.nan) return np.where(np.isnan(res), fill, res)

逻辑说明:safe_div先去掉分母为0的位置,再把无意义值统一填充成0;safe_log只对正数取对数。这样写出来的因子在任何极端行情下都不会出现inf。参数fill可以根据因子分布定,一般填0或者该列中位数。

这一步很多人偷懒跳过,结果就是因子在个别股票上出现几千倍的离群值,后续去极值也救不回来。记住:先清理inf/nan,再做截面统计。

4.3 覆盖缺失值并做数据对齐

转换后的因子在停牌、新股、极端行情下可能整行缺失。这些缺失行不能直接删,否则不同因子的样本量不一致,IC比较就没有可比性。常见做法是先按date和stock做外连接对齐,然后用截面中位数填充缺失。

此外,交易数据天然有位置敏感问题:一个股票在t日的因子值必须对应t+1到t+5的收益,不能因为groupby排序搞错offset。我在构造y时就固定用shift(-5)后的收益,并在最终合并时保留date字段做二次校验。校验办法很简单:随机抽十个日期,检查因子与future_ret是否错位一周。错位是最隐蔽的坑,复盘时比过拟合还难发现。

缺失值处理还有一个量化里特有的点:涨跌停日股票流动性变差,因子值往往极端。如果你把涨跌停样本的缺失值用全截面中位数填充,相当于淡化极端事件,反而会让因子在回测里显得更平滑、更“干净”。我的做法是保留涨跌停标记作为单独特征,而不是偷偷填充掉。gplearn能自己对X组合搜索,给它一个is_limit的0/1变量,它会自己决定要不要把涨跌停条件用进公式。

4.4 去极值、标准化与中性化:让因子可比较

新因子在进入候选池之前,我会统一做三步处理。第一步是MAD去极值,比用百分比截尾更抗极端值;第二步是截面zscore标准化;第三步是按市值和行业做中性化,去掉风格暴露。

def mad_winsorize(s, n=5): med = s.median() mad = (s - med).abs().median() scale = 1.4826 * mad return s.clip(med - n * scale, med + n * scale) def zscore_by_date(series): return (series - series.mean()) / (series.std() + 1e-8) # 用最小二乘取残差,完成市值与行业中性化 def neutralize(series, exposure_df): X = np.column_stack([np.ones(len(series)), exposure_df]) beta, _, _, _ = np.linalg.lstsq(X, series, rcond=None) return series - X @ beta

mad_winsorize的n是极值倍数,5倍MAD是常见起点;对干净一点的量价因子可以降到3。zscore_by_date要求同一日期内标准化,避免不同市场环境下的波动差异压过因子本身。neutralize的exposure_df里,行业列要one-hot展开,市值列取对数。取残差后,因子与行业、市值正交,后续IC才更纯粹。

中性化这步容易被忽视,尤其做指数增强的人。如果你辛辛苦苦进化出一个因子,结果它只是“小市值+高波动”的加壳表达式,放进模型后会加大风格暴露。gplearn根本不知道“风格”是什么,它只认y和X的相关性,所以你必须用中性化把风格效应先剥离掉,再决定是否保留残差因子。

做完这四步,gplearn输出的一堆杂项才算变成一个能被检验的标准因子。直接把原始transform结果扔进回测框架是最常见的“看起来跑通了、最后不敢用”的根源。

4.5 四步完成后的自检清单

最后再补一个自检习惯,四步做完不要急着看IC,先跑一个统计摘要:

new_factor = pd.DataFrame(new_factor, columns=[f'gp_{i}' for i in range(new_factor.shape[1])]) for col in new_factor.columns: print(col, 'nan占比:', np.isnan(new_factor[col]).mean(), 'inf占比:', np.isinf(new_factor[col]).mean(), '去极值后std:', mad_winsorize(new_factor[col]).std())

这里关注两件事。一是nan和inf占比,任何一个超过1%就说明第4.2步的保护函数没写全;二是去极值后std,如果接近0,说明这个因子在大多数样本上是常数,比如div(X0, X1)里X0远小于X1,结果几乎全为0。这类因子即使IC高,也是极少数极端样本贡献的,进不了实盘。

5. gplearn因子挖掘常见坑排查:从过拟合到公式爆炸

5.1 训练集IC高、验证集IC转负

现象:进化过程里训练IC一路涨到0.08,模型还在verbose里显示找到了更优个体;换到验证集,IC直接变成-0.02,分组收益也倒挂。

原因:gplearn没有正则化样本外验证。它的适应度函数就是训练集上的Spearman相关,训练集里只要存在几根异常K线或涨跌停板造成的极端样本,树就往那些样本上拟合。代数越高,过拟合越严重。我见过最夸张的一次,训练IC到了0.12,验证IC是-0.08,原因就是训练集里包含一个连续三天的板块性涨停,树学会了“追涨停后一天还有溢价”的假规律。

解决:先把generations降回3~5,把parsimony_coefficient从0.01上调到0.05,强制公式变短;再检查训练样本里是否混入了全天一字板的极端行情,剔除涨跌停样本重跑。最关键的还是按时间切分,把验证集完全隔离出进化过程,只用作事后评估。还有一个小技巧:把训练集末尾5%单独留出来做“夹层验证”,如果进化过程在夹层上适应度大幅下降,说明种群已经开始记住训练集的个体噪声,这时候应该立即停止增加代数。

5.2 生成的公式里全是inf和nan

现象:transform之后,有一列超过30%的样本值是nan或inf;打印表达式看到div(X0, X1)、log(X0)的组合。

原因:function_set里放了log、div,但没有对这些运算符做保护。gplearn的进化只管公式的“结构新颖度”,不管运行时的数值合法性。当X0接近0或X1等于0,整棵树的结果就被污染。另外,浮点溢出也会出现,比如mul(mul(mul(X0,X0),X0),X0)在X0量级是1e4时直接变成1e16。

解决:在function_set里避免直接使用裸log和div,改用safe_div、safe_log包一层再传参;或者在重写公式时统一用第4.2节的两个函数。跑完fit后,第一时间执行np.isinf(new_factor).sum()和np.isnan(new_factor).sum(),只要任一列超过1%,这组因子直接作废。如果你想保留原始gplearn表达式用于观察,可以只改transform之后的后处理,不修改function_set,但这样每跑一遍都要做一轮清洗,工作量翻倍,不值得。

5.3 多进程并行把内存打爆

现象:population_size设到5000,generations设到10,n_jobs=4,训练到一半进程卡死或者被OOM杀掉;换成n_jobs=1又慢得离谱。

原因:gplearn的并行是把种群拆成多份交给joblib,每个worker都要持有当前代所有个体的表达式和评估矩阵。代数多了,Program对象的引用也被保留在_programs里,内存是随pop_size乘generations增长,不是线性增长。我之前在一个64G内存的服务器上跑pop_size=8000、generations=20,跑到第11代直接OOM,连jupyter内核都崩了。

解决:先设n_jobs=1跑一小时代数,确认单worker内存占用;再把n_jobs提高到2或4,同时把max_samples设成5000或10000,限制每次评估的样本量。_programs里历史代的个体如果不复现,可以训练完立即只保留_programs[-1],其余置空再存模型。进阶做法是分阶段搜:先跑一个pop_size=3000扛过5代,把表现最好的几百棵树作为下一阶段初始种群,再代际更迭。这样可以避免一次性开大种群,内存压力小得多。

5.4 生成的多个因子之间相关性过高

现象:n_components设成5,得到的5列因子两两相关系数普遍在0.9以上,加起来不如只取一列;放到多因子模型里,有效暴露高度重合。

原因:SymbolicTransformer的进化目标只针对适应度函数,不包含“让输出之间互不相关”的约束。于是种群很快收敛到少数几棵相似树,尤其是当某个结构在Spearman指标上特别突出时。比如X0是动量,X1是换手率,树里最常出现的结构可能是div(abs(X0), X1),一旦这个结构适应度领先,其他输出树也纷纷接近这个形态。

解决:跑完先算候选因子相关矩阵,保留每个相关性聚簇里IC最高的一个;或者把n_components降到1~2,跑多个random_state,再合并筛选。如果一定要一次生成多个低相关因子,需要自己在外部做正交化,gplearn本身控不了。我现在的做法是分两轮:第一轮生成20个单因子,第二轮用层次聚类合并相关性低于0.5的因子,然后只保留每簇的IC最高者。这样得到的因子池,多样性比单次n_components=20好很多。

5.5 因子与未来收益错位:最隐蔽的泄漏

现象:训练集和验证集IC都稳定在0.03以上,可到了实盘前模拟盘上换手高得离谱,交易成本把收益全部吃掉;查看因子明细,发现因子在第t日使用的特征,其实是t日收盘包含未来信息。

原因:构造X时rolling窗口没有shift,比如用当日收盘价除以5日均线,如果均线窗口含当日和之前4天,没问题;但如果你用未来5日收益率当特征,或者用包含次日停牌信息的复权因子,泄漏就藏进去了。gplearn的适应度函数不会察觉这是泄漏,它只会照着这种“提前知道答案”的特征造出一个高IC假象。

解决:所有基础特征统一用groupby('stock').shift(1)后再去做滚动计算,y用future_ret,两个时间戳严格错开。构造完成后随机抽几行人工核对数据,再用当天因子值预测昨天收益做反向测试,如果IC还高,肯定有泄漏。反向测试的做法是:把y替换成过去5日收益,如果gplearn在这样荒谬的y上还能训练出高IC,说明X里含有“未来信息通过某种变换漏进来”的路径,需要逐列检查特征。

5.6 训练时间过长,一晚上都跑不完

现象:population_size=2000,generations=10,数据集是3000只股票×1000天,跑了两个小时还在第3代。

原因:gplearn的时间瓶颈主要在评估所有树的适应度,而不是遗传算子。每次评估都要对X做一次完整的前向计算,如果一个样本有50个特征,population有2000棵树,一次迭代就是2000次50维运算。max_samples增加也会放大时间成本。

解决:优先砍max_samples到5000,把n_jobs提到与物理核心数相同;关掉verbose可以少一点IO开销;如果还是慢,换小一点的function_set。function_set里少一个log或sqrt,树的组合空间会明显缩小,搜索也快得多。最后,别在每次调参时都用全量历史,先拿最近200天数据探路,确定参数后再拉长样本只跑一次正式版。

6. 上线前的最后一步:滚动重训与单因子验证

gplearn挖出的因子衰减得比手工因子快,因为它的搜索过程容易被市场风格带着走。我现在的习惯是每两周做一次滚动重训:取最近800天行情,训练gplearn;把历史已上线因子的表达式作为基准,新因子先与已有因子做正交化,再评估增量IC。经过至少两次滚动窗口都能稳定贡献增量,才考虑纳入实盘模型。

一个单因子能否上线,我只看三个数字:IC均值、ICIR、分层单调性。IC均值低于0.02的因子直接丢弃;ICIR低于0.3说明稳定性不足;分层回测时,因子按大小分成五组,收益必须单调,中间两组可以平,但不能出现第一组和第五组收益同向。

如果手头没有现成的回测框架,单因子验证可以用这段简化的滚动IC代码跑通:

def check_factor(panel, factor_col, ret_col, date_col): ic_series = [] for dt, idx in panel.groupby(date_col).groups.items(): sub = panel.loc[idx] ic, _ = spearmanr(sub[factor_col], sub[ret_col]) ic_series.append(ic) ic_series = pd.Series(ic_series) return ic_series.mean(), ic_series.std() / (ic_series.mean() + 1e-8)

输出IC和IR,再配合月度分组收益,就能快速淘汰大多数gplearn因子。算完这几个数,我还会把因子表达式打印出来,看一眼是否只是把现有因子做了简单非线性变换。如果只是X0 / X1这类结构,而X0、X1本身已经在因子库里,那它不配占用一个因子额度。

做gplearn因子挖掘这一年多,我最大的教训是:进化算法产出的不是策略,只是一批需要被严格检验的候选对象。把验证流程做扎实,比调高population_size更值钱。希望帮到你。

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

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

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

立即咨询