从零开始构建神经网络分类器:一步步指南

社区文章 发布于 2024年7月1日

分类是深度学习的基本任务之一。虽然 PyTorch、JAX、Keras 和 TensorFlow 等现代框架提供了构建和训练神经网络的便捷抽象,但从零开始构建一个神经网络能更全面地理解其涉及的细微之处。

在本文中,我们将用 Python 实现构建和训练一个多层感知器所需的关键模块,该感知器用于对服装图像进行分类。特别是,我们将深入探讨**近似**、**非线性**、**正则化**、**梯度**和**反向传播**的基本原理。此外,我们还将探讨**随机参数初始化**的重要性以及**迷你批次训练**的好处。

通过本指南,你将能够从头开始构建神经网络的基石,了解它如何学习,并将其部署到 HuggingFace Spaces 以对真实世界的服装图像进行分类。

部署到 HuggingFace Spaces 的服装分类器

神经网络背后的直觉

我们的目标是通过将一个大型数学函数近似于服装图像的训练数据集,从而对服装图像进行分类。我们将通过随机初始化函数参数来开始这个过程,并调整它们以组合输入像素值,直到我们获得类预测形式的有利输出。这种迭代方法旨在学习训练数据集中区分不同类别的特征。

这种方法的基础在于通用近似定理,这是一个基本概念,它强调了线性变换和非线性函数的组合,以近似复杂的模式,例如计算机视觉所需的模式。

通过示例而不是显式编程来教导计算机的原则可以追溯到 1949 年的 Arthur Samuel [1]。Samuel 提出了使用权重作为函数参数的概念,这些参数可以进行调整以影响程序的行为和输出。他强调了自动化这种方法的想法,该方法根据这些权重在实际任务中的性能进行测试和优化。

然后,我们将实现一种自动调整权重的方法,在迷你批次中应用随机梯度下降 [2]。实际上,这涉及以下步骤:

  1. 初始化权重和偏差参数。
  2. 计算“迷你批次”上的预测。
  3. 计算预测与目标之间的平均损失。
  4. 计算“梯度”以了解需要如何更改参数才能最小化损失。
  5. 根据梯度和学习率更新权重和偏差参数。
  6. 从步骤 2 重复。
  7. 一旦满足条件(例如时间限制或训练/验证损失和指标停止改善),则停止该过程。

“迷你批次”是指“训练数据集的随机选择子集”,用于在每次迭代中计算损失和更新权重。在训练部分解释了迷你批次训练的好处。.

“梯度”是从函数的导数推断出的度量,它指示通过修改其参数,函数的输出将如何变化。在神经网络的上下文中,它们表示一个向量,**指示我们需要改变每个权重的方向和大小,以改进我们的模型**。

架构

在以下部分中,我们将深入探讨构建和训练多层感知器所需组件的实现细节。为了更简单地集成梯度计算等高级功能,这些组件将定义为自定义 PyTorch 模块。

线性变换

我们神经网络的核心是线性函数。这些函数执行两个关键操作:(i)通过矩阵乘法,根据其权重和偏差参数对输入值进行变换,以及(ii)降维(或在某些情况下进行增维)。

变换操作将输入值投影到不同的空间,这与堆叠的线性层结合使用,使网络能够逐步学习更抽象和复杂的模式。当线性层中的输出单元数小于输入数时,即可实现降维。这种压缩迫使层捕获高维输入的最显著特征。

class Linear(nn.Module):
    def __init__(self, in_features: int, out_features: int, std: float = 0.1):
        """
        Initialize a linear layer with random weights.

        Weights and biases are registered as parameters, allowing for 
        gradient computation and update during backpropagation.
        """
        super(Linear, self).__init__()
        self.in_features = in_features
        self.out_features = out_features

        weight = torch.randn(in_features, out_features, requires_grad=True) * std
        bias = torch.zeros(out_features, requires_grad=True)
        
        self.weight = nn.Parameter(weight)
        self.bias = nn.Parameter(bias)
        self.to(device=device)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Perform linear transformation by multiplying the input tensor
        with the weight matrix, and adding the bias.
        """
        return x @ self.weight + self.bias

请注意,**权重根据高斯分布(`randn`)随机初始化,以打破对称性并实现有效学习**。如果所有参数最初都设置为相同的值(例如零),它们将在反向传播期间计算相同的梯度,导致相同的权重更新和更慢(或不)收敛。

此外,**缩放权重也是初始化中的常见做法**。这有助于控制方差,并可能对训练动态产生重大影响。我们偏爱相对较小的缩放值(`std=0.1`),因为大值可能导致反向传播期间梯度呈指数增长(并溢出为 NaN),从而导致“梯度爆炸问题”。

引入非线性

如果没有非线性,无论我们的神经网络有多少层,它仍然会表现得像一个单层感知器。这是因为连续线性变换的组合本身就是另一个线性变换,这将阻止模型近似复杂的模式。

为了克服这一限制,我们遵循通用近似定理,并通过实现修正线性单元 (ReLU) 引入非线性,这是一种广泛使用且有效的激活函数,它将负值设置为零,同时保留正值。

class ReLU(nn.Module):
    """
    Rectified Linear Unit (ReLU) activation function.
    """

    @staticmethod
    def forward(x: torch.Tensor) -> torch.Tensor:
        return torch.clip(x, 0.)

修正线性单元 (ReLU) 由 Kunihiko Fukushima 于 1969 年在分层神经网络的视觉特征提取背景下提出 [3]。2011 年 [4],发现它与广泛使用的激活函数“逻辑 sigmoid”和“双曲正切”相比,能够更好地训练更深层的网络。

正则化

正则化是用于减少神经网络中“过拟合”的基本技术,过拟合是指参数在训练期间对单个数据点的噪声过度拟合。一种广泛使用且有效的正则化方法是“Dropout”函数,由 G. Hinton 的研究团队于 2014 年引入 [5]。Dropout 的工作原理是在训练阶段随机停用部分网络单元。这鼓励每个单元独立贡献,防止模型过度依赖过度专业化的单个单元,并增强其对新数据的泛化能力。

class Dropout(nn.Module):
    """
    Applies the dropout regularization technique to the input tensor.

    During training, it randomly sets a fraction of input units to 0 with probability `p`,
    scaling the remaining values by `1 / (1 - p)` to maintain the same expected output sum.
    During evaluation, no dropout is applied.
    """

    def __init__(self, p=0.2):
        super(Dropout, self).__init__()
        self.p = p

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        if self.training:
            mask = (torch.rand(x.shape) > self.p).float().to(x) / (1 - self.p)
            return x * mask
        return x

展平变换

在深度学习中,为了在将多维数据输入分类模型之前将其转换为一维 (1D) 数组,展平图像是必要的。我们的训练数据集 Fashion MNIST [6] 包含 60,000 张 28x28 灰度图像。我们加入了一个变换,将这些图像的宽度和高度尺寸展平,以减少内存使用(多维数组需要额外的内存开销来管理其结构),并简化模型的输入(每个像素成为一个独立的单元)。

class Flatten(nn.Module):
    """
    Reshape the input tensor by flattening all dimensions except the first dimension.
    """

    @staticmethod
    def forward(x: torch.Tensor) -> torch.Tensor:
        """
        x.view(x.size(0), -1) reshapes the x tensor to (x.size(0), N)
        where N is the product of the remaining dimensions.
        E.g. (batch_size, 28, 28) -> (batch_size, 784)
        """
        return x.view(x.size(0), -1)

序列层

为了构建完整的神经网络架构,我们需要一种方法以顺序方式连接各个线性操作和激活函数,形成从输入到输出的前馈路径。这是通过使用序列层实现的,它允许定义网络中各个层的特定顺序和组成。

class Sequential(nn.Module):
    """
    Sequential container for stacking multiple modules,
    passing the output of one module as input to the next.
    """

    def __init__(self, *layers):
        super(Sequential, self).__init__()
        self.layers = nn.ModuleList(layers)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        for layer in self.layers:
            x = layer(x)
        return x

分类器模型

在展平输入图像之后,我们通过非线性函数堆叠线性操作,使网络能够学习数据中的层次表示和模式。这对于我们的图像分类任务至关重要,因为网络需要捕获视觉特征以区分各种类别。

class Classifier(nn.Module):
    """
    Classifier model consisting of a sequence of linear layers and ReLU activations,
    followed by a final linear layer that outputs logits (unnormalized scores)
    for each of the 10 garment classes.

    It encapsulates also a method to convert logits into a label/probability dict.
    """

    def __init__(self):
        """
        The output logits of the last layer can be passed directly to
        a loss function like CrossEntropyLoss, which will apply the 
        softmax function internally to calculate a probability distribution.
        """
        super(Classifier, self).__init__()
        self.labels = ['T-shirt/Top', 'Trouser/Jeans', 'Pullover', 'Dress', 'Coat', 'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle-Boot']
        
        self.main = Sequential(
            Flatten(),
            Linear(in_features=784, out_features=256),
            ReLU(),
            Dropout(0.2),
            Linear(in_features=256, out_features=64),
            ReLU(),
            Dropout(0.2),
            Linear(in_features=64, out_features=10),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.main(x)
    
    def predictions(self, x):
        with torch.no_grad():
            logits = self.forward(x)
            probs = torch.nn.functional.softmax(logits, dim=1)
            predictions = dict(zip(self.labels, probs.cpu().detach().numpy().flatten()))    
        return predictions

研究论文《可视化和理解卷积网络》[7] 提供了类似于分层渐进学习的概念,专门应用于卷积层。这提供了类似的直觉来理解堆叠层如何能够自动学习图像中的特征。

卷积神经网络中特征的可视化 - https://arxiv.org/pdf/1311.2901.pdf

梯度下降优化器

我们实现了一个基本的优化器,根据梯度自动调整神经网络的参数、权重和偏差。在反向传播过程中计算的梯度指示了如何更新这些参数以最小化损失函数。利用这些梯度,优化器以步进方式更新参数,步长由学习率决定。

class Optimizer:
    """
    Update model parameters during training.
    
    It performs a simple gradient descent step by updating the parameters
    based on their gradients and the specified learning rate (lr).
    """

    def __init__(self, params, lr):
        self.params = list(params)
        self.lr = lr

    def step(self):
        for p in self.params:
            p.data -= p.grad.data * self.lr

    def zero_grad(self):
        """
        Reset the gradients of all parameters to zero.
        Since PyTorch accumulates gradients, this method ensures that
        the gradients from previous optimization steps do not interfere
        with the current step.
        """
        for p in self.params:
            p.grad = None

反向传播

Paul Werbos 于 1974 年 [8] 提出了神经网络的反向传播概念,几十年来几乎完全被忽视。然而,如今它被认为是人工智能最重要的基础之一。

反向传播的核心作用是计算损失函数相对于网络中每个参数的梯度。这是通过应用微积分的链式法则实现的,系统地计算这些梯度,从输出层反向传播到输入层——因此得名“反向传播”。

从底层来看,此方法涉及计算复杂函数的偏导数,并维护一个有向无环图 (DAG) 以跟踪输入数据的操作序列。为了简化此任务,PyTorch 等现代框架提供了称为 Autograd 的自动微分工具。实际上,如“线性变换”的实现所示,设置 `requires_grad = True` 是控制模型哪些部分需要跟踪并包含在梯度计算中的主要方式。

训练

时尚数据集

Fashion-MNIST 是由Zalando Research 整理的服装图像数据集——包含 60,000 个训练样本和 10,000 个测试样本。每个样本都是 28x28 灰度图像,与 10 个类别(T 恤/上衣、裤子、套头衫、连衣裙、外套、凉鞋、衬衫、运动鞋、包和踝靴)中的一个标签相关联。

为什么选择这个数据集?正如 Zalando Research 团队所解释的:“MNIST 太简单了。卷积网络可以轻松达到 99.7%,经典机器学习算法也可以轻松达到 97% [...] 我们打算让 Fashion-MNIST 直接替代原始的 MNIST 数据集。它具有相同的图像大小和训练/测试拆分结构。”

时尚-MNIST 数据集

迷你批次数据加载器

在训练过程中,我们需要高效地处理数据集的加载和预处理。为此,我们将使用 PyTorch 提供的实用类 `torch.utils.data.DataLoader`,它有助于批量处理、打乱和并行加载数据。

使用迷你批次而不是整个数据集会带来:

  • (i) **计算效率**,因为 GPU 在处理大量并行工作时性能更好;
  • (ii) **更好的泛化能力**,通过在每个 epoch 随机打乱迷你批次,这引入了方差并防止模型过拟合;以及,
  • (iii) **减少内存使用**,因为这是一种实用的选择,避免一次性将整个数据集加载到 GPU 内存中。
from torchvision.transforms import ToTensor
from torchvision import datasets
from torch.utils.data import DataLoader

train_data = datasets.FashionMNIST(root = 'data', train = True, transform = ToTensor(), download = True)
test_data = datasets.FashionMNIST(root = 'data', train = False, transform = ToTensor())

loaders = {'train' : DataLoader(train_data, batch_size=config.batch_size, shuffle=True, num_workers=2),
           'test'  : DataLoader(test_data, batch_size=config.batch_size, shuffle=False, num_workers=2)}

通过在训练加载器中设置 `shuffle=True`,我们会在每个 epoch 中重新打乱数据。这是一个重要的考虑因素,因为原始训练数据可能存在由数据收集方式(例如按字母顺序或时间顺序)引起的关联。

模型拟合

在神经网络架构和数据加载器都准备就绪后,我们现在可以专注于模型训练过程,也称为将模型“拟合”到数据。训练过程可分为两个主要部分:训练循环和验证循环。

**训练循环**负责将小批量数据输入模型,计算预测和损失,并使用反向传播和优化算法更新模型参数。此循环通常运行固定数量的 epoch 或直到满足某个停止条件。

另一方面,**验证循环**用于在单独的验证数据集上评估模型的性能,该数据集不用于训练。这有助于监控模型的泛化性能并防止过拟合训练数据。

在下面的代码中,我们实现了一个 `Learner` 类,该类封装了此逻辑,并为模型拟合和性能监控提供了便捷的接口。

class Learner:
    """
    Learner class for training and evaluating a model.
    """

    def __init__(self, config, loaders):
        """
        Initialize the Learner with custom configuration and data loaders.
        """
        self.model = config.model
        self.loaders = loaders
        self.optimizer = Optimizer(self.model.parameters(), config.lr)
        [...]

    def train_epoch(self, epoch):
        epoch_loss = 0.0
        for x, y in self.loaders["train"]:
            x, y = x.to(self.device), y.to(self.device)
            batch_size = x.size(0)

            # Zero out the gradients - otherwise, they will accumulate.
            self.optimizer.zero_grad()
   
            # Forward pass, loss calculation, and backpropagation
            output = self.model(x)
            loss = self.criterion(output, y)
            loss.backward()
            self.optimizer.step()

            epoch_loss += loss.item() * batch_size

        train_loss = epoch_loss / len(self.loaders['train'].dataset)
        return train_loss
    
    def valid_loss(self):
        self.model.eval()
        val_loss = 0.0
        with torch.no_grad():
            for x, y in self.loaders["test"]:
                x, y = x.to(self.device), y.to(self.device)
                output = self.model(x)
                val_loss += self.criterion(output, y).item() * y.size(0)
        val_loss /= len(self.loaders["test"].dataset)
        return val_loss

    def batch_accuracy(self, x, y):    
        _, preds = torch.max(x.data, 1)
        return (preds == y).sum().item() / x.size(0)

    def validate_epoch(self):      
        accs = [self.batch_accuracy(self.model(x.to(self.device)), y.to(self.device))
                for x, y in self.loaders["test"]]
        return sum(accs) / len(accs)
            
    def fit(self):
        """
        Train the model for the specified number of epochs.
        """
        print('epoch\ttrain_loss\tval_loss\ttest_accuracy')
        for epoch in range(self.epochs):
            train_loss = self.train_epoch(epoch)
            valid_loss = self.valid_loss()
            batch_accuracy = self.validate_epoch()
            print(f'{epoch+1}\t{train_loss:.6f}\t{valid_loss:.6f}\t{batch_accuracy:.6f}')

        metrics = self.evaluate()
        return metrics

模型评估

经过 25 个 epoch 后,我们的模型达到了 0.868 的准确率,这与基准测试结果(使用 ReLU 作为激活函数的 MLP 分类器为 0.874)相当接近。

模型评估(epochs=25, lr=0.005, batch_size=32, SGD, CrossEntropyLoss)与自实现模块

我们观察到,我们自己实现的模块与使用相同超参数(`epochs=25, lr=0.005, batch_size=32`)的标准 PyTorch 实现之间,准确率水平相当。值得注意的是,PyTorch 模型在验证损失和训练损失之间的差距略小,这表明其泛化能力更好。

模型评估(epochs=25, lr=0.005, batch_size=32, SGD, CrossEntropyLoss)与 PyTorch 模块

此外,对精确度(特定类别正向预测的准确度)、召回率(检测特定类别所有相关实例的能力)和 F1 分数(精确度和召回率的平均值)的基本分析表明,我们的模型在具有独特特征的类别(如裤子/牛仔裤、凉鞋、包和踝靴)中表现出色。然而,它在衬衫、套头衫和外套方面的表现低于平均水平。

不同类别的精确度、召回率和 F1 分数

混淆矩阵证实,衬衫类别经常与 T 恤/上衣、套头衫和外套类别混淆;而外套则与衬衫和套头衫混淆。这表明在 28x28 像素分辨率下工作可能会使上半身服装类别在视觉上具有挑战性。

混淆矩阵

推理

训练模型后,我们可以将其用于推理,这涉及对新数据进行预测。推理过程相对简单,但需要将真实世界的服装图像转换为训练数据集的格式。为了实现这一点,我们实现了一个 PyTorch 变换。

import torch
import torchvision.transforms as transforms

# Images need to be transformed to the `fashion MNIST` dataset format
transform = transforms.Compose(
    [
        transforms.Resize((28, 28)),
        transforms.Grayscale(),
        transforms.ToTensor(),
        transforms.Normalize((0.5,), (0.5,)), # Normalization
        transforms.Lambda(lambda x: 1.0 - x), # Invert colors
        transforms.Lambda(lambda x: x[0]),
        transforms.Lambda(lambda x: x.unsqueeze(0)),
    ]
)

这可以轻松集成到 Gradio App 中,然后部署到 HuggingFace Spaces

部署到 HuggingFace Spaces 的服装分类器

资源

参考文献

  • [1] A. L. Samuel. 1959. 《使用跳棋游戏进行机器学习的一些研究》. IBM Journal of Research and Development, 第 3 卷,第 3 期,第 210-229 页。doi: 10.1147/rd.33.0210

  • [2] Herbert Robbins, Sutton Monro. 1951. 《一种随机近似方法》. The annals of mathematical statistics, 第 22 卷, 第 3 期, 第 400-407 页。JSTOR 2236626

  • [3] Kunihiko Fukushima. 1969. 《通过模拟阈值元件的多层网络进行视觉特征提取》。doi:10.1109/TSSC.1969.300225

  • [4] Xavier Glorot, Antoine Bordes, Yoshua Bengio. 2011. 《深度稀疏修正器神经网络》. 第十四届人工智能与统计国际会议论文集。PMLR 15:315-323

  • [5] Nitish Srivastava, 等。2014. 《Dropout: 一种防止神经网络过拟合的简单方法》. Journal of Machine Learning Research 14。JMLR 14:1929-1958

  • [6] Han Xiao, Kashif Rasul, Roland Vollgraf. 2017. 《Fashion-MNIST: 用于基准测试机器学习算法的新型图像数据集》。arXiv:1708.07747

  • [7] Matthew D Zeiler, Rob Fergus. 2013. 《可视化和理解卷积网络》。arxiv:1311.2901

  • [8] Paul Werbos. 1974. 《超越回归:行为科学预测和分析的新工具》。

社区

注册登录 发表评论