简介:一套面向计算机专业毕业设计与课程作业的深度学习情感分析项目资料,基于长短时记忆网络(LSTM)对京东商城评论数据进行情感分类,完整覆盖数据爬取、清洗、预处理、模型训练与评估全流程。压缩包共39个文件,含8个Python脚本、模型权重、数据分片、索引、检查点、训练结果图片及说明文档等,整体大小164.28MB,模块划分清晰,便于直接运行与二次开发。已有173人浏览学习。资料内嵌京东商品评论爬虫与情感分析模型两大模块,附带停用词表、结果图片和已训练模型;通过研读代码与文档,可深入理解LSTM中遗忘门、输入门、输出门如何解决长依赖问题,并将其应用于电商评论情感识别。适合作为NLP课程设计、毕设课题或深度学习入门实践参考,也可替换数据集快速迁移到其他领域。
1. 这个毕设项目到底在做什么:从京东评论到 LSTM 情感分类
很多人拿到这个标题时,以为难点在 LSTM 神经网络本身,真正做下来才会发现,基于深度学习(LSTM)的情感分析项目,最花时间的是把京东商城数据洗成模型能吃的样子。这个毕设/课程作业要解决的事情很具体:给一段京东买家评论,让模型判断它是好评还是差评。项目可能只有几千条评论,也可能有几十万条,但流程几乎都一样:清洗、分词、词表构建、LSTM 训练、评估。适合正在做深度学习课程设计、或者想用 NLP 方向项目做毕业设计的同学。我给你的第一条建议是:先别急着调 LSTM 的层数,先把清洗到序列化的数据管道跑通,项目就成了八成了。
2. 京东商城数据怎么处理成 LSTM 能吃的样子:清洗、分词与序列化
京东商城原始评论最常见的是一个 CSV,一行一条记录。字段一般包含用户名、商品ID、评论内容、评分(1~5)、点赞数、评论时间等。有的数据包会提前给你打好评/差评标签,有的只给评分和评论文本,需要你自己构造标签。先明确目标:LSTM 的输入是一段定长的整数索引序列,输出是好评/差评的概率,所以中间所有处理都是围绕“把字符串变成定长数字序列”来做。
2.1 京东评论数据的常见字段与“脏数据”长什么样
先读进来看一下。我用 pandas 读 CSV,打印形状和前几行,并统计 score 分布。
import pandas as pd df = pd.read_csv('jd_comments.csv', encoding='utf-8-sig') print(df.shape) print(df.head(5)) print(df['score'].value_counts())读取时会遇到的第一坑是编码。京东系爬下来的 CSV 经常是 gbk 或 utf-8-sig 而不是纯 utf-8。我一般直接写utf-8-sig,它能自动吃掉开头的 BOM 头。如果文件是 gbk,就改成encoding='gbk'再试。不要一个编码走到黑,报UnicodeDecodeError就换。
“脏数据”在评论区尤其多:无意义的“此用户没有填写评价内容”;夹杂“【赠品】”和“回复”;链接和 HTML;emoji 表情;还有大量完全重复的评论。另外 score 字段不一定和文本一致,有人给 1 分却写“好用”,也有人给 5 分写“垃圾”。要降低这种噪声,如果做的是二分类,建议把 score 为 3 分的中评丢掉,因为“一般般”这类中性表达会让模型很困惑。如果数据包里已经给了 label 字段,就优先用 label。
2.2 清洗与去重:为什么不能只做 strip
常见误用是只str.strip()一下就去分词。文本里带着 HTML 和 URL,分词后会产生大量“a”“href”这种噪声词。我的清洗函数处理四件事:去掉 URL、去掉 HTML 标签、统一小写、把非中英文和数字的字符换成空格,最后压缩连续空格。
import re def clean_comment(text: str) -> str: if not isinstance(text, str): return '' text = re.sub(r'http\S+', '', text) # 去掉URL text = re.sub(r'<[^>]+>', '', text) # 去掉HTML标签 text = text.lower() # 统一小写,避免Apple/apple分开 text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9]', ' ', text) text = re.sub(r'\s+', ' ', text).strip() return text df['comment_clean'] = df['comment'].apply(clean_comment) df = df[df['comment_clean'].str.len() >= 2] # 去掉太短的评论 df = df.drop_duplicates(subset=['comment_clean']) if 'score' in df.columns: df = df[df['score'] != 3] # 二分类时去掉中评这里的关键点是:不要直接删除标点,而是替换成空格。比如“质量不错,喜欢”和“质量不错。喜欢”,直接删标点会变成“质量不错喜欢”,句意还在;但“不要”这种否定结构如果被粗暴删除字符,词序会被破坏。替换成空格后,标点变成天然的分词边界。
清洗之后去重要用清洗后的文本,不是原始文本。默认评论可以直接匹配字符串删掉。最后过滤掉长度小于 2 的样本,“好”“行”这类单字评论没有足够上下文,留着反而干扰训练。
2.3 分词与停用词:jieba 的自定义词典和停用表
中文分词最常用的还是 jieba。京东评论里有很多品牌名和商品型号,比如“iPhone”“ThinkPad”“海尔洗衣机”,如果不加自定义词典,jieba 会拆成“think”“pad”。我会准备一份用户词典jd_words.txt,每行一个词,格式是“词 词频 词性”,例如:
海尔洗衣机 100 nt ThinkPad 50 nz iPhone 100 nzimport jieba jieba.load_userdict('jd_words.txt') def segment(text: str) -> list: return list(jieba.lcut(text)) df['words'] = df['comment_clean'].apply(segment) stopwords = set() with open('stopwords.txt', encoding='utf-8') as f: for line in f: stopwords.add(line.strip()) def filter_stop(words): return [w for w in words if w not in stopwords and w.strip()] df['words_filtered'] = df['words'].apply(filter_stop)分词后做停用词过滤。注意停用词表不是越全越好。“不”“没”“别”“虽然”“但是”这些词绝对不能进停用表,它们是情感转折的关键。很多同学复现时直接用网上下载的通用停用表,把“不”也滤掉,结果差评全变成好评,这种翻车现场很常见。如果不想自己筛,就只过滤标点和单字,删除“的、了、吧、啊”这类虚词。
2.4 序列化与 Padding:把词变成索引,把句子变成等长
LSTM 要求一个 batch 内每条样本长度一致,所以我要先把词表建出来。常见做法是统计词频,每个词给一个下标,保留两个特殊符号:0 给<pad>,1 给<unk>。为了控制词表大小,只保留出现次数 >= 2 的词,低频词全映射到<unk>。
from collections import Counter counter = Counter() for words in df['words_filtered']: counter.update(words) vocab = {'<pad>': 0, '<unk>': 1} for word, freq in counter.most_common(): if freq >= 2: vocab[word] = len(vocab) def words_to_ids(words): return [vocab.get(w, vocab['<unk>']) for w in words] df['ids'] = df['words_filtered'].apply(words_to_ids)接下来自己实现 padding,顺便把训练和预测阶段都会用到的max_len定下来。我一般取 100,因为京东评论 80% 以上都在 100 字以内,太短会截掉转折句,太长会增大无效 padding,让 LSTM 多跑很多无意义的<pad>位置。
import numpy as np max_len = 100 def pad_ids(ids_list): X = [] for ids in ids_list: if len(ids) >= max_len: ids = ids[:max_len] else: ids = ids + [0] * (max_len - len(ids)) X.append(ids) return np.array(X, dtype=np.int64) X = pad_ids(df['ids']) if 'label' in df.columns: y = df['label'].astype(int).values else: y = (df['score'] >= 4).astype(int).values这里有两个边界参数值得注意。词表阈值设为 2,表示只出现过一次的词全部变成<unk>,优点是小词表、训练快,缺点是测试集里的生僻词可能全部失效。如果你发现测试集准确率明显低于验证集,先检查是不是 unk 太多。另一个是max_len,不要为了凑模型输入把长度调到 200,序列越长训练越慢,性能也不一定更好。
还有一点很重要:构建词表只能用训练集,不能拿全量数据去统计词频,否则等于提前让模型看到测试集词汇,这属于数据泄漏,答辩时老师一追问就会露馅。词表和max_len一旦定下来,训练、验证、测试全过程都要保持一致。
3. 搭一个能跑通的 LSTM 情感分析模型:PyTorch 实现与参数选择
3.1 为什么选 LSTM 而不是 CNN 或直接用全连接
情感判断高度依赖词序和上下文。比如“物流很快但质量差”,如果把词序打乱或者只看局部窗口,就很容易判错。LSTM 的循环结构能把前文的隐藏状态传递给下一步,相当于带着记忆读完整句话,所以处理这种带转折的句子比 CNN 更自然。CNN 可以捕捉局部 n-gram,但感受野之外的依赖要叠很多层才能触及;全连接则完全丢掉词序,只能当词袋模型用。毕设里选择 LSTM,除了效果,还因为它的门控机制好讲、公式好画图,导师不容易质疑。
但代价也要知道:LSTM 的训练速度比 CNN 慢,在小数据集上更容易过拟合。所以后面参数选择上要刻意加约束,不是网络越深越好。
3.2 词嵌入层:随机初始化还是预训练词向量
词嵌入层本质是查表,把词索引变成稠密向量。有两种初始化方式。随机初始化就是nn.Embedding默认随机生成,让模型在训练中自己学词向量;优点是省事,和数据匹配度最高,缺点是训练数据少时学不出足够好的语义关系。预训练词向量是用 gensim 加载中文 word2vec,然后对模型词表里的每个词去预训练词典里查向量,查不到的变成随机初始化向量。对几千条评论的课程作业,预训练向量通常能让准确率提升 2~5 个点,但需要处理词表对齐,稍麻烦。
常见做法是先用随机初始化把整个管道跑通,确认 loss 能下降,再决定要不要换预训练向量。如果一开始就加预训练,变量太多,排查问题会很痛苦。
3.3 LSTM 层、Dropout 与全连接分类头的配合
我的默认结构是:Embedding(100维) -> 双层LSTM(隐藏128) -> 平均池化 -> Dropout -> 全连接输出2分类。下面这段是可以直接复制训练的最小模型。
import torch import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embed_size=100, hidden_size=128, num_layers=2, num_classes=2, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) self.lstm = nn.LSTM(embed_size, hidden_size, num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_size, num_classes) def forward(self, x): emb = self.embedding(x) # [batch, seq_len, embed_size] out, _ = self.lstm(emb) # out: [batch, seq_len, hidden_size] mask = (x != 0).unsqueeze(-1) # [batch, seq_len, 1] masked = out * mask lengths = mask.sum(dim=1) # [batch, 1] pooled = masked.sum(dim=1) / lengths.clamp(min=1) pooled = self.dropout(pooled) return self.fc(pooled)说明一下为什么用平均池化而不是out[:, -1]。因为 padding 后句末是无数个<pad>,out[:, -1]基本是模型处理 pad 之后的状态,没有信息量。平均池化把所有有效位置的状态取均值,对 pad 不敏感。如果你想用最后一个真实词的隐状态,需要借助pack_padded_sequence,这里不展开,但你知道有这个方案即可。
hidden_size=128是起步值,数据量小就降到 64;num_layers=2表达能力更强,但数据低于 2 万条建议单层起步,不然容易过拟合。padding_idx=0告诉 Embedding 层把<pad>位置的嵌入固定为 0 向量,和 mask 配合才一致。
3.4 损失函数、优化器与训练循环细节
分类任务用交叉熵。京东评论好评通常远多于差评,如果打开统计发现好评:差评 = 8:2,就给差评样本加权重。CrossEntropyLoss的weight参数按类别频率的倒数设置,比如差评权重 2.0,好评 1.0。优化器建议用 AdamW,学习率 1e-3,配合weight_decay=1e-4,比 Adam 更能压住 LSTM 的过拟合。
训练循环里有两个容易被忽略的点:梯度裁剪和最优模型保存。LSTM 时序较长时容易梯度爆炸,loss 跳到 nan,用clip_grad_norm_把梯度范数限制在 5 以内就能避免。同时不要用最后一个 epoch 的模型,要保存验证集准确率最高的那一轮,因为情感分析任务在 10 轮左右就会过拟合,最后几轮准确率反而下降。
from torch.utils.data import DataLoader, TensorDataset, random_split X_t = torch.tensor(X, dtype=torch.long) y_t = torch.tensor(y, dtype=torch.long) dataset = TensorDataset(X_t, y_t) split = int(len(dataset) * 0.8) train_ds, val_ds = random_split(dataset, [split, len(dataset) - split]) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False) model = LSTMClassifier(len(vocab)) weights = torch.tensor([1.0, 2.0]) # 假设0=差评, 1=好评,差评少时调高差评权重 loss_fn = nn.CrossEntropyLoss(weight=weights) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) best_acc = 0 for epoch in range(10): model.train() for xb, yb in train_loader: optimizer.zero_grad() out = model(xb) loss = loss_fn(out, yb) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=5) optimizer.step() model.eval() correct = total = 0 with torch.no_grad(): for xb, yb in val_loader: pred = model(xb).argmax(dim=1) correct += (pred == yb).sum().item() total += yb.size(0) acc = correct / total print(f'epoch {epoch+1}: acc {acc:.4f}') if acc > best_acc: best_acc = acc torch.save({'model': model.state_dict(), 'vocab': vocab, 'max_len': max_len}, 'best_model.pth')参数说明:batch_size=64在 CPU 上也能跑,内存紧张就改 32。epoch=10是起步值,不要一上来就 50,先看 val acc 在第几轮封顶;如果第 8 轮还在涨,可以再加 5 轮;如果第 3 轮就开始跌,说明过拟合,调大 dropout 或者减少层数。保存模型时把vocab和max_len一起写进字典,这是之后预测能复现的关键。
如果你的数据集很大,可以在训练前加一句if torch.cuda.is_available(): model = model.to('cuda'),同时把 xb、yb 也移到 cuda。CPU 训练 1 万条数据大概几分钟到十几分钟,毕设完全够用。
4. 训练过程中的避坑与血泪经验:从 loss 不降到过拟合
这一章写实际调这个项目时反复翻车的几个场景,每一条都可以直接对照排查。LSTM 情感分析的训练过程有黑匣子属性,出现问题不要盲目堆模型,先按顺序检查:数据、标签、序列化、训练参数。
4.1 现象:loss 一直不降,准确率卡在 60% 附近
现象:训练 5 轮后 loss 几乎不变,验证准确率一直 60% 上下,比随机猜测好一点但明显没意义。
原因:这类项目 60% 卡住多数不是模型问题,而是标签和文本对不上。比如评分 1~5 分,有人把 score<=2 当差评、score>=4 当好评,但数据包里的 label 字段是反的;或者 score=3 的中评没剔除,中性样本把两个类别搅混。也可能是序列化出错:所有句子被 padding 后,某个位置输入全零,模型学到的是预测多数类。
解决:先打印 20 条训练数据的comment_clean、分词结果、ids 序列、y 标签,人工看一遍有没有明显错位。再做一次类别统计,确认好评和差评都有足够样本。最省事的验证是挑选 50 条正负样本,直接预测并输出前 5 个词,看看模型到底有没有读到有效内容。
4.2 现象:训练集准确率 95%,验证集只有 72%
现象:train acc 一路冲到 95%,验证 acc 在第 3 轮达到 75% 然后下跌,典型的过拟合。
原因:LSTM 模型参数量很大,而京东评论课程作业往往只有几千到一两万条,模型很容易把训练集“背”下来,而不是学泛化特征。另外词表太大也会加剧过拟合,低频词全部放进词表,等于给模型提供了记忆训练样本的捷径。
解决:优先调小模型而不是加数据。把hidden_size从 128 降到 64,num_layers从 2 降到 1,dropout从 0.3 提高到 0.5;优化器weight_decay从 1e-4 调到 5e-4;同时把词表阈值从 2 提高到 3。如果仍然过拟合,就提前停止,保存验证集最高点的模型,而不是最后一个 epoch。
4.3 现象:预测新评论时结果很差,之前调的准确率都是假的
现象:测试集准确率 80%,但拿几条真实评论去预测,几乎全是好评或随机结果。
原因:最可能是测试集和真实评论不是同分布。比如训练数据来自手机品类,测试评论是食品或服饰;另一个常见原因是很多词在训练里没见过,全部变成<unk>,导致模型只能靠少数常见词做判断。
解决:预测时用和训练完全相同的clean_comment和分词函数,不要另写一套。词表构建时把阈值降到 2 甚至 1,保留更多低频词;如果数据允许,按品类划分训练/验证,别让一个品类独占测试集。还可以在预测前统计新评论里<unk>的比例,超过 20% 就说明词表需要重做。
4.4 现象:准确率很高,但差评完全召回不了
现象:准确率 85%,可差评的召回率只有 30%,模型把大量差评判成好评。
原因:类别不平衡。京东商城好评远多于差评,比例可能 9:1。模型学到“全部预测好评”就有 90% 准确率,所以 loss 不惩罚它。
解决:损失函数加weight,差评权重设为 2 或 3;评估指标看F1而不是准确率;在 DataLoader 里对差评过采样,也就是重复抽差评样本。如果你做三分类,还要考虑中性类的权重。答辩时“准确率高但差评漏报”是一个值得展开讲的问题,别把只报准确率的短板留给自己。
4.5 现象:换一台电脑跑同样代码,结果完全不一样
现象:训练脚本在室友电脑上跑出 0.90 准确率,在自己电脑上只有 0.82;或者 reload 模型后预测结果对不上。
原因:PyTorch 的默认初始化在每次运行时都不同,包括 Dropout、数据切分也带随机性。如果不固定种子,两次实验结果不能复现,在课程作业里是硬伤。
解决:训练脚本开头固定所有随机源:random、numpy、torch、torch.cuda,并且设置torch.backends.cudnn.deterministic = True。保存模型时把vocab、max_len、标签映射一起存下来,这样从 checkpoint 恢复时不需要重新构建词表,预测才一致。
5. 项目交付时怎么让人信服:评估指标、demo 与可复现性
毕设或课程作业不只是“跑通”,而是要让老师 3 分钟看懂你的结果并且相信它是真的。我通常按下面三步准备。
5.1 用混淆矩阵和 F1 而不是只看准确率
给导师打印混淆矩阵。好评差评的不平衡决定了准确率有欺骗性。
from sklearn.metrics import confusion_matrix, classification_report with torch.no_grad(): y_true = [] y_pred = [] for xb, yb in val_loader: y_true.extend(yb.tolist()) y_pred.extend(model(xb).argmax(dim=1).tolist()) print(classification_report(y_true, y_pred, target_names=['差评', '好评'])) print(confusion_matrix(y_true, y_pred))classification_report直接给出 precision、recall、F1,混淆矩阵能看出错在哪一类。这一步比只贴 acc 有说服力得多。
5.2 做一个命令行 demo 或简单 Web 接口
我一般会在项目根目录放一个predict.py,接收一条评论,走同一个清洗、分词、词表映射流程,加载保存的 checkpoint,输出概率。这个 demo 决定答辩时能不能当场演示。最小命令就是:
python predict.py --comment "物流很快,但是质量一般"代码里核心是把vocab还原,然后对输入句子走一遍和训练相同的预处理。
注意:预测时也要把序列 pad 到
max_len,并且 pad 在右边。如果忘做,模型会直接报维度错误。
5.3 固定随机种子和实验记录:留个后悔药
我的习惯是每次训练前把随机种子、词表大小、max_len、batch_size、epoch 写进一个config.py,并在 README 里写明运行顺序:clean -> build_vocab -> train -> predict。看起来琐碎,但课程作业跨两三个月,重装环境、换机器后能直接恢复结果。固定随机种子之后,同一个 checkpoint 在不同机器上的预测结果才一致,否则答辩前突然变了,自己都没法解释。这些不是加分项,是基本盘。
我自己曾被“换电脑后结果复现不了”坑过一整晚,后来所有实验都先固定随机种子并保存完整词表。这个习惯也就成了我做这类项目的底线。希望帮到你。
本文还有配套的精品资源,点击获取