ARTICLE DETAIL

建站实战干货

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

Python机器学习驱动的基本面量化:从财报清洗到因子建模实战

2026/9/28 6:55:17 拓冰建站 浏览量
Python机器学习驱动的基本面量化:从财报清洗到因子建模实战 简介面向金融量化与机器学习交叉方向的研究者、学生及入门实践者这份项目资料围绕基本面量化投资展开结合市盈率、ROA、ROE、NOA、有形资产等财务因子数据演示如何用多种机器学习算法完成因子分析、建模与预测适合作课程设计或毕业设计参考。压缩包共183个文件其中167个csv为数据集11个py为可运行源码另有3个pdf说明文档及1个md说明包体约145.6MB目录层次清楚便于定位数据与代码。源码均经过本地编译运行评审分95分以上难度适中内容经过审定能够支撑完整复现与二次扩展。目前已有309人浏览学习适合需要一份可直接运行的量化投资机器学习项目作为起步模板的读者下载使用。1. 机器学习驱动的基本面量化先别急着选模型先想清楚赚的是谁的钱一个很反直觉的事实是用 Python 做机器学习驱动的基本面量化投资最容易赚钱的环节不是选模型而是把财报数据洗干净。股票样本量撑死几千只有效历史不过十几年信噪比低到让任何复杂算法都接近随机猜测当你把一个机器学习模型在训练集上的 R² 做到 0.8 以上时八成不是找到了圣杯而是标签泄漏了。这个基于 Python 的机器学习驱动的基本面量化投资研究项目把源码和数据集打包在一起正好把研究门槛从「读论文」降到「能复现」。适合两类人一类是有 Python 基础、想用真实财务数据跑通因子研究流程的量化入门者另一类是已经在做技术面或价量策略、想补充基本面维度的从业者。它解决的核心问题不是预测股价而是用一套可复现的流程把公司财务数据变成能进模型的特征再用多种机器学习算法做横截面选股。2. 拿到源码包先做什么目录结构、数据字典与三大预设的前提假设研究类压缩包和工业项目不一样它的价值密度分布极不均匀。真正值钱的不是 train.py 那几百行代码而是数据集和对齐逻辑。所以拿到包以后第一件事不是急着跑 demo而是先规划时间用七成时间理解数据两成时间复现因子一成时间调参。2.1 先看目录再跑代码一个量化研究包的典型骨架常见的做法是先把它解压到一个独立目录里避免和别的项目混在一起。我一般会先跑一遍 tree 看层级再决定从哪入口切入。mkdir -p ~/projects/fundamental_ml cd ~/projects/fundamental_ml unzip ../fundamental_ml_source.zip -d . find . -maxdepth 2 -type d | head -30 find . -maxdepth 2 -type f -name *.py | head -20逻辑说明第一步建独立目录防止包里的相对路径把文件散落到系统各处第二步解压第三步用 find 列出文件夹和脚本分布。这一步的意义在于先摸清模块边界避免一上来就打开某个三百行的脚本从头读到尾。head -30和head -20只是限制输出行数实际文件数不用追求精确重要的是看清有没有 data / feature / model / backtest 四类模块。参数说明-maxdepth 2代表只往下看两层目录够判断数据文件夹和代码文件夹的归属再深容易陷入细节。解压之后如果发现数据是 csv 或 parquet优先打开数据字典如果整个包里一个说明文档都没有就要做好逐字段排查的心理准备。2.2 数据字典里最容易漏掉的事实面板结构、财报发布日期与存量字段量化研究用的基本面数据本质上是一个面板结构行是「股票 × 时间」列是财务指标。但很多人拿到的 CSV 并不是这种结构而是「每行一只股票、每列一个日期的宽表」或者是「每行一份财报、多个报告期混在一起」的长表。判断标准就一句话能不能唯一确定一行是「某只股票在某一个横截面的最新可得财务值」。股票代码 报告期 财报发布日期 营业收入 净利润 000001 2023-12-31 2024-03-20 ... ... 000001 2024-03-31 2024-04-26 ... ...这段示意数据说明真正该关注的字段是「财报发布日期」而不是「报告期」。原因在于实盘里你只能在财报发布之后看到数据如果使用报告期做对齐就会把 2024 年一季报的数据平成到 2024 年 3 月 31 日当天而实际上这份财报要到 4 月底才披露这个时间差就是未来函数最常见的来源之一。存量字段也要留意比如「总股本」在财报里是快照值在行情接口里可能是实时值两种口径直接混用会让因子失真。2.3 三大预设假设未来函数、行业中性化、训练切分的时序性任何一个基本面量化源码包背后都藏了三个预设假设。第一是假设数据已经做了 point-in-time 处理即每个截面只使用当时已经公开的信息第二是假设特征已经做过行业中性化比如市盈率在银行和白酒之间没有可比性第三是假设训练集和测试集是按时间切分而不是随机切分。这三个假设只要有一个不成立后面的回测指标全部作废。特征名 含义 处理方式 pe_ttm 滚动市盈率 按行业去极值后再做行业中位数差分 roe_ttm 滚动净资产收益率 原始值不做中性化 log_mv 对数市值 保留原始值作为回归残差化的控制变量这张小表来自我常见的数据字典改造方案作用是把「原始财务字段」和「进入模型的因子字段」区分开。pe_ttm不直接进模型先进去极值再做行业中性化log_mv则专门用来吸收市值效应。拿到源码包后建议你把数据字典里的字段名逐条对照这张表凡是缺了处理逻辑的字段默认它未经清洗不要直接送进模型。3. 用 Python 把财报数据变成特征矩阵对齐、去极值与行业中性化的落地代码数据清洗这一步决定了机器学习项目的一半成败。基本面量化尤甚因为财报数据天然存在缺失、异常值、行业跨度三个问题而这三点恰好是主流机器学习库的盲区。下面三步是我对这类包做特征工程时固定会走的流程。3.1 用 Python 对齐多张财报表从宽表到面板的合并套路拿到原始数据后常见情况是因子分散在多个 CSV 里一张表存估值指标一张表存成长指标还有一张存行情快照。先把它们合并成一个面板再按股票代码和交易日期做排序。直接丢弃所有字段缺失的行是一种常见误用正确做法是先将字段按“财报发布日期”映射到最近的一个交易日再决定缺失填充方式。import pandas as pd valuation pd.read_csv(valuation.csv, parse_dates[announce_date]) growth pd.read_csv(growth.csv, parse_dates[announce_date]) price pd.read_csv(price.csv, parse_dates[trade_date]) # 把公告日映射到最近的交易日 ann_dates sorted(price[trade_date].unique()) def nearest_trade_date(d, candidatesann_dates): idx pd.Series(candidates).searchsorted(d) if idx len(candidates): return candidates[-1] return candidates[max(idx - 1, 0)] valuation[mapped_date] valuation[announce_date].map(nearest_trade_date) growth[mapped_date] growth[announce_date].map(nearest_trade_date) # 用映射后的日期做左连接 panel valuation.merge( growth, on[stock_code, mapped_date], howleft, suffixes(_val, _gro) ).merge(price, left_on[stock_code, mapped_date], right_on[stock_code, trade_date], howleft)逻辑说明第一段读入三张原始表第二段把公告日换算成交易日这一步的作用是让财务数据与行情数据对齐到同一个时间轴上第三段用merge把估值、成长和价格信息拼成一张宽表。suffixes参数专门用来区分两张表中重名的字段比如pe_val和pe_gro。这里有一个细节searchsorted要求候选列表有序所以ann_dates必须先从 DataFrame 里取出并排序。参数说明howleft在第一步连接里以估值表为主体某些成长字段合并后为空这是预期内的缺失不要在这一步删行留给去极值模块处理。parse_dates让 pandas 在读入时直接转成时间对象避免后续反复调用to_datetime。3.2 去极值与标准化MAD 法与 z-score 在这个场景里的参数选择金融数据天然带厚尾直接做 z-score 会把极端值变成巨大的离群值。常见做法是先用 MAD绝对中位差把极端值拉回边界再做标准化。在基本面场景里我惯用 3 倍 MAD 作为阈值而不是 5 倍因为财报因子的信噪比更低3 倍已经能识别到真正异常的公司。import numpy as np def mad_winsorize(series, n3): median series.median() mad (series - median).abs().median() if mad 0: return series lower median - n * 1.4826 * mad upper median n * 1.4826 * mad return series.clip(lower, upper) # 按每个截面做去极值 panel[pe_mad] panel.groupby(mapped_date)[pe_ttm].transform(mad_winsorize) panel[pe_z] panel.groupby(mapped_date)[pe_mad].transform( lambda s: (s - s.mean()) / s.std() )逻辑说明transform保证去极值和标准化的统计量都是按同一个交易日截面计算的这符合量化研究里「横截面处理」的原则。1.4826是 MAD 与标准差的换算系数假设正态分布时用它把 MAD 折算成标准差尺度。先裁剪后标准化的顺序不能反如果先算 z-score 再裁剪均值和方差会被极端值污染。参数说明n3是阈值倍数研究探索阶段可以试 2.5 到 4。值调小会让因子分布更平滑但也可能把真实的基本面极端变化当噪声削掉调大则保留更多尾部信息对后续模型的正则化压力更大。标准化的std()默认用总体标准差横截面上的样本量通常几百只这个差别很小不用刻意修。3.3 行业中性化与市值处理回归残差化的一个小实现传统做法是直接减去行业中位数这个办法方便但粗暴更稳的做法是把因子对行业虚拟变量和对数市值做线性回归取残差作为中性化后的因子值。原因在于市值和行业对因子解释力的重叠度很高只做中位数差分会留下可被市值解释的部分。import statsmodels.api as sm def industry_neutralize(factor, industry_dummies, log_mv): X pd.concat([industry_dummies, log_mv], axis1) X sm.add_constant(X) model sm.OLS(factor, X).fit() return model.resid # 示例对 pe_z 做中性化 panel[pe_neu] panel.groupby(mapped_date).apply( lambda df: industry_neutralize( df[pe_z], pd.get_dummies(df[industry]), df[log_mv] ) ).reset_index(level0, dropTrue)逻辑说明这里直接用 OLS 把因子中可以被行业和市值解释的成分剔除。resid是回归残差代表剔除行业和市值影响后留下的纯因子收益。groupby.apply保证每个截面独立回归不跨时间泄漏信息。industry_neutralize里的X同时包含行业虚拟变量和市值这一步能解决银行股 PE 低、成长股 PE 高的经典干扰。参数说明add_constant是必须的否则回归会强制过原点残差性质会变差。get_dummies默认生成 k 列行业虚拟变量与截距项一起会出现完全共线性statsmodels 会自动剔除冗余列但如果换成 sklearn 的 LinearRegression需要手动drop_firstTrue这是两个库之间最容易翻车的地方。4. 多种机器学习算法同台对比逻辑回归、随机森林与 LightGBM 的参数设置与选型逻辑基本面量化里没有「一个模型打天下」这回事。财报因子的结构往往是稀疏的、有缺失的、且包含大量离散行业信号所以选模型的标准只有一个在不同复杂度之间做基线对比。用逻辑回归定位下限用随机森林看非线性上限用 LightGBM 做最终调优是我在这个项目里反复验证过的高性价比组合。4.1 为什么拿逻辑回归当基准可解释性与坏样本的比例陷阱逻辑回归作为基准的意义不在预测精度而在透明度。它的预测结果可以近似折回去看原始特征方向这在因子研究里尤其重要——一个因子如果连系数符号都和经济学直觉相反那它大概率有数据问题。同时股票收益的二元化处理存在坏样本比例陷阱把收益为正定义为 1会受市场整体牛熊影响牛市的 IC 天然高。from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score # 按时间切分前 80% 时间做训练 split_idx int(len(panel) * 0.8) train panel.iloc[:split_idx] test panel.iloc[split_idx:] feature_cols [pe_neu, roe_ttm, profit_yoy, debt_ratio] X_train, y_train train[feature_cols], (train[next_ret] 0).astype(int) X_test, y_test test[feature_cols], (test[next_ret] 0).astype(int) model LogisticRegression(max_iter500) model.fit(X_train, y_train) print(AUC:, roc_auc_score(y_test, model.predict_proba(X_test)[:, 1]))逻辑说明切分时严格按行序号因为面板已经按时间排序序号切分等价于时间切分。随机切分在时序数据里是不能用的否则模型会在未来数据上训练把标签泄漏变成一种隐性形式。predict_proba取第二列为预测为正类的概率roc_auc_score用来衡量排序能力而不是绝对的分类正确率排序能力才是选股真正关心的。参数说明max_iter500是因为基本面特征通常几十维默认的 100 次迭代可能不收敛如果特征做过分箱或引入了高阶交互还可以加正则项C0.5但第一个版本先不要动它把精力留到特征上。4.2 随机森林的常用参数n_estimators、max_depth 与 min_samples_leaf 在截面预测上的设置随机森林在这里承担的角色是「探测非线性」。基本面因子之间确实存在交互比如高 ROE 加低负债率的效果不是各自独立相加的。但随机森林有个致命弱点在低信噪比数据上容易过拟合表现得像一个记住训练样本的查找表。from sklearn.ensemble import RandomForestClassifier rf RandomForestClassifier( n_estimators300, max_depth5, min_samples_leaf50, max_featuressqrt, random_state42, n_jobs-1 ) rf.fit(X_train, y_train) print(RF AUC:, roc_auc_score(y_test, rf.predict_proba(X_test)[:, 1]))逻辑说明max_depth5是刻意限制树的高度因为截面样本里真正的信号非常弱深树只会把噪声当模式记下来。min_samples_leaf50是另一个防止过拟合的关键参数它规定每个叶子节点至少要有 50 个样本叶子越少越容易对个别股票单独建模。max_featuressqrt让每棵树只随机看部分特征增加树间差异这是随机森林相对 bagging 的本质改进。参数说明n_estimators300不是越大越好到达一定量后 AUC 提升趋缓换来的是训练时间和内存成本。如果机器是 8 核以下可以先降到 150观察 AUC 差值是否超过 0.01超过就补齐不超过说明已经收敛。4.3 LightGBM 的收敛代价早停轮数、num_leaves 与特征重要性排序LightGBM 是目前这类项目里效果最稳的选择原因是它天生支持缺失值、对类别特征友好、训练快。代价是参数敏感度远高于随机森林几个关键参数设错结果直接从优秀变垃圾。import lightgbm as lgb from sklearn.model_selection import train_test_split X_tr, X_va, y_tr, y_va train_test_split( X_train, y_train, test_size0.2, shuffleFalse ) lgb_model lgb.LGBMClassifier( num_leaves15, learning_rate0.03, n_estimators500, feature_fraction0.6, bagging_fraction0.7, bagging_freq1, reg_alpha0.5, reg_lambda2.0, random_state42 ) lgb_model.fit( X_tr, y_tr, eval_set[(X_va, y_va)], eval_metricauc, callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)] ) print(LGB AUC:, roc_auc_score(y_test, lgb_model.predict_proba(X_test)[:, 1]))逻辑说明这里用了一次内置的验证集划分shuffleFalse保证验证集在时间上晚于训练集避免随机打乱带来的未来信息。feature_fraction和bagging_fraction分别是列采样和行采样目的和随机森林的max_features一致都是增加基学习器之间的独立性但 LightGBM 默认不带这些正则要手动打开。early_stopping(50)是防止训练轮数过多导致过拟合的重要开关模型在验证集 AUC 连续 50 轮不增长时主动停下。参数说明num_leaves15是 LightGBM 的核心复杂度参数经验上初始值设为 2 的 4 次方左右再根据过拟合程度微调。learning_rate0.03配合n_estimators500形成「小步慢跑」的配比如果学习率调到 0.1n_estimators应减半否则验证集 AUC 会先涨后跌。reg_alpha和reg_lambda分别控制 L1 和 L2 正则在特征数少于 50 的基本面场景里先保持reg_lambda2.0reg_alpha可以置 0。训练完成后务必打印feature_importance(gain)按分裂增益汇总特征贡献。如果排名前三的特征都是市值、行业这类控制变量说明原始因子还没有被充分中性化此时加再多模型复杂度都是在拟合噪声。5. 回测里的避坑清单幸存者偏差、标签泄漏与样本外失效的三类血泪现场这部分是整个项目里最不值钱也最值钱的部分——不值钱是因为它不产生任何漂亮曲线值钱是因为它能替你挡掉 80% 的无效工作。下面四条是我反复踩过的坑按现象、原因、解决三段式记录。5.1 幸存者偏差模型看起来每年都赚实盘一上就暴露现象训练出来的模型在回测区间里每年稳定跑赢基准但一到实盘就频繁买到退市股净值曲线头半年就开始跑输。把回测股票池和实盘股票池一对比发现 ST 股和已退市股在回测里根本不存在。原因数据供应商的常见做法是只保留当前仍在上市的股票历史上的退市股票根本没进数据库。用这套数据训练模型模型学到的是「这些股票今天还活着」天然躲开了退市风险但实盘买的时候无法预知哪只会退市。解决换用带退市标记的完整历史数据回测池包含「当时存在但后来退市」的股票。具体操作用退市名单做标记训练阶段把is_delisted作为一个已知标签列保留而不是删除。如果拿到的源码包数据是快照型至少要检查最早年份的股票数量与最晚年份的数量差差额接近零就是幸存者偏差的危险信号。5.2 标签泄漏预测未来收益的标签用了未来数据现象训练集 AUC 高达 0.88验证集 0.75测试集 0.7看似稳健但把特征按时间画折线发现某个因子的分布在某一天突然跳变恰好对应财报季。原因标签next_ret构造时「下期收益」包含了从 T1 到 T20 的收益但当 T10 出了新财报你在 T 时刻的特征矩阵里用到了这份财报的新数据相当于让模型提前看到了未来。另一种常见泄漏是去极值和标准化用了全样本的统计量比如用 2024 年一整年的均值去标准化 2020 年的截面数据。解决构造标签时先把「财报发布日期」晚于 T20 的财报数据剔掉再合并特征。同时把标准化、去极值从全样本改成滚动截面每组统计量只用当前时点之前的数据计算。一个快速自检技巧把特征和标签分别加上时间戳随机取一个标签检查其特征是否全部早于标签所属时点。5.3 样本外失效训练集和测试集同分布实盘是另一个世界现象模型在测试集上排名第一换到重点股票池以后 AUC 直接掉到 0.52接近瞎猜。特征分布图对比显示测试集里roe_ttm的中位数远高于实盘新数据的值。原因财报数据在时间上有明显漂移比如近几年成长股 ROE 分布整体上移模型在旧分布上学到的阈值对新分布不再适配。这种情况在随机森林里尤其明显因为它会记住训练集的特征空间边界数据一漂移就外推失效。解决加时间衰减权重让训练样本按距离当前时间指数递减。LightGBM 里直接传sample_weight逻辑回归则在训练前对靠近当前时间的样本做重采样。另一个关键动作是给连续因子做分位数变换而不是用原始值分位数让因子分布变得平稳涨潮退潮时都能保持可比。5.4 过拟合的隐形信号排名 IC 逐年衰减但整体均值很高现象全样本 IC 均值 0.06单年看第一年 0.11第二年 0.07第三年 0.02逐年衰减但整体均值和显著性都还能看。原因这是过拟合的温床之一。前期调参时不知不觉把模型调成了「最懂历史前三年」的形态后几年才是真实表现。整体均值好看是幸存者偏差的另一种形式时间序列上的过拟合被平均值掩盖。解决把回测结果按年度拆开看排名 IC如果单调衰减立刻停止调参回到特征层面。更严格的做法是做 walk-forward 验证把时间切成 5 段训练第 1-3 段预测第 4 段再训练 1-4 段预测第 5 段每次只保留样本外段落的表现。这个历史上排名第一但逐年衰减的模型我后来发现是早年调learning_rate时不小心用到了全样本的均值做标签标准化属于最典型的一类踩坑。6. 把模型搬进实盘滚动训练频率、仓位映射与每季度一次的人工复核模型在样本外表现不错接下来才是和真实市场交手。滚动训练、仓位控制和定期复核三者缺一不可这才是把「研究项目源码」变成个人实盘策略的最后一步。6.1 滚动训练的最小闭环3 年窗口与月度重训我惯用的配置是训练窗口 3 年每月第一个交易日重训一次验证集用最近 6 个月。窗口太短跨市场周期的样本不够窗口太长老样本拖慢对新市场结构的适应。重训频率不必高于月度基本面数据本身就按季度发布模型更新再快也追不上数据发布节奏。def rolling_train(df, feature_cols, ret_col, train_years3, freqME): df df.sort_values(trade_date) start df[trade_date].min() retrain_dates pd.date_range(startstart pd.DateOffset(yearstrain_years), enddf[trade_date].max(), freqfreq) for t in retrain_dates: train_data df[df[trade_date] t] train_data train_data[train_data[trade_date] t - pd.DateOffset(yearstrain_years)] # 每次重训前重新做中性化与标准化避免用到未来统计量 ...逻辑说明retrain_dates生成每个月的最后一个交易日作为重训时点train_data取距当前不超过 3 年的数据保留最近信息。每次重训都重跑一次中性化和标准化这个动作在代码里看起来冗余实际意义重大它确保没有未来统计量泄漏进训练集。freqME是 pandas 2.2 之后的月度频率写法老版本用M升级 pandas 后代码会报错这是环境迁移时的经典坑。6.2 把模型分数变成仓位分位数映射与约束条件模型输出的是股票优劣排序概率不能直接当仓位。常见做法是把预测分数按横截面排序分五组或十组只买最顶层每组内部等权或按分数加权。分位组 持仓比例 调仓周期 适用场景 Top 10% 总资金 40% 月度 进攻型 Top 20% 总资金 60% 月度 均衡型 Top 30% 总资金 80% 季度 稳健型这张参数表说明分位越窄单票集中度越高进攻性强但对错误预测的容忍度低。Top 30% 配季度调仓的版本交易频率低适合手工跟单Top 10% 月度调仓的版本冲击成本大建议资金量超过百万时把最高单票仓位压到 8% 以内。无论哪种组合行业暴露上限建议控制在 30%避免模型在某一个行业上过度自信。6.3 每季度一次的人工复核清单模型表现好时最容易松懈的是复核环节。我固定的习惯是每个季度末做四件事打印近 90 天预测分数最高的 20 只股票逐一翻财报确认有无暴雷重算因子 IC 和换手率看是否同步下滑检查新入池股票的平均市值是否比半年前变小警惕模型滑向小盘风格最后把预测分数和实际收益做成散点图看是否存在分段失效。这几年做下来我最大的教训是不要迷信 AUC不要在回测里反复调参更不要在模型连续三个月不赚钱时立刻换模型。基本面数据的发布周期决定了策略的验证周期是以季度计的任何按天评估的焦虑都只是在放大噪声。希望帮到你。本文还有配套的精品资源点击获取