1-Simple Sentiment Analysis
Introduction
试验介绍
看了一下 github 上一个入门级别的情感分析小demo – 1 - Simple Sentiment Analysis
这个试验使用 RNN + 全连接层对 IMDB 影评数据集进行文本情感分类。
根据 RNN 获取一个句子的最终隐藏层状态,然后将其输入到一个线性层(全连接层)计算概率,进行情感分类预测。
数据集
IMDB 数据集是一个影评数据集,每一个 example 由一句电影评论(review) + 情感标签(sentiment,二分类- pos or neg)组成。
试验环境
试验环境:torch1.8 + torchtext0.9 + spacy
1 Preparing Data
1.1 Field、spacy 定义如何处理数据
使用 torchtext 可以用来处理数据
-
Field:定义如何处理数据
TEXT
field:定义如何处理 reviewLABEL
field:定义如何处理 sentiment
-
spaCy:分词
- 因为 IMDB 中 TEXT field 的 tokenize=‘spacy’ argument ,所以采用 spacy 进行分词
- 如果没有 tokenize argument ,可默认按照空格分词
-
tokenizer_language:告诉 torchtext 使用 spacy 的那个模型
- 这里他使用的是 en_core_web_sm,需要下载一下
- 官网是说通过
python -m spacy download en_core_web_sm
下载,但是可能由于网络原因我是下载不了。 - 后来,我是先去官网是下载 en_core_web_sm 到本地,在通过 xftp 传到服务器,再在指定虚拟环境下安装的。具体参考这个
from torchtext.legacy import data SEED = 1234 torch.manual_seed(SEED) torch.backends.cudnn.deterministic = True TEXT = data.Field(tokenize = 'spacy', tokenizer_language = 'en_core_web_sm') LABEL = data.LabelField(dtype = torch.float)
1.2 下载 IMDB 数据集
torchtext 另一个方便的特性是:它支持 NLP 中常见的公共数据集。
- 运行一下代码,就会自动下载 IMDB 数据到当前文件夹的隐藏目录 .data 下
- 但是,单纯依赖下面这个下载数据集压根就没有反应。可能还是因为网络原因,解决办法仍然同上面安装 en_core_web_sm。可以参考 这个
from torchtext.legacy import datasets
train_data, test_data = datasets.IMDB.splits(TEXT, LABEL)
1.3 划分 train/validation
IMDB 数据集中只有 train/test,这里需要通过 .split() 方法来创建一个 validation。
通常,默认划分比率是 70/30,这里调整 split_ratio 为 0.8,将 train set 划分为 80/20 的 train/validation。
蜜汁操作,那 test 去哪里了?不要了?可能我理解不对。test应该没动,划分的是从 train 从拿出 20 作为 dev
import random
train_data, valid_data = train_data.split(random_state = random.seed(SEED))
使用 SEED 作为 random_state 参数,确保每次获得相同的 train/validation 分割
1.4 构建词典(vocabulary)
-
vocabulary 是一个有效的查找表,其中数据集中的每个唯一单词都有相应的索引 (vocabulary:word to index)
-
这里采用 one-hot 来编码单词。
-
词典大小为所有不重复单词的个数
IMDB 数据集里有 100,000 个不同的单词,也就是说 one-hot vector 具有 100,000 维。CPU 估计有点gg了,最好得用 GPU
-
-
有两种方法可以有效地减少词汇量
-
只取最常见的 m 个单词
-
要么忽略出现次数少于 n 的单词
本实验会做前者,只保留前 25000字
-
-
处理不在词典中单词的做法:
- 我们如何处理出现在例子中但我们已经从词汇表中删去的单词?
- 本实验采用用一个特殊的未知或 标记替换它们
-
只在 train 上构建词典,暂时不考虑 dev 和 test
# 构建词汇表,只保留最常见的 max_size 词
MAX_VOCAB_SIZE = 25_000
TEXT.build_vocab(train_data, max_size = MAX_VOCAB_SIZE) # 这里为啥是25002,不是25000?因为这里多了<unk>和<pad>
LABEL.build_vocab(train_data)
-
padded 句子
-
训练时是一次仍一个 batch 到 model 中进行 train,这时必须保证同一个 batch 中所有句子具有相等的长度
-
一个 batch 内任何短于最长长度的都要 padded
-
1.5 creating the iterators
-
我们在 training/evaluation 循环进行迭代,它们在每次迭代时返回一批 example(索引并转换为张量)。
-
我们将使用 BucketIterator 迭代器,它将返回一批 examples,其中每个示例的长度相似,从而最小化每个 example 的填充(padded)量
-
可以使用 GPU 加速
BATCH_SIZE = 64 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') train_iterator, valid_iterator, test_iterator = data.BucketIterator.splits( (train_data, valid_data, test_data), batch_size = BATCH_SIZE, device = device)
2 建立模型
模型主要包含三个部分:
-
embedding layer
-
RNN
-
linear layer
所有参数均随机初始化
2.1 embedding layer
- embedding layer作用:用来将稀疏的 one-hot vector(因为大部分都是0,所以 sparse)转化为 dense embedding vector(因为维度更小,且所有元素都是真实值,所以为 dense)
- 嵌入层只是一个全连接层
- 嵌入层除了降低 RNN输入的维数外,还有一种理论认为,在这个密集的向量空间中,对评论情绪有类似影响的单词被紧密地映射在一起
2.2 RNN layer
- RNN 利用上面得到的 dense vector 和前一个隐藏状态 h t − 1 h_{t-1} ht−1,它用来计算下一个隐藏状态 h t h_{t} ht
2.3 linear layer
- 线性层获取 rnn 最终的隐藏状态,并输入进一个全连接的层,将其转换为正确的输出维度
2.4 Overall model 细节
-
每一个 batch 中,text 维度为 [sentence length, batch size]
-
text 是一组句子,每个句子的每个单词都转换成了 one-hot vector
理论上还需要一个维度来表示 one-hot vector,但是 pytorch方便地将 one hot 向量存储为其索引值
即表示句子的张量只是该句子中每个单词的索引张量。将单词列表转换为索引列表的行为通常称为数值化。
-
-
通过 embedding layer 来获得 dense vector embedded
- embedded 维度:***[sentence length, batch size, embedding dim]***
- embedded 被输入到 RNN 中
-
RNN 返回两个 tensor:output 和 hidden
-
output :***[sentence length, batch size, hidden dim]*** ,其中 output 是所有时间步中的隐藏状态
-
hidden:***[1, batch size, hidden dim]***,其中 hidden 只是最后一步的隐藏状态
这里,通过 assert 来验证以上 output 和 hideen。
其中,squeeze 用来削减掉一个维度
-
-
最终,将最后一个时刻的隐藏状态 hidden 输入到一个线性层(全连接层,fc)中,进行预测
# 建立模型
import torch.nn as nn
class RNN(nn.Module):
def __init__(self, input_dim, embedding_dim, hidden_dim, output_dim):
super().__init__()
self.embedding = nn.Embedding(input_dim, embedding_dim) # embedding 层:one-hot to dense vector
self.rnn = nn.RNN(embedding_dim, hidden_dim) # rnn 层:获取最后一步隐藏状态
self.fc = nn.Linear(hidden_dim, output_dim) # 线性层:利用rnn层最后一步隐藏层状态来进行预测
def forward(self, text):
#text = [sent len, batch size]
embedded = self.embedding(text) # 转化为 dense vector
#embedded = [sent len, batch size, emb dim] # 参考pytorch官方文档,nn.Embedding的输出回比输入多一个维度 embedding_dim
output, hidden = self.rnn(embedded)
#output = [sent len, batch size, hid dim]
#hidden = [1, batch size, hid dim]
assert torch.equal(output[-1,:,:], hidden.squeeze(0)) # 通过 *assert* 来验证 4.2.4中的 output 和 hideen
return self.fc(hidden.squeeze(0))
用原始的 vim 来贴 py 代码有点头大,处理空格真心有点不爽,有空看看 carl 哥的一站式配置 vim 文章,尝试配一下…
2.5 创建 RNN 实例
- 输入维度:one-hot 向量的维度。
- 取决于词汇表大小
- embedding 维度:dense 词向量的维度
- 取决于词汇表大小
- hidden 维度:隐藏曾状态的数量。
- 取决于词汇表大小和密集向量的大小
- 输出维度:分类的数量。
- 这里是二分类,所以输出值只有 0/1,只用一个维度实数就行
INPUT_DIM = len(TEXT.vocab)
EMBEDDING_DIM = 100
HIDDEN_DIM = 256
OUTPUT_DIM = 1
model = RNN(INPUT_DIM, EMBEDDING_DIM, HIDDEN_DIM, OUTPUT_DIM)
2.6 计算学习参数个数
def count_parameters(model):
return sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f'The model has {count_parameters(model):,} trainable parameters')
3 Train the Model
3.1 创建优化器(Optimizer)
使用 SGD 更新模型的所有学习参数。
import torch.optim as optim
optimizer = optim.SGD(model.parameters(), lr=1e-3) # 第一个参数为模型参数,第二个参数是学习率
3.2 定义损失函数
这里使用 binary cross entropy with logits 损失函数
因为本模型输出结果是一个实数,并且标签是 0/1,所以这里使用 SIGMOD 函数。
criterion = nn.BCEWithLogitsLoss() # 既有 SIGMOD,又有二元交叉熵损失
model = model.to(device) # 将模型放到 GPU 加速
criterion = criterion.to(device)
3.3 计算精确率
def binary_accuracy(preds, y):
"""
Returns accuracy per batch, i.e. if you get 8/10 right, this returns 0.8, NOT 8
"""
# round predictions to the closest integer
rounded_preds = torch.round(torch.sigmoid(preds))
correct = (rounded_preds == y).float() # convert into float for division
acc = correct.sum() / len(correct)
return acc
3.4 train 函数
- train 函数一次处理一个 batch 训练数据
model.train()
切换到 “training mode”- training mode 可以进行 dropout and batch normalization
- 一个好的训练一般都包含 dropout 和 BN,但是本实验这里不包含二者
对于每个 batch 训练,主要包含以下几步:
- 梯度归零(zero the gradients)
- 模型中的每个参数都有一个grad属性,其存储由 criterion 计算的梯度
- pytorch 不会自动将这些梯度归零,所以需要手动置零
- 计算模型预测值
- 将一个 batch 句子,batch.text 输入到模型中计算对应的预测值
- RNN 模型输出维度为 ***[batch size, 1]***,但是下面计算精确率需要的维度为 [batch size]
- 所以,这里需要使用 squeeze 方法去除一个维度
- 计算损失和精确率
- 根据预测值 predictions 和 batch.label 的不同个数,计算准确率
- 计算梯度
- 使用 loos.backward() 计算每个参数的梯度
- 更新参数
- 使用 optimizer.step() 根据梯度更新每个参数
- 返回平均损失和平均精确率
def train(model, iterator, optimizer, criterion):
epoch_loss = 0
epoch_acc = 0
model.train() # 切换到 "training mode"
for batch in iterator:
optimizer.zero_grad() # 梯度归零
predictions = model(batch.text).squeeze(1) # 计算模型预测值
loss = criterion(predictions, batch.label) # 计算损失和精确率
acc = binary_accuracy(predictions, batch.label)
loss.backward() # 计算梯度
optimizer.step() # 更新参数
epoch_loss += loss.item() # item() 函数:从只包含单个值中的 tensor 中提取标量
epoch_acc += acc.item()
return epoch_loss / len(iterator), epoch_acc / len(iterator) # 返回平均损失和平均精确率
3.5 evaluate 函数
-
evaluate 与 train 类似,只是做了一些修改,因为此时不用更新参数
- 不包含 train() 中的
optimizer.zero_grad()
,loss.backward()
andoptimizer.step()
- 因为这里不用更新参数
- 不包含 train() 中的
-
model.evaluate()
切换到 “evaluating mode”- evaluating mode 关闭了 dropout and batch normalization
- 一个好的训练一般都包含 dropout 和 BN,但是本实验这里不包含二者
-
with no_grad() 块中没有计算梯度的操作
- 这会使用更少的内存并加快计算速度
def evaluate(model, iterator, criterion):
epoch_loss = 0
epoch_acc = 0
model.eval()
with torch.no_grad(): # with no_grad()块中没有计算梯度的操作,会使用更少的内存并加快计算速度
for batch in iterator:
predictions = model(batch.text).squeeze(1)
loss = criterion(predictions, batch.label)
acc = binary_accuracy(predictions, batch.label)
epoch_loss += loss.item()
epoch_acc += acc.item()
return epoch_loss / len(iterator), epoch_acc / len(iterator)
3.6 计算模型训练时间
import time
def epoch_time(start_time, end_time):
elapsed_time = end_time - start_time
elapsed_mins = int(elapsed_time / 60)
elapsed_secs = int(elapsed_time - (elapsed_mins * 60))
return elapsed_mins, elapsed_secs
3.7 训练
- 通过多个 epochs 训练模型,一个 epoch 是所有 train 和 dev 中所有 example 的一次完整遍历
- 在每一个 epoch 中,保存当前为止性能最好的模型参数,以用来在后面 test set 中进行测试。
N_EPOCHS = 5
best_valid_loss = float('inf')
for epoch in range(N_EPOCHS):
start_time = time.time() #
train_loss, train_acc = train(model, train_iterator, optimizer, criterion) # 计算训练损失、精确率
valid_loss, valid_acc = evaluate(model, valid_iterator, criterion)
end_time = time.time()
epoch_mins, epoch_secs = epoch_time(start_time, end_time) # 计算模型训练时间
if valid_loss < best_valid_loss: # 保存当前为止性能最好的模型参数
best_valid_loss = valid_loss
torch.save(model.state_dict(), 'tut1-model.pt')
print(f'Epoch: {epoch+1:02} | Epoch Time: {epoch_mins}m {epoch_secs}s') # 打印
print(f'\tTrain Loss: {train_loss:.3f} | Train Acc: {train_acc*100:.2f}%')
print(f'\t Val. Loss: {valid_loss:.3f} | Val. Acc: {valid_acc*100:.2f}%')
本实验性能不好(损失较高,精确率较低),但是这里只是为了看一下整体流程。后面几个试验会对性能进行改进…
3.8 计算 test set 上的损失、精确率
- 加载之前在 dev 上性能最佳的模型(dev 上损失最小),计算其在 test set 上的 loss 和准确率
model.load_state_dict(torch.load('tut1-model.pt')) # 加载之前性能最佳的模型参数
test_loss, test_acc = evaluate(model, test_iterator, criterion)
print(f'Test Loss: {test_loss:.3f} | Test Acc: {test_acc*100:.2f}%')
3.9 试验结果
本实验模型较为简单,词嵌入向量表示也较为简单,所以模型性能不佳,结果如下:
4 Next Steps
接下来几个模型会从以下几个方面提升模型性能:
- packed padded sequences
- pre-trained word embeddings
- different RNN architecture
- bidirectional RNN
- multi-layer RNN
- regularization
- a different optimizer
这将会达到大约 84% 的准确率…