Run this notebook online:Binder or Colab: Colab

8.5. 递归神经网络从头开始的实现

在本节中,我们将根据 Section 8.4中的描述, 从头开始基于循环神经网络实现字符级语言模型。 这样的模型将在H.G.威尔斯的时光机器数据集上训练。 和前面 Section 8.3中介绍过的一样, 我们先读取数据集。

%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
@FunctionalInterface
public interface TriFunction<T, U, V, W> {
    public W apply(T t, U u, V v);
}

@FunctionalInterface
public interface QuadFunction<T, U, V, W, R> {
    public R apply(T t, U u, V v, W w);
}

@FunctionalInterface
public interface SimpleFunction<T> {
    public T apply();
}

@FunctionalInterface
public interface voidFunction<T> {
    public void apply(T t);
}

@FunctionalInterface
public interface voidTwoFunction<T, U> {
    public void apply(T t, U u);
}
NDManager manager = NDManager.newBaseManager();
int batchSize = 32;
int numSteps = 35;
Pair<List<NDList>, Vocab> timeMachine = SeqDataLoader.loadDataTimeMachine(batchSize, numSteps, false, 10000, manager);
List<NDList> trainIter = timeMachine.getKey();
Vocab vocab = timeMachine.getValue();

8.5.1. 独热编码

回想一下,每个标记在 trainIter. 中都表示为一个数字索引。 将这些指数直接输入神经网络可能会使其难以识别 学习 我们通常将每个标记表示为更具表现力的特征向量。 最简单的表示法称为 (one-hot encoding), 介绍了 in Section 3.4.1.

简言之,我们将每个索引映射到一个不同的单位向量:假设词汇表中不同标记的数量为 \(N\) (vocab.length()) ,标记索引的范围为0到 \(N-1\)。 如果token的索引是整数 \(i\),那么我们创建一个长度为 \(N\) 的所有0的向量,并将元素的位置 \(i\) 设置为1。 此向量是原始token的一个热向量。索引为0和2的一个独热向量如下所示。

manager.create(new int[] {0, 2}).oneHot(vocab.length())
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()). 我们经常转换输入,以便获得一个 形状输出 (时间步数、批次大小、词汇表大小)。 这将使我们 更方便 循环通过最外层维度 用于更新小批量的隐藏状态。

NDArray X = manager.arange(10).reshape(new Shape(2,5));
X.transpose().oneHot(28).getShape()
(5, 2, 28)

8.5.2. 初始化模型参数

接下来,我们初始化的模型参数 循环神经网络模型。 隐藏单元数 numHiddens 是一个可调超参数。 在培训语言模型时, 输入和输出来自同一词汇表。 因此,它们具有相同的维度, 这等于词汇量。

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);
}

8.5.3. 循环神经网络模型

要定义循环神经网络模型, 我们首先需要一个 initRNNState ‘函数 在初始化时返回隐藏状态。 它返回一个填充为0且形状为(批量大小、隐藏单元数)的数据数组。

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 , 此外 这里的激活函数使用 \(\tanh\) 函数。 像 描述于 Section 4.1, 该 当元素均匀分布时, \(\tanh\) 函数的平均值为0 分布在实数上。

public static Pair<NDArray, NDList> 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));
}

定义了所有需要的功能, 接下来,我们创建一个类来包装这些函数,并存储从头实现的循环神经网络模型的参数。

/** 从头开始实现的RNN模型 */
public class RNNModelScratch {
    public int vocabSize;
    public int numHiddens;
    public NDList params;
    public TriFunction<Integer, Integer, Device, NDList> initState;
    public TriFunction<NDArray, NDList, NDList, Pair> forwardFn;

    public RNNModelScratch(
            int vocabSize,
            int numHiddens,
            Device device,
            TriFunction<Integer, Integer, Device, NDList> getParams,
            TriFunction<Integer, Integer, Device, NDList> initRNNState,
            TriFunction<NDArray, NDList, NDList, Pair> 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);
    }
}

让我们检查输出是否具有正确的形状,例如,以确保隐藏状态的维度保持不变。

int numHiddens = 512;
TriFunction<Integer, Integer, Device, NDList> getParamsFn = (a, b, c) -> getParams(a, b, c);
TriFunction<Integer, Integer, Device, NDList> initRNNStateFn =
        (a, b, c) -> initRNNState(a, b, c);
TriFunction<NDArray, NDList, NDList, Pair> 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<NDArray, NDList> 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());
(10, 29)
(2, 512)

我们可以看到输出形状是(时间步数 \(\times\) batch大小,词汇表大小),而隐藏状态形状保持不变,即(批大小,隐藏单元数)。

8.5.4. 预测

让我们首先定义预测函数来生成prefix之后的新字符, 其中的prefix是一个用户提供的包含多个字符的字符串。 在循环遍历prefix中的开始字符时, 我们不断地将隐状态传递到下一个时间步,但是不生成任何输出。 这被称为预热(warm-up)期, 因为在此期间模型会自我更新(例如,更新隐状态), 但不会进行预测。 预热期结束后,隐状态的值通常比刚开始的初始值更适合预测, 从而预测字符并输出它们。

/** 在 `prefix` 后面生成新字符。 */
public static String predictCh8(
        String prefix, int numPreds, RNNModelScratch net, Vocab vocab, Device device) {
    NDList state = net.beginState(1, device);
    List<Integer> outputs = new ArrayList<>();
    outputs.add(vocab.getIdx("" + prefix.charAt(0)));
    SimpleFunction<NDArray> 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<NDArray, NDList> 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个附加字符。 鉴于我们没有对网络进行培训, 它将产生荒谬的预测。

predictCh8("time traveller ", 10, net, vocab, manager.getDevice());
time traveller ks<unk>s<unk>s<unk>s<unk>s

8.5.5. 梯度裁剪

对于长度为 \(T\) 的序列, 我们在一次迭代中计算这些 \(T\) 时间步上的梯度,这导致在反向传播期间产生长度为 \(\mathcal{O}(T)\) 的矩阵乘积链。 如 Section 4.8, 中所述,它可能导致数值不稳定,例如,当 \(T\) 较大时,梯度可能会爆炸或消失。因此,RNN模型通常需要额外的帮助来稳定训练。

一般来说, 在解决优化问题时, 我们对模型参数采取更新步骤, 以向量形式说 \(\mathbf{x}\), 在小批量上的负梯度方向 \(\mathbf{g}\) 例如, 以 \(\eta > 0\) 作为学习率, 在一次迭代中,我们更新 \(\mathbf{x}\) 作为\(\mathbf{x} - \eta \mathbf{g}\). 让我们进一步假设目标函数 \(f\) 行为良好,例如, Lipschitz continuous ,常数为 \(L\). 就是说, 对于任何 \(\mathbf{x}\)\(\mathbf{y}\) 我们都有

(8.5.1)\[|f(\mathbf{x}) - f(\mathbf{y})| \leq L \|\mathbf{x} - \mathbf{y}\|.\]

在这种情况下,我们可以安全地假设,如果我们将参数向量更新为 \(\eta \mathbf{g}\), 那么

(8.5.2)\[|f(\mathbf{x}) - f(\mathbf{x} - \eta\mathbf{g})| \leq L \eta\|\mathbf{g}\|,\]

也就是说 我们观察到的变化不会超过 \(L \eta \|\mathbf{g}\|\)。这既是坏事也是好事。 在坏事方面, 它限制了进步的速度; 而在好事方面, 它限制了如果我们朝着错误的方向前进,事情可能会出错的程度。

有时梯度可能相当大,优化算法可能无法收敛。我们可以通过降低学习率 \(\eta\). 但是如果我们很少 得到大的梯度呢?在这种情况下,这种做法可能显得毫无根据。一种流行的替代方法是通过将梯度 \(\mathbf{g}\) 投影回给定半径的球,例如 \(\theta\) 来剪裁梯度 \(\mathbf{g}\)

(8.5.3)\[\mathbf{g} \leftarrow \min\left(1, \frac{\theta}{\|\mathbf{g}\|}\right) \mathbf{g}.\]

通过这样做,我们知道梯度范数永远不会超过 \(\theta\) ,并且 更新的梯度与 \(\mathbf{g}\) 的原始方向完全对齐。 它还具有限制任何给定影响的理想副作用 minibatch(以及其中的任何给定样本)可以应用于参数向量。这 赋予模型一定程度的鲁棒性。渐变剪裁提供 快速修复渐变爆炸。虽然它不能完全解决问题,但它是缓解问题的众多技术之一。

下面我们定义一个函数来剪裁 从头开始实现的模型或由高级API构建的模型。 还要注意,我们计算了所有模型参数的梯度范数。

/** 修剪梯度 */
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);
        }
    }
}

8.5.6. 训练

在训练模特之前, 让我们定义一个函数来在一个历元中训练模型。它与我们在三个地方训练: Section 3.6 模型的方式不同:

  1. 顺序数据的不同采样方法(随机采样和顺序分区)将导致隐藏状态初始化的差异。

  2. 在更新模型参数之前,我们剪裁梯度。这确保了模型不会发散,即使在训练过程中的某个点上坡度增大。

  3. 我们使用困惑度来评估模型。如 Section 8.4.4 中所述,这确保了不同长度的序列具有可比性。

明确地 当使用顺序分区时,我们仅在每个历元开始时初始化隐藏状态。 由于下一个minibatch中的 \(i^\mathrm{th}\) 子序列示例与当前的 \(i^\mathrm{th}\) 子序列示例相邻, 当前批处理结束时的隐藏状态 将 用于初始化 下一个迷你批处理开始时的隐藏状态。 这样, 序列的历史信息 以隐藏状态存储 可能溢出 一个epoch内相邻的子序列。 然而,隐藏状态的计算 任何时候都取决于以前的所有小批量 在同一epoch, 这使得梯度计算复杂化。 为了降低计算成本, 我们在处理任何小批量之前分离梯度 使隐态的梯度计算 始终限于 一个小批量中的时间步长。

在使用随机抽样时, 我们需要为每个迭代重新初始化隐藏状态,因为每个示例都是使用随机位置采样的。 与 Section 3.6 中的trainepoch3函数相同, updater 是一个通用函数 以更新模型参数。 它可以是从头开始实现的函数,也可以是中的内置优化函数 深度学习框架。

/** 在一个opoch内训练一个模型。 */
public static Pair<Double, Double> trainEpochCh8(
        RNNModelScratch net,
        List<NDList> trainIter,
        Loss loss,
        voidTwoFunction<Integer, NDManager> 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<NDArray, NDList> 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。

/** 训练一个模型 */
public static void trainCh8(
        RNNModelScratch net,
        List<NDList> trainIter,
        Vocab vocab,
        int lr,
        int numEpochs,
        Device device,
        boolean useRandomIter) {
    SoftmaxCrossEntropyLoss loss = new SoftmaxCrossEntropyLoss();
    Animator animator = new Animator();
    // 初始化
    voidTwoFunction<Integer, NDManager> updater =
            (batchSize, subManager) -> Training.sgd(net.params, lr, batchSize, subManager);
    Function<String, String> predict = (prefix) -> predictCh8(prefix, 50, net, vocab, device);
    // 训练和推理
    double ppl = 0.0;
    double speed = 0.0;
    for (int epoch = 0; epoch < numEpochs; epoch++) {
        Pair<Double, Double> 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个标记,所以模型需要更多的时间来更好地收敛

int numEpochs = Integer.getInteger("MAX_EPOCH", 500);

int lr = 1;
trainCh8(net, trainIter, vocab, lr, numEpochs, manager.getDevice(), false);
perplexity: 1.0, 42902.3 tokens/sec on gpu(0)
time traveller came back andfilby s anecdote collapsedthe thing
traveller broce tea ls thoug be than s abe asions if at un

最后 让我们检查一下使用随机抽样方法的结果。

trainCh8(net, trainIter, vocab, lr, numEpochs, manager.getDevice(), true);
perplexity: 1.1, 41602.9 tokens/sec on gpu(0)
time traveller fol so eat you dan  homi frealt ato hesperte m sm
traveller bet her wioke treo damsyon intw b tare arougn e o

从零开始实现上述循环神经网络模型, 虽然有指导意义,但是并不方便。 在下一节中,我们将学习如何改进循环神经网络模型。 例如,如何使其实现地更容易,且运行速度更快。

8.5.7. 总结

  • 我们可以训练一个基于循环神经网络的字符级语言模型,根据用户提供的文本的前缀生成后续文本。

  • 一个简单的循环神经网络语言模型包括输入编码、循环神经网络模型和输出生成。

  • 循环神经网络模型在训练以前需要初始化状态,不过随机抽样和顺序划分使用初始化方法不同。

  • 当使用顺序划分时,我们需要分离梯度以减少计算量。

  • 在进行任何预测之前,模型通过预热期进行自我更新(例如,获得比初始值更好的隐状态)。

  • 梯度裁剪可以防止梯度爆炸,但不能应对梯度消失。

8.5.8. 练习

  1. 显示一个热编码相当于为每个对象选择不同的嵌入。

  2. 调整超参数(例如,epoch数、隐藏单元数、小批量时间步数和学习速率)以改善困惑。

    • 你能降到多低?

    • 用可学习的嵌入替换一个热编码。这会导致更好的性能吗?

    • 它在H.G.威尔斯的其他书籍上的效果如何,例如 *世界大战*?

  3. 修改预测函数,例如使用采样,而不是拾取最可能的下一个字符。

    • 会发生什么?

    • 使模型偏向更可能的输出,例如,通过从 \(q(x_t \mid x_{t-1}, \ldots, x_1) \propto P(x_t \mid x_{t-1}, \ldots, x_1)^\alpha\) for \(\alpha > 1\)进行采样。

  4. 在不剪切梯度的情况下运行本节中的代码。会发生什么?

  5. 更改顺序分区,使其不会从计算图中分离隐藏状态。运行时间有变化吗?那么困惑呢?

  6. 用ReLU替换本节中使用的激活功能,并重复本节中的实验。我们还需要梯度剪裁吗?为什么?