在这里插入图片描述

PyTorch Java 高校计算机硕士研一课程

循环神经网络 (RNN) 概述

收藏

前馈网络是独立处理输入的。然而,许多问题都涉及序列数据,其中顺序很重要,并且先前项的背景信息会影响当前项。例如,理解一个句子、预测股价或转录语音。每个词、价格点或声音片段都依赖于它之前的内容。标准的前馈网络缺乏一种内在机制来‘记住’序列中的过去信息。

这就是循环神经网络(RNN)的作用所在。它们通过引入循环的构想,专门设计用于处理序列数据。

记忆的构想:隐状态

RNN 的决定性特征是其内部循环。在处理序列的每一步,网络不仅考虑当前输入,还会考虑它从先前步骤中保留下来的信息。这些保留的信息存储在所谓的隐状态中。

设想你正在阅读一个句子。你不会孤立地处理每个词。你对当前词的把握会受到你已阅读词语的很大影响。RNN 中的隐状态就像这种运行中的总结或背景信息。它捕获了序列中先前元素的相关信息。

逐步处理序列

RNN 一次处理序列中的一个元素(或“时间步”)时。对于每个时间步 tt

  1. 它接收该时间步的输入,我们称之为 xtx**t
  2. 它还接收来自前一个时间步的隐状态 ht−1h**t−1。
  3. 它使用一组学习到的权重结合 xtx**t 和 ht−1h**t−1,以计算新的隐状态 hth**t。这个新的隐状态现在包含了从所有步骤直到 tt 的信息。
  4. 可选地,它可以为当前时间步生成一个输出 yty**t,这通常基于隐状态 hth**t

重要的是,每个时间步都使用相同的一组权重(结合输入和先前状态以及生成输出的规则)。这种权重共享使得 RNN 效率高,并能使其将模式推广到不同长度的序列。

可视化循环:时间上的展开

通常,通过在时间上“展开”RNN 会有所帮助。我们可以绘制一条链来表示网络在每个时间步的状态,而不是绘制循环。

时间 t-1时间 t时间 t+1h(t-1)RNN 单元x(t-1)y(t-1)h(t)…RNN 单元x(t)RNN 单元y(t)h(t+1)…x(t+1)y(t+1)

一个在时间上“展开”的 RNN。相同的 RNN 单元(代表共享权重)处理输入 xtx**t 和先前的隐状态 ht−1h**t−1,以生成新的隐状态 hth**t 和可选的输出 yty**t。隐状态从一个时间步传递到下一个时间步。

从数学角度看,简单 RNN 单元在时间步 tt 内的核心计算通常表示为:

计算新的隐状态 hth**t

ht=tanh⁡(Whhht−1+Wxhxt+bh)h**t=tanh(Whhh**t−1+Wxhxt+b**h)

计算输出 yty**t

yt=Whyht+byy**t=Whyht+b**y

这里:

  • xtx**t 是时间步 tt 的输入。
  • ht−1h**t−1 是来自前一个时间步的隐状态。
  • hth**t 是时间步 tt 的新隐状态。
  • yty**t 是时间步 tt 的输出。
  • WhhW**hh、WxhWxh 和 WhyWhy 是在训练期间学习到的权重矩阵。它们分别代表了先前隐状态、当前输入和当前隐状态的影响程度。这些权重在所有时间步之间是共享的。
  • bhb**h 和 byb**y 是偏置向量,也是学习得到的。
  • tanh⁡tanh 是双曲正切激活函数,常用于简单的 RNN 中以引入非线性。根据具体任务,输出层可以使用其他激活函数(例如,用于分类的 Softmax)。

重要之处在于 hth**t 的循环公式,它同时依赖于当前输入 xtx**t 和先前的隐状态 ht−1h**t−1。正是这种依赖性赋予了 RNN 记忆能力。

RNN 的应用场景

RNN 在处理序列模式的任务中表现出色:

  • 自然语言处理(NLP): 语言建模(预测下一个词)、机器翻译、情感分析、文本生成。
  • 语音识别: 将口语音频转换为文本。
  • 时间序列分析: 预测股价、天气预报、传感器数据分析。
  • 视频分析: 理解视频帧中随时间发生的动作。

挑战与后续

尽管功能强大,但像上面描述的简单 RNN 在学习长距离依赖时可能会遇到困难。来自早期时间步的信息在通过多个步骤传播时可能会被稀释或丢失,这个问题通常被称为梯度消失问题。反之,梯度有时可能会变得过大,这被称为梯度爆炸问题

这些挑战促成了更精密的循环架构的发展,如长短期记忆(LSTM)和门控循环单元(GRU),它们使用门控机制来更好地控制信息流和记忆。本章稍后将简要提及这些内容。

目前,掌握循环的核心思想、隐状态的作用以及逐步处理过程就足够了。在接下来的部分中,我们将了解如何使用 PyTorch 的 nn.RNN 模块实现一个基本的 RNN。

在PyTorch中构建一个简单的RNN

收藏

构建一个简单的RNN模型,使用PyTorch的torch.nn库,以展示循环神经网络如何利用隐藏状态处理序列数据。PyTorch提供了一个便捷的模块nn.RNN,它封装了RNN的核心逻辑。

nn.RNN 模块

PyTorch中基本RNN的主要构成部分是torch.nn.RNN类。当你创建这个类的一个实例时,你就在创建一个可以处理序列的RNN层(或者可能是多个堆叠层)。

nn.RNN层主要接收一个输入序列和一个可选的初始隐藏状态。然后,它会遍历输入序列的每个时间步,根据当前输入和前一个隐藏状态更新其隐藏状态。它会生成一个输出序列(每个时间步的隐藏状态)以及处理完整个序列后的最终隐藏状态。

要初始化一个nn.RNN层,你需要指定几个重要参数:

  • input_size:这定义了在每个时间步输入xx中期望的特征数量。例如,如果你正在处理维度为300的词嵌入,那么input_size将是300。
  • hidden_size:这决定了隐藏状态hh中的特征数量。它也定义了每个时间步输出的维度。hidden_size的选择是一个超参数,会影响模型的容量。
  • num_layers:这允许你堆叠多个RNN层。第一个层的输出序列成为第二个层的输入序列,依此类推。默认值为1。堆叠层有时可以帮助模型学习更复杂的时序模式。
  • nonlinearity:要使用的非线性函数。可以是'tanh'(默认)或'relu'
  • batch_first:一个布尔型参数。如果为True,则输入和输出张量以(batch_size, seq_len, feature_dim)的形式提供。如果为False(默认值),则为(seq_len, batch_size, feature_dim)。当使用生成序列批次的数据加载器时,将此参数设为True通常更直观。
  • dropout:如果非零,则在除最后一层之外的每个RNN层的输出上引入一个Dropout层,其Dropout概率等于dropout。默认值:0。
  • bidirectional:如果为True,则成为一个双向RNN。默认值:False。我们目前将专注于单向RNN。

输入和输出的形状

理解输入和输出张量的预期形状对于正确使用nn.RNN是必不可少的。为了便于理解,我们假设batch_first=True,因为它很常用。

  • 输入: 输入序列应该是一个形状为(batch_size, seq_len, input_size)的张量。
    • batch_size:批次中的序列数量。
    • seq_len:每个序列的长度(时间步数)。
    • input_size:每个时间步的特征数量(与nn.RNNinput_size参数匹配)。
  • 初始隐藏状态 (h_0): (可选)如果你想提供一个初始隐藏状态,它的形状应为(num_layers, batch_size, hidden_size)。如果未提供,则默认为全零张量。
  • 输出序列 (output): 该张量包含来自最后一层RNN的、每个时间步的输出特征(隐藏状态)。其形状为(batch_size, seq_len, hidden_size)
  • 最终隐藏状态 (h_n): 该张量包含处理完整个序列后,每个RNN层的最终隐藏状态。其形状为(num_layers, batch_size, hidden_size)。你可以将此最终隐藏状态用作后续层(例如用于分类的线性层)的输入。

如果batch_first=False(默认值),则输入和输出序列张量中的batch_sizeseq_len维度会互换。隐藏状态张量(h_0h_n)始终将batch_size作为第二个维度,无论batch_first设置如何。

实现一个简单的RNN模型

让我们使用nn.Module创建一个基本的RNN模型。该模型将包含一个nn.RNN层,后面跟着一个nn.Linear层,用于将序列的最终隐藏状态映射到输出预测。这种模式在序列分类任务中很常见。

import torch
import torch.nn as nn

class SimpleRNNModel extends nn.Module:
    def __init__(input_dim, hidden_dim, output_dim, num_rnn_layers=1):
        """
        初始化SimpleRNNModel。

        Args:
            input_dim (int): 每个时间步输入特征的维度。
            hidden_dim (int): RNN隐藏状态的维度。
            output_dim (int): 最终输出的维度。
            num_rnn_layers (int): 堆叠RNN层的数量。默认值为1。
        """
        super().__init__() # 调用父类 (nn.Module) 的 __init__ 方法
        val hidden_dim = hidden_dim
        val num_rnn_layers = num_rnn_layers

        // 定义RNN层
        // batch_first=True 表示输入/输出张量形状为: (batch, seq, feature)
        val rnn = nn.RNN(
            input_size=input_dim,
            hidden_size=hidden_dim,
            num_layers=num_rnn_layers,
            batch_first=true, // 确保输入形状是 (batch, seq_len, input_size)
            nonlinearity="tanh" // 默认激活函数
        )

        // 定义输出层(全连接层)
        // 它以RNN的最终隐藏状态作为输入
        val fc = nn.Linear(hidden_dim, output_dim)

    def forward(x: torch.Tensor):
        """
        定义模型的正向传播。

        Args:
            x (torch.Tensor): 输入张量,形状为 (batch_size, seq_len, input_dim)。

        Returns:
            torch.Tensor: 输出张量,形状为 (batch_size, output_dim)。
        """
        // 初始化隐藏状态为零
        // 形状: (num_layers, batch_size, hidden_size)
        val batch_size = x.size(0)
        val h0 = torch.zeros(num_rnn_layers, batch_size, hidden_dim).to(x.device)

        // 通过RNN层传递数据
        // rnn_out 形状: (batch_size, seq_len, hidden_size)
        // hn 形状: (num_layers, batch_size, hidden_size)
        val rnn_out, hn = rnn(x, h0)

        // 我们只需要最后一层、最后一个时间步的隐藏状态
        // hn 包含所有层的最终隐藏状态。
        // hn[-1] 获取最后一层的最终隐藏状态。
        // hn[-1] 的形状: (batch_size, hidden_size)
        val last_layer_hidden_state = hn[-1]

        // 将最后一个隐藏状态通过全连接层
        // out 形状: (batch_size, output_dim)
        val out = fc(last_layer_hidden_state)

        return out

//--- 示例用法 ---

// 定义模型参数
val INPUT_DIM = 10   // 输入特征维度(例如,嵌入大小)
val HIDDEN_DIM = 20  // 隐藏状态维度
val OUTPUT_DIM = 5   // 输出维度(例如,类别数量)
val NUM_LAYERS = 1   // RNN层数

// 创建模型
val model = SimpleRNNModel(INPUT_DIM, HIDDEN_DIM, OUTPUT_DIM, NUM_LAYERS)
print("模型结构:")
print(model)

// 创建一些虚拟输入数据
val BATCH_SIZE = 4
val SEQ_LEN = 15
val dummy_input = torch.randn(BATCH_SIZE, SEQ_LEN, INPUT_DIM) // 形状: (batch, seq, feature)

// 执行前向传播
val output = model(dummy_input)
// 打印输入和输出形状
print(f"\n输入形状: {dummy_input.shape}")
print(f"输出形状: {output.shape}")

// 验证输出形状是否与 (BATCH_SIZE, OUTPUT_DIM) 匹配
assert output.shape == (BATCH_SIZE, OUTPUT_DIM)
import org.bytedeco.javacpp.LongPointer;
import org.bytedeco.javacpp.PointerScope;
import org.bytedeco.pytorch.*;
import org.bytedeco.pytorch.Module;
import org.bytedeco.pytorch.global.torch;

// SimpleRNNModel类实现
class SimpleRNNModel extends Module {
    private final RNNImpl rnn;
    private final LinearImpl fc;
    private final long numRnnLayers;
    private final long hiddenDim;

    public SimpleRNNModel(long inputDim, long hiddenDim, long outputDim, long numRnnLayers) {
        super("SimpleRNNModel");
        this.numRnnLayers = numRnnLayers;
        this.hiddenDim = hiddenDim;

        // 定义RNN层
        RNNOptions rnnOptions = new RNNOptions(inputDim, hiddenDim);
        rnnOptions.num_layers().put(numRnnLayers);
        rnnOptions.batch_first().put(true); // 确保输入形状是 (batch, seq_len, input_size)
        rnnOptions.nonlinearity().put(new kTanh); // 默认激活函数
        this.rnn = register_module("rnn", new RNNImpl(rnnOptions));

        // 定义输出层(全连接层)
        this.fc = register_module("fc", new LinearImpl(hiddenDim, outputDim));
    }

    public Tensor forward(Tensor x) {
        // 初始化隐藏状态为零
        // 形状: (num_layers, batch_size, hidden_size)
        long batchSize = x.size(0);
        Tensor h0 = torch.zeros(new long[]{numRnnLayers, batchSize, hiddenDim}).to(x.device(), x.dtype());

        // 通过RNN层传递数据
        // rnn_out 形状: (batch_size, seq_len, hidden_size)
        // hn 形状: (num_layers, batch_size, hidden_size)
        var rnnOutput = rnn.forward(x, h0);
        Tensor rnnOut = rnnOutput.get0();
        Tensor hn = rnnOutput.get1();

        // 我们只需要最后一层、最后一个时间步的隐藏状态
        // hn 包含所有层的最终隐藏状态。
        // hn[-1] 获取最后一层的最终隐藏状态。
        // hn[-1] 的形状: (batch_size, hidden_size)
        Tensor lastLayerHiddenState = hn.slice(0, new LongOptional(numRnnLayers - 1), new LongOptional(numRnnLayers),1);
        lastLayerHiddenState = lastLayerHiddenState.squeeze(0);

        // 将最后一个隐藏状态通过全连接层
        // out 形状: (batch_size, output_dim)
        Tensor out = fc.forward(lastLayerHiddenState);

        // 关闭临时张量
        h0.close();
        rnnOut.close();
        hn.close();
        lastLayerHiddenState.close();

        return out;
    }
}

            // 定义模型参数
            long INPUT_DIM = 10; // 输入特征维度(例如,嵌入大小)
            long HIDDEN_DIM = 20; // 隐藏状态维度
            long OUTPUT_DIM = 5; // 输出维度(例如,类别数量)
            long NUM_LAYERS = 1; // RNN层数

            // 创建模型
            SimpleRNNModel model = new SimpleRNNModel(INPUT_DIM, HIDDEN_DIM, OUTPUT_DIM, NUM_LAYERS);
            System.out.print("\n模型结构: ");
            System.out.print(model);

            // 创建一些虚拟输入数据
            long BATCH_SIZE = 4;
            long SEQ_LEN = 15;
            Tensor dummyInput = torch.randn(new long[]{BATCH_SIZE, SEQ_LEN, INPUT_DIM})
                    .to(torch.ScalarType.Float); // 形状: (batch, seq, feature)

            // 执行前向传播
            Tensor output02 = model.forward(dummyInput);

            // 打印输入和输出形状
            System.out.print("\n输入形状: " + getShapeString(dummyInput));
            System.out.print(" 输出形状: " + getShapeString(output02));

            // 关闭临时张量
            dummyInput.close();
            output02.close();

代码分析

  1. 初始化 (__init__):我们定义nn.RNN层,指定input_dimhidden_dimnum_rnn_layers,以及重要的batch_first=True。我们还定义了一个标准的nn.Linear层(self.fc),它将接收来自RNN的最终隐藏状态作为输入并生成模型的最终输出。
  2. 正向传播 (forward)
    • 我们首先从输入张量x中确定batch_size
    • 创建一个形状为(num_layers, batch_size, hidden_dim)的全零初始隐藏状态h0。我们使用.to(x.device)确保它与输入x位于相同的设备上。
    • 输入x和初始隐藏状态h0被传递给self.rnn层。它返回两个张量:rnn_out(来自最后一层的所有时间步的隐藏状态)和hn(经过最后一个时间步后所有层的最终隐藏状态)。
    • 由于我们的目标通常是序列分类或摘要,我们通常使用最终隐藏状态。hn的形状是(num_layers, batch_size, hidden_size)。我们使用hn[-1]选择最后一层的最终隐藏状态,这会得到一个形状为(batch_size, hidden_size)的张量。
    • 这个最终隐藏状态hn[-1]通过全连接层self.fc,以获得形状为(batch_size, output_dim)的最终输出张量。
  3. 示例用法:我们实例化模型并创建随机输入数据,使其符合预期的(batch_size, seq_len, input_size)格式(因为我们设置了batch_first=True)。运行模型会生成一个输出张量,我们验证其形状是否为(batch_size, output_dim),这适用于多类别分类等任务,其中批次中的每个序列都会被分配一个输出向量。

循环神经网络(RNN)的序列数据输入处理

收藏

循环神经网络(RNN)专门用于处理序列数据,无论是句子中的词语、音乐作品中的音符,还是随时间变化的测量值。与独立处理固定大小输入的传统前馈网络不同,RNN在序列的每个步骤都会更新一个内部隐藏状态,这使得之前步骤的信息能够影响当前和未来步骤的处理。这种序列处理方式需要特定的输入数据格式。

标准RNN输入形状

PyTorch的RNN层(如 nn.RNNnn.LSTMnn.GRU)默认期望输入数据为一个三维张量,具有以下维度:

(序列长度, 批次大小, 输入特征)

我们来逐一分析每个维度:

  1. 序列长度 (seq_len): 这是序列中的时间步数量。例如,如果您正在处理句子,并且批次中最长的句子有15个词,那么您的seq_len通常就是15(较短的句子需要填充,稍后讨论)。
  2. 批次大小 (batch_size): 这表示您同时处理的独立序列的数量。分批训练是提高效率和更好地估计梯度的标准做法。
  3. 输入特征 (input_sizefeatures): 这是表示每个时间步输入特征的维度。如果您处理的是用50维嵌入表示的词语,那么input_size将是50。如果您处理的是单变量时间序列(每个时间步只有一个值),那么input_size将是1。

示例: 假设您想处理一个包含32个句子的批次,其中每个句子表示为20个词的序列,并且每个词都转换为100维向量嵌入。输入张量的形状将是 (20, 32, 100)

import torch

// 示例参数
val seq_len = 20      // 最长序列长度
val batch_size = 32   // 批次中的序列数量
val input_features = 100 // 每个词嵌入的维度

// 创建一个虚拟输入张量(例如,用随机数填充)
// 形状: (序列长度, 批次大小, 输入特征)
val rnn_input = torch.randn(seq_len, batch_size, input_features)

println(s"标准RNN输入形状: ${rnn_input.shape}")
// 输出: 标准RNN输入形状: torch.Size([20, 32, 100])

          // 示例参数
            long seqLen2 = 20; // 最长序列长度
            long batchSize2 = 32; // 批次中的序列数量
            long inputFeatures2 = 100; // 每个词嵌入的维度

            // 创建一个虚拟输入张量(例如,用随机数填充)
            // 形状: (序列长度, 批次大小, 输入特征)
            Tensor rnnInput = torch.randn(new long[]{seqLen2, batchSize2, inputFeatures2});

            System.out.println("\n标准RNN输入形状: " + getShapeString(rnnInput));

            // 关闭临时张量
            rnnInput.close();

batch_first 选项

尽管 (序列长度, 批次大小, 输入大小) 是默认设置,但许多人认为将批次维度放在首位更符合直觉,这也与数据常见组织方式以及其他层类型(如卷积层或线性层)通常处理输入的方式保持一致。PyTorch RNN层为此提供 batch_first 参数。

如果您使用 batch_first=True 初始化RNN层,它将期望输入张量形状为:

(批次大小, 序列长度, 输入特征)

示例(接续前文): 如果使用 batch_first=True,相同数据的形状将是 (32, 20, 100)

import torch
import torch.nn as nn

// 示例参数(同前)
val seq_len = 20      // 最长序列长度
val batch_size = 32   // 批次中的序列数量
val input_features = 100 // 每个词嵌入的维度
val hidden_size = 50 // RNN的示例隐藏大小

// 创建一个批次维度在前的虚拟输入张量
// 形状: (批次大小, 序列长度, 输入特征)
val rnn_input_batch_first = torch.randn(batch_size, seq_len, input_features)

// 使用 batch_first=True 初始化RNN层
val rnn_layer = nn.RNN(input_size=input_features, hidden_size=hidden_size, batch_first=True)

// 将输入通过层(输出形状也将是批次维度在前)
val output, hidden_state = rnn_layer(rnn_input_batch_first)

println(s"批次维度优先的RNN输入形状: ${rnn_input_batch_first.shape}")
// 输出: 批次维度优先的RNN输入形状: torch.Size([32, 20, 100])
println(s"批次维度优先的RNN输出形状: ${output.shape}")
// 输出: 批次维度优先的RNN输出形状: torch.Size([32, 20, 50])
  try (PointerScope scope = new PointerScope()) {
            // 03
            // 示例参数
            long seqLen = 20; // 最长序列长度
            long batchSize = 32; // 批次中的序列数量
            long inputFeatures = 100; // 每个词嵌入的维度
            long hiddenSize = 50; // RNN的示例隐藏大小

            // 创建一个批次维度在前的虚拟输入张量
            // 形状: (批次大小, 序列长度, 输入特征)
            Tensor rnnInputBatchFirst = torch.randn(new long[]{batchSize, seqLen, inputFeatures})
                    .to(torch.ScalarType.Float);

            // 使用 batch_first=True 初始化RNN层
            RNNOptions rnnOptions = new RNNOptions(inputFeatures, hiddenSize);
            rnnOptions.batch_first().put(true);
            RNNImpl rnnLayer = new RNNImpl(rnnOptions);

            // 将输入通过层(输出形状也将是批次维度在前)
            var rnnOutput = rnnLayer.forward(rnnInputBatchFirst, null);

            System.out.println("\n批次维度优先的RNN输入形状: " + getShapeString(rnnInputBatchFirst));
            System.out.println("批次维度优先的RNN输出形状: " + getShapeString(rnnOutput.get0()));

            // 关闭临时张量
            rnnInputBatchFirst.close();
            rnnOutput.close();
}

使用 batch_first=True 通常可以简化数据准备流程,因为数据集通常在加载时就是批次维度优先。请记住,如果设置了 batch_first=True,输出形状也将采用 (批次大小, 序列长度, 隐藏大小) 格式。

输入张量(序列长度, 批次大小, 特征)或(批次大小, 序列长度, 特征)T0T1…T(seq_len-1)一个序列F0F1…F(features-1)一个时间步T0T1…T(seq_len-1)

RNN输入数据结构的视觉表示。输入通常是一个三维张量,表示多个序列(批次),每个序列包含多个时间步,并且每个时间步具有多个特征。

处理变长序列

“一个常见的问题是,数据集中的序列长度很少完全相同(例如,句子的词数不同)。由于张量要求统一的维度,您需要使批次中的序列长度一致。这通常通过以下方式完成:”

  1. 填充: 较短的序列会在末尾填充一个特殊值(通常是零),直到它们达到批次中最长序列的长度(seq_len)。
  2. 打包(可选但推荐): 为了防止RNN处理这些无意义的填充值,PyTorch提供了实用工具(torch.nn.utils.rnn.pack_padded_sequencetorch.nn.utils.rnn.pad_packed_sequence)。您可以在将填充序列送入RNN之前“打包”它们,告诉RNN批次中每个序列的真实长度。然后,RNN只处理实际的数据点。之后您再“填充”输出以获取标准张量。虽然我们在此不详细说明打包,但它是使用变长数据进行高效且准确RNN训练的重要技术。

理解期望的输入形状((序列长度, 批次大小, 特征),如果 batch_first=True 则是 (批次大小, 序列长度, 特征))对于正确准备数据并将其送入PyTorch的循环层必不可少。始终查阅您所用特定层的文档,并确保您的数据预处理流程生成所需形状的张量。

LSTM 和 GRU 简要介绍

收藏

简单的循环神经网络 (RNN) 层,例如 PyTorch 中的 nn.RNN,通过维护隐藏状态来处理序列。然而,这些网络在学习跨越长时间跨度的模式时经常遇到困难。这种困难主要是由于梯度消失问题,即在通过许多时间步进行反向传播时,梯度变得极其小,从而阻碍了模型根据早期输入有效更新权重的能力。

为了解决这个局限,开发了更复杂的循环单元。PyTorch 中现成的两种最受欢迎且有效的替代方案是长短期记忆 (LSTM) 和门控循环单元 (GRU)。

长短期记忆 (LSTM)

LSTM,由 Hochreiter & Schmidhuber 于 1997 年提出,其设计目的是应对梯度消失问题并更好地捕获长距离依赖关系。LSTM 的核心改进在于其内部结构,它不仅包含像简单 RNN 一样的隐藏状态 (hth**t),还包含一个独立的 单元状态 (ctc**t)。

可以将单元状态视为一条信息高速公路,它允许早期时间步的相关信息相对顺畅地流经网络。信息进出此单元状态的流动由三种称为 的专门机制调控:

  1. 遗忘门: 决定从前一个单元状态 (ct−1c**t−1) 中丢弃哪些信息。
  2. 输入门: 决定将当前输入 (xtx**t) 和前一个隐藏状态 (ht−1h**t−1) 中的哪些新信息存储到当前单元状态 (ctc**t) 中。
  3. 输出门: 决定单元状态的哪些部分应作为新的隐藏状态 (hth**t) 输出。

这些门使用 sigmoid 激活函数(输出值在 0 到 1 之间)来控制信息通过的程度。这种门控机制允许 LSTM 有选择地长时间记忆信息并遗忘不相关的细节,使它们在涉及复杂序列模式的任务中非常有效,例如机器翻译、语言建模和语音识别。

在 PyTorch 中,可以通过 torch.nn.LSTM 层使用 LSTM。其用法在预期的输入/输出形状和初始化参数(如 input_sizehidden_sizenum_layers)方面与 nn.RNN 非常相似。

import torch
import torch.nn as nn

// 示例:定义一个 LSTM 层
val input_size = 10
val hidden_size = 20
val num_layers = 2

val lstm_layer = nn.LSTM(input_size, hidden_size, num_layers, batch_first=true)

// 示例输入 (batch_size, seq_length, input_size)
val batch_size = 5
val seq_length = 15
val dummy_input = torch.randn(batch_size, seq_length, input_size)

// 前向传播需要初始隐藏状态和单元状态 (h_0, c_0)
// 如果不提供,它们默认为零。
// 形状: (num_layers * num_directions, batch_size, hidden_size)
val h0 = torch.randn(num_layers, batch_size, hidden_size)
val c0 = torch.randn(num_layers, batch_size, hidden_size)

// 前向传播
val output = lstm_layer(dummy_input, (h0, c0))

// output 形状: (batch_size, seq_length, hidden_size)
// hn 形状: (num_layers, batch_size, hidden_size) - 每个层的最终隐藏状态
// cn 形状: (num_layers, batch_size, hidden_size) - 每个层的最终单元状态
print("LSTM Output shape:", output.shape)
print("LSTM Final Hidden State shape:", hn.shape)
print("LSTM Final Cell State shape:", cn.shape)
            // 示例:定义一个 LSTM 层
            long inputSize = 10;
            long hiddenSize = 20;
            long numLayers = 2;

            // 定义LSTM层
            LSTMOptions lstmOptions = new LSTMOptions(inputSize, hiddenSize);
            lstmOptions.num_layers().put(numLayers);
            lstmOptions.batch_first().put(true);
            LSTMImpl lstmLayer = new LSTMImpl(lstmOptions);

            // 示例输入 (batch_size, seq_length, input_size)
            long batchSize02 = 5;
            long seqLength = 15;
            Tensor dummyInput = torch.randn(new long[]{batchSize02, seqLength, inputSize})
                    .to(torch.ScalarType.Float);

            // 前向传播需要初始隐藏状态和单元状态 (h_0, c_0)
            // 如果不提供,它们默认为零。
            // 形状: (num_layers * num_directions, batch_size, hidden_size)
            Tensor h0 = torch.randn(new long[]{numLayers, batchSize02, hiddenSize})
                    .to(torch.ScalarType.Float);
            Tensor c0 = torch.randn(new long[]{numLayers, batchSize02, hiddenSize})
                    .to(torch.ScalarType.Float);

            // 前向传播
            var lstmOutput = lstmLayer.forward(dummyInput, new T_TensorTensor_T(h0, c0));

            // 打印结果形状
            System.out.println("\nLSTM Output shape: " + getShapeString(lstmOutput.get0()));
            System.out.println("LSTM Final Hidden State shape: " + getShapeString(lstmOutput.get1().get0()));
            System.out.println("LSTM Final Cell State shape: " + getShapeString(lstmOutput.get1().get1()));

            // 关闭临时张量
            dummyInput.close();
            h0.close();
            c0.close();
            lstmOutput.close();

门控循环单元 (GRU)

GRU,由 Cho 等人于 2014 年提出,是新一代的门控循环单元,它简化了 LSTM 架构。它们也旨在解决梯度消失问题并捕获长期依赖关系,但通过稍微不同且计算量较小的结构实现这一点。

GRU 将单元状态和隐藏状态合并为一个隐藏状态 (hth**t)。它们只使用两个门:

  1. 重置门: 决定在提出新的候选隐藏状态时,要遗忘多少前一个隐藏状态 (ht−1h**t−1) 的信息。
  2. 更新门: 决定保留多少前一个隐藏状态 (ht−1h**t−1) 的信息,以及将多少新的候选隐藏状态的信息并入最终隐藏状态 (hth**t)。

由于门更少且没有独立的单元状态,GRU 在相同隐藏大小时比 LSTM 具有更少的参数。这可以使它们训练更快,并且在较小数据集上可能更不容易过拟合,同时在许多任务上通常能达到与 LSTM 相当的性能。

PyTorch 提供了 torch.nn.GRU 层,其用法与 nn.RNNnn.LSTM 遵循相同的模式。

// 示例:定义一个 GRU 层
val gru_layer = nn.GRU(input_size, hidden_size, num_layers, batch_first=true)

// 前向传播需要初始隐藏状态 (h_0)
// 如果不提供,它默认为零。
// 形状: (num_layers * num_directions, batch_size, hidden_size)
val h0_gru = torch.randn(num_layers, batch_size, hidden_size)

// 前向传播
val output_gru = gru_layer(dummy_input, h0_gru)

// output 形状: (batch_size, seq_length, hidden_size)
// hn 形状: (num_layers, batch_size, hidden_size) - 每个层的最终隐藏状态
println("\nGRU Output shape:", output_gru.shape)
println("GRU Final Hidden State shape:", hn_gru.shape)
         // 示例:定义一个 GRU 层
            GRUOptions gruOptions = new GRUOptions(inputSize, hiddenSize);
            gruOptions.num_layers().put(numLayers);
            gruOptions.batch_first().put(true);
            GRUImpl gruLayer = new GRUImpl(gruOptions);

            // 前向传播需要初始隐藏状态 (h_0)
            // 如果不提供,它默认为零。
            // 形状: (num_layers * num_directions, batch_size, hidden_size)
            Tensor h0Gru = torch.randn(new long[]{numLayers, batchSize02, hiddenSize})
                    .to(torch.ScalarType.Float);

            // 重新创建输入张量
            dummyInput = torch.randn(new long[]{batchSize02, seqLength, inputSize})
                    .to(torch.ScalarType.Float);

            // 前向传播
            var gruOutput = gruLayer.forward(dummyInput, h0Gru);

            // 打印结果形状
            System.out.println("\nGRU Output shape: " + getShapeString(gruOutput.get0()));
            System.out.println("GRU Final Hidden State shape: " + getShapeString(gruOutput.get1()));

            // 关闭临时张量
            dummyInput.close();
            h0Gru.close();
            gruOutput.close();

实际上,在处理需要长期依赖的序列数据时,LSTM 和 GRU 都被广泛用于替代简单 RNN。LSTM 和 GRU 之间的选择通常取决于对特定任务和数据集的经验评估,尽管当计算资源或训练时间受限时,GRU 可能因其更简单的结构而更受青睐。PyTorch 使得对两者进行实验变得简单。

实践:实现基本CNN和RNN

收藏

你将使用PyTorch的nn.Module以及相关层,实现卷积神经网络(CNN)和循环神经网络(RNN)这两种架构的基本版本。这种动手实践将巩固你对这些模型如何构建以及数据如何在其中流动的理解。

我们将侧重于定义模型结构和理解输入/输出维度,直接基于第4章的nn.Module知识和本章前面部分对层的说明进行构建。请记住,这些是简化示例;将它们集成到完整的训练循环中需要添加数据加载(第5章)、损失函数、优化器以及训练逻辑(第6章)。

实现一个基本CNN

CNN擅长处理网格状数据,例如图像。让我们构建一个可用于图像分类的简单CNN。我们将定义一个包含卷积层、激活函数、池化层和最终全连接层的网络。

定义CNN架构

我们创建一个继承自nn.Module的类。在__init__中,我们定义所需的层:用于卷积的nn.Conv2d,用于激活的nn.ReLU,用于池化的nn.MaxPool2d,以及用于最终分类的nn.Linear层。forward方法定义了输入数据如何流经这些层。

import torch
import torch.nn as nn

class SimpleCNN extends nn.Module:
    def __init__(self, num_classes: Int = 10):
        super(SimpleCNN, self).__init__()
        // 输入形状: (批次, 1, 28, 28) - 假设是像MNIST那样的灰度图像
        val conv1 = nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, stride=1, padding=1)
        // conv1后的形状: (批次, 16, 28, 28) -> (28 - 3 + 2*1)/1 + 1 = 28
        val relu1 = nn.ReLU()
        val pool1 = nn.MaxPool2d(kernel_size=2, stride=2)
        // pool1后的形状: (批次, 16, 14, 14) -> 28 / 2 = 14

        val conv2 = nn.Conv2d(in_channels=16, out_channels=32, kernel_size=3, stride=1, padding=1)
        // conv2后的形状: (批次, 32, 14, 14) -> (14 - 3 + 2*1)/1 + 1 = 14
        val relu2 = nn.ReLU()
        val pool2 = nn.MaxPool2d(kernel_size=2, stride=2)
        // pool2后的形状: (批次, 32, 7, 7) -> 14 / 2 = 7

        // 展平输出以便连接到线性层
        // 展平后的尺寸 = 32 * 7 * 7 = 1568
        val fc = nn.Linear(32 * 7 * 7, num_classes)

    def forward(x: Tensor):
        // 应用第一个卷积块
        val out = conv1(x)
        val out = relu1(out)
        val out = pool1(out)

        // 应用第二个卷积块
        val out = conv2(out)
        val out = relu2(out)
        val out = pool2(out)

        // 展平卷积层的输出
        // -1 表示推断批次大小
        val out = out.view(out.size(0), -1)

        // 应用全连接层
        val out = fc(out)
        return out
class SimpleCNN extends Module {
    private final Conv2dImpl conv1;
    private final ReLUImpl relu1;
    private final MaxPool2dImpl pool1;
    private final Conv2dImpl conv2;
    private final ReLUImpl relu2;
    private final MaxPool2dImpl pool2;
    private final LinearImpl fc;

    public SimpleCNNzs(long numClasses) {
        super("SimpleCNNz");
        var padding = 1l
        LongPointer paddingPointer = new LongPointer(padding, padding);
        // 定义卷积层和池化层
        Conv2dOptions conv1Options = new Conv2dOptions(1, 16, new LongPointer(3));
        conv1Options.stride().put(1);
        conv1Options.padding().put(paddingPointer);
        this.conv1 = register_module("conv1", new Conv2dImpl(conv1Options));

        this.relu1 = register_module("relu1", new ReLUImpl());

        MaxPool2dOptions pool1Options = new MaxPool2dOptions(new LongPointer(2));
        pool1Options.stride().put(2);
        this.pool1 = register_module("pool1", new MaxPool2dImpl(pool1Options));

        Conv2dOptions conv2Options = new Conv2dOptions(16, 32, new LongPointer(3));

        conv2Options.stride().put(1);
        conv2Options.padding().put(paddingPointer);
        this.conv2 = register_module("conv2", new Conv2dImpl(conv2Options));

        this.relu2 = register_module("relu2", new ReLUImpl());

        MaxPool2dOptions pool2Options = new MaxPool2dOptions(new LongPointer(2));
        pool2Options.stride().put(2);
        this.pool2 = register_module("pool2", new MaxPool2dImpl(pool2Options));

        // 定义全连接层
        this.fc = register_module("fc", new LinearImpl(32 * 7 * 7, numClasses));
    }

    public Tensor forward(Tensor input) {
        // 应用第一个卷积块
        Tensor out = conv1.forward(input);
        out = relu1.forward(out);
        out = pool1.forward(out);

        // 应用第二个卷积块
        out = conv2.forward(out);
        out = relu2.forward(out);
        out = pool2.forward(out);

        // 展平卷积层的输出
        // -1 表示推断批次大小
        out = out.view(out.size(0), -1);

        // 应用全连接层
        out = fc.forward(out);

        return out;
    }
}


在此示例中:

  • 我们假设输入是灰度图像(1通道),类似于MNIST数据集中的图像,尺寸为28x28。
  • nn.Conv2d(in_channels=1, out_channels=16, ...):接收1个输入通道,应用16个滤波器。kernel_size=3stride=1padding=1是常见的选择,它们在卷积后保持空间维度。
  • nn.MaxPool2d(kernel_size=2, stride=2):将高度和宽度减半。
  • 第二个池化层的输出形状为(批次,32,7,7)。
  • out.view(out.size(0), -1):将张量从形状(批次,32,7,7)展平为(批次,32 * 7 * 7)=(批次,1568),以便可以将其输入到线性层。
  • nn.Linear(32 * 7 * 7, num_classes):最后一层将展平后的特征映射到所需数量的输出类别。

使用虚拟数据测试CNN

让我们创建一些匹配预期形状(批次大小,通道,高度,宽度)的虚拟输入数据,并将其传入我们的网络以查看输出形状。

// 实例化模型
val cnn_model = SimpleCNN(num_classes=10)

// 创建虚拟输入批次(例如,4张图像,1通道,28x28像素)
// requires_grad=False,因为我们只是进行前向传播演示
val dummy_input_cnn = torch.randn(4, 1, 28, 28, requires_grad=false)

// 执行前向传播
val output_cnn = cnn_model(dummy_input_cnn)

// 打印输入和输出形状
println(s"Input shape: ${dummy_input_cnn.shape}")
println(s"Output shape: ${output_cnn.shape}")
      SimpleCNN cnnModel = new SimpleCNN(10);

            // 创建虚拟输入批次(例如,4张图像,1通道,28x28像素)
            // requires_grad=False,因为我们只是进行前向传播演示
            Tensor dummyInputCnn = torch.randn(new long[]{4, 1, 28, 28})
                    .to(torch.ScalarType.Float);
            dummyInputCnn.requires_grad_(false);

            // 执行前向传播
            Tensor outputCnn = cnnModel.forward(dummyInputCnn);

            // 打印输入和输出形状
            System.out.println("\nInput shape: " + getShapeString(dummyInputCnn));
            System.out.println("Output shape: " + getShapeString(outputCnn));

            // 关闭临时张量
            dummyInputCnn.close();
            outputCnn.close();

运行这段代码应该输出:

Input shape: torch.Size([4, 1, 28, 28])
Output shape: torch.Size([4, 10])

这确认了我们的网络接收一个包含4张图像的批次,并为每张图像输出10个类别的预测。请注意forward方法如何决定数据流向,以及我们如何需要根据最终池化层的输出形状来计算线性层的展平尺寸。你可以回顾“理解CNN层的输入/输出形状”一节,练习手动计算这些维度。

实现一个基本RNN

RNN旨在处理序列数据。例如,让我们构建一个可以处理字符序列或传感器读数的简单RNN。

定义RNN架构

我们将使用nn.RNN层。请记住,RNN层期望的输入格式是(序列长度,批次大小,输入特征)。

import torch
import torch.nn as nn

class SimpleRNN extends nn.Module:
    def __init__(self, input_size: Int, hidden_size: Int, output_size: Int, num_layers: Int = 1):
        super(SimpleRNN, self).__init__()
        self.hidden_size = hidden_size
        self.num_layers = num_layers

        // RNN层
        // 输入尺寸: input_size
        // 隐藏状态尺寸: hidden_size
        // 层数: num_layers
        // batch_first=False是默认值,期望输入格式为: (序列长度, 批次, 输入特征)
        val rnn = nn.RNN(input_size, hidden_size, num_layers, batch_first=False)

        // 全连接层,将RNN输出映射到最终输出尺寸
        val fc = nn.Linear(hidden_size, output_size)

    def forward(self, x: Tensor, h0: Tensor = None):
        // x 形状: (序列长度, 批次, 输入特征)

        // 如果未提供,则初始化隐藏状态
        // 形状: (层数 * 方向数, 批次, 隐藏尺寸)
        if h0 is None then
            h0 = torch.zeros(self.num_layers, x.size(1), self.hidden_size).to(x.device)

        // 数据通过RNN层
        // out 形状: (序列长度, 批次, 隐藏尺寸) -> 包含每个时间步的输出特征
        // hn 形状: (层数 * 方向数, 批次, 隐藏尺寸) -> 包含最终隐藏状态
        val out, hn = rnn(x, h0)

        // 我们可以选择使用最后一个时间步的输出
        // out[-1] 形状: (批次, 隐藏尺寸)
        // 或者,如果需要,处理整个序列 'out'
        val out_last_step = out(-1, :, :)

        // 将最后一个时间步的输出通过线性层
        val final_output = fc(out_last_step)
        // final_output 形状: (批次, 输出尺寸)

        return final_output, hn // 返回最终输出和最后一个隐藏状态
class SimpleCNN extends Module {
    private final Conv2dImpl conv1;
    private final ReLUImpl relu1;
    private final MaxPool2dImpl pool1;
    private final Conv2dImpl conv2;
    private final ReLUImpl relu2;
    private final MaxPool2dImpl pool2;
    private final LinearImpl fc;

    public SimpleCNN(long numClasses) {
        super("SimpleCNNz");
        var padding = 1l
        LongPointer paddingPointer = new LongPointer(padding, padding);
        // 定义卷积层和池化层
        Conv2dOptions conv1Options = new Conv2dOptions(1, 16, new LongPointer(3));
        conv1Options.stride().put(1);
        conv1Options.padding().put(paddingPointer);
        this.conv1 = register_module("conv1", new Conv2dImpl(conv1Options));

        this.relu1 = register_module("relu1", new ReLUImpl());

        MaxPool2dOptions pool1Options = new MaxPool2dOptions(new LongPointer(2));
        pool1Options.stride().put(2);
        this.pool1 = register_module("pool1", new MaxPool2dImpl(pool1Options));

        Conv2dOptions conv2Options = new Conv2dOptions(16, 32, new LongPointer(3));

        conv2Options.stride().put(1);
        conv2Options.padding().put(paddingPointer);
        this.conv2 = register_module("conv2", new Conv2dImpl(conv2Options));

        this.relu2 = register_module("relu2", new ReLUImpl());

        MaxPool2dOptions pool2Options = new MaxPool2dOptions(new LongPointer(2));
        pool2Options.stride().put(2);
        this.pool2 = register_module("pool2", new MaxPool2dImpl(pool2Options));

        // 定义全连接层
        this.fc = register_module("fc", new LinearImpl(32 * 7 * 7, numClasses));
    }

    public Tensor forward(Tensor input) {
        // 应用第一个卷积块
        Tensor out = conv1.forward(input);
        out = relu1.forward(out);
        out = pool1.forward(out);

        // 应用第二个卷积块
        out = conv2.forward(out);
        out = relu2.forward(out);
        out = pool2.forward(out);

        // 展平卷积层的输出
        // -1 表示推断批次大小
        out = out.view(out.size(0), -1);

        // 应用全连接层
        out = fc.forward(out);

        return out;
    }
}


在此示例中:

  • input_size:序列中每个步长的特征数量。
  • hidden_size:隐藏状态中的特征数量。
  • num_layers:堆叠的RNN层数。
  • nn.RNN(...):核心RNN层。batch_first=False是默认值,表示序列长度维度在前。
  • forward方法接受输入序列x和一个可选的初始隐藏状态h0。如果未提供h0,则将其初始化为零。
  • nn.RNN层返回out(每个时间步的输出)和hn(最终隐藏状态)。
  • 我们经常使用最后一个时间步的输出(out[-1, :, :])进行序列分类或预测任务,并将其通过一个最终的线性层。

使用虚拟数据测试RNN

让我们创建一个虚拟序列并将其传入我们的RNN。

// 定义参数
val input_features = 10 // 例如,字符/单词的嵌入维度
val hidden_nodes = 20
val output_classes = 5 // 例如,根据序列预测5个类别之一
val sequence_length = 15
val batch_size = 4

// 实例化模型
val rnn_model = SimpleRNN(input_size=input_features, hidden_size=hidden_nodes, output_size=output_classes)

// 创建虚拟输入批次(序列长度,批次大小,输入特征)
// requires_grad=False 用于演示
val dummy_input_rnn = torch.randn(sequence_length, batch_size, input_features, requires_grad=false)

// 执行前向传播(不提供h0,它将被初始化)
val output_rnn, final_hidden_state = rnn_model(dummy_input_rnn)

// 打印输入和输出形状
println(s"Input sequence shape: ${dummy_input_rnn.shape}")
println(s"Output prediction shape: ${output_rnn.shape}")
println(s"Final hidden state shape: ${final_hidden_state.shape}")
     try (PointerScope scope = new PointerScope()) {
            // 06
            // 定义参数
            long inputFeatures = 10; // 例如,字符/单词的嵌入维度
            long hiddenNodes = 20;
            long outputClasses = 5; // 例如,根据序列预测5个类别之一
            long sequenceLength = 15;
            long batchSize = 4;

            // 实例化模型
            SimpleRNNs rnnModel = new SimpleRNNs(inputFeatures, hiddenNodes, outputClasses, 1);

            // 创建虚拟输入批次(序列长度,批次大小,输入特征)
            // requires_grad=False 用于演示
            Tensor dummyInputRnn = torch.randn(new long[]{sequenceLength, batchSize, inputFeatures})
                    .to(torch.ScalarType.Float);
            dummyInputRnn.requires_grad_(false);

            // 执行前向传播(不提供h0,它将被初始化)
           RNNOutput rnnOutput = rnnModel.forward(dummyInputRnn, null);

            // 打印输入和输出形状
            System.out.println("Input sequence shape: " + getShapeString(dummyInputRnn));
            System.out.println("Output prediction shape: " + getShapeString(rnnOutput.getOutput()));
            System.out.println("Final hidden state shape: " + getShapeString(rnnOutput.getHiddenState()));

            // 关闭临时张量
            dummyInputRnn.close();
            rnnOutput.close();
            
            }

运行这段代码应该产生类似如下的输出:

Input sequence shape: torch.Size([15, 4, 10])
Output prediction shape: torch.Size([4, 5])
Final hidden state shape: torch.Size([1, 4, 20])

这表明模型处理一个包含4个序列的批次,每个序列长15步,每步有10个特征。它为批次中的每个序列输出一个大小为5的最终预测向量,以及最终的隐藏状态。隐藏状态的形状反映了(层数,批次大小,隐藏尺寸)。

更多实践

现在你已经实现了这些架构的基本版本,请尝试进行试验:

  1. CNN变体:
    • 更改nn.Conv2d层中的kernel_sizestridepadding。在运行代码之前预测输出形状。当步长为1时,padding='same'如何影响输出维度?
    • 添加另一个卷积/池化块。请记住重新计算nn.Linear层的输入尺寸。
    • 更改卷积层中out_channels的数量。
  2. RNN变体:
    • 增加SimpleRNN中的num_layers。观察初始隐藏状态h0和最终隐藏状态hn的形状。
    • 更改hidden_size
    • nn.RNN替换为nn.LSTMnn.GRU。请注意,nn.LSTM处理一个元组隐藏状态(隐藏状态和单元状态)。你需要相应调整隐藏状态的初始化和处理方式。输入/输出形状大体遵循相同的模式。
    • 修改forward方法,以使用所有时间步的输出(out)而不是仅最后一个,例如通过对每个步应用线性层或使用平均等聚合方法。

这次实践为构建CNN和RNN提供了扎实基础。通过理解如何定义这些层、在forward方法中连接它们以及管理它们的输入/输出形状,你将具备良好能力,能够使用PyTorch构建和调整这些强大的架构以完成各种深度学习任务。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐