俄罗斯有个大佬每年都会用 SQL 来实现一个挑战庆祝新年,已经坚持了 10 多年了。而 2023 年年底他完成了一件非常了不起的事情,即用 500 行 SQL 实现了 GPT2:https://explainextended.com/2023/12/31/happy-new-year-15/
整个项目的代码参见:https://github.com/quassnoi/explain-extended-2024
这里搬运总结做一下分享。算法和原理上如果有不准确的地方,感谢专业的大佬们捉捉虫。原文有些内容对于我这样的外行人有点理解困难,我会用粗体斜体字文本做额外补充说明 。
从技术角度来看,大语言模型是什么? 大语言模型(LLM)本质上是一个函数。它将一个文本字符串作为输入(即 prompt ),并返回一个字符串和数字的数组。
这个函数可能是这样的:
llm(prompt: str) -> list[tuple[str, float]]
首先这个函数是确定性的。它在底层进行了大量的数学运算,但所有这些运算都是固定的。如果你用相同的输入多次调用它,它总是会返回相同的输出。 但已经体验过 ChatGPT 产品的同学可能会有疑惑,因为我们在使用时即便输入同一个问题,返回的答案也是不同的。别急,后面会解释这个问题。
还是回到上面那个函数,它的返回值是什么?这里给一个例子:
llm("I wish you a happy New")
0 (' Year', 0.967553)
1 (' Years', 0.018199688)
2 (' year', 0.003573329)
3 (' York', 0.003114716)
4 (' New', 0.0009022804)
…
50252 (' carbohyd', 2.3950911e-15)
50253 (' volunte', 2.2590102e-15)
50254 ('pmwiki', 1.369229e-15)
50255 (' proport', 1.1198108e-15)
50256 (' cumbers', 7.568147e-17)
它会返回一组元组,每个元组由一个字符串和一个数字组成,数字是这个单词继续提示的概率。模型会认为“I wish you a happy New”这句话的下一个词有 96.7% 的概率是“Year”,1.8% 的概率是“Years”,以此类推。当然模型并不是真的思考,它只是根据一些固定的内部逻辑机械地返回字符和数字的组合。
既然这个函数这么简单粗暴且具有确定性,怎么生成不同的文本呢?
LLM 被用于很多文本应用场景(聊天机器人、内容生成器以及代码助手等),这些应用会反复调用模型并选择它认为的文本,这个过程也带有一定的随机性。选中的文本会进一步加到 prompt 中,然后再次调用模型,以此循环下去,直到生成足够的字符。累积的字符会看起来像自然语言中的文本,完整地包含语法、句法,甚至是一些智能和推理,其原理和马尔可夫链没有本质区别。
LLM 的这种巧妙的设计,加上一系列科学突破(以及大量码农的不懈努力),使业界足以将起封装成一个函数。也因此诞生了 GPT 、生成式预训练 Transformer 等算法。
这里简单介绍下生成式预训练 Transformer 。
正如最初的 GPT 论文提到的 :
“我们展示了通过在多样化的无标签文本语料库上进行语言模型的生成式预训练,然后在每个特定任务上进行区分性的微调,可以实现这些任务的大幅提升。”
但后来人们才意识到,如果模型足够大,后续的工作已经没有必要了。一个仅仅用于训练生成本文的 Transformer 模型,已经可以遵循人类语言指令,完成人们期望的任务,之后大模型和 AIGC 的时代也就到来了。
下面先说明下使用 GPT2 生成文本的过程:
def generate(prompt: str) -> str:
# 将一个字符串转换成 tokens 列表
tokens = tokenize(prompt) # tokenize(prompt: str) -> list[int]
while True:
# Runs the algorithm.
# 运行算法
# 返回 tokens 的概率:一个包含50257个浮点数的列表,加起来等于 1
candidates = gpt2(tokens) # gpt2(tokens: list[int]) -> list[float]
# 从候选列表中选择下一个 token
next_token = select_next_token(candidates)
# select_next_token(candidates: list[float]) -> int
# 将其追加到 token列表中
tokens.append(next_token)
# 判断是否要结束生成,可以根据 token 数量、超时、停用词或者其他的逻辑。
if should_stop_generating():
break
# 将 token 列表转换成字符串
completion = detokenize(tokens) # detokenize(tokens: list[int]) -> str
return completion
下面将用 SQL 一步步实现上述逻辑:
在文本输入到神经网络之前,需要将其转换成数字列表。这就是 Unicode 这种文本编码的工作。但是纯粹的 Unicode 并不真正适合神经网络。
神经网络的核心是进行大量的矩阵乘法,并基于这些矩阵的权重进行文本预测。中一些矩阵,每个可能的“字母表”中的值都对应一行;而其他矩阵,则每个“字符”对应一行。
进一步解释: 神经网络是由许多相互连接的节点(或称为神经元)组成的计算系统,这些节点通常组织在不同的层中。在进行学习和推理过程时,神经网络会利用大量的数学运算,其中最为核心的运算便是矩阵乘法。
在神经网络的上下文中,提到的矩阵往往指的是称为权重的参数,这些权重储存了神经网络在训练过程中学习到的信息。权重矩阵的系数(即每个矩阵元素的数值)反映了输入数据的特征与目标输出之间的关系。神经网络的训练过程基本上就是调整这些权重系数,以最小化预测结果和实际结果之间的差异。
当提到每个可能的“字母表”中的值都对应一行时,这通常是在描述与处理分类数据相关的权重矩阵。这种情况下的“字母表”可以理解为一个离散的特征集合或分类集合,其中每个类别或特征值都对应矩阵中的一行。这样的结构通常用于所谓的嵌入层(embedding layers),其中每个类别都会被映射为一个高维空间中的点,即嵌入向量。
而每个“字符”对应一行的矩阵可能是在描述一个字符级的嵌入,其中“字符”可能指单个字母、数字或其他符号。在这种情况下,神经网络会为每个单独的字符分配一个嵌入向量,以便能够处理和学习字符级别的模式,例如在自然语言处理任务中处理单个字母或标点符号。
矩阵乘法在本文中用于传播和转换输入信号通过网络。当一个输入(通常表示为一个向量)通过网络层时,它会被权重矩阵乘以输入向量,然后可能还会添加一个偏置向量,并通过一个非线性激活函数来生成该层的输出。这一过程在网络的每一层中重复进行,直到达到输出层并产生最终的预测结果。
这里,“字母表”和“字符”的词义并不是通常的意思。在 Unicode 中,“字母表”长达 149186 个字符,而一个“字符”可以是像这样的东西:?(这是一个单独的Unicode点号65021,编码了一个非常重要的阿拉伯短语)。这个短语本可以用通常的阿拉伯字母书写,这意味着同一文本可以有许多编码。
举一个例子,我们来看看单词“PostgreSQL”。如果我们使用 Unicode 对其进行编码(转换成一系列数字),我们会得到10个数字,这些数字可能从 1 到 149186 不等。这意味着我们的神经网络需要存储一个有149186行的矩阵,并在这个矩阵的10行上进行一系列计算。其中的一些行(对应于英文字母表的字母)将会被大量使用并包含大量信息;其他的一些晦涩符号,几乎根本不会被使用,但仍然占用空间。
自然,我们希望将这两个数字,“字母表”的长度和“字符”的数量,保持尽可能低。理想情况下,我们的字母表中的所有“字符”应均匀分布,同时我们的编码像 Unicode 一样强大。
我们可以通过给文本中经常出现的词语序列分配唯一的数字来实现这一点。在 Unicode 中,相同的阿拉伯语宗教短语可以使用单个码位或逐字母来编码。由于我们在创建我们自己的编码,我们可以对模型中重要的词语和短语(即在文本中经常出现的)做同样的处理。
例如,我们可以为“Post”、“greSQL”和“ing”分配不同的数字。这样,单词“PostgreSQL”和“Posting”在我们的表示中都将具有长度为2。当然,我们仍会对较短的序列和单个字节保持单独的码点。即使遇到特殊文本,依然是可以编码的,尽管长度会更长。
GPT2 使用一种叫做字节对编码(Byte Pair Encoding, BPE)的算法变体实现上述思路,它的分词器使用一个包含 50257 个码点(在AI 领域被称为 tokens )的字典,这些码点对应 UTF-8 中的不同字节序列(加上“文本结束”作为单独的 token )。
这个字典的建立步骤如下:
假设我们有以下文本语料库:“aaabdaaabac”。我们将使用BPE算法分析这个语料库:
通过这种方式,BPE算法不仅减少了数据表示的大小,还保留了文本中的常用序列信息。这种压缩对于神经网络模型来说特别有用,因为它们通常需要大量数据和计算资源。通过BPE,模型可以更有效地处理大量文本,同时减少了内存使用和提高了处理速度。
数字 50000 是开发者们或多或少随意选择的。其他模型保持token数量在类似的范围内(从3万到10万)。 在这个算法的每次迭代中,一个新的 token 将被添加到字典中,这个新 token 是两个前面的 token 的连接。最终,我们会得到 50256个 token。再加上一个固定编号的 token 表示“文本结束”,这部分工作就完成了。
GPT2 版本的 BTE 有另一层编码:token 字典将 token 映射到字符串,而不是字节数组。从字节到字符串字符的映射在这个函数中定义。我们将在 encoder 表中保存它生成的字典。
接下来的重头戏来看看如何在 SQL 中实现分词器: 分词器是 GPT2 不可分割的一部分,token 字典可以从 OpenAI 的网站上下载,连同模型的其他部分一起。我们需要将它导入到tokenizer 表中。原作者已经将具体的代码和模型放到了 GitHub:https://github.com/quassnoi/explain-extended-2024 在一个递归的 CTE 中,我们将把这个单词分割成 token(从单字节开始)并合并最佳的相邻对,直到没有剩余可合并的内容。合并本身发生在一个嵌套的递归 CTE 中。
下面的例子将使用单词“Mississippilessly”。结果集中的每条记录都显示到目前为止找到的最佳合并对,以及查询的进展情况。
在每一步中,BPE算法都会找到最佳的要合并的标记对,并将它们合并(在输出中可以看到合并的标记对及其排名)。这个过程将标记空间的大小从 Unicode 的 150k 减少到 50k,并且将特定词中的标记数量从 17 减少到 5。这两者都是巨大的改进。 在处理多个单词时,分词器首先使用这个正则表达式将文本分割成独立的单词,并分别合并每个单词内的标记。但是 PostgreSQL 不支持在正则表达式中使用 Unicode 字符属性,因此作者做了调整(在这个过程中可能破坏了正确的 Unicode 支持)。
这些 tokens 展示了自然语言的一部分(一般来说,大约每个 token 有 0.75 个单词),因此任何试图在文本补全方面取得成功的模型都应该以某种方式编码这些部分之间的关系。即使是孤立存在的语言部分,也有一组正交的属性。
以单词“subpoena”(传票)为例(在 GPT2 的分词器中恰好整个单词就是一个 token )。它是一个名词吗?是的,非常明确。它是一个动词吗?嗯,有点儿像。它是一个形容词吗?不那么像,但如果你努力想象,它也可以是。它是法律术语吗?绝对是。诸如此类。
所有这些属性都是正交的,即彼此独立的。一个词可以是法律术语名词,但不是形容词或动词。在英语中,任何这样的组合都是可能的。
具有正交属性的事物最好使用向量来编码。它有许多属性,而不是只有一个(比如一个 token 编号)。如果我们能够随意调整这些属性,那就更加有帮助。例如对于这样一句需要补充的文本:“律师提及的法院提到了……” ,后面要补充的词首先在法律术语维度上的权重应该很高,其次在名词维度上权重也要高,但在形容词、动词、甚至花朵维度上的权重就无关紧要。
在数学中,将较窄的值映射到较宽的空间(比如将 token 编号映射到向量)称为 embedding 。这正是我们要做的工作。
那如何决定这些向量代表哪些属性?并不需要做。
只需要为每个标记提供足够的向量空间,并计划模型在其训练阶段能够用有意义的内容填充这些维度。GPT2 为其向量使用了 768 个维度。事先无法知道(实际上,事后也不容易知道)哪个词的属性会被比如第 247 维所编码。模型会编码某些东西,但并不知道具体的对应关系。
那应该将 token 的哪些属性映射到向量空间呢?能够预测下个 token 是什么的属性都应该做映射。
token 编码:不同的标记代表不同的含义。 token 位置:“蓝紫色"和"紫蓝色"不是同一回事。
token 之间关系:这是最重要的一点,Transformer 架构中的 Attention 块是第一个做对的。 token和位置很容易嵌入。假设有一个短语"PostgreSQL is great”,正如我们已经知道的,它映射为四个标记:[6307, 47701, 318, 1049]。在 GPT2 的其他参数中,有两个矩阵被称为WTE(word token embedding )和 WPE(word position embedding )。顾名思义,前者存储 token 的嵌入,后者存储位置的嵌入。这些嵌入的实际值已经在GPT2的训练过程中被填充(“学习”)。它们是数据库表中常数。
WTE 是 50257×768,WPE 是1024×768。后者意味着我们在向 GPT2 提供的每个 prompt 最多可以使用 1024 个 token 。如果我们在prompt 中提供更多的 token,我们就无法为嵌入它们的位置。这是模型设计时设置的一个架构层面的特点(在 AI 领域被称为“hyperparameter”),并且不能通过训练来改变。当人们谈论 LLM 的“context window”时,他们指的就是这个数字。
我们在位置 0 有token 6307,在位置1有 47701,在位置 2 有 318,在位置 3 有 1049。对于这些标记和位置,我们有两个向量:一个来自 WTE,另一个来自 WPE。我们需要将它们加在一起。四个得到的向量将是算法下一部分的输入:具有 Attention 机制的前馈神经网络。
对于 SQL 部分,将使用 pgvector,这是一个 PostgreSQL extension。
运行结果:
笔者:这部分内容有点晦涩,我只能理解 Attention 机制要做的事情,但具体的原理理解比较困难。
真正让 Transformer 架构运作起来的是它的 Attention 机制。它首次出现在 Vasmani 等人在 2017 年发表的论文《Attention Is All You Need》中,这可能是最著名的 AI 论文。
目前为止,我们已经有了几个向量,我们希望这些向量编码了我们 prompt 中单词的一些句法和语义属性。我们需要这些属性以某种方式传递到最后一个向量上。提前剧透下,它将是最后一个向量存储着续接词的嵌入。
在下面这个短语中“I looked at the violet and saw that it was not the usual …”,省略号后面的内容必须是你看到的东西(这个概念必须从“saw”跳转过来),是紫罗兰的某个属性(从“violet”跳到“it”然后到省略号),以及某种“unusual”东西(从“not”和“usual”跳转并在负责表示 usualness 维度上相反的符号)。现实世界中的类比可能是一个人在阅读一种他们基本掌握但并不非常熟悉的外国语言的书。他们需要有意识地从一个词跳到另一个词,如果他们不注意短语的关键部分,他们的理解就会出错。
为了实现这种从一个 token 到另一个 token 的意义转移,我们需要让所有 token 的向量相互影响。如果我们想给单词“it”赋予一些具体的语义,那么多少语义应该来自 prompt 中前面的向量,又有多少应该保留单词“it”本身的语义?
为了解决这个问题,模型使用了 12 组矩阵,即 Q(查询)、K(键)和 V(值)。每个矩阵有 64 列。它们通过一个 768×2304 的线性变换 c_attn 从向量嵌入中获得,其权重和偏差分别存储在 c_attn_w 和 c_attn_b 表中。 c_attn 的结果是一个有 n_token 行和 2304 列(3×12×64)的矩阵。它由 12 个 Q 矩阵、12 个 K 矩阵和 12 个 V 矩阵按此顺序水平堆叠组成。 每组 Q、K 和 V 称为一个“head”。它们用于执行“multi-headed causal self-attention”步骤,通过计算 attention 函数来完成。 以下是 attention 函数的公式:
这里 softmax 函数是权重归一化函数。它的定义如下:
M 是一个叫"因果掩码"的常数矩阵,它的定义是:
softmax 将负无穷大转换为零。
在之前的例子中,prompt 有 4 个 token,并且模型做的第一件事情就是为这 4 个 token 计算 4 个 embedding。随着模型的推进,这些向量将经历很多计算,但在大多数情况下,它们将是独立和并行的。一个向量的变化不会影响其它向量。自注意力(self-attention)模块是模型中唯一一个向量相互影响的地方。
一旦模型完成了计算,下一个 token 的候选将完全由最后一个 embedding 决定。所有的信息流应该指向这最后的向量,而不是来自它。在模型的前向传播过程中,最后一个 embedding 的瞬态值不应该影响前面嵌入的瞬态值。
这就是为什么我们要“掩蔽”后面的嵌入,使它们不通过这个特定的通道影响早期的嵌入。因此,在“multi-headed causal self-attention”中有“Causal”一词。
在机器学习中,一般来说,计算不应该涉及可变长度的循环或语句分支。所有的事情都应该通过简单的解析函数(加法、乘法、幂、对数和三角函数)的组合完成。这使得依赖于自动微分等技术的反向传播可以有效工作。 k-v 存储的数学模型表达式:
但它不是一个光滑的、可微的函数,它不会与反向传播算法很好地协同工作。为了使其有效,我们需要将其转换成一个光滑的函数,当k 与 q 接近时,这个函数应接近于v,而在其他情况下接近于0。
而高斯分布在缩放至 V,期望为 K 的时候,并且标准差足够小的时候,将满足这个要求:
在一个维度足够多的向量空间中,如果我们取一个固定的向量 K 和若干个向量 Q ,这些 Q 向量在每个维度上都从 K 随机均匀地偏离,它们的点积自然会形成高斯分布。因此,在向量空间中,“可微的 K-V 存储”的概念可以通过表达式(Q·K)V 来建模,这正是我们在attention 函数中使用的。
运行结果:
这里所做的工作包括:
再往前看一点:attention 模块是整个算法中唯一一个 token 在前向传播过程中可以相互影响的地方。由于在这一步禁用了后来 token 对前面 token 的影响能力,所以在模型的前向传播过程间,对前面 token 所做的所有计算都是可以重用的。
因为模型是通过向提示符追加令牌来操作的。如果我们原始的(分词后的)prompt 是“PostgreSQL ?is ?great”,而下一个 prompt 是(例如)“PostgreSQL ?is ?great ?for”,那么对前四个 token 所做的所有计算结果都可以为新的 prompt 所重用;无论追加了什么,它们都永远不会改变。 Jay Mody的解释性文章并没有利用这一事实(为了简单起见,本文也没有),但原始的 GPT2 实现确实使用了。 一旦所有的头部完成了,我们将最终得到12个矩阵,每个矩阵有 64 列宽和 n_tokens 行高。为了将其映射回嵌入向量的维度(768),我们只需水平地堆叠这些矩阵。 multi-headed attention 的最后一步涉及通过学习到的线性变换将值投影,变换的维度相同。其权重和偏差存储在 c_proj_w 和 c_proj_b 表中。
运行结果:
在 multi-headed attention 的结果传递到下一个步骤之前,原始输入会被加到这些结果上。这个技巧在原始的 Transformer 论文中有描述。它旨在帮助解决梯度消失和梯度爆炸的问题。
这是训练过程中的一个常见问题:有时参数的梯度变得太大或太小。在训练迭代中改变它们要么对损失函数几乎没有影响(因此模型收敛非常缓慢),要么相反,影响太大以至于即使是小的变化也会使损失函数远离其局部最小值,从而抵消了训练的努力。
Feedforward
这就是深度神经网络所做的。模型参数的大部分实际上在这一步骤中使用。 这个步骤是一个具有三层(768, 3072, 768)的multi-layer perceptron,使用高斯误差线性单元(GELU)作为激活函数:
这个函数在深度神经网络中被观察到能产生良好的结果。它可以像这样解析近似:
层连接的学习线性变换参数被称为 c_fc(768 → 3072)和 c_proj(3072 → 768)。第一层的值首先使用学习参数 ln_2 中的系数进行规范化。在前向传播步骤完成后,其输入再次被加到输出上。 这也是原始变换器设计的一部分。
这个输出是 GPT2 的第一个 block 的输出结果。
在前面几步看到的内容是在层中重复的(称为 block )。这些 block 按照管道方式设置,以便一个 block 的输出直接传递到下一个 block 。每个 block 都有自己的一组学习参数。 在 SQL 中,我们需要使用递归的 CTE 来连接这些 block。 一旦最后一个 block 产生了值,我们需要使用学习的参数 ln_f 来规范化它。 模型最终的样子是这样的:
第四个向量是模型预测的下一个 token 的实际 embedding ,我们只需要将它映射回 token 。
我们有一个嵌入(一个768维向量),基于模型,它捕捉了 prompt 最有可能续写的语义和语法,现在需要将它映射回 token。
模型进行的第一步就是将 tokens 映射到它们的 embedding。这通过一个 50257×768 的矩阵 wpe 来完成。我们将需要使用同一个矩阵来将 embedding 映射回 token 。
问题是,精确的反向映射是不可能的:嵌入不太可能(可能性很小)与矩阵中的任何一行相等。所以我们需要找到与 embedding “最接近”的 token 。
由于 embedding 的维度捕捉了 token 的一些语义和语法方面,我们需要它们尽可能地匹配。一种方法将是计算两个 embedding 的点积。点积越高,token 与预测越接近。
为了做到这一点,我们将通过矩阵 wte 乘以 embedding 。结果将是一个单列矩阵,高 50257 行。这个结果中的每个值将是预测 的embedding 和 token 的 embedding 的点积。这个数值越高,token 继续 prompt 的可能性越大。
为了选择下一个 token ,我们需要将相似度转换为概率。为了做到这一点,将继续使用 softmax 函数。
Softmax 具有满足Luce 选择公理的性质。这意味着两个选项的相对概率不依赖于其他选项的存在或概率。如果 A 的概率是 B 的两倍,那么其他选项的存在或不存在不会改变这个比率。
点积向量(在 AI 领域中称为“对数几率”)包含任意的分数,这些分数没有内在的尺度。如果 A 的分数大于 B,我们知道它更可能,但仅此而已。我们可以随意调整 softmax 的输入,只要它们保持顺序(即较大的分数保持较大)。
常见的一种做法是通过从中减去集合中的最大值来规范化分数(这样最大的分数变成 0,其余变成负数)。然后我们取一些固定数目( 比如说五个或十个 )最高分数。最后,在将每个分数提供给 softmax 之前,我们将每个分数乘以一个常数。
我们取的最高分数通常称为 top_n,乘法常数(其倒数)称为“温度”(T)。温度越高,概率越平滑,下一个选择的 token 不仅仅是第一个的概率就越大。
最后,我们准备开始一些真正的推理:运行模型,根据概率选择一个 token ,将其添加到提示中,然后重复此过程,直到生成足够的 token 。
正如上文提到的,LLM本身是确定性的:它只是一系列在预定义常数上的矩阵乘法和其他数学计算。只要 prompt 和 温度、top_n 等超参数保持不变,输出也将是相同的。
唯一的非确定性过程是 token 选择,它涉及到随机性。这就是为什么基于 GPT 的聊天机器人可以对同一个提示给出不同的回答。
我们将使用短语“Happy New Year! I wish”作为提示,并让模型为这个提示生成10个新词标。