简介:这份压缩包是为毕业设计或课程作业准备的深度学习多模态检索项目,面向计算机专业学生,解决医学影像报告跨模态检索问题。项目使用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", header=0) 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_col="text", img_col="image_path"): self.df = df.reset_index(drop=True) 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_length=128, padding="max_length", truncation=True, return_tensors="pt" ) 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_workers>0报错,把训练代码包进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_dim=256, text_model_name="bert-base-chinese"): super().__init__() # 图像塔:使用timm加载预训练ResNet50,去掉分类头 self.image_encoder = timm.create_model("resnet50", pretrained=True, num_classes=0) 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_ids=input_ids, attention_mask=attention_mask) feat = outputs.pooler_output # [B, 768] return F.normalize(self.text_proj(feat), dim=-1) def contrastive_loss(img_emb, text_emb, temperature=0.07): # img_emb和text_emb已经是L2归一化的向量 logits = img_emb @ text_emb.T / temperature # [B, B] 相似度矩阵 labels = torch.arange(logits.shape[0], device=logits.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-5(BERT微调学习率过大会破坏预训练语义),温度系数0.07,训练轮次5到10轮。
优化器用AdamW,weight_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_length=32,把报告里最有区分力的结论部分全截掉了。解决方法是先检查代码里backward()是否对两个塔的参数都更新了,然后打印一批文本样本看看截断后的内容是否保留了关键结论。
5.2 检索结果大量命中“阴性报告”
影像学报告里“未见明显异常”这类阴性描述占比极高,如果数据集是真实医院导出的,可能有超过一半的报告是阴性结果。模型会倾向于把所有查询都往高频文本上推。这不是模型bug,而是数据分布问题。解决方法是训练时做一次简单的重采样,让阴性报告和阳性报告的数量比例控制在2:1以内;推理阶段如果想提升实用价值,可以单独准备一个阴性文本过滤器,先把明显阴性的候选排除掉再排名。如果是毕设,这一点可以作为论文里的对比实验来写:在原始分布上训练和重采样后训练的Recall@10,往往能拉开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减1,batch size缩小后负样本太少,模型学不到足够的判别信息。解决方法是保持有效batch size不变,采用梯度累积:batch size设16,累积4步,让梯度看起来和64一个量级。实践中还能顺手提升稳定性,因为梯度累积等效于让优化器看到更平滑的梯度估计。
5.5 报告文本里带编号和患者信息,检索被“作弊”
DICOM文件头里经常有患者ID,如果预处理时把这个ID误拼进文本,模型会直接学习“图像A对应患者甲,患者甲的文本是B”这种捷径,测试时指标虚高。解决方法是清洗时必须去掉所有与检查内容无关的字段,只保留影像表现和诊断结论。验证自己有没有犯这个错误,可以做一个“文本打乱”实验:把训练集的文本全部随机重排,如果模型loss还能降得很快,说明模型在靠ID之类的捷径学习,数据清洗没做到位。
6. 检索系统的效果评估与两个提升方向的验证技巧
训练完成后,评估不能只看loss,要用检索任务的标准指标衡量:Recall@K和MRR。说白了就是:给定一张查询图,前K个返回结果里有没有正确答案;以及正确答案排名有多靠前。对毕设来说,把这两个指标算出来画成表格,比放十张检索可视化图更有说服力。
def evaluate_recall(image_emb, text_emb, k=10): # 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, dim=1, descending=True) # 每行按相似度排序 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, dim=1, descending=True) 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内,把相似度最高但不是配对的样本拿出来额外计算损失,对模型判别力有明显提升,也比较好写。第二个是结合报告的结构化信息做弱监督:影像学报告结尾的“诊断结论”通常可以单独抽出来,把它作为额外的强语义标签,训练时让图像向量同时靠近“完整报告向量”和“结论摘要向量”,往往能把Recall@10再提升3到5个点。
我自己做这类项目时养成的一个习惯是:每训练完一个版本,把检索结果中排名第一的图像-文本对打印出来用肉眼看一遍,而不是只看指标。指标只告诉你“有没有提升”,人工检查能告诉你“模型到底靠什么特征匹配”——有很多次是检查时才发现模型在靠图像里的设备水印和文本里的设备型号做匹配,彻底偏离了语义。这个习惯让我少走了很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取