ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

机器学习多因子选股实战:从因子处理到组合构建全流程

2026/10/2 8:33:06 拓冰建站 浏览量
机器学习多因子选股实战:从因子处理到组合构建全流程 简介这份资源面向计算机、人工智能及金融工程方向的学生与量化爱好者提供一套基于机器学习方法构建多因子选股模型的完整项目源码与文档适合作为毕业设计参考或量化选股入门实战。压缩包共38个文件约14.71MB包含15个Python脚本、10份PDF研报、7张因子分类示意图以及md说明、docx文档、ipynb笔记与bat运行脚本等覆盖单因子测试、共线性分析、因子筛选到多模型回测的完整链路。项目实现了等权重线性模型与SVR、LSTM、XGBoost、随机森林、AdaBoost等多种baseline模型并记录回测结果与可视化分析最优随机森林模型累计收益约60%经择时风控后最大回撤控制在9%左右夏普率约0.9。目前已有464人学习下载代码均经测试运行成功配套研报与说明文档可帮助读者理解因子体系与建模思路快速复现并拓展自己的选股策略。1. 多因子选股遇上机器学习从因子暴露到组合权重的完整链路很多人第一次听到「基于机器学习方法构建多因子选股模型」脑子里浮现的是把几十个财务指标丢进模型、调个参、输出一份买入清单。真做过一轮就知道这条链路里最难的从来不是模型本身而是因子怎么对齐、标签怎么定义、回测怎么避免未来函数。我见过太多人拿着一个 AUC 0.75 的模型上线结果实盘跑不过沪深300问题几乎都出在数据管道而不是算法。这个方向适合两类人一类是已经会写 Python、懂点 pandas想把机器学习真正落到选股场景的量化新手另一类是手里有因子库、但一直用线性加权打分、想升级成非线性组合的从业者。它解决的核心问题是——在几十上百个因子里如何让模型自动学习因子之间的交互和时变权重而不是靠人拍脑袋定权重。下面这套流程从数据准备到组合构建每一步我都会给出可复现的代码和参数说明你照着跑一遍就能得到自己的选股信号。2. 因子与标签的工程化处理决定模型上限的一步2.1 因子池的构建与常见因子分类多因子模型的原料是因子。业内常见的因子分几大类估值类PE、PB、PS、成长类营收同比、净利润同比、质量类ROE、毛利率、资产负债率、动量类20日/60日收益率、波动类换手率、振幅、规模类总市值、流通市值。你不需要一上来就搞几百个因子先用 20 到 30 个覆盖这几大类跑通链路比堆因子重要得多。数据来源上常见做法是用 Tushare、AkShare 或者本地 Wind 导出。不管用哪个统一成一张宽表索引是「日期 股票代码」列是各因子值。下面是一个标准化的因子表构建示例import pandas as pd import numpy as np # 假设 raw 是从数据源拉取的原始数据columns 包含 date, code, pe, pb, roe, revenue_yoy 等 raw pd.read_csv(factor_raw.csv, parse_dates[date]) # 统一索引 raw raw.set_index([date, code]).sort_index() # 因子列清单 factor_cols [pe, pb, roe, revenue_yoy, profit_yoy, momentum_20d, momentum_60d, turnover_20d, amplitude_20d, total_mv] # 缺失值处理行业内中位数填充比全局均值更合理 def fill_by_industry(df, factor_cols, industry_colindustry): df[factor_cols] df.groupby([date, industry_col])[factor_cols].transform( lambda x: x.fillna(x.median()) ) return df raw fill_by_industry(raw, factor_cols)这段代码的关键点在于缺失值填充策略。直接用全局均值填充会引入行业偏差——银行股的 PE 和科技股的 PE 根本不在一个量纲上。按「日期 行业」分组取中位数能保留行业内部的相对关系。参数上industry列需要你提前映射好申万一级或中信一级都行粒度不要太细否则每组样本太少中位数不稳定。2.2 去极值与标准化让因子在同一尺度上说话原始因子值往往有极端值。比如 PE 可能出现负数亏损股或者几千倍的情况直接标准化会被极端值带偏。标准流程是三步去极值、标准化、中性化。去极值常用 MAD 法中位数绝对偏差比 3σ 法更稳健def winsorize_mad(series, n5): median series.median() mad (series - median).abs().median() upper median n * 1.4826 * mad lower median - n * 1.4826 * mad return series.clip(lower, upper) # 对每个交易日的每个因子做去极值 for col in factor_cols: raw[col] raw.groupby(date)[col].transform(winsorize_mad) # Z-Score 标准化 for col in factor_cols: raw[col] raw.groupby(date)[col].transform( lambda x: (x - x.mean()) / x.std() )n5是经验值意思是超过中位数 5 倍 MAD 的值被截断。这个参数不要设太小否则会把真实的极端收益信号砍掉也不要太大否则去极值没意义。标准化按日期分组做保证每天截面上因子均值为 0、标准差为 1这样不同日期的因子值才可比。注意市值因子和估值因子通常需要做行业中性化和市值中性化否则模型学到的可能只是「小市值涨得好」这个 beta而不是真正的 alpha。中性化用回归取残差的方式这里不展开但你在实盘前一定要做。2.3 标签定义未来收益怎么算才不踩未来函数标签就是模型要预测的目标。选股场景下标签通常是「未来 N 日收益率」。N 取 5、10、20 都有取决于你的调仓频率。周频调仓用 5 日月频用 20 日。关键坑在于计算未来收益时必须用「未来第 N 个交易日的收盘价 / 当前交易日收盘价 - 1」而且要对齐交易日历不能简单用自然日。# 假设 close 是收盘价宽表index 是日期columns 是股票代码 close raw[close].unstack() # 未来 5 日收益率 forward_ret_5d close.shift(-5) / close - 1 # 把标签合并回因子表 raw[label] forward_ret_5d.stack() raw raw.dropna(subset[label])shift(-5)是往未来取数这一步最容易出未来函数。如果你在计算因子时也用了shift(-n)那就彻底翻车了。因子只能用当前及历史数据标签才能用未来数据。另外dropna会丢掉最后 5 天的样本因为它们的未来收益还不知道这是正常的。3. 模型选型与训练从线性回归到梯度提升树3.1 为什么选树模型而不是深度学习多因子选股的数据有几个特点样本量中等A 股约 5000 只股票 × 250 个交易日 ≈ 125 万条、信噪比极低、因子之间存在非线性交互。深度学习在这个场景下往往过拟合而梯度提升树LightGBM、XGBoost对中等规模表格数据更稳训练快、可解释性也更好。我一般首选 LightGBM原因是它对缺失值天然友好、支持类别特征、训练速度比 XGBoost 快。如果你追求极致可解释性可以先跑一个线性回归做 baseline再用树模型对比提升幅度。如果树模型比线性模型提升不到 10%说明你的因子线性关系已经很强上树模型的性价比不高。3.2 训练集/验证集/测试集的时间切分绝对不能随机切分。金融数据有时间序列特性随机切分会导致未来信息泄露。正确做法是按时间切前 70% 做训练中间 15% 做验证最后 15% 做测试。from sklearn.model_selection import train_test_split import lightgbm as lgb # 按日期排序 dates raw.index.get_level_values(date).unique().sort_values() train_end dates[int(len(dates) * 0.7)] valid_end dates[int(len(dates) * 0.85)] train raw[raw.index.get_level_values(date) train_end] valid raw[(raw.index.get_level_values(date) train_end) (raw.index.get_level_values(date) valid_end)] test raw[raw.index.get_level_values(date) valid_end] X_train, y_train train[factor_cols], train[label] X_valid, y_valid valid[factor_cols], valid[label] X_test, y_test test[factor_cols], test[label] # LightGBM 参数 params { objective: regression, metric: mse, learning_rate: 0.05, num_leaves: 31, max_depth: 6, min_data_in_leaf: 100, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 5, lambda_l2: 1.0, verbose: -1 } dtrain lgb.Dataset(X_train, y_train) dvalid lgb.Dataset(X_valid, y_valid, referencedtrain) model lgb.train( params, dtrain, num_boost_round1000, valid_sets[dvalid], callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)] )参数说明num_leaves31和max_depth6是控制模型复杂度的核心叶子太多必然过拟合。min_data_in_leaf100保证每个叶子至少有 100 个样本金融数据噪声大这个值可以设到 200 甚至 500。lambda_l21.0是 L2 正则抑制权重过大。early_stopping(50)表示验证集 50 轮不提升就停这是防过拟合的第一道闸。3.3 特征重要性与因子筛选训练完看特征重要性把重要性接近 0 的因子删掉再重训。LightGBM 提供feature_importance接口importance pd.DataFrame({ factor: factor_cols, gain: model.feature_importance(importance_typegain) }).sort_values(gain, ascendingFalse) print(importance)gain比split更可靠因为它衡量的是该因子带来的总增益而不是被分裂的次数。如果某个因子 gain 占比低于 1%可以考虑剔除。但注意因子重要性低不代表没用——可能是它和另一个因子高度相关信息被抢走了。删因子之前先看相关性矩阵。4. 回测与组合构建模型输出怎么变成持仓4.1 从预测值到打分排序模型输出的是每只股票的预测收益率。每天截面上按预测值从高到低排序取前 10% 或前 50 只作为候选池。但直接取 Top N 会有行业集中风险常见做法是行业内选股每个行业内取预测值前 20%再等权或按市值加权。# 每日截面上按预测值排序 test test.copy() test[pred] model.predict(X_test) # 行业内排序取前 20% def select_top_by_industry(df, pred_colpred, industry_colindustry, top_pct0.2): df[rank] df.groupby([date, industry_col])[pred_col].rank(pctTrue) return df[df[rank] 1 - top_pct] selected select_top_by_industry(test)rank(pctTrue)返回的是百分位排名 0.8就是前 20%。这样每个行业都有持仓避免全押在一个赛道上。4.2 回测框架与绩效指标回测要算清楚手续费、滑点、停牌。简化版可以用 pandas 手写但更推荐用 vectorbt 或 backtrader。这里给一个最小回测逻辑# 假设 selected 里有 date, code, pred # 计算每日持仓收益 selected[weight] selected.groupby(date)[pred].transform(lambda x: x / x.sum()) # 下一天的收益 next_ret forward_ret_5d.shift(-1).stack().rename(next_ret) selected selected.join(next_ret, on[date, code]) daily_ret selected.groupby(date).apply( lambda x: (x[weight] * x[next_ret]).sum() ) # 年化收益、夏普、最大回撤 annual_ret daily_ret.mean() * 252 annual_vol daily_ret.std() * np.sqrt(252) sharpe annual_ret / annual_vol cum_ret (1 daily_ret).cumprod() max_dd (cum_ret / cum_ret.cummax() - 1).min() print(f年化收益: {annual_ret:.2%}, 夏普: {sharpe:.2f}, 最大回撤: {max_dd:.2%})这段代码里forward_ret_5d.shift(-1)是为了对齐——今天收盘选出的持仓收益从明天开始算。手续费按双边千三扣滑点按千一扣这些都要在daily_ret里减掉。如果回测夏普超过 2先别高兴大概率有未来函数或者过拟合。4.3 换手率控制与交易成本估算高频调仓的模型回测好看实盘会被交易成本吃光。周频调仓的换手率通常在 50% 到 100% 之间月频在 20% 到 40%。控制换手的方法有两种一是调仓时只替换排名变化大的股票二是加持有期惩罚。# 简单换手控制新持仓与旧持仓取交集优先 def control_turnover(old_holdings, new_candidates, max_turnover0.5): keep set(old_holdings) set(new_candidates) add list(set(new_candidates) - keep) n_add int(len(old_holdings) * max_turnover) return list(keep) add[:n_add]max_turnover0.5表示每次最多换掉一半持仓。这个参数需要根据你的资金规模和手续费谈判资金量大就设低一点。5. 避坑与排查那些让回测和实盘对不上的细节5.1 现象回测夏普 3.0实盘跑不过指数原因最常见的是未来函数。检查三处——因子计算有没有用shift(-n)、标签对齐有没有错位、标准化有没有用全样本统计量。全样本标准化是隐蔽的未来函数因为你在计算某天的因子 Z-Score 时用到了未来数据的均值和方差。解决所有标准化、去极值必须按日期分组做或者用扩展窗口expanding window。5.2 现象模型在验证集表现好测试集突然变差原因过拟合。树模型叶子太多、训练轮数太多、因子太多都会导致。解决把num_leaves降到 15 以下min_data_in_leaf提到 200 以上early_stopping设到 30。另外检查因子数量如果因子数超过样本数的十分之一就要做因子筛选。5.3 现象每天选出的股票和昨天几乎一样原因因子更新频率太低或者标签周期太长。如果你用的是季度财务因子那每天的变化确实很小。解决加入日频量价因子动量、换手率、振幅或者缩短标签周期到 5 日。但注意标签周期越短信噪比越低模型越难学。5.4 现象回测里某些股票收益异常高原因停牌股、ST 股、次新股没过滤。停牌期间价格不变复牌后补跌回测会高估收益。解决在选股前过滤掉停牌、ST、上市不满 60 天的股票。代码里加一行raw raw[~raw[is_st] ~raw[is_suspended]]就行。5.5 现象LightGBM 训练报错「Label must be in [0, 1] for binary classification」原因你把objective设成了binary但标签是连续收益率。解决选股是回归问题objective用regression或huber。如果你非要做分类先把收益率离散化成涨/跌两类但回归通常效果更好。6. 进阶技巧用滚动训练和模型集成稳住实盘表现模型上线后最大的敌人是市场风格切换。2021 年有效的因子2023 年可能完全失效。解决办法是滚动训练每季度用最近 3 年的数据重新训练一次模型而不是用固定训练集。def rolling_train(raw, factor_cols, train_years3, retrain_freqQ): dates raw.index.get_level_values(date).unique().sort_values() models {} for i, date in enumerate(dates): if i % 60 ! 0: # 每 60 个交易日重训一次 continue train_start date - pd.DateOffset(yearstrain_years) train_data raw[(raw.index.get_level_values(date) train_start) (raw.index.get_level_values(date) date)] if len(train_data) 10000: continue X train_data[factor_cols] y train_data[label] model lgb.train(params, lgb.Dataset(X, y), num_boost_round500) models[date] model return modelstrain_years3是经验值A 股一轮风格周期大约 2 到 3 年。retrain_freq用 60 个交易日约一季度比较平衡太频繁训练成本高太慢跟不上风格切换。另一个技巧是模型集成同时训练 LightGBM、XGBoost 和 Ridge 回归把三个模型的预测值做 rank 平均。这样单模型过拟合的风险会被摊薄。我自己的习惯是实盘信号必须至少两个模型同时看多才买入虽然会错过一些机会但回撤明显更小。最后说一个验证方法把你的模型信号和常见因子比如 20 日动量做相关性分析。如果相关性超过 0.7说明你的模型没学到新东西只是动量的代理。真正有价值的模型应该和单因子保持中等偏低的相关性同时在多空收益上有独立贡献。这套流程我前后迭代了两年最大的教训是不要迷信模型复杂度数据质量和标签定义决定了 80% 的效果。先把因子对齐和回测框架搭对再谈调参和集成。希望帮到你。本文还有配套的精品资源点击获取