余利区

 找回密码
 立即注册
查看: 107|回复: 0

初学者入门,使用pytorch构建RNN网络

[复制链接]

3

主题

6

帖子

12

积分

新手上路

Rank: 1

积分
12
发表于 2022-12-2 17:46:41 | 显示全部楼层 |阅读模式
本篇为翻译文章,原文地址如下
Beginner’s Guide on Recurrent Neural Networks with PyTorch

RNN网络已经解答了许多序列问题和NLP问题,它的一个经典实例LSTM仍然广泛应用于当前最优秀的模型中。
在本篇,我将概述RNN有关的概念,同时使用pytorch实现一个简单的vanilla RNN模型来生成文本。

虽然本篇内容是用于入门,但还是希望读者至少有一些基本的对前馈神经网络的理解。
好,现在我们开始。

1. 基础概念

什么是RNN? 首先,让我们来对比一下RNN和传统前馈神经网络的架构与流程。



对比图

主要区别在于模型如何获取输入数据。
传统的前馈神经网络接收固定数量的输入数据同时, 每次都产生固定数量的输出。 另外,RNN 不会一次处理所有输入数据。 相反,他们一次又一次地按顺序接收它们。 在每一步,RNN 在产生输出之前都会进行一系列计算。 然后将输出(称为隐藏状态)与序列中的下一个输入组合以产生另一个输出。 这个过程一直持续到模型被执行完成或输入序列结束。

看到这里,还是有一些不理解? 下面有一个动态图片, 能够可视化 RNN 的流程。



rnn可视化图

你可以看到,每一时刻的计算以隐藏状态(hidden state)的形式考虑到了前一时刻的上下文(context)。能够使用来自先前输入的上下文(context)信息是 RNN 在序列问题中取得成功的关键。
注: 隐藏状态,hidden state; 上下文,Context;后面不再翻译为中文了。
你可能看到在计算图中每一时刻都在使用一个不同的RNN单元(Cell),但实际上,贯穿整个网络,RNN cell始终都是一个.
注:RNN单元,或RNN 细胞,RNN Cell,后面不再翻译为中文了。
2. RNN的输出

你可能想知道,我从 RNN 的哪个部分获取输出? 这实际上取决于使用的情况。
例如,如果将 RNN 用于分类任务,则在传入所有输入后,只需要一个最终输出,一个表示分类概率分数的向量。 在另一种情况下,如果正在根据前一个字符/单词进行文本生成,将需要在每个时间步都有一个输出。



RNN的不同输出场景

这是 RNN 真正灵活的地方,可以适应不同的需求。 如上图所示,输入和输出大小可以有不同的形式,但它们仍然可以输入到 RNN 模型中并从中获取输出。



多对一

对于只需要整个过程的单个输出的场景,获取该输出可能相当简单,因为可以轻松获取序列中最后一个 RNN Cell产生的输出。 由于此最终输出已经通过所有先前的Cell进行了计算,因此已捕获所有先前输入的Context。 这意味着最终结果确实取决于所有先前的计算和输入。



多对多

对于第二种情况,需要来自中间时刻的输出信息,该信息可以从每个步骤产生的hidden state中获取,如上图所示。 如有必要,产生的输出也可以在下一个时刻反馈到模型中。
当然,可以从 RNN 模型获得的输出类型不仅限于这两种情况。 还有其他方法,例如序列到序列转换,其中输出仅在所有输入都通过后按序列生成。 下图描述了它的样子。



序列到序列

注:上图实际上展示的是使用sequence to sequence的方式生成翻译,但在实际网络中,至少有两个RNN网络来完成此任务,一个为encoder,编码器;一个为decoder,解码器。encoder将原文编码生成Context Vector,encoder可以是一个多对一的RNN网络结构;decoder需要该context vector, encoder的hidden state来生成译文,decoder是一个多对多的RNN网络结构。
3. 工作原理


现在我们已经对 RNN 的工作原理有了基本的了解和鸟瞰图,让我们探索 RNN  Cell必须进行的一些基本计算,以产生隐藏状态和输出。
hidden_t = F(hidden_{t-1}, input_t)
初始状态下,hidden state一般被初始为全0矩阵。 在最简单的 RNN 中,hidden state和输入数据将乘以通过 Xavier 或 Kaiming 等方案初始化的权重矩阵(可以在此处阅读有关此主题的更多信息)。计算的结果被传入激活函数中产生非线性变化。
hidden_t = tanh(weight_{hidden} * hidden_{t-1} + weight_{input} * input_t)
此外,如果我们需要在每个时刻的末尾输出一个输出,我们可以将我们刚刚产生的hidden state传递给一个线性层,或者将它乘以另一个权重矩阵以获得所需的结果形状。
output_t = weight_{output} * hidden_t
然后,我们刚刚生成的hidden state将与下一个输入一起反馈到 RNN Cell中,并且这个过程一直持续到我们用完输入或模型执行完成生成输出。
如前所述,上面介绍的这些计算只是 RNN Cell如何进行计算的简单表示。 对于更高级的 RNN 结构,如 LSTM、GRU 等,计算通常要复杂得多。

4. 训练与反向传播

与其他形式的神经网络类似,RNN 模型需要经过训练才能在一组输入通过后产生准确且理想的输出。



反向传播示意图

在训练过程中,对于每条训练数据,我们都会有一个相应的真实标签,或者简单地放置一个我们希望模型输出的“正确答案”。 当然,在我们通过模型传递输入数据的前几次,我们不会获得等于这些正确答案的输出。 然而,在收到这些输出后,我们在训练期间要做的是计算该过程的损失,它衡量模型的输出与正确答案的差距。 使用这个损失,我们可以计算反向传播的损失函数的梯度。
使用我们刚刚获得的梯度,我们可以相应地更新模型中的权重,以便将来使用输入数据进行的计算将产生更准确的结果。 这里的权重是指在前向传播过程中与输入数据和hidden state相乘的权重矩阵。 计算梯度和更新权重的整个过程称为反向传播。 与前向传播相结合,反向传播会一次又一次地循环,每次修改权重矩阵值以挑选出数据模式时,模型的输出都会变得更加准确。
尽管看起来好像每个 RNN Cell都使用不同的权重,如图所示,但所有权重实际上是相同的,因为 RNN 单元在整个过程中基本上被重复使用。 因此,只有输入数据和前向hidden state在每个时刻都是唯一的。

5. 文本输入

神经网络不像人类那样善于出于文本输入。因为绝大多数的NLP任务,文本数据都会先通过嵌入码(Embedding code),独热编码(One-hot encoding)等方式转为数字编码。在本篇文章中,我将使用one-hot编码标识我们的字符。因此,我将简要介绍一下它是什么。
这里的字符,是原文中的characters,是指单词,以及符号
与大多数机器学习或深度学习项目一样,数据预处理通常会占用项目大部分时间。 在稍后的示例中,我们将把文本数据预处理为简单的表示形式——字符级别的One-hot encoding。
这种编码形式基本上是给文本中的每个字符一个唯一的向量。 例如,如果我们的文本只包含单词“GOOD”,那么只有 3 个唯一字符,G,O,D,三个,因此我们的词汇量只有 3。我们将为每个唯一字符分配一个唯一向量,其中除了索引中的一项之外,所有项都为零。 这就是我们向模型表示每个字符的方式。
对于只有三个单词的one-hot,那么维度即为3;按序编码G,O,D,那么
G为1,展开one-hot就是[1,0,0],
O为2,  就是[0,1,0],
D为3,就是[0,0,1]



one-hot encoding

输出也可能类似,我们可以取向量中的最高数字并将其作为预测字符。

6. 动手实例

我们已经了解了 RNN 的大部分基础知识。 虽然可能仍然有一些不确定的概念,但有时在代码中阅读和实现它可能会帮助你理清思路!

在这个实现中,我们将使用 PyTorch 库,这是一个易于使用并被顶级研究人员广泛使用的深度学习平台。 我们将构建一个模型,该模型将根据传入的一个单词或几个字符来完成一个句子。


该模型将输入一个单词,并预测句子中的下一个字符是什么。 这个过程会不断重复,直到我们生成所需长度的句子。
为了保持简短和简单,我们不会使用任何大型或外部数据集。 相反,我们将只定义几个句子来看看模型如何从这些句子中学习。 此实现将采取的过程如下:


我们将首先导入主要的 PyTorch 包以及构建模型时将使用的 nn 包。 此外,我们将只使用 NumPy 来预处理我们的数据,因为 Torch 与 NumPy 配合得非常好。
import torch
from torch import nn

import numpy as np首先,我们将定义我们希望模型在输入第一个单词或前几个字符时输出的句子。
然后我们将从句子中的所有字符创建一个字典,并将它们映射到一个整数。 这将允许我们将输入字符转换为它们各自的整数(char2int),反之亦然(int2char)。
text = ['hey how are you','good i am fine','have a nice day']

# Join all the sentences together and extract the unique characters from the combined sentences
chars = set(''.join(text))

# Creating a dictionary that maps integers to the characters
int2char = dict(enumerate(chars))

# Creating another dictionary that maps characters to integers
char2int = {char: ind for ind, char in int2char.items()}char2int 字典看起来像这样:它包含我们句子中出现的所有字母/符号,并将它们中的每一个映射到一个唯一的整数。
[Out]: {'f': 0, 'a': 1, 'h': 2, 'i': 3, 'u': 4, 'e': 5, 'm': 6, 'w': 7, 'y': 8, 'd': 9, 'c': 10, ' ': 11, 'r': 12, 'o': 13, 'n': 14, 'g': 15, 'v': 16}接下来,我们将填充(padding)输入句子以确保所有句子都是标准长度。 虽然 RNN 通常能够接收可变大小的输入,但我们通常希望分批输入训练数据以加快训练过程。 为了使用批次来训练我们的数据,我们需要确保输入数据中的每个序列大小相等。
因此,在大多数情况下,可以通过用 0 值填充太短的序列和修剪太长的序列来完成填充。 在我们的例子中,我们将找到最长序列的长度,并用空格填充其余句子以匹配该长度。
# Finding the length of the longest string in our data
maxlen = len(max(text, key=len))

# Padding

# A simple loop that loops through the list of sentences and adds a ' ' whitespace until the length of
# the sentence matches the length of the longest sentence
for i in range(len(text)):
  while len(text)<maxlen:
      text += ' '由于我们要在每个时间步预测序列中的下一个字符,我们必须将每个句子分为:

  • 输入数据
    最后一个字符需排除因为它不需要作为模型的输入
  • 目标/真实标签
    它为每一个时刻后的值,因为这才是下一个时刻的值。
# Creating lists that will hold our input and target sequences
input_seq = []
target_seq = []

for i in range(len(text)):
    # Remove last character for input sequence
  input_seq.append(text[:-1])
   
    # Remove first character for target sequence
  target_seq.append(text[1:])
  print("Input Sequence: {}\nTarget Sequence: {}".format(input_seq, target_seq))输入和输出样例如下:

  • 输入:hey how are yo
  • 对应的标签:  ey how are you
现在我们可以通过使用上面创建的字典映射输入和目标序列到整数序列。 这将允许我们随后对输入序列进行一次one-hot encoding。
for i in range(len(text)):
    input_seq = [char2int[character] for character in input_seq]
    target_seq = [char2int[character] for character in target_seq]定义如下三个变量

  • dict_size: 字典的长度,即唯一字符的个数。它将决定one-hot vector的长度
  • seq_len:输入到模型中的sequence长度。这里是最长的句子的长度-1,因为不需要最后一个字符
  • batch_size: mini batch的大小,用于批量训练
dict_size = len(char2int)
seq_len = maxlen - 1
batch_size = len(text)

def one_hot_encode(sequence, dict_size, seq_len, batch_size):
    # Creating a multi-dimensional array of zeros with the desired output shape
    features = np.zeros((batch_size, seq_len, dict_size), dtype=np.float32)
   
    # Replacing the 0 at the relevant character index with a 1 to represent that character
    for i in range(batch_size):
        for u in range(seq_len):
            features[i, u, sequence] = 1
    return features同时定义一个helper function,用于初始化one-hot向量
# Input shape --> (Batch Size, Sequence Length, One-Hot Encoding Size)
input_seq = one_hot_encode(input_seq, dict_size, seq_len, batch_size)到此我们完成了所有的数据预处理,可以将数据从NumPy数组转为PyTorch张量啦。
input_seq = torch.from_numpy(input_seq)
target_seq = torch.Tensor(target_seq)接下来就是搭建模型的步骤,你可以在这一步使用全连接层,卷积层,RNN层,LSTM层等等。但是我在这里使用最最基础的nn.rnn来示例一个RNN是如何使用的。
在开始构建模型之前,让我们使用 PyTorch 中的内置功能来检查我们正在运行的设备(CPU 或 GPU)。 此实现不需要 GPU,因为训练非常简单。 但是,随着处理具有数百万个可训练参数的大型数据集和模型,使用 GPU 对加速训练非常重要。
# torch.cuda.is_available() checks and returns a Boolean True if a GPU is available, else it'll return False
is_cuda = torch.cuda.is_available()

# If we have a GPU available, we'll set our device to GPU. We'll use this device variable later in our code.
if is_cuda:
    device = torch.device("cuda")
    print("GPU is available")
else:
    device = torch.device("cpu")
    print("GPU not available, CPU used")要开始构建我们自己的神经网络模型,我们可以为所有神经网络模块定义一个继承 PyTorch 的基类(nn.module)的类。 这样做之后,我们可以开始在构造函数下定义一些变量以及模型的层。 对于这个模型,我们将只使用一层 RNN,然后是一个全连接层。 全连接层将负责将 RNN 输出转换为我们想要的输出形状。
我们还必须将 forward() 下的前向传递函数定义为类方法。 前向函数是按顺序执行的,因此我们必须先将输入和零初始化隐藏状态通过 RNN 层,然后再将 RNN 输出传递到全连接层。 请注意,我们使用的是在构造函数中定义的层。
我们必须定义的最后一个方法是我们之前调用的用于初始化hidden state的方法 - init_hidden()。 这基本上会在我们的隐藏状态的形状中创建一个零张量。
class Model(nn.Module):
    def __init__(self, input_size, output_size, hidden_dim, n_layers):
        super(Model, self).__init__()

        # Defining some parameters
        self.hidden_dim = hidden_dim
        self.n_layers = n_layers

        #Defining the layers
        # RNN Layer
        self.rnn = nn.RNN(input_size, hidden_dim, n_layers, batch_first=True)   
        # Fully connected layer
        self.fc = nn.Linear(hidden_dim, output_size)
   
    def forward(self, x):
        
        batch_size = x.size(0)

        # Initializing hidden state for first input using method defined below
        hidden = self.init_hidden(batch_size)

        # Passing in the input and hidden state into the model and obtaining outputs
        out, hidden = self.rnn(x, hidden)
        
        # Reshaping the outputs such that it can be fit into the fully connected layer
        out = out.contiguous().view(-1, self.hidden_dim)
        out = self.fc(out)
        
        return out, hidden
   
    def init_hidden(self, batch_size):
        # This method generates the first hidden state of zeros which we'll use in the forward pass
        # We'll send the tensor holding the hidden state to the device we specified earlier as well
        hidden = torch.zeros(self.n_layers, batch_size, self.hidden_dim)
        return hidde在定义了上面的模型之后,我们必须用相关参数实例化模型并定义我们的超参数。 我们在下面定义的超参数是:

  • n_epochs: 模型训练所有数据集的次数
  • lr: learning rate学习率
有关超参数,可以参考该文章作为进一步学习。
与其他神经网络类似,我们也必须定义优化器和损失函数。 我们将使用 CrossEntropyLoss,因为最终输出基本上是一个分类任务和常见的 Adam 优化器。
# Instantiate the model with hyperparameters
model = Model(input_size=dict_size, output_size=dict_size, hidden_dim=12, n_layers=1)
# We'll also set the model to the device that we defined earlier (default is CPU)
model.to(device)

# Define hyperparameters
n_epochs = 100
lr=0.01

# Define Loss, Optimizer
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=lr)现在我们可以开始训练了! 由于我们只有几句话,所以这个训练过程非常快。 然而,随着我们的进步,更大的数据集和更深的模型意味着输入数据要大得多,并且我们必须计算的模型中的参数数量要多得多。
# Training Run
for epoch in range(1, n_epochs + 1):
    optimizer.zero_grad() # Clears existing gradients from previous epoch
    input_seq.to(device)
    output, hidden = model(input_seq)
    loss = criterion(output, target_seq.view(-1).long())
    loss.backward() # Does backpropagation and calculates gradients
    optimizer.step() # Updates the weights accordingly
   
    if epoch%10 == 0:
        print('Epoch: {}/{}.............'.format(epoch, n_epochs), end=' ')
        print("Loss: {:.4f}".format(loss.item()))

[Out]:  Epoch: 10/100............. Loss: 2.4176
        Epoch: 20/100............. Loss: 2.1816
        Epoch: 30/100............. Loss: 1.7952
        Epoch: 40/100............. Loss: 1.3524
        Epoch: 50/100............. Loss: 0.9671
        Epoch: 60/100............. Loss: 0.6644
        Epoch: 70/100............. Loss: 0.4499
        Epoch: 80/100............. Loss: 0.3089
        Epoch: 90/100............. Loss: 0.2222
        Epoch: 100/100............. Loss: 0.1690
现在让我们测试我们的模型,看看我们会得到什么样的输出。 作为第一步,我们将定义一些辅助函数来将我们的模型输出转换回文本。
# This function takes in the model and character as arguments and returns the next character prediction and hidden state
def predict(model, character):
    # One-hot encoding our input to fit into the model
    character = np.array([[char2int[c] for c in character]])
    character = one_hot_encode(character, dict_size, character.shape[1], 1)
    character = torch.from_numpy(character)
    character.to(device)
   
    out, hidden = model(character)

    prob = nn.functional.softmax(out[-1], dim=0).data
    # Taking the class with the highest probability score from the output
    char_ind = torch.max(prob, dim=0)[1].item()

    return int2char[char_ind], hidden
# This function takes the desired output length and input characters as arguments, returning the produced sentence
def sample(model, out_len, start='hey'):
    model.eval() # eval mode
    start = start.lower()
    # First off, run through the starting characters
    chars = [ch for ch in start]
    size = out_len - len(chars)
    # Now pass in the previous characters and get a new one
    for ii in range(size):
        char, h = predict(model, chars)
        chars.append(char)

    return ''.join(chars)让我们测试一下good
sample(model, 15, 'good')
[Out]: 'good i am fine '
正如我们所看到的,如果我们用“good”这个词输入到模型,该模型能够提出“good i am fine”这个句子。 几行代码相当不错,是吗?
<hr/>
这里是完整的源码地址

6.1. 该模型的局限

虽然这个模型绝对是一个过度简化的语言模型,但让我们回顾一下它的局限性以及为了训练更好的语言模型需要解决的问题。
局限一、过拟合 over-fitting
我们只为模型提供了 3 个训练句子,因此它基本上“记住”了这些句子的字符序列,从而返回了我们训练它的确切句子。 但是,如果在更大的数据集上训练一个类似的模型,并添加一些随机性,该模型将挑选出一般的句子结构和语言规则,并且能够生成自己独特的句子。
尽管如此,使用单个样本或批次运行模型可以作为对工作流程的健全性检查,确保您的数据类型全部正确,模型学习良好等。
局限二、处理未见过的字符
该模型目前只能处理它之前在训练数据集中看到的字符。 通常,如果训练数据集足够大,所有字母和符号等应该至少出现一次,从而出现在我们的词汇表中。 然而,有一种方法来处理从未见过的字符总是好的,例如将所有未知数分配给它自己的索引。
局限三、文本标识的方式
在这个实现中,我们使用 one-hot 编码来表示我们的字符。 虽然由于它的简单性,它可能适合此任务,但大多数时候它不应该用作实际或更复杂问题的解决方案。 这是因为:

  • 对于大型数据集,计算成本太高
  • one-hot向量中没有嵌入上下文/语义信息
以及许多其他使此解决方案不太可行的缺点。
相反,大多数现代 NLP 解决方案依赖于词嵌入(word2vec、GloVe)或最近在 BERT、ELMo 和 ULMFit 中的独特上下文词表示。 这些方法允许模型根据出现在它之前的文本来学习单词的含义,并且在 BERT 等的情况下,也可以从出现在它之后的文本中学习。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

云顶设计嘉兴有限公司模板设计.

免责声明:本站上数据均为演示站数据,如购买模板可以上DISCUZ应用中心购买,欢迎惠顾.

云顶官方站点:云顶设计 模板原创设计:云顶模板   Powered by Discuz! X3.4© 2001-2017 Comsenz Inc.

快速回复 返回顶部 返回列表