卷积神经网络CNN

[!note] 使用FNN处理图像时遇到的问题

  • 参数过多:$100 \times 100 \times 3$ 的图像,,在FNN中,第一个隐藏层的每个神经元到输入层都有$100 \times 100 \times 3=30000$ 个连接,且每个连接都有1个独立的参数,那么随着隐藏层神经元增多,参数规模也会急剧增大,使得训练效率非常低
  • 局部不变性特征:尺度缩放、平移、旋转等操作不影响语义信息,而FNN很难提取

卷积神经网络,也就是convolutional neural networks(简称CNN),现在已经被用来应用于各个领域,如物体分割,风格转换,自动上色等。但是CNN真正能做的,只是起到一个特征提取器的作用,所有这些应用,都是建立在CNN对图像进行特征提取的基础上进行的。

在用神经网络训练提取图像特征时,我们主要考虑到图像的两个性质:

  1. _平移不变性_(translation invariance):不管检测对象出现在图像中的哪个位置,神经网络的前面几层应该对相同的图像区域具有相似的反应,即为“平移不变性”。
  2. _局部性_(locality):神经网络的前面几层应该只探索输入图像中的局部区域,而不过度在意图像中相隔较远区域的关系,这就是“局部性”原则。最终,可以聚合这些局部特征,以在整个图像级别进行预测。

在李宏毅的网课上,他提到了三个特性,是针对神经网络的:

  • 局部连接
  • 权重共享
  • 汇聚

怎么把图像当做模型的输入呢?对于机器,图像可以描述为三维张量(张量可以想成维度大于 2 的矩阵)。一张图像是一个三维的张量,其中一维代表图像的宽,另外一维代表图像的高,还有一维代表图像的通道(channel)的数目(如果为rgb,那么就有3个色彩通道)。

再传入网络之前,需要先进行“拉直”,即将其按照通道、排列进行衔接,形成一个巨大的向量
image.png

如果把原本的向量作为全连接网络输入,那么特征向量会非常长,对应需要的权重参数也会非常多。因此需要专门针对图像任务对网络进行修改

修改后的网络输出一个向量$y’$,通过softmax后输出一个$\hat{y}$,我们希望$y’$和$\hat{y}$的交叉熵越小越好

参考:
卷积神经网络CNN完全指南终极版(一) - 沉迷学习的糕糕的文章 - 知乎

卷积基本概念

观察1:检测模式不需要整张图像

比如,检测一只鸟,我们往往是通过看到了显著的特征,从而进行分类
那么,对于神经元来说,只要它们看到的一小部分检测出了一些关键模式,即可帮助进行物种判断
image.png

感受野

根据观察 1 可以做第 1 个简化,卷积神经网络会设定一个区域,即感受野(receptivefield),每个神经元都只关心自己的感受野里面发生的事情,感受野是由我们自己决定的。

蓝色的神经元看左上角这个范围,这是它的感受野。黄色的神经元看右下角 3 × 3 × 3 的范围。图 4.7 中的一个正方形代表 3 × 3 × 3 的范围,右下角的正方形是黄色神经元的感受野

神经元会把 3 × 3 × 3 的数值“拉直”变成一个长度是 3 × 3 × 3=27 维的向量,再把这 27 维的向量作为神经元的输入,这个神经元会给 27 维的向量的每个维度一个权重,所以这个神经元有 3 × 3 × 3 = 27 个权重,再加上偏置(bias)得到输出。这个输出再送给下一层的神经元当作输入。

感受野可以互相重叠
image.png

在卷积神经网络中,感受野的定义是 卷积神经网络每一层输出的特征图(feature map)上的像素点在原始图像上映射的区域大小,更深的网络可以使得感受野更广阔

image.png

假设我们有一个简单的卷积神经网络,其中包含两个卷积层。我们将关注第二个卷积层的一个特定神经元,想要计算它的感受野大小。

  1. 第一个卷积层的每个神经元在输入图像上应用了3x3大小的卷积核
  2. 第一个卷积层的步幅(stride)为1,没有填充(padding为0)。
  3. 第二个卷积层的卷积核大小也为3x3,步幅为1,没有填充。

现在,让我们计算第二个卷积层的一个神经元的感受野大小:

  1. 第一个卷积层的神经元的感受野是3x3,因为它受到了3x3大小的卷积核的影响。
  2. 第二层的卷积核大小为3x3。但是,由于它是在第一层的输出上操作,通过上图的映射可以看到第二层神经元的感受野是5x5。

可以看到,随着网络的深度增加,神经元的感受野也逐渐扩大,可以感受到更大范围的输入信息。这种分层结构允许网络在不同层次上捕获输入数据的不同特征和抽象信息,从而有助于更好地理解图像或其他类型的数据

感受野计算公式:
$$
RF_{i}=(RF_{i+1}-1)\times stride_{i}+K_{size_{i}}
$$
卷积计算公式:N=(W-F+2P)/S+1

卷积计算

概念|卷积的三种模式:valid、same、full_valid卷积-CSDN博客

计算即为互相关运算

在二维互相关运算中,卷积窗口从输入张量的左上角开始,从左到右、从上到下滑动。 当卷积窗口滑动到新一个位置时,包含在该窗口中的部分张量与卷积核张量进行按元素相乘,得到的张量再求和得到一个单一的标量值,由此我们得出了这一位置的输出张量值。

注意,输出大小略小于输入大小。这是因为卷积核的宽度和高度大于1, 而卷积核只与图像中每个大小完全适合的位置进行互相关运算。 所以,输出大小等于输入大小$n_h×n_w$减去卷积核大小$k_h×k_w$,即:

$$
(n_{h}-k_{h}+1)\times (n_{w}-k_{w}+1)
$$

填充

[!question] 感受野超出了图像的范围,怎么办呢?
如果不在超过图像的范围“摆”感受野,就没有神经元去检测出现在边界的模式,这样就会漏掉图像边界的地方,所以一般边界的地方也会考虑的。如图 所示,超出范围就做填充(padding),填充就是补值,一般使用零填充(zero padding),超出范围就补 0
image.png

image.png

通常,如果我们添加$p_h$行填充(大约一半在顶部,一半在底部)和$p_w$列填充(左侧大约一半,右侧一半),则输出形状将为
$$
(n_{k}-k_{h}+p_{h}+1) \times (n_{w}-k_{w}+p_{w}+1)
$$

卷积核的高度和宽度通常为奇数,例如1、3、5或7。 选择奇数的好处是,保持空间维度的同时,我们可以在顶部和底部填充相同数量的行,在左侧和右侧填充相同数量的列

更详细的计算公式:
image.png

步幅

在计算互相关时,卷积窗口从输入张量的左上角开始,向下、向右滑动。 在前面的例子中,我们默认每次滑动一个元素。 但是,有时候为了高效计算或是缩减采样次数,卷积窗口可以跳过中间位置,每次滑动多个元素。

滑动的幅度,是超参数,因为希望感受野跟感受野之间是有重叠的,所以步幅往往不会设太大,一般设为 1 或 2。

当设置垂直步幅为3,水平步幅为2的二维互相关运算时,可以看到,为了计算输出中第一列的第二个元素和第一行的第二个元素,卷积窗口分别向下滑动三行和向右滑动两列。但是,当卷积窗口继续向右滑动两列时,没有输出,因为输入元素无法填充窗口(除非我们添加另一列填充)。

image.png

通常,当垂直步幅为$s_h$、水平步幅为$s_w$时,输出形状为
$$⌊(n_h−k_h+p_h+s_h)/s_h⌋×⌊(n_w−k_w+p_w+s_w)/s_w⌋.$$

如果我们设置了$p_h=k_h−1和p_w=k_w−1$,则输出形状将简化为$⌊(n_h+s_h−1)/s_h⌋×⌊(n_w+s_w−1)/s_w⌋$。 更进一步,如果输入的高度和宽度可以被垂直和水平步幅整除,则输出形状将为$(n_h/s_h)×(n_w/s_w)$。

观察2:共享参数(通道)

同样的模式可能会出现在图像的不同区域

假设其中有一个神经元可以检测鸟嘴,鸟嘴出现在图像的中间也会被检测出来。

这些检测鸟嘴的神经元做的事情是一样的,只是它们守备的范围不一样。既然如此,没必要每个守备范围都去放一个检测鸟嘴的神经元。而是让不同感受野的神经元共享参数

image.png

图 4.16 中使用一样的颜色代表这两个神经元共享一样的参数,所以每个感受野都只有一组参数,就是上面感受野的第 1 个神经元会跟下面感受野的第 1 个神经元共用参数,上面感受野的第 2 个神经元跟下面感受野的第 2 个神经元共用参数……所以每个感受野都只有一组参数而已,这些参数称为滤波器(filter)

image.png

由此引入多输入多输出通道的具体内容

多输入通道

  • 当输入通道数$c_{i}=1$时,卷积核即为简单的$k_{h} \times k_{w}$的二维张量
  • 但当$c_i>1$时,我们卷积核的每个输入通道将包含形状为$k_h×k_w$的张量
  • 将这些张量$c_i$连结在一起可以得到形状为$c_i×k_h×k_w$的卷积核。由于输入和卷积核都有$c_i$个通道,我们可以对每个通道输入的二维张量和卷积核的二维张量进行互相关运算,再对通道求和(将$c_i$的结果相加)得到二维张量。
  • 这是多通道输入和多输入通道卷积核之间进行二维互相关运算的结果。

image.png

现实情况在相加后可能还有偏置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import torch
from d2l import torch as d2l

def corr2d_multi_in(X, K):
# 先遍历“X”和“K”的第0个维度(通道维度),再把它们加在一起
return sum(d2l.corr2d(x, k) for x, k in zip(X, K))

X = torch.tensor([[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]],
[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]])
K = torch.tensor([[[0.0, 1.0], [2.0, 3.0]], [[1.0, 2.0], [3.0, 4.0]]])

corr2d_multi_in(X, K)
'''
tensor([[ 56., 72.],
[104., 120.]])
'''

多输出通道

增加多个卷积核即可

直观地说,我们可以将每个通道看作对不同特征的响应。而现实可能更为复杂一些,因为每个通道不是独立学习的,而是为了共同使用而优化的。因此,多输出通道并不仅是学习多个单通道的检测器。
用$c_i$和$c_o$分别表示输入和输出通道的数目,并让$k_h$和$k_w$为卷积核的高度和宽度。为了获得多个通道的输出,我们可以为每个输出通道创建一个形状为$c_i×k_h×k_w$的卷积核张量,这样卷积核的形状是$c_o×c_i×k_h×k_w$(我的理解是有$c_{o}$个卷积核)。在互相关运算中,每个输出通道先获取所有输入通道,再以对应该输出通道的卷积核计算出结果。

1
2
3
4
5
6
7
8
9
def corr2d_multi_in_out(X, K):
# 迭代“K”的第0个维度,每次都对输入“X”执行互相关运算。
# 最后将所有结果都叠加在一起
return torch.stack([corr2d_multi_in(X, k) for k in K], 0)

K = torch.stack((K, K + 1, K + 2), 0)
K.shape
# 执行互相关运算
corr2d_multi_in_out(X, K)

学习卷积核

可以通过仅查看“输入-输出”对来学习由X生成Y的卷积核。 我们先构造一个卷积层,并将其卷积核初始化为随机张量。接下来,在每次迭代中,我们比较Y与卷积层输出的平方误差,然后计算梯度来更新卷积核。为了简单起见,我们在此使用内置的二维卷积层,并忽略偏置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
X = torch.ones((6, 8))
X[:, 2:6] = 0

# 构造一个二维卷积层,它具有1个输出通道和形状为(1,2)的卷积核
conv2d = nn.Conv2d(1,1, kernel_size=(1, 2), bias=False)

# 这个二维卷积层使用四维输入和输出格式(批量大小、通道、高度、宽度),
# 其中批量大小和通道数都为1
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
lr = 3e-2 # 学习率

for i in range(10):
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
conv2d.zero_grad()
l.sum().backward()
# 迭代卷积核
conv2d.weight.data[:] -= lr * conv2d.weight.grad
if (i + 1) % 2 == 0:
print(f'epoch {i+1}, loss {l.sum():.3f}')

conv2d.weight.data.reshape((1, 2)) # tensor([[ 1.0010, -0.9739]])

学习到的权重接近想要的权重

1 x 1 卷积

通常用于调整网络层的通道数量和控制模型复杂性

因为使用了最小窗口,1×1卷积失去了卷积层的特有能力——在高度和宽度维度上,识别相邻元素间相互作用的能力。 其实1×1卷积的唯一计算发生在通道上。

下图展示了使用1×1卷积核与3个输入通道和2个输出通道的互相关计算。
image.png

这里输入和输出具有相同的高度和宽度,输出中的每个元素都是从输入图像中同一位置的元素的线性组合。 我们可以将1×1卷积层看作在每个像素位置应用的全连接层,以ci个输入值转换为co个输出值。 因为这仍然是一个卷积层,所以跨像素的权重是一致的。 同时,1×1卷积层需要的权重维度为co×ci,再额外加上一个偏置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def corr2d_multi_in_out_1x1(X, K):
c_i, h, w = X.shape
c_o = K.shape[0]
X = X.reshape((c_i, h * w))
K = K.reshape((c_o, c_i))
# 全连接层中的矩阵乘法
Y = torch.matmul(K, X)
return Y.reshape((c_o, h, w))

X = torch.normal(0, 1, (3, 3, 3))
K = torch.normal(0, 1, (2, 3, 1, 1))

Y1 = corr2d_multi_in_out_1x1(X, K)
Y2 = corr2d_multi_in_out(X, K)
assert float(torch.abs(Y1 - Y2).sum()) < 1e-6

观察3:池化(汇聚)层

假设三:下采样不影响图像的识别

通常当我们处理图像时,我们希望逐渐降低隐藏表示的空间分辨率、聚集信息,这样随着我们在神经网络中层叠的上升,每个神经元对其敏感的感受野(输入)就越大。

而我们的机器学习任务通常会跟全局图像的问题有关(例如,“图像是否包含一只猫呢?”),所以我们最后一层的神经元应该对整个输入的全局敏感。通过逐渐聚合信息,生成越来越粗糙的映射,最终实现学习全局表示的目标,同时将卷积图层的所有优势保留在中间层。

此外,当检测较底层的特征时,我们通常希望这些特征保持某种程度上的平移不变性。例如,如果我们拍摄黑白之间轮廓清晰的图像X,并将整个图像向右移动一个像素,即Z[i, j] = X[i, j + 1],则新图像Z的输出可能大不相同。而在现实中,随着拍摄角度的移动,任何物体几乎不可能发生在同一像素上。即使用三脚架拍摄一个静止的物体,由于快门的移动而引起的相机振动,可能会使所有物体左右移动一个像素(除了高端相机配备了特殊功能来解决这个问题)。

汇聚层具有双重目的:

  • 降低卷积层对位置的敏感性
  • 降低对空间降采样表示的敏感性

最大汇聚和平均汇聚

汇聚层运算符由一个固定形状的窗口组成,该窗口根据其步幅大小在输入的所有区域上滑动,为固定形状窗口(有时称为 汇聚窗口 )遍历的每个位置计算一个输出。然而,不同于卷积层中的输入与卷积核之间的互相关计算,汇聚层不包含参数
相反,池运算是确定性的,我们通常计算汇聚窗口中所有元素的最大值或平均值。
这些操作分别称为 _最大汇聚层_(maximum pooling)和 _平均汇聚层_(average pooling)。

image.png

回到开头提到的对象边缘检测示例,现在我们将使用卷积层的输出作为2×2最大汇聚的输入。 设置卷积层输入为X,汇聚层输出为Y。 无论X[i, j]X[i, j + 1]的值相同与否,或X[i, j + 1]X[i, j + 2]的值相同与否,汇聚层始终输出Y[i, j] = 1。 也就是说,使用2×2最大汇聚层,即使在高度或宽度上移动一个元素,卷积层仍然可以识别到模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import torch
from torch import nn
from d2l import torch as d2l

def pool2d(X, pool_size, mode='max'):
p_h, p_w = pool_size
Y = torch.zeros((X.shape[0] - p_h + 1, X.shape[1] - p_w + 1))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
if mode == 'max':
Y[i, j] = X[i: i + p_h, j: j + p_w].max()
elif mode == 'avg':
Y[i, j] = X[i: i + p_h, j: j + p_w].mean()
return Y

X = torch.tensor([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
pool2d(X, (2, 2))

多通道

在处理多通道输入数据时,汇聚层在每个输入通道上单独运算,而不是像卷积层一样在通道上对输入进行汇总。 这意味着汇聚层的输出通道数与输入通道数相同。

在实践中,通常将卷积和汇聚交替使用,可以先做几次卷积,再做一次汇聚

CNN 代码精读

1
git clone https://www.modelscope.cn/datasets/Datawhale/LeeDL-HW3-CNN.git

先看到Datawhale的介绍:
卷积神经网络是深度学习中的一个非常重要的分支,本作业提供了进行图像分类任务的基本范式。

  • 准备数据
  • 训练模型
  • 应用模型

要完成一个深度神经网络训练模型的代码,大概需要完成下面的内容:

  1. 导入所需要的库/工具包
  2. 数据准备与预处理
  3. 定义模型
  4. 定义损失函数和优化器等其他配置
  5. 训练模型
  6. 评估模型
  7. 进行预测
    此范式不仅适用于图像分类任务,对于广泛的深度学习任务也是适用的。

导入必要的库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 导入必要的库
import numpy as np
import pandas as pd
import torch
import os
import torch.nn as nn
import torchvision.transforms as transforms
from PIL import Image
# “ConcatDataset” 和 “Subset” 在进行半监督学习时可能是有用的。
from torch.utils.data import ConcatDataset, DataLoader, Subset, Dataset
from torchvision.datasets import DatasetFolder, VisionDataset
# 这个是用来显示进度条的。
from tqdm.auto import tqdm
import random

设置随机种子,配置CUDA

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 设置随机种子以确保实验结果的可重复性
myseed = 6666

# 确保在使用CUDA时,卷积运算具有确定性,以增强实验结果的可重复性
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False

# 为numpy和pytorch设置随机种子
np.random.seed(myseed)
torch.manual_seed(myseed)

# 如果使用CUDA,为所有GPU设置随机种子
if torch.cuda.is_available():
torch.cuda.manual_seed_all(myseed)

torch.backends.cudnn.benchmark 是 PyTorch 深度学习框架中的一个参数,它与 CUDA 神经网络库(cuDNN)相关。cuDNN 是 NVIDIA 提供的一个 GPU 加速的深度神经网络库,它允许 PyTorch 利用 NVIDIA 的 GPU 进行快速的神经网络运算。

torch.backends.cudnn.benchmark 参数的作用是:

  • 当设置为 True 时,cuDNN 会尝试寻找最优的算法来执行每个操作,这可能会增加模型的初始化时间,但可以提高模型运行时的效率。
  • 当设置为 False 时,cuDNN 会使用一个默认的算法来执行操作,这可能会减少初始化时间,但可能不会达到最优的运行效率。

通常,在训练大型模型或进行多次迭代时,使用 torch.backends.cudnn.benchmark=True 可能会带来性能上的提升。然而,如果模型较小或者只运行一次,开启这个参数可能不会带来明显的好处,反而可能会因为初始化时间的增加而导致总体运行时间变长。

数据准备与预处理

数据准备包括从指定路径加载图像数据,并对其进行预处理。作业中对图像的预处理操作包括调整大小和将图像转换为Tensor格式。

Torchvision为图像预处理、数据增强和数据加载提供了一系列的API,这些API可以方便的实现图像预处理、数据增强和数据加载。  

具体不同的详细操作可以通过Pytorch的官方文档查看。

为了增强模型的鲁棒性,可以对训练集进行数据增强。相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 在测试和验证阶段,通常不需要图像增强。
# 我们所需要的只是调整PIL图像的大小并将其转换为Tensor。
test_tfm = transforms.Compose([
transforms.Resize((128, 128)),
transforms.ToTensor(),
])

# 不过,在测试阶段使用图像增强也是有可能的。
# 你可以使用train_tfm生成多种图像,然后使用集成方法进行测试。
train_tfm = transforms.Compose([
# 将图像调整为固定大小(高度和宽度均为128)
transforms.Resize((128, 128)),
# TODO:你可以在这里添加一些图像增强的操作。

# ToTensor()应该是所有变换中的最后一个。
transforms.ToTensor(),
])

数据集

数据通过名称进行标记,因此在调用’getitem’时我们同时加载图像和标签。  

定义了一个名为 FoodDataset 的类,继承自 Dataset,用于加载并预处理食品图像数据集,支持图像变换及从文件名中提取标签。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class FoodDataset(Dataset):
"""
用于加载食品图像数据集的类。

该类继承自Dataset,提供了对食品图像数据集的加载和预处理功能。
它可以自动从指定路径加载所有的jpg图像,并对这些图像应用给定的变换。
"""

def __init__(self, path, tfm=test_tfm, files=None):
"""
初始化FoodDataset实例。

参数:
- path: 图像数据所在的目录路径。
- tfm: 应用于图像的变换方法(默认为测试变换)。
- files: 可选参数,用于直接指定图像文件的路径列表(默认为None)。
"""
super(FoodDataset).__init__()
self.path = path
# 列出目录下所有jpg文件,并按顺序排序
self.files = sorted([os.path.join(path, x) for x in os.listdir(path) if x.endswith(".jpg")])
if files is not None:
self.files = files # 如果提供了文件列表,则使用该列表
self.transform = tfm # 图像变换方法

def __len__(self):
"""
返回数据集中图像的数量。

返回:
- 数据集中的图像数量。
"""
return len(self.files)

def __getitem__(self, idx):
"""
获取给定索引的图像及其标签。

参数:
- idx: 图像在数据集中的索引。

返回:
- im: 应用了变换后的图像。
- label: 图像对应的标签(如果可用)。
"""
fname = self.files[idx]
im = Image.open(fname)
im = self.transform(im) # 应用图像变换

# 尝试从文件名中提取标签
try:
label = int(fname.split("/")[-1].split("_")[0])
except:
label = -1 # 如果无法提取标签,则设置为-1(测试数据无标签)

return im, label

模型定义

这段代码定义了一个图像分类器类(Classifier),继承自PyTorch的nn.Module。该分类器通过一系列卷积层、批归一化层、激活函数和池化层构建卷积神经网络(CNN),用于提取图像特征。随后,这些特征被输入到全连接层进行分类,最终输出11个类别的概率,用于图像分类任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class Classifier(nn.Module):
"""
定义一个图像分类器类,继承自PyTorch的nn.Module。
该分类器包含卷积层和全连接层,用于对图像进行分类。
"""
def __init__(self):
"""
初始化函数,构建卷积神经网络的结构。
包含一系列的卷积层、批归一化层、激活函数和池化层。
"""
super(Classifier, self).__init__()
# 定义卷积神经网络的序列结构
self.cnn = nn.Sequential(
nn.Conv2d(3, 64, 3, 1, 1), # 输入通道3,输出通道64,卷积核大小3,步长1,填充1
nn.BatchNorm2d(64), # 批归一化,作用于64个通道
nn.ReLU(), # ReLU激活函数
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0

nn.Conv2d(64, 128, 3, 1, 1), # 输入通道64,输出通道128,卷积核大小3,步长1,填充1
nn.BatchNorm2d(128), # 批归一化,作用于128个通道
nn.ReLU(),
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0

nn.Conv2d(128, 256, 3, 1, 1), # 输入通道128,输出通道256,卷积核大小3,步长1,填充1
nn.BatchNorm2d(256), # 批归一化,作用于256个通道
nn.ReLU(),
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0

nn.Conv2d(256, 512, 3, 1, 1), # 输入通道256,输出通道512,卷积核大小3,步长1,填充1
nn.BatchNorm2d(512), # 批归一化,作用于512个通道
nn.ReLU(),
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0

nn.Conv2d(512, 512, 3, 1, 1), # 输入通道512,输出通道512,卷积核大小3,步长1,填充1
nn.BatchNorm2d(512), # 批归一化,作用于512个通道
nn.ReLU(),
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0
)
# 定义全连接神经网络的序列结构
self.fc = nn.Sequential(
nn.Linear(512*4*4, 1024), # 输入大小512*4*4,输出大小1024
nn.ReLU(),
nn.Linear(1024, 512), # 输入大小1024,输出大小512
nn.ReLU(),
nn.Linear(512, 11) # 输入大小512,输出大小11,最终输出11个类别的概率
)

def forward(self, x):
"""
前向传播函数,对输入进行处理。

参数:
x -- 输入的图像数据,形状为(batch_size, 3, 128, 128)

返回:
输出的分类结果,形状为(batch_size, 11)
"""
out = self.cnn(x) # 通过卷积神经网络处理输入
out = out.view(out.size()[0], -1) # 展平输出,以适配全连接层的输入要求
return self.fc(out) # 通过全连接神经网络得到最终输出

定义损失函数和优化器等其他配置

这段代码实现了图像分类模型的初始化和训练配置,目的是准备好训练环境和参数。它选择合适的设备(GPU或CPU),设置模型、批量大小、训练轮数、提前停止策略,定义了损失函数和优化器,为后续的模型训练奠定了基础。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 根据GPU是否可用选择设备类型
device = "cuda" if torch.cuda.is_available() else "cpu"

# 初始化模型,并将其放置在指定的设备上
model = Classifier().to(device)

# 定义批量大小
batch_size = 64

# 定义训练轮数
n_epochs = 8

# 如果在'patience'轮中没有改进,则提前停止
patience = 5

# 对于分类任务,我们使用交叉熵作为性能衡量标准
criterion = nn.CrossEntropyLoss()

# 初始化优化器,您可以自行调整一些超参数,如学习率
optimizer = torch.optim.Adam(model.parameters(), lr=0.0003, weight_decay=1e-5)

加载数据

1
2
3
4
5
6
7
8
9
10
# 构建训练和验证数据集
# "loader" 参数定义了torchvision如何读取数据
train_set = FoodDataset("./hw3_data/train", tfm=train_tfm)
# 创建训练数据加载器,设置批量大小、是否打乱数据顺序、是否使用多线程加载以及是否固定内存地址
train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=0, pin_memory=True)
# 构建验证数据集
# "loader" 参数定义了torchvision如何读取数据
valid_set = FoodDataset("./hw3_data/valid", tfm=test_tfm)
# 创建验证数据加载器,设置批量大小、是否打乱数据顺序、是否使用多线程加载以及是否固定内存地址
valid_loader = DataLoader(valid_set, batch_size=batch_size, shuffle=True, num_workers=0, pin_memory=True)

训练与验证模型

这段代码实现了一个图像分类模型的训练和验证循环,目的是通过多轮训练(epochs)逐步优化模型的参数,以提高其在验证集上的性能,并保存效果最好的模型。训练阶段通过前向传播、计算损失、反向传播和参数更新来优化模型,验证阶段评估模型在未见过的数据上的表现。如果验证集的准确率超过了之前的最好成绩,保存当前模型,并在连续多轮验证性能未提升时提前停止训练。

训练完成后,需要在测试集上评估模型的性能。通过计算准确率来衡量模型在测试集上的表现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# 初始化追踪器,这些不是参数,不应该被更改
stale = 0
best_acc = 0

for epoch in range(n_epochs):
# ---------- 训练阶段 ----------
# 确保模型处于训练模式
model.train()

# 这些用于记录训练过程中的信息
train_loss = []
train_accs = []

for batch in tqdm(train_loader):
# 每个批次包含图像数据及其对应的标签
imgs, labels = batch
# imgs = imgs.half()
# print(imgs.shape,labels.shape)

# 前向传播数据。(确保数据和模型位于同一设备上)
logits = model(imgs.to(device))

# 计算交叉熵损失。
# 在计算交叉熵之前不需要应用softmax,因为它会自动完成。
loss = criterion(logits, labels.to(device))

# 清除上一步中参数中存储的梯度
optimizer.zero_grad()

# 计算参数的梯度
loss.backward()

# 为了稳定训练,限制梯度范数
grad_norm = nn.utils.clip_grad_norm_(model.parameters(), max_norm=10)

# 使用计算出的梯度更新参数
optimizer.step()

# 计算当前批次的准确率
acc = (logits.argmax(dim=-1) == labels.to(device)).float().mean()

# 记录损失和准确率
train_loss.append(loss.item())
train_accs.append(acc)

train_loss = sum(train_loss) / len(train_loss)
train_acc = sum(train_accs) / len(train_accs)

# 打印信息
print(f"[ 训练 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {train_loss:.5f}, acc = {train_acc:.5f}")

# ---------- 验证阶段 ----------
# 确保模型处于评估模式,以便某些模块如dropout能够正常工作
model.eval()

# 这些用于记录验证过程中的信息
valid_loss = []
valid_accs = []

# 按批次迭代验证集
for batch in tqdm(valid_loader):
# 每个批次包含图像数据及其对应的标签
imgs, labels = batch
# imgs = imgs.half()

# 我们在验证阶段不需要梯度。
# 使用 torch.no_grad() 加速前向传播过程。
with torch.no_grad():
logits = model(imgs.to(device))

# 我们仍然可以计算损失(但不计算梯度)。
loss = criterion(logits, labels.to(device))

# 计算当前批次的准确率
acc = (logits.argmax(dim=-1) == labels.to(device)).float().mean()

# 记录损失和准确率
valid_loss.append(loss.item())
valid_accs.append(acc)
# break

# 整个验证集的平均损失和准确率是所记录值的平均
valid_loss = sum(valid_loss) / len(valid_loss)
valid_acc = sum(valid_accs) / len(valid_accs)

# 打印信息
print(f"[ 验证 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f}")

# 更新日志
if valid_acc > best_acc:
with open(f"./{_exp_name}_log.txt", "a"):
print(f"[ 验证 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f} -> 最佳")
else:
with open(f"./{_exp_name}_log.txt", "a"):
print(f"[ 验证 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f}")

# 保存模型
if valid_acc > best_acc:
print(f"在第 {epoch} 轮找到最佳模型,正在保存模型")
torch.save(model.state_dict(), f"{_exp_name}_best.ckpt") # 只保存最佳模型以防止输出内存超出错误
best_acc = valid_acc
stale = 0
else:
stale += 1
if stale > patience:
print(f"连续 {patience} 轮没有改进,提前停止")
break

进行预测

最后的代码构建一个测试数据集和数据加载器,以便高效地读取数据。实例化并加载预训练的分类器模型,并将其设置为评估模式。在不计算梯度的情况下,遍历测试数据,使用模型进行预测,并将预测标签存储在列表中。将预测结果与测试集的ID生成一个DataFrame,并将其保存为submission.csv文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 构建测试数据集
# "loader"参数指定了torchvision如何读取数据
test_set = FoodDataset("./hw3_data/test", tfm=test_tfm)
# 创建测试数据加载器,批量大小为batch_size,不打乱数据顺序,不使用多线程,启用pin_memory以提高数据加载效率
test_loader = DataLoader(test_set, batch_size=batch_size, shuffle=False, num_workers=0, pin_memory=True)

# 实例化分类器模型,并将其转移到指定的设备上
model_best = Classifier().to(device)

# 加载模型的最优状态字典
model_best.load_state_dict(torch.load(f"{_exp_name}_best.ckpt"))

# 将模型设置为评估模式
model_best.eval()

# 初始化一个空列表,用于存储所有预测标签
prediction = []

# 使用torch.no_grad()上下文管理器,禁用梯度计算
with torch.no_grad():
# 遍历测试数据加载器
for data, _ in tqdm(test_loader):
# 将数据转移到指定设备上,并获得模型的预测结果
test_pred = model_best(data.to(device))
# 选择具有最高分数的类别作为预测标签
test_label = np.argmax(test_pred.cpu().data.numpy(), axis=1)
# 将预测标签添加到结果列表中
prediction += test_label.squeeze().tolist()

# 创建测试csv文件
def pad4(i):
"""
将输入数字i转换为长度为4的字符串,如果长度不足4,则在前面补0。
:param i: 需要转换的数字
:return: 补0后的字符串
"""
return "0" * (4 - len(str(i))) + str(i)

# 创建一个空的DataFrame对象
df = pd.DataFrame()
# 使用列表推导式生成Id列,列表长度等于测试集的长度
df["Id"] = [pad4(i) for i in range(len(test_set))]
# 将预测结果赋值给Category列
df["Category"] = prediction
# 将DataFrame对象保存为submission.csv文件,不保存索引
df.to_csv("submission.csv", index=False)