简介:面向金融风控、小微企业与信贷建模从业者及研究者的技术文档,围绕多源Transformer整合非结构化数据构建小微企业评分模型展开,从研究背景、传统评估方法不足,到Transformer注意力机制、多源数据编码、特征融合、模型实现与评估验证均有完整梳理。全文共42页,单文件约2.14MB,PDF支持目录跳转与大纲定位,文字、图表显示完整。内容涵盖结构化与非结构化数据预处理、文本与图像编码器、基于注意力机制的特征融合、风险评分模块与可解释性设计,并涉及模型实现代码、开发环境搭建、训练优化、超参数调优及准确率/F1/基尼系数/PSI等评估指标,可作为搭建同类评分模型的框架参考。目前已有72人学习,适合信贷风控领域专题研究、模型设计借鉴或课程报告写作时参考。
1. 多源Transformer做信贷评分:先从业务痛点理解这套方案
小微企业的信贷审批里,真正决定风险的信息大多不在申请表上。经营流水散在Excel导出里,合同文本藏在扫描件里,舆情和司法涉诉记录挂在外部数据接口里,客户经理往往要花半天去翻这些资料才能给出一个粗略判断。传统评分卡模型只能吃下几十个结构化字段,大量非结构化信息只能靠人工经验折算成虚拟变量,既不可复现,也难以校验。这个标题指向的信贷风险评估方案,核心就是把这些多源非结构化数据统一交给一个Transformer模型,让它从原始证据里直接学习风险线索并输出评分。这类方案通常以PDF立项材料的形式出现在评审会上,但它解决的问题非常具体,一句话说就是:把过去靠老师傅眼力的判断,变成可训练、可回测、可持续迭代的模型分数,尤其适合正在做小微评分卡升级或信贷数据治理的团队参考。
2. 多源Transformer的模型设计:从评分卡到序列化特征融合
2.1 为什么小微场景值得上Transformer:序列建模与多源信息融合的收益
传统评分卡在信贷领域统治了很多年,逻辑回归加WOE分箱至今仍是监管友好、解释性强的底线方案。但小微企业有一个特殊性:单户数据极度稀疏且碎片化。一个年流水千万级的小微商户,其基本户流水在银行的记录可能只有几十笔大额进出;其经营状况更多体现在代发工资记录、水电费缴纳、上下游合同文本、甚至法人名下其他企业的关联交易里。这些信息天然是序列、文本和稀疏类别的混合体,传统评分卡要么手工交叉特征,要么干脆丢掉。
Transformer在这里的实际价值,不是替代XGBoost,而是提供了一个统一的建模框架。用The Illustrated Transformer里对attention的理解方式去类比:它本质上是在替每一个输入片段检索其他来源的证据。一笔异常的当日大额转出,如果没有同期合同文本、发票流水的相互印证,单独看只是孤立事件;通过多头注意力,模型可以自己学习“高额流水波动”与“涉诉文本出现”之间的联动信号,而不是靠建模人员预先猜一个交互项。
需要提醒的是,这个场景下序列顺序和业务时间高度相关。Transformer的位置编码会被用来表达“距今天第几个月”这类相对信息,而不是像文本那样表达“第几个字”。从业务角度,这比LSTM更友好,因为attention可以等权看到一年前的季度趋势和最近一个月的突变,LSTM则更容易被近期信息淹没。
2.2 多源编码层:数值特征、序列特征和文本特征如何统一到一个向量空间
多源信息融合的第一步,是把不同来源的数据转成同一序列里的不同Token。我在实际项目里一般把输入分成三类:
- 数值特征:法人年龄、注册资本、员工人数、近N月流水均值和方差等。
- 类别特征:行业分类、区域代码、是否首贷、担保方式等。
- 序列/文本特征:按时间排序的月度经营流水、历史借贷记录、外部文本片段。
数值特征经过标准化后,用一个线性层映射到d_model维;类别特征先做embedding,再拼接后映射;文本片段用预训练模型编码或直接用词表embedding。三类向量统一后拼成一个长序列,前面再放一个类似BERT里的融合Token(CLS Token),用于汇总全序列信息输出最终评分。下面是我常用的模型骨架结构:
import torch import torch.nn as nn class MultiSourceTransformerScore(nn.Module): def __init__( self, d_model=128, nhead=8, num_layers=4, max_seq_len=256, num_numeric=16, cat_cardinality_list=[20, 30, 15] ): super().__init__() self.d_model = d_model # 数值特征:标准化后过一层线性映射 self.numeric_proj = nn.Linear(num_numeric, d_model) # 类别特征:每个字段各自embedding,再拼接降维 self.cat_embeddings = nn.ModuleList( [nn.Embedding(cardinality, 32) for cardinality in cat_cardinality_list] ) self.cat_proj = nn.Linear(32 * len(cat_cardinality_list), d_model) # 文本特征:这里用可训练embedding做示意,实际可替换为预训练编码器 self.text_embedding = nn.Embedding(vocab_size, d_model) # 可学习位置编码:让模型自己学“时间距离”的表达 self.position_encoding = nn.Parameter( torch.randn(1, max_seq_len, d_model) * 0.02 ) # 融合token self.cls_token = nn.Parameter(torch.randn(1, 1, d_model) * 0.02) encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=d_model * 4, dropout=0.1, batch_first=True ) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) # 输出分数 self.score_head = nn.Sequential( nn.Linear(d_model, 32), nn.ReLU(), nn.Dropout(0.1), nn.Linear(32, 1) ) def forward(self, numeric, categorical, text_tokens): # numeric: (B, num_numeric) num_vec = self.numeric_proj(numeric).unsqueeze(1) # categorical: (B, len(cat_cardinality_list)) cat_embs = [ emb(categorical[:, i]) for i, emb in enumerate(self.cat_embeddings) ] cat_vec = torch.cat(cat_embs, dim=-1) # (B, 32 * num_cat_fields) cat_vec = self.cat_proj(cat_vec).unsqueeze(1) # text_tokens: (B, T) text_vec = self.text_embedding(text_tokens) # (B, T, d_model) batch_size = numeric.size(0) cls_vec = self.cls_token.expand(batch_size, 1, self.d_model) seq = torch.cat([cls_vec, num_vec, cat_vec, text_vec], dim=1) seq = seq + self.position_encoding[:, :seq.size(1), :] encoded = self.encoder(seq) score = self.score_head(encoded[:, 0, :]) return score几个关键参数说明:d_model取128而不是文本任务里常用的768,因为信贷样本量通常只有几万到几十万条,大模型很容易过拟合。nhead=8搭配128维是常见配置,每个头负责16维子空间,可以学到不同来源之间的微弱相关性。num_layers我建议从4层起步,层数再加深在样本量不足时收益会衰减,还显著增加Transformer架构模型参数计算量和训练时长。embedding维度统一到d_model的目的是让后续位置编码和attention计算不需要额外做维度匹配。
2.3 跨源注意力融合层:为什么CLS Token比把所有向量平均更可靠
模型把三种来源的向量拼成一个序列后,交给Transformer编码器。这里有一个常被忽略的细节:输出时不能把整条序列做全局平均池化。因为序列里不同来源的长度天然不平衡——文本片段可能有几十个Token,而数值和类别特征只有两三个Token。如果做平均池化,文本片段会主导最终表示,数值信号被稀释。CLS Token的方式更合理:它本身不携带具体业务信息,但可以通过attention机制按需“读取”所有来源,相当于模型自己决定当前样本该重点关注流水还是文本。
这个设计在实际项目里还有一个额外的好处:便于做特征归因。最终输出只依赖CLS Token位置,我们可以在推理时对CLS位置的attention分数做加权求和,粗略估算每个输入来源对分数的贡献比例。虽然attention权重不能直接当作严谨的解释性证据,但在给业务方做初版评审时,能快速说明模型不是只盯着某一个字段。
3. 非结构化数据的处理管线:从原始流水到可训练样本
3.1 三类高价值非结构化数据及各自动手难度对比
信贷风险评估里,非结构化数据远不止“文本”一种。我通常把它们按结构复杂度分成三类:
- 时序型:银行流水、税票数据、开票记录。每一条是一行带时间戳和金额的明细,问题在于粒度细、噪音大、客户间记录条数差异悬殊。
- 文本型:经营合同、财务说明、涉诉信息、舆情新闻。长短差异极大,短则几十字,长则几千字。
- 登记型:工商变更记录、关联企业信息。本质上是稀疏的高基数类别,直接one-hot会制造大量无效维度。
数据处理的最终目标,是把这三类统一成固定长度的序列样本。注意“定长”很关键,Transformer虽然能接受动态长度,但批量训练时pad太多会浪费算力,更麻烦的是线上推理和训练时长度不一致会导致结果不可比。下表是三类数据的建议处理策略:
| 数据类型 | 常见原始格式 | 处理策略 | 主要风险 |
|---|---|---|---|
| 经营流水 | CSV/Excel明细 | 按自然月聚合成窗口特征序列 | 时间口径混乱、隐私字段未脱敏 |
| 文本资料 | PDF/扫描件/网页正文 | 固定步长切片后Tokenize | 切片截断关键信息 |
| 关联登记信息 | 接口JSON/清单 | Embedding+缺失掩码 | 高基数稀疏、口径不稳定 |
注意:所有原始数据进入特征工程之前,必须先做脱敏。身份证号、银行卡号、手机号要么整体掩码,要么替换为哈希ID。模型记忆明文证件号的后果是上线后无法通过合规评审。
3.2 把流水变成时间窗口序列:一个可复现的窗口聚合实现
流水数据在信贷模型里是最可信的硬信息,但直接用明细会让模型面对几千个时间步,训练效率和效果都很差。常见做法是按月聚合成序列,再做滑动窗口特征。比如把过去36个月的月度统计量作为序列,每个元素是当月总流入、总流出、交易笔数、单笔最大金额等。这样既保留了时间趋势,又把序列长度控制在几十步以内。
import pandas as pd def build_monthly_flow_features(flow_df, windows=[3, 6, 12]): # flow_df必须包含customer_id, trade_time, amount, direction # direction: 'in'为流入,'out'为流出 # 1. 截取年月字段并汇总 flow_df["month"] = flow_df["trade_time"].astype(str).str[:6] monthly_agg = ( flow_df.groupby(["customer_id", "month", "direction"])["amount"] .agg(["sum", "count"]) .reset_index() ) # 2. 把流入流出转成宽表 monthly_pivot = monthly_agg.pivot_table( index=["customer_id", "month"], columns="direction", values=["sum", "count"], aggfunc="first", fill_value=0, ).reset_index() monthly_pivot.columns = [ "customer_id", "month", "in_sum", "in_count", "out_sum", "out_count", ] # 3. 按客户逐月排序,再做滚动窗口 monthly_pivot = monthly_pivot.sort_values( ["customer_id", "month"], ascending=[True, True] ) for w in windows: col_name = f"in_sum_{w}m" monthly_pivot[col_name] = ( monthly_pivot.groupby("customer_id")["in_sum"] .apply(lambda s: s.rolling(w, min_periods=1).sum()) .reset_index(level=0, drop=True) ) return monthly_pivot这段代码里有几个细节值得展开。第一步把时间戳转成“YYYYMM”字符串月份,是为了规避跨年排序的坑,比直接取month字段更安全。第三步里rolling的窗口宽度是以“月”为单位的,窗口取3、6、12分别对应近一季度、半年和一年,这三个跨度在信贷模型里分别捕捉短期流动性波动、中期经营趋势和长期稳定性。min_periods=1是必须的,否则新开户不足3个月的客户直接产出NaN,模型样本会大幅减少。
实际业务里,大部分客户的流水记录并不连续。有客户可能连续5个月没有交易,rolling窗口会把0或空值一并计入,这其实是有效信息——“账户长期没有经营活动”本身就是一个风险信号。但如果客户整段缺失是因为数据源漏传,那就会形成误导。所以我在生产环境里会额外记录一个“覆盖月数”字段,统计该客户窗口内有实际流水的月份占比,供模型区分“真沉寂”与“无数据”。
3.3 文本切片与稀疏特征归一化:补齐多源输入的最后一环
文本处理我一般不用整篇输入。原因很现实:一份30页的购销合同,真正与风险相关的可能只有付款条款、违约条款和甲乙方名称这几个片段;而一条200字的裁判文书摘要,可能整段都是关键信息。常见做法是把文本按固定步长切成多个片段,每个片段单独编码后作为序列中的多个Token输入模型。这样既保留了局部语义,又不让长文本挤压数值特征的位置。
def encode_text_segments(text, tokenizer, max_segments=4, max_len=128): # 统一处理缺失文本 if text is None or len(str(text).strip()) == 0: return [tokenizer.pad_token_id] * (max_segments * max_len) text = str(text) # 按步长均匀切分,而不是按句子边界切分 segments = [] step = max(len(text) // max_segments, 1) for i in range(0, len(text), step): segment = text[i:i + max_len] segments.append(segment) if len(segments) >= max_segments: break # 逐段tokenize并拼接 tokens = [] for seg in segments: seg_tokens = tokenizer.encode(seg, max_length=max_len, truncation=True) tokens.extend(seg_tokens) return tokens为什么要均匀切而不是按句子切?因为业务文本的句子长度和语义密度不固定。按句子切的话,有的样本只产出2个片段,有的产出15个片段,导致序列长度不可控;均匀切配合max_segments上限,可以严格保证每一个样本文本部分长度不超过预设值。这样带来的收益是训练期shape稳定、线上推理延迟可预期。代价是可能会切断一句话,但后续的Transformer attention仍然能看到相邻片段之间的语义关联,影响远小于想象。
数值特征的预处理相对简单,但有一个容易翻车的地方:缺失值的填充不能用全数据集均值一次算完。在信贷场景里,建模样本是跨时间累积的,用未来样本的均值填充历史样本会导致前几个月的数据被“偷看”。正确的做法是先用建模时点之前的数据统计均值、标准差,再对后续所有样本统一使用这套固定统计量。
def make_sparse_numeric_input(row, num_cols, num_stats): num_vec = [] for col in num_cols: val = row[col] if pd.isna(val): # 固定用训练期前段数据的统计量填充 val = num_stats[col]["median"] num_vec.append((val - num_stats[col]["mean"]) / (num_stats[col]["std"] + 1e-6)) return np.array(num_vec, dtype=np.float32)这个函数里的num_stats必须在模型训练开始前提前算好并保存,线上推理时直接加载同一份文件。很多团队在生产环境翻车,都是因为训练特征工程和线上特征工程各写了一套代码,统计口径对不上。我的习惯是:特征计算函数写成同一个脚本文件,训练和推理共用,不接受任何“线上稍微优化一下”的临时改动。
4. 训练与评估:一份可直接复用的配置
4.1 样本设计与损失函数:小微数据集上Focal Loss比BCE更合用
小微信贷样本有两个特性:总量不大,正样本稀疏。一个真实业务里可能只有4%~8%的客户最终发生逾期,直接训练BCE会让模型只为学到“全部预测为0”就能达到很低的loss。常见方案有两种:给正样本加重权重,或者使用Focal Loss。Focal Loss的好处是它对“已经分得很清楚”的样本降低关注,把模型能力集中到难分样本上,这在风险分布长尾明显的场景里比较实用。
import torch import torch.nn.functional as F def focal_loss(logits, labels, alpha=0.75, gamma=2.0): # logits: 模型输出,未经过sigmoid # labels: 0/1,1代表逾期或违约 bce = F.binary_cross_entropy_with_logits( logits.view(-1), labels.float(), reduction="none" ) pt = torch.exp(-bce) # alpha_t: 正样本权重高 alpha_t = torch.where(labels == 1, alpha, 1 - alpha) loss = alpha_t * (1 - pt) ** gamma * bce return loss.mean()alpha=0.75在这里的含义是:当模型预测一个正样本的概率很低时,它的损失会被放大0.75的相对倍数;负样本则缩小到0.25倍。gamma=2.0控制聚焦强度,gamma越大,对易分样本的抑制越强。这个值不建议超过2.5,否则模型会变得过于激进,把大量低风险客户也判为高风险。如果你用的是带class_weight的BCE,效果通常也不会差,但Focal Loss在小样本、强类别不平衡时更稳。
4.2 训练循环、早停与超参速查
训练配置直接影响能不能收束到一个可用的分数。Transformer训练里最常见的三个问题是:梯度爆炸、过拟合、验证指标震荡。前两个问题分别用梯度裁剪和早停解决,第三个问题需要用验证集AUC配合patience机制来容忍波动。
def train_model(model, train_loader, val_loader, epochs=20, patience=5): optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) best_auc = 0.0 wait = 0 for epoch in range(epochs): model.train() total_loss = 0.0 for numeric, categorical, text_tokens, labels in train_loader: logits = model(numeric, categorical, text_tokens) loss = focal_loss(logits, labels) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss += loss.item() * len(labels) val_auc = evaluate_auc(model, val_loader) if val_auc > best_auc: best_auc = val_auc wait = 0 torch.save(model.state_dict(), "best_model.pt") else: wait += 1 if wait >= patience: breakclip_grad_norm设置为1.0是文本相关模型的标准选择。因为文本embedding层在训练初期梯度变化剧烈,如果不裁剪,两三个epoch后数值特征部分的学习效果就会被摧毁。学习率设1e-4而不是常规的3e-5,是因为这里并没有加载大规模预训练模型权重,整个模型从头训练,稍微大一点的学习率可以加速收敛。
常用超参我整理如下:
| 参数 | 推荐值 | 参考理由 |
|---|---|---|
| d_model | 128~192 | 样本少,过大必过拟合 |
| num_layers | 4~6 | 超过6层在小数据上收益为负 |
| dropout | 0.2~0.3 | 比文本任务更大,防过拟合 |
| batch_size | 64~128 | 吞吐与收敛稳定性折中 |
| lr | 1e-4 | AdamW配合线性warmup |
| warmup_steps | 500~1000 | 防止初期loss剧烈跳动 |
| patience | 5 | 验证集AUC连续5轮不涨即停 |
4.3 评估指标不能只看AUC:KS、捕获率和时间外验证
很多团队验收模型只汇报AUC,这是一个很有误导性的习惯。在小微数据集里,AUC对样本不均衡不敏感,一个4万样本只有1800个正样本的实验,AUC可能达到0.82,但实际放到业务里,模型在top 10%的客户中只捕获到30%的坏账,资损并没有显著下降。评估模型应该至少看三组数字:
| 指标 | 含义 | 什么时候用 |
|---|---|---|
| AUC | 随机正样本比随机负样本得分高的概率 | 初筛模型是否有效 |
| KS | 好坏样本累计分布最大差距 | 判断分数可分性 |
| Top N捕获率 | 分数最高N%的客户里坏账占比 | 评估落地资金效果 |
| 时间外AUC | 用后一时间段数据验证 | 判断过拟合程度 |
我自己的习惯是,建模时固定训练数据截至日期,比如用2022和2023年数据训练,2024年上半年作为时间外验证集。如果时间外AUC比训练集AUC低超过0.1,说明模型已经记住了训练期的客群特殊性,上线后大概率会快速衰减。
5. 信贷评分Transformer落地避坑:5个高频现场问题
5.1 验证集AUC高达0.92,回放却跑不过规则模型
这是一个极其常见的翻车现场。现象上,模型在回测集上表现完美,但把分数套到历史放款样本上,业绩反而不如原有评分卡。问题几乎都出在特征泄漏上。最常见的泄漏有三种:把标签期信息混入特征期,比如用“当前是否逾期”反推客户过去一直有潜在风险;把未来流水计入特征窗口;使用了数据源里已经包含结果信息的字段。解决的核心手段是给所有特征设置一个严格的as_of时间口径,模型在预测t时刻的未来风险时,只能用t时刻之前已产生的数据。建议把特征工程函数改造成接收as_of_date参数的形式,拒绝任何不带截止日期的特征计算。
5.2 上线后模型分数漂移,拒绝率悄悄上升
模型上线一个月后,风控同事反馈审批通过率明显下降。分数分布整体向右偏移,原本60%的通过线只有45%的客户能过。原因通常是客群结构或外部数据源口径变化,比如经营流水特征字段的统计方式被数据源方改了。解决的常规做法是:上线前保存一份特征分布快照,上线后每周计算PSI。单个特征PSI超过0.1标记观察,超过0.25触发重训。另外要特别关注类别特征的新取值——比如行业分类字段出现训练集里没见过的类别,embedding会直接落到未知索引,分数会不可控地飘。
5.3 注意力可视化很漂亮,但业务方完全不认可
拿多头注意力的热力图去给业务方解释模型,是我认为最不值得做的事情。看起来某些头确实关注了流水和合同的交叉位置,但这只能说明模型学到了一定模式,无法证明它是在用业务逻辑决策。业务方真正关心的是“到底哪些数据让这个客户的评分变了”。解决思路是用归因方法替代注意力可视化。对每一个待解释样本,将某一来源的特征整体替换为训练集均值,重复推理并计算分数差异,这个差值比注意力权重更有说服力。文本部分同理,把某一片段mask掉再看预测变化,定位关键句。
5.4 多源数据对齐错位导致特征维度不一致
训练时好好的,线上推理却报维度错误。原因通常是对齐逻辑只保证了“数量相同”,没保证“顺序一致”。举例来说,文本片段列表如果按爬虫返回顺序排列,不同数据源的更新频率会打乱顺序,同一个客户的第3个片段在训练集里是合同结尾,在线上可能是合同开头。解决的方法是在特征生产阶段就固定排序键:流水按月、文本按文档类型加段落序号、登记信息按更新日期。所有特征进入模型前统一走同一个序列化函数,不允许在线推理时临时拼装。
5.5 样本只有几万条,硬训一个深层Transformer注定过拟合
新手跑Transformer模型最容易犯的错,是把文本领域的大模型配置照搬过来。d_model=768、num_layers=12下去,几万条样本不到10个epoch训练集loss就归零了,验证集AUC却纹丝不动。解决的核心是大幅压缩模型容量:d_model降到128,层数降到4,embedding不要从随机初始化开始训,直接用预训练模型输出做特征缓存。如果业务数据不允许外部调用预训练模型,还有一个折中方案:把文本部分单独用传统方法抽取TF-IDF或主题分布特征,只把数值和序列部分交给Transformer,效果往往接近但稳定得多。
6. 上线前最值得花时间的验证方法
6.1 用影子回放测算模型相对于规则的增益
模型评审最有说服力的材料,不是AUC曲线,而是一张回放对比表。逻辑很简单:拿历史放款样本(即当时通过审批、且现在已经有表现结果的客户),用新模型重新打分,观察模型分数最高的Top N客户里逾期率提升了多少。
import pandas as pd def shadow_replay(df, score_col="score", label_col="is_default", top_rates=[0.05, 0.1, 0.2]): df = df.sort_values(score_col, ascending=False).reset_index(drop=True) base_rate = df[label_col].mean() records = [] for rate in top_rates: cut = int(len(df) * rate) top_df = df.loc[:cut, [label_col]] hit_rate = top_df[label_col].mean() records.append({ "top_rate": rate, "bad_rate": hit_rate, "lift": hit_rate / base_rate }) return pd.DataFrame(records)注意回放有一个天然局限,它只能评估“当时被准入”的客户,对当初被规则拒绝的客户无法获得真实表现。这就是所谓的拒绝推断问题,业界常用做法是给被拒客户打上推测标签,或做两阶段模型修正,但没有任何方法能完全还原真实结果。评审时主动承认这个局限,比含糊其辞更容易获得业务方信任。
6.2 给业务方留两个可解释口径再谈上线
模型最终是否上线,往往不取决于技术指标,而取决于业务方是否敢为它背书。我做这个方向最深刻的教训是:不要试图让业务方理解attention,而是要给他们两个可审查的数字。第一个是“拒绝解析”:把新模型拒绝名单中分数最高的前20个客户逐一出报告,说明是哪些来源的数据导致了评分下降;第二个是“通过对比”:对同一批存量客户,展示新模型评分与旧评分卡结果的差异,把差异原因归纳为几条可解释的业务规律。这两件事做完,评审会基本就稳了。
另一个建议是设定最小监控项:每日分数均值、Top 10%逾期率、单个特征PSI预警。这三个指标不需要复杂报表系统,一个定时脚本加企业微信推送就够了。希望这些经验对准备做多源Transformer评分方案的人有帮助,少走几趟我已经走过的弯路。
本文还有配套的精品资源,点击获取