简介:2020年全国大学生数学建模竞赛C题“中小微企业信贷决策”实践资料包,包含可编辑Word论文与MATLAB源代码,面向参赛学生、毕业设计者及金融数据分析学习者,系统展示基于统计建模解决企业信贷风险评估的完整流程。压缩包共160个文件,总大小约248.35MB,涵盖xlsx企业数据表、txt说明文件、m源码、csv数据集、docx文档及jpg/png图表;文件类型覆盖原始数据、特征处理结果、模型代码与可视化输出,便于按需查阅。已有663人学习下载。论文部分围绕信息不对称、风险高、信用评估难等小微信贷特点,详述数据预处理(缺失值/异常值处理、清洗)、特征工程以及线性回归、决策树等模型的构建与交叉验证;MATLAB代码则对应实现数据导入、建模、训练、测试等模块,读者可对照论文复现,并结合各类图表直观理解模型预测效果与实际数据的差异。整体上,这套资料将理论与实战结合,是理解数学建模在金融风控中落地应用的高价值案例。1. 把中小微企业信贷决策C题拆成四份:数据、评分、定价、检验拿到这份2020年数学建模竞赛C题资源包时,第一反应不是「这是一份论文」,而是一整套可以落地复现的中小微企业信贷决策流程。赛题给了一百多家企业的进销项发票、纳税申报和历史信贷记录,要回答三个问题:哪些企业该放贷、额度给多少、利率怎么定。看得多了会发现,这道题表面是数据分析,内核其实是银行信贷审批里的风险定价。这份资源适合两类人:一类是准备参赛拿高分的学生,另一类是想用真实赛题练手信贷风控建模的从业者。我习惯把这类题拆成四步解:洗干净数据画像、给企业打信用分、把分数折算成额度和利率、最后做稳健性检验。下面按这个顺序,把每个环节的参数选择和踩坑点讲透。2. 数据清洗与特征工程:从进销项发票到企业信贷画像2.1 先想清楚企业拿什么还钱,再动手写 groupby信贷决策的风险来源拆开只有两件事:企业还不起钱是能力问题,不想还钱是意愿问题。能力看经营流水,意愿看历史违约记录。所以特征工程的每一列都得服务于这两件事之一。我一般先算三个方向的特征:经营规模,比如销项金额、进项金额、税额;经营稳定性,比如连续活跃月份数、收入波动幅度;还有申报与发票的偏离度,用纳税申报收入比上发票销项收入,偏离越大越可疑。这三个方向不是拍脑袋定的,它们分别对应风险敞口和违约概率这两个核心变量。如果连经营规模都算不准,后面的评分模型再精细也是空中楼阁。C题的数据通常把发票明细和企业信息分成多个表,第一步一定是做聚合而不是直接建模。很多队伍急着上模型,结果特征表里全是缺失值,回头补数据的成本远高于一次仔细的清洗。2.2 主线代码:一版够用的企业信用画像构建下面这版代码是这类赛题里最常见的骨架,字段名换成自己的数据后就能跑通。import pandas as pd import numpy as np def build_credit_profile(invoice, sales_report): # 字段假设:ent_id 企业ID,direction 进项/销项,amount 金额,tax 税额,date 开票日期 df = invoice.copy() df["month"] = pd.to_datetime(df["date"]).dt.to_period("M") # 按企业、方向、月份三层聚合 agg = df.groupby(["ent_id", "direction", "month"]).agg( amount_sum=("amount", "sum"), invoice_cnt=("amount", "count"), tax_sum=("tax", "sum"), ).reset_index() # 销项收入按月转成宽表,后续特征都基于它 out = agg[agg["direction"] == "out"].pivot_table( index="ent_id", columns="month", values="amount_sum", aggfunc="sum" ) # 只取最近6个月,避免用未来的数据倒退特征 recent = out.iloc[:, -6:] profile = pd.DataFrame(index=out.index) profile["active_months"] = recent.notna().sum(axis=1) profile["sales_ma3"] = out.ffill(axis=1).rolling(3, axis=1).mean(axis=1).iloc[:, -1] profile["sales_std"] = recent.std(axis=1).fillna(0) profile["sales_total"] = recent.sum(axis=1).fillna(0) # 实际税负率:销项发票里的税金占比 out_tax = agg[agg["direction"] == "out"].groupby("ent_id")["tax_sum"].sum() profile["tax_rate"] = (out_tax / profile["sales_total"]).replace([np.inf, -np.inf], np.nan) # 申报收入与发票销项收入的比值,偏离过大的企业标记出来 merged = profile.merge(sales_report, on="ent_id", how="left") merged["declared_vs_invoice"] = merged["declared_revenue"] / (merged["sales_total"] + 1e-6) return merged需要说明的参数选择:窗口为什么取3个月和6个月:3个月平滑短期波动,6个月覆盖一个季度账期加上缓冲。窗口太短会把季节性波动误判成经营下滑,太长又会抹掉新企业的成长曲线。缺失值用 ffill 而不是 dropna:老企业某月没开票可能只是结算周期问题,新企业成立晚则天然缺数据。直接把缺失行删掉,等于把新企业全踢出局。前向填充能保住宽表的完整性。税负率单独拿出来讲,这在信贷圈子里有点玄学成分,但风控确实在看这个指标。税负率异常低的企业,要么在偷漏税,要么收入是虚的,后续评分时要给它降档。2.3 时间对齐和发票口径:两个能毁掉整张表的细节第一个坑是时间。不同企业的数据起止时间往往不一样,直接用全时段汇总做特征,成立时间长的企业天然占优势。常见做法是给每家企业设一个统一的预测截止日,特征窗口都从截止日往前推固定长度。我一般会额外写一个时间对齐函数,把所有企业的数据截到同一个日期,避免有的企业多看半年、有的少看半年。第二个坑是发票口径。作废票、红冲发票在明细里可能表现为负数或特殊状态,清洗时一定要过滤。拿到的原始表里如果这些票混在正常销项里,销项收入会被重复计算,税负率也跟着失真。建议在构建特征前先做一次完整性核对:检查项预期结果不合格时的处理企业ID是否唯一无重复查合并口径或拆分口径发票状态只剩有效票无作废、红冲按状态列过滤每家企业时间跨度差异在合理范围统一预测截止日后截取窗口进项与销项关系大部分进项小于销项进销比异常企业单独标记还有一个容易忽略的点:把企业按数据活跃期分成两桶,不足6个月的新企业单独处理。它们的特征只能用「近3个月是否有进项」「最近开票距今多少天」这类比率指标,直接用月度均值会和老企业混在一起,导致新企业评分全部偏低。这个小改动往往能把评分尾部表现拉回来不少。2.4 用三五家企业做人工核对特征工程做完别急着喂模型。我每次都会随机抽三到五家企业,把它们的原始流水和特征值逐行核对一遍:销项收入加总是否对得上,连续活跃月份是否合理,申报比对值有没有出现离谱的百倍量级。这一步看着土,但抓出来的问题比任何自动化检查都多。有一次我发现某家企业的进项税额比销项税额还大,查下来是发票状态没过滤干净,红冲票混了进来,修正之后税负率特征才恢复正常。这种错误如果带到模型里,最后论文结论都是错的。3. 信用评分建模:为什么这类题要放弃精确度、只追区分度3.1 准确率在这道题里没有意义先泼一盆冷水:如果只看准确率,一个「全都不违约」的预测器能拿到接近九成的分数。这道题违约样本占比很小,模型天然面临严重的类别不均衡,所以评判标准不是预测准不准,而是能不能把违约企业和正常企业的分数拉开。评分模型输出的是一列连续分数而不是二分类标签,区分度足够高,后面才有做差异化额度和利率的空间。建模路线常用有三条:标准评分卡、Logistic回归、聚类。评分卡在银行用得最多,解释性强,评委看着有代入感;Logistic回归实现最简单,是评分卡的简化版;聚类可以处理没有信用记录的企业,但要解释清楚聚出来的每一类是什么意思,否则答辩容易被追问。C题数据量不大,几百家企业的量级,我建议先跑逻辑回归做基线,时间富余再升级成分箱评分卡。3.2 端到端建模代码:从训练到KS统计量from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split def build_score_model(X, y): # 有违约记录的企业才进训练集,无记录企业另行处理 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) # class_weight="balanced",让模型把违约样本当回事 model = LogisticRegression(class_weight="balanced", C=0.5, max_iter=1000) model.fit(X_train, y_train) # 取违约概率作为分数来源,而不是直接取0/1 y_prob = model.predict_proba(X_test)[:, 1] return model, y_prob, y_test def ks_stat(y_true, y_score): # 按分数从高到低排序,计算违约组与正常组累计分布的最大差距 df = pd.DataFrame({"y": y_true, "score": y_score}) df = df.sort_values("score", ascending=False).reset_index(drop=True) pos = df["y"].cumsum() / max(df["y"].sum(), 1) neg = (1 - df["y"]).cumsum() / max((1 - df["y"]).sum(), 1) return (pos - neg).abs().max()参数和逻辑说明:class_weight="balanced":违约样本少,普通逻辑回归会倒向多数类。这个参数按样本量倒数加权,把少数类的损失放大,训练出来的分数在违约组和正常组之间才有分隔。C=0.5:正则化强度。几百个样本的小数据集,C 太大容易过拟合,太小则把特征全压平,0.5 是个合理的起点,调参时按数量级试。为什么用 predict_proba 而不是 predict:下一章的信贷策略需要连续分数来切额度档位,二分类标签会把大量信息离散化。KS 统计量是这篇论文最该放进去的指标:它表示排序后两组累计分布的最大差值,0.3 以上说明模型有区分能力,0.4 以上就很不错。评委只要看到你算 KS,就知道你懂评分模型的核心逻辑。分数怎么转成 0-100 的信用分也顺手说一下。最直接的做法是把违约概率排个百分位,再镜像一下,让高概率对应低分数:final_score = (1 - y_prob.rank(pct=True)) * 100这样得到的信用分近似均匀分布在 0-100 之间,不受原始概率值偏移的影响。直接拿100 - y_prob*100也行,但如果全部概率挤在 0.1 附近,分数会堆在 90 以上毫无区分度,百分位排名能解决这个问题。3.3 切分方式与阈值策略:别让验证集偷看答案模型验证的切分方式我一直坚持「时间切分」而不是随机切分:用前80%的时间段做训练,后20%做验证。信贷业务本质是预测未来,随机切分会把未来的数据混进训练集,指标虚高,且高得没有实际意义。阈值更不能默认 0.5。违约概率分布整体偏低,0.5 作为阈值等于宣布几乎没有企业会违约。常见做法是取 KS 最大值对应的分数作为风险分界线,或者用百分位的 70 分位作为预警线。论文里明确写清楚「阈值根据 KS 曲线选取」,比丢一个默认阈值有说服力得多。另外,有一部分没有信贷记录的企业进不了监督训练。我的做法是用有监督模型给它们打分做排序,再叠加一条规则校验,论文里写成「混合评估框架」,逻辑上比硬分类站得住。提示:如果违约样本太少,连分层抽样都救不回来,就不要硬做有监督模型。改用规则打分加上离群点检测,写成混合信贷评估框架,评委反而更认可。4. 信贷策略:评分转额度利率的四步定价法与稳健性检验4.1 先定银行的三条底线,再谈利润评分模型给出的是排序,信贷策略给出的是真金白银的额度和利率。把分数映射成策略之前,先写死三条底线:不良率不能超过某上限,单户额度不能超过企业经营能力,利率不能低于资金成本。这三个约束是后面所有参数的上限来源。在C题场景里,资金成本按年化 4.5% 左右估算比较常见,那么优质客户利率应该落在 4.5% 附近,风险客户往上加风险溢价。额度则锚定近六个月的销项收入,不能给一个流水根本撑不住的金额。有了约束再看收益,评分高给低利率和高额度,评分低反过来,这就是风险定价的基本逻辑。4.2 额度和利率的映射函数代码这一步把信用分转成可执行的信贷参数。我用的映射策略是三档再加一个拒绝区,分段的好处是每一档的业务含义都能向评委说清。def credit_policy(final_score, sales_6m, ratio_cap=0.4, base_rate=0.045): # final_score: 0-100,越高信用越好 # sales_6m: 近6个月销项收入,用于限制单户额度 if final_score < 60: return 0, None # 拒绝放贷 # 额度:先按销项收入乘比例,再用评分做折价 limit = sales_6m * ratio_cap * (final_score / 100) if final_score >= 85: rate = base_rate # 优质客户拿基准利率 elif final_score >= 75: rate = base_rate + 0.015 # 良好客户加150个基点 else: rate = base_rate + 0.03 # 65分以上的客户加300个基点 return round(limit, 2), round(rate, 4)参数怎么选:额度的锚定基准是销项收入而不是利润。利润波动大且口径不统一,销项收入来自发票,可信度高。ratio_cap=0.4 的意思是额度上限等于近6个月销项收入的 40%。真实信贷里这个比例对应「债务偿还比率」,保守的机构会压到 0.3 以内。这个值的选取要配合后面的敏感性分析一起讲。利率等差递增是因为题目里没有违约损失率这类数据,用分段映射更利于解释。每一档利率都对应一个风险区间,评委看得出你是认真做了定价设计,而不是随便套了个公式。拒绝线定在 60 分,意味着评分后约 20% 的企业会被拒绝放贷,这个比例跟真实银行的小微审批通过率大致相符。4.3 稳健性检验:参数扰动与压力测试这一节是拉开论文差距的地方。模型定了不代表策略稳,评委最爱追问「你的参数换一下,结论会不会变」。我一般做两件事:参数扰动和压力情景测试。import numpy as np rng = np.random.default_rng(42) def robustness_check(final_score, sales_6m, n=500): results = [] for _ in range(n): cap = rng.uniform(0.3, 0.5) # 额度比例扰动 ±25% base = rng.uniform(0.035, 0.055) # 基准利率扰动 ±22% limits, rates = credit_policy(final_score, sales_6m, cap, base) valid_rates = [r for r in rates if r is not None] results.append([cap, base, limits.sum(), np.mean(valid_rates)]) return np.array(results)输出矩阵的四列分别是两个扰动参数和两个业务结果:总额度、平均利率。我通常看两个结论:总额度对参数扰动的波动幅度,以及平均利率的稳定区间。如果参数扰动 ±25% 以内,业务指标变化不超过 15%,就说策略是稳健的,这段写进论文就是一个独立的实证小节。压力测试的做法更粗暴:把所有企业的销项收入统一打七折再跑一遍整套策略,观察拒绝率上升多少、总额度缩水多少。评委看到这一页,基本不会再问你「参数是不是凑出来的」,因为你在主动演示边界条件。这部分的论文呈现建议用一张表,列方向是参数变动范围、总额度变化率、平均利率变化率。三行就够,写清楚基线值和扰动范围,页脚注明随机种子,保证可复现。很多队伍论文写了一整本,却拿不出一张稳健性表格,这是最可惜的丢分点。5. 常见问题排查:从分箱到泄漏,参赛中最容易翻车的五个坑5.1 时间跨度不一致:老企业靠数据量碾压新企业现象:评分模型跑完,成立时间短的企业分数几乎全部压在尾部,看起来像一群「坏客户」,一刀切放贷额度全被拒掉。原因:所有企业混在一张宽表里,老企业天然有更多月份的销项记录,聚合出的金额总额和活跃月数都更大,模型把「数据量大」误当成了「经营实力强」。解决:按我第2章说的,把活跃期不足6个月的企业单独分桶,用近3个月比率特征替代总量特征;所有企业在同一个预测截止日下对齐时间窗。5.2 特征泄漏:训练集漂亮得像开卷考试现象:训练集AUC接近0.98,验证集却一落千丈,现场换数据直接就崩。原因:特征里混进了「之后才知道」的信息。有人把全年的销项收入算完均值,再去预测下半年是否违约,等于拿答案做预测。解决:坚持时间切分,特征只允许使用预测截止日之前的数据。写代码时把特征工程封装成函数,参数里传入截止日,所有窗口计算都在截止日之前完成,从机制上杜绝泄漏。5.3 类别不均衡:0.5 阈值的致命默认值现象:模型输出的违约预测几乎全是0,查了类别分布才发现违约样本只占一小部分。原因:没做类别加权,模型学到的最优策略就是把所有样本预测成正常类;再用默认0.5阈值切分,违约企业全部被放走。解决:训练时加class_weight="balanced",评估时不看准确率,看KS和AUC。另外写论文时明确写一句「决策阈值由KS曲线选取」,而不是留在默认值,这句话能拦住一半的追问。5.4 额度上限缺失:给了流水根本撑不起的放款额现象:某企业信用分不错,模型给了一笔大额贷款,但一对照销项流水,额度相当于月收入的3倍,按这个金额根本还不出来。原因:直接用评分映射额度,忘了给额度设置偿付能力上限。解决:额度必须等于销项收入乘以倍数系数。我通常把系数范围控制在0.3到0.5,并且在论文里把额度公式写清楚,附上一句「额度上限受企业近6个月销项收入约束」,这是风控常识,评委看了不会挑毛病。5.5 论文没有稳健性检验:答辩被追问就卡壳现象:评委问「你的额度比例从0.4改成0.6,结论还成立吗」,答辩人当场愣住。原因:全文只贴了模型训练效果,没做任何参数扰动分析,策略的边界完全没交代。解决:补上第4章的扰动实验,哪怕只改额度比例和基准利率两个参数,生成一张参数-结果表,说服力都会完全不同。从那以后我做这类题都会提前跑一遍随机种子固定的扰动,数据放论文附录里都是加分项。6. 进阶技巧:从C题到通用风控的复用要点6.1 分箱与IV值:把逻辑回归升级成标准评分卡C题的数据量不大,但特征分箱这个技巧可以直接复用。分箱能处理非线性关系,还能把缺失值单独成箱,解释性比连续变量更好。IV值用于筛选特征:低于0.02的特征直接丢掉,0.02到0.1是弱特征,0.1到0.3算中等,超过0.3才是强特征。用这个标准去筛C题里构造的十几个特征,剩下来的通常只有销项稳定度和申报偏离度这两个,筛选过程本身就能写进论文的特征工程章节。def woe_iv(x, y, bins=10): df = pd.DataFrame({"x": x, "y": y}) df["bin"] = pd.qcut(df["x"], bins, duplicates="drop") agg = df.groupby("bin", observed=True).agg( good=("y", lambda s: (s == 0).sum()), bad=("y", lambda s: (s == 1).sum()) ) agg["woe"] = np.log((agg["bad"] / agg["bad"].sum()) / (agg["good"] / agg["good"].sum() + 1e-6)) agg["iv"] = ((agg["bad"] / agg["bad"].sum()) - (agg["good"] / agg["good"].sum())) * agg["woe"] return agg["iv"].sum()6.2 与真实信贷数据的差异C题是静态快照,真实风控是持续更新的流式数据,两者差距明显,心里要有数。维度C题数据真实生产环境样本量几百家企业几万到几十万是否含法人征信无通常有数据形态静态快照持续更新特征维度若干发票字段征信、工商、司法等多源模型要求一版论文即可可监控、可解释C题练手最大的价值是让你完整走通「数据清洗—评分—定价—检验」这条链,真实环境里这条链会更长,但核心逻辑不变。6.3 固定工作流:拿到信贷数据后的五步检查整理一下,我拿到任何一份信贷数据都会强制走一遍:第一步,数据字典逐字段核对,搞清楚每个字段的单位和口径;第二步,画出时间轴,标出每个样本的预测截止日;第三步,先跑规则基线,比如销项下降超过30%直接拒绝,再用模型;第四步,所有特征封装成独立函数,参数写死,保证可复现;第五步,论文里的每个结论都要能找到对应的代码输出。这套流程看着笨,但能避免第九版数据覆盖第八版时把结论带偏。从那以后我每次做信贷决策题都把这条链完整走一遍,再没出现过回测良好、现场翻车的情况。这套方法论希望对你有帮助。本文还有配套的精品资源,点击获取
简介:2020年全国大学生数学建模竞赛C题“中小微企业信贷决策”实践资料包,包含可编辑Word论文与MATLAB源代码,面向参赛学生、毕业设计者及金融数据分析学习者,系统展示基于统计建模解决企业信贷风险评估的完整流程。压缩包共160个文件,总大小约248.35MB,涵盖xlsx企业数据表、txt说明文件、m源码、csv数据集、docx文档及jpg/png图表;文件类型覆盖原始数据、特征处理结果、模型代码与可视化输出,便于按需查阅。已有663人学习下载。论文部分围绕信息不对称、风险高、信用评估难等小微信贷特点,详述数据预处理(缺失值/异常值处理、清洗)、特征工程以及线性回归、决策树等模型的构建与交叉验证;MATLAB代码则对应实现数据导入、建模、训练、测试等模块,读者可对照论文复现,并结合各类图表直观理解模型预测效果与实际数据的差异。整体上,这套资料将理论与实战结合,是理解数学建模在金融风控中落地应用的高价值案例。1. 把中小微企业信贷决策C题拆成四份:数据、评分、定价、检验拿到这份2020年数学建模竞赛C题资源包时,第一反应不是「这是一份论文」,而是一整套可以落地复现的中小微企业信贷决策流程。赛题给了一百多家企业的进销项发票、纳税申报和历史信贷记录,要回答三个问题:哪些企业该放贷、额度给多少、利率怎么定。看得多了会发现,这道题表面是数据分析,内核其实是银行信贷审批里的风险定价。这份资源适合两类人:一类是准备参赛拿高分的学生,另一类是想用真实赛题练手信贷风控建模的从业者。我习惯把这类题拆成四步解:洗干净数据画像、给企业打信用分、把分数折算成额度和利率、最后做稳健性检验。下面按这个顺序,把每个环节的参数选择和踩坑点讲透。2. 数据清洗与特征工程:从进销项发票到企业信贷画像2.1 先想清楚企业拿什么还钱,再动手写 groupby信贷决策的风险来源拆开只有两件事:企业还不起钱是能力问题,不想还钱是意愿问题。能力看经营流水,意愿看历史违约记录。所以特征工程的每一列都得服务于这两件事之一。我一般先算三个方向的特征:经营规模,比如销项金额、进项金额、税额;经营稳定性,比如连续活跃月份数、收入波动幅度;还有申报与发票的偏离度,用纳税申报收入比上发票销项收入,偏离越大越可疑。这三个方向不是拍脑袋定的,它们分别对应风险敞口和违约概率这两个核心变量。如果连经营规模都算不准,后面的评分模型再精细也是空中楼阁。C题的数据通常把发票明细和企业信息分成多个表,第一步一定是做聚合而不是直接建模。很多队伍急着上模型,结果特征表里全是缺失值,回头补数据的成本远高于一次仔细的清洗。2.2 主线代码:一版够用的企业信用画像构建下面这版代码是这类赛题里最常见的骨架,字段名换成自己的数据后就能跑通。import pandas as pd import numpy as np def build_credit_profile(invoice, sales_report): # 字段假设:ent_id 企业ID,direction 进项/销项,amount 金额,tax 税额,date 开票日期 df = invoice.copy() df["month"] = pd.to_datetime(df["date"]).dt.to_period("M") # 按企业、方向、月份三层聚合 agg = df.groupby(["ent_id", "direction", "month"]).agg( amount_sum=("amount", "sum"), invoice_cnt=("amount", "count"), tax_sum=("tax", "sum"), ).reset_index() # 销项收入按月转成宽表,后续特征都基于它 out = agg[agg["direction"] == "out"].pivot_table( index="ent_id", columns="month", values="amount_sum", aggfunc="sum" ) # 只取最近6个月,避免用未来的数据倒退特征 recent = out.iloc[:, -6:] profile = pd.DataFrame(index=out.index) profile["active_months"] = recent.notna().sum(axis=1) profile["sales_ma3"] = out.ffill(axis=1).rolling(3, axis=1).mean(axis=1).iloc[:, -1] profile["sales_std"] = recent.std(axis=1).fillna(0) profile["sales_total"] = recent.sum(axis=1).fillna(0) # 实际税负率:销项发票里的税金占比 out_tax = agg[agg["direction"] == "out"].groupby("ent_id")["tax_sum"].sum() profile["tax_rate"] = (out_tax / profile["sales_total"]).replace([np.inf, -np.inf], np.nan) # 申报收入与发票销项收入的比值,偏离过大的企业标记出来 merged = profile.merge(sales_report, on="ent_id", how="left") merged["declared_vs_invoice"] = merged["declared_revenue"] / (merged["sales_total"] + 1e-6) return merged需要说明的参数选择:窗口为什么取3个月和6个月:3个月平滑短期波动,6个月覆盖一个季度账期加上缓冲。窗口太短会把季节性波动误判成经营下滑,太长又会抹掉新企业的成长曲线。缺失值用 ffill 而不是 dropna:老企业某月没开票可能只是结算周期问题,新企业成立晚则天然缺数据。直接把缺失行删掉,等于把新企业全踢出局。前向填充能保住宽表的完整性。税负率单独拿出来讲,这在信贷圈子里有点玄学成分,但风控确实在看这个指标。税负率异常低的企业,要么在偷漏税,要么收入是虚的,后续评分时要给它降档。2.3 时间对齐和发票口径:两个能毁掉整张表的细节第一个坑是时间。不同企业的数据起止时间往往不一样,直接用全时段汇总做特征,成立时间长的企业天然占优势。常见做法是给每家企业设一个统一的预测截止日,特征窗口都从截止日往前推固定长度。我一般会额外写一个时间对齐函数,把所有企业的数据截到同一个日期,避免有的企业多看半年、有的少看半年。第二个坑是发票口径。作废票、红冲发票在明细里可能表现为负数或特殊状态,清洗时一定要过滤。拿到的原始表里如果这些票混在正常销项里,销项收入会被重复计算,税负率也跟着失真。建议在构建特征前先做一次完整性核对:检查项预期结果不合格时的处理企业ID是否唯一无重复查合并口径或拆分口径发票状态只剩有效票无作废、红冲按状态列过滤每家企业时间跨度差异在合理范围统一预测截止日后截取窗口进项与销项关系大部分进项小于销项进销比异常企业单独标记还有一个容易忽略的点:把企业按数据活跃期分成两桶,不足6个月的新企业单独处理。它们的特征只能用「近3个月是否有进项」「最近开票距今多少天」这类比率指标,直接用月度均值会和老企业混在一起,导致新企业评分全部偏低。这个小改动往往能把评分尾部表现拉回来不少。2.4 用三五家企业做人工核对特征工程做完别急着喂模型。我每次都会随机抽三到五家企业,把它们的原始流水和特征值逐行核对一遍:销项收入加总是否对得上,连续活跃月份是否合理,申报比对值有没有出现离谱的百倍量级。这一步看着土,但抓出来的问题比任何自动化检查都多。有一次我发现某家企业的进项税额比销项税额还大,查下来是发票状态没过滤干净,红冲票混了进来,修正之后税负率特征才恢复正常。这种错误如果带到模型里,最后论文结论都是错的。3. 信用评分建模:为什么这类题要放弃精确度、只追区分度3.1 准确率在这道题里没有意义先泼一盆冷水:如果只看准确率,一个「全都不违约」的预测器能拿到接近九成的分数。这道题违约样本占比很小,模型天然面临严重的类别不均衡,所以评判标准不是预测准不准,而是能不能把违约企业和正常企业的分数拉开。评分模型输出的是一列连续分数而不是二分类标签,区分度足够高,后面才有做差异化额度和利率的空间。建模路线常用有三条:标准评分卡、Logistic回归、聚类。评分卡在银行用得最多,解释性强,评委看着有代入感;Logistic回归实现最简单,是评分卡的简化版;聚类可以处理没有信用记录的企业,但要解释清楚聚出来的每一类是什么意思,否则答辩容易被追问。C题数据量不大,几百家企业的量级,我建议先跑逻辑回归做基线,时间富余再升级成分箱评分卡。3.2 端到端建模代码:从训练到KS统计量from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split def build_score_model(X, y): # 有违约记录的企业才进训练集,无记录企业另行处理 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) # class_weight="balanced",让模型把违约样本当回事 model = LogisticRegression(class_weight="balanced", C=0.5, max_iter=1000) model.fit(X_train, y_train) # 取违约概率作为分数来源,而不是直接取0/1 y_prob = model.predict_proba(X_test)[:, 1] return model, y_prob, y_test def ks_stat(y_true, y_score): # 按分数从高到低排序,计算违约组与正常组累计分布的最大差距 df = pd.DataFrame({"y": y_true, "score": y_score}) df = df.sort_values("score", ascending=False).reset_index(drop=True) pos = df["y"].cumsum() / max(df["y"].sum(), 1) neg = (1 - df["y"]).cumsum() / max((1 - df["y"]).sum(), 1) return (pos - neg).abs().max()参数和逻辑说明:class_weight="balanced":违约样本少,普通逻辑回归会倒向多数类。这个参数按样本量倒数加权,把少数类的损失放大,训练出来的分数在违约组和正常组之间才有分隔。C=0.5:正则化强度。几百个样本的小数据集,C 太大容易过拟合,太小则把特征全压平,0.5 是个合理的起点,调参时按数量级试。为什么用 predict_proba 而不是 predict:下一章的信贷策略需要连续分数来切额度档位,二分类标签会把大量信息离散化。KS 统计量是这篇论文最该放进去的指标:它表示排序后两组累计分布的最大差值,0.3 以上说明模型有区分能力,0.4 以上就很不错。评委只要看到你算 KS,就知道你懂评分模型的核心逻辑。分数怎么转成 0-100 的信用分也顺手说一下。最直接的做法是把违约概率排个百分位,再镜像一下,让高概率对应低分数:final_score = (1 - y_prob.rank(pct=True)) * 100这样得到的信用分近似均匀分布在 0-100 之间,不受原始概率值偏移的影响。直接拿100 - y_prob*100也行,但如果全部概率挤在 0.1 附近,分数会堆在 90 以上毫无区分度,百分位排名能解决这个问题。3.3 切分方式与阈值策略:别让验证集偷看答案模型验证的切分方式我一直坚持「时间切分」而不是随机切分:用前80%的时间段做训练,后20%做验证。信贷业务本质是预测未来,随机切分会把未来的数据混进训练集,指标虚高,且高得没有实际意义。阈值更不能默认 0.5。违约概率分布整体偏低,0.5 作为阈值等于宣布几乎没有企业会违约。常见做法是取 KS 最大值对应的分数作为风险分界线,或者用百分位的 70 分位作为预警线。论文里明确写清楚「阈值根据 KS 曲线选取」,比丢一个默认阈值有说服力得多。另外,有一部分没有信贷记录的企业进不了监督训练。我的做法是用有监督模型给它们打分做排序,再叠加一条规则校验,论文里写成「混合评估框架」,逻辑上比硬分类站得住。提示:如果违约样本太少,连分层抽样都救不回来,就不要硬做有监督模型。改用规则打分加上离群点检测,写成混合信贷评估框架,评委反而更认可。4. 信贷策略:评分转额度利率的四步定价法与稳健性检验4.1 先定银行的三条底线,再谈利润评分模型给出的是排序,信贷策略给出的是真金白银的额度和利率。把分数映射成策略之前,先写死三条底线:不良率不能超过某上限,单户额度不能超过企业经营能力,利率不能低于资金成本。这三个约束是后面所有参数的上限来源。在C题场景里,资金成本按年化 4.5% 左右估算比较常见,那么优质客户利率应该落在 4.5% 附近,风险客户往上加风险溢价。额度则锚定近六个月的销项收入,不能给一个流水根本撑不住的金额。有了约束再看收益,评分高给低利率和高额度,评分低反过来,这就是风险定价的基本逻辑。4.2 额度和利率的映射函数代码这一步把信用分转成可执行的信贷参数。我用的映射策略是三档再加一个拒绝区,分段的好处是每一档的业务含义都能向评委说清。def credit_policy(final_score, sales_6m, ratio_cap=0.4, base_rate=0.045): # final_score: 0-100,越高信用越好 # sales_6m: 近6个月销项收入,用于限制单户额度 if final_score < 60: return 0, None # 拒绝放贷 # 额度:先按销项收入乘比例,再用评分做折价 limit = sales_6m * ratio_cap * (final_score / 100) if final_score >= 85: rate = base_rate # 优质客户拿基准利率 elif final_score >= 75: rate = base_rate + 0.015 # 良好客户加150个基点 else: rate = base_rate + 0.03 # 65分以上的客户加300个基点 return round(limit, 2), round(rate, 4)参数怎么选:额度的锚定基准是销项收入而不是利润。利润波动大且口径不统一,销项收入来自发票,可信度高。ratio_cap=0.4 的意思是额度上限等于近6个月销项收入的 40%。真实信贷里这个比例对应「债务偿还比率」,保守的机构会压到 0.3 以内。这个值的选取要配合后面的敏感性分析一起讲。利率等差递增是因为题目里没有违约损失率这类数据,用分段映射更利于解释。每一档利率都对应一个风险区间,评委看得出你是认真做了定价设计,而不是随便套了个公式。拒绝线定在 60 分,意味着评分后约 20% 的企业会被拒绝放贷,这个比例跟真实银行的小微审批通过率大致相符。4.3 稳健性检验:参数扰动与压力测试这一节是拉开论文差距的地方。模型定了不代表策略稳,评委最爱追问「你的参数换一下,结论会不会变」。我一般做两件事:参数扰动和压力情景测试。import numpy as np rng = np.random.default_rng(42) def robustness_check(final_score, sales_6m, n=500): results = [] for _ in range(n): cap = rng.uniform(0.3, 0.5) # 额度比例扰动 ±25% base = rng.uniform(0.035, 0.055) # 基准利率扰动 ±22% limits, rates = credit_policy(final_score, sales_6m, cap, base) valid_rates = [r for r in rates if r is not None] results.append([cap, base, limits.sum(), np.mean(valid_rates)]) return np.array(results)输出矩阵的四列分别是两个扰动参数和两个业务结果:总额度、平均利率。我通常看两个结论:总额度对参数扰动的波动幅度,以及平均利率的稳定区间。如果参数扰动 ±25% 以内,业务指标变化不超过 15%,就说策略是稳健的,这段写进论文就是一个独立的实证小节。压力测试的做法更粗暴:把所有企业的销项收入统一打七折再跑一遍整套策略,观察拒绝率上升多少、总额度缩水多少。评委看到这一页,基本不会再问你「参数是不是凑出来的」,因为你在主动演示边界条件。这部分的论文呈现建议用一张表,列方向是参数变动范围、总额度变化率、平均利率变化率。三行就够,写清楚基线值和扰动范围,页脚注明随机种子,保证可复现。很多队伍论文写了一整本,却拿不出一张稳健性表格,这是最可惜的丢分点。5. 常见问题排查:从分箱到泄漏,参赛中最容易翻车的五个坑5.1 时间跨度不一致:老企业靠数据量碾压新企业现象:评分模型跑完,成立时间短的企业分数几乎全部压在尾部,看起来像一群「坏客户」,一刀切放贷额度全被拒掉。原因:所有企业混在一张宽表里,老企业天然有更多月份的销项记录,聚合出的金额总额和活跃月数都更大,模型把「数据量大」误当成了「经营实力强」。解决:按我第2章说的,把活跃期不足6个月的企业单独分桶,用近3个月比率特征替代总量特征;所有企业在同一个预测截止日下对齐时间窗。5.2 特征泄漏:训练集漂亮得像开卷考试现象:训练集AUC接近0.98,验证集却一落千丈,现场换数据直接就崩。原因:特征里混进了「之后才知道」的信息。有人把全年的销项收入算完均值,再去预测下半年是否违约,等于拿答案做预测。解决:坚持时间切分,特征只允许使用预测截止日之前的数据。写代码时把特征工程封装成函数,参数里传入截止日,所有窗口计算都在截止日之前完成,从机制上杜绝泄漏。5.3 类别不均衡:0.5 阈值的致命默认值现象:模型输出的违约预测几乎全是0,查了类别分布才发现违约样本只占一小部分。原因:没做类别加权,模型学到的最优策略就是把所有样本预测成正常类;再用默认0.5阈值切分,违约企业全部被放走。解决:训练时加class_weight="balanced",评估时不看准确率,看KS和AUC。另外写论文时明确写一句「决策阈值由KS曲线选取」,而不是留在默认值,这句话能拦住一半的追问。5.4 额度上限缺失:给了流水根本撑不起的放款额现象:某企业信用分不错,模型给了一笔大额贷款,但一对照销项流水,额度相当于月收入的3倍,按这个金额根本还不出来。原因:直接用评分映射额度,忘了给额度设置偿付能力上限。解决:额度必须等于销项收入乘以倍数系数。我通常把系数范围控制在0.3到0.5,并且在论文里把额度公式写清楚,附上一句「额度上限受企业近6个月销项收入约束」,这是风控常识,评委看了不会挑毛病。5.5 论文没有稳健性检验:答辩被追问就卡壳现象:评委问「你的额度比例从0.4改成0.6,结论还成立吗」,答辩人当场愣住。原因:全文只贴了模型训练效果,没做任何参数扰动分析,策略的边界完全没交代。解决:补上第4章的扰动实验,哪怕只改额度比例和基准利率两个参数,生成一张参数-结果表,说服力都会完全不同。从那以后我做这类题都会提前跑一遍随机种子固定的扰动,数据放论文附录里都是加分项。6. 进阶技巧:从C题到通用风控的复用要点6.1 分箱与IV值:把逻辑回归升级成标准评分卡C题的数据量不大,但特征分箱这个技巧可以直接复用。分箱能处理非线性关系,还能把缺失值单独成箱,解释性比连续变量更好。IV值用于筛选特征:低于0.02的特征直接丢掉,0.02到0.1是弱特征,0.1到0.3算中等,超过0.3才是强特征。用这个标准去筛C题里构造的十几个特征,剩下来的通常只有销项稳定度和申报偏离度这两个,筛选过程本身就能写进论文的特征工程章节。def woe_iv(x, y, bins=10): df = pd.DataFrame({"x": x, "y": y}) df["bin"] = pd.qcut(df["x"], bins, duplicates="drop") agg = df.groupby("bin", observed=True).agg( good=("y", lambda s: (s == 0).sum()), bad=("y", lambda s: (s == 1).sum()) ) agg["woe"] = np.log((agg["bad"] / agg["bad"].sum()) / (agg["good"] / agg["good"].sum() + 1e-6)) agg["iv"] = ((agg["bad"] / agg["bad"].sum()) - (agg["good"] / agg["good"].sum())) * agg["woe"] return agg["iv"].sum()6.2 与真实信贷数据的差异C题是静态快照,真实风控是持续更新的流式数据,两者差距明显,心里要有数。维度C题数据真实生产环境样本量几百家企业几万到几十万是否含法人征信无通常有数据形态静态快照持续更新特征维度若干发票字段征信、工商、司法等多源模型要求一版论文即可可监控、可解释C题练手最大的价值是让你完整走通「数据清洗—评分—定价—检验」这条链,真实环境里这条链会更长,但核心逻辑不变。6.3 固定工作流:拿到信贷数据后的五步检查整理一下,我拿到任何一份信贷数据都会强制走一遍:第一步,数据字典逐字段核对,搞清楚每个字段的单位和口径;第二步,画出时间轴,标出每个样本的预测截止日;第三步,先跑规则基线,比如销项下降超过30%直接拒绝,再用模型;第四步,所有特征封装成独立函数,参数写死,保证可复现;第五步,论文里的每个结论都要能找到对应的代码输出。这套流程看着笨,但能避免第九版数据覆盖第八版时把结论带偏。从那以后我每次做信贷决策题都把这条链完整走一遍,再没出现过回测良好、现场翻车的情况。这套方法论希望对你有帮助。本文还有配套的精品资源,点击获取