简介:这份资源面向金融风控、数据挖掘方向的开发者与高年级学生,提供一套完整的上市公司财务数据造假预测方案,涵盖从特征工程到深度学习建模的全流程。包内共33个文件,以15个csv数据集、9个Python脚本为主,辅以xlsx原始附件、png结果图、pdf说明文档与md运行指南,压缩包约35.46MB,结构清晰便于按模块复现。方案先用SMOTE过采样配合决策树、随机森林、ExtraTree、XGBoost四种树模型计算特征重要性均值,筛选制造业与其他行业前30个关键因子;再以多层感知机、残差网络与Cross网络构建DCRN模型,通过短路连接、外积交互与Batch Normalize层缓解梯度问题,并舍弃过采样、改用Bagging集成降低方差。已有191人学习,读者可获取完整源码、数据集、训练模型与运行说明,直接用于复现实验、对比树模型与深度模型效果,或迁移到其他二分类风控场景。
1. 财务造假预测这件事,为什么单模型总是差一口气
上市公司财务造假预测,本质是一个极度不平衡的二分类问题:几千家样本里,真正被处罚或公开认定造假的可能只有几十到一两百家,正负比动辄 1:50 甚至更极端。很多开发者第一次拿到这类数据集,用逻辑回归或者单棵决策树跑一遍,AUC 看着还行,一到召回率就崩了——真正有问题的公司几乎全被漏掉。这不是模型不行,而是单模型在这种高噪声、小正样本、特征维度又高的表格数据上,方差压不住。
Bagging 加深度学习这套组合,解决的正是这个痛点。Bagging 通过有放回抽样训练多个基学习器再投票,把方差降下来;深度学习负责在财务比率、现金流结构、关联交易占比这些非线性特征里挖出人工规则写不出来的交互关系。两者叠在一起,既能扛住样本不平衡带来的抖动,又比纯树模型多一层表征能力。这套方案适合谁?适合手里有财报数据、想快速搭一个可复现基线、又不想从零调参的算法工程师和金融风控方向的学生。下面我把从数据到模型到跑通的完整路径拆开讲,参数和坑都标清楚。
2. 数据从哪来、特征怎么选:财务造假预测的数据地基
2.1 数据集结构与标签定义
这类项目的数据集通常由三块拼成:财务报表主表、财务比率衍生表、标签表。主表包含资产总计、负债合计、营业收入、净利润、经营活动现金流净额等原始科目;衍生表是在主表基础上算出来的比率,比如资产负债率、毛利率、应收账款周转率、现金流与净利润比值。标签表一般是一列股票代码加一列是否造假,造假样本来自监管处罚记录,正常样本是同期未被处罚的公司。
拿到数据后第一件事不是急着建模,而是确认标签的时间对齐。财务造假有个特点:处罚公告往往滞后于造假行为两到三年。如果你用 2023 年的处罚标签去匹配 2023 年的财报,模型学到的是“被查出来的公司长什么样”,而不是“正在造假的公司长什么样”。常见做法是把标签年份往前推两年,用 T-2 年的财报预测 T 年的造假状态。这一步不做,后面 AUC 再高都是自欺欺人。
import pandas as pd import numpy as np # 读取三张表,假设文件名为 fin_main.csv / fin_ratio.csv / label.csv main = pd.read_csv("fin_main.csv") ratio = pd.read_csv("fin_ratio.csv") label = pd.read_csv("label.csv") # 标签年份前推两年,避免用处罚年份直接对齐财报 label["report_year"] = label["punish_year"] - 2 # 按股票代码和报告年份合并 df = main.merge(ratio, on=["stock_code", "report_year"], how="inner") df = df.merge(label[["stock_code", "report_year", "is_fraud"]], on=["stock_code", "report_year"], how="left") # 未匹配到的样本标记为正常 df["is_fraud"] = df["is_fraud"].fillna(0).astype(int) print("总样本数:", len(df)) print("造假样本数:", df["is_fraud"].sum()) print("正负比: 1 :", round((len(df) - df["is_fraud"].sum()) / max(df["is_fraud"].sum(), 1), 1))这段代码的核心逻辑是时间对齐和表连接。report_year由处罚年份减 2 得到,保证特征和标签在时间上不穿越。合并用 inner join 保证主表和比率表都有记录,标签用 left join 避免丢掉没有处罚记录的正常样本。最后打印正负比,如果低于 1:30,后面采样策略要格外小心。
2.2 缺失值与异常值的处理边界
财务数据缺失是常态,尤其是中小板公司。处理方式不能一刀切用均值填充,因为缺失本身可能携带信息——比如某公司连续两年不披露某项关联交易金额,这本身就是风险信号。我一般会分三步走:先算每个特征的缺失率,缺失率超过 60% 的直接删列;缺失率在 10% 到 60% 之间的,加一列缺失标记再填充;缺失率低于 10% 的,用行业中位数填充。
异常值同理。资产负债率超过 1 不一定错,可能是资不抵债;但毛利率超过 100% 或者为负几千,大概率是科目口径问题。处理方式是先做分位数截断,把 1% 以下和 99% 以上的值拉到边界,而不是直接删样本,因为造假样本本来就少,删不起。
# 缺失率统计与分策略填充 missing_rate = df.isnull().mean().sort_values(ascending=False) # 缺失率超过 60% 的列直接删除 drop_cols = missing_rate[missing_rate > 0.6].index.tolist() df = df.drop(columns=drop_cols) # 缺失率 10%-60% 的列加标记后填充 mid_missing = missing_rate[(missing_rate >= 0.1) & (missing_rate <= 0.6)].index.tolist() for col in mid_missing: df[col + "_is_missing"] = df[col].isnull().astype(int) df[col] = df[col].fillna(df[col].median()) # 缺失率低于 10% 的列用中位数填充 low_missing = missing_rate[missing_rate < 0.1].index.tolist() for col in low_missing: if col in df.columns: df[col] = df[col].fillna(df[col].median()) # 分位数截断异常值 num_cols = df.select_dtypes(include=[np.number]).columns.tolist() for col in num_cols: if col in ["is_fraud", "report_year"]: continue low, high = df[col].quantile(0.01), df[col].quantile(0.99) df[col] = df[col].clip(low, high)这里的关键参数是 0.6 和 0.1 两个阈值。0.6 是经验值,超过这个比例的特征即使填充也是噪声为主;0.1 以下用中位数填充风险可控。加缺失标记这一步很多人会省,但在造假预测里,缺失模式本身就是特征,省掉会丢信息。截断用 1% 和 99% 分位数,比 3σ 更稳,因为财务数据分布厚尾严重。
2.3 特征筛选:别把几百列比率一股脑塞进去
财务比率动辄上百列,很多是高度共线的,比如流动比率和速动比率、总资产周转率和固定资产周转率。全塞进去,深度学习模型也能跑,但训练慢、解释性差、过拟合风险高。我一般先用方差过滤去掉近似常量的列,再用皮尔逊相关系数去掉共线对,最后用树模型的特征重要性做一轮粗筛,保留前 60 到 80 列。
from sklearn.feature_selection import VarianceThreshold from sklearn.ensemble import RandomForestClassifier # 去掉方差接近 0 的特征 selector = VarianceThreshold(threshold=0.01) X = df.drop(columns=["stock_code", "report_year", "is_fraud"]) y = df["is_fraud"] X_var = pd.DataFrame(selector.fit_transform(X), columns=X.columns[selector.get_support()]) # 用随机森林做一轮特征重要性排序 rf = RandomForestClassifier(n_estimators=200, max_depth=8, random_state=42, n_jobs=-1) rf.fit(X_var, y) importance = pd.Series(rf.feature_importances_, index=X_var.columns).sort_values(ascending=False) # 保留前 70 个特征 top_features = importance.head(70).index.tolist() X_final = X_var[top_features] print("最终特征数:", X_final.shape[1])方差阈值 0.01 是去掉那些几乎不变的列,比如某年所有公司都一样的行业标记。随机森林用 200 棵树、深度 8,是为了快速拿到重要性排序,不追求最优性能。保留前 70 个特征是平衡信息量和训练成本的折中,实际可以按验证集表现微调。注意特征筛选必须在训练集上做,再应用到验证集和测试集,否则会引入选择偏差。
3. Bagging 和深度学习怎么拼:从基学习器到集成策略
3.1 Bagging 的抽样逻辑与基学习器选择
Bagging 的核心是有放回抽样,每次从训练集里抽同样大小的样本,但允许重复。对于造假预测这种正样本极少的数据,直接对全量做有放回抽样,很多基学习器可能一个正样本都抽不到。所以实际做法是分层抽样:先按标签分层,在每个层内做有放回抽样,保证每个基学习器的正负比和原始数据一致。
基学习器选什么?常见组合是决策树、随机森林、梯度提升树各来几个。但既然标题里带了深度学习,我一般会把 Bagging 的基学习器设成一个小型 MLP,这样集成的是多个神经网络的预测。MLP 结构不用太深,两层隐藏层、每层 64 到 128 个神经元就够了,因为财务表格数据的特征交互复杂度有限,太深反而过拟合。
from sklearn.ensemble import BaggingClassifier from sklearn.neural_network import MLPClassifier from sklearn.model_selection import train_test_split # 分层划分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split( X_final, y, test_size=0.2, stratify=y, random_state=42 ) # 定义 MLP 基学习器 base_mlp = MLPClassifier( hidden_layer_sizes=(128, 64), activation="relu", solver="adam", alpha=0.001, batch_size=64, learning_rate_init=0.001, max_iter=300, early_stopping=True, random_state=42 ) # Bagging 集成,分层抽样 bagging = BaggingClassifier( estimator=base_mlp, n_estimators=15, max_samples=0.8, max_features=0.7, bootstrap=True, bootstrap_features=False, random_state=42, n_jobs=-1 ) bagging.fit(X_train, y_train) print("训练集 AUC:", bagging.score(X_train, y_train)) print("测试集 AUC:", bagging.score(X_test, y_test))n_estimators=15是基学习器数量,太少方差降不下来,太多训练时间线性增长,15 到 25 之间比较合适。max_samples=0.8表示每个基学习器抽 80% 的样本,留一点差异度。max_features=0.7表示每个基学习器随机选 70% 的特征,进一步增加多样性。MLP 的alpha=0.001是 L2 正则化系数,财务数据噪声大,正则不能太弱。early_stopping=True让验证集损失不下降时提前停,省时间也防过拟合。
3.2 深度学习分支:用类别权重和 Focal Loss 对抗不平衡
Bagging 解决的是方差问题,但正样本太少导致的梯度偏向问题,还得靠损失函数。标准交叉熵在 1:50 的正负比下,模型会把所有样本预测为负类就能拿到 98% 的准确率,但召回率为零。常见做法有两种:一是给正样本加权,权重设为负正样本比的倒数;二是用 Focal Loss,让模型聚焦在难分类的样本上。
import torch import torch.nn as nn class FocalLoss(nn.Module): def __init__(self, alpha=0.75, gamma=2.0): super().__init__() self.alpha = alpha self.gamma = gamma def forward(self, logits, targets): # logits: (N, 1), targets: (N,) probs = torch.sigmoid(logits).squeeze() targets = targets.float() # 正样本权重 alpha,负样本权重 1-alpha alpha_t = self.alpha * targets + (1 - self.alpha) * (1 - targets) pt = probs * targets + (1 - probs) * (1 - targets) loss = -alpha_t * ((1 - pt) ** self.gamma) * torch.log(pt + 1e-8) return loss.mean() class FraudMLP(nn.Module): def __init__(self, input_dim): super().__init__() self.net = nn.Sequential( nn.Linear(input_dim, 128), nn.BatchNorm1d(128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 64), nn.BatchNorm1d(64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, 1) ) def forward(self, x): return self.net(x)Focal Loss 里alpha=0.75表示正样本权重更高,gamma=2.0是聚焦参数,越大越关注难样本。网络结构里 BatchNorm 放在 Linear 之后、ReLU 之前,是标准做法,能加速收敛。Dropout 设 0.3,因为财务特征之间相关性高,dropout 太强会丢信息。输出层是 1 维,配合 sigmoid 做二分类。
3.3 把 Bagging 和深度学习串起来:两种集成路径
路径一:Bagging 包深度学习。就是 3.1 里的做法,用 BaggingClassifier 包 MLP,每个基学习器是一个独立训练的 MLP,最后投票。优点是实现简单,sklearn 一把梭;缺点是每个 MLP 独立训练,没法共享底层表征,训练慢。
路径二:深度学习内部做 Bagging。在 PyTorch 里手动实现:每次 epoch 前对训练集做有放回抽样,训练一个子网络,跑完所有子网络后对预测取平均。这种方式可以在子网络之间共享部分底层参数,训练效率更高,但代码复杂度上去了。
我一般先用路径一快速验证方案可行性,确认 AUC 和召回率能到预期后,再切路径二做性能优化。路径一在 15 个基学习器、每个 MLP 跑 300 epoch 的情况下,单卡大概 20 到 30 分钟能跑完,路径二能压到 10 分钟左右。
# 路径二:手动 Bagging 深度学习 def train_bagged_mlp(X_train, y_train, n_models=10, epochs=50, sample_ratio=0.8): input_dim = X_train.shape[1] models = [] for i in range(n_models): # 有放回抽样 idx = np.random.choice(len(X_train), size=int(len(X_train) * sample_ratio), replace=True) X_boot = torch.FloatTensor(X_train.iloc[idx].values) y_boot = torch.LongTensor(y_train.iloc[idx].values) model = FraudMLP(input_dim) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) criterion = FocalLoss(alpha=0.75, gamma=2.0) model.train() for epoch in range(epochs): optimizer.zero_grad() logits = model(X_boot) loss = criterion(logits, y_boot) loss.backward() optimizer.step() models.append(model) return models def predict_ensemble(models, X_test): X_tensor = torch.FloatTensor(X_test.values) preds = [] for model in models: model.eval() with torch.no_grad(): logits = model(X_tensor) probs = torch.sigmoid(logits).squeeze().numpy() preds.append(probs) return np.mean(preds, axis=0)n_models=10是子网络数量,sample_ratio=0.8控制每个子网络的训练集比例。weight_decay=1e-4是 Adam 的 L2 正则,和 Focal Loss 配合用。预测时把所有子网络的概率取平均,这是 Bagging 投票的标准做法。注意每个子网络训练前要重新抽样,不能共用同一份数据,否则集成就退化成单模型了。
4. 训练、验证与调参:把 AUC 和召回率同时拉起来
4.1 评估指标怎么选:别只看准确率
造假预测里准确率是最没用的指标。一个把所有样本判为正常的模型,准确率能到 98%,但召回率为零。我一般看三个指标:AUC 衡量排序能力,召回率衡量抓造假的能力,F1 衡量精确率和召回率的平衡。如果业务上更怕漏掉造假公司,召回率优先;如果更怕误伤正常公司,精确率优先。
from sklearn.metrics import roc_auc_score, recall_score, precision_score, f1_score # 用 Bagging 模型预测测试集 y_prob = bagging.predict_proba(X_test)[:, 1] y_pred = (y_prob > 0.5).astype(int) print("AUC:", round(roc_auc_score(y_test, y_prob), 4)) print("召回率:", round(recall_score(y_test, y_pred), 4)) print("精确率:", round(precision_score(y_test, y_pred), 4)) print("F1:", round(f1_score(y_test, y_pred), 4))阈值 0.5 是默认值,但在不平衡数据里往往不是最优。可以画 PR 曲线,找 F1 最大的阈值。如果业务要求召回率不低于 0.8,就把阈值往下调,牺牲一点精确率换召回。
4.2 关键超参数与调参顺序
这套方案里影响最大的超参数有五个:Bagging 的基学习器数量、MLP 的隐藏层结构、学习率、Dropout 比例、Focal Loss 的 alpha 和 gamma。调参顺序我一般按影响从大到小来:先定隐藏层结构,再调学习率和 Dropout,然后调 Focal Loss 参数,最后调 Bagging 的基学习器数量。
| 超参数 | 推荐范围 | 影响 | 调参建议 |
|---|---|---|---|
| 隐藏层结构 | (128,64) 到 (256,128,64) | 容量与过拟合 | 先试 (128,64),不够再加 |
| 学习率 | 1e-4 到 1e-3 | 收敛速度 | 从 1e-3 开始,不收敛降一个量级 |
| Dropout | 0.2 到 0.5 | 正则强度 | 过拟合就加,欠拟合就减 |
| Focal alpha | 0.6 到 0.9 | 正样本权重 | 召回低就加,精确低就减 |
| Focal gamma | 1.0 到 3.0 | 难样本聚焦 | 从 2.0 开始,微调 |
| 基学习器数量 | 10 到 30 | 方差降低 | 到 20 以后收益递减 |
调参时用分层 K 折交叉验证,不要用单次划分,因为正样本少,单次划分的验证集可能只有几个正样本,指标抖动大。5 折交叉验证取平均,结果稳得多。
4.3 训练过程监控与早停
深度学习训练最怕两件事:过拟合和梯度消失。监控手段是每个 epoch 记录训练损失和验证集 AUC,如果训练损失持续下降但验证 AUC 开始下降,就是过拟合,该早停。如果训练损失几乎不降,可能是学习率太小或者梯度消失,检查 BatchNorm 有没有加对。
# 训练过程监控示例 train_losses, val_aucs = [], [] best_auc = 0 patience = 10 wait = 0 for epoch in range(200): model.train() optimizer.zero_grad() logits = model(torch.FloatTensor(X_train.values)) loss = criterion(logits, torch.LongTensor(y_train.values)) loss.backward() optimizer.step() train_losses.append(loss.item()) # 验证 model.eval() with torch.no_grad(): val_logits = model(torch.FloatTensor(X_test.values)) val_prob = torch.sigmoid(val_logits).squeeze().numpy() val_auc = roc_auc_score(y_test, val_prob) val_aucs.append(val_auc) # 早停逻辑 if val_auc > best_auc: best_auc = val_auc wait = 0 torch.save(model.state_dict(), "best_model.pth") else: wait += 1 if wait >= patience: print(f"早停于 epoch {epoch},最佳 AUC: {best_auc:.4f}") breakpatience=10表示验证 AUC 连续 10 个 epoch 不提升就停。保存最佳模型权重,而不是最后一个 epoch 的权重,因为最后一个 epoch 可能已经过拟合了。这个监控逻辑在路径一和路径二里都适用。
5. 避坑与排查:财务造假预测里最容易翻车的五个地方
5.1 时间穿越:用未来数据预测过去
现象:验证集 AUC 高到 0.95 以上,上线后效果暴跌。原因:特征里混入了未来信息,比如用了处罚公告年份的财务数据去预测同年的造假标签。解决:严格按报告年份对齐,标签年份前推两到三年,所有特征只取报告年份及之前的数据。
5.2 样本泄漏:训练集和测试集有重叠公司
现象:测试集表现异常好,换一批公司就崩。原因:同一家公司的不同年份样本被分到了训练集和测试集,模型记住了这家公司的特征。解决:按公司划分训练集和测试集,同一家公司的所有年份要么全在训练集,要么全在测试集。
5.3 正样本太少导致交叉验证不稳定
现象:5 折交叉验证每折的 AUC 波动超过 0.1。原因:正样本总数可能只有一两百,每折验证集里正样本只有几十个,指标抖动大。解决:用分层 K 折,保证每折正负比一致;或者用重复分层 K 折,取多次平均。
5.4 特征标准化在树模型和神经网络之间不通用
现象:Bagging 里混了树模型和 MLP,树模型不需要标准化,MLP 需要,一起处理导致树模型性能下降。原因:标准化改变了特征的数值尺度,树模型的分裂点会变。解决:树模型和神经网络分开处理,树模型用原始特征,神经网络用标准化后的特征,最后在预测层面集成。
5.5 阈值选择拍脑袋
现象:模型 AUC 不错,但业务方说抓出来的全是误报。原因:分类阈值用了默认的 0.5,没有根据业务需求调整。解决:画 PR 曲线,根据业务能接受的误报率反推阈值;或者用代价敏感学习,给漏报和误报设不同的代价。
6. 进阶技巧:用 SHAP 做特征归因,让模型不再是黑匣子
模型跑通只是第一步,业务方最常问的问题是“为什么这家公司被判定为高风险”。这时候 SHAP 就派上用场了。SHAP 基于博弈论里的 Shapley 值,能给每个样本的每个特征算出一个贡献值,正贡献推高造假概率,负贡献拉低。对于 Bagging 加深度学习的集成模型,可以用 KernelSHAP 做近似,虽然慢一点,但比深度学习原生的归因方法稳。
import shap # 用训练集的一个子集做背景数据,加速 SHAP 计算 background = X_train.sample(100, random_state=42) explainer = shap.KernelExplainer(bagging.predict_proba, background) # 对测试集前 50 个样本做归因 shap_values = explainer.shap_values(X_test.iloc[:50], nsamples=200) # 输出单个样本的特征贡献 sample_idx = 0 contributions = pd.Series(shap_values[1][sample_idx], index=X_test.columns) print("推高造假概率的前五个特征:") print(contributions.sort_values(ascending=False).head(5)) print("拉低造假概率的前五个特征:") print(contributions.sort_values().head(5))background用 100 个样本做背景分布,太多会慢,太少不准。nsamples=200是 KernelSHAP 的采样次数,越大越准也越慢,200 是精度和速度的折中。shap_values[1]取的是正类的 SHAP 值,因为二分类输出两个类别。输出里正贡献最大的特征就是这家公司被判定为高风险的主要原因,可以直接拿给业务方看。
我自己的习惯是,每次模型迭代后都跑一遍全局 SHAP 摘要图,看特征重要性排序有没有大变化。如果某次迭代后,某个之前不重要的特征突然排到前三,大概率是数据管道出了问题,比如某列缺失值填充逻辑改了,或者某张表的连接键对错了。这个习惯帮我提前发现过好几次数据事故,比等模型上线后报警要划算得多。
另外一个小技巧:把 SHAP 值和原始财务比率对照着看。如果模型认为“应收账款周转率异常低”是主要风险信号,那就去翻这家公司的应收账款账龄结构,看是不是有大额长期挂账。模型给的是统计关联,业务验证给的是因果确认,两者结合才能让风控策略真正落地。希望帮到你。
本文还有配套的精品资源,点击获取