循环神经网络的引入
循环神经网络(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_length
,n
为batch_size
,d
为input_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 torchimport torch.nn as nnimport torch.optim as optimfrom torch.utils.data import Dataset, DataLoaderfrom collections import Counterimport jiebaimport numpy as npimport os file_path = './data/data.txt' seq_length = 20 batch_size = 64 embed_size = 256 hidden_size = 512 num_layers = 2 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 ] vocab = ['<PAD>' , '<UNK>' ] + vocab word_to_idx = {word: i for i, word in enumerate (vocab)} return word_to_idx, vocabwith 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 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 )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) out, hidden = self .rnn(x, hidden) out = self .fc(out) 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():.4 f} ' ) 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 ))