Run this notebook online:Binder or Colab: Colab

14.5. 子词嵌入

在英语中,“helps”、“helped”和“helping”等单词都是同一个词“help”的变形形式。“dog”和“dogs”之间的关系与“cat”和“cats”之间的关系相同,“boy”和“boyfriend”之间的关系与“girl”和“girlfriend”之间的关系相同。在法语和西班牙语等其他语言中,许多动词有40多种变形形式,而在芬兰语中,名词最多可能有15种变形。在语言学中,形态学研究单词形成和词汇关系。但是,word2vec和GloVe都没有对词的内部结构进行探讨。

14.5.1. fastText模型

回想一下词在word2vec中是如何表示的。在跳元模型和连续词袋模型中,同一词的不同变形形式直接由不同的向量表示,不需要共享参数。为了使用形态信息,fastText模型提出了一种子词嵌入方法,其中子词是一个字符\(n\)-gram [Bojanowski et al., 2017]。fastText可以被认为是子词级跳元模型,而非学习词级向量表示,其中每个中心词由其子词级向量之和表示。

让我们来说明如何以单词“where”为例获得fastText中每个中心词的子词。首先,在词的开头和末尾添加特殊字符“<”和“>”,以将前缀和后缀与其他子词区分开来。 然后,从词中提取字符\(n\)-gram。 例如,值\(n=3\)时,我们将获得长度为3的所有子词: “<wh”、“whe”、“her”、“ere”、“re>”和特殊子词“<where>”。

在fastText中,对于任意词\(w\),用\(\mathcal{G}_w\)表示其长度在3和6之间的所有子词与其特殊子词的并集。词表是所有词的子词的集合。假设\(\mathbf{z}_g\)是词典中的子词\(g\)的向量,则跳元模型中作为中心词的词\(w\)的向量\(\mathbf{v}_w\)是其子词向量的和:

(14.5.1)\[\mathbf{v}_w = \sum_{g\in\mathcal{G}_w} \mathbf{z}_g.\]

fastText的其余部分与跳元模型相同。与跳元模型相比,fastText的词量更大,模型参数也更多。此外,为了计算一个词的表示,它的所有子词向量都必须求和,这导致了更高的计算复杂度。然而,由于具有相似结构的词之间共享来自子词的参数,罕见词甚至词表外的词在fastText中可能获得更好的向量表示。

14.5.2. 字节对编码(Byte Pair Encoding)

在fastText中,所有提取的子词都必须是指定的长度,例如\(3\)\(6\),因此词表大小不能预定义。为了在固定大小的词表中允许可变长度的子词,我们可以应用一种称为字节对编码(Byte Pair Encoding,BPE)的压缩算法来提取子词 [Sennrich et al., 2015]

字节对编码执行训练数据集的统计分析,以发现单词内的公共符号,诸如任意长度的连续字符。从长度为1的符号开始,字节对编码迭代地合并最频繁的连续符号对以产生新的更长的符号。请注意,为提高效率,不考虑跨越单词边界的对。最后,我们可以使用像子词这样的符号来切分单词。字节对编码及其变体已经用于诸如GPT-2 [Radford et al., 2019]和RoBERTa [Liu et al., 2019]等自然语言处理预训练模型中的输入表示。在下面,我们将说明字节对编码是如何工作的。

首先,我们将符号词表初始化为所有英文小写字符、特殊的词尾符号'_'和特殊的未知符号'[UNK]'

%load ../utils/djl-imports
%load ../utils/Functions.java
import java.util.stream.*;
NDManager manager = NDManager.newBaseManager();
String[] symbols = {
            "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p",
            "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "_", "[UNK]"
        };

因为我们不考虑跨越词边界的符号对,所以我们只需要一个字典rawTokenFreqs将词映射到数据集中的频率(出现次数)。注意,特殊符号'_'被附加到每个词的尾部,以便我们可以容易地从输出符号序列(例如,“a_all er_man”)恢复单词序列(例如,“a_all er_man”)。由于我们仅从单个字符和特殊符号的词开始合并处理,所以在每个词(词典tokenFreqs的键)内的每对连续字符之间插入空格。换句话说,空格是词中符号之间的分隔符。

HashMap<String, Integer> rawTokenFreqs = new HashMap<>();
rawTokenFreqs.put("fast_", 4);
rawTokenFreqs.put("faster_", 3);
rawTokenFreqs.put("tall_", 5);
rawTokenFreqs.put("taller_", 4);

HashMap<String, Integer> tokenFreqs = new HashMap<>();
for (Map.Entry<String, Integer> e : rawTokenFreqs.entrySet()) {
    String token = e.getKey();
    tokenFreqs.put(String.join(" ", token.split("")), rawTokenFreqs.get(token));
}

tokenFreqs
{f a s t e r _=3, t a l l e r _=4, f a s t _=4, t a l l _=5}

我们定义以下getMaxFreqPair函数,其返回词内最频繁的连续符号对,其中词来自输入词典tokenFreqs的键。

public static Pair<String, String> getMaxFreqPair(HashMap<String, Integer> tokenFreqs) {
    HashMap<Pair<String, String>, Integer> pairs = new HashMap<>();
    for (Map.Entry<String, Integer> e : tokenFreqs.entrySet()) {
        // Key of 'pairs' is a tuple of two consecutive symbols
        String token = e.getKey();
        Integer freq = e.getValue();
        String[] symbols = token.split(" ");
        for (int i = 0; i < symbols.length - 1; i++) {
            pairs.put(
                    new Pair<>(symbols[i], symbols[i + 1]),
                    pairs.getOrDefault(new Pair<>(symbols[i], symbols[i + 1]), 0) + freq);
        }
    }
    int max = 0; // Key of `pairs` with the max value
    Pair<String, String> maxFreqPair = null;
    for (Map.Entry<Pair<String, String>, Integer> pair : pairs.entrySet()) {
        if (max < pair.getValue()) {
            max = pair.getValue();
            maxFreqPair = pair.getKey();
        }
    }
    return maxFreqPair;
}

作为基于连续符号频率的贪心方法,字节对编码将使用以下mergeSymbols函数来合并最频繁的连续符号对以产生新符号。

public static Pair<HashMap<String, Integer>, String[]> mergeSymbols(
        Pair<String, String> maxFreqPair, HashMap<String, Integer> tokenFreqs) {
    ArrayList<String> symbols = new ArrayList<>();
    symbols.add(maxFreqPair.getKey() + maxFreqPair.getValue());

    HashMap<String, Integer> newTokenFreqs = new HashMap<>();
    for (Map.Entry<String, Integer> e : tokenFreqs.entrySet()) {
        String token = e.getKey();
        String newToken =
                token.replace(
                        maxFreqPair.getKey() + " " + maxFreqPair.getValue(),
                        maxFreqPair.getKey() + "" + maxFreqPair.getValue());
        newTokenFreqs.put(newToken, tokenFreqs.get(token));
    }
    return new Pair(newTokenFreqs, symbols.toArray(new String[symbols.size()]));
}

现在,我们对词典tokenFreqs的键迭代地执行字节对编码算法。在第一次迭代中,最频繁的连续符号对是't''a',因此字节对编码将它们合并以产生新符号'ta'。在第二次迭代中,字节对编码继续合并'ta''l'以产生另一个新符号'tal'

int numMerges = 10;
for (int i = 0; i < numMerges; i++) {
    Pair<String, String> maxFreqPair = getMaxFreqPair(tokenFreqs);
    Pair<HashMap<String, Integer>, String[]> pair =
            mergeSymbols(maxFreqPair, tokenFreqs);
    tokenFreqs = pair.getKey();
    symbols =
            Stream.concat(Arrays.stream(symbols), Arrays.stream(pair.getValue()))
                    .toArray(String[]::new);
    System.out.println(
            "合并 #"
                    + (i + 1)
                    + ": ("
                    + maxFreqPair.getKey()
                    + ", "
                    + maxFreqPair.getValue()
                    + ")");
}
合并 #1: (l, l)
合并 #2: (a, ll)
合并 #3: (t, all)
合并 #4: (s, t)
合并 #5: (a, st)
合并 #6: (f, ast)
合并 #7: (e, r)
合并 #8: (er, _)
合并 #9: (tall, _)
合并 #10: (tall, er_)

在字节对编码的10次迭代之后,我们可以看到列表symbols现在又包含10个从其他符号迭代合并而来的符号。

Arrays.toString(symbols)
[a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z, _, [UNK], ll, all, tall, st, ast, fast, er, er_, tall_, taller_]

对于在词典rawTokenFreqs的键中指定的同一数据集,作为字节对编码算法的结果,数据集中的每个词现在被子词“fast_”、“fast”、“er_”、“tall_”和“tall”分割。例如,单词“fast er_”和“tall er_”分别被分割为“fast er_”和“tall er_”。

tokenFreqs.keySet()
[fast _, tall_, taller_, fast er_]

请注意,字节对编码的结果取决于正在使用的数据集。我们还可以使用从一个数据集学习的子词来切分另一个数据集的单词。作为一种贪心方法,下面的segmentBPE函数尝试将单词从输入参数symbols分成可能最长的子词。

public static List<String> segmentBPE(String[] tokens, String[] symbols) {
    List<String> outputs = new ArrayList<>();
    for (String token : tokens) {
        int start = 0;
        int end = token.length();
        ArrayList<String> curOutput = new ArrayList<>();
        // Segment token with the longest possible subwords from symbols
        while (start < token.length() && start < end) {
            if (Arrays.asList(symbols).contains(token.substring(start, end))) {
                curOutput.add(token.substring(start, end));
                start = end;
                end = token.length();
            } else {
                end -= 1;
            }
        }
        if (start < tokens.length) {
            curOutput.add("[UNK]");
        }
        String temp = "";
        for (String s : curOutput) {
            temp += s + " ";
        }
        outputs.add(temp.trim());
    }
    return outputs;
}

我们使用列表symbols中的子词(从前面提到的数据集学习)来表示另一个数据集的tokens

String[] tokens = new String[] {"tallest_", "fatter_"};
System.out.println(segmentBPE(tokens, symbols));
[tall e st _, f a t t er_]

14.5.3. 小结

  • fastText模型提出了一种子词嵌入方法:基于word2vec中的跳元模型,它将中心词表示为其子词向量之和。

  • 字节对编码执行训练数据集的统计分析,以发现词内的公共符号。作为一种贪心方法,字节对编码迭代地合并最频繁的连续符号对。

  • 子词嵌入可以提高稀有词和词典外词的表示质量。

14.5.4. 练习

  1. 例如,英语中大约有\(3\times 10^8\)种可能的\(6\)-元组。子词太多会有什么问题呢?如何解决这个问题?提示:请参阅fastText论文第3.2节末尾 [Bojanowski et al., 2017]

  2. 如何在连续词袋模型的基础上设计一个子词嵌入模型?

  3. 要获得大小为\(m\)的词表,当初始符号词表大小为\(n\)时,需要多少合并操作?

  4. 如何扩展字节对编码的思想来提取短语?