RNN及其变体

RNN及其变体

循环神经网络(RNN)及其变体是专门为处理序列数据而设计的一类神经网络。它们的核心思想是引入"记忆"机制,能够利用历史信息来影响当前的输出,这使其在自然语言处理、时间序列分析等领域非常有效。

循环神经网络 (RNN)

RNN 的设计灵感来源于人类认知的"记忆"机制。与传统神经网络假设输入样本相互独立不同,RNN 通过引入隐藏状态 (Hidden State),使得信息可以在时间步之间传递。

  • 核心结构 :RNN 在时间维度上具有自环结构。在每一个时间步 t,隐藏状态 h_t 不仅取决于当前的输入 x_t,还取决于上一时刻的隐藏状态 h_{t-1}
  • 参数共享:在所有时间步上,RNN 共享同一套权重参数,这极大地减少了模型参数量,并使其能够处理任意长度的序列。
  • 数学表达:
    • 隐藏状态更新:h_t = tanh(W_{xh} * x_t + W_{hh} * h_{t-1} + b_h)
    • 输出计算:y_t = W_{hy} * h_t + b_y
RNN 的分类
按照输入和输出的结构进行分类
  • N vs N - RNN

    它是RNN最基础的结构形式, 最大的特点就是:输入和输出序列是等长的。由于这个限制的存在, 使其适用范围比较小, 可用于生成等长度的合辙诗句。

  • N vs 1 - RNN

    有时候我们要处理的问题输入是一个序列,而要求输出是一个单独的值而不是序列,应该怎样建模呢?我们只要在最后一个隐层输出hhh上进行线性变换就可以了,大部分情况下,为了更好的明确结果, 还要使用sigmoid或者softmax进行处理。这种结构经常被应用在文本分类问题上。

  • 1 vs N - RNN

    如果输入不是序列而输出为序列的情况怎么处理呢?我们最常采用的一种方式就是使该输入作用于每次的输出之上. 这种结构可用于将图片生成文字任务等。

  • N vs M - RNN

    这是一种不限输入输出长度的RNN结构, 它由编码器和解码器两部分组成,两者的内部结构都是某类RNN,它也被称为seq2seq架构。 输入数据首先通过编码器, 最终输出一个隐含变量c, 之后最常用的做法是使用这个隐含变量c作用在解码器进行解码的每一步上, 以保证输入信息被有效利用。

    seq2seq架构最早被提出应用于机器翻译, 因为其输入输出不受限制,如今也是应用最广的RNN模型结构。 在机器翻译,阅读理解,文本摘要等众多领域都进行了非常多的应用实践。

按照RNN的内部构造进行分类
  • 传统RNN
  • LSTM
  • Bi-LSTM
  • GRU
  • Bi-GRU
🌰对RNN的运行过程进行简单的分析:
1. 输入准备:把文字变成数字

计算机看不懂 "The cat...",首先需要将这句话拆解成时间序列。假设我们以"单词"为单位进行切分,这句话就被分成了 6 个时间步(Timesteps):

  1. x1x_1x1 : "The"
  2. x2x_2x2: "cat"
  3. x3x_3x3 : "sat"
  4. x4x_4x4 : "on"
  5. x5x_5x5 : "the"
  6. x6x_6x6 : "mat"

在输入网络前,这些词会被转换成向量(比如 One-Hot 编码或 Word2Vec 向量)。

2. 核心运行过程:像滚雪球一样传递记忆

RNN 的核心在于它有一个隐藏状态(Hidden State, hhh ),你可以把它想象成网络的"短期记忆"。这个记忆会随着阅读的深入不断更新。

让我们一步步看网络是如何运行的:

初始状态 ( t=0t=0t=0 )

在还没开始读之前,网络的大脑是一片空白的。

  • 输入:无
  • 隐藏状态 ( h0{h_0}h0 ):通常初始化为全 0 向量。

第一步 ( t=1t=1t=1 ):读到 "The"

  • 输入 : x1x_1x1 ("The" 的向量)。

  • 记忆 : h0h_0h0 (初始空白记忆)。

  • 计算:RNN 单元结合当前的输入 x1x_1x1 和上一时刻的记忆

    h0h_0h0,计算出新的记忆 h1h_1h1 。

    • 公式逻辑 : h1=tanh⁡(W⋅x1+U⋅h0+b)h_1=tanh⁡(W⋅x_1+U⋅h_0+b)h1=tanh⁡(W⋅x1+U⋅h0+b)
  • 结果 : h1h_1h1 包含了 "The" 的信息。

第二步 ( t=2t=2t=2 ):读到 "cat"

  • 输入 : x2x_2x2 ("cat" 的向量)。
  • 记忆 : h1h_1h1 (刚才学到的 "The" 的信息)。
  • 计算:网络结合 "cat" 和 "The" 的上下文,更新记忆。
    • 公式逻辑 : h2=tanh⁡(W⋅x2+U⋅h1+b)h_2=tanh⁡(W⋅x_2+U⋅h_1+b)h2=tanh⁡(W⋅x2+U⋅h1+b)
  • 结果 : h2h_2h2 现在不仅仅包含 "cat",还隐含了 "The cat" 这个主语短语的信息。

第三步 ( t=3t=3t=3 ):读到 "sat"

  • 输入 : x3x_3x3 ("sat" 的向量)。
  • 记忆 : h2h_2h2 (包含了 "The cat" 的信息)。
  • 计算:网络理解了"谁"(The cat)做了"什么动作"(sat)。
    • 公式逻辑 : h3=tanh⁡(W⋅x3+U⋅h2+b)h_3=tanh⁡(W⋅x_3+U⋅h_2+b)h3=tanh⁡(W⋅x3+U⋅h2+b)
  • 结果 : h3h_3h3 包含了 "The cat sat" 的完整语义。

...以此类推,直到最后一个词。

最后一步 ( t=6t=6t=6 ):读到 "mat"

  • 输入 : x6x_6x6 ("mat" 的向量)。
  • 记忆 : h5h_5h5 (包含了 "The cat sat on the" 的所有历史信息)。
  • 计算 : h6=tanh⁡(W⋅x6+U⋅h5+b)h_6=tanh⁡(W⋅x_6+U⋅h_5+b)h6=tanh⁡(W⋅x6+U⋅h5+b)
  • 结果 :最终的隐藏状态 h6h_6h6 是一个浓缩的向量,它在数学上代表了整句话 "The cat sat on the mat" 的语义信息。
3. 输出:利用记忆做预测

RNN 处理完这句话后,通常会接一个任务,比如预测下一个词

  • 机制 :网络利用最后生成的隐藏状态h6h_6h6 ,通过一个输出层(通常是 Softmax 函数)来计算词汇表中每个词出现的概率。
  • 场景模拟:
    • 因为 h6{h_6}h6包含了"猫坐在...上"的语境,网络会预测下一个词可能是句号".",或者是"softly"(轻轻地)。
    • 如果句子是 "The cat sat on the...",网络会大概率预测 "mat" 或 "floor"。
4. 关键总结与局限性

通过 "The cat sat on the mat" 这个例子,我们可以看到 RNN 的两个核心特征:

  1. 参数共享 :注意上面的公式,无论是处理 "The" 还是 "mat",RNN 使用的权重矩阵 WWW 和 UUU 是同一套。这意味着无论句子多长,模型处理每个词的逻辑是一样的。
  2. 序列依赖 : h6h_6h6 的计算依赖于 h5h_5h5 , h5h_5h5 依赖于 h4h_4h4 ... 一直追溯到 h1h_1h1 。
  3. RNN 的痛点(梯度消失):
    如果句子变得非常长,例如:"The cat , which was playing with a ball, sat on the mat"。
    当 RNN 读到最后的 "mat" 时,它需要回忆起开头的 "cat" 来理解是谁坐在垫子上。但在层层传递( h1→h2→...→h10h1→h2→...→h10h1→h2→...→h10 )的过程中,关于 "cat" 的信息可能会逐渐变淡甚至消失(数学上表现为梯度消失)。这就是为什么后来出现了 LSTM(长短期记忆网络),专门用来解决这种"记性不好"的问题。
Pytorch中RNN工具的使用
python 复制代码
import torch
import torch.nn as nn

# --- 1. 数据准备 ---
# 我们的句子
sentence = "The cat sat on the mat"
words = sentence.split()
vocab = list(set(words)) # 去重构建词典
word_to_idx = {w: i for i, w in enumerate(vocab)}
idx_to_word = {i: w for i, w in enumerate(vocab)}

print(f"词典: {vocab}")
print(f"句子长度 (时间步): {len(words)}\n")

# 将句子转换为索引序列
# 例如: [0, 1, 2, 3, 0, 4] (假设 The=0, cat=1...)
input_indices = torch.tensor([word_to_idx[w] for w in words], dtype=torch.long)
# 为了模型输入,增加一个维度 (batch_size=1, seq_len=6)
input_indices = input_indices.unsqueeze(0) 

# --- 2. 定义 RNN 模型 ---
# 参数说明:
# input_size=10: 词典大小 (One-hot 维度)
# hidden_size=4: 隐藏层维度 (为了演示,设小一点,方便观察)
# batch_first=True: 输入格式为 (batch, seq, feature)
rnn = nn.RNN(input_size=10, hidden_size=4, batch_first=True)

# --- 3. 运行过程分析 ---
print("=== RNN 运行过程分析 ===")

# 将索引转换为 One-Hot 向量
# input_one_hot 形状: (1, 6, 10) -> (Batch, Seq_Len, Input_Size)
input_one_hot = torch.zeros(1, len(words), 10)
input_one_hot.scatter_(2, input_indices.unsqueeze(2), 1)

# 初始隐藏状态 h0
# 形状: (1, 4) -> (Num_Layers, Hidden_Size)
# 初始化为全0,代表"失忆"状态
h0 = torch.zeros(1, 4) 

print(f"初始隐藏状态 h0: {h0}")

# 前向传播
# output: 所有时间步的输出 (这里我们主要关注 hidden)
# hidden: 最后一个时间步的隐藏状态 (包含了整句的信息)
output, hidden = rnn(input_one_hot, h0)

print("-" * 30)

# --- 4. 逐步打印隐藏状态 (透视 RNN 的记忆) ---
# 注意:PyTorch 的 output 实际上就是每个时间步的 hidden state
# output 的形状是 (1, 6, 4),我们取第0个batch的数据
print("\n逐步隐藏状态变化 (模拟阅读过程):")
for t in range(len(words)):
    word = words[t]
    # 获取当前时间步的隐藏状态
    current_h = output[0, t, :]
    
    print(f"t={t+1} [读取: '{word}'] -> 隐藏状态: {current_h.detach().numpy()}")

print("-" * 30)
print(f"\n最终隐藏状态 (代表整句语义): \n{hidden.detach().numpy()}")
print(f"注意:最终状态与上面 t=6 的状态是一致的。")

# --- 5. 简单的预测演示 ---
# 假设我们要用最后的隐藏状态预测下一个词
# 这里只是演示逻辑,不训练模型,所以结果是随机的
print("\n=== 预测演示 ===")
# 将最终隐藏状态通过一个简单的线性层映射回词汇表大小
# 这模拟了 Softmax 之前的步骤
fc = nn.Linear(4, 10) 
logits = fc(hidden.squeeze(0)) # (1, 10)
probs = torch.softmax(logits, dim=1)

# 找出概率最大的词 (虽然没训练过,这里只是看流程)
predicted_idx = torch.argmax(probs, dim=1).item()
print(f"基于整句信息,模型预测下一个词可能是: {idx_to_word[predicted_idx]} (随机结果)")

output vs hidden

  • output:包含了每一个时间步的隐藏状态。如果你要做"每读一个词就预测下一个词"的任务(如语言模型),你会用到这个。
  • hidden:只包含最后一个时间步的状态。如果你要做"句子分类"(如判断这句话是褒义还是贬义),你只需要看这个最终状态。
RNN 输入和输出的张量形状

batch_first 参数决定了 PyTorch 中 RNN 输入和输出张量的维度顺序 ,特别是 batch_size(批次大小)和 seq_len(序列长度)这两个维度的位置。

两种模式的形状对比

假设你的数据参数如下:

  • batch_size = 32 (一次处理32个样本)
  • seq_len = 50 (每个样本有50个时间步)
  • input_size = 128 (每个时间步的特征维度)
  • hidden_size = 256 (隐藏层维度)
模式 输入张量形状 输出张量形状 说明
batch_first=False (默认模式) (seq_len, batch_size, input_size) [50, 32, 128] (seq_len, batch_size, hidden_size) [50, 32, 256] 序列长度在第0维,批次大小在第1维。这是 PyTorch 的数学计算习惯。
batch_first=True (常用模式) (batch_size, seq_len, input_size) [32, 50, 128] (batch_size, seq_len, hidden_size) [32, 50, 256] 批次大小在第0维,序列长度在第1维。这更符合大多数数据加载器的习惯。
🤔为什么需要注意这个区别?
  1. 数据加载器的兼容性
    PyTorch 的 DataLoader 默认生成的批次数据通常是 (batch_size, ...) 的格式。如果你直接使用这种数据输入到默认的 RNN (batch_first=False) 中,会报错或计算错误。
    • 解决方案 A (推荐) :在定义 RNN 时设置 batch_first=True,这样可以直接把 DataLoader 的数据喂进去,无需调整维度。
    • 解决方案 B :保持默认 batch_first=False,但在输入 RNN 之前,手动使用 .transpose(0, 1).permute(1, 0, 2) 交换数据的第0维和第1维。
  2. 隐藏状态的形状不受影响
    无论 batch_firstTrue 还是 False,RNN 返回的隐藏状态 h_n 的形状始终保持不变
    • 形状(num_layers, batch_size, hidden_size)
    • 它永远是以层数开头,批次在中间。

虽然 PyTorch 默认是 batch_first=False,但在实际工程(尤其是 NLP 任务)中,强烈建议设置 batch_first=True。这样可以避免频繁的维度转换操作,让代码逻辑更清晰,也更符合我们对"一批数据"的直观理解。

RNN 的局限性:梯度消失/爆炸

尽管 RNN 理论上可以捕捉序列中的长期依赖,但在实践中,当序列过长时,它会遇到一个致命问题:梯度消失 (Vanishing Gradient)梯度爆炸 (Exploding Gradient)

  • 原因:在通过时间反向传播(BPTT)算法进行训练时,梯度需要沿着时间步反向传播。这个过程涉及大量的矩阵连乘。如果连乘的值长期小于1,梯度会指数级衰减至接近0(梯度消失);如果长期大于1,梯度会指数级增长至无穷大(梯度爆炸)。
  • 后果:梯度消失导致网络无法学习到距离较远的时间步之间的依赖关系,即"记不住太久远的信息"。

长短期记忆网络 (LSTM)

为了解决 RNN 的梯度消失问题,长短期记忆网络(LSTM)被提出。它的核心设计思想是引入一个贯穿整个序列的细胞状态 (Cell State),作为信息流动的"高速公路",并设计了精巧的"门控机制"来控制信息的流动。

  • 细胞状态 (Cell State, C_t):类似于一条传送带,信息可以在上面几乎无损地长距离传递,这从根本上缓解了梯度消失问题。
  • 门控机制 (Gate Mechanism):LSTM 通过三个"门"来精确控制细胞状态,每个门都是一个 Sigmoid 神经网络层,输出0到1之间的值,代表"完全阻断"到"完全通过"的程度。
LSTM 的三个门
  1. 遗忘门 (Forget Gate)
    • 作用 :决定从上一时刻的细胞状态 C_{t-1} 中丢弃哪些信息。它读取 h_{t-1}x_t,输出一个0到1之间的向量 f_t,与 C_{t-1} 逐元素相乘。
    • 公式f_t = sigmoid(W_f * [h_{t-1}, x_t] + b_f)
  2. 输入门 (Input Gate)
    • 作用:决定哪些新信息将被添加到细胞状态中。这个过程分为两步:
      • 首先,一个 Sigmoid 层(输入门 i_t)决定需要更新哪些值。
      • 然后,一个 tanh 层创建一个新的候选值向量 C̃_t,包含可能被加入的新信息。
    • 公式i_t = sigmoid(W_i * [h_{t-1}, x_t] + b_i)C̃_t = tanh(W_C * [h_{t-1}, x_t] + b_C)
  3. 细胞状态更新
    • 作用 :这是 LSTM 的核心。它将旧的细胞状态 C_{t-1} 与遗忘门 f_t 相乘,忘记不需要的信息;然后将新的候选值 C̃_t 与输入门 i_t 相乘,加入新的信息。
    • 公式C_t = f_t * C_{t-1} + i_t * C̃_t
  4. 输出门 (Output Gate)
    • 作用 :基于更新后的细胞状态 C_t,决定输出哪些信息作为当前的隐藏状态 h_t
    • 公式o_t = sigmoid(W_o * [h_{t-1}, x_t] + b_o)h_t = o_t * tanh(C_t)
PyTorch 中LSTM 的标准用法

这个例子演示了如何用 LSTM 处理句子 "The cat sat on the mat",并观察其内部状态的变化。

python 复制代码
import torch
import torch.nn as nn

# --- 1. 数据准备 ---
sentence = "The cat sat on the mat"
words = sentence.split()
vocab = list(set(words))
word_to_idx = {w: i for i, w in enumerate(vocab)}

# 将句子转换为索引序列
input_indices = torch.tensor([word_to_idx[w] for w in words], dtype=torch.long)
# 增加 batch 维度 (Batch_Size=1, Seq_Len=6)
input_indices = input_indices.unsqueeze(0) 

# --- 2. 定义 LSTM 模型 ---
# 参数说明:
# input_size=10: 输入特征维度 (即 One-Hot 的词汇表大小)
# hidden_size=5: 隐藏层维度 (LSTM 内部向量的大小)
# batch_first=True: 输入格式为 (batch, seq, feature)
lstm = nn.LSTM(input_size=10, hidden_size=5, batch_first=True)

# --- 3. 数据预处理 (One-Hot) ---
# 将索引转换为 One-Hot 向量
# 形状: (1, 6, 10) -> (Batch, Seq_Len, Input_Size)
input_one_hot = torch.zeros(1, len(words), 10)
# scatter_ 是一个高级索引操作,用于填充 One-Hot
input_one_hot.scatter_(2, input_indices.unsqueeze(2), 1)

# --- 4. 初始化隐藏状态 ---
# LSTM 有两个状态需要初始化:
# h_0: 隐藏状态 (Short-term memory)
# c_0: 细胞状态 (Long-term memory)
# 形状都是 (Num_Layers, Batch_Size, Hidden_Size)
h0 = torch.zeros(1, 1, 5)
c0 = torch.zeros(1, 1, 5)

print(f"输入形状: {input_one_hot.shape}")
print(f"初始隐藏状态 h0 形状: {h0.shape}")
print(f"初始细胞状态 c0 形状: {c0.shape}\n")

# --- 5. 前向传播 ---
# 将输入和初始状态传入 LSTM
# output: 所有时间步的输出特征
# (h_n, c_n): 最后一个时间步的隐藏状态和细胞状态
output, (h_n, c_n) = lstm(input_one_hot, (h0, c0))

# --- 6. 结果分析 ---
print("=== LSTM 运行结果分析 ===")

# 1. 观察输出 output
# output 包含了每个时间步的隐藏状态输出
# 形状: (Batch, Seq_Len, Hidden_Size) -> (1, 6, 5)
print(f"输出序列形状: {output.shape}")
print(f"第1个时间步 ('The') 的输出向量:\n {output[0, 0, :]}")
print(f"最后1个时间步 ('mat') 的输出向量:\n {output[0, -1, :]}")

# 2. 观察最终状态 h_n 和 c_n
# h_n 是最后的隐藏状态,c_n 是最后的细胞状态(长期记忆)
# 它们应该与 output 的最后一个时间步(经过处理后)是一致的
print("-" * 30)
print(f"最终隐藏状态 h_n:\n {h_n.squeeze(0)}")
print(f"最终细胞状态 c_n (LSTM特有的长期记忆):\n {c_n.squeeze(0)}")

# 3. 验证一致性
# output 的最后一个时间步应该等于 h_n (对于单层 LSTM)
is_same = torch.allclose(output[:, -1, :], h_n.squeeze(0))
print(f"\n验证: output的最后一帧是否等于 h_n? {is_same}")
代码关键点解析

双状态机制

与普通 RNN 不同,LSTM 在传递信息时有两个"管道":

  • h (隐藏状态):类似于普通 RNN 的状态,负责短期的上下文传递。
  • c (细胞状态):这是 LSTM 的"高速公路"。它像一条传送带,贯穿整个序列,让信息可以直接从开头传到结尾,不容易丢失。这就是为什么 LSTM 能记住长句子的原因。

在代码中,你需要同时初始化这两个状态:

python 复制代码
# (h0, c0) 是一个元组
hidden_state = (torch.zeros(1, 1, 5), torch.zeros(1, 1, 5))

输入输出形状

  • 输入:

    复制代码
    (Batch_Size, Seq_Len, Input_Size)
    • 在我们的例子中是 (1, 6, 10),表示 1 句话,6 个词,每个词是 10 维的 One-Hot。
  • 输出:

    • output: (Batch_Size, Seq_Len, Hidden_Size)。如果你要做序列标注(如分词、词性标注),用这个。
    • h_n: (Num_Layers, Batch_Size, Hidden_Size)。如果你要做句子分类(如情感分析),通常取这个最后的隐藏状态。
进阶:如何构建多层 LSTM?

在实际应用中,为了提取更深层的特征,我们通常会堆叠多层 LSTM。这非常简单,只需修改 num_layers 参数。

python 复制代码
# 构建一个 2 层的 LSTM
# 第1层处理输入,第2层处理第1层的输出
lstm_deep = nn.LSTM(input_size=10, hidden_size=5, num_layers=2, batch_first=True)

# 此时初始化状态需要匹配层数
# h0 形状变为 (2, 1, 5),c0 形状变为 (2, 1, 5)
h0 = torch.zeros(2, 1, 5)
c0 = torch.zeros(2, 1, 5)

output, (h_n, c_n) = lstm_deep(input_one_hot, (h0, c0))
  • 核心区别 :LSTM 比 RNN 多了一个 c_state(细胞状态),它是 LSTM 拥有"长记忆"的关键。
  • 使用场景:如果你发现普通 RNN 在处理长句子时效果不好(前面的词记不住了),请毫不犹豫地切换到 LSTM 或 GRU。

双向长短期记忆网络 (BiLSTM)

标准的 LSTM 只能按照时间正序(从左到右)处理序列。然而,在许多任务中(如词性标注、命名实体识别),一个词的语义不仅依赖于上文,也受到下文的影响。

  • 核心思想 :BiLSTM 通过同时处理序列的正向反向信息,来获取当前时刻的完整上下文信息。
  • 结构:它由两个独立的 LSTM 网络并行构成:
    • 前向网络 (Forward Pass):按自然顺序处理序列,捕捉过去的上下文。
    • 后向网络 (Backward Pass):按逆序处理序列,捕捉未来的上下文。
  • 输出 :在每个时间步 t,将前向和后向 LSTM 的隐藏状态拼接起来,作为该时间步的最终输出。
BiLSTM 的优势

BiLSTM 由两个独立的 LSTM 层组成:

  1. 前向层 :从左往右读( t=1→t=nt=1→t=nt=1→t=n ),捕捉"过去"的信息。
  2. 后向层 :从右往左读( t=n→t=1t=n→t=1t=n→t=1 ),捕捉"未来"的信息。

在每一个时间步,BiLSTM 会将这两个方向的隐藏状态拼接起来。这意味着,对于句子中的任何一个词,模型都能同时看到它前面的词和后面的词,从而获得完整的上下文语境。

适用场景

  • 适合:文本分类、命名实体识别、情感分析、完形填空(因为我们可以看到整句话)。
  • 不适合:实时语音识别、实时翻译(因为未来的信息还没发生,无法获取)。
PyTorch 代码实现

在 PyTorch 中,构建 BiLSTM 非常简单,只需要在 nn.LSTM 中设置 bidirectional=True 即可。

python 复制代码
import torch
import torch.nn as nn

# --- 1. 定义 BiLSTM 模型 ---
class BiLSTMClassifier(nn.Module):
    def __init__(self, input_size, hidden_size, num_layers, num_classes):
        super(BiLSTMClassifier, self).__init__()
        self.hidden_size = hidden_size
        self.num_layers = num_layers
        
        # 核心:设置 bidirectional=True
        self.lstm = nn.LSTM(input_size, hidden_size, num_layers, 
                            batch_first=True, bidirectional=True)
        
        # 注意:全连接层的输入维度要乘以 2,因为是双向的
        self.fc = nn.Linear(hidden_size * 2, num_classes)

    def forward(self, x):
        # x 形状: (Batch, Seq_Len, Input_Size)
        
        # 初始化隐藏状态 (层数 * 2, Batch, Hidden_Size)
        h0 = torch.zeros(self.num_layers * 2, x.size(0), self.hidden_size)
        c0 = torch.zeros(self.num_layers * 2, x.size(0), self.hidden_size)
        
        # 前向传播
        # out 形状: (Batch, Seq_Len, Hidden_Size * 2)
        out, (hn, cn) = self.lstm(x, (h0, c0))
        
        # 解码:通常取最后一个时间步的输出,或者对所有时间步取平均
        # 这里取最后一个时间步作为句子的特征表示
        # out[:, -1, :] 形状: (Batch, Hidden_Size * 2)
        out = self.fc(out[:, -1, :])
        return out

# --- 2. 模拟数据与运行 ---
# 参数设置
input_size = 10   # 词汇表大小
hidden_size = 128 # 隐藏层维度
num_layers = 2    # 层数
num_classes = 2   # 分类数 (如:正面/负面)
seq_length = 5    # 句子长度
batch_size = 4    # 批次大小

# 实例化模型
model = BiLSTMClassifier(input_size, hidden_size, num_layers, num_classes)

# 随机输入数据 (模拟 One-Hot 向量)
# 形状: (Batch, Seq_Len, Input_Size)
input_data = torch.randn(batch_size, seq_length, input_size)

# 前向传播
output = model(input_data)

print(f"输入形状: {input_data.shape}")
print(f"输出形状: {output.shape}")
print(f"输出结果 (Logits): \n{output}")
关键细节解析

维度变化(最容易出错的地方)

组件 普通 LSTM 双向 LSTM (BiLSTM) 说明
LSTM 输出 (Batch, Seq, Hidden) (Batch, Seq, Hidden * 2) 输出向量拼接了前向和后向的信息
隐藏状态 h0 (Layers, Batch, Hidden) (Layers * 2, Batch, Hidden) 需要为前向和后向分别初始化状态
全连接层输入 Hidden Hidden * 2 后续接全连接层时,输入维度必须翻倍

内部结构图解

当输入一个序列 x1,x2,...,x5x_1,x_2,...,x_5x1,x2,...,x5 时,BiLSTM 的内部运作如下:

  1. 前向传播 :计算 h1⃗,h2⃗,...,h5⃗\vec{h_1}, \vec{h_2}, ..., \vec{h_5}h1 ,h2 ,...,h5
  2. 后向传播 :计算 h←5,h←4,...,h←1\overleftarrow{h}_5, \overleftarrow{h}_4, ..., \overleftarrow{h}_1h 5,h 4,...,h 1
  3. 合并 :在时刻 t=3t=3t=3 ,最终的输出是 [h3⃗,h3←][\vec{h_3},\overleftarrow{h_3}][h3 ,h3 ]] 的拼接。
进阶:BiLSTM + Attention

在实际的高阶应用中,BiLSTM 常与 Attention 机制 结合使用。

  • 为什么结合?
    简单的 BiLSTM 只取最后一个时间步的输出(out[:, -1, :])来代表整句话,这可能会丢失中间的一些细节信息。
  • Attention 的作用
    Attention 机制会对 BiLSTM 输出的所有时间步out)进行加权求和,让模型自动"关注"句子中对分类最重要的词(比如情感分析中的"好"、"坏"),从而进一步提升准确率。
总结
  • BiLSTM = 前向 LSTM + 后向 LSTM。
  • 核心优势:利用全句上下文信息,特征提取能力更强。
  • 代码关键bidirectional=True,且后续全连接层的输入维度要 * 2
  • 地位:在 BERT 等 Transformer 模型普及之前,BiLSTM (+ Attention) 是 NLP 任务(如情感分析、命名实体识别)的绝对主流模型。

门控循环单元 (GRU)

GRU 是 LSTM 的一个简化变体,它在保持与 LSTM 相似性能的同时,结构更简单,参数更少,训练速度也更快。

核心原理

简化之处:

  • 将 LSTM 的遗忘门输入门 合并为一个更新门 (Update Gate, z_t),同时控制信息的遗忘和写入。
  • 移除了独立的细胞状态 C_t,将隐藏状态 h_t 同时用作记忆和输出。

两个门:

  • 重置门 (Reset Gate, r_t) :控制上一时刻的隐藏状态 h_{t-1} 对当前候选状态的影响程度,有助于捕捉短期依赖。负责"遗忘"。

    • 作用 :决定丢弃多少过去的信息。

    • 直觉:当你读到一个新的话题(比如句子中的"但是"),重置门会判断之前的记忆是否还有用。如果没用,它就把之前的记忆"重置"掉,让你专注于当前的输入。

    • 数学表达

      rt=σ(Wr⋅[ht−1,xt])r_t = \sigma(W_r \cdot [h_{t-1}, x_t])rt=σ(Wr⋅[ht−1,xt])

      rtr_trt 接近 0 时,表示忽略过去的状态 ht−1h_{t-1}ht−1

  • 更新门 (z_t) :负责"记忆"。

    • 作用 :决定保留 多少过去的信息,以及写入多少新的信息。

    • 直觉:这相当于 LSTM 中的"遗忘门"和"输入门"的结合体。如果更新门的值接近 1,说明当前信息很重要,要一直记在脑子里(长期记忆);如果接近 0,说明可以翻篇了。

    • 数学表达

      zt=σ(Wz⋅[ht−1,xt])z_t = \sigma(W_z \cdot [h_{t-1}, x_t])zt=σ(Wz⋅[ht−1,xt])

      ht=(1−zt)⊙ht−1+zt⊙h~th_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_tht=(1−zt)⊙ht−1+zt⊙h~t

      ztz_tzt 控制着旧状态 ht−1h_{t-1}ht−1 和候选新状态 h~t\tilde{h}_th~t 的混比例

形象比喻

GRU 就像一个智能过滤器

  • 重置门说:"前面的剧情讲完了,那些细节不用记了,翻篇吧。"
  • 更新门说:"这句话里的'凶手是管家'很重要,一定要刻在脑子里,一直传到文章结尾。"
GRU vs. LSTM:选谁更好?

GRU 是 LSTM 的简化版,两者的对比如下表:

特性 GRU LSTM
门控数量 2 个(重置门、更新门) 3 个(遗忘门、输入门、输出门)
内部状态 只有隐藏状态 ( hth_tht ) 隐藏状态 ( hth_tht ) 和 细胞状态 ( ctc_tct )
参数量 较少(计算更快,训练更高效) 较多(计算开销大)
性能表现 在中小数据集上往往表现更好,收敛更快 在极长序列或超大数据集上可能略强
复杂度 简单,代码更易实现 复杂,逻辑较繁琐

结论

  • 如果你需要快速训练 ,或者数据量不是特别巨大,首选 GRU
  • 如果你对超长序列(如几千个词的文档)的建模要求极高,可以尝试 LSTM。
PyTorch 代码实现

在 PyTorch 中,使用 GRU 和使用 LSTM 几乎一模一样,只需要把 nn.LSTM 换成 nn.GRU 即可。

python 复制代码
import torch
import torch.nn as nn

# --- 1. 定义 GRU 模型 ---
class GRUModel(nn.Module):
    def __init__(self, input_size, hidden_size, num_layers, num_classes):
        super(GRUModel, self).__init__()
        self.hidden_size = hidden_size
        self.num_layers = num_layers
        
        # 核心:将 LSTM 替换为 GRU
        # input_size: 输入特征维度
        # hidden_size: 隐藏层维度
        # num_layers: 层数
        # batch_first=True: 输入格式为 (batch, seq, feature)
        self.gru = nn.GRU(input_size, hidden_size, num_layers, batch_first=True)
        
        # 全连接层
        self.fc = nn.Linear(hidden_size, num_classes)

    def forward(self, x):
        # x 形状: (Batch, Seq_Len, Input_Size)
        
        # 初始化隐藏状态 h0
        # 形状: (Num_Layers, Batch_Size, Hidden_Size)
        h0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size)
        
        # 前向传播
        # GRU 只返回 output 和 h_n,没有 c_n (细胞状态)!
        # out 形状: (Batch, Seq_Len, Hidden_Size)
        out, h_n = self.gru(x, h0)
        
        # 解码:取最后一个时间步的输出进行分类
        # out[:, -1, :] 形状: (Batch, Hidden_Size)
        out = self.fc(out[:, -1, :])
        return out

# --- 2. 模拟数据与运行 ---
# 参数设置
input_size = 10   # 词汇表大小
hidden_size = 128 # 隐藏层维度
num_layers = 2    # 层数
num_classes = 2   # 分类数
seq_length = 5    # 句子长度
batch_size = 4    # 批次大小

# 实例化模型
model = GRUModel(input_size, hidden_size, num_layers, num_classes)

# 随机输入数据
input_data = torch.randn(batch_size, seq_length, input_size)

# 前向传播
output = model(input_data)

print(f"输入形状: {input_data.shape}")
print(f"输出形状: {output.shape}")
print("GRU 模型运行成功!")
关键细节总结

状态初始化

LSTM 需要初始化两个状态 (h0, c0),而 GRU 只需要初始化一个状态 h0

python 复制代码
# LSTM: h0, c0 = torch.zeros(...), torch.zeros(...)
# GRU:
h0 = torch.zeros(num_layers, batch_size, hidden_size)

输出返回值
nn.GRU 的返回值是 (output, h_n),没有细胞状态 c_n

双向 GRU

和 BiLSTM 一样,GRU 也可以变成双向的。只需设置 bidirectional=True

python 复制代码
self.gru = nn.GRU(input_size, hidden_size, num_layers, bidirectional=True, batch_first=True)
# 注意:此时全连接层的输入维度也要乘以 2 (hidden_size * 2)

GRU 是深度学习中的"实用主义者"。它保留了 LSTM 处理长依赖的能力,却砍掉了复杂的细胞状态,用更少的参数实现了同样优秀的效果。在实际工程中,GRU 往往是替代 LSTM 的首选方案。

模型选择指南

不同的 RNN 变体各有优劣,适用于不同的场景。

模型 参数规模 训练速度 适用场景
传统 RNN 短序列任务(如简单的词性标注)
LSTM 需要捕捉长序列依赖的任务(如机器翻译)
GRU 资源受限场景(如移动端NLP)
BiRNN/BiLSTM 翻倍 需要完整上下文信息的任务(如命名实体识别)

🌰人名分类案例

以一个人名为输入, 使用模型帮助我们判断它最有可能是来自哪一个国家的人名, 这在某些国际化公司的业务中具有重要意义, 在用户注册过程中, 会根据用户填写的名字直接给他分配可能的国家或地区选项, 以及该国家或地区的国旗, 限制手机号码位数等

数据格式说明 每一行第一个单词为人名,第二个单词为国家名。

tex 复制代码
Huffmann    German
Hummel  German
Hummel  German
Hutmacher   German
Ingersleben German
Jaeger  German
Jager   German
Deng    Chinese
Ding    Chinese
Dong    Chinese
Dou Chinese
Duan    Chinese
Eng Chinese
Fan Chinese
Fei Chinese
Abaimov Russian
Abakeliya   Russian
Abakovsky   Russian
Abakshin    Russian
Abakumoff   Russian
Abakumov    Russian
Abakumtsev  Russian
Abakushin   Russian
Abalakin    Russian
PyTorch代码
python 复制代码
# 第一步:导入必备的工具包
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import numpy as np
import os
from collections import Counter
import matplotlib.pyplot as plt


# 第二步:对数据文件中的数据进行处理
# --- 2.1 读取数据 ---
def load_data(file_path):
    names = []
    categories = []
    with open(file_path, 'r', encoding='utf-8') as f:
        for line in f:
            line = line.strip()
            if line:
                name, category = line.split('\t')
                names.append(name)
                categories.append(category)
    return names, categories

# 假设数据文件在当前目录的 data 文件夹下
data_path = 'data/name_classification.txt' 
# 如果文件不存在,请确保路径正确
if not os.path.exists(data_path):
    print(f"错误:找不到数据文件 {data_path}。请检查路径。")
    # 为了演示,我们创建一个假数据
    names = ["Huffmann", "Deng", "Abaimov", "Smith", "Garcia", "Ivanov", "Wang", "Li", "Muller"]
    categories = ["German", "Chinese", "Russian", "English", "Spanish", "Russian", "Chinese", "Chinese", "German"]
else:
    names, categories = load_data(data_path)

# --- 2.2 构建词汇表和类别映射 ---
# 1. 字符级词汇表 (因为人名是短序列,字符级处理效果通常很好)
# 我们使用一个特殊的字符来表示序列的开始和结束,以及未知字符
START_TOKEN = '<S>'
END_TOKEN = '<E>'
UNK_TOKEN = '<U>'

# 统计所有字符
all_chars = START_TOKEN + END_TOKEN + UNK_TOKEN
for name in names:
    all_chars += name

# 创建字符到索引的映射
char_to_idx = {char: idx for idx, char in enumerate(sorted(set(all_chars)))}
idx_to_char = {idx: char for char, idx in char_to_idx.items()}
vocab_size = len(char_to_idx)
print(f"词汇表大小: {vocab_size}")

# 2. 类别映射
category_to_idx = {cat: idx for idx, cat in enumerate(sorted(set(categories)))}
idx_to_category = {idx: cat for cat, idx in category_to_idx.items()}
num_categories = len(category_to_idx)
print(f"类别数量: {num_categories}")

# --- 2.3 创建 PyTorch Dataset ---
class NameDataset(Dataset):
    def __init__(self, names, categories, char_to_idx, category_to_idx):
        self.names = names
        self.categories = categories
        self.char_to_idx = char_to_idx
        self.category_to_idx = category_to_idx
        
    def __len__(self):
        return len(self.names)
    
    def __getitem__(self, idx):
        name = self.names[idx]
        category = self.categories[idx]
        
        # 将人名转换为索引序列
        # 添加 START 和 END token
        name_with_tokens = START_TOKEN + name + END_TOKEN
        name_indices = [self.char_to_idx.get(char, self.char_to_idx[UNK_TOKEN]) for char in name_with_tokens]
        
        # 转换为 PyTorch 张量
        name_tensor = torch.tensor(name_indices, dtype=torch.long)
        category_tensor = torch.tensor(self.category_to_idx[category], dtype=torch.long)
        
        return name_tensor, category_tensor

# 创建数据集和数据加载器
dataset = NameDataset(names, categories, char_to_idx, category_to_idx)
# 为了训练,我们通常会将数据分为训练集和测试集,这里简化处理,使用全部数据
train_loader = DataLoader(dataset, batch_size=32, shuffle=True)


# 第三步:构建 RNN 模型

# --- 3.1 传统 RNN 模型 ---
class RNNModel(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim, n_layers=1):
        super(RNNModel, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.rnn = nn.RNN(embedding_dim, hidden_dim, n_layers, batch_first=True)
        self.fc = nn.Linear(hidden_dim, output_dim)
        
    def forward(self, x):
        # x shape: (batch_size, seq_len)
        embedded = self.embedding(x) # (batch_size, seq_len, embedding_dim)
        
        # RNN 输出所有时间步的隐藏状态和最后一个时间步的隐藏状态
        # output, hidden = self.rnn(embedded)
        # 我们只关心最后一个时间步的隐藏状态来进行分类
        _, hidden = self.rnn(embedded) # hidden shape: (n_layers, batch_size, hidden_dim)
        
        # 取出最后一层的隐藏状态
        hidden = hidden[-1] # (batch_size, hidden_dim)
        
        output = self.fc(hidden) # (batch_size, output_dim)
        return output

# --- 3.2 LSTM 模型 ---
class LSTMModel(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim, n_layers=1):
        super(LSTMModel, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.lstm = nn.LSTM(embedding_dim, hidden_dim, n_layers, batch_first=True)
        self.fc = nn.Linear(hidden_dim, output_dim)
        
    def forward(self, x):
        embedded = self.embedding(x)
        # LSTM 返回 (output, (h_n, c_n))
        _, (hidden, _) = self.lstm(embedded)
        hidden = hidden[-1] # 取最后一层的隐藏状态
        output = self.fc(hidden)
        return output

# --- 3.3 GRU 模型 ---
class GRUModel(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim, n_layers=1):
        super(GRUModel, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.gru = nn.GRU(embedding_dim, hidden_dim, n_layers, batch_first=True)
        self.fc = nn.Linear(hidden_dim, output_dim)
        
    def forward(self, x):
        embedded = self.embedding(x)
        # GRU 返回 (output, h_n)
        _, hidden = self.gru(embedded)
        hidden = hidden[-1] # 取最后一层的隐藏状态
        output = self.fc(hidden)
        return output

# --- 3.4 模型超参数 ---
EMBEDDING_DIM = 64
HIDDEN_DIM = 128
OUTPUT_DIM = num_categories
N_LAYERS = 2

# 实例化模型 (这里以 LSTM 为例,你可以尝试 RNNModel 或 GRUModel)
model = LSTMModel(vocab_size, EMBEDDING_DIM, HIDDEN_DIM, OUTPUT_DIM, N_LAYERS)
print(model)

# 第四步:构建训练函数并进行训练
# --- 4.1 定义损失函数和优化器 ---
criterion = nn.CrossEntropyLoss() # 适用于多分类问题
optimizer = optim.Adam(model.parameters(), lr=0.001)

# --- 4.2 训练函数 ---
def train(model, data_loader, criterion, optimizer, n_epochs=10):
    model.train()
    for epoch in range(n_epochs):
        total_loss = 0
        correct = 0
        total = 0
        
        for batch_idx, (names, categories) in enumerate(data_loader):
            # names shape: (batch_size, seq_len)
            # categories shape: (batch_size,)
            
            optimizer.zero_grad() # 清空梯度
            
            # 前向传播
            outputs = model(names)
            
            # 计算损失
            loss = criterion(outputs, categories)
            
            # 反向传播和优化
            loss.backward()
            optimizer.step()
            
            total_loss += loss.item()
            
            # 计算准确率
            _, predicted = torch.max(outputs.data, 1)
            total += categories.size(0)
            correct += (predicted == categories).sum().item()
            
        epoch_loss = total_loss / len(data_loader)
        epoch_acc = correct / total
        print(f'Epoch [{epoch+1}/{n_epochs}], Loss: {epoch_loss:.4f}, Accuracy: {epoch_acc:.4f}')

# --- 4.3 开始训练 ---
# 注意:由于数据量可能很小,这里只训练 10 个 epoch
train(model, train_loader, criterion, optimizer, n_epochs=10)

# 第五步:构建预测函数并进行预测
# --- 5.1 预测函数 ---
def predict(model, name, char_to_idx, idx_to_category):
    model.eval()
    with torch.no_grad():
        # 准备输入数据
        name_with_tokens = START_TOKEN + name + END_TOKEN
        name_indices = [char_to_idx.get(char, char_to_idx[UNK_TOKEN]) for char in name_with_tokens]
        name_tensor = torch.tensor([name_indices], dtype=torch.long) # 添加 batch 维度
        
        # 进行预测
        output = model(name_tensor)
        
        # 获取概率最大的类别
        probability = torch.nn.functional.softmax(output, dim=1)
        predicted_prob, predicted_idx = torch.max(probability, 1)
        
        predicted_category = idx_to_category[predicted_idx.item()]
        confidence = predicted_prob.item()
        
        return predicted_category, confidence

# --- 5.2 测试预测 ---
test_names = ["Huffmann", "Deng", "Abaimov", "Smith", "Garcia", "Ivanov", "Wang", "Li", "Muller"]

print("\n--- 预测结果 ---")
for name in test_names:
    category, confidence = predict(model, name, char_to_idx, idx_to_category)
    print(f"人名: {name:<10} -> 预测国家: {category:<10} (置信度: {confidence:.4f})")
相关推荐
xixixi777771 小时前
深度解读:网信办“清朗·整治AI应用乱象”专项行动,AI产业告别野蛮生长,全面迈入合规治理深水区
人工智能·安全·ai·大模型·合规·深度伪造·网信办
TechubNews1 小时前
AI 又一次成了「体面理由」:从 Coinbase 裁员 14% 看 Web3 的现实困局
人工智能·web3
摆烂大大王1 小时前
AI 日报 | 2026年5月6日 — DeepSeek V4 持续发酵、Cursor 3 重构IDE形态、全球AI监管加速
人工智能
码途漫谈2 小时前
UI-UX-Pro-Max开源项目介绍
人工智能·ui·ai·开源·ai编程·ux
Panzer_Jack2 小时前
Copiwaifu:一个和 Claude Code、Codex、Copilot 等 AI 编程工具联动的 Live2D 桌宠[特殊字符]
前端·人工智能·copilot·web·live2d·pixi.js·easy-live2d
卷卷说风控2 小时前
【卷卷观察】硅谷要把AI数据中心扔进海里,这操作我服了
人工智能·深度学习
保卫大狮兄2 小时前
一文讲清:仓库管理最核心的10个公式
人工智能·算法·仓库管理
果汁华2 小时前
OpenClaw:36 万 Star 的个人 AI 助手,龙虾之道
人工智能
程序员小白条2 小时前
别盲目卷算法!2026 程序员\&大学生,最稳的 AI 技术进阶路线全梳理
java·网络·人工智能·网络协议·http·面试