发布于 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

欢迎留言