简介:2020年数学建模C题企业信贷风险预测的完整解答文档,面向备战数模竞赛、希望掌握机器学习风控建模的读者。文档围绕“量化分析—特征工程—模型构建—评估优化”主线展开:先将企业信贷违约风险转化为可量化评分,再从进项与销项有效发票数、作废发票数、负数发票次数及占比、交易金额与时长等维度系统挖掘特征;推荐决策树、随机森林、XGBoost、LightGBM等模型输出违约概率,以0.5为阈值判定违约,并按概率排序划分五级或八级风险档位。文中还给出特征重要性排序、PDPBox可解释性分析、添加随机扰动验证鲁棒性,以及多模型评价指标对比与耦合优化的进阶思路,可直接用于论文撰写与答辩展示。资源为1个docx文件,压缩包仅9KB,内容紧凑实用。已有4018人学习下载,适合赛前快速梳理建模流程、补全论文分析亮点。
1. 一道让多数参赛队栽在数据上的建模题:2020年C题到底在算什么
2020年数学建模C题“中小微企业的信贷决策”,表面是给银行做贷款审批,实际是一场特征工程和可解释性的大考。很多队伍把精力花在堆模型上,结果被评阅人一句话噎死——“你的特征怎么解释?”这道题的核心不在于拟合精度,而在于你能否从发票流水里读出企业的经营真相:进项是采购、销项是收入、供求稳定是波动率、信誉评级是违约概率的先验。用错一个口径,后面全盘皆输。本文从数据清洗、特征构建、决策模型到避坑验证,按我实际打这类题的习惯拆给你看,适合准备建模竞赛的新手,也适合想优化信贷评分逻辑的分析师。
2. 从docx赛题到干净数据集:信贷数据的清洗逻辑与特征工程
2.1 第一步不是建模,是把附件读成可计算的DataFrame
2020年C题附件里给的是企业流水和进销项发票数据,常见格式是CSV或Excel,但题面解答文档用docx包装。很多队伍拿到手就急着做回归,结果变量读进来全是字符串、日期格式不统一、进项金额带负号。我一般会先用pandas把数据读进来,做一次类型体检,把每列dtype和缺失率打印出来,再决定清洗策略。这个步骤虽然琐碎,但能避免后面所有特征计算出现“哑巴错误”。
import pandas as pd import numpy as np df = pd.read_excel('附件1_企业流水.xlsx', sheet_name='Sheet1', parse_dates=['开票日期']) df.info() print(df.isnull().mean().sort_values(ascending=False).head(20))这段代码的逻辑是先用info()看整体列类型,再用isnull().mean()看每列缺失率。如果缺失超过30%,要么剔除该列,要么用业务含义填充;如果日期列没被解析成datetime,后面的月度聚合会直接报错。参数说明:parse_dates指定需要解析的日期列名,sheet_name按实际Excel页签名称改。这里的关键在于——你不能默认数据是干净的,尤其是从docx附件导出的数据,经常带不可见字符、全角数字或Excel科学计数法残留下来的精度问题。
清洗发票金额时,最常见的坑是进项和销项混在一列,靠一个“类型”字段区分,但那个字段可能有大小写、空格或繁体变体。我会先统一去除空白、统一小写,再单独构造进项金额和销项金额两列。注意汇票、退票、红字发票这些特殊记录,直接影响后面销售收入和采购成本的准确性,如果附件说明里有“作废”“负数”“红冲”标记,第一步就过滤掉,不然后面算出来的营收和成本全是错的。
2.2 特征不是越多越好:信贷场景要的是稳定性和趋势
做过信贷评分的人都知道,银行关心两个问题:这个企业能不能还、愿不愿意还。“能不能还”看现金流规模和稳定性,“愿不愿意还”看历史违约和信誉评级。2020年C题的4个附件数据里,有企业基本信息(信誉评级、是否违约)、进项发票、销项发票、以及一个供求稳定性的标签。特征构建上,我一般分成三组:
- 规模特征:年销项总额、年进项总额、平均月销项金额
- 稳定性特征:月销项标准差、变异系数(标准差/均值),这个比绝对标准差更合理,因为大企业的标准差天然比小企业大
- 趋势特征:近三个月销项均值比前九个月均值,或者线性回归的斜率
df['month'] = df['开票日期'].dt.to_period('M') g = df.groupby(['企业代号', 'month']).agg( 销项金额=('销项金额', 'sum'), 进项金额=('进项金额', 'sum') ).reset_index() stat = g.groupby('企业代号').agg( 月均销项=('销项金额', 'mean'), 销项_std=('销项金额', 'std'), 销项_变异系数=('销项金额', 'std') / ('销项金额', 'mean'), 最近三月均销项=('销项金额', lambda x: x.tail(3).mean()), 其余月均销项=('销项金额', lambda x: x.iloc[:-3].mean() if len(x) > 3 else 0) )这段代码先把日期按月份聚合,然后分别计算月均销项、标准差和变异系数。最后一个特征里,tail(3).mean()取的是最近三个月的均值,iloc[:-3]取的是倒数第四个月往前所有月份,两者相除就是趋势比。我通常把这个趋势比大于1.2定义为“增长型”,小于0.8定义为“衰退型”,中间定义为“平稳型”。注意,如果企业只有两三个月的数据,iloc[:-3]会返回空,所以要加if len(x) > 3的守卫,否则特征计算直接报NaN。这个细节看起来小,但很多队伍就是死在“为什么我的预测结果全是NaN”上。
2.3 供求稳定不能靠肉眼:把定性标签变成可量化特征
2020年C题里有一列“供求稳定性”,题面描述的是企业近一年的供求波动情况。很多队伍直接把这个字段当成字符串丢给模型,或者用LabelEncoder编码成0、1、2,但这两种做法都不理想。原因在于,“高”“中”“低”三个等级虽然有序,但它们和违约概率的关系不一定是线性的。我一般用两种方式处理:
第一种是目标编码(target encoding),用同一等级下的历史违约率替换等级本身。第二种是做成哑变量,把“高稳定”“中稳定”“低稳定”拆成三列,分别用0/1表示。后者的好处是可解释性强,在论文里可以直接说“低稳定企业违约概率比高稳定高X个百分点”;前者的好处是能够压缩维度,适合后面接逻辑回归时减少参数个数。
from sklearn.preprocessing import OneHotEncoder df_encoded = pd.get_dummies(df_clean, columns=['供求稳定性'], prefix='稳定')目标编码的做法在信贷业务里更常见,因为它保留了等级的信息量而不是把等级当作无序类别。做法是先按等级分组计算违约率均值,再把均值映射回对应样本。但要注意,这个映射必须在训练集内部完成,不能把全量数据的违约率算完再回填,否则会引入未来信息,也就是泄漏。这一点在C题里不涉及严格的时间序列测试集划分,但如果你要写成论文里的“稳健性检验”,最好还是按随机划分的逻辑走,避免被评委质疑。
3. 信贷决策基线模型:贷不贷、贷多少、利率怎么定
3.1 分层决策框架为什么比单模型更合适
信贷决策的目标不是一个回归值,而是一个策略组合:先判断是否放贷,再决定额度,最后定价利率。把这个流程放进一个端到端的深度模型里,效果未必好,而且可解释性极差。评委要看你“为什么给这家企业50万而不是80万”,一个黑匣子给不出答案。所以我在这类题里一贯的做法是拆成三层:第一层是准入规则,第二层是额度测算,第三层是利率定价。这样做的好处非常直接——每一层都可以单独写进论文,每一层都可以画成流程图,每一层调参都有业务含义支撑。
3.2 准入规则:信誉评级和违约标签的交叉判断
先看一个基本功:把企业基本信息和违约标签做交叉表,观察信誉评级与违约率的关系。2020年C题给的数据里,信誉评级分为A、B、C、D四档,其中D档基本等于高危客户,违约率显著高于其他档。经验上,A和B级客户可以全量准入,C级需要附加条件,D级直接拒绝。但别急着拍脑袋,先用代码验证统计规律。
cross = pd.crosstab(df_clean['信誉评级'], df_clean['是否违约'], normalize='index') cross['违约率'] = cross[1] if 1 in cross.columns else 0 print(cross)这段代码输出的就是每个评级级别下的违约率。如果数据显示A级违约率是2%,D级违约率是35%,那就说明评级和违约之间有强关联,准入规则可以直接引用这个统计结论。我在实际写论文时,通常还会加一步卡方检验,p值小于0.05才在正文里写“显著相关”,否则只作为描述统计。因为建模竞赛评阅时,统计显著性是一个得分点,不写白不写。
3.3 额度测算:现金流倍数法还是评分卡映射
准入之后,额度怎么定?最朴素的做法是“年销项金额乘以一个固定系数”,比如0.2,意思是银行最多给企业一年收入的20%作为贷款。但这么一刀切,会让稳定型企业和大规模企业吃亏。更合理的是按评分卡走——先算一个信用得分,再把得分映射到额度比例区间。比如得分在90分以上额度比例是0.5,60分以下只有0.1,中间线性插值。这里的评分可以用前面算好的特征加权得到,权重的来源可以是逻辑回归系数,也可以是专家打分。
def calc_limit(row): if row['信誉评级'] == 'D': return 0 score = row['信用得分'] # 由前面的回归模型输出 ratio = 0.1 + 0.4 * (score - 50) / 50 ratio = min(max(ratio, 0.1), 0.5) limit = row['年销项总额'] * ratio return round(min(limit, 500), 1) # 单户授信上限500万 df_result['建议额度'] = df_result.apply(calc_limit, axis=1)这里我把单户上限设在500万,是参考银行小微企业信贷普遍的单户限额,但这个数不是绝对的,你可以根据题面要求调整。ratio线性映射的逻辑很简单——信用得分50分时只给10%的比例,100分时给50%,中间线性增长,超过边界就截断。这个映射的参数(0.1下限、0.5上限、500万封顶)在论文里就是敏感度分析的对象,后面我会专门讲怎么验证“额度系数调大调小对银行总收益的影响”。
3.4 利率定价:用风险溢价覆盖期望损失
利率定价的核心公式是:贷款利率 = 资金成本 + 运营成本 + 风险溢价 + 目标利润。在建模赛里,资金成本和运营成本通常给固定值,比如4%和2%,剩下要计算的就是风险溢价。风险溢价可以从违约概率推导——如果一家企业违约概率是3%,那么银行为了覆盖这笔损失,至少要在利率上加收相当于3%的溢价(粗略算法,严格讲还要考虑违约损失率LGD)。所以上一节算出来的违约概率,直接就是利率定价的输入。
def calc_rate(row): if row['建议额度'] == 0: return None pd_ = row['违约概率'] lgd = 0.5 # 假定损失率50% risk_premium = pd_ * lgd rate = 0.04 + 0.02 + risk_premium + 0.01 return round(min(max(rate, 0.06), 0.15), 4) df_result['建议利率'] = df_result.apply(calc_rate, axis=1)这个函数里,lgd = 0.5表示一旦违约,银行只能收回一半本金,这是行业常用的假设。目标利润取1%,你也可以改成1.5%或2%,利率下限6%和上限15%是参考普惠小微贷款的常见区间。注意这里用的是简化逻辑——真正的风控模型会把EAD(违约暴露)和LGD分开建模,但竞赛场景里你写清楚假设、给出公式和参数,评委就能给分。最怕的是你写了一堆模型,却连风险溢价从哪来都说不清。
4. 把ABC信誉评级变成违约概率:评分卡与Logistic回归的落地玩法
4.1 为什么用Logistic回归而不是XGBoost
2020年C题的训练集规模大概在几百家企业,特征维度在十到二十之间,这是一个典型的小样本分类问题。小样本场景下,XGBoost、随机森林这类强模型容易过拟合,而且变量的系数解释不直观。Logistic回归的好处有三点:第一,输出天然是概率;第二,每个特征的系数有明确业务含义,比如“变异系数每增加0.1,违约概率上升X个百分点”;第三,在几百条样本上训练速度极快,可以反复跑交叉验证调参数。很多队伍上来就堆XGBoost,结果测试集AUC比训练集低一大截,这就是过拟合的典型信号。
4.2 特征标准化与标签平衡处理
建模前先做两件事:连续特征做标准化,类别特征做哑变量。标准化用StandardScaler,目的是让逻辑回归的梯度下降收敛更快,同时让系数之间有可比性——如果不标准化,销项金额(百万级别)和变异系数(0.3左右)的系数会差几个数量级,没法比较哪个特征更重要。标签平衡方面,如果违约样本占比低于20%,属于类别不平衡,我会用class_weight='balanced'让模型自动给少数类更高权重。
from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline features = ['月均销项', '销项_变异系数', '最近三月均销项', '其余月均销项', '成立年限'] X = df_model[features] y = df_model['是否违约'] pipe = make_pipeline(StandardScaler(), LogisticRegression(class_weight='balanced', C=0.5, max_iter=1000)) pipe.fit(X, y) df_model['违约概率'] = pipe.predict_proba(X)[:, 1]这里有一个容易忽略的点——C=0.5是正则化强度的倒数,C越小正则化越强,越不容易过拟合。在小样本上我一般从0.1开始试,用交叉验证选最优C,而不是直接用默认值1。max_iter=1000是因为特征标准化后虽然收敛更快,但某些特征分布极不均匀时,默认100次迭代可能不收敛。代码跑完后,把系数打印出来看正负方向是否符合业务直觉——比如变异系数应该是正数(波动越大违约概率越高),成立年限应该是负数(经营越久越稳定)。如果方向反了,先检查特征计算逻辑,不要急着调参。
4.3 用评分卡映射把概率转成整数分
违约概率本身已经可以直接用,但在竞赛论文里,写“该企业违约概率为0.23”不如写“该企业信用得分为68分”来得直观。评分卡的做法是把违约概率做一个对数比变换,再线性映射到一个整数区间。公式是:
score = offset - factor * ln(p / (1-p))
其中offset和factor是两个调节参数。我习惯把600分定义为违约率50%的基准分,每升高20分违约率减半,也就是factor = 20 / ln(2) ≈ 28.85,offset = 600 + 28.85 * ln(1) = 600。这样算出来的分数区间大概在300到800之间,和常见征信评分卡量纲一致,评委读起来也不费力。
import math factor = 20 / math.log(2) offset = 600 def prob_to_score(p): p = min(max(p, 1e-6), 1 - 1e-6) return round(offset - factor * math.log(p / (1 - p))) df_model['信用得分'] = df_model['违约概率'].apply(prob_to_score)这个变换的好处在于,它把模型输出的连续概率映射成了业务上一眼能看懂的分数,而且分数和违约概率之间有严格单调关系。论文里放一张“分组违约率表”——把得分分成600以下、600-650、650-700、700以上四组,分别统计组内实际违约率——就可以证明这个评分卡有区分度。评阅人最喜欢看到这种“模型输出→业务解释”的闭环,这比堆AUC数字更打动人。
4.4 阈值选择:不用默认0.5,用利润最优解
二分类默认阈值是0.5,但信贷场景里即使违约概率只有0.2,银行也可能不愿意放贷——因为放贷的收益上限是利息收入,而损失的底线是本息全无。最优阈值要让“期望利润最大化”,而不是让“准确率最大化”。具体做法是遍历所有可能的概率阈值,计算每个阈值下的总放贷利润,选利润最高的那个点。
thresholds = np.arange(0.1, 0.8, 0.05) profits = [] for t in thresholds: loan_set = df_model[df_model['违约概率'] < t] if len(loan_set) == 0: profits.append(0) continue profit = loan_set['建议额度'].sum() * loan_set['建议利率'].mean() - loan_set['建议额度'].sum() * 0.03 profits.append(profit) best_t = thresholds[np.argmax(profits)]这里的建议利率是上一章算出来的,0.03大概等于无风险利率,也就是不放贷时资金的机会成本。通过这个遍历,你会发现最优阈值可能落在0.3附近,而不是0.5——这意味着按默认阈值会放过一批高风险的坏客户,表面上放贷量更大,但坏账吃掉利润后总收益反而更低。如果你在论文里能讲清楚这个“阈值不是拍脑袋定的,是从利润函数推出来的”,这道题已经超过大部分队伍了。
5. 避坑指南:C题最容易翻车的五个细节与排查思路
5.1 销项金额当收入用,额度直接虚高一倍
现象:算出来的年收入高得离谱,授信额度随随便便就超过500万上限,导致大量客户额度被截断。原因:发票上的销项金额是含税价,而实际收入口径是不含税销售额,增值税税率可能高达13%。用含税价直接乘以额度系数,等于多给了10%以上的额度。解决:先看题面是否给税率,如果给了就做价税分离,公式是不含税金额 = 含税金额 / (1 + 税率);如果没给,在论文里明确假设“所有金额均已做价税分离”,避免评委抠口径。
df['销项不含税'] = df['销项含税'] / 1.135.2 信誉评级直接编码成1、2、3、4,模型学出错误序数关系
现象:用LabelEncoder把A、B、C、D编码成0、1、2、3后,逻辑回归系数显示级别越高违约率越低,但真实数据可能B级客户比A级更优质。原因:ABCD是业务评级,不是线性递增的数值,把它强行当作连续变量等于给模型灌输了一个错误先验。解决:把信誉评级做OneHot编码,或者按“违约率单调性检验”确认是否适合目标编码。如果检验结果是单调的,用目标编码;如果不是,老老实实OneHot。
5.3 全年均值抹平了季节性,稳定性特征失真
现象:某企业前11个月几乎无销项,12月突然爆发大量发票,但算出的变异系数是1.2——看起来波动不大,实际经营极不稳定。原因:按年聚合时,一个月的异常被全年平均稀释了,变异系数也相应被拉低。解决:改用滚动窗口计算稳定性,比如每三个月为一个窗口,计算窗口间的变异系数取最大值,或者直接用月度变异系数而不是年度变异系数。我在特征工程时用的是月度数据聚合,月度变异系数天然能捕捉这类“前冷后热”的模式。
5.4 用全量数据训模型,评阅一问“你验证了吗”就哑火
现象:论文里只报训练集AUC,没有做任何验证集评估,评委一问“你的模型在样本外表现如何”直接哑火。原因:常见做法是把所有企业数据一口气丢进模型,然后报一个漂亮的准确率。解决:先用train_test_split分出一个20%的验证集,或者用5折交叉验证报告AUC均值±标准差,这样论文里能写“5折交叉验证AUC为0.83±0.04”,比单次训练更有说服力。如果样本量实在太小(比如低于100),不做严格划分也要做Bootstrap重采样来验证稳定性。
5.5 只报准确率,不分违约类别的“代价敏感”损失
现象:代码输出confusion matrix,准确率92%,但仔细一看违约样本只占10%,模型把所有客户都预测为不违约。原因:类别不平衡下,准确率是欺骗性指标——全预测多数类就能拿高分。解决:报告AUC、召回率和精确率,特别关注违约类别的召回率,因为信贷场景里漏掉一个违约客户的损失远大于多拒一个优质客户的遗憾收益。对于C题,即使是最简单的决策树,也建议用class_weight='balanced'和AUC这两个指标双重要求,才能论文里站得住脚。
6. 验证与进阶:用敏感性分析证明你的模型不是玄学
如果你不想让评阅人觉得结果都是“调参调出来的”,敏感性分析是最实用的辩护利器。具体做法是把关键参数在一定范围内扫描,观察核心输出指标的波动。比如额度系数从0.1扫到0.5,利率下限从5%扫到10%,记录每组参数下的银行总利润和坏账率,画一张二维表格——你会发现存在一个最优区间,超过这个区间利润曲线快速下滑,这就是你在论文里可以理直气壮写下的“参数选择依据”。验证技巧上,我习惯同时在边界参数下重跑一遍决策流程,如果评级为D的企业被放贷、或者违约率超过40%的企业拿到大额授信,说明某个环节的逻辑有漏洞,需要回溯到特征计算或信用打分那一步。
另一个进阶做法是压力测试——把所有企业的基础利率上浮200个基点,或者模拟经济下行时违约率翻倍,看银行总利润是否仍然为正。虽然2020年C题没有要求这一步,但写进论文里会让方案完整度上一个台阶,评委看到的是“这个方案经过了极端条件验证”。我自己的习惯是:把所有参数和阈值集中放在论文附录的一张表里,正文只保留最优参数组合和敏感性分析的核心结论,这样既显得专业,又不会让主文被参数淹没。记住,建模竞赛比的是“你有多懂这道题的商业逻辑”,不是“你调了多少轮参”。希望我的这套流程能帮你在C题上少走弯路,也期望你读完能亲手跑一遍这些代码——数据永远不会骗你,但代码会。
final_columns = ['企业代号', '建议额度', '建议利率', '信用得分', '违约概率', '准入结论'] df_final = df_result[final_columns] df_final.to_excel('信贷决策方案.xlsx', index=False, sheet_name='决策结果')导出Excel时我会顺带把准入结论列加进去——“准入”“拒绝”“有条件准入”三档,方便在论文里贴一张结果概览。实际上,这一列就是前面准入规则的可视化输出,评委最想看到的不是一堆概率值,而是“你的决策规则最终圈定了哪些客户”。这一步做完,整套方案才算闭环。
本文还有配套的精品资源,点击获取