Loading
有好几周,Mind Map 一直在为一个没人写过的句子绘制注意力弧线。
如果你输入 It costs £5,模型收到的并不是 £5。它收到的是 £ 和 5——两个彼此独立的东西,关系不比任何一对相邻词更紧密。而词表里明明有 £5 这一条:第 27,813 行,自 2018 年起就在那里。分词器径直走了过去。
一切看上去都没有问题。界面显示着词,弧线带着合理的权重,数字加起来也对。下游的算术毫无瑕疵——只是这份毫无瑕疵的算术,作用在了错误的输入上。这是最昂贵的一种"正确"。
关键结论: 模型并不读你的句子。它读的是一串整数,而它今后所能注意到的一切,早在第一层运行之前就已经被决定了。
“hens” → 2 个片段. 这才是模型收到的内容,而不是你输入的词。
这是随包发布的 WordPiece 分词器针对 30,522 条词表的真实输出。带 ## 的片段是延续片段,只能接在前一个片段之后。
在你的键盘和第一次矩阵乘法之间,夹着一个翻译步骤。它比几乎所有人预想的都更有损、也更奇怪。
BERT 并没有为每个词准备一个词。它恰好拥有 30,522 条词表条目,在训练时固定,此后不可更改。你能输入的任何语言的任何句子,都必须用这 30,522 个符号表达出来——就像一台字盘固定的活字印刷机,只能用它拥有的铅字去排任何一本书。
当一个词在字盘里,它被整体使用。不在,就要用碎片重建。
重建规则叫 WordPiece,简单得近乎粗暴:从词首取出词表中存在的最长片段,切下来,再对剩余部分重复。第一个之后的每个片段都带 ## 前缀,标记它是延续片段——只能接在前面的内容之后。
正是这条贪心规则,让切出来的碎片很少与你会选择的音节吻合:
| 你输入 | 模型收到 |
|---|---|
hens | hen + ##s |
unopened | uno + ##pen + ##ed |
syringe | sy + ##ring + ##e |
tokenization | token + ##ization |
hen + ##s 是一个人也会认可的拆分。uno + ##pen + ##ed 不是——"unopened"在任何意义上都不是由 uno 和 pen 构成的。分词器并不在寻找意义,它只是在用手头最长、最少的片段覆盖这个字符串;只要能覆盖字符,它就乐意把 uno 和 pen 交给模型。
这里有个值得停下来想一想的后果:模型从未见过"unopened"这个词。 它看到的是三个片段,必须靠注意力跨三个位置重新拼出关于这个词的任何概念。这种重建是真实发生的,而且效果好得出人意料——但那是模型做出来的工作,不是别人给它的事实。
29 个词 · / 30,522
词表固定为 30,522 条。之外的一切都会被贪心地、最长匹配优先地拆成片段重建——所以这些片段很少与你认得的音节对齐。
我弄错的规则是这样的。
HuggingFace 的分词器在 WordPiece 运行之前,会先做一遍切分:按空白,以及按标点。而我的实现是按空白,以及按"任何非字母非数字的字符"。
听起来是同一条规则。并不是。差别恰好就是那些属于符号而非标点的字符:£、°、¹⁄₂、emoji。HuggingFace 不在这些字符上切分,我的实现切了。
这个分歧长期不可见,原因既具体又令人不安:ASCII 符号——$、%、+、=、&、*——落在 HuggingFace 的标点区间内,因此两种实现对它们的切分完全一致。而人们想得到要写的测试,用的全是 ASCII。这个 bug 完整地活在非 ASCII 的尾部。
九个词表条目无法在被输入后存活:
°c (6362) °f £1 £2 £3 £5 £10 £100 ¹⁄₂还有两个 emoji:HuggingFace 会把它们变成一个未知词元,而我的实现变成了两个各自独立的未知词元。
30,522 条里的九条,占词表的 0.03%。但它同时也是包含它们的句子的 100%,而那些句子的注意力图,都是在一个模型永远不会收到的输入上算出来的。
测试通过,是因为测试和代码共享了同一个假设。
我先写了分词器,再为它写测试,两者出自我对"分词是怎么回事"的同一套心智模型。当你对规则的理解是错的,你会写出一个错误的实现,和一个与之完美吻合的错误测试——然后一切都是绿的。
打破僵局的,是第二个与第一个毫无共享的分词器:不同语言、不同的写作时间、依据规范而非依据代码写成,在原始权重上以 fp64 NumPy 运行。当两个独立实现给出不同答案时,其中一个一定是错的,你也就再没有资格心安理得。
它们在 £5 上分歧了。第二个是对的。
这里我想谨慎一些,因为那个"神谕"有一份来之不易的谦逊:它自己也错过一次。 它的第一版缺少了 HuggingFace 会给每个 CJK 字符两侧加空格、使其各自成词的那一步。它在 中文 上与运行时分歧——而那一次,对的是运行时。一份提交进仓库的固定样本终结了争论。
没有人验证过的神谕,只是第二种意见。如今它在获得裁判资格之前,必须先与一次 PyTorch 参考运行对齐,而且这项校验会在每次比较之前执行。
最有力的防线,恰恰是最不聪明的那条。与其罗列我想得到的词,不如把词表中全部 30,522 条逐一单独送回分词器,要求它原样返回。一条无法完成往返的词表条目,就是一个模型拥有、却永远无法被交到它手上的词。这个测试抓住的是 bug 的类别而非个例——也正因如此,我才敢说那九条之后没有藏着第十条。
三点结论,会改变你看待本系列每一张可视化的方式。
屏幕上的单位不是词。 当 Mind Map 显示 hens 时,它显示的是由两个词元位置拼成的一个词,旁边的权重是这两个位置合并后的结果。这个合并是一种选择——在查询词的片段上取平均,在目标词的片段上求和——它站得住脚,但它是横在你与模型之间的一层解释。
有些输入确实在模型之外。 输入一个 emoji,你得到 [UNK]:那不是近似,而是近似的缺席。模型收到的词元的含义是这里曾有个东西,我完全不知道它是什么。任何指向它的弧线,都是真实地把注意力投给了一次耸肩。
长度不是你以为的那个长度。 界面的上限是 24 个词元,不是 24 个词。Water boils at 100°c. 是五个词、十个词元。这个上限要等词表下载完成后才可知——所以工具选择事后诚实地报告,而不是事先预测。
分词通常被当作预备步骤来讲——精彩部分之前那段无聊的流程。它其实是模型的整个感知世界被固定下来的那一步。此后十二次矩阵乘法所能注意到的关于你这句话的一切,都必须已经存在于那串整数之中。
一旦这一步错了,系统的其余部分依然会继续产出漂亮、笃定、格式完美的答案——回答一个你并没有问过的问题。
下一篇:单个注意力头究竟计算了什么——三个矩阵、一次 softmax,不用任何比喻。