电商评论情感分类-基于CNN-Pytorch

6 篇文章 0 订阅
3 篇文章 0 订阅

导包

%matplotlib inline

import re
import os
import jieba
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from pylab import rcParams
from matplotlib import rc
from collections import defaultdict
from sklearn.metrics import confusion_matrix, classification_report
from sklearn.model_selection import train_test_split

import torchtext
from torchtext import vocab
from torchtext.legacy import data
from torchtext.legacy import datasets

import torch
from torch.utils.data import random_split
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F


# 固定随机种子
RANDOM_SEED = 2022
np.random.seed(RANDOM_SEED)
torch.manual_seed(RANDOM_SEED)
# 计算硬件
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
device
device(type='cpu')

1 数据读取、预处理、检查、清洗

1.1 数据读取

#读取数据
df_review = pd.read_csv('./data/dataset.csv')
#数据预览
df_review.head()
text\tlabel
0酒店设计有特色,房间很舒服亦很美,位置于南门很方便出入,而且又有得免费上网。前台服务员不错,...
1地理位置不错,闹中取静。房间比较干净,布局合理。但是隔音效果太差了,有住简易客栈的感觉。临水...
2不错,下次还考虑入住。交通也方便,在餐厅吃的也不错。\t正向
3地理位置比较便捷,逛街、旅游、办事都比较方便。老的宾馆新装修改的,房间内的设施简洁、干净,但...
4因为只住了一晚,所以没什么感觉,差不多四星吧。大堂的地砖很漂亮。房间小了一点。\t正向
df_review.iloc[0,0]
'酒店设计有特色,房间很舒服亦很美,位置于南门很方便出入,而且又有得免费上网。前台服务员不错,唯退房时出了点问题,令大打折扣。事缘我们有饮用酒店雪柜内的汽水,但临退房前已经买回一样的饮品放进去,但酒店说汽水的包装不一样,所以必须收我们钱,最终由查房、收钱、拿回我们的汽水花了我二十分钟,刚刚我又要赶车,很气愤!我们一共住了三天,花了千多元,那几元都要和我们收足,很讨厌!\t正向'

1.2 预处理

# 分割text和label
df_review['text'], df_review['label'] = zip(*df_review['text\tlabel'].str.split('\t'))
del df_review['text\tlabel']
df_review.head()
textlabel
0酒店设计有特色,房间很舒服亦很美,位置于南门很方便出入,而且又有得免费上网。前台服务员不错,...正向
1地理位置不错,闹中取静。房间比较干净,布局合理。但是隔音效果太差了,有住简易客栈的感觉。临水...正向
2不错,下次还考虑入住。交通也方便,在餐厅吃的也不错。正向
3地理位置比较便捷,逛街、旅游、办事都比较方便。老的宾馆新装修改的,房间内的设施简洁、干净,但...正向
4因为只住了一晚,所以没什么感觉,差不多四星吧。大堂的地砖很漂亮。房间小了一点。正向
# 标签数值化
cate = pd.Categorical(df_review['label'])
df_review['label'] = cate.codes
df_review.head()
textlabel
0酒店设计有特色,房间很舒服亦很美,位置于南门很方便出入,而且又有得免费上网。前台服务员不错,...0
1地理位置不错,闹中取静。房间比较干净,布局合理。但是隔音效果太差了,有住简易客栈的感觉。临水...0
2不错,下次还考虑入住。交通也方便,在餐厅吃的也不错。0
3地理位置比较便捷,逛街、旅游、办事都比较方便。老的宾馆新装修改的,房间内的设施简洁、干净,但...0
4因为只住了一晚,所以没什么感觉,差不多四星吧。大堂的地砖很漂亮。房间小了一点。0

1.3 检查

# 缺失值检查
df_review.isna().sum()
text     0
label    0
dtype: int64
# 重复值检查
df_review.duplicated().sum()
0
# 标签分布
df_review['label'].value_counts()
0    5000
1    5000
Name: label, dtype: int64
# 解决中文显示问题
plt.rcParams['font.sans-serif']=['STHUPO']
# 解决负号显示问题
plt.rcParams['axes.unicode_minus'] = False

# 画出标签分布
fig,ax = plt.subplots(figsize=(6,4), dpi=80)
sns.countplot(x=df_review.label,  edgecolor="black", alpha=0.7, data=df_review)
sns.despine()
plt.xlabel('标签')
plt.ylabel('数量')
plt.title("标签数量分布")
plt.tight_layout()
for p in ax.patches:
    ax.annotate(f'\n{p.get_height()}', (p.get_x(), p.get_height()+100), color='black', size=10)

图1

# 经检查,数据集中无缺失值与重复值,且标签分布平均,无不平衡情况
# 评论文本长度检查
df_review['text_len']=df_review['text'].map(len)
df_review['text_len'].describe()
count    10000.000000
mean       109.083700
std        126.177622
min          4.000000
25%         34.000000
50%         69.000000
75%        135.000000
max       1985.000000
Name: text_len, dtype: float64
# 解决中文显示问题
plt.rcParams['font.sans-serif']=['STHUPO']
# 解决负号显示问题
plt.rcParams['axes.unicode_minus'] = False

# 画出评论文本长度分布
fig,ax = plt.subplots(figsize=(6,4), dpi=80)
plt.xlabel('长度')
plt.ylabel('数量')
plt.title("文本长度分布")
plt.tight_layout()
plt.hist(df_review['text_len'],bins=10)
(array([8.672e+03, 9.680e+02, 2.440e+02, 7.700e+01, 1.800e+01, 1.600e+01,
        3.000e+00, 0.000e+00, 0.000e+00, 2.000e+00]),
 array([   4. ,  202.1,  400.2,  598.3,  796.4,  994.5, 1192.6, 1390.7,
        1588.8, 1786.9, 1985. ]),
 <BarContainer object of 10 artists>)

图2

# text文本长度大于500的个数
sum(df_review['text_len']>500) 
205
# 以上分析结论,评论文本的长度集中在0~500之间,大于500的非常少

1.4 清洗、分词

#定义删除除字母,数字,汉字以外的所有符号的函数
def remove_pun(line):
    rule = re.compile(u"[^a-zA-Z0-9\u4E00-\u9FA5]")
    line = rule.sub('',line)
    return line

#读取停用词表
stopwords = [line.strip() for line in open('./data/中文停用词库.txt', 'r', encoding='utf-8').readlines()] 
#字符清理
df_review['clean_text'] = df_review['text'].map(lambda x:remove_pun(x))
#结合停用词表进行分词
df_review['cut_text'] = df_review['clean_text'].apply(lambda x: " ".join(w for w in jieba.lcut(x) if w not in stopwords))
Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
Loading model cost 0.622 seconds.
Prefix dict has been built successfully.
df_review.head()
textlabeltext_lenclean_textcut_text
0酒店设计有特色,房间很舒服亦很美,位置于南门很方便出入,而且又有得免费上网。前台服务员不错,...0184酒店设计有特色房间很舒服亦很美位置于南门很方便出入而且又有得免费上网前台服务员不错唯退房时出...酒店设计 特色 房间 舒服 美 位置 南门 出入 免费 上网 前台 服务员 不错 唯 退房 ...
1地理位置不错,闹中取静。房间比较干净,布局合理。但是隔音效果太差了,有住简易客栈的感觉。临水...0119地理位置不错闹中取静房间比较干净布局合理但是隔音效果太差了有住简易客栈的感觉临水的房间风景不...地理位置 不错 闹中取静 房间 干净 布局合理 隔音 效果 太差 住 简易 客栈 感觉 临水...
2不错,下次还考虑入住。交通也方便,在餐厅吃的也不错。026不错下次还考虑入住交通也方便在餐厅吃的也不错不错 下次 入住 交通 餐厅 吃 不错
3地理位置比较便捷,逛街、旅游、办事都比较方便。老的宾馆新装修改的,房间内的设施简洁、干净,但...059地理位置比较便捷逛街旅游办事都比较方便老的宾馆新装修改的房间内的设施简洁干净但宾馆整体建筑设...地理位置 便捷 逛街 旅游 办事 宾馆 新装 修改 房间内 设施 简洁 干净 宾馆 整体 建...
4因为只住了一晚,所以没什么感觉,差不多四星吧。大堂的地砖很漂亮。房间小了一点。039因为只住了一晚所以没什么感觉差不多四星吧大堂的地砖很漂亮房间小了一点只住 一晚 没什么 感觉 四星 大堂 地砖 很漂亮 房间 一点
# 词云展示
from wordcloud import WordCloud,STOPWORDS,ImageColorGenerator
from PIL import Image

background_image = np.array(Image.open("data/pl.jpg"))
fig,ax = plt.subplots(figsize=(12,8), dpi=100)
mytext = ''
for i in range(len(df_review)):
    mytext += df_review['text'][i]
wcloud = WordCloud(width=2400, height=1600,
                   background_color="white",
                   stopwords=stopwords, 
                   font_path="data/STHUPO.TTF",
                   mask = background_image)
wcloud = wcloud.generate(mytext)
plt.imshow(wcloud)
plt.axis('off')

(-0.5, 1023.5, 857.5, -0.5)

图3

2 使用torchtext进行词嵌入

# 切分训练集、验证集、测试集
df_train, df_test = train_test_split(df_review, test_size=0.2, random_state=RANDOM_SEED)
df_val, df_test = train_test_split(df_test, test_size=0.5, random_state=RANDOM_SEED)
df_train.shape, df_val.shape, df_test.shape
((8000, 5), (1000, 5), (1000, 5))
# torchtext的DataSet
class ReviewDataset(data.Dataset):

    def __init__(self, df, fields, is_test=True, **kwargs):
        examples = []
        for i, row in df.iterrows():
            # label = row.label if not is_test else None
            label = row.label 
            text = row.cut_text
            examples.append(data.Example.fromlist([text, label], fields))

        super().__init__(examples, fields, **kwargs)

    @staticmethod
    def sort_key(ex):
        return len(ex.text)

    @classmethod
    def splits(cls, fields, train_df, val_df=None, test_df=None, **kwargs):
        train_data, val_data, test_data = (None, None, None)
        data_field = fields

        if train_df is not None:
            train_data = cls(train_df.copy(), data_field, **kwargs)
        if val_df is not None:
            val_data = cls(val_df.copy(), data_field, **kwargs)
        if test_df is not None:
            test_data = cls(test_df.copy(), data_field, True, **kwargs)

        return tuple(d for d in (train_data, val_data, test_data) if d is not None)
# 转换成torchtext的DataSet
TEXT = data.Field(sequential=True, lower=True, fix_length=500,tokenize=str.split,batch_first=True)
LABEL = data.LabelField(dtype=torch.float)
fields = [('text',TEXT), ('label',LABEL)]
train_ds, val_ds , test_ds= ReviewDataset.splits(fields, train_df=df_train, val_df=df_val, test_df=df_test)
# 看一个随机的例子
print(vars(train_ds[666]))
{'text': ['酒店', '位置', '没得说', '静安寺', '边上', '房间', '里', '一股', '子', '怪味', '窗上', '纱窗', '一夜', '两臂', '苍蝇', '蚊子', '叮出', '大包', '点着', '蚊香', '国营企业', '德行'], 'label': 1}
# 检查类型
print(type(train_ds[666]))
<class 'torchtext.legacy.data.example.Example'>
# 加载预训练词向量
pretrained_name = 'sgns.sogou.word' # 预训练词向量文件名
pretrained_path = './model/' #预训练词向量存放路径
vectors = torchtext.vocab.Vectors(name=pretrained_name, cache=pretrained_path)
  0%|          | 0/364990 [00:00<?, ?it/s]Skipping token b'364990' with 1-dimensional vector [b'300']; likely a header
100%|██████████| 364990/364990 [00:22<00:00, 16418.00it/s]
# 创建vocabulary
MAX_VOCAB_SIZE = 25000

TEXT.build_vocab(train_ds, 
                 max_size = MAX_VOCAB_SIZE, 
                 vectors = vectors,
                 unk_init = torch.Tensor.zero_)

LABEL.build_vocab(train_ds)
# 数据集分批
BATCH_SIZE = 128

train_iterator, valid_iterator, test_iterator = data.BucketIterator.splits((train_ds, val_ds, test_ds),
                                                                           batch_size=BATCH_SIZE,
                                                                           # sort_within_batch = False,
                                                                           device=device)

3 模型构建、编译、训练

3.1 TextCNN模型构建

class TextCNN(nn.Module):

    def __init__(self,
                 class_num,  # 最后输出的种类数
                 filter_sizes,  # 卷积核的长也就是滑动窗口的长
                 filter_num,   # 卷积核的数量
                 vocabulary_size,  # 词表的大小
                 embedding_dimension,  # 词向量的维度
                 vectors,  # 词向量
                 dropout):  # dropout率
        super(TextCNN, self).__init__()  # 继承nn.Module

        chanel_num = 1  # 通道数,也就是一篇文章一个样本只相当于一个feature map

        self.embedding = nn.Embedding(vocabulary_size, embedding_dimension)  # 嵌入层
        self.embedding = self.embedding.from_pretrained(vectors)  # 嵌入层加载预训练词向量

        self.convs = nn.ModuleList(
            [nn.Conv2d(chanel_num, filter_num, (fsz, embedding_dimension)) for fsz in filter_sizes])  # 卷积层
        self.dropout = nn.Dropout(dropout)  # dropout
        self.fc = nn.Linear(len(filter_sizes) * filter_num, class_num)  # 全连接层

    def forward(self, x):
        # x维度[句子长度,一个batch中所包含的样本数] 例:[3451,128]
        # 经过嵌入层之后x的维度,[句子长度,一个batch中所包含的样本数,词向量维度] 例:[3451,128,300]
        x = self.embedding(x)
        # print('1',x.shape)
        # x = x.permute(1,0,2) # permute函数将样本数和句子长度换一下位置,[一个batch中所包含的样本数,句子长度,词向量维度] 例:[128,3451,300]
        # print('2',x.shape)
        # conv2d需要输入的是一个四维数据,所以新增一维feature map数 unsqueeze(1)表示在第一维处新增一维,[一个batch中所包含的样本数,一个样本中的feature map数,句子长度,词向量维度] 例:[128,1,3451,300]
        x = x.unsqueeze(1)
        # 与卷积核进行卷积,输出是[一个batch中所包含的样本数,卷积核数,句子长度-卷积核size+1,1]维数据,因为有[3,4,5]三张size类型的卷积核所以用列表表达式 例:[[128,16,3459,1],[128,16,3458,1],[128,16,3457,1]]
        x = [conv(x) for conv in self.convs]
        # squeeze(3)判断第三维是否是1,如果是则压缩,如不是则保持原样 例:[[128,16,3459],[128,16,3458],[128,16,3457]]
        x = [sub_x.squeeze(3) for sub_x in x]
        x = [F.relu(sub_x) for sub_x in x]  # ReLU激活函数激活,不改变x维度
        # 池化层,根据之前说的原理,max_pool1d要取出每一个滑动窗口生成的矩阵的最大值,因此在第二维上取最大值 例:[[128,16,1],[128,16,1],[128,16,1]]
        x = [F.max_pool1d(sub_x, sub_x.size(2)) for sub_x in x]
        # 判断第二维是否为1,若是则压缩 例:[[128,16],[128,16],[128,16]]
        x = [sub_x.squeeze(2) for sub_x in x]
        x = torch.cat(x, 1)  # 进行拼接,例:[128,48]
        # 去除掉一些神经元防止过拟合,注意dropout之后x的维度依旧是[128,48],并不是说我dropout的概率是0.5,去除了一半的神经元维度就变成了[128,24],而是把x中的一些神经元的数据根据概率全部变成了0,维度依旧是[128,48]
        x = self.dropout(x)
        logits = self.fc(x)  # 全接连层 例:输入x是[128,48] 输出logits是[128,10]
        return logits

3.2 模型编译

class_num = len(LABEL.vocab)  # 类别数目,会生成一个开头占位
filter_size = [2, 3, 4]  # 卷积核种类数
filter_num = 64   # 卷积核数量
vocab_size = len(TEXT.vocab)  # 词表大小
embedding_dim = TEXT.vocab.vectors.size()[-1]  # 词向量维度
vectors = TEXT.vocab.vectors  # 词向量
dropout = 0.5
learning_rate = 0.001  # 学习率
epochs = 5   # 迭代次数
save_dir = './model/'  # 模型保存路径
steps_show = 20   # 每20步查看一次训练集loss和mini batch里的准确率
steps_eval = 100  # 每100步测试一下验证集的准确率
early_stopping = 1000  # 若发现当前验证集的准确率在1000步训练之后不再提高 一直小于best_acc,则提前停止训练

textcnn_model = TextCNN(class_num=class_num,
                        filter_sizes=filter_size,
                        filter_num=filter_num,
                        vocabulary_size=vocab_size,
                        embedding_dimension=embedding_dim,
                        vectors=vectors,
                        dropout=dropout)

class_num
2

3.3 模型训练

# 定义模型训练函数
def train(train_iter, dev_iter, model):

    if torch.cuda.is_available():  # 判断是否有GPU
        model.cuda()

    optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)  # 梯度下降优化器,采用Adam
    steps = 0
    best_acc = 0
    last_step = 0
    model.train()
    for epoch in range(1, epochs + 1):
        for batch in train_iter:
            feature, target = batch.text, batch.label
            # print(target.numpy())
            if torch.cuda.is_available():  # 如果有GPU将特征更新放在GPU上
                feature, target = feature.cuda(), target.cuda()
            
            optimizer.zero_grad()  # 将梯度初始化为0,每个batch都是独立训练地,因为每训练一个batch都需要将梯度归零
            logits = model(feature)
            loss = F.cross_entropy(logits, target.to(torch.int64))  # 计算损失函数 采用交叉熵损失函数
            loss.backward()  # 反向传播
            optimizer.step()  # 放在loss.backward()后进行参数的更新
            steps += 1

            if steps % steps_show == 0:  # 每训练多少步计算一次准确率,我这边是1,可以自己修改
                # logits是[128,10],torch.max(logits, 1)也就是选出第一维中概率最大的值,输出为[128,1],torch.max(logits, 1)[1]相当于把每一个样本的预测输出取出来,然后通过view(target.size())平铺成和target一样的size (128,),然后把与target中相同的求和,统计预测正确的数量
                corrects = (torch.max(logits, 1)[1].view(target.size()).data == target.data).sum()
                train_acc = 100.0 * corrects / batch.batch_size  # 计算每个mini batch中的准确率
                print('steps:{} - loss: {:.6f}  acc:{:.4f}'.format(steps,loss.item(),train_acc))

            if steps % steps_eval == 0:  # 每训练100步进行一次验证
                dev_acc = dev_eval(dev_iter, model)
                if dev_acc > best_acc:
                    best_acc = dev_acc
                    last_step = steps
                    print('Saving best model, acc: {:.4f}%\n'.format(best_acc))
                    save(model, save_dir, steps) # 保存模型
                else:
                    if steps - last_step >= early_stopping:
                        print('\n提前停止于 {} steps, acc: {:.4f}%'.format(last_step, best_acc))
                        raise KeyboardInterrupt


# 定义模型验证函数
def dev_eval(dev_iter, model):
    model.eval()
    corrects, avg_loss = 0, 0
    for batch in dev_iter:
        feature, target = batch.text, batch.label
        if torch.cuda.is_available():
            feature, target = feature.cuda(), target.cuda()
        logits = model(feature)
        loss = F.cross_entropy(logits, target.to(torch.int64))
        avg_loss += loss.item()
        corrects += (torch.max(logits, 1)[1].view(target.size()).data == target.data).sum()
    size = len(dev_iter.dataset)
    avg_loss /= size
    accuracy = 100.0 * corrects / size
    print('\nEvaluation - loss: {:.6f}  acc: {:.4f}%({}/{}) \n'.format(avg_loss, accuracy, corrects, size))
    return accuracy


# 定义模型保存函数
def save(model, save_dir, steps):
    if not os.path.isdir(save_dir):
        os.makedirs(save_dir)
    save_path = 'bestmodel_steps{}.pt'.format(steps)
    save_bestmodel_path = os.path.join(save_dir, save_path)
    torch.save(model.state_dict(), save_bestmodel_path)

# 启动模型训练
train(train_iterator,valid_iterator,textcnn_model)
steps:20 - loss: 0.416524  acc:82.8125
steps:40 - loss: 0.378324  acc:85.1562
steps:60 - loss: 0.327376  acc:86.7188
steps:80 - loss: 0.236540  acc:92.1875
steps:100 - loss: 0.338137  acc:84.3750

Evaluation - loss: 0.002457  acc: 87.5000%(875/1000) 

Saving best model, acc: 87.5000%

steps:120 - loss: 0.243032  acc:89.0625
steps:140 - loss: 0.235221  acc:90.6250
steps:160 - loss: 0.176186  acc:92.9688
steps:180 - loss: 0.214169  acc:92.1875
steps:200 - loss: 0.167112  acc:96.8750

Evaluation - loss: 0.002242  acc: 89.0000%(890/1000) 

Saving best model, acc: 89.0000%

steps:220 - loss: 0.183157  acc:95.3125
steps:240 - loss: 0.110481  acc:97.6562
steps:260 - loss: 0.122785  acc:96.8750
steps:280 - loss: 0.118930  acc:95.3125
steps:300 - loss: 0.132682  acc:95.3125

Evaluation - loss: 0.002152  acc: 89.9000%(899/1000) 

Saving best model, acc: 89.9000%
# 测试集的准确率
dev_eval(test_iterator, textcnn_model)
Evaluation - loss: 0.002377  acc: 87.6000%(876/1000) 






tensor(87.6000)
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值