LSTM实战(上篇):微博情感分析——词表构建与数据集加载
本文是上篇《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的完整源代码,无任何省略:
fromtqdmimporttqdmimportpickleaspkl# 使用腾讯词向量4762,所有设置词有4760# 4761放不是词库的 4762放填充的MAX_VOCAB_SIZE=4760UNK,PAD='<UNK>','<PAD>'defbuild_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 lstokenizer=lambdax:[yforyinx]vocab_dic={}#词表withopen(file_path,'r',encoding='UTF-8')asf:i=0forlineintqdm(f):ifi==0:#去掉标题的label,review,取内容i+=1continuelin=line[2:].strip()#内容特点:0,巴拉巴拉 取数据ifnotlin:#空数据跳过continueforwordintokenizer(lin):#当词表中有该词的值则返回对应值,没有则返回第二个参数0#给词表一个独一无二的键值对vocab_dic[word]=vocab_dic.get(word,0)+1#取前4760个最大值作为词表vocab_list=sorted([_for_invocab_dic.items()if_[1]>=min_freq],key=lambdax:x[1],reverse=True)[:max_size]#给取得的每个值赋予独一无二的值,类似独热编码{a:0,b:1 ---}vocab_dic={word_count[0]:idxforidx,word_countinenumerate(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,转换为词向量returnvocab_dicif__name__=='__main__':vocab=build_vocab('simplifyweibo_4_moods.csv',MAX_VOCAB_SIZE,3)print('vocab')2.3 代码逐段解析
① 词频统计阶段
withopen(file_path,'r',encoding='UTF-8')asf:i=0forlineintqdm(f):ifi==0:# 跳过CSV标题行i+=1continuelin=line[2:].strip()# CSV格式:"0,评论内容" → 取索引2之后的内容ifnotlin:continueforwordintokenizer(lin):vocab_dic[word]=vocab_dic.get(word,0)+1# 词频统计为什么要用
line[2:]而不是按逗号分割?
CSV 中每行格式为0,评论内容,第 0 位是标签,第 1 位是逗号,正文从第 2 位开始。用line[2:]可以快速跳过标签和分隔符。
② 过滤低频词并排序
vocab_list=sorted([_for_invocab_dic.items()if_[1]>=min_freq],key=lambdax:x[1],reverse=True)[:max_size]min_freq=3:出现次数 < 3 的字视为噪声,过滤掉reverse=True:按词频从高到低排序[:max_size]:取前 4760 个高频字
③ 构建索引字典
vocab_dic={word_count[0]:idxforidx,word_countinenumerate(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.1load_dataset函数完整代码
fromsklearn.model_selectionimportStratifiedShuffleSplitimportnumpyasnpfromtqdmimporttqdmimportpickleaspklimportrandomimporttorch# 由于使用腾讯的词向量训练4762*200# 所以UNK = 4760 PAD = 4761UNK,PAD='<UNK>','<PAD>'defload_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=lambdax:[yforyinx]'''等价 def tokenize(x) ls = [] for y in x: ls.append(y) return ls '''withopen(path,'r',encoding='UTF-8')asf:i=0forlineintqdm(f):ifi==0:i+=1continueifnotline:continuelabel=int(line[0])#读取标签content=line[2:].strip('\n')#读取数据words_line=[]#添加后续每个数据中的词的"独热编码"#读取数据长度token=tokenizer(content)seq_len=len(token)ifpad_size:iflen(token)<pad_size:#一条句子词的数量小于pad_size则填充token.extend([PAD]*(pad_size-len(token)))else:#一条句子大于pad_size则直接删减后面的token=token[:pad_size]seq_len=pad_sizeforwordintoken:#vocab.get(word,1) 如果字典中有word对应的值则返回对应值,否则返回第二个参数1words_line.append(vocab.get(word,vocab.get(UNK)))#vocab.get(UNK)返回的是4760contents.append((words_line,int(label),seq_len))#提取所有标签(用于分层)labels=[item[1]foritemincontents]# 分层划分训练集(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]foriintrain_idx]temp_data=[contents[i]foriintemp_idx]# 从临时集分层划分验证集(10%) + 测试集(10%)temp_labels=[temp_data[i][1]foriinrange(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]foriindev_idx]test_data=[temp_data[i]foriintest_idx]returnvocab,train_data,dev_data,test_data3.2 数据格式说明
simplifyweibo_4_moods.csv格式如下:
label,review 0,哈哈哈这次玩的太开心了! 1,这个人真的太让人愤怒了 2,这东西真的太恶心了 3,唉今天什么都不想做 ...- 第 0 列:情绪标签(0=喜悦, 1=愤怒, 2=厌恶, 3=低落)
- 第 1 列之后:评论文本
3.3 填充/截断逻辑详解
ifpad_size:iflen(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=23.4 分层划分数据集
常规随机划分容易导致类别不均衡,本项目使用StratifiedShuffleSplit保证每个分割的类别比例与原始数据一致:
# 第一次分割:训练集(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]foriintrain_idx]temp_data=[contents[i]foriintemp_idx]# 第二次分割:验证集(10%) + 测试集(10%)temp_labels=[temp_data[i][1]foriinrange(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]foriindev_idx]test_data=[temp_data[i]foriintest_idx]数据集划分比例:
原始数据集(100%) ├── 训练集 train 80% ├── 验证集 dev 10% └── 测试集 test 10%
random_state=1保证每次运行的划分结果完全一致,是实验可复现性的重要保障。
四、批次迭代器:DatasetIterater
PyTorch 的DataLoader对自定义数据格式不够灵活,本项目手动实现了一个轻量级迭代器。
4.1 完整代码
classDatasetIterater(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#判断批次大小是否整除iflen(batches)%self.n_batches!=0:self.residue=Trueself.index=0self.device=devicedef_to_tensor(self,datas):x=torch.LongTensor([_[0]for_indatas]).to(self.device)y=torch.LongTensor([_[1]for_indatas]).to(self.device)seq_len=torch.LongTensor([_[2]for_indatas]).to(self.device)return(x,seq_len),ydef__next__(self):#调用出现for循环时,直接调用 __next__下的代码ifself.residueandself.index==self.n_batches:'''当批次不剩数据时'''batches=self.batches[self.index*self.batch_size:len(self.batches)]self.index+=1batches=self._to_tensor(batches)returnbatcheselifself.index>self.n_batches:#遍历结束self.index=0raiseStopIterationelse:'''整除最大批次后剩余数据 再多加一个批次'''batches=self.batches[self.index*self.batch_size:(self.index+1)*self.batch_size]self.index+=1batches=self._to_tensor(batches)returnbatchesdef__iter__(self):returnselfdef__len__(self):ifself.residue:returnself.n_batches+1else:returnself.n_batches4.2__init__初始化详解
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=0self.device=device举例:若有 1000 条数据,batch_size=128,则:
n_batches = 1000 // 128 = 7(完整批次)residue = True(剩余 1000 - 7×128 = 104 条,不足一批)
4.3 核心转换:_to_tensor
def_to_tensor(self,datas):x=torch.LongTensor([_[0]for_indatas]).to(self.device)# [B, 70]y=torch.LongTensor([_[1]for_indatas]).to(self.device)# [B]seq_len=torch.LongTensor([_[2]for_indatas]).to(self.device)# [B]return(x,seq_len),y返回值说明:
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__
def__next__(self):# 情况1:最后一个不完整批次ifself.residueandself.index==self.n_batches:batches=self.batches[self.index*self.batch_size:len(self.batches)]self.index+=1batches=self._to_tensor(batches)returnbatches# 情况2:遍历结束elifself.index>self.n_batches:self.index=0raiseStopIteration# 情况3:正常整除批次else:batches=self.batches[self.index*self.batch_size:(self.index+1)*self.batch_size]self.index+=1batches=self._to_tensor(batches)returnbatches4.5 支持for循环遍历
def__iter__(self):returnselfdef__len__(self):ifself.residue:returnself.n_batches+1else:returnself.n_batches有了__iter__和__len__,迭代器可以直接用for batch in train_iter:遍历:
for(trains,labels)intrain_iter:output=model(trains)loss=F.cross_entropy(output,labels)...五、主程序测试
load_dataset.py自带的测试入口:
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 的架构设计细节,完整展示模型的每一行代码。
