作为NLP入门必备,在此记录RNN学习历程以及相关细节。
一、RNN理论基础以及文本情感分类
(一)文本的tokenization
tokenization就是分词的以上,分出的每个词语我们称其为token。
常见的分词工具:
jieba:https://github.com/fxsjy/jieba
常见的中英文分词方法:
就是将句子转换为词语或单个字。比如‘我爱学习’,可以分为['我','爱','学习']或['我','爱','学','习']
(二)jieba分词工具下载:
个人理解勿喷,我想着是jieba肯定是一个python包,一般python包都是在你配置环境的site-packages中,那我们就直接下载好jieba的源码以及相关配置文件,然后复制到对应环境中就行了。
地址已经给出:https://github.com/fxsjy/jieba
然后正常下载zip就可以。
解压至一个找得到位置的文件夹:
找到你使用环境python的安装位置,cmd用where python。
将文件复制至Lib/site-packages/中,就可以import该包了。
但实际上有更加快捷的方法就是pip install jieba
哈哈哈~,如果你只是这样导入,对源码不熟悉的情况下,很难正常使用api,如果用pip导入该包,使用起来就非常轻松啦。
(三)N-garm表示方法
N-garm其实指的是N个词语组成的一个小短句,其中N表示每组词语的词语个数。
具体例子:2-garm
(四)向量化
由于文本不能直接被模型计算,必须要将其编码转换为向量。
主要有两种方法:
one-hot编码与word embedding
1.one-hot编码:
将token转换为一个字典,将每个token编码为长度为N的向量,其中N为字典长度。其实就是把文档用jieba分词或者N-garm方式处理,然后去重得到字典,然后进行编码。
2.word embedding(常用方法):
word embedding使用了浮点型的稠密矩阵来表示token。word embedding使用了浮点型的稠密矩阵来表示token。根据词典的大小,我们的向量通常使用不同的维度,例如100,256,300等。其中向量中的每一个值是一个参数,其初始值是随机生成的,之后会在训练的过程中进行学习而获得。
相比于one-hot编码,word embedding所得到向量构成的矩阵要更加稠密。例如,当一个文本中有一百个不同的词语,经过初步处理后,对于one-hot编码而言,会产生一个100x100的稀疏矩阵。如果使用word embedding,只需要100x30甚至更少。
主要是将token转换为num,再将num用一个互异的向量表示出来。
torch.nn中提供了word embedding api,主要传入para为字典大小,embedding的维度
注意:经过word embedding处理后的数据维度为发生变化。
例如,每个batch中的每个句子有10个词语,经过形状为[20,4]的Word embedding之后,数据形状变为[batch_size,20,4]。记住word embedding只是一个词典,相当于一个哈希表。
(五)文本情感分类案例
有一个经典的电影评论数据,简称IMDB数据集
数据集地址:http://ai.stanford.edu/~amaas/data/sentiment/
根据样本利用torch完成模型,进行评论情感预测。
这个案例可以看作分类问题或回归问题。情感评分可以看成1-10分,可以看成10个类别。在这里,我们把它看作分类问题。主要有四步,准备数据、构建模型、模型训练、模型评估。
1.准备数据集:
当然可以用torchtext中的IMDB类,但是由于torchtext要求的版本比较高,安装了torchtext需要更加高版本的torch,如果要使用更加高版本的torch往往需要更高版本的cuda,对电脑要求太高,我这台19年的笔记本最高只能支持11.6版本的cuda,所以就直接下载了IMDB数据集。
由于每个数据中有一些换行符和制表符,需要把这些符号进行排除,这里需要用到python内置的re库。
这里需要用到re.sub(pattern(正则中的模式字符串), repl(取代的字符串), string(代替的字符串), count=0, flags=0)。(默默说一句,正则表达式真的抽象)
定义dataset还是老规矩,先继承Dataset父类,然后定义__getitem__与__len__方法。但是由于IMDB数据集的特殊性,它将每个单一的样本分为一个单独的文件,所以我们为了方便读取,要获取每个train的neg与pos文件路径,当然要分训练模式和测试模式两个情况。IMDB数据集主要划分为train与test两个文件夹,然后分别又划分为neg与pos两个文件夹。然后每个评论数据均是txt文件,文件名为序号_分数。因此我们要做一些操作把标签提取出来,也要把具体文件路径整理出来。
然后用一个总文件路径列表存放所有文件路径,为了获得文件路径加文件名字的方式,采用os的listdir方法获得文件名字列表,然后用os.path.join方法将路径拼接在一起。
在__getitem__中为了获取数据标签与数据,采用os.path.basename获取文件名。然后为了获取标签,将文件名利用'_'进行分割,然后取对应的字符串。由于还有.txt后缀,所以需要用'.'继续分割,然后取第一个就是评分。因为是1-10,所以还需要减1将其转化为0-9,方便计算。然后在利用前面定义的tokenize对数据进行处理,可以不用strip(),因为在tokenize已经处理好了。最后返回评分与处理好的文本数据(list)。
__len__操作只要直接len(总的文件路径列表)就好了。
最后进行实例化,准备dataloader迭代器即可。
但出现了问题:每个batch列表中的元素不是一样的长度。
然后查了博客说是collate_fn参数的问题,因为没有定义这个collate_fn,所以默认使用Dataloader自定义的方法。为了解决这一问题,跟着老师定义了一个collate_fn函数。
zip(*batch)相当于解压,将数据解压为列表,batch由labels与text组成。因此collate_fn的功能就是将原本组成的batch元组拆分返回labels与texts,并删除解压后的batch。看似无用,可是当我去看默认的default_collate才知道,如果collate_fn为默认值,Dataloader会将数据集切分为batch_size大小的数据,由于label和text形状不一致,自然会报错且无法切分。
此时便输出正常:
但又有一个疑惑出现了,为什么labels是[7,9],在前面的__get__items方法中不是一个int类型的值吗。恍然大悟,因为batch_size是2,所以自然有两个标签哈哈。
2.将文本序列化:
在使用word embedding方法时,我们必须要将文本转换为num,然后根据num查询到相关的向量。在这里我们可以用Python的字典去存储对应文本内容和对应序列值,然后根据字典将句子映射为包含数字的列表里。
当然,由于文本内容比较复杂,我们必须要注意以下问题:
首先, 如何使用字典把词语和数字进行对应
其次,不同的词语出现的次数不尽相同,是否需要对高频或者低频词语进行过滤,以及总的词语数量是否需要进行限制
接着,得到词典之后,如何把句子转化为数字序列,如何把数字序列转化为句子
然后,其不同句子长度不相同,每个batch的句子如何构造成相同的长度(可以对短句子进行填充,填充特殊字符)
最后,对于新出现的词语在词典中没有出现怎么办(可以使用特殊字符代理)
为了解决以上问题,我们首先对句子进行分词,然后将词语存入字典,然后根据次数进对词语进行过滤,并统计次数,实现文本转序列与数字序列转文本的方法。
具体实现如下:
定义一个类Word2Sequence将以上操作进行分词。首先
import numpy as np
class Word2Sequence():
"""
#自然语言处理中常见标识符,<UNK>指低频词或未在词表中的词,<PAD>指补全字符。
其他:<GO>/<SOS>: 句子起始标识符
<EOS>: 句子结束标识符
[SEP]:两个句子之间的分隔符
[MASK]:填充被掩盖掉的字符
"""
UNK_TAG = "UNK"
PAD_TAG = "PAD"
UNK = 0
PAD = 1
def __init__(self):
self.dict = {
self.UNK_TAG :self.UNK,
self.PAD_TAG :self.PAD
}
self.fited = False #必须进行fit操作才能进行调用其他函数。
"""将单词转换为num。首先用assert关键字判断是否对象被实例化且进行了fit操作,然后查字典返回num
"""
def to_index(self,word):
assert self.fited == True,"必须先进行fit操作"
return self.dict.get(word,self.UNK)
"""将num转换为文本。首先用assert关键字判断是否对象被实例化且进行了fit操作,然后查inversed_dict返回文本
"""
def to_word(self,index):
"""index -> word"""
assert self.fited , "必须先进行fit操作"
if index in self.inversed_dict:
return self.inversed_dict[index]
return self.UNK_TAG
def __len__(self):
return len(self.dict)
def fit(self, sentences, min_count=1, max_count=None, max_feature=None):
"""
:param sentences:[[word1,word2,word3],[word1,word3,wordn..],...]
:param min_count: 最小出现的次数
:param max_count: 最大出现的次数
:param max_feature: 总词语的最大数量
:return:
"""
#首先判断每个单词出现的次数。
count = {}
for sentence in sentences:
for a in sentence:
if a not in count:
count[a] = 0
count[a] += 1
# 比最小的数量大和比最大的数量小的需要。字典推导式,即将在[min_count,max_count]范围的训练加入字典中
"""
代码可以精简为:
if not min_count and not max_count:
count = {k: v for k, v in count.items() if min_count <= v <= max_count}
"""
if min_count is not None:
count = {k: v for k, v in count.items() if v >= min_count}
if max_count is not None:
count = {k: v for k, v in count.items() if v <= max_count}
"""
限制最大的数量.isinstance类似于type,传入参数为(object,classinfo),但是该方法会考虑继承关系。在这里的作用就是判断max_feature是否为int。
如果为int类型,则将count利用sorted进行升序排序,按照values。然后进行判断,max_feature不为none且列表长度大于max_feature,然后将多出的内容利用切片进行删除(删除的是出现频率小的,因为是倒序排序)。然后将count内容进行整理,将每个单词对应的keys设置为一个等差数列,即有序序列,从3开始,字典原本有两个元素了。
如果对最大长度没有限制,就直接加入self.dict。
"""
if isinstance(max_feature, int):
count = sorted(list(count.items()), key=lambda x: x[1])
if max_feature is not None and len(count) > max_feature:
count = count[-int(max_feature):]
for w, _ in count:
self.dict[w] = len(self.dict)
else:
for w in sorted(count.keys()):
self.dict[w] = len(self.dict)
self.fited = True
# 准备一个index->word的字典,将字典的values与keys翻转过来,然后压缩并转换为字典,就是实现了inversed_dict。
self.inversed_dict = dict(zip(self.dict.values(), self.dict.keys()))
def transform(self, sentence,max_len=None):
"""
实现吧句子转化为数组(向量)
:param sentence:
:param max_len:
:return:
首先判断是否为fited = True状态。判断max_len是否有传入。传入了该值,然后设置一个r,为一个数值全为1,长度为max_len的列表。如果没有传入,就设置一个长度为传入句子长度的列表。如果句子长度大于max_len,就进行截断操作。然后将句子转变为向量,返回一个np数组。
"""
assert self.fited, "必须先进行fit操作"
if max_len is not None:
r = [self.PAD]*max_len
else:
r = [self.PAD]*len(sentence)
if max_len is not None and len(sentence)>max_len:
sentence=sentence[:max_len]
for index,word in enumerate(sentence):
r[index] = self.to_index(word)
return np.array(r,dtype=np.int64)
"""
这里就容易看得懂了
"""
def inverse_transform(self,indices):
"""
实现从数组 转化为文字
:param indices: [1,2,3....]
:return:[word1,word2.....]
"""
sentence = []
for i in indices:
word = self.to_word(i)
sentence.append(word)
return sentence
#测试案例
if __name__ == '__main__':
w2s = Word2Sequence()
w2s.fit([
["你", "好", "么"],
["你", "好", "哦"]])
print(w2s.dict)
print(w2s.fited)
print(w2s.transform(["你","好","嘛"]))
print(w2s.transform(["你好嘛"],max_len=10))
简单输出以下结果。
将IMDB进行worksequence操作:
import pickle
from tqdm import tqdm
#1. 对IMDB的数据记性fit操作
def fit_save_word_sequence():
from wordSequence import Word2Sequence #这里将Word2Sequence放到另外一个py文件了
ws = Word2Sequence() #实例化对象
"""
读取路径与访问文件
"""
train_path = [os.path.join(data_base_path,i) for i in ["train/neg","train/pos"]]
total_file_path_list = []
for i in train_path:
total_file_path_list.extend([os.path.join(i, j) for j in os.listdir(i)])
#tqdm是进度条库,需要传入一个iterable,desc是进度条右边的文字
for cur_path in tqdm(total_file_path_list,desc="fitting"):
ws.fit(tokenize(open(cur_path,error = 'ignore').read().strip()))
ws.build_vocab() #在Work2Sequence中添加一个函数,功能为运行fit_save_word_sequence()。这个函数其实就是加载语料库。
# 对wordSequesnce进行保存,pickle就是保存数据,dump串行化(字符流)保存文件,文件必须是可写状态。load从文件中读出数据。
pickle.dump(ws,open("./model/ws.pkl","wb"))
#2. 在dataset中使用wordsequence
fit_save_word_sequence()
ws = pickle.load(open("./model/ws.pkl", "rb"))
"""
由于在进行文本序列化的操作需要每个文本的长度,所以必须要用lengths去判断是否当前文本长度与MAX_LEN的关系。所以要更改collate_fn函数。
"""
def collate_fn(batch):
MAX_LEN = 500
#MAX_LEN = max([len(i) for i in texts]) #取当前batch的最大值作为batch的最大长度
batch = list(zip(*batch))
labes = torch.tensor(batch[0],dtype=torch.int)
texts = batch[1]
#获取每个文本的长度
lengths = [len(i) if len(i)<MAX_LEN else MAX_LEN for i in texts]
texts = torch.tensor([ws.transform(i, MAX_LEN) for i in texts])
del batch
return labes,texts,lengths
ws = pickle.load(open("./model/ws.pkl","rb"))
#3. 获取输出
dataset = ImdbDataset(ws,mode="train")
dataloader = DataLoader(dataset=dataset,batch_size=20,shuffle=True,collate_fn=collate_fn)
for idx,(label,text,length) in enumerate(dataloader):
print("idx:",idx)
print("table:",label)
print("text:",text)
print("length:",length)
break
在进行代码执行前,需要将ws对象传入ImdbDataset的__init__中,所以__init__的形参改为(self,ws,mode):
建立语料库:
返回结果:
3.构建模型:
使用word embedding方法构建模型。
nn.functional提供Embedding方法,传入参数为词典长度,人为设定的维度数,自然语言处理中常见标识符。这里要把加载数据与wordSequence封装为build_dataset。这里只有一层embedding。将经过embedding处理的数据传入模型,然后返回log_softmax,得到取对数的log_softmax值。
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch import optim
from build_dataset import get_dataloader,ws,MAX_LEN
class IMDBModel(nn.Module):
def __init__(self,max_len):
super(IMDBModel,self).__init__()
self.embedding = nn.Embedding(len(ws),300,padding_idx=ws.PAD) #[N,300]
self.fc = nn.Linear(max_len*300,10) #[max_len*300,10]
def forward(self, x):
embed = self.embedding(x) #[batch_size,max_len,300]
embed = embed.view(x.size(0),-1)
out = self.fc(embed)
return F.log_softmax(out,dim=-1)
4.模型测试:
分为train和test两个函数:
train_batch_size = 128
test_batch_size = 1000
imdb_model = IMDBModel(MAX_LEN)
optimizer = optim.Adam(imdb_model.parameters())
criterion = nn.CrossEntropyLoss()
def train(epoch):
mode = True
imdb_model.train(mode)
train_dataloader =get_dataloader(mode,train_batch_size)
for idx,(target,input,input_lenght) in enumerate(train_dataloader):
optimizer.zero_grad()
output = imdb_model(input)
loss = F.nll_loss(output,target) #traget需要是[0,9],不能是[1-10]
loss.backward()
optimizer.step()
if idx %10 == 0:
print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
epoch, idx * len(input), len(train_dataloader.dataset),
100. * idx / len(train_dataloader), loss.item()))
torch.save(imdb_model.state_dict(), "model/mnist_net.pkl")
torch.save(optimizer.state_dict(), 'model/mnist_optimizer.pkl')
def test():
test_loss = 0
correct = 0
mode = False
imdb_model.eval()
test_dataloader = get_dataloader(mode, test_batch_size)
with torch.no_grad():
for target, input, input_lenght in test_dataloader:
output = imdb_model(input)
test_loss += F.nll_loss(output, target,reduction="sum")
pred = torch.max(output,dim=-1,keepdim=False)[-1]
correct = pred.eq(target.data).sum()
test_loss = test_loss/len(test_dataloader.dataset)
print('\nTest set: Avg. loss: {:.4f}, Accuracy: {}/{} ({:.2f}%)\n'.format(
test_loss, correct, len(test_dataloader.dataset),
100. * correct / len(test_dataloader.dataset)))
if __name__ == '__main__':
test()
for i in range(3):
train(i)
test()