在学习大语言模型的基本原理的时候,发现遇到了很多问题,比如一个个单词是怎么转换为向量的,是提前得到的还是训练过程中得到?Transformer的输出向量又是怎么转化为自然语言的?Finetune的时候不是只需要输入和输出吗,为什么FineTune的脚本做了那么多的处理?找了很多资料发现基本都是在讲Transformer的原理,缺乏顶层视角的原理,最后不得已阅读了一下HuggingFace上关于LLaMA的代码才得到答案。
就像程序员也需要知道计算机硬件的基本原理一样,一个AI开发者也需要知道大语言模型的基本原理。本文主要是从AI开发者需要知道的角度来写,所以本文不会去涉及太底层的数学逻辑。下面一起来探讨一下。
整体架构
整体框架分为两部分
- 分词器(Tokenizer) :将自然语言转化为TokenID的形式,大体可以理解为每个单词对应一个数字。(实际中稍有差异不过不影响理解,后面会详细说明)
- 模型:输入是TokenID,输出也是TokenID。大名鼎鼎的Transformer是这里面的一部分,当然了是最核心的部分。
为了便于理解,我们看一下用huggingface的transformers
库来写代码是什么样的,做一下对比理解。
python
from transformers import AutoTokenizer, LlamaForCausalLM
# 加载模型
model = LlamaForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
#加载分词器
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
prompt = "Who is LogicBit?"
#对输入的自然语言进行分词,转化为list[TokenID]
inputs = tokenizer(prompt, return_tensors="pt")
# 模型推理,得到list[TokenID]
generate_ids = model.generate(inputs.input_ids, max_length=30)
# 将模型的输出list[TokenID]转化为自然语言
tokenizer.batch_decode(generate_ids, skip_special_tokens=True, clean_up_tokenization_spaces=False)[0]
从上面可以看出基本是和上面画的整体架构是一致的。使用接口上也是分为了分词器和模型。下面依次看一下具体的细节。
Tokenizer
目的:在计算的时候只能使用数字来进行表示,所以需要将自然语言转化为数字。
编码: 输入一个句子(str
),输出:对应的TokenID的列表(List[TokenID]
)
解码:编码的逆向操作。
基本原理:基于训练样本,通过某种算法得到一个词汇表,然后针对每个词汇进行编号就得到单词与TokenID(也就是编号)的对应关系。
举个例子
假如我的训练样本只有who is LogicBit
这句话,那么我的算法就可以直接是每个单词对应一个ID。由于在英文里面空格就是区分两个单词的,所以空格我们就可以不要了。去掉空格相当于只有三个单词。
python
#注意一下以下的代码是一个逻辑上的意思,无法运行。
#按单词出现的顺序进行编号
tokenID['who'] = 1
tokenID['is'] = 2
tokenID['LogicBit'] = 3
#编码
tokenizer.encode('who is LogicBit') = [1, 2, 3]
#解码
tokenizer.decode([1, 2, 3]) = 'who is LogicBit'
#Too Simple, right?
扩展一下
实际使用中,其实没有上面这么简单,训练的算法是比较复杂的。因为这里面有很多的考量。比如为了提高表示效率,一个单词可能对应多个TokenID。
举个例子,look
这个单词就有很多状态的词汇,现在进行时looking
,过去分词looked
,而watch
也同样有watched
,watching
。如果按照一个词汇对应一个ID,我们的词汇表就有6个['looking', 'looked', 'looking', 'watch', 'watched', 'watching']
,但是如果按照词根来的话就可以表示为[look, watch, ed, ing]
,只需要4个即可表示。这个时候比如说looking
就会对应两个TokenID, ['look', 'ing']
。下面具体来看个实际的例子。
python
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
tokenizer.encode("who is LogicBit?")
#output:[1, 1058, 338, 4522, 293, 21591, 29973]
# 1 是llama词汇表中一个句子的起始字符,对应'<s>',每个句子都会有,自动加上去的。
# who -> 1058
# is -> 338
# LogicBit -> [4522, 293, 21591] ,这里就出现了一个单词对应多个tokenid的情况。
# ? -> 29973
模型
模型整体的输入输出:输入也就是上面分词器得到的
List[TokenID]
,输出也是List[TokenID]
。 Embedding模块: 将每个tokenID转化为一个向量,向量的维度是模型定义的(看模型大小)。 Transformer:输入一排向量,输出另一排向量(向量的个数是一样的,向量的维度可能不一样) Output Translation:将Transformer的输出转为TokenID。
Embedding模块
- TokenID转化为向量
由于一个数字无法有效的衡量一个单词的含义,比如['who', 'error']对应[1058, 1059]
,但是你不能说who
跟error
是很像的东西吧,只能说毫无关系。由于一开始不知道哪个关系更近,所以就用一个向量来表示。先假设各个单词之间没有关系,然后在训练中学习。
python
## 还是前面的例子
tokenID['who'] = 1
tokenID['is'] = 2
tokenID['LogicBit'] = 3
#假设三个单词没有关系,由于词汇表容量是3,就可以用一个三维的向量来表示。
Vector['who'] = [1, 0, 0]
Vector['is'] = [0, 1, 0]
Vector['LogicBit'] = [0, 0, 1]
为什么要用这种方式? 因为这种表示方法在数学上是正交的,模型在计算相关度的时候一般用cosincosincosin的方式来做,这样子各个单词相关度都为0.
- Embedding
前面假设各个单词是没有关系,现实里面却是有关系的,所以我们还是需要学习这个关系。
Embedding=W∗VtokenEmbedding = W*V_{token}Embedding=W∗Vtoken
VtokenV_{token}Vtoken即为上面的tokenID转化后的向量。W为需要学习的参数矩阵,[DoutDim,DvocSize][D_{outDim},D_{vocSize}][DoutDim,DvocSize],DoutDimD_{outDim}DoutDim是由Transformer定义的向量维度,像LLaMA就是768维,DvocSizeD_{vocSize}DvocSize就是词汇表的大小。
Transformer
由于我们主要是讲LLM的顶层原理,所以Transformer的原理不是我们的重点,我们这里主要讲一下Transformer给我们提供的能力。(感兴趣的可以自己可以看下李宏毅老师的视频,讲得非常清楚)
我们这里只画了Decoder,是因为现在大部分生成式的模型都是用的Decoder。
输入输出:都是一排向量。
Output Translation
由于transformer的输出是每个token对应一个向量,但是我们需要的是tokenID,才能转化为自然语言。
输出是一个tokenID,其实是一个分类问题,只不过我们这里的类别比较多,和词汇表的大小一致。如何将一个向量转换为一个类别,机器学习里面有一个通用的做法,就是把这个向量的每个维度看成是一个类别,值代表概率。
- 线性转换:由于Transformer的输出的向量维度一般和词汇表的大小不一致,所以需要先转化为词汇表的大小维度,这个有点像是前面embedding的反向操作。经过转换后得到维度为词汇表大小的向量。
output=w∗zoutput = w*zoutput=w∗z
其中w的维度为(Dimvoc,DimT)(Dim_{voc},Dim_{T})(Dimvoc,DimT),z为transformer的输出,维度为(DimT,1)(Dim_T,1)(DimT,1).
- Softmax:将各个维度进行一次计算得到每个维度的概率。其实也可以不做这个操作,只是原来的数值加起来可能大于1,或者小于1,不方便后续算法的处理而已。
假设输出向量x=[x1x2…xn]假设输出向量 x= \begin{bmatrix} x_1\x_2\…\x_n \end{bmatrix}假设输出向量x=x1x2…xn
那么第i个类别的概率p(i)=exi∑k=1nexk那么第i个类别的概率 p(i) = \frac{e{x_i}}{\sum_{k=1}ne^{x_k}}那么第i个类别的概率p(i)=∑k=1nexkexi
- 选择:选择概率最高的作为输出的值即可。(注意一下,实际上大语言模型在生成答案的时候会加入随机性,并不一定选择概率最高的,只不过概率最高的被选中的几率更大)
预训练
给定上文,预测下文。简单来说就是预测下一个词是什么?举个例子,假设样本里面有一句话['I', 'am', 'happy']
,针对这句话的任务如下:
输入
I
,预期输出am
输入I am
,预期输出happy
注:上面只是为了方便理解,实际上并不是真的将一句话拆分成多次预测任务,而是并行计算,也就是输入I am happy
, 输出I am happy
,然后算法在计算某个位置的输出的时候不会考虑后面的结果是什么,也就达到了基于上文去预测的效果。
收集大量的句子按照如上进行训练。
Finetune
给定一个输入输出对的数据集,基于这个数据集在预训练的模型上面做微调。举个例子来说明一下:
输入:
who are you?
输出:I am a llm.
之前一直认为就是直接按照这个输入输出进行微调的。
其实并不是,这个看上面的预训练就可以理解了,在预训练的时候llm的输入输出是一样,所以在微调的时候输入输出应该也是一样的(不然transformer的attention计算会出问题,这个留给各位思考一下为什么? )。所以需要将上面两句话合并,也就是输入输出都是who are you? I am a llm.
一般Finetune的脚本都会做这个处理。
这里可能又会产生一个疑问?如果是这样子的话,那大语言模型的输出不是会把我们的问题带上吗,但是使用像chatgpt的时候,输出并没有包括输入的问题。这个是因为在对话的时候会做特殊处理,输出的内容会将问题过滤掉。但是原始的输出是会包括输入的内容的。
python
#使用huggingface的generate没有做处理的时候,就可以看到输出是带了输入的。
model.generate()