多源Transformer在小微企业信贷评分中的落地实践与避坑指南
2026/9/23 15:14:43 网站建设 项目流程

简介:一份面向金融风控从业者、机器学习和深度学习研究者的PDF资料,聚焦小微企业信贷风险评估,提出基于多源Transformer整合非结构化数据的评分模型。文档共42页,单个PDF文件约2.14MB,支持目录章节跳转与阅读器大纲快速定位,页面内容清晰完整,排版正常。目前已有72人学习下载。内容全面覆盖小微企业信贷风险来源与传统评估方法、非结构化数据的类型与价值、Transformer模型结构与注意力机制原理,并重点阐述多源数据编码器、基于注意力机制的特征融合、风险评分模块设计、模型可解释性实现等核心环节。同时涵盖数据预处理、模型开发环境搭建、训练优化、评估指标与验证方法,适合需要系统学习深度学习在信贷风控领域落地应用的读者参考。

1. 多源Transformer凭什么进小微企业信贷评分

小微贷款审批现场,客户经理通常先翻核心流水、再看购销合同、再翻聊天记录和组织架构,最后手写一段几百字的尽调意见。这一堆信息最终落到审批系统里,往往只剩一张财务报表评分卡。系统只能看到结构化字段,文本描述、流水节奏、舆情波动全被丢掉。这个标题把多源Transformer嵌入小微企业评分模型,本质就是让模型替客户经理把没进评分卡的信息读回来——流水时序、尽调文本、外部数据各自走编码器,再用注意力机制融合,输出一个可解释的违约分。这篇笔记讲清楚数据怎么对齐、模型怎么搭、参数怎么调,以及信贷场景最容易在哪几个地方翻车。适合正在做评分卡升级或从零搭小微风控模型的团队。

2. 多源数据对齐:把财报、流水、文本和舆情整理成一套张量

先说清什么是“多源”。在小微评分里,常规说法是四个来源:

  • 结构化数值:注册资本、从业年限、财务报表关键科目、征信摘要。
  • 时序数据:核心结算账户的银行流水、纳税频率、开票金额变化。
  • 文本数据:客户经理尽调意见、合同备注、行业研报摘要。
  • 外部标签数据:工商变更、被执行人记录、舆情事件。

这四个来源的挑战与其说是模型难,不如说是“对齐难题”。流水是按天一条条的,文本是一段段的,财务字段是一行行的。要让Transformer把多源非结构化数据放进同一个样本,第一步是先把它们转成统一格式。

我一般用“窗口切分 + token映射”来做。文本按分词结果截断到N个token;流水把近12个月按月聚合出12 x D的张量,再按周粒度补一条,得到时序矩阵;数值字段先做分位数归一化,再映射成固定长度向量。这里有个常见误解:Transformer要求输入是一个序列,很多团队就把所有特征拼成一个长向量,再硬塞进编码器。这确实能跑通,但融合效果会差很多,因为文本和数值的语义密度完全不同,强行拼接会让注意力机制学到的是“位置偏移”而不是“语义关联”。更稳的做法是每个源先自己编码,到融合层再交互。

下面这份预处理代码,把三类源数据压成一条样本:

import torch import numpy as np import pandas as pd TEXT_MAX_LEN = 120 # 文本token上限 FLOW_DAYS = 360 # 流水窗口长度(近一年) FEAT_DIM = 18 # 结构化特征个数 FIN_FLOW_DIM = 8 # 月流量统计维度 def make_sample(row, texts, flows): # texts: dict,段落到id序列,已提前分词 # flows: 该样本的流水DataFrame [date, amount, balance] # 1) 文本源:截断/补齐到定长 tokens = texts[row["sample_id"]][:TEXT_MAX_LEN] if len(tokens) < TEXT_MAX_LEN: tokens = tokens + [0] * (TEXT_MAX_LEN - len(tokens)) # 0为pad id text_tensor = torch.tensor(tokens, dtype=torch.long) # 2) 时序源:按月重采样,生成 12 x 8 flow = flows[flows["sample_id"] == row["sample_id"]].copy() flow["month"] = pd.to_datetime(flow["date"]).dt.to_period("M") month_stat = flow.groupby("month").agg( sum_amt=("amount", "sum"), cnt=("amount", "count"), avg_bal=("balance", "mean") ) seq = np.zeros((12, FIN_FLOW_DIM), dtype=np.float32) for i in range(len(month_stat)): if i >= 12: break seq[i, 0] = np.log1p(month_stat["sum_amt"].iloc[i]) seq[i, 1] = np.log1p(month_stat["cnt"].iloc[i]) seq[i, 2] = np.log1p(month_stat["avg_bal"].iloc[i]) # 维度不足8的部分,用其他特征补:大额支出次数、透支天数等 flow_tensor = torch.tensor(seq, dtype=torch.float32) # 3) 结构化特征:分位数归一化 num_cols = ["age", "reg_capital", "credit_score", "tax_rate"] x_num = row[num_cols].astype(np.float32).values / 100.0 num_tensor = torch.tensor(x_num, dtype=torch.float32) return text_tensor, flow_tensor, num_tensor

这段代码的核心是“三个张量、三种语义”:文本是离散token,时序是连续矩阵,数值是低维稠密向量。注意月份序列顺序不能乱,这里的index 0是最近一个月还是最早一个月,必须在数据管线里统一,后面训练时很容易在这里出错。另一个容易错的地方是归一化。结构化特征用 /100 是偷懒写法,正式场景应该按全样本分位数做单调变换,保留原始排序关系。文本截断的120是经验值,太长会拖慢编码器,太短会丢尽调意见里的风险信号;我一般用尽调意见的平均字数,乘以1.2作为截断上限。

说一下嵌入维度设计。文本分支嵌入维度取128,时序分支用两层Conv1D先压到64维再进Transformer,数值分支只做线性映射到64维。后面的融合层统一对齐到64维,这样多头注意力才有共同的可比空间。别把文本嵌入维度设成768,再把时序压到16,后面融合时维度差太大,注意力矩阵会很稀疏。

预处理完成后,通常还要做一次“负样本审计”。在原始样本上看一看每个坏客户的尽调文本里是不是真的存在风险描述词,流水里是不是真的有断流、集中转出这类模式。这一步帮我在后期解释模型时省了很多力。

3. 模型结构:编三个编码器分支,再用门控融合成违约分

把“多源”看清楚之后,模型结构基本上是照着标题定死的:每个源一个编码器,再融合。常见做法不是“大而全的单个Transformer”,而是“小分支 + 融合注意力”。原因很直接——小微评分的样本量通常在几万级,单个大模型的参数规模撑不住。

这里的结构我按三个分支来讲。

文本分支:用一个小型的Transformer Encoder,4层,隐层128,4个注意力头。分词可以用已有的金融领域词表,也可以直接上通用中文预训练模型的embedding层,后面不跟着大模型跑,只把它当成静态词向量。训练时冻结embedding,能极大减少参数量。时序分支:流水数据不是天然的语言序列,我把按月的12 x 8矩阵摊平后,再加一层位置嵌入,编码器2层即可。数值分支:只有18个特征,直接用MLP映射到64维,然后参与融合。

常见的错误是让数值特征也过编码器,这理论上可行,但18个特征做自注意力,信息量不足,训练时很容易只学到冗余的重复向量,反而干扰。

融合层我用“门控融合 + 拼接”。具体是:三个分支的输出向量分别叫 h_text、h_flow、h_num,先都映射到64维,然后计算一个门控向量 g = sigmoid(W·[h_text; h_flow; h_num]),最后融合向量 h_fused = g_text * h_text + g_flow * h_flow + g_num * h_num。门控的好处是给不同源一个可学习的权重,而不是固定拼接。真正上线后你会发现有的企业文本信息很弱、流水信息很强,门控能自动压制弱源。

下面是一个多源Transformer评分模型的核心定义,用PyTorch写的:

import torch import torch.nn as nn class PosEmbedding(nn.Module): def __init__(self, d_model, max_len=512): super().__init__() self.pe = nn.Embedding(max_len, d_model) def forward(self, x): pos = torch.arange(x.size(1), device=x.device) return x + self.pe(pos) class BranchEncoder(nn.Module): def __init__(self, d_model, nhead, num_layers): super().__init__() encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, batch_first=True ) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) def forward(self, x): return self.encoder(x) class MultiSourceScorer(nn.Module): def __init__(self, vocab_size, d_model=64, nhead=4, num_text_layers=4, num_flow_layers=2, feat_dim=18): super().__init__() self.d_model = d_model # 文本分支:词向量 + 位置编码 + Encoder self.text_emb = nn.Embedding(vocab_size, d_model, padding_idx=0) self.text_pos = PosEmbedding(d_model) self.text_enc = BranchEncoder(d_model, nhead, num_text_layers) # 时序分支:先过两层1D卷积抽局部特征,再过Encoder self.flow_conv = nn.Sequential( nn.Conv1d(8, 32, kernel_size=3, padding=1), nn.GELU(), nn.Conv1d(32, d_model, kernel_size=3, padding=1), nn.GELU(), ) self.flow_pos = PosEmbedding(d_model, max_len=12) self.flow_enc = BranchEncoder(d_model, nhead, num_flow_layers) # 数值分支:直接MLP到d_model self.num_proj = nn.Sequential( nn.Linear(feat_dim, 64), nn.GELU(), nn.Linear(64, d_model), ) # 门控融合 self.gate = nn.Linear(d_model * 3, 3) self.head = nn.Sequential( nn.Linear(d_model, 32), nn.GELU(), nn.Linear(32, 1), ) def forward(self, text_ids, flow_seq, num_feat): # 文本分支输出:取全局最大池化,避免依赖单token t = self.text_emb(text_ids) t = self.text_pos(t) t = self.text_enc(t) t = torch.max_pool1d(t.transpose(1, 2), kernel_size=t.size(1)).squeeze(-1) # 时序分支 f = flow_seq.transpose(1, 2) # (B, 8, 12) f = self.flow_conv(f) # (B, d_model, 12) f = f.transpose(1, 2) # (B, 12, d_model) f = self.flow_pos(f) f = self.flow_enc(f) f = f.mean(dim=1) # 平均池化 # 数值分支 n = self.num_proj(num_feat) # (B, d_model) # 门控融合 fused_all = torch.cat([t, f, n], dim=-1) g = torch.softmax(self.gate(fused_all), dim=-1) h_fused = g[:, 0:1] * t + g[:, 1:2] * f + g[:, 2:3] * n # 输出违约概率 logit = self.head(h_fused) return torch.sigmoid(logit).squeeze(-1)

这段代码的几处设置说明一下。位置编码用的nn.Embedding而不是原始的sin/cos公式,是因为流水和文本的“位置”语义不同——文本里位置偏句法,流水里位置代表月份先后,两者用同一套三角公式意义不大,直接让模型学更省事。这算是“Transformer的位置信息怎么计算”这个老问题在小微场景里的一个变体答案。

文本分支的池化用最大池化而不是CLS token,是因为小微尽调文本很短,CLS在短文本上不稳定。时序分支用均值池化,因为流水信号是累积性的。门控融合把三个分支的置信度做成可学习的权重,比直接concat多一个好处:训练后期收敛更稳,不会因为某一个分支的梯度主导整个网络。

如果你是在TensorFlow环境里做,等价实现就是Keras Functional API拼三路输入,逻辑一样。不建议用那种把文本、流水、数值拼在一个二维输入里的实现,那是把多源做成了伪单源。

4. 训练与调参:不平衡样本下的收敛策略

模型结构定了之后,真正决定好坏的是训练流程。信贷评分和别的分类任务有两个显著差异:正负样本严重不平衡,以及错误代价不对称——把坏客户放进来比把好客户拒掉贵得多。

以我见过的典型小微数据为例,坏样本占比通常在3%到8%之间。直接用BCE Loss,模型会很快学到“全预测为好”这个局部最优。解决方案按数据量分:样本只有几千条时用负采样,把好客户压到坏客户的两三倍;样本足够多时用Focal Loss,让模型把注意力放在那些“难分”的坏客户上。

Focal Loss的实现直接给:

class FocalLoss(nn.Module): def __init__(self, alpha=0.75, gamma=2.0): super().__init__() self.alpha = alpha # 正样本权重 self.gamma = gamma # 困难样本聚焦系数 def forward(self, prob, target): eps = 1e-8 prob = torch.clamp(prob, eps, 1 - eps) pt = target * prob + (1 - target) * (1 - prob) alpha_t = self.alpha * target + (1 - self.alpha) * (1 - target) loss = -alpha_t * (1 - pt) ** self.gamma * torch.log(pt) return loss.mean()

alpha在这里表示“正样本的权重”,0.75意味着一个坏客户在损失里的权重是一个好客户的3倍。gamma控制对易分样本的降权程度,2.0是常用起点,gamma太小易分样本仍然主导,太大则训练初期震荡。跑第一轮验证时我会盯两个指标:坏样本的召回率,以及损失曲线有没有在第二三个epoch之后稳定下降。如果召回率一直在0.1以下,先调alpha,不要急着动模型结构。

训练循环里还有三个我认为比学习率更重要的点。

第一是梯度裁剪。多源Transformer融合层很容易梯度爆炸,尤其是文本分支词向量解冻之后。我一般把max_grad_norm设为1.0,既能压住爆炸又不影响收敛速度。第二是早停的指标。很多团队用验证集AUC早停,但信贷场景AUC对坏样本占比并不敏感,一个阈值预测也可能有虚高AUC。我会在验证集上同时计算AUC和坏样本召回率@5%(即分数最高的5%客户里覆盖了多少真实坏客户),以这个复合指标作为早停依据。第三是学习率调度。先warmup 500步,然后余弦衰减。多源模型的前几个epoch里不同分支的学习速度不一致,直接上一层大的全局学习率,经常出现文本分支已经过拟合、时序分支还没动静的情况。

下面是一个典型训练循环的骨架:

optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr=3e-4, total_steps=total_steps, pct_start=0.15, ) scaler = torch.cuda.amp.GradScaler() for epoch in range(max_epochs): for batch in train_loader: text_ids, flow_seq, num_feat, label = batch optimizer.zero_grad() with torch.cuda.amp.autocast(): prob = model(text_ids, flow_seq, num_feat) loss = focal_loss(prob, label) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update() scheduler.step()

强调一下max_lr和pct_start的关系。warmup占比15%是给文本分支的词向量“热启动”用的,这一阶段学习率从0升到3e-4,等文本分支学到基本词义后,再统一下降。如果发现训练早期损失震荡,把pct_start提调到0.3,比手动降学习率更省事。

还有一个容易被忽略的点:Batch Size。多源Transformer每个样本至少三条输入流,显存消耗大,Batch Size设到128以上才比较稳。显存不够时不要粗暴减小Batch Size,应该先减小文本截断长度,比如从120降到80,把显存给Batch Size留足。混合精度在这个场景收益明显,能提升30%以上的吞吐,且评分模型是数值敏感任务,FP16对最终AUC几乎无影响。

用这套流程训练完,模型在开发集上的KS通常能做到0.35上下,比纯结构化模型高0.08到0.12。但先别高兴,下一章讲的几个坑,几乎每个模型都会踩至少两三个。

5. 避坑清单:多源Transformer评分模型最容易翻车的五个位置

这个标题看着新颖,落地时翻车点却相当集中。下面按“现象→原因→解决”写最常见的五个,都是我自己的血泪经验。

坑一:验证集里混进了“未来信息”,离线指标虚高

现象是验证集AUC 0.72,上线后只有0.58,回测怎么都对不上。原因是预处理里有一步“最近一个月流水统计”和标签生成用的观测期重叠了。比如用第12个月的数据预测第13个月的违约,那么流水窗口末端和未来违约区间天然有信息泄漏。解决的规则只有一条:按时间切分,把整个过程串起来——[1, 10]月训练、11月做验证、12月做测试,任何落在验证/测试区间内的特征一律归零或用历史均值填充。这条坑是新手最容易踩的,但老手也常在文本字段上翻车:尽调意见里偶尔会手写“客户已于X月逾期”,这份意见又在验证集里被当成输入。所以在清洗阶段就要过滤掉所有包含“逾期”“违约”“不良”字样的尽调文本。

坑二:多源特征直接concat,训练初期损失不降

现象是loss从一开始就卡在0.69附近(全预测为好客户的CE值),怎么调都不动。原因通常是三个分支的数值尺度差异太大——文本输出是0到1的sigmoid值,时序是归一化后的对数金额,数值分支是原始字段,三者拼起来后梯度被某个大尺度分支主导,其他分支的权重几乎不更新。解决分两步:一是每个分支输出过LayerNorm再进融合层;二是检查各分支梯度的范数,如果差异超过一个数量级,就给大梯度分支的输入乘0.1的缩放系数,让各分支的学习节奏接近。

坑三:固定窗口截断文本,把尽调意见后半段的信号全丢了

现象是线上精确率还行,召回率始终提不上去,调了很多参数都没用。原因在于很多尽调意见的格式是“前半段企业概况,后半段风险提示”,截断到120 token时,风险提示正好被切掉。解决方法是分两段截断:先截head 80 token + tail 40 token,中间用省略符接上,Transformer的注意力能抓住两端信息;或者直接按段落切分,每段保留首尾句。这个方案对短文本尤其有效,我在小微场景用后坏客户召回率涨了6个点。

坑四:Transformer在几千条样本上严重过拟合

现象很典型:训练AUC 0.95,验证0.68,而且dropout从0.1加到0.5都没用。原因是样本量撑不起4层编码器的参数量。解决策略:把文本分支的层数从4降到2,词向量冻结;时序分支不做1D卷积,直接线性映射;dropout仍然设在0.2。如果还是过拟合,就用预训练编码器加只训练最后两层,把整个模型的前半段当特征抽取器用。小微数据通常不满一万条,这种情况下与其改结构,不如做特征工程:把文本转换成规则特征(是否含负面词、风险描述数量、长度),并到融合层里。理论上是退回了特征工程,但它能让模型更稳。

坑五:门控融合在推理阶段出现“源遮蔽”

现象是融合权重g_text总是0.9,其他两个源无论怎么调都占不到权重。原因是Softmax让门控向量的早期梯度方向过于一致,训练后期固定成了局部最优。解决方式是在门控层的输出上加温度系数:g = softmax(gate / 0.5),让权重在0.1到0.9之间波动更缓和。更硬核的做法是直接去掉门控,改用三个分支的加权均值固定权重(文本0.5、时序0.3、数值0.2),然后用融合层接一个1x1卷积去学习残差。后者在噪声数据上更稳定,缺点是解释性差一些。

五个坑说完,你会发现它们大都发生在数据和训练策略层,真正模型结构本身反而不会太作妖。这也符合这类方案的规律——瓶颈不在Transformer,而在数据管线。

6. 可解释性与上线验证:让多方愿意接受这个黑匣子评级

小微评分模型有一个特有约束:客户经理、风控主管、贷审会都要能看懂“为什么给这个分数”。按上面的结构直接上线,大概率会被挑战。所以我在训练完后会做两件事:注意力归因和分数校准。

先说注意力归因。Transformer本身有注意力矩阵,把文本分支最后一层的注意力权重按token汇总,可以看到哪些词在影响违约概率。方法类似把attention_map按平均头展开,再做一个逐token的相对权重:正权重词表里能直接看到“赌博”“民间借贷”“隐瞒”,这时候模型的可信度会提高不少。不过注意力权重不等于因果贡献,别拿去跟业务解释“因为这个词所以降分”,更适合当特征洞察工具用。

更严谨的解释用SHAP做。对数值分支和门控融合层的输出做SHAP归因,能算出每个字段对最终分数的边际贡献。常见做法是:把样本分箱,对每箱跑200次蒙特卡洛采样,得到每个特征的期望贡献值,再画一个贡献排名表。这里有个坑:SHAP在多源模型上计算量大,建议先对融合层输出做归因,不直接归因到词级别,业务上足够用。

然后说分数校准。模型输出是0到1的概率,风控体系需要0到100的评分。我用等频分箱把开发集概率映射到1到100分,分箱边界存成JSON,上线时用二分查找落箱。这里要注意概率和分数的方向。业务上通常是“分数越高、风险越高”还是“分数越低、风险越高”,必须在映射时定死,否则后面总会有人拿错方向的数据去做权限。

最后做一套“上线前体检”,我自己固定跑这三个检查:

检查项通过标准失败处理
训练集/验证集/测试集分布PSI < 0.1重新切分或采集样本
坏样本覆盖率@5%>= 60%调采样或加特征
关键因子单调性无明显倒挂检查数据质量

这三项都过了,模型才敢交到审批环节试运行。试运行头一个月,每周看一次监控报表,重点观察分数分布有没有漂移、新发放贷款里80分以上客户占比有没有异常提升。

我现在做新模型有个习惯:拿到任何评分模型的第一个动作,是先画分数分布对比图,看新旧模型对同一批客户的排序差异,而不是盯着AUC不放。凡是对排序改变过大的模型,先想办法降低激进程度,再聊准确率。这个习惯帮我挡掉了至少两个上线后要返工的方案。这套多源Transformer的路子,按上面几章走,一万条样本以内也能跑出可用的模型,关键是把对齐、训练、验证三条链路都控住。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询