05. GRU模型

一、什么是 GRU 模型

  Gate Recurrent Unit(GRU)是为了进一步简化 LSTM 结构,降低计算成本而提出的一种变体。GRU 保留了门控机制的核心思想,但相比 LSTM 而言,GRU 结构更为简洁,参数更少,训练效率更高。在许多实际任务中,GRU 能在保持类似性能的同时,显著减少训练时间。

  与 LSTM 相比,GRU 作出了如下改进:

  • 取消了 LSTM 中独立的记忆单元,只保留隐藏状态。
  • 通过两个门控结构控制信息流动:更新门(Update Gate)和 重置门(Reset Gate)。

  具体的结构如下所示:

GRU展开结构

【1】、重置门(Reset Gate)

  重置门由上一个时间步的隐藏状态和当前时间步的输入计算得到。

GRU的重置门

  它的计算公式如下:

\[r_{t} = \sigma(h_{t - 1} \times W^{r}_{h} + x_{t} \times W^{r}_{t} + b_{r}) \]

  重置门会在计算当前时间步信息(候选隐藏状态)时,作用在上一个时间步的隐藏状态,用于控制遗忘多少旧信息。

重置门作用时机

  当前时间步的信息(候选隐藏信息)的计算公式如下:

\[\tilde{h_{t}} = tanh((r_{t} \bigodot h_{t - 1}) \times W_{h} + x_{t} \times W_{x} + b) \]

【2】、更新门(Update Gate)

  更新门也由上一时间步的隐藏状态和当前时间步的输入计算得到。

GRU的更新门

  它的计算公式如下:

\[z_{t} = \sigma(h_{t - 1} \times W^{z}_{h} + x_{t} \times W^{z}_{x} + b_{z}) \]

  更新门会在计算当前时间步最终的隐藏状态 \(h_{t}\) 时,分别作用于上一时刻的隐藏状态 \(h_{t - 1}\) 和当前新计算出的候选隐藏状态 \(h_{t}\),用于控制保留多少旧信息,以及引入多少新信息。

更新门的作用时机

  最终的隐藏状态的计算公式如下:

\[h_{t} = z_{t} \bigodot h_{t - 1} + (\vec{1} - z_{t}) \bigodot \tilde{h_{t}} \]

  PyTorch 中提供了 torch.nn.GRU 模块实现 GRU。该模块支持单层或多层 LSTM,也可以通过设置参数启用双向结构。我们在终端中输入 pip 指令安装 PyTorch。默认是从国外的主站上下载,因此,我们可能会遇到网络不好的情况导致下载失败。我们可以在 pip 指令后通过 -f 指定国内镜像源下载。

pip install torch torchvision -f https://mirrors.aliyun.com/pytorch-wheels/cu132

  国内常用的 pip 下载源列表:

  安装完 PyTorch 之后,我们可以通过如下代码查看 CUDA 是否可用:

import torch

print(f"PyTorch的版本:{torch.__version__}")
print(f"CUDA是否可用:{torch.cuda.is_available()}")

如果输出 CUDA 不可用,有可能你使用国内镜像只同步 PyPI 主站,不包含 PyTorch 官方 CUDA 专用包,pip 只会找到 CPU 版,找不到 CUDA 版。因此,我们还需要使用 --extra-index-url https://download.pytorch.org/whl/cu132 补充官方 CUDA 源 。

  在 PyTroch 中,我们可以使用 torch.nn.GRU 来 初始化循环神经网络层。

torch.nn.GRU(
    input_size: int,                                                            # 输入特征的数量
    hidden_size: int,                                                           # 隐藏状态的特征维度
    num_layers: int = 1,                                                        # 循环神经网络的层数
    bias: bool = True,                                                          # 是否在权重计算中加入偏置项
    batch_first: bool = False,                                                  # 输入/输出张量的批次维度是否放在第一位
    dropout: float = 0.0,                                                       # 隐藏层的失活率
    bidirectional: bool = False,                                                # 是否为双向 GRU
    device=None,                                                                # 模型参数存放的设备
    dtype=None,                                                                 # 模型参数的数据类型
)

  参数 batch_first 用于控制是否将 输入/输出张量的批次维度放在第一位。如果为 True,则输入形状为 (batch, seq_len, input_size)。如果为 False,则输入形状为 (seq_len, batch, input_size)。

  创建 GRU 之后,当我们调用进行前向传播时,需要传入两个参数:

output, hn = gru(x, hx)

  参数 x 为 输入序列,形状为 (seq_len, batch_size, input_size),如果 batch_first = True,则为 (batch_size, seq_len, input_size)。其中 seq_len 为 序列长度,输入的时间步总数。batch_size 为 批量大小,即一次输入几个独立的样本。input_size 为 输入特征维度,即每个时间步数据的向量长度(比如词向量的维度)。

  参数 hx 是 初始隐状状态,形状为 [num_layers * num_directions, batch_size, hidden_size]。其中 num_layers 为 隐藏层层数。num_directions 为 方向数,单向 为 1,双向 为 2。batch_size 为 批量大小,必须和 input 的 batch_size 一致。hidden_size 为 隐藏层维度。这个参数是 可选的。如果不传入(即 hx=None),PyTorch 会自动将其初始化为全零张量。

  返回值 output 是 RNN 层的输出,它包含 每个时间步(从第 1 步到第 seq_len 步)的最后一层 RNN 输出的隐藏状态,形状为 [seq_len, batch_size, hidden_size * num_directions],如果 batch_first = True,则为 (batch_size, seq_len, hidden_size * num_directions)。

  返回值 hn 是 最后一个时间步的隐藏状态,它包含每一层的每个方向,形状为 [number_layers * num_directions, batch_size, hidden_size]。

import torch
import torch.nn as nn

if __name__ == "__main__":
    input_size = 3
    hidden_size = 4
    num_layers = 1
    seq_len = 4
    batch_size = 2

    gru = nn.GRU(input_size, hidden_size, num_layers)                           # 创建 GRU

    x = torch.randn(seq_len, batch_size, input_size)                            # 输入数据,形状为 (序列长度, 批量大小, 输入序列)
    h0 = torch.randn(num_layers, batch_size, hidden_size)                       # 初始隐藏状态,形状为 (隐藏层层数, 批量大小, 隐藏层维度)
     
    output, hn = gru(x, h0)                                                     # 前向传播,得到输出和新的隐藏状态
    print(output.shape)                                                         # torch.Size([4, 2, 4]),形状为 (序列长度, 批量大小, 隐藏层维度)
    print(hn.shape)                                                             # torch.Size([1, 2, 4]),形状为 (隐藏层层数, 批量大小, 隐藏层维度)

二、GRU 的复杂结构

2.1、多层结构

  GRU 同样支持多层结构,具体的结构如下所示:

GRU的多层结构

2.2、双向结构

  GRU 同样支持双向结构,具体的结构如下所示:

GRU的双向结构

2.3、多层加双向结构

  GRU 同样支持多层结构,具体的结构如下所示:

GRU的多层加双向结构

三、GRU 存在的问题

  GRU 在简化结构、提高训练效率方面表现优秀,但在超长依赖建模、灵活性和并行计算方面仍存在天然限制。

【1】、难以并行计算

  GRU 的时间步之间具有强依赖性(后一个时间步的输入依赖前一个时间步的输出),导致无法进行大规模并行加速,训练和推理速度受限。

【2】、参数量大,计算开销搞

  每个 GRU 单元内部包含多个门控机制(重置门、更新门),每个门都需要独立计算,导致参数数量和计算量远大于普通 RNN。在资源受限的场景下(如移动端、嵌入式设备),部署 GRU 会面临挑战。

【3】、长期依赖建模仍然有限

  虽然 GRU 延缓了梯度消失问题,但不能完全消除。当序列极长时,模型依然难以有效捕捉非常远距离的依赖关系。

四、电商评论情感分类案例

  我们可以从 GitHub 中下载电商评论数据集: https://github.com/InsaneLife/ChineseNLPCorpus 。如果我们无法访问可以使用 瓦特工具箱(Steam++官网) - Watt Toolkit 加速访问。

  我们点击 online_shopping_10_cats 那行的超链接跳转到电商评论,然后,我们下载电商评论数据集的压缩包。它的下载链接如下: https://github.com/SophonPlus/ChineseNlpCorpus/blob/master/datasets/online_shopping_10_cats/online_shopping_10_cats.zip 。

跳转电商评论数据集

下载电商评论的数据集压缩包

  我们可以在终端中使用 pip 安装 pandas、sklearn 机器学习库 和 jieba。默认是从国外的主站上下载,因此,我们可能会遇到网络不好的情况导致下载失败。我们可以在 pip 指令后通过 -i 指定国内镜像源下载。

pip install scikit-learn pandas jieba -i https://pypi.tuna.tsinghua.edu.cn/simple

  数据预处理:

import os

DATA_DRIECTORY = os.path.join(os.getcwd(), "assets", "data")
MODEL_DRIECTORY = os.path.join(os.getcwd(), "assets", "models")

UNK_TOKEN = "<unk>"
PAD_TOKEN = "<pad>"

SPECIAL_TOKENS = [UNK_TOKEN, PAD_TOKEN]
import os

import jieba
import pandas as pd

from sklearn.model_selection import train_test_split

def preprocess_data(file_path, test_size):
    # 读取 CSV 文件,usecols 为要使用的列
    df = pd.read_csv(file_path, usecols=["label", "review"], encoding="utf-8").dropna()

    # 划分训练集与测试集,stratify 参数指定所选列进行分层抽样
    train_df, test_df = train_test_split(df, test_size=test_size, stratify=df["label"])

    # 切词获取词元并去重
    vacab_set = set([vacab for sentence in train_df["review"] for vacab in jieba.lcut(sentence) if vacab.strip()])
    vacab_list = SPECIAL_TOKENS+ list(vacab_set)                                # 添加特殊符号,表示从未出现的词和填充词
    print("构建词表成功")
    
    if not os.path.exists(DATA_DRIECTORY):
        os.makedirs(DATA_DRIECTORY)

    # 保存词表到文件中
    file_path = os.path.join(DATA_DRIECTORY, "online_shopping_vocab.txt")
    with open(file_path, "w", encoding="utf-8") as f:
        f.write("\n".join(vacab_list))

    print("保存词表到文件中成功")

    word2index = {word: index for index, word in enumerate(vacab_list)}         # 构建词表到索引的映射
    print("构建词表到索引的映射成功")

    # 计算序列长度,这里以 95% 分位数作为自己长度
    seq_len = int(train_df["review"].apply(lambda sentence: len([token for token in jieba.lcut(sentence)])).quantile(0.95))

    # 构建训练集
    train_df["review"] = train_df["review"].apply(lambda sentence: get_word2index(sentence, word2index, seq_len))
    print("构建训练集成功")

    file_path = os.path.join(DATA_DRIECTORY, "online_shopping_train.jsonl")
    train_df.to_json(file_path, orient="records", lines=True)                   # 保存训练集
    print("保存训练集数据成功")

    # 构建测试集
    test_df["review"] = test_df["review"].apply(lambda sentence: get_word2index(sentence, word2index, seq_len))
    print("构建测试集成功")

    file_path = os.path.join(DATA_DRIECTORY, "online_shopping_test.jsonl")
    test_df.to_json(file_path, orient="records", lines=True)                    # 保存测试集
    print("保存测试集数据成功")

def get_word2index(text, word2index, seq_len):
    tokens = jieba.lcut(text)                                                   # 分词
    tokens = [token for token in tokens if token]

    # 截取或填充到指定长度
    if len(tokens) > seq_len:
        tokens = tokens[:seq_len]
    elif len(tokens) < seq_len:
        tokens += [PAD_TOKEN] * (seq_len - len(tokens))

    return [word2index.get(token, SPECIAL_TOKENS.index(UNK_TOKEN)) for token in tokens]

if __name__ == "__main__":
    test_size = 0.2
    file_path = os.path.join(DATA_DRIECTORY, "online_shopping_10_cats.csv")

    preprocess_data(file_path, test_size)

  创建数据集:

import torch
import pandas as pd

from torch.utils.data import Dataset

class ReviewAnalyzeDataset(Dataset):
    def __init__(self, file_path):
        self.data = pd.read_json(file_path, orient="records", lines=True).to_dict(orient="records")

    def __len__(self):
        return len(self.data)

    def __getitem__(self, index):
        index = min(max(index, 0), len(self.data) - 1)                          # 索引边界检查

        # x.shape: [序列长度]
        x = torch.tensor(self.data[index]["review"], dtype=torch.long)

        # y.shape: []
        y = torch.tensor(self.data[index]["label"], dtype=torch.float)
        
        return x, y

  该案例采用基于 GRU 的语言模型结构来实现 “电商评论情感分类” 功能。模型整体由以下三个主要部分组成:

  • 嵌入层(Embedding):将输入的词或字索引映射为稠密向量表示,便于后续神经网络处理。
  • GRU:用于建模输入序列的上下文信息,输出最后一个时间步的隐藏状态作为上下文表示。
  • 输出层(Linear):将 GRU 的隐藏状态输出映射为一个标量,表示该评论为正向情感分析的倾向得分(经过 Sigmod() 函数后,大于 0.5 判断为 正情感倾向,小于等于 0.5 判断为 负情感倾向)。

  创建模型:

import torch

from torch import nn

class ReviewAnalyzeModel(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_layers, padding_idx):
        super().__init__()

        # 词嵌入层 [词向量数(词表大小), 词向量维度, 填充值的索引]
        self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx)
        # GRU 层 [输入维度(词向量维度), 隐藏层维度, 隐藏层层数, 是否将batch作为输入张量的第一个维度]
        self.gru = nn.GRU(embedding_dim, hidden_dim, num_layers, batch_first=True)
        # 输出层 [输入维度(隐藏层维度), 输出维度(二分类)]
        self.liner = nn.Linear(hidden_dim, 1)

    def forward(self, x):
        # x.shape: [批量大小, 序列长度]
        # embed.shape: [批量大小, 序列长度, 词向量维度]
        embed = self.embedding(x)                                               # 获取单词的嵌入向量

        # output 最后一层每个时间步的隐藏层状态
        # output.shape: [批量大小, 序列长度, 隐藏层维度]
        # hn 最后一个时间步的隐藏层状态(包含每一个层,每一个方向)
        # hn.shape: [隐藏层层数, 批量大小, 隐藏层维度]
        output, hn = self.gru(embed)

        batch_indexes = torch.arange(0, output.shape[0])                        # 获取每个样本的索引
        lengths = (x != self.embedding.padding_idx).sum(dim=1)                  # 获取每个样本真实token的长度
        
        # last_hidden_state.shape: [批量大小, 隐藏层维度]
        last_hidden_state = output[batch_indexes, lengths - 1]                  # 获取每个样本真实token的隐藏状态

        # Liner 层保留输入张量的所有维度(除最后一维外全部不变),仅将最后一维(特征维度)替换为 out_features
        # squeeze(dim) 删除张量(Tensor)中长度为 1 的指定维度(即移除大小为 1 的轴)
        # 如果该维度大小为 1:该维度被移除,张量总维度数减 1
        # 如果该维度大小不为 1:操作无效(PyTorch 会抛出 ValueError 或 RuntimeError)
        # 如果不指定 dim 参数:删除所有长度为 1 的维度
        # 如果 dim 为负索引,dim=-1 指向最后一个维度,dim=-2 指向倒数第二个维度,以此类推
        # output.shape: [批量大小]
        output = self.liner(last_hidden_state).squeeze(-1)
        
        return output, hn

  模型单轮训练:

def model_train_one_epoch(model, device, data_loader, criterion, optimizer):
    model.train()                                                               # 训练模式
    total_loss = 0.0

    for x, y in data_loader:
        # x.shape: [批量大小, 序列长度]
        # y.shape: [批量大小]
        x, y = x.to(device), y.to(device)                                       # 指定设备

        # output.shape: [批量大小]
        output, hn = model(x)                                                   # 模型前向传播
        
        # BCEWithLogitsLoss() 二分类损失函数
        # 如果目标值(y)的形状为 N,则预测值(output)的形状也为 [N]
        loss = criterion(output, y)                                             # 计算损失
        optimizer.zero_grad()                                                   # 清空梯度
        loss.backward()                                                         # 反向传播
        optimizer.step()                                                        # 优化器更新参数

        # x.shape[0] 是当前批次的样本数量
        # 乘以 x.shape[0] 是为了消除批次大小不一致(尤其是最后一个不完整批次)带来的统计偏差
        total_loss += loss.item() * x.shape[0]                                  # 计算当前批次的损失

    # len(dataloader.dataset) 返回数据集的样本数量
    # len(dataloader) 返回数据加载器的批次数量
    return total_loss / len(data_loader.dataset)                                # 返回平均损失

  训练模型,情感分析为 二分类问题,所以损失函数采用 BCEWithLogitsLoss(),其结合了 softmax() 激活函数和二分类交叉熵计算。

EMBEDDING_DIM = 128
HIDDEN_DIM = 256
NUM_LAYERS = 1

BATCH_SIZE = 128

LEARNING_RATE = 1e-3
EPOCHS = 10
import os

import torch

from torch.utils.data import DataLoader

def model_train(model, device, data_loader, learning_rate, epochs):
    model.to(device)                                                            # 指定设备

    criterion = torch.nn.BCEWithLogitsLoss()                                    # 二分类损失函数
    optimizer = torch.optim.Adam(model.parameters(), learning_rate)             # 创建优化器

    # 开始训练
    for epoch in range(epochs):
        print(f"开始第 {epoch + 1} 次训练")
        loss = model_train_one_epoch(model, device, data_loader, criterion, optimizer)
        print(f"epoch: {epoch + 1:0>2},loss: {loss:.4f}")
        
    print("训练完成,开始保存模型")
    
    if not os.path.exists(MODEL_DRIECTORY):
        os.makedirs(MODEL_DRIECTORY)

    file_path = os.path.join(MODEL_DRIECTORY, "review_analyze_model_by_gru.pth")
    torch.save(model.state_dict(), file_path)                                   # 保存模型

    print("模型保存完成")

if __name__ == "__main__":
    padding_ids = SPECIAL_TOKENS.index(PAD_TOKEN)
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    # 加载词表
    file_path = os.path.join(DATA_DRIECTORY, "online_shopping_vocab.txt")
    with open(file_path, "r", encoding="utf-8") as f:
        vocab_list = [line.strip() for line in f.readlines()]

    print("加载词表完成")

    file_path = os.path.join(DATA_DRIECTORY, "online_shopping_train.jsonl")
    train_dataset = ReviewAnalyzeDataset(file_path)                             # 训练数据集
    print("创建训练集成功")

    train_data_loader = DataLoader(train_dataset, BATCH_SIZE, True)             # 创建训练集数据加载器
    print("创建训练集数据加载器成功")

    model = ReviewAnalyzeModel(len(vocab_list), EMBEDDING_DIM, HIDDEN_DIM, NUM_LAYERS, padding_ids) # 模型
    print("创建模型成功")
    
    model_train(model, device, train_data_loader, LEARNING_RATE, EPOCHS)

  模型单批次预测:

import torch

def model_predict_one_batch(model, x):
    model.eval()                                                                # 评估模式
    
    with torch.no_grad():                                                       # 禁用梯度计算
        # output.shape: [批量大小, 输出维度(词表大小)]
        output, hn = model(x)                                                   # 模型前向传播

    return torch.sigmoid(output).tolist()

  模型评估:

import os

import torch

from torch.utils.data import DataLoader

def model_evaluate(model, device, data_loader):
    total_count = 0
    correct_count = 0

    model.to(device)                                                            # 指定设备

    for x, y in data_loader:
        # x.shape: [批量大小, 序列长度]
        # y.shape: [批量大小]
        x, y = x.to(device), y.to(device)                                       # 指定设备
        
        targets = y.tolist()
        results = model_predict_one_batch(model, x)

        # 统计准确率
        for probability, target in zip(results, targets):
            predict_label = 1 if probability > 0.5 else 0
            correct_count += (1 if predict_label == target else 0)
            total_count += 1

    accuracy = correct_count / total_count

    print(f"准确率:{accuracy:.4f}")

if __name__ == "__main__":
    padding_ids = SPECIAL_TOKENS.index(PAD_TOKEN)
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    # 加载词表
    file_path = os.path.join(DATA_DRIECTORY, "online_shopping_vocab.txt")
    with open(file_path, "r", encoding="utf-8") as f:
        vocab_list = [line.strip() for line in f.readlines()]

    print("加载词表完成")

    file_path = os.path.join(DATA_DRIECTORY, "online_shopping_test.jsonl")
    test_dataset = ReviewAnalyzeDataset(file_path)                              # 测试数据集
    print("创建测试集完成")

    test_data_loader = DataLoader(test_dataset, BATCH_SIZE)                     # 创建测试集数据加载器
    print("创建测试集数据加载器成功")
    
    model = ReviewAnalyzeModel(len(vocab_list), EMBEDDING_DIM, HIDDEN_DIM, NUM_LAYERS, padding_ids).to(device)
    print("创建模型成功")
    
    file_path = os.path.join(MODEL_DRIECTORY, "review_analyze_model_by_gru.pth")
    model.load_state_dict(torch.load(file_path))                                # 加载模型参数
    print("加载模型参数成功")

    model_evaluate(model, device, test_data_loader)

  模型预测:

import os

import torch
import jieba

def model_predict(model, device, text, word2index):
    model.to(device)                                                            # 指定设备

    tokens = jieba.lcut(text)                                                   # 切词
    indexes = [word2index.get(token, SPECIAL_TOKENS.index(UNK_TOKEN)) for token in tokens]
    # x.shape: [批量大小(1), 序列长度(len(indexes))]
    x = torch.tensor([indexes], dtype=torch.long, device=device)                # 输入数据

    results = model_predict_one_batch(model, x)

    return results[0]

def model_run_predict(model, device, word2index):
    print("欢迎使用评论情感分析模型,输入q或quit退出")

    while True:
        text = input("> ")
        if text.strip() in ["q", "quit"]:
            break

        if text.strip() == "":
            continue

        result = model_predict(model, device, text, word2index)
        print(f"输入的评论:{text}")
        print(f"模型预测的情感:{'正面' if result > 0.5 else '负面'}(置信度:{result if result > 0.5 else 1 - result})")

if __name__ == "__main__":
    embedding_dim = 128
    hidden_dim = 256
    num_layers = 1
    padding_idx = 1
    
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    # 加载词表
    file_path = os.path.join(DATA_DRIECTORY, "online_shopping_vocab.txt")
    with open(file_path, "r", encoding="utf-8") as f:
        vocab_list = [line.strip() for line in f.readlines()]

    word2index = {word: index for index, word in enumerate(vocab_list)}         # 构建词表到索引的映射
    print("构建词表到索引的映射成功")
    
    model = ReviewAnalyzeModel(len(vocab_list), embedding_dim, hidden_dim, num_layers, padding_idx).to(device)
    print("创建模型成功")
    
    file_path = os.path.join(MODEL_DRIECTORY, "review_analyze_model_by_gru.pth")
    model.load_state_dict(torch.load(file_path))                                # 加载模型参数
    print("加载模型参数成功")
    
    model_run_predict(model, device, word2index)
posted @ 2026-10-09 21:29  星光映梦  阅读(9)  评论(0)    收藏  举报