简介:面向计算机专业毕业设计与课程作业的实战项目,利用长短期记忆网络对京东商城的用户评论进行情感分类,完整覆盖从数据爬取、文本去噪、模型训练到效果评估的流程。压缩包内共有39个文件,大小约164兆,主要包含Python源代码、模型权重与检查点、TensorFlow数据文件、配置文件、说明文档以及训练过程可视化图片。其中脚本与文档便于修改复现,模型文件可直接加载应用。项目按照爬虫模块与分析模块进行组织,提供停用词表、训练好的模型以及启动脚本,清晰展现了长短期记忆网络中的遗忘门、输入门和输出门机制,同时探讨了Python训练模型与C++部署推理的工程思路,能够帮助深入理解真实电商评论的情感分类实现方案。目前已有174人学习,项目适合需要快速搭建情感分析系统并参考完整工程结构的初学者或毕业设计学生。
1. 基于深度学习(LSTM)的情感分析,拆开就是一条数据处理流水线
一份「毕设&课程作业_基于深度学习(LSTM)的情感分析(京东商城数据).zip」传到你手上时,多数人的第一反应是“解压、跑通、出结果、写报告”。真做一遍才会发现:LSTM 模型本身只占整个工程不到两成的工作量,最耗时的是把京东的原始评论清洗成模型能吃的数值序列。这个项目的本质是一条完整的数据流水线——抓评论、去噪、分词、建词表、序列化、训练 LSTM、看指标、调参、再训练。它适合两类人:期末要交课程设计或毕业论文的学生,以及第一次用深度学习做文本分类、想知道 LSTM 到底怎么落地的开发者。照着下面的步骤走,你能从头重建这个项目,并知道每个参数动了会发生什么、哪些环节最容易翻车。
2. 京东评论数据的预处理:清洗、分词与序列化
2.1 数据从哪来:自己抓评论与用现成数据集的取舍
京东商城数据的获取,常见做法是两条路:一条是直接用网上公开的电商评论数据集,另一条是自己写脚本从商品评论页抓。“直接下载数据集”的好处是省时间,坏处是你不知道这份数据的标签规则、评分分布和重复情况,运气不好还得花几天做数据清洗。自己抓的好处是标签可靠——京东把好评、中评、差评直接分好了,你不需要自己标注;坏处是反爬机制和页面结构变化会让脚本变得脆弱。
对毕设和课程作业来说,我一般建议:如果导师没指定必须自己采数据,优先用现成数据集,把精力省给清洗和模型调参。如果一定要抓,不要用正则去解析整个 HTML,而是直接请求商品评论的 JSON 接口。京东评论接口返回的字段里包含评论内容、评分、点赞数、追评时间和商品规格,拿到后只保留你需要的字段,存成 CSV,比存 HTML 再解析要稳得多。无论哪条路,最终都要落到一份带“评论文本”和“标签”两列的 CSV 上,标签要么是 1/0,要么是好评/差评这样可映射的文本。
2.2 清洗与分词:第一道决定模型上限的工序
我见过太多人把时间花在调 LSTM 结构上,却忽略了清洗。京东评论区里最常见的噪音有:HTML 残留、URL、表情符号、重复标点、商品规格描述、系统默认好评的模板句。这些噪音不处理,词表会被撑大,训练出来的 embedding 会被无效 token 干扰。清洗的原则是“宁多勿少”——分词前把能去掉的非文本内容全部去掉,因为 LSTM 对词表外的词只能给一个 unk 标记,噪音越多,unk 比例越高,模型学到的语义就越稀薄。
下面这段清洗脚本是我在类似项目里常用的起步写法:
import re import jieba def clean_and_seg(text): # 1. 去掉 HTML 标签和链接 text = re.sub(r"<[^>]+>", "", text) text = re.sub(r"http\S+|www\.\S+", "", text) # 2. 去掉空白字符和重复标点 text = re.sub(r"\s+", " ", text) text = re.sub(r"([。!?,,.!?])\1+", r"\1", text) # 3. 只保留中文、英文和数字,其余字符全部丢弃 text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", "", text) # 4. jieba 精确模式分词 return [w for w in jieba.cut(text) if w.strip()]这段脚本的逻辑很直白:先去掉标签和链接,再压缩重复标点,最后用“白名单”方式把表情、特殊符号、emoji 全部过滤掉。之所以用白名单而不是黑名单,是因为评论里的特殊字符种类太多,黑名单永远列不全。分词用 jieba 的精确模式,适合短评;如果是整段长文,可以考虑 jieba 的搜索引擎模式,但对京东评论这种十几二十个字的短文本,精确模式更不容易切碎语义。
分词完成后要做一步很多人会省略的操作:去停用词。常见做法是维护一个停用词表,把“的、了、是、在、吗”这类高频虚词过滤掉。但注意不要一刀切——京东评论里“物流快”“客服差”这类表达里,“快”“差”才是情感核心,所以停用词表只需要覆盖没有情感色彩的虚词,不要覆盖形容词和副词。这一步做完,你就得到了一条由词组成的列表,接下来要把它变成数值。
2.3 词表构建与序列化:让中文变成模型能吃的矩阵
LSTM 不能直接吃中文文本,它吃的是数值序列。所以需要构建一个词表,把每个词映射成一个整数 id。构建词表的常见做法是统计全量训练数据里的词频,保留出现次数超过阈值的词,其余映射到 unk。这里有两个参数需要拍板:最低词频 MIN_COUNT 和最大词表大小 MAX_VOCAB。MIN_COUNT 设 1 会导致低频词太多,词表爆炸且这些词几乎没有有效 embedding;设 5 又可能把一些重要的情感词漏掉。对于几万条评论的规模,我一般设 MIN_COUNT=2,MAX_VOCAB=50000,够用且不会让模型过大。
序列化的核心是“定长填充”,因为 LSTM 的一个 batch 必须是同形状的张量。京东评论大多很短,但总有几百字的长评。常见做法是设定 max_len,超过的部分从尾部截断,不足的部分用 pad 标记补齐。这里有个值得注意的细节:截断应该保留开头还是结尾。我的经验是保留开头部分,因为绝大多数用户会把主要评价写在前面,后半段往往是补充或者复述。下面是词表构建和序列化代码:
from collections import Counter MIN_COUNT = 2 MAX_VOCAB = 50000 MAX_LEN = 100 # 统计训练集所有分词结果的词频 counts = Counter() for seq in train_seqs: # train_seqs 是分词后的列表的列表 counts.update(seq) # 构建词表,id 0 留给 pad,id 1 留给 unk vocab = {"<pad>": 0, "<unk>": 1} for word, cnt in counts.most_common(MAX_VOCAB): if cnt >= MIN_COUNT: vocab[word] = len(vocab) def encode(seq, vocab, max_len=MAX_LEN): ids = [vocab.get(w, vocab["<unk>"]) for w in seq[:max_len]] if len(ids) < max_len: ids = ids + [vocab["<pad>"]] * (max_len - len(ids)) return ids这段代码的重点有两个:一是 pad 的 id 必须独立占位,且在 embedding 层里指定 padding_idx,这样模型对 pad 位置的 embedding 不会更新,也不会让 pad 参与语义计算;二是 unk 的 id 固定为 1,凡是词表外的词都映射到它,这能保证训练和预测时遇到新词行为一致。序列化完成后,把每条评论的 id 数组和标签组合成训练样本,交给 DataLoader 做 batch 迭代。
3. LSTM 情感分析模型拆解:从公式到 PyTorch 实现
3.1 为什么选 LSTM 而不是 RNN 或 CNN
很多人直接跳到调参,却没想清楚“为什么是 LSTM”。简单循环神经网络 RNN 的问题在于,当句子长度超过十几二十个词时,梯度在反向传播中会指数衰减,导致前面的词学不到有效的上下文信息,这就是常说的梯度消失。LSTM 通过输入门、遗忘门、输出门和记忆单元,给信息提供了一条跨时间的“高速公路”,让梯度可以沿着记忆单元传导到较远的位置,因此对短文本情感分析这种任务特别合适。
CNN 也不是不能用,它擅长抓局部 n-gram 特征,比如“太差”“非常好”这种固定搭配,但 CNN 感受野有限,要覆盖长距离依赖得堆很多层。京东评论虽然是短文本,但“虽然物流慢,但客服态度好”这种转折句,情感极性往往由后半句决定,LSTM 的序列建模能力对这种结构更友好。这也是为什么这个方向的典型方案都以 LSTM 或 BiLSTM 做主力结构,而不是 CNN。
对中文评论情感分析,我一般建议直接用双向 LSTM。单向 LSTM 只能看到当前词之前的信息,双向 LSTM 同时从前往后和从后往前扫描,每个位置都能聚合完整上下文。说句实话,对一句话里的情感词,单向往往已经够用,但双向在验证集上普遍能涨一到两个百分点的准确率,代价是训练时间几乎翻倍。毕设项目里,这个加价是值得的。
3.2 一个能跑的最小模型:Embedding 加双向 LSTM 加分类头
模型结构可以拆成三段:Embedding 层负责把词 id 映射成稠密向量;LSTM 层负责把这个向量序列编码成上下文相关的隐藏状态;最后的全连接分类头把隐藏状态映射成类别分数。PyTorch 里的实现非常紧凑,下面是一个完整的可运行模型定义:
import torch import torch.nn as nn class LSTMSentiment(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_size=256, num_layers=1, num_classes=2): super().__init__() self.embedding = nn.Embedding( vocab_size, embed_dim, padding_idx=0 ) self.lstm = nn.LSTM( embed_dim, hidden_size, num_layers, batch_first=True, bidirectional=True, ) self.fc = nn.Linear(hidden_size * 2, num_classes) def forward(self, x): # x: (batch, seq_len) emb = self.embedding(x) # (batch, seq_len, embed_dim) out, _ = self.lstm(emb) # out: (batch, seq_len, hidden*2) last = out[:, -1, :] # 取最后一个时间步的输出 return self.fc(last) # (batch, num_classes)几个参数需要说明。embed_dim 是每个词的向量维度,128 是常见起步值,词的语义容量不够时再调大,但超过 300 收益会变小。hidden_size 是 LSTM 隐层维度,256 对这种规模的数据完全够。num_layers 设 1 最稳,LSTM 每多一层,训练难度和过拟合风险都会上升,京东评论这种短文本,两层以上的收益非常有限。
最后一个时间步的取法值得单独说。bidirectional=True 时,模型实际上有两个方向的 LSTM,它们的输出在最后一维拼接,所以全连接层输入维度是 hidden_size * 2。取 out[:, -1, :] 表示取每个序列最后一个时间步的输出。对双向 LSTM 而言,这个位置已经聚合了前向的完整信息和反向最后一个词的信息,经验上比直接取隐藏状态 h 更稳。有人会把所有时间步的输出做池化,常见的有平均池化和最大池化,效果在某些数据集上更好,但这里用最后一个时间步足以作为能跑的基线。
3.3 损失函数与优化器:交叉熵、Adam 与学习率的搭配
情感分类是标准的多分类问题,损失函数直接用交叉熵,PyTorch 里就是 nn.CrossEntropyLoss。这里有个容易被忽略的点:CrossEntropyLoss 内部已经把 softmax 做了,所以模型最后一层不要额外加 softmax 激活,直接输出原始 logits 就行。如果加了 softmax,反向传播时数值会变得特别平缓,训练会非常慢,这是新手最常见的翻车点之一。
优化器我用 Adam,学习率从 1e-3 起步。SGD 在这类任务上收敛太慢,而且对学习率的设置极其敏感。Adam 自带自适应调整,1e-3 是一个能让模型在前几个 epoch 明显下降的配置。训练到中后段时,可以切换到学习率衰减策略,后面章节会细说。另一个必要的操作是梯度裁剪,因为 LSTM 即使有门结构,遇到特别长的评论时仍可能梯度爆炸,一个简单做法是把梯度范数限制在 5.0 以内,几乎不会损失效果,却能避免训练 loss 突然变成 NaN。
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() clip_value = 5.0 for epoch in range(epochs): model.train() for batch in train_loader: x = batch["input_ids"].to(device) y = batch["label"].to(device) optimizer.zero_grad() logits = model(x) loss = criterion(logits, y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), clip_value) optimizer.step()这段训练循环里,clip_grad_norm_ 是 PyTorch 对梯度张量做全局范数裁剪的接口,参数 clip_value 代表裁剪阈值。它做的事可以理解为“如果所有参数的梯度拼接起来长度超过 5,就等比例缩小”,这样既保留梯度方向,又避免大梯度把参数顶到非正常区域。训练时建议打印每个 epoch 的训练集 loss 和验证集 loss,不要只盯准确率——loss 曲线能更早暴露过拟合和欠拟合。
4. 训练与评估:早停、学习率衰减和混淆矩阵的正确用法
4.1 训练循环之外:验证集划分与 batch 设计
很多课程作业把数据分成训练集和测试集就开跑,这是个大问题。测试集只能在最后评估时用一次,中间频繁用它调参,结果就是模型悄悄“记住”了测试集,答辩时看起来分数不错,换一批新数据就露馅。正确做法是划分出训练集、验证集、测试集三份,比例常见的是 8:1:1。验证集负责早停和调超参,测试集只在最终报告时用一次。
DataLoader 的设计也有讲究。batch_size 我一般设 64 或 128,取决于显存大小。这里不要为了“更快”把 batch 设到很大,LSTM 本身是串行结构,batch 增大不会像 CNN 那样线性加速,反而会占更多显存。shuffle 必须打开,否则每个 epoch 的 batch 顺序固定,模型可能学到样本顺序的假规律。还有一个容易踩的坑:京东评论里的好评数量通常远超差评,如果训练集按原始分布切分,负样本可能太少。要么在划分时做分层采样,要么在采样器里给差评样本更高的权重,否则后面准会出问题。
4.2 早停与学习率衰减:用验证集损失拦住过拟合
LSTM 在几千到几万条样本的训练集上很容易过拟合,特征就是训练集 loss 继续下降、验证集 loss 却开始掉头上涨。这时候如果你没有早停机制,模型会用后面那些“专门记住训练集噪音”的权重覆盖之前的最佳状态,等训练结束再看,验证集指标已经烂掉了。所以我的训练脚本里一定会保存“验证集损失最小的模型参数”,而不是训练结束时的参数。
早停的常见实现是设置一个 patience 值,比如连续 3 个 epoch 验证集 loss 没有刷新最低值,就停止训练。学习率衰减可以和早停配合:每经过一个没有改善的 epoch,把学习率乘以 0.5,让模型在逼近最优解时用小步长微调。下面是一段带早停和动态学习率的训练框架:
best_val_loss = float("inf") best_state = None patience = 3 no_improve = 0 scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="min", factor=0.5, patience=1 ) for epoch in range(epochs): train_loss = train_one_epoch(model, train_loader, optimizer, criterion) val_loss = evaluate(model, val_loader, criterion) if val_loss < best_val_loss: best_val_loss = val_loss best_state = {k: v.clone() for k, v in model.state_dict().items()} no_improve = 0 else: no_improve += 1 if no_improve >= patience: print(f"epoch {epoch}: early stop") break scheduler.step(val_loss) model.load_state_dict(best_state)这段代码里有个值得琢磨的点:为什么不直接保存模型文件,而是深拷贝 state_dict?因为训练还在进行,当前盘里的权重可能下一秒就被覆盖,深拷贝到内存里最保险。等早停触发后再 load 回去,模型处在验证集表现最好的位置。ReduceLROnPlateau 的 mode="min" 表示监控 loss,loss 不再下降时学习率乘以 factor=0.5。patience 设 3 意味着最多容忍 3 个 epoch 没有突破,再多就停。对课程作业这种数据规模,10 到 15 个 epoch 内大概率能触发早停。
4.3 结果解读:准确率之外,更要看混淆矩阵
到了出结果这一步,很多报告只写一行“准确率 92%”,这在答辩时很容易被追问卡住。92% 的准确率到底意味着什么?如果测试集里 90% 是好评,那模型只要全预测好评就有 90% 准确率,和瞎猜差不多。所以必须看混淆矩阵,把预测结果拆成四个数字:真正例、真负例、假正例、假负例。
from sklearn.metrics import confusion_matrix, classification_report preds, labels = predict_model(model, test_loader) print(confusion_matrix(labels, preds)) print(classification_report( labels, preds, target_names=["差评", "好评"] ))混淆矩阵输出是一个 2x2 矩阵,第一行是真实的差评,第二行是真实的好评;第一列是预测差评,第二列是预测好评。对角线上的数字越大越好。分类报告里最重要的不是准确率,而是 F1 分数——它同时惩罚漏报和误报。如果差评的 F1 明显低于好评,说明模型倾向于把所有评论都预测成好评,这在情感分析里是一个非常典型的问题,下一章会专门讲怎么处理。
在写报告或者答辩 PPT 时,最好选几条预测错误的样本,手工分析错误原因。常见的有三种:反讽表达、数据标注本身有误、含有多个评价维度的复合评论。把这三类挑出来分析,比堆十个技术名词更能说明你真的理解了这个任务。
5. LSTM 情感分析避坑与排查:五个最容易反复折腾的环节
5.1 现象:训练集 loss 一路下降,验证集 loss 掉头上涨,准确率却还在升
这个现象我第一次跑 LSTM 时也遇到过,看起来很矛盾:验证集准确率在涨,loss 却在涨。原因是 loss 反映的是概率分布的对数损失,模型预测越来越“自信”,但自信的方向有一部分是错的;准确率只看是否预测对了类别,没考虑置信度。所以准确率可能还在微涨,但整体分布已经偏离了真实标签。
原因其实就是过拟合。解决手段按优先级来:先加 dropout,在 LSTM 层和全连接层之间加 nn.Dropout(0.5);再调小 hidden_size 或 embed_dim;如果还不行,检查训练集里是不是有大量重复文本,京东评论里“默认好评”模板句如果被重复取样,会让模型死记这些句子。用 pandas 的 duplicated 方法检查并去重,往往立竿见影。
5.2 现象:词表构建到一半内存爆掉,训练集莫名其妙变大
原因基本集中在两个地方:一是清洗没做干净,评论里混入了整段 JavaScript 或 CSS;二是没有对超长评论做截断,一条几万字的复制的商品介绍被当成一条评论。京东商品页里经常有“此用户未填写评价内容”或系统模板,这类文本没有情感信息,留在训练集里只会增加噪音。解决方法是清洗脚本里加一个长度过滤,分词后超过 300 个词的直接截断,少于 2 个词的直接去除。另一个隐蔽的问题是重复评论:同一个用户对不同商品复制了相同评论,这些样本会导致验证集和训练集分布不一致,务必按文本内容去重。
5.3 现象:训练完模型全输出“好评”,准确率还有八九十
这是情感分析最经典的翻车现场。原因几乎都是类别不平衡:京东商城的好评占比动辄九成以上,如果数据采集时没控制比例,模型不学特征、直接全部预测好评,就已经能拿到很高的准确率。这时候你会看到混淆矩阵第一行几乎全零,差评一条都没抓出来。
解决思路有三条,按推荐程度排序。第一条是数据层面重采样,对差评做上采样或对好评做下采样,让两类比例接近 1:1;第二条是损失函数层面加权重,在 CrossEntropyLoss 里传入 weight 参数,把差评类的 loss 权重调高到 2 到 5 之间;第三条是评价指标层面改用 F1 作为主要指标,而不是准确率。课程作业做到第一条就足够,答辩时能说清楚“差评误判代价更高”这个点,比调参技巧更打动老师。
5.4 现象:训练到一半显存溢出,调小 batch 后又特别慢
LSTM 的显存占用和批量大小、序列长度强相关。最大开销来自每个时间步都要保存一份隐状态用于反向传播,所以序列越长,占用越大。100 的 max_len 配合 256 的 hidden_size,一个 batch 为 64 时显存约在 2GB 以下;如果 max_len 放到 300,显存会直接翻两三倍。
解决方法是先审视 max_len 是否合理。统计一下训练集评论分词后的长度分布,选 P95 作为 max_len 而不是直接拍脑袋设 100 或 200,大多数场景下这个数字在 60 到 120 之间。如果调参后显存还是吃紧,换更小的 batch_size 配合梯度累积,概念上等价于一个更大 batch,但对显存更友好。还有一种常见做法是训练时关闭梯度计算里的中间缓存,比如用 torch.jit 或检查 LSTM 实现,但课程作业阶段没必要玩这么花,减 max_len 才是正道。
5.5 现象:保存模型后重新加载,预测结果和训练时不一样
这个问题几乎每个跑过 PyTorch 的人都遇到过。第一次跑发现训练时 loss 很低,加载保存的模型再预测,效果稀烂。常见原因是保存和加载时模型状态不一致。排查顺序:模型类必须在加载前用相同参数实例化;加载权重时指定 map_location=device,避免模型被加载到 CPU 而训练在 GPU;加载后一定要调用 model.eval(),否则 dropout 和 batch norm 仍然按训练模式跑,dropout 会让预测结果带随机性。
另一个隐蔽原因是随机种子没有固定,DataLoader 的 shuffle 每次运行都重新打乱,如果模型没有固定随机种子,每次训练出来的权重都会有差异。这是“玄学”,但不是真玄学,而是没有固定 seed。在脚本开头设置 torch.manual_seed(42) 和 random.seed(42),并把 DataLoader 的 shuffle 用同一个种子初始化,结果就能复现。毕设项目里这一步尤其重要,因为答辩时老师很可能让你现场重新跑一遍训练,种子固定能让结果和报告一致。
6. 让演示多几分底气:注意力可视化、模型导出与验证曲线
6.1 简化注意力可视化:把 LSTM 的“注意力”画出来
很多课程作业的项目止步于“跑出了准确率”,但答辩时老师一旦问“模型为什么认为这条评论是好评”,就只能干瞪眼。一个既简单又加分的做法是做一个简化的注意力可视化:把 LSTM 最后一个时间步的输出当作 query,计算每个位置输出与它的余弦相似度,再用 softmax 归一化成权重,权重高的词就是模型“重点看”的词。这个方法虽然不是论文里的标准注意力机制,但对“解释模型行为”这个目标足够用。
def get_attention(model, token_ids, vocab): model.eval() with torch.no_grad(): x = torch.tensor([token_ids], device=next(model.parameters()).device) emb = model.embedding(x) # (1, seq_len, embed_dim) out, _ = model.lstm(emb) # (1, seq_len, hidden*2) q = out[:, -1, :] # 最后时刻输出作为 query scores = torch.cosine_similarity(out[0], q[0], dim=-1) weights = torch.softmax(scores, dim=-1).tolist() words = [vocab_id_to_word.get(i, "<unk>") for i in token_ids] return list(zip(words, weights))把权重最高的前 10 个词打印出来,和人工判断核对。比如“物流”“垃圾”“客服”这类词权重高,说明模型确实学到了情感倾向;如果权重全落在“的”“了”这类词上,说明清洗和词表构建还不够好,模型没学到重点。这套可视化输出可以直接放进答辩 PPT,比贴十个 loss 曲线的说服力强得多。
6.2 模型导出与答辩演示脚本:保证现场不翻车
答辩现场最容易出的状况是环境不一致。笔记本上训练好的模型,换到教室电脑上,PyTorch 版本不一样,直接 load 可能报错。我一般会做两件事兜底:一是导出时同时保存词表 vocab.json 和模型权重,加载时先从词表重建模型的 vocab_size,再加载权重;二是写一个独立的 predict.py 脚本,输入一句评论文本、输出情感类别和置信度,确保演示时不会突然冒出维度错误。
torch.save({ "model_state": model.state_dict(), "vocab": vocab, "config": { "vocab_size": len(vocab), "embed_dim": 128, "hidden_size": 256, "num_layers": 1, } }, "lstm_sentiment.pt")加载的时候按 config 重建模型,再 load state_dict,词表直接从文件里取,避免代码运行环境不一致导致的 key 错误。这个习惯不仅对演示有用,也是把实验模型变成可复用产物的重要一步。最后一章开头说的“模型部署”,本质就是从这一步开始的——把训练逻辑和预测逻辑彻底分开。
我的习惯是每次训练完都把这份 checkpoint 保留,而不是只覆盖保存。因为调参过程中,某个阶段的模型可能在验证集上不如最新版,但在某些类型的评论上反而更好。留下历史版本,等于给实验留了后悔药。希望这些踩坑经验能帮到你,少在深夜对着 loss 曲线发呆。
本文还有配套的精品资源,点击获取