ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyTorch重写金融风控评分卡:从WOE分箱到TorchScript上线实战

PyTorch重写金融风控评分卡:从WOE分箱到TorchScript上线实战 简介这份PDF文档面向金融风控从业者、数据科学学习者与算法工程师系统讲解如何用PyTorch构建并优化企业信用评分卡模型帮助读者打通从数据准备到模型部署的完整链路。文档共31页以PDF单文件形式打包大小约2.12MB支持目录章节跳转、阅读器左侧大纲显示与章节快速定位查阅体验流畅。内容覆盖金融风控与评分卡概述、PyTorch环境搭建与数据预处理、逻辑回归/决策树/神经网络三类模型构建、准确率与AUC等评估指标解读、特征工程与超参数调优等优化策略以及本地、云平台与容器化部署实践并附完整案例分析。已有107人学习适合希望掌握评分卡建模全流程、提升风控实战能力的中高级读者参考。1. 金融风控信用评分卡为什么用 PyTorch 重写逻辑回归不是杀鸡用牛刀很多做金融风控的团队评分卡模型到今天还在用 sklearn 的LogisticRegression加WOE分箱那一套。这套东西稳定、可解释、监管认但一旦你要做多任务学习、要接嵌入层处理高基数类别特征、要在同一个网络里同时输出 PD 和 LGDsklearn 就顶不住了。信用评分卡的本质是一个二分类概率模型输出的是未来某段时间内违约的概率再通过PDO、base_score、base_odds三个参数映射成 300 到 850 的整数分。用 PyTorch 实现评分卡不是把简单问题复杂化而是把评分卡从「一次性离线训练」变成「可微、可增量、可多任务」的工程组件。这篇文章面向的是已经懂WOE、IV、KS、AUC这些指标但想把评分卡搬到 PyTorch 上做工业级落地的风控工程师。我会从张量化的分箱逻辑讲起一路写到自定义损失、单调性约束、分数映射和线上推理的torchscript导出。中间会给出可直接抄的代码块也会把我在真实项目里翻过车的参数和坑讲清楚。如果你正在纠结「pytorch安装教程超详细」那类入门问题这篇文章可能偏深但如果你已经能跑通pytorch实战里的分类 demo想把它变成能过模型验证的评分卡那接下来的内容就是给你准备的。2. 把评分卡拆成 PyTorch 能吃的张量分箱、WOE 与嵌入层2.1 评分卡的数学形式与 PyTorch 的对应关系传统评分卡对每个特征做分箱每个箱给一个WOE值然后逻辑回归的线性组合是score sum(woe_i * beta_i) intercept。这个形式在 PyTorch 里就是一个没有隐藏层的nn.Linear输入是WOE编码后的稠密向量。但真实业务里有很多高基数类别特征比如职业代码、地区代码如果强行分箱会丢失信息这时候常见做法是加一个nn.Embedding层把类别映射成低维稠密向量再和WOE特征拼接后进线性层。这样做的好处是模型仍然是广义线性模型的变体可解释性可以通过嵌入向量的权重和线性层系数回溯同时又能处理传统评分卡处理不好的高基数特征。我一般会把连续特征做监督分箱后转WOE类别特征走嵌入最后统一到一个nn.Linear(1)输出 logit。下面这段代码定义了一个最小可用的评分卡网络结构。import torch import torch.nn as nn class ScorecardNet(nn.Module): def __init__(self, n_woe_features, cat_cardinalities, emb_dim4): super().__init__() # WOE 特征直接进线性层对应传统评分卡的线性部分 self.woe_linear nn.Linear(n_woe_features, 1, biasFalse) # 高基数类别特征用嵌入每个类别一个 emb_dim 维向量 self.embeddings nn.ModuleList([ nn.Embedding(card, emb_dim) for card in cat_cardinalities ]) # 嵌入拼接后的投影层把嵌入空间映射到 logit 尺度 self.emb_proj nn.Linear(len(cat_cardinalities) * emb_dim, 1, biasFalse) self.bias nn.Parameter(torch.zeros(1)) def forward(self, x_woe, x_cat): # x_woe: (batch, n_woe_features) 已经是 WOE 值 # x_cat: (batch, n_cat_features) 类别索引 woe_logit self.woe_linear(x_woe) emb_list [emb(x_cat[:, i]) for i, emb in enumerate(self.embeddings)] emb_cat torch.cat(emb_list, dim1) emb_logit self.emb_proj(emb_cat) return woe_logit emb_logit self.bias这段代码里woe_linear的权重就是传统评分卡里每个 WOE 特征的系数bias对应截距项。嵌入部分是可选的如果监管要求完全可解释可以把emb_dim设为 1 并加约束退化成类似 WOE 的编码。参数上emb_dim一般取 2 到 8太高会过拟合太低表达不足我通常从 4 开始调。n_woe_features是你经过分箱后的 WOE 特征数量通常控制在 15 到 30 个之间太多会导致模型不稳定。2.2 WOE 分箱的张量化实现与 IV 筛选WOE 的计算本身不依赖 PyTorch但为了和后续训练打通我习惯把分箱边界和 WOE 映射表固化成张量这样推理时可以直接用torch.bucketize做分箱避免 Python 循环拖慢线上性能。下面这个函数把训练集上算好的分箱边界和 WOE 值转成可复用的张量字典。import numpy as np import torch def build_woe_tensors(bin_edges_dict, woe_map_dict): bin_edges_dict: {feature_name: [edge1, edge2, ...]} woe_map_dict: {feature_name: [woe_bin0, woe_bin1, ...]} 返回可直接用于 torch.bucketize 的张量 woe_tensors {} for feat, edges in bin_edges_dict.items(): # 边界张量注意 bucketize 要求边界升序 edges_t torch.tensor(edges, dtypetorch.float32) # WOE 值张量长度比边界多 1 woe_t torch.tensor(woe_map_dict[feat], dtypetorch.float32) woe_tensors[feat] (edges_t, woe_t) return woe_tensors def transform_woe(x_num, woe_tensors, feature_order): x_num: (batch, n_features) 原始数值特征 返回 (batch, n_features) 的 WOE 编码 cols [] for i, feat in enumerate(feature_order): edges, woe_vals woe_tensors[feat] # bucketize 返回每个值落在哪个箱的索引 idx torch.bucketize(x_num[:, i], edges) cols.append(woe_vals[idx]) return torch.stack(cols, dim1)torch.bucketize的行为是返回第一个大于等于输入值的边界索引所以边界必须升序排列且 WOE 值张量长度要比边界多一个。这里有个容易翻车的点如果训练时用的分箱边界是[-inf, 0, 100, inf]转成张量时不能把inf放进去要只保留有限边界[0, 100]然后 WOE 值给三个箱。IV 筛选一般在分箱后做保留 IV 大于 0.02 的特征低于这个值的特征对模型的贡献很小强行加入只会增加噪声。2.3 类别特征嵌入的维度选择与初始化嵌入层的维度选择没有绝对公式常见经验是min(50, (cardinality 1) // 2)但在评分卡场景下我建议更保守因为评分卡样本量通常不大嵌入太大会直接过拟合。我一般用emb_dim min(8, int(cardinality ** 0.25))并且对嵌入层加weight_decay。初始化用nn.init.normal_(std0.01)不要用默认的均匀分布默认初始化在类别数多的时候会导致初始 logit 方差过大训练初期 loss 震荡。另外类别特征一定要做频率过滤出现次数少于 50 次的类别合并成OTHER桶否则嵌入层对这些稀有类别学到的向量基本是噪声。这个过滤要在建词表的时候做不要等到训练时再处理。下面是一个建词表的示例。from collections import Counter def build_category_vocab(series, min_count50): counter Counter(series) vocab {cat: idx 1 for idx, (cat, cnt) in enumerate(counter.items()) if cnt min_count} # 0 保留给 OTHER 桶 return vocab def encode_category(series, vocab): return series.map(lambda x: vocab.get(x, 0)).valuesmin_count设 50 是一个经验值如果样本量在十万级别可以降到 20如果只有几万条样本建议提到 100。OTHER桶的嵌入向量也会参与训练它代表的是所有稀有类别的平均效应通常权重会接近 0这是正常的。3. 训练评分卡自定义损失、单调性约束与 KS 监控3.1 带单调性惩罚的损失函数设计金融风控对评分卡有一个硬性要求某些特征的 WOE 或分数贡献必须单调。比如逾期次数越多分数应该越低。传统评分卡通过分箱时的单调性检查来保证但在 PyTorch 里如果嵌入层自由学习可能破坏单调性。常见做法是在损失里加一个单调性惩罚项对指定特征的嵌入向量或线性权重做约束。具体来说如果某个连续特征经过分箱后WOE 值应该随特征值单调递减那么在线性层里对应这个特征的系数符号应该固定。更通用的做法是对嵌入向量按类别顺序计算差分惩罚负向差分。下面这个损失函数在标准BCEWithLogitsLoss基础上加了单调性正则。import torch.nn.functional as F def scorecard_loss(logits, labels, monotonic_pairs, lambda_mono0.1): monotonic_pairs: list of (emb_layer, direction) direction1 表示嵌入向量随类别索引递增-1 表示递减 bce F.binary_cross_entropy_with_logits(logits, labels, reductionmean) mono_penalty 0.0 for emb, direction in monotonic_pairs: # emb.weight: (cardinality, emb_dim) diffs emb.weight[1:] - emb.weight[:-1] # 只惩罚与期望方向相反的差分 violation F.relu(-direction * diffs) mono_penalty mono_penalty violation.mean() return bce lambda_mono * mono_penaltylambda_mono控制单调性约束的强度设太大会导致模型欠拟合设太小约束无效。我一般从 0.05 开始观察验证集 KS 和单调性违反比例逐步调到 0.2 左右。monotonic_pairs里传入的是需要约束的嵌入层和方向方向根据业务逻辑确定比如学历等级递增对应违约风险递减方向就是 -1。注意这个惩罚只对嵌入层有效如果你用的是纯 WOE 线性层单调性应该在分箱阶段就保证不需要额外惩罚。3.2 训练循环与 KS 的计算评分卡训练不能只看 loss 和 AUCKS 才是风控最关心的指标。KS 等于max(TPR - FPR)在 PyTorch 里可以在每个 epoch 结束后用验证集算。下面是一个完整的训练循环包含早停和 KS 监控。def train_scorecard(model, train_loader, val_loader, epochs50, lr1e-3, patience5): optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decay1e-4) best_ks 0.0 wait 0 for epoch in range(epochs): model.train() for x_woe, x_cat, y in train_loader: optimizer.zero_grad() logits model(x_woe, x_cat).squeeze(-1) loss scorecard_loss(logits, y, monotonic_pairs[]) loss.backward() optimizer.step() # 验证集算 KS model.eval() all_scores, all_labels [], [] with torch.no_grad(): for x_woe, x_cat, y in val_loader: logits model(x_woe, x_cat).squeeze(-1) all_scores.append(torch.sigmoid(logits)) all_labels.append(y) scores torch.cat(all_scores).numpy() labels torch.cat(all_labels).numpy() ks compute_ks(scores, labels) if ks best_ks: best_ks ks wait 0 torch.save(model.state_dict(), best_scorecard.pt) else: wait 1 if wait patience: break return best_ks def compute_ks(scores, labels): from sklearn.metrics import roc_curve fpr, tpr, _ roc_curve(labels, scores) return max(tpr - fpr)lr设 1e-3 是 Adam 的常规起点如果 loss 震荡明显可以降到 5e-4。weight_decay设 1e-4 对嵌入层有正则作用如果过拟合严重可以提到 1e-3。patience设 5 表示连续 5 个 epoch KS 不提升就停这个值不要设太小评分卡训练本身波动大设 3 容易早停错过最优。KS 在验证集上一般要求大于 0.3低于 0.25 的模型基本不可用需要回去检查分箱和特征。3.3 分数映射从 logit 到 300-850 分模型输出的是 logit要转成业务用的分数需要PDO、base_score、base_odds三个参数。公式是score base_score - PDO / ln(2) * ln(odds)其中odds p / (1-p)p是违约概率。在 PyTorch 里可以把这个映射写成一个模块方便导出。class ScoreMapper(nn.Module): def __init__(self, base_score600, pdo50, base_odds1/20): super().__init__() self.base_score base_score self.pdo pdo self.base_odds base_odds self.factor pdo / torch.log(torch.tensor(2.0)) self.offset base_score - self.factor * torch.log(torch.tensor(base_odds)) def forward(self, logit): # logit 是违约的 log-odds prob torch.sigmoid(logit) odds prob / (1 - prob 1e-8) score self.offset - self.factor * torch.log(odds 1e-8) return scorebase_score和base_odds决定分数刻度常见设定是base_score600、base_odds1/20、pdo50意思是 odds 为 1/20 时分数 600odds 翻倍分数降 50。pdo越大分数对风险变化越不敏感一般设 20 到 50。这个映射模块可以接在模型后面一起导出线上直接输出分数避免在服务端再算一遍。4. 避坑与排查评分卡上 PyTorch 后最容易翻车的五件事4.1 现象训练 loss 正常下降但 KS 极低原因通常是 WOE 编码时用了全量数据算分箱边界导致验证集信息泄漏。分箱和 WOE 计算必须只在训练集上做然后应用到验证集和测试集。另一个可能是嵌入层维度太大模型把高基数类别特征记住了验证集上这些类别的嵌入向量没有泛化能力。解决方法是把emb_dim降到 2 或 4并对嵌入层加更强的weight_decay同时检查分箱流程是否严格隔离。4.2 现象单调性惩罚加了之后模型完全不收敛原因一般是lambda_mono设得太大或者monotonic_pairs里传入的嵌入层方向搞反了。先确认业务逻辑上的单调方向比如「历史逾期次数」应该是递增对应风险递增嵌入向量方向设为 1。然后把lambda_mono从 0.01 开始试观察 loss 是否正常下降。如果仍然不收敛检查嵌入层的类别索引是否按业务顺序排列乱序的索引会让差分惩罚失去意义。4.3 现象线上推理分数和离线评估分数对不上最常见的原因是线上用的分箱边界和训练时不一致比如训练时边界是[0, 100]线上写成了[0, 100, 200]导致bucketize索引偏移。另一个原因是ScoreMapper里的base_odds在训练和线上用了不同的值。解决方法是把分箱边界、WOE 值、ScoreMapper参数全部固化到一个配置文件里训练和推理共用同一份不要在两处分别维护。4.4 现象嵌入层某些类别的向量全是 NaN原因是某个类别在训练集里只出现了一两次梯度更新时方差过大导致数值溢出。虽然建词表时做了min_count过滤但如果过滤后仍然有类别在某个 batch 里只出现一次且学习率偏高就可能出现 NaN。解决方法是在嵌入层后加LayerNorm或者把学习率降到 1e-4并对稀有类别做梯度裁剪。我一般会在训练循环里加torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)这个习惯能省掉很多排查时间。4.5 现象导出的 TorchScript 模型在线上输出和 Python 模型不一致原因通常是torch.bucketize在 TorchScript 里的行为和 Python 有细微差异尤其是边界值恰好等于输入值时。另一个可能是ScoreMapper里的torch.log在 TorchScript 里对接近 0 的输入处理不同。解决方法是在导出前用torch.jit.trace跑一批边界样本对比 Python 和 TorchScript 的输出差异大于 1e-4 就要检查。我一般会在ScoreMapper里把1e-8改成1e-6减少数值不稳定。5. 进阶技巧用 TorchScript 导出可上线的评分卡并做分数校准5.1 导出 TorchScript 并验证一致性训练完的模型要上线最稳妥的方式是导出成 TorchScript摆脱 Python 依赖。导出时要把 WOE 变换和分数映射一起 trace 进去这样线上只需要传原始特征。下面是一个完整的导出和验证流程。def export_scorecard(model, woe_tensors, feature_order, cat_vocab, save_pathscorecard_ts.pt): class FullPipeline(nn.Module): def __init__(self, model, woe_tensors, feature_order): super().__init__() self.model model self.woe_tensors woe_tensors self.feature_order feature_order self.mapper ScoreMapper() def forward(self, x_num, x_cat): x_woe transform_woe(x_num, self.woe_tensors, self.feature_order) logit self.model(x_woe, x_cat).squeeze(-1) return self.mapper(logit) pipeline FullPipeline(model, woe_tensors, feature_order) pipeline.eval() # 用一批样本 trace example_num torch.randn(2, len(feature_order)) example_cat torch.zeros(2, len(cat_vocab), dtypetorch.long) traced torch.jit.trace(pipeline, (example_num, example_cat)) traced.save(save_path) # 验证一致性 with torch.no_grad(): py_out pipeline(example_num, example_cat) ts_out traced(example_num, example_cat) diff (py_out - ts_out).abs().max().item() print(fMax diff: {diff}) assert diff 1e-4, TorchScript 输出不一致检查 bucketize 和 log 数值稳定性 return tracedexample_cat的维度要和实际类别特征数量一致这里用len(cat_vocab)只是示意实际应该是一个固定长度的零张量。torch.jit.trace对控制流敏感transform_woe里如果有 Python 循环trace 会把它展开成固定图所以feature_order必须是固定的不能动态变化。导出后一定要跑一致性验证差异超过 1e-4 就说明有数值问题最常见的是bucketize边界处理。5.2 分数校准让输出分数符合真实违约率模型输出的分数是相对排序但业务上往往要求分数对应的违约概率是校准过的。PyTorch 模型经过BCEWithLogitsLoss训练后sigmoid 输出理论上接近真实概率但样本不均衡时会有偏差。常见做法是在验证集上做 Platt Scaling 或 Isotonic Regression把分数映射到校准后的概率。我一般用 Isotonic Regression因为它对单调性友好不会破坏评分卡的单调性。from sklearn.isotonic import IsotonicRegression def calibrate_scores(model, val_loader, feature_order, woe_tensors): model.eval() scores, labels [], [] with torch.no_grad(): for x_woe, x_cat, y in val_loader: logits model(x_woe, x_cat).squeeze(-1) scores.append(torch.sigmoid(logits).numpy()) labels.append(y.numpy()) scores np.concatenate(scores) labels np.concatenate(labels) iso IsotonicRegression(out_of_boundsclip) iso.fit(scores, labels) return iso校准后的概率再走ScoreMapper转成分数这样分数对应的违约率就是校准过的。out_of_boundsclip保证超出训练范围的分数也能映射不会报错。校准这一步在监管要求严格的场景下是必须的普通场景可以跳过但建议至少做一次验证看看校准前后的分数分布差异。5.3 一个我踩过的坑别在训练集上做分数校准早期我做分数校准的时候图省事直接在训练集上 fit Isotonic Regression结果线上分数严重偏高因为训练集的预测概率本身就被模型拟合得过于乐观。校准必须在独立的验证集或留出集上做而且这个验证集不能参与任何训练和早停决策。我现在的习惯是切三份训练集、验证集早停和调参、校准集分数校准三份严格隔离。这个习惯让我在后来的项目里再也没出现过分数校准翻车的情况。希望帮到你。本文还有配套的精品资源点击获取
返回列表