【NLP理论到实战】09 文本情感分类项目实战

文本情感分类

目标

  1. 知道文本处理的基本方法
  2. 能够使用数据实现情感分类的

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个类别(也可以理解为回归问题,这里当做分类问题考虑)。为了简化问题,我们这里根据正负情感,改为二分类问题。那么根据之前的经验,我们的大致流程如下:

  1. 准备数据集(重写数据集类和准备数据加载类对象、文本序列化)
  2. 构建模型
  3. 模型训练
  4. 模型评估

知道思路之后,那么我们一步步来完成上述步骤


项目完成后整体文件架构图如下:
在这里插入图片描述

3. 准备数据集

准备数据集和之前的方法一样,分为两大步:

  • 第一大步是:实例化dataset,准备dataloader,最终我们的数据可以处理成类似如下图格式(以dataloader中第一个batch为例)。
    在这里插入图片描述
  • 第二大步是:将文本序列化,最终我们的数据可以处理成类似如下第二张图格式(以dataloader中的第一个batch为例)
    在这里插入图片描述

其中有几点需要注意:

  1. 如何完成基础Dataset的构建和Dataloader的准备
  2. 每个batch中文本的长度不一致的问题如何解决
  3. 每个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

思路分析:

  1. 对所有句子进行分词
  2. 词语存入字典,根据次数对词语进行过滤,并统计次数
  3. 实现文本转数字序列的方法
  4. 实现数字序列转文本方法
""" 
二、文本序列化(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,所以模型只有一层,即:

  1. 数据经过word embedding
  2. 数据通过全连接层返回结果,计算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. 模型的训练和评估

训练流程和之前相同

  1. 实例化模型,损失函数,优化器
  2. 遍历dataset_loader,梯度置为0,进行向前计算
  3. 计算损失,反向传播优化损失,更新参数
"""
五、模型的训练和评估(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的使用方法

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值