ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

中文情感分析实战:影评数据上的MLP、CNN与LSTM对比

中文情感分析实战:影评数据上的MLP、CNN与LSTM对比

简介:面向中文电影评论情感分析任务,这份学习资源提供了完整的神经网络实现与配套数据集,适合自然语言处理入门者、课程设计学生及希望对比三种经典网络性能的开发者。资源内含三个Python脚本,分别实现多层感知机、卷积神经网络和长短期记忆网络,并配有数据预处理逻辑、训练集与验证集划分,覆盖从中文分词、停用词过滤到模型评估的完整实验流程;同时包含依赖文件、说明文档及训练历史图表,便于复现和调参。整个压缩包共18个文件,大小约6.15MB,以Python脚本、文本数据、图表和说明文档为主,结构清晰、体量轻量。目前已有67人学习下载。借助此包,读者不仅能直接运行三组情感分类对比实验,还可通过准确率与损失曲线直观理解不同网络结构的收敛特点,参考环境配置和备份资料进一步提升工程实践能力。

1. 中文情感分析:为什么拿影评练手反而最容易翻车

写基于电影评论数据的中文情感分析,很多人上来就上BERT、上GPT,结果发现跑一轮要半小时,精度还堆不上去。实际上影评这个场景非常典型:句式口语化、长度差异大、反转表达多(“没有想象中那么差”),用NN(MLP)、CNN与LSTM这套经典深度学习路径,配合公开中文情感分析数据集,完全能在普通显卡甚至CPU上跑出85%上下的准确率。下面按“数据准备 → 文本向量化 → 三种模型实现 → 避坑 → 实验对比”的顺序,把每一步的代码和参数讲清楚。新手能跟着复现,熟手能直接拿走边界和调参经验。

2. 数据集准备与预处理:从原始评论到可训练的Tensor

2.1 数据集怎么选:公开语料与自建影评的取舍

中文情感分析公开数据集里,ChnSentiCorp系列出现频率最高,包含酒店、商品、书籍等评论,也有影视评论的变体;另一个常见做法是爬豆瓣影评,按评分映射成正负标签。对这篇文章来说,更适合的是“单领域、二分类”的数据:单领域能让词表更集中,二分类能让标签更干净。数据规模控制在1万到10万条之间比较合适,太小了三种模型的差异看不出来,太大了训练时间会反过来限制你迭代模型的速度。

拿到原始数据后,第一步永远是清洗和查看分布,而不是直接接模型。常见问题包括:评论里混入HTML标签、全角半角符号混乱、同一句话被爬虫重复抓取、好评差评数量悬殊。下面这段代码把CSV读进来,做基础清洗和标签统计:

import pandas as pd import re def load_and_clean(path="movie_reviews.csv"): df = pd.read_csv(path) # 只保留文本和标签两列,标签约定为 0/1(0=负向,1=正向) df = df[["comment", "label"]].dropna() def clean_text(s): s = re.sub(r"<[^>]+>", "", s) # 去HTML标签 s = re.sub(r"\s+", " ", s).strip() # 合并空白符 return s df["comment"] = df["comment"].apply(clean_text) df = df[df["comment"].str.len() >= 4] # 过滤过短的无意义评论 df = df.drop_duplicates(subset=["comment"]) # 去掉完全重复的评论 print(df["label"].value_counts()) return df df = load_and_clean()

这里有个容易被忽视的参数:drop_duplicates(subset=["comment"])。如果爬虫脚本中断重跑,或者同一部电影被多次抓取,重复样本会很自然地溜进训练集和测试集,导致评估指标虚高。先按文本去重,再按电影ID(如果有这一列)做分组划分,是影评数据特有的一个前置操作。

标签分布打印出来之后,重点看正负样本比例。多数影评场景是正向偏多,比例可能到1.3:1以上。偏差不严重时可以不管,偏差超过2:1就要考虑第5章里说的采样策略。

2.2 分词与词表构建:jieba的参数和OOV边界

中文没有天然空格,分词绕不开。最常用的做法是jieba分词,配合一个停用词表去掉“的、了、就”这类高频但无语义的词。分词粒度直接决定词表质量:粒度太粗会让词表膨胀,粒度太细会丢失词义。jieba默认的精确模式在影评口语上表现够用,不需要强行上领域词典。

词表构建主要调两个参数:min_count(出现次数低于该值的词丢弃)和词表上限。min_count设得太大,很多低频但情感强烈的词(“垃圾”“神作”)会被丢掉;设得太小,词表里全是噪声。我一般先设min_count=2,跑完看词表容量和OOV覆盖率再调。

import jieba from collections import Counter STOP_WORDS = set(["的", "了", "是", "就", "都", "而", "及", "与", "着", "或", "一个", "没有", "我们", "你们", "他们"]) def tokenize(text): return [w for w in jieba.cut(text) if w.strip() and w not in STOP_WORDS] # 建词表:每个词 -> id counter = Counter() for text in df["comment"]: counter.update(tokenize(text)) # 过滤低频词,并按频率排序分配 id min_count = 2 vocab = {"<pad>": 0, "<unk>": 1} for word, freq in counter.most_common(): if freq < min_count: break if word not in vocab: vocab[word] = len(vocab) print("词表大小:", len(vocab))

注意<pad>和<unk>固定占用0和1两个id:0用于padding,1用于未登录词。这一决定直接关联后面nn.Embedding的padding_idx参数。词表大小在1万到3万之间比较常见,如果超过5万,先回头看看是不是分词粒度太细或停用词表没生效。

OOV(out-of-vocabulary)是这里的主要风险点。测试集里出现“绝绝子”这种训练集没见过的新词时,会被映射到<unk>,模型只能靠上下文猜。缓解手段包括:把英文、数字、URL统一替换成占位符后再分词;把min_count降到1(代价是词表变大、训练变慢);或用字符n-gram特征兜底。

2.3 训练、验证、测试集划分:分层抽样是底线

数据划分不是随便切三份就行。影评数据天然带类别偏斜,随机切分很容易让验证集的正负比例出现偶然波动。常见做法是用train_test_split的stratify参数按标签分层抽样,保证三个集合里的正负比例与原始数据一致。

from sklearn.model_selection import train_test_split train_df, temp_df = train_test_split( df, test_size=0.3, stratify=df["label"], random_state=42 ) val_df, test_df = train_test_split( temp_df, test_size=0.5, stratify=temp_df["label"], random_state=42 ) print(len(train_df), len(val_df), len(test_df))

关于切分比例我有个习惯:总量10万条以下时,测试集留3000到5000条就够;超过10万条,固定测试集大小(比如1万条)比固定比例更稳,否则测试集会大到没必要。验证集不要太大,只需要保证和测试集同分布,用来做早停和超参选择。

如果你用的是按时间爬取的影评,还要考虑“时间泄漏”:先按时间排序,再用前80%做训练、后20%做测试,而不是随机切分。否则模型会在训练集里“看到”未来才出现的新词和表达,测试时高估真实效果。

数据准备到这里,核心就一句话:先让数据干净、词表可控、划分可信,再谈模型结构。

3. 文本向量化:Embedding、Padding与batch里那些看不见的坑

3.1 为什么文本分类不直接用TF-IDF

很多从传统机器学习转过来的人,第一步想把评论做成TF-IDF向量喂给模型。TF-IDF在MLP上确实能跑,但对CNN和LSTM有两个硬伤:一是高维稀疏向量(词表2万时,每行就是2万维),全连接层参数量爆炸;二是它丢失了词序,LSTM根本无从学起。Embedding本质是一张“词id → 稠密向量”的查找表,维度通常是128或300,既压低了参数量,又让模型可以在训练中不断调整向量本身。

所以这篇文章里的三种模型统一走Embedding路线,输入是一个batch的整数序列。这里的整型id就是第2章词表分配的id。Embedding层的作用不是特征工程,而是把离散符号变成连续空间里的点,让后续网络能用梯度去挪动这些点。

3.2 nn.Embedding 的初始化和padding_idx

PyTorch里创建Embedding层很简单,但有两个参数必须说明。第一个是padding_idx,要设为词表里<pad>的id(这里为0),这样padding位置在反向传播时梯度恒为0,不会把padding向量越训越偏。第二个是max_norm,可以约束向量范数防止数值溢出,但新手阶段不建议开,正常训练用不到。

import torch from torch import nn embed_dim = 128 vocab_size = len(vocab) embedding = nn.Embedding( num_embeddings=vocab_size, embedding_dim=embed_dim, padding_idx=0, # 与词表的 <pad>=0 对齐 max_norm=None ) # 默认均匀分布随机初始化,均值0方差1 print(embedding.weight.shape) # [vocab_size, embed_dim]

embed_dim的选择:128在影评规模足够,300通常不会比128涨多少精度,但训练时间增加一倍以上。先用128跑通全流程,最后调参阶段再试256或300。词向量要不要拿预训练的word2vec初始化?可以做,但影评口语里有大量网络新词,通用语料训练的词向量覆盖不到,反而引入OOV。我通常先用随机初始化跑基线,确认数据管道没问题之后,再实验预训练初始化带来的增量。

3.3 collate_fn:变长序列如何塞进一个batch

批训练要求一个batch内每个样本形状一致,但影评长度从十几个字到上千字都有。常见做法是在collate_fn里把一个batch的句子pad到相同长度,这也是最容易出问题的环节:padding位置上的0向量,后续如果参与平均池化或attention计算,会把有效特征拉低。所以collate_fn里不只要pad,还要记录每个样本的真实长度,以及一个0/1的mask矩阵。

from torch.utils.data import Dataset, DataLoader from torch.nn.utils.rnn import pad_sequence def encode(text): ids = [vocab.get(w, 1) for w in tokenize(text)] # 1 是 <unk> 的 id return torch.tensor(ids, dtype=torch.long) class MovieReviewDataset(Dataset): def __init__(self, df): self.texts = df["comment"].tolist() self.labels = df["label"].astype("int64").tolist() def __len__(self): return len(self.texts) def __getitem__(self, idx): return encode(self.texts[idx]), self.labels[idx] def collate_fn(batch): ids, labels = zip(*batch) padded = pad_sequence(ids, batch_first=True, padding_value=0) mask = (padded != 0).long() # 1=有效位置, 0=padding lengths = mask.sum(dim=1) # 每个样本的真实长度 return padded, torch.tensor(labels), mask, lengths train_loader = DataLoader( MovieReviewDataset(train_df), batch_size=64, shuffle=True, collate_fn=collate_fn )

参数说明:batch_size=64在小数据集和CPU上都能接受,显存不够再降到32。pad_sequence默认按batch内最长句子pad,但如果数据里有极长评论,单个batch会被拖得很长,训练速度骤降。更稳的做法是设定一个max_len(比如256或512),分词后直接截断,或按长度分桶batch,这个我放在第5章坑位里细说。

mask这个矩阵三种模型都用得上:MLP的mean pooling需要它来求有效位置的平均;CNN如果改用mean池化也需要;LSTM虽然用h_n取隐藏状态不直接需要mask,但如果你改成attention或对所有时间步做池化,mask同样必须传进去。这一章把mask准备好,后面每个模型都省事。

4. 三种模型实现:MLP、CNN与LSTM的代码骨架与参数选择

4.1 MLP:全连接基线不能小看

MLP是三种模型里最容易被低估的。它不关心词序,把一条评论中所有词的embedding做一个带mask的均值池化,得到128维的句向量,再经过两层全连接输出2分类概率。由于只能看到“词袋级”的全局信息,MLP能捕捉“出现了哪些情感词”这种信号,但捕捉不了“虽然…但是”这种转折结构。

class MLPSentiment(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_size=256, num_classes=2, dropout=0.3, pad_idx=0): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx) self.fc1 = nn.Linear(embed_dim, hidden_size) self.relu = nn.ReLU() self.dropout = nn.Dropout(dropout) self.fc2 = nn.Linear(hidden_size, num_classes) def forward(self, input_ids, mask, lengths): emb = self.embedding(input_ids) # [B, T, D] # mask均值池化:只对有效位置求和除以真实长度 masked = emb * mask.unsqueeze(-1) # padding位置归零 pooled = masked.sum(dim=1) / lengths.unsqueeze(-1) # [B, D] h = self.dropout(self.relu(self.fc1(pooled))) return self.fc2(h)

hidden_size=256是经验起点,影评二分类用不到太大的隐层;dropout=0.3放在全连接层之间,防止过拟合。forward里mask.unsqueeze(-1)把[B, T]扩展成[B, T, 1]去乘[B, T, D]的embedding,这个形状操作是新手最容易写错的地方。用lengths做除法而不是用mask.sum(dim=1),本质一样,只是后者多一次sum。

MLP是项目的基线模型,它的分数决定了后续CNN/LSTM值不值得上。如果MLP在验证集上已经低于75%,大概率是数据或预处理有问题,先回去查第2章,而不是换模型。

4.2 CNN:一维卷积就是n-gram特征的深度学习版

文本CNN与图像CNN的区别在于卷积核只沿序列长度方向滑动,不沿embedding方向滑动。卷积核大小为3的Conv1d,相当于在词向量序列上扫描“连续3个词”的特征组合。常见做法是用3、4、5三种尺寸并行,再全局最大池化,最后拼接在一起,这就是经典TextCNN骨架。

import torch.nn.functional as F class CNNSentiment(nn.Module): def __init__(self, vocab_size, embed_dim, num_filters=100, kernel_sizes=(3, 4, 5), num_classes=2, dropout=0.3, pad_idx=0): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, k, padding=k // 2) for k in kernel_sizes ]) self.fc = nn.Linear(num_filters * len(kernel_sizes), num_classes) self.dropout = nn.Dropout(dropout) def forward(self, input_ids, mask, lengths): emb = self.embedding(input_ids) # [B, T, D] emb = emb.transpose(1, 2) # Conv1d 需要 [B, D, T] conv_outs = [] for conv in self.convs: c = F.relu(conv(emb)) # [B, num_filters, T] c = F.adaptive_max_pool1d(c, 1).squeeze(-1) # 全局最大池化 -> [B, num_filters] conv_outs.append(c) feats = torch.cat(conv_outs, dim=-1) # [B, num_filters*3] return self.fc(self.dropout(feats))

这里有两个关键参数。第一个是padding=k//2:为了让卷积输出长度与输入一致,使用half padding,否则长句两端的信息在卷积后丢失。第二个是池化方式:我不用固定窗口的MaxPool1d,而是对每个卷积核的输出做adaptive_max_pool1d(c, 1),也就是在时间步上取最大值,相当于在整条评论里提取“最强烈的n-gram信号”。第5章会讲,如果你在这里改用平均池化而不带mask,Padding的零向量会把特征稀释。

num_filters=100意味着每种卷积核尺寸有100个滤波器,三种尺寸共300维特征。想控制参数量就降到50,想提精度就升到128到150,但超过256在影评数据上收益很小。transpose(1, 2)是因为nn.Conv1d的输入通道维在第二位,这个形状转换忘掉的话会直接报维度错误。CNN最大的优势是并行高效,没有时间步依赖,GPU利用率远高于LSTM,训练速度通常比同规模LSTM快3到5倍。

4.3 LSTM:为什么双向比单向更适合影评

LSTM的优势是能记住从前文一路读过来的上下文。单向LSTM在时间步t只能看到t之前的词;但影评里“没有想象中那么差”这种转折,要到句子末尾才能判断前面“差”是否被否定。双向LSTM用正向和反向两个隐藏状态拼接,让每个位置都能看到两侧信息,这也是LSTM这类循环神经网络做情感分析的常见标配。

class LSTMSentiment(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_size=128, num_layers=2, num_classes=2, dropout=0.3, pad_idx=0): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx) self.lstm = nn.LSTM( input_size=embed_dim, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, bidirectional=True, dropout=dropout if num_layers > 1 else 0 ) self.fc = nn.Linear(hidden_size * 2, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, input_ids, mask, lengths): emb = self.embedding(input_ids) # [B, T, D] output, (h_n, c_n) = self.lstm(emb) # 双向LSTM:取最后一层正向和反向的 hidden state 拼接 last_forward = h_n[-2] # [B, hidden] last_backward = h_n[-1] # [B, hidden] feat = torch.cat([last_forward, last_backward], dim=-1) return self.fc(self.dropout(feat))

h_n的形状是[num_layers*2, B, hidden]。num_layers=2时索引-2是最后一层正向,-1是最后一层反向,这个索引经常有人写错,取成第一层的状态就丢掉了深层语义。hidden_size=128配num_layers=2是入门配置,加深到3层在影评上提升有限,反而更容易过拟合。

LSTM的训练有三个配套手段:梯度裁剪、学习率衰减、早停。梯度裁剪是必须项,因为LSTM在长序列上梯度幅度波动大,不裁剪很容易出现loss跳到NaN。下面这段是可复用的训练循环骨架:

optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() for epoch in range(10): model.train() total_loss = 0 for input_ids, labels, mask, lengths in train_loader: optimizer.zero_grad() logits = model(input_ids, mask, lengths) loss = criterion(logits, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # 防梯度爆炸 optimizer.step() total_loss += loss.item() print(f"epoch {epoch} loss {total_loss / len(train_loader):.4f}")

max_norm=1.0表示把整个模型的梯度范数裁剪到不超过1.0,这个值在LSTM上安全;CNN和MLP可以不裁剪但加上也无害。lr=1e-3对Adam在影评数据上是比较稳的起点,loss震荡就先降到5e-4,降太慢可以试2e-3,但不建议一上来就用大学习率。

三种模型的代码到这里齐了。下一步不是急着调参,而是先跑一组实验看谁在验证集上先过拟合、谁收敛慢,这比凭直觉断定“CNN一定比LSTM好”靠谱得多。

5. 避坑排查:五条血泪经验,让你少调一周参

5.1 坑位一:mean池化把padding也平均进去了

现象:MLP的验证集准确率比预期低3到5个百分点,且短句预测特别差。

原因:有些实现直接emb.mean(dim=1),把<pad>对应的零向量参与求平均。一条10个词的评论被pad到50,真实特征的贡献被稀释到原来的五分之一,等于信息被“平均掉”了。

解决:只要做池化,就必须把mask乘进embedding再除以真实长度,也就是第4章MLP里的写法。如果图省事用了mean(dim=1),后面所有模型的池化逻辑都要回头改。

5.2 坑位二:词表min_count设太高,情感词被清空

现象:训练集loss下降正常,但验证集上<unk>占比高,模型几乎没见过测试时出现的口语词。

原因:min_count=5会把“垃圾”“巨烂”这类只在少量评论里出现但情感极强的词直接丢弃,或者测试集里“不错”被切成不同形式不在词表里。

解决:min_count从2起步,观察OOV在验证集上的覆盖率;如果词表超过5万,优先检查停用词表是否生效。另外把连续的英文、数字统一替换成<num>这类占位符,能显著压低OOV。

5.3 坑位三:LSTM第三个epoch loss直接变NaN

现象:前两个epoch正常,第三个epoch开始loss变成nan,之后一直nan,准确率归零。

原因:梯度在反向传播中累积到超出数值范围。LSTM比CNN更容易触发,尤其学习率偏大、序列偏长时。

解决:在每次loss.backward()之后加clip_grad_norm_,同时把学习率降到1e-3以下。NaN一旦出现,之前训练好的权重也没救了,除非在训练循环里每个epoch保存一次model.state_dict()——这是最便宜的后悔药。

5.4 坑位四:测试集与训练集重复,指标虚高

现象:测试集准确率比验证集还高3个点以上,人工抽查错误样本却完全看不懂模型为什么对。

原因:爬虫抓取时同一影评重复出现,去重只做了dataframe层面但没做文本级去重;或者按时间划分时,训练集和测试集时间窗口重叠。

解决:划分前先按文本去重(第2章的drop_duplicates(subset=["comment"])),再按电影ID分组划分。一句话概括:测试集要保证模型“真的没见过”这段文本,而不是同一句话换个ID混进来。

5.5 坑位五:过拟合与早停——验证集loss开始回升就停

现象:训练loss持续下降,但验证集loss在第4个epoch掉头向上,准确率止步不前。

原因:模型容量超过数据复杂度,开始死记训练集里的影评。

解决:Dropout从0.3提到0.5,LSTM的hidden_size从128降到64,同时加早停。早停最简单的做法是监视验证集loss,连续3个epoch没有下降就回退到最佳checkpoint,这个逻辑可以直接写进训练循环。

这五条不会一次踩全,但踩到任何一条的反应时间都能从一天缩短到半小时。处理完这些,“换模型是不是就能涨点”才是一个值得讨论的问题。

6. 进阶验证:三模型公平对比表与错误样本分析方法

6.1 三模型公平对比的固定配置表

模型对比最常犯的错误是不同模型用了不同的超参数,最后说“谁好谁坏”。公平对比必须固定数据划分、embed_dim、batch_size、epoch上限(或统一用早停),只改变网络结构。我习惯先跑一张固定配置表:

模型参数量训练时间(秒/epoch)验证准确率测试准确率测试F1
MLP待填充待填充0.XX0.XX0.XX
CNN待填充待填充0.XX0.XX0.XX
LSTM待填充待填充0.XX0.XX0.XX

参数量的计算不用手算,PyTorch里一行sum(p.numel() for p in model.parameters())就够。训练时间用秒/epoch记录,CNN通常比LSTM快数倍,这个差距在需要反复调参时比0.5个点的准确率重要得多——如果LSTM只高出一个点但慢五倍,线下实验可能无所谓,线上推理就得重新权衡。

6.2 用错误样本分析决定下一步调参方向

除了跑分,我还习惯做错误样本分析:把测试集里预测错误的样本按softmax概率置信度排序,人工看Top 20。影评里最常见的错误类型是反讽和否定范围,比如“这片子烂到让我笑了”“没有想象中那么差”。如果错误集中在长句,说明CNN的局部窗口不够,LSTM调整会有收益;如果错误集中在短句和稀有词,说明问题在词表覆盖率,换模型没用。这一步能决定后续调参方向,比盲目在三种模型间来回切换有效得多。

最后说个我自己踩过的教训:第一次做影评情感分析时,我把全部精力放在LSTM调参上,忘了给池化加mask,结果LSTM和CNN分数几乎一样,还以为是随机种子问题。后来把mask补上,LSTM才真正拉开差距。数据和形状处理永远是这类任务的地基。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进