从零实现深度学习框架——Transformer从菜鸟到高手(二)

news2025/1/12 12:12:55

引言

💡本文为🔗[从零实现深度学习框架]系列文章内部限免文章,更多限免文章见 🔗专栏目录。

本着“凡我不能创造的,我就不能理解”的思想,系列文章会基于纯Python和NumPy从零创建自己的类PyTorch深度学习框架。

上篇文章中我们介绍了多头注意力,本文我们来了解Transformer Encoder模块剩下的组件,即残差连接、层归一化和前馈网络层。

Transformer架构

image-20230731105136951

图1. Transformer架构图

它也是一个encoder-decoder架构,左边是encoder,右边是decoder。我们先来看下它们内部的构件(从下到上)。

  • Encoder
    • Input Embedding:输入嵌入层
    • Positional Encoding:位置编码
    • Encoder Transformer Block:由于Encoder和Decoder的Block不同,这里区分来展开。
      • Multi-Head Attention:多头注意力
      • Add: 残差连接
      • (Layer) Norm:层归一化
      • (Position-wise) Feed Forward:位置级前馈网络
      • 上面是一个Block包含的内容,由于设计成了输入和输出的维度一致,因此可以堆叠N个。
  • Decoder
    • Output Embedding:输出嵌入层
    • Positional Encoding:位置编码
    • Decoder Transformer Block
      • Masked Multi-Head Attention:掩码多头注意力
      • Add: 残差连接
      • (Layer) Norm:层归一化
      • Multi-Head Attention:多头注意力
      • (Position-wise) Feed Forward:位置级前馈网络
      • 上面是一个Block包含的内容,由于设计成了输入和输出的维度一致,因此可以堆叠N个。
    • Linear:线性映射层
    • Softmax:输出概率

再回顾一下Transformer架构,已经实现的用红色标出。

残差连接

残差连接(residual connection,skip residual,也称为残差块)其实很简单,如下图所示:

image-20230821165356635

图2. 残差连接示意图

x \pmb x x为网络层的输入,该网络层包含非线性激活函数,记为 F ( x ) F(\pmb x) F(x),用公式描述的话就是:
y = x + F ( x ) (1) \pmb y = \pmb x + F(\pmb x) \tag 1 y=x+F(x)(1)
y \pmb y y是该网络层的输出,它作为第二个网络层的输入。有点像LSTM中的门控思想,输入 x \pmb x x没有被遗忘。

一般网络层数越深,模型的表达能力越强,性能也就越好。但随着网络的加深,也带来了很多问题,比如梯度消失、梯度爆炸。

image-20230821170459985

图3. ResNet-56,有无残差连接损失平面的区别,来自论文Visualizing the Loss Landscape of Neural Nets

可以看出来,增加了残差连接后,损失平面更加平滑,没有那么多局部极小值。直观地看,有了残差连接了, x \pmb x x的信息可以直接传递到下一层,哪怕中间 F ( x ) F(\pmb x) F(x)是一个非常深的网络,只要它能学到将自己的梯度设成很小,不影响 x \pmb x x梯度的传递即可。

还有一些研究(Residual networks behave like ensembles of relatively shallow networks)表明,深层的残差网络可以看成是不同浅层网络的集成。

残差连接实现起来非常简单,就是公式 ( 1 ) (1) (1)的代码化:

x = x + layer(x)

层归一化

层归一化想要解决一个问题,这个问题在Batch Normalization的论文中有详细的描述(参考小节中有论文笔记,建议阅读原论文),即深层网络中内部结点在训练过程中分布的变化(Internal Covariate Shift,ICS,内部协变量偏移)问题。

如果神经网络的输入都保持同一分布,比如高斯分布,那么网络的收敛速度会快得多。但如果不做处理的话,这很难实现。由于低层参数的变化(梯度更新),会导致每层输入的分布也会在训练期间变化。

考虑有sigmoid激活函数 z = g ( W u + b ) z=g(Wu+b) z=g(Wu+b)的网络层,其中 u u u是该层的输入; W W W b b b是可学习的参数,且 g ( x ) = 1 1 + exp ⁡ ( − x ) g(x) = \frac{1}{1 +\exp(-x)} g(x)=1+exp(x)1。随着 ∣ x ∣ |x| x增加, g ′ ( x ) g^\prime (x) g(x)趋向于 0 0 0。这意味着对于 x = W u + b x = Wu+b x=Wu+b 中除了绝对值较小的维度之外的所有维度,流向 u u u的梯度将消失,导致模型训练缓慢。然而,因为 x x x也被 W , b W,b W,b和所有后续层的参数影响,在训练期间改变这些参数值也可能将 x x x的很多维度移动到非线性上的饱和区域(见下图红线位置),减缓收敛速度。这种影响还会随着网络层数的加深而增强。实际中,该饱和和梯度消失问题通常通过使用ReLU激活单元来解决,并且需要小心地初始化,以及小的学习率,这也会导致训练过慢。

image-20230821172911227

图4. Sigmoid导函数图像

批归一化首先被提出来通过在深度神经网络中包含额外的归一化阶段来减少训练时间。批归一化通过使用训练数据中每个批次输入的均值和标准差来归一化每个输入。它需要计算累加输入统计量的移动平均值。在具有固定深度的网络中,可以简单地为每个隐藏层单独存储这些统计数据。针对的是同一个批次内所有数据的同一个特征。

然而批归一化并不适用于处理NLP任务的RNN(Transformer)中,循环神经元的累加输入通常会随着序列的长度而变化,而且循环神经元的需要计算的次数是不固定的(与序列长度有关)。

通常在NLP中一个批次内的序列长度各有不同,所以需要进行填充,存在很多填充token。如果使用批归一化,则容易受到长短不一中填充token的影响,造成训练不稳定。而且需要为序列中每个时间步计算和存储单独的统计量,如果测试序列不任何训练序列都要长,那么这也会是一个问题。

而层归一化针对的是批次内的单个序列样本,通过计算单个训练样本中一层的所有神经元(特征)的输入的均值和方差来归一化。没有对批量大小的限制,因此也可以应用到批大小为 1 1 1的在线学习。

批归一化是不同训练数据之间对单个隐藏单元(神经元,特征)的归一化,层归一化是单个训练数据对同一层所有隐藏单元(特征)之间的归一化。对比见下图:

img

图5. 层归一化和批归一化的对比,来自参考文章How does Layer Normalization work?

如上图右所示,批归一化针对批次内的所有数据的单个特征(Feature);层归一化针对批次内的单个样本的所有特征,它们都包含所有时间步。

说了这么多,那么具体是如何计算层归一化的呢?
y = x − E [ x ] Var [ x ] + ϵ ⋅ γ + β (2) \pmb y = \frac{\pmb x -E[\pmb x]}{\sqrt{\text{Var}[\pmb x] + \epsilon}} \cdot \pmb\gamma + \pmb\beta \tag 2 y=Var[x]+ϵ xE[x]γ+β(2)
x \pmb x x是归一化层的输入; y \pmb y y是归一化层的输出(归一化的结果);

γ \pmb \gamma γ β \pmb \beta β是为归一化层每个神经元(特征)分配的一个自适应的缩放和平移参数。这些参数和原始模型一起学习,可以恢复网络的表示。通过设置 γ ( k ) = Var [ x ( k ) ] \gamma^{(k)} = \sqrt{\text{Var}[\pmb x^{(k)}]} γ(k)=Var[x(k)] β ( k ) = E [ x ( k ) ] \beta^{(k)}=E[\pmb x^{(k)}] β(k)=E[x(k)],可以会输入恢复成原来的激活值,如果模型认为有必要的话;

ϵ \epsilon ϵ是一个很小的值,防止除零。

class LayerNorm(Module):
    def __init__(self, features: int, eps: float = 1e-6):
        """

        Args:
            features: 特征个数
            eps:
        """

        super().__init__()
        self.gamma = Parameter(Tensor.ones(features))
        self.beta = Parameter(Tensor.zeros(features))
        self.eps = eps

    def forward(self, x: Tensor) -> Tensor:
        """

        Args:
            x: (batch_size, input_len, emb_size)

        Returns:

        """
        mean = x.mean(-1, keepdims=True)
        std = x.std(-1, keepdims=True)
        return self.gamma * (x - mean) / (std + self.eps) + self.beta

FFN

Position-wise Feed Forward(FFN),逐位置的前馈网络,其实就是一个全连接前馈网络。

它一个简单的两层全连接神经网络,不是将整个嵌入序列处理成单个向量,而是独立地处理每个位置的嵌入。所以称为position-wise前馈网络层。也可以看为核大小为1的一维卷积。

目的是把输入投影到特定的空间,再投影回输入维度。

class PositionWiseFeedForward(nn.Module):
    '''
    实现FFN网路
    '''

    def __init__(self, d_model, d_ff, dropout=0.1):
        """

        Args:
            d_model: 模型大小
            d_ff: FF层的大小,2
            dropout:
        """
        super().__init__()
        # 将输入转换为d_ff维度
        self.linear1 = nn.Linear(d_model, d_ff)
        # 将d_ff转换回d_model
        self.linear2 = nn.Linear(d_ff, d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        # 经过三次变换: 线性->非线性->线性
        return self.dropout(self.linear2(F.relu(self.linear1(x))))

我们继续之前的例子:

import numpy as np

embed_dim = 512
vocab_size = 5000

num_heads = 8
# 第二个样本包含两个填充
input_data = Tensor(
    np.array([[1, 2, 3, 4, 5], [6, 7, 8, 0, 0]])
)
# batch_size = 2
# seq_len = 5
batch_size, seq_len = input_data.shape

embedding = nn.Embedding(vocab_size, embed_dim)
# 模拟嵌入层
input_embeds = embedding(input_data)

mask = generate_mask(input_data)

attn = MultiHeadAttention(embed_dim=embed_dim, num_heads=num_heads)

ffn = PositionWiseFeedForward(embed_dim, d_ff=2048)

# 编码器中,query、key、value来自同一个嵌入
values = attn(input_embeds, input_embeds, input_embeds, mask)
print(values.shape)
output = ffn(values)
print(values.shape)
(2, 5, 512)
(2, 5, 512)

可以看到FFN并没有改变输入的维度,下面通过全1输入证明它对每个位置的作用是一样的:

input_data = Tensor.ones(2, 5)
ffn = PositionWiseFeedForward(5, d_ff=2048)
ffn.eval() # 抑制dropout
print(ffn(input_data))
Tensor(
[[-2.9619  1.7855  2.1681 -1.176  -0.2168]
 [-2.9619  1.7855  2.1681 -1.176  -0.2168]], requires_grad=True)

可以看到,由于这两个输入是完全一样的,它们经过这个FFN得到的结果也是一样的,这里要取消dropout,否则可能(被dropout后)不一样了。可以理解为这种线性变换同等地应用到输入的每个位置上。

Post-LN v.s. Pre-LN

在Transformer中应用层归一化时,通常有两种选择。即Post-Layer Normalization(Post-LN)和Pre-Layer Normalization(Pre-LN)。

所谓Post-LN,即原始的Transformer将层归一化放置在残差连接之间,这被称为是Post-Layer Normalization(Post-LN)的做法。

image-20230822162159047

图6. 两种LN的对比

见上图中的Post-LN,可以看到层归一化在两个残差连接(块)中间;而Pre-LN的设计下层归一化在残差连接内部。

Post-LN也是Transformer的默认做法。但这种方式很从零开始训练,把层归一化放到残差块之间,接近输出层的参数的梯度往往较大。然后在那些梯度上使用较大的学习率会使得训练不稳定。通常需要用到学习率预热(warm-up)技巧,在训练开始时学习率需要设成一个极小的值,然后在一些迭代后逐步增加,最后再持续衰减。同时还需要小心地初始化模型的参数。

将层归一化放到残差连接的范围内,即Pre-LN。这在训练期间往往更加稳定,收敛更快,并且通常不需要任何学习率预热。在论文ON LAYER NORMALIZATION IN THE TRANSFORMER ARCHITECTURE中有详细的介绍,也可以看后面的参考文章,论文作者说使用Pre-LN的方式可以安全地移除学习预热阶段。其实如果仔细看的话,输入流有可能直接流向最后的输出,残差连接内的网络层相当于不存在了,对于梯度也是一样。

但Pre-LN真的是又快又好吗?最近人们认为,Pre-LN虽然更容易训练,但最终效果往往不如Post-LN好。

我们来看Pre-LN对应的公式:
x t + 1 = x t + F ( Norm ( x t ) ) (3) \pmb x_{t+1} = \pmb x_t +F(\text{Norm}(\pmb x_t)) \tag 3 xt+1=xt+F(Norm(xt))(3)
这里 Norm \text{Norm} Norm代表层归一化; F F F表示另一个网络层,比如多头注意力或FFN,括号内的参数表示作为输入。

对它从 t + 2 t+2 t+2处展开有:
x t + 2 = x t + 1 + F ( Norm ( x t + 1 ) ) = x t + F ( Norm ( x t ) ) + F ( Norm ( x t + 1 ) ) \begin{aligned} \pmb x_{t+2} &= \pmb x_{t+1} +F(\text{Norm}(\pmb x_{t+1})) \\ &= \pmb x_t + F(\text{Norm}(\pmb x_{t})) +F(\text{Norm}(\pmb x_{t+1})) \end{aligned} xt+2=xt+1+F(Norm(xt+1))=xt+F(Norm(xt))+F(Norm(xt+1))
实际上是增加了网络的"宽度",网络的深度并没有增加。因为当 t t t较大时, x t + 1 \pmb x_{t+1} xt+1 x t \pmb x_t xt的差别是很小的。

怎么理解网络的宽度呢?下图就可以很好地理解:

image-20230822170910021

图6 网络的宽度和深度

这样我们了解了实现Encoder Transformer Block的所有细节。实现起来就不难了,我们这里支持Post-LN和Pre-LN二选一。

class TransformerEncoderLayer(nn.Module):
    def __init__(self, d_model: int, num_heads: int, dim_feedforward: int = 2048, dropout: float = 0.1,
                 norm_first: bool = False):
        """

        Args:
            d_model: 输入的特征个数
            num_heads: 多头个数
            dim_feedforward: FFN中的扩张的维度大小,通常会比d_model要大
            dropout:
            norm_first: 为True记为Pre-LN;默认为False对应的Post-LN。
        """

        super().__init__()
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.attn = MultiHeadAttention(d_model, num_heads, dropout)
        self.feed_forward = PositionWiseFeedForward(d_model, dim_feedforward, dropout)
        self.dropout1 = nn.Dropout(dropout)
        self.dropout2 = nn.Dropout(dropout)

        self.norm_first = norm_first

    def forward(self, src: Tensor, src_mask: Tensor = None) -> Tensor:
        x = src
        if self.norm_first:
            # 层归一化
            x = self.norm1(x)
            # 多头注意力 -> 残差连接
            x = x + self.dropout1(self.attn(x, x, x, src_mask))
            # 层归一化 -> FFN -> 残差连接
            x = x + self.dropout2(self.feed_forward(self.norm2(x)))
        else:
            # 多头注意力 -> 残差连接 -> 层归一化
            x = self.norm1(x + self.dropout1(self.attn(x, x, x, src_mask)))
            x = self.norm2(x + self.dropout2(self.feed_forward(x)))

        return x

我们测试一下前向传播:

import numpy as np

embed_dim = 512
vocab_size = 5000

num_heads = 8
# 第二个样本包含两个填充
input_data = Tensor(
    np.array([[1, 2, 3, 4, 5], [6, 7, 8, 0, 0]])
)
# batch_size = 2
# seq_len = 5
batch_size, seq_len = input_data.shape

embedding = nn.Embedding(vocab_size, embed_dim)
# 模拟嵌入层
input_embeds = embedding(input_data)

mask = generate_mask(input_data)

encoder_layer = TransformerEncoderLayer(embed_dim, num_heads,dim_feedforward=2048)
output = encoder_layer(input_embeds, mask)
print(output.shape)
(2, 5, 512)

参考

  1. [论文翻译]Attention Is All You Need
  2. The Annotated Transformer
  3. Speech and Language Processing
  4. The Illustrated Transformer
  5. [论文笔记]Batch Normalization
  6. [论文笔记]Layer Normalization
  7. [论文笔记]ON LAYER NORMALIZATION IN THE TRANSFORMER ARCHITECTURE
  8. How does Layer Normalization work?

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/915510.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

C++信息学奥赛2046:【例5.15】替换字母

这段代码的功能是对输入的字符串进行处理&#xff0c;将字符串中的字符 a 替换为字符 b 后输出结果。 #include<bits/stdc.h> using namespace std; int main() {string s; // 定义字符串变量s&#xff0c;用来存储输入的字符串char a, b; // 定义字符变量a和b&#xff…

港科夜闻|香港科大将与世界经济论坛合作举办大中华区首个全球青年领袖论坛领导力发展课程,与全球青年领袖共同探讨人工智能...

关注并星标 每周阅读港科夜闻 建立新视野 开启新思维 1、​香港科大将与世界经济论坛合作举办大中华区首个"全球青年领袖论坛领导力发展课程",与全球青年领袖共同探讨人工智能。香港科大下月将与世界经济论坛合作&#xff0c;接待40位来自包括欧洲、拉丁美洲、非洲和…

采用typescript编写,实现ofd前端预览、验章

前言 浏览器内核已支持pdf文件的渲染&#xff0c;这极大的方便了pdf文件的阅读和推广。ofd文件作为国产板式标准&#xff0c;急需一套在浏览器中渲染方案。 本人研究ofd多年&#xff0c;分别采用qt、c# 开发了ofd阅读器。本人非前端开发人员&#xff0c;对js、typescript并不熟…

GNU-gcc编译选项-1

include目录 -I &#xff0c;比如: -I. -I ./Platform/include -I ./Platform/include/prototypes -I ./tpm/include -I ./tpm/include/prototypes -I ./Simulator/include -I ./Simulator/include/prototypes 编译选项 在GCC编译器中&#xff0c;-D是一个编译选项&…

京东CEO许冉的第一份成绩单 我们打分:80!

大数据产业创新服务媒体 ——聚焦数据 改变商业 2023年8月16日&#xff0c;京东发布了截至2023年6月30日的二季度财报及中期业绩。这也是京东集团CEO许冉由CFO升任CEO后交出的第一份成绩单。 在看成绩单之前&#xff0c;我们先回顾一下许冉上任时京东的状况。当时&#xff0c;…

群晖上用Docker安装OpenWrt

什么是 OpenWrt &#xff1f; OpenWrt 是一款基于 Linux 系统的开源路由器操作系统&#xff0c;可以将普通的 PC 或嵌入式设备转变成为一个功能强大的路由器。 老苏对没玩过的东西总是比较好奇&#xff0c;准备用 Docker 搭建一个 OpenWrt 来研究研究。 网上管这种玩法叫旁路路…

Xmake v2.8.2 发布,官方包仓库数量突破 1k

Xmake 是一个基于 Lua 的轻量级跨平台构建工具。 它非常的轻量&#xff0c;没有任何依赖&#xff0c;因为它内置了 Lua 运行时。 它使用 xmake.lua 维护项目构建&#xff0c;相比 makefile/CMakeLists.txt&#xff0c;配置语法更加简洁直观&#xff0c;对新手非常友好&#x…

ChatGPT只是玩具:生成式人工智能在不同行业的应用

源自&#xff1a;IT经理网 生成式人工智能的十一个行业用例 打开生成式 AI的正确姿势 声明:公众号转载的文章及图片出于非商业性的教育和科研目的供大家参考和探讨&#xff0c;并不意味着支持其观点或证实其内容的真实性。版权归原作者所有&#xff0c;如转载稿涉及版权等问题&…

ssm在线云音乐系统的设计与实现

ssm在线云音乐系统的设计与实现042 开发工具&#xff1a;idea 数据库mysql5.7 数据库链接工具&#xff1a;navcat,小海豚等 技术&#xff1a;ssm 摘 要 随着移动互联网时代的发展&#xff0c;网络的使用越来越普及&#xff0c;用户在获取和存储信息方面也会有激动人心的…

代码随想录算法训练营(回溯总结篇)

回溯也可以说是暴力搜索&#xff08;最多剪枝一下&#xff09;。回溯是递归的副产品&#xff0c;只要有递归就会有回溯。 一.分类 1.组合问题 &#xff08;1&#xff09;按组合元素的个数 &#xff08;2&#xff09;按组合元素的总和 有重复元素 同一元素可以重复选&#x…

【C语言】喝汽水问题

大家好&#xff01;今天我们来学习C语言中的喝汽水问题&#xff01; 目录 1. 题目内容&#xff1a; 2. 思路分析 2.1 方法一 2.2 方法二 2.3 方法三 3. 代码实现 3.1 方法一 3.2 方法二 3.3 方法三 1. 题目内容 喝汽水&#xff0c;1瓶汽水1元&#xff0c;2个空瓶可以…

Java学习笔记37

Java笔记37 TCP案例 TCP实现发送消息 下面我们来分别编写一个客户端程序和一个服务端程序&#xff0c;使用用户端给服务端发送一句消息&#xff0c;然后使用服务端接收用户端发送过来的这句消息并打印输出。 客户端&#xff1a; 创建一个与服务端Socket类的实例对象&#xf…

基于java+mysql+控件台学生信息管理系统

基于javamysql控件台学生信息管理系统 一、系统介绍二、功能展示四、其他系统实现五、获取源码 一、系统介绍 项目类型&#xff1a;Java SE项目&#xff08;控制台打印&#xff09; 项目名称&#xff1a;基于Java学生信息管理系统&#xff08;student_sys) 当前版本&#xf…

寻找重复数-快慢指针

给定一个包含 n 1 个整数的数组 nums &#xff0c;其数字都在 [1, n] 范围内&#xff08;包括 1 和 n&#xff09;&#xff0c;可知至少存在一个重复的整数。 假设 nums 只有 一个重复的整数 &#xff0c;返回 这个重复的数 。 你设计的解决方案必须 不修改 数组 nums 且只用常…

使用 MATLAB 和 Simulink 对雷达系统进行建模和仿真

&#x1f4a5;&#x1f4a5;&#x1f49e;&#x1f49e;欢迎来到本博客❤️❤️&#x1f4a5;&#x1f4a5; &#x1f3c6;博主优势&#xff1a;&#x1f31e;&#x1f31e;&#x1f31e;博客内容尽量做到思维缜密&#xff0c;逻辑清晰&#xff0c;为了方便读者。 ⛳️座右铭&a…

ClickHouse(二十五):ClickHouse 可视化工具操作

​​​​​​​ 进入正文前&#xff0c;感谢宝子们订阅专题、点赞、评论、收藏&#xff01;关注IT贫道&#xff0c;获取高质量博客内容&#xff01; &#x1f3e1;个人主页&#xff1a;含各种IT体系技术&#xff0c;IT贫道_Apache Doris,大数据OLAP体系技术栈,Kerberos安全认证…

vite创建项目命令

1.第一步运行创建命令&#xff08;npm&#xff09; npm create vitelatest也可以使用yarn yarn create vite还可以 pnpm create vite注意的地方&#xff1a;首次创建的时候会出现这个 Need to install the following packages:create-vitelatest Ok to proceed? (y) 直接y就…

如何设计一个订单号生成服务?

一、数据量的大小二、有意义的ID三、高可用四、高性能五、唯一性六、包含分库分表的业务字段七、实战 1) Redis2) Leaf-segment 数据库生成3)Leaf-snowflake方案4) UUID 如何设计要给订单号生成服务 一、数据量的大小 在设计订单号的生成服务时候&#xff0c;我们首先要考虑的…

【深度学习 | ResNet核心思想】残差连接 跳跃连接:让信息自由流动的神奇之道

&#x1f935;‍♂️ 个人主页: AI_magician &#x1f4e1;主页地址&#xff1a; 作者简介&#xff1a;CSDN内容合伙人&#xff0c;全栈领域优质创作者。 &#x1f468;‍&#x1f4bb;景愿&#xff1a;旨在于能和更多的热爱计算机的伙伴一起成长&#xff01;&#xff01;&…