发布于 2026-07-11 18:23:07

《动手学深度学习》学习记录

李沐的《动手学深度学习》是一个非常不错的深度学习入门教材。在学习一段时间后,我觉得有必要以笔记的形式记录下我对每个章节的概括以及一些关键问题的认识和理解(在学习过程中产生的但教材没有解答的一些疑问和经过研究与思考后我的理解),也能强化自己对深度学习的理解。

本文的章节组织形式将与D2L中文版教材基本相同。

前言、安装与符号

前言:主要介绍了教材的内容与结构,并使用Jupyter Notebook的形式来组织。

安装:主要介绍如何配置运行Python、Jupyter Notebook、相关库以及运行本书所需的代码等所需的环境。由于教材及其配套代码的最终更新日期已是23年,所以相关库依赖与当前最新版本存在一些冲突。另外如果是在Windows平台上使用,还涉及到我之前在关于python与pytorch的各种配置问题与迷思中提到的一些问题。因此在安装pytorch和d2l库时,建议通过以下方法安装:

conda create -n d2l python=3.14
conda activate d2l

conda install pytorch torchvision
pip install d2l --no-deps # 不直接安装d2l包声明的依赖,而是手动安装依赖
pip install matplotlib pandas requests scipy numpy

符号:介绍了教材中所使用的各类数学符号的含义,包括数值对象、集合论、函数和运算符、微积分、概率统计与信息论等领域。

引言

引入机器学习(深度学习),表达它与“业务逻辑”的关键区别在于:机器学习是一类强大的可以从经验中学习的技术,而业务逻辑执行的一直是相同的任务,无论积累多少经验,都不会自动提高,除非开发人员认识到问题并更新软件。

机器学习的关键组件其实只有4个:数据模型目标函数优化算法

机器学习问题大致可以按如下方式分类:

  • 监督学习
    • 回归
    • 分类
    • 标记问题(多标签分类,学习预测不相互排斥的类别的问题)
    • 搜索
    • 推荐系统
    • 序列学习
  • 无监督学习
    • 聚类
    • 主成分分析
    • 因果关系和概率图模型
    • 生成对抗性网络
  • 强化学习(会与环境互动)

近些年深度学习的发展主要归功于数据、算力的增长以及日益强大的开源infra工具链。

深度学习方法中最显著的共同点是使用端到端训练,通过力大砖飞的思想,取代了传统机器学习管道末端的浅层模型以及劳动密集型的特征工程,实现了一种相对简单、通用、大一统的方法来解决各类机器学习问题。正如《苦涩的教训》所表达的观点:利用人类知识构建AI的方法最终都会失效,基于大规模算力和通用搜索/学习算法的方法才是最有效的。

预备知识

对于数学知识来说,我认为重要性和难度排序是(仅针对入门):概率统计 > 微积分 > 线性代数。

数据操作

其他内容基本中规中矩,理解广播机制比较重要。python的list相加,其实是拼接操作;而Tensor的相加其实是两个Tensor按元素相加的操作。

当两个Tensor维度不匹配的时候,会从最后一个维度来依次往前匹配,若有一个维度的大小不匹配,就无法相加,除非其中一个维度的大小是1,此时会应用广播机制,将该维度的元素按另一个维度的大小复制相同的份数,再进行计算。例如下面的代码会得到一个全为2的、shape为[4, 6, 3, 3, 6, 7]的Tensor。

t1=torch.ones((    3,1,6,7))
t2=torch.ones((4,6,1,3,1,1))
print((t1+t2).shape)

数据预处理

介绍了怎么借助pandas来做数据清洗。

线性代数

主要介绍如何在pytorch中对标量、向量、矩阵和张量执行各种操作。

除了最基本的操作之外,本节介绍了一个比较重要的操作即通过形如A.sum(axis=1, keepdims=True)的方式进行按某维度求和与非降维求和(便于借助广播机制做下一步计算)。

微积分

介绍微积分中的几个关键概念:导数偏导数梯度链式法则

单变量函数可以求导数,多变量可以求偏导数。连结一个多元函数对其所有变量的偏导数,可以得到该函数的梯度向量:

所以梯度是一个向量,每个分量是函数对某个变量的偏导数。多元函数的全微分可以看作是梯度向量与自变量位移向量的点积。

链式法则为我们做导数(梯度)计算提供了一种循序渐进的方式,通过将一个复杂函数拆解为一系列简单函数的复合函数,实现将复杂函数的梯度计算拆解为一系列简单函数的梯度计算结果的乘积。

自动微分

主要介绍如何利用pytorch做“自动的”梯度计算。这里的“自动”,指的是在完成计算函数值 的过程(称为前向传播)后,可以通过执行y.backward()反过来把计算过程中涉及到的变量的梯度计算出来(称为反向传播)。通过x.grad可以取到执行反向传播的那个变量对x的梯度,在这个例子中就是的梯度,也就是

本节只讲用法,不讲原理。具体的实现过程会在后面的章节中详细介绍。

概率

介绍概率论中的一些基本概念,包括:

  • 随机变量的定义
  • 联合概率:
  • 条件概率:
  • 贝叶斯定理:
  • 边际化:从包含多个变量的联合概率分布中,通过累加或积分不需要的变量,来计算出单个或部分变量的边缘概率分布的过程
  • 独立性: ,两个随机变量是独立的,当且仅当两个随机变量的联合分布是其各自分布的乘积
  • 期望与方差

本节介绍的概率论的概念比较基础,想要完全理解后续章节的各类方法的理论推导还远远不够,不过下面我还会对其他的需要的概率知识做补充,虽然不一定能做到非常严密的推导,但至少可以从思维逻辑上讲清楚。

另外还有两个基础概念是本节没有介绍的,这里做个补充:

  • 概率质量/密度函数:
    • 概率质量函数(PMF):专门用于离散型随机变量。它直接表示随机变量取到某个具体数值的真实概率。例如,掷一枚硬币正面朝上的概率。
    • 概率密度函数(PDF):专门用于连续型随机变量。因为连续变量取某一个精确值的概率为零,PDF描述的是变量落在某个区间内的“概率密集程度”(需要对区间求积分才能得到概率)。
  • 累积分布函数(CDF):表示随机变量的取值小于或等于某一特定数值的概率,等于PMF累加或PDF积分

线性神经网络

线性神经网络是深度学习的基础,真正搞懂它是非常重要的。

机器学习领域中的大多数任务通常都与预测有关。本章介绍了两类线性神经网络:线性回归softmax回归,分别解决数值预测分类预测问题。核心目的是:通过最简单的神经网络,讲清楚深度学习的基本概念和流程。

这里的神经网络,指的就是机器学习的4个关键组件之一——模型,而线性神经网络指的就是我们使用的模型是线性的。

如果我们能够得到输出(标签) 关于输入(特征) 的函数,我们就可以说我们能够实现预测从本质上讲,深度学习就是设计这个函数所使用的模型,并用大量的输入输出数据去拟合函数,得到函数模型的参数的过程。

线性回归

基本假设:线性回归解决数值预测问题,它采用线性的函数模型来进行预测,它的关键假设是:自变量和因变量之间的关系是线性的, 即可以表示为中元素的加权和。

模型:当我们的输入包含个特征时,我们将预测结果表示为 ,或者更简洁的点积形式 。当我们用矩阵 表示整个数据集的个样本时(每一行是一个样本,每一列是一种特征),个样本的预测值向量

损失函数:我们的目的是希望通过样本数据,得到最符合样本数据的。为了衡量“符合”的程度,我们需要定义一个关于的函数,即损失函数,其中特征、标签、预测值均为参数,我们的目标是寻找一组参数,能最小化在所有训练样本上的总损失

这里,我们的损失函数是平方损失,即标签与预测值之差的平方。但为什么是平方损失?这是因为我们假设了观测中包含噪声,其中噪声服从正态分布,通过极大似然估计法,参数的最优值是使整个数据集的似然(通过给定观测到特定)最大的值。最终可以推导出:在高斯噪声的假设下,最小化均方误差等价于对线性模型的极大似然估计。

需要注意的是,我们要优化的问题是损失函数的值,损失函数相当于在模型函数(输出关于输入的函数)基础上用样本数据做了复合,并把函数的自变量从输入变成了模型参数。

优化算法:其实线性回归问题是有解析解的,想象一下:先假设没有观测误差的情况,每一个样本数据就可以列出一个关于的方程,只要线性无关的样本数量与参数(也就是方程的未知数)相同,就可以得到方程组的唯一解;再假设有观测误差的情况,也就是最小化平方损失,我们合并中,只需要设其对的导数为0,就可以得到解析解。但并不是所有的问题都存在解析解,所以我们采用数值方法得到数值解,这种方法叫做随机梯度下降

梯度的定义是“函数增长最快的方向”,于是我们可以在求出损失函数关于参数的梯度后,通过每次沿着梯度的反方向修改的值,就可以不断降低损失函数的值。

为什么沿着梯度的反方向就可以不断降低损失函数的值?我们先设想一维场景:关于的导数表达了的变化方向和变化率,为正则表示的增加而增加,为负则表示的增加而减少,所以随着导数的反方向调整的值意味着朝着减少的方向调整。这个导数扩展到多维场景就是梯度。

softmax回归

softmax回归核心要解决的问题就是:如何将线性模型应用到分类问题中?

假设我们有4个特征和3个可能的输出类别,我们可以用这样的一个线性模型(12个标量来表示权重、3个标量来表示偏置)来表示:

用向量形式可以表达为 。在多样本的情况下(特征个、样本个、类别个)表达为: (特征为 ,权重为 ,偏置为 )。

但至此我们还存在一个问题:输出怎么对应到预测的分类上?一个简单的想法是:选择一个最大的作为我们预测的那个分类。这个想法是合理的,但现在面临一个新的问题:我们该如何定义损失函数以优化这个模型?

类似于我们在线性回归中的方法,我们自然想到利用极大似然估计推导出损失函数,但首先我们需要将输出转化为概率。

教材中给出的方法是利用softmax运算,将输出转化为预测概率向量(每个分类的预测概率): ,但并没有解释为什么要用softmax运算而不是其他运算,并且在损失函数推导中没有像线性回归一样基于噪声模型做推导,而是直接使用softmax运算得到的概率进行极大似然估计。

想要解释这个问题,关键还是在于为什么要选softmax运算。由于分类问题要解决的就是给定输入预测其属于各个类别的条件概率,在特征与标签之间存在线性关联的假设下,我们可以从两方面来解释:

  • 从噪声角度:如果假设噪声是逻辑分布,条件概率分布就是对输出结果取softmax
  • 从信息论中的最大熵原理角度:熵最大的条件概率分布就是对输出结果取softmax

所以并不是在softmax回归问题中没有噪声,而是对输出的数值结果进行softmax运算转化为概率的过程就已经蕴含了对噪声的假设,是将“连续误差”的假设转换为了“类别概率(离散选择)”的假设。

我们再换个角度来理解。我们依然采用与线性回归一样的极大似然估计的方式:让在给定的特征观测到特定标签的似然最大化,这在分类问题上等于是最大化整个数据集上的预测正确的概率乘积。给定的特征观测到特定标签的概率是什么?取决于噪声是怎样的。如果没有噪声,就是在求线性模型的解析解(或是其他模型的插值问题)而非回归问题。在噪声是逻辑分布的假设下,这个概率就是。极大似然估计的过程就是在该噪声假设下寻找最优参数的过程。

因此,基于softmax后的概率进行极大似然估计,我们很容易得到极大似然估计等价于最小化损失函数:

该损失函数也被称为交叉熵损失。从信息论的角度理解交叉熵的含义:只要预测概率分布与真实概率不一样,就一定比真实概率的熵大,所以我们希望最小化交叉熵。交叉熵不是“预测分布自身蕴含的信息量”,而是“用预测分布去适配真实数据时,产生的额外代价/浪费”。

另外注意一个工程上的点,在简洁实现中,使用了nn.CrossEntropyLoss作为损失函数,它内部做了softmax运算,所以我们无需针对网络的输出层单独手动做softmax运算。

多层感知机

Important

d2l包中缺少了本章所要使用的一些函数,所以需要把train_ch3train_epoch_ch3evaluate_accuracypredict_ch3等函数手动复制到Lib\site-packages\d2l\torch.py目录中。

多层感知机原理

线性意味着单调假设: 任何特征的增大都会导致模型输出的增大(如果对应的权重为正), 或者导致模型输出的减小(如果对应的权重为负)。因此,线性模型是一个很强的假设。

由于线性模型无论叠加多少次线性运算永远都是线性模型,为了让模型能够进行非线性的预测,我们可以通过在线性模型中引入非线性层来克服线性模型的限制,使其能处理更普遍的函数关系类型。

在输入层和输出层中间的层叫做隐藏层,我们只要添加非线性的隐藏层即可,最简单的方式是,针对线性层的输出结果应用一个非线性的激活函数,就可以将该层转变为非线性层。最常见的激活函数是ReLU函数。

模型选择、欠拟合和过拟合

  • 欠拟合:训练误差和验证误差都很严重,模型不能降低训练误差。这可能意味着模型过于简单(即表达能力不足)。
  • 过拟合:训练误差明显低于验证误差。模型倾向于记住样本的数据规律,泛化性可能会变差。但过拟合并不总是一件坏事,关键还是看验证误差的情况。

是否过拟合或欠拟合可能取决于模型复杂性可用训练数据集的大小

接下来教材使用了一个多项式回归的例子来说明模型复杂性与欠拟合和过拟合的影响:对于一个三阶多项式下得到的样本数据,使用线性函数拟合会欠拟合,使用高阶多项式函数拟合会过拟合。

权重衰减

为了解决过拟合的问题,我们引入权重衰减这一正则化模型的技术。这是一种可以在不去收集更多的训练数据,也不用调整模型复杂性来缓解过拟合的一种有效手段。

经典泛化理论认为,为了缩小训练和测试性能之间的差距,应该以简单的模型为目标权重衰减是最广泛使用的正则化的技术之一,通常被称为正则化,这项技术通过函数与零的距离来衡量函数的复杂度,希望通过一种手段让训练后的模型尽可能简单,即比较小。

想要实现这一点,最常用方法是将其范数作为惩罚项加到最小化损失的问题中,将原来的训练目标最小化训练标签上的预测损失,调整为最小化预测损失和惩罚项之和:

接下来教材使用了高维线性回归的例子(200维的线性模型、20个样本的小训练集)演示了过拟合如何产生以及如何通过权重衰减来缓解。

但是,我们还没有讨论一个问题:为什么我们要选择正则化而不是正则化?教材中的解释是:正则化对权重向量的大分量施加了巨大的惩罚,使得我们的学习算法偏向于在大量特征上均匀分布权重的模型,在实践中,这可能使它们对单个变量中的观测误差更为稳定;惩罚会导致模型将权重集中在一小部分特征上,而将其他权重清除为零,这称为特征选择。相比特征选择会离散地决定某些特征要还是不要,权重衰减为我们提供了一种连续的机制来调整函数的复杂度。

但是为什么惩罚会有这样的效果?这是因为:无论权重本身是大是小,惩罚都会施加一个大小恒定、方向始终指向0的梯度;而惩罚的拉力随权重成正比衰减。

此外,我们还可以从概率的角度来解释正则化的科学性。教材中最后一题提到了基于贝叶斯公式的后验计算:后验 = 似然 * 先验 / 边缘似然(证据)。详细解释如下:

之前我们在推导损失函数时,用的都是极大似然估计。如果我们从后验的角度去考虑,根据贝叶斯公式,的后验概率为:

极大后验估计(最小化负对数后验估计)相比极大似然估计,损失函数会多出来一项(其中 无关,所以我们使用正比而忽略它)。因此我们可以说:正则化等价于给权重设定特定的先验分布。也就是说:

  • 不带正则的训练(只最小化损失) = 极大似然估计(MLE):最大化 ,完全相信观测数据,对参数没有任何预设,容易被训练数据里的噪声带偏,也就是过拟合。
  • 带正则的训练(损失 + 惩罚项) = 极大后验估计(MAP):最大化 ,既参考观测数据,也保留我们对参数的先验常识。

因此,正则化不是强行惩罚大权重,而是在数据之外,引入我们对模型参数的合理信念。我们对权重设定的特定的先验分布,决定了我们将采用什么样的正则化

  • 权重衰减的平方惩罚,本质对应给权重加零均值高斯先验:我们默认“所有权重都不该太大”,用高斯分布把这个信念量化,就得到了权重衰减
  • 正则的绝对值惩罚,本质对应给权重加拉普拉斯先验:我们默认“只有少数特征重要、大多数权重应该是0”,用拉普拉斯分布把这个信念量化,就得到了稀疏正则。

对于网络有多层的情况,我们只需要让惩罚项是所有之和即可。这是因为我们假设网络中每一层的权重是相互独立的,由于多个独立事件同时发生的联合概率等于各自概率的乘积,因此在极大后验估计中就会变成的乘积,在取对数后就会变成求和项。

暂退法(Dropout)

本节进一步讨论过拟合的问题。

Note

本节教材有一处小瑕疵,在从零开始实现中,定义模型class Net__init__函数中,没有对线性层初始化,这会导致它使用pytorch默认的nn.init.kaiming_uniform_,而非下面简洁实现的nn.init.normal_,所以两种实现并非完全等价。

线性模型的泛化性很好,但线性模型没有考虑到特征之间的交互作用,只能表示一小类函数,所以必须引入非线性的激活函数,但这样一来,即使我们有比特征多得多的样本,深度神经网络也有可能过拟合。

上一节已经讨论了,经典泛化理论认为我们应该以简单的模型为目标,并引入了权重衰减,它能够起作用,是因为参数的范数代表了一种有用的简单性度量(基于奥卡姆剃刀,简单模型先验概率大)。简单性的另一个角度是平滑性,即函数不应该对其输入的微小变化敏感,为输入添加随机噪声应该对结果基本无影响。

那么关键的挑战就是如何注入这种噪声,但无论怎么注入噪声,预期都是

  • Bishop是将高斯噪声添加到线性模型的输入中,恰好会推导出与权重衰减相同的形式
  • 标准暂退法正则化中,每个隐藏层以暂退概率随机丢弃一些参数不参与本次训练,剩余参数等比例放大,保持总期望不变:概率为其他情况

暂退法的这一策略基于这样的朴素想法:暂退法会破坏共适应性,强迫特征具备更强的健壮性。

由此,我们可以对比一下两种防止过拟合的策略,并将损失函数的推导过程也加入对比:

方法 应用对象 基本思想
权重衰减/特征选择 特征 基于极大后验估计,注入我们对参数的先验常识(往往更简单)
输入噪声训练/暂退法 输入/每一个中间层的输出 增加模型平滑性(输入添加噪声/每个中间层的输出做概率性暂退以破坏共适应性)
--- --- ---
损失函数 标签 基于对观测噪声的假设,应用极大似然估计来推导损失函数

此外,通过研究习题中的问题,我们还可以得出以下结论:

  • 越靠近输出的深层抽象特征层,越适合设置更高的dropout概率,浅层底层特征层适合更低的丢弃率,避免有效基础特征大量丢失
  • 权重dropout相对于标准激活dropout,有明显短板:无法打破神经元协同适应,还会让优化的稳定性变差(梯度方差变大)
  • 标准dropout是在激活函数后dropout而非先dropout再应用激活函数,因为dropout的目的是对隐藏层特征进行随机失活,尽管在ReLU激活下两者数学等价

前向传播、反向传播和计算图

本节详细介绍了梯度是怎么计算的。其关键在于:

  • 前向传播:在计算目标函数的过程中构建出计算图
  • 反向传播:再通过反向遍历计算图把目标函数对图中涉及到的变量的梯度依次通过链式法则计算出来。
---
config:
  look: handDrawn
---
graph LR
    x["$$\mathbf{x}$$"] --> mul1(("$$\times$$"))
    W1["$$\mathbf{W}^{(1)}$$"] --> mul1
    mul1 --> z["$$\mathbf{z}$$"]
    z --> phi(("$$\phi$$"))
    phi --> h["$$\mathbf{h}$$"]
    h --> mul2(("$$\times$$"))
    W2["$$\mathbf{W}^{(2)}$$"] --> mul2
    mul2 --> o["$$\mathbf{o}$$"]
    o --> loss(("$$l$$"))
    y["$$y$$"] --> loss
    loss --> L["$$L$$"]
    L --> add(("$$+$$"))
    add --> J["$$J$$"]
    
    W1 --> l2(("$$\mathcal{l}_2$$"))
    W2 --> l2
    l2 --> s["$$s$$"]
    s --> add
    
    %% 普通节点:纯白背景
    classDef white fill:#ffffff,stroke:#333,stroke-width:1.5px,color:#000,font-size:20px
    class x,z,h,o,y,s,L,J white
    
    %% 权重方块:蓝色填充
    classDef weight fill:#66bfff,stroke:#333,stroke-width:1.5px,color:#000,font-size:20px
    class W1,W2 weight
    
    %% 运算圆圈:浅蓝色填充
    classDef op fill:#b2d9ff,stroke:#333,stroke-width:1.5px,color:#000,font-size:20px
    class mul1,phi,mul2,l2,loss,add op

用计算机的语言一句话讲反向传播的计算过程:先把图中所有边反向,然后找目标函数能够抵达某个参数的所有路径,每个路径用链式法则一步步求中间变量的梯度并相乘得到对参数的梯度,最后把所有路径计算出来的梯度求和就是总梯度。

用纯数学简单理解这个思路

  1. 我们希望对自变量求全导数
  2. 含有自变量的函数可能有很多,只要有路径能够到达自变量,就要把这条路径计算出的导数加进来
  3. 每个路径上做的都是一次复合函数求导

值得一提的是,反向传播的这种计算方式,天然是计算量更小的一种方式,其通过计算量较小的向量-雅可比乘积(VJP)完成梯度计算,而无需构造完整雅可比矩阵,也无需计算雅可比矩阵的乘法,下面解释一下:

我们考虑多层感知机中的某一层,即一个从维向量映射到向量的函数 ,其导数是一个雅可比矩阵(分母布局):

由复合函数的链式求导法则可知,多层神经网络中间层的输出对输入的导数就是多个上述雅可比矩阵的乘积。但是,一个完整的神经网络的总输出和输入并不是都是向量,而是标量(目标函数)和向量(特征),这意味着最终求出来的梯度会是个向量。换句话说,我们想求的是(其中 ),是向量-雅可比乘积,而不是一个个雅可比矩阵和雅可比矩阵的乘积,这无需矩阵乘法,也并不需要完整构建矩阵(因为可以在实际运算时拆分成多个向量与向量乘积)。

当然,我们的目的是求梯度后做参数更新,所以要在链式求导计算到需要更新参数的那一层时,将这一层输出对输入的求导转为输出对参数的求导。典型地,当这一层参数 时,该导数就是一个雅可比张量,最终的梯度就是,但整个过程仍然无需完整构造矩阵(张量)或是执行矩阵(张量)乘法,只需向量-雅可比矩阵(张量)乘积。

让我们再来看看pytorch中真实的生产环境的计算图是什么样的

(下图使用本代码生成,点击展开)
import torch
import torchviz
from torch import nn

def l2_penalty(W1, W2):
    return torch.sum(W1.pow(2)) + torch.sum(W2.pow(2)) # 为了计算图展示的简洁性,省略常数计算
# 为了计算图展示的简洁性,不使用手动实现的损失函数
# def cross_entropy(O, y):
#     def softmax(X):
#         X_exp = torch.exp(X)
#         partition = X_exp.sum(1, keepdim=True)
#         return X_exp / partition
#     y_hat=softmax(O)
#     return - torch.log(y_hat[range(len(y_hat)), y])
cross_entropy = nn.CrossEntropyLoss(reduction='none')
def relu(X):
    a = torch.zeros_like(X)
    return torch.max(X, a)
def net(X):
    X = X.reshape((-1, num_inputs))
    H = relu(X @ W1)
    return H @ W2

batch_num, num_inputs, num_outputs, num_hiddens = 20, 784, 10, 256

W1 = nn.Parameter(torch.randn(num_inputs, num_hiddens, requires_grad=True) * 0.01)
W2 = nn.Parameter(torch.randn(num_hiddens, num_outputs, requires_grad=True) * 0.01)

X = torch.randn(batch_num, num_inputs)
y = torch.randint(num_outputs, (batch_num,))

O = net(X)
L = cross_entropy(O, y)
s = l2_penalty(W1, W2)
J = L + s

# 因为反向传播完了计算图就会销毁,所以需要retain_graph
# 不过这里不进行反向传播也完全可以,因为计算图是前向传播时构建的
J.sum().backward(retain_graph=True)

graph = torchviz.make_dot(
    J,
    params={"W1": W1, "W2": W2},
    show_attrs=True,
    show_saved=False,
)
graph.graph_attr['rankdir'] = 'LR'
graph.render("compute_graph", format="svg")

compute_graph.svg

在pytorch的计算图中,只有参数、目标值和算子。中间张量值(包括输入张量)会被存放在算子中,这是因为只有在进行算子运算时才需要这些张量。在pytorch中前向传播与反向传播的具体操作如下:

  • 按照提供的计算过程一步步计算出目标值,在过程中只要有参与计算的张量的requires_grad=True(例如参数),就会像传染一样,其后的计算就会纳入计算图构建,计算出的中间张量(或目标张量)会写入grad_fn属性,表示计算该张量所需的算子,例如图中的MmPowMaximum(用于激活函数)、LogSoftmax等算子
  • grad_fn有两个重要属性:grad_fn.saved_tensors表示反向传播计算导数时所需要的张量,grad_fn.next_functions表示要把当前节点计算好的梯度往哪里传,它指向下一个算子
  • grad_fn作为入口函数,在当前算子计算完梯度后,通过grad_fn.next_functions找下一个算子并将梯度传入,下一个算子用上游梯度结合算子自身的导数计算逻辑计算VJP,得到新的梯度,重复以上过程
  • 直到传播到AccumulateGrad算子,这是一个特殊的算子,表示计算图至此已经抵达叶子节点,该算子会将最终梯度写到叶子张量的.grad属性中

pytorch为我们把几乎所有常见的运算做好了算子封装,如果想要自己实现,可以通过继承torch.autograd.Function并实现forwardbackward两个函数即可,例如下面这个矩阵乘法算子:

class MyMatmul(torch.autograd.Function):
    @staticmethod
    def forward(ctx, W, X):
        # 把反向需要用到的输入存起来
        ctx.save_for_backward(W, X)
        Z = W @ X
        # 返回顺序必须和backward的输入顺序一致:Z对应grad_output
        return Z

    @staticmethod
    def backward(ctx, grad_output):
        # 取出前向时存的值
        W, X = ctx.saved_tensors
        # 链式法则分别求对w和x的梯度
        grad_W = grad_output @ X.T
        grad_X = W.T @ grad_output
        # 返回顺序必须和forward的输入顺序一致:W对应grad_W,X对应grad_X
        return grad_W, grad_X

使用apply函数即可执行算子计算,例如MyMatmul.apply(X, W1)

数值稳定性和模型初始化

本节介绍数值稳定性的两个重要问题:梯度消失和梯度爆炸。为了解决这两个问题,除了模型的设计(例如非线性激活函数的选择),模型初始化是非常重要的一环。

随机初始化本身是保证在进行优化前打破对称性的关键,以防止模型表达能力受限。

在模型初始化的具体策略方面,教材介绍了一种叫做Xavier初始化的方式:

对于一个线性全连接层输出,假设权重具有零均值和方差,输入具有零均值和方差,可计算出输出的均值是0,方差是。为了数值稳定性,我们希望输出与输入具备同样的均值和方差,因此我们希望 。我们再考虑反向传播,我们也希望梯度方差在反向传播中保持稳定,即上游梯度方差与本层计算出的梯度方差保持一致,经计算可以得出只有 才满足。这样一来就产生了矛盾,前向传播和反向传播数值稳定性所要求的条件不同,不存在一个权重的初始化方法能让它的同时满足前向与反向的方差条件。

因此Xavier初始化采取了一种折中的方案,让 ,这在实践中被证明是有效的。它实现了尽可能地让输出的方差不受输入数量的影响,梯度的方差不受输出数量的影响。

环境和分布偏移

本节核心讨论训练数据的问题。如果数据本身有偏(与真实环境分布不符,训练集和测试集并不来自同一个分布),对模型的影响很可能是致命的。此外,通过将基于模型的决策引入环境,可能会影响环境,进而破坏模型。

首先是讨论了三种训练数据中的分布偏移问题

  • 协变量偏移:特征的分布与真实不符(例如图片猫狗分类问题现实中新出现的大量二次元猫狗图片),但给定特征依然正确对应标签
  • 标签偏移:标签的分布与真实不符(例如症状->疾病诊断中不同疾病的流行率),但给定的标签依然正确对应特征(症状还是对应疾病,但如果训练集中某罕见病的样本很多,稍模糊一些的症状会倾向于预测为样本多的罕见病)
  • 概念偏移:标签的定义发生变化(例如精神疾病的诊断标准、时尚与复古的定义等)

接下来是怎么进行分布偏移纠正

  • 协变量偏移纠正:训练模型用的数据分布,和实际测试时遇到的数据分布不一样,那我们就针对每个样本加权,权重是真实分布与训练数据分布之比 ,让真实分布里常见的样本权重变大、训练集里泛滥但真实场景少见的样本权重变小(将权重应用到该样本计算出的loss上)。权重的获取方法是:
    • 抽取训练集和测试集的相同数量的样本数据训练一个二分类器(逻辑回归)来区分一张图是来自训练集还是测试集。注意:这里我们可以使用测试集中的数据的原因是,我们只使用了测试集的特征,并没有使用它的标签,这个二分类器所使用的标签是我们人为构造的(来自训练集还是测试集)。
    • 针对训练集的所有数据用分类器判断样本来自测试集的概率,而来自训练集的概率就是 ,从而反推出权重。
    • 这个方法成立的前提是测试集中出现的每个样本,在训练集中都有出现。
  • 标签偏移纠正:同样采用样本加权的方法,某个标签在真实场景中越常见,权重就越大。权重 的获取方法是:
    • 可以直接通过训练集标签分布统计得到,而标签的真实分布无法直接得到。
    • 基于已训练好的在训练集上表现良好的模型,使用验证集(也来自训练分布)计算混淆矩阵表示真实标签为而模型预测为的样本数量所占的比例。由于标签相比特征的量级小了很多,这个矩阵规模不大。
    • 如果标签偏移假设成立(给定类别,特征的分布在训练集和测试集是一样的),那么在相同模型下用验证集(也来自训练分布)和测试集计算出的就是相同的,于是有 ,表示:分类器的犯错模式*测试集的真实标签分布=观测到的模型的平均预测分布
    • 用测试集跑出平均预测分布,解出
    • 基本逻辑可以理解为:用验证集计算出的分类器的犯错模式,再在测试集上反推出测试集的真实标签分布
  • 概念偏移纠正:只能从零开始收集新标签和训练,但在实践中极端的偏移是罕见的,通常概念的变化总是缓慢的,因此我们可以使用新数据更新现有的网络权重,而不是从头开始训练。

以上为分布变化的理论知识,接下来教材介绍了几种机器学习问题形式化的一些实际问题类型:经典的批量学习、基于预测决策的应用结果来优化模型的在线学习、有限行动下的老虎机问题(在线学习的特例)、环境会记住之前动作的控制类问题、强调如何基于环境而行动的强化学习、会随着时间的推移而发生变化的问题等。

最后还提到了机器学习中的公平、责任和透明度,表达出精度不一定是评估模型好坏的标准,这是因为将预测转化为行动时,我们通常会考虑到各种方式犯错的潜在成本敏感性。另外还提到了“失控反馈循环”现象,我们要避免发生这种预测-决策-环境的这种坏的正向反馈循环。

实战Kaggle比赛:预测房价

本节通过一个实际的例子来应用之前学过的知识。本节在实战中应用到了K折交叉验证的技术。

  • 通常的训练过程:将数据集拆分成训练集和验证集,用训练集训练、用验证集计算误差,以观察模拟是否会过拟合,选择模型和调整超参数。但是这类粗暴的拆分方法,在数据量小的情况下,受数据划分的运气影响较大。
  • K折交叉验证:通过多次划分,分别训练模型,对误差结果取平均,避免了评估结果的偶然性与波动。本质上是一种“多次测量取平均”的方法。

深度学习计算

本章深入讨论深度学习计算的关键组件:模型构建、参数访问与初始化、设计自定义层和块、将模型读写到磁盘,以及利用GPU实现显著的加速。基本上都是在讲pytorch怎么使用这些深度学习的基本元素。

层和块

层就是神经网络中的一层,例如线性层nn.Linear;而块是神经网络中一个或多个层的组合,是一个抽象概念,主要目的是便于组合成一个逻辑结构,以通过简洁的代码实现复杂的神经网络,例如nn.Sequential

本节主要介绍如何自定义块。自定义块只需要继承nn.Module,实现__init__forward即可。由于计算图是前向传播过程中动态生成的,所以forward中是可以执行代码实现控制流的。

class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        # 层实例直接保存在类实例(self)中,框架会自动注册进self._modules,使得框架可以拿到层的参数
        self.hidden = nn.Linear(20, 256)
        self.out = nn.Linear(256, 10)

    def forward(self, X):
        return self.out(nn.functional.relu(self.hidden(X)))

参数管理

本节介绍怎么访问参数、初始化参数,以及参数绑定(在多个层间共享参数)。

访问参数很简单,先直接下标访问到对应层,然后取层的相关参数即可,例如线性层的.weight.bias。但此时获取的是parameter,可以通过.weight.data获取到普通张量(做运算不会记录计算图,不累积梯度),.weight.grad获取梯度。

通过.named_parameters()可以遍历所有的参数,例如net[0].named_parameters(),返回值是一个由(name, param)组成的Iterator。

可以通过.state_dict()直接获取所有参数组成的dict,其中key的命名方式与.named_parameters()返回的元组中的name是一致的。这让我们可以以一种字符串表达的形式获取参数,例如net.state_dict()['2.bias'].data

初始化参数,我们可以定义一个my_init函数,然后net.apply(my_init)即可。

至于参数绑定,我们只需要在定义net时,针对需要共享参数的块,使用相同的nn.Module实例即可。

延后初始化

所谓延后初始化,就是在没有指定输入维度的情况下先搭建起网络结构,在首次前向传播时初始化,其核心目的是简化开发。

中文版教材中没有介绍pytorch的方法,但实际上是支持的。具体使用如下:

import torch
from torch import nn

net = nn.Sequential(nn.LazyLinear(256), nn.ReLU(), nn.LazyLinear(10))
print(net)
X = torch.rand(100, 20)
net(X)
print(net)

# 会得到以下输出
# Sequential(
#   (0): LazyLinear(in_features=0, out_features=256, bias=True)
#   (1): ReLU()
#   (2): LazyLinear(in_features=0, out_features=10, bias=True)
# )
# Sequential(
#   (0): Linear(in_features=20, out_features=256, bias=True)
#   (1): ReLU()
#   (2): Linear(in_features=256, out_features=10, bias=True)
# )

在使用nn.LazyLinear的情况下,只需要定义输出大小即可,输入可以省略。事实上我们需要省略的其实只有第一层(因为通常只有第一层的输入不知道),但只要把层定义成lazy的,尽管可以通过前面层的输出大小推断出输入大小,也依然会在第一次前向传播时才初始化。

自定义层

本节主要介绍如何自定义层。下面的代码自定义了一个带ReLU的全连接线性层。

Note

教材中有处错误,forward函数中使用的是参数的.data做计算,这会让前向传播过程无法构建计算图,导致无法反向传播计算梯度。

import torch
import torch.nn.functional as F
from torch import nn

class MyLinear(nn.Module):
    def __init__(self, in_units, units):
        super().__init__()
        self.weight = nn.Parameter(torch.randn(in_units, units))
        self.bias = nn.Parameter(torch.randn(units,))
    def forward(self, X):
        linear = torch.matmul(X, self.weight) + self.bias
        return F.relu(linear)

另外,pytorch的线性层实现中,参数采用输出通道在前的存储方式,这样性能更好。

读写文件

张量读写:

  • 写:torch.save(x, 'x-file')
  • 读:x2 = torch.load('x-file')

但其实它们也能读写内置类型与张量的组合,甚至模型也可以。但是load归load,想要加载后能正常使用,前提是load的变量所需要的各种定义在代码中都有。张量和pytorch的预定义层都是引入了pytorch就自动有定义了,所以能直接load,但自定义的块和层就不一定了。

所以,对于保存模型的场景,建议是保存参数权重(state_dict),而不保存整个模型。我们可以通过如下代码保存和恢复参数:

# 保存
net = MLP()
params = net.state_dict()
torch.save(params, 'mlp.params')

# 恢复
params2 = torch.load('mlp.params')
net2 = MLP()
net2.load_state_dict(params2)

简单来说,就是通过前面介绍的方式,调用模型获取所有参数的方法,然后保存变量;恢复时加载变量,定义好网络,再调用模型的恢复参数的方法。

GPU

利用GPU加速计算,其核心是将张量的计算放到显存并利用GPU完成计算操作。但两个张量计算的前提是它们必须在同一个设备上。下面以代码形式整理一下所有常见的操作:

import torch
from torch import nn

# 基本操作:查看GPU的数量
torch.cuda.device_count()
# 基本操作:获取device,用于下面的传参;cuda等价于cuda:0
torch.device("cpu"), torch.device("cuda"), torch.device("cuda:1")
# 基本操作:查看张量所在device
X.device

# 张量操作:定义张量时指定device
X = torch.ones(2, 3, device=torch.device("cpu"))
# 张量操作:转移数据到其他device(不会修改原变量,所以必须赋值)
X = X.to(torch.device("cuda"))
X = X.cuda(0)

net = nn.Sequential(nn.Linear(3, 1))
# 模型操作:将模型参数转移到其他device,原地生效
net.to(torch.device("cuda"))
net.cuda()

卷积神经网络

从本章开始,将介绍一些全连接层以外的模型结构。

从全连接层到卷积 & 图像卷积

卷积层的发明,主要来源于图像领域的机器学习问题。

核心问题:图像数据过于庞大,不可能用全连接层来实现,过多的参数会导致无法训练

关键思路:基于图像数据特性和要解决的问题的特性来做简化,减少参数及其计算

具体的特性是:

  • 平移不变性:通常我们针对图像想做的事情是识别,而图像中某一块区域的像素所代表的基本含义在别的位置不会产生变化(例如狗还是狗),即使有变化也是更高级的含义而非基本含义产生变化(高级含义可以后续用其他类型的层来表达)
  • 局部性:神经网络的前面几层应该只探索输入图像中的局部区域,而不过度在意图像中相隔较远区域的关系,不需要每个像素之间都有参数表达关系

依据一些假设来确定模型/目标函数等机器学习组件设计的过程,我们称之为归纳偏置

基于这两个朴素的想法,我们想到利用信号处理领域常见的卷积运算来表达。其实我们完全没必要理解数学定义上的卷积,在机器学习领域使用的卷积运算只是采用了类似的思想。严格来讲深度学习所使用的卷积运算实际上叫互相关运算。

卷积层核心做的事情是,针对每个输入像素,使用该像素周围一圈的像素进行加权求和,作为该位置的输出。其中加权的权重叫做卷积核,它同时也决定了周围一圈的像素取多大范围。这个操作本质上类似于滑动加权求和运算。

用数学表达就是:每个输出的值,等于在输入周围按卷积核大小取元素出来,然后与按元素相乘求和。即:

但图像每个像素的值并不是一个标量而是一个向量(例如颜色),卷积运算得到的隐藏层输出往往也不是只有一个特征(每个像素周围一圈的像素所组成的图像往往不能用单一的特征来描述,对于每一个空间位置,我们想要采用一组而不是一个隐藏表示)。因此,我们需要在输入中增加一个维度表达不同颜色通道,输出中也增加一个维度表达多个隐藏表示(我们对这两个新增加的维度用通道来称呼)。相应地,卷积核就需要加两个维度。于是,卷积计算将会变成下式:

填充和步幅

如果我们按上述定义,输入的大小是大于的(先不考虑通道),因为只要卷积核不是 ,那么在图像边缘部分就涉及到需要对不存在图像的部分做卷积运算的问题,如果忽略这些位置,输出的尺寸就会小于输入。如果我们对输入的边缘做填充(例如填充0),此时输入和输出就应该具有相同大小。因此,填充可以增加输出的高度和宽度

除了填充我们还可以调整步幅。简单来讲步幅就是卷积核所应用到输入上的间距,也就是应用一次卷积核计算出结果后,计算下一个结果时要在输入上横向和纵向移动的距离。前面我们所介绍的都是步幅为1的情况,在应用值为stride的步幅的情况下,我们可以让输出以stride为比例缩减。因此,步幅可以减小输出的高和宽

总的来说,填充和步幅可用于有效地调整数据的维度,对于输入尺寸为 、卷积核尺寸为 、填充大小为 (指上下共填充、左右共填充)、步幅大小为 的情况,输出形状为:

注意:pytorch中nn.Conv2d传入的padding值是上下或左右各填充的大小,而不是共填充的大小(是上面的一半)。

多输入多输出通道

本节重新讨论通道的问题,在有个输入通道和个输出通道的情况下,卷积核形状是

多输入多输出通道下互相关的实际运算逻辑为:针对每个输出通道,获取所有输入通道以对应的卷积核计算出结果并求和。简单讲就是for(每个输出通道)计算sum(每个输入通道对应的卷积结果矩阵)。操作后会将输入通道维度消掉,输出通道维度保留。

特别地,如果是卷积层是 的,相当于我们在每个像素位置应用全连接层(每个像素位置的输入通道数据线性变换成输出通道数据)。

汇聚层

汇聚层与卷积层类似,做的仍然是互相关运算,但是汇聚层没有参数,所做的事情从卷积层的输入与参数进行按元素相乘并求和,改为了对输入参数求最大值和求平均,分别称为最大汇聚层和平均汇聚层。汇聚层的主要优点之一是减轻卷积层对位置的过度敏感。

  • 平均汇聚:对窗口内所有像素做算术平均,平滑局部特征,弱化高频噪声,能够保留区域的整体信息,对全局均值偏移更不敏感,但会模糊边缘细节
  • 最大汇聚:取窗口内像素最大值,只保留局部最显著的激活特征,能更好保留纹理、边缘这类关键特征,对特征轻微平移更不敏感,但容易放大噪声、出现过拟合

两者优化目标不同:平均汇聚侧重区域整体信息平滑,最大汇聚侧重局部最强特征筛选。

此外,汇聚层的输出通道数与输入通道数相同,也就是说会保留原有的通道,不会像卷积层一样在通道上对输入进行汇总。

卷积神经网络(LeNet)

本节介绍最早发布的卷积神经网络之一LeNet,它发明于上世纪90年代。其网络结构如下:

net = nn.Sequential(
    nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Flatten(),
    nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(),
    nn.Linear(120, 84), nn.Sigmoid(),
    nn.Linear(84, 10)
)

现代卷积神经网络

本章按时间顺序介绍现代的卷积神经网络架构,这些神经网络是将人类直觉和相关数学见解结合后,经过大量研究试错后的结晶。

深度卷积神经网络(AlexNet)

相比之前盛极一时的特征提取方法,另一组研究人员认为特征本身应该被学习。在合理地复杂性前提下,特征应该由多个共同学习的神经网络层组成,每个层都有可学习的参数。

尽管一直有一群执着的研究者不断钻研,试图学习视觉数据的逐级表征,然而很长一段时间里这些尝试都未有突破。深度卷积神经网络的突破出现在2012年,突破可归因于数据和硬件这两个关键因素。

AlexNet相比LeNet,使用了更复杂一些的网络,但基本理念是相似的。它以很大的优势赢得了2012年ImageNet图像识别挑战赛。AlexNet相比LeNet变化如图:

alexnet.svg

使用块的网络(VGG)

VGG将卷积层、非线性激活函数、汇聚层这些常用的层抽象成块,多个这样的块再加上全连接层就构成了VGG网络。

vgg.svg

网络中的网络(NiN)

LeNet、AlexNet和VGG都有一个共同的设计模式:通过一系列的卷积层与汇聚层来提取空间结构特征,然后通过全连接层对特征的表征进行处理。

NiN换了一种方式:针对每个像素位置应用一个全连接层。即将空间维度中的每个像素视为单个样本,将通道维度视为不同特征。NiN和AlexNet之间的一个显著区别是NiN完全取消了最后的全连接层,最后一个NiN块的输出通道数等于标签类别的数量,然后放一个全局平均汇聚层直接输出结果。设计影响了许多后续卷积神经网络的设计。

nin.svg

含并行连结的网络(GoogLeNet)

GoogLeNet吸收了NiN中串联网络的思想,并在此基础上做了改进。它解决了什么样大小的卷积核最合适的问题。GoogLeNet使用不同大小的卷积核组合,通过多个Inception块结构(计算4路不同大小的卷积核组成的卷积层,并在通道维度聚合)的组合,有效地识别不同范围的图像细节。它以较低的计算复杂度提供了类似的测试精度。

inception.svg inception-full.svg

批量规范化

训练深层神经网络是十分困难的。批量规范化(Batch Normalization)是一种流行且有效的技术,可持续加速深层网络的收敛速度。再结合后面的残差块,批量规范化使得研究人员能够训练100层以上的网络。

BN层在训练时做如下计算:

其中,

简单来说就是针对一个batch的输入数据中的每一个特征做归一化。

  • 线性层:用batch中所有样本计算每个特征的均值和方差然后做归一化
  • 卷积层:类似线性层,但是只把通道作为特征,对所有样本和样本中所有像素位置一起算均值和方差(因为同一通道全部空间像素是同类空间特征,H/W只是位置,不算独立特征)

而对于推理过程来说,会使用在训练过程中通过批次间指数加权平均得到的近似的全局均值和方差,对输入做归一化,进行下一步计算:

BN层通常加在激活函数前。实践表明:加BN后损失下降更平稳、梯度不会剧烈震荡、能开更大学习率。2018年后续研究证明BN真正价值是平滑优化损失曲面,而非稳定层内分布。

残差网络(ResNet)

批量规范化解决了深层神经网络收敛速度的问题,但它只能缓解,最根本的还是模型本身的设计。ResNet通过残差块的设计在该问题上取得了质的突破,对如何建立深层神经网络产生了深远的影响。

ResNet的核心思想源于这样的思考:当我们要添加一层新网络,如果新增层可以被训练成恒等映射 ,那么新网络的输出完全等于旧网络,效果不会变差,至少和原模型一样强。

简单来说就是:给网络加深时,只要新增层能学习 “什么都不做”,加深网络就不会带来性能退化,深层网络理论上一定不差于浅层。

然而,深层网络堆叠几十上百层后,优化器很难找到一组权重,让多层叠加后抵消所有变换。ResNet采用残差结构,将学习目标改为 ,强制让恒等映射成为简单解。此时模型只需要让残差分支 就能满足 。无论叠加多少残差块,每个残差块都可以选择输出0,整体网络等价于浅层模型,所以只会更好不会更差。

残差块示意图:

residual-block.svg

稠密连接网络(DenseNet)

DenseNet在某种程度上是ResNet的逻辑扩展。在跨层连接上,不同于ResNet中将输入与输出相加,DenseNet在通道维上连结输入与输出。稠密块中每一层都连接在它前面的所有层的输出。

以泰勒展开式做类比,ResNet中通过 分解为两部分:一个简单的线性项和一个复杂的非线性项。DenseNet则是把这个分解做得更彻底,将块中所有的输出都作为输入传递给后面的层,具体的方法是直接使用torch.cat

densenet-block.svg

相比ResNet,DenseNet这样做的好处是拓宽了特征存储与复用的上限(而不是只能用上一层的输入),并且能使用到块中前面每个层的原始输出(而不是上一层输出与输入混合后的结果)。

此外,DenseNet没有改变ResNet让恒等映射成为简单解的性质。DenseNet新增层只需要把自身卷积权重、偏置全部训练为0,输出一组全零特征通道拼接在原有特征后方,即可使得函数性质不变。这是因为全零通道不携带任何有效信息,下游所有运算结果和完全没有这一层时完全一致,因此拥有和ResNet一样的“新增网络层不会导致性能退化”的保底能力。通道数量只是存储空间维度,携带全零的额外通道不会改变网络任何输出结果。

循环神经网络

本章介绍的循环神经网络(recurrent neural network,RNN),主要服务于序列信息处理。循环神经网络通过引入状态变量存储过去的信息和当前的输入来预测当前的输出。

序列模型

前面学习了数值预测、分类预测、图像分类预测等问题的建模方法,现在讨论序列模型。时间序列、文本序列都属于序列,序列模型解决的就是这些序列的预测问题。

序列模型的数学本质是对序列的联合概率分布建模,标准数学定义是:输入任意长度序列,输出这个序列的联合概率分布,也就是

如果我们有了这样的一个模型,能够知道任意序列的联合概率分布,我们就可以通过该模型做预测:给定,寻找使得最大的那个即可。

但在实际的训练和推理中,我们不会直接预测,而是预测条件概率 。这是因为,根据概率链式法则,联合概率可以拆解成条件概率乘积:

于是问题被转化为条件概率:

  • 对于训练来说:对每个位置最大化条件概率,等价于最大化整条序列的联合概率
  • 对于推理来说:我们真正需要的是用条件概率逐步构造出完整序列,而不是求整条序列的联合概率

为了训练这样的模型,我们还需要一些统计工具,这是因为,随着数据的积累,输入序列长度会不断增加,我们需要一个近似方法来使这个计算变得容易处理。常见的策略有:

  • 自回归模型:假设我们不需要完整序列,只需要最近长度为的时间跨度的序列数据。特别地,如果这种假设是近似精确的,我们就称序列满足马尔可夫条件,如果 ,我们称之为一阶马尔可夫模型
  • 隐变量自回归模型:保留并持续更新对历史序列的总结,并以此来估计

需要说明的是,以上策略都建立在一个假设之上:尽管随时间推移具体的观测值会不断发生变化,但支配整个序列生成和演化规律的底层物理或统计学规律(即系统动力学)保持不变。否则我们得到的模型也只能描述过去已知序列,不能预测未来。

随着我们对预测时间的增加,会造成误差的快速累积和预测质量的极速下降,这是序列预测的一个重大挑战。

文本预处理

文本是序列数据的一种最常见的形式之一。本节基于一些语料库,展示如何针对文本数据做预处理,以将其组织成文本序列。

简单来讲就是将文本拆分为词元,构建词表将词元字符串映射为数字索引,并将文本数据转换为词元索引以供模型操作。

语言模型和数据集

本节主要介绍语言序列这种特殊的序列在建模时需要注意的问题,以及数据集的处理方式。

Note

本节所使用的代码有些问题,由于教材英文版与中文版有差异(英文版有略微更新),d2l库是以英文版为基准设计的,导致本节代码的运行会出现一些问题,需要做一些修改:

  1. 添加read_time_machine函数而且相比之前章节的定义有修改
    def read_time_machine():  #@save
        d2l.DATA_HUB['time_machine'] = (d2l.DATA_URL + 'timemachine.txt', '090b5e7e70c295757f55df93cb0a180b9691891a')11111111111111
        """将时间机器数据集加载到文本行的列表中"""
        with open(download('time_machine'), 'r') as f:
            lines = f.readlines()
        return [re.sub('[^A-Za-z]+', ' ', line).strip().lower() for line in lines]
    
  2. 修改Vocab__init__中对self.idx_to_token的赋值为
            self.idx_to_token = list(sorted(set((['<unk>'] if not tokens or (tokens and not isinstance(tokens[0], tuple)) else [('<unk>',)]) + reserved_tokens + [
                token for token, freq in self.token_freqs if freq >= min_freq])))
    

语言模型预测的关键目标依然是 ,即给定前面几个单词后出现某个单词的条件概率。

那这个模型应该怎么设计呢?一个朴素的建模方式是:根据单词或单词元组在语料库中出现的频率计算联合概率,再根据公式计算出条件概率,例如 这样的一种简单的建模方式有几个问题

  • 工程上无法实现计数存储:单词元组的所有组合数量非常庞大,参数组合数量以词汇量为底的指数增加,我们无法存储所有的计数
    • 可以通过马尔可夫模型来近似,只需统计n元语法,无需统计所有长度的组合;或者使用隐变量的方式
  • 无法理解语义:基于频数统计的模型无法捕捉同义词之间的语义关联,且泛化能力有限
  • 数据稀疏与长尾效应的问题:一些不常见的单词组合可能是存在的,但是在数据集中却找不到,导致无法统计这些单词元组的概率,模型无法正常工作
    • 这个问题存在一些打补丁的方案,例如添加拉普拉斯平滑,具体方法是在所有计数中添加一个小常量
    • 但齐夫定律指出,事物出现的频率与其排名之间呈反比,通过计算语料库的数据可以发现,一元语法、二元语法和三元语法均符合齐夫定律
    • 很多n元组很少出现,因此拉普拉斯平滑非常不适合语言建模

这种只是简单地统计先前“看到”的单词序列频率的模型肯定是表现不佳的。

接下来介绍如何根据数据集生成可供模型训练的小批量数据。由于模型中的网络一次处理具有预定义长度(例如n个时间步)的小批量序列,为了生成一个小批量数据的特征和标签,我们有两种典型的拆分方式:

  • 随机采样:每个样本都是在原始的长序列上任意捕获的子序列(将原本长序列拆成长度为num_steps的子序列,按批次大小batch_size随机组合成多个批次)
  • 顺序分区:保证两个相邻的小批量中的子序列在原始序列上也是相邻的(将原本长序列拆成若干个子序列,每个子序列再拆成若干个子子序列,也就是样本,每个小批次的所有样本由每个子序列按顺序各取一个样本构成,这样可以确保相邻小批量之间的相同下标的样本在原始长序列中也是相邻的)

循环神经网络实现

Note

本节的d2l库缺失load_corpus_time_machineseq_data_iter_randomseq_data_iter_sequentialclass SeqDataLoaderload_data_time_machine等函数,简洁实现还需要predict_ch8grad_clippingtrain_epoch_ch8train_ch8,可以手动补上或者直接回退旧版。

本节正式介绍循环神经网络,它是具有隐状态的神经网络。它与无隐状态的神经网络的关键区别在于:该网络存在时间步的概念,训练和推理过程需要增加一个对时间步的循环,并且在这个过程中保存一个跟随时间步增加而不断更新的隐状态。

先不考虑时间步。假设我们在时间步有小批量输入 ,它的大小取决于batch_size和vocab_size。我们需要计算的是:

其中的每一行是样本在时间步的单词在词典的index转化为的独热向量。所以我们对输出取softmax之后就是每个样本在时间步模型预测的词典中每一个单词是下一个单词的概率。

再考虑加上时间步的情况。数据集中的输入的形状其实是(batch_size, num_steps)(批次大小, 时间步数)),值为词元在词典中的index。我们需要做两个操作才能执行上述的计算:

  • 将输入转置:我们是逐时间步训练的,每次训练一个小批量数据,因此需要时间步在前,小批量数据在后
  • 将token转为独热向量

最终我们得到的就是形状为(num_steps, batch_size, vocab_size)的三维张量。

再看损失计算和优化过程。由于同属分类问题,我们可以继续使用交叉熵损失,直接用每个时间步和小批次中每个样本的损失均值即可。

但是RNN有个特殊情况,每个小批次训练,都要做个时间步的上述计算,将会在反向传播过程中产生长度为的矩阵乘法链,这会导致梯度爆炸或梯度消失。梯度裁剪是解决梯度爆炸的一个有效手段,它通过 ,将梯度的大小限制在范数不超过的范围内,无需在梯度不大的时候也无差别地降低学习率。这一方法建立在损失函数具备利普希茨连续这一性质的假设之上,即损失函数不会突变,参数一步更新带来的损失变化有界。

除了损失,语言模型往往还会使用“困惑度”的概念来度量模型质量。困惑度(Perplexity, PPL)的计算公式为:

它计算的是每个词预测概率倒数的几何平均数,含义为:平均而言,模型需要在大约多少个候选词元中挑选出正确的下一词。困惑度通常仅作为评估模型效果的指标,训练时实际使用的损失函数仍是交叉熵。

最后还要注意一下隐变量的初始化问题。每一个epoch我们肯定要对隐变量做初始化,初始化为0即可。而一个epoch中的每个小批量样本在训练前是否要初始化为0,取决于数据集生成方式。如果是随机抽样,就需要初始化为0;如果是顺序分区就不需要,这是因为每个小批量样本之间在原始序列上是相邻的,隐变量状态可以保留,但是这种情况我们需要分离梯度(因为产生了跨批次复用,但只需要计算本批次的梯度,防止梯度计算链路过长)。隐变量在时间步之间是要保留和不断更新的。

对于预测过程来说,本质上就是一个样本跑一遍模型,但是由于输入文本是多个时间步的数据,因此需要通过预热期进行自我更新,以获得比初始值更好的隐状态。

通过时间反向传播

本节将更深入地探讨序列模型反向传播的细节。

对于反向传播,使用链式法则,目标函数关于参数的梯度为:

其中,前两项比较简单,而第三项是需要循环地计算的:

很大时这个链就会变得很长,计算量会爆炸。常见的有这么几种计算方案:

  • 完全计算:计算非常缓慢,并且可能会发生梯度爆炸,初始条件的微小变化就可能会对结果产生巨大的影响。
  • 截断时间步:只在固定时间步内计算梯度,例如前面已经在用的分离梯度的操作。
  • 随机截断:每个样本以一个概率随机一个时间步截断,并根据概率加大长序列梯度值的权重,实现最终计算出的梯度值的期望不变(这里有点类似于dropout的做法)。这种操作理论上看着很好,但实际应用中却不是很理想。

现代循环神经网络

本章介绍RNN的优化和变体:解决数值不稳定性的GRU和LSTM、具有多隐藏层的深层RNN、基于前向和后向循环计算的双向RNN、其他序列学习问题的建模方法等。

门控循环单元(GRU)

Note

本节的d2l库缺失class RNNModelScratchclass RNNModel

RNN的矩阵连续乘积容易导致梯度消失或梯度爆炸,然而这种时序信息又是我们所需要的。

门控循环神经网络通过引入重置门和更新门,优化了RNN的网络结构,精准控制信息的保留与遗忘,在一定程度上避免了梯度消失或梯度爆炸,使其可以更好地捕获时间步距离很长的序列上的依赖关系。

  • 重置门:控制新的隐状态候选 依赖上一个隐状态的程度,极端情况就完全不依赖于之前的隐状态,只取决于当前的最新输入,实现重置隐状态的效果。这有助于捕获序列中的短期依赖关系。
  • 更新门:确定新的隐状态 在多大程度上来自旧的状态,即旧的隐状态 新的隐状态候选 的比例关系,极端情况就完全使用旧的隐状态,实现跳过当前时间步的效果。这有助于捕获序列中的长期依赖关系。

其计算隐状态的公式如下:

长短期记忆网络(LSTM)

LSTM比GRU略微复杂一点,使用了输入门、遗忘门和输出门等三个门,两个隐藏层输出隐状态和记忆元(但只有隐状态会传递到输出层,而记忆元完全属于内部信息)。具体公式如下:

输入门、遗忘门、输出门以及候选记忆元:

记忆元和隐状态:

深度循环神经网络

只讨论了具有一个单向隐藏层的循环神经网络,深度循环神经网络是将多层循环神经网络堆叠在一起的网络结构,隐藏层变成了多层结构。

计算公式如下(其中 ):

deep-rnn.svg

双向循环神经网络

双向循环神经网络是一种同时考虑上文和下文的RNN,用于解决完形填空类的序列预测问题。相比普通的RNN只有基于上文的隐状态,它还引入了基于下文的隐状态:

birnn.svg

这一设计基于如下数学理论:

考虑一个隐马尔可夫模型的概率图模型:在任意时间步,假设存在某个隐变量,通过概率 控制我们观测到的,且任何 的转移都由状态转移概率 给出。简单来说就是只与有关,只与有关。

于是,模型(联合概率分布)就可以通过下式表达:

由于一阶马尔可夫性质:

所有前置变量所有变量

可以得到模型:

再来考虑我们的问题,实际上我们的目标是计算 ,也就是在已知以外的所有的情况下的概率。由贝叶斯公式, ,其中分母是不依赖的常数,所以本质上我们要求的还是

为了求解它,需要把隐变量边缘化:

在边缘化之前,我们先对模型改写:

只和有关只和有关

于是,我们可以用递推的方式,通过前向和后向分别边缘化,令:

我们得到对以外的隐变量边缘化的结果:

最后再对边缘化,我们有:

这看起来非常像一个更新方程,其中都是可学习的函数。这便是我们设计双向循环神经网络的理论基础。

机器翻译与数据集

本节介绍机器翻译数据集及其相关预处理操作,介绍如何通过截断(truncation)和填充(padding)生成小批次样本(引入一些特殊token表达末尾、占位符、未知词元,将低频词元视为相同的未知词元等)。

编码器-解码器架构

本节介绍序列转换模型常用的编码器-解码器架构。

“编码器-解码器”是一个通用的抽象架构,其基本思想是将输入数据编码为一个便于神经网络学习和理解的中间表示,再由解码器将中间表示转换为所需要的输出。这种架构可以根据具体需求适用于不同的实际场景中。

  • 编码器:接受一个长度可变的序列作为输入,并将其转换为具有固定形状的编码状态
  • 解码器:将固定形状的编码状态映射到长度可变的序列

通过这样的设计,我们就可以方便地处理长度可变的输入和输出序列,实现序列到序列的学习,例如机器翻译问题。

序列到序列学习(seq2seq)

本节正式以机器翻译为例,讲解序列到序列学习。

Note

本节的d2l库要修改class EncoderDecoderreturn self.decoder(dec_X, dec_state)

Important

本节的predict_seq2seq配合class Seq2SeqDecoder存在错误,本节的decoder是把encoder得到的state与输入拼接起来交给self.rnn计算,这在训练过程中没问题,因为训练是一次性用num_steps个时间步的数据训练的,self.rnn内部做了num_steps个时间步的计算,decoder返回的state也没有被二次使用。而在预测过程中,decoder的输入的num_steps是1,也就是每次只做一个时间步的计算,整个循环是在predict_seq2seq函数中,而非self.rnn内部,这就导致每次执行self.rnn所使用的输入,其实用的是decoder的最近一个时间步的状态,是一直在变的,而非固定使用encoder的最后一个状态。 这个问题只在本节的例子中存在,后续Bahdanau注意力中所实现的class Seq2SeqAttentionDecoder由于固定使用encoder输出的enc_outputs,所以没有问题。

事实上,普通的RNN也能够解决seq2seq问题,例如我们训练一个<...原文...><特殊标记><...译文...><结束标记>的序列模型,输入原文和特殊标记后,让模型预测直到模型输出一个特定的结束标记即可。

但是单RNN续写的思路效果不好,所以我们引入编码器-解码器架构,把理解和生成拆开,两个模型各司其职:

  • 编码器:专门学习“源语言序列”->“整句语义向量”的映射,只处理源语言
  • 解码器:专门学习“语义向量”->“目标语言序列”的生成,只处理目标语言

二者唯一的交集,就是编码器把最终隐状态交给解码器做初始状态(Sutskever et al., 2014)。而在其他的设计(Cho et al., 2014)中,不仅把编码器最终隐状态交给解码器做初始状态,还把它加到编码器每一个时间步的输入中。以这个设计为例,流程如下:

flowchart LR
    src["源"]
    dst["目标"]
    embedding_src["嵌入层"]
    embedding_dst["嵌入层"]
    init_state_src(["初始化RNN状态\n(用0初始化)"])
    init_state_dst(["初始化RNN状态"])
    rnn_src["RNN"]
    rnn_dst["RNN"]
    output_src["$$\mathbf{O}_i$$"]@{ shape: processes}
    state_src["$$\mathbf{H}_i$$"]@{ shape: processes}
    output_dst["$$\mathbf{O}_i$$"]@{ shape: processes}
    state_dst["$$\mathbf{H}_i$$"]@{ shape: processes}
    X_and_context_dst["$$concat(\mathbf{X}_i,\mathbf{H}_t)$$"]
    dense_dst["全连接层"]
    Y_hat_dst["$$\hat{\mathbf{Y}}_i$$"]@{ shape: processes}
    Y_dst["$$\mathbf{Y}_i$$"]@{ shape: processes}
    loss["loss(SoftmaxCE)"]
    mask["mask(loss)"]

    subgraph encoder
    src --> embedding_src e1@-.->|"$$\mathbf{X}$$"| rnn_src
    init_state_src --> rnn_src e2@-->|num_steps| rnn_src
    rnn_src e3@-.-> output_src
    rnn_src e4@-.-> state_src
    end
    subgraph decoder
    dst --> embedding_dst e5@-.->|"$$\mathbf{X}$$"| X_and_context_dst
    state_src -.->|"$$\mathbf{H}_t$$"| X_and_context_dst e6@--> rnn_dst
    init_state_dst --> rnn_dst e7@-->|num_steps| rnn_dst
    state_src -.->|"$$\mathbf{H}_t$$"| init_state_dst
    rnn_dst e8@-.-> output_dst
    rnn_dst e9@-.-> state_dst
    output_dst --> dense_dst -.-> Y_hat_dst
    end
    Y_hat_dst -.-> loss
    Y_dst -.-> loss
    loss --> mask

    classDef animate stroke-dasharray: 9,5,stroke-dashoffset: 900,animation: dash 25s linear infinite;
    class e1,e2,e3,e4,e5,e6,e7,e8,e9,e10 animate

为了让解码器产生第一个输入,我们要对解码器提供一个特殊的输入<bos>,以表达预测序列的开始。

相比普通的RNN,在这一流程中还引入了嵌入层和带掩码的损失函数。

  • 嵌入层:把独热向量转化为嵌入向量,可以降低维度、训练词与词之间的语义关联
  • 带掩码的损失函数:训练时让标签文本中用于补齐的<pad>字符不参与损失计算和梯度更新

此外,在本节中,我们使用教师强制的方法训练,而不是自回归:

  • 教师强制:每个时间步用真实上文生成一个下文计算损失
  • 自回归:每个时间步都用上一个时间步模型生成的结果作为上文生成一个下文计算损失

最后,本节介绍了一个用于预测序列的评估的方法BLEU:

它主要包含两项:对于较短的预测序列的惩罚项、预测序列与标签序列元语法的匹配度。

  • 惩罚项:如果预测序列相比标签序列短很多,惩罚因子就会比较小,会降低会降低BLEU值。
  • 匹配度:如果我们要评估元语法的匹配度,其值为的匹配度的乘积,其中匹配度定义为:预测序列的所有元语法中,其与标签序列中匹配的元语法所占的比例。另外我们还通过指数对更大的赋予更高的权重。

束搜索

本节主要讲解如何在预测过程中选择候选的问题。序列预测问题特殊的一个地方在于,其产出的是一个序列而非一个值,我们需要在每个时间步选择一个值,实现最大化整个序列的联合概率。

之前我们的预测过程都是在每个时间步直接选择概率最大的值,这是典型的贪心做法,不能保证最终选出来的序列是所有个可能的序列中联合概率最大的那一个。

与之相对应的,穷举搜索是将所有可能的序列都计算联合概率,找到一个联合概率最大的序列,这种方法的计算量巨大。因此贪心搜索与穷举搜索是两个极端。

本节引入束搜索,其关键思想是:每个时间步并非直接找概率最大的token,而是找到个候选,针对个候选计算下一个时间步所有个候选,选择其中概率最大的个作为新的候选继续计算。在整个搜索过程中把生成了<eos>的序列都拿出来作为最终候选输出序列集合,选择其中条件概率乘积最高的序列作为输出序列:

其中是最终候选序列的长度,分母中的用于惩罚短序列(因为长序列的项数更多,条件概率乘积天然小于短序列)。

注意力机制

终于!本章正式开始介绍注意力机制!本章中文版与英文版差异较大,因此以英文版为主。

在注意力机制诞生前,虽然大量创新方法(ReLU激活、残差层、批量归一化、Dropout、自适应学习率策略等)被广泛投入实际使用,但主流基础架构本质上只是经典思路的大规模工程化扩展

Transformer诞生后,主流模型架构的格局终于迎来颠覆性变革,几乎所有自然语言处理任务的标杆模型都基于Transformer搭建。行业标准流程为:先选用大规模预训练Transformer模型,按需修改输出层,再基于下游任务自有数据微调。Transformer模型的核心思想是注意力机制。

注意力机制,最初作为编码器-解码器架构的增强模块诞生。编码器-解码器架构中,编码器会将整个输入压缩成一个固定长度的向量,然后输入到解码器。注意力机制背后的直觉是:与其压缩输入,不如让解码器在每个步骤中重新访问输入序列;解码器不应该总是看到输入的相同表示,而应该在特定的解码步骤中选择性地关注输入序列的特定部分。Bahdanau的注意力机制提供了一种简单的方法,使解码器能够在每个解码步骤中动态地关注输入的不同部分。其核心思想是:编码器可以生成长度与原始输入序列相同的表示;在解码时,解码器(通过某种控制机制)接收一个上下文向量作为输入,该上下文向量由每个时间步输入表示的加权和构成。直观地说,权重决定了每个时间步的上下文对每个输入词元的“关注”程度。

注意力机制很快成为重要的关注点,其价值不再局限于增强编码器-解码器结构以提取显著输入。Vaswani等人(2017)提出了用于机器翻译的Transformer架构,完全摒弃了循环连接,转而依靠精心设计的注意力机制来捕捉输入和输出词元之间的所有依赖关系。该架构表现极其亮眼,到2018年,绝大多数NLP领域SOTA系统都开始采用Transformer。

同一时期,NLP行业形成新范式:先用海量通用无标注语料对超大模型做自监督预训练,再基于下游任务数据微调。在这套预训练体系下,Transformer相比传统架构的性能优势被进一步放大。Transformer崛起的过程,恰好也是大规模预训练模型(如今常被称作基座模型,Bommasani等人,2021)蓬勃发展的阶段。

本章将由浅入深讲解注意力模型:从最基本的直觉和最简单的实现入手,逐步延伸至Transformer架构、视觉Transformer,以及当下各类基于Transformer的预训练模型生态。

注意力提示(Queries, Keys, and Values)

本节介绍注意力的基本概念。

CNN和RNN中的输入长度都是固定的,针对变长输入,现有方案要么采用特殊设计的卷积核,要么逐token串行处理。当输入长度差异极大、承载的信息含量也参差不齐时,这类方案会暴露出严重缺陷。尤其是处理超长序列时,网络很难完整记住所有已生成、已读取的内容。

人类的注意力是有限的、有价值和稀缺的资源,对于模型来说也是类似的。一个朴素的想法是:通过引入注意力机制,可以更好地处理信息以优化神经网络的效果。我们将输入信息类比为数据库中的数据,其实我们无需压缩、简化数据库,只需要根据需求在庞大的状态空间(数据库)上执行检索即可。相比记住,检索是相当简单的事情。

注意力分两种:非自主性注意力和自主性注意力。之前我们在使用的普通的汇聚层、全连接层是固定变换,输入确定则输出确定,属于非自主性注意力。本章我们说的是自主性注意力,它是动态的,查询目标不同,输出的加权结果就不同,信息筛选的结果会随任务目标动态变化。注意力机制与全连接层或者汇聚层的区别源于增加的自主提示。

注意力=拿目标(Query),去匹配外界特征(Key),抽取对应信息(Value)。注意力机制通过注意力汇聚将Query(自主性提示)和Key(非自主性提示)结合在一起,实现对Value(感官输入)的选择倾向。

因此,注意力是个权重矩阵,其形状是(Query的数量,Key的数量),表达了每个Query对每个Key的权重。

注意力汇聚:Nadaraya-Watson核回归(Attention Pooling by Similarity)

本节将介绍注意力汇聚的更多细节,以便从宏观上了解注意力机制在实践中的运作方式。

首先引入Nadaraya-Watson核回归的概念,它是现代注意力机制最早的雏形之一。核回归是一种非参数模型,它直接用样本数据做加权平均得到预测结果,其本质上就是一种谁离得近就更像谁的局部加权平滑技术。用上文介绍的注意力的语言来描述,核回归就是通过计算查询(Query)与键(Key)之间的距离或相似度来分配权重,进而对值(Value)进行加权平均。用来计算相似度的函数就叫做核函数。

我们将其与线性回归对比如下:

对比维度 线性回归 核回归
核心原理 求解固定的权重参数和偏置,通过全局损失最小化(如 MSE)一次性确定参数 利用核函数计算样本间相似度,进行局部加权预测,通过核技巧隐式实现高维映射
模型形式 ,有显式权重参数 ,无显式参数
核心假设 假设输入和输出呈线性关系,依赖该全局假设构建模型 不预设全局函数形式,仅假设数据局部光滑,无线性假设
模型类型 参数模型,预设直线或超平面关系,参数数量固定,不随训练样本增加而增加 非参数模型,无预设全局函数形式,模型复杂度随训练数据规模增长,预测时依赖全部或部分训练样本
拟合能力 只能拟合直线或平面,处理非线性数据时需人工手动对特征做多项式扩展,易出现欠拟合 可直接拟合复杂非线性关系,无需显式计算高维坐标,能灵活捕捉数据局部变化

核方法的核心哲学:低维的非线性纠缠,往往只是高维线性可分结构的一个投影。核函数可以将输入数据投影到高维空间,从而使得我们可以用简单的线性算法,去捕捉复杂的非线性关系。

非参数的Nadaraya-Watson核回归具有一致性的优点:如果有足够的数据,此模型会收敛到最优结果。

Nadaraya-Watson核回归是一个最简单的具有注意力机制的模型,它选择一个固定的核函数(例如高斯核),直接确定了数据的权重如何分配。如果我们在其基础上引入参数,那它就是一个可被训练的模型。

教材以一个正弦函数预测的例子进行了讲解。假设我们使用高斯核 ,对应的 ,加上一个可学习的权重:

我们的任务就是训练。教材随机生成了50对用于训练的(同时也作为数据库)和50对用于预测的,因此批次大小为50,数据维度都是1,对应的Q/K/V形状如下:

  • Q:(50,),内容为(根据是预测还是训练选择对应的值),在forward中被repeat_interleave为(50,49),便于与K进行softmax计算
  • K:(50,49),代表了对于批次中每个输入,数据库的候选KV对中的值(训练时针对每个输入在数据库候选中剔除了它自己,所以只有49)
  • 注意力权重:(50,49),代表了对于每个输入,数据库中的每个候选的权重大小
  • V:(50,49),代表了对于批次中每个输入,数据库的候选KV对中的

因此,注意力权重是Q与K经过核函数计算出来的,将注意力权重与V在第二维求点积,就可以得到加权后的值。

总结一下:Nadaraya-Watson核回归的注意力汇聚是对训练数据中输出的加权平均。从注意力的角度来看,分配给每个值的注意力权重取决于将值所对应的键和查询作为输入的函数。

英文版教材的实现更为简单,没有引入Q/K/V的概念而是直接按定义计算预测结果,测试了不同核函数对拟合效果的影响。最后选择高斯核测试了不同参数值(核宽度)对结果的影响:核越窄,估计结果就越不平滑,注意力权重的范围也越窄;同时,它对局部变化的适应性也更好。

因此,选择相同的宽度可能并非理想之选,这说明了手工设计的注意力机制的局限性,更好的策略是学习该机制,即学习查询和键的表示。

英文版有个很有意思的习题:基于Parzen窗口密度估计公式 ,通过 将其用于二分类,其结果等价于Nadaraya-Watson分类。这是因为:

  1. Parzen窗口密度估计公式的含义是,在处的估计密度等于所有训练样本对的核相似度做平均,可以类比为直方图的光滑升级版(由统计固定区间内样本数量,变成了用核函数对所有样本给出一个相似度后求和平均)
  2. Parzen窗口二分类是先估计两类的联合密度,做密度相减,看谁更大,以此做分类
  3. 这等价于用核相似度对标签做加权平均,看加权平均的结果符号做分类

注意力评分函数(Attention Scoring Functions)

前面我们使用高斯核来对Q与K之间的关系建模,高斯核指数部分可以视为注意力评分函数。

用数学语言描述,注意力汇聚函数表示成V的加权和:

其中,注意力权重(标量)是通过注意力评分函数将两个向量映射成标量,再经过softmax运算得到的:

选择不同的注意力评分函数会导致不同的注意力汇聚操作。有两个流行的评分函数:

对比维度 加性注意力 缩放点积注意力
计算公式
使用条件 可以处理Q和K的向量长度不一致的情况 要求Q和K具有相同的长度(否则无法通过点积计算相似度)
查询的特征向量的维数键的特征向量的维数相同
Q的形状 (批量大小, 查询数量, 查询的特征向量的维数)
K的形状 (批量大小, 键值对数量, 键的特征向量的维数)
V的形状 (批量大小, 键值对数量, 标签向量的维度)
权重矩阵的形状 (批量大小, 查询数量, 键值对数量)
注意力汇聚输出的形状 (批量大小, 查询数量, 标签向量的维度)

在某些情况下,并非所有的值都应该被纳入到注意力汇聚中(例如<pad>词元),所以我们需要对注意力权重计算中的softmax做改造,变成masked_softmax,以按需求遮蔽掉权重矩阵第三维(键值对数量)中的部分数据。

英文版教材还额外提到了:高斯核注意力,在LayerNorm加持下,可以简化成量点积。这是一个很重要的视角:缩放点积注意力可以看作高斯核注意力的简化版本。解释一下:高斯核本质上计算的是向量之间的距离,那么当两个向量的范数是常数时,剩下的就是向量之间的点积,因此可以用向量之间的点积来表达向量之间的距离。而距离代表了相似度,所以向量点积就代表了相似度。

Bahdanau注意力(The Bahdanau Attention Mechanism)

Note

本节使用的d2l.AdditiveAttention做了简化,通过使用LazyLinear,省略了查询的特征向量的维数键的特征向量的维数两个参数的传入。要想继续使用教材上的代码,需要把库代码改一下。

Bahdanau注意力相比Cho et al., 2014只是将每次传给decoder用于拼接到输入上的状态(称为context),从encoder最后一个时间步的状态,改为encoder所有时间步的状态的加权和。

也就是说,在时间步的context通过下式计算:

其中,时间步 时的解码器隐状态是查询,编码器隐状态既是键也是值,注意力权重使用加性注意力打分函数。

这里要使用作为查询而不是当前时间步的输入嵌入向量,是因为查询需要表达当前解码的完整语义需求,即:当前我(解码器)已经生成了这些内容,接下来我需要从源序列里调取哪部分信息。

接下来讨论一些实现上的细节。

本节的Seq2SeqAttentionDecoder相比之前的Seq2SeqEncoder有个不同点是:

  • 之前的Seq2SeqEncoder在forward中直接用self.rnn处理所有时间步。
  • 本节的Seq2SeqAttentionDecoder在forward中手动做num_steps的循环,这是因为每个时间步需要的输入不同(在每步RNN计算之间插入动态注意力计算),没办法在self.rnn内部直接处理所有时间步。

为了更好地看清楚代码,回顾和分析一下几个点:

  • nn.LSTM的输出是一个元组(output, (h_n, c_n))
    • output是最后一层每个时间步的隐藏状态,形状为(num_steps, batch_size, num_directions * num_hiddens)
    • h_n是所有层在最后时间步的隐藏状态,形状为(num_layers * num_directions, batch_size, num_hiddens)
    • c_n是所有层在最后时间步的记忆状态,形状与h_n相同
  • 一共有两个地方用到mask
    1. decoder在计算权重矩阵时,忽略掉词元为<pad>的键值对(用的是原文)
    2. 计算损失时,忽略掉词元为<pad>的时间步所产生的损失(用的是译文)
    • 教材中的encoder用原文计算出隐状态的过程,并没有忽略掉<pad>(但训推是一致的,都没有忽略)

多头注意力(Multi-Head Attention)

前面我们使用的注意力只有一组Q/KV投影,只能学习一种类型的依赖关系,而现实中的依赖关系是多种多样的:有的是短距离依赖,有的是长距离依赖,还有语法、语义、指代等不同模式的关系。因此我们希望模型在同一套QKV输入下,可以同时学到多种不同的注意力行为,再把这些知识组合起来。

要想实现这一点,我们需要将原本的一组QKV扩展到多组,通过可学习的全连接层得到多份不同的QKV,这称为多头注意力。每个头的投影权重完全独立学习,得到不同子空间表示。

给定查询 、键 和值 ,每个注意力头 )的计算方法为:

其中,可学习的参数包括 ,以及代表注意力汇聚的函数可以是加性注意力和缩放点积注意力。多头注意力的输出需要经过另一个线性转换,它对应着个头连结后的结果,因此其可学习参数是

基于这种设计,每个头都可能会关注输入的不同部分,可以表示比简单加权平均值更复杂的函数。

在实现过程中通常选择缩放点积注意力作为每一个注意力头。为了避免计算代价和参数代价的大幅增长,往往设定 ,也就是假设最终输出的隐状态是num_hiddens个,则每个注意力头输出num_hiddens/h个隐状态。这样参数就不需要拆出个出来分别计算,直接串在一起统一做大张量计算即可(中间涉及到reshape和permute操作)。

自注意力和位置编码(Self-Attention and Positional Encoding)

前面介绍机器翻译这种将序列分为原文和译文,并使用编码器-解码器架构的场景,由于Q和KV不同源,所以被称为交叉注意力。(Bahdanau注意力就是为了解决机器翻译场景在无注意力下固定上下文向量导致的信息丢失问题提出的一整套方法)

而对于序列预测场景,相比于裸用RNN,我们也可以加上注意力机制,由于是同一个序列内部做注意力,QKV完全同源,因此称为自注意力。用公式表示如下:

在处理词元序列时,RNN是逐个的重复地处理词元的,而自注意力是每个词元都直接连接到任何其他词元,它为了并行计算而放弃了顺序操作(训练可以并行但推理仍需要顺序输出)。

因此,自注意力有个问题,其本身完全不感知位置和顺序(因为只是把输入序列各行打乱重排,输出也会对应同样打乱,算子数学本身不携带时序和位置信息),所以必须人为额外把顺序信息塞进去,这就是位置编码

本节介绍transformer论文使用的三角位置编码。它是一组经过三角函数调制出来的位置特征向量,交给神经网络去解读。

假设输入表示 包含一个序列中个词元的维嵌入表示。位置编码使用相同形状的位置嵌入矩阵 输出 ,矩阵第行、第列和 列上的元素为:

简单来说就是:序列中每个位置的词元都用一个向量来表达位置,该向量的长度与词元本身的嵌入向量一样大,这样我们就可以将输入矩阵与位置矩阵直接相加,以将位置信息融合到输入中去。

很显然,最朴素的编码位置的想法是:使用二进制向量来表达位置信息,假设序列中有1024个词元,那就可以用长度为10的向量来表达位置。但是这样编码的坏处是需要随最大序列改变维度,同时很难表达相对位置的概念。

三角位置编码,在编码向量(横向)使用多个不同频率的三角函数,不同序列间(纵向)是对应三角函数值。编码向量中的低维元素值随着序列词元下标增加的变化较快,高位元素值随着序列词元下标增加的变化较慢,从而实现通过不同频率模式类比二进制比特,即:位置编码向量里,一部分维度对微小位置变化敏感(高频,分辨相邻token);另一部分维度对远距离变化敏感(低频,分辨相距很远的token)。

此外,三角函数编码还具备表达相对位置偏移的能力。位置向量使用的不同频率的三角函数中,每个频率使用的是一对sin和cos表达。通过三角函数的和角公式,可以证明任何一对可以通过不依赖于任何位置索引的线性变换,变换为

由于神经网络有能力去学到这个变换,从而这种编码能够利用相对位置。

Transformer(The Transformer Architecture)

本节仍以机器翻译为例,介绍编码器-解码器架构下的Transformer架构。

它关键的创新在于:用自注意力完全替代了循环结构,使得整个序列的所有位置可以同时计算。一个1024长度的序列,在RNN里要算1024步,在Transformer里自注意力一次矩阵乘法就搞定了。这才是它能scale up到现在这个规模的根本原因。Transformer的革命性不在于注意力机制本身有多神奇,而在于它证明了不用循环结构也能处理序列,而且效果更好。

Important

本节教材中有两处关键错误:

  1. 编码器和解码器在计算嵌入向量后,进行了的缩放操作,Transformer原文也是这么做的,目的是让嵌入向量与位置编码的大小匹配。但是nn.Embedding初始化已经是,而非原实现的,方差与位置向量的量级是匹配的,所以不需要额外做缩放。
  2. 预测模式下,d2l.predict_seq2seq()函数将输入词元一个一个单独传入TransformerDecoder进行预测,使得TransformerDecoder内部是获取不到每个词元在序列中的位置的,所以其内部调用d2l.PositionalEncoding(试图根据词元下标来计算位置编码)计算出的位置编码永远都是第一个位置的编码。参考评论区的解决方案:让predict_seq2seq()将词元的位置信息也传入Decoder,以交给d2l.PositionalEncoding内部正确处理:
    • d2l.predict_seq2seq()调用decoder解码时添加offset参数
      # 原代码
      for _ in range(num_steps):
          Y, dec_state = net.decoder(dec_X, dec_state)
      # 新代码
      for idx in range(num_steps):
          Y, dec_state = net.decoder(dec_X, dec_state, offset=idx)
      
    • TransformerDecoder.forward(),将offset透传给d2l.PositionalEncoding
      # 原代码
      def forward(self, X, state):
          X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens)) # 结合上面第一个问题,其实这里不需要缩放
      # 新代码
      def forward(self, X, state, offset=0):
          X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens), offset)
      
    • d2l.PositionalEncoding.forward(),利用传入的offset计算出位置编码
      # 原代码
      def forward(self, X):
          X = X + self.P[:, :X.shape[1], :].to(X.device)
      # 新代码
      def forward(self, X, offset=0):
          X = X + self.P[:, offset:offset+X.shape[1], :].to(X.device)
      

transformer.svg

编码器与解码器的结构略有不同:

  • 编码器:原文(小批量、多个时间步)经过嵌入层并加上位置编码后,连续进行多个block的计算
    • 每个block的计算步骤为:
      • [编码器多头自注意力->残差网络连接->层规范化] ->
      • [逐位置前馈网络->残差网络连接->层规范化]
  • 解码器:译文(小批量、1个时间步,以<bos>开头)经过嵌入层并加上位置编码后,连续进行多个block的计算,最后经过一个全连接层得到新输出token的softmax概率
    • 每个block的计算步骤为:
      • [解码器掩蔽多头自注意力->残差网络连接->层规范化] ->
      • [编码器-解码器多头注意力->残差网络连接->层规范化]->
      • [逐位置前馈网络->残差网络连接->层规范化]

其中还有还有ReLU和Dropout操作,我们这里暂且省略。下面解释下每个计算步骤的含义:

  • 编码器多头自注意力:对原文中所有时间步的向量之间计算多头自注意力。QKV对第一个block来说就是每个词元加了位置编码的每个嵌入向量,对后面的block来说就是前一个block的输出(这一点对于下面其他类型的自注意力也是一样的,下面不再重复说明)。计算自注意力时对词元为<pad>的时间步所对应的KV加了mask,以避免对<pad>位置的词元产生注意力。
  • 残差网络连接:使用ResNet的思想,把上一个节点的输入和输出加起来,作为本节点的输出。
  • 层规范化:基于特征维度进行规范化,即每个样本的每个时间步的向量单独做规范化处理。由于不跨样本、不跨时间步计算,即使遇到极长或极短的句子,LayerNorm也能以完全相同的逻辑稳定计算,使其在自然语言处理任务中比批量规范化效果更好。

    BatchNorm与LayerNorm的对比如下:
    BatchNorm是对一个batch_size样本内的每个特征分别做归一化,LayerNorm是分别对每个样本的所有特征做归一化
    BatchNorm抹杀了不同特征之间的大小关系,但是保留了不同样本间的大小关系;LayerNorm抹杀了不同样本间的大小关系,但是保留了一个样本内不同特征之间的大小关系
    BatchNorm假设跨样本的同一特征是同分布,这个假设在CV成立,在NLP变长、语义多样的场景不成立;LayerNorm放弃跨样本统计,只做样本内部归一化,可以避开这些问题

  • 逐位置前馈网络(Position-wise Feed-Forward Network):本质上就是个多层感知机,只不过无论是哪个位置(时间步),使用的都是相同的一套参数。
  • 解码器掩蔽多头自注意力:对译文中所有时间步的向量之间计算多头自注意力。这里之所以叫掩蔽多头自注意力,是因为训练阶段是有完整译文的,但为了保留自回归属性,计算自注意力时对Q之后的词元的时间步所对应的KV加了mask,以避免对其后续位置的词元产生注意力(这是对训练来说,而对于预测,词元是一个个生成的,天然不会对未产生的词元产生注意力,所以不需要掩蔽)。
  • 编码器-解码器多头注意力:用当前节点的输入作为Q、编码器对原文各时间步生成的词元向量作为KV计算注意力。

这个编码器解码器架构的典型场景是机器翻译。而对于理解类场景,使用编码器only架构即可。对于标准的自回归生成式语言模型,使用解码器only架构即可,此时解码器中的编码器-解码器多头注意力也应直接去掉。

了解了Transformer的整体架构后,我们再来讨论KV Cache到底缓存的是什么

  • 训练阶段是不需要做KV Cache的,这是因为整个输入序列是已知的,模型可以一次性算出所有位置的KV和注意力结果。
  • 在预测阶段,由于词元是一个个生成的,所以Q是一个个产生的,那么历史的Q(作为后续Q的KV)肯定是需要保存的。但是历史的Q转化为KV参与注意力计算的过程中,有一步其实是重复性计算,那就是KV投影,也就是。所以KV Cache本质上缓存的就是这个投影向量值。KV Cache并没有改变的复杂度,而是消除掉了原本计算量很大的计算KV投影的个矩阵乘法,只剩计算更简单的个点积。

【TODO】Transformers for Vision(视觉Transformer)

【TODO】Large-Scale Pretraining with Transformers(Transformer的大规模预训练)

【TODO】优化算法

【TODO】计算性能

欢迎留言