☰
机器学习多因子选股:从因子工程到回测避坑的完整工程实践
2026/10/14 11:49:30 网站建设 项目流程

简介:基于机器学习方法构建多因子选股模型的完整实践项目,面向量化金融、金融工程方向的在校学生与开发者,尤其适合作为毕业设计或课程大作业的参考方案。项目围绕单因子测试、因子共线性分析、机器学习模型回测与择时策略展开,覆盖随机森林、支持向量回归、长短时记忆网络、梯度提升树等多种模型的构建与对比,并提供了数据探索、特征标签构建、回测结果记录与可视化的全套代码。最优随机森林模型可实现约60%的累计收益,经择时策略风险控制后最大回撤约9%,夏普比率约0.9。压缩包共38个文件,以Python脚本为主,辅以PDF研究报告、PNG可视化图表、Markdown说明文档、Jupyter Notebook等,整体大小约14.71MB,目录结构清晰,按模型、因子测试、报告等模块分类存放,目前已有468人学习下载。借助这套资料,读者可获得可运行的源代码、因子筛选与风险控制思路,以及从数据探索到回测分析的完整实操经验。

1. 机器学习多因子选股:把“因子失效”变成可迭代的工程问题

传统多因子选股最头疼的不是因子不够,而是权重拍脑袋。估值、成长、动量、质量每个维度都有道理,可一旦换了一段行情,手工配的权重立刻失效,IC(信息系数)从 0.05 掉到 0.02 甚至转负,你还说不清是哪一步出了问题。把多因子选股交给机器学习模型,本质上是把“因子怎么组合”这个黑匣子从人脑挪到算法里,让模型自己学因子之间的非线性关系。但这也带来了新问题:模型在训练集上 IC 高达 0.1,换到验证集立刻崩盘。这篇文章要讲的就是怎么用机器学习把多因子选股的流程搭成一套可迭代、可验证、可追踪的工程方案,从因子构建、模型选型、回测设计到排错兜底,所有坑都摊开说。适合有 Python 基础、正在从传统打分法往机器学习迁移的从业者。

2. 因子工程这一步做到位,模型才有东西可学:数据源、因子构建与清洗

2.1 数据源与最小字段集:别一上来就接高频

机器学习多因子模型的地基不是模型,是数据。我见过太多人上来就抓了 50 个因子、三年日线数据,跑出一个漂亮回测,结果复盘时发现复权因子算错了。常见做法是先用日线级别的行情数据和基本面数据把流程跑通,再考虑扩到分钟级。最小字段集其实很朴素:股票代码、交易日、收盘价(前复权)、总市值、流通市值、市盈率、市净率、ROE、营收同比增速、近 60 日收益率、近 60 日换手率均值。字段再少模型学不到东西,再多容易引入前视偏差。

我自己一般把数据切成三张表:价格表、基本面表、衍生因子表。价格表负责算动量、波动率、换手率;基本面表管 PE、PB、ROE、增速;衍生因子表是前两张表加工后的结果,也是真正喂给模型的特征。这样做的理由是当你想加一个新因子时,不用重跑全流程,只需要在衍生因子表里加一列,训练代码一行不用改。

2.2 三类常用因子怎么构建:动量、估值与质量

机器学习模型不挑食,但因子本身要有经济学含义,否则模型学到的是噪声。动量因子常见做法是取过去 20 日和 60 日的收益率差值,反映中期趋势延续;估值因子用 PE 的倒数(EP)而不是 PE 本身,因为 EP 接近正态分布,树模型虽然不敏感,但线性模型非常依赖这种分布形态;质量因子用 ROE 减去一个行业基准,做行业中性化处理,避免模型把行业偏见当成 alpha。

import pandas as pd import numpy as np # price_df: columns = [code, date, close, pre_close, volume] # factor_df: 输出因子表 def build_momentum(price_df, window=20): price_df = price_df.sort_values(["code", "date"]) price_df["ret"] = price_df["close"] / price_df["pre_close"] - 1 price_df["mom_20"] = ( price_df.groupby("code")["close"].pct_change(window) ) price_df["mom_60"] = ( price_df.groupby("code")["close"].pct_change(60) ) price_df["mom_diff"] = price_df["mom_20"] - price_df["mom_60"] return price_df[["code", "date", "mom_20", "mom_60", "mom_diff"]]

这段代码里用pct_change(window)直接算区间收益率,比shift(window) / shift(1) - 1更简洁,但要注意pct_change默认会跳过 NaN,金融数据停牌日会留下空洞,后面需要统一填充。mom_diff度量短中期动量差值,在 A 股这种散户占比高的市场里有明显反转效应,模型会自己决定该不该用这列,你不需要手工去线性组合它。

2.3 去极值、标准化与缺失值处理:三个默认参数不能省

因子数据进模型之前必须做三步:去极值、标准化、缺失值填充。去极值我常用 MAD 法而不是 3σ 法,因为因子分布通常有厚尾,标准差本身会被极端值污染。MAD 的阈值取 3.5 倍,超过的部分直接截断到边界值。标准化用 z-score 足够,但要注意是按截面(同一天所有股票)做,而不是按时间序列做。按时间标准化会把未来信息泄漏进历史,这是新手最容易踩的坑。缺失值用截面中位数填充,因子缺失率超过 30% 的股票直接剔除,不要硬填。

def mad_winsorize(factor_series, n=3.5): median = factor_series.median() mad = (factor_series - median).abs().median() upper = median + n * mad lower = median - n * mad return factor_series.clip(lower, upper) def cross_sectional_zscore(factor_df, date_col, factor_col): grouped = factor_df.groupby(date_col)[factor_col] return (factor_df[factor_col] - grouped.transform("median")) / grouped.transform("std")

mad_winsorize里的n=3.5是经验值。你可以在回测里试 3.0 和 4.0,差异通常不大,但 3.5 在大多数股票池里能保留 98% 以上的有效样本。cross_sectional_zscore的groupby(date_col)是关键,它确保每一天的因子分布都是均值 0、方差 1,这样模型不会学到“某个月因子整体偏大”这种伪规律。

3. 从线性到树模型再到集成:选模型的判断标准不是“越复杂越好”

3.1 基准模型:线性回归与它的三个天花板

机器学习多因子项目里,线性回归不是拿来直接用的,是拿来当基准线、当质检员用的。为什么?因为线性模型给出了 IC 的下限参考:如果线性回归在验证集上 IC 是 0.03,而某个复杂模型跑出 0.08,你至少知道提升空间确实存在;如果线性回归 IC 是 0.05,复杂模型只有 0.055,那说明因子本身的信息量就到这了,换模型没用,该回去补因子。线性回归的第二个用处是看因子方向:正系数代表正向驱动,负系数代表反向,这个能帮你发现因子符号是不是和经济学直觉反了。第三个用处是诊断多重共线性,比如动量 20 日和动量 60 日相关系数经常超过 0.7,线性回归的系数会被这种共线性扯得极不稳定。

我一般用岭回归而不是普通最小二乘,因为因子之间相关性强,岭回归的 L2 惩罚能压住系数抖动。正则化系数 alpha 从 0.01 起步,用验证集 IC 选,别用训练集 R² 选,那是自欺欺人。线性模型的预测值会有明显的截面偏差,所以预测值本身不能直接当仓位,要转换成截面排名或分位数,这就是后面回测里要处理的事。

3.2 树模型为什么是中场主力:非线性、交互作用与缺失值容忍

随机森林和 XGBoost、LightGBM 这类树模型是当前多因子选股的实战主力。原因有三个:第一,因子和未来收益的关系普遍是非线性的,比如市值因子在小盘股区间有效、大盘股区间钝化,线性模型永远拟合不出这种分段关系;第二,树模型天然处理因子之间的交互作用,比如“高动量 + 低估值”的组合在特定行情下才有超额收益,这种交互项你手工构造非常麻烦,树模型分裂时自动做了;第三,树模型对缺失值有原生处理路径,不需要在训练之前把所有缺失都填得干干净净。

但树模型也有两个致命毛病。一是外推能力为零,因子值超出训练集范围时,预测值直接饱和在训练集边界上,所以必须确保训练集覆盖完整的市场状态,比如包含 2018 年的熊市和 2020 年的白马行情。二是树模型对因子重要性有偏,XGBoost 的gain指标偏向于取值多的连续因子,分箱少的哑变量因子容易被低估,这个后面单独讲怎么修正。

3.3 神经网络的适用边界:什么时候值得上深度模型

经常有人问我:“能不能直接上 LSTM 或者 Transformer?”我的回答是先把树模型跑稳,再考虑深度模型。神经网络的价值在于学极复杂的非线性关系,但这个能力在我们这个场景里容易变成过拟合加速器。我的判断标准是三条:样本量是否够大(股票池 x 年份 x 252 个交易日,至少要有 30 万条样本);因子之间的关系是否确实复杂到树模型扛不住(拿验证集 IC 对比,差值小于 0.01 就不值得);回测基础设施是否支持滚动训练(神经网络要频繁重训,时间成本是树模型的 5 到 10 倍)。三条都满足,才值得上 MLP 或 LSTM;否则就是给回测曲线加滤镜。

真到了那一步,我的常见做法是先用 MLP 试水而不是直接 LSTM。MLP 在截面数据上足够,而且稳定很多。网络结构不要深,三到四层、每层 64 到 128 个神经元起步,加 Dropout 0.2 和 BatchNorm。LSTM 适合带时序依赖的因子场景,但金融数据本身的信噪比太低,时序依赖很容易被噪声淹没,需要非常谨慎的验证设计才能确认收益是真实的。

4. 训练与回测的最小闭环:一份可以直接改着跑的代码与参数说明

4.1 训练集、验证集、测试集怎么切:时间序列不能随机打散

机器学习多因子选股里,数据切分是决定成败的一步。普通机器学习里随机打散数据集没问题,但金融数据有强时间相关性,随机打散会把未来信息泄漏进训练集。正确做法是按时间顺序切分,比如用 2020 年至 2022 年做训练集,2023 年做验证集,2024 年做测试集。训练集内部也建议留出最后一小段做早停监测,而不是直接在验证集上早停,否则验证集也被污染了。

另一种常见做法是滚动切分,比如每 3 年训练、1 年验证、滑窗前进,这样能模拟真实交易中的模型更新节奏。但这种做法成本高,初期不建议一上来就滚动。先把单次切分的流程跑通,确认每一个环节没有泄漏,再上滚动。

4.2 完整训练代码:从因子表到预测值的全流程

import pandas as pd import numpy as np from lightgbm import LGBMRegressor from sklearn.metrics import mean_squared_error # feature_df: 因子表, 含 code, date, label 列 # label 构造: 未来 20 日收益率(已做行业中性化) def train_model(feature_df, feature_cols, train_end, valid_start, valid_end): train_df = feature_df[feature_df["date"] <= train_end] valid_df = feature_df[ (feature_df["date"] >= valid_start) & (feature_df["date"] <= valid_end) ] X_train = train_df[feature_cols].values y_train = train_df["label"].values X_valid = valid_df[feature_cols].values y_valid = valid_df["label"].values model = LGBMRegressor( n_estimators=500, learning_rate=0.03, num_leaves=31, max_depth=6, subsample=0.8, colsample_bytree=0.8, reg_alpha=0.1, reg_lambda=0.5, random_state=42, ) model.fit( X_train, y_train, eval_set=[(X_valid, y_valid)], eval_metric="rmse", callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)], ) valid_pred = model.predict(X_valid) ic = np.corrcoef(valid_pred, y_valid)[0, 1] return model, ic, valid_pred

这段代码里n_estimators=500配合early_stopping(50),意思是树最多 500 棵,但验证集 RMSE 连续 50 轮不下降就提前停。learning_rate=0.03不能太大,金融数据噪声高,学习率一大树模型疯狂拟合噪声。subsample=0.8和colsample_bytree=0.8是防止过拟合的双保险。reg_alpha和reg_lambda的正则是最后一道防线,我在因子多的时候会把reg_alpha提到 0.5,因子少的时候降回 0.05。

4.3 回测评价:IC、ICIR 与分组收益,三个指标一起看

只有 IC 是不够的。IC 反映的是预测值和实际收益的截面相关性,但选股要的是前 20% 的组合能不能跑赢基准。所以我同时看三个指标:IC 均值(月频截面相关系数的平均值)、ICIR(IC 均值除以 IC 标准差,衡量稳定性)、分组收益(按预测值从高到低分成十组,多空组合收益)。分组收益是最直观的:如果第一组到第十组有严格的单调性,模型才是真的学到了东西;如果只有两端有收益、中间乱跳,说明模型只学会了选极端股,这种信号实盘噪音极大。

我不建议一开始就写完整回测引擎,浪费大量时间在处理停牌、涨跌停、滑点上。可以用最朴素的日频调仓加简单收益率累加来评估,确认模型方向对了,再补交易细节。真正上了模拟盘之后,再迭代滑点、手续费、涨跌停过滤这些细节。

5. 多因子模型翻车避坑:过拟合、幸存者偏差与数据泄漏的排查清单

5.1 训练集 IC 0.12、验证集 IC 0.005:过拟合的三个信号与对应解法

现象:训练集 IC 高得惊人,验证集 IC 接近零,预测值在训练集和验证集上的分布差异巨大。原因:模型把训练集里的个股噪声当成了规律。树模型尤其容易这样,因为它能无限分裂去贴合训练样本。另外因子数量如果超过 30 个,而有效样本不到 5 万条,过拟合几乎是必然的。解决办法从三个方向同时做:减少因子数量(用特征重要性做一轮筛选,相关性大于 0.7 的因子只保留一个),增强正则(num_leaves从 63 降到 15,min_child_samples提到 50),以及加入早停机制。我见过最离谱的一次,训练集 IC 0.15、验证集 0.002,把因子从 48 个删到 17 个之后,验证集 IC 回到 0.045。

5.2 回测收益 30%,实盘一塌糊涂:幸存者偏差到底藏在哪里

现象:回测曲线漂亮得像印钞机,实盘第一周就吃瘪。原因:股票池只用了当前还存在、且没有退市的股票,历史上曾经大涨后退市的股票根本没出现在数据里。这就是幸存者偏差。解决办法只有一个:回测数据必须包含“当时点存在”的全量股票列表和退市股票的完整生命周期数据。我在数据接入时特意检查了退市股的行情是否完整,这一步不能省。还有一些隐蔽的幸存者偏差藏在因子计算里:比如只用有基本面数据的股票,ST 股被提前剔除等,这属于选择偏误,需要在因子表里加上股票状态标记,回测时按当时的状态过滤。

5.3 用未来函数而不自知:三种隐蔽的前视偏差

现象:验证集 IC 稳定在 0.08 以上,但换了一个数据源就跌到 0.03,排除数据质量问题后还是不对。原因:很可能用了未来函数。最常见的三种:一是用当天收盘价计算因子,却用当天收盘价作为买入价,成交不了;二是基本面数据没有做季度披露日期对齐,用了“未来才知道”的报告期数据;三是在截面标准化时用了全样本的均值和标准差,把未来信息偷偷放进去了。解决方法是建立点对点的对齐检查,写一段校验代码,逐因子检查因子值在时间维度上是否只依赖过去信息。第四种更隐蔽:有时候我用全部股票做截面 z-score,但实盘买入只能在涨停打开的那部分股票里选,等于用了一个未来才能确认的子集。正确做法是标准化时用可以交易的股票池,至少标记出来。

6. 让模型在新行情里仍能打:特征重要性、滚动训练与融合的进阶技巧

6.1 特征重要性不能只看 Gain:用置换重要性做二次校验

XGBoost 和 LightGBM 的feature_importance("gain")会被高基数的连续因子主导。我一般会同时计算置换重要性:把验证集上某一列因子随机打乱,观察模型预测 IC 的下降幅度。下降越多说明依赖越强。这一步能帮你发现那些 Gain 不高但实际是核心驱动力的因子。在我自己的项目里,换手率因子 Gain 排第 9,但置换重要性排第 2,差距非常大。两个重要性排名综合之后,再做一轮因子筛选再训练,最终验证集 IC 稳定提升了 0.008 左右。

def permutation_importance(model, X_valid, y_valid, feature_cols, n_repeats=5): base_ic = np.corrcoef(model.predict(X_valid), y_valid)[0, 1] result = {} for col in feature_cols: col_idx = feature_cols.index(col) score_drops = [] for _ in range(n_repeats): X_permuted = X_valid.copy() rng = np.random.default_rng(42) X_permuted[:, col_idx] = rng.permutation(X_permuted[:, col_idx]) perm_ic = np.corrcoef(model.predict(X_permuted), y_valid)[0, 1] score_drops.append(base_ic - perm_ic) result[col] = np.mean(score_drops) return result

这里n_repeats=5是让重要性估计更稳定,因为单次打乱会受到随机种子影响。注意打乱是要在验证集上做,不要在训练集上做,否则你只是在测模型记忆训练数据的能力。置换重要性的绝对数值不重要,重要的是排名和方向。如果一个因子打乱后 IC 反而上升了,说明模型在用这个因子的随机噪声做预测,这个因子果断删掉。

6.2 滚动训练:怎么设置再训练频率才不算浪费

市场风格切换之后,静态模型迟早会失效。常见做法是月度再训练或季度再训练:每个月底用过去三年的数据重新训练,在第二个月用它做预测。滚动训练的成本主要不是训练时长,而是你的数据管线是否自动化。LightGBM 训练一次大约一两分钟,真正的瓶颈是因子表更新、清洗、去极值、标准化这整条链路。我一般把这条链路的调度周期设成每周五晚上跑一次,月度更换模型权重,周度只更新因子数据。这样模型参数保持着月度节奏更新,且预测时总是用最新数据。

滚动训练有一个重要陷阱:不要每次都重新调参。我在实践中吃过亏,因为某个月验证集 IC 不佳,就去调学习率、调叶子数,结果模型在当月表现变好,次月又变差。原因是短期波动被当成了调参信号。正确做法是先固定一组超参数,连续滚动三到四个月,确认 IC 的均值水平之后再微调;否则你的调参过程本身就在拟合某一段特定行情。

6.3 模型融合的半正确姿势:等权平均比加权更稳

把 LightGBM、随机森林和一个简单线性模型的预测值做等权平均,通常比任何单个模型都稳。加权平均也可以用,但权重不能只在训练集上算。我自己的经验是做一个样本外的权重搜索:用验证集前一季度的数据算每个模型单独的 IC,以这个 IC 的相对大小作为权重,然后在当前验证集上检验。等权平均看起来笨,但它不会在某个模型过拟合时被带偏。还有一点:分组收益不是只看多空组合,还要看第一组和第十组成员是不是在频繁换手。我要求第一组的月度换手率不超过 80%,否则大量交易在吃掉收益,模型学到的是追涨杀跌而不是真正的 alpha。我的习惯是每次更新权重之后,跑一遍最新三个月的真实模拟盘数据,和回测结果对比 IC 衰减幅度,如果超过 30%,就回头查因子和模型,而不是延长再训练周期。这套流程跑了两个完整的市场风格切换区间,验证集 IC 仍然稳定在 0.03 到 0.05 的区间内,虽然不是超高收益,但至少我知道这个数来自模型真实的预测能力,而不是数据泄漏或过拟合的幻觉。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询