Run this notebook online:Binder or Colab: Colab

8.3. 语言模型和数据集

Section 8.2中, 我们了解了如何将文本数据映射为词元, 以及将这些词元可以视为一系列离散的观测,例如单词或字符。 假设长度为\(T\)的文本序列中的词元依次为\(x_1, x_2, \ldots, x_T\)。 于是,\(x_t\)\(1 \leq t \leq T\)) 可以被认为是文本序列在时间步\(t\)处的观测或标签。 在给定这样的文本序列时,语言模型(language model)的目标是估计序列的联合概率

(8.3.1)\[P(x_1, x_2, \ldots, x_T).\]

例如,只需要一次抽取一个词元\(x_t \sim P(x_t \mid x_{t-1}, \ldots, x_1)\), 一个理想的语言模型就能够基于模型本身生成自然文本。 与猴子使用打字机完全不同的是,从这样的模型中提取的文本 都将作为自然语言(例如,英语文本)来传递。 只需要基于前面的对话片断中的文本, 就足以生成一个有意义的对话。 显然,我们离设计出这样的系统还很遥远, 因为它需要“理解”文本,而不仅仅是生成语法合理的内容。

尽管如此,语言模型依然是非常有用的。 例如,短语“to recognize speech”和“to wreck a nice beach”读音上听起来非常相似。 这种相似性会导致语音识别中的歧义,但是这很容易通过语言模型来解决, 因为第二句的语义很奇怪。 同样,在文档摘要生成算法中, “狗咬人”比“人咬狗”出现的频率要高得多, 或者“我想吃奶奶”是一个相当匪夷所思的语句, 而“我想吃,奶奶”则要正常得多。

8.3.1. 学习语言模型

显而易见,我们面对的问题是如何对一个文档, 甚至是一个词元序列进行建模。 假设在单词级别对文本数据进行词元化, 我们可以依靠在 Section 8.1中对序列模型的分析。 让我们从基本概率规则开始:

(8.3.2)\[P(x_1, x_2, \ldots, x_T) = \prod_{t=1}^T P(x_t \mid x_1, \ldots, x_{t-1}).\]

例如,包含了四个单词的一个文本序列的概率是:

(8.3.3)\[P(\text{deep}, \text{learning}, \text{is}, \text{fun}) = P(\text{deep}) P(\text{learning} \mid \text{deep}) P(\text{is} \mid \text{deep}, \text{learning}) P(\text{fun} \mid \text{deep}, \text{learning}, \text{is}).\]

为了训练语言模型,我们需要计算单词的概率, 以及给定前面几个单词后出现某个单词的条件概率。 这些概率本质上就是语言模型的参数。

这里,我们假设训练数据集是一个大型的文本语料库。 比如,维基百科的所有条目、 古登堡计划, 或者所有发布在网络上的文本。 训练数据集中词的概率可以根据给定词的相对词频来计算。 例如,可以将估计值\(\hat{P}(\text{deep})\) 计算为任何以单词“deep”开头的句子的概率。 一种(稍稍不太精确的)方法是统计单词“deep”在数据集中的出现次数, 然后将其除以整个语料库中的单词总数。 这种方法效果不错,特别是对于频繁出现的单词。 接下来,我们可以尝试估计

(8.3.4)\[\hat{P}(\text{learning} \mid \text{deep}) = \frac{n(\text{deep, learning})}{n(\text{deep})},\]

其中\(n(x)\)\(n(x, x')\)分别是单个单词和连续单词对的出现次数。 不幸的是,由于连续单词对“deep learning”的出现频率要低得多, 所以估计这类单词正确的概率要困难得多。 特别是对于一些不常见的单词组合,要想找到足够的出现次数来获得准确的估计可能都不容易。 而对于三个或者更多的单词组合,情况会变得更糟。 许多合理的三个单词组合可能是存在的,但是在数据集中却找不到。 除非我们提供某种解决方案,来将这些单词组合指定为非零计数, 否则将无法在语言模型中使用它们。 如果数据集很小,或者单词非常罕见,那么这类单词出现一次的机会可能都找不到。

一种常见的策略是执行某种形式的拉普拉斯平滑(Laplace smoothing), 具体方法是在所有计数中添加一个小常量。 用\(n\)表示训练集中的单词总数,用\(m\)表示唯一单词的数量。 此解决方案有助于处理单元素问题,例如通过:

(8.3.5)\[\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\)。 上面的公式是 [Wood et al., 2011] 的一个相当原始的变形。

然而,这样的模型很容易变得无效,原因如下: 首先,我们需要存储所有的计数; 其次,这完全忽略了单词的意思。 例如,“猫”(cat)和“猫科动物”(feline)可能出现在相关的上下文中, 但是想根据上下文调整这类模型其实是相当困难的。 最后,长单词序列大部分是没出现过的, 因此一个模型如果只是简单地统计先前“看到”的单词序列频率, 那么模型面对这种问题肯定是表现不佳的。

8.3.2. 马尔可夫模型与\(n\)元语法

在讨论包含深度学习的解决方案之前,我们需要了解更多的概念和术语。 回想一下我们在 Section 8.1中对马尔可夫模型的讨论, 并且将其应用于语言建模。 如果\(P(x_{t+1} \mid x_t, \ldots, x_1) = P(x_{t+1} \mid x_t)\), 则序列上的分布满足一阶马尔可夫性质。 阶数越高,对应的依赖关系就越长。 这种性质推导出了许多可以应用于序列建模的近似公式:

(8.3.6)\[\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}\]

通常,涉及一个、两个和三个变量的概率公式分别被称为 “一元语法”(unigram)、“二元语法”(bigram)和“三元语法”(trigram)模型。 下面,我们将学习如何去设计更好的模型。

8.3.3. 自然语言统计

我们看看在真实数据上如果进行自然语言统计。 根据 Section 8.2中介绍的时光机器数据集构建词表, 并打印前\(10\)个最常用的(频率最高的)单词。

%load ../utils/djl-imports
%load ../utils/plot-utils
%load ../utils/PlotUtils.java

%load ../utils/Accumulator.java
%load ../utils/Animator.java
%load ../utils/Functions.java
%load ../utils/StopWatch.java
%load ../utils/Training.java
%load ../utils/timemachine/Vocab.java
%load ../utils/timemachine/RNNModelScratch.java
%load ../utils/timemachine/TimeMachine.java
NDManager manager = NDManager.newBaseManager();
String[][] tokens = TimeMachine.tokenize(TimeMachine.readTimeMachine(), "word");
// 由于每一行文字不一定是一个句子或段落,因此我们把所有文本行拼接到一起
List<String> corpus = new ArrayList<>();
for (int i = 0; i < tokens.length; i++) {
    for (int j = 0; j < tokens[i].length; j++) {
        if (tokens[i][j] != "") {
            corpus.add(tokens[i][j]);
        }
    }
}

Vocab vocab = new Vocab(new String[][] {corpus.toArray(new String[0])}, -1, new String[0]);
for (int i = 0; i < 10; i++) {
    Map.Entry<String, Integer> token = vocab.tokenFreqs.get(i);
    System.out.println(token.getKey() + ": " + token.getValue());
}
the: 2261
i: 1267
and: 1245
of: 1155
a: 816
to: 695
was: 552
in: 541
that: 443
my: 440

正如我们所看到的,最常用的词看起来很并没有意义, 这些词通常被称为停用词(stop words),因此可以被过滤掉。 尽管如此,它们本身仍然是有意义的,我们仍然会在模型中使用它们。 此外,还有个明显的问题是词频衰减的速度相当地快。 例如,最常用单词的词频对比,第\(10\)个还不到第\(1\)个的\(1/5\)。 为了更好地理解,我们可以画出的词频图:

int n = vocab.tokenFreqs.size();
double[] freqs = new double[n];
double[] x = new double[n];
for (int i = 0; i < n; i++) {
    freqs[i] = (double) vocab.tokenFreqs.get(i).getValue();
    x[i] = (double) i;
}

PlotUtils.plotLogScale(new double[][] {x}, new double[][] {freqs}, new String[] {""},
                       "token: x", "frequency: n(x)");

通过此图我们可以发现:词频以一种明确的方式迅速衰减。 将前几个单词作为例外消除后,剩余的所有单词大致遵循双对数坐标图上的一条直线。 这意味着单词的频率满足齐普夫定律(Zipf’s law), 即第\(i\)个最常用单词的频率\(n_i\)为:

(8.3.7)\[n_i \propto \frac{1}{i^\alpha},\]

等价于

(8.3.8)\[\log n_i = -\alpha \log i + c,\]

其中\(\alpha\)是刻画分布的指数,\(c\)是常数。 这告诉我们想要通过计数统计和平滑来建模单词是不可行的, 因为这样建模的结果会大大高估尾部单词的频率,也就是所谓的不常用单词。 那么其他的词元组合,比如二元语法、三元语法等等,又会如何呢? 我们来看看二元语法的频率是否与一元语法的频率表现出相同的行为方式。

String[] bigramTokens = new String[corpus.size()-1];
for (int i = 0; i < bigramTokens.length; i++) {
    bigramTokens[i] = corpus.get(i) + " " + corpus.get(i+1);
}
Vocab bigramVocab = new Vocab(new String[][] {bigramTokens}, -1, new String[0]);
for (int i = 0; i < 10; i++) {
    Map.Entry<String, Integer> token = bigramVocab.tokenFreqs.get(i);
    System.out.println(token.getKey() + ": " + token.getValue());
}
of the: 309
in the: 169
i had: 130
i was: 112
and the: 109
the time: 102
it was: 99
to the: 85
as i: 78
of a: 73

这里值得注意:在十个最频繁的词对中,有九个是由两个停用词组成的, 只有一个与“the time”有关。 我们再进一步看看三元语法的频率是否表现出相同的行为方式。

String[] trigramTokens = new String[corpus.size()-2];
for (int i = 0; i < trigramTokens.length; i++) {
    trigramTokens[i] = corpus.get(i) + " " + corpus.get(i+1) + " " + corpus.get(i+2);
}
Vocab trigramVocab = new Vocab(new String[][] {trigramTokens}, -1, new String[0]);
for (int i = 0; i < 10; i++) {
    Map.Entry<String, Integer> token = trigramVocab.tokenFreqs.get(i);
    System.out.println(token.getKey() + ": " + token.getValue());
}
the time traveller: 59
the time machine: 30
  : 26
the medical man: 24
it seemed to: 16
it was a: 15
here and there: 15
seemed to me: 14
i did not: 14
i saw the: 13

最后,我们直观地对比三种模型中的词元频率:一元语法、二元语法和三元语法。

n = bigramVocab.tokenFreqs.size();
double[] bigramFreqs = new double[n];
double[] bigramX = new double[n];
for (int i = 0; i < n; i++) {
    bigramFreqs[i] = (double) bigramVocab.tokenFreqs.get(i).getValue();
    bigramX[i] = (double) i;
}

n = trigramVocab.tokenFreqs.size();
double[] trigramFreqs = new double[n];
double[] trigramX = new double[n];
for (int i = 0; i < n; i++) {
    trigramFreqs[i] = (double) trigramVocab.tokenFreqs.get(i).getValue();
    trigramX[i] = (double) i;
}

PlotUtils.plotLogScale(new double[][] {x, bigramX, trigramX}, new double[][] {freqs, bigramFreqs, trigramFreqs},
                       new String[] {"unigram", "bigram", "trigram"}, "token: x", "frequency: n(x)");

这张图非常令人振奋!原因有很多: 首先,除了一元语法词,单词序列似乎也遵循齐普夫定律, 尽管公式 (8.3.7)中的指数\(\alpha\)更小 (指数的大小受序列长度的影响)。 其次,词表中\(n\)元组的数量并没有那么大,这说明语言中存在相当多的结构, 这些结构给了我们应用模型的希望。 第三,很多\(n\)元组很少出现,这使得拉普拉斯平滑非常不适合语言建模。 作为代替,我们将使用基于深度学习的模型。

8.3.4. 读取长序列数据

由于序列数据本质上是连续的,因此我们在处理数据时需要解决这个问题。 在 Section 8.1中我们以一种相当特别的方式做到了这一点: 当序列变得太长而不能被模型一次性全部处理时, 我们可能希望拆分这样的序列方便模型读取。

在介绍该模型之前,我们看一下总体策略。 假设我们将使用神经网络来训练语言模型, 模型中的网络一次处理具有预定义长度 (例如\(n\)个时间步)的一个小批量序列。 现在的问题是如何随机生成一个小批量数据的特征和标签以供读取。

首先,由于文本序列可以是任意长的, 例如整本《时光机器》(The Time Machine), 于是任意长的序列可以被我们划分为具有相同时间步数的子序列。 当训练我们的神经网络时,这样的小批量子序列将被输入到模型中。 假设网络一次只处理具有\(n\)个时间步的子序列。 Section 8.3.4画出了 从原始文本序列获得子序列的所有不同的方式, 其中\(n=5\),并且每个时间步的词元对应于一个字符。 请注意,因为我们可以选择任意偏移量来指示初始位置,所以我们有相当大的自由度。

分割文本时,不同的偏移量会导致不同的子序列

因此,我们应该从 Section 8.3.4中选择哪一个呢? 事实上,他们都一样的好。 然而,如果我们只选择一个偏移量, 那么用于训练网络的、所有可能的子序列的覆盖范围将是有限的。 因此,我们可以从随机偏移量开始划分序列, 以同时获得覆盖性(coverage)和随机性(randomness)。 下面,我们将描述如何实现随机采样(random sampling)和 顺序分区(sequential partitioning)策略。

8.3.4.1. 随机采样

在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列。 在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻。 对于语言建模,目标是基于到目前为止我们看到的词元来预测下一个词元, 因此标签是移位了一个词元的原始序列。

下面的代码每次可以从数据中随机生成一个小批量。 在这里,参数batch_size指定了每个小批量中子序列样本的数目, 参数num_steps是每个子序列中预定义的时间步数。

/**
 * 使用随机抽样生成一小批 子序列。
 */
public ArrayList<NDList>
        seqDataIterRandom(List<Integer> corpus, int batchSize, int numSteps, NDManager manager) {
    // 从一个随机偏移量(包括'numSteps-1')开始到分区a
    // 序列
    corpus = corpus.subList(new Random().nextInt(numSteps - 1), corpus.size());
    // 减去1,因为我们需要考虑标签
    int numSubseqs = (corpus.size() - 1) / numSteps;
    // `numSteps` 长度子序列的起始指数
    List<Integer> initialIndices = new ArrayList<>();
    for (int i = 0; i < numSubseqs * numSteps; i += numSteps) {
        initialIndices.add(i);
    }
    // 在随机抽样中,来自两个相邻随机序列的子序列
    // 迭代过程中的小批量不一定在
    // 原始序列
    Collections.shuffle(initialIndices);

    int numBatches = numSubseqs / batchSize;

    ArrayList<NDList> pairs = new ArrayList<NDList>();
    for (int i = 0; i < batchSize * numBatches; i += batchSize) {
        // 这里,`initialIndices` 包含的是
        // 子序列
        List<Integer> initialIndicesPerBatch = initialIndices.subList(i, i + batchSize);

        NDArray xNDArray = manager.create(new Shape(initialIndices.size(), numSteps), DataType.INT32);
        NDArray yNDArray = manager.create(new Shape(initialIndices.size(), numSteps), DataType.INT32);
        for (int j = 0; j < initialIndices.size(); j++) {
            ArrayList<Integer> X = data(initialIndices.get(j), corpus, numSteps);
            xNDArray.set(new NDIndex(j), manager.create(X.stream().mapToInt(Integer::intValue).toArray()));
            ArrayList<Integer> Y = data(initialIndices.get(j)+1, corpus, numSteps);
            yNDArray.set(new NDIndex(j), manager.create(Y.stream().mapToInt(Integer::intValue).toArray()));
        }
        NDList pair = new NDList();
        pair.add(xNDArray);
        pair.add(yNDArray);
        pairs.add(pair);
    }
    return pairs;
}

ArrayList<Integer> data(int pos, List<Integer> corpus, int numSteps) {
    // 返回从`pos` 开始的长度为`numSteps` 的序列
    return new ArrayList<Integer>(corpus.subList(pos, pos + numSteps));
}

下面我们生成一个从\(0\)\(34\)的序列。 假设批量大小为\(2\),时间步数为\(5\),这意味着可以生成 \(\lfloor (35 - 1) / 5 \rfloor= 6\)个“特征-标签”子序列对。 如果设置小批量大小为\(2\),我们只能得到\(3\)个小批量。

List<Integer> mySeq = new ArrayList<>();
for (int i = 0; i < 35; i++) {
    mySeq.add(i);
}

for (NDList pair : seqDataIterRandom(mySeq, 2, 5, manager)) {
    System.out.println("X:\n" + pair.get(0).toDebugString(50, 50, 50, 50));
    System.out.println("Y:\n" + pair.get(1).toDebugString(50, 50, 50, 50));
}
X:
ND: (6, 5) gpu(0) int32
[[17, 18, 19, 20, 21],
 [ 2,  3,  4,  5,  6],
 [ 7,  8,  9, 10, 11],
 [12, 13, 14, 15, 16],
 [22, 23, 24, 25, 26],
 [27, 28, 29, 30, 31],
]

Y:
ND: (6, 5) gpu(0) int32
[[18, 19, 20, 21, 22],
 [ 3,  4,  5,  6,  7],
 [ 8,  9, 10, 11, 12],
 [13, 14, 15, 16, 17],
 [23, 24, 25, 26, 27],
 [28, 29, 30, 31, 32],
]

X:
ND: (6, 5) gpu(0) int32
[[17, 18, 19, 20, 21],
 [ 2,  3,  4,  5,  6],
 [ 7,  8,  9, 10, 11],
 [12, 13, 14, 15, 16],
 [22, 23, 24, 25, 26],
 [27, 28, 29, 30, 31],
]

Y:
ND: (6, 5) gpu(0) int32
[[18, 19, 20, 21, 22],
 [ 3,  4,  5,  6,  7],
 [ 8,  9, 10, 11, 12],
 [13, 14, 15, 16, 17],
 [23, 24, 25, 26, 27],
 [28, 29, 30, 31, 32],
]

X:
ND: (6, 5) gpu(0) int32
[[17, 18, 19, 20, 21],
 [ 2,  3,  4,  5,  6],
 [ 7,  8,  9, 10, 11],
 [12, 13, 14, 15, 16],
 [22, 23, 24, 25, 26],
 [27, 28, 29, 30, 31],
]

Y:
ND: (6, 5) gpu(0) int32
[[18, 19, 20, 21, 22],
 [ 3,  4,  5,  6,  7],
 [ 8,  9, 10, 11, 12],
 [13, 14, 15, 16, 17],
 [23, 24, 25, 26, 27],
 [28, 29, 30, 31, 32],
]

8.3.4.2. 顺序分区

除了对原始序列进行随机抽样外,我们还可以确保 来自两个相邻小批次的子序列 迭代期间 在原始序列上相邻。 当在小批量上迭代时,此策略保持分割子序列的顺序,因此称为顺序分区。

/**
 * 使用顺序分区生成一小批 子序列。
 */
public ArrayList<NDList> seqDataIterSequential(List<Integer> corpus, int batchSize, int numSteps,
                                               NDManager manager) {
    // 从随机偏移量开始划分序列
    int offset = new Random().nextInt(numSteps);
    int numTokens = ((corpus.size() - offset - 1) / batchSize) * batchSize;

    NDArray Xs = manager.create(
        corpus.subList(offset, offset + numTokens).stream().mapToInt(Integer::intValue).toArray());
    NDArray Ys = manager.create(
        corpus.subList(offset + 1, offset + 1 + numTokens).stream().mapToInt(Integer::intValue).toArray());
    Xs = Xs.reshape(new Shape(batchSize, -1));
    Ys = Ys.reshape(new Shape(batchSize, -1));
    int numBatches = (int) Xs.getShape().get(1) / numSteps;


    ArrayList<NDList> pairs = new ArrayList<NDList>();
    for (int i = 0; i < numSteps * numBatches; i += numSteps) {
        NDArray X = Xs.get(new NDIndex(":, {}:{}", i, i + numSteps));
        NDArray Y = Ys.get(new NDIndex(":, {}:{}", i, i + numSteps));
        NDList pair = new NDList();
        pair.add(X);
        pair.add(Y);
        pairs.add(pair);
    }
    return pairs;
}

使用相同的设置, 让我们为通过顺序分区读取的每个小批量子序列打印特征X 和标签 Y 注意 来自两个相邻小批次的子序列 迭代期间 在原始序列上确实是相邻的。

for (NDList pair : seqDataIterSequential(mySeq, 2, 5, manager)) {
    System.out.println("X:\n" + pair.get(0).toDebugString(10, 10, 10, 10));
    System.out.println("Y:\n" + pair.get(1).toDebugString(10, 10, 10, 10));
}
X:
ND: (2, 5) gpu(0) int32
[[ 0,  1,  2,  3,  4],
 [17, 18, 19, 20, 21],
]

Y:
ND: (2, 5) gpu(0) int32
[[ 1,  2,  3,  4,  5],
 [18, 19, 20, 21, 22],
]

X:
ND: (2, 5) gpu(0) int32
[[ 5,  6,  7,  8,  9],
 [22, 23, 24, 25, 26],
]

Y:
ND: (2, 5) gpu(0) int32
[[ 6,  7,  8,  9, 10],
 [23, 24, 25, 26, 27],
]

X:
ND: (2, 5) gpu(0) int32
[[10, 11, 12, 13, 14],
 [27, 28, 29, 30, 31],
]

Y:
ND: (2, 5) gpu(0) int32
[[11, 12, 13, 14, 15],
 [28, 29, 30, 31, 32],
]

现在,我们将上述两个采样函数封装到一个类中,以便以后可以将其用作数据迭代器。

public class SeqDataLoader implements Iterable<NDList> {
    public ArrayList<NDList> dataIter;
    public List<Integer> corpus;
    public Vocab vocab;
    public int batchSize;
    public int numSteps;

    /**
     * 加载序列数据的迭代器
     */
    @SuppressWarnings("unchecked")
    public SeqDataLoader(int batchSize, int numSteps, boolean useRandomIter, int maxTokens) throws