遗传规划自动挖因子:从公式进化到可解释Alpha策略
简介基于遗传编程的阿尔法因子生成算法Python代码包面向量化投资研究人员、因子策略开发者和金融工程学习者。该工具可有效缓解传统阿尔法因子被广泛使用后逐渐失效的问题适用于多因子选股、行业轮动及个股收益预测等场景。其核心是通过符号回归自动搜索与证券收益显著相关的因子表达式以证券收益为因变量、用户自定义特征为自变量并采用股票代码与时间序列构成的二维数据框输入贴近实际投资逻辑。压缩包共8个文件7个Python脚本和1个Markdown说明文档涵盖遗传算法核心、函数构建、适应度评估、工具函数及演示模块整体仅28KB模块化清晰便于定位逻辑和二次扩展。配合演示脚本可跑通因子进化全流程也支持替换自有数据生成个性化阿尔法因子已有700人浏览学习适合从手工因子转向自动化挖掘的实践者参考和复现。1. 遗传规划挖因子把找因子从拍脑袋变成自动化搜索做过多因子策略的人都有体会估值、动量、反转这些显式因子早被研究透了上线没几个月 alpha 就衰减得不成样子。这个项目解决的正是这个问题——用遗传规划Genetic Programming自动搜索新的阿尔法因子。它不是让你手动挑因子而是构造一组随机公式当作初始种群用适应度函数告诉算法哪些公式和证券收益的关联更强再通过交叉与变异一代代进化最后输出一个可读的数学表达式。和传统遗传规划不同它直接消费时间序列数据自变量可以是你能想到的任何和收益相关的变量因变量就是证券收益。适合两类人想做因子补充的量化研究者以及想找一个能落地到 DataFrame 上的完整 Python 实现的开发者。解压之后按 demo 跑一遍很快能看到第一批自动生成的因子长什么样。2. 架构拆解从 genetic.py 到 fitness.py 的因子进化链路2.1 目录结构八个文件的职责分配解压后是一个标准的 Python 包结构文件不多但每个模块的边界划得很清楚。我第一次打开的时候大概花了十分钟就把整个流程读通了因为它的依赖方向是单向的genetic.py 调 fitness.py 和 _program.pydemo.py 调 genetic.pyutils.py 只负责数据。文件职责genetic.py进化算法主入口种群初始化、选择、交叉、变异的主循环_program.py公式树的数据结构定义个体一棵树怎么创建、怎么转成表达式字符串functions.py算子函数集包含加减乘除、对数、排名等基础运算以及保护性实现fitness.py适应度评估计算 IC、ICIR 等指标决定一个公式算不算好因子utils.py数据加载与预处理工具把原始面板数据整理成算法能吃的格式demo.py最小可运行示例把数据准备和进化流程串起来README.md使用说明init.py包初始化导出公开接口依赖关系很简洁没有外部数据库没有网络调用整个项目是一个纯内存运算的离线脚本集合。这也意味着你拿到手之后跑起来几乎不需要额外适配只要把数据喂对就行。2.2 主流程一次完整的因子进化整个算法的主循环是标准的遗传规划流程初始化种群、评估适应度、选择父代、交叉变异生成子代、精英保留然后重复。以我对这类实现的经验核心骨架基本长这样# 简化后的主循环骨架细节以解压后的源码为准 for gen in range(max_generations): scores fitness.evaluate(population, panel_df, target) # 给每个个体打分 elites keep_best(population, scores, n_elite2) # 精英保留 parents tournament_select(population, scores, k3) # 锦标赛选择 children [] for p1, p2 in zip(parents[::2], parents[1::2]): c1 crossover(p1, p2, max_depthmax_depth) # 子树交换 c1 mutate(c1, func_set, max_depthmax_depth) # 随机变异 children.append(c1) population elites children[: pop_size - len(elites)] best_program population[scores.argmax()]这里每一步都值得展开说。锦标赛选择是每轮随机抽 k 个个体、留下适应度最高的那个当父代k 越大选择压力越大但也越容易早熟crossover 交换两颗树的子树让新个体继承旧个体的有效结构而不是完全从零开始mutate 会随机替换树中的某个节点或者整棵子树保证种群的探索性。精英保留的意义在于防止当前最好的公式在交叉变异中被破坏我一般把 n_elite 控制在 2 到 4 个太少保不住最优解太多则种群多样性下降。参数层面的默认配置常见的取值范围大致是种群 200 到 500 个个体进化 20 到 50 代树深度限制在 4 到 6。这几个参数直接决定运行时间种群翻倍单代评估时间近似翻倍深度加深单棵树的运算量指数级增长。做因子挖掘并不是树越深越好金融数据里深度超过 7 的表达式基本就开始过拟合了出来的公式在样本外往往跑不动。2.3 数据契约为什么输入必须是二维面板这个项目最值得注意的设计是数据组织方式。它推翻了机器学习里习惯的 X, y 二维表要求第一维是股票代码、第二维是时间。换句话说自变量是一个面板数据行为股票代码列为交易日期import pandas as pd import numpy as np # 行股票代码列交易日期 panel pd.DataFrame( np.random.randn(500, 1200), index[fstock_{i} for i in range(500)], columnspd.date_range(2020-01-01, periods1200) ) # 下一期收益作为因变量注意 shift 必须沿列方向移动 future_return panel.shift(-1, axis1)为什么这样设计因为遗传规划生成的表达式会同时包含横截面操作比如 rank 排名和时间序列操作比如过去 20 日的动量均值。如果数据只是一列算法只能在单一股票上做时间维度的运算根本用不上市场的横截面信息而因子投资里最核心的正是横截面上的相对强弱——同一时刻哪些股票在目标特征上排名靠前。面板结构让公式树每个节点都能拿到全部股票的完整时间序列既能在时间上滚动又能在横截面上比较。这里和传统遗传规划的区别也体现在数据契约上传统符号回归的输入输出都是数值向量这里每个自变量本身就是一张面板因变量是下一期的收益面板。适应度函数在计算时会逐日做横截面排名再对时间序列求平均这个细节直接决定了后面 fitness.py 的写法。3. 核心实现走读公式树、遗传算子与时间序列适应度3.1 genetic.py种群初始化与进化主循环genetic.py 是算法的心脏。种群里的每个个体都是一棵公式树树的内节点是算子叶子节点是特征名或常量。初始化种群时常见的做法是 half-and-half一半用 full 方法生成所有叶子深度相同一半用 grow 方法生成各分支深度随机。这样初始种群既有结构规整的个体也有形态多样的个体避免一开始就陷入单一形状的搜索空间。# _program.py 中公式树节点的简化实现 class Node: def __init__(self, value, arity0, childrenNone): self.value value # 算子符号、特征名或常量 self.arity arity # 子节点数量叶子节点为 0 self.children children or [] def evaluate(self, panel): # 叶子节点直接取面板里对应特征列 if self.arity 0: return panel[self.value] # 算子节点先递归算出子树结果再套用算子 args [c.evaluate(panel) for c in self.children] return FUNC_TABLE[self.value](*args)这段代码的巧妙之处在于 evaluate 的输入输出都是面板每个子节点返回一张 (股票 × 时间) 的 DataFrame算子节点对这张表做向量化运算所以表达式无论嵌套多深最后得到的因子矩阵维度始终和收益面板对齐。这也是为什么整个项目只需要 numpy 和 pandas 就能跑的底层原因。交叉操作一般发生在树的中间节点上随机选出父代 A 的某个子树替换成父代 B 的某个子树。替换深度会被 max_depth 卡住超出就重新选交叉点。变异则有三种常见类型点变异把某个算子替换成另一个算子、子树变异随机生成一棵新子树替换旧子树、hoist 变异把子树里某个分支提升到更高的位置。一个合格实现的遗传算子不会只做一种因为金融数据里公式结构微小改变可能带来完全不同的因子表现。3.2 functions.py保护性算子和函数集设计金融数据的一个常见麻烦是除零和取对数价格、收益率、成交量这些字段经常出现零值或负值。原生除法和 log 会把整棵树的评估结果污染成 NaN适应度计算直接崩掉。所以 functions.py 里一定会有保护性算子这是我在读这种项目时会特别留意的细节。# functions.py 中常见的保护性实现 import numpy as np def safe_div(a, b): # 当分母绝对值过小时用 1 替代避免结果变成无穷或 NaN return np.where(np.abs(b) 1e-6, 1.0, a / b) def safe_log(x): # 对非正数做截断保持输出数值稳定 return np.where(x 1e-10, np.log(x), 0.0) def rank(x): # 横截面排名按列操作每列是一个交易日的截面数据 return x.rank(axis0)safe_div 的阈值 1e-6 不是随便定的太小了仍然会有数值溢出的风险太大会把正常的小分母全部扭曲掉。rank 算子在这里是把整张面板的每个时间截面分别排名输出的是排名分位数而不是原始数值这样能让公式对异常值和量纲不敏感——两个股票一个价格是 5 元一个是 500 元直接相减没有意义排名之后才有横截面可比性。函数集里一般还会放 ts_mean、ts_delta 这类带窗口的时间序列算子窗口长度通常由常量子代随机生成这也是项目能产出动量和反转类因子的原因。3.3 fitness.py用截面秩相关评估公式好坏适应度是因子挖掘的灵魂。源码里对单个公式的评估核心指标大概率是 ICInformation Coefficient和 ICIR。IC 衡量的是因子值和未来收益在横截面上的相关强度ICIR 则是 IC 的均值除以标准差代表因子有效性的稳定性。一个因子如果 IC 高但波动大实际使用体验往往不如 IC 平稳的因子。# fitness.py 中 rank IC 的常见计算方式 def rank_ic(factor_panel, target_panel): # 每个交易日对因子值和未来收益分别做横截面排名 factor_rank factor_panel.rank(axis0) target_rank target_panel.rank(axis0) # 逐日计算因子排名与收益排名的相关性 ic_series factor_rank.corrwith(target_rank, axis0) # 返回平均 IC 与 ICIR return ic_series.mean(), ic_series.mean() / ic_series.std()注意两个细节第一为什么用秩相关而不是皮尔逊相关。收益分布厚尾极端涨跌会拉高线性相关的假信号Spearman 秩相关只关心排序关系对极端值天然免疫。第二axis0 在这里是列方向操作对应的是每一列一个交易日的横截面排名整个 DataFrame 行是股票、列是日期所以按列排名就是在对每个时点上的全市场股票排序。这样算出来的 IC 序列长度为交易日数再对其求均值和标准差就得到一个既能衡量强度又能衡量稳定性的标量。这种适应度设计的取舍在于它只看横截面排序忽略了因子值本身的分布形态。一个公式即使整体偏移很大只要排序对IC 照样高。这既是优点也是风险后面避坑章节会提到这个问题的实际后果。4. 跑通 demo数据准备、运行命令与输出解读4.1 环境准备只需要 numpy 和 pandas跑这个项目的门槛很低。我建议先建一个干净的虚拟环境避免污染系统 Pythonpython -m venv .venv # Windows 下 .venv\Scripts\activate # macOS / Linux 下 source .venv/bin/activate pip install numpy pandas依赖只有这两个说明源码里的所有算子都是基于 DataFrame 向量化实现的没有用到 sklearn、torch 之类的重型库。装完之后直接运行python demo.py等它跑完就能看到输出。如果当前环境的 pandas 版本比较新rolling 相关算子接口有过调整遇到报错时先去看 pandas 的变更日志而不是急着改源码。4.2 demo.py 的调用方式与参数说明demo.py 做的事情是把数据准备和进化流程串起来。以解压后的实际代码为准调用形态大致是这样# demo.py 的典型调用方式 from genetic import GeneticAlpha from utils import load_panel # 读取面板数据要求行是股票代码、列是日期 panel load_panel(adj_close.csv) # 用收益率序列作为目标当日价格 / 前一日价格 - 1 target panel.pct_change().shift(-1, axis1) model GeneticAlpha( pop_size300, max_generations30, max_depth5, func_set[add, sub, mul, safe_div, rank, ts_mean], ) model.fit(panel, target) print(model.best_program())参数含义直接看名字就能猜个大概但实际调参有几个经验点。pop_size 决定每代能探索多少公式太小容易早熟太大跑得慢max_generations 决定总进化轮数一般看适应度曲线是否在最后几代还在上升max_depth 是公式树的深度上限控制表达式复杂度。func_set 里算子越多搜索空间越大但也会引入更多无意义的组合我一般会从基础四则运算加 rank、ts_mean 起步跑通之后再逐步加算子。另一个重要参数是目标收益的计算。代码里用pct_change()算的是当日相对前一日的收益率shift(-1, axis1)让它变成下一期收益。这里有个关键约束公式中用到的所有特征必须只依赖 T 时刻及之前的数据而目标必须是 T 时刻之后的收益否则就是未来函数。demo 数据里特征列是复权后的价格天然满足这个约束但你自己接数据时一定要确认。4.3 输出解读最优公式、适应度曲线与因子矩阵运行结束后输出一般包含三块最优公式的表达式、每代的适应度记录、以及最优因子在样本数据上的因子矩阵。最优公式长什么样以我的经验输出类似best program: rank(ts_mean(adj_close, 20)) / rank(adj_close) train IC: 0.038, ICIR: 0.46第一行是遗传规划抠出来的表达式语义上可以解读成过去 20 日均价与当前价格的比值在全市场的排名这本质上是一个带排名的动量因子。第二行是它在训练数据上的表现IC 在 0.03 到 0.05 之间是量化因子里的常见水平ICIR 大于 0.3 说明有效性还算稳定。如果 IC 超过 0.08先别高兴大概率是数据泄漏或者过拟合后面避坑章节会展开。适应度曲线看的是收敛情况如果前几代 IC 快速上升后面趋于平稳说明搜索是有效的如果曲线一直在低位抖动考虑加大种群或者增加算子。因子矩阵是最终产物shape 应该是 (股票数, 日期数)可以直接存成 parquet 或 csv 进因子库后续做分层回测或者相关性分析。5. 避坑指南因子搜索里的翻车现场和排查手段5.1 数据维度搞反后面全是白算现象demo 换成自己的数据后报错提示行列对不齐或者训练出来的因子矩阵转置之后才和收益对齐。原因把面板数据传成了行是日期、列是股票代码。这个项目的数据契约是行股票代码、列日期和很多量化平台导出的数据方向正好相反。解决加载数据后先打印data.shape和data.index[:2]、data.columns[:2]确认第一维是股票代码再往下走。另一个更隐蔽的情况是收益率计算用的shift方向写反了导致目标里混进了过去收益IC 看起来正常但逻辑完全错误。5.2 进化过程失控深度爆炸和早熟收敛现象跑着跑着内存占用越来越高或者进化到第 10 代以后种群里的公式全部长得一样。原因深度爆炸是因为交叉变异时没有严格执行 max_depth 限制子树替换后某些分支嵌套越来越深表达式树膨胀成深不见底的葡萄藤早熟收敛则是选择压力太大、变异率太低导致的锦标赛规模 k 设得过大几代之后劣质个体全被淘汰剩下个体高度相似。解决在交叉点选择时检查子树深度超出限制就重新选点同时把 tournament_size 从 5 调到 2 到 3把 mutation_prob 从 0.1 上调到 0.2 左右。深度爆炸还有一个通用处理手段就是对个体长度加惩罚项——适应度减去一个随树节点数增长的惩罚值逼着算法偏好简洁公式。5.3 样本内 IC 很高样本外直接失效现象训练集上跑出来的公式 IC 0.07、ICIR 0.8看起来很漂亮换到最近一年的数据上回测多空组合净值和躺平没什么区别。原因遗传规划强大的搜索能力会找到那些恰好拟合历史噪声的公式尤其是人口规模大、进化代数多的时候过拟合几乎是必然事件。另一个常见来源是公式里混入了时间相关的常数——比如进化过程中生成了一个只依赖某个固定日期的特征在训练段内表现稳定出了训练段就失效。解决最有效的方法是把数据按时间切段前 80% 做进化搜索后 20% 做样本外验证只有样本外 IC 仍然显著的公式才进入候选池。其次是多看 ICIR 而不是只看 ICIC 可以靠运气高ICIR 高说明的是多头和空头分组在市场各个时期都稳定分化这才是可交易的因子——这也是 fitness.py 里为什么同时返回两个指标的原因。6. 二次开发把 Genetic-Alpha 接进你自己的因子流水线6.1 值得动手改的三个切入点第一个切入点是 functions.py 的算子扩展。默认函数集能挖出价格和收益类表达式但换手率、成交额、波动率这些衍生特征需要自己定义算子。我一般会加ts_delta(x, n)表示过去 n 日变化量wma(x, n)表示加权移动平均加完算子之后放进 func_set 参数就能参与进化。第二个切入点是 fitness.py 的评估函数。如果你做的是多空组合而不是纯横截面选股可以把适应度从 rank IC 改成多空组合夏普比率——按因子值每日分组做多 top 10%、做空 bottom 10%计算组合净值序列的夏普。这样进化出来的公式会直接优化可交易收益而不是优化相关关系减少IC 高但交易成本吃掉收益的落差。第三个切入点是并行评估。纯 Python 的树评估在大种群下会很慢一个低成本的改造是用 joblib 把每代种群评估并行化按 CPU 核数分块。改造时注意适应度函数要能被 pickle所以算子定义要放在模块顶层不能写在 fit 方法里。我踩过这个坑自定义算子在局部函数里的时候并行直接报错。6.2 改造后怎么验证每次改动都要走一遍验证路径先跑 demo 确认没改坏基线再跑你的历史数据最后把输出因子矩阵存下来做分层回测。分层回测的做法是把每天按因子值从小到大分成五组画五组累计收益曲线如果 top 组和 bottom 组没有拉开差距说明因子有效性不足这时候回看适应度函数和数据预处理问题多半出在那里。记得我第一次拿它批量扫因子时直接上了全市场 3000 只股票跑了两个多小时出来的最优公式竟然是个常数。排查半天才发现目标收益的列名传错了适应度函数在算一个全部相等的因子和收益的相关IC 的方差为零所以返回了一个异常值。从那以后我每次跑新数据都会先打印因子矩阵的describe()看方差确认它不是常数再进入进化主循环。希望帮到你。本文还有配套的精品资源点击获取