茴香豆的“茴”字有几种写法,你知道么?读完这篇笔记你就知道了。本文的写作尽量做到面向所有读者,不论技术背景。
本文的三个小节按照从通用原理到特殊应用的方式排列。
计算机是如何储存文字的
这是一个基础的问题。以最朴素的 ASCII 码为例:字符 ‘A’ 可以映射到码位 65,再以8位无符号整数的方法存储,得到字节 0x41 (对应二进制表示:00100001)。我们可以提取出计算机存储文字的通用的流程:
文字 (字符,Character) -> 自然数(码位,Codepoint)-> 字节流
ASCII 一共有 128 个码位,远远不足以对应世界上的字符。为了存储和表示更多字符,一般使用 Unicode 标准。
从字符到码位
Unicode 标准定义了一个世界上诸多字符到 [0, 17*2^16) 这些自然数的映射。这个码位范围按照 Unicode 标记标准,应该书写为 U+0000 ~ U+10FFFF (该标准规定标记为 U+ 后面跟十六进制数,该数字不足四位时在前面补 0 到四位)。
每组 2^16 连续的码点被称为一个平面,共有 17 个平面。0 号平面是基本多语言平面,包含了常用的文字。
实验:用 Python 转换字符和 Unicode 码位。(后面的代码默认使用 Python,用 “>>>” 表示在交互式解释器 REPL 中执行)
>>> ord('字')
23383
>>> chr(23383)
'字'
从码位到字节流
码位转换为字节流的流程可以有诸多。比如,我们当然可以用 3 个字节表示 Unicode 的每个码点。然而,这样并不一定高效,因为有些码点(字符)总是比其它字符出现得更频繁,如果能在不引起歧义的情况下用更少的字节表示它,显然有利于文字信息在计算机中的存储和传输。
这是信息论中的源编码问题(Source Coding),期望码位长度是与输入字符的分布相关的。显然,我们希望出现频繁的字符编码更短。这是不同的国家地区使用不同编码标准的内在原因。举例而言,中文字符在英语国家中不常见,所以它在 UTF-8 编码标准中需要 3 个字节,而在中国的 GB 2312 编码标准中就只需要 2 个字节。
然而,为了计算机的互通性,势必要约定世界范围内互通的标准,UTF-8 就是其中之一。
从码位到字节流:UTF-8 编码器
UTF-8 既可以指编码标准(Character Coding Standard),也可以指这个标准的实现也就是编码器(codec)。它是 Unicode 标准的一部分。具体而言,它将每个 Unicode 码用 1~4 个字节表示,用以下规则转换:
First code point | Last code point | Byte 1 | Byte 2 | Byte 3 | Byte 4 |
---|---|---|---|---|---|
U+0000 | U+007F | 0xxxxxxx | |||
U+0080 | U+07FF | 110xxxxx | 10xxxxxx | ||
U+0800 | U+FFFF | 1110xxxx | 10xxxxxx | 10xxxxxx | |
U+10000 | U+10FFFF | 11110xxx | 10xxxxxx | 10xxxxxx | 10xxxxxx |
每个 Unicode 码到底要用几个字节,取决于码点范围:
- 用一个字节表示的,共 128 个码位,恰好与 ASCII 标准的 128 个码位相对应,所以与 ASCII 标准后向兼容
- 用两个字节表示的码点总体而言是拉丁字母。
- 三个字节以内的码点范围(U+0000 to U+FFFF)是基本多语言平面中的所有字符。中日韩文字需要用三个字节表示。
从这张表中,我们还可以获得如下“肉眼解码 UTF-8” 的技巧:
- 16进制以 8,9,A,B 开头的字节是”接续字节”,其余情况是“起始字节”
- 起始字节 0~7 是 ASCII 码,C,D 代表两个字节,E 代表三个字节,F 代表四个字节
- 例如,中日韩文字的 UTF-8 编码形如
\xe?\{8,9,a,b}?\{8,9,a,b}?
从这张表中,我们还可以验证 UTF-8 编码是前缀码 (prefix code)。这个良好的性质保证,当我们把很多字符的编码拼接在一起,形成一个字节流,也可以通过从前向后线性扫描一次,解码出所有字符。例如:
>>> "哥德尔 Gödel".encode('utf-8').hex()
'e593a5e5beb7e5b0942047c3b664656c'
# e5 93 a5 哥
# e5 be b7 德
# e5 b0 94 尔
# 20 <空格>
# 47 G
# c3 b6 ö
# 64 d
# 65 e
# 6c l
我们手动演算其中字符 ‘ö’ 的编码:
Code point for ö is U+00F6, falls in the second category
=> use the 110xxxxx 10xxxxxx format, need 11 x's
=> 00F6 is 00011 110110
=> the two bytes are 11000011 10110110 = C3 B6
该用什么解码器?
面对文件或者网络流中的字节流,该用什么解码器来解码?显然是与编码器为同一个标准的解码器。编码、解码的标准不一致,就会发生乱码或错误:
>>> '你好'.encode('utf-8')
b'\xe4\xbd\xa0\xe5\xa5\xbd'
>>> b'\xe4\xbd\xa0\xe5\xa5\xbd'.decode('gbk')
'浣犲ソ'
>>> '你好'.encode('gbk')
b'\xc4\xe3\xba\xc3'
>>> b'\xc4\xe3\xba\xc3'.decode('utf-8')
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc4 in position 0: invalid continuation byte
我们可能需要通过外部信息来判断编码标准,如文件其它位置的说明。
我们也可以通过字节流本身来猜测编码器:上面我们已经讨论了 UTF-8 编码器编码出的字节流的特点,不同编码器输出的字节流一定有一些区分特征。很多文本编辑器都内置了这一功能,在你打开文件时就能够猜测一个编码标准用以解码。
ASCII 中的控制字符
了解了通用的文字编码方法,我们现在感兴趣 ASCII 中的若干控制字符(Control Characters),也就是不会被打印出的字符。经常使用终端,就会遇见它们。一些示例的控制字符如下:
^ | C0 | Abbr | Name | Effect |
---|---|---|---|---|
^J | 0x0A | LF | Line Feed | Moves to next line. |
^M | 0x0D | CR | Carriage Return | Moves the cursor to column zero. |
^[ | 0x1B | ESC | Escape | Starts all the escape sequences |
表格第一列是脱字符表示法(Caret Notation),本意是在键盘上通过按住 Ctrl(对应^) + 大写字母来输入不可见字符。(不过好像这个表示法和 Ctrl + C 等快捷键有冲突?)。总之,这一传统在 Unix Shell 中有所保留:
- 可以用 ^C (end of text) 来终端一个运行中的进程
- 可以用 ^D 来退出 shell 或者表明输入结束(标准输入的结束。例如 C System Call
read(0,buffer,sizeof(buffer));
在收到 ^D 后就会停止读取标准输入)
表中的 0x1B (ESC) 具有特殊地位,能够开启转义序列 (Escape Sequence):大意就是从 ESC 之后的若干字符不能按字面义理解,而要特殊解读。
ANSI 转义序列中的 CSI 命令
ANSI 转义序列标准被很多类 Unix 系统的终端采用。这个标准中,CSI 命令(Control Sequence Introducer Commands)是以 ESC [
开头的转义序列,最为常用。在编程语言源代码中,这个前缀经常写作 \e[
或者 \033[
。
在这里枚举一些在使用终端时有用或者有趣的 CSI 命令:
echo -e '\e[?25h'
显示光标echo -e '\e[?25l'
隐藏光标echo -e '\e[31;46mxx'
显示彩色的 ‘xx’
Trivia
异体字的 Unicode 编码
不同东亚书写系统的“字”,很多字形之间只有微小的差异,且语言学上共通。“异体字”描述的也是相似的现象。这种现象在计算机中也有对应:
戶 户 戸
这种现象一般分为两种情况,
- 可能是因为页面上字符的 Unicode 本身就不同
- 也可能是因为你使用的软件的功劳(如浏览器、MS Word 等有字体、格式功能),而字符的 Unicode 是相同的(可以通过粘贴到命令行终端中验证)
对于异体字,历史上有些地区将这些异体字分别编码,所以有些异体字在 Unicode 中也是分别编码的。例如,“户”的三种异体字被分别编码:
>>> chr(0x6236)
'戶'
>>> chr(0x6237)
'户'
>>> chr(0x6238)
'戸'
Unicode 与 Emoji
【注:实际渲染出的 Emoji 很可能因阅读本文所用的软件平台而异】
Unicode 支持 Emoji:
>>> chr(128514)
'😂'
这并不稀奇,有趣的是 Unicode 定义了 Emoji 的“合成“规则。
两个字符定义一个 Emoji
base_emoji = '\U0001F926'
print("Base Face Palm Emoji:", base_emoji)
for i in range(4):
skin_tone_modifier = chr(ord('\U0001F3FC')+i)
grinning_face_with_skin_tone = base_emoji + skin_tone_modifier
print("Skin Tone Modifier:", skin_tone_modifier)
print("Combined Emoji with Skin Tone Modifier:", grinning_face_with_skin_tone)
输出如下
Base Face Palm Emoji: 🤦
Skin Tone Modifier: 🏼 Combined Emoji with Skin Tone Modifier: 🤦🏼
Skin Tone Modifier: 🏽 Combined Emoji with Skin Tone Modifier: 🤦🏽
Skin Tone Modifier: 🏾 Combined Emoji with Skin Tone Modifier: 🤦🏾
Skin Tone Modifier: 🏿 Combined Emoji with Skin Tone Modifier: 🤦🏿
通过 ZWJ 衔接
woman_emoji = '\U0001F469' # Woman
man_emoji = '\U0001F468' # Man
girl_emoji = '\U0001F467' # Girl
zwj = '\U0000200D'
family_emoji = woman_emoji + zwj + man_emoji + zwj + girl_emoji
print("Family Emoji:", family_emoji)
输出如下:👩👨👧
【2025年5月2日 更新】番外:语言模型是如何表示文字的
我们可以想象一种最朴素的办法:每个码位对应一个 token。这样,一个纯英文的模型只需要 ~100 量级的 token 就可以表示它需要输入和输出的语料。
不过,如果我们希望多语言输入输出呢?以汉字为例,我们可以只包含不那么罕见的汉字,需要 ~10000 个 token(不被包含的汉字输入会被变成 <unk>)。但在这个语言模型中,这 10000 个汉字的使用频率是完全不同的,比如 3000 常用字和剩余的汉字出现频率完全不同,然而它们都占用了 embedding matrix 的一行,产生计算和存储的开销。更进一步,如果我们英汉混合输出,那么两次预测可以输出一个中文词汇,然而同样信息量用英文单词输出可能需要 ~10 个字母,也就是十次预测,英文输出的效率太低。
Byte-pair Encoding (BPE)
凭借“预测次数与信息量相匹配”的直觉,我们把高频率一起出现的 token 合并为新的 token,用字母表大小换 inference 效率。这就是 BPE (Byte-pair Encoding),可以简单理解为从字母和基本符号出发,逐步构建出一个包含字词 “subword” 的单词表。GPT2 的字母表大小 “50257” 就是 256 个单字节基本符号,加上 50000 个 “subword”,加上特殊 token 构成的。
可以在 Tiktokenizer 用 tokenizer 做实验:
This is UTF-8
“This”, ” is”, ” UTF”, “-“, “8”
1212, 318, 41002, 12, 23
可以看到一个 token 对应多个英文字母,英文输出的效率不用愁
Byte level BPE
上面的方法仍未解决多语言输入中出现未知 token 的问题:无论你收录多少语言中的字符,总有你未收录的字符,除非你穷尽了基本多语言平面(2^16=65535)(那么还有 emoji?)。一个“以不变应万变”的方法是将所有输入用 UTF-8 编码为字节流,然后以一个字节的 256 种码位作为最基本单元,应用 BPE 计算 subword,输入模型。这样,模型的输入就不会出现未知字符了。
这个方法的问题在于,模型输出的 tokens 转换为字节流之后,不一定是合法的 UTF-8 字节(位置、数目错误的接续字节)。这种情况,在解码时需要特殊处理,如尝试纠错或忽视:
decoded_text = byte_sequence.decode('utf-8', errors='replace')
# 用这个字符 "�" (literally U+FFFD 这个被称为 "replacement character" 的字符) 取代非法序列
不过这么说来,如果训练 BPE 时中文预料占比不足,频率较低,subword 可能不会将中文字符的 UFT-8 字节拼回一起,这样模型输出中文的效率就会很低下(最坏情况下每个中文字符用 3 个字节/token 表示)。
对比 GPT2 和 Deepseek-R1 的 tokenizer,可以看出 Deepseek 对于中文做了很多优化:
计算机和语言模型的字符编码
GPT2: 164, 106, 94, 163, 106, 245, 17312, 118, 161, 240, 234, 46237, 255, 164, 101, 222, 162, 101, 94, 161, 252, 233, 21410, 27764, 245, 163, 105, 99, 163, 120, 244, 163, 254, 223
Deepseek-R1: 11766, 548, 7831, 52727, 15019, 26263
Leave a Reply