pytorch的反向传播及求导机制相关

本人在GITHUB上发布了CAM和GradCAM注意力可视化方法以及 FeatureExtractor特征提取特征可视化方法。欢迎star和fork。

autograd机制

参考:pytorch docs

在pytorch中,autograd是由计算图实现的。
如果一个操作有一个输入需要梯度,那么它的输出也需要梯度。相反,只有当所有输入都不需要梯度时,输出才会不需要梯度。在所有张量都不需要梯度的子图中,不会执行反向梯度计算。
如此,对每个tensor的requires_gradflag置false可以使其从计算图中排除,减少非必要tensor的梯度计算,以提高效率。
因此,当你想冻结你的模型的一部分,或者你事先知道你不打算使用一些参数的梯度的时候,这是特别有用的。例如,如果你想微调一个预先训练好的CNN,在冻结的基础上将requires_grad置为false就足够了,并且不会保存中间缓冲区,直到计算到达最后一层你需要优化的参数权重,才将需要梯度。

model = torchvision.models.resnet18(pretrained=True)
for param in model.parameters():
param.requires_grad = False
# Replace the last fully-connected layer
# Parameters of newly constructed modules have requires_grad=True by default
model.fc = nn.Linear(512, 100)
# Optimize only the classifier
optimizer = optim.SGD(model.fc.parameters(), lr=1e-2, momentum=0.9)

注意:神经网络的全连接层卷积层等结构的参数都是默认需要梯度的,而用torch.tensor([1.,2.,3.])得到的tensor默认是不需要梯度的,但可以通过torch.tensor([1.,2.,3.], requires_grad = True)显式指定。注意的是,此时的[1., 2., 3.]只能是浮点数,不能是整数。

关于计算图

参考:手把手教你使用PyTorch

从PyTorch的设计原理上来说,在每次进行前向计算得到pred时,会产生一个用于梯度回传的计算图,这张图储存了进行反向传播需要的中间结果,这张计算图保存了计算的相关历史和提取计算所需的所有信息。只有调用了xx.backward()后,它才会从内存中被释放。为了理解,给出multi-task任务一个标准的流程:

# http://www.imooc.com/article/282785
for idx, data in enumerate(train_loader):
xs, ys = data
optmizer.zero_grad()
# 计算d(l1)/d(x)
pred1 = model1(xs) #生成graph1
loss1 = loss_fn1(pred1, ys)
loss1.backward() #释放graph1
# 计算d(l2)/d(x)
pred2 = model2(xs)#生成graph2
loss2 = loss_fn2(pred2, ys)
loss2.backward() #释放graph2
# 使用d(l1)/d(x)+d(l2)/d(x)进行优化
optmizer.step()

其中,optmizer.zero_grad()用于在计算梯度之前将需要梯度的tensor的梯度置零,否则会出现梯度随iteration不断累加的情况。loss.backward()用于得到关于loss的所有梯度保存在对应的tensor中,同时释放计算图,最后optmizer.step()进行一次梯度更新。
这里,因为是多任务,所以每一iteration只进行一次梯度置零,两次梯度计算,一次梯度更新。如此被累加的梯度的更新实现了多任务学习,而且不会导致多个分支同时保留计算图,占用过多显存。
关于梯度累加的思想可以参考:PyTorch中的梯度累加。用optimizer.zero_grad()玩出花样!

临时关闭求导with torch.no_grad()

参考:浅谈 PyTorch 中的 tensor 及使用

我们在训练时前向传播得到pred和保留的计算图,然后反向传播得到梯度。但测试的时候,对于输入数据,我们只需要前向传播得到pred,因此不需要backward()进行反向传播,更不需要保留计算图来进行梯度回传。因此可以将前向传播代码放在with torch.no_grad()下,不保留计算图,从而大大地减少显存使用率。
一个简单的示例:

>>> x = torch.randn(3, requires_grad = True)
>>> print(x.requires_grad)
True
>>> print((x ** 2).requires_grad)
True
>>> with torch.no_grad():
>>> print((x ** 2).requires_grad)
False
>>> print((x ** 2).requires_grad)
True

in-place 原地操作

参考:PyTorch中in-place

in-place运算指改变一个tensor的值的时候,直接在原始内存空间上进行值的改变,不经过复制操作。
一般以后缀表示原地操作,如add_()或者 +=
由于pytorch中参数的求导在计算图中进行,因此,一旦在原始内存中修改了数据,则需要重写计算图,所以绝大多数情况不推荐使用in-place操作。

detach() 与 data()

detach()data()都是从计算图中得到一个新的相同的tensor,和原始的数据共享内存空间,但requires_grad为False。而一旦对新的tensor进行赋值等操作,会同样改变原始tensor的值。
detach()data()方式得到的新的tensor值被改变后,都会相应改变原始的tensor的值,造成原始tensor反向传播不能求导的情况。但是!不同的是,detach()会以RuntimeError报错,而data()却不会报错,但得到的$grad$也是错的!
具体理解可以看下面的例子:

# 使用detach()
>>> a = torch.tensor([1,2,3.], requires_grad = True)
>>> out = a.sigmoid()
>>> c = out.detach()
>>> c.zero_()
tensor([ 0., 0., 0.])
>>> out # modified by c.zero_() !!
tensor([ 0., 0., 0.])
>>> out.sum().backward() # Requires the original value of out, but that was overwritten by c.zero_()
RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation

# 使用data()
>>> a = torch.tensor([1,2,3.], requires_grad = True)
>>> out = a.sigmoid()
>>> c = out.data
>>> c.zero_()
tensor([ 0., 0., 0.])
>>> out # out was modified by c.zero_()
tensor([ 0., 0., 0.])
>>> out.sum().backward()
>>> a.grad # The result is very, very wrong because `out` changed!
tensor([ 0., 0., 0.])

因此,日常取用计算图中的tensor推荐使用detach(),不建议data(),这样出现问题的时候能够及时报错。
当然,还有一种特殊的情况,detach()后进行原地操作再梯度计算不会导致RuntimeError报错,可以参考:pytorch中的detach和data

注意:之前的版本中,对新tensor进行resize_ / resize_as_ / set_ / transpose_原地操作也同样会改变原始tensor。而新的版本中,对新tensor进行resize_ / resize_as_ / set_ / transpose_原地操作不会改变原始tensor,反而会报错。

item()与tolist()

tensor.item()以标准python数字的形式返回tensor的值,只适用于只有一个元素的tensor。其他情况,使用tensor.tolist()返回python list。

>>> x = torch.tensor([1.0])
>>> x.item()
1.0
>>> a = torch.rand(2,2)
>>> a.tolist()
[[0.33648067712783813, 0.29370278120040894], [0.28659379482269287, 0.07816547155380249]]
>>> a.cuda()
tensor([[0.3365, 0.2937],
[0.2866, 0.0782]], device='cuda:0')
>>> a.tolist()
[[0.33648067712783813, 0.29370278120040894], [0.28659379482269287, 0.07816547155380249]]

附录1 一点神经网络训练tricks

  • 一种方法是constant warmup,18年Facebook又针对constant warmup进行了改进,因为从一个很小的学习率一下变为比较大的学习率可能会导致训练误差突然增大。提出了gradual warmup来解决这个问题,即从最开始的小学习率开始,每个iteration增大一点,直到最初设置的比较大的学习率。
  • 在凸优化问题中,随着批量的增加,收敛速度会降低,神经网络也有类似的实证结果。随着batch size的增大,处理相同数据量的速度会越来越快,但是达到相同精度所需要的epoch数量越来越多。也就是说,使用相同的epoch时,大batch size训练的模型与小batch size训练的模型相比,验证准确率会减小。具体做法很简单,比如ResNet原论文中,batch size为256时选择的学习率是0.1,当我们把batch size变为一个较大的数b时,学习率应该变为 0.1 * b/256。
  • 标签平滑(Label-smoothing regularization,LSR)是一种通过在标签y中加入噪声,实现对模型约束,降低模型过拟合程度的一种正则化方法。它的具体思想是降低我们对于标签的信任,例如我们可以将损失的目标值从1稍微降到0.9,或者将从0稍微升到0.1。标签平滑最早在inception-v2中被提出,它将真实的概率改造为

参考:深度神经网络模型训练中的最新tricks总结

附录2 tensor和module的hook

这个有时间针对$Grad-CAM$做一次解读。
参考:PyTorch的hook及其在Grad-CAM中的应用