这节课介绍了分词器 Tokenization 的作用和分类

Tokenization

intro_to_tokenization

分词化就是获取通常表示为 Unicode 字符串的原始文本,并将其本质上转化为一组整数,每个整数代表一个 token

我们需要一个程序 Tokenizer,可以将字符串编码为 token,再将它们解码回字符串

词汇表大小就是 token 值的数量,也就是整数范围的数量

注意点:空格也是 token 的一部分

压缩率 = 原始字节数 / 编码后的 token 数

character_tokenizer

基于字符的分词,一个 Unicode 字符串就是一个 Unicode 字符序列,并且每个字符都可以转化为一个整数,称为码点(code point)

这种分词方法的问题:词汇表非常大(基本上是为每个字符平均分配一个槽位),而有些字符出现频率远高与其他字符,对"预算"的利用不是很有效

# 每个字符都可以转化为一个码点,通过 `ord`
assert ord("a") == 97
assert ord("🌍") == 127757

# 还可以通过 `chr` 转化回去
assert chr(97) == "a"
assert chr(127757) == "🌍"

byte_tokenizer

Unicode 字符串可以用字节序列来表示,因为每个字符串都可以直接转化为字节

转化为字节后,所有的索引值都在 0~255 之间,因为一个字节一共有 2^8=256 个可能的值

词汇表较小,但是压缩比为 1,效率很低

# 一些 Unicode 字符用一个字节表示(b 表示 byte)
assert bytes("a", encoding="utf-8") == b"a"

# 其他的字符需要多个字节表示
assert bytes("🌍", encoding="utf-8") == b"\xf0\x9f\x8c\x8d"

word_tokenizer

既然我们不能给每一个 token 分配一个字符或一个字节,也许有些 token 可以代表很多字节,有些 token 可以代表很少字节

于是想到将字符串分割成一个片段序列,给每个 token 分配一个整数

问题在于不知道词汇表的上限,对于一个新的输入,就可能得到一个以前从未见过的片段,而新词必须获得 UNK 标记

bpe_tokenizer

基本思想:不是预先设定好如何分割的概念,而是在原始文本上训练分词器

越常见的字符序列尝试表示为一个 token,罕见的序列表示为多个 token

BPE算法:先将字符串转换为字节序列,然后重复地、连续地合并最常见的相邻 token 对(并在词汇表中加入,从 256 开始)

def train_bpe(string: str, num_merges: int) -> BPETokenizerParams:  

    indices = list(map(int, string.encode("utf-8")))  
    merges: dict[tuple[int, int], int] = {}  
    vocab: dict[int, bytes] = {x: bytes([x]) for x in range(256)}  

    for i in range(num_merges):

        # 计数        
        counts = defaultdict(int)
        for index1, index2 in zip(indices, indices[1:]):  
            counts[(index1, index2)] += 1  

        # 查找
        pair = max(counts, key=counts.get)  
        index1, index2 = pair

        # 合并
        new_index = 256 + i  
        merges[pair] = new_index  
        vocab[new_index] = vocab[index1] + vocab[index2]  
        indices = merge(indices, pair, new_index)  

    return BPETokenizerParams(vocab=vocab, merges=merges)

更多推荐