简介:这份PDF文档聚焦信贷风险评估领域,面向金融科技研究者、风控建模工程师及高校相关专业师生,系统讲解如何借助多源Transformer整合非结构化数据,构建小微企业评分模型。文档共42页,以单一PDF形式打包,大小约2.14MB,支持目录章节跳转、阅读器左侧大纲显示与章节快速定位,查阅体验完整流畅。内容从研究背景与小微企业信贷风险概述切入,梳理非结构化数据在声誉评估、市场趋势分析、经营监测中的应用价值,再深入Transformer模型基础,包括编码器解码器结构、缩放点积注意力与多头注意力机制,并展开多源数据编码、特征融合、风险评分与可解释性设计等模型实现细节,同时覆盖训练优化、评估指标与验证方法。已有72人学习,适合希望掌握注意力机制在信贷风控中落地思路的读者参考。
1. 信贷风险评估:多源 Transformer 整合非结构化数据的小微企业评分模型到底在解决什么
给小微企业做信贷评分,最头疼的从来不是模型不够深,而是数据太散、太脏、太非结构化。财务报表是 PDF,流水是 Excel,工商信息是 JSON,合同扫描件是图片,客户经理的尽调记录是自由文本——传统评分卡把这些东西要么丢掉,要么硬塞成几个手工特征,信息损耗极大。多源 Transformer 整合非结构化数据的小微企业评分模型,核心思路就是让 Transformer 的注意力机制自动完成多源信息融合,把文本、表格、类别字段统一编码后联合建模,输出违约概率。它适合两类人:一是手里有真实信贷数据、想从 LR/XGBoost 往上走的算法工程师;二是想理解多源信息融合在金融风控里怎么落地的技术负责人。这篇笔记不讲空理论,直接按「数据怎么对齐 → 模型怎么搭 → 参数怎么调 → 坑在哪」走一遍。
2. 多源数据怎么对齐:从非结构化原始件到模型可吃的张量
2.1 先想清楚三路数据各自的编码方式
小微企业信贷场景里,数据源通常分三路。第一路是结构化数值,比如近 12 个月开票金额、纳税等级、资产负债率,这类走分箱加 embedding,或者直接标准化后过线性层。第二路是类别与序列,比如行业代码、区域代码、近 6 个月流水趋势,行业用 embedding,流水趋势用一维卷积或直接当序列喂给 Transformer。第三路是非结构化文本,比如工商变更记录、司法涉诉描述、尽调笔记,这类用预训练中文模型编码成向量序列。
关键决策点:三路数据是早期融合还是晚期融合。我的经验是,如果文本字段平均长度超过 50 字、且和数值特征有交互(比如涉诉文本里提到「拖欠」时,流水特征权重应该变化),就用早期融合——把三路 token 拼成一个长序列,让 Transformer 自己学跨源注意力。如果文本只是补充信息、量很小,晚期融合更稳,各塔独立编码后拼接再过 MLP。
import torch import torch.nn as nn class MultiSourceEncoder(nn.Module): def __init__(self, num_vocab, cat_vocab, text_dim=768, d_model=256): super().__init__() # 数值特征:分箱后 embedding,假设 32 个 bin self.num_emb = nn.Embedding(32, d_model) # 类别特征:行业、区域等 self.cat_emb = nn.Embedding(cat_vocab, d_model) # 文本特征:预训练模型输出投影 self.text_proj = nn.Linear(text_dim, d_model) # 来源类型 embedding,让模型知道每个 token 来自哪一路 self.source_type = nn.Embedding(3, d_model) self.norm = nn.LayerNorm(d_model) def forward(self, num_ids, cat_ids, text_feats): # num_ids: [B, N_num], cat_ids: [B, N_cat], text_feats: [B, L, 768] num_emb = self.num_emb(num_ids) + self.source_type.weight[0] cat_emb = self.cat_emb(cat_ids) + self.source_type.weight[1] text_emb = self.text_proj(text_feats) + self.source_type.weight[2] # 拼接成统一序列:[B, N_num+N_cat+L, d_model] return self.norm(torch.cat([num_emb, cat_emb, text_emb], dim=1))这段代码的核心是source_type这个来源类型 embedding。没有它,模型无法区分某个向量是数值分箱还是文本语义,跨源注意力会退化成普通自注意力。d_model建议从 128 起步,数据量超过 5 万条再上 256。文本编码器选型上,中文场景用 RoBERTa-wwm 或 MacBERT 都行,输出取 last_hidden_state 而不是 pooler_output,因为我们需要 token 级序列来保留细节。
2.2 序列长度与 padding 策略直接决定显存和效果
多源拼接后序列长度 = 数值 token 数 + 类别 token 数 + 文本 token 数。数值和类别通常各 10~30 个,文本如果截断到 128,总长大概 180~200。这个长度对 Transformer 很友好,显存可控。但要注意:数值和类别 token 没有顺序含义,文本 token 有顺序含义,位置编码要区别对待。
常见做法是给数值和类别 token 用可学习的位置 embedding,给文本 token 用预训练模型自带的位置编码,然后在拼接后不再额外加位置编码。如果统一加正弦位置编码,数值 token 之间的「位置」会引入虚假顺序信息,反而干扰。我一般会在拼接后只对文本段保留位置信息,数值和类别段的位置 embedding 固定为同一个可学习向量。
padding 方面,文本按 batch 内最大长度 pad,数值和类别固定长度。attention mask 要把 pad 位置屏蔽掉,否则 pad token 会参与注意力计算,导致评分偏移。这个坑很隐蔽,线下 AUC 可能只掉 0.005,但线上排序会乱。
2.3 标签定义与样本对齐的工程细节
小微企业信贷的标签通常是「放款后 6 个月内是否出现逾期 30+」。这里有两个坑:一是表现期不足的样本要剔除,否则标签噪声大;二是不同放款月份的正负样本比例波动大,需要按月份分层采样或加时间衰减权重。
样本对齐时,所有特征必须取「申请时点」之前的数据。文本类特征尤其容易穿越,比如尽调笔记如果是放款后补录的,绝对不能进模型。我一般会在特征表里加feature_date字段,训练前强制校验feature_date < apply_date,不满足的直接置空。
# 特征时点校验,防止标签穿越 def validate_feature_time(df): df['feature_date'] = pd.to_datetime(df['feature_date']) df['apply_date'] = pd.to_datetime(df['apply_date']) mask = df['feature_date'] >= df['apply_date'] # 穿越样本直接置空,不删除,保留样本量 df.loc[mask, 'text_feature'] = None df.loc[mask, 'num_feature'] = None return df置空而不是删除,是因为删除会改变样本分布,置空后模型会学到「该特征缺失」的模式,反而更稳。缺失率超过 70% 的特征直接整列去掉,别硬留。
3. 模型结构怎么搭:Transformer 编码器 + 多任务头的落地配置
3.1 编码器层数与注意力头的选择依据
数据量在 1 万条以下时,Transformer 编码器 2 层、4 头足够,再深必然过拟合。5 万条以上可以上 4 层 8 头。隐藏维度d_model和头数满足d_model % nhead == 0,常见组合是 256/8、128/4。
前馈层维度一般是d_model的 4 倍,但信贷场景特征维度低,2 倍就够,还能省显存。dropout 设 0.1~0.3,文本噪声大就取 0.3。激活函数用 GELU,比 ReLU 在金融数据上略稳。
class CreditTransformer(nn.Module): def __init__(self, encoder, d_model=256, nhead=8, num_layers=4, dropout=0.2): super().__init__() self.encoder = encoder layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=d_model * 2, dropout=dropout, activation='gelu', batch_first=True ) self.transformer = nn.TransformerEncoder(layer, num_layers=num_layers) # 多任务头:主任务违约概率 + 辅助任务行业风险等级 self.risk_head = nn.Linear(d_model, 1) self.aux_head = nn.Linear(d_model, 5) def forward(self, num_ids, cat_ids, text_feats, mask): x = self.encoder(num_ids, cat_ids, text_feats) # 用 mask 做加权池化,而不是取 [CLS] x = self.transformer(x, src_key_padding_mask=mask) weights = (~mask).float().unsqueeze(-1) pooled = (x * weights).sum(1) / weights.sum(1) return torch.sigmoid(self.risk_head(pooled)), self.aux_head(pooled)这里用加权池化替代[CLS]token,是因为多源拼接序列没有天然的[CLS]位置,强行加一个反而增加参数量。加权池化按有效 token 平均,对缺失鲁棒。
3.2 多任务辅助头怎么设才不拖后腿
辅助任务选行业风险等级、是否涉诉、流水波动分档都行,但辅助任务标签必须和主任务相关且容易学。如果辅助任务太难,梯度会干扰主任务。我的做法是辅助任务损失权重从 0.1 起步,观察验证集主任务 AUC,如果下降就降到 0.05 或直接去掉。
损失函数用加权 BCE,正样本权重 = 负样本数 / 正样本数,再乘一个 0.8~1.2 的调节系数。不要用 focal loss 直接上,小微企业样本正负比通常 1:5 到 1:20,focal 的 gamma 调不好会让模型对困难样本过度关注,反而过拟合。
3.3 训练配置:学习率、warmup 与早停
学习率用 1e-4 到 3e-4,Transformer 部分用 1e-5 到 5e-5(如果文本编码器参与微调)。warmup 步数设总步数的 10%,之后余弦退火。batch size 32 或 64,太小梯度噪声大,太大泛化差。
早停监控验证集 AUC,patience 设 5~8 个 epoch。如果验证 AUC 波动超过 0.01,说明数据划分有问题,检查是否按时间划分。信贷数据必须按时间切分,随机切分会导致未来信息泄露,线下 AUC 虚高 0.05 以上是常事。
from torch.optim import AdamW from transformers import get_cosine_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=2e-4, weight_decay=0.01) total_steps = len(train_loader) * epochs scheduler = get_cosine_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps ) # 早停逻辑 best_auc, patience_counter = 0, 0 for epoch in range(epochs): train_one_epoch() val_auc = evaluate() if val_auc > best_auc + 0.001: best_auc, patience_counter = val_auc, 0 torch.save(model.state_dict(), 'best.pt') else: patience_counter += 1 if patience_counter >= 6: breakweight_decay设 0.01 是 Transformer 的常规值,但 embedding 层建议不加 weight decay,否则类别 embedding 会被过度压缩。可以在 optimizer 里分组参数,embedding 和 LayerNorm 的 weight_decay 设 0。
4. 避坑与排查:多源 Transformer 评分模型最常见的 5 个翻车点
4.1 现象:线下 AUC 0.85,上线后 KS 只有 0.2
原因:特征穿越或时间泄露。最常见的是文本特征里包含了放款后信息,或者标准化时用了全量数据的均值方差。解决:所有统计量只在训练集上拟合,然后应用到验证和测试集;文本特征加时点校验,feature_date >= apply_date的直接置空。上线前用最近 3 个月数据做一次样本外测试,KS 掉超过 30% 就回查特征。
4.2 现象:训练 loss 正常下降,但验证 AUC 从第 2 个 epoch 开始持续下跌
原因:过拟合,且多半是文本编码器参数量太大。解决:冻结文本编码器前 6 层,只微调后 2 层;或者直接把文本编码器输出当固定特征,不参与训练。同时把 dropout 从 0.1 提到 0.3,d_model从 256 降到 128。如果还不行,检查辅助任务权重是否过高。
4.3 现象:同一批数据多次训练,AUC 波动超过 0.03
原因:随机种子未固定,或者 batch 内正负样本比例波动大。解决:固定torch.manual_seed、numpy.random.seed、random.seed;用分层 batch sampler,保证每个 batch 正样本比例接近全局。另外,如果用了 dropout,推理时记得model.eval(),这个低级错误我见过不止一次。
4.4 现象:文本字段缺失率 40%,模型对缺失样本评分全部偏高
原因:缺失值填充方式有问题。如果用 0 填充文本 embedding,模型会学到「全 0 向量 = 低风险」的虚假关联。解决:缺失文本单独用一个可学习的[MISSING]embedding 替代,并在 attention mask 里标记为有效 token,让模型自己学缺失模式。数值缺失用中位数填充后加一个缺失指示特征。
4.5 现象:推理时显存溢出,batch size 降到 1 才能跑
原因:序列长度没截断,或者 attention mask 没生效导致 pad token 参与计算。解决:文本硬截断到 128 token,数值和类别固定 32 个;检查src_key_padding_mask的 shape 是否为[B, L],且 True 表示屏蔽。另外,推理时用torch.no_grad(),别忘。
5. 进阶技巧:用注意力权重做特征归因与模型监控
模型上线只是开始,信贷场景最怕的是模型黑匣子。Transformer 的注意力权重可以拿来做归因:对每个样本,取出最后一层跨源注意力,看数值 token、类别 token、文本 token 各自被关注了多少。如果某个样本的评分主要由文本 token 驱动,而文本又来自尽调笔记,那就要人工复核这条笔记的真实性。
具体做法:在 forward 里返回 attention weights,然后按来源类型聚合。
# 获取最后一层注意力权重 attn_weights = [] # 在 TransformerEncoderLayer 里 hook def hook_fn(module, input, output): attn_weights.append(output[1]) # 假设返回 attn # 聚合:数值段、类别段、文本段各自的平均注意力 num_attn = attn_weights[-1][:, :, :N_num].mean(dim=(1, 2)) cat_attn = attn_weights[-1][:, :, N_num:N_num+N_cat].mean(dim=(1, 2)) text_attn = attn_weights[-1][:, :, N_num+N_cat:].mean(dim=(1, 2))如果某个样本text_attn超过 0.6,且评分接近拒绝阈值,就触发人工复核。这个机制在实测中能抓住不少「文本描述与数值特征矛盾」的案例,比如流水很好但尽调笔记提到「近期有民间借贷纠纷」。
另一个技巧是用注意力权重做特征漂移监控。线上推理时,统计每个来源类型的平均注意力占比,如果文本注意力占比从 0.3 突然跳到 0.6,说明文本数据分布变了,可能是上游 OCR 或文本抽取出了问题。这个信号比监控输入特征均值更早发现异常。
我自己的习惯是:每次模型迭代,先跑一遍注意力归因,看 top 20 高注意力文本 token 是不是合理的词。如果出现「的」「了」这种停用词排前面,说明文本编码器没学好,得回去检查预训练模型选型或微调策略。这个检查花 10 分钟,能省掉后面几天的排查。希望帮到你。
本文还有配套的精品资源,点击获取