ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

简介:这份资源是面向计算机相关专业学生与开发者的购物平台评价情感分析系统源码,基于Python与PyTorch实现,整合了Bert、CNN、RNN、RCNN、DPCNN、ERNIE等多种深度学习模型,可用于毕业设计、课程设计、期末大作业或项目立项演示。压缩包共55个文件,以py源码与pyc编译文件为主,另含md说明文档及一个zip子包,整体约503KB,目录中清晰划分了models模型定义、pytorch_pretrained预训练加载、train训练脚本与main入口等模块,便于按功能检索与二次开发。目前已有265人学习下载。读者可借此掌握从预训练模型加载、多网络结构对比到情感分类训练评估的完整流程,并在此基础上修改网络层、替换数据集或扩展新模型,快速搭建属于自己的文本情感分析应用。

1. 购物评价情感分析:为什么 Bert+CNN+RNN+RCNN 四路对比才是靠谱起点

做电商的朋友大概率遇到过这种场景:运营每天要盯几千条商品评价,差评里藏着物流慢、色差、客服态度这些关键信号,靠人工翻根本翻不完。购物平台评价情感分析系统要解决的就是这件事——把「好评/差评/中性」自动判出来,再按维度聚合给运营看。标题里这套源码把 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(r"http[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 或预训练词向量128
BertBertTokenizerbert-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_len=128): enc = bert_tokenizer( text, max_length=max_len, padding="max_length", truncation=True, return_tensors="pt" ) return enc["input_ids"], enc["attention_mask"]

padding="max_length"保证一个 batch 里所有样本长度一致,truncation=True处理超长评价。注意 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_size=0.2, random_state=42, stratify=df["label"] ) class_weights = compute_class_weight( class_weight="balanced", classes=np.unique(train_df["label"]), y=train_df["label"] ) class_weights = torch.tensor(class_weights, dtype=torch.float)

stratify=df["label"]保证训练集和测试集里各类比例一致,不然测试集可能全是好评,评估结果没有参考价值。compute_class_weight算出来的权重直接塞进CrossEntropyLoss(weight=class_weights),少数类的损失会被放大,模型就不敢忽略差评了。

3. 四个模型怎么搭:从 CNN 到 RCNN 的结构差异与代码

3.1 CNN 做文本分类:卷积核尺寸怎么选

文本 CNN 的核心是用不同尺寸的卷积核去抓不同长度的 n-gram 特征。比如 kernel_size=2 抓「物流快」这种双词组合,kernel_size=3 抓「客服态度差」这种三词组合。一般选 2、3、4 三个尺寸,每个尺寸 128 个卷积核,最后拼接池化。

import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_classes=3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # 三个不同尺寸的卷积核,各 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, dim=1) # [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_dim=128, hidden_dim=128, num_classes=3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM( embed_dim, hidden_dim, num_layers=2, bidirectional=True, batch_first=True, dropout=0.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)

bidirectional=True让 hidden_dim 翻倍,所以全连接层输入是hidden_dim * 2。num_layers=2加dropout=0.3是防止深层 LSTM 过拟合的常见组合。取out[:, -1, :]是取最后一个时间步,也可以用平均池化,两者在小数据集上差别不大,大数据集上平均池化更稳。

3.3 RCNN:把 CNN 和 RNN 串起来的混合结构

RCNN 的思路是先用双向 RNN 拿到每个词的上下文表示,再用 CNN 在这个表示上做最大池化。这样既有 RNN 的序列建模能力,又有 CNN 的特征选择能力。

class TextRCNN(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=128, num_classes=3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM( embed_dim, hidden_dim, num_layers=1, bidirectional=True, batch_first=True ) # 输入是 词向量 + 前向隐状态 + 后向隐状态 self.maxpool = nn.MaxPool1d(kernel_size=128) # 假设 seq_len=128 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], dim=2) # [batch, seq_len, embed+hidden*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], dim=2)这一步是 RCNN 的精髓:每个词的表示 = 原始词向量 + 前向上下文 + 后向上下文。permute是为了让MaxPool1d在序列维度上池化。注意kernel_size=128要和你设定的最大长度一致,如果长度变了这里也要改,否则会报维度错误。

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_labels=3 ) optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) total_steps = len(train_loader) * 3 # 训练 3 轮 scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps )

weight_decay=0.01是 Bert 微调的标配,能抑制过拟合。num_warmup_steps设总步数的 10%,这是原论文推荐的。训练轮数 3 轮足够,再多容易过拟合,尤其是你的评价数据只有几万条的时候。

4. 训练、评估与四模型横向对比的实操细节

4.1 统一训练循环与早停策略

四个模型结构不同,但训练循环可以统一。关键是加早停,验证集 loss 连续 2 轮不降就停,避免过拟合。

def train_model(model, train_loader, val_loader, epochs=5, patience=2): criterion = nn.CrossEntropyLoss(weight=class_weights) optimizer = torch.optim.Adam(model.parameters(), lr=1e-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_norm=5.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(f"Early stop at epoch {epoch}") break

clip_grad_norm_(max_norm=5.0)是防梯度爆炸的,RNN 类模型尤其需要。早停的patience=2意思是验证 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, dim=1) 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_norm=5.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_features=5000) X = vectorizer.fit_transform(combined["text"]) y = combined["is_test"] # 对抗验证分类器 clf = LogisticRegression(max_iter=1000) 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 条挨个看一遍,看三轮你就能摸清模型的脾气,知道它到底在哪些表达上犯糊涂。这比调参有用得多。

希望帮到你。

本文还有配套的精品资源,点击获取

返回列表