简介:这份资源面向计算机、人工智能及金融工程方向的学生与量化爱好者,提供一套基于机器学习方法构建多因子选股模型的完整项目源码与文档,适合作为毕业设计参考或量化选股入门实战。压缩包共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_col="industry"): 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, n=5): 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() )n=5是经验值,意思是超过中位数 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, reference=dtrain) model = lgb.train( params, dtrain, num_boost_round=1000, valid_sets=[dvalid], callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)] )参数说明:num_leaves=31和max_depth=6是控制模型复杂度的核心,叶子太多必然过拟合。min_data_in_leaf=100保证每个叶子至少有 100 个样本,金融数据噪声大,这个值可以设到 200 甚至 500。lambda_l2=1.0是 L2 正则,抑制权重过大。early_stopping(50)表示验证集 50 轮不提升就停,这是防过拟合的第一道闸。
3.3 特征重要性与因子筛选
训练完看特征重要性,把重要性接近 0 的因子删掉再重训。LightGBM 提供feature_importance接口:
importance = pd.DataFrame({ "factor": factor_cols, "gain": model.feature_importance(importance_type="gain") }).sort_values("gain", ascending=False) 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_col="pred", industry_col="industry", top_pct=0.2): df["rank"] = df.groupby(["date", industry_col])[pred_col].rank(pct=True) return df[df["rank"] >= 1 - top_pct] selected = select_top_by_industry(test)rank(pct=True)返回的是百分位排名,>= 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_turnover=0.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_turnover=0.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_years=3, retrain_freq="Q"): 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(years=train_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_round=500) models[date] = model return modelstrain_years=3是经验值,A 股一轮风格周期大约 2 到 3 年。retrain_freq用 60 个交易日(约一季度)比较平衡,太频繁训练成本高,太慢跟不上风格切换。
另一个技巧是模型集成:同时训练 LightGBM、XGBoost 和 Ridge 回归,把三个模型的预测值做 rank 平均。这样单模型过拟合的风险会被摊薄。我自己的习惯是,实盘信号必须至少两个模型同时看多才买入,虽然会错过一些机会,但回撤明显更小。
最后说一个验证方法:把你的模型信号和常见因子(比如 20 日动量)做相关性分析。如果相关性超过 0.7,说明你的模型没学到新东西,只是动量的代理。真正有价值的模型,应该和单因子保持中等偏低的相关性,同时在多空收益上有独立贡献。
这套流程我前后迭代了两年,最大的教训是:不要迷信模型复杂度,数据质量和标签定义决定了 80% 的效果。先把因子对齐和回测框架搭对,再谈调参和集成。希望帮到你。
本文还有配套的精品资源,点击获取