简介:一份面向中文电影评论情感分析的研究型资源,适合自然语言处理学习者、算法工程师及科研人员参考。项目完整实现了多层感知机、卷积神经网络、长短期记忆网络三种主流模型,并通过自建的训练集与验证集进行对比实验,覆盖从数据预处理(分词、去除停用词、标准化)到模型训练、指标评估的完整流程。压缩包共18个文件,大小约6.15MB,内含可直接运行的Python源码(模型实现与预处理脚本)、标注文本数据、多张训练过程可视化图表(如准确率与损失曲线、混淆矩阵)以及环境依赖说明,结构清晰,便于直接复现或二次开发。目前已有67人学习浏览,适合希望系统掌握中文情感分析建模、比较不同神经网络性能的开发者快速上手,用于课程设计或实际项目拓展。
1. 从一句“烂得我竟然看完了”说起:中文情感分析要拆的不只是词义
一段电影评论里同时出现“烂”和“看完”,人眼知道这是讽刺,模型却可能被“看完”带偏。所谓中文情感分析,就是把这类带主观倾向的短文本自动判成好评、差评或中性。基于电影评论数据做这件事,比普通电商评论更考验模型:反讽、否定、转折、网络新词全混在一起。我最初用电影评论跑 MLP、CNN 和 LSTM 三个模型时,也被结果吓到——几千条数据上三者准确率差不多,真正的差距出现在数据处理和验证方法上。这篇文章适合想从零复现一遍中文情感分析、并明确了解每个模型边界的人;你能拿到可运行的代码、参数选择和踩坑经验。
2. 数据集先行:电影评论的收集、清洗和标签设计
文本分类项目里最容易被低估的是数据准备。模型结构再漂亮,评论数据里满是重复、错标签和噪声,后面所有实验都不可信。这一章先把数据集讲清楚,再给出可复制的清洗和分词流程。
2.1 先聊数据来源:公开语料和自己整理短评的差别
常见的开源中文情感语料大多来自酒店、外卖或电商评论,比如酒店评论和外卖评论,它们能用来验证模型流程,但不符合“基于电影评论数据”这个前提。真正要跑电影评论,常见做法是整理带星级标注的豆瓣短评,或使用第三方平台已经导出的 CSV/TSV。这种数据通常只有两列:label和text,label 可以是 pos/neg,也可以是 1-5 星。
我一般先把文件读进来,确认标签分布和文本长度,再决定后续怎么清洗。下面这段代码做了最基础的加载和统计:
import pandas as pd df = pd.read_csv('movie_comments.tsv', sep='\t', header=0, 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(r'http\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(drop=True) 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 和 OOV
Keras 的Tokenizer会把文本转成词频索引,出现次数越多的词,索引越小。num_words只保留前 20000 个高频词,目的是砍掉只出现一两次的噪声词;oov_token='<UNK>'让词表外的词统一映射到一个特殊编号,避免预测时遇到新词就报错。
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_words=vocab_size, oov_token='<UNK>') tokenizer.fit_on_texts(df['cut']) sequences = tokenizer.texts_to_sequences(df['cut']) X = pad_sequences(sequences, maxlen=max_len, padding='post', truncating='post') y = df['label'].valuespadding='post'是在句子后面补 0,truncating='post'是从句子后面截断。对电影短评来说,结尾常出现“不值”“失望”这类结论词,如果 maxlen 没覆盖尾部,信息会丢。所以宁可把truncating='pre'换成post,优先保留结尾内容。这里的X矩阵每一行是一个固定长度的整数序列,0 是 padding,1 是<UNK>,2 开始是真实词。
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], trainable=False)这里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_size=20000、max_len=100、embedding_dim=128,只在结构上区别。
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_dim=128): inputs = Input(shape=(max_len,)) emb = Embedding(vocab_size, embedding_dim)(inputs) x = GlobalAveragePooling1D()(emb) x = Dense(256, activation='relu')(x) x = Dropout(0.5)(x) outputs = Dense(1, activation='sigmoid')(x) return Model(inputs, outputs)Dense(256, activation='relu')让模型在平均向量上做非线性变换,256 是我在短文本分类里常用的起点;数据量小就降到 128,数据量大可以升到 512。Dropout(0.5)是为了防止全连接层过拟合,特别是训练集只有几千条时,没有 Dropout 的 MLP 很容易把训练集背下来。
4.2 CNN:多尺度卷积核捕捉词组的局部组合
CNN 做文本分类的核心是卷积核在序列维度上滑动,每个卷积核相当于在寻找某种 n-gram 特征。kernel_size=3的卷积核看到的是连续三个词,能捕捉“非常 好看”“不 值得”这类三元组合;kernel_size=2更偏向紧邻搭配。我通常把[2,3,4]三种尺寸拼接起来,让模型同时看二元、三元、四元特征,比单独用一个尺寸要稳定。
def build_cnn(max_len, vocab_size, embedding_dim=128): inputs = Input(shape=(max_len,)) emb = Embedding(vocab_size, embedding_dim)(inputs) pools = [] for kernel_size in [2, 3, 4]: conv = Conv1D(filters=64, kernel_size=kernel_size, activation='relu')(emb) pool = GlobalMaxPooling1D()(conv) pools.append(pool) x = concatenate(pools) x = Dense(128, activation='relu')(x) x = Dropout(0.5)(x) outputs = Dense(1, activation='sigmoid')(x) return Model(inputs, outputs)filters=64表示每组卷积核有 64 个不同的特征提取器,个数越多表达力越强,但也越容易过拟合。GlobalMaxPooling1D从整条卷积结果里挑出最强的那个特征,对短文本特别合适,因为情感往往靠一两处关键词就能决定。这个 CNN 模型在电影短评上的训练速度通常比 LSTM 快好几倍,参数也少得多,但效果不一定差。
4.3 LSTM:记住“前面说差,后面转折”的能力
LSTM 的优势是能建模长距离依赖。电影评论里常有“前期节奏慢,但结尾反转很惊艳”,模型需要记住前面“慢”这个负面信息,再结合后面“惊艳”得出整体偏正面结论。CNN 只能看到局部窗口,很难把相隔很远的两个词联系起来,而 LSTM 在顺序读词时会把前面信息保留在记忆单元里。
def build_lstm(max_len, vocab_size, embedding_dim=128): inputs = Input(shape=(max_len,)) emb = Embedding(vocab_size, embedding_dim, mask_zero=True)(inputs) x = LSTM(units=128, dropout=0.3, recurrent_dropout=0.3)(emb) x = Dense(128, activation='relu')(x) x = Dropout(0.5)(x) outputs = Dense(1, activation='sigmoid')(x) return Model(inputs, outputs)mask_zero=True很关键,它让 LSTM 忽略 padding 的 0 位置,不会把补零当成真实内容来记忆。units=128是记忆单元数量,数值越大模型容量越高,但短文本场景下 128 已经足够。recurrent_dropout=0.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_size=0.2, random_state=42, stratify=df['label']) tokenizer = Tokenizer(num_words=vocab_size, oov_token='<UNK>') tokenizer.fit_on_texts(train_df['cut']) X_train = pad_sequences( tokenizer.texts_to_sequences(train_df['cut']), maxlen=max_len, padding='post', truncating='post') X_val = pad_sequences( tokenizer.texts_to_sequences(val_df['cut']), maxlen=max_len, padding='post', truncating='post') 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( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss='binary_crossentropy', metrics=['accuracy']) callbacks = [ tf.keras.callbacks.EarlyStopping( monitor='val_loss', patience=3, restore_best_weights=True), tf.keras.callbacks.ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=2) ] model.fit( X_train, y_train, batch_size=64, epochs=30, validation_data=(X_val, y_val), callbacks=callbacks, verbose=0) preds = (model.predict(X_val) > 0.5).astype(int) print(name) print(classification_report(y_val, preds, target_names=['neg', 'pos'])) return modelrandom_state=42固定随机种子,保证三个模型在同一份验证集上比较。stratify=df['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_zero=True;给优化器加梯度裁剪clipnorm=1.0;在model.fit里传class_weight。越小的数据集越需要保守优化,可以先用ReduceLROnPlateau把基础学习率压下来,再观测 loss. 如果加了这些仍然震荡,优先检查标签是否有错,而不是继续加层。
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3, clipnorm=1.0) # class_weight 示例:假设负样本是正样本的两倍 class_weight = {0: 1.0, 1: 2.0}5.3 三个模型准确率几乎一样:先看数据,别急着说 LSTM 没用
现象:MLP、CNN、LSTM 跑出来准确率都在 83%-85% 之间,排名的顺序换一次随机种子就变化。
原因:电影短评往往只有几十个字,情感词集中出现在局部。MLP 通过平均词向量也能捕捉“烂”“差”“值得”这些高频情感词,所以 baseline 并不低;要体现 LSTM 的顺序优势,需要有足够的样本长度和信息密度。几千条短评、maxlen=100 的任务,可能不足以拉开差距。
解决:不要只看单次运行结果,用多个随机种子跑 5 次,取平均和标准差。如果差异小于 1%,就说明模型间的差距在噪声范围内,不能宣称某模型“更好”。另外可以换成更复杂的任务,比如 5 星评分,让模型预测 1-5 分布,序列信息会更重要。模型对比的本质是验证方法,不是堆模型复杂度。
scores = [] for seed in [1, 2, 3, 4, 5]: # 每次重新初始化并记录 val_accuracy scores.append(val_acc) print(f"acc: {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_splits=5, shuffle=True, random_state=42) 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 的复杂中,找到当前任务真正需要的那个平衡点。
本文还有配套的精品资源,点击获取