尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

多源Transformer信贷评分:小微授信从三张表到文本融合的落地实践

多源Transformer信贷评分:小微授信从三张表到文本融合的落地实践 简介金融风控从业者、数据科学研究者和量化分析人员可借助这份PDF系统梳理多源Transformer整合非结构化数据的小微企业信贷风险评分模型内容涵盖从研究背景、风险来源到Transformer注意力机制再到多源数据编码、特征融合、模型训练与评估验证的完整链路。全文共42页目录结构清晰支持章节跳转与大纲定位方便按需查阅。压缩包共包含1个PDF文件大小仅2.14MB轻量便携。目前已有72人学习浏览内容包括传统评估方法局限、非结构化数据应用价值、Transformer模型结构、注意力机制、数据预处理、多源编码器、风险评分模块及可解释性设计等图表与文字完整清晰。读者能借此系统掌握结构化与非结构化数据融合建模的核心思路以及Transformer在信贷评分中的实现步骤与评估指标适合中高级风控建模人员与NLP技术实践者参考。1. 多源Transformer信贷评分小微授信从“三张表”到“一段文本”的尺度跨越小微企业信贷风险评估一直是个没被模型彻底解决的场景营业执照信息有开票流水有水电煤气有裁判文书和经营场所信息也有但这些数据分散在多个源头形态完全不同。传统评分卡只能取其中几列数值剩下大量的非结构化数据被直接扔掉了。多源Transformer想做的事就是把文本、时序、坐标和字段放在同一个序列里让模型自己做跨源交互——这家小微有没有真实经营、有没有纠纷风险、经营趋势是不是在恶化都由模型从原始证据里读出来。这个方向对三类人最有价值正在做小微线上化审批的风控团队想从评分卡切到机器学习模型的算法工程师以及手里有大量文本和流水数据但不知道怎么下手的机构。Transformer在这里不是用来做语义理解的通用大模型而是一个多源融合编码器它的输入不是一句话而是一长串来自不同数据源的特征块。本文按一条可复现的路径展开多源仓库怎么建、非结构化数据怎么编码、小模型参数怎么定、训练目标怎么切最后把输出映射成审批团队看得懂的分数。2. 多源数据仓库与特征嵌入把税务流水、文本和位置信息装进同一张索引表2.1 多源仓库接口配置先对齐统一客户标识再谈特征多源数据仓库接口配置是整套方案的地基但也是最容易被低估的一段。实际项目里经常出现这种情况税务流水里的主体叫“某市某区商贸有限公司”法院文书里的被告是“某市某区商贸有限公司法定代表人王某某”核心企业的台账里又一个“CUST_25813”。如果没有一张主数据映射表后面所有特征都是各说各话。我对惯用的配置方式分三步第一步以统一社会信用代码作为小微主体的强主键第二步往下挂自然人维度也就是法人代表、股东、实控人这类关联人第三步建立“一码一库”的映射表外部源全部通过主键关联进来内部字段统一命名。import pandas as pd # 假设三个源头已经落在数仓里 ax pd.read_csv(tax_invoice.csv, dtypestr) # 开票流水 court pd.read_parquet(court_text.parquet) # 裁判文书文本 osm pd.read_parquet(store_location.parquet) # 经营场所坐标 # 主数据映射统一信用代码 - 内部客户号 id_map pd.read_parquet(id_mapping.parquet)[[credit_code, inner_cust_id]] def attach_uid(source_df, source_key): df source_df.merge(id_map, left_onsource_key, right_oncredit_code, howleft) null_ratio df[inner_cust_id].isna().mean() print(f{source_key} 映射缺失比例: {null_ratio:.3%}) return df tax attach_uid(ax, credit_code) legal attach_uid(court, credit_code) poi attach_uid(osm, credit_code)上面这段脚本的关键意图不是跑通而是验证。合并之后要优先打印映射缺失比例缺失超过一成就要回头查主数据维护流程而不是直接继续做特征。还有一种容易被忽略的坑同一企业改名或注销后统一信用代码保持不变但工商注册信息里的企业名称会变这会导致文本检索时匹配不到。标准做法是名称字段不要参与主键只保留在特征里。2.2 非结构化数据编码文本向量、用量曲线与坐标序列的三类表示非结构化数据的形态可以拆成三类文本、时序、空间轨迹。小微企业场景里这三类的信息密度完全不同编码手段也要分开。文本类最常见的是裁判文书、经营异常名录、行政处罚决定书。这些文本短则几百字长则几千字适合用中文预训练模型编码成向量。实践里我不会直接拿一个生活领域的中文BERT就来处理判决书而是先看训练语料里有没有法律文书类的增量预训练模型。如果机构内部没有就退一步用通用的中文RoBERTa但必须做长度截断和池化策略。时序类主要是开票流水、用水用电、社保人数这类按月度或按周记录的量。它们本质上不是文本而是一串数值。最简单的表示是滑窗统计量最近6个月的均值、方差、最小值、末月值与历史均值的比值。激进一点的方案是像处理时间序列一样把最近12个点作为12个token输入Transformer。我建议从统计量起步因为小微企业的流水稀疏度极高很多月份是零值硬套时序模型容易学到大量假规律。空间轨迹类以经营场所坐标、物流路径、POS机位置为代表。真正有意义的往往不是单个坐标而是轨迹的聚散程度。常见的做法是把坐标网格化成500米乘500米的格子计算到主营仓库和注册地址的距离以及不同时段内出现的格子数量。import numpy as np from geopy.distance import distance def build_spatial_features(points): points: list of (lat, lon)按时间排序 if len(points) 2: return [0, 0, 0] center np.mean(points, axis0) dist_dev np.mean([distance(center, p).km for p in points]) radius dist_dev spread_count len(set(np.round(np.array(points), 2).tobytes() for _ in [0])) # 夜间出现比例小微企业夜间经营权重越高越像实体经营 night_ratio sum(1 for p in points if 22 p[2] 23 or 0 p[2] 5) return [radius, dist_dev, night_ratio / max(len(points), 1)]坐标特征务必在源头把质量校验做掉空值、单点跳变、行政区域外漂移都要过滤。这些特征只是编码层的输入后面还要拼到同一个训练序列里所以必须在统一特征表落库。2.3 用最小脚本把结构化字段和文本向量拼成Transformer序列Transformer的输入是一个序列序列里每一个token都可以表示一个来源特征块。常见做法是把结构化数值标准化后直接作为token把文本编码成向量后作为一个token把时序统计量拆成多个token每个token附带一个“来源标识”。来源标识的作用是让模型在attention计算时知道哪个token来自文本、哪个token来自税务。import torch import numpy as np from transformers import AutoTokenizer, AutoModel tokenizer AutoTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext) encoder AutoModel.from_pretrained(hfl/chinese-roberta-wwm-ext) def encode_text(text: str) - np.ndarray: inputs tokenizer(text, max_length256, truncationTrue, return_tensorspt) with torch.no_grad(): hidden encoder(**inputs).last_hidden_state # [1, seq_len, hidden] vector hidden.mean(dim1).squeeze(0).numpy() # [hidden] return vector # 特征表每个客户的左侧是数值特征右侧是文本向量 def build_transformer_inputs(feat_df, text_dict, numeric_cols): numeric_data feat_df[numeric_cols].to_numpy(dtypenp.float32) mean numeric_data.mean(axis0) std numeric_data.std(axis0) 1e-6 numeric_data (numeric_data - mean) / std rows [] for i, cust_id in enumerate(feat_df[cust_id]): text_vec encode_text(text_dict.get(cust_id, )) src_ids [0] * numeric_data.shape[1] [1] * text_vec.shape[0] token_embed np.concatenate([numeric_data[i], text_vec], axis0) rows.append((np.array(token_embed, dtypenp.float32), np.array(src_ids, dtypenp.int64))) return rows这段代码的核心在最后一步数值特征和文本向量各自作为一个段接入同一个序列来源标识也一并传进去。数值特征标准化时用训练集的均值和标准差不要用全量数据fit否则验证集和线上推理会出现信息泄漏。文本编码模型冻结在特征提取阶段不参与后续Transformer训练这样在推理和训练时的文本向量是完全一致的避免每次都需要重跑BERT。3. 小型Transformer编码器把多源特征融合成一串“带位置”的序列3.1 多头注意力为什么适合多源信息融合多头注意力处理多源数据的独特价值在于它不预设“哪个源重要”。传统评分卡把税务流水和裁判文书强制分到两个逻辑回归项里再由人拍权重而Transformer的query和key来自不同位置模型有机会学到“开票流水突然断了同时经营地址又出现变更”这类组合信号。这是单一模型做交互的优势。比如裁判文书文本向量位于序列第15位开票流水统计量位于第2位普通全连接网络需要等这两段拼到一起之后才能交互而且中间必须经过足够宽的隐层Transformer则直接通过attention计算全局任意两个token之间的相关度。每个head可以关注不同维度有的head关注法人代表变更和司法诉讼另一个head关注水电用量与开票流水的背离最后在addnorm层叠加。这种特性的前提是序列长度别太长。小微场景的token总量通常在32到64之间远小于文本模型的512计算代价可控也可以更放心地提高head数量获取多视角。3.2 d_model、head数与位置编码怎么设才不亏小规模表格型数据不需要复刻BERT-large。地基参数建议控制在百万级参数量内这个量级用小几千条样本就能训起来同时能享受Transformer的交互能力。需要说明的是这里的参数设置经验来自多轮对比。我建议起始配置如下参数名建议值说明d_model64 或 96向量维度过大提升有限还容易过拟合nhead4d_model必须能被nhead整除num_layers2小微企业数据分两层足够了dim_feedforwardd_model * 2不需要扩到4倍减少参数压力dropout0.1训练样本少时可用0.15但不建议更高max_len32 或 48特征块排列总长度batch_size256 或 512梯度更平滑位置编码在文本任务是表示词序在多源场景里更多是表示“这个token来自哪个源、在哪个时间窗口”。我采用的是一种可学习的位置向量每个特征块有一个source embedding加上一个位置embedding两个向量相加。这样模型既知道当前token是税务流水又知道它是流水序列中的第几个月。import torch import torch.nn as nn class MultiSourceEncoder(nn.Module): def __init__(self, feat_dim, d_model64, nhead4, num_layers2, max_len48, num_sources6, dropout0.1): super().__init__() self.num_sources num_sources self.max_len max_len # 数值特征线性变换到d_model self.proj nn.Linear(feat_dim, d_model) # 两个可学习嵌入来源id和位置id self.source_emb nn.Embedding(num_sources, d_model) self.pos_emb nn.Embedding(max_len, d_model) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforwardd_model * 2, dropoutdropout, batch_firstTrue, activationgelu ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.head nn.Sequential( nn.LayerNorm(d_model), nn.Linear(d_model, 1), ) def forward(self, x, src_ids, key_padding_maskNone): x: [batch, seq_len, feat_dim] src_ids: [batch, seq_len] key_padding_mask: [batch, seq_len], True代表该token要遮住 seq_len x.size(1) h self.proj(x) # 来源嵌入 src_vec self.source_emb(src_ids) # 位置编码 pos_ids torch.arange(seq_len, devicex.device).unsqueeze(0) pos_vec self.pos_emb(pos_ids) h h src_vec pos_vec # 其他源缺失时用mask遮掉防止空白token污染attention h self.encoder(h, src_key_padding_maskkey_padding_mask) pooled h.mean(dim1) logits self.head(pooled).squeeze(-1) return logits这个编码器的核心设计有三个。第一个是source embedding来源特征块有自己独立的嵌入向量初始随机但在训练中会学会“司法文本”和“开票流水”在语义空间里的相对关系。第二个是位置embedding同一来源的不同时间点可以被区分。第三个是padding mask如果一个客户没有水电数据对应token的key_padding_mask全部置Trueattention计算时跳过这些位置而不是把空值当作有效信息学进去。3.3 训练中的关键超参数学习率、warmup与梯度裁剪多源Transformer在小微评分上容易出现的训练问题是震荡和过拟合。数据量不大时学习率设太高参数会在几个epoch之后剧烈波动loss曲线看似下降验证指标却原地踏步。常见做法是先跑一个200步左右的warmup让学习率从0线性升到峰值再按余弦衰减。峰值学习率建议在1e-4到3e-4之间比普通分类模型的BERT微调要低一些。梯度裁剪对这种小模型同样重要。数据中偶尔会出现异常特征块比如某家企业的开票流水统计量因为重复导入数值巨大经过投影层后产生梯度爆炸。设一个max_grad_norm1.0的裁剪能避免单条样本破坏整个模型。我一般会记录三类指标来观察模型是否真的学习到了多源交互训练集loss、验证集AUC、每个epoch后评分分布的PSI值。如果PSI在训练中就大幅漂移说明模型对某些特征块的依赖过强要调整dropout或对特征做更强标准化。4. 训练目标与评分校准把违约看成“排序校准”而不是二分类4.1 观察期与表现期怎么切才不会把“下个月就逾期”的客户标成好人信贷模型的标签切分原理并不复杂但落地时很容易出错。观察期是生成特征的时间窗口表现期是判断是否违约的窗口。一个小微企业在2024年6月申请贷款我们用截至2024年5月的数据构造特征然后观察从2024年6月到2024年12月这6个月内它有没有逾期。如果这个客户在5月已经逾期应该直接排除或单独标记否则特征和标签不在同一个时间维度。样本时间窗设置代码可以拼在数据准备阶段。import pandas as pd # feature_start: 观察期起点, feature_end: 观察期终点 df[obs_end] pd.to_datetime(df[loan_date]) - pd.Timedelta(days1) df[perf_start] pd.to_datetime(df[loan_date]) df[perf_end] pd.to_datetime(df[loan_date]) pd.Timedelta(days180) # 样本筛选表现期内过期超过30天的定义为违约 df[bad] ((df[overdue_days] 30) (df[overdue_date] df[perf_start]) (df[overdue_date] df[perf_end])).astype(int)这段代码的关键是overdue_days不是看最终逾期天数而是要判断逾期发生时间是否落在表现期内。如果客户在特征窗口已经逾期30天以上这笔单子已经不是常规授信决策范围应该打上存量风险标签或排除。小微企业的表现期不建议用12个月建议用6个月。小微客户生命周期短资金周转快很多风险在3到6个月就会暴露过长的表现期会导致样本量锐减而且模型上线后很难快速迭代样本批次。4.2 正负样本失衡用focal loss替换交叉熵违约客户在小微群体里通常只有3%到8%的比例。直接用二分类交叉熵会造成模型学会“把所有样本都预测成好客户”AUC看着不差实际决策毫无价值。处理方式有两个层面样本层面可以做欠采样或负样本加权损失函数层面可以用focal loss。focal loss的核心是降低易分样本的权重让模型关注那些难以区分但是真正会违约的客户。多源Transformer场景里很多违约客户恰恰是那些特征分布偏离正常群体的人比如突然断票、司法诉讼、经营地变更这些样本正是focal loss最关注的。def focal_loss(logits, targets, alpha0.25, gamma2.0): probs torch.sigmoid(logits) pt torch.where(targets 1, probs, 1 - probs) focal_weight (1 - pt) ** gamma base_loss torch.nn.functional.binary_cross_entropy_with_logits( logits, targets, reductionnone ) alpha_t torch.where(targets 1, torch.full_like(targets, alpha), 1 - alpha) return (alpha_t * focal_weight * base_loss).mean()alpha控制正样本的基线权重通常取0.25或0.3表示即使难易程度相同模型也偏向正样本。gamma取2.0是默认经验值如果模型对违约客户的召回不足可以把gamma降到1.5或1.0让所有正样本的损失贡献更平均。需要强调的是focal loss不能完全替代采样。如果训练集只有几万个样本且坏样本只有几百个建议先做负样本的SMOTE或直接提高坏样本的重复采样次数。4.3 从概率到0-1000分保序回归让输出对齐审批习惯模型输出的是一个logits或概率但审批团队需要的是一个越大约好的分数。常见的评分卡是0到1000分分数越高代表风险越低。直接线性映射概率会带来问题模型输出的概率分布往往不是线性的低分段挤在一起稍微调整阈值就使审批通过率产生巨大抖动。保序回归是常用的校准手段它能保证映射函数单调递增同时不约束具体函数形态。from sklearn.isotonic import IsotonicRegression # val_prob验证集上的预测概率 # val_label验证集上的真实违约标签 iso IsotonicRegression(out_of_boundsclip) iso.fit(val_prob, val_label) # 校准后才能转分数分数 预测违约概率 * 1000好客户分数更高 calibrated_prob iso.predict(val_prob) score (1000 - calibrated_prob * 1000).astype(int) # 观察分数分布是否集中 pd.Series(score).describe()保序回归的输出应当是违约概率分数设计成“1000减去违约概率映射值”会让高分代表低风险。实际落地时保序回归在低概率段容易产生过拟合建议先按分位分成20段再拟合。分段等频后模型分数才能与旧评分卡对齐审批策略里的阈值也才有实际含义。4.4 验证方式滚动时间窗口而不是随机切分随机切分在时序数据里的危害是隐蔽的。同一个企业可能在多个时间段出现多次申请随机切分会把同一企业的不同记录同时分进训练集和验证集测出来的AUC虚高。更合理的方式是按月份滚动切分用前18个月样本训练后面3个月样本验证再做一次扩展窗口测试模拟模型上线后遇到未来样本的真实表现。小样本场景下可以只做一次时间切分但如果样本量超过10万建议使用多重时间窗口交叉验证。我这里用过的分量方式是观察期为24个月训练窗口滑动步长为3个月每次测试窗口取后3个月得到多组AUC后取中位数。这样才能排除单个时间段里特定行业集中爆雷带来的误导性指标。5. 避坑与常见问题多源Transformer在小微评分里最容易翻车的四个点5.1 样本数量大幅缩水现象多源仓库接口配置完成后把开票、司法、用电几个表做inner join原本90万条样本只剩不到11万条而且剩下的客户明显偏向头部企业。原因inner join要求每个客户在所有数据源上都出现。大量小微个体户没有开票流水或没有独立电表被直接过滤掉了模型只能学到少数信息完整的企业恰恰把最需要风控的群体丢掉了。解决改用主表左连接保留没有辅源数据的客户。缺失的数据用单独的mask标识让模型自己学习“这个源缺失”是否代表风险。缺失标识比空白填充更重要。5.2 裁判文书过长导致效果退化现象一个客户有几千字的判决书截断成512个字符后再编码模型预测分数偏低但人工看判决结果属于已经结案的债务纠纷风险有限。原因把所有文本都截头部判决结果往往在文末模型根本没看到关键信息。更糟糕的是不同数据源的截断位置不同导致特征语义不稳定。解决采用分段编码方案。文本按前、中、后三段各取256字符分别编码后拼接成三个token块最后三个块都进Transformer。关键信息无论落在文首还是文末都不会丢。文本编码和预测仍在同一个序列位置编码和来源编码清晰区分这三个块。5.3 验证集AUC很高人工复审通过率却大幅下降现象测试集的AUC达到0.79看起来很好。上线后同意的客户通过率却只有原来的六成且被推荐的“好客户”人工审核普遍不认可。原因标签里只包含了“已经被审批通过并放款的客户”那些被人工拒绝的客户没有表现期标签。模型学到的是“与过去通过客户相似的才是好客户”而不是“未来不会逾期的才是好客户”。这本质是幸存者偏差。解决至少收集拒绝样本的后续表现对拒绝样本做拒绝推断。最简单的方法是给历史拒绝的客户打一个较低的权重让它们的特征参与训练但不与正常好客户同等计算损失。更完善的做法是用二阶段模型估计拒绝客户的表现期逾期概率。5.4 水电数据延迟导致分数批量波动现象每月10号水电数据入库后评分模型输出分数整体跳升和模型本身没有任何关系。原因水电数据的T2更新让一部分客户在月初时“被缺失”模型训练时缺失值标准化后用0填充导致模型把这些客户全部预测成高风险。月中数据补齐后分数又被拉回来。解决对每个数据源增加一个“距上次更新时间”的特征并在推理时单独判断该特征是否处于正常区间。数据源更新时间异常时要么拒绝推理要么把对应数据源整体置为缺失并加上缺失标记而不是填零。6. 部署前三个参数的自检从文件到服务的最后一公里模型在训练环境里跑得再好上线之后还是会被数据时序、批处理、请求延迟这些细节摆一道。交付前我会拉着做特征工程的同事一起过三个自检项。第一是延迟。多源Transformer本身的编码器很小2层、4个头、64维embedding前向推理一般只要几毫秒。真正的延迟瓶颈在文本编码。如果每次请求都调用RoBERTa模型对最新裁判文书做编码单次延迟可能冲到30到60毫秒。对在线审批来说这是一个可接受但偏高的数字。我一般会把文本向量做成离线预计算表每天凌晨增量更新一次在线服务只读取向量和存储特征不再实时跑BERT。第二是阈值回溯。评分从0到1000需要一个回溯实验来确认策略分数线的合理性。做法是把最近三个月的客户按模型分数从高到低分成20个等频桶统计每个桶内的实际逾期率。某个此前设定的700分阈值如果对应逾期率仍在2%以上就要把阈值上调或者增加人工复核区间。分数阈值永远不能只看模型输出要和预期逾期率挂钩。第三是缺失源切换后的行为。线上运行期间某个数据仓库接口配置变更或者数据未按时送达评分服务不能静默降级。要提前定义规则同一客户缺失的数据源超过两个时强制走人工审批缺失一个源的客户需要复核其最近半年的历史分数变化。这个规则听起来简单但不提前做出问题时没有后悔药。我现在的习惯是每次发布前都写一个空跑脚本把特征缺失比例、分数分布、逾期率预估三个指标打出来附带一个“自检报告”让风控团队签字。这套动作不比训练模型轻松却决定模型是真的能用还是永远站在PPT里。希望这份从多源数据到评分卡刻度的落地路径能帮到你。数据永远是乱的指标永远是粗的只有把每一步的输入输出控制住模型才敢真正坐到审批席上。本文还有配套的精品资源点击获取
返回列表