最近一直在做类ChatGPT项目的部署 微调,关注比较多的是两个:一个LLaMA,一个ChatGLM,会发现有不少模型是基于这两个模型去做微调的,说到微调,那具体怎么微调呢,因此又详细了解了一下微调代码,发现微调LLM时一般都会用到Hugging face实现的Transformers库的Trainer类
从而发现,如果大家想从零复现ChatGPT,便得从实现Transformer开始,因此便开启了本文:如何从零起步实现Transformer、ChatGLM(至于LLaMA已在之前的博客里解读过),主要分为两个大部分
总之,一如既往的保持对初学者的足够友好,让即便没有太多背景知识的也能顺畅理解本文
transformer强大到什么程度呢,基本是17年之后绝大部分有影响力模型的基础架构都基于的transformer(比如,这里有200来个,包括且不限于基于decode的GPT、基于encode的BERT、基于encode-decode的T5等等)
通过博客内的这篇文章《Transformer通俗笔记:从Word2Vec、Seq2Seq逐步理解到GPT、BERT》,我们已经详细了解了transformer的原理(如果忘了,建议必复习下再看本文,当然,如果你实在不想跳转,就只想呆在本文,也行,我努力..)
如果把上图中的各种细节也显示出来,则如下大图所示(此大图来源于七月在线NLP11里倪老师讲的Transformer模型源码解读,positional encoding、多头等没画)

考虑到Hugging face实现的Transformers库虽然功能强大,但3000多行,对于初次实现的初学者来说,理解难度比较大,因此,咱们一步步结合对应的原理来逐行编码实现一个简易版的transformer
为了方便后面代码的编写,先引入一些库
import numpy as np # 导入NumPy库,用于进行矩阵运算和数据处理
import torch # 导入PyTorch库,用于构建神经网络及相关操作
import torch.nn as nn # 导入PyTorch神经网络模块,用于构建神经网络层
import torch.nn.functional as F # 导入PyTorch神经网络函数库,用于激活函数、损失函数等
import math, copy, time # 导入数学库、复制库和时间库,用于各种数学计算、复制操作和计时
from torch.autograd import Variable # 从PyTorch自动微分库中导入Variable类,用于构建自动微分计算图
import matplotlib.pyplot as plt # 导入Matplotlib的pyplot模块,用于绘制图表和可视化
import seaborn # 导入Seaborn库,用于绘制统计图形和美化图表
seaborn.set_context(context="talk") # 设置Seaborn的上下文环境,设置图表的尺寸和标签字体大小等
%matplotlib inline # IPython魔术命令,使Matplotlib绘制的图形直接显示在Notebook内
对于模型来说,每一句话比如“七月的服务真好,答疑的速度很快”,在模型中都是一个词向量,但如果每句话都临时抱佛脚去生成对应的词向量,则处理起来无疑会费时费力,所以在实际应用中,我们会事先预训练好各种embedding矩阵,这些embedding矩阵包含常用领域常用单词的向量化表示,且提前做好分词
| 维度1 | 维度2 | 维度3 | 维度4 | ... | 维度512 | |
| 教育 | ||||||
| 机构 | ||||||
| 在线 | ||||||
| 课程 | ||||||
| .. | ||||||
| 服务 | ||||||
| 答疑 | ||||||
| 老师 |
从而当模型接收到“七月的服务真好,答疑的速度很快”这句输入时,便可以从对应的embedding矩阵里查找对应的词向量,最终把整句输入转换成对应的向量表示
这部分的代码 可以如下表示
# 定义一个名为Embeddings的类,继承自PyTorch的nn.Module类
class Embeddings(nn.Module):
# 初始化Embeddings类
def __init__(self, d_model, vocab):
# 调用父类nn.Module的初始化方法
super(Embeddings, self).__init__()
# 创建一个词嵌入层,参数为词汇表大小和词嵌入维度
self.lut = nn.Embedding(vocab, d_model)
# 将词嵌入维度保存为类属性
self.d_model = d_model
# 定义前向传播方法
def forward(self, x):
# 通过词嵌入层将输入的单词编码为向量,并乘以词嵌入维度的平方根进行缩放
return self.lut(x) * math.sqrt(self.d_model)
然,如此篇文章所述,RNN的结构包含了序列的时序信息,而Transformer却完全把时序信息给丢掉了,比如“他欠我100万”,和“我欠他100万”,两者的意思千差万别,故为了解决时序的问题,Transformer的作者用了一个绝妙的办法:位置编码(Positional Encoding)。
即将每个位置编号,从而每个编号对应一个向量,最终通过结合位置向量和词向量,作为输入embedding,就给每个词都引入了一定的位置信息,这样Attention就可以分辨出不同位置的词了,具体怎么做呢?

至于是embedding向量的位置下标对2求商并取整(可用双斜杠
表示整数除法,即求商并取整),它的取值范围是
,比如
,
,
,
,
,
,
...,
,
不要小看transformer的这个位置编码,不少做NLP多年的人也不一定对其中的细节有多深入,而网上大部分文章谈到这个位置编码时基本都是千篇一律、泛泛而谈,很少有深入,故本文还是细致探讨下
考虑到一图胜千言 一例胜万语,举个例子,当我们要编码「我 爱 你」的位置向量,假定每个token都具备512维,如果位置下标从0开始时,则根据位置编码的计算公式可得『且为让每个读者阅读本文时一目了然,我计算了每个单词对应的位置编码示例(在此之前,这些示例在其他地方基本没有)』

然后再叠加上embedding向量,可得

最终得到的可视化效果如下图所示

代码实现如下
“”“位置编码的实现,调用父类nn.Module的构造函数”“”
class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout, max_len=5000):
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(p=dropout) # 初始化dropout层
# 计算位置编码并将其存储在pe张量中
pe = torch.zeros(max_len, d_model) # 创建一个max_len x d_model的全零张量
position = torch.arange(0, max_len).unsqueeze(1) # 生成0到max_len-1的整数序列,并添加一个维度
# 计算div_term,用于缩放不同位置的正弦和余弦函数
div_term = torch.exp(torch.arange(0, d_model, 2) *
-(math.log(10000.0) / d_model))
# 使用正弦和余弦函数生成位置编码,对于d_model的偶数索引,使用正弦函数;对于奇数索引,使用余弦函数。
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0) # 在第一个维度添加一个维度,以便进行批处理
self.register_buffer('pe', pe) # 将位置编码张量注册为缓冲区,以便在不同设备之间传输模型时保持其状态
# 定义前向传播函数
def forward(self, x):
# 将输入x与对应的位置编码相加
x = x + Variable(self.pe[:, :x.size(1)],
requires_grad=False)
# 应用dropout层并返回结果
return self.dropout(x)
从下图可知,经过「embedding + 位置编码」得到的输入,会乘以「三个权重矩阵:
」得到查询向量Q、键向量K、值向量V(你可以简单粗暴的理解为弄出来了三个分身)

举个例子,针对「我想吃酸菜鱼」这句话,经过embedding + 位置编码后,可得(注:可以512维,也可以是768维,但由于transformer论文中作者设置的512维,所以除了这个酸菜鱼的例子暂为768维外,其他地方均统一为512维)

然后乘以三个权重矩阵得

为此,我们可以先创建4个相同的线性层,每个线性层都具有 d_model 的输入维度和 d_model 的输出维度
self.linears = clones(nn.Linear(d_model, d_model), 4)
前三个线性层分别用于对 Q向量、K向量、V向量进行线性变换(至于这第4个线性层在随后的第3点)
我们聚焦下transformer论文中原图的这部分,可知,输入通过embedding+位置编码后,先后做以下两个步骤

attention = self.attention(query, key, value, mask)
output = self.dropout(self.norm1(attention + query)) 这个相加具体是怎么个相加法呢?事实上,Add代表的Residual Connection(残差连接),是为了解决多层神经网络训练困难的问题,通过将前一层的信息无差的传递到下一层,可以有效的仅关注差异部分,这一方法之前在图像处理结构如ResNet等中常常用到 
"""一个残差连接(residual connection),后面跟着一个层归一化(layer normalization)操作"""
class SublayerConnection(nn.Module):
# 初始化函数,接收size(层的维度大小)和dropout(dropout率)作为输入参数
def __init__(self, size, dropout):
super(SublayerConnection, self).__init__() # 调用父类nn.Module的构造函数
self.norm = LayerNorm(size) # 定义一个层归一化(Layer Normalization)操作,使用size作为输入维度
self.dropout = nn.Dropout(dropout) # 定义一个dropout层
# 定义前向传播函数,输入参数x是输入张量,sublayer是待执行的子层操作
def forward(self, x, sublayer):
# 将残差连接应用于任何具有相同大小的子层
# 首先对输入x进行层归一化,然后执行子层操作(如self-attention或前馈神经网络)
# 接着应用dropout,最后将结果与原始输入x相加。
return x + self.dropout(sublayer(self.norm(x))) 而Norm则代表了Layer Normalization,通过对层的激活值的归一化,可以加速模型的训练过程,使其更快的收敛,编码时用 LayerNorm 函数实现 """构建一个层归一化(layernorm)模块"""
class LayerNorm(nn.Module):
# 初始化函数,接收features(特征维度大小)和eps(防止除以零的微小值)作为输入参数
def __init__(self, features, eps=1e-6):
super(LayerNorm, self).__init__() # 调用父类nn.Module的构造函数
self.a_2 = nn.Parameter(torch.ones(features)) # 定义一个大小为features的一维张量,初始化为全1,并将其设置为可训练参数
self.b_2 = nn.Parameter(torch.zeros(features)) # 定义一个大小为features的一维张量,初始化为全0,并将其设置为可训练参数
self.eps = eps # 将防止除以零的微小值eps保存为类实例的属性
# 定义前向传播函数,输入参数x是输入张量
def forward(self, x):
mean = x.mean(-1, keepdim=True) # 计算输入x在最后一个维度上的均值,保持输出结果的维度
std = x.std(-1, keepdim=True) # 计算输入x在最后一个维度上的标准差,保持输出结果的维度
# 对输入x进行层归一化,使用可训练参数a_2和b_2进行缩放和偏移,最后返回归一化后的结果
return self.a_2 * (x - mean) / (std + self.eps) + self.b_2 forward = self.feed_forward(output)
block_output = self.dropout(self.norm2(forward + output))
return block_output 总而言之,上述过程用公式表达则如下
第一步中的X代表Multi-Head Attention,第二步中的X代表FFN(本质上就是一个全连接层MLP),最终这个编码器层代码可以完整的写为
"""编码器(Encoder)由自注意力(self-attention)层和前馈神经网络(feed forward)层组成"""
class EncoderLayer(nn.Module):
# 初始化函数,接收size(层的维度大小)、self_attn(自注意力层实例)
# feed_forward(前馈神经网络实例)和dropout(dropout率)作为输入参数
def __init__(self, size, self_attn, feed_forward, dropout):
super(EncoderLayer, self).__init__() # 调用父类nn.Module的构造函数
self.self_attn = self_attn # 将自注意力层实例保存为类实例的属性
self.feed_forward = feed_forward # 将前馈神经网络实例保存为类实例的属性
# 创建两个具有相同参数的SublayerConnection实例(用于残差连接和层归一化)
self.sublayer = clones(SublayerConnection(size, dropout), 2)
self.size = size # 将层的维度大小保存为类实例的属性
def forward(self, x, mask):
# 先对输入x进行自注意力操作
# 然后将结果传递给第一个SublayerConnection实例(包括残差连接和层归一化)
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
# 将上一步的输出传递给前馈神经网络
# 然后将结果传递给第二个SublayerConnection实例(包括残差连接和层归一化),最后返回结果
return self.sublayer[1](x, self.feed_forward)
接下来,先看下缩放点积注意力(Scaled Dot-Product Attention)的整体实现步骤




# torch.matmul是PyTorch库提供的矩阵乘法函数
# 具体操作即是将第一个矩阵的每一行与第二个矩阵的每一列进行点积(对应元素相乘并求和),得到新矩阵的每个元素
scores = torch.matmul(query, key.transpose(-2, -1)) \
/ math.sqrt(d_k) 
# 对 scores 进行 softmax 操作,得到注意力权重 p_attn
p_attn = F.softmax(scores, dim = -1) 


# 用注意力权重 p_attn 对 value 向量进行加权求和,得到最终的输出
return torch.matmul(p_attn, value), p_attn 同样的方法,也可以计算出,如下图8所示, b2就是拿q2去对其他的key做attention,最后再与其他的value值相乘取weighted sum得到,最终每个单词都包含了上下文相关单词的语义信息,不再只是attention计算之前,每个单词只有它自己的信息,和上下文没有关联

另外,这里面还有一点值得注意的是,可能有同学疑问:当我们计算x1与x2、x3、x4的相似度之后,x2会再与x1、x3、x4再依次计算一遍相似度,这两个过程中,前者算过了x1和x2的相似度,后者则再算一遍x2与x1的相似度,这不是重复计算么?其实不然,这是两码事,原因很简单,正如你喜欢一个人 你会觉得她对你很重要,但那个人不一定喜欢你 她不会觉得你对她有多重要..
最终,Scaled Dot-Product Attention这部分对应的完整代码可以写为
'''计算“缩放点积注意力'''
# query, key, value 是输入的向量组
# mask 用于遮掩某些位置,防止计算注意力
# dropout 用于添加随机性,有助于防止过拟合
def attention(query, key, value, mask=None, dropout=None):
d_k = query.size(-1) # 获取 query 向量的最后一个维度的大小,即词嵌入的维度
# 计算 query 和 key 的点积,并对结果进行缩放,以减少梯度消失或爆炸的可能性
scores = torch.matmul(query, key.transpose(-2, -1)) \
/ math.sqrt(d_k)
# 如果提供了 mask,根据 mask 对 scores 进行遮掩
# 遮掩的具体方法就是设为一个很大的负数比如-1e9,从而softmax后 对应概率基本为0
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
# 对 scores 进行 softmax 操作,得到注意力权重 p_attn
p_attn = F.softmax(scores, dim = -1)
# 如果提供了 dropout,对注意力权重 p_attn 进行 dropout 操作
if dropout is not None:
p_attn = dropout(p_attn)
# 用注意力权重 p_attn 对 value 向量进行加权求和,得到最终的输出
return torch.matmul(p_attn, value), p_attn
先看2个头的例子,依然还是通过生成对应的三个矩阵
、
、
,然后这三个矩阵再各自乘以两个转移矩阵得到对应的分矩阵,如
至于同理,也生成对应的6个分矩阵
、
、
、
、
、
接下来编码时,分两步


如果是8个头呢,计算步骤上也是一样的,只是从2个头变化到8个头而已,最终把每个头得到的结果直接concat,最后经过一个linear变换,得到最终的输出,整体如下所示

这部分Multi-Head Attention的代码可以写为
'''代码来自nlp.seas.harvard.edu,我针对每一行代码、甚至每行代码中的部分变量都做了详细的注释/解读'''
class MultiHeadedAttention(nn.Module):
# 输入模型的大小(d_model)和注意力头的数量(h)
def __init__(self, h, d_model, dropout=0.1):
super(MultiHeadedAttention, self).__init__()
assert d_model % h == 0 # 确保 d_model 可以被 h 整除
# 我们假设 d_v(值向量的维度)总是等于 d_k(键向量的维度)
self.d_k = d_model // h # 计算每个注意力头的维度
self.h = h # 保存注意力头的数量
self.linears = clones(nn.Linear(d_model, d_model), 4) # 上文解释过的四个线性层
self.attn = None # 初始化注意力权重为 None
self.dropout = nn.Dropout(p=dropout) # 定义 dropout 层
# 实现多头注意力的前向传播
def forward(self, query, key, value, mask=None):
if mask is not None:
# 对所有 h 个头应用相同的 mask
mask = mask.unsqueeze(1)
nbatches = query.size(0) # 获取 batch 的大小
# 1) 批量执行从 d_model 到 h x d_k 的线性投影
query, key, value = \
[l(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
for l, x in zip(self.linears, (query, key, value))]
# 2) 在批量投影的向量上应用注意力
# 具体方法是调用上面实现Scaled Dot-Product Attention的attention函数
x, self.attn = attention(query, key, value, mask=mask,
dropout=self.dropout)
# 3) 使用 view 函数进行“拼接concat”,然后做下Linear变换
x = x.transpose(1, 2).contiguous() \
.view(nbatches, -1, self.h * self.d_k)
return self.linears[-1](x) # 返回多头注意力的输出
在上文,咱们逐一编码实现了embedding、位置编码、缩放点积/多头注意力,以及Add和Norm,整个编码器部分还剩最后一个模块,即下图框里的Feed Forward Network(简称FFN)

其中包括两个线性变换:维度上先扩大后缩小,最终输入和输出的维数为,内层的维度为
,过程中使用ReLU作为激活函数
虽然线性变换在不同位置上是相同的,但它们在层与层之间使用不同的参数,相当于使用了两个内核大小为1的卷积
这部分的代码可以如下编写
‘’‘定义一个名为PositionwiseFeedForward的类,继承自nn.Module’‘’
class PositionwiseFeedForward(nn.Module):
# 文档字符串:实现FFN方程
# 初始化方法,接受三个参数:d_model,d_ff和dropout(默认值为0.1)
def __init__(self, d_model, d_ff, dropout=0.1):
# 调用父类nn.Module的初始化方法
super(PositionwiseFeedForward, self).__init__()
self.w_1 = nn.Linear(d_model, d_ff) # 定义一个全连接层,输入维度为d_model,输出维度为d_ff
self.w_2 = nn.Linear(d_ff, d_model) # 定义一个全连接层,输入维度为d_ff,输出维度为d_model
self.dropout = nn.Dropout(dropout) # 定义一个dropout层,dropout概率为传入的dropout参数
# 定义前向传播方法,接受一个输入参数x
def forward(self, x):
# 将输入x通过第一个全连接层w_1后,经过ReLU激活函数,再通过dropout层,最后通过第二个全连接层w_2,返回最终结果
return self.w_2(self.dropout(F.relu(self.w_1(x))))
N可以等于6或其他数值
class Encoder(nn.Module): # 定义一个名为Encoder的类,它继承了nn.Module类
# 一个具有N层堆叠的核心编码器
# 初始化方法,接受两个参数:layer(编码器层的类型)和N(编码器层的数量)
def __init__(self, layer, N):
super(Encoder, self).__init__() # 调用父类nn.Module的初始化方法
self.layers = clones(layer, N) # 创建N个编码器层的副本,并将其赋值给实例变量self.layers
self.norm = LayerNorm(layer.size) # 创建一个LayerNorm层,并将其赋值给实例变量self.norm
# 定义前向传播方法,接受两个参数:x(输入数据)和mask(掩码)
def forward(self, x, mask):
# 文档字符串:解释本方法的功能是将输入(及其掩码)依次传递给每一层
for layer in self.layers: # 遍历self.layers中的每一个编码器层
x = layer(x, mask) # 将输入x和mask传递给当前编码器层,并将输出结果赋值给x
return self.norm(x) # 对最终的输出x应用LayerNorm层,并将结果返回
其中的clone函数的代码为
def clones(module, N):
"Produce N identical layers."
return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
咱们再回顾下transformer的整个模型架构,特别是解码器的部分,毕竟BERT外,GPT等很有影响力的模型都用的transformer decode结构

从底至上,

由于在第一部分介绍过了embedding、positional encoding、FFN、Add&Norm、linear、softmax、multi-head attention,故本部分只重点介绍下Masked Multi-Head Self-attention
本过程和第一部分介绍的Multi-Head self-attention基本一致,区别在于加了个mask机制

整个解码器架构的代码可以如下编写『有一点值得注意的是,如下文代码中所述
# 定义DecoderLayer类,继承自PyTorch的nn.Module类
class DecoderLayer(nn.Module):
# 初始化方法,接收五个参数:size, self_attn, src_attn, feed_forward, dropout
# 调用父类nn.Module的初始化方法
def __init__(self, size, self_attn, src_attn, feed_forward, dropout):
super(DecoderLayer, self).__init__()
# 将size赋值给实例变量self.size
self.size = size
# 将self_attn赋值给实例变量self.self_attn
self.self_attn = self_attn
# 将src_attn赋值给实例变量self.src_attn
self.src_attn = src_attn
# 将feed_forward赋值给实例变量self.feed_forward
self.feed_forward = feed_forward
# 使用SublayerConnection类创建三个子层,并存储到实例变量self.sublayer中
self.sublayer = clones(SublayerConnection(size, dropout), 3)
# 定义前向传播方法,接收四个参数:x, memory, src_mask, tgt_mask
def forward(self, x, memory, src_mask, tgt_mask):
# 将memory赋值给局部变量m
m = memory
# 对输入x执行自注意力计算并进行第一个子层的处理
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))
# 对输入x执行源注意力计算并进行第二个子层的处理
x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask))
# 对输入x执行前馈神经网络计算并进行第三个子层的处理,然后返回结果
return self.sublayer[2](x, self.feed_forward)
且Decoder也是由N=6个相同层组成
class Decoder(nn.Module):
"Generic N layer decoder with masking."
def __init__(self, layer, N):
super(Decoder, self).__init__()
self.layers = clones(layer, N)
self.norm = LayerNorm(layer.size)
def forward(self, x, memory, src_mask, tgt_mask):
for layer in self.layers:
x = layer(x, memory, src_mask, tgt_mask)
return self.norm(x)
最终,整个transformer完整模型的整体封装代码为
def make_model(src_vocab, tgt_vocab, N=6,
d_model=512, d_ff=2048, h=8, dropout=0.1):
"Helper: Construct a model from hyperparameters."
c = copy.deepcopy
attn = MultiHeadedAttention(h, d_model)
ff = PositionwiseFeedForward(d_model, d_ff, dropout)
position = PositionalEncoding(d_model, dropout)
model = EncoderDecoder(
Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout), N),
Decoder(DecoderLayer(d_model, c(attn), c(attn),
c(ff), dropout), N),
nn.Sequential(Embeddings(d_model, src_vocab), c(position)),
nn.Sequential(Embeddings(d_model, tgt_vocab), c(position)),
Generator(d_model, tgt_vocab))
# This was important from their code.
# Initialize parameters with Glorot / fan_avg.
for p in model.parameters():
if p.dim() > 1:
nn.init.xavier_uniform(p)
return model
# Small example model.
tmp_model = make_model(10, 10, 2)
None
当我们把编码器和解码器组合到一起后,看下它两是如何一块协作的

需要注意的是
具体实现时,先创建批次和掩码
class Batch:
def __init__(self, src, trg=None, pad=0):
self.src = src # 输入数据源(通常为源语言)
self.src_mask = (src != pad).unsqueeze(-2) # 创建源语言的掩码,用于忽略填充部分
if trg is not None: # 如果目标语言数据存在
self.trg = trg[:, :-1] # 目标语言数据,去掉最后一个词
self.trg_y = trg[:, 1:] # 目标语言数据,去掉第一个词
self.trg_mask = \
self.make_std_mask(self.trg, pad) # 创建目标语言的掩码,用于忽略填充部分和未来词汇
self.ntokens = (self.trg_y != pad).data.sum() # 计算目标语言中非填充词的数量
@staticmethod
def make_std_mask(tgt, pad):
"Create a mask to hide padding and future words."
tgt_mask = (tgt != pad).unsqueeze(-2) # 创建目标语言的掩码,用于忽略填充部分
tgt_mask = tgt_mask & Variable(
subsequent_mask(tgt.size(-1)).type_as(tgt_mask.data)) # 使用子掩码屏蔽未来词汇
return tgt_mask # 返回完整的目标语言掩码
接下来,我们创建一个通用的训练和得分函数来跟踪损失。我们传入一个通用的损失计算函数,它也处理参数更新
def run_epoch(data_iter, model, loss_compute):
start = time.time() # 记录当前时间
total_tokens = 0 # 初始化总tokens计数
total_loss = 0 # 初始化总损失
tokens = 0 # 初始化tokens计数
# 遍历数据集中的每个批次
for i, batch in enumerate(data_iter):
# 对每个批次进行前向传播
out = model.forward(batch.src, batch.trg,
batch.src_mask, batch.trg_mask)
# 计算每个批次的损失
loss = loss_compute(out, batch.trg_y, batch.ntokens)
# 累加损失
total_loss += loss
total_tokens += batch.ntokens # 累加tokens
tokens += batch.ntokens # 累加tokens
# 每50个批次进行一次日志记录
if i % 50 == 1:
elapsed = time.time() - start # 计算已用时间
# 输出当前批次,损失和每秒处理的tokens
print("Epoch Step: %d Loss: %f Tokens per Sec: %f" %
(i, loss / batch.ntokens, tokens / elapsed))
start = time.time() # 重置开始时间
tokens = 0 # 重置tokens计数
return total_loss / total_tokens # 返回平均损失
下面这段代码定义了一个名为 SimpleLossCompute 的类,实现了简单的损失计算和训练函数
# 定义 SimpleLossCompute 类,实现简单的损失计算和训练函数
class SimpleLossCompute:
# 初始化 SimpleLossCompute 类的实例
def __init__(self, generator, criterion, opt=None):
self.generator = generator # 生成器,用于预测输出
self.criterion = criterion # 损失函数,如交叉熵损失
self.opt = opt # 优化器,如 Adam
# 定义调用 SimpleLossCompute 类实例时的操作
def __call__(self, x, y, norm):
x = self.generator(x) # 生成预测输出
# 计算损失,这里需要将预测输出和目标输出转换为合适的形状
loss = self.criterion(x.contiguous().view(-1, x.size(-1)),
y.contiguous().view(-1)) / norm
loss.backward() # 计算梯度
if self.opt is not None: # 如果提供了优化器
self.opt.step() # 更新模型参数
self.opt.optimizer.zero_grad() # 清空梯度缓存
return loss.data[0] * norm # 返回损失值乘以规范化因子(实际损失值)
优化器(optimizer)经常用于在训练过程中更新模型参数以最小化损失函数,而Adam(Adaptive Moment Estimation)是一种常用的优化器,它结合了两种传统优化算法的优点:Momentum和RMSprop
为了通俗易懂地理解Adam,可以将其比作一个赛车手。训练模型就像是找到一辆赛车在赛道上的最佳行驶速度和路径,以达到最快的速度并取得优异的成绩。在这个过程中,速度的调整(即学习率)非常重要
首先,Adam像Momentum一样,具有动量效应。这意味着赛车手(模型)会积累动量,使其在下坡时更快,而在上坡时减速。这有助于模型更快地穿越平坦区域,并避免在最低点附近摆动
其次,Adam像RMSprop一样,会自适应地调整每个参数的学习率。在我们的赛车比喻中,这就像赛车手会针对每个轮胎的摩擦系数(赛道状况)做出相应的速度调整。这有助于模型更快地收敛到最优解
总之,Adam可以自动调整学习率,并具有动量效应。总的来说,它能帮助我们的“赛车手”在不同的赛道状况下更快地找到最佳行驶速度和路径,从而更快地训练出高效的模型
transformer原始论文便选择的Adam作为优化器,其参数为,
和
,根据以下公式,我们在训练过程中改变了学习率:

在预热中随步数线性地增加学习速率,并且此后与步数的反平方根成比例地减小它,设置预热步数为4000
我们来看下具体的编码实现。下面这段代码定义了一个名为 NoamOpt 的类,实现了一种自适应学习率调整策略,该策略在训练 Transformer 模型时常用。在训练的前几个步骤(预热期)中,学习率会线性增长,之后学习率会随着步数的增加而逐渐降低。这种策略有助于模型在训练初期更快地收敛,同时在训练后期保持较低的学习率,有利于模型的稳定训练。
# 定义 NoamOpt 类,实现自适应学习率调整策略
class NoamOpt:
# 初始化 NoamOpt 类的实例
def __init__(self, model_size, factor, warmup, optimizer):
self.optimizer = optimizer # 优化器对象(如 Adam)
self._step = 0 # 记录优化步数
self.warmup = warmup # 预热步数
self.factor = factor # 缩放因子
self.model_size = model_size # 模型维度大小
self._rate = 0 # 初始学习率
# 更新模型参数和学习率
def step(self):
self._step += 1 # 优化步数加 1
rate = self.rate() # 计算当前学习率
for p in self.optimizer.param_groups: # 更新优化器中的学习率
p['lr'] = rate
self._rate = rate # 存储当前学习率
self.optimizer.step() # 更新模型参数
# 计算当前步数的学习率
def rate(self, step=None):
if step is None: # 如果未提供步数,使用当前步数
step = self._step
return self.factor * \
(self.model_size ** (-0.5) * # 计算学习率公式中的模型维度项
min(step ** (-0.5), step * self.warmup ** (-1.5))) # 计算学习率公式中的最小值项
# 定义用于获取 NoamOpt 类实例的函数
def get_std_opt(model):
return NoamOpt(model.src_embed[0].d_model, 2, 4000,
torch.optim.Adam(model.parameters(), lr=0, betas=(0.9, 0.98), eps=1e-9))
最后总结一下Transformer的影响力
ChatGLM-6B(介绍页面、代码地址),是智谱 AI 开源、支持中英双语的对话语言模型。
话不多说,直接干,虽然6B的版本相比GPT3 175B 不算大,但毕竟不是一个小工程,本文就不一一贴所有代码了,更多针对某个文件夹下或某个链接下的代码进行整体分析/说明,以帮助大家更好、更快的理解ChatGLM-6B,从而加速大家的类ChatGPT复现之路

# 使用PyTorch的JIT编译器,将Python函数转换为Torch脚本,以便优化和加速执行
@torch.jit.script
# 定义名为gelu_impl的函数,接受一个参数x
def gelu_impl(x):
# 返回GELU激活函数的计算结果,这里使用了一种近似计算方法
return 0.5 * x * (1.0 + torch.tanh(0.7978845608028654 * x *
(1.0 + 0.044715 * x * x)))
# 定义名为gelu的函数,接受一个参数x
def gelu(x):
# 调用gelu_impl函数并返回结果
return gelu_impl(x) # 类的前向传播方法,接收三个参数
def forward(self, x, seq_dim=1, seq_len=None):
# 如果没有提供序列长度,则从输入张量的形状中获取序列长度
if seq_len is None:
seq_len = x.shape[seq_dim]
# 如果缓存的最大序列长度不存在,或者提供的序列长度大于缓存的最大序列长度
if self.max_seq_len_cached is None or (seq_len > self.max_seq_len_cached):
# 更新缓存的最大序列长度
self.max_seq_len_cached = None if self.learnable else seq_len
# 创建等差序列
t = torch.arange(seq_len, device=x.device, dtype=self.inv_freq.dtype)
# 计算频率张量
freqs = torch.einsum('i,j->ij', t, self.inv_freq)
# 将频率张量沿最后一个维度进行拼接,形成旋转嵌入
emb = torch.cat((freqs, freqs), dim=-1).to(x.device)
# 如果精度为bfloat16,将旋转嵌入转换为float类型
if self.precision == torch.bfloat16:
emb = emb.float()
# 计算旋转嵌入的余弦值和正弦值,形状为 [sx, 1 (b * np), hn]
cos_cached = emb.cos()[:, None, :]
sin_cached = emb.sin()[:, None, :]
if self.precision == torch.bfloat16:
# 如果精度为bfloat16,将余弦值转换为bfloat16类型
cos_cached = cos_cached.bfloat16()
# 如果精度为bfloat16,将正弦值转换为bfloat16类型
sin_cached = sin_cached.bfloat16()
# 如果旋转嵌入是可学习的
if self.learnable:
# 返回余弦值和正弦值
return cos_cached, sin_cached
# 更新缓存的余弦值和正弦值
self.cos_cached, self.sin_cached = cos_cached, sin_cached
# 返回截取后的余弦值和正弦值,以匹配输入序列的长度
return self.cos_cached[:seq_len, ...], self.sin_cached[:seq_len, ...]
# 使用PyTorch的JIT编译器,将Python函数转换为Torch脚本,以便优化和加速执行
@torch.jit.script
# 定义一个名为apply_rotary_pos_emb_index的函数,接收五个参数
def apply_rotary_pos_emb_index(q, k, cos, sin, position_id):
# 通过position_id获取cos和sin的嵌入表示
# cos.squeeze(1)和sin.squeeze(1)用于去除多余的维度
# 而unsqueeze(2)则用于重新添加所需的维度
# 从而将cos和sin的形状从[sq, 1, hn]变为[sq, b, np, hn],以便后续q和k进行运算
cos, sin = F.embedding(position_id, cos.squeeze(1)).unsqueeze(2), \
F.embedding(position_id, sin.squeeze(1)).unsqueeze(2)
# 计算旋转位置编码后的q和k,将q和k与cos和sin进行点积运算
q, k = (q * cos) + (rotate_half(q) * sin), (k * cos) + (rotate_half(k) * sin)
# 返回旋转位置编码后的q和k
return q, k 定义了一个名为SelfAttention的PyTorch模块,它实现了自注意力机制。这个模块在许多自然语言处理任务中都被用作基本构建块。以下是代码中的关键部分:
# 定义attention函数
def attention_fn(
self,
query_layer, # 查询层张量
key_layer, # 键层张量
value_layer, # 值层张量
attention_mask, # 注意力掩码张量
hidden_size_per_partition, # 每个分区的隐藏层大小,每个分区可能包含2或4或8个头
layer_id, # 当前层的ID
layer_past=None, # 保存过去的键和值的张量,用于解码器的自回归任务
scaling_attention_score=True, # 是否缩放注意力分数,默认为True
use_cache=False, # 是否使用缓存,默认为False
):
# 如果layer_past不为空,则获取然后拼接过去的key和value
if layer_past is not None:
past_key, past_value = layer_past[0], layer_past[1]
key_layer = torch.cat((past_key, key_layer), dim=0)
value_layer = torch.cat((past_value, value_layer), dim=0)
# 获取key_layer的形状信息
# 包括序列长度sq、批大小b、注意力头数(np,原代码为nh,应该是笔误)、每个注意力头的隐藏层大小hn
seq_len, b, nh, hidden_size = key_layer.shape
# 如果使用缓存,则设置present为key和value的元组,否则为None
if use_cache:
present = (key_layer, value_layer)
else:
present = None
# 计算查询-键层缩放系数
query_key_layer_scaling_coeff = float(layer_id + 1)
# 如果需要缩放注意力分数,对查询层进行缩放
if scaling_attention_score:
query_layer = query_layer / (math.sqrt(hidden_size) * query_key_layer_scaling_coeff)
# 设置输出张量的大小,计算原始注意力分数的形状:[b, np, sq, sk]
output_size = (query_layer.size(1), query_layer.size(2), query_layer.size(0), key_layer.size(0))
"""
解释下:query_layer 的原始形状为
[seqlen, batch,num_attention_heads,hidden_size_per_attention_head],简写为[sq,b,np,hn]
故query_layer.size(1)对应b, query_layer.size(2)对应np, query_layer.size(0)对应sq
key_layer 的原始形状为
[seklen,batch,num_attention_heads,hidden_size_per_attention_head],简写为[sk,b,np,hn]
所以key_layer.size(0)对应sk
"""
# 通过之前第39行的output_size[b, np, sq, sk],重塑查询层和键层张量 好进行矩阵相乘
# [sq, b, np, hn] -> [sq, b * np, hn]
query_layer = query_layer.view(output_size[2], output_size[0] * output_size[1], -1)
# [sk, b, np, hn] -> [sk, b * np, hn]
key_layer = key_layer.view(output_size[3], output_size[0] * output_size[1], -1)
"""
上面那两行再解释下,因为需要计算每个批次中每个注意力头的注意力分数,为此
将批次大小(batch)和注意力头数量(num_attention_heads)合并到一个维度中以便于执行矩阵乘法
因此,我们将 query_layer 的形状从[sq,b,np,hn]调整为 [sq, b * np, hn]
同理,对于 key_layer,将 key_layer 的形状从[sk,b,np,hn]调整为 [sk, b * np, hn]
"""
# 初始化乘法结果张量
matmul_result = torch.zeros(
1, 1, 1,
dtype=query_layer.dtype,
device=query_layer.device,
)
# 计算查询层和键层的乘积
matmul_result = torch.baddbmm(
matmul_result,
# 将 query_layer 的形状从 [sq, b * np, hn] 转换为 [b * np, sq, hn]
query_layer.transpose(0, 1),
# 将 key_layer 的形状从 [sk, b * np, hn] 转换为 [b * np, hn, sk]
# 相当于对key_layer 进行了两次转置操作,得到形状为 [b * np, hn, sk] 的张量
key_layer.transpose(0, 1).transpose(1, 2),
beta=0.0,
alpha=1.0,
)
# 上面最终query_layer为[b * np, sq, hn]
# 上面最终key_layer 为[b * np, hn, sk]
# 现在,沿用之前第39行的output_size的注意力分数张量[b, np, sq, sk]
attention_scores = matmul_result.view(*output_size)
# 使用缩放掩码Softmax计算注意力概率
if self.scale_mask_softmax:
self.scale_mask_softmax.scale = query_key_layer_scaling_coeff
attention_probs = self.scale_mask_softmax(attention_scores, attention_mask.contiguous())
else:
# 如果掩码不全为0,应用注意力掩码
if not (attention_mask == 0).all():
attention_scores.masked_fill_(attention_mask, -10000.0)
# 转换注意力分数张量的数据类型为浮点数
dtype = attention_scores.dtype
attention_scores = attention_scores.float()
# 缩放注意力分数
attention_scores = attention_scores * query_key_layer_scaling_coeff
# 对注意力分数执行Softmax操作以获取注意力概率
attention_probs = F.softmax(attention_scores, dim=-1)
# 将注意力概率张量的数据类型恢复为原始数据类型
attention_probs = attention_probs.type(dtype)
"""
计算上下文层[sq, b, hp]
"""
# 对原始value_layer做下转换得到新的output_size:[sk, b, np, hn] --> [b, np, sq, hn]
output_size = (value_layer.size(1), value_layer.size(2), query_layer.size(0), value_layer.size(3))
# 对原始value_layer的中间两个维度做下合并 [sk, b, np, hn] -> [sk, b * np, hn]
value_layer = value_layer.view(value_layer.size(0), output_size[0] * output_size[1], -1)
# 调整注意力概率:对之前得到的前两个维度做下合并:[b, np, sq, sk] =》[b * np, sq, sk]
attention_probs = attention_probs.view(output_size[0] * output_size[1], output_size[2], -1)
# 对上一行得到的attention_probs[b * np, sq, sk]
# 乘以『value_layer即[sk, b * np, hn]的转置』,即[b * np, hn, sk]
# 相当于[b * np, sq, sk] x [b * np, hn, sk],最终得到[b * np, sq, hn]
context_layer = torch.bmm(attention_probs, value_layer.transpose(0, 1))
# 上行得到context_layer的[b * np, sq, hn]通过上面第116行的新output_size调整为4个维度的
# [b, np, sq, hn]
# 使其更直观地表示批量大小b、注意力头数np、查询序列长度sq以及每个注意力头的隐藏层大小hn
context_layer = context_layer.view(*output_size)
# [b, np, sq, hn] --> [sq, b, np, hn],使其与查询层(query_layer)的形状一致
context_layer = context_layer.permute(2, 0, 1, 3).contiguous()
# [sq, b, np, hn] --> [sq, b, hp],此举的作用在于前两个维度(sq 和 b)不变
# 同时将后两个维度(np 和 hn)合并成单个维度,即每个分区的隐藏层大小(hp)
new_context_layer_shape = context_layer.size()[:-2] + (hidden_size_per_partition,)
context_layer = context_layer.view(*new_context_layer_shape)
# 将上下文层、当前的键值对(present)以及注意力概率(attention_probs)打包成一个元组
outputs = (context_layer, present, attention_probs)
return outputs SelfAttention类定义:这个类实现了自注意力机制,包括定义类的初始化方法和成员变量。类的初始化方法包括设置各种属性,如hidden_size,num_attention_heads,layer_id等。类还包含一个名为rotary_emb的RotaryEmbedding实例,用于处理位置编码。此外,query_key_value和dense是用于计算查询、键和值的线性层。
@staticmethod
def attention_mask_func(attention_scores, attention_mask):
# 使用掩码 (attention_mask) 更新注意力得分 (attention_scores)
# 对于掩码值为0的位置,将注意力得分设置为-10000.0
attention_scores.masked_fill_(attention_mask, -10000.0)
# 返回更新后的注意力得分张量
return attention_scores GLMBlock 类:这是一个包含多个子模块的Transformer层,如层归一化 (LayerNorm)、自注意力 (SelfAttention) 和门控线性单元 (GLU)。GLMBlock 类的 forward 方法接收与SelfAttention的forward方法类似的参数,如输入序列的隐藏状态、位置编码、注意力掩码等。在这个方法中,首先应用层归一化,然后计算自注意力,接着应用第二个层归一化,最后通过门控线性单元 (GLU) 计算输出。在每个步骤之间,都有残差连接来保留之前的信息。最后,返回输出张量、隐藏状态以及注意力概率(如果需要的话)。
接下来第661-729行,定义了一个名为 ChatGLMPreTrainedModel 的类,它继承自 PreTrainedModel。这个类是用于处理权重初始化以及简化下载和加载预训练模型的接口。
此外,还定义了一个名为 CHATGLM_6B_START_DOCSTRING 的变量,包含有关 ChatGLM6BConfig 的文档字符串,描述了如何使用这个 PyTorch 模型。
定义了一个名为ChatGLMModel的类,它继承自ChatGLMPreTrainedModel。这是一个基于transformer的模型,能够作为编码器(仅使用自注意力机制)或解码器。解码器的情况下,会在自注意力层之间添加一个跨注意力层。模型的结构遵循论文Attention is all you need中描述的结构。
ChatGLMModel类的forward方法负责执行模型的前向传播。这个方法接收一系列输入参数,如input_ids、attention_mask、past_key_values等。根据这些输入,方法将执行以下操作:
这个模型的设计可以在序列到序列(Seq2Seq)任务中使用,这时需要将is_decoder和add_cross_attention参数设置为True,并在前向传播时提供encoder_hidden_states。
定义了一个名为ChatGLMForConditionalGeneration的类,,它用于条件生成任务,如文本生成。这个类继承自ChatGLMPreTrainedModel,主要包括初始化方法、模型的前向传播逻辑以及生成过程中需要的输入预处理方法。
主要部分的解释如下:
该类中还包括一些辅助方法,例如 _get_logits_processor, _get_stopping_criteria, _get_logits_warper, prepare_inputs_for_generation, 和 _update_model_kwargs_for_generation,这些方法用于处理生成过程中的各种设置和参数。
// 待5月份补充
如何在buildr项目中使用Ruby?我在很多不同的项目中使用过Ruby、JRuby、Java和Clojure。我目前正在使用我的标准Ruby开发一个模拟应用程序,我想尝试使用Clojure后端(我确实喜欢功能代码)以及JRubygui和测试套件。我还可以看到在未来的不同项目中使用Scala作为后端。我想我要为我的项目尝试一下buildr(http://buildr.apache.org/),但我注意到buildr似乎没有设置为在项目中使用JRuby代码本身!这看起来有点傻,因为该工具旨在统一通用的JVM语言并且是在ruby中构建的。除了将输出的jar包含在一个独特的、仅限ruby
在rails源中:https://github.com/rails/rails/blob/master/activesupport/lib/active_support/lazy_load_hooks.rb可以看到以下内容@load_hooks=Hash.new{|h,k|h[k]=[]}在IRB中,它只是初始化一个空哈希。和做有什么区别@load_hooks=Hash.new 最佳答案 查看rubydocumentationforHashnew→new_hashclicktotogglesourcenew(obj)→new_has
我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden
我的主要目标是能够完全理解我正在使用的库/gem。我尝试在Github上从头到尾阅读源代码,但这真的很难。我认为更有趣、更温和的踏脚石就是在使用时阅读每个库/gem方法的源代码。例如,我想知道RubyonRails中的redirect_to方法是如何工作的:如何查找redirect_to方法的源代码?我知道在pry中我可以执行类似show-methodmethod的操作,但我如何才能对Rails框架中的方法执行此操作?您对我如何更好地理解Gem及其API有什么建议吗?仅仅阅读源代码似乎真的很难,尤其是对于框架。谢谢! 最佳答案 Ru
我的假设是moduleAmoduleBendend和moduleA::Bend是一样的。我能够从thisblog找到解决方案,thisSOthread和andthisSOthread.为什么以及什么时候应该更喜欢紧凑语法A::B而不是另一个,因为它显然有一个缺点?我有一种直觉,它可能与性能有关,因为在更多命名空间中查找常量需要更多计算。但是我无法通过对普通类进行基准测试来验证这一点。 最佳答案 这两种写作方法经常被混淆。首先要说的是,据我所知,没有可衡量的性能差异。(在下面的书面示例中不断查找)最明显的区别,可能也是最著名的,是你的
几个月前,我读了一篇关于rubygem的博客文章,它可以通过阅读代码本身来确定编程语言。对于我的生活,我不记得博客或gem的名称。谷歌搜索“ruby编程语言猜测”及其变体也无济于事。有人碰巧知道相关gem的名称吗? 最佳答案 是这个吗:http://github.com/chrislo/sourceclassifier/tree/master 关于ruby-寻找通过阅读代码确定编程语言的rubygem?,我们在StackOverflow上找到一个类似的问题:
我目前正在使用以下方法获取页面的源代码:Net::HTTP.get(URI.parse(page.url))我还想获取HTTP状态,而无需发出第二个请求。有没有办法用另一种方法做到这一点?我一直在查看文档,但似乎找不到我要找的东西。 最佳答案 在我看来,除非您需要一些真正的低级访问或控制,否则最好使用Ruby的内置Open::URI模块:require'open-uri'io=open('http://www.example.org/')#=>#body=io.read[0,50]#=>"["200","OK"]io.base_ur
前言作为一名程序员,自己的本质工作就是做程序开发,那么程序开发的时候最直接的体现就是代码,检验一个程序员技术水平的一个核心环节就是开发时候的代码能力。众所周知,程序开发的水平提升是一个循序渐进的过程,每一位程序员都是从“菜鸟”变成“大神”的,所以程序员在程序开发过程中的代码能力也是根据平时开发中的业务实践来积累和提升的。提高代码能力核心要素程序员要想提高自身代码能力,尤其是新晋程序员的代码能力有很大的提升空间的时候,需要针对性的去提高自己的代码能力。提高代码能力其实有几个比较关键的点,只要把握住这些方面,就能很好的、快速的提高自己的一部分代码能力。1、多去阅读开源项目,如有机会可以亲自参与开源
英文版英文链接关注公众号在“亚特兰蒂斯的回声”中踏上一段难忘的冒险之旅,深入未知的海洋深处。足智多谋的考古学家AriaSeaborne偶然发现了一件古代神器,揭示了一张通往失落之城亚特兰蒂斯的隐藏地图。在她神秘的导师内森·兰登教授的指导和勇敢的冒险家亚历克斯·默瑟的帮助下,阿丽亚开始了一段危险的旅程,以揭开这座传说中城市的真相。他们的冒险之旅带领他们穿越险恶的大海、神秘的岛屿和充满陷阱和谜语的致命迷宫。随着Aria潜在的魔法能力的觉醒,她被睿智勇敢的QueenNeria的幻象所指引,她让她为即将到来的挑战做好准备。三人组揭开亚特兰蒂斯令人惊叹的隐藏文明,并了解到邪恶的巫师马拉卡勋爵试图利用其古
华为OD机试题本篇题目:明明的随机数题目输入描述输出描述:示例1输入输出说明代码编写思路最近更新的博客华为od2023|什么是华为od,od薪资待遇,od机试题清单华为OD机试真题大全,用Python解华为机试题|机试宝典【华为OD机试】全流程解析+经验分享,题型分享,防作弊指南华为o