3. 深度学习初探:多分类任务实战#

通过本次任务,你将学会如何使用 PyTorch 构建和训练一个深度神经网络模型,以解决一个实际的多分类问题。

3.1. 任务背景#

自从机器人特调柠檬水成功抓住顾客的味蕾,你的无人糖水铺账户余额便持续攀升。但这这小小的甜头却让你嗅到了更大的风口——生椰系咖啡正在街头蔓延,生椰拿铁、双椰拿铁、烤椰拿铁接连刷屏社交媒体。你迅速跟进,让机器人同步上新这三款饮品。

现实却很快泼来一盆冷水:由于原料相近、口味相似、品质不稳,顾客纷纷反馈“喝不出区别”。你明白,市场要的是鲜明特色,而不是模糊的将就——只有让风味足够突出,顾客才会记住“这就是生椰拿铁”,而不是“好像带点椰子味的咖啡”。

如何破局?灵感倏然而至:既然上次通过模型预测柠檬水评分成效显著,这次何不直接训练一个“风味质检员”?让它像资深品鉴师一样,准确分辨出生椰拿铁、双椰拿铁或烤椰拿铁,并给出对应概率。只要置信度超过 80%,才允许机器人出品;低于这条线,则自动返工调整。这样一来,每一杯都能稳稳锁定其风味核心,不再依靠偶然的口碑。

新的挑战已来,而技术的齿轮,又一次在创新的起点悄然转动。

3.2. 最少必要知识#

  • 深度神经网络

  • 激活函数

  • 交叉熵损失

3.3. 任务鸟瞰#

3.3.1. 任务分析#

本次的任务是训练一个风味质检模型,它能够识别出各种咖啡的类型,并给出相应的概率。

image.png

3.3.2. 模型结构#

在机器学习中,任务复杂度直接决定了模型所需具备的能力。本次任务由单输出回归问题拓展为多输出分类问题,不仅输出维度增加,也对输入与输出之间关系的建模能力提出了更高要求。为有效捕捉输入特征与多个输出之间的复杂依赖,模型需要拥有更强大的表示能力。

加深模型深度是增强模型能力的直接策略之一。有多个层相互堆叠的神经网络,也叫深度神经网络(Deep Neural Network,DNN)。深度神经网络可以实现层次化的特征抽象与转换:

  • 浅层网络通常学习基础、局部的特征模式;

  • 深层网络则进一步整合这些基础特征,形成更加复杂和全局的表示。

本次的任务中,模型将采用深度神经网络结构,其结构如下:

image.png

本次的模型包含两个层:输入层和输出层。

  • 输入层:接收咖啡的样本数据,并生成相应的特征表示。

  • 输出层:则根据样本的特征表示,生成类型的预测得分。

我们依然遵循以下四个步骤组织本章的内容:数据准备、模型定义、模型训练与模型评估。在正式开始之前,先配置环境,避免因为环境不同而导致程序不能复现。

3.4. 环境配置#

3.4.1. 安装依赖#

!pip install --upgrade dsxllm -i https://pypi.org/simple

3.4.2. 环境版本#

from dsxllm.util import show_version

show_version()
本书愿景:
+------+--------------------------------------------------------+
| Info |                  《动手学大语言模型》                  |
+------+--------------------------------------------------------+
| 作者 |                       吾辈亦有感                       |
| 哔站 |      https://space.bilibili.com/3546632320715420       |
| 定位 | 基于'从零构建'的理念,用实战帮助程序员快速入门大模型。 |
| 愿景 | 若让你的AI学习之路走的更容易一点,我将倍感荣幸!祝好😄 |
+------+--------------------------------------------------------+
环境信息:
+-------------+--------------+------------------------+
| Python 版本 | PyTorch 版本 | PyTorch Lightning 版本 |
+-------------+--------------+------------------------+
|   3.12.12   |    2.10.0    |         2.6.1          |
+-------------+--------------+------------------------+

3.5. 数据准备#

3.5.1. 观察数据#

在模型训练伊始,对数据进行初步审视是至关重要的步骤。我们首先读取训练语料,并随机展示其中的 5 条样本,直观了解数据的结构。

import pandas as pd

# 读取训练集数据
train_data_frame = pd.read_csv('./dataset/coffee_train.csv')

# 随机显示5行数据
train_data_frame.sample(5)
椰浆 咖啡 牛奶 种类
31 5.4 3.4 1.5 0.4 生椰拿铁
4 5.0 3.6 1.4 0.2 生椰拿铁
11 4.8 3.4 1.6 0.2 生椰拿铁
34 4.9 3.1 1.5 0.2 生椰拿铁
53 6.1 2.9 4.7 1.4 双椰拿铁

我们可以看到,每条样本数据中均包含四个明确的输入特征(分别对应椰浆、咖啡、牛奶和糖的含量)以及一个对应的饮品类别标签(生椰拿铁、双椰拿铁或烤椰拿铁)。为便于后续训练模型,需将原始的表格数据转换为 <输入特征, 目标标签ID> 数据对的形式:

{
  "input_features": [5.1, 3.5, 1.4, 0.2],
  "target_ids": 0
}

其中:

  • input_features:一个列表,包含了当前咖啡的四个特征数值,这些是模型输入的特征。

  • target_ids:一个整数,代表该咖啡对应的类别编号,作为模型训练的目标。

在先前的项目中,我们手动维护与调度数据。然而,当面对大规模数据集,并需要实现高效批处理、并行加载等复杂需求时,手动管理的方式将变得捉襟见肘,难以维护且效率低下。

PyTorch框架为此提供了优雅的解决方案,封装了 DatasetDataLoader 这两个类是对数据集的抽象封装,能够极大地简化数据准备和批量加载的过程。接下来,我们将学习如何利用这两个类,将我们的饮品特征数据封装成一个规范、高效的数据集。

3.5.2. PyTorch 数据管理#

PyTorch中的 DatasetDataLoader 类是数据加载和处理的核心组件,它们极大地简化了数据准备和批量加载的过程。

  • Dataset 类负责定义数据的组织结构和处理过程,即“如何根据索引获取单个样本”以及“如何将原始数据转化成训练所需的格式”;

  • DataLoader 则围绕 Dataset 工作,负责将单个样本组装成批次(batch),并提供打乱、多进程加载等高级功能,使得训练循环更加清晰和高效。

PyTorch 中数据管理的整体思路如图所示:

image.png

Dataset类的作用

  1. 抽象数据表示:Dataset类提供了一个表示数据集的抽象接口,允许用户自定义如何加载和访问数据。

  2. 数据预处理:可以在Dataset类中实现数据的预处理操作,如图像的变换、文本的分词等。

  3. 统一数据访问:通过实现__getitem__和__len__方法,使得数据集可以像列表一样被索引,并且可以获取数据集的大小。

DataLoader类的作用

  1. 批量加载:自动将数据集中的数据分成小批量,便于模型训练。

  2. 数据打乱:在每个训练周期(epoch)开始时,可以随机打乱数据,以减少模型对数据顺序的依赖,提高泛化能力。

  3. 并行加载:利用多进程并行加载数据,加速数据准备过程,减少GPU等待数据的时间。

  4. 灵活的数据组合:通过collate_fn参数,可以自定义如何将多个样本组合成一个批量,例如对序列数据进行填充。

3.5.3. 自定义数据集#

在此次的任务中我们需要将 csv 中的每一行数据转化为 <输入特征, 目标标签ID> 数据对。转化处理的流程如下图所示:

image.png

其中 input_features 表示输入特征,target_ids 表示类别对应的ID。

3.5.3.1. 自定义咖啡分类数据集#

自定义咖啡分类 CoffeeDataset 类,需要继承 torch.utils.data.Dataset,并实现 __init____len____getitem__方法。

  • __init__ 方法:初始化数据集,通常包含样本列表、标签映射等参数。

  • __len__ 方法:返回数据集的长度,即样本数量。

  • __getitem__ 方法:根据索引获取指定样本的数据,通常会将数据的转换过程放在这里。

除了实现 __init____len____getitem__ 方法,我们还需要添加从CSV文件加载数据的类方法 load_from_csv(),方便直接从数据文件中直接创建数据集对象。

import torch
from torch.utils.data import Dataset


class CoffeeDataset(Dataset):
    """
    自定义咖啡数据集类,继承自PyTorch的Dataset基类
    用于加载和处理咖啡分类数据
    """

    def __init__(self, samples, label_to_id):
        """
        初始化数据集
        
        参数:
        - samples: 包含特征和标签的样本列表,每个元素是(features, label)的元组
        - label_to_id: 标签到ID的映射字典,如果为None则自动创建
        """
        self.samples = samples

        # 使用提供的标签映射
        self.label_to_id = label_to_id
        # 创建反向映射(ID到标签),用于后续将预测结果转换回标签名称
        self.id_to_label = {idx: label for label, idx in self.label_to_id.items()}

    def __len__(self):
        """
        返回数据集的大小(样本总数)
        
        返回:
        - 数据集中的样本数量
        """
        return len(self.samples)

    def __getitem__(self, index):
        """
        获取指定索引的样本数据
        
        参数:
        - index: 样本索引
        
        返回:
        - 包含输入特征和目标标签ID的字典
        """
        # 1️⃣ 根据索引获取样本的特征和标签
        features, label = self.samples[index]

        # 2️⃣ 将标签转换为对应的ID
        label_id = self.label_to_id[label]

        # 3️⃣ 返回包含输入特征和目标标签ID的字典
        return {
            "input_features": features,  # 输入特征数据
            "target_ids": label_id  # 目标标签ID
        }

    @classmethod
    def load_from_csv(cls, file_path, label_to_id):
        """
        从CSV文件加载数据的类方法
        
        参数:
        - file_path: CSV文件路径
        
        返回:
        - CoffeeDataset实例
        """
        # 使用pandas读取CSV文件并删除包含缺失值的行
        data = pd.read_csv(file_path).dropna()

        # 存储处理后的样本
        samples = []

        # 遍历数据中的每一行
        for index in range(len(data)):
            # 1️⃣ 获取当前行数据
            row = data.iloc[index]

            # 2️⃣ 提取特征数据(除最后一列外的所有列),并转换为float32类型
            features = row.iloc[:-1].values.astype("float32")

            # 3️⃣ 提取标签(最后一列)
            label = row.iloc[-1]

            # 4️⃣ 将特征和标签作为一个元组添加到样本列表中
            samples.append((features, label))

        # 创建并返回CoffeeDataset实例
        return cls(samples, label_to_id)

3.5.3.2. 加载数据集实例#

使用训练数据文件 coffee_train.csv 和评估数据文件 coffee_val.csv 来创建对应的数据集对象,查看数据集大小。

from pprint import pprint

# 使用 CoffeeDataset 类的 load_from_csv 方法加载咖啡数据集
label_to_id = {"生椰拿铁": 0, "双椰拿铁": 1, "烤椰拿铁": 2}
train_dataset = CoffeeDataset.load_from_csv("./dataset/coffee_train.csv", label_to_id)
val_dataset = CoffeeDataset.load_from_csv("./dataset/coffee_val.csv", label_to_id)

# 使用 pprint 函数打印数据集中的第一个样本,展示其结构和内容
# 这有助于查看数据的格式,包括输入特征(input_features)和目标标签(target_ids)
print(f"数据集样例:{train_dataset[0]}\n")

# 打印训练集和评估集的大小
print(f"训练集大小: {len(train_dataset)}")
print(f"评估集大小: {len(val_dataset)}")
数据集样例:{'input_features': array([5.1, 3.5, 1.4, 0.2], dtype=float32), 'target_ids': 0}

训练集大小: 118
评估集大小: 30

3.5.4. 数据加载器#

DataLoader 是 PyTorch 中用于加载数据的一个重要工具,它提供了一个可迭代的对象,可以方便地访问 Dataset 中的数据,并支持自动批处理、打乱数据、多进程加载等。

有以下主要参数:

  • dataset: 一个 Dataset 对象,即要加载的数据集。

  • batch_size: 每个批次的大小,默认为 1。

  • shuffle: 是否在每个 epoch 开始时打乱数据,默认为 False。通常训练集设置为 True,验证集/测试集为 False。

  • num_workers: 使用多少个子进程来加载数据,默认为 0,表示在主进程中加载。

  • collate_fn: 如何将多个样本数据拼接成一个批次,默认为 torch 默认的拼接方式。

  • drop_last: 当数据集大小不能被 batch_size 整除时,是否丢弃最后一个不完整的批次。

下面基于自定义的咖啡分类数据集创建数据加载器。这里创建一个批次大小为 5 的训练数据加载器,并打印一个批次的数据。

3.5.4.1. 创建数据加载器#

创建数据加载器的时候需要设置数据集、批次大小、是否打乱数据等参数,训练数据加载器通常会打乱数据,而评估数据加载器则通常不需要打乱数据。

from torch.utils.data import DataLoader

# 设置每个批次的数据量
batch_size = 5

# 创建训练数据的数据加载器,打乱数据
# DataLoader会自动处理数据的批量加载和随机打乱(shuffle=True)
train_dataloader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)

# 创建评估数据的数据加载器,无需打乱数据
# 评估时通常不需要打乱数据(shuffle=False),以便于结果的复现和分析
val_dataloader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False)

3.5.4.2. 使用数据加载器#

当我们实例化一个 DataLoader 后,它返回的是一个可迭代对象。数据加载器在每次迭代时返回一个批次(batch)的数据。

数据集加载器内部会维护一个索引,每次迭代时,采样器会根据索引从数据集中选择数据,然后使用将单个样本组合成一个 batch。所以我们可以通过下面 for 循环的迭代方式,每次获取一个批次的数据,直到数据遍历一遍为止。

from dsxllm.util import print_red

# 使用 Dataloader 打印一个批次的数据
print_red("使用 Dataloader 打印一个批次的数据:")

input_features = None
target_ids = None

# 遍历训练数据加载器,获取一个批次的数据
for batch in train_dataloader:
    # 从批次数据中提取输入特征(input_features)和目标标签(target_ids)
    input_features = batch["input_features"]
    target_ids = batch["target_ids"]

    # 使用pprint美化打印批次数据,sort_dicts=False保持字段顺序
    pprint(batch, sort_dicts=False)

    # 只打印第一个批次后就跳出循环
    break
使用 Dataloader 打印一个批次的数据:
{'input_features': tensor([[4.9000, 3.6000, 1.4000, 0.1000],
        [6.1000, 2.6000, 5.6000, 1.4000],
        [7.2000, 3.0000, 5.8000, 1.6000],
        [6.1000, 2.8000, 4.0000, 1.3000],
        [6.9000, 3.1000, 4.9000, 1.5000]]),
 'target_ids': tensor([0, 2, 2, 1, 1])}

执行结果和我们的预期一样,每次迭代都会返回一个批次为 5 的数据,其中包含输入特征 input_features 和目标标签 target_ids。组 batch 时, DataLoader 会自动地根据样本中的 key 进行堆叠,所以 input_featurestarget_ids 都被转换成了列表的形式:

  • input_features:数据形状为 (5, 4),即每个批次有 5 个数据样本,每个样本有 4 个输入特征

  • target_ids:数据形状为 (5,),即每个批次有 5 个数据样本,每个样本有 1 个目标标签

批次:用部分对整体的近似估计

这里我们引入了批次(batch)的概念,在训练顾客评分预测模型时,每次训练都会使用全部的数据,虽然使用全量数据的 loss 更能反映模型在整个数据集的性能,但是训练的过程会很慢。因此,在训练过程中,通常会采用小批量(mini-batch)的数据去近似估计整个训练集,即每次只使用一部分数据进行训练,从而平衡训练效率和模型性能。

正式因为这种近似估计的理念,所以通常会将训练数据集进行随机打乱,这样每个训练轮次中批次的数据都不同,可以提高模型的泛化性能。

3.6. 构建模型#

3.6.1. 风味质检模型的模型结构#

本次任务我们需要创建一个用于咖啡分类的模型,学习四种配料与咖啡品类之间的关系。这是一个拥有多个神经网络层和多个输出的模型,其模型的层结构如下:

image.png

激活函数:从线性到非线性的飞跃

激活函数是施加在人工神经网络每个神经元输出上的数学函数。它的核心使命是引入非线性。这是神经网络能够学习并逼近任意复杂函数关系的基石。

一个没有激活函数的神经网络,无论有多少层,其整体输出都只是输入数据的线性组合,本质上等价于一个单层的神经网络,能力极其有限。激活函数打破了这种线性束缚,使得神经网络能够拟合曲线、形成复杂的决策边界,从而处理图像识别、自然语言处理等高度非线性的现实问题。

3.6.2. 风味质检模型的代码实现#

定义一个继承自 nn.ModuleCoffeeClassifier 类,重写模型的 __init__forward 方法,分别完成模型的初始化和前向计算。

  • __init__ 方法:定义模型的参数和层结构,创建一个输入层和一个输出层,不同的层之间使用 ReLU 激活函数进行非线性映射。

  • forward 方法:定义数据在网络中的流动方式,先使用输入层提取输入数据中的隐藏信息,最后通过输出层进行分类预测。

激活函数:ReLU

ReLU,全称为 Rectified Linear Unit,中文译为修正线性单元。它的数学定义非常简单:

\[f(x) = max(0, x)\]

也就是说:

  • 如果输入 x > 0,输出就是 x。

  • 如果输入 x ≤ 0,输出就是 0。

import torch


class CoffeeClassifier(torch.nn.Module):
    """
    咖啡分类模型,继承自PyTorch的nn.Module基类
    这是一个简单的两层全连接神经网络,用于咖啡种类分类任务
    """

    def __init__(self, input_size, hidden_size, output_size):
        """
        初始化模型结构
        
        参数:
        - input_size: 输入特征维度(本项目中为4,即椰浆、咖啡、牛奶、糖的含量)
        - hidden_size: 隐藏层神经元数量
        - output_size: 输出类别数量(本项目中为3,即生椰拿铁、双椰拿铁、烤椰拿铁)
        """
        # 调用父类初始化方法
        super(CoffeeClassifier, self).__init__()

        # 输入层:将输入特征映射到隐藏层
        self.input_layer = torch.nn.Linear(in_features=input_size, out_features=hidden_size)

        # 激活函数:引入非线性因素,增强模型表达能力
        self.relu = torch.nn.ReLU()

        # 输出层:将隐藏层映射到输出类别
        self.output_layer = torch.nn.Linear(in_features=hidden_size, out_features=output_size)

    def forward(self, input_features):
        """
        前向传播函数,定义数据在网络中的流动过程
        
        参数:
        - input_features: 输入数据,形状为(batch_size, input_size)
        
        返回:
        - out: 模型输出,形状为(batch_size, output_size)
        """
        # 输入通过输入层,数据形状变化:(batch_size, hidden_size) → (batch_size, hidden_size)
        out = self.input_layer(input_features)

        # 经过ReLU激活函数
        out = self.relu(out)

        # 通过输出层得到最终输出,数据形状变化:(batch_size, hidden_size) → (batch_size, output_size)
        out = self.output_layer(out)

        # 返回输出结果
        return out

3.6.3. 风味质检模型的详细信息#

3.6.3.1. 创建模型实例#

创建咖啡分类模型实例,并打印模型信息:

  • 输入特征维度为 4 (椰浆、咖啡、牛奶、糖)

  • 隐藏层维度为 10

  • 输出类别数为 3 (生椰咖啡、双椰咖啡、烤椰咖啡)

"""
创建咖啡分类模型实例:
 - 输入特征维度为 4(椰浆、咖啡、牛奶、糖)
 - 中间隐藏特征维度为 10
 - 输出类别数为 3(生椰咖啡、双椰咖啡、烤椰咖啡)
"""
model = CoffeeClassifier(input_size=4, hidden_size=10, output_size=3)

# 打印模型结构
print(model)
CoffeeClassifier(
  (input_layer): Linear(in_features=4, out_features=10, bias=True)
  (relu): ReLU()
  (output_layer): Linear(in_features=10, out_features=3, bias=True)
)

从打印的模型信息中可以看到,模型由输入层、激活函数和输出层组成,其中:

  • 输入层 input_layer:将输入特征进行组合后映射到隐藏空间,输入特征维度为 4,隐藏特征维度为 10

  • 激活函数 relu:使用 ReLU 激活函数,引入非线性因素,增强模型表达能力

  • 输出层 output_layer:将隐藏特征映射到输出类别,得到每一个类别的预测得分,隐藏特征维度为 10,输出类别数为 3

3.6.3.2. 查看模型详情#

使用 torchinfo 库查看模型的结构,包括输入大小、输出大小、参数数量和是否可训练。

from torchinfo import summary

# input_data: 输入数据的形状,用于推断模型的输入输出大小
# col_names: 指定显示的列名,包括输入大小、输出大小、参数数量和是否可训练
print("Model Summary:")
print(summary(model, input_data=input_features,
              col_names=["input_size", "output_size", "num_params"]))
Model Summary:
===================================================================================================================
Layer (type:depth-idx)                   Input Shape               Output Shape              Param #
===================================================================================================================
CoffeeClassifier                         [5, 4]                    [5, 3]                    --
├─Linear: 1-1                            [5, 4]                    [5, 10]                   50
├─ReLU: 1-2                              [5, 10]                   [5, 10]                   --
├─Linear: 1-3                            [5, 10]                   [5, 3]                    33
===================================================================================================================
Total params: 83
Trainable params: 83
Non-trainable params: 0
Total mult-adds (Units.MEGABYTES): 0.00
===================================================================================================================
Input size (MB): 0.00
Forward/backward pass size (MB): 0.00
Params size (MB): 0.00
Estimated Total Size (MB): 0.00
===================================================================================================================

从模型摘要详情中,可以看到模型的层结构、输入输出的形状以及参数数量等信息。

3.7. 模型的训练与评估#

模型训练与评估的流程如下:

image.png

在训练模型之前,先对模型一次评估,以了解模型在训练前的性能。然后再训练模型,在训练完成后,再对模型进行评估,确认训练是否成功。

3.7.1. 训练前评估#

训练前评估为模型性能建立了初始基准,使得后续的训练进度能够被量化追踪。通过对比训练前后的评估结果,开发者可以清晰看到模型的改进幅度,判断训练是否朝着正确的方向发展。

3.7.1.1. 定义评估函数#

创建一个评估函数 evaluate,用于评估模型在测试数据集上的准确率。评估时会遍历测试数据加载器中的每个批次,计算模型的预测结果,并统计正确预测的样本数和总样本数,最后计算模型在整个测试数据集上的准确率。

评估每个批次具体步骤如下:

  1. 从批次数据中获取输入特征和目标标签

  2. 模型根据输入特征进行前向计算,得到针对每个样本在每个类型上的预测得分

  3. 使用 argmax 获取每个样本预测概率最高的类别作为预测结果,即预测结果为预测概率最高的类别的索引

  4. 比对预测结果和目标标签,统计当前批次中预测正确的样本数,并累加到总正确样本数中

  5. 统计并累加当前批次的总样本数,计算总体的准确率

from dsxllm.util import print_red


def evaluate(model, dataloader, device="cpu"):
    """
    评估模型在测试数据集上的准确率
    
    参数:
    - model: 训练好的模型
    - dataloader: 待测试数据集的数据加载器
    - device: 运行设备,默认为CPU
    
    返回:
    - accuracy: 准确率
    - total_correct: 正确预测的样本数
    - total_samples: 总样本数
    """
    # 将模型设置为评估模式,关闭如dropout等训练时特有的操作
    model.eval()
    # 将模型移动到指定设备上,只有模型和数据在同一设备上时,才能进行计算
    model.to(device)

    # 初始化正确预测的样本数和总样本数
    total_correct = 0
    total_samples = 0

    # 关闭梯度计算,因为在评估阶段不需要计算梯度,降低显存开销
    with torch.no_grad():
        # 遍历测试数据加载器中的每个批次
        for batch_data in dataloader:
            # 1️⃣ 从批次数据中获取输入特征和目标标签
            input_features, target_ids = batch_data["input_features"], batch_data["target_ids"]
            # 将输入数据和目标标签移动到指定设备上
            input_features, target_ids = input_features.to(device), target_ids.to(device)

            # 2️⃣ 模型前向传播,得到输出(未经过argmax处理)
            output_logits = model(input_features)

            # 3️⃣ 获取每个样本预测概率最高的类别作为预测结果
            predicted_ids = torch.argmax(output_logits, dim=1)

            # 4️⃣ 统计当前批次中预测正确的样本数
            total_correct += (predicted_ids == target_ids).sum().item()

            # 5️⃣ 累加当前批次的样本总数
            total_samples += target_ids.size(0)

    # 计算准确率
    accuracy = total_correct / total_samples
    # 打印准确率信息
    print_red(f"Accuracy: {accuracy:.4f} ({total_correct}/{total_samples})")
    # 返回准确率、正确预测的样本数和总样本数
    return accuracy, total_correct, total_samples

模型评估模式:model.eval()

  1. 评估时,使用 torch.no_grad() 关闭梯度计算,可以在推理过程中不存储中间结果,减少显存占用

  2. 评估时,使用 to(device) 将模型和数据移动到指定设备上,只有模型和数据在同一设备上时,才能进行计算

3.7.1.2. 评估模型训练前准确率#

from dsxllm.util import print_table

# 评估模型在评估集上的准确率
accuracy, total_correct, total_samples = evaluate(model, val_dataloader, device="cpu")

# 打印评估结果表格
print_table("🎯 训练前预测准确率", ["总样本数", "正确样本数", "准确率"],
            [[total_samples, total_correct, f"{accuracy * 100:.2f}%"]])
Accuracy: 0.3333 (10/30)
🎯 训练前预测准确率:
+----------+------------+--------+
| 总样本数 | 正确样本数 | 准确率 |
+----------+------------+--------+
|    30    |     10     | 33.33% |
+----------+------------+--------+

在模型训练之前,模型预测的准确率为 33.3%,基本上和随机瞎猜的准确率一致,说明模型在训练前没有任何的预测能力。

3.7.2. 训练模型#

风味质检模型训练的流程如下:

image.png

风味质检模型是一个多分类模型,模型的预测结果是各个类别的得分,因此使用交叉熵损失函数计算损失。

交叉熵损失函数:CrossEntropyLoss

交叉熵损失函数(Cross-Entropy Loss)是机器学习和深度学习中广泛用于分类任务的一种损失函数,尤其适用于多分类和二分类问题。它衡量的是模型预测的概率分布与真实标签之间的差异。

3.7.2.1. 定义训练循环#

训练深度神经网络的典型训练循环如下:

image.png

  1. 创建一个训练函数 train_one_batch,用于对一个批次的数据进行训练。

  2. 创建一个训练函数 train_one_epoch,用于对一个轮次的数据进行训练。

  3. 创建一个训练函数 train_model,用于对多个轮次的数据进行训练。

3.7.2.1.1. 训练一个批次#

定义 train_one_batch 函数,用于对一个批次的数据进行训练。

  1. 前向传播:将输入数据传入模型,得到预测类别的得分(未经过 softmax 处理)

  2. 计算损失:使用损失函数计算预测类别与真实类别之间的误差

  3. 清空梯度:在反向传播前清空优化器中的梯度缓存,以避免梯度累积

  4. 反向传播:计算损失相对于模型参数的梯度

  5. 参数更新:使用优化器根据计算出的梯度更新模型参数

  6. 返回损失:返回当前批次的损失值,记录训练过程中性能的变化

def train_one_batch(model, optimizer, loss_fn, input_features, target_ids):
    model.train()  # 将模型设置为训练模式

    # 1️⃣ 前向传播:将输入数据传入模型,得到预测输出
    output_logits = model(input_features)

    # 2️⃣ 计算损失:使用损失函数计算预测值与真实值之间的误差
    loss = loss_fn(output_logits, target_ids)

    # 3️⃣ 清零梯度:在反向传播前清空优化器中的梯度缓存
    optimizer.zero_grad()

    # 4️⃣ 反向传播:计算损失相对于模型参数的梯度
    loss.backward()

    # 5️⃣ 更新参数:根据计算出的梯度更新模型参数
    optimizer.step()

    # 6️⃣ 返回当前批次的损失值
    return loss
3.7.2.1.2. 训练一个轮次#

定义 train_one_epoch 函数,遍历数据加载器中的每一个批次,进行一个轮次的训练。

def train_one_epoch(model, optimizer, loss_fn, train_dataloader, device="cpu", log_interval=50):
    epoch_loss = 0  # 累计损失
    batch_ids = 0  # 批次计数器

    # 遍历训练数据加载器中的每一个批次
    for batch_data in train_dataloader:
        # 1️⃣ 从批次中获取输入和标签,并移动到指定设备
        # input_features: 输入特征数据
        # target_ids: 真实标签数据
        input_features, target_ids = batch_data["input_features"], batch_data["target_ids"]
        input_features, target_ids = input_features.to(device), target_ids.to(device)

        # 2️⃣ 调用train_one_batch函数训练一个批次并返回损失
        batch_loss = train_one_batch(model, optimizer, loss_fn, input_features, target_ids)

        batch_ids += 1  # 增加批次计数器
        epoch_loss += batch_loss.item()  # 累加当前批次的损失值

        # 3️⃣ 按照指定间隔打印损失值,用于监控训练过程
        if batch_ids % log_interval == 0:
            print(f"Batch {batch_ids}, loss -> {batch_loss.item()}")

    # 计算并返回该epoch的平均损失
    return epoch_loss / len(train_dataloader)
3.7.2.1.3. 训练多个轮次#

定义 train_model 函数,对模型进行多轮训练。该函数将:

  • 循环执行指定数量的训练轮次(epoch)

  • 记录每个epoch的平均损失值

  • 按设定间隔进行模型评估

  • 监控模型性能变化趋势

在训练过程中,系统会定期对模型在验证集上的表现进行评估,便于实时观察模型的学习效果和性能提升情况。

def train_model(num_epochs, model, optimizer, loss_fn, train_dataloader, val_dataloader, device="cpu",
                log_interval=50, val_interval=3):
    """
    训练模型

    Returns: 包含训练损失和评估准确率的列表。
    """

    train_losses = []  # 存储每个 epoch 的训练损失
    val_accuracies = []  # 存储每个评估点的准确率

    # 循环执行指定数量的训练轮次(epoch)
    for epoch in range(num_epochs):
        # 打印当前轮次信息
        print(">" * 30, f"Epoch {epoch + 1} / {num_epochs}", "<" * 30)

        # 1️⃣ 调用 train_one_epoch 函数训练一个完整的 epoch,返回该 epoch 的平均损失值
        epoch_loss = train_one_epoch(model, optimizer, loss_fn, train_dataloader, device=device,
                                     log_interval=log_interval)

        # 2️⃣ 将当前 epoch 的损失信息保存到 train_losses 列表中
        train_losses.append({
            "epoch": epoch + 1,  # epoch编号
            "loss": epoch_loss  # 平均损失值
        })

        # 3️⃣ 定期评估模型性能:每隔 eval_interval 轮或者在最后一轮进行评估
        if (epoch + 1) % val_interval == 0 or epoch == num_epochs - 1:
            # 调用 evaluate 函数评估模型在验证集上的准确率
            accuracy, total_correct, total_samples = evaluate(model, val_dataloader, device="cpu")

            # 将评估结果保存到 eval_accuracies 列表中
            val_accuracies.append({
                "epoch": epoch + 1,  # epoch编号
                "总样本数": total_samples,  # 验证集总样本数
                "正确样本数": total_correct,  # 预测正确的样本数
                "准确率": accuracy  # 准确率
            })

    # 返回训练过程中的损失记录和评估结果
    return train_losses, val_accuracies

3.7.2.2. 启动训练循环#

定义好了训练方法后,可以开始训练模型了。训练模型需要以下步骤:

  • 加载数据集:加载训练集和验证集

  • 创建数据加载器:设置批处理大小为 20,创建训练数据加载器和评估数据加载器

  • 创建模型实例:input_size=4hidden_size=10num_classes=3

  • 初始化模型训练组件:损失函数和优化器

    • 损失函数:因为此任务为分类任务,使用交叉熵损失函数(CrossEntropyLoss)

    • 优化器:使用 Adam 优化器优化模型参数,学习率为 0.01

  • 执行训练循环:调用 train_model 函数进行 30 轮训练,每 3 轮进行一次评估,每 50 个批次记录一次训练损失。

# 设置 GPU 设备类型
# 检查是否有可用的CUDA设备(GPU)
if torch.cuda.is_available():
    device = "cuda"
# 如果没有CUDA设备,检查是否有可用的MPS设备(Mac上的GPU加速)
elif torch.backends.mps.is_available():
    device = "mps"
# 如果都没有,则使用CPU
else:
    device = "cpu"

# 打印当前使用的设备类型
print(f"Using device: {device}")

# 1️⃣ 加载数据集
label_to_id = {"生椰拿铁": 0, "双椰拿铁": 1, "烤椰拿铁": 2}
train_dataset = CoffeeDataset.load_from_csv("./dataset/coffee_train.csv", label_to_id)
val_dataset = CoffeeDataset.load_from_csv("./dataset/coffee_val.csv", label_to_id)

# 2️⃣ 创建数据加载器 
# 设置批处理大小
batch_size = 20
# 创建训练数据加载器,shuffle=True表示每个epoch打乱数据顺序
train_dataloader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
# 创建评估数据加载器,shuffle=False表示不打乱数据顺序
val_dataloader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False)

# 3️⃣ 创建新的咖啡分类模型实例
model = CoffeeClassifier(input_size=4, hidden_size=10, output_size=3)

# 4️⃣ 设置训练轮数
num_epochs = 30

# 5️⃣ 开始训练模型
# model: 要训练的模型
# optimizer: 优化器,使用Adam优化器,学习率为0.01
# loss_fn: 损失函数,使用交叉熵损失函数
# train_dataloader: 训练数据加载器
# val_dataloader: 评估数据加载器
# device: 设备类型,这里设置为"cpu"
train_losses, eval_accuracies = train_model(num_epochs, model=model,
                                            optimizer=torch.optim.Adam(model.parameters(), lr=0.01),
                                            loss_fn=torch.nn.CrossEntropyLoss(),
                                            train_dataloader=train_dataloader, val_dataloader=val_dataloader,
                                            device="cpu")
Using device: mps
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 1 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 2 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 3 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Accuracy: 0.7333 (22/30)
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 4 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 5 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 6 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Accuracy: 0.7000 (21/30)
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 7 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 8 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 9 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Accuracy: 0.9667 (29/30)
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 10 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 11 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 12 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Accuracy: 0.9667 (29/30)
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 13 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 14 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 15 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Accuracy: 0.9667 (29/30)
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 16 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 17 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 18 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Accuracy: 1.0000 (30/30)
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 19 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 20 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 21 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Accuracy: 1.0000 (30/30)
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 22 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 23 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 24 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Accuracy: 1.0000 (30/30)
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 25 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 26 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 27 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Accuracy: 0.9667 (29/30)
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 28 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 29 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Epoch 30 / 30 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Accuracy: 1.0000 (30/30)

优化器:Adam

Adam 是一个集大成者的优化算法,通过自适应地调整每个参数的学习率并结合动量,实现了快速且稳定的收敛。虽然在某些任务上 SGD 可能具有最终的泛化优势,但 Adam/AdamW 因其卓越的易用性和强大的默认性能,已成为深度学习实践中的主力优化器。

3.7.2.2.1. 训练过程可视化#

绘制训练过程中损失值的变化曲线,更直观地观察损失值在训练过程中的变化趋势。

from dsxllm.util import plot_loss_curves

plot_loss_curves(train_losses)
../_images/0b6f29906f72aed30961f683621080ae771da512af44a43a24b3114176d184f1.png

从训练日志中可以看出随着训练的进行,损失值不断下降,表示模型预测准确性不断提高。

3.7.2.2.2. 查看模型评估记录#

查看训练过程中的评估结果,更直观地观察模型在验证集上的表现。

from dsxllm.util import to_dataframe

print_red("Eval Accuracies:")
df = to_dataframe(eval_accuracies)
df
Eval Accuracies:
epoch 总样本数 正确样本数 准确率
0 3 30 22 0.7333
1 6 30 21 0.7000
2 9 30 29 0.9667
3 12 30 29 0.9667
4 15 30 29 0.9667
5 18 30 30 1.0000
6 21 30 30 1.0000
7 24 30 30 1.0000
8 27 30 29 0.9667
9 30 30 30 1.0000

3.7.3. 训练后评估#

from dsxllm.util import print_table

accuracy, total_correct, total_samples = evaluate(model, val_dataloader, device="cpu")

print_table("🎯 训练前预测准确率", ["总样本数", "正确样本数", "准确率"],
            [[total_samples, total_correct, f"{accuracy * 100:.2f}%"]])
Accuracy: 1.0000 (30/30)
🎯 训练前预测准确率:
+----------+------------+---------+
| 总样本数 | 正确样本数 |  准确率 |
+----------+------------+---------+
|    30    |     30     | 100.00% |
+----------+------------+---------+

从评估结果中可以看出,训练前的模型的准确率是 33.3%,训练后的准确率是 100%,说明模型训练非常有效。

3.8. 使用模型进预测#

模型训练完成后,可以使用训练好的模型对新的数据进行预测。模型预测的步骤如下:

image.png

在预测阶段和训练时不太一样,预测阶段需要使用 softmax() 将模型预测的类型得分转换为概率,并使用 argmax() 选择概率最大的类别 ID 作为预测结果。预测阶段不需要进行反向传播,因此可以设置 torch.no_grad() 来关闭梯度计算,提高预测效率。

3.8.1. 定义预测单个样本的函数#

创建一个 predict_sample 函数,该函数接收一个模型实例、一个样本特征向量和类别标签的映射表。模型会对该样本进行预测得到预测的类别 ID后,通过类别 ID 获取对应的类别名称,并返回预测的类别名称和对应的预测概率。

def predict_sample(model, sample_features, id_to_label):
    """
    使用训练好的模型对单个样本进行预测
    
    参数:
    - model: 训练好的模型
    - sample_features: 样本特征,如 [椰浆, 咖啡, 牛奶, 糖]
    - id_to_label: 标签映射
    
    返回:
    - predicted_label: 预测的类别名称
    - predicted_probabilities: 各类别的预测概率
    """
    # 将模型设置为评估模式,关闭dropout等训练时特有的操作
    model.eval()

    # 转换输入特征为tensor
    input_features_tensor = torch.tensor(sample_features, dtype=torch.float32)

    # 添加批次维度,因为模型期望输入是批量数据
    # unsqueeze(0)在第0维添加一个维度,例如 shape [4] -> [1, 4]
    input_features_tensor = input_features_tensor.unsqueeze(0)

    # 模型预测
    with torch.no_grad():  # 关闭梯度计算,节省内存并提高推理速度
        # 前向传播得到模型输出
        output_logits = model(input_features_tensor)
        # 对输出应用softmax函数,将logits转换为概率分布
        probabilities = torch.softmax(output_logits, dim=1)
        # 获取概率最高的类别作为预测类别的ID
        predicted_id = torch.argmax(output_logits, dim=1)

    # 获取预测类别名称:通过id_to_label字典将类别ID转换为类别名称
    predicted_label = id_to_label[predicted_id.item()]

    # 返回预测标签和概率分布列表
    # squeeze()移除多余的维度,tolist()将tensor转换为Python列表
    return predicted_label, probabilities.squeeze().tolist()

unsqueezesqueeze

在深度学习模型的预测(inference)和解码(decoding)过程中,unsqueeze 和 squeeze 是两个非常常见的张量操作,主要用于调整维度以匹配模型输入/输出的要求。

  • unsqueeze(dim):在指定维度 dim 上增加一个大小为 1 的维度。

  • squeeze(dim):移除大小为 1 的维度(若未指定 dim,则移除所有大小为 1 的维度)。

x = torch.tensor([1, 2, 3])          # shape: [3]
x_unsqueezed = x.unsqueeze(0)        # shape: [1, 3]
x_squeezed = x_unsqueezed.squeeze(0) # shape: [3]

训练时通常使用 批量数据(batch)进行训练,但在推理时,可能只处理一个样本,这时需要手动添加 batch 维度。模型输出中也包含着不必要的 batch 维度,需要 squeeze 来还原数据的维度。

3.8.2. 使用训练好的模型进行预测#

模型训练完成后,可以使用训练好的模型对新的数据进行预测,查看模型的泛化能力。

创建 3 个新样本,每个样本包含[椰浆, 咖啡, 牛奶, 糖]四个特征以及对应的类别,使用模型进行预测,查看模型的预测结果是否正确。

from dsxllm.util import print_classification_predictions

# 初始化各类别的标签到ID的映射关系(需要和训练模型时一致)
label_to_id = {
    "生椰拿铁": 0,
    "双椰拿铁": 1,
    "烤椰拿铁": 2
}
id_to_label = {v: k for k, v in label_to_id.items()}

# 1️⃣ 假设我们有几个新的咖啡样本,每个样本包含[椰浆, 咖啡, 牛奶, 糖]四个特征
sample_features = [[4.4, 2.9, 1.4, 0.2], [6, 2.9, 4.5, 1.5], [6.9, 3.2, 5.7, 2.3]]
# 定义这些样本对应的真实标签
true_labels = ["生椰拿铁", "双椰拿铁", "烤椰拿铁"]
# 将真实标签转换为对应的ID
true_label_ids = [label_to_id[label] for label in true_labels]

# 2️⃣ 使用训练好的模型对每个样本进行预测
predictions = []  # 存储预测的标签名称
probabilities = []  # 存储各类别的预测概率
for i, sample_feature in enumerate(sample_features):
    # 对单个样本进行预测,返回预测标签和各类别概率
    predicted_label, probabilities_label = predict_sample(model, sample_feature, id_to_label)
    predictions.append(predicted_label)  # 添加预测标签
    probabilities.append(probabilities_label)  # 添加概率分布

# 3️⃣ 将预测的标签名称转换为对应的ID
predicted_label_ids = [label_to_id[prediction] for prediction in predictions]

# 4️⃣ 输出最终的分类预测结果,包括准确率统计和详细对比
print_classification_predictions(sample_features, true_label_ids, predicted_label_ids, probabilities, id_to_label)
🎯 分类预测结果 (准确率: 3/3 = 100.00%):
+----------------------+----------+----------+----------+------+
|         输入         | 真实标签 | 预测标签 | 最高概率 | 标记 |
+----------------------+----------+----------+----------+------+
| [4.4, 2.9, 1.4, 0.2] | 生椰拿铁 | 生椰拿铁 |  0.9814  |  ☑   |
|  [6, 2.9, 4.5, 1.5]  | 双椰拿铁 | 双椰拿铁 |  0.7911  |  ☑   |
| [6.9, 3.2, 5.7, 2.3] | 烤椰拿铁 | 烤椰拿铁 |  0.8515  |  ☑   |
+----------------------+----------+----------+----------+------+

从结果中我们可以看到,模型在所有测试样本上都做出了正确预测。但是,对每一个样本模型的置信度仍然存在差异:

  • 样本 1 (生椰拿铁): 预测概率 0.9814,置信度很高

  • 样本 2 (双椰拿铁): 预测概率 0.7911,置信度中等

  • 样本 3 (烤椰拿铁): 预测概率 0.8515,置信度较高

样本 2 的预测概率相对较低(0.7911),说明模型对这个样本的分类相对不够确定,表示模型对预测的结果不够自信。如果置信度低于阈值(0.8),我们可以认为这个样本偏离了我们的训练数据集,也就是说这杯咖啡未通过模型的质检,未达到品控标准。

3.9. 本章小结#

本章我们成功地从回归任务拓展到了分类任务,并使用 PyTorch 训练了我们的第一个深度神经网络模型。我们了解了加深神经络模型的层可以提高模型的表示能力,但需要在堆叠的层之间添加激活函数来增加非线性能力。在这个过程中,我们学会了使用 DatasetDataLoader 自定义数据集来高效地组织数据。另外,训练多分类模型时,需要使用交叉熵损失函数 CrossEntropyLoss 来计算损失;推理解码时使用 Softmax 函数将输出转换为概率分布。下一章,我们将在此基础上,探索如何利用更高级的框架来进一步优化我们的工程实践。

3.10. 答疑讨论#