循环神经网络(RNN)

循环神经网络的引入

循环神经网络(RNN)本质上就是一个隐变量自回归模型(latent autoregressive models),循环神经网络通过引入时间维度的循环连接,使网络具备记忆能力。其核心结构包括输入层、隐层和输出层,隐层的输出不仅传递给当前时刻的输出层,还会传递给下一时刻的隐层,形成时间展开的动态结构。

RNN

可以认为\(\mathbf{H}_t\)中就存储了之前时刻输入的记忆,当前\(t\)时刻的记忆取决于\(t\)时刻的观测\(\mathbf{X}_t\)\(t-1\)时刻的记忆\(\mathbf{H}_{t-1}\)\[ \mathbf{H}_t = \phi(\mathbf{X}_t\mathbf{W}_{xh} + \mathbf{H}_{t-1}\mathbf{W}_{hh} + \mathbf{b}_h) \] 其中\(\phi\)是激活函数

对于时间步\(t\),输出层: \[ \mathbf{O}_t = \mathbf{H}_t\mathbf{W}_{hq} + \mathbf{b}_q \]

其中,小批量样本\(\mathbf{X}_t\in\mathbb{R}^{n\times d}\)\(n\)代表批量大小,\(d\)代表输入维度;隐藏层\(\mathbf{H}_t\in\mathbb{R}^{n\times h}\)

循环神经网络的参数包括隐藏层的权重\(\mathbf{W}_{xh}\in\mathbb{R}^{d\times h}\)\(\mathbf{W}_{hh}\in\mathbb{R}^{h\times h}\)和偏置\(\mathbf{b}_h\in\mathbb{R}^{1\times h}\),以及输出层的权重\(\mathbf{W}_{hq}\in\mathbb{R}^{h\times q}\)和偏置\(\mathbf{b}_q\in\mathbb{R}^{1\times q}\)

在不同的时间步,循环神经网络也总是使用这些模型参数,即这些参数在不同的时间步上是共享的,因此,循环神经网络的参数开销并不会随着时间步的增加而增加。

困惑度

困惑度是衡量语言模型好坏的指标,可以认为是循环神经网络的损失函数。困惑度的本质是“模型预测下一个词元时的平均不确定性”,公式为: \[ \exp\bigg(-\frac{1}{n}\sum_{t=1}^n \log P(x_t|x_{t-1},\cdots,x_1)\bigg) \] 其中\(-\sum_{t=1}^n\log P(x_t|x_{t-1},\cdots,x_1)\)就是交叉熵损失函数,\(-\frac{1}{n}\sum_{t=1}^n\log P(x_t|x_{t-1},\cdots,x_1)\)就是在不同时间步上交叉熵损失的平均值。由于历史的原因,自然语言处理科学家更喜欢用困惑度(perplexity)来作为衡量指标,也就是平均交叉熵损失的指数函数。

  • 在最好的情况下,模型总是完美地估计标签词元的概率为1。在这种情况下,模型的困惑度为1
  • 在最坏的情况下,模型总是预测预测标签词元的概率为0。在这种情况下,困惑度是正无穷大
  • 在基线上,该模型的预测是词表的所有可用词元上的均匀分布。在这种情况下,困惑度等于词表中唯一词元的数量。事实上,如果我们在没有任何压缩的情况下存储序列,这将是我们能做的最好的编码方式。因此,这种方式提供了一个重要的上限,而任何实际模型都必须超过这个上限。

对“该模型的预测是词表的所有可用词元上的均匀分布,困惑度等于词表中唯一词元的数量”的证明:

假设词表大小为\(V\),模型对所有词元的预测概率为均匀分布,即: \[ P(w)=\frac{1}{V},\quad\forall w\in 词表 \] 因为模型对所有词元的预测概率为均匀分布,我们可以认为每个时间步上的预测都是相互独立的,则 \[ P(x_t|x_{t-1},\cdots,x_1) = P(x_t) \] 对于长度为\(T\)的时间序列,每个时间步的交叉熵均为\(-\log P(x_t) = -\log \frac{1}{V} = \log V\),则平均交叉熵: \[ -\frac{1}{T}\sum_{t=1}^T\log P(x_t|x_{t-1},\cdots,x_1) = \frac{1}{T}\cdot T\log V = \log V \] 困惑度: \[ \exp\bigg(-\frac{1}{T}\sum_{t=1}^T \log P(x_t|x_{t-1},\cdots,x_1)\bigg) = \exp(\log V) = V \]

相较于平均交叉熵损失,使用困惑度的好处:

  • 困惑度的数值直接对应“候选词的等效选择数”。例如,困惑度为20表示模型在预测时平均面临约20个可能的选择。
  • 困惑度能放大平均交叉熵损失的影响

Temperature

Temperature是控制大语言模型(LLM)生成文本随机性和创造性的核心技术参数,通过调整概率分布的尖锐程度,直接影响输出的多样性与确定性。

对于文本生成模型,词元向量输入后经过隐藏层,得到\(H_t\),然后在隐藏层输出后面加入一个全连接层和Softmax回归,Softmax回归就是判定该输出是词汇表中的不同词元的概率,因此,该全连接层的维度应该是(hidden_size, vocab_size)

Temperature的方法就是对全连接层的输出\(O_t\)每个元素均除以\(T\)\[ P(z_i) = \frac{e^{z_i/T}}{\sum_{j=1}^n e^{z_j/T}} \] 对于指数函数\(e^x\)\(x\)的值越大,\(e^x\)斜率越大,也就是\(e^x\)增大的幅度越来越大。

因此,如果\(0<T<1\),原本的鹅全连接层的输出均放大,而原本就大的元素经过指数函数后变化程度更大,因此Softmax分布更尖锐,(如\(T = 0.5\)时最高概率词占比从\(55.77\%\)升至\(86.17\%\)),模型输出更保守,适用于代码生成、事实问答等需要准确性的场景

如果\(T>1\),会使得Softmax分布更加平滑,缩小高低概率词的差距,(如\(T=2\)时最高概率词占比从\(55.77\%\)降至\(36.02\%\)),增加低概率词被选中的机会,适合诗歌创作、头脑风暴等需要创意的任务

\(T\rightarrow0\)时,退化为贪婪采样(Greedy Sampling),仅选最高概率词,输出完全确定但重复性高

\(T\rightarrow\infty\)时,概率分布趋于均匀,生成完全随机文本,可能失去逻辑性

1
2
probs = torch.softmax(output.squeeze() / temperature, dim=0).detach().cpu().numpy()
next_idx = np.random.choice(vocab_size, p=probs)

使用Temperature技术后,根据Softmax分布概率选取该时间步的输出

代码

对于输入,一般为多个时间步、一个小批量的数据,即输入维度:(seq_length, batch_size, input_size)seq_length为序列长度,input_size为一个时间步上的一个输入向量维度)

即: \[ X = \left[ \begin{matrix} X_1\\ X_2\\ \vdots\\ X_l \end{matrix} \right] \] 其中,\(X_t\in\mathbb{R}^{n\times d}\)

注:\(l\)seq_lengthnbatch_sizedinput_size

这样处理的好处是每个时间步都可以通过矩阵的形式直接计算多个样本同一个时间步的数据。

但是,对人类而言,更容易读懂和处理的数据维度应该是(batch_size, seq_length, input_size),如果要使用这种维度的数据,只需要设置nn.RNN()的参数batch_first=True,这样输入和输出的维度都是以batch_size作为第一个维度,当我们设置batch_first=True时,在RNN内部计算时,会自动进行维度转换计算,不会影响隐藏层的维度形状。

输出层应该为nn.Linear(hidden_size, vocab_size),即使用一个Softmax回归进行分类,决定该时间步上输出的是哪个词元。

因为使用了Softmax回归进行分类,损失函数可以使用交叉熵损失,并对每个时间步进行平均:

1
loss = loss_function(output.reshape(-1, vocab_size), y.reshape(-1))

这里使用的output.reshape(-1, vocab_size)可以认为将所有不同时间步的输出均当成预测,不再考虑时间步的问题。

这里使用的损失函数并不是困惑度,如果要计算困惑度:

1
perplexity = torch.exp(loss.item())
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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from collections import Counter
import jieba
import numpy as np
import os

file_path = './data/data.txt' # 小说文本路径
seq_length = 20 # 输入序列长度(单词数)
batch_size = 64 # 批大小
embed_size = 256 # 词向量维度
hidden_size = 512 # 隐藏层维度
num_layers = 2 # RNN层数
lr = 0.001
num_epochs = 20
vocab_limit = 10000 # 词汇表最大词数
device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')

# 分词函数
def tokenize(text):
return [word for word in jieba.cut(text) if word.strip()]

# 构建词汇表
def build_vocab(tokenized_text, vocab_limit):
word_counts = Counter(tokenized_text)
vocab = sorted(word_counts, key=word_counts.get, reverse=True)[:vocab_limit - 1] # 保留一个位置给<UNK>
vocab = ['<PAD>', '<UNK>'] + vocab
word_to_idx = {word: i for i, word in enumerate(vocab)}
return word_to_idx, vocab

with open(file_path, 'r') as f:
text = ''
for i in range(30000):
text += f.readline()

# 分词处理
tokenized_text = tokenize(text)
print(f'总词数:{len(tokenized_text)}')

# 构建词汇表
word_to_idx, vocab = build_vocab(tokenized_text=tokenized_text, vocab_limit=vocab_limit)
idx_to_word = {i: word for i, word in enumerate(vocab)}
vocab_size = len(vocab)
print(f'词汇表大小:{vocab_size}')

# 文本转数字序列(处理未知词)
data = [word_to_idx[word] if word in word_to_idx else word_to_idx['<UNK>'] for word in tokenized_text]

# 创建数据集
class TextDataset(Dataset):
def __init__(self, data, seq_length):
self.data = data
self.seq_length = seq_length

def __len__(self):
return len(self.data) - self.seq_length # 一共有len(self.data) - self.seq_length + 1个,但是最后一个数据不取,否则无法训练

def __getitem__(self, index):
x = torch.tensor(self.data[index: index + self.seq_length], dtype=torch.long)
y = torch.tensor(self.data[index + 1: index + self.seq_length + 1], dtype=torch.long)
return x, y

dataset = TextDataset(data, seq_length)
dataloader = DataLoader(dataset, shuffle=True, batch_size=batch_size, drop_last=True)

# 定义RNN模型
class WordRNN(nn.Module):
def __init__(self, vocab_size, embed_size, hidden_size, num_layers):
super().__init__()
self.hidden_size = hidden_size
self.num_layers = num_layers

self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.RNN(embed_size, hidden_size, num_layers, batch_first=True)
self.fc = nn.Linear(hidden_size, vocab_size)

def forward(self, x, hidden):
# 嵌入层
x = self.embedding(x) # (batch_size, seq_len, embed_size)

# RNN前向传播
out, hidden = self.rnn(x, hidden)

# 全连接层
# PyTorch的全连接层在计算时只对最后一个维度进行线性变换,其他维度保持不变,实际上等价于对每个样本的每个时间步的隐藏状态向量执行全连接层的计算
out = self.fc(out) # (batch_size, seq_len, vocab_size)
return out, hidden

def init_hidden(self, batch_size):
return torch.zeros(num_layers, batch_size, self.hidden_size).to(device)


model = WordRNN(vocab_size, embed_size, hidden_size, num_layers).to(device)
loss_function = nn.CrossEntropyLoss(ignore_index=word_to_idx['<PAD>'])
optimizer = optim.Adam(model.parameters(), lr=lr)


# 训练
for epoch in range(num_epochs):
hidden = model.init_hidden(batch_size)
for batch, (x, y) in enumerate(dataloader):
x, y = x.to(device), y.to(device)

optimizer.zero_grad()

output, hidden = model(x, hidden)
hidden = hidden.detach() # 断开隐藏状态的梯度连接

loss = loss_function(output.reshape(-1, vocab_size), y.reshape(-1))

loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 5) # 梯度裁剪
optimizer.step()

# 打印训练信息
if batch % 100 == 0:
print(f'Epoch [{epoch+1}/{num_epochs}], Batch {batch}, Loss: {loss.item():.4f}')


# ----------------- 文本生成函数 -----------------
def generate_text(model, start_text, length=50, temperature=1.0):
model.eval()
words = tokenize(start_text)
hidden = model.init_hidden(1)

# 处理初始文本
for word in words:
if word not in word_to_idx:
word = '<UNK>'
x = torch.tensor([[word_to_idx[word]]]).to(device)
_, hidden = model(x, hidden)

# 生成新文本
for _ in range(length):
# 前向传播
output, hidden = model(x, hidden)

# 应用温度采样
probs = torch.softmax(output.squeeze() / temperature, dim=0).detach().cpu().numpy()
next_idx = np.random.choice(vocab_size, p=probs)

# 处理生成结果
next_word = idx_to_word[next_idx] if next_idx in idx_to_word else '<UNK>'
words.append(next_word)

# 准备下一轮输入
x = torch.tensor([[next_idx]]).to(device)

# 合并分词结果
return ''.join(words) if len(''.join(words)) == len(words) else ' '.join(words)


# 示例生成
print(generate_text(model, "你好", length=500, temperature=0.8))

循环神经网络(RNN)
https://blog.shinebook.net/2025/03/30/人工智能/理论基础/深度学习/循环神经网络/
作者
X
发布于
2025年3月30日
许可协议