文章目录
文本情感分类
目标
- 知道文本处理的基本方法
- 能够使用数据实现情感分类的
1. 案例介绍
为了对前面的word embedding这种常用的文本向量化的方法进行巩固,这里我们会完成一个文本情感分类的案例
现在我们有一个经典的数据集IMDB
数据集,地址:http://ai.stanford.edu/~amaas/data/sentiment/,这是一份包含了5万条流行电影的评论数据,其中训练集25000条,测试集25000条。数据格式如下:
上图左边为名称,其中名称包含两部分,分别是序号和情感评分,(1-4为neg,5-10为pos),右边为评论内容
根据上述的样本,需要使用pytorch完成模型,实现对评论情感进行预测
2. 思路分析
首先可以把上述问题定义为分类问题,情感评分分为1-10,10个类别(也可以理解为回归问题,这里当做分类问题考虑)。为了简化问题,我们这里根据正负情感,改为二分类问题。那么根据之前的经验,我们的大致流程如下:
- 准备数据集(重写数据集类和准备数据加载类对象、文本序列化)
- 构建模型
- 模型训练
- 模型评估
知道思路之后,那么我们一步步来完成上述步骤
项目完成后整体文件架构图如下:
3. 准备数据集
准备数据集和之前的方法一样,分为两大步:
- 第一大步是:实例化dataset,准备dataloader,最终我们的数据可以处理成类似如下图格式(以dataloader中第一个batch为例)。
- 第二大步是:将文本序列化,最终我们的数据可以处理成类似如下第二张图格式(以dataloader中的第一个batch为例)
其中有几点需要注意:
- 如何完成基础Dataset的构建和Dataloader的准备
- 每个batch中文本的长度不一致的问题如何解决
- 每个batch中的文本如何转化为数字序列
3.1 基础Imdb_dataset和Imdb_dataloader的准备
"""
一、重写数据集类和准备数据加载类对象(dataset.py)
"""
import torch
from torch.utils.data import Dataset,DataLoader
import os
from utils import tokenize
import config
class ImdbDataset(Dataset): # 1.5重写Imdb数据集类,包括(init方法:获取所有文件路径列表)、(getitem方法:获取索引文件内容)、(len方法:计算文件总数)
def __init__(self,train=True):
root_path = '.\\data\\aclImdb'
root_path = os.path.join(root_path,'train') if train else os.path.join(root_path,'test')
all_father_path = [os.path.join(root_path,'pos'),os.path.join(root_path,'neg')]
self.all_file_path = []
for father_path in all_father_path:
file_paths = [os.path.join(father_path,file_name) for file_name in os.listdir(father_path) if file_name.endswith('.txt')]
self.all_file_path.extend(file_paths)
def __getitem__(self,index):
file_path = self.all_file_path[index]
content = tokenize(open(file_path,encoding='UTF-8').read()) # 1.6获取当前索引文件内容时,需要调用工具包分词过滤函数处理
label = 1 if file_path.split('\\')[-2] == 'pos' else 0
return content,label
def __len__(self):
return len(self.all_file_path)
def collate_fn(batch): # 1.8重写collate_fn方法(zip操作+转换为LongTensor类型操作)
contents,labels = zip(*batch)
# contents = torch.LongTensor([config.ws.transform(content,max_len=config.max_len) for content in contents])
labels = torch.LongTensor(labels)
return contents,labels
def get_dataloader(train=True): # 1.3定义获取dataset和dataloader的函数
Imdb_dataset = ImdbDataset(train=True) # 1.4 调用重写的Imdb数据集类
batch_size = config.train_batch_size if train else config.test_batch_size # 1.7 划分batch大小需要根据训练集还是测试集划分,就对应数字单独写到一个配置包中需要引入
Imdb_dataloader = DataLoader(Imdb_dataset,batch_size=2,shuffle=True,collate_fn=collate_fn) # 1.8获取数据集加载类,并重写参数collate_fn方法
return Imdb_dataloader
if __name__=='__main__': # 1.1测试入口,打印第一个batch结果
for idx,(x,y_true) in enumerate(get_dataloader()): # 1.2调用函数,获取dateloader,取到数据集
print('idx: ',idx)
print('text: ',x)
print('label: ',y_true)
break
"""
工具包:定义文本过滤及分词方法函数(utils.py)
"""
import re
def tokenize(text): # 1.6.1 定义文本过滤及分词函数
filters = ['!', '"', '#', '$', '%', '&', '\(', '\)', '\*', '\+', ',', '-', '\.', '/', ':', ';', '<', '=', '>',
'\?', '@', '\[', '\\', '\]', '^', '_', '`', '\{', '\|', '\}', '~', '\t', '\n', '\x97', '\x96', '”', '“', '<.*?>']
text = re.sub("|".join(filters), " ", text, flags=re.S)
return [word.lower() for word in text.split()]
"""
配置包:用于配置保存常用的常量及模型(config.py)
"""
import pickle
train_batch_size = 2
test_batch_size = 500
# max_len = 50
# ws = pickle.load(open('./model/TextSentiment/ws_norm.pkl'))
输出如下:
idx: 0
text: (['i', 'really', 'like', 'this', 'film', 'when', 'i', 'started', 'to', 'watch', 'it', 'i', 'thought', 'i', 'would', 'get', 'bored', 'pretty', 'soon', 'but', 'it', 'surprised', 'me', 'i', 'thought', 'it', 'was', 'a', 'great', 'film', 'and', 'have', 'seen', 'it', 'a', 'few', 'times', 'now', 'the', 'characters', 'are', 'believable', 'and', 'i', 'have', 'to', 'say', 'that', 'i', 'fell', 'in', 'love', 'with', 'brian', 'austin', 'green', 'all',
'over', 'again', 'the', 'first', 'time', 'being', 'beverly', 'hills', '90210', 'i', 'would', 'recommend', 'this', 'film', 'if', 'you', 'are', 'a', 'fan', 'of', 'his', 'but', 'i', 'do', 'agree', 'with', 'another', 'comment', 'made', 'earlier', 'that', 'the', 'ending', 'is', 'sort', 'of', 'disappointing', 'i', 'would', 'have', 'loved', 'it', 'to', 'turn', 'out', 'a', 'little', 'different', 'never', 'mind', 'though', 'good', 'gripping', 'story'], ["it's", 'been', 'a', 'while', 'since', "i've", 'watched', 'this', 'movie', 'and', 'the', 'series', 'but', 'now', "i'm", 'refreshing', 'my', 'memory', 'this', 'was', 'a', 'very', 'funny', 'movie', 'based', 'on', 'the', 'classic', 'series', 'johnny', 'knoxville', 'and', 'seann', 'william', 'scott', 'were', 'hilarious', 'together', 'bo', 'and', 'luke', 'duke', 'help', 'uncle', 'jesse', 'run', 'moonshine', 'in', 'the', 'general', 'lee', 'when', 'boss', 'hogg', 'forces', 'the', 'dukes', 'off', 'their', 'farm', 'bo', 'and', 'luke', 'sneak', 'around', "hogg's", 'local', 'construction', 'site', 'and', 'find', 'samples', 'of', 'coal', 'they', 'soon', 'realize', 'that', 'boss', 'hogg', 'is', 'gonna', 'strip', 'mine', 'hazzard', 'county', 'unless', 'the', 'dukes', 'can', 'stop', 'him', 'with', 'the', 'help', 'of', 'their', 'beautiful', 'cousin', 'daisy', 'my', 'only', 'two', 'problems', 'with', 'the', 'movie', 'was', 'that', 'burt', 'reynolds', "wasn't", 'right', 'for', 'the', 'part', 'of', 'boss', 'hogg', 'and', 'sheriff', 'rosco', 'p', 'coltrane', 'was', 'way', 'too', 'serious', 'other', 'than', 'that', 'i', 'highly', 'recommend', 'the', 'dukes', 'of', 'hazzard'])
label: tensor([1, 1])
注:
我们在调用Dataloader类中重写了参数方法collate_fn
,如果不重写的话结果报错如下RuntimeError: each element in list of batch should be of equal size
理由如下:
collate_fn
的默认值为torch自定义的default_collate
,collate_fn
的作用就是对每个batch进行处理,而默认的default_collate
递归调用自己时第二次处理时出现批处理中元素大小不一致出错。查询源码发现错误出现在这里:
解决问题的思路:
手段1:考虑先把数据转化为数字序列,观察其结果是否符合要求,之前使用DataLoader并未出现类似错误
手段2:考虑自定义一个collate_fn
,观察结果
这里使用方式2,自定义一个collate_fn
,然后观察结果:#注意:参数batch是list,其中每项是一个元组,每个元组是dataset中__getitem__的结果,即每个元组为([token,label],[token,label],...,[token,label]) def collate_fn(batch): # 1.8重写collate_fn方法(zip操作+转换为LongTensor类型操作) contents,labels = zip(*batch) # contents = torch.LongTensor(ws.transform(content,max_len=max_len) for content in contents) labels = torch.LongTensor(labels) return contents,labels
3.2 文本序列化
再介绍word embedding的时候,我们说过,不会直接把文本转化为向量,而是先转化为数字,再把数字转化为向量,那么这个过程该如何实现呢?
这里我们可以考虑把文本中的每个词语和其对应的数字,使用字典保存,同时实现方法把句子通过字典映射为包含数字的列表。
实现文本序列化之前,考虑以下几点:
1. 如何使用字典把词语和数字进行对应?
- 答:遍历一遍文本,把每个词对应一个数字
2. 不同的词语出现的次数不尽相同,是否需要对高频或者低频词语进行过滤,以及总的词语数量是否需要进行限制?
- 答:过滤掉低频和高频词,限制选最大的前1w个词语
3. 得到词典之后,如何把句子转化为数字序列,如何把数字序列转化为句子
- 答:实现两个函数,分别是句子->数字序列,数字序列->句子
4. 不同句子长度不相同,每个batch的句子如何构造成相同的长度(可以对短句子进行填充,填充特殊字符)
- 答:长句子裁剪,短句子填充。填充词PAD对应数字1
5. 对于新出现的词语在词典中没有出现怎么办(可以使用特殊字符代理)
- 答:训练时用的是训练集中的所有词语,测试的时用的是另外一些句子,出现测试时没有的一些词,用未出现词用UNK代替对应数字0
思路分析:
- 对所有句子进行分词
- 词语存入字典,根据次数对词语进行过滤,并统计次数
- 实现文本转数字序列的方法
- 实现数字序列转文本方法
"""
二、文本序列化(word2sequence.py)
"""
class Word2Sequence: # 1.定义文本转序列类,包含六个方法:(init方法:初始化词-序列字典和词频字典)、(fit方法:统计词频得到词频字典)、(build_vocab方法:由全部文本和条件构造词-序列字典和序列-词字典)、(transform方法:将一个文本转化为数字序列)、(inverse_transform方法:将一个数字序列转化为文本)、(len方法:统计词-序列字典的长度)
UNK_TAG = '<UNK>' # 表示未知字符
PAD_TAG = '<PAD>' # 表示填充符
UNK = 0 # 未知字符对应数字序列中的数字
PAD = 1 # 填充字符对应数字序列中的数字
def __init__(self): # 1.1 init方法:初始化词-序列字典和词频字典
self.wordToSequence_dict = { # 初始化词—序列字典
self.UNK_TAG:self.UNK,
self.PAD_TAG:self.PAD
}
self.count_dict = {} # 初始化词频字典
def fit(self,text): # 1.2 fit方法:统计所有文本的词频得到词频字典
for word in text: # 构造词频字典
self.count_dict[word] = self.count_dict.get(word,0)+1
def build_vocab(self,min_count=None,max_count=None,max_features=None): # 1.3 build_vocab方法:由全部文本和条件构造词-序列字典和序列-词字典
if min_count is not None:
self.count_dict = {word:count for word,count in self.count_dict.items() if count>=min_count}
if max_count is not None:
self.count_dict = {word:count for word,count in self.count_dict.items() if count<=max_count}
if max_features is not None: # key=lambda x: x[-1] 为对前面对象中最后一维数据(即value)的值进行排序。
self.count_dict = dict(sorted(self.count_dict.items(),key=lambda x: x[-1],reverse=True)[:max_features])
for word in self.count_dict: # 将词频字典中的每一个词依次递增转为数字,形成所有文本词的词-序列字典
self.wordToSequence_dict[word] = len(self.wordToSequence_dict)
self.sequenceToWord_dict= dict(zip(self.wordToSequence_dict.values(),self.wordToSequence_dict.keys())) # 反转得到所有词文本的序列-词字典
def transform(self,text,max_len=None): # 1.4 transform方法:将一个文本转化为数字序列
if max_len is not None:
if len(text)>max_len:
text = text[:max_len]
else:
text = text + [self.PAD_TAG] * (max_len-len(text))
return [self.wordToSequence_dict.get(word,self.UNK) for word in text]
def inverse_transform(self,sequence): # 1.5 inverse_transform方法:将一个数字序列转化为文本
return [self.sequenceToWord_dict.get(num,self.UNK_TAG) for num in sequence]
def __len__(self): # 1.6 len方法:统计词-序列字典的长度)
return len(self.wordToSequence_dict)
if __name__=='__main__': # 测试入口,模拟字典的构建及转换效果
one_batch_text = (['今天','菜','很','好'],['今天','去','吃','什么']) # 模拟一个batch的text
ws = Word2Sequence() # 初始化文本转序列类示例
for text in one_batch_text: # 遍历所有文本构建词频字典
ws.fit(text)
ws.build_vocab(max_features=6) # 利用传入限制条件的词频字典构建所有词文本的词-序列字典
print(ws.wordToSequence_dict)
new_text = ['去','吃','什么','菜','好','不','好','呀']
result1 = ws.transform(new_text,max_len=10)
result2 = ws.inverse_transform(result1)
print(result1)
print(result2)
输出如下:
{'<UNK>': 0, '<PAD>': 1, '今天': 2, '菜': 3, '很': 4, '好': 5, '去': 6, '吃': 7}
[6, 7, 0, 3, 5, 0, 5, 0, 1, 1]
['去', '吃', '<UNK>', '菜', '好', '<UNK>', '好', '<UNK>', '<PAD>', '<PAD>']
3.3 构建保存数据集的字典
完成了
word2sequence
之后,接下来就是保存现有样本中的数据字典,方便后续的使用。
"""
三、主函数,即整合前两大步骤:构建训练集和测试集dataloader中所有batch的text的字典,并保存为模型ws.pkl(main.py)
"""
from dataset import get_dataloader
from word2sequence import Word2Sequence
import pickle
from tqdm import tqdm
if __name__=='__main__':
ws = Word2Sequence()
train_dataloader = get_dataloader(train=True)
test_dataloader = get_dataloader(train=False)
for one_batch_text,labels in tqdm(train_dataloader):
for text in one_batch_text:
ws.fit(text)
for one_batch_text,labels in tqdm(test_dataloader):
for text in one_batch_text:
ws.fit(text)
ws.build_vocab()
print(len(ws))
pickle.dump(ws,open('.\\model\\TextSentiment\\ws_norm.pkl','wb')) # 构建完整个字典后,保存实例化对象成文件
输出如下
100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 12500/12500 [02:58<00:00, 70.06it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 12500/12500 [00:09<00:00, 1379.22it/s]
87991
最后我们:
- 1、添加配置包
config.py
中的最大序列长度max_len和应该加载的模型ws_norm:""" 配置包:用于配置保存常用的常量及模型(config.py) """ import pickle train_batch_size = 512 test_batch_size = 500 max_len = 50 ws = pickle.load(open('./model/TextSentiment/ws_norm.pkl'))
- 2、去掉数据处理文件
dataset.py
中的# contents = torch.LongTensor([config.ws.transform(content,max_len=config.max_len) for content in contents])
的注释运行
dataset.py
,实现对IMDB数据的序列化处理,输出如下:
idx: 0
text: tensor([[ 105, 106, 12, 1506, 4671, 3611, 380, 441, 1460, 9,
112, 2882, 6788, 19, 20, 7523, 4284, 9, 17806, 971,
7770, 1654, 7719, 81, 77, 9, 463, 106, 43, 43,
1244, 201, 58, 308, 105, 1027, 14324, 112, 151, 2824,
20403, 764, 351, 9, 993, 14950, 31, 20, 7523, 4284],
[ 4948, 7, 23269, 70, 791, 16489, 34, 2740, 12, 814,
20, 1719, 1061, 19, 12, 23020, 3903, 7, 2266, 37,
19208, 2359, 31, 3357, 16, 7615, 36336, 124, 20, 149,
474, 12, 7528, 23895, 2139, 6175, 796, 12, 8458, 3682,
7, 1618, 20, 5947, 19, 20, 3478, 22, 179, 591]])
label: tensor([1, 1])
思考:前面我们自定义了MAX_LEN作为句子的最大长度,如果我们需要把每个batch中的最长的句子长度作为当前batch的最大长度,该如何实现?
4. 构建模型
这里我们只练习使用word embedding,所以模型只有一层,即:
- 数据经过word embedding
- 数据通过全连接层返回结果,计算
log_softmax
"""
四、构建模型(model.py)
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
import config
class NormImdbModel(nn.Module):
def __init__(self):
super(NormImdbModel,self).__init__()
self.emb = nn.Embedding(num_embeddings=len(config.ws),embedding_dim=300) # word embedding操作,将每次词随机初始化嵌入为词向量
self.fc = nn.Linear(config.max_len*300,2) # 通过一个简单的全连接层进行学习
def forward(self,input): # input.size():[512, 50]
x = self.emb(input) # x.size():[512, 50, 300]
x = x.view([-1,config.max_len*300]) # x.size():[512, 15000]
out = self.fc(x) # out.size():[512,2]
return F.log_softmax(out,dim=-1)
5. 模型的训练和评估
训练流程和之前相同
- 实例化模型,损失函数,优化器
- 遍历dataset_loader,梯度置为0,进行向前计算
- 计算损失,反向传播优化损失,更新参数
"""
五、模型的训练和评估(train_test.py)
"""
from model import NormImdbModel
import torch
import torch.nn.functional as F
from dataset import get_dataloader
import os
import numpy as np
Imdb_model = NormImdbModel()
optimizer = torch.optim.Adam(Imdb_model.parameters(),lr=1e-3)
if os.path.exists('./model/TextSentiment/imdb_norm_model.pkl'):
Imdb_model.load_state_dict(torch.load('./model/TextSentiment/imdb_norm_model.pkl'))
optimizer.load_state_dict(torch.load('./model/TextSentiment/imdb_norm_optimizer.pkl'))
def train(epoch):
train_dataloader = get_dataloader(train=True)
for idx,(x,y_true) in enumerate(train_dataloader):
y_predict = Imdb_model(x)
loss = F.nll_loss(y_predict,y_true)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if not(idx % 10):
print('Train epoch:{} \t idx:{:>3} \t loss:{}'.format(epoch,idx,loss.item()))
if not(idx % 20):
torch.save(Imdb_model.state_dict(), './model/TextSentiment/imdb_norm_model.pkl')
torch.save(optimizer.state_dict(), './model/TextSentiment/imdb_norm_optimizer.pkl')
def test():
loss_list = []
acc_list = []
Imdb_model.eval()
test_dataloader = get_dataloader(train=False)
for idx, (x, y_true) in enumerate(test_dataloader):
with torch.no_grad():
y_predict = Imdb_model(x)
cur_loss = F.nll_loss(y_predict, y_true)
pred = y_predict.max(dim=-1)[-1]
cur_acc = pred.eq(y_true).float().mean()
loss_list.append(cur_loss)
acc_list.append(cur_acc)
print(np.mean(acc_list), np.mean(loss_list))
if __name__=='__main__':
test()
for i in range(2):
train(i)
test()
输出结果如下:
0.49983996 0.7704513 Train epoch:0 idx: 0 loss:0.7502627372741699 Train epoch:0 idx: 10 loss:0.8134662508964539 Train epoch:0 idx: 20 loss:0.7971565127372742 Train epoch:0 idx: 30 loss:0.8451452255249023 Train epoch:0 idx: 40 loss:0.7865151166915894 0.78380007 0.46087968 Train epoch:1 idx: 0 loss:0.4794747829437256 Train epoch:1 idx: 10 loss:0.4958028495311737 Train epoch:1 idx: 20 loss:0.49108320474624634 Train epoch:1 idx: 30 loss:0.5427263975143433 Train epoch:1 idx: 40 loss:0.5083655714988708 0.86660004 0.33511993
这里我们仅仅使用了一层全连接层,其分类效果不会很好,但这里重点是理解常见的模型流程和word embedding的使用方法