尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

遗传规划选股因子挖掘:从研报落地到因子工厂的工程实践

遗传规划选股因子挖掘:从研报落地到因子工厂的工程实践 简介这份华泰证券2019年6月发布的深度研究报告聚焦遗传规划在选股因子挖掘中的应用面向量化投资研究者、金融工程从业者及具备一定编程基础的因子研究学习者。报告系统讲解遗传规划的原理与完整流程涵盖公式树形表示、适应度计算、选择、交叉、变异与终止条件等环节并深度定制改进gplearn程序包扩充函数集、引入单因子测试与并行运算。测试部分以个股20个交易日后的收益率为预测目标挖掘出6个选股因子在剔除行业、市值、换手率、波动率等影响后仍具稳定RankIC且多数因子相关性不高体现增量信息。资源为1个PDF文件压缩包约3.32MB内容完整、结构清晰适合按章节研读。目前已有930人学习下载可作为“先有公式、后有逻辑”的因子研究思路参考同时需注意因子复杂性与失效风险。1. 遗传规划选股因子挖掘从一份25页研报到一个能跑通的因子工厂很多人第一次听到「遗传规划选股因子挖掘」脑子里浮现的是机器学习那套调参炼丹。但真正动手做过一轮就会发现它更像一个自动化的公式搜索器你给它算子、给它数据、给它一个适应度函数它自己进化出成百上千个候选因子表达式最后你从里面挑出真正有超额收益的那几个。华泰这份2019年6月的研报核心思路就是用遗传规划去自动挖掘量价因子替代人工穷举公式的低效。适合谁有Python基础、懂一点pandas、想给自己的多因子模型补充新因子的量化从业者。gplearn这个库是绕不开的起点它把遗传规划的进化框架封装好了你只需要定义好算子集和适应度函数。这篇文章不讲虚的从环境配置到因子表达式落地把每一步的参数和坑都摊开说。2. 遗传规划到底在搜什么符号回归与因子表达式的映射关系2.1 从「人工写公式」到「机器进化公式」的范式转换传统因子挖掘靠研究员拍脑袋想逻辑比如「20日动量」「换手率波动」然后写代码回测。这种方式的天花板很明显人的想象力有限能想到的公式组合就那么多。遗传规划换了个思路——把因子表达式当成一棵语法树树的叶子是原始特征开盘价、收盘价、成交量、换手率等内部节点是算子加减乘除、rank、delay、corr等然后用进化算法去搜索这棵树的最优结构。具体来说一个因子表达式rank(corr(close, volume, 20))在遗传规划里长这样根节点是rank它的子节点是corrcorr下面挂着close、volume和常数20。遗传规划要做的就是随机生成一堆这样的树评估每棵树作为因子的表现比如IC值、多空收益然后让表现好的树「繁殖」——交叉、变异、选择——一代代迭代下去。这里的关键认知是遗传规划不保证找到全局最优它是在一个巨大的表达式空间里做启发式搜索。所以参数设置直接决定了它是在「有效探索」还是「随机游走」。研报里用的算子集和适应度函数设计就是把这个搜索过程约束在金融逻辑合理的范围内。2.2 gplearn的SymbolicTransformer为什么选它而不是自己造轮子Python里做遗传规划gplearn是最成熟的选择。它提供了SymbolicRegressor和SymbolicTransformer两个类前者做回归预测后者做特征生成——我们做因子挖掘要的是后者。SymbolicTransformer的输出是一组新的特征列每列对应一个进化出来的表达式可以直接喂给下游的选股模型。为什么不用DEAP或者自己写DEAP更底层灵活但工作量大自己写进化框架光是语法树的编码、交叉变异算子、并行评估就能耗掉两周。gplearn把这些都封装好了而且它的API和sklearn一致fit、transform、get_feature_names一套下来很顺。代价是它的算子集需要你自己定义内置的算子偏数学金融场景下需要补充rank、delay、ts_corr这类时序算子。安装很简单pip install gplearn但要注意版本兼容性。gplearn对scikit-learn的版本有要求太新的sklearn可能导致SymbolicTransformer的fit报错。我一般会锁定在scikit-learn0.24.x或1.0.xgplearn用0.4.2。如果你用conda可以单独建一个环境conda create -n gp_factor python3.8 conda activate gp_factor pip install scikit-learn1.0.2 gplearn0.4.2 pandas numpy提示不要在生产环境直接升级gplearn它的维护频率不高新版本可能引入不兼容的改动。锁定版本是血泪经验。3. 把研报思路落地成代码数据准备、算子定义与进化参数3.1 数据准备量价数据的对齐与标准化遗传规划对输入数据的质量极其敏感。如果不同股票的数据没有对齐或者存在大量缺失值进化出来的因子就是噪声。我一般会准备一个宽表行是交易日列是股票代码每个因子对应一个宽表。比如收盘价矩阵close_df、成交量矩阵volume_df、换手率矩阵turnover_df。数据预处理的核心步骤import pandas as pd import numpy as np # 假设原始数据是长表date, stock, close, volume, turnover # 先转成宽表 close_df raw_data.pivot(indexdate, columnsstock, valuesclose) volume_df raw_data.pivot(indexdate, columnsstock, valuesvolume) turnover_df raw_data.pivot(indexdate, columnsstock, valuesturnover) # 对齐索引和列 common_idx close_df.index.intersection(volume_df.index).intersection(turnover_df.index) common_cols close_df.columns.intersection(volume_df.columns).intersection(turnover_df.columns) close_df close_df.loc[common_idx, common_cols] volume_df volume_df.loc[common_idx, common_cols] turnover_df turnover_df.loc[common_idx, common_cols] # 缺失值处理前向填充但限制最多填5天 close_df close_df.fillna(methodffill, limit5) volume_df volume_df.fillna(methodffill, limit5) turnover_df turnover_df.fillna(methodffill, limit5) # 去掉仍然有缺失的股票 valid_cols close_df.columns[close_df.isna().sum() 0] close_df close_df[valid_cols] volume_df volume_df[valid_cols] turnover_df turnover_df[valid_cols]逻辑说明宽表是gplearn的输入格式每一行是一个样本这里是一个交易日每一列是一个特征这里是一只股票的某个指标。但遗传规划做的是截面因子挖掘它需要的是「在某一天所有股票的因子值」。所以更常见的做法是把数据整理成「面板数据展平」的形式每个样本是一个日期股票对特征是该股票在该日期的量价指标。参数说明limit5控制前向填充的最大天数太长会引入未来信息太短会留下太多缺失。valid_cols过滤掉数据不全的股票避免进化过程中因为缺失值报错。3.2 自定义算子让遗传规划懂金融逻辑gplearn内置的算子有add、sub、mul、div、sqrt、log、abs、neg、inv、max、min等。这些是纯数学算子但金融因子需要的是时序和截面算子。比如rank截面排名、delay滞后、ts_corr时序相关性、ts_std时序标准差。gplearn允许你通过function_set参数传入自定义函数。每个自定义函数需要是一个可调用对象接收numpy数组返回numpy数组。但这里有个坑gplearn的进化是在「样本级别」操作的它不知道什么是「截面」什么是「时序」。所以你需要把截面和时序逻辑编码进数据里。一种常见的做法是把每个交易日的截面数据作为一个样本特征是该截面上所有股票的某个指标。但这样样本量太小一天一个样本进化效率极低。更好的做法是把面板数据展平每个日期股票对作为一个样本然后用「分组算子」来实现截面逻辑。from gplearn.functions import make_function def _rank(x): 截面排名对每个交易日的所有股票进行排名 # 这里假设x是一个二维数组行是日期列是股票 # 实际使用时需要根据数据形状调整 return pd.DataFrame(x).rank(axis1).values def _delay(x, period): 滞后算子取period天前的值 return np.roll(x, period, axis0) def _ts_corr(x, y, period): 时序相关性过去period天的相关系数 return pd.DataFrame(x).rolling(period).corr(pd.DataFrame(y)).values # 注册为gplearn函数 rank_func make_function(function_rank, namerank, arity1) delay_func make_function(function_delay, namedelay, arity2) ts_corr_func make_function(function_ts_corr, namets_corr, arity3) function_set [add, sub, mul, div, rank_func, delay_func, ts_corr_func]逻辑说明make_function把普通Python函数包装成gplearn能识别的算子。arity参数指定函数接收几个输入。rank接收1个输入要排名的指标delay接收2个输入指标和滞后期数ts_corr接收3个输入两个指标和窗口期。参数说明_rank里的axis1表示按行排名即每个交易日独立排名。_delay里的np.roll是循环移位实际使用时要注意边界处理避免用未来数据填充。_ts_corr里的rolling(period)窗口期需要根据因子逻辑调整常见的是5、10、20、60天。注意自定义算子的计算效率直接影响进化速度。如果每个算子都用pandas的apply一轮进化可能要跑几个小时。建议用numpy向量化实现或者用numba加速。3.3 适应度函数IC、RankIC还是多空收益适应度函数决定了遗传规划往哪个方向进化。研报里通常用IC信息系数或RankIC作为适应度。IC是因子值和下期收益的皮尔逊相关系数RankIC是斯皮尔曼相关系数。两者的区别在于RankIC对异常值更稳健但计算稍慢。在gplearn里适应度函数通过metric参数指定。默认是pearson对应IC。如果要RankIC需要自定义from scipy.stats import spearmanr def rank_ic(y_true, y_pred): 计算RankIC返回负值因为gplearn是最小化metric ic, _ spearmanr(y_true, y_pred) return -ic # gplearn默认最小化所以取负 # 在SymbolicTransformer中指定 from gplearn.genetic import SymbolicTransformer gp SymbolicTransformer( generations20, population_size1000, hall_of_fame100, n_components20, function_setfunction_set, metricrank_ic, parsimony_coefficient0.001, max_samples0.9, random_state42, n_jobs-1 )逻辑说明generations是进化代数population_size是每代的个体数hall_of_fame是保留的最优个体数n_components是最终输出的因子个数。parsimony_coefficient控制表达式复杂度惩罚值越大越倾向于简单的表达式。max_samples是每次评估时采样的样本比例小于1可以增加多样性。参数说明population_size1000和generations20是研报里常见的设置但实际跑起来很慢。如果只是验证思路可以先设population_size200、generations10。n_jobs-1开启所有CPU核心并行能显著缩短时间。random_state固定随机种子保证结果可复现。3.4 训练与因子输出从进化结果到可用的因子值训练过程就是调用fit# 假设X是特征矩阵y是下期收益 # X的每一行是一个样本日期股票每一列是一个原始特征 gp.fit(X, y) # 输出进化出来的因子表达式 for i, program in enumerate(gp._best_programs): print(f因子{i}: {program})gp._best_programs是进化结束后保留的最优表达式列表。每个program可以打印成可读的字符串比如rank(div(close, volume))。这些表达式就是你要的因子。但直接拿这些表达式去回测还不够你需要把它们转换成因子值。SymbolicTransformer提供了transform方法# 生成因子值 factor_values gp.transform(X) # factor_values的形状是(n_samples, n_components) # 每一列对应一个进化出来的因子逻辑说明transform会把每个表达式应用到输入数据上计算出因子值。这些因子值可以直接用于下游的选股模型比如打分、排序、组合优化。参数说明transform的输出是numpy数组需要自己转回DataFrame并加上日期和股票索引。如果n_components20你会得到20个新因子。但并不是所有因子都有用通常需要进一步筛选——计算每个因子的IC均值、ICIR、换手率、与已有因子的相关性剔除表现差的和高度冗余的。4. 避坑与排查遗传规划因子挖掘的五个翻车现场4.1 因子值全是NaN算子边界没处理现象transform输出的因子值大量缺失甚至整列都是NaN。原因自定义算子没有处理边界情况。比如div算子在分母为0时会返回inflog在输入为负数时会返回NaNdelay在数据开头会引入NaN。这些异常值在进化过程中会被传播导致整个表达式失效。解决在每个自定义算子内部加保护。比如div改成安全除法def _safe_div(x, y): return np.where(np.abs(y) 1e-8, x / y, 0)log改成log(abs(x) 1)delay在开头用前向填充而不是NaN。另外在fit之前对输入数据做一次清洗把inf和极值替换掉。4.2 进化出来的因子全是复杂表达式简约系数没调好现象打印出来的因子表达式长达十几层比如add(mul(div(rank(close), ts_std(volume, 20)), delay(turnover, 5)), ...)可读性极差而且回测表现往往不好。原因parsimony_coefficient设置得太小遗传规划倾向于用复杂表达式去拟合训练数据导致过拟合。复杂表达式在样本外表现通常很差。解决增大parsimony_coefficient比如从0.001调到0.01甚至0.05。这个参数的本质是「复杂度惩罚」值越大越倾向于选择简单的表达式。另外可以限制init_depth初始树的深度和max_depth最大深度从结构上控制复杂度。4.3 训练集IC很高但样本外IC为负过拟合了现象在训练集上因子的IC均值达到0.08但一到样本外就变成-0.02。原因遗传规划在训练集上搜索了几千个表达式总能找到一些「碰巧」表现好的。这些表达式往往没有经济逻辑支撑纯粹是数据挖掘的产物。另外如果训练集和样本外的市场环境差异大因子失效也正常。解决第一用滚动窗口训练比如用过去3年数据训练在接下来1年验证然后滚动。第二在适应度函数里加入样本外验证比如用交叉验证的IC均值作为适应度。第三对进化出来的因子做逻辑筛选只保留有经济含义的表达式比如包含rank、corr、delay这些金融算子的。4.4 运行速度慢到无法接受并行和采样没配好现象fit跑了一整天还没结束CPU占用率只有25%。原因n_jobs没设置或者设置成了1max_samples设成了1.0导致每次评估都用全量数据。解决n_jobs-1开启所有核心max_samples0.7到0.9之间减少每次评估的计算量。另外把数据量控制一下——如果股票池有5000只股票、10年数据样本量太大可以先随机采样或者只保留流动性最好的前1000只。还有自定义算子尽量用numpy向量化避免在算子内部用pandas的apply。4.5 因子之间高度相关去冗余没做现象transform输出的20个因子两两之间的相关系数超过0.9实际上只有两三个独立因子。原因遗传规划在进化过程中会收敛到相似的表达式结构尤其是当适应度函数只奖励IC、不惩罚相关性时。解决在SymbolicTransformer里没有直接的去冗余参数需要手动做。一种方法是在适应度函数里加入相关性惩罚计算当前表达式与已保留因子的最大相关性如果超过阈值就降低适应度。另一种方法是进化结束后做聚类每个簇只保留IC最高的那个因子。我一般用后者简单直接。5. 进阶技巧用滚动训练和因子正交化把遗传规划因子变成可上线的信号遗传规划挖出来的因子最怕的就是「回测很美、实盘很惨」。要让它真正可用滚动训练和因子正交化是两个必须做的步骤。滚动训练的思路是不要用全量数据训练一次就完事而是模拟实盘的信息集。比如从2015年开始每年初用过去3年数据训练一次得到一组因子表达式然后在接下来一年里用这组表达式计算因子值。这样每年更新一次因子避免用未来数据。代码框架train_years [2015, 2016, 2017, 2018, 2019] for year in train_years: train_start f{year-3}-01-01 train_end f{year}-01-01 test_start f{year}-01-01 test_end f{year1}-01-01 X_train, y_train prepare_data(train_start, train_end) X_test, y_test prepare_data(test_start, test_end) gp SymbolicTransformer(...) gp.fit(X_train, y_train) factor_train gp.transform(X_train) factor_test gp.transform(X_test) # 计算样本外IC ic compute_ic(factor_test, y_test) print(f{year}年样本外IC: {ic:.4f})逻辑说明每年重新训练一次用过去3年数据在接下来1年验证。这样得到的样本外IC序列更能反映实盘表现。如果某一年样本外IC突然转负说明市场环境变了因子失效需要警惕。参数说明train_start和train_end控制训练窗口3年是常见选择也可以试2年或5年。test_start和test_end是验证窗口通常1年。compute_ic计算因子值和下期收益的RankIC。因子正交化的目的是去掉因子之间的共线性。遗传规划进化出来的因子往往高度相关直接放进多因子模型会导致权重不稳定。正交化的做法是对每个因子用已有因子做回归取残差作为新的因子值。这样新因子与已有因子正交信息不重叠。from sklearn.linear_model import LinearRegression def orthogonalize(factor_df, existing_factors): 将factor_df对existing_factors做正交化返回残差 residuals [] for col in factor_df.columns: y factor_df[col].values X existing_factors.values model LinearRegression().fit(X, y) resid y - model.predict(X) residuals.append(resid) return pd.DataFrame(np.array(residuals).T, columnsfactor_df.columns)逻辑说明对每个新因子用已有因子矩阵做线性回归残差就是正交化后的因子值。这样新因子中与已有因子相关的部分被剔除只保留独立信息。参数说明existing_factors是已经保留的因子矩阵可以逐步构建——先选IC最高的因子然后正交化第二个再正交化第三个以此类推。正交化后的因子需要重新计算IC确保独立信息仍然显著。最后说一个我自己的习惯遗传规划挖出来的因子我从来不会直接上线。我会先看它的表达式有没有经济逻辑——比如rank(corr(close, volume, 20))可以解释为「量价相关性排名」有明确的金融含义而add(mul(div(close, volume), delay(turnover, 3)), sqrt(rank(high)))这种就说不清逻辑大概率是过拟合。有逻辑的因子即使IC低一点我也愿意多给权重没逻辑的IC再高也先放观察池。这个习惯帮我避开了不少「回测王者、实盘青铜」的坑。希望帮到你。本文还有配套的精品资源点击获取
返回列表