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_natural-language-processing-pretraining/subword-embedding.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_natural-language-processing-pretraining/subword-embedding.ipynb .. _sec_fasttext: 子词嵌入 ======== 在英语中,“helps”、“helped”和“helping”等单词都是同一个词“help”的变形形式。“dog”和“dogs”之间的关系与“cat”和“cats”之间的关系相同,“boy”和“boyfriend”之间的关系与“girl”和“girlfriend”之间的关系相同。在法语和西班牙语等其他语言中,许多动词有40多种变形形式,而在芬兰语中,名词最多可能有15种变形。在语言学中,形态学研究单词形成和词汇关系。但是,word2vec和GloVe都没有对词的内部结构进行探讨。 fastText模型 ------------ 回想一下词在word2vec中是如何表示的。在跳元模型和连续词袋模型中,同一词的不同变形形式直接由不同的向量表示,不需要共享参数。为了使用形态信息,\ *fastText模型*\ 提出了一种\ *子词嵌入*\ 方法,其中子词是一个字符\ :math:`n`-gram :cite:`Bojanowski.Grave.Joulin.ea.2017`\ 。fastText可以被认为是子词级跳元模型,而非学习词级向量表示,其中每个\ *中心词*\ 由其子词级向量之和表示。 让我们来说明如何以单词“where”为例获得fastText中每个中心词的子词。首先,在词的开头和末尾添加特殊字符“<”和“>”,以将前缀和后缀与其他子词区分开来。 然后,从词中提取字符\ :math:`n`-gram。 例如,值\ :math:`n=3`\ 时,我们将获得长度为3的所有子词: “”和特殊子词“”。 在fastText中,对于任意词\ :math:`w`\ ,用\ :math:`\mathcal{G}_w`\ 表示其长度在3和6之间的所有子词与其特殊子词的并集。词表是所有词的子词的集合。假设\ :math:`\mathbf{z}_g`\ 是词典中的子词\ :math:`g`\ 的向量,则跳元模型中作为中心词的词\ :math:`w`\ 的向量\ :math:`\mathbf{v}_w`\ 是其子词向量的和: .. math:: \mathbf{v}_w = \sum_{g\in\mathcal{G}_w} \mathbf{z}_g. fastText的其余部分与跳元模型相同。与跳元模型相比,fastText的词量更大,模型参数也更多。此外,为了计算一个词的表示,它的所有子词向量都必须求和,这导致了更高的计算复杂度。然而,由于具有相似结构的词之间共享来自子词的参数,罕见词甚至词表外的词在fastText中可能获得更好的向量表示。 .. _subsec_Byte_Pair_Encoding: 字节对编码(Byte Pair Encoding) -------------------------------- 在fastText中,所有提取的子词都必须是指定的长度,例如\ :math:`3`\ 到\ :math:`6`\ ,因此词表大小不能预定义。为了在固定大小的词表中允许可变长度的子词,我们可以应用一种称为\ *字节对编码*\ (Byte Pair Encoding,BPE)的压缩算法来提取子词 :cite:`Sennrich.Haddow.Birch.2015`\ 。 字节对编码执行训练数据集的统计分析,以发现单词内的公共符号,诸如任意长度的连续字符。从长度为1的符号开始,字节对编码迭代地合并最频繁的连续符号对以产生新的更长的符号。请注意,为提高效率,不考虑跨越单词边界的对。最后,我们可以使用像子词这样的符号来切分单词。字节对编码及其变体已经用于诸如GPT-2 :cite:`Radford.Wu.Child.ea.2019`\ 和RoBERTa :cite:`Liu.Ott.Goyal.ea.2019`\ 等自然语言处理预训练模型中的输入表示。在下面,我们将说明字节对编码是如何工作的。 首先,我们将符号词表初始化为所有英文小写字符、特殊的词尾符号\ ``'_'``\ 和特殊的未知符号\ ``'[UNK]'``\ 。 .. code:: java %load ../utils/djl-imports %load ../utils/Functions.java .. code:: java import java.util.stream.*; .. code:: java NDManager manager = NDManager.newBaseManager(); .. code:: java 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``\ 的键)内的每对连续字符之间插入空格。换句话说,空格是词中符号之间的分隔符。 .. code:: java HashMap rawTokenFreqs = new HashMap<>(); rawTokenFreqs.put("fast_", 4); rawTokenFreqs.put("faster_", 3); rawTokenFreqs.put("tall_", 5); rawTokenFreqs.put("taller_", 4); HashMap tokenFreqs = new HashMap<>(); for (Map.Entry e : rawTokenFreqs.entrySet()) { String token = e.getKey(); tokenFreqs.put(String.join(" ", token.split("")), rawTokenFreqs.get(token)); } tokenFreqs .. parsed-literal:: :class: output {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``\ 的键。 .. code:: java public static Pair getMaxFreqPair(HashMap tokenFreqs) { HashMap, Integer> pairs = new HashMap<>(); for (Map.Entry 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 maxFreqPair = null; for (Map.Entry, Integer> pair : pairs.entrySet()) { if (max < pair.getValue()) { max = pair.getValue(); maxFreqPair = pair.getKey(); } } return maxFreqPair; } 作为基于连续符号频率的贪心方法,字节对编码将使用以下\ ``mergeSymbols``\ 函数来合并最频繁的连续符号对以产生新符号。 .. code:: java public static Pair, String[]> mergeSymbols( Pair maxFreqPair, HashMap tokenFreqs) { ArrayList symbols = new ArrayList<>(); symbols.add(maxFreqPair.getKey() + maxFreqPair.getValue()); HashMap newTokenFreqs = new HashMap<>(); for (Map.Entry 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'``\ 。 .. code:: java int numMerges = 10; for (int i = 0; i < numMerges; i++) { Pair maxFreqPair = getMaxFreqPair(tokenFreqs); Pair, 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() + ")"); } .. parsed-literal:: :class: output 合并 #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个从其他符号迭代合并而来的符号。 .. code:: java Arrays.toString(symbols) .. parsed-literal:: :class: output [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\_”。 .. code:: java tokenFreqs.keySet() .. parsed-literal:: :class: output [fast _, tall_, taller_, fast er_] 请注意,字节对编码的结果取决于正在使用的数据集。我们还可以使用从一个数据集学习的子词来切分另一个数据集的单词。作为一种贪心方法,下面的\ ``segmentBPE``\ 函数尝试将单词从输入参数\ ``symbols``\ 分成可能最长的子词。 .. code:: java public static List segmentBPE(String[] tokens, String[] symbols) { List outputs = new ArrayList<>(); for (String token : tokens) { int start = 0; int end = token.length(); ArrayList 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``\ 。 .. code:: java String[] tokens = new String[] {"tallest_", "fatter_"}; System.out.println(segmentBPE(tokens, symbols)); .. parsed-literal:: :class: output [tall e st _, f a t t er_] 小结 ---- - fastText模型提出了一种子词嵌入方法:基于word2vec中的跳元模型,它将中心词表示为其子词向量之和。 - 字节对编码执行训练数据集的统计分析,以发现词内的公共符号。作为一种贪心方法,字节对编码迭代地合并最频繁的连续符号对。 - 子词嵌入可以提高稀有词和词典外词的表示质量。 练习 ---- 1. 例如,英语中大约有\ :math:`3\times 10^8`\ 种可能的\ :math:`6`-元组。子词太多会有什么问题呢?如何解决这个问题?提示:请参阅fastText论文第3.2节末尾 :cite:`Bojanowski.Grave.Joulin.ea.2017`\ 。 2. 如何在连续词袋模型的基础上设计一个子词嵌入模型? 3. 要获得大小为\ :math:`m`\ 的词表,当初始符号词表大小为\ :math:`n`\ 时,需要多少合并操作? 4. 如何扩展字节对编码的思想来提取短语?