大可的PyTorch学习笔记(一) 神经网络初步

这篇笔记主要是根据官方教程提供的Jupyter代码进行,我一边跑通代码一边进行了一些尝试和总结。

基本知识提要

Pytorch提供了自动计算梯度的工具,即backward方法,每个要更新的参数会包含一个autograd属性来记录梯度,参与计算的函数(表现为一个中间或结果变量)会具有grad_fn属性,用来记录它的函数表达式。Pytorch还提供了nn.Module模块,只需要定义该类的相关函数就可以很容易地搭出一个网络模型。

教程中提到了建立一个网络的常用步骤:

  • 定义一个具有可学习参数(或权重)的神经网络
  • 根据数据集输入进行迭代
  • 由神经网络处理输入
  • 计算损失函数值
  • 反向传播给网络
  • 更新网络权重

这些步骤在这篇博客中都会涉及,下面以教程中的简单CNN模型为例,一边提供源代码一边进行理解。

Define the network

首先,教程中提供的CNN网络结构如下图

CNN结构
CNN结构示意

可以获取的信息有:

  1. 输入为一张单通道且尺寸为32x32的图片
  2. 第一层卷积层的卷积核大小为3x3,数量为6
  3. 第一层池化层的窗口为2x2
  4. 第二层卷积层的卷积核大小为3x3,数量为16
  5. 第二层池化层的窗口为2x2
  6. 第一层全连接层长度为120
  7. 第二层全连接层长度为84
  8. 输出层长度为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不能连续调用两次的原因(吧?)!总之,我们需要的训练参数肯定都是显式定义的叶子结点,因此问题不大。但是如果你需要知道中间结点的梯度值,那就要另寻他路了,我还没去探索过......

 

重复了那么多遍计算图,其实我还没搞清楚计算图到底长啥样......以后了解了再来补这个坑吧

  • 1
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值