简介:这份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 表示。
样本量小意味着不能用太深的网络,否则过拟合是必然的。我一般把特征控制在 80~200 维,样本少于 5000 条时优先考虑带正则的浅层 MLP 或 Embedding+LR 的混合结构。标签滞后期要留够,至少 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(columns=drop_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, bins=10): """等频分箱后计算WOE和IV""" # 等频分箱,处理重复边界 df["bin"] = pd.qcut(df[feature], q=bins, duplicates="drop") grouped = df.groupby("bin")[target].agg(["sum", "count"]) grouped.columns = ["bad", "total"] grouped["good"] = grouped["total"] - grouped["bad"] # 防止除零,加平滑项 grouped["bad"] = grouped["bad"].clip(lower=0.5) grouped["good"] = grouped["good"].clip(lower=0.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 # IV<0.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.02~0.1 弱,0.1~0.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, dtype=torch.float32) # cat_features: 类别特征索引,int64 self.cat_features = torch.tensor(cat_features, dtype=torch.long) self.labels = torch.tensor(labels, dtype=torch.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_size=0.2, random_state=42, stratify=df[label_col]) train_df, val_df = train_test_split(train_df, test_size=0.2, random_state=42, stratify=train_df[label_col]) # 构建DataLoader,batch_size在样本少时设小一点 train_loader = DataLoader( CreditDataset(train_num, train_cat, train_label), batch_size=256, shuffle=True, drop_last=True )drop_last=True在样本量小的时候要注意,最后一个不完整 batch 如果只有几条样本,BatchNorm 的统计量会抖得厉害。样本少于 1 万条时,batch_size 建议 128~256,太大梯度更新次数不够,太小训练不稳定。按时间切分而不是随机切分,是为了模拟「用历史数据预测未来」的真实场景,随机切分会让模型偷看到未来信息,线下 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_dim=8, hidden=(128, 64), dropout=0.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, dim=1) return self.mlp(x).squeeze(-1)Embedding 维度取min(8, (cardinality+1)//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_weight=self.pos_weight ) # 计算正样本权重 neg = (train_label == 0).sum() pos = (train_label == 1).sum() pos_weight = torch.tensor([neg / pos], dtype=torch.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_norm=5.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_norm=5.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(), lr=1e-3, weight_decay=1e-4) scheduler = ReduceLROnPlateau(optimizer, mode="max", factor=0.5, patience=3) # 在验证后调用 scheduler.step(val_auc)AdamW 比 Adam 多了正确的权重衰减,对评分卡这种小样本场景更稳。weight_decay 设 1e-4 是起点,过拟合严重加到 1e-3。ReduceLROnPlateau 的 factor=0.5 表示验证指标不升时学习率减半,patience=3 表示等 3 轮。
4.2 把概率输出校准成 300~850 分
模型输出的是概率,业务要的是分数。标准做法是用score = offset + factor * ln(odds),其中 odds = (1-p)/p。设定基准分和 PDO(Points to Double the Odds)。
def prob_to_score(prob, base_score=600, base_odds=50, pdo=40): """把违约概率转成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_score=600、base_odds=50、pdo=40 是一套常用参数,意思是 odds 为 50:1 时对应 600 分,odds 翻倍加 40 分。这套参数要和业务方对齐,不是技术侧拍脑袋定的。分数裁剪到 300~850 是行业惯例,超出范围的分数没有业务含义。
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, bins=10): """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(f"AUC: {roc_auc_score(test_label, test_probs):.4f}") print(f"KS: {calc_ks(test_label, test_probs):.4f}") print(f"PSI: {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, (cardinality+1)//2)控制,类别数少于 10 时维度不超过 4。同时给 Embedding 加 weight_decay,或者对低频类别做合并。
5.3 BatchNorm 在小 batch 下统计量失真
现象:训练时 loss 震荡,验证指标忽高忽低。
原因:batch_size 太小(比如 32),BatchNorm 每批统计的均值和方差波动大,尤其最后一个不完整 batch 只有几条样本。
解决:batch_size 至少 128,drop_last=True丢掉不完整 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 个特征,人工检查方向对不对。如果发现「注册资本越高违约概率越高」这种反常识的贡献方向,大概率是特征工程或数据有问题,别急着上线。模型可以黑盒,但特征方向必须符合业务常识,这是评分卡和纯预测任务最大的区别。希望帮到你。
本文还有配套的精品资源,点击获取