简介:《基于机器学习的商品评论情感分析》毕业设计项目,面向计算机相关专业学生与NLP入门者,完整覆盖从数据清洗、分词、情感词典构建到词向量表征、多模型训练对比与评估的通用流程。压缩包共43个文件,包含14个Python脚本、多个CSV与XLS数据文件、预训练词向量与模型文件(pkl/h5/yml)以及GUI界面等,既有可复用的爬虫抓取、文本预处理与模型测试脚本,也提供情感分类模型和可视化绘图代码,便于直接参考或二次开发。包体约66.66MB,已有167人学习下载。内容涵盖朴素贝叶斯、SVM、LSTM等基线模型与深度学习方案,并附有数据结果文件与项目配置信息,能帮助读者理解情感分析任务中的数据处理、特征提取、超参数调优和结果评估等关键环节,适合作为课程设计或毕业设计的完整范例。
1. 商品评论情感分析:为什么这个毕设题每年都有人翻车在入口处
用机器学习做商品评论情感分析,很多人一上来就把赌注押在模型上,LSTM、BERT 轮番上阵,最后却被中文分词和标签质量卡住。我拆这个毕业设计项目时最大的感受是:整条链路的瓶颈不在算法,而在数据——评论怎么抓、标签怎么映射、分词器有没有把「不太满意」切成两半,这四件事做不好,再好的模型也白搭。这份资源把从数据采集、文本预处理、特征工程到模型对比的完整流程走通了,适合正在做毕设、课程设计,或者想快速掌握情感分析落地路线的读者。它能帮你省掉至少两周的踩坑时间,尤其是爬虫和标签清洗那两关。
2. 评论数据从哪来:京东爬虫的真实边界与 CSV 数据集对齐
2.1 京东评论的抓取逻辑与常见坑
很多人在这一步就先翻车了。京东商详页的评论区是异步加载的,直接请求item.jd.com/xxx.html拿回来的 HTML 里根本没有评论内容。你在浏览器 F12 里看到的评论,是页面加载后由 JavaScript 再请求一个独立的评论接口拿到的 JSON。常见做法是直接构造这个接口请求,而不是解析 HTML。
import requests import time import random HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://item.jd.com/", } def fetch_jd_comments(product_id, page, score=0): url = "https://club.jd.com/comment/productPageComments.action" params = { "productId": product_id, # 商品ID "score": score, # 0全部 1差评 2中评 3好评 "sortType": 5, # 5表示按时间排序 "page": page, # 页码从0开始 "pageSize": 10, # 每页条数 } resp = requests.get(url, params=params, headers=HEADERS, timeout=10) data = resp.json() comments = data.get("comments") or [] result = [] for c in comments: result.append({ "content": c.get("content", "").replace("\n", " "), "rating": c.get("score"), "nickname": c.get("nickname", ""), "date": c.get("creationTime", "")[:10], }) return result # 翻页抓取并落盘 rows = [] for page in range(0, 5): rows.extend(fetch_jd_comments(100012043978, page, score=0)) time.sleep(random.uniform(1, 3)) # 随机休眠,避免请求过密这段代码里,score参数是核心。很多教程默认只让抓全部评论,但实际上score=1能直接拿到差评,score=3能拿到好评,分标签抓取比抓回来再筛更省事。翻页逻辑上,page从 0 开始递增,pageSize一次 10 条。接口对请求频率很敏感,连续快速翻页会出现两种情况:要么返回的comments是空数组,要么直接返回一段带登录提示的 HTML。我在代码里加了 1 到 3 秒的随机休眠,这个间隔是实际测下来比较稳的频率。
爬虫在这个项目里的定位要摆正:它只是证明「数据来源可靠」的一环,不是毕设的核心。你不可能把时间全耗在爬虫上,爬到几万条评论后,第一时间落盘成 CSV,后面所有实验都从 CSV 读取,不再碰接口。这既规避了封禁风险,也让实验可复现。
2.2 数据集的规范与标签映射
无论你用的是自己爬的评论,还是资源里装配好的数据集,第一步都是把列对齐成统一的格式。我一般固定用这五列:comment_id、product_id、content、rating、date。其中rating是 1 到 5 的整数星,content是原始评论文本,后面所有预处理都基于这一列。
标签映射是这里最关键也最容易想当然的环节。我见过不少毕设直接写label = 1 if rating >= 3 else 0,把三星评价当正面,结果模型在「一般般」「凑合」这类文本上一通乱猜。更合理的映射是只保留强信号:
import pandas as pd df = pd.read_csv("goods_comments.csv", encoding="utf-8-sig") def map_label(rating): if rating >= 4: return 1 # 好评 if rating <= 2: return 0 # 差评 return None # 3星中评,先不参与二分类 df["label"] = df["rating"].apply(map_label) df = df.dropna(subset=["label"]) # 丢掉中评样本 print(df["label"].value_counts()) print(df["content"].str.len().describe()) print(df.sample(5, random_state=42)[["content", "label"]].to_string())encoding="utf-8-sig"是为了兼容 Excel 打开 CSV 时的乱码问题,Windows 下默认编码不是 UTF-8,utf-8-sig能避免中文读出来是乱码。value_counts()看正负样本是否失衡,str.len().describe()看文本长度分布,这一步能帮你发现两类脏数据:空文本和超长文本。空文本直接删,超长文本大多是复制粘贴的营销话术,对情感分析没什么贡献。资源里自带的 CSV 已经是这个格式,装好 Pandas 就能直接跑,不需要再折腾爬虫。
3. 中文预处理这条线:分词、停用词与标签弱清洗
3.1 清洗的优先级与正则模板
中文评论预处理的第一原则是「先清洗,后分词」。次序不能反,否则 URL、HTML 标签、@ 回复这些噪音会被分词器切成莫名其妙的碎片,污染后面的词频统计。我习惯把清洗拆成四步,每步用一个正则解决:
import re def clean_comment(text): if not isinstance(text, str): return "" text = re.sub(r"https?://\S+|www\.\S+", "", text) # 去URL text = re.sub(r"<[^>]+>", "", text) # 去HTML标签 text = re.sub(r"回复\s*@.*?[::]", "", text) # 去@回复 text = re.sub(r"(\W)\1+", r"\1", text) # 连续标点压缩 return text.strip()这里每一行的顺序都是有讲究的。URL 里含/和.,不先去掉会被当成普通字符;@ 回复如果不处理,「回复 @用户:」里的用户名会串进正文;连续标点压缩是为了避免「!!!!!» 这种重复句号把词频刷起来。值得注意的一点是:这个清洗函数里没有删 emoji。因为 emoji 本身就是强情感信号,「😡」和「😄」的区分度可能比几个汉字还高,删掉反而损失信息。我会单独保留 emoji,在特征阶段把它映射成[angry]、[happy]这类文本标记再进分词。
3.2 分词、停用词与用户词典
Jieba 分词是中文 NLP 事实上的默认选择,不需要额外训练模型。但默认词典对商品评论领域覆盖不足,最典型的就是「不太满意」这种带否定语气的情感短语,默认会切成「不太」和「满意」两个词,情感方向直接从负面变成了中性偏正。解决方式是维护一个用户词典:
import jieba with open("stopwords.txt", encoding="utf-8") as f: stopwords = set(line.strip() for line in f) # user_words.txt 内容示例 # 不太满意 800 # 性价比高 800 # 效果一般 500 jieba.load_userdict("user_words.txt") def tokenize(text): words = jieba.lcut(text) words = [w.strip() for w in words if w.strip() and w not in stopwords and len(w.strip()) > 1 and not w.isdigit()] return " ".join(words)用户词典的格式是「词 + 空格 + 频次」,频次影响切分优先级。把「不太满意」「性价比高」这类高频情感短语整体切成一个 token 后,TF-IDF 和 LSTM 都能直接吃到完整的语义单元。停用词表的选型上,哈工大停用词表偏全,百度表偏简,常见做法是选一份通用表,再追加商品领域专属词——比如「京东」「物流」「客服」这类对情感方向没帮助的词。我用的停用词表里还额外保留了否定词白名单:「不」「没」「无」「非」「别」绝不能进停用词表,否则模型学到的情感信号会被拦腰截断,后面第 5 章会专门讲这个坑。
3.3 标签弱清洗与噪声剔除
这一节处理的问题是:四星评价真的是好评吗?实际数据里,四星评论经常是「还行吧」「凑合,有点失望」,情感偏向模糊。如果你把它当正样本,模型就会在大量模糊样本上摇摆。我一般用「弱清洗」策略,强制拉大正负样本的边界:
# 强信号样本:1星和5星 strong_df = df[df["rating"].isin([1, 5])].copy() # 弱信号样本:2星和4星,单独看或丢弃 medium_df = df[df["rating"].isin([2, 4])].copy() df = strong_df.copy() df["text_clean"] = df["content"].apply(clean_comment).apply(tokenize) # 剔除清洗后为空或只有一两个词的评论 df = df[df["text_clean"].str.len() > 4]丢弃四星不是浪费数据,而是降低标签噪声。情感分析的训练数据里,最贵的不是数量,而是标签的置信度。你可以对比一下用 1/5 星训练和用全部星级训练的 F1 差距,通常至少能涨两到三个点。当然,如果数据集整体量级只有几千条,丢掉四星会让数据更紧张,这时我更建议改成三分类任务,把「中性」单独作为一类,而不是硬塞进二分类里。
4. 特征与模型对比:TF-IDF、LSTM 与参数标定的完整走通
4.1 先拿 TF-IDF 建立基线
不要一上来就上 LSTM。情感分析在中文短文本上有个被反复验证的经验:TF-IDF 加线性模型能打平甚至打爆很多深度模型,而且训练时间从分钟级降到秒级。先用传统模型定基线,后面模型有问题也有对照。下面是特征构建和基线的完整代码:
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split from sklearn.naive_bayes import MultinomialNB from sklearn.linear_model import LogisticRegression from sklearn.metrics import classification_report vec = TfidfVectorizer( max_features=8000, # 最大特征数,防止矩阵过大 ngram_range=(1, 2), # 1元词 + 2元短语 min_df=3, # 至少在3个文档里出现 max_df=0.9, # 过滤90%以上文档都有的词 sublinear_tf=True # 对词频做log平滑 ) X = vec.fit_transform(df["text_clean"]) y = df["label"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y )这几个参数值得细说。max_features=8000是经验值,特征太多会让矩阵稀疏且训练变慢,太少又抓不住长尾情感词;ngram_range=(1,2)是最关键的——清洗后的文本已经按空格分词,bigram 能组成「太慢」「不行」「没味道」这样的情感短语,弥补分词阶段的部分缺陷;min_df=3是为了过滤错别字和用户名昵称这类只出现一两次的噪音 token;sublinear_tf=True会把tf做对数变换,弱化高频词的绝对次数。
基线上用朴素贝叶斯和逻辑回归各跑一遍:
models = { "nb": MultinomialNB(alpha=0.5), "lr": LogisticRegression(max_iter=1000, C=1.0) } for name, model in models.items(): model.fit(X_train, y_train) y_pred = model.predict(X_test) acc = (y_pred == y_test).mean() print(f"{name}: {acc:.4f}") print(classification_report(y_test, y_pred, target_names=["负", "正"]))MultinomialNB(alpha=0.5)里的alpha是拉普拉斯平滑参数,0.5 到 1 之间是常见区间,太小会过拟合训练集的低频词。逻辑回归的C=1.0是正则强度的倒数,这个场景下不用太动,max_iter=1000是为了保证收敛。stratify=y必须带上——它能让训练集和测试集的正负比例保持一致,不然随机切分可能把差评样本全分到测试集,acc 和 F1 都会失真。我自己跑类似数据的体验是:LR 通常比 NB 高 1 到 2 个点,NB 的优势是快和稳,LR 的优势是边界更准。
4.2 数据量不够,别急着上 LSTM
很多人毕设答辩时被问「你为什么不试试深度学习」,所以 LSTM 通常作为对照实验存在。但有一个前提:数据量低于 5000 条时,LSTM 大概率跑不过 LR。原因很简单,深度模型需要的标注数据量本来就更大,几千条评论里每个词的平均出现次数低到不足以让 Embedding 层学出有效表示。数据量足的情况下,LSTM 可以这样搭:
from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping MAX_LEN = 64 # 评论截断长度 MAX_VOCAB = 20000 # 词表大小 tokenizer = Tokenizer(num_words=MAX_VOCAB) tokenizer.fit_on_texts(df["text_clean"]) X_seq = pad_sequences( tokenizer.texts_to_sequences(df["text_clean"]), maxlen=MAX_LEN )MAX_LEN=64不是随便定的。中文商品短评的均值大概在 30 到 50 字,64 能覆盖绝大多数样本,太长会拖慢训练且引入大量 padding。pad_sequences会统一序列长度,短则补零,长则截断。注意Tokenizer只是把词映射成数字索引,不做清洗,清洗工作必须在它之前完成,否则前面所有正则和分词都白做了。
模型结构上我建议克制:
model = Sequential([ Embedding(MAX_VOCAB, 128), LSTM(64, dropout=0.4, recurrent_dropout=0.2), Dense(64, activation="relu"), Dropout(0.3), Dense(1, activation="sigmoid") ]) model.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"]) model.fit( X_train_seq, y_train, epochs=10, batch_size=128, validation_split=0.1, callbacks=[EarlyStopping(patience=2)] )Embedding 维度 128 对两万词表来说够用了,再大收益有限。LSTM 单元数取 64 不是玄学,是短文本任务里常见区间,网络做深了收益反而低,还容易过拟合。dropout=0.4和recurrent_dropout=0.2是两个独立的防过拟合开关——前者丢输入到隐层的连接,后者丢隐层内部的循环连接。EarlyStopping(patience=2)是后悔药,验证集 loss 连续两个 epoch 不降就停训练,省得你一边过拟合一边纠结该跑几个 epoch。
4.3 把三个模型的对比做成一页纸
毕设答辩时老师要看的是对比结果,不是代码。我习惯把结果整理成这样一张表:
| 模型 | 准确率 | 负类 F1 | 训练耗时 | 定位 |
|---|---|---|---|---|
| 朴素贝叶斯 | 0.88 左右 | 0.79 左右 | 秒级 | 基线模型 |
| 逻辑回归 | 0.90 左右 | 0.83 左右 | 秒级 | 主模型 |
| LSTM | 0.89 左右 | 0.80 左右 | 分钟级 | 对照实验 |
三个模型的准确率其实相差不大,差距主要体现在负类 F1 上——差评样本本来就少,F1 更能反映模型对差评的识别能力。一般 LR 在负类 F1 上会拉开几个点,LSTM 如果数据量不够甚至会掉到和 NB 差不多。这三张图的结论写进论文里就是:数据量中等时,线性模型加 TF-IDF 具备足够强的情感判别能力,深度模型没有显著优势,但在更大数据集上有潜力。这样写既诚实,又显得你理解模型的适用边界。
5. 常见问题与避坑清单:五个让 accuracy 崩坏的细节
5.1 爬虫返回登录页 HTML 而不是 JSON
现象:请求评论接口后,resp.json()抛异常,打印resp.text发现返回的是一段 HTML,里面带「登录后才能查看」的字样。原因:短时间内请求次数过多触发了接口的风控,接口返回了登录验证页而不是数据。解决:第一优先级是降低频率,把休眠时间从 1 秒拉到 3 到 5 秒;第二是带上完整的请求头,Referer和User-Agent都要和浏览器一致。如果还是被拒,就不要再和接口较劲了——直接改用已落盘的 CSV 继续实验。爬虫在毕设里的作用只是把数据拿到手,不是要做一个高并发采集系统,千万别在这一步耗掉一周。
5.2 停用词表把「不」「没」滤掉了
现象:模型准确率不升反降,负类 F1 掉到 0.5 以下,跟随机猜差不多。原因:我最初用的是网上找的通用停用词表,里面居然把「不」「没」「无」这类否定词也列进去了。分词后这些词被全部过滤,「非常不满意」变成了「非常满意」,情感方向整个反转。解决:每次换停用词表,都要先跑一遍否定词检查:
for w in ["不", "没", "无", "非", "别", "莫"]: if w in stopwords: print(f"危险停用词: {w}")不要在通用停用词表上直接全盘信任,它可能来自某个新闻语料库,对情感分析场景来说否定词是强特征,必须以白名单形式强制保留。
5.3 重复评论导致训练集和测试集泄露
现象:测试集准确率高达 98%,但把模型拿到新评论上验证,效果惨不忍睹。原因:爬虫数据里有大量重复文本——同一个用户追评、爆款评论被复制传播。train_test_split是随机切分,同一条文案的多个副本被分到了训练集和测试集两边,模型相当于「见过答案再考试」。解决:切分前先按文本内容去重,更严格一点是按商品 ID 分组切分,确保同一个商品的评论不会同时出现在训练集和测试集里。前者用df.drop_duplicates(subset=["content"]),后者用 sklearn 的GroupShuffleSplit。这个坑在情感分析里特别阴,因为评论区本身就自带重复推荐,不检查你根本发现不了。
5.4 中评当负样本,边界一塌糊涂
现象:模型精度没变,但召回率崩到 0.4,「还行」「凑合」这种中立表达全被预测成差评。原因:标签映射时把 3 星、2 星都归成了负类,三星样本里大量是「勉强能用」「一般还行」这样的中性表达,和真实差评「垃圾」「别买」的特征空间重叠度极低,把决策边界搅乱了。解决:回到 2.2 节的弱清洗策略,二分类只用 1 星和 5 星训练,2 到 4 星全部弃用。如果你觉得浪费数据,那就改成三分类,把中性单独设为一类,让模型学三个类别而不是在两个互相矛盾的模式里纠结。
5.5 LSTM 的 loss 一直不降
现象:loss在 0.65 附近徘徊不上不下,accuracy稳定在 68% 左右,怎么调学习率都不动。原因:最常见的两个:一是序列里有大量空文本或清洗后只剩标点的文本,unknown信号太多;二是MAX_LEN设太长,padding 比例过高,梯度更新被空 token 稀释。解决:训练前先检查清洗结果:
empty_cnt = df[df["text_clean"].str.strip() == ""].shape[0] print(f"空文本数量: {empty_cnt}")如果空文本占比超过 1%,回到 3.3 节把过滤阈值从len > 4往上提。另外,看看你的文本平均长度,如果均值只有 30,MAX_LEN=64可以让一半以上的序列都是 padding,这种情况下截到 48 或者 40 会有明显改善。
6. 答辩前的验证习惯:混淆矩阵、Bad Case 与一句血泪忠告
模型训练完,准确率 90%,然后呢?很多毕设到这里就停了,这是最可惜的。答辩时老师问你「你的模型在哪些样本上会出错」,你如果答不上来,前面所有工作都会被打上问号。所以要养成一个习惯:跑完模型不急着看 accuracy,先看混淆矩阵和 Bad Case。
混淆矩阵能直接告诉你错在哪一类:
from sklearn.metrics import ConfusionMatrixDisplay import matplotlib.pyplot as plt ConfusionMatrixDisplay.from_predictions( y_test, y_pred, display_labels=["负", "正"], cmap="Blues" ) plt.savefig("confusion_matrix.png", dpi=150)混淆矩阵主要回答两个问题:差评被误判成好评的比例高不高?好评被误判成差评的比例高不高?如果负类 F1 明显低于正类,说明模型对差评的记忆不够,优先去查差评样本的清洗是否丢了关键特征。
比混淆矩阵更进阶的是 Bad Case 分析。做法很简单:把模型预测置信度最低的样本打出来看,用predict_proba找到概率接近 0.5 的那些——它们就是模型「最纠结」的样本,每一条都可能揭示一个系统性问题。
proba = nb.predict_proba(X_test)[:, 1] cases = pd.DataFrame({ "text": X_test_text, # 测试集原始文本 "true": y_test, "pred": y_pred, "proba": proba }) cases["confidence"] = abs(cases["proba"] - 0.5) confused = cases.nlargest(10, "confidence") for _, row in confused.iterrows(): print(row["text"], "| 真实:", row["true"], "| 预测:", row["pred"])nlargest(10, "confidence")取置信度最接近 0.5 的前十条,人工扫一遍就能看出模型犯错是文本本身模糊,还是预处理环节有漏洞。这份资源从爬虫落盘、文本预处理、TF-IDF 到 LSTM 对比的完整代码都是串好的,数据也整理成了标准 CSV,拿着就能直接跑通整条流程。
我第一版模型跑出来 accuracy 0.91,当时觉得稳了,答辩那天老师现场扔了一条真实评论进来:「物流太慢,差评」,模型预测成了好评。后来查了才发现是停用词表把「太」滤掉了,bigram 没来得及覆盖这个词,预处理链路里的一个点,直接让模型在最短的否定句上翻车。从那以后,我每次跑完模型都强制走一遍 Bad Case 分析,不急着看准确率。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取