☰
遗传规划生成阿尔法因子:Python实战与抗衰减指南
2026/10/12 20:04:09 网站建设 项目流程

简介:这份资源面向量化投资研究者与Python开发者,提供一套基于遗传编程的阿尔法因子生成工具,用于解决传统因子逐渐失效、需要自动挖掘新因子的痛点。其核心思路是符号回归:先随机生成一批简单公式,刻画自变量与证券收益之间的关系,再通过进化筛选出与回报显著相关的表达式,支持时间序列二维数据框输入,第一维为股票代码、第二维为时间,与原始遗传规划算法有明显区别。资源包共8个文件,以7个py源码和1个md说明文档为主,压缩后约28KB,涵盖遗传算法主流程、适应度计算、函数库与工具模块,并附带demo.py测试入口,便于快速跑通示例。目前已有700人学习下载,适合具备一定Python与因子投资基础、希望把遗传规划落地到多因子策略中的读者参考,可据此理解符号回归选因子的完整实现路径与代码组织方式。

1. 遗传规划生成阿尔法因子:为什么它比手工挖因子更抗衰减

做过多因子策略的人大概都有过这种体验:辛辛苦苦手工挖出来的因子,回测夏普 2.5,实盘跑三个月就衰减到 0.8。这不是玄学,而是因子拥挤和过拟合的必然结果。遗传规划算法生成阿尔法因子,本质上是把「人工构造因子表达式」这件事交给进化算法自动完成——你定义好算子集合、数据字段和适应度函数,算法自己搜索出成千上万个候选表达式,再通过交叉、变异、选择把适应度高的因子留下来。它解决的核心问题是:因子挖掘的搜索空间太大,人工只能覆盖极小一部分,而遗传规划可以在符号回归的框架下系统性地遍历表达式树空间。

这套方案适合谁?适合已经有一套多因子回测框架、手里有干净的价量或基本面面板数据、想批量产出候选因子再筛选的量化从业者。Python 生态里gplearn、deap这类库能让你在几百行代码内跑通一个最小闭环。接下来我会从表达式树的编码方式讲起,一路落到适应度函数怎么设、算子怎么选、过拟合怎么防,最后给一套能直接跑的代码骨架。

2. 遗传规划挖因子的底层机制:表达式树、算子集与适应度

2.1 因子表达式为什么天然适合用树结构编码

一个阿尔法因子,比如(close - ts_mean(close, 20)) / ts_std(close, 20),本质上是一棵表达式树:叶子节点是数据字段(close、volume、turnover 等)和常数,内部节点是算子(减法、除法、ts_mean、ts_std)。遗传规划用树来编码个体,每个个体就是一棵完整的因子表达式树。交叉操作交换两棵树的子树,变异操作替换某个子树或节点,这样搜索空间就是所有合法表达式构成的集合。

这种编码方式的好处是:生成的因子天然具有可解释性。你拿到一个适应度高的表达式,能直接看出它在算什么——是动量、反转还是波动率。相比之下,神经网络挖出来的因子是黑匣子,你很难说清它到底在捕捉什么。对于多因子策略来说,可解释性直接关系到你能不能判断这个因子在未来会不会失效。

树编码还有一个隐性优势:它天然处理量纲问题。表达式里同时出现价格和成交量时,算子会自动做标准化或比值运算,不需要你手动做 z-score。当然这也带来一个坑——如果算子集里没有保护机制,除法可能产生无穷大,对数可能对负数取对数,这些都会让适应度计算崩掉。

2.2 算子集怎么选:从基础算术到时序算子的取舍

算子集决定了遗传规划能搜索到的因子形态。我一般把算子分成四类:

类别常用算子说明
基础算术add, sub, mul, divdiv 必须做保护,分母加极小值
一元变换log, abs, sqrt, neglog 和 sqrt 要处理非正输入
时序算子ts_mean, ts_std, ts_max, ts_min, delta, delay窗口参数建议固定为 5/10/20/60
横截面算子rank, zscore, scale按日截面计算,避免未来函数

算子集不是越大越好。我试过把算子加到 30 个以上,结果搜索空间爆炸,种群收敛极慢,而且生成的因子大量是等价变形。常见做法是控制在 15 到 20 个算子,时序窗口用固定几个值而不是让算法自己搜——窗口参数如果也放进搜索空间,过拟合风险会急剧上升。

提示:时序算子的窗口参数建议写死在算子内部,比如ts_mean_20作为一个独立算子,而不是ts_mean(x, n)让 n 参与进化。这样能大幅降低搜索维度。

2.3 适应度函数:IC、ICIR 还是分层回测收益

适应度函数是遗传规划的方向盘。最常用的是 IC(信息系数),即因子值与未来收益的截面相关系数。但单纯用 IC 有个问题:它只衡量线性相关性,而且对极端值敏感。我一般用 ICIR,即 IC 的均值除以 IC 的标准差,这样同时惩罚了不稳定因子。

具体计算方式:每个调仓日计算因子与下期收益的 Spearman 秩相关,得到 IC 序列,然后取mean(IC) / std(IC)作为适应度。调仓周期要和策略实际调仓频率一致——做周频调仓就用 5 日收益,做月频就用 20 日收益。这里有个血泪经验:如果用日频 IC 但策略是周频调仓,选出来的因子在周频上可能完全无效。

适应度函数里还要加惩罚项。表达式树深度超过阈值要罚,算子数量过多要罚,因子与已有因子相关性过高也要罚。我通常设树最大深度 6,节点数上限 30,与已有因子池的截面相关性超过 0.7 就扣分。

import numpy as np from scipy.stats import spearmanr def fitness_factor(factor_values, forward_returns, existing_factors=None): """ factor_values: shape (T, N) 因子面板 forward_returns: shape (T, N) 下期收益面板 existing_factors: list of (T, N) 已有因子,用于相关性惩罚 """ ic_list = [] for t in range(factor_values.shape[0]): f = factor_values[t] r = forward_returns[t] mask = np.isfinite(f) & np.isfinite(r) if mask.sum() < 30: continue ic, _ = spearmanr(f[mask], r[mask]) if np.isfinite(ic): ic_list.append(ic) if len(ic_list) < 20: return -999.0 ic_arr = np.array(ic_list) icir = ic_arr.mean() / (ic_arr.std() + 1e-8) # 相关性惩罚 penalty = 0.0 if existing_factors: for ef in existing_factors: corr_list = [] for t in range(factor_values.shape[0]): f = factor_values[t] e = ef[t] mask = np.isfinite(f) & np.isfinite(e) if mask.sum() < 30: continue c, _ = spearmanr(f[mask], e[mask]) if np.isfinite(c): corr_list.append(abs(c)) if corr_list and np.mean(corr_list) > 0.7: penalty += 0.5 return icir - penalty

这段代码的核心逻辑是:先逐日算 Spearman IC,过滤掉有效样本不足 30 的交易日,然后算 ICIR。相关性惩罚部分遍历已有因子,如果平均截面相关性超过 0.7 就扣 0.5 分。参数上,30 是最小样本阈值,20 是最少有效天数,0.7 是相关性阈值,这些都可以根据你的股票池大小调整。股票池只有 100 只时,30 的阈值可以降到 20;股票池 3000 只时,可以提到 50。

3. 用 Python 跑通遗传规划因子挖掘的最小闭环

3.1 数据准备:面板数据的对齐与去极值

遗传规划对数据质量极其敏感。输入数据如果有缺失值、停牌未处理、涨跌停未剔除,生成的因子会大量失真。我一般按这个流程准备数据:

第一步,拉取后复权价量数据,构建close、open、high、low、volume、amount、turnover等基础字段的面板。第二步,剔除停牌日和上市不足 60 日的股票。第三步,对每个截面做 MAD 去极值,中位数加减 5 倍 MAD 之外的值截断。第四步,检查未来函数——所有时序算子只能用 t 日及之前的数据,横截面算子按日独立计算。

import pandas as pd import numpy as np def prepare_panel(raw_df): """ raw_df: MultiIndex (date, stock) 的 DataFrame,含 close/volume 等列 返回对齐后的面板字典 """ df = raw_df.copy() # 剔除停牌 df = df[df['volume'] > 0] # 剔除上市不足 60 日 df['listed_days'] = df.groupby('stock').cumcount() df = df[df['listed_days'] >= 60] # MAD 去极值 def mad_clip(x): med = x.median() mad = (x - med).abs().median() if mad == 0: return x return x.clip(med - 5 * mad, med + 5 * mad) for col in ['close', 'volume', 'amount', 'turnover']: df[col] = df.groupby('date')[col].transform(mad_clip) # 构建面板 panel = {} for col in ['close', 'volume', 'amount', 'turnover']: panel[col] = df.pivot(index='date', columns='stock', values=col) return panel

逻辑说明:先按成交量过滤停牌,再用 cumcount 算上市天数,然后对每个截面做 MAD 去极值。groupby('date').transform(mad_clip)保证去极值是按日截面做的,不会引入跨期信息。参数上,5 倍 MAD 是常用值,想更激进可以降到 3 倍,但太激进会损失尾部信息。上市天数 60 是经验值,次新股波动大,因子在次新股上表现往往不稳定。

3.2 基于 gplearn 自定义因子挖掘流程

gplearn原生是给符号回归用的,输入是二维数组,输出是标量。但因子挖掘需要输出面板,所以不能直接用SymbolicRegressor。我的做法是继承gplearn的遗传规划框架,重写适应度计算和个体评估部分。核心是自定义function_set和metric。

from gplearn.genetic import SymbolicRegressor from gplearn.functions import make_function import numpy as np # 自定义时序算子:20 日均值 def _ts_mean_20(x): """x: (T, N) 面板,返回 (T, N) 时序均值""" result = np.full_like(x, np.nan) for t in range(19, x.shape[0]): result[t] = np.nanmean(x[t-19:t+1], axis=0) return result ts_mean_20 = make_function(function=_ts_mean_20, name='ts_mean_20', arity=1) # 自定义保护除法 def _protected_div(x1, x2): return np.where(np.abs(x2) > 1e-6, x1 / x2, 0.0) protected_div = make_function(function=_protected_div, name='div', arity=2) function_set = ['add', 'sub', 'mul', protected_div, 'log', 'abs', 'neg', ts_mean_20]

这里的关键点是:gplearn的make_function要求函数接受一维数组,但因子面板是二维的。实际使用时,我会把面板展平成一维,在函数内部 reshape 回二维做时序运算,再展平返回。这样能复用gplearn的进化框架,只需要重写适应度评估。

注意:gplearn默认的适应度是 MSE,不适合因子挖掘。必须自定义metric参数,传入一个接受(y_true, y_pred)返回标量的函数。但因子挖掘的适应度需要面板信息,所以更彻底的做法是重写SymbolicRegressor的_programs评估部分。

3.3 种群、代数与交叉变异率的参数配置

遗传规划的参数直接决定搜索效率和结果质量。我一般用这套配置起步:

参数推荐值说明
population_size500-1000太小搜索不充分,太大计算慢
generations20-50看适应度曲线是否收敛
tournament_size20锦标赛选择,越大选择压力越大
p_crossover0.7交叉概率
p_subtree_mutation0.1子树变异
p_hoist_mutation0.05提升变异
p_point_mutation0.1点变异
max_samples0.9每代用 90% 样本评估
parsimony_coefficient0.001复杂度惩罚

种群 500 起步,如果 20 代后适应度还在涨,加到 1000。代数不是越多越好,超过 50 代后大概率在过拟合。锦标赛大小 20 是中等选择压力,想更快收敛可以加到 30,但多样性会下降。parsimony_coefficient控制树复杂度惩罚,0.001 是温和惩罚,如果生成的因子树深度普遍超过 8,可以加到 0.005。

from gplearn.genetic import SymbolicRegressor est = SymbolicRegressor( population_size=800, generations=30, tournament_size=20, p_crossover=0.7, p_subtree_mutation=0.1, p_hoist_mutation=0.05, p_point_mutation=0.1, max_samples=0.9, parsimony_coefficient=0.001, function_set=function_set, metric=my_icir_metric, verbose=1, random_state=42, n_jobs=-1 )

n_jobs=-1开满 CPU 核数,遗传规划是计算密集型,并行能省大量时间。random_state固定后结果可复现,但建议跑 3 到 5 个不同随机种子,取适应度最高的因子池,避免单次运行的偶然性。

4. 避坑与排查:遗传规划挖因子最容易翻车的五个地方

4.1 因子在样本内 IC 很高但样本外直接归零

现象:训练集 ICIR 达到 1.5,测试集 ICIR 只有 0.1,因子完全失效。

原因:过拟合。遗传规划在搜索空间里反复试错,很容易找到在训练集上恰好表现好的表达式,但这些表达式捕捉的是噪声而非规律。种群越大、代数越多,过拟合越严重。

解决:第一,严格划分训练集和测试集,训练集用 2018 到 2021 年,测试集用 2022 到 2023 年,中间留 1 个月缓冲期避免信息泄露。第二,适应度函数里加复杂度惩罚,parsimony_coefficient调到 0.003 以上。第三,用滚动窗口验证——每半年重新训练一次,看因子在下一个半年的表现,而不是只看一次样本外。第四,限制树深度不超过 6,节点数不超过 30。

4.2 生成的因子全是等价变形,多样性极低

现象:跑完 30 代,种群里的因子表达式大量重复,比如close / close和close * (1/close)同时存在。

原因:算子集里存在冗余算子,或者交叉变异率设置不当导致种群早熟收敛。另外,如果数据字段太少(比如只有 close 和 volume),搜索空间本身就小,容易撞车。

解决:第一,算子集里去掉冗余项,比如有了div就不需要inv。第二,提高变异率,p_subtree_mutation从 0.1 提到 0.15,p_point_mutation从 0.1 提到 0.15。第三,增加数据字段,除了价量,加入换手率、市值、行业哑变量等。第四,用 niching 策略——如果新个体与已有因子相关性超过 0.9,直接丢弃。

4.3 时序算子引入未来函数,回测虚高

现象:因子回测夏普 3.0,实盘完全跑不出这个收益。

原因:时序算子在实现时用了未来数据。比如ts_mean(x, 20)如果写成x.rolling(20).mean().shift(-19),就把未来 19 天的数据算进来了。或者横截面算子按日计算时,用了当日收盘后才能得到的数据。

解决:第一,所有时序算子统一用rolling(window).mean()的默认行为,即包含当前值、向前看 window-1 期。第二,在因子计算完成后,做一次「未来函数检测」——把因子值整体 shift 1 期,如果回测收益大幅下降,说明有未来函数。第三,横截面算子严格按日 groupby 计算,不跨日。

4.4 适应度函数用 Pearson IC 导致因子偏向极端值

现象:生成的因子在少数股票上有极大值,IC 看起来不错,但分层回测单调性很差。

原因:Pearson IC 对极端值敏感,因子里的异常值会主导相关系数。遗传规划会利用这一点,生成大量有极端值的因子来「骗」适应度。

解决:改用 Spearman 秩相关,它对单调变换不变,对极端值稳健。另外,在适应度计算前对因子做截面 rank 或 z-score 标准化,进一步降低极端值影响。如果因子仍然出现极端值,检查数据去极值是否到位。

4.5 计算资源爆炸,跑一次要十几个小时

现象:种群 1000、代数 50,跑一次超过 12 小时,迭代效率极低。

原因:适应度评估是逐个体逐日算 IC,Python 循环太慢。另外,如果每次评估都重新计算时序算子,重复计算量巨大。

解决:第一,用 numpy 向量化替代循环,IC 计算用矩阵运算。第二,缓存中间结果——相同子表达式在不同个体中重复出现时,直接复用。第三,用joblib并行评估个体,n_jobs=-1开满核。第四,先用小种群(200)跑 10 代看趋势,确认方向对了再放大。第五,把面板数据转成 numpy 数组而不是 pandas DataFrame,pandas 的索引开销在循环里很致命。

5. 因子池去相关与滚动重训:让遗传规划产出的因子真正能上实盘

遗传规划跑完一轮,你手里会有几百个候选因子。直接全塞进多因子模型是灾难——因子之间相关性极高,回归系数不稳定。我一般做三步处理。

第一步,按适应度排序,取前 50 个因子。第二步,计算这 50 个因子的截面相关性矩阵,用层次聚类做去相关。具体做法:把相关性矩阵转成距离矩阵1 - |corr|,用scipy.cluster.hierarchy.linkage做聚类,在每个聚类簇里只保留适应度最高的那个因子。这样通常能把 50 个压缩到 15 到 20 个。第三步,把这 15 到 20 个因子和已有因子池合并,再做一次去相关,最终保留 10 到 15 个新因子。

from scipy.cluster.hierarchy import linkage, fcluster from scipy.spatial.distance import squareform import numpy as np def deduplicate_factors(factor_dict, fitness_dict, corr_threshold=0.7): """ factor_dict: {name: (T, N) 因子面板} fitness_dict: {name: fitness} 返回去相关后的因子名列表 """ names = list(factor_dict.keys()) n = len(names) # 计算平均截面相关性 corr_mat = np.eye(n) for i in range(n): for j in range(i+1, n): fi = factor_dict[names[i]] fj = factor_dict[names[j]] corrs = [] for t in range(fi.shape[0]): a, b = fi[t], fj[t] mask = np.isfinite(a) & np.isfinite(b) if mask.sum() < 30: continue c = np.corrcoef(a[mask], b[mask])[0, 1] if np.isfinite(c): corrs.append(abs(c)) corr_mat[i, j] = corr_mat[j, i] = np.mean(corrs) if corrs else 0 # 层次聚类 dist = 1 - corr_mat np.fill_diagonal(dist, 0) condensed = squareform(dist, checks=False) Z = linkage(condensed, method='average') clusters = fcluster(Z, t=1-corr_threshold, criterion='distance') # 每个簇保留适应度最高的 selected = [] for c in np.unique(clusters): idx = np.where(clusters == c)[0] best = max(idx, key=lambda i: fitness_dict[names[i]]) selected.append(names[best]) return selected

这段代码的逻辑是:先算因子两两之间的平均截面相关性,构建相关性矩阵,然后转成距离矩阵做层次聚类。corr_threshold=0.7意味着相关性超过 0.7 的因子会被聚到同一簇,每簇只留适应度最高的。参数上,0.7 是常用阈值,想更激进可以去到 0.5,但会损失更多因子。聚类方法用average比single更稳健,不容易形成长链。

滚动重训是另一个关键。遗传规划挖出的因子会随着市场结构变化而衰减,我一般每半年重新跑一次遗传规划,用最近 3 年数据做训练,最近 1 年做验证。重训时把上一轮保留的因子作为existing_factors传入适应度函数,这样新挖的因子会自动避开已有因子,保持因子池的多样性。

最后一个技巧:遗传规划生成的因子表达式,不要直接扔进实盘。先做因子暴露分析——看它和市值、行业、动量等常见风险因子的相关性。如果某个新因子和市值因子相关性 0.8,那它本质上就是个市值因子,没有增量信息。我一般要求新因子与所有已有风险因子的截面相关性绝对值不超过 0.5,才纳入因子池。

这套流程我跑了两年多,最大的教训是:遗传规划不是「设好参数等结果」的黑匣子,而是一个需要反复调适应度函数、反复做去相关、反复滚动验证的迭代系统。每次跑完不要只看适应度最高的那几个因子,要把整个种群导出来,按表达式结构分类,看看算法到底在搜索什么方向。有时候适应度排 50 名的因子,因为逻辑干净、和现有因子正交,反而在实盘上表现更稳。希望帮到你。

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

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

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

立即咨询