ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

车险定损多模态Transformer:文档-影像融合与证据链实战

车险定损多模态Transformer:文档-影像融合与证据链实战 简介这份PDF文档聚焦车险定损场景面向保险科技研究者、理赔系统开发者及对多模态Transformer感兴趣的技术人员探讨如何用文档-影像Transformer构建多模态证据链以优化理赔流程。文档共28页为单一PDF文件包体约1.95MB支持目录章节跳转、阅读器左侧大纲显示与章节快速定位查阅体验完整流畅。内容从车险定损现状与挑战切入系统讲解Transformer架构原理、多头注意力机制、多模态数据融合方法早期、晚期与中间融合、证据链建模与推理机制并覆盖数据预处理、特征提取、模型训练优化、系统架构设计及真实案例分析最后延伸至人寿、财产、健康保险等领域的应用潜力。已有57人学习适合希望掌握多模态证据链构建思路、对照目录快速定位关键模块并用于学习参考的读者。1. 车险定损为什么需要文档-影像Transformer从一张维修单和六张照片说起车险理赔的现场查勘员手里通常有两类材料一类是定损单、维修项目清单、配件报价表这类文档另一类是事故车多角度照片、VIN码照片、损伤特写这类影像。传统流程里这两条线是分开走的——文档靠OCR抽字段影像靠人工或单图分类模型判断损伤最后靠理赔员在脑子里把两边拼起来。问题就出在这个拼字上维修单上写着左前翼子板更换照片里左前翼子板却只是轻微划痕这种文档与影像的语义冲突单模态模型根本发现不了。文档-影像Transformer要解决的就是这件事把文档的文本token和影像的patch token放进同一个注意力空间让模型自己去学维修项目和损伤区域之间的对齐关系最终输出一条可追溯的多模态证据链——哪个损伤对应哪个维修项、置信度多少、依据是哪张图的哪个区域和文档的哪一行。这套方案适合有历史理赔数据积累的保险公司技术团队也适合做车险SaaS的工程团队。它不是一个纯研究课题落地路径已经比较清晰但坑也不少下面按原理选型→数据构造→模型实现→训练调参→避坑→进阶的顺序讲透。2. 文档-影像Transformer的架构选型为什么不是CLIP直接拿来用2.1 车险定损场景对多模态融合的三个硬约束在选架构之前先把场景约束列清楚否则很容易选一个论文里好看、落地翻车的方案。第一个约束是细粒度对齐。车险定损不是判断这张图是不是事故车这种粗粒度任务而是要定位到具体损伤区域和具体维修项目。CLIP那种全局对比学习得到的是一整张图和一个整句的相似度粒度太粗。你需要的是patch-level和token-level的对齐这就决定了必须用能保留空间结构的Transformer变体。第二个约束是文档结构敏感。定损单不是纯文本段落它有表格结构——项目名称、工时费、配件费、数量分列。如果把文档当纯文本序列喂进去表格的行列关系就丢了。常见做法是用LayoutLMv3这类文档理解模型先编码保留bbox位置信息再和影像特征做跨模态注意力。第三个约束是证据可追溯。理赔场景要求模型输出可解释不能只给一个定损金额8000元的结果。你需要跨模态注意力权重能映射回具体的文档token和影像patch这样才能生成证据链。这意味着架构里不能有太多不可逆的池化操作注意力矩阵要能取出来。综合这三点我一般会选双流编码跨模态交叉注意力的结构文档侧用LayoutLMv3或Donut编码影像侧用Swin Transformer或ViT编码中间加2到4层跨模态Transformer层做融合。不直接用CLIP的另一个原因是CLIP的预训练数据是图文对而车险场景的文档-影像对分布差异极大直接微调CLIP效果往往不如从头设计融合层。2.2 双流编码器的具体配置与参数含义文档侧编码器我通常用LayoutLMv3-base输入是OCR后的token序列加上每个token的bbox坐标。关键参数有三个max_position_embeddings设为512够用因为定损单通常不超过500个tokenbbox_dim保持默认的768维投影attention_mask要同时mask掉padding token和无效bbox。影像侧编码器用Swin-Tiny输入分辨率设成384×384。为什么不用ViT-Base因为车险损伤区域往往只占整图的5%到15%ViT的全局注意力在浅层对局部细节不敏感Swin的窗口注意力在浅层就能捕捉局部纹理对划痕、凹陷这类损伤更友好。patch size设4×4窗口大小7×7这些保持Swin默认即可。跨模态融合层是核心我一般堆4层标准Transformer decoder层但把self-attention换成cross-attentionquery来自文档侧key和value来自影像侧反过来再做一次影像query文档的交叉注意力。两个方向的注意力输出拼接后再过FFN。层数不建议超过6层超过之后证据链的可追溯性会下降因为深层注意力的权重已经很难映射回原始输入了。import torch import torch.nn as nn from transformers import LayoutLMv3Model from timm.models.swin_transformer import SwinTransformer class DocImageFusionTransformer(nn.Module): def __init__(self, doc_model_namemicrosoft/layoutlmv3-base, img_model_nameswin_tiny_patch4_window7_224, fusion_dim768, num_fusion_layers4, num_heads8): super().__init__() # 文档侧编码器保留bbox信息 self.doc_encoder LayoutLMv3Model.from_pretrained(doc_model_name) # 影像侧编码器Swin对局部损伤更敏感 self.img_encoder SwinTransformer( img_size384, patch_size4, in_chans3, embed_dim96, depths[2,2,6,2], num_heads[3,6,12,24], window_size7 ) # 影像特征投影到融合维度 self.img_proj nn.Linear(768, fusion_dim) # 跨模态融合层双向交叉注意力 self.fusion_layers nn.ModuleList([ nn.TransformerDecoderLayer(d_modelfusion_dim, nheadnum_heads, dim_feedforward2048, dropout0.1) for _ in range(num_fusion_layers) ]) # 证据链输出头损伤分类 维修项匹配 self.damage_head nn.Linear(fusion_dim, 12) # 12类常见损伤 self.match_head nn.Linear(fusion_dim, 2) # 匹配/不匹配 def forward(self, doc_input_ids, doc_bbox, doc_attention_mask, img_pixel_values): # 文档编码输出每个token的上下文表示 doc_out self.doc_encoder( input_idsdoc_input_ids, bboxdoc_bbox, attention_maskdoc_attention_mask ).last_hidden_state # [B, L_doc, 768] # 影像编码取多尺度特征图的最后一层 img_feat self.img_encoder(img_pixel_values) # [B, L_img, 768] img_feat self.img_proj(img_feat) # 双向交叉注意力融合 fused doc_out for layer in self.fusion_layers: # 文档query影像 fused layer(fused, img_feat) # 影像query文档转置后过同一层 img_feat layer(img_feat, fused) # 损伤分类对影像侧融合特征做池化 damage_logits self.damage_head(img_feat.mean(dim1)) # 维修项匹配文档侧融合特征做二分类 match_logits self.match_head(fused.mean(dim1)) return damage_logits, match_logits, fused, img_feat这段代码里几个参数需要重点说明。num_fusion_layers4是经验值层数太少融合不充分太多则证据链追溯困难。fusion_dim768是为了和两个编码器的输出维度对齐如果显存紧张可以降到512但损伤分类精度会掉2到3个点。damage_head的输出维度12是根据车险常见损伤类型定的划痕、凹陷、破裂、脱落、变形、锈蚀、玻璃裂纹、轮胎损伤、灯具损坏、保险杠损伤、车门损伤、翼子板损伤。实际项目里要根据自己的标签体系调整。注意LayoutLMv3的bbox输入要求是归一化到0-1000的整数不是原始像素坐标。很多新手在这里翻车直接传像素坐标导致位置编码完全错乱模型收敛不了。3. 多模态证据链的数据构造从理赔工单到训练样本的四个转换步骤3.1 从原始工单抽取文档-影像对保险公司的理赔系统里一个工单通常包含定损单PDF、查勘照片若干、维修厂报价单、历史理赔记录。你需要把它们对齐成训练样本。核心字段是claim_id通过它关联文档和影像。文档侧要做的处理用OCR引擎PaddleOCR或Tesseract抽取文本和bbox然后按定损单的表格结构重组。我一般会把文档切成三类片段车辆信息段车牌、VIN、车型、损伤描述段部位、损伤类型、程度、维修项目段项目名、工时、配件价。每段保留原始bbox这样后续证据链才能定位到具体位置。影像侧要做的处理每张照片标注拍摄角度前/后/左/右/顶/底/特写和对应的损伤区域bbox。如果历史数据没有损伤bbox标注可以用一个预训练的损伤检测模型先做伪标注再人工修正。这一步很费人力但省不得——没有区域级标注证据链就是空中楼阁。import json from PIL import Image from paddleocr import PaddleOCR ocr PaddleOCR(use_angle_clsTrue, langch) def parse_claim_document(pdf_path, claim_id): 从定损单PDF抽取结构化文档片段 # 实际项目用pdf2image转成图片再OCR result ocr.ocr(pdf_path, clsTrue) segments {vehicle_info: [], damage_desc: [], repair_items: []} for line in result[0]: bbox, (text, conf) line if conf 0.8: continue # 低置信度文本丢弃 # 按关键词路由到不同片段 if any(kw in text for kw in [车牌, VIN, 车型, 发动机号]): segments[vehicle_info].append({text: text, bbox: bbox}) elif any(kw in text for kw in [划痕, 凹陷, 破裂, 损伤]): segments[damage_desc].append({text: text, bbox: bbox}) elif any(kw in text for kw in [更换, 维修, 喷漆, 工时]): segments[repair_items].append({text: text, bbox: bbox}) return {claim_id: claim_id, segments: segments} def build_multimodal_sample(claim_id, doc_segments, image_paths, damage_bboxes): 构造一个多模态训练样本 sample { claim_id: claim_id, doc_tokens: [], doc_bboxes: [], doc_segment_ids: [], # 0车辆信息 1损伤描述 2维修项目 images: [], damage_labels: [], match_labels: [] # 文档维修项与影像损伤的匹配关系 } seg_map {vehicle_info: 0, damage_desc: 1, repair_items: 2} for seg_name, seg_id in seg_map.items(): for item in doc_segments[segments][seg_name]: sample[doc_tokens].append(item[text]) sample[doc_bboxes].append(item[bbox]) sample[doc_segment_ids].append(seg_id) for img_path, dmg_bbox in zip(image_paths, damage_bboxes): img Image.open(img_path).convert(RGB).resize((384, 384)) sample[images].append(img) sample[damage_labels].append(dmg_bbox[label]) return sample这段代码的关键逻辑是按语义片段路由文档token。doc_segment_ids这个字段很重要它让模型知道每个token属于车辆信息、损伤描述还是维修项目后续做证据链时可以直接按segment聚合注意力权重。conf 0.8这个阈值是经验值低于0.8的OCR结果噪声太大宁可丢弃也不要引入错误监督信号。3.2 构造跨模态匹配标签正负样本比例控制在1:3证据链的核心监督信号是文档维修项和影像损伤区域的匹配关系。正样本来自真实理赔记录中确认的对应关系负样本需要人工构造。常见做法是对每个正样本对随机替换文档侧的维修项或影像侧的损伤区域生成3个负样本。正负比控制在1:3比较合适太高模型学不到区分边界太低则正样本被淹没。负样本构造有个坑不能随机替换成完全不相关的项。比如左前翼子板更换的负样本如果替换成发动机大修模型太容易区分了学不到细粒度匹配。应该替换成同部位但不同损伤类型如左前翼子板喷漆或者同损伤类型但不同部位如右前翼子板更换这样模型才能学到真正的跨模态对齐。import random def build_match_labels(doc_repair_items, img_damage_regions, true_pairs): 构造跨模态匹配标签正负比1:3 labels [] # 正样本 for doc_idx, img_idx in true_pairs: labels.append({ doc_idx: doc_idx, img_idx: img_idx, label: 1 }) # 负样本同部位不同损伤 或 同损伤不同部位 for doc_idx, img_idx in true_pairs: doc_item doc_repair_items[doc_idx] img_region img_damage_regions[img_idx] # 策略1替换损伤类型保留部位 neg_img find_same_part_diff_damage(img_region, img_damage_regions) if neg_img is not None: labels.append({doc_idx: doc_idx, img_idx: neg_img, label: 0}) # 策略2替换部位保留损伤类型 neg_doc find_same_damage_diff_part(doc_item, doc_repair_items) if neg_doc is not None: labels.append({doc_idx: neg_doc, img_idx: img_idx, label: 0}) # 策略3完全随机替换少量 if random.random() 0.3: rand_img random.randint(0, len(img_damage_regions)-1) if rand_img ! img_idx: labels.append({doc_idx: doc_idx, img_idx: rand_img, label: 0}) return labels3.3 数据集划分与类别不均衡处理车险理赔数据天然不均衡前保险杠损伤占30%以上而轮胎损伤可能不到2%。直接训练会导致模型对稀有损伤类型几乎无召回。我一般用两种手段组合一是加权采样让稀有类在batch里出现频率提升3到5倍二是focal loss对易分类样本降权。数据集划分按时间切分比随机切分更合理。用前8个月的数据做训练后2个月做验证和测试。随机切分会导致同一辆车的多次理赔记录同时出现在训练和测试集里造成数据泄漏指标虚高。from torch.utils.data import WeightedRandomSampler import numpy as np def build_weighted_sampler(damage_labels, num_classes12): 按类别频率的倒数加权缓解不均衡 class_counts np.bincount(damage_labels, minlengthnum_classes) class_weights 1.0 / (class_counts 1e-6) sample_weights [class_weights[label] for label in damage_labels] sampler WeightedRandomSampler( weightssample_weights, num_sampleslen(sample_weights), replacementTrue ) return samplerclass_weights取倒数后稀有类的采样权重会显著高于常见类。replacementTrue表示有放回采样保证每个batch里稀有类都能出现。这个采样器配合focal loss使用效果最好单独用采样器可能导致常见类过拟合。4. 训练文档-影像Transformer损失函数设计与三个必调参数4.1 多任务损失函数的权重分配这个模型有两个输出头损伤分类和跨模态匹配。损失函数是两者的加权和。损伤分类用focal loss匹配用带标签平滑的交叉熵。权重分配我一般设成loss 1.0 * damage_loss 0.8 * match_loss。匹配损失权重略低是因为匹配任务的标签噪声更大——人工构造的负样本不一定真的不匹配只是未被确认为匹配。import torch.nn.functional as F def focal_loss(logits, targets, alpha0.25, gamma2.0): Focal loss缓解类别不均衡 ce_loss F.cross_entropy(logits, targets, reductionnone) pt torch.exp(-ce_loss) focal alpha * (1 - pt) ** gamma * ce_loss return focal.mean() def label_smoothed_ce(logits, targets, smoothing0.1): 标签平滑的交叉熵匹配任务用 n_classes logits.size(-1) log_probs F.log_softmax(logits, dim-1) with torch.no_grad(): true_dist torch.zeros_like(log_probs) true_dist.fill_(smoothing / (n_classes - 1)) true_dist.scatter_(1, targets.unsqueeze(1), 1.0 - smoothing) return torch.mean(torch.sum(-true_dist * log_probs, dim-1)) def compute_total_loss(damage_logits, match_logits, damage_targets, match_targets, damage_weight1.0, match_weight0.8): d_loss focal_loss(damage_logits, damage_targets) m_loss label_smoothed_ce(match_logits, match_targets) total damage_weight * d_loss match_weight * m_loss return total, d_loss.item(), m_loss.item()alpha0.25和gamma2.0是focal loss的标准配置对稀有类提升明显。smoothing0.1是匹配任务的标签平滑系数因为负样本构造有噪声完全硬标签会让模型过度自信。4.2 学习率、batch size和warmup的配合这三个参数要一起调单独调任何一个都容易翻车。我的一般配置是batch_size16受显存限制learning_rate2e-5warmup_ratio0.1。为什么学习率这么小因为两个编码器都是预训练模型大学习率会破坏预训练权重。warmup让模型在前10%的step里从0线性升到2e-5避免初期梯度震荡。如果显存够用batch_size提到32学习率可以相应提到3e-5。但不要超过5e-5否则LayoutLMv3的预训练权重会被冲掉表现为训练loss震荡不下降。判断方法很简单看前500个step的loss曲线如果上下跳动超过0.5就是学习率太大了。from transformers import AdamW, get_linear_schedule_with_warmup def build_optimizer_and_scheduler(model, num_training_steps, lr2e-5, warmup_ratio0.1): # 分层学习率编码器小融合层和输出头大 encoder_params list(model.doc_encoder.parameters()) \ list(model.img_encoder.parameters()) head_params list(model.fusion_layers.parameters()) \ list(model.damage_head.parameters()) \ list(model.match_head.parameters()) optimizer AdamW([ {params: encoder_params, lr: lr}, {params: head_params, lr: lr * 5} # 新初始化的层用5倍学习率 ], weight_decay0.01) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(num_training_steps * warmup_ratio), num_training_stepsnum_training_steps ) return optimizer, scheduler分层学习率是关键技巧预训练编码器用基础学习率新初始化的融合层和输出头用5倍学习率。这样编码器不会被破坏新层又能快速收敛。weight_decay0.01是AdamW的标准配置对Transformer类模型有效。4.3 证据链输出的后处理从注意力权重到可读证据模型训练好后推理阶段需要把跨模态注意力权重转成可读的证据链。具体做法是取最后一层融合层的cross-attention矩阵对每个文档维修项token找到注意力权重最高的top-3影像patch再把这些patch映射回原图的bbox区域。同时反向操作对每个影像损伤区域找到最相关的文档token。def extract_evidence_chain(model, doc_inputs, img_inputs, top_k3): 从注意力权重提取证据链 model.eval() with torch.no_grad(): # 前向传播时保留注意力权重 outputs model(doc_inputs, img_inputs, return_attentionsTrue) # 取最后一层融合层的cross-attention attn outputs[cross_attentions][-1] # [B, heads, L_doc, L_img] attn attn.mean(dim1) # 多头平均 evidence [] for doc_idx in range(attn.size(1)): # 每个文档token最相关的top-k影像patch weights, indices attn[0, doc_idx].topk(top_k) evidence.append({ doc_token_idx: doc_idx, img_patch_indices: indices.tolist(), attention_weights: weights.tolist() }) return evidencetop_k3是经验值取太多证据链会冗长取太少可能漏掉关键证据。attn.mean(dim1)对多头注意力取平均也可以取最大头但平均更稳定。实际部署时还要把patch索引映射回原图坐标这需要保存Swin的patch划分信息。5. 车险定损多模态模型的避坑清单五个血泪教训5.1 坑一OCR质量差导致文档侧输入全是噪声现象训练loss从第一个epoch就不下降文档侧注意力权重几乎均匀分布模型完全没学到文档结构。原因定损单扫描件质量参差不齐有的倾斜、有的模糊、有的表格线干扰OCR。低质量OCR输出的token序列里混入了大量乱码和错误bboxLayoutLMv3的位置编码被污染。解决在OCR后加一道质量过滤。具体做法是对每个token计算置信度低于0.8的丢弃对bbox做合法性检查宽高小于5像素或超出页面范围的丢弃对连续3个以上低置信度token的片段整段丢弃。另外训练前用100张样本人工检查OCR准确率低于90%就先优化OCR别急着训模型。5.2 坑二影像分辨率压缩太狠小损伤区域消失现象模型对划痕类小损伤召回率极低但对凹陷、破裂这类大损伤正常。原因原始查勘照片是4000×3000为了省显存直接resize到224×224一个占原图3%的划痕区域在224分辨率下只剩不到10个像素Swin的patch size是4相当于只有2到3个patch特征几乎消失。解决影像侧分辨率至少384×384有条件上512×512。如果显存不够用梯度累积而不是降分辨率。另一个技巧是损伤区域裁剪增强训练时以50%概率裁剪出损伤区域附近的子图resize到384让模型在训练时就能看到高分辨率的小损伤。5.3 坑三跨模态注意力坍缩所有文档token关注同一片影像区域现象证据链输出里所有维修项都指向影像的同一个区域通常是图像中心注意力权重分布高度集中。原因融合层数太多或学习率太大导致cross-attention退化成常数注意力。深层Transformer的注意力矩阵容易坍缩这是已知问题。解决融合层数控制在4层以内在cross-attention上加注意力多样性正则惩罚注意力矩阵的行间相似度学习率降到1e-5再试。如果还不行在cross-attention的softmax前加温度系数温度大于1会让分布更平滑。5.4 坑四负样本构造太简单模型学不到细粒度匹配现象匹配任务准确率在训练集上到99%但验证集只有70%证据链里大量错误匹配。原因负样本全是左前翼子板更换配发动机大修这种跨部位跨损伤类型的简单负样本模型学会了部位不同就不匹配这个捷径但没学会细粒度语义对齐。解决负样本构造要遵循同部位不同损伤、同损伤不同部位、同部位同损伤不同程度三个策略简单随机负样本占比不超过30%。另外在匹配头上加一个对比学习辅助损失拉近正样本对的表示、推远负样本对能显著提升细粒度区分能力。5.5 坑五证据链后处理时patch索引映射错位现象模型指标正常但证据链可视化时高亮区域总是偏移几十个像素。原因Swin Transformer的patch划分有重叠窗口img_patch_indices是窗口内的相对索引直接映射回原图坐标会错位。另外如果推理时的resize方式和训练时不一致比如训练用bilinear、推理用bicubic坐标映射也会有偏差。解决保存Swin的window_size和shift_size用官方的window_reverse函数把patch索引还原到特征图坐标再按resize比例映射回原图。推理时的resize方式必须和训练完全一致这个细节很容易忽略但影响很大。6. 进阶技巧用证据链一致性做无监督质量校验模型上线后最大的运维成本不是推理而是发现模型什么时候在胡说。车险定损场景里模型输出一个错误匹配如果理赔员没发现直接导致赔付金额错误。我后来养成了一个习惯用证据链的双向一致性做无监督质量校验。具体做法是对每个文档维修项正向找到top-k影像区域再对每个影像区域反向找到top-k文档token。如果正向和反向的结果互相包含说明这个匹配是双向确认的置信度高如果正向指向A但反向指向B说明模型内部存在矛盾这个样本应该标记出来人工复核。def bidirectional_consistency_check(evidence_forward, evidence_backward, doc_idx, img_idx): 双向一致性校验正向和反向证据是否互相包含 # 正向doc_idx关注的影像区域 fwd_imgs set(evidence_forward[doc_idx][img_patch_indices]) # 反向img_idx关注的文档token bwd_docs set(evidence_backward[img_idx][doc_token_indices]) # 双向确认条件 forward_ok img_idx in fwd_imgs backward_ok doc_idx in bwd_docs if forward_ok and backward_ok: return confirmed, 1.0 elif forward_ok or backward_ok: return partial, 0.6 else: return conflict, 0.2这个校验不需要额外标注纯靠模型自身的注意力对称性。实测下来confirmed样本的匹配准确率在95%以上conflict样本的准确率不到40%。把conflict样本挑出来人工复核能把整体错误率降低60%以上而人工复核量只增加15%左右。另一个进阶技巧是证据链的时序累积。同一辆车如果多次理赔把历史证据链和当前证据链做交叉验证。比如上次理赔换了左前大灯这次照片里左前大灯又有损伤模型应该给出更高的置信度——因为同一部位重复损伤的概率较低可能存在欺诈风险。这个信号可以作为一个额外特征输入到最终的理赔决策模型里。我自己的习惯是每次模型迭代后先跑一遍双向一致性校验看conflict比例有没有异常升高。如果升高超过5个百分点说明这次迭代引入了问题先回滚再排查。这个习惯帮我避免了好几次线上事故。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进