遗传规划选股因子挖掘:从公式进化到实盘验证的工程实践
简介这份华泰证券金工深度研究报告聚焦遗传规划在选股因子挖掘中的应用面向量化投资研究者、因子开发人员及金融工程方向的学习者帮助读者理解如何借助启发式公式演化技术突破人工构建因子的思维局限。资源为1个PDF文件压缩包约3.32MB内容完整呈现了遗传规划的原理、总体流程与公式树形表示、适应度计算、交叉与变异等进化方法并深入讲解gplearn程序包的定制改进包括扩充函数集、引入单因子测试与并行运算加速。报告还展示了以个股20个交易日后收益率为预测目标的实测流程初步挖掘出6个具有增量信息且可解释性良好的选股因子同时客观讨论了因子复杂度过高、可解释性下降等局限。目前已有929人学习适合希望系统掌握遗传规划因子挖掘框架、并据此调整数据源、股票池与评价指标的读者参考。1. 遗传规划选股因子挖掘为什么它能从公式堆里捞出真正赚钱的信号很多人做量化因子第一步就卡在“人工想公式”上——动量、反转、波动率、换手率翻来覆去就那几十个IC 衰减得比谁都快。遗传规划Genetic ProgrammingGP换了个思路不靠人写公式让程序自己进化出因子表达式。华泰这份 2019 年的研报把 GP 用在选股因子上核心就是用树形结构表示公式通过选择、交叉、变异不断迭代最后挑出 IC 稳定、换手可控的因子。它解决的是因子挖掘的“供给瓶颈”适合已经有一批基础量价数据、想批量产出候选因子的从业者。Python 生态里gplearn是最常见的落地工具配合 pandas 做数据对齐能跑通从原始行情到因子表达式的完整链路。这一章先把 GP 选股因子的定位讲清楚后面几章拆实现、参数和坑。2. 遗传规划挖因子的底层逻辑树、算子与适应度怎么定2.1 用树形表达式表示一个选股因子GP 和传统回归最大的区别在于它搜索的不是系数而是结构。一个因子在 GP 里就是一棵表达式树叶子节点是特征变量比如收盘价、成交量、换手率内部节点是算子加减乘除、rank、delay、corr 等。比如rank(corr(close, volume, 20))这棵树根是 rank左子节点是 corrcorr 下面挂 close、volume 和窗口 20。这样表示的好处是任意复杂度的公式都能被编码而且交叉、变异操作直接在树上做不会破坏语法结构。我一般会把特征分成三类价格类open/high/low/close/vwap、量能类volume/amount/turnover、衍生类收益率、振幅、换手率变化。算子集也要控制规模太多算子会让搜索空间爆炸太少又挖不出非线性关系。常见做法是保留四则运算、rank、ts_rank、delay、delta、corr、std 这几类基本能覆盖研报里那批因子的形态。2.2 适应度函数IC、ICIR 还是多目标适应度决定进化方向选错了后面全白干。最直接的是用因子值和未来一期收益的 Rank IC 绝对值但单期 IC 噪声大容易过拟合。更稳的做法是用 ICIRIC 均值除以 IC 标准差它同时看稳定性和强度。华泰研报里也提到用 IC 和换手率做联合筛选因为高换手因子扣掉交易成本后可能不赚钱。实操中我会把适应度写成加权形式fitness |IC_mean| * w1 - turnover * w2 - complexity * w3。复杂度惩罚项很关键防止 GP 进化出几百个节点的怪物公式那种因子样本外基本失效。权重怎么定没有标准答案我一般先让 w11、w20.1、w30.001 跑一轮看因子分布再调。2.3 选择、交叉、变异进化流程的最小实现下面这段代码用gplearn搭一个最小可跑的 GP 因子挖掘流程数据用模拟的量价面板重点是让读者看清每一步在干什么。import numpy as np import pandas as pd from gplearn.genetic import SymbolicTransformer from gplearn.functions import make_function from scipy.stats import spearmanr # 1. 构造模拟面板数据500 只股票300 个交易日 np.random.seed(42) n_stock, n_day 500, 300 close np.random.randn(n_stock, n_day).cumsum(axis1) 50 volume np.random.rand(n_stock, n_day) * 1e6 1e5 turnover volume / 1e6 # 2. 标签未来 5 日收益的截面 rank future_ret np.zeros_like(close) future_ret[:, :-5] close[:, 5:] / close[:, :-5] - 1 y np.apply_along_axis(lambda x: spearmanr(x, np.arange(len(x)))[0], 1, future_ret) # 3. 自定义算子时序 rank 和相关系数 def _ts_rank(x, window): return pd.Series(x).rolling(window).apply( lambda s: spearmanr(s, np.arange(len(s)))[0], rawTrue).values ts_rank make_function(function_ts_rank, namets_rank, arity2) # 4. 特征矩阵每行是一个样本股票-日期列是特征 X np.column_stack([close.ravel(), volume.ravel(), turnover.ravel()]) y_flat y.ravel() # 5. GP 进化生成 20 个因子表达式 gp SymbolicTransformer( generations15, # 进化代数 population_size2000, # 每代个体数 hall_of_fame100, # 名人堂保留数 n_components20, # 最终输出因子数 function_set[add, sub, mul, div, ts_rank], parsimony_coefficient0.001, # 复杂度惩罚 metricspearman, # 适应度用 rank IC random_state42, n_jobs-1 ) gp.fit(X, y_flat) # 6. 输出因子表达式和对应 IC for i, prog in enumerate(gp._best_programs[:5]): print(f因子{i}: {prog}) factor_val prog.execute(X) ic spearmanr(factor_val, y_flat)[0] print(f IC {ic:.4f}, 复杂度 {prog.length_})这段代码的逻辑链条是先造一份带截面标签的数据再把特征拉平成 GP 能吃的矩阵然后用SymbolicTransformer做进化最后打印表达式和 IC。参数上generations和population_size决定搜索强度2000×15 在单机上大概跑十几分钟parsimony_coefficient控制公式膨胀设太大因子会变得极简但 IC 低设太小会出怪物公式metricspearman让适应度直接对齐 Rank IC比默认的 MSE 更贴合选股场景。hall_of_fame和n_components配合使用前者是候选池后者是最终输出数量一般名人堂留 100、输出 20 左右比较平衡。跑完之后别急着上实盘先看表达式里有没有明显未来函数比如算子窗口写成了负数或者用了当天收盘算当天因子。GP 不会主动避免这些得靠数据对齐和算子实现来兜底。3. 从原始行情到 GP 输入特征工程与数据对齐的实操3.1 量价特征的构造与标准化GP 对输入特征的尺度和分布很敏感。原始收盘价是 50 块成交量是百万级直接丢进去四则运算会被大量级特征主导挖出来的因子全是围绕成交量的。常见做法是先做截面标准化每个交易日对每个特征做 z-score 或 rank 归一化让所有特征在同一量纲上。我一般用 rank 归一化因为它对异常值不敏感而且和 IC 的截面排序逻辑一致。def cross_section_rank(df, feature_cols): 按日做截面 rank 归一化输出 0~1 for col in feature_cols: df[col _rank] df.groupby(date)[col].rank(pctTrue) return df # 假设 df 有 date, stock, close, volume, turnover 列 feature_cols [close, volume, turnover] df cross_section_rank(df, feature_cols)这段代码按date分组做rank(pctTrue)把每个特征压到 0~1。参数上pctTrue输出百分位比原始 rank 更适合做后续运算。注意别把标签也一起 rank 了标签要保留原始收益用于计算 IC。3.2 算子窗口与未来函数的边界GP 挖因子最容易翻车的地方就是未来函数。比如ts_rank(close, 20)如果实现成包含当天收盘而标签是未来 5 日收益那当天收盘和未来收益之间没有直接泄露但如果你把标签算成了close[t1]/close[t]而因子用了close[t]这其实是正常的。真正危险的是算子窗口方向写反或者用了shift(-1)这类前视操作。我的习惯是在算子实现里强制加一层检查所有时序算子只允许访问t及之前的数据。gplearn的自定义函数拿到的是一维数组窗口滚动时默认是从左到右只要不手动反转就没问题。另外标签计算要用close[t5]/close[t] - 1这种形式确保因子和标签的时间戳对齐。3.3 样本切分时序切分而不是随机切分GP 进化过程会反复在训练集上评估适应度如果随机切分同一只股票的不同日期会同时出现在训练和测试集导致信息泄露。正确做法是按时间切前 70% 交易日做训练后 30% 做样本外验证。更严格一点可以用滚动窗口每滚一次重新进化一轮看因子在多个样本外窗口的 IC 是否稳定。dates sorted(df[date].unique()) split int(len(dates) * 0.7) train_dates, test_dates dates[:split], dates[split:] train_mask df[date].isin(train_dates) test_mask df[date].isin(test_dates) X_train, y_train X[train_mask.values], y_flat[train_mask.values] X_test, y_test X[test_mask.values], y_flat[test_mask.values]切分后分别 fit 和评估样本外 IC 如果只有训练集的一半不到基本可以判定过拟合。我一般要求样本外 IC 至少保留 60%否则回去调parsimony_coefficient或减少generations。4. 避坑与排查GP 选股因子挖掘里最容易翻车的五件事4.1 因子 IC 很高但换手率爆炸现象挖出来的因子样本内 IC 0.08看着不错但一算换手率每天 80%扣掉手续费后收益归零。原因是 GP 为了追求 IC进化出了大量短周期反转类公式比如rank(delta(close, 1))这种信号每天翻来覆去。解决在适应度里加换手率惩罚项或者直接在算子集里限制delta的窗口不小于 5。我一般还会在最终筛选时加一条硬规则日均换手率超过 30% 的因子直接丢弃。4.2 表达式里出现常数除法导致 NaN 扩散现象跑完 GP 发现一半因子值全是 NaN回看表达式里有div(close, volume)这种某些股票停牌时 volume 为 0除法直接爆掉。原因是算子实现没做除零保护。解决自定义div算子时加一个极小值兜底比如x / (y 1e-8)或者在数据预处理阶段把 volume 为 0 的样本剔除。gplearn自带的div有保护但自定义算子容易忘。4.3 训练集 IC 0.1样本外 IC 0.01现象进化了 30 代训练集 IC 冲到 0.1样本外惨不忍睹。原因是种群多样性丧失GP 早熟收敛到一组过拟合公式。解决增大population_size提高变异率p_crossover降到 0.7p_subtree_mutation提到 0.1或者用多个随机种子跑多次取交集。我一般会跑 5 个种子只保留在至少 3 个种子里都出现的因子结构。4.4 因子之间高度相关输出 20 个等于 1 个现象n_components20输出 20 个因子但两两相关系数 0.9 以上本质上是同一个信号。原因是 GP 的名人堂里保留了大量相似个体。解决在输出前做相关性过滤贪心选择 IC 最高且与已选因子相关性低于 0.7 的。gplearn的SymbolicTransformer本身有去相关逻辑但阈值偏松建议自己再筛一遍。4.5 数据对齐错误导致因子用了未来信息现象样本外 IC 异常高达到 0.15 以上但实盘一上就亏。原因是特征矩阵和标签矩阵在 ravel 时顺序错位或者日期索引没对齐。解决在构造 X 和 y 时用同一个 DataFrame 的同一批行ravel 前先 reset_index确保第 i 行特征对应第 i 行标签。我习惯在 fit 之前打印前 5 行特征和标签的日期肉眼确认对齐。5. 让 GP 因子真正可用的进阶技巧去相关、滚动进化与实盘验证5.1 用贪心去相关把 20 个因子压到 5 个GP 输出的因子池通常冗余严重直接全上等于没分散。我一般用贪心法按 IC 从高到低排序依次选入因子如果新因子与已选因子的最大相关系数超过 0.7 就跳过。这样能把 20 个压到 5 个左右且彼此信息互补。def greedy_select(factors, ic_values, corr_threshold0.7): factors: (n_samples, n_factors) 矩阵 order np.argsort(-np.abs(ic_values)) selected [] for idx in order: if not selected: selected.append(idx) continue corr np.abs(np.corrcoef(factors[:, idx].T, factors[:, selected].T)[0, 1:]) if corr.max() corr_threshold: selected.append(idx) return selected selected_idx greedy_select(gp.transform(X_train), [spearmanr(gp.transform(X_train)[:, i], y_train)[0] for i in range(20)]) print(f去相关后保留 {len(selected_idx)} 个因子)参数上corr_threshold设 0.7 是经验值设 0.5 会更分散但可能丢掉有用信号设 0.9 则去相关效果不明显。选完后用这 5 个因子做等权合成再算合成因子的 IC通常比单因子高 20%~30%。5.2 滚动进化每季度重新挖一轮市场风格会变2019 年有效的因子到 2021 年可能就失效了。固定一批因子用到底不现实。我的做法是每季度用最近 3 年数据重新跑一次 GP输出新因子池然后和旧因子池合并去相关。这样既能捕捉新规律又不会完全抛弃历史有效信号。滚动进化的计算量不小单次 2000×15 大概十几分钟一季度一次可以接受。5.3 实盘前的三层验证第一层是样本外 IC滚动窗口下 IC 均值大于 0.03、ICIR 大于 0.3 才算及格。第二层是分组回测按因子值分 5 组看多空收益是否单调单调性差说明因子和收益关系不稳定。第三层是交易成本模拟按日均换手率扣 0.2% 双边成本看净值曲线是否还向上。三层都过了才考虑小仓位实盘。验证层指标及格线不达标怎么办样本外 ICIC 均值 / ICIR0.03 / 0.3减少代数加复杂度惩罚分组回测多空单调性5 组收益单调检查因子是否非线性过强成本模拟扣费后年化0加换手率惩罚拉长窗口这三层里最容易被跳过的是成本模拟但恰恰是它把大部分“纸面因子”打回原形。我自己的血泪经验是一个样本外 IC 0.05 的因子如果日均换手 50%扣费后基本不赚钱反而 IC 0.03、换手 10% 的因子更值得上。GP 挖出来的因子天然偏短周期所以换手控制比 IC 高低更重要。最后说个习惯每次跑完 GP我都会把表达式抄到一张表里标注生成日期、样本外 IC、换手率和去相关后的保留情况。攒了两年之后回头看哪些算子组合反复出现、哪些窗口参数稳定一目了然。这比每次重新调参靠谱得多。希望帮到你。本文还有配套的精品资源点击获取