☰
中文情感分析实战:TextCNN与Bi-LSTM对比实现(PyTorch)
2026/10/9 6:34:58 网站建设 项目流程

简介:一份基于Python的中文情感分析完整源码项目,聚焦卷积神经网络与双向长短期记忆网络文本分类模型,适合毕业设计、课程设计或期末大作业使用。源码附带详细注释,即使新手也能快速理解模型构建、训练与预测流程;项目说明提到个人手打九十八分、导师认可,部署简单、运行稳定。压缩包共三十五个文件,约73.2MB,主要内容包括十三个Python源码文件(模型训练、预测及评分脚本)、十份文本说明、两份模型权重、两份训练数据、若干图片与索引文档等,可满足从数据读取到模型评估的完整链路。目前已有七十四人学习下载,适用于需要快速复现中文情感分析实验,或希望改进模型架构的学习者。项目中含酒店评论数据、模型目录及评分报告模块,可直接对照运行,节省搭建环境与调试时间。

1. 中文情感分析毕设的真相:CNN与Bi-LSTM不是二选一,是两组对照实验

毕业论文答辩现场最常被问的一句话,我猜是:“你的CNN和Bi-LSTM,到底差在哪?”而“基于python的中文情感分析源码+项目说明(CNN,BI-LSTM,文本分类)”这类高分毕设,课题设计上就已经把答案写进了题目里——用两种结构完全不同的深度学习模型在同一个中文文本分类任务上做对比,最后用指标、曲线和案例说清楚谁在什么条件下更强。它解决的是带具体诉求来的:评论是好评还是差评,弹幕是积极还是消极,舆情是正面还是负面。适合正在准备毕设的学生,也适合想快速跑通一套文本分类基线再替换成预训练模型的工程新人。这个方向真正考验人的不是“能跑”,而是能不能把清洗、词表、训练、对比实验和项目说明串成一条别人能复现的完整链路。

2. 从评论文本到模型输入:中文语料清洗、分词与词表构建

2.1 先拿到一份能用的语料:清洗比选模型更影响结果

常见做法是先用公开的中文情感语料跑通流程,比如酒店评论语料和微博情绪数据,这类数据集网上能找到很多整理好的版本,标签通常为0(负向)和1(正向)。如果自己爬电商评论,字段一般长这样:id、content、label。我一般会先用pandas读进来,看一眼缺失值和标签分布,再决定清洗策略。这个环节看起来和深度学习无关,但它对最终指标的贡献往往大于后面换模型结构。

# data_prepare.py import pandas as pd df = pd.read_csv("reviews.csv", encoding="utf-8-sig") print("数据集大小:", len(df)) print("标签分布:\n", df["label"].value_counts()) # 去空:content为空的样本不能参与训练 df = df.dropna(subset=["content"]) # 去重:同一句话被爬虫抓到多次,不去重会造成训练/验证重叠 df = df.drop_duplicates(subset=["content"], keep="first") # 标签对齐:确保label是0和1,而不是字符串 df["label"] = df["label"].astype(int) df = df[df["label"].isin([0, 1])].reset_index(drop=True) print("清洗后大小:", len(df))

这里去重是最容易忽略的一步,它的意义不只是减少数据量,更重要的是避免同一句话同时出现在训练集和验证集里。那种“验证集准确率99%,一上线就露馅”的翻车现场,多半是去重没做干净。标签分布如果极端,比如负向只占5%,后面要么做分层抽样,要么在损失函数里给少数类加权,不然模型会无脑输出多数类。

2.2 分词、停用词与序列化:把中文变成模型能吃的索引

中文没有天然空格,分词是绕不开的步骤。毕设里最常用的工具依然是jieba,配合一个停用词表把“的、了、吗、呢”这类高频无意义词过滤掉。这里有个血泪经验:停用词过滤对深度学习模型不一定总是正向收益,尤其对短文本,去掉太多会让句子变得支离破碎。我的默认做法是先不滤停用词跑一版,再在对比实验里试一版过滤的,让数据说话。

# tokenize.py import jieba import re def clean_text(text: str) -> str: text = str(text) text = re.sub(r"<.*?>", "", text) # 去html标签 text = re.sub(r"http\S+|www\.\S+", "", text) # 去url text = re.sub(r"\d+", " ", text) # 数字归一 text = re.sub(r"\s+", " ", text) return text.strip() def tokenize(text: str) -> list[str]: words = jieba.lcut(clean_text(text)) return [w for w in words if w.strip() and w != " "] df["tokens"] = df["content"].apply(tokenize) print(df["tokens"].head())

这里的分词结果是后续所有模型的共同输入,CNN和Bi-LSTM都基于同一份tokens做实验,这样模型对比才有意义。jieba的默认词典覆盖不了所有领域词汇,如果语料是美妆评论,建议把“熬夜霜、零毛孔”这类领域词加进自定义词典,分词质量会直接影响模型下界。clean_text里的正则可以根据自己的语料扩充,比如把emoji、@用户、#话题#都处理掉。

2.3 词表、padding与截断:固定max_len的3个判断依据

模型输入要求定长,所以每个句子都要转成索引序列,再按固定长度截断或补零。max_len怎么定?常见的做法是看分词后句子长度的分位数,我一般取P95,也就是95%的样本在这个长度以内。取太小会丢失信息,取太大batch里全是零填充,浪费显存还拖慢训练。这里用PyTorch实现一个简单的pad函数,不依赖TensorFlow。

# build_vocab.py import numpy as np import torch from collections import Counter all_tokens = [t for tokens in df["tokens"] for t in tokens] vocab_counter = Counter(all_tokens) vocab = {word: i + 2 for i, (word, _) in enumerate(vocab_counter.most_common(50000))} vocab["<PAD>"] = 0 vocab["<UNK>"] = 1 def encode(tokens: list[str]) -> list[int]: return [vocab.get(w, 1) for w in tokens] df["encoded"] = df["tokens"].apply(encode) def pad_sequences_torch(sequences, max_len, pad_value=0): padded = [] for seq in sequences: seq = seq[:max_len] if len(seq) < max_len: seq = seq + [pad_value] * (max_len - len(seq)) padded.append(seq) return torch.tensor(padded, dtype=torch.long) max_len = int(np.percentile(df["encoded"].apply(len), 95)) print("max_len:", max_len) X = pad_sequences_torch(df["encoded"].tolist(), max_len=max_len, pad_value=0) y = torch.tensor(df["label"].to_numpy(), dtype=torch.long) print("X shape:", X.shape, "y shape:", y.shape)

词表大小限制了Embedding层的参数规模,50000个词基本能覆盖大多数评论语料,低频词统一落到 。 占0、 占1是惯例,训练时padding位置不会参与有效计算。这里的关键是先统计词表再编码,后面避坑章会回头展开这个细节。词表只能从训练集构建,这是文本分类项目里最容易犯的规范错误。

2.4 随机词向量还是预训练词向量:Bi-LSTM比CNN更挑向量

Bi-LSTM对词向量的质量比CNN更敏感。词向量有两种落地路径:一是Embedding层随机初始化、随模型一起训练,实现简单、在小数据集上容易过拟合;二是加载预训练好的中文词向量做初始化,再决定冻结或微调。常见的预训练资源是腾讯AI Lab公开的中文词向量,也可以用自己的语料训练Word2Vec,后者对领域词汇覆盖更好。

# word2vec.py(选做,建议数据量大于2万条时再试) from gensim.models import Word2Vec sentences = [t for t in df["tokens"]] w2v = Word2Vec(sentences=sentences, vector_size=200, window=5, min_count=2, workers=4, epochs=10) w2v.save("w2v.model") # 把向量转成embedding矩阵 embedding_matrix = np.random.uniform(-0.05, 0.05, (len(vocab), 200)) for word, idx in vocab.items(): if word in w2v.wv: embedding_matrix[idx] = w2v.wv[word] print("词向量命中率:", np.mean(np.sum(embedding_matrix != 0, axis=1) > 0))

如果语料只有几千条,Word2Vec训练出来的词向量其实没什么优势,随机初始化加一个不错的dropout反而更稳。预训练向量真正的价值是在词表量级大、句子覆盖不全的场景,比如几十万条电商评论里出现的新词。embedding_matrix的行数必须和len(vocab)一致,否则加载到torch.nn.Embedding时维度对不上。这里我建议给毕设留两个版本:基础版用随机初始化,增强版加载Word2Vec,对比表里写清楚。

3. 用TextCNN跑通中文情感分类:卷积核尺寸、池化与调参基线

3.1 为什么先选TextCNN,而不是直接上Bi-LSTM

TextCNN的思路是把句子当一维图像,用不同尺寸的卷积核去扫相邻词的组合。比如卷积核尺寸为3,每次看三个连续词,相当于捕捉“不太好”“性价比高”这类局部n-gram特征;尺寸为5能看得更宽一点。中文情感分类里很多信号就是局部词组合,所以TextCNN在短文本上收敛快、训练稳定、对显存要求低,作为毕设第一个模型非常合适。它比TF-IDF+SVM强的点在于Embedding能学到词语义,但解决不了长距离依赖,这在第4章会形成对比。

3.2 PyTorch实现TextCNN:完整代码与逐行说明

代码用PyTorch实现,X来自上一章pad_sequences_torch的输出,形状是(batch, seq_len),num_classes取2。vocab_size就是len(vocab),embedding_dim用来控制词向量的维度。

# textcnn.py import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embedding_dim, num_filters, filter_sizes, num_classes, dropout=0.5, pad_idx=0): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=pad_idx) # 每个卷积核尺寸对应一个Conv2d,卷积核形状是(size, embedding_dim) self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (size, embedding_dim), padding=(size // 2, 0)) for size in filter_sizes ]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(len(filter_sizes) * num_filters, num_classes) def forward(self, x): # x: (batch, seq_len) emb = self.embedding(x) # (batch, seq_len, embedding_dim) emb = emb.unsqueeze(1) # (batch, 1, seq_len, embedding_dim) conv_out = [] for conv in self.convs: c = torch.relu(conv(emb)) # (batch, num_filters, seq_len, 1) c = c.squeeze(3) # (batch, num_filters, seq_len) c = torch.max_pool1d(c, c.size(2)).squeeze(2) # global max pooling conv_out.append(c) out = torch.cat(conv_out, dim=1) out = self.dropout(out) return self.fc(out)

四个关键参数这里要给足。embedding_dim通常取100或200,小语料取100,有预训练词向量时直接用对应维度。num_filters是每种卷积核的通道数,常用100或128,翻倍效果不一定翻倍,但显存和训练时间会明显上升。filter_sizes是最值得调的参数,短文本常用[2,3,4],数据量大的时候试[3,4,5],尺寸越大看到的词组越宽。padding=(size//2,0)是为了让卷积后seq_len不变,height维度即embedding_dim被卷积后高度变成1,所以卷积核写成(size, embedding_dim)直接把整个词向量维度卷掉。

这里用max_pooling取出每个窗口最显著的特征,再把不同尺寸的池化结果拼起来进全连接。embedding层里把pad_idx传进去,padding位置就不会参与反向传播,这是很多初版代码忽略的细节。

3.3 TextCNN的训练封装:随机种子、验证集F1与模型保存

# train_utils.py import random import numpy as np import torch import torch.nn as nn def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) def evaluate(model, val_loader, device="cuda"): model.eval() preds, gts = [], [] with torch.no_grad(): for batch_x, batch_y in val_loader: preds.extend(model(batch_x).argmax(1).tolist()) gts.extend(batch_y.tolist()) from sklearn.metrics import f1_score return f1_score(gts, preds, average="binary") def train_model(model, train_loader, val_loader, epochs=20, lr=1e-3, device="cuda"): optimizer = torch.optim.Adam(model.parameters(), lr=lr) criterion = nn.CrossEntropyLoss() best_f1, best_state = 0, None for epoch in range(epochs): model.train() total_loss, correct = 0, 0 for batch_x, batch_y in train_loader: optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() optimizer.step() total_loss += loss.item() * batch_x.size(0) correct += (logits.argmax(1) == batch_y).sum().item() val_f1 = evaluate(model, val_loader, device) if val_f1 > best_f1: best_f1 = val_f1 best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()} print(f"epoch {epoch+1}, loss {total_loss / len(train_loader.dataset):.4f}, val_f1 {val_f1:.4f}") model.load_state_dict(best_state) return model

关键决策是“按验证集F1保存模型”而不是按最后一个epoch保存。TextCNN训练到第五六个epoch时往往已经够了,继续训练会过拟合,val准确率不再上升甚至下滑。你也可以在连续3个epoch没有提升时直接break,但保存best_state是最简单的后悔药。evaluate里用sklearn的f1_score,二分类用average="binary",多分类记得改成"macro"。

3.4 一组能直接跑的TextCNN配置:参数从哪来

给一组能直接复现的配置,前提是你的语料是电商或酒店短评,句子长度不大。max_len取上一章算出的P95,一般80到120之间;vocab_size保持50000;embedding_dim取100;filter_sizes取[2,3,4];num_filters取128;dropout取0.5;batch_size取128;lr取1e-3;epochs取20并且配合best_state保存。这组配置在几千条数据上通常能把验证集F1跑到87到92之间,单张普通显卡训练时间不超过10分钟,CPU也能跑但会慢很多。如果你的数据是长文本,比如一篇商品详情,max_len要提高到200以上,否则长尾信息全被截断掉了。

4. 用Bi-LSTM做中文情感分类:双向上下文与最后的分类头

4.1 Bi-LSTM在文本分类里解决什么

CNN看的是局部窗口,Bi-LSTM会从句子开头一路读到结尾,再反向读一遍,让每个位置同时携带前文和后文信息。在中文情感分析里,“虽然服务态度很好,但是上菜太慢”这类转折句,前半个分句是正向,后半个才是真正的情绪,单向LSTM读到“很好”时会放大正向信号,Bi-LSTM反向通行时能看到“上菜太慢”,最终分类结果更稳。这也是Bi-LSTM比CNN在长文本上更有潜力的原因。

4.2 PyTorch实现Bi-LSTM:embedding、双向层与最后一个有效位置

# bilstm.py class BiLSTM(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_layers, num_classes, dropout=0.5, pad_idx=0): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=pad_idx) self.lstm = nn.LSTM(embedding_dim, hidden_size, num_layers, bidirectional=True, batch_first=True, dropout=dropout) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_size * 2, num_classes) def forward(self, x): emb = self.embedding(x) # (batch, seq_len, embedding_dim) out, _ = self.lstm(emb) # (batch, seq_len, hidden_size*2) out = self.dropout(out) # 取最后一个有效位置而不是最后一个padding位置 lengths = (x != 0).sum(dim=1) # (batch,) idx = (lengths - 1).unsqueeze(1).unsqueeze(2) # (batch, 1, 1) last_mask = idx.expand(-1, -1, out.size(2)) last = out.gather(1, last_mask).squeeze(1) # (batch, hidden_size*2) return self.fc(last)

这段代码最值得说的是取最后一个有效位置的处理。如果用out[:, -1, :],拿到的是整个句子padding结束后 位置的隐层,模型就被带偏了。lengths = (x != 0).sum(dim=1)拿到每个样本真实长度,再用gather取出最后一个真实词的隐层向量。这是Bi-LSTM实践里最容易被忽略的坑,很多人最后发现模型不如CNN,问题往往出在这一行。另一个注意点是pad_idx要传0,和前面的词表约定保持一致,否则(x != 0)这个判断会失效。

4.3 Bi-LSTM的hidden_size、num_layers与训练稳定性

hidden_size取128还是256,取决于数据量和显存。几千条数据用128就够,增大hidden_size只会让模型更快过拟合。num_layers堆到2层通常足够,超过2层收益很小,训练时间成倍涨。dropout在LSTM内部和全连接前各有一层,PyTorch的LSTM里dropout只在非最后一层生效,所以第一层的隐层输出不会自动加dropout,别把它当成防过拟合的万能开关。

# train_bilstm.py optimizer = torch.optim.Adam(model.parameters(), lr=5e-4) criterion = nn.CrossEntropyLoss() for epoch in range(epochs): model.train() for batch_x, batch_y in train_loader: optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() # 每3轮把学习率衰减到原来的0.6 if (epoch + 1) % 3 == 0: for group in optimizer.param_groups: group["lr"] *= 0.6

max_norm取1.0是把梯度的L2范数裁到1.0以内,防止梯度爆炸把loss打成nan。学习率从TextCNN的1e-3降到5e-4,因为LSTM对学习率更敏感。之后再配合StepLR式的衰减,Bi-LSTM的训练曲线就会比裸跑稳定得多。飘得厉害的时候还可以把batch_size从128调到64,很多训练不稳定其实是batch太小导致梯度噪声太大。

4.4 对比实验怎么列:准确率、F1、推理时间一起上桌

毕设答辩要看对比实验,不能只给准确率。下面这张是典型的实验表,在同一个数据集划分下跑:

模型准确率Macro-F1推理耗时(ms/条)是否加载预训练向量
TF-IDF + SVM0.8720.8630.4否
TextCNN0.9020.8972.1否
BiLSTM0.9110.90512.6否
BiLSTM + Word2Vec0.9180.91312.8是

表格里的数值以你自己跑出来的为准。横向比模型,纵向比是否加载预训练向量,这是论文里最有说服力的部分。传统机器学习基线一定要有一个,它既是baseline,也是一种对照思路。如果BiLSTM在你的数据上反而比CNN差,不要硬调参硬说它强,真实记录并分析原因,答辩老师反而认可这种诚实。

5. 情感分析文本分类避坑:数据泄漏、标签泄漏与训练不收敛的排查

5.1 验证集95分,新数据预测全是错的

现象:测试集准确率很高,拿几条真实评论进去预测,结果却不合理。比如明显好评被判成负向,或所有输入都输出同一类。原因:数据泄漏。最常见的有三种:一是全量数据一起做了去重,训练和验证里出现同一句话;二是只用全量数据统计词表,导致验证集和测试集词汇偷偷进了词表;三是Embedding层的词向量在全量文本上训好。解决:先把原始数据按train/val/test 8:1:1或7:2:1切分,再分别做去重和词表构建。严格做法是词表只用训练集统计,验证集里没见过的词一律转 。重新划分后,如果指标没有明显变化,基本可以排除泄漏。

5.2 Bi-LSTM训练到某个epoch,loss变成nan或猛涨

现象:前面还正常,某几个epoch后loss变成nan,或者train_loss不掉、val_loss反而涨到几十。原因:要么学习率太大导致梯度爆炸,要么Embedding层有奇怪数值,要么padding_idx没设置。还有一个容易被忽略的是验证集里出现了长度超出模型容量的样本,前向时报维度错误或数值溢出。解决:先试lr降到1e-4,加clip_grad_norm;再把训练和验证走同一个截断逻辑,确保训练时用的max_len和验证时一致。最后查一遍数据里是否有极端长文本或大量连续空白,清洗阶段没做掉的脏数据会在LSTM里放大。

5.3 CNN比TF-IDF+SVM还差,深度学习被传统ML吊打

现象:深度学习模型准确率始终低于简单的TF-IDF加SVM,loss下降正常但不转化为指标。原因:数据量太小是第一位。深度学习真正的优势是数据量大、特征复杂,如果只有一两千条标注样本,传统模型在没有噪声的特征空间上反而更稳。另一个原因是短文本里关键词密度高,CNN需要从词向量学到语义,而TF-IDF直接吃词频,在小语料上这个先验就很强。解决:别急着堆模型复杂度。把数据量扩到一万条以上,如果实在没有,把TextCNN和BiLSTM都加上预训练词向量再比一次;同时把SVM自己调好,它作为baseline越强,后面深度模型的胜出反而越有说服力。

5.4 同一个模型重跑三次,准确率差三个百分点

现象:啥都没改,只是重新跑了一遍,结果波动很大。原因:随机种子没有固定。PyTorch里GPU的卷积实现也可能引入非确定性,即使固定了Python和numpy的种子也不行。解决:在set_seed里把torch.backends.cudnn.deterministic设为True,cudnn.benchmark设为False。固定种子之后,结果可以保持可复现,这也方便在实验表里写“稳定性校验”。平时我们把随机性当玄学,但毕设论文的每个数字都需要别人能重新得出来。

5.5 OOM:显存爆炸,或CPU内存先把进程杀死

现象:训练到一半报CUDA out of memory,或者还没开始训练就MemoryError。原因:常见于batch_size太大、max_len太长、Embedding词表维度太高。另一个隐蔽来源是在数据加载时把整个数据集一次性转成Tensor再通过DataLoader,峰值内存被无谓抬高。解决:batch_size从128降到64或32;max_len从P95再砍一刀;词表从50000降到30000。DataLoader里加num_workers>0并用pin_memory=True,按batch从磁盘读而不要一次性载入。多分类任务class_num很大时,最后一层全连接的显存占用也会超预期,可以考虑在输出层先降维再分类。

6. 把项目说明写成答辩材料:从指标表到能跑的demo

项目说明文档最该解决的是“别人拿到代码后能不能在30分钟内跑起来”。我一般会按这个顺序组织README:环境依赖(python版本、依赖库)、目录结构、数据来源与预处理命令、模型训练顺序、实验结果表、demo启动方式。比写一堆设计理念有用得多,因为答辩老师翻开文档第一件事就是找“怎么跑”。

一个常被忽略的技巧是,用Flask或Streamlit做一个最简demo,能打字输入一句话,输出情感判别结果,并同时显示CNN和BiLSTM各自给出的概率。毕设答辩现场让老师亲手敲一句话进去,比放十页PPT都有说服力。如果没法部署服务,就写好model.predict函数,并录一段命令行执行的视频,效果是一样的。另外把每个模型的配置文件、数据版本、实验日期记在实验记录里,而不是只保留最终结果。我的习惯是每次跑完实验就追加一条记录,这样答辩被追问“你试过xxx吗”时,能直接拿出证据。希望今天这篇里的清洗、词表、TextCNN、BiLSTM和那五个排查点,让你少走一圈我当年走过的弯路。祝答辩顺利。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询