一、前言

上一篇我们完成了数据预处理,将原始微博文本转换为了模型可接受的数字张量。本篇将重点讲解:

  • TextRNN 模型的结构与实现(Embedding + 双向 LSTM + 全连接层)
  • 训练、验证与测试的完整流程
  • 主程序入口与运行配置
  • 相关深度学习核心知识点补充

二、TextRNN 模型结构(TextRNN.py)

TextRNN 是基于循环神经网络(RNN)的文本分类模型,本项目使用双向 LSTM作为核心结构。

2.1 模型整体架构

输入文本ID (batch, seq_len)
        ↓
  Embedding层 → (batch, seq_len, embed_dim)
        ↓
  双向LSTM(3层) → (batch, seq_len, hidden*2)
        ↓
  取最后时刻输出 → (batch, hidden*2)
        ↓
  全连接层 → (batch, num_classes)
        ↓
  分类结果(喜悦/愤怒/厌恶/低落)

2.2 完整代码

import torch
import torch.nn as nn

class Model(nn.Module):
    def __init__(self, embedding_pretrained, n_vocab, embed, num_classes):
        super(Model, self).__init__()

        # ========== Embedding层:将字符ID转换为稠密向量 ==========
        if embedding_pretrained is not None:
            # 使用预训练词向量初始化,freeze=False表示训练中继续微调
            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)

        # ========== 双向LSTM层 ==========
        self.lstm = nn.LSTM(
            input_size=embed,       # 输入特征维度(词向量维度)
            hidden_size=128,        # 每个方向隐藏层的神经元数
            num_layers=3,           # LSTM堆叠层数
            bidirectional=True,     # 是否双向
            batch_first=True,       # 输入格式为 (batch, seq, feature)
            dropout=0.3             # 层间dropout比例
        )

        # ========== 全连接分类层 ==========
        # 双向LSTM输出维度是 hidden_size * 2,因此输入为 128*2=256
        self.fc = nn.Linear(128 * 2, num_classes)

    def forward(self, x):
        # x 是 ((文本id张量, 有效长度), 标签) 的嵌套结构
        # 这里只取文本id张量,忽略有效长度和标签
        x, _ = x

        # Embedding:[batch, seq_len] → [batch, seq_len, embed]
        out = self.embedding(x)

        # LSTM:[batch, seq_len, embed] → [batch, seq_len, hidden*2]
        # 返回 (output, (h_n, c_n)),这里只取output,忽略隐藏状态和细胞状态
        out, _ = self.lstm(out)

        # 取最后一个时刻的输出用于分类:[batch, seq_len, hidden*2] → [batch, hidden*2]
        out = self.fc(out[:, -1, :])

        return out

2.3 关键知识点详解

(1)Embedding 层

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

Embedding 层的作用是将稀疏的 one-hot 编码转换为稠密的低维向量。

  • n_vocab:词表大小,即有多少个不同的字符(本项目为 4762)
  • embed:词向量维度,使用预训练词向量时为其维度,否则为 300
  • padding_idx:指定填充标记的索引。该位置的向量在训练中不更新梯度,始终为零向量,避免 padding 对模型产生干扰

预训练词向量初始化:

nn.Embedding.from_pretrained(embedding_pretrained, padding_idx=n_vocab-1, freeze=False)
  • from_pretrained:使用预训练的词向量矩阵初始化 Embedding 层
  • freeze=False:允许在训练中继续微调词向量;若设为 True 则冻结不更新

本项目使用的是腾讯开源中文词向量(Tencent AI Lab Embedding),存储在 embedding_Tencent.npz 文件中。使用预训练词向量相当于给模型提供了先验的语义知识,通常能加快收敛并提升效果。

(2)LSTM 原理简述

LSTM(Long Short-Term Memory,长短期记忆网络)是一种特殊的 RNN,通过门控机制解决了传统 RNN 的梯度消失问题,能够更好地捕捉长距离依赖。

LSTM 的核心是三个门和一个细胞状态:

  • 遗忘门(Forget Gate):决定从细胞状态中丢弃哪些信息
  • 输入门(Input Gate):决定哪些新信息存入细胞状态
  • 输出门(Output Gate):决定输出哪些信息
  • 细胞状态(Cell State):信息的"高速公路",在时间步之间传递

LSTM 的计算公式:

f t = σ ( W f ⋅ [ h t − 1 , x t ] + b f ) (遗忘门) i t = σ ( W i ⋅ [ h t − 1 , x t ] + b i ) (输入门) C ~ t = tanh ⁡ ( W C ⋅ [ h t − 1 , x t ] + b C ) (候选细胞状态) C t = f t ⊙ C t − 1 + i t ⊙ C ~ t (细胞状态更新) o t = σ ( W o ⋅ [ h t − 1 , x t ] + b o ) (输出门) h t = o t ⊙ tanh ⁡ ( C t ) (隐藏状态输出) \begin{aligned} f_t &= \sigma(W_f \cdot [h_{t-1}, x_t] + b_f) \quad \text{(遗忘门)} \\ i_t &= \sigma(W_i \cdot [h_{t-1}, x_t] + b_i) \quad \text{(输入门)} \\ \tilde{C}_t &= \tanh(W_C \cdot [h_{t-1}, x_t] + b_C) \quad \text{(候选细胞状态)} \\ C_t &= f_t \odot C_{t-1} + i_t \odot \tilde{C}_t \quad \text{(细胞状态更新)} \\ o_t &= \sigma(W_o \cdot [h_{t-1}, x_t] + b_o) \quad \text{(输出门)} \\ h_t &= o_t \odot \tanh(C_t) \quad \text{(隐藏状态输出)} \end{aligned} ft​it​C~t​Ct​ot​ht​​=σ(Wf​⋅[ht−1​,xt​]+bf​)(遗忘门)=σ(Wi​⋅[ht−1​,xt​]+bi​)(输入门)=tanh(WC​⋅[ht−1​,xt​]+bC​)(候选细胞状态)=ft​⊙Ct−1​+it​⊙C~t​(细胞状态更新)=σ(Wo​⋅[ht−1​,xt​]+bo​)(输出门)=ot​⊙tanh(Ct​)(隐藏状态输出)​

其中 σ \sigma σ 为 Sigmoid 函数, ⊙ \odot ⊙ 为逐元素相乘(哈达玛积)。

(3)双向 LSTM

bidirectional=True

双向 LSTM 同时使用前向和后向两个 LSTM 处理序列:

  • 前向 LSTM:从左到右读取文本,捕捉上文信息
  • 后向 LSTM:从右到左读取文本,捕捉下文信息

两个方向的隐藏状态在每个时刻拼接(concat),因此输出维度是 hidden_size * 2。

对于情感分析任务,一个词的情感极性往往同时依赖上下文(例如"不"字对后面形容词的否定),双向 LSTM 能同时利用上下文信息,通常比单向 LSTM 效果更好。

(4)多层 LSTM

num_layers=3

堆叠 3 层 LSTM,上层 LSTM 的输入是下层 LSTM 的输出。多层结构可以学习更抽象的特征:

  • 第 1 层:学习低级特征(如字与字的组合)
  • 第 2 层:学习中级特征(如短语模式)
  • 第 3 层:学习高级特征(如句子整体情感倾向)

但层数增加也会带来计算量增大和过拟合风险,需要配合 dropout 等正则化手段。

(5)Dropout

dropout=0.3

Dropout 是一种正则化技术,在训练过程中随机让一定比例(30%)的神经元失活,防止模型过拟合。

(6)取最后时刻输出

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

LSTM 的输出形状为 [batch, seq_len, hidden*2],out[:, -1, :] 取每个句子最后一个时刻的隐藏状态,形状变为 [batch, hidden*2],然后送入全连接层分类。

在文本分类任务中,我们需要将变长序列压缩为一个固定长度的向量表示。取最后时刻的隐藏状态是最简单的做法,因为 LSTM 的最后时刻隐藏状态理论上包含了整个序列的信息。


三、训练、验证与测试(train_eval_test.py)

3.1 训练函数

import torch
import torch.nn.functional as F
import numpy as np
from sklearn import metrics


def train(model, train_iter, dev_iter, test_iter, class_list):
    model.train()  # 进入训练模式,启用dropout等训练专属行为
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)  # Adam优化器

    total_batch = 0               # 记录进行到多少batch
    dev_best_loss = float('inf')  # 验证集最优损失,初始化为无穷大
    last_improve = 0              # 上次验证集loss下降的batch数
    flag = False                  # 记录是否很久没有效果提升
    epochs = 20                   # 最大训练轮数

    for epoch in range(epochs):
        print('Epoch [{}/{}]'.format(epoch + 1, epochs))
        for i, (trains, labels) in enumerate(train_iter):
            # ===== 前向传播 =====
            outputs = model(trains)          # 模型预测输出,形状 [batch, 4]
            loss = F.cross_entropy(outputs, labels)  # 计算交叉熵损失

            # ===== 反向传播三步走 =====
            model.zero_grad()  # 1. 清空上一轮的梯度
            loss.backward()    # 2. 反向传播,计算梯度
            optimizer.step()   # 3. 根据梯度更新参数

            # ===== 每100个batch输出一次训练和验证效果 =====
            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)

                # 保存验证集损失最小的模型(最优模型)
                if dev_loss < dev_best_loss:
                    dev_best_loss = dev_loss
                    torch.save(model.state_dict(), 'TextRNN.ckpt')
                    last_improve = total_batch

                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()  # evaluate中切换到了eval模式,这里切回训练模式

            total_batch += 1

            # ===== 早停机制:超过10000个batch验证集loss没下降,停止训练 =====
            if total_batch - last_improve > 10000:
                print("No optimization for a long time, auto-stopping...")
                flag = True
                break
        if flag:
            break

    # 训练结束后在测试集上评估
    test(model, test_iter, class_list)

3.2 训练关键知识点

(1)训练模式与评估模式

model.train()  # 训练模式
model.eval()   # 评估模式
  • train():启用 Dropout 和 Batch Normalization 的训练行为
  • eval():关闭 Dropout,使用全局统计量的 BN,确保评估结果稳定可复现

训练和验证交替进行时,必须在验证前调用 eval(),验证后调用 train() 切回训练模式。

(2)交叉熵损失函数

loss = F.cross_entropy(outputs, labels)

交叉熵损失是分类任务中最常用的损失函数,公式为:

L o s s = − ∑ i = 1 C y i log ⁡ ( p i ) Loss = -\sum_{i=1}^{C} y_i \log(p_i) Loss=−i=1∑C​yi​log(pi​)

其中 y i y_i yi​ 是真实标签的 one-hot 编码, p i p_i pi​ 是模型预测的概率, C C C 是类别数。

(3)反向传播三步

model.zero_grad()  # 1. 清空上一轮的梯度(PyTorch默认梯度累加)
loss.backward()    # 2. 反向传播,自动计算所有参数的梯度
optimizer.step()   # 3. 根据梯度和优化器策略更新参数

这是 PyTorch 训练的标准流程,三步缺一不可:

  • 不清零梯度 → 梯度会累加,导致更新方向错误
  • 不反向传播 → 没有梯度,无法更新
  • 不 step → 计算了梯度但没有实际更新参数

(4)Adam 优化器

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

Adam(Adaptive Moment Estimation)结合了动量法(Momentum)和 RMSprop 的优点,自适应调整每个参数的学习率,是深度学习中最常用的优化器之一。

  • 学习率设为 1e-3(0.001)是 Adam 的常见默认值
  • model.parameters() 传入模型所有可学习的参数

(5)早停机制(Early Stopping)

if total_batch - last_improve > 10000:
    print("No optimization for a long time, auto-stopping...")
    flag = True
    break

当验证集损失连续 10000 个 batch 没有下降时,提前终止训练。早停的作用:

  • 防止过拟合:模型在训练集上继续变好,但验证集不再提升,说明开始过拟合
  • 节省训练时间:避免无意义的继续训练

(6)模型保存

torch.save(model.state_dict(), 'TextRNN.ckpt')

保存模型的参数字典(state_dict),而不是整个模型对象。这是 PyTorch 推荐的做法,优点是:

  • 文件体积小
  • 加载灵活(可以加载到不同结构的模型中,只要参数名匹配)
  • 兼容性好

加载时使用:

model.load_state_dict(torch.load('TextRNN.ckpt'))

3.3 验证函数

def evaluate(class_list, model, data_iter, test: bool = False):
    """
    模型评估函数:计算数据集上损失、准确率,测试集可输出分类报告
    :param class_list: 类别名称列表
    :param model: 训练/待评估模型
    :param data_iter: 数据集迭代器(验证集/测试集)
    :param test: 是否为测试集;True返回acc,loss,分类报告,False仅返回acc,loss
    :return: acc, avg_loss [, classification_report]
    """
    model.eval()  # 设置模型为评估模式:关闭Dropout、BatchNorm训练行为,权重只读不更新
    loss_total = 0  # 累加全部batch损失
    predict_all = np.array([], dtype=int)  # 保存全部预测标签
    labels_all = np.array([], dtype=int)   # 保存全部真实标签

    # 关闭梯度计算,节省显存,推理阶段不需要反向传播
    with torch.no_grad():
        for texts, labels in data_iter:
            outputs = model(texts)                 # 模型前向传播,得到原始输出logits
            loss = F.cross_entropy(outputs, labels)# 计算当前batch交叉熵损失
            loss_total += loss                     # 累加损失
            labels = labels.data.cpu().numpy()     # 真实标签转到cpu,转为numpy数组
            # 获取预测类别:取logits维度上最大值对应的下标
            predic = torch.max(outputs.data, 1)[1].cpu().numpy()
            labels_all = np.append(labels_all, labels)
            predict_all = np.append(predict_all, predic)

    # 计算整体准确率
    acc = metrics.accuracy_score(labels_all, predict_all)
    if test:
        # 测试集:输出精确率、召回率、F1分类报告,保留4位小数
        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)

3.4 验证关键知识点

(1)torch.no_grad()

with torch.no_grad():

验证/测试时不需要计算梯度,使用 torch.no_grad() 上下文管理器可以:

  • 减少内存占用(不需要存储计算图)
  • 加快计算速度

(2)预测类别获取

predic = torch.max(outputs.data, 1)[1].cpu().numpy()
  • torch.max(outputs, 1):按维度 1(类别维度)取最大值,返回元组 (最大值, 最大值索引)
  • [1]:取索引,即预测的类别 ID
  • .cpu():将张量从 GPU 移到 CPU
  • .numpy():转为 numpy 数组,方便与 sklearn 配合使用

(3)sklearn 评估指标

准确率(Accuracy):

metrics.accuracy_score(labels_all, predict_all)

A c c u r a c y = 预测正确的样本数 总样本数 Accuracy = \frac{预测正确的样本数}{总样本数} Accuracy=总样本数预测正确的样本数​

详细分类报告:

metrics.classification_report(labels_all, predict_all, target_names=class_list, digits=4)

生成每个类别的详细指标:

指标公式含义
Precision(精确率) T P / ( T P + F P ) TP / (TP + FP) TP/(TP+FP)预测为某类的样本中,真正属于该类的比例
Recall(召回率) T P / ( T P + F N ) TP / (TP + FN) TP/(TP+FN)真实属于某类的样本中,被正确预测的比例
F1-score 2 × P × R / ( P + R ) 2 \times P \times R / (P + R) 2×P×R/(P+R)精确率和召回率的调和平均
Support—该类别的真实样本数量

3.5 测试函数

def test(model, test_iter, class_list):
    model.eval()  # 进入测试模式
    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)

训练结束后,在测试集上评估最终性能,输出测试损失、准确率和详细分类报告。


四、主程序入口(main.py)

4.1 完整代码

import torch
import numpy as np
import load_dataset, TextRNN
from train_eval_test import train

# ========== 设备选择:优先GPU,其次MPS(Mac),最后CPU ==========
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"

# ========== 设置随机种子,保证实验可复现 ==========
np.random.seed(1)              # numpy随机数种子
torch.manual_seed(1)           # PyTorch CPU随机数种子
torch.cuda.manual_seed_all(1)  # 所有GPU的随机数种子
torch.backends.cudnn.deterministic = True  # 使用确定性的cuDNN算法

# ========== 加载数据集 ==========
vocab, train_data, dev_data, test_data = load_dataset.load_dataset('simplifyweibo_4_moods.csv')

# ========== 创建数据迭代器,batch_size=128 ==========
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)

# ========== 加载预训练词向量(腾讯词向量) ==========
embedding_pretrained = torch.tensor(
    np.load('embedding_Tencent.npz')["embeddings"].astype('float32'))
# embedding_pretrained = None    # 不使用外部预训练词向量时取消注释

# 词向量维度:有预训练则取其维度,否则设为300
embed = embedding_pretrained.size(1) if embedding_pretrained is not None else 300

# ========== 类别定义与模型构建 ==========
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)

4.2 关键知识点详解

(1)设备选择

device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"

自动选择最优计算设备:

设备说明速度
CUDANVIDIA GPU,深度学习训练首选最快
MPSApple Silicon(M1/M2/M3 芯片)的 GPU 加速较快
CPU中央处理器,没有 GPU 时的兜底方案较慢

(2)随机种子设置

np.random.seed(1)
torch.manual_seed(1)
torch.cuda.manual_seed_all(1)
torch.backends.cudnn.deterministic = True

设置随机种子是为了保证实验可复现:

  • np.random.seed:numpy 随机数种子(数据打乱等)
  • torch.manual_seed:PyTorch CPU 随机数种子(参数初始化等)
  • torch.cuda.manual_seed_all:所有 GPU 的随机数种子
  • cudnn.deterministic = True:使用确定性的 cuDNN 卷积算法

(3)预训练词向量加载

embedding_pretrained = torch.tensor(
    np.load('embedding_Tencent.npz')["embeddings"].astype('float32'))

从 npz 文件中加载预训练的词向量矩阵:

  • np.load():加载 npz 格式的压缩 numpy 数组
  • ["embeddings"]:取出键为 embeddings 的数组
  • .astype('float32'):转为 32 位浮点数,PyTorch 默认使用 float32
  • torch.tensor():转为 PyTorch 张量

如果不使用预训练词向量,设为 None,模型会随机初始化 Embedding 层并从头学习。

(4)模型参数汇总

参数值说明
词表大小47624760 个字符 + UNK + PAD
词向量维度预训练维度 / 300使用腾讯预训练词向量
LSTM 隐藏层128每个方向的隐藏单元数
LSTM 层数3堆叠层数
是否双向是bidirectional=True
Dropout0.3层间 dropout 比例
全连接输入256128 × 2(双向拼接)
类别数4喜悦 / 愤怒 / 厌恶 / 低落
Batch Size128每批样本数
学习率1e-3Adam 优化器
最大 Epoch20最大训练轮数
早停阈值10000 batch验证集 loss 不下降的最大 batch 数
序列长度70pad_size

五、补充知识点

5.1 梯度消失与 LSTM

传统 RNN 在处理长序列时,由于反向传播中链式法则的连乘效应,梯度会指数级衰减(梯度消失)或爆炸(梯度爆炸),导致无法学习长距离依赖。

LSTM 通过**细胞状态(Cell State)**的"高速公路"设计,让梯度可以在时间步之间更顺畅地流动(遗忘门接近 1 时梯度几乎无损传递),从而有效缓解了梯度消失问题。

5.2 过拟合与应对策略

过拟合是指模型在训练集上表现很好,但在测试集上表现差,即模型"记住"了训练数据而非"学会"了规律。本项目使用了以下应对策略:

  1. Dropout:LSTM 层间 dropout=0.3,训练时随机失活神经元
  2. 早停(Early Stopping):验证集 loss 长期不下降则停止训练
  3. 预训练词向量:用外部知识初始化 Embedding,减少需要从头学习的参数量
  4. 数据量:微博数据集本身规模较大,一定程度上抑制过拟合

5.3 为什么取最后时刻的隐藏状态?

在文本分类任务中,需要将变长序列压缩为固定长度的向量表示。取最后时刻隐藏状态是最简单的做法,因为 LSTM 的最后时刻隐藏状态理论上汇总了整个序列的信息。

其他常见做法对比:

方法做法适用场景
最后时刻取 h T h_T hT​短文本、简单分类
平均池化所有时刻取平均各位置贡献均匀时
最大池化所有时刻取最大值关注关键词时
注意力机制学习权重后加权求和需要关注不同位置重要性时

5.4 训练输出示例

训练过程中的典型输出如下:

Epoch [1/20]
Iter:      0,  Train Loss:   1.4,  Train Acc:  25.00%,  Val Loss:   1.4,  Val Acc:  25.30%
Iter:    100,  Train Loss:  0.95,  Train Acc:  60.16%,  Val Loss:  0.98,  Val Acc:  58.45%
Iter:    200,  Train Loss:  0.82,  Train Acc:  68.75%,  Val Loss:  0.85,  Val Acc:  65.12%
...
Test Loss:  0.75, Test Acc: 71.23%
              precision    recall  f1-score   support

          喜悦     0.7523    0.7812    0.7665      1200
          愤怒     0.6987    0.6543    0.6758      1100
          厌恶     0.6845    0.7012    0.6928      1150
          低落     0.7156    0.6987    0.7071      1050

    accuracy                         0.7123      4500
   macro avg     0.7128    0.7089    0.7106      4500
weighted avg     0.7135    0.7123    0.7125      4500

以上为示例输出,实际结果取决于数据和训练情况。


六、项目运行步骤

  1. 准备数据:将 simplifyweibo_4_moods.csv 放在项目目录下
  2. 构建词表:运行 vocab_create.py,生成 simplifyweibo_4_moods.pkl
  3. 准备预训练词向量:下载腾讯词向量并处理为 embedding_Tencent.npz
  4. 开始训练:运行 main.py,训练过程中自动保存最优模型 TextRNN.ckpt
  5. 查看结果:训练结束后输出测试集的准确率和详细分类报告

七、总结

本项目通过微博四分类情感分析任务,完整展示了深度学习 NLP 项目的全流程:

数据预处理 → 词表构建 → 文本向量化 → TextRNN模型 → 训练与调优 → 评估测试

核心技术点回顾:

  • 字符级分词与词表构建(频次统计、UNK/PAD、pickle 序列化)
  • Embedding 词向量层(支持预训练词向量加载与微调)
  • 双向多层 LSTM(门控机制、双向上下文、dropout 正则化)
  • 交叉熵损失与 Adam 优化器
  • 早停机制与最优模型保存
  • 训练 / 验证 / 测试三阶段评估(accuracy、precision、recall、f1)

更多推荐