李宏毅2020年机器学习作业4—RNN学习笔记
前言
声明:本文参考了李宏毅机器学习2020年作业例程,基本上是将代码复现了一遍,开发平台是Pycharm社区版2021。
开发环境:Anaconda3+Pycharm,python3.6.8。
一、问题描述
数据可以从Kaggle上获取。链接:Kaggle数据下载
作业要求:本次作业所用到的数据为Twitter上的推文,训练数据会被打上正面或负面的标签。通过循环神经网络(Recurrent Neural Networks, RNN)对句子进行情感分类。给定一句句子,判断这句句子是正面还是负面的(正面标1,负面标0)。
- 1
带标签的训练数据,中间的+++$+++只是分隔符,共200000条数据:
- 2
不带标签的训练数据,共1178614条数据:
- 3
测试数据,共200000条数据:
我们可以借助于深度学习的框架(例如:Tensorflow/pytorch等)来帮助我们快速实现网络的搭建,在这里我们利用Pytorch来实现。
二、实现过程
2.1 读取数据
#utils.py
import torch
import pandas as pd
import torch.nn as nn
def load_training_data(path='./training_label.txt'):
"""
读取训练数据。
如果是 'training_label.txt',需要读取 label,如果是 'training_nolabel.txt',不需要读取 label
:param path:数据路径
:return: 若为带标签的训练数据,返回x,y
若为不带标签的训练数据,返回y
"""
if 'training_label' in path:
with open(path, 'r', encoding='UTF-8') as f:
lines = f.readlines()
# lines是二维数组,第一维是行line(按回车分割),第二维是每行的单词(按空格分割)
lines = [line.strip().split(' ') for line in lines]
# 每行第2个符号(包括第2个字符(索引从0开始))之后都是句子的单词
train_x = [line[2:] for line in lines]
# 第0个符号是label
train_y = [line[0] for line in lines]
return train_x, train_y
else:
with open(path, 'r', encoding='UTF-8') as f:
lines = f.readlines()
# lines是二维数组,第一维是行line(按回车分割),第二维是每行的单词(按空格分割)
train_x = [line.strip().split(' ') for line in lines]
return train_x
def load_testing_data(path='./testing_data.txt'):
"""
读取测试数据
:param path:数据路径
:return:返回x
"""
with open(path, 'r', encoding='UTF-8') as f:
lines = f.readlines()
# 第0行是表头,从第1行开始是数据
# 第0列是id,第1列是文本,按逗号分割,需要逗号之后的文本
test_data = [''.join(line.strip('\n').split(',', 1)[1:]).strip() for line in lines[1:]] # 在第一个,处将句子分成两半,并取后一半
test_data = [sen.split(' ') for sen in test_data]
return test_data
2.2 词向量处理
通过导入第三方库gensim计算得到。
参数含义(摘自Gensim 中 word2vec 函数的使用):
size: 词向量的维度。
alpha: 模型初始的学习率。
window: 表示在一个句子中,当前词于预测词在一个句子中的最大距离。
min_count: 用于过滤操作,词频少于 min_count 次数的单词会被丢弃掉,默认值为 5。
max_vocab_size: 设置词向量构建期间的 RAM 限制。如果所有的独立单词数超过这个限定词,那么就删除掉其中词频最低的那个。根据统计,每一千万个单词大概需要1GB 的RAM。如果我们把该值设置为 None ,则没有限制。
sample: 高频词汇的随机降采样的配置阈值,默认为 1e-3,范围是(0, 1e-5)。
seed: 用于随机数发生器。与词向量的初始化有关。
workers: 控制训练的并行数量。
min_alpha: 随着训练进行,alpha 线性下降到 min_alpha。
sg: 用于设置训练算法。当 sg=0,使用 CBOW 算法来进行训练;当 sg=1,使用 skip-gram 算法来进行训练。
hs: 如果设置为 1 ,那么系统会采用 hierarchica softmax技巧。如果设置为 0(默认情况),则系统会采用 negative samping 技巧。
negative: 如果这个值大于 0,那么 negative samping 会被使用。该值表示 “noise words” 的数量,一般这个值是 5 - 20,默认是5。如果这个值设置为 0,那么 negative samping 没有使用。
cbow_mean: 如果这个值设置为 0,那么就采用上下文词向量的总和。如果这个值设置为 1 (默认情况下),那么我们就采用均值。但这个值只有在使用 CBOW 的时候才起作用。
hashfxn: hash函数用来初始化权重,默认情况下使用 Python 自带的 hash 函数。
iter: 算法迭代次数,默认为 5。
trim_rule: 用于设置词汇表的整理规则,用来指定哪些词需要被剔除,哪些词需要保留。默认情况下,如果 word count < min_count,那么该词被剔除。这个参数也可以被设置为 None,这种情况下 min_count 会被使用。
sorted_vocab:如果这个值设置为 1(默认情况下),则在分配 word index 的时候会先对单词基于频率降序排序。
batch_words: 每次批处理给线程传递的单词的数量,默认是 10000。
#w2v.py
from gensim.models import Word2Vec
from utils import load_training_data
from utils import load_testing_data
# 训练 word to vector 的 word embedding
def train_word2vec(x):
model = Word2Vec(x, size=250, window=5, min_count=5, workers=12, iter=10, sg=1)
return model
print('loading training data ...')
train_x, train_y = load_training_data()
train_x_no_label = load_training_data('./training_nolabel.txt')
print('load testing data ...')
test_x = load_testing_data()
# 把 training 中的 word 变成 vector
word2evc_model = train_word2vec(train_x + test_x)
# 保存 vector
print('saving model ...')
word2evc_model.save('w2v.model')
2.3 数据预处理
数据预处理主要实现调整单词长度、生成单词与词向量映射关系等。
定义一个预处理的类Preprocess():
-
w2v_path:word2vec的存储路径
-
sentences:句子
-
sen_len:句子的固定长度
-
idx2word 是一个列表,比如:self.idx2word[1] = ‘he’
-
word2idx 是一个字典,记录单词在 idx2word 中的下标,比如:self.word2idx[‘he’] = 1
-
embedding_matrix 是一个列表,记录词嵌入的向量,比如:self.embedding_matrix[1] = ‘he’ vector
对于句子,我们就可以通过 embedding_matrix[word2idx[‘he’] ] 找到 ‘he’ 的词嵌入向量。
Preprocess()的调用如下:
- 训练模型:preprocess = Preprocess(train_x, sen_len, w2v_path=w2v_path)
- 测试模型:preprocess = Preprocess(test_x, sen_len, w2v_path=w2v_path)
另外,这里除了出现在 train_x 和 test_x 中的单词外,还需要两个单词(或者叫特殊符号):
-
“PAD”:Padding的缩写,把所有句子都变成一样长度时,需要用"PAD"补上空白符
-
“UNK”:Unknown的缩写,凡是在 train_x 和 test_x 中没有出现过的单词,都用"UNK"来表示
#preprocess.py
import torch
from gensim.models import Word2Vec
class PreProcess():
def __init__(self, sentences, sen_len, w2v_path):
self.w2v_path = w2v_path # 模型存储地址
self.sentences = sentences # 句子
self.sen_len = sen_len # 句子长度
self.idx2word = []
self.word2idx = {
}
self.embedding_matrix = [] # 词向量矩阵
def get_w2v_model(self):
# 读取之前训练好的 word2vec
self.embedding = Word2Vec.load(self.w2v_path)
self.embedding_dim = self.embedding.vector_size
def add_embedding(self, word):
# 这里的 word 只会是 "<PAD>" 或 "<UNK>"
# 把一个随机生成的表征向量 vector 作为 "<PAD>" 或 "<UNK>" 的嵌入
vector = torch.empty(1, self.embedding_dim)
torch.nn.init.uniform_(vector)
self.idx2word.append(word)
self.word2idx[word] = len(self.word2idx)
self.embedding_matrix = torch.cat([self.embedding_matrix, vector], 0)
def make_embedding(self, load=True):
# 生成词向量矩阵
print("Get embedding ...")
if load:
print("loading word to vec model ...")
self.get_w2v_model() # 获取训练好的 Word2vec word embedding
else:
raise NotImplementedError
for i, word in enumerate(self.embedding.wv.vocab): # 遍历词向量
print('\r当前构建词向量矩阵进度:{:.2f}%'.format(i / len(self.embedding.wv.vocab) * 100), end='')
self.idx2word.append(word) # idx2word是一个列表,列表的下标索引对应了单词
self.word2idx[word] = len(self.word2idx)
# self.word2idx[word] = self.idx2word.index(word) # 也可以这样写,但这样速度会慢一些
self.embedding_matrix.append(
self.embedding[word]) # 在embedding_matrix中加入词向量,word所对应的索引就是词向量在embedding_matrix所在的行
print('')
self.embedding_matrix = torch.tensor(self.embedding_matrix) # 转成tensor
# 将 <PAD> 和 <UNK> 加入 embedding
self.add_embedding("<PAD>") # 训练时需要将每个句子调整成相同的长度,短的句子需要补<PAD>
self.add_embedding("<UNK>") # word2vec时有些词频低的被删掉了,所以有些词可能没有词向量,对于这种词,统一用一个随机的<UNK>词向量表示
print("total words: {}".format(len(self.embedding_matrix)))
return self.embedding_matrix
def pad_sequence(self, sentence):
# 将句子调整成相同长度,即sen_len
if len(sentence) > self.sen_len:
sentence = sentence[:self.sen_len] # 截断
else:
pad_len = self.sen_len - len(sentence) # 补<PAD>
for _ in range(pad_len):
sentence.append(self.word2idx['<PAD>'])
assert len(sentence) == self.sen_len
return sentence
def sentence_word2idx(self):
# 将句子单词用词向量索引表示
sentence_list = []
for i, sen in enumerate(self.sentences):
sentence_idx = []
for word in sen:
if word in self.word2idx.keys():
sentence_idx.append(self.word2idx[word])
else:
sentence_idx.append(self.word2idx["<UNK>"]) # 表中没有的词用<UNK>表示
sentence_idx = self.pad_sequence(sentence_idx) # 调整长度
sentence_list.append(sentence_idx)
return torch.LongTensor(sentence_list) # torch.size(句子数, sen_len)
def labels_to_tensor(self, y):
# 把 labels 转成 tensor
y = [int(label) for label in y]
return torch.LongTensor(y)
2.4 定义Dataset
from torch.utils.data import Dataset
class TwitterDataset(Dataset):
def __init__(self, X, y):
self.data = X
self.label = y
def __getitem__(self, idx):
if self.label is None:
return self.data[idx]
return self.data[idx], self.label[idx]
def __len__(self):
return len(self.data)
2.5 模型定义
定义一个简单的只有一层的LSTM,其中词向量由gensim训练得到的数据导入。
LSTM参数:
input_size:输入维数
hidden_size:输出维数
num_layers:LSTM层数,默认是1
bias:True 或者 False,决定是否使用bias, False则b_h=0. 默认为True
batch_first:True 或者 False,因为nn.lstm()接受的数据输入是(序列长度,batch,输入维数),这和我们cnn输入的方式不太一致,所以使用batch_first,我们可以将输入变成(batch,序列长度,输入维数)
dropout:表示除了最后一层之外都引入一个dropout
bidirectional:表示双向LSTM,也就是序列从左往右算一次,从右往左又算一次,这样就可以两倍的输出这里是引用
#model.py
import torch
from gensim.models import Word2Vec
import torch.nn as nn
class LSTM_Net(nn.Module):
def __init__(self, embedding, embedding_dim, hidden_dim, num_layers, dropout=0.5, fix_embedding=True):
super(LSTM_Net, self).__init__()
self.embedding = torch.nn.Embedding(embedding.size(0), embedding.size(1))
self.embedding.weight = torch.nn.Parameter(embedding)
# 是否将embedding固定住,不固定的话embedding会在训练过程中随之改变
self.embedding.weight.requires_grad = False if fix_embedding else True
self.embedding_dim = embedding.size(1) # 词向量的维度,也就是之后的input_size
self.hidden_dim = hidden_dim # 隐藏层维度
self.num_layers = num_layers # LSTM层数
self.dropout = dropout
self.lstm = nn.LSTM(embedding_dim, hidden_dim, num_layers=num_layers, batch_first=True)
self.classifier = nn.Sequential(
nn.Dropout(dropout),
nn.Linear(hidden_dim, 1),
nn.Sigmoid()
)
def forward(self, inputs):
inputs = self.embedding(inputs) # 先将索引映射为词向量
x, _ = self.lstm(inputs, None)
# x的dimension (batch, seq_len, hidden_size)
# 取句子最后一个单词输出的hidden state丢到分类器中
x = x[:, -1, :]
x = self.classifier(x)
return x
2.6 训练模型
#train.py
from sklearn.model_selection import train_test_split
from utils import load_training_data, load_testing_data
from preprocess import PreProcess
from model import LSTM_Net
from data import TwitterDataset
from torch.utils.data import DataLoader
import torch
import torch.nn as nn
def evaluation(outputs, labels):
# outputs => 预测值,概率(float)
# labels => 真实值,标签(0或1)
outputs[outputs >= 0.5] = 1 # 大于等于0.5为正面
outputs[outputs < 0.5] = 0 # 小于0.5为负面
accuracy = torch.sum(torch.eq(outputs, labels)).item()
return accuracy
def training(batch_size, n_epoch, lr, train, valid, model, device):
# 输出模型总的参数数量、可训练的参数数量
total = sum(p.numel() for p in model.parameters()) # 返回数组中元素的个数
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print('\nstart training, parameter total:{}, trainable:{}\n'.format(total, trainable))
loss = nn.BCELoss() # 定义损失函数为二元交叉熵损失 binary cross entropy loss
t_batch = len(train) # training数据的batch数量
v_batch = len(valid) # validation数据的batch数量
optimizer = torch.optim.Adam(model.parameters(), lr=lr) # optimizer用Adam,设置适当的学习率lr
total_loss, total_acc, best_acc = 0, 0, 0
for epoch in range(n_epoch):
total_loss, total_acc = 0, 0
# training
model.train()
for i, (inputs, labels) in enumerate(train):
inputs = inputs.to(device, dtype=torch.long) # 因为 device 为 "cuda",将 inputs 转成 torch.cuda.LongTensor
labels = labels.to(device,
dtype=torch.float) # 因为 device 为 "cuda",将 labels 转成 torch.cuda.FloatTensor,loss()需要float
optimizer.zero_grad() # 由于 loss.backward() 的 gradient 会累加,所以每一个 batch 后需要归零
outputs = model(inputs) # 模型输入Input,输出output
outputs = outputs.squeeze() # 去掉最外面的 dimension,好让 outputs 可以丢进 loss()
batch_loss = loss(outputs, labels) # 计算模型此时的 training loss
batch_loss.backward() # 计算 loss 的 gradient
optimizer.step() # 更新模型参数
accuracy = evaluation(outputs, labels) # 计算模型此时的 training accuracy
total_acc += (accuracy / batch_size)
total_loss += batch_loss.item()
print('Epoch | {}/{}'.format(epoch + 1, n_epoch))
print('Train | Loss:{:.5f} Acc: {:.3f}'.format(total_loss / t_batch, total_acc / t_batch * 100))
# validation
model.eval()
with torch.no_grad():
total_loss, total_acc = 0, 0
for i, (inputs, labels) in enumerate(valid):
inputs = inputs.to(device, dtype=torch.long)
labels = labels.to(device, dtype=torch.float)
outputs = model(inputs)
outputs = outputs.squeeze()
batch_loss = loss(outputs, labels)
accuracy = evaluation(outputs, labels)
total_acc += (accuracy / batch_size)
total_loss += batch_loss.item()
print("Valid | Loss:{:.5f} Acc: {:.3f} ".format(total_loss