这篇笔记主要是根据官方教程提供的Jupyter代码进行,我一边跑通代码一边进行了一些尝试和总结。
基本知识提要
Pytorch提供了自动计算梯度的工具,即backward方法,每个要更新的参数会包含一个autograd属性来记录梯度,参与计算的函数(表现为一个中间或结果变量)会具有grad_fn属性,用来记录它的函数表达式。Pytorch还提供了nn.Module模块,只需要定义该类的相关函数就可以很容易地搭出一个网络模型。
教程中提到了建立一个网络的常用步骤:
- 定义一个具有可学习参数(或权重)的神经网络
- 根据数据集输入进行迭代
- 由神经网络处理输入
- 计算损失函数值
- 反向传播给网络
- 更新网络权重
这些步骤在这篇博客中都会涉及,下面以教程中的简单CNN模型为例,一边提供源代码一边进行理解。
Define the network
首先,教程中提供的CNN网络结构如下图
![CNN结构](https://i-blog.csdnimg.cn/blog_migrate/ae25a701058afbf8038f7d9a6ee08c6f.png)
可以获取的信息有:
- 输入为一张单通道且尺寸为32x32的图片
- 第一层卷积层的卷积核大小为3x3,数量为6
- 第一层池化层的窗口为2x2
- 第二层卷积层的卷积核大小为3x3,数量为16
- 第二层池化层的窗口为2x2
- 第一层全连接层长度为120
- 第二层全连接层长度为84
- 输出层长度为10
可以定义如下的神经网络:
import torch
import torch.nn as nn
import torch.nn.functional as F
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()#调用父类构造函数
# 1 input image channel, 6 output channels, 3x3 square convolution
# kernel
self.conv1 = nn.Conv2d(1, 6, 3) #输入为1个channel,用6个3x3的卷积核
self.conv2 = nn.Conv2d(6, 16, 3) #输入为6个channel,用16个3x3的卷积核
# an affine operation: y = Wx + b
self.fc1 = nn.Linear(16 * 6 * 6, 120) # 6*6 from image dimension 全连接层
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, 10)
def forward(self, x):
# Max pooling over a (2, 2) window
x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))#Pooling层将卷积结果进行ReLU变换增加非线性拟合能力
# If the size is a square you can only specify a single number
x = F.max_pool2d(F.relu(self.conv2(x)), 2)
x = x.view(-1, self.num_flat_features(x))#view相当于resize,-1表示自动推断维数,拉成了一维
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
x = self.fc3(x)
print("called")
return x
def num_flat_features(self, x):
size = x.size()[1:] # all dimensions except the batch dimension
num_features = 1
for s in size:
num_features *= s
return num_features
net = Net()
print(net)
我们定义的网络类New继承了nn.Module,需要我们进行覆盖的函数主要是__init__和forward。
__init__函数
__init__函数中需要调用父类的构造函数,并定义需要训练的参数。在本例中,只有卷积层和全连接层的权重需要被训练。
仔细观察__init__中所调用的函数:nn.Conv2d(1, 6, 3),一看便知是定义了一个卷积层,根据理解,参数中的1表示输入层数为1,需要使用6个卷积核,且尺寸为3x3;nn.Linear(16 * 6 * 6, 120)则定义了全连接层,16*6*6表示最后一层池化层为16个通道,每个通道尺寸为6*6,而该全连接层的长度为120。
观察这两个函数的参数特点,我们可以知道,每次定义一个需要被训练的网络层时,需要提供输入单元的个数,以及用于描述该层特征的参数(诸如卷积核尺寸、全连接层cell个数等),当然还会有其他更丰富的参数,在这里我们暂时用不到。
forward函数
forward函数内主要定义不需要被训练的参数,在本例中主要是将卷积结果进行非线性化的ReLU函数以及池化层。
F.max_pool2d(F.relu(self.conv1(x)), (2, 2))函数为池化(下采样)操作,它的输入是第一层卷积层的输出通过ReLU函数变换后的结果,(2,2)则表示池化操作的窗口大小。当然,直接用2来代替(2,2)效果是一样的。
x.view(-1, self.num_flat_features(x))的操作是将最后一层池化层的输出进行resize,为之后全连接层的输入做准备。第一个参数-1表示根据第二个参数自动推断第一维的大小。
forward函数中调用了一个辅助函数num_flat_features(self, x),用来求出最后一层池化层的单元个数。个人认为,教程中增加这样一个函数,主要是为了告诉大家可以合理扩充nn.Module的功能,当做一个自己的类来使用。
至此,我们的神经网络就已经定义好了,输出这个神经网络得到的结果如下:
Net( (conv1): Conv2d(1, 6, kernel_size=(3, 3), stride=(1, 1)) (conv2): Conv2d(6, 16, kernel_size=(3, 3), stride=(1, 1)) (fc1): Linear(in_features=576, out_features=120, bias=True) (fc2): Linear(in_features=120, out_features=84, bias=True) (fc3): Linear(in_features=84, out_features=10, bias=True) )
可以看到,我们定义的待更新的参数都被正确的初始化且输出出来了。
如果我们想进一步了解参数细节,可以使用网络的parameters属性,代码如下:
params = list(net.parameters()) #神经网络中需要更新的参数
print(len(params))
for p in params:
print(p.size())
print(params[1])
print(params[9])
输出如下:
10 torch.Size([6, 1, 3, 3]) torch.Size([6]) torch.Size([16, 6, 3, 3]) torch.Size([16]) torch.Size([120, 576]) torch.Size([120]) torch.Size([84, 120]) torch.Size([84]) torch.Size([10, 84]) torch.Size([10]) Parameter containing: tensor([-0.2524, 0.1838, -0.2556, 0.2493, 0.0351, -0.1683], requires_grad=True) Parameter containing: tensor([-0.0384, -0.0640, -0.0358, -0.1085, 0.0110, 0.0109, -0.0633, -0.0059, 0.0284, 0.0762], requires_grad=True)
谁能来告诉我为啥有10个待训练参数??可能我CNN没学好emmmmm(更新解答:weight和bias分开作为两个参数的缘故)
在这里提一下,requires_grad属性是Tensor的固有属性,可以在定义时设定为True,代表需要计算该张量的梯度,所得梯度会保存在grad属性中。由某个requires_grad属性为True的Tensor参与计算得到的其他Tensor的该属性也会为True。由于我们直接调用了nn.Conv2d等函数来定义训练参数,因此得到的参数的requires_grad属性都是True,不用我们手动去设置。
接下来,我们需要生成一张输入的“图片”,方法是使用Pytorch自带的函数随机生成一个Tensor。我们再把这个图片“喂”给我们刚刚定义的网络,代码如下:
input = torch.randn(1, 1, 32, 32) #第一个1是指batch大小为1,第二个是channel为1,后面是图片大小
out = net(input) #输出了called,代表自动调用了forward
print(out)
注意:nn.Module能够接收的输入只能为mini_batch,也就是若干条训练数据为一组进行输入。虽然这里只有一张图片,但我们也需要显示声明当前batch的大小为1,也就是通过input的第一维设置为1来说明。
输出结果如下:
called tensor([[-0.0069, -0.0766, -0.0521, 0.0825, -0.0590, -0.0177, 0.0293, 0.0676, 0.1104, 0.0665]], grad_fn=<AddmmBackward>)
由于在这里我有一个疑问,就是为什么没有调用forward的函数就输出了结果。于是我在forward定义的地方加了一行“print("called")”来验证是否被调用。根据输出结果发现,确实隐式调用了forward函数。在这里留意一下,out的size为1x10的2维Tensor,不是单纯的1维。
接下来就是喜闻乐见的求梯度时间。求梯度的方式很简单,由于我们之前声明了所有需要求梯度的参数,因此现在直接对结果调用backward即可将所有参与输出计算的参数求解梯度,代码如下:
net.zero_grad()
out.backward(torch.randn(1, 10))
至于这里为什么先调用了zero.grad(),我们后面再提。
刚刚求梯度时我们没有计算loss function,Pytorch提供了许多常见的损失函数供我们调用。我们定义了一个损失函数后,将目标输出和实际输出作为参数传入即可计算出Tensor类型的loss,实现如下:
output = net(input)
target = torch.randn(10) # a dummy target, for example
print("target before:",target) #本来是1维
target = target.view(1, -1) # make it the same shape as output
print("target after:",target) #现在是2维,为1x10
criterion = nn.MSELoss()
loss = criterion(output, target)
print(loss)
注意,我们刚刚提到过output的维数为1x10,因此我们的目标输出也要调整为1x10的尺寸。
输出如下:
called target before: tensor([ 0.6487, 1.1432, 1.1470, -0.0597, -1.1251, -0.4244, -0.0537, -0.3115, 0.5840, 1.5839]) target after: tensor([[ 0.6487, 1.1432, 1.1470, -0.0597, -1.1251, -0.4244, -0.0537, -0.3115, 0.5840, 1.5839]]) tensor(0.7355, grad_fn=<MseLossBackward>)
在此,我们可以通过追踪loss的grad_fn属性来回溯参与计算的函数:
print(loss.grad_fn) # MSELoss
print(loss.grad_fn.next_functions[0][0]) # Linear
print(loss.grad_fn.next_functions) #啥玩意儿
print(loss.grad_fn.next_functions[0]) #啥玩意儿
print(loss.grad_fn.next_functions[0][0].next_functions[0][0]) # ReLU
print(loss.grad_fn.next_functions[0][0].next_functions[0][0].next_functions) #为啥没了
输出如下:
<MseLossBackward object at 0x00000292023BF898> <AddmmBackward object at 0x00000292023BF860> ((<AddmmBackward object at 0x00000292023BFA20>, 0),) (<AddmmBackward object at 0x00000292023BF860>, 0) <AccumulateGrad object at 0x00000292023BFA20> ()
当然我还不是很理解grad_fn的底层结构,试着变花样输出它,结果还是有待理解。
Backprop
定义了网络,计算了梯度之后,是时候该反向传播了!
虽然我们前面已经使用过backward,但那时候还没有定义loss function,也没有解释为什么要先使用zero_grad()函数,下面我们来正式介绍,首先上代码:
net.zero_grad() # zeroes the gradient buffers of all parameters
print('conv1.bias.grad before backward')
print(net.conv1.bias.grad)
#loss.backward() #运行第二次的时候会报错,因为计算图的缓冲区被释放了
loss.backward(retain_graph=True) #这样就可以重复计算梯度
print('conv1.bias.grad after backward')
print(net.conv1.bias.grad)
官方Tutorial有这么一个Note:
Observe how gradient buffers had to be manually set to zero using
``optimizer.zero_grad()``. This is because gradients are accumulated
as explained in `Backprop`_ section.
我们提到过,计算好的梯度保留在grad属性中。Pytorch中计算的梯度有一个特点,也就是,梯度都是累积的。我的理解是,假设你的网络中使用了两层loss function,且某个参数u参与了这两种损失函数的计算,那么在对这两个损失函数分别求梯度传播时,两次的梯度结果会在u.grad上进行叠加。在某些场合,这种效果是我们需要的,但也有许多场合,我们不希望这种情况发生。因此,我们在每次更新梯度前,可以对网络中所有参数的梯度进行清空,这就是net.zero_grad()起到的作用。
在这里还需要指出的一点是,在同一轮训练中,backward操作只能执行一次。原因是,执行过一次梯度计算后,计算图的缓冲区就会被释放,再次调用backward就会报错,除非执行下一轮训练。当然,你也可以指明backward的retain_graph参数为True,这样就会保留计算图。
我们看看代码输出的结果:
conv1.bias.grad before backward tensor([0., 0., 0., 0., 0., 0.]) conv1.bias.grad after backward tensor([ 0.0089, 0.0052, 0.0123, -0.0118, 0.0063, 0.0001])
Update the weights
有了梯度,我们就可以去更新权重了。最简单的方法就是拿参数原来的值减去梯度与学习率的乘积,就如下面代码所示:
learning_rate = 0.01
for f in net.parameters():
f.data.sub_(f.grad.data * learning_rate)
但是Pytorch提供了丰富的工具来更新参数,并封装在torch.optim中。权重更新的实现如下:
import torch.optim as optim
# create your optimizer
optimizer = optim.SGD(net.parameters(), lr=0.01)
# in your training loop:
optimizer.zero_grad() # zero the gradient buffers
output = net(input)
loss = criterion(output, target)
loss.backward()
optimizer.step() # Does the update
代码还是很直观的,无需多作说明。
以上就是完整的一轮CNN的训练,接下来我做了一丢丢小实验来验证Tensor的grad属性的一些特性,代码如下:
x = torch.randn(2,3)
print(x.requires_grad)
y = x*x
print(y.requires_grad)
x.requires_grad_(True)
y = x*x #y是中间变量,即使用了y.requires_grad_(True)还是不会保留梯度
print(y.requires_grad) #此处为True,说明requires_grad只代表计算梯度,但只会保留初始变量的梯度值
z = y.mean() #如果z=y**2这种是无法进行反向传播的,因为训练时的输入是一个batch的x,因此z得是求和之类的聚集形式
print(z.requires_grad)
print(x.grad)
print(y.grad)
print(z.grad)
z.backward()
print(x.grad)
print(y.grad)
print(z.grad)
我先把输出给出来:
False False True True None None None tensor([[-0.1856, 0.3039, -0.0770], [ 0.3274, 0.2262, -0.3229]]) None None
首先我随机初始化了一个Tensor为x,可以看到刚初始化的Tensor的requires_grad属性为False,且其定义的y的该属性也为False。
随后,我手动把x的requires_grad设置为True,代表需要计算其梯度,结果是y的requires_grad也成为了True,而由y定义的z的requires_grad属性也是True。
接着,我们试着输出三个Tensor的grad属性,发现由于我们没有对该属性进行初始化(清零等操作),其值为None。在对z进行backward后,按照正常思路,每个requires_grad为True的变量都应该得到了自己的梯度。但是通过print我们发现,只有x的grad存在值!这是因为,只有x是显式定义的,而y和z都是对x进行运算的中间结果。在计算图中,x是叶子结点,其余则不是。只有叶子结点的梯度会在backward后被保留,其余结点的缓冲区则会被释放。这也是为什么backward不能连续调用两次的原因(吧?)!总之,我们需要的训练参数肯定都是显式定义的叶子结点,因此问题不大。但是如果你需要知道中间结点的梯度值,那就要另寻他路了,我还没去探索过......
重复了那么多遍计算图,其实我还没搞清楚计算图到底长啥样......以后了解了再来补这个坑吧