本文是上篇《LSTM实战:遗忘门、输入门与输出门解决长期依赖》的续篇 。上篇深入解析了 LSTM 三大门的理论机制,本文进入实战阶段:以微博四分类情感分析项目为例,从零搭建一套完整的 NLP 数据预处理流水线。
⚠️ 声明:本项目代码面向学习入门,提供完整可运行的思路框架,模型调优、超参数搜索等进阶优化不在本代码体现范围内。
一、项目总览
1.1 任务定义
| 项目 | 说明 |
|---|---|
| 任务类型 | 多分类情感分析(4类) |
| 数据来源 | 微博评论数据集 simplifyweibo_4_moods.csv |
| 情感类别 | 喜悦 / 愤怒 / 厌恶 / 低落 |
| 模型架构 | 双向多层 LSTM(Bi-LSTM) |
| 词向量 | 腾讯预训练词向量(4762×200维) |
1.2 项目结构
情感分析项目/
├── save_vocab.py # 步骤1:构建词表 → 生成 .pkl 文件
├── load_dataset.py # 步骤2:加载数据集 + 构建迭代器
├── TextRNN.py # 步骤3:Bi-LSTM 模型定义
├── train_eval_test.py # 步骤4:训练 / 评估 / 早停逻辑
├── main.py # 步骤5:整合入口 + 推理预测
├── simplifyweibo_4_moods.csv # 原始数据
├── simplifyweibo_4_moods.pkl # 生成的词表文件
└── embedding_Tencent.npz # 腾讯预训练词向量
1.3 完整流程图
原始CSV数据
│
▼ save_vocab.py
词表构建(4760个高频字 + UNK + PAD)
│
▼ load_dataset.py
数据加载 → 字符分词 → 词ID转换 → 填充/截断
│
▼ DatasetIterater
批次化数据迭代器 → 转 LongTensor
│
▼ TextRNN.Model
Bi-LSTM 前向传播 → 分类输出
│
▼ train_eval_test.py
训练 → 验证 → 早停 → 保存最优模型
│
▼ main.py
加载最优权重 → 推理预测
二、词表构建:save_vocab.py
词表是 NLP 项目的基石。在将文本送入模型之前,必须先建立"字→ID"的映射字典。
2.1 核心设计思路
本项目采用按字分词(Character-level Tokenization)策略:将每个汉字视为独立 token,不做分词处理。这在中文短文本任务中是一种常见且有效的简化方案。
"今天心情很好" → ['今','天','心','情','很','好']
2.2 完整代码解析
以下是 save_vocab.py 的完整源代码,无任何省略:
python
from tqdm import tqdm
import pickle as pkl
# 使用腾讯词向量4762,所有设置词有4760
# 4761放不是词库的 4762放填充的
MAX_VOCAB_SIZE = 4760
UNK, PAD = '<UNK>', '<PAD>'
def build_vocab(file_path, max_size, min_freq):
'''#函数作用,遍历爬取数据文件,按字返回词表
file_path数据名,max_size词库大小,min_freq词库中词最少数量'''
#定义一个tokenizer函数:
#def tokenizer(x)
# ls = []
# for y in x:
# ls.append(y)
# return ls
tokenizer = lambda x:[y for y in x]
vocab_dic = {} #词表
with open(file_path, 'r', encoding='UTF-8') as f:
i = 0
for line in tqdm(f):
if i ==0: #去掉标题的label,review,取内容
i +=1
continue
lin = line[2:].strip()#内容特点:0,巴拉巴拉 取数据
if not lin: #空数据跳过
continue
for word in tokenizer(lin):
#当词表中有该词的值则返回对应值,没有则返回第二个参数0
#给词表一个独一无二的键值对
vocab_dic[word] = vocab_dic.get(word,0)+1
#取前4760个最大值作为词表
vocab_list = sorted([_ for _ in vocab_dic.items() if _[1]>=min_freq] ,key=lambda x:x[1],reverse=True)[:max_size]
#给取得的每个值赋予独一无二的值,类似独热编码{a:0,b:1 ---}
vocab_dic = {word_count[0]:idx for idx,word_count in enumerate(vocab_list)}
vocab_dic.update({UNK:len(vocab_dic),PAD:len(vocab_dic)+1})
print(vocab_dic)
pkl.dump(vocab_dic, open('simplifyweibo_4_moods.pkl','wb'))
print(f"Vocab size:{len(vocab_dic)}")#将评论的内容,根据你现在词表vocab_dic,转换为词向量
return vocab_dic
if __name__=='__main__':
vocab = build_vocab('simplifyweibo_4_moods.csv', MAX_VOCAB_SIZE, 3)
print('vocab')
2.3 代码逐段解析
① 词频统计阶段
python
with open(file_path, 'r', encoding='UTF-8') as f:
i = 0
for line in tqdm(f):
if i == 0: # 跳过CSV标题行
i += 1
continue
lin = line[2:].strip() # CSV格式:"0,评论内容" → 取索引2之后的内容
if not lin:
continue
for word in tokenizer(lin):
vocab_dic[word] = vocab_dic.get(word, 0) + 1 # 词频统计
为什么要用
line[2:]而不是按逗号分割?CSV 中每行格式为
0,评论内容,第 0 位是标签,第 1 位是逗号,正文从第 2 位开始。用line[2:]可以快速跳过标签和分隔符。
② 过滤低频词并排序
python
vocab_list = sorted(
[_ for _ in vocab_dic.items() if _[1] >= min_freq],
key=lambda x: x[1],
reverse=True
)[:max_size]
min_freq=3:出现次数 < 3 的字视为噪声,过滤掉reverse=True:按词频从高到低排序[:max_size]:取前 4760 个高频字
③ 构建索引字典
python
vocab_dic = {word_count[0]: idx for idx, word_count in enumerate(vocab_list)}
vocab_dic.update({UNK: len(vocab_dic), PAD: len(vocab_dic) + 1})
pkl.dump(vocab_dic, open('simplifyweibo_4_moods.pkl', 'wb'))
enumerate(vocab_list):从 0 开始给每个词分配唯一索引- UNK 索引 = 4760,PAD 索引 = 4761,与腾讯词向量矩阵行数对齐
pkl.dump:序列化词表,后续加载无需重新统计
2.4 词表结构示意
词表(简化示意):
{
'的': 0, # 词频最高
'了': 1,
'是': 2,
...
'罕': 4759, # 词频最低(仍 ≥ min_freq)
'<UNK>': 4760,
'<PAD>': 4761
}
词表总大小 = 4762
为什么 MAX_VOCAB_SIZE 设为 4760?
腾讯预训练词向量矩阵共 4762 行,最后两行预留给 UNK 和 PAD,因此常规词的上限为 4760。
三、数据加载:load_dataset.py
3.1 load_dataset 函数完整代码
python
from sklearn.model_selection import StratifiedShuffleSplit
import numpy as np
from tqdm import tqdm
import pickle as pkl
import random
import torch
# 由于使用腾讯的词向量训练4762*200
# 所以UNK = 4760 PAD = 4761
UNK, PAD = '<UNK>', '<PAD>'
def load_dataset(path, pad_size=70):
'''#处理每个读取的句子,1.把长度超过70的直接后面减去
#2.把长度低于70的用PAD填充,3不在上述处理词表中的用UNK代替
作用返回1词表 2训练数据 3验证数据 4测试数据
'''
contents = []#添加句子中词的('独热编码',标签,长度)
vocab = pkl.load(open('simplifyweibo_4_moods.pkl','rb'))#读取词表
tokenizer = lambda x: [y for y in x]
'''等价
def tokenize(x)
ls = []
for y in x:
ls.append(y)
return ls
'''
with open(path,'r',encoding='UTF-8') as f:
i = 0
for line in tqdm(f):
if i ==0:
i += 1
continue
if not line:
continue
label = int(line[0])#读取标签
content = line[2:].strip('\n')#读取数据
words_line = [] #添加后续每个数据中的词的"独热编码"
#读取数据长度
token = tokenizer(content)
seq_len = len(token)
if pad_size:
if len(token) < pad_size: #一条句子词的数量小于pad_size则填充
token.extend([PAD]*(pad_size - len(token)))
else: #一条句子大于pad_size则直接删减后面的
token = token[:pad_size]
seq_len = pad_size
for word in token:
#vocab.get(word,1) 如果字典中有word对应的值则返回对应值,否则返回第二个参数1
words_line.append(vocab.get(word,vocab.get(UNK))) #vocab.get(UNK)返回的是4760
contents.append((words_line,int(label),seq_len))
#提取所有标签(用于分层)
labels = [item[1] for item in contents]
# 分层划分训练集(80%) + 临时集(20%)
sss1 = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=1)
train_idx, temp_idx = next(sss1.split(contents, labels))
train_data = [contents[i] for i in train_idx]
temp_data = [contents[i] for i in temp_idx]
# 从临时集分层划分验证集(10%) + 测试集(10%)
temp_labels = [temp_data[i][1] for i in range(len(temp_data))]
sss2 = StratifiedShuffleSplit(n_splits=1, test_size=0.5, random_state=1)
dev_idx, test_idx = next(sss2.split(temp_data, temp_labels))
dev_data = [temp_data[i] for i in dev_idx]
test_data = [temp_data[i] for i in test_idx]
return vocab,train_data,dev_data,test_data
3.2 数据格式说明
simplifyweibo_4_moods.csv 格式如下:
label,review
0,哈哈哈这次玩的太开心了!
1,这个人真的太让人愤怒了
2,这东西真的太恶心了
3,唉今天什么都不想做
...
- 第 0 列:情绪标签(0=喜悦, 1=愤怒, 2=厌恶, 3=低落)
- 第 1 列之后:评论文本
3.3 填充/截断逻辑详解
python
if pad_size:
if len(token) < pad_size:
token.extend([PAD] * (pad_size - len(token))) # 短句用 PAD 填充
else:
token = token[:pad_size] # 长句直接截断
seq_len = pad_size
三种情况示意(pad_size=5):
原句:"今天心情很好"(6个字)→ 截断 → ['今','天','心','情','很'] seq_len=5
原句:"好开心"(3个字) → 填充 → ['好','开','心','<PAD>','<PAD>'] seq_len=3
原句:"还行"(2个字) → 填充 → ['还','行','<PAD>','<PAD>','<PAD>'] seq_len=2
3.4 分层划分数据集
常规随机划分容易导致类别不均衡,本项目使用 StratifiedShuffleSplit 保证每个分割的类别比例与原始数据一致:
python
# 第一次分割:训练集(80%) + 临时集(20%)
sss1 = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=1)
train_idx, temp_idx = next(sss1.split(contents, labels))
train_data = [contents[i] for i in train_idx]
temp_data = [contents[i] for i in temp_idx]
# 第二次分割:验证集(10%) + 测试集(10%)
temp_labels = [temp_data[i][1] for i in range(len(temp_data))]
sss2 = StratifiedShuffleSplit(n_splits=1, test_size=0.5, random_state=1)
dev_idx, test_idx = next(sss2.split(temp_data, temp_labels))
dev_data = [temp_data[i] for i in dev_idx]
test_data = [temp_data[i] for i in test_idx]
数据集划分比例:
原始数据集(100%)
├── 训练集 train 80%
├── 验证集 dev 10%
└── 测试集 test 10%
random_state=1保证每次运行的划分结果完全一致,是实验可复现性的重要保障。
四、批次迭代器:DatasetIterater
PyTorch 的 DataLoader 对自定义数据格式不够灵活,本项目手动实现了一个轻量级迭代器。
4.1 完整代码
python
class DatasetIterater(object):
#数据 大小 设备
def __init__(self,batches,batch_size,device):
self.batch_size = batch_size
self.batches = batches
self.n_batches = len(batches) // batch_size #数据划分n个批次
self.residue = False #判断批次大小是否整除
if len(batches) % self.n_batches != 0:
self.residue = True
self.index = 0
self.device = device
def _to_tensor(self,datas):
x = torch.LongTensor([_[0] for _ in datas]).to(self.device)
y = torch.LongTensor([_[1] for _ in datas]).to(self.device)
seq_len = torch.LongTensor([_[2] for _ in datas]).to(self.device)
return (x,seq_len),y
def __next__(self): #调用出现for循环时,直接调用 __next__下的代码
if self.residue and self.index == self.n_batches:
'''当批次不剩数据时'''
batches = self.batches[self.index * self.batch_size:len(self.batches)]
self.index += 1
batches = self._to_tensor(batches)
return batches
elif self.index > self.n_batches:#遍历结束
self.index = 0
raise StopIteration
else:
'''整除最大批次后剩余数据 再多加一个批次'''
batches = self.batches[self.index * self.batch_size:(self.index + 1) * self.batch_size]
self.index += 1
batches = self._to_tensor(batches)
return batches
def __iter__(self):
return self
def __len__(self):
if self.residue:
return self.n_batches + 1
else:
return self.n_batches
4.2 __init__ 初始化详解
python
def __init__(self, batches, batch_size, device):
self.batch_size = batch_size
self.batches = batches
self.n_batches = len(batches) // batch_size # 整除的批次数
self.residue = len(batches) % batch_size != 0 # 是否有尾部不完整批次
self.index = 0
self.device = device
举例:若有 1000 条数据,batch_size=128,则:
n_batches = 1000 // 128 = 7(完整批次)residue = True(剩余 1000 - 7×128 = 104 条,不足一批)
4.3 核心转换:_to_tensor
python
def _to_tensor(self, datas):
x = torch.LongTensor([_[0] for _ in datas]).to(self.device) # [B, 70]
y = torch.LongTensor([_[1] for _ in datas]).to(self.device) # [B]
seq_len = torch.LongTensor([_[2] for _ in datas]).to(self.device) # [B]
return (x, seq_len), y
返回值说明:
python
return (x, seq_len), y
# └────────────┘ │
# │ └─ 标签 [batch_size]
# └─ 元组:(词ID序列, 真实长度)
Tensor 形状说明:
| 变量 | 形状 | 含义 |
|---|---|---|
x |
[batch_size, 70] |
每条评论的词ID序列 |
y |
[batch_size] |
情绪标签(0~3) |
seq_len |
[batch_size] |
每条评论的真实字数 |
4.4 迭代逻辑:__next__
python
def __next__(self):
# 情况1:最后一个不完整批次
if self.residue and self.index == self.n_batches:
batches = self.batches[self.index * self.batch_size : len(self.batches)]
self.index += 1
batches = self._to_tensor(batches)
return batches
# 情况2:遍历结束
elif self.index > self.n_batches:
self.index = 0
raise StopIteration
# 情况3:正常整除批次
else:
batches = self.batches[self.index * self.batch_size : (self.index + 1) * self.batch_size]
self.index += 1
batches = self._to_tensor(batches)
return batches
4.5 支持 for 循环遍历
python
def __iter__(self):
return self
def __len__(self):
if self.residue:
return self.n_batches + 1
else:
return self.n_batches
有了 __iter__ 和 __len__,迭代器可以直接用 for batch in train_iter: 遍历:
python
for (trains, labels) in train_iter:
output = model(trains)
loss = F.cross_entropy(output, labels)
...
五、主程序测试
load_dataset.py 自带的测试入口:
python
if __name__=='__main__':
vocab,train_data,dev_data,test_data = load_dataset('simplifyweibo_4_moods.csv')
print(train_data,dev_data,test_data)
print(f"vocab.get(UNK) = {vocab.get(UNK)}")
print("结束")
运行后可看到词表中的 UNK 索引值是否为 4760,以及三组数据集的划分结果。
六、小结
本篇完整解析了情感分析项目数据预处理的两个核心模块,共 5 个函数/类:
| 模块 | 完整函数/类 | 核心功能 | 输出 |
|---|---|---|---|
save_vocab.py |
build_vocab() |
统计词频 → 过滤低频 → 构建字典 → 序列化 | .pkl 词表文件 |
load_dataset.py |
load_dataset() |
读取CSV → 字符分词 → 填充截断 → 分层划分 | 三组 (词ID, 标签, 长度) 列表 |
load_dataset.py |
DatasetIterater |
批次化 → 转 GPU Tensor → 支持 for 循环遍历 |
可迭代的批次数据流 |
下篇预告 :数据准备好后,如何搭建能读懂情感的 Bi-LSTM 模型?下篇将解析
TextRNN.py,剖析双向三层 LSTM 的架构设计细节,完整展示模型的每一行代码。