30秒速览
- - 光搞DICOM数据清洗和管道就耗了四个多月,医院真实数据远比论文里的干净数据集脏得多,工程脏活占大头。
- - AI报告在10个病例上能捡漏结节,但放到100个就暴露出术语混乱、术后状态遗漏和不恰当肯定句式,医生最烦不是不准,是不敢信。
- - 最后不是提升模型精度,而是靠交互式分段生成、不确定性标注和完整的审计归档才让医生勉强接受,信任这东西比BLEU分数难搞一万倍。
我一开始以为最难的是模型,结果被DICOM数据管道教做人
项目启动会上,我拍着胸脯跟科室主任说:“给我三个月,先做一个能看懂胸片、自动生成报告的模型。”那时候我刚读完几篇用ViT+GPT-2做放射报告生成的论文,脑子里全是attention map和BLEU分数。真正上手才发现,模型连一张像样的DICOM都拿不到,更别提出报告了。
医院的PACS系统是个运行了八年的庞然大物,厂商提供的“集成接口”是一套私有协议的HL7 v2消息和基于WADO的DICOM web服务。但问题在于,PACS里存储的检查数据远不止图像。一个CT平扫序列可能包含2000多张DICOM切片,散落在不同的Study和Series里,有些带定位线,有些是重建后的厚层,还有些甚至不是轴向位——冠状位、矢状位混在一起,文件名根本没有规律。我第一次尝试直接用pydicom递归遍历某个患者的Study目录,结果读取了将近40G的数据,内存直接飙到90%,k8s pod被OOMKilled了三次。后来我写了一个专门的DICOM扫描器,根据DICOM Tag (0008,0060) Modality、(0020,000D) Study Instance UID、(0020,000E) Series Instance UID以及(0018,0050) Slice Thickness等字段做过滤,只保留轴位薄层(比如层厚≤1.25mm)且不包含overlay的序列。代码大概是这样的:
from pydicom import dcmread
from pathlib import Path
def scan_ct_series(root_dir):
series_map = {}
for f in Path(root_dir).rglob("*.dcm"):
ds = dcmread(f, stop_before_pixels=True)
if ds.Modality != "CT": continue
study_uid = ds.StudyInstanceUID
series_uid = ds.SeriesInstanceUID
thickness = float(getattr(ds, 'SliceThickness', 0))
if thickness > 1.25: continue # 跳过厚层
series_map.setdefault(study_uid, {}).setdefault(series_uid, []).append(f)
# 只保留切片数最多的那个序列(通常是轴位薄层)
for study, series_dict in series_map.items():
best_series = max(series_dict, key=lambda s: len(series_dict[s]))
yield study, best_series, series_dict[best_series]
这个扫描器帮我在一台128G内存的机器上稳定跑了三天,处理了大约3万个历史检查。但数据质量远比我预想的糟糕:有些老设备生成的DICOM不带SliceLocation标签,导致排序错误;有的Study里同一个Series包含两种重建核(kernel),软组织窗和骨窗混在一起;还有的研究者把增强扫描的动脉期、静脉期全塞进一个SeriesUID,只靠AcquisitionTime区分。为了清洗这些数据,我不得不写了一套DICOM修正脚本,用SimpleITK重算层间距和原点,甚至根据ImagePositionPatient做三维排序。说实话,当时我每天骂厂商八百遍,但后来跟同行业的朋友交流才知道,这几乎是业内常态——数据工程的脏活累活占了整个项目60%的时间,模型训练反而像是一种享受。
更大的坑在报告文本。PACS自带的报告系统输出的是RTF格式,但放射科医生有时候直接在里面改模板,导致一段报告里可能出现“双肺纹理清晰,未见实质性病变”这样的标准描述,紧接着又有一段手打的“右下肺小斑片影,请结合临床”。更离谱的是,有些老报告里夹杂着医生之间闲聊的备注,比如“老王,这个结节去年就有,不用慌”。我试图用正则和规则去提取“影像所见”和“诊断意见”部分,但格式完全不统一。最后我用了一个基于BERT的命名实体识别模型先做段落切分,再根据章节标题的语义相似度归类,准确率也只有85%左右。这直接导致训练集里存在大量噪声——你给模型喂进去的“标准答案”本身就不标准,怎么指望它写出靠谱的报告?
后来我索性把清洗流程打包成一个Airflow DAG,每天从PACS的DICOM网关拉取增量数据,自动筛选、归一化、配对报告,并把结果存进MinIO的对象存储,同时写入一个PostgreSQL的元数据库。这套数据管道跑通之后,我才真正有了一个可供模型训练和在线推理的数据底座。但此时距离项目开始的“三个月”已经过去四个半月,模型还没开始写第一行代码。
融合视觉、文本和时序?我搭的那个pipeline差点把自己绕晕
数据底座有了,我开始设计模型。目标很明确:输入一个CT检查的所有关键序列(通常是轴位薄层平扫序列),以及患者的主诉、临床病史等文本信息,输出一份结构化的影像报告。我希望模型能看懂的不只是单张片子,而是整个检查的三维上下文,同时还能考虑到时序——有些患者是来复查的,历史检查对比对诊断很重要。所以最终模型架构是一个多模态、多时间点的编码-解码框架。
视觉分支我用了Swin Transformer V2的3D变体,因为CT本身就是三维体数据。我把整个序列resample到1mm各向同性,裁切到384x384x32的patch块(32张切片作为一个块,有重叠滑动窗口),送入Swin 3D提取特征,然后通过一个transformer encoder做层级特征聚合,得到整个检查的视觉表示。文本分支则把患者主诉、病史等用ClinicalBERT编码,输出一个文本嵌入向量。时序分支处理历史检查:如果患者三个月前拍过一次CT,我会把当时这个序列的视觉特征和当时的报告文本特征,通过一个cross-attention模块融合到当前检查的表示中。简单说,就是在encoder的最后一层,用当前检查的视觉特征作为query,历史特征作为key和value,让模型学会“看看上次哪里有结节,这次变大了没有”。
解码器是一个标准的transformer decoder,采用自回归方式生成报告。但在生成过程中,我加了一个视觉注意力约束:每一层decoder的cross-attention不仅要关注encoder输出的全局特征,还要同时关注Swin 3D最后一层的特征图。这样模型在生成“右肺上叶”这个词的时候,attention map会直接高亮对应解剖区域,为后续的可解释性提供基础。整个模型用PyTorch实现,训练时用了8张A100 80G,batch size设到16,用DeepSpeed ZeRO-3来省显存。训练loss包括标准的交叉熵,外加一个辅助的contrastive loss——让同一患者不同时间点的检查表示在隐空间里拉近,不同患者的拉远,这样能强化时序对比能力。
推理pipeline设计更麻烦。因为要做在线服务,延迟必须控制在30秒以内(不然医生等得不耐烦)。我把整个pipeline拆成几个微服务:DICOM预处理服务用C++写了图像归一化和裁切逻辑,通过gRPC把tensor直接送到模型推理服务;文本预处理用FastAPI搭了个简单服务;模型推理本身部署在Triton Inference Server上,同时加载了Swin 3D encoder和报告decoder,支持dynamic batching。但真正的噩梦是历史检查的检索。理论上我应该从PACS实时拉取患者的历史DICOM,但PACS的WADO接口单次查询延迟接近2秒,加上网络波动,经常超时。后来我只能提前把历史特征缓存到一个Redis集群里,key是患者ID+检查日期。每次新检查进来,先查Redis有没有该患者的历史特征,有就直接用,没有就触发一个异步任务去拉取DICOM并计算特征、写回缓存。但这样又带来了缓存一致性问题——如果放射科医生事后修改了历史报告,缓存里的特征不会自动更新,导致对比结果不准。我花了整整两周时间搭建了一套CDC(change data capture)机制,监听PostgreSQL报告表的binlog变更,一旦发现报告修改就失效对应的Redis key,勉强解决了问题。
部署pipeline上线第一周,我就发现平均延迟在27-35秒之间波动,偶尔还会飙到45秒。排查下来,大头竟然是pydicom在读取大序列时的元数据解析——那个stop_before_pixels参数虽然跳过了像素数据,但pydicom还是会遍历整个文件的所有tag,对于2000张切片的序列,光做元数据解析就花掉8秒。我最后用GDCM的C++库写了个极简DICOM header parser,只提取我们需要的20个tag,通过Python的ctypes调用,把解析时间压到了0.6秒。这个优化让我意识到,在真实生产环境中,模型之外的I/O和工程细节才是性能瓶颈的大头,学术界刷指标时的那些花哨技巧,到了医院全得让路给“别让医生等着”。
从10个病例的惊艳到100个病例的质疑,只用了两周
模型在医院内网试运行那天,我们从PACS里随机拉了10个最近完成的胸部CT检查,让模型生成报告,然后把AI报告和原版医生报告并排打印出来,请科室三位高年资医生盲审评分。结果让所有人振奋:在“病灶检出”维度,模型发现了2个被原报告遗漏的微小结节(其中一个后来被证实是早期肺腺癌),在“报告可读性”上,三位医生平均给了4.2分(满分5)。科室主任当场拍板:“两周内扩大到100例临床验证,效果好就准备上正式流程。”
两周后,100个病例的结果摆在桌上,气氛却完全变了。模型在50个“正常或大致正常”的病例中表现得四平八稳,但只要是稍微复杂的病例——术后改变、间质性肺炎、纵隔淋巴结多发肿大等等,AI报告就开始出各种幺蛾子。印象最深的是一个肺叶切除术后复查的患者:右肺下叶缺如,术区有条索状高密度影,同时左肺代偿性肺气肿。模型的报告里把“右肺下叶切除术后改变”写成了“右肺下叶体积缩小”,漏掉了“术后”这个关键信息;又把代偿性肺气肿描述成“左肺透亮度增高,考虑肺气肿可能”,让读报告的医生以为患者有慢性阻塞性肺病。这在临床上是很严重的误导。
更让医生们抓狂的是术语的不一致。同一种影像表现,模型在不同患者身上可能用不同的词汇:比如同一个磨玻璃结节,上一份报告写“磨玻璃密度影”,下一份写“云雾状密度增高区”;“纵隔淋巴结”有时缩写成“纵隔LN”,有时又全称。我查了训练集,发现这些变体全来自真实的医生报告——人类医生本来就有个人写作风格,有些喜欢简洁,有些喜欢详细,但AI不加区分地学过来,就变成了术语摇摆。一位医生私下跟我吐槽:“你们这个模型写的报告,就像把十个医生的笔迹硬拼在一起,我根本不知道该信哪句。”
还有更隐蔽的问题:模型在生成“阴性发现”时很自信,但面对不确定的情况时依然用肯定句式。例如一个直径4mm的肺微小结节,按照Lung-RADS指南,良性可能性极大,通常报告会写成“建议年度随访复查”,语气偏向保守。但AI直接写“右肺上叶小结节,考虑良性”,没有任何随访建议。医生看了直摇头:“你这报告万一患者看了,以后不复查了怎么办?我们写报告不是写流水账,是要承担临床决策责任的。”这时候我才意识到,模型把生成任务当成了机器翻译式的“图像-文本映射”,完全没理解医学报告背后的责任属性和不确定性的表达规范。
技术指标也暴露了这一点。如果只看BLEU-4和ROUGE-L,100个病例的分数(0.38和0.52)其实比10个病例时期还略有提升。但医生在意的是临床准确性、决策一致性和法律安全性,这些根本不是自然语言指标能反映的。我们后来做了个细分的错误分析:在100份AI报告中,临床专家标注了34处“潜在误导致错”,其中14处与术语不一致有关,11处属于遗漏术后/放疗后状态,6处把随访建议写错,还有3处是报告结构混乱。这让我彻底认清了一个事实:在放射科,一份好的报告不是“语法正确、描述详细”就够了,它需要遵循这个科室的文化、习惯和法律边界。而这些东西,是任何公开医学文本语料上都学不到的。
医生说“别抢我笔”的那一刻,我才明白信任比准确率贵得多
100例验证的结果出来之后,科室开了一次正式反馈会。一位快退休的老主任在会上说了一句让我到现在都记得的话:“小刘,你那机器写的报告,我看得懂,但我改起来比我自己从头写还累。你们别抢我笔。”他说的“抢笔”,指的倒不是AI要替代医生,而是那种被强行塞进一个不熟悉的写作流程里的不适感。他习惯用一套自己摸索了三十年的模板,先描述主要病灶,再写次要发现,最后结合临床给出诊断意见。AI生成的报告虽然信息齐全,但叙述顺序跟他的思维路径完全反着来,他每改一份AI报告都像在做阅读理解重组,时间反而多了。
这背后是交互设计的失败。一开始我们把AI报告定位成“最终产物”,以为只要质量足够好,医生直接点个“采纳”就行。但现实中,没有一个医生愿意原封不动采用AI生成的文字——这涉及到签名责任。他们希望AI是一个提词器,而不是代笔人。基于这个反馈,我们紧急做了三个迭代,前前后后又花了两个月。
第一个迭代是交互式报告修正。我们不再一次性吐出完整报告,而是分节生成:先输出“影像所见”部分,让医生可以在富文本编辑器里实时编辑;AI同时根据医生的修改动态更新后续的“诊断印象”建议。技术上,这需要一个轻量级的生成式重排序机制:每次医生修改某个段落,我们用修改后的文本作为prompt前缀,让decoder重新generate后续token,只做局部重生成而不影响已经审核通过的部分。为了保持一致性,我用了一个缓存的KV cache技巧——把已确认生成段落的key和value缓存下来,每次新生成只从断点处开始前向计算,保证上下文连续。实现代码大致如下:
# 假设已确认的前缀token和对应的past_key_values
confirmed_ids = tokenizer.encode(confirmed_text, return_tensors="pt")
with torch.no_grad():
outputs = model.decoder(
input_ids=confirmed_ids,
encoder_hidden_states=visual_features,
use_cache=True,
past_key_values=None
)
past = outputs.past_key_values
new_tokens = model.generate(
encoder_hidden_states=visual_features,
max_new_tokens=128,
past_key_values=past,
do_sample=False
)
这个设计让医生感到“我在主导,AI在辅助”,抵触情绪明显下降。但依然有医生抱怨:AI生成的备选诊断有时候太离谱,比如一个明显的炎性假瘤,AI的第二诊断竟然考虑“淋巴瘤待排”,虽然可能性极低,但这样的文字如果留在报告系统里,怕将来有法律隐患。
于是第二个迭代我们加了不确定性标注与分层建议。模型在生成诊断印象时,额外输出一个confidence score(基于decoder输出logits的熵),并对低置信度的陈述自动加上括号和问句形式,比如“右下肺小结节(炎性假瘤?建议抗炎后复查)”。同时,高置信度的关键阳性发现(比如明确的新发实性结节)会用高亮背景标注,提示医生必须过一眼。我们把这个confidence机制做到模型架构级别:在decoder最后一层接一个辅助分类头,用诊断的最终报告是否被医生大改的标注数据(来自我们之前收集的修改日志)作为弱监督信号,训练它判断某句话被修改的概率。这个头输出一个0到1的score,在生成过程中如果某句score低于0.7,就自动触发不确定性的措辞转换。
第三个迭代是合规归档。医院的信息科要求所有AI辅助生成的报告必须保留完整的审计轨迹:哪个版本是原始AI生成的,医生在什么时间点做了哪些修改,最终归档的版本是谁签发的。我们基于DICOM SR(Structured Reporting)做了扩展,把每次交互过程序列化为一个DICOM SR对象的多个content item,同时用XAdES格式的数字签名对每一步做时间戳签名,存入支持DICOMweb STOW-RS的PACS归档模块。这样,无论是医疗纠纷举证还是科室质量评估,都可以精确追溯AI的贡献和人的决策。这一套归档流程实现起来非常痛苦,因为DICOM SR模板(TID 2000-2009)原本是为CAD设计的,跟自由文本报告不完全兼容,我们不得不在厂商的配合下自定义了若干个私有tag。但做完之后,科室的法律顾问终于点了头。
全部迭代完成后,我们又进行了一轮100个病例的试用,这次医生的主观满意度从之前的惨不忍睹提升到了73%。但那个老主任还是坚持自己写报告,只在遇到复杂测量(比如肺结节三维径线、体积倍增时间计算)时会点开AI辅助测量工具。他跟我说:“小刘,你们做的这东西不是没用,是帮了我们年轻人的忙。对我们这些老家伙,最顺手的笔还是自己手里的那支。” 我听了之后反而踏实了——我从来没想让AI取代任何一位医生,能让那些每天值夜班的年轻大夫少打几百个字,多眯五分钟,这事儿就没白干。而真正让我学会的,是技术落地必须从“模型中心”切换到“人中心”,所有的管道、架构、指标,最后都得绕着那个握着笔的人转。