一、数据准备

数据整理:将杂乱的数据集按列别整理到标准的目录结构,确保格式统一,便于后续处理。

数据整理主要分为三步:

  • 分析数据类别:扫描原始数据,确定所有类别及其分布
  • 创建目录结构:建立train、valid、test目录,每个目录下包含所有类别的子目录
  • 生成元数据并划分:创建包含文件路径和类别信息的元数据,使用分层抽样按70-15-15比例划分为训练集、验证集和测试集
  • 组织物理文件:根据划分结果将文件复制到对应的目录中
import pandas as pd
import os
import shutil
from sklearn.model_selection import train_test_split

def prepare_data_only(raw_data_dir, output_dir):

    # 1. 收集元数据
    metadata = []
    for filename in os.listdir(raw_data_dir):
        if filename.endswith(('.jpg', '.png', '.jpeg')):
            category = filename.split('_')[0]
            metadata.append({
                'filename': filename,
                'category': category,
                'file_path': os.path.join(raw_data_dir, filename)
            })
    
    df = pd.DataFrame(metadata)
    categories = df['category'].unique()
    print(f"发现 {len(categories)} 个类别: {list(categories)}")
    
    # 2. 创建目录结构
    for split in ['train', 'valid', 'test']:
        for category in categories:
            os.makedirs(os.path.join(output_dir, split, category), exist_ok=True)
    
    # 3. 划分数据集
    train_df, temp_df = train_test_split(
        df, test_size=0.3, stratify=df['category'], random_state=42
    )
    val_df, test_df = train_test_split(
        temp_df, test_size=0.5, stratify=temp_df['category'], random_state=42
    )
    
    # 4. 复制文件到对应目录
    for split_name, split_df in [('train', train_df), ('valid', val_df), ('test', test_df)]:
        for _, row in split_df.iterrows():
            src = row['file_path']
            dst = os.path.join(output_dir, split_name, row['category'], row['filename'])
            shutil.copy2(src, dst)
        
        # 保存划分信息
        split_df.to_csv(os.path.join(output_dir, f'{split_name}_metadata.csv'), index=False)
    
    print("数据准备完成!")
    return train_df, val_df, test_df

# 使用:这个函数只需要运行一次!
# train_meta, val_meta, test_meta = prepare_data_only('./raw_data', './processed_data')

二、模型配置

参数配置:设置超参数、配置模型路径、数据路径定义数据增强策略。

超参数是模型训练前需要手动设定的参数,它们控制着学习过程和模型结构。合理的设置超参数能够显著影响模型的性能;良好的路径管理能够确保代码的可移植性、避免硬编码并为团队协作提供方便。

整个参数配置的过程中需要将配置分解为独立的组件,以便于维护和修改,并且需要保证实验的可重复性和类的可扩展性,为后续的模型训练、评估和部署奠定基础。

方法一:创建对应的配置类,通过动态类来确保可扩展性

class CifarConfig:
    pass

cfg = CifarConfig()
cfg.pb = True  # 是否采用渐进式采样
cfg.mixup = False  # 是否采用mixup
cfg.mixup_alpha = 1.  # beta分布的参数. beta分布是一组定义在(0,1) 区间的连续概率分布。
cfg.label_smooth = False  # 是否采用标签平滑
cfg.label_smooth_eps = 0.01  # 标签平滑超参数

cfg.train_bs = 128
cfg.valid_bs = 128
cfg.workers = 4

cfg.lr_init = 0.1
cfg.momentum = 0.9
cfg.weight_decay = 1e-4
cfg.factor = 0.1
cfg.milestones = [160, 180]
cfg.max_epoch = 200

cfg.log_interval = 20

# 数据预处理设置
cfg.norm_mean = [0.4914, 0.4822, 0.4465] 
cfg.norm_std = [0.2023, 0.1994, 0.2010]

normTransform = transforms.Normalize(cfg.norm_mean, cfg.norm_std)
cfg.transforms_train = transforms.Compose([
    transforms.Resize(32),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomCrop(32, padding=4),
    transforms.ToTensor(),
    normTransform
])

cfg.transforms_valid = transforms.Compose([
    transforms.Resize((32, 32)),
    transforms.ToTensor(),
    normTransform
])

方法二:简单的参数配置可以使用采用EasyDict()来实现,这样就不用通过写配置类中的str魔术方法来配合日志的获取了。

cfg = EasyDict()  # 访问属性的方式去使用key-value 即通过 .key获得value

cfg.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
cfg.max_epoch = 150  # 150

cfg.crop_size = (360, 480)

# 梯度裁剪
cfg.hist_grad = True
cfg.is_clip = False
cfg.clip_value = 0.2

# batch size
cfg.train_bs = 8
cfg.valid_bs = 4
cfg.workers = 4

# 学习率
cfg.lr_init = 0.1  # pretraied_model::0.1
cfg.factor = 0.1
cfg.milestones = [75, 130]
cfg.weight_decay = 1e-4
cfg.momentum = 0.9

cfg.log_interval = 10

三、模型训练

1、数据加载

实现自定义的dataset类,加载数据集,应用数据增强和预处理。

PyTorch 的数据加载核心是DataLoader,它可以自动实现并行数据加载、批处理、打乱顺序等。但 DataLoader需要一个标准接口来知道如何获取每一个数据及其标签。这个标准接口就是 Dataset 类。通过实现__len__() 和 __getitem__()方法,我们告诉 DataLoader 数据的总量以及如何按索引获取第 i 个样本。

import torch
from torch.utils.data import Dataset, DataLoader
from PIL import Image
import pandas as pd
import os

class CustomDataset(Dataset):
    """
    自定义数据集类
    """
    def __init__(self, annotations_file, img_dir, transform=None):
        """
        初始化函数,通常在这里读取数据索引或元信息,并设置数据变换。
        一次性将所有文件的路径和标签读入内存,而不是读入所有数据,节省内存。
        `transform` 参数使得数据增强和预处理可以灵活配置。
        """
        self.img_labels = pd.read_csv(annotations_file)
        self.img_dir = img_dir
        self.transform = transform

    def __len__(self):
        """
        返回数据集的长度。
        为什么必须实现?DataLoader需要知道在一个epoch中要迭代多少次。
        """
        return len(self.img_labels)

    def __getitem__(self, idx):
        """
        根据索引idx返回一个样本(数据和标签)。
        为什么必须实现?DataLoader在每次迭代时就是调用这个方法来获取一个batch的数据。
        """
        # 1. 根据索引从元信息中定位数据(如图片路径和标签)
        img_path = os.path.join(self.img_dir, self.img_labels.iloc[idx, 0])
        image = Image.open(img_path) # 懒加载:用到时才读入图片
        label = self.img_labels.iloc[idx, 1]
        
        # 2. 应用预处理和数据增强
        if self.transform:
            image = self.transform(image) # 例如 ToTensor(), Normalize()
            
        # 3. 返回张量格式的数据和标签
        return image, label

# 使用示例
from torchvision import transforms

# 定义训练和验证时的数据变换(规则:训练需要增强,验证只需基础预处理)
train_transform = transforms.Compose([
    transforms.RandomHorizontalFlip(), # 数据增强,增加模型泛化能力
    transforms.Resize((224, 224)),
    transforms.ToTensor(), # 将PIL图像或NumPy数组转换为PyTorch张量,并归一化到[0,1]
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) # 标准化
])
val_transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

# 创建Dataset实例
train_dataset = CustomDataset("train_labels.csv", "train_images/", transform=train_transform)
val_dataset = CustomDataset("val_labels.csv", "val_images/", transform=val_transform)

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

2、训练核心:Train()类

训练过程包含很多固定的组件:模型、损失函数、优化器、学习率调度器等。同时,训练和验证的逻辑非常相似(前向传播、计算损失),但又有关键区别(是否计算梯度、是否更新参数)。将其封装成类,可以将这些组件和状态(如当前epoch、最佳准确率)很好地管理起来,使代码更清晰,并且易于扩展功能(如早停、模型保存、日志记录)。

import torch
import torch.nn as nn
from tqdm import tqdm # 用于显示进度条

class Trainer:
    def __init__(self, model, criterion, optimizer, device, scheduler=None):
        """
        初始化训练器。
        将组件作为参数传入: 提高灵活性,可以轻松更换不同的模型、损失函数等。
        """
        self.model = model
        self.criterion = criterion
        self.optimizer = optimizer
        self.device = device
        self.scheduler = scheduler # 学习率调度器,非必须

        # 记录训练过程中的指标,用于后续分析
        self.train_losses = []
        self.val_losses = []
        self.train_accuracies = []
        self.val_accuracies = []

        # 将模型移动到指定设备(GPU/CPU)
        self.model.to(self.device)

    def train_epoch(self, dataloader):
        """在一个epoch上训练模型"""
        self.model.train() # 设置模型为训练模式(影响Dropout和BatchNorm等层)
        running_loss = 0.0
        correct = 0
        total = 0

        # 使用tqdm包装数据加载器,显示进度条
        pbar = tqdm(dataloader, desc="Training")
        for batch_idx, (data, targets) in enumerate(pbar):
            # 1. 将数据移动到设备
            data, targets = data.to(self.device), targets.to(self.device)

            # 2. 前向传播
            outputs = self.model(data)
            loss = self.criterion(outputs, targets)

            # 3. 反向传播与优化
            self.optimizer.zero_grad() # 清空上一步的梯度,防止累积
            loss.backward() # 反向传播,计算梯度
            self.optimizer.step() # 更新模型参数

            # 4. 统计信息
            running_loss += loss.item()
            _, predicted = outputs.max(1)
            total += targets.size(0)
            correct += predicted.eq(targets).sum().item()

            # 更新进度条信息
            pbar.set_postfix({
                'Loss': f"{running_loss/(batch_idx+1):.4f}",
                'Acc': f"{100.*correct/total:.2f}%"
            })

        epoch_loss = running_loss / len(dataloader)
        epoch_acc = 100. * correct / total
        self.train_losses.append(epoch_loss)
        self.train_accuracies.append(epoch_acc)
        return epoch_loss, epoch_acc

    def validate_epoch(self, dataloader):
        """在一个epoch上验证模型"""
        self.model.eval() # 设置模型为评估模式(关闭Dropout等)
        running_loss = 0.0
        correct = 0
        total = 0

        # 在验证阶段不计算梯度,节省内存和计算资源
        with torch.no_grad():
            pbar = tqdm(dataloader, desc="Validation")
            for batch_idx, (data, targets) in enumerate(pbar):
                data, targets = data.to(self.device), targets.to(self.device)
                outputs = self.model(data)
                loss = self.criterion(outputs, targets)

                running_loss += loss.item()
                _, predicted = outputs.max(1)
                total += targets.size(0)
                correct += predicted.eq(targets).sum().item()

                pbar.set_postfix({
                    'Loss': f"{running_loss/(batch_idx+1):.4f}",
                    'Acc': f"{100.*correct/total:.2f}%"
                })

        epoch_loss = running_loss / len(dataloader)
        epoch_acc = 100. * correct / total
        self.val_losses.append(epoch_loss)
        self.val_accuracies.append(epoch_acc)
        return epoch_loss, epoch_acc

核心要点:

  • model.train()和model.eval()是必须遵循的规则,他控制了Dropout和BatchNorm等层的行为,训练的时候需要这些层工作,验证和测试的时候就需要这些层关闭。
  • troch.no_grad():在验证和测试的时候使用,可以大幅减少内存消耗并增加计算,因为其禁用了梯度计算。
  • optimizer.zero_grad():PyTorch默认会累积梯度,所以在每次反向传播前需要手动清空。

3、模型训练

模型训练脚本是主训练脚本,它是项目的“总控制器”或“配置中心”。在这里,我们实例化所有组件,并定义整个训练流程(跑多少个epoch,何时验证,何时保存模型)。这种结构使得超参数和实验设置一目了然。

import torch
import torch.nn as nn
import torch.optim as optim
from torch.optim import lr_scheduler

# 假设我们从一个模型文件中导入我们的模型
from models.my_model import MyCNN
from data.dataset import CustomDataset
from core.trainer import Trainer

def main():
    # 配置超参数和设备
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    num_epochs = 50
    learning_rate = 0.001

    # 1. 初始化组件
    print("Initializing components...")
    model = MyCNN(num_classes=10)
    criterion = nn.CrossEntropyLoss() # 分类任务常用损失
    optimizer = optim.Adam(model.parameters(), lr=learning_rate)
    # 使用学习率调度器,在验证损失停滞时降低学习率
    scheduler = lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', patience=5, factor=0.5)

    # 2. 创建数据加载器 
    train_loader = ...
    val_loader = ...

    # 3. 初始化训练器
    trainer = Trainer(model, criterion, optimizer, device, scheduler)

    # 4. 训练循环
    print("Starting training...")
    best_val_acc = 0.0
    for epoch in range(num_epochs):
        print(f"\nEpoch [{epoch+1}/{num_epochs}]")
        
        # 训练一个epoch
        train_loss, train_acc = trainer.train_epoch(train_loader)
        # 验证一个epoch
        val_loss, val_acc = trainer.validate_epoch(val_loader)

        # 根据验证损失调整学习率
        trainer.scheduler.step(val_loss)

        # 保存最佳模型(规则:在验证集上性能最好的模型)
        if val_acc > best_val_acc:
            best_val_acc = val_acc
            torch.save({
                'epoch': epoch,
                'model_state_dict': model.state_dict(),
                'optimizer_state_dict': optimizer.state_dict(),
                'best_acc': best_val_acc,
            }, 'best_model.pth')
            print(f"==> Saved new best model with Val Acc: {best_val_acc:.2f}%")

    print("Training Finished!")

if __name__ == '__main__':
    main()

关键要点:

  • 设备检测:代码优先使用GPU,这个是深度学习训练的默认规则。
  • 保存最佳模型:我们根据验证集准确率来保存模型,而不是训练集准确率。这是为了防止过拟合,并选择泛化能力最强的模型,这是机器学习中一个核心的工程实践。
  • 学习率调度:ReduceLROnPlateu 是一个常用的策略,当模型性能不再提升时自动降低学习率,有助于模型收敛到更优点。

四、模型评估

全面的性能评估:在测试集上评估训练好的模型,得出准确率(整体性能的直观指标)、混淆矩阵(解释类别间的混淆关系)、分类报告(详细的precision \ recall \ f1-score)指标。

评估指标选择原则:多维度评估模型性能、识别模型的优势和弱点、为错误分析提供依据。

def evaluate_flower(model, test_generator, config):
    """
    全面评估模型性能
    
    工程化评估方法:
    - 使用保留的测试集保证评估客观性
    - 多指标综合评估
    - 可视化评估结果
    """
    
    # 1. 基础准确率评估
    test_loss, test_accuracy = model.evaluate(test_generator)
    print(f"测试集准确率: {test_accuracy:.4f}")
    
    # 2. 预测结果
    y_pred = model.predict(test_generator)
    y_pred_classes = np.argmax(y_pred, axis=1)
    y_true = test_generator.classes
    
    # 3. 混淆矩阵 - 分析类别间混淆情况
    cm = confusion_matrix(y_true, y_pred_classes)
    plt.figure(figsize=(10, 8))
    sns.heatmap(cm, annot=True, fmt='d', cmap='Blues')
    plt.title('混淆矩阵')
    plt.ylabel('真实标签')
    plt.xlabel('预测标签')
    plt.savefig('confusion_matrix.png')
    
    # 4. 分类报告 - 详细的性能指标
    class_report = classification_report(
        y_true, 
        y_pred_classes, 
        target_names=test_generator.class_indices.keys()
    )
    print("分类报告:")
    print(class_report)
    
    # 5. 额外指标
    precision = precision_score(y_true, y_pred_classes, average='weighted')
    recall = recall_score(y_true, y_pred_classes, average='weighted')
    f1 = f1_score(y_true, y_pred_classes, average='weighted')
    
    print(f"加权精确率: {precision:.4f}")
    print(f"加权召回率: {recall:.4f}")
    print(f"加权F1分数: {f1:.4f}")
    
    return {
        'accuracy': test_accuracy,
        'confusion_matrix': cm,
        'classification_report': class_report,
        'precision': precision,
        'recall': recall,
        'f1_score': f1
    }

五、最终所有的流程

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐