zzzss654321 LV
发表于 2025-4-9 14:00:59
前面都讲了是指什么,我来浅答一下目前大模型时代分词是怎么做的☺️,主要内容为WordPiece,Byte-Pair Encoding (BPE),Byte-level BPE(BBPE)分词方法的原理以及其代码实现,全篇阅读可能需要45分钟,建议收藏~
Tokenization(分词) 在自然语言处理(NLP)的任务中是最基本的一步,把文本内容处理为最小基本单元即token(标记,令牌,词元,没有准确的翻译)用于后续的处理,如何把文本处理成token呢?有一系列的方法,基本思想是构建一个词表通过词表一一映射进行分词,但如何构建合适的词表呢?以下以分词粒度为角度进行介绍:
1.word(词)粒度
在英文语系中,word(词)级别分词实现很简单,因为有天然的分隔符。在中文里面word(词)粒度,需要一些分词工具比如jieba,以下是中文和英文的例子:- 中文句子:我喜欢看电影和读书。
- 分词结果:我 | 喜欢 | 看 | 电影 | 和 | 读书。
- 英文句子:I enjoy watching movies and reading books.
- 分词结果:I | enjoy | watching | movies | and | reading | books.
复制代码 word(词)粒度的优点有:
- 语义明确:以词为单位进行分词可以更好地保留每个词的语义,使得文本在后续处理中能够更准确地表达含义。
- 上下文理解:以词为粒度进行分词有助于保留词语之间的关联性和上下文信息,从而在语义分析和理解时能够更好地捕捉句子的意图。
缺点:
- 长尾效应和稀有词问题: 词表可能变得巨大,包含很多不常见的词汇,增加存储和训练成本,稀有词的训练数据有限,难以获得准确的表示。
- OOV(Out-of-Vocabulary): 词粒度分词模型只能使用词表中的词来进行处理,无法处理词表之外的词汇,这就是所谓的OOV问题。
- 形态关系和词缀关系: 无法捕捉同一词的不同形态,也无法有效学习词缀在不同词汇之间的共通性,限制了模型的语言理解能力,比如love和loves在word(词)粒度的词表中将会是两个词。
2.char(字符)粒度
以字符为单位进行分词,即将文本拆分成一个个单独的字符作为最小基本单元,这种字符粒度的分词方法适用于多种语言,无论是英文、中文还是其他不同语言,都能够一致地使用字符粒度进行处理,因为英文就26个字母以及其他的一些符号,中文常见字就6000个左右。- 中文句子:我喜欢看电影和读书。
- 分词结果:我 | 喜 | 欢 | 看 | 电 | 影 | 和 | 读 | 书 | 。
- 英文句子:I enjoy watching movies and reading books.
- 分词结果:I | | e | n | j | o | y | | w | a | t | c | h | i | n | g | | m | o | v | i | e | s | | a | n | d | | r | e | a | d | i | n | g | | b | o | o | k | s | .
复制代码 char(字符)粒度的优点有:
- 统一处理方式:字符粒度分词方法适用于不同语言,无需针对每种语言设计不同的分词规则或工具,具有通用性。
- 解决OOV问题:由于字符粒度分词可以处理任何字符,无需维护词表,因此可以很好地处理一些新创词汇、专有名词等问题。
缺点:
- 语义信息不明确:字符粒度分词无法直接表达词的语义,可能导致在一些语义分析任务中效果较差。
- 处理效率低:由于文本被拆分为字符,处理的粒度较小,增加后续处理的计算成本和时间。
3.subword(子词)粒度
在很多情况下,既不希望将文本切分成单独的词(太大),也不想将其切分成单个字符(太小),而是希望得到介于词和字符之间的子词单元。这就引入了 subword(子词)粒度的分词方法。
在BERT时代,WordPiece 分词方法被广泛应用[1],比如 BERT、DistilBERT等。WordPiece 分词方法是 subword(子词)粒度的一种方法。
3.1 WordPiece
WordPiece核心思想是将单词拆分成多个前缀符号(比如BERT中的##)最小单元,再通过子词合并规则将最小单元进行合并为子词级别。例如对于单词"word",拆分如下:然后通过合并规则进行合并,从而循环迭代构建出一个词表,以下是核心步骤:
- 计算初始词表:通过训练语料获得或者最初的英文中26个字母加上各种符号以及常见中文字符,这些作为初始词表。
- 计算合并分数:对训练语料拆分的多个子词单元通过合拼规则计算合并分数。
- 合并分数最高的子词对:选择分数最高的子词对,将它们合并成一个新的子词单元,并更新词表。
- 重复合并步骤:不断重复步骤 2 和步骤 3,直到达到预定的词表大小、合并次数,或者直到不再有有意义的合并(即,进一步合并不会显著提高词表的效益)。
- 分词:使用最终得到的词汇表对文本进行分词。
简单举例[1]:
我们有以下的训练语料中的样例,括号中第2位为在训练语料中出现的频率:- ("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
复制代码 我们对其进行拆分为带前缀的形式:- ("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##g" "##s", 5)
复制代码 所以这些样例的初始词表将会是:- ["b", "h", "p", "##g", "##n", "##s", "##u"]
复制代码 接下来重要的一步进行计算合并分数,也称作互信息(信息论中衡量两个变量之间的关联程度[2]),简单来说就是以下公式来计算- score=(freq_of_pair)/(freq_of_first_element×freq_of_second_element)
- 分数 = 合并pair候选的频率 / (第一个元素的频率 × 第二个元素的频率)
复制代码 对于上述样例中这个pair("##u", "##g")出现的频率是最高的20次,但是"##u"出现的频率是36次, "##g"出现的频率是20次,所以这个pair("##u", "##g")的分数是(20)/(36*20) = 1/36,同理计算这个pair("##g", "##s")的分数为(5)/(20*5) = 1/20,所以最先合并的pair是("##g", "##s")→("##gs")。此时词表和拆分后的的频率将变成以下:- Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs"]
- Corpus: ("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##gs", 5)
复制代码 重复上述的操作,直到达到你想要的词表的大小- Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu", "hug"]
- Corpus: ("hug", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("hu" "##gs", 5)
复制代码 代码实现:
用一些包含中英文的文本作为训练语料,因为英文有天然的分隔符,所以在这个例子中,中文已经进行了分词:- sentences = [
- "我",
- "喜欢",
- "吃",
- "苹果",
- "他",
- "不",
- "喜欢",
- "吃",
- "苹果派",
- "I like to eat apples",
- "She has a cute cat",
- "you are very cute",
- "give you a hug",
- ]
复制代码 统计每个词出现的频率并初始化初始词表:- from collections import defaultdict
- # 构建频率统计
- def build_stats(sentences):
- stats = defaultdict(int)
- for sentence in sentences:
- symbols = sentence.split()
- for symbol in symbols:
- stats[symbol] += 1
- return stats
- stats = build_stats(sentences)
- print("stats:", stats)
- alphabet = []
- for word in stats.keys():
- if word[0] not in alphabet:
- alphabet.append(word[0])
- for letter in word[1:]:
- if f"##{letter}" not in alphabet:
- alphabet.append(f"##{letter}")
- alphabet.sort()
- # 初始词表
- vocab = alphabet.copy()
- print("alphabet:", alphabet)
- # 结果
- stats: defaultdict(<class &#39;int&#39;>, {&#39;我&#39;: 1, &#39;喜欢&#39;: 2, &#39;吃&#39;: 2, &#39;苹果&#39;: 1, &#39;他&#39;: 1, &#39;不&#39;: 1, &#39;苹果派&#39;: 1, &#39;I&#39;: 1, &#39;like&#39;: 1, &#39;to&#39;: 1, &#39;eat&#39;: 1, &#39;apples&#39;: 1, &#39;She&#39;: 1, &#39;has&#39;: 1, &#39;a&#39;: 2, &#39;cute&#39;: 2, &#39;cat&#39;: 1, &#39;you&#39;: 2, &#39;are&#39;: 1, &#39;very&#39;: 1, &#39;give&#39;: 1, &#39;hug&#39;: 1})
- # 初始词表
- alphabet: [&#39;##a&#39;, &#39;##e&#39;, &#39;##g&#39;, &#39;##h&#39;, &#39;##i&#39;, &#39;##k&#39;, &#39;##l&#39;, &#39;##o&#39;, &#39;##p&#39;, &#39;##r&#39;, &#39;##s&#39;, &#39;##t&#39;, &#39;##u&#39;, &#39;##v&#39;, &#39;##y&#39;, &#39;##果&#39;, &#39;##欢&#39;, &#39;##派&#39;, &#39;I&#39;, &#39;S&#39;, &#39;a&#39;, &#39;c&#39;, &#39;e&#39;, &#39;g&#39;, &#39;h&#39;, &#39;l&#39;, &#39;t&#39;, &#39;v&#39;, &#39;y&#39;, &#39;不&#39;, &#39;他&#39;, &#39;吃&#39;, &#39;喜&#39;, &#39;我&#39;, &#39;苹&#39;]
复制代码 根据初始词表拆分每个词:- splits = {
- word: [c if i == 0 else f&#34;##{c}&#34; for i, c in enumerate(word)]
- for word in stats.keys()
- }
- print(&#34;splits:&#34;, splits)
- # 结果
- splits: {&#39;我&#39;: [&#39;我&#39;], &#39;喜欢&#39;: [&#39;喜&#39;, &#39;##欢&#39;], &#39;吃&#39;: [&#39;吃&#39;], &#39;苹果&#39;: [&#39;苹&#39;, &#39;##果&#39;], &#39;他&#39;: [&#39;他&#39;], &#39;不&#39;: [&#39;不&#39;], &#39;苹果派&#39;: [&#39;苹&#39;, &#39;##果&#39;, &#39;##派&#39;], &#39;I&#39;: [&#39;I&#39;], &#39;like&#39;: [&#39;l&#39;, &#39;##i&#39;, &#39;##k&#39;, &#39;##e&#39;], &#39;to&#39;: [&#39;t&#39;, &#39;##o&#39;], &#39;eat&#39;: [&#39;e&#39;, &#39;##a&#39;, &#39;##t&#39;], &#39;apples&#39;: [&#39;a&#39;, &#39;##p&#39;, &#39;##p&#39;, &#39;##l&#39;, &#39;##e&#39;, &#39;##s&#39;], &#39;She&#39;: [&#39;S&#39;, &#39;##h&#39;, &#39;##e&#39;], &#39;has&#39;: [&#39;h&#39;, &#39;##a&#39;, &#39;##s&#39;], &#39;a&#39;: [&#39;a&#39;], &#39;cute&#39;: [&#39;c&#39;, &#39;##u&#39;, &#39;##t&#39;, &#39;##e&#39;], &#39;cat&#39;: [&#39;c&#39;, &#39;##a&#39;, &#39;##t&#39;], &#39;you&#39;: [&#39;y&#39;, &#39;##o&#39;, &#39;##u&#39;], &#39;are&#39;: [&#39;a&#39;, &#39;##r&#39;, &#39;##e&#39;], &#39;very&#39;: [&#39;v&#39;, &#39;##e&#39;, &#39;##r&#39;, &#39;##y&#39;], &#39;give&#39;: [&#39;g&#39;, &#39;##i&#39;, &#39;##v&#39;, &#39;##e&#39;], &#39;hug&#39;: [&#39;h&#39;, &#39;##u&#39;, &#39;##g&#39;]}
复制代码 根据上述提到的计算互信息的分数公式进行计算:- def compute_pair_scores(splits):
- letter_freqs = defaultdict(int)
- pair_freqs = defaultdict(int)
- for word, freq in stats.items():
- split = splits[word]
- if len(split) == 1:
- letter_freqs[split[0]] += freq
- continue
- for i in range(len(split) - 1):
- pair = (split[i], split[i + 1])
- letter_freqs[split[i]] += freq
- pair_freqs[pair] += freq
- letter_freqs[split[-1]] += freq
- scores = {
- pair: freq / (letter_freqs[pair[0]] * letter_freqs[pair[1]])
- for pair, freq in pair_freqs.items()
- }
- return scores
- pair_scores = compute_pair_scores(splits)
- for i, key in enumerate(pair_scores.keys()):
- print(f&#34;{key}: {pair_scores[key]}&#34;)
- if i >= 5:
- break
复制代码 一些结果:- (&#39;喜&#39;, &#39;##欢&#39;): 0.5
- (&#39;苹&#39;, &#39;##果&#39;): 0.5
- (&#39;##果&#39;, &#39;##派&#39;): 0.5
- (&#39;l&#39;, &#39;##i&#39;): 0.5
- (&#39;##i&#39;, &#39;##k&#39;): 0.5
- (&#39;##k&#39;, &#39;##e&#39;): 0.125
复制代码 我们需要的是将分数最高的进行合并然后开始循环迭代,看一看分数最高的pair(子词对):- best_pair = &#34;&#34;
- max_score = None
- for pair, score in pair_scores.items():
- if max_score is None or max_score < score:
- best_pair = pair
- max_score = score
- print(best_pair, max_score)
- # 结果
- (&#39;S&#39;, &#39;##h&#39;) 1.0
复制代码 结果为(&#39;S&#39;, &#39;##h&#39;) 1.0,所以最先合成的就是(&#39;S&#39;, &#39;##h&#39;)→&#39;##Sh&#39;,合并的函数如下:- def merge_pair(a, b, splits):
- for word in stats:
- split = splits[word]
- if len(split) == 1:
- continue
- i = 0
- while i < len(split) - 1:
- if split[i] == a and split[i + 1] == b:
- merge = a + b[2:] if b.startswith(&#34;##&#34;) else a + b
- split = split[:i] + [merge] + split[i + 2 :]
- else:
- i += 1
- splits[word] = split
- return splits
复制代码 最后就是一直进行循环迭代,直到vocab达到了我们想要的数量- vocab_size = 50
- while len(vocab) < vocab_size:
- scores = compute_pair_scores(splits)
- best_pair, max_score = &#34;&#34;, None
- for pair, score in scores.items():
- if max_score is None or max_score < score:
- best_pair = pair
- max_score = score
- splits = merge_pair(*best_pair, splits)
- new_token = (
- best_pair[0] + best_pair[1][2:]
- if best_pair[1].startswith(&#34;##&#34;)
- else best_pair[0] + best_pair[1]
- )
- vocab.append(new_token)
- print(&#34;vocab:&#34;, vocab)
- # 结果
- vocab: [&#39;##a&#39;, &#39;##e&#39;, &#39;##g&#39;, &#39;##h&#39;, &#39;##i&#39;, &#39;##k&#39;, &#39;##l&#39;, &#39;##o&#39;, &#39;##p&#39;, &#39;##r&#39;, &#39;##s&#39;, &#39;##t&#39;, &#39;##u&#39;, &#39;##v&#39;, &#39;##y&#39;, &#39;##果&#39;, &#39;##欢&#39;, &#39;##派&#39;, &#39;I&#39;, &#39;S&#39;, &#39;a&#39;, &#39;c&#39;, &#39;e&#39;, &#39;g&#39;, &#39;h&#39;, &#39;l&#39;, &#39;t&#39;, &#39;v&#39;, &#39;y&#39;, &#39;不&#39;, &#39;他&#39;, &#39;吃&#39;, &#39;喜&#39;, &#39;我&#39;, &#39;苹&#39;, &#39;Sh&#39;, &#39;喜欢&#39;, &#39;苹果&#39;, &#39;苹果派&#39;, &#39;li&#39;, &#39;lik&#39;, &#39;gi&#39;, &#39;giv&#39;, &#39;##pl&#39;, &#39;##ppl&#39;, &#39;##ry&#39;, &#39;to&#39;, &#39;yo&#39;, &#39;ea&#39;, &#39;eat&#39;]
复制代码 上述就是WordPiece分词方法的代码实现,一般来说最后会在词表中加上一些特殊词汇,以及英文中26个字母加上各种符号以及常见中文字符,不过如果训练语料比较大以及词表比较大那这些应该也是已经包括了,只需要添加特殊词汇:- all_vocab = vocab + [&#34;[PAD]&#34;, &#34;[UNK]&#34;, &#34;[CLS]&#34;, &#34;[SEP]&#34;, &#34;[MASK]&#34;] + other_alphabet
复制代码 在大语言模型时代,最常用的分词方法是Byte-Pair Encoding (BPE)和Byte-level BPE(BBPE),Byte-Pair Encoding (BPE)最初是一种文本压缩算法在15年被引入到NLP用于分词[3],在训练 GPT 时被 OpenAI 用于tokenization,后续好多模型GPT,RoBERTa等都采用了这种分词方法。Byte-level BPE(BBPE)是于19年在BPE的基础上提出以Byte-level(字节)为粒度的分词方法[4],目前 GPT2,BLOOM,Llama,Falcon等采用的是该分词方法。
3.2 Byte-Pair Encoding (BPE)
Byte-Pair Encoding (BPE)核心思想是逐步合并出现频率最高的子词对而不是像Wordpiece计算合并分数,从而构建出一个词汇表,以下是核心步骤:
- 计算初始词表:通过训练语料获得或者最初的英文中26个字母加上各种符号以及常见中文字符,这些作为初始词表。
- 构建频率统计:统计所有子词单元对(两个连续的子词)在文本中的出现频率。
- 合并频率最高的子词对:选择出现频率最高的子词对,将它们合并成一个新的子词单元,并更新词汇表。
- 重复合并步骤:不断重复步骤 2 和步骤 3,直到达到预定的词汇表大小、合并次数,或者直到不再有有意义的合并(即,进一步合并不会显著提高词汇表的效益)。
- 分词:使用最终得到的词汇表对文本进行分词。
简单的代码实现[5]:
用一些包含中英文的文本作为训练语料和上面相同,因为英文有天然的分隔符,所以在这个例子中,中文已经进行了分词:- sentences = [
- &#34;我&#34;,
- &#34;喜欢&#34;,
- &#34;吃&#34;,
- &#34;苹果&#34;,
- &#34;他&#34;,
- &#34;不&#34;,
- &#34;喜欢&#34;,
- &#34;吃&#34;,
- &#34;苹果派&#34;,
- &#34;I like to eat apples&#34;,
- &#34;She has a cute cat&#34;,
- &#34;you are very cute&#34;,
- &#34;give you a hug&#34;,
- ]
复制代码 统计每个词出现的频率并初始化初始词表:- # 构建频率统计
- def build_stats(sentences):
- stats = defaultdict(int)
- for sentence in sentences:
- symbols = sentence.split()
- for symbol in symbols:
- stats[symbol] += 1
- return stats
- stats = build_stats(sentences)
- print(&#34;stats:&#34;, stats)
- alphabet = []
- for word in stats.keys():
- for letter in word:
- if letter not in alphabet:
- alphabet.append(letter)
- alphabet.sort()
- # 初始词表
- vocab = alphabet.copy()
- print(&#34;alphabet:&#34;, alphabet)
- # 结果
- stats: defaultdict(<class &#39;int&#39;>, {&#39;我&#39;: 1, &#39;喜欢&#39;: 2, &#39;吃&#39;: 2, &#39;苹果&#39;: 1, &#39;他&#39;: 1, &#39;不&#39;: 1, &#39;苹果派&#39;: 1, &#39;I&#39;: 1, &#39;like&#39;: 1, &#39;to&#39;: 1, &#39;eat&#39;: 1, &#39;apples&#39;: 1, &#39;She&#39;: 1, &#39;has&#39;: 1, &#39;a&#39;: 2, &#39;cute&#39;: 2, &#39;cat&#39;: 1, &#39;you&#39;: 2, &#39;are&#39;: 1, &#39;very&#39;: 1, &#39;give&#39;: 1, &#39;hug&#39;: 1})
- # 初始词表
- alphabet: [&#39;I&#39;, &#39;S&#39;, &#39;a&#39;, &#39;c&#39;, &#39;e&#39;, &#39;g&#39;, &#39;h&#39;, &#39;i&#39;, &#39;k&#39;, &#39;l&#39;, &#39;o&#39;, &#39;p&#39;, &#39;r&#39;, &#39;s&#39;, &#39;t&#39;, &#39;u&#39;, &#39;v&#39;, &#39;y&#39;, &#39;不&#39;, &#39;他&#39;, &#39;吃&#39;, &#39;喜&#39;, &#39;我&#39;, &#39;果&#39;, &#39;欢&#39;, &#39;派&#39;, &#39;苹&#39;]
复制代码 根据初始词表拆分每个词,计算左右pair(子词对)出现的频率- splits = {word: [c for c in word] for word in stats.keys()}
- print(&#34;splits:&#34;, splits)
- def compute_pair_freqs(splits):
- pair_freqs = defaultdict(int)
- for word, freq in stats.items():
- split = splits[word]
- if len(split) == 1:
- continue
- for i in range(len(split) - 1):
- pair = (split[i], split[i + 1])
- pair_freqs[pair] += freq
- return pair_freqs
- pair_freqs = compute_pair_freqs(splits)
- for i, key in enumerate(pair_freqs.keys()):
- print(f&#34;{key}: {pair_freqs[key]}&#34;)
- if i >= 5:
- break
- # 结果
- splits: {&#39;我&#39;: [&#39;我&#39;], &#39;喜欢&#39;: [&#39;喜&#39;, &#39;欢&#39;], &#39;吃&#39;: [&#39;吃&#39;], &#39;苹果&#39;: [&#39;苹&#39;, &#39;果&#39;], &#39;他&#39;: [&#39;他&#39;], &#39;不&#39;: [&#39;不&#39;], &#39;苹果派&#39;: [&#39;苹&#39;, &#39;果&#39;, &#39;派&#39;], &#39;I&#39;: [&#39;I&#39;], &#39;like&#39;: [&#39;l&#39;, &#39;i&#39;, &#39;k&#39;, &#39;e&#39;], &#39;to&#39;: [&#39;t&#39;, &#39;o&#39;], &#39;eat&#39;: [&#39;e&#39;, &#39;a&#39;, &#39;t&#39;], &#39;apples&#39;: [&#39;a&#39;, &#39;p&#39;, &#39;p&#39;, &#39;l&#39;, &#39;e&#39;, &#39;s&#39;], &#39;She&#39;: [&#39;S&#39;, &#39;h&#39;, &#39;e&#39;], &#39;has&#39;: [&#39;h&#39;, &#39;a&#39;, &#39;s&#39;], &#39;a&#39;: [&#39;a&#39;], &#39;cute&#39;: [&#39;c&#39;, &#39;u&#39;, &#39;t&#39;, &#39;e&#39;], &#39;cat&#39;: [&#39;c&#39;, &#39;a&#39;, &#39;t&#39;], &#39;you&#39;: [&#39;y&#39;, &#39;o&#39;, &#39;u&#39;], &#39;are&#39;: [&#39;a&#39;, &#39;r&#39;, &#39;e&#39;], &#39;very&#39;: [&#39;v&#39;, &#39;e&#39;, &#39;r&#39;, &#39;y&#39;], &#39;give&#39;: [&#39;g&#39;, &#39;i&#39;, &#39;v&#39;, &#39;e&#39;], &#39;hug&#39;: [&#39;h&#39;, &#39;u&#39;, &#39;g&#39;]}
- (&#39;喜&#39;, &#39;欢&#39;): 2
- (&#39;苹&#39;, &#39;果&#39;): 2
- (&#39;果&#39;, &#39;派&#39;): 1
- (&#39;l&#39;, &#39;i&#39;): 1
- (&#39;i&#39;, &#39;k&#39;): 1
- (&#39;k&#39;, &#39;e&#39;): 1
复制代码 然后开始循环迭代找到出现频率最高的pair(子词对):- best_pair = &#34;&#34;
- max_freq = None
- for pair, freq in pair_freqs.items():
- if max_freq is None or max_freq < freq:
- best_pair = pair
- max_freq = freq
- print(best_pair, max_freq)
复制代码 结果为【(&#39;喜&#39;, &#39;欢&#39;) 2】,所以最先合成的就是(&#39;喜&#39;, &#39;欢&#39;)→&#39;喜欢&#39;,然后合并的函数如下:- def merge_pair(a, b, splits):
- for word in stats:
- split = splits[word]
- if len(split) == 1:
- continue
- i = 0
- while i < len(split) - 1:
- if split[i] == a and split[i + 1] == b:
- split = split[:i] + [a + b] + split[i + 2 :]
- else:
- i += 1
- splits[word] = split
- return splits
复制代码 最后就是一直进行循环直到vocab达到了我们想要的数量:- # 假设我们想要的词典为50
- merges = {}
- vocab_size = 50
- while len(vocab) < vocab_size:
- pair_freqs = compute_pair_freqs(splits)
- best_pair = &#34;&#34;
- max_freq = None
- for pair, freq in pair_freqs.items():
- if max_freq is None or max_freq < freq:
- best_pair = pair
- max_freq = freq
- splits = merge_pair(*best_pair, splits)
- merges[best_pair] = best_pair[0] + best_pair[1]
- vocab.append(best_pair[0] + best_pair[1])
- print(&#34;merges:&#34;, merges)
- print(&#34;vocab:&#34;, vocab)
- # 结果
- merges: {(&#39;喜&#39;, &#39;欢&#39;): &#39;喜欢&#39;, (&#39;苹&#39;, &#39;果&#39;): &#39;苹果&#39;, (&#39;a&#39;, &#39;t&#39;): &#39;at&#39;, (&#39;c&#39;, &#39;u&#39;): &#39;cu&#39;, (&#39;cu&#39;, &#39;t&#39;): &#39;cut&#39;, (&#39;cut&#39;, &#39;e&#39;): &#39;cute&#39;, (&#39;y&#39;, &#39;o&#39;): &#39;yo&#39;, (&#39;yo&#39;, &#39;u&#39;): &#39;you&#39;, (&#39;v&#39;, &#39;e&#39;): &#39;ve&#39;, (&#39;苹果&#39;, &#39;派&#39;): &#39;苹果派&#39;, (&#39;l&#39;, &#39;i&#39;): &#39;li&#39;, (&#39;li&#39;, &#39;k&#39;): &#39;lik&#39;, (&#39;lik&#39;, &#39;e&#39;): &#39;like&#39;, (&#39;t&#39;, &#39;o&#39;): &#39;to&#39;, (&#39;e&#39;, &#39;at&#39;): &#39;eat&#39;, (&#39;a&#39;, &#39;p&#39;): &#39;ap&#39;, (&#39;ap&#39;, &#39;p&#39;): &#39;app&#39;, (&#39;app&#39;, &#39;l&#39;): &#39;appl&#39;, (&#39;appl&#39;, &#39;e&#39;): &#39;apple&#39;, (&#39;apple&#39;, &#39;s&#39;): &#39;apples&#39;, (&#39;S&#39;, &#39;h&#39;): &#39;Sh&#39;, (&#39;Sh&#39;, &#39;e&#39;): &#39;She&#39;, (&#39;h&#39;, &#39;a&#39;): &#39;ha&#39;}
- vocab: [&#39;I&#39;, &#39;S&#39;, &#39;a&#39;, &#39;c&#39;, &#39;e&#39;, &#39;g&#39;, &#39;h&#39;, &#39;i&#39;, &#39;k&#39;, &#39;l&#39;, &#39;o&#39;, &#39;p&#39;, &#39;r&#39;, &#39;s&#39;, &#39;t&#39;, &#39;u&#39;, &#39;v&#39;, &#39;y&#39;, &#39;不&#39;, &#39;他&#39;, &#39;吃&#39;, &#39;喜&#39;, &#39;我&#39;, &#39;果&#39;, &#39;欢&#39;, &#39;派&#39;, &#39;苹&#39;, &#39;喜欢&#39;, &#39;苹果&#39;, &#39;at&#39;, &#39;cu&#39;, &#39;cut&#39;, &#39;cute&#39;, &#39;yo&#39;, &#39;you&#39;, &#39;ve&#39;, &#39;苹果派&#39;, &#39;li&#39;, &#39;lik&#39;, &#39;like&#39;, &#39;to&#39;, &#39;eat&#39;, &#39;ap&#39;, &#39;app&#39;, &#39;appl&#39;, &#39;apple&#39;, &#39;apples&#39;, &#39;Sh&#39;, &#39;She&#39;, &#39;ha&#39;]
复制代码 再加上一些特殊词汇和其他词汇:- all_vocab = vocab + [&#34;[PAD]&#34;, &#34;[UNK]&#34;, &#34;[BOS]&#34;, &#34;[EOS]&#34;] + other_alphabet
复制代码 上述就是BPE的代码实现,BPE理论上还是会出现OOV的,当词汇表的大小受限时,一些较少频繁出现的子词和没有在训练过程中见过的子词,就会无法进入词汇表出现OOV,而Byte-level BPE(BBPE)理论上是不会出现这个情况的。
3.3 Byte-level BPE(BBPE)
基础知识:
Unicode: Unicode 是一种字符集,旨在涵盖地球上几乎所有的书写系统和字符。它为每个字符分配了一个唯一的代码点(code point)用于标识字符。Unicode 不关注字符在计算机内部的具体表示方式,而只是提供了一种字符到代码点的映射。Unicode 的出现解决了字符集的碎片化问题,使得不同的语言和字符能够在一个共同的标准下共存。然而,Unicode 并没有规定如何在计算机内存中存储和传输这些字符。
UTF-8: UTF-8(Unicode Transformation Format-8)是一种变长的字符编码方案,它将 Unicode 中的代码点转换为字节序列。UTF-8 的一个重要特点是它是向后兼容 ASCII 的,这意味着标准的 ASCII 字符在 UTF-8 中使用相同的字节表示,从而确保现有的 ASCII 文本可以无缝地与 UTF-8 共存。在 UTF-8 编码中,字符的表示长度可以是1到4个字节,不同范围的 Unicode 代码点使用不同长度的字节序列表示,这样可以高效地表示整个 Unicode 字符集。UTF-8 的编码规则是:
- 单字节字符(ASCII 范围内的字符)使用一个字节表示,保持与 ASCII 编码的兼容性。
- 带有更高代码点的字符使用多个字节表示。UTF-8 使用特定的字节序列来指示一个字符所需的字节数,以及字符的实际数据。
例如,英文字母 &#34;A&#34; 的 Unicode 代码点是U+0041,在 UTF-8 中表示为 0x41(与 ASCII 编码相同);而中文汉字 &#34;你&#34; 的 Unicode 代码点是U+4F60,在 UTF-8 中表示为0xE4 0xBD 0xA0三个字节的序列。
所以简单的来说:
- Unicode 是字符集,为每个字符分配唯一的代码点。
- UTF-8 是一种基于 Unicode 的字符编码方式,用于在计算机中存储和传输字符。
Byte(字节):计算机存储和数据处理时,字节是最小的单位。一个字节包含8个(Bit)二进制位,每个位可以是0或1,每位的不同排列和组合可以表示不同的数据,所以一个字节能表示的范围是256个。
言归正传:
Byte-level BPE(BBPE)和Byte-Pair Encoding (BPE)区别就是BPE是最小词汇是字符级别,而BBPE是字节级别的,通过UTF-8的编码方式这一个字节的256的范围,理论上可以表示这个世界上的所有字符。
所以实现的步骤和BPE就是实现的粒度不一样,其他的都是一样的。
- 初始词表:构建初始词表,包含一个字节的所有表示(256)。
- 构建频率统计:统计所有子词单元对(两个连续的子词)在文本中的出现频率。
- 合并频率最高的子词对:选择出现频率最高的子词对,将它们合并成一个新的子词单元,并更新词汇表。
- 重复合并步骤:不断重复步骤 2 和步骤 3,直到达到预定的词汇表大小、合并次数,或者直到不再有有意义的合并(即,进一步合并不会显著提高词汇表的效益)。
- 分词:使用最终得到的词汇表对文本进行分词。
简单代码实现,不做赘述,读者朋友们可以自己实现一下- from collections import defaultdict
- sentences = [
- &#34;我&#34;,
- &#34;喜欢&#34;,
- &#34;吃&#34;,
- &#34;苹果&#34;,
- &#34;他&#34;,
- &#34;不&#34;,
- &#34;喜欢&#34;,
- &#34;吃&#34;,
- &#34;苹果派&#34;,
- &#34;I like to eat apples&#34;,
- &#34;She has a cute cat&#34;,
- &#34;you are very cute&#34;,
- &#34;give you a hug&#34;,
- ]
- # 构建初始词汇表,包含一个字节的256个表示
- initial_vocab = [bytes([byte]) for byte in range(256)]
- vocab = initial_vocab.copy()
- print(&#34;initial_vocab:&#34;, initial_vocab)
- # 构建频率统计
- def build_stats(sentences):
- stats = defaultdict(int)
- for sentence in sentences:
- symbols = sentence.split()
- for symbol in symbols:
- stats[symbol.encode(&#34;utf-8&#34;)] += 1
- return stats
- stats = build_stats(sentences)
- splits = {word: [byte for byte in word] for word in stats.keys()}
- def compute_pair_freqs(splits):
- pair_freqs = defaultdict(int)
- for word, freq in stats.items():
- split = splits[word]
- if len(split) == 1:
- continue
- for i in range(len(split) - 1):
- pair = (split[i], split[i + 1])
- pair_freqs[pair] += freq
- return pair_freqs
- pair_freqs = compute_pair_freqs(splits)
- def merge_pair(pair, splits):
- merged_byte = bytes(pair)
- for word in stats:
- split = splits[word]
- if len(split) == 1:
- continue
- i = 0
- while i < len(split) - 1:
- if split[i:i+2] == pair: # 检查分割中是否有这对字节
- split = split[:i] + [merged_byte] + split[i + 2 :]
- else:
- i += 1
- splits[word] = split
- return splits
- vocab_size = 50
- while len(vocab) < vocab_size:
- pair_freqs = compute_pair_freqs(splits)
- best_pair = ()
- max_freq = None
- for pair, freq in pair_freqs.items():
- if max_freq is None or max_freq < freq:
- best_pair = pair
- max_freq = freq
- splits = merge_pair(best_pair, splits)
- merged_byte = bytes(best_pair)
- print(&#34;vocab:&#34;, vocab)
复制代码 着重解释一下为什么Byte-level BPE(BBPE)不会出现OOV问题,初始的词表里有256个表示如下:- [b&#39;\x00&#39;, b&#39;\x01&#39;, b&#39;\x02&#39;, b&#39;\x03&#39;, b&#39;\x04&#39;, b&#39;\x05&#39;, b&#39;\x06&#39;, b&#39;\x07&#39;, b&#39;\x08&#39;, b&#39;\t&#39;, b&#39;\n&#39;, b&#39;\x0b&#39;, b&#39;\x0c&#39;, b&#39;\r&#39;, b&#39;\x0e&#39;, b&#39;\x0f&#39;, b&#39;\x10&#39;, b&#39;\x11&#39;, b&#39;\x12&#39;, b&#39;\x13&#39;, b&#39;\x14&#39;, b&#39;\x15&#39;, b&#39;\x16&#39;, b&#39;\x17&#39;, b&#39;\x18&#39;, b&#39;\x19&#39;, b&#39;\x1a&#39;, b&#39;\x1b&#39;, b&#39;\x1c&#39;, b&#39;\x1d&#39;, b&#39;\x1e&#39;, b&#39;\x1f&#39;, b&#39; &#39;, b&#39;!&#39;, b&#39;&#34;&#39;, b&#39;#&#39;, b&#39;$&#39;, b&#39;%&#39;, b&#39;&&#39;, b&#34;&#39;&#34;, b&#39;(&#39;, b&#39;)&#39;, b&#39;*&#39;, b&#39;+&#39;, b&#39;,&#39;, b&#39;-&#39;, b&#39;.&#39;, b&#39;/&#39;, b&#39;0&#39;, b&#39;1&#39;, b&#39;2&#39;, b&#39;3&#39;, b&#39;4&#39;, b&#39;5&#39;, b&#39;6&#39;, b&#39;7&#39;, b&#39;8&#39;, b&#39;9&#39;, b&#39;:&#39;, b&#39;;&#39;, b&#39;<&#39;, b&#39;=&#39;, b&#39;>&#39;, b&#39;?&#39;, b&#39;@&#39;, b&#39;A&#39;, b&#39;B&#39;, b&#39;C&#39;, b&#39;D&#39;, b&#39;E&#39;, b&#39;F&#39;, b&#39;G&#39;, b&#39;H&#39;, b&#39;I&#39;, b&#39;J&#39;, b&#39;K&#39;, b&#39;L&#39;, b&#39;M&#39;, b&#39;N&#39;, b&#39;O&#39;, b&#39;P&#39;, b&#39;Q&#39;, b&#39;R&#39;, b&#39;S&#39;, b&#39;T&#39;, b&#39;U&#39;, b&#39;V&#39;, b&#39;W&#39;, b&#39;X&#39;, b&#39;Y&#39;, b&#39;Z&#39;, b&#39;[&#39;, b&#39;\\&#39;, b&#39;]&#39;, b&#39;^&#39;, b&#39;_&#39;, b&#39;`&#39;, b&#39;a&#39;, b&#39;b&#39;, b&#39;c&#39;, b&#39;d&#39;, b&#39;e&#39;, b&#39;f&#39;, b&#39;g&#39;, b&#39;h&#39;, b&#39;i&#39;, b&#39;j&#39;, b&#39;k&#39;, b&#39;l&#39;, b&#39;m&#39;, b&#39;n&#39;, b&#39;o&#39;, b&#39;p&#39;, b&#39;q&#39;, b&#39;r&#39;, b&#39;s&#39;, b&#39;t&#39;, b&#39;u&#39;, b&#39;v&#39;, b&#39;w&#39;, b&#39;x&#39;, b&#39;y&#39;, b&#39;z&#39;, b&#39;{&#39;, b&#39;|&#39;, b&#39;}&#39;, b&#39;~&#39;, b&#39;\x7f&#39;, b&#39;\x80&#39;, b&#39;\x81&#39;, b&#39;\x82&#39;, b&#39;\x83&#39;, b&#39;\x84&#39;, b&#39;\x85&#39;, b&#39;\x86&#39;, b&#39;\x87&#39;, b&#39;\x88&#39;, b&#39;\x89&#39;, b&#39;\x8a&#39;, b&#39;\x8b&#39;, b&#39;\x8c&#39;, b&#39;\x8d&#39;, b&#39;\x8e&#39;, b&#39;\x8f&#39;, b&#39;\x90&#39;, b&#39;\x91&#39;, b&#39;\x92&#39;, b&#39;\x93&#39;, b&#39;\x94&#39;, b&#39;\x95&#39;, b&#39;\x96&#39;, b&#39;\x97&#39;, b&#39;\x98&#39;, b&#39;\x99&#39;, b&#39;\x9a&#39;, b&#39;\x9b&#39;, b&#39;\x9c&#39;, b&#39;\x9d&#39;, b&#39;\x9e&#39;, b&#39;\x9f&#39;, b&#39;\xa0&#39;, b&#39;\xa1&#39;, b&#39;\xa2&#39;, b&#39;\xa3&#39;, b&#39;\xa4&#39;, b&#39;\xa5&#39;, b&#39;\xa6&#39;, b&#39;\xa7&#39;, b&#39;\xa8&#39;, b&#39;\xa9&#39;, b&#39;\xaa&#39;, b&#39;\xab&#39;, b&#39;\xac&#39;, b&#39;\xad&#39;, b&#39;\xae&#39;, b&#39;\xaf&#39;, b&#39;\xb0&#39;, b&#39;\xb1&#39;, b&#39;\xb2&#39;, b&#39;\xb3&#39;, b&#39;\xb4&#39;, b&#39;\xb5&#39;, b&#39;\xb6&#39;, b&#39;\xb7&#39;, b&#39;\xb8&#39;, b&#39;\xb9&#39;, b&#39;\xba&#39;, b&#39;\xbb&#39;, b&#39;\xbc&#39;, b&#39;\xbd&#39;, b&#39;\xbe&#39;, b&#39;\xbf&#39;, b&#39;\xc0&#39;, b&#39;\xc1&#39;, b&#39;\xc2&#39;, b&#39;\xc3&#39;, b&#39;\xc4&#39;, b&#39;\xc5&#39;, b&#39;\xc6&#39;, b&#39;\xc7&#39;, b&#39;\xc8&#39;, b&#39;\xc9&#39;, b&#39;\xca&#39;, b&#39;\xcb&#39;, b&#39;\xcc&#39;, b&#39;\xcd&#39;, b&#39;\xce&#39;, b&#39;\xcf&#39;, b&#39;\xd0&#39;, b&#39;\xd1&#39;, b&#39;\xd2&#39;, b&#39;\xd3&#39;, b&#39;\xd4&#39;, b&#39;\xd5&#39;, b&#39;\xd6&#39;, b&#39;\xd7&#39;, b&#39;\xd8&#39;, b&#39;\xd9&#39;, b&#39;\xda&#39;, b&#39;\xdb&#39;, b&#39;\xdc&#39;, b&#39;\xdd&#39;, b&#39;\xde&#39;, b&#39;\xdf&#39;, b&#39;\xe0&#39;, b&#39;\xe1&#39;, b&#39;\xe2&#39;, b&#39;\xe3&#39;, b&#39;\xe4&#39;, b&#39;\xe5&#39;, b&#39;\xe6&#39;, b&#39;\xe7&#39;, b&#39;\xe8&#39;, b&#39;\xe9&#39;, b&#39;\xea&#39;, b&#39;\xeb&#39;, b&#39;\xec&#39;, b&#39;\xed&#39;, b&#39;\xee&#39;, b&#39;\xef&#39;, b&#39;\xf0&#39;, b&#39;\xf1&#39;, b&#39;\xf2&#39;, b&#39;\xf3&#39;, b&#39;\xf4&#39;, b&#39;\xf5&#39;, b&#39;\xf6&#39;, b&#39;\xf7&#39;, b&#39;\xf8&#39;, b&#39;\xf9&#39;, b&#39;\xfa&#39;, b&#39;\xfb&#39;, b&#39;\xfc&#39;, b&#39;\xfd&#39;, b&#39;\xfe&#39;, b&#39;\xff&#39;]
复制代码 通过上述的方式其实是在一直根据训练语料循环迭代合成子词或者词,最后形成词表,比如“苹果”通过UTF-8进行编码后为“\xe8\x8b\xb9\xe6\x9e\x9c”,如果词表里面有,那“苹果”就通过词表映射成了1个表示,准确来说是1个token;如果词表里没有,那就用256中的“\xe8+\x8b+\xb9+\xe6+\x9e+\x9c”来表示“苹果”这个词,那就是6个token。在先前的各种分词方法中,如果词典里没有”苹果“这个词,也没有”苹“,”果“这样的子词的话,那就变成了[UNK]。所以在现在的大模型中,以Byte-level BPE(BBPE)这种方式进行分词是不会出现OOV,但词表中如果没有word级别的词的话,一些中英文就会分词分的很细碎,比如Llama在中文上就会把一些词分成多个token其实就是UTF-8后的中文编码,对编码效率以及语义会有影响,于是出现了一些扩充Llama中文词表的工作。
上述分词算法在工程上实现一般使用sentencpiece工具包[6],谷歌在这个包中实现了上述的一系列算法,扩充Llama中文词表的工作也都是在此上面实现的。后续我也会写一篇文章进行详细的讲解。欢迎关注~
其他文章:
Glan格蓝:LLM大语言模型Generate/Inference生成/推理参数与解码策略原理及其代码实现 |
|