此标题已被分词 (Tokun P.2)

在机器学习中,计算机科学、数学和语言学这三个领域经常相互冲突。
每个领域都以不同的形式处理文本
- 计算机处理原始数字,如字节序列
- 数学操作张量和向量
- 而语言学则侧重于字形(字符)及其组合(单词)
分词长期以来一直被用作桥梁,将人类可读的文本转换为机器友好的格式。它依赖于像BPE这样的算法,这些算法借鉴了人类的直觉。
在我的上一篇文章中,我建议让模型本身学习从原始字节到嵌入的映射。
然而,有一种更有效的替代方案:直接使用 Unicode 作为 LLM 中嵌入的基础。
TL;DR
与其在模型外部合并编码字节(BPE等),不如在模型内部组合基本嵌入。
这可以通过对 Transformer 架构的输入和输出层进行少量更改来实现。
输入管道
输入处理如下:
- 文本使用 UTF-32-BE 编码为字节序列(值在
[0 .. 256[
中) - 每个字节使用
(256, E)
内核独立嵌入 - 字节嵌入按大小为
T
的组合并
从 UTF-32-BE 字节开始,且 T = 2

T
和 E
可以自由选择:令牌长度可以是 4、8 甚至 16。通过匹配字节的嵌入维度,T * E
被引入到模型维度,例如 4096。
由于嵌入表,字节可以被赋予独立的含义。每个字节都对最终嵌入的特定部分做出贡献。

并且整体组合模式包含有关令牌组成的信息。
输出管道
输出层可以是每个字节预测的深度为 256 的标准 Softmax。
但是,与其评估 256 种选项中的每一种,不如更高效地将值预测为一个 8 位向量。

头部激活被 Sigmoid 替换,它为每个位返回一个独立的概率。
优点
就像Tokun 的前一个迭代一样,这个方案解决了大多数分词的缺点。
此外
- 令牌长度:令牌长度可以自由选择,它现在是一个超参数
- 简单直观:无需额外的预处理或训练
- 优化(次要):输入和输出层的内核更小
- 相关性:预测与文本组成之间存在直接匹配
您将在比较部分找到更多详细信息。
特别是,最后一点具有广泛的影响:例如,数字在 Unicode 中编码为 48 + d
,因此数字表示被偏移但保留了下来。
目录
分词与古代语言
本质上,分词将单个字符(字节)合并为整体块。在这里,56 个西里尔字符被分组为 20 个词元。

LLM 只知道右侧的索引值,并失去了有关这些词元原始组成的信息。
想象一下每个数字和单词变体都有一个独特的符号,就像只用表情符号交流!
早期的书面语言,如象形文字,就是基于这种表意文字:代表整个概念的符号。然而,它们仍然有会意规则,可以从符号组合中形成细微的含义。
例如,在埃及象形文字中形成复数,你可以将一个表意文字重复三次,或者在其旁边添加 3 条横线:“house”是“𓉐”,“houses”是“𓉐 𓏪”。
相比之下,流行的分词器 o200k 有 " house" (4276)、" House" (7826)、"house" (9983)、" houses" (20327)、"House" (27796)、"-house" (46400) 等。
这种方法忽视了现代语言如何从符号组合中获取意义。
特别是,语音和位置系统允许构成单词和数字。而单词的构成提供了许多关于其含义的指示。
在前面提到的所有三个领域中,宏观元素分解为更简单的部分。对于文本,不同的尺度大致如下:
- 计算机科学:序列 → 码点 → 字节 → 位
- 数学:张量 → 轴 → 维度
- 语言学:段落 → 句子 → 单词 → 符号/字母
分词过早地中断了分解:它在计算机端的序列和码点之间停止,这在语言学上介于句子和字形之间。
为了保持组合表达能力,我们将从基本的字形重新开始。
Unicode 嵌入
在计算机上,语言单元通过 Unicode 标准转换为数字。它是通用的,拥有 161 种脚本中的 149813 个符号。
大多数数字文本都以这个标准表示,包括这个网页本身。
码点嵌入
传统的令牌化算法如BPE从Unicode开始。正如字节对编码(Byte Pair Encoding)这个名称所示,它通过将字符两两合并来生成新的索引。
o200K 的词汇表是通过在训练集中对最频繁的对重复此过程创建的。因此,o200k 中的每个索引都等同于底层的 Unicode 码点序列。
立场 | 词元 | o200k | UTF-32-BE |
---|---|---|---|
0 | M |
44 |
(77) |
1 | inds |
13834 |
(105, 110, 100, 115) |
2 | aren't |
23236 |
(32, 97, 114, 101, 110, 39, 116) |
3 | read |
1729 |
(32, 114, 101, 97, 100) |
4 | . |
13 |
(46) |
... | ... | ... | ... |
现在所有的索引都是 Unicode,没有理由保留不规则的块了
立场 | 数据块 | UTF-32-BE | 嵌入 |
---|---|---|---|
0 | Mind |
(77, 105, 110, 100) |
(0.00029373, 0.00040054, 0.00041962, 0.00038147) |
1 | s ar |
(115, 32, 97, 114) |
(0.00043869, 0.00012207, 0.00037003, 0.00043488) |
2 | en't |
(101, 110, 39, 116) |
(0.00038528, 0.00041962, 0.00014877, 0.0004425 ) |
3 | rea |
(32, 114, 101, 97) |
(0.00012207, 0.00043488, 0.00038528, 0.00037003) |
... | ... | ... | ... |
这个操作看似平常,但我们已将数据从序列轴移动到特征轴!现在,这个表格看起来像一个实际的嵌入张量!
在对值进行归一化后,码点可以直接作为嵌入处理。并且“令牌”可以任意长
立场 | 数据块 | UTF-32-BE | 嵌入 |
---|---|---|---|
0 | Minds ar |
(77, 105, 110, 100, 115, 32, 97, 114) |
(2.94e-4, 4.01e-4, 4.20e-4, 3.81e-4, 4.39e-4, 1.22e-4, 3.70e-4, 4.35e-4) |
1 | en't rea |
(101, 110, 39, 116, 32, 114, 101, 97) |
(3.85e-4, 4.20e-4, 1.49e-4, 4.43e-4, 1.22e-4, 4.35e-4, 3.85e-4, 3.70e-4) |
... | ... | ... | ... |
现在,序列块(“词元”)的长度是一个超参数,就像模型中的层数一样。
这些向量嵌入了大量信息。降维显示了由相似字符构成的向量如何接近
由于标准将 Unicode 空间组织成主题值范围,因此嵌入天生与内容相关。例如,每个字符集(拉丁语、西里尔语等)、表情符号、符号、特殊字符等都有区域。
这些归一化嵌入可以作为 LLM 的输入张量。模型随后可以扩展嵌入维度以进行进一步处理。
该方案继承了 Unicode 的特性,并且已经具备了TL;DR 中列出的大部分优点。
然而,仍然有很多地方需要改进
- 脆弱:嵌入值非常精确,它们之间仅相距
1 / 0x40000 = 3.8147-06
- 线性:嵌入间隔均匀,尽管含义存在不连续性
- 昂贵:有 262144 个“基本”元素,这并非对常规词汇表的改进
字节嵌入
分解可以更进一步:每个 Unicode 码点的 32 位可以分解成字节。
立场 | 数据块 | UTF-32-BE | 嵌入 |
---|---|---|---|
0 | Mind |
(0, 0, 0, 77, 0, 0, 0, 105, 0, 0, 0, 110, 0, 0, 0, 100) |
(0 0 0 0.30078125 0 0 0 0.41015625 0 0 0 0.4296875 0 0 0 0.390625) |
1 | s ar |
(0, 0, 0, 115, 0, 0, 0, 32, 0, 0, 0, 97, 0, 0, 0, 114) |
(0 0 0 0.44921875 0 0 0 0.125 0 0 0 0.37890625 0 0 0 0.4453125) |
2 | en't |
(0, 0, 0, 101, 0, 0, 0, 110, 0, 0, 0, 39, 0, 0, 0, 116) |
(0 0 0 0.39453125 0 0 0 0.4296875 0 0 0 0.15234375 0 0 0 0.453125) |
3 | rea |
(0, 0, 0, 32, 0, 0, 0, 114, 0, 0, 0, 101, 0, 0, 0, 97) |
(0 0 0 0.125 0 0 0 0.4453125 0 0 0 0.39453125 0 0 0 0.37890625) |
... | ... | ... | ... |
现在,除以 256 足以进行归一化。而且 Unicode 的结构在这些嵌入中更加明显。
此转换解决了前一种方法的两个缺点
- 降低了复杂性:嵌入现在从 256 个基本元素而非 20 万个元素中导出
- 增加了分离:嵌入空间中的字节值之间的距离更大
尽管如此,嵌入仍然是线性分布的。更好地区分特殊值,尤其是空字节,会更好。
复合嵌入
实际上,整数字节可以解释为传统嵌入层中的索引。在连接每个字节的嵌入后,形成一个“令牌”嵌入。

即使每个字节的嵌入是随机初始化的,合并后的嵌入仍保留了令牌组合的信息
现在,“令牌”长度是模型的超参数。例如,Gemma2-27B 架构可以这样调整:
- 嵌入维度
H
保持在 4608 - 令牌维度
T
设置为 32(字节,相当于 8 个 Unicode 字符) - 字节维度
E
则为 4608 / 32 = 144
在此设置下,批次维度 B
为 128、序列维度为 16384 (4096 字符) 的输入张量将是
- 首先重塑为
(B, S / T, T) = (128, 256, 64)
- 并作为形状为
(B, S / T, T * E) = (128, 256, 4608)
的张量从复合嵌入层输出
LLM 将把输入作为 256 个嵌入的序列进行处理,每个嵌入代表 8 个字符。这些嵌入中的每一个都由 32 个字节嵌入连接而成。
然后,该层可以进行训练,并且模型可以调整每个字节的嵌入。这使得模型可以为每个字节设置独立的含义,这与上面两节中的方案不同。
最后,LLM 通过其嵌入了解每个令牌的组成。它可以原生执行计算,创建和理解新词等。
二进制预测
由于输入格式发生了变化,目标也应该具有匹配的表示。
让我们回到当前模型(截至 2024 年),假设 GPT-4o 处理了以下句子
This paper was based mainly on the attention mechanism developed by Bahdanau et al. in 2014.[11]
对于序列中的每个位置,模型评估每个令牌的概率。
考虑到令牌“201”之前的所有内容,概率向量可能如下所示
索引 | 0 | ... | 290 | ... | 667 | ... | 1179 | ... | 1323 | ... | 34902 | ... | 199,997 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
词元 | ! |
... | the |
... | 201 |
... | 200 |
... | 202 |
... | september |
... | cocos |
目标 | 0 | ... | 0 | ... | 1 | ... | 0 | ... | 0 | ... | 0 | ... | 0 |
预测 | 0 | ... | 0.15 | ... | 0.4 | ... | 0.1 | ... | 0.25 | ... | 0.08 | ... | 0 |
这个 one-hot 向量的维度为 200k,通常通过以下任一方式获得:
- Softmax 激活
- 嵌入向量的点投影
相反,200k 以下的每个数字都可以用仅 18 位表示。下一个令牌“201”的目标索引667
在二进制中是110110010100000000
。
每个位都可以通过将激活从 softmax 切换到 sigmoid 来进行独立的概率预测
索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
目标 | 1 | 1 | 0 | 1 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
预测 | 0.6 | 0.58 | 0.55 | 0.7 | 0.64 | 0.37 | 0.2 | 0.8 | 0.25 | 0.9 | 0.08 | 0.12 | 0.04 | 0.1 | 0.02 | 0 | 0 | 0 |
上面的二进制向量在索引 2 处有一个预测误差,并编码了预测“671”:在此方案下,误差在数值上是接近的,因为每个位只贡献预测的一部分。
不幸的是,分词器的词汇是混乱的:数值上的接近与语义上的相似性无关。例如,o200k 中“201”周围的词元是:“ can”、“п”、“ me”、“ с”、b“\xe0\xb3”。
同样,Unicode 表示作为目标也很有用。与输入张量一样,目标可以塑形为 (B, S / T, T)
字节张量。然后,每个字节预测是一个维度为 8(位)的向量,最终输出为 (B, S / T, 8 * T)
。
设 L = 8
,整个过程如下

对于文本块“201”,目标预测将是
- 字节表示为
(0, 0, 0, 50, 0, 0, 0, 48, 0, 0, 0, 49)
- 或者字节
49
的最终二进制目标为(0, 0, 1, 1, 0, 0, 0, 1)
如你所见,要预测的 3 个字节——48、49 和 50——彼此接近,就像它们所代表的字符一样。即使二进制输出有误差,预测也不会偏离太远。
现在模型的输出与输入的二进制特性对齐,我们可以探索这些变化如何影响模型的性能。
与分词的比较
以下所有比较都已设置超参数
- 参考分词器是 o200k
- 批次维度:
B = 128
- 序列维度:
S = 32,768
个字符 - 令牌维度:
T = 64
- 嵌入维度
- 每个字节:
E = 64
- 模型内部:
H = 4096
- 每个字节:
一致性
令牌大小不规则,而 UTF-32-BE 允许将字节分组为固定大小的块。每个嵌入所覆盖的字符数成为一个可调超参数。
此外,分词器的词汇表取决于训练数据
- 令牌频率随时间变化:日期、专有名词、事件、俚语等
- 训练数据通常有限
- 地理上,仅限于少数语言
- 受语境影响,受词汇领域限制
而 Unicode 是永恒和通用的。
压缩
输入张量
序列维度 S = 32,768
导致
- 分词(平均而言,使用 o200k,为了比较目的)的上下文维度为
C = 8,192
- 一个
4 * S = 131,072
字节的序列
嵌入后,输入张量为
- 分词后为
(8192, 4096)
- 采用复合嵌入后为
(4 * S / T, 4096) = (2048, 4096)
复合嵌入是 T = 64
个维度为 E = 64
的向量的组合,总共 4096。
虽然 UTF-32 暂时扩展了输入序列,但随后它被缩减为一个更小的张量。
输出张量
最后,输出显著更小
- 分词后为
(8192, 199998)
- 使用二进制预测后为
(4 * S / T, 8 * T) = (2048, 512)
二进制预测小 1600 倍,并且相对非常密集。
嵌入权重
复合嵌入的内核形状为 (256, E)
,此处为 (256, 64)
。相比之下,词汇表 o200k 的内核为 (199998, H)
,即 (199998, 4096)
。
后一个内核需要大量数据,以便词汇表中的每个令牌都能在多种语境中出现。相反,所有字节值都出现在无数组合中,每个字节值都将得到扎实的训练。
此外,复合嵌入内核的参数数量减少了 50000 倍。
投影权重
同样,投影层的形状为
- 在点积和 softmax 头部的转置情况下为
(199998, H) = (199998, 4096)
- 在二进制预测中使用 sigmoid 激活时为
(H, 8 * T) = (4096, 512)
头部也小了 400 倍。
内部层权重
然而,输入和输出的范围大大扩展,以涵盖所有现代语言。虽然这种扩展的影响难以量化,但我的经验表明,它需要一个更大的模型。
为了达到基于令牌的模型的性能,我不得不将内层嵌入维度增加约 1.5 倍。
因此,虽然复合嵌入减少了输入和输出内核的大小,但整体模型通常最终会有更多参数。
预测误差
使用分词
- 预测是一个完整的子词,取自词汇表
- 令牌的排列顺序混乱,相邻令牌之间无关
- 数值误差跨越整个输出维度(词汇量大小)
使用二进制预测
- 下一个文本块逐字节预测
- 字节按照 Unicode 标准排序,该标准结构非常严谨
- 每个预测位都对预测/误差的一部分做出贡献
因此,如果下一个令牌是 e
,则目标将是
- 对于带分词器的模型(199997 个零和一个一),索引
327
处为 1 的独热向量 (0, 0, 0, 101)
或二进制表示为((0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 1, 1, 0, 0, 1, 0, 1))
而错误的预测将分别是
- 索引 328 或
of
((0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 1, 1, 0, 0, 1, 1, 1))
或(0, 0, 0, 103)
表示g
根据我的经验,模型很少(几乎从不)未能预测空字节。
总结一下
- 令牌预测中的误差是随机的
- 二进制误差位于目标附近,这意味着由于 Unicode 的原因,它与目标相似
- 令牌预测始终是有意义的子词
- 而字节级别的预测可能中间出现“错字”
所以这两种方法各有优缺点。
实现
我将只在此处提供 Tensorflow / Keras 实现。有关 PyTorch 版本和更多内容,请参阅资源部分。
复合嵌入
复合嵌入可以在一个非常简单的层中实现。例如,在 Keras 中:
@keras.saving.register_keras_serializable(package='layers')
class TokunEmbedding(keras.layers.Embedding):
def call(self, inputs: keras.Input) -> keras.Input:
# embed each element separately
__outputs = super(TokunEmbedding, self).call(inputs)
# concatenate the embeddings
return keras.ops.einsum('bste -> bs(te)', __outputs)
einsum
操作可以用一个更通用的“合并”操作来代替,该操作独立于其输入的秩。例如,einsum
方程可以根据输入的秩生成。
def _equation(self, inputs: keras.Input) -> str:
__rank = len(keras.ops.shape(inputs))
__indices = [chr(97 + __i) for __i in range(__rank + 1)] # embedding adds an axis
return '{} -> {}({})'.format(''.join(__indices), ''.join(__indices[:-2]), ''.join(__indices[-2:]))
二进制预测
二进制预测的目标是通过将输入分解为基数 2 来计算的。例如在 Tensorflow 中:
def expand_base(data: tf.Tensor, base: int, depth: int, bigendian: bool=True) -> tf.Tensor:
__shape = len(list(data.shape)) * [1] + [depth]
# base indexes
__idx = range(depth)[::-1] if bigendian else range(depth)
# base divisor and moduli
__div = tf.convert_to_tensor([base ** __e for __e in __idx], dtype=data.dtype)
__mod = tf.convert_to_tensor([base ** (__e + 1) for __e in __idx], dtype=data.dtype)
# match the input shape
__div = tf.reshape(__div, shape=__shape)
__mod = tf.reshape(__mod, shape=__shape)
# Euclidean algorithm
__digits = tf.math.floordiv(x=tf.math.floormod(x=tf.expand_dims(data, axis=-1), y=__mod), y=__div)
# format
return tf.cast(__digits, dtype=data.dtype)
在推理过程中,可以通过执行反向操作来解释预测
def reduce_base(data: tf.Tensor, base: int, axis: int=-1, keepdims: bool=False, bigendian: bool=True) -> tf.Tensor:
__rank = len(data.shape)
# select the dimension of the given axis
__shape = [__d if (__i - axis) % __rank == 0 else 1 for __i, __d in enumerate(list(data.shape))]
# exponents
__exp = range(__shape[axis])[::-1] if bigendian else range(__shape[axis])
# base multipliers
__base = tf.convert_to_tensor([base ** __e for __e in __exp], dtype=data.dtype)
# match the input shape
__base = tf.reshape(__base, shape=__shape)
# recompose the number
return tf.reduce_sum(data * __base, axis=axis, keepdims=keepdims)
下一步
通过这些输入和输出表示,LLM 对文本有了更精细和更广泛的理解。然而,这可能以内部层扩展为代价。
为了更好地了解复合嵌入的实际价值,我开发了一系列名为 llaminate
的模型。特别是,我可能会对这个项目中的一个神经编译器撰写一份简短的评论。
资源
参考实现
- 在 Tensorflow + Keras 中:mlable PyPi 包
- 在 PyTorch 中:Mr Karpathy 的 GPT2 分支中的 Notebook(WIP)
Unicode
- 关于Unicode 平面的维基百科文章
- symbl.cc 上的 Unicode 表