5分钟搞定脊椎CT图像分割:U-Net实战教程(附Python代码)

如果你是一名医学影像处理的新手,或者是一位需要快速验证想法的研究人员,面对脊椎CT分割这个听起来就让人头大的任务,是不是感觉无从下手?别担心,这篇文章就是为你准备的。我们绕开那些冗长的理论推导和复杂的文献综述,直接切入核心:如何用最经典的U-Net模型,从零开始,一步步搭建一个能实际跑起来的脊椎分割系统。我会把重点放在你真正会遇到的问题上:数据怎么处理、模型怎么调、结果怎么看。读完这篇文章,你不仅能得到一个可运行的代码库,更能理解每一步背后的“为什么”,从而具备解决其他医学影像分割问题的能力。

1. 环境准备与数据初探

在敲下第一行代码之前,一个稳定、可复现的环境是成功的基石。我强烈建议使用 Anaconda 来管理你的Python环境,它能有效避免不同项目间的依赖冲突。对于深度学习项目,PyTorch是目前学术界和工业界的主流选择,其动态图机制对研究和实验非常友好。

首先,我们创建一个专属的虚拟环境并安装核心依赖:

conda create -n spine_seg python=3.9
conda activate spine_seg
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118  # 根据你的CUDA版本调整
pip install numpy pandas matplotlib scikit-learn scikit-image opencv-python
pip install SimpleITK  # 处理医学影像DICOM/NIfTI格式的利器
pip install nibabel  # 另一个常用的医学影像处理库
pip install tqdm  # 用于显示进度条

提示:如果你的显卡支持CUDA,务必安装对应的PyTorch GPU版本,这将使训练速度提升数十倍。可以通过 nvidia-smi 命令查看CUDA版本。

接下来,我们谈谈数据。医学影像数据,尤其是带有精细标注的脊椎CT数据,获取门槛较高。对于学习和实验,你可以从一些公开数据集中寻找,例如 VerSe (Vertebrae Segmentation) 挑战赛提供的数据集。假设你已经获得了一些 .nii.gz 格式的CT图像和对应的标注文件,让我们先来“认识”一下它们。

医学影像数据与普通的JPG图片截然不同。一个 .nii.gz 文件通常包含一个三维体数据(多个连续的二维切片)。我们需要用专门的库来读取和查看。

import nibabel as nib
import numpy as np
import matplotlib.pyplot as plt

# 加载CT图像和标签
ct_path = ‘path/to/your/ct_scan.nii.gz’
label_path = ‘path/to/your/segmentation.nii.gz’

ct_img = nib.load(ct_path)
label_img = nib.load(label_path)

# 获取原始数据(numpy数组)
ct_data = ct_img.get_fdata()
label_data = label_img.get_fdata()

print(f“CT数据形状: {ct_data.shape}”)  # 例如 (512, 512, 120), 表示120层512x512的切片
print(f“标签数据形状: {label_data.shape}”)
print(f“CT值范围: [{ct_data.min():.1f}, {ct_data.max():.1f}]”)
print(f“标签唯一值: {np.unique(label_data)}”)  # 通常0是背景,1是椎体

CT值的单位是亨氏单位(HU),它直接反映了组织的密度。空气约为-1000 HU,水为0 HU,骨骼则在几百到上千HU。了解这个范围对后续的预处理至关重要。

2. 数据预处理:从原始CT到模型可“消化”的格式

原始CT数据不能直接扔给神经网络。这一步的目标是标准化和增强,是影响模型性能的关键,却常常被初学者忽视。一个鲁棒的预处理流程至少包含以下几步:

2.1 窗宽窗位调整与强度归一化

CT扫描仪生成的原始数据HU范围很广(例如-1000到3000),但我们对骨骼感兴趣的区域只占其中一部分。临床上通过“窗宽”和“窗位”来观察特定组织。对于脊椎,我们通常关注骨窗。

def apply_window(data, window_center, window_width):
    “”“应用CT窗宽窗位”“”
    window_min = window_center - window_width / 2
    window_max = window_center + window_width / 2
    data = np.clip(data, window_min, window_max)
    data = (data - window_min) / (window_max - window_min)  # 归一化到[0,1]
    return data

# 骨窗典型值:窗宽1500HU,窗位500HU
ct_windowed = apply_window(ct_data, window_center=500, window_width=1500)

之后,我们还需要将数据归一化到模型习惯的范围,比如[-1, 1]或[0, 1]。

def normalize_intensity(data, min_val=-200, max_val=800):
    “”“将特定HU范围内的值线性映射到[0,1]”“”
    data = np.clip(data, min_val, max_val)
    data = (data - min_val) / (max_val - min_val)
    return data

ct_normalized = normalize_intensity(ct_data, min_val=-200, max_val=800)

2.2 重采样与切片提取

不同扫描仪、不同患者的CT数据,其层厚和像素间距可能不同。为了训练的一致性,我们需要将所有数据重采样到相同的空间分辨率。

import SimpleITK as sitk

def resample_image(itk_image, new_spacing=[1.0, 1.0, 1.0]):
    “”“将图像重采样到新的像素间距(单位:mm)”“”
    original_spacing = itk_image.GetSpacing()
    original_size = itk_image.GetSize()

    new_size = [
        int(round(original_size[0] * (original_spacing[0] / new_spacing[0]))),
        int(round(original_size[1] * (original_spacing[1] / new_spacing[1]))),
        int(round(original_size[2] * (original_spacing[2] / new_spacing[2])))
    ]

    resampler = sitk.ResampleImageFilter()
    resampler.SetOutputSpacing(new_spacing)
    resampler.SetSize(new_size)
    resampler.SetOutputDirection(itk_image.GetDirection())
    resampler.SetOutputOrigin(itk_image.GetOrigin())
    resampler.SetTransform(sitk.Transform())
    resampler.SetDefaultPixelValue(itk_image.GetPixelIDValue())
    resampler.SetInterpolator(sitk.sitkLinear)  # 对于图像用线性插值
    return resampler.Execute(itk_image)

# 使用SimpleITK读取
itk_ct = sitk.ReadImage(ct_path)
itk_ct_resampled = resample_image(itk_ct, [1.0, 1.0, 1.0])
ct_data_resampled = sitk.GetArrayFromImage(itk_ct_resampled)  # 形状变为 (Z, Y, X)

U-Net最初是为2D图像设计的,因此我们通常按切片(Z轴)来处理3D体数据。这意味着我们将一个3D体积转化为一系列2D图像进行训练和预测。

2.3 数据增强:让小数据集发挥大作用

医学数据标注昂贵,样本量往往有限。数据增强通过对训练图像进行随机变换来人工扩展数据集,是防止模型过拟合、提升泛化能力的法宝。我们需要设计适用于医学影像的增强策略。

import albumentations as A
from albumentations.pytorch import ToTensorV2

def get_train_transform():
    “”“定义训练时的数据增强管道”“”
    return A.Compose([
        A.RandomRotate90(p=0.5),  # 随机旋转90度
        A.Flip(p=0.5),  # 水平或垂直翻转
        A.ElasticTransform(alpha=1, sigma=50, alpha_affine=50, p=0.1),  # 弹性形变,模拟组织柔软性
        A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3),  # 轻微亮度对比度变化
        A.GaussNoise(var_limit=(10.0, 50.0), p=0.1),  # 添加高斯噪声
        A.Normalize(mean=[0.5], std=[0.5]),  # 归一化到[-1,1]区间
        ToTensorV2(),  # 转换为PyTorch Tensor
    ])

def get_val_transform():
    “”“验证/测试时,只进行归一化和Tensor转换”“”
    return A.Compose([
        A.Normalize(mean=[0.5], std=[0.5]),
        ToTensorV2(),
    ])

注意:对图像和其对应的分割标签进行增强时,必须确保施加完全相同的空间变换。albumentations库通过同时对image和mask参数调用transform来保证这一点。

3. 构建你的U-Net模型:深入理解每一层

网上有很多U-Net的实现,但“复制粘贴”不如自己亲手搭建一遍来得深刻。我们将从最基础的组件开始,构建一个清晰、模块化的U-Net。理解其编码器-解码器结构以及跳跃连接的精髓,比单纯调用一个模型重要得多。

3.1 基础构建块:双重卷积

U-Net的每个阶段都由重复的“卷积 -> 批归一化 -> 激活函数”单元构成,通常是连续两次。我们将其封装成一个模块。

import torch
import torch.nn as nn
import torch.nn.functional as F

class DoubleConv(nn.Module):
    “”“(卷积 => 批归一化 => ReLU) * 2”“”
    def __init__(self, in_channels, out_channels, mid_channels=None):
        super().__init__()
        if not mid_channels:
            mid_channels = out_channels
        self.double_conv = nn.Sequential(
            nn.Conv2d(in_channels, mid_channels, kernel_size=3, padding=1, bias=False),
            nn.BatchNorm2d(mid_channels),
            nn.ReLU(inplace=True),
            nn.Conv2d(mid_channels, out_channels, kernel_size=3, padding=1, bias=False),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace=True)
        )

    def forward(self, x):
        return self.double_conv(x)

3.2 下采样与上采样模块

编码器部分通过下采样(通常是最大池化)逐步提取高级特征并缩小空间尺寸。解码器部分则通过上采样(转置卷积或插值)逐步恢复空间细节。

class Down(nn.Module):
    “”“下采样层:一个最大池化 + 一个DoubleConv块”“”
    def __init__(self, in_channels, out_channels):
        super().__init__()
        self.maxpool_conv = nn.Sequential(
            nn.MaxPool2d(2),
            DoubleConv(in_channels, out_channels)
        )

    def forward(self, x):
        return self.maxpool_conv(x)

class Up(nn.Module):
    “”“上采样层。包含上采样操作和与编码器对应层的特征融合(拼接)。”“”
    def __init__(self, in_channels, out_channels, bilinear=True):
        super().__init__()
        # 选择上采样方式
        if bilinear:
            self.up = nn.Upsample(scale_factor=2, mode=‘bilinear’, align_corners=True)
            self.conv = DoubleConv(in_channels, out_channels, in_channels // 2)
        else:
            self.up = nn.ConvTranspose2d(in_channels, in_channels // 2, kernel_size=2, stride=2)
            self.conv = DoubleConv(in_channels, out_channels)

    def forward(self, x1, x2):
        “”“
        x1: 来自解码器的特征图(需要上采样)
        x2: 来自编码器的对应层特征图(用于跳跃连接)
        ”“”
        x1 = self.up(x1)
        # 处理尺寸可能不完全匹配的情况(由于池化舍入等)
        diffY = x2.size()[2] - x1.size()[2]
        diffX = x2.size()[3] - x1.size()[3]
        x1 = F.pad(x1, [diffX // 2, diffX - diffX // 2,
                        diffY // 2, diffY - diffY // 2])
        # 沿通道维度拼接
        x = torch.cat([x2, x1], dim=1)
        return self.conv(x)

3.3 组装完整的U-Net

现在,我们可以像搭积木一样,将这些模块组合成完整的U-Net网络。

class UNet(nn.Module):
    def __init__(self, n_channels, n_classes, bilinear=True):
        super(UNet, self).__init__()
        self.n_channels = n_channels
        self.n_classes = n_classes
        self.bilinear = bilinear

        # 编码器路径 (Contracting Path)
        self.inc = DoubleConv(n_channels, 64)
        self.down1 = Down(64, 128)
        self.down2 = Down(128, 256)
        self.down3 = Down(256, 512)
        factor = 2 if bilinear else 1
        self.down4 = Down(512, 1024 // factor)

        # 解码器路径 (Expansive Path)
        self.up1 = Up(1024, 512 // factor, bilinear)
        self.up2 = Up(512, 256 // factor, bilinear)
        self.up3 = Up(256, 128 // factor, bilinear)
        self.up4 = Up(128, 64, bilinear)

        # 最后的1x1卷积,将通道数映射到类别数
        self.outc = nn.Conv2d(64, n_classes, kernel_size=1)

    def forward(self, x):
        x1 = self.inc(x)          # 初始特征
        x2 = self.down1(x1)       # 下采样1
        x3 = self.down2(x2)       # 下采样2
        x4 = self.down3(x3)       # 下采样3
        x5 = self.down4(x4)       # 瓶颈层

        # 上采样并融合跳跃连接的特征
        x = self.up1(x5, x4)
        x = self.up2(x, x3)
        x = self.up3(x, x2)
        x = self.up4(x, x1)
        logits = self.outc(x)     # 输出每个像素的类别分数
        return logits

# 实例化模型
model = UNet(n_channels=1, n_classes=2)  # 1个输入通道(灰度图),2个输出类别(背景和椎体)
print(model)

这个结构清晰地展示了U-Net的对称美:左侧编码器捕获上下文信息,右侧解码器精确定位。跳跃连接将编码器中的高分辨率细节直接传递到解码器,弥补了在下采样过程中丢失的空间信息,这是U-Net在医学图像分割中成功的关键。

4. 训练循环与实战技巧

有了数据和模型,接下来就是让它们“动”起来。训练一个深度学习模型不仅仅是调用 model.fit(),其中包含了许多影响最终结果的细节和技巧。

4.1 数据加载器(DataLoader)的实现

我们需要一个自定义的数据集类,来集成我们之前讨论的所有预处理和数据增强步骤。

from torch.utils.data import Dataset, DataLoader

class SpineSegDataset(Dataset):
    def __init__(self, image_paths, mask_paths, transform=None, is_train=True):
        self.image_paths = image_paths
        self.mask_paths = mask_paths
        self.transform = transform
        self.is_train = is_train

    def __len__(self):
        return len(self.image_paths)

    def __getitem__(self, idx):
        # 加载单张切片(假设已提前从3D体积中提取并保存为2D图像)
        image = np.load(self.image_paths[idx])  # 形状 (H, W),值已归一化
        mask = np.load(self.mask_paths[idx])    # 形状 (H, W),值为0/1

        # 增加通道维度,因为PyTorch期望 (C, H, W)
        image = np.expand_dims(image, axis=0).astype(np.float32)  # (1, H, W)
        mask = np.expand_dims(mask, axis=0).astype(np.float32)    # (1, H, W)

        if self.transform:
            # 使用albumentations,需要将通道维度移到最后 (H, W, C)
            image_trans = image.transpose(1, 2, 0).squeeze()  # (H, W)
            mask_trans = mask.transpose(1, 2, 0).squeeze()    # (H, W)
            augmented = self.transform(image=image_trans, mask=mask_trans)
            image = augmented[‘image’]  # 此时已是Tensor (C, H, W)
            mask = augmented[‘mask’].unsqueeze(0)  # 增加通道维
        else:
            # 仅转换为Tensor
            image = torch.from_numpy(image)
            mask = torch.from_numpy(mask)

        return image, mask.long()  # 标签需要是long类型

# 划分训练集和验证集
train_image_paths, val_image_paths = ... # 你的文件路径列表
train_mask_paths, val_mask_paths = ... 

train_dataset = SpineSegDataset(train_image_paths, train_mask_paths, transform=get_train_transform(), is_train=True)
val_dataset = SpineSegDataset(val_image_paths, val_mask_paths, transform=get_val_transform(), is_train=False)

train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True, num_workers=4, pin_memory=True)
val_loader = DataLoader(val_dataset, batch_size=4, shuffle=False, num_workers=4, pin_memory=True)

4.2 损失函数与评估指标的选择

对于二分类分割任务,Dice Loss 或 BCEWithLogitsLoss 与 Dice Loss 的结合是常见选择。Dice系数直接衡量预测区域与真实区域的重叠度,其作为损失函数可以缓解前景背景像素数量严重不平衡的问题。

def dice_coeff(pred, target, smooth=1e-6):
    “”“计算Dice系数”“”
    pred_flat = pred.contiguous().view(-1)
    target_flat = target.contiguous().view(-1)
    intersection = (pred_flat * target_flat).sum()
    return (2. * intersection + smooth) / (pred_flat.sum() + target_flat.sum() + smooth)

class DiceBCELoss(nn.Module):
    “”“Dice Loss + BCE Loss 的组合”“”
    def __init__(self, weight=None, size_average=True):
        super(DiceBCELoss, self).__init__()
        self.bce = nn.BCEWithLogitsLoss()

    def forward(self, inputs, targets):
        # inputs: 模型输出的logits (未经过sigmoid)
        # targets: 真实标签 (0或1)
        bce_loss = self.bce(inputs, targets.float())
        # 对inputs应用sigmoid得到概率
        inputs_sigmoid = torch.sigmoid(inputs)
        dice_loss = 1 - dice_coeff(inputs_sigmoid, targets)
        return bce_loss + dice_loss

4.3 完整的训练循环与验证

下面是一个包含了训练、验证、学习率调度和模型保存的完整循环示例。

import torch.optim as optim
from torch.optim import lr_scheduler
import time
import copy

def train_model(model, criterion, optimizer, scheduler, num_epochs=25, device=‘cuda’):
    since = time.time()
    best_model_wts = copy.deepcopy(model.state_dict())
    best_dice = 0.0

    model.to(device)

    for epoch in range(num_epochs):
        print(f‘Epoch {epoch}/{num_epochs - 1}’)
        print(‘-’ * 10)

        # 每个epoch都有训练和验证阶段
        for phase in [‘train’, ‘val’]:
            if phase == ‘train’:
                model.train()  # 设置模型为训练模式
                dataloader = train_loader
            else:
                model.eval()   # 设置模型为评估模式
                dataloader = val_loader

            running_loss = 0.0
            running_dice = 0.0

            # 迭代数据
            for inputs, labels in dataloader:
                inputs = inputs.to(device)
                labels = labels.to(device)

                # 清零梯度
                optimizer.zero_grad()

                # 前向传播
                # 只在训练时跟踪历史以计算梯度
                with torch.set_grad_enabled(phase == ‘train’):
                    outputs = model(inputs)
                    loss = criterion(outputs, labels)

                    # 计算Dice系数用于评估
                    probs = torch.sigmoid(outputs)
                    preds = (probs > 0.5).float()
                    dice = dice_coeff(preds, labels)

                    # 反向传播 + 优化仅在训练阶段进行
                    if phase == ‘train’:
                        loss.backward()
                        optimizer.step()

                # 统计
                running_loss += loss.item() * inputs.size(0)
                running_dice += dice.item() * inputs.size(0)

            if phase == ‘train’:
                scheduler.step()

            epoch_loss = running_loss / len(dataloader.dataset)
            epoch_dice = running_dice / len(dataloader.dataset)

            print(f‘{phase} Loss: {epoch_loss:.4f} Dice: {epoch_dice:.4f}’)

            # 深度复制表现最好的模型
            if phase == ‘val’ and epoch_dice > best_dice:
                best_dice = epoch_dice
                best_model_wts = copy.deepcopy(model.state_dict())
                # 保存最佳模型
                torch.save(model.state_dict(), ‘best_spine_unet_model.pth’)
        print()

    time_elapsed = time.time() - since
    print(f‘Training complete in {time_elapsed // 60:.0f}m {time_elapsed % 60:.0f}s’)
    print(f‘Best val Dice: {best_dice:4f}’)

    # 加载最佳模型权重
    model.load_state_dict(best_model_wts)
    return model

# 初始化模型、损失函数、优化器
device = torch.device(“cuda:0” if torch.cuda.is_available() else “cpu”)
model = UNet(n_channels=1, n_classes=1)  # 二分类,输出单通道用sigmoid激活
model = model.to(device)

criterion = DiceBCELoss()
optimizer = optim.Adam(model.parameters(), lr=1e-4)
# 每10个epoch将学习率乘以0.1
exp_lr_scheduler = lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)

# 开始训练!
model = train_model(model, criterion, optimizer, exp_lr_scheduler, num_epochs=30, device=device)

在训练过程中,你会观察到损失逐渐下降,验证集Dice系数逐渐上升。如果验证指标在多个epoch后不再提升甚至下降,可能是过拟合的信号,需要考虑使用更激进的数据增强、Dropout层或提前停止。

5. 结果可视化与模型推理

模型训练好后,我们需要直观地看到它的分割效果,并将其应用到新的数据上。这一步是检验我们工作成果的关键。

5.1 可视化训练过程与分割结果

我们可以绘制训练曲线,并随机挑选一些验证集样本,将原始CT、真实标签和模型预测结果放在一起对比。

def visualize_predictions(model, dataloader, device, num_samples=3):
    model.eval()
    fig, axes = plt.subplots(num_samples, 3, figsize=(12, 4*num_samples))

    with torch.no_grad():
        for idx, (images, masks) in enumerate(dataloader):
            if idx >= num_samples:
                break
            images = images.to(device)
            masks = masks.to(device)

            outputs = model(images)
            probs = torch.sigmoid(outputs)
            preds = (probs > 0.5).float()

            # 移回CPU并转换为numpy数组用于绘图
            img_np = images.cpu().squeeze().numpy()
            mask_np = masks.cpu().squeeze().numpy()
            pred_np = preds.cpu().squeeze().numpy()

            # 绘制
            axes[idx, 0].imshow(img_np, cmap=‘gray’)
            axes[idx, 0].set_title(‘Input CT Slice’)
            axes[idx, 0].axis(‘off’)

            axes[idx, 1].imshow(mask_np, cmap=‘gray’)
            axes[idx, 1].set_title(‘Ground Truth’)
            axes[idx, 1].axis(‘off’)

            axes[idx, 2].imshow(pred_np, cmap=‘gray’)
            axes[idx, 2].set_title(‘Model Prediction’)
            axes[idx, 2].axis(‘off’)

    plt.tight_layout()
    plt.show()

# 调用可视化函数
visualize_predictions(model, val_loader, device, num_samples=3)

5.2 对新的3D CT体积进行推理

在实际应用中,我们通常需要对整个病人的3D CT扫描进行分割。这需要我们将训练好的2D模型逐片应用到每一个切片上,然后再将结果重组为3D体积。

def predict_volume(model, volume_path, device, preprocess_func):
    “”“
    对整个3D体积进行预测
    volume_path: 3D .nii.gz 文件路径
    preprocess_func: 与训练时一致的预处理函数
    ”“”
    # 1. 加载并预处理体积数据
    itk_img = sitk.ReadImage(volume_path)
    # 这里应包含重采样、窗宽窗位调整、归一化等步骤,与训练一致
    volume_data = sitk.GetArrayFromImage(itk_img)  # (Z, H, W)
    original_shape = volume_data.shape

    # 2. 初始化一个空数组存放预测结果
    pred_volume = np.zeros(original_shape, dtype=np.uint8)

    model.eval()
    with torch.no_grad():
        for z in range(original_shape[0]):
            slice_2d = volume_data[z, :, :]  # 取出一张切片
            # 应用预处理
            processed_slice = preprocess_func(slice_2d)  # 返回 (1, H, W) 的Tensor
            processed_slice = processed_slice.unsqueeze(0).to(device)  # 增加batch维度

            # 预测
            output = model(processed_slice)
            prob = torch.sigmoid(output)
            pred_slice = (prob > 0.5).squeeze().cpu().numpy().astype(np.uint8)

            pred_volume[z, :, :] = pred_slice

    # 3. (可选)后处理:去除小连通域、形态学操作等
    pred_volume_cleaned = postprocess_volume(pred_volume)

    return pred_volume_cleaned

def postprocess_volume(volume, min_size=100):
    “”“简单的后处理:保留最大的连通区域”“”
    from scipy import ndimage
    labeled_array, num_features = ndimage.label(volume)
    sizes = ndimage.sum(volume, labeled_array, range(num_features + 1))
    mask_size = sizes < min_size
    remove_pixel = mask_size[labeled_array]
    volume[remove_pixel] = 0
    return volume

5.3 性能评估与常见问题排查

最后,我们需要用一些定量指标来客观评估模型在测试集上的表现。除了Dice系数,IoU(交并比)、精确率、召回率 也是常用的指标。

from sklearn.metrics import confusion_matrix, precision_score, recall_score, jaccard_score

def evaluate_model(model, test_loader, device):
    model.eval()
    all_preds = []
    all_labels = []

    with torch.no_grad():
        for images, labels in test_loader:
            images = images.to(device)
            labels = labels.to(device)

            outputs = model(images)
            probs = torch.sigmoid(outputs)
            preds = (probs > 0.5).float()

            all_preds.append(preds.cpu().view(-1).numpy())
            all_labels.append(labels.cpu().view(-1).numpy())

    all_preds = np.concatenate(all_preds)
    all_labels = np.concatenate(all_labels)

    dice = dice_coeff(torch.from_numpy(all_preds), torch.from_numpy(all_labels))
    iou = jaccard_score(all_labels, all_preds)
    precision = precision_score(all_labels, all_preds)
    recall = recall_score(all_labels, all_preds)

    print(f“测试集评估结果:”)
    print(f“  Dice系数: {dice:.4f}”)
    print(f“  IoU: {iou:.4f}”)
    print(f“  精确率: {precision:.4f}”)
    print(f“  召回率: {recall:.4f}”)

    # 打印混淆矩阵
    cm = confusion_matrix(all_labels, all_preds)
    print(f“混淆矩阵:\n{cm}”)

# 调用评估函数
evaluate_model(model, test_loader, device)

如果在评估中发现模型表现不佳,可以从以下几个方向排查:

  • 数据问题:检查预处理是否与训练时完全一致?标签是否正确?数据量是否足够?
  • 模型问题:模型是否足够复杂(深度/宽度)?是否出现了过拟合或欠拟合?
  • 训练问题:学习率设置是否合适?损失函数是否适用于当前任务?训练轮数是否足够?

我自己的经验是,在医学影像分割任务中,数据质量和预处理的一致性往往是决定模型上限的最关键因素。花时间仔细检查数据管道,确保输入模型的每一张图像都经过了正确的处理,这比盲目调整模型超参数要有效得多。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐