0%

在 PyTorch 中借助 GloVe 词嵌入完成情感分析

词嵌入能够用更加有意义的向量表示单词。在NLP任务中使用预训练的词嵌入,往往能极大地加快训练效率。在这篇文章中,我将面向NLP初学者,分享一下如何在PyTorch中使用预训练的GloVe词嵌入,并借助它完成一个简单的NLP任务——情感分析。

相关的背景知识可以参考我之前有关词嵌入的文章:词嵌入 (Word2Vec, GloVe)

项目网址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/SentimentAnalysis

GloVe 词嵌入

GloVe是一种学习词嵌入的方法,它希望拟合给定上下文单词$i$时单词$j$出现的次数$x_{ij}$。使用的误差函数为:

其中,$N$是词汇表大小,$\theta, b$是线性层参数,$e_i$是词嵌入。$f(x)$是权重项,用于平衡不同频率的单词对误差的影响,并消除$log0$时式子不成立的情况。

GloVe作者提供了官方的预训练词嵌入(https://nlp.stanford.edu/projects/glove/ )。预训练的GloVe有好几个版本,按数据来源,可以分成:

  • 维基百科+gigaword(6B)
  • 爬虫(42B)
  • 爬虫(840B)
  • 推特(27B)

其中,括号里的数字表示数据集的token数。

按照词嵌入向量的大小分,又可以分成50维、100维、200维等不同维度。

预训练GloVe的文件格式非常简明。一行表示一个单词向量,每行先是一个单词,再是若干个浮点数,表示该单词向量的每一个元素。

当然,在使用PyTorch时,我们不必自己去下载解析GloVe,而是可以直接调用PyTorch的库自动下载解析GloVe。首先,我们要安装PyTorch的NLP库——torchtext。

conda可以用下面的命令安装:

1
conda install -c pytorch torchtext

pip可以直接安装:

1
pip install torchtext

之后,在Python里运行下面的代码,就可以获取GloVe的类了。

1
2
3
4
import torch
from torchtext.vocab import GloVe

glove = GloVe(name='6B', dim=100)

如前文所述,GloVe的版本可以由其数据来源和向量维数确定。在构建GloVe类时,要提供这两个参数。最好是去GloVe的官网查好一个确定的版本,用该版本的参数构建这个GloVe类。我在这个项目中使用的是6B token,维度数100的GloVe。

调用glove.get_vecs_by_tokens,我们能够把token转换成GloVe里的向量。

1
2
3
# Get vectors
tensor = glove.get_vecs_by_tokens(['', '1998', '199999998', ',', 'cat'], True)
print(tensor)

PyTorch提供的这个函数非常方便。如果token不在GloVe里的话,该函数会返回一个全0向量。如果你运行上面的代码,可以观察到一些有趣的事:空字符串和199999998这样的不常见数字不在词汇表里,而1998这种常见的数字以及标点符号都在词汇表里。

GloVe类内部维护了一个矩阵,即每个单词向量的数组。因此,GloVe需要一个映射表来把单词映射成向量数组的下标。glove.itosglove.stoi完成了下标与单词字符串的相互映射。比如用下面的代码,我们可以知道词汇表的大小,并访问词汇表的前几个单词:

1
2
3
myvocab = glove.itos
print(len(myvocab))
print(myvocab[0], myvocab[1], myvocab[2], myvocab[3])

最后,我们来通过一个实际的例子认识一下词嵌入的意义。词嵌入就是向量,向量的关系常常与语义关系对应。利用词嵌入的相对关系,我们能够回答“x1之于y1,相当于x2之于谁?”这种问题。比如,男人之于女人,相当于国王之于王后。设我们要找的向量为y2,我们想让x1-y1=x2-y2,即找出一个和x2-(x1-y1)最相近的向量y2出来。这一过程可以用如下的代码描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def get_counterpart(x1, y1, x2):
"""Find y2 that makes x1-y1=x2-y2"""
x1_id = glove.stoi[x1]
y1_id = glove.stoi[y1]
x2_id = glove.stoi[x2]
x1, y1, x2 = glove.get_vecs_by_tokens([x1, y1, x2], True)
target = x2 - x1 + y1
max_sim = 0
max_id = -1
for i in range(len(myvocab)):
vector = glove.get_vecs_by_tokens([myvocab[i]], True)[0]
cossim = torch.dot(target, vector)
if cossim > max_sim and i not in {x1_id, y1_id, x2_id}:
max_sim = cossim
max_id = i
return myvocab[max_id]


print(get_counterpart('man', 'woman', 'king'))
print(get_counterpart('more', 'less', 'long'))
print(get_counterpart('apple', 'red', 'banana'))

在函数get_counterpart中,我们遍历所有向量,根据cosine相似度,找一个和x2-x1+y1最相近的向量(除三个输入向量之外)。使用这个函数,我们可以回答以下三组问题:

  • man-woman, king-queen
  • more-less, long-short
  • apple-red, banana-yellow

词嵌入确实非常神奇,连反义词、水果的颜色这种抽象关系都能记录。当然,这里我只挑选了几组成功的例子。这种算法并不能认出单词的比较级(good-better, bad-worse)等更抽象的关系。

通过这一节的实践,我们认识了GloVe的基本用法。接下来,我们来看看怎么用词嵌入完成情感分析任务。

基于GloVe的情感分析

情感分析任务与数据集

和猫狗分类类似,情感分析任务是一种比较简单的二分类NLP任务:给定一段话,输出这段话的情感是积极的还是消极的。

比如下面这段话:

I went and saw this movie last night after being coaxed to by a few friends of mine. I’ll admit that I was reluctant to see it because from what I knew of Ashton Kutcher he was only able to do comedy. I was wrong. Kutcher played the character of Jake Fischer very well, and Kevin Costner played Ben Randall with such professionalism. ……

这是一段影评,大意说,这个观众本来不太想去看电影,因为他认为演员Kutcher只能演好喜剧。但是,看完后,他发现他错了,所有演员都演得非常好。这是一段积极的评论。

再比如这段话:

This is a pale imitation of ‘Officer and a Gentleman.’ There is NO chemistry between Kutcher and the unknown woman who plays his love interest. The dialog is wooden, the situations hackneyed.

这段影评说,这部剧是对《军官与绅士》的一个拙劣的模仿。Kutcher和那个成为他心上人的路人女性之间没有产生任何“化学反应”。对话太死板,场景太陈腐了。这是一段消极的评论。

这些评论都选自斯坦福大学的大型电影数据集。它收录了IMDb上的电影评论,正面评论和负面评论各25000条。这个数据集是情感分析中最为常用的数据集,多数新手在学习NLP时都会用它训练一个情感分析模型。我们这个项目也会使用这个数据集。

这个数据集的文件结构大致如下:

text
1
2
3
4
5
6
7
8
9
10
├─test
│ ├─neg
│ │ ├ 0_2.txt
│ │ ├ 1_3.txt
│ │ └ ...
│ └─pos
├─train
│ ├─neg
│ └─pos
└─imdb.vocab

其中,imdb.vocab记录了数据集中的所有单词,一行一个。testtrain是测试集和训练集,它们的negpos子文件夹分别记录了负面评论和正面评论。每一条评论都是一句话,存在一个txt文件里。

使用下面这个函数,我们就可以读取一个子文件夹里的所有评论:

1
2
3
4
5
6
7
8
9
10
11
import os

def read_imdb(dir='data/aclImdb', split='pos', is_train=True):
subdir = 'train' if is_train else 'test'
dir = os.path.join(dir, subdir, split)
lines = []
for file in os.listdir(dir):
with open(os.path.join(dir, file), 'rb') as f:
line = f.read().decode('utf-8')
lines.append(line)
return lines

这里,顺便介绍一下torchtext提供的分词工具。在NLP中,我们在得到一段文本时,一般需要对文本做一步预处理操作,把一段话变成“单词”的数组。这里的“单词”即可以是英文单词,也可以是数字序列、标点符号。在NLP中,这步预处理操作称为分词,“单词”叫做token(中文直译是“符号,记号”)。

使用torchtext把一段话转换成token数组的方式如下:

1
2
3
4
5
from torchtext.data import get_tokenizer

tokenizer = get_tokenizer('basic_english')
print(tokenizer('a, b'))
# >> ['a', ',', 'b']

有了它,我们可以验证读取IMDb数据集和分词的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
from torchtext.data import get_tokenizer

def main():
lines = read_imdb()
print('Length of the file:', len(lines))
print('lines[0]:', lines[0])
tokenizer = get_tokenizer('basic_english')
tokens = tokenizer(lines[0])
print('lines[0] tokens:', tokens)


if __name__ == '__main__':
main()

输出:

text
1
2
3
Length of the file: 12500
lines[0]: This is a very light headed comedy about a wonderful ...
lines[0] tokens: ['this', 'is', 'a', 'very', 'light', 'headed', 'comedy', 'about', 'a', 'wonderful', ...

获取经GloVe预处理的数据

在这个项目中,我们的模型结构十分简单:输入序列经过词嵌入,送入单层RNN,之后输出结果。整个项目最难的部分是如何把token转换成GloVe词嵌入。在这一节里,我将介绍一种非常简单的实现方法。

torchtext其实还提供了一些更方便的NLP工具类(Field, Vectors等),用于管理词向量。但是,这些工具需要一定的学习成本。由于本文的主旨是介绍深度学习技术而非PyTorch使用技巧,本项目不会用到这些更高级的类。如果你以后要用PyTorch完成NLP任务,建议看完本文后参考相关文章进一步学习torchtext的用法。

PyTorch通常用nn.Embedding来表示词嵌入层。nn.Embedding其实就是一个矩阵,每一行都是一个词嵌入。每一个token都是整型索引,表示该token在词汇表里的序号。有了索引,有了矩阵,就可以得到token的词嵌入了。

但是,有些token在词汇表中并不存在。我们得对输入做处理,把词汇表里没有的token转换成<unk>这个表示未知字符的特殊token。同时,为了对齐序列的长度,我们还得添加<pad>这个特殊字符。而用GloVe直接生成的nn.Embedding里没有<unk><pad>字符。如果使用nn.Embedding的话,我们要编写非常复杂的预处理逻辑。

为此,我们可以用GloVe类的get_vecs_by_tokens直接获取token的词嵌入,以代替nn.Embedding。回忆一下前文提到的get_vecs_by_tokens的使用结果,所有没有出现的token都会被转换成零向量。这样,我们就不必操心数据预处理的事情了。

get_vecs_by_tokens应该发生在数据读取之后,它可以直接被写在Dataset的读取逻辑里。我为此项目编写的Dataset如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from torch.utils.data import DataLoader, Dataset
from torchtext.data import get_tokenizer
from torchtext.vocab import GloVe

from dldemos.SentimentAnalysis.read_imdb import read_imdb

GLOVE_DIM = 100
GLOVE = GloVe(name='6B', dim=GLOVE_DIM)


class IMDBDataset(Dataset):
def __init__(self, is_train=True, dir='data/aclImdb'):
super().__init__()
self.tokenizer = get_tokenizer('basic_english')
pos_lines = read_imdb(dir, 'pos', is_train)
neg_lines = read_imdb(dir, 'neg', is_train)
self.lines = pos_lines + neg_lines
self.pos_length = len(pos_lines)
self.neg_length = len(neg_lines)

def __len__(self):
return self.pos_length + self.neg_length

def __getitem__(self, index):
sentence = self.tokenizer(self.lines[index])
x = GLOVE.get_vecs_by_tokens(sentence)
label = 1 if index < self.pos_length else 0
return x, label

数据预处理的逻辑都在__getitem__里。每一段字符串会先被token化,之后由GLOVE.get_vecs_by_tokens得到词嵌入数组。

对齐输入

使用一个batch的序列数据时常常会碰到序列不等长的问题。在我的上篇RNN代码实战文章中,我曾计算了序列的最大长度,并手动为每个序列都创建了一个最大长度的向量。实际上,利用PyTorch DataLoadercollate_fn机制,还有一些更简洁的实现方法。

在这个项目中,我们可以这样创建DataLoader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import torch
from torch.nn.utils.rnn import pad_sequence

def get_dataloader(dir='data/aclImdb'):
def collate_fn(batch):
x, y = zip(*batch)
x_pad = pad_sequence(x, batch_first=True)
y = torch.Tensor(y)
return x_pad, y

train_dataloader = DataLoader(IMDBDataset(True, dir),
batch_size=32,
shuffle=True,
collate_fn=collate_fn)
test_dataloader = DataLoader(IMDBDataset(False, dir),
batch_size=32,
shuffle=True,
collate_fn=collate_fn)
return train_dataloader, test_dataloader

PyTorch DataLoader在获取Dataset的一个batch的数据时,实际上会先调用Dataset.__getitem__,获取若干个样本,再把所有样本拼接成一个batch。比如用__getitem__获取4个[3, 10, 10]的图片张量,再拼接成[4, 3, 10, 10]这一个batch。可是,序列数据通常长度不等,__getitem__可能会获得[10, 100], [15, 100]这样不等长的词嵌入数组。

为了解决这个问题,我们要手动编写把所有张量拼成一个batch的函数。这个函数就是DataLoadercollate_fn函数。我们的collate_fn应该这样编写:

1
2
3
4
5
def collate_fn(batch):
x, y = zip(*batch)
x_pad = pad_sequence(x, batch_first=True)
y = torch.Tensor(y)
return x_pad, y

collate_fn的输入batch是每次__getitem__的结果的数组。比如在我们这个项目中,第一次获取了一个长度为10的积极的句子,__getitem__返回(Tensor[10, 100], 1);第二次获取了一个长度为15的消极的句子,__getitem__返回(Tensor[15, 100], 0)。那么,输入batch的内容就是:

1
[(Tensor[10, 100], 1), (Tensor[15, 100], 0)]

我们可以用x, y = zip(*batch)把它巧妙地转换成两个元组:

1
2
x = (Tensor[10, 100], Tensor[15, 100])
y = (1, 0)

之后,PyTorch的pad_sequence可以把不等长序列的数组按最大长度填充成一整个batch张量。也就是说,经过这个函数后,x_pad变成了:

1
x_pad = Tensor[2, 15, 100]

pad_sequencebatch_first决定了batch是否在第一维。如果它为False,则结果张量的形状是[15, 2, 100]

pad_sequence还可以决定填充内容,默认填充0。在我们这个项目中,被填充的序列已经是词嵌入了,直接用全零向量表示<pad>没问题。

有了collate_fn,构建DataLoader就很轻松了:

1
2
3
4
DataLoader(IMDBDataset(True, dir),
batch_size=32,
shuffle=True,
collate_fn=collate_fn)

注意,使用shuffle=True可以令DataLoader随机取数据构成batch。由于我们的Dataset十分工整,前一半的标签是1,后一半是0,必须得用随机的方式去取数据以提高训练效率。

模型

模型非常简单,就是单层RNN:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class RNN(torch.nn.Module):
def __init__(self, hidden_units=64, dropout_rate=0.5):
super().__init__()
self.drop = nn.Dropout(dropout_rate)
self.rnn = nn.GRU(GLOVE_DIM, hidden_units, 1, batch_first=True)
self.linear = nn.Linear(hidden_units, 1)
self.sigmoid = nn.Sigmoid()

def forward(self, x: torch.Tensor):
# x shape: [batch, max_word_length, embedding_length]
emb = self.drop(x)
output, _ = self.rnn(emb)
output = output[:, -1]
output = self.linear(output)
output = self.sigmoid(output)

return output

这里要注意一下,PyTorch的RNN会返回整个序列的输出。而在预测分类概率时,我们只需要用到最后一轮RNN计算的输出。因此,要用output[:, -1]取最后一次的输出。

训练、测试、推理

项目的其他地方都比较简单,我把剩下的所有逻辑都写到main函数里了。

先准备好模型。

1
2
3
4
def main():
device = 'cuda:0'
train_dataloader, test_dataloader = get_dataloader()
model = RNN().to(device)

第一步是训练。训练照着普通RNN的训练模板写就行,没什么特别的。注意,在PyTorch中,使用二分类误差时,要在模型里用nn.Sigmoid,并使用nn.BCELoss作为误差函数。算误差前,得把序列长度那一维去掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# train

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
citerion = torch.nn.BCELoss()
for epoch in range(100):

loss_sum = 0
dataset_len = len(train_dataloader.dataset)

for x, y in train_dataloader:
batchsize = y.shape[0]
x = x.to(device)
y = y.to(device)
hat_y = model(x)
hat_y = hat_y.squeeze(-1)
loss = citerion(hat_y, y)

optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
optimizer.step()

loss_sum += loss * batchsize

print(f'Epoch {epoch}. loss: {loss_sum / dataset_len}')

torch.save(model.state_dict(), 'dldemos/SentimentAnalysis/rnn.pth')

训练几十个epoch,模型就差不多收敛了。词嵌入对于训练还是有很大帮助的。

训练完了,接下来要测试精度。这些代码也很简单,跑完了模型和0.5比较得到预测结果,再和正确标签比较算一个准确度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# test

# model.load_state_dict(
# torch.load('dldemos/SentimentAnalysis/rnn.pth', 'cuda:0'))

accuracy = 0
dataset_len = len(test_dataloader.dataset)
model.eval()
for x, y in test_dataloader:
x = x.to(device)
y = y.to(device)
with torch.no_grad():
hat_y = model(x)
hat_y.squeeze_(1)
predictions = torch.where(hat_y > 0.5, 1, 0)
score = torch.sum(torch.where(predictions == y, 1, 0))
accuracy += score.item()
accuracy /= dataset_len

print(f'Accuracy: {accuracy}')

我的精度达到了90%多。考虑到模型并不复杂,且并没有用验证集进行调参,这个精度已经非常棒了。

训练完了模型,我们来看看模型能不能在实际应用中排上用场。我去最近的财经新闻里摘抄了几句对美股的评论:

U.S. stock indexes fell Tuesday, driven by expectations for tighter Federal Reserve policy and an energy crisis in Europe. Stocks around the globe have come under pressure in recent weeks as worries about tighter monetary policy in the U.S. and a darkening economic outlook in Europe have led investors to sell riskier assets.

1
2
3
4
5
6
7
8
9
10
# Inference
tokenizer = get_tokenizer('basic_english')
article = ...

x = GLOVE.get_vecs_by_tokens(tokenizer(article)).unsqueeze(0).to(device)
with torch.no_grad():
hat_y = model(x)
hat_y = hat_y.squeeze_().item()
result = 'positive' if hat_y > 0.5 else 'negative'
print(result)

评论说,受到联邦政府更紧缩的保守经济政策和欧洲能源危机的影响,美国股市指数在周二下跌。近几周,全球股市都笼罩在对美国更紧缩的经济政策的担忧压力之下,欧洲灰暗的经济前景令投资者选择抛售高风险的资产。这显然是一段消极的评论。我的模型也很明智地输出了”negative”。看来,情感分析模型还是能在实际应用中发挥用场的。

总结

在这篇文章中,我介绍了GloVe词嵌入在PyTorch的一个应用。如果你只是想学习深度学习,建议多关注一下词嵌入的意义,不需要学习过多的API。如果你正在入门NLP,建议从这个简单的项目入手,体会一下词嵌入的作用。

欢迎关注我的其它发布渠道