Run this notebook online:\ |Binder| or Colab: |Colab| .. |Binder| image:: https://mybinder.org/badge_logo.svg :target: https://mybinder.org/v2/gh/deepjavalibrary/d2l-java/master?filepath=chapter_recurrent-neural-networks/rnn-scratch.ipynb .. |Colab| image:: https://colab.research.google.com/assets/colab-badge.svg :target: https://colab.research.google.com/github/deepjavalibrary/d2l-java/blob/colab/chapter_recurrent-neural-networks/rnn-scratch.ipynb .. _sec_rnn_scratch: 递归神经网络从头开始的实现 ========================== 在本节中,我们将根据 :numref:`sec_rnn`\ 中的描述, 从头开始基于循环神经网络实现字符级语言模型。 这样的模型将在H.G.威尔斯的时光机器数据集上训练。 和前面 :numref:`sec_language_model`\ 中介绍过的一样, 我们先读取数据集。 .. code:: java %load ../utils/djl-imports %load ../utils/plot-utils %load ../utils/Functions.java %load ../utils/PlotUtils.java %load ../utils/StopWatch.java %load ../utils/Accumulator.java %load ../utils/Animator.java %load ../utils/Training.java %load ../utils/timemachine/Vocab.java %load ../utils/timemachine/RNNModelScratch.java %load ../utils/timemachine/TimeMachine.java %load ../utils/timemachine/SeqDataLoader.java .. code:: java @FunctionalInterface public interface TriFunction { public W apply(T t, U u, V v); } @FunctionalInterface public interface QuadFunction { public R apply(T t, U u, V v, W w); } @FunctionalInterface public interface SimpleFunction { public T apply(); } @FunctionalInterface public interface voidFunction { public void apply(T t); } @FunctionalInterface public interface voidTwoFunction { public void apply(T t, U u); } .. code:: java NDManager manager = NDManager.newBaseManager(); .. code:: java int batchSize = 32; int numSteps = 35; Pair, Vocab> timeMachine = SeqDataLoader.loadDataTimeMachine(batchSize, numSteps, false, 10000, manager); List trainIter = timeMachine.getKey(); Vocab vocab = timeMachine.getValue(); 独热编码 -------- 回想一下,每个标记在 ``trainIter``. 中都表示为一个数字索引。 将这些指数直接输入神经网络可能会使其难以识别 学习 我们通常将每个标记表示为更具表现力的特征向量。 最简单的表示法称为 (*one-hot encoding*), 介绍了 in :numref:`subsec_classification-problem`. 简言之,我们将每个索引映射到一个不同的单位向量:假设词汇表中不同标记的数量为 :math:`N` (``vocab.length()``) ,标记索引的范围为0到 :math:`N-1`\ 。 如果token的索引是整数 :math:`i`\ ,那么我们创建一个长度为 :math:`N` 的所有0的向量,并将元素的位置 :math:`i` 设置为1。 此向量是原始token的一个热向量。索引为0和2的一个独热向量如下所示。 .. code:: java manager.create(new int[] {0, 2}).oneHot(vocab.length()) .. parsed-literal:: :class: output ND: (2, 29) gpu(0) float32 [[1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., ... 9 more], [0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., ... 9 more], ] 我们每次采样的小批量的形状是(批量大小、时间步数)。 ``oneHot`` 函数将这样一个小批量转换为三维数据数组,最后一个维度等于词汇表大小(\ ``vocab.length()``). 我们经常转换输入,以便获得一个 形状输出 (时间步数、批次大小、词汇表大小)。 这将使我们 更方便 循环通过最外层维度 用于更新小批量的隐藏状态。 .. code:: java NDArray X = manager.arange(10).reshape(new Shape(2,5)); X.transpose().oneHot(28).getShape() .. parsed-literal:: :class: output (5, 2, 28) 初始化模型参数 -------------- 接下来,我们初始化的模型参数 循环神经网络模型。 隐藏单元数 ``numHiddens`` 是一个可调超参数。 在培训语言模型时, 输入和输出来自同一词汇表。 因此,它们具有相同的维度, 这等于词汇量。 .. code:: java public static NDList getParams(int vocabSize, int numHiddens, Device device) { int numOutputs = vocabSize; int numInputs = vocabSize; // 隐藏层参数 NDArray W_xh = normal(new Shape(numInputs, numHiddens), device); NDArray W_hh = normal(new Shape(numHiddens, numHiddens), device); NDArray b_h = manager.zeros(new Shape(numHiddens), DataType.FLOAT32, device); // 输出层参数 NDArray W_hq = normal(new Shape(numHiddens, numOutputs), device); NDArray b_q = manager.zeros(new Shape(numOutputs), DataType.FLOAT32, device); // 加上梯度 NDList params = new NDList(W_xh, W_hh, b_h, W_hq, b_q); for (NDArray param : params) { param.setRequiresGradient(true); } return params; } public static NDArray normal(Shape shape, Device device) { return manager.randomNormal(0f, 0.01f, shape, DataType.FLOAT32, device); } 循环神经网络模型 ---------------- 要定义循环神经网络模型, 我们首先需要一个 ``initRNNState`` '函数 在初始化时返回隐藏状态。 它返回一个填充为0且形状为(批量大小、隐藏单元数)的数据数组。 .. code:: java public static NDList initRNNState(int batchSize, int numHiddens, Device device) { return new NDList(manager.zeros(new Shape(batchSize, numHiddens), DataType.FLOAT32, device)); } 下面的 ``rnn`` 函数定义了如何计算隐藏状态和输出 在一个时间步。 注意 循环神经网络模型 循环通过\ ``inputs``\ 的最外层维度 这样它就可以更新小批量的隐藏状态 ``H`` , 此外 这里的激活函数使用 :math:`\tanh` 函数。 像 描述于 :numref:`sec_mlp`, 该 当元素均匀分布时, :math:`\tanh` 函数的平均值为0 分布在实数上。 .. code:: java public static Pair rnn(NDArray inputs, NDList state, NDList params) { // 输入的形状:(`numSteps`、`batchSize`、`vocabSize`) NDArray W_xh = params.get(0); NDArray W_hh = params.get(1); NDArray b_h = params.get(2); NDArray W_hq = params.get(3); NDArray b_q = params.get(4); NDArray H = state.get(0); NDList outputs = new NDList(); // 'X'的形状:('batchSize','vocabSize`) NDArray X, Y; for (int i = 0; i < inputs.size(0); i++) { X = inputs.get(i); H = (X.dot(W_xh).add(H.dot(W_hh)).add(b_h)).tanh(); Y = H.dot(W_hq).add(b_q); outputs.add(Y); } return new Pair<>(outputs.size() > 1 ? NDArrays.concat(outputs) : outputs.get(0), new NDList(H)); } 定义了所有需要的功能, 接下来,我们创建一个类来包装这些函数,并存储从头实现的循环神经网络模型的参数。 .. code:: java /** 从头开始实现的RNN模型 */ public class RNNModelScratch { public int vocabSize; public int numHiddens; public NDList params; public TriFunction initState; public TriFunction forwardFn; public RNNModelScratch( int vocabSize, int numHiddens, Device device, TriFunction getParams, TriFunction initRNNState, TriFunction forwardFn) { this.vocabSize = vocabSize; this.numHiddens = numHiddens; this.params = getParams.apply(vocabSize, numHiddens, device); this.initState = initRNNState; this.forwardFn = forwardFn; } public Pair forward(NDArray X, NDList state) { X = X.transpose().oneHot(this.vocabSize); return this.forwardFn.apply(X, state, this.params); } public NDList beginState(int batchSize, Device device) { return this.initState.apply(batchSize, this.numHiddens, device); } } 让我们检查输出是否具有正确的形状,例如,以确保隐藏状态的维度保持不变。 .. code:: java int numHiddens = 512; TriFunction getParamsFn = (a, b, c) -> getParams(a, b, c); TriFunction initRNNStateFn = (a, b, c) -> initRNNState(a, b, c); TriFunction rnnFn = (a, b, c) -> rnn(a, b, c); NDArray X = manager.arange(10).reshape(new Shape(2, 5)); Device device = manager.getDevice(); RNNModelScratch net = new RNNModelScratch( vocab.length(), numHiddens, device, getParamsFn, initRNNStateFn, rnnFn); NDList state = net.beginState((int) X.getShape().getShape()[0], device); Pair pairResult = net.forward(X.toDevice(device, false), state); NDArray Y = pairResult.getKey(); NDList newState = pairResult.getValue(); System.out.println(Y.getShape()); System.out.println(newState.get(0).getShape()); .. parsed-literal:: :class: output (10, 29) (2, 512) 我们可以看到输出形状是(时间步数 :math:`\times` batch大小,词汇表大小),而隐藏状态形状保持不变,即(批大小,隐藏单元数)。 预测 ---- 让我们首先定义预测函数来生成\ ``prefix``\ 之后的新字符, 其中的\ ``prefix``\ 是一个用户提供的包含多个字符的字符串。 在循环遍历\ ``prefix``\ 中的开始字符时, 我们不断地将隐状态传递到下一个时间步,但是不生成任何输出。 这被称为\ *预热*\ (warm-up)期, 因为在此期间模型会自我更新(例如,更新隐状态), 但不会进行预测。 预热期结束后,隐状态的值通常比刚开始的初始值更适合预测, 从而预测字符并输出它们。 .. code:: java /** 在 `prefix` 后面生成新字符。 */ public static String predictCh8( String prefix, int numPreds, RNNModelScratch net, Vocab vocab, Device device) { NDList state = net.beginState(1, device); List outputs = new ArrayList<>(); outputs.add(vocab.getIdx("" + prefix.charAt(0))); SimpleFunction getInput = () -> manager.create(outputs.get(outputs.size() - 1)) .toDevice(device, false) .reshape(new Shape(1, 1)); for (char c : prefix.substring(1).toCharArray()) { // 预热期 state = (NDList) net.forward(getInput.apply(), state).getValue(); outputs.add(vocab.getIdx("" + c)); } NDArray y; for (int i = 0; i < numPreds; i++) { Pair pair = net.forward(getInput.apply(), state); y = pair.getKey(); state = pair.getValue(); outputs.add((int) y.argMax(1).reshape(new Shape(1)).getLong(0L)); } StringBuilder output = new StringBuilder(); for (int i : outputs) { output.append(vocab.idxToToken.get(i)); } return output.toString(); } 现在我们可以测试 ``predict_ch8`` 函数。 我们将前缀指定为 ``time traveller`` ,并让它生成10个附加字符。 鉴于我们没有对网络进行培训, 它将产生荒谬的预测。 .. code:: java predictCh8("time traveller ", 10, net, vocab, manager.getDevice()); .. parsed-literal:: :class: output time traveller ksssss 梯度裁剪 -------- 对于长度为 :math:`T` 的序列, 我们在一次迭代中计算这些 :math:`T` 时间步上的梯度,这导致在反向传播期间产生长度为 :math:`\mathcal{O}(T)` 的矩阵乘积链。 如 :numref:`sec_numerical_stability`, 中所述,它可能导致数值不稳定,例如,当 :math:`T` 较大时,梯度可能会爆炸或消失。因此,RNN模型通常需要额外的帮助来稳定训练。 一般来说, 在解决优化问题时, 我们对模型参数采取更新步骤, 以向量形式说 :math:`\mathbf{x}`, 在小批量上的负梯度方向 :math:`\mathbf{g}` 例如, 以 :math:`\eta > 0` 作为学习率, 在一次迭代中,我们更新 :math:`\mathbf{x}` 作为\ :math:`\mathbf{x} - \eta \mathbf{g}`. 让我们进一步假设目标函数 :math:`f` 行为良好,例如, *Lipschitz continuous* ,常数为 :math:`L`. 就是说, 对于任何 :math:`\mathbf{x}` 和 :math:`\mathbf{y}` 我们都有 .. math:: |f(\mathbf{x}) - f(\mathbf{y})| \leq L \|\mathbf{x} - \mathbf{y}\|. 在这种情况下,我们可以安全地假设,如果我们将参数向量更新为 :math:`\eta \mathbf{g}`, 那么 .. math:: |f(\mathbf{x}) - f(\mathbf{x} - \eta\mathbf{g})| \leq L \eta\|\mathbf{g}\|, 也就是说 我们观察到的变化不会超过 :math:`L \eta \|\mathbf{g}\|`\ 。这既是坏事也是好事。 在坏事方面, 它限制了进步的速度; 而在好事方面, 它限制了如果我们朝着错误的方向前进,事情可能会出错的程度。 有时梯度可能相当大,优化算法可能无法收敛。我们可以通过降低学习率 :math:`\eta`. 但是如果我们\ *很少* 得到大的梯度呢?在这种情况下,这种做法可能显得毫无根据。一种流行的替代方法是通过将梯度 :math:`\mathbf{g}` 投影回给定半径的球,例如 :math:`\theta` 来剪裁梯度 :math:`\mathbf{g}` .. math:: \mathbf{g} \leftarrow \min\left(1, \frac{\theta}{\|\mathbf{g}\|}\right) \mathbf{g}. 通过这样做,我们知道梯度范数永远不会超过 :math:`\theta` ,并且 更新的梯度与 :math:`\mathbf{g}` 的原始方向完全对齐。 它还具有限制任何给定影响的理想副作用 minibatch(以及其中的任何给定样本)可以应用于参数向量。这 赋予模型一定程度的鲁棒性。渐变剪裁提供 快速修复渐变爆炸。虽然它不能完全解决问题,但它是缓解问题的众多技术之一。 下面我们定义一个函数来剪裁 从头开始实现的模型或由高级API构建的模型。 还要注意,我们计算了所有模型参数的梯度范数。 .. code:: java /** 修剪梯度 */ public static void gradClipping(RNNModelScratch net, int theta, NDManager manager) { double result = 0; for (NDArray p : net.params) { NDArray gradient = p.getGradient(); gradient.attach(manager); result += gradient.pow(2).sum().getFloat(); } double norm = Math.sqrt(result); if (norm > theta) { for (NDArray param : net.params) { NDArray gradient = param.getGradient(); gradient.muli(theta / norm); } } } 训练 ---- 在训练模特之前, 让我们定义一个函数来在一个历元中训练模型。它与我们在三个地方训练: :numref:`sec_softmax_scratch` 模型的方式不同: 1. 顺序数据的不同采样方法(随机采样和顺序分区)将导致隐藏状态初始化的差异。 2. 在更新模型参数之前,我们剪裁梯度。这确保了模型不会发散,即使在训练过程中的某个点上坡度增大。 3. 我们使用困惑度来评估模型。如 :numref:`subsec_perplexity` 中所述,这确保了不同长度的序列具有可比性。 明确地 当使用顺序分区时,我们仅在每个历元开始时初始化隐藏状态。 由于下一个minibatch中的 :math:`i^\mathrm{th}` 子序列示例与当前的 :math:`i^\mathrm{th}` 子序列示例相邻, 当前批处理结束时的隐藏状态 将 用于初始化 下一个迷你批处理开始时的隐藏状态。 这样, 序列的历史信息 以隐藏状态存储 可能溢出 一个epoch内相邻的子序列。 然而,隐藏状态的计算 任何时候都取决于以前的所有小批量 在同一epoch, 这使得梯度计算复杂化。 为了降低计算成本, 我们在处理任何小批量之前分离梯度 使隐态的梯度计算 始终限于 一个小批量中的时间步长。 在使用随机抽样时, 我们需要为每个迭代重新初始化隐藏状态,因为每个示例都是使用随机位置采样的。 与 :numref:`sec_softmax_scratch` 中的\ ``trainepoch3``\ 函数相同, ``updater`` 是一个通用函数 以更新模型参数。 它可以是从头开始实现的函数,也可以是中的内置优化函数 深度学习框架。 .. code:: java /** 在一个opoch内训练一个模型。 */ public static Pair trainEpochCh8( RNNModelScratch net, List trainIter, Loss loss, voidTwoFunction updater, Device device, boolean useRandomIter) { StopWatch watch = new StopWatch(); watch.start(); Accumulator metric = new Accumulator(2); // 训练损失总数 try (NDManager childManager = manager.newSubManager()) { NDList state = null; for (NDList pair : trainIter) { NDArray X = pair.get(0).toDevice(device, true); X.attach(childManager); NDArray Y = pair.get(1).toDevice(device, true); Y.attach(childManager); if (state == null || useRandomIter) { // 在第一次迭代或 // 使用随机取样 state = net.beginState((int) X.getShape().getShape()[0], device); } else { for (NDArray s : state) { s.stopGradient(); } } state.attach(childManager); NDArray y = Y.transpose().reshape(new Shape(-1)); X = X.toDevice(device, false); y = y.toDevice(device, false); try (GradientCollector gc = manager.getEngine().newGradientCollector()) { Pair pairResult = net.forward(X, state); NDArray yHat = pairResult.getKey(); state = pairResult.getValue(); NDArray l = loss.evaluate(new NDList(y), new NDList(yHat)).mean(); gc.backward(l); metric.add(new float[] {l.getFloat() * y.size(), y.size()}); } gradClipping(net, 1, childManager); updater.apply(1, childManager); // 因为已经调用了“mean”函数 } } return new Pair<>(Math.exp(metric.get(0) / metric.get(1)), metric.get(1) / watch.stop()); } 训练功能支持 实现了一个RNN模型 要么从头开始 或者使用高级API。 .. code:: java /** 训练一个模型 */ public static void trainCh8( RNNModelScratch net, List trainIter, Vocab vocab, int lr, int numEpochs, Device device, boolean useRandomIter) { SoftmaxCrossEntropyLoss loss = new SoftmaxCrossEntropyLoss(); Animator animator = new Animator(); // 初始化 voidTwoFunction updater = (batchSize, subManager) -> Training.sgd(net.params, lr, batchSize, subManager); Function predict = (prefix) -> predictCh8(prefix, 50, net, vocab, device); // 训练和推理 double ppl = 0.0; double speed = 0.0; for (int epoch = 0; epoch < numEpochs; epoch++) { Pair pair = trainEpochCh8(net, trainIter, loss, updater, device, useRandomIter); ppl = pair.getKey(); speed = pair.getValue(); if ((epoch + 1) % 10 == 0) { animator.add(epoch + 1, (float) ppl, ""); animator.show(); } } System.out.format( "perplexity: %.1f, %.1f tokens/sec on %s%n", ppl, speed, device.toString()); System.out.println(predict.apply("time traveller")); System.out.println(predict.apply("traveller")); } 现在我们可以训练循环神经网络模型。 因为我们在数据集中只使用10000个标记,所以模型需要更多的时间来更好地收敛 .. code:: java int numEpochs = Integer.getInteger("MAX_EPOCH", 500); int lr = 1; trainCh8(net, trainIter, vocab, lr, numEpochs, manager.getDevice(), false); .. raw:: html
.. parsed-literal:: :class: output perplexity: 1.0, 42283.1 tokens/sec on gpu(0) time traveller but now you begin to seethe object of my investig traveller chour and simet burateer gat omey l bat of pracie 最后 让我们检查一下使用随机抽样方法的结果。 .. code:: java trainCh8(net, trainIter, vocab, lr, numEpochs, manager.getDevice(), true); .. raw:: html
.. parsed-literal:: :class: output perplexity: 1.1, 41992.6 tokens/sec on gpu(0) time traveller held in his hand was o caing abogitt to veeettor traveller suicu and nst doun now to ssiage op s ais anl peg 从零开始实现上述循环神经网络模型, 虽然有指导意义,但是并不方便。 在下一节中,我们将学习如何改进循环神经网络模型。 例如,如何使其实现地更容易,且运行速度更快。 总结 ---- - 我们可以训练一个基于循环神经网络的字符级语言模型,根据用户提供的文本的前缀生成后续文本。 - 一个简单的循环神经网络语言模型包括输入编码、循环神经网络模型和输出生成。 - 循环神经网络模型在训练以前需要初始化状态,不过随机抽样和顺序划分使用初始化方法不同。 - 当使用顺序划分时,我们需要分离梯度以减少计算量。 - 在进行任何预测之前,模型通过预热期进行自我更新(例如,获得比初始值更好的隐状态)。 - 梯度裁剪可以防止梯度爆炸,但不能应对梯度消失。 练习 ---- 1. 显示一个热编码相当于为每个对象选择不同的嵌入。 2. 调整超参数(例如,epoch数、隐藏单元数、小批量时间步数和学习速率)以改善困惑。 - 你能降到多低? - 用可学习的嵌入替换一个热编码。这会导致更好的性能吗? - 它在H.G.威尔斯的其他书籍上的效果如何,例如 `*世界大战* `__? 3. 修改预测函数,例如使用采样,而不是拾取最可能的下一个字符。 - 会发生什么? - 使模型偏向更可能的输出,例如,通过从 :math:`q(x_t \mid x_{t-1}, \ldots, x_1) \propto P(x_t \mid x_{t-1}, \ldots, x_1)^\alpha` for :math:`\alpha > 1`\ 进行采样。 4. 在不剪切梯度的情况下运行本节中的代码。会发生什么? 5. 更改顺序分区,使其不会从计算图中分离隐藏状态。运行时间有变化吗?那么困惑呢? 6. 用ReLU替换本节中使用的激活功能,并重复本节中的实验。我们还需要梯度剪裁吗?为什么?