文章目录
本节我们主要介绍Transformer的重要的第一部分
我会非常注重代码的解析~因为那是我最初学习的痛苦的来源,希望你们不受这些痛苦折磨哈哈哈
1 为什么要用?
Transformer本身并没有位置信息~该怎么理解呢?
具体来说,就是在不适用位置编码的情况下,更换词语的位置,输出结果是不会发生变化的
比如这两句话
“你喜欢她”和“她喜欢你”
两句哈意思差别很大(不过还是希望两句都成立,你们双向奔赴哈哈哈)
但是送到Transformer里面,出来的结果是一样的,因而是不行的,这会闹出笑话的
因而研究者们一拍桌子,不行,需要给Transformer输入嵌入位置编码
2 直接使用,直观感受
我们先直观感受一下Positional_encoding怎么使用
首先对于一批数据(往往是固定数量的句子,假设有4个句子,每个句子有12个字)我们取字符级Token,每个Token会被转换为512维度向量~
春风拂面桃花盛开景色宜人
夜幕降临繁星点点月色朦胧
书页翻动墨香四溢知识海洋
晨跑归来汗水淋漓心情愉悦
最后输入就是(4,12,512)分别代表的含义是4个句子,每个句子12个token,每个token是512维度的。
OK ,那么实际上位置编码就在原本这个输入的基础上加一个量PE,这个量怎么计算呢?大家肯定在很多文章上看到这样一个公式
其中的pos代表某一个token在一个句子中的位置,比如”春风拂面桃花盛开景色宜人“这句话中的春 pos=0,风 pos=1,佛 pos=2 ……以此类推
i代表某一个token的第i个维度,当i为偶数时,加sin,当i为奇数时候,加cos,d_model就是512维度的
可能还是有点抽象,我们马上举一个例子,在举这样一个例子之前,请大家思考一个问题
不同句子的位置编码一样吗?
实际上你会发现,是一样的!
所以我们只需要研究清楚一个句子的位置编码,计算一遍,然后给这一批次(batch)中每一个句子都加一次相同的位置编码即可
好滴,那我们聚焦于一个句子
如下图计算
注意我们是从0开始的,所以纵的0-11,横的0-512
然后一个句子是这样计算PE的,实际上每一个句子都是这样的~
这时候你可能懂了,然后也理解了计算方法
3 代码初探 写法一
你觉得比较简单,这不就是两个循环解决的事情吗哈哈哈
然后你可能会写出这样的代码(好吧,实际这是我写的代码)不过这是直观最容易想到的代码
import math
import torch
from torch import nn
input=torch.ones(4,10,512) #模拟一个输入,输入量都是1(实际中不可能都是1,只是为了简单起见)
def positional_encoding(input): #对单个句子做计算
num=input.size(0) #获得句子的token数,相当于上面的行数
ndim=input.size(1) #获得每个的token维度,相当于上面的列数
for k in range(num):
for i in range(ndim):
if i%2==0:
input[k][i]+=math.sin(k/(10000.0**(2*i/ndim)))
else:
input[k][i]+=math.cos(k/(10000.0**(2*i/ndim)))
return input.unsqueeze(0)
temp=[]
for x in input:
temp.append(positional_encoding(x))
output=torch.cat(temp,dim=0)
print(output.shape)
print(output)
这种写法很烂
直观来看
首先
1.每个句子的位置编码是一样的,我却多计算了好多遍
2.再者两层循环,Python的循环慢的要死
虽然这种写法很烂,但我还要给自己树一个大拇指
我们接下来学习大师的写法
4 高手代码 写法二
其实上面的代码典型的CPU思维,也就是没有用到矩阵,而我们知道深度学习最重要的就是并行计算,并行计算怎么实现,实际上也就是通过矩阵的方式实现,通过空间换时间
而高手是通过torch进行矩阵并行运算
看看官方代码
import math
import torch
from torch import nn
input=torch.ones(4,10,512)
#尽量减少循环的使用~
class PositionalEncoding(nn.Module):
"Implement the PE function."
def __init__(self, d_model, dropout, max_len=5000):
super(PositionalEncoding, self).__init__()
# Compute the positional encodings once in log space.
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2) *
-(math.log(10000.0) / 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)
self.dropout = nn.Dropout(p=dropout)
def forward(self, x):
x = x + self.pe[:, :x.size(1)].detach()
return self.dropout(x)
positional_encoding=PositionalEncoding(64,0.1)
output=positional_encoding(input)
print(output.shape)
print(output)
虽然,短短几行代码,却凝缩了大智慧~
从上往下,我们依次讲解,首先我们还是模拟上面的例子,给一个输入(4,10,512)
4.1 PE矩阵的计算
1
大师申明了一个类,并将pe的计算放在了初始化函数里面,妙啊!这样只需要初始化时候计算一遍,之后就可以一直用了~不用每次前向传递的时候计算
2
首先 pe = torch.zeros(max_len, d_model) 初始化一个pe矩阵
position = torch.arange(0, max_len).unsqueeze(1) 这里生成了一个从0到可能的最大长度矩阵(5000),然后用unsqueeze函数增加一维度之后变为(1,5000)
position实际上就是上面原理中讲到的pos,我们写法一是通过循环得到pos的,写法二直接一个等差列矩阵生成pos 就很快
关于张量形状变换的总结,可以看我这篇博客
【pytorch进阶】| 各类张量形状变化函数总结对比分析,view,reshape,pernute,transpose,squeeze,unsqueeze-CSDN博客
3
div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model)) div_term 是计算分母一项,你可能发现分母不太一样和公式里的,是因为进行了指数对数变换,pos我们不需要考虑,前面已经计算过了,只考虑分母部分
具体如下
1 1000 0 2 i / d m o d e l = e − l n ( 1000 0 2 i / d m o d e l ) = e − 2 i / d m o d e l ∗ l n 10000 \frac{1}{10000^{2i/d_{model}}}=e^{-ln(10000^{2i/d_{model})}}=e^{-2i/d_{model}*ln10000} 100002i/dmodel1=e−ln(100002i/dmodel)=e−2i/dmodel∗ln10000 然后你对应代码看一看就可以理解了
这里为什么要折腾指数变换,其实我还是有一点疑惑的,有朋友了解可以在评论区告诉我,我之后懂了会补充回来,据说是为了稳定性?
4
接下来就通过矩阵切片操作
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
实现并行计算~
4.2 注册缓冲区
代码中有一句 是self.register_buffer(‘pe’, pe)
我查询后得知
register_buffer 注册缓冲区,缓冲区的作用:缓冲区通常用于存储模块的状态,但不被视为模型参数,缓冲下来后就不用每次都计算了。
例如,BatchNorm 的 running_mean
就是一个缓冲区,它记录了模块的状态。
在深度学习模型中,存储模块的状态是非常重要的。这些状态可能包括但不限于学习率、优化器的状态、BatchNorm层的运行平均值和方差等。这些状态对于模型的训练和推理都是非常重要的。
- 模型训练:在模型训练过程中,我们需要跟踪和更新这些状态。例如,BatchNorm层的运行平均值和方差会在每个batch训练时进行更新,这对于模型的训练是必要的。
- 模型保存和加载:当我们保存模型时,除了模型的参数,我们还需要保存这些状态。这样,当我们重新加载模型进行推理或者继续训练时,我们可以从上次训练结束的状态开始,而不是从头开始。
- 模型推理:在模型推理阶段,我们需要使用这些状态。例如,BatchNorm层在推理时会使用存储的运行平均值和方差,而不是当前batch的平均值和方差。
在大师的代码中,self.register_buffer('pe', pe)
这行代码是在将位置编码(positional encoding)保存为模块的状态。这样,无论何时我们需要使用这个模块,位置编码都是可用的,而不需要每次都重新计算。
缓冲区默认是持久的,什么意思呢?就是会与参数一起保存在模块的 state_dict
属性中。当然,我们可以通过将 persistent
参数设置为 False
来改变这种行为。非持久的缓冲区不会包含在模块的 state_dict
中。
4.3 加入dropout 正则化
dropout正则化可以防止过拟合
将某一些位置的值置零
4.4 前向过程
利用广播机制为每一个句子都添加位置编码
5 本质原理
为什么这样做,为什么这样做位置编码很高效?
本质是因为 用cos,sin这样的方式既可以表现相对距离,又可以表现绝对距离
具体的数学推到可以看
Transformer升级之路:1、Sinusoidal位置编码追根溯源 - 科学空间|Scientific Spaces
6 思考
虽然这部分代码很短,却给我了很多思考
包括如何利用矩阵替代循环计算,高效利用广播机制等等,值得反复思考