自顶向下——可能要懂的底层原理简单讲解
自顶向下——可能要懂的底层原理简单讲解
0.致谢
uuz 让我在最前面写一个给他的致谢
感谢 ShaddockNH3 长期以来的帮助和指导
1.前言
现代的一些人工智能库(例如 transformer 库)提供了高度封装的方法供现在的 AI 应用开发去直接调用,从而不需要去关注方法的底层实现是什么样的,从顶层应用的角度上讲,这无疑是技术上的进步。以 Transformer 的实现为例,传统方法采用 Pytorch 去实现需要编写上百行代码,需要程序员将略显晦涩的数学语言翻译成机器可以执行的代码,但很可惜的是,这些数学公式实在不是那么好翻译的,在实际的开发环境中,没有人会去等待一个程序员从头开始使用 Pytorch 去实现 Transformer。
顶层高度封装的接口是现代人工智能技术发展的必然,毕竟反复搓轮子不是什么值得去花时间的事情,在实际的应用开发中也鼓励使用这些顶层封装好的方法,将时间花费在真正需要去关注的地方,反复纠结于那些将数学公式翻译为代码的工作不是一个开发者应该做的,这是科学家的任务。
但我曾经长期认为只要会使用那些封装好的方法就万事大吉了,后面发现其实并不是这样的。那些封装好的接口和方法固然好用,但是还有一些问题,就例如说我们在使用一些方法“炼丹”的时候,如果模型的 loss 始终没有下降,那这个时候我们应该要怎么办?这个问题从底层视角上看并不是一个难题,无非就是那么几种情况什么反向传播写错了,或者是 softmax 特性导致的一些梯度消失什么的。可在一些只关注于顶层应用的程序员而言,这其实是非常费解的,文档里面写着:“loss 应该随着 epoch 的增加而下降/趋于稳定” 而这样的情况确乎是闻所未闻的,甚至有些人根本就不知道 loss 是什么东西,只知道按道理而言,这个“变量”应该是要逐渐下降的才对,至少不能现在和跳舞一样疯狂抖动吧?这不是一个好的现象,这种纯顶层的设计反而在基本的工程指标面前形成了一个可怕的知识断层。
面对这样的问题常规的想法是:“那就让他们去从 Numpy 手写开始,一步一步去理解那些底层原理”。其实是有道理的这种想法,这条路径听着是非常美好的,因为如果真的坚持做下去的确能对底层的原理形成一个系统性的认知。可这太理想了,现实的情况是,很多人在面对那些晦涩难懂的数学公式和概念时就会马上敲退堂鼓,还没有等到写代码就放弃了,而这也并非是重点。
我想要通过我这篇 Blog 讲明白在我认知里一些顶层开发者也必须要懂的概念,也必须要有的一些科学家思维。其实通过我这样一篇文章就想着去完全理解底层的奥妙是不现实的,能起到一个抛砖引玉的作用就是最好了,所以接下来请带着批判性的思维去看。
2.从线性分类器到全连接神经网络
基本认知
最开始的人工智能是为了解决一些图像分类上的问题,在顶层我们通常调用大模型来解决一类任务,而在最底层,我们需要使用一些基础的算法,例如 nn(请注意,这个 nn 和后面我们会提到的神经网络的 nn 并不一样,它的完整表述叫做“最近邻算法”)以及其超集 knn(你不需要了解这两个到底是什么),他们的作用大致是通过计算物体像素之间的距离来判断物体的相似度,但由于 knn 存在非常严重的性能瓶颈,而且非常原始,逐渐落后于时代,难以满足更大规模以及更复杂的图像分类任务。
于是业界后来就提出了线性分类器,而线性分类器的那个线性映射公式,也在后来成为了人工智能领域的“圣经”
这个函数长得很像一个一次函数吧(我当时也叫它“风味一次函数”),但其实这个公式里的 和 都是矩阵。 是一个相对不那么重要的参数,重点是 ,我们一般叫这个变量“权重”。
那么为什么叫它人工智能领域的“圣经”甚至基石呢?从我们生活中的场景出发,我们经常会使用大模型问问题,而大模型就可以理解为 , 就是我们输入给大模型的提示词等等,而最后通过这个函数得到的结果,就是大模型输出给我们的各种模态的信息了。
讲到这里还不理解也无所谓,你只需要记住下面这一句话
“简单理解的话, 就是模型,而后面跟着的那个 不那么重要,但它也会影响最终结果”
模型生来是什么都不知道的,所以我们就要去训练它,而我前面说过了, 是一个矩阵,甚至是一个非常高维的矩阵,训练的本质就是不断的去调整这个矩阵里的每一个数字,说的简单点就是加加减减。可是这个时候问题出现了,如果我们都不知道怎么去评价一个模型的好坏,那么我们又要怎么去调那个参数呢?
那么这个时候就引入了一个非常重要的概念——“模型损失”,也就是我在前言里提了好多次的变量“loss”
损失具体是要怎么算的?不重要,把那个公式摆在上面不是我这篇博客的目的。只需要知道我们用 loss 去衡量一个模型的好坏就行了。
要算 loss 那肯定就要先算模型得分和理想得分之间的差距。普通的方法计算得分非常粗暴,就是直接算出一个原始数值,而现在常用的一种方法是叫做 softmax,这个方法将分数转化为 0~1 之间的一个数字以便于模型最后去计算损失,这是一个非常重要的方法。
那么大家直觉里面的 loss 为什么会随着训练而下降呢?训练的本质就是通过一个不断进行的程序(这个程序后面会展开讲),不断去做题,不断根据正确答案打分,然后不断去订正,不断去学习,然后模型会自己改自己的参数,会努力让自己下次的表现变的更好,而这个过程,是有专门的名词去描述的。
讲一个可能有点反直觉的事情,有人会想着说 loss 越小越好,但这其实是错误的观点,loss 太接近于 0 不是一件好事,而这个就是顶层应用上也会提到的一个概念叫做过拟合。过拟合的模型泛用性极低,对策性极强,总得来说强度属于大杯,而我们要的其实是一个泛化的模型,而不是一个死记硬背数据集的模型。 至于我们要怎么去约束呢?用一个叫“正则化”的方法,不过这个不重要
所以最好的模型不是 loss 非常低,而是在一个完美的范围内,而模型,是有天然过拟合的趋势的
前向与反向传播
前向传播,反向传播,这俩名词听着感觉挺晦涩的,从字面上感觉也很不明所以,但没关系,其实是很好懂的东西。
前向传播我一句话就能讲完:前向传播就是你把信息输入给模型然后模型得出答案的过程
反向传播如果用学院派的方法去讲,那真的会洋洋洒洒讲一大堆。这里你只需要记住:反向传播就是模型自己改自己参数的过程,就是我说的那个模型让自己变得更好的过程,目的是要让下次训练的 loss 下降
如果你还想知道更多,我可以告诉你的是,反向传播的一个重要过程就是求导,求完导之后有非常多的方法去做参数更新。然后更新的力度取决于一个叫做学习率的参数
反向传播是一个很“数学”的过程,但好消息是,Pytorch 已经提供了一个反向传播一键方法——torch.backward() 。使用这个方法只需要我们搭建好前向传播,然后反向传播就可以全自动了,非常方便。
神经网络
神经网络是一个超级套娃。
我们经常会听到神经网络有几层几层什么的,或许一开始觉得很晦涩,但是其实很简单啊,我拿一个最简单的神经网络公式举例
你感觉这个公式和上面我说的线性映射好像,就是把两个线性映射套在一起了。你的直觉是对的,神经网络就是这样的。
所以我为什么说神经网络是套娃,因为它就是把前一层的结果处理了一下,然后就把那个结果当成新的参数给下一层。只不过要记住,给下一层传信息的时候记得过非线性层,不然的话层数会坍缩。
这样子理解吧:假如说我们构建了一个 AI 工作流,我们要把一串杂乱无章的文本提取出来必要的信息生成一个 jsonl 文件,那么第一步我们就拿 A 模型去让它提取出有用的文本信息,然后把这个文本信息扔给下一个模型 B,让模型 B 去生成一个 json 文件,最后我们再拿模型 C 去检查模型 B 生成的结果有没有错,订正修改啥的,最后去写入 jsonl 文件里面去。这种信息在不同层间流动的过程其实就是神经网络工作的核心,因为它长得像人类神经元的传播
神经网络看似是一个很奇怪的概念,感觉不用讲也无所谓,但它是后面一些更高级方法的基石。而为什么标题写“全连接神经网络”呢?后面你就知道了,因为有不全连接的啊。
3.卷积神经网络
从图像算法上讲,早期主流的图像算法是经典的机器学习方法(如 kNN),后来发展出了全连接神经网络(NN)再到后来为了解决参数量过大的问题,演进出了非全连接的卷积神经网络(CNN),这是一个影响力非凡的东西。
在 Pytorch 中它时常被直接写成 torch.conv2d() 这个方法,但是这个方法里面有太多参数,例如 padding ,stride 等等。如果不了解卷积神经网络的一些基本概念,那么到后面这些参数基本就是等于乱填了,而可惜的是,CNN 和我们前面说的那几个不一样,它现在还在被广泛使用。
我上一节提了一嘴有“不全连接的神经网络”, CNN 就是那个不全连接的神经网络,它本质上还是疯狂去在不同层之间传信息,但是它传信息的方式是非常不同的。
CNN 最重要的那个概念叫做——“卷积核”,要怎么去理解?你可以认为这是一个“滤镜”,而 CNN 有很多这样的“滤镜”,这些滤镜的职责就是在我们给的数据上面疯狂扫,然后找出要的东西就会拉出来。卷积核的参数其实就代表着一种特征
那么这个时候其实就可以讲 torch.conv2d() 这个方法里面的三个参数都是什么意思了
-
kernel_size:这个就是卷积核的大小了,按照需求使用,一般而言这个参数越大,说明你越在意整体趋势,反之就是你更在意局部细节(但事实上,在现代应用中没有人会单纯通过加大卷积核尺寸去捕捉整体趋势,因为加大卷积核尺寸会导致计算量平方级增长),同时,这个参数的设置也会对性能产生影响 -
padding:这个叫做填充,因为我们定义的卷积核大小可能不能完美的匹配上尺寸,所以就要去填充一些 0 数据。 -
stride:这个叫做步长,也就是卷积核一次移动多少,一般而言选 1 和 2,但是还是看实际情况。
CNN 一般用在图像任务上,因为它实在是太暴力太力大砖飞了。常见的一些用途有
- 图像辨别分类,就例如那个手写数字分类任务。
- 超分辨率任务,通过训练实现将模糊图像提升为高画质(可以去玩玩那个叫 Bigjpg 的网站,那就是典型的 CNN 超分)
- ……
总的来说,CNN 就是不断的用更高效的方法去提取出局部的重要信息,而局部连接的特性就是体现在它使用卷积核而不是直接开算这一点上。在各种图像分析任务上 CNN 的发挥会非常好,因为它具备从抽象到具体,以及识别特征位置无关的特点。
4.从 RNN 到 Transformer
Transformer 一手缔造了现在各类大模型的辉煌,但我觉得可能需要讲一讲,Transformer 是怎么生出来的。
我前面讲的很多东西大多数都是在图像这个模态上做文章,而到了这里其实就要去接触现在我们用的最多的一个方向 NLP (自然语言处理)
语言有一个很重要的特征,每一句话都不是孤立的,都和前面说过的一些话有着千丝万缕之间的联系,那么我们要怎么去记录这些过往的状态就成了一个非常重要的问题了。
最开始 RNN 采用了一个叫做隐藏状态机制的东西,这个东西在传统线性映射的基础上加入了一步利用前面的隐藏状态去计算结果并影响最终答案的过程,这样子做事实上确实可以实现“记忆”机制,但是,你不觉得这样很简单粗暴而且很蠢吗?
后来 RNN 的优化版 LSTM 出现了,它引入了细胞状态的说法,它解决了 RNN 在反向传播中因为连乘效应而梯度消失的问题,简单来说细胞状态就是一个长期的记忆,并且有两个关键的操作就是输入和遗忘。
以上都只是科普而已,知道就行了,接下来的重头戏是 Transformer 是如何杀出一条血路的。
我认为 Transformer 的核心机制是必须需要知道的。它的核心机制我们叫做——“自注意力机制”
Transformer 完全抛弃了 RNN 那一套去用各种状态记录上下文记忆的机制,这一套机制到后面响应会超级慢,而且反向传播也会逐渐失效。那么 Transformer 做的革命性的改良是就是自注意力机制了
自注意力机制一般而言是这样子运作的
-
产生注意力权重:在这个过程中模型的每一个词都会与其他词进行点积计算,算出一个“相关度”,这些分数经过一遍 Softmax 之后会变成一组加起来为 1 的概率权重。
-
加权求和:将上面得到的权重去对句中每个词的特征向量进行加权求和,最后得到一个整合了上下文信息的新向量。
-
一路向下传:事实上,这个向量并不会直接变成最终答案,而是会被模型再反复加工,最后乘以一个巨大的词表矩阵,过第二遍 Softmax 吐出下一个词。
在这里需要注意一个底层细节:在 Transformer 内部,其实前后一共开过两遍 Softmax。
第一遍开在自注意力机制里面,它是为了算出当前这个词对上下文其他词的“注意力分配权重”(算出来的权重加起来等于 1);而当信息一路传到模型的最后一层(输出层)时,模型会把特征向量映射到整个海量的词表上,然后再开第二遍 Softmax,去预测“下一个词该蹦出什么的概率分布”。
详细的过程就不给了,毕竟不是数学课。
而 Transformer 架构依赖于两个核心组件
- 编码器:负责深度理解,负责处理输入的信息并且整合上下文
- 解码器:负责将编码器传来的数据解析,并且去预测下一个词该是什么。
你可能听过,现代的 LLM 大模型其实就是在做概率计算,而这个说法就是源于 Transformer 的底层设计
在这个机制中还有一个非常重要的概念叫做多头注意力,我们都知道,看待事物要从多个角度去看待,多头注意力就是去做这件事情,不同的注意力头会关注不同的特点,就例如说A 注意力头关注语法,B 注意力头关注情绪,C 注意力头关注实体等等,最后大家拼在一起就形成了一个整体的认知。
我们说自注意力机制把一大堆词丢在一块,那么模型要怎么去知道谁先谁后?Transformer 中有一个叫做位置编码和位置掩码的东西,基于正弦函数去实现,会给每一个词打上一个独特的时间标签,然后通过掩码来遮挡未来的词。这样他们就知道谁先谁后,不会乱套。至于为什么 Transformer 需要但 RNN 不需要,因为 RNN 自带位置识别。
那么训练的过程是什么样的?简单来说,训练的时候就是让每一个词去并行预测下一个词,然后一下子直接算出所有的 loss,用掩码去保证不会偷看到未来的词。
推理的时候就是去一个词一个词的生成,生成之后就拼到上文,作为新的序列去预测下一个词。
Transformer用自注意力一次解决了并行和长距离依赖——然后借着这个强大的架构,人们发现不用每个任务从零训练,只要在海量文本上做‘下一个词预测’这个游戏,得到一个通才,再用你们的数据微调,这就是你们现在拿到的每一个大模型API和基座模型文件的来源。
5.原理之后
只讲原理还是有点生涩了,我想着不如讲讲一些实际应用上的底层实现,至于更多的就交给你们去想了
为什么可以做到多模态
简单来说,Transformer 只认一个东西,那就是token 向量序列,而所有的模态在进入 Transformer 处理之前都需要将其转换为 token 向量序列(这个不同的模态有不同的方法)。这种的做法好处是具有非常良好的拓展性,因为在 Transformer 看来,它不知道你给的东西到底是什么东西,它只在乎,这东西可以变成向量序列,只要是向量序列,那它就能直接解析。
为什么会有长下文窗口的限制
Transformer 的自注意力机制要求每个词都去盯着所有词,都去计算相关性,那么这个在底层上的实现就是去通过一个点积计算实现的(想知道可以自己去查),一旦上下文长起来了,自注意力机制运行起来就会变得非常慢,而且会非常吃显存,如果不去做优化那么是很难吃得消的,但是优化往往是有代价的。所以下次不要说想一次丢一本《红楼梦》进去了
大模型缓存是什么
这个还是注意力机制的事情,因为 Transformer 生成第 N 个 token 是完全依赖于前面 N-1 个 token 的,那么就要每次都去跑一遍自注意力机制,没办法一次出全文。可是前面的很多计算都是完全重复的,聪明的做法就是把那些计算塞到缓存里面去,这样就可以很显著的减少计算次数。而我们部署大模型之后会发现显存占用持续增加,那其实就是大模型给缓存预留了一块地方。而使用缓存是有非常多的好处的,至少有些时候做优化你应该知道先去动缓存,而不是瞎调参数。
temperature是干啥的
依旧自注意力机制的事情。大模型吐出一个词是因为它觉得这个词的概率最大,它是贪心的,它每次会直接选取词表里概率最大的那个词。这样子做固然是稳定的,但是长此以往在面临有些场景时就会陷入呆板的困境。temperature是在大模型开启了采样策略时(你可以理解为一种摇号抽词),在还没有过最后一遍 Softmax 变成概率之前除以它,让概率分布更加平滑,这样子大模型就会吐出更多样的东西来。
模型为什么会对中间部分的理解差
这个和位置编码有关系,传统的 Transformer 其实位置编码是有一个上限的,这个和模型训练阶段有关系。如果推理阶段出现了超出它训练时的位置,那么它就会不知所措。尽管现代已经有人做了优化,但这个稀释效应还是刻在架构骨子里的。所以下次记得把重要信息放开头和结尾。
提示词工程为何有效
提示词工程让我们不用费劲的去微调模型(这个真的很麻烦),当我们输入提示词之后其实就已经在大模型内部建立了一个思维范式,从而让模型的自注意力自己更多的往我们设定的那个提示词方向去发展,也就是自注意力分配的权重更高了。
如果说的更准确点其实是这样的:大模型本质是一个条件概率生成器。提示词的作用是在语义空间中提供更精准的“上下文条件”,将模型导航到它在预训练阶段学到的知识和思维范式中。
两种微调
微调是在一个训练好的基座上进行的,这个要知道。一般有两种,一种是全参数微调,另外一种是 LoRA。
全参数微调简单来说就是从头到尾根据你的数据继续走一遍完整的训练过程,是非常烧钱的。要海量的显存,并且稍不留神就容易炸,它会更新全部的参数。
LoRA 不一样,它是外挂了两个矩阵去保护原本的矩阵不要被乱动,这两个外挂的矩阵是低秩的,含有的参数也更少,因为微调本来就只是“微”,不用满秩的矩阵那么大就行。最后我们得出的结果就是这几个矩阵一同作用的。所以说 LoRA 本质是外挂矩阵,所以它有可拔插的良好特性。
6.结语
讲到这里其实在我个人看来需要理解的底层知识已经讲的差不多了,我更希望我这篇文章起到的作用是抛砖引玉,而不是想着从我这篇文章里获得所有答案。
理解一些底层的原理其实是很重要的,这个可以帮助我们更高效更有方向的去做一些事情并且在遇到麻烦的时候有一个大致的思路去解决问题,查阅详细的资料,制定具体的方法等等。
最后还是那句话,因为我本人实际水平有限,如果有讲的不好的地方可以通过我的联系方式联系我指正。
——Schariac125 26.5.31 写于福州大学铜盘校区
