)
摘要本文用一个不到 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 → 差评每一关为什么必须存在分词中文没有空格分隔房间必须被切出来才是一个语义单位查词表神经网络只认数字。先扫一遍全部语料统计词频取高频词建词→编号字典统一长度评论长短不一但要凑成一个 batch 一起训练张量必须等长——短的补 0长的截断模型编号本身没有含义编号 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, encodingutf-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行做什么为什么①读 TSVTab 分隔文件这份数据用 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) 1get(word, 0) 1是第一次见到算 0 再加 1的经典写法。跑完后形如{的: 4200, 房间: 890, ...}。6.3 构建词表信息量最大的三行vocab {PAD: 0, UNK: 1} top_words sorted(word_count.items(), keylambda x: x[1], reverseTrue)[: MAX_VOCAB_SIZE - 2] for word, _ in top_words: vocab[word] len(vocab)先手动占住 0 号和 1 号PADpadding补位用 0UNKunknown生僻词兜底 1。这两个不是语料里的词是系统保留位按词频降序排序只取前 9998 个高频词拿到小编号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), vocabvocab.get(w, 1)关键设计——没进词表的词统一映射成 1UNK而不是报错seq[:MAX_LEN]超过 50 词的砍掉尾巴[0] * (MAX_LEN - len(seq))不足 50 词的用PAD补齐。妙处在于如果句子本身超过 50 词MAX_LEN - len(seq)是负数[0]*负数恰好等于空列表——一行代码同时处理了截断和填充两种情况返回(2960, 50)的 numpy 数组 词表。 用过 Keras 的朋友会发现这就是手写版的pad_sequences逻辑完全一致。七、第三步自定义数据集CommentDatasetclass 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], dtypetorch.long), torch.tensor([self.labels[index]], dtypetorch.float))这是 PyTorch 的固定协议继承Dataset实现__len__告诉 DataLoader 有多少条和__getitem__按索引取一条。两个细节值得盯住序列用dtypetorch.longEmbedding 层只接受整数索引必须用 longint64标签包了一层[...]且用torch.float包成[label]让形状变成(1,)64 条样本堆起来才是(64, 1)能和模型输出对齐用 float 是因为BCEWithLogitsLoss要求目标是浮点型。八、第四步模型定义LSTMModelclass LSTMModel(nn.Module): def __init__(self, vocab_size): super().__init__() self.embedding nn.Embedding(vocab_size, EMBED_DIM, padding_idx0) self.lstm nn.LSTM(EMBED_DIM, HIDDEN_DIM, batch_firstTrue) # 二分类输出只有一个值(置信率好评的概率) 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_idx0)(词编号) → (64维向量)一张 10000 行 × 64 列的可学习大表LSTM(64, 64, batch_firstTrue)(批,50,64) → (批,50,64)逐词阅读维护记忆Linear(64, 1)(批,64) → (批,1)把句子语义压成 1 个分数logits三个高频疑问这里一次讲清①padding_idx0是干什么的让 0 号行PAD永远固定为全 0 向量且不参与梯度更新——补位的 0 不产生语义也不该被学习。②batch_firstTrue是什么nn.LSTM默认输入形状是(序列长度, 批次, 特征)加上这个参数后才变成更符合直觉的(批次, 序列长度, 特征)方便和 Embedding 的输出对接。③ 为什么取h[-1]代表整句话out存的是每个时刻的隐藏状态形状(batch, 50, 64)h存的是每一层最后时刻的状态h[-1]取最后一层、最后一个时刻的隐藏状态形状(batch, 64)LSTM 从左到右阅读读完最后一个词时记忆单元里已经浓缩了整句话的语义——就像人读完一句话后的总体印象。注意最后一层Linear后面没有接 sigmoidsigmoid 的工作被放进了损失函数里见第十二节。九、第五步训练函数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对比记忆三个区别没有zero_grad / backward / step——评估不更新参数model.eval()torch.no_grad()——关闭梯度省显存、加速把所有批次的预测攒成大列表最后一次性算总准确率。为什么要攒起来而不是逐批平均因为最后一批可能不满 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], dtypetorch.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_size0.2, random_state42) # 3. DataLoader train_dataloader DataLoader( CommentDataset(train_seq, train_labels), batch_sizeBATCH_SIZE, shuffleTrue) test_dataloader DataLoader( CommentDataset(test_seq, test_labels), batch_sizeBATCH_SIZE, shuffleFalse) # 4. 定义模型、损失函数和优化器 model LSTMModel(vocab_sizevocab_size) criterion nn.BCEWithLogitsLoss() optimizer optim.Adam(model.parameters(), lrLR) # 5. 训练模型 for epoch in range(EPOCHS): loss, acc train(model, train_dataloader, criterion, optimizer) print(fEpoch {epoch1}/{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而不是BCELossBCEWithLogitsLosssigmoidBCELoss合体BCELoss要求输入已经是概率需要先手动 sigmoidBCEWithLogitsLoss直接吃原始分数内部自动做 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%说明模型确实学到了情感倾向而不是死记硬背。十四、知识点回顾知识点在本项目中的位置中文分词jiebatext2seq/predict词表构建、PAD/UNK保留位text2seq序列截断与填充text2seq手写版 pad_sequences自定义Dataset协议CommentDatasetnn.Embedding词嵌入含padding_idxLSTMModelnn.LSTMbatch_first、h/c 含义LSTMModel用最后隐藏状态做句子表示forward中的h[-1]标准训练循环四件套trainmodel.eval()torch.no_grad()evaluate/predictBCEWithLogitsLoss二分类损失主流程十五、常见问题避坑指南Q1报错FileNotFoundError: data/train.tsvA运行目录不对。代码用的是相对路径请在项目根目录下执行python model.py。Q2Embedding 层报IndexError或越界A检查词表大小是否一致。nn.Embedding的第一个参数必须是len(vocab)且输入编号不能 ≥ 词表大小。Q3为什么我的准确率上不去A可以依次尝试① 加大EMBED_DIM / HIDDEN_DIM② 增加EPOCHS③ 给模型加nn.Dropout④ 换用双向nn.LSTM(bidirectionalTrue)注意 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 条评论里学会「分词编号 → 词向量 → 语序语义 → 情感概率」整条映射。看懂这一个项目你就拿到了文本分类任务的通用骨架——以后无论是商品评论、新闻分类还是垃圾短信识别套路都是一样的只是换数据、调结构。如果这篇文章对你有帮助欢迎点赞、收藏、关注三连支持你的支持是我更新的最大动力