ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

多模态检索与双塔结构:从对比学习到医学影像报告匹配的PyTorch实现

多模态检索与双塔结构:从对比学习到医学影像报告匹配的PyTorch实现 简介这份压缩包是为毕业设计或课程作业准备的深度学习多模态检索项目面向计算机专业学生解决医学影像报告跨模态检索问题。项目使用Python和C开发涉及CNN、RNN、LSTM、Transformer等模型以及多模态融合、图像处理与自然语言处理等技术。包内共52个文件包含23个Python脚本、14个编译后的pyc文件、模型配置文件、数据集说明文本、GUI界面图像等整体约208.4MB。已有147人学习下载。通过完整源码、训练脚本如Corr_CAE_Train.py、ECAE_Train.py、数据处理与检索模块search.py、GUI界面以及README说明读者可掌握多模态检索系统的搭建流程理解特征提取、融合与检索实现细节可直接作为毕设基础或课程设计改进。资源结构清晰适合希望快速上手深度学习医疗应用的开发者。1. 影像学报告多模态检索毕设和课程作业都能落地的一条主线拿到这个项目标题很多人的第一反应是先去解压找代码但更该先想清楚它到底要求你做什么一批影像CT、X光、病理切片和一批影像学报告文本你需要让系统支持“拿一张图搜到对应报告”或者“拿一段报告文字搜到对应影像”。这类任务在深度学习里叫多模态检索核心思路不是让模型“看懂”医学图像而是把图像和文本分别编码到同一个向量空间然后按余弦相似度做排名。对毕业设计和课程作业来说这条技术栈有成熟框架可抄、有明确的评估指标、还能做出可交互的演示界面性价比很高。适合正在做深度学习毕设、课程大作业或者想入门多模态方向的从业者——只要会PyTorch基础整个链路都能在单卡上跟完。2. 双塔结构为什么是影像学报告检索的默认答案编码、对齐与选型多模态检索最怕的不是模型不够深而是图像特征和文本特征不在同一个空间里没法直接比较。常见的做法是把图像和文本各送进一个编码器各自输出一个向量再用对比学习把“正确配对”的图像和文本在向量空间里拉近把“错误配对”推开。这就是双塔结构也是CLIP这类图文模型在检索任务里的标配结构。对毕设体量来说这条路几乎是最稳的起点。2.1 双塔结构两个编码器一个向量空间双塔的“双”指的是两条独立的特征提取分支。图像分支吃一张影像输出一个固定长度的向量文本分支吃一段报告输出同样长度的向量。训练时让同一份影像和它的报告在向量空间里靠近让影像和其他报告远离。检索阶段可以提前把全部文本向量算好存起来来一张新图像只需算一次图像向量再和库里所有文本向量做点积或余弦相似度排序就能得出匹配结果。为什么不是那种把图像和文本拼在一起送进一个大模型的单塔结构单塔的交互更强、效果上限更高但每次查询都要把图像和全部候选文本重新过一遍模型推理成本高毕设演示时很容易卡顿也不好做“预先建库”的架构展示。双塔结构训练简单、推理快而且两个塔可以分别换不同的预训练模型对一台只有一张消费级显卡的机器来说是更务实的选择。我在实现时习惯先把图像塔和文本塔的输出维度统一到256或512而不是直接用BERT的768和ResNet的2048。这样后续做相似度计算和可视化都方便也方便在塔后面再接投影层做降维。2.2 对比损失函数InfoNCE 与温度系数双塔训练最常用的是InfoNCE对比损失也叫多模态对比损失。原理是一个batch里有N个图像-文本配对对每个图像来说它的配对文本是正样本其余N-1个文本是负样本。模型要学的是让正样本对的相似度尽量高负样本对的相似度尽量低。损失函数里有一个温度系数τ这个参数直接决定训练的难度和稳定性。τ太小softmax分布会非常尖锐模型为了把正样本拉高会“用力过猛”训练容易震荡τ太大所有样本的相似度被抹平梯度信号太弱模型学不动。常见区间是0.05到0.2我一般从0.07起步如果损失震荡就放大到0.1如果训练太平缓就调小到0.05。实现上有个细节值得注意相似度矩阵是对称的既存在“图像检索文本”的方向也存在“文本检索图像”的方向。很多刚写的对比损失只算了图像到文本的方向导致损失一直在降但检索指标不升。稳妥的做法是计算双向对齐损失——把矩阵转置再算一次两个方向取平均。2.3 编码器选型ResNet/Swin 配 BERT/医学BERT图像塔的选择需要根据显存和数据集规模来定。如果报告数量在几万张以内用ResNet50足够如果数据量更大、显卡在12GB以上可以换Swin-Tiny或ViT-Base。ResNet50的优势是预训练权重好找、显存占用低、训练稳定对医学影像这种细节多的图像虽然不如Transformer结构敏感但跑通全流程完全没有问题。文本塔我习惯先用中文BERT或英文BioBERT/ClinicalBERT。如果报告是中文的用通用中文BERT先跑通不要一上来就找医学BERT——很多医学BERT权重版本老旧底层框架不兼容折腾半天环境反而把主线耽误了。等基线模型跑通、评估指标有了再换医学预训练模型对比效果这样论文里也能多一组对比实验。选型的核心判断标准是显存低于8GB就选ResNet50加BERT-base显存在12GB以上再考虑Swin和更大的文本模型。本质上这个任务吃显存的是图像分支文本分支的BERT-base只有110M参数反而是最省心的部分。3. 影像学报告配对数据的构建与预处理决定了模型一半的成败很多做图像分类的人第一次碰多模态会低估数据处理的难度。影像学报告不像普通图片描述那样是一句简短的标题而是一大段带模板的正式诊断文本里面有大量阴性描述、解剖部位、检查技术和结论。如果不好好清洗模型会学到很多噪声。3.1 影像学报告的数据长什么样一份典型的影像学报告格式通常是检查部位、影像表现、诊断结论三个部分。影像表现里有很多固定模板套话比如“双肺纹理清晰”“未见明显异常”这类高频用语诊断结论里则是一两句关键判断。对检索任务来说整份报告都可以作为正文本用但直接从报告里截前512个字符往往会截到一堆套话模型学不到有区分力的信息。我一般会做两个版本的文本一个是完整报告用于最终检索一个是只保留“影像表现诊断结论”两个字段的裁剪版用于训练。这样能让模型更关注有实际内容的文本而不是被患者信息、检查编号这类无关字段干扰。另外要注意影像学报告里经常出现“未见明确”“考虑”“可能”这类模糊表述这些词在不同报告里出现的频率差异很大分词后会被BERT的词典切得比较碎属于正常的模型学习内容不需要特殊处理。3.2 图文对构建与清洗的完整流程整个清洗流程可以分为五步读DICOM、转图像、抽取文本、配对、过滤。其中配对是每一步都要核对编号的。import pydicom import pandas as pd import numpy as np from PIL import Image def parse_dicom_and_report(dicom_path, report_path, output_img_path): # 读取DICOM文件并转为8位灰度图 ds pydicom.dcmread(dicom_path) pixel_array ds.pixel_array # 有些DICOM是16位整型需要归一化到0-255 pixel_array (pixel_array - pixel_array.min()) / (pixel_array.max() - pixel_array.min() 1e-8) * 255 img Image.fromarray(pixel_array.astype(np.uint8)) img img.convert(RGB) # 统一转成三通道适配ImageNet预训练权重 img img.resize((224, 224)) img.save(output_img_path) # 清洗报告文本 report pd.read_csv(report_path, sep\t, header0) text report[report].iloc[0] text text.replace(\n, ).replace(\r, ) text text[:512] # 先截断后续由tokenizer再做分词级截断 return text这段代码做了两件事把16位医学影像转成ImageNet预训练模型可接受的8位RGB图同时把报告文本做了基础清洗。归一化时加了个1e-8防止除零这是一个容易漏掉的细节因为部分CT图像的像素最大值是0。需要特别说明的是DICOM转PNG时不要直接保存成灰度图。虽然医学影像本身是单通道但大多数开源预训练权重如ResNet的ImageNet权重第一层卷积接受三通道输入。直接送单通道图会把权重加载搞出维度不匹配的报错除非你自己改第一层卷积结构。3.3 数据集划分和负样本策略图文对清洗完需要按患者维度划分训练集、验证集和测试集不能随机划分。同一个患者可能有多次检查、多份影像和多份报告如果同一患者的数据同时出现在训练集和测试集里检索任务会变得异常简单——模型只需要记住患者特征就能匹配评估指标虚高答辩时被问一句“你的测试集和训练集有没有患者重叠”就会很被动。按患者ID划分而不是按样本行划分patient_ids df[patient_id].unique() train_patients, val_patients, test_patients np.split( np.random.permutation(patient_ids), [int(len(patient_ids) * 0.7), int(len(patient_ids) * 0.85)] ) train_df df[df[patient_id].isin(train_patients)]负样本在对比学习中是隐式的靠同批次的其他样本充当。所以批次的构建方式很关键如果同一个患者的多次检查出现在同一个batch里它们互为负样本但其实内容高度相似会造成很硬的负样本训练初期很难收敛。常规做法是每个batch里先按患者分组再组内采样尽量保证一个batch内不出现同一个患者的多份报告。4. 用PyTorch搭建双塔检索模型Datasets、模型结构与训练配置结构和数据都准备好了进入写代码的环节。这里给出的是一套可以直接跑通的最小实现不依赖任何第三方检索框架只需要PyTorch和HuggingFace Transformers。4.1 自定义Dataset与DataLoader实现import torch from torch.utils.data import Dataset from transformers import AutoTokenizer class MedicalImageTextDataset(Dataset): def __init__(self, df, img_dir, text_coltext, img_colimage_path): self.df df.reset_index(dropTrue) self.img_dir img_dir self.text_col text_col self.img_col img_col self.tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] # 加载预处理好的图像 img load_image(f{self.img_dir}/{row[self.img_col]}) # 返回tensor [3,224,224] text row[self.text_col] # 对文本做tokenize最长截断到128 tokens self.tokenizer( text, max_length128, paddingmax_length, truncationTrue, return_tensorspt ) return { image: img, input_ids: tokens[input_ids].squeeze(0), attention_mask: tokens[attention_mask].squeeze(0), }Dataset的关键点是图像和文本的预处理尽量不要放到__getitem__里做重活。图像读取、resize这些操作可以提前在数据准备阶段做完并缓存成npy或小尺寸PNG训练时直接loaded进内存。影像学报告动辄几千甚至上万条每轮epoch都在__getitem__里做DICOM解析会让训练慢到没法接受。DataLoader方面num_workers可以根据机器CPU核数设置我一般设成4或8。需要注意Windows系统下如果num_workers0报错把训练代码包进if __name__ __main__:就能解决这是一个非常常见的环境问题。4.2 双塔模型与对比损失函数的PyTorch实现模型定义这块我采用“预训练编码器 投影头”的结构。两个塔各自输出一个低维向量然后用InfoNCE损失做对齐。import torch.nn as nn import torch.nn.functional as F from transformers import BertModel import timm class DualEncoder(nn.Module): def __init__(self, embed_dim256, text_model_namebert-base-chinese): super().__init__() # 图像塔使用timm加载预训练ResNet50去掉分类头 self.image_encoder timm.create_model(resnet50, pretrainedTrue, num_classes0) img_dim self.image_encoder.num_features # 2048 self.image_proj nn.Sequential( nn.Linear(img_dim, embed_dim), nn.ReLU(), nn.Linear(embed_dim, embed_dim), ) # 文本塔BERT 池化 投影 self.text_encoder BertModel.from_pretrained(text_model_name) self.text_proj nn.Sequential( nn.Linear(768, embed_dim), nn.ReLU(), nn.Linear(embed_dim, embed_dim), ) def encode_image(self, image): feat self.image_encoder(image) # [B, 2048] return F.normalize(self.image_proj(feat), dim-1) def encode_text(self, input_ids, attention_mask): outputs self.text_encoder(input_idsinput_ids, attention_maskattention_mask) feat outputs.pooler_output # [B, 768] return F.normalize(self.text_proj(feat), dim-1) def contrastive_loss(img_emb, text_emb, temperature0.07): # img_emb和text_emb已经是L2归一化的向量 logits img_emb text_emb.T / temperature # [B, B] 相似度矩阵 labels torch.arange(logits.shape[0], devicelogits.device) # 双向对比损失图像到文本 文本到图像 loss_img F.cross_entropy(logits, labels) loss_text F.cross_entropy(logits.T, labels) return (loss_img loss_text) / 2这里有三点要说明。第一投影头前面的F.normalize是必要的它会将向量模长归一化到1这样点积就等于余弦相似度相似度数值稳定在[-1,1]温度系数才有意义。第二损失函数里logits img_emb text_emb.T这一行实际上是在构造一个B×B的配分矩阵矩阵对角线是正样本对非对角线是负样本对——这就是前文提到的隐式负样本机制。第三loss_text F.cross_entropy(logits.T, labels)千万不能省只算loss_img会让文本塔学不到梯度信号图像塔和文本塔的对齐效果会差一大截。4.3 训练配置学习率、批次大小与混合精度训练参数的选择直接影响能否收敛。我的经验值如下batch size 32到64之间图像塔学习率1e-4因为ResNet的预训练权重比较成熟微调不需要太大文本塔学习率2e-5BERT微调学习率过大会破坏预训练语义温度系数0.07训练轮次5到10轮。优化器用AdamWweight_decay设0.01。在训练前几轮可以观察loss的变化趋势如果loss在2到3轮内不下降先检查数据配对是否正确再检查学习率。很多人一上来就调模型结构其实这种任务十有八九是数据没配对好。如果你的显卡只有6GB显存batch size 32可能显存不够。两个解决办法一是把图像resize到192×192而不是224×224二是用梯度累积每4个step做一次参数更新等效batch size不变但显存占用降到四分之一。5. 影像学报告检索训练与部署的常见问题排查翻车现场记录这个项目翻车的点很集中我按“现象→原因→解决”的格式整理了几条基本覆盖了从训练到答辩全流程容易踩的坑。5.1 训练loss下降但检索结果全乱现象是损失函数正常降到很低但检索出来的结果是随机的相似度都差不多。原因有两个可能一是双塔中的某一塔没有收到梯度最常见的就是第4章提到的只算单向对比损失另一个是文本长度截断设置得太短比如max_length32把报告里最有区分力的结论部分全截掉了。解决方法是先检查代码里backward()是否对两个塔的参数都更新了然后打印一批文本样本看看截断后的内容是否保留了关键结论。5.2 检索结果大量命中“阴性报告”影像学报告里“未见明显异常”这类阴性描述占比极高如果数据集是真实医院导出的可能有超过一半的报告是阴性结果。模型会倾向于把所有查询都往高频文本上推。这不是模型bug而是数据分布问题。解决方法是训练时做一次简单的重采样让阴性报告和阳性报告的数量比例控制在2:1以内推理阶段如果想提升实用价值可以单独准备一个阴性文本过滤器先把明显阴性的候选排除掉再排名。如果是毕设这一点可以作为论文里的对比实验来写在原始分布上训练和重采样后训练的Recall10往往能拉开10个点以上的差距。5.3 BERT加载预训练权重时报shape不匹配现象是load_state_dict报错size mismatch for bert.embeddings.word_embeddings.weight。原因是报告的tokenizer和预训练模型不匹配比如你用中文BERT的tokenizer去加载英文BioBERT的权重词表大小对不上。解决方法是统一用AutoTokenizer和AutoModel加载同一个模型名不要手动分开指定。这个坑在医学领域特别常见因为BioBERT和ClinicalBERT的底层词表不同交叉搭配必报错。5.4 显存溢出后调小batch size效果反而变差很多人遇到OOM就把batch size从64改到8结果loss震荡得厉害。原因是对比学习的负样本数量等于batch size减1batch size缩小后负样本太少模型学不到足够的判别信息。解决方法是保持有效batch size不变采用梯度累积batch size设16累积4步让梯度看起来和64一个量级。实践中还能顺手提升稳定性因为梯度累积等效于让优化器看到更平滑的梯度估计。5.5 报告文本里带编号和患者信息检索被“作弊”DICOM文件头里经常有患者ID如果预处理时把这个ID误拼进文本模型会直接学习“图像A对应患者甲患者甲的文本是B”这种捷径测试时指标虚高。解决方法是清洗时必须去掉所有与检查内容无关的字段只保留影像表现和诊断结论。验证自己有没有犯这个错误可以做一个“文本打乱”实验把训练集的文本全部随机重排如果模型loss还能降得很快说明模型在靠ID之类的捷径学习数据清洗没做到位。6. 检索系统的效果评估与两个提升方向的验证技巧训练完成后评估不能只看loss要用检索任务的标准指标衡量RecallK和MRR。说白了就是给定一张查询图前K个返回结果里有没有正确答案以及正确答案排名有多靠前。对毕设来说把这两个指标算出来画成表格比放十张检索可视化图更有说服力。def evaluate_recall(image_emb, text_emb, k10): # image_emb和text_emb形状都是[N, D]第i对是正确答案 sim_matrix image_emb text_emb.T # [N, N] num sim_matrix.shape[0] # 图像检索文本方向 ranks torch.argsort(sim_matrix, dim1, descendingTrue) # 每行按相似度排序 recall_at_k 0 for i in range(num): rank_list ranks[i].tolist() if i in rank_list[:k]: recall_at_k 1 recall_img2text recall_at_k / num # 文本检索图像方向同理 ranks_t torch.argsort(sim_matrix.T, dim1, descendingTrue) recall_at_k 0 for i in range(num): rank_list ranks_t[i].tolist() if i in rank_list[:k]: recall_at_k 1 recall_text2img recall_at_k / num return (recall_img2text recall_text2img) / 2注意一个易错点计算是对N个测试样本逐个判断如果N太大argsort整个N×N矩阵会耗尽内存。测试集超过5000条时建议分批计算或者用近似最近邻搜索库来加速否则答辩现场跑一次评估要等几分钟体验很差。如果基线跑通还有余力可以在论文里加两个进阶实验。第一个是难负样本挖掘在一个batch内把相似度最高但不是配对的样本拿出来额外计算损失对模型判别力有明显提升也比较好写。第二个是结合报告的结构化信息做弱监督影像学报告结尾的“诊断结论”通常可以单独抽出来把它作为额外的强语义标签训练时让图像向量同时靠近“完整报告向量”和“结论摘要向量”往往能把Recall10再提升3到5个点。我自己做这类项目时养成的一个习惯是每训练完一个版本把检索结果中排名第一的图像-文本对打印出来用肉眼看一遍而不是只看指标。指标只告诉你“有没有提升”人工检查能告诉你“模型到底靠什么特征匹配”——有很多次是检查时才发现模型在靠图像里的设备水印和文本里的设备型号做匹配彻底偏离了语义。这个习惯让我少走了很多弯路。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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