Run this notebook online:Binder or Colab: Colab

9.6. 序列到序列学习(seq2seq)

正如我们在 Section 9.5中看到的, 机器翻译中的输入序列和输出序列都是长度可变的。 为了解决这类问题,我们在 sec_encoder-decoder中 设计了一个通用的”编码器-解码器“架构。 本节,我们将使用两个循环神经网络的编码器和解码器, 并将其应用于序列到序列(sequence to sequence,seq2seq)类的学习任务 [Sutskever.Vinyals.Le.2014][Cho.Van-Merrienboer.Gulcehre.ea.2014]

遵循编码器-解码器架构的设计原则, 循环神经网络编码器使用长度可变的序列作为输入, 将其转换为固定形状的隐状态。 换言之,输入序列的信息被编码到循环神经网络编码器的隐状态中。 为了连续生成输出序列的词元, 独立的循环神经网络解码器是基于输入序列的编码信息 和输出序列已经看见的或者生成的词元来预测下一个词元。 Section 9.6演示了 如何在机器翻译中使用两个循环神经网络进行序列到序列学习。

使用循环神经网络编码器和循环神经网络解码器的序列到序列学习

Section 9.6中, 特定的“<eos>”表示序列结束词元。 一旦输出序列生成此词元,模型就会停止预测。 在循环神经网络解码器的初始化时间步,有两个特定的设计决定: 首先,特定的“<bos>”表示序列开始词元,它是解码器的输入序列的第一个词元。 其次,使用循环神经网络编码器最终的隐状态来初始化解码器的隐状态。 例如,在 [Sutskever.Vinyals.Le.2014]的设计中, 正是基于这种设计将输入序列的编码信息送入到解码器中来生成输出序列的。 在其他一些设计中 [Cho.Van-Merrienboer.Gulcehre.ea.2014], 如 Section 9.6所示, 编码器最终的隐状态在每一个时间步都作为解码器的输入序列的一部分。 类似于 Section 8.3中语言模型的训练, 可以允许标签成为原始的输出序列, 从源序列词元“<bos>”、“Ils”、“regardent”、“.” 到新序列词元 “Ils”、“regardent”、“.”、“<eos>”来移动预测的位置。

下面,我们动手构建 Section 9.6的设计, 并将基于 Section 9.5中 介绍的“英-法”数据集来训练这个机器翻译模型。

%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/TrainingChapter9.java
%load ../utils/timemachine/Vocab.java
%load ../utils/timemachine/RNNModel.java
%load ../utils/timemachine/RNNModelScratch.java
%load ../utils/timemachine/TimeMachine.java
%load ../utils/timemachine/TimeMachineDataset.java
%load ../utils/NMT.java
%load ../utils/lstm/Encoder.java
%load ../utils/lstm/Decoder.java
%load ../utils/lstm/EncoderDecoder.java
import java.util.stream.*;
import ai.djl.modality.nlp.*;
import ai.djl.modality.nlp.embedding.*;
NDManager manager = NDManager.newBaseManager();
ParameterStore ps = new ParameterStore(manager, false);

9.6.1. 编码器

从技术上讲,编码器将长度可变的输入序列转换成 形状固定的上下文变量\(\mathbf{c}\), 并且将输入序列的信息在该上下文变量中进行编码。 如 Section 9.6所示,可以使用循环神经网络来设计编码器。

考虑由一个序列组成的样本(批量大小是\(1\))。 假设输入序列是\(x_1, \ldots, x_T\), 其中\(x_t\)是输入文本序列中的第\(t\)个词元。 在时间步\(t\),循环神经网络将词元\(x_t\)的输入特征向量 \(\mathbf{x}_t\)\(\mathbf{h} _{t-1}\)(即上一时间步的隐状态) 转换为\(\mathbf{h}_t\)(即当前步的隐状态)。 使用一个函数\(f\)来描述循环神经网络的循环层所做的变换:

(9.6.1)\[\mathbf{h}_t = f(\mathbf{x}_t, \mathbf{h}_{t-1}).\]

总之,编码器通过选定的函数\(q\), 将所有时间步的隐状态转换为上下文变量:

(9.6.2)\[\mathbf{c} = q(\mathbf{h}_1, \ldots, \mathbf{h}_T).\]

比如,当选择\(q(\mathbf{h}_1, \ldots, \mathbf{h}_T) = \mathbf{h}_T\)时 (就像 Section 9.6中一样), 上下文变量仅仅是输入序列在最后时间步的隐状态\(\mathbf{h}_T\)

到目前为止,我们使用的是一个单向循环神经网络来设计编码器, 其中隐状态只依赖于输入子序列, 这个子序列是由输入序列的开始位置到隐状态所在的时间步的位置 (包括隐状态所在的时间步)组成。 我们也可以使用双向循环神经网络构造编码器, 其中隐状态依赖于两个输入子序列, 两个子序列是由隐状态所在的时间步的位置之前的序列和之后的序列 (包括隐状态所在的时间步), 因此隐状态对整个序列的信息都进行了编码。

现在,让我们实现循环神经网络编码器。 注意,我们使用了嵌入层(embedding layer) 来获得输入序列中每个词元的特征向量。 嵌入层的权重是一个矩阵, 其行数等于输入词表的大小(vocab_size), 其列数等于特征向量的维度(embed_size)。 对于任意输入词元的索引\(i\), 嵌入层获取权重矩阵的第\(i\)行(从\(0\)开始)以返回其特征向量。 另外,本文选择了一个多层门控循环单元来实现编码器。

public static class Seq2SeqEncoder extends Encoder {

    private TrainableWordEmbedding embedding;
    private GRU rnn;

    // 用于序列到序列学习的循环神经网络编码器
    public Seq2SeqEncoder(
            int vocabSize, int embedSize, int numHiddens, int numLayers, float dropout) {
        List<String> list =
                IntStream.range(0, vocabSize)
                        .mapToObj(String::valueOf)
                        .collect(Collectors.toList());
        Vocabulary vocab = new DefaultVocabulary(list);
        // Embedding layer
        embedding =
                TrainableWordEmbedding.builder()
                        .optNumEmbeddings(vocabSize)
                        .setEmbeddingSize(embedSize)
                        .setVocabulary(vocab)
                        .build();
        addChildBlock("embedding", embedding);
        rnn =
                GRU.builder()
                        .setNumLayers(numLayers)
                        .setStateSize(numHiddens)
                        .optReturnState(true)
                        .optBatchFirst(false)
                        .optDropRate(dropout)
                        .build();
        addChildBlock("rnn", rnn);
    }

    /** {@inheritDoc} */
    @Override
    public void initializeChildBlocks(
            NDManager manager, DataType dataType, Shape... inputShapes) {
        embedding.initialize(manager, dataType, inputShapes[0]);
        Shape[] shapes = embedding.getOutputShapes(new Shape[] {inputShapes[0]});
        try (NDManager sub = manager.newSubManager()) {
            NDArray nd = sub.zeros(shapes[0], dataType);
            nd = nd.swapAxes(0, 1);
            rnn.initialize(manager, dataType, nd.getShape());
        }
    }

    @Override
    protected NDList forwardInternal(
            ParameterStore ps,
            NDList inputs,
            boolean training,
            PairList<String, Object> params) {
        NDArray X = inputs.head();
        // 输出'X'的形状: (batchSize, numSteps, embedSize)
        X = embedding.forward(ps, new NDList(X), training, params).head();
        // 在循环神经网络模型中,第一个轴对应于时间步
        X = X.swapAxes(0, 1);

        return rnn.forward(ps, new NDList(X), training);
    }
}

循环层返回变量的说明可以参考 Section 8.6

下面,我们实例化上述编码器的实现: 我们使用一个两层门控循环单元编码器,其隐藏单元数为\(16\)。 给定一小批量的输入序列X(批量大小为\(4\),时间步为\(7\))。 在完成所有时间步后, 最后一层的隐状态的输出是一个张量(output由编码器的循环层返回), 其形状为(时间步数,批量大小,隐藏单元数)。

Seq2SeqEncoder encoder = new Seq2SeqEncoder(10, 8, 16, 2, 0);
NDArray X = manager.zeros(new Shape(4, 7));
encoder.initialize(manager, DataType.FLOAT32, X.getShape());
NDList outputState = encoder.forward(ps, new NDList(X), false);
NDArray output = outputState.head();

output.getShape()
(7, 4, 16)

由于这里使用的是门控循环单元, 所以在最后一个时间步的多层隐状态的形状是 (隐藏层的数量,批量大小,隐藏单元的数量)。 如果使用长短期记忆网络,state中还将包含记忆单元信息。

NDList state = outputState.subNDList(1);
System.out.println(state.size());
System.out.println(state.head().getShape());
1
(2, 4, 16)

9.6.2. 解码器

正如上文提到的,编码器输出的上下文变量\(\mathbf{c}\) 对整个输入序列\(x_1, \ldots, x_T\)进行编码。 来自训练数据集的输出序列\(y_1, y_2, \ldots, y_{T'}\), 对于每个时间步\(t'\)(与输入序列或编码器的时间步\(t\)不同), 解码器输出\(y_{t'}\)的概率取决于先前的输出子序列 \(y_1, \ldots, y_{t'-1}\)和上下文变量\(\mathbf{c}\), 即\(P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \mathbf{c})\)

为了在序列上模型化这种条件概率, 我们可以使用另一个循环神经网络作为解码器。 在输出序列上的任意时间步\(t^\prime\), 循环神经网络将来自上一时间步的输出\(y_{t^\prime-1}\) 和上下文变量\(\mathbf{c}\)作为其输入, 然后在当前时间步将它们和上一隐状态 \(\mathbf{s}_{t^\prime-1}\)转换为 隐状态\(\mathbf{s}_{t^\prime}\)。 因此,可以使用函数\(g\)来表示解码器的隐藏层的变换:

(9.6.3)\[\mathbf{s}_{t^\prime} = g(y_{t^\prime-1}, \mathbf{c}, \mathbf{s}_{t^\prime-1}).\]

在获得解码器的隐状态之后, 我们可以使用输出层和softmax操作 来计算在时间步\(t^\prime\)时输出\(y_{t^\prime}\)的条件概率分布 \(P(y_{t^\prime} \mid y_1, \ldots, y_{t^\prime-1}, \mathbf{c})\)

根据 Section 9.6,当实现解码器时, 我们直接使用编码器最后一个时间步的隐状态来初始化解码器的隐状态。 这就要求使用循环神经网络实现的编码器和解码器具有相同数量的层和隐藏单元。 为了进一步包含经过编码的输入序列的信息, 上下文变量在所有的时间步与解码器的输入进行拼接(concatenate)。 为了预测输出词元的概率分布, 在循环神经网络解码器的最后一层使用全连接层来变换隐状态。

public static class Seq2SeqDecoder extends Decoder {

    private TrainableWordEmbedding embedding;
    private GRU rnn;
    private Linear dense;

    /* The RNN decoder for sequence to sequence learning. */
    public Seq2SeqDecoder(
            int vocabSize, int embedSize, int numHiddens, int numLayers, float dropout) {
        List<String> list =
                IntStream.range(0, vocabSize)
                        .mapToObj(String::valueOf)
                        .collect(Collectors.toList());
        Vocabulary vocab = new DefaultVocabulary(list);
        embedding =
                TrainableWordEmbedding.builder()
                        .optNumEmbeddings(vocabSize)
                        .setEmbeddingSize(embedSize)
                        .setVocabulary(vocab)
                        .build();
        addChildBlock("embedding", embedding);
        rnn =
                GRU.builder()
                        .setNumLayers(numLayers)
                        .setStateSize(numHiddens)
                        .optReturnState(true)
                        .optBatchFirst(false)
                        .optDropRate(dropout)
                        .build();
        addChildBlock("rnn", rnn);
        dense = Linear.builder().setUnits(vocabSize).build();
        addChildBlock("dense", dense);
    }

    /** {@inheritDoc} */
    @Override
    public void initializeChildBlocks(
            NDManager manager, DataType dataType, Shape... inputShapes) {
        embedding.initialize(manager, dataType, inputShapes[0]);
        try (NDManager sub = manager.newSubManager()) {
            Shape shape = embedding.getOutputShapes(new Shape[] {inputShapes[0]})[0];
            NDArray nd = sub.zeros(shape, dataType).swapAxes(0, 1);
            NDArray state = sub.zeros(inputShapes[1], dataType);
            NDArray context = state.get(new NDIndex(-1));
            context =
                    context.broadcast(
                            new Shape(
                                    nd.getShape().head(),
                                    context.getShape().head(),
                                    context.getShape().get(1)));
            // Broadcast `context` so it has the same `numSteps` as `X`
            NDArray xAndContext = NDArrays.concat(new NDList(nd, context), 2);
            rnn.initialize(manager, dataType, xAndContext.getShape());
            shape = rnn.getOutputShapes(new Shape[] {xAndContext.getShape()})[0];
            dense.initialize(manager, dataType, shape);
        }
    }

    public NDList initState(NDList encOutputs) {
        return new NDList(encOutputs.get(1));
    }

    @Override
    protected NDList forwardInternal(
            ParameterStore parameterStore,
            NDList inputs,
            boolean training,
            PairList<String, Object> params) {
        NDArray X = inputs.head();
        NDArray state = inputs.get(1);
        X =
                embedding
                        .forward(parameterStore, new NDList(X), training, params)
                        .head()
                        .swapAxes(0, 1);
        NDArray context = state.get(new NDIndex(-1));
        // Broadcast `context` so it has the same `numSteps` as `X`
        context =
                context.broadcast(
                        new Shape(
                                X.getShape().head(),
                                context.getShape().head(),
                                context.getShape().get(1)));
        NDArray xAndContext = NDArrays.concat(new NDList(X, context), 2);
        NDList rnnOutput =
                rnn.forward(parameterStore, new NDList(xAndContext, state), training);
        NDArray output = rnnOutput.head();
        state = rnnOutput.get(1);
        output =
                dense.forward(parameterStore, new NDList(output), training)
                        .head()
                        .swapAxes(0, 1);
        return new NDList(output, state);
    }
}

下面,我们用与前面提到的编码器中相同的超参数来实例化解码器。 如我们所见,解码器的输出形状变为(批量大小,时间步数,词表大小), 其中张量的最后一个维度存储预测的词元分布。

Seq2SeqDecoder decoder = new Seq2SeqDecoder(10, 8, 16, 2, 0);
state = decoder.initState(outputState);
NDList input = new NDList(X).addAll(state);
decoder.initialize(manager, DataType.FLOAT32, input.getShapes());
outputState = decoder.forward(ps, input, false);

output = outputState.head();
System.out.println(output.getShape());

state = outputState.subNDList(1);
System.out.println(state.size());
System.out.println(state.head().getShape());
(4, 7, 10)
1
(2, 4, 16)

总之,上述循环神经网络“编码器-解码器”模型中的各层如 fig_seq2seq_details所示。

循环神经网络编码器-解码器模型中的层 .. _fig_seq2seq_details:

9.6.3. 损失函数

在每个时间步,解码器预测了输出词元的概率分布。 类似于语言模型,可以使用softmax来获得分布, 并通过计算交叉熵损失函数来进行优化。 回想一下 Section 9.5中, 特定的填充词元被添加到序列的末尾, 因此不同长度的序列可以以相同形状的小批量加载。 但是,我们应该将填充词元的预测排除在损失函数的计算之外。

为此,我们可以使用下面的sequenceMask()函数 通过零值化屏蔽不相关的项, 以便后面任何不相关预测的计算都是与零的乘积,结果都等于零。 例如,如果两个序列的有效长度(不包括填充词元)分别为\(1\)\(2\), 则第一个序列的第一项和第二个序列的前两项之后的剩余项将被清除为零。

X = manager.create(new int[][] {{1, 2, 3}, {4, 5, 6}});
System.out.println(X.sequenceMask(manager.create(new int[] {1, 2})));
ND: (2, 3) gpu(0) int32
[[ 1,  0,  0],
 [ 4,  5,  0],
]

我们还可以使用此函数屏蔽最后几个轴上的所有项。如果愿意,也可以使用指定的非零值来替换这些项。

X = manager.ones(new Shape(2, 3, 4));
System.out.println(X.sequenceMask(manager.create(new int[] {1, 2}), -1));
ND: (2, 3, 4) gpu(0) float32
[[[ 1.,  1.,  1.,  1.],
  [-1., -1., -1., -1.],
  [-1., -1., -1., -1.],
 ],
 [[ 1.,  1.,  1.,  1.],
  [ 1.,  1.,  1.,  1.],
  [-1., -1., -1., -1.],
 ],
]

现在,我们可以通过扩展softmax交叉熵损失函数来遮蔽不相关的预测。 最初,所有预测词元的掩码都设置为1。 一旦给定了有效长度,与填充词元对应的掩码将被设置为0。 最后,将所有词元的损失乘以掩码,以过滤掉损失中填充词元产生的不相关预测。

public static class MaskedSoftmaxCELoss extends SoftmaxCrossEntropyLoss {
    /* The softmax cross-entropy loss with masks. */

    @Override
    public NDArray evaluate(NDList labels, NDList predictions) {
        NDArray weights = labels.head().onesLike().expandDims(-1).sequenceMask(labels.get(1));
        // Remove the states from the labels NDList because otherwise, it will throw an error as SoftmaxCrossEntropyLoss
        // expects only one NDArray for label and one NDArray for prediction
        labels.remove(1);
        return super.evaluate(labels, predictions).mul(weights).mean(new int[] {1});
    }
}

我们可以创建三个相同的序列来进行代码健全性检查, 然后分别指定这些序列的有效长度为\(4\)\(2\)\(0\)。 结果就是,第一个序列的损失应为第二个序列的两倍,而第三个序列的损失应为零。

Loss loss = new MaskedSoftmaxCELoss();
NDList labels = new NDList(manager.ones(new Shape(3, 4)));
labels.add(manager.create(new int[] {4, 2, 0}));
NDList predictions = new NDList(manager.ones(new Shape(3, 4, 10)));
System.out.println(loss.evaluate(labels, predictions));
ND: (3, 1) gpu(0) float32
[[2.3026],
 [1.1513],
 [0.    ],
]

9.6.4. 训练

在下面的循环训练过程中,如 Section 9.6所示, 特定的序列开始词元(“<bos>”)和 原始的输出序列(不包括序列结束词元“<eos>”) 拼接在一起作为解码器的输入。 这被称为强制教学(teacher forcing), 因为原始的输出序列(词元的标签)被送入解码器。 或者,将来自上一个时间步的预测得到的词元作为解码器的当前输入。

public static void trainSeq2Seq(
            EncoderDecoder net,
            ArrayDataset dataset,
            float lr,
            int numEpochs,
            Vocab tgtVocab,
            Device device)
            throws IOException, TranslateException {
    Loss loss = new MaskedSoftmaxCELoss();
    Tracker lrt = Tracker.fixed(lr);
    Optimizer adam = Optimizer.adam().optLearningRateTracker(lrt).build();

    DefaultTrainingConfig config =
            new DefaultTrainingConfig(loss)
                    .optOptimizer(adam) // Optimizer (loss function)
                    .optInitializer(new XavierInitializer(), "");

    Model model = Model.newInstance("");
    model.setBlock(net);
    Trainer trainer = model.newTrainer(config);

    Animator animator = new Animator();
    StopWatch watch;
    Accumulator metric;
    double lossValue = 0, speed = 0;
    for (int epoch = 1; epoch <= numEpochs; epoch++) {
        watch = new StopWatch();
        metric = new Accumulator(2); // Sum of training loss, no. of tokens
        try (NDManager childManager = manager.newSubManager(device)) {
            // Iterate over dataset
            for (Batch batch : dataset.getData(childManager)) {
                NDArray X = batch.getData().get(0);
                NDArray lenX = batch.getData().get(1);
                NDArray Y = batch.getLabels().get(0);
                NDArray lenY = batch.getLabels().get(1);

                NDArray bos =
                        childManager
                                .full(new Shape(Y.getShape().get(0)), tgtVocab.getIdx("<bos>"))
                                .reshape(-1, 1);
                NDArray decInput =
                        NDArrays.concat(
                                new NDList(bos, Y.get(new NDIndex(":, :-1"))),
                                1); // Teacher forcing
                try (GradientCollector gc = Engine.getInstance().newGradientCollector()) {
                    NDArray yHat =
                            net.forward(
                                            new ParameterStore(manager, false),
                                            new NDList(X, decInput, lenX),
                                            true)
                                    .get(0);
                    NDArray l = loss.evaluate(new NDList(Y, lenY), new NDList(yHat));
                    gc.backward(l);
                    metric.add(new float[] {l.sum().getFloat(), lenY.sum().getLong()});
                }
                TrainingChapter9.gradClipping(net, 1, childManager);
                // Update parameters
                trainer.step();
            }
        }
        lossValue = metric.get(0) / metric.get(1);
        speed = metric.get(1) / watch.stop();
        if ((epoch + 1) % 10 == 0) {
            animator.add(epoch + 1, (float) lossValue, "loss");
            animator.show();
        }
    }
    System.out.format(
            "loss: %.3f, %.1f tokens/sec on %s%n", lossValue, speed, device.toString());
}

现在,在机器翻译数据集上,我们可以 创建和训练一个循环神经网络“编码器-解码器”模型用于序列到序列的学习。

int embedSize = 32;
int numHiddens = 32;
int numLayers = 2;
int batchSize = 64;
int numSteps = 10;
int numEpochs = Integer.getInteger("MAX_EPOCH", 300);

float dropout = 0.1f, lr = 0.005f;
Device device = manager.getDevice();

Pair<ArrayDataset, Pair<Vocab, Vocab>> dataNMT =
        NMT.loadDataNMT(batchSize, numSteps, 600, manager);
ArrayDataset dataset = dataNMT.getKey();
Vocab srcVocab = dataNMT.getValue().getKey();
Vocab tgtVocab = dataNMT.getValue().getValue();

encoder = new Seq2SeqEncoder(srcVocab.length(), embedSize, numHiddens, numLayers, dropout);
decoder = new Seq2SeqDecoder(tgtVocab.length(), embedSize, numHiddens, numLayers, dropout);

EncoderDecoder net = new EncoderDecoder(encoder, decoder);
trainSeq2Seq(net, dataset, lr, numEpochs, tgtVocab, device);
loss: 0.008, 5406.0 tokens/sec on gpu(0)

9.6.5. 预测

为了采用一个接着一个词元的方式预测输出序列, 每个解码器当前时间步的输入都将来自于前一时间步的预测词元。 与训练类似,序列开始词元(“<bos>”) 在初始时间步被输入到解码器中。 该预测过程如 Section 9.6.5所示, 当输出序列的预测遇到序列结束词元(“<eos>”)时,预测就结束了。

使用循环神经网络编码器-解码器逐词元地预测输出序列。

我们将在 Section 9.7中介绍不同的序列生成策略。

/* 序列到序列模型的预测 */
public static Pair<String, ArrayList<NDArray>> predictSeq2Seq(
        EncoderDecoder net,
        String srcSentence,
        Vocab srcVocab,
        Vocab tgtVocab,
        int numSteps,
        Device device,
        boolean saveAttentionWeights)
        throws IOException, TranslateException {
    Integer[] srcTokens =
            Stream.concat(
                            Arrays.stream(
                                    srcVocab.getIdxs(srcSentence.toLowerCase().split(" "))),
                            Arrays.stream(new Integer[] {srcVocab.getIdx("<eos>")}))
                    .toArray(Integer[]::new);
    NDArray encValidLen = manager.create(srcTokens.length);
    int[] truncateSrcTokens = NMT.truncatePad(srcTokens, numSteps, srcVocab.getIdx("<pad>"));
    // 添加批量轴
    NDArray encX = manager.create(truncateSrcTokens).expandDims(0);
    NDList encOutputs =
            net.encoder.forward(
                    new ParameterStore(manager, false), new NDList(encX, encValidLen), false);
    NDList decState = net.decoder.initState(encOutputs.addAll(new NDList(encValidLen)));
    // 添加批量轴
    NDArray decX = manager.create(new float[] {tgtVocab.getIdx("<bos>")}).expandDims(0);
    ArrayList<Integer> outputSeq = new ArrayList<>();
    ArrayList<NDArray> attentionWeightSeq = new ArrayList<>();
    for (int i = 0; i < numSteps; i++) {
        NDList output =
                net.decoder.forward(
                        new ParameterStore(manager, false),
                        new NDList(decX).addAll(decState),
                        false);
        NDArray Y = output.get(0);
        decState = output.subNDList(1);
        // 我们使用具有预测最高可能性的词元,作为解码器在下一时间步的输入
        decX = Y.argMax(2);
        int pred = (int) decX.squeeze(0).getLong();
        // 保存注意力权重(稍后讨论)
        if (saveAttentionWeights) {
            attentionWeightSeq.add(net.decoder.attentionWeights);
        }
        // 一旦序列结束词元被预测,输出序列的生成就完成了
        if (pred == tgtVocab.getIdx("<eos>")) {
            break;
        }
        outputSeq.add(pred);
    }
    String outputString =
            String.join(" ", tgtVocab.toTokens(outputSeq).toArray(new String[] {}));
    return new Pair<>(outputString, attentionWeightSeq);
}

9.6.6. 预测序列的评估

我们可以通过与真实的标签序列进行比较来评估预测序列。 虽然 [Papineni.Roukos.Ward.ea.2002] 提出的BLEU(bilingual evaluation understudy) 最先是用于评估机器翻译的结果, 但现在它已经被广泛用于测量许多应用的输出序列的质量。 原则上说,对于预测序列中的任意\(n\)元语法(n-grams), BLEU的评估都是这个\(n\)元语法是否出现在标签序列中。

我们将BLEU定义为:

(9.6.4)\[\exp\left(\min\left(0, 1 - \frac{\mathrm{len}_{\text{label}}}{\mathrm{len}_{\text{pred}}}\right)\right) \prod_{n=1}^k p_n^{1/2^n},\]

其中\(\mathrm{len}_{\text{label}}\)表示标签序列中的词元数和 \(\mathrm{len}_{\text{pred}}\)表示预测序列中的词元数, \(k\)是用于匹配的最长的\(n\)元语法。 另外,用\(p_n\)表示\(n\)元语法的精确度,它是两个数量的比值: 第一个是预测序列与标签序列中匹配的\(n\)元语法的数量, 第二个是预测序列中\(n\)元语法的数量的比率。 具体地说,给定标签序列\(A\)\(B\)\(C\)\(D\)\(E\)\(F\) 和预测序列\(A\)\(B\)\(B\)\(C\)\(D\), 我们有\(p_1 = 4/5\)\(p_2 = 3/4\)\(p_3 = 1/3\)\(p_4 = 0\)

根据 (9.6.4)中BLEU的定义, 当预测序列与标签序列完全相同时,BLEU为\(1\)。 此外,由于\(n\)元语法越长则匹配难度越大, 所以BLEU为更长的\(n\)元语法的精确度分配更大的权重。 具体来说,当\(p_n\)固定时,\(p_n^{1/2^n}\) 会随着\(n\)的增长而增加(原始论文使用\(p_n^{1/n}\))。 而且,由于预测的序列越短获得的\(p_n\)值越高, 所以 (9.6.4)中乘法项之前的系数用于惩罚较短的预测序列。 例如,当\(k=2\)时,给定标签序列\(A\)\(B\)\(C\)\(D\)\(E\)\(F\) 和预测序列\(A\)\(B\),尽管\(p_1 = p_2 = 1\), 惩罚因子\(\exp(1-6/2) \approx 0.14\)会降低BLEU。

BLEU的代码实现如下。

/* 计算 BLEU. */
public static double bleu(String predSeq, String labelSeq, int k) {
    String[] predTokens = predSeq.split(" ");
    String[] labelTokens = labelSeq.split(" ");
    int lenPred = predTokens.length;
    int lenLabel = labelTokens.length;
    double score = Math.exp(Math.min(0, 1 - lenLabel / lenPred));
    for (int n = 1; n < k + 1; n++) {
        float numMatches = 0f;
        HashMap<String, Integer> labelSubs = new HashMap<>();
        for (int i = 0; i < lenLabel - n + 1; i++) {
            String key =
                    String.join(" ", Arrays.copyOfRange(labelTokens, i, i + n, String[].class));
            labelSubs.put(key, labelSubs.getOrDefault(key, 0) + 1);
        }
        for (int i = 0; i < lenPred - n + 1; i++) {
            String key =
                    String.join(" ", Arrays.copyOfRange(predTokens, i, i + n, String[].class));
            if (labelSubs.getOrDefault(key, 0) > 0) {
                numMatches += 1;
                labelSubs.put(key, labelSubs.getOrDefault(key, 0) - 1);
            }
        }
        score *= Math.pow(numMatches / (lenPred - n + 1), Math.pow(0.5, n));
    }
    return score;
}

最后,利用训练好的循环神经网络“编码器-解码器”模型, 将几个英语句子翻译成法语,并计算BLEU的最终结果。

String[] engs = {"go .", "i lost .", "he\'s calm .", "i\'m home ."};
String[] fras = {"va !", "j\'ai perdu .", "il est calme .", "je suis chez moi ."};
for (int i = 0; i < engs.length; i++) {
    Pair<String, ArrayList<NDArray>> pair = predictSeq2Seq(net, engs[i], srcVocab, tgtVocab, numSteps, device, false);
    String translation = pair.getKey();
    ArrayList<NDArray> attentionWeightSeq = pair.getValue();
    System.out.format("%s => %s, bleu %.3f\n", engs[i], translation, bleu(translation, fras[i], 2));
}
go . => va <unk> ., bleu 0.000
i lost . => j'ai perdu ., bleu 1.000
he's calm . => il est venu ., bleu 0.658
i'm home . => je suis chez moi ., bleu 1.000

9.6.7. 小结

  • 根据“编码器-解码器”架构的设计, 我们可以使用两个循环神经网络来设计一个序列到序列学习的模型。

  • 在实现编码器和解码器时,我们可以使用多层循环神经网络。

  • 我们可以使用遮蔽来过滤不相关的计算,例如在计算损失时。

  • 在“编码器-解码器”训练中,强制教学方法将原始输出序列(而非预测结果)输入解码器。

  • BLEU是一种常用的评估方法,它通过测量预测序列和标签序列之间的\(n\)元语法的匹配度来评估预测。

9.6.8. 练习

  1. 你能通过调整超参数来改善翻译效果吗?

  2. 重新运行实验并在计算损失时不使用遮蔽。你观察到什么结果?为什么?

  3. 如果编码器和解码器的层数或者隐藏单元数不同,那么如何初始化解码器的隐状态?

  4. 在训练中,如果用前一时间步的预测输入到解码器来代替强制教学,对性能有何影响?

  5. 用长短期记忆网络替换门控循环单元重新运行实验。

  6. 有没有其他方法来设计解码器的输出层?