Skip to content

【AI】大模型LoRA微调和参数训练的原理分析(未完成) ​

todo 简单介绍 本来想一篇文章介绍 LoRA微调和参数训练的原理,在加上实验验证。但是没想到原理的介绍越来越长,因此还是分开文章说吧。

前置数学基础 ​

人工智能相关算法中,包含很多的数学知识,其中更是包含大量矩阵操作。虽然上学的时候学过,但已经忘了不少。因此这里再简单复习一下下文中会用到的一些数学知识。

矩阵乘法 ​

一个M×N的矩阵AM×N,一般表示M行N列。例如下面为三行两列的矩阵A3×2,每个元素aij表示这个元素在第i行,第j列。

[a11a12a21a22a31a32]

矩阵和矩阵之间可以进行运算,其中矩阵加法就是要求矩阵行列相同每个元素相加,矩阵相乘则麻烦一点。两个矩阵相乘,要求左边矩阵的列数等于右边矩阵的行数,形如:AM×N⋅BN×K=CM×K。矩阵相乘的结果是一个矩阵,行数和列数分别为左边矩阵的行数和右边矩阵的列数。其中的每个元素值为左边行于右边列中每个元素相乘再相加的结果。

设A3×2=[a11a12a21a22a31a32],B2×4=[b11b12b13b14b21b22b23b24]C3×4=A3×2⋅B2×4=[a11a12a21a22a31a32]⋅[b11b12b13b14b21b22b23b24]=[a11b11+a12b21a11b12+a12b22a11b13+a12b23a11b14+a12b24a21b11+a22b21a21b12+a22b22a21b13+a22b23a21b14+a22b24a31b11+a32b21a31b12+a32b22a31b13+a32b23a31b14+a32b24]

因此,矩阵相乘不满足交换律,因为换之后中间的行列数可能不相等,即使相等,计算结果也不同;但满足结合律,即计算括号可以重新组合。

交换律:AM×N⋅BN×K合法BN×K⋅AM×N不合法结合律:(AM×N⋅BN×K)⋅CK×J=AM×N⋅(BN×K⋅CK×J)

向量可以看作是一个特殊的矩阵,即行或者列为1。下面列举几种矩阵相乘的特殊场景。首先是矩阵乘向量,结果为另一个向量:

C3×1=A3×2⋅B2×1=[a11a12a21a22a31a32]⋅[b11b21]=[a11b11+a12b21a21b11+a22b21a31b11+a32b21]

然后是向量乘向量,结果有两种,如果左边一行形式向量乘右边一列形式的向量,则结果为一个数字。如果反过来,则是一个矩阵。

A1×3⋅B3×1=[a11a12a13]⋅[b11b21b31]=a11b11+a12b21+a13b31A3×1⋅B1×3=[a11a21a31]⋅[b11b12b13]=[a11b11a11b12a11b13a21b11a21b12a21b13a31b11a31b12a31b13]

对角矩阵指的是当矩阵的元素中行坐标和列坐标相等时才有元素值,其余都为0。这里展示一下普通矩阵乘对角矩阵的效果,可以看到相当于给列元素增加了一个系数。

C3×2=A3×2⋅B2×2=[a11a12a21a22a31a32]⋅[b1100b22]=[a11b11a12b22a21b11a22b22a31b11a32b22]

矩阵的秩 ​

首先介绍一下矩阵的初等变换。矩阵的初等变换有行变换和列变换,规则是一致的,这里以行变换说明。以下几种变换是初等行变换:

  1. 交换任意两行的位置
  2. 非0的常数乘任意一行
  3. 把任意一行乘以非0常数,加到另一行上面

如果一个矩阵经过任意数量的初等变换之后,尽量将矩阵中的行(或者列)变为全0。剩下的无法变化的行数(或列数)就是矩阵的秩。如何可以变为全0?如果一行是其它行的倍数,或者可以被其它多个行经过倍数和加减表示,那么这一行就可以变为0。

例如下面的例子,第二行是第一行的两倍,那么第二行=第一行*2,第四行=第一行+第三行,那么第二行和第四行都可以经过初等行变换处理为全0行。

[A1A2A3A4]=[1234246810102244]=[12341∗22∗23∗24∗210101+12+03+14+0]=[A1A1∗2A3A1+A3]

剩下无法被其它向量表示的向量,都是线性无关的,这些线性无关的向量个数即是矩阵的秩。从信息论的角度来说,这些可以被其它向量表示的向量,是不增加信息量的,它自身没有存在价值,属于冗余参数。

对数 ​

首先看一下对数的概念。如果a的y次方为x,那么y就是以a为底的x的对数。其中a>0且a≠1。其中自然对数In(x)是以e为底的对数。e是一个无理数,值为e=2.71828...。

ax=b⇔x=loga⁡bex=b⇔x=ln⁡b

对数满足一些特殊的运算性质:

loga⁡M∗N=loga⁡M+loga⁡Nloga⁡MN=loga⁡M−loga⁡Nloga⁡MN=N∗loga⁡M

再简单提一下指数的运算性质:

am⋅an=am+naman=am−n(am)n=am⋅n

对数相关的求导公式:

(loga⁡x)′=1xln⁡a(ln⁡x)′=1x

当a的范围在0到1之间时,与a>1时,对应y=loga⁡x的函数图像不同,一个开口向下,一个开口向上。例如下图中,红线是1/2时函数的曲线,蓝线是底数为2的曲线。不管底数的值如何,它们都经过(1, 0)这个点。

​

特别的,y=x−1这个直线是y=ln⁡x这个函数在(1, 0)这个点的切点,且ln⁡x≤x−1永远成立,且相等的位置只有(1, 0)这个点。通过图像可以直观感受到:

​

但是对于其它底数,这个不等式却不一定成立。例如y=log2⁡x在(1, 0)这个点的切点斜率(也就是导数)是1xln⁡2,和y=x−1的斜率不同,因此这条直线并不是切线,函数值也不一定都在这条直线下方,如图所示,x在1到2的区间内,函数值在直线上方。

​

偏导数 ​

首先来复习一下导数的概念。函数的导数也是一个函数,指的是函数在某一点的变化率,也可以被称作斜率。用极限公式表示如下:

设△x为变量x在某一点的变化y=f(x)的导数函数为:dydx=f′(x)=lim△x→0f(x+△x)−f(x)△x

当一个函数的变量有多个时,如果想求这个函数对其中一个变量的导数,那这就是偏导数。

设函数z=f(x,y)函数对于x的偏导数表示为:∂z∂x=∂(f(x,y))∂x函数对于y的偏导数表示为:∂z∂y=∂(f(x,y))∂y

如何求偏导数的值呢?实际上和普通的求导方式一致,即把当前求导的变量看做变量,而把其它变量看作常数即可。这里举个简单的例子:

设函数z=x2+y2+xy∂z∂x=2x+y(相当于dzdx)∂z∂y=2y+x(相当于dzdy)

从几何意义上来说,假设z=f(x,y)是一个三位曲面,则对x求偏导相当于曲面沿x轴方向的变化率,对y求偏导相当于曲面沿y轴方向的变化率。还有一些其它下面可能涉及到的求导公式:

(1u)′=−1u2(uv)′=u′v+uv′(uv)′=u′v−uv′v2

然后再说一下求导的链式法则,即复合函数的导数等于内层函数导数乘外层函数导数。这个法则对于导数和偏导数都适用:

设z=g(y)y=f(x)则z=g(f(x))链式法则:dzdx=dzdy⋅dydx设z=g(x,y)y=f(a)则z=g(x,f(a))链式法则:∂z∂a=∂z∂y⋅∂y∂a

这个法则在平时计算导数时也是经常使用的,我们看个求导的简单例子:

设:z=ln(x2),求dzdx解:令y=x2则z=ln(x2)⇒{z=g(y)=ln⁡(y)y=f(x)=x2dzdx=dzdy⋅dydx=1y⋅2x=2xx2=2x

但注意如果是多个子函数中都出现同一个求导的自变量a,那么链式法则就要变化一下了,需要每个涉及到自变量的都进行链式求导,再相加。

设z=f1(x,y)y=f2(a)x=f3(a)则z=f1(f3(a),f2(a))链式法则:∂z∂a=∂z∂y⋅∂y∂a+∂z∂x⋅∂x∂a

等比数列求和公式 ​

等比数列的每项与前一项相除,都是一个固定的个常数。定义如下:

等比数列的第i项为aiai+1ai=q,为一个常数通项公式:ai=a1qn−1前n项的和为Sn=a1+a2+...+an

现在求解等比数列前n项和Sn的计算公式:

Sn=a1+a2+...+an=a1+a1q+a1q2+...+a1qn−1Sn−a1=a1q+a1q2+...+a1qn−1=q(a1+a1q+a1q2+...+a1qn−2)=q(a1+a2+a32+...+an−1)=q(Sn−a1qn−1)=>Sn−a1=q(Sn−a1qn−1)=>Sn−qSn=a1−a1qn=>Sn=a1(1−qn)1−q当q≠1时

LoRA方法原理 ​

LoRA简介 ​

众所周知,大模型之所以有“大”这个字,是因为模型参数量非常大,训练和部署都需要较高的算力和内存(显存)。尤其是训练对于硬件的要求更高,在之前的文章中我们也描过:【AI】一文读懂大模型生态:分类/参数/结构/训练/GPU/评测/排行/社区。当我们想微调模型使其更好的适应某些任务或者知识时,如果将所有的参数一起调整,那么如此高的硬件要求会使得大部分开发者望而却步,无法实现。深度神经网络中的参数近似一个黑盒,我们无法区分出哪些参数对应哪些知识,精确的对某一部分参数做针对性调整。

那么有没有一种方法可以对大模型进行微调,但是不要求如此高的硬件性能,且不用精确挑选参数呢?有的,这就是LoRA方法。LoRA英文全称叫做Low-Rank Adaptation,即“低秩适应”方法,是由微软在2021年提出的,论文原文:[LoRA: Low-Rank Adaptation of Large Language Models(https://arxiv.org/abs/2106.09685)。

在大模型的每层中,有Q/K/V/O几个矩阵,这些矩阵的行数和列数都和向量维度有关,例如在Qwen3-0.6B中是1024‌×1024和1024‌×2048,每个矩阵的参数量都非常大。实际使用LoRA方法时,可以对这些矩阵全部微调,或者只选择部分微调。我们来看一下公式,这里以1024‌×1024为例说明。

设:W1024×1024为原始矩阵X1024为输入向量H1024为输出向量模型本身执行:H1024=W1024×1024X1024

如果对所有参数都进行调整,这叫做“全量微调”,相当于在W旁边挂一个和W一样参数量的矩阵,它对于硬件的要求非常高。

设:V1024×1024为全量微调的参数全量微调执行:H1024=W1024×1024X1024+V1024×1024X1024=(W1024×1024+V1024×1024)X1024

但LoRA方法,将些矩阵冻结不修改,而是在旁边挂A和B两个小矩阵,通过调整两个小矩阵的参数值来影响最终结果。

设:Ar×1024和B1024×r为LoRA微调的参数LoRA微调执行:H1024=W1024×1024X1024+(ar)B1024×rAr×1024X1024=(W1024×1024+(ar)B1024×rAr×1024)X1024=(W1024×1024+△W1024×1024)X1024

其中a和r为超参数。超参数的含义是我们在训练之前就提前确定好的参数,训练过程中不会变化。虽然训练中不会变化,但参数值对于模型表现还是有较大影响的,甚至有时需要尝试针对不同的超参数进行训练。

r表示AB两个矩阵的行数和列数,一般可以取8,16,64等值。这里我们以8来举例。对于一个矩阵,全量微调需要调整的参数量为1024×1024=1048576。而LoRA方法仅需调整AB两个矩阵,参数量为2×1024×8=16384。16384/1048576=0.015%,也就是说通过LoRA使用r=8的参数,调整的参数量为全量微调的0.015%。

同时矩阵乘法的性质,B1024×rAr×1024=△W1024×1024,AB两个矩阵无论r值取多少,相乘之后的矩阵形状都和模型原有的参数矩阵W一致,因此虽然LoRA方法参数量小,但它可以影响到模型原矩阵的每一个参数值。且当我们训练完成,希望将LoRA方法的调整混合回原模型时,也是简单的相加即可:

W1024×1024′=W1024×1024+B1024×rAr×1024=W1024×1024+△W1024×1024H1024=(W1024×1024+△W1024×1024)X1024=W1024×1024′X1024

a表示缩放比例。这个超参数如果固定不变,当r增大时,可以抵消由于矩阵维度增加带来数字增加,使得调节幅度突然增大的问题。但也有很多人与r搭配使用,使得a/r之后的数字相同,代表BA矩阵在不同的r下采用相同的缩放系数。

在实际应用中,BA矩阵可以合并进模型中,永久修改参数值,也可以保留外挂形式,甚至可以针对不同的任务微调不同的参数,使用时再切换不同的外挂矩阵。

简化计算 ​

合并进原模型矩阵时需要计算BA相乘,组成1024×1024的矩阵。但在LoRA训练时,却不需要这么处理,而是走一条计算量更少的路,这里我们对比两种计算方式。其中每个操作计算方式为结果矩阵元素个数*单个元素的计算量。第一种是计算BA相乘的方式:

△W1024×1024=B1024×8A8×1024乘法:1024∗1024∗8=8388608加法:1024∗1024∗7=7340032W1024×1024′=W1024×1024+△W1024×1024加法:1024∗1024=1048576H1024=W1024×1024′X1024乘法:1024∗1024=1048576加法:1024∗1023=1047552合计乘法:9437184加法:9436160

可以看到,第一种方式计算量最大的就是BA相乘。第二种是先另A与X相乘,这样会得到一个8维的向量,再用这个向量与B相乘,这种方式可以大幅缩减原有BA相乘的计算消耗:

C8=A8×1024X1024乘法:8∗1024=8192加法:8∗1023=8184H1024″=B1024×8C8乘法:1024∗8=8192加法:1024∗7=7168H1024′=W1024×1024X1024乘法:1024∗1024=1048576加法:1024∗1023=1047552H1024=H1024′+H1024″加法:1024合计乘法:1064960加法:1063928

通过计算结果可以看到,不管是乘法还是加法都明显大幅下降,这里我们再给出下降的比例,这里以乘法为例:

  • 9437184/1064960 = 8.86 第一种的计算量是第二种的接近9倍
  • 不使用LoRA方法的场景:只有WX这一个矩阵乘法计算,计算量为1048576
  • 9437184/1048576 = 9 以第一种方式使用LoRA,计算量为不使用的9倍
  • 1064960/1048576 = 1.0156 以第二种方式使用LoRA,计算量仅增加了1.56%

仅通过改变计算的先后顺序,能做到计算量相比不使用LoRA仅有微小的上涨,实际使用时可以几乎忽略不计。

低秩证明 ​

前面说到LoRA的中文名叫做“低秩适应”,这里的秩表示的就是矩阵的秩,低秩意思是它用一个秩较低的矩阵取适应这个大的W矩阵,这里秩的值就是超参数r。

前面说过r值是矩阵A和B的列数和行数,那么A和B的矩阵的秩是小于等于r的。那么BA相乘的组合矩阵,虽然维度是1024×1024,但是秩依然是小于等于r的。事实上它不超过A的秩,也不超过B的秩。这里我们证明一下这个结论:

B1024×rAr×1024=[b11a11+b12a21+...b11a12+b12a22+...b11a13+b12a23+......b21a11+b22a21+...b21a12+b22a22+...b21a13+b22a23+......b31a11+b32a21+...b31a12+b32a22+...b31a13+b32a23+..................]=[b11[a11a12a13...]+b12[a21a22a23...]+...b21[a11a12a13...]+b22[a21a22a23...]+...b31[a11a12a13...]+b32[a21a22a23...]+......]

可以看到,通过将矩阵乘法后的每一行拆分,可以将b看作系数,结果矩阵中的每一行都是由不同的B的系数乘以A矩阵中的每一行组成的(公式中为了方便说明以列向量表示,实际都是A的行向量)。通过这种方式,结果矩阵的每一行都是A矩阵的组合,根据矩阵的秩的定义,那么结果矩阵的秩肯定小于等于矩阵A的秩。

同理,我们查看结果矩阵的每一列,发现可以表示A矩阵作为系数,B的列向量作为结果矩阵每一列的组成部分。这里公式未列出,可以自行推导。因此结果矩阵的秩肯定小于等于矩阵B的秩。这里就证明了前面的结论:BA相乘的结果矩阵的秩,不超过A的秩,也不超过B的秩。

我们一般把r叫做LoRA训练后矩阵的秩。理论上训练结果的秩最大是r,小于r也是可能的。在实际的模型训练中,由于r的取值一般比较小,实际上都能让AB矩阵的秩达到r的状态。

使用LoRA来修改模型参数,好处是模型参数量小使得存储空间占用小,且训练容易。但低秩特性也有一些劣势,与全量微调的效果是有差距的。例如可以用LoRA做到调整输出风格,适配简单任务,但在学习有些新知识方面却较难做到。

初始化和均匀分布 ​

在训练之前,首先将AB两个矩阵初始化。其中A矩阵的每个元素初始化为随机数,B矩阵初始化为全0矩阵。这样在开始训练时得到的结果与原模型一致。

原模型:H1024=W1024×1024X1024LoRA初始化时:H1024=W1024×1024X1024+B1024×rAr×1024X1024=W1024×1024X1024+[00...00............]Ar×1024X1024=W1024×1024X1024

在这个初始化基础上,每次输入训练数据得到输出token,再根据输出token是否正确来反向更新AB矩阵的每个参数,每次只更新一个很小的值。最后训练完毕后得到AB矩阵最终的参数值。

那么应该如何A矩阵的初始化方式呢?理论上任意的随机数都可以,因为模型训练时会逐步调整参数值。但部分不合理的初始化方式会使得模型初始化时偏差较大,致使参数调整过程更长也更难。因此,合理的初始化方式可以让训练过程更轻松,更容易达到想要的结果。

微软在当年提出的论文中,使用的是高斯分布来生成随机数。但目前最常用的是采用Kaiming Uniform分布(均匀分布)来实现,这也是各大框架的默认方式。这是由中国人何恺明发明的,他还发明了深度残差网络(ResNet),现在几乎所有大模型都在使用。

首先介绍下均匀分布。均分分布指的是设定一个范围,在这个范围内生成随机数,且随机数的在这个范围内每个位置出现的可能性都是一样的。例如范围为(0,10),则随机数在(0,5)之间的概率和(5,10)一致,都是50%;且和(3,8)的概率也一致。

然后再介绍一下Kaiming Uniform分布,这个分布是为了ReLU激活函数设计的权重初始化方法‌。它的均匀分布中点为0,分布的范围是这样:(-bound, bound)。其中bound的计算公式如下:

bound=6(1+a2)fan_in

其中a与RELU有关,fan_in指的是输入维度的的个数。但本文的LoRA与激活函数是两回事,因此这里就不介绍公式和ReLU了。在PyTorch中为了和之前的分布公式做兼容,LoRA中的a值默认取5,计算结果如下:

bound=6(1+a2)fan_in=6(1+52)fan_in(带入a=5)=66fan_in=1fan_in

这就是LoRA中A矩阵初始化随机数的分布范围计算公式。带入fan_in的值为1024,最后计算出bound的结果为1/32 = 0.03125。因此均匀分布的范围就是 (-0.03125,0.03125)。

Softmax归一化函数 ​

前面我们介绍了LoRA方法的技术原理,也提到了如何对LoRA进行初始化,但是没有描述如何训练,怎么对这些参数进行更新。事实上,对LoRA中的AB矩阵进行训练的方式,与在原模型中训练方式的方式是一致的。都是先输入一段文本,转换成token列表,正向计算一遍输出预测下一个token的概率。然后与正确结果比较,通过反向传播更新参数值。只不过更新的参数值不一样。为了更好的理解参数训练流程,介绍一下从输出结果开始的部分参数训练的原理。首先介绍的是Softmax归一化函数。

通过大模型神经网络一层一层计算,最后得到的输出是一个向量,长度为词表长度,其中每个值为词表中每个token的对应值。这个值越大,表示预测为这个token的概率越高。但这个值可能是正数,也可能是负数,甚至可能向量中每个值都是负数。此时肯定不能将所有值简单相加算概率,否则就会出现负的概率值,而且可能某些token的概率超过1。这里举个几个例子:

设pi为第i个元素的概率例子1:A=[3,−1,3]p2=−13−1+3=−15例子2:A=[−3,1,3]p3=3−3+1+3=3

这明显是不可行的。因此需要一个归一化函数,将这些值对应位每个token对应的概率值,同时保证不会出现负值,且所有概率值相加为1。Softmax就是一个指数归一化函数。这里列举函数的计算公式:

设A=[a1,a2,...,an]为模型输出向量;n为向量长度,pi为第i个token的概率值pi=softmax(ai)=eai∑j=1neaj

可以看到,公式其实比较简单,就是将前面我们直接将输出值相加,改成了先计算指数再加和计算概率。因为不管原值如何,经过指数运算之后都变成了正值,因此保证了所有概率值相加为1,且不会出现负值。Softmax仅仅是比较值之间的差,根据差值计算概率。对于值中的“相同部分”并不会影响概率值。这里举几个例子:

例子1:A=[1,2,3]pi=eie1+e2+e3例子2:A=[101,102,103]pi=e100+ie101+e102+e103=e100⋅eie100⋅e1+e100⋅e2+e100⋅e3=eie1+e2+e3

通过上面的例子可以看到,虽然第一个例子是1,2,3;第二个例子是101,102,103;但我们利用指数运算性质,拆分出例子2中每个值的相同部分,然后被分子分母约掉,就只剩下每个数之间相差的部分了。因此Softmax做到了仅根据值之间的差来计算概率。

注意这是训练时计算概率值的方式。推理时也使用Softmax计算概率,但输出会多一些处理。这里是描述训练过程,因此不介绍推理的相关步骤。

损失函数和交叉熵 ​

损失函数概念 ​

前面我们通过Softmax函数,拿到大模型预测每个Token的概率值。在训练时,我们使用的是带答案的训练集数据,因此会有一个正确的输出token值。但只拿到这些还不够,大模型需要一个值来表示模型距离预测正确还有多远,即把大模型输出值和训练集“正确值”比较,看看模型预测的是正确还是错误,错误的量是多少。这样可以对模型当前预测结果给一个定量的评价,再根据这个评价调整参数值。这就是损失函数需要做的事情。

有人会说,这太简单了。如果模型预测token和训练集的正确token一致,那说明模型预测正确,如果不一致,就是模型预测错误。但这样只解决了正确性问题,没有定量的结果。试想模型输出的token值是一个概率,这个概率的大小不同,对于模型的评价应该是不一样的:

设P为模型输出的概率向量,pi为模型输出第i个token的概率例子1:P=[0.01,...0.01,pa=0.02,0.01,...0.01]且a为正确token,即预测正确例子2:P=[0.01,...0.01,pa=0.8,0.01,...0.01]且a为正确token,即预测正确例子3:P=[0.01,...0.01,pa=0.02,0.01,...0.01]且a为不正确token,即预测错误例子4:P=[0.01,...0.01,pa=0.8,0.01,...0.01]且a为不正确token,即预测错误

例子1和例子2都预测正确了,但是概率值相差巨大,对于模型的评价应该是一样的么?例子1和例子2都预测正确了,但错误的概率值相差巨大,对于模型的评价应该是一样的么?希望回答这些问题,就要找一个合适的损失函数来评价大模型场景的输出。

大模型场景实际上是一种One-hot分类场景,即输出值是一个向量,向量中的每个数字是这个分类的概率,但是只有一个分类是正确的。适用于One-hot分类场景的损失函数就是交叉熵。如果是多分类场景,例如给图像打标签,但是允许一个图像有多个标签,这时候就不能用交叉熵,需要采用其他损失函数。

损失函数的英文名叫做loss function,其中的损失就是loss。损失函数的入参为模型的输出值和训练集给出的结果,出参为得到的损失值,即模型离“完全正确”有多远。因此损失函数的输出一般为非负数,0值表示完全正确,值越大说明损失越大,即离正确越远。

信息量 ​

想要了解交叉熵,需要先了解信息量和熵的概念。信息量指的是一个事件发生时,提供给我们的信息有多少,或者说事件发生时我们的惊讶程度。例如一个概率为99%的事件发生时,信息量比较小,因为它几乎时必然发生的。但是当概率为1%的事件发生时,我们会非常惊讶,因为基本不可能发生的事情发生了。因此,信息量有如下的特点:

  1. 事件发生的概率越小,当这个事件真正发生时,信息量就越大。
  2. 概率为100%的事件发生时,信息量为0,概率为0的事件发生时,信息量为无穷大。
  3. 信息量为非负数。
  4. 如果两个事件独立,则两个事件同时发生时的信息量等于两个事件单独发生时的信息量相加。

因此,在满足这些条件的基础上,将信息量的公式定义如下:

设P(x)为x事件发生的概率,I(x)为x事件发生的信息量I(x)=log(1P(x))=−log(P(x))

​

通过对应的公式曲线图,可以看到当处于横坐标(也就是概率值)处于0-1的范围内时,函数值从无穷开始逐渐下降,一直到0。这里再明确计算下上面说的性质:

−log(0)=+∞−log(1)=0I(a)+I(b)=−log(P(a))−log(P(b))=−log(P(a)∗P(b))=−log(P(ab))=I(ab)

前面的公式中我们只用了log,没有提到底数值,事实上不同场景使用的底数不同。当希望计算信息量对应的二进制位时,以2为底数计算;而在深度学习大模型中,为了计算方便使用e为底数。这里举例下以2为底时,直接求得的信息量值,就是需要的二进制位表示。

−log2(0.5)=−log2(12)=log2(2)=1bit−log2(0.25)=−log2(14)=log2(4)=2bit−log2(0.1)=−log2(110)=log2(10)≈3.32bit

这里的二进制位是什么意思呢,可以看作是这个概率所需要的平均编码位数的最低值。这里举几个例子:

  • 四个事件,每个概率1/4。每个信息量为2。分别编码为 00 01 10 11 编码长度一致
  • 四个事件,每个概率为1/2, 1/4, 1/8, 1/8。对应信息量为1,2,3,3。对应哈夫曼编码为:0 10 110 111。编码长度一致。
  • 三个事件,每个概率1/3。每个信息量约为1.58。对应哈夫曼编码为:0 10 11。 平均编码长度5/3 ≈ 1.67 > 信息量。

可以看到,当概率是2的n次方时,使用哈夫曼编码的长度与信息量一致。但如果信息量计算结果非整数,那么哈夫曼编码的每个元素必须是整数值,因此可能达不到最小值。

信息熵 ​

前面描述的信息量,表示的是单个事件发生时的信息多少或者惊讶程度。那么对于一个完整的分布(即多个互斥事件的组合,加起来概率为1)它的平均信息量则用信息熵来表示,它是信息量的期望,也就是整个分布的平均惊讶程度。计算公式如下:

设X为整个分布,n为分布中事件个数H(X)=∑i=0nP(i)I(i)=−∑i=0nP(i)log(P(i))

可以看到,实际上就是每个事件发生的信息量乘事件发生的概率。以通常意义来讲,如果这个分布确定性较高,那么信息熵比较小,如果分布的更随机,那么信息熵就更大。当事件个数固定,每个事件的概率相等时,分布最随机,此时信息熵最大。这里举个以2为底的例子:

  • 两个事件,每个概率1/2。信息熵为1.
  • 两个事件,概率分别为1/4,3/4。信息熵为1/2 + 3/4*0.415 ≈ 0.81
  • 三个事件,每个概率1/3。信息熵约为1.58。
  • 三个事件,概率分别为1/8,1/8,3/4。信息熵为3/8 + 3/8 + 3/4*0.415 ≈ 1.06
  • 三个事件,概率分为1,0,0 信息熵为0

这个现象背后的含义是,如果每个事件概率相等,则最终哪个事件发生是非常不确定的,难以预测的。但如果某个事件概率较高(对应其它事件概率较低),那么说明这个分布更容易发生这个事件,更容易预测,则信息熵更低。极端情况下,当某个事件概率为1,必然发生,则这个分布整体是没有不确定性的,此时信息熵为0。

交叉熵 ​

前面描述的信息熵,表达了真实世界(或者说正确的)事件发生的概率分布所代表的平均信息量。对于模型来说,它不知道正确的概率分布是什么,它有一个自己的概率分布,通过学习来预测和逼近正确的概率分布,这就是大模型学习的意义。大模型预测的概率分布和正确的概率分布是有差别的,那么如果来描述这个差别呢,就要交叉熵。

设P(x)为正确的概率分布,Q(x)为预测的概率分布H(p,q)为交叉熵,n为事件个数H(p,q)=∑i=0nP(i)I(i)=−∑i=0nP(i)log(Q(i))

可以看到公式和信息熵非常相似,区别在于是以真实发生的概率乘模型预测概率的信息量,可以理解为真实事件发生时,我们心里想的是预测分布,以预测分布的角度对事件的平均惊讶程度。当我们预测的概率分布与真实分布完全一致时,交叉熵的格式就与信息熵完全一致了。

交叉熵减去信息熵的差值有个名称,叫做KL散度(Kullback-Leibler Divergence),用于表示两个分布之间的差异。KL散度的公式如下:

DKL(p,q)=−∑i=0nP(i)log(Q(i))−(−∑i=0nP(i)log(P(i)))=∑i=0nP(i)(log(P(i))−log(Q(i))))=∑i=0nP(i)log(P(i)Q(i))

为什么叫做“散度”?这个词的直观可以理解为“散开的程度”。其实它也想叫做“距离”,但是因为不完全满足距离的几个条件,因此被叫做散度。(下面的条件可以随便举个例子来证明不满足,这里证明就不列出了)

  1. 对称性 不满足 H(p,q) ≠‌ H(q,p)
  2. 非负性 满足
  3. 相同对象距离为0 满足
  4. 三角不等式,即三个对象两两连接,任意两条边距离的和大于第三条边的距离 不满足

交叉熵永远大于等于信息熵,且相等时表示预测分布与真实分布一致。也就是说KL散度的永远大于等于0,这也就是吉布斯不等式的定义。这里给出证明:

设交叉熵的公式以a为底DKL(p,q)=∑i=0nP(i)loga(P(i)Q(i))=∑i=0nP(i)ln(P(i)Q(i))lna=1lna∑i=0nP(i)ln(P(i)Q(i))=1lna∑i=0nP(i)ln(P(i)Q(i))=−1lna∑i=0nP(i)ln(Q(i)P(i))(由于lnx≤x−1且x=1时等号成立那么−lnx≥x−1)≥−1lna∑i=0nP(i)(Q(i)P(i)−1)=−1lna∑i=0n(Q(i)−P(i))=−1lna(∑i=0nQ(i)−∑i=0nP(i))=−1lna(1−1)=0∴DKL(p,q)≥0且当Q(i)P(i)=1时等号成立,即两个分布一致

注意看,虽然前面对数的不等式只对自然对数成立,但我们利用换底公式把任意底数转换为了自然对数,同时提出一个公共常数。由于最后减号两侧互相抵消,因此常数部分被不起作用。

在深度学习中,经常使用交叉熵作为概率分类的损失函数使用,交叉熵的值越小,即KL散度越小,则模型越逼近正确的概率分布,即模型的效果越好(当然也可能是过拟合了)。但是在大模型这种One-hot分类场景中,正确分布只有一个值,概率为1,因此交叉熵会在上述公式的基础上简化为非常简单的形式:

设x为正确分布中发生的事件H(p,q)=−∑i=0nP(i)ln(Q(i))=−0⋅ln(Q(1))−0⋅ln(Q(2))...−1⋅ln(Q(x))...−0⋅ln(Q(n))=−ln(Q(x))

反向传播和梯度 ​

概念说明 ​

大模型接收token列表,通过多层深度神经网络处理,最后输出一个词表长度的向量,这个向量的名字叫做logits。然后再使用前面介绍的Softmax归一化为概率向量,再通过交叉熵损失函数最后求得loss值。这个完整的过程叫做前向传播,除了计算loss之外,和推理过程是基本一致的。loss值可以评价模型离“正确输出”有多远,使用loss值作为基础,从后到前反向通过每一层神经网络,指导每个参数应该如何更新,即修改参数值。这就是反向传播的过程。

要知道大模型参数值非常巨大,如何根据loss值计算出每个参数应该更新多少呢?这就要通过梯度的方式。梯度实际上是每个参数对于loss的偏导数,即每个参数对于loss值的变化率。

试想我们计算loss的目的是通过loss来修改参数值,最终使得loss值变小。训练时需要经过大量数据,每个数据都会产生一个loss值,都会去更新每个参数值;那么每个数据对于参数值的影响,即每次修改的参数值范围是很小的。这里就和偏导数的直观含义类似:即参数有一个微小变化时,会对loss值产生一个微小的影响,这个微小影响就是参数在这个值的变化率,即偏导数。

因此,梯度就是偏导数。反向传播的概念就是通过loss值,一步一步从后向前计算出每个参数的偏导数。最后再将梯度输入优化器,通过优化器给参数一个微小改动,从而使得模型拥有学习和调整能力。注意优化器和修改参数值本身并不属于反向传播的过程。

我们可以把神经网络看作是一个超大的函数。前向传播时,函数的变量是token向量,网络中的参数值是常量。但反向传播时,我们把神经网络入参看作是不变的常量,将网络中的参数值看作是变量,通过这种方式来求偏导数。

logits梯度 ​

前面介绍过,神经网络的输出在经过Softmax归一化之前的向量,叫做logits。梯度的计算的起点就是logits,这里首先要计算logits中的每一个值对于的loss的偏导数。将交叉熵损失函数和Softmax合并起来一起计算偏导,会更简单,因此先尝试合并计算的方式。首先是列出两个函数合并后的公式表示:

设:L为loss值;x为正确token对应的词表序号;H为交叉熵函数;P为模型预测概率向量,每个元素为pi;S为Softmax函数;A为logits向量,每个元素为ai;L=H(S(A))=−ln(S(ax))=−ln(eax∑j=1neaj)=ln(∑j=1neajeax)=ln(∑j=1neaj)−ln(eax)=ln(∑j=1neaj)−ax

可以看到,这个公式和正确token对应的词表序号有关,因此求偏导时要分为两种场景,这里分别给出证明:

当i≠x时:∂L∂ai=eai∑j=1neaj=pi当i=x时:∂L∂ax=eax∑j=1neaj−1=px−1公式可以合并表示为:∂L∂ai=pi−yi(当i=x时,yi=1;否则yi=0)

可以看到,经过合并和化简,使用很简单的方式就可以求出偏导数,结果也非常简洁。这就是logits向量中每个元素对于损失的梯度。

分开证明 ​

前面介绍了合并计算的方式,但实际上单独对于交叉熵和Softmax求偏导计算梯度,也是可行的,就是麻烦一点(事实上也不是很麻烦,一开始我打算试一下看能求么,没想到直接就做出来了)。这里我们尝试将两个函数分开求偏导。首先求归一化后的向量对于loss的偏导数是非常简单的:

设Q=[q1,...qn]为归一化后的输出向量L为loss值;x为正确token。∂L∂qi=∂(−ln(qx))∂qi={−1qx当i=x0当i≠x

根据链式求导法则,如果想算loss对于logits的偏导数,可以分别求loss对于归一化向量的偏导数,乘以归一化向量对于logits的偏导数。第一个我们前面已经算出来了,第二个就是Softmax函数。

设:Q=[q1,...qn]为归一化后的输出向量A=[a1,...an]为logits向量∂qi∂ai=∂(eai∑j=1neaj)∂ai=∂(eai)∂ai⋅∑j=1neaj−eai⋅∂(∑j=1neaj)∂ai(∑j=1neaj)2=eai⋅∑j=1neaj−eai⋅ai(∑j=1neaj)2=eai(∑j=1neaj−eai)(∑j=1neaj)2按照Softmax函数公式qi=softmax(ai)=eai∑j=1neaj且q1+...+qn=1原式=qi∑j=1neaj−eai∑j=1neaj=qi(1−qi)

再根据链式法则合起来计算Loss对于logits的偏导数。注意首先纠正一个错误的求法:

错误∂L∂ai=∂L∂qi⋅∂qi∂ai正确∂L∂ai=∑k=1n∂L∂qk⋅∂qk∂ai

L即loss时一个数字,但是Q即概率是一个向量,这个向量里面的每个值都是一个关于A向量中所有值的函数,因此L对ai中实际上包含了Q的所有参数。因此需要这样计算:

L=fQ([q1,...,qn]);qi=fA([a1,...,qn]);因此按照偏导数的链式法则,应该这样计算:∂L∂ai=∂L∂q1∂q1∂ai+∂L∂q2∂q2∂ai...+∂L∂qn∂qn∂ai=∑k=1n∂L∂qk∂qk∂ai

由于我们前面只求了Q和A中下标相等的场景,这里再求一下不相等时候的偏导数。

∂qx∂ay=∂(eax∑j=1neaj)∂ay当x≠y时=−eaxeay(∑j=1neaj)2=−(eax∑j=1neajeay∑j=1neaj)=−qxqy

那么现在分开的偏导数已经全部求出来了,现在将他们拼合起来,求L对于A的偏导数:

∂L∂ai=∑k=1n∂L∂qk∂qk∂ai=0∂q1∂ai+...+0∂qn∂ai+∂L∂qx∂qx∂ai=−1qx∂qx∂ai={−1qi(qi(1−qi))当i=x−1qx(−qxqi)当i≠x={qi−1当i=xqi当i≠x=qi−yi(当i=x时,yi=1;否则yi=0)

费了一番功夫,这样我们就得到了和前面合并计算一样的梯度计算结果。虽然计算过程并不难,但确实比合并计算要麻烦,在模型实际运算中,因为合并计算公式简洁简单,因此都不选择分开计算。

雅可比矩阵 ​

向量如何对向量或者矩阵求梯度呢?这就需要介绍雅可比矩阵,它对于梯度的理解有重要的作用。我们先抛开大模型,重新假设X向量对Y向量求偏导数的场景。

设Ym×1=[y1,...,ym],Xn×1=[x1,...,xn]Ym×1=f(Xn×1)⟹{y1=f1(x1,...,xn)y2=f2(x1,...,xn)...yn=fn(x1,...,xn)则∂yi∂xj=∂(fj(x1,...,xn))∂xj

可以看到,当函数输出值是一个向量时,就相当于每个输出值有一个独立的函数,入参是X向量的全部参数。在向量xi对向量yj求偏导时,相当于挑出第j个函数来计算偏导数。因为i和j是独立的,因此向量对向量的偏导数实际是一个m×n的矩阵:

∂Ym×1∂Xn×1=[∂y1∂x1∂y1∂x2⋯∂y1∂xn∂y2∂x1∂y2∂x2⋯∂y2∂xn⋮⋮⋱⋮∂ym∂x1∂ym∂x2⋯∂ym∂xn]

这个矩阵就叫做雅可比矩阵。对应的我们前面计算过:单个数字对于向量求偏导,偏导数是向量;向量对于矩阵求偏导,结果是三维的雅可比张量;矩阵对矩阵求偏导,结果是四维雅可比张量。什么是张量?三维及以上维度的矩阵,就叫做张量。这也是深度学习相关工具(PyTorch,TensorFlow)中的数据表示形式。

线性层梯度计算和传播 ​

我们求得logits向量的梯度,实际上只是反向传播的第一步。logits是大模型的“中间结果”,并不是我们直接要调整的参数,因此我们还要继续从后向前传播,算出前面每个参数的梯度值。这里我们以一个线性层来举例梯度在神经网络中是如何计算的。对于非线性层,也是类似的计算和传播方式。

设向量维度为m,则一个线性层的矩阵计算公式可以这样举例。其中Y是输出向量,X是输入向量,A和B分别是模型中的参数。(为了公式简单一点,这里统一维度为相同数字,实际大模型中经常是不同的,但计算方式一样,只不过公式写起来没这么好看)

Ym×1=Am×mXm×1+Bm×1

在前向传播中,我们以X作为自变量计算结果。但是在反向传播计算梯度时,计算哪个参数的梯度,哪个参数就要作为自变量,其余的参数则作为常量。为了方便理解,这里我们可以假设Y就是logits向量。假设要求loss对AB等参数的梯度,按照链式求导法则,我们已经求得了L对Y的偏导数,因此只需要求Y对A和B的偏导数即可。

在实际模型计算时,并不需要得到loss对AB等参数的真正梯度公式,只需要计算Y对于参数的梯度公式来就好了。因此在上一步logits向量的梯度计算后,我们拿到的是logits向量的梯度实际值,不需要再合并公式了。且模型层数越长,这个公式恐怕非常难表示。我们从简单的开始,首先对B求梯度。

∂Y∂B=[∂y1∂b1∂y1∂b2⋯∂y1∂bm∂y2∂b1∂y2∂b2⋯∂y2∂bm⋮⋮⋱⋮∂ym∂b1∂ym∂b2⋯∂ym∂bm]∵yj=AX+b当i≠j时,b=0求偏导数时,AX为常数。因此i≠j时,偏导数为0原式=[10⋯001⋯0⋮⋮⋱⋮00⋯1]

然后求Loss对于B的梯度。注意这里loss对Y的梯度是之前已经计算出的,这里直接套上实际的值来计算即可,没必要再合并公式了。且模型参数多层数长,这个公式恐怕非常难表示。

∂L∂B=∂L∂Y∂Y∂B=[∂L∂y1∂L∂y2...∂L∂ym][10⋯001⋯0⋮⋮⋱⋮00⋯1]=[∂L∂y1∂L∂y2...∂L∂ym]

然后再对A求梯度。因为A本身就是一个二维矩阵,因此雅可比张量是三维,这里就不列出张量了,我们直接写出矩阵中每个元素的偏导计算公式。(求对A偏导数时B直接变为0,因此这里就不列出了)

∂yk∂aij=∂([ak1...akm][x1...xm])∂aij=∂(ak1x1+...+akmxm)∂aij={∂(ai1x1+...+aimxm)∂aij当k=i∂(ak1x1+...+akmxm)∂aij当k≠i={∂(aijxj)∂aij当k=i0当k≠i={xj当k=i0当k≠i

然后我们再将L对Y的偏导数相乘,注意链式法则对于中间变量的连加。

∂L∂aij=∑k=1m∂L∂yk∂yk∂aij注意k从1到m遍历,必然遇到一次k=i=∂L∂yi∂yi∂aij=xj∂L∂yi

可以看到对A和B求的梯度都是非常简洁的。然后我们还需要对X求梯度。为什么?X并不是参数,我们不会根据梯度调整它的值,为什么要计算呢?这是因为在大模型中不止一层网络,一个公式,它是由好多线性或者非线性的公式一层一层向下计算的,上一个公式的输出Y,也就是下一个公式的输入X。因此我们计算了本公式X的梯度之后,这个梯度就是上一个公式中Y的梯度。然后再根据相同的计算方式向上继续求梯度即可,这对于线性层和非线性层都适用。因此,中间结果即使不用来调整参数,梯度还是照样计算的。

设第一个函数的输出实际上就是第二个函数的输入,即Y1=X2Y1=A1X1+B1Y2=A2X2+B2则首先计算出∂L∂X2=∂L∂Y2∂Y2∂X2=∂L∂Y1则第一个公式就可以直接利用这个结果来计算梯度了:∂L∂A1=∂L∂Y1∂Y1∂A1∂L∂B1=∂L∂Y1∂Y1∂B1如果还需要向前传播,则需要计算出∂L∂X1=∂L∂Y1∂Y1∂X1

我们再来真正的计算出X的梯度结果。

∂yk∂xi=∂([ak1...akm][x1...xm])∂xi=∂(ak1x1+...+akmxm)∂xi=∂(akixi)∂xi=aki

最后求出loss对X的梯度结果。

∂L∂xi=∑k=1m∂L∂yk∂yk∂xi=∑k=1maki∂L∂yk

梯度下降与优化器 ​

梯度下降法 ​

通过反向传播拿到各个参数的梯度值之后,下一步就是更新参数值,以求获得更低的loss。最直观也是最简单的方式,就是了类比导数的定义,将梯度值乘一个微小量得到一个数字,参数值减去这个数字,作为一个新的参数值:

设:θold为当前参数值;θnew为新参数值;η为学习率θnew=θold−η∂L∂θ|θ=θold

其中学习率,就是这里的微小量,可以取值为0.001,0.0001等。它可以作为训练的超参数,一旦设定后训练过程中就不再变化。也可以使用学习率调度器,在训练的不同截断单独调整学习率(后面单独介绍)。这里为什么是减去梯度而不是加上梯度?试想导数的定义时当x增加微小量时,y变化的差值。如果y增大那么导数就是正的;y减小导数就是负值。上面公式如果是加梯度,那么就变成了增大Loss了。但我们目标是减小Loss值,因此要减去梯度。

梯度下降法(Gradient Descent‌)就是直接使用前面的公式来更新参数值的方法。根据平均值情况的不同,分为以下三个方法:

  • SGD 随机梯度下降 Stochastic Gradient Descent
    • 一个样本就更新一次参数值
  • MBGD 批量梯度下降 Mini-Batch Gradient Descent
    • 多个样本组合为一个batch,一个batch完成后计算一次梯度值
  • BGD 小批量梯度下降 Batch Gradient Descent
    • 所有训练样本完成后再计算一次梯度值

例如我们训练集有10000个样本,SGD表示跑一个样本就更新一次;MBGD是跑batch个数据就更新一次(例如32 64 128等);BGD表示跑完所有10000样本再更新一次参数。MBGD和BGD的公式可以用这样的形式表示,只不过数量的n的大小不同:

θnew=θold−η1n∑i=1n∂Li∂θ|θ=θold

实际上就是对n个样本计算的梯度求一个平均数,以这个平均数作为梯度来更新参数。由于大模型训练时样本数量比较大,整个训练完是需要花很长时间的,而且模型学习率也是很多,需要修改很多次才能对参数值有明显改动,如果使用BGD方法更新,那么首先训练时间就太长了,难以接受。

而SGD要求一次只能跑一个样本;但是为了加快训练时间,利用GPU并行计算能力,训练时一般都会将多个样本拼接起来组成一个batch,一起放入大模型中并行计算。这时候如果使用SGD,必须训练一个样本更新一次,就不能使用并行计算多个样本的能力了。因此,大模型训练中使用最多的还是MBGD。

注意,虽然我们是多个样本一起更新参数值,但是每个样本的梯度是单独计算的。因为在前面梯度计算的公式中可以看到,偏导数的值依赖于模型输入,正确值,以及模型计算的过程和中间结果,这些数据合起来叫做计算图。因此即使参数值相同时,模型对于不同输入的梯度值是不同的,因此训练时会把计算图暂存起来,对每个样本进行自己的反向传播计算梯度,最后再求平均,但实际计算时反向传播也是利用GPU并行计算能力,一次计算整个batch的梯度。

事实上,由于大模型跑一次只能输出一个token,因此一个样本内部也是并行多个位置的token,同时反向传播的。关于在这个我们会在后面的参数训练流程部分中提到。

梯度下降类比 ​

前面关于梯度下降和偏导数的相关说明,相信很多同学是理解为什么要这么做的。但是网上很多同学也会拿下山这件事情来类比,这里我们也举例说明一下。

梯度下降法类似于:

  • 你想以最快的速度从山顶下到最低点 -> 对应希望求最小的Loss值
  • 但是你的眼睛被蒙住,看不到应该如何下山最快 -> 对应不知道全局最优解,也不知道函数应该表示为什么样子
  • 因此只能一步一步尝试 -> 对应模型只能一批样本更新一次参数,训练很多次才能调整好参数
  • 每次从最陡的方向下山一步 -> 对应每个参数的负梯度值,就是最陡的方向
  • 每一步的大小 -> 学习率
  • 当前所处的位置 -> 当前的参数值

因此,梯度下降法,确实很像蒙眼下山的场景。我们注意到其中一点:为什么参数的负梯度值,即减去梯度值,就是最陡的方向?换成数学的话来说,一个多元函数(即多参数函数),每个参数可以看作是一个维度;每个参数的偏导数作为这个方向的分量,将所有的参数的偏导数组合为一个向量,这个向量的方向就是函数当前上升最快的方向。对应大模型这里,就是这个向量的反方向就是函数当前下降最快的方向。

下面来证明这个结论。首先需要了解全增量和全微分的概念。

设:X为n个参数组成的向量,展开表示为[x1,...,xn]△X为向量增加一个微小量,展开表示为[△x1,...,△xn]z为函数值,在大模型场景中即为loss;△z为函数值在△X下的变化量公式表示为:z=f(X)全增量:△z=f(X+△X)−f(X)全微分:dz=∑i=1n∂z∂xi△xi=∂z∂x1△x1+...+∂z∂xn△xn△z≈dz当△X⟶0时,△z−dz=二阶及以上的无穷小量

可以看到,全增量就是函数在入参遇到微小变化时,实际的函数值变化。全微分则是这个微小变化用每个参数的微分*变化值来线性的近似全增量的值。他们的差值是这个微小变化的二阶无穷小,基本可以忽略不计。带入梯度下降法场景,全增量是我们实际参数调整后模型的结果,全微分是我们希望参数调整后的模型的结果,它们是近似的。再用蒙眼下山场景类比,全增量是是我们实际踏出的一步走的海拔变化,全微分是我们走之前预估走的海拔变化。

设:∇f(x)或∇f为X的偏导数组成的向量,展开表示为[∂z∂x1,...,∂z∂xn]dz=∂z∂x1△x1+...+∂z∂xn△xn=[∂z∂x1...∂z∂xn]⋅[△x1...△xn]=∇f⋅△X

可以看到,全微分可以表示为两个向量相乘,我们的目标是让向量相乘之后的值最大(加负号之后就变为了值最小)。在几何的维度下,两个向量相乘可以表示为两个向量的长度乘以cos夹角值:

设:θ为两个向量夹角∇f⋅△X=|∇f||△X|cos⁡θ

那什么时候乘积的值最大和最小呢,就是cosθ = 1和-1的时候。即两个向量的夹角为0度和180度,那也就是说X向量增加一个微小量ΔX的方向,即每个参数增长的比例,和梯度的方向一致时,值的增长最大;方向相反时,值的减小最大。因此按照负梯度值的方向下山,是最陡的方向,也是Loss值下降最快的方向。最后我们拿一个二元函数来举例:

z=x2+y2∂z∂x=2x∂z∂y=2y

​

这个函数的三维图像如上,是一个类似于锅的形状,最低点为(0,0),越往外围值越大。此时我们求三个点的值和梯度:

(1,1)z=2∂z∂x=2∂z∂y=2梯度向量(2,2)(1,−1)z=2∂z∂x=2∂z∂y=−2梯度向量(2,−2)(−1,−1)z=2∂z∂x=−2∂z∂y=−2梯度向量(−2,−2)

这里的梯度向量,实际上就是在坐标轴上的方向,注意这里只是表示方向,不表示位置,因此梯度值为(2,2)和(1,1)是没有方向的区别的。但是梯度向量值的大小会影响走多大的步。对应在图上,就是(1, 1)的点往x轴正向45度,y轴正向45度时,z值增长最大;(1, -1)的点时,x轴正向45度,y轴负向45度时,z值增长最大。

​

对应到图上,就是xy平面上有很多方向,x和y值往哪个方向走,z值增长最快。可以看到绿色箭头对应的在曲面上的投影是z值增加最快的方向,其它蓝色箭头的投影低一些,有的还会让z值减小。

梯度下降缺陷 ​

使用梯度下降法来更新参数,看似简洁又好用,但是也存在一些问题,这里列举几个。一是震荡问题:在面临一个两边都是下坡的最低点时,由于学习率是固定的,且模型梯度值也比较高,导致模型每一步会走的很大,直接从下坡的一侧穿过最低点到了另一侧。导致模型可以左右横跳震荡,很长时间都才能走到最低点。甚至可能步子迈大了,彻底跳出这个区域。

​

顺便说一下这里的曲线图是通过GeoGebra在线制作的,我先通过离散点定义曲线的形状,再通过函数排序和实际连成曲线。不同例子中离散点的数据不一样。具体公式如下:

L = {(0,0),(1,1),(-1,1),(2,4),(-2,4),(4,4),(-4,4),(6,4),(-6,4)}
Ls = Sort(L, x(L))
Spline(Ls)

上图中列举了这两种场景的示意。这两个场景是可以通过调小学习率来解决,但是调小学习率之后会造成训练时间增加,而且可能更会陷在局部最小值中出不来,这就是第二个问题。模型可能在局部最小点,驻点,鞍点等位置停住,模型无法继续前进。这些点的特性是梯度为0,函数“曲线”处在一个类似“平面”的位置上,带入梯度下降法的公式可以看到,此时参数的变化值为0,无法更新了,但实际上模型还没有走到全局最优点。这里依然用图片举例:

​

还有一种场景是模型遇到一个很长,而且下降幅度很低的缓坡,由于梯度值很小,因此模型经过缓坡时走一步的距离很小,导致模型需要很长的时间才能经过这个缓坡,非常影响训练效率。这个问题可以通过调大学习率来解决,但是调大之后前面的问题又更容易出现了。

​

BGD因为是直接用所有数据计算,没有随机因素,所以更容易发生这些问题。SGD和MBGD因为每次模型输入的数据不一样,带来了一些随机性,因此可以缓解部分现象,但问题依旧存在。为了尝试解决这些问题,又涌现出了其它梯度下降的方法,一般被叫做“优化器”。

动量法 ​

动量英文名是Momentum,实际上类似于物理学中的惯性。即运动中的物体在不受到外力作用时,保持它之前的运动状态;如果收到外力作用时,则新的运动状态之前的运动状态和外力作用的叠加效果。带入大模型场景,即参数每次的移动不仅看这次梯度的变化,还要参考历史的移动。这里列出动量法的公式:

设:θold为当前参数值;θnew为新参数值;η为学习率vold为当前“速度”,vnew为新速度μ为动量系数,g为参数梯度,即∂L∂θ|θ=θold的简写vnew=μvold+gθnew=θold−ηvnew

其中μ是预先设定好的超参数,一般设置为0.9。通过公式看到可以看到动量法引入了“速度”的概念,当前速度不仅看当前的梯度,还要参考之前的速度。这并不是完全套用惯性,但和惯性的特点类型,如果当前通过当前梯度为0时,速度依然是上一步速度的90%。虽然这仅仅是对梯度下降公式的一个小的调整,但是可以缓解前面提到的缺陷。

首先是缓坡问题。假设在较长一段距离内,梯度值都为0.1,我们对比梯度下降法和动量法两者每步走的距离:

设:η学习率为0.001;μ动量系数为0.9;g参数梯度固定为0.1v1=0+0.1=0.1θ1=0−0.001×0.1=−0.0001v2=0.1×0.9+0.1=0.19θ2=−0.0001−0.001×0.19=−0.00029v3=0.19×0.9+0.1=0.271θ3=−0.00029−0.001×0.271=−0.000561v4=0.3439θ4=−0.0009049...

通过每步的计算可以看到,走的越长,速度越快,通过这种方式穿过缓坡的速度会比梯度下降要块很多。那么当梯度不变时,这个速度最大值是多少呢?

vt+1=0.9vt+g当步数趋近于无穷时,假设存在一个稳定值vmaxvmax=0.9vmax+gvmax=g1−0.9=10g

通过推导可以得到,当步数趋近于无穷时,最大速度为1/(1-动量系数),当动量系数为0.9时,最大速度正好为10倍的梯度。因此动量法在穿过长缓坡时,最高速度可以达到梯度下降的10倍。

对于左右横跳震荡问题,因为两次的梯度方向相反,下一次的速度会被当前的速度抵消一些,因此可以解决部分震荡问题。通过下面例子可以看到,对比原始梯度的左右横跳,动量法在一边尝试下坡。

设:η学习率为0.001;μ动量系数为0.9;g参数梯度为10,−8,6,−4等v1=0.9×0+10=10θ1=0−0.001×10=−0.01v2=0.9×10−8=1θ2=−0.01−0.001×1=−0.011v3=0.9×1+6=6.9θ3=−0.011−0.001×6.9=−0.0179v4=2.21θ4=−0.02011...对比原始梯度下降法:θ1=0−0.001×10=−0.01θ2=−0.01−0.001×(−8)=−0.002θ3=−0.002−0.001×6=−0.008θ4=−0.008−0.001×(−4)=−0.004...

而且由于动量法具有惯性,在遇到局部最低点时,虽然梯度为0,但是模型还保留有速度,可能会通过惯性跳出局部最低点,避免陷进去。但也仅仅是部分场景可以,如果局部最低点又深又大,模型还是无法逃脱。

AdaGrad和RMSProp ​

在前面介绍的方法中,不管在训练的任何阶段和参数,学习率都是固定的超参数。但不同参数的调整速度不一定一致,有些参数需要慢速调整,有些参数则需要大步向前走,甚至有些参数早早的就找到了最优点,不再需要调整了。AdaGrad方法的英文全称叫做Adaptive Gradient Algorithm,即自适应梯度算法,可以根据参数历史的梯度值,对每个参数设置不同的学习率。下面是AdaGrad方法的计算公式:

设:θt为第t步的参数值,每个参数分别计算;η为学习率Gt为历史梯度值的平方相加,g为参数梯度ϵ为防止除数为0的超参数,一般为10−8Gt+1=Gt+g2θt+1=θt−ηGt+1+ϵg

可以看到,公式就是在基础梯度下降法的基础上,对学习率进行了变化,至于梯度本身没有改动。这样改动后,当累计梯度累计大的参数值分母更大,学习率低,即参数变化更慢;累计梯度累计小的参数分母更小,学习率大,参数变化快。通过这种方式,每个参数就有了自己独立的学习率。

为什么不是让梯度累积大的参数有更大的学习率,变化更快呢?因为这样参数值变化太快,比较难收敛。另外由于先平方再开方的形式,忽略了梯度的正负号,且对于梯度更大的值更敏感。这里举个例子:

g1=1G1=12=1G1=1g2=10G2=1+102=101G2≈10.05g3=100G3=101+1002=10101G3≈1005

可以看到,对于同一个参数梯度累积来说,经过平方后,更大的梯度值对学习率的影响会放大。即使再经过开方,大梯度对学习率的影响还是被放大的。

AdaGrad的G是一直在增加,越来越大的。对应的学习率分母会越来越大,这样就造成一开始很大,后续越来越低,低到一定程度后,参数值就基本不会变化,停止学习了。但可能此时参数值还没有到达最低点,没有训练完成。

这种学习率一直下降的场景并不符合实际情况,参数可能在某些时候需要高学习率,某些时候需要低学习率。因此在AdaGrad基础上进行改进,又出现了RMSProp方法。它的全称是Root Mean Square Propagation,即平方根传播方法。

设:θt为第t步的参数值,每个参数分别计算;η为学习率Gt为历史梯度的平方加权平均;β为权重,一般为0.9或0.99g为参数梯度,ϵ为防止除数为0的超参数,一般为10−8Gt+1=βGt+(1−β)g2θt+1=θt−ηGt+1+ϵg

RMSProp方法的计算公式和AdaGrad方法非常像,只是加了β这个权重系数,让历史的梯度累积影响逐渐衰减,但是看权重系数的取值一般比较大,因此衰减时间比较长。

通过这种方式,使得学习率具有AdaGrad的特性,又防止了梯度累积造成学习率过低,参数停止训练的问题。而且这种方式会使得参数在经过不同的位置时,出现不同的学习率,可以自适应的根据位置调整学习率。

移动加权指数平均和矩估计 ​

在前面RMSProp方法的介绍中,使用权重系数β让历史梯度的影响逐渐衰减,同时让本次梯度的权重为1-β,使得权重总和为1。这种计算方式并不是RMSProp方法独创的,它是一个公共方法,名字叫做移动加权指数平均(Exponential Moving Average, EMA)。它的公式定义如下:

设:vt为第t步的结果值,xt为第t步的参数β为权重系数,取值为0−1之间vt+1=βvt+(1−β)xt+1

公式非常简单,但是这样可以让权重的比例随时间缩小。我们将公式拆解开,第t步结果中每个参数的权重如下:

vt=(1−β)xt+βvt−1=(1−β)xt+β((1−β)xt−1+β(vt−2))=(1−β)xt+β((1−β)xt−1+β((1−β)xt−2+β(vt−3)))=(1−β)xt+β(1−β)xt−1+β2(1−β)xt−2+β3vt−3(1−β)(xt+βxt−1+β2xt−2+β3xt−3+...)

由于β的值为0到1之间,因此β的乘方越高,对应参数权重越低,对应到公式中,第t步也就是最新的参数权重最高,时间越早的参数权重越低,但是每个参数都对值依然有着贡献。使用这种方法,可以用来平滑数据,判断波动,提取特征等。它也是一种给参数根据时间变化求期望的方式。

矩是概率中的个概念,是用来描述函数分布特征的一种指标,包含几种包含X阶矩,以及中心矩,原点矩等:

设X=[x1,...,xi,...,xn]为第i个场景的值,pi为第i个场景的概率k为矩的阶数,μ为期望原点矩E[Xk]=∑i=1npi(xi)k中心矩E[(X−μ)k]=∑i=1npi(xi−μ)k

在实际意义上,一阶原点矩就是分布的期望值,一阶中心距是0,二阶中心矩则是方差。

一阶原点矩E[X]=∑i=1npixi二阶中心矩E[(X−μ)2]=∑i=1npi(xi−μ)2

矩估计则是一种参数估计方法,即我们使用样本计算出来矩的值,作为真实的矩的估计值,从而分析和尝试求解真实的分布。移动加权指数平均实际上就是对不同步骤的参数赋予一个权重概率,因此它也是一种矩估计方法。为什么要讲这些?因为在下面的Adam优化器中会用到。

Adam优化器 ​

Adam优化器的全称为Adaptive Moment Estimation,直接翻译的名字就是“自适应矩估计”。它集合了前面讲过的方法:使用动量法给梯度增加惯性;使用RMSProp方法自适应学习率;同时将这两种方法融合到矩估计上面,使其有更好的解释性。Adam的计算公式如下:

设:θt为第t步的参数值,gt为第t步的梯度值η为学习率,ϵ为防止除数为0的超参数β1为动量系数,β2为自适应学习率权重一阶矩动量法部分mt=β1mt−1+(1−β1)gt二阶矩RMSProp方法部分vt=β2vt−1+(1−β2)(gt)2偏差修正m^t=mt1−(β1)tv^t=vt1−(β2)t参数更新θt=θt−1−ηv^t+ϵm^t

可以看到,Adam并没有使用原始的动量法,而是做了一点改动,将当前梯度值也乘一个权重。最后的公式形式就是移动加权指数平均了。为什么要这么做?因为原始动量法的累加的,惯性会轻松过超过速度本身,如果β1为0.9,最高可达梯度的10倍,但是RMSProp方法的却始终是梯度平方的一倍,这样就造成了量纲的不统一,直接结合会有问题,因此将动量法也改成了移动加权指数平均的形式。

动量法改造之后,两个部分都是移动加权指数平均,而且动量法是一阶矩,确定了参数的更新方向;RMSProp方法是二阶矩,确定了参数更新幅度;这给Adam方法逃入了矩估计的理论基础。不过这两个部分都是原点矩而不是中心距。虽然中心距的实际意义和可解释性更好,但对于移动加权指数平均方法来说,还是原点矩更适用。

Adam还有一个改进时增加了偏差修正,即动量法和RMSProp算出的数字并不直接用来更新参数,而是经过偏差修正处理后再最终更新参数值。偏差修正的作用是什么?观察移动加权指数平均方法,由于开始时没有历史梯度累计值,因此启动非常慢,拿β1=0.9为例,最开始的m1仅仅为梯度的十分之一,β1也是类似的情况。而加入偏差修正后,会放大开始时计算的值,解决了启动慢的问题。我们看下例子:

设:每步的梯度值为一个常数g,β1动量系数为0.9m1=0+0.1g=0.1gm2=0.9∗0.1g+0.1g=0.19gm3=0.9∗0.19g+0.1g=0.271g...m^1=0.1g1−0.9=gm^2=0.19g1−(0.9)2=gm^3=0.271g1−(0.9)3=g...

可以看到当梯度值为一个常数时,偏差修正公式很神奇的将修正后的值与梯度值相同了!随着时间增长,mt越来越大,同时偏差修正公式中的分母越来越大,修正后的值始终和梯度值相同。事实上,通过偏差修正公式处理后,值为移动加权指数平均后梯度的期望值。而且不仅动量法适用,RMSProp也适用于这个偏差修正公式。下面通过求解得出这个公式,首先是动量法部分:

mt=β1mt−1+(1−β1)gt=(1−β1)(gt+β1gt−1+β12gt−2+β13gt−3+...)=(1−β1)∑i=1tgiβ1t−i对两边求期望,设gi的期望为一个常数gE(mt)=g(1−β1)∑i=1tβ1t−i=g(1−β1)∑i=1tβ1i−1=g(1−β1)1−β1t1−β1=g(1−β1t)⇒g=E(mt)1−β1t

因此,梯度的期望就是偏差修正公式处理后mt的期望。同样RMSProp也可以证明成立:

vt=β2vt−1+(1−β2)gt2=(1−β2)(gt2+β2gt−12+β22gt−22+β23gt−32+...)=(1−β2)∑i=1tgi2β2t−i对两边求期望,设gi2的期望为一个常数g2E(vt)=g2(1−β2)∑i=1tβ2t−i=g2(1−β2)∑i=1tβ2i−1=g2(1−β2)1−β2t1−β2=g2(1−β2t)⇒g2=E(vt)1−β2t

RMSProp求得的数据要经过开方,因此这里是梯度平方的期望。可以看到,这个偏差估计公式实际上是针对于移动加权指数平均。E(mt)和E(vt)这种属于梯度的有偏估计,通过修正可以变成无偏估计,即梯度(或平方)的期望。这就是偏差修正公式的意义,它可以消除移动加权指数平均带来的慢启动问题。

L2正则化和AdamW ​

AdamW方法是基于Adam改进得来的,但介绍AdamW之前,首先要了解L2正则化。前面提到的损失函数,反向传播和梯度下降优化器等方法,可以使得模型参数不断调整,学习到训练集中的知识和格式等,使得Loss下降。但是这样做也许会让模型参数过拟合,即在训练数据上表现非常好,但是在测试集和实际使用中却效果不好,即模型泛化能力差。

正则化方法可以解决这一问题。将前面的损失函数中另加入一项,表示参数的权重项即可。正则化有很多种方法,这里描述一下Lp正则化方法,即使用Lp范数作为参数权重项。Lp范数又L1范数,L2范数等,一直到无穷,表示不同意义下的距离函数:

设X=(x1,...,xn)为入参集合;Lp范数中的p表示幂次Lp范数的表示形式为:‖X‖p=(∑i=1n|xi|p)1pL1范数:‖X‖1=∑i=1n|xi|L2范数:‖X‖2=∑i=1nxi2L∞范数:‖X‖∞=max(|X|)即集合中绝对值最大的元素

可以看到,Lp范数的结果为一个距离值。L1范数为参数的绝对值相加;L2范数就是二维的欧几里得距离。因为幂次越高,绝对值更大的参数对于结果的影响更大,当幂次到无穷时,就只有最大的参数对结果产生影响,因此就只剩绝对值最大的元素了。

在大模型中,经常使用L2正则化作为避免过拟合的方式,即L2范数的平方,也就是去掉开方,因为这样求导方便。这里修改原有的损失函数,增加一项:

设:X=[x1,...,Xn]为所有参数的集合Lold(X)为原有的损失函数,Lnew(X)为新的损失函数λ为正则化系数Lnew(X)=Lold(X)+λ12‖X‖22=Lold(X)+λ12∑i=1nxi2

前面加1/2的原因也是为了求导方便。正则化系数是一个0到1之间的数字,则控制L2正则化方法对于参数影响的大小,不同的模型或者优化器设置的并不相同,常见为0.1或者0.01等。这样模型中每个参数对于损失函数的偏导数,都会增加一个参数大小的项,而且非常容易计算:

∂Lnew∂xi=∂Lold∂xi+λxi

L2正则化和梯度下降优化器是独立的,优化器将这个新的梯度值作为输入,经过处理后更新参数即可。但是在Adam方法中,这个新的梯度值会经过动量和自适应学习率处理再来更新,但L2正则化的部分不需要经过这些处理,处理后反而效果变差。

因此在Adam方法的基础上,出现了AdamW方法,其中W指的是Weight decay,即权重衰减。AdamW将L2正则化部分从损失函数和梯度值中拿出来,直接作为优化器公式的一部分,直接作用于参数更新,这也是权重衰减的意思。

Adam参数更新公式:θt=θt−1−ηv^t+ϵm^tAdamW参数更新公式:θt=θt−1−ηv^t+ϵm^t−ηλθt−1

注意依然要乘以学习率超参数。为什么?因为L2正则化项如果不乘学习率,可能会比Adam方法得到的值更大,大幅影响优化器本身的效果,甚至参数可能往反方向走。

通过AdamW方法,L2正则化并不需要修改原有的Loss损失函数,也不需要修改梯度值,仅仅在优化器中增加与L2正则求偏导值一样的一项即可,而且直接作用于参数更新,效果更好。Qwen3即采用了AdamW方法作为梯度下降的优化器。

学习率调度 ​

为什么需要学习率调度 ​

首先固定梯度,看adam方法拿到的更新值是多少,然后确定学习率的必要性。说明前面的rmsprop方法的自适应学习率是在不同参数之间调整学习率,但是学习率调度是对训练的所有参数统一的调整。

(先了解调度方法)

调度方法介绍 ​

参数训练流程 ​

batch之类的介绍 ​

一个样本一格一格跑 ​

总体参数流程图 ​

总结 ​

  1. 还有很多在LoRA基础上改进的方法
  2. 这里讲的参数训练流程,包括损失函数,梯度,优化器等,都只讲了大模型中常用的一个方法,事实上这些流程中涉及到的方法有很多。
  3. 虽然之前听很多人说神经网络算法不可解释,但没想到实际上这些算法原理全都是数学
  4. 虽然是数学,但也不怎么难,这篇文章中我涉及的公式,基本也就是大学高等数学的水平。
  5. 不过也有一些难的公式我没有讲,因为理解这些对于原理来说已经足够了,且不能一开始希望把所有东西都搞懂,要循序渐进的学习。
  6. 我或许应该读研的时候就按照这种学习方式,或许人生路径会有另一种结果呢
  7. 搞清楚原理是一回事,代码实现是一回事,能不能通过实验得到好结果是另一回事。
  8. 我喜欢把计算过程陡展示出来,在不限制文章长度的情况下,不喜欢太多“略”,“显而易见”等。毕竟有可能我现在懂,但是后面再看的时候,这些显然易见我自己可能也证明不出来了。

参考 ​