摘要:本文用一个不到 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", 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)
-
先手动占住 0 号和 1 号 :
<PAD>(padding,补位用)= 0,<UNK>(unknown,生僻词兜底)= 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), 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__(按索引取一条)。
两个细节值得盯住:
-
序列用
dtype=torch.long:Embedding 层只接受整数索引,必须用 long(int64); -
标签包了一层
[...]且用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 对比记忆,三个区别:
-
没有
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], 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.LSTM(batch_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 条评论里学会「分词编号 → 词向量 → 语序语义 → 情感概率」整条映射。
看懂这一个项目,你就拿到了文本分类任务的通用骨架------以后无论是商品评论、新闻分类还是垃圾短信识别,套路都是一样的,只是换数据、调结构。
如果这篇文章对你有帮助,欢迎 点赞、收藏、关注 三连支持,你的支持是我更新的最大动力!