# Table of Contents - [RethinkFun深度学习 · GitBook](#rethinkfun-gitbook) - [如何学习深度学习 · GitBook](#-gitbook) - [语义分割 · GitBook](#-gitbook) - [迁移学习 · GitBook](#-gitbook) - [更多应用 · GitBook](#-gitbook) - [ResNet实现 · GitBook](#resnet-gitbook) - [目标检测 · GitBook](#-gitbook) - [语言模型采样 · GitBook](#-gitbook) - [Token编码 · GitBook](#token-gitbook) - [RNN的不同类型 · GitBook](#rnn-gitbook) - [双向循环神经网络 · GitBook](#-gitbook) - [自然语言处理的进化之路 · GitBook](#-gitbook) - [词典生成 · GitBook](#-gitbook) - [GPT2 · GitBook](#gpt2-gitbook) - [层归一化 · GitBook](#-gitbook) - [GPT3和GPT4 · GitBook](#gpt3-gpt4-gitbook) - [动机与影响 · GitBook](#-gitbook) - [GPT1 · GitBook](#gpt1-gitbook) - [Llama3 · GitBook](#llama3-gitbook) - [深度循环神经网络 · GitBook](#-gitbook) - [NLP常见任务 · GitBook](#nlp-gitbook) - [Llama2 · GitBook](#llama2-gitbook) - [BERT · GitBook](#bert-gitbook) - [GRU · GitBook](#gru-gitbook) - [RNN · GitBook](#rnn-gitbook) - [LSTM · GitBook](#lstm-gitbook) - [数据准备 · GitBook](#-gitbook) - [注意力机制 · GitBook](#-gitbook) - [模型定义 · GitBook](#-gitbook) - [Llama1 · GitBook](#llama1-gitbook) - [注意力机制 · GitBook](#-gitbook) - [模型评估 · GitBook](#-gitbook) - [DeepSeekV3 · GitBook](#deepseekv3-gitbook) - [位置编码 · GitBook](#-gitbook) - [BLEU评价指标 · GitBook](#bleu-gitbook) - [旅程的终点,也是新的起点 · GitBook](#-gitbook) - [DeepSeekMOE · GitBook](#deepseekmoe-gitbook) - [旋转位置编码 · GitBook](#-gitbook) - [MOE · GitBook](#moe-gitbook) - [利用Transformer实现翻译模型 · GitBook](#-transformer-gitbook) - [把这一切组装起来 · GitBook](#-gitbook) - [DeepSeekV2 · GitBook](#deepseekv2-gitbook) --- # RethinkFun深度学习 · GitBook [RethinkFun深度学习](https://www.rethink.fun/) =========================================== 还记得是2010年,那时我刚加入IBM,开始从事人工智能平台的研发工作。当我第一次看到“逻辑回归”、“神经网络”这些词时,感觉到既神秘又新奇。从此便开启了我人工智能的学习和职业生涯。我非常庆幸自己选择了人工智能这个职业方向,因为它实在是太神奇和有趣了。每学到一个新的算法都我能感受到它的有趣,而每一次训练好的模型开始工作时,我都不禁感叹它的神奇。 目前我在国内一家公司做首席AI科学家,在我自己这从事人工智能相关的这十几年工作经历中,有三次人工智能技术的突破让我惊叹。第一次是卷积神经网络引领的计算机视觉浪潮,它让机器从此可以看懂自然世界,完成很多原来只有人才能完成的工作,并且在一些视觉任务上超越人类表现。像人脸识别的门禁,或者提取照片里的文字这些在现在看来已经不算是高科技的技术,都是在卷积神经网络出现后才达到技术的普及,改变了我们的生活。 第二次是Bert预训练模型的出现,它让机器可以理解人类语言,又进一步扩展了机器智能的边界。记得当时第一次用Bert微调的模型来判断用户评论是否是投诉信息,以及从投诉信息里提取投诉的产品和具体问题类别。识别的结果让我非常惊叹AI的语义理解能力竟然能达到如此水平。 第三次是以ChatGPT为代表的大语言模型的出现,它让机器具备了原来只有人类才有的理解、推理、生成能力,让通用人工智能看起来不再遥远。还记得第一次使用ChatGPT时对我的震撼,当时我就确信这标志着一个新时代的到来。而现在我已经习惯于有什么问题先通过ChatGPT来寻找答案,因为我发现不论我问多么细节的问题,它都能很好的理解,并给出有启发性的回答。目前大模型技术还在飞速发展着,非常庆幸能生活在这个人工智能快速发展的年代。相信在不远的将来,大模型技术一定会在人类社会的各个方面引起大的变革。 当初我在学习人工智能技术的路上参考了很多前辈的资料,现在我也想回过头来仔细再重新思考一下深度学习技术的来龙去脉,并把我的思考记录下来,以帮助初学者。另外我觉得做一件事,有趣是最重要的。所以取名叫做Rethink Fun,希望自己的教程能有深度并且有趣。同时我注册了www.rethink.fun这个域名,会在这个地址以gitbook的形式发布我的教程。 年我在学习大模型技术时,同步在B站做了技术视频进行了分享。一方面在制作视频过程中让我重新整理了自己学到的知识,加深了理解。另一方面发布视频后收到了很多人的点赞和留言。看到大家对我视频的喜欢,也让我很有成就感。我在B站的账号为[RethinkFun](https://space.bilibili.com/18235884) ,欢迎大家关注。 之前应清华大学出版社编辑的邀请,我出版了一本《大模型核心技术和应用》的书。不过这本书是针对有深度学习基础的读者的。你可以在学完本门课程后阅读。 ![book](http://rethink.fun/imgs/book.png) RethinkFun深度学习这个教程适合初学者入门,但它不是一个速成教程。我会带着你从头开始慢慢了解深度学习技术。如果你把深度学习、人工智能作为你未来几十年的职业方向,那我建议你不妨慢下来,了解每一个技术细节,进行深入思考,相信你会从中获得乐趣。流水不争先,而在于滔滔不绝。 这个教程会涉及以下内容: * 深度学习的数学基础:包括线性代数,微积分,概率与统计。 * 从线性回归到全连接神经网络的演进。 * 解决计算机视觉问题的卷积神经网络。 * 解决序列问题的循环神经网络。 * 大模型的基础架构Transformer网络。 * 时下最热门的大模型,包括GPT,Llama,DeepSeek。 这个教程会提供以下资源: * 视频教程,在我的[B站账号](https://space.bilibili.com/18235884) 逐步更新。 * 电子书在我的[个人网站](http://www.rethink.fun/) 。 * 教程附带的数据和源代码,在[github](https://github.com/RethinkFun/DeepLearning) 可供大家下载。 最后希望你也能喜欢上人工智能技术,并从中获得乐趣。让我们开始吧! results matching "" =================== No results matching "" ====================== --- # 如何学习深度学习 · GitBook [如何学习深度学习](https://www.rethink.fun/) ===================================== 1.2.1 深度学习里的数学 ============== 很多人在准备学习深度学习时,都从网上或者前辈那里得到建议说要想学深度学习,一定要先学好微积分,线性代数和概率与统计这几门课。然后大家便带着对深度学习的热情,一头扎进了对数学的学习中。最后热情在枯燥的数学学习中被消耗殆尽,然后学习深度学习的事就不了了之了。 虽然这三门数学课程在大学和考研时各学过一遍。但我当年也是花了很多时间去复习这三门课程,不过幸好我当时对深度学习的热情还算比较大,坚持了下来。现在我回想起来,觉得自己花费了太多的时间在数学上,学了很多在深度学习领域用不上的数学知识。 如果你现在咨询我的建议,我会建议你在学习一些基本的数学知识后,就尽快开始深度学习知识的学习。快速体会到深度学习的奥妙,然后在某些算法需要更深的数学知识时再去查漏补缺。那时你将有更大的驱动力去学习。 在接下来的章节里我会假设你没有任何高等数学的知识进行数学知识的讲解。但仅学习深度学习必要的数学知识,然后就可以开始我们深度学习的学习了。 1.2.2 我对学习的理解 ============= 我认为学习实际上是一个说服自己的过程,你说服了自己,那你就认为自己学会了。也就是说如果你新学到的知识在你的知识体系里能自圆其说了,和你现有的知识体系没有矛盾了,那么你就认为自己学会了。 这也是为什么有的人说自己学会了,但是你和他深入探讨时,才发现他知之甚少。有时候你觉得自己对一个概念不理解,学的没有别人快。可能是因为你比别人较真,新的知识要进入你的知识体系,必须经过你之前学到的知识的种种验证。所以不要气馁,这时只要你坚持下来,搞清楚你的疑惑,学到的新知识会融合进你已有的知识体系,会发挥更大的作用。 1.2.3 这本书如何带你学习深度学习 =================== 这本书我会用尽可能简单有趣的方式来讲述深度学习的相关知识。学习深度学习的本质,避免走我之前自学时走过的弯路。带你一起体会深度学习的乐趣,让你喜欢上深度学习这门技术。 除了深度学习的核心原理外,本书还会带你学习PyTorch框架,以PyTorch框架为基础,实现各种基本的深度学习算法。 通过本书的学习,你将会深刻理解深度学习算法的核心原理,具备编程实现深度学习算法的能力。为你下一步从事深度学习算法的研究打下坚实的基础。 results matching "" =================== No results matching "" ====================== --- # 语义分割 · GitBook [语义分割](https://www.rethink.fun/) ================================= 11.9 语义分割 --------- 目标检测可以给出图片上目标的边界框。但是在某些情况下这个精度还不够,比如你想从照片中,识别出人像,并且把背景进行虚化。这时候你就需要知道图片中每个像素是人像还是背景。再比如对于卫星遥感照片,需要识别地面上森林的面积,森林的形状是不规则的。这时,就需要能识别遥感照片上每个像素是否属于森林。 这种需要对图片上每个像素都赋予语义信息的任务叫做语义分割(semantic segmentation)。 ![1065.png](http://rethink.fun/imgs/1065.png) 这一节我们就来了解一下语义分割的基本原理。 ### 11.9.1 语义分割的原理 语义分割任务实际上就是对原始图片上的每一个像素进行多分类。比如我们要对原始图片进行语义分割,其中类别包括:背景,行人,汽车,摩托车。输入的图片特征为224×224×3,那么经过语义分割的神经网络后,输出的特征图高宽应该一样,但是通道数为4,对应要分类的4个类别。所以最终输出的特征图形状为224×224×4。每个像素上都有4个特征,对这4个特征应用softmax,进行4分类,最终给出每个像素的分类值。 主流的语义分割网络都是先利用卷积神经网络对图片进行下采样,得到高级语义特征。然后利用**转置卷积**将特征图上采样到原始图片大小,再对图像每个像素进行分类。 ### 11.9.2 转置卷积 我们之前讲过的卷积操作,如果不加Padding,卷积核不为1×1,那么经过卷积操作,特征图的高和宽总是会变小。而转置卷积是一种特殊的卷积操作,它可以放大特征图的高和宽。 ![1066.png](http://rethink.fun/imgs/1066.png) 上图是一个转置卷积的计算过程。输入是2×2的特征图,转置卷积核的形状为3×3。进行转置计算时,步长为1。 * 第一步,取出输入特征中左上角第一个元素:1,与卷积核相乘,得到一个矩阵。 * 第二步,取出输入特征中右上角第二个元素:2,与卷积核相乘,因为步长为1,右移一位。 * 第三步,取出输入特征中左下角第三个元素:1,与卷积核相乘,因为步长为1,下移一位。 * 第四步,取出输入特征中右下角第四个元素:0,与卷积核相乘,因为步长为1,右移一位。 * 第五步,将上边四步的矩阵相加,最终得到一个4×4的扩充的特征图。 如果步长为2,则计算过程如下图: ![1067.png](http://rethink.fun/imgs/1067.png) 同样,转置卷积的卷积核参数是在训练过程中学到的。通过学习,它知道如何上采样能保持图像的语义信息。 ### 11.9.3 U-Net原理 ![1068.png](http://rethink.fun/imgs/1068.png) U-Net的网络结构图如上图所示,因为其像字母U,而命名为U-Net。 U-Net 的网络主要分为两个部分: 编码器(Encoder)——下采样路径 解码器(Decoder)——上采样路径 此外还有一条贯穿左右的 跳跃连接(Skip Connections),它是 U-Net 的核心创新之一。 **编码器(收缩路径)** 编码器类似于传统的卷积神经网络,用于提取图像的特征。它通常由若干个卷积层和最大池化层组成。每一次下采样操作都会减小特征图的空间分辨率,但增加通道数,从而捕捉到更深层次的语义信息。 **解码器(扩展路径)** 解码器通过上采样操作逐步恢复图像的空间分辨率。每一步中都包括上采样(如反卷积或上采样插值)和卷积操作。其目的是根据高层特征图还原出原图大小的分割结果。 **跳跃连接(Skip Connections)** U-Net 的一个关键特点是,它在每个上采样步骤中,把编码器中对应层的特征图与解码器当前的特征图进行拼接(concatenate)。这样可以保留低层的空间信息,使得模型在恢复图像时更加精细。这种跳跃连接机制帮助 U-Net 解决了图像细节信息在下采样过程中丢失的问题。让网络既能“看的懂”,又能“看的清”。 **U-Net 的输出** 最后一层使用一个 1×1 的卷积操作,将多通道特征图映射为类别数通道。然后使用 softmax 函数进行像素级别的分类。 需要注意的是U-Net原始论文里进行跳跃连接时,两边特征图的高宽并不一致,需要进行裁剪,这样很麻烦。两边特征图高宽的不一致是因为原论文3×3卷积并没有加Padding,导致特征图尺寸的变化。后来为了方便,实现时大家都利用加Padding的卷积层,保证了跳跃连接时两边特征图高宽的一致。 11.9.4 U-Net实现 import torch import torch.nn as nn class DoubleConv(nn.Module): """两个连续的 3x3 Conv(padding=1)+ BN + ReLU""" def __init__(self, in_ch, out_ch): super().__init__() self.double_conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.double_conv(x) class UNet(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() # 编码路径 self.conv1 = DoubleConv(in_ch, 64) self.pool1 = nn.MaxPool2d(kernel_size=2) self.conv2 = DoubleConv(64, 128) self.pool2 = nn.MaxPool2d(kernel_size=2) self.conv3 = DoubleConv(128, 256) self.pool3 = nn.MaxPool2d(kernel_size=2) self.conv4 = DoubleConv(256, 512) self.pool4 = nn.MaxPool2d(kernel_size=2) self.conv5 = DoubleConv(512, 1024) # 最底层 # 解码路径 self.up6 = nn.ConvTranspose2d(1024, 512, kernel_size=2, stride=2) # 上采样 ×2 self.conv6 = DoubleConv(1024, 512) # 拼接后通道数变为 512+512=1024 self.up7 = nn.ConvTranspose2d(512, 256, kernel_size=2, stride=2) self.conv7 = DoubleConv(512, 256) self.up8 = nn.ConvTranspose2d(256, 128, kernel_size=2, stride=2) self.conv8 = DoubleConv(256, 128) self.up9 = nn.ConvTranspose2d(128, 64, kernel_size=2, stride=2) self.conv9 = DoubleConv(128, 64) self.final_conv = nn.Conv2d(64, out_ch, kernel_size=1) def forward(self, x): # 编码 c1 = self.conv1(x) # c1: [B, 64, H, W] p1 = self.pool1(c1) # p1: [B, 64, H/2, W/2] c2 = self.conv2(p1) # c2: [B,128, H/2,W/2] p2 = self.pool2(c2) # p2: [B,128, H/4,W/4] c3 = self.conv3(p2) # c3: [B,256, H/4,W/4] p3 = self.pool3(c3) # p3: [B,256, H/8,W/8] c4 = self.conv4(p3) # c4: [B,512, H/8,W/8] p4 = self.pool4(c4) # p4: [B,512, H/16,W/16] c5 = self.conv5(p4) # c5: [B,1024,H/16,W/16] # 解码,第 1 级上采样 u6 = self.up6(c5) # u6: [B,512, H/8,W/8] # 直接拼接 c4([B,512,H/8,W/8])和 u6 u6 = torch.cat([c4, u6], dim=1) # 拼接后:[B,1024,H/8,W/8] c6 = self.conv6(u6) # c6: [B,512, H/8,W/8] # 解码,第 2 级 u7 = self.up7(c6) # u7: [B,256, H/4,W/4] u7 = torch.cat([c3, u7], dim=1) # [B,512, H/4,W/4] c7 = self.conv7(u7) # [B,256, H/4,W/4] # 解码,第 3 级 u8 = self.up8(c7) # [B,128, H/2,W/2] u8 = torch.cat([c2, u8], dim=1) # [B,256, H/2,W/2] c8 = self.conv8(u8) # [B,128, H/2,W/2] # 解码,第 4 级 u9 = self.up9(c8) # [B,64, H, W] u9 = torch.cat([c1, u9], dim=1) # [B,128,H,W] c9 = self.conv9(u9) # [B,64, H, W] # 最后一层 1x1 卷积,得到最终预测 out = self.final_conv(c9) # [B,out_ch,H,W] return out results matching "" =================== No results matching "" ====================== --- # 迁移学习 · GitBook [迁移学习](https://www.rethink.fun/) ================================= 11.7 迁移学习 --------- 回想你第一次学骑自行车的经历:想必经历过不少磕碰,花费了不少时间才掌握平衡。但你会发现,一旦学会了骑自行车,再去学骑电动车就容易多了。这种学习速度的显著提升,正是你在不知不觉中运用了**迁移学习**的原理。 ![1101.png](http://rethink.fun/imgs/1101.png) ### 11.7.1 什么是迁移学习 迁移学习的核心思想是:将在一个任务(源任务)中获得的知识或能力,有效地应用到另一个相关但不同的任务(目标任务)中。例如,从骑自行车中习得的保持平衡和转向控制能力,可以直接迁移到学习骑电动车上。这种能力的迁移能够大幅缩短学习新任务所需的时间。 ### 11.7.2 神经网络中的迁移学习 之前我们了解到,神经网络的浅层通常学习基础的通用特征(如线条、纹理),而深层则学习更抽象、更与任务相关的高级特征。最终,这些特征会输入一个由线性层和激活函数(Sigmoid/Softmax)构成的分类器(称为"分类头")进行图像分类。 假设我们已经在ImageNet的海量数据上训练了一个ResNet模型来完成1000类图像分类任务。现在,我们需要为一家面包店开发一个面包种类识别的视觉模型。这时,迁移学习就能大显身手: ![1102.png](http://rethink.fun/imgs/1102.png) 1. **加载预训练模型**:获取已在ImageNet上训练好的ResNet模型及其权重。 2. **冻结特征提取层**:锁定ResNet中负责特征提取的层(通常是除了最后的分类层之外的所有层),使其参数在后续训练中保持不变。 3. **替换分类头**:移除ResNet原有的1000类分类层,根据面包分类任务的需求(例如,10种面包),创建一个新的、随机初始化的分类层。 4. **微调新分类头**:使用标注好的面包图像数据集进行训练。在训练过程中,**只更新新添加的分类层的参数**,而预训练的特征提取层参数保持冻结。 通过这种方式,我们就能利用在ImageNet上学到的强大特征提取能力,即使只使用少量标注的面包数据,也能快速训练出一个性能不错的面包分类模型。 ### 11.7.3 迁移学习的一般原则与策略 迁移学习的核心流程是:首先在一个具有**大量标注数据**的数据集(如ImageNet)上训练一个**基础模型(预训练模型)**。然后,根据目标任务的特点和数据量,采取不同的迁移策略: * **任务相关性是关键**:新任务(目标任务)必须与预训练模型的源任务(如ImageNet分类)**领域相近**。任务越相似,迁移学习的效果通常越好。 * **策略取决于目标数据量**: * **数据量很少(如几百张)**:仅替换预训练模型的最后一层(分类头),创建一个新的、适应目标类别数的分类层。**冻结**网络中除新分类层外的**所有参数**。训练时**只更新新分类层的参数**。使用**较小的学习率**(如1e-4, 1e-5)。 * **数据量适中(如几千张)**:替换分类头。同时,**解冻**预训练模型**靠近输出端的最后几层**(例如ResNet的`layer4`)。训练时,允许这些解冻层的参数以及新分类头的参数一起更新。其他层参数保持冻结。学习率设置仍需谨慎。 * **数据量较大(如几万张或更多)**:替换分类头,并允许**整个网络的参数(包括预训练部分)**在目标数据集上进行更新(微调)。此时可以使用稍大的学习率(但仍通常小于从头训练的学习率),或采用学习率衰减策略。 **为什么主要替换最后一层?** 1. **分类需求不同**:预训练模型的最后一层(分类头)的神经元数量与源任务的类别数(如ImageNet的1000类)严格对应。新任务的类别数几乎必然不同,因此必须创建一个新的、尺寸匹配的分类层。 2. **特征组合不同**:即使新旧任务的类别数巧合地相同(例如都是2类),其分类的具体语义也完全不同。例如,源任务是区分狗的"公/母"(性别),新任务是区分"金毛/哈士奇"(品种)。最后一层负责将高级特征组合映射到具体类别,这种组合关系因任务而异,保留原参数没有意义,重新初始化更优。 3. **特征通用性**:网络的浅层和中间层学习到的特征(边缘、纹理、基本形状、物体部件)通常是**通用**的,与最终的具体分类任务关联较弱。这些特征对许多视觉任务都有价值,因此在数据不足时,冻结这些层可以保留其强大的通用特征提取能力,避免在小数据集上过拟合。 ### 11.7.4 用PyTorch进行迁移学习 只更换分类头: from torchvision import models model = models.resnet18(pretrained=True) # 加载 ImageNet 预训练权重 for param in model.parameters(): param.requires_grad = False # 冻结所有层 in_features = model.fc.in_features model.fc = nn.Linear(in_features, 1) # 新生成一个二分类头 更换分类头同时解冻第四阶段的参数: model = models.resnet18(pretrained=True) # 加载 ImageNet 预训练权重 for param in model.parameters(): param.requires_grad = False # 冻结所有层 # 解冻第四阶段的参数(最后一个残差模块) for param in model.layer4.parameters(): param.requires_grad = True in_features = model.fc.in_features model.fc = nn.Linear(in_features, 1) # 二分类 你可以用上边的代码去重新训练我们之前的猫狗分类模型,利用强大的ResNet和迁移学习,模型精度可以大幅提升。下边是利用迁移学习进行猫狗分类的代码: import torchvision.models from torch.utils.data import Dataset, DataLoader from PIL import Image import random import torch from torchvision import transforms, models import torch.nn as nn import os torchvision.models.resnet18() def verify_images(image_folder): classes = ["Cat", "Dog"] class_to_idx = {"Cat": 0, "Dog": 1} samples = [] for cls_name in classes: cls_dir = os.path.join(image_folder, cls_name) for fname in os.listdir(cls_dir): if not fname.lower().endswith(('.jpg', '.jpeg', '.png')): continue path = os.path.join(cls_dir, fname) try: with Image.open(path) as img: img.verify() samples.append((path, class_to_idx[cls_name])) except Exception: print(f"Warning: Skipping corrupted image {path}") return samples class ImageDataset(Dataset): def __init__(self, samples, transform=None): self.samples = samples self.transform = transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] with Image.open(path) as img: img = img.convert('RGB') if self.transform: img = self.transform(img) return img, label def evaluate(model, test_dataloader): model.eval() val_correct = 0 val_total = 0 with torch.no_grad(): for inputs, labels in test_dataloader: inputs = inputs.to(DEVICE) labels = labels.float().unsqueeze(1).to(DEVICE) outputs = torch.sigmoid(model(inputs)) preds = (outputs > 0.5).float() val_correct += (preds == labels).sum().item() val_total += labels.size(0) val_acc = val_correct / val_total return val_acc if __name__ == "__main__": DATA_DIR = r"E:\电子书\RethinkFun深度学习\data\PetImages" BATCH_SIZE = 64 IMG_SIZE = 128 EPOCHS = 15 LR = 0.001 PRINT_STEP = 100 DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") all_samples = verify_images(DATA_DIR) random.seed(42) random.shuffle(all_samples) train_size = int(len(all_samples) * 0.8) train_samples = all_samples[:train_size] valid_samples = all_samples[train_size:] train_transform = transforms.Compose([\ transforms.Resize((150, 150)),\ transforms.RandomCrop(size=(IMG_SIZE, IMG_SIZE)),\ transforms.RandomHorizontalFlip(p=0.5),\ transforms.ColorJitter(\ brightness=0.2,\ contrast=0.2,\ # saturation=0.2,\ # hue=0.1\ ),\ #transforms.RandomRotation(degrees=30),\ transforms.ToTensor(),\ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])\ ]) valid_transform = transforms.Compose([\ transforms.Resize((IMG_SIZE, IMG_SIZE)),\ transforms.ToTensor(),\ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])\ ]) train_dataset = ImageDataset(train_samples, train_transform) valid_dataset = ImageDataset(valid_samples, valid_transform) train_dataloader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=4) valid_dataloader = DataLoader(valid_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=4) model = models.resnet18(pretrained=True) # 加载 ImageNet 预训练权重 for param in model.parameters(): param.requires_grad = False # 冻结所有层 # 解冻第四阶段的参数(最后一个残差模块) for param in model.layer4.parameters(): param.requires_grad = True in_features = model.fc.in_features model.fc = nn.Linear(in_features, 1) # 二分类 model = model.to(DEVICE) criterion = nn.BCELoss() optimizer = torch.optim.Adam(model.parameters(), lr=LR) for epoch in range(EPOCHS): print(f"\nEpoch {epoch + 1}/{EPOCHS}") model.train() running_loss = 0.0 for step, (inputs, labels) in enumerate(train_dataloader): inputs = inputs.to(DEVICE) labels = labels.float().unsqueeze(1).to(DEVICE) optimizer.zero_grad() outputs = torch.sigmoid(model(inputs)) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() if (step + 1) % PRINT_STEP == 0: avg_loss = running_loss / PRINT_STEP print(f" Step [{step + 1}] - Loss: {avg_loss:.4f}") running_loss = 0.0 val_acc = evaluate(model, valid_dataloader) print(f"Validation Accuracy after epoch {epoch + 1}: {val_acc:.4f}") results matching "" =================== No results matching "" ====================== --- # 更多应用 · GitBook [更多应用](https://www.rethink.fun/) ================================= 11.10 更多应用 ---------- 由于章节所限,我们不能一一介绍卷积神经网络的所有应用。但是卷积神经网络几乎可以解决所有的图像问题,下边我们再来简要看几种应用。 ### 11.10.1 人脸识别 ![1070.png](http://rethink.fun/imgs/1070.png) 人脸识别的核心思想是让神经网络学习如何将人脸图像转换为一个紧凑且具有判别性的数字向量(称为特征向量或嵌入向量),使得同一个人的不同照片对应的向量非常相似,而不同人的照片对应的向量差异很大。我们让卷积神经网络最后一层的神经元的输出作为一个人脸照片的特征向量。 训练时,每组训练数据包含一个基准人脸照片(anchor),同一个人的另一张照片(positive),不同人的照片(negative)。损失函数让anchor生成的向量和positive生成向量尽可能的接近,而anchor和negative向量的距离尽可能的远。 ### 11.10.2 风格迁移 ![1069.png](http://rethink.fun/imgs/1069.png) 风格迁移像是给图片加一个滤镜,改变图片的风格。你需要提供两个图片,一个是内容图片,一个是风格图片。神经网络的任务是将风格图片里的风格迁移到内容图片中。 风格迁移的基本思想是,神经网络浅层学习到的是纹理,线条等细节信息,深层学习到的是高级的语义信息。风格迁移的网络是拿一个预训练好的的卷积神经网络。比如在ImageNet上训练好的VGGNet,它的各层已经可以很好的提取图片特征了。在训练时并不更改网络参数,而是修改内容图片(输入图片的RGB值在反向传播时被修改)。 **保持语义** 修改的原理是选取网络深层的某一层,要求生成图片这一层激活值尽可能和内容图片一致,这样保证图片的语义信息不变。 **迁移风格** 抽样网络浅层,深层等不同的几个层,要求生成图片在这几层各个通道之间的特征图的相关性尽可能和风格图片保持一致。为什么特征通道的相关性就能代表风格呢?假设一个通道识别的是斜的线条,另一通道识别黄色。那这两个通道同时被激活或者同时被抑制,相关性高,表明图片中的风格总是容易出现黄色的斜线条。如果生成图片网络各层通道相关性与风格图片一致,则两个图片的风格就一致。 ### 11.10.3 AlphaGo Google在围棋AI,AlphaGo里也应用了卷积神经网络。仔细想来,围棋和图片一样都具有“局部性”和“平移不变性”。每一部分的棋和自己周围的棋关系最大,而且同样的模式,不论在棋盘哪个位置,解法都差不多。 所以Alpha Go将棋盘视为图像: 围棋棋盘是一个 19x19 的网格,每个交叉点可以处于三种状态之一:空、黑子、白子。AlphaGo 非常巧妙地将当前的棋盘状态视为一个 19x19 像素的“图像”。每个像素点的“颜色”代表了该点的状态。为了提供更多信息(如“气”、征子、打劫等局部特征),这个“图像”实际上有多个通道。例如: 通道1:当前位置是否有己方棋子。 通道2:当前位置是否有对方棋子。 通道3:当前位置是否是空的。 通道4:己方棋子有多少“气”。 通道5:对方棋子有多少“气”。 通道6:上次落子位置(针对打劫规则)。 等等... 这样,一个复杂的围棋局面就转化为一个多维张量(例如 19x19xN),非常适合作为 CNN 的输入。然后预测下一步的落子。 ### 11.10.4 其他应用 ![1104](http://rethink.fun/imgs/1104.png) 卷积神经网络几乎解决了计算机视觉的所有问题,而且它还可以被应用到其他领域。就像应用在围棋领域一样。只要这个领域满足“局部性”和“平移不变性”就可以。比如我们可以利用卷积神经网络来进行城市规划,输入的每个通道可以是公路,人口,住宅,办公区,河流,绿化,医院,学校等信息,同时这些信息都具有“局部性”和“平移不变性”。 * * * 恭喜你,你已经掌握了卷积神经网络所有的基本内容。 扫码请作者喝一杯咖啡来分享你的喜悦吧! ![zsm](http://rethink.fun/imgs/zsm.png) results matching "" =================== No results matching "" ====================== --- # ResNet实现 · GitBook [ResNet实现](https://www.rethink.fun/) ===================================== 11.6 ResNet实现 ------------- 上一节我们介绍了ResNet的原理,这一节我们来讨论一些实现细节,最终给出完整的实现。 ### 11.6.1 ResNet的整体架构 ResNet提供了ResNet-18,ResNet-34,ResNet-50,ResNet-101,ResNet-152等不同的版本。后边的数字代表不同的网络层数。 ![1050.png](http://rethink.fun/imgs/1050.png) 上图是一个ResNet-18的整体架构。它分为6个**阶段**:Conv1-Conv5和Output阶段。Conv1-Conv5阶段内包含多个**残差模块(Residual Block)**。 **Conv1** 输入是224×224×3的图片。Conv1阶段首先对输入图片进行一个卷积操作,卷积核大小为7×7,输入通道数为3,64个卷积核,输出通道数为64,padding为3,stride为2。输出的特征图为112×112×64。这里是通过卷积步长设置为2来缩小特征图的尺寸。 **Conv2-Conv5** 可以看到Conv2-Conv5中,最重要的就是Residual Block了。后边我们会详细介绍。Conv2-Conv5,每一个阶段都有两个Residual Block。而且每次都是输出特征图的高和宽减半,通道数加倍。有一个细节不同:Conv2阶段是通过Max Poolling来减少特征图尺寸的,而Conv3-Conv5是通过卷积步长为2来减少特征图尺寸的。 **Output** Output阶段,首先利用一个全局平均池化层来抽取特征,接着连接一个全连接层(fully connection,fc),再加softmax激活进行分类。 ![1051.png](http://rethink.fun/imgs/1051.png) 上图是不同层数的ResNet的架构表格。通过观察可以看到: * 它们都由6个阶段构成。Conv1-Conv5,output。 * 不论ResNet有几层,它们的conv1和output阶段都一样。 * ResNet-18,ResNet-34比较类似,只是不同阶段里边的Residual Block个数不同,但是每个阶段的Residual Block都是一样的。 * ResNet-50,ResNet-101,ResNet-152比较类似,也是每个阶段的Residual Block一样,只是个数不同。 ### 11.6.2 Residual Block 下边我们就来详细看一下不同类型的Residual Block。 第一种是BasicBlock,用于ResNet-18,ResNet-34,由两个3x3卷积层组成。 第二种是Bottleneck,用于ResNet-50/101/152中使用,由一个1x1卷积层,一个3x3卷积层,一个1x1卷积层构成。 #### 11.6.2.1 BasicBlock 我们以ResNet-18为例,它的第二个阶段Conv2,由2个ResidualBlock构成, ![1052.png](http://rethink.fun/imgs/1052.png) 它的输入是Conv1的输出,特征图尺寸为 112×112×64。然后经过一个3×3的max poolling,padding=1,stride=2。输出特征图尺寸为56×56×64。 然后进入第一个Residual Block,经过64个3×3×64的卷积核,padding为1,stride为1,输出特征图的尺寸不变,还是56×56×64。然后进入Batch Norm,需要注意的是这里的Batch Norm是按通道进行的,因为每个通道代表一个不同的特征。上边我们讨论的是一张图片的特征图尺寸为56×56×64,在用批量数据进行训练时,tensor的shape就为:batch\_size×56×56×64(在PyTorch里是batch\_size×64×56×56)。其中56×56是高和宽,64是通道数。假如一个通道是检测眼睛这个特征的,那么在一个Batch里就有batch\_size×56×56个代表眼睛的特征。需要在这么多个特征上计算均值和方差,然后进行标准化,再进行线性变化。一句话来说,在卷积网络里,进行Batch Norm,不仅考虑一个Batch里不同样本,还要考虑同一个通道上的不同高,宽位置上的特征。 接着下来对特征图里的每个特征按元素进行ReLU激活。然后进入下一个卷积层,输出特征图尺寸不变,接下来是Batch Norm。捷径连接时用这个Residual Blcok的输入特征图,和Batch Norm之后的特征图进行相加,然后求和结果进行ReLU,作为Conv2阶段第一个Residual Block的输出。然后再经过一个同样的Residual Block,得到Conv2的输出。需要注意:这里每个Residual Block第二个Batch Norm后的特征图可以和Residual Block的输入特征图直接相加,是因为它们的形状完全相同,都是56×56×64。 接着我们来看ResNet-18的第三个阶段Conv3,它也有2个个ResidualBlock构成。 ![1053.png](http://rethink.fun/imgs/1053.png) Conv3与Conv2有以下不同: * Conv3第一个Residual Block里第一个3×3,128,p=1,s=2的卷积层实现了特征图高宽减半,通道数加倍的效果,输入特征图为56×56×64,输出特征图为28×28×128。 * 在整个Conv3里,剩下的3×3卷积层都是3×3,128,p=1,s=1。保持特征图尺寸一直不变:28×28×128。 * 因为第一个卷积层改变了特征图尺寸,导致输入和输出特征图尺寸不一致,这也让捷径连接产生了问题,因为两个特征图尺寸不同,不能直接相加。所以这里用了1×1,128,stride=2的卷积,同样对输入特征图进行高宽减半,通道数加倍的效果。经过这个1×1卷积后,就可以进行残差连接了。 > 你可能奇怪为什么1×1的卷积可以步长为2,那样不是就跳过大量的特征,这些特征就不参加运算吗?这是因为ResNet的设计哲学就是让捷径连接尽量不产生额外的计算量。让模型在加残差连接之前和之后,计算量差不多。但是残差连接必须保证特征图的长宽,以及通道数都一致才可以进行按位相加。而1×1的卷积,步长为2,是达到这一点(长宽减半,通道数加倍)计算量最小的实现。这里确实会丢失信息,但是信息还是主要靠主通道进行传递。 Conv4和Conv5都和Conv3一样,第一个3×3卷积实现特征图高宽减半,通道数翻倍,后边的3×3卷积都维持特征图尺寸不变,捷径连接用一个1×1,stride=2的卷积实现特征图尺寸的变化,然后进行连接。 下边我们给出BasicBlock的代码: class BasicBlock(nn.Module): # Standard ResNet BasicBlock (v1). # 两个 3x3 卷积,每个卷积后跟 BN 和 ReLU。若输入输出维度不一致,则在捷径路径使用 1x1 卷积。 expansion = 1 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super(BasicBlock, self).__init__() # 第一个Residual Block的卷积层可能输入和输出通道数不一致: # 对于Conv2,第一个Residual Block的卷积层输入和输出通道数一致。 # 对于Conv3-5的第一个Residual Block:输入输出通道数不一致,则stride设置为2,达到同时减半高宽,翻倍通道数。 # 第二个Residual Block的卷积层输入和输出通道数一致,stride设置为1,保持特征图尺寸一致。 self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) # 第二个卷积层的输入和输出通道数都是out_channels,stride=1,保证输入和输出特征图尺寸一致。 self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.downsample = downsample def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) # 如果输入和输出特征图尺寸不一致,需要调用stride=2的1×1卷积进行特征图尺寸的调节。 if self.downsample is not None: identity = self.downsample(x) out += identity out = self.relu(out) return out #### 11.6.2.2 Bottleneck 对于更深的ResNet,比如ResNet-50,ResNet-101,ResNet-152,它们都采用了Bottleneck结构的Residual Block。这样做是为了减少网络的计算量。正是因为采用了Bottleneck结构,ResNet-50的计算量几乎和ResNet-34的计算量相当。这样让训练更深层的卷积神经网络变得可以承受。 我们以ResNet-50的Conv2阶段,我们来分析它的3个Residual Block。 ![1054.png](http://rethink.fun/imgs/1054.png) 从下到上,我们先看Residual Block1,它的输入特征图是经过max pooling之后的56×56×64。 然后经过1×1,64,stride=1的卷积层,特征图尺寸保持不变,然后经过3×3,64,stride=1的卷积层,特征图尺寸不变。然后再经过一个1×1,256,stride=1,这里特征图的高和宽不变,但通道数翻了4倍。所以捷径连接这里也需要用1×1,256,stride=1来保持特征图高宽不变,通道数翻4倍,变为256。最后再将两个同尺寸的特征图进行相加。 接下来我们看Residual Block2,它的输入特征图是56×56×256,然后第一个卷积层是1×1,64,stride=1。这个卷积层不改变特征图的高宽,但是大幅降低了通道数,正是因为这里通道数的降低,让下一层的卷积计算量大幅降低。因为通道数在这里大幅下降,所以叫做Bottleneck。接下来在64通道上进行3×3卷积,然后再接一个1×1,256,stride=1的卷积层将维度提升为256。这样保证了这个Residual Block2的输入和输出维度一致,所以可以直接进行捷径连接。 Residual Blcok3和Residual Block2完全一样,先降低通道,进行3×3卷积,然后提升通道数为256。 接着我们分析ResNet-50的Conv3阶段,它有4个Residual Block。 ![1056.png](http://rethink.fun/imgs/1056.png) Conv3阶段的Residual Block1的输入特征图是56×56×256。 第一个卷积层通过1×1卷积,降低通道数为128。 第二个3×3卷积层通过步长为2,对特征图的高宽进行减半,通道数不变。 第三个卷积层通过1×1卷积,提高通道数为512。 Residual Block1的整体效果为对输入特征图高宽减半,通道数加倍。所以捷径连接这里需要一个1×1卷积来调整特征图高宽和通道数,才能进行特征图的按元素相加。 Residual Block2的输入特征图是28×28×512。 第一个卷积层通过1×1卷积,降低通道数为128。 第二个3×3卷积不改变特征图尺寸。 第三个卷积层通过1×1卷积,恢复到输入特征图尺寸。 Residual Block2在内部先降低通道数,进行3×3卷积,再恢复通道数,整体不改变特征图尺寸,所以捷径连接可以直接相连。 Residual Block3,4 和Residual Block2完全一致。先通过1×1卷积层降低特征通道数,然后进行3×3卷积,最后再利用1×1卷积恢复通道数,保证输入和输出的特征图shape完全一致。 Conv4,Conv5阶段和Conv3阶段原理一致。都是通过第一个Residual Block完成特征图高宽减半,通道数翻倍。后边的Residual Block内部先降低特征图尺寸,进行卷积计算,再恢复特征图通道数,整体不改变特征尺寸。 最终ResNet50的架构图如下: ![1055.png](http://rethink.fun/imgs/1055.png) 下边我们给出Bottleneck类型的Residual Block的实现代码: class Bottleneck(nn.Module): # Bottleneck block for deeper ResNet (v1). # 使用 1x1 降维 -> 3x3 卷积 -> 1x1 恢复通道数。 expansion = 4 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super(Bottleneck, self).__init__() mid_channels = out_channels self.conv1 = nn.Conv2d(in_channels, mid_channels, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(mid_channels) self.conv2 = nn.Conv2d(mid_channels, mid_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(mid_channels) self.conv3 = nn.Conv2d(mid_channels, out_channels * self.expansion, kernel_size=1, bias=False) self.bn3 = nn.BatchNorm2d(out_channels * self.expansion) self.relu = nn.ReLU(inplace=True) self.downsample = downsample def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) out = self.relu(out) out = self.conv3(out) out = self.bn3(out) if self.downsample is not None: identity = self.downsample(x) out += identity out = self.relu(out) return out 最后我们给出构建ResNet的代码: class ResNet(nn.Module): def __init__(self, block, layers, num_classes=1000): super(ResNet, self).__init__() self.in_channels = 64 self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) self.layer1 = self._make_layer(block, 64, layers[0]) self.layer2 = self._make_layer(block, 128, layers[1], stride=2) self.layer3 = self._make_layer(block, 256, layers[2], stride=2) self.layer4 = self._make_layer(block, 512, layers[3], stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512 * block.expansion, num_classes) def _make_layer(self, block, out_channels, blocks, stride=1): downsample = None if stride != 1 or self.in_channels != out_channels * block.expansion: downsample = nn.Sequential( nn.Conv2d(self.in_channels, out_channels * block.expansion, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels * block.expansion), ) layers = [block(self.in_channels, out_channels, stride, downsample)] self.in_channels = out_channels * block.expansion for _ in range(1, blocks): layers.append(block(self.in_channels, out_channels)) return nn.Sequential(*layers) def forward(self, x): x = self.conv1(x) x = self.bn1(x) x = self.relu(x) x = self.maxpool(x) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.layer4(x) x = self.avgpool(x) x = torch.flatten(x, 1) x = self.fc(x) return x def res_net50(num_classes=1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes=num_classes) def res_net18(num_classes=1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes=num_classes) # 测试模型构建与前向传播 if __name__ == '__main__': # resnet18 = res_net18(num_classes=1000) # print(resnet18) resnet50 = res_net50(num_classes=1000) print(resnet50) * * * 恭喜你,你已经掌握了可以说是卷积神经网络中最著名的ResNet的原理和实现。 扫码请作者喝一杯咖啡来分享你的喜悦吧! ![zsm](http://rethink.fun/imgs/zsm.png) results matching "" =================== No results matching "" ====================== --- # 目标检测 · GitBook [目标检测](https://www.rethink.fun/) ================================= 11.8 目标检测 --------- 之前我们讲的卷积神经网络都是以图像分类任务作为例子,但是在实际应用中,更多的是目标检测的任务。因为实际场景中,一个图片中可能会同时有很多不同的物体。我们需要知道一个图片中都包含哪些物体,以及它们的位置和大小。比如在自动驾驶场景中,自动驾驶汽车需要能识别马路上的所有目标和位置。 ![1057.png](http://rethink.fun/imgs/1057.png) 下边我们就以检测“汽车”,“行人”,“自行车”为例来讲解目标检测。 ### 11.8.1 分类和定位 首先我们简化问题,假设一个图片里最多只能有一个目标(“汽车”,“行人”,“自行车”)。我们怎么在对这个目标进行分类的同时给出它的位置和大小。 ![1058.png](http://rethink.fun/imgs/1058.png) 对于上图,在数据标注时,需要标注人员用一个框来框出目标,并给出目标类别。这个框叫做“边界框”(bounding box)。有了这个框就告诉了我们目标在图片中的位置。一般我们用中心点的x坐标,y坐标,框高,框宽四个数字来表示目标位置。 ![1059.png](http://rethink.fun/imgs/1059.png) 在目标检测中,以图片左上角作为原点(0,0),对图片的高和宽做归一化,右下角坐标为(1,1)。然后再获取边界框的位置数值。比如对于上边图片中的汽车,它的中心点坐标就为(0.51,0.53),高为0.26,宽为0.31。 上图的标注数据一共包含8个元素,除了边界框位置的4个值外,还包含以下几个值: 图片中是否包含目标:PcP\_cPc​,标注时,如果图片中含有汽车,行人,自行车中的任一个目标,值为1,如果都不包含,值为0。 图片中是否包含汽车:PcarP\_{car}Pcar​,标注时,如果是汽车,值为1,否则为0。 图片中是否包含行人:PpedP\_{ped}Pped​。 图片中是否包含自行车:PbicP\_{bic}Pbic​。 这8个元素一起构成一个标注数据(在深度学习里,标注数据也叫做Ground Truth。) \[PcP\_cPc​,x,y,h,w,PcarP\_{car}Pcar​,PpedP\_{ped}Pped​,PbicP\_{bic}Pbic​\] 对应到上边这张图,它的Ground Truth就为: \[1,0.51,0.53,0.26,0.31,1,0,0\] 我们也需要对卷积神经网络进行改造,让网络可以输出对应的这8个值,原来在做图像三分类的卷积神经网络时,我们输出层有3个神经元,在做分类和定位任务时,我们需要改造输出层为8个神经元。其中每个神经元对应上边一个元素。 ![1103.png](http://rethink.fun/imgs/1103.png) 在训练的loss计算时,分为三部分进行,第一部分是对PcP\_cPc​进行判断,这是一个二分类,激活函数用sigmoid,将网络输出映射到0-1的概率值。损失函数用二分类交叉熵损失。对于x,y,h,w,这几个连续值,直接使用MSE损失函数进行计算。对于PcarP\_{car}Pcar​,PpedP\_{ped}Pped​,PbicP\_{bic}Pbic​,它们被当做一组多分类处理,激活函数用softmax,将网络输出映射到0-1的概率。并且这3个分类的概率和为1。损失函数用多分类交叉熵损失。 总的损失函数分为两种情况: 1. **如果标注数据Pc\=0P\_c=0Pc​\=0**,表明图片中不包含任何目标,这时就不在意网络后边其他几个值的输出。网络的整体loss就仅为判断图片是否包含目标的二分类交叉熵损失。不计算边界框坐标 (x, y, h, w) 的损失和类别概率的损失。 2. **如果标注数据Pc\=1P\_c=1Pc​\=1**,就是将对8个元素所有的损失相加,得到分类和定位的最终损失函数。 最终用总的loss计算梯度,反向传播。 如果你有足够的标注数据,经过上边方法的训练,你会得到不错的结果。由此可见: 1. 卷积神经网络不仅擅长做分类,也可以做回归任务。 2. 如果你的一个任务有多个目标,可以列出每个目标的loss,然后将多个loss加和,组成这个任务总的loss。这样神经网络在学习时会兼顾多个目标。 ### 11.8.2 滑动窗口法 通过上边的办法,我们已经可以输出图片中一个目标的边界框了。那对于一个图片里含有多个目标的情况,我们该怎么处理呢?一个最简单的想法就是定义不同大小的窗口,让它在图片上滑动,每滑动一次,就截取一个图片,让卷积神经网络来进行分类和定位。这样就可以对整个图片进行目标检测了。 ![1061.gif](http://rethink.fun/imgs/1061.gif) 因为图片中可能有不同大小的目标,所以可以定义不同大小的窗口来滑动。另外对于滑动窗口的步长也很重要,如果步长太小,则截取的图片太多,检测太慢。步长太大,又有可能没有一个窗口可以截取到完整的目标。 ### 11.8.3 利用卷积节省计算 ![1061.png](http://rethink.fun/imgs/1061.png) 对于上图,上边一排是利用一个大的窗口在图像上滑动了4次。下方是每次窗口滑动截取的图片。利用滑动窗口法进行目标检测,对下边几个图片要分别独立进行卷积神经网络。可以发现截图有很多重复的部分,这些重复的部分每次都会被后边分类定位的卷积神经网络计算。造成大量的计算浪费。 我们首先看对于上图左上角的第一个滑动窗口截取图片进行分类和定位操作的卷积神经网络: ![1062.png](http://rethink.fun/imgs/1062.png) 它经过卷积,池化,等操作,最后得到了一个1×1×8的特征图,其中的8个元素对应目标的类别和位置信息。 ![1063.png](http://rethink.fun/imgs/1063.png) 如上图,为了共享计算,我们可以对原始完整图片进行同样的卷积,池化等操作。最后得到了2×2×8的特征图,它分别对应原始图片4个滑动窗口最终获得的8个代表类别和位置的特征。这样对原始图片进行一次卷积网络的计算,就可以获得所有滑动窗口的最终8个特征,大大减少了计算量。 ### 11.8.4 R-CNN 系列算法 R-CNN(Regions with Convolutional Neural Networks)是目标检测中的一类重要算法。 它的思想是分两阶段进行目标检测。 * 第一阶段获得目标可能存在的区域。获取可能区域的办法可以用各种方法,可以用卷积神经网络也可以用传统计算机图形学手动构造特征的办法,总之只要能给出目标可能存在的区域即可。 * 第二阶段对目标区域运行卷积神经网络进行进行分类和定位。这里可以对图片的可能区域部分进行卷积操作。也可以先对原始完整图片进行一次卷积操作,获得完整的特征图,然后将可能区域映射到对应的特征图上的区域,再进行分类和定位。 R-CNN分了两阶段进行目标检测,所以它的速度相对较慢,但是精度比较高。 ### 11.8.5 YOLO 系列算法 YOLO(You Only Look Once)是一种端到端的实时目标检测算法,它最大的特点是速度快、实时性强。它是目标检测领域另一个重要的算法。 ![1064.png](http://rethink.fun/imgs/1064.png) YOLO的基本思想是将原始图片划分为多个cell,比如上图分为3×3的cell。那最终就让卷积神经输出的特征图为3×3×8。原始图片上的每个目标的中心点落在哪个cell,就由哪个cell负责预测。因为特征图上每个像素的感受野是大于它对应cell的,所以即使目标有一部分超出它所在的cell。特征图里的特征还是能准确预测出每个目标的位置和大小。注意这里我们每个cell输出8个特征,是因为我们只分3类,假如你要分1000类,那么你最后每个cell的特征个数就是5+1000=1005,这里的5是4个位置信息和1个代表这个cell内是否包含目标的标志位。另外这里我们为了演示简单,将原图分为3×3个cell,但是在实际中都会划分成更多的cell,比如13×13。 ### 11.8.6 总结 目标检测是计算视觉的一个重要分支,我们上边讲的只是最基本,简化后的原理。实际目标检测还要处理更多问题,比如如果一个cell里包含多个目标的情况怎么处理?多个cell对同一个目标进行了预测怎么办?图片里的目标大小差别很大怎么办?等等。后边我会专门出一个教程来讲目标检测。在深度学习这门课里我们对目标检测的讨论就到这里。 results matching "" =================== No results matching "" ====================== --- # 语言模型采样 · GitBook [语言模型采样](https://www.rethink.fun/) =================================== 12.5 语言模型采样 ----------- 大语言模型的出现,彻底颠覆了NLP领域,甚至颠覆了整个人工智能领域。后边章节我们会讲如何训练大语言模型,本节我们先来讨论如果我们已经有了一个语言模型,如何利用它。本书后边讨论的语言模型都是指神经网络语言模型,不是统计语言模型。 ### 12.5.1 计算文本序列的概率 语言模型的最初的作用就是生成文本序列的概率,比如我们给出两句读音相同的话: “我每天都要洗澡” “我每天都要洗枣” 输入语言模型后,语言模型会输出每个token出现在这句话中的概率值,我们只需要对这些概率值进行连乘就可以得到这句话合理性的概率。实际实现中因为每个token的概率都很低,连乘值太小,可以转化为每个概率值的log值的连加来代表整句话合理性的大小。 ### 12.5.2 生成文本 大语言模型的成功正是直接利用了语言模型的生成能力。你可以给出一个文本序列的开头,比如: “今天天气” 大模型通过计算在输入token后增加词典里任一token的概率值,然后根据某种策略选取一个token,加到原始文本后边,比如我们选择概率最大的token。它是“真”,于是现在的序列变为: “今天天气真” 接着,计算以“今天天气真”为输入序列,后边增加任一token的概率,再取最高概率的token,假如这个token是“好”。那么更新后的序列就为: “今天天气真好” 依次类推,语言模型就可以不断生成新的token。直到语言模型输出我们约定的序列结束符``停止。 ### 12.5.3 概率最大生成策略 语言模型生成时每次都取最大概率的token,这是贪婪策略。这可能是大家最容易想到的一种策略。你可能认为这可以生成语言模型认为概率最大的文本序列,但实际情况并非如此。因为我们的目的是生成整个输出序列的联合概率最大,而不是每一步最大。 ![1213.png](http://rethink.fun/imgs/1213.png) 如果按照贪心算法,那么大模型输出为“我喜欢吃饭”。因为在每一步都选取当前步概率最大的token进行输出。但实际上,通过计算可以发现“我喜欢学习”的联合概率更高。 **Beam Search** 如何让大模型生成时可以找到这种整个序列概率最大的输出呢?贪心算法的问题是每一步都只选择概率最大的token,而丢弃了其他潜在序列概率最大的token。Beam Search,假如设置Beam为2,它会一直保留2个目前概率最大的序列,最终输出概率最大的那个序列。以下图为例,如果Beam为2,在第一个步,会保留“我喜欢吃”和“我喜欢学”两个序列,概率分别为0.32和0.28。接下来两个序列都会预测自己接下来的token,并计算联合概率。 ![1214.png](http://rethink.fun/imgs/1214.png) 可以发现“我喜欢吃饭”的序列概率为0.128,“我喜欢学习”的序列概率为0.196。它们两个是第二步概率最大的两个序列。Beam Search会保留这两个序列,分别为它们生成下一个token。如果设置最大生成token数为2,也就是第二步就要输出。那此时输出的就是概率最大的序列:“我喜欢学习”。 你设置的Beam越多,越可能找到联合概率最大的输出序列。 ### 12.5.4 随机生成策略 不论是贪婪生成策略,还是Beam Search, 语言模型生成的序列都是固定的,如果我们希望语言模型可以给我们生成笑话,我们希望它每次讲的笑话不一样。那么我们可以采取下边的策略: **Top\_k** 语言模型输出时,首先是输出每个token的logits的值,然后选取Top\_k 个token保留他们的logits值,其他token的logits值设置为一个很大的负数。然后进行softmax(),这样,非Top\_k的token被选择的概率就为0。然后按照token概率进行随机采样,生成下一个token。 **Top\_p** 取概率最大的一批token,作为候选,它们的累加概率和必须刚好大于设置的参数Top\_p。然后再对所有的候选token的概率值做softmax,让它们的概率和为1,然后按更新后的概率随机采样作为下一个token。 **Temperature** 控制语言模型生成随机性的还有一个重要的参数是温度值(Temperature)。当设置了温度值t后,大模型输出的logits会首先除以温度值。然后再进行后续的采样。温度值对softmax()最后生成的概率值的影响,如下图所示: ![1215.png](http://rethink.fun/imgs/1215.png) 可以看到温度值t取0.5时,最后每个token之间的概率差别较大。当t取2时,最后每个token之间的概率差别较小。也就是温度值越小,模型生成的序列越确定,温度值越大,模型生成的序列越不确定。 * * * 恭喜你,你已经了解了NLP任务的基本内容,下边章节我们就来学习更多细节。 扫码请作者喝一杯咖啡来分享你的喜悦吧! ![zsm](http://rethink.fun/imgs/zsm.png) results matching "" =================== No results matching "" ====================== --- # Token编码 · GitBook [Token编码](https://www.rethink.fun/) ==================================== 12.4 Token编码 ------------ 我们主要关注如何用神经网络来解决NLP问题,根据之前我们的学习,我们知道神经网络只能处理数字。而句子的输入是一些Token。如何让神经网络来处理这些Token呢?那就是对Token进行编码。 我们不能直接用Token在词典里的位置序号来作为编码,因为序号作为连续型变量,有顺序大小关系。但这些序号对应的Token彼此之间并没有顺序,大小的关系。如果一个序号为8的Token在线性回归部分会比序号为4的Token作用放大2倍。但是实际上这两个序号对应的Token在语义上并没有这种2倍关系,所以不能用序号来编码Token。 ### 12.4.1 独热编码 之前我们介绍过独热编码(one-hot encoding),它是一种最简单、最直观的Token编码方式。其核心思想是将每一个不同的Token映射为一个长度为词汇表大小(Vocabulary Size, 通常记作V)的向量。在该向量中,只有与当前Token对应的位置为1,其余位置均为0。 例如,假设我们的词汇表是: \["我", "喜欢", "学习", "深度"\] 词汇表大小V = 4,那么: "我" 的独热编码是 \[1, 0, 0, 0\] "喜欢" 的独热编码是 \[0, 1, 0, 0\] "学习" 的独热编码是 \[0, 0, 1, 0\] "深度" 的独热编码是 \[0, 0, 0, 1\] 通过独热编码,神经网络可以接收数字向量作为输入。然而,独热编码也存在明显的缺点: 维度高:词汇表越大,独热向量的维度越高,计算和存储成本增加。假如一个词典有15万的词,那么每个Token的维度就为15万,这个计算代价就太大了。 稀疏性强:大部分位置为0,信息利用率低。 无法表达词之间的关系:不同Token之间的独热编码是正交的(即内积为0),无法体现它们之间的语义相似性。比如“土豆”和“马铃薯”两个词本来是同一个意思,但是它们的编码却毫无关系。 因此,在现代NLP系统中,我们通常不会直接使用独热编码来表示Token。 ### 12.4.2 词嵌入 词嵌入(Word Embedding)是一种将Token编码成低维、稠密向量的技术。词嵌入的目标是通过学习,将语义相似的词映射到空间中相近的位置上。这里的词指的就是Token。 举例来说,假设词嵌入的维度为4,我们得到如下的词嵌入: | Token | 性别 | 可食用性 | 重量 | 尊贵性 | | --- | --- | --- | --- | --- | | 国王 | 0.93 | 0.13 | 0.3 | 0.98 | | 皇后 | \-0.91 | 0.11 | 0.23 | 0.97 | | 男人 | 0.97 | 0.19 | 0.28 | 0.20 | | 女人 | \-0.93 | 0.17 | 0.21 | 0.19 | | 土豆 | 0.01 | 0.98 | 0.05 | 0.04 | | 马铃薯 | 0.01 | 0.97 | 0.05 | 0.04 | 可以发现,每个维度都有具体的含义,土豆和马铃薯在各个维度上的值都基本相等,这表明它们两个在物理世界中代表同样的含义。国王和皇后的尊贵性都高于男人和女人。国王和男人的性别维度都为正值,而皇后的女人的性别维度都为负值。 更神奇的是,通过良好的词嵌入甚至具有一些推理的能力,比如在上边这个例子里。我们发现对Token的向量在嵌入空间里做运算,有如下的结果: 国王 - 皇后 ≈ 男人 - 女人 有了这个推理能力,假如我们知道中国的首都是北京,但是不知道柬埔寨的首都在哪里,就可以生成一个向量: 北京 - 中国 ≈ ?- 柬埔寨 ?≈ 北京 - 中国 + 柬埔寨 经过计算得到一个向量,然后我们就在所有的词嵌入里找哪个词的Embedding和这个计算出的Embedding最接近。你会发现找到的词是:**金边**。 相较于独热编码,词嵌入具有如下优势: 低维稠密:大大降低了计算和存储成本。比如大语言模型里一般只用几千维的向量来表示一个Token。 语义表达能力强:向量之间的距离、方向能够体现词的相似性、关系等语义特征。 可迁移性好:预训练好的词嵌入可以迁移到不同的下游任务中使用,提升模型效果。 词嵌入有这么多好处,那我们该如何得到Token的Embedding呢?答案是它们都是通过神经网络训练得到的。上边我们例子中的“性别”、“可食用性”、“重量”、“尊贵性”等属性都是为了方便大家理解人为设定的,具体神经网络学到的每个维度是什么含义,完全是由神经网络决定的。可能有物理含义,也可能没有。 Google在2013年提出的Word2Vec,是最著名的词嵌入实验。它通过大规模的数据,训练出来的Embedding具有很强的语义能力(“国王 - 皇后 ≈ 男人 - 女人” 这样的向量推理现象)。很快推动了词嵌入的普及。Word2Vec用了两种训练方法,分别是CBOW(Continuous Bag of Words)和 Skip-gram。 这两种训练方法的目标相同:从大量无标签文本中学习高质量的词向量,这些向量能够捕捉词语的语义和语法信息。但它们的实现方式和侧重点有所不同。 核心思想: 两者都基于分布式假设:**一个词的语义由其出现的上下文决定。** 它们都是浅层神经网络模型。 它们都采用无监督学习,只需要大量的原始文本。 ### 12.4.3 CBOW 是一种通过上下文预测中心词的词向量训练模型。其核心思想是用上下文词向量的平均值预测中心词。这里的词指的就是Token。 假设我们取中心词前后各2个词来预测中心词。但在句子边界处可动态缩减。 输入句子为“I love natural language processing” 则对于CBOW模型的输入和输出如下: | 输入 | 输出 | | --- | --- | | \["love","natural"\] | "I" | | \["I","natural","language"\] | "love" | | \["I","love","language ","processing"\] | "natural" | | \["love","natural","processing"\] | "language" | | \["natural","language"\] | "processing" | 接着对每个Token生成独热编码: ![1209.png](http://rethink.fun/imgs/1209.png) 然后给每个Token随机初始化一个Embedding,假设Embedding的维度为4,所有Token的Embedding形成一个Embedding矩阵。 ![1210.png](http://rethink.fun/imgs/1210.png) 这样,我们想取得一个Token的Embedding时,只需要用Token的独热编码乘以Embedding矩阵即可。 ![1211.png](http://rethink.fun/imgs/1211.png) CBOW的网络结构非常简单: 对于中心词为“natural”,它的输入有4个词。首先获得这四个词的Embedding,然后取平均值,再接一个线性层,这个线性层神经元的个数为词典的大小,比如上边的例子,我们词典大小为5。然后用softmax做分类,Label为“natural”。利用交叉熵损失函数训练模型。 ![1212.png](http://rethink.fun/imgs/1212.png) 需要特别注意的是这里的Embedding矩阵里是可学习的参数,每次训练过程都会更新Embedding矩阵的值,同样网络的分类线性层的参数也是被更新的。但是最终我们训练的目的是得到Embedding矩阵的值,这个矩阵里每一行的向量代表了一个Token的Embedding。 经过大量训练后,我们得到的Embedding矩阵就是所有Token很好的词向量。 ### 12.4.4 Skip-gram Skip-gram与CBOW刚好相反,它是利用中心词来预测上下文的词。同样它需要对Token进行独热编码,和构建Embedding矩阵。 假如每个中心词需要预测它前后2个词,则对于上边的例子,它的输入输出为: | 输入 | 输出 | | --- | --- | | "I" | "love" | | "I" | "natural" | | "love" | "I" | | "love" | "natural" | | "love" | "language" | | "natural" | "I" | | "natural" | "love" | | "natural" | "language" | | "natural" | "processing" | | "language" | "love" | | "language" | "natural" | | "language" | "processing" | | "processing" | "natural" | | "processing" | "language" | Skip-gram模型的输入只有一个Token,所以不需要进行多Token Embedding的平均,直接用输入Token的Embedding作为输入,然后和CBOW一样接一个线性分类头,预测输出Token。经过大量训练后也可以得到很好的Embedding矩阵。 值得注意的是在训练CBOW和Skip-gram模型时,需要平衡常见词和罕见词的影响。可以对常见词进行下采样,或者对罕见词进行上采样。 ### 12.4.5 现代大模型 现代大模型会随机初始化每个Token的Embedding,然后随着大模型本身任务的训练,一起同步训练Embedding。这样就会得到更适合任务本身的Token Embedding。 results matching "" =================== No results matching "" ====================== --- # RNN的不同类型 · GitBook [RNN的不同类型](https://www.rethink.fun/) ===================================== 13.2 RNN的不同类型 ------------- 上一节我们看了RNN的基本结构,用的是NLP里NER这个任务作为例子。NER的输入序列是多个向量,输出序列也是多个向量。我们把它称为多对多的任务。而且是输入序列长度和输出序列长度完全一致的情况。但实际中还有很多其他情况,这一节我们就来详细介绍。 ### 13.2.1 多对一 对于文本情感分类问题,输入序列长度有多个token,输出只是一个用来进行分类的向量。向量维度为分类个数。这就是一个多对一的序列问题。 RNN处理流程为: ![1308.png](http://rethink.fun/imgs/1308.png) 可以看到RNN在处理序列数据时,除了最后一个时间步有输出外,其他时间步都只更新隐藏状态。 ### 13.2.2 一对多 对于小说生成,我们希望输入一个向量,这个向量代表小说类型的各个维度,然后RNN就可以自动生成输出token序列,这就是一个一对多的序列问题。 ![1309.png](http://rethink.fun/imgs/1309.png) 上图可以看到,第一个时间步,输入xxx,RNN输出第一个token:y1y\_1y1​,然后将y1y\_1y1​作为第二个时间步的输入token,继续生成y2y\_2y2​,以此类推,直到模型生成了结束符``。 ### 13.2.3 多对多 多对多序列任务分为两种:一种是输入序列和输出序列长度一致,比如NER任务。一种是输入序列和输出序列长度不一致,比如翻译任务。 **长度一致** ![1306.png](http://rethink.fun/imgs/1306.png) 长度一致情况,RNN在每个时间步的输入都是之前隐藏状态和当前时间步的输入,输出分为两个,分别是当前步的输出以及当前RNN的隐藏状态。 **长度不一致** ![1307.png](http://rethink.fun/imgs/1307.png) 比如对于翻译问题,输入token长度为m,输出token长度为n。则RNN分为两个阶段,一个阶段是编码,在这个阶段RNN每个时间步不进行输出,只会更新隐藏状态。当读入了所有x后,RNN进入解码阶段,解码阶段每个时间步会输出一个目标token,同时将输出的目标token作为下一个时间步的输入token。这样直到RNN输出序列结束符``。 ### 13.2.4 一对一 为了讨论的完整性,我们也看一下一对一的情况,因为一对一的情况就只有一个时间步,所以就退化为一个普通的全连接神经网络了。 ![1310.png](http://rethink.fun/imgs/1310.png) ### 13.2.4 总结 可以看到上边不同类型的RNN,有的时间步是没有输出的,但是隐状态一直在传递。这正是我们上一节最后所说的,循环层和隐状态才是RNN的关键。普通层只是为了完成特定任务额外添加的。 results matching "" =================== No results matching "" ====================== --- # 双向循环神经网络 · GitBook [双向循环神经网络](https://www.rethink.fun/) ===================================== 13.5 双向循环神经网络 ------------- 双向循环神经网络(Bidirectional Recurrent Neural Network,BiRNN)是一种可以正向和反向循环的双向循环的神经网络。 ### 13.5.1 单向循环神经网络的局限性 假设有下边两句话: “我喜欢苹果味的汽水。” “我喜欢苹果手机。” ![1333.png](http://rethink.fun/imgs/1333.png) 这两句话里的“苹果”一个是水果,一个是品牌。如果用我们之前讲的RNN,在处理到“苹果”这个token的时候,它只有前边token的隐状态,是无法判断这里的苹果具体是指水果还是品牌。必须依赖后续的token才能判断这里苹果的含义。 ### 13.5.2 BiRNN的结构 BiRNN的想法是创建两个RNN,一个对序列正向循环,一个是对序列反向循环,然后把对应的某个序列元素的在正向和反向循环输出的隐状态进行合并,再作为完成特定任务的普通层的输入。这个输入里就同时包含了序列元素前边和后边序列的信息。相当于它可以看到整个序列的信息来做出判断。 ![1334.png](http://rethink.fun/imgs/1334.png) 像上图中正向RNN在“苹果”这个时间点,输出的隐状态包含了“我喜欢苹果”的信息。反向RNN在这个时间点输出的隐状态包含了“苹果手机”的信息,将这两个隐状态向量进行拼接,再接一个普通层进行输出,就可以准确判断这里的苹果代表的是品牌名了。 我们上边讲的是RNN,对于GRU,LSTM也是一样的道理。 ### 13.5.3 BiRNN的局限性 双向循环神经网络必须输入完整的序列进行处理。这对于NER,摘要,或者对序分类的任务都比单向RNN有优势。但是对于生成类的任务,比如我们只输入一个文章类型,让RNN来自动生成文章的内容。这时BiRNN就不适用了。因为在生成任务时,后续的序列还不知道。所以生成任务是单向任务,只能用单向RNN来完成。 results matching "" =================== No results matching "" ====================== --- # 自然语言处理的进化之路 · GitBook [自然语言处理的进化之路](https://www.rethink.fun/) ======================================== 12.1 自然语言处理的发展 -------------- 自然语言,从远古时期的甲骨文,到如今我们在互联网上的留言,一直作为人类智慧的载体,记录着人类的思考和对自然世界的认识。长久以来让计算机理解人类自然语言一直让科学家既着迷又挫败。在人工智能征服表格数据和图像识别后,自然语言处理( Natural Language Processing, NLP)才在近十几年迎来真正的突破。回望这条探索之路,充满了曲折的历程。 ![1201.png](http://rethink.fun/imgs/1201.png) 还记得当卷积神经网络在图像分类,目标检测领域出现突破进展时。NLP的研究人员当时沮丧的抱怨,图像分类,目标检测这些视觉任务太简单,三岁小孩,或者动物都可以完成。而且一个图片里包含了所有必要的信息。但是文本蕴含的内容可能远在文本在外。比如“色即是空”只是简单4个字,但是它包含的内容并不是每个人都明白。 但也正是NLP任务的困难,让大语言模型取得突破后,爆发出了前所未有的能量,对世界带来的变革远大于计算机视觉任务的突破。下边我们就来看人类是如何征服NLP这个难题的。 ### 12.1.1 人工规则(1950-1980) 计算机科学诞生之初,科学家们满怀信心地试图用逻辑规则“教会”计算机理解语言。主导思想清晰而直接:句子由词汇通过语法规则组合而成,只要精确建模词法(构词、词性)和句法(句子结构),计算机就能破解语言密码。 然而,二十多年的努力几乎没有取得什么有价值的进展,主要是以下几个原因: 歧义无处不在:经典例子“中国队大胜美国队”与“中国队大败美国队”,规则系统如何理解“胜”与“败”这对反义词在此竟指向同一结果(中国队赢)?这揭示了词汇语义和语境对理解的极端重要性,远非简单规则可以捕捉。 结构嵌套难解:如“一行行行行行”(一行行,行行行。指一个行业干好了,各个行业都可以干好。),其含义依赖多音字和深层结构,规则系统难以精确定义其解析路径。 规则爆炸失控:人类语言充满例外、习语和动态变化,穷举所有规则并处理其冲突成为不可能的任务。 这条基于符号逻辑和人工编写规则(如早期的ELIZA聊天机器人、SHRDLU积木世界系统)的道路,最终被证明是一条死胡同。它无法处理真实世界语言的丰富性、模糊性和创造性。 ### 12.1.2 概率与统计(1980s末-2000s初) 70年代已经有人尝试利用统计和概率解决NLP问题,但真正的突破发生在80年代末至90年代初。研究者们转向了概率和统计的视角,提出了一个革命性的观点:语言的规律隐藏在海量文本数据本身之中。 核心范式是统计语言模型,它通过分析词序列在大规模语料库中出现的频率来估算概率。模型的目标是根据统计,预测下一个词出现的可能性。比如通过对海量文本的统计,当出现“我爱”这两个连续的字的时候,接下来出现“你”的概率为0.1,出现“玩”的概率为0.03,等等,给每个字分配一个概率。这一看似简单的转变却带来了立竿见影的效果: 机器翻译飞跃:基于词或短语共现统计的模型显著提升了翻译流畅度。 语音识别实用化:将声学模型与统计语言模型结合,大幅提高了识别准确率。 词性标注与基础任务:基于概率的方法在词性标注、命名实体识别等任务上表现远超规则系统。 统计方法第一次让NLP系统能够从真实语言数据中“学习”模式,处理语言的模糊性,NLP技术也迈向了实用化。 ### 12.1.3 神经网络(2000s中-2010s末) 统计方法虽有成效,但特征工程复杂且难以捕捉长距离依赖和深层语义。机器学习的深化,尤其是深度学习的兴起,开启了NLP的新纪元。 其中关键里程碑包括: 神经语言模型(NNLM):用神经网络学习词的分布式表示(词向量/嵌入),比如Word2Vec模型。词不再作为孤立的符号,而是在向量空间中具有语义关联的点。比如土豆和马铃薯的语义向量在向量空间中就几乎重合。而土豆和汽车的距离就很远。 循环神经网络(RNN/LSTM/GRU):引入记忆单元处理序列数据,能更好地建模上下文信息,在文本生成、情感分析等任务上表现出色。 2017年Transformer模型的横空出世:Google提出的Transformer架构彻底抛弃了RNN的顺序处理,依靠自注意力机制(Self-Attention),实现了对输入序列中所有元素关系的并行计算和高效建模。Transformer在捕捉长距离依赖和并行训练效率上的巨大优势,为后续NLP技术的爆发奠定了基础。 神经网络让模型具备了更强的特征自动提取能力和语义理解潜力。 ### 12.1.4 大语言模型(2020s-) 在Transformer架构的基础上,配合超大规模参数,海量数据,庞大的算力支持,一起催生了大语言模型(LLM) 的奇迹。 以ChatGPT为代表的大模型震撼了世界,大语言模型不再局限于特定NLP任务(如翻译、问答)。它还展现出强大的泛化能力、上下文学习能力(In-Context Learning) 和指令遵循能力(Instruction Following),能撰写创意文本、编写调试代码、进行复杂推理等。传统的NLP任务(分类、翻译、摘要、生成)被统一到一个模型。并且它的能力超越了NLP任务,展现出了解决数学、逻辑乃至跨模态(图文)问题的潜力,让通用人工智能看起来不再遥远。 results matching "" =================== No results matching "" ====================== --- # 词典生成 · GitBook [词典生成](https://www.rethink.fun/) ================================= 12.3 词典生成 --------- 在进行NLP任务时,因为输入的是文本序列。文本是由多个词构成的,词是有意义的最小单元,所以在NLP任务中,一般处理的基本单元是词。也有以单个字(中文)或者单个字母(英语)作为最小处理单元的NLP模型,但是比较少见,我们这里不做讨论。NLP模型处理的最小输入输出单元叫做Token。 既然NLP任务以Token作为处理的最小单元,那么NLP模型需要支持多少Token呢?NLP模型支持的所有Token就构成一个词典。不同NLP模型的词典是不同的,比如ChatGPT的词典和DeepSeek是不同的。 那么NLP模型的词典是怎么产生的呢? ### 12.3.1 利用现成的词典 最简单的办法就是找一本词典,比如《汉语大辞典》或者《四六级单词表》,然后再加上一些常用的其他符号,构成模型可以处理的词典。 有了词典后,对于一句话,可以根据词典来进行分词,分词的一般原则为:**最大匹配优先**,也就是说尽量使用词典中长度最长、匹配优先级最高的词。例如,“自然语言处理”在词典中如果有“自然语言处理”这个词条,则整体作为一个Token;如果没有,则可能拆成“自然语言”和“处理”两个Token,或者进一步拆成更小的单元。 这种方法的优点是简单、易于理解、词典可控,缺点是词典覆盖率有限,对于新词、专有名词、拼写错误、口语表达等,容易出现无法分词或分词不合理的情况。因此,很多现代NLP模型通常不会完全依赖于人工编制的词典。 ### 12.3.2 特殊的Token 在为NLP模型构建词典时,为了模型训练,会增加一些特殊的Token,它们并不是来自人类语言。 ``:Unknow,代表所有未在词典中出现的Token。如果模型输入的句子中包含了不在词典里的Token,都会被映射到``这个特殊的Token。 ``: BeginOfSequence,表示序列的开头。有的NLP模型在处理输入时,会在句子前加上这个Token,代表一个输入序列的开始。 ``: EndOfSequence, 表示序列的结束。NLP在进行输出时,如果模型输出了``这个Token,则代表模型输出结束了,我们就终止模型的输出。 ``: Separation,有的NLP模型需要输入两个序列,为了区分两个序列,会在中间添加``这个特殊Token。 上边只是举了一些例子,NLP模型可以根据自己的需要设计一些特殊Token。比如带思考的大模型会在输出时用``...``这两个特殊Token代表它们之间的Token是模型思考的内容。 ### 12.3.3 词典大小的设计 像早期的NLP模型一般词典大小为几万,它们可能只能处理一两种语言。到了大语言模型时代,模型的词典大小一般都是十几万。它们可以处理大部分的语言。 词典设计太小,在实际处理文本时,可能会出现大量的`` Token。导致模型能力差。但是词典设计太大,又会增加模型参数量、计算开销和存储成本,造成训练和推理效率下降。 ### 12.3.4 字节对编码 字节对编码(Byte Pair Encoding,BPE)是一个常用的,用于构建NLP模型词典构造的方法。进行BPE编码前,需要提前指定: 1. 语料。也就是模型将来要处理的大量文本。 2. 词典的大小。指定一个数字,词典包含多少个Token。 英文单词数量就有几十万个,而且还不断有新的词汇产生。把这些单词都加入词典就太多了。人类会根据词根来推断单词含义。比如认识了high这个单词,就知道higher、highest的含义。同样知道了low就知道了lower、lowest的含义。如果让模型也可以利用词根来认识单词,则可以减少词典大小。模型只要学习low、high、er、est这四个token的含义。根据词根来划分token可以获得更多针对每个token的训练样本,从而让训练后的模型拥有更好的泛化效果。 下边看一个对英文利用BPE编码构造词典的例子。我们收集了一批语料,并对其中的单词进行了统计。得到每个单词出现的次数。同时,我们规定了词典大小为15。 算法初始化时,首先给每个单词后边加上``表示这是一个单词结束的位置。然后按照字母对每个单词进行切分,将切分后的字母和``放入词典: ![1203.png](http://rethink.fun/imgs/1203.png) 接下来对所有相邻token进行统计,找出出现最多的两个token组合,将它们合并为一个新的token,放入词典。同时记录合并规则。如果同时有多个token对出现次数一样且都是最大,则任选一对进行合并。这一步被合并的是i和g,它们一共出现了9次。同时,i与g的合并规则也被记录下来。 ![1204.png](http://rethink.fun/imgs/1204.png) 经过第一次合并后,ig被作为一个token,然后统计所有单词里相邻token出现最多的组合。e和r的组合出现了最多次,所以这次将e和r进行合并,并记录合并规则。 ![1205.png](http://rethink.fun/imgs/1205.png) 第三次合并将er和``进行合并。 ![1206.png](http://rethink.fun/imgs/1206.png) 第四次合并将hi和g进行合并。 ![1207.png](http://rethink.fun/imgs/1207.png) 第五次合并将hig和h进行合并。经过五次合并后,词典内的token数量达到了预设的15个,所以停止合并。此时,算法产生了一个词典,同时有一组合并规则。 ![1208.png](http://rethink.fun/imgs/1208.png) 有了通过BPE算法生成的词典和合并规则,对于一句话的分词过程为先将单词按字符拆分,再应用合并规则进行token合并,最终返回token在字典里的序号。这就完成了对句子的分词。比如,如果输入higher lower,则分词完的结果为\[14,12,8,9,7,12\]。 results matching "" =================== No results matching "" ====================== --- # GPT2 · GitBook [GPT2](https://www.rethink.fun/) ================================= 16.3 GPT-2 ---------- BERT 的核心特性是其双向编码能力,即模型在处理任意 token 时可以同时考虑该 token 前后的上下文。这种全局语境感知大幅增强了 BERT 在语义提取任务中的表现,因此在众多自然语言处理任务中,BERT 相比 GPT-1 展现出了更全面的优势。随后,OpenAI 推出了 GPT-2,其论文标题为 Language Models are Unsupervised Multitask Learners,中文译为《语言模型是非监督的多任务学习器》。GPT-2 的设计初衷是为了解决 GPT-1 与 BERT 虽然引入了预训练机制,但在处理具体下游任务时仍需进行微调的局限。 ### 16.3.1 GPT-2的目标 GPT-2 的目标是通过一个强大的预训练模型直接处理所有下游任务,从而尽可能地避免对特定任务的数据进行微调。在 GPT-1 中,执行下游任务时通常需要引入特殊 token 来微调模型,而 GPT-2 则尝试完全依赖自然语言提示(prompt)来指导模型完成任务。 在使用 GPT-2 进行预测时,输入内容由实际数据和自然语言提示组成,提示部分用以明确当前任务的目标。基于这些输入,模型生成相应输出。这种方式有效地简化了预训练模型到实际应用之间的转化流程,使得 GPT-2 可以更广泛地应用于多种语言任务。 例如,在执行情感分类任务时,GPT-1 的做法是在文本前后添加特定的起始与结束标记,然后通过分类头进行预测。而在 GPT-2 中,任务说明需要以自然语言形式嵌入到输入中,模型据此“理解”并执行相应任务。并且GPT-2与GPT-1在解决下游任务时的方式不一样,GPT-1会根据不同的任务设计不同的分类头。相当于把任务类型融入到模型架构中去。但是GPT-2训练时完全不引入特殊token,也不针对具体任务改变模型架构。预测时,完全是靠模型输出自然语言来给出答案。 例如,对于不同的问题的提示词如下: **翻译任务:**“把下面的英文翻译成中文:Hello world =>” **情感分析:**“评论:这部电影太精彩了! 情感:正向/负向? 答:” **摘要:**“总结以下文章:`[文章内容]`, 总结:” ### 16.3.2 架构改进 相比 GPT-1,GPT-2 在模型架构方面做出了如下几个重要优化: **层归一化的位置调整** 层归一化操作从每个模块的输出移至输入阶段,即先进行层归一化再执行注意力计算。这种调整有助于提高训练的稳定性。同时,在最后一个子层的注意力模块后新增了额外的层归一化操作,进一步提升了模型的稳定性。 **残差连接初始化的优化** 随着网络深度的增加,GPT-2 减小了残差连接的初始化参数,旨在更好地维持梯度的传播效率,防止梯度消失或爆炸问题。 **词汇表的扩展** 词汇表的容量扩大至 50,257 个 token,能够覆盖更多词汇和表达方式,提升模型的语言覆盖能力。 **模型规模的提升** GPT-2 的参数规模增加至 15.42 亿,大幅增强了模型处理复杂任务的能力。 ### 16.3.3 训练数据与配置 在训练设置方面,GPT-2 也进行了多项提升: 上下文长度从原本的 512 扩展至 1024; 批处理大小由 32 增加到 512; 使用了 Reddit 社区中高质量的网页链接内容,数据总量达 800 万条文本,约合 40GB。 ### 16.3.4 总结 尽管 GPT-2 在多个任务上已经展现出了优异的性能表现,但在一些特定任务中,尚未完全超越传统的监督学习模型。然而,OpenAI 发现:随着模型规模的扩大,性能持续提升,这预示着大模型具有巨大的扩展潜力。 GPT-2的意义是模型训练时只需要一个任务,那就是预测下一个词,就可以训练出一个可以解决各种问题,具备通用能力的模型。这个模型不需要在不同任务上进行微调,就能得到一个还不错的成绩。在GPT-2之前,具体任务类型需要被设计进网络结构,(比如添加分类头,设置分类数量),但是GPT-2是把任务类型通过prompt这种自然语言的方式传入到模型。模型也不是输出任务可能结果的概率值,而是以自然语言方式给出答案。这种统一是一个巨大的进步。 results matching "" =================== No results matching "" ====================== --- # 层归一化 · GitBook [层归一化](https://www.rethink.fun/) ================================= 15.3 层归一化 --------- 在Transformer里采用了一种更适合序列数据的归一化方式:层归一化(Layer Normalization),本节我们就来介绍。 ### 15.3.1 序列归一化问题 序列问题的一大特点是序列长度是可变的,为了让这些可变长度的序列放入维度固定的tensor,会在一个batch里短的序列后边填充 `` token。如果用Batch Norm,会有以下两个问题: 1. 因为序列可能很长,导致Batch Size很小,不同Batch 之间的均值和方差差距会非常大。 2. 大量的 `` token的特征会影响正常token的均值和方差计算。 另外BatchNorm本身还有一个问题,那就是区分训练和推理状态,训练时会动量更新维护每个特征的均值和方差。推理时需要用训练时保存的均值和方差。这样比较麻烦。 ### 15.3.2 Layer Norm 在解决序列问题时,层归一化,会按照每个token来分别统计每个token所有特征的均值和方差。然后在token特征的每个维度都定义两个可学习参数γ和β来进行线性变化。 所以,如果一个token的编码维度为512维,则对每个token的这512个特征数字计算均值和方差,并且所有token共享512个γ参数和512个β参数。 y\=x−E\[x\]Std\[x\]+ϵ∗γ+β y=\\frac{x-E\[x\]}{Std\[x\]+\\epsilon}\*\\gamma+\\beta y\=Std\[x\]+ϵx−E\[x\]​∗γ+β 通过Layer Norm后基本保证了每个token的特征大致分布为均值为0,方差为1。这样保证了后边进行注意力计算时点积值的稳定性,不会落入softmax的失活区域。 Layer Norm因为是对输入每个token embedding的维度进行均值和方差的统计。对于推理状态也可以进行,所以不区分训练状态和推理状态。 ### 15.3.3 PyTorch实现 class LayerNormalization(nn.Module): def __init__(self, features: int, eps: float = 10 ** -6) -> None: super().__init__() self.eps = eps # 可学习权重 self.alpha = nn.Parameter(torch.ones(features)) # 可学习偏差 self.bias = nn.Parameter(torch.zeros(features)) def forward(self, x): # x: (batch, seq_len, hidden_size) # 保留维度来进行广播 mean = x.mean(dim=-1, keepdim=True) # (batch, seq_len, 1) std = x.std(dim=-1, keepdim=True) # (batch, seq_len, 1) # eps 是为了防止除0设置的很小的值 return self.alpha * (x - mean) / (std + self.eps) + self.bias results matching "" =================== No results matching "" ====================== --- # GPT3和GPT4 · GitBook [GPT3和GPT4](https://www.rethink.fun/) ====================================== 16.4 GPT-3和GPT-4 ---------------- 从 GPT-1 到 GPT-4,OpenAI 一路走来,每一代模型都在参数规模、任务能力和实际应用中取得了飞跃。但与此同时, OpenAI越来越不Open,技术报告透露出来的技术细节越来越少。所以,我们把GPT-3和GPT-4放到一节来讲。 ### 16.4.1 GPT-3 继 GPT-2 之后,OpenAI 开始研发更大规模的 GPT-3,其论文名为《语言模型是少样本学习者》(Language Models are Few-Shot Learners)。研究的出发点是模仿人类的语言能力:我们只需几个例子,就能理解并完成新任务,而不需要成千上万的数据。 传统模型(如 BERT 和 GPT-1)在处理特定任务时,通常需要大量标注数据进行微调。而 GPT-3 探索的是另一种方式:能否仅通过“提示”(prompt)和少量示例,就完成复杂任务,而无需调整模型参数。 GPT-3 沿用了 GPT-2 的结构,并将模型规模推向了新高度,最大版本拥有 1750 亿个参数,在 3000 亿个 token 的文本数据上进行了训练。 ![1603.png](http://rethink.fun/imgs/1603.png) GPT-3 提出了三种上下文学习方式: * 零样本(Zero-shot):直接通过一个提示命令完成任务,例如:“将 cheese 翻译成法语”。 * 单样本(One-shot):提供一个示例后,再要求模型完成类似任务。 * 少样本(Few-shot):给出多个例子后,请求模型做同样的任务。 这种方法不需要微调模型,仅通过输入上下文就能引导模型完成不同任务,效果尤其明显。 **技术特点** 模型结构:GPT-3 与 GPT-2 类似,但引入了稀疏注意力机制,即每个 token 不再对所有前文 token 做注意力计算,只关注部分 token,从而提升效率,便于模型扩展。 数据来源:主要使用清洗后的 CommonCrawl 数据,以及维基百科、书籍等高质量文本。其中,维基百科虽然数据量小,但权重较高。 **训练细节** 上下文长度提升至 2048 token 批处理大小达 320 万 GPT-3 在少样本学习中的表现非常优秀,甚至在某些任务上超越了需要微调的监督学习模型。它的文本生成质量也非常逼真,尤其在新闻生成方面,已达到“真假难辨”的程度,引发了社会对人工智能生成内容的广泛讨论。 ### 16.4.2 GPT-4 随着 GPT-3 的成功,OpenAI 接着推出了 GPT-4。但与之前相比,GPT-4 的公开资料极为有限,社交媒体上甚至有评论戏称:“我帮你们读了GPT-4的技术报告,简而言之,就是‘我们使用了Python。’”。 GPT-4 最大的亮点是其多模态能力:它可以同时处理文本和图像输入(但输出仍为文本)。这意味着它不仅能理解文字,还能看懂图片。例如,它能够识别图像中的幽默点,具备基础的视觉理解能力。 ![1604.png](http://rethink.fun/imgs/1604.png) GPT-4 在多个标准化考试中表现出色,不仅全面超越 GPT-3,甚至在一些领域超过了人类。例如,它成功通过了美国律师资格考试,得分高于大多数考生。 ![1605.png](http://rethink.fun/imgs/1605.png) 虽然 GPT-4 的报告透露的信息不多,但有一点值得注意:大模型的核心能力主要在预训练阶段就已形成。后期通过人类反馈进行的强化学习,主要是优化输出风格和可控性,而不是增强模型的基础认知能力。如果强化学习设计不当,反而可能削弱模型已有的能力。 * * * 恭喜你,你已经了解了GPT系列模型的原理! 扫码请作者喝一杯咖啡来分享你的喜悦吧! ![zsm](http://rethink.fun/imgs/zsm.png) results matching "" =================== No results matching "" ====================== --- # 动机与影响 · GitBook [动机与影响](https://www.rethink.fun/) ================================== 15.1 动机与影响 ---------- Transformer与全连接前馈网络(FFN)、卷积神经网络(CNN)、循环神经网络(RNN)并称为**深度学习的四大核心架构**。其中,Transformer的影响力无疑是最大的。它在序列建模(如自然语言处理)、计算机视觉、多模态学习等多个领域都取得了突破性进展,并且**几乎成为当前所有主流大模型架构的基础**。 Transformer模型于2017年由Google Brain的Vaswani等人在论文《Attention is All You Need》中首次提出,最初用于解决机器翻译(序列到序列)任务。 ### 15.1.1 序列建模的挑战 此前,循环神经网络(RNN)及其改进版本(如LSTM、GRU)是序列建模的主流方法。它们通过对前一时刻的隐藏状态进行递归更新来处理序列数据。然而,这种循环依赖机制存在固有缺陷: 1. **长距离依赖建模困难(梯度消失/爆炸)**:随着序列长度的增加,RNN在捕捉远距离元素间依赖关系时效果不佳,训练过程也变得不稳定。尽管LSTM和GRU在一定程度上缓解了这个问题,但**仍然存在长距离依赖建模的瓶颈**。 2. **串行计算的限制**:每一步的计算都严格依赖于前一步的结果,导致模型**无法充分利用并行计算资源**,严重制约了训练和推理的速度。 与此同时,研究者也探索了使用卷积神经网络(CNN)构建序列模型。虽然取得了一些进展,但CNN同样面临挑战: 1. **局部感受野的局限**:CNN通过滑动卷积核提取局部特征,要建模长距离依赖关系,**通常需要堆叠非常深的网络层**,这不仅显著增加了模型参数量,也大大提升了计算复杂度。 2. **信息流动效率低**:序列起始位置的信息需要经过多层卷积操作才能传递到尾部位置,**信号路径过长可能导致信息衰减或丢失**。 GPU的普及极大地推动了对大规模并行计算能力的需求,这使得RNN系列模型固有的串行计算缺陷愈发凸显,成为性能瓶颈。另一方面,注意力机制(Attention Mechanism)的蓬勃发展,为模型提供了**直接访问序列中任意位置信息的能力**,为解决长距离依赖问题开辟了新途径。 ### 15.1.2 Transformer的突破性贡献 Transformer的核心思想体现在其论文标题《Attention is All You Need》中。它首次提出了**完全基于自注意力(Self-Attention)机制**构建的编码器-解码器架构,**彻底摒弃了RNN和CNN在序列建模中的主导地位**。该架构的核心优势在于: 1. **全局信息交互**:自注意力机制允许序列中的**任意两个位置直接建立联系并进行信息交互**,极大地提升了信息传递的效率和范围,从根本上解决了长距离依赖问题。 2. **强大的并行计算能力**:自注意力层的计算可以**在序列长度维度上完全并行化**,这充分利用了现代硬件(如GPU/TPU)的并行计算能力,**显著加速了模型的训练和推理过程**。 Transformer带来的性能飞跃是惊人的。在WMT 2014英德(EN→DE)机器翻译任务上,单个Transformer模型将当时的最高BLEU分数(由基于LSTM的模型保持的0.284分)**大幅提升至0.418分**,实现了约0.134分的巨大进步。在速度方面,Transformer在训练阶段得益于并行计算,**每秒处理的token数量提升了数倍**;在推理阶段,其生成速度也**得到了显著加快**。 自此,Transformer架构迅速成为自然语言处理乃至整个AI领域的基石,并催生了一系列具有里程碑意义的模型,如BERT、GPT、LLaMA、DeepSeek等,持续推动着人工智能的发展。 results matching "" =================== No results matching "" ====================== --- # GPT1 · GitBook [GPT1](https://www.rethink.fun/) ================================= 16.1 GPT-1 ---------- 2018 年,OpenAI 发布了 GPT-1,正式拉开了大语言模型发展的序幕。随着模型参数规模和训练数据量的持续扩张,这一系列模型展现出了卓越的语言理解与生成能力。特别是 GPT-3,其拥有 1750 亿参数,被广泛认为是第一个真正意义上的“大语言模型”;而 GPT-4 更进一步,不仅显著提升了性能,还支持多模态输入(图像+文本),为人工智能的发展带来了更多可能性。 ### 16.1.1 研究背景与动机 GPT-1 的论文《Improving Language Understanding by Generative Pre-Training》(通过生成式预训练改进自然语言理解)旨在解决当时自然语言处理(NLP)领域面临的两大核心挑战: * 多数 NLP 任务严重依赖大量人工标注数据; * 面向特定任务设计的模型缺乏通用性,难以迁移到其他任务。 受计算机视觉中“ImageNet 预训练 + 微调”成功模式的启发,OpenAI 提出了一种新方案,希望通过通用的预训练机制提高模型的泛化能力,并便于迁移到多种下游任务中。 面临的主要问题包括: * **数据问题:** NLP 领域缺乏类似 ImageNet 的大规模标注数据集,原始文本虽多,但缺少标注; * **架构问题:** 需要一种能在多种任务中泛化良好的模型架构,理想状态是只需加一个简单的线性分类器即可适配不同任务。 **OpenAI 的解决思路如下:** * 利用自回归语言模型进行无监督预训练,训练目标是预测下一个词,这样就能直接使用海量原始文本; * 采用 Transformer 架构,在捕捉序列模式和迁移能力方面优于以往模型,因此选定其作为基础结构。 ### 16.1.2 模型架构 ![1601.png](http://rethink.fun/imgs/1601.png) GPT-1 采用的是 Transformer 的 **Decoder-only** 架构,也就是说它省略了原始 Transformer 的编码器部分,进而也移除了与编码器相关的交叉注意力机制。 文本的处理流程如下: 1. **分词与嵌入:** 文本首先通过分词器切分成 token,再经过嵌入层转换为词向量,并添加可学习的位置编码,以引入位置信息。 2. **掩码多头自注意力:** 使用带掩码的多头注意力机制,确保模型在预测每个 token 时只能参考其前文,保持自回归性质。 3. **残差连接与归一化:** 每个模块使用残差连接与层归一化,随后通过前馈神经网络处理,再次进行残差与归一化。 4. **堆叠结构:** GPT-1 包含 12 层这样的解码器模块。 **模型参数细节如下:** * 解码器层数:12 * 隐藏层维度:768 * 注意力头数:12 * 总参数量:1.17 亿 * 激活函数:GELU(Gaussian Error Linear Unit) * 位置嵌入:采用可学习参数(非正弦函数) * 词嵌入与输出层权重共享 > GPT-1 将词嵌入矩阵(形状为 `[vocab_size, hidden_size]`)与输出线性层的权重(形状为 `[hidden_size, vocab_size]`)共享,从而减少了参数量。这种策略在小模型中尤为重要,因为嵌入矩阵在总参数中占比很大。 ### 16.1.3 下游任务适配方式 预训练完成后,OpenAI 采用极简的方式对 GPT-1 进行微调来适配不同下游任务,只需在文本中加入特殊 token。把组合好的序列输入预训练好的GPT-1。并拿出最后一个Token对应的输出向量, 然后接上一个简单的线性层来完成不同的任务。 ![1602.png](http://rethink.fun/imgs/1602.png) #### 文本分类任务 * 在文本前后加入表示开始和结束的 token; * 最后一个token的输出向量接入一个线性分类器,直接进行分类。 #### 文本蕴含任务(Textual Entailment) * 输入为两句话:前提 + 假设; * 结构为 `[开始][前提][分隔符][假设][结束]`; * 输出为三分类:“支持”、“反对”或“不相关”。 #### 语义相似度评估 * 同样使用 `[开始][句子1][分隔符][句子2][结束]` 的输入格式; * 因任务对称,交换句子位置进行两次推理,将输出向量相加后输入到线性层中做分类。 #### 多项选择题 * 将每个问题与每个选项组合为独立序列; * 每个序列送入 GPT-1 得到得分,通过 softmax 计算每个选项的概率。 这种将任务指令融入输入序列的策略使 GPT-1 能以极少的结构修改适应不同任务,并充分利用其在预训练中学习到的语言能力。 ### 16.1.4 多任务联合训练 在下游任务微调时,GPT-1 同时执行两个目标: 1. 特定任务的分类目标; 2. 自回归语言模型任务(预测下一个词)。 通过为这两个损失函数设置不同的权重,实现多任务学习,有助于模型保持语言建模能力的同时提升特定任务性能,增强泛化能力。 ### 16.1.5 训练数据与设置 GPT-1 的预训练使用了 **BooksCorpus** 数据集: * 包含约 7000 本未出版图书; * 总词量约为 8 亿; * 上下文窗口长度:512 个 token; * 批处理大小:32。 ### 16.1.5 效果 GPT1通过“预训练+微调”的范式,用通用的模型架构,通过对不同人物设计不同的输入的方式下,不对模型结构进行大幅修改,在12个NLP任务中,有9个刷新了当时的最好成绩,显示出其强大的通用性和迁移能力。 GPT-1 的发布,标志着以无监督预训练为核心的大模型路线正式确立。它证明了即使使用最基础的“预测下一个词”的目标,也能学到强大的语言表示能力,并且易于迁移到多种 NLP 任务中。这为后续如 GPT-2、GPT-3 和 GPT-4 等更强大的语言模型奠定了坚实的基础。 results matching "" =================== No results matching "" ====================== --- # Llama3 · GitBook [Llama3](https://www.rethink.fun/) =================================== 17.4 Llama3 ----------- 2024年4月18日,Meta发布了其新一代大规模语言模型——Llama3,正式开启了Llama系列模型的第三代演进。Llama3首次发布了两个版本:Llama3-8B 和 Llama3-70B,旨在提供能够媲美最强商业模型的开源替代品。更高规模的版本如400B模型,以及具备多语言与多模态能力的模型也已在研发中。 ### 17.4.1 性能突破 Llama3在多个公开基准测试中取得了显著进展,尤其是Llama3-8B的性能已经超越了Llama2-70B。Llama3-70B在与GPT-4的五项关键任务对比中表现出色,三项指标超过了GPT-4,而Llama3-400B更是全面领先。 | 测试集 | Llama2 7B | Llama2 13B | Llama2 70B | Llama3 8B | Llama3 70B | Llama3 400B | GPT-3.5 | GPT-4 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | MMLU | 34.1 | 47.8 | 52.9 | 68.4 | 82.0 | 86.1 | 70.0 | 86.4 | | GPQA | 21.7 | 22.3 | 21.0 | 34.2 | 39.5 | 48.0 | 28.1 | 35.7 | | HumanEval | 7.9 | 14.0 | 25.6 | 62.2 | 81.7 | 84.1 | 48.1 | 67.0 | | GSM8K | 25.7 | 77.4 | 57.5 | 79.6 | 93.0 | 94.1 | 57.1 | 92.0 | | MATH | 3.8 | 6.7 | 11.6 | 30.0 | 50.4 | 57.8 | 34.1 | 52.9 | ### 17.4.2 关键架构改进 Llama3在架构上做出了多项重要升级: * **词表扩展**:token数量由32K扩展到128K,大幅提升了非英语文本,特别是中文的编码效率。 * **GQA技术全面应用**:从Llama2仅限70B使用GQA,到Llama3全系采用该技术,增强了推理效率。 * **最大序列长度提升**:从4096扩展至8192 token,支持更长文本处理。 * **数据集规模扩展**:Llama3使用了15万亿token进行训练,是Llama2的七倍。 * **数据多样性增强**:引入了更多高质量代码数据(增加四倍),5%的多语言数据,覆盖超过30种语言。 * **高效数据清洗**:使用Llama2生成的数据辅助训练文本质量分类器,确保语料质量。 ### 17.4.3 训练与优化策略 Llama3使用了包括24,000个H100 GPU的训练集群进行训练,训练效率超过95%,是Llama2时代的三倍。 | 模型 | GPU小时数 | 使用GPU | | --- | --- | --- | | Llama1 65B | 1.0M | A100 80G | | Llama2 70B | 1.7M | A100 80G | | Llama3 70B | 7.7M | H100 80G | Meta还强调了两个核心原则: 1. **缩放定律**:小模型的训练效果可预测大模型的表现; 2. **数据优先**:增加数据比扩展模型参数更有效。 ### 17.4.4 模型微调与指令跟随 Llama3不再使用“Chat”命名后缀,而是采用 **Llama3-Instruct**,强调其在指令跟随、多任务和智能应用执行上的能力。 微调阶段包括: * **监督微调(SFT)** * **拒绝采样(Rejection Sampling)** * **直接偏好优化(DPO)** DPO在对齐人类偏好方面表现更优,且所需计算资源更少。 ### 17.4.5 Llama3.1:更强上下文与多语言能力 2024年7月发布的 Llama3.1 引入了如下重要升级: * **上下文长度提升至128K tokens** * **更强多语言能力与工具调用能力** * **使用多种模型分类器清洗和筛选训练数据** 数据清洗采用如下策略: * 去除Markdown内容; * 针对HTML、代码、数学公式进行特殊处理; * 利用FastText、RoBERTa等模型进行质量过滤; * 使用多语言Llama2分类器进行多语言排序。 此外,训练数据组合也进行了优化:50%通识知识、25%数学/推理、17%代码、8%多语言数据。 ### 17.4.6 预训练三阶段机制 以Llama3-405B为例,完整预训练包含三阶段: 1. **初始预训练**:标准序列长度,使用线性预热与余弦退火,共训练120万步。 2. **长上下文训练**:分六阶段逐步提升上下文长度至128K,总共使用约800B tokens。 3. **退火训练**:使用高质量数据,线性降学习率至0,增强对复杂任务的泛化能力。 训练过程中采用特殊注意力掩码,防止token访问拼接文档中的跨文段信息。 ### 17.4.7 Llama3.2:多模态时代的到来 2024年9月,Meta发布 **Llama3.2**,其中首次引入多模态模型,包括: * **轻量文本模型**:Llama3.2-1B 与 Llama3.2-3B,适用于移动端; * **多模态大模型**:11B 与 90B 版本,支持图像理解与图文推理。 在多模态模型中,Meta使用图像编码器 + 权重适配器方式,将图像输入映射到语言模型空间。语言模型参数完全冻结,仅训练图像部分,使模型在保留语言能力的同时具备图像理解能力。 多模态训练过程分阶段: 1. 使用图像-文本配对数据进行预训练; 2. 使用领域内高质量图文数据进行精调; 3. 执行SFT、拒绝采样与DPO完成对齐。 Llama3.2使开源大模型生态具备了处理图像、图表和视觉语言交互的能力,为多模态智能应用打开了广阔空间。 * * * 恭喜你,你已经了解了Llama系列模型的原理! 扫码请作者喝一杯咖啡来分享你的喜悦吧! ![zsm](http://rethink.fun/imgs/zsm.png) results matching "" =================== No results matching "" ====================== --- # 深度循环神经网络 · GitBook [深度循环神经网络](https://www.rethink.fun/) ===================================== 13.6 深度循环神经网络 ------------- 之前我们讲的不论是RNN,GRU,LSTM,它们都只有一层循环层,一层普通层。 ![1335.png](http://rethink.fun/imgs/1335.png) 我们再来复习一下对于一个循环层,一个普通层的RNN来说,对于时间步t,隐状态hth\_tht​就是将上一时间步的隐状态与当前时间步的输入拼接,通过循环层的线性变化,以tanh激活作为输出。 ht\=tanh(\[ht−1∣xt\]wh+bh)h\_t=tanh(\[h\_{t-1}|x\_t\]w\_h+b\_h)ht​\=tanh(\[ht−1​∣xt​\]wh​+bh​) yt\=softmax(htwy+by)y\_t=softmax(h\_tw\_y+b\_y)yt​\=softmax(ht​wy​+by​) 上边假设是分类任务,所以yty\_tyt​应用的是softmax激活函数。 具体对于第二个时间步公式为: h2\=tanh(\[h1∣x2\]wh+bh)h\_2=tanh(\[h\_1|x\_2\]w\_h+b\_h)h2​\=tanh(\[h1​∣x2​\]wh​+bh​) y2\=softmax(h2wy+by)y\_2=softmax(h\_2w\_y+b\_y)y2​\=softmax(h2​wy​+by​) 但是在实际中RNN可以有多个循环层,和多个普通层的。 ### 13.6.1 增加更多的循环层 ![1336.png](http://rethink.fun/imgs/1336.png) 上图中我们增加了第二个循环层,用上标表示层数。比如h12h\_1^2h12​表示的是第二个循环层在第一个时间步输出的隐状态。对于第二个循环层来说,和第一个循环层是完全一样的,只不过第一个循环层当前时间步的输入是xtx\_txt​,而第二个循环层当前时间步的输入是第一个循环层在当前时间步输出的隐状态。 所以对于多循环层的某一时间步的输入就等于当前层上一时刻的隐状态与上一层当前时刻的隐状态拼接,然后经过当前循环层的线性变化,以tanh作为激活。 ht2\=tanh(\[ht−12∣ht1\]wh2+bh2)h\_t^2=tanh(\[h\_{t-1}^2|h\_t^1\]w\_h^2+b\_h^2)ht2​\=tanh(\[ht−12​∣ht1​\]wh2​+bh2​) 以第二个时间步第二层隐藏层为例,它的隐状态为: h22\=tanh(\[h12∣h21\]wh2+bh2)h\_2^2=tanh(\[h\_1^2|h\_2^1\]w\_h^2+b\_h^2)h22​\=tanh(\[h12​∣h21​\]wh2​+bh2​) ### 13.6.2 增加更多的普通层 ![1337.png](http://rethink.fun/imgs/1337.png) 增加更多的普通层,就是增加普通的全连接神经网络,除了最后一个普通层会根据具体任务选择激活函数,其他的普通层一般以ReLU作为激活函数。具体细节我们就不再讨论了。 ### 13.6.3 总结 上边我们是以RNN举例,但是实际对于GRU,LSTM,或者双向RNN,双向GRU,双向LSTM也都是一样的道理。 另外因为循环层比较复杂,即使是深度循环神经网络,循环层大家一般也都取2-5层,我了解到最深的也就8层。建议你一般2-3层就可以。对于普通层你可以适当增加深度。 results matching "" =================== No results matching "" ====================== --- # NLP常见任务 · GitBook [NLP常见任务](https://www.rethink.fun/) ==================================== 12.2 NLP常见任务 ------------ 这一节我们来看一下NLP都有哪些常见任务,这些任务都有哪些特点和难点。 ### 12.2.1 文本分类 文本分类任务是自然语言中非常常见的一种任务。比如用户在淘宝上对一个商品的评价,系统要识别这是正面评价和负面评价。再比如公司邮箱系统根据邮件内容需要判断邮件是否为垃圾邮件,以便进行拦截。还有一个新闻网站希望模型根据新闻内容,自动将新闻分到不同的类别。这些都是文本分类任务。 这些任务的特点是,输入是长短不一的文本,输出是一个分类值。 ### 12.2.2 文本回归 文本回归的任务是输入一段文本,模型输出一个连续值。比如你想根据用户的评价文本,返回一个具体的评价分值。或者语文老师给模型输入学生的一篇作文,模型给出这篇作文的得分。 ### 12.2.3 命名实体识别 命名实体识别(Named Entity Recognition,NER)也是一种常见的NLP任务。任务的具体内容是给出一段文本,模型需要识别出这段文本中的专有名词,比如人名、地名、机构名等。命名实体识别之后,可以根据识别的实体构建知识图谱,或者根据不同的实体进行文档归类或者索引,以便进行检索。 命名实体识别任务是一个文本序列到标注序列的问题,并且它模型输入长度和输出序列长度是一致的。一般标注采用BIO(Begin,Inside,Outside)方式进行标注。 B-XXX:实体的开头 I-XXX:实体的内部 O:不属于任何实体 比如我们用B-T表示时间实体的开始,I-T表示时间实体的内部。B-N,I-N表示人名实体。B-L,I-L表示地点。O表示不属于任何实体。之所以要用B,I来区分实体的开始和内部,是因为会有连续的实体。假如出现:“北京上海”这样连续的地名,就可以用“B-L,I-L,B-L,I-L”区分出这是两个地名实体。 ![1202.png](http://rethink.fun/imgs/1202.png) 比如上边,对于一句话:“25年,库克在加州发布了新产品。” 它的标注结果如上图所示。这时模型对输入的每个词进行分类。对于上边的例子,类别种类为7,分别为“B-T,I-T,B-N,I-N,B-L,I-L,O”。 ### 12.2.4 翻译、摘要 翻译、摘要,它们的输入和输出都是文本序列的问题。与NER任务不同,它们的输入和输出序列长度是不同的。 ### 12.2.5 文本蕴含 对于文本蕴含任务,该任务涉及分析两句话之间的逻辑关系:一句是前提,另一句是假设。任务的目标是判断从前提是否可以逻辑地推断出假设,输出结果为“支持”、“反对”或“不相关”。例如,如果前提是“我刚吃完饭”,假设是“我不饿”,那么推断关系应被标记为“支持”;如果假设是“我很饿”,则关系标记为“反对”;如果假设是“我喜欢学习”,则标记为“不相关”。 文本蕴含任务的输入是两个序列,输出是一个分类结果。 ### 12.2.6 语言模型 语言模型(Language Model, LM)的作用是你给出一个句子,模型可以输出这个句子合理性的概率。 比如对于一个语音识别系统而言: “我每天都会洗澡。” “我每天都会洗枣。” 上边两句话从读音上是完全一致的。这时就需要语言模型来判断,哪一句话在现实生活中出现的概率更大,语音识别系统会取概率大的作为识别结果。 早期的语言模型是通过统计大量文本来构建。 P(w1,w2,...,wn)\=P(w1)⋅P(w2∣w1)⋅P(w3∣w1,w2)⋅…⋅P(wn∣w1,...,wn−1) P(w\_1, w\_2, ..., w\_n) = P(w\_1) \\cdot P(w\_2|w\_1) \\cdot P(w\_3|w\_1,w\_2) \\cdot \\ldots \\cdot P(w\_n|w\_1,...,w\_{n-1}) P(w1​,w2​,...,wn​)\=P(w1​)⋅P(w2​∣w1​)⋅P(w3​∣w1​,w2​)⋅…⋅P(wn​∣w1​,...,wn−1​) 上式表达的含义为对于一句话,它由n个单词构成,分别为w1,w2,...,wnw\_1, w\_2, ..., w\_nw1​,w2​,...,wn​,这一句话出现的概率就等于w1w\_1w1​出现的概率,乘以在w1w\_1w1​出现时,后边出现w2w\_2w2​的概率。再乘以w1,w2w\_1,w\_2w1​,w2​出现时,后边出现w3w\_3w3​的概率。以此类推,直到乘以w1,...,wn−1w\_1,...,w\_{n-1}w1​,...,wn−1​出现的情况下,wnw\_nwn​出现的概率。 在实际应用中,直接计算上述公式中的完整条件概率P(wn∣w1,...,wn−1)P(w\_n|w\_1,...,w\_{n-1})P(wn​∣w1​,...,wn−1​)是非常困难的。这是因为自然语言的上下文非常丰富,词汇表庞大,训练语料中很多词序列可能根本没有出现过,导致数据稀疏问题严重。为了降低计算复杂度,同时缓解数据稀疏的问题,通常采用一种近似方法,n-gram 语言模型。n-gram 模型的基本思想是:假设当前单词的出现只依赖于前面的 (n-1) 个单词,而不是整句之前的所有单词。 其中,n 是窗口大小,常见的取值有: unigram (1-gram):只考虑每个词独立出现的概率。 bigram (2-gram):当前词只依赖于前一个词。 trigram (3-gram):当前词依赖于前两个词。 更高阶 n-gram:依赖更长的上下文,但计算和数据需求也会更高。基本上不用。 举个例子,假设我们有个文本: _"I love natural language processing"_ 使用bigram模型,整句话的概率近似为: P(I)⋅P(love∣I)⋅P(natural∣love)⋅P(language∣natural)⋅P(processing∣language) P(I) \\cdot P(love \\mid I) \\cdot P(natural \\mid love) \\cdot P(language \\mid natural) \\cdot P(processing \\mid language) P(I)⋅P(love∣I)⋅P(natural∣love)⋅P(language∣natural)⋅P(processing∣language) 通过统计大量文本中的词对共现频率,便可以估计出这些条件概率。构建一个基于概率的语言模型。 后来神经网络出现后,不再通过统计来给出每个词的概率,而是让神经网络输出每个词的概率。语言模型的能力进一步提升。后边我们会具体讲到利用RNN,LSTM,Transformer,GPT等模型进行神经网络语言模型的构建。 在ChatGPT这样的大语言模型出来前,语言模型并不作为一个单独的NLP任务出现,它通常是辅助完成其他NLP任务,比如上边说的语音转文本任务。 但是当大语言模型能力突破性的提升后,大语言模型统一了所有NLP的任务。既然语言模型可以给出一个句子合理性的概率,它便可以找出当前句子接下来最可能出现的词(概率最大的词),依次类推,不断续写。如果原始输入是一个任务描述呢?比如: “我喜欢这个杯子。上边这句话是正面还是负面评价?” 让大语言模型找出接下来概率最大的词,它会输出“正面”。这样语言模型就完成了文本分类的任务。 大语言模型能力强大,不光可以解决传统NLP问题,还可以解决其他问题。但是它计算成本大,所以目前通过其他方式实现的NLP模型依然还在各行各业发挥作用。 results matching "" =================== No results matching "" ====================== --- # Llama2 · GitBook [Llama2](https://www.rethink.fun/) =================================== 17.3 Llama 2 ------------ Meta 发布的 Llama 2 模型全称为 **Open Foundation and Fine-Tuned Chat Models**,中文可译为《开放的基础和微调对话模型》。相较于前代 Llama 1,Llama 2 在多个方面实现了显著的进步,成为开源大模型中的里程碑。 ### 17.3.1 三大核心进步 Llama 2 相比 Llama 1 有以下三大关键改进: 1. **开放性提升** Llama 2 不仅继续开源,同时也开放了商业使用许可,极大地扩展了其实际应用范围。 2. **训练数据量增加** Meta 强调“数据优先”的理念,相比扩大模型参数,优先扩充训练数据集。Llama 2 的训练数据量较前代增长了约 **40%**,总量达到 **2 万亿个 token**。 3. **引入对话模型** Llama 2 推出专门用于对话的微调版本 —— **Llama 2-Chat**,意图提供 ChatGPT 的开源替代方案。 ### 17.3.2 模型参数与结构配置 Llama 2 共发布三个参数规模的版本: * **Llama 2–7B**:约 70 亿参数 * **Llama 2–13B**:约 130 亿参数 * **Llama 2–70B**:约 700 亿参数 其他主要配置如下: * **上下文长度**从 LLaMA 1 的 2048 token 提升至 **4096 token**; * **训练 GPU 时长**:以 70B 模型为例,训练总计使用了约 **172 万 GPU 小时**。 ### 17.3.3 全流程训练体系 ![1706.png](http://rethink.fun/imgs/1706.png) Llama 2 的训练流程如上图所示,可分为以下阶段: 1. **预训练(Pretraining)** 使用大规模无标注文本进行自回归训练,学习语言的基础结构和语义。 2. **监督微调(Supervised Fine-tuning, SFT)** 使用 **10 万条** 问答对进行标注训练,使模型输出更符合人类意图。 3. **奖励模型训练(Reward Modeling)** 利用 **100 万条** 人类偏好数据,对同一问题的多个回答进行排序,分别训练两个奖励模型: * 安全性奖励模型(Safety) * 有用性奖励模型(Helpfulness) 4. **强化学习(Reinforcement Learning)** 基于奖励模型提供的信号,优化模型输出质量,最终形成用于对话的 LLaMA 2-Chat 模型。 ### 17.3.4 分组查询注意力机制(GQA) 在 Llama 2 的架构中,特别是 70B 模型中引入了 **分组查询注意力机制(Group Query Attention, GQA)**,用于优化传统多头注意力机制。其优点包括: * 降低模型参数量; * 减少推理过程中的KV-Cache; * 提升计算效率。 > 在基于注意力计算的大模型在进行生成时,每次生成一个token,然后将这个token加入到输入序列里,再生成下一个token。这样导致序列前边的token会被重复多次送入大模型进行计算。又因为mask机制限制了每个token只能看到自己后边的token。所以每个token再次被送入大模型,它在每一层计算出来的q,k,v向量都是不变的。又因为在计算后边token时,只用到前边token的k,v向量。所以每次可以将新生成token的k,v向量缓存下来,避免下次生成时的重复计算。这个缓存就叫做KV-Cache。 ![1707.png](http://rethink.fun/imgs/1707.png) 在传统的多头注意力机制中,每个 token 的特征对应生成相等数量的key、query和value头部,如上图(a)所示的八个。在这种设置中,每个query都与其他 token 的相应位置的key以及与自身相应位置的key进行注意力计算。然后根据注意力权重对value向量进行加权求和。 相比之下,如上图(c)所示的多查询注意力机制,虽然每个token的特征仍然生成八个query,但仅生成一个key和一个value。在这种结构中,每个token所有的query都与自己和其他token的生成的唯一key进行相似度计算。并对每个token唯一的value进行注意力加权求和。 分组查询注意力,如上图(b)所示,结合了多头注意力机制和多查询注意力机制的特点。在GQA中,query被分组,例如每组两个,每组对应一个key和一个value。这种方法既保持了注意力机制的精确度,又提高了参数使用的效率。 在传统的多头注意力机制中,假设原始token的维度为 512,对应的Q、K和V矩阵的生成通过线性变换层实现,其中权重矩阵w\_q、w\_k和w\_v的维度均为512×512。由此生成的q、k和v向量维度也均为512,并被分为8个头,每个头的维度为64。 当优化为GQA时,考虑到每两个查询一组的配置,对应的k和v向量可以通过减半其线性层输出的维度来生成,即w\_k和w\_v的维度降为512×256,有效减少了模型的参数量。在这种设置中,尽管q保持8个头,但k和v的头数减少为4个。 为了在实现上保持一致性并有效利用矩阵乘法,k和v的头部在进行注意力计算前会被复制一份,从而与q的头再次对应。这样的处理使得q、k、v三者的头部数量一致,保证了后续逻辑与传统多头注意力机制相符。 GQA在llama2里只应用在最大的模型Llama70B里。其中8个query共享一个key和value。 ### 17.3.5 表现与发展潜力 ![1708.png](http://rethink.fun/imgs/1708.png) 尽管 Llama 2 已使用高达 **2 万亿 token** 的训练数据,但模型的性能表现仍显示出持续优化的空间。如上图所示,训练困惑度(Perplexity)在 token 增加后仍持续下降,Meta 也据此在下一代 LLaMA 3 中进一步加大了数据规模。 Llama 2 不仅在训练数据、模型开放性和对话能力上大幅提升,更在架构上引入创新机制如 GQA,提高了性能与效率。作为开源社区的重要成果,Llama 2 正日益成为工业界与研究界开发智能对话系统的核心基础。 results matching "" =================== No results matching "" ====================== --- # BERT · GitBook [BERT](https://www.rethink.fun/) ================================= 16.2 BERT --------- GPT-1的成功让人们看到了在NLP领域“预训练+微调”模式的可行性,GPT-1是Decoder-Only的架构。BERT(**B**idirectional **E**ncoder **R**epresentations from **T**ransformers)的作者认为GPT-1的注意力是单向的,也就是每个token只能关注它前面token的信息。但是对于NLP任务,双向注意力可以看到序列里所有token的信息,这样对于上下文理解会更加全面,应该会取得更好的效果。 ### 16.2.1 BERT模型架构 BERT是一个Encoder-Only的架构。它采用了Transformer里的Encoder部分的架构。 ![1511.png](http://rethink.fun/imgs/1511.png) BERT里将Encoder模块的层数用L表示,Token Embedding和隐藏层大小用H表示,自注意力的头用A表示。然后推出了两种大小的BERT模型。 **BERT\_base:**(L=12,H=768,A=12) 总参数量为110M。这个大小与GPT-1是同样参数规模的,就是用来和GPT-1比效果的。 **BERT\_large**(L=24,H=1024,A=16)总参数量为340M。 BERT也是采用了“预训练+微调”的模式。在预训练期间,BERT通过不同的预训练任务在未标记的数据上进行模型训练。 对于微调,首先使用预训练的参数初始化 BERT 模型,然后使用下游任务中的标记数据对所有参数进行微调。 每个下游任务都有单独的微调模型,即使它们使用相同的预训练参数进行了初始化。BERT 的一个特征是其跨不同任务的统一结构。也就是BERT预训练的结构和最终的下游任务在模型结构之间的差异很小。 ### 16.2.2 输入和输出 BERT可以解决NLP领域的两类问题,一类是序列级别的问题,一类是token级别的问题。 #### 序列级别的问题 序列级别的问题也分为两类,一类是单个序列的,比如对一句话进行正面和负面情绪的判别。一类是两个序列的,比如判断两句话的蕴含关系。所以这就需要BERT在输入设计上能够支持单个序列,也要能支持两个序列。输出上需要有一个位置可以输出对单个或者多个序列进行总结的信息。 #### Token级别的问题 Token级别的问题类似NER,它需要BERT能够为每个token输出它在上下文中的含义信息。 所以最终BERT的输入如下: ![1606.png](http://rethink.fun/imgs/1606.png) 上图中最上边的Input是原始输入,我们给所有的输入前增加一个特殊的\[cls\] token,它是一个占位符,将来在BERT Encoder Block最后一层第一个位置的输出向量,就代表了整个输入的全局信息。利用这个信息,加一个分类头就可以对序列(单序列或者多序列)进行分类。我们发现Input中间还有一个\[SEP\] token,它就是序列的分隔符,用来隔开两个序列的。通过这个token,让模型知道这个token的前后属于不同的序列。 对于token级别的任务,每个token位置最后一层Encoder的输出的向量就代表了这个token结合上下文后的表示信息,加一个分类头就可以进行NER分类了。对于所有token都共享一个NER分类头。 上图中下边有3个不同的Embedding。它们按位相加,构成了序列最终的输入向量。 * Token Embedding,就是每个token的词向量,代表了每个token的原始含义。此时还没有上下文信息。字典里每个token都有一个Embedding,并且是可学习的参数。 * Segment Embedding,用于区分每个token属于哪个序列的Embedding,每个序列共享一个Embedding,也是可学习参数。 * Position Embedding,用于给每个位置一个Embedding,不同与Transformer里用sin和cos函数生成固定位置编码,这里的位置编码也是可学习参数,让模型在训练中自己学习。 ### 16.2.3 预训练BERT 因为BERT预设是可以同时解决序列级别问题和token级别的问题。所以预训练时就必须设计能提取token上下文信息和序列信息的任务。 #### Token级别任务:遮蔽语言模型 遮蔽语言模型(Masked Language Model),是随机将输入token中的一些进行mask,替换为\[MASK\] token。然后在BERT输出时,根据\[MASK\]token的输出向量加一个分类头,预测出原始被遮蔽的token id。这个任务就是填空题,锻炼模型根据上下文信息猜出空缺token的能力。这样训练模型就让模型能更高的提取token级别的上下文信息。但是为了防止模型只是在输入\[MASK\]这个token时才刻意提取它的上下文信息,实际BERT在训练时,先随机选择15%的token做最终的token id预测。在这15%的token里,80%的概率用\[MASK\] token替换原来的token,10%的概率随机替换为其他token,10%的概率保持原有token不变。经过这样的数据设计,BERT模型就可以很好的提取没个token的上下文信息了。 #### 序列级别任务:下一句预测 很多NLP下游任务,比如问答,自然语言推理,都是基于理解两个句子的关系。BERT设计的序列级别的任务是判断两个句子是否是连续的。在预训练样本中采集两个连续的句子作为正样本,两个不连续的句子作为负样本,提取\[cls\] token 最后一层的输出向量加一个分类头,进行二分类判断。这个任务可以很好的让\[cls\]token提取序列级别的上下文信息。 对于预训练数据,BERT使用了BooksCorpus(800万个单词)和英文的Wikipedia(25亿个单词)。 ### 16.2.4 微调BERT BERT的微调很简单,只需要按照问题类型组织输入token即可。比如是两个序列,就在序列之间加\[SEP\] token,并且设置合适的Segment Embedding。 另外根据不同的任务,利用输出不同位置token的输出,加上分类头来进行下游任务。序列级别的就提取\[CLS\] token的输出,token级别的就提取每个token的输出。 BERT进行微调时,只有分类头是全新的随机初始化的,其他参数,包括Encoder和Embedding都是预训练好的,进行全参数量的微调。 ### 16.2.5 BERT的意义 BERT 在概念上很简单,在实际应用中非常强大。 它在 11 种自然语言处理任务上获得了当时最优结果,包括将 GLUE 得分提高到 80.5%(提升绝对值 7.7%),MultiNLI 准确度达到 86.7%(提升绝对值 4.6%),SQuAD v1.1 问答测试 F1 达到 93.2(提升绝对值 1.5 个点)以及 SQuAD v2.0 测试集 F1 达到 83.1(提升绝对值 5.1 个点)。 BERT是NLP领域第一个火出圈,现象级的预训练模型,在大语言模型出来之前,BERT就是NLP领域最受人瞩目的模型。大多数的NLP问题,仅需要少量的标注数据,甚至几百个,就可以达到非常不错的效果。 results matching "" =================== No results matching "" ====================== --- # GRU · GitBook [GRU](https://www.rethink.fun/) ================================ 13.3 GRU -------- 上一节我们讲了LSTM,它确实有些复杂,门控循环单元(Gated Recurrent Unit,GRU)是对LSTM的简化版,实验表明,GRU的性能基本和LSTM相当。 ### 13.3.1 GRU的网络结构 GRU去掉了LSTM中的记忆细胞状态ctc\_tct​,仅用隐状态hth\_tht​就解决了长期记忆和梯度消失的问题。我们来一步步看一下GRU循环层的设计逻辑。 ![1328.png](http://rethink.fun/imgs/1328.png) 首先我们看如何从隐状态中读取信息,GRU里也通过一个遗忘门来决定从长期记忆里去掉一些信息。不过GRU里叫做重置门,GrG\_rGr​。重置门也是用`simgoid`函数,它的输入是上一时刻的记忆ht−1h\_{t-1}ht−1​和当前时刻的输入xtx\_txt​进行拼接,然后经过一个线性层得到的。线性层的权重为wrw\_rwr​。 Gr\=sigmoid(\[ht−1∣xt\]wr+br)G\_r=sigmoid(\[h\_{t-1}|x\_t\]w\_r+b\_r)Gr​\=sigmoid(\[ht−1​∣xt​\]wr​+br​) 于是ht−1⊙Grh\_{t-1}\\odot G\_rht−1​⊙Gr​就是经过重置后的长期记忆。 ![1329.png](http://rethink.fun/imgs/1329.png) 重置后的长期记忆和当前输入xtx\_txt​合并,然后经过一个线性层(权重为whw\_hwh​),加`tanh`激活,就得到当前层的备用输出ht~\\tilde{h\_t}ht​~​。 此时,备用输出还是不能直接输出,因为GRU就只能靠隐状态来传递长期记忆,这里将需要长期保留的记忆加进来再作为当前时间步的隐状态作为输出。怎么决定哪些维度保留长期记忆,哪些维度更新为备用输出的隐状态呢?答案还是用一个门函数来控制。不过这个门函数同时决定保留多少长期记忆,更新多少当前步产生的记忆。 ![1330.png](http://rethink.fun/imgs/1330.png) 首先用一个`sigmoid`更新门,生成一个更新向量,它和备用输出按位相乘,获得要更新到长期记忆里的信息。然后用1减去更新向量里的每一维,这样就得到了对长期记忆的保留向量。用保留向量与长期记忆按位点乘,就得到了保留的长期记忆,在和更新信息相加,就得到了这一步输出的长期记忆,hth\_tht​。 ![1331.png](http://rethink.fun/imgs/1331.png) 我们将两个GRU相连,可以发现它也和LSTM类似,实现了隐状态的相连,可以长期保留记忆,也可以让梯度更容易传递到前边的时间步。 ![1332.png](http://rethink.fun/imgs/1332.png) ### 13.3.2 公式化表示 下边我们用公式来定义每个向量的计算: 重置门: Gr\=sigmoid(\[ht−1∣xt\]wr+br\]G\_r=sigmoid(\[h\_{t-1}|x\_t\]w\_r+b\_r\]Gr​\=sigmoid(\[ht−1​∣xt​\]wr​+br​\] 更新门: Gu\=sigmoid(\[ht−1∣xt\]wu+bu\]G\_u=sigmoid(\[h\_{t-1}|x\_t\]w\_u+b\_u\]Gu​\=sigmoid(\[ht−1​∣xt​\]wu​+bu​\] 备用输出: ht~\=tanh(\[ht−1⊙Gr∣xt\]wh+bh)\\tilde{h\_t}=tanh(\[h\_{t-1} \\odot G\_r | x\_t\]w\_h+b\_h)ht​~​\=tanh(\[ht−1​⊙Gr​∣xt​\]wh​+bh​) 隐状态: ht\=Gu⊙ht~+(1−Gu)⊙ht−1h\_t=G\_u \\odot \\tilde{h\_t} + (1-G\_u) \\odot h\_{t-1}ht​\=Gu​⊙ht​~​+(1−Gu​)⊙ht−1​ ### 13.3.3 用GRU和LSTM LSTM一般更常用,效果更好,用的人更多。但是LSTM因为更复杂,参数更多训练成本会更高一些。如果你更在意训练成本可以用GRU。 results matching "" =================== No results matching "" ====================== --- # RNN · GitBook [RNN](https://www.rethink.fun/) ================================ 13.1 RNN -------- 循环神经网络(Recurrent Neural Network,RNN)是在Transformer架构出现之前,处理序列问效果最好的网络结构。 序列问题,简单来说,就是处理**数据之间存在顺序关系**的问题,数据的顺序改变则代表含义改变。更正式一点的定义是: 序列问题是指输入、输出或两者都是有序的数据序列,元素之间存在时间上或位置上的依赖关系,模型在处理当前元素时需要考虑前面(或未来)元素的信息。 序列问题的特点是: * 数据是按顺序排列的(顺序有意义)。 * 数据之间存在依赖关系(后面的值依赖前面的值)。 * 进行处理时不能简单打乱数据顺序。 典型的序列问题有: * 自然语言处理(NLP) * 时间序列预测(股价、天气) * 语音识别 * 视频分析 * 机器人控制 ### 13.1.1 序列到序列问题 我们以之前讲过的NLP相关问题为例,可以发现,序列到序列问题有以下几种输入和输出情况: **多对一** 比如输入为一条评论文本,输出是一个二分类,判断是评论正面还是负面情绪。输入是多个向量,输出是一个向量。 **多对多** 多对多任务分为两种,一种是输入token序列长度和输出token序列长度完全一样。比如命名实体识别(NER)任务。还有一种是输入token序列和输出token序列长度不一样的情况,比如对输入文本生成摘要,或者翻译任务。输入和输出都是多个向量。 不论对于输入或输出的序列而言,它们的序列元素个数也是不固定的,我们不能像之前设计全连接神经网络那样,固定输入和输出的神经元个数。 ### 13.1.2 RNN网络结构 我们还是以之前举的NER任务为例,目标是识别输入句子里的人名。我们分别看两个输入: _“我的鞋是李宁的。”_ _“我叫李宁。”_ 一个好的NER模型应该能根据上下文识别出第一句里的“李宁”是品牌名,第二句的“李宁”是人名。 为了让RNN能够处理变长的输入,它被设计成循环调用的方式,每次只输入序列里的一个元素。对应到NLP任务里,每个元素就是一个token的embedding。 以第二个句子“我叫李宁”为例,目标是识别人名,所以对于每个token的输出是一个三分类(B-N:名字的开头,I-N:名字的后续,O:其他)。NER的输入Token序列长度和输出序列长度一样。 ![1301.png](http://rethink.fun/imgs/1301.png) RNN对于第一个token,输入是它的embedding,经过两层,一个隐藏层(3个神经元)。一个是输出层,输出层有3个神经元,对应NER的三分类。这看起来和普通的神经网络没有区别。 RNN需要能为序列保留记忆,这样后边的token才可能判断自己是人名还是公司名。怎么保存记忆呢?RNN把隐藏层的输出作为记忆保存,如下图: ![1302.png](http://rethink.fun/imgs/1302.png) 接下来我们看RNN如何处理第二个token,它将当前RNN的记忆与第二个token的embedding进行拼接作为输入,这样第二个token就有了上下文信息了。同时RNN在处理第二个token时会更新RNN网络的记忆,以便后用。特别需要注意的是:第一步和第二步**RNN的参数是共享的**,如下图所示: ![1303.png](http://rethink.fun/imgs/1303.png) 第三步和第二步一样,拼接RNN的记忆和当前token的embedding,传入RNN,并用隐藏层的输出更新RNN的记忆。 ![1304.png](http://rethink.fun/imgs/1304.png) 细心的你可能发现处理第一个token时,没有记忆怎么办?答案是即使是第一步,我们会用同维度的全零记忆向量表示当前RNN没有记忆。这样就保证了所有步网络结构和处理逻辑的统一。 ![1305.png](http://rethink.fun/imgs/1305.png) 下边我们用公式详细定义一下RNN的计算: 我们用xtx\_txt​表示第ttt个时刻的输入。 我们用yty\_tyt​表示第ttt个时刻的输出。 我们用hth\_tht​表示第ttt个时刻更新后记忆,人们更常把它叫做隐状态。 我们用whw\_hwh​表示隐藏层的权重,用bhb\_hbh​表示隐藏层的偏置。 我们用wyw\_ywy​表示输出层的权重,用byb\_yby​表示输出层的偏置。 当前时刻的隐状态等于前一时刻的隐状态和当前时刻的序列输入进行拼接,然后经过隐藏层线性变化和激活函数后的输出。 ht\=tanh(\[ht−1∣xt\]wh+bh) h\_t = tanh(\[h\_{t-1}|x\_t\]w\_h+b\_h) ht​\=tanh(\[ht−1​∣xt​\]wh​+bh​) 这里一般更常用tanh作为激活函数,也可以用ReLU。之前我们介绍过ReLU可以更好的缓解梯度消失,RNN用tanh做激活,是因为它有其他方法来应对梯度消失。我们后边会讲。 输出层是一个多分类,激活函数用softmax: yt\=softmax(htwy+by) y\_t = softmax(h\_tw\_y+b\_y) yt​\=softmax(ht​wy​+by​) > 注意:你看其他教程里可能会把whw\_hwh​分拆为whhw\_{hh}whh​和wxhw\_{xh}wxh​。然后用这两个权重分别和ht−1,xth\_{t-1},x\_{t}ht−1​,xt​进行线性变化,然后再把结果相加。ht\=tanh(xtwxh+ht−1whh+bh)h\_t=tanh(x\_tw\_{xh}+h\_{t-1}w\_{hh}+b\_h)ht​\=tanh(xt​wxh​+ht−1​whh​+bh​)。这个运算实际上和我上边讲的是完全等价的,而且我讲的应该更好理解一些。 ### 13.1.3 循环层和普通层 ![1316.png](http://rethink.fun/imgs/1316.png) 通过上图,我们观察RNN中的计算图,发现RNN中的循环实际上只发生在第一层,它是RNN的关键,是循环实际发生的地方,所以我们可以叫它**循环层**。每一个时间步对循环层的梯度更新会通过计算图递归调用到前边所有时间步的循环层。循环层的网络参数会被更新多次。 上图中的第二层是**普通层**,它不参与循环。每一个时间步对普通层的梯度更新,只会发生在当前步,普通层的网络参数只会被更新一次。这和普通神经网络没有区别。 所以说,**循环层的递归调用才是RNN的本质。**每一时间步通过对之前所有时间步的循环层的调用,输出关键的隐状态hth\_tht​。对于普通层,可以看成是每一时间步利用hth\_tht​向量作为输入,进行的额外的分类或者回归任务。普通层不是RNN的核心,它只是为了完成每一步的特定任务添加的任务层。 RNN重要的是循环层,以及循环层在每个时刻输出的隐状态,普通层利用隐状态完成每个时间步的任务。 ### 13.1.4 RNN的优势 通过上边对RNN结构的讲解,不难发现RNN的优势: * 通过隐状态机制,赋予模型记忆能力,能利用历史信息进行当前决策。 * 参数共享机制极大地提高了模型的效率和泛化能力。 * 能够应对任意长度的序列输入。 results matching "" =================== No results matching "" ====================== --- # LSTM · GitBook [LSTM](https://www.rethink.fun/) ================================= 13.3 LSTM --------- 这一节我们先来了解RNN中存在的问题,然后来看解决办法。 ### 13.3.1 RNN中的问题 我们看下边这句话: “**小强**是一个爱学习,有礼貌,每次考试成绩都很好,同时又热爱各种体育活动,对人热情大方的好学生。老师们都很喜欢**他**。” 可以看到这句话前边的token“小强”,影响最后一个token是“他”,而不是“她”。自然语言中存在大量的这种长期依赖,但是RNN的记忆一般是都是短期的,很难保留很长的时间步。 从前向传播来看,虽然每个时间步的RNN都通过隐状态向下一时间步传递信息,但是隐状态和当前步的输入一起进入线性层,激活函数。复杂的线性变化和激活函数,让隐状态很难经过多个时间步后还能完整的保留之前的记忆信息。所以隐状态本身传递的是一种短期记忆(Short-Term Memory)。 再从后向传播角度看,以上边的“小明”对应“他”的例子,如果训练时模型错误的输出为“她”,这个错误信息计算loss,生成模型在输入为“小强”时刻的参数的梯度,需要经过大量的时间步向后传递,大量小数值的梯度值相乘,会出现梯度消失的问题。这和深度神经网络梯度很难传递到网络靠前的层是一样的。 所以我们需要想办法解决RNN中长距离记忆传递,以及梯度消失的问题。 ![1311.png](http://rethink.fun/imgs/1311.png) ### 13.3.2 LSTM网络结构 LSTM(Long Short-Term Memory)长短期记忆网络就是被用来解决RNN没有长期记忆的问题的。 ![1319.png](http://rethink.fun/imgs/1319.png) 如上图所示,LSTM是通过给RNN里增加一个记忆细胞来实现长期记忆的。 ![1320.png](http://rethink.fun/imgs/1320.png) 如上图所示,假如我们来了一个新的信息,它是一个神经网络的输出logits,用ZZZ表示,它是一个向量,里边有多个维度。经过激活函数`tanh`后为将要写入记忆细胞的记忆。这里它不能直接写入,要不然它就完全覆盖了老的记忆,达不到长期记忆的效果。解决办法就是我们通过一个门函数来控制当前新信息里的哪些维度可以写入到长期记忆里,这里的门函数是`sigmoid`,它输出的也是一个向量,取值是0到1,对新信息里的每个维度进行独立控制。因为sigmoid在大部分定义域都接近0或者1,所以它可以看做是允许某些维度的新信息进入长期记忆,某些维度的新信息不能进入长期记忆。如果是在0到1之间的值,就代表部分可以进入长期记忆,我们把这个门叫做输入门。 接下来我们看经过输入门筛选的新信息如何进入长期记忆。首先要取出记忆细胞内的长期记忆,取出长期记忆时,需要通过一个sigmoid的遗忘门,来遗忘长期记忆里某些维度的信息,如下图所示: ![1321.png](http://rethink.fun/imgs/1321.png) 接下来把经过筛选的新信息和经过遗忘的长期记忆按位相加,然后经过一个`tanh`激活,作为当前记忆细胞待输出的记忆。如下图所示: ![1322.png](http://rethink.fun/imgs/1322.png) 目前的待输出信息是这个时间步更新后的长期记忆,需要经过一个`tanh`激活函数,进行转化,同时还要再加一个`sigmoid`的输出门来控制一下哪些值适合在当前时间步输出。这里的输出就对应之前RNN的隐状态。 ![1323.png](http://rethink.fun/imgs/1323.png) LSTM通过输入门、遗忘门来控制对长期记忆的更新,通过输出门来控制隐状态的输出。上边描述的都是循环层,如果每个时间步需要输出,则对隐状态的输出增加普通层即可。 那记忆细胞的状态如何在多个时间步进行传递呢?原来的RNN在多个时间步之间传递一个隐状态hhh,现在就再多传递一个记忆细胞状态ccc。 ![1325.png](http://rethink.fun/imgs/1325.png) 之前我们还没有说上图中下边的四个向量Z,Zi,Zf,ZoZ,Z\_i,Z\_f,Z\_oZ,Zi​,Zf​,Zo​是怎么来的,它们实际上都是上一个状态的ht−1h\_{t-1}ht−1​和当前时刻的xtx\_txt​拼接后作为输入,分别做4个线性回归得到的logits值。这4个线性回归对应的权重分别为wh,wi,wf,wow\_h,w\_i,w\_f,w\_owh​,wi​,wf​,wo​。如下图所示: ![1326.png](http://rethink.fun/imgs/1326.png) 我们把两个时刻的LSTM循环层连接起来: ![1327.png](http://rethink.fun/imgs/1327.png) 此时你会发现记忆细胞的长期记忆有一个直接连接的通道,这条通道上没有线性回归,没有激活函数。长期记忆容易保持,反向传递时,梯度也更容易传递到前边的时间步。 ### 13.3.3 公式化表达 上边我们通过图形化方式对LSTM进行了理解,下边我们用公式来定义每个向量的计算: 新信息的logits: Z\=\[ht−1∣xt\]wh+bhZ = \[h\_{t-1}|x\_t\]w\_h+b\_hZ\=\[ht−1​∣xt​\]wh​+bh​ 输入门: Zi\=\[ht−1∣xt\]wi+biZ\_i = \[h\_{t-1}|x\_t\]w\_i+b\_iZi​\=\[ht−1​∣xt​\]wi​+bi​ Gi\=sigmoid(Zi)G\_i = sigmoid(Z\_i)Gi​\=sigmoid(Zi​) 遗忘门: Zf\=\[ht−1∣xt\]wf+bfZ\_f = \[h\_{t-1}|x\_t\]w\_f+b\_fZf​\=\[ht−1​∣xt​\]wf​+bf​ Gf\=sigmoid(Zf)G\_f = sigmoid(Z\_f)Gf​\=sigmoid(Zf​) 输出门: Zo\=\[ht−1∣xt\]wo+boZ\_o = \[h\_{t-1}|x\_t\]w\_o+b\_oZo​\=\[ht−1​∣xt​\]wo​+bo​ Go\=sigmoid(Zo)G\_o = sigmoid(Z\_o)Go​\=sigmoid(Zo​) 记忆细胞状态: ct\=ct−1⊙Gf+tanh(Z)⊙Gic\_t = c\_{t-1}\\odot G\_f+ tanh(Z)\\odot G\_ict​\=ct−1​⊙Gf​+tanh(Z)⊙Gi​ 隐状态: ht\=tanh(ct)⊙Goh\_t = tanh(c\_t)\\odot G\_oht​\=tanh(ct​)⊙Go​ ### 13.3.4 我的看法 我第一次学完LSTM,觉得这实在太复杂了,但是它的效果确实不错,在Transformer架构之前,它是解决序列问题最好的网络架构。如果你一次看不懂没有关系,多看几遍慢慢就理解了。我当年学习时也是反复多次才搞清楚的。 * * * 恭喜你,你已经掌握了RNN里最复杂的LSTM模型! 扫码请作者喝一杯咖啡来分享你的喜悦吧! ![zsm](http://rethink.fun/imgs/zsm.png) results matching "" =================== No results matching "" ====================== --- # 数据准备 · GitBook [数据准备](https://www.rethink.fun/) ================================= 14.1 数据准备 --------- 从这一节开始,我们将动手来实现一个可以将英文翻译为中文的模型。这一节我们先来做一些数据准备工作。 具体代码在[translator.py](https://github.com/RethinkFun/DeepLearning/blob/master/chapter14/translator.py) 里。 ### 14.1.1 训练数据 你可以从[这个地址](https://pan.baidu.com/s/1v2K4Awr03mXUUPJbpyMfpw?pwd=4ak4) 下载英文和中文对照的数据。解压`en2cn.zip`文件后,你会得到4个文件。 `train_en.txt`和`train_zh.txt`是训练数据。 `valid_en.txt`和`valid_zh.txt`是验证数据。 其中`train_en.txt`有一千万行英文句子,`train_zh.txt`中有对应的一千万行中文翻译句子。类似的`valid_en.txt`和`valid_zh.txt`分别有8000个英文和中文对照的句子。 ### 14.1.2 构建词典 我们在[之前章节](http://www.rethink.fun/chapter12/%E8%AF%8D%E5%85%B8%E7%94%9F%E6%88%90.html) 讲过如何利用BPE算法构建NLP模型的词典。这里我们就来实际构建一次。 首先你需要安装sentencepiece这个包, pip install sentencepiece 然后运行下边代码来分别生成英文和中文的词典。 import sentencepiece as spm spm.SentencePieceTrainer.Train('--input="data\\en2cn\\train_en.txt" --model_prefix=en_bpe --vocab_size=16000 --model_type=bpe --character_coverage=1.0 --unk_id=0 --pad_id=1 --bos_id=2 --eos_id=3') spm.SentencePieceTrainer.Train('--input="data\\en2cn\\train_zh.txt" --model_prefix=zh_bpe --vocab_size=16000 --model_type=bpe --character_coverage=0.9995 --unk_id=0 --pad_id=1 --bos_id=2 --eos_id=3') \--character\_coverage参数是覆盖多少用字符集,因为英文单个字符有限,所以我们设置为1.0。但是中文有很多生僻字,所以我们设置为0.9995防止词表被大量生僻词占用。 vocab\_size=16000参数是设置词表的大小,我们都设置为16000。 因为英语基本字符有限,中文基本字符较多,字符组合可能较多,需要分别统计频率,所以BPE生成中文词表过程会比较慢。这个过程可能需要几十分钟。建议你耐心等它生成完成。 如果你真的心急,可以通过下边的参数来采样一百万句子来生成词表。 --input_sentence_size=1000000 --shuffle_input_sentence=true 生成完之后,我们可以打开`en_bpe.vocab`来看一下英文词表: 0 0 0 0 ▁t -0 he -1 ▁a -2 in -3 ou -4 re -5 ▁s -6 ▁w -7 on -8 ▁the -9 er -10 at -11 ▁c -12 ▁m -13 ▁I -14 ▁b -15 an -16 it -17 ing -18 中文词表: 0 0 0 0 ▁我 -0 .. -1 ▁你 -2 我们 -3 什么 -4 ▁他 -5 一个 -6 知道 -7 ... -8 ▁我们 -9 他们 -10 ▁但 -11 如果 -12 不是 -13 没有 -14 可以 -15 因为 -16 ▁在 -17 你的 -18 词表里的每个词,我们叫做一个token,其中符号“▁”表示词开始的位置。后边的数字,分数越大(接近 0),表示该 token 在训练时越频繁或优先级越高;分数越小(负号越大),表示频率更低。 接下来我们使用我们训练出来的词典模型进行分词: import sentencepiece as spm sp_cn = spm.SentencePieceProcessor() sp_cn.load('zh_bpe.model') text = "今天天气非常好。" eoncode_result = sp_cn.encode(text, out_type=int) print("编码:", eoncode_result) decode_result = sp_cn.decode(eoncode_result) print("解码:", decode_result) 可以看到输出为: 编码: [387, 3205, 5241, 11821] 解码: 今天天气非常好。 其中: “今天” 作为一个token,编码为387。 “天气” 作为一个token被编码为3205。 “非常好” 作为一个token被编码为5241。 “。” 作为一个token被编码为11821。 ### 14.1.3 定义Dataset import torch from torch.utils.data import Dataset, DataLoader class TranslationDataset(Dataset): ## 初始化方法,读取英文和中文训练文本。然后给每个句子前后增加。 为了防止训练时显存不足,对于长度超过限制的 ## 句子进行过滤。 def __init__(self, src_file, trg_file, src_tokenizer, trg_tokenizer, max_len=100): with open(src_file, encoding='utf-8') as f: src_lines = f.read().splitlines() with open(trg_file, encoding='utf-8') as f: trg_lines = f.read().splitlines() assert len(src_lines) == len(trg_lines) self.pairs = [] self.src_tokenizer = src_tokenizer self.trg_tokenizer = trg_tokenizer for src, trg in zip(src_lines, trg_lines): # 每个句子前边增加后边增加 src_ids = [BOS_ID] + self.src_tokenizer(src) + [EOS_ID] trg_ids = [BOS_ID] + self.trg_tokenizer(trg) + [EOS_ID] # 只保留输入和输出序列token数同时小于max_len的训练样本。 if len(src_ids) <= max_len and len(trg_ids) <= max_len: self.pairs.append((src_ids, trg_ids)) # <-- 直接保存token id序列 def __len__(self): return len(self.pairs) def __getitem__(self, idx): src_ids, trg_ids = self.pairs[idx] return torch.LongTensor(src_ids), torch.LongTensor(trg_ids) ## 对一个batch的输入和输出token序列,依照最长的序列长度,用 token进行填充,确保一个batch的数据形状一致,组成一个tensor。 @staticmethod def collate_fn(batch): src_batch, trg_batch = zip(*batch) src_lens = [len(x) for x in src_batch] trg_lens = [len(x) for x in trg_batch] src_pad = nn.utils.rnn.pad_sequence(src_batch, padding_value=PAD_ID) trg_pad = nn.utils.rnn.pad_sequence(trg_batch, padding_value=PAD_ID) return src_pad, trg_pad, src_lens, trg_lens 如上代码所示,我们定义了一个`TranslationDataset`,它继承自`Dataset`。在初始化方法中,读取了英文和中文训练数据,并对每个句子首尾增加``和``token。这两个token在训练模型时很重要,生成模型看到输入是``就知道接下来要开始输出翻译内容了。 如果生成模型输出了``就代表生成模型输出完毕。为了防止训练时显存不足,我们去掉了过长的训练数据。 我们同时定义了一个`collate_fn`方法,它是对batch数据来进行额外处理的。它的作用是把一个batch里的英文token序列或者中文token序列都补成同样长度,batch长度以batch里最长的序列为准,其他序列后边以`` token来填充。这么做的原因是我们想把一个batch的数据作为一个tensor传入模型进行训练,但是tensor要求内部数据形状必须一致,所以我们用`` token填充短的序列。如下图所示: ![1341.png](http://rethink.fun/imgs/1341.png) 因为我们把读取数据和编码过程都放在了Dataset的初始化方法里,所以加载数据需要一些时间。 ### 14.1.4 利用Dataloader读取数据 dataset = TranslationDataset('data\\en2cn\\train_en.txt', 'data\\en2cn\\train_zh.txt', tokenize_en, tokenize_cn) loader = DataLoader(dataset, batch_size=4, shuffle=True, collate_fn=TranslationDataset.collate_fn) for src, trg, _, _ in loader: print(src.shape, trg.shape) print(src, trg) break 输出为: torch.Size([15, 4]) torch.Size([13, 4]) tensor([[ 2, 2, 2, 2],\ [ 140, 582, 315, 5447],\ [15878, 7077, 15878, 15880],\ [ 9, 2199, 15860, 315],\ [ 178, 146, 418, 15878],\ [ 172, 416, 42, 15860],\ [ 42, 250, 219, 15875],\ [ 2118, 2836, 882, 196],\ [10110, 146, 15869, 15875],\ [ 54, 3, 3, 746],\ [ 3995, 1, 1, 1185],\ [15869, 1, 1, 130],\ [ 3, 1, 1, 928],\ [ 1, 1, 1, 15880],\ [ 1, 1, 1, 3]]) tensor([[ 2, 2, 2, 2],\ [ 2238, 211, 1535, 846],\ [ 18, 13633, 2661, 7902],\ [12171, 12687, 4458, 11833],\ [12885, 5072, 11827, 11458],\ [11850, 12222, 11821, 11823],\ [12787, 12, 3, 26],\ [12400, 79, 1, 449],\ [11827, 1204, 1, 11822],\ [11821, 12, 1, 1203],\ [ 3, 3, 1, 7762],\ [ 1, 1, 1, 11833],\ [ 1, 1, 1, 3]]) 你可能注意到,输出的tensor的shape为\[seq\_len, batch\_size\]。这时因为在pytorch里,RNN默认的数据处理和模型输入都先是seq\_len,后是batch\_size。因为RNN里对序列数据的处理必须是按序列顺序从前到后处理,所以将seq\_len放在第一位方便按照序列顺序读取数据。我们在对batch数据处理的函数里调用了`nn.utils.rnn.pad_sequence()`函数,它默认输出的tensor形状就是\[seq\_len, batch\_size\]。 results matching "" =================== No results matching "" ====================== --- # 注意力机制 · GitBook [注意力机制](https://www.rethink.fun/) ================================== 13.7 注意力机制 ---------- 注意力机制是一个可以显著提升序列到序列模型性能的技术。我们这一节就来一探究竟。 ### 13.7.1 序列到序列模型的问题 假如我们要做一个英文翻译到中文的模型,需要翻译的英文为: “Since I started writing this book on December 6, 2024, 193 days have passed. I’m truly grateful that I’ve been able to keep going until now. I will continue writing until the book is complete, and I hope it will be helpful to everyone.” 它对应的中文为: “自从我在2024年12月6日开始写这本书以来,已经过去193天了,我非常庆幸自己能够坚持到现在。我会继续写下去,直到这本书完成,也希望这本书能对大家有所帮助。” 翻译问题需要用到我们之前介绍的Encoder-Decoder的模式,如下图所示: ![1338.png](http://rethink.fun/imgs/1338.png) 在Encoder阶段,读入所有的英文token,最后只有一个隐状态传递给Decoder。Decoder阶段,第一个时间步的输入有两个:分别是代表序列开始的`` token,和Encoder传递来的隐状态。输出为中文的第一个token,和Decoder第一个时间步的隐状态。然后把输出的第一个中文token的embedding作为下一个时间步的token输入,外加上一时间步的隐状态,输出第二个中文token。依次类推,直到某个时间步Decoder输出的token为序列结束符``。 你可能会有一个担心,输入的英文句子很长,并且里边还有大量的比如时间这样的细节信息,这些信息都可以被压缩到Encoder最终输出的这一个隐状态里吗?答案是并不能,这种结构在输入句子较短的时候表现还可以,但是随着输入句子的长度增加,效果会随之下降。 ### 13.7.2 注意力机制 我们人类是如何进行长句的翻译呢?我们也不是一次性记住所有要翻译的句子,然后一次性全部翻译。我们会在翻译时,时不时的把注意力放在原始的英文句子的某些部分,然后逐步的生成中文的翻译。注意力机制就是模拟人类这种翻译方法,让Decoder在生成内容时,可以把注意力放在Encoder不同时间步的隐状态上作为参考信息。 所以添加了注意力机制的Encoder-Decoder RNN在Decoder生成内容时,它有以下几个输入: 1. 上一时刻的隐状态(LSMT还有上一时刻的细胞状态)。 2. 上一时刻输出token的embedding(上一时刻的输出就是这一时刻的输入)。 3. 通过注意力观察后的由Encoder不同时间步的隐状态构成的注意力向量(即使是LSTM,也只用隐状态。因为隐状态更能代表当时时间步的信息,细胞状态是长期记忆信息)。 注意力机制其实很简单,就是将Encoder在每个时间步,也就是对每个英文token生成的隐状态的值进行加权求和(权值之和为1)生成一个注意力向量。 attention\=∑i\=1tαihi attention = \\sum\_{i=1}^{t} \\alpha \_ih\_i attention\=i\=1∑t​αi​hi​ 其中αi\\alpha\_iαi​是标量,表示在翻译当前token时对hih\_ihi​的注意力,且∑i\=1tαi\=1\\sum\_{i=1}^{t} \\alpha \_i=1∑i\=1t​αi​\=1。hih\_ihi​为第i个时间步,也就是第i个英文token输出的隐状态向量。 有了注意力向量,将它与每个时间步输入的中文token的embedding进行拼接作为输入就实现了带注意力的Encoder-Decoder RNN了。 我们就剩下一个问题了,那就是如何计算注意力权重αi\\alpha\_iαi​? 在翻译过程中,对于不同的时间步,需要关注的点,注意力权重是不一样的。在Decoder不同时间步,对Encoder中不同英文token输出的隐状态的注意力是不同的。具体注意力权重的值是通过一个简单的神经网络来计算的。我们通过下边这个例子来详细解释。 ![1339.png](http://rethink.fun/imgs/1339.png) 假设Encoder输入3个英文token,分别为x0,x1,x2x\_0,x\_1,x\_2x0​,x1​,x2​。初始隐状态为h0h\_0h0​,三个时间步对应的隐状态分别为h1,h2,h3h\_1,h\_2,h\_3h1​,h2​,h3​。将h3h\_3h3​作为解码器的隐状态输入,为了区分,Decoder的隐状态用s表示。所以h3\=s0h\_3=s\_0h3​\=s0​。Decoder每一步生成的中文token,作为下一时间步的输入。并且中间产生隐状态s1,s2,s3s\_1,s\_2,s\_3s1​,s2​,s3​。这个图里边是没有增加注意力机制的Encoder-Decoder RNN,这里我们只是定义了符号,接下来我们看如何计算注意力权值。 我们以Decoder第一个时间步的注意力向量att0att\_0att0​为例,需要生成对h1,h2,h3h\_1,h\_2,h\_3h1​,h2​,h3​的注意力权重值α1,α2,α3\\alpha\_1,\\alpha\_2,\\alpha\_3α1​,α2​,α3​,并且需要保证α1+α2+α3\=1\\alpha\_1+\\alpha\_2+\\alpha\_3=1α1​+α2​+α3​\=1。则注意力向量att0att\_0att0​就等于: att0\=α1h1+α2h2+α3h3 att\_0=\\alpha\_1h\_1+\\alpha\_2h\_2+\\alpha\_3h\_3 att0​\=α1​h1​+α2​h2​+α3​h3​ 我们定义一个简单的全连接神经网络,假设它只有一层,输出层,输出层只有一个神经元。首先将s0s\_0s0​分别和h1,h2,h3h\_1,h\_2,h\_3h1​,h2​,h3​进行拼接,得到3个输入向量,然后分别经过全连接神经网络,得到3个标量的logits值,再对这3个logits值应用softmax,就得到了α1,α2,α3\\alpha\_1,\\alpha\_2,\\alpha\_3α1​,α2​,α3​的具体注意力权重值了。 Decoder第二个时间步的注意力向量为att1att\_1att1​,它也需要计算当前时间步对h1,h2,h3h\_1,h\_2,h\_3h1​,h2​,h3​的注意力权重值α1,α2,α3\\alpha\_1,\\alpha\_2,\\alpha\_3α1​,α2​,α3​。做法和上一个时间步一样,将s1s\_1s1​分别和h1,h2,h3h\_1,h\_2,h\_3h1​,h2​,h3​进行拼接,得到3个输入向量,然后这3个输入向量分别经过全连接神经网络,得到3个logits值,再对这3个logits值应用softmax,就得到第二个时间步对h1,h2,h3h\_1,h\_2,h\_3h1​,h2​,h3​的注意力权重:α1,α2,α3\\alpha\_1,\\alpha\_2,\\alpha\_3α1​,α2​,α3​了。 ![1340.png](http://rethink.fun/imgs/1340.png) 带注意机制的Decoder部分,每个时间步不光有隐状态输入,还有注意力向量和上一步输出token的embedding进行拼接的向量作为输入。 ### 13.7.3 总结 上边我们以RNN进行讲解,实际上可以替换为LSTM,GRU。另外在计算注意力时,我们讲的是最简单的情况,只有一个输出层,实际大部分情况下我们会添加一层隐藏层。上边我们是以翻译为例子,注意力机制也可以应用到其他序列到序列的模型里。 * * * 恭喜你,你已经掌握了RNN所有的原理知识! 扫码请作者喝一杯咖啡来分享你的喜悦吧! ![zsm](http://rethink.fun/imgs/zsm.png) results matching "" =================== No results matching "" ====================== --- # 模型定义 · GitBook [模型定义](https://www.rethink.fun/) ================================= 14.3 模型定义 --------- 这一节我们来具体定义翻译模型的架构。具体代码在[translator.py](https://github.com/RethinkFun/DeepLearning/blob/master/chapter14/translator.py) 里。 网络结构示意图如下: ![1342.png](http://rethink.fun/imgs/1342.png) 编码器阶段我们用3层的双向LSTM来提取英文句子的信息。我们之前讲过,因为解码器负责生成中文翻译,在生成第一个中文token时,还不知道后边的token,不能用双向的LSTM。因此这里采用了一个单向的3层LSTM网络。 我们首先分析Encoder的第一层: 1. 第一层每个时间步都会输入一个英文Token的Embedding,输入维度为Embedding\_dim。 2. 第一层多个时间步之间都会传递正向的隐状态,细胞状态,维度为Hidden\_dim。同样会传递反向的隐状态,细胞状态,维度也为Hidden\_dim。 3. 第一层每个时间步会将正向和反向的隐状态进行拼接,作为这个时间步的双向合并隐状态输出给第二层,作为第二层每个时间步的输入,维度为2×Hidden\_dim。 4. 第一层的细胞状态仅在第一层多个时间步内传递,保存长期记忆,不会传递到第二层。 接着我们分析Encoder的第二层: 1. 第二层每个时间步,都会接受第一层每个时间步的双向拼接隐状态作为输入,维度为2×Hidden\_dim。 2. 第二层多个时间步之间都会传递正向的隐状态,细胞状态,维度为Hidden\_dim。同样会传递反向的隐状态,细胞状态,维度也为Hidden\_dim。 3. 第二层每个时间步的双向拼接隐状态作为第三层每个时间步的输入,维度为2×Hidden\_dim。 接着我们分析Encoder的第三层: 1. 第三层每个时间步,都会接受第二层每个时间步的双向拼接隐状态作为输入,维度为2×Hidden\_dim。 2. 第二层多个时间步之间都会传递正向的隐状态,细胞状态,维度为Hidden\_dim。同样会传递反向的隐状态,细胞状态,维度也为Hidden\_dim。 3. 第三层每个时间步的双向拼接隐状态作为Encoder对每个英文token的信息提取输出,后边用来计算注意力向量。 然后我们分析Decoder解码器: Decoder的第一层: 1. 第一层每个时间步都会输入一个中文Token的Embedding和当前时间步对应的注意力向量的拼接向量。这个中文Token为Decoder上一时间步生成的中文Token。对于第一个时间步是`` Token。 2. 第一层多个时间步之间都会传递隐状态和细胞状态。 3. 解码器第一个时间步的隐状态和细胞状态来自编码器的最后一个时间步的隐状态和细胞状态。因为编码器的隐状态和细胞状态是双向拼接的,维度为2×Hidden\_dim。而解码器的隐状态和细胞状态是单向的,维度都为Hidden\_dim。所以中间有一个线性层来改变维度。 Decoder第二层: 1. Decoder第二层的第一个时间步,从Encoder第二层的双向拼接隐状态和细胞状态经过线性变化后的输入作为Decoder第二层的输入隐状态和细胞状态的输入。 2. Decoder第二层每个时间步都接收Decoder第一层每个时间步的隐状态,维度为Hidden\_dim作为输入。 3. Decoder第二层每个时间步都输出自己的隐状态作为第三层的输入。 Decoder第三层和Decoder第二层类似,输出自己每个时间步的隐状态,后边再加一个分类头用来预测每个时间步应该输出的中文token。 ### 14.3.1 Encoder class Encoder(nn.Module): def __init__(self, vocab_size, emb_dim, hid_dim, n_layers=3): super().__init__() # 默认3个循环层 self.n_layers=n_layers # 定义Embedding,可以将BPE分词输出的token id,转化为emd_dim的embedding向量。不参与运算,它的embedding不需要学习。 self.embedding = nn.Embedding(vocab_size, emb_dim, padding_idx=PAD_ID) # 定义双向LSTM模型 self.bi_lstm = nn.LSTM(emb_dim, hid_dim, n_layers, bidirectional=True) # 定义线性层列表来降低维度。因为每个Encoder是双向的,隐状态和细胞状态为hid_dim*2,Decoder是单向的,隐状态和细胞状态维度为hid_dim。 self.fc_hidden = nn.ModuleList([nn.Linear(hid_dim * 2, hid_dim) for _ in range(n_layers)]) self.fc_cell = nn.ModuleList([nn.Linear(hid_dim * 2, hid_dim) for _ in range(n_layers)]) def forward(self, src, src_len): embedded = self.embedding(src) #将一个 padded sequence(已经填充到统一长度的 batch 序列) 转换为一个特殊的 PackedSequence 对象 #这个对象在传入 RNN 时能跳过 padding 部分的计算。 packed = nn.utils.rnn.pack_padded_sequence(embedded, src_len, enforce_sorted=False) #outputs,形状为 (seq_len, batch_size, hid_dim*2),表示每个时间步、最后一层LSTM的双向隐状态拼接。 #(hidden, cell) ,形状都为(num_layers * 2, batch_size, hid_dim)表示每一层、每个方向在最后一个时间步的隐状态或细胞状态。 outputs, (hidden, cell) = self.bi_lstm(packed) #将 PackedSequence 类型的输出还原成带 padding 的标准 Tensor,方便后续处理。 outputs, _ = nn.utils.rnn.pad_packed_sequence(outputs) # [src_len, batch, hid_dim*2] # 重塑隐藏状态和细胞状态: [n_layers * 2, batch, hid_dim] -> [n_layers, 2, batch, hid_dim] hidden = hidden.view(self.n_layers, 2, -1, hidden.size(2)) cell = cell.view(self.n_layers, 2, -1, cell.size(2)) # 为每一层处理前向和后向状态 final_hidden = [] final_cell = [] for layer in range(self.n_layers): # 对每一层将正向和反向的隐状态,细胞状态合并,通过一个线性层将维度从hid_dim*2降低为hid_dim维度。 h_cat = torch.cat((hidden[layer][-2], hidden[layer][-1]), dim=1) c_cat = torch.cat((cell[layer][-2], cell[layer][-1]), dim=1) h_layer = torch.tanh(self.fc_hidden[layer](h_cat)).unsqueeze(0) c_layer = torch.tanh(self.fc_cell[layer](c_cat)).unsqueeze(0) final_hidden.append(h_layer) final_cell.append(c_layer) # 调整好维度为hid_dim的隐状态和细胞状态,可以传递给Decoder。 hidden_concat = torch.cat(final_hidden, dim=0) cell_concat = torch.cat(final_cell, dim=0) return outputs, hidden_concat, cell_concat Encoder 设计如下: 1. 定义Embedding模块。BPE分词后会给每个token分配一个ID。Embedding模块给每个ID分配一个可学习的Embedding向量,在训练时可更新。 2. 定义一个3层的双向LSTM。它的隐状态和细胞状态因为是双向的,所以维度都为hid\_dim×2。 3. 双向LSTM输出有三个变量:outputs,形状为 (seq\_len, batch\_size, hid\_dim×2),表示每个时间步、最后一层LSTM的双向隐状态拼接。(hidden, cell) ,形状都为(num\_layers × 2, batch\_size, hid\_dim)表示每一层、每个方向在最后一个时间步的隐状态或细胞状态。 4. 进行英译中时,因为英文句子是完整输入的,Encoder可以看到完整的英文输入,所以可以定义为双向的,但是Decoder输出中文翻译时,只能单向逐个生成,所以是单向的。因此Decoder的隐状态和细胞状态维度为hid\_dim。 5. Encoder需要将自己最后一个时间步的隐状态和细胞状态传递给Decoder的第一个时间步,但是它们的隐状态和细胞状态向量的维度不同,所以,每个循环层都需要一个线性层来进行转化。每个线性层的输入维度为hid\_dim×2,输出维度为hid\_dim。 上边的代码进行了良好的注释,你可以逐行阅读和调试。 ### 14.3.2 Attention class Attention(nn.Module): def __init__(self, hid_dim): super().__init__() #第一层输入维度为Encoder的输出隐状态(因为是双向的,所以维度为hid_dim*2,)和Decoder的输入隐状态(单向,维度为hid_dim—)的拼接。 self.attn = nn.Linear(hid_dim * 2 + hid_dim, hid_dim) #输出一个代表注意力的logit值。 self.v = nn.Linear(hid_dim, 1, bias=False) def forward(self, hidden, encoder_outputs, mask): # 调整Decoder当前时间步输入隐状态的维度: [1, batch, hid_dim] -> [batch, 1, hid_dim] hidden = hidden.permute(1, 0, 2) # 调整encoder各个时间步输出隐状态的维度: [src_len, batch, hid_dim*2] -> [batch, src_len, hid_dim*2] encoder_outputs = encoder_outputs.permute(1, 0, 2) src_len = encoder_outputs.shape[1] # 中文当前一个token需要和英文所有token计算注意力。所以需要把中文token的状态复制多份,以便进行统一拼接。 # 因为Decoder只有当前时间步输入的隐状态,复制到和Encoder输出隐状态同样的src_len。 hidden = hidden.repeat(1, src_len, 1) # [batch, src_len, hid_dim] # 拼接Decoder当前输入的隐状态和Encoder在各个时间步输出的隐状态,然后经过一个线性层,tanh激活。 energy = torch.tanh(self.attn(torch.cat((hidden, encoder_outputs), dim=2))) # [batch, src_len, hid_dim] # 输出Decoder当前中文token与所有英文token的注意力值。 attention = self.v(energy).squeeze(2) # [batch, src_len] # mask标志哪些位置为,对于填充的位置,注意力值为一个大的负值。这样经过softmax就为0。 attention = attention.masked_fill(mask == 0, -1e10) # 利用softmax将注意力的值归一化。让生成当前中文token对输入的英文各个token的注意力之和为1。 return torch.softmax(attention, dim=1) # [batch, src_len] Attention类的实现通过一个两层的神经网络来计算Decoder生成每个中文token对输入英文的每个token的注意力的值。并且这个中文token对输入所有英文token的注意力的值加和为1。因为输入时,我们在有的序列后边增加了`` token。``token被分配的注意力永远为0。 计算Attention的两层神经网络会分别计算当前Decoder要生成的中文token对Encoder每个英文token的注意力。这个网络的输入就为Encoder每个token的输出隐状态(因为是双向的,所以维度为hid\_dim×2,)和Decoder的输入隐状态(单向,维度为hid\_dim)的拼接。输出为一个代表注意力的值。 上边的代码进行了很好的注释,你可以逐行理解并调试。 ### 14.3.3 Decoder class Decoder(nn.Module): def __init__(self, output_dim, emb_dim, hid_dim, attention, n_layers=3): super().__init__() self.output_dim = output_dim self.attention = attention self.n_layers = n_layers self.embedding = nn.Embedding(output_dim, emb_dim, padding_idx=PAD_ID) # 单向LSTM,输入维度为注意力加权后的,Encoder输出隐状态维度(hid_dim*2)加上输入token的embedding的维度emb_dim。 self.rnn = nn.LSTM(hid_dim * 2 + emb_dim, hid_dim,num_layers=n_layers) # 定义最终分类头,输入为3倍的hid_dim,输出为字典大小。 # 为什么输入是3倍的hid_dim呢? # 因为预测中文token时,输入不光是Decoder的最后一层的隐状态,还拼接了注意力向量。 # 这样对这个时间步生成翻译的中文token会有帮助。而注意力向量来自双向的Encoder,所以要额外加上一个2倍的hid_dim. self.fc_out = nn.Linear(hid_dim*3, output_dim) def forward(self, input_token, hidden, cell, encoder_outputs, mask): # input_token: [batch] # 输入单个字符,增加一个维度 input_token = input_token.unsqueeze(0) # [1, batch] # 获取token的embedding embedded = self.embedding(input_token) # [1, batch, emb_dim] # 获取当前步的输入隐状态 last_hidden = hidden[-1].unsqueeze(0) # 当前步对所有encoder输出的注意力 a = self.attention(last_hidden, encoder_outputs, mask) # [batch, src_len] a = a.unsqueeze(1) # [batch, 1, src_len] encoder_outputs = encoder_outputs.permute(1, 0, 2) # [batch, src_len, enc_hid_dim*2] # 用矩阵乘法获取注意力上下文向量 weighted = torch.bmm(a, encoder_outputs) # [batch, 1, enc_hid_dim*2] weighted = weighted.permute(1, 0, 2) # [1, batch, enc_hid_dim*2] # 拼接输入token编码向量和注意力上下文向量 lstm_input = torch.cat((embedded, weighted), dim=2) # [1, batch, emb_dim + enc_hid_dim*2] # 一次只执行lstm的一个时间步。 output, (hidden, cell) = self.rnn(lstm_input, (hidden, cell)) # output: [1, batch, hid_dim] # 移除第0维(第一个维度) output = output.squeeze(0) # [batch, hid_dim] weighted = weighted.squeeze(0) # 拼接输出和注意力上下文向量,进入分类头,计算分类logtis prediction = self.fc_out(torch.cat((output, weighted), dim=1)) # [batch, output_dim] return prediction, hidden, cell, a.squeeze(1) # attention weights for visualization Decoder的设计如下: 1.定义了中文token的embedding。 2. 单向3层的LSTM作为Decoder,输入为注意力上下文向量拼接当前时刻输入token的embedding。 3. 一个线性层作为分类头,输入为每个时间步的隐状态,输出为字典维度的向量,经过softmax后为字典每个token作为当前中文token输入的概率。 4. Decoder的调用是逐步调用的,每次调用都输入前一个时刻的隐状态,细胞状态。以及注意力上下文向量和当前token的embedding的拼接向量。生成输出隐状态,细胞状态。然后把输出隐状态和注意力上下文向量拼接上,一起作为最后分类头的输入。分类得到下一个token。 ### 14.3.4 Seq2Seq class Seq2Seq(nn.Module): def __init__(self, encoder, decoder, device): super().__init__() self.encoder = encoder self.decoder = decoder self.device = device def forward(self, src, src_len, trg): batch_size = trg.shape[1] max_len = trg.shape[0] vocab_size = self.decoder.output_dim outputs = torch.zeros(max_len, batch_size, vocab_size).to(self.device) # 调用encoder encoder_outputs, hidden, cell = self.encoder(src, src_len) input_token = trg[0] mask = (src != PAD_ID).permute(1, 0) # 逐步调用Decoder for t in range(1, max_len): output, hidden, cell, _ = self.decoder(input_token, hidden, cell, encoder_outputs, mask) outputs[t] = output input_token = trg[t] return outputs Seq2Seq将Encoder和Decoder组装在一起,输入为英文token id序列,一次性调用encoder,输出output、hidden、cell向量,然后逐步调用Decoder。需要注意的是每一步我们都是以Label数据token作为输入,而不是以Decoder上一步生成的token作为输入。 ### 14.3.5 train方法 def train(model, iterator, optimizer, criterion): model.train() epoch_loss = 0 step_loss = 0 # 用于累计每个step的loss step_count = 0 # 当前step计数器 for i, (src, trg, src_len, _) in enumerate(iterator): src, trg = src.to(model.device), trg.to(model.device) optimizer.zero_grad() output = model(src, src_len, trg) output_dim = output.shape[-1] output = output[1:].view(-1, output_dim) trg = trg[1:].view(-1) loss = criterion(output, trg) loss.backward() optimizer.step() # 更新损失统计 step_loss += loss.item() epoch_loss += loss.item() step_count += 1 # 每100个step打印一次 if (i + 1) % 100 == 0: avg_step_loss = step_loss / step_count print(f'Step [{i + 1}/{len(iterator)}] | Loss: {avg_step_loss:.4f}') step_loss = 0 # 重置step损失 step_count = 0 # 重置step计数器 return epoch_loss / len(iterator) # 返回整个epoch的平均loss 上边代码是对Seq2Seq模型进行训练的方法,相信你应该可以看懂,我们不做解释。 ### 14.3.6 main方法 if __name__ == '__main__': device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') dataset = TranslationDataset('data/en2cn/train_en.txt', 'data/en2cn/train_zh.txt', tokenize_en, tokenize_cn) loader = DataLoader(dataset, batch_size=32, shuffle=True, collate_fn=TranslationDataset.collate_fn) INPUT_DIM = sp_en.get_piece_size() OUTPUT_DIM = sp_cn.get_piece_size() ENC_EMB_DIM = 512 DEC_EMB_DIM = 512 HID_DIM = 512 attention = Attention(HID_DIM).to(device) encoder = Encoder(INPUT_DIM, ENC_EMB_DIM, HID_DIM).to(device) decoder = Decoder(OUTPUT_DIM, DEC_EMB_DIM, HID_DIM, attention).to(device) model = Seq2Seq(encoder, decoder, device).to(device) model.load_state_dict(torch.load('seq2seq_bpe_attention.pt', map_location=device)) optimizer = optim.Adam(model.parameters()) criterion = nn.CrossEntropyLoss(ignore_index=PAD_ID) N_EPOCHS = 1 for epoch in range(N_EPOCHS): loss = train(model, loader, optimizer, criterion) print(f'Epoch {epoch + 1}/{N_EPOCHS} | Loss: {loss:.4f}') torch.save(model.state_dict(), 'seq2seq_bpe_attention.pt') 上边代码是训练模型的入口,创建了模型,定义了优化器和loss函数,指定迭代次数,每个迭代保存一个模型。这里为了实验,可以设置为1个迭代。我在3090,24GB显存的机器上,训练一个迭代大概需要24小时。你根据你的情况可以调整batch\_size和N\_EPOCHS的设置。 results matching "" =================== No results matching "" ====================== --- # Llama1 · GitBook [Llama1](https://www.rethink.fun/) =================================== 17.1 Llama1 ----------- Llama 系列模型由 Meta 研发,在DeepSeek出现之前,是开源社区中最受关注的大语言模型之一。许多开发者在此基础上进行模型微调或构建智能应用。 Llama1 的论文题为 _Open and Efficient Foundation Language Models_(中文:《开放高效的基础语言模型》),强调了两个核心理念:**开放性(Open)**与**高效性(Efficient)**。 ### 开放(Open) Llama1 完全使用公开获取的数据进行训练,不涉及 Meta 内部或客户的私有数据。其性能已达到 GPT-3 水平,并已向研究社区开源,但目前仅限非商业用途。 ### 高效(Efficient) 传统模型扩展策略往往以训练计算成本为衡量标准。例如,小模型(如 10B 参数)在训练初期损失下降较快,但后期趋于饱和;而大模型(如 50B 参数)在后期表现更优。如图所示(图 2-27),设定相同性能目标时,大模型的单位训练成本反而更低。 ![1701.png](http://rethink.fun/imgs/1701.png) Meta 提出了新的视角:**应更关注推理阶段的计算成本**,因为训练只需一次,而推理会被频繁调用。因此,可以在训练阶段为小模型投入更多资源,从而实现推理时的高效性。 此外,Meta 的实验表明,增加训练数据规模比扩大模型参数更能提升性能。例如,尽管原有经验建议 10B 参数模型使用 2000 亿 token,Meta 实验发现,即便 7B 模型使用 1 万亿 token,性能依然在持续提升。这一发现也成为 Llama 设计哲学的核心之一。 * * * ### 17.1.1 训练细节 Llama1 的训练数据全部来源于公开渠道。其主要数据集是 2017~2020 年间的 CommonCrawl,经过去重和清洗,去除了所有非英文文本。 为了保证数据质量,Meta 使用一个线性分类器进行文本筛选:以维基百科引用的网页作为正样本,其他网页为负样本,从而筛选出更高质量文本。 训练时,书籍和维基百科数据各训练两个 epoch,其余数据仅训练一次。总训练数据达 **1.4 万亿 token**。 训练使用 **2048 块 80GB 显存的 A100 GPU**,上下文长度设为 **2048 token**,整个过程持续 **21 天**。 ### 17.1.2 模型结构设计 Llama 使用与 GPT 系列类似的 **Transformer 解码器架构**,并在其基础上做出若干优化,以提升性能与稳定性: 1. **前置归一化(Pre-Norm)**:将归一化步骤放在每个子层输入位置,提高训练稳定性。 2. **RMSNorm 替代 LayerNorm**:减少归一化的计算开销。 3. **使用 SiLU 激活函数**:提升模型表达能力。 4. **采用旋转位置编码(RoPE)**:更好处理相对位置信息。 #### RMSNorm 简介 RMSNorm 是对传统 LayerNorm 的一种优化,其关键在于去除归一化中的减均值步骤及偏置项,从而降低计算复杂度。 * **LayerNorm** 的公式如下: LayerNorm\=x−E\[x\]Var\[x\]+ϵ⋅γ+β \\text{LayerNorm} = \\frac{x - \\mathbb{E}\[x\]}{\\sqrt{\\text{Var}\[x\] + \\epsilon}} \\cdot \\gamma + \\beta LayerNorm\=Var\[x\]+ϵ​x−E\[x\]​⋅γ+β * **RMSNorm** 的公式如下: RMSNorm\=xMean(x2)+ϵ⋅γ \\text{RMSNorm} = \\frac{x}{\\sqrt{\\text{Mean}(x^2) + \\epsilon}} \\cdot \\gamma RMSNorm\=Mean(x2)+ϵ​x​⋅γ 这种归一化方式减少了冗余操作,提高了训练效率。 #### SiLU 激活函数 SiLU(Sigmoid Linear Unit),也称 Swish,定义如下: SiLU(x)\=x⋅sigmoid(x)\=x1+e−x \\text{SiLU}(x) = x \\cdot \\text{sigmoid}(x) = \\frac{x}{1 + e^{-x}} SiLU(x)\=x⋅sigmoid(x)\=1+e−xx​ ![1702.png](http://rethink.fun/imgs/1702.png) 如上图所示,SiLU 函数在 0 附近较为平滑,远离 0 时则近似于 ReLU。虽然计算上比 ReLU 稍贵(因需计算 sigmoid),但实验显示它有助于提升模型精度,因此被 Llama1 所采用。 总结来看,Llama1 结合了开放的数据源、高效的训练策略与结构优化,不仅在性能上可与 GPT-3 相抗衡,还为开源社区与研究开发提供了可靠、高质量的大模型基础。 results matching "" =================== No results matching "" ====================== --- # 注意力机制 · GitBook [注意力机制](https://www.rethink.fun/) ================================== 15.2 注意力机制 ---------- Transformer的论文题目就是《Attention is all you need》,由此可见注意力机制在Transformer里的重要性。 ### 15.2.1 一个比喻 假如你去自助餐厅,拿起一个盘子,准备拿取第一轮的美食。这其中有3个因素,决定了你最终盘子里装了什么。 ![1514.png](http://rethink.fun/imgs/1514.png) **个人喜好** 你是喜欢吃甜食,素食,肉食等等,这是个人喜好,每个人是不一样的。我们是带着个人喜好去寻找食物的,这个我们可以称为**“Query”**因素。也就是你个人的查找条件。 **食物表现** 每个食物都有自身的属性,甜食,素食,肉食,色泽,气味等,这些属性用来匹配你的“查找条件”,我们叫做**“Key”**因素,表示每个食物表现出来的特性。 **食物本身** 每个食物本身,我们称之为**“Value”**。 那么人的注意力机制是如何利用Q(Query)、K(Key)、V(Value)来拿取一盘食物呢?你用你的喜好Q,去匹配食物的属性K,计算一个注意力匹配值。根据这个匹配值的大小决定你拿取食物的多少。比如你想吃肉食(Q),这里刚好有个烤羊腿(K),Q和K匹配率很高,也就是说最能吸引你的注意力,那你就会多拿一些羊腿。如果这里有香肠,它虽然也算肉食,但是匹配度不高,吸引你注意力低一些,你就少拿一些。旁边还有一些蔬菜,它和你想吃肉食的Q没有一点匹配,完全吸引不了你的注意力,你就一点蔬菜也不会拿。 这就是注意力机制的核心,它有3个重要的值决定:一个是Q,代表查询变量。一个是K,代表应答变量。一个是V,代表值。Q和K之间计算注意力系数,决定最终取用值的多少。 ### 15.2.2 注意力的计算 我们之前在RNN里讲过注意力机制,那里的注意力机制中的Q就是Decoder当前时间步输入的隐状态。K就是Encoder每个时间步输出的隐状态。V和K一样,都是Encoder每个时间步输出的隐状态。最终计算就是Q和K计算出注意力权值,经过softmax归一化后,按注意力权值对V进行加权求和,就得到了注意力上下文向量了。因为Decoder每个时间步的输入隐状态Q不同,所以最终的注意力上下文也不同。 在之前讲的RNN里计算Q和K的注意力值,是通过将Q向量和K向量进行拼接,然后经过一个神经网络,输出一个值。一个Q和一组K分别计算,得到一组logits值,这一组logits值再经过softmax,就得到这一个Q对这一组K的注意力权重。 在Transformer里,计算Q和K的注意力值用的是一个更简单的办法,就是直接计算Q和K的点乘。当然,计算点乘的前提是Q和K的维度是一致的。一个Q和一组K分别计算点乘,得到一组logits值,再通过softmax,就到的注意力权重。 ### 15.2.3 Transformer里的注意力机制 Transformer原作者是用来解决序列到序列问题的,具体来说,就是解决翻译问题的。 我们之前介绍过对token进行Embedding得到的向量能更好的表示一个token的语义信息,但是同一个token,虽然初始化后的Embedding是唯一的,但是在不同的上下文语境中它的意思是不同的。 比如下边两句话: “我爱吃苹果” “我喜欢苹果手机” ![1501.png](http://rethink.fun/imgs/1501.png) 比如上图中,对于“苹果”这个token,我们希望在不同的上下文里,它的Embedding向量值能有所不同,表达出各自不同的含义。比如对于第一句话,我们希望“苹果”这个token,能注意到“吃”这个token,从而改变自己的Embedding,让含义是水果苹果。而第二句话中的“苹果”这个token,能注意到“手机”这个token,从而改变自己的Embedding,让含义是苹果公司。 #### 15.2.3.1 自注意力机制 ![1502.png](http://rethink.fun/imgs/1502.png) 如上图所示,首先定义3个全连接层,利用这3个全连接层为每个token生成它们各自的q、k、v向量。全连接层的输入都是当前他们的Embedding。有了q、k、v向量,就可以按照我们上边讲过的方式来进行注意力计算。 苹果这个token,用自己的q向量分别和所有的token(包括自身)的k向量进行点积计算,得到一组值A。 A\=\[q4⋅k1,q4⋅k2,q4⋅k3,q4⋅k4\] A = \[q\_4\\cdot k\_1, \\quad q\_4\\cdot k\_2, \\quad q\_4\\cdot k\_3, \\quad q\_4\\cdot k\_4\] A\=\[q4​⋅k1​,q4​⋅k2​,q4​⋅k3​,q4​⋅k4​\] 然后应用softamx,转化为注意力权重。 A\=softmax(A)\=\[0.1,0.1,0.3,0.5\] A=softmax(A)=\[0.1,0.1,0.3,0.5\] A\=softmax(A)\=\[0.1,0.1,0.3,0.5\] 利用注意力权重,对所有的token的V向量进行加权求和,就得到了苹果这个token根据上下文进行注意力更新后的新的Embedding表示。 v4\=0.1v1+0.1v2+0.3v3+0.5v4 v\_4 = 0.1v\_1+0.1v\_2+0.3v\_3+0.5v\_4 v4​\=0.1v1​+0.1v2​+0.3v3​+0.5v4​ 因为这里的注意力计算是序列本身内部的token进行的,所以叫做Self-Attention,自注意力。像我们之前讲的RNN里的注意力,是Decoder序列里的Token对Encoder里的Token计算注意力的,是不同序列token之间计算的注意力,那个叫做Cross-Attenion,交叉注意力。 #### 15.2.3.2 多头自注意力 ![image.png](http://rethink.fun/imgs/1503.png) 在卷积神经网络里,某一层可以定义多个卷积核,每个卷积核来发现不同的特征。注意力机制也可以定义多个,我们把一个注意力计算叫做一个“头”。其中一个“头”负责这个token里的某种特性,比如“单复数”、“性别”、“材质”、“重量”等等。每个头中的q负责查询这个特性,k负责应答这个特性,v提供这个特性的表示值。每个头分别计算自己的注意力权重,并对这个头内部的v加权相加,得到这个头的输出。然后将各个头的输出拼接,就构成了一个token经过多头自注意更新后的Embedding向量了。 为了保证更新前后每个token的Embedding维度不变,假如注意力有N个头,那么每个头的q、k、v的维度就为原始token Embedding维度的1/N。最终N个头进行注意力计算后的输出进行拼接,Embedding的维度就可以保持不变了。 假设“苹果”这个token的Embedding维度为512,8个头,则每个头的q、k、v向量的维度就都为64。每个token的第一个头和序列里其他token的第一个头进行注意力计算,得到一个64维的输出。最终8个头,每个64维的输出,进行拼接,就得到了更新后的512维的Embedding。 #### 15.2.3.3 矩阵运算加速 Transformer的一大优势,就是可以同时更新所有的token的embedding,不用像RNN那样计算每个token的隐状态需要等待序列前边token依次计算出各自的隐状态。我们发现上边讲的注意力机制更新每个token的embedding,所有token是可以同步进行的,没有次序上的依赖关系。 ![1504.png](http://rethink.fun/imgs/1504.png) 更进一步,GPU擅长进行矩阵运算。以一个头的注意力机制为例,也就是q、k、v向量的维度和embedding向量的维度都一样,为hidden\_size。所以我们将序列内所有token产生的q向量合并到一起,形成一个Q矩阵,形状为\[seq\_len,hidden\_size\]。将所有的k向量合并到一起,形成一个K矩阵,形状为\[seq\_len,hidden\_size\]。同理生成V矩阵,形状也为\[seq\_len,hidden\_size\]。此时,将Q乘以K的转置相乘:QKTQK^TQKT,得到结果形状为\[seq\_len,seq\_len\]。然后按行应用softmax就得到了注意力权重矩阵。 ![1515.png](http://rethink.fun/imgs/1515.png) 其中结果的第一行表示第一个token和序列里每个token注意力权重的值。第n行的每个值表示序列里第n个token和序列所有token计算的注意力权重值。 比如上图中就是对于token长度为4的序列计算softmax(QKT)softmax(QK^T)softmax(QKT)后得到的注意力权重矩阵,其中第一行,就表示“我”这个token,分别对“我”、“爱”、“吃”、“苹果”这4个token的注意力权重值。 最后,注意力权重矩阵再与V相乘就得到这个注意力层的输出,也就是对序列所有token根据上下文,应用注意力机制更新后的embedding。 Attention(Q,K,V)\=softmax(QKT)V Attention(Q,K,V)=softmax(QK^T)V Attention(Q,K,V)\=softmax(QKT)V 接着我们还要在上边的公式上做一些小的改动,让它更稳定。那就是: Attention(Q,K,V)\=softmax(QKTdk)V Attention(Q,K,V)=softmax(\\frac{QK^T}{\\sqrt{d\_k}})V Attention(Q,K,V)\=softmax(dk​​QKT​)V 可以看到,改动是给Q乘以K的转置后的结果先除以dk\\sqrt{d\_k}dk​​,然后再应用softmax。其中dkd\_kdk​就是特征维度hidden\_size。为什么这里要除以dk\\sqrt{d\_k}dk​​呢?因为特征维度是一个超参数,可以调节,如果特征值分布一致,特征维度越大,那么两个特征向量点乘的值越大,这造成了经过softmax之后,分布越尖锐,注意力大部分都集中在几个token上。其他token的信息就利用不上。假设q、k两个向量维度都为dkd\_kdk​,向量内的特征值都是均值为0,方差为1,那么它们两个向量的点积结果,均值为0,方差为dkd\_kdk​。所以这里除以标准差dk\\sqrt{d\_k}dk​​,让注意力logits的这一组值也符合均值为0,方差为1的分布。 矩阵运算同样也可以支持多头注意力,我们之前说过为了保证Embedding向量更新前后维度不变,所以对于N个头的多头注意力机制,每个头的q,k,v向量的维度为hidden\_size/N。 Transformer在为每个token生成q,k,v向量时,生成的维度和embedding的维度是一致的,然后再按头的个数划分为多个q,k,v,代表每个头的q,k,v。 ![1505.png](http://rethink.fun/imgs/1505.png) 比如上边就是4个token的Q,K,V矩阵,token的hidden\_size为6,分了3个头,每个头的q,k,v向量维度为2。整个序列的4个token进行多头注意计算时就是Head1、Head2、Head3的Q,K,V矩阵分别进行计算,分别得到结果矩阵,再将结果矩阵进行拼接,就得到4个token更新后的Embedding。 Attention(Q,K,V)\=concat(Attention(Q,K,V)=concat(Attention(Q,K,V)\=concat( Attention(Qh1,Kh1,Vh1),Attention(Q\_{h1},K\_{h1},V\_{h1}),Attention(Qh1​,Kh1​,Vh1​), Attention(Qh2,Kh2,Vh2),Attention(Q\_{h2},K\_{h2},V\_{h2}),Attention(Qh2​,Kh2​,Vh2​), Attention(Qh3,Kh3,Vh3)Attention(Q\_{h3},K\_{h3},V\_{h3})Attention(Qh3​,Kh3​,Vh3​) ))) 一个完整的注意力机制计算后还会通过一个全连接层来整理token的embedding。这个全连接层不会改变token embedding的维度。 #### 15.2.3.4 pytorch实现 class MultiHeadAttentionBlock(nn.Module): def __init__(self, d_model: int, h: int, dropout: float) -> None: super().__init__() self.d_model = d_model # embedding特征大小 self.h = h # 头的个数 # 确保d_model可以被h整除 assert d_model % h == 0, "d_model 不能被 h整除" self.d_k = d_model // h # 每个头特征大小 self.w_q = nn.Linear(d_model, d_model, bias=False) # Wq self.w_k = nn.Linear(d_model, d_model, bias=False) # Wk self.w_v = nn.Linear(d_model, d_model, bias=False) # Wv self.w_o = nn.Linear(d_model, d_model, bias=False) # Wo self.dropout = nn.Dropout(dropout) @staticmethod def attention(query, key, value, mask, dropout: nn.Dropout): # 获取d_k的值。 d_k = query.shape[-1] # Q乘以K的转置,除以根号下d_k。 # (batch, h, seq_len, d_k) --> (batch, h, seq_len, seq_len) attention_scores = (query @ key.transpose(-2, -1)) / math.sqrt(d_k) if mask is not None: # 给mask为0的位置填入一个很大的负值,这样在进行softmax,注意力就为0。 attention_scores.masked_fill_(mask == 0, -1e9) # 进行softmax,归一化。得到注意力权重 # (batch, h, seq_len, seq_len) attention_scores = attention_scores.softmax(dim=-1) if dropout is not None: attention_scores = dropout(attention_scores) # 注意力权重乘以V,得到更新后的embedding。 # (batch, h, seq_len, seq_len) --> (batch, h, seq_len, d_k) return (attention_scores @ value), attention_scores def forward(self, q, k, v, mask): # 通过3个全连接层,获取Q、K、V矩阵 query = self.w_q(q) # (batch, seq_len, d_model) --> (batch, seq_len, d_model) key = self.w_k(k) # (batch, seq_len, d_model) --> (batch, seq_len, d_model) value = self.w_v(v) # (batch, seq_len, d_model) --> (batch, seq_len, d_model) # 对多头进行拆分 # (batch, seq_len, d_model) --> (batch, seq_len, h, d_k) --> (batch, h, seq_len, d_k) query = query.view(query.shape[0], query.shape[1], self.h, self.d_k).transpose(1, 2) key = key.view(key.shape[0], key.shape[1], self.h, self.d_k).transpose(1, 2) value = value.view(value.shape[0], value.shape[1], self.h, self.d_k).transpose(1, 2) # 计算注意力 x, self.attention_scores = MultiHeadAttentionBlock.attention(query, key, value, mask, self.dropout) # 多个头合并 # (batch, h, seq_len, d_k) --> (batch, seq_len, h, d_k) --> (batch, seq_len, d_model) x = x.transpose(1, 2).contiguous().view(x.shape[0], -1, self.h * self.d_k) # 乘以输出层 return self.w_o(x) 上边的代码进行了很好的注释,你可以逐行阅读。有几点需要注意: forward函数传入的q,k,v是尚未经过全连接层的原始向量,在self-attention里,这里的q,k,v都是token的embedding。并且这里的embedding的形状为(batch,seq\_len,d\_model)。可见Transformer里的tensor将batch size放在第一个维度,因为Transformer里可以同时对所有token进行处理,并不需要按照序列顺序依次处理。而在RNN里将seq\_len放在第一个维度,是因为RNN里是按照序列顺序处理数据,seq\_len放在第一个维度会方便一些。 Attention计算时,可以传入一个mask矩阵,mask矩阵用0标记了哪些位置不参与注意力计算。比如对于`` token就不必参与注意力计算。对于mask标记了0的位置,在注意力logits值计算完成后,给赋值一个很大的负值,这样在进行softmax后,对于这个位置的注意力就为0。相当于不参加注意力计算。 results matching "" =================== No results matching "" ====================== --- # 模型评估 · GitBook [模型评估](https://www.rethink.fun/) ================================= 14.4 模型评估 --------- 经过漫长的等待,终于得到我们期望的模型了,每次看着训练过程中loss的变化,也是我的一大乐趣,希望你也能享受这个过程。下边我们就来对训练出来的模型进行评估。 ### 14.4.1 人工测评 import torch import sentencepiece as spm from translator import Seq2Seq, Encoder, Decoder, Attention # ---------------------# # 1. Load Tokenizers # ---------------------# sp_en = spm.SentencePieceProcessor() sp_en.load('en_bpe.model') sp_cn = spm.SentencePieceProcessor() sp_cn.load('zh_bpe.model') PAD_ID = sp_en.pad_id() BOS_ID = sp_en.bos_id() EOS_ID = sp_en.eos_id() # ---------------------# # 2. Load Trained Model # ---------------------# DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # Model hyperparameters (must match training) INPUT_DIM = sp_en.get_piece_size() OUTPUT_DIM = sp_cn.get_piece_size() ENC_EMB_DIM = 512 DEC_EMB_DIM = 512 HID_DIM = 512 N_LAYERS = 3 attention = Attention(HID_DIM).to(DEVICE) encoder = Encoder(INPUT_DIM, ENC_EMB_DIM, HID_DIM, n_layers=N_LAYERS).to(DEVICE) decoder = Decoder(OUTPUT_DIM, DEC_EMB_DIM, HID_DIM, attention, n_layers=N_LAYERS).to(DEVICE) model = Seq2Seq(encoder, decoder, DEVICE).to(DEVICE) model.load_state_dict(torch.load('seq2seq_bpe_attention.pt', map_location=DEVICE)) model.eval() # ---------------------# # 3. Translation Function (Greedy) # ---------------------# def translate_sentence(sentence, max_len=100): # Tokenize and convert to IDs tokens = [BOS_ID] + sp_en.encode(sentence, out_type=int) + [EOS_ID] src_tensor = torch.LongTensor(tokens).unsqueeze(1).to(DEVICE) # [src_len, 1] src_len = [len(tokens)] # 调用Encoder with torch.no_grad(): encoder_outputs, hidden, cell = encoder(src_tensor, src_len) # 第一个输入token,序列起始token: trg_indices = [BOS_ID] # 逐个token,循环调用Decoder。 for _ in range(max_len): # 最新生成的token作为输入 trg_tensor = torch.LongTensor([trg_indices[-1]]).to(DEVICE) with torch.no_grad(): output, hidden, cell, _ = decoder(trg_tensor, hidden, cell, encoder_outputs, (src_tensor != PAD_ID).permute(1, 0)) # 取预测概率最大的token作为输出 pred_token = output.argmax(1).item() trg_indices.append(pred_token) if pred_token == EOS_ID: break # 将token id解码为文字 (跳过) translated = sp_cn.decode(trg_indices[1:-1]) return translated # ---------------------# # 4. Interactive Test # ---------------------# if __name__ == '__main__': while True: src_sent = input("Enter English sentence (or 'quit' to exit): ") if src_sent.lower() in ['quit', 'exit']: break translation = translate_sentence(src_sent) print(f"Chinese Translation: {translation}\n") 你可以通过上边的代码[inference.py](https://github.com/RethinkFun/DeepLearning/blob/master/chapter14/inference.py) 进行测试,它依赖[translator.py](https://github.com/RethinkFun/DeepLearning/blob/master/chapter14/translator.py) 。 可以看到英文句子是一次性送入Encoder的。而在Decoder生成中文翻译阶段,是逐字生成的。首先用``作为初始输入,Decoder产生一个中文token,然后把新生成中文token作为输入,再送入Decoder,生成下一个中文token。这种模式叫做**自回归**。 运行后,你可以输入英文句子,模型会输出翻译后的中文句子。怎么样,效果是不是还不错?尽管我已经训练过很多模型,但是每次看到模型开始按照预想方式的工作,内心还是不由得感叹它的精妙,这可能就是人工智能技术的魅力吧。 ### 14.4.2 BLEU指标评估 通过运行[BLEU\_socre.py](https://github.com/RethinkFun/DeepLearning/blob/master/chapter14/BLEU_score.py) ,它依赖[inference.py](https://github.com/RethinkFun/DeepLearning/blob/master/chapter14/inference.py) 。运行后将显示你训练模型的BLEU值。 import sacrebleu from inference import translate_sentence # 读取验证集的英文原文和中文参考 with open('data/en2cn/valid_en.txt', 'r', encoding='utf-8') as f: src_sentences = [line.strip() for line in f.readlines()] with open('data/en2cn/valid_zh.txt', 'r', encoding='utf-8') as f: ref_sentences = [line.strip() for line in f.readlines()] # 检查长度是否匹配 assert len(src_sentences) == len(ref_sentences), "源语言和参考翻译句子数不匹配" # 用模型生成翻译 hypotheses = [] for i, src in enumerate(src_sentences): print(f"Translating {i+1}/{len(src_sentences)}...") translation = translate_sentence(src) print(ref_sentences[i], translation) hypotheses.append(translation.strip()) # 计算 BLEU bleu = sacrebleu.corpus_bleu(hypotheses, [ref_sentences], tokenize='zh') print("\n========== BLEU Evaluation Result ==========") print(f"BLEU Score: {bleu.score:.2f}") ### 14.4.3 下一步改进 如果你想停留在这里继续完善这个模型,那么我建议你可以尝试: 1. 你可以通过增加隐状态的维度:hid\_dim,词向量的维度:emb\_dim。 2. 增加LSTM层的数量。 3. 多训练一些迭代。 4. 可以把贪婪生成中文翻译改为Beam Search。 我们后边会讲Transformer架构,在翻译任务上它会有更好的表现。 * * * 恭喜你,你已经用RNN实现了一个不错的翻译模型! 扫码请作者喝一杯咖啡来分享你的喜悦吧! ![zsm](http://rethink.fun/imgs/zsm.png) results matching "" =================== No results matching "" ====================== --- # DeepSeekV3 · GitBook [DeepSeekV3](https://www.rethink.fun/) ======================================= 18.4 DeepSeekV3 --------------- ![1822.png](http://rethink.fun/imgs/1822.png) 首先我们来看一下DeepSeekV3和其他模型的性能对比,可以看到它在各项测评中都超过了闭源的OpenAI的GPT-4o模型。这一点就足以在业界引起轰动,然而更让大家惊叹的是,它的训练成本只有500多万美元。 Llama3 405B模型用H100,训练了3080万GPU小时。而DeepSeekV3 671B模型,用的是性能不如H100的H800GPU,训练了279万GPU小时,在性能超过Llama3的同时,训练花费还不到Llama3的十分之一。所以这让DeepSeek火出了圈。也否定了之前无脑堆算力来提升模型性能的做法,引起了英伟达股票的大跌。 DeepSeekV3除了在模型架构方面做了一些优化和改进外,也在训练框架方面也做了非常多的改进,比如是业界首次使用FP8混合精度训练超大参数量模型。但今天我们主要讲DeepSeekV3在模型架构方面的一些改进点。 ### 18.4.1 模型架构 ![1817.png](http://rethink.fun/imgs/1817.png) 首先我们看一下DeepSeekV3的模型架构图,它采用了我们之前讲过的DeepSeekMOE,和MLA,这两点都没有变化。但在实现细节上有一些小的变化。在DeepSeekV2里,路由网络出来对各个专家的权重logits用softmax作为激活函数,这样让所有专家的权重加和为1。但是因为最终只选择TopK个专家,选择TopK个专家后,对权重还要再做一次归一化,所以这里也不必一定用softmax。在DeepSeekV3里,就改成对每个专家的权重的logits用Sigmoid激活函数,然后对选择的topK个专家,再进行归一化,做为最终的专家权重。 接下来是负载均衡的修改,之前我们讲MOE时讲过针对batch的辅助负载均衡损失函数,在DeepSeekV3里去掉了针对Batch的辅助负载均衡损失函数,而是训练时,在每个step,监控每个专家的负载,如果某个专家负载过高,就自动调整减小它对应的权重。如果专家负载过低,就自动调整增加它的权重。需要注意的是,这个修改只在专家路由时起作用,在和最终专家输出相乘时,还是采用原来的权重值。DeepSeek也做了消融实验,发现去掉Batch的辅助负载均衡损失函数,通过动态调节每个专家的权重的方法可以获得更好的模型效果。并且同时在不同大小的模型下做了实验,效果都是一致的。 另外DeepSeekV3增加了针对序列的负载均衡损失函数,这个损失函数的形式和我们讲MOE时针对batch的负载均衡损失是一样的。都是用各个专家被选择的频率值乘以平均概率值再求和。之所以要增加序列负载均衡损失函数,是防止在序列内部专家的负载不均衡。如果专家仅仅是在batch内负载均衡,那么有可能是按照序列划分的专家的,有的专家可能擅长回答某一类问题。这样当模型部署后,如果用户在某一时间问多个同一方向的问题(比如数学问题),就会出现专家负载不均衡的现象。 DeepSeek V3最大的改动就是训练时采用了多token预测,即 Multi-Token Prediction MTP。 ### 18.4.2 MTP 之前我们学的大语言模型都是每次只能预测一个token。但实际上想Google,Meta等公司的研究人员一直在研究让大语言模型一次预测多个token。 ![1823.png](http://rethink.fun/imgs/1823.png) 普通的大模型结构如上图所示。 ![1824.png](http://rethink.fun/imgs/1824.png) 上图是DeepSeek V3出现之前的多token预测模型架构。首先是L-1层的共享的Transformer Block,为什么这里是L-1层呢?因为把最后一层的Transformer Block提出来,放在上边最左边,作为主要的一个头,它的作用并没有变,还是预测下一个token。然后又额外增加了其他的头,它们从第L-1层接入token的特征,经过额外增加的一个Transformer Block,再经过分类头,分别预测后边第2个token,第3个token。比如这里输入是t1,原始的输出头预测T2,下一个MTP头需要预测T3,再下一个MTP头需要预测T4。注意,这里的分类头是共享参数的。在计算Loss时,会分别考虑T2,T3,T4的交叉熵损失。其中T2的损失权重会大一些,T3和T4的损失权重会小一些。 这样做也是有一定道理的,那就是我们人在说一句话时,大脑并不是一个字一个字生成,有可能一下子就思考出一个语义片段。 具体到模型训练上,对骨干网络的训练监督信号由原来的一个token,变为多个token,可以增加训练信号,提高数据的利用率。另外,这样迫使模型可以提前规划它需要提取的特征,提取的特征需要能更好的预测未来的多个token。 多Token预测还有一个好处就是可以加速预测。它的加速预测不是简单的直接用多个头来生成接下来的多个token。因为预测最准确的还是预测下一个token的那个头。预测越远的token,越不准确,需要进行修正。 下边我们看一下利用MTP进行预测加速的方案: 首先,假如输入是三个token:“天”、“生”、“我”,模型有4个头,每个头并行预测接下来第一个,第二个,第三个,和第四个token。假设它们为“才”,“必”,“有”,“人”。 ![1831.png](http://rethink.fun/imgs/1831.png) 这里“才”用深颜色表示,因为这是用第一个头预测的下一个token,它是最准确的了。我们就认为它是最终的输出,但是后边几个token肯定没有第一个头预测的下一个token准确。所以用浅颜色表示,表示不确信。那么对于后边这几个不确信的token怎么办呢?那就是让第一个头再去验证一下。 ![1826.png](http://rethink.fun/imgs/1826.png) 所以我们构造了这样的一个batch,用第一个头来对不确定的token逐个进行确认。比如这里第一个头的输出分别为“必”,“有”,“用”。通过验证,发现多头预测的前两个预测对了,第3个错了。需要注意的是,这里3个序列是通过一个batch并行进行验证的。因为GPU擅长并行计算,主要时间开销是显存访问。所以认为这一个batch的3个序列的验证时间和一个序列的生成时间差不多。 ![1827.png](http://rethink.fun/imgs/1827.png) 第三步就是接受最长的正确序列了。虽然第四个头给出的最后一个预测:"人"是错误的,但是验证时第一个头给出了更好的答案:“用”,最终接受时就采用第一个头给出的预测。最终给出的输出为:"天生我才必有用" 可以看到通过预测和验证两步运算,就预测出来4个token。原来这要通过4步运算才可以。所以MTP可以加速推理,但是你可以看到这是以增加GPU的计算量为代价的,因为在第二步要同时对4个序列进行预测。 ![1828.png](http://rethink.fun/imgs/1828.png) 更进一步,可以将验证和预测放在一起。比如初始时一样,根据输入:“天”,“生”,“我”,用四个头分别预测,假如理想情况下四个头都预测正确了,预测为“才”,“必”,“有”,“用”。接下来在验证时,我们之前是只让第一个头工作,现在我们让4个头同时工作,那就会生成后边的预测。比如验证时以4个序列为一个batch。则在验证完成后,又可以进入下一步的验证了。这样就只要不断进行验证+预测的循环就可以。在每个batch结束后输出接受的序列。这样效率更高。 MTP预测加速只在生成单个序列时有效,就是整个推理集群只为你一个人做推理服务,那确实可以加速推理。 但是我们知道,现在像vLLM这样的推理框架,支持动态batch,可以支持多用户同时访问大模型。它会自动的将多个不同请求组合成batch进行预测。这时MTP的预测加速就没有优势了,反而会因为验证不通过而浪费计算。所以DeepSeek MTP,它只是在训练时利用MTP来提升模型的性能,在推理部署时,一般就丢弃其他几个预测多步的头,就只用第一个头做下一个token的预测。和普通单头的大模型没有区别。 ### 18.4.3 DeepSeekMTP DeepSeekMTP也在模型架构上进行了修改。原来的多token预测有个问题。就是跨多步的token不知道它前面的几个token,从而让预测正确概率变低,不利于模型的收敛。比如对于第四个头的预测,它要在不知道它前边3个token的前提下预测出下一个token,这样难度非常大。 SeepSeekMTP就是给每个头传入了额外的信息。帮助多token预测的头能更好的预测自己接下来的token。 ![1829.gif](http://rethink.fun/imgs/1829.gif) 首先是原始的DeepSeek模型架构,输入为t1,进过L层的Transformer Block,输出经过分类头,预测出t2, 然后我们增加一个MTP头,它的输入来源有两个,一个是来自DeepSeek第L层的token的特征。注意,这里是第L层,不是L-1层。另一个是t2这个token经过Embedding后的特征。可以理解为左边传来的是可以预测T2的token的特征,因为主网络就是需要根据这个特征来预测t2。下边传来的是t2的Embedding的特征。然后分别做归一化,再按Token把特征拼接起来,这样每个token的特征维度就为原来的2倍,再经过一个线性层映射为原来的token的特征维度。经过一个TransformerBlock,接下来经过分类头来预测t3。可以看到对于这个MTP头,它输入的信息包含了可以预测t2这个token的特征,也包含了t2的Embedding,那就更容易预测出t2的下一个token:t3。接下来我们看第二个MTP头,同样它的输入来自两个部分,一部分是可以预测t3的特征,一部分是t3的Embedding,然后经过归一化,降维,Transformerblock,预测出T4。这样每个MTP头训练会更容易,也更稳定。其中embedding层和分类头是共享参数的。 ![1829.png](http://rethink.fun/imgs/1829.png) 最后我们看一下原始论文里的MTP模型结构图,因为训练时所有token都是已知的,可以通过mask的方式一次进行多个token的训练,这里输入为t1-t4,主模型,预测下一个token,输出是t2-t5,它给MTP1传入的是可以预测出t2-t5的token的特征,同时t2-t5的embedding也被传入第一个MTP头,最终,第一个MTP头预测出t3-t6。第二个MTP头也是类似。输入t3-t6的相关特征,预测出t4-t7的token。 这里有两个MTP头,它的交叉熵loss最终也是会加入到模型最终的loss里的。比如在DeepSeekV3里先对所有MTP头的loss取平均,然后乘以权重𝜆,在训练前10Ttoken时,𝜆的值为0.3,在训练最后4.8Ttoken时,𝜆的值为0.1。 LossMTP\=λD∑k\=1DLossMTPk Loss\_{MTP}=\\frac{\\lambda}{D}\\sum\_{k=1}^DLoss\_{MTP}^k LossMTP​\=Dλ​k\=1∑D​LossMTPk​ ![1830.png](http://rethink.fun/imgs/1830.png) 最后我们可以看一下论文里的消融实现,在添加MTP训练后,不论是15.7B的小模型还是228.7B的大模型,模型性能都有提高。 * * * 恭喜你,你已经了解了DeepSeek系列模型的原理! 扫码请作者喝一杯咖啡来分享你的喜悦吧! ![zsm](http://rethink.fun/imgs/zsm.png) results matching "" =================== No results matching "" ====================== --- # 位置编码 · GitBook [位置编码](https://www.rethink.fun/) ================================= 15.4位置编码 -------- 序列问题最主要的特征就是数据的顺序是非常重要的,如果数据交换顺序,那么表达的含义就会发生改变。比如对于两个文本序列“狗咬人”和“人咬狗”,token都一样,但是序列不同意思不同。之前RNN是通过顺序读入序列数据来保障的,但是之前我们看到Transformer只用注意力来更新token的embedding。并且所有token可以并行计算,进行更新。这其中并没有引入任何token在序列中的位置信息。这一节我们就给token加入位置信息。 ### 15.4.1 绝对位置编码 ![1506.png](http://rethink.fun/imgs/1506.png) 我们能想到的最简单的编码方式,那就是绝对位置编码了,比如上图,对每个token设计一个和word embedding一样维度的position embedding。 对于“我爱吃苹果”这句话由4个token构成,它们position embedding的后三位分别为“001”、“010”、“011”、“100”。编码后将word embedding和position embedding按位相加。这样就给每个token的embedding里增加了位置信息。 绝对位置编码有个问题就是训练时的序列长度是固定的,位置编码每个维度的数值只能是0或者1(离散的),如果预测时序列长度超出训练时的长度,模型就无法处理,也不能根据训练时的数据外推到更大的长度。 ### 15.4.2 什么是好的位置编码 一个好的位置编码,应该每个维度都是连续值,这样即使将来出现更长的序列,只需要更大的数字表示就可以。还有一点就是可以借鉴机械表的设计,表有时针,分针,秒针。在计时过程中,秒针变化最快,分针次之,时针变化最慢。且都是周期性变化。 所以我们期望的位置编码是每个维度上的数字都是连续型的。而且位置编码的低维数字变化快,高维数字变化慢,每个维度都为周期性变化。 ### 15.4.3 Sin函数位置编码 ![1507.png](http://rethink.fun/imgs/1507.png) 通过上图可以看到,sin函数是个周期函数。通过给输入x增加一个系数,可以调节波长,波长越长,函数值随x变化越慢。这样我们就可以用sin函数作为位置编码函数。位置编码的低维度用波长短的sin函数,这样值的变化快。高维度用波长长的sin函数,这样值的变化慢。 假设位置编码有3个维度,我们分别用3个sin函数来对3个维度进行编码:sin(x)、sin(0.5x)、sin(0.2x)。其中x代表token在序列中的位置。 则第一个token的位置编码为: \[sin(1),sin(0.5),sin(0.2)\]\=\[0.84,0.48,0.20\] \[sin(1),sin(0.5),sin(0.2)\]=\[0.84,0.48,0.20\] \[sin(1),sin(0.5),sin(0.2)\]\=\[0.84,0.48,0.20\] 第二个token的位置编码为: \[sin(2),sin(1),sin(0.4)\]\=\[0.91,0.84,0.39\] \[sin(2),sin(1),sin(0.4)\]=\[0.91,0.84,0.39\] \[sin(2),sin(1),sin(0.4)\]\=\[0.91,0.84,0.39\] 对于一个512维的位置编码,我们可以如下定义: ![1508.png](http://rethink.fun/imgs/1508.png) 上图中每个token的位置编码都在sin函数里的wiw\_iwi​前乘以自己的位置序号,“我”是第一个token,乘以1。“爱”是第二个token,乘以2。依次类推。 上图中每个编码位置的sin()函数的系数用wiw\_iwi​表示,i从0开始,到feature\_size减1。随着编码位置的增加,wiw\_iwi​变小,意味着sin()函数的波长变长。 wi\=110000i/feature\_size w\_i = \\frac{1}{10000^{i/feature\\\_size}} wi​\=10000i/feature\_size1​ 但实际上Transformer里是用sin和cos函数交替来进行编码的。如下图所示: ![1509.png](http://rethink.fun/imgs/1509.png) 上图中偶数位置用sin函数系数用w2iw\_{2i}w2i​表示,奇数位置用cos函数,系数用w2i+1w\_{2i+1}w2i+1​表示。 w2i\=1100002i/feature\_size w\_{2i}= \\frac{1}{10000^{2i/feature\\\_size}} w2i​\=100002i/feature\_size1​ w2i+1\=1100002i/feature\_size w\_{2i+1}= \\frac{1}{10000^{2i/feature\\\_size}} w2i+1​\=100002i/feature\_size1​ 同样sin和cos函数都是随着维度增加波长增加的周期函数,那么入cos函数的好处是什么呢?注意力机制是通过q向量和k向量的点积来计算的。对于用sin和cos交替进行编码的两个位置向量,一个位置是t,一个位置是t加△t\\triangle t△t。对这两个位置编码进行点积运算如下: (sin(wit),cos(wit))⋅(sin(wi(t+△t)),cos(wi(t+△t))(sin(w\_it),cos(w\_it))\\cdot(sin(w\_i(t+\\triangle t)),cos(w\_i(t+\\triangle t))(sin(wi​t),cos(wi​t))⋅(sin(wi​(t+△t)),cos(wi​(t+△t)) \=sin(wit)sin(wi(t+△t))+cos(wit)cos(wi(t+△t))\=sin(w\_it)sin(w\_i(t+\\triangle t))+cos(w\_it)cos(w\_i(t+\\triangle t))\=sin(wi​t)sin(wi​(t+△t))+cos(wi​t)cos(wi​(t+△t)) \=cos(wi(t−(t+△t)))\=cos(w\_i(t-(t+\\triangle t)))\=cos(wi​(t−(t+△t))) \=cos(wi△t)\=cos(w\_i\\triangle t)\=cos(wi​△t) 通过上边计算可以看到最后注意力计算结果只与两个位置之间的相对位置△t\\triangle t△t有关,而和绝对位置t无关,这对于自然语言处理是非常重要的,因为一个词组的意思只和这个词组里单个字之间的相对位置有关,而和这个词出现在句子里的绝对位置无关。 ### 15.4.4 PyTorch实现位置编码 class PositionalEncoding(nn.Module): def __init__(self, d_model: int, seq_len: int, dropout: float) -> None: super().__init__() self.d_model = d_model self.seq_len = seq_len self.dropout = nn.Dropout(dropout) # 创建一个空的tensor pe = torch.zeros(seq_len, d_model) # (seq_len, d_model) # 创建一个位置向量 position = torch.arange(0, seq_len, dtype=torch.float).unsqueeze(1) # 计算分母 div_term = torch.pow(10000.0, -torch.arange(0, d_model, 2, dtype=torch.float) / d_model) # (d_model / 2) # 偶数位调用sin pe[:, 0::2] = torch.sin(position * div_term) # 奇数为调用cos pe[:, 1::2] = torch.cos(position * div_term) # 增加batch维度 pe = pe.unsqueeze(0) # (1, seq_len, d_model) # 注册位置编码为一个buffer,这个tensor不会参与训练,但是会随同模型一起被保存或者迁移到GPU。 self.register_buffer('pe', pe) def forward(self, x): x = x + (self.pe[:, :x.shape[1], :]).requires_grad_(False) return self.dropout(x) results matching "" =================== No results matching "" ====================== --- # BLEU评价指标 · GitBook [BLEU评价指标](https://www.rethink.fun/) ===================================== 14.2 BLEU评价指标 ------------- 之前我们说过,做一个深度学习项目,一定要先确定一个对模型的衡量指标。这个指标是单一数值的,可以定量的衡量模型的表现,它是引导整个项目的关键指标。 BLEU(Bilingual Evaluation Understudy)是机器翻译中最常用的自动评价指标之一,它衡量的是机器翻译结果和一个或多个参考译文之间的相似度。 BLEU指标的取值范围为\[0,1\]之间,值越大代表机器翻译与参考译文越相似,表明翻译效果越好。 | BLEU得分 | 模型表现 | | --- | --- | | 0-0.1 | 差,基本不可读 | | 0.1-0.2 | 可表达大体意思,有时会有错误 | | 0.2-0.3 | 基本流畅可用,有一定错误或者不自然 | | 0.3-0.4 | 很流畅,但不完美 | | 0.4-0.5 | 很好,接近人工翻译 | | 0.5-1.0 | 非常好(罕见) | ### 14.2.1 BLEU原理 BLEU的大致原理是: * 将模型翻译句子和参考译文都转化成token序列。 * 计算每个模型翻译的token在参考译文的token序列中出现率p1p\_1p1​。 * 计算每两个连续翻译token序列在参考token序列中的出现率p2p\_2p2​。 * 计算每三个,每四个连续翻译token序列在参考token序列中的出现率p3,p4p\_3,p\_4p3​,p4​。 * 综合p1,p2,p3,p4p\_1,p\_2,p\_3,p\_4p1​,p2​,p3​,p4​,并惩罚模型输出翻译太短的情况,得到BLEU最终得分。 ### 14.2.2 调用BLEU 首先安装依赖库: pip install sacrebleu BLEU score对于一个机器翻译出来的句子,可以有多个参考的翻译。这是因为一个意思可以有多种不同的表达方式。如果想要BLEU值准确,就应该提供各种可能得参考翻译。但在我们这个demo的数据里,每个翻译只有一个参考答案。具体调用BLEU的代码: import sacrebleu # 单个参考译文列表 references = [\ "今天天气很好。",\ "我喜欢在雨天散步。",\ "今天要早点下班。"\ ] # 模型生成的候选翻译 hypotheses = [\ "今日天气不错。",\ "下雨的时候我喜欢散步。",\ "今天下班要早些。"\ ] # sacrebleu 需要 references 是 list[list[str]] # 即使只有一个参考,也要是内层 list references = [references] # 变成:[[ref1, ref2, ref3]] # 计算 BLEU bleu = sacrebleu.corpus_bleu(hypotheses, references, tokenize='zh') print(f"BLEU score: {bleu.score:.2f}") 运行后,可以看到,BLEU得分为16.31,注意这里输出的是百分比,对应实际BLEU值为0.1631。需要注意一点,在调用`sacrebleu.corpus_bleu`时,需要设置正确的分词方式,我们这里是对中文进行分词,需要设置`tokenize='zh'`。sacrebleu库里对中文的分词是单个字认为是一个token。 ### 14.2.3 BLEU详解 如果你不想了解BLEU的细节,本章节你看到这里就可以停止了。你只要知道BLEU是衡量模型翻译好坏的指标,取值范围\[0-1\],越大越好。0.3以上就表示该翻译模型基本可用。这些知识就可以让你利用BLEU来评价你的翻译模型了。 接下来我们就来详细了解BLEU的计算过程。 #### 14.2.3.1 p1p\_1p1​ 准确率p1p\_1p1​衡量的是模型翻译的单个token参照参考翻译的准确率。 假设参考翻译TrT\_rTr​为“今天天气很好”,模型翻译TmT\_mTm​为:“今天今天今天” 都按照单个字为一个token来拆解,则TmT\_mTm​里有两个不同的token:“今”、“天”。这两个token也都出现在TrT\_rTr​的token序列里。我们是否就可以定义p1\=1p\_1=1p1​\=1呢?当然不行,这个翻译明显是个很差的翻译。BLEU的具体做法如下: | token | count(Tm,token)count(T\_m,token)count(Tm​,token) | min(count(Tm,token),count(Tr,token))min(count(T\_m,token),count(T\_r,token))min(count(Tm​,token),count(Tr​,token)) | | --- | --- | --- | | 今 | 3 | 1 | | 天 | 3 | 2 | 上表中第二列统计了对于某个token在TmT\_mTm​中出现的个数,把这个值叫做countcountcount。上表中第三列统计了对于某个token在Tm,TrT\_m,T\_rTm​,Tr​中出现较小的那个count值,把这个值叫做clip\_countclip\\\_countclip\_count。 则有: p1\=∑clip\_count∑count\=3/6\=0.5 p\_1 = \\frac{\\sum clip\\\_count}{\\sum count}=3/6 =0.5 p1​\=∑count∑clip\_count​\=3/6\=0.5 #### 14.2.3.2 p2p\_2p2​ 准确率p2p\_2p2​衡量的是模型翻译token序列中两个连续token参照参考翻译的准确率。 假设参考翻译TrT\_rTr​为“今天天气不错”,模型翻译TmT\_mTm​为:“今天今天不错”。 则TmT\_mTm​中2个不同的连续token序列为“今天”、“天今”、“天不”、“不错” | token | count(Tm,token)count(T\_m,token)count(Tm​,token) | min(count(Tm,token),count(Tr,token))min(count(T\_m,token),count(T\_r,token))min(count(Tm​,token),count(Tr​,token)) | | --- | --- | --- | | 今天 | 2 | 1 | | 天今 | 1 | 0 | | 天不 | 1 | 0 | | 不错 | 1 | 1 | p1\=∑clip\_count∑count\=2/5\=0.4 p\_1 = \\frac{\\sum clip\\\_count}{\\sum count}=2/5 =0.4 p1​\=∑count∑clip\_count​\=2/5\=0.4 同理你可以求出p3,p4p\_3,p\_4p3​,p4​ #### 14.2.3.3 对p值求平均 我们求得了p1,p2,p3,p4p\_1,p\_2,p\_3,p\_4p1​,p2​,p3​,p4​之后,如何综合考虑对不同长度序列的翻译精度呢?最简单的方式是求算数平均值,但是算数平均数有个问题,它不能突出这一组数中的小值。有可能p1,p2,p3p\_1,p\_2,p\_3p1​,p2​,p3​很大,p4p\_4p4​为0,这样均值还是一个不错的值。但这不是我们希望的结果,我们希望这个均值对每个小的p值都会很敏感。这时就需要用到几何平均数。这里我们考虑p1,p2,p3,p4p\_1,p\_2,p\_3,p\_4p1​,p2​,p3​,p4​对结果的权重都是相同的,都为1/4。则它的几何平均数为: ∏n\=14Pn14 \\prod\_{n=1}^{4}P\_n^{\\frac{1}{4}} n\=1∏4​Pn41​​ 我们举个例子: p1\=0.9p\_1=0.9p1​\=0.9 p2\=0.7p\_2=0.7p2​\=0.7 p3\=0.4p\_3=0.4p3​\=0.4 p4\=0.0p\_4=0.0p4​\=0.0 算数平均:(0.9+0.7+0.4+0.0)/4\=0.5(0.9+0.7+0.4+0.0)/4=0.5(0.9+0.7+0.4+0.0)/4\=0.5 几何平均:0.9⋅0.7⋅0.4⋅0.04\=0\\sqrt\[4\]{0.9\\cdot 0.7\\cdot 0.4 \\cdot 0.0} =040.9⋅0.7⋅0.4⋅0.0​\=0 另外直接用几何平均数的连乘形式,有可能多个很小的数连乘,造成float无法表示。所以一般用它的另一种等价表示方式: ∏n\=14Pn14\=exp(∑n\=1414log⁡pn) \\prod\_{n=1}^{4}P\_n^{\\frac{1}{4}}=exp(\\sum\_{n=1}^{4} {\\frac{1}{4}\\log{p\_n}}) n\=1∏4​Pn41​​\=exp(n\=1∑4​41​logpn​) #### 14.2.3.4 短句惩罚 假设参考翻译TrT\_rTr​为“今天天气很好”,模型翻译TmT\_mTm​为:“今天天气”。可以看到如果模型输出短句的话,它的准确率会很高,但是实际效果却很差。所以需要对短句进行惩罚。做法就是在上边的p值几何平均数的结果上乘一个值。 BP\={1 if m\>rexp(1−rm) if m≤r BP=\\begin{cases} 1 & \\text{ if } m>r \\\\ exp(1-\\frac{r}{m}) & \\text{ if } m\\le r \\end{cases} BP\={1exp(1−mr​)​ if m\>r if m≤r​ 其中r表示参考翻译的长度,m表示模型翻译的长度。 #### 14.2.3.5 最终公式 BLEU\=BP⋅exp(∑n\=1414log⁡pn) BLEU=BP\\cdot exp(\\sum\_{n=1}^{4} {\\frac{1}{4}\\log{p\_n}}) BLEU\=BP⋅exp(n\=1∑4​41​logpn​) 上边我们默认p1,p2,p3,p4p\_1,p\_2,p\_3,p\_4p1​,p2​,p3​,p4​的权重都是1/4。你也可以单独对每个p值设置不同的权重。wnw\_nwn​为pnp\_npn​对应的权重,公式就变为: BLEU\=BP⋅exp(∑n\=14wnlog⁡pn) BLEU=BP\\cdot exp(\\sum\_{n=1}^{4} {w\_n\\log{p\_n}}) BLEU\=BP⋅exp(n\=1∑4​wn​logpn​) 你只要理解BLEU的原理就行,实际中不需要自己动手实现。 results matching "" =================== No results matching "" ====================== --- # 旅程的终点,也是新的起点 · GitBook [旅程的终点,也是新的起点](https://www.rethink.fun/) ========================================= 不知不觉间,我们已经携手走完了这趟激动人心的深度学习之旅,从第一章的好奇,到一步步拆解数学原理。我们的手中的模型,也从线性回归,逐步长成了让我们引以为傲的DeepSeek。 站在这个终点回望,你是否和我一样,心中充满感慨?感慨深度学习这门科学的深邃与活力,感慨于人类智慧的创造力。更感慨我们自己-作为学习者和探索者,一路走来的坚持与成长。 **那么,接下来呢?** **请保持你的好奇,持续探索!**深度学习的世界日新月异,大模型,多模态学习、强化学习、可解释AI、具身智能等方向蓬勃发展。我强烈建议你选择一个最感兴趣的领域深入钻研,阅读最新的论文,复现经典或前沿的代码。或者将本书中学到的知识应用到你的一个实际项目中去。 如果你选择继续深入探索大模型领域,我推荐你阅读我的这本由清华大学出版社出版的《大模型核心技术与应用》。 ![book](http://rethink.fun/imgs/book.png) **感谢你的一路相伴,让我们保持联系!** 你可以请我喝杯咖啡: ![zsm](http://rethink.fun/imgs/zsm.png) 也请在B站关注我:RethinkFun,我会持续与你分享人工智能相关的知识和我的看法。 _再会,朋友!_ _RethinkFun_ results matching "" =================== No results matching "" ====================== --- # DeepSeekMOE · GitBook [DeepSeekMOE](https://www.rethink.fun/) ======================================== 18.2 DeepSeekMOE ---------------- 上一节我们讲了普通的MOE大模型的架构,这一节我们就来学习DeepSeekMOE。DeepSeekMOE的论文题目为:迈向让专家更加专精的MOE语言模型(DeepSeekMoE: Towards Ultimate Expert Specialization in Mixture-of-Experts Language Models)。可以看到DeepSeekMOE的改进点是让专家更加专精。 ### 18.2.1 架构改进 ![1803.png](http://rethink.fun/imgs/1803.png) 基础的MOE实现如上图,假设有N个专家,每次选择2个专家。DeepSeek认为传统的MOE设置的专家数太少了,导致每个专家学习了过多,彼此不相关的知识,从而不够专精。比如一个医院里只有2名医生,一个内科一个外科,那每个医生要掌握的医学领域就很广。不利于医术的精进。 ![1804.png](http://rethink.fun/imgs/1804.png) DeepSeek想到的办法就是将专家进行更进一步的细分,同时每个专家的网络也变小。比如这里,将原来N个专家变为2N个专家。每个专家网络参数量也为原来专家的一半。这样在网络前向传播时,维持和之前网络同样计算代价的前提下,就可以激活4个专家。 假如原来有8个专家,激活2个专家,现在细分为16个专家,激活4个专家。原来8个里边选2个,有28种可能的组合,现在是16个专家里边选4个专家,就有1820种可能的组合。这种组合方式更灵活。就像医院里分了各个科室,每个专家负责的东西变少,也有利于让每个专家学习到更加专精的内容。 ![1805.png](http://rethink.fun/imgs/1805.png) DeepSeek又进一步想所有专家,可能都要学习一些基础的能力,是否可以将所有专家都要学习的通用基础能力提取出来,作为一个共享专家。共享专家保证每次都被激活,负责所有专家原来都需要的通用能力。你可以理解为医院里的各个科室都需要验血。那就把验血作为一个共享科室,所有科室都可以利用。 比如这里在细分的2N个专家里,提取出一个专家作为共享专家,他每次都被激活,然后再从后边2N减1个专家里选择3个专家。这样保证路由专家和共享专家总数不变,还是4。和原来选择2个共享专家的原始MOE网络计算代价是相同的。 ### 18.2.2 模型效果 ![1806.png](http://rethink.fun/imgs/1806.png) 通过这些改进,可以看一下DeepSeekMOE的效果,它在同等计算代价的情况下比稠密网络和普通MOE的效果都有很大的提升。 ![1807.png](http://rethink.fun/imgs/1807.png) 而且DeepSeek也做了实验,就是DeepSeekMOE的性能已经达到MOE模型的极限,也就是基本与每次全部激活的稠密网络性能相当。 ![1808.png](http://rethink.fun/imgs/1808.png) 上边这张图,通过消融实验,验证了DeepSeek对模型进行细分,以及抽取出共享专家的作用。横坐标是不同的测试集,每个测试集上的四个表现是四种不同的网络结构。前提是保证这四种网络结构他们的网络总参数,运行时激活的参数量都是相等的。从左到右依次是:第一个从16个专家里激活2个专家的表现。第二个,从16个专家里提取一个共享专家,运行时每个token再选择一个路由专家。第三个,细分为32个专家,其中1个共享专家,运行时路由3个专家。第四个,更细分为64个专家,其中1个共享专家,运行时路由7个专家。可以看到,提取共享专家和对专家进行细分都是有利于模型性能提升的。 ![1809.png](http://rethink.fun/imgs/1809.png) 这张图是DeepSeekMOE和同等规模的GShard在禁用掉不同比例最高权重专家后Loss的改变,可以看到禁用掉同样比率的最高权重专家后,DeepSeek的性能下降更明显,这也证明了DeepSeek的专家专业性更强,与其他专家之间不能互相替代。 这些实验都足以证明DeepSeek MOE架构的优越性。 results matching "" =================== No results matching "" ====================== --- # 旋转位置编码 · GitBook [旋转位置编码](https://www.rethink.fun/) =================================== 17.2 旋转位置编码 ----------- 之前我们讲过Transformer里使用sin和cos函数生成位置编码,Bert里采用的是可学习位置编码,Llama-1采用了旋转位置编码。在大模型时代越来越多的模型采用了旋转位置编码,今天我们就来一探究竟。 ### 17.2.1 旋转矩阵 旋转位置编码的核心是通过sin和cos函数构成的旋转矩阵,对二维向量进行旋转。在二维坐标系里,假设一个向量位于x轴上,即向量v\=(1,0)v=(1,0)v\=(1,0)。我们希望讲这个向量绕原点旋转一个角度θ\\thetaθ。旋转后的向量v′v'v′长度保持不变,则其坐标变化为(cosθ,sinθ)(cos\\theta,sin\\theta)(cosθ,sinθ)。我们定义旋转矩阵R(θ)R(\\theta)R(θ)为: R(θ)\=\[cosθsinθ−sinθcosθ\] R(\\theta)=\\begin{bmatrix} cos\\theta & sin\\theta \\\\ -sin\\theta & cos\\theta \\end{bmatrix} R(θ)\=\[cosθ−sinθ​sinθcosθ​\] 将向量vvv与旋转矩阵R(θ)R(\\theta)R(θ)相乘后,结果为(cosθ,sinθ)(cos\\theta,sin\\theta)(cosθ,sinθ)。 ![1703.png](http://rethink.fun/imgs/1703.png) 同理,对于位于y轴上的向量v\=(0,1)v=(0,1)v\=(0,1),逆时针旋转θ\\thetaθ后,得到向量v′\=(−sinθ,cosθ)v'=(-sin\\theta,cos\\theta)v′\=(−sinθ,cosθ)。v′v'v′同样等于vvv与旋转矩阵R(θ)R(\\theta)R(θ)相乘的结果。 ![1704.png](http://rethink.fun/imgs/1704.png) 向量(1,0)和向量(0,1)是二维坐标系中的标准基向量。当这两个基向量通过旋转矩阵R(θ)R(\\theta)R(θ)逆时针旋转θ\\thetaθ角度时,则等于整个坐标系随之旋转了θ\\thetaθ角度。 接下来证明一下旋转矩阵R(θ)R(\\theta)R(θ)可以对任意二维向量进行逆时针旋转θ\\thetaθ角度。假设一个向量长度为rrr,与x轴夹角为α\\alphaα,逆时针旋转β\\betaβ角度。则证明如下: \[rcosαrsinα\]\[cosβsinβ−sinβcosβ\]\\begin{bmatrix} rcos\\alpha& rsin\\alpha \\end{bmatrix} \\begin{bmatrix} cos\\beta & sin\\beta \\\\ -sin\\beta & cos\\beta \\end{bmatrix}\[rcosα​rsinα​\]\[cosβ−sinβ​sinβcosβ​\] \=\[rcosθ∗cosβ−rsinα∗sinβrcosα∗sinβ+rsinα∗cosβ\]\=\\begin{bmatrix} rcos\\theta\*cos\\beta-rsin\\alpha \* sin\\beta & rcos\\alpha \* sin\\beta+rsin\\alpha\*cos\\beta \\end{bmatrix}\=\[rcosθ∗cosβ−rsinα∗sinβ​rcosα∗sinβ+rsinα∗cosβ​\] \=\[rcos(α+β)rsin(α+β)\]\=\\begin{bmatrix} rcos(\\alpha+\\beta)& rsin(\\alpha+\\beta) \\end{bmatrix}\=\[rcos(α+β)​rsin(α+β)​\] ### 17.2.2 旋转矩阵的性质 旋转矩阵具备以下两个性质: **旋转矩阵具有结合性:** R(θ1)R(θ2)\=R(θ1+θ2) R(\\theta\_1)R(\\theta\_2)=R(\\theta\_1+\\theta\_2) R(θ1​)R(θ2​)\=R(θ1​+θ2​) **旋转矩阵的转置就是旋转负的θ\\thetaθ角度**,证明如下: R(θ)T\=\[cosθ−sinθsinθcosθ\]\=\[cos(−θ)sin(−θ)−sin(−θ)cos(−θ)\]\=R(−θ) R(\\theta)^{T}=\\begin{bmatrix} cos\\theta & -sin\\theta \\\\ sin\\theta & cos\\theta \\end{bmatrix}=\\begin{bmatrix} cos(-\\theta) & sin(-\\theta) \\\\ -sin(-\\theta) & cos(-\\theta) \\end{bmatrix}=R(-\\theta) R(θ)T\=\[cosθsinθ​−sinθcosθ​\]\=\[cos(−θ)−sin(−θ)​sin(−θ)cos(−θ)​\]\=R(−θ) 其中证明过程中用到了sin和cos的奇偶性:cosθ\=cos(−θ)cos\\theta=cos(-\\theta)cosθ\=cos(−θ)和sin(−θ)\=−sinθsin(-\\theta)=-sin\\thetasin(−θ)\=−sinθ ### 17.2.3 旋转位置编码 在Transformer架构里进行注意力计算时,关键在于计算查询向量q和应答向量k之间的点积。qkTqk^TqkT表示两个向量之间的点积,这种计算方式没有直接考虑到token的位置信息。为了引入位置信息,可以通过旋转矩阵对两个向量进行编码来添加位置信息。编码过程为对每个向量根据它们的位置索引(分别为m和n)进行旋转。如果对q应用旋转矩阵R(m)R(m)R(m),对k应用旋转矩阵R(n)R(n)R(n),然后再进行点积计算,则有: qR(m)⋅kR(n)qR(m)\\cdot kR(n)qR(m)⋅kR(n) \=qR(m)R(n)TkT\=qR(m)R(n)^Tk^T\=qR(m)R(n)TkT \=qR(m)R(−n)kT\=qR(m)R(-n)k^T\=qR(m)R(−n)kT \=qR(m−n)kT\=qR(m-n)k^T\=qR(m−n)kT 上边的证明用到了旋转矩阵的两个性质。我们注意到最后得到的注意力表达式里不光含有q,k,还还有m-n这样的相对位置信息。这种相对位置信息可以更好的建模token之间的关系。 ### 17.2.4 拓展到高维 上边讨论的是2维的旋转矩阵,如何扩展到高维呢?答案是将维度两两一组进行旋转,如果q和k的特征长度为8,那么就让特征两两一组,一共4组。每一组在它们两个特征组成的子空间内进行旋转,至于是哪两个特征一组,是无所谓的。任意两个一组都可以。 如下图所示,m代表token的位置,比如一个token在序列里第一个,m就为0。F代表sin()和cos()的系数。在这个公式里,系数最大的是第一个二维子空间,系数为1。系数最大,则波长最短。同理,系数最小的是最后一个二维子空间,系数接近1/10000。系数最小,波长最长。 ![1705.png](http://rethink.fun/imgs/1705.png) 给予不同的二维子空间不同的旋转频率,是因为sin()和cos()都是周期函数。可以通过时钟来理解,频率最快的是秒针,它精准,但是每过一分钟就重复一次。频率最慢的是时针,它频率慢,但不容易重复。秒针,分针和时针一起组合来构成时间。 因为多维旋转矩阵里大部分都是0,token的特征在和多维旋转矩阵相乘时可以简化,其中h是token的特征向量,hih\_ihi​是特征向量第_i_个位置的值。R(h,m)R(h,m)R(h,m)表示对序列里第m个位置的token的特征向量h进行旋转。 R(h,m)1\=\[h0,h1,...hd−2,hd−1\]⊗\[cos(mf0),cos(mf0),..,cos(mfd2−1),cos(mfd2−1)\]R(h,m)\_1=\[h\_0,h\_1,...h\_{d-2},h\_{d-1}\]\\otimes \[cos(mf\_0),cos(mf\_0),..,cos(mf\_{\\frac{d}{2}-1}),cos(mf\_{\\frac{d}{2}-1})\]R(h,m)1​\=\[h0​,h1​,...hd−2​,hd−1​\]⊗\[cos(mf0​),cos(mf0​),..,cos(mf2d​−1​),cos(mf2d​−1​)\] R(h,m)2\=\[−h1,h0,...−hd−1,hd−2\]⊗\[sin(mf0),sin(mf0),..,sin(mfd2−1),sin(mfd2−1)\]R(h,m)\_2=\[-h\_1,h\_0,...-h\_{d-1},h\_{d-2}\]\\otimes \[sin(mf\_0),sin(mf\_0),..,sin(mf\_{\\frac{d}{2}-1}),sin(mf\_{\\frac{d}{2}-1})\]R(h,m)2​\=\[−h1​,h0​,...−hd−1​,hd−2​\]⊗\[sin(mf0​),sin(mf0​),..,sin(mf2d​−1​),sin(mf2d​−1​)\] R(h,m)\=R(h,m)1+R(h,m)2R(h,m)=R(h,m)\_1+R(h,m)\_2R(h,m)\=R(h,m)1​+R(h,m)2​ results matching "" =================== No results matching "" ====================== --- # MOE · GitBook [MOE](https://www.rethink.fun/) ================================ 18.1 MOE -------- MOE(Mixture of Experts)是混合专家模型的英文缩写。与MOE大模型对应,我们之前讲的大模型都是稠密(Dense)大模型。在训练一个大模型时,达到同样的性能时,大参数量的模型训练花费更少。我们之前讲过Llama模型训练的哲学是,用大量数据,在训练时花费更大的代价,让小参数量的模型达到一个尽可能好的表现。虽然训练一个同等性能的小参数量模型不如训练一个大参数量模型划算。但是因为训练只有一次,但是推理是无数次。小参数量模型在推理时的花费少,足以弥补训练时的额外花费。 MOE模型的特点是训练时花费代价很小,推理时花费的代价也很小。 ### 18.1.1 MOE模型架构 我们首先来复习稠密大模型里的一个Block。 ![1801.png](http://rethink.fun/imgs/1801.png) MOE就是对其中的FeedForwardLayer全连接模块进行了改造,上图展示了全连接模块的内部结构。全连接模块是对**每个token**进行应用的。假设token的维度是4096,一般稠密大模型的全连接模块都是先升维到原来的4倍:16384,然后再降维到4096。如上图所示。 ![1802.png](http://rethink.fun/imgs/1802.png) MOELayer将原来的一个全连接模块,拆分为多个小的全连接模块。里边每个线性层的维度都是4096x4096。这些小的一个个的全连接模块就被称为是一个个专家网络。那对于每个token该选择走哪个专家呢?答案是通过路由网络来决定。路由网络输出每个token走每个专家网络的概率。然后找出其中概率值最大的几个专家,具体几个专家被选中,也是一个可以调节的超参数。比如上图中,选择排名靠前的前两名专家。然后让token的特征,通过选中的这两个专家网络,得到两个维度为4096的特征向量,然后这两个特征向量按照专家被选中的权重值进行加权求和,就得到了MOELayer对于这个token的最终输出。这里需要注意的是专家的选择是对每个token进行的,不是对当前序列的。 ### 18.1.2 MOE架构的特点 因为MOELayer减少了计算量,所以在相同的计算代价下,MOE模型可以增大网络参数的规模,性能更好。基本可以达到和相同参数规模的稠密模型的网络性能。 相比同等参数规模的稠密网络,MOE模型计算代价变小,但是显存占用不变。因为所有的专家网络都是要被加载到显存中。 MOE模型的训练难度比稠密模型要大,因为可能存在专家负载不均衡的问题,造成大量的token都被少数几个专家网络处理的情况,其他的专家网络占用了网络参数,但是却很难被激活。 ### 18.1.3 专家负载均衡 为了让专家负载均衡,人们想了很多办法。 训练时,每个token最少选择2个专家,选择概率最大的专家,然后在剩下的专家里按概率再选择一个专家进行训练。这样防止专家概率低,得不到训练,得不到训练,性能差,性能差被选择概率更小的恶性循环。 还有一种做法是给每个专家设置token容量,也就是在一个batch里每个专家能处理的最大token数。当每个专家处理的token到达容量限制后,则跳过对这个token的处理。这些被跳过的 token 在本层就不经过专家计算,直接通过残差连接带到下一层。 还有一种做法就是设置一个负载均衡的辅助损失。让模型在训练过程中自己学会负载均衡。 ### 18.1.4 负载均衡损失 负载均衡损失的目的就是希望每个专家被调用的频率是相等时损失最小。 第i个专家被调用的频率fif\_ifi​就等于该专家被调用的次数除以所有专家被调用的次数。 loss函数定义为: Lossbalance\=∑i\=1N(fi)2Loss\_{balance}=\\sum\_{i=1}^N(f\_i)^2Lossbalance​\=∑i\=1N​(fi​)2 我们通过一个例子来理解这个loss函数。 假设有两个专家,如果是最极端的情况,分配最不平衡的情况,每个token都调用第一个专家。 f1\=1;f2\=0;lossbalance\=12+02\=1f\_1=1;f\_2=0; \\qquad loss\_{balance}=1^2+0^2=1f1​\=1;f2​\=0;lossbalance​\=12+02\=1 稍微平衡一点的情况,第一个专家有0.8的概率被调用,第二个专家有0.2的概率被调用。 f1\=0.8;f2\=0.2;lossbalance\=0.82+0.22\=0.68f\_1=0.8;f\_2=0.2; \\qquad loss\_{balance}=0.8^2+0.2^2=0.68f1​\=0.8;f2​\=0.2;lossbalance​\=0.82+0.22\=0.68 绝对平衡的情况,两个专家都各有0.5的概率被调用到: f1\=0.5;f2\=0.5;lossbalance\=0.52+0.52\=0.5f\_1=0.5;f\_2=0.5; \\qquad loss\_{balance}=0.5^2+0.5^2=0.5f1​\=0.5;f2​\=0.5;lossbalance​\=0.52+0.52\=0.5 如果要严格证明为这么只有负载均衡时,这个loss最小,可以通过柯西不等式来证明。我们这里就不做证明了。 然而这个损失函数却不能直接被拿来当做MOE网络的损失函数。因为我们计算每个专家被调用的次数,是通过`torch.topk`这样取前几个最大值的方式来获取的。这个操作并不是数值计算,不可微分,无法通过梯度下降算法来进行优化。 作为改进,我们定义负载均衡的损失函数为: lossbalance\=∑i\=1Nfipiloss\_{balance}=\\sum\_{i=1}^N{f\_ip\_i}lossbalance​\=∑i\=1N​fi​pi​ 可以看到上式中将一个频率值fif\_ifi​替换为概率值pip\_ipi​。pip\_ipi​的值是一个批次中所有token对该专家的路由概率的平均值。理论上这里的概率平均值当token无穷大时就等于这个专家被选择的频率值。这样做有个好处,这个概率值是通过softmax数据计算得来的,是可以微分的,可以通过梯度下降来进行优化改进让所有专家负载均衡。 results matching "" =================== No results matching "" ====================== --- # 利用Transformer实现翻译模型 · GitBook [利用Transformer实现翻译模型](https://www.rethink.fun/) ================================================ 15.6 利用Transformer实现翻译模型 ------------------------ 在上一章里,我们用RNN训练了一个英文翻译中文的模型,这一节我们改为Transformer模型。 完整的训练代码可以从github下载[train.py](https://github.com/RethinkFun/DeepLearning/blob/master/chapter15/train.py) 。它依赖于[transformer.py](https://github.com/RethinkFun/DeepLearning/blob/master/chapter15/transformer.py) 。 ### 15.6.1 数据准备 我们用和上一节同样的数据,大家可以拷贝上一章的数据到本章节代码下。同时我们还是用上一章创建的中英文词表。 在定义Dataset时,代码也和上一章基本类似,但是有一点不同,最终生成的批量序列token id 张量时,Transformer的tensor形状是把batch放在第一个维度的,`batch_frist=True` 具体Dataset代码如下: class TranslationDataset(Dataset): ## 初始化方法,读取英文和中文训练文本。然后给每个句子前后增加。 为了防止训练时显存不足,对于长度超过限制的 ## 句子进行过滤。 def __init__(self, src_file, trg_file, src_tokenizer, trg_tokenizer, max_len=100): with open(src_file, encoding='utf-8') as f: src_lines = f.read().splitlines() with open(trg_file, encoding='utf-8') as f: trg_lines = f.read().splitlines() assert len(src_lines) == len(trg_lines) self.pairs = [] self.src_tokenizer = src_tokenizer self.trg_tokenizer = trg_tokenizer index = 0 for src, trg in zip(src_lines, trg_lines): index += 1 if index % 100000 == 0: print(index) # 每个句子前边增加后边增加 src_ids = [BOS_ID] + self.src_tokenizer(src) + [EOS_ID] trg_ids = [BOS_ID] + self.trg_tokenizer(trg) + [EOS_ID] # 只保留输入和输出序列token数同时小于max_len的训练样本。 if len(src_ids) <= max_len and len(trg_ids) <= max_len: self.pairs.append((src_ids, trg_ids)) # <-- 直接保存token id序列 def __len__(self): return len(self.pairs) def __getitem__(self, idx): src_ids, trg_ids = self.pairs[idx] return torch.LongTensor(src_ids), torch.LongTensor(trg_ids) ## 对一个batch的输入和输出token序列,依照最长的序列长度,用 token进行填充,确保一个batch的数据形状一致,组成一个tensor。 @staticmethod def collate_fn(batch): src_batch, trg_batch = zip(*batch) src_lens = [len(x) for x in src_batch] trg_lens = [len(x) for x in trg_batch] ## 注意,Transformer里的tensor,设置batch_frist=True。 src_pad = nn.utils.rnn.pad_sequence(src_batch, batch_first=True, padding_value=PAD_ID) trg_pad = nn.utils.rnn.pad_sequence(trg_batch, batch_first=True,padding_value=PAD_ID) return src_pad, trg_pad, src_lens, trg_lens Transformer训练时,会传递一个Mask矩阵,Mask矩阵的作用有两个: * 对于Encoder输入的序列,在计算自注意力时,不关注`` token。 * 对于Decoder输入的序列,在进行自注意力时,不关注`` token 和当前 token 后边的 token。 生成Mask的代码: def create_mask(src, tgt, pad_idx): # mask token for encoder. src_mask = (src != pad_idx).unsqueeze(1).unsqueeze(2) # (batch, 1, 1, src_len) # mask token for decoder. tgt_pad_mask = (tgt != pad_idx).unsqueeze(1).unsqueeze(2) # (batch, 1, 1, tgt_len) tgt_len = tgt.size(1) # decoder mask 当前token后边的token。 tgt_sub_mask = torch.tril(torch.ones((tgt_len, tgt_len), device=tgt.device)).bool() # (tgt_len, tgt_len) # decoder 同时mask token, 以及当前token后边的token。 tgt_mask = tgt_pad_mask & tgt_sub_mask # (batch, 1, tgt_len, tgt_len) return src_mask, tgt_mask ### 15.6.2 训练代码 相信你可以看懂下边的代码,我们不做过多解释。得益于Transformer的并行化,训练时间比训练RNN要快不少。 def train(model, dataloader, optimizer, criterion, pad_idx): model.train() total_loss = 0 step = 0 log_loss = 0 # 用于每100步统计 for src, tgt, src_lens, tgt_lens in dataloader: step += 1 src = src.to(DEVICE) tgt = tgt.to(DEVICE) tgt_input = tgt[:, :-1] tgt_output = tgt[:, 1:] src_mask, tgt_mask = create_mask(src, tgt_input, pad_idx) optimizer.zero_grad() encoder_output = model.encode(src, src_mask) decoder_output = model.decode(encoder_output, src_mask, tgt_input, tgt_mask) output = model.project(decoder_output) output = output.reshape(-1, output.shape[-1]) tgt_output = tgt_output.reshape(-1) loss = criterion(output, tgt_output) loss.backward() optimizer.step() total_loss += loss.item() log_loss += loss.item() if step % 100 == 0: avg_log_loss = log_loss / 100 print(f"Step {step}: Avg Loss = {avg_log_loss:.4f}") log_loss = 0 # 重置每100步的loss计数 return total_loss / len(dataloader) def main(): # 超参数 SRC_VOCAB_SIZE = 16000 TGT_VOCAB_SIZE = 16000 SRC_SEQ_LEN = 128 TGT_SEQ_LEN = 128 BATCH_SIZE = 2 NUM_EPOCHS = 10 LR = 1e-4 # 数据集加载 train_dataset = TranslationDataset('valid_en.txt', 'valid_zh.txt',tokenize_en, tokenize_cn) train_dataloader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, collate_fn=train_dataset.collate_fn) # 构建模型 model = build_transformer(SRC_VOCAB_SIZE, TGT_VOCAB_SIZE, SRC_SEQ_LEN, TGT_SEQ_LEN).to(DEVICE) optimizer = optim.Adam(model.parameters(), lr=LR) criterion = nn.CrossEntropyLoss(ignore_index=PAD_ID) for epoch in range(NUM_EPOCHS): loss = train(model, train_dataloader, optimizer, criterion, PAD_ID) print(f"Epoch {epoch+1}/{NUM_EPOCHS} - Loss: {loss:.4f}") torch.save(model.state_dict(), "transformer.pt") ### 15.6.3 模型测试 你可以通过[inference.py](https://github.com/RethinkFun/DeepLearning/blob/master/chapter15/inference.py) 来手动测试模型的英译中的能力,相信通过对比,你可以发现它的能力比同样的RNN模型要好不少。 同时,你也可以测评它的BLEU score。具体代码和上一章的代码一样, [BLEU\_score.py](https://github.com/RethinkFun/DeepLearning/blob/master/chapter15/BLEU_score.py) 。它依赖于上边的inference.py。通过运行BLEU score,你也可以发现模型性能比RNN的翻译模型要提升不少。 * * * 恭喜你,你已经训练了一个你自己的Transformer模型! 扫码请作者喝一杯咖啡来分享你的喜悦吧! ![zsm](http://rethink.fun/imgs/zsm.png) results matching "" =================== No results matching "" ====================== --- # 把这一切组装起来 · GitBook [把这一切组装起来](https://www.rethink.fun/) ===================================== 15.5 把这一切组装起来 ------------- 之前章节我们讲了Transformer模型里的大部分组件,这一节我们就把它们组装起来。 ### 15.5.1 整体架构 ![1510.png](http://rethink.fun/imgs/1510.png) 上图来自Transformer的论文,它整体上分为左右两部分。左半部分是编码器Encoder部分,右边部分是解码器Decoder部分。 ### 15.5.2 编码器 ![1511.png](http://rethink.fun/imgs/1511.png) 我们首先来看编码器部分。对于将英文翻译为中文的这个序列到序列问题。编码器部分输入的是一个batch的token id列表,还有这个batch token 列表对应的mask,mask用来标志哪些token是填充的`` token。`` token在进行注意力计算时将被忽略。 接下来利用Embedding模块,根据每个token的id,转化为token的embedding,然后加上每个token的位置编码信息。输出的表达token embedding的tensor 形状为\[batch\_size,seq\_len,d\_k\]。其中d\_k为embedding的维度,标准Transformer里是512。 接下来就进入N个EncoderBlock,标准Transformer里N为6。每个EncoderBlock的输入为表示inputs embedding张量,它的维度为\[batch\_size,seq\_len,d\_k\]。在EncoderBlock内部,根据自注意模块和全连接模块的embedding更新后,保持维度不变,还是\[batch\_size,seq\_len,d\_k\]。因为EncoderBlock输入和输出的张量维度一致,所以可以堆叠多层。 我们来查看一下EncoderBlock内部的细节,首先是一个我们之前讲过的多头自注意里层,然后是一个残差连接和Layer Norm。我们之前讲过残差连接,它可以让更深的层容易训练。然后是一个全连接层,同样后边有一个残差连接和Layer Norm。 全连接模块实现: class FeedForwardBlock(nn.Module): def __init__(self, d_model: int, d_ff: int, dropout: float) -> None: super().__init__() self.linear_1 = nn.Linear(d_model, d_ff) self.dropout = nn.Dropout(dropout) self.linear_2 = nn.Linear(d_ff, d_model) def forward(self, x): # (batch, seq_len, d_model) --> (batch, seq_len, d_ff) --> (batch, seq_len, d_model) return self.linear_2(self.dropout(torch.relu(self.linear_1(x)))) 全连接模块里定义了两个层,第一个层将每个token embedding的维度从512扩展到2048,然后应用了ReLU激活,接着进行dropout。第二层将每个token embedding的维度从2048重新降维到512。 Add & Norm 模块实现: class ResidualConnection(nn.Module): def __init__(self, features: int, dropout: float) -> None: super().__init__() self.dropout = nn.Dropout(dropout) self.norm = LayerNormalization(features) def forward(self, x, sublayer): return x + self.dropout(sublayer(self.norm(x))) 上边代码中传入的sublayer,可以是多头注意力模块,也可以是全连接模块。可以看到在前向传播时,先对输入的序列进行Layer Norm,然后进入sublayer,加一个dropout,最后和输入序列进行一个残差连接。 ### 15.5.3 编码器的完整代码 class EncoderBlock(nn.Module): def __init__(self, features: int, self_attention_block: MultiHeadAttentionBlock, feed_forward_block: FeedForwardBlock, dropout: float) -> None: super().__init__() # 定义多头自注意力模块 self.self_attention_block = self_attention_block # 定义全连接模块 self.feed_forward_block = feed_forward_block # 定义两个Add & Norm模块 self.residual_connections = nn.ModuleList([ResidualConnection(features, dropout) for _ in range(2)]) def forward(self, x, src_mask): # 第一个残差连接,跳过多头注意力模块 x = self.residual_connections[0](x, lambda x: self.self_attention_block(x, x, x, src_mask)) # 第二个残差连接,跳过全连接模块 x = self.residual_connections[1](x, self.feed_forward_block) return x class Encoder(nn.Module): def __init__(self, features: int, layers: nn.ModuleList) -> None: super().__init__() # 传入的6个EncoderBlock self.layers = layers self.norm = LayerNormalization(features) def forward(self, x, mask): # 依次调用6个EncoderBlock for layer in self.layers: x = layer(x, mask) # 输出前进行Layer Norm return self.norm(x) ### 15.5.4 编码器的总结 编码器的作用是根据上下文,不断更新输入序列中每个token的Embedding。让最终输出序列token的embedding有最佳的语义。因为自注意力机制,可以一次性输入**整个序列**,序列内多个token可以**并行处理**,大大加快了处理速度。其中使用了位置编码增加了token的位置信息。通过多头注意力让token可以根据上下文信息修改自身embedding。通过全连接模块让每个token更新自身embedding。通过残差连接和LayerNorm让深度网络更容易训练。 ### 15.5.5 解码器 ![1510.png](http://rethink.fun/imgs/1510.png) 以英文翻译中文任务为例,解码器的作用就是参考编码器的输出,以及自身已经翻译的内容,生成下一个中文token。 ![1512.png](http://rethink.fun/imgs/1512.png) 解码器的输入包含两个部分: **编码器输出** 编码器输出的是英文序列里每个token的embedding。它的维度为512。经过多层编码器的自注意力机制,每个token的embedding都已经根据上下文,计算出恰当的语义信息。它们将作为解码器输出的重要参考。 **已经翻译出来的token序列** Transformer的编码器可以一次性输入完整的英文token序列,但在模型进行实际翻译时,需要解码器逐个生成对应的中文token序列。``作为解码器的初始输入,代表序列开始。在上图例子里,我们希望模型输入``,能输出“我”这个token。然后将“``, 我”这两个token 序列作为输入,希望模型能输出“爱”这个token。依次类推,直到模型输出``这个token。当然在这个过程中的每一步解码器也同时参考了编码器给出的英文token 序列的embedding。 对于Transformer模型在推理时,解码器确实如上述过程所述,是逐个生成中文token的。但是在训练时,因为我们已经知道英文对应的中文token序列,所以我们可以通过一种叫做**带掩码的多头注意力机制**(Masked Multi-Head Attention,MMHA)来实现并行化训练。 对于上图中中文token序列:`` | 我 | 爱 | 吃 | 苹果,一共有5个token,我们可以构造一个下三角矩阵: ![1513.png](http://rethink.fun/imgs/1513.png) Mask矩阵每行表示当前token可以看到的token,1代表可以看到,0代表看不到。 比如第一行只有第一个位置为1,代表第一个token `` 只能看到第一个token,也就是自己本身。 第二个token 可以看到前两个token。 Transformer里只有注意力计算时是跨token,根据上下文更新自身embedding的。在进行注意计算时,不光传入Q,K,V矩阵,还需要传入这个Mask矩阵。让每个token在注意里计算时,根据mask矩阵,只关注自己以及前边的token。这样在一次训练里就可以对整个中文序列一次性进行训练了。 在实际代码实现时,先利用Q、K矩阵进行注意力计算,在softmax()前,把Mask矩阵为0的位置的注意力结果填充成一个非常大的负值,这样经过softmax()后,这些位置的值就都为0。在接下来计算当前token的特征值时,序列中Mask为0的token的权重就为0,代码如下: def attention(query, key, value, mask, dropout: nn.Dropout): d_k = query.shape[-1] # (batch, h, seq_len, d_k) --> (batch, h, seq_len, seq_len) attention_scores = (query @ key.transpose(-2, -1)) / math.sqrt(d_k) if mask is not None: # 给mask为0的位置填入一个很大的负值 attention_scores.masked_fill_(mask == 0, -1e9) # (batch, h, seq_len, seq_len) attention_scores = attention_scores.softmax(dim=-1) if dropout is not None: attention_scores = dropout(attention_scores) # (batch, h, seq_len, seq_len) --> (batch, h, seq_len, d_k) return (attention_scores @ value), attention_score Mask机制只能在训练时一次性对解码器所有位置的token进行训练。但是在模型训练好进行生成时,因为事先并不知道答案,只能一步生成一个新的token,然后拼接到解码器的输入,再生成下一个。 **交叉注意力 Cross-Attention** 观察Transformer模型架构图的Decoder Block部分,它也是6个,每个Decoder Block有两个注意力模块,一个全连接模块。我们上边讲了第一个注意力模块,Masked Multi-Head Attention模块,下边我们看关键的第二个注意力模块。 之前我们讲RNN的注意力时讲过,解码器是和编码器的输出计算注意力,然后取解码器的输出的注意力加权值。这种注意力计算是两个不同序列的,我们叫做交叉注意力 Cross-Attention。 Corss-Attention是非常重要的一步,它是解码器从编码器获取信息的唯一途径。从模型架构图中可以看到,交叉注意力模块的K和V矩阵来自编码器的输出。Q矩阵来自解码器部分。所以解码器根据当前翻译的需要提出查询向量q,和所有编码器输出的k进行匹配,计算注意力。最终得到编码器输出的v的注意力加权值。 def forward(self, x, encoder_output, src_mask, tgt_mask): # 第一个自注意力模块,Q、K、V都来自自身。 x = self.residual_connections[0](x, lambda x: self.self_attention_block(x, x, x, tgt_mask)) # 第二个交叉注意力模块的Q矩阵来自Decoder,K,V矩阵来自Encoder的输出。 x = self.residual_connections[1](x, lambda x: self.cross_attention_block(x, encoder_output,encoder_output, src_mask)) # 全连接模块。 x = self.residual_connections[2](x, self.feed_forward_block) return x 解码器最终会接一个线性分类头,输入是512,输出维度是字典的大小。线性层的输出再经过softmax()之后,就是字典里每个token作为输出下一个token的概率值。 ### 15.5.6 解码器实现 class DecoderBlock(nn.Module): def __init__(self, features: int, self_attention_block: MultiHeadAttentionBlock, cross_attention_block: MultiHeadAttentionBlock, feed_forward_block: FeedForwardBlock, dropout: float) -> None: super().__init__() self.self_attention_block = self_attention_block self.cross_attention_block = cross_attention_block self.feed_forward_block = feed_forward_block self.residual_connections = nn.ModuleList([ResidualConnection(features, dropout) for _ in range(3)]) def forward(self, x, encoder_output, src_mask, tgt_mask): x = self.residual_connections[0](x, lambda x: self.self_attention_block(x, x, x, tgt_mask)) # 交叉注意力模块的Q矩阵来自Decoder,K,V矩阵来自Encoder的输出 x = self.residual_connections[1](x, lambda x: self.cross_attention_block(x, encoder_output,encoder_output, src_mask)) x = self.residual_connections[2](x, self.feed_forward_block) return x class Decoder(nn.Module): def __init__(self, features: int, layers: nn.ModuleList) -> None: super().__init__() self.layers = layers self.norm = LayerNormalization(features) def forward(self, x, encoder_output, src_mask, tgt_mask): for layer in self.layers: x = layer(x, encoder_output, src_mask, tgt_mask) return self.norm(x) ### 15.5.7 完整代码 完整代码可以从github下载[transformer.py](https://github.com/RethinkFun/DeepLearning/blob/master/chapter15/transformer.py) 。 * * * 恭喜你,你已经掌握了Transformer的原理! 扫码请作者喝一杯咖啡来分享你的喜悦吧! ![zsm](http://rethink.fun/imgs/zsm.png) results matching "" =================== No results matching "" ====================== --- # DeepSeekV2 · GitBook [DeepSeekV2](https://www.rethink.fun/) ======================================= 18.3 DeepSeekV2 --------------- DeepSeekV2的论文题目为: A Strong, Economical, and Efficient Mixture-of-Experts Language Model 可以翻译为《一种强大、经济且高效的混合专家语言模型》。这一节我们就来看一下DeepSeekV2都做了什么改进。 ### 18.3.1 模型架构 ![1817.png](http://rethink.fun/imgs/1817.png) 可以看到DeepSeekV2是由多个Transformer Block构成。每个Transformer Block里由RMS Norm, Attention, Feed-Forward Network几个模块,以及残差连接构成。 上一节我们讲了DeepSeek-MOE对Feed-Forward Network模块进行了修改,它由多个专精的专家以及共享专家构成。这个改动在DeepSeek V2里依然保留。同时,在DeepSeek V2里也对注意力层进行了修改。提出了MLA (Multi-Head Leitent Attention),多头潜在注意力机制。 ### 18.3.2 KV Cache 之前我们提到过KV Cache。它是一种为了加快模型推理时生成速度,减少不必要重复计算的技术。因为语言模型逐个token生成的原理,每次生成一个token,并将新生成的token拼接到原始输入,再生成下一个token。但是因为序列前边的token,在mask机制的限制下是看不到后边token的。所以后边新生成的token,并不会改变之前token在Transformer Block里每层的计算。但是后边生成新的token时,是可以看到它前边的token的。需要和前边的token的k,v向量进行注意力计算,生成新token的上下文表示向量。所以,就将每个token各层的k和v向量进行存储,避免每次重复计算,并可被新生成token时计算注意力时使用。 ### 18.3.3 MQA,GQA,MHA 随着序列的生成,KV Cache会逐渐增大,导致显存不足,不能进行更长序列的生成。之前我们介绍过有一种技术可以减少KV Cache,那就是多查询注意力(MQA)和分组注意力机制(GQA)。这种技术通过在多头之间共享k,v向量的方式相比多头注意力机制(MHA)减小了KV Cache,同时也减少了生成k,v向量时的计算量。 ![1707.png](http://rethink.fun/imgs/1707.png) ![1810.gif](http://rethink.fun/imgs/1810.gif) 以多查询注意力机制为例,对于一个token而言,通过一个完整的WQW^QWQ权重矩阵,生成多头的q向量。然后通过一个小的WKW^KWK矩阵生成一个头大小的k向量,再通过一个小的WVW^VWV向量生成一个头大小的v向量,然后将一个头大小的k,v向量共享给多个头。这样可以达到多个效果,减少了WK,WVW^K,W^VWK,WV的参数量,减少了生成k,v向量的计算量,减少了KV Cache的缓存量,减小了缓存占用。 但是DeepSeek经过试验表明,这种多个头之间共享k,v向量的做法是以性能衰减为代价的。 ![1811.png](http://rethink.fun/imgs/1811.png) 可以看到同等参数量的MQA,GQA,MHA,还是传统的多头注意力机制(MHA)效果最好,MQA共享最多,性能也最差。 ### 18.3.4 MLA DeepSeek找到一种可以减少参数量,计算量,缓存量,并且不影响模型性能,甚至模型性能还能提升的方法,那就是多头潜在注意力机制,MLA (Multi-Head Leitent Attention)。 ![1818.png](http://rethink.fun/imgs/1818.png) 它的原理非常简单,就是对token的特征向量首先通过一个参数矩阵进行压缩转化,这个参数我们把它叫做WDKVW^{DKV}WDKV,上标中的D是down的意思,是向下压缩的意思,KV,就是k向量和v向量的意思。比如这里原来特征维度为6,经过WDKVW^{DKV}WDKV,压缩到2维。然后缓存时只需要缓存这个2维的kv压缩特征。在进行计算时,需要用到真实的k和v向量时,再从KV压缩向量通过两个解压矩阵转化为原来的维度就可以了。把KV压缩向量进行解压,投影到实际K向量维度的参数矩阵叫做WUKW^{UK}WUK,上表中的U代表up的意思,向上升维,k代表k向量。同理,对v向量进行解压升维的参数矩阵叫做WUVW^{UV}WUV。 ![1812.png](http://rethink.fun/imgs/1812.png) 通过上边的图可以看到通过与MHA,MQA相比,MLA是缓存量最小的一种技术。同时DeepSeek也在小参数量模型(15.8B)和大参数量(250B)做了测试,MLA的模型性能竟然比MHA好要好,如下图所示: ![1813.png](http://rethink.fun/imgs/1813.png) ### 18.3.5 计算合并 ![1814.png](http://rethink.fun/imgs/1814.png) 这一切看起来都非常不错,但是KV Cache的本意是什么呢?它是为了减少推理时对之前token k和v向量的计算而产生的。MLA因为缓存了压缩的KV Cache而减少了KV Cache的显存占用,但是在取出缓存后,不能直接用,还要经过解压计算才可以。这不是在推理时又引入了额外的计算吗?这和KV Cache的初衷相悖。 ![1815.png](http://rethink.fun/imgs/1815.png) 我们看一下带KV Cache的推理过程,如上图,对于标准的MHA,对于当前的token,计算Q,K,V,然后存储KV,对于之前的token,直接从缓存取出K和V向量就可以,然后进行attention的计算。 ![1816.png](http://rethink.fun/imgs/1816.png) 对于MLA,当前token的计算,Q的计算不变。但在K和V的计算时,先通过WDKVW^{DKV}WDKV参数矩阵进行压缩,然后生成压缩的KV的隐特征CKVC^{KV}CKV。并将CKVC^{KV}CKV存储KV Cache。K,V向量通过将KV压缩隐特征CKVC^{KV}CKV与K向量的解压参数矩阵WUKW^{UK}WUK,V向量的解压参数矩阵WUVW^{UV}WUV进行相乘,得到当前token,可用于注意力集散的K和V向量。 对于之前的token,则从KV Cache里取出压缩的隐特征向量C^{KV},然后经过k,v向量的解压参数矩阵投影,得到可以计算的k,v向量。 所以进行注意力计算时,它的公式如上图右边所示,我们主要关注这里Q和K的转置进行的矩阵计算,代入Q\=XWQQ=XW^{Q}Q\=XWQ, K 等于CKVWUKC^{KV}W^{UK}CKVWUK。然后将这个转置符号代入括号里的两个矩阵,交换顺序,得到表达式XWQWUKTCKVTXW^QW^{UK^T}C^{KV^T}XWQWUKTCKVT。可以发现,对K进行解压操作的矩阵WUKTW^{UK^T}WUKT可以和WQW^QWQ矩阵融合,这个融合矩阵WQUKW^{QUK}WQUK可以提前计算好,这样在推理时就不用额外进行对K的解压计算了。 这样我们通过矩阵相乘的结合律,对矩阵提前进行融合,就可以规避MLA引入的推理时因为解压隐特征带来的额外计算了。刚才我们详细看了WUKW^{UK}WUK可以和WQW^QWQ进行融合,同样对V向量进行解压的WUVW^{UV}WUV也可以和注意力模块计算出来的注意力加权向量最后经过的那线性层权重WOW^OWO进行融合。 ![1819.png](http://rethink.fun/imgs/1819.png) MLA除了对K和V进行压缩外,对Q向量也进行了压缩,这样的好处是降低了参数量,而且可以提高模型性能。可以看到这里通过WDQ对q向量进行压缩,通过WUQ对Q向量进行解压。但是Q的隐向量并不需要缓存,只需要缓存KV共用的KV压缩隐向量。 ### 18.3.6 增加旋转位置编码 很好,现在似乎什么问题都解决了,KV Cache减小了,模型表现还得到提升。但是好事多磨,刚才我们一直没有讨论位置编码,确切的说是旋转位置编码。我们知道旋转位置编码需要对每一层的q向量和k向量进行旋转。而且根据token位置的不同,旋转矩阵的参数也不同。这里以第i个token的q向量和第j个token的k向量进行点积运算为例,图示如下: ![1820.png](http://rethink.fun/imgs/1820.png) 如果不考虑旋转位置编码,就是之前我们讲过的,对K进行解压的矩阵可以和WQW^QWQ矩阵融合。但是如果考虑旋转矩阵呢?因为不同位置旋转矩阵不同,这里我们用RiR\_iRi​和RjR\_jRj​代表第i和第j个token位置的旋转矩阵。可以发现如果增加了旋转矩阵,它就出现在了WQW^QWQ和WUKTW^{UK^T}WUKT之间,而且因为RiR\_iRi​和RjR\_jRj​和位置相关无法一起融合。所以它破坏了之前想到的推理时矩阵提前融合的方案。 DeepSeek最终想到了一个方案,就是给Q和K向量额外增加一些维度来表示位置信息。 ![1821.gif](http://rethink.fun/imgs/1821.gif) 对于Q向量,它通过WQRW^QRWQR权重(这里上标Q代表Q向量,R代表旋转位置编码)为每个头生成一些原始特征。然后通过旋转位置编码增加位置信息,再把生成的带位置信息的特征拼接到每个注意力头的q向量。 对于K向量,通过WKRW^{KR}WKR矩阵生成一个头,共享的特征,然后通过旋转位置编码增加位置信息,然后复制到多个头,共享位置信息。这里多头共享的带位置编码的额外增加的k向量也需要被缓存,以便计算时拼接完整k向量时用到。 可以看到最终处理完后,q和k向量一样长,它们和v向量不一样长。这没有关系,q和k进行点积运算。最后生成对v向量的权重,对v向量进行加权求和即可。 最后,我们再来看一下原始论文里的MLA的示例图。 ![1821.png](http://rethink.fun/imgs/1821.png) 注意图中阴影部分为需要缓存的中间变量,其中需要缓存的只有KV共用的压缩隐特征,以及K的多头共享的带位置编码的额外向量。 results matching "" =================== No results matching "" ====================== ---