机器学习重构多因子选股:从IC加权到排序学习的完整实战路线
发布时间:2026/10/11 22:02:19 锦皓数字建站

简介一份基于机器学习构建多因子选股模型的完整项目含源代码与文档说明面向量化金融专业学生及智能选股开发者。项目覆盖单因子测试、因子共线性分析、特征与标签构建并实现支持向量回归、长短期记忆网络、极端梯度提升、随机森林等多种模型回测最优随机森林模型累计收益约六成经择时风控后最大回撤约百分之九夏普率约零点九。资源共38个文件、约14.71MB包含15个脚本因子测试与模型训练、10份行业报告、7张因子图及笔记文档。已有467人学习浏览。作为高分毕设项目答辩均分96分代码均运行验证读者可据此复现多因子选股全流程、理解单因子检验与机器学习回测框架还可参考其交易逻辑和风控方法用于课程设计或量化入门实践。1. 机器学习方法如何重塑多因子选股模型从IC加权到排序学习多因子选股模型从线性打分转向机器学习本质上是在回答一个问题因子值到未来收益之间到底是直线关系还是非线性关系。传统做法是算因子IC、加权合成综合分再按分排序选股这套流程占了很长时间的主流但样本外表现经常让从业者头疼因子有效期越来越短线性加权把因子间的交互关系全部抹掉了。机器学习方法不一样它把因子当作特征、把未来收益当作标签用树模型或神经网络直接学一个排序函数。这篇内容适合已经跑过传统多因子打分、手里有行情数据和Python环境想往机器学习方向切换的量化研究者。文章会沿着一条完整的技术路线展开因子如何处理、模型如何构建、回测如何排错、源代码和文档如何组织让你不只能看懂同类项目还能自己动手复现一个能跑通的最小版本。2. 多因子选股为什么需要换引擎线性加权的三个局限2.1 IC不够用时问题往往出在“线性假设”上传统的多因子选股模型核心流程是对每个因子计算IC因子值与未来收益的秩相关系数然后用IC均值或ICIR作为权重把因子值线性加权成一个综合分。这套逻辑有一个前提假设——因子得分和未来收益之间的关系是单调线性的。可实际数据里这种关系经常不成立。典型例子是估值因子。低PE的股票平均收益确实好于高PE但如果把PE分成10档收益并不严格按档位单调递增第三档、第四档之间经常出现跳跃。另一个例子是成长因子过高的营收增速反而对应更低的未来收益市场为高增长付了过高的价格。线性加权模型用一个常数权重去表达这种时变、非线性、区间相关的逻辑样本外自然容易失效。我见过不少团队在IC加权失效后第一反应是加更多因子、缩短调仓周期结果只是把过拟合从因子权重搬到了因子选择上。真正要改的其实是合成层把“因子→收益”的非线性映射交给机器学习模型学而不是让分析师手工指定线性关系。这也是“用机器学习方法构建多因子选股模型”最核心的动机。2.2 因子间的交互效应是线性加权最大的盲区传统打分模型还有一个天然缺陷因子之间是简单的加法关系没有交互项。这在两个维度上造成损失。第一是同逻辑因子互斥。比如低波动因子和红利因子它们捕捉的风险溢价来自类似来源线性加权会把两倍的同类风险暴露叠加起来组合的实际风格偏斜远大于预期。第二是条件性有效。某个因子在特定市场状态下有效比如换手率因子的有效性会被市场情绪放大或削弱。机器学习模型可以通过树结构的自动分裂把这种交互关系挖出来——比如“高换手 低波动 小市值”同时在某种条件下才成为正贡献组合这种模式靠手工构造交互项几乎无法完整枚举。我在实操里会把因子当作特征丢进树模型训练然后单独看模型学到的分裂结构。一个常见现象是模型的第一个有效分裂往往不是IC最高的因子而是能把样本分成两个收益分布差异最明显的因子——排序逻辑和线性IC逻辑在这里就有根本分歧。2.3 机器学习模型在排序任务上的优势不需要精确预测收益率做机器学习选股时模型输出的预测值本身不需要精确预测下期收益率是多少只要排序正确组合就能跑赢基准。这个任务和回归有区别也和分类有区别它本质上是排序学习问题。常见的做法是让模型学习截面上的相对收益排名。比如在每个调仓日把当期全部股票的收益转化成截面排名或者分成若干档作为标签。模型在训练时拟合的是排名而不是绝对收益好处是绝对值预测的偏差不会直接影响选股而且排名标签对极端值有天然鲁棒性。这也是我把XGBoost作为基准模型的原因——它对排序任务有很好的适应性能捕捉非线性又有足够的参数空间来处理过拟合。具体的模型选择和调参在后面章节展开这里先提示一个关键点不要追求回归器的低MSE要看验证集上的排序IC和分组收益差这两个指标才是选股模型真正关心的目标。3. 因子数据准备与预处理把面板数据喂给机器学习前的三个关键动作3.1 先给因子做去极值和标准化避免树模型被异常值带偏机器学习模型虽然比线性模型更容忍异常值但不代表可以不做预处理。以XGBoost为例树模型的分裂点选择依赖排序单个极端的因子值会拉松分裂区间让模型精度大幅下降。我见过有人直接把未处理的原始PE值丢进模型结果模型学出来的结构完全被市净率极低的资不抵债股带着走。我在预处理环节固定使用MAD中位数绝对偏差去极值再用分位数标准化。MAD方法比Z-score稳健因为它的中心是截尾均值而不是均值对长尾因子更安全。去极值之后再做一次横截面的标准化保证不同量纲的因子在模型中的可比性。下面是核心代码片段import numpy as np import pandas as pd def mad_winsorize(factor_series, n5): MAD去极值 factor_series: 某一期全市场的因子值序列 n: 偏离中位数的MAD倍数超过则截断 median factor_series.median() mad (factor_series - median).abs().median() if mad 0: # 防止常数值因子导致除零 return factor_series lower median - n * mad * 1.4826 upper median n * mad * 1.4826 return factor_series.clip(lower, upper) def zscore_std(factor_series): # 截尾后标准化保留训练集统计量以便推理时复用 mu factor_series.mean() std factor_series.std() if std 0: return factor_series * 0 return (factor_series - mu) / std参数n一般取3到5之间n越小截断越狠。我通常先用n5跑一版然后观察去极值后因子的IC是否比原始因子更稳定如果仍然剧烈波动再逐步下调到3。这里的1.4826是标准正态分布下MAD到标准差的换算系数并不是玄学。需要提醒的是去极值和标准化都必须是在横截面上进行也就是每一期单独算中位数、均值、标准差而不是把所有时间戳的数据混在一起算。跨时间混合会引入未来信息这在量化建模里属于严重的前视偏差。3.2 行业中性化与市值中性化让模型学真正的α不处理行业和市值暴露模型很容易偷懒学出“买小盘股”或“买某行业”这种简单规律。树模型一旦识别到市值因子和收益的相关性最强就会把它放在最重要的分裂位置其他真正有价值的因子被挤到深层节点几乎不参与决策。这个问题在传统线性打分模型里也存在但机器学习会把这种风格偏移放得更大。行业中性化的常见做法是回归取残差把原始因子值对行业哑变量或行业均值做线性回归取残差作为中性化后的因子值。市值中性化同理把因子对流通市值取对数做回归取残差。这里我是直接对每个横截面单独做回归确保中性化过程不泄露未来信息。import statsmodels.api as sm def neutralization(factor_df, ind_df, mv_df): 行业市值中性化按截面逐期处理 factor_df: 因子面板index为日期columns为股票代码 ind_df: 行业哑变量面板 mv_df: 流通市值面板 result pd.DataFrame(indexfactor_df.index, columnsfactor_df.columns) for dt in factor_df.index: y factor_df.loc[dt].dropna() y_index y.index X_industry ind_df.loc[dt].reindex(y_index).values X_mv np.log(mv_df.loc[dt].reindex(y_index)).values.reshape(-1, 1) X np.hstack([X_industry, X_mv]) X sm.add_constant(X) model sm.OLS(y.values, X).fit() # 残差即中性化后的因子值 result.loc[dt, y_index] model.resid return result这里的核心参数是回归模型的拟合方式我用的是普通最小二乘不设权重。注意不要在这个环节引入过多个性化参数比如加LASSO惩罚那会让不同期的因子处理标准不一致反而增加样本外的不确定性。做完中性化后再过一遍去极值标准化管线后面喂给模型的特征就干净多了。3.3 应用流程固定下来训练集、验证集、测试集的时序切分整个机器学习应用流程中最容易被新手忽略的是数据切分。多因子选股的面板数据是带时间索引的不能用随机切分的方式把某一天的样本一部分放训练集、另一部分放验证集——这会直接引入未来信息泄漏回测结果虚高到失真。我习惯按“时间点”顺序切分前70%的时间段做训练集中间15%做验证集用于调参最后15%做测试集用于最终评估。一个常用的比例是“训练集近五年验证集近一年测试集近一年”具体跨度取决于你手上的时间跨度但原则是先时间后随机。另一个细节是滚动切分调参时不要让验证集只有一段固定时间最好做3至5段滚动验证每一段都按时间顺序重新切一次把验证IC的平均值和波动率作为调参依据。这样选出的参数在多个市场状态下都稳定而不是只在特定行情里凑巧有效。4. 模型构建与调参从标签设计到XGBoost落地的完整训练管线4.1 标签定义用未来N日收益的分档替代连续回归值标签怎么设直接决定模型学出来的东西是什么。我第一版用的是未来20个交易日的收益率作为连续回归标签但效果很一般尤其是市场大幅波动时个别股票收益极端模型花大量容量去拟合那些极端样本排序精度反而下降。后来改成未来20日收益的横截面排名再转成类别标签训练效果明显改善。分档数量我常用5档每期把股票按未来收益排名等分为5组标签取0到4。这种离散化有两个作用第一是剔除精确预测收益率的伪目标避免极端值干扰第二是让模型的损失函数和“选股排序”这个实际目标对齐。分类概率输出的排序效果通常优于直接回归的数值排序。import pandas as pd import numpy as np def make_labels(ret_fwd, n_quantiles5): ret_fwd: 未来N日收益率面板, index为日期, columns为股票代码 返回每期按收益排名的类别标签 labels pd.DataFrame(np.nan, indexret_fwd.index, columnsret_fwd.columns) for dt in ret_fwd.index: row ret_fwd.loc[dt].dropna() if row.shape[0] n_quantiles: # 股票太少时跳过 continue # qcut 按每期截面分位切分 ranks pd.qcut(row.rank(methodfirst), n_quantiles, labelsFalse) labels.loc[dt, row.index] ranks return labels分档数的选择建议从5档起步。档位太小损失信息档位太大会导致每类样本数量不均衡训练不稳定。代码里用了rank(methodfirst)避免相同收益率导致qcut报错这是面板数据切分很容易踩的细节。4.2 训练样本的构建时间截断与样本权重样本权重在选股模型里很容易被忽略可它直接影响模型优化的重心。A股市场全市场约5000只股票每期都用全部股票做样本在不做处理的情况下模型会倾向于把更多容量分配给数量庞大的小市值股票——这和组合投资目标按权重选股并不完全一致。我通常在训练时做两个处理第一剔除上市不满一年的次新股和ST股票第二给不同股票设置权重例如以总市值的平方根作为权重相当于让大市值股票有更高的代表性。这不是标准答案但能明显改善模型在大市值股票上的排序效果。def build_training_panel(features, labels, maskNone, weight_by_mvNone): 构建训练样本的X和y features: 特征面板 3D: (日期, 股票, 因子) labels: 标签面板 mask: 布尔面板, 控制哪些样本参与训练 weight_by_mv: 市值权重, 用于调整样本权重 x_list, y_list, w_list [], [], [] for dt in features.index: X_t features.loc[dt] y_t labels.loc[dt].reindex(X_t.index) keep y_t.notna() X_t.notna().all(axis1) if mask is not None: keep keep mask.loc[dt].reindex(X_t.index).fillna(False) X_t X_t[keep] y_t y_t[keep] if X_t.shape[0] 0: continue x_list.append(X_t.values) y_list.append(y_t.values.astype(int)) if weight_by_mv is not None: mv weight_by_mv.loc[dt].reindex(X_t.index) w_list.append(np.sqrt(mv.clip(lower1e8)).values) # 避免过小权重 X_all np.vstack(x_list) y_all np.concatenate(y_list) w_all np.concatenate(w_list) if w_list else None return X_all, y_all, w_all这里特征的生成细节很关键所有因子必须是在当期截面已经结束信息的数据绝不能用未来若干个交易日的数据填充缺失值。市值权重取平方根是经验值目的是压缩权重差距又不至于让大市值股完全主导训练过程。4.3 XGBoost的五个必调参数与一组可复现的起始配置模型选择上我把XGBoost作为默认起步方案而不是直接用深度神经网络。原因很实际XGBoost需要的样本量小、调参维度相对可控、训练速度快而且在表格特征上的表现足够好。神经网络通常需要更多的数据和更长的调参周期等XGBoost跑通基准、确认因子的信息量足够之后再考虑用集成或神经网络来提升。参数配置中我用如下一组起点值大部分场景下这个配置能给出一个合理基准import xgboost as xgb params { objective: multi:softprob, # 输出5类的概率 num_class: 5, learning_rate: 0.05, # 偏低的学习率, 配合较多迭代树 max_depth: 4, # 防止单棵树过深捕获噪声 min_child_weight: 50, # 叶节点最小样本权重和, 防止碎片分裂 subsample: 0.8, # 样本采样比例, 增加鲁棒性 colsample_bytree: 0.7, # 特征采样比例, 防止单因子主导 eval_metric: mlogloss, tree_method: hist, # 直方图近似加速, 对大规模数据友好 } dtrain xgb.DMatrix(X_train, labely_train, weightw_train) dvalid xgb.DMatrix(X_valid, labely_valid, weightw_valid) bst xgb.train( params, dtrain, num_boost_round2000, evals[(dvalid, valid)], early_stopping_rounds100, # 验证集连续100轮不提升则停止 verbose_eval50, )这些参数不是凭感觉设的。learning_rate用0.05是平衡精度与速度的常用点配合最大2000轮迭代和早停机制模型会自动收敛到合适容量。max_depth4是为了抑制单棵树过度刻画局部样本金融数据的信噪比很低树太深几乎必过拟合。min_child_weight50是要确保每个叶子节点的样本权重和足够大防止模型为几十只股票学出独立规则。subsample和colsample_bytree分别做了样本和特征维度的随机采样即使用50个因子也能防止某个强因子把其他因子的贡献压死。4.4 全连接网络的对比什么情况下值得把XGBoost换掉XGBoost作为基准跑通之后如果测试集IC有一个相对稳定的提升空间再考虑神经网络。但不是所有数据都适合上神经网络。全连接网络需要样本量足够大一般至少要有几十万条有效样本否则过拟合风险远大于收益。若你手上的训练数据量在几十万这个量级可以用一个简单的三层MLP做对照用同样的训练集和验证集对比ICimport torch import torch.nn as nn class SimpleMLP(nn.Module): def __init__(self, input_dim, hidden128, num_class5): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, hidden), nn.BatchNorm1d(hidden), nn.ReLU(), nn.Dropout(0.3), # 随机丢弃抑制过拟合 nn.Linear(hidden, hidden // 2), nn.ReLU(), nn.Linear(hidden // 2, num_class), ) def forward(self, x): return self.net(x)这里dropout0.3和BatchNorm1d是两个关键点。Dropout让网络不能依赖单个特征组合BatchNorm则让每层输入分布稳定配合小学习率才能让训练平稳。如果发现MLP比XGBoost提升不到10%我通常不会在线上采用它——多出来的调参成本和训练时间不值得。5. 回测与常见问题排查让机器学习选股模型不翻车的五个检查点5.1 随机切分带来的前视偏差为什么验证集IC高出天际现象 模型的验证集IC高达0.15但实盘明显达不到甚至测试集上也开始衰减。原因 训练时用了随机切分把同一时间段内部分股票放进训练集、另一部分放进验证集。因子的填充值或行业均值在当期是全市场混算的验证集样本的因子值间接包含了同期的信息验证过程落入了“信息泄漏”的陷阱。解决 强制要求切分只按时间轴进行。把训练集、验证集、测试集对应的起止日期写死甚至可以把日期列表单独保存一份杜绝乱切。另一个检查方法是打印训练集和验证集的时间范围是否重叠如果重叠就说明切分逻辑有误。5.2 未剔除ST和停牌股导致模型学出“垃圾股偏好”现象 模型的预测排序打分中长期停牌复牌股经常被排到最前面组合持仓里出现大量基本面恶化的股票。原因 没有在训练和推理阶段统一剔除ST、*ST和停牌股票。机器学习模型不关心股票的基本面标签它只会注意到这类股票在某个时点收益异常高并把这种模式作为稳定的分裂规则记住。解决 在build_training_panel里加一列is_tradable的布尔标记。训练前剔除不可交易样本推理时同样剔除。这个逻辑必须同时在“训练侧”和“预测侧”生效否则模型会学到一套线上线下不一致的行为。5.3 因子填充方向错误缺失值用未来数据填充现象 模型训练时一切正常但换到最新行情数据后就频繁给出异常排序尤其在新上市股票上异常明显。原因 某个因子的缺失值是用未来一段时间的均值或中位数填充的。比如用下一周的行业均值填当前缺失值这在历史数据上可行但在实时滚动推理时未来的数据根本不存在模型等于被要求用“不存在的信息”做预测。解决 所有因子缺失值填充必须只用截面当期已发生的数据。最保守的做法是直接丢弃因子缺失率过高的股票不填充。对于关键因子的缺失可以用当期行业已实现部分的中位数填充绝对不能用未来窗口。5.4 早停轮数和学习率的组合不当导致模型训练不稳定现象 同一份数据、同样的参数跑两遍结果差异很大早停轮数设得很大验证集损失已经走平很久才停模型容量过大。原因early_stopping_rounds和learning_rate是一对联动参数。学习率太低时每轮提升幅度很小早停轮数反而被噪声波动骗到模型记忆了验证集噪声。解决 控制“有效迭代次数”在300到600轮之间是比较健康的范围。若超过800轮仍然没有早停把学习率从0.05提高到0.1或者把min_child_weight调大让分裂更保守。同时把seed固定下来让每次训练完全可复现再谈调参。5.5 回测指标只看IC忽略了换手率与交易成本现象 回测累计收益曲线很漂亮年化超额收益超过20%但实盘跑起来收益大幅缩水甚至亏损。原因 模型每个调仓日都会大幅调整持仓换手率极高。回测里不计交易成本和冲击成本高换手策略的所有优势被手续费和滑点吃掉了。解决 在回测中加入双边交易成本A股按双边千分之二到三估算再加上冲击成本。另外把“平均换手率”作为回测报告里的核心指标超过50%就要警惕。如果换手率高但IC稳定可以通过预测概率的置信度过滤减少交易只在模型预测最确定的股票上做调仓。6. 源代码与文档该如何组织让机器学习多因子项目真正可复现6.1 源码目录结构与阅读顺序先看数据、再看模型、最后看回测拿到一份多因子选股模型的源代码第一件事不是看训练代码而是按“数据→特征→模型→回测”的顺序理清依赖关系。我常用的目录组织方式分四大块数据脚本、特征脚本、模型脚本、回测脚本。这样的结构对新手友好也便于后续维护。quant_model/ ├── data/ │ ├── load_price.py # 行情数据加载与清洗 │ └── load_factor.py # 因子原始值加载 ├── features/ │ ├── preprocess.py # 去极值/标准化/中性化 │ └── factor_utils.py # 因子计算与衍生 ├── model/ │ ├── train.py # 模型训练与早停 │ ├── predict.py # 推理与打分 │ └── config.py # 所有参数配置集中管理 ├── backtest/ │ ├── run_backtest.py # 组合回测 │ └── evaluate.py # IC/分组收益/换手率统计 └── docs/ ├── README.md # 项目运行说明 └── model_card.md # 模型版本与效果说明我不建议把所有代码堆在一个notebook里哪怕训练过程在notebook里跑通了也要把核心逻辑拆成独立脚本。notebook适合探索脚本适合复现。config.py集中管理参数是这类项目里容易忽略但实际非常重要的部分——参数散落在各个脚本里调参时很容易改了A忘了B等发现IC已经下降才发现问题出在某个被遗忘的参数上。6.2 文档说明里必须写清楚的四类信息标题里带着“文档说明”说明一份正经的文档不只是代码注释而是要让另一个人能在不咨询你的情况下完整跑通项目。我在读过大量源码后的经验是文档必须覆盖四类信息。第一是数据来源包括原始数据的字段、时间跨度、复权方式。第二是运行环境包括Python版本、依赖库版本和安装命令。第三是运行顺序从数据下载到最后一步回测每一步需要哪些前置文件。第四是参数表每个核心参数的默认值、取值范围和调整方式。### 模型参数记录表 | 参数名 | 默认值 | 说明 | 常见调整方向 | |-----------------------|--------|------------------------------|----------------------------| | learning_rate | 0.05 | 每轮迭代步长 | 偏大加速收敛偏小更精细 | | max_depth | 4 | 单棵树的最大深度 | 树越深越容易过拟合 | | min_child_weight | 50 | 叶子节点最小样本权重和 | 值越大分裂越保守 | | subsample | 0.8 | 每轮迭代使用的样本比例 | 0.5~0.9之间调节 | | colsample_bytree | 0.7 | 每棵树使用的特征比例 | 因子越多可适当调小 |模型的每一次改动都要更新文档的版本记录包括训练时间范围、特征列表、参数和验证集指标。这些都是“后悔药”等三个月后回看同一个模型没有记录就等于没有做过。6.3 从复现到改造比跑通代码更重要的验证技巧源码在你手里跑通只是第一步。真正验证模型的稳健性还需要主动做三件事。第一是把训练时间段往前或往后平移半年重新训练一遍看模型效果是否剧烈变化。如果平移半年后IC衰减超过30%说明模型过度依赖特定行情阶段。第二是固定模型不动单独替换特征组合——把某个因子全部置零看模型输出的组合收益变化幅度识别模型的核心依赖因子。第三是把测试集中预测概率排名前1%和后1%的股票做一次人工审查确认模型没有在数据错误的股票上输出极端分数。我经历过最典型的教训是代码能跑通、回测也漂亮但把训练时间段平移后发现IC衰减严重。后来排查下来是某个技术指标因子在牛市后半段记录了过多的涨跌停样本模型恰好学会了“涨停次日的惯性”这种阶段性规律。停用该因子后模型效果回升原因就是数据切分逻辑和特征剔除规则重新做了对齐。这些验证动作不需要额外研发资源只是多花半天时间跑几个对比实验。但从“能跑”到“可信赖”差的恰恰是这一步。这个方向值得投入也值得把每一次实验细节记录下来。希望这些实战经验能帮你在机器学习多因子选股的路上少踩几个坑。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。