本文描述如何使用 使用 PyTorch RNN 进行文本分类。
考虑一个客户服务满意度的场景,客户和客服人员进行会话,会话结束后,自动将会话按照客户满意度进行分类。分类结果可以用0-5来表达,0表示最不满意,5表示最满意。
在执行学习任务之前,需要对训练数据进行标注。将对话内容打上满意度标签。最满意是5颗星,最不满意是0颗星。
同时我们还要准备词向量模型,就是能够把每个中文词映射到一个向量上,训练这个词向量模型,是一个需要大量语料的工作,通过观察词语在句子中的上下文的位置,从而确定词语本身的向量。非常遗憾我大中国,没有什么可以使用的开放的中文语料(可悲,可叹,可怜,国家的钱都白花了),我们使用了中文 wiki 提供的“国际援助”。
把对话内容进行分词,然后通过加载词向量模型找到每个词的向量,如果找不到就用一个默认向量替代(惨,说明词向量模型覆盖的不够),然后每个会话就构成了一个二维矩阵,每行是一个词向量,多少行就是这个会话中有多少词,就是所谓的时间序列,因为话是从前往后说。(有倒着说话的吗?倒背如流)
这里使用 GRU,相当于简化的 LSTM 吧,广义上都是 RNN(自己研究吧,真没啥东西,我们只讨论如何正确的使用);一个 RNN 网络实际上就是能够把时间序列数据一个个输入,这里就是一个个词向量进行输入(再加上上下文数据),然后 RNN 网络输出两个值,一个是正常的输出,一个是上下文输出。这里面非常重要的信息是,RNN 输入一定包含两部分,一部分是时间序列中的一个元素;另一部分是上下文。每次 RNN 需要的输入只是时间序列的一个元素,不是整个序列,RNN 可没有这么牛逼(很多讲 RNN 的示例图都画得好像 RNN 能一次吞了整个时间序列数据,给人无限困扰),但是我们在使用 PyTorch 的时候输入数据是整个序列,那是程序抽象出来的方便使用而已。
在计算会话的第一个词向量的时候,是没有上一个词向量的上下文输出,往往会指定一个随机值或者 None作为上下文向量,把第一个词向量和上下文向量提供给 RNN。再进一步,第二个词向量和第一个词向量通过网络计算出来的上下文输出作为 RNN 的网络输入,如此下去,直到把一个对话的词向量都用光;这时候取得最后一个网络输出(注意不是上下文输出),把这个输出做一个跟输出标签层一个全连接的 softmax。这样的 RNN 实际上又加了一个最后的全连接输出层,这个层用来把 RNN 的输出 softmax 化,就是能够把输出变成概率的形式。训练的时候的标签是 one-hot 编码,如 0 颗星就是 【1,0,0,0,0,0】,一颗星就是【0,1,0,0,0,0】,等等,5颗星就是【0,0,0,0,0,1】。0颗星就是在 0 个位置上是 1,5颗星就是在第5个位置上是1。
值得注意的是 RNN 网络的两个输出,一个是个上下文输出,反馈于序列的下一个输入;一个是网络输出,整个序列中只有最后一个网络输出有用处,如果一个会话中有 N 个分词,那么 从 1 到 N-1 的序列产生的网络输出都直接扔掉。只有第 N 个用来做 softmax,然后做损失计算,反向传播计算,调整参数。同样也可以看出来,N 次前向使用 RNN 才反向一次。是不是也还算比较节约的。
这个训练过程有点类似把序列数据进行高密度压缩了,像压缩编码一样。
下面看如何通过 Pytorch 实现:
PyTorch 有提供封装好的 GRU 模型,(当然也有最基础的 RNN 和 LSTM)这个模型提供的便利是,给模型提供输入时候可以一次把完成的时间序列提供,还可以一次提供多个时间序列。输入要求是三个维度,BATCH,TIME SEQUENCE,DATA;BATCH 就是在我们讨论的情景里面就是多少个会话,TIME SEQUENCE 就是一个会话中多少个词向量,DATA 就是词向量。我们经常能看到 很多 RNN 训练中的 DEMO 中提出要把句子补全成一个统一长度,这是完全没必要的,句子多长就告诉 RNN TIME SEQUENCE 就OK,为什么还要统一成一个长度的?画蛇添足?还是我们理解错了?
BATCH 我们都指定是 1,就是并没有一次提供多个会话给 RNN,这样在评估损失的时候貌似好计算一些(别的我们也没有试过,😀)。
在初始化 PyTorch 网络模型的时候可以指定网络输入输出参数中 BATCH 维度的位置,如果不指定 BATCH FIRST
的话,那么数据的第一个维度就是 TIME SEQUENCE,第二个维度才是 BATCH。
PyTorch 官网的的样例中 SEQ2SEQ 用来做翻译,使用了内置的词嵌入构造和查找方法,这样的好处是不用引用第三方词嵌入模型,也不会出现找不到词所对应的词向量的问题,因为这个词向量空间是根据问题域临时定义的,相当小,也正好覆盖问题,但是不足以表达世界的常识。通过翻阅很多 PyTorch 的 GITHUB 项目,我们发现这个例子深深迷惑了众生。PyTorch 样例本意是帮助使用者能够冷启动,不依赖词嵌入模型,但是实际应用中没有词嵌入模型是可能是玩不了的,但是新人很难理解全部概念,如何把样例的词嵌入过程去掉,有很多文章直接把词向量模型直接copy_到 PyTorch 中,这可的确不是一个好办法,貌似重用了原来的代码,但是更容易混淆概念。正确的方法是直接从准备好的词向量模型中查到词向量,把词向量和网络上下文(上一个输出或者随机初始上下文)提供给 RNN。其关键之处在于了解 RNN 需要的输入向量是什么构造,就是前文所指的 BATCH,TIME SEQUENSE 和 DATA,搞清楚了这一点,就不必非要勉强的使用官方的样例程序了。
在使用交叉熵进行损失计算的时候,也有一个值得注意的问题,PyTorch需要的标签值是个标量,它会自动帮你转换成 one-hot 编码。所以训练数据的标签,应该就是 0-5,0表示没有星,5表示5颗星。
根据上面所介绍的思路实现代码:
import torch
class EncoderRNNWithVector(torch.nn.Module):
def __init__(self, hidden_size, out_size, n_layers=1, batch_size=1):
super(EncoderRNNWithVector, self).__init__()
self.batch_size = bactch_size
self.hidden_size = hidden_size
self.n_layers = n_layers
self.out_size = out_size
# 这里指定了 BATCH FIRST
self.gru = torch.nn.GRU(hidden_size, hidden_size, n_layers, batch_first=True)
# 加了一个线性层,全连接
self.out = torch.nn.Linear(hidden_size, out_size)
def forward(self, word_inputs, hidden):
# -1 是在其他确定的情况下,PyTorch 能够自动推断出来,view 函数就是在数据不变的情况下重新整理数据维度
# batch, time_seq, input
inputs = word_inputs.view(self.batch_size, -1, self.hidden_size)
# hidden 就是上下文输出,output 就是 RNN 输出
output, hidden = self.gru(inputs, hidden)
output = self.out(output)
# 仅仅获取 time seq 维度中的最后一个向量
# the last of time_seq
output = output[:,-1,:]
return output, hidden
def init_hidden(self):
# 这个函数写在这里,有一定迷惑性,这个不是模型的一部分,是每次第一个向量没有上下文,在这里捞一个上下文,仅此而已。
hidden = torch.autograd.Variable(torch.zeros(self.n_layers, 1, self.hidden_size))
return hidden
下面这个函数利用上文定义好的 PyTorch 模型进行计算,我们随机生成一些数据和标签。
def _test_rnn_rand_vec():
import random
# 这里随机生成一个 Tensor,维度是 1000 x 10 x 200;其实就是1000个句子,每个句子里面有10个词向量,每个词向量 200 维度,其中的值符合 NORMAL 分布。
_xs = torch.randn(1000, 10, 200)
_ys = []
# 标签值 0 - 5 闭区间
for i in range(1000):
_ys.append(random.randint(0, 5))
# 隐层 200,输出 6,隐层用词向量的宽度,输出用标签的值得个数 (one-hot)
encoder_test = EncoderRNNWithVector(200, 6)
criterion = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(encoder_test.parameters(), lr=0.001, momentum=0.9)
for i in range(_xs.size()[0]):
encoder_hidden = encoder_test.init_hidden()
input_data = torch.autograd.Variable(_xs[i])
output_labels = torch.autograd.Variable(torch.LongTensor([_ys[i]]))
#print(output_labels)
encoder_outputs, encoder_hidden = encoder_test(input_data, encoder_hidden)
optimizer.zero_grad()
loss = criterion(encoder_outputs, output_labels)
loss.backward()
optimizer.step()
print("loss: ", loss.data[0])
return