【PyTorch NLP实战】从零实现 LSTM 酒店评论情感分析(附完整代码逐行讲解)

摘要:本文用一个不到 250 行的完整项目,带你走通 NLP 最经典的入门任务------文本情感分类。从中文分词、建词表、文本转数字序列,到 Embedding + LSTM 建模、训练、评估、预测,全流程逐行讲解。学完这篇,你就拥有了文本分类任务的通用骨架。

关键词:PyTorch、LSTM、情感分析、词嵌入、NLP


一、前言:我们要解决什么问题

打开订酒店 App,每条评论后面都有个"好评/差评"标签。这个标签能不能让机器自动打?

这就是情感分析(Sentiment Analysis) :输入一段自然语言文本,输出它的情感倾向。本文做的是最简单的二分类版本------好评(1) / 差评(0)。

它和我们熟悉的表格数据分类(比如鸢尾花分类)有本质区别:

表格数据分类 文本情感分类
输入 固定个数的数值特征 一段有顺序的文字
难点 特征工程 文字不能直接进网络;词序携带语义

"房间很脏" 和 "脏很房间" 用的词完全一样,但只有前者是通顺的差评。这种顺序信息正是全连接网络处理不了、而 RNN/LSTM 擅长的。

二、整体思路:四道关卡

先建立一个全局认知------整个项目就是在帮文字闯四道关:

复制代码
原始文本          "房间很脏,体验非常差"
    │  关卡1:分词(中文没有空格,要先切成词)
    ▼
词语列表          ["房间", "很", "脏", "体验", "非常", "差"]
    │  关卡2:查词表,词 → 编号
    ▼
数字序列          [305, 12, 88, 421, 67, 9]
    │  关卡3:统一长度(截断 / 补0 到 50)
    ▼
等长张量          [305, 12, 88, ..., 0, 0, 0]   形状(50,)
    │  关卡4:模型(Embedding → LSTM → 全连接)
    ▼
一个 0~1 的概率    0.03 → 差评

每一关为什么必须存在:

  1. 分词:中文没有空格分隔,"房间"必须被切出来才是一个语义单位;

  2. 查词表:神经网络只认数字。先扫一遍全部语料统计词频,取高频词建"词→编号"字典;

  3. 统一长度:评论长短不一,但要凑成一个 batch 一起训练,张量必须等长------短的补 0,长的截断;

  4. 模型:编号本身没有含义(编号 305 和 306 不代表语义相近),所以还要再转成真正的向量,交给 LSTM 按顺序"阅读"。

一句话概括项目精髓:端到端(end-to-end)------不需要人工设计"含有'差'字就判差评"的规则,模型自己从 3000 条评论里学会整条映射。

三、项目结构与环境依赖

复制代码
lstm-demo/
├── model.py          # 全部代码,约230行
├── data/
│   └── train.tsv     # 3000条酒店评论,sentence + label(0差评/1好评)
└── README.md

环境依赖(建议用虚拟环境安装):

复制代码
pip install torch jieba numpy pandas scikit-learn

注意:代码里数据路径是相对路径 data/train.tsv,运行时工作目录必须是项目根目录,否则会找不到文件。

四、超参数区:把所有"旋钮"集中在开头

复制代码
MAX_VOCAB_SIZE = 10000  # 词表最大容量,保留出现频率最高的10000个词
MAX_LEN = 50            # 所有句子统一成50个词,不足补0,超过截断
EMBED_DIM = 64          # 词向量维度
HIDDEN_DIM = 64         # LSTM隐藏层维度
BATCH_SIZE = 64         # 批量大小
EPOCHS = 10             # 训练轮数
LR = 0.001              # 学习率
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")

这几个值的取舍逻辑:

  • 词表 10000:语料只有 3000 条评论,1 万高频词足够覆盖。砍掉低频词还能降噪------只出现一两次的词根本学不出有意义的向量;

  • 句长 50:看一眼数据就知道,绝大多数评论分词后不到 50 个词;

  • EMBED_DIM 和 HIDDEN_DIM 都取 64 不是巧合:词向量进 LSTM 不需要再做维度适配,是常见搭配。

五、第一步:加载数据 load_data()

复制代码
def load_data():
    # 1. 读取tsv文件,构建DF对象
    df = pd.read_csv("data/train.tsv", sep="\t", encoding="utf-8")
    # 2. 删除评论内容为空的行
    df = df.dropna(subset=["sentence", "label"])
    # 3. 提取评论列,转换为字符串类型,转成Python列表
    texts = df["sentence"].astype(str).tolist()
    # 4. 提取标签列,转成numpy数组
    labels = df["label"].values
    return texts, labels
做什么 为什么
读 TSV(Tab 分隔)文件 这份数据用 Tab 分隔,所以 sep="\t"
删掉评论或标签为空的行 脏数据防御:空句子无法分词,空标签无法算损失
评论列 → Python 字符串列表 后面要交给 jieba 分词
标签列 → numpy 数组 后面要进 train_test_split,它吃数组

注意返回值是"分家"的texts 要走"分词→词表→序列"的加工线,labels 不用加工直接进训练,两条线直到 Dataset 那一步才重新配对。

六、第二步:文本预处理 text2seq()(全项目核心)

这个函数一口气干了四件事:分词 → 统计词频 → 建词表 → 转等长数字序列

6.1 中文分词

复制代码
tokens = [jieba.lcut(t) for t in texts]

结果形如:[["房间", "很", "脏"], ["位置", "很好"], ...],每个句子变成一个词列表。

6.2 统计词频

复制代码
word_count = {}
for sentence in tokens:
    for word in sentence:
        word_count[word] = word_count.get(word, 0) + 1

get(word, 0) + 1 是"第一次见到算 0 再加 1"的经典写法。跑完后形如 {"的": 4200, "房间": 890, ...}

6.3 构建词表(信息量最大的三行)

复制代码
vocab = {"<PAD>": 0, "<UNK>": 1}
top_words = sorted(word_count.items(), key=lambda x: x[1], reverse=True)[: MAX_VOCAB_SIZE - 2]
for word, _ in top_words:
    vocab[word] = len(vocab)
  1. 先手动占住 0 号和 1 号<PAD>(padding,补位用)= 0,<UNK>(unknown,生僻词兜底)= 1。这两个不是语料里的词,是系统保留位

  2. 按词频降序排序,只取前 9998 个:高频词拿到小编号;

  3. vocab[word] = len(vocab):利用"当前词表长度就是下一个可用编号"这个事实,依次赋值 2, 3, 4...

最终词表最多 10000 个条目:{"<PAD>":0, "<UNK>":1, "的":2, "房间":3, ...}

6.4 文本转数字序列 + 统一长度

复制代码
seqs = []
for sentence in tokens:
    seq = [vocab.get(w, 1) for w in sentence]         # 查表,查不到→1(<UNK>)
    seq = seq[:MAX_LEN] + [0] * (MAX_LEN - len(seq))  # 截断/补0
    seqs.append(seq)
return np.array(seqs), vocab
  • vocab.get(w, 1)关键设计 ------没进词表的词统一映射成 1(<UNK>),而不是报错;

  • seq[:MAX_LEN]:超过 50 词的砍掉尾巴;

  • [0] * (MAX_LEN - len(seq)):不足 50 词的用 <PAD> 补齐。妙处在于 :如果句子本身超过 50 词,MAX_LEN - len(seq) 是负数,[0]*负数 恰好等于空列表------一行代码同时处理了截断和填充两种情况;

  • 返回 (2960, 50) 的 numpy 数组 + 词表。

💡 用过 Keras 的朋友会发现,这就是手写版的 pad_sequences,逻辑完全一致。

七、第三步:自定义数据集 CommentDataset

复制代码
class CommentDataset(Dataset):
    def __init__(self, seqs, labels):
        self.seqs = seqs
        self.labels = labels
​
    def __len__(self):
        return len(self.seqs)
​
    def __getitem__(self, index):
        return (torch.tensor(self.seqs[index], dtype=torch.long),
                torch.tensor([self.labels[index]], dtype=torch.float))

这是 PyTorch 的固定协议 :继承 Dataset,实现 __len__(告诉 DataLoader 有多少条)和 __getitem__(按索引取一条)。

两个细节值得盯住:

  1. 序列用 dtype=torch.long:Embedding 层只接受整数索引,必须用 long(int64);

  2. 标签包了一层 [...] 且用 torch.float :包成 [label] 让形状变成 (1,),64 条样本堆起来才是 (64, 1),能和模型输出对齐;用 float 是因为 BCEWithLogitsLoss 要求目标是浮点型。

八、第四步:模型定义 LSTMModel

复制代码
class LSTMModel(nn.Module):
    def __init__(self, vocab_size):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, EMBED_DIM, padding_idx=0)
        self.lstm = nn.LSTM(EMBED_DIM, HIDDEN_DIM, batch_first=True)
        # 二分类输出只有一个值(置信率:好评的概率)
        self.fc = nn.Linear(HIDDEN_DIM, 1)
​
    def forward(self, x):
        # x: [batch_size, seq_len]
        x = self.embedding(x)      # [batch_size, seq_len, embed_dim]
        out, (h, c) = self.lstm(x)
        # 取最后一个隐藏状态的输出作为句子表示
        out = self.fc(h[-1])       # [batch_size, 1]
        return out

三层,各司其职:

形状变化 说明
Embedding(10000, 64, padding_idx=0) (词编号) → (64维向量) 一张 10000 行 × 64 列的可学习大表
LSTM(64, 64, batch_first=True) (批,50,64) → (批,50,64) 逐词阅读,维护记忆
Linear(64, 1) (批,64) → (批,1) 把句子语义压成 1 个分数(logits)

三个高频疑问,这里一次讲清:

padding_idx=0 是干什么的? 让 0 号行(<PAD>)永远固定为全 0 向量,且不参与梯度更新------补位的 0 不产生语义,也不该被学习。

batch_first=True 是什么? nn.LSTM 默认输入形状是 (序列长度, 批次, 特征);加上这个参数后才变成更符合直觉的 (批次, 序列长度, 特征),方便和 Embedding 的输出对接。

③ 为什么取 h[-1] 代表整句话?

  • out 存的是每个时刻 的隐藏状态,形状 (batch, 50, 64)

  • h 存的是每一层最后时刻的状态,h[-1] 取最后一层、最后一个时刻的隐藏状态,形状 (batch, 64)

  • LSTM 从左到右阅读,读完最后一个词时,记忆单元里已经浓缩了整句话的语义------就像人读完一句话后的"总体印象"。

注意:最后一层 Linear 后面没有接 sigmoid,sigmoid 的工作被放进了损失函数里(见第十二节)。

九、第五步:训练函数 train()

复制代码
def train(model, dataloader, criterion, optimizer):
    model.train()
    total_loss = 0
    correct = 0
    total = 0
​
    for seqs, labels in dataloader:
        optimizer.zero_grad()              # ① 梯度清零
        outputs = model(seqs)              # ② 前向传播
        loss = criterion(outputs, labels)  # ③ 计算损失
        loss.backward()                    # ④ 反向传播
        optimizer.step()                   # ⑤ 更新参数
​
        total_loss += loss.item()
        # 计算预测结果, sigmoid > 0.5为好评
        preds = (torch.sigmoid(outputs) > 0.5).float()
        correct += (preds == labels).sum().item()
        total += labels.size(0)
​
    return total_loss / len(dataloader), correct / total

①~⑤ 是 PyTorch 训练的标准四件套(清零→前向→反向→更新),任何任务都一样。新增的只有顺手统计训练准确率

  • 模型输出的是原始分数(logits,可正可负),sigmoid 压成概率,>0.5 判好评;

  • .float() 是为了和 float 型标签做相等比较;

  • 返回的准确率是训练集上的,反映"学没学会";真实水平要看测试集。

十、第六步:评估函数 evaluate()

复制代码
def evaluate(model, dataloader):
    model.eval()
    preds = []
    trues = []
​
    with torch.no_grad():
        for seqs, labels in dataloader:
            output = model(seqs)
            pred = (torch.sigmoid(output) > 0.5).numpy()
            preds.extend(pred)
            trues.extend(labels.numpy())
​
    return accuracy_score(trues, preds)

train 对比记忆,三个区别:

  1. 没有 zero_grad / backward / step------评估不更新参数;

  2. model.eval() + torch.no_grad()------关闭梯度,省显存、加速;

  3. 把所有批次的预测攒成大列表,最后一次性算总准确率。

为什么要攒起来而不是逐批平均?因为最后一批可能不满 64 条,逐批平均会给它过高的权重,攒起来算才是真正的全局准确率。

十一、第七步:预测函数 predict()

复制代码
def predict(model, text, vocab):
    tokens = jieba.lcut(text)                           # ① 分词
    seq = [vocab.get(w, 1) for w in tokens]             # ② 查表转编号
    seq = seq[:MAX_LEN] + [0] * (MAX_LEN - len(seq))    # ③ 截断/补0
    seq = torch.tensor([seq], dtype=torch.long)         # ④ 加batch维
​
    model.eval()
    with torch.no_grad():
        output = model(seq)
        prob = torch.sigmoid(output).item()             # ⑤ 转概率
        res = "好评" if prob > 0.5 else "差评"
    return res, prob

①②③ 就是把 text2seq 对单条数据重做一遍 ------注意:训练时建的 vocab 被传了进来。新数据必须用同一份词表 编号,否则模型对不上号(见过的词保持原编号,没见过的照样进 <UNK>)。

  • torch.tensor([seq]):外面套一层列表,把 (50,) 变成 (1, 50)------模型只认 (batch, seq_len),哪怕只有一条也要有 batch 维,这就是 unsqueeze(0) 的等价写法;

  • ⑤ 返回 (结果, 概率),概率表示模型"有多确信"。

十二、主流程:把零件装配起来

复制代码
if __name__ == "__main__":
    # 1. 加载数据,文本转数字序列
    texts, labels = load_data()
    seqs, vocab = text2seq(texts)
    vocab_size = len(vocab)
​
    # 2. 划分训练集和测试集
    train_seq, test_seq, train_labels, test_labels = train_test_split(
        seqs, labels, test_size=0.2, random_state=42)
​
    # 3. DataLoader
    train_dataloader = DataLoader(
        CommentDataset(train_seq, train_labels), batch_size=BATCH_SIZE, shuffle=True)
    test_dataloader = DataLoader(
        CommentDataset(test_seq, test_labels), batch_size=BATCH_SIZE, shuffle=False)
​
    # 4. 定义模型、损失函数和优化器
    model = LSTMModel(vocab_size=vocab_size)
    criterion = nn.BCEWithLogitsLoss()
    optimizer = optim.Adam(model.parameters(), lr=LR)
​
    # 5. 训练模型
    for epoch in range(EPOCHS):
        loss, acc = train(model, train_dataloader, criterion, optimizer)
        print(f"Epoch {epoch+1}/{EPOCHS}, Loss: {loss:.4f}, Accuracy: {acc:.4f}")
​
    # 6. 模型评估
    test_acc = evaluate(model, test_dataloader)
    print(f"测试集准确率: {test_acc:.3f}")
​
    # 7. 模型预测
    test_list = [
        "酒店环境很好,服务态度也不错,下次还会选择入住",
        "房间很脏,设施老旧,体验非常差,不推荐",
    ]
    for t in test_list:
        res, prob = predict(model, t, vocab)
        print(f"评论: {t}\n预测结果: {res}, 置信率: {prob:.4f}\n")

流程就是教科书式的七步:数据 → 预处理 → 划分 → DataLoader → 三件套 → 训练 → 评估预测

为什么用 BCEWithLogitsLoss 而不是 BCELoss

BCEWithLogitsLoss = sigmoid + BCELoss 合体:

  • BCELoss 要求输入已经是概率(需要先手动 sigmoid);

  • BCEWithLogitsLoss 直接吃原始分数,内部自动做 sigmoid,而且数值上更稳定(内部用 log-sum-exp 技巧避免 exp 溢出)。

这解释了全文一个容易困惑的现象:模型最后一层不接 sigmoid,而 train / evaluate / predict 里都要手动补一个 sigmoid

十三、运行效果

<!-- TODO:发布前请替换为你本机的真实运行输出 -->

复制代码
 🚀 开始训练
Epoch 1/10, Loss: 0.6521, Accuracy: 0.6246
Epoch 2/10, Loss: 0.5643, Accuracy: 0.7280
Epoch 3/10, Loss: 0.4466, Accuracy: 0.8214
...
Epoch 10/10, Loss: 0.1738, Accuracy: 0.9430
​
 🧪 开始评估
 🏁 测试集准确率: 0.898
​
 🔍 模型预测
评论: 酒店环境很好,服务态度也不错,下次还会选择入住
预测结果: 好评, 置信率: 0.9992
​
评论: 房间很脏,设施老旧,体验非常差,不推荐
预测结果: 差评, 置信率: 0.0021

可以看到:loss 稳定下降、训练准确率稳步上升,测试集准确率接近九成;两条新评论的预测置信度都接近 100%,说明模型确实学到了情感倾向,而不是死记硬背。

十四、知识点回顾

知识点 在本项目中的位置
中文分词(jieba) text2seq / predict
词表构建、<PAD> / <UNK> 保留位 text2seq
序列截断与填充 text2seq(手写版 pad_sequences)
自定义 Dataset 协议 CommentDataset
nn.Embedding 词嵌入(含 padding_idx LSTMModel
nn.LSTMbatch_first、h/c 含义) LSTMModel
用最后隐藏状态做句子表示 forward 中的 h[-1]
标准训练循环四件套 train
model.eval() + torch.no_grad() evaluate / predict
BCEWithLogitsLoss 二分类损失 主流程

十五、常见问题(避坑指南)

Q1:报错 FileNotFoundError: data/train.tsv A:运行目录不对。代码用的是相对路径,请在项目根目录下执行 python model.py

Q2:Embedding 层报 IndexError 或越界? A:检查词表大小是否一致。nn.Embedding 的第一个参数必须是 len(vocab),且输入编号不能 ≥ 词表大小。

Q3:为什么我的准确率上不去? A:可以依次尝试:① 加大 EMBED_DIM / HIDDEN_DIM;② 增加 EPOCHS;③ 给模型加 nn.Dropout;④ 换用双向 nn.LSTM(bidirectional=True)(注意 fc 输入维度要相应翻倍);⑤ 使用预训练词向量代替随机初始化的 Embedding。

Q4:为什么取 h[-1] 而不是对 out 做平均? A:两种都可行。取 h[-1] 是"用读完全句后的记忆做判断";对 out 做平均池化(mean pooling)则是"综合每个时刻的信息"。对短文本两者差距不大,本文用的是最经典的写法。

Q5:模型没有保存怎么办? A:训练完成后加一行 torch.save(model.state_dict(), "lstm_sentiment.pth"),推理时 model.load_state_dict(torch.load(...)) 加载即可。

十六、总结

这个项目的精髓是端到端:不依赖人工规则、不依赖外部预训练词向量,模型自己从 3000 条评论里学会「分词编号 → 词向量 → 语序语义 → 情感概率」整条映射。

看懂这一个项目,你就拿到了文本分类任务的通用骨架------以后无论是商品评论、新闻分类还是垃圾短信识别,套路都是一样的,只是换数据、调结构。


如果这篇文章对你有帮助,欢迎 点赞、收藏、关注 三连支持,你的支持是我更新的最大动力!

相关推荐
蓝速科技3 小时前
固定涉外场景台式翻译机选型与落地指南
网络·人工智能·自然语言处理·语音识别·技术分享
空堂与归14 小时前
迁移学习怎么落地?Transformers 库微调实战
人工智能·机器学习·自然语言处理·transformer·迁移学习
leoZ23121 小时前
第 6 篇:SchemaForm 渲染器核心实现
前端·javascript·vue.js·人工智能·神经网络·机器学习·自然语言处理
XLYcmy21 小时前
Self-Adapting Language Models论文分享
自然语言处理·llm·微调·sft·论文笔记·强化学习·自进化
大雷神1 天前
HarmonyOS ArkGraphics 2D 自定义字体实操:注册字体并验证中文回退
pytorch·华为·harmonyos
熊猫钓鱼>_>2 天前
MiniMax 深度观察:不是又一个大模型,是AI生产效率的范式革新
人工智能·ai·自然语言处理·媒体·benchmark·minimax·行业
盼小辉丶2 天前
PyTorch强化学习实战——融合人类示范数据的高效强化学习
人工智能·pytorch·python·深度学习·强化学习
李妍.2 天前
PyTorch GPU 版安装记录(RTX 5060 + Python 3.14)
人工智能·pytorch·python
zx_741484812 天前
【机器学习入门】PyTorch 神经网络、卷积神经网络 CNN 实现矿物分类
pytorch·神经网络·机器学习