LSTM
LSTM(Long Short-Term Memory):长短期记忆网络
我们以文本情感分析为例来介绍LSTM
文本情感分析又叫做观点提取、主题分析、倾向性分析等。我们一起来看一个生活中的应用
比如我们在购物网站上选购一款商品时,首先会翻阅一下商品评价,看看是否有中差评。这些评论信息表达了人们的各种情感色彩和情感倾向性,如喜、怒、哀、乐和批评、赞扬等
像这样根据评价文本,由计算机自动区分评价属于好评、中评或者差评,背后用到的技术就是情感分析
如果进一步观察,还会发现,在好评差评的上方还有一些标签,比如“声音大小合适”、“连接速度快”、“售后态度很好”等。这些标签其实也是计算机根据文本,自动提取的主题或者观点
情感分析的快速发展得益于社交媒体的兴起,自2000年初以来,情感分析已经成长为自然语言处理(NLP)中最活跃的研究领域之一,它也被广泛应用在个性化推荐、商业决策、舆情监控等方面
数据准备
现在我们手中有一批影评数据(IMDB数据集),影评被分为两类:正面评价与负面评价。我们需要训练一个情感分析模型,对影评文本进行分类
这个问题本质上还是一个文本分类问题,研究对象是电影评论类的文本,我们需要对文本进行二分类。下面我们来看一看训练数据
IMDB(Internet Movie Database)是一个来自互联网电影数据库,其中包含了50000条严重两极分化的电影评论。数据集被划分为训练集和测试集,其中训练集和测试集中各有25000条评论,并且训练集和测试集都包含50%的正面评论和50%的消极评论
使用Torchtext读取数据集
pip install torchtext
Torchtext中包含了上面我们要使用的IMDB数据集,并且还有读取语料库、词转词向量、词转下标、建立相应迭代器等功能,可以满足我们对文本的处理需求
# 读取IMDB数据集
import torchtext
train_iter = torchtext.datasets.IMDB(root='./data', split='train')
# 每一行是情绪分类以及后面的评论文本。“neg”表示负面评价,“pos”表示正面评价
print(next(iter(train_iter)))
数据处理pipelines
读取出了数据集中的评论文本和情绪分类,我们还需要将文本和分类标签处理成向量,才能被计算机读取。处理文本的一般过程是先分词,然后根据词汇表将词语转换为id
Torchtext为我们提供了基本的文本处理工具,包括分词器“tokenizer”和词汇表“vocab”。我们可以用下面两个函数来创建分词器和词汇表
get_tokenizer函数的作用是创建一个分词器。将文本喂给相应的分词器,分词器就可以根据不同分词函数的规则完成分词。例如英文的分词器,就是简单按照空格和标点符号进行分词
build_vocab_from_iterator函数可以帮助我们使用训练数据集的迭代器构建词汇表,构建好词汇表后,输入分词后的结果,即可返回每个词语的id
创建分词器和构建词汇表的代码如下。首先我们要建立一个可以处理英文的分词器tokenizer,然后再根据IMDB数据集的训练集迭代器train_iter建立词汇表vocab
# 创建分词器
tokenizer = torchtext.data.utils.get_tokenizer('basic_english')
# ['here', 'is', 'the', 'an', 'example', '!']
print(tokenizer('here is the an example!'))
# 构建词汇表
def yield_tokens(data_iter):
for _, text in data_iter:
yield tokenizer(text)
vocab = torchtext.vocab.build_vocab_from_iterator(yield_tokens(train_iter), specials=["<pad>", "<unk>"])
vocab.set_default_index(vocab["<unk>"])
# [131, 9, 40, 464, 0, 0]
print(vocab(tokenizer('here is the an example <pad> <pad>')))
在构建词汇表的过程中,yield_tokens函数的作用就是依次将训练数据集中的每一条数据都进行分词处理。另外,在构建词汇表时,用户还可以利用specials参数自定义词表
上述代码中自定义的两个词语:<pad>
和<unk>
,分别表示占位符和未登录词。由于每条影评文本的长度不同,不能直接批量合成矩阵,因此需通过截断或填补占位符来固定长度,未登录词是指没有被收录在分词词表中的词
为了方便后续调用,我们使用分词器和词汇表来建立数据处理的pipelines。文本pipeline用于给定一段文本,返回分词后的id。标签pipeline用于将情绪分类转化为数字,即“neg”转化为0,“pos”转化为1
# 数据处理pipelines
text_pipeline = lambda x: vocab(tokenizer(x))
label_pipeline = lambda x: 1 if x == 'pos' else 0
# [131, 9, 40, 464, 0, 0 , ... , 0]
print(text_pipeline('here is the an example'))
# 输出:0
print(label_pipeline('neg'))
生成训练数据
有了数据处理的pipelines,接下来就是生成训练数据,也就是生成DataLoader
这里还涉及到一个变长数据处理的问题。我们在将文本pipeline所生成的id列表转化为模型能够识别的tensor时,由于文本的句子是变长的,因此生成的tensor长度不一,无法组成矩阵
这时,我们需要限定一个句子的最大长度。例如句子的最大长度为256个单词,那么超过256个单词的句子需要做截断处理;不足256个单词的句子,需要统一补位,这里用“/”来填补
上面所说的这些操作,我们都可以放到collate_batch函数中来处理
collate_batch负责在DataLoad提取一个batch的样本时,完成一系列预处理工作:包括生成文本的tensor、生成标签的tensor、生成句子长度的tensor,以及上面所说的对文本进行截断、补位操作。所以,我们将collate_batch函数通过参数collate_fn传入DataLoader,即可实现对变长数据的处理
# 生成训练数据
import torch
import torchtext
from torch.utils.data import DataLoader
from torch.utils.data.dataset import random_split
from torchtext.data.functional import to_map_style_dataset
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
def collate_batch(batch):
max_length = 256
pad = text_pipeline('<pad>')
label_list, text_list, length_list = [], [], []
for (_label, _text) in batch:
label_list.append(label_pipeline(_label))
processed_text = text_pipeline(_text)[:max_length]
length_list.append(len(processed_text))
text_list.append((processed_text+pad*max_length)[:max_length])
label_list = torch.tensor(label_list, dtype=torch.int64)
text_list = torch.tensor(text_list, dtype=torch.int64)
length_list = torch.tensor(length_list, dtype=torch.int64)
return label_list.to(device), text_list.to(device), length_list.to(device)
train_iter = torchtext.datasets.IMDB(root='./data', split='train')
train_dataset = to_map_style_dataset(train_iter)
num_train = int(len(train_dataset) * 0.95)
split_train_, split_valid_ = random_split(train_dataset,
[num_train, len(train_dataset) - num_train])
train_dataloader = DataLoader(split_train_, batch_size=8, shuffle=True, collate_fn=collate_batch)
valid_dataloader = DataLoader(split_valid_, batch_size=8, shuffle=False, collate_fn=collate_batch)
这段代码的流程,一共是五个步骤
- 利用torchtext读取IMDB的训练数据集,得到训练数据迭代器
- 使用to_map_style_dataset函数将迭代器转化为Dataset类型
- 使用random_split函数对Dataset进行划分,其中95%作为训练集,5%作为验证集
- 生成训练集的DataLoader
- 生成验证集的DataLoader
到此为止,数据部分已经全部准备完毕了,接下来我们来进行网络模型的构建
模型构建
由于RNN自身的结构问题,在进行反向传播时,容易出现梯度消失或梯度爆炸。LSTM网络在RNN结构的基础上进行了改进,通过精妙的门控制将短时记忆与长时记忆结合起来,一定程度上解决了梯度消失与梯度爆炸的问题
# 定义模型
class LSTM(torch.nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim, n_layers, bidirectional,
dropout_rate, pad_index=0):
super().__init__()
self.embedding = torch.nn.Embedding(vocab_size, embedding_dim, padding_idx=pad_index)
self.lstm = torch.nn.LSTM(embedding_dim, hidden_dim, n_layers, bidirectional=bidirectional,
dropout=dropout_rate, batch_first=True)
self.fc = torch.nn.Linear(hidden_dim * 2 if bidirectional else hidden_dim, output_dim)
self.dropout = torch.nn.Dropout(dropout_rate)
def forward(self, ids, length):
embedded = self.dropout(self.embedding(ids))
packed_embedded = torch.nn.utils.rnn.pack_padded_sequence(embedded, length, batch_first=True,
enforce_sorted=False)
packed_output, (hidden, cell) = self.lstm(packed_embedded)
output, output_length = torch.nn.utils.rnn.pad_packed_sequence(packed_output)
if self.lstm.bidirectional:
hidden = self.dropout(torch.cat([hidden[-1], hidden[-2]], dim=-1))
else:
hidden = self.dropout(hidden[-1])
prediction = self.fc(hidden)
return prediction
网络模型的具体结构,首先是一个Embedding层,用来接收文本id的tensor,然后是LSTM层,最后是一个全连接分类层。其中,bidirectional为True,表示网络为双向LSTM,bidirectional为False,表示网络为单向LSTM
模型训练与评估
import tqdm
import sys
import numpy as np
vocab_size = len(vocab)
embedding_dim = 300
hidden_dim = 300
output_dim = 2
n_layers = 2
bidirectional = True
dropout_rate = 0.5
model = LSTM(vocab_size, embedding_dim, hidden_dim, output_dim, n_layers, bidirectional, dropout_rate)
model = model.to(device)
lr = 5e-4
criterion = torch.nn.CrossEntropyLoss()
criterion = criterion.to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
def train(dataloader, model, criterion, optimizer, device):
model.train()
epoch_losses = []
epoch_accs = []
for batch in tqdm.tqdm(dataloader, desc='training...', file=sys.stdout):
(label, ids, length) = batch
label = label.to(device)
ids = ids.to(device)
length = length.to(device)
prediction = model(ids, length)
loss = criterion(prediction, label) # loss计算
accuracy = get_accuracy(prediction, label)
# 梯度更新
optimizer.zero_grad()
loss.backward()
optimizer.step()
epoch_losses.append(loss.item())
epoch_accs.append(accuracy.item())
return epoch_losses, epoch_accs
def evaluate(dataloader, model, criterion, device):
model.eval()
epoch_losses = []
epoch_accs = []
with torch.no_grad():
for batch in tqdm.tqdm(dataloader, desc='evaluating...', file=sys.stdout):
(label, ids, length) = batch
label = label.to(device)
ids = ids.to(device)
length = length.to(device)
prediction = model(ids, length)
loss = criterion(prediction, label) # loss计算
accuracy = get_accuracy(prediction, label)
epoch_losses.append(loss.item())
epoch_accs.append(accuracy.item())
return epoch_losses, epoch_accs
def get_accuracy(prediction, label):
batch_size, _ = prediction.shape
predicted_classes = prediction.argmax(dim=-1)
correct_predictions = predicted_classes.eq(label).sum()
accuracy = correct_predictions / batch_size
return accuracy
n_epochs = 10
best_valid_loss = float('inf')
train_losses = []
train_accs = []
valid_losses = []
valid_accs = []
for epoch in range(n_epochs):
train_loss, train_acc = train(train_dataloader, model, criterion, optimizer, device)
valid_loss, valid_acc = evaluate(valid_dataloader, model, criterion, device)
train_losses.extend(train_loss)
train_accs.extend(train_acc)
valid_losses.extend(valid_loss)
valid_accs.extend(valid_acc)
epoch_train_loss = np.mean(train_loss)
epoch_train_acc = np.mean(train_acc)
epoch_valid_loss = np.mean(valid_loss)
epoch_valid_acc = np.mean(valid_acc)
if epoch_valid_loss < best_valid_loss:
best_valid_loss = epoch_valid_loss
torch.save(model.state_dict(), 'lstm.pt')
print(f'epoch: {epoch+1}')
print(f'train_loss: {epoch_train_loss:.3f}, train_acc: {epoch_train_acc:.3f}')
print(f'valid_loss: {epoch_valid_loss:.3f}, valid_acc: {epoch_valid_acc:.3f}')