实战 | 手把手教你用PyTorch实现图像描述(附完整代码)

640?wx_fmt=jpeg&wxfrom=5&wx_lazy=1


作者 | 李理


环信人工智能研发中心 VP,十多年自然语言处理和人工智能研发经验。主持研发过多款智能硬件的问答和对话系统,负责环信中文语义分析开放平台和环信智能机器人的设计与研发。


想要详细了解该系列文章,营长建议你先阅读上篇:一文详解循环神经网络的基本概念(代码版)


Tensor


和TensorFlow 类似,PyTorch 的核心对象也是Tensor。下面是创建Tensor 的代码:


 
  

x = torch.Tensor(5, 3)
print(x)


对应的下标是5,那么在这个下标的值为1,而其余的值为0,因此一个词只有一个位置不为0,所以叫作one-hot 的表示方法。这种表示方法的缺点是它是一种“稀疏”的表示方法,两个词,不论语义是相似还是不同,都无法通过这个向量表示出来。比如我们计算两个向量的内积,相同的词内积为1(表示相似度很高);而不同的词为0(表示完全不同)。但实际我们希望“猫”和“狗”的相似度要高于“猫”和“石头”,使用one-hot 就无法表示出来。


Word Embedding 的思想是把高维的稀疏向量映射到一个低维的稠密向量,要求是两个相似的词会映射到低维空间里距离比较近的两个点;而不相似的距离较远。我们可以这样来“理解”这个低维的向量——假设语义可以用n 个基本的“正交”的“原子”语义表示的话,那么向量的不同的维代表这个词在这个原子语义上的“多少”。


当然这只是一种假设,但实际这个语义空间是否存在,或者即使存在也可能和人类理解的不同,但是只要能达到前面的要求——相似的词的距离近而不相似的远,也就可以了。


举例来说,假设向量的第一维表示动物,那么猫和狗应该在这个维度上有较大的值,而石头应该较小。


Embedding 一般有两种方式得到,一种是通过与任务无直接关系的无监督任务中学习,比如早期的RNN 语言模型,它的一个副产品就是Word Embedding,包括后来的专门Embedding 方法如Word to Vector 或者GloVe 等,本书后面的章节会详细介绍。另外一种方式就是在当前任务中让它自己学习出最合适的Word Embedding来。前一种方法的好处是可以利用海量的无监督数据,但是由于领域有差别以及它不是针对具体任务的最优化表示,它的效果可能不会很好;而后一种方法它针对当前任务学习出最优的表示(和模型的参数配合),但是它需要海量的训练数据,这对很多任务来说是无法满足的条件。在实践中,如果领域的数据非常少,我们可能直接用在其它任务中Pretraining 的Embedding 并且fix 住它;而如果领域数据较多的时候我们会用Pretraining 的Embedding 作为初始值,然后用领域数据驱动它进行微调。


PyTorch 基础知识



Tensor


和TensorFlow 类似,PyTorch 的核心对象也是Tensor。下面是创建Tensor 的代码:


 
  

x = torch.Tensor(5, 3)
print(x)


输出:


 
  

0.2455 0.1516 0.5319
0.9866 0.9918 0.0626
0.0172 0.6471 0.1756
0.8964 0.7312 0.9922
0.6264 0.0190 0.0041
[torch.FloatTensor of size 5x3]


我们可以得到Tensor 的大小:


 
  

print(x.size())
输出:
torch.Size([5, 3])


Operation


和TensorFlow 一样,有了Tensor 之后就可以用Operation 进行计算了。但是和TensorFlow 不同,TensorFlow 只是定义计算图但不会立即“执行”,而Pytorch 的Operation 是马上“执行”的。所以PyTorch 使用起来更加简单,当然PyTorch 也有计算图的执行引擎,但是它不对用户可见,它是“动态”编译的。


首先是加分操作:


 
  

y = torch.rand(5, 3)
print(x + y)


上面的加法会产生一个新的Tensor,但是我们可以提前申请一个Tensor 来存储Operation 的结果:


 
  

result = torch.Tensor(5, 3)
torch.add(x, y, out=result)
print(result)


也可以in-place 的修改:


 
  

# adds x to y
y.add_(x)
print(y)


一般来说,如果一个方法已_ 结尾,那么这个方法一般来说就是in-place 的函数。


PyTorch 支持numpy 的索引操作,比如取第一列:


 
  

print(x[:, 1])


我们也可以用view 来修改Tensor 的shape,注意view 要求新的Tensor 的元素个数和原来是一样的。


 
  

x = torch.randn(4, 4)
y = x.view(16)
z = x.view(-1, 8) # the size -1 is inferred from other dimensions
print(x.size(), y.size(), z.size())
输出:torch.Size([4, 4]) torch.Size([16]) torch.Size([2, 8])


numpy ndarray 的转换


我们可以很方便的把Tensor 转换成numpy 的ndarray 或者转换回来,注意它们是共享内存的,修改Tensor 会影响numpy 的ndarray,反之亦然。


Tensor 转numpy


 
  

a = torch.ones(5)
b = a.numpy()
a.add_(1) # 修改a会影响b


numpy 转Tensor


 
  

import numpy as np
a = np.ones(5)
b = torch.from_numpy(a)
np.add(a, 1, out=a) # 修改a会影响b


CUDA Tensor


Tensor 可以移到GPU 上用GPU 来加速计算:


 
  

# let us run this cell only if CUDA is available
if torch.cuda.is_available():
x = x.cuda()
y = y.cuda()
x + y 在GPU上计算


640?wx_fmt=png

图5.18: PyTorch 的变量


Autograd


autograd 是PyTorch 核心的包,用于实现前面我们提到的自动梯度算法。首先我们介绍其中的变量。


Variable


autograd.Variable 是Tensor 的封装,我们定义(也就计算)好了最终的变量(一般是Loss) 后,我们可以调用它的backward() 方法,PyTorch 就会自动的计算好梯度。如图5.18所示,PyTorch 的变量值会存储到data 里,而梯度值会存放到grad 里,此外还有一个grad_fn,它是用来计算梯度的函数。除了用户创建的Tensor 之外,通过Operatioon 创建的变量会记住它依赖的变量,从而形成一个有向无环图。计算这个变量的梯度的时候会自动计算它依赖的变量的梯度。


我们可以这样定义变量,参数requires_grad 说明这个变量是否参与计算梯度:


 
  

x = Variable(torch.ones(2, 2), requires_grad=True)


Gradient


我们可以用backward() 来计算梯度,它等价于variable.backward(torch.Tensor([1.0])),梯度会往后往前传递,最后的变量一般传递的就是1,然后往前计算梯度的时候会把之前的值累积起来,PyTorch 会自动处理这些东西,我们不需要考虑。


 
  

x = Variable(torch.ones(2, 2), requires_grad=True)
y=x+2
z = y * y * 3
out = z.mean()
out.backward() # 计算所有的dout/dz,dout/dy,dout/dx
print(x.grad) # x.grad就是dout/dx
输出为:
4.5000 4.5000
4.5000 4.5000
[torch.FloatTensor of size 2x2]


我们手动来验证一下:


640?wx_fmt=png


注意每次调用backward() 都会计算梯度然后累加到原来的值之上,所以如果每次计算梯度之前要调用变量的zero_grad() 函数。


变量的requires_grad 和volatile


每个变量有两个flag:requires_grad 和volatile,它们可以细粒度的控制一个计算图的某个子图不需要计算梯度,从而可以提高计算速度。一个Operation 如果所有的输入都不需要计算梯度(requires_grad==False),那么这个Operation 的requires_grad就是False,而只要有一个输入,那么这个Operation 就需要计算梯度。比如下面的代码片段:


 
  

>>> x = Variable(torch.randn(5, 5))
>>> y = Variable(torch.randn(5, 5))
>>> z = Variable(torch.randn(5, 5), requires_grad=True)
>>> a = x + y
>>> a.requires_grad
False
>>> b = a + z
>>> b.requires_grad
True


如果你想固定住模型的某些参数,或者你知道某些参数的梯度不会被用到,那么就可以把它们的requires_grad 设置成False。比如我们想细调(fine-tuing) 预先训练好的一个CNN,我们会固定所有最后全连接层之前的卷积池化层参数,我们可以这样:


 
  

model = torchvision.models.resnet18(pretrained=True)
for param in model.parameters():
  param.requires_grad = False
 
# 把最后一个全连接层替换成新构造的全连接层
# 默认的情况下,新构造的模块的requires_grad=True
model.fc = nn.Linear(512, 100)

# 优化器只调整新构造的全连接层的参数。
optimizer = optim.SGD(model.fc.parameters(), lr=1e-2, momentum=0.9)


volatile 在0.4.0 之后的版本以及deprecated 了,不过我们后面的代码会用到它之前的版本,因此还是需要了解一下。它适用于预测的场景,在这里完全不需要调用backward() 函数。它比requires_grad 更加高效,而且如果volatile 是True,那么它会强制requires_grad 也是True。它和requires_grad 的区别在于:如果一个Operation的所有输入的requires_grad 都是False 的时候,这个Operation 的requires_grad 才是False,这时这个Operation 就不参与梯度的计算;而如果一个Operation 的一个输入是volatile 是True,那么这个Operation 的volatile 就是True 了,那么这个Operation 就不参与梯度的计算了。因此它很适合的预测场景时:不修改模型的任何定义,只是把输入变量(的一个)设置成volatile,那么计算forward 的时候就不会保留任何用于backward 的中间结果,这样就会极大的提高预测的速度。下面是示例代码:


 
  

>>> regular_input = Variable(torch.randn(1, 3, 227, 227))
>>> volatile_input = Variable(torch.randn(1, 3, 227, 227), volatile=True)
>>> model = torchvision.models.resnet18(pretrained=True)
>>> model(regular_input).requires_grad
True
>>> model(volatile_input).requires_grad
False
>>> model(volatile_input).volatile
True
>>> model(volatile_input).grad_fn is None
True


640?wx_fmt=png

图5.19: 卷积网络


神经网络


有了前面的变量和梯度计算,理论上我们就可以自己实现各种深度学习算法,但用户会有很多重复的代码,因此PyTorch 提供了神经网络模块torch.nn。在实际的PyTorch 开发中,我们通过继承nn.Module 来定义一个网络,我们一般值需要实现forward() 函数,而PyTorch 自动帮我们计算backward 的梯度,此外它还提供了常见的Optimizer 和Loss,减少我们的重复劳动。我们下面会实现如图5.19的卷积网络,因为之前已经详细的介绍了理论的部分,我们这里只是简单的介绍怎么用PyTorch 来实现。


完整代码:

https://github.com/fancyerii/deep_learning_theory_and_practice/

blob/master/codes/ch05/PyTorch%20CNN.ipynb


对于PyTorch 的开发来说,一般是如下流程:


  • 定义网络可训练的参数

  • 变量训练数据

  • forward 计算loss

  • backward 计算梯度

  • 更新参数,比如weight = weight - learning_rate * gradient


下面我们按照前面的流程来实现这个卷积网络。


定义网络


 
  

import torch
from torch.autograd import Variable
import torch.nn as nn
import torch.nn.functional as F

class
Net(nn.Module): # 必须集成nn.Module

   def __init__(self):
      super(Net, self).__init__() # 必须调用父类的构造函数,传入类名和self
      # 输入是1个通道(灰度图),卷积feature
          map的个数是6,大小是5x5,无padding,stride是1
      self.conv1 = nn.Conv2d(1, 6, 5)
      # 第二个卷积层feature map个数是16,大小还是5*5,无padding,stride是1。
self.conv2 = nn.Conv2d(6, 16, 5)
      # 仿射层y = Wx + b,ReLu层没有参数,因此不在这里定义
self.fc1 = nn.Linear(16 * 5 * 5, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, 10)

def
forward(self, x)
:
   # 卷积然后Relu然后2x2的max pooling
   x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
   # 再一层卷积relu和max pooling
   x = F.max_pool2d(F.relu(self.conv2(x)), 2)
   # 把batch x channel x width x height 展开成batch x all_nodes
   x = x.view(-1, self.num_flat_features(x))
   x = F.relu(self.fc1(x))
   x = F.relu(self.fc2(x))
   x = self.fc3(x)
   return x

def
num_flat_features(self, x)
:
   size = x.size()[1:] # 除了batchSize之外的其它维度
   num_features = 1
   for s in size:
       num_features *= s
    return num_features

net = Net()

print(net)

输出为:

Net(
 (conv1): Conv2d (1, 6, kernel_size=(5, 5), stride=(1, 1))
 (conv2): Conv2d (6, 16, kernel_size=(5, 5), stride=(1, 1))
 (fc1): Linear(in_features=400, out_features=120)
 (fc2): Linear(in_features=120, out_features=84)
 (fc3): Linear(in_features=84, out_features=10)
)


我们值需要实现forward() 函数,PyTorch 会自动帮我们实现backward() 和梯度计算。我们可以列举net 的所有可以训练的参数,前面我们在Net 里定义的所有变量都会保存在net 的parameters 里。


 
  

params = list(net.parameters())
print(len(params))
print(params[0].size()) # conv1's .weight
10
torch.Size([6, 3, 5, 5])


代码要求forward 的输入是一个变量(不需要梯度),它的大小是batch x 1 x 32 x 32。


 
  

input = Variable(torch.randn(1, 1, 32, 32))
out = net(input)
print(out)


注意,我们直接调用net(input),不需要显式调用forward() 方法。我们可以调用backward() 来计算梯度,调用前记得调用zero_grad。


 
  

net.zero_grad()
out.backward(torch.randn(1, 10))


nn.Conv2d() 只支持batch 的输入,如果只有一个数据,也要转成batchSize 为1 的输入。如果输入是channel x width x height,我们可以使用input.unsqueeze(0) 把它变成1 x channel x width x height 的。


损失函数


接下来我们会定义损失函数,PyTorch 为我们提供了很多常见的损失函数,比如


MSELoss:


 
  

output = net(input)
target = Variable(torch.arange(1, 11)) # 只是示例
criterion = nn.MSELoss()
loss = criterion(output, target)
print(loss)


如果我们沿着loss 从后往前用grad_fn 函数查看,可以得到如下:


 
  

input -> conv2d -> relu -> maxpool2d -> conv2d -> relu -> maxpool2d
-> view -> linear -> relu -> linear -> relu -> linear
-> MSELoss
-> loss


我们可以用next_function 来查看之前的grad_fn。


 
  

print(loss.grad_fn) # MSELoss
print(loss.grad_fn.next_functions[0][0]) # Linear
print(loss.grad_fn.next_functions[0][0].next_functions[0][0]) # ReLU


梯度计算


有了Loss 之后我们就可以计算梯度:


 
  

net.zero_grad() # 记得清零。
print('conv1.bias.grad before backward')
print(net.conv1.bias.grad)
loss.backward()
print('conv1.bias.grad after backward')
print(net.conv1.bias.grad)


更新参数


我们可以自己更新参数:weight = weight - learning_rate * gradient。比如代码:


 
  

learning_rate = 0.01
for f in net.parameters():
f.data.sub_(f.grad.data * learning_rate)


但是除了标准的SGD 算法,我们前面还介绍了很多算法比如Adam 等,没有必要让大家自己实现,所以PyTorch 提供了常见的算法,包括SGD:


 
  

import torch.optim as optim
# 创建optimizer
optimizer = optim.SGD(net.parameters(), lr=0.01)
# 训练循环
optimizer.zero_grad() # 清零
output = net(input)
loss = criterion(output, target)
loss.backward()
optimizer.step() # 更新参数


数据集和transforms


对于常见的MNIST 和CIFAR-10 数据集,PyTorch 自动提供了下载和读取的代码:


 
  

transform = transforms.Compose(
[transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])

trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=4,
shuffle=True, num_workers=2)

testset = torchvision.datasets.CIFAR10(root='./data', train=False,
download=True, transform=transform)
testloader = torch.utils.data.DataLoader(testset, batch_size=4,
shuffle=False, num_workers=2)


这里有个transform,datasets.CIFAR10 返回的是PIL 的[0,1] 的RGB 值,我们首先转成Tensor,然后在把它转换成[-1,1] 区间的值。transforms.Normalize(0.5,0.5) 会用如下公式进行归一化:


 
  

input[channel] = (input[channel] − mean[channel])/std[channel]


对于上面的取值input=(input-0.5)*2,也就是把范围从[0,1] 变成[-1,1]。关于Dataset和DataLoader 更多细节,请参考完整代码


完整代码在ch05/PyTorch CNN.ipynb。我们这里的目的只是介绍PyTorch 的基本概念,因此使用了最简单的CNN。


PyTorch 循环网络示例



接下来我们通过几个示例介绍怎么在 PyTorch 里使用卷积网络。


姓名分类


这个示例会构建一个分类器,输入是一个姓名,输出是这个姓名的人可能来自哪个国家。我们下面会训练一个字符级别的RNN 模型来预测一个姓名是哪个国家人的姓名。我们的数据集收集了18 个国家的近千个人名(英文名,注意中国人也是英文名,否则就是语言识别问题了),我们最终的模型就可以预测这个姓名是哪个国家的人。


完整代码在

https://github.com/fancyerii/deep_learning_theory_and_practice/blob/

master/codes/ch05/Char%20RNN%20Classifier.ipynb


数据准备


在data/names 目录下有18 个文本文件,命名规范为[语言].txt。每个文件的每一行都是一个人名。此外,我们实现了一个unicode_to_ascii 把诸如à 之类转换成a。最终我们得到一个字典category_lines,language: [names ...]。key 是语言名,value 是名字的列表。all_letters 里保存所有的字符。


 
  

import glob

all_filenames = glob.glob('../data/names/*.txt')
print(all_filenames)

import unicodedata
import string

all_letters = string.ascii_letters + " .,;'"
n_letters = len(all_letters)

# http://stackoverflow.com/a/518232/2809427
def unicode_to_ascii(s):
  return ''.join(
      c for c in unicodedata.normalize('NFD', s)
      if unicodedata.category(c) != 'Mn'
      and c in all_letters
)

print(unicode_to_ascii('Ślusàrski'))

category_lines = {}
all_categories = []

def readLines(filename):
  lines = open(filename).read().strip().split('\n')
  return [unicode_to_ascii(line) for line in lines]

for
filename in all_filenames:
  category = filename.split('/')[-1].split('.')[0]
  all_categories.append(category)
  lines = readLines(filename)
  category_lines[category] = lines
 
n_categories = len(all_categories)
print('n_categories =', n_categories)


把姓名(String) 变成Tensor


现在我们已经把数据处理好了,接下来需要把姓名从字符串变成Tensor,因为机器学习只能处理数字。为了表示一个字母,我们使用“one-hot” 的表示方法。这是一个长度为<1 x n_letters> 的向量,对应字符的下标为1,其余为0。对于一个姓名,我们用大小为<line_length x 1 x n_letters> 的Tensor 来表示。第二维表示batch 大小,因为PyTorch 的RNN 要求输入是< time x batch x input_features>。


 
  

import torch

# 把一个字母变成<1 x n_letters> Tensor
def letter_to_tensor(letter):
   tensor = torch.zeros(1, n_letters)
   letter_index = all_letters.find(letter)
   tensor[0][letter_index] = 1
   return tensor

# 把一行(名字)转换成<line_length x 1 x n_letters>的Tensor

def line_to_tensor(line):
   tensor = torch.zeros(len(line), 1, n_letters)
   for li, letter in enumerate(line):
       letter_index = all_letters.find(letter)
       tensor[li][0][letter_index] = 1
return tensor


创建网络


如果想“手动”创建网络,那么在PyTorch 里创建RNN 和全连接网络的代码并没有太大差别。因为PyTorch 的计算图是动态实时编译的,不同time-step 的for 循环不需要“内嵌”在RNN 里。因此每个训练数据即使长度不同也没有关系,因为每次都是根据当前的数据长度“实时”编译出来的计算图。网络结构如下图所示:

640?wx_fmt=png

图5.20: RNN 分类器网络结构


这个网络结构和vanilla RNN 的区别在于我们使用了两个全连接层,一个用于计算新的hidden;另一个用于计算当前的输出。而在vanilla RNN 中只有一个全连接层计算hidden,同时用这个hidden 计算输出。定义网络的代码如下:


 
  

import torch.nn as nn
from torch.autograd import Variable

class RNN(nn.Module):
   def __init__(self, input_size, hidden_size, output_size):
       super(RNN, self).__init__()

self.input_size = input_size
self.hidden_size = hidden_size
self.output_size = output_size

self.i2h = nn.Linear(input_size + hidden_size, hidden_size)
self.i2o = nn.Linear(input_size + hidden_size, output_size)
self.softmax = nn.LogSoftmax(dim=1)

def forward(self, input, hidden):
  combined = torch.cat((input, hidden), 1)
  hidden = self.i2h(combined)
  output = self.i2o(combined)
  output = self.softmax(output)
  return output, hidden
def init_hidden(self):
  return Variable(torch.zeros(1, self.hidden_size))


和之前的全连接网络一样, 首先我们的类需要基础nn.Module 并且实现

__init__、forward 和init_hidden 这3 个方法。在__init__ 方法里,我们定

义网络中的变量,以及两个全连接层。forward 函数根据当前的输入input 和上一个时刻的hidden 计算新的输出和hidden。init_hidden 创建一个初始为0 的隐状态。


测试网络


定义好了网络之后我们可以测试一下:


 
  

n_hidden = 128
rnn = RNN(n_letters, n_hidden, n_categories)

input = Variable(line_to_tensor('Albert'))
hidden = Variable(torch.zeros(1, n_hidden))

# 实际是遍历所有input

output, next_hidden = rnn(input[0], hidden)
print(output)
hidden=net_hidden


准备训练


测试没什么问题之后就可以开始训练了。训练之前,我们需要一些工具函数。第一个就是根据网络的输出把它变成分类,我们这里使用Tensor.topk 来选取概率最大的那个下标,然后得到分类名称。


 
  

def category_from_output(output):
  top_n, top_i = output.data.topk(1) # Tensor out of Variable with .data
  category_i = top_i[0][0]
  return all_categories[category_i], category_i
 
print(category_from_output(output))


我们也需要一个函数来随机挑选一个训练数据:


 
  

import random

def random_training_pair():
  category = random.choice(all_categories)
  line = random.choice(category_lines[category])
  category_tensor =
      Variable(torch.LongTensor([all_categories.index(category)]))
  line_tensor = Variable(line_to_tensor(line))
  return category, line, category_tensor, line_tensor

for
i in range(10):
  category, line, category_tensor, line_tensor = random_training_pair()
  print('category =', category, '/ line =', line)


训练


现在我们可以训练网络了,因为RNN 的输出已经取过log 了,所以计算交叉熵只需要选择正确的分类对于的值就可以了,PyTorch 提供了nn.NLLLoss() 函数来实现这个目的,它基本就是实现了loss(x, class) = -x[class]。


 
  

criterion = nn.NLLLoss()


我们可以用optimizer 而不是自己手动来更新参数,这里我们使用最原始的SGD 算法。


 
  

learning_rate = 0.005
optimizer = torch.optim.SGD(rnn.parameters(), lr=learning_rate)


训练的每个循环如下:创建输入和输出Tensor 创建初始化为零的隐状态Tensor for each letter in 输入Tensor: output, hidden=rnn(input,hidden) 计算loss backward 计算梯度optimizer.step


 
  

def train(category_tensor, line_tensor):
  rnn.zero_grad()
  hidden = rnn.init_hidden()

for
i in range(line_tensor.size()[0]):
  output, hidden = rnn(line_tensor[i], hidden)

loss = criterion(output, category_tensor)
loss.backward()

optimizer.step()
return output, loss.data[0]


接下来我们就要用训练数据来训练了。因为上面的函数同时返回输出和损失,我们可以保存下来用于绘图。


 
  

import time
import math

n_epochs = 100000
print_every = 5000
plot_every = 1000

current_loss = 0
all_losses = []

def time_since(since):
  now = time.time()
  s = now - since
  m = math.floor(s / 60)
  s -= m * 60
  return '%dm %ds' % (m, s)

start = time.time()

for epoch in range(1, n_epochs + 1):
  # 随机选择一个样本
  category, line, category_tensor, line_tensor = random_training_pair()
  output, loss = train(category_tensor, line_tensor)
  current_loss += loss
 
  if epoch % print_every == 0:
    guess, guess_i = category_from_output(output)
    correct = '

阅读更多

猜你喜欢

转载自blog.csdn.net/heyc861221/article/details/80269465
今日推荐