机器学习驱动的基本面量化投资:从数据清洗到模型评估全流程

发布时间:2026/9/13 1:12:57
机器学习驱动的基本面量化投资:从数据清洗到模型评估全流程 简介面向金融科技与量化投资方向的毕业设计及研究者压缩包内含一个基于Python的机器学习驱动基本面量化投资的完整研究项目系统尝试用12种机器学习算法提升A股股票收益预测效果。包内共183个文件、约145.6MB核心是167个CSV因子数据集涵盖1997年至2018年A股96项异象因子另有11个Python脚本实现预测组合算法、Lasso、岭回归、弹性网络、偏最小二乘、SVM、梯度提升树、XGBoost、集成神经网络、深度前馈网络、循环时序网络与LSTM等模型以及PDF和Markdown说明文档方便复现实验。已有285人学习该资源对于学习量化选股、展开实证分析或构建毕业设计项目都有直接参考价值。通过源码与数据可深入理解机器学习如何识别异象因子与超额收益间的复杂模式实测深度前馈网络模型构建的多空组合月度收益可达2.78%因子重要性筛选后更进一步提升至3.41%为优化基本面量化策略提供了具体方向。1. 机器学习如何真正融入基本面量化投资如果把财报数据直接扔给机器学习模型然后指望它预测股价结果大概率是过拟合。基本面量化投资的核心难点从来不是模型不够强而是数据如何处理——财报的发布频率、指标含义、跨行业的可比性全都在模型训练之前就已经决定成败。Python 在这里的价值在于它能把财报解析、财务指标计算、因子处理、模型训练、回测验证一条链路打通让机器学习在基本面分析里成为真正可用的决策工具而不是一个噱头。这篇内容的定位适合已经会用 Pandas 和 sklearn、但对金融数据处理不熟的从业者。熟悉机器学习的人看完能补上金融对齐和评估方法熟悉基本面研究的人看完能理解模型为什么需要专门设计的数据管线。整体路线是先讲数据对齐与标签构造再做因子清洗然后对比三类建模思路最后落到验证和实操。2. 从财报到训练样本基本面数据怎么对齐、怎么打标签拿到类似题目的项目压缩包时通常都会有一组 CSV 或 Parquet 格式的财报数据文件。第一步不是急着建模而是先检查文件清单和数据字典。常见的数据集至少包含两张表一张是公司财务数据营收、净利润、总资产、股东权益等另一张是股票行情数据日线或周线的收盘价、成交量。很多项目里还附带因子计算结果或者代码说明文档。2.1 数据源与字段选择先确认频率和单位数据分析之前先把字段读进来做一次类型和缺失值诊断。财报数据里最容易踩的坑是数值单位不统一有的字段单位是元有的是万元还有的是亿元部分字段会有--或空字符串表示缺失。统一单位这步做不好后面算出来的净资产收益率、毛利率全都会偏离真实水平。import pandas as pd import numpy as np # 财报数据每一行是一家公司在某个报告期的财务摘要 fin pd.read_csv(financial_data.csv, parse_dates[ann_date, report_date]) fin fin.sort_values([ts_code, report_date]) # 行情数据每日收盘价用于构造未来收益标签 mkt pd.read_csv(market_data.csv, parse_dates[trade_date]) mkt mkt.sort_values([ts_code, trade_date]) # 统一单位为元假设原始数据里 total_assets 单位是千元 fin[total_assets] fin[total_assets] * 1000.0 # 剔除核心财务字段缺失的样本 fin fin.replace([--, ], np.nan) fin fin.dropna(subset[total_revenue, net_profit, total_assets])逻辑上ann_date是公告发布日期report_date是报告期截止日这两个字段必须严格区分。机器学习训练时只能用ann_date之前已经公开的信息如果错误使用了report_date当作用户已知信息就会引入前视偏差。参数上这里需要留意有的数据集没有ann_date只有report_date这种场景常见退路是统一假定滞后 4 个月发布即ann_date report_date 4 months。处理前先打印fin[ann_date].isna().mean()看看缺失比例再做决定。2.2 时间对齐把季度财报对齐到交易日财务数据是低频的一般一年 4 期加上部分公司只披露半年报实际对齐到日频或月频行情时要做 forward fill——也就是把最新一期已披露财报当作该时点已知的基本面状态。常见做法是以月频或周频做重采样因为日频数据会让前后样本高度相关导致模型评估虚高。# 只保留每月最后一个交易日 month_end mkt.groupby(ts_code).resample(ME, ontrade_date).last().reset_index() month_end month_end[[ts_code, trade_date, close]] # 按股票代码和公告日期做 asof 对齐取公告日之前最近一期的财报 merged pd.merge_asof( month_end.sort_values(trade_date), fin.sort_values(ann_date), byts_code, left_ontrade_date, right_onann_date, directionbackward, ) merged merged.dropna(subset[total_assets])pd.merge_asof的directionbackward表示对每个交易月取公告日期早于该月最后一个交易日的最新财报。这里不能用普通的pd.merge因为交易日期和公告日期永远不可能完全相等。对齐完成以后检查一下merged.groupby(trade_date).size()的分布是否均匀如果有几个月样本量骤降大概率是原始数据集本身缺了一段财报需要回上游补数据。2.3 标签工程未来收益率的窗口与平移屏蔽机器学习驱动的基本面量化标签一般是未来 N 日收益率。N 的选择直接决定模型的预测尺度N 太小如 1 天基本面因子几乎没有任何信息量N 太大如 250 天样本之间重叠严重换手率也会变得不可用。入门数据集里最常见的是 20 天窗口。# 未来20个交易日收益率作为标签 merged merged.sort_values([ts_code, trade_date]) merged[future_ret_20d] ( merged.groupby(ts_code)[close].shift(-20) / merged[close] - 1 ) # 去掉最后20行无法计算未来收益的尾巴 merged merged.dropna(subset[future_ret_20d]) # 标签去极值防止某只股票暴涨暴跌主导损失函数 low, high merged[future_ret_20d].quantile([0.01, 0.99]) merged[future_ret_20d_clip] merged[future_ret_20d].clip(low, high)注意shift(-20)用在了按股票代码分组后的收盘价序列上含义是“这只股票 20 个交易日之后的价格”。标签构造完成后对标签做 1% 和 99% 分位的 clip 是基本功目的是削弱停牌复牌或财报暴雷导致的极端收益对损失函数的影响。这段代码如果在shift之前没有sort_values分组结果会是乱序的务必先排序再移位。数据集如果本身只有月频数据则把窗口从 20 改成 6约半年是更合理的选择。3. 让特征可解释基本面因子清洗与中性化基本面数据构造出的原始因子比如市盈率、市净率、总资产收益率直接送进模型前必须做三层处理。第一层是极端值截尾第二层是行业和市值中性化第三层是截面标准化。这三步不做好模型学到的很可能是行业偏好而不是真正的超额收益信号。3.1 极端值处理分位数截尾与 MAD 两种方法市值因子天然是右偏的市盈率因为微利或亏损企业会出现极端负值。对这些因子做去极值处理常见做法是按截面做分位数截尾另一种更稳健的做法是用中位数绝对偏差MAD做过滤。def winsorize_series(s, lower0.01, upper0.99): 分位数截尾把低于下分位和高于上分位的值强制设为边界值 lo, hi s.quantile(lower), s.quantile(upper) return s.clip(lo, hi) def mad_winsorize(s, n5): 基于中位数绝对偏差的截尾超出中位数 n 倍 MAD 视为异常 median s.median() mad (s - median).abs().median() lower, upper median - n * 1.4826 * mad, median n * 1.4826 * mad return s.clip(lower, upper) # 逐月截面处理 merged[pe_ratio_w] merged.groupby(trade_date)[pe_ratio].transform( lambda x: winsorize_series(x, 0.01, 0.99) )1.4826这个常数是为了让 MAD 在正态分布下等价于标准差。分位数截尾实现简单但对样本量敏感——月度截面只有几百只股票时1% 分位可能只有几个样本受极端值影响较大。MAD 方法对样本量不敏感更适合小数据集。这两个函数建议同时保留行业研究员通常对 PE、PB 用分位数截尾对毛利率、ROE 这类比值用 MAD因为比值的异常值往往来自数据错误而不是真实经营结果。3.2 行业与市值中性化用回归取残差不同行业的市盈率中枢完全不同银行股的 PE 常年低于 10 倍科技股可以到 50 倍以上。如果不做中性化机器学习模型会抓住“行业分类”这一最强特征而对行业内个股的差异视而不见。常见做法是把市值对数和行业哑变量作为解释变量对因子做线性回归取残差作为中性化后的因子值。import statsmodels.api as sm def neutralize(s, log_mktcap, industry_dummies): 因子中性化把因子值拆成由行业和市值解释的部分 残差 s: 原始因子值Series log_mktcap: 对数市值Series industry_dummies: 行业哑变量DataFrame每列一个行业 X pd.concat([log_mktcap, industry_dummies], axis1) X sm.add_constant(X) model sm.OLS(s.astype(float), X).fit() return model.resid # 示例只在单一交易日截面内做中性化 sample merged[merged[trade_date] merged[trade_date].max()] industry_dummies pd.get_dummies(sample[industry], prefixind) sample[pe_neutral] neutralize( sample[pe_ratio_w], np.log(sample[total_mv]), industry_dummies, )这里要特别强调中性化必须按截面进行不能把所有历史数据混在一起回归。比如 2020 年 1 月和 2023 年 6 月市场整体估值中枢完全不同混在一起回归会让残差失去横向可比性。常见做法是在groupby(trade_date)循环内逐月调用neutralize。statsmodels的 OLS 会自动丢弃缺失值这本身没问题但要注意残差的索引需要reindex回原序列否则后续merge会错位。3.3 标准化与因子合成截面上做 z-score中性化之后的残差仍然存在量纲差异ROE 的残差可能是小数PE 的残差可能是几十。模型输入前必须做标准化。严谨的做法是在每个截面上做 z-score也就是减去截面均值、除以截面标准差。这样每个因子的分布都变成均值为 0、方差为 1模型在计算特征重要性时才不会被大数值特征带偏。def zscore(s): return (s - s.mean()) / s.std() merged[pe_z] merged.groupby(trade_date)[pe_neutral].transform(zscore) merged[roe_z] merged.groupby(trade_date)[roe].transform(zscore)标准化之后还要检查一个细节每个截面的 z-score 均值应当约等于 0标准差约等于 1。如果某个月份的标准差明显异常极有可能是该月截面样本数量太少或原始因子缺失过多导致中性化回归失败。这样处理完的因子矩阵才算真正可以被机器学习模型直接消费。4. 模型选型分类、回归、排序学习三条路线的取舍因子处理好以后进入建模。这一章给出三条主流路线每条路线的目标不同、损失函数不同、评估方式也不同。原则上如果数据集样本量在 5 万行以下优先用线性模型或浅层树模型样本量大且因子间交互明显时再考虑梯度提升树。深度学习在基本面低频数据上的优势并不明显除非你有非常长周期的另类数据比如新闻舆情、供应链关系。4.1 分类器路线先回答涨还是跌最简单的切入方式是把标签转成二分类未来 20 日收益为正记 1否则记 0。此时可以选择逻辑回归、随机森林或带早停的 XGBoost/LightGBM。逻辑回归的优势是可解释性强缺点是只能捕捉线性关系随机森林对特征交互友好但外推能力弱梯度提升树综合表现最好但也最容易过拟合。from sklearn.model_selection import GroupKFold from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score feature_cols [pe_z, roe_z, gross_margin_z, debt_ratio_z, log_mktcap_log] X merged[feature_cols].fillna(0) y (merged[future_ret_20d] 0).astype(int) # 防止同一只股票的多个月份同时出现在训练集和验证集 gkf GroupKFold(n_splits5) for train_idx, val_idx in gkf.split(X, y, groupsmerged[ts_code]): model LogisticRegression(max_iter1000) model.fit(X.iloc[train_idx], y.iloc[train_idx]) auc roc_auc_score(y.iloc[val_idx], model.predict_proba(X.iloc[val_idx])[:, 1]) print(fVal AUC: {auc:.4f})GroupKFold在这里是不可省略的设计选择。如果不按股票代码分组同一只股票相邻月份的样本会被切到训练集和验证集两边模型等于见过“未来”验证分数会虚高到不可信。这一点对熟悉 sklearn 但刚接触金融数据的人来说是最常见的坑。逻辑回归拿到 AUC 之后还要看系数方向是否符合基本面直觉pe_z系数通常应该为负低估值更容易涨roe_z系数通常为正。4.2 回归路线用 LightGBM 的 quantile loss 预测收益幅度分类只回答涨跌方向丢掉了一部分信息。收益幅度对组合构建很重要因为同样的上涨信号涨 2% 和涨 10% 的仓位配置完全不同。回归路线直接用未来收益率作为标签用 Huber 损失或 quantile 损失来削弱极端值的影响。import lightgbm as lgb dtrain lgb.Dataset(X, labelmerged[future_ret_20d_clip]) params { objective: quantile, alpha: 0.5, # 预测中位数对极端值稳健 metric: quantile, learning_rate: 0.03, num_leaves: 31, feature_fraction: 0.8, bagging_fraction: 0.8, verbose: -1, } model lgb.train( params, dtrain, num_boost_round500, callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)], )alpha0.5时quantile loss 等价于预测条件中位数比 MSE 对极端标签稳健得多。想要更偏保守的预测可以把alpha调到 0.7让模型拟合收益分布的上分位点——这种情况下模型会更倾向于标记“可能大涨”的股票适合进攻型组合使用。LightGBM 的原生接口优势是能在训练时直接输出特征重要性作为因子筛选的依据。但要注意num_leaves31在低频数据上已经偏大如果验证集分数震荡严重第一优先级是降num_leaves而不是调学习率。4.3 排序学习路线把目标定成“相对排名”量化组合构建本质上需要的是股票的相对强弱排名而不是绝对收益预测。排名学习learning to rank正是为这种场景设计的。它的做法是不直接拟合收益数值而是让模型学会将股票按未来收益大小排序。常见实现包括 LambdaRank 和 ListNetLightGBM 中可以用objectivelambdarank直接训练。import lightgbm as lgb lgb_train lgb.Dataset(X, labelmerged[future_ret_20d_clip], groupmerged.groupby(trade_date).size()) params { objective: lambdarank, metric: ndcg, ndcg_eval_at: [10, 20], learning_rate: 0.05, num_leaves: 63, min_data_in_leaf: 50, } model lgb.train(params, lgb_train, num_boost_round300)这里的group参数是排序学习特有的一项它告诉模型每个月截面是一组组内样本互相比较排名。与分类回归最大的不同是排序学习不需要手动调标签分布也不需要对收益率做任何截尾——极端收益只用它影响排名不会拖累损失函数。缺点是对样本量的要求更高月度截面少于 100 只股票时组内信息不足排名不稳定。这套方案在实际专业组合里用得最多也是“机器学习驱动的基本面量化”听起来比传统打分法高级的真正原因。4.4 三类路线的对比与选择下表是三类路线在基本面低频数据上的特征对比。选择时主要看自己的目标维度——只排名就选排序学习要算仓位再加一个回归头如果只是为了快速验证因子有效性分类器路线最省事。路线预测目标损失函数优点常见坑分类涨跌未来收益为正的概率LogLoss / AUC简单稳健易解释把 AUC 高当成能赚钱回归幅度未来收益率MSE / Huber / Quantile可直接用于仓位权重极端值主导训练排序学习排名组内相对排名NDCG / LambdaRank贴近组合构建逻辑小样本下排名不稳定5. 从模型到策略分组评估、IC 分析与换手率控制模型训练完离策略还差最关键的一步验证它不是靠运气并把预测分数转化为收益。最基本的验证方法是分层回测——把预测分数从小到大排成五组或十组观察每一组的未来收益均值是否单调递增。如果第三组比第四组收益还高哪怕整体是单调的也要警惕模型在局部没有稳定区分能力。5.1 分位数组单调性验证一张表看清模型能力merged[score] model.predict(X) # 用排序学习模型的输出分数 merged[quantile_rank] merged.groupby(trade_date)[score].transform( lambda s: pd.qcut(s, 5, labelsFalse, duplicatesdrop) ) result ( merged.groupby(quantile_rank) .agg( avg_future_ret(future_ret_20d, mean), win_rate(future_ret_20d, lambda x: (x 0).mean()), ) .reset_index() ) print(result)理想的结果是第一组到第五组平均收益单调递增且第五组与第一组的差值显著为正。只看多头组的收益还不够必须同时看单调性和多空差。pd.qcut分位数分组时如果出现重复分位点会报错加了duplicatesdrop可以避免因为大量相同预测分数导致的崩溃。实际项目里如果第五组胜率低于 50%即使平均收益很高也可能是被少数极端样本拉起来的需要回到标签处理环节检查。5.2 IC 分析与衰减跟踪按月截面计算 RankIC分层验证是离散视角IC 是连续视角。RankIC 是每月截面中预测分数与未来收益的 Spearman 秩相关系数它能更细致地刻画模型排序能力。IC 值大于 0.03 就可以认为因子有微弱但稳定的预测力大于 0.05 属于优秀大于 0.08 在低频基本面领域已经非常罕见。from scipy.stats import spearmanr ic_list [] for date, group_df in merged.groupby(trade_date): if len(group_df) 30: continue ic, _ spearmanr(group_df[score], group_df[future_ret_20d]) ic_list.append({trade_date: date, rank_ic: ic}) ic_df pd.DataFrame(ic_list) print(fICIR {ic_df[rank_ic].mean() / ic_df[rank_ic].std():.3f}) print(fIC 胜率 {(ic_df[rank_ic] 0).mean():.2%})ICIR 是 IC 均值与标准差的比值代表预测力的稳定性。ICIR 大于 0.5 在月频基本面策略里是可用的水平低于 0.2 说明模型基本没有稳定信息量。同时建议把每月 IC 画成时序曲线观察模型是不是只在特定市场环境下有效——如果 IC 在 2023 年之后持续为负大概率是市场风格切换导致因子失效需要重新训练或加入风格因子。5.3 控制换手率平滑预测分数的常用做法模型分数每天都会变直接按分数排序建仓换手率会高到无法实盘。常见解决办法是对预测分数做时间序列平滑比如取过去 3 个月的分数均值作为最终排序依据。另一个务实做法是设定调仓阈值只有当股票分数排名从当前持仓区间跳到另一个区间时才触发换仓否则维持原持仓。# 对预测分数做滚动 3 期均值平滑 merged merged.sort_values([ts_code, trade_date]) merged[score_smooth] ( merged.groupby(ts_code)[score].transform(lambda s: s.rolling(3, min_periods1).mean()) ) # 调仓阈值只有排名分位变化超过 0.2 才换仓 merged[prev_rank] merged.groupby(ts_code)[quantile_rank].shift(1) merged[trade_signal] ( (merged[quantile_rank] - merged[prev_rank]).abs() 1 ).astype(int)真正落地时平滑窗口的长度取决于策略的预期持仓周期。想持仓 60 天以上平滑窗口至少 3 个月想半个月调一次仓平滑窗口设 1 个月就够。换手率过低会让组合拖尾滞后过高则交易成本侵蚀收益一般把月度双边换手控制在 20%~40% 区间比较好。在拿到一个基础模型后优先调平滑窗口和调仓阈值比继续调模型参数带来的收益提升更明显。本文还有配套的精品资源点击获取