在这里插入图片描述

PyTorch Scala 高校计算机硕士研一课程

章节 7: 常用模型结构介绍

您已经掌握了 PyTorch 的核心构成部分,比如张量(Tensors)、使用 Autograd 的自动求导、通过 torch.nn 定义模型,以及实现数据加载和训练步骤。本章将在之前所学知识之上,讲解如何构建特定且应用广泛的神经网络模型。

我们将着重介绍两种重要的模型类别:

  1. 卷积神经网络(CNNs): 您将了解卷积和池化的核心思想,学习为何 CNN 对网格状数据(特别是图像)表现出色,并使用 nn.Conv2dnn.MaxPool2d 等层实现一个简单的 CNN 模型。我们还将说明如何处理这些层的输入和输出形状。
  2. 循环神经网络(RNNs): 您将接触到使用循环连接和隐藏状态处理序列数据的思路。我们将使用 nn.RNN 层构建一个简单的 RNN,并讨论 PyTorch 中序列输入所需的特定数据格式。还会简要提及更高级的变体,如 LSTM 和 GRU。

到本章结束时,您将能够在 PyTorch 中构建这些常用模型的简单版本,为您后续处理更复杂的模型做好准备。

卷积神经网络 (CNN) 概述

标准神经网络层,如 nn.Linear,将输入数据视为一个扁平向量。尽管功能强大,但这种方法未能内在理解图像等数据中存在的空间结构。对于图像来说,相互靠近的像素通常是关联的,它们构成边缘、纹理或物体的一部分。当直接应用于图像时,全连接层面临两个主要问题:

  1. 参数效率低下: 将一张中等大小的图像(例如,224x224 像素,3 个颜色通道)展平为向量会导致输入维度非常大。即使将其连接到一个中等大小的隐藏层,也需要大量权重,使得模型容易过拟合,并且计算成本高昂。
  2. 空间信息丢失: 展平图像会丢弃像素的 2D(或包含通道的 3D)排列。网络会丢失关于哪些像素最初是相邻的信息。

卷积神经网络 (CNN) 是一种专门设计用于处理具有网格状拓扑数据(如图像(2D 网格)或时间序列数据(1D 网格))的神经网络。它们通过结合两个主要思想来解决标准网络的局限:局部感受野(通过卷积)和空间下采样(通过池化)。

卷积操作:识别局部模式

CNN 的核心组成部分是卷积层。卷积层不将每个输入单元连接到每个输出单元,而是使用小的过滤器(也称为核),它们在输入数据上滑动。每个过滤器都是一个小的权重矩阵。

想象一个微小的放大镜(即过滤器)在输入图像上滑动。在每个位置,过滤器会与其当前覆盖的图像区域执行元素级乘法,并将结果求和以在输出中生成一个单一值。这个过程在整个输入图像上重复进行,生成一个输出特征图

输入区域过滤器(核)输出值wu03a3(输入 u00d7 过滤器)www

过滤器对输入的局部区域施加权重,以计算输出特征图中的一个值。

这种滑动过滤器方法具有两个显著优点:

  1. 局部连接: 特征图中的每个单元仅连接到输入的一个小区域(过滤器大小)。这使得网络能够在早期层中学习到局部模式,如边缘或角落。
  2. 参数共享: 相同的过滤器(具有相同的权重集合)在输入图像的不同位置重复使用。这与全连接层相比大幅减少了参数数量,并使网络对特征的平移具有等变性。如果一个模式(如垂直边缘)被过滤器学习,它可以在图像中任何位置检测到该模式。

通常,一个卷积层会使用多个过滤器,每个过滤器学习识别不同类型的特征(例如,一个过滤器识别水平边缘,另一个识别垂直边缘,还有一个识别特定纹理)。这些过滤器的输出堆叠在一起,形成该层的最终输出体。PyTorch 主要通过 nn.Conv2d 层来实现图像数据的这一操作。

激活函数

就像在标准网络中一样,非线性激活函数(例如 ReLU,在 PyTorch 中实现为 nn.ReLU)通常在卷积操作之后进行元素级应用。这使得网络能够学习特征之间复杂的非线性关系。

池化操作:下采样与不变性

在通过卷积层检测到特征后,通常有益于使表示更紧凑并对小的空间变异具有抵抗力。这通过使用池化层来实现。

最常见的类型是最大池化。它也涉及在特征图上滑动一个窗口(通常小于卷积过滤器且不重叠或带步幅)。但是,它不应用学习到的权重,而只是简单地取出该窗口内的最大值。

特征图区域 (2x2)输出值1max(1, 5, 3, 2) = 5532

最大池化选择特征图局部窗口内的最大值。

池化提供多项益处:

  1. 维度降低: 它减少了特征图的空间维度(高度和宽度),降低了后续层的计算负担。
  2. 平移不变性(局部): 通过用其最大激活来概括局部区域,池化使表示对特征在该区域内的确切位置更具稳定性。

PyTorch 提供了 nn.MaxPool2d 等池化层。

典型 CNN 架构

一个典型的 CNN 架构通常会堆叠这些组件:

  1. 一个或多个卷积 -> 激活 -> 池化层块。早期层倾向于使用较小的过滤器来捕捉精细细节,而后期层可能使用较大的过滤器,或依赖于早期层的池化特征来捕捉更大空间区域上的更复杂模式。
  2. 在经过多个卷积和池化层之后,得到的特征图通常会展平为一个向量。
  3. 然后,这个向量被馈入一个或多个全连接 (nn.Linear) 层,类似于标准前馈网络,用于最终的分类或回归。

输入图像Conv + ReLUMaxPoolConv + ReLUMaxPoolFlattenLinear + ReLU输出(分数)

一个典型的 CNN 架构流程。

CNN 运用卷积和池化,直接从网格状数据中自动学习特征的分层表示,这使得它们在图像识别、物体检测等任务中表现非常出色,甚至在文本得到适当表示时,也能用于自然语言处理。在下一节中,你将看到如何在 PyTorch 中实现像 nn.Conv2dnn.MaxPool2d 这样的构建模块,以构建你的第一个 CNN。

在PyTorch中构建一个简单的CNN

将卷积神经网络的核心概念转化为可运行的PyTorch模型。CNN通常通过堆叠卷积层、激活函数和池化层来构建,之后通常跟随一个或多个全连接层,用于分类或回归。PyTorch的torch.nn模块提供了这些核心组件的预构建实现,以便高效构建。

我们的目标是构建一个能够处理图像数据的简单CNN。我们将从定义网络结构开始,将其作为一个Python类,并继承自torch.nn.Module

CNN的核心层在PyTorch中

  1. 卷积层 (nn.Conv2d):此层对输入应用可学习的滤波器。主要参数有:

    • in_channels:输入张量的通道数(例如,灰度图像为1,RGB图像为3)。
    • out_channels:滤波器数量(也是输出张量的通道数)。每个滤波器学习检测不同的特征。
    • kernel_size:滤波器尺寸(高 x 宽)。单个整数k表示k x k的滤波器。
    • stride:滤波器每次移动的像素数(默认为1)。
    • padding:在输入周围添加填充,常用于控制输出的空间尺寸(默认为0)。
    import torch
    import torch.nn as nn
    
    // 示例:一个Conv2d层,接收3个输入通道(例如RGB图像),
    // 使用5x5滤波器生成16个输出通道。
    val conv1 = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=5, stride=1, padding=2)
    
  2. 池化层 (nn.MaxPool2d):此层减小特征图的空间尺寸(高和宽),使表示更紧凑,并对特征位置的变化略微更具鲁棒性。

    • kernel_size:取最大值的窗口大小。
    • stride:窗口移动的距离。对于非重叠池化,通常设为等于kernel_size
    // 示例:一个MaxPool2d层,使用2x2窗口和步长为2。
    // 这通常会将输入的高度和宽度减半。
    val pool1 = nn.MaxPool2d(kernel_size=2, stride=2)
    
  3. 激活函数 (例如,nn.ReLU):引入非线性,使网络能够学习复杂的模式。ReLU(修正线性单元)是常用选择。它逐元素应用:f(x)=max(0,x)f(x)=ma**x(0,x)。

    // ReLU激活函数
    val relu1 = nn.ReLU()
    
  4. 线性层 (nn.Linear):一个标准的全连接层。通常用于CNN的末尾,在空间特征被提取和展平之后。

    • in_features:输入特征的数量(需要展平卷积/池化层的输出)。
    • out_features:输出特征的数量(例如,分类任务中的类别数)。
    // 示例:一个线性层,接收一个展平的512个特征向量,
    // 并输出10个值(例如,用于10个类别)。
    val fc1 = nn.Linear(in_features=512, out_features=10)
    

定义CNN结构

我们通过继承nn.Module来定义我们的CNN。各层通常在__init__方法中定义,而前向传播(数据如何流经各层)则在forward方法中定义。

我们来构建一个具有以下结构的CNN:

  • 输入:[批大小, 1, 28, 28](例如,像MNIST那样的灰度图像)
  • Conv1:1个输入通道,16个输出通道,5x5核,步长1,填充2
  • ReLU1
  • MaxPool1:2x2核,步长2
  • Conv2:16个输入通道,32个输出通道,5x5核,步长1,填充2
  • ReLU2
  • MaxPool2:2x2核,步长2
  • 展平
  • Linear1:(输入特征取决于MaxPool2的输出),128个输出特征
  • ReLU3
  • Linear2:128个输入特征,10个输出特征(例如,用于10个类别)
import torch
import torch.nn as nn
import torch.nn.functional as F // 通常包含激活函数和其他实用工具

class SimpleCNN extends nn.Module:
    def __init__(self):
        super(SimpleCNN, self).__init__()
        // 层定义
        // 卷积层1
        val conv1 = nn.Conv2d(in_channels=1, out_channels=16, kernel_size=5, stride=1, padding=2)
        // 最大池化层1
        val pool1 = nn.MaxPool2d(kernel_size=2, stride=2)

        // 卷积层2
        val conv2 = nn.Conv2d(in_channels=16, out_channels=32, kernel_size=5, stride=1, padding=2)
        // 最大池化层2
        val pool2 = nn.MaxPool2d(kernel_size=2, stride=2)

        // 全连接层
        // fc1的输入特征取决于池化后的输出形状
        // 输入:28x28 -> Conv1 (padding=2) -> 28x28 -> Pool1 (stride=2) -> 14x14
        // -> Conv2 (padding=2) -> 14x14 -> Pool2 (stride=2) -> 7x7
        // 因此,展平后的尺寸是 32 个通道 * 7 高度 * 7 宽度 = 1568
        val fc1 = nn.Linear(in_features=32 * 7 * 7, out_features=128)
        val fc2 = nn.Linear(in_features=128, out_features=10) // 用于10个类别的输出

    def forward(x: Tensor):
        // 定义数据流经各层的方式
        // 输入x形状:[批大小, 1, 28, 28]

        // 应用Conv1、ReLU、Pool1
        x = pool1(F.relu(conv1(x)))
        // pool1后的形状:[批大小, 16, 14, 14]

        // 应用Conv2、ReLU、Pool2
        x = pool2(F.relu(conv2(x)))
        // pool2后的形状:[批大小, 32, 7, 7]

        // 展平张量以用于全连接层
        // -1 保持批大小维度不变
        x = x.view(-1, 32 * 7 * 7)
        // view后的形状:[批大小, 1568]

        // 应用FC1和ReLU
        x = F.relu(fc1(x))
        // fc1后的形状:[批大小, 128]

        // 应用FC2(输出层,此处无激活函数,通常与损失函数一起应用)
        x = fc2(x)
        // fc2后的形状:[批大小, 10]
        return x

我们来可视化架构流程:

输入 (1x28x28)Conv1 (16x5x5, s1, p2)ReLUMaxPool1 (2x2, s2)16x28x28Conv2 (32x5x5, s1, p2)ReLU16x14x14MaxPool2 (2x2, s2)32x14x14展平32x7x7Linear1 (1568 -> 128)ReLU1568Linear2 (128 -> 10)128输出 (10)

数据和张量形状流经SimpleCNN模型。请注意,通道数增加,而空间维度(高/宽)减小。

使用模型

要使用这个模型,首先实例化该类。然后,您可以将输入数据(作为PyTorch张量)传入其中。输入张量必须具有预期的形状,包括批次维度。对于我们的SimpleCNN,这具体为[N, 1, 28, 28],其中N是批次中的样本数量。

// 实例化模型
val model = SimpleCNN()
println(model)

// 创建一个虚拟输入张量(4张图像的批次,1个通道,28x28)
// 如果您打算训练,需要梯度跟踪
val dummy_input = torch.randn(4, 1, 28, 28)

// 将输入传入模型(前向传播)
val output = model(dummy_input)

// 检查输出形状
println(s"\nInput shape: ${dummy_input.shape}")
println(s"Output shape: ${output.shape}") // 预期:[4, 10]

运行此代码将打印模型的层结构,并确认输出张量形状符合我们的预期([4, 10]),表示批次中每张图像的10个类别的得分。

这个例子展示了如何在nn.Module中组合nn.Conv2dnn.MaxPool2dnn.ReLUnn.Linear层来创建一个基本的CNN。设计CNN时的一个重要细节是正确计算每个层之后张量形状的变化,特别是在连接卷积/池化部分和全连接部分时。我们将在下一节更详细地说明这些形状的跟踪。

理解CNN层的输入/输出形状

当你开始构建卷积神经网络(CNN)时,最常见的实际问题之一是确保一个层的输出形状能正确匹配下一个层的预期输入形状。与只需要考虑一个维度的简单全连接层不同,卷积层和池化层对多维网格状数据(如图像)进行操作,涉及高度、宽度和通道维度。了解这些维度如何变化对于构建有效的CNN架构非常重要。

让我们看一个用于二维CNN层(如nn.Conv2dnn.MaxPool2d)的典型输入张量。它通常有四个维度:(N,Cin,Hin,Win)(N,C**in,H**in,W**in)

  • NN: 批大小(同时处理的样本数量)。
  • CinC**in: 输入通道数(例如,RGB图像为3,灰度图像为1)。
  • HinH**in: 输入特征图的高度。
  • WinW**in: 输入特征图的宽度。

批维度 NN 通常保持不变。主要的变换发生在通道 (CC)、高度 (HH) 和宽度 (WW) 上。

卷积层 (nn.Conv2d)

torch.nn.Conv2d层对由多个输入平面组成的输入信号应用二维卷积。影响输出形状最重要的参数是:

  • in_channels (CinC**in): 必须与输入张量中的通道数匹配。
  • out_channels (CoutCou**t): 决定卷积产生的通道数。这是该层学习的滤波器数量。
  • kernel_size: 卷积核(滤波器)的大小。可以是一个整数用于方形卷积核(例如,3表示3x3),也可以是一个元组(kH, kW)用于指定高度和宽度。
  • stride: 卷积核在输入特征图上滑动时的步长。默认为1。可以是一个整数或一个元组(sH, sW)。较大的步长会导致输出特征图尺寸更小。
  • padding: 输入边缘添加的零填充量。默认为0。可以是一个整数或一个元组(padH, padW)。填充有助于控制输出的空间维度,并能保留边界信息。
  • dilation: 卷积核元素之间的间距。默认为1。较大的空洞(扩张)允许卷积核覆盖输入更广的区域,而不会增加参数数量(空洞卷积)。

输出形状 (N,Cout,Hout,Wout)(N,Cou**t,Hou**t,Wou**t) 如下确定:

  1. **通道数 (Cout*C**o*u*t*): 这由nn.Conv2d层的out_channels参数直接设置。每个滤波器产生一个输出通道(特征图)。

  2. 高度 (Hout*H**o**u**t*) 和宽度 (Wout*W*o*u*t*): 这些取决于输入维度 (Hin,WinHin*,W**in) 和层的参数。计算输出高度的公式是:

    Hout=⌊Hin+2×填充[0]−空洞[0]×(卷积核尺寸[0]−1)−1步长[0]+1⌋Hou**t=⌊步长[0]H**in+2×填充[0]−空洞[0]×(卷积核尺寸[0]−1)−1+1⌋

    对于宽度 (WoutWou**t) 也是类似的:

    Wout=⌊Win+2×填充[1]−空洞[1]×(卷积核尺寸[1]−1)−1步长[1]+1⌋Wou**t=⌊步长[1]W**in+2×填充[1]−空洞[1]×(卷积核尺寸[1]−1)−1+1⌋

    注意:如果paddingdilationkernel_sizestride被指定为单个整数,它们将应用于高度和宽度两个维度(例如,padding[0] = padding[1] = padding)。符号 ⌊⋅⌋⌊⋅⌋ 表示向下取整函数(向下舍入到最接近的整数)。

让我们看一个当dilation = 1的常见情况。公式简化为:

Hout=⌊Hin+2×填充[0]−卷积核尺寸[0]步长[0]+1⌋Hou**t=⌊步长[0]H**in+2×填充[0]−卷积核尺寸[0]+1⌋Wout=⌊Win+2×填充[1]−卷积核尺寸[1]步长[1]+1⌋Wou**t=⌊步长[1]W**in+2×填充[1]−卷积核尺寸[1]+1⌋

示例:

假设我们有一个形状为(16, 3, 32, 32)的输入张量(批=16,通道=3,高=32,宽=32)。我们将其通过一个定义如下的nn.Conv2d层:

import torch
import torch.nn as nn

val conv_layer = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, stride=1, padding=1)
// 输入: N=16, Cin=3, Hin=32, Win=32
val input_tensor = torch.randn(16, 3, 32, 32)

// 参数: K=3, S=1, P=1, D=1 (默认)
// H_out = floor((32 + 2*1 - 1*(3-1) - 1)/1 + 1) = floor((32 + 2 - 2 - 1)/1 + 1) = floor(31/1 + 1) = 32
// W_out = floor((32 + 2*1 - 1*(3-1) - 1)/1 + 1) = floor((32 + 2 - 2 - 1)/1 + 1) = floor(31/1 + 1) = 32
// 简化公式 (D=1):
// H_out = floor((32 + 2*1 - 3)/1 + 1) = floor(31/1 + 1) = 32
// W_out = floor((32 + 2*1 - 3)/1 + 1) = floor(31/1 + 1) = 32

// 前向传播
val output_tensor = conv_layer(input_tensor)
println(s"Output shape: ${output_tensor.shape}") // 预期:[16, 64, 32, 32]

在此示例中,使用kernel_size=3stride=1padding=1是一种常见组合,它保持了输入的高度和宽度(32x32 -> 32x32),同时将通道数从3变为64。这有时被称为“相同”填充,尽管PyTorch不像其他一些框架那样有明确的'same'选项;你可以通过正确设置参数来实现。

如果我们将步长改为2(stride=2),输出维度将会减小:

import torch
import torch.nn as nn

val conv_layer_s2 = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, stride=2, padding=1)
// H_out = floor((32 + 2*1 - 3)/2 + 1) = floor(31/2 + 1) = floor(15.5 + 1) = floor(16.5) = 16
// W_out = floor((32 + 2*1 - 3)/2 + 1) = floor(31/2 + 1) = floor(15.5 + 1) = floor(16.5) = 16
// 前向传播
val output_tensor_s2 = conv_layer_s2(input_tensor)
println(s"Output shape: ${output_tensor_s2.shape}") // 预期:[16, 64, 16, 16]

池化层 (nn.MaxPool2d)

池化层,例如nn.MaxPool2d,用于减小特征图的空间维度(下采样),使表示更紧凑且对小的平移具有鲁棒性。它们在每个通道上独立操作。

影响形状的主要参数与nn.Conv2d相似,但没有out_channels这个参数,因为池化不会改变通道数量:

  • kernel_size: 池化窗口的大小。
  • stride: 窗口的步长。通常设置为与kernel_size相等,以实现不重叠的池化(默认值是kernel_size)。
  • padding: 添加的零填充量。
  • dilation: 控制池化元素之间的间距。

输出形状 (N,Cout,Hout,Wout)(N,Cou**t,Hou**t,Wou**t) 中 HoutHou**t 和 WoutWou**t 的计算遵循与卷积层完全相同的公式:

Hout=⌊Hin+2×填充[0]−空洞[0]×(卷积核尺寸[0]−1)−1步长[0]+1⌋Hou**t=⌊步长[0]H**in+2×填充[0]−空洞[0]×(卷积核尺寸[0]−1)−1+1⌋Wout=⌊Win+2×填充[1]−空洞[1]×(卷积核尺寸[1]−1)−1步长[1]+1⌋Wou**t=⌊步长[1]W**in+2×填充[1]−空洞[1]×(卷积核尺寸[1]−1)−1+1⌋

重要区别: 池化层改变通道数量。因此,Cout=CinCou**t=C**in

示例:

让我们使用我们第一个conv_layer的输出(形状为[16, 64, 32, 32])并将其通过一个常见的最大池化层:

import torch
import torch.nn as nn

// 来自前一个卷积层的输入: N=16, Cin=64, Hin=32, Win=32
val pool_layer = nn.MaxPool2d(kernel_size=2, stride=2, padding=0) // 常见设置

// 参数: K=2, S=2, P=0, D=1 (默认)
// H_out = floor((32 + 2*0 - 1*(2-1) - 1)/2 + 1) = floor((32 - 1 - 1)/2 + 1) = floor(30/2 + 1) = floor(15 + 1) = 16
// W_out = floor((32 + 2*0 - 1*(2-1) - 1)/2 + 1) = floor((32 - 1 - 1)/2 + 1) = floor(30/2 + 1) = floor(15 + 1) = 16
// 前向传播
val pooled_output = pool_layer(output_tensor)
println(s"Output shape: ${pooled_output.shape}") // 预期:[16, 64, 16, 16]

在此,具有2x2卷积核和步长为2的池化层将高度和宽度维度减半(32x32 -> 16x16),而通道数量保持不变(64)。

输入(N, 3, 32, 32)nn.Conv2d(输出=64, K=3, S=1, P=1)保持高宽(N, 64, 32, 32)nn.MaxPool2d(K=2, S=2, P=0)高宽减半(N, 64, 16, 16)输出(N, 64, 16, 16)

张量维度通过一个示例卷积和池化层序列的流向。

实际中跟踪形状

在构建复杂的CNN时,手动计算形状会变得繁琐且容易出错。这里有一些实用建议:

  1. 打印形状: 在初始开发阶段,在层之后添加print(x.shape)语句以验证维度。
  2. 使用虚拟输入: 创建一个具有预期形状的虚拟输入张量,并将其逐步或逐层地通过你的网络定义,以查看形状如何变化。
  3. 辅助函数: 编写一个小的辅助函数,以层和输入形状作为参数,并使用公式计算输出形状。
  4. 库/工具: 一些库或工具(如torchinfopytorch-summary)可以自动总结你的模型,显示给定输入尺寸下每个层的输出形状。

掌握形状计算是在设计和调试CNN时必要的一步。通过理解kernel_sizestridepaddingdilation如何影响空间维度,以及out_channels如何决定深度,你可以放心地堆叠层来构建有效的深度学习模型。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐