实战 | 手把手教你用PyTorch实现图像描述(附完整代码)
作者 | 李理
环信人工智能研发中心 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上计算
图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]
我们手动来验证一下:
注意每次调用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
图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://*.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 里。因此每个训练数据即使长度不同也没有关系,因为每次都是根据当前的数据长度“实时”编译出来的计算图。网络结构如下图所示:
图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 = '