news 2026/9/28 13:22:23

机器学习基本面量化实战:财报数据因子建模与回测防泄漏

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
机器学习基本面量化实战:财报数据因子建模与回测防泄漏

简介:这是一份将基本面分析与多种机器学习算法相结合的量化投资研究项目,面向金融、统计及计算机背景的学生、研究者和入门量化分析师。包内167个CSV文件构成基本面因子数据集(涵盖销售、ROA、ROE、市值、净经营资产等指标),11个Python源码实现多种机器学习算法的建模与回测流程,另有3个PDF辅助文档和Markdown说明。全部源码均已在本地编译运行通过,评审分达95分以上,项目难度适中,内容经助教老师审定,适合课程设计、毕业课题或个人研究时参考复现。压缩包约145.6MB,共183个文件,以数据表、脚本、文档三类组织,便于按数据、代码、说明的顺序展开学习。已有300余人学习下载,资源覆盖数据清洗、因子构建、多种算法训练与结果解读的完整研究链路,可直接对照运行。

1. 用机器学习做基本面量化:为什么低频的财报数据反而更适合个人研究者

把机器学习塞进量化投资,很多人第一反应是高频行情、逐笔盘口;但这个方向很不一样——它用 Python 把财报数据整理成特征,让多种机器学习算法去预测未来的超额收益。基本面量化听起来不如高频“性感”,但它有一个反直觉的优势:数据密度低、噪声相对小、逻辑链条清晰,个人研究者用一台普通电脑就能完整跑通,不需要券商机房,不需要低延迟通道。这套源码+数据集的组合,核心就是把「读财报 → 造因子 → 训练模型 → 回测验证」这条链路做成可复现的工程。适合两类人:一是想从传统财务分析转向量化投研的从业者,二是正在找机器学习落地场景、不想只做图像分类竞赛的学生。我拆过不少这类项目,多数代码并不复杂,真正的门槛全在数据对齐、标签构造和回测防泄漏上。

2. 从数据集到可运行的因子库:用 Python 把财报整理成模型能吃的格式

2.1 拿到压缩包先别跑模型,把数据集的底细摸清楚

很多人在这一步就翻车:解压后直接python train.py,然后报错、缺列、时间范围对不上,折腾一下午。我拿到任何量化项目的第一件事,永远是先读数据、打印字段、看缺失率和时间范围。常见做法是先把资产负债表、利润表、现金流量表和日行情表各看一眼,确认三张财务表的报告期数量、股票覆盖范围、行情表的交易日跨度。

import pandas as pd from pathlib import Path data_dir = Path("./data") # 这类项目通常把财报和行情分文件存放,先把关键文件加载进来核对 df_balance = pd.read_csv(data_dir / "balance_sheet.csv", parse_dates=["report_date", "ann_date"]) df_income = pd.read_csv(data_dir / "income_statement.csv", parse_dates=["report_date", "ann_date"]) df_cashflow = pd.read_csv(data_dir / "cashflow_statement.csv", parse_dates=["report_date", "ann_date"]) df_market = pd.read_csv(data_dir / "daily_market.csv", parse_dates=["trade_date"]) for name, df in [("balance", df_balance), ("income", df_income), ("cash", df_cashflow)]: print(f"{name}: shape={df.shape}, 报告期={df['report_date'].min()} ~ {df['report_date'].max()}") print(f" 缺失率Top5:\n{df.isna().mean().sort_values(ascending=False).head()}")

这段代码不干任何建模的事,只做三件事:确认三张财务表的行数是否在同一量级、报告期是否覆盖足够的季度、哪些字段缺失率超过 30%。缺失率过高的字段我一般直接放弃,不补。基本面数据不像行情数据那样可以用前值无缝填充,比如现金流量表里的“投资活动现金净流量”在某些行业就是天然的缺失,硬补只会给模型塞噪声。

需要注意字段里的ann_date(公告日期),这是整条管线的命脉。国内财报的report_date是季度末,比如一季报的report_date是 3 月 31 日,但公告日期往往在 4 月中下旬。如果只用report_date做时间对齐,等于在 3 月 31 日就知道了还在审计中的利润数据,这就是典型的未来函数。后面的每一节都会围绕「只用已公开信息」这条铁律。

2.2 用 merge_asof 把财报对齐到交易日:没有这一步,模型再强也是自欺欺人

财务数据和行情数据的频率完全不同,前者一个季度一条,后者一天一条。对齐的核心逻辑只有一个:在某一个交易日,我只能知道公告日期早于当天的财报,晚于当天的必须不可见。pandas 的merge_asof是处理这种前向对齐最顺手的工具。

# 行情表按交易日排序,财报按公告日排序,做向后匹配 df_market = df_market.sort_values(["stock_code", "trade_date"]) df_income = df_income.sort_values(["stock_code", "ann_date"]) # 只保留利润表里最常用的净利润字段,避免一次合并太多列 df_income_used = df_income[["stock_code", "ann_date", "report_date", "net_profit"]] aligned = pd.merge_asof( df_market, df_income_used, left_on="trade_date", right_on="ann_date", by="stock_code", direction="backward", ) # 每只股票在合并后做一次检查:确保公告日不晚于交易日 assert (aligned["ann_date"] <= aligned["trade_date"]).all(), "存在未来函数泄漏"

direction="backward"是关键参数,意思是“找最近一条公告日早于或等于当前交易日的财报”。这模拟了真实世界的信息流:4 月 20 日交易时,你只能看到 4 月 20 日之前已经公告的一季报,看不到 4 月 21 日才公告的内容。合并之后我还习惯加一个assert,这不是形式主义——很多项目倒就倒在数据某一行出了脏数据,断言能一次性把问题暴露在训练之前。

对齐之后的数据一个季度才刷新一次,意味着同一份净利润会连续被使用几十个交易日,这是基本面数据天然带来的“慢变量”特征。模型如果从这么多天里学到同样的信息,会放大单期财报的权重,后面做特征工程时要想办法缓解。常见做法是对原始因子做过去 N 日的均值或衰减加权,把“脉冲式更新”平滑成趋势变量。

2.3 行业中性化与市值处理:不剔除这两个噪声源,选股变成选行业

财报数据里有两类“伪信号”需要处理:行业属性和市值规模。钢铁行业的资产负债率普遍比消费品行业高,如果你不做处理,模型学到的可能是“钢铁行业应该被低配”。市值的影响更隐蔽,大市值公司的营收增速天然低于小市值公司,但两者在未来收益分布上有系统性差异。常见做法是按行业和市值做中性化,把因子的横截面排名放在同类公司之间比较。

def winsorize_and_neutralize(df, factor_col, industry_col="industry", mktcap_col="log_mktcap"): # 第一步:缩尾,防止极端值主导分组 lo, hi = df[factor_col].quantile([0.01, 0.99]) df[factor_col] = df[factor_col].clip(lo, hi) # 第二步:对行业哑变量和市值对数做线性回归,取残差 import statsmodels.api as sm X = pd.get_dummies(df[industry_col], drop_first=True).astype(float) X[mktcap_col] = df[mktcap_col] X = sm.add_constant(X) model = sm.OLS(df[factor_col], X).fit() df[factor_col + "_neu"] = model.resid return df

这段代码的核心逻辑是把行业和市值因素“回归掉”,残差就是中性化之后的因子值。用statsmodels而不是手写最小二乘,是因为它返回的resid直接可用,且代码更接近研究人员的习惯。注意winsorize要在中性化之前做,否则极端值会把回归系数拉偏。

提示:中性化应该在每个交易日截面上单独做,不能把全历史数据放在一起回归。原因很简单——行业构成和市值分布会随时间漂移,混在一起做会把未来信息带入历史。

3. 构造基本面因子:从原始三张表到有预测力的特征工程

3.1 基础财务因子:ROE、毛利率、负债率,先算对再谈优化

模型吃的是特征,不是原始报表。把净利润、营收、总资产这些绝对数直接丢给机器学习模型,效果往往很差,因为绝对数受公司规模影响太大。真正稳定的是比率类因子,它们天然做了规模归一。这一节我给出一组最常用的基础因子计算方式,里面每一步都有值得注意的细节。

# 假设 aligned 已经是按 2.2 合并好的宽表,先按股票和时间排序 aligned = aligned.sort_values(["stock_code", "trade_date"]).copy() # ROE:用归母净利润 / 归母净资产,注意不要用总资产 aligned["roe"] = aligned["net_profit"] / aligned["total_equity"] # 毛利率:营收减营业成本后除以营收,不是净利润率 aligned["gross_margin"] = (aligned["revenue"] - aligned["operating_cost"]) / aligned["revenue"] # 资产负债率:总负债 / 总资产,用于刻画杠杆水平 aligned["debt_ratio"] = aligned["total_liability"] / aligned["total_asset"] # 营收同比:与去年同季度比,避开季节性扰动 aligned["revenue_yoy"] = aligned.groupby("stock_code")["revenue"].pct_change(4)

pct_change(4)里的 4 表示往前推 4 期,对应财务数据的季度频率,也就是和去年同季度比较。这里有个容易弄错的地方:如果原始数据包含了年报和季报混合的序列,同一季度内会多出一期“年报”,pct_change(4)就会错位。所以我习惯在计算同比之前,先剔除report_date重复且报告类型为年报的行,保证每个季度只有一条数据。

这些因子里,roe和revenue_yoy对后续 A 股选股效果通常最明显。ROE 代表存量资本的盈利能力,营收同比代表增长动能,两者互补。若想要一个“质量因子”,可以把 ROE 再拆成三个部分的乘积:净利率 × 资产周转率 × 权益乘数,杜邦分解出来的每一项都有独立的商业含义。我在实际项目里会保留杜邦分解的三个成分而不是只留 ROE,因为模型可以自己学到它们之间的非线性关系。

3.2 衍生因子与平滑:基本面因子的信号周期是季度,必须做时间平滑

单期财报因子有一个固有缺陷:数据只在报告期更新,信息在两次公告之间完全静止。如果模型在 4 月 20 日看到一季报,那么这个 ROE 值会持续使用到 8 月底半年报公告。当初值不精准或者存在一次性损益时,这种“粘滞性”会在回测里制造虚假的持续性收益。常用做法是对原始因子做指数加权移动平均,让信号缓慢衰减而不是突然切换。

# 对因子做时间平滑:半衰期约 20 个交易日 def ewma_factor(s, halflife=20): return s.ewm(halflife=halflife, adjust=False).mean() aligned["roe_smooth"] = aligned.groupby("stock_code")["roe"].transform(lambda x: ewma_factor(x)) aligned["gross_margin_smooth"] = aligned.groupby("stock_code")["gross_margin"].transform( lambda x: ewma_factor(x) )

halflife=20的含义是:20 个交易日前的信息权重衰减到一半。这个参数对结果影响很大,太小会让信号频繁抖动,太大则反应迟钝。我一般会在 10 和 40 之间做网格搜索,而不是拍脑袋取一个固定值。另外强调一句:平滑必须在分组之后的因子序列上做,如果用pd.DataFrame.ewm跨股票平滑,那就等于把不同公司的数据混在一起,属于数据泄漏。

除了平滑,衍生因子还能做“变化量”和“意外量”。变化量是本期 ROE 减去上期 ROE,代表盈利的边际变化;意外量是实际公告值和分析师一致预期的差,但一致预期数据不在这个数据集里,所以只能用同比增速的变化来近似。变化类因子在基本面量化中的预测力往往不输水平类因子,因为市场价格已经部分包含了 ROE 的水平信息,而“改善”是增量信息。

3.3 特征筛选与标准化:先砍相关性,再在截面上做 Z-score

因子算完,常见误区是把所有因子一股脑送进模型。财报因子的相关性非常高,比如 ROE 和毛利率在很多行业里天然正相关,营收同比和净利润同比也高度纠缠。高相关特征不会让树模型崩溃,但会稀释每个特征的重要性,还会让线性模型变得不稳定。用一个简单的相关性过滤就能减少不少冗余。

# 计算因子相关性矩阵,剔除高相关特征 factor_cols = ["roe_smooth", "gross_margin_smooth", "debt_ratio", "revenue_yoy", "roe_change", "revenue_yoy_change"] corr = aligned[factor_cols].corr().abs() # 找出相关系数高于 0.7 的特征对,保留方差更大或者业务含义更清晰的那个 upper = corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool)) high_corr = [(col1, col2) for col1 in upper.index for col2 in upper.columns if upper.loc[col1, col2] > 0.7] print("需要关注的高相关对:", high_corr)

这个相关矩阵还有一种用法:不只用于删特征,也用于判断你的因子库是否真的“多元”。如果一个项目里所有因子都指向同一个维度,它们的相关矩阵会呈现一块巨大的高相关区块,这时模型的稳健性测试通常会暴露问题。

标准化的关键是从全样本 Z-score 换成截面 Z-score。全样本标准化等于把未来数据的均值和方差偷用了,这在训练测试切分中属于温和版泄漏,不会立刻让回测崩掉,但会让样本外表现明显衰减。正确的做法是:每个交易日,把所有股票在该因子上的取值做一次 Z-score,让模型看到的是“这只股票在当天的相对位置”。

def zscore_by_day(df, factor_col): # 按交易日分组,做截面标准化 cols = factor_col df[cols] = df.groupby("trade_date")[cols].transform( lambda x: (x - x.mean()) / x.std() ) return df aligned = zscore_by_day(aligned, ["roe_smooth", "gross_margin_smooth", "debt_ratio"])

截面 Z-score 做完之后,每个因子的均值接近 0、标准差接近 1,模型训练时收敛快很多。对于树模型,Z-score 不是必须的,但统一量纲后做可视化对比很方便,而且如果后面要接入线性模型或神经网络,这一步就成了必需品。

4. 多种机器学习算法怎么选:用统一打分框架让模型自己竞争

4.1 标签怎么打:未来 60 个交易日的收益分组,模型才有学习目标

无监督也能做选股,但绝大多数落地项目用的是监督学习。标签的构造方式直接决定模型在学什么。先把未来 N 日的收益率算出来,然后按阈值转成分类标签。N 的选择有三种常见窗口:5 日太短,基本面信息还没传导完;120 日太长,中间噪声累积过多;60 日是兼顾信号强度和样本量的常用折中。

# 计算未来 60 个交易日的收益率作为标签 df_market["ret_fwd_60"] = ( df_market.groupby("stock_code")["close"].shift(-60) / df_market["close"] - 1 ) # 合并到对齐好的特征数据上 df_data = aligned.merge( df_market[["stock_code", "trade_date", "ret_fwd_60"]], on=["stock_code", "trade_date"], how="inner", ) # 二分类标签:未来60天收益>5%标记为1,<=5%标记为0 df_data["label"] = (df_data["ret_fwd_60"] > 0.05).astype(int) print(df_data["label"].value_counts(normalize=True))

阈值 5% 不是随便拍的。如果取 0%,在震荡市里正负样本大约对半开,但模型区分的是“微涨”和“微跌”,对噪声非常敏感;取 10%,正样本占比可能掉到 20% 以下,类别失衡。5% 大致对应年化 25% 的收益门槛,落在可实现的区间内。我一般会同时打印正负样本比例,如果低于 1:2,就要考虑改阈值或者用样本权重补偿。

注意:shift(-60)会把每只股票最后 60 天的数据变成 NaN,这部分不能直接丢进训练集。没有未来收益的样本只能用于推理,不能用于训练,否则模型会隐式学到“尾部行情不参与”的偏差。

4.2 统一模型评估框架:把逻辑回归、随机森林、XGBoost、LightGBM 放在同一条流水线里比

标题里说“利用多种机器学习算法”,核心不是把一堆模型结果堆在一起,而是给它们一个公平竞争的擂台。同一个特征集、同一个标签、同一个时间序列切分方式,先各自跑一遍,再谈集成。我用TimeSeriesSplit而不是普通的train_test_split,因为股票数据按时间排序后,随机切分会让训练集看到未来。

from sklearn.model_selection import TimeSeriesSplit from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import roc_auc_score from xgboost import XGBClassifier from lightgbm import LGBMClassifier import numpy as np feature_cols = ["roe_smooth", "gross_margin_smooth", "debt_ratio", "revenue_yoy", "roe_change", "revenue_yoy_change"] X = df_data[feature_cols].fillna(0) y = df_data["label"] tscv = TimeSeriesSplit(n_splits=4) models = { "lr": LogisticRegression(max_iter=1000), "rf": RandomForestClassifier(n_estimators=200, min_samples_leaf=50, random_state=42, n_jobs=-1), "xgb": XGBClassifier(n_estimators=200, max_depth=3, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, eval_metric="auc", random_state=42), "lgb": LGBMClassifier(n_estimators=200, num_leaves=15, learning_rate=0.05, min_child_samples=50, random_state=42), } for name, model in models.items(): auc_list = [] for train_idx, valid_idx in tscv.split(X): model.fit(X.iloc[train_idx], y.iloc[train_idx]) proba = model.predict_proba(X.iloc[valid_idx])[:, 1] auc_list.append(roc_auc_score(y.iloc[valid_idx], proba)) print(f"{name}: AUC = {np.mean(auc_list):.4f} ± {np.std(auc_list):.4f}")

TimeSeriesSplit(n_splits=4)会把数据按时间顺序切成 5 段,每次用前 4 段训练、最后 1 段验证,滚动 4 次。这种“滚动前推”的验证方式最接近真实交易场景——你在今天做决策时,只能看到今天之前的数据。每个模型的关键参数都值得单独解释:

逻辑回归的max_iter=1000是因为财务因子共线性可能导致收敛慢;随机森林的min_samples_leaf=50强制每个叶子至少有 50 个样本,对弱信号数据而言是有效的防过拟合手段;XGBoost 把max_depth压到 3、subsample和colsample_bytree都设为 0.8,是在方差和偏差之间找平衡,财务数据样本量通常只有几千到几万行,树太深必过拟合;LightGBM 的num_leaves=15取代了对max_depth的直接控制,叶子数越多模型越精细,但在小数据集上很容易把噪声也学进去。

4.3 调参顺序:先把随机性与样本量定死,再动树模型的三个核心旋钮

很多人一上来就抄 Kaggle 上的参数,这在基本面量化上几乎是灾难。财报数据一年只有 4 期,即使覆盖 2000 只股票五年,有效样本也就几万行,远小于图像或 NLP 的规模。我的调参顺序是固定的三层:第一层固定随机种子和样本权重;第二层用默认参数跑一遍作为基线;第三层只调对过拟合影响最大的三个参数。

# 一个简化版的基线调参流程:在验证集上比较不同 num_leaves 的效果 from sklearn.metrics import roc_auc_score X_train, X_valid = X.iloc[:8000], X.iloc[8000:] y_train, y_valid = y.iloc[:8000], y.iloc[8000:] for leaves in [8, 15, 31, 63]: model = LGBMClassifier(n_estimators=200, num_leaves=leaves, learning_rate=0.05, min_child_samples=50, random_state=42, verbose=-1) model.fit(X_train, y_train) auc = roc_auc_score(y_valid, model.predict_proba(X_valid)[:, 1]) print(f"num_leaves={leaves}: AUC={auc:.4f}")

在基本面数据上,num_leaves超过 31 后 AUC 往往不升反降,这是一个高频出现的规律。原因是财务因子之间的关系相对线性,过大的叶子数只是在记忆历史中的噪声组合。同理,min_child_samples建议从 20 起步往上调,learning_rate不要一上来就 0.3,用 0.05 配 200 棵树更稳妥。

如果模型 AUC 在训练集 0.75、验证集 0.55,说明过拟合严重,优先加min_child_samples或减小num_leaves,而不是加正则项硬压。正则项是最后的手段,它在压低方差的同时也会压低信号,而样本量不足时信号本来就很弱。

4.4 多模型集成:不是平均所有模型,而是让模型各司其职再投票

多模型集成有两种常见路径:一种是无脑平均所有模型的预测概率,简单但容易被弱模型拖累;另一种是用 Stacking,训练一个元模型去学习“什么时候该信哪个模型”。在样本量有限的场景下,我更推荐前者配合筛选,而不是复杂的 Stacking——元模型在小数据集上很容易二次过拟合。

# 用前一轮的模型对象,把各个模型的预测概率存成 DataFrame,再做等权平均 pred_dict = {} for name, model in models.items(): pred_dict[name] = model.predict_proba(X)[:, 1] pred_df = pd.DataFrame(pred_dict) # 简单看一眼模型之间的相关性,相关性过低的模型才值得集成 print(pred_df.corr()) # 集成分:先对每个模型的预测做截面排名,再取平均排名 def rank_score(df): return df.rank(pct=True).mean(axis=1) df_data["ensemble_score"] = rank_score(pred_df)

rank(pct=True)会在同一批股票内部把概率转为相对分位。直接平均概率有个问题:逻辑回归的概率普遍集中在中间,XGBoost 的概率分布更尖锐,平均之后量纲不一致会让模型分布形态更极端的算法主导结果。转成排名再平均,等于把每个模型当成一个打分投票器,谁强谁弱由它在样本外的表现决定,这比裸平均稳健得多。

如果某个模型的 AUC 明显低于其他模型,可以先用它和其余模型的预测做相关分析。相关度高说明它没提供增量信息,剔除即可;相关度低说明它捕获了不同的模式,即使整体 AUC 低也值得保留,因为集成的价值恰恰来自低相关性的多样模型。

5. 避坑清单:基本面量化项目里高频翻车的 5 个实战问题

5.1 幸存者偏差:数据集里只剩活着的公司,回测虚胖

现象:回测年化收益 30%,实盘三个月亏掉一半,很多人把原因归结为“市场变了”,但第一嫌疑是幸存者偏差。

原因:很多公开数据集只保留当前还在上市的公司,退市股的数据被整体剔除。模型训练时天然看不到“后来退市的公司”,于是学到一套只适用于优质公司的规律,回测当然好看。这在 A 股尤其致命,因为退市和 ST 公司恰恰是风险信号最密集的群体。

解决:拿到数据集后第一件事就是核实股票列表的快照时点。常见做法是找期初已经存在但期末已退市的股票是否还能在原始行情文件里查到。如果数据集里完全找不到退市股,回测前要主动从分组里排除 ST、*ST 和股价低于 1 元的样本,至少把最明显的风险桶遮住,再用“全市场回测 vs 剔除风险样本回测”两组结果做对比。

5.2 未来函数:财报公告日和报告期日混用

现象:模型在 3 月 31 日就给出了基于一季报的选股信号,回测曲线比真实操作提前启动了整整一个月。

原因:这是基本面量化最经典的坑。一季报的截止日是 3 月 31 日,但披露截止日是 4 月 30 日。有人直接用report_date做标签对齐,等于让模型提前看到了审计完的数据。

解决:强制使用ann_date作为对齐键,并在合并代码里加断言检查。更严苛的做法是使用ann_date再延后一个交易日,因为公告通常在收盘后发出,当天盘中无法交易。我在前面 2.2 节的merge_asof里已经演示过,这里再强调一句:任何事后计算的历史指标,只要是用report_date对齐的,都要怀疑是否引入了未来信息。

5.3 标签泄漏:标准化时用了全样本的均值与方差

现象:训练集 AUC 0.78,分层回测表现极好,但滚动训练时 AUC 一路下滑,滑到 0.52。

原因:对因子做标准化时用了整段历史的均值和标准差。比如用sklearn.preprocessing.StandardScaler在合并前的全量数据上 fit,未来数据的信息就通过均值偷偷流进了训练集。树模型不受量纲影响,所以换成随机森林时大家以为没事,但因子筛选时的相关性矩阵、缺失值填充时的全样本均值,都会引入同样的问题。

解决:所有预处理都严格放在训练集内完成。缺失值用训练集的中位数填充,标准化用训练集拟合的 scaler 转换验证集。验证集在预处理阶段的任何环节都不能参与 fit,只能 transform。这个小纪律能解决一半以上的“回测好、实盘差”问题。

5.4 类别不平衡:正负样本比例失衡,模型学会了永远说“跌”

现象:标签阈值为 0% 收益时正样本约 40%,模型 AUC 0.58,看着还行;但一看混淆矩阵,预测为正的比例只有 3%,模型几乎把所有股票都判成了“跌”。

原因:A 股长期来看处于震荡和下行的时间不短,未来 60 日收益为正的股票占比往往不到 50%,加上阈值调高到 10% 之后正样本进一步稀缺。逻辑回归在类别不平衡时会倾向于输出低概率,树模型则可能把少数类当成噪声。

解决:先看正样本占比,低于 25% 就启用class_weight="balanced"或在标签构造时降低阈值。更精细的做法是对训练样本做时间上的欠采样——不是随机丢弃负样本,而是保留所有正样本、按 1:2 的比例采样负样本。欠采样会损失一些信息,但在样本量足够大时可接受。注意:验证集必须保持原始分布,否则 AUC 会被虚高。

5.5 代码复现不了:路径写死、Python 版本不一致、随机种子没固定

现象:同一份源码在作者电脑上能跑出 AUC 0.72,别人拿到手跑出 0.65,再跑一次又变成 0.68。

原因:这类项目最常见的三个元凶:一是数据路径用了绝对路径,换机器必挂;二是train_test_split或树模型没有固定random_state,导致每次结果不同;三是 pandas 版本升级后sort_values的稳定性变化,导致同一批数据的行顺序不同,进而影响树模型的切分点。

解决:拿到代码的第一时间,先把所有绝对路径改成基于项目根目录的相对路径,然后把每个模型的random_state固定下来,最后在文件开头打印环境版本号,方便别人复现。不要小看这个环境打印,遇到“我这边能跑你那边报错”的问题时,版本号比任何报错日志都能更快定位原因。

提示:以上五个坑里,幸存者偏差和未来函数是“方向上错了,怎么调参都没用”的致命伤;类别不平衡和标签泄漏是“结果虚高、实盘缩水”的隐形伤。跑通一个项目不算本事,能发现它的回测漏洞才算入门。

6. 进阶用法:用 SHAP 打开模型黑匣子,把预测结果变成可复核的每日选股清单

模型训练完,很多人的第一个问题是:它凭什么选这支票?这个问题在基本面量化里不是学术洁癖,而是实盘信任问题。没有解释的模型,你不敢重仓;不敢重仓的策略,参数再漂亮也没有意义。我在做完模型对比之后,必做的一步是 SHAP 值分析。

import shap # 用 XGBoost 当作解释对象(树模型解释速度快) explainer = shap.TreeExplainer(models["xgb"]) # 取最近一个交易日的全部股票做批量解释 shap_values = explainer.shap_values(X.iloc[-500:]) shap.summary_plot(shap_values, X.iloc[-500:], feature_names=feature_cols)

summary_plot的输出能直观看到每个因子对预测的贡献方向和大小。比如它会告诉你roe_smooth偏高时模型倾向于给正分,而debt_ratio过高时模型会强烈扣分。这个结论可以直接拿回基本面逻辑里验证:是不是符合常识?如果某个高贡献因子在逻辑上站不住脚,那它大概率是数据泄漏的产物,而不是真实的选股信号。

解释完模型,最后一公里是把预测分数落到每日操作。我会生成一个带解释字段的选股清单,而不是只丢一个股票代码列表。这样每天复盘时能快速盯住“为什么是它进榜”。

latest_date = df_data["trade_date"].max() today_data = df_data[df_data["trade_date"] == latest_date].copy() today_data["ensemble_score"] = df_data.loc[today_data.index, "ensemble_score"] # 选总分前 20,且排除净资产为负的极端公司 picks = today_data.sort_values("ensemble_score", ascending=False) picks = picks[picks["total_equity"] > 0].head(20) # 输出关键解释字段,供人工复核 picks[["stock_code", "trade_date", "ensemble_score", "roe_smooth", "debt_ratio"]].to_csv( "daily_picks.csv", index=False )

选股清单里,我会同时看三个维度:集成分排名、盈利因子排序、风险因子排序。一支股票如果集成分很高但负债率同时排名也很高,说明它可能是靠高杠杆驱动的高盈利,这类机会风险不小。人工复核的价值就在这一步——模型负责在海选阶段缩小范围,人负责在最后 20 支里做质量把关。

我最早做这类项目时,只看 AUC 和回测曲线,觉得模型黑匣子也没关系,结果把一套有明显泄漏的管线跑到了“模拟交易盈利”的地步,实盘前被我偶然发现对齐日期差了一个月。那次之后,我给自己立了个规矩:任何预测结果先做样本外排名稳定性检查,再做逐股解释检查,最后才生成清单。现在回头看,这个习惯帮我挡掉了至少三个会让回测和实盘严重背离的设计错误。这套流程里的每一步,从数据对齐、因子构造到模型对比、解释复核,没有哪一步是少数人才能掌握的神秘技术,真正需要的是对“信息何时可得”的敬畏。希望这些按真实踩坑经验整理出来的步骤,能帮你在自己的数据集上少走几段弯路。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 13:21:57

Python实现波束形成算法仿真:从CBF到MVDR的工程实践

简介&#xff1a;压缩包内共114个文件&#xff0c;含23个Python脚本、73张仿真结果图、17个编译缓存文件及1份README说明文档&#xff0c;整体大小约9.11MB。项目聚焦波束形成典型算法仿真&#xff0c;覆盖延迟求和、最小方差无失真响应&#xff08;MVDR&#xff09;、线性约束…

作者头像 李华
网站建设 2026/9/28 13:21:33

POST接口资产化实践:从接口失控到契约治理的全面复盘

干了几年接口平台维护&#xff0c;我对“接口资产化”这个词的体会&#xff0c;是从一场事故开始的。合作方下午三点在群里喊“你们的POST接口挂了”&#xff0c;我查了网关、查了Nginx、查了连接池&#xff0c;最后发现是上游团队一个多月前把POST /transactions里的source字段…

作者头像 李华
网站建设 2026/9/28 13:21:02

MySQL子查询全解:从语法实战到性能优化避坑指南

前几天有个朋友发来一条SQL&#xff0c;三层子查询嵌套&#xff0c;跑了小半天都不出结果&#xff0c;问我怎么优化。我一看&#xff0c;他是用相关子查询在百万行表里做逐行扫描&#xff0c;关联字段上还没有索引&#xff0c;不慢才怪。MySQL里的子查询&#xff08;subquery&a…

作者头像 李华
网站建设 2026/9/28 13:20:56

PointNet模型权重详解:从加载推理到训练验证

简介&#xff1a;这是一份 PointNet 点云模型权重资源包&#xff0c;包含 TorchScript、ONNX、OpenVINO 与 TensorRT 四种主流部署格式&#xff0c;面向从事点云分类、分割或三维理解的开发者与研究人员。资源基于 Qi 等人 2017 年提出的 PointNet 结构&#xff0c;提供已训练好…

作者头像 李华
网站建设 2026/9/28 13:20:20

计算机组成原理中断系统详解:从原理到408真题实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 13:20:00

人行道检测数据集VOC+YOLO格式训练避坑与难例挖掘实战

简介&#xff1a;一份面向目标检测入门实践的人行道&#xff08;斑马线&#xff09;检测数据集&#xff0c;采用Pascal VOC与YOLO双格式标注&#xff0c;适合算法学习者、毕业设计及工程验证场景直接使用。资源整体共2000个文件&#xff0c;以795个txt标签、793个xml标注与jpg原…

作者头像 李华