
简介这份PDF文档面向金融风控从业者、算法工程师及深度学习入门者系统讲解如何用PyTorch构建并优化企业信用评分卡模型帮助读者打通从数据准备到模型部署的完整链路。文档共31页以单个PDF文件交付压缩包约2.12MB支持目录章节跳转与阅读器左侧大纲快速定位查阅体验流畅。内容覆盖金融风控与评分卡概述、PyTorch环境搭建与数据预处理、逻辑回归/决策树/神经网络三类模型构建、准确率与AUC等评估指标解读、特征工程与超参数调优等优化策略以及本地、云平台与容器化部署实践并附完整案例分析。已有107人学习适合希望将深度学习落地于信用风险评估场景的读者参考可据此掌握评分卡建模全流程与调优排错思路。1. 从一份 PDF 标题说起PyTorch 信用评分卡到底在解决什么问题银行零售信贷部门每天面对成千上万条进件审批员不可能逐条看征信报告。真正决定「批不批、批多少、利率上浮多少」的是一张把客户特征映射成分数的表——信用评分卡。传统做法用逻辑回归加 WOE 分箱可解释性强但面对高维稀疏行为数据、时序还款轨迹、多头借贷图谱时线性模型的天花板很明显。把 PyTorch 引进来不是要用深度网络替换掉评分卡而是用神经网络学出更强的特征表达再把输出校准成标准分数兼顾区分度和可解释性。这篇笔记面向三类人做风控建模想从 sklearn 迁到 PyTorch 的工程师、接了「企业信用评分卡」需求但不知道从哪下手的算法同学、以及想把现有评分卡做一次效果迭代的从业者。核心链路是数据准备 → 特征工程 → PyTorch 模型构建 → 训练调优 → 分数校准 → 部署监控。下面按这条链路拆开讲每一步都给可复现的代码和参数说明坑也一并标出来。2. 数据准备与特征工程评分卡的原料怎么处理2.1 企业信用数据的三个来源与标签定义企业信用评分和 C 端个人评分最大的区别在于样本量小、特征维度高、标签滞后严重。常见数据来源分三块工商与司法公开信息注册资本、成立年限、涉诉次数、经营与财务数据开票金额、纳税等级、流水波动、信贷履约数据历史逾期、担保代偿、多头借贷。标签一般定义为「观察点后 12 个月内是否发生 M1 逾期」用 0/1 表示。样本量小意味着不能用太深的网络否则过拟合是必然的。我一般把特征控制在 80200 维样本少于 5000 条时优先考虑带正则的浅层 MLP 或 EmbeddingLR 的混合结构。标签滞后期要留够至少 12 个月表现期否则负样本还没暴露出来就训练模型学到的全是噪声。import pandas as pd import numpy as np from sklearn.model_selection import train_test_split # 读取原始宽表一行一个企业一列一个特征 df pd.read_csv(enterprise_credit.csv) # 标签观察点后12个月内是否M1逾期 # 1坏样本(逾期)0好样本(正常) label_col is_default_12m # 剔除表现期不足的样本避免标签未成熟 df df[df[performance_months] 12].copy() # 缺失率超过60%的特征直接丢弃 missing_rate df.isnull().mean() drop_cols missing_rate[missing_rate 0.6].index.tolist() df df.drop(columnsdrop_cols) print(f样本数: {len(df)}, 特征数: {df.shape[1]-2}) print(f坏样本率: {df[label_col].mean():.4f})这段代码做了三件事过滤表现期不足的样本、丢弃高缺失特征、打印坏样本率。坏样本率是关键参数企业信用场景通常在 2%8% 之间低于 1% 要考虑过采样或调整损失函数权重高于 15% 要回头检查标签定义是不是太宽松。2.2 分箱、WOE 与缺失值处理评分卡的传统优势在于可解释WOE 编码是核心。即便用 PyTorch我也建议保留 WOE 作为输入特征因为神经网络对单调性没有天然约束WOE 能把非线性关系预先压平降低网络学习难度。连续变量先做卡方分箱或等频分箱再算 WOE。def calc_woe(df, feature, target, bins10): 等频分箱后计算WOE和IV # 等频分箱处理重复边界 df[bin] pd.qcut(df[feature], qbins, duplicatesdrop) grouped df.groupby(bin)[target].agg([sum, count]) grouped.columns [bad, total] grouped[good] grouped[total] - grouped[bad] # 防止除零加平滑项 grouped[bad] grouped[bad].clip(lower0.5) grouped[good] grouped[good].clip(lower0.5) total_bad grouped[bad].sum() total_good grouped[good].sum() grouped[woe] np.log((grouped[bad]/total_bad) / (grouped[good]/total_good)) grouped[iv] ((grouped[bad]/total_bad) - (grouped[good]/total_good)) * grouped[woe] return grouped[woe].to_dict(), grouped[iv].sum() # 对每个连续特征计算WOE映射 woe_maps {} iv_values {} for col in continuous_cols: woe_map, iv calc_woe(df, col, label_col) woe_maps[col] woe_map iv_values[col] iv # IV0.02的特征区分度太弱剔除 weak_cols [c for c, v in iv_values.items() if v 0.02] print(f剔除弱特征: {weak_cols})WOE 计算里加了 0.5 的平滑项这是血泪经验——某个分箱里坏样本为 0 时log 会直接炸掉。IV 值判断标准小于 0.02 基本没用0.020.1 弱0.10.3 中等大于 0.3 要警惕是不是标签泄漏。缺失值不要简单填 0单独设一个「缺失」分箱因为企业数据里「没查到涉诉」和「涉诉为 0」含义完全不同。2.3 把 DataFrame 转成 PyTorch 能吃的 Dataset特征工程做完要转成 Tensor。企业信用数据里类别特征多行业代码、地区、纳税等级用 Embedding 处理比 one-hot 更省内存也能学到类别间的相似性。import torch from torch.utils.data import Dataset, DataLoader class CreditDataset(Dataset): def __init__(self, num_features, cat_features, labels): # num_features: 连续WOE特征float32 self.num_features torch.tensor(num_features, dtypetorch.float32) # cat_features: 类别特征索引int64 self.cat_features torch.tensor(cat_features, dtypetorch.long) self.labels torch.tensor(labels, dtypetorch.float32) def __len__(self): return len(self.labels) def __getitem__(self, idx): return self.num_features[idx], self.cat_features[idx], self.labels[idx] # 划分训练/验证/测试按时间切分比随机切分更贴近真实场景 train_df, test_df train_test_split(df, test_size0.2, random_state42, stratifydf[label_col]) train_df, val_df train_test_split(train_df, test_size0.2, random_state42, stratifytrain_df[label_col]) # 构建DataLoaderbatch_size在样本少时设小一点 train_loader DataLoader( CreditDataset(train_num, train_cat, train_label), batch_size256, shuffleTrue, drop_lastTrue )drop_lastTrue在样本量小的时候要注意最后一个不完整 batch 如果只有几条样本BatchNorm 的统计量会抖得厉害。样本少于 1 万条时batch_size 建议 128256太大梯度更新次数不够太小训练不稳定。按时间切分而不是随机切分是为了模拟「用历史数据预测未来」的真实场景随机切分会让模型偷看到未来信息线下 AUC 虚高。3. PyTorch 评分卡模型构建网络结构怎么设计3.1 Embedding MLP 的混合结构纯 MLP 处理类别特征需要 one-hot维度爆炸。Embedding 把每个类别映射成低维稠密向量既降维又能表达类别间关系。整体结构是连续特征走 BatchNorm Linear类别特征走 Embedding两路拼接后过几层 MLP最后输出一个 logit。import torch.nn as nn class CreditScoreNet(nn.Module): def __init__(self, num_cont, cat_cardinalities, emb_dim8, hidden(128, 64), dropout0.3): super().__init__() # 连续特征做标准化 self.bn_cont nn.BatchNorm1d(num_cont) # 每个类别特征一个Embedding表 self.embeddings nn.ModuleList([ nn.Embedding(card, min(emb_dim, (card 1) // 2)) for card in cat_cardinalities ]) emb_total sum(min(emb_dim, (c 1) // 2) for c in cat_cardinalities) input_dim num_cont emb_total layers [] prev input_dim for h in hidden: layers [ nn.Linear(prev, h), nn.BatchNorm1d(h), nn.ReLU(), nn.Dropout(dropout) ] prev h layers.append(nn.Linear(prev, 1)) self.mlp nn.Sequential(*layers) def forward(self, x_cont, x_cat): x_cont self.bn_cont(x_cont) emb_out [emb(x_cat[:, i]) for i, emb in enumerate(self.embeddings)] x torch.cat([x_cont] emb_out, dim1) return self.mlp(x).squeeze(-1)Embedding 维度取min(8, (cardinality1)//2)是经验公式类别数少时维度跟着降避免参数浪费。hidden 层用 (128, 64) 对大多数企业信用场景够用样本过万可以加到 (256, 128, 64)。dropout 设 0.3 是起点过拟合严重就往上加但别超过 0.5否则欠拟合。3.2 损失函数选型Focal Loss 还是加权 BCE企业信用数据坏样本率低标准 BCE 会让模型偏向预测好样本。两种处理方式给正样本加权或者用 Focal Loss 聚焦难分样本。我一般先用加权 BCE简单可控如果坏样本率低于 2% 且 AUC 上不去再换 Focal Loss。class WeightedBCELoss(nn.Module): def __init__(self, pos_weight): super().__init__() self.pos_weight pos_weight def forward(self, logits, targets): # pos_weight 负样本数 / 正样本数 return nn.functional.binary_cross_entropy_with_logits( logits, targets, pos_weightself.pos_weight ) # 计算正样本权重 neg (train_label 0).sum() pos (train_label 1).sum() pos_weight torch.tensor([neg / pos], dtypetorch.float32) criterion WeightedBCELoss(pos_weight)pos_weight直接取负正样本比比如坏样本率 5%权重就是 19。这个值不要盲目调大权重过高会让模型把太多好样本误判成坏样本审批通过率暴跌。实际业务里要结合通过率、坏账率的平衡点来定不是 AUC 越高越好。3.3 训练循环与早停策略训练循环本身不复杂关键是验证集监控和早停。评分卡模型最容易过拟合训练集 AUC 冲到 0.9 而验证集只有 0.7 是常态。from sklearn.metrics import roc_auc_score def train_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0 for x_cont, x_cat, y in loader: x_cont, x_cat, y x_cont.to(device), x_cat.to(device), y.to(device) optimizer.zero_grad() logits model(x_cont, x_cat) loss criterion(logits, y) loss.backward() # 梯度裁剪防止Embedding梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() return total_loss / len(loader) torch.no_grad() def evaluate(model, loader, device): model.eval() preds, labels [], [] for x_cont, x_cat, y in loader: x_cont, x_cat x_cont.to(device), x_cat.to(device) logits model(x_cont, x_cat) preds.extend(torch.sigmoid(logits).cpu().numpy()) labels.extend(y.numpy()) return roc_auc_score(labels, preds) # 早停验证AUC连续5轮不提升就停 best_auc, patience, wait 0, 5, 0 for epoch in range(100): train_loss train_epoch(model, train_loader, optimizer, criterion, device) val_auc evaluate(model, val_loader, device) if val_auc best_auc: best_auc, wait val_auc, 0 torch.save(model.state_dict(), best_scorecard.pt) else: wait 1 if wait patience: print(f早停于epoch {epoch}, 最佳AUC {best_auc:.4f}) break梯度裁剪max_norm5.0是 Embedding 层的保险丝Embedding 梯度偶尔会飙到很大不裁容易 NaN。早停 patience 设 5 是折中样本少可以设 3样本多可以设 8。保存最佳模型而不是最后一轮模型这是基本操作但很多人图省事直接存最后一个线下评估就吃亏。4. 模型优化与分数校准从 logit 到标准评分4.1 超参数调优的优先级调参不要一把抓按影响从大到小排学习率 网络层数/宽度 dropout batch_size Embedding 维度。学习率用 1e-3 起步配合余弦退火或 ReduceLROnPlateau。层数和宽度先固定 (128, 64)AUC 不够再加。from torch.optim.lr_scheduler import ReduceLROnPlateau optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler ReduceLROnPlateau(optimizer, modemax, factor0.5, patience3) # 在验证后调用 scheduler.step(val_auc)AdamW 比 Adam 多了正确的权重衰减对评分卡这种小样本场景更稳。weight_decay 设 1e-4 是起点过拟合严重加到 1e-3。ReduceLROnPlateau 的 factor0.5 表示验证指标不升时学习率减半patience3 表示等 3 轮。4.2 把概率输出校准成 300850 分模型输出的是概率业务要的是分数。标准做法是用score offset factor * ln(odds)其中 odds (1-p)/p。设定基准分和 PDOPoints to Double the Odds。def prob_to_score(prob, base_score600, base_odds50, pdo40): 把违约概率转成300-850的标准分 factor pdo / np.log(2) offset base_score - factor * np.log(base_odds) # 防止prob为0或1导致log爆炸 prob np.clip(prob, 1e-6, 1 - 1e-6) odds (1 - prob) / prob score offset factor * np.log(odds) return np.clip(score, 300, 850) # 对测试集打分 test_probs predict_proba(model, test_loader, device) test_scores prob_to_score(test_probs) print(f分数分布: min{test_scores.min():.0f}, max{test_scores.max():.0f}, mean{test_scores.mean():.0f})base_score600、base_odds50、pdo40 是一套常用参数意思是 odds 为 50:1 时对应 600 分odds 翻倍加 40 分。这套参数要和业务方对齐不是技术侧拍脑袋定的。分数裁剪到 300850 是行业惯例超出范围的分数没有业务含义。4.3 区分度与稳定性监控指标上线前必须看三个指标AUC区分度、KS最大区分度、PSI稳定性。AUC 看整体排序能力KS 看最佳切分点的区分能力PSI 看训练集和测试集分布差异。from scipy.stats import ks_2samp def calc_ks(labels, preds): KS 好样本和坏样本累积分布的最大差值 good preds[np.array(labels) 0] bad preds[np.array(labels) 1] ks_stat, _ ks_2samp(good, bad) return ks_stat def calc_psi(expected, actual, bins10): PSI 0.1 稳定0.1-0.25 需关注0.25 不稳定 breakpoints np.percentile(expected, np.linspace(0, 100, bins 1)) breakpoints[0], breakpoints[-1] -np.inf, np.inf exp_perc np.histogram(expected, breakpoints)[0] / len(expected) act_perc np.histogram(actual, breakpoints)[0] / len(actual) exp_perc np.clip(exp_perc, 1e-6, None) act_perc np.clip(act_perc, 1e-6, None) return np.sum((act_perc - exp_perc) * np.log(act_perc / exp_perc)) print(fAUC: {roc_auc_score(test_label, test_probs):.4f}) print(fKS: {calc_ks(test_label, test_probs):.4f}) print(fPSI: {calc_psi(train_probs, test_probs):.4f})AUC 低于 0.65 基本不能用0.7 以上算合格0.75 以上算好。KS 一般要求 0.3 以上。PSI 超过 0.25 说明模型不稳定要排查是特征分布变了还是样本选取有问题。这三个指标要一起看AUC 高但 PSI 高说明模型过拟合了训练集的分布上线会翻车。5. 避坑与排查评分卡落地最常见的五个翻车点5.1 标签泄漏AUC 0.95 的幻觉现象训练完 AUC 0.95兴奋地上线实际坏账率没降反升。原因特征里混入了标签发生后的信息。比如「最近一次逾期天数」这种特征在预测「未来是否逾期」时它本身就是结果的一部分。企业数据里「当前涉诉金额」「近3个月代偿次数」都可能是泄漏源。解决逐个特征问一句「这个特征在观察点当天能拿到吗」。拿不到的、或者含义上包含未来信息的一律剔除。用时间切分验证如果随机切分 AUC 0.95 而时间切分只有 0.7基本就是泄漏。5.2 Embedding 维度设太大导致过拟合现象训练 loss 一直降验证 AUC 从第 3 轮开始掉。原因类别特征基数不大比如行业代码只有 20 类却给了 32 维 Embedding参数量远超样本能支撑的量。解决Embedding 维度按min(8, (cardinality1)//2)控制类别数少于 10 时维度不超过 4。同时给 Embedding 加 weight_decay或者对低频类别做合并。5.3 BatchNorm 在小 batch 下统计量失真现象训练时 loss 震荡验证指标忽高忽低。原因batch_size 太小比如 32BatchNorm 每批统计的均值和方差波动大尤其最后一个不完整 batch 只有几条样本。解决batch_size 至少 128drop_lastTrue丢掉不完整 batch。样本实在少就换 LayerNorm 或 GroupNorm不依赖 batch 统计量。5.4 分数校准参数拍脑袋定现象模型 AUC 不错但业务方说「分数看不懂600 分到底代表什么风险」。原因base_score、base_odds、pdo 没有和业务对齐分数和实际违约率对不上。解决拿验证集的实际违约率反推参数。比如业务要求 600 分对应 5% 违约率就用这个锚点算 offset 和 factor。上线后定期用实际表现数据回测分数段的违约率偏差超过 20% 就要重新校准。5.5 上线后不做 PSI 监控现象模型上线半年效果还行突然某个月坏账率飙升。原因宏观经济变化或客群迁移导致特征分布漂移模型还在用旧分布打分。解决每月算一次 PSI超过 0.25 触发告警。同时监控分数分布如果高分人群占比突然变大说明模型变宽松了。PSI 高的特征要单独排查必要时重新训练。6. 进阶技巧用 SHAP 把黑盒模型讲成业务能听懂的话评分卡落地最大的阻力不是技术是业务方不信任。逻辑回归能给出每个特征的系数神经网络给不出。SHAP 是目前最实用的解释工具能把每个样本的预测拆解成特征贡献。import shap # 包装模型让SHAP能调用 class ModelWrapper(nn.Module): def __init__(self, model): super().__init__() self.model model def forward(self, x_cont, x_cat): return torch.sigmoid(self.model(x_cont, x_cat)) wrapped ModelWrapper(model).eval() # 取背景样本数量不宜多100条够用 background (train_num[:100], train_cat[:100]) explainer shap.DeepExplainer(wrapped, background) # 解释测试集前200条 shap_values explainer.shap_values((test_num[:200], test_cat[:200]))SHAP 计算量大全量跑不现实取几百条样本做代表性分析即可。输出的 shap_values 可以画成 force plot 或 summary plot业务方能看到「这个企业因为涉诉次数多扣了 30 分因为纳税等级高加了 50 分」。注意 SHAP 解释的是模型行为不是因果别跟业务说「因为 A 所以违约」要说「模型认为 A 让违约概率上升了」。另一个进阶方向是单调性约束。评分卡业务上要求「逾期次数越多分数越低」但神经网络不保证这个性质。可以在损失函数里加单调性惩罚项或者用单调网络结构。实现复杂度高样本少时不建议上容易欠拟合。我自己的习惯是每次模型迭代先跑一遍 SHAP挑出贡献最大的 10 个特征人工检查方向对不对。如果发现「注册资本越高违约概率越高」这种反常识的贡献方向大概率是特征工程或数据有问题别急着上线。模型可以黑盒但特征方向必须符合业务常识这是评分卡和纯预测任务最大的区别。希望帮到你。本文还有配套的精品资源点击获取