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完整源代码,无任何省略:

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 的架构设计细节,完整展示模型的每一行代码。

相关推荐
大江东去浪淘尽千古风流人物3 小时前
【cuVSLAM】GPU 加速、多相机、实时视觉/视觉惯性 SLAM设计优势
c++·人工智能·数码相机·ubuntu·计算机视觉·augmented reality
Elastic 中国社区官方博客8 小时前
Elasticsearch:使用 Agent Builder 的 A2A 实现 - 开发者的圣诞颂歌
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
chools8 小时前
【AI超级智能体】快速搞懂工具调用Tool Calling 和 MCP协议
java·人工智能·学习·ai
郝学胜-神的一滴9 小时前
深度学习必学:PyTorch 神经网络参数初始化全攻略(原理 + 代码 + 选择指南)
人工智能·pytorch·python·深度学习·神经网络·机器学习
leobertlan9 小时前
好玩系列:用20元实现快乐保存器
android·人工智能·算法
笨笨饿9 小时前
#58_万能函数的构造方法:ReLU函数
数据结构·人工智能·stm32·单片机·硬件工程·学习方法
jr-create(•̀⌄•́)9 小时前
从零开始:手动实现神经网络识别手写数字(完整代码讲解)
人工智能·深度学习·神经网络
冬奇Lab9 小时前
一天一个开源项目(第78篇):MiroFish - 用群体智能引擎预测未来
人工智能·开源·资讯
冬奇Lab9 小时前
你的 Skill 真的好用吗?来自OpenAI的 Eval 系统化验证 Agent 技能方法论
人工智能·openai