简介:这份PDF文档面向金融风控从业者、数据科学学习者与算法工程师,系统讲解如何用PyTorch构建并优化企业信用评分卡模型,帮助读者打通从数据准备到模型部署的完整链路。文档共31页,以PDF单文件形式打包,大小约2.12MB,支持目录章节跳转、阅读器左侧大纲显示与章节快速定位,查阅体验流畅。内容覆盖金融风控与评分卡概述、PyTorch环境搭建与数据预处理、逻辑回归/决策树/神经网络三类模型构建、准确率与AUC等评估指标解读、特征工程与超参数调优等优化策略,以及本地、云平台与容器化部署实践,并附完整案例分析。已有107人学习,适合希望掌握评分卡建模全流程、提升风控实战能力的中高级读者参考。
1. 金融风控信用评分卡:为什么用 PyTorch 重写逻辑回归不是杀鸡用牛刀
很多做金融风控的团队,评分卡模型到今天还在用 sklearn 的LogisticRegression加WOE分箱那一套。这套东西稳定、可解释、监管认,但一旦你要做多任务学习、要接嵌入层处理高基数类别特征、要在同一个网络里同时输出 PD 和 LGD,sklearn 就顶不住了。信用评分卡的本质是一个二分类概率模型,输出的是未来某段时间内违约的概率,再通过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_dim=4): super().__init__() # WOE 特征直接进线性层,对应传统评分卡的线性部分 self.woe_linear = nn.Linear(n_woe_features, 1, bias=False) # 高基数类别特征用嵌入,每个类别一个 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, bias=False) 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, dim=1) 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, dtype=torch.float32) # WOE 值张量,长度比边界多 1 woe_t = torch.tensor(woe_map_dict[feat], dtype=torch.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, dim=1)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_(std=0.01),不要用默认的均匀分布,默认初始化在类别数多的时候会导致初始 logit 方差过大,训练初期 loss 震荡。
另外,类别特征一定要做频率过滤,出现次数少于 50 次的类别合并成OTHER桶,否则嵌入层对这些稀有类别学到的向量基本是噪声。这个过滤要在建词表的时候做,不要等到训练时再处理。下面是一个建词表的示例。
from collections import Counter def build_category_vocab(series, min_count=50): 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_mono=0.1): """ monotonic_pairs: list of (emb_layer, direction) direction=1 表示嵌入向量随类别索引递增,-1 表示递减 """ bce = F.binary_cross_entropy_with_logits(logits, labels, reduction='mean') 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 和 AUC,KS 才是风控最关心的指标。KS 等于max(TPR - FPR),在 PyTorch 里可以在每个 epoch 结束后用验证集算。下面是一个完整的训练循环,包含早停和 KS 监控。
def train_scorecard(model, train_loader, val_loader, epochs=50, lr=1e-3, patience=5): optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-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_score=600, pdo=50, base_odds=1/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_score=600、base_odds=1/20、pdo=50,意思是 odds 为 1/20 时分数 600,odds 翻倍分数降 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_path='scorecard_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), dtype=torch.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(f'Max 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_bounds='clip') iso.fit(scores, labels) return iso校准后的概率再走ScoreMapper转成分数,这样分数对应的违约率就是校准过的。out_of_bounds='clip'保证超出训练范围的分数也能映射,不会报错。校准这一步在监管要求严格的场景下是必须的,普通场景可以跳过,但建议至少做一次验证,看看校准前后的分数分布差异。
5.3 一个我踩过的坑:别在训练集上做分数校准
早期我做分数校准的时候,图省事直接在训练集上 fit Isotonic Regression,结果线上分数严重偏高,因为训练集的预测概率本身就被模型拟合得过于乐观。校准必须在独立的验证集或留出集上做,而且这个验证集不能参与任何训练和早停决策。我现在的习惯是切三份:训练集、验证集(早停和调参)、校准集(分数校准),三份严格隔离。这个习惯让我在后来的项目里再也没出现过分数校准翻车的情况。
希望帮到你。
本文还有配套的精品资源,点击获取