
简介电商评论情感分析项目以深度学习LSTM算法为核心提供从京东商品评论爬取到情感分类的完整Python实现。项目面向计算机相关专业毕业设计、课程设计与项目实战学习者包含爬虫管线、数据清洗、模型训练与评估模块附带已训练模型与直接可用的爬虫框架可运行并扩展分析维度。压缩包共39个文件涵盖8个Python脚本、7张结果展示图片、6组模型权重及相关索引数据另有说明文档与停用词表整体约164MB目录按爬虫和情感分析模块划分清晰权重文件支持继续训练或迁移应用。已有237人学习代码经测试运行成功下载后可远程协助调试建议先阅读说明文件以便快速搭建环境并复现训练与预测流程。1. 电商评论情感分析这个项目为什么上半场在爬数据下半场才轮到 LSTM电商评论情感分析尤其是在京东这类平台商品评论上做用户好评 / 差评的判断标题里最抢眼的三个词是 LSTM、深度学习、Python 源码可真正跑过一遍的人会告诉你这个项目 70% 的时间花在爬评论、洗评论、造标签上LSTM 只是最后那一段路。很多团队第一步就想训一个漂亮的模型结果发现爬回来的数据里一半是“此用户未填写评价内容”另一半是“物流很快”标签还对齐不上模型性能自然上不来。我给你的判断是LSTM 虽然不像 BERT 那么新但它的循环结构确实适合处理短文本里的否定、转折和程度词且相比大型预训练模型LSTM 训练快、显存占用低适合中小规模电商评论场景。这篇文章会把从爬取京东商品评论数据到 LSTM 模型训练的全过程拆开并把我在真实数据上踩过的坑直接标出来。2. 爬取京东商品评论先解决数据可信再谈训练、LSTM 和情感分析效果拿不到干净数据后面一切都是白搭。这个章节我们不只讲爬虫怎么写更要讲清楚为什么很多人的数据集“看着挺大、训练完没用”。先说结论自己爬京东评论是获取电商评论情感分析数据最直接的路径。公开数据集虽然省事但商品品类、时间分布、评价风格不一定贴合你的需求。自己爬的好处是标签随商品走能按 SKU 切分能控制数量也方便后续追溯。风险是平台接口会变、反爬存在所以下面这套代码我只能保证思路通用落地时建议你打开浏览器开发者工具抓一次真实请求用当时的实际接口路径去替换。2.1 从评论接口拿到原始 JSON请求构造与字段解析京东 PC 端的商品评论接口路径业内不少公开教程用的是 club.jd.com/comment/productPageComments.action 这个风格。它返回的是一个 JSON里面有 comments 数组每条评论的核心字段是 content、score、creationTime以及用户公开可见的商品规格信息。注意商品详情页上的 id 有 skuId 和 productId 两种叫法评论接口通常认的是 skuId也就是我们常说的“商品最小维度”。一个商品如果有多个 sku评论是分开统计的。下面是一个最小可运行的采集脚本框架import requests import time import json sku_id 100012043978 # 占位符替换为目标商品 sku # 这个 headers 非常关键没有 UA 和 Referer很容易直接被拒 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: fhttps://item.jd.com/{sku_id}.html, Accept: application/json, } all_comments [] for page in range(0, 50): # page 从 0 开始 params { productId: sku_id, score: 0, # 0 表示全部评分 sortType: 5, # 5 表示按时间排序 page: page, pageSize: 10, # 固定 10 比较保险 } url https://club.jd.com/comment/productPageComments.action resp requests.get(url, paramsparams, headersheaders, timeout10) if resp.status_code ! 200: print(f第 {page} 页返回异常状态码 {resp.status_code}) break try: data resp.json() except json.JSONDecodeError: print(f第 {page} 页返回的不是 JSON可能被风控了) break comments data.get(comments, []) if not comments: print(f第 {page} 页没有评论停止分页) break for c in comments: all_comments.append({ sku_id: sku_id, content: c.get(content, ).strip(), score: c.get(score, 0), time: c.get(creationTime, ), }) time.sleep(2) # 降速是爬虫的命sleep 别省 print(f共采集 {len(all_comments)} 条评论)这段代码的要点有三个。第一pageSize 固定为 10是我测试过的折中方案pageSize 调大虽然页数少但更容易触发平台风控返回的条目反而不完整。第二每次请求之间 sleep 2 秒这不是做样子是让请求频率保持在低频避免被同一 IP 短时间大量访问打上标签。第三解析 JSON 之前要先捕获 json.JSONDecodeError因为被风控时接口经常返回一段 HTML 而不是 JSON不做这个保护脚本会直接崩在半路。2.2 把 1~5 星映射成情感标签并过滤掉模板评论京东评论的 score 字段是 1 到 5 的整数。情感分析项目最常见的做法是做二分类4 星和 5 星算好评1 星和 2 星算差评3 星直接丢掉。为什么不把 3 星也归进负样本因为 3 星评论往往是“还行、一般”这类模棱两可的表达人类自己都很难给一个确定标签丢给模型只会让它学出一个模糊边界降低两端的区分度。真正容易被忽略的是模板评论。京东大量订单会被系统自动追评或者用户直接点默认选项产生的内容往往是“此用户未填写评价内容”“商品不错”“好评”这类毫无区分度的文本。如果这些文本混进训练集模型会严重偏向预测好评。我的过滤规则是内容长度小于 6 个字或者命中常见模板关键词直接剔除。import re def filter_useless(content: str) - bool: 返回 True 表示这条评论应该被剔除 if len(content) 6: return True patterns [ r此用户未填写评价内容, r默认好评, r好评$, r商品不错$, r物流很快$, ] for p in patterns: if re.search(p, content): return True return False def score_to_label(score: int): if score 4: return 1 # 好评 elif score 2: return 0 # 差评 else: return None # 3 星丢弃过滤条件里的“内容长度小于 6”是我根据自己的数据调出来的不一定适合所有品类。如果你的商品是数码电子产品用户的追评普遍较长但如果是低价日用品大量真实好评也只有三五个字这时长度阈值要往下调。所以更稳的做法是先跑一遍统计看看短文本里有多少是模板噪音再决定阈值。2.3 按商品切分训练集和验证集少一个 skuId 维度都可能隐瞒真实效果很多教程会把全部评论混在一起然后按行随机切训练集、验证集。这在电商评论场景里是个坑。同一个 SKU 的评论用词习惯高度相似比如“掉漆”“续航”“充电慢”等词会反复出现。如果同一个 SKU 的评论同时出现在训练集和验证集模型其实是在“读过答案”的情况下考试验证集的准确率会虚高换到新商品上立刻打回原形。我一般会按 SKU 分组切分让同一个 SKU 的评论全部落在同一个集合里import pandas as pd from sklearn.model_selection import train_test_split df pd.DataFrame(all_comments) df[label] df[score].apply(lambda x: score_to_label(x)) df df[df[label].notna()].copy() # 按 sku_id 分组取出唯一的 sku 列表再切分 unique_skus df[sku_id].unique() train_skus, val_skus train_test_split( unique_skus, test_size0.2, random_state42 ) train_df df[df[sku_id].isin(train_skus)] val_df df[df[sku_id].isin(val_skus)] print(train_df[label].value_counts()) print(val_df[label].value_counts())这样切完之后还要检查两个集合的正负样本比例。电商评论里好评通常占 60% 以上有的品类甚至到 90%。如果验证集里差评只有几十条那 F1 分数基本没有参考价值。遇到这种情况要么继续扩大采样要么在模型层面做类别平衡处理这部分我会在第 4 章展开。3. 把一串中文变成 LSTM 能学的向量分词、词表、填充与批次切片LSTM 神经网络的输入不是字符串而是一个整数组成的序列。这中间要过四道工序分词、构建词表、把句子转成索引序列、把不等长的序列填充到统一长度。很多新手在这一步会犯一个定位错误直接把“商品不错”按字符拆开喂模型。字符级的优点是永远不会遇到未登录词缺点是丢失词汇边界LSTM 难以学到“物流”“客服”“售后”这种组合语义。电商评论场景我更推荐词级建模配合 jieba 分词专业词可以通过加载自定义词典解决。这一章的目的不是给你一个完美的 NLP 预处理库而是让你理解每一步在做什么以及参数为什么这么设。3.1 词表构建与 OOV 处理高频词表加两个保留位中文分词我习惯用 jieba 的精确模式。精确模式适合短文本分类它会把句子切成最合理的词序列不像搜索引擎模式那样过度切分。分词之后我们要统计所有词的频次建立词表。这里有一个取舍min_count 设置得太高低频但强情感的词会被清掉比如“掉漆”“起球”“漏液”设置得太低词表膨胀训练变慢且容易过拟合。对评论数据我一般用 min_count1最多 2因为电商评论的低频词恰恰是最有判别力的描述。词表里必须保留两个特殊位置0 给 PAD1 给 UNK。PAD 是填充位UNK 是未登录词。给 PAD 分配索引 0 不是随意的后面 Embedding 层的 padding_idx 参数直接依赖这个约定。import jieba from collections import Counter def tokenize(text: str) - list: return list(jieba.cut(text)) # 第一次遍历统计词频 word_counter Counter() for content in train_df[content]: for word in tokenize(content): word_counter[word] 1 # 构建词表出现 2 次及以上的词保留外加 PAD 和 UNK min_count 2 vocab { PAD: 0, UNK: 1, } for word, freq in word_counter.items(): if freq min_count: vocab[word] len(vocab) # 同时建立反向映射后面保存模型时会用到 idx_to_word {idx: word for word, idx in vocab.items()} print(f词表大小: {len(vocab)})这里有个容易被忽略的细节训练时我们会把序列中的词转成 vocab 里的索引遇到词表里没有的词统一替换成 UNK 的索引 1。但 UNK 在训练阶段如果出现得太频繁模型会把它学成一个“万能词”导致验证集里那些真正的高频词反而不被信任。所以分词前最好做一次基础清洗比如把英文、数字、特殊符号都统一处理掉减少无效 UNK。3.2 padding、mask 与 DataLoader短文本用什么策略最省事评论长度天然参差不齐。LSTM 是循环网络理论上能处理任意长度但 PyTorch 的批量计算要求一个 batch 内的张量形状相同所以必须把每条评论 pad 到同一个长度。这个长度不能拍脑袋定先看长度分布lengths [len(tokenize(c)) for c in train_df[content]] lengths.sort() p95 lengths[int(len(lengths) * 0.95)] print(f95% 分位长度: {p95}, 最大长度: {lengths[-1]})max_len 我一般取 95 分位而不是最大值。因为少数超长评论的尾部信息通常是重复叙述截断掉对分类影响不大但能让训练速度快很多。比如大部分评论在 60 个词以内偶尔有几条 300 词的长文你没必要为了这几条把整批数据都 pad 到 300。padding 策略是左补还是右补LSTM 按顺序读取序列最后输出的隐藏状态更倾向于代表句子靠后的信息。所以短句通常右补齐让真实内容集中在序列前半段这样模型拿到的最后一个有效 token 更靠近真实语义。不过 Polar 分类里这一点不是决定性的关键是 padding 不能和 UNK 混淆。自定义 Dataset 和 collate_fn 的写法import torch from torch.utils.data import Dataset, DataLoader class CommentDataset(Dataset): def __init__(self, texts, labels, vocab, max_len): self.texts texts self.labels labels self.vocab vocab self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens tokenize(self.texts[idx]) if len(tokens) self.max_len: tokens tokens[:self.max_len] ids [self.vocab.get(w, 1) for w in tokens] # 未登录词用 UNK1 # 右补齐到 max_len用 PAD0 ids ids [0] * (self.max_len - len(ids)) return torch.tensor(ids, dtypetorch.long), torch.tensor(self.labels[idx], dtypetorch.long) train_dataset CommentDataset( train_df[content].tolist(), train_df[label].astype(int).tolist(), vocab, max_len64, ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers2)为什么不引入 pack_padded_sequence 或者 attention mask因为对 max_len 只有 64 的短文本来说packing 省下的计算量有限但代码复杂度上升一个量级。先让模型跑通如果后续要处理长篇评论或者追求极致训练速度再考虑对等长序列做 mask。入门阶段直接 pad 到固定长度是最可控的做法。3.3 类别不均衡先处理不然后面 acc 很容易骗人电商评论的正负样本比例业内常说“好评 6 成打底爆款可能到 9 成”。如果你直接把原始比例喂给模型它学到的策略是“全部预测好评”因为这样准确率也能到 80% 以上。这是情感分析项目最典型的虚假繁荣。处理办法有很多我倾向优先用交叉熵损失里的 class_weight而不是简单复制差评样本。复制样本会改变数据分布模型容易对复制过的样本过拟合class_weight 只是在损失计算时放大少数类的梯度贡献不改数据本身。from sklearn.utils.class_weight import compute_class_weight import numpy as np labels_arr train_df[label].astype(int).values class_weights compute_class_weight( class_weightbalanced, classesnp.array([0, 1]), ylabels_arr, ) class_weights torch.tensor(class_weights, dtypetorch.float32) print(差评/好评的 loss 权重:, class_weights) criterion torch.nn.CrossEntropyLoss(weightclass_weights)注意 compute_class_weight 返回的权重顺序和 classes 参数顺序是对应的。如果差评样本只有好评的四分之一差评的 loss 权重就会接近 4这样模型在差评上犯错的代价更高预测时就不会一味倒向好评价。4. 用 PyTorch 训练 LSTM 情感模型网络结构、损失函数与关键参数模型结构本身并不复杂Embedding 层把词索引映射成稠密向量LSTM 层读序列、产出最后一个时间步的隐藏状态全连接层把这个隐藏状态压成 2 分类 logits。复杂的是训练参数怎么定。这一章给出最小可用的 LSTM 神经网络代码并把你最需要动的几个参数逐一说明。至于训练里的玄学部分我会直接用参数表告诉你从哪里下手。4.1 最小可用的 LSTM 分类网络先贴模型定义import torch import torch.nn as nn class LSTMSentiment(nn.Module): def __init__(self, vocab_size, embedding_dim128, hidden_size128, num_layers1, num_classes2, dropout0.3): super().__init__() self.embedding nn.Embedding( vocab_size, embedding_dim, padding_idx0 ) self.lstm nn.LSTM( embedding_dim, hidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalFalse, ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_size, num_classes) def forward(self, x): # x: [batch_size, seq_len] emb self.dropout(self.embedding(x)) # out: [batch, seq_len, hidden_size] # hn: [num_layers, batch, hidden_size] out, (hn, cn) self.lstm(emb) # 取最后一层的隐藏状态形状是 [batch, hidden_size] last_hidden hn[-1] logits self.fc(self.dropout(last_hidden)) return logits两个细节值得说明。第一为什么不直接取 LSTM 输出的 out[:, -1, :] 作为最后的特征因为如果序列做了右补齐最后一个位置可能是 PADPAD 对应的隐藏状态并不代表真实语义。取 hn[-1] 是拿 LSTM 处理完最后一个有效 token 后输出的隐藏状态更接近整句的语义压缩。第二为什么默认不开启双向电商评论是短文本语义方向性不像阅读理解任务那么强双向带来的收益有限但参数翻倍、训练变慢所以入门阶段用单向就够了。想提升效果时再改成 bidirectionalTrue同时要把全连接层的输入维度改成 hidden_size * 2。4.2 训练循环与早停把 epoch 和 checkpoint 管住训练循环的骨架各家大同小异差别在细节。第一个细节是梯度裁剪LSTM 在长序列上非常容易出现梯度爆炸loss 突然变成 NaN 就是这个原因。clip_grad_norm_ 把梯度范数限制在 5.0 以内能避免大多数训练崩溃。第二个细节是早停加模型保存只在验证集分数提升时保存权重而不是等到所有 epoch 跑完再回头挑。import torch.optim as optim from sklearn.metrics import f1_score, accuracy_score model LSTMSentiment(vocab_sizelen(vocab), embedding_dim128, hidden_size128) optimizer optim.Adam(model.parameters(), lr3e-3) criterion torch.nn.CrossEntropyLoss(weightclass_weights) best_f1 0.0 patience 0 max_patience 3 for epoch in range(10): model.train() total_loss 0.0 for batch_ids, batch_labels in train_loader: optimizer.zero_grad() logits model(batch_ids) loss criterion(logits, batch_labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() # 验证 model.eval() val_preds [] val_labels [] val_loss 0.0 with torch.no_grad(): for batch_ids, batch_labels in val_loader: logits model(batch_ids) val_loss criterion(logits, batch_labels).item() preds torch.argmax(logits, dim-1) val_preds.extend(preds.tolist()) val_labels.extend(batch_labels.tolist()) val_f1 f1_score(val_labels, val_preds, averagebinary) val_acc accuracy_score(val_labels, val_preds) print(fepoch {epoch1}, train_loss{total_loss:.4f}, val_acc{val_acc:.4f}, val_f1{val_f1:.4f}) if val_f1 best_f1: best_f1 val_f1 patience 0 torch.save({ model_state_dict: model.state_dict(), vocab: vocab, max_len: 64, }, best_lstm.pt) else: patience 1 if patience max_patience: print(验证集 F1 连续未提升提前停止) break这里我刻意把 early stopping 的判断指标定为 F1而不是准确率。在好评占多数的情况下准确率很容易被大类别带跑F1 能同时反映差评的召回和精确情况。保存 checkpoint 时把 vocab 和 max_len 一起存下来是为了后续推理不需要重新构建词表。这是很多教程不会写但实战里特别重要的一个步骤单独保存模型权重然后让它和词表分离等推理时再找不齐 vocab那是非常现实的头痛问题。4.3 参数预设和调参顺序先词表再结构再学习率把最常见的参数预设做成一张表方便对照参数建议初始值调整方向embedding_dim128词表大、样本多可提到 200词表小则 100hidden_size128效果不足可加 256过拟合则退回 64num_layers1数据量 5 万可试 2 层但要加 dropoutdropout0.3过拟合时提到 0.5欠拟合时降到 0.2max_len取 95 分位长度长文本场景可提到 128batch_size64显存不足降 32模型不收敛可降 16learning_rate3e-3振荡难收敛降到 1e-3训练过慢可试 5e-3clip_norm5.0出现梯度爆炸降为 3.0调参顺序比参数本身更重要。我的习惯是先确认词表质量再看 loss 曲线最后才动网络结构。如果训练 loss 不下降优先怀疑学习率太大或太小而不是去加一个 LSTM 层如果训练 loss 下降、验证 loss 不降优先检查过拟合而不是继续堆数据。LSTM 对 learning_rate 的敏感度不亚于对隐藏层大小的敏感度与其反复改 hidden_size 寻求“玄学提升”不如先跑一个 3e-3 的 baseline再往 1e-3 方向微调。5. LSTM 情感分析翻车避坑爬虫、数据质量与模型训练的五个真实问题这一章把我做这个方向时遇到过的典型问题整理成排查记录。每一条都是“现象 → 原因 → 解决”的结构希望你在复现时避开这些水坑。5.1 爬虫翻车接口返回 HTML 而不是 JSON现象requests 请求评论接口返回的 content-type 是 text/htmljson() 直接抛异常有的情况是返回了 200 状态码但里面是一个验证页。原因请求频率太高被风控或者缺少 UA、Referer、Cookie 等关键请求头。平台对无头访问的识别能力一直在增强低频请求反而更安全。解决先确认请求头是否完整尤其是 Referer 必须指向对应商品页。其次把 sleep 间隔从 1 秒提高到 2 秒以上。如果已经被限流不要硬顶等 10 到 30 分钟再继续。另外强烈建议把每一页的原始响应落盘存档这样即便后续解析逻辑改动也不需要重新爬一遍。5.2 模型全预测成好评acc 很高但 F1 惨不忍睹现象训练结束后验证集 acc 0.85但查混淆矩阵发现差评的召回率是 0.03模型把所有评论都判成了好评。原因数据分布极端不平衡好评占比 90% 以上模型的最优策略就是全预测好评。这时候 acc 完全失去参考意义。解决用 class_weight 或者 WeightedRandomSampler 做类别平衡同时把评估指标换成差评的 F1。还要看验证集里差评的绝对数量如果只有 20 条F1 波动也很大建议先补数据再做结论。5.3 训练集 loss 降得很好验证集却纹丝不动现象训练 loss 从 1.2 稳定降到 0.2验证集 loss 却在第 4 个 epoch 后开始回升。原因可能是过拟合也可能是 train/test 分布不一致。电商评论里同一个 SKU 的数据被同时分到训练集和验证集模型学到了 SKU 特有的高频词验证集表现虚高一旦按 SKU 切分虚高立刻消失。解决先确认切分方式是不是按 skuId 分组而不是按行随机切分。如果是过拟合就降低 hidden_size、加强 dropout、或提前停止。我一般会先检查验证集预测错误样本看模型是不是被某个品类的高频词带跑偏。5.4 填充长度设太小反讽和转折评论被截断现象max_len 设为 32模型对长评论判断总出错尤其是一些“先扬后抑”的评论前面写优点后面写缺点但后半段被截掉了。原因max_len 拍脑袋定的没有看训练集的长度分布。电商评论里真实存在的 60 词以上长文占了大概 5%但这 5% 往往信息量最大。解决训练前先画长度分布图max_len 取 95 分位。取 95 分位是权衡了计算速度和信息保留的结果不是必须 64。如果确实要保留完整长文本考虑用 pack_padded_sequence 配合实际长度。5.5 模板评论没有过滤“默认好评”污染训练集现象模型预测结果极度偏好评而且对“此用户未填写评价内容”这种文本也给高置信度的好评预测。原因数据清洗阶段没有剔除模板评论。这类文本没有真实情感信号它在训练集里就是一个“好评捷径”模型学到了看见固定句式就给好评。解决清洗阶段做长度过滤和模板正则匹配在爬虫解析完成后马上执行。还要注意有些模板评论不是完全相同的字符串比如“物流快”“服务好”“正品”等短语拼接纯匹配可能漏网。对这类短句直接按长度阈值砍掉更省事。6. 上线前的最后一关用新评论回验、校准阈值和冷启动处理先给出一段最小推理代码把保存的 checkpoint 加载回来对一条新评论输出概率分数import torch checkpoint torch.load(best_lstm.pt, map_locationcpu) model LSTMSentiment(vocab_sizelen(checkpoint[vocab])) model.load_state_dict(checkpoint[model_state_dict]) model.eval() max_len checkpoint[max_len] def predict_sentiment(text: str): import jieba tokens list(jieba.cut(text.lower()))[:max_len] ids [checkpoint[vocab].get(w, 1) for w in tokens] ids ids [0] * (max_len - len(ids)) x torch.tensor([ids], dtypetorch.long) with torch.no_grad(): logits model(x) prob torch.softmax(logits, dim-1)[0] return prob.tolist() # [差评概率, 好评概率] print(predict_sentiment(客服态度差退货还不给运费险差评))这里有一个我后来才养成的习惯不要直接拿 argmax 当最终判决先看两个概率的差。如果差评概率是 0.52好评概率是 0.48这条评论应该进入人工抽检队列而不是自动打标。很多项目在某个区间内设置“置信模糊带”优于盲目设定 0.5 阈值。阈值校准的简单做法是在验证集上遍历 0.3 到 0.7 的阈值画一条差评的 precision-recall 曲线根据业务需要选择一个点。如果你更怕漏掉差评就把阈值往低调比如差评概率超过 0.35 就判为差评如果你更怕误伤好评就往上调。这种阈值调整不需要重训模型只是一两行代码的事但收益往往比加一层 LSTM 还明显。冷启动是另一个现实问题。给一款新品做评论区情感分析时没有历史标签爬下来的评论也没法直接监督训练。常见做法是先用词频统计快速筛出疑似差评词汇比如“退货、漏水、异响、态度差”这类强信号词人工确认后作为种子标签再训练模型。LSTM 在这种小样本场景下还能用但不要指望它无中生有词表里的语义联想能力远不如预训练模型。真正有价值的经验是每次上线前用手工标注 200 条当前商品品类的评论做“金标准”拿模型去对比而不是盲目相信测试集分数。我自己的教训是有一版模型在旧数据上 F1 0.86换了一批新品类评论直接掉到 0.7原因就是训练数据里根本没出现过相关词汇。从那以后我把“品类外验证”写进了固定的操作流程里这个习惯帮我避开了至少两次上线翻车。希望帮到你。本文还有配套的精品资源点击获取