医学影像识别实战:用Python和CNN从零搭建肺炎分类模型
2026/9/16 20:02:03 网站建设 项目流程

医学影像识别和CNN这两个词放在一起,搜索量一直都很高,但很多刚开始接触深度学习的读者卡在了同一个地方:明明看了不少理论,代码也在一堆教程里见过,真到自己从零搭一个能跑的医学影像识别项目时,却不知道数据该放哪、模型该长什么样、训练完怎么评估。这篇文章就围绕着Python、CNN、医学影像识别、模型、代码这几个核心词,把完整流程拆开来,从环境配置一直讲到推理验证,每一步都给可运行的代码和取舍理由。你可以直接复制代码跑通,再根据你自己的数据场景去改。

1. 为什么医学影像识别绕不开 CNN

1.1 医学影像不是普通表格数据

很多人在入门时会觉得,分类任务用传统机器学习或者全连接神经网络不也能做吗?但在医学影像场景里,输入是一张二维图像,有时甚至是三维体数据(比如CT序列或MRI多平面重建)。如果你把一张 224×224 的X光胸片直接拉平成一维向量,长度是50176,接下来全连接层的权重数量会爆炸到什么程度?假设第一层全连接输出 256 个神经元,那仅这一层就有近 1284 万个参数。训练这样一层,需要的数据量是普通项目很难提供的,而且它完全丢掉了像素之间的空间位置关系。

医学影像的识别本质上依赖的是局部特征:某个肺野的形状、纹理的分布、阴影的边界,这些信息都带有明显的空间结构。像素本身只是灰阶值,但相邻像素构成了边缘、纹理、区域等语义信息。只用全连接网络处理这种输入,等于丢掉最有价值的信息去学习一个理解力很差的映射函数。

1.2 局部感受野和参数共享这两件事太关键了

CNN 的核心设计就是针对图像这类数据来的。卷积核每次只在一个局部窗口内做加权求和,这就是局部感受野,它天然关注“某个小区域里有什么模式”,比如边缘、拐角、电缆征或者毛玻璃影。随着卷积层加深,后面的层可以把前面层提取的局部模式组合成更高级的语义特征,这正是医学影像诊断里很实用的一种思路:先看细节,再看整体。

参数共享也很好理解,同一个卷积核会在整张图上滑动,意味着你学到的“肺部阴影边缘检测器”在图像的任何一个位置都生效,不需要为每个位置单独准备一套参数。相比全连接网络,CNN 的参数量少了好几个数量级,训练起来稳定得多,也不容易被数据量和数据尺寸逗得束手束脚。这也是“图像处理为啥用CNN不用前馈神经网络”这个问题最直接的答案。

1.3 本文的落地目标:X光胸片肺炎二分类

为了保证每一步都能被复现,我选用公开的胸部X光肺炎数据集作为示例。完整项目目标如下:

  • 输入:RGB 形式的 X 光胸片,统一调整为 224×224。
  • 输出:二分类概率,0 表示正常,1 表示肺炎。
  • 技术栈:TensorFlow / Keras + Python。
  • 流程:数据组织、预处理、数据增强、CNN 搭建、训练、评估、保存与推理。

这个目标足够简单,不会让环境或接口的细节淹没主线,但它又覆盖了一个完整医学影像识别项目所必须的所有环节。你把肺炎换成肺结节、皮肤镜分类、眼底照分类,核心流程完全一样,只换数据集和最后的类别数量就行。

2. 环境准备和数据集组织方式

2.1 安装依赖环境

我默认你用的是 Python 3.9 或 3.10,这两个版本在 TensorFlow 下的兼容性很稳。安装指令如下:

pip install tensorflow numpy pandas matplotlib scikit-learn opencv-python tqdm

如果你有 NVIDIA 显卡并且装好了 CUDA,TensorFlow 会自动识别 GPU 并加速训练。没有 GPU 也没关系,本文的 CNN 模型不大,用 CPU 也跑得动,只是建议把 epoch 数减少一点,或者在启动训练前把batch_size调小到 16 左右,避免内存吃不消。

这里有个容易踩的坑:不要在新环境里直接用pip install tensorflow-gpu,现代版本的 TensorFlow 已经统一了安装包,CPU 和 GPU 都在tensorflow里。直装这个包,然后验证一下:

import tensorflow as tf print(tf.__version__) print(tf.config.list_physical_devices('GPU'))

如果第二条打印出来是空的,说明当前没有可用 GPU,或者 TensorFlow 没找到 CUDA 环境,老老实实用 CPU 跑就行了。

2.2 数据目录结构

医学影像项目里,数据目录组织最好和标签一一对应,不要用一张 CSV 硬管所有路径,那样增删数据、随机划分、类别统计都不方便。我推荐下面的结构:

dataset/ ├── train/ │ ├── normal/ │ │ ├── normal_001.png │ │ └── ... │ └── pneumonia/ │ ├── pneumonia_001.png │ └── ... ├── val/ │ ├── normal/ │ └── pneumonia/ └── test/ ├── normal/ └── pneumonia/

trainvaltest三层目录,分别对应训练、验证、测试。很多刚入门的朋友只分训练和测试,训练过程中不看验证集表现,结果模型过拟合了都不知道。验证集专门用来做早停和选超参数,测试集只在最后评估时碰一次,这样结果才可信。

目录名normalpneumonia本身就是标签,Keras 的flow_from_directory会按目录名自动生成类别标签。注意它默认按字母顺序排序,所以normal会被映射为 0,pneumonia被映射为 1,后面看混淆矩阵的时候别搞反了。

2.3 数据集划分比例

医学影像数据集通常不大,所以划分比例要讲究。我一般按 70%、15%、15% 划分,如果原始数据总量低于 2000,甚至可以考虑 80%、10%、10%。划分时不建议用train_test_split直接切,因为医学数据往往存在同一个病人多张切片的关联,直接把切片打乱切分会造成数据泄露。稳妥做法是先按病人编号分组,再把病人整组划到不同集合里。公开肺炎数据集大部分已经按病人分好文件夹,直接按文件移动即可。

从一个有经验的角度说,我强烈建议你在读取数据后先统计每个类别的样本数,并在训练前打印出来。类别不均衡在医学影像里是常态,肺炎和正常的比例经常达到 2:1 甚至更高,这个信息会直接影响损失函数设置和权重策略。

3. 数据读取与预处理细节

3.1 原图不能直接喂给模型

医学影像的原始格式五花八门:有的是灰度 PNG,有的是 DICOM,有的是 JPEG,尺寸更是从几百到几千不等。CNN 内部结构要求每次传入的图片尺寸一致,否则中间层输出维度都对不上。所以预处理第一步就是把所有图统一调整到固定尺寸。

为什么选 224×224?一方面这是 ImageNet 预训练模型最常用的输入尺寸,如果你后面打算用迁移学习接 ResNet50 或 EfficientNet,这个尺寸直接兼容;另一方面,224×224 对于 X 光胸片来说并不会丢失太多关键纹理信息,而运算量只是 512×512 的约五分之一,能明显加速训练迭代。

3.2 归一化和通道处理

图像像素值范围为 0–255,直接送入网络会带来两个问题:特征值尺度差异大,导致梯度更新不稳定;模型需要额外学习一个偏置去适应这个尺度,白白增加训练负担。归一化到 [0, 1] 是最常用也最省心的方式,代码一行:

from tensorflow.keras.preprocessing.image import ImageDataGenerator train_datagen = ImageDataGenerator(rescale=1./255) val_datagen = ImageDataGenerator(rescale=1./255) test_datagen = ImageDataGenerator(rescale=1./255)

还有一个细节:X光胸片原图是灰度图,只有一个通道。如果你直接用color_mode='grayscale'加载,图片数组形状是(224, 224, 1),这也没问题,只要你把模型第一层输入通道数改成 1 就可以了。但如果你后面想用 ImageNet 预训练模型,预训练权重默认接受三通道输入,所以更通用的做法是用color_mode='rgb'让灰度图自动复制成三通道,保持代码的高度可移植性。

3.3 数据增强:医学场景要克制

数据增强是通过平移、旋转、缩放、翻转等方式,从已有样本中生成更多变体,让模型学习到不随这些变化而改变的本质特征,提升泛化能力。在医学影像场景里,增强策略要谨慎:比如左右翻转在X光胸片上通常是合理的,因为肺的左右结构对称;但旋转就需要控制幅度,X光摄影时病人基本是端正站立的,过度旋转产生的图像在真实诊断中几乎不会出现。

我建议这样配置数据增强:

train_datagen = ImageDataGenerator( rescale=1./255, rotation_range=8, width_shift_range=0.05, height_shift_range=0.05, zoom_range=0.1, horizontal_flip=True, fill_mode='nearest' )

旋转 8 度以内、平移 5%、缩放 10%,这些参数不会改变人体结构关系,又足够让模型减少对位置的过拟合。注意验证集和测试集永远不能做增强,因为评估模型时需要看它在“真实未见数据”上的表现,而不是在再处理过的数据上的表现。这个原则很多人一开始会忽略,导致验证分数虚高,测试分数却很低。

数据加载这一步,直接使用flow_from_directory最省事:

train_generator = train_datagen.flow_from_directory( 'dataset/train', target_size=(224, 224), batch_size=32, class_mode='binary', color_mode='rgb', shuffle=True ) val_generator = val_datagen.flow_from_directory( 'dataset/val', target_size=(224, 224), batch_size=32, class_mode='binary', color_mode='rgb', shuffle=False ) test_generator = test_datagen.flow_from_directory( 'dataset/test', target_size=(224, 224), batch_size=32, class_mode='binary', color_mode='rgb', shuffle=False )

shuffle控制的是每个 epoch 是否打乱数据顺序。训练集必须打乱,避免模型按顺序学到无意义的相关性;验证集和测试集不打乱,这样后面算混淆矩阵、输出预测概率时,顺序能跟真实标签一一对应。

4. CNN 模型搭建与训练

4.1 网络结构设计思路

这个项目的骨干结构如下:

import tensorflow as tf from tensorflow.keras import layers, models def build_cnn(): model = models.Sequential([ layers.Conv2D(32, (3, 3), activation='relu', padding='same', input_shape=(224, 224, 3)), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activation='relu', padding='same'), layers.MaxPooling2D((2, 2)), layers.Conv2D(128, (3, 3), activation='relu', padding='same'), layers.MaxPooling2D((2, 2)), layers.Conv2D(256, (3, 3), activation='relu', padding='same'), layers.GlobalAveragePooling2D(), layers.Dense(128, activation='relu'), layers.Dropout(0.5), layers.Dense(1, activation='sigmoid') ]) return model model = build_cnn()

网络由浅到深,通道数从 32 增加到 256。为什么第一层不用很大的卷积核或者很多通道?因为浅层已经能提取边缘、纹理这些基础特征,通道数太多反而容易在医学小数据集上过拟合。我选择padding='same'是为了让特征图尺寸在卷积后保持原大小,空间信息不因为边缘裁剪而丢失太多。

特别注意最后用GlobalAveragePooling2D而不是FlattenFlatten会把特征图拉成一个很长的向量,在 256 个通道且特征图为 14×14 的情况下,会产生 50176 维的特征,后续全连接层参数自然非常多;GlobalAveragePooling2D对每个通道求全局均值,直接压成 256 维,参数瞬间少下来,逻辑上相当于让每个通道表达一种语义特征的强度和权重,对分类任务很合适。

4.2 损失函数、优化器和评价指标的选择

二分类问题第一选择是binary_crossentropy。输出层用sigmoid,它把神经网络的原始分数映射到 0–1 之间,可以直接解读为肺炎的概率。

优化器我用Adam,初始学习率设为 1e-4。为什么不直接用默认的 1e-3?医学影像数据集通常不大,学习率太大会让损失在最优解附近来回震荡,很难收敛到好的局部最优点。1e-4 会更稳定,代价是收敛略慢,但配合早停完全值得。

除准确率之外,我还加了精确率、召回率、F1、AUC 这些指标。光看 accuracy 在类别不均衡时是非常误导的:如果一个数据集里 85% 是正常、15% 是肺炎,模型瞎猜所有样本都叫正常,准确率也有 85%,但这个模型显然毫无用处。医学诊断场景里,漏诊肺炎的代价远大于良性误报,所以召回率必须放在和准确率同等重要的位置。

模型编译:

model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-4), loss='binary_crossentropy', metrics=[ 'accuracy', tf.keras.metrics.Precision(name='precision'), tf.keras.metrics.Recall(name='recall'), tf.keras.metrics.AUC(name='auc') ] )

4.3 训练代码实现

现在可以开始训练了。这里我用到两个关键回调:EarlyStoppingModelCheckpoint

from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint import math steps_per_epoch = math.ceil(train_generator.samples / train_generator.batch_size) validation_steps = math.ceil(val_generator.samples / val_generator.batch_size) callbacks = [ EarlyStopping( monitor='val_loss', patience=10, restore_best_weights=True ), ModelCheckpoint( 'best_cnn.h5', monitor='val_loss', save_best_only=True ) ] history = model.fit( train_generator, steps_per_epoch=steps_per_epoch, epochs=30, validation_data=val_generator, validation_steps=validation_steps, callbacks=callbacks )

EarlyStopping监控验证损失,连续 10 个 epoch 没有下降就停止训练并恢复最优权重。这是处理过拟合最实用的自动策略,不要裸训练 50 个 epoch 不管结果。ModelCheckpoint只保存验证集上最好的模型,而不是最后一个 epoch 的模型。训练后期模型很容易在验证集上变差,如果不这样保存,你可能拿着一个过拟合版本去部署。

训练时steps_per_epoch不能漏掉,否则生成器无法知道一个 epoch 要取多少次 batch。计算公式就是总样本数除以 batch 大小向上取整。

4.4 训练收敛状态判断

训练过程中你可能会看到 train accuracy 接近 1.0,但 val_loss 一直在涨,这就是过拟合信号,早停会在合适位置救你。如果 train loss 和 val loss 都在缓慢下降但 val accuracy 还在波动,这是正常现象,耐心跑完即可。如果 train loss 完全不动,优先检查数据读取是否正常、标签是否正确、学习率是否太小。

如果 loss 出现 NaN,最常见的原因是图像像素没有归一化或者梯度爆炸,回去检查预处理和优化器学习率。

5. 结果评估与调优经验

5.1 只看准确率会掉进大坑

训练完成后,在测试集上做评估:

import numpy as np from sklearn.metrics import confusion_matrix, classification_report test_generator.reset() y_pred_prob = model.predict(test_generator) y_pred = (y_pred_prob > 0.5).astype(int) y_true = test_generator.classes print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred, target_names=['normal', 'pneumonia']))

从混淆矩阵里可以同时看敏感性和特异性。敏感性又叫召回率,表示实际有病的人被正确检出的比例;特异性表示实际没病的人被正确排除的比例。临床场景下,敏感性太低意味着漏诊,这是不可接受的,所以如果敏感性不足,就要想办法在不牺牲太多特异性的前提下提升它。

调整分类阈值是立刻能试的方法。sigmoid输出默认以 0.5 作为决策边界,但0.5并不是数学上的最优选择。我经常扫一遍验证集上所有可能的阈值,找出 F1 最大值对应的阈值来用:

from sklearn.metrics import precision_recall_curve precisions, recalls, thresholds = precision_recall_curve(y_true, y_pred_prob) f1_scores = 2 * (precisions * recalls) / (precisions + recalls + 1e-9) best_threshold = thresholds[np.argmax(f1_scores[:-1])] print(f"best threshold: {best_threshold:.3f}")

这个阈值就是对模型的最终调参,把它记录下来,后面推理时用同样的阈值,而不是盲目用 0.5。

5.2 类别不均衡再处理

如果混淆矩阵显示少数类召回率低得吓人,光调阈值不够,还得从训练环节根治。两种常用解法:

  • 给少数类更高的损失权重,也就是class_weight。Keras 里可以这样:给model.fitclass_weight参数,让少数类的样本被误分时惩罚更大。
  • 在数据增强时针对少数类做更多样的变换,相当于缓解样本量不足。

class_weight的计算方式很简单,样本多的类别权重给 1,少的类别权重按比例给大值。比如类别 0 有 2000 张,类别 1 有 1000 张,那类别 1 的权重可以设为 2.0。这种方式不改数据分布,只影响损失函数,收敛起来往往比简单复制少数类样本更稳。

这里要注意:class_weight 设得太大会让少数类过拟合,模型会变得更激进地报告阳性,特异性会下降。调整时一定要看验证集而不是训练集。

5.3 从零训练还是迁移学习

医学影像数据量少是常态,随机初始化的 CNN 要学到有效的影像特征,通常需要几十万甚至上百万张图片,这在实际医疗场景里很难凑齐。迁移学习是更聪明的做法:先让模型在 ImageNet 这种千万级图像数据集上学到通用的边缘、纹理、形状特征,再拿到医学影像上做微调。

使用迁移学习的代码:

base_model = tf.keras.applications.ResNet50( include_top=False, weights='imagenet', input_shape=(224, 224, 3) ) base_model.trainable = False model = models.Sequential([ base_model, layers.GlobalAveragePooling2D(), layers.Dropout(0.3), layers.Dense(1, activation='sigmoid') ]) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-4), loss='binary_crossentropy', metrics=['accuracy', tf.keras.metrics.Recall()] )

最开始冻结全部预训练层的权重,只训练顶部的分类层。等训练稳定后,再把最后几个卷积层解冻,用一个更小的学习率比如 1e-5 做微调。这种两阶段训练是我在医学影像项目里最推荐的做法,能显著提高小数据集上的表现。

不过迁移学习不是银弹。有些医学影像与自然图像差异极大,比如超声图像、OCT 眼底图像,ImageNet 特征未必管用,这种情况下轻量级 CNN 从零训练反而可能更可控。务必要用验证集做对比。我的经验是,先在可快速迭代的自定义 CNN 上跑通流程,拿到一个基线,再试迁移学习,比较两者在验证集上的 F1 再决定。

6. 模型保存、加载与推理验证

6.1 保存与加载

训练完的模型一定要保存下来,否则下次用又要重新训练。用回调已经保存了best_cnn.h5,也可以手动再存一次:

model.save('cnn_pneumonia_final.h5')

加载模型同样一行:

from tensorflow.keras.models import load_model model = load_model('cnn_pneumonia_final.h5')

加载后建议跑一遍model.summary()确认结构完整,再对测试集做一次预测,验证模型文件没有损坏。

6.2 单张医学影像推理

实际部署时往往要处理单张图片,推理代码如下:

from tensorflow.keras.preprocessing import image import numpy as np def predict_single_image(img_path, model, target_size=(224, 224)): img = image.load_img( img_path, color_mode='rgb', target_size=target_size ) x = image.img_to_array(img) x = x / 255.0 x = np.expand_dims(x, axis=0) prob = model.predict(x, verbose=0)[0][0] if prob >= 0.5: print(f"预测为肺炎,置信度:{prob:.3f}") else: print(f"预测为正常,置信度:{1 - prob:.3f}") return prob

把单个文件路径传进去就能得到结果。注意np.expand_dims这一步是把形状从(224,224,3)变成(1,224,224,3),因为模型本身就要求一个 batch 维度,即使你只预测一张图也要补上这个维度。忘记这一步是最常见的推理报错原因。

6.3 部署时容易忽略的细节

第一个坑是预处理不一致。训练时你用了rescale=1./255,推理时同样要除以255,否则输入分布完全不同,模型输出概率会乱掉。第二个坑是尺寸不一致。训练时用target_size=(224,224),推理时也要一样,如果图片本身是 512×512,load_img会自动缩放,但缩放算法和训练数据处理方式要尽量一致。第三个坑是灰度图通道不一致。推理图如果是灰度图,color_mode='rgb'会自动转成三通道,这个我上面代码里已经处理好了。

还有一个容易忽略的问题:训练时如果最终使用了最佳阈值而不是 0.5,记得把阈值也保存下来。很多人保存了模型,却把阈值写在训练代码里,换了环境部署时直接忘了这回事,线上预测结果跟离线评估对不上。建议把阈值写在一个配置文件或者注释里,推理代码直接引用。

最后再分享一点个人经验

把 CNN 从零搭起来只是第一步,真正花时间的地方在数据处理和评估阶段。我在实际项目中吃过最大的亏,就是一开始太依赖默认的 0.5 阈值和准确率指标,结果模型看起来不错,真实场景里漏检严重,后来改成以召回率为核心指标并重新选择阈值,模型价值才真正体现出来。医学影像识别是典型的低容错场景,宁可多让专家复核一版,也不能盲目相信一个高 accuracy 的模型。如果你正在做类似项目,建议先拿小规模数据子集把整个流程跑通,再逐步加大数据量,这个节奏会节省你大量排错时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询