#从零到一搭建微博情感分类系统:基于BiLSTM的PyTorch完整实战教程【3W字保姆级详解】

从零到一搭建微博情感分类系统:基于BiLSTM的PyTorch完整实战教程【3W字保姆级详解】

前言

在自然语言处理(NLP)领域,情感分析是最具落地价值的任务之一。从电商平台的商品评论口碑监测,到社交平台的舆情趋势研判,再到智能客服的用户情绪识别,情感分类技术已经渗透到互联网产品的方方面面。

微博作为国内最具代表性的社交舆论场,每天产生海量的用户原创内容,这些短文本承载着用户最真实的情绪表达------喜悦、愤怒、厌恶、低落。如何让计算机自动识别微博文本背后的情感倾向,是NLP入门者必须掌握的经典实战项目。

本文将以4分类微博情感数据集为基础,基于PyTorch框架从零搭建一套完整的BiLSTM情感分类系统。我们会逐行拆解项目中7个核心文件的代码逻辑,从数据集探索、词表构建、数据加载、模型设计,到训练验证、测试评估、单句预测,覆盖项目全流程的每一个细节。

读完本文,你将收获:

  • 完整掌握字符级文本分类的全流程实现
  • 深入理解Embedding层、双向LSTM的工作原理
  • 学会自定义数据集迭代器、早停机制、模型保存加载
  • 能够独立复现整个项目,并完成单句情感预测的拓展功能
  • 积累一套可直接复用的PyTorch文本分类工程模板

本文所有代码均保持原始实现不变,仅做原理讲解与逻辑拆解,确保你可以对照源码逐行学习。


第一章 项目全局概览与环境准备

1.1 项目整体架构

本项目是一套标准的PyTorch文本分类工程,共包含7个核心Python文件、1份数据集、1份预训练词向量文件,分工明确,结构清晰:

文件名 核心功能
Data_presentation.py 数据集探索与可视化统计
vocab_create.py 构建字符级词表,生成词汇映射字典
load_dataset.py 文本转ID、Padding处理、数据集划分、自定义迭代器
TextRNN.py BiLSTM情感分类模型的网络结构定义
train_eval_test.py 训练、验证、测试三大核心逻辑实现
main.py 项目主入口,参数配置与全流程启动
ceshi.csv 简易测试样本,用于验证预测功能

整个项目的数据流走向为:

原始CSV数据集构建词表文本转数字ID + Padding划分训练/验证/测试集封装为迭代器输入BiLSTM模型前向传播计算损失反向传播更新参数验证集评估+早停测试集最终评测

1.2 开发环境配置

1.2.1 硬件与系统要求
  • 系统:Windows 10/11、Linux、macOS均可
  • 硬件:推荐带NVIDIA显卡(支持CUDA),CPU也可运行仅速度较慢
  • 内存:建议8GB以上,处理数据集与模型训练更流畅
1.2.2 软件依赖安装

本项目基于Python 3.x开发,需安装以下第三方库:

bash 复制代码
# 基础科学计算
pip install numpy pandas
# 深度学习框架
pip install torch torchvision
# 机器学习评估工具
pip install scikit-learn
# 进度条展示
pip install tqdm

若使用CUDA加速,请安装对应显卡驱动版本的PyTorch,可前往PyTorch官网获取安装命令。GPU训练速度通常是CPU的5-20倍,是深度学习的首选。

1.3 数据集简介

项目使用的是simplifyweibo_4_moods.csv微博情感数据集,共包含4类情感标签,标签与情感对应关系为:

  • 0 → 喜悦
  • 1 → 愤怒
  • 2 → 厌恶
  • 3 → 低落

每条数据由两部分组成:第一列为标签(0-3的数字),第二列为微博文本内容。数据集为字符级处理,无需分词,直接按汉字拆分即可。

为什么使用字符级而非词语级?对于中文短文本(如微博),字符级处理可以规避分词错误带来的误差,且词表规模更小,模型训练更快,非常适合入门级情感分类任务。


第二章 数据集探索与可视化分析

在正式建模之前,我们必须先对数据集有全面的认知:总样本量多少、各类别是否均衡、文本长度分布如何、样本内容有什么特点。Data_presentation.py就是完成这一工作的入口。

2.1 完整代码与逐行解读

python 复制代码
import pandas as pd
file_all = pd.read_csv('simplifyweibo_4_moods.csv')
moods = {0: '喜悦', 1: '愤怒', 2: '厌恶', 3: '低落'}
print('微博数目(总体):%d' % file_all.shape[0])
for label, mood in moods.items():
    print('微博数目({}):{}'.format(mood,  file_all[file_all.label==label].shape[0]))
print(file_all.sample(20))
逐行拆解:
  1. import pandas as pd

    导入Pandas库,这是Python数据分析的标配工具,擅长处理表格型数据,读取CSV文件仅需一行代码。

  2. file_all = pd.read_csv('simplifyweibo_4_moods.csv')

    读取CSV文件到内存,保存为DataFrame格式。file_all包含了所有样本的标签与文本内容,shape[0]代表行数(样本数),shape[1]代表列数。

  3. moods = {0: '喜悦', 1: '愤怒', 2: '厌恶', 3: '低落'}

    定义标签到情感名称的映射字典,方便输出时将数字标签转为可读的中文情感。

  4. print('微博数目(总体):%d' % file_all.shape[0])

    打印数据集总样本数量,让我们对数据规模有直观认知。

  5. 循环打印各类别样本数

    python 复制代码
    for label, mood in moods.items():
        print('微博数目({}):{}'.format(mood,  file_all[file_all.label==label].shape[0]))

    遍历4个情感类别,通过布尔索引筛选出对应标签的样本,统计每类的数量。这一步非常重要,可以判断数据集是否存在类别不平衡问题------如果某类样本远少于其他类,模型会倾向于预测多数类,导致少数类效果很差。

  6. print(file_all.sample(20))

    随机抽取20条样本打印输出,直观查看微博文本的内容风格、长度、表达习惯。sample()函数会随机打乱抽样,避免只看到文件开头的同类数据。

2.2 运行结果解读

运行该脚本后,你会看到类似如下输出:

复制代码
微博数目(总体):xxxx
微博数目(喜悦):xxxx
微博数目(愤怒):xxxx
微博数目(厌恶):xxxx
微博数目(低落):xxxx
      label                                             text
xxxx      0  今天天气真好,和朋友一起去公园玩太开心啦
xxxx      2  这种商家也太坑了吧,再也不来了
...

通过输出我们可以分析:

  • 数据规模:总样本量决定了模型的上限,样本越多通常效果越好,但训练时间也越长。
  • 类别均衡性:如果四类样本数量差距不大(比例接近1:1:1:1),则无需特殊处理;如果差距悬殊,则需要考虑过采样、欠采样或类别权重。
  • 文本特点:微博文本通常较短,包含网络用语、表情符号、标点符号,口语化强,和正式文本差异较大,这也是字符级处理更适合的原因之一。

2.3 拓展分析建议

原始代码只做了基础统计,实际项目中我们还可以补充:

  • 文本长度分布直方图,帮助确定合理的pad_size
  • 高频字统计,查看哪些字在不同情感中出现频率差异大
  • 空值、重复值检查,清洗脏数据

第三章 词表构建:从汉字到数字的映射

计算机无法直接识别文字,必须先将文本转为数字编号。词表(Vocabulary)就是"汉字→数字ID"的映射字典,是所有NLP任务的基础。vocab_create.py的核心任务就是基于语料库构建这份映射表。

3.1 核心概念铺垫

3.1.1 为什么需要词表

深度学习模型的输入必须是数值张量(Tensor),汉字本身无法参与矩阵运算。通过词表,我们将每个唯一的汉字映射为一个整数ID,比如"我"→12,"你"→34,后续再通过Embedding层将整数ID转为稠密向量。

3.1.2 特殊符号:UNK与PAD
  • UNK(Unknown):未知字符。词表不可能覆盖所有汉字,当遇到词表中没有的生僻字、特殊符号时,统一用UNK代替,对应的ID固定。
  • PAD(Padding):填充符号。模型要求同一个batch内的句子长度一致,但实际文本长短不一。短于规定长度的句子,在末尾用PAD填充,保证维度统一。
3.1.3 词频过滤与截断

语料库中会出现大量低频字(比如只出现1次的生僻字),这些字对模型贡献很小,还会增大词表规模、增加计算量。因此我们会设置:

  • min_freq:最低词频,出现次数低于该值的字直接丢弃
  • max_size:词表最大容量,只保留词频最高的前N个字

3.2 完整代码逐行深度解析

python 复制代码
from tqdm import tqdm   #python第3方,显示进度条
import pickle as pkl    #标准库,打包数据
MAX_VOCAB_SIZE = 4760  # 词表长度限制
UNK, PAD = '<UNK>', '<PAD>'  # 未知字,padding符号
导入与全局变量部分:
  1. from tqdm import tqdm

    tqdm是Python最常用的进度条库,包裹可迭代对象后,会在控制台显示循环进度、剩余时间、速度等信息,处理大规模数据时非常实用,避免程序"假死"的焦虑。

  2. import pickle as pkl

    pickle是Python标准库,用于序列化对象。可以把Python中的字典、列表等数据结构保存为二进制文件,下次直接加载即可,无需重新计算。这里用来保存构建好的词表字典。

  3. MAX_VOCAB_SIZE = 4760

    词表最大长度限制为4760。结合后面的代码,最终词表会加上UNK和PAD两个特殊符号,总长度为4762,PAD的索引就是4761,这和模型中padding_idx = n_vocab-1完全对应。

  4. UNK, PAD = '<UNK>', '<PAD>'

    定义两个特殊标记的字符串表示,后续会加入词表字典。


python 复制代码
def build_vocab(file_path, max_size, min_freq):
    '''功能:基于文本内容建立词表vocab,vocab中包含语料库中的字
        参数file_path:需要读取的语料库的路径
        max_size:     获取词频最高的前max_size个词.
        min_freq      剔除字频低于min_freq个的词'''
    tokenizer = lambda x: [y for y in x]    #def  简单的函数定义了一个函数tokenizer,功能为分字
    vocab_dic = {}  #用于保存词的字典  pandas
函数定义与初始化部分:
  1. 函数定义与文档字符串

    build_vocab函数接收三个参数:语料文件路径、词表最大尺寸、最低词频。文档字符串清晰说明了函数功能和参数含义,这是工程化代码的好习惯。

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

    这是一个匿名函数,作用是字符级分词 。输入一个字符串,输出每个字符组成的列表。例如输入"今天开心",输出['今','天','开','心']

    为什么用lambda而不用def?因为函数逻辑非常简单,一行即可完成,lambda写法更简洁。如果是更复杂的分词逻辑(比如结巴分词),就需要单独写函数。

  3. vocab_dic = {}

    初始化空字典,用于保存"字→出现次数"的映射。键是汉字,值是该字在语料库中出现的总次数。


python 复制代码
    with open(file_path, 'r', encoding='UTF-8') as f:
        i = 0       #
        for line in tqdm(f):#用来显示循环的进度条
            if i ==0:       #跳过文件种第1行表头内容
                i += 1
                continue
            lin = line[2:].strip()  #获取评论内容,剔除标签。不用split分割,因为评论内容中可能会存在逗号。
            if not lin:     #如果lin中没有内容则continue
                continue
            for word in tokenizer(lin): #[啊,呀,呀,。。。]
                vocab_dic[word] = vocab_dic.get(word, 0) + 1    #统计每个字出现的次数,并以字典保存
词频统计循环部分:

这是词表构建的核心循环,逐行读取文件,统计每个字的出现次数。

  1. with open(file_path, 'r', encoding='UTF-8') as f:

    以只读模式打开CSV文件,指定UTF-8编码。with语句会自动管理文件关闭,无需手动调用f.close(),是Python读取文件的标准写法。

  2. i = 0 + 跳过表头

    CSV文件第一行通常是表头(如label,text),不是真实数据,因此用计数器i判断,第一行直接跳过。

  3. for line in tqdm(f):

    逐行遍历文件。用tqdm()包裹文件对象,控制台会显示读取进度。对于几十万行的数据集,进度条能让我们清晰知道程序运行到哪一步。

  4. lin = line[2:].strip()

    这是非常巧妙的写法。因为每行数据格式为"标签,文本内容",标签是单个数字(0-3)加一个逗号,正好占2个字符位置。因此直接从索引2开始切片,就能拿到纯文本内容。

    • strip()用于去除字符串首尾的空白字符(换行符、空格、制表符等)
    • 为什么不用split(',')分割?因为微博文本中本身可能包含逗号,用split会把文本切散,导致内容缺失。直接切片是更稳妥的方式。
  5. if not lin: continue

    空行判断。如果该行去除空白后内容为空,直接跳过,不参与统计。

  6. 字符遍历与词频累加

    python 复制代码
    for word in tokenizer(lin):
        vocab_dic[word] = vocab_dic.get(word, 0) + 1

    先将句子拆分为单个字符,然后遍历每个字,更新词频字典。

    • vocab_dic.get(word, 0):字典的get方法,如果键存在则返回对应的值,不存在则返回默认值0。
    • 每遇到一个字,就将它的计数+1。遍历完整篇语料后,vocab_dic就保存了所有字的出现次数。

python 复制代码
        vocab_list = sorted([_ for _ in vocab_dic.items() if _[1] > min_freq], key=lambda x: x[1], reverse=True)[:max_size]
        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)}")
    return vocab_dic
词表生成与保存部分:
  1. 过滤+排序+截断

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

    这一行代码完成了三件事:

    • 过滤 :列表推导式筛选出词频大于min_freq的字,丢弃低频字
    • 排序 :用sorted()按词频从高到低排序(reverse=True降序)
    • 截断 :切片[:max_size]只保留前max_size个高频字

    最终vocab_list是一个列表,每个元素是(字, 词频)的元组,按词频降序排列。

  2. 生成"字→ID"映射字典

    python 复制代码
    vocab_dic = {word_count[0]: idx for idx, word_count in enumerate(vocab_list)}

    字典推导式,遍历排序后的列表,给每个字分配一个递增的整数ID。词频最高的字ID为0,次高的为1,以此类推。

    为什么高频字ID小?这是行业惯例,没有强制要求,但低ID在Embedding层中访问效率略高,且符合"常用字靠前"的直觉。

  3. 添加特殊符号

    python 复制代码
    vocab_dic.update({UNK: len(vocab_dic), PAD: len(vocab_dic) + 1})

    向字典中追加UNK和PAD两个特殊标记。

    • UNK的ID = 当前词表长度(因为索引从0开始,长度值正好是下一个可用ID)
    • PAD的ID = 当前词表长度 + 1
      例如普通字有4760个,UNK就是4760,PAD就是4761,和代码中padding_idx = n_vocab-1完全对应。
  4. 保存词表到文件

    python 复制代码
    pkl.dump(vocab_dic, open('simplifyweibo_4_moods.pkl', 'wb'))

    使用pickle将词表字典序列化为二进制文件。

    • 'wb'模式:二进制写入模式
    • 后续训练、预测时,直接加载这个pkl文件即可复用词表,无需重新统计构建。
  5. 打印词表大小并返回

    python 复制代码
    print(f"Vocab size: {len(vocab_dic)}")
    return vocab_dic

    输出最终词表总长度,方便核对。函数执行完毕后返回词表字典。


python 复制代码
if __name__ == "__main__":#当自己直接执行本文件代码,会运行main中的代码
    vocab = build_vocab('simplifyweibo_4_moods.csv',MAX_VOCAB_SIZE,3)
    print('vocab')
    #如果是调用本代码,则不会执行main中的代码
主程序入口:

if __name__ == "__main__":是Python的标准写法:

  • 当直接运行python vocab_create.py时,该代码块会执行
  • 当其他文件import vocab_create时,该代码块不会执行

这里调用build_vocab函数,传入数据集路径、最大词表长度4760、最低词频3。也就是说,在整个语料中出现少于3次的字会被直接丢弃,归为UNK处理。

3.3 词表构建的意义与思考

  1. 字符级vs词语级

    本项目用字符级处理,优势是词表小、无分词误差、实现简单;缺点是无法捕捉词语级语义。如果是更复杂的任务,通常会用分词工具(jieba)做词语级处理,或者直接用预训练模型(BERT)的分词器。

  2. min_freq设置的权衡

    设置太高会丢失很多信息,设置太低词表会膨胀。对于微博短文本,min_freq=3是比较合理的取值,既过滤了极低频的生僻字和乱码,又保留了绝大多数常用字。

  3. pickle文件的通用性

    生成的pkl词表文件是项目的重要资产,训练、验证、测试、预测都必须使用同一份词表,否则ID映射不一致,模型会完全失效。


第四章 数据集加载与迭代器设计

词表构建完成后,下一步是将全部文本转为数字ID,处理成长度一致的序列,划分为训练/验证/测试集,并封装为可迭代的批次数据供给模型训练。load_dataset.py承担了这一核心数据处理工作。

4.1 文本预处理的标准流程

在送入模型之前,文本必须完成以下几步转换:

  1. 分词:将句子拆分为字符列表
  2. 转ID:根据词表将每个字符转为整数ID
  3. 截断与填充:统一序列长度,长句截断,短句补PAD
  4. 记录长度:保存句子原始长度(部分模型会用到)
  5. 数据集划分:按比例切分为训练集、验证集、测试集
  6. 批次封装:将数据按batch_size打包,转为PyTorch张量

4.2 load_dataset函数逐行解析

python 复制代码
from tqdm import tqdm
import pickle as pkl
import random
import torch
UNK, PAD = '<UNK>', '<PAD>'  # 未知字,padding符号
导入与全局符号:

导入进度条、pickle、随机数、PyTorch库,重新定义UNK和PAD常量,保证模块独立性------即使单独运行该文件也不依赖其他文件。


python 复制代码
def load_dataset(path, pad_size=70):
    contents = []#用来存储转换为数值标号的句子
    vocab = pkl.load(open('simplifyweibo_4_moods.pkl', 'rb')) #读取vocab文件
    tokenizer = lambda x: [y for y in x]
函数初始化:
  1. def load_dataset(path, pad_size=70):

    函数接收数据集文件路径和填充长度pad_size,默认句子统一长度为70。这个值是根据数据集平均长度设定的,覆盖大部分微博的长度范围。

  2. contents = []

    空列表,用于保存处理好的所有样本,每个样本是(ID列表, 标签, 原始长度)的三元组。

  3. vocab = pkl.load(open('simplifyweibo_4_moods.pkl', 'rb'))

    加载第三章生成的词表字典。'rb'是二进制读取模式,pickle.load()会将二进制文件还原为Python字典。

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

    和词表构建时保持一致的字符级分词器,必须保证分词规则完全相同,否则ID映射会错位。


python 复制代码
    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')
逐行读取数据:

和词表构建时读取文件的逻辑一致:跳过表头、跳过空行、提取标签和文本。

  • label = int(line[0]):每行第一个字符就是标签,转为整数
  • content = line[2:].strip('\n'):从索引2开始取文本,去除末尾换行符

python 复制代码
            words_line = []
            token = tokenizer(content)  # 将每一行的内容进行分字
            seq_len = len(token)    #获取一行实际内容的长度
            if pad_size:#判断每条评论是否超过70个字
                if len(token) < pad_size:  # 如果一行的字少于70,则补充<PAD>
                    token.extend([PAD] * (pad_size - len(token)))
                else:  # 如果一行的字大于70,则只取前70个字
                    token = token[:pad_size]    #如果一条评论种的字大于或等于70个字,索引的切分
                    seq_len = pad_size      #当前评论的长度
长度标准化(Padding & Truncation):

这是文本预处理最关键的一步,将所有句子处理为相同长度。

  1. words_line = []:用于存放最终的数字ID列表
  2. token = tokenizer(content):将文本拆分为字符列表
  3. seq_len = len(token):记录句子原始长度,后续模型部分场景会用到
  4. if pad_size::如果设置了填充长度,则执行标准化
    • 短句填充token.extend([PAD] * (pad_size - len(token)))
      列表扩展,在句子末尾追加PAD符号,直到总长度等于pad_size。
      例如句子只有30个字,就补40个PAD,凑够70。
    • 长句截断token = token[:pad_size]
      只保留前pad_size个字符,后面的直接丢弃。同时将seq_len设为pad_size,因为超出部分被截断了。

为什么在末尾补PAD而不是开头?这是惯例。RNN是按顺序读取的,前面是真实内容,后面是填充,模型更容易学习。当然也有前补的做法,效果差异不大,保持一致即可。


python 复制代码
            # word to id
            for word in token:
                words_line.append(vocab.get(word, vocab.get(UNK)))
            contents.append((words_line, int(label), seq_len))
字符转ID:

遍历处理好的字符列表(包含PAD),通过词表转为数字ID:

  • vocab.get(word, vocab.get(UNK)):如果字在词表中就返回对应ID,不在就返回UNK的ID
  • 最终将(ID列表, 标签, 原始长度)三元组加入contents列表

python 复制代码
        random.shuffle(contents)#打乱顺序
        train_data = contents[ : int(len(contents)*0.8)]#前80%的评论数据作为训练集
        dev_data = contents[int(len(contents)*0.8) : int(len(contents)*0.9)]#把80%~90%的评论数据集作为验证数据
        test_data = contents[int(len(contents) * 0.9):]#90%~最后的数据作为测试数据集
    return vocab, train_data, dev_data, test_data
数据集划分:
  1. random.shuffle(contents)

    随机打乱所有样本顺序。原始数据可能是按类别排序的,不打乱的话训练集全是某一类,验证集全是另一类,模型根本无法训练。打乱是保证数据分布一致的必要操作。

  2. 8:1:1划分

    • 训练集(train):前80%,用于模型参数更新
    • 验证集(dev):中间10%,用于训练过程中评估效果、调参、早停
    • 测试集(test):最后10%,训练完全结束后才使用,模拟真实泛化效果

为什么需要三个集?训练集用来学参数,验证集用来调超参数和选最优模型,测试集用来做最终评测。如果只用训练集和测试集,会导致测试集信息泄露,结果偏高。

最后函数返回词表、三份数据集。

4.3 自定义迭代器DatasetIterater深度解析

PyTorch自带DataLoader,但本项目实现了一个轻量级的自定义迭代器,更适合入门学习,能让我们彻底理解批次数据是怎么生成的。

4.3.1 迭代器基础概念

在Python中,实现了__iter__()__next__()方法的对象就是迭代器,可以用for x in xxx的方式遍历。每次循环都会自动调用__next__()获取下一个元素,遍历完毕抛出StopIteration异常,for循环会自动捕获并结束。

自定义迭代器的优势:

  • 完全掌控数据生成逻辑
  • 按需生成批次,不用一次性把所有数据加载到GPU
  • 灵活处理特殊的数据格式

python 复制代码
class DatasetIterater(object):
    '''将数据batches切分为batch_size的包。'''
    def __init__(self, batches, batch_size,device):
        self.batch_size = batch_size
        self.batches = batches          #1285  128
        self.n_batches = len(batches) // batch_size     #数据划分batch的数量
        self.residue = False                #记录划分后的数据是否存在剩余的数据
        if len(batches) % self.n_batches != 0:#表示有余数
            self.residue = True
        self.index = 0
        self.device = device
迭代器初始化:
  1. __init__接收三个参数:

    • batches:全部样本列表(训练集/验证集/测试集)
    • batch_size:每个批次包含多少条样本,例如128
    • device:数据放到哪个设备(CPU/CUDA/MPS)
  2. 核心属性:

    • n_batches:完整批次的数量 = 总样本数 // batch_size(整数除法)
    • residue:布尔值,标记总样本数是否不能被batch_size整除,是否有剩余样本构成最后一个不完整批次
    • index:当前读取位置的游标,初始为0
    • device:设备信息,后续转tensor时使用

python 复制代码
    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)
        # pad前的长度(超过pad_size的设为pad_size)
        seq_len = torch.LongTensor([_[2] for _ in datas]).to(self.device)
        return (x, seq_len), y  # (([23,34,..,13],70),2)
数据转Tensor函数:

这是迭代器的核心辅助函数,将Python列表转为PyTorch张量,并移动到指定设备。

  1. x = torch.LongTensor([_[0] for _ in datas]).to(self.device)

    提取每个样本的第一个元素(文本ID列表),构成长整型张量。Embedding层要求输入必须是LongTensor类型。

    • 形状:[batch_size, pad_size],例如128, 70
  2. y = torch.LongTensor([_[1] for _ in datas]).to(self.device)

    提取每个样本的标签,构成长整型一维张量。

    • 形状:[batch_size]
  3. seq_len = torch.LongTensor([_[2] for _ in datas]).to(self.device)

    提取每个句子的原始长度。

    • 形状:[batch_size]
  4. 返回格式:(x, seq_len), y

    这是一个嵌套元组,第一部分是输入数据(文本ID+序列长度),第二部分是标签。这种格式和模型forward的入参完全对应。

为什么用LongTensor?因为Embedding层的输入是索引值,必须是整数类型。FloatTensor是浮点数,不能用来做索引。


python 复制代码
    def __next__(self):#用于定义迭代器对象的下一个元素。当一个对象实现了__next__方法时,它可以被用于创建迭代器对象。
        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)#转换数据类型tensor
            return batches
        elif self.index > self.n_batches:   #当读取完最后一个batch时:
            self.index = 0
            raise StopIteration     #为了防止迭代永远进行,我们可以使用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
__next__方法:获取下一个批次

这是迭代器最核心的方法,分三种情况处理:

  1. 情况一:读取最后一个不完整批次

    条件:self.residue and self.index == self.n_batches

    当存在剩余数据,且游标正好指向最后一个不完整批次时:

    • 切片从index * batch_size到列表末尾,取出剩余的所有样本
    • 游标+1
    • 转为tensor后返回
      这个批次的样本数会小于batch_size。
  2. 情况二:遍历结束

    条件:self.index > self.n_batches

    游标超过了总批次数量,说明所有数据都遍历完了:

    • 将游标重置为0(方便下一轮epoch从头开始)
    • 抛出StopIteration异常,通知for循环结束
  3. 情况三:正常完整批次

    其他情况都是正常的完整批次:

    • 切片取出当前批次的batch_size条样本
    • 游标+1
    • 转为tensor后返回

python 复制代码
    def __iter__(self):
        return self

    def __len__(self):
        if self.residue:
            return self.n_batches + 1
        else:
            return self.n_batches
iter__与__len
  1. __iter__:返回自身,这是迭代器的标准实现,让对象本身就是可迭代的。
  2. __len__:返回总批次数量,有剩余就+1,没有就返回n_batches。这样我们可以用len(train_iter)直接获取总批次数。

4.4 主函数测试

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('结束')

直接运行该文件会执行数据加载流程,打印三份数据集,验证数据处理是否正确。

4.5 关键思考与拓展

  1. 为什么要记录seq_len?

    本项目的模型其实没有用到seq_len,直接取了最后一个时刻的输出。但在更规范的LSTM实现中,会配合pack_padded_sequence使用,让模型忽略PAD部分的计算,提升准确率和速度。保留seq_len是良好的工程习惯,方便后续优化。

  2. shuffle的时机

    本项目只在数据集划分前打乱一次。标准做法是每个epoch都重新打乱一次,让每个epoch的批次组成都不同,模型泛化性更好。可以在迭代器的__iter__方法中加入shuffle逻辑。

  3. 多线程与加速

    PyTorch的DataLoader支持多线程预读取,训练速度更快。自定义迭代器是单线程的,适合学习原理,工业级项目推荐使用DataLoader。


第五章 BiLSTM情感分类模型详解

数据准备就绪后,就进入最核心的模型设计环节。本项目使用双向三层LSTM 作为特征提取器,配合Embedding层和全连接层,完成4分类情感识别。TextRNN.py定义了完整的网络结构。

5.1 从RNN到LSTM的演进

5.1.1 为什么不用普通RNN

循环神经网络(RNN)是处理序列数据的经典结构,它逐个读取输入,每个时刻的隐藏状态由当前输入和上一时刻的隐藏状态共同决定,理论上可以捕捉序列的上下文信息。

但普通RNN有致命缺陷:长序列梯度消失。当句子很长时,前面的信息在传递过程中会指数级衰减,到最后几乎丢失殆尽。对于情感分类任务,可能句子开头的关键词才是情感核心,普通RNN根本记不住。

5.1.2 LSTM的门控机制

长短期记忆网络(LSTM)通过三个门控结构解决了长依赖问题:

  • 遗忘门:决定上一时刻的细胞状态要丢弃哪些信息
  • 输入门:决定当前输入有哪些信息要存入细胞状态
  • 输出门:决定细胞状态中有多少输出到隐藏状态

细胞状态(Cell State)像一条传送带,信息在上面流动,只有少量的线性交互,梯度可以顺畅传递很远,从而记住长距离的依赖关系。

5.1.3 双向LSTM(BiLSTM)

单向LSTM只能从左到右读取信息,每个时刻只知道"上文"不知道"下文"。双向LSTM同时运行两个LSTM:

  • 正向LSTM:从左到右读,捕捉上文信息
  • 反向LSTM:从右到左读,捕捉下文信息

两个方向的隐藏状态拼接在一起,作为最终的输出。这样每个位置都同时拥有了完整的上下文信息,对情感分类等任务提升非常明显。

5.2 模型代码逐行深度拆解

python 复制代码
import torch.nn as nn
class Model(nn.Module):#名字随意定义
    def __init__(self, embedding_pretrained,n_vocab,embed,num_classes):
        super(Model, self).__init__()
导入与类定义:
  1. import torch.nn as nn

    nn是PyTorch的神经网络模块,包含了所有常用的层(Embedding、LSTM、Linear等)和损失函数,是构建模型的基础。

  2. class Model(nn.Module):

    自定义模型类继承自nn.Module,这是PyTorch所有模型的基类。继承后就拥有了参数管理、设备迁移、保存加载等能力。

  3. __init__构造函数参数:

    • embedding_pretrained:预训练词向量,None表示不使用预训练
    • n_vocab:词表大小
    • embed:词向量维度
    • num_classes:分类类别数(这里是4)
  4. super(Model, self).__init__()

    调用父类的构造函数,这是继承nn.Module的必填操作,必须写在第一行。


python 复制代码
        if embedding_pretrained is not None:
            self.embedding = nn.Embedding.from_pretrained(embedding_pretrained, padding_idx = n_vocab-1, freeze=False)
        else:
            self.embedding = nn.Embedding(n_vocab, embed, padding_idx = n_vocab - 1)
Embedding层:从ID到稠密向量

这是模型的第一层,负责将整数ID转为低维稠密向量。分两种情况初始化:

情况1:使用预训练词向量

nn.Embedding.from_pretrained(embedding_pretrained, padding_idx = n_vocab-1, freeze=False)

  • from_pretrained:用预训练好的向量矩阵初始化Embedding层
  • padding_idx = n_vocab-1:指定填充符的索引。PAD对应的ID是n_vocab-1(4761),该位置的向量永远是0,且不会更新梯度,保证填充不影响语义
  • freeze=False:是否冻结Embedding层。False表示预训练向量会随着训练继续微调,通常效果更好;True则固定不动,适合小数据集防止过拟合
情况2:随机初始化

nn.Embedding(n_vocab, embed, padding_idx = n_vocab - 1)

  • 随机初始化一个形状为[n_vocab, embed]的嵌入矩阵
  • 每个字的向量初始为随机值,会在训练过程中逐步学习优化
  • 同样指定padding_idx,保证PAD位置梯度为0
Embedding层输出形状

输入形状:[batch_size, seq_len](例如128, 70

输出形状:[batch_size, seq_len, embed](例如128, 70, 200

每个整数ID都被替换成了一个embed维的向量,一句话就变成了一个二维矩阵。


python 复制代码
        self.lstm = nn.LSTM(embed, 128, 3, bidirectional=True, batch_first=True, dropout=0.3)
LSTM层:序列特征提取

这是模型的核心层,负责从文本序列中提取语义特征。我们来逐个解释参数:

  1. embed:输入特征维度,也就是词向量的维度,和Embedding层输出对应。
  2. 128:隐藏层维度(hidden_size)。每个方向、每一层的LSTM,隐藏状态和细胞状态的维度都是128。
  3. 3:LSTM的层数(num_layers)。堆叠3层LSTM,上层LSTM接收下层的输出作为输入,逐层提取更抽象的特征。深层LSTM的表达能力更强,但也更容易过拟合、更难训练。
  4. bidirectional=True:启用双向LSTM。开启后,输出的特征维度会自动翻倍(正向128 + 反向128 = 256)。
  5. batch_first=True:输入输出的第一维是batch。
    • 默认False:形状为[seq_len, batch_size, feature]
    • 设置True:形状为[batch_size, seq_len, feature]
      我们的数据组织方式是batch在前,因此设为True更方便,不用频繁转置。
  6. dropout=0.3:dropout概率。在多层LSTM之间加入dropout,每一层的输出有30%的概率被随机置0,防止过拟合。注意:dropout只在多层之间生效,最后一层输出不会加dropout。
LSTM的输出

nn.LSTM的前向传播会返回两个值:

  • 第一个:所有时刻的隐藏状态输出,形状[batch, seq_len, hidden_size * num_directions]
    双向的话就是[batch, 70, 256],每个时刻都有一个256维的向量
  • 第二个:元组(h_n, c_n),最后时刻的隐藏状态和细胞状态
    形状为[num_layers * num_directions, batch, hidden_size]

代码中out, _ = self.lstm(out)用下划线忽略了第二个返回值,只保留了所有时刻的输出。


python 复制代码
        self.fc = nn.Linear(128 *2, num_classes)
全连接层:分类输出

全连接层(线性层)负责将LSTM提取的特征映射到4个类别上。

  • 输入维度:128 * 2 = 256,双向LSTM的输出维度
  • 输出维度:num_classes = 4,对应4类情感
  • 这一层不包含激活函数,因为后续交叉熵损失函数内部会自动做Softmax

python 复制代码
    def forward(self, x):   #([23,34,..,13],69)
        x, _ = x    #就是只提取评论的独热编码
        out = self.embedding(x)  #
        out, _ = self.lstm(out)
        out = self.fc(out[:, -1, :])  # 句子最后时刻的 hidden state
        return out
forward方法:前向传播逻辑

forward定义了数据从输入到输出的完整计算流,是模型的灵魂。

  1. def forward(self, x):

    输入x是迭代器返回的(x, seq_len)元组,包含文本ID张量和序列长度张量。

  2. x, _ = x

    解包元组,只取第一个元素(文本ID),忽略seq_len。

    如前所述,这里没有使用序列长度信息,是简化实现。进阶优化可以加入pack_padded_sequence。

  3. out = self.embedding(x)

    经过Embedding层,整数ID转为词向量。

    形状变化:[128, 70][128, 70, 200]

  4. out, _ = self.lstm(out)

    经过三层双向LSTM,提取序列特征。

    形状变化:[128, 70, 200][128, 70, 256]

    下划线忽略了(h_n, c_n)。

  5. out = self.fc(out[:, -1, :])

    最后一个时刻的隐藏状态,送入全连接层得到分类结果。

    • out[:, -1, :]的含义:
      • 第一个维度::所有batch样本都取
      • 第二个维度-1:取序列的最后一个位置
      • 第三个维度::所有特征维度都保留
    • 为什么取最后一个时刻?因为LSTM是顺序读取的,理论上最后一个时刻已经读完了整句话,包含了全文的语义信息。
    • 形状变化:[128, 70, 256][128, 256][128, 4]
  6. return out

    返回最终的分类logits,形状为[batch_size, num_classes]。每个样本对应4个数值,数值越大代表模型认为属于该类的概率越高。

注意:这里输出的不是概率,是logits(原始分值)。计算损失时交叉熵函数会自动做Softmax转概率。

5.3 模型参数量与复杂度分析

  • Embedding层:词表大小 × 词向量维度 ≈ 4762 × 200 ≈ 95万参数
  • LSTM层:三层双向,参数量较大,约几十万
  • 全连接层:256 × 4 = 1024参数
    总参数量约百万级,属于轻量级模型,CPU也能较快训练。

5.4 模型设计的思考与优化点

  1. 为什么用最后时刻输出而不是平均池化?

    取最后时刻是RNN分类的经典做法,简单高效。但对于双向LSTM,其实正向的最后时刻和反向的最后时刻位置相反,严格来说应该拼接正向最后一个和反向第一个。简化实现中直接取最后一个位置,效果也能接受。

    进阶优化可以用:平均池化、最大池化、注意力机制,效果通常会更好。

  2. 三层LSTM是否必要?

    对于简单的4分类情感任务,1-2层LSTM通常就足够了。层数太多容易过拟合,且训练速度慢。可以作为超参数调优。

  3. 隐藏层维度128合理吗?

    隐藏层维度决定了模型的表达能力。维度越大能力越强,但越容易过拟合、计算越慢。128是比较均衡的取值,小数据集也可以用64,大数据集可以用256。


第六章 训练、验证与测试全流程实现

模型结构定义好之后,就需要编写训练逻辑。train_eval_test.py包含了三个核心函数:evaluate(验证/评估)、test(最终测试)、train(训练主循环),完整实现了深度学习训练的标准流程。

6.1 训练的基本流程

一个完整的训练epoch包含以下步骤:

  1. 模型切换为训练模式
  2. 遍历训练集迭代器,逐批次读取数据
  3. 前向传播:数据输入模型,得到预测输出
  4. 计算损失:用损失函数对比预测和真实标签
  5. 梯度清零:清空上一轮的梯度
  6. 反向传播:根据损失计算参数梯度
  7. 参数更新:优化器根据梯度更新模型权重
  8. 定期在验证集上评估,保存最优模型
  9. 若长期没有提升,触发早停结束训练

6.2 evaluate函数:验证集评估

python 复制代码
def evaluate(class_list, model, data_iter, test=False):
    model.eval()
    loss_total = 0
    predict_all = np.array([], dtype=int)
    labels_all = np.array([], dtype=int)
初始化部分:
  1. model.eval()

    将模型切换为评估模式。这一步非常重要!在评估模式下:

    • Dropout层会失效,不再随机失活
    • BatchNorm层会使用累计的均值方差,而不是当前batch的
      评估时如果不切换模式,结果会有偏差,甚至完全错误。
  2. loss_total = 0

    累计总损失,最后计算平均损失。

  3. predict_alllabels_all

    两个空的numpy数组,用于收集所有批次的预测结果和真实标签,最后统一计算准确率等指标。


python 复制代码
    with torch.no_grad(): #一个上下文管理器,关闭梯度计算。当你确认不会调用Tensor.backward()的时候。这可以减少计算所用内存消耗。
        for texts, labels in data_iter:
            outputs = model(texts)#它就是输出。
            loss = F.cross_entropy(outputs, labels)
            loss_total += loss
            labels = labels.data.cpu().numpy()
            predic = torch.max(outputs.data, 1)[1].cpu().numpy()
            labels_all = np.append(labels_all, labels)
            predict_all = np.append(predict_all, predic)
无梯度遍历评估:
  1. with torch.no_grad():

    关闭梯度计算的上下文管理器。在这个缩进内,所有张量的计算都不会记录计算图,不保存梯度。

    • 评估阶段不需要反向传播,不需要梯度
    • 关闭梯度可以大幅节省显存,加快计算速度
      这是评估代码的标配,绝对不能省略。
  2. 遍历数据迭代器

    for texts, labels in data_iter:

    逐批次读取数据,texts是输入,labels是真实标签。

  3. 前向传播

    outputs = model(texts)

    将输入送入模型,得到预测输出。

  4. 计算损失

    loss = F.cross_entropy(outputs, labels)

    用交叉熵损失函数计算预测值和真实值的差距。

    • F.cross_entropy是函数式调用,会自动做Softmax+计算交叉熵
    • 输入outputs是logits,不需要提前做Softmax
    • 返回的是该batch的平均损失
  5. 累加损失

    loss_total += loss

    将每个batch的损失累加,最后除以batch总数得到平均损失。

  6. 标签转numpy

    labels = labels.data.cpu().numpy()

    将GPU上的标签张量移回CPU,转为numpy数组,方便后续sklearn计算指标。

  7. 获取预测类别

    predic = torch.max(outputs.data, 1)[1].cpu().numpy()

    • torch.max(outputs.data, 1):在第1维(类别维度)上取最大值,返回(最大值, 最大值索引)的元组
    • [1]:取索引,也就是预测的类别ID
    • 同样移到CPU转numpy
  8. 追加到总数组

    np.append()将当前batch的标签和预测追加到总数组中。全部批次遍历完后,就得到了整个数据集的预测结果。


python 复制代码
    acc = metrics.accuracy_score(labels_all, predict_all)
    if test:
        report = metrics.classification_report(labels_all, predict_all, target_names=class_list, digits=4)
        return acc, loss_total / len(data_iter), report
    return acc, loss_total / len(data_iter)
计算指标并返回:
  1. acc = metrics.accuracy_score(labels_all, predict_all)

    计算准确率。准确率 = 预测正确的样本数 / 总样本数,是最直观的评估指标。

  2. 测试模式下输出详细报告

    如果test=True(测试集评估),则生成完整的分类报告:

    • classification_report会输出每个类别的精确率、召回率、F1值、支持数
    • target_names=class_list:用中文情感名称作为类别名
    • digits=4:保留4位小数,结果更精确
  3. 返回值

    • 普通验证模式:返回(准确率, 平均损失)
    • 测试模式:返回(准确率, 平均损失, 分类报告)

6.3 test函数:最终测试

python 复制代码
def test(model, test_iter , class_list):
    model.eval()
    start_time = time.time()
    test_acc, test_loss, test_report = evaluate(class_list, model, test_iter, test=True)
    msg = 'Test Loss: {0:>5.2},  Test Acc: {1:>6.2%}'
    print(msg.format(test_loss, test_acc))
    print(test_report)

测试函数非常简洁,就是调用evaluate并打印完整结果:

  1. 模型设为评估模式
  2. 记录开始时间
  3. 调用evaluate,test=True获取完整报告
  4. 格式化打印测试损失和准确率
  5. 打印详细分类报告

测试集只能在全部训练结束后运行一次!多次用测试集调参等于信息泄露,结果失去参考价值。

6.4 train函数:训练主循环

这是整个项目最复杂、最核心的函数,包含了完整的训练流程、验证、早停、模型保存。

python 复制代码
def train(model, train_iter, dev_iter, test_iter,class_list):
    model.train()       #进入训练模式,允许model训练的权限,w
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
    total_batch = 0                     # 记录进行到多少batch
    dev_best_loss = float('inf')        #表示无穷大
    last_improve = 0                    # 记录上次验证集loss下降的batch数
    flag = False                        # 记录是否很久没有效果提升
    epochs = 2                         #设置训练次数
初始化部分:
  1. model.train()

    切换为训练模式,启用Dropout、BatchNorm等训练专属机制。和eval()相对,训练前必须调用。

  2. optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

    定义优化器,这里用Adam优化器:

    • model.parameters():获取模型所有可训练参数,交给优化器管理
    • lr=1e-3:学习率设为0.001,Adam的默认学习率,是比较稳妥的初始值
      Adam自适应学习率优化器是目前最常用的优化器,收敛快、效果稳,对初学者友好。
  3. total_batch = 0

    全局batch计数器,记录从训练开始到现在总共处理了多少个batch。

  4. dev_best_loss = float('inf')

    验证集最佳损失,初始化为正无穷。只要验证损失比这个值小,就说明模型进步了,保存新的最优模型。

  5. last_improve = 0

    记录上一次验证损失下降时的batch编号。用于早停判断:如果过了很多batch损失还没下降,就停止训练。

  6. flag = False

    早停标志位,为True时表示触发早停,结束训练。

  7. epochs = 2

    最大训练轮数。2轮表示完整遍历训练集2次。对于大数据集可能需要更多epoch,小数据集2轮也能看到效果。


python 复制代码
    for epoch in range(epochs):     #训练次数
        print('Epoch [{}/{}]'.format(epoch + 1, epochs))
        for i, (trains, labels) in enumerate(train_iter):
            outputs = model(trains)
            loss = F.cross_entropy(outputs, labels)
            model.zero_grad()
            loss.backward()
            optimizer.step()
内层训练循环:

外层循环遍历epoch,内层循环遍历每个batch。

  1. 打印当前epoch

    print('Epoch [{}/{}]'.format(epoch + 1, epochs))

    直观显示训练到第几轮。

  2. 遍历训练迭代器

    for i, (trains, labels) in enumerate(train_iter):

    逐批次读取训练数据,i是当前epoch内的batch序号。

  3. 前向传播

    outputs = model(trains)

    输入数据,得到预测结果。

  4. 计算损失

    loss = F.cross_entropy(outputs, labels)

    计算交叉熵损失。

  5. 梯度清零

    model.zero_grad()

    将模型所有参数的梯度清零。PyTorch默认会累加梯度,不清零的话梯度会越积越大,这一步必不可少。

  6. 反向传播

    loss.backward()

    反向传播,从损失开始沿计算图反向计算每个参数的梯度。这是深度学习最核心的一步,所有参数的梯度值就是在这一步算出来的。

  7. 参数更新

    optimizer.step()

    优化器根据梯度和学习率更新模型的所有参数。一步梯度下降完成。


python 复制代码
            if total_batch % 100 == 0:  # 每多少轮输出在训练集和验证集上的效果
                predic = torch.max(outputs.data, 1)[1].cpu()
                train_acc = metrics.accuracy_score(labels.data.cpu(), predic)
                dev_acc, dev_loss = evaluate(class_list, model, dev_iter)
定期评估逻辑:

每处理100个batch,就做一次全面评估,打印训练集和验证集的指标。

  1. 条件判断

    if total_batch % 100 == 0:

    每100个batch评估一次。频率太高会拖慢训练,太低看不到中间过程。100是比较均衡的取值。

  2. 计算训练集准确率

    • 取预测类别,移到CPU
    • 用sklearn计算当前batch的训练准确率
      注意:这里只算了当前batch的准确率,不是整个训练集的,作为参考即可。
  3. 验证集评估

    调用evaluate函数,在整个验证集上跑一遍,得到验证集准确率和平均损失。


python 复制代码
                if dev_loss < dev_best_loss:
                    dev_best_loss = dev_loss       #保存最优模型
                    torch.save(model.state_dict(), 'TextRNN.ckpt')
                    last_improve = total_batch #保存最优模型的batch值
保存最优模型:

这是早停机制的核心部分。

  1. 判断验证损失是否创新低
    如果当前验证损失小于历史最佳,说明模型效果提升了。
  2. 更新最佳损失记录
  3. 保存模型参数
    torch.save(model.state_dict(), 'TextRNN.ckpt')
    • model.state_dict():获取模型所有参数的字典
    • 保存为TextRNN.ckpt文件,ckpt是checkpoint的缩写
      我们只保存参数,不保存整个模型结构,文件更小,加载更灵活。
  4. 记录本次提升的batch位置
    last_improve = total_batch

为什么保存验证集损失最低的模型,而不是最后一轮的模型?因为训练后期模型可能会过拟合,验证损失上升但训练损失继续下降。验证集表现最好的模型,泛化能力通常最强。


python 复制代码
                msg = 'Iter: {0:>6},  Train Loss: {1:>5.2},  Train Acc: {2:>6.2%},  Val Loss: {3:>5.2},  Val Acc: {4:>6.2%}'
                print(msg.format(total_batch, loss.item(), train_acc, dev_loss, dev_acc))
                model.train()
打印日志并切回训练模式:
  1. 格式化打印指标

    输出迭代次数、训练损失、训练准确率、验证损失、验证准确率。格式化字符串用右对齐,输出整齐美观。

  2. model.train()

    切回训练模式。因为evaluate里调用了model.eval(),评估完必须重新切回训练模式,否则后面的训练batch会一直跑在eval模式下,dropout不生效,模型容易过拟合。


python 复制代码
            total_batch += 1
            if total_batch - last_improve > 10000:
                print("No optimization for a long time, auto-stopping...")
                flag = True
        if flag:
            break
早停判断:
  1. total_batch += 1

    全局batch计数器自增。

  2. 早停条件判断

    if total_batch - last_improve > 10000:

    如果连续10000个batch,验证集损失都没有下降过,说明模型已经无法再提升,继续训练只会浪费时间甚至过拟合。

    • 打印早停提示
    • 设置flag=True
  3. 跳出epoch循环

    内层循环结束后检查flag,如果为True就break跳出外层epoch循环,训练提前结束。


python 复制代码
    test(model, test_iter,class_list)
最终测试:

训练全部结束后(正常结束或早停),调用test函数,在测试集上做最终评测,输出完整的分类报告。

注意:这里测试时用的是最后一轮的模型参数,而不是保存的最优模型。规范的做法应该是加载TextRNN.ckpt最优参数再测试。代码注释里也提到了加载模型的语句,取消注释即可。

6.5 训练流程中的关键细节

  1. 为什么每100batch验证一次,而不是每个epoch一次?

    大数据集一个epoch可能有几千个batch,等一整轮太久了。定期验证可以更早发现最优模型、更早触发早停。

  2. 早停阈值10000的设置

    这个值要根据总batch数调整。总batch少的话阈值设小一点,总batch多就设大一点。本质是控制"耐心值",即愿意容忍多少轮没有提升。

  3. loss.item()的作用

    loss是张量,.item()可以取出里面的Python标量值。打印日志时用item可以节省显存,避免张量积累。


第七章 主程序入口与完整运行流程

main.py是项目的总入口,负责配置参数、加载数据、初始化模型、启动训练。把前面所有模块串联起来,一键启动整个训练流程。

7.1 完整代码逐行解析

python 复制代码
import torch
import numpy as np
import load_dataset,TextRNN
from train_eval_test import train
导入模块:
  • torch、numpy:基础依赖
  • load_dataset:数据加载与迭代器
  • TextRNN:模型定义
  • train_eval_test中的train函数:训练主函数

python 复制代码
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
自动选择计算设备:

这是非常优雅的写法,自动适配不同硬件:

  1. 优先检测CUDA(NVIDIA显卡),有则用cuda
  2. 没有CUDA则检测MPS(苹果硅芯片的GPU加速),有则用mps
  3. 都没有就回退到CPU

这样同一份代码在Windows、Linux、Mac上都能自动使用最优硬件,无需手动修改。


python 复制代码
np.random.seed(1)
torch.manual_seed(1)
torch.cuda.manual_seed_all(1)
torch.backends.cudnn.deterministic = True
设置随机种子,保证可复现:

深度学习中有很多随机因素(参数初始化、数据打乱、dropout等),如果不固定随机种子,每次运行结果都会不一样,不利于调试验证。

这四行分别固定了:

  1. numpy的随机种子
  2. PyTorch CPU的随机种子
  3. PyTorch所有GPU的随机种子
  4. cudnn使用确定性算法,保证卷积操作结果一致

完全可复现是理想状态,不同PyTorch版本、不同硬件之间仍可能有微小差异,但整体结果会非常接近。


python 复制代码
vocab, train_data, dev_data, test_data = load_dataset.load_dataset('simplifyweibo_4_moods.csv')
train_iter = load_dataset.DatasetIterater(train_data, 128,device)
dev_iter = load_dataset.DatasetIterater(dev_data, 128,device)
test_iter = load_dataset.DatasetIterater(test_data, 128,device)
加载数据并封装迭代器:
  1. 调用load_dataset函数,得到词表和三份数据集
  2. 分别为训练集、验证集、测试集创建DatasetIterater迭代器
    • batch_size统一设为128
    • 数据都放到前面选定的device上

batch_size=128是比较常用的取值:

  • 太小:训练慢,梯度噪声大
  • 太大:显存占用高,可能溢出,泛化效果未必更好

python 复制代码
embedding_pretrained = torch.tensor(np.load('embedding_Tencent.npz')["embeddings"].astype('float32'))
# embedding_pretrained = None       #不使用外部训练的词向量
embed = embedding_pretrained.size(1) if embedding_pretrained is not None else 200
预训练词向量配置:
  1. 加载腾讯预训练词向量

    从npz文件中读取embeddings数组,转为float32类型的PyTorch张量。

    预训练词向量是在大规模语料上提前训练好的,包含丰富的语义知识。用它初始化Embedding层,可以让模型起点更高,收敛更快,小数据集上效果提升尤其明显。

  2. 注释掉的None选项

    如果没有预训练词向量,就设为None,模型会随机初始化Embedding层。

  3. 确定词向量维度

    • 如果用预训练,维度就取预训练向量的第二维大小
    • 如果不用预训练,就手动设为200维

python 复制代码
class_list = ['喜悦','愤怒', '厌恶', '低落']
num_classes = len(class_list)
model = TextRNN.Model(embedding_pretrained,len(vocab),embed,num_classes).to(device)
train(model, train_iter, dev_iter, test_iter,class_list)
初始化模型并启动训练:
  1. 定义类别名称列表,4类情感
  2. 类别数=列表长度
  3. 实例化BiLSTM模型,传入所有参数,并移动到指定设备
  4. 调用train函数,开始训练

7.2 运行步骤与预期输出

运行步骤:
  1. 先运行vocab_create.py生成词表pkl文件
  2. 确保预训练词向量文件在目录下(或改用随机初始化)
  3. 运行main.py开始训练
预期输出日志:
复制代码
Epoch [1/2]
Iter:      0,  Train Loss:  1.4,  Train Acc: 25.00%,  Val Loss:  1.4,  Val Acc: 26.32%
Iter:    100,  Train Loss:  0.9,  Train Acc: 62.50%,  Val Loss:  0.9,  Val Acc: 61.45%
Iter:    200,  Train Loss:  0.7,  Train Acc: 74.22%,  Val Loss:  0.7,  Val Acc: 72.18%
...
Test Loss:  0.6,  Test Acc: 75.32%
              precision    recall  f1-score   support

          喜悦     0.7821    0.8012    0.7915      xxxx
          愤怒     0.7234    0.6897    0.7061      xxxx
          厌恶     0.7109    0.7345    0.7225      xxxx
          低落     0.7654    0.7489    0.7571      xxxx

    accuracy                         0.7532      xxxx
   macro avg     0.7455    0.7436    0.7443      xxxx
weighted avg     0.7528    0.7532    0.7527      xxxx

训练过程中损失逐渐下降,准确率逐渐上升,最终测试集准确率通常能达到70%-80%区间,对于轻量级BiLSTM和字符级处理来说,这个结果是合理的。


第八章 实战拓展:单句情感预测功能实现

源码的作业部分要求实现"输入一句话,得到最终的情感结果"。这是模型落地的关键一步------训练好的模型最终就是要用来预测新数据的。

8.1 预测功能的实现思路

单句预测的完整流程:

  1. 加载训练好的最优模型参数
  2. 加载词表字典
  3. 对输入句子做和训练时完全一致的预处理:字符拆分、转ID、Padding
  4. 转为Tensor,送入模型
  5. 前向传播得到logits,取最大概率对应的类别
  6. 将数字标签映射为中文情感名称返回

8.2 完整预测代码实现

我们可以新建一个predict.py文件,实现单句预测功能:

python 复制代码
import torch
import pickle as pkl
from TextRNN import Model

# 配置参数
PAD = '<PAD>'
UNK = '<UNK>'
pad_size = 70
class_list = ['喜悦', '愤怒', '厌恶', '低落']
vocab_path = 'simplifyweibo_4_moods.pkl'
model_path = 'TextRNN.ckpt'
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"

# 加载词表
vocab = pkl.load(open(vocab_path, 'rb'))
n_vocab = len(vocab)
embed = 200  # 和训练时保持一致,若用预训练则对应修改
num_classes = 4

# 初始化模型并加载参数
model = Model(None, n_vocab, embed, num_classes).to(device)
model.load_state_dict(torch.load(model_path, map_location=device))
model.eval()

def predict_sentence(sentence):
    # 字符级分词
    tokenizer = lambda x: [y for y in x]
    token = tokenizer(sentence)
    seq_len = len(token)
    
    # Padding处理
    if len(token) < pad_size:
        token.extend([PAD] * (pad_size - len(token)))
    else:
        token = token[:pad_size]
    
    # 转ID
    words_line = []
    for word in token:
        words_line.append(vocab.get(word, vocab.get(UNK)))
    
    # 转Tensor
    x = torch.LongTensor([words_line]).to(device)
    
    # 无梯度预测
    with torch.no_grad():
        output = model((x, None))
        pred_idx = torch.max(output.data, 1)[1].item()
    
    return class_list[pred_idx]

if __name__ == '__main__':
    test_sentences = [
        "今天考试得了满分,太开心啦",
        "这种人也太过分了吧,真的很生气",
        "这部电影也太难看了,浪费时间",
        "最近心情很低落,什么都不想做",
        "今天天气真好,和朋友出去玩很愉快"
    ]
    for s in test_sentences:
        result = predict_sentence(s)
        print(f"句子:{s}")
        print(f"情感:{result}\n")

8.3 预测代码关键说明

  1. map_location=device

    加载模型时指定设备。如果训练用GPU保存,预测用CPU加载,不加这个参数会报错。指定map_location可以自动适配。

  2. model.eval()

    预测前必须切换到评估模式,关闭dropout,保证结果稳定。

  3. 输入增加batch维度

    torch.LongTensor([words_line])外面加了一层列表,形状从[70]变成[1, 70],增加了batch维度。模型要求输入必须有batch维度,即使只有一条样本。

  4. .item()

    从只有一个元素的张量中取出Python标量值,得到预测的类别索引。

8.4 运行效果示例

复制代码
句子:今天考试得了满分,太开心啦
情感:喜悦

句子:这种人也太过分了吧,真的很生气
情感:愤怒

句子:这部电影也太难看了,浪费时间
情感:厌恶

句子:最近心情很低落,什么都不想做
情感:低落

句子:今天天气真好,和朋友出去玩很愉快
情感:喜悦

对于简单明确的句子,模型通常能给出正确的预测。对于讽刺、反语、歧义句,效果会打折扣,这也是轻量级模型的局限。


第九章 实验结果分析与优化方向

9.1 结果分析

基于字符级BiLSTM的微博情感分类,通常能达到70%-80%的准确率。我们可以从分类报告中分析:

  • 哪类效果最好:通常"喜悦"类最容易识别,因为正向情绪的关键词比较明确
  • 哪类容易混淆:"愤怒"和"厌恶"容易混淆,因为都带有负面情绪;"低落"和"厌恶"也可能交叉错分
  • 精确率vs召回率:如果某类精确率高召回率低,说明模型比较保守,预测该类很谨慎;反之则说明模型容易把其他类误判成该类

9.2 常见问题排查

  1. 训练损失不下降

    • 检查学习率是否太小或太大
    • 检查数据预处理是否正确,标签和文本是否对应
    • 检查模型forward是否有逻辑错误
  2. 训练集准确率很高,验证集很低

    • 典型的过拟合现象
    • 可以增大dropout、减少LSTM层数、降低隐藏层维度
    • 增加训练数据,或者加入数据增强
  3. 预测结果全是同一类

    • 数据集严重不平衡,模型倾向于预测多数类
    • 学习率不合适,模型没学好
    • 检查词表是否和训练时一致

9.3 进阶优化方向

9.3.1 模型结构优化
  1. 加入注意力机制:在LSTM输出上加自注意力,让模型自动关注关键词,效果通常有2-3个点提升
  2. 改用GRU:GRU比LSTM参数少、训练快,效果接近
  3. 多层CNN+LSTM混合:CNN提取局部n-gram特征,LSTM提取序列特征,优势互补
  4. 替换为预训练模型:用BERT、RoBERTa等中文预训练模型微调,准确率可以轻松突破90%,是当前工业界的主流方案
9.3.2 训练策略优化
  1. 学习率调度:使用余弦退火、阶梯衰减等学习率策略,提升收敛效果
  2. 梯度裁剪:防止梯度爆炸,让训练更稳定
  3. 权重衰减:L2正则化,抑制过拟合
  4. 类别权重:针对不平衡数据集,给损失函数加类别权重
  5. K折交叉验证:更充分地利用数据,评估结果更可靠
9.3.3 数据层面优化
  1. 词语级处理:用jieba分词,改用词语级词表,语义粒度更合理
  2. 数据增强:同义词替换、随机插入、随机交换、回译等,扩充训练数据
  3. 清洗数据:去除无意义的符号、表情、重复数据,提升数据质量
  4. 加入更多特征:表情符号、标点数量、感叹号数量等统计特征

第十章 情感分析技术的工业应用与前沿进展

10.1 工业界典型应用场景

  1. 电商评论分析:自动分析商品好评差评,提取优缺点,辅助产品改进
  2. 舆情监测:监控社交平台、新闻论坛,及时发现负面舆情预警
  3. 客服质检:分析客服对话的用户情绪,识别不满客户,提升服务质量
  4. 推荐系统:结合用户评论情感,优化推荐排序,提升用户体验
  5. 金融市场:分析社交媒体情绪,辅助股票、加密货币的市场情绪研判

10.2 技术发展历程

情感分析技术经历了三代演进:

  1. 词典规则时代:基于情感词典和规则打分,简单但泛化差
  2. 机器学习时代:SVM、朴素贝叶斯等传统机器学习,依赖人工特征工程
  3. 深度学习时代:CNN、RNN、Transformer等端到端模型,自动提取特征,效果大幅提升
  4. 大模型时代:通用大模型+微调/提示工程,零样本或少样本即可适配任务

10.3 当前前沿方向

  1. 细粒度情感分析:不仅判断整体情感,还要分析评价对象、属性级情感
  2. 多模态情感分析:结合文本、图片、语音、视频,综合判断情感
  3. 讽刺与反语识别:识别字面意思和真实情感相反的表达
  4. 跨领域情感迁移:在一个领域训练,迁移到其他领域仍有好效果
  5. 低资源情感分类:小样本、零样本场景下的情感识别

10.4 本项目的定位与价值

本项目虽然是入门级的BiLSTM字符级分类,但涵盖了NLP文本分类的完整流程,是学习深度学习NLP的绝佳练手项目。理解了这套基础框架,再去学习更复杂的模型和技术,就会水到渠成。

技术在迭代,但工程化的思想是相通的:数据处理、模型设计、训练验证、测试评估、上线预测,这套流程贯穿了所有深度学习项目。


第十一章 常见问题FAQ

Q1:为什么用字符级而不是分词?

A:入门项目用字符级实现简单,无需引入分词工具,避免分词错误引入的误差。工业级项目通常会用词语级或者直接用BERT分词器。

Q2:pad_size为什么设为70?

A:根据微博文本的平均长度设定,覆盖绝大多数样本。可以通过统计数据集的长度分布来确定最优值,比如取95分位数。

Q3:预训练词向量必须要有吗?

A:不是必须。没有的话把embedding_pretrained设为None即可,模型会随机初始化Embedding。只是有预训练的话,收敛更快、效果通常好一些。

Q4:训练2轮够吗?

A:取决于数据集大小。大数据集可能需要更多epoch,小数据集2轮也可能收敛。可以观察验证损失,不再下降就可以停了。

Q5:为什么测试时要加载最优模型,而不是用最后一轮?

A:因为后期可能过拟合,验证集表现最好的模型泛化能力最强。源码里test函数注释了加载语句,建议取消注释使用最优模型测试。

Q6:怎么提升准确率?

A:最有效的方式是换成预训练模型微调(BERT等),通常能有10个点以上的提升。在现有BiLSTM基础上,可以调参、加注意力、改用词语级、加数据增强等。


总结

本文用超过三万字的篇幅,完整拆解了基于BiLSTM的微博情感分类项目的每一行代码、每一个模块、每一步流程。我们从数据集探索开始,一步步走过了词表构建、数据加载、迭代器设计、模型搭建、训练验证、单句预测的全链路。

技术学习没有捷径,一行行读代码、一遍遍调参数、一次次排错,才是最快的成长路径。希望这篇保姆级教程能帮你少走弯路,扎实掌握文本分类技术。

后续可以尝试的进阶练习:给模型加入注意力机制、改用GRU对比效果、实现词语级分词、加入学习率衰减、用BERT重做这个任务......动手实践才是最好的学习。


如果这篇文章对你有帮助,欢迎点赞、收藏、评论交流。有任何问题都可以在评论区留言,我们一起探讨学习。