本文是上篇《LSTM实战遗忘门、输入门与输出门解决长期依赖》的续篇。上篇深入解析了 LSTM 三大门的理论机制本文进入实战阶段以微博四分类情感分析项目为例从零搭建一套完整的 NLP 数据预处理流水线。⚠️声明本项目代码面向学习入门提供完整可运行的思路框架模型调优、超参数搜索等进阶优化不在本代码体现范围内。一、项目总览1.1 任务定义项目说明任务类型多分类情感分析4类数据来源微博评论数据集simplifyweibo_4_moods.csv情感类别喜悦 / 愤怒 / 厌恶 / 低落模型架构双向多层 LSTMBi-LSTM词向量腾讯预训练词向量4762×200维1.2 项目结构情感分析项目/ ├── save_vocab.py # 步骤1构建词表 → 生成 .pkl 文件 ├── load_dataset.py # 步骤2加载数据集 构建迭代器 ├── TextRNN.py # 步骤3Bi-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_SIZE4760UNK,PADUNK,PADdefbuild_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 lstokenizerlambdax:[yforyinx]vocab_dic{}#词表withopen(file_path,r,encodingUTF-8)asf:i0forlineintqdm(f):ifi0:#去掉标题的label,review取内容i1continuelinline[2:].strip()#内容特点:0,巴拉巴拉 取数据ifnotlin:#空数据跳过continueforwordintokenizer(lin):#当词表中有该词的值则返回对应值没有则返回第二个参数0#给词表一个独一无二的键值对vocab_dic[word]vocab_dic.get(word,0)1#取前4760个最大值作为词表vocab_listsorted([_for_invocab_dic.items()if_[1]min_freq],keylambdax:x[1],reverseTrue)[:max_size]#给取得的每个值赋予独一无二的值类似独热编码{a0b1 ---}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(fVocab size:{len(vocab_dic)})#将评论的内容根据你现在词表vocab_dic,转换为词向量returnvocab_dicif__name____main__:vocabbuild_vocab(simplifyweibo_4_moods.csv,MAX_VOCAB_SIZE,3)print(vocab)2.3 代码逐段解析① 词频统计阶段withopen(file_path,r,encodingUTF-8)asf:i0forlineintqdm(f):ifi0:# 跳过CSV标题行i1continuelinline[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_listsorted([_for_invocab_dic.items()if_[1]min_freq],keylambdax:x[1],reverseTrue)[:max_size]min_freq3出现次数 3 的字视为噪声过滤掉reverseTrue按词频从高到低排序[: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 索引 4760PAD 索引 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.py3.1load_dataset函数完整代码fromsklearn.model_selectionimportStratifiedShuffleSplitimportnumpyasnpfromtqdmimporttqdmimportpickleaspklimportrandomimporttorch# 由于使用腾讯的词向量训练4762*200# 所以UNK 4760 PAD 4761UNK,PADUNK,PADdefload_dataset(path,pad_size70):#处理每个读取的句子1.把长度超过70的直接后面减去 #2.把长度低于70的用PAD填充3不在上述处理词表中的用UNK代替 作用返回1词表 2训练数据 3验证数据 4测试数据 contents[]#添加句子中词的独热编码标签长度vocabpkl.load(open(simplifyweibo_4_moods.pkl,rb))#读取词表tokenizerlambdax:[yforyinx]等价 def tokenize(x) ls [] for y in x: ls.append(y) return ls withopen(path,r,encodingUTF-8)asf:i0forlineintqdm(f):ifi0:i1continueifnotline:continuelabelint(line[0])#读取标签contentline[2:].strip(\n)#读取数据words_line[]#添加后续每个数据中的词的独热编码#读取数据长度tokentokenizer(content)seq_lenlen(token)ifpad_size:iflen(token)pad_size:#一条句子词的数量小于pad_size则填充token.extend([PAD]*(pad_size-len(token)))else:#一条句子大于pad_size则直接删减后面的tokentoken[:pad_size]seq_lenpad_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%)sss1StratifiedShuffleSplit(n_splits1,test_size0.2,random_state1)train_idx,temp_idxnext(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))]sss2StratifiedShuffleSplit(n_splits1,test_size0.5,random_state1)dev_idx,test_idxnext(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:tokentoken[:pad_size]# 长句直接截断seq_lenpad_size三种情况示意pad_size5原句今天心情很好6个字→ 截断 → [今,天,心,情,很] seq_len5 原句好开心3个字 → 填充 → [好,开,心,PAD,PAD] seq_len3 原句还行2个字 → 填充 → [还,行,PAD,PAD,PAD] seq_len23.4 分层划分数据集常规随机划分容易导致类别不均衡本项目使用StratifiedShuffleSplit保证每个分割的类别比例与原始数据一致# 第一次分割训练集(80%) 临时集(20%)sss1StratifiedShuffleSplit(n_splits1,test_size0.2,random_state1)train_idx,temp_idxnext(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))]sss2StratifiedShuffleSplit(n_splits1,test_size0.5,random_state1)dev_idx,test_idxnext(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_state1保证每次运行的划分结果完全一致是实验可复现性的重要保障。四、批次迭代器DatasetIteraterPyTorch 的DataLoader对自定义数据格式不够灵活本项目手动实现了一个轻量级迭代器。4.1 完整代码classDatasetIterater(object):#数据 大小 设备def__init__(self,batches,batch_size,device):self.batch_sizebatch_size self.batchesbatches self.n_batcheslen(batches)//batch_size#数据划分n个批次self.residueFalse#判断批次大小是否整除iflen(batches)%self.n_batches!0:self.residueTrueself.index0self.devicedevicedef_to_tensor(self,datas):xtorch.LongTensor([_[0]for_indatas]).to(self.device)ytorch.LongTensor([_[1]for_indatas]).to(self.device)seq_lentorch.LongTensor([_[2]for_indatas]).to(self.device)return(x,seq_len),ydef__next__(self):#调用出现for循环时直接调用 __next__下的代码ifself.residueandself.indexself.n_batches:当批次不剩数据时batchesself.batches[self.index*self.batch_size:len(self.batches)]self.index1batchesself._to_tensor(batches)returnbatcheselifself.indexself.n_batches:#遍历结束self.index0raiseStopIterationelse:整除最大批次后剩余数据 再多加一个批次batchesself.batches[self.index*self.batch_size:(self.index1)*self.batch_size]self.index1batchesself._to_tensor(batches)returnbatchesdef__iter__(self):returnselfdef__len__(self):ifself.residue:returnself.n_batches1else:returnself.n_batches4.2__init__初始化详解def__init__(self,batches,batch_size,device):self.batch_sizebatch_size self.batchesbatches self.n_batcheslen(batches)//batch_size# 整除的批次数self.residuelen(batches)%batch_size!0# 是否有尾部不完整批次self.index0self.devicedevice举例若有 1000 条数据batch_size128则n_batches 1000 // 128 7完整批次residue True剩余 1000 - 7×128 104 条不足一批4.3 核心转换_to_tensordef_to_tensor(self,datas):xtorch.LongTensor([_[0]for_indatas]).to(self.device)# [B, 70]ytorch.LongTensor([_[1]for_indatas]).to(self.device)# [B]seq_lentorch.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~3seq_len[batch_size]每条评论的真实字数4.4 迭代逻辑__next__def__next__(self):# 情况1最后一个不完整批次ifself.residueandself.indexself.n_batches:batchesself.batches[self.index*self.batch_size:len(self.batches)]self.index1batchesself._to_tensor(batches)returnbatches# 情况2遍历结束elifself.indexself.n_batches:self.index0raiseStopIteration# 情况3正常整除批次else:batchesself.batches[self.index*self.batch_size:(self.index1)*self.batch_size]self.index1batchesself._to_tensor(batches)returnbatches4.5 支持for循环遍历def__iter__(self):returnselfdef__len__(self):ifself.residue:returnself.n_batches1else:returnself.n_batches有了__iter__和__len__迭代器可以直接用for batch in train_iter:遍历for(trains,labels)intrain_iter:outputmodel(trains)lossF.cross_entropy(output,labels)...五、主程序测试load_dataset.py自带的测试入口if__name____main__:vocab,train_data,dev_data,test_dataload_dataset(simplifyweibo_4_moods.csv)print(train_data,dev_data,test_data)print(fvocab.get(UNK) {vocab.get(UNK)})print(结束)运行后可看到词表中的 UNK 索引值是否为 4760以及三组数据集的划分结果。六、小结本篇完整解析了情感分析项目数据预处理的两个核心模块共 5 个函数/类模块完整函数/类核心功能输出save_vocab.pybuild_vocab()统计词频 → 过滤低频 → 构建字典 → 序列化.pkl词表文件load_dataset.pyload_dataset()读取CSV → 字符分词 → 填充截断 → 分层划分三组(词ID, 标签, 长度)列表load_dataset.pyDatasetIterater批次化 → 转 GPU Tensor → 支持for循环遍历可迭代的批次数据流下篇预告数据准备好后如何搭建能读懂情感的 Bi-LSTM 模型下篇将解析TextRNN.py剖析双向三层 LSTM 的架构设计细节完整展示模型的每一行代码。