ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

中文情感分析实战:MLP、CNN与LSTM模型对比与避坑指南

中文情感分析实战:MLP、CNN与LSTM模型对比与避坑指南 简介一份面向中文电影评论情感分析的研究型资源适合自然语言处理学习者、算法工程师及科研人员参考。项目完整实现了多层感知机、卷积神经网络、长短期记忆网络三种主流模型并通过自建的训练集与验证集进行对比实验覆盖从数据预处理分词、去除停用词、标准化到模型训练、指标评估的完整流程。压缩包共18个文件大小约6.15MB内含可直接运行的Python源码模型实现与预处理脚本、标注文本数据、多张训练过程可视化图表如准确率与损失曲线、混淆矩阵以及环境依赖说明结构清晰便于直接复现或二次开发。目前已有67人学习浏览适合希望系统掌握中文情感分析建模、比较不同神经网络性能的开发者快速上手用于课程设计或实际项目拓展。1. 从一句“烂得我竟然看完了”说起中文情感分析要拆的不只是词义一段电影评论里同时出现“烂”和“看完”人眼知道这是讽刺模型却可能被“看完”带偏。所谓中文情感分析就是把这类带主观倾向的短文本自动判成好评、差评或中性。基于电影评论数据做这件事比普通电商评论更考验模型反讽、否定、转折、网络新词全混在一起。我最初用电影评论跑 MLP、CNN 和 LSTM 三个模型时也被结果吓到——几千条数据上三者准确率差不多真正的差距出现在数据处理和验证方法上。这篇文章适合想从零复现一遍中文情感分析、并明确了解每个模型边界的人你能拿到可运行的代码、参数选择和踩坑经验。2. 数据集先行电影评论的收集、清洗和标签设计文本分类项目里最容易被低估的是数据准备。模型结构再漂亮评论数据里满是重复、错标签和噪声后面所有实验都不可信。这一章先把数据集讲清楚再给出可复制的清洗和分词流程。2.1 先聊数据来源公开语料和自己整理短评的差别常见的开源中文情感语料大多来自酒店、外卖或电商评论比如酒店评论和外卖评论它们能用来验证模型流程但不符合“基于电影评论数据”这个前提。真正要跑电影评论常见做法是整理带星级标注的豆瓣短评或使用第三方平台已经导出的 CSV/TSV。这种数据通常只有两列label和textlabel 可以是 pos/neg也可以是 1-5 星。我一般先把文件读进来确认标签分布和文本长度再决定后续怎么清洗。下面这段代码做了最基础的加载和统计import pandas as pd df pd.read_csv(movie_comments.tsv, sep\t, header0, names[label, text]) print(df.shape) print(df[label].value_counts()) df[length] df[text].str.len() print(df[length].describe())这里的sep\t是按 Tab 分隔读取如果你的数据是 CSV 逗号分隔改成sep,。先看value_counts()是为了确认是不是二分类如果出现3星这样的中间档后面要单独处理。length这列不是训练特征只用来判断短评长度的分布电影短评大多集中在 20-100 字如果出现大量 500 字长评说明数据来源混入了影评文章清洗逻辑要调整。2.2 清洗、去重和分词三个不能省的步骤数据清洗不是简单去空格。评论里可能有 HTML 标签、URL、全角标点、表情符号和重复内容。对于中文情感分析我会保留中文、字母和数字去掉标点因为感叹号虽然能表达情绪但在第一个版本里会带来噪声。去重比清洗更关键同一句话在训练集和验证集各出现一次验证准确率会被严重高估。分词我使用 jieba 精确模式。这里有一个很容易犯的错不要在一开始就用通用停用词表过滤“不、没、别”这些是情感转折的核心词。先只做最保守的清洗让模型自己学。import re import jieba def clean_text(s): # 去掉HTML标签和URL s re.sub(r.*?, , s) s re.sub(rhttp\S, , s) # 只保留中文、字母、数字去掉中文标点和表情 s re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , s) return s.strip() df[text] df[text].apply(clean_text) # 去除完全重复的短评 df df.drop_duplicates(subset[text]) # 过滤掉清洗后太短的样本 df df[df[text].str.len() 4] # 用空格连接分词结果方便后续Keras Tokenizer处理 df[cut] df[text].apply(lambda s: .join(jieba.cut(s))) print(df[[label, text, cut]].head())drop_duplicates(subset[text])删除的是“完全一样”的评论如果两条评论只是标点不同清洗后也会变一样所以要先清洗再去重。str.len() 4是为了过滤“很好”“不错”这类过短样本它们不是没有价值但在二分类里会让模型只依赖一两个词掩盖真实的泛化能力。jieba 输出用空格连接是为了让后续每个词变成一个独立 token。2.3 标签平衡和任务设计二分类还是三分类电影评分通常有 1 到 5 星直接做多分类会遇到一个问题3 星评论往往是“还行”“能看”情感边界很模糊。如果你手里的数据以短评为主我会建议先做二分类把 1-2 星映射为负面4-5 星映射为正面3 星直接删掉。三分类需要的中性样本数量要翻倍否则模型会牺牲正负类的召回。def map_star_to_label(x): try: x int(x) except (ValueError, TypeError): return None if x 2: return 0 # 负面 elif x 4: return 1 # 正面 else: return None # 3星丢弃 df[label] df[label].apply(map_star_to_label) df df.dropna(subset[label]).reset_index(dropTrue) print(df[label].value_counts())如果映射后正负样本比例超过 3:1我建议不要直接删样本而是用class_weight或sample_weight处理。删负样本虽然能让准确率看起来更平衡但会丢失真实评论的多样性。class_weight可以在不删除样本的情况下让少数类样本在 loss 里权重更高后面训练时我会再提。3. 文本向量化用定长序列统一 MLP、CNN、LSTM 的输入三个模型要用同一个输入口径才有可比性。如果 MLP 吃 TF-IDF 稀疏向量CNN 吃 token 序列LSTM 吃词向量平均最后结论会被输入差异污染。这一章我把所有评论统一转成定长的词索引序列。3.1 为什么不让三个模型各用各的输入词袋模型和 TF-IDF 的优势是简单但缺点也很明显维度高、丢失词序、无法表达“不太好”和“不是太好”之间的区别。Embedding 层的本质是一张词向量查找表模型在训练中自己调整每个词的向量让相似语义的词距离更近。MLP 不能直接吃变长序列所以我用GlobalAveragePooling1D把整个序列平均成一个句子向量CNN 用卷积核在序列上滑动抓住局部词组LSTM 按顺序逐词读入。三者的输入都是(batch_size, max_len)的词索引矩阵唯一区别是模型内部的序列处理方式这样比较出来的差异才是模型结构造成的。from tensorflow.keras.layers import Input, Embedding, GlobalAveragePooling1D如果你坚持让 MLP 直接吃 TF-IDF也可以但会面临另一个问题MLP 参数量爆炸而且无法和 CNN、LSTM 共用同一套 Embedding。统一输入是更省事、更容易解释的做法。3.2 Tokenizer 和 padding 的参数num_words、maxlen 和 OOVKeras 的Tokenizer会把文本转成词频索引出现次数越多的词索引越小。num_words只保留前 20000 个高频词目的是砍掉只出现一两次的噪声词oov_tokenUNK让词表外的词统一映射到一个特殊编号避免预测时遇到新词就报错。maxlen是最需要看数据分布再定的参数不能随便写个 512。短评平均长度可能只有 30如果 maxlen 设成 512绝大多数序列会被 padding 填满占用大量内存且让 LSTM 收敛变慢。我通常先看分词后长度的分位数取 90% 分位数保证大部分评论不被截断。from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences import numpy as np lengths df[cut].apply(lambda s: len(s.split())) print(np.percentile(lengths, [50, 90, 99])) max_len 100 vocab_size 20000 # 演示用正式训练时要在train_test_split之后再fit tokenizer Tokenizer(num_wordsvocab_size, oov_tokenUNK) tokenizer.fit_on_texts(df[cut]) sequences tokenizer.texts_to_sequences(df[cut]) X pad_sequences(sequences, maxlenmax_len, paddingpost, truncatingpost) y df[label].valuespaddingpost是在句子后面补 0truncatingpost是从句子后面截断。对电影短评来说结尾常出现“不值”“失望”这类结论词如果 maxlen 没覆盖尾部信息会丢。所以宁可把truncatingpre换成post优先保留结尾内容。这里的X矩阵每一行是一个固定长度的整数序列0 是 padding1 是UNK2 开始是真实词。3.3 用不用预训练词向量小数据量场景的取舍随机初始化 Embedding 在小数据集上不一定学得好因为每个词被更新的机会少尤其对只出现几次的词向量基本是噪声。中文公开预训练词向量能提供通用的语义先验比如“烂”和“差”在向量空间里会更接近。但预训练向量文件通常很大加载和匹配 tokenizer 也需要额外代码。我的习惯是第一版先用随机 Embedding 跑通流程拿到基线如果 CNN/LSTM 表现不满意再引入预训练词向量。引入时有一个关键选择trainable设 True 还是 False。数据量小我建议先把预训练向量冻结住只训练后面的 Dense 层避免微调把原有语义破坏等模型整体收敛后再解冻 Embedding 做少量微调。# 伪代码假设 pretrained 是 {word: np.array} 的字典 vocab tokenizer.word_index embedding_matrix np.random.uniform(-0.05, 0.05, (vocab_size, 128)) for word, idx in vocab.items(): if idx vocab_size: continue if word in pretrained: embedding_matrix[idx] pretrained[word] # 使用 weights 参数传入 Embedding # Embedding(vocab_size, 128, weights[embedding_matrix], trainableFalse)这里embedding_matrix的形状必须和Embedding的input_dim、output_dim完全一致。vocab_size是截断后保留的最大词数量128是词向量维度。如果你决定用 300 维预训练向量Embedding的output_dim也要改成 300后面的卷积核和 LSTM 隐藏层可以不用变。4. 模型实现与对比MLP、CNN、LSTM 的 Keras 代码和训练参数这一章给出三个模型的完整构建代码。我用 TensorFlow Keras 的函数式 API而不是 Sequential因为后面 CNN 要用多个卷积结果拼接函数式更方便。三种模型共享vocab_size20000、max_len100、embedding_dim128只在结构上区别。import tensorflow as tf from tensorflow.keras.layers import ( Input, Embedding, GlobalAveragePooling1D, GlobalMaxPooling1D, Dense, Dropout, Conv1D, LSTM, concatenate ) from tensorflow.keras.models import Model4.1 MLP嵌入层加全局平均池化先跑出一个基线MLP 在文本分类里通常指“多层全连接网络”但它不能直接处理序列所以我先用GlobalAveragePooling1D把 embedding 后的全部词向量平均成一个向量。这个操作等同于把评论里所有词的信息压缩在一起不再保留顺序。它的优点是训练最快缺点也很明显无法识别“先夸后贬”这类转折。def build_mlp(max_len, vocab_size, embedding_dim128): inputs Input(shape(max_len,)) emb Embedding(vocab_size, embedding_dim)(inputs) x GlobalAveragePooling1D()(emb) x Dense(256, activationrelu)(x) x Dropout(0.5)(x) outputs Dense(1, activationsigmoid)(x) return Model(inputs, outputs)Dense(256, activationrelu)让模型在平均向量上做非线性变换256 是我在短文本分类里常用的起点数据量小就降到 128数据量大可以升到 512。Dropout(0.5)是为了防止全连接层过拟合特别是训练集只有几千条时没有 Dropout 的 MLP 很容易把训练集背下来。4.2 CNN多尺度卷积核捕捉词组的局部组合CNN 做文本分类的核心是卷积核在序列维度上滑动每个卷积核相当于在寻找某种 n-gram 特征。kernel_size3的卷积核看到的是连续三个词能捕捉“非常 好看”“不 值得”这类三元组合kernel_size2更偏向紧邻搭配。我通常把[2,3,4]三种尺寸拼接起来让模型同时看二元、三元、四元特征比单独用一个尺寸要稳定。def build_cnn(max_len, vocab_size, embedding_dim128): inputs Input(shape(max_len,)) emb Embedding(vocab_size, embedding_dim)(inputs) pools [] for kernel_size in [2, 3, 4]: conv Conv1D(filters64, kernel_sizekernel_size, activationrelu)(emb) pool GlobalMaxPooling1D()(conv) pools.append(pool) x concatenate(pools) x Dense(128, activationrelu)(x) x Dropout(0.5)(x) outputs Dense(1, activationsigmoid)(x) return Model(inputs, outputs)filters64表示每组卷积核有 64 个不同的特征提取器个数越多表达力越强但也越容易过拟合。GlobalMaxPooling1D从整条卷积结果里挑出最强的那个特征对短文本特别合适因为情感往往靠一两处关键词就能决定。这个 CNN 模型在电影短评上的训练速度通常比 LSTM 快好几倍参数也少得多但效果不一定差。4.3 LSTM记住“前面说差后面转折”的能力LSTM 的优势是能建模长距离依赖。电影评论里常有“前期节奏慢但结尾反转很惊艳”模型需要记住前面“慢”这个负面信息再结合后面“惊艳”得出整体偏正面结论。CNN 只能看到局部窗口很难把相隔很远的两个词联系起来而 LSTM 在顺序读词时会把前面信息保留在记忆单元里。def build_lstm(max_len, vocab_size, embedding_dim128): inputs Input(shape(max_len,)) emb Embedding(vocab_size, embedding_dim, mask_zeroTrue)(inputs) x LSTM(units128, dropout0.3, recurrent_dropout0.3)(emb) x Dense(128, activationrelu)(x) x Dropout(0.5)(x) outputs Dense(1, activationsigmoid)(x) return Model(inputs, outputs)mask_zeroTrue很关键它让 LSTM 忽略 padding 的 0 位置不会把补零当成真实内容来记忆。units128是记忆单元数量数值越大模型容量越高但短文本场景下 128 已经足够。recurrent_dropout0.3是 LSTM 内部循环状态上的 Dropout它比普通 Dropout 更消耗训练时间但能在序列依赖上提供正则化。4.4 训练脚本共同超参、EarlyStopping 和模型保存训练三个模型前需要先用train_test_split划分数据再在训练集上重新拟合 Tokenizer。这一步直接关系到会不会数据泄漏具体危害我在第 5 章会展开。下面这段代码同时完成数据划分、向量化和模型训练。from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report train_df, val_df train_test_split( df, test_size0.2, random_state42, stratifydf[label]) tokenizer Tokenizer(num_wordsvocab_size, oov_tokenUNK) tokenizer.fit_on_texts(train_df[cut]) X_train pad_sequences( tokenizer.texts_to_sequences(train_df[cut]), maxlenmax_len, paddingpost, truncatingpost) X_val pad_sequences( tokenizer.texts_to_sequences(val_df[cut]), maxlenmax_len, paddingpost, truncatingpost) y_train, y_val train_df[label].values, val_df[label].values def train_and_eval(build_fn, name): model build_fn(max_len, vocab_size) model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), lossbinary_crossentropy, metrics[accuracy]) callbacks [ tf.keras.callbacks.EarlyStopping( monitorval_loss, patience3, restore_best_weightsTrue), tf.keras.callbacks.ReduceLROnPlateau( monitorval_loss, factor0.5, patience2) ] model.fit( X_train, y_train, batch_size64, epochs30, validation_data(X_val, y_val), callbackscallbacks, verbose0) preds (model.predict(X_val) 0.5).astype(int) print(name) print(classification_report(y_val, preds, target_names[neg, pos])) return modelrandom_state42固定随机种子保证三个模型在同一份验证集上比较。stratifydf[label]让训练集和验证集的正负比例保持一致避免某一次划分偏到全是好评。EarlyStopping监控验证 loss连续 3 个 epoch 不下降就停并回滚到最优权重。ReduceLROnPlateau在验证 loss 连续 2 个 epoch 不降时把学习率减半这两个回调比盲目跑满 30 个 epoch 更可靠。5. 避坑笔记数据泄漏、loss 震荡和模型“没有差异”这一章记录我在这个项目里真实遇到过的坑每一条都按“现象、原因、解决”来讲。很多问题不是模型选错而是数据或训练流程出了偏差。5.1 数据泄漏tokenizer 在划分前 fit验证集被“剧透”现象验证集准确率接近 95%测试集或线上效果却只有 75%或者三个模型的验证指标都好得反常。原因常见做法是先对全量数据调用fit_on_texts再切训练验证集。Tokenzier 在统计词频时已经看过验证集的文本验证集里的生僻词也被记进了词表等价于验证集参与了模型输入构造。另一个原因是去重不彻底完全相同的评论同时出现在训练集和验证集。解决先train_test_split再只对train_df[cut]调用fit_on_texts。我在 4.4 的代码里已经按这个顺序写了验证集只通过texts_to_sequences转换不参与词表统计。另外去重要在切分之前完成最好基于清洗后的文本去重而不是原始文本否则“很好看”和“很好看”会被当作两条不同样本。5.2 训练不收敛或 loss 震荡padding、学习率和类别不平衡现象LSTM 的 loss 从 0.69 降到 0.68 就停住不动或者训练时高时低val_loss 不降反升MLP 反而很快收敛。原因最常见的是mask_zero没打开。LSTM 会把 padding 的 0 也当作真实 token 读进去大量补零让序列状态被无用信息淹没。另一个原因是学习率太大Adam 默认 1e-3 在大多数时候可用但遇到小数据或稀疏特征时会震荡还有可能是正负样本严重不平衡模型偏向输出多数类准确率看似还行召回率一塌糊涂。解决LSTM 的 Embedding 层加mask_zeroTrue给优化器加梯度裁剪clipnorm1.0在model.fit里传class_weight。越小的数据集越需要保守优化可以先用ReduceLROnPlateau把基础学习率压下来再观测 loss. 如果加了这些仍然震荡优先检查标签是否有错而不是继续加层。optimizer tf.keras.optimizers.Adam(learning_rate1e-3, clipnorm1.0) # class_weight 示例假设负样本是正样本的两倍 class_weight {0: 1.0, 1: 2.0}5.3 三个模型准确率几乎一样先看数据别急着说 LSTM 没用现象MLP、CNN、LSTM 跑出来准确率都在 83%-85% 之间排名的顺序换一次随机种子就变化。原因电影短评往往只有几十个字情感词集中出现在局部。MLP 通过平均词向量也能捕捉“烂”“差”“值得”这些高频情感词所以 baseline 并不低要体现 LSTM 的顺序优势需要有足够的样本长度和信息密度。几千条短评、maxlen100 的任务可能不足以拉开差距。解决不要只看单次运行结果用多个随机种子跑 5 次取平均和标准差。如果差异小于 1%就说明模型间的差距在噪声范围内不能宣称某模型“更好”。另外可以换成更复杂的任务比如 5 星评分让模型预测 1-5 分布序列信息会更重要。模型对比的本质是验证方法不是堆模型复杂度。scores [] for seed in [1, 2, 3, 4, 5]: # 每次重新初始化并记录 val_accuracy scores.append(val_acc) print(facc: {np.mean(scores):.3f} ± {np.std(scores):.3f})5.4 停用词表误伤把“不”和“没”当停用词删掉现象模型把“不太好看”预测成正面把“没有惊喜”也预测成正面。原因预处理时套用了通用中文停用词表里面包含“不”“没”“别”“不太”这些否定词。对情感分析来说否定词是决定情感方向的关键信号把它们删掉等于告诉模型“不太好看”和“很好看”是同一种输入。jieba 虽然能把“不太”切成一个词但如果后续又用停用词表过滤信号仍然会丢失。解决停用词只保留“的”“了”“吧”“吗”这种纯功能词不碰否定词和程度副词。如果担心“不太好看”被切错可以手动加到 jieba 词库里或者用jieba.suggest_freq调准切分概率。我现在的习惯是清洗阶段不去停用词先让模型自己判断只在规则兜底里做更精细的过滤。6. 进阶验证方法交叉验证、错误分析和预训练模型迁移模型训练完别急着写结论。单次 train/val 划分只能代表某一组随机状态更可信的做法是用交叉验证确认指标再用错误分析找出模型短板。from sklearn.model_selection import StratifiedKFold skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) for train_idx, val_idx in skf.split(df[cut], df[label]): train_fold df.iloc[train_idx] val_fold df.iloc[val_idx] # 每个 fold 内重新构建 tokenizer 和 model # 记录 fold 的 val_accuracy最后求均值交叉验证每次用 4/5 数据训练、1/5 验证跑 5 次后得到的均值和标准差比单次模型更真实。小数据集上一次好结果可能是随机运气不看波动很容易选错模型。错误分析也很直接把预测错误样本按预测概率排序概率接近 0.5 的往往是模型最不确定的评论。我遇到过模型把“看完我只想给导演寄刀片”判成正面因为样本里“寄刀片”出现太少。这种错误不是调参能解决的要么补充类似表达样本要么在预处理里把网络用语单独映射成情感短语。真正有效的改进往往来自这里而不是把 LSTM 换成双向 LSTM。最后说预训练模型迁移。如果你愿意引入 BERT 类模型电影评论情感分析准确率通常能比 CNN/LSTM 再高几个点但代价是显存占用大、推理慢。数据量很小且有验证错误集中时先补样本和修正预处理比直接上 BERT 更划算。我现在的选择是基线 错误分析先跑透再用预训练语言模型做一次对比确认提升值不值得部署成本。这套流程跑完后我的一个习惯是每次训练前先确认数据划分和 tokenizer fit 的先后顺序再开始调模型。最开始的几次实验我因为没重视这个顺序浪费了大量时间在“假高分”上。希望这些步骤和坑能帮你少走弯路也希望你到底能从 MLP 的简单与 LSTM 的复杂中找到当前任务真正需要的那个平衡点。本文还有配套的精品资源点击获取
返回列表