循环神经网络(Recurrent Neural Networks) #
传统的机器学习模型(如线性回归、逻辑回归和多层感知机)主要针对固定长度的数据,比如表格数据,这些数据通常以行和列的形式组织,其中每一行是一个样本,每一列是一个特征。对于这些模型,数据的结构并不重要,只需确保每个样本的特征数量固定即可。
在处理图像数据时,由于图像包含固定长度的像素信息,我们可以引入卷积神经网络(CNN)来捕获数据的层次结构和空间不变性。然而,这类数据仍然是固定长度的,例如Fashion-MNIST数据集中每张图像是固定大小的 \(28 \times 28\) 的像素网格。
许多学习任务需要处理序列数据(Sequential Data)。例如,图像字幕生成、语音合成和音乐生成需要模型输出序列化数据;而时间序列预测、视频分析等任务需要模型从序列化数据中学习。在自然语言处理、对话系统设计、机器人控制等领域,这些输入和输出的序列特性通常同时存在。
循环神经网络(RNN) 是一种深度学习模型,专门设计用来捕获序列数据的动态特性。通过引入循环连接(可看作网络中的循环边),RNN能够跨时间步共享参数并传递信息。在展开视图中(即将循环连接按时间步展开),RNN可以看作一种特殊的前馈神经网络,其参数在各时间步间共享。循环连接动态地跨时间步传播信息,而常规连接则在同一时间步内传播信息。
RNN的核心思想是:许多输入和目标无法轻易表示为固定长度的向量,但可以表示为固定长度向量的变长序列。例如,文档可以表示为单词序列,医疗记录可以表示为事件序列,视频可以表示为静态图像序列。
处理序列(Working with Sequences) #
在传统模型中,输入通常是单一的特征向量(feature vector)。而在处理序列数据时,输入变为一个有序的特征向量列表,每个特征向量按照时间步(time step)进行索引。这样的序列数据在许多场景中广泛存在,例如长时间的传感器数据流、文本序列或病人住院记录。
对于序列数据,有两种主要形式:
- 单个超长序列(如气候科学中的传感器数据流),可以通过随机采样固定长度的子序列生成训练数据集。
- 独立的多个序列集合(如文档集合,每个文档由不同长度的单词序列表示;或住院记录,每次住院由不同长度的事件序列组成)。
在传统的独立样本假设下,我们认为每个输入样本是从相同分布中独立采样的。而在序列数据中,尽管整个序列可以被认为是独立的,但序列内部的时间步之间往往具有强相关性。例如,文档后续单词的出现通常依赖于前面的单词,病人第10天的用药往往取决于前9天的病情记录。这种依赖性正是序列模型存在的意义:如果序列中的元素互不相关,就没有必要将其建模为序列。序列模型的核心在于捕捉这些依赖性。
根据任务目标的不同,序列建模可以分为以下几种类型:
- 固定目标预测:给定一个序列输入,预测一个固定目标,例如基于电影评论进行情感分类。
- 输入: 一个文本序列,例如电影评论。
dim = [batch_size, seq_length]
,其中batch_size
是批次大小,seq_length
是每个文本序列的长度。 - 输出:对应的情感类别(通常是一个标量),例如“正面”或“负面”。
dim = [batch_size, 1]
或[batch_size]
,这是一个标量值表示类别。
- 输入: 一个文本序列,例如电影评论。
- 序列目标预测:给定固定输入,预测一个序列目标,例如图像描述生成。
- 输入: 一个图像输入,通常是一个张量表示的图像或图像的特征(例如从CNN提取的特征向量)。
[batch_size, feature_size]
,其中feature_size
是图像特征的维度。 - 输出: 一个文本序列(描述)。
[batch_size, seq_length]
,其中seq_length
是生成的描述的词数。
- 输入: 一个图像输入,通常是一个张量表示的图像或图像的特征(例如从CNN提取的特征向量)。
- 序列到序列预测:同时处理序列输入和序列输出。例如:
- 对齐的序列到序列任务:输入和输出在时间步上逐一对应,如词性标注(part-of-speech tagging)。
- 输入: 一个输入文本序列,例如源语言句子(例如英文句子)。
[batch_size, seq_length]
,其中seq_length
是源语言句子的长度。 - 输出:一个输出文本序列,目标语言的翻译(例如中文句子)。
[batch_size, seq_length]
,其中seq_length
是目标语言句子的长度。
- 输入: 一个输入文本序列,例如源语言句子(例如英文句子)。
- 非对齐的序列到序列任务:输入与输出没有严格的时间步对应关系,如机器翻译或视频字幕生成。
- 输入:一个视频帧序列(或者是视频帧的特征表示)。
[batch_size, num_frames, feature_size]
,其中num_frames
是视频帧数,feature_size
是每个帧的特征维度。 - 输出: 一个文本描述序列,描述视频内容。
[batch_size, seq_length]
,这是生成的字幕。
- 输入:一个视频帧序列(或者是视频帧的特征表示)。
- 对齐的序列到序列任务:输入和输出在时间步上逐一对应,如词性标注(part-of-speech tagging)。
自回归模型(Autoregressive Models) #
假设一位交易员希望进行短期交易,根据对下一时间步指数价格涨跌的预测来决定买入或卖出。如果没有其他辅助特征(如新闻或财报数据),交易员只能依据截至当前的价格历史数据预测下一时刻的价格。这时需要估计的就是价格在下一时间步可能取值的概率分布 \(P(x_{t+1} | x_1, \dots, x_t)\) 。由于直接估计连续变量的整个概率分布较为困难,交易员通常更关注分布的一些关键统计量,例如期望值(expected value)和方差(variance)。
一种简单的估计条件期望的方法是应用线性回归模型(linear regression model),即根据信号的历史值预测其未来值。这类模型被称为自回归模型(autoregressive models)。自回归模型假设当前时间步的观测值 \(x_t\) 是之前时间步观测值 \(x_{t-1}, x_{t-2}, \dots\) 的线性组合加上一个随机噪声项 \(\epsilon_t\) 。表达式如下: \[ x_t = c + \phi_1 x_{t-1} + \phi_2 x_{t-2} + \dots + \phi_p x_{t-p} + \epsilon_t \]
自回归模型特指那些仅依赖于序列本身的历史观测值进行预测的统计模型。如果模型涉及到外部因素、非线性关系或更复杂的结构(如RNN、LSTM等),则它们不再是严格意义上的自回归模型。
然而,自回归模型面临一个主要问题:输入数量 \(t\) 随着时间步数增长而变化,导致每个样本的特征数量不一致。这给训练过程带来了挑战,因为许多模型(例如线性回归或深度网络)都要求固定长度的输入向量。克服这一挑战的常用策略有:
- 窗口化(Windowing):为了简化模型的输入维度,可以假设在预测短期未来时,仅需要观察最近的 \(\tau\) 个时间步数据,而无需回溯到整个历史。这种情况下,只需使用一个长度为 \(\tau\) 的滑动窗口 \((x_{t-\tau+1}, \dots, x_t)\) 作为输入。这种方式确保了输入特征的数量固定,适用于许多要求固定输入长度的模型。
- 隐变量模型(Latent Autoregressive Models):构建一种模型,该模型通过维护一个过去观测值的总结 \(h_t\) 来压缩历史信息。在每个时间步,该模型不仅预测 \(x_{t+1}\) ,还更新摘要 \(h_{t+1} = g(h_t, x_t)\) 。由于 \(h_t\) 是未观测到的隐变量(latent variable),这样的模型也被称为隐变量自回归模型。这种方法可以捕捉更复杂的历史依赖关系。
序列模型(Sequence Models) #
在处理序列数据,尤其是语言时,我们常常希望估计整个序列的联合概率。这种任务通常被称为序列建模(sequence modeling),在自然语言处理中,序列建模常被称为语言模型(language model)。语言模型不仅可以用来评估句子的可能性,还能生成新序列或优化生成的序列。在语言建模中,我们可以通过链式法则将序列的联合概率分解成条件概率的乘积,从而将问题转化为自回归预测(autoregressive prediction)。如果序列数据是离散信号(如单词),自回归模型通常是一个概率分类器,输出词汇表中下一个词的概率分布。 \[ P(x_1, \ldots, x_T) = P(x_1) \prod_{t=2}^T P(x_t \mid x_{t-1}, \ldots, x_1) \]
有时我们会希望在建模时 仅依赖于前几个时间步的历史数据,而不是整个序列的历史。此时,如果我们丢弃超过前几个时间步的历史而不损失预测能力,我们称该序列满足 马尔可夫条件(Markov condition),即 未来仅依赖于最近的历史,而与更早的历史无关。当我们仅依赖于前一个时间步时,数据符合一阶马尔可夫模型;如果依赖于前两个时间步,则符合二阶马尔可夫模型。在实际应用中,我们通常会选择近似满足马尔可夫条件的模型,尽管真实的文本数据会随着更多历史信息的加入逐渐改善预测效果,但增益是有限的。因此,有时我们会选择使用高阶马尔可夫模型,以减少计算和统计上的困难。 \[ P(x_1, \ldots, x_T) = P(x_1) \prod_{t=2}^T P(x_t \mid x_{t-1}) \]
在文本序列解码时,通常选择按照从左到右的顺序来分解条件概率。这种顺序更符合我们日常阅读习惯(如大多数语言是从左到右读的),而且我们也能更直观地预测下一个可能出现的词。通过这种方式,我们可以为任意长的序列分配概率,只需要将新的词的条件概率乘以前面已计算的概率。此外,预测相邻词的模型通常比预测其他位置的词更加精准,这也是选择从左到右解码的一个原因。对于许多数据类型来说,这种顺序的预测比其他顺序更易于建模。例如,在因果结构数据中,未来的事件不能影响过去的事件,这使得从当前时刻预测未来比反向预测更容易。
基于 n-阶马尔可夫条件(Markov condition),即只依赖于前 n 个数据点来做预测。当我们用这种方式进行 一步预测(one-step-ahead prediction)时,模型效果良好,因为它依赖于已知的历史数据。(e.g. 基于时间点604 预测时间点 605)
然而,当我们进行 多步预测(multi-step-ahead prediction) 时,问题变得复杂。(e.g. 基于时间点604 预测时间点 609)我们无法直接通过已知数据计算预测值,因此我们需要利用先前的预测值作为输入来进行后续预测(e.g. 因为我们没有时间点 605-608 的数据,所以需要根据 604 先预测 605,再依据 605 预测 606,以此类推)。这种逐步递推的方式会导致预测的误差在每一步都积累。这些误差会随着时间步的推进而累积,导致预测结果逐渐偏离真实值。就像天气预报一样,短期预测较为准确,但长期预测误差逐渐增大。
文本预处理(Converting Raw Text into Sequence Data) #
在处理文本数据时,我们通常需要将原始文本转换为适合模型使用的数值形式。这一过程包含以下几个步骤:
- 读取文本数据:将原始文本加载为字符串,并预处理以去掉标点和大小写。
- 分词(Tokenization):将文本分割为基本的语义单元(Token)。Token可以是单词、字符,或更小的词片(Word Piece)。例如,句子“Baby needs a new pair of shoes”可以被表示为包含7个单词的序列或30个字符的序列。选择哪种形式取决于具体应用。
- 构建词汇表(Vocabulary):将Token映射到唯一的数值索引。首先确定训练数据中所有唯一Token的集合,并为每个Token分配索引。构建好的词汇表可以将字符串转换为数值序列,同时保留原始信息,支持将数值序列还原为字符串。例如:
文本: ['the', 'time', 'machine', 'by', 'h', 'g', 'wells'] 索引: [1, 19, 50, 40, 2183, 2184, 400] 文本: ['twinkled', 'and', 'his', 'usually', 'pale', 'face'] 索引: [2186, 3, 25, 1044, 362, 113]
- 文本预处理代码示例:
# 原始文本
text = "Baby needs a new pair of shoes. Baby likes shoes too!"
# 转小写并去掉标点符号
import re
processed_text = re.sub(r"[^\w\s]", "", text.lower())
print(processed_text)
# 输出: "baby needs a new pair of shoes baby likes shoes too"
# 按单词分词
tokens = processed_text.split()
print(tokens)
# 输出: ['baby', 'needs', 'a', 'new', 'pair', 'of',
# 'shoes', 'baby', 'likes', 'shoes', 'too']
# 构建词汇表
vocab = {token: idx for idx, token in enumerate(sorted(set(tokens)))}
print(vocab)
# 输出: {'a': 0, 'baby': 1, 'likes': 2, 'needs': 3,
# 'new': 4, 'of': 5, 'pair': 6, 'shoes': 7, 'too': 8}
# 将单词序列转换为数值索引序列
numerical_sequence = [vocab[token] for token in tokens]
print(numerical_sequence)
# 输出: [1, 3, 0, 4, 6, 5, 7, 1, 2, 7, 8]
语言模型和数据集(Language Models) #
语言模型通过估计整个文本序列的联合概率 \(P(x_1, x_2, \dots, x_T)\) 来建模语言,其中 \(T\) 是文本序列的长度, \(x_t\) 表示序列中的第 \(t\) 个 token。这种联合概率可以分解为条件概率的连乘形式: \[ P(x_1, x_2, \dots, x_T) = \prod_{t=1}^{T} P(x_t \mid x_1, x_2, \dots, x_{t-1}) \]
比如: \[ \begin{split}\begin{aligned}&P(\textrm{deep}, \textrm{learning}, \textrm{is}, \textrm{fun}) \\ =&P(\textrm{deep}) P(\textrm{learning} \mid \textrm{deep}) P(\textrm{is} \mid \textrm{deep}, \textrm{learning}) P(\textrm{fun} \mid \textrm{deep}, \textrm{learning}, \textrm{is}).\end{aligned}\end{split} \]
理想的语言模型不仅可以生成自然语言文本,还可以通过上下文信息生成合理的对话内容。然而,设计这样一个能够真正理解文本含义的系统仍然非常困难。
Note: 语言模型解决的问题:语言模型和根据给定的序列预测下一个词。它们的核心任务是:通过对前面已经出现的词(或符号)进行建模,来预测下一个最可能出现的词,或者生成后续的词序列。
概率规则与马尔可夫模型(Markov Models) #
在马尔可夫模型中,序列满足一阶马尔可夫性质,即当前状态只依赖于前一个状态。根据依赖长度,模型可分为单词(Unigram)、双词(Bigram)和三词(Trigram)模型。模型参数包括单词概率和条件概率。 \[ \begin{split}\begin{aligned} P(x_1, x_2, x_3, x_4) &= P(x_1) P(x_2) P(x_3) P(x_4),\\ P(x_1, x_2, x_3, x_4) &= P(x_1) P(x_2 \mid x_1) P(x_3 \mid x_2) P(x_4 \mid x_3),\\ P(x_1, x_2, x_3, x_4) &= P(x_1) P(x_2 \mid x_1) P(x_3 \mid x_1, x_2) P(x_4 \mid x_2, x_3). \end{aligned}\end{split} \]
词频估计(Word Frequency) #
假设训练数据集是一个大规模的文本语料库,例如维基百科条目或网络上发布的所有文本。单词的概率可以通过该单词在训练数据中的相对词频来计算。例如,可以通过统计“deep”作为句子开头出现的次数,来估计概率。另一种稍微不准确的方法是统计“deep”出现的总次数,并除以语料库中的总单词数。这种方法对于频繁出现的单词效果较好。进一步地,可以尝试估计二元组(如“deep learning”)的概率: \[ \hat{P}(\textrm{learning} \mid \textrm{deep}) = \frac{\text{Count}(\text{deep, learning})}{\text{Count}(\text{deep})} \] 其中,分子是二元组的出现次数,分母是单个单词的出现次数。然而,估计二元组的概率更加困难,因为“deep learning”这样的二元组在语料库中出现的频率通常较低。对于一些不常见的词组合,可能很难找到足够的出现次数来进行准确的估计。
对于三元组及以上的组合情况(如“deep learning models”),问题变得更严重。许多可能的三词组合在语料库中可能完全不存在。如果不给这些词组合分配一个非零的计数,就无法在语言模型中使用它们。当数据集较小或单词本身极为罕见时,甚至可能找不到这些组合的任何一个实例。
因此,基于词频的简单统计方法虽然可以处理常见单词和短语,但在应对长序列或罕见组合时存在明显局限性,需要其他方法进行改进。
拉普拉斯平滑(Laplace Smoothing) #
为此,我们使用拉普拉斯平滑(Laplace Smoothing)来改善上述问题。具体方法是在所有计数中添加一个小常量。 用 \(n\) 表示训练集中的单词总数,用 \(m\) 表示唯一单词的数量。 \[ \begin{split}\begin{aligned} \hat{P}(x) & = \frac{n(x) + \epsilon_1/m}{n + \epsilon_1}, \\ \hat{P}(x' \mid x) & = \frac{n(x, x') + \epsilon_2 \hat{P}(x')}{n(x) + \epsilon_2}, \\ \hat{P}(x'' \mid x,x') & = \frac{n(x, x',x'') + \epsilon_3 \hat{P}(x'')}{n(x, x') + \epsilon_3}. \end{aligned}\end{split} \] 其中 \(\epsilon_1,\epsilon_2\) ,和 \(\epsilon_3\) 是超参数。以 \(\epsilon_1\) 为例:当 \(\epsilon_1=0\) 时,不应用平滑;当 \(\epsilon_1\) 接近正无穷大时, \(\hat{P}(x)\) 接近均匀概率分布 \(1/m\) 。
然而,这样的模型很容易变得无效,原因如下: 首先,我们需要存储所有的计数; 其次,这完全忽略了单词的意思。 例如,“猫”(cat)和“猫科动物”(feline)可能出现在相关的上下文中, 但是想根据上下文调整这类模型其实是相当困难的。 最后,长单词序列大部分是没出现过的, 因此一个模型如果只是简单地统计先前“看到”的单词序列频率, 那么模型面对这种问题肯定是表现不佳的。
困惑度(Perplexity) #
衡量语言模型质量的一种方法是评估其对文本的预测能力。一个好的语言模型能够以较高的准确性预测下一个词(token)。例如,对于短语“It is raining”,不同模型可能生成以下扩展:
- “It is raining outside”(合理且逻辑通顺)
- “It is raining banana tree”(语法正确但意义不通)
- “It is raining piouw;kcj pwepoiut”(完全无意义且不合规范)
显然,第一个扩展质量最好,模型能够捕获合理的词序和上下文语义。第二个扩展较差,但至少模型学会了单词拼写和部分词语之间的关联性。而第三个扩展表明模型训练不足,无法正确拟合数据。
为了量化模型质量,可以通过计算序列的似然(likelihood)。然而,直接比较似然值并不直观,因为较短的序列通常有更高的似然值。因此,需要一种标准化的方法使得不同长度的文档结果具有可比性。在信息论中,我们用交叉熵(cross-entropy)衡量模型对序列的预测能力。具体地,对于给定序列 \(x_1, x_2, \ldots, x_n\) ,交叉熵损失的公式为: \[ \frac{1}{n} \sum_{t=1}^n -\log P(x_t \mid x_{t-1}, \ldots, x_1) \] 这里, \(P(x_t \mid x_{t-1}, \ldots, x_1)\) 是模型预测的概率, \(x_t\) 是序列中实际的词。这种方法将不同长度文档的性能变得可比。
自然语言处理领域通常使用困惑度(Perplexity) 作为评价标准,它是交叉熵损失的指数形式: \[ \text{Perplexity} = \exp\left(-\frac{1}{n} \sum_{t=1}^n \log P(x_t \mid x_{t-1}, \ldots, x_1)\right) \]
困惑度可以理解为我们在选择下一个词时平均可用的真实选项数的倒数,具体如下:
- 最佳情况:模型对目标词的概率预测为 1,此时困惑度为 1,表明模型预测完全准确。
- 最差情况:模型对目标词的概率预测为 0,此时困惑度为正无穷。
- 基线情况:模型对所有词分布均匀预测,此时困惑度等于词汇表大小 \(V\) 。这提供了一个非平凡的上界,任何有用的模型都应超越这一基线。
困惑度越低,模型质量越高,表明其对文本序列的预测能力越强。
读取长序列数据(Partitioning Sequences) #
由于序列数据本质上是连续的,因此我们在处理数据时需要解决这个问题。为了高效处理数据,模型通常以固定长度的序列小批量(minibatch)为单位进行训练。一个关键问题是如何从数据集中随机读取输入序列和目标序列的小批量。
假设数据集是一个表示语料库中词索引的序列。我们将其划分为固定长度 \(n\) 的子序列(subsequence)。为了在每个训练周期(epoch)中覆盖几乎所有的词,同时保证随机性,我们在每轮训练开始时丢弃前 \(r\) 个词,其中 \(r\) 是从均匀分布随机采样的整数。接下来,将剩余的序列划分为 \(n\) 长度的子序列。每个子序列从时间步 \(i\) 的第 \(n\) 个词开始,可以记为: \[ x_i = \{x_i, x_{i+1}, \ldots, x_{i+n-1}\} \] 假设网络一次只处理具有 \(n\) 个时间步的子序列。 下图画出了 从原始文本序列获得子序列的所有不同的方式, 其中 \(n=5\) ,并且每个时间步的词元对应于一个字符。
对于语言建模,目标是基于当前观察到的标记预测下一个标记。因此,对于任何输入序列 \(x_i\) ,目标序列(标签)是原始序列右移一个时间步,记为 \(y_i\) ,其长度与 \(x_i\) 相同。
Note: 在语言模型中,截断长度 n 并不意味着只根据前 n 个词预测下一个词,而是定义了一种固定长度的输入序列,从中学习预测下一个词的能力。模型不仅仅预测输入序列的最后一个标记(如 D 的目标是 E),而是同时预测输入序列中所有时间步的下一个标记。
- 准备输入数据和标签
class TextDataset(Dataset):
def __init__(self, data, vocab_size, seq_length):
self.data = data # 数据是一个索引序列
self.vocab_size = vocab_size
self.seq_length = seq_length
def __len__(self):
return len(self.data) - self.seq_length
def __getitem__(self, idx):
# 获取一个长度为seq_length的输入序列和一个目标值
x = self.data[idx:idx + self.seq_length] # [seq_length]
y = self.data[idx + 1:idx + self.seq_length + 1] # [seq_length],目标是下一个时间步的序列
# Inputs: tensor([[2, 3, 4],
# [6, 7, 8]])
# Targets: tensor([[3, 4, 5],
# [7, 8, 9]])
return torch.tensor(x), torch.tensor(y)
循环神经网络(RNN)概述 #
在用马尔可夫模型和n-gram模型用于语言建模时,这些模型中某一时刻的词(token)只依赖于前 \(n\) 个词。如果我们希望将更早时间步的词对当前词的影响考虑进来,就需要增加 \(n\) 的值。然而,随着 \(n\) 增加,模型参数的数量也会呈指数级增长,因为需要为词汇表中的每个词存储对应的参数。因此, 因此与其将 \(P(x_t \mid x_{t-1}, \ldots, x_{t-n+1})\) 模型化,不如使用隐变量模型(latent variable model): \[ P(x_t \mid x_{t-1}, \ldots, x_1) \approx P(x_t \mid h_{t-1}) \] 潜在变量模型的核心思想是通过引入一个隐藏状态(hidden state),它保存了直到当前时间步的序列信息。具体来说,隐藏状态在任意时间步 \(t\) 可以通过当前输入 \(x_t\) 和上一个隐藏状态 \(h_{t-1}\) 来计算: \[ h_t = f(x_{t}, h_{t-1}) \] 这里, \(f\) 是一个强大的函数,通过它可以计算隐藏状态。在这个模型中,隐藏状态不仅存储了之前所有的观测数据,而且通过适当设计,可以避免直接增加模型参数的数量。尽管如此,计算和存储的成本仍然可能较高。循环神经网络(RNN) 就是通过隐藏状态来建模序列数据的神经网络。
Note: “隐藏层”(hidden layers)和“隐藏状态”(hidden states)是两个完全不同的概念。隐藏层指的是从输入到输出路径中不可见的层,而隐藏状态是当前步骤的输入,可以通过查看之前的时间步的数据来计算。
RNN的结构与计算 #
在没有隐藏状态的神经网络中(例如多层感知机,MLP),输入通过隐藏层的激活函数进行处理,得到隐藏层的输出。对于每个小批量 \(\mathbf{X} \in \mathbb{R}^{n \times d}\) (minibatch)的输入,通过加权求和(包括偏置项),然后应用激活函数来计算隐藏层的输出 \(\mathbf{H} \in \mathbb{R}^{n \times h}\) 。 \[ \mathbf{H} = \phi(\mathbf{X} \mathbf{W}_{xh} + \mathbf{b}_h) \] 这些输出将作为下一层(通常是输出层)的输入。输出层的计算则通过类似于回归问题的方法得到。如果是分类问题,输出层会通过激活函数(如softmax)生成概率分布,来预测输出类别。通过自动微分和随机梯度下降(SGD),可以优化网络的参数。 \[ \mathbf{O} = \mathbf{H} \mathbf{W}_{hq} + \mathbf{b}_q, \]
然而,当神经网络引入了 隐藏状态(hidden states) 时,情况就有所不同。假设在时间步 \(t\) 时,我们有一个小批量的输入 \(x_t\) 。与MLP不同,RNN在每个时间步 \(t\) 都会保存前一个时间步的隐藏状态 \(h_{t-1}\) ,并使用一个新的权重矩阵 \(W_{hh}\) 来结合当前输入和前一个时间步的隐藏状态进行计算。具体地,当前时间步的隐藏状态 \(h_t\) 由当前输入 \(x_t\) 和前一时间步的隐藏状态 \(h_{t-1}\) 共同决定: \[ \mathbf{H}_t = \phi(\mathbf{X}_t \mathbf{W}_{xh} + \mathbf{H}_{t-1} \mathbf{W}_{hh} + \mathbf{b}_h) \]
\(\mathbf{X}_t\) 是当前时间步的输入向量,其维度为 \((\text{batch size}, \text{num inputs})\) ,其中 \(\text{batch size}\) 是批量大小, \(\text{num inputs}\) 是每个输入样本的特征数; \(\mathbf{H}_{t-1}\) 是上一时间步的隐藏状态,维度为 \((\text{batch size}, \text{num hiddens})\) , \(\text{num hiddens}\) 表示隐藏单元的数量。权重矩阵 \(\mathbf{W}_{xh}\) 的维度为 \((\text{num inputs}, \text{num hiddens})\) ,用于将输入特征映射到隐藏单元; \(\mathbf{W}_{hh}\) 的维度为 \((\text{num hiddens}, \text{num hiddens})\) ,用于描述隐藏单元之间的递归关系; \(\mathbf{b}_h\) 是偏置向量,维度为 \((\text{num hiddens},)\) 。隐藏状态 \(\mathbf{H}_t\) 的维度为 \((\text{batch size}, \text{num hiddens})\) ,是激活函数 \(\phi\) 作用后的结果,捕捉当前时间步的特征和动态信息。
Note: RNN 的隐藏单元与普通神经网络(NN)的隐藏单元有本质的不同。在传统的前馈神经网络(如全连接层,CNN)中,隐藏单元主要负责提取固定的特征表示。每个隐藏单元通常捕捉输入数据中的某种模式(feature),如图像的边缘、纹理或数据的非线性关系。
RNN 的隐藏单元不再是独立学习某个静态的特征,而是通过递归公式动态更新,捕捉输入序列中的时间依赖性规律,主要用于总结从时间步 1 到 t 的所有历史信息。例如,在自然语言处理中,RNN 的隐藏状态可以表示句子中已经看到的词的语义和语法结构。
RNN 的隐藏状态仅对当前时间有效。RNN 的设计目标是逐步传播信息。当前时间步的隐藏状态 h_t 是基于前 t 个输入计算的「总结」,只对当前任务有直接的意义。它并不显式保留每个时间步的特征,而是对这些特征进行压缩和提炼。随着时间步的增加,隐藏状态会逐渐遗忘较早的输入信息。
其中 \(f\) 表示激活函数, \(W_{xh}\) 是输入到隐藏层的权重, \(W_{hh}\) 是前一时间步隐藏状态到当前时间步的权重, \(b_h\) 是偏置项。通过这种方式,RNN的隐藏层不仅仅依赖于当前输入,还考虑了历史信息,因此具有”记忆”的功能,隐藏状态就是网络当前时刻的”记忆”。
Note: 「num_inputs」 x 表示输入特征的维度。每个时间步的输入向量长度。如果输入是一个独热编码(one-hot encoding)表示的词汇,num_inputs 就等于词汇表的大小(vocabulary size)。例如,一个词汇表有 10000 个单词,使用独热编码表示每个单词,那么 num_inputs = 10000。
「num_hiddens」h 表示 RNN 隐藏层中隐藏状态的维度。隐藏层状态向量的长度,控制网络的记忆能力(越大代表记忆能力越强)。每个时间步 RNN 的隐藏状态维度是固定的。如果 num_hiddens = 128,说明每个时间步的隐藏状态是一个 128 维的向量。
在RNN中,隐藏状态的计算是递归的,意味着它依赖于前一个时间步的状态。这种递归计算的特点使得RNN能够处理序列数据,并捕捉数据中的时序依赖关系。RNN的每一层都执行这一递归计算,被称为递归层(recurrent layer)。
Note: 在标准的RNN模型中,隐藏单元(hidden unit)的权重是共享的,即相同的权重矩阵 W_xh(从输入到隐藏状态的权重)和 W_hh(从上一个时间步的隐藏状态到当前隐藏状态的权重)在每个时间步都会被复用。这意味着,RNN通过不断更新隐藏状态,并依赖同一组权重来捕捉序列中的时序信息。尽管RNN在每个时间步都会计算新的隐藏状态,但在基础模型中没有涉及多层结构的概念。模型的核心是通过递归地传递隐藏状态来逐步更新信息。因此,RNN的“深度”通常指的是层数,在基础RNN中,只需要学习这些基本的权重。
与MLP类似,RNN的输出层的计算也类似,只是它的输入是当前时间步的隐藏状态 \(h_t\) 。RNN的目标是根据当前的隐藏状态,输出当前时间步的预测结果: \[ \mathbf{O}_t = \mathbf{H}_t \mathbf{W}_{hq} + \mathbf{b}_q \]
Note: W_hq 是语言模型的输出层权重矩阵,主要负责将 RNN 隐藏状态(hidden state)映射到词汇表的概率分布。它将隐藏状态 H_{t} 转换为一个向量,其中每个维度对应词汇表中的一个词的得分。得分经过 softmax 转换为概率分布,用于预测下一个词的概率。
W_{xh} 负责将当前输入 X_{t} 映射到隐藏状态空间。 W_{hh} 负责将前一时间步隐藏状态 H_{t-1} 更新到当前时间步隐藏状态 H_{t} 。
上图展示了循环神经网络在三个相邻时间步的计算逻辑。 在任意时间步 \(t\) ,隐状态的计算可以被视为:
- 拼接当前时间步 \(t\) 的输入 \(X_t\) 和前一时间步 \(t-1\) 的隐状态 \(H_{t-1}\) ;
- 将拼接的结果送入带有激活函数 \(\phi\) 的全连接层。 全连接层的输出是当前时间步的隐状态 \(H_{t}\) 。
- RNN 代码实现
import torch from torch import nn class RNNScratch(nn.Module): def __init__(self, num_inputs, num_hiddens, sigma=0.01): super().__init__() self.num_inputs = num_inputs self.num_hiddens = num_hiddens # 初始化权重参数 self.W_xh = nn.Parameter( torch.randn(num_inputs, num_hiddens) * sigma) # 输入到隐藏层权重 self.W_hh = nn.Parameter( torch.randn(num_hiddens, num_hiddens) * sigma) # 隐藏层到隐藏层权重 self.b_h = nn.Parameter(torch.zeros(num_hiddens)) # 隐藏层偏置 def forward(self, inputs, state=None): if state is None: # 初始化隐藏状态为零 state = torch.zeros((inputs.shape[1], self.num_hiddens)) outputs = [] for X in inputs: # X 的形状为 (batch_size, num_inputs) # 隐藏状态更新公式 state = torch.tanh(torch.matmul(X, self.W_xh) + torch.matmul(state, self.W_hh) + self.b_h) outputs.append(state) # 保存每个时间步的输出 return outputs, state
Note: 为什么以列表保存state?
在RNN的过程中,我们通常需要保存每个时间步的隐藏状态(state)。在序列输入-序列输出(Sequence-to-Sequence)任务中,比如机器翻译、时间序列预测、语音生成等,模型需要对每一个时间步生成对应的输出。在这些情况下,每个时间步的隐藏状态都需要被保存。
基于循环神经网络的字符级语言模型 #
假设我们将文本“machine”作为输入序列,并且为了简化训练过程,我们将文本划分为字符,而不是词汇。在训练过程中,对于每个时间步,我们对输出层的结果进行 softmax 操作,然后使用交叉熵损失函数计算模型输出和目标之间的误差。在 RNN 中,由于隐层的隐藏状态是递归计算的,因此第 3 个时间步的输出是由“m”、“a”和“c”三个字符决定的。由于训练数据中序列的下一个字符是“h”,因此第 3 个时间步的损失将依赖于基于特征序列“m”、“a”、“c”生成的下一个字符的概率分布,并与目标“h”进行比较。
在实际应用中,每个字符通常表示为一个维度为 \(d\) 的向量,而我们使用批量大小 \(B\) 。因此,在时间步 \(t\) 时的输入将是一个 \(B \times d\) 的矩阵。
One-Hot Encoding 独热编码 #
在处理类别型数据(如词汇中的单词或字符)时,常用 独热编码(One-Hot Encoding) 表示。独热向量的长度等于词汇表大小,只有一个位置的值为1,其余为0。例如,词汇表长度为5时,索引0和2的独热向量分别为: \([1, 0, 0, 0, 0] \quad \text{and} \quad [0, 0, 1, 0, 0]\) 。
对于每个输入的时间步,独热编码生成的输入张量形状为 ( \(\text{batch size}, \text{time steps}, \text{vocab size}\) )。模型通常会转置输入,以方便循环逐时间步更新隐状态。
- RNN One-Hot Encoding运用代码实现
class RNNLMScratch(nn.Module): """从零实现的基于 RNN 的语言模型""" def __init__(self, rnn, vocab_size, sigma=0.01): super().__init__() self.rnn = rnn # RNN 模型 self.vocab_size = vocab_size # 输出层参数 self.W_hq = nn.Parameter(torch.randn(rnn.num_hiddens, vocab_size) * sigma) self.b_q = nn.Parameter(torch.zeros(vocab_size)) def one_hot(self, X): """将输入转换为独热编码""" return F.one_hot(X.T, self.vocab_size).type(torch.float32) def output_layer(self, rnn_outputs): """应用输出层将隐藏状态映射到词表""" outputs = [torch.matmul(H, self.W_hq) + self.b_q for H in rnn_outputs] return torch.stack(outputs, dim=1) # 形状: (batch_size, num_steps, vocab_size) def forward(self, X, state=None): """前向传播""" embs = self.one_hot(X) # 独热编码: (num_steps, batch_size, vocab_size) rnn_outputs, state = self.rnn(embs, state) # 经过 RNN return self.output_layer(rnn_outputs), state # 返回输出和最终隐藏状态
梯度裁剪(Gradient Clipping) #
在RNN中,序列长度引入了新的深度概念:输入不仅在单个时间步内通过网络从输入传播到输出,还需要沿着时间步形成一个深度为 \(T\) 的层链。反向传播时,梯度需要通过这条时间链传递,从而形成长度为 \(T\) 的矩阵乘积链。由于权重矩阵的特性,梯度可能会出现数值不稳定的情况,导致梯度爆炸(exploding gradients)或消失(vanishing gradients)。
当梯度过大时,可能在一次梯度更新中对模型造成严重破坏,甚至导致训练发散或损失函数不稳定。最直接的方法是:减小学习率,但这会降低所有训练步骤的优化效率,即使大梯度事件是少数。梯度裁剪 是一种更常见的替代方法是将梯度投影到一个以半径 \(\theta\) 为界的球中,限制梯度范数不超过 \(\theta\) ,公式为: \[ \mathbf{g} \leftarrow \min\left(1, \frac{\theta}{\|\mathbf{g}\|}\right) \mathbf{g} \]
这样做确保了梯度大小受到控制,并且更新后的梯度方向与原始梯度一致。这种方法还可以限制单个小批量数据对模型参数的影响,提高模型的鲁棒性。
- 梯度裁剪(Gradient Clipping)代码实现:梯度裁剪通常在训练过程的反向传播(backward pass)之后和参数更新(optimizer.step())之前执行。这是因为梯度裁剪的目的是直接对反向传播计算得到的梯度进行操作,在它们被优化器用来更新模型参数之前对其进行规范化或限制。
def clip_gradients(model, grad_clip_val): # 筛选需要梯度更新的参数 params = [p for p in model.parameters() if p.requires_grad] # 计算梯度的 L2 范数 total_norm = torch.sqrt(sum(torch.sum(p.grad ** 2) for p in params)) # 如果梯度范数超过阈值,则按比例缩小 if total_norm > grad_clip_val: scaling_factor = grad_clip_val / total_norm for param in params: param.grad[:] *= scaling_factor
for epoch in range(num_epochs): for batch_inputs, batch_targets in dataloader: # 前向传播 logits, hidden_state = model(batch_inputs, hidden_state) loss = criterion(logits.view(-1, vocab_size), batch_targets.view(-1)) # 反向传播 optimizer.zero_grad() loss.backward() # 自定义梯度裁剪 clip_gradients(model, grad_clip_val) # 参数更新 optimizer.step()
解码 (Decoding) #
在训练好语言模型后,模型不仅可以预测下一个 token,还可以通过将前一个预测的 token 作为输入,连续地预测后续的 token。这种解码过程既可以从一个空白文档开始生成文本,也可以基于用户提供的前缀 (prefix) 来生成。比如,在搜索引擎的自动补全功能或邮件撰写助手中,可以将用户已经输入的内容作为前缀,生成可能的续写。解码过程的步骤可以总结为:
- Warm-up阶段:
- 解码开始时,将前缀输入到模型中,不输出任何结果。
- 目的是通过传递隐藏状态 \(\text{hidden state}\) ,初始化模型内部状态以适应上下文。
- 续写生成:
- 在输入完前缀后,模型开始生成后续字符。
- 每次生成一个字符,将其作为下一个时间步的输入,依次循环生成目标长度的文本。
- 输入和输出映射:
- 使用独热编码(one-hot embedding)处理输入。
- 通过输出层预测字符分布,并选择概率最大的字符作为结果。
- RNN 预测部分代码实现
def predict(model, prefix, num_preds, vocab, device=None): """ 基于前缀生成文本 Parameters: model: nn.Module 用于生成文本的语言模型,通常包含 RNN 和输出层。 prefix: list[str] 文本生成的前缀,即用于初始化上下文信息的一段文本序列。例如 ["I", "love"]。 num_preds: int 需要生成的后续单词数(预测的时间步数)。 vocab: object 词汇表对象,通常具有以下属性: - vocab[token]: 将 token 转换为其对应的索引。 - vocab.idx_to_token: 索引到 token 的映射,用于将生成的索引还原为文本。 Returns: str 拼接后的生成文本,包括前缀和预测的后续内容。 """ state = None # 初始化RNN的状态 outputs = [vocab[prefix[0]]] # 将前缀的第一个字符的索引加入输出序列 for i in range(len(prefix) - 1): # Warm-up阶段 X = torch.tensor([[outputs[-1]]], device=device) # 当前输入 embs = model.one_hot(X) # 获取当前输入的独热编码 rnn_outputs, state = model.rnn(embs, state) # 经过RNN更新状态 outputs.append(vocab[prefix[i + 1]]) # 继续在输出序列中加入前缀的下一个字符 # 预测阶段,生成后续字符 for _ in range(num_preds): # 预测num_preds步 X = torch.tensor([[outputs[-1]]], device=device) # 当前输入 embs = model.one_hot(X) # 获取当前输入的独热编码 rnn_outputs, state = model.rnn(embs, state) # 经过RNN更新状态 Y = model.output_layer(rnn_outputs) # 通过输出层映射到词汇表 next_token = int(Y.argmax(axis=2).reshape(1)) # 选择最大概率的字符 outputs.append(next_token) # 将预测结果添加到输出序列 # 将索引转为对应的字符并拼接为生成的文本 return ''.join([vocab.idx_to_token[i] for i in outputs])
RNN中的反向传播算法 #
时间反向传播是将递归神经网络(RNN)展开为一个时间步长的计算图,并通过链式法则对参数进行梯度反向传播。它的主要挑战在于处理长序列时可能出现的数值不稳定问题,比如梯度爆炸和梯度消失。
在简化模型中,我们将时间步 \(t\) 的隐状态表示为 \(h_t\) ,输入表示为 \(x_t\) ,输出表示为 \(o_t\) 。 输入和隐状态可以拼接后与隐藏层中的一个权重变量相乘。 因此,我们分别使用 \(w_h\) 和 \(w_o\) 来表示隐藏层和输出层的权重。 每个时间步的隐状态和输出可以写为:
\[ \begin{split}\begin{aligned}h_t &= f(x_t, h_{t-1}, w_h),\\ o_t &= g(h_t, w_o),\end{aligned}\end{split} \]其中 \(f\) 和 \(g\) 分别是隐藏层和输出层的变换。 因此,我们有一个链 \(\{\ldots, (x_{t-1}, h_{t-1}, o_{t-1}), (x_{t}, h_{t}, o_t), \ldots\}\) ,它们通过循环计算彼此依赖。前向传播相当简单,一次一个时间步的遍历三元组 \((x_t, h_t, o_t)\) ,然后通过一个目标函数在所有个时间步 \(T\) 内 评估输出 \(o_t\) 和对应的标签 \(y_t\) 之间的差异:
\[ L(x_1, \ldots, x_T, y_1, \ldots, y_T, w_h, w_o) = \frac{1}{T}\sum_{t=1}^T l(y_t, o_t) \]对于反向传播,按照链式法则:
\[ \begin{split}\begin{aligned}\frac{\partial L}{\partial w_h} & = \frac{1}{T}\sum_{t=1}^T \frac{\partial l(y_t, o_t)}{\partial w_h} \\& = \frac{1}{T}\sum_{t=1}^T \frac{\partial l(y_t, o_t)}{\partial o_t} \frac{\partial g(h_t, w_o)}{\partial h_t} \frac{\partial h_t}{\partial w_h}.\end{aligned}\end{split} \]乘积的第一项和第二项很容易计算,而第三项 \(\partial h_t/\partial w_h\) 是使事情变得棘手的地方,因为我们需要循环地计算参数对的影响。使用链式法则得到:
\[ \begin{split}\begin{aligned} \frac{\partial h_t}{\partial w_h} &= \frac{\partial f(x_{t},h_{t-1},w_h)}{\partial w_h} +\frac{\partial f(x_{t},h_{t-1},w_h)}{\partial h_{t-1}} \frac{\partial h_{t-1}}{\partial w_h} \\ & =\frac{\partial f(x_{t},h_{t-1},w_h)}{\partial w_h}+\sum_{i=1}^{t-1}\left(\prod_{j=i+1}^{t} \frac{\partial f(x_{j},h_{j-1},w_h)}{\partial h_{j-1}} \right) \frac{\partial f(x_{i},h_{i-1},w_h)}{\partial w_h}. \end{aligned}\end{split} \]虽然我们可以使用链式法则递归地计算 \(\partial h_t/\partial w_h\) , 但当 \(t\) 很大时这个链就会变得很长。常用解决策略可以总结为:
- 全量计算: 对所有时间步长进行完整的梯度反向传播,然而,这样的计算非常缓慢,并且可能会发生梯度爆炸,因为初始条件的微小变化就可能会对结果产生巨大的影响。
- 时间步截断(Truncated BPTT): 截断时间步长,仅计算过去 \(\tau\) 步的梯度。这样做导致该模型主要侧重于短期影响,而不是长期影响。 这在现实中是可取的,因为它会将估计值偏向更简单和更稳定的模型。
- 随机截断(Randomized Truncation): 用一个随机变量替换 \(\partial h_t/\partial w_h\) ,该随机变量在预期中是正确的,但是会截断序列。虽然随机截断在理论上具有吸引力, 但很可能是由于多种因素在实践中并不比常规截断更好。
反向传播的细节 #
在时间反向传播(BPTT)中,我们需要计算目标函数对模型参数的梯度。考虑一个没有偏置参数的循环神经网络, 其在隐藏层中的激活函数使用恒等映射( \(\phi(x)=x\) )。对于时间步 \(t\) ,设单个样本的输入及其对应的标签分别为 \(\mathbf{x}_t \in \mathbb{R}^d\) 和 \(y_{t}\) 。 计算隐状态 \(\mathbf{h}_t \in \mathbb{R}^h\) 和输出 \(\mathbf{o}_t \in \mathbb{R}^q\) 的方式为:
\[ \begin{split}\begin{aligned}\mathbf{h}_t &= \mathbf{W}_{hx} \mathbf{x}_t + \mathbf{W}_{hh} \mathbf{h}_{t-1},\\ \mathbf{o}_t &= \mathbf{W}_{qh} \mathbf{h}_{t},\end{aligned}\end{split} \]其中权重参数为 \(\mathbf{W}_{hx} \in \mathbb{R}^{h \times d}\) 、 \(\mathbf{W}_{hh} \in \mathbb{R}^{h \times h}\) 和 \(\mathbf{W}_{qh} \in \mathbb{R}^{q \times h}\) 。用 \(l(\mathbf{o}_t, y_t)\) 表示时间步处(即从序列开始起的超过 \(T\) 个时间步)的损失函数, 则我们的目标函数的总体损失是:
\[ L = \frac{1}{T} \sum_{t=1}^T l(\mathbf{o}_t, y_t). \]损失函数对输出 \(\mathbf{o}_t\) 的梯度计算如下: \[ \frac{\partial L}{\partial \mathbf{o}_t} = \frac{\partial l (\mathbf{o}_t, y_t)}{T \cdot \partial \mathbf{o}_t} \in \mathbb{R}^q. \]
对于输出层参数 \(\mathbf{W}_{qh}\) ,使用链式法则得:
\[ \frac{\partial L}{\partial \mathbf{W}_{qh}} = \sum_{t=1}^T \text{prod}\left(\frac{\partial L}{\partial \mathbf{o}_t}, \frac{\partial \mathbf{o}_t}{\partial \mathbf{W}_{qh}}\right) = \sum_{t=1}^T \frac{\partial L}{\partial \mathbf{o}_t} \mathbf{h}_t^\top, \]对于最终时间步 \(T\) ,目标函数只通过 \(\mathbf{o}_T\) 依赖于隐藏状态 \(\mathbf{h}_T\) ,因此: \[ \frac{\partial L}{\partial \mathbf{h}_T} = \text{prod}\left(\frac{\partial L}{\partial \mathbf{o}_T}, \frac{\partial \mathbf{o}_T}{\partial \mathbf{h}_T} \right) = \mathbf{W}_{qh}^\top \frac{\partial L}{\partial \mathbf{o}_T}. \]
对于中间时间步 \(t\) ,隐藏状态 \(\mathbf{h}_t\) 同时通过 \(\mathbf{o}_t\) 和 \(\mathbf{h}_{t+1}\) 影响目标函数。利用递归公式计算:
\[ \frac{\partial L}{\partial \mathbf{h}_t} = \text{prod}\left(\frac{\partial L}{\partial \mathbf{h}_{t+1}}, \frac{\partial \mathbf{h}_{t+1}}{\partial \mathbf{h}_t} \right) + \text{prod}\left(\frac{\partial L}{\partial \mathbf{o}_t}, \frac{\partial \mathbf{o}_t}{\partial \mathbf{h}_t} \right) = \mathbf{W}_{hh}^\top \frac{\partial L}{\partial \mathbf{h}_{t+1}} + \mathbf{W}_{qh}^\top \frac{\partial L}{\partial \mathbf{o}_t}. \]展开递归公式可得:
\[ \frac{\partial L}{\partial \mathbf{h}_t}= \sum_{i=t}^T {\left(\mathbf{W}_{hh}^\top\right)}^{T-i} \mathbf{W}_{qh}^\top \frac{\partial L}{\partial \mathbf{o}_{T+t-i}}. \]在长序列中,由于 \(\mathbf{W}_{hh}\) 的特征值可能远小于或大于 1,导致梯度逐步消失或爆炸。
最终隐藏层参数 \(\mathbf{W}_{xh}\) 和 \(\mathbf{W}_{hh}\) 的梯度计算如下: \[ \begin{split}\begin{aligned} \frac{\partial L}{\partial \mathbf{W}_{hx}} &= \sum_{t=1}^T \text{prod}\left(\frac{\partial L}{\partial \mathbf{h}_t}, \frac{\partial \mathbf{h}_t}{\partial \mathbf{W}_{hx}}\right) = \sum_{t=1}^T \frac{\partial L}{\partial \mathbf{h}_t} \mathbf{x}_t^\top,\\ \frac{\partial L}{\partial \mathbf{W}_{hh}} &= \sum_{t=1}^T \text{prod}\left(\frac{\partial L}{\partial \mathbf{h}_t}, \frac{\partial \mathbf{h}_t}{\partial \mathbf{W}_{hh}}\right) = \sum_{t=1}^T \frac{\partial L}{\partial \mathbf{h}_t} \mathbf{h}_{t-1}^\top, \end{aligned}\end{split} \]
简单循环神经网络实现 #
import torch
import torch.nn as nn
import torch.optim as optim
# RNN模型定义
class RNN(nn.Module):
def __init__(self, num_inputs, num_hiddens):
super().__init__()
self.rnn = nn.RNN(num_inputs, num_hiddens, batch_first=True)
def forward(self, inputs, hidden_state=None):
return self.rnn(inputs, hidden_state)
# RNN语言模型定义
class RNNLM(nn.Module):
"""基于RNN的语言模型"""
def __init__(self, rnn, vocab_size, num_hiddens, lr=1e-2):
super(RNNLM, self).__init__()
self.rnn = rnn
self.vocab_size = vocab_size
self.num_hiddens = num_hiddens
self.linear = nn.Linear(num_hiddens, vocab_size) # 输出层
self.softmax = nn.Softmax(dim=-1)
self.lr = lr
def forward(self, inputs, hidden_state=None):
# 前向传播,输入通过RNN,接着通过线性层
rnn_output, hidden_state = self.rnn(inputs, hidden_state)
output = self.linear(rnn_output) # 获取输出
return output, hidden_state
def output_layer(self, hiddens):
return self.linear(hiddens).swapaxes(0, 1)
def init_params(self):
"""初始化模型参数"""
for name, param in self.named_parameters():
if 'weight' in name:
nn.init.normal_(param, mean=0, std=0.01)
else:
nn.init.zeros_(param)
def predict(self, prefix, num_preds, vocab, device=None):
"""基于前缀生成文本"""
state = None
outputs = [vocab[prefix[0]]]
for i in range(len(prefix) - 1): # Warm-up 阶段
X = torch.tensor([[outputs[-1]]], device=device) # 当前输入
embs = self.one_hot(X) # 获取当前输入的独热编码
rnn_outputs, state = self.rnn(embs, state) # 经过RNN更新状态
outputs.append(vocab[prefix[i + 1]]) # 继续在输出序列中加入前缀的下一个字符
# 预测阶段,生成后续字符
for _ in range(num_preds): # 预测num_preds步
X = torch.tensor([[outputs[-1]]], device=device) # 当前输入
embs = self.one_hot(X) # 获取当前输入的独热编码
rnn_outputs, state = self.rnn(embs, state) # 经过RNN更新状态
Y = self.output_layer(rnn_outputs) # 通过输出层映射到词汇表
next_token = int(Y.argmax(axis=2).reshape(1)) # 选择最大概率的字符
outputs.append(next_token) # 将预测结果添加到输出序列中
# 将索引转为对应的字符并拼接为生成的文本
return ''.join([vocab.idx_to_token[i] for i in outputs])
def one_hot(self, X):
"""将输入转换为独热编码"""
return torch.nn.functional.one_hot(X.T, self.vocab_size).float()
# 实例化模型
rnn = RNN(num_inputs=len(vocab), num_hiddens=32)
model = RNNLM(rnn, vocab_size=len(vocab), num_hiddens=32, lr=0.01)