简介这是一套面向计算机、人工智能及医学信息处理方向学生与教师的深度学习项目源码围绕LSTM-CLIP多模态自主疾病诊疗方法展开可用于课程设计、毕业设计或大作业演示。项目包含电子病历信息预处理、Transformer文本编码器、图像编码器、图像特征提取网络、LSTM循环神经网络以及基于价值网络DDQN的强化学习交互模块完整覆盖从病历与影像数据预处理、多模态特征编码、时序建模到诊疗动作决策的全流程其中特征提取网络可按需插入决策网络强化学习模块则依据病人反馈评分生成奖赏以指导智能体决策。压缩包共20个文件以12个py源码为主辅以png结构示意图、txt说明与md项目文档整体约418KB目录清晰便于按模块查阅。已有306人学习下载适合希望理解多模态医学图像处理与强化学习决策实现思路、并在此基础上二次开发的读者参考。1. 医学图像平台从零搭起为什么预处理和编码层决定了后面所有模块的天花板拿到一份标注为“Python 基于深度学习的医学图像处理和分析平台源码”的压缩包时多数人第一反应是翻到 LSTM 或强化学习模块想看看“智能”到底写在哪。但真正跑过医学图像项目的人会先看另外两个地方预处理管线和图像编码层。原因很直接——CT、MRI、X 光、病理切片这些数据的像素分布、位深、窗宽窗位、通道数跟自然图像完全不是一回事预处理没对齐后面接 CNN、LSTM 还是强化学习策略网络输入都是错的训练 loss 曲线会呈现出一种让人怀疑人生的“玄学”形态不收敛、震荡、或者收敛到一个毫无临床意义的局部最优。这个平台要解决的问题是把“读图—清洗—编码—特征提取—序列建模—决策优化”串成一条可复现的流水线。它适合两类人一类是刚入门深度学习、想找一个完整医学图像项目练手的 Python 开发者另一类是有临床数据但缺乏工程化经验、需要把零散脚本整理成平台的研究人员。核心关键词会反复出现在后面章节Python、深度学习、医学图像处理、LSTM、强化学习。接下来不聊虚的直接按“数据怎么进来、特征怎么出去、序列和决策模块怎么接”的顺序拆开讲。2. 预处理与图像编码把 DICOM 变成网络能吃的张量2.1 医学图像预处理的四个必做动作自然图像预处理那套“Resize Normalize”直接搬到医学图像上翻车概率极高。医学图像的像素值往往对应物理量如 CT 的 HU 值直接归一化到 [0,1] 会丢失绝对信息。我一般会按下面四步走第一步读取元数据。DICOM 文件里的RescaleSlope、RescaleIntercept、WindowCenter、WindowWidth决定了像素值到 HU 的映射关系不读这些直接pydicom.dcmread().pixel_array拿到的是原始存储值不是物理值。第二步转 HU 并做窗宽窗位截断。以腹部 CT 为例常用窗位 40、窗宽 400把 HU 截断到 [-160, 240] 再归一化这样肝脏、肾脏、肌肉的对比度才拉得开。第三步处理各向异性体素。很多 CT 的层厚z 轴间距是 1.25mm 或 5mm而层内像素间距是 0.7mm 左右直接送进 3D 网络会导致空间关系扭曲。常见做法是重采样到各向同性比如统一到 1mm×1mm×1mm。第四步强度归一化。对 CT 用窗宽窗位截断后线性映射到 [0,1]对 MRI 因为没有绝对物理单位通常用 z-score 按体积归一化或者用 N4 偏置场校正后再归一化。import pydicom import numpy as np import cv2 def load_dicom_as_hu(dicom_path): 读取单张 DICOM 并转换为 HU 值 ds pydicom.dcmread(dicom_path) # 关键先转 float再乘 slope 加 intercept image ds.pixel_array.astype(np.float32) hu image * float(ds.RescaleSlope) float(ds.RescaleIntercept) return hu, ds def apply_window(hu, window_center40, window_width400): 按窗宽窗位截断并归一化到 [0,1] lower window_center - window_width // 2 upper window_center window_width // 2 hu_clipped np.clip(hu, lower, upper) # 线性映射保留窗内对比度 normalized (hu_clipped - lower) / (upper - lower) return normalized.astype(np.float32) def resample_volume(volume, original_spacing, target_spacing(1.0, 1.0, 1.0)): 将 3D 体积重采样到各向同性间距 import scipy.ndimage as ndimage zoom_factors [o / t for o, t in zip(original_spacing, target_spacing)] return ndimage.zoom(volume, zoom_factors, order1)逻辑说明load_dicom_as_hu里先转float32再乘斜率是因为pixel_array可能是uint16直接乘浮点斜率会溢出。apply_window的lower和upper根据检查部位调整头颅常用窗位 40、窗宽 80肺部用窗位 -600、窗宽 1500。resample_volume用order1双线性插值比最近邻平滑又比三次插值快适合大批量预处理。参数说明window_center和window_width不是随便设的DICOM 头里通常有推荐值优先读ds.WindowCenter和ds.WindowWidth读不到再用经验值。target_spacing设成 1mm 是折中设太小显存爆炸设太大细节丢失。2.2 图像编码从像素到特征向量的三条路预处理完的数据要变成网络能学的表示这就是图像编码层干的事。平台里常见三条路第一条是直接端到端 CNN 编码。用 ResNet、DenseNet 或 EfficientNet 的 backbone把 2D 切片或 3D 体积映射成 512 维或 1024 维特征向量。医学图像数据量通常不大我一般会加载 ImageNet 预训练权重但注意ImageNet 是 RGB 三通道医学图像是单通道第一层卷积要改成单通道输入权重取三通道均值。第二条是手工特征加编码。在数据量极少比如几十例罕见病时CNN 容易过拟合这时用 PyRadiomics 提取形状、一阶统计、GLCM、GLRLM 等影像组学特征再用 PCA 或自编码器降维。这条路可解释性强但特征表达能力有限。第三条是混合编码。先用 CNN 提深度特征再拼接影像组学特征最后过一个全连接层融合。平台源码里如果同时有radiomics和torchvision依赖大概率走的是这条路。import torch import torch.nn as nn from torchvision import models class MedicalImageEncoder(nn.Module): def __init__(self, feature_dim512, pretrainedTrue): super().__init__() # 加载 ResNet18修改第一层为单通道 backbone models.resnet18(pretrainedpretrained) original_conv backbone.conv1 backbone.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse) # 用原三通道权重的均值初始化单通道卷积 with torch.no_grad(): backbone.conv1.weight[:] original_conv.weight.mean(dim1, keepdimTrue) # 去掉最后的全连接层保留特征提取部分 self.features nn.Sequential(*list(backbone.children())[:-1]) self.project nn.Linear(512, feature_dim) def forward(self, x): # x: (B, 1, H, W) feat self.features(x) # (B, 512, 1, 1) feat feat.flatten(1) # (B, 512) return self.project(feat) # (B, feature_dim)逻辑说明backbone.conv1.weight.mean(dim1, keepdimTrue)把预训练的三通道卷积核在通道维度取平均变成单通道卷积核这样既保留了预训练学到的边缘、纹理检测能力又适配了医学图像的单通道输入。self.features去掉全连接层后输出 512 维再通过project映射到指定维度方便后面接 LSTM 或强化学习模块。参数说明feature_dim根据下游任务定接 LSTM 时通常设 256 或 512pretrainedTrue在数据量小于 1000 例时建议开启数据量很大时可以关掉从头训。注意models.resnet18(pretrainedTrue)在较新版本的 torchvision 里会提示弃用换成weightsmodels.ResNet18_Weights.IMAGENET1K_V1即可但源码里如果写的是旧版保持原样能跑通就行。3. 特征提取与 LSTM 序列建模把切片序列变成时间依赖3.1 特征提取模块的工程化写法预处理和编码之后平台通常会把每个病例表示成一个特征序列。比如一个 CT 体积有 80 层切片每层经过 CNN 编码得到 512 维向量整个病例就是(80, 512)的矩阵。但直接送进 LSTM 有两个问题一是层数太多LSTM 长程依赖会衰减二是相邻切片高度冗余计算浪费。我一般会做两步处理先按固定间隔采样比如每 5 层取一层80 层变成 16 层再用一个 1D 卷积或小窗口池化在序列维度上降采样。这样既保留了 z 轴方向的变化趋势又控制了序列长度。import torch import torch.nn as nn class SliceFeatureExtractor(nn.Module): def __init__(self, in_dim512, out_dim256, seq_len16): super().__init__() # 沿序列维度做一维卷积压缩冗余 self.temporal_conv nn.Sequential( nn.Conv1d(in_dim, out_dim, kernel_size3, padding1), nn.BatchNorm1d(out_dim), nn.ReLU(), nn.Conv1d(out_dim, out_dim, kernel_size3, padding1), nn.BatchNorm1d(out_dim), nn.ReLU() ) self.seq_len seq_len def forward(self, x): # x: (B, T, D) - 转成 (B, D, T) 做 Conv1d x x.permute(0, 2, 1) x self.temporal_conv(x) # 自适应池化到固定长度兼容不同层数的输入 x nn.functional.adaptive_avg_pool1d(x, self.seq_len) return x.permute(0, 2, 1) # (B, seq_len, out_dim)逻辑说明permute(0, 2, 1)是因为Conv1d要求输入形状是(batch, channels, length)而编码器输出是(batch, length, channels)。adaptive_avg_pool1d把任意长度的序列压到固定seq_len这样不同病例的切片数不一致也能 batch 训练。两层Conv1d加BatchNorm和ReLU是标准配置第一层降维第二层提非线性。参数说明in_dim要和编码器输出对齐out_dim一般设 128 或 256太大容易过拟合。seq_len设 16 是经验值对应 80 层切片每 5 层采样一次如果原始层数少设 8 或 12 也行。3.2 LSTM 模块医学序列建模的经典选择与它的边界LSTM 在医学图像平台里的角色通常是对切片序列或随访时间序列建模。比如一个病人做了多次 CT 随访每次提取一个特征向量按时间排成序列LSTM 可以捕捉病灶的演变趋势。又比如一个 3D 体积按 z 轴切片展开LSTM 可以学习层与层之间的空间连续性。但 LSTM 不是万能的。血泪经验是当序列长度超过 100 步LSTM 的门控机制虽然理论上能记住长程信息实际训练中梯度还是会衰减这时候要么用 Transformer要么先用 1D 卷积降采样。另外LSTM 对序列顺序敏感如果切片顺序打乱输出会完全不同所以数据加载器里不能随便 shuffle 序列维度。class LSTMClassifier(nn.Module): def __init__(self, input_dim256, hidden_dim128, num_layers2, num_classes2, dropout0.3): super().__init__() self.lstm nn.LSTM( input_sizeinput_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout if num_layers 1 else 0 ) # 双向 LSTM 输出维度是 hidden_dim * 2 self.classifier nn.Sequential( nn.Linear(hidden_dim * 2, 64), nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, num_classes) ) def forward(self, x): # x: (B, T, input_dim) lstm_out, (h_n, c_n) self.lstm(x) # 取最后一个时间步的输出做分类 last_step lstm_out[:, -1, :] return self.classifier(last_step)逻辑说明batch_firstTrue让输入形状是(B, T, D)符合直觉。bidirectionalTrue让 LSTM 同时从正反两个方向读序列对医学图像切片序列来说正向是从头到脚反向是从脚到头两个方向的上下文都利用上通常比单向高 2-3 个点。lstm_out[:, -1, :]取最后一个时间步如果做的是序列标注任务应该取所有时间步的输出。参数说明hidden_dim设 128 是平衡表达能力和显存的选择设 256 也行但训练更慢。num_layers2时dropout才生效设 0.3 是常用值。num_classes根据任务改二分类设 2多分类设对应类别数。注意LSTM 的输入序列如果来自 3D 体积切片切片顺序必须和训练时一致。我见过有人预处理时按文件名排序训练时按 DICOM 的InstanceNumber排序结果顺序不一致模型学了个寂寞。4. 强化学习模块在医学图像平台里它到底能做什么4.1 强化学习在医学图像分析中的合理定位看到“强化学习模块”五个字很多人会兴奋觉得平台很“高级”。但冷静下来想医学图像分析里强化学习到底解决什么问题常见的有三类场景第一类是自适应扫描策略。比如 MRI 扫描时根据当前已采集的 k 空间数据决定下一步采集哪个区域用强化学习可以在保证图像质量的前提下减少扫描时间。这类场景里状态是当前重建图像动作是选择下一个采样位置奖励是图像质量指标。第二类是病灶检测的序列决策。比如在 3D 体积里逐层看决定“继续看下一层”还是“当前区域已经确认病灶输出结果”。这本质是一个部分可观测马尔可夫决策过程LSTM 可以作为策略网络或价值网络的一部分。第三类是治疗规划。比如放疗剂量分配状态是当前剂量分布动作是调整射束角度和强度奖励是靶区覆盖率和危及器官保护的综合评分。平台源码里如果强化学习模块和 LSTM 模块放在一起大概率是第二类场景用 CNN 编码当前切片用 LSTM 编码历史切片信息输出动作概率。4.2 一个最小可跑的强化学习训练循环下面用 PyTorch 写一个简化版的策略梯度训练循环场景是“在切片序列中决定何时停止并输出分类结果”。状态是 LSTM 的隐藏状态动作是“继续”或“停止”奖励在停止时根据分类正确与否给出。import torch import torch.nn as nn import torch.optim as optim import numpy as np class PolicyNetwork(nn.Module): def __init__(self, feature_dim256, hidden_dim128): super().__init__() self.lstm nn.LSTM(feature_dim, hidden_dim, batch_firstTrue) self.policy_head nn.Linear(hidden_dim, 2) # 动作继续/停止 self.value_head nn.Linear(hidden_dim, 1) # 状态价值 def forward(self, x, hiddenNone): lstm_out, hidden self.lstm(x, hidden) logits self.policy_head(lstm_out) value self.value_head(lstm_out) return logits, value, hidden def train_rl_episode(policy, features, label, optimizer, gamma0.99): 一个 episode在切片序列上做决策 hidden None log_probs [] values [] rewards [] # 逐切片输入模拟序列决策 for t in range(features.size(1)): x_t features[:, t:t1, :] # (B, 1, D) logits, value, hidden policy(x_t, hidden) # 动作 0 继续动作 1 停止 probs torch.softmax(logits[:, 0, :], dim-1) action torch.multinomial(probs, 1) log_prob torch.log(probs.gather(1, action) 1e-8) log_probs.append(log_prob) values.append(value[:, 0, :]) if action.item() 1: # 停止时给奖励分类正确 1错误 -1 reward 1.0 if label.item() 1 else -1.0 rewards.append(reward) break else: rewards.append(0.0) # 继续决策中间奖励为 0 # 计算折扣回报 returns [] R 0 for r in reversed(rewards): R r gamma * R returns.insert(0, R) returns torch.tensor(returns, dtypetorch.float32) # 策略梯度损失 价值损失 policy_loss [] value_loss [] for log_prob, value, R in zip(log_probs, values, returns): advantage R - value.item() policy_loss.append(-log_prob * advantage) value_loss.append(nn.functional.mse_loss(value, torch.tensor([[R]]))) optimizer.zero_grad() total_loss torch.stack(policy_loss).sum() torch.stack(value_loss).sum() total_loss.backward() optimizer.step() return total_loss.item()逻辑说明这个循环模拟了“逐切片看决定继续还是停止”的过程。torch.multinomial按概率采样动作保证探索。奖励只在停止时给出中间步骤奖励为 0这是典型的稀疏奖励设置。advantage R - value.item()是优势函数用实际回报减去价值估计降低方差。策略损失和价值损失一起反向传播。参数说明gamma0.99是折扣因子越接近 1 越看重长期回报。1e-8防止 log 零。实际训练时通常要跑多个 episode 取平均并且用entropy bonus鼓励探索这里为了简洁省略了。提示强化学习在医学图像里训练极不稳定我一般会先用监督学习预训练 CNN 和 LSTM再冻结编码器只训策略头。这样收敛快很多也不容易把特征提取器带偏。5. 避坑与排查医学图像平台落地时最容易翻车的五个地方5.1 现象训练 loss 不下降准确率卡在 50%原因最常见的是标签和图像没对齐。医学图像数据集经常按病例分文件夹每个病例有多张切片如果 DataLoader 里按切片 shuffle 但标签按病例给就会出现同一病例的切片被分到不同 batch标签却一样模型学不到东西。解决写一个检查脚本随机抽 10 个 batch把图像和标签打印出来人工核对。或者用torch.utils.data.Subset按病例划分训练集和验证集确保同一病例的切片不跨集。5.2 现象验证集准确率很高测试集一塌糊涂原因数据泄漏。医学图像里同一病人的多次检查、同一扫描的不同重建版本如果被分到训练集和测试集模型会“记住”病人特征而不是病灶特征。解决按病人 ID 划分数据集不是按图像文件。如果病人 ID 不可用至少按检查日期或扫描设备划分。平台源码里如果有split_dataset函数重点看它按什么字段分组。5.3 现象LSTM 训练时 loss 突然变成 NaN原因序列长度不一致时 padding 处理不当或者梯度爆炸。医学图像切片数差异很大有的病例 20 层有的 200 层如果 padding 到最大长度且没有 maskLSTM 会在 padding 上计算损失梯度累积后爆炸。解决用pack_padded_sequence和pad_packed_sequence处理变长序列或者在损失计算时用 mask 把 padding 位置排除。另外加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)。5.4 现象强化学习训练几百个 episode 后策略退化成只选一个动作原因奖励稀疏且没有探索机制。如果停止动作的奖励总是正或总是负策略会很快收敛到“总是停止”或“总是不停止”。解决加 entropy bonus 鼓励探索loss policy_loss - entropy_coef * entropyentropy_coef设 0.01 到 0.1。另外检查奖励设计确保正确分类和错误分类的奖励有区分度不要都是 0。5.5 现象预处理后的图像看起来正常但模型学到的特征毫无意义原因窗宽窗位设错或者归一化方式不匹配。比如头颅 CT 用腹部窗宽窗位脑组织全被截断成一片灰模型只能学到头骨轮廓。解决预处理后把图像保存成 PNG 抽查和原始 DICOM 在专业阅片软件里的显示对比。另外确认训练和推理用同一套窗宽窗位参数不要训练时用一套、推理时用另一套。6. 进阶技巧用注意力机制给 LSTM 加一双眼睛LSTM 做序列建模时最后一个时间步的输出未必能代表整个序列。比如一个 CT 体积里病灶可能只在中间几层出现最后几层是正常组织LSTM 读到后面反而把病灶信息冲淡了。这时候加一个注意力机制让模型自己决定哪些时间步重要通常能涨 3-5 个点。class AttentionLSTM(nn.Module): def __init__(self, input_dim256, hidden_dim128, num_classes2): super().__init__() self.lstm nn.LSTM(input_dim, hidden_dim, batch_firstTrue, bidirectionalTrue) # 注意力打分把每个时间步的 LSTM 输出映射成一个标量 self.attention nn.Sequential( nn.Linear(hidden_dim * 2, 64), nn.Tanh(), nn.Linear(64, 1) ) self.classifier nn.Linear(hidden_dim * 2, num_classes) def forward(self, x): # x: (B, T, D) lstm_out, _ self.lstm(x) # (B, T, 2H) scores self.attention(lstm_out) # (B, T, 1) weights torch.softmax(scores, dim1) # 加权求和得到序列的全局表示 context (lstm_out * weights).sum(dim1) # (B, 2H) return self.classifier(context)逻辑说明self.attention是一个两层 MLP把每个时间步的 LSTM 输出映射成一个分数softmax归一化成权重然后对 LSTM 输出做加权求和。这样模型可以自动关注病灶所在的切片忽略无关切片。bidirectionalTrue让每个时间步的输出同时包含正向和反向上下文。参数说明注意力中间层设 64 是经验值设 32 或 128 也可以差别不大。Tanh比ReLU更适合做注意力打分因为输出有正有负softmax 后区分度更好。验证注意力是否有效可以把weights可视化出来看高权重是否落在病灶切片上。如果高权重均匀分布或者落在无关区域说明注意力没学到东西可能是序列太短、或者 LSTM 输出本身没有区分度。我自己的习惯是每加一个模块先跑一个最小数据集比如 20 个病例过拟合确认模型能记住这 20 个病例再上全量数据。如果最小数据集都过拟合不了说明代码有 bug不是数据问题。这个习惯帮我省了无数个通宵排查的夜晚。希望帮到你。本文还有配套的精品资源点击获取
