多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

中文情感分析实战:TextCNN与Bi-LSTM对比实现(PyTorch)

中文情感分析实战:TextCNN与Bi-LSTM对比实现(PyTorch) 简介一份基于Python的中文情感分析完整源码项目聚焦卷积神经网络与双向长短期记忆网络文本分类模型适合毕业设计、课程设计或期末大作业使用。源码附带详细注释即使新手也能快速理解模型构建、训练与预测流程项目说明提到个人手打九十八分、导师认可部署简单、运行稳定。压缩包共三十五个文件约73.2MB主要内容包括十三个Python源码文件模型训练、预测及评分脚本、十份文本说明、两份模型权重、两份训练数据、若干图片与索引文档等可满足从数据读取到模型评估的完整链路。目前已有七十四人学习下载适用于需要快速复现中文情感分析实验或希望改进模型架构的学习者。项目中含酒店评论数据、模型目录及评分报告模块可直接对照运行节省搭建环境与调试时间。1. 中文情感分析毕设的真相CNN与Bi-LSTM不是二选一是两组对照实验毕业论文答辩现场最常被问的一句话我猜是“你的CNN和Bi-LSTM到底差在哪”而“基于python的中文情感分析源码项目说明CNNBI-LSTM文本分类”这类高分毕设课题设计上就已经把答案写进了题目里——用两种结构完全不同的深度学习模型在同一个中文文本分类任务上做对比最后用指标、曲线和案例说清楚谁在什么条件下更强。它解决的是带具体诉求来的评论是好评还是差评弹幕是积极还是消极舆情是正面还是负面。适合正在准备毕设的学生也适合想快速跑通一套文本分类基线再替换成预训练模型的工程新人。这个方向真正考验人的不是“能跑”而是能不能把清洗、词表、训练、对比实验和项目说明串成一条别人能复现的完整链路。2. 从评论文本到模型输入中文语料清洗、分词与词表构建2.1 先拿到一份能用的语料清洗比选模型更影响结果常见做法是先用公开的中文情感语料跑通流程比如酒店评论语料和微博情绪数据这类数据集网上能找到很多整理好的版本标签通常为0负向和1正向。如果自己爬电商评论字段一般长这样id、content、label。我一般会先用pandas读进来看一眼缺失值和标签分布再决定清洗策略。这个环节看起来和深度学习无关但它对最终指标的贡献往往大于后面换模型结构。# data_prepare.py import pandas as pd df pd.read_csv(reviews.csv, encodingutf-8-sig) print(数据集大小, len(df)) print(标签分布\n, df[label].value_counts()) # 去空content为空的样本不能参与训练 df df.dropna(subset[content]) # 去重同一句话被爬虫抓到多次不去重会造成训练/验证重叠 df df.drop_duplicates(subset[content], keepfirst) # 标签对齐确保label是0和1而不是字符串 df[label] df[label].astype(int) df df[df[label].isin([0, 1])].reset_index(dropTrue) 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(rhttp\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_value0): 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, dtypetorch.long) max_len int(np.percentile(df[encoded].apply(len), 95)) print(max_len:, max_len) X pad_sequences_torch(df[encoded].tolist(), max_lenmax_len, pad_value0) y torch.tensor(df[label].to_numpy(), dtypetorch.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(sentencessentences, vector_size200, window5, min_count2, workers4, epochs10) 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, axis1) 0))如果语料只有几千条Word2Vec训练出来的词向量其实没什么优势随机初始化加一个不错的dropout反而更稳。预训练向量真正的价值是在词表量级大、句子覆盖不全的场景比如几十万条电商评论里出现的新词。embedding_matrix的行数必须和len(vocab)一致否则加载到torch.nn.Embedding时维度对不上。这里我建议给毕设留两个版本基础版用随机初始化增强版加载Word2Vec对比表里写清楚。3. 用TextCNN跑通中文情感分类卷积核尺寸、池化与调参基线3.1 为什么先选TextCNN而不是直接上Bi-LSTMTextCNN的思路是把句子当一维图像用不同尺寸的卷积核去扫相邻词的组合。比如卷积核尺寸为3每次看三个连续词相当于捕捉“不太好”“性价比高”这类局部n-gram特征尺寸为5能看得更宽一点。中文情感分类里很多信号就是局部词组合所以TextCNN在短文本上收敛快、训练稳定、对显存要求低作为毕设第一个模型非常合适。它比TF-IDFSVM强的点在于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, dropout0.5, pad_idx0): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idxpad_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, dim1) 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(seed42): 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, devicecuda): 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, averagebinary) def train_model(model, train_loader, val_loader, epochs20, lr1e-3, devicecuda): optimizer torch.optim.Adam(model.parameters(), lrlr) 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(fepoch {epoch1}, 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二分类用averagebinary多分类记得改成macro。3.4 一组能直接跑的TextCNN配置参数从哪来给一组能直接复现的配置前提是你的语料是电商或酒店短评句子长度不大。max_len取上一章算出的P95一般80到120之间vocab_size保持50000embedding_dim取100filter_sizes取[2,3,4]num_filters取128dropout取0.5batch_size取128lr取1e-3epochs取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-LSTMembedding、双向层与最后一个有效位置# bilstm.py class BiLSTM(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_layers, num_classes, dropout0.5, pad_idx0): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idxpad_idx) self.lstm nn.LSTM(embedding_dim, hidden_size, num_layers, bidirectionalTrue, batch_firstTrue, dropoutdropout) 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(dim1) # (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(dim1)拿到每个样本真实长度再用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(), lr5e-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_norm1.0) optimizer.step() # 每3轮把学习率衰减到原来的0.6 if (epoch 1) % 3 0: for group in optimizer.param_groups: group[lr] * 0.6max_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训练到某个epochloss变成nan或猛涨现象前面还正常某几个epoch后loss变成nan或者train_loss不掉、val_loss反而涨到几十。原因要么学习率太大导致梯度爆炸要么Embedding层有奇怪数值要么padding_idx没设置。还有一个容易被忽略的是验证集里出现了长度超出模型容量的样本前向时报维度错误或数值溢出。解决先试lr降到1e-4加clip_grad_norm再把训练和验证走同一个截断逻辑确保训练时用的max_len和验证时一致。最后查一遍数据里是否有极端长文本或大量连续空白清洗阶段没做掉的脏数据会在LSTM里放大。5.3 CNN比TF-IDFSVM还差深度学习被传统ML吊打现象深度学习模型准确率始终低于简单的TF-IDF加SVMloss下降正常但不转化为指标。原因数据量太小是第一位。深度学习真正的优势是数据量大、特征复杂如果只有一两千条标注样本传统模型在没有噪声的特征空间上反而更稳。另一个原因是短文本里关键词密度高CNN需要从词向量学到语义而TF-IDF直接吃词频在小语料上这个先验就很强。解决别急着堆模型复杂度。把数据量扩到一万条以上如果实在没有把TextCNN和BiLSTM都加上预训练词向量再比一次同时把SVM自己调好它作为baseline越强后面深度模型的胜出反而越有说服力。5.4 同一个模型重跑三次准确率差三个百分点现象啥都没改只是重新跑了一遍结果波动很大。原因随机种子没有固定。PyTorch里GPU的卷积实现也可能引入非确定性即使固定了Python和numpy的种子也不行。解决在set_seed里把torch.backends.cudnn.deterministic设为Truecudnn.benchmark设为False。固定种子之后结果可以保持可复现这也方便在实验表里写“稳定性校验”。平时我们把随机性当玄学但毕设论文的每个数字都需要别人能重新得出来。5.5 OOM显存爆炸或CPU内存先把进程杀死现象训练到一半报CUDA out of memory或者还没开始训练就MemoryError。原因常见于batch_size太大、max_len太长、Embedding词表维度太高。另一个隐蔽来源是在数据加载时把整个数据集一次性转成Tensor再通过DataLoader峰值内存被无谓抬高。解决batch_size从128降到64或32max_len从P95再砍一刀词表从50000降到30000。DataLoader里加num_workers0并用pin_memoryTrue按batch从磁盘读而不要一次性载入。多分类任务class_num很大时最后一层全连接的显存占用也会超预期可以考虑在输出层先降维再分类。6. 把项目说明写成答辩材料从指标表到能跑的demo项目说明文档最该解决的是“别人拿到代码后能不能在30分钟内跑起来”。我一般会按这个顺序组织README环境依赖python版本、依赖库、目录结构、数据来源与预处理命令、模型训练顺序、实验结果表、demo启动方式。比写一堆设计理念有用得多因为答辩老师翻开文档第一件事就是找“怎么跑”。一个常被忽略的技巧是用Flask或Streamlit做一个最简demo能打字输入一句话输出情感判别结果并同时显示CNN和BiLSTM各自给出的概率。毕设答辩现场让老师亲手敲一句话进去比放十页PPT都有说服力。如果没法部署服务就写好model.predict函数并录一段命令行执行的视频效果是一样的。另外把每个模型的配置文件、数据版本、实验日期记在实验记录里而不是只保留最终结果。我的习惯是每次跑完实验就追加一条记录这样答辩被追问“你试过xxx吗”时能直接拿出证据。希望今天这篇里的清洗、词表、TextCNN、BiLSTM和那五个排查点让你少走一圈我当年走过的弯路。祝答辩顺利。本文还有配套的精品资源点击获取
返回列表