05. GRU模型
一、什么是 GRU 模型
Gate Recurrent Unit(GRU)是为了进一步简化 LSTM 结构,降低计算成本而提出的一种变体。GRU 保留了门控机制的核心思想,但相比 LSTM 而言,GRU 结构更为简洁,参数更少,训练效率更高。在许多实际任务中,GRU 能在保持类似性能的同时,显著减少训练时间。
与 LSTM 相比,GRU 作出了如下改进:
- 取消了 LSTM 中独立的记忆单元,只保留隐藏状态。
- 通过两个门控结构控制信息流动:更新门(Update Gate)和 重置门(Reset Gate)。
具体的结构如下所示:

【1】、重置门(Reset Gate)
重置门由上一个时间步的隐藏状态和当前时间步的输入计算得到。

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

当前时间步的信息(候选隐藏信息)的计算公式如下:
【2】、更新门(Update Gate)
更新门也由上一时间步的隐藏状态和当前时间步的输入计算得到。

它的计算公式如下:
更新门会在计算当前时间步最终的隐藏状态 \(h_{t}\) 时,分别作用于上一时刻的隐藏状态 \(h_{t - 1}\) 和当前新计算出的候选隐藏状态 \(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 下载源列表:
- 阿里云 https://mirrors.aliyun.com/pypi/simple
- 清华大学 https://pypi.tuna.tsinghua.edu.cn/simple
- 中国科学技术大学 https://pypi.mirrors.ustc.edu.cn/simple
安装完 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 同样支持多层结构,具体的结构如下所示:

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

2.3、多层加双向结构
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)

浙公网安备 33010602011771号