PyTorch
主要是围绕 PyTorch 中的 tensor 展开的,讨论了张量的求导机制,在不同设备之间的转换,神经网络中权重的更新等内容。文中的代码例子基于 Python 3 和 PyTorch 1.1。

1、requires_grad

  1. 创建一个张量 (tensor) 的时候,如果没有特殊指定的话,那么这个张量是默认是不需要求导的。可以通过 tensor.requires_grad检查一个张量是否需要求导

在张量间的计算过程中,如果在所有输入中有一个输入需要求导,那么输出一定会需要求导;相反,只有当所有输入都不需要求导的时候,输出才会不需要
举一个比较简单的例子,比如在训练一个网络的时候,从 DataLoader 中读取出来的一个 mini-batch 的数据,这些输入默认是不需要求导的,其次,网络的输出没有特意指明需要求导,Ground Truth 也没有特意设置需要求导。这么一想,那之前的那些 loss 怎么还能自动求导
其实原因就是上边那条规则,虽然输入的训练数据是默认不求导的,但是, model 中的所有参数,它默认是求导的,这么一来,其中只要有一个需要求导,那么输出的网络结果必定也会需要求的。来看个实例:

  1. input = torch.randn(8, 3, 50, 100)
  2. print(input.requires_grad)
  3. # False
  4. net = nn.Sequential(nn.Conv2d(3, 16, 3, 1),
  5. nn.Conv2d(16, 32, 3, 1))
  6. for param in net.named_parameters():
  7. print(param[0], param[1].requires_grad)
  8. # 0.weight True
  9. # 0.bias True
  10. # 1.weight True
  11. # 1.bias True
  12. output = net(input)
  13. print(output.requires_grad)
  14. # True

大家请注意前面只是举个例子来说明。在写代码的过程中,不要把网络的输入和 Ground Truth 的 requires_grad 设置为 True。虽然这样设置不会影响反向传播,但是需要额外计算网络的输入和 Ground Truth 的导数,增大了计算量和内存占用不说,这些计算出来的导数结果也没啥用。因为只需要神经网络中的参数的导数,用来更新网络,其余的导数都不需要。
好了,有个这个例子做铺垫,那么试试把网络参数的 requires_grad 设置为 False 会怎么样,同样的网络:

  1. input = torch.randn(8, 3, 50, 100)
  2. print(input.requires_grad)
  3. # False
  4. net = nn.Sequential(nn.Conv2d(3, 16, 3, 1),
  5. nn.Conv2d(16, 32, 3, 1))
  6. for param in net.named_parameters():
  7. param[1].requires_grad = False
  8. print(param[0], param[1].requires_grad)
  9. # 0.weight False
  10. # 0.bias False
  11. # 1.weight False
  12. # 1.bias False
  13. output = net(input)
  14. print(output.requires_grad)
  15. # False

这样有什么用处?用处大了。可以通过这种方法,在训练的过程中冻结部分网络,让这些层的参数不再更新,这在迁移学习中很有用处。来看一个例子:

  1. # https://pytorch.org/tutorials/beginner/finetuning_torchvision_models_tutorial.html#initialize-and-reshape-the-networks
  2. model = torchvision.models.resnet18(pretrained=True)
  3. for param in model.parameters():
  4. param.requires_grad = False
  5. # 用一个新的 fc 层来取代之前的全连接层
  6. # 因为新构建的 fc 层的参数默认 requires_grad=True
  7. model.fc = nn.Linear(512, 100)
  8. # 只更新 fc 层的参数
  9. optimizer = optim.SGD(model.fc.parameters(), lr=1e-2, momentum=0.9)
  10. # 通过这样,就冻结了 resnet 前边的所有层,
  11. # 在训练过程中只更新最后的 fc 层中的参数。

2、torch.no_grad()

当在做 evaluating 的时候(不需要计算导数),可以将推断(inference)的代码包裹在 **with torch.no_grad():** 之中,以达到暂时不追踪网络参数中的导数的目的,总之是为了减少可能存在的计算和内存消耗。看官方Tutorial 给出的例子:

  1. # https://link.zhihu.com/?target=https%3A//pytorch.org/tutorials/beginner/blitz/autograd_tutorial.html%23gradients
  2. x = torch.randn(3, requires_grad = True)
  3. print(x.requires_grad)
  4. # True
  5. print((x ** 2).requires_grad)
  6. # True
  7. with torch.no_grad():
  8. print((x ** 2).requires_grad)
  9. # False
  10. print((x ** 2).requires_grad)
  11. # True

3、反向传播及网络的更新

这部分比较简单地讲一讲,有了网络输出之后怎么根据这个结果来更新网络参数呢。以一个非常简单的自定义网络来讲解这个问题,这个网络包含2个卷积层,1个全连接层,输出的结果是20维的,类似分类问题中一共有20个类别,网络如下:

  1. class Simple(nn.Module):
  2. def __init__(self):
  3. super().__init__()
  4. self.conv1 = nn.Conv2d(3, 16, 3, 1, padding=1, bias=False)
  5. self.conv2 = nn.Conv2d(16, 32, 3, 1, padding=1, bias=False)
  6. self.linear = nn.Linear(32*10*10, 20, bias=False)
  7. def forward(self, x):
  8. x = self.conv1(x)
  9. x = self.conv2(x)
  10. x = self.linear(x.view(x.size(0), -1))
  11. return x

接下来用这个网络,来研究一下整个网络更新的流程

  1. # 创建一个很简单的网络:两个卷积层,一个全连接层
  2. model = Simple()
  3. # 为了方便观察数据变化,把所有网络参数都初始化为 0.1
  4. for m in model.parameters():
  5. m.data.fill_(0.1)
  6. criterion = nn.CrossEntropyLoss()
  7. optimizer = torch.optim.SGD(model.parameters(), lr=1.0)
  8. model.train()
  9. # 模拟输入8个 sample,每个的大小是 10x10,
  10. # 值都初始化为1,让每次输出结果都固定,方便观察
  11. images = torch.ones(8, 3, 10, 10)
  12. targets = torch.ones(8, dtype=torch.long)
  13. output = model(images)
  14. print(output.shape)
  15. # torch.Size([8, 20])
  16. loss = criterion(output, targets)
  17. print(model.conv1.weight.grad)
  18. # None
  19. loss.backward()
  20. print(model.conv1.weight.grad[0][0][0])
  21. # tensor([-0.0782, -0.0842, -0.0782])
  22. # 通过一次反向传播,计算出网络参数的导数,
  23. # 因为篇幅原因,只观察一小部分结果
  24. print(model.conv1.weight[0][0][0])
  25. # tensor([0.1000, 0.1000, 0.1000], grad_fn=<SelectBackward>)
  26. # 网络参数的值一开始都初始化为 0.1 的
  27. optimizer.step()
  28. print(model.conv1.weight[0][0][0])
  29. # tensor([0.1782, 0.1842, 0.1782], grad_fn=<SelectBackward>)
  30. # 回想刚才设置 learning rate 为 1,这样,
  31. # 更新后的结果,正好是 (原始权重 - 求导结果) !
  32. # https://blog.csdn.net/scut_salmon/article/details/82414730
  33. optimizer.zero_grad()
  34. print(model.conv1.weight.grad[0][0][0])
  35. # tensor([0., 0., 0.])
  36. # 每次更新完权重之后,记得要把导数清零啊,
  37. # 不然下次会得到一个和上次计算一起累加的结果。
  38. # 当然,zero_grad() 的位置,可以放到前边去,
  39. # 只要保证在计算导数前,参数的导数是清零的就好。

这里,多提一句,把整个网络参数的值都传到 optimizer 里面了,这种情况下调用 **model.zero_grad()**效果是和 **optimizer.zero_grad()** 一样的。这个知道就好,建议大家坚持用 optimizer.zero_grad()。现在来看一下如果没有调用 zero_grad(),会怎么样吧:

  1. # ...
  2. # 代码和之前一样
  3. model.train()
  4. # 第一轮
  5. images = torch.ones(8, 3, 10, 10)
  6. targets = torch.ones(8, dtype=torch.long)
  7. output = model(images)
  8. loss = criterion(output, targets)
  9. loss.backward()
  10. print(model.conv1.weight.grad[0][0][0])
  11. # tensor([-0.0782, -0.0842, -0.0782])
  12. # 第二轮
  13. output = model(images)
  14. loss = criterion(output, targets)
  15. loss.backward()
  16. print(model.conv1.weight.grad[0][0][0])
  17. # tensor([-0.1564, -0.1684, -0.1564])

可以看到,第二次的结果正好是第一次的2倍。第一次结束之后,因为没有更新网络权重,所以第二次反向传播的求导结果和第一次结果一样,加上上次没有将梯度清零,所以结果正好是2倍。

4、tensor.detach()

接下来探讨两个 0.4.0 版本更新产生的遗留问题。第一个,tensor.datatensor.detach()
在 0.4.0 版本以前,.data 是用来取 Variable 中的 tensor 的,但是之后 Variable 被取消,.data 却留了下来。现在调用 tensor.data,可以得到 tensor的数据 + requires_grad=False 的版本,而且二者共享储存空间,也就是如果修改其中一个,另一个也会变。因为 PyTorch 的自动求导系统不会追踪 tensor.data 的变化,所以使用它的话可能会导致求导结果出错。官方建议使用 tensor.detach() 来替代它,二者作用相似,但是 detach 会被自动求导系统追踪,使用起来很安全。多说无益,来看个例子:

  1. a = torch.tensor([7., 0, 0], requires_grad=True)
  2. b = a + 2
  3. print(b)
  4. # tensor([9., 2., 2.], grad_fn=<AddBackward0>)
  5. loss = torch.mean(b * b)
  6. b_ = b.detach()
  7. b_.zero_()
  8. print(b)
  9. # tensor([0., 0., 0.], grad_fn=<AddBackward0>)
  10. # 储存空间共享,修改 b_ , b 的值也变了
  11. loss.backward()
  12. # RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation

这个例子中,b 是用来计算 loss 的一个变量,在计算完 loss 之后,进行反向传播之前,修改 b 的值。这么做会导致相关的导数的计算结果错误,因为在计算导数的过程中还会用到 b 的值,但是它已经变了(和正向传播过程中的值不一样了)。在这种情况下,PyTorch 选择报错来提示。但是,如果使用 tensor.data 的时候,结果是这样的:

  1. # https://github.com/pytorch/pytorch/issues/6990
  2. a = torch.tensor([7., 0, 0], requires_grad=True)
  3. b = a + 2
  4. print(b)
  5. # tensor([9., 2., 2.], grad_fn=<AddBackward0>)
  6. loss = torch.mean(b * b)
  7. b_ = b.data
  8. b_.zero_()
  9. print(b)
  10. # tensor([0., 0., 0.], grad_fn=<AddBackward0>)
  11. loss.backward()
  12. print(a.grad)
  13. # tensor([0., 0., 0.])
  14. # 其实正确的结果应该是:
  15. # tensor([6.0000, 1.3333, 1.3333])

这个导数计算的结果明显是错的,但没有任何提醒,之后再 Debug 会非常痛苦。所以,建议大家都用 tensor.detach()

5、CPU and GPU

接下来来说另一个问题,是关于 tensor.cuda()tensor.to(device) 的。后者是 0.4.0 版本之后后添加的,当 device 是 GPU 的时候,这两者并没有区别。那为什么要在新版本增加后者这个表达呢,是因为有了它,直接在代码最上边加一句话指定 device ,后面的代码直接用to(device) 就可以了:

  1. device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
  2. a = torch.rand([3,3]).to(device)
  3. # 干其他的活
  4. b = torch.rand([3,3]).to(device)
  5. # 干其他的活
  6. c = torch.rand([3,3]).to(device)

而之前版本的话,当每次在不同设备之间切换的时候,每次都要用 if cuda.is_available() 判断能否使用 GPU,很麻烦。

  1. # https://stackoverflow.com/questions/53331247/pytorch-0-4-0-there-are-three-ways-to-create-tensors-on-cuda-device-is-there-s
  2. if torch.cuda.is_available():
  3. a = torch.rand([3,3]).cuda()
  4. # 干其他的活
  5. if torch.cuda.is_available():
  6. b = torch.rand([3,3]).cuda()
  7. # 干其他的活
  8. if torch.cuda.is_available():
  9. c = torch.rand([3,3]).cuda()

关于使用 GPU 还有一个点,想把 GPU tensor 转换成 Numpy 变量的时候,需要先将 tensor 转换到 CPU 中去,因为 Numpy 是 CPU-only的。其次,如果 tensor 需要求导的话,还需要加一步 detach,再转成 Numpy 。例子如下:

  1. x = torch.rand([3,3], device='cuda')
  2. x_ = x.cpu().numpy()
  3. y = torch.rand([3,3], requires_grad=True, device='cuda').
  4. y_ = y.cpu().detach().numpy()
  5. # y_ = y.detach().cpu().numpy() 也可以
  6. # 二者好像差别不大?来比比时间:
  7. start_t = time.time()
  8. for i in range(10000):
  9. y_ = y.cpu().detach().numpy()
  10. print(time.time() - start_t)
  11. # 1.1049120426177979
  12. start_t = time.time()
  13. for i in range(10000):
  14. y_ = y.detach().cpu().numpy()
  15. print(time.time() - start_t)
  16. # 1.115112543106079
  17. # 时间差别不是很大,当然,这个速度差别可能和电脑配置
  18. # (比如 GPU 很贵,CPU 却很烂)有关。

6、tensor.item()

在提取 loss 的纯数值的时候,常常会用到 **loss.item()**,其返回值是一个 Python 数值 (python number)。不像从 tensor 转到 numpy (需要考虑 tensor 是在 cpu,还是 gpu,需不需要求导),无论什么情况,都直接使用 item() 就完事了。如果需要从 gpu 转到 cpu 的话,PyTorch 会自动处理。
但注意 item() 只适用于 tensor 只包含一个元素的时候。因为大多数情况下的 loss 就只有一个元素,所以就经常会用到 loss.item()。如果想把含多个元素的 tensor 转换成 Python list 的话,要使用 **tensor.tolist()**

  1. x = torch.randn(1, requires_grad=True, device='cuda')
  2. print(x)
  3. # tensor([-0.4717], device='cuda:0', requires_grad=True)
  4. y = x.item()
  5. print(y, type(y))
  6. # -0.4717346727848053 <class 'float'>
  7. x = torch.randn([2, 2])
  8. y = x.tolist()
  9. print(y)
  10. # [[-1.3069953918457031, -0.2710231840610504], [-1.26217520236969, 0.5559719800949097]]

结语

以上内容就是平时在写代码的时候,觉得需要注意的地方。文章中用了一些简单的代码作为例子,旨在帮助大家理解。