Skip to main content

循环神经网络RNN

RNN基本原理

想象一下你在读一句话,或者听别人说话。

  • 你的大脑是怎么工作的?
    • 当你读到或听到一个词时,你不仅仅是理解这个词本身的意思,你还会结合前面已经读过或听过的词来理解当前这个词在整个句子中的含义。
    • 比如,听到“我今天感觉很...”,你大脑里会根据前面的“我今天感觉很”来预测后面可能出现的词,比如“开心”、“难过”、“累”等等。
    • 你的大脑里似乎有一个**“短期记忆”**,它保存了你刚刚处理过的信息,并用这些信息来帮助理解接下来的内容。

RNN 就是想模拟这种“带有记忆”的处理方式。

普通神经网络 (前馈神经网络) 的局限:

  • 想象一个普通的图像识别神经网络,它看到一张猫的图片,然后输出“猫”。它看到一张狗的图片,输出“狗”。
  • 它处理每个输入都是独立的,它不会记住之前看过的图片是什么。它没有“上下文”的概念。
  • 这种网络不适合处理像句子这样的序列数据,因为句子中词语的顺序和上下文非常重要。

RNN 的核心特点:“循环”与“记忆”

RNN 的神奇之处在于它有一个**“循环” (Recurrent)** 的结构,这个结构让它能够拥有类似“短期记忆”的能力。

  1. 处理序列中的每个元素:

    • RNN 会一个接一个地处理序列中的元素(比如句子中的每个词,或者时间序列中的每个数据点)。
  2. “隐藏状态” (Hidden State) —— 扮演短期记忆的角色:

    • 在处理每个元素时,RNN 不仅仅看当前的输入,它还会参考一个叫做**“隐藏状态” (Hidden State)** 的东西。
    • 这个“隐藏状态”可以看作是 RNN 到目前为止处理过的所有前面元素的**“摘要”或“记忆”**。
  3. 循环更新记忆:

    • 当 RNN 处理完当前这个词(比如“感觉”)后,它会:
      • 根据当前的词 (“感觉”)上一时刻的“短期记忆” (比如对“我今天”的记忆) 来更新它的“短期记忆”,形成一个新的“短期记忆” (比如对“我今天感觉”的记忆)。
      • 同时,它可能还会根据当前的词和更新后的“短期记忆”来做一个输出 (比如预测下一个词可能是什么,或者对当前词进行某种分类)。
    • 这个新的“短期记忆” 会被传递到处理序列中下一个元素(比如“很”)的时候使用。
  4. 参数共享:

    • 重要的是,RNN 在处理序列中不同位置的元素时,使用的是同一套“规则”或“参数”(权重)。这意味着它学习到的处理方式是通用的,可以应用于序列的不同部分。就像你用同一种语法规则去理解句子的不同部分一样。

简单概括 RNN 的工作流程:

想象 RNN 是一个小机器人,它在读一个单词列表: "我", "爱", "你"

  1. 读第一个词 "我":

    • 机器人看到 "我"。
    • 因为它刚开始,没有之前的“记忆”,所以它根据 "我" 更新了自己的“记忆”(比如,记住了句子的主语是“我”)。
    • 它可能还会输出一些东西(取决于具体任务)。
  2. 读第二个词 "爱":

    • 机器人看到 "爱"。
    • 它会结合当前的词 "爱"它对 "我" 的“记忆”,来更新它的“记忆”(比如,现在记住了“我爱...”)。
    • 它可能又输出一些东西。
  3. 读第三个词 "你":

    • 机器人看到 "你"。
    • 它会结合当前的词 "你"它对 "我爱" 的“记忆”,再次更新它的“记忆”(比如,现在记住了整个句子“我爱你”)。
    • 它可能又输出一些东西。

这个过程中,“记忆”(隐藏状态)不断地被新的输入所更新,并影响着对后续输入的处理和模型的输出。这就是“循环”的含义——信息在网络内部循环流动,并不断更新状态。

RNN 的优点:

  • 能够处理变长的序列数据。
  • 能够捕捉到序列中的短期依赖关系(即当前元素与它前面不远处的元素之间的关系)。
  • 参数共享使得模型更紧凑,能处理不同长度的序列。

RNN 的挑战 (也是为什么后来发展出 LSTM, GRU 等):

  • 短期记忆的瓶颈: 就像普通人一样,基本的 RNN 的“短期记忆”能力有限。当序列非常长的时候,它很难记住很久以前的信息,这被称为长期依赖问题 (Long-Term Dependencies Problem)
  • 梯度消失/爆炸: 在训练很长的序列时,梯度在反向传播过程中可能会变得非常小(梯度消失)或非常大(梯度爆炸),导致模型难以学习。

总结一下:

RNN 的核心原理就是通过一个循环的结构和一个不断更新的隐藏状态(记忆),使得网络在处理序列数据时能够考虑到前面已经出现过的信息。它就像一个有短期记忆的处理器,一步一步地读取序列,并不断更新自己对序列的理解。虽然它有局限性,但它为处理序列数据奠定了重要的基础。

GRU (Gated Recurrent Unit, 门控循环单元)

背景回顾:RNN 和 LSTM 的小烦恼

  • 简单 RNN (小明): 记性不太好,读长句子容易忘掉开头的内容(长期依赖问题)。
  • LSTM (记忆超人小李): 记性特别好,能记住很久以前的关键信息。他有三个“门”(遗忘门、输入门、输出门)和一个专门的“长期记忆小本本”(细胞状态)来管理信息。虽然厉害,但小李的装备(门和细胞状态)有点多,计算起来稍微复杂一些。

GRU (聪明的简化版小王) 的出现:

GRU 可以看作是 LSTM 的一个简化版本,它也旨在解决长期依赖问题,但结构上比 LSTM 更简单一些。小王也想拥有好的记忆力,但他觉得小李的三个门和一个小本本有点繁琐,于是他想了个更精简的办法。

GRU 的核心秘诀:两个门来管事

小王(GRU)只用了**两个“门”**来控制信息的流动,并且他把 LSTM 的“长期记忆小本本”(细胞状态)和“短期记忆”(隐藏状态)合并了,用一个统一的“隐藏状态”来承载记忆。

这两个门是:

  1. 更新门 (Update Gate):

    • 作用: 这个门决定了多少过去的记忆 (前一个时刻的隐藏状态) 需要被保留到当前时刻,以及多少新的候选记忆需要被加入。它有点像 LSTM 中遗忘门和输入门的组合功能。
    • 通俗例子: 小王在听一个新的信息。
      • 更新门会说:“嗯,之前记住的那些东西里,有 80% 还是很重要的,要继续留着。同时,这次听到的新内容里,有 20% 是值得加入到记忆里的。”
      • 它控制着新旧信息的“融合比例”。如果更新门决定更多地保留旧记忆,那么新信息的影响就会小一些;反之亦然。
  2. 重置门 (Reset Gate):

    • 作用: 这个门决定了多少过去的记忆需要被“忽略”或“重置”,以便计算当前时刻的候选记忆。也就是说,它控制了前一个时刻的隐藏状态对当前候选隐藏状态的影响程度。
    • 通俗例子: 小王在听一个新的信息,准备形成对这个新信息的初步理解(候选记忆)。
      • 重置门会说:“在思考这个新信息的时候,我们先暂时忘掉一部分过去的记忆,比如说忘掉 70%,只基于剩下 30% 的旧记忆和当前的新输入来形成对新信息的初步看法。因为过去的某些记忆可能和现在这个新信息关系不大,带着它们反而会干扰判断。”
      • 它帮助模型决定哪些过去的记忆与计算当前“新想法”相关。如果重置门关闭(值接近0),那么过去的记忆对当前新想法的形成影响就很小,模型会更侧重于当前的输入。

GRU 的工作流程(简化版):

  1. 接收当前输入和上一时刻的隐藏状态(记忆)。
  2. 更新门决定: 应该保留多少过去的记忆,以及应该加入多少“新想法”。
  3. 重置门决定: 在形成“新想法”时,应该多大程度上忽略过去的记忆。
  4. 计算候选隐藏状态(“新想法”): 结合当前输入和(被重置门“筛选”过的)过去记忆,形成一个对当前情况的初步理解。
  5. 最终更新隐藏状态: 根据更新门的指示,把旧的隐藏状态和候选隐藏状态(“新想法”)进行加权组合,形成当前时刻最终的隐藏状态(记忆)。这个最终的隐藏状态会传递给下一个时间步。

GRU vs. LSTM:

  • 相似点:
    • 都是为了解决 RNN 的长期依赖问题。
    • 都使用了门控机制来控制信息流。
  • 不同点:
    • 门数量: GRU 有 2 个门(更新门、重置门),LSTM 有 3 个门(遗忘门、输入门、输出门)。
    • 细胞状态: GRU 没有像 LSTM 那样独立的细胞状态(长期记忆小本本),它将细胞状态和隐藏状态(短期记忆)合并了。
    • 参数数量: GRU 的参数通常比 LSTM 少一些,因为门更少。

GRU 的优点:

  • 计算效率更高: 由于门更少,参数也更少,GRU 通常比 LSTM 计算速度更快,训练起来也可能更快一些。
  • 在某些任务上表现与 LSTM 相当甚至更好: 特别是在数据量不是特别巨大的情况下,GRU 的简洁性可能反而是一种优势。
  • 更容易训练: 参数少一些,可能更容易调整。

GRU 的缺点:

  • 表达能力可能略逊于 LSTM (理论上): LSTM 有独立的细胞状态,可以更精细地控制长期记忆的存储和读取,理论上表达能力可能更强一些,尤其是在需要非常长距离依赖或更复杂记忆模式的任务上。但在很多实际应用中,两者的表现差异并不显著。

总结一下:

GRU 就像一个聪明的、追求效率的记忆管理者。它用更少的“门”(更新门和重置门)和更简洁的内部结构(合并了细胞状态和隐藏状态),同样能够有效地控制信息的流动,捕捉序列中的长期依赖关系。在很多情况下,GRU 能够以更快的速度达到与 LSTM 相媲美的性能,因此也成为了处理序列数据的一个非常受欢迎的选择。

你可以把 GRU 看作是 LSTM 的一个非常成功的简化和优化版本。

序列到序列 (Seq2Seq) 模型。

想象一下你在做翻译工作,或者你在给一段复杂的文字写摘要。

  • 输入是一个序列: 你拿到一段中文句子(一个词语序列),或者一篇长长的文章(一个句子序列或词语序列)。
  • 输出也是一个序列: 你需要把它翻译成英文句子(另一个词语序列),或者写出一段简短的摘要(也是一个词语序列)。

Seq2Seq 模型就是专门用来处理这种“输入是一个序列,输出也是一个序列”的任务的。

核心思想:“编码器-解码器”架构 (Encoder-Decoder Architecture)

Seq2Seq 模型通常由两个主要部分组成,就像一个翻译团队的两个核心成员:

  1. 编码器 (Encoder) - “阅读理解专家”

    • 作用: 负责读取并理解输入的整个序列,并把它压缩成一个固定大小的“思想摘要”或“语义表示”。这个“摘要”我们通常叫做上下文向量 (Context Vector) 或“思想向量 (Thought Vector)”。
    • 如何工作: 通常使用一个循环神经网络 (RNN),比如 LSTM 或 GRU。编码器会一个词一个词地读取输入序列。每读一个词,它都会更新自己的“内部状态”(隐藏状态)。当它读完整个输入序列后,它最终的隐藏状态(或者所有隐藏状态的某种组合)就被认为是这个输入序列的“思想摘要”。
    • 通俗例子: 翻译团队里的第一个人,他非常懂中文。他会仔细阅读整句中文“今天天气真好”,然后在大脑里形成一个对这句话核心意思的理解(“天气好”、“心情愉悦”等)。这个理解就是“思想摘要”。他不需要记住每个字是怎么写的,只需要抓住核心含义。
  2. 解码器 (Decoder) - “表达输出专家”

    • 作用: 负责根据编码器给出的“思想摘要”,生成目标输出序列
    • 如何工作: 通常也使用一个循环神经网络 (RNN),比如 LSTM 或 GRU。
      • 解码器会接收编码器产出的“思想摘要”作为初始信息。
      • 然后,它会一个词一个词地生成输出序列。
      • 在生成每个词的时候,它会考虑:
        1. 编码器给的“思想摘要”。
        2. 它自己上一个时刻生成的词
        3. 它自己当前的“内部状态”。
      • 它会一直生成词语,直到生成一个特殊的“结束符 (End-of-Sequence, EOS)”或者达到预设的最大长度。
    • 通俗例子: 翻译团队里的第二个人,他非常懂英文。他接收到第一个人给的“思想摘要”(关于“今天天气真好”的理解)。然后他开始构思英文句子:
      • 第一个词可能想到 "The"。
      • 根据 "The" 和“思想摘要”,他想到第二个词 "weather"。
      • 根据 "The weather" 和“思想摘要”,他想到 "is"。
      • 以此类推,直到生成 "The weather is really nice today.",然后输出一个结束标记。

Seq2Seq 模型的工作流程:

  1. 编码阶段: 输入序列(比如中文句子 "你好世界")被送入编码器。编码器逐个处理序列中的元素("你", "好", "世", "界"),最终输出一个上下文向量 (Context Vector),这个向量代表了整个输入序列的含义。
  2. 解码阶段: 解码器将上下文向量作为其初始状态(或输入的一部分)。然后,解码器开始生成输出序列:
    • 它首先生成第一个词(比如英文 "Hello")。
    • 然后,它将已生成的词 "Hello" 和上下文向量(以及它自己的内部状态)作为输入,生成下一个词(比如 "world")。
    • 这个过程不断重复,直到生成一个特殊的结束标记 </EOS>,表示输出序列结束。

Seq2Seq 模型的关键点:

  • 处理变长序列: 编码器可以将任意长度的输入序列压缩成一个固定长度的上下文向量,解码器也可以从这个上下文向量生成任意长度的输出序列。这是它的一大优势。
  • 上下文向量是桥梁: 上下文向量是连接编码器和解码器的唯一信息通道。它承载了输入序列的全部“精华”。
  • 自回归 (Autoregressive) 的解码: 解码器在生成当前词时,会依赖于之前已经生成的词。

Seq2Seq 模型的应用场景:

Seq2Seq 模型非常强大,可以应用于各种序列转换任务:

  • 机器翻译 (Machine Translation): 例如,从中文翻译到英文。
  • 文本摘要 (Text Summarization): 将长篇文章概括成几句简短的话。
  • 对话系统 (Chatbots / Dialogue Systems): 根据用户说的话(输入序列)生成回复(输出序列)。
  • 语音识别 (Speech Recognition): 将音频信号(输入序列)转换为文字(输出序列)。
  • 代码生成 (Code Generation): 根据自然语言描述生成代码。
  • 图像描述生成 (Image Captioning):(结合CNN)输入一张图片,输出对图片的文字描述。

Seq2Seq 模型的挑战和改进(例如引入注意力机制):

  • 信息瓶颈: 将整个输入序列的所有信息都压缩到一个固定长度的上下文向量中,对于很长的输入序列来说,可能会丢失一些重要信息。这个上下文向量成为了一个“信息瓶颈”。
  • 注意力机制 (Attention Mechanism) 的出现: 为了解决这个瓶颈问题,后来引入了注意力机制。注意力机制允许解码器在生成输出序列的每一步时,能够“关注”输入序列中不同的部分,而不是仅仅依赖于那个固定的上下文向量。这使得模型能更好地处理长序列,并生成更准确的输出。

总结一下:

Seq2Seq 模型就像一个**“输入一个序列,输出另一个序列”的通用框架**。它通过一个**“阅读理解专家”(编码器)来理解输入序列并生成一个“思想摘要”,然后通过一个“表达输出专家”(解码器)**来根据这个“思想摘要”逐步生成输出序列。它是许多自然语言处理和其他序列建模任务的核心基础。

循环神经网络 (RNN) - 使用 GRU 进行序列到序列任务(简化版机器翻译或摘要)

任务假设: 我们有一个源语言句子序列和一个对应的目标语言句子序列(例如,英语到法语的翻译,或者长文本到短摘要)。 模型输入一个源语言句子的词ID序列,输出一个目标语言句子的词ID序列。 这是一个序列到序列 (Seq2Seq) 的模型框架,通常包含一个编码器 (Encoder) 和一个解码器 (Decoder)。

模型结构设想 (Encoder-Decoder 架构):

  1. 编码器 (Encoder):
    • Embedding 层: 将源语言词ID转换为词嵌入。
    • GRU 层 (Gated Recurrent Unit,一种比LSTM更简洁的RNN变体): 读取源语言词嵌入序列,并将其压缩成一个固定大小的上下文向量 (Context Vector),这个向量理论上编码了整个输入序列的信息。
  2. 解码器 (Decoder):
    • Embedding 层: 将目标语言词ID转换为词嵌入(通常有自己的嵌入层)。
    • GRU 层: 以编码器的上下文向量作为初始隐藏状态,并接收上一个时间步预测的目标词和当前的隐藏状态,来生成下一个目标词的预测。
    • Linear 层: 将 GRU 的输出映射到目标语言词汇表大小的维度,得到每个目标词的 logits。
    • (通常还会加入注意力机制 (Attention Mechanism) 来让解码器在生成每个目标词时,可以关注源序列的不同部分,但这里为了简化,我们先不加注意力)。

代码实现 (简化版,不带 Attention,且解码是贪婪的或教师强制的简单形式):

import torch
import torch.nn as nn
import torch.nn.functional as F
import random # 用于教师强制

class EncoderRNN(nn.Module):
    def __init__(self, input_vocab_size, embedding_dim, hidden_dim, n_layers, dropout_prob, pad_idx):
        super().__init__()
        self.embedding = nn.Embedding(input_vocab_size, embedding_dim, padding_idx=pad_idx)
        self.gru = nn.GRU(embedding_dim, hidden_dim, n_layers,
                          dropout=dropout_prob if n_layers > 1 else 0,
                          batch_first=True, bidirectional=False) # 通常编码器可以是双向的,这里简化为单向
        self.dropout = nn.Dropout(dropout_prob)

    def forward(self, src_ids, src_lengths):
        # src_ids: (batch_size, src_seq_len)
        # src_lengths: (batch_size)
        embedded = self.dropout(self.embedding(src_ids)) # (batch_size, src_seq_len, embedding_dim)

        packed_embedded = nn.utils.rnn.pack_padded_sequence(embedded, src_lengths.cpu(),
                                                            batch_first=True, enforce_sorted=False)

        # outputs: (batch_size, src_seq_len, hidden_dim * num_directions) - 如果解包
        # hidden: (n_layers * num_directions, batch_size, hidden_dim)
        packed_outputs, hidden = self.gru(packed_embedded)

        # hidden 是编码器最后的隐藏状态,作为上下文向量
        return hidden


class DecoderRNN(nn.Module):
    def __init__(self, output_vocab_size, embedding_dim, hidden_dim, n_layers, dropout_prob, pad_idx):
        super().__init__()
        self.output_vocab_size = output_vocab_size
        self.embedding = nn.Embedding(output_vocab_size, embedding_dim, padding_idx=pad_idx)
        self.gru = nn.GRU(embedding_dim, hidden_dim, n_layers, # 解码器输入维度是嵌入维度
                          dropout=dropout_prob if n_layers > 1 else 0,
                          batch_first=True, bidirectional=False) # 解码器通常是单向的
        self.fc_out = nn.Linear(hidden_dim, output_vocab_size)
        self.dropout = nn.Dropout(dropout_prob)

    def forward(self, decoder_input_id, hidden_context):
        # decoder_input_id: (batch_size, 1) - 当前时间步的输入词ID (初始是<SOS>, 之后是上一步预测的词)
        # hidden_context: (n_layers, batch_size, hidden_dim) - 来自编码器的上下文或解码器上一步的隐藏状态

        # decoder_input_id 需要 unsqueeze(1) 如果不是 (batch_size, 1) 而是 (batch_size)
        # 这里假设输入已经是 (batch_size, 1)

        embedded = self.dropout(self.embedding(decoder_input_id)) # (batch_size, 1, embedding_dim)

        # output: (batch_size, 1, hidden_dim)
        # hidden: (n_layers, batch_size, hidden_dim)
        output, hidden = self.gru(embedded, hidden_context)

        # output 是 (batch_size, seq_len=1, hidden_dim)
        # 我们需要 (batch_size, hidden_dim) 送入全连接层
        prediction_logits = self.fc_out(output.squeeze(1)) # (batch_size, output_vocab_size)

        return prediction_logits, hidden


class Seq2Seq(nn.Module):
    def __init__(self, encoder, decoder, device):
        super().__init__()
        self.encoder = encoder
        self.decoder = decoder
        self.device = device

    def forward(self, src_ids, src_lengths, trg_ids=None, teacher_forcing_ratio=0.5):
        # src_ids: (batch_size, src_seq_len)
        # src_lengths: (batch_size)
        # trg_ids: (batch_size, trg_seq_len) - 训练时提供,推理时为 None
        # teacher_forcing_ratio: 训练时使用真实目标词作为下一步输入的概率

        batch_size = src_ids.shape[0]
        trg_len = trg_ids.shape[1] if trg_ids is not None else MAX_TRG_LEN_INFERENCE # 推理时需要预设最大长度
        trg_vocab_size = self.decoder.output_vocab_size

        # 存储解码器输出的张量
        outputs_logits = torch.zeros(batch_size, trg_len, trg_vocab_size).to(self.device)

        # 1. 编码器处理源序列
        encoder_hidden_context = self.encoder(src_ids, src_lengths)
        # encoder_hidden_context 形状 (n_layers, batch_size, hidden_dim)

        # 解码器的第一个输入是 <SOS> (Start Of Sentence) token
        # 假设 <SOS> 的 ID 是 1 (需要根据你的词汇表确定)
        decoder_input_id = torch.ones((batch_size, 1), dtype=torch.long, device=self.device) * SOS_TOKEN_IDX

        # 解码器的初始隐藏状态是编码器的最终隐藏状态
        decoder_hidden = encoder_hidden_context

        # 2. 解码器逐个生成目标序列的词
        for t in range(trg_len): # 遍历目标序列的每个时间步
            # decoder_output_logits: (batch_size, trg_vocab_size)
            # decoder_hidden: (n_layers, batch_size, hidden_dim)
            decoder_output_logits, decoder_hidden = self.decoder(decoder_input_id, decoder_hidden)

            outputs_logits[:, t, :] = decoder_output_logits

            # 决定下一个解码器的输入:教师强制或使用当前预测
            teacher_force = random.random() < teacher_forcing_ratio

            top1_predicted_id = decoder_output_logits.argmax(1) # (batch_size)

            if self.training and teacher_force and trg_ids is not None:
                decoder_input_id = trg_ids[:, t].unsqueeze(1) # 使用真实目标词 (batch_size, 1)
            else:
                decoder_input_id = top1_predicted_id.unsqueeze(1) # 使用模型自己的预测 (batch_size, 1)

            # 如果所有批次都预测了 <EOS> token,可以提前停止 (简化版未实现)

        return outputs_logits # (batch_size, trg_seq_len, trg_vocab_size)

# --- 模拟参数 ---
INPUT_VOCAB_SIZE_RNN = 5000
OUTPUT_VOCAB_SIZE_RNN = 5500
EMBEDDING_DIM_RNN = 128
HIDDEN_DIM_RNN = 256
N_LAYERS_RNN = 2
DROPOUT_PROB_RNN = 0.3
PAD_IDX_RNN = 0
SOS_TOKEN_IDX = 1 # 假设 <SOS> 符号的ID
EOS_TOKEN_IDX = 2 # 假设 <EOS> 符号的ID
MAX_TRG_LEN_INFERENCE = 15 # 推理时解码的最大长度

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# 实例化编码器和解码器
encoder_rnn = EncoderRNN(INPUT_VOCAB_SIZE_RNN, EMBEDDING_DIM_RNN, HIDDEN_DIM_RNN,
                         N_LAYERS_RNN, DROPOUT_PROB_RNN, PAD_IDX_RNN).to(device)
decoder_rnn = DecoderRNN(OUTPUT_VOCAB_SIZE_RNN, EMBEDDING_DIM_RNN, HIDDEN_DIM_RNN,
                         N_LAYERS_RNN, DROPOUT_PROB_RNN, PAD_IDX_RNN).to(device)

# 实例化 Seq2Seq 模型
seq2seq_model = Seq2Seq(encoder_rnn, decoder_rnn, device).to(device)
print("\nSeq2Seq 模型结构 (Encoder):\n", seq2seq_model.encoder)
print("\nSeq2Seq 模型结构 (Decoder):\n", seq2seq_model.decoder)

total_params_seq2seq = sum(p.numel() for p in seq2seq_model.parameters() if p.requires_grad)
print(f"\nSeq2Seq 模型总可训练参数数量: {total_params_seq2seq:,}")

# --- 模拟输入数据 ---
BATCH_SIZE_RNN = 3
SRC_SEQ_LEN_RNN = 10
TRG_SEQ_LEN_RNN = 12 # 训练时的目标序列长度

dummy_src_ids = torch.randint(3, INPUT_VOCAB_SIZE_RNN, (BATCH_SIZE_RNN, SRC_SEQ_LEN_RNN), device=device)
dummy_src_lengths = torch.tensor([10, 8, 9], device=device) # 假设的源序列长度
# 模拟填充
dummy_src_ids[1, 8:] = PAD_IDX_RNN
dummy_src_ids[2, 9:] = PAD_IDX_RNN

dummy_trg_ids = torch.randint(3, OUTPUT_VOCAB_SIZE_RNN, (BATCH_SIZE_RNN, TRG_SEQ_LEN_RNN), device=device) # 训练时的目标

print(f"\n模拟输入源ID形状: {dummy_src_ids.shape}")
print(f"模拟输入目标ID形状 (训练时): {dummy_trg_ids.shape}")

# --- 模型前向传播 (训练模式) ---
seq2seq_model.train() # 设置为训练模式以使用教师强制
output_seq_logits = seq2seq_model(dummy_src_ids, dummy_src_lengths, dummy_trg_ids, teacher_forcing_ratio=0.5)

print("\nSeq2Seq 模型输出 Logits (形状: batch_size, trg_seq_len, output_vocab_size):")
print(output_seq_logits.shape)

# --- 模型前向传播 (推理模式,不提供trg_ids) ---
seq2seq_model.eval()
with torch.no_grad():
    # 推理时,trg_ids 为 None,teacher_forcing_ratio 通常为 0
    # 注意:这里的 MAX_TRG_LEN_INFERENCE 会在 forward 中被用来确定循环次数
    inference_logits = seq2seq_model(dummy_src_ids, dummy_src_lengths, trg_ids=None, teacher_forcing_ratio=0.0)
print("\nSeq2Seq 模型推理输出 Logits 形状:")
print(inference_logits.shape)

Seq2Seq (RNN) 通俗解释:

这个模型像一个“翻译员二人组”:一个“阅读理解员”(编码器)和一个“写作员”(解码器)。

  1. EncoderRNN (__init__forward) (阅读理解员):

    • embedding: 和之前文本分类的嵌入层一样,把源语言的词ID变成“含义向量”。
    • gru: GRU 是一个更现代的循环神经网络单元,比基础 RNN 更能记住长期信息,比 LSTM 参数少一点。它会按顺序“阅读”源语言句子的词向量。
    • forward 过程:
      • 编码器接收源语言句子(词ID和实际长度)。
      • 词ID通过嵌入层变成词向量。
      • pack_padded_sequence 同样用于处理填充。
      • GRU 单元处理这些词向量。最重要的是,当 GRU 读完整个句子后,它会输出一个最终的“隐藏状态” (hidden)。这个 hidden 状态就像是编码器对整个源句子的“理解总结”或“思想精华”,我们称之为上下文向量 (context vector)
      • 编码器的任务就是把可变长度的输入句子,浓缩成一个固定大小的上下文向量。
  2. DecoderRNN (__init__forward) (写作员):

    • embedding: 这是解码器自己的嵌入层,用于目标语言的词(例如法语词)。
    • gru: 解码器的 GRU。它也按顺序工作,但它的任务是根据上下文向量和已经生成的目标词,来预测下一个目标词。
    • fc_out: 一个线性层,将解码器 GRU 的输出(表示当前预测词的隐藏信息)转换成目标语言词汇表中每个词的分数(logits)。
    • forward 过程 (一次只预测一个词):
      • 解码器接收两个主要输入:
        • decoder_input_id: 当前应该输入的词。在开始生成时,这是一个特殊的“句子开始”符号 (<SOS>)。之后,它可以是上一步真实的目标词(训练时的教师强制),或者是上一步模型自己预测的词。
        • hidden_context: 解码器的当前“记忆状态”。第一次调用时,这个状态是编码器给的“上下文向量”。之后,它是解码器自己上一步的隐藏状态。
      • 输入词ID通过嵌入层变向量。
      • GRU 单元结合嵌入向量和当前的 hidden_context,更新自己的记忆,并输出一个新的“思考结果” (output) 和新的记忆状态 (hidden)。
      • fc_out 层把 GRU 的“思考结果”转换成对目标词汇表中所有词的预测分数 (prediction_logits)。
      • 它返回预测分数和更新后的记忆状态 hidden(这个 hidden 会作为下一次预测的 hidden_context)。
  3. Seq2Seq (__init__forward) (翻译总指挥):

    • __init__: 把编码器和解码器组合起来。
    • forward 过程:
      • 编码: 首先,把源语言句子 (src_ids, src_lengths) 交给编码器,得到“上下文向量” (encoder_hidden_context)。
      • 准备解码:
        • 创建一个空的列表或张量 outputs_logits 来存放每一步的预测分数。
        • 解码器的第一个输入词是 <SOS> 符号。
        • 解码器的初始“记忆”就是编码器给的“上下文向量”。
      • 循环解码:
        • 一步一步地生成目标序列中的词,直到达到最大长度或者预测出“句子结束”符号 (<EOS>)。
        • 在每一步 t:
          • 调用解码器的 forward 方法,传入当前的输入词和当前的解码器记忆,得到下一个词的预测分数和更新后的解码器记忆。
          • 保存这些预测分数。
          • 决定下一步解码器的输入:
            • 教师强制 (Teacher Forcing): 在训练时,有一定概率(teacher_forcing_ratio),我们会直接把真实的目标词作为下一步的输入,而不是用模型自己上一步的预测。这有助于模型更快地学习正确的序列。
            • 自由运行: 如果不使用教师强制,或者在推理(测试)时,我们会选择当前预测分数最高的那个词作为下一步的输入。
      • 最终返回整个目标序列每个位置上所有词的预测分数。

Seq2Seq 如何学习? 损失函数会比较模型生成的整个目标词序列的 logits (outputs_logits) 和真实的目标词序列 (trg_ids)。常用的损失函数是 CrossEntropyLoss,但需要对 logits 和 target 进行适当的变形,因为损失是按每个词计算然后平均或求和的。梯度会从解码器的最后一步一直反向传播到解码器的第一步,然后再传播到编码器,更新所有相关的权重。