基于 LSTM 的中文电商评论情感分类:从数据处理到 91% 准确率实战
项目地址(Gitee):https://gitee.com/Touari/lstm_sentiment_2class.git
本文整合自项目《构建文档》与《技术文档》,既讲复现步骤,也讲设计原理。
1. 项目简介
构建一个中文电商评论情感分类模型 :给定一条评论文本,判定其情感倾向为正向(好评)或负向(差评) 。在工程上等价于基于句子的二分类任务(Binary Text Classification)。
输入:这个商品质量非常好,物流也快,很满意
输出:正向(概率 0.9997)
与上一个项目(RNN 中文输入法,预测下一词)的演进对比:
| RNN_TextInput(上项目) | LSTM_Sentiment_2class(本项目) | |
|---|---|---|
| 任务 | 前 5 词预测下一词 | 整句判定正/负情感 |
| 任务本质 | 多分类(21,138 类) | 二分类(2 类) |
| 循环层 | nn.RNN(简单循环单元) |
nn.LSTM(三门控机制) |
| 输入 | 定长窗口 5 词 | 变长句子 → 定长 128(填充/截断) |
| 输出层 | Linear(hidden → 21138) |
Linear(hidden → 1)(单个 logit) |
| 损失函数 | CrossEntropyLoss | BCEWithLogitsLoss |
2. 整体架构与数据流
data/raw/online_shopping_10_cats.csv(62774 条电商评论,10 品类,0/1 标签)
│ process.py:读取 → 分层划分 8:2 → jieba 分词 → 建词表 → 定长编码
▼
data/processed/train.jsonl(50218 条)+ test.jsonl(12555 条)
│ 每条:{"label": 0/1, "review": [128 个词 id]}
│ dataset.py:SentimentDataset + DataLoader(batch=64)
▼
src/train.py:SentimentModel(Embedding→LSTM→Linear(1))+ BCEWithLogitsLoss + Adam
│
├──→ models/best.pt(loss 最优权重,约 32 MB)
├──→ logs/(TensorBoard 曲线)
▼
evaluate.py(测试集准确率 91.48%) predict.py(交互式预测)
核心思想:把「判断情感」转化为「二分类问题」------模型对整句输出一个 logit(正负得分),经 sigmoid 变成 0~1 的概率,>0.5 判正向。
3. 数据处理详解
3.1 原始数据
online_shopping_10_cats.csv,62,774 条京东/携程电商评论,列:cat(品类)、label(0 差评 / 1 好评)、review(评论文本)。
- 10 个品类:书籍、平板、手机、水果、洗发水、热水器、蒙牛、衣服、计算机、酒店
- 标签高度均衡:好评 31,728(50.5%)/ 差评 31,046(49.5%)------ 二分类理想状态,可直接用准确率作为可信指标
- 评论长度:中位数 34 字,>200 字仅 3.4%,最长 2876 字 ------ 长短差异极大,是变长处理问题的根源
3.2 划分数据集
train_test_split(test_size=0.2, random_state=42, stratify=df['label'])stratify分层抽样:保证训练/测试集正负比例一致(相比上项目是改进点)- 训练 50,218 条 / 测试 12,555 条
3.3 分词与词表
- jieba 分词 (
jieba.lcut) - 词表 =
['<pad>', '<unk>'] + 训练集出现过的词,共 60,973 词 <pad>(索引 0) :填充占位符,nn.Embedding(padding_idx=0)使其向量恒为 0、不参与梯度<unk>(索引 1):未登录词统一映射,避免 OOV 崩溃- 只使用训练集建词表 ------ 防止测试集信息泄漏(标准做法)
3.4 定长序列化(核心设计决策)
句子长短不一(1~1795 个词),而批量训练要求矩阵等长,因此统一到 SEQ_LEN = 128:
分词后: [w1, w2, ..., wN]
N > 128 → 截断尾部:保留前 128 个词
N < 128 → 尾部填充:补 (128 - N) 个 <pad>
最终: [w1, ..., w128](全部等长)
- 上限怎么定的 :先对训练集做长度分布统计,
quantile(0.96)约 116 词 → 取 128 覆盖 96% 的句子,只截断最长 4% 的极端评论 - 为什么选定长而非动态填充 :「先跑通」路线------process 阶段一次性定长,dataset 零负担(DataLoader 默认 stack 即可,无需
collate_fn)。进阶路线(pad_sequence动态填充 +pack_padded_sequence)可在此基础上升级 - 填充方向 :统一尾部填充(pad 在句尾),这是后面「取最后一个真实词」策略的前提
4. 模型结构详解
4.1 网络结构
python
SentimentModel:
Embedding(60,973 → 128, padding_idx=0) # 每个词映射为 128 维向量
LSTM(input=128, hidden=256) # 单层单向,batch_first=True
Linear(256 → 1) # 隐状态 → 单个 logit
4.2 前向传播形状流转
输入 x: [batch, 128] # 128 个词 id(定长)
→ Embedding: [batch, 128, 128] # 每个词 → 128 维向量
→ LSTM: [batch, 128, 256] # 每时间步输出 256 维隐状态
→ 取最后真实词: [batch, 256] # output[batch_idx, lengths-1]
→ Linear: [batch, 1] # 单个 logit
→ squeeze(-1): [batch] # 形状契约:每个样本一个 logit
→ sigmoid(在预测时): [batch] # 概率(损失函数内部自带)
4.3 关键设计点(最有含金量的三处)
① 取「最后一个真实词」的隐状态,而不是最后一步
实际句子: [好, 用, 很, 快, <pad>, <pad>, <pad>]
↑ lengths-1 = 3(最后一个真实词) ↑ -1 = 6(pad 位置)
由于 pad 在尾部,output[:, -1, :] 取到的是 pad 位置的隐状态(无意义)。正确做法:
python
lengths = (x != self.embedding.padding_idx).sum(dim=1) # 每个样本真实长度
last_hidden = output[batch_indexes, lengths - 1] # 精确取最后一个真实词
注意:LSTM 返回的 (h_n, c_n) 中 h_n 虽也是"最后时间步"隐状态,但同样位于 pad 位置,不可直接使用。
② padding_idx=0
告诉 Embedding 层 0 号是 pad:pad 位置的词向量恒为 0,且不参与梯度更新。漏掉此参数是定长填充模型最常见的隐性错误。
③ 输出层 squeeze(-1) 的形状契约
模型统一输出 [batch](每个样本一个 logit),与 BCEWithLogitsLoss 的输入要求(input 与 target 形状一致)对齐。若保留 [batch, 1] 会报 Target size must be the same as input size。
4.4 参数量计算(为什么是 820 万)
| 层 | 计算 | 参数量 |
|---|---|---|
| Embedding | 60,973 × 128 | 7,804,544 |
| LSTM 权重 ih | 4 × 256 × 128 | 131,072 |
| LSTM 权重 hh | 4 × 256 × 256 | 262,144 |
| LSTM 偏置 | 4 × 256 × 2 | 2,048 |
| Linear | 256 × 1 + 1 | 257 |
| 合计 | 8,200,065 ≈ 820 万 |
要点:
- LSTM 有 4 组权重 (输入门/遗忘门/细胞门/输出门),因此参数量是同等规模
nn.RNN的 4 倍------这就是「门控」的物理代价 - 参数大头在 Embedding(95%);FP32 训练态显存约数十 MB,普通显卡毫无压力
4.5 为什么 LSTM 优于 RNN(换层的意义)
nn.RNN 每个时间步 h_t = tanh(W·x_t + U·h_{t-1}),链式相乘导致梯度随序列长度指数级衰减/爆炸(长距离信息记不住)。LSTM 引入三个门控制信息流:
- 遗忘门:决定丢弃多少旧记忆
- 输入门:决定写入多少新信息
- 输出门:决定暴露多少记忆给输出
门控使梯度可以"绕行"细胞状态(c_t)直接传播,缓解了长序列的梯度消失,这是 LSTM 自 1997 年提出后统治 NLP 十余年的核心原因。
5. 超参数设计
| 超参 | 值 | 设计考量 |
|---|---|---|
| SEQ_LEN | 128 | 句长上限,quantile(0.96) 统计确定,覆盖 96% 句子 |
| BATCH_SIZE | 64 | 序列短、模型小,64 提升 GPU 吞吐 |
| EMBEDDING_DIM | 128 | 词向量维度,6 万词表下 128~256 常见区间 |
| HIDDEN_DIM | 256 | LSTM 隐状态维度,与嵌入维同量级 |
| LEARNING_RATE | 0.001 | Adam 常用默认学习率 |
| EPOCHS | 10 | loss 仍在下降未到平台期,可适当增加(见第 8 节) |
6. 训练细节
- 损失函数 :
BCEWithLogitsLoss------ 内部融合 sigmoid 与二元交叉熵,数值更稳定。二分类专用;输入 logits(未过 sigmoid)与 0/1 标签 - 优化器 :
Adam(lr=0.001) - 设备 :
cuda优先 - 数据加载 :
DataLoader(batch_size=64, shuffle=True) - 日志 :
SummaryWriter每轮记录 loss 到logs/<时间戳>/ - 模型保存 :每轮后若 loss 优于历史最优则覆盖保存
models/best.pt(state_dict) - 设备一致性 :
torch.arange(..., device=output.device)------ 索引张量与模型同设备,避免 CUDA 下崩溃(本项目踩过并修复的坑)
实测训练曲线(10 epochs):
0.3932 → 0.2474 → 0.1852 → 0.1419 → 0.1105 → 0.0823 → 0.0560 → 0.0378 → 0.0281 → 0.0229
初始 loss ≈ 0.693(= ln2,二分类随机猜测的理论交叉熵),说明初始化不偏不倚;逐轮下降说明学习正常。
7. 实验结果
7.1 测试集评估(12,555 条全量)
| 指标 | 数值 | 基线 |
|---|---|---|
| 准确率 | 91.48% | 随机猜测 50% |
解读:91.48% 意味着模型真正学到了好评/差评的语言规律("质量差劲"、"再也不买" vs "很满意"、"值得购买"),远非瞎猜。二分类场景下 90%+ 已属良好水平。
7.2 预测示例
| 输入 | 判断 | 概率 |
|---|---|---|
| 这个商品质量非常好,物流也快,很满意 | 正向 | 0.9997 |
| 东西太差了,用了两天就坏了 | 负向 | 0.0000 |
| 太好了 | 正向 | 0.9859 |
| 不好看 | 负向 | 0.9933 |
7.3 置信度解读
预测概率普遍落在 0 或 1 附近(如 0.9997 / 0.0000),这是 sigmoid 饱和的正常现象------训练充分的二分类模型对分布内样本非常自信。概率落在中间(0.4~0.6)反而意味着模型不确定。
8. 快速开始(复现指南)
8.1 环境准备
- Python 3.11
- 依赖:torch 2.7.1+cu118、pandas 3.0.2、jieba 0.42.1、scikit-learn 1.8.0、tqdm 4.68.2、tensorboard 2.21.0
bash
python -m venv .venv
# Windows 激活
.venv\Scripts\activate
pip install torch pandas jieba scikit-learn tqdm tensorboard
命令行运行注意:所有脚本需
cd src后运行(模块间使用import config平级导入),且建议python -E启动防止 PYTHONPATH 污染。
8.2 目录结构
LSTM_Sentiment_2class/
├── data/
│ ├── raw/online_shopping_10_cats.csv 原始数据(62774 条电商评论)
│ └── processed/train.jsonl, test.jsonl 预处理产物(定长 128 的 id 序列 + 标签)
├── logs/ 训练日志(TensorBoard)
├── models/
│ ├── vocab.txt 词表(60973 词,<pad>=0, <unk>=1)
│ └── best.pt 最优模型权重(约 32 MB)
├── src/
│ ├── config.py 路径与超参唯一来源
│ ├── process.py 数据预处理流水线
│ ├── tokenizer.py 词表构建与编解码(含定长填充)
│ ├── dataset.py 数据集与 DataLoader
│ ├── model.py 情感分类模型(Embedding+LSTM+Linear)
│ ├── train.py 训练循环
│ ├── evaluate.py 测试集评估
│ └── predict.py 交互式预测
├── .gitignore
└── main.py
8.3 全流程四步
第 1 步:数据预处理
bash
cd src
python -E process.py
预期输出:打印 词表大小: 60973 与 数据处理完成。产物:train.jsonl(50218 条)、test.jsonl(12555 条)、vocab.txt。
第 2 步:训练
bash
python -E train.py
每轮打印 ========== Epoch N ========== 与 Loss: x.xxxx,loss 从约 0.39 逐轮下降;每轮若 loss 优于历史最优则覆盖 models/best.pt。RTX 5080 上 10 epochs 约 5~10 分钟。
训练曲线可用 TensorBoard 查看:
bash
tensorboard --logdir logs
第 3 步:评估
bash
python -E evaluate.py
预期输出:
词表加载成功
模型加载成功
评估结果
acc: 0.9148
第 4 步:交互式预测
bash
python -E predict.py
> 这个商品质量非常好,物流也快,很满意
正向(置信度:0.9997)
> 不好看
负向(置信度:0.9933)
> q
欢迎下次再来
9. 已知边界与改进方向
已知边界
- 分布外(OOD)输入不可靠 :模型学的是电商评论语言分布。测试集(分布内)91.48%,但训练分布外的表达(如夸奖人的口语"你真棒")会误判。这是所有 NLP 模型的共性,不是 bug
- 无中性类:二分类强制所有输入二选一,"一般般吧"这类中性表达会被迫归入某一类
- 单层单向 LSTM:只能利用左侧上下文,无法看到"不过......但是......"这类转折后置的信息
- 定长截断:超过 128 词的句子尾部被截掉,长句关键信息可能丢失(仅影响最长 4%)
改进方向(按性价比排序)
- 训练循环内加测试集评估 :每 epoch 打印 val_acc,观察过拟合拐点,配合早停(Early Stopping)
- 继续训练:loss 降至 0.023 仍未到平台期,可增加 EPOCHS 或调大 HIDDEN_DIM
- 双向 LSTM :
nn.LSTM(bidirectional=True),输出维度翻倍(hidden 取 128 保持参数量),利用右侧上下文 - 动态填充 :
collate_fn+pad_sequence+pack_padded_sequence,避免 pad 位置的无效计算 - 升级架构:TextCNN、Transformer/BERT ------ 属后续项目范畴
10. 小结
本项目用 Embedding → LSTM → Linear(1) 的经典结构,在 6 万词表的中文电商评论上实现了测试集准确率 91.48% 的情感二分类。相比上一项目(RNN 输入法),本项目的进阶点清晰可见:LSTM 门控替代简单 RNN、分层抽样、定长序列化 + 精确取真实词隐状态、BCEWithLogitsLoss 二分类损失。项目代码结构清晰、全流程可复现,是 NLP 序列建模从「预测」走向「理解」的典型练习。
完整代码与数据见项目仓库:https://gitee.com/Touari/lstm_sentiment_2class.git