一、项目概述

本文将通过一个完整的微博四分类情感分析项目,从零入门深度学习在自然语言处理(NLP)中的应用。项目使用 TextRNN(双向LSTM) 模型,对微博文本进行四种情感分类:喜悦、愤怒、厌恶、低落。

整个项目分为以下模块:

  1. 构建字符级词表
  2. 数据加载、预处理与批量迭代器
  3. TextRNN模型定义
  4. 训练、验证与测试逻辑
  5. 主程序入口,串联全流程

本篇为第一部分,重点讲解数据预处理相关的两个模块:词表构建与数据集加载。


二、数据集介绍

本项目使用的数据集为 simplifyweibo_4_moods.csv,是简化版的微博四情感分类数据集。每条数据包含两部分:

  • 情感标签:0 / 1 / 2 / 3,分别对应喜悦、愤怒、厌恶、低落
  • 微博文本:用户发布的微博内容

CSV 文件格式大致如下:

label,text
0,今天天气真好心情愉快
1,简直太让人生气了
2,这种行为真让人无语
3,感觉今天有点低落
...

三、词表构建(vocab_create.py)

在深度学习处理文本之前,需要将文本中的字符映射为数字 ID,这个映射关系就是词表(Vocabulary)。

3.1 核心思路

本项目采用字符级分词,即把每句话拆成单个汉字/符号,而不是按词拆分。这样做的好处是:

  • 不需要依赖中文分词工具(如 jieba)
  • 词表规模可控(几千个汉字即可覆盖大部分常用文本)
  • 不会因为分词错误影响后续模型效果

3.2 完整代码

"""
词表构建
"""
from tqdm import tqdm
import pickle as pkl

# 词表最大容量:最多保留4760个高频字符
MAX_VOCAB_SIZE = 4760
# 特殊标记:<UNK>代表未登录字符,<PAD>用于补齐文本长度
UNK, PAD = '<UNK>', '<PAD>'


def build_vocab(file_path, max_size, min_freq):
    """
    构建字符级词表
    :param file_path: csv数据集文件路径
    :param max_size: 词表最多保留多少个高频字符
    :param min_freq: 字符最低出现频次,低于该值会被丢弃
    :return: vocab_dic 字符->数字id的映射字典
    """
    # 分词器:字符级拆分,一句话拆成单个汉字/符号,例如"你好" -> ['你','好']
    tokenizer = lambda x: [y for y in x]

    # 第一步:统计每个字符出现的次数
    vocab_dic = {}

    with open(file_path, 'r', encoding='UTF-8') as f:
        i = 0
        # tqdm:添加读取进度条,方便查看文件读取进度
        for line in tqdm(f):
            # i==0 代表第一行,是csv表头,跳过不处理
            if i == 0:
                i += 1
                continue
            # line[2:]:舍弃每行前2个字符(情感标签+逗号),只取后面的微博文本
            # strip():去除字符串前后换行、空格
            lin = line[2:].strip()
            # 如果文本为空,跳过当前行
            if not lin:
                continue
            # 遍历文本里每一个字符,统计频次
            for word in tokenizer(lin):
                # get(word,0): 如果word不在字典,返回0;否则返回已有计数,然后+1
                vocab_dic[word] = vocab_dic.get(word, 0) + 1

        # 1.筛选:只保留出现次数 > min_freq 的字符
        # 2.按出现频次从高到低排序 reverse=True
        # 3.切片截取前max_size个高频字符
        vocab_list = sorted([_ for _ in vocab_dic.items() if _[1] > min_freq],
                             key=lambda x: x[1], reverse=True)[:max_size]

        # 构建 字符 -> id 的映射:高频字符依次分配id,从0开始
        vocab_dic = {word_count[0]: idx for idx, word_count in enumerate(vocab_list)}
        # 添加特殊符号:UNK和PAD,放在词表最后
        vocab_dic.update({UNK: len(vocab_dic), PAD: len(vocab_dic) + 1})

        print(f'Vocab size: {len(vocab_dic)}')
        # 使用pickle把词表字典保存到本地文件,后续训练可以直接加载
        pkl.dump(vocab_dic, open('simplifyweibo_4_moods.pkl', 'wb'))

    return vocab_dic


if __name__ == '__main__':
    # 执行构建词表:读取微博情感csv,最多保留4760个字符,过滤出现次数<=3的字符
    vocab = build_vocab('simplifyweibo_4_moods.csv', MAX_VOCAB_SIZE, 3)
    print('词表构建结束')

3.3 关键知识点详解

(1)字符级分词

tokenizer = lambda x: [y for y in x]

将字符串逐字拆分,例如 "你好" → ['你', '好']。这是一个 lambda 匿名函数,等价于:

def tokenizer(x):
    return [y for y in x]

(2)频次统计

vocab_dic[word] = vocab_dic.get(word, 0) + 1

使用字典统计每个字符的出现次数。dict.get(key, default) 方法在 key 不存在时返回默认值,非常适合计数场景,避免了 if word in vocab_dic 的判断。
执行逻辑: 先 get 取出 word 已有的计数(没有则取 0),再 +1 写回字典,从而实现累加。

(3)筛选与排序

vocab_list = sorted([_ for _ in vocab_dic.items() if _[1] > min_freq],
                     key=lambda x: x[1], reverse=True)[:max_size]

这一行完成了三件事:

  1. 筛选:只保留出现次数大于 min_freq(本项目为 3)的字符,过滤生僻字和噪声
  2. 排序:按频次从高到低排序(reverse=True)
  3. 截断:只取前 max_size(4760)个高频字符

为什么取 max_size = 4760 ?

词向量文件 embedding_Tencent.npz 总共有 4762 个向量。

  • 4760:留给真实词汇 / 字符
  • 剩下 2 个位置:专门预留给特殊 token:PAD(填充符)、UNK(未知词)
    总数量:4760 + 2 = 4762,刚好和腾讯预训练词向量矩阵维度对齐。

(4)特殊标记 <UNK> 和 <PAD>

vocab_dic.update({UNK: len(vocab_dic), PAD: len(vocab_dic) + 1})
  • <UNK>(Unknown):未登录词。当数据集中出现词表中没有的字符时,用 <UNK> 的 ID 代替,避免因生僻字导致报错。
  • <PAD>(Padding):填充标记。由于神经网络要求输入长度一致,短句子需要在末尾补 <PAD> 来统一长度。

这两个特殊标记被放在词表的最后两个位置。最终词表大小为 4760 + 2 = 4762。

(5)pickle 序列化

pkl.dump(vocab_dic, open('simplifyweibo_4_moods.pkl', 'wb'))

pickle 是 Python 的序列化模块,可以将 Python 对象(如字典)保存为二进制文件。后续通过 pkl.load() 直接加载,避免重复构建词表。

  • 'wb':以二进制写入模式打开文件
  • 对应的读取模式为 'rb'(二进制读取)

(6)tqdm 进度条

for line in tqdm(f):

tqdm 可以为循环添加进度条,在处理大规模数据时直观展示读取进度,是数据处理中的常用工具。


四、数据加载与预处理(load_dataset.py)

词表构建完成后,需要将原始文本转换为模型可接受的数字张量,并划分为训练集、验证集和测试集。

4.1 完整代码

"""
数据集处理
微博四分类情感数据集预处理
功能:读取csv文本,字符级分词、padding截断、词转id,划分训练/验证/测试集
"""
from tqdm import tqdm
import pickle as pkl
import random
import torch

# 定义特殊标记:未知字符、填充占位符
UNK, PAD = '<UNK>', '<PAD>'


def load_dataset(path, pad_size=70):
    """
    加载并预处理数据集
    :param path: csv数据集文件路径
    :param pad_size: 句子统一长度,超过截断,不足补PAD
    :return: vocab词典, train训练集, dev验证集, test测试集
             每条样本格式:(词id列表, 标签, 有效序列长度)
    """
    contents = []  # 存放全部预处理后的样本

    # 加载预保存的词表pkl文件,key为字符,value为对应的id
    vocab = pkl.load(open('simplifyweibo_4_moods.pkl', 'rb'))

    # 字符级分词器:按单个字切分句子(中文,字级别)
    tokenizer = lambda x: [y for y in x]

    with open(path, 'r', encoding='utf-8') as f:
        i = 0
        # tqdm:显示文件读取进度条
        for line in tqdm(f):
            # 跳过csv表头第一行
            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占位符
                    token.extend([PAD] * (pad_size - len(token)))
                else:
                    # 句子过长,只保留前pad_size个字
                    token = token[:pad_size]
                    seq_len = pad_size  # 截断后有效长度等于pad_size

            # 将每个字转为词典对应的id,不在词表就用UNK的id
            for word in token:
                words_line.append(vocab.get(word, vocab.get(UNK)))

            # 保存单条样本:(词id序列, 标签, 有效长度)
            contents.append((words_line, int(label), seq_len))

    # 打乱全部样本顺序,消除数据集顺序带来的偏差
    random.shuffle(contents)

    # 按8:1:1划分 训练集、验证集、测试集
    train_data = contents[: int(len(contents) * 0.8)]
    dev_data = contents[int(len(contents) * 0.8): int(len(contents) * 0.9)]
    test_data = contents[int(len(contents) * 0.9):]

    return vocab, train_data, dev_data, test_data

4.2 关键知识点详解

(1)Padding 与截断

神经网络的批量训练要求每个 batch 中的样本长度一致,因此需要统一序列长度:

  • 短句子:末尾补充 <PAD> 占位符
  • 长句子:截断保留前 pad_size 个字符
if len(token) < pad_size:
    token.extend([PAD] * (pad_size - len(token)))
else:
    token = token[:pad_size]
    seq_len = pad_size

本项目中 pad_size=70,即每条微博统一为 70 个字符的长度。微博文本通常较短,70 个字符可以覆盖大部分内容。

(2)字符转 ID

for word in token:
    words_line.append(vocab.get(word, vocab.get(UNK)))

遍历每个字符,从词表中查找对应的 ID。
vocab.get(word, vocab.get(UNK)) 的含义是:

  • 如果 word 在词表中,返回其对应的 ID
  • 如果不在,返回 <UNK> 对应的 ID(即 vocab.get(UNK))

这样即使遇到生僻字也不会报错,而是用统一的未知标记代替。

(3)数据集划分

random.shuffle(contents)
train_data = contents[: int(len(contents) * 0.8)]
dev_data = contents[int(len(contents) * 0.8): int(len(contents) * 0.9)]
test_data = contents[int(len(contents) * 0.9):]
  • 先 random.shuffle() 打乱数据,消除原始数据顺序带来的偏差(例如数据集中前半部分都是某一类别的情况)
  • 按 8:1:1 比例划分:
数据集比例用途
训练集(train)80%用于模型参数学习
验证集(dev)10%用于训练过程中调参和早停判断
测试集(test)10%用于最终模型性能评估

(4)样本格式

每条样本保存为三元组:

(words_line, label, seq_len)
# 例如:([23, 45, 67, ..., 4761, 4761], 0, 15)
  • words_line:长度为 70 的字符 ID 列表(不足部分用 PAD 的 ID 填充)
  • label:情感标签(0-3)
  • seq_len:原始句子的有效长度(未 padding 前的长度)

4.3 自定义数据迭代器(DatasetIterater)

PyTorch 训练时通常按 batch(批次)喂数据,这里自定义了一个迭代器来实现批量数据的封装。

class DatasetIterater(object):
    """将数据切分为batch_size的包"""
    def __init__(self, batches, batch_size, device):
        self.batch_size = batch_size
        self.batches = batches
        self.n_batches = len(batches) // batch_size
        self.residue = False  # 记录划分后的数据是否存在剩余的数据
        if len(batches) % self.n_batches != 0:
            self.residue = True
        self.index = 0  # 处理了多少个包数据
        self.device = device

    def _to_tensor(self, datas):
        """将数据转换为PyTorch张量"""
        x = torch.LongTensor([_[0] for _ in datas]).to(self.device)       # 评论内容id序列
        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):
        """返回下一个batch的数据"""
        if self.residue and self.index == self.n_batches:
            # 处理最后一个不完整的batch(剩余数据)
            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:
            # 所有batch读取完毕,重置索引并抛出停止迭代异常
            self.index = 0
            raise StopIteration
        else:
            # 正常读取一个完整的batch
            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

关键知识点详解

(1)Python 迭代器协议

自定义迭代器需要实现两个魔术方法:

  • __iter__():返回迭代器对象自身,使得对象可以被 for 循环遍历
  • __next__():返回下一个元素,遍历结束时抛出 StopIteration 异常

实现了这两个方法的对象可以直接用 for x in iterator 遍历,也可以用 next(iterator) 逐个获取。

(2)数据转张量

x = torch.LongTensor([_[0] for _ in datas]).to(self.device)
  • torch.LongTensor:创建 64 位整数张量。文本 ID 和标签都是整数,使用 LongTensor
  • .to(device):将张量移动到指定设备(CPU / GPU / MPS)

(3)返回格式

return (x, seq_len), y

返回一个嵌套元组:

  • 外层:(输入数据, 标签)
  • 输入数据:(文本id张量, 有效长度张量)

在模型的 forward 方法中,通过 x, _ = x 可以只提取文本 id 张量,忽略有效长度。

五、总结

本文完成了微博四分类情感分析项目的数据预处理模块,包含字符级词表构建、数据集加载与自定义批量迭代器三大部分。

  1. 词表构建:逐字符统计文本频次,过滤低频字符,保留前 4760 个高频汉字,再追加<UNK>未知字符、<PAD>填充符,最终词表总大小 4762,与腾讯预训练字符向量维度匹配;使用 pickle 持久化保存词表,避免重复统计。
  2. 数据集预处理:读取 CSV 微博文本,字符级拆分,统一序列长度(pad_size=70),长句截断、短句补 PAD;将字符转为词表 ID,未登录字符映射为<UNK>;打乱数据集后按 8:1:1 切分训练、验证、测试集,每条样本存储(id序列,标签,有效长度)三元组。
  3. 自定义 DatasetIterater 迭代器:实现 Python 迭代器协议,自动按 batch_size 分批,把列表数据转为 PyTorch LongTensor 并迁移至指定设备;支持处理最后不足一个 batch 的残余样本,方便后续 TextRNN 模型循环训练。

更多推荐