【作者主页】Francek Chen
【专栏介绍】 ⌈ ⌈ ⌈Python机器学习 ⌋ ⌋ ⌋ 机器学习是一门人工智能的分支学科,通过算法和模型让计算机从数据中学习,进行模型训练和优化,做出预测、分类和决策支持。Python成为机器学习的首选语言,依赖于强大的开源库如Scikit-learn、TensorFlow和PyTorch。本专栏介绍机器学习的相关算法以及基于Python的算法实现。
【GitCode】专栏资源保存在我的GitCode仓库:https://gitcode.com/Morse_Chen/Python_machine_learning。
文章目录
- 一、卷积
- 二、神经网络中的卷积
- 三、用卷积神经网络完成图像分类任务
- 四、用预训练的卷积神经网络完成色彩风格迁移
- (一)VGG网络
- (二)内容与风格表示
- 五、拓展:数据增强
本文继续讲解基于神经网络的模型。在MLP中,层与层的神经元之间两两连接,模拟了线性变换 W x + b \boldsymbol W\boldsymbol x+\boldsymbol b Wx+b。在此基础上,我们可以通过调整神经元间的连接结构,进一步扩展对输入变换的方式,而不局限于线性变换,从而解决不同类型的任务。在k近邻算法一文中,我们已经完成了色彩风格迁移这样一个简单的图像任务。在过程中,我们依照KNN的思想,提取了图像的局部特征进行匹配。对于MLP来说,如果我们要完成类似的图像处理任务,将图像作为输入时,如果仅仅对其进行线性变换,就相当于把二维的图像拉成一维的长条,忽视了其另一个维度上的特征。虽然如果MLP的层数足够多,理论上我们仍然可以用其提取出二维特征,但这无疑会大大增加模型的复杂度和训练难度。因此,我们需要更适合提取高维特征的运算和网络结构。卷积神经网络(convolutional neural network,CNN)就是基于这一思路而设计的。本文将会讲解卷积运算的特点和作用,以及如何搭建CNN来完成基于图像的任务。
一、卷积
卷积(convolution)是一种数学运算。对于两个函数 f ( t ) f(t) f(t)和 g ( t ) g(t) g(t),其卷积的结果 ( f ∗ g ) ( t ) (f*g)(t) (f∗g)(t)也是一个关于 t t t的函数,定义为 ( f ∗ g ) ( t ) = ∫ − ∞ + ∞ f ( τ ) g ( t − τ ) d τ (f*g)(t) = \int_{-\infty}^{+\infty}f(\tau)g(t-\tau)\mathrm{d}\tau (f∗g)(t)=∫−∞+∞f(τ)g(t−τ)dτ
如果函数是离散的,假设其定义在整数集合 Z \mathbb Z Z上,用 f [ n ] f[n] f[n]表示 f f f在整数 n n n处的取值,那么 f [ n ] f[n] f[n]和 g [ n ] g[n] g[n]的离散卷积为 ( f ∗ g ) [ n ] = ∑ m = − ∞ + ∞ f [ m ] g [ n − m ] (f*g)[n] = \sum_{m=-\infty}^{+\infty}f[m]g[n-m] (f∗g)[n]=m=−∞∑+∞f[m]g[n−m]
这两个式子理解起来可能有些困难,毕竟涉及函数相乘的积分或求和,因此我们尽量从其应用场景来理解卷积的含义。卷积运算在信号处理中有广泛的应用,常常用于计算输入信号经过某个系统处理后得到的输出信号。假设输入信号是 g ( τ ) g(\tau) g(τ),系统对输入的响应是 f ( τ ) f(\tau) f(τ),如图1所示。
如果将 t t t理解为时间,那么当信号 g ( t ) g(t) g(t)输入给系统时,应当是 t t t较小的一方先被收到,因此在图像上应当将 g ( τ ) g(\tau) g(τ)水平翻转过来,变为 g ( t − τ ) g(t-\tau) g(t−τ),如图2所示。
接下来,随着 t t t不断增加, g ( t − τ ) g(t-\tau) g(t−τ) 的图像向右移动,直到开始与系统 f ( τ ) f(\tau) f(τ)的图像出现重合,代表系统收到了信号。这时,系统 f f f对信号 g g g的响应为两者重叠部分相乘后图像的面积。如图3所示,灰色曲线为 f ( τ ) g ( t − τ ) f(\tau)g(t-\tau) f(τ)g(t−τ),其下方的浅灰色区域的面积就是该时刻卷积的值。
我们知道,函数曲线下方的面积可以用积分来计算,该面积为 ( f ∗ g ) ( t ) = ∫ − ∞ + ∞ f ( τ ) g ( t − τ ) d τ (f*g)(t) = \int_{-\infty}^{+\infty}f(\tau)g(t-\tau)\mathrm{d}\tau (f∗g)(t)=∫−∞+∞f(τ)g(t−τ)dτ
上式即为卷积的定义式。如果信号是离散的点,只需要将积分改为求和即可。从这个过程中我们可以看出,卷积的含义是系统对输入信号产生的响应。如果将式中的 f ( τ ) f(\tau) f(τ)看作权重的话,卷积就是在求 t t t时刻 g ( t − τ ) g(t-\tau) g(t−τ) 按 f ( τ ) f(\tau) f(τ)加权的平均值。因此我们可以认为,卷积的本质是计算某种特殊的加权平均。
二、神经网络中的卷积
在前面的文章中,我们面对图像输入的任务时,常常会想到从图像中提取对任务有帮助的特征,例如图像中不同物体的边界、物体间的相对位置等。如果将图像看作由像素点组成的二维离散信号的话,自然可以借用信号处理中卷积的滤波特性,对图像也做卷积,从而把其中我们想要的特征过滤出来。事实上,用卷积进行图像处理的技术在神经网络之前就已经出现了,而神经网络将其威力进一步增强。
为了在图像上应用卷积,我们先把一维的卷积扩展到二维。由于图像都是离散的,每一个像素有具体的索引,这里我们只给出二维离散卷积的公式: ( f ∗ g ) [ m , n ] = ∑ k = − ∞ + ∞ ∑ l = − ∞ + ∞ f [ k , l ] g [ m − k , n − l ] (f*g)[m,n] = \sum_{k=-\infty}^{+\infty}\sum_{l=-\infty}^{+\infty}f[k,l]g[m-k,n-l] (f∗g)[m,n]=k=−∞∑+∞l=−∞∑+∞f[k,l]g[m−k,n−l]
仔细观察可以发现,该公式还是在计算以 f [ m , n ] f[m,n] f[m,n]为权重的 g [ m − k , n − l ] g[m-k,n-l] g[m−k,n−l] 在空间上的加权平均。如果将 g g g看作是图像,不同位置的权重就代表了图像上不同像素的重要程度。通过适当的权重 f f f,我们就可以将组成某一特征的像素全部提取出来。在神经网络中,我们可以将设置为可以训练的参数,通过梯度反向传播的方式进行训练,自动调整其权重值。与MLP中的线性变换不同,主要由卷积运算构成的神经网络就称为卷积神经网络(CNN),在CNN中进行卷积运算的层称为卷积层,层中的权重 f f f称为卷积核(convolutional kernel)。
CNN中进行的卷积与实际的卷积有些差异。注意到卷积运算关于 f f f和 g g g是对称的,还可以写成 ( f ∗ g ) [ m , n ] = ∑ k = − ∞ + ∞ ∑ l = − ∞ + ∞ f [ m − k , n − l ] g [ k , l ] (f*g)[m,n] = \sum_{k=-\infty}^{+\infty}\sum_{l=-\infty}^{+\infty}f[m-k,n-l]g[k,l] (f∗g)[m,n]=k=−∞∑+∞l=−∞∑+∞f[m−k,n−l]g[k,l]
对于神经网络来说,卷积核 f f f的参数由梯度下降算法自动调整。如果将 f f f在计算时先翻转过来,把 m − k m-k m−k 改成 k − m k-m k−m,那么最后得到的参数也仅仅在位置上是翻转的,对具体的参数值没有影响。因此,在CNN中,我们通常省略了翻转操作,而是直接计算 ( f ∗ g ) [ m , n ] = ∑ k = − ∞ + ∞ ∑ l = − ∞ + ∞ f [ k − m , l − n ] g [ k , l ] (f*g)[m,n] = \sum_{k=-\infty}^{+\infty}\sum_{l=-\infty}^{+\infty}f[k-m,l-n]g[k,l] (f∗g)[m,n]=k=−∞∑+∞l=−∞∑+∞f[k−m,l−n]g[k,l] 该运算称为互相关(cross-correlation),但是在机器学习中,习惯上我们依然称其为卷积。对于图像这样的空间信号来说,我们并不需要像时间信号那样,只有到了特定的时刻才能知道输入信号值。相反,我们可以从图像的任意位置开始处理图像。因此,上式中将求和的下标分别向后推移 m m m和 n n n个单位不会对现实中的计算产生影响,所以上式还等于 ( f ∗ g ) [ m , n ] = ∑ k = − ∞ + ∞ ∑ l = − ∞ + ∞ f [ k , l ] g [ k + m , l + n ] (f*g)[m,n] = \sum_{k=-\infty}^{+\infty}\sum_{l=-\infty}^{+\infty}f[k,l]g[k+m,l+n] (f∗g)[m,n]=k=−∞∑+∞l=−∞∑+∞f[k,l]g[k+m,l+n]
至此,我们把卷积的公式做了一些不影响实际结果的变形。可以看出,现在在计算 ( f ∗ g ) [ m , n ] (f*g)[m,n] (f∗g)[m,n] 时,卷积核 f f f从 g [ 0 , 0 ] g[0,0] g[0,0]开始滑动,并在每一个位置与 g g g相乘再求和。对于 g g g的下标 m , n m,n m,n超出图像尺寸的情况,我们直接假设 g g g在该处的值为零。因此,上面的求和在实际计算时无须真的遍历 − ∞ -\infty −∞到 + ∞ +\infty +∞范围。
图4给出了一个
2
×
2
2\times 2
2×2 的卷积核作用到3像素×3像素的图像上得到的结果。我们假设横向为
x
x
x轴,纵向为
y
y
y轴,正方形分别为向右和向下。图像左上角的坐标是
(
0
,
0
)
(0,0)
(0,0),右下角是
(
2
,
2
)
(2,2)
(2,2);卷积核左上角是
(
0
,
0
)
(0,0)
(0,0),右下角是
(
1
,
1
)
(1,1)
(1,1)。那么按照上面给出的计算公式,整个计算过程为
(
f
∗
g
)
[
0
,
0
]
=
∑
k
=
0
1
∑
l
=
0
1
f
[
k
,
l
]
g
[
k
,
l
]
=
3
×
0
+
1
×
4
+
7
×
1
+
2
×
3
=
17
(
f
∗
g
)
[
0
,
1
]
=
∑
k
=
0
1
∑
l
=
0
1
f
[
k
,
l
]
g
[
k
,
l
+
1
]
=
1
×
0
+
5
×
4
+
2
×
1
+
3
×
3
=
31
(
f
∗
g
)
[
1
,
0
]
=
∑
k
=
0
1
∑
l
=
0
1
f
[
k
,
l
]
g
[
k
+
1
,
l
]
=
7
×
0
+
2
×
4
+
0
×
1
+
4
×
3
=
20
(
f
∗
g
)
[
1
,
1
]
=
∑
k
=
0
1
∑
l
=
0
1
f
[
k
,
l
]
g
[
k
+
1
,
l
+
1
]
=
2
×
0
+
3
×
4
+
4
×
1
+
0
×
3
=
16
\begin{aligned} (f*g)[0,0] &= \sum_{k=0}^1\sum_{l=0}^1f[k,l]g[k,l] &=3 \times 0 + 1 \times 4 + 7 \times 1 + 2 \times 3 =17 \\ (f*g)[0,1] &= \sum_{k=0}^1\sum_{l=0}^1f[k,l]g[k,l+1] &=1 \times 0 + 5 \times 4 + 2 \times 1 + 3 \times 3 =31 \\ (f*g)[1,0] &= \sum_{k=0}^1\sum_{l=0}^1f[k,l]g[k+1,l] &=7 \times 0 + 2 \times 4 + 0 \times 1 + 4 \times 3 =20 \\ (f*g)[1,1] &= \sum_{k=0}^1\sum_{l=0}^1f[k,l]g[k+1,l+1] &=2 \times 0 + 3 \times 4 + 4 \times 1 + 0 \times 3 =16 \end{aligned}
(f∗g)[0,0](f∗g)[0,1](f∗g)[1,0](f∗g)[1,1]=k=0∑1l=0∑1f[k,l]g[k,l]=k=0∑1l=0∑1f[k,l]g[k,l+1]=k=0∑1l=0∑1f[k,l]g[k+1,l]=k=0∑1l=0∑1f[k,l]g[k+1,l+1]=3×0+1×4+7×1+2×3=17=1×0+5×4+2×1+3×3=31=7×0+2×4+0×1+4×3=20=2×0+3×4+4×1+0×3=16
在上面的计算过程中,由于图像边界的存在,卷积得到的结果会比原来的图像更小。设原图宽为 W W W像素,高为 H H H像素,卷积核的宽和高分别为 W k W_k Wk和 H k H_k Hk,易知输出图像的大小为 W o u t = W − W k + 1 , H o u t = H − H k + 1 W_{out}=W-W_k+1, \quad H_{out}=H-H_k+1 Wout=W−Wk+1,Hout=H−Hk+1
有时候,我们希望输出图像能保持和输入图像同样的大小,因此会对输入图像的周围进行填充(padding),以此抵消卷积对图像尺寸的影响。填充操作会在输入图像四周补上数层额外的像素,假设为了保持图像尺寸不变,需要填充的总层数为 W p a d = W k − 1 , H p a d = H k − 1 W_{pad}=W_k-1,\quad H_{pad}=H_k-1 Wpad=Wk−1,Hpad=Hk−1
填充常用的方式有全零填充、常数填充、边界扩展填充等等。其中,全零填充和常数填充即用0或某指定的常数进行填充,边界扩展填充则是将边界处的值向外复制。通常来说,如果 W p a d W_{pad} Wpad或 H p a d H_{pad} Hpad是偶数,那么在两侧各填充总层数的一半以保持对称;当然也可以根据需要,自行调整不同方向填充的具体层数。以上面展示的卷积运算为例,如果我们希望输出大小与输入相同,还是3像素×3像素,就需要对输入的宽和高在两侧分别填充一层。如果我们在左方和上方进行全零填充,得到的结果如图5所示。其中输入图像左边和上边灰色的部分是填充的0,得到了4像素×4像素的图像,与 2 × 2 2×2 2×2 的卷积核进行卷积后,输出的矩阵与原图像大小相同。我们用浅绿色标出了输出中有填充0参与运算的部分,其余部分和原本的输出相同。通过适当的填充操作,我们可以自由调整输出的图像大小。
卷积运算可以对一定范围内的图像进行特征提取,其提取范围就是卷积核的大小 W k W_k Wk和 H k H_k Hk,因此,卷积核的大小又称为卷积核的感受野(receptive field)。这一概念同样来源于神经科学,本义是指一个感觉神经元所支配的感受器在视网膜、皮肤等位置能感受到外界刺激的范围。较小的卷积核可以提取局部特征,但是难以发现全局之间物体的位置关系;而较大的卷积核可以发现整体的结构特征,但对细节的捕捉能力较差。例如,假如我们需要识别图像中的人脸,小卷积核可以提取出五官特征,而大卷积核可以提取出脸的整体轮廓。因此,在一个CNN中,我们常常会使用多种大小的卷积核,以有效提取不同尺度上的特征信息,并且会使用多个相同大小的卷积核,以学出同一尺度下不同的局部信息提取模式。
除此之外,考虑到图像中通常会有大量相邻的相似像素,在这些区域上进行卷积得到的结果基本是相近的,从而包含大量冗余信息;另一方面,小卷积核对局部的信息可能过于敏感。因此,在卷积之外,我们通常还会对图像进行池化(pooling)操作。池化是一种降采样(downsampling)操作,即用一个代表像素来替代一片区域内的所有像素,从而降低整体的复杂度。常用的池化有平均池化和最大池化两种。顾名思义,平均池化(average pooling)是用区域内所有像素的平均值来代表区域,最大池化(max pooling)是用所有像素的最大值来代表区域。图6展示了对4像素×4像素的图像做最大池化的过程。池化与卷积操作类似,都可以看作将一个固定大小的窗口在图像上滑动。但是通常来说,池化运算的窗口在滑动时不会重复,因此得到的输出图像会比输入图像显著缩小。
三、用卷积神经网络完成图像分类任务
下面,我们讲解如何用PyTorch实现一个卷积神经网络,并用它完成图像分类任务。该任务要求模型能识别输入图像中的主要物体的类别。本次我们采用的CIFAR-10
数据集包含了10个种类的图像,类别与标签如下表所示。
图像标签 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|---|
图像内容 | 飞机 | 汽车 | 鸟 | 猫 | 鹿 | 狗 | 青蛙 | 马 | 船 | 卡车 |
数据集中所有图像大小均为32像素×32像素,色彩为RGB共3个通道。训练集大小为50000,测试集大小为10000,其中每个类别都有6000张图像。我们首先导入数据集,并从每一类中抽取一些图像展示出来,以对数据集有更清晰的了解。
import os
import numpy as np
import matplotlib.pyplot as plt
from tqdm import tqdm # 进度条工具
import torch
import torch.nn as nn
import torch.nn.functional as F
# transforms提供了数据处理工具
import torchvision.transforms as transforms
# 由于数据集较大,我们通过工具在线下载数据集
from torchvision.datasets import CIFAR10
from torch.utils.data import DataLoader
# 下载训练集和测试集
data_path = './cifar10'
trainset = CIFAR10(root=data_path, train=True, download=True, transform=transforms.ToTensor())
testset = CIFAR10(root=data_path, train=False, download=True, transform=transforms.ToTensor())
print('训练集大小:', len(trainset))
print('测试集大小:', len(testset))
# trainset和testset可以直接用下标访问
# 每个样本为一个元组 (data, label)
# data是3*32*32的Tensor,表示图像
# label是0-9之间的整数,代表图像的类别
# 可视化数据集
num_classes = 10
fig, axes = plt.subplots(num_classes, 10, figsize=(15, 15))
labels = np.array([t[1] for t in trainset]) # 取出所有样本的标签
for i in range(num_classes):
indice = np.where(labels == i)[0] # 类别为i的图像的下标
for j in range(10): # 展示前10张图像
# matplotlib绘制RGB图像时
# 图像矩阵依次是宽、高、颜色,与数据集中有差别
# 因此需要用permute重排数据的坐标轴
axes[i][j].imshow(trainset[indice[j]][0].permute(1, 2, 0).numpy())
# 去除坐标刻度
axes[i][j].set_xticks([])
axes[i][j].set_yticks([])
plt.show()
根据上面的讲解,一个基础的CNN主要由卷积核池化两部分构成,如图7所示。我们依次用卷积核池化提取图像中不同尺度的特征,最终将最后的类别特征经过全连接层,输出一个10维向量,其每一维分别代表图像属于对应类别的概率。其中,全连接层与我们在MLP中讲过的相同,是前后两层神经元全部互相连接的层,相当于线性变换 W x + b \boldsymbol W\boldsymbol x+\boldsymbol b Wx+b,因此又称为线性层。本图中展示的是1998年由杨立昆(Yann Lecun)和约书亚·本吉奥(Yoshua Bengio)提出的LeNet-5,这是最早的可以实用的CNN之一,最初被应用在手写数字识别任务上,是CNN领域的奠基性工作。
在多分类任务中,为了使输出的所有分类概率总和为1,我们常常在输出层使用softmax激活函数。通过softmax得到每一类的概率后,我们再应用最大似然估计的思想。将MLE与softmax结合,得到多分类的交叉熵损失: L C E ( θ ) = − 1 d ∑ i = 1 d y i log e y ^ i ∑ j = 1 d e y ^ j \mathcal L_{\mathrm{CE}}(\theta) = -\frac{1}{d}\sum_{i=1}^dy_i\log\frac{\mathrm e^{\hat y_i}}{\sum_{j=1}^d \mathrm e^{\hat y_j}} LCE(θ)=−d1i=1∑dyilog∑j=1dey^jey^i 其中, y ^ = f θ ( X ) \hat{\boldsymbol y} = f_\theta(\boldsymbol X) y^=fθ(X) 是模型的原始输出, y \boldsymbol y y是用独热向量表示的输入 X \boldsymbol X X的真实类别。假设 X \boldsymbol X X的类别是 c c c,那么向量 y \boldsymbol y y只有第 c c c维为1,其他维全部为0。这一形式我们曾在逻辑斯谛回归中推导过。
在CNN的发展历程中诞生了许多非常有效的网络结构。其中,2012年由亚历克斯·克里泽夫斯基(Alex Krizhevsky)提出的AlexNet可以说是CNN中划时代的设计。考虑到算力限制,我们这里使用其简化版本,其结构如图8所示。该网络共有8层,其中前6层由两组卷积、卷积、池化的网络构成,最后两层为全连接层。因为单个卷积核只能提取图像中的某一个特征,而即使是同一尺度下,图像也可能包含多个有用的特征。为了同时提取它们,我们可以在同一层中使用多个不同的卷积核分别进行卷积运算。这样,每个卷积核之间相互独立,可以各自发现不同的特征。由于不同的卷积核表示的是图像上同一个位置的不同特征,与图像的色彩通道含义类似,我们将卷积核的数量也称为通道(channel)。对于多个通道的输入,我们可以同样使用多通道卷积核来进行计算,得到多通道的输出。下图标注的每一层的矩阵大小中,@符号之前的数字就表示矩阵的通道。
可以看出,初始时图像有RGB共3种色彩,其通道为3。接下来在卷积和池化的过程中,每次我们都用逐渐变小的卷积核提取图像不同尺度的特征,这也是AlexNet的重要特点之一。同时,图像的大小逐渐变小,但通道逐渐变多,每一个通道都代表一个不同的特征。到全连接层之前,我们已经提取出了64种特征,最后再由全连接层对特征进行分类。与MLP类似,在适当的位置我们会插入非线性激活函数。在神经网络与多层感知机中我们已经讲过,非线性变换可以增加数据维度,提升模型的表达能力。
除此之外,我们还需要加入丢弃层(dropout)。如图9所示,丢弃层会在每次前向传播时,随机把输入中一定比例的神经元遮盖住,使它们对后面的输出不再产生影响,也就不产生梯度回传。对于模型来说,相当于这些神经元暂时“不存在”,从而降低了模型的复杂度,可以缓解模型的过拟合问题。临时遮盖的思想在CNN这样由多层网络组成的复杂模型中尤为重要。
接下来,我们利用PyTorch中的工具,来具体实现图8中展示的网络结构。
class CNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
# 类别数目
self.num_classes = num_classes
# Conv2D为二维卷积层,参数依次为
# in_channels:输入通道
# out_channels:输出通道,即卷积核个数
# kernel_size:卷积核大小,默认为正方形
# padding:填充层数,padding=1表示对输入四周各填充一层,默认填充0
self.conv1 = nn.Conv2d(in_channels=3, out_channels=32, kernel_size=3, padding=1)
# 第二层卷积,输入通道与上一层的输出通道保持一致
self.conv2 = nn.Conv2d(32, 32, 3, padding=1)
# 最大池化,kernel_size表示窗口大小,默认为正方形
self.pooling1 = nn.MaxPool2d(kernel_size=2)
# 丢弃层,p表示每个位置被置为0的概率
# 随机丢弃只在训练时开启,在测试时应当关闭
self.dropout1 = nn.Dropout(p=0.25)
self.conv3 = nn.Conv2d(32, 64, 3, padding=1)
self.conv4 = nn.Conv2d(64, 64, 3, padding=1)
self.pooling2 = nn.MaxPool2d(2)
self.dropout2 = nn.Dropout(0.25)
# 全连接层,输入维度4096=64*8*8,与上一层的输出一致
self.fc1 = nn.Linear(4096, 512)
self.dropout3 = nn.Dropout(0.5)
self.fc2 = nn.Linear(512, num_classes)
# 前向传播,将输入按顺序依次通过设置好的层
def forward(self, x):
x = F.relu(self.conv1(x))
x = F.relu(self.conv2(x))
x = self.pooling1(x)
x = self.dropout1(x)
x = F.relu(self.conv3(x))
x = F.relu(self.conv4(x))
x = self.pooling2(x)
x = self.dropout2(x)
# 全连接层之前,将x的形状转为 (batch_size, n)
x = x.view(len(x), -1)
x = F.relu(self.fc1(x))
x = self.dropout3(x)
x = self.fc2(x)
return x
最后,我们设置超参数,利用梯度下降法进行训练。小批量的生成直接使用PyTorch中的DataLoader
工具实现。另外,由于本次网络结构比较复杂,较难优化,我们使用SGD优化器的一个改进版本Adam优化器。该优化器在大多数情况下更加稳定,收敛性能更好,但其原理较为复杂,超出了讨论范围,感兴趣的可以自行查阅其论文和其他相关资料。
batch_size = 64 # 批量大小
learning_rate = 1e-3 # 学习率
epochs = 5 # 训练轮数
np.random.seed(0)
torch.manual_seed(0)
# 批量生成器
trainloader = DataLoader(trainset, batch_size=batch_size, shuffle=True)
testloader = DataLoader(testset, batch_size=batch_size, shuffle=False)
model = CNN()
# 使用Adam优化器
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
# 使用交叉熵损失
criterion = F.cross_entropy
# 开始训练
for epoch in range(epochs):
losses = 0
accs = 0
num = 0
model.train() # 将模型设置为训练模式,开启dropout
with tqdm(trainloader) as pbar:
for data in pbar:
images, labels = data
outputs = model(images) # 获取输出
loss = criterion(outputs, labels) # 计算损失
# 优化
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 累积损失
num += len(labels)
losses += loss.detach().numpy() * len(labels)
# 精确度
accs += (torch.argmax(outputs, dim=-1) == labels).sum().detach().numpy()
pbar.set_postfix({
'Epoch': epoch,
'Train loss': f'{losses / num:.3f}',
'Train acc': f'{accs / num:.3f}'
})
# 计算模型在测试集上的表现
losses = 0
accs = 0
num = 0
model.eval() # 将模型设置为评估模式,关闭dropout
with tqdm(testloader) as pbar:
for data in pbar:
images, labels = data
outputs = model(images)
loss = criterion(outputs, labels)
num += len(labels)
losses += loss.detach().numpy() * len(labels)
accs += (torch.argmax(outputs, dim=-1) == labels).sum().detach().numpy()
pbar.set_postfix({
'Epoch': epoch,
'Test loss': f'{losses / num:.3f}',
'Test acc': f'{accs / num:.3f}'
})
小故事
卷积神经网络来源于神经科学中对生物的视觉系统的研究。1959年,神经科学家大卫·休伯尔(David Hubel)和托斯坦·威泽尔(Torsten Wisesel)提出了感受野的概念,并在1962年通过猫身上的实验确定了感受野等功能的存在。1979年,福岛邦彦(Kunihiko Fukushima)受到其工作的启发,通过神经网络模拟了生物视觉中的感受野,并用不同的层分别提取特征和进行抽象,这就是如今的CNN的雏形。CNN第一次大规模应用是在1998年,杨立昆和本吉奥等人设计的LeNet-5在识别手写数字上取得了巨大成功,并被广泛应用于美国的邮政系统和银行,用来自动识别邮件和支票上的数字。但是,受到当时的硬件条件和技术手段制约,深度神经网络整体在解决复杂问题上遇到困难,CNN研究也沉寂许久。2012年,克里泽夫斯基设计的AlexNet在ImageNet图像分类比赛中以大幅度优势获得第一,确立了CNN在计算机视觉领域的统治地位。2015年,CNN在该比赛中的错误率已经低于了人类水平。
CNN并非只能应用在图像任务上,只要是具有空间关联的输入,都可以通过CNN来提取特征。2016年,由DeepMind公司研制的AlphaGo击败了人类围棋世界冠军李世石九段,让人工智能彻底出圈,成为了家喻户晓的名词。AlphaGo及其后来的改进版本AlphaGo Zero、AlphaZero等模型,都是以CNN为基础进行棋盘特征提取的。此外,CNN还在语音、文本、时间序列等具有一维空间关联的任务上有出色表现。
四、用预训练的卷积神经网络完成色彩风格迁移
从CNN的原理上来说,当我们训练完成一个CNN后,其中间的卷积层可以提取出图像中不同类型的特征。进一步分析CNN的结构可以发现,网络前几层的大量卷积和池化层负责将图像特征提取出来,而最后的全连接层和MLP一样,接受提取的特征作为输入,再根据具体的任务目标给出相应的输出。这一发现提醒我们,CNN中的特征提取结构的参数很可能并不依赖于具体任务。在一个任务上训练完成的卷积核,完全可以直接迁移到新的任务上去。在本节中我们就按照这一思路,从预训练好的网络出发,通过微调完成图像的色彩风格迁移任务。值得注意的是,与传统的“对参数求导”的方式不同,本节涉及“对数据求导”的方式来完成图像色彩风格的迁移,这是机器学习中一个重要的思维方式。
(一)VGG网络
本节采用的预训练网络是另一个广泛应用的CNN结构:VGG网络。VGG网络给出了一种CNN的结构设计范式,即通过反复堆叠基础的模块来构建网络,而无须像AlexNet一样为每一层都调整卷积核和池化的大小。图10展示了VGG16网络的结构,其中16表示网络中卷积层和全连接层的数目。网络中的基本模块称为VGG块,由数个大小为3×3、边缘填充为1的卷积层和一个窗口大小为2×2的最大池化层组成。在一个VGG块中,卷积层由于引入了边缘填充,卷积前后图像矩阵的大小不变,而池化层会使图像矩阵的长和宽变为原来的一半。同时,每个VGG块的输出通道都是输入通道的两倍。这样,随着输入尺寸的减小,网络提取出的特征不断增多。最后,网络再将所有特征输入3层全连接层,得到最后的结果。VGG16的第一个VGG块输出通道数目为64,之后每次翻倍,直到512为止,共包含5个VGG块。我们也可以堆叠更多的VGG块来得到更庞大的网络,但是相应的模型复杂度和训练时间也会增加,同时也容易出现过拟合等现象。对于本节的简单任务来说,VGG16网络已经足够。
(二)内容与风格表示
与KNN一文中自行提取特征的方法不同,现在我们已经可以直接利用训练好的VGG网络提取图像在不同尺度下的各个特征。因此,我们应当考虑的是如何利用这些特征提取结构来完成风格迁移。利用梯度下降的思想,我们可以从一张空白图像或者随机图像出发,通过VGG网络提取其内容和风格特征,与目标的内容图像或者风格图像的相应特征进行比较来计算损失,再通过梯度的反向传播更新输入图像的内容。这样,我们就可以得到内容与内容图像相近、且风格与风格图像相近的结果。
那么,我们该如何使用训练好的VGG网络来提取特征呢?对于CNN来说,从最初的卷积层开始,随着层数加深,网络提取出的特征会越来越侧重于图像整体的风格,以及有关图像中物体相对位置的信息。这是因为图像经过了池化,不同位置的像素被合并在了一起,其精细的信息已经损失了,留下的大多是整体性的信息。图11中的内容表示部分是利用VGG16中的5个不同的VGG块对内容图像的一个局部进行处理的结果。可以看出,浅层的卷积核基本只输出原图的像素信息,而深层的卷积核可以输出图像整体的一些抽象结构特征。因此,我们使用VGG16中第5个VGG块的第3个卷积层提取图像的内容。
设提取内容的模型为 f c f_c fc,当前图像的矩阵为 X \boldsymbol X X,内容图像的矩阵为 C \boldsymbol C C,我们直接用MSE作为内容上的损失: L c ( X ) = 1 2 ∥ f c ( X ) − f c ( C ) ∥ F 2 \mathcal L_c(\boldsymbol X) = \frac{1} {2}\lVert f_c(\boldsymbol X)-f_c(\boldsymbol C)\lVert_F^2 Lc(X)=21∥fc(X)−fc(C)∥F2
相比于内容,图像的风格更难描述,也更不容易直接得到。直观上来说,一张图像的风格指的是图像的色彩、纹理等要素,这些要素在每个尺度上都有体现。因此,我们考虑同一个卷积层中由不同的卷积核提取出的特征。由于这些特征属于同一层,因此基本属于原图中的相同尺度。那么,它们之间的相关性可以一定程度上反映图像在该尺度上的风格特点。设某一层中卷积核的数量为 N N N,每个卷积核与输入运算得到的输出大小为 M M M,其中 M M M表示输出矩阵中元素的总数量。那么,该卷积层输出的总特征矩阵为 F ∈ R N × M \boldsymbol F\in \mathbb R^{N\times M} F∈RN×M,每个行向量都表示一个卷积核输出的特征。为了表示不同特征之间的关系,我们使用与因子分解机类似的思想,将特征之间做内积,得到 G = F F T \boldsymbol G=\boldsymbol F\boldsymbol F^{\mathrm T} G=FFT
该矩阵称为格拉姆矩阵(Gram matrix)。矩阵 G ∈ R N × N \boldsymbol G\in \mathbb R^{N\times N} G∈RN×N 在计算乘积的过程中,把与特征在图像中的位置有关的维度消掉了,只留下了和卷积核有关的维度。直观上来说,这体现了图像的风格特征与其相对位置无关,是图像的全局属性。设由第 i i i个卷积层提取的当前图像的风格矩阵为 G X ( i ) \boldsymbol G_X^{(i)} GX(i),风格图像的风格矩阵为 G S ( i ) \boldsymbol G_S^{(i)} GS(i),我们同样用平方误差MSE作为损失函数: L s ( i ) ( X ) = 1 4 N ( i ) 2 M ( i ) 2 ∥ G X ( i ) − G S ( i ) ∥ F 2 \mathcal L_s^{(i)}(\boldsymbol X) = \frac{1}{4N^2_{(i)}M^2_{(i)}}\lVert \boldsymbol G_X^{(i)}-\boldsymbol G_S^{(i)}\lVert_F^2 Ls(i)(X)=4N(i)2M(i)21∥GX(i)−GS(i)∥F2 这里,由于不同卷积层的参数量可能不同,我们额外除以 4 N ( i ) 2 M ( i ) 2 4N^2_{(i)}M^2_{(i)} 4N(i)2M(i)2来进行层间的归一化。最后,再用权重 w i w_i wi对不同卷积层的损失做加权平均,得到总的风格损失为 L s ( X ) = ∑ i w i L s ( i ) ( X ) \mathcal L_s(\boldsymbol X) = \sum_iw_i\mathcal L_s^{(i)}(\boldsymbol X) Ls(X)=i∑wiLs(i)(X)
简单起见,后面我们直接令每一层的权重相等。具体到VGG16中,图11的风格表示部分从左至右分别是通过前1至前5个VGG块的第一个卷积层提取的风格信息重建出的图像。可以看出,使用了最多层重建的图像对图像的风格还原度最高,其原因与内容重建中所介绍的类似,不同深度的卷积层提取出的是不同尺度下的特征。小尺度上更偏向纹理,大尺度上更偏向色彩。只有将这些特征全部组合起来,才能得到更完整的图像风格。因此,我们采用全部5个VGG块的第一个卷积层作为风格提取模块。
最终,总的损失函数为 L ( X ) = L c ( X ) + λ L s ( X ) \mathcal L(\boldsymbol X) = \mathcal L_c(\boldsymbol X) + \lambda\mathcal L_s(\boldsymbol X) L(X)=Lc(X)+λLs(X) 其中, λ \lambda λ用于控制两种损失的相对大小。再次注意,本节的损失函数的输入不再是模型参数,而是数据本身,通过对数据求导从而调整数据(本节的数据调整具体而言是图像的色彩风格),进而最小化损失函数值。这样的方法自然也要求数据数据本身是连续可导的,包括图像、语音等,而离散的文本、类别数据则无法如此处理。总的训练流程可以用图12表示,其中VGG16网络在此过程中完全固定,梯度回传并不经过网络。
下面,我们来用PyTorch动手完成风格迁移任务。首先导入必要的库,并读取数据集。
# 该工具包中有AlexNet、VGG等多种训练好的CNN网络
from torchvision import models
import copy
# 定义图像处理方法
transform = transforms.Resize([512, 512]) # 规整图像形状
def loadimg(path):
# 加载路径为path的图像,形状为H*W*C
img = plt.imread(path)
# 处理图像,注意重排维度使通道维在最前
img = transform(torch.tensor(img).permute(2, 0, 1))
# 展示图像
plt.imshow(img.permute(1, 2, 0).numpy())
plt.show()
# 添加batch size维度
img = img.unsqueeze(0).to(dtype=torch.float32)
img /= 255 # 将其值从0-255的整数转换为0-1的浮点数
return img
content_image_path = os.path.join('style_transfer', 'content', '04.jpg')
style_image_path = os.path.join('style_transfer', 'style.jpg')
# 加载内容图像
print('内容图像')
content_img = loadimg(content_image_path)
# 加载风格图像
print('风格图像')
style_img = loadimg(style_image_path)
接下来,我们按照上面讲解的方式,定义内容损失和风格损失模块。由于我们后续要在VGG网络上做改动,这里把两种损失都按nn.Module
的方式定义。
# 内容损失
class ContentLoss(nn.Module):
def __init__(self, target):
# target为从目标图像中提取的内容特征
super().__init__()
# 我们不对target求梯度,因此将target从梯度的计算图中分离出来
self.target = target.detach()
self.criterion = nn.MSELoss()
def forward(self, x):
# 利用MSE计算输入图像与目标内容图像之间的损失
self.loss = self.criterion(x.clone(), self.target)
return x # 只计算损失,不改变输入
def backward(self):
# 由于本模块只包含损失计算,不改变输入,因此要单独定义反向传播
self.loss.backward(retain_graph=True)
return self.loss
def gram(x):
# 计算G矩阵
batch_size, n, w, h = x.shape # n为卷积核数目,w和h为输出的宽和高
f = x.view(batch_size * n, w * h) # 变换为二维
g = f @ f.T / (batch_size * n * w * h) # 除以参数数目,进行归一化
return g
# 风格损失
class StyleLoss(nn.Module):
def __init__(self, target):
# target为从目标图像中提取的风格特征
# weight为设置的强度系数lambda
super().__init__()
self.target_gram = gram(target.detach()) # 目标的Gram矩阵
self.criterion = nn.MSELoss()
def forward(self, x):
input_gram = gram(x.clone()) # 输入的Gram矩阵
self.loss = self.criterion(input_gram, self.target_gram)
return x
def backward(self):
self.loss.backward(retain_graph=True)
return self.loss
然后,我们下载已经训练好的VGG16网络,并按照其结构抽取我们需要的卷积层,舍弃最后与原始任务高度相关的全连接层,但将激活函数和池化层保持原样。我们将抽出的层依次加入到一个新创建的模型中,供后续提取特征使用。
vgg16 = models.vgg16(weights=True).features # 导入预训练的VGG16网络
# 选定用于提取特征的卷积层,Conv_13对应着第5块的第3卷积层
content_layer = ['Conv_13']
# 下面这些层分别对应第1至5块的第1卷积层
style_layer = ['Conv_1', 'Conv_3', 'Conv_5', 'Conv_8', 'Conv_11']
content_losses = [] # 内容损失
style_losses = [] # 风格损失
model = nn.Sequential() # 储存新模型的层
vgg16 = copy.deepcopy(vgg16)
index = 1 # 计数卷积层
# 遍历 VGG16 的网络结构,选取需要的层
for layer in list(vgg16):
if isinstance(layer, nn.Conv2d): # 如果是卷积层
name = "Conv_" + str(index)
model.append(layer)
if name in content_layer:
# 如果当前层用于抽取内容特征,则添加内容损失
target = model(content_img).clone() # 计算内容图像的特征
content_loss = ContentLoss(target) # 内容损失模块
model.append(content_loss)
content_losses.append(content_loss)
if name in style_layer:
# 如果当前层用于抽取风格特征,则添加风格损失
target = model(style_img).clone()
style_loss = StyleLoss(target) # 风格损失模块
model.append(style_loss)
style_losses.append(style_loss)
if isinstance(layer, nn.ReLU): # 如果激活函数层
model.append(layer)
index += 1
if isinstance(layer, nn.MaxPool2d): # 如果是池化层
model.append(layer)
# 输出模型结构
print(model)
现在我们已经以VGG16为基础定义了自己的新模型。应当始终注意,我们并不关心模型的输出,也不会对输出计算任何损失函数。我们所关心的部分只有模型中计算内容损失和风格损失的模块,所有的梯度也是从这些层中产生的。最后,我们设置超参数,并训练模型,查看生成图像的效果。
epochs = 50
learning_rate = 0.05
lbd = 1e6 # 强度系数
input_img = content_img.clone() # 从内容图像开始迁移
param = nn.Parameter(input_img.data) # 将图像内容设置为可训练的参数
optimizer = torch.optim.Adam([param], lr=learning_rate) # 使用Adam优化器
for i in range(epochs):
style_score = 0 # 本轮的风格损失
content_score = 0 # 本轮的内容损失
model(param) # 将输入通过模型,得到损失
for cl in content_losses:
content_score += cl.backward()
for sl in style_losses:
style_score += sl.backward()
style_score *= lbd
loss = content_score + style_score
# 更新输入图像
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 每次对输入图像进行更新后
# 图像中部分像素点会超出0-1的范围
# 因此要对其进行剪切
param.data.clamp_(0, 1)
if i % 10 == 0 or i == epochs - 1:
print(f'训练轮数:{i},\t风格损失:{style_score.item():.4f},\t内容损失:{content_score.item():.4f}')
plt.imshow(input_img[0].permute(1, 2, 0).numpy())
plt.show()
五、拓展:数据增强
在【机器学习的基本思想】模型优化与评估 中我们讲过,模型的复杂度应当和数据的复杂度相匹配,否则就很容易出现欠拟合或过拟合的情况。对于深度神经网络来说,其参数量非常庞大。然而,高质量的训练样本又非常稀缺,许多时候要依赖人工标注,费时费力,这使得神经网络的复杂度往往会超过数据的复杂度,从而发生过拟合的情况。因此,防止神经网络的过拟合是现代机器学习算法研究中的一个重要课题。我们已经知道,通过引入正则化、丢弃层等方式可以限制模型的复杂度。另一方面,我们也可以从数据的角度出发,通过适当地扩充数据集来增加数据的复杂度:引入许多原本在数据集中不存在、但是又同样合理的数据,提升模型的泛化性能,降低过拟合的程度,这就是数据增强(data augmentation)技术。
我们以图像任务为例进行简单介绍。在k近邻算法里,我们使用KNN完成了手写数字识别任务。当然,CNN可以做得更好。但是假设图像中的背景不再是黑色,而是每种数字都有一个独特的颜色,比如1是蓝色、2是红色、3是绿色等等,再让CNN在这样的数据集上训练,它会学到数据中的什么关联呢?接下来,如果把红色背景的1输入给模型进行测试,模型会把它识别成1还是2呢?实验表明,CNN更多地依赖图像的纹理、颜色来识别图像,而非图像的轮廓。因此,模型大概率会把红色背景的1识别为2。感兴趣的可以自行验证这一猜想。该现象表明,模型没有学习到数字轮廓和标签之间的关联,反而过拟合到了数字的颜色上。
为了解决类似的问题,我们常常会在训练时对图像进行一定的变换,从而生成一系列相似、但又不相同的图像,如图13所示:
- 旋转与翻转:把图像旋转一定的角度或进行水平、竖直翻转。
- 随机裁剪:保留图像核心内容的前提下,随机裁掉一定的边缘部分,再放大至原始大小。
- 缩放:将图像放大或缩小。放大后超出原始大小的部分裁减掉,缩小后不足的部分用常数或其他方式填充。
- 改变颜色:通过算法调整图像的色调、亮度、对比度等颜色信息。
- 添加噪声:在图像中随机添加噪点,例如高斯噪声。
需要注意,并非所有的图像数据增强方法都适用于所有任务。例如在本文用到的CIFAR-10数据集中,飞机的图像旋转90°还是飞机,汽车的图像水平翻转后还是汽车;但在MNIST数据集中,数字1旋转90°后就不再是数字1了,数字6旋转180°更是变成了数字9。因此,旋转与翻转方法在后者上就不适用。在实际应用中,我们需要根据任务的具体要求和图像特点,选择合适的数据增强方法。在第三节中,我们用简化的AlexNet在CIFAR-10上达到了70%左右的分类准确率。感兴趣的读者可以尝试用合适的方法进行数据增强,从而大幅提升模型表现,预计在测试集上有75%以上的准确率。
并非只有图像数据可以进行数据增强。在文本任务中,我们可以通过对词语做同义词替换、选择性遮盖句子的部分成分、将句子翻译成其他语言再翻译回来等方式,得到新的训练文本。事实上,数据增强在各种类型的机器学习任务中都有广泛应用,更有许多算法将数据增强的部分与模型合为一体,它已经成为了现代机器学习中提升数据利用率和提升模型泛化性能的关键技术。
附:以上文中的数据集及相关资源下载地址:
链接:https://pan.quark.cn/s/dc4d94a8692f
提取码:vxy6