05_RNN循环神经网络

1 自然语言概述

自然语言处理 (Natural Language Processing,NLP)的目标就是让计算机理解人类语言,发展至今大致可以分为下面几个阶段:

  1. 1950年代 - 1970年代:基于规则的系统,如 ELIZA 聊天机器人。
  2. 1980年代 - 1990年代:基于统计方法,如 N-gram、隐马尔可夫(HMM)和最大熵模型。
  3. 1990年代 - 2010年代:基于机器学习阶段,如逻辑回归、SVM、决策树、条件随机场(CRF)等。
  4. 2010年代至今:基于深度学习阶段,如 RNN、LSTM、GRU、Transformer 等,取代了机器学习阶段复杂的手工特征工程。

2 词嵌入层

词向量是用于表示单词语义的向量,也可以看作词的特征向量。将词映射到向量的技术称为词嵌入(Word Embedding)

  1. 对文本进行分词,根据需要进行清洗和标准化。
  2. 构建词表(Vocabulary),每个词对应一个索引。
  3. 构建词嵌入矩阵(Embedding Matrix),将词索引映射到对应的词向量。

使用 nn.Embedding 初始化词嵌入矩阵,刚开始是随机的,但是会根据训练数据进行更新。

1
2
3
4
5
6
7
8
import torch
import torch.nn as nn

torch.manual_seed(66)

# num_embeddings: 词表大小
# embedding_dim: 词向量的维度
embed = nn.Embedding(num_embeddings=10, embedding_dim=5)

中文可以使用 jieba 进行分词,然后使用 embedding 进行词嵌入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import jieba

text = "我爱自然语言处理,我爱机器学习。"

stop_words = [',', '。']

# 分词,过滤停用词
words = [word for word in jieba.lcut(text) if word not in stop_words]

# 去重,利用列表的天然索引,构建词表,也就是索引到词的映射
vocab = list(set(words))

# 构建词到索引的映射
word2idx = {word: idx for idx, word in enumerate(vocab)}

print(f'vocab: {vocab}\nword2idx: {word2idx}\n')

# 构建词嵌入层,参数为词表的大小,词嵌入的维度
embed = nn.Embedding(num_embeddings=len(vocab), embedding_dim=4)

for word, idx in word2idx.items():
word_vector = embed(torch.tensor(idx))
print(f'{idx}:{word:<5}\t{word_vector.detach().numpy()}')
/opt/miniconda3/envs/dl/lib/python3.12/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
Building prefix dict from the default dictionary ...
Loading model from cache /var/folders/7s/cqjc9twd1ngbl_f7129vv6bc0000gn/T/jieba.cache
Loading model cost 0.262 seconds.
Prefix dict has been built successfully.


vocab: ['我', '爱', '学习', '自然语言', '处理', '机器']
word2idx: {'我': 0, '爱': 1, '学习': 2, '自然语言': 3, '处理': 4, '机器': 5}

0:我     [ 0.4493519  0.5169413  1.2761555 -0.8370651]
1:爱     [ 0.34803388 -1.4246558  -0.34050012  1.2940743 ]
2:学习    [-0.6461887   0.34460533 -0.17983264  1.2675058 ]
3:自然语言  [-0.93329114  0.03108138 -0.53790736  0.850731  ]
4:处理    [-0.63556343  1.4519262  -2.4802206   0.20678595]
5:机器    [ 0.76517206 -1.9815451  -0.8956107   0.71585   ]

3 循环网络层

在这之前的神经网络都是前馈(feedforward)型神经网络,也就是传播方向是单向的,虽然结构简单,但是无法学习时序数据的本质关系。而语言是具有时序关系的,如果打乱文本顺序,那么语义将会发生改变。于是循环神经网络(Recurrent Neural Network,RNN)应运而生。

循环神经网络(RNN)是一种具有隐藏状态并允许将过去输出用作输入的神经网络。

  1. 输入层:接收序列数据。
  2. 隐藏层:循环神经网络的核心,隐藏层由一组循环连接的神经元组成。每个神经元不仅处理当前时间步的输入,还结合前一个时间步的隐藏状态信息。这种状态记录了网络对过去输入的记忆,使其能够理解当前元素的上下文。
    • 循环连接:循环神经网络的关键,使得网络能够拥有时间维度上的记忆。通过将上一个时间步的隐藏状态传递到当前时间步,利用历史信息来影响当前的输出。
    • ht = f(Whht − 1 + Wxxt + bh)
    • 当前时间步输入 xt 和权重 Wx 、上一个时间步的输出 ht − 1 和权重 Wh,执行完求积后,加上偏置项 bh ,经过激活函数 f,得到当前时间步的隐藏状态 ht
  3. 激活函数:激活函数对当前时间步的输入和前一个时间步的隐藏状态组合进行转换,以引入非线性特征,常用 tanh ,或 ReLU 激活函数。
  4. 输出层:输出层根据处理后的信息生成网络的预测结果,具体结构取决于具体任务。在语言模型中,它可能会预测序列中的下一个词。

在中间层中,可以使用多个隐藏层,每个隐藏层都有其自身的激活函数、权重和偏置。在一个隐藏层内,会循环多个时间步运行,所有时间步使用相同的权重。隐藏状态会递归地使用当前输入和前一个隐藏状态进行更新。

使用 nn.RNN 构建 RNN 层。

1
2
3
4
# input_size: 输入的维度,也就是输入词向量的维度
# hidden_size: 隐藏层的维度,对应神经元的个数,也就是输出词向量的维度
# num_layers: 层数,可以直接堆叠多层 RNN,上一层的输出作为下一层的输入
rnn = nn.RNN(input_size=4, hidden_size=5, num_layers=2)

由于第一个时间步没有上一个时间步,所以需要传入初始的隐状态 h0,当然也可以省略此参数,默认为全零向量。

1
2
3
4
5
6
7
8
9
10
11
input_ = torch.randn(3, 2, 4)
h0 = torch.randn(2, 2, 5)

# input: [seq_len, batch_size, input_size],输入数据
# h0: [num_layers, batch_size, hidden_size],初始隐状态
output, hn = rnn(input_, h0)
# output: [seq_len, batch_size, hidden_size],输出数据
# hn: [num_layers, batch_size, hidden_size],最终隐状态

print(f' input shape: {list(input_.shape)} h0 shape: {list(h0.shape)}')
print(f'output shape: {list(output.shape)} hn shape: {list(hn.shape)}\n')
 input shape: [3, 2, 4] h0 shape: [2, 2, 5]
output shape: [3, 2, 5] hn shape: [2, 2, 5]

这里只讨论最简单的单向 RNN,不考虑双向 RNN。

input(3, 2, 4) [seq_len, batch_size, input_size]

  • seq_len(输入序列的长度):也就是时间步,对应每次处理多少个 token,在 RNN 中循环迭代的次数,比如 [‘自然’, ‘语言’, ‘处理’] 作为一个样本输入。
  • batch_size(批量大小):也就是每次处理多少个样本,2 个样本就是两个序列为 3 的输入。
  • input_size(输入大小):也就是输入的维度,每个 token 的词向量维度为 4.

output(3, 2, 5) [seq_len, batch_size, hidden_size]

  • seq_len 和 batch_size 和输入相同。
  • hidden_size(隐藏层大小):也就是输出的词向量维度,对应隐藏层神经元的个数。

h0(2, 2, 5) [num_layers, batch_size, hidden_size]

  • num_layers(层数):初始隐状态的数量和层数相关。

hn 就是下一层的初始隐状态,所以和 h0 维度完全一致。

1
print(f'output: \n{output}\nhn: \n{hn}')
output: 
tensor([[[ 0.7895,  0.2584,  0.6769, -0.7742, -0.3992],
         [-0.1474,  0.2047,  0.6347, -0.5527,  0.4279]],

        [[-0.4130,  0.1993,  0.1084, -0.2995,  0.4788],
         [ 0.1983,  0.3894,  0.3309, -0.6349,  0.0103]],

        [[ 0.3517, -0.1504,  0.1967, -0.5610,  0.2852],
         [-0.2731,  0.2325,  0.1598, -0.6785,  0.2983]]],
       grad_fn=<StackBackward0>)
hn: 
tensor([[[ 0.6711, -0.7225, -0.6723,  0.5355,  0.5065],
         [-0.4187, -0.4725, -0.7022,  0.6706,  0.3087]],

        [[ 0.3517, -0.1504,  0.1967, -0.5610,  0.2852],
         [-0.2731,  0.2325,  0.1598, -0.6785,  0.2983]]],
       grad_fn=<StackBackward0>)
  • input[3, 2, 4]:3 个时间步,2 个批次,每个输入单词是 4 维词向量。
  • h0[2, 2, 5]:2 层 RNN,2 个批次,每层 5 个神经元都需要有初始隐状态输入。
  • output[3, 2, 5]:3 个时间步,2 个批次,每个单词变成了 5 维词向量。
  • hn[2, 2, 5]:2 层 RNN,2 个批次,每层 5 个神经元都需有输出。

其中 output 对应的是最后一层 RNN 的所有时间步输出,而 hn 对应的是每一层最后一个时间步的输出,一个是时间上的表示,一个是空间上的表示。具体使用哪个,需要根据具体任务来定。

不使用 batch_first=True,使用输入参数seq_len=3, batch_size=2, input_size=4,对应到实际场景 [["自然", "语言", "处理"], ["人", "喜欢", "狗"]],有如下表示:

建议设置 batch_first=Trueinput的维度顺序变为[batch_size, seq_len, input_size]output的维度顺序变为[batch_size, seq_len, hidden_size]

经过隐藏层之后输出只改变了每个词向量的维度,相当于改变了词向量的空间大小,由神经网络抽取了词的其他维度信息。

4 自定义 Dataset

我们希望输入一个序列 x,输出一个序列 y 作为目标,需要自定义数据集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
from torch.utils.data import Dataset, DataLoader

class MyDataset(Dataset):
def __init__(self, sequences, seq_len):
"""
初始化函数。

Args:
sequences (list of int): 包含所有数据(词索引)的列表。
seq_len (int): RNN 的序列长度。
"""
self.sequences = sequences
self.seq_len = seq_len
# 计算样本数, 比如 len(sequences) = 10, seq_len = 3, 0~9的索引,
# 后 3 个数据 7~9 留给目标序列,那么输入样本范围为 0~6,共 7 个样本
self.num_samples = len(sequences) - seq_len

def __len__(self):
"""
返回数据集的大小(序列的数量)。
"""
return self.num_samples

def __getitem__(self, idx):
"""
获取指定索引的序列和目标。

Args:
idx (int): 序列的索引。

Returns:
tuple: 包含序列和目标的元组。
序列是一个 shape 为 (sequence_length) 的 LongTensor。
目标是对应序列下一个单词的索引,是一个 LongTensor。
"""
if idx < 0 or idx >= self.num_samples:
raise IndexError("Index out of range")

x = torch.tensor(self.sequences[idx: idx + self.seq_len])
y = torch.tensor(self.sequences[idx + 1: idx + self.seq_len + 1])

return x, y
1
2
3
words = ['我', '爱', '自然语言', '处理', '我', '爱', '机器', '学习']
sequences_idx = [word2idx.get(word) for word in words]
print(sequences_idx)
[0, 1, 3, 4, 0, 1, 5, 2]
1
2
3
dataset = MyDataset(sequences=sequences_idx, seq_len=3)
for x, y in dataset:
print(x, y)
tensor([0, 1, 3]) tensor([1, 3, 4])
tensor([1, 3, 4]) tensor([3, 4, 0])
tensor([3, 4, 0]) tensor([4, 0, 1])
tensor([4, 0, 1]) tensor([0, 1, 5])
tensor([0, 1, 5]) tensor([1, 5, 2])
1
2
3
4
dataloader = DataLoader(dataset=dataset, batch_size=2, shuffle=True)

for x, y in dataloader:
print(x, y)
tensor([[4, 0, 1],
        [1, 3, 4]]) tensor([[0, 1, 5],
        [3, 4, 0]])
tensor([[3, 4, 0],
        [0, 1, 3]]) tensor([[4, 0, 1],
        [1, 3, 4]])
tensor([[0, 1, 5]]) tensor([[1, 5, 2]])
  • dataset:

使用 for i in dataset 遍历数据集触发 __getitem__(self, idx) 方法,idx 默认会一直累加,直到遇到抛出异常停止。如果传入的数据不会发生索引越界报错,便会陷入无限循环,此时需要自定义异常抛出,或者使用 for i in range(len(dataset)) 的方式遍历数据集。

  • dataloader:

使用 for i in dataloader 遍历数据集触发 __getitem__(self, idx) 方法,需要搭配 __len()__ 方法的重写, idx 的范围会被限制在 [0, len(dataset) - 1] 中。


05_RNN循环神经网络
https://zhubaoduo.com/2024/08/10/大模型开发/05 深度学习/05_RNN循环神经网络/
作者
baoduozhu
发布于
2024年8月10日
许可协议