文本生成【终】 Transformer结构、流程及代码详解
目录
摘要
本文将根据Transformer架构构建一个基本的小说内容生成器,以及对Transformer的结构进行一个详细的解析。
Transformer架构
一.模型总览
先看看Transformer的基本架构:

可以看出它的架构比以往的循环神经网络都更为复杂,它的效果也比其他的模型更好,主要是在以下几个方面进行了改进:
1.多头注意力机制(Multi-Head Attention)
在编码层以及解码层之中(也就是图中的灰色方框之内)引入多头注意力机制,继承注意力机制的优点同时利于并行计算。
2.位置编码(Positional Encoding)
在输入的词嵌入层之后,手动对序列进行位置编码,序列中的词不再依赖前一个,还可以保留相对位置信息。
3.掩码(Masked)
在解码层中使用掩码,避免生成时【未卜先知】,提升模型生成质量。
4.前馈全连接层和规范化层(Add&Norm)
增强模型拟合能力,加快模型收敛。
二.模型代码及流程详解
写代码就是处理数据矩阵的过程,所以以下流程还是以几个例子向量展示。
1.输入部分

输入有两个,一个是训练集文本(inputs),以及目标文本(Outputs)(shifted right,也就是右移,表示是训练集的理想输出,例如:
inputs:[PAD, SOS, 训, 练, 模, 型]
shifted right:[SOS, 训, 练, 模, 型, EOS]
类似于我在上篇文章提到的TeacherForcing策略,可以加快模型收敛)。因为两边的流程一致,我们接下来就以inputs作为例子。
1.1词嵌入层
这个不用多说,唯一要注意的是返回的值多乘以一个系数避免词嵌入层与位置编码贡献大小不一致:
class Embeddings(nn.Module):
def __init__(self, d_model, vocab):
# 参数d_model 每个词汇的特征尺寸 词嵌入维度
# 参数vocab 词汇表大小
super(Embeddings, self).__init__()
self.d_model = d_model
self.vocab = vocab
# 定义词嵌入层
self.lut = nn.Embedding(self.vocab, self.d_model)
def forward(self, x):
# 乘以维度的开根是为了放大词嵌入层的初始化值,使词嵌入层面对位置编码时能保持一个平衡的权重
return self.lut(x) * math.sqrt(self.d_model)
1.2位置编码
先来看位置编码的公式:
,
其中,是原来的位置,比如对于例子而言位置就是:
pos_example = [0, 1, 2, 3, 4, 5]
而和
分别对应词向量维度的偶数与奇数索引。
对于词嵌入层的默认初始化,一般都是均匀的:
# 实际初始化方式大致是:
embedding.weight ~ Uniform(-sqrt(1/vocab_size), sqrt(1/vocab_size))
那么,位置编码也就相当于是对词嵌入层的优化处理。优点之前已经介绍过了,而我们现在从公式推理:由于三角函数公式的特殊性,任意固定偏移量 k 的位置编码,可以表示为另一个位置编码的线性变换。意味着模型 理论上能学习到“相对位置关系”,比如 “动词通常在名词之后”。
class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout, max_len=5000):
# 参数d_model 词嵌入维度 eg: 512个特征
# 参数max_len 单词token个数 eg: 6个单词
super(PositionalEncoding, self).__init__()
# 定义dropout层
self.dropout = nn.Dropout(p=dropout)
# 定义位置编码矩阵PE eg pe[6, 512], 位置编码矩阵和特征矩阵形状是一样的
pe = torch.zeros(max_len, d_model)
# 定义位置列-矩阵position 数据形状[max_len,1] eg: [0,1,2,3,4,5,6]^T
position = torch.arange(0, max_len).unsqueeze(1)
# 定义变化矩阵div_term [1,256]
# torch.arange(start=0, end=512, 2)结果并不包含end。在start和end之间做一个等差数组 [0, 2, 4, 6 ... 510]
div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model))
# 位置列-矩阵 @ 变化矩阵 做矩阵运算 [6*1]@ [1*256] ==> 6 *256
# 矩阵相乘也就是行列对应位置相乘再相加,其含义,给每一个列属性(列特征)增加位置编码信息
my_matmulres = position * div_term
# 给位置编码矩阵奇数列,赋值sin曲线特征
pe[:, 0::2] = torch.sin(my_matmulres)
# 给位置编码矩阵偶数列,赋值cos曲线特征
pe[:, 1::2] = torch.cos(my_matmulres)
# 形状变化 [6,512]-->[1,6,512]
pe = pe.unsqueeze(0)
# 把pe位置编码矩阵 注册成模型的持久缓冲区buffer; 模型保存再加载时,可以根模型参数一样,一同被加载
# 什么是buffer: 对模型效果有帮助的,但是却不是模型结构中超参数或者参数,不参与模型训练
self.register_buffer('pe', pe)
def forward(self, x):
# 注意:输入的x形状2*6*512 pe是1*6*512 形状 如何进行相加
# 只需按照x的单词个数 给特征增加位置信息
x = x + Variable( self.pe[:,:x.size()[1]], requires_grad=False)
return self.dropout(x)
2.编码层

编码层将上一部分也就是经过位置编码后的输入作为输入,并且可以设置多个编码器(N及其所包含的灰色区域)。
2.1多头注意力机制(自)
编码层中的多头注意力机制是自主意力机制,也就是。复习一下普通的注意力机制(掩码下一节解码器再讲):
def attention(query, key, value, mask=None, dropout=None):
# query, key, value:代表注意力的三个输入张量
# mask:代表掩码张量
# dropout:传入的dropout实例化对象
# 1 求查询张量特征尺寸大小
d_k = query.size()[-1]
# 2 求查询张量q的权重分布socres q@k^T /math.sqrt(d_k)
# [2,6,512] @ [2,512,6] --->[2,6,6]
scores = torch.matmul(query, key.transpose(-2, -1) ) / math.sqrt(d_k)
# 3 是否对权重分布scores 进行 masked_fill
if mask is not None:
# 根据mask矩阵0的位置 对sorces矩阵对应位置进行掩码
scores = scores.masked_fill(mask == 0, -1e9)
# 4 求查询张量q的权重分布 softmax
p_attn = F.softmax(scores, dim=-1)
# 5 是否对p_attn进行dropout
if dropout is not None:
p_attn = dropout(p_attn)
# 返回 查询张量q的注意力结果表示 bmm-matmul运算, 注意力查询张量q的权重分布p_attn
# [2,6,6]*[2,6,512] --->[2,6,512]
return torch.matmul(p_attn, value), p_attn
多头注意力机制的图:

多头注意力机制,顾名思义,也就是多个注意力机制组合在一起。先实现一个克隆的函数:
def clones(module, N):
return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
有利于我们实现多头。
简单来说,多头注意力机制会把你之前的词向量维度均分,分别实现一个并行的注意力机制结果,然后再进行合成。多头注意力机制会让一个任务被多个注意力均分,让每个注意力机制去优化每个词汇的不同特征部分,从而均衡同一种注意力机制可能产生的偏差,让词义拥有来自更多元的表达,实验表明可以从而提升模型效果。
class MultiHeadedAttention(nn.Module):
def __init__(self, head, embedding_dim, dropout=0.1):
super(MultiHeadedAttention, self).__init__()
# 确认数据特征能否被被整除 eg 特征尺寸512 % 头数8
assert embedding_dim % head == 0
# 计算每个头特征尺寸 特征尺寸512 // 头数8 = 64
self.d_k = embedding_dim // head
# 多少头数
self.head = head
# 四个线性层
self.linears = clones(nn.Linear(embedding_dim, embedding_dim), 4)
# 注意力权重分布
self.attn = None
# dropout层
self.dropout = nn.Dropout(p = dropout)
def forward(self, query, key, value, mask=None):
# 若使用掩码,则掩码增加一个维度[8,4,4] -->[1,8,4,4]
if mask is not None:
mask = mask.unsqueeze(0)
# 求数据多少行 eg:[2,6,512] 则batch_size=2
batch_size = query.size()[0]
# 数据形状变化[2,6,512] ---> [2,6,8,64] ---> [2,8,6,64]
# 6代表6个单词 8代表8个头 让句子长度6和句子特征64靠在一起 更有利捕捉句子特征
# -1的地方是序列长度6,写-1会自动计算
query, key, value = [model(x).view(batch_size, -1, self.head,
self.d_k).transpose(1,2)
for model, x in zip(self.linears, (query, key, value) ) ]
# 注意力结果表示x形状 [2,8,6,64] 注意力权重attn形状:[2,8,6,4]
x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)
# 数据形状变化 [2,8,6,64] ---> [2,6,8,64] ---> [2,6,512]
x = x.transpose(1,2).contiguous().view(batch_size, -1, self.head*self.d_k)
# 返回最后变化后的结果 [2,4,512]---> [2,4,512]
return self.linears[-1](x)
2.2前馈全连接层
前馈全连接层的作用: 考虑注意力机制可能对复杂过程的拟合程度不够, 通过增加两层网络来增强模型的能力。这个没什么多说的,就是两个线性层变换:
class PositionwiseFeedForward(nn.Module):
def __init__(self, d_model, d_ff, dropout=0.1):
# d_model 第1个线性层输入维度
# d_ff 第2个线性层输出维度
super(PositionwiseFeedForward, self).__init__()
# 定义线性层w1 w2 dropout
self.w1 = nn.Linear(d_model, d_ff)
self.w2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(p= dropout)
def forward(self, x):
# 数据依次经过第1个线性层 relu激活层 dropout层,然后是第2个线性层
return self.w2(self.dropout(F.relu(self.w1(x))))
2.3规范化层
对数据进行标准化,避免过大或过小:
class LayerNorm(nn.Module):
def __init__(self, features, eps=1e-6):
# 参数features 待规范化的数据
# 参数 eps=1e-6 防止分母为零
super(LayerNorm, self).__init__()
# 定义a2 反向传播规范化层的系数 y=kx+b中的k
self.a2 = nn.Parameter(torch.ones(features))
# 定义b2 反向传播规范化层的系数 y=kx+b中的b
self.b2 = nn.Parameter(torch.zeros(features))
self.eps = eps
def forward(self, x):
# 对数据求均值 保持形状不变
# [2,6,512] -> [2,6,1]
mean = x.mean(-1,keepdims=True)
# 对数据求方差 保持形状不变
# [2,6,512] -> [2,6,1]
std = x.std(-1, keepdims=True)
# 对数据进行标准化变换 反向传播可学习参数a2 b2
# 注意 * 表示对应位置相乘 不是矩阵运算
y = self.a2 * (x-mean)/(std + self.eps) + self.b2
return y
2.4子层链接
编码器图中的Add&Norm就是子层链接,包含了残差连接和上文的规范化层。
class SublayerConnection(nn.Module):
def __init__(self, size, dropout=0.1):
# 参数size 词嵌入维度尺寸大小
# 参数dropout 置零比率
super(SublayerConnection, self).__init__()
# 定义norm层
self.norm = LayerNorm(size)
# 定义dropout
self.dropout = nn.Dropout(dropout)
def forward(self, x, sublayer):
# 参数x 代表数据
# sublayer 函数入口地址 子层函数(前馈全连接层 或者 注意力机制层函数的入口地址)
# 方式1 # 数据self.norm() -> sublayer()->self.dropout() + x
myres = x + self.dropout(sublayer(self.norm(x)))
# 方式2 # 数据sublayer() -> self.norm() ->self.dropout() + x
# myres = x + self.dropout(self.norm(x.subtype(x)))
return myres
【总】
编码器层代码:
class EncoderLayer(nn.Module):
def __init__(self, size, self_atten, feed_forward, dropout):
super(EncoderLayer, self).__init__()
# 实例化多头注意力层对象
self.self_attn = self_atten
# 前馈全连接层对象feed_forward
self.feed_forward = feed_forward
# size词嵌入维度512
self.size = size
# clones两个子层连接结构 self.sublayer = clones(SublayerConnection(size,dropout),2)
self.sublayer = clones(SublayerConnection(size, dropout) ,2)
def forward(self, x, mask):
# 数据经过第1个子层连接结构
# 参数x:传入的数据 参数lambda x... : 子函数入口地址
x = self.sublayer[0](x, lambda x:self.self_attn(x, x, x, mask))
# 数据经过第2个子层连接结构
# 参数x:传入的数据 self.feed_forward子函数入口地址
x = self.sublayer[1](x, self.feed_forward)
return x
以及个层组成的编码器:
class Encoder(nn.Module):
def __init__(self, layer, N):
# 参数layer 1个编码器层
# 参数 编码器层的个数
super(Encoder, self).__init__()
# 实例化多个编码器层对象
self.layers = clones(layer, N)
# 实例化规范化层
self.norm = LayerNorm(layer.size)
def forward(self, x, mask):
# 数据经过N个层 x = layer(x, mask)
for layer in self.layers:
x = layer(x, mask)
# 返回规范化后的数据 return self.norm(x)
return self.norm(x)
3.解码层

解码器跟编码器差不太多,唯一一个就是这个掩码张量(Masked),以及多头注意力机制不是自注意力而是一个普通的。接下来就只讲解掩码张量。
3.1掩码张量
掩码张量,也就是一个张量、矩阵。【掩】字说明了它的作用,为了在实现注意力机制时掩盖后文内容,避免模型【未卜先知】。比如,对于之前的例子而言,对于生成“模”字,注意力机制就不应该考虑以后的数据,如“型”。因此,这个矩阵一般是个由0组成的上三角矩阵。

猜猜看两个部分在矩阵中的数值?
# 下三角矩阵作用: 生成字符时,希望模型不要使用当前字符和后面的字符。
# 使用遮掩mask,防止未来的信息可能被提前利用
# 实现方法: 1 - 上三角矩阵
# 函数 subsequent_mask 实现分析
# 产生上三角矩阵 np.triu(m=np.ones((1, size, size)), k=1).astype('uint8')
# 返回下三角矩阵 torch.from_numpy(1 - my_mask )
def subsequent_mask(size):
# 产生上三角矩阵 产生一个方阵
subsequent_mask = np.triu(m = np.ones((1, size, size)), k=1).astype('uint8')
# 返回下三角矩阵
return torch.from_numpy(1 - subsequent_mask)
【总】
class DecoderLayer(nn.Module):
def __init__(self, size, self_attn, src_attn, feed_forward, dropout):
super(DecoderLayer, self).__init__()
# 词嵌入维度尺寸大小
self.size = size
# 自注意力机制层对象 q=k=v
self.self_attn = self_attn
# 一遍注意力机制对象 q!=k=v
self.src_attn = src_attn
# 前馈全连接层对象
self.feed_forward = feed_forward
# clones3子层连接结构
self.sublayer = clones(SublayerConnection(size, dropout), 3)
def forward(self, x, memory, source_mask, target_mask):
m = memory
# 数据经过子层连接结构1
x = self.sublayer[0](x, lambda x:self.self_attn(x, x, x, target_mask))
# 数据经过子层连接结构2
x = self.sublayer[1](x, lambda x:self.src_attn (x, m, m, source_mask))
# 数据经过子层连接结构3
x = self.sublayer[2](x, self.feed_forward)
return x
class Decoder(nn.Module):
def __init__(self, layer, N):
# 参数layer 解码器层对象
# 参数N 解码器层对象的个数
super(Decoder, self).__init__()
# clones N个解码器层
self.layers = clones(layer, N)
# 定义规范化层
self.norm = LayerNorm(layer.size)
def forward(self, x, memory, source_mask, target_mask):
# 数据以此经过各个子层
for layer in self.layers:
x = layer(x, memory, source_mask, target_mask)
# 数据最后经过规范化层
return self.norm(x)
4.输出部分
# 解码器类 Generator 实现思路分析
# init函数 (self, d_model, vocab_size)
# 定义线性层self.project
# forward函数 (self, x)
# 数据 F.log_softmax(self.project(x), dim=-1)
class Generator(nn.Module):
def __init__(self, d_model, vocab_size):
# 参数d_model 线性层输入特征尺寸大小
# 参数vocab_size 线层输出尺寸大小
super(Generator, self).__init__()
# 定义线性层
self.project = nn.Linear(d_model, vocab_size)
def forward(self, x):
# 数据经过线性层 最后一个维度归一化 log方式
x = F.log_softmax(self.project(x), dim=-1)
return x
尾声
虽然最终结果也不是很理想,但还是进步了一些。我还在代码中改进了两个生成方式,大家可以试一试。
惜哉剑气疏/programs_0
https://gitee.com/zirui-shu/programs_0
让我们以生成的文本作为收官吧:
陈平安眯眼望向远方,一道金色剑气破开天幕,瞬间离开人间。陈平安一个骤然之间,以双指抵住地面,以手指抵住眉心处,然后一个迅猛拧转,将其碾碎,然后再次随手一抓,以武夫罡气布满整座小天地,与天地接壤的边境线之间,一条手臂横向上,直线向前。陈平安一个身形跃起,一脚踏地,一脚踩在那把剑锋刃之上,在剑尖处,重重吐出一口浊气,以那抹金光、流转的飞剑掠出养剑葫内。以更快速度远遁,再次丢掷出去,与此同时,与此同时,飞剑初一和十五骤然现身,悬停空中,剑尖直指大地。陈平安伸出手掌一抹剑光划破长空,如一把拉伸出并拢双指,好似捻住一张......
更多推荐


所有评论(0)