版权声明:
除非注明,本博文章均为原创,转载请以链接形式标明本文地址。


从零开始编写大型语言模型(LLM),第二部分

ref: https://www.gilesthomas.com/2024/12/llm-from-scratch-2

发布日期:2024年12月23日
分类:AI, Python, 从零开始编写LLM, TIL深度探索

我正在阅读Sebastian Raschka的书籍《从零开始构建大型语言模型》,并计划每天发布阅读笔记(至少在我阅读的日子里——圣诞节那天我可能不会发布),分享我觉得有趣的内容。

我原本计划每天阅读一章,但对于这样一本内容密集的书来说,这个目标似乎过于乐观了!所以今天,我阅读了第二章“处理文本数据”的前半部分。这一章概述了文本在进入LLM之前的预处理过程,接着描述了一个简单的分词系统(包括源代码),然后简要介绍了我们将在LLM中实际使用的字节对编码方法。

概述

核心思想是,LLM实际上无法直接处理文本——甚至无法直接处理单词。我认为,将文本输入LLM的第一步是进行分词(在我进行微调的冒险中,这一点非常常见),但事实证明,这只是第一步。

处理过程如下:

  1. 获取原始文本,这是一系列字符(我内心的纯粹主义者想在这里说“字节”,或者添加一个初步步骤,即“确定文本使用的编码并将其转换为跨文档一致的内容”)。
  2. 对其进行分词,得到一系列分词ID——每个单词或子词对应一个整数。
  3. 为每个分词生成一个嵌入。
  4. 然后,这些嵌入实际上被发送到LLM。

词嵌入简介

这里提到的嵌入是每个分词一个嵌入,而不是第一章中介绍的编码器-解码器Transformer中使用的整个句子或文档的大嵌入。但它们仍然是高维向量,以某种抽象的方式表示相关分词的含义。

Rashka举了Word2vec嵌入的例子,这在当时是相当轰动的,因为你可以对生成的向量进行算术运算并得到合理的结果。例如,使用w2v作为一个假想的函数,它接受一个单词并返回嵌入向量,并将+和-视为向量的逐元素运算符,我们可以看到如下内容:

1
w2v("king") - w2v("man") + w2v("woman") ~= w2v("queen")

或者:

1
w2v("Paris") - w2v("France") + w2v("Germany") ~= w2v("Berlin")

但更成问题的是:

1
w2v("doctor") - w2v("man") + w2v("woman") ~= w2v("nurse")

这清楚地表明,保持训练材料的无偏性将非常重要。

无论如何,他指出,LLM使用的嵌入通常不是像Word2vec这样预先创建的,而是由与LLM本身一起训练的嵌入引擎生成的。让我感到惊讶的是,这些嵌入的维度数量——他说GPT-2有768个维度,而GPT-3则有惊人的12,288个维度!

他还简要提到了音频和视频的嵌入模型的存在,虽然他没有明确提到,但这让我想到了我们现在拥有的多模态LLM可能如何工作。毕竟,如果LLM只接受嵌入作为输入,那么只要它们在某种意义上是兼容的,就没有理由不让它像处理词嵌入一样处理音频嵌入或图像嵌入。不过,这只是我个人的推测。

在阅读这部分时,我还想到,如果LLM专门使用嵌入进行计算,那么它的直接输出可能是下一个分词的嵌入,而不是分词本身。因此,我们需要以某种方式从嵌入映射到分词,这将非常困难(在数千维的空间中找到与随机嵌入最接近的分词是一项昂贵的操作),所以也许这种对称性“嵌入 -> LLM -> 嵌入”只是我的一个误解,LLM实际上只是生成下一个分词(或者更准确地说,是一组带有相关概率的分词)。我们拭目以待!

分词:自己编写

这部分对我来说没有太多新内容——分词的概念,即将单词(或部分单词)转换为数字,我认为任何阅读本文的人都会熟悉,而Raschka所实现的代码非常简单明了。

在第一个版本中,我们将文本拆分为单词和标点符号,例如:

1
"Hello, how are you?" -> ["Hello", ",", "how", "are", "you", "?"]

(他指出,如果需要,可以保留空格,例如Python代码。)

完成此操作后,为每个唯一的单词/标点符号分配一个唯一的整数,并构建一个从单词到ID的映射。

有了这个映射,你可以构建一个简单的类,其中包含一个encode方法,将文本映射到分词ID列表,以及一个decode方法,将此类列表转换回文本。

然而,如果它遇到一个从未见过的单词,它将会中断。因此,在第二个版本中,我们增强了它以处理未知单词。他通过在生成单词到ID映射之前将字符串<|unk|>添加到词汇表中来实现这一点,这样它就会获得一个ID,然后修改编码器,以便在遇到不认识的单词时输出该分词。他还添加了一个<|endoftext|>分词,以便在同一输入流中分隔不同的文档。

这是一个很好的解决方案,效果很好,但我从之前的阅读中得到的印象是,这并不是当前的最佳实践(例如,截至GPT-4)。我想这里使用的系统是因为Raschka在这段代码中试图实现的只是一个分词工作原理的示例——也就是说,这段代码是一个用于教学目的的简单实现。

但无论如何,我会解释我认为它的问题所在,主要是为了确保我真正理解它 :-)

如果<|endoftext|>出现在一个简单的从单词到分词ID的编码器的词汇表中,那么有人可以将该字符串放入LLM应用程序的提示中,并获得该分词。我相当确定我记得一些早期的提示注入/越狱技巧,这些技巧似乎就是沿着这些思路工作的。

有一些方法可以避免这种情况(参见下一节中的好例子),但它给人一种感觉,即那些足够擅长越狱的人可能能够绕过它。当我看到它时,我脑海中负责在网站代码中触发SQL注入警报的部分确实被触发了。

根据我的了解(我可能完全错了),最新的分词器没有任何特定字符串映射到特殊分词——也就是说,它们只是分词ID。在解码方法中可能会有一些输出它们的方式,但在编码方法中无法通过使用特殊格式的文本来生成它们。因此,例如,生成保留给未知单词的分词ID的唯一方法是提供一个未知单词。

无论如何,所有这些都无关紧要,因为我们将用于LLM本身的分词器不是上面的那个,而是更复杂的东西。

分词:字节对编码

使用具有固定词汇表的分词器(如上所述)的问题是:

  1. 你最终不得不扫描整个训练集以找到所有唯一的单词(如果你在训练整个互联网的抓取数据,你会发现各种各样的东西,比如lksfdklkajfdfklj)。你会得到一个包含大量几乎从未使用过的单词的庞大词汇表,然而,在训练之后,你仍然会遇到包含训练中未见过的单词的输入问题。
  2. 或者你只是接受在训练期间也会出现未知单词,这限制了你的LLM可以学习的内容。

我们可以通过更复杂的分词器来避免这个问题。Rashka提供了一些使用OpenAI的tiktoken库的示例代码,该库使用字节对编码进行分词。字节对编码是一种系统,分词器通过训练过程学习自己的分词集——即:

  1. 它最初拥有所有字母、数字和标点符号的分词。
  2. 然后它被展示大量数据,发现现有分词的常见组合,并为它们创建新的分词。
  3. 未知单词可以由它拥有的分词表示——在最坏的情况下,它只需要逐个字母拼写出来。

他展示的示例代码给出了一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
In [64]: import tiktoken

In [65]: tokenizer = tiktoken.get_encoding("gpt2")

In [66]: text = "Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace."

In [67]: integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})

In [68]: tokenizer.decode(integers)
Out[68]: 'Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace.'
In [69]: [tokenizer.decode([ii]) for ii in integers]
Out[69]:
['Hello',
',',
' do',
' you',
' like',
' tea',
'?',
' ',
'<|endoftext|>',
' In',
' the',
' sun',
'lit',
' terr',
'aces',
' of',
' some',
'unknown',
'Place',

所以你可以看到"someunknownPlace"被分解为"some"、"unknown"和"Place"的分词。

这也突出了我之前从其他地方了解到的一点——现代分词器通常将前导空格作为分词本身的一部分,因此——例如——" do"和"do"有不同的分词——它们有不同的ID:

1
2
In [70]: print(tokenizer.encode("do do"))
[4598, 466]

解码后看起来也不同:

1
2
In [71]: [tokenizer.decode([ii]) for ii in tokenizer.encode("do do")]
Out[71]: ['do', ' do']

另一个让我感兴趣的是encode调用中的allowed_special参数,这是一种预防措施,以防止我之前提到的越狱问题。有了它,它将愉快地将文本解析为特殊分词:

1
2
In [72]: [tokenizer.decode([ii]) for ii in tokenizer.encode("<|endoftext|>", allowed_special={"<|endoftext|>"})]
Out[72]: ['<|endoftext|>']

没有它,分词器会识别并拒绝它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
In [73]: [tokenizer.decode([ii]) for ii in tokenizer.encode("<|endoftext|>")]
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[73], line 1
----> 1 [tokenizer.decode([ii]) for ii in tokenizer.encode("<|endoftext|>")]

File ~/.virtualenvs/llm-from-scratch/lib/python3.12/site-packages/tiktoken/core.py:117, in Encoding.encode(self, text, allowed_special, disallowed_special)
115 disallowed_special = frozenset(disallowed_special)
116 if match := _special_token_regex(disallowed_special).search(text):
--> 117 raise_disallowed_special_token(match.group())
119 try:
120 return self._core_bpe.encode(text, allowed_special)

File ~/.virtualenvs/llm-from-scratch/lib/python3.12/site-packages/tiktoken/core.py:398, in raise_disallowed_special_token(token)
397 def raise_disallowed_special_token(token: str) -> NoReturn:
--> 398 raise ValueError(
399 f"Encountered text corresponding to disallowed special token {token!r}.\n"
400 "If you want this text to be encoded as a special token, "
401 f"pass it to <!--CODE_BLOCK_4958-->, e.g. <!--CODE_BLOCK_4959-->.\n"
402 f"If you want this text to be encoded as normal text, disable the check for this token "
403 f"by passing <!--CODE_BLOCK_4960-->.\n"
404 "To disable this check for all special tokens, pass <!--CODE_BLOCK_4961-->.\n"
405 )

ValueError: Encountered text corresponding to disallowed special token '<|endoftext|>'.
If you want this text to be encoded as a special token, pass it to <!--CODE_BLOCK_4962-->, e.g. <!--CODE_BLOCK_4963-->.
If you want this text to be encoded as normal text, disable the check for this token by passing <!--CODE_BLOCK_4964-->.
To disable this check for all special tokens, pass <!--CODE_BLOCK_4965-->.

或者,根据那个(非常详细的)错误消息,你可以让它被解析为没有任何特殊含义的字符序列:

1
2
In [77]: [tokenizer.decode([ii]) for ii in tokenizer.encode("<|endoftext|>", disallowed_special=(tokenizer.special_tokens_set - {'<|endoftext|>'}))]
Out[77]: ['<', '|', 'end', 'of', 'text', '|', '>']

最后一个感觉有点冒险!在一个更大的系统中(比如某种LLM相互通信的框架),很容易想象这些分词流被天真的代码重新组装,然后像受信任的一样进行分词。

总结

无论如何,我觉得今天就到这里吧。有趣的是,我觉得我花在整理这些笔记上的时间大约是阅读时间的两倍,但这可能是一个很好的平衡。我确信,通过这次写作,我会更好地记住我读到的内容!

接下来的部分,我预计明天会阅读(尽管是平安夜,所以我可能无法完成),是关于数据采样的,然后是创建嵌入。我原本以为后者会非常复杂,但在快速浏览后,我发现我们在现阶段所做的只是生成随机嵌入,这是有道理的——实际值将在我们开始训练时学习。

小问题和奇怪之处

今天只有一个问题——真的是我在吹毛求疵,但在第28页,命令print(tokenizer.decode(ids))的输出被渲染为:

1
'" It\' s the last to be painted, you know," Mrs. Gisburn said with pardonable pride.'

它应该是:

1
" It' s the last to be painted, you know," Mrs. Gisburn said with pardonable pride.

——我猜它是从CLI会话中复制粘贴的,其中命令只是tokenizer.decode(ids),所以Python提供了一个repr

基本上是一个错别字,我觉得甚至提到它都有点不好意思 ;-)