
最近我把一套基于机器学习的多因子选股预测模型从零到一完整跑通了好几遍覆盖了数据获取、数据预处理、特征选择再到模型训练和回测验证。说实话真正动手之后才发现很多看似不起眼的细节——比如复权口径怎么选、行业中性化放在哪一步做、特征与标签在时间轴上怎么对齐——都会直接决定这个模型最后能不能用。这篇文章想用尽量直白的语言把这套全流程完整记录下来把我实际使用的数据样例、处理代码和排查心得也一并放出来。如果你已经会一点编程想自己从零搭建一个多因子选股模型或者已经搭到一半被某些细节卡住这篇文章应该能帮你少走不少弯路。1. 项目全貌与整体思路1.1 多因子选股到底在做什么多因子选股的底层逻辑其实不复杂股票未来收益受多种因素影响比如估值水平、盈利能力、价格动量、交易活跃度等。传统做法是人工找到一批因子给每个因子打分再按经验赋予权重合并成一个综合得分最后按得分排序选股。这里面最容易出问题的就是“权重怎么定”因为因子和收益之间往往不是简单的线性关系人工去调难免顾此失彼。我这次要做的模型核心思路是把过去N日的各类因子数据作为特征把未来一段时间的收益作为预测目标交给机器学习模型去学。模型不关心因子之间的经济含义谁强谁弱只关心从数据里能不能找出预测未来收益的规律。这样设计的优势在于可以自动捕捉因子之间的交互作用比如一个低估值股票在业绩加速增长时可能表现更好这种“估值因子×成长因子”的组合效应传统线性加权很难显式刻画。1.2 为什么用机器学习而不是传统线性回归我最早也试过用线性回归来做因子打分效果不算差但有几个问题始终绕不开。一是因子和未来收益的关系经常是非线性的线性模型给的系数会低估这种关系的强度。二是因子之间高度相关回归系数会变得很不稳定。三是线性回归对极端值很敏感个别异常样本就能把系数方向整体带偏。换成机器学习之后前两个问题有了更顺手的解法。梯度提升树一类模型天然能处理特征之间的交互和非线性关系对特征的尺度变化也不敏感标准化与否对结果影响不大。更重要的是树模型可以处理高维稀疏数据即使你塞进去几十个相关性较高的因子它也能通过特征重要性筛选出真正有效的部分不会像线性回归那样直接崩溃。不过代价也很明显。机器学习模型的参数更多、更容易过拟合对样本外的时间预测能力反而比线性模型更脆弱。所以我这一版的整体策略是先用传统金融逻辑做一轮因子初筛再用机器学习做二次筛选和建模最后用严格的时间序列回测来验证而不是一上来就把所有原始字段堆给模型。1.3 全流程的工程规划整个项目我拆成了六个阶段每个阶段都有明确的输入输出阶段输入主要动作输出数据获取行情、财务、行业分类原始数据下载、拼接、字段整理面板数据表数据预处理面板数据表去极值、缺失值处理、中性化干净特征矩阵特征选择干净特征矩阵有效性检验、去冗余、重要性筛选入选因子清单标签构造行情数据计算未来N日收益标签列模型训练特征与标签时间序列交叉验证、训练预测模型回测验证模型与数据集滚动预测、分组回测、绩效统计评估报告这六个阶段并不是一次性走完就结束实际项目里至少需要循环两三轮。因为特征选择的结果会影响建模效果而回测阶段暴露出来的问题又可能追溯到数据预处理阶段比如某些因子出现极端值残留或者行业暴露没有被完全中性化。2. 数据获取给模型准备口粮2.1 数据源与数据口径这一块是整个流程里最“磨人”的环节。市场上有多种数据渠道有的提供免费日线行情有的提供机构级财务数据库还有的需要自己爬公告数据。我这次用的是模拟项目数据集数据量不算大但基本覆盖了行情、财务、行业分类三类信息。之所以强调“口径”是因为同一只股票的市盈率在不同数据源里可能差很多有的用总股本有的用流通股本有的用TTM净利润有的用上年报净利润。我这里统一的口径是估值类因子全部用总股本口径计算财务类数据全部使用最新可得财报数据行业分类使用各报告期更新的申万一级行业。这种口径选择不一定最优但好在逻辑一致不会出现同一个因子在不同股票之间计算基准完全不同的情况。还有一个必须保持一致的是价格复权方式。价格因子如果直接用不复权价格计算遇到分红送股就会出现虚假的下跌动量因子会完全失真。我这里使用的是后复权日线价格好处是历史价格不会随最新数据更新而改变回测结果可复现性强。2.2 核心字段清单与样本结构数据层面至少需要三类表行情表、财务指标表、行业分类表。行情表按日存储包含每只股票的开盘价、最高价、最低价、收盘价、成交量、成交额和换手率。财务指标表通常按季度更新包含市盈率、市净率、股息率、净资产收益率、营收增速、净利润增速等。行业分类表是静态的但注意行业分类本身也会调整采样时应保留“报告期行业代码”两个字段。我自己整理的最小可用数据表结构如下字段名类型说明datedatetime交易日stock_codestring股票唯一标识industrystring所属行业分组pefloat市盈率TTMpbfloat市净率roefloat净资产收益率ret_1mfloat过去一个月累计涨幅ret_12mfloat过去一年累计涨幅turnoverfloat换手率volatilityfloat过去60日波动率在实际项目中这样的字段至少有几十个。数量不是重点重点是每个字段都要能从原始表里追溯来源否则后面发现模型结果异常时你根本不知道该往哪一层去排查。2.3 数据获取阶段最容易踩的三个坑第一个坑是复权方式不统一。我最早做因子测试时行情数据里混合了前复权和后复权价格计算结果出现荒谬的高动量。前复权价格会随最新价格变化历史区间的动量值也会被动改变后复权价格则相对稳定。我在项目里把全部价格统一成后复权只在计算真实交易成本时才使用原始未复权价格。第二个坑是财务数据的“可得性”被忽略。财报数据有严格的公告滞后比如年报可能到4月底才披露但如果你直接用报告期标识去匹配行情就会用到当时根本看不到的财报数据导致严重的前视偏差。正解是使用“公告日期”字段来对齐保证某一天的因子值只使用当时已经公开的数据尽量规避审查问题。第三个坑是样本范围的选择。我在模拟数据集里剔除了上市不足60个交易日的股票也剔除了长期停牌的样本。如果不做这个处理新股上市初期的暴涨暴跌会被模型当成稳定的预测规律实盘时就会踩中一组完全失效的因子。3. 数据预处理从面板数据到干净特征3.1 统一面板数据结构多因子数据本质上是面板数据也就是每个股票在每个交易日都有一条记录。第一步不是清洗而是先把数据框变成“股票×日期×特征”的标准结构。我习惯把date和stock_code设为联合索引所有特征列统一放后面方便后续切片。import pandas as pd import numpy as np df pd.read_csv(multi_factor_sample.csv, parse_dates[date]) df df.sort_values([stock_code, date]).reset_index(dropTrue) df df.set_index([date, stock_code])这个结构的好处是用层次索引做截面操作非常顺手。比如计算某个交易日全市场所有股票的因子秩直接按date分组做rank即可不用额外循环。3.2 缺失值、极端值与标准化缺失值处理是第一个容易被低估的环节。金融数据天然存在大量缺失原因是新股没有历史动量、长期停牌没有波动率、某些行业不适用资产负债因子。我的处理策略分三步如果某个因子在全部样本中的缺失率超过30%直接放弃该因子对于剩余缺失值使用该因子在最近一个有效交易日的行业均值填充边缘场景如整个行业当天都没有数据才退一步使用全体均值。极端值用“分位数去极值”处理比MAD去极值更直观。我把所有因子统一压缩到2%到98%分位之间超出部分用边界值代替。这样做的目的不是丢掉信息而是防止个别的极端样本在训练时把模型带偏。标准化因为有树模型的存在不做硬性要求但如果后面要用线性模型或者神经网络就必须做z-score标准化。def winsorize_series(series, lower0.02, upper0.98): q_low series.quantile(lower) q_high series.quantile(upper) return series.clip(q_low, q_high)数据处理这里必须形成“先清洗再测试”的肌肉记忆。我以前偷懒直接拿原始因子算IC结果相关性被极少数的极端样本撑起来到了样本外一塌糊涂。3.3 行业中性化和市值中性化中性化是整个预处理里最影响模型质量的一步。原因很简单如果你直接用原始PE或PB因子模型很可能学到的是行业偏好而不是真正的估值效应。比如成长行业整体PE偏高价值行业整体PE偏低模型只要判断出行业就能获得不错的预测效果但这并不是我们想要的多因子alpha。我采用回归取残差的方式把每个因子对行业哑变量和对数市值做线性回归取回归残差作为新的因子值。这样得到的因子已经剥离了行业和市值的影响剩余的才是纯粹的个股特征。from sklearn.linear_model import LinearRegression def neutralize(factor_df): # factor_df包含 industry, log_mktcap, raw_factor 三列 dummies pd.get_dummies(factor_df[industry], prefixind) X pd.concat([dummies, factor_df[[log_mktcap]]], axis1) y factor_df[raw_factor] model LinearRegression().fit(X, y) return y - model.predict(X)每次做中性化我都提醒自己一件事回归取残差的做法理论上会让有效因子的样本外IC变小因为剔除了行业和市值这两个“简单粗暴但有效”的变量。但这是必要的代价我们追求的是模型在实盘里换手率和持仓集中度更健康而不是在历史回测里曲线更漂亮。3.4 时间对齐与未来函数的防范数据预处理里有一个极容易忽视的细节所有因子的时间戳必须使用“数据实际可得时间”而不是数据所属的会计区间。财务因子尤其要小心年报里披露的每股收益是截止去年12月的但公告日可能是今年4月。如果直接用会计年度去匹配4月之前的行情相当于提前使用了未来数据。我实际项目里维护了一张“财务公告日表”把每个报告期的公告日期保存起来然后所有财务类因子都以公告日作为生效日期。行情类因子本身没有这个问题但也要注意停牌日的数据不可用要向前填充到最近可交易日的值。还有一个时间对齐的小细节预测截面是T日收盘后因子的数据必须全部是T日及之前的信息而标签是T1日买入、T1N日卖出的收益。这个“买入日与信号日错开一天”的设计是为了避免回测里用T日信号去买T日已经涨停或跌停的股票能更真实地模拟可交易性。4. 特征选择给算法只留能用的因子4.1 单因子有效性检验我筛因子不会只看机器学习跑出来的重要性因为树模型的重要性是局部的、条件于数据分布的很难解释因子本身的独立预测能力。我更习惯先用传统量化指标过一遍把明显无效的因子剔除掉再让模型做二次筛选。单因子检验的标准工具是Rank IC。具体做法是在每个交易日截面上计算因子值与未来N日收益的Spearman秩相关系数然后对所有交易日求均值和标准差。均值即ICIC的均值除以标准差即ICIR因子有效性的经验阈值一般是IC绝对值大于0.02ICIR绝对值大于0.2。为了直观对比我做了这样一张表因子平均RankICICIR结论动量因子过去12月收益0.0350.28有效短期反转因子过去1月收益-0.032-0.24有效市盈率因子0.0180.15弱有效换手率因子-0.021-0.18需观察只看这张表会有一个误区IC均值高不代表一定能稳定赚钱还要看时序上的稳定性。如果一个因子在大部分月份都稳定有效偶尔来一个大月贡献主要ICICIR就会被拉高但实际使用的信心并不足。所以我还会补充看IC的月度分布通常用箱线图就能发现这种异常。4.2 相关性分析与去冗余有效但高度相关的因子会让模型学到重复信息还会让特征重要性变得分散难以判断到底哪个因子在起作用。我通常对所有入选因子的日度秩值计算相关性矩阵设定阈值0.7高于阈值的因子只保留IC更高、经济逻辑更清晰的一个。这里有个容易犯的错误直接对原始因子值计算Pearson相关性会被极端值严重干扰。正确做法是先在每个截面上把因子值转成横截面秩再对秩值计算Spearman相关这样才能反映真实排序层面的信息重合度。去冗余之后的因子数目尽量控制在15到30个。太多则模型容易过拟合太少则模型抗风险能力不足。我的经验是保留20个左右、分属不同逻辑维度的因子模型训练速度和稳定性都比较好。4.3 用机器学习做二次筛选传统金融筛完一轮后还剩一批相对独立但不知道是否对预测有贡献的因子。这时候我再用机器学习的特征重要性来二次筛选。具体操作是先用全量入选因子训练一个梯度提升树模型观察特征重要性排序剔除重要性几乎为零的因子再重新训练检验效果。import xgboost as xgb model xgb.XGBRegressor( n_estimators300, learning_rate0.05, max_depth4, subsample0.8, colsample_bytree0.8 ) model.fit(X_train, y_train) importance pd.Series(model.feature_importances_, indexX_train.columns)特征重要性只能作为参考不能完全代替逻辑判断。我自己就遇到过某个因子重要性极低但单因子IC稳定为正的情况最后因为它的经济逻辑清晰仍然保留了。机器学习筛因子最大的价值不是告诉你“哪些因子有用”而是告诉你“哪些因子在给定其他因子后是多余的”。5. 模型训练与回测验证5.1 标签怎么构造预测目标的选择会直接影响模型的行为。我这次用的标签是“未来20个交易日的收益率”并按横截面排序转换为相对强弱。具体计算如下df[ret_future] (df.groupby(stock_code)[close].shift(-20) / df[close] - 1) * 100这里有几个细节要注意。一是future收益必须使用未复权价格因为复权价会人为放大或缩小实际交易收益。二是如果样本在持有期内退市或者长期停牌不能直接删除否则会引入严重的幸存者偏差。三是标签构造完成后要按横截面做标准化避免不同时段市场整体涨幅差异对模型拟合造成干扰。5.2 样本划分与交叉验证金融时序数据最忌讳随机划分训练集和测试集。因为相邻日期的样本高度相关随机划分会导致模型见过许多测试集样本的“邻居”测试集结果会虚高。我使用滚动时间窗划分训练集采用过去24个月验证集采用随后6个月测试集采用最后12个月并且不重叠滑动。我把这个划分逻辑刻意做成显式代码防止自己偷懒随机切分train_end 2022-04-30 val_end 2022-10-31 test_end 2023-04-30 train df.loc[df[date] train_end] valid df.loc[(df[date] train_end) (df[date] val_end)] test df.loc[(df[date] val_end) (df[date] test_end)]时间序列交叉验证不能解决所有过拟合问题但至少能防范最荒唐的“未来信息穿越”。5.3 滚动训练与预测金融模型最突出的问题是概念漂移市场规律会随时间变化。固定一次训练、之后一直拿模型预测是最初级的做法实盘效果衰减极快。我采用的是滚动重训机制每次预测时只使用截止到预测日之前的数据训练模型然后预测下一期再用真实数据更新训练集滚动下去。滚动训练的主要缺点是计算成本高。股票样本量较大时每期重训一次数百棵树的模型会消耗大量时间。我采用的折中方案是每4周重训一次预测日采用最近一次训练的模型。这样做既控制了成本又不至于让模型信息过时太多。5.4 回测评价指标与过拟合识别我最后用来判断模型好坏的指标不是总收益而是这几个样本外Rank IC预测值与实际收益在截面上的秩相关衡量排序能力。分层回测收益按预测得分分5到10层观察高预期层的收益是否显著高于低预期层。换手率预测排序的月度变化率换手过高意味着交易成本会吃掉收益。最大回撤策略净值的最大回撤衡量极端风险。过拟合最常见的表现是训练集IC很高测试集IC直接归零甚至变负。另一个容易被忽略的表现是分层收益曲线单调但高预期层的超额收益集中在少数几个月份收益来源不够分散。这种模型实盘后很容易被打回原形。6. 常见问题与踩坑记录6.1 未来函数导致虚假收益这是回测造假最常见的一种我自己也栽过。典型场景是把当日收盘前的某些指标当成收盘后可用数据用在了当天的收益预测上。比如用当天涨停板的换手率去预测当天涨停显然荒谬。排查方法很简单任何特征的时间戳都必须严格小于等于信号的生成时刻任何未来信息都不得出现在训练和预测的特征中。我用了一张“数据可得时间表”将所有特征的可用时间单独记录在每次训练前做一次检查。6.2 幸存者偏差如果数据集只保留目前还在市场上的股票模型会自然学会规避退市股票但实盘里你根本不知道哪只会退市。幸存者偏差会让测试集收益虚高到离谱。处理办法是在构建面板数据时保留所有出现过交易记录、后来退市或长期停牌的股票至少要从建模样本里保留足够长历史记录不做剔除。6.3 因子衰减与模型更新多因子模型失效的本质是因子被市场参与者发现并过度利用导致超额收益消失。我见过某个动量因子在回测期效果极好上实盘运行三个月后IC就从0.03下降到0.01附近几乎失去预测能力。应对措施有两个一是保持定期重训的节奏让模型及时适应因子关联结构的变化二是平时监控每个因子的滚动IC一旦发现某个因子的IC持续走弱就考虑在下一期特征选择中把它替换掉。6.4 实战中的几点体会这一版模型跑完之后我最大的体会是全流程的价值不在于选出了哪几个因子而在于建立了一套可复现、可追溯、可监控的流水线。数据预处理和特征选择看起来不如模型训练“高级”但决定模型上限的往往是这些基础环节。因子构造一处口径不一致、未来数据残留一处漏掉后面所有复杂模型都是白搭。另一个体会是要敢于接受模型样本外表现变差。机器学习多因子模型本质上是在历史数据中寻找统计规律市场结构一变规律就可能消失。与其追求一条完美曲线不如把回测做得务实一些预留足够的交易成本和滑步让自己对策略的真实预期收益心中有数。这套全流程改到现在我认为最值钱的是那些曾被忽略、在回测中吃过亏的细节它们才是实盘能否落地的关键分水岭。