Skip to content

机器学习笔记: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)的集合。

参数区别

vocab用于decode,merges用于encode。

>>> 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用于encode
  • encode:对一个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

参考链接


  1. 长尾信息往往是信息量更大的,比如用户画像中的尾部兴趣标签比热点标签有价值,垂直app的安装信息也更有价值,但是如果加入到词表中,学习不充分也很难学好向量,丢弃又有点可惜,是个矛盾点。 

Comments