简介:这份资源面向参加全国大学生数学建模竞赛的选手、指导教师,以及对金融风控与数据分析感兴趣的学习者,聚焦2020年C题「中小微企业信贷决策」的完整解题方案。压缩包共160个文件,约248.35MB,以xlsx数据表、txt说明、jpg与png图表、m脚本、csv数据集及docx论文为主,另含pdf与嵌套rar,覆盖数据、代码、论文与可视化全流程。论文部分系统阐述信贷风险评估建模思路,涉及信息不对称、信用评估困难等背景,并运用线性回归、决策树、随机过程等工具,配合缺失值处理、异常值检测、特征工程与交叉验证完成模型选择与验证;MATLAB源码则实现数据导入、清洗、建模、训练、测试与结果可视化,便于读者对照复现。目前已有663人学习,适合需要完整赛题方案、可运行脚本与排错参考的读者深入研读。
1. 2020年国赛C题到底在考什么:中小微企业信贷决策的建模骨架
2020年全国大学生数学建模竞赛C题,题目背景是银行对中小微企业放贷时,如何在有限信贷额度下决定给谁贷、贷多少、利率定多少、期限多长,同时把坏账风险压到可接受范围。这道题之所以被反复拿出来复盘,是因为它同时踩中了三个真实业务痛点:数据里既有企业规模、营收、利润这类结构化指标,又有信誉评级、是否违约这类离散标签;决策变量不是单一数值,而是「贷不贷 + 贷多少 + 利率 + 期限」的组合;目标函数既要最大化银行收益,又要控制风险,天然是一个带约束的多目标优化问题。
如果你正在准备数学建模竞赛,或者想拿这道题当练手项目补上「数据清洗 → 特征工程 → 风险评分 → 信贷组合优化」的完整链路,这篇笔记就是按这个顺序拆的。我会把论文里常见的建模骨架、源代码里真正要写的几个模块、参数怎么定、哪里容易翻车,一条条讲清楚。适合已经会 Python 基础语法、但还没独立跑通过一道完整信贷决策题的人;也适合带队的指导老师拿去当讲评材料。
2. 从附件数据到可用特征:信贷决策题的数据预处理链路
2.1 三个附件表的结构与合并逻辑
这道题通常给三张表:企业信息表(含规模、营收、利润、信誉评级等)、信贷记录表(历史贷款金额、利率、期限、是否违约)、以及可能的进项/销项发票表。真实比赛里最耗时的不是建模,而是把这三张表按企业 ID 对齐。常见做法是先用企业信息表做主表,左连接信贷记录,再对发票表做聚合后合并。
import pandas as pd import numpy as np # 读取三张原始表,注意编码,国赛附件常见 gbk info = pd.read_csv('企业信息.csv', encoding='gbk') credit = pd.read_csv('信贷记录.csv', encoding='gbk') invoice = pd.read_csv('进销项发票.csv', encoding='gbk') # 发票表按企业聚合成特征:总金额、笔数、月均金额、金额标准差 inv_feat = invoice.groupby('企业ID').agg( 发票总金额=('金额', 'sum'), 发票笔数=('金额', 'count'), 发票月均金额=('金额', 'mean'), 发票金额波动=('金额', 'std') ).reset_index() # 主表左连接,保证不丢企业 df = info.merge(credit, on='企业ID', how='left') \ .merge(inv_feat, on='企业ID', how='left') # 缺失值处理:数值列填 0 或中位数,类别列填众数 num_cols = df.select_dtypes(include=[np.number]).columns df[num_cols] = df[num_cols].fillna(df[num_cols].median()) df['信誉评级'] = df['信誉评级'].fillna(df['信誉评级'].mode()[0])这段代码的关键在how='left':必须以企业信息表为准,否则会丢掉没有信贷记录的企业,而这类企业恰恰是决策里「要不要放贷」的重点对象。发票聚合用std而不是只求和,是因为金额波动大的企业往往经营不稳定,这个特征在后续风险评分里权重不低。
参数上,缺失值填充策略要分列讨论:营收、利润这类连续变量用中位数比均值稳,因为信贷数据里极端值多;信誉评级这种有序类别,填众数后还要做序数编码(A=1, B=2, C=3, D=4),不能直接 one-hot,否则丢掉等级信息。
2.2 特征工程:把「能不能贷」翻译成数值
原始字段直接丢进模型效果通常一般,需要构造几类衍生特征。第一类是偿债能力比率:利润/营收、营收/贷款额、发票总金额/贷款额。第二类是稳定性特征:发票金额波动/发票月均金额,这个比值越大说明经营越不稳。第三类是历史行为特征:历史违约次数、历史贷款平均利率。
# 偿债能力 df['利润率'] = df['利润'] / df['营收'].replace(0, np.nan) df['营收贷款比'] = df['营收'] / df['贷款额'].replace(0, np.nan) df['发票覆盖比'] = df['发票总金额'] / df['贷款额'].replace(0, np.nan) # 稳定性 df['经营波动率'] = df['发票金额波动'] / df['发票月均金额'].replace(0, np.nan) # 历史行为 df['历史违约次数'] = df.groupby('企业ID')['是否违约'].transform('sum') # 无穷值和缺失再兜一次 df = df.replace([np.inf, -np.inf], np.nan) df = df.fillna(df.median(numeric_only=True))replace(0, np.nan)这一步是血泪经验:分母为 0 直接除会得到 inf,后面标准化时整个特征列就废了。transform('sum')而不是agg,是为了保持行数不变,方便直接拼回原表。
提示:特征做完先做一次相关性检查,利润率、营收贷款比、发票覆盖比三者相关性往往很高,保留两个即可,否则共线性会让逻辑回归的系数解释变得很难看。
3. 风险评分模型:逻辑回归、XGBoost 怎么选怎么调
3.1 为什么信贷题首选逻辑回归做基线
信贷决策题有一个隐藏要求:模型要能解释。银行不可能接受一个「黑匣子」告诉你某企业不能贷却说不清原因。逻辑回归输出的是概率,系数可以直接翻译成「某指标每上升一个单位,违约几率变化多少」,这在论文里好写,在答辩时也好答。
from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.metrics import roc_auc_score, classification_report features = ['利润率', '营收贷款比', '发票覆盖比', '经营波动率', '历史违约次数'] X = df[features] y = df['是否违约'] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y) scaler = StandardScaler() X_train_s = scaler.fit_transform(X_train) X_test_s = scaler.transform(X_test) lr = LogisticRegression(class_weight='balanced', max_iter=1000, C=1.0) lr.fit(X_train_s, y_train) prob = lr.predict_proba(X_test_s)[:, 1] print('AUC:', roc_auc_score(y_test, prob)) print(classification_report(y_test, (prob > 0.5).astype(int)))class_weight='balanced'是必须的:违约样本通常远少于正常样本,不加这个参数模型会倾向于全预测「不违约」,准确率看着高但毫无用处。C=1.0是正则强度倒数,C 越小正则越强,如果特征多且样本少,调到 0.1 到 0.5 之间能压住过拟合。stratify=y保证训练测试集里违约比例一致,否则测试集可能一个违约样本都没有,AUC 直接算不出来。
3.2 XGBoost 做对比与特征重要性验证
逻辑回归跑通后,用 XGBoost 做一版对比,主要目的不是替换,而是看特征重要性排序是否一致。如果两个模型都认为「历史违约次数」最重要,那这个结论就站得住。
import xgboost as xgb dtrain = xgb.DMatrix(X_train, label=y_train) dtest = xgb.DMatrix(X_test, label=y_test) params = { 'objective': 'binary:logistic', 'eval_metric': 'auc', 'max_depth': 4, 'eta': 0.1, 'subsample': 0.8, 'colsample_bytree': 0.8, 'scale_pos_weight': (y_train == 0).sum() / (y_train == 1).sum() } model = xgb.train(params, dtrain, num_boost_round=200, evals=[(dtest, 'test')], early_stopping_rounds=20) xgb.plot_importance(model, max_num_features=10)max_depth=4是信贷数据常用的深度,再深容易记住噪声。scale_pos_weight等价于逻辑回归的 class_weight,处理不平衡。early_stopping_rounds=20防止无效迭代,验证集 AUC 连续 20 轮不升就停。
两个模型跑完,如果 AUC 差距在 0.03 以内,论文里就主推逻辑回归,XGBoost 作为稳健性检验。这个取舍在答辩时是加分项,因为说明你懂业务约束,不是唯精度论。
4. 信贷组合优化:把评分变成「贷给谁、贷多少、利率多少」
4.1 决策变量与目标函数的建模
风险评分给出的是每个企业的违约概率 p_i,接下来要解一个优化问题:在总信贷额度约束下,选择放贷企业集合、每家贷款额 x_i、利率 r_i、期限 t_i,使银行期望收益最大。
期望收益可以写成:Σ [ (1 - p_i) · x_i · r_i · t_i - p_i · x_i · LGD ],其中 LGD 是违约损失率,常见取 0.5 到 0.7。约束包括:总贷款额不超过额度上限、单家贷款额在上下限之间、利率在监管区间内、以及风险敞口约束(比如总违约期望损失不超过某个阈值)。
from scipy.optimize import minimize import numpy as np n = len(df) p = lr.predict_proba(scaler.transform(df[features]))[:, 1] # 违约概率 LGD = 0.6 R_max = 0.15 # 利率上限 R_min = 0.04 # 利率下限 Total = 1e7 # 总信贷额度 def neg_profit(x): # x 前 n 个是贷款额,后 n 个是利率 amt = x[:n] rate = x[n:] profit = np.sum((1 - p) * amt * rate - p * amt * LGD) return -profit cons = [ {'type': 'ineq', 'fun': lambda x: Total - np.sum(x[:n])}, # 总额约束 {'type': 'ineq', 'fun': lambda x: 0.05 * Total - np.sum(p * x[:n] * LGD)} # 风险约束 ] bounds = [(0, 0.1 * Total)] * n + [(R_min, R_max)] * n x0 = np.concatenate([np.full(n, Total / n * 0.5), np.full(n, 0.08)]) res = minimize(neg_profit, x0, bounds=bounds, constraints=cons, method='SLSQP')SLSQP适合这种带边界和不等式约束的中小规模问题。x0初始值给总额的一半均分,比全零收敛快。风险约束里0.05 * Total是允许的期望损失上限,这个参数要根据银行风险偏好调,调太小会导致很多企业贷不到,调太大又失去风控意义。
4.2 用遗传算法处理离散决策
上面是连续松弛版,实际业务里「贷不贷」是 0/1 决策。如果企业数量在几十家以内,可以用遗传算法或模拟退火处理离散选择。
import random def fitness(selected): # selected 是 0/1 列表 amt = np.array([df.iloc[i]['建议额度'] if selected[i] else 0 for i in range(n)]) if amt.sum() > Total: return -1e9 rate = np.full(n, 0.08) profit = np.sum((1 - p) * amt * rate - p * amt * LGD) return profit # 简单遗传算法框架 pop = [[random.randint(0, 1) for _ in range(n)] for _ in range(50)] for gen in range(100): pop = sorted(pop, key=fitness, reverse=True)[:25] # 交叉变异略,按标准 GA 流程补全遗传算法的关键是适应度函数里对超额度直接给极大负值,让不可行解自然淘汰。种群规模 50、迭代 100 代是常见起点,企业数超过 100 家时要把种群调大,否则搜索不充分。
5. 避坑与排查:这道题最容易翻车的五个地方
5.1 违约标签泄漏
现象:模型 AUC 高到 0.98 以上,论文里看着很漂亮。原因:特征里混入了「是否违约」直接相关的字段,比如某些附件里「违约次数」和标签是同一来源。解决:建模前把和标签同源的列全部剔除,只保留贷款申请时点之前能拿到的信息。
5.2 利率优化结果全是边界值
现象:优化出来的利率不是上限就是下限。原因:目标函数对利率是线性的,没有加入利率上升导致违约概率上升的反馈。解决:把 p_i 写成利率的函数,比如 p_i(r) = p_i0 + k·r,k 取 0.5 到 1.5 之间,这样最优利率会落在中间。
5.3 数据标准化在训练测试集上分别做
现象:测试集 AUC 比交叉验证低很多。原因:先对全量数据标准化再切分,测试集信息泄漏到训练集。解决:fit_transform只在训练集上做,测试集用训练集的 scaler 做transform,上面代码里已经这么写了。
5.4 忽略企业规模分层
现象:模型对大企业预测准,对小企业全预测不违约。原因:小企业样本少且违约模式不同。解决:按规模分层建模,或者把规模作为交互项加入,比如「利润率 × 是否小微」。
5.5 优化问题无解时直接报错
现象:minimize返回失败,论文里没结果。原因:约束太紧,可行域为空。解决:把风险约束改成软约束,加惩罚项,或者先解一个松弛问题找到可行点再逐步收紧。
6. 论文与代码怎么对齐:让评阅人一眼看到复现路径
最后一章讲一个具体技巧:论文里的模型描述和源代码必须能一一对应。我一般会在论文每个公式后面标注对应的代码文件名和函数名,比如「式(3) 对应optimize.py的neg_profit函数」。这样评阅人想验证时不用猜。
再给一个验证方法:把源代码里的随机种子固定,跑三遍,如果三次结果完全一致,说明没有隐藏的随机性;如果论文里写的结果和代码跑出来的对不上,优先检查数据划分和标准化顺序。我自己的习惯是,论文定稿前一定用一份干净的虚拟环境重跑一遍全流程,把requirements.txt里的版本号锁死,避免「在我电脑上能跑」的经典翻车。
这道题的价值不在于拿奖,而在于它是一条完整的「数据 → 模型 → 优化 → 决策」链路,跑通一遍,后面遇到任何信贷、风控、资源分配类问题都能套。希望帮到你。
本文还有配套的精品资源,点击获取