机器学习笔记:LLM的中文分词¶
在word2vec时代,基本上就是先jieba.cut
,把中文切成token。后面再接w2v或者fasttext。但是第一次接触transformer的工具时,发现并不需要预先进行jieba.cut
,因此速度也加快了不少。借此机会想再研究下,到底是为什么能跳过。
1、理论部分¶
从早期NLP时代过来的人知道,英文一般是word based,词表会很大,动辄几十万都未必够,需要限制vocab的大小;但这么做就会损失长尾信息1,就像用户画像中的id类标签或者app编码遇到的问题一样。中文理论上可以直接character based切分成单字,这样词表大小就是有限的汉字数了,但模型需要在内部学习词组的含义,对模型要求更高。
之前做标题党识别的时候,我的方案就是jieba先分词,然后再利用fasttext的subword机制,将词的embedding分成character embedding的sum;这样就可以兼顾两种方面,
- 常用的词组可以被jieba提取出来
- 长尾的词也不会出现UNK,因为fasttext内部保底,会把Out-Of-Vocabulary的多字词组拆成单个汉字的和,比如周董拆成
Emb('周') + Emb('董')
,靠模型学习,最终增加与Emb('周杰伦')
的相似度
现在主流做法,BERT(WordPiece)和GPT(BPE)的两种分词方案,我感觉思路也是相通的,中和了word based和character based —— 都是把常用的character组合成词组,加到vocab中。这样一来,大部分长尾词,可以切成character+word的组合,减少了UNK的出现,词表的大小也可以根据需要控制到任意大小,是一种更为高效的编码方式。
具体的算法介绍,可以看Huggingface的Summary of Tokenizers,不作赘述。
总结WordPiece和BPE的主要区别
- 词表的生成方式:WordPiece is slightly different to BPE in that it evaluates what it loses by merging two symbols to ensure it’s worth it.
- BPE是byte-level的,因为是跑在UTF-8编码后的文本上的,词表都是
\x3a
这种形式
2、实践一下¶
目前BERT/GPT模型,主流使用的是huggingface的tokenizers
和openai提供的tiktoken
。
2.1 tokenizers
¶
首先看下,WordPiece和BPE在tokenizers中,分别是怎么创建和编码的。
WordPiece¶
>>> from tokenizers.models import WordPiece
>>> from tokenizers import Tokenizer
>>> vocab = {"[UNK]": 1, "抖音": 7, "抖": 8, "##平台": 9, "平台": 13}
>>> model = WordPiece(vocab, unk_token="[UNK]")
>>> tokenizer = Tokenizer(model)
>>> tokenizer.pre_tokenizer = BertPreTokenizer()
- pre_tokenizer只是根据标点和空格来切分,对于中文来说,不是难点
- vocab中定义了可能的词根,
##
前缀的表示前面 必须有其他token,没有的则表示前面 必须无token
>>> tokenizer.encode("抖音平台").tokens
['抖音', '##平台']
>>> tokenizer.encode("抖音平台是一个").tokens
['[UNK]']
>>> tokenizer.encode("平台 是一个").tokens
['平台', '[UNK]']
>>> tokenizer.encode("平台抖音").tokens
['[UNK]']
- 第2条是WordPiece对UNK的定义,只要切分后发现有一个词根不在词表中,比如这里的“是一个”,那么整体就当成UNK,而不会切成
抖音,##平台,UNK
- 第3条是说明pre_tokenizer起作用了,按照空格切分了,如果不加pre的话,那结果就是
UNK
- 第4条的原因是,词表中没有
##抖音
,而抖音
这个token要求前面无token。正是这个原因,当去看text2vec这种中文BERT模型的vocab.txt时,就会发现,大部分时候,字
和##字
都同时加进去了
其实,现在能看出来了,tokenizer的功能就是jieba的功能,只是分词算法变了。提速的原因可能是,这种方法对结果要求明显低于jieba,毕竟是给模型用的,分出“##是一个”也是可以接受的,要是结巴分成这样肯定被骂。
训练
如果vocab不是像我这样人工填的,调用下面的方式可以学出来
trainer = WordPieceTrainer(vocab_size=20)
tokenizer.train_from_iterator(document, trainer)
BPE¶
接下来是BPE的调用,需要多一个参数merges
,定义Z = X + Y
中(X, Y)
的集合。
参数区别
merges用于encode,vocab用于encode和decode
>>> from tokenizers.models import BPE
... from tokenizers import Tokenizer
... tokenizers.pre_tokenizers import WhitespaceSplit
>>> vocab = {"[UNK]": 1, "抖": 2, "音": 3, "平": 4, "台": 5, "抖音": 6, "平台": 7, "是": 8}
>>> merges = [("抖", "音"), ("平", "台")]
>>> model = BPE(vocab, merges, unk_token="[UNK]")
>>> tokenizer = Tokenizer(model)
>>> tokenizer.pre_tokenizer = WhitespaceSplit()
>>> tokenizer.encode("抖音平台是一个").tokens
['抖音', '平台', '是', '[UNK]', '[UNK]']
- 能看到UNK的定义方式和WordPiece不同,但估计充分训练后差异不大
ByteLevel¶
上面测试的其实是unicode-level的BPE算法,但最正的BPE是byte-level的,是对二进制文件进行编码,词表也是二进制bytes。
一个汉字的unicode,在存储到文件utf-8编码时是3个bytes,例如,
“抖” -> b'\xe6\x8a\x96' -> [b'\xe6', b'\x8a', b'\x96']
byte-level的BPE看待“抖”字,实际是看到了三个bytes。如果BPE统计大量文本后发现,前两个bytes总同时出现,那么就会把抖字encode成,
[b'\xe6\x8a', b'\x96'] -> [2222, 96]
两个部分,这两部分无法单独decode("utf-8")
成unicode,打开vocab文件看上去就是乱码的字符'�'
了。
那Byte-Level编码相对于unicode有什么优势吗?
Byte-Level编码优势
词表是仅b'\x00'
到b'\xff'
这 256 个字符来回组合,就可以表示所有的unicode了(就不会出现UNK)。这件事要想直接用unicode实现,那得把整个几十万的unicode单字先加到词表中才行,vocab的size将远远增加,编码效率低。
在tokenizers
中使用ByteLevel BPE
需要在pre_tokenizer中增加一个ByteLevel()
,见issue的讨论。
2.2 tiktoken
¶
这节测试下 openai.tiktoken
的实现,它是byte-level的。中文里有多大的比例会把单字拆字,有多少是完整的,我们测试一下,
def show_bpe(text):
print("="*50)
print(text)
print("="*50)
for token in enc.encode(text):
try:
print(enc.decode_single_token_bytes(token).decode("utf-8"), end="/")
except UnicodeDecodeError:
print(f"'{token}'", end="/")
print()
show_bpe(prompt[:100])
运行结果,
==================================================
# 任务说明:
我会提供一些用户的在**竞品**平台的个人简介和昵称,根据这些信息,请提取用户在微博平台的昵称。
## 注意
1. 需要的是微博昵称,而不是其他竞品的昵称,比如微信昵称、抖音昵
==================================================
/#/ /任务/说明/:
/我/会/提/供/一/些/用户/的/在/**/'25781'/'252'/品/**/平/台/的/个/人/简/介/和/'11881'/'113'/称/,/'15308'/'117'/据/这/些/信息/,请/提/取/用户/在/微/'11239'/'248'/平/台/的/'11881'/'113'/称/。
/##/ 注意/
/1/./'18630'/'250'/'222'/要/的/是/微/'11239'/'248'/'11881'/'113'/称/,/而/不/是/其他/'25781'/'252'/品/的/'11881'/'113'/称/,/比/如/微/信/'11881'/'113'/称/、/'24326'/'244'/音/'11881'/'113'/
从结果的比例上看,单字>拆字>词组。
BPE让我尤其不好理解的是,如果预测出一个没法解码的token,怎么处理呢?
我感觉答案是:预测出不可理解的token,就不处理了,只能希望模型不出这种情况。
有人在issue中也回答了,
Quote
My mandarin is not that great, byte level is using bytes, so if you have � symbol, which is the unknown utf-8 it simply means the model generated some bytes which were not valid utf-8. This is perfectly normal from tokenizers
standpoint.
For you model, you may need more training indeed, or this data is a bit out of domain. More rarely, you could have an issue in your dataset itself (for instance if it's not encoded in utf-8 and some stuff gets lost in translation).
为验证结论,人工构造了一些“抖”字附近的拆字,测试BPE是否能正常解码,
>>> enc.encode("抖音")
[24326, 244, 79785]
>>> for i in range(-5, 5):
... print(enc.decode([24326, 244 + i, 79785]))
抑音
抒音
抓音
抔音
投音
抖音
抗音
折音
抙音
抚音
>>> enc.decode([24326, 79785])
'�音'
>>> enc.decode([24326, 291, 79785])
'�ed音'
>>> for i in range(-10, 10):
... print(enc.decode([11881, 113 + i, 25666]))
昪称
昫称
昬称
昮称
是称
昰称
昱称
昲称
昳称
昴称
昵称
昶称
昷称
昸称
昹称
昺称
昻称
昼称
昽称
显称
从上面的结果中可以看到
- 确实会存在decode不出来的tokens
- BPE相比WordPiece有个特别之处,拆字后,相同部首的在unicode编码中挨着比较近,所以没准有可能发现额外的偏旁信息;不过我感觉作用也有限,大部分都不可能替换。
2.3 minbpe
¶
Andrej Karpathy写了一个tiktoken的学习版本 minbpe,代码量很小,文档注释无比详细,且逐步增加功能。非常推荐拿来做源码分析,可以理解GPT-4的tiktoken具体是怎么实现的。
总结核心要点:
- 实现三个接口,train, encode, decode
train
:vocab词表初始化为256个bytes,经过train后,生成vocab和merges,vocab用于decode,merges用于encodeencode
:对一个text进行pre_tokenize成为0-255的ids列表,统计ids中共现次数的最多的一个相邻(X, Y),按照merges的词典映射成Z,更新ids,然后重复直到停止;停止后ids列表是比之前短的decode
:直接查vocab
对齐GPT-4的细节:
- 使用regex表达式来pre_tokenize,把数字和符号切分成组,分组encode,结果再extend拼接到一起;相当于组间不merge
>>> import regex as re
>>> GPT4_SPLIT_PATTERN = r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+"""
>>> pattern = re.compile(GPT4_SPLIT_PATTERN)
# %%
>>> text = "hello123!!!? (我是谁呢?) 😉"
>>> pattern.findall(text)
['hello', '123', '!!!?', ' (', '我是谁呢', '?)', ' 😉']
- 特殊字符识别成组:使用re.split,切分成组,分组encode
>>> special = {'<|endoftext|>': 259}
>>> special_pattern = "(" + "|".join(re.escape(k) for k in special) + ")"
'(<\\|endoftext\\|>)'
>>> special_chunks = re.split(special_pattern, text)
['', '<|endoftext|>', 'hello world']
3、总结¶
回到最开始的问题,为什么BERT之后的模型都可以跳过jieba.cut
步骤了,到底是哪部分替代了jieba?
- pre_tokenizer:英文是按照标点、引号、空格、或者regex规则,将句子切分成word;但中文语法下,由于没有空格,pre_tokenizer作用不大,标点只能切成子句,所以不是它
- model:WordPiece/BPE模型,是将英文的word切分成subword的算法;在中文语法下,model才是将句子切分成词组的最关键部分,等价于
jieba.cut
参考链接¶
- Summary of Tokenizers,Huggingface的综述
-
Byte-Pair Encoding tokenization,Huggingface课程
-
Working with Chinese, Japanese, and Korean text in Generative AI pipelines
- 深入理解NLP Subword算法:BPE、WordPiece、ULM,中文介绍
- WordPiece 算法,调用实践
-
长尾信息往往是信息量更大的,比如用户画像中的尾部兴趣标签比热点标签有价值,垂直app的安装信息也更有价值,但是如果加入到词表中,学习不充分也很难学好向量,丢弃又有点可惜,是个矛盾点。 ↩