机器学习驱动的基本面量化选股:从因子工程到多模型回测实战
2026/9/24 21:56:21 网站建设 项目流程

简介:基于Python的机器学习驱动的基本面量化投资研究项目,面向金融工程、量化投资及机器学习交叉领域的开发者与学生,提供一套完整可运行的源码与配套数据集。项目整合多种机器学习算法,围绕公司财务基本面构建选股与预测模型,涵盖数据预处理、特征工程、模型训练与评价等环节,适合希望系统掌握量化因子分析、模型训练与回测流程的进阶学习者。压缩包共183个文件,以167个CSV数据文件、11个Python脚本、3个PDF文档为主,另含Markdown说明与gitignore配置项,整体大小145.6MB,目录结构清晰,便于按数据、模型、文档分类查阅。数据集中包含ROA、ROE、LM、rd_mve、NOA、tang、CT等财务因子,覆盖盈利能力、运营效率、估值水平等多维度指标,可支撑多角度实证研究;源码均经本地编译可运行,评审分达95分以上,并由助教老师审定,难度适中,既可用于课程设计、论文复现,也可作为入门量化投资的实战参考。目前已有309人学习下载,具有较高参考价值。

1. 把 ROE、研发强度这些会计因子丢进 XGBoost,真能选出下一季度跑赢指数的股票吗?答案是能,但前提是你把数据坑填平。这份基于 Python 的机器学习驱动的基本面量化投资研究项目源码加数据集,不是那种只丢给你一堆跑不出结果的半成品——它自带八张面板数据表,覆盖 ROA、ROE、研发销售比、净经营资产、现金转换周期等经典会计因子,配套随机森林、梯度提升等多种机器学习算法的完整实现,源码本地编译可运行,评审分在 95 分以上。适合正在做量化课程设计、毕业设计,或者想从零复现一篇多因子选股论文的在校生和从业者。我拆这套项目时最大的感受是:模型部分其实不太难,真正卡人的全是数据对齐、时点切分这些脏活。

2. 数据因子工程:把八张 CSV 拼成模型吃得下的训练集

拿压缩包先别急着跑 train.py。这套项目的数据组织方式是按「股票 × 报告期」展开的面板数据,每张 CSV 对应一个会计因子。你要做的第一件事,是把它们拼成一张宽表,再做缩尾、标准化和标签构造。这一步处理得好不好,直接决定后面模型分数是 0.3 还是 0.03。

2.1 先弄明白每一列在算什么

资源里的文件命名规则不算直白,但按会计逻辑去猜基本八九不离十。我做因子拆解时对照了常见的基本面因子口径,整理成下面这张表:

文件因子含义常用计算口径
69ROA.csv总资产收益率净利润 / 总资产
68ROE.csv净资产收益率净利润 / 股东权益
17LM.csv杠杆率总负债 / 总资产
76RDsale.csv研发强度研发费用 / 营业收入
75rd_mve.csv研发市值比研发费用 / 期末总市值
65NOA.csv净经营资产经营资产 − 经营负债,衡量应计异象
94tang.csv有形资产占比(总资产 − 无形资产 − 商誉) / 总资产
70CT.csv现金转换周期存货周转天数 + 应收周转天数 − 应付周转天数

提示:不同教材对 NOA、tang 这类因子的口径有细微差异,先按资源里列出的约定来,后续要复现论文结果再逐个口径对齐。

这八个因子不是随手选的。RD 相关的两个变量捕捉的是企业创新溢出效应,NOA 是 Sloan(1996)提出的应计异象核心变量,LM 和 tang 度量的是财务弹性与抵押能力。它们在一起,覆盖了盈利质量、成长性、杠杆风险、营运效率四个维度,这正好是基本面量化里最常用的因子骨架。

2.2 多表合并与缺失值处理

我一般会用 pandas 循环读入所有 CSV,按股票代码和报告期拼接。资源里每张表的列结构按常规面板格式设计,我写了一个可复用的合并脚本:

import pandas as pd files = ["76RDsale.csv", "69ROA.csv", "68ROE.csv", "17LM.csv", "75rd_mve.csv", "65NOA.csv", "94tang.csv", "70CT.csv"] df = None for f in files: # 每张表假设包含 code(股票代码)、date(报告期)、value(因子值) 三列 tmp = pd.read_csv(f) tmp = tmp.rename(columns={"value": f.split(".")[0]}) tmp = tmp.set_index(["code", "date"]) if df is None: df = tmp else: df = df.join(tmp, how="outer") df = df.reset_index() df["date"] = pd.to_datetime(df["date"]) df = df.sort_values(["code", "date"]).reset_index(drop=True) print(df.shape) print(df.isnull().mean())

这段逻辑不复杂,但有两个细节值得说。第一,join 用的是 outer,而不是 inner,因为不是每家公司在每个报告期都有全部八个因子值——比如金融行业一般不披露有意义的研发费用;用 inner 会直接把高研发行业整批滤掉,样本偏差非常严重。第二,合并之后要马上看每列缺失率,缺失率超过 30% 的因子需要重新考虑是否纳入模型,而不是一律 fillna。

缺失值处理上,我习惯对连续型因子用截面中位数填充,而不是 0。这个场景下把缺失的研发费用填成 0 会创造一条「研发为零」的假规律,后面避坑章节我会专门展开。

2.3 缩尾、标准化与标签构造

基本面因子的分布普遍带厚尾,直接扔进模型,30 倍的 ROA 异常值会被树模型当成有用信号。学术界通行的处理是缩尾,也叫 winsorize,把每期截面 1% 和 99% 分位之外的极端值压缩回分位点。做完缩尾再做 z-score 标准化。关键是标准化必须按截面来做:

import pandas as pd from scipy.stats import mstats def winsorize_series(s, lower=0.01, upper=0.01): return pd.Series(mstats.winsorize(s, limits=(lower, upper)), index=s.index) def standardize_by_date(df, factor_col): # 每个报告期内部做 z-score,消除宏观环境变化带来的整体漂移 df[f"{factor_col}_z"] = ( df.groupby("date")[factor_col] .transform(lambda x: (x - x.mean()) / x.std()) ) return df for col in ["69ROA", "68ROE", "17LM", "76RDsale", "75rd_mve"]: df[col] = winsorize_series(df.groupby("date")[col].transform(lambda x: x)) df = standardize_by_date(df, col)

这里最关键的是 groupby("date")。如果图省事在全样本上做标准化,等于把 2015 年的 ROA 和 2023 年的 ROA 强行放进同一个标尺,而这两个年份的利率环境、利润质量完全不是一回事。截面标准化保证的是同一时间截面上股票之间的相对比较,这才是选股模型要的。

标签构造则是把未来一段时间收益作为预测目标。我参照资源里的研究周期,用未来 20 个交易日的区间收益做标签,切换时点要注意对齐报告期披露日,而不是报告期结束日:

# 假设 df 里已有 per_end_date(报告期结束日)、ann_date(实际披露日) # 这里以 ann_date 往后推 20 个交易日作为持有期起点 df = df.sort_values(["code", "date"]) df["label"] = df.groupby("code")["close"].transform( lambda x: x.shift(-20) / x - 1 )

shift(-20) 的逻辑是把当天的收益对应到 20 天前那根 K 线上去,保证特征和标签错开,防止未来函数。如果直接用当天的 close 和 20 天后的 close,训练时看起来分数很高,实盘必翻车。

3. 模型训练:四种机器学习算法在同一份数据上的正确姿势

数据工程做完,模型部分其实选择空间很大。这套资源里同时给了多种机器学习算法的实现,我按自己的拆解经验把这部分重新组织成一条标准流水线,方便你对照源码理解每一步在干什么。

3.1 模型池与选型理由

基本面量化里最常用的不是某个单一模型,而是一组模型互相验证。这套资源覆盖的几类算法各有分工:

模型优势在这个场景里的角色
线性回归 / Lasso可解释性最强,系数就是因子权重基线模型,用来判断非线性增益有多少
随机森林抗噪能力强,能捕捉因子间的交互主力模型,min_samples_leaf 控制过拟合
GBDT / XGBoost梯度提升,训练效率高,精度上限高最终选股模型,通常在 IC 上表现最好
LightGBM叶子生长策略,处理缺失值更灵活快速验证特征重要性时的首选

我的做法是先用 Lasso 跑一遍基线,看线性关系能解释多少 alpha;再用树模型对比同一份验证集上的 IC。如果 XGBoost 比 Lasso 提升不到 3%,那说明数据里的信号大概率是线性的,不必上复杂模型增加过拟合风险。反过来,如果树模型显著更好,说明因子之间存在交互效应。

3.2 时间序列交叉验证:直接 KFold 会偷看未来

基本面数据的标签在时序上有强自相关性,直接用 sklearn 的 KFold 把样本随机打乱切分,等于允许模型从未来季度学习规律去预测过去。我拆这套项目时验证过同类错误:普通 KFold 下测试集 IC 比 TimeSeriesSplit 高出近一倍,全是假象。

正确的做法是严格按时间顺序切分,训练集永远在验证集之前:

from sklearn.model_selection import TimeSeriesSplit from sklearn.preprocessing import StandardScaler from sklearn.ensemble import RandomForestRegressor tscv = TimeSeriesSplit(n_splits=5, test_size=60) scores = [] X = df[feature_cols].values y = df["label"].values for train_idx, val_idx in tscv.split(X): scaler = StandardScaler().fit(X[train_idx]) X_tr = scaler.transform(X[train_idx]) X_val = scaler.transform(X[val_idx]) model = RandomForestRegressor( n_estimators=400, min_samples_leaf=10, max_depth=6, random_state=42, n_jobs=-1 ) model.fit(X_tr, y[train_idx]) scores.append(model.score(X_val, y[val_idx])) print(scores)

这里 TimeSeriesSplit 的 test_size 要按你的数据频率调。如果数据是季度面板,test_size 设为 8 个季度左右比较合理,太小会让验证集波动过大;如果是月度数据,test_size 提到 24。scaler 必须在训练折上单独 fit,再transform 验证折,不能在整个数据集上先统一标准化,否则还是泄露。

3.3 特征重要性与排序衰减观察

跑完模型后,特征重要性表值得认真看。我见过太多人只盯着 R² 或 MSE,却不知道模型在用什么做决策:

importance = ( pd.Series(model.feature_importances_, index=feature_cols) .sort_values(ascending=False) ) print(importance.head(10))

随机森林的 feature_importances_ 本质上衡量的是该特征在所有决策树分裂中带来的不纯度下降总量。如果 17LM(杠杆率)高居榜首,不要意外,基本面因子里杠杆因子对收益的区分度长期都很强。但要注意,当 RD 相关因子的重要性为 0 时,常见原因是缺失比例太高,这时要去检查填充策略,而不是断言研发投入对股价没有解释力。

树模型在这个场景里的超参数我推荐这样起步:n_estimators 300 到 500,min_samples_leaf 5 到 20,max_depth 4 到 8。财务数据的信噪比很低,叶子太小会开始拟合噪声,出现训练集 R² 高、验证集 IC 为负的典型症状。优先调 min_samples_leaf,它对泛化能力的影响远大于 n_estimators 翻倍。

4. 分层回测与绩效归因:别把 R² 当饭吃

模型拟合完,真正检验选股能力的是回测。但回测不是算出总收益就完事,我拆这类项目时习惯用「分层回测 + 换手率分析 + 分年度归因」三步走,每一步都在回答不同的问题。

4.1 分层回测:预测值排序后的单调性检验

回测的第一步,是把样本按模型预测值从低到高分成五组或十组,看每组未来收益是否单调递增。单调性是比 R² 更重要的信号。只关注整体相关的话,可能模型只在头部股票上预测准,组合构建时就很难用满预测能力。

import pandas as pd df["pred"] = model.predict(X_all) # 每个截面内按预测值分成 5 组 df["quintile"] = df.groupby("date")["pred"].transform( lambda s: pd.qcut(s.rank(method="first"), 5, labels=False) ) # 分组等权平均未来收益 group_ret = df.groupby(["date", "quintile"])["label"].mean().unstack() # 做多最高组、做空最低组的对冲收益 long_short = group_ret[4] - group_ret[0] print("多空组合累计收益:", (1 + long_short).prod() - 1) print("多空组合年化波动:", long_short.std() * 12**0.5)

重点看三件事:第一,group_ret 的分组均值是否有单调性,理想的排列应该是第 4 组收益显著高于第 0 组;第二,多空组合在样本期内的累计曲线是否长期向上,而不是靠某一年暴涨拉起来的;第三,多空组合每天的收益波动,这个波动率直接暗示了实盘持有这种策略需要的心理承受力。

4.2 换手率与交易成本:模型分数是幻觉,费率是现实

机器学习模型容易给相邻期的股票打出相差很大的分数,导致组合每月大换血。我在看这套项目基线代码时发现它就踩过类似的坑——分层收益不错,但换手率算出来吓人。基本面因子的换仓周期偏长,月换手如果超过 40%,双边千三的费率加冲击成本会把超额收益吃掉一大半。

处理方式是在切仓时加缓冲带,也叫 no-trade band。上一期预测排名在前 20% 的股票,这一期只要还在前 30%,就不动它。这个机制的收益回撤不大,但换手能直接降一半:

def apply_no_trade(prev_rank, curr_rank, buy_thr=0.2, sell_thr=0.3): # 上期买入且仍未跌出 sell_thr 的持有;新进入 buy_thr 的买入 hold = (prev_rank <= sell_thr) & (curr_rank <= sell_thr) buy = (~hold) & (curr_rank <= buy_thr) return hold, buy

从经验看,基本面量化策略的交易成本对净 Sharpe 的影响在 0.2 到 0.5 之间,绝不是一个可以忽略的细节。回测报告里如果不单列换手率这一列,基本可以判定报告没有实盘参考价值。

4.3 分年度收益稳定性:识别「靠一年吃饭」

把多空组合的收益按自然年拆开,逐列计算分组单调性。我拆这套资源时特意跑了一遍分年度表,发现它在震荡市年份分组收益略平,但 2016 到 2020 那几年趋势非常好。这说明它的 alpha 来源是慢变量——基本面数据本来就适合中低频调仓。

分年度看有个通用判断准则:如果至少有 60% 的年份多空收益为正,策略大体可信;如果只有一两年贡献了全部收益,其他年份都在回撤,那大概率是过拟合出来的幸存者。对基本面策略来说,行业集中度也是需要瞄一眼的东西。如果某一组大量堆积某个单一行业,收益驱动就说不清是因子还是行业 beta,这时候应该按行业做一层中性化再回测。

5. 避坑指南:基本面量化里最容易翻车的五个地方

这部分是血泪经验。我在复现和调试过程中踩过的坑,大多数不是模型问题,而是数据在某些不起眼的环节出了偏差。下面的每一条都是「现象 — 原因 — 解决」三段式,可以直接拿来排查你自己的管线。

5.1 训练集评分很高,实盘季度却亏钱

这是最常见的翻车现场。现象是模型在验证集上 R² 达到 0.35,分层回测曲线漂亮得吓人,但一用实盘数据就失效。原因几乎总是未来函数——最常见的是标签时点没对齐披露日,直接用 report_date 切分,等于在财报还没发布时就用上了财报数据。

解决:把所有特征统一对齐到 ann_date,也就是财报实际披露日。规则是「t 期完整的披露日利润表,最早只能在披露日收盘后用于预测 t 期之后的收益」。这个错位哪怕差一个月,结论都可能反转。

5.2 幸存者偏差让收益虚高 20%

现象是回测收益特别稳定,年化高得离谱。原因是股票池用的是当前时点在市的公司列表,退市的、被 ST 的全被过滤掉了。这等于考试只统计活下来的考生。基本面因子恰恰容易踩中这类偏差,因为财务困境公司往往在退市前表现极端。

解决:把股票池构建改成「未来任一时点均为历史上市状态」的全集。A 股可以用上市日期和退市日期做筛选条件,确保每一期股票池只包含当时真实存在的公司,同时明确剔除 ST 和上市不足 60 个交易日的次新。

5.3 对缺失因子一律 fillna(0),因子重要性全面失真

现象是研发相关因子的 feature_importance 异常高。原因很直接:研发费用不是每家公司都有,填 0 之后模型学到的是「有研发 vs 无研发」这个哑变量,而不是研发强度的高低。它带来的分组收益其实是行业分布的影子。

解决:把缺失单独建哑变量列,原始因子列用行业中位数填充。这样模型既能用上研发强度的连续信息,又不会被缺失模式误导。遇到任何缺失比例超过 10% 的因子,我都建议做同样处理。

5.4 全样本标准化导致截面 IC 虚高

现象是标准化后因子 IC 明显比不做时要好。原因是整个样本期用一个均值和标准差拉伸,等于把时间趋势信息混进了因子取值里,模型学到的可能只是「这几年整体估值抬升」。

解决:按 date 分组做截面标准化,并在交叉验证时保证 scaler 只 fit 训练折。这一点我在 2.3 节代码里已经体现,但值得反复强调——多因子模型的处理几乎全都要在截面上做,大家习惯的机器学习全量标准化在这里是错的。

5.5 因子收益逐年衰减,模型前两年赚钱后三年失效

现象是分年度收益表里,最近两三年多空收益明显缩小,甚至翻转。原因有两层:第一,此类因子被市场挖掘后,套利空间本身就衰减;第二,你的预测目标里没有处理因子拥挤度。

解决:把预测目标换成「未来 20 日超额收益」,超额基准用中证 500 或同市值组均值,而不是绝对收益。同时把因子暴露的历史 IC 衰减曲线作为风控项,当某因子近三期 IC 由正转负时自动降低该因子权重,这在源码里对应到动态权重模块。

6. 进阶技巧:用滚动截面 IC 曲线判断模型是否真的在选股

最后一个技巧,也是我鉴定任何一个机器学习选股模型时必做的检验:计算滚动截面 IC,并画出它的衰减曲线。很多人看回测只看净值,但净值可以被少数几次极端行情撑起来,滚动 IC 则能反映模型在任何时间段是否稳定地具备预测力。

from scipy.stats import spearmanr df = df.sort_values(["date", "code"]) ic_list = [] for dt, grp in df.groupby("date"): pred = grp["pred"] label = grp["label"] rank_ic, _ = spearmanr(pred, label) # 截面秩相关,对极端值不敏感 ic_list.append({"date": dt, "ic": rank_ic}) ic_df = pd.DataFrame(ic_list).set_index("date") ic_df["ic_ma"] = ic_df["ic"].rolling(12).mean() print(ic_df.describe())

读这个结果有三个层次。第一,看 IC 均值,单因子或单模型月均截面 IC 达到 0.03 以上就算有可用信号;第二,看 IC 标准差,IC_IR(IC 均值除以标准差)最好大于 0.3,否则策略收益路径会颠簸到难以坚持;第三,看 IC 衰减曲线——按持有期 5、10、20、40 个交易日分别计算 IC,如果 IC 在 20 日后就衰减到 0 附近,说明模型捕捉的是短期反转信号;如果在 20 日后仍在递增,说明它抓住的是基本面缓慢扩散的中长期逻辑。这套资源里的基本面因子应该属于后者,你拿到手可以自己验证。

我做这个检验的习惯来自一次踩坑。当时一个树模型回测年化 35%,我差点直接上模拟盘,后来用滚动 IC 一跑,发现它只在 2019 到 2020 年有预测力,其余时间段 IC 在零轴附近反复横跳。从那以后,我每次跑完模型都强制自己先出滚动 IC 图,IC 曲线不平滑,后面的绩效归因压根不看。这个习惯救了我很多次,希望你也能用上。

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

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

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

立即咨询