Lec 1: Tokenization

语言模型的本质是对token序列做建模

\[ p(x_1, x_2, \dots, x_T) \] 其中\(x_T\)代表的是一个整数token id,我们还需要有一个抽象接口做双向映射,这个接口就是Tokenizer

  • encode(str) -> List[int]
  • decode(List[int]) -> str

Tradeoffs:

  • Volcabulary size is big: 序列短,但有可能稀有 token 学不到、embedding 巨大。
  • Volcabulary size is small: 泛化好,参数省,但是序列长导致Transformer attention \(O(T^2)\) 很贵

Q&A:

  1. 为什么词表大 ≠ “更好学”,反而有“稀有 token 学不到”的问题?

    如果词表里包含很罕见的整词一个 token:`“supercalifragilisticexpialidocious”,它在训练里可能只出现 1 次或几次,那它对应的 embedding/输出权重基本没训练到,这个时候如果用更小的词表,这个长词会被拆成多个更常见的子词/字符组合,能学的更好。

  2. 为什么词表大会导致embedding整体变大?

    这个主要是因为输出的logits的形状大小是 \(\text{Vocab_size} \times \text{Hidden_size}\), 词表变大,这一块的参数量翻倍,代价更大

Compression Ratio

1
2
3
num_bytes = len(string.encode("utf-8"))
indices = tokenizer(string)
num_tokens = len(indices)

\[ \text{compression_ratio} = \frac{\text{num_bytes}}{\text{num_tokens}} \] 该值越大表明每个token能够表示更多的字节,序列更短,信息的压缩程度越高。

Character Tokenizer

核心思想是把字符串拆成Unicode字符,每个字符用 ord() 变成整数。decodechr() 拼回去。

1
2
encode: list(map(ord, string))
decode: "".join(map(chr, indices))

优点是简单、可逆,缺点是词表非常大(Unicode 大约 150K+),稀有字符非常多,浪费词表的容量

Byte Tokenizer

核心思想就是字符串转 UTF-8 bytes,每个 byte 是 0~255。词表固定 256

1
2
3
string_bytes = string.encode("utf-8")
indices = list(map(int, string_bytes))
decode: bytes(indices).decode("utf-8")

Compression_ratio恒等于1

优点是词表很小,且完全可逆,但是序列太长,对于注意力机制来说是灾难。

Word Tokenizer

核心思想是用正则把文本切成segments,然后给每个 segment 分配一个 id

优点是序列会更短,缺点是词表的大小不固定,还有可能会爆炸,新词需要 UNK(会影响 perplexity & 泛化体验)

BPE Tokenizer

在“byte 的小词表”和“word 的短序列”之间找平衡:

  • 常见片段合并成一个 token(序列变短)
  • 罕见片段仍可退化成多个 byte

执行步骤,从byte开始,重复做num_merges次:

  1. 统计当前序列里所有相邻 pair 的出现次数
  2. 找出现最多的 pair
  3. 把它合并成新 token(新 id = 256 + i)
  4. 更新 vocab:vocab[new] = vocab[a] + vocab[b]
  5. 在序列里把所有该 pair 替换掉

Encode/Decode

  • encode:从 bytes 开始,按 merges 顺序把能合的 pair 合掉
  • decode:把每个 token id 映射回 bytes,再拼起来 decode utf-8

优点是词表可控(256 + num_merges),序列变短,缺点是朴素的encode会非常慢

Reference

Lecture Material