简单项目 · 基于 ResNet50 微调与多模态融合的骨龄评估模型

目录

要做什么

思路

数据集下载

代码实现

导入相关需要的库

自定义数据集 

实现基于 ResNet50 微调的骨龄预测模型

构建训练和验证函数

构建计算MAE损失函数

进入主函数 

数据的预处理以及数据加载

训练设备和模型的加载 

进入模型的第一阶段的训练——训练最后新增加的层

进入模型的第二阶段的训练——训练全部网络

保存模型参数以及返回模型性能评价

总结反思


要做什么

手腕部骨龄是预测青少年儿童生长发育的重要指标,在传统的医学诊断中主要依赖人工判断,这一过程过程主观性强、耗时费力。

如果能够使用机器学习的方案,将X光片和性别信息输入,自动获取骨龄信息,那么无疑将极大地缓解医生压力。

目前已有许多研究利用机器学习,尤其是深度学习方法,对儿童骨龄X光片进行自动骨龄检测和评估。主要的研究工作和方法包括:

  1. RSNA骨龄挑战赛推动的深度学习方法
    2017年RSNA举办的“RSNA Pediatric Bone Age Challenge”提供了一个大规模公开数据集,极大促进了自动骨龄评估模型的开发。许多团队利用卷积神经网络(CNN)进行端到端的骨龄预测,并以均方误差(MAE)等指标进行评价。

  2. 基于深度卷积神经网络的骨龄预测模型
    例如,Ren等(2018)提出了基于回归卷积神经网络的自动骨龄评估方法,该方法直接从手部X光图像中提取特征,并实现了较低的预测误差。

    同时,Lee等(2017)开发了一个完全自动化的深度学习系统,该系统在处理海量手部X光图像时能快速给出骨龄预测结果。
  3. 结合图像分割技术的骨龄评估
    有研究采用了Mask R-CNN等图像分割方法来精确分离出手部的骨骼区域,进一步减少背景干扰,再利用Xception等网络进行骨龄回归,从而提升预测精度。

  4. 中国学者的相关探索
    针对我国儿童的骨龄特点,部分研究基于“中华05标准”等本土骨龄评估方法,结合卷积神经网络、迁移学习等技术,设计了数字化骨龄X光片自动识别算法,实现了误差仅在0.5岁左右的高准确度。

  5. 其他机器学习方法
    除了深度学习模型外,还有部分研究尝试使用支持向量机(SVM)、贝叶斯网络和传统回归等方法,对骨龄进行评估;近期也有一些工作引入了对抗性回归学习和岭回归神经网络,以进一步提高模型的泛化能力和准确性。

为了代码练手,增大项目经验,接下来笔者将分享自己使用基于 ResNet50 模型微调的骨龄预测模型

思路

使用已经在更大的数据集上预训练好的 ResNet50 模型,将最后的 fc 替换为符合任务需要的层。

在训练的时候先锁定预训练的层,只训练替换的层,充分训练之后再训练全部的层。

最后计算 MAE 损失,使得直观的得到模型的性能。

数据集下载

Bone Age Regression | Kaggle

代码实现

导入相关需要的库

import os
import pandas as pd
from PIL import Image

import torch
from torch import nn, optim
from torch.utils.data import Dataset, DataLoader
import torchvision.transforms as transforms
import torchvision.models as models
from sklearn.model_selection import train_test_split

自定义数据集 

 由于本项目任务是实现多模态预测模型,需要 X-ray 图像信息  male 二元文字信息 综合之后得到预测结果,所以这里自定义骨龄数据集,用于保存、迭代对应的信息。

class BoneAgeDataset(Dataset):
    """
    自定义数据集
    """
    def __init__(self, csv_file, root_dir, transform=None):
        """
        :param csv_file: CSV 文件路径,文件中包含 "fileName", "boneage" 和 "male" 三列
        :param root_dir: 图像文件存放的根目录
        :param transform: 图像预处理转换
        """
        self.data_info = pd.read_csv(csv_file)
        self.root_dir = root_dir
        self.transform = transform

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

    def __getitem__(self, idx):
        # 获取图像文件名及完整路径
        filename = self.data_info.iloc[idx]['fileName']
        img_path = os.path.join(self.root_dir, filename)
        image = Image.open(img_path).convert("RGB")

        # 获取骨龄标签
        bone_age = self.data_info.iloc[idx]['boneage']

        # 获取性别信息,并转换为数值(“male”列中 TRUE 表示男性)
        gender_str = str(self.data_info.iloc[idx]['male']).strip().upper()
        gender_val = 1.0 if gender_str == "TRUE" else 0.0

        if self.transform:
            image = self.transform(image)
        return image, torch.tensor(bone_age, dtype=torch.float32), torch.tensor(gender_val, dtype=torch.float32)

实现基于 ResNet50 微调的骨龄预测模型

 这里使用已经在较大的数据集上预训练好的 ResNet50 模型,进行迁移学习。

为了实现迁移学习的目的,首先通过 self.num_features = backbone.fc.in_features 来保存原 ResNet50 模型最后全连接层的输入特征数。然后通过 backbone.fc = nn.Identity()  去掉 ResNet50 模型最后的全连接层,将在后续工作换上目标任务的全连接层。

为了实现多模态的任务目的,将使用 self.gender_branch 将原有的1维布尔型性别分类信息转化为32维的高维可学习向量。

接着构建 self.fc 来实现模型的数据流动过程。

在前向传播中,为了使得图像信息和性别信息能够多模态融合,将性别信息构造成和图像信息适配的形状,然后和图像信息 cat 起来,一起进入self.fc 进行处理。

class EnhancedBoneAgeModel(nn.Module):
    """
    模型构建
    """
    def __init__(self):
        super().__init__()
        # 使用预训练的 ResNet50 作为特征提取器
        weights = models.ResNet50_Weights.IMAGENET1K_V1
        backbone = models.resnet50(weights=weights)
        self.num_features = backbone.fc.in_features
        backbone.fc = nn.Identity()  # 去掉最后的全连接层
        self.backbone = backbone

        # 构建性别信息处理分支,将1维信息映射到32维
        self.gender_branch = nn.Sequential(
            nn.Linear(1, 32),
            nn.BatchNorm1d(32),
            nn.ReLU(),
            nn.Dropout(0.2)
        )

        # 融合后的全连接网络
        self.fc = nn.Sequential(
            nn.Linear(self.num_features + 32, 256),
            nn.BatchNorm1d(256),
            nn.ReLU(),
            nn.Dropout(0.5),
            nn.Linear(256, 512),
            nn.BatchNorm1d(512),
            nn.ReLU(),
            nn.Dropout(0.5),
            nn.Linear(512, 256),
            nn.BatchNorm1d(256),
            nn.ReLU(),
            nn.Dropout(0.5),
            nn.Linear(256, 128),
            nn.BatchNorm1d(128),
            nn.ReLU(),
            nn.Dropout(0.5),
            nn.Linear(128, 1)
        )

    def forward(self, image, gender):
        features = self.backbone(image)  # [batch_size, num_features]
        # 保证 gender 形状为 [batch_size, 1]
        if gender.dim() == 1:
            gender = gender.unsqueeze(1)
        gender_features = self.gender_branch(gender)  # [batch_size, 32]
        x = torch.cat([features, gender_features], dim=1)
        out = self.fc(x)
        return out

构建训练和验证函数

为了让代码增强可读性和模块性,将构建训练和验证函数,分别返回对应的损失值。

# 训练与验证函数
def train_epoch(model, loader, criterion, optimizer, device):
    model.train()
    running_loss = 0.0
    for images, bone_ages, genders in loader:
        images = images.to(device)
        bone_ages = bone_ages.to(device).unsqueeze(1)  # [batch_size, 1]
        genders = genders.to(device)

        optimizer.zero_grad()
        outputs = model(images, genders)
        loss = criterion(outputs, bone_ages)
        loss.backward()
        optimizer.step()

        running_loss += loss.item() * images.size(0)
    return running_loss / len(loader.dataset)


def eval_epoch(model, loader, criterion, device):
    model.eval()
    running_loss = 0.0
    with torch.no_grad():
        for images, bone_ages, genders in loader:
            images = images.to(device)
            bone_ages = bone_ages.to(device).unsqueeze(1)
            genders = genders.to(device)

            outputs = model(images, genders)
            loss = criterion(outputs, bone_ages)

            running_loss += loss.item() * images.size(0)
    return running_loss / len(loader.dataset)

构建计算MAE损失函数

为了很直观的衡量模型的性能,直接得到模型预测和真实值误差多少时间,使用MAE损失函数来进行最后的评价。

这里构建一个计算函数来实现。

def compute_mae(model, loader, device):
    model.eval()
    total_mae = 0.0
    with torch.no_grad():
        for images, bone_ages, genders in loader:
            images = images.to(device)
            bone_ages = bone_ages.to(device).unsqueeze(1)
            genders = genders.to(device)

            outputs = model(images, genders)
            mae = torch.abs(outputs - bone_ages).sum().item()
            total_mae += mae
    return total_mae / len(loader.dataset)

进入主函数 

这里的训练策略为:

  1. 首先固定原 ResNet50 的参数,对于新增加的头(fc、gender_branch)进行训练。
  2. 在充分训练之后,此时对于新增加的头的参数,已经是相对优良的状态。
  3. 然后解锁之前固定的参数,对全部的网络进行训练。
  4. 在充分训练之后,对于模型的全部参数,达到较为不错的状态

数据的预处理以及数据加载

为了增强数据的质量,在数据的预处理阶段,将训练的数据图像进行放缩到指定大小、随机水平翻转、随机旋转、归一化等操作,对于评测的数据图像仅进行归一化的操作。

增强后的数据有利于模型更好地学习。

然后按照8:2的比例将数据进行划分为 训练集和测试集,保存它们的信息为 “train_split.csv” 和 “val_split.csv”

接下来加载数据,进行下一步训练。

    # 图像预处理配置
    IMG_SIZE = 224
    train_transform = transforms.Compose([
        transforms.Resize((IMG_SIZE, IMG_SIZE)),
        transforms.RandomHorizontalFlip(),
        transforms.RandomRotation(20),
        transforms.ToTensor(),
        transforms.Normalize(mean=[0.1827, 0.1827, 0.1827],
                             std=[0.1643, 0.1643, 0.1643])
    ])

    val_transform = transforms.Compose([
        transforms.Resize((IMG_SIZE, IMG_SIZE)),
        transforms.ToTensor(),
        transforms.Normalize(mean=[0.1827, 0.1827, 0.1827],
                             std=[0.1643, 0.1643, 0.1643])
    ])


    csv_path = 'data.csv'
    df = pd.read_csv(csv_path)
    train_df, val_df = train_test_split(df, test_size=0.2, random_state=42)

    train_csv = 'train_split.csv'
    val_csv = 'val_split.csv'
    train_df.to_csv(train_csv, index=False)
    val_df.to_csv(val_csv, index=False)

    image_root = 'dataset\images'

    # 构造数据集
    train_dataset = BoneAgeDataset(csv_file=train_csv, root_dir=image_root, transform=train_transform)
    val_dataset = BoneAgeDataset(csv_file=val_csv, root_dir=image_root, transform=val_transform)

    BATCH_SIZE = 32
    train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=4)
    val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=4)

训练设备和模型的加载 

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    model = EnhancedBoneAgeModel().to(device)

进入模型的第一阶段的训练——训练最后新增加的层

通过   param.requires_grad = False   来控制模型无需训练的层

    # 阶段1:冻结预训练部分,仅训练新增层(包括 fc 和 gender_branch)
    for param in model.backbone.parameters():
        param.requires_grad = False
    criterion = nn.MSELoss()
    optimizer = optim.Adam(list(model.fc.parameters()) + list(model.gender_branch.parameters()),
                           lr=1e-3, weight_decay=1e-4)
    scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5)
    epochs_top = 30

    print("=== 阶段1:训练新增层和性别分支 ===")
    for epoch in range(epochs_top):
        train_loss = train_epoch(model, train_loader, criterion, optimizer, device)
        val_loss = eval_epoch(model, val_loader, criterion, device)
        scheduler.step()
        print(f"Epoch [{epoch + 1}/{epochs_top}], 训练损失: {train_loss:.4f}, 验证损失: {val_loss:.4f}")

进入模型的第二阶段的训练——训练全部网络

通过   param.requires_grad = True  来打开模型需要训练的层

    # 阶段2:Fine-tuning 全部网络
    for param in model.parameters():
        param.requires_grad = True
    optimizer = optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-4)
    scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5)
    epochs_finetune = 30

    print("=== 阶段2:Fine-tuning 全部层 ===")
    for epoch in range(epochs_finetune):
        train_loss = train_epoch(model, train_loader, criterion, optimizer, device)
        val_loss = eval_epoch(model, val_loader, criterion, device)
        scheduler.step()
        print(f"Epoch [{epoch + 1}/{epochs_finetune}], 训练损失: {train_loss:.4f}, 验证损失: {val_loss:.4f}")

保存模型参数以及返回模型性能评价

    torch.save(model.state_dict(), "enhanced_bone_age_predictor.pth")
    print("模型已保存!")

    mae_val = compute_mae(model, val_loader, device)
    print(f"验证集 MAE: {mae_val:.4f}")

总结反思

下面将展示模型的运行结果: 

 可以看到,模型的平均预测误差为 8.26 个月,和目前被认为是最领先的4~5个月虽然仍有一定的差距,但是对于新手练习基于预训练模型微调以及多模态模型的构建仍然有积极意义。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值