资讯动态

数模竞赛C题中小微企业信贷决策:从发票特征到违约概率与利率定价

发布时间:2026/10/9 17:04:21 来源:尧图企业网站定制
简介涉足金融风控与数学建模的读者这是一份2020年全国大学生数学建模竞赛C题“中小微企业信贷决策”的完整解决方案包含可编辑Word论文与MATLAB实现源码。资料从信贷风险理论入手涵盖数据预处理、特征工程、模型构建与交叉验证等环节既适合参赛者复盘也适合数据分析初学者了解建模落地流程。压缩包共160个文件主要含60个xlsx数据表、42个txt说明、10个m源码、6个csv数据集及5个docx文档另有可视化图片与PDF整体248.35MB目录结构便于对照查阅。配套代码覆盖数据导入、模型训练与结果可视化可直观看到决策树、随机森林等方法的实际调用方式论文则详细阐述理论依据与实验结论。已有663人学习下载对于想深入理解小微企业信贷风险评估的读者是一份理论与实践结合的参考案例。1. 2020年全国大学生数学建模竞赛C题中小微企业信贷决策先判能不能放再算放多少2020年全国大学生数学建模竞赛C题中小微企业信贷决策要求的不是写一个分类器而是站在银行信贷员的位置基于进销项发票、企业信用评级、上下游关系这三类数据替银行回答“贷不贷、贷多少、利率定多少”。中小微企业没有完整财报传统风控模型在这里几乎失效只能从发票流水里找经营痕迹而评分只覆盖部分企业更多企业需要靠模型外推。赛题难点不在某个算法多高深在于把真实业务约束翻译成数学模型并把银行利润和坏账损失算得让评委挑不出毛病。这个题目适合两类读者一类是正在备战的数模参赛者想快速拿到一套能跑通的完整框架另一类是从事风控或数据分析的从业者想把这套数据清洗、评分卡、利率定价方法挪到自己的业务场景里。下面按我习惯的落地顺序讲先做特征工程再建违约概率模型最后把利率和额度写进利润函数并附上最容易翻车的地方。2. 从发票流水到企业画像特征工程的三个关键动作2.1 数据形态盘点进项、销项和状态字段决定你的上限拿到数据先别建模。C题的发票表通常包含开票日期、销方单位代号、购方单位代号、金额、税额、价税合计和发票状态等字段同一张表里同时混有“进项”和“销项”两种口径。建模前必须先做两件事第一明确每个企业的销售额应该用该企业作为销方的发票聚合采购额用该企业作为购方的发票聚合第二把发票状态字段过一遍作废、红冲这类记录在业务上不算真实收入直接筛掉否则后面的指标全被少数异常单子带偏。我一般会先把字段名统一、日期转成时间类型再按销方、购方分别复制一份窄表后续所有特征都从这个窄表出发。不要在原表上反复切片后面调特征的时候你会后悔没一开始就拆开。import pandas as pd import numpy as np # 读入原始数据企业信息表和发票明细表 df_info pd.read_excel(附件1.xlsx, sheet_name企业信息) df_inv pd.read_excel(附件2.xlsx, sheet_name发票明细) # 统一列名去掉首尾空格避免“开票日期 ”和“开票日期”同时存在 df_inv.columns [str(c).strip() for c in df_inv.columns] df_inv[开票日期] pd.to_datetime(df_inv[开票日期], errorscoerce) df_inv[金额] pd.to_numeric(df_inv[金额], errorscoerce) df_inv[价税合计] pd.to_numeric(df_inv[价税合计], errorscoerce) # 剔除作废和红冲状态再用金额正数兜底 df_inv df_inv[~df_inv[发票状态].astype(str).str.contains(作废|红冲, naFalse)] df_inv df_inv[df_inv[价税合计] 0].copy() # 拆成销项视角和进项视角分别做特征 sale df_inv[[销方单位代号, 开票日期, 价税合计]].copy() sale.columns [企业代号, date, amount] purchase df_inv[[购方单位代号, 开票日期, 价税合计]].copy() purchase.columns [企业代号, date, amount]逻辑说明第一步筛选状态字段这里的关键判断是“金额大于0”并不能完全替代状态筛选因为部分正金额的作废发票依然存在所以状态字段优先。第二步把销方、购方分别做成独立窄表好处是后面计算月均、同比、环比时不会把进销项混在一起。参数说明“作废|红冲”是一个正则表达式匹配任意包含这两个词的状态值如果你的数据集状态字段是数字编码应该改成状态码白名单比如只保留状态码为1的行而不是用文本匹配。2.2 把流水变成指标滚动窗口聚合出经营稳定性有了窄表之后特征工程的核心是“窗口怎么选”。常见做法是按自然月聚合得出每个企业每月的销售额和采购额再用最近3个月、6个月、12个月三个窗口做滚动统计得到均值、标准差、变异系数、最大回撤几类指标。为什么用标准差和变异系数因为银行担心的是“看着很猛下个月突然没了”的企业。只有总额没有波动性特征的模型信用风险会被严重低估。时间窗口有个细节赛题给的发票跨度不是完全对齐的有的企业先有进项后有销项有的只有销项没有进项。对只有单边发票的企业我通常用另一侧缺失值直接填充0同时加一个“是否有采购记录”的哑变量。不要让缺失值悄悄变成0又不加标记那是黑匣子评委答辩时一问就露馅。def monthly_volume(df, label): 按企业-月份聚合业务量label 用来区分销项/进项 df df.copy() df[ym] df[date].dt.to_period(M) grouped df.groupby([企业代号, ym])[amount].sum().reset_index() grouped[label] label return grouped sale_monthly monthly_volume(sale, sale) purchase_monthly monthly_volume(purchase, purchase) def add_rolling_features(monthly, prefix, windows[3, 6, 12]): features [] for code, g in monthly.groupby(企业代号): g g.sort_values(ym) row {企业代号: code} for w in windows: # 取最近 w 个月包含当月 recent g.tail(w)[amount] row[f{prefix}_sum_{w}m] recent.sum() row[f{prefix}_mean_{w}m] recent.mean() row[f{prefix}_std_{w}m] recent.std(ddof0) if recent.mean() 0: row[f{prefix}_cv_{w}m] recent.std(ddof0) / recent.mean() else: row[f{prefix}_cv_{w}m] 0 # 连续有业务月份占比有多少个月在最近总月数中有销项 months_active len(g[g[amount] 0]) months_total max(1, g[ym].nunique()) row[f{prefix}_active_ratio] months_active / months_total features.append(row) return pd.DataFrame(features) sale_feat add_rolling_features(sale_monthly, sale) purchase_feat add_rolling_features(purchase_monthly, purchase)逻辑说明monthly的groupby先对每个企业按月求和得到以月为单位的业务序列再对每个企业取tail(w)做滚动窗口。注意std在只有一两个月时是NaN要先用fillna(0)或直接保留让后续模型处理缺失不要让它变成模型里的异常大数。active_ratio衡量这个企业在观测窗口内是不是稳定开票。参数说明窗口[3, 6, 12]是竞赛里比较好用的组合既能捕捉短期波动又能捕捉年度趋势。若有效月份低于3个月默认用全量月份具体按赛题给出的还款周期去定如果要求按月还贷窗口就要覆盖至少一个还款周期。2.3 指标归一化与权重用熵权法替代拍脑袋打分特征造出十几列后下一步是把指标合成一个综合信用分。常见做法是熵权法或CRITIC权重而不是直接主观给权因为评委更愿意看到“权重来自数据”。熵权法的思路是某个指标在所有企业中的差异越大它携带的信息越多权重越高。实际操作按四步走归一化、计算比重、计算熵值、算权重。我常用Min-Max归一化把每个指标压到0到1。对于反向指标如变异系数需要先取倒数或用1减x转换否则方向不对。这一步最容易翻车方向错了后续评分排名全反。def entropy_weight(df, cols): 输入包含指标列的 DataFrame输出各指标权重 # 1. 归一化并做平移避免 log(0) data df[cols].copy() data data.fillna(data.median()) data (data - data.min()) / (data.max() - data.min() 1e-9) data data 1e-9 # 2. 计算比重 pij p data / data.sum(axis0) # 3. 计算熵值 ej k 1.0 / np.log(len(data)) e -k * (p * np.log(p)).sum(axis0) # 4. 差异系数归一化得到权重 d 1 - e w d / d.sum() return w.to_dict() feature_table sale_feat.merge(purchase_feat, on企业代号, howouter) cols [sale_sum_12m, sale_cv_6m, sale_active_ratio, purchase_sum_12m, purchase_cv_6m] weights entropy_weight(feature_table, cols) print(weights)逻辑说明熵权法要求所有指标同向否则权重没有意义。比如变异系数是越小越好先做“1/(1cv)”或“max-cv”再放进熵权法。另一个要点是空值处理熵权法里一旦有NaN会传染整个列所以我先用全局中位数填补再做归一化。参数说明归一化分母中的1e-9是防御性常数防止指标最大值等于最小值时除以0。平移常数1e-9的意义是让最小归一化值不为0对数函数才能计算。填中位数会降低指标方差但熵权法对分布敏感度一般实际影响可接受。3. 违约概率建模逻辑回归打底树模型提分3.1 违约标签怎么定义先把历史信贷记录和发票对起来赛题给的企业信息里通常有一批企业是银行已经给过信贷的这批企业里谁能正常还款、谁逾期甚至跑路是判断模型能否学习违约风险的唯一依据。实操中我会先建一张违约标签表有过信贷记录并且还款情况不达标的标为1正常还款标为0没有信贷记录的企业不能被拿来训练因为银行并没实际放贷你不能凭空猜它会不会违约。这步有隐藏坑有些企业虽然标着“无信贷记录”但其信用评级已经给出。按建模逻辑这类样本可以参与信用评分特征构造但不能进违约概率的训练集否则模型会把对未知的猜测当成真实标签产生乐观偏差。# 假设附件1里有“是否有信贷记录”和“违约记录”两列 df_label df_info[[企业代号, 是否有信贷记录, 违约记录]].copy() train_label df_label[df_label[是否有信贷记录] 1] train_label train_label.assign( def_flagtrain_label[违约记录].map({正常: 0, 违约: 1}) ) train_label train_label[train_label[def_flag].notna()]逻辑说明先把企业信息表拉出来只保留给过信贷的企业做标注。违约记录如果被编码为“正常、逾期、未兑付”等文本要显式映射成0和1。部分情况下同一企业有多条还款记录就用出现过一次逾期即违约的保守口径银行视角下一次实质违约就应减少额度。参数说明这个保守口径直接影响后面的精确率。如果你把逾期3次才算违约正样本会少一半模型更容易偏向不放贷。竞赛中建议先用最保守口径再在稳健性检验里换宽松口径对比这本身就是加分项。3.2 快速对比三套模型逻辑回归、随机森林、梯度提升树违约概率模型的选型不用追求花哨。历史信贷样本通常只有几百个企业维度过高容易过拟合模型输出还要能被评委解释。我用三个模型做基线逻辑回归作为白盒参考随机森林和GBDT作为非线性增强。特征从上一章的特征表里合并过来注意训练集只含有信贷记录的企业。from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier from sklearn.metrics import roc_auc_score, average_precision_score feature_cols [c for c in feature_table.columns if c ! 企业代号] train feature_table.merge(train_label, on企业代号, howinner) X train[feature_cols].fillna(train[feature_cols].median()) y train[def_flag] # 分层抽样保证正负样本比例在训练和测试里一致 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.25, stratifyy, random_state42 ) models { logit: LogisticRegression(max_iter1000), rf: RandomForestClassifier(n_estimators300, max_depth4, random_state42), gbdt: GradientBoostingClassifier(n_estimators200, max_depth3, random_state42), } for name, m in models.items(): m.fit(X_train, y_train) y_prob m.predict_proba(X_test)[:, 1] print(name, AUC%.3f % roc_auc_score(y_test, y_prob), AP%.3f % average_precision_score(y_test, y_prob))逻辑说明test_size0.25把四分之一的样本留作验证stratifyy让正负样本比例在训练和测试里保持一致。竞赛数据正类很少如果不分层完全有可能测试集里一个违约样本都没有AUC无法计算。参数说明随机森林深度限制在4避免在小样本上彻底记住噪声。GBDT的n_estimators200配max_depth3也是保守组合如果样本量只有300多深度再大模型会过拟合。max_iter1000是逻辑回归保证收敛的常见设定不让求解器提早停在中间。3.3 概率校准与阈值选择别把0.5当成万能门槛模型输出的是违约概率p而不是最终贷或不贷的判断。在信贷场景下p要直接进下一步的利润函数所以需要校准过的概率。逻辑回归自带校准但树模型的概率容易偏用sklearn的CalibratedClassifierCV做Platt缩放后效果更稳。阈值选择上不应固定为0.5。我会画不同阈值下的违约覆盖率曲线用业务能接受的“被拒绝企业中违约率”反推阈值。例如希望至少不漏掉80%的违约企业就取覆盖率等于0.8对应的阈值这个选择比0.5硬切更经得起评委追问。from sklearn.calibration import CalibratedClassifierCV gbdt GradientBoostingClassifier(n_estimators200, max_depth3, random_state42) gbdt_calib CalibratedClassifierCV(gbdt, cv5, methodplatt) gbdt_calib.fit(X_train, y_train) y_prob gbdt_calib.predict_proba(X_test)[:, 1] # 找召回违约样本80%对应的阈值 best_th 0.5 for th in np.arange(0.05, 0.95, 0.01): recall (y_prob[y_test 1] th).mean() if recall 0.8: best_th th break print(建议阈值:, best_th, AUC:, roc_auc_score(y_test, y_prob))逻辑说明CalibratedClassifierCV的内部cv5用五折交叉验证做概率校准避免用训练集自身来校准带来的偏差。校准后的概率分布更接近真实违约率这样第4章计算期望收益时利息收入和坏账损失才能放到同一基准下比较。参数说明methodplatt适用于违约率较低的样本它假设概率经过逻辑回归映射最接近真实分布如果样本量在几千以上可以用isotonic等渗回归。阈值搜索区间从0.05起步因为竞赛正类很少硬用0.5会把所有违约都压没。4. 放贷额度和利率定价把银行利润写成目标函数4.1 单户期望收益公式利率、违约率、LGD三者的联动前面得到的违约概率只解决贷不贷真正拉大分数差距的是额度与利率的定价模型。银行收益可写成简化公式期望利润 贷款金额 × 利率 × 期限 ×(1 − 违约率) − 贷款金额 × 违约率 × 违约损失率(LGD)对中小微企业LGD通常设成50%到70%简化处理取0.5代表一旦违约只能收回一半本金。利率调高能多赚钱但也可能提高违约率更严谨的做法是把利率当作调节因子加入违约概率的修正但在竞赛题里一般取评级不同默认不同违约率、评级对应基准利率的简化假设。你只需要明确自己用了哪个假设并写清楚理由。银行视角还有一个逻辑利率不是越高越好因为客户可能流失或选择不贷。赛题常见边界是给出年利率上限这步必须先读清题干写进约束条件。def expected_profit(principal, rate, term_years, p_def, lgd0.5): 单企业期望利润单位为万元 interest principal * rate * term_years loss principal * lgd * p_def profit interest * (1 - p_def) - loss return profit # 示例放100万年利率6%期限1年违约率0.12 print(expected_profit(100, 0.06, 1, 0.12))逻辑说明利率即年化利率期限按年计算。核心要点是interest用本金乘年利率乘期限而loss用本金乘违约率乘LGD违约时不收利息。实际业务中违约发生后可能仍有部分回收LGD不高于1这里用0.5是把问题线性化便于下一节做矩阵优化。参数说明lgd根据赛题场景调整。有的题默认有抵押物LGD取0.3没有抵押物的纯信用贷款LGD接近0.7。没必要过度精确关键是在论文里写明无抵押场景取0.5稳健性检验再试0.3和0.7。4.2 全局约束可用资金、利率上限、行业集中度利润公式算的是单户但银行不是无限钱。全局约束通常有三个总放贷金额不超过给定资金额度每家企业的贷款金额在客户申请上限和银行最低额度之间对单一行业的放贷金额占比不超过某个比例。行业归属可以从附件1里的企业类型或经营范围字段做映射。这部分最容易被忽略。很多队伍算出利润最大化就写结论结果总贷款金额超出限额评委一眼看穿模型不可行。我一般把约束全部写成显式参数而不是写死在代码里。TOTAL_QUOTA 100000 # 总信贷额度单位万元 MAX_RATE 0.15 # 年利率上限来自赛题或法规约束 MIN_AMOUNT, MAX_AMOUNT 10, 500 # 单笔贷款上下限单位万元 INDUSTRY_LIMIT 0.4 # 单一行业放贷金额占比上限逻辑说明TOTAL_QUOTA来自赛题背景例如某银行一年可用的信贷总额。把限额全部提到代码开头的好处是做敏感性测试时只需要改这一处参数不用回头翻模型代码。参数说明单笔上下限通常要结合赛题里“申请额度”字段的分布来设。如果申请额度本身都小于100万硬设成500万上限就没有实际意义建议先统计申请额度的分位数再用P90作为上限。4.3 给企业排序和放贷用枚举加约束条件跑通第一版候选企业一般只有几百家不需要上复杂的整数规划用等距数组组合加筛选就够了。做法先算每个企业在建议利率下的期望利润再用按违约率从低到高、按单户期望利润从高到低两种排序生成名单最后在两种名单里取总利润更大且满足约束的一组。这个方法在速度和可解释性上都是最优的方便论文里画出对比表。candidate feature_table.merge(prob_result, on企业代号, howleft) candidate[amount] candidate[申请额度].clip(MIN_AMOUNT, MAX_AMOUNT) def rate_by_risk(p): if p 0.05: return 0.045 elif p 0.15: return 0.06 else: return 0.08 candidate[rate] candidate[p_def].apply(rate_by_risk) candidate[profit] expected_profit( candidate[amount], candidate[rate], 1, candidate[p_def] ) # 排序法一按期望利润降序优先放利润高的企业 cand_sorted candidate.sort_values(profit, ascendingFalse) result, used [], 0 for _, row in cand_sorted.iterrows(): if used row[amount] TOTAL_QUOTA: continue used row[amount] result.append(row) print(总放贷金额%.0f万元总期望利润%.2f万元 % (used, sum(r[profit] for r in result)))逻辑说明rate_by_risk是分档定价函数用违约率区间决定利率。现实里信用评级也参与定价我这里只用模型违约率如果要贴近赛题把附件1评级作为另一档来联合查表。排序后循环内用continue而不是break意思是跳过个别大额申请继续放给后面的小额企业这样额度利用率更高。参数说明分档利率是模拟值正式提交前要根据赛题里基准利率、上浮比例、评级对应利率的表述重新调表。这个版本没有处理行业集中度加上行业约束只需在循环条件里加一个字段判断。5. 实战避坑信贷模型最容易翻车的五个地方5.1 作废发票和红字发票把销售额整体抬高现象某企业销售额排名高得离谱一个月几百万但真实流水波动为零。检查发现是作废发票被重复计算进了月度聚合。原因发票数据里作废、红冲状态在行级不显眼groupby求和时自动吞进统计量。很多队伍在预处理里只做了金额大于0的筛选没有看状态字段。解决在特征工程第一步先按状态字段筛查并剔除若不清楚字段编码就看发票号码是否在表中重复出现两次正负金额成对出现的基本是红字冲销。把状态白名单和金额大于0的筛选同时写入预处理函数后续重跑不会复发。5.2 随机划分训练集导致“未来信息”泄漏现象模型AUC能到0.97但实际放贷名单非常激进把一批高违约率企业也放了贷。原因把企业均匀随机划分训练集里混有更晚时期的数据模型无意中学到了时间段效应比如某季度整体转好或转差导致评估虚高。解决用时间划分以决策点之前连续12个月做训练特征决策点之后月份只在测试集里出现。做不了严格时间序列时按企业首次开票时间分层排序后再切分答辩时直接说“我按时间切分避免前视偏差”这关就过了。5.3 违约样本太少模型只会把所有人预测成不违约现象模型在训练集上正确率95%但预测的违约概率普遍小于0.1放贷名单几乎等于全员放。原因违约样本占比往往不到10%多数模型在类不平衡下退化为永远预测多数类。解决先把评价指标从正确率换成AUC和PR-AUC再考虑给少数类加class_weight或做SMOTE过采样。简单做法是在训练时把违约类的权重设成“非违约样本数除以违约样本数”代价是预测概率整体上移需要再次做Platt校准。5.4 只按信用评分排序忽视了额度和利率联动现象放贷名单和评分排名完全一致总利润却不如另一种排序高。原因评分高只代表违约率低不代表单位本金利润高。如果高信用企业只申请小额贷款利润总额并不大一个违约率稍高但申请大额、利率也高的企业可能有更大期望贡献。解决不要直接按评分排名而是按期望利润排序。在论文里放两个对比表评分排序的放贷结果与期望利润排序的放贷结果后者总利润更高这个对比本身就是模型有效性的证据。5.5 输出格式和字段单位对不上前面全白做现象结果被扣分回头发现提交表里企业代号用的是企业名称而题目要求代号金额单位从万元混成了元。原因赛题提交格式通常要求“企业代号、贷款金额万元、贷款利率、是否信贷”代码导出的列顺序不一致Excel又默认把贷款金额转成科学计数法。解决最后一步固定用DataFrame构造提交表列顺序和列名严格按照题目给的表格顺序金额四舍五入到万元的小数点后两位。导出前写一个断言检查企业代号去重数量、金额非空、利率在合法区间全部通过再保存。6. 论文和配套代码里最能加分的四个细节6.1 数据描述先说清楚有多少行、多少字段、为什么缺失评分老师拿到论文先看数据概览。我的习惯是开篇放一张数据集清单表列包括数据表、维度、字段类型、缺失率、处理方式然后写一句话说明“剔除作废发票后有效数据占比由92%提升至97%”让审阅者第一页就知道你做了数据质量工作。6.2 模型结果给AUC、PR-AUC、利润贡献三件套只写准召率容易被追问阈值怎么选。我建议每类模型固定输出三件套AUC衡量排序能力PR-AUC衡量少数类辨识度最终策略总利润衡量业务效果。论文里用一张横向对比表把逻辑回归、随机森林、GBDT三行并排给这三个指标比五页解释更有说服力。6.3 做一次参数敏感性分析额度上限、LGD、分档利率最容易让评委认可的非技术环节是敏感性分析。把TOTAL_QUOTA从8万调到12万把LGD从0.3调到0.7记录总期望利润的变化曲线。只要结论是“策略在不同参数下都优于只用信用评分的基线”你就把方案从“一个模型”推向“一个决策框架”。6.4 代码里留一个入口函数让复现成本降到最低代码包放在论文之外评委大概率不会一行行读代码但会找入口在哪。给代码包加一个run_all.ipynb按“数据读取、特征提取、违约模型、利率定价、策略表输出”五步顺序执行每一步输出中间结果表。这样复现只需一个入口复现成本降到最低。这次C题做完我最深的教训是“模型不是拿分的主要部分把银行业务规则翻译成数学约束才是”。先把数据和规则理清楚再上模型整个过程不会翻车。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑