资讯详情 机器学习驱动的基本面量化投资:从财务因子到Python选股实战
📅 2026/10/13 0:37:51
简介面向金融量化初学者与有一定Python基础的开发者这份资源围绕基本面量化投资场景以Python实现多种机器学习算法的建模流程覆盖数据获取、特征构造、模型训练与效果评估等环节可帮助读者理解如何将财务数据转化为可用的量化信号。压缩包共183个文件约145.6MB主体为167个CSV格式的市场与财务数据集另有11个Python源码文件、3个PDF说明文档以及配置与项目说明文件目录划分较清晰便于按模块对照学习。目前已有309人学习下载。源码均经过本地编译运行验证评审分在95分以上内容难度适中且经过审定适合作为课程设计、毕业设计或入门实践参考结合数据集与代码读者可以复现完整研究流程并在此基础上替换指标或调整模型进行扩展。1. 为什么基本面量化要用机器学习从财务因子到预测模型做基本面量化的人多半经历过这种尴尬按ROE、ROA、NOA这些财务指标手工打分选股回测曲线很好看一上实盘就钝。因为传统多因子模型默认的是线性关系而基本面数据和未来收益之间更多是交互和阈值效应。这份「机器学习驱动的基本面量化投资研究」源码包用Python把这套逻辑补上了它把一组公司财务csv因子丢给多种机器学习算法让模型自己找规律。包里带的数据集覆盖盈利、杠杆、资产质量这几类指标代码是本地编译可跑的评审分到95以上。适合正在做量化课程设计、或者想从传统打分转向机器学习选股的人拿来直接改特征和标签就能复现。2. 数据与因子看懂那几张CSV构建特征面板2.1 从文件清单反推数据结构这些csv里到底有什么拿到压缩包先别急着跑代码我习惯先数一遍文件。文件列表里是10行但其中17LM.csv和94tang.csv各出现了两次实际应该是8张因子表。文件名是“序号简称”例如76RDsale.csv、69ROA.csv、68ROE.csv、75rd_mve.csv、65NOA.csv、70CT.csv大概率是不同基本面因子的面板数据。RD_sale和rd_mve都是研发投入类指标ROA和ROE是盈利指标NOA是净经营资产tang是有形资产比例LM可能是杠杆倍数CT可能是资本周转率。这些都是基本面量化里的经典特征不是随便起的名字。打开文件之前要有个预期这些csv是长表还是宽表列名叫什么。常见做法是stock_codedatevalue三列一张表一个因子也有的是宽表行是公司、列是日期。我一般会先写个小脚本统一检查na比例、日期范围、股票数量避免后面合并时维度不齐。比较坑的是有的csv时间字段叫ym存的是202301这种整型有的叫date存的是2023-01-31。读进来不统一合并时就会出现大量NaN而且是那种让你很难察觉的错位。import pandas as pd fp 69ROA.csv tmp pd.read_csv(fp) print(tmp.columns) print(tmp.dtypes) print(tmp.head()) print(tmp[date].min(), tmp[date].max(), tmp[stock_code].nunique())这段代码专门用来做“体检”。dtypes这里要看date是不是datetime64类型如果显示object说明日期是字符串后面必须先pd.to_datetime。nunique统计股票数量如果一个因子表只有几十只股票另一个有几百只合并时很容易出现大面积缺失你要么考虑用交集要么接受样本量损失。我的习惯是先用这个脚本把所有表都跑一遍把每张表的股票数、日期范围、缺失率记在一个dict里再决定用outer还是inner合并。2.2 用pandas合并与清洗把多张因子表拼成面板数据确认结构后我把所有因子文件读到一个字典里按股票代码和交易日期做外层合并。这样即使某张表缺日期样本也不会被丢掉后续可以靠填充或删除策略处理。import pandas as pd import glob files glob.glob(factor/*.csv) df None for fp in files: name fp.split(/)[-1].replace(.csv, ) tmp pd.read_csv(fp, parse_dates[date]) tmp tmp.rename(columns{value: name}) tmp tmp.drop_duplicates(subset[stock_code, date], keeplast) tmp[stock_code] tmp[stock_code].astype(str) if df is None: df tmp else: df df.merge(tmp, on[stock_code, date], howouter) df df.sort_values([stock_code, date]).reset_index(dropTrue) print(df.shape, df.head())这里有几个参数值得单独说。howouter是以所有股票和日期的并集为准保留样本如果你只想用全部因子都齐全的公司就改成howinner但那样样本量可能砍掉三分之一。keeplast是因为文件名重复时后读的文件会覆盖相同键的数据避免17LM.csv出现两次导致重复行。astype(str)是我吃过亏的地方——股票代码如果被读成数值600001和600001.0在merge时会被当成不同公司这种错极其隐蔽等跑到模型里才发现样本量不对。合并之后最麻烦的是日期对齐。基本面数据按季度披露但你的收益标签通常是月度甚至日度。常见做法是先把因子日期推到最近的月末再用merge_asof向上匹配df[month_end] df[date] pd.offsets.MonthEnd(0) ret pd.read_csv(monthly_ret.csv, parse_dates[date]) ret[month_end] ret[date] pd.offsets.MonthEnd(0) df ( pd.merge_asof( df.sort_values(month_end), ret.sort_values(month_end), onmonth_end, bystock_code, directionbackward, ) )merge_asof在这里的作用是允许因子日期和收益日期不完全相等只要因子在那个月末之前披露就用它匹配当月收益。directionbackward表示取最接近且早于等于month_end的那条记录这能模拟“财报披露后我们才知道因子值”的真实场景。如果你用merge做严格等值匹配很多季报数据因为披露日晚于财报期末会被错误地归到下一期回测时等于开了天眼。2.3 特征加工标准化、MAD缩尾与缺失值处理特征加工直接决定模型能不能学出东西。基本面数据最怕两个东西极端值和量纲差异。ROE有百分之几的也有几百的不处理的话树模型还好线性模型和神经网络会被极端值带着跑。我看到有些教程直接按3σ缩尾但基本面数据里大市值公司的ROE分布和小市值公司差很多3σ容易被少数妖股拉宽区间缩完等于没缩。我一般用中位数绝对偏差MAD缩尾对异常值更稳健。import numpy as np def mad_winsorize(s, n5): med s.median() mad (s - med).abs().median() * 1.4826 lo med - n * mad hi med n * mad return s.clip(lo, hi) factor_cols [c for c in df.columns if c.startswith((ROA, ROE, NOA, CT, LM, tang, rd))] for c in factor_cols: df[c] df.groupby(date)[c].transform(mad_winsorize) df[c] df.groupby(date)[c].transform( lambda x: (x - x.mean()) / x.std() )按日期分组缩尾是这里的核心。如果对整个面板一起缩尾早期股票数量少数值分布和后期不一致等于把不同时期的尺度混在一起。n5表示容忍5倍MAD大约对应3.4个标准差既能把明显错误数据拉回来又不会误杀高成长公司。标准化用截面z-score让每个时期因子均值归零、方差为1这样后面算IC时不同因子的量纲才可比。缺失值处理我放在最后一步。很多人习惯直接dropna()但基本面因子天然存在披露滞后比如研发费用很多小公司不披露直接删行留下的样本会偏向大型成熟公司。更合理的做法是根据因子覆盖率过滤某个因子在某个日期缺失率超过40%就暂时剔除低于40%的用中位数填充。填充时也按日期分组避免用全历史均值污染未来信息。到这里特征面板基本能用。实际项目里标签通常是未来一期或未来一季度的超额收益源码里应该有对应的生成逻辑。我的做法是构造T1收益再减去同期中位数收益得到超额收益下一章结合模型细拆。3. 模型选型与训练从线性基准到集成学习跑通源码核心管线3.1 为什么不能只用线性打分基本面数据天生非线性传统多因子模型给因子配固定权重ROE高1个点预期收益就高固定幅度。但现实里不是这样高ROE的公司如果再配上低负债、高现金转化率收益弹性更大高ROE配上高负债反而可能是财务杠杆堆出来的虚高。这种交互效应线性模型是抓不到的。机器学习模型尤其是树模型天然支持特征分裂时的条件判断能自动找到“ROE10且NOA0.2”这类组合规则。这就是这份源码用多种机器学习算法的根本原因——不是炫技而是线性打分在交互项面前确实不够用。3.2 训练脚本骨架切分、标准化、交叉验证与早停训练前需要明确这里不是分类问题是预测未来超额收益的排序问题。我一般把标签处理成二分类跑赢中位数1跑输0这样逻辑回归、随机森林、XGBoost都能直接套用评价指标也可统一看AUC。源码如果用的是回归看IC也一样不影响思路。import pandas as pd import numpy as np from sklearn.model_selection import TimeSeriesSplit from sklearn.ensemble import RandomForestClassifier from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score from sklearn.preprocessing import StandardScaler # 假设df已经准备好因子列label列 X df[factor_cols].copy() y df[label].copy() # 时间序列切分训练集永远在过去测试集永远在未来 tscv TimeSeriesSplit(n_splits5) fold_auc [] for train_idx, test_idx in tscv.split(X): X_train, X_test X.iloc[train_idx], X.iloc[test_idx] y_train, y_test y.iloc[train_idx], y.iloc[test_idx] scaler StandardScaler().fit(X_train) X_train_s scaler.transform(X_train) X_test_s scaler.transform(X_test) clf RandomForestClassifier( n_estimators300, max_depth6, min_samples_leaf50, random_state42, n_jobs-1, ) clf.fit(X_train_s, y_train) pred clf.predict_proba(X_test_s)[:, 1] fold_auc.append(roc_auc_score(y_test, pred)) print(AUC per fold:, np.round(fold_auc, 4)) print(Mean AUC:, np.mean(fold_auc))几个参数需要解释。TimeSeriesSplit(n_splits5)是按时间顺序切5折训练集始终在测试集之前这比随机K折更贴近实盘因为股票数据有强时间序列依赖随机切分会把未来信息混进训练集导致AUC虚高。max_depth6和min_samples_leaf50是故意限制单棵树深度基本面因子样本量一般不大树太深就是纯记忆噪声叶子节点样本太少预测结果在样本外会蹦来蹦去。n_estimators300在这个数据规模下足够收敛再多也就是多花时间。3.3 多模型对比随机森林、XGBoost与逻辑回归怎么选源码名字叫“多种机器学习算法”我实际跑下来最重要的是先建一个逻辑回归做基准再和树模型比较。逻辑回归虽然抓不到交互但它能暴露数据泄露和特征清洗问题因为一旦有前视偏差线性模型和树模型的AUC都会虚高但树模型虚高得更明显你分不清是模型能力强还是数据脏。try: from xgboost import XGBClassifier xgb XGBClassifier( n_estimators300, learning_rate0.03, max_depth4, subsample0.8, colsample_bytree0.8, eval_metricauc, early_stopping_rounds20, ) xgb.fit( X_train_s, y_train, eval_set[(X_test_s, y_test)], verboseFalse, ) xgb_pred xgb.predict_proba(X_test_s)[:, 1] except ImportError: print(xgboost未安装跳过)early_stopping_rounds20是我比较坚持的参数一旦测试集AUC连续20轮不提升就停止训练。如果不加这个300棵树的XGBoost很容易后期过拟合训练AUC升到99%测试AUC却开始往下掉。subsample0.8和colsample_bytree0.8是让每棵树用80%的样本和80%的因子增加随机性树之间相关性降低集成效果更好。逻辑回归那边我建议用C0.1做一点正则别让它把因子系数学得太极端。跑完对比我的判断标准是如果随机森林和XGBoost的AUC比逻辑回归高不到0.01那大概率不是模型不够强而是特征里没有可挖掘的交互信息。这时候与其调参不如回去看因子加工。4. 回测与评价别只看准确率IC与分层回测才见真章4.1 从预测分数到持仓分组最简单的分层回测模型输出的是概率或分数不是买卖信号。我习惯先把样本按预测分数从高到低分成5组或10组然后看每组未来收益的中位数。如果第1组收益单调递减到第5组说明模型确实有排序能力如果中间挤成一团只有两端有差异那模型在中间区域的预测基本是噪音。df[pred] np.nan for train_idx, test_idx in tscv.split(X): # 这里复用上一章训练好的clf df.iloc[test_idx, df.columns.get_loc(pred)] clf.predict_proba(scaler.transform(X.iloc[test_idx]))[:, 1] df[group] df.groupby(date)[pred].transform( lambda s: pd.qcut(s, 5, labelsFalse, duplicatesdrop) ) group_ret df.groupby([date, group])[future_ret].median().unstack() print(group_ret.mean())qcut是按每期的预测分数分位数分5组duplicatesdrop是防止某期预测值相同导致分位边界报错。这里必须按date分组做分位数不能全局分因为不同市场环境下模型打分的整体高低会漂移全局分组会让某些时期一组臃肿。4.2 评价指标IC、Rank IC、多空收益与换手率模型预测分和实际收益之间的相关性比AUC更直观。IC用的是Pearson相关Rank IC用Spearman相关后者只关注排序关系更适合选股场景因为最后买不买只取决于相对排名。指标计算方式我习惯的阈值IC每期预测分与收益的Pearson相关均值0.02才敢用Rank IC每期预测分与收益的Spearman相关均值0.03且IR0.2多空收益Top组收益 - Bottom组收益年化8%才有意义换手率每期调仓的股票变动比例30%比较合理IC均值不是全部IC的稳定性更重要。我用IC_IRIC均值/IC标准差衡量稳定性小于0.2的模型即使均值不错也容易在一段时间内持续失效。4.3 画出净值曲线用matplotlib判断模型有没有失效净值曲线能直接看出模型在哪些时间段失效。我习惯画两条线一条是模型多空组合的累计净值一条是基准比如全样本等权。import matplotlib.pyplot as plt group_ret_long_short group_ret[4] - group_ret[0] # 第5组减第1组 nav (1 group_ret_long_short).cumprod() nav.index pd.to_datetime(nav.index) plt.figure(figsize(10, 4)) plt.plot(nav, labellong-short NAV, linewidth2) plt.axhline(1.0, colorgray, linestyle--) plt.legend() plt.title(Model Long-Short Cumulative Return) plt.show()画这个图不是走形式。如果净值曲线在某个时间区间持续回撤超过6个月我会怀疑这个模型已经失效而不是短期波动。这种时候先别急着调参先去看那段时间的因子分布——是不是某些因子在横盘市里集体失效。源码里的数据集可以支撑这种复盘直接把那段时间的因子均值画出来对比即可。5. 实战避坑五个让我翻过车的基本面量化细节5.1 数据泄露用了未来信息还浑然不觉现象训练集AUC高达0.98但一到回测就亏钱怎么看都不对。原因合并因子时没有考虑披露延迟直接把财报期末数据当成当期可用数据。比如一季报披露日往往是4月底但财报期末是3月31日如果不做merge_asof模型就等于提前一个月看到了财报。解决所有因子在进入模型前必须统一打上“可获取日期”用directionbackward的merge_asof向收益匹配。这个坑检查起来最难因为AUC不降反升我每次都会单独写测试把因子日期随机打乱如果AUC没有明显下降就说明训练过程在靠未来信息硬撑。5.2 重复文件导致样本量虚高现象合并后的数据和单独看每张表的行数对不上模型训练时报索引重复。原因压缩包里17LM.csv和94tang.csv出现了两次如果直接用glob读取同一个因子会被读入两次merge时会产生笛卡尔积。解决在读取阶段用drop_duplicates(subset[stock_code, date], keeplast)并且打印合并前后shape对比确认行数没暴涨。这个坑很小但一旦踩到后期所有指标都是错的。5.3 直接dropna导致样本选择偏差现象模型在测试集上表现不错但选出来的股票集中在少数大型成熟公司。原因基本面因子中研发投入类变量缺失严重直接dropna()会把高成长但尚未盈利的公司全部删掉样本失去代表性。解决按因子覆盖率过滤然后对缺失值按日期分组做中位数填充。填充值也必须有滞后性不能用未来中位数。这一点在金融数据上尤其重要。5.4 训练集和测试集随机切分现象AUC稳定在0.75以上实盘却跑不出回测效果。原因随机K折把同一时期的数据既放训练集又放测试集时间序列上的自相关性让模型学到了“整体市场涨跌”这种非个股信息。解决换成TimeSeriesSplit并保证训练集结束日期严格早于测试集开始日期。交叉验证代码里这个改动就一行但效果差异极大。5.5 只盯准确率不看IC稳定性现象模型整体准确率不错但换仓后连续两个月跑输基准。原因准确率这种指标对类别不平衡不敏感当市场上涨时即使模型什么都不学只要预测多数为涨准确率也会虚高。解决把评价指标改成Rank IC并计算IC_IR。我给自己定的标准是Rank IC均值0.03且IC_IR0.2低于这个线就退回特征加工阶段而不是继续调参。6. 进阶预测结果上实盘前先过这三道验证模型回测做完不急着模拟盘我会再补三道验证这套流程是从源码项目里学到的血泪经验。第一道是滚动训练验证。不能用一次性训练出来的模型覆盖全部历史因为基本面因子的有效性会衰减。我用60个月训练、12个月测试的窗口每次往前滚动12个月得到一组“样本外”预测。这样得到的净值曲线才是模拟实盘的真实曲线而不是用全样本训练一次再回测的作弊曲线。final_pred pd.Series(indexdf.index, dtypefloat) for start in range(0, len(df) - 72, 12): train df.iloc[start:start60] test df.iloc[start60:start72] # 这里复用前面的训练流程 scaler.fit(train[factor_cols]) clf.fit(scaler.transform(train[factor_cols]), train[label]) final_pred[test.index] clf.predict_proba(scaler.transform(test[factor_cols]))[:, 1]第二道是因子单调性检查。用上一章的分层结果看每一组的平均收益是否严格单调。如果第1组到第5组的收益是“高-低-中-高-低”说明模型只是抓住了部分极端样本中间地带是黑匣子。第三道是换手率压力测试。把调仓周期从月度改成双周看换手率翻了多少倍。如果换手率超过50%交易成本会吃掉大部分超额收益。我一般会强制加一个持仓置信度阈值预测分只有进入前20%或后20%才触发调仓中间部分不动作。从那以后我每次拿到新的量化源码都强制走一遍这三道验证尤其是滚动训练它治好了我“回测很兴奋、实盘就慌张”的老毛病。希望帮到你。本文还有配套的精品资源点击获取