⽂本分类是⾃然语⾔处理的⼀个常⻅任务,它把⼀段不定⻓的⽂本序列变换为⽂本的类别。它的⼀个⼦问题:使⽤⽂本情感分类来分析⽂本作者的情绪。这个问题也叫情感分析,并有着⼴泛的应⽤。例如,我们可以分析⽤户对产品的评论并统计⽤户的满意度,或者分析⽤户对市场⾏情的情绪并⽤以预测接下来的⾏情。
这里将应⽤预训练的词向量和含多个隐藏层的双向循环神经⽹络,来判断⼀段不定⻓的⽂本序列中包含的是正⾯还是负⾯的情绪。
1、导入包和模块
1 import collections
2 import os
3 import random
4 import tarfile
5 import torch
6 from torch import nn
7 import torchtext.vocab as Vocab
8 import torch.utils.data as Data
9
10 import sys
11 sys.path.append("..")
12 import d2lzh_pytorch as d2l
13
14 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
15
16 DATA_ROOT = "./Datasets"
View Code
2、读入数据
使⽤斯坦福的IMDb数据集(Stanford's Large Movie Review Dataset)作为⽂本情感分类的数据集。这个数据集分为训练和测试⽤的两个数据集,分别包含25,000条从IMDb下载的关于电影的评论。在每个数据集中,标签为“正⾯”和“负⾯”的评论数量相等。下载数据解压至Datasets中。读取训练数据集和测试数据集。每个样本是⼀条评论及其对应的标签: 1表示“正⾯”, 0表示“负⾯”。
1 from tqdm import tqdm
2 def read_imdb(folder='train', data_root="./Datasets/aclImdb"):
3 data = []
4 for label in ['pos', 'neg']:
5 folder_name = os.path.join(data_root, folder, label)
6 for file in tqdm(os.listdir(folder_name)):
7 with open(os.path.join(folder_name, file), 'rb') as f:
8 review = f.read().decode('utf-8').replace('\n', '').lower()
9 data.append([review, 1 if label == 'pos' else 0])
10 random.shuffle(data)
11 return data
12
13 train_data, test_data = read_imdb('train'), read_imdb('test')
View Code
3、预处理数据
定义的 get_tokenized_imdb 函数使⽤最简单的⽅法:基于空格进⾏分词。
1 def get_tokenized_imdb(data):
2 """
3 data: list of [string, label]
4 """
5 def tokenizer(text):
6 return [tok.lower() for tok in text.split(' ')]
7 return [tokenizer(review) for review, _ in data]
View Code
可以根据分好词的训练数据集来创建词典了。我们在这⾥过滤掉了出现次数少于5的词。
1 def get_vocab_imdb(data):
2 tokenized_data = get_tokenized_imdb(data)
3 counter = collections.Counter([tk for st in tokenized_data for tk in st])
4 return Vocab.Vocab(counter, min_freq=5)
5
6 vocab = get_vocab_imdb(train_data)
7 '# words in vocab:', len(vocab) # ('# words in vocab:', 46151)
View Code
因为每条评论⻓度不⼀致所以不能直接组合成⼩批量,我们定义 preprocess_imdb 函数对每条评论进⾏分词,并通过词典转换成词索引,然后通过截断或者补0来将每条评论⻓度固定成500。
1 def preprocess_imdb(data, vocab):
2 max_l = 500 # 将每条评论通过截断或者补0,使得长度变成500
3
4 def pad(x):
5 return x[:max_l] if len(x) > max_l else x + [0] * (max_l - len(x))
6
7 tokenized_data = get_tokenized_imdb(data)
8 features = torch.tensor([pad([vocab.stoi[word] for word in words]) for words in tokenized_data])
9 labels = torch.tensor([score for _, score in data])
10 return features, labels
View Code
4、创建数据迭代器
我们创建数据迭代器。每次迭代将返回⼀个⼩批量的数据。
1 batch_size = 64
2 train_set = Data.TensorDataset(*preprocess_imdb(train_data, vocab))
3 test_set = Data.TensorDataset(*preprocess_imdb(test_data, vocab))
4 train_iter = Data.DataLoader(train_set, batch_size, shuffle=True)
5 test_iter = Data.DataLoader(test_set, batch_size)
View Code
5、RNN model
在这个模型中,每个词先通过嵌⼊层得到特征向量。然后,我们使⽤双向循环神经⽹络对特征序列进⼀步编码得到序列信息。最后,我们将编码的序列信息通过全连接层变换为输出。具体来说,我们可以将双向⻓短期记忆在最初时间步和最终时间步的隐藏状态连结,作为特征序列的表征传递给输出层分类。在下⾯实现的 BiRNN 类中, Embedding 实例即嵌⼊层, LSTM 实例即为序列编码的隐藏层, Linear实例即⽣成分类结果的输出层。
1 class BiRNN(nn.Module):
2 def __init__(self, vocab, embed_size, num_hiddens, num_layers):
3 super(BiRNN, self).__init__()
4 self.embedding = nn.Embedding(len(vocab), embed_size) # 嵌入层
5
6 # bidirectional设为True即得到双向循环神经网络
7 self.encoder = nn.LSTM(input_size=embed_size,
8 hidden_size=num_hiddens,
9 num_layers=num_layers,
10 bidirectional=True) # 隐藏层
11 self.decoder = nn.Linear(4*num_hiddens, 2) # 初始时间步和最终时间步的隐藏状态作为全连接层输入
12
13 def forward(self, inputs):
14 # inputs的形状是(批量大小, 词数),因为LSTM需要将序列长度(seq_len)作为第一维,所以将输入转置后
15 # 再提取词特征,输出形状为(词数, 批量大小, 词向量维度)
16 embeddings = self.embedding(inputs.permute(1, 0))
17 # rnn.LSTM只传入输入embeddings,因此只返回最后一层的隐藏层在各时间步的隐藏状态。
18 # outputs形状是(词数, 批量大小, 2 * 隐藏单元个数)
19 outputs, _ = self.encoder(embeddings) # output, (h, c)
20 # 连结初始时间步和最终时间步的隐藏状态作为全连接层输入。它的形状为
21 # (批量大小, 4 * 隐藏单元个数)。
22 encoding = torch.cat((outputs[0], outputs[-1]), -1)
23 outs = self.decoder(encoding)
24 return outs
View Code
创建⼀个含两个隐藏层的双向循环神经⽹络。
1 embed_size, num_hiddens, num_layers = 100, 100, 2
2 net = BiRNN(vocab, embed_size, num_hiddens, num_layers)
View Code
6、加载预训练的词向量
由于情感分类的训练数据集并不是很⼤,为应对过拟合,我们将直接使⽤在更⼤规模语料上预训练的词向量作为每个词的特征向量。这⾥,我们为词典 vocab 中的每个词加载100维的GloVe词向量。然后,我们将⽤这些词向量作为评论中每个词的特征向量。注意,预训练词向量的维度需要与创建的模型中的嵌⼊层输出⼤⼩ embed_size ⼀致。此外,在训练中我们不再更新这些词向量。
1 glove_vocab = Vocab.GloVe(name='6B', dim=100, cache=os.path.join(DATA_ROOT, "glove"))
2 def load_pretrained_embedding(words, pretrained_vocab):
3 """从预训练好的vocab中提取出words对应的词向量"""
4 embed = torch.zeros(len(words), pretrained_vocab.vectors[0].shape[0]) # 初始化为0
5 oov_count = 0 # out of vocabulary
6 for i, word in enumerate(words):
7 try:
8 idx = pretrained_vocab.stoi[word]
9 embed[i, :] = pretrained_vocab.vectors[idx]
10 except KeyError:
11 oov_count += 0
12 if oov_count > 0:
13 print("There are %d oov words.")
14 return embed
15
16 net.embedding.weight.data.copy_(load_pretrained_embedding(vocab.itos, glove_vocab))
17 net.embedding.weight.requires_grad = False # 直接加载预训练好的, 所以不需要更新它
View Code
对于预训练词向量我们使⽤基于维基百科⼦集预训练的50维GloVe词向量。第⼀次 创 建 预 训 练 词 向 量 实 例 时 会 ⾃ 动 下 载 相 应 的 词 向 量 到 cache 指 定 ⽂ 件 夹 ( 默 认为 .vector_cache ),因此需要联⽹。
cache_dir = "./Datasets/glove"
glove = vocab.GloVe(name='6B', dim=50, cache=cache_dir)
# ./Datasets/glove/glove.6B.zip: 862MB [40:57, 351kB/s]
# 100%|█████████▉| 399022/400000 [00:30<00:00, 24860.36it/s]
7、训练并评价模型
1 lr, num_epochs = 0.01, 5
2 optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, net.parameters()), lr=lr)
3 loss = nn.CrossEntropyLoss()
4 d2l.train(train_iter, test_iter, net, loss, optimizer, device, num_epochs)
View Code
8、定义预测函数
1 def predict_sentiment(net, vocab, sentence):
2 """sentence是词语的列表"""
3 device = list(net.parameters())[0].device
4 sentence = torch.tensor([vocab.stoi[word] for word in sentence], device=device)
5 label = torch.argmax(net(sentence.view((1, -1))), dim=1)
6 return 'positive' if label.item() == 1 else 'negative'
View Code
实现预测
predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'great']) # positive