这次作业我们先完成相应的题目,相关知识点与总结全部在Part.4部分,前面放上各个作业问题的代码实现与结果。 

1. 实现SRN

SRN,即简单循环网络(Simple Recurrent Network,SRN)-只有一个隐藏层的循环神经网络,下面我们对其进行不同方式的实现。

(1)使用Numpy

老师已经在课上给出了需要实现的SRN结构,我们照着网络进行编程实现。

分析SRN的基本结构我们可以知道,对于每一时刻输入的数据,隐藏层h_1是由两条路径计算得到的:①由输入x_1x_2加权计算得到。②由延时单元a_1a_2加权计算得到。最后将两条路径所得到的结果进行加和即是隐层神经元h_1的值。

同时我们还要注意在每一次计算完隐层神经元,要及时更新延时器两个延时单元的值。由此可得到代码。

代码 

import numpy as np

# 设置输入序列
inputs = np.array([[1., 1.],
                   [1., 1.],
                   [2., 2.]])
print(f"输入序列\n{inputs}\n")
# 设置延时结构
state = np.zeros(2, )  # 初始化
print(f"存储结构{state}\n")
# 设置权重都为 1
print('----------------------------------')
w1, w2, w3, w4, w5, w6, w7, w8 = 1., 1., 1., 1., 1., 1., 1., 1.
# 存储结构权重
U1, U2, U3, U4 = 1., 1., 1., 1.
# 前线传播,计算输出
for t, input_t in enumerate(inputs):
    print('第', t + 1, '时刻:')
    print('输入序列\n', input_t)
    print('延时单元\n', state)
    # 先根据输入与延时单元计算隐层神经元h1与h2
    in_h1 = np.dot([w1, w3], input_t) + np.dot([U2, U4], state)  # h1由两条路径计算,一条是x1与x2计算,另一条是a1与a2也就是延时单元
    in_h2 = np.dot([w2, w4], input_t) + np.dot([U1, U3], state)  # h2同理
    state = in_h1, in_h2  # 本时刻计算完成后,记录隐层神经元作为存储
    output_y1 = np.dot([w5, w7], [in_h1, in_h2])
    output_y2 = np.dot([w6, w8], [in_h1, in_h2])
    print('输出为:', output_y1, output_y2)
    print('----------------------------------')

 结果

 得到的结果与老师所给出的答案相同,编程验证成功!

(2)在1的基础上,增加激活函数tanh

在计算隐藏层的代码上添加tanh激活即可。

代码

import numpy as np

# 设置输入序列
inputs = np.array([[1., 1.],
                   [1., 1.],
                   [2., 2.]])
print(f"输入序列\n{inputs}\n")
# 设置延时结构
state = np.zeros(2, )  # 初始化
print(f"存储结构{state}\n")
# 设置权重都为 1
print('----------------------------------')
w1, w2, w3, w4, w5, w6, w7, w8 = 1., 1., 1., 1., 1., 1., 1., 1.
# 存储结构权重
U1, U2, U3, U4 = 1., 1., 1., 1.
# 前线传播,计算输出
for t, input_t in enumerate(inputs):
    print('第', t + 1, '时刻:')
    print('输入序列\n', input_t)
    print('延时单元\n', state)
    # 先根据输入与延时单元计算隐层神经元h1与h2
    in_h1 = np.tanh(np.dot([w1, w3], input_t) + np.dot([U2, U4], state))  # h1由两条路径计算,一条是x1与x2计算,另一条是a1与a2也就是延时单元
    in_h2 = np.tanh(np.dot([w2, w4], input_t) + np.dot([U1, U3], state))  # h2同理
    state = in_h1, in_h2  # 本时刻计算完成后,记录隐层神经元作为存储
    output_y1 = np.dot([w5, w7], [in_h1, in_h2])
    output_y2 = np.dot([w6, w8], [in_h1, in_h2])
    print('输出为:', output_y1, output_y2)
    print('----------------------------------')

结果

结果也与老师给出的验证结果相同。 

(3)使用nn.RNNCell实现

使用torch.nn.RNNCell()函数进行SRN构造,这个函数是实现单层、基本循环神经网络单元的模块。详细介绍在后文中涉及。

代码

import torch

batch_size = 1
seq_len = 3  # 序列长度
input_size = 2  # 输入的序列长度
hidden_size = 2  # 隐藏层维度
output_size = 2  # 输出维度
# 设置RNNCell
cell = torch.nn.RNNCell(input_size=input_size, hidden_size=hidden_size)
# 初始化参数,连接输入层与隐层
for name, param in cell.named_parameters():
    if name.startswith('weight'):
        torch.nn.init.ones_(param)  # 如果是权重就初始化为1
    else:
        torch.nn.init.zeros_(param)  # 不是权重都初始化为0
# 线性层,连接隐层与输出层
linear = torch.nn.Linear(hidden_size, output_size)
# 直接初始化即可
linear.weight.data = torch.Tensor([[1, 1], [1, 1]])
linear.bias.data = torch.Tensor([0, 0])
# 设置输入序列
seq = torch.Tensor([[[1, 1]],
                    [[1, 1]],
                    [[2, 2]]])
# 初始化中间值
hidden = torch.zeros(batch_size, hidden_size)
output = torch.zeros(batch_size, output_size)

for idx, input in enumerate(seq):
    print('--------------------------------------')
    print('第', idx, '时刻:')

    print('输入:', input)
    print('隐层:', hidden)

    hidden = cell(input, hidden)
    output = linear(hidden)
    print('输出:', output)

结果

这里结果是激活之后的,与(2)中手动实现的SRN输出结果完全相同。

这里明明没有用激活函数,可是出来的结果却是与激活后的值相同,这就证明了在torch.nn.RNNCell()函数中是有进行默认激活操作的,而激活函数正是tanh。 

(4)使用nn.RNN实现

使用torch.nn.RNN实现,与RNNCell()区别在于多了一个层数参数,因为RNNCell()仅仅用于处理一个时间步数据,而RNN可以直接处理所有时间步。

代码

import torch

batch_size = 1
seq_len = 3  # 序列长度
input_size = 2  # 输入的序列长度
hidden_size = 2  # 隐藏层维度
output_size = 2  # 输出维度
num_layers = 1  # 隐层有几个
# 设置RNNCell
cell = torch.nn.RNN(input_size=input_size, hidden_size=hidden_size, num_layers=num_layers)
# 初始化参数,连接输入层与隐层
for name, param in cell.named_parameters():
    if name.startswith('weight'):
        torch.nn.init.ones_(param)  # 如果是权重就初始化为1
    else:
        torch.nn.init.zeros_(param)  # 不是权重都初始化为0
# 线性层,连接隐层与输出层
linear = torch.nn.Linear(hidden_size, output_size)
# 直接初始化即可
linear.weight.data = torch.Tensor([[1, 1], [1, 1]])
linear.bias.data = torch.Tensor([0, 0])
# 设置输入序列
seq = torch.Tensor([[[1, 1]],
                    [[1, 1]],
                    [[2, 2]]])
hidden = torch.zeros(num_layers, batch_size, hidden_size)
out, hidden = cell(seq, hidden)

print('输入:', seq[0])
print('隐层:', 0, 0)
print('输出:', linear(out[0]))
print('--------------------------------------')
print('输入:', seq[1])
print('隐层:', out[0])
print('输出:', linear(out[1]))
print('--------------------------------------')
print('输入:', seq[2])
print('隐层:', out[1])
print('输出:', linear(out[2]))

结果 

输出结果相当于多次调用RNNCell()处理,结果与其完全相同,也是正确的。

2. 实现“序列到序列”

观看视频,学习RNN原理,并实现视频P12中的教学案例12.循环神经网络(基础篇)_哔哩哔哩_bilibili

观看视频学习后,得到这一部分UP主刘二大人想要我们做一个简单的RNN网络模型,来学习一下序列"hello"并将其转化为序列"ohlol"。

这张图说明的是,要用RNN Cell来实现上述转换的话,需要其中每一个Cell将对应位置的输入转化为对应的输出,比如h->o,从而实现Seq2Seq的转换。

这张图说明的是,由于单词不是数字,无法进行数值计算,所以我们要将文本编码向量化。视频中给出的方式是将每一个单字母映射为下标构成字典表,随后根据字典再对原文本进行独热编码,可以得到独热向量。

这个映射方法可是说是比较简单了,之前读论文时见过一些经典的Word2vec方法如CBOWSkip-Gram,都是将单词映射为向量的经典方法。

后续视频中讲的就是这个问题本质上来说是一个分类问题,主要是训练网络学得输入是helo,在网络中,得到预测的最可能的输出(也是一个长度为4的向量),再用Softmax函数转化为分布,就可以进行预测了。

这是整理后绘制的网络结构图,输入一个序列,在RNNCell中处理后得到一个长度为4的预测输出,再接Softmax预测为分类值,计算损失反向传播进行训练。 下面可以进行代码实现了。

代码

import torch

# 设置输入尺寸
input_size = 4
hidden_size = 4
batch_size = 1

idx2char = ['e', 'h', 'l', 'o']  # 字典里的所有字母
# 这里进行了一个潜在的字典映射
x_data = [1, 0, 2, 2, 3]  # 输入序列下标化
y_data = [3, 1, 2, 3, 2]  # 输出序列下标化
# 独热向量的查询
one_hot_lookup = [[1, 0, 0, 0],
                  [0, 1, 0, 0],
                  [0, 0, 1, 0],
                  [0, 0, 0, 1]]
x_one_hot = [one_hot_lookup[x] for x in x_data]  # 获取输入x的独热编码查询
# 需要将输入与标签都转换为相应的维度
inputs = torch.Tensor(x_one_hot).view(-1, batch_size, input_size)  # [batch_size,input_size]
labels = torch.LongTensor(y_data).view(-1, 1)


# 构造RNN
class Model(torch.nn.Module):
    def __init__(self, input_size, hidden_size, batch_size):
        super(Model, self).__init__()
        self.batch_size = batch_size
        self.input_size = input_size
        self.hidden_size = hidden_size
        self.rnncell = torch.nn.RNNCell(input_size=self.input_size, hidden_size=self.hidden_size)

    def forward(self, input, hidden):
        hidden = self.rnncell(input, hidden)  # 做一步时间序列前向传播
        return hidden

    def init_hidden(self):
        return torch.zeros(self.batch_size, self.hidden_size)  # 初始化隐层


net = Model(input_size, hidden_size, batch_size)  # 实例化网络
criterion = torch.nn.CrossEntropyLoss()  # 交叉熵损失
optimizer = torch.optim.Adam(net.parameters(), lr=0.1)  # 优化器
# 训练迭代
for epoch in range(15):
    loss = 0
    optimizer.zero_grad()
    hidden = net.init_hidden()  # 初始化
    print('Predicted string: ', end='')
    for input, label in zip(inputs, labels):  # Seq_size * batch_size * input_length
        # 按序列拿取输出,并累加损失
        hidden = net(input, hidden)
        loss += criterion(hidden, label)
        _, idx = hidden.max(dim=1)  # 找输出里的最大值
        print(idx2char[idx.item()], end='')  # 根据词典输出预测
    loss.backward()
    optimizer.step()
    print(', Epoch [%d/15] loss=%.4f' % (epoch + 1, loss.item()))

结果

根据结果可以看到,每一次迭代的预测值是不断变化的,越来越接近我们的预期输出,同时损失值也在不断减小。也验证了RNNCell有学习一个序列,并将其预测为另外一个序列的能力。 

这里后续视频还讲到了用RNN来预测,代码结构变简单了,更改了一些输入输出维度,我们这里给出代码,结果也是差不多的。

import torch

# 设置输入尺寸
input_size = 4
hidden_size = 4
num_layers = 1
seq_len = 5
batch_size = 1

idx2char = ['e', 'h', 'l', 'o']  # 字典里的所有字母
# 这里进行了一个潜在的字典映射
x_data = [1, 0, 2, 2, 3]  # 输入序列下标化
y_data = [3, 1, 2, 3, 2]  # 输出序列下标化
# 独热向量的查询
one_hot_lookup = [[1, 0, 0, 0],
                  [0, 1, 0, 0],
                  [0, 0, 1, 0],
                  [0, 0, 0, 1]]
x_one_hot = [one_hot_lookup[x] for x in x_data]  # 获取输入x的独热编码查询
# 需要将输入与标签都转换为相应的维度
inputs = torch.Tensor(x_one_hot).view(seq_len, batch_size, input_size)  # [batch_size,input_size]
labels = torch.LongTensor(y_data)


# 构造RNN
class Model(torch.nn.Module):
    def __init__(self, input_size, hidden_size, batch_size, num_layers=1):
        super(Model, self).__init__()
        self.num_layers = num_layers
        self.batch_size = batch_size
        self.input_size = input_size
        self.hidden_size = hidden_size
        self.rnn = torch.nn.RNN(input_size=self.input_size, hidden_size=self.hidden_size, num_layers=num_layers)

    def forward(self, input):
        hidden = torch.zeros(self.num_layers, self.batch_size, self.hidden_size)
        out, _ = self.rnn(input, hidden)
        return out.view(-1, self.hidden_size)

    def init_hidden(self):
        return torch.zeros(self.batch_size, self.hidden_size)  # 初始化隐层


net = Model(input_size, hidden_size, batch_size, num_layers)  # 实例化网络
criterion = torch.nn.CrossEntropyLoss()  # 交叉熵损失
optimizer = torch.optim.Adam(net.parameters(), lr=0.1)  # 优化器
# 训练迭代
for epoch in range(15):
    optimizer.zero_grad()
    outputs = net(inputs)
    loss = criterion(outputs, labels)
    loss.backward()
    optimizer.step()
    _, idx = outputs.max(dim=1)
    idx = idx.data.numpy()
    print('Predicted:', ''.join([idx2char[x] for x in idx]), end='')
    print(', Epoch [%d/15] loss=%.4f' % (epoch + 1, loss.item()))

3. “编码器-解码器”的简单实现 

也是一个看视频,自学,并复现的题目。

视频链接:seq2seq的PyTorch实现_哔哩哔哩_bilibili

作者博客:Seq2Seq的PyTorch实现 - mathor

作者在视频中从实战的方面讲解了Seq2Seq。

拿这张图来举例吧,这张图是视频里比较全面的一张图。

编码器的作用就是将输入序列转换为一个固定大小的向量;而解码器的作用就是将编码器生成的上下文向量解码为目标序列。在翻译中,编码器就是将外文转化为向量,解码器就是将向量转化为中文,我们在模型中训练,也是主要训练的编码器与解码器。

图片中的模型,输入文本在解码器中首先通过独热编码的形式,将输入文本向量化,并通过RNN进行预测,得到的最后一层隐藏层状态送入解码器中,作为解码器输入,解码器首先输入一个开始标志,随后不停重复将开始标志与预测结果输入RNN模型,直到预测结束,输出结果加上结束标志,代表着解码完成。

注:这里编码器与解码器均应用RNN网络结构对序列数据进行处理。

代码

import torch
import numpy as np
import torch.nn as nn
import torch.utils.data as Data

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# S: Symbol that shows starting of decoding input
# E: Symbol that shows starting of decoding output
# ?: Symbol that will fill in blank sequence if current batch data size is short than n_step
# 定义字母表和特殊符号集合,同时生成每个字符到索引的映射字典
letter = [c for c in 'SE?abcdefghijklmnopqrstuvwxyz']
letter2idx = {n: i for i, n in enumerate(letter)}

# 每个数据表示输入序列和目标序列的对应关系,任务是反义词预测
seq_data = [['man', 'women'], ['black', 'white'], ['king', 'queen'], ['girl', 'boy'], ['up', 'down'], ['high', 'low']]

# Seq2Seq Parameter
n_step = max([max(len(i), len(j)) for i, j in seq_data])  # max_len(=5)
n_hidden = 128
n_class = len(letter2idx)  # classfication problem
batch_size = 3


def make_data(seq_data):
    # 函数用于将数据转换为独热编码形式,并生成编码器和解码器的输入/输出
    enc_input_all, dec_input_all, dec_output_all = [], [], []

    for seq in seq_data:
        for i in range(2):
            seq[i] = seq[i] + '?' * (n_step - len(seq[i]))  # 'man??', 'women'

        # 编码器输入: 在序列末尾添加结束符号 'E'。
        enc_input = [letter2idx[n] for n in (seq[0] + 'E')]  # ['m', 'a', 'n', '?', '?', 'E']
        # 解码器输入: 在目标序列开头添加起始符号 'S'。
        dec_input = [letter2idx[n] for n in ('S' + seq[1])]  # ['S', 'w', 'o', 'm', 'e', 'n']
        # 解码器输出: 在目标序列末尾添加结束符号 'E'。
        dec_output = [letter2idx[n] for n in (seq[1] + 'E')]  # ['w', 'o', 'm', 'e', 'n', 'E']

        enc_input_all.append(np.eye(n_class)[enc_input])  # 将索引转换为独热向量
        dec_input_all.append(np.eye(n_class)[dec_input])
        dec_output_all.append(dec_output)  # 输出不用独热编码,因为目标是索引

    # make tensor
    return torch.Tensor(enc_input_all), torch.Tensor(dec_input_all), torch.LongTensor(dec_output_all)


'''
enc_input_all: [6, n_step+1 (because of 'E'), n_class]
dec_input_all: [6, n_step+1 (because of 'S'), n_class]
dec_output_all: [6, n_step+1 (because of 'E')]
'''
enc_input_all, dec_input_all, dec_output_all = make_data(seq_data)


class TranslateDataSet(Data.Dataset):
    def __init__(self, enc_input_all, dec_input_all, dec_output_all):
        self.enc_input_all = enc_input_all
        self.dec_input_all = dec_input_all
        self.dec_output_all = dec_output_all

    def __len__(self):  # return dataset size
        return len(self.enc_input_all)

    def __getitem__(self, idx):
        # 返回指定索引的数据,分别是编码器输入、解码器输入和解码器目标输出
        return self.enc_input_all[idx], self.dec_input_all[idx], self.dec_output_all[idx]


loader = Data.DataLoader(TranslateDataSet(enc_input_all, dec_input_all, dec_output_all), batch_size, True)


# Model
class Seq2Seq(nn.Module):
    def __init__(self):
        super(Seq2Seq, self).__init__()
        self.encoder = nn.RNN(input_size=n_class, hidden_size=n_hidden, dropout=0.5)  # encoder
        self.decoder = nn.RNN(input_size=n_class, hidden_size=n_hidden, dropout=0.5)  # decoder
        self.fc = nn.Linear(n_hidden, n_class)  # 自己的注释: 全连接层将隐藏状态映射到分类结果。

    def forward(self, enc_input, enc_hidden, dec_input):
        # enc_input(=input_batch): [batch_size, n_step+1, n_class]
        # dec_inpu(=output_batch): [batch_size, n_step+1, n_class]
        enc_input = enc_input.transpose(0, 1)  # enc_input: [n_step+1, batch_size, n_class]
        dec_input = dec_input.transpose(0, 1)  # dec_input: [n_step+1, batch_size, n_class]

        # h_t : [num_layers(=1) * num_directions(=1), batch_size, n_hidden]
        _, h_t = self.encoder(enc_input, enc_hidden)
        # 使用编码器的最后隐藏状态作为解码器的初始隐藏状态
        # outputs : [n_step+1, batch_size, num_directions(=1) * n_hidden(=128)]
        outputs, _ = self.decoder(dec_input, h_t)

        # 将解码器的输出通过全连接层映射到字符类别空间
        model = self.fc(outputs)  # model : [n_step+1, batch_size, n_class]
        return model


model = Seq2Seq().to(device)
criterion = nn.CrossEntropyLoss().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

for epoch in range(5000):
    for enc_input_batch, dec_input_batch, dec_output_batch in loader:
        # make hidden shape [num_layers * num_directions, batch_size, n_hidden]
        h_0 = torch.zeros(1, batch_size, n_hidden).to(device)

        (enc_input_batch, dec_intput_batch, dec_output_batch) = (
            enc_input_batch.to(device), dec_input_batch.to(device), dec_output_batch.to(device))
        # enc_input_batch : [batch_size, n_step+1, n_class]
        # dec_intput_batch : [batch_size, n_step+1, n_class]
        # dec_output_batch : [batch_size, n_step+1], not one-hot
        pred = model(enc_input_batch, h_0, dec_intput_batch)
        # pred : [n_step+1, batch_size, n_class]
        pred = pred.transpose(0, 1)  # [batch_size, n_step+1(=6), n_class]
        loss = 0
        for i in range(len(dec_output_batch)):
            # pred[i] : [n_step+1, n_class]
            # dec_output_batch[i] : [n_step+1]
            loss += criterion(pred[i], dec_output_batch[i])
        if (epoch + 1) % 1000 == 0:
            print('Epoch:', '%04d' % (epoch + 1), 'cost =', '{:.6f}'.format(loss))

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()


# Test
def translate(word):
    enc_input, dec_input, _ = make_data([[word, '?' * n_step]])
    enc_input, dec_input = enc_input.to(device), dec_input.to(device)
    # make hidden shape [num_layers * num_directions, batch_size, n_hidden]
    hidden = torch.zeros(1, 1, n_hidden).to(device)
    output = model(enc_input, hidden, dec_input)
    # output : [n_step+1, batch_size, n_class]

    predict = output.data.max(2, keepdim=True)[1]  # select n_class dimension
    decoded = [letter[i] for i in predict]
    translated = ''.join(decoded[:decoded.index('E')])

    return translated.replace('?', '')


print('test')
print('man ->', translate('man'))
print('mans ->', translate('mans'))
print('king ->', translate('king'))
print('black ->', translate('black'))
print('up ->', translate('up'))

结果

 可以看到,测试结果与预期结果相同。

4.总结

RNN简介

本次课程学习了RNN的相关知识,作业中也都用到了相关的RNN知识,这里我们进行简单的介绍。

RNN意为循环神经网络(Recurrent Neural Network)是一种具有循环连接的神经网络结构。

我们前面所学到的FNN有一个致命的问题就是,在FNN中,每一个输入加权结合得到一个输出,即我们只关注到了层与层之间的连接与传播忽视了本层之间所有神经元的联系,这就导致了FNN完全无法处理时序数据,即输入的数据之间会因为出现的先后顺序而有影响的数据

就拿我们日常说话来举例,给出一句话:

老师走进教室,对张三说:你好,同学。张三说:您好___

若是使用FNN来学习这句话的话,每一个输入神经元对应一个输入,FNN只会考虑这其中的每个字对空缺位置文字的影响,没考虑字与字之间的相互影响,从而忽略了我们日常说话其实是一种时间序列这一点。

但是序列信息是非常重要的,为了有效的学习序列数据RNN应运而生。

RNN的基本思想就是在每次前向传播时,不只考虑层与层之间的联系,还考虑了隐层神经元之间的相互影响。在每次隐层传播的时候,记录本层隐层状态,以在下一次传播来的时候,结合隐层状态与前向传播得到输出。

上图是一个形象的RNN网络结构,将每个时刻的状态按照列与列的形式展开,图中每一列对应着一个时刻的情况。

继续拿上述例子来说,这样一来,RNN不仅学习每一个字对最终结果的影响,还有这前面的每一个字对现在这个字的影响,既保留了FNN的信息,又保留了时序信息。

循环神经单元

那么RNN如何记录序列中元素与元素之间的信息呢?它是通过额外在每一个隐层建立一个额外的延时单元,存储之前状态的信息。

循环神经单元其实就是在隐层中加入一个延时单元来代表上一个时间步的隐层信息,然后在本层不仅对输入加权求和,还对上一次的隐层信息加权求和得到最终结果。加入了循环神经单元的隐层输出如下: 

h_t=f\left( Uh_{t-1}+Wx_t+b \right)

其中U是状态-状态权重矩阵,W是状态-输入权重矩阵,b是偏置,f(x)是激活函数。

h_{t-1}则是我们加入的演示单元,用于记录上一时刻的隐层状态。 

如上图所示,原先FNN是没有图中的橙色连接的,在加上了循环神经单元之后,隐藏层神经元之间就有了联系。

RNN具有短时记忆

我们已经知道了,RNN由于每一个时间步都会存储上一个时间步的隐层状态,因此RNN的网络是能够记忆一些东西的。但是由于每一次迭代时间步,RNN只会保存前一个时间步的隐层状态,而不是所有前面隐层的状态,只有一种“短时记忆”的效果。

不过其实这里的短时记忆不能算是真正意义上的短时记忆,因为每次迭代的时候下一个时间步的结果也一定程度上受到过前面所有时间步状态的影响,只不过是在迭代的过程中,离得比较远的时间步对本次迭代影响小了而已,这是一种间接的影响,真正起到直接作用的,还是相邻的几个时间步。

为什么RNN默认激活函数是tanh?

其实一开始看到Pytorch给出的默认激活函数是tanh我就有这个疑问,然后在做作业的时候查阅了一下资料,简单总结一下(@知乎 歪杠小胀):

1.Sigmoid函数的梯度值范围非常小,在实际应用中,可能会造成梯度消失问题;而ReLu函数的梯度值又非常大,可能会造成梯度爆炸问题,但是tanh的梯度值就介于两者之间,可小可大,可以说是适应性稍强一些。

2.但是,也不是完全不能用ReLU,是可以使用ReLU作为激活函数的,并且在序列较短、网络较浅时ReLU表现优于tanh,但是当序列变长、网络加深后,ReLu的输出过大,连乘后容易梯度爆炸,效果就比tanh差;

3.图像的分类是由感受野中的局部信息联合决定,而文本、语音等序列的分类可由若干个关键词进行主导分类。即可以说图像信息是稠密的,文本信息是稀疏的。如果使用ReLU激活函数,在ReLU的左侧值为0,可能就会因此出现信息丢失的情况。在LSTM和GRU中存在门控机制可以解决长期依赖问题,并且是按照需求来筛选信息,而ReLU更多是一刀切地剔除信息。

4.由于ReLU是输出是非负的,这样在递归相乘时就只会保留大于等于0的部分,而tanh这种以0中心对称的激活函数可以决定保留哪些信息、去除哪些信息。

nn.RNNCell与nn.RNN

本次作业用到了nn.RNNCell函数与nn.RNN函数,在这里总结一下。

nn.RNNCell

Pytorch官方给出的用法如图所示。

官方文档给出了四个需要传入的参数。

input_size:输入特征的维度(每个时间步的特征数量)

hidden_size:隐藏状态的维度(隐藏层的输出特征数量)

bias:是否使用偏置参数,默认为 True

nonlinearity:非线性激活函数,可选 'tanh'(默认)或 'relu'

如图所示RNNCell中做了一个事情就是完成了单个时间步的 RNN 前向传播计算。必须要传入的参数是当前时间步的输入向量上一时间步的隐藏状态,这里保存上一时间步的隐藏状态需要由我们实现。传入后对输入加权求和,同时上一时间步的隐藏状态加权求和,默认使用tanh激活并输出。

nn.RNN

参数使用方法如下:

input_size:输入特征的维度(每个时间步的特征数量)。

hidden_size:隐藏状态的维度(隐藏层的输出特征数量)。

num_layers:堆叠的 RNN 层数。默认值是 1,即单层RNN。

nonlinearity:非线性激活函数,仅适用于非LSTM 或GRU的模型。可选 'tanh'(默认)或 'relu'。

bias:是否使用偏置参数,默认为True。

batch_first:是否将输入/输出的第一个维度视为batch维度。如果为True,输入数据的维度应为 [batch, seq, feature]。

dropout:每一层输出之间的dropout概率,仅在num_layers > 1时生效。

bidirectional:是否构造一个双向 RNN。默认值是 False,即单向RNN。

RNNCell是循环神经网络中的一个单元,而RNN相当于将许多个RNNCell堆叠在一起。

注:我们传入的参数要是整个输入序列、初始时刻每个隐藏层的值以及隐藏层的堆叠层数。最后RNN的输出也有两个,一个是每一个输入经过多个隐层前向传播计算后的值即最后一层的每个时间步的输出,另外一个是每一层的最后时间步的隐藏状态。

在RNN方法中,主要完成了整个序列的 RNN 前向传播计算。它封装了时间步循环,接收一个序列的输入,逐步计算每个时间步的隐藏状态,最终返回所有时间步的输出和最后时间步的隐藏状态。

参数num_layers

num_layers=1时

 num_layers=3时

num_layers代表隐藏层的堆叠数量,堆叠的越多网络越复杂,能够捕获的特征也就越复杂,当然,越复杂的网络可能导致的问题就越多,实际应用中,需要我们根据具体任务再设计我们的网络。

两者的区别

从处理范围上来看

nn.RNNCell只处理单个时间步,也就是一次RNN前向传播,需要我们自己定义时间步的循环;而nn.RNN内部封装了整个序列的处理逻辑,内部调用了多个RNNCell进行处理,自动进行处理所有时间步的循环。

从输入数据来看

nn.RNNCell每次仅接受当前时间步的输入和前一个时间步的隐藏状态,无需其他输入即可计算本次时间步的输出;而nn.RNN需要接受完整的序列输入与所有隐藏层的初始状态,并且还可以根据需求输入隐层的叠加层数来更改网络复杂度。

从输出数据来看

nn.RNNCell输出单个时间步的计算结果,是当前时刻的隐层输出;而nn.RNN输出的是所有时间步计算完成后的最后一个隐层输出结果与所有隐层在最后一时刻的状态(方便我们进行其他RNN的使用)。

从适用角度来看

nn.RNNCell用于更细粒度的控制,例如自定义时间步逻辑或非标准时间序列;而nn.RNN则用于处理标准序列任务,无需手动实现循环逻辑,代码更加简洁。

参数初始化

需要注意的是,RNN的weight和bias封装在parameters中,且需要对weight和bias分开初始化,否则会报如下错误!

Fan in and fan out can not be computed for tensor with fewer than 2 dimensions

 这里给出初始化示例,需要我们在__init__构造函数中进行初始化。

self.rnn = nn.LSTM(input_size=embedding_size, hidden_size=128, num_layers=1, bidirectional=False)
for name, param in self.rnn.named_parameters():
	if name.startswith("weight"):
		nn.init.xavier_normal_(param)
	else:
		nn.init.zeros_(param)

“序列”与“序列到序列”

序列

序列这个概念我们在上面引入RNN的时候已经探讨过了,就是一组有先后顺序的数据,可以分为时间序列符号序列生物序列

例如我们的语言就是一种时间序列,“今天天气真好,我要出去玩。”,这句话字与字之间是有着先后顺序的,若是颠倒顺序,语义发生变化,因此这是一个序列。

序列到序列Seq2Seq

序列到序列顾名思义,就是我们在模型中输入一个序列,模型相应的给出的输出也是一个序列,类似于“翻译软件”。

序列到序列又可以分为同步的序列到序列模式异步的序列到序列模式。

同步的序列到序列模式

同步的序列到序列架构中,每一时刻都有输入和输出,输入序列和输出序列的长度相同。主要的应用是序列标注任务。

异步的序列到序列模式

异步的序列到序列架构中,输入序列和输出序列的长度是可变的,比如翻译软件翻译一个由10个英文单词组成的句子可能只会用到5个汉字。

Encondr-Decoder模型

我们实现序列到序列的最直接方法是使用两个循环神经网络来分别进行编码和解码,也称为编码器-解码器模型。编码器-解码器模型我们在刚才也已经实现了一个简单的Demo。

如图所示,一个简单的编码器-解码器模型其实组成还是比较简单的,由一个编码器与一个解码器组成,对于不同的任务,我们可以训练不同的编码器与解码器来学习Seq2eq的隐性映射。

上图是一个简单的编码器结构,本质上就是一个RNN结构,输入完整序列与第一时刻初始化的所有隐层状态进行输出,得到最后一个时刻隐藏状态的信息,送入解码器。

上图是一个简单的解码器结构,解码器本质上也是一个RNN结构。解码器的作用就是结合编码器输出与一个[start]开始标记共同送入RNN中进行预测,并将上一次的预测结果序列再作为输入送入解码器中,如此迭代,预测得到的序列会越来越长,直到得到[end]结束标记。

经过这样一个编码器-解码器流程,最终我们就能得到输入序列的另外一种表示,不过这个映射关系是需要我们输入训练集,不断训练优化编码器与解码器的参数得到的。

作者在自己博客中整理了一些我们可能产生的疑问,这里放一张图好了。

Word2Vec

编码器-解码器结构我们已经大体明白了,可是模型计算需要的是数据,是数字,纯粹的将英文字母或者汉字输入模型中是没用的,那我们该如何将序列数字化呢?这里就需要用到Word2Vec的技术了。

独热编码

一般的方法也是最简单的方法是,对输入序列中所有不重复的单词建立一个字典,用字典的下标来代表这个字,然后根据字典查询下标取值,对每一个字进行独热编码生成稀疏向量。这样就实现了将文本序列转化为向量。

独热编码的缺点

独热编码首先就是表示文本太过于简单,不能体现出单词或语义之间的相似程度。

同时,一旦字典的规模大了之后,每个词都变为一个稀疏向量表示,这样在模型中会极大的增加运算复杂度,降低模型效率。所以在一般应用时,我们会采用embedding操作,对数据进行降维,本质上是通过矩阵乘法将高维特征映射到了低维。下面两种方法是简单的学习这个Embedding的方法。

CBOW

之前读文献的时候接触到了这种方法,基本思想是对于一组向量,挖空一个向量进行学习,将四个向量加在一起合成一个向量,学得结果后。对结果进行解码,解码结果与确实的向量进行比对,从而达成根据上下文学习的效果。

Skip-Gram

这种方法相对于CBOW,是对于每一个向量,挖空上下词,通过目标词学习上下文分布。

需要注意的是,后两种方法也是需要对原序列进行独热编码的。

心得体会

1.本周课上和作业涉及到的一个全新的网络结构RNN,循环神经网络,专门用来处理序列的一种网络,由于前面所学的各种神经网络都无法捕捉序列之间的相互影响关系,所以提出了RNN来学习每一个时刻隐层状态之间的关系,从而模拟序列。学到这里真是觉得深度学习太强大了,从FNN到CNN再到RNN,涉及最简单的数值型数据到图像数据再到文本数据,可以说对于数据的处理方式越来越接近生物处理信息的方法,说不定以后深度学习再发展还能有更多形式的神经网络,越来越接近人的思考方式。

2.这次作业也是我第一次尝试编写RNN的程序,之前学机器学习多多少少都涉及一点前馈神经网络和卷积神经网络的编程,这次一到了RNN编程,函数是什么,怎么用都不知道,也是从头到尾认识了一下RNNCell函数和RNN函数,并且参照着老师给的代码在理解的基础上自己打了一遍,添加了自己理解的注释,有了不小的收获,就像卷积神经网络一开始要认识卷积函数Conv2d一样。

3.这次实验设计自主学习比较多,主要是看了讲解SRN与Seq2Seq的视频,又自己进行了一些资料的查询,不过看完确实能够收获一些课上没有学到的知识,就比如刘二大人的视频中讲解代码的部分就挺清楚的,知道我们最核心要了解的是模型每一层的输入输出维度。可能看完视频还有没有吸收的地方,下次遇到继续总结吧。

4.其实写作业也是一个梳理思路的过程,写着写着一个题目就想起来一个问题,自己就会想去查阅资料总结一下,写着写着时间就过去了,这次作业也写了挺久的,不过想想看也值得。

参考

【1】【23-24 秋学期】NNDL 作业9 RNN - SRN-CSDN博客

【2】深度学习05-RNN循环神经网络-CSDN博客

【3】torch.nn - PyTorch 中文文档

【4】pytorch模型参数初始化

【5】Seq2Seq的PyTorch实现 - mathor

【6】NNDL 作业九 RNN - SRN-CSDN博客

【7】RNN 中为什么要采用 tanh,而不是 ReLU 作为激活函数?

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐