首页 / 资讯中心 / 文章详情

购物评价情感分析:Bert+CNN+RNN+RCNN四模型对比实战

购物评价情感分析:Bert+CNN+RNN+RCNN四模型对比实战 ★ FEATURED ARTICLE
简介这份资源是面向计算机相关专业学生与开发者的购物平台评价情感分析系统源码基于Python与PyTorch实现整合了Bert、CNN、RNN、RCNN、DPCNN、ERNIE等多种深度学习模型可用于毕业设计、课程设计、期末大作业或项目立项演示。压缩包共55个文件以py源码与pyc编译文件为主另含md说明文档及一个zip子包整体约503KB目录中清晰划分了models模型定义、pytorch_pretrained预训练加载、train训练脚本与main入口等模块便于按功能检索与二次开发。目前已有265人学习下载。读者可借此掌握从预训练模型加载、多网络结构对比到情感分类训练评估的完整流程并在此基础上修改网络层、替换数据集或扩展新模型快速搭建属于自己的文本情感分析应用。1. 购物评价情感分析为什么 BertCNNRNNRCNN 四路对比才是靠谱起点做电商的朋友大概率遇到过这种场景运营每天要盯几千条商品评价差评里藏着物流慢、色差、客服态度这些关键信号靠人工翻根本翻不完。购物平台评价情感分析系统要解决的就是这件事——把「好评/差评/中性」自动判出来再按维度聚合给运营看。标题里这套源码把 Bert、CNN、RNN、RCNN 四个模型放在一起做对比这个设计思路其实很务实单跑一个模型你永远不知道它是真的好还是只是在这份数据上碰巧好。Bert 负责抓语义CNN 抓局部 n-gram 特征RNN 抓序列依赖RCNN 把两者串起来四个模型在同一份数据、同一套预处理下横向比谁强谁弱一目了然。这套东西适合两类人一类是想拿现成项目练手 NLP 的 Python 学习者另一类是真的要给自家平台搭评价分析管线的工程师。下面我按「数据怎么进、模型怎么搭、坑在哪、怎么验证」把整条链路拆开讲。2. 数据准备与中文预处理的四个关键动作2.1 评价语料从哪来、怎么清洗购物评价数据的来源无非几种平台导出的 CSV、爬虫抓的 JSON、数据库里的订单评价表。不管哪种第一步都是统一成「文本 标签」两列。标签一般是三分类0 负面、1 中性、2 正面。我一般会先做一轮粗筛把纯表情、纯数字、少于 4 个字的评价丢掉这类样本对模型几乎没信息量还会拉低收敛速度。清洗的核心是去噪而不是去信息。HTML 标签、URL、连续重复字符「好好好好好」要处理但「不」「没」「差」这些否定词一个都不能动。很多人一上来就套停用词表把「不」也删了结果「不好」变成「好」模型直接学反这是血泪经验。import re import pandas as pd def clean_text(text): if not isinstance(text, str): return # 去掉 HTML 标签和 URL text re.sub(r[^], , text) text re.sub(rhttp[s]?://\S, , text) # 连续重复字符压缩比如 好好好好 - 好好 text re.sub(r(.)\1{2,}, r\1\1, text) # 只保留中文、英文、数字和基本标点 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9。、], , text) return text.strip() df pd.read_csv(reviews.csv) df[text] df[text].apply(clean_text) df df[df[text].str.len() 4] df df.dropna(subset[label]) print(df[label].value_counts())这段代码里\1{2,}那个正则要解释一下它匹配「同一个字符连续出现 3 次以上」然后替换成 2 次。为什么要留 2 次而不是 1 次因为「很好」「不错」这种叠词本身有语气强度全压成 1 次会丢情绪强度信息。[^\u4e00-\u9fa5...]这个字符集是保留中文、字母、数字和中文标点其他符号统一变空格避免特殊符号干扰分词。2.2 中文分词与 Bert 分词器的取舍这里有个容易翻车的点CNN、RNN、RCNN 这三个模型通常走「分词 词向量」路线而 Bert 走的是「字级别 WordPiece」路线。如果你强行让四个模型共用一套分词结果Bert 的预训练优势就废了一半。我的做法是分两条预处理管线模型分词方式词表/词向量最大长度CNN/RNN/RCNNjieba 分词自训练 Word2Vec 或预训练词向量128BertBertTokenizerbert-base-chinese 自带词表128长度统一到 128 是个经验值。购物评价 90% 以上在 100 字以内设 128 能覆盖绝大多数样本又不至于让显存爆掉。超过的截断不足的补 PAD。import jieba from transformers import BertTokenizer # 传统模型管线jieba 分词 def tokenize_jieba(text): return .join(jieba.lcut(text)) # Bert 管线字级别 WordPiece bert_tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def encode_bert(text, max_len128): enc bert_tokenizer( text, max_lengthmax_len, paddingmax_length, truncationTrue, return_tensorspt ) return enc[input_ids], enc[attention_mask]paddingmax_length保证一个 batch 里所有样本长度一致truncationTrue处理超长评价。注意 Bert 的attention_mask一定要传给模型否则 PAD 位置也会参与注意力计算效果会明显下降这个坑我见过太多人踩。2.3 标签平衡与数据集划分购物评价天然不平衡好评往往占 70% 以上。直接训练模型会偏向预测好评。常见做法有两种一是对少数类过采样二是用带权重的损失函数。我一般选后者因为过采样容易让模型记住重复样本。from sklearn.model_selection import train_test_split from sklearn.utils.class_weight import compute_class_weight import numpy as np train_df, test_df train_test_split( df, test_size0.2, random_state42, stratifydf[label] ) class_weights compute_class_weight( class_weightbalanced, classesnp.unique(train_df[label]), ytrain_df[label] ) class_weights torch.tensor(class_weights, dtypetorch.float)stratifydf[label]保证训练集和测试集里各类比例一致不然测试集可能全是好评评估结果没有参考价值。compute_class_weight算出来的权重直接塞进CrossEntropyLoss(weightclass_weights)少数类的损失会被放大模型就不敢忽略差评了。3. 四个模型怎么搭从 CNN 到 RCNN 的结构差异与代码3.1 CNN 做文本分类卷积核尺寸怎么选文本 CNN 的核心是用不同尺寸的卷积核去抓不同长度的 n-gram 特征。比如 kernel_size2 抓「物流快」这种双词组合kernel_size3 抓「客服态度差」这种三词组合。一般选 2、3、4 三个尺寸每个尺寸 128 个卷积核最后拼接池化。import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim128, num_classes3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) # 三个不同尺寸的卷积核各 128 个 self.convs nn.ModuleList([ nn.Conv2d(1, 128, (k, embed_dim)) for k in (2, 3, 4) ]) self.dropout nn.Dropout(0.5) self.fc nn.Linear(128 * 3, num_classes) def forward(self, x): # x: [batch, seq_len] x self.embedding(x) # [batch, seq_len, embed_dim] x x.unsqueeze(1) # [batch, 1, seq_len, embed_dim] x [torch.relu(conv(x)).squeeze(3) for conv in self.convs] # 每个卷积结果做全局最大池化 x [torch.max_pool1d(i, i.size(2)).squeeze(2) for i in x] x torch.cat(x, dim1) # [batch, 128*3] x self.dropout(x) return self.fc(x)nn.Conv2d(1, 128, (k, embed_dim))这里卷积核的高度是 k、宽度等于词向量维度相当于在「k 个连续词」这个窗口上做特征提取。max_pool1d取每个特征图的最大值代表「这个 n-gram 特征在句子里出现过的最强信号」。dropout 设 0.5 是文本分类的常规值再高会欠拟合再低容易过拟合。3.2 RNN/LSTM 做文本分类为什么用双向RNN 的优势是能建模长距离依赖比如「虽然物流慢但是质量好」这种转折句前面的「虽然」对后面的判断有影响。单向 RNN 只能看到前文双向 LSTM 能同时看前后文对情感分析这种需要全局语义的任务更合适。class TextRNN(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim128, num_classes3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM( embed_dim, hidden_dim, num_layers2, bidirectionalTrue, batch_firstTrue, dropout0.3 ) self.dropout nn.Dropout(0.5) self.fc nn.Linear(hidden_dim * 2, num_classes) def forward(self, x): x self.embedding(x) out, _ self.lstm(x) # [batch, seq_len, hidden*2] # 取最后一个时间步的输出 out out[:, -1, :] out self.dropout(out) return self.fc(out)bidirectionalTrue让 hidden_dim 翻倍所以全连接层输入是hidden_dim * 2。num_layers2加dropout0.3是防止深层 LSTM 过拟合的常见组合。取out[:, -1, :]是取最后一个时间步也可以用平均池化两者在小数据集上差别不大大数据集上平均池化更稳。3.3 RCNN把 CNN 和 RNN 串起来的混合结构RCNN 的思路是先用双向 RNN 拿到每个词的上下文表示再用 CNN 在这个表示上做最大池化。这样既有 RNN 的序列建模能力又有 CNN 的特征选择能力。class TextRCNN(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim128, num_classes3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM( embed_dim, hidden_dim, num_layers1, bidirectionalTrue, batch_firstTrue ) # 输入是 词向量 前向隐状态 后向隐状态 self.maxpool nn.MaxPool1d(kernel_size128) # 假设 seq_len128 self.fc nn.Linear(hidden_dim * 2 embed_dim, num_classes) def forward(self, x): emb self.embedding(x) # [batch, seq_len, embed_dim] out, _ self.lstm(emb) # [batch, seq_len, hidden*2] # 拼接词向量和双向隐状态 concat torch.cat([emb, out], dim2) # [batch, seq_len, embedhidden*2] concat concat.permute(0, 2, 1) # [batch, feat, seq_len] pooled self.maxpool(concat).squeeze(2) return self.fc(pooled)torch.cat([emb, out], dim2)这一步是 RCNN 的精髓每个词的表示 原始词向量 前向上下文 后向上下文。permute是为了让MaxPool1d在序列维度上池化。注意kernel_size128要和你设定的最大长度一致如果长度变了这里也要改否则会报维度错误。3.4 Bert 微调学习率和 warmup 怎么设Bert 微调最忌讳的就是学习率设太大。预训练模型已经学好了语言表示微调只是让它适配你的任务学习率一般设 2e-5 到 5e-5配合 warmup 让前 10% 的步数慢慢升上去。from transformers import BertForSequenceClassification, AdamW, get_linear_schedule_with_warmup model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels3 ) optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) total_steps len(train_loader) * 3 # 训练 3 轮 scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps )weight_decay0.01是 Bert 微调的标配能抑制过拟合。num_warmup_steps设总步数的 10%这是原论文推荐的。训练轮数 3 轮足够再多容易过拟合尤其是你的评价数据只有几万条的时候。4. 训练、评估与四模型横向对比的实操细节4.1 统一训练循环与早停策略四个模型结构不同但训练循环可以统一。关键是加早停验证集 loss 连续 2 轮不降就停避免过拟合。def train_model(model, train_loader, val_loader, epochs5, patience2): criterion nn.CrossEntropyLoss(weightclass_weights) optimizer torch.optim.Adam(model.parameters(), lr1e-3) best_loss float(inf) wait 0 for epoch in range(epochs): model.train() for batch in train_loader: input_ids, labels batch optimizer.zero_grad() logits model(input_ids) loss criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() # 验证 model.eval() val_loss 0 with torch.no_grad(): for batch in val_loader: input_ids, labels batch logits model(input_ids) val_loss criterion(logits, labels).item() val_loss / len(val_loader) if val_loss best_loss: best_loss val_loss wait 0 torch.save(model.state_dict(), best_model.pt) else: wait 1 if wait patience: print(fEarly stop at epoch {epoch}) breakclip_grad_norm_(max_norm5.0)是防梯度爆炸的RNN 类模型尤其需要。早停的patience2意思是验证 loss 连续 2 轮不降就停这个值设 2 到 3 比较合适设 1 太敏感设 5 又浪费训练时间。4.2 评估指标准确率不够要看 F1三分类任务里准确率会骗人。如果测试集 70% 是好评模型全猜好评也有 70% 准确率。所以必须看每个类别的 F1 和宏平均 F1。from sklearn.metrics import classification_report, confusion_matrix def evaluate(model, test_loader): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for batch in test_loader: input_ids, labels batch logits model(input_ids) preds torch.argmax(logits, dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) print(classification_report(all_labels, all_preds, target_names[负面, 中性, 正面])) print(confusion_matrix(all_labels, all_preds))classification_report会输出每个类别的 precision、recall、f1-score。重点看负面类的 recall因为漏掉差评的代价比误判好评大得多。混淆矩阵能看出模型把哪些类别搞混了比如负面和中性容易混说明模型对情绪强度的区分还不够。4.3 四模型对比结果怎么读四个模型跑完后把结果整理成表模型准确率宏 F1负面类 Recall训练时间/轮TextCNN0.860.830.79快TextRNN0.870.840.81中TextRCNN0.880.850.83中Bert0.920.900.88慢一般规律是 Bert 最强但最慢RCNN 在传统模型里综合最好CNN 最快但长句表现差。如果你的场景要求实时推理CNN 或 RCNN 更合适如果追求精度且能接受 GPU 成本Bert 是首选。这个对比表本身就是这套源码最大的价值——它让你有数据支撑地做选型而不是拍脑袋。5. 避坑与排查情感分析系统上线前必须过的五道坎5.1 现象模型在测试集上 F1 很高上线后差评全漏原因测试集和线上数据分布不一致。测试集是从同一批数据里随机切的但线上新来的评价可能包含新的表达方式、新的商品类型模型没见过。解决定期用线上数据做增量评估发现 F1 下降超过 5% 就触发重新训练。另外在预处理阶段保留一份「未见过词」的统计如果 OOV 比例超过 10%说明词表需要更新。5.2 现象Bert 微调 loss 不降一直卡在 1.0 左右原因学习率设太大模型在预训练权重附近震荡或者attention_mask没传对PAD 位置干扰了注意力。解决先把学习率降到 1e-5 试一轮确认 loss 能降再调回 2e-5。检查attention_mask是否和input_ids一起传给了模型Bert 的 forward 必须同时接收这两个。5.3 现象RNN 训练时 loss 变成 NaN原因梯度爆炸。RNN 在长序列上反向传播时梯度容易累积到很大。解决加clip_grad_norm_(max_norm5.0)把梯度范数限制住。如果还不行把 LSTM 层数从 2 降到 1或者把序列长度从 128 降到 64。5.4 现象四个模型准确率都在 0.7 左右怎么调都不涨原因标签质量问题。购物评价里「还行」「一般」这种中性评价不同人标注结果可能不一样模型学不到一致的模式。解决抽样 200 条检查标签一致性如果发现标注歧义严重要么合并中性类到正面/负面要么制定更细的标注规范重新标。数据质量的天花板就是模型效果的天花板这个坑绕不过去。5.5 现象RCNN 的 MaxPool1d 报维度错误原因kernel_size设成了固定值 128但实际序列长度经过截断或 padding 后不是 128。解决把kernel_size改成动态的concat.size(2)或者确保预处理阶段所有样本都 padding 到 128。我一般选前者更灵活pooled nn.functional.max_pool1d(concat, concat.size(2)).squeeze(2)6. 进阶技巧用对抗验证挑出「最难」的样本做针对性优化模型上线后你会发现大部分样本早就分对了真正拉低指标的是那一小撮「难样本」。与其盲目加数据不如先把难样本找出来。我常用的方法是对抗验证训练一个二分类器去区分「训练集」和「测试集」如果它很难区分说明两个集合分布一致如果很容易区分说明分布有偏移偏移最大的那些样本就是难样本。from sklearn.linear_model import LogisticRegression from sklearn.feature_extraction.text import TfidfVectorizer import numpy as np # 给训练集和测试集打标记 train_df[is_test] 0 test_df[is_test] 1 combined pd.concat([train_df, test_df]) # TF-IDF 向量化 vectorizer TfidfVectorizer(max_features5000) X vectorizer.fit_transform(combined[text]) y combined[is_test] # 对抗验证分类器 clf LogisticRegression(max_iter1000) clf.fit(X, y) # 找出被判定为测试集概率最高的训练样本 probs clf.predict_proba(X)[:, 1] combined[test_prob] probs hard_samples combined[combined[is_test] 0].nlargest(100, test_prob) print(hard_samples[[text, label]].head(10))这段代码的逻辑是如果训练集和测试集分布一致分类器准确率应该接近 0.5瞎猜。如果准确率明显高于 0.5说明两个集合有系统性差异。test_prob最高的训练样本就是「长得像测试集」的样本它们往往包含测试集里常见但训练集里少见的表达方式。把这些样本挑出来人工检查要么补充标注要么调整预处理比无脑加数据有效得多。这个技巧我用了两年多最大的体会是模型效果上不去八成不是模型不够复杂而是数据里有你没发现的结构性问题。对抗验证就是那个帮你把问题照出来的镜子。另外一个小习惯——每次训练完把验证集里预测错的样本导出成 CSV按置信度从低到高排前 50 条挨个看一遍看三轮你就能摸清模型的脾气知道它到底在哪些表达上犯糊涂。这比调参有用得多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站