机器学习相关内容学习笔记
环境搭建
MiniConda
Conda 是一个开源的软件包管理系统和环境管理系统,用于安装多个版本的软件包及其依赖关系,并在它们之间轻松切换。
这意味着我们可以想使用Pip或者Pacman或者Winget那样使用Conda来安装,例如使用conda安装pytorch
1 | conda install pytorch torchvision torchaudio pytorch-cuda=11.7 -c pytorch-nightly -c nvidia |
并且Conda还能轻松的帮我们管理不同的软件版本,比如我们可以设置不同的环境使用不同的Python版本,并且这两个环境并不影响:
1 | conda create --name d2l python==3.8 |
Conda除了管理python环境以外,还支持C、JAVA、等等一些语言的环境隔离。并且自带UI界面。
而MiniConda是Conda的精简版,其中只包含Conda的软件包管理器和Python。
下面我们位Conda配置环境:
首先我们从官网上下载系统对应版本的MiniConda:
center large安装时注意需要把添加环境变量选上,或者手动在系统变量的Path中添加如下变量:
1 | E:\MiniConda # MiniConda安装路径 |
CUDA
训练模型通常需要我们使用GPU加速,因此我们需要配置CUDA才能让PyTorch这类框架能够利用到我们的GPU。(如果显卡是N卡的话)
如果是AMD显卡,则需要使用Ubuntu(或其他Linux发行版)来安装ROCm
首先我们通过Nvidia控制面板,点击系统信息(左下角) 组件标签
查看NVCUDA64.DLL这一栏,产品名称后面写的CUDA版本号:
例如我这一台支持的CUDA版本是11.7.101
感兴趣的话还可以去一下链接产看一下自己显卡的算力:
前往如下网站下载对应版本的CUDA:
center large安装完成后输入以下命令查看是否安装成功:
1 | nvcc -V |
然后配置如下环境变量:
1 | E:\CUDA\Computing\bin |
运行如下命令查看版本:
1 | nvidia-smi |
PyTorch
前往PyTorch官网:
查看对应版本的下载命令,例如目前使用的CUDA11.7只有Preview版本支持:
1 | pip3 install --pre torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/nightly/cu117 |
如果下载过程过于缓慢,可以将pip或Conda的源换成国内源。下面我们用pip举例:
在C:/User/XXXX/下新建pip文件夹
在pip文件夹下新建
pip.ini
文件在文件中写下如下内容:
[global] index-url = https://pypi.tuna.tsinghua.edu.cn/simple [install] trusted-host = https://pypi.tuna.tsinghua.edu.cn
1
2
3
4
5
6
7
8
9
4. 保存后在命令行中输入以下命令` pip install update`验证是否配置成功
PyTorch安装后使用如下命令测试是否安装成功:
```python
python
>>> import torch
>>> torch.cuda.is_available()
返回True则表示PyTorch和CUDA均安装成功。
损失函数
补充一些基础知识
交叉熵
交叉熵本来是信息论种的内容,那么先来看看关于信息论的知识
信息量
信息量的基本思想是:
- 一个不太可能发生的事情发生了,包含的信息量更大
- 一个非常可能发生的事情发生了,包含的信息更小
对于如下两个事件:
- 今天早上太阳升起
- 今天早上又日食
信息论种认为1发生了这件事所包含的信息如此之少,以至于没有通知大家的必要
而2发生了这件事所包含的信息如此之多,或者是如此之有用,以至于需要在各大新闻平台播报
如果从提取数学模型,可以发现:
事件包含的信息量应与其发生的概率成负相关
于是我们假定某一离散的随机变量X,取值集合为${x_1, x_2, … , x_n}$,那么随机事件$X = x_i$的信息量被定义为:
$I(x_i) = -logP(X = x_i)$
其中log
标识自然对数,底数为e(也有资料的定义中标识底数为2),公式中P为变量X取值为$x_i$的概率。这个概率将落在0到1之间。
1 | import numpy as np |
可见信息量与概率的反比趋势。
并且随着概率趋向于0,即0概率事件,事件的信息量趋近于正无穷,满足信息量的基本想法。
信息熵
又称香农熵,用以对整个概率分布的平均信息量进行描述,具体方法即是对信息量关于概率分布P求期望:
$H(X) = -\sum_{i=1}^nP(X=x_i)logP(X=x_i)$
相对熵(KL散度)
上述计算都是基于一个假设:我们能够准确地得到一个随机变量的分布情况。
但往往我们无法观测一个随机变量的真实分布,通常情况下我们会使用一个近似的分布$Q(X)$来进行建模。因此我们需要一些附加的信息来抵消分布不同造成的误差。因此提出了相对熵的概念,也叫KL散度,它可以用来衡量两个分布的差异:
$D_{KL}(P || Q) = -\sum_{i=1}^nP(x_i)logQ(x_i) - (-\sum {i=1}^nP(x_i)logP(x_i)) = \sum{i=1}^n P(x_i) log\frac{P(x_i)}{Q(x_i)}$
KL散度具有如下两个性质:
- 不对称性,$D_{KL}(P||Q) \neq D_{KL}(Q||P)$
- $KL \geq 0 $始终成立,当且仅当$P(X) = Q(X)$时$KL = 0$
交叉熵
交叉熵与KL散度密切相关,我们将上述KL散度公式换一种写法:
$D_{KL}(P || Q) = -\sum_{i=1}^nP(x_i)logQ(x_i) - (-\sum {i=1}^nP(x_i)logP(x_i)) \ = \sum{i=1}^n P(x_i) log\frac{P(x_i)}{Q(x_i)} = -H(P(X)) - \sum_{i=1}^nP(x_i)logQ(x_i)$
交叉熵被定义为
$H(P,Q) = H(P)+D_{KL}(P||Q) = -\sum_{i-1}^nP(x_i)logQ(x_i)$
也就是KL散度公式的右半部分(带负号)
当我们考虑用于拟合真实分布的Q分布时,P分布相当于一个确定的分布,那么KL散度的左半$-H(P(X))$可以认为是固定值
那么我们可以将神经网络视为Q,那么神经网络的目的就是通过训练使近似分布Q逼近真实分布P
那么优化KL散度和优化交叉熵实际上是等效的,因此在机器学习上我们通常选择优化计算量更少的交叉熵。
交叉熵损失函数
而在实际应用中,我们将整个模型看作是对真实分布中的一次拟合,那么对于单个样本,假设真实分布为y,网络输出分布为$\hat y$,总的类别数为n,则交叉熵损失函数的计算方式如下:
$Loss = -\sum_{i=1}^ny_i log(\hat y_i)$
对一个batch,单标签n分类任务的交叉熵损失函数的计算方法为:
$Loss = -\sum_{j=1} ^ {batch _ size}\sum_{i=1}^ny_{ji} log(\hat y_{ji})$
自动求导
列对行求导:
求导 | 标量(1,) | 向量(n,1) | 矩阵(n,k) |
---|---|---|---|
标量(1,) | 标量(1,) | 行向量(1,n) | 矩阵(k,n) |
向量(m,1) | 列向量(m,1) | 矩阵(m,n) | 张量(m,k,n) |
矩阵(m,l) | 矩阵(m,l) | 张量(m,l,n) | 张量 (m,l,k,n) |
标量对向量求导
标量关于列向量的导数为行向量
| y | a | au | sum(x) | $||x||^2$ |
| ——————————— | ———– | ———————————– | ———– | ———— |
| $\frac{\delta y}{\delta \vec{X}}$ | $\vec{0}^T$ | $a\frac{\delta u}{\delta{\vec{X}}}$ | $\vec{1}^T$ | $2\vec{X}^T$ |
y | u + v | uv | $<\vec{u},\vec{v}>$ |
---|---|---|---|
$\frac{\delta y}{\delta \vec{X}}$ | $\frac{\delta u}{\delta \vec{X}} + \frac{\delta v}{\delta \vec{X}}$ | $\frac{\delta u}{\delta \vec{X}} v + \frac{\delta v}{\delta \vec{X}} u$ | $\vec{u}^T \frac{\delta u}{\delta \vec{X}} + \vec{v}^T\frac{\delta v}{\delta \vec{X}}$ |
向量对标量求导
列向量对标量的导数为列向量
向量对向量求导
分类
自动求导分为两类:
- 符号求导
- 数值求导
- 通过竖直拟合导数:
计算图
- 将代码分解为操作子
- 将计算表示为一个无环图
计算图构造方式有两种:
- 显示构造
- 隐式构造
其中Tensorflow
和Theano
需要显示构造
而PyTorch
是隐式构造
MXNet
两种方式都支持。
显示构造如下所示,直接列出公式即可:
隐式构造需要告知代码记住梯度:
根据链式法则,自动求导抱恨两种方式:正向以及反向:
在计算图上,正向和反向计算的过程如下所示:
因此反向累积的过程如下:
为什么使用反向而不是正向传播
多层感知机
线性模型的缺陷
线性意味着单调假设:
任何特征的增大都会导致模型输出增大(权重为正时,或减小(权重为负时
这对于部分具有线性特征的事件:
例如收入与还款概率
或可以使用线性来代替的事件:
例如根据体温预测死亡率可以使用与37摄氏度的距离作为特征
但我们生活中还有很多非线性的事件:
例如对猫狗的图片进行分类时,某个位置像素的强度无法增加图像描绘猫/狗的相似度。
但是进一步思考,为什么这样的线性关系不存在呢:
因为任何像素的重要性都以复杂的方式取决于该像素的上下文(周围像素的值)
为此,我们需要找到一种可以考虑特征之间的相关交互作用的表示方法。
在深度神经网络中,我们使用观测数据来联合学习隐藏层表示和应用与该表示的线性预测器。
多层感知机
可以通过在线性网络中加入一个或多个隐藏层来克服线性模型的限制,使其能处理更普遍的函数关系类型
我们将许多全连接层堆叠在一起,每一层都输出到上面的层,直到生成最后的输出
我们可以把前L−1层看作表示,把最后一层看作线性预测器。 这种架构通常称为多层感知机(multilayer perceptron),通常缩写为MLP。
但如果我们只给隐藏层的各个单元设置权重和偏置,假设为$W^{(1)}, b^{(1)}$,而输出层的权重和偏置为$W^{(2)},b^{(2)}$,那么整个网络可以表示为:
$H = XW^{(1)} + b^{(1)}$
$O = HW^{(2)} + b^{(2)}$
H表示隐藏层输出,O表示感知机的输出。
可见以上两式可以通过带入法,求得W和b的结合式:
$W = W^{(1)}W^{(2)}$
$b = b^{(1)}W^{(2)} +b^{(2)}$
那么我们就可以只有单层的模型来替换这个模型。那这个模型又有什么意义呢?
于是为了发挥多层架构的潜力,我们需要一个额外的参数:在放射变换后对每个隐藏单元应用非线性的激活函数(activation function)$\sigma$
于是代表模型的函数终于变为了非线性:
$H = \sigma (XW^{(1)} + b^{(1)})$
$O = HW^{(2)} + b^{(2)}$
激活函数
激活函数(activation function)通过计算加权和并加上偏置来确定神经元是否应该被激活, 它们将输入信号转换为输出的可微运算。 大多数激活函数都是非线性的。
下面介绍一下常见的激活函数
ReLu
修正线性单元Relu(Rectified linear unit),对于给定元素x,ReLu被定义为该元素与0的最大值:
$ReLu(x) = max(x,0)$
下面我们尝试使用PyTorch
绘制Relu的图像:
1 | import torch |
可见ReLu是一个分段函数。
当输入为负时,ReLU函数的导数为0,而当输入为正时,ReLU函数的导数为1。 注意,当输入值精确等于0时,ReLU函数不可导。 在此时,我们默认使用左侧的导数,即当输入为0时导数为0。 我们可以忽略这种情况,因为输入可能永远都不会是0。
我们来绘制一下ReLu函数的导函数:
1 | import torch |
关于backwards中的参数的解释,可以从如下文章中得到答案:
此外,ReLU函数还有许多变体,包括参数化ReLU函数,该变体为ReLU添加一个线性项,因此即使参数为负数,某些信息仍然可以通过:
$pReLU(x) = max(0,x) + \alpha min(0,x)$
优缺点
优点:
1). 使用 ReLU 的 SGD 算法的收敛速度比 sigmoid 和 tanh 快;
2.) 在 x > 0 上,不会出现梯度饱和,梯度消失的问题。
3.) 计算复杂度低,不需要进行指数运算,只要一个阈值(0)就可以得到激活值。
缺点:
1.) ReLU 的输出不是 0 均值的,它将小于 0 的值都置为 0; 使得所有参数的更新方
向都相同,导致了 ZigZag 现象。
2.) Dead ReLU Problem (ReLU 神经元坏死现象):某些神经元可能永远不被激活,
导致相应参数永远不会被更新(在负数部分,梯度为 0)
3.) ReLU 不会对数据做幅度压缩,所以数据的幅度会随着模型层数的增加不断扩
张。
注: ZigZag 现象指的是,模型中所有的参数在一次梯度更新的过程中,更新方向相
同,即同为正或者同为负。这就导致了梯度更新图像呈现 Z 字形,进而导致梯度更新
效率比较低。
Sigmoid函数
对于一个定义域在R中的输入, sigmoid函数将输入变换为区间(0, 1)上的输出。 因此,sigmoid通常称为挤压函数(squashing function): 它将范围(-inf, inf)中的任意输入压缩到区间(0, 1)中的某个值:
$sigmoid(x) = \frac {1} {1 + exp(-x)}$
sigmoid函数可以视为softmax函数的特例,下面我们来绘制该函数:
1 | import torch |
下面我门来看看该函数的导数:
$\frac{d}{dx}sigmoid(x) = \frac{exp(-x)}{(1 + exp(-x))^2} = sigmoid(x)(1 - sigmoid(x))$
下面我们利用pytorch的反向传播机制来绘制sigmoid的导数图像,需要注意的是,当输入为0时,sigmoid函数的导数达到最大值0.25,而输入在任意方向上越远离0,导数就越接近于0:
1 | import torch |
tanh函数
与sigmoid函数类似, tanh(双曲正切)函数也能将其输入压缩转换到区间(-1, 1)上。 tanh函数的公式如下:
$tanh(x) = \frac {1 - exp(-2x)}{1 + exp(-2x)}$
下面我们绘制tanh函数。 注意,当输入在0附近时,tanh函数接近线性变换。 函数的形状类似于sigmoid函数, 不同的是tanh函数关于坐标系原点中心对称。
1 | import torch |
以及该函数的导数:
$\frac{d}{dx}tanh(x) = 1 - tanh^2$
当输入接近0时,tanh函数的导数接近最大值1。 与我们在sigmoid函数图像中看到的类似, 输入在任一方向上越远离0点,导数越接近0。
1 | import torch |
多层感知机实现
数据
我们继续使用Fashion_MNIST图像分类数据集进行实验,首先我们获取数据集:
1 | import torch |
参数
我们再次将图像展平作为特征,并设置输出层为10层,对于隐藏层和输出层的参数,我们为其设置维度:
1 | """准备参数 |
激活函数
然后为隐藏层设置激活函数:
1 | """激活函数 |
模型
定义模型:
1 | """模型 |
损失函数
定义损失函数:
1 | """损失函数 |
训练
模型训练与预测:
1 | """训练 |
结果
最终得到模型精度和损失曲线:
和预测结果:
多层感知机API实现
定义模型
使用torch.nn.Sequential
定义层级:
1 | # TODO:定义模型 |
初始化权重
定义权重初始化回调函数:
1 |
|
获取数据并训练
1 | # TODO:获取数据 |
模型选择
误差
训练误差
模型在训练数据集上计算得到的误差
泛化误差
模型应用在同样从原石样本的分布中抽取的无限多数据样本时,模型误差的期望
模型复杂性
模型容量指:
- 拟合各种函数的能力
- 低容量的模型难以拟合训练数据
- 高容量的模型可以记住所有的训练数据
具有更多参数的模型可能被认为更复杂, 参数有更大取值范围的模型可能更为复杂。 通常对于神经网络,我们认为需要更多训练迭代的模型比较复杂, 而需要“早停”(early stopping)的模型(即较少训练迭代周期)就不那么复杂。
下面介绍几个倾向于影响模型泛化的因素:
- 可调整参数的数量。当可调整参数的数量(有时称为自由度)很大时,模型往往更容易过拟合。
- 参数采用的值。当权重的取值范围较大时,模型可能更容易过拟合。
- 训练样本的数量。即使你的模型很简单,也很容易过拟合只包含一两个样本的数据集。而过拟合一个有数百万个样本的数据集则需要一个极其灵活的模型。
数据复杂性
衡量数据复杂度的重要因素有:
- 样本个数
- 每个样本的元素个数(特征)
- 时间、空间结构
- 多样性
模型选择
模型容量\数据 | 简单 | 复杂 |
---|---|---|
低 | 正常 | 欠拟合 |
高 | 过拟合 | 正常 |
在机器学习中,我们通常在评估几个候选模型后选择最终的模型。 这个过程叫做模型选择。 有时,需要进行比较的模型在本质上是完全不同的(比如,决策树与线性模型)。 又有时,我们需要比较不同的超参数设置下的同一类模型。
验证集
实际情况下,为了避免让模型出现过拟合的情况,我们通常不能依靠测试数据进行模型选择,然而我们不能仅仅依靠训练数据来进行模型选择,因为我们无法估计训练数据的泛化误差。
解决该问题常见的作法是将数据分为三份,除了训练集和测试集以外,再增加一个验证数据集(validation dataset)。
K-折交叉验证
当训练数据稀缺时,我们甚至可能无法提供足够的数据来构成一个合适的验证集。 这个问题的一个流行的解决方案是采用K折交叉验证。 这里,原始训练数据被分成K个不重叠的子集。 然后执行K次模型训练和验证,每次在K−1个子集上进行训练, 并在剩余的一个子集(在该轮中没有用于训练的子集 )上进行验证。 最后,通过对K次实验的结果取平均来估计训练和验证误差。
欠拟合和过拟合
欠拟合
训练误差和验证误差都很严重,但他们之间仅有一点差距时,如果模型不能降低训练误差,可能意味着模型过于简单(即表达能力不足),无法捕获试图学习的模式,此外由于我们的训练和验证误差之间的泛化误差很小,我们有理由相信可以用一个更复杂的模型降低训练误差,这种现象被称为欠拟合(underfitting)]
过拟合
当我们的训练误差明显地狱验证误差是,表明了严重的过拟合(iverfitting),但对于深度学习领域,最好的训练模型在训练数据上的表现往往比在验证集(或测试集)上好得多。**最终我们通常更关心验证误差,而不是训练误差和验证误差之间的差距。
权重衰退(Weight decay)
一种常用的处理过拟合的方法
上节讲到,我们可以通过控制参数的取值范围和数量来控制模型容量
使用均方范式作为硬性优化
例如最小化随时函数时,限制W的取值范围
$min\ Loss(\vec{W}, b) \ subject \ to \ ||\vec{W}||^2 \leq \theta$
- 但是通常不会限制偏移量b(因为效果差不多)
- 小的$\theta$意味着更强的正则项
使用均方范式作为柔性优化
这种方式更为常用,因为优化起来比硬性优化更方便
对每个$\theta $,都可以找到$\lambda $使得之前的目标函数等价于下面形式:
$min\ Loss(\vec{W}, b) + \frac{\lambda}{2}||\vec{W}||^2$
- 可以通过拉格朗日乘子来证明
超参数$\lambda$控制了正则项的重要程度
- $\lambda = 0$:无作用
- $\lambda \to \infty, \vec{W}^{} \to 0$其中W表示W的最优解
参数更新法则
- 计算梯度
$\frac{\delta}{\delta \vec{W}} (Loss(\vec{W}, b) + \frac{\lambda}{2}||\vec{W}||^2) = \frac{\delta Loss(\vec{W}, b)}{\delta \vec{W}} + \lambda \vec{W}$
- 时间t更新参数
$\vec{W}{t + 1} = \vec{W}{t} - \eta \frac{\delta}{\delta \vec{W}_t} (Loss(\vec{W}_t, b_t) + \frac{\lambda}{2}||\vec{W}_t||^2)$
将上式带入得:
$\vec{W}_{t + 1} = (1 - \eta \lambda)\vec{W}_t - \eta \frac{\delta Loss(\vec{W}_t, b_t)}{\delta \vec{W}_t}$
- 通常$\eta \lambda < 1$,在深度学习种通常叫权重衰退
总结
- 权重衰退通过L2正则项使得模型参数不会过大,从而控制模型复杂度
- 正则权重是控制模型复杂度的超参数
丢弃法(Dropout)
动机
一个好的模型需要对输入数据的扰动鲁棒
- 使用有噪音的数据等价于Tikhonov正则
- 丢弃法:在层间加入噪音
无偏差噪音
对于$X$加入噪音$X’$,我们希望
$E[X’] = X$
即增加噪声后,其期望仍然是原值
丢弃法则是通过如下的方式保证这一条件的:
$x_i’ = \begin{cases} 0 & with \ probability \ p \ \frac{x_i}{1-p} & otherwise \end{cases}$
显然计算$x_i’$的期望得到的值是$x$
Dropout的使用
通常将Dropout作用在隐藏层的输出上,直观来看就是将隐藏层中的某些神经元去掉了:
Dropout相当于一个正则项,指在训练时使用,推理过程并不适用。这样保证了输出的确定性
总结
- 丢弃法将一些输出项随机置0来控制模型复杂度
- 常用在多层感知机的隐藏层输出上
- 丢弃概率P时控制模型复杂度的超参数(常用0.5、0.9、0.1)
实验
首先我们手动的实现一下Dropout算法:
1 | import math |
上述代码上我们使用两个256个神经元的隐藏层来拟合一个较为简单的数据集,模型复杂而数据集简单,容易出现过拟合。因此使用Dropout的效果还是很明显的:
然后我们将p设为0看看不采用Dropout训练的结果:
可见在测试集上的误差相较于图1有些许下降
使用PyTorch提供的API实现也会得到类似的结果:
1 | # 简洁实现 |
数值稳定性和模型初始化
糟糕的初始化选择可能会导致我们在训练时遇到梯度爆炸或者梯度消失。
对于神经网络的梯度,我们考虑如下d层的神经网络
$h^t = f_t(h^{t-1}) \ and \ y = l (f_d(f_{d-1}(…f_1(x)))$
其中y为网络的损失,x为网络的输入,t为网络的第t层,$h_i$为第i个隐藏层,每一个隐藏层由变换$f_t$定义,每一层的权重为$W_t$,那么我们计算损失l关于参数$W_t$的梯度:
$\frac{\delta{l}}{\delta W^t} = \frac{\delta l}{\delta h^d} \frac{\delta h^d}{\delta h^{d-1}}…\frac{\delta{h^{t+1}}}{\delta h^t}\frac{\delta{h^{t}}}{\delta W^t}$
此处为了计算方便省去了偏置项,可见最终的结果是一个d-t次的矩阵乘法。
其中$\frac{\delta h^t}{\delta h^{t-1}}$是一个矩阵,为什么呢,我们来将他展开:
$h^t = f_t(h^{t-1}) = \sigma (W_th^{t-1})$
我们对$h^{t-1}$求导:
$\frac{\delta }{\delta h^{t-1}} \sigma (W_th^{t-1}) = \frac{\delta }{\delta W_th^{t-1}} \sigma (W_th^{t-1}) * \frac{\delta }{\delta h^{t-1}} (W_th^{t-1})$
其中$\frac{\delta }{\delta W_th^{t-1}} \sigma (W_th^{t-1})$我们可以看作是$\frac{\delta }{\delta \vec{X}} F(\vec{X})$
而$F(\vec{X})$实际上就是对$\vec{X}$中的每一项求$F(x_i)$,得到一个向量$\vec{Y}$,而对向量$\vec{X}$求导,则是将$\vec{Y}$的每一个分量对$\vec{X}$的每一个分量求导。因此只有在行和列相等的时候求导值才不为0。即一个对角矩阵,所以最终求导的结果如下:
$\frac{\delta }{\delta h^{t-1}} \sigma (W_th^{t-1}) = diag(\sigma ‘(W^th^{t-1}))(W^t)^T$
因此在处理概率时容易受到数值下溢的影响。
处理概率时,一个很常见的技巧是切换到对数空间,即将数值表示的压力从尾数转移到指数。
但实际上这样处理会导致原本矩阵中表示的各种各样的特征值发生变化。
不稳定梯度带来的风险不止在于数值表示; 不稳定梯度也威胁到我们优化算法的稳定性。
梯度消失将会带来以下危害:
- 梯度值变为0
- 对16位浮点数尤为严重
- 训练没有进展
- 不管如何选择学习率
- 对底部层尤为严重,
- 仅仅对顶部层训练效果较好
- 无法让神经网络更深
梯度消失
参数更新过小,在每次更新时几乎不会移动,导致模型无法学习
例如我们常用的sigmoid函数:
$sigmoid(x) = \frac{1}{1 + e^{-x}}$
它是导致梯度消失问题的常见愿意,我们来进行如下实验:
1 | import torch |
我们将Sigmoid函数和他的导数输出:
可见当函数的输入喊打活很小时,梯度接近0,非常小。
当反向传播通过许多层时,除非我们在刚刚好的地方, 这些地方sigmoid函数的输入接近于零,否则整个乘积的梯度可能会消失
梯度爆炸
参数更新过大,破坏了模型的稳定收敛
例如我们使用ReLU作为激活函数:
那么$diag(\sigma ‘(W^th^{t-1}))$中的值不是0就是1,因此最终结果$\frac{\delta{l}}{\delta W^t}$中的值完全来自于$(W^t)^T$,也就意味着如果d-t很大(即网络比较深),值也会很大。
这将会导致:
- 值超出值域
- 特别是对于16位浮点数($[6e^{-5}, 6e^4]$)(因为目前INVIDIA的显卡处理16位浮点数的运算要比32位快2倍,因此通常使用16位浮点数)
- 对学习率敏感
- 如果学习率过大,将会带来较大的参数值,将会带来更大的梯度
- 如果学习率过小,训练可能无法进展
让训练更稳定
因此我们最终需要做的是让训练更稳定,也就是让梯度的变化在一个可控的范围内例如让梯度在$[1e^{-6},1e^3]$
常用的方法有:
- 为了解决连乘导致的值过大和值过小的问题,可以将乘法变成加法
- 该方法在ResNet和LSTM中经常使用
- 归一化
- 梯度归一化(即将过大或过小的梯度映射到0,1的区间)
- 梯度裁剪(即将超过某一阈值的梯度强行设置为阈值)
- 进行合理的权重初始化和选择激活函数
下面我们来着重学习一些进行合理的权重初始化和选择激活函数的方法
让每层的方差是一个常数
为了做到这一点,我们可以:
- 将每层的输出和梯度都看作随机变量
- 让它们的均值和方差都保持一致
例如:
对于正向传播,我们将每一层的输出看作是一个随机向量,并控制随机向量的均值与方差相同:
$E[h_i^{t}] = 0,Var[h_i^t] = a$
而对于反向传递,我们梯度视为随机向量,并控制随机向量的均值与方差相同:
$E[\frac{\delta l}{\delta h_i^t}]=0,Var[\frac{\delta l}{\delta h_i^t}] =b$
我们可以使用如下方式来保证权重的这一特性
权重初始化
在合理值区间里随机初始化参数
因为训练开始的时候更容易有数值不稳定,远离最优解的地方损失函数表面可能很复杂,最优解附近表面会比较平整
使用N(0,0.01)来初始化权重可能对小网络没问题,但不能保证深度神经网络
我们使用一个MLP来举例(此处忽略激活函数):
那么正向的方差为:
其中第三行到第二行的公式是由于$W_{i,j}^t 和 h_{j}^{t-1}$的均值为0,那么均值的平方也为0,则补充上这两项之后可以转为方差。
此处我们另$E[W_{i,j}^t] = \gamma_t$
然后反向的方差:
由此我们得到两个条件:
- $n_{t-1} \gamma _t = 1$
- $n_{t} \gamma _t = 1$
但是通常这两个条件同时满足很困难,除非输入和输出维度相等,而Xavier则是在这一基础上做了一个权衡
Xavier初始
激活函数选择
我们再来看看为了满足期望为0方差不变的条件,激活函数需要具备怎样的特征:
此处我们先使用线性激活函数来计算特征,同样分别讨论正向和反向:
正向
反向
可见正向和反向,如果需要达到目标,当我们的激活函数为斜率为1的正比例函数时,效果最好,因此我们来看看我们的各种激活函数,并使用泰勒展开将其分解为多个简单的指数函数:
可见在一个很小的区间内,tanh和relu函数是更符合要求的,而sigmoid并不那么符合要求。所以为什么tanh和relu函数在真实训练中使用的更多。
同时我们也可以对sigmoid函数进行一些调整,例如如下操作:
$sigmoid’ = 4 \times sigmoid - 2$
可见调整后的sigmoid、tanh和relu函数在中间的位置更符合要求。
Kaggle房价预测
亲手搭建一个预测模型:
1 | import pandas as pd |
未调参之前的结果
PyTorch深度学习
层与块
在实际研究中我们发现,研究讨论比单个层大,但比整个模型小的组件更有价值。里热ResNet-152就有数百层。这些层是由层组(groups of layers)的重复模式组成的。
因此我们给出块的定义:
块(block)可以描述单个层、由多个层组成的组件或整个模型本身
下图描述了一个由层组成块,由块组成层的过程:
从编程的角度来看,我们将块用类表示。它必须包含:
- 一个将其输入转换为输出的前向传播函数
- 必需的参数
- 反向传播函数
由上面三项熟悉我们可以大致将块需要实现的功能进行归纳:
- 将输入数据作为前向传播函数的参数
- 通过前向传播函数生成输出
- 计算其输出关于输入的梯度,并可通过方向传播函数进行访问
- 存储和访问前向传播计算所需参数
- 柑橘需要初始化模型参数
以此我们可以自定义一个块,下面我们以一个MLP块为例:
1 | import torch |
事实上我们继承了nn.Module
后就无需自己实现反向传播函数了
顺序块
PyTorch中我们使用Secquential
来构建具备顺序执行能力的连续层。我们也可以尝试构建自己的顺序快,我们需要实现两个关键功能:
- 将块逐个追加到列表中的函数
- 用于将输入按块顺序传递的链条
1 | # 顺序快 |
_modules
的主要优点是: 在模块的参数初始化过程中, 系统知道在_modules
字典中查找需要初始化参数的子块。
前向传播中执行代码
有些架构需要在前向传播的过程中进行一些变换,例如参数固定的线性变换,或加入一些控制流。
1 | # 前向传播时执行代码 |
混合块
我们还可以混合搭配上面定义的各种块。
1 | # 混合搭配 |
参数管理
PyTorch允许我们直接访问参数,访问方法和访问Dict非常类似,并且可以直接通过名称来访问某个神经元的权重和偏置:
1 | import torch |
这使得我们对参数进行初始化会非常方便。
默认情况下,PyTorch会根据一个范围均匀地初始化权重和偏置矩阵, 这个范围是根据输入和输出维度计算出的。 PyTorch的
nn.init
模块提供了多种预置初始化方法。
1 | import torch |
延后初始化
可以发现我们在定义网络时,并未指定输入维度,添加层时也没有指定前一层的输出维度,初始化参数时也没有足够的信息来确定模型应该包含多少参数。
而这一切都被框架的延后初始化(defers initialization),即直到数据第一次通过模型传递时,框架才会动态地判断出每个层的大小。
需要注意的是,PyTorch中并没有提供一个稳定的内置延迟初始化功能,目前可以是使用torch.nn.LazyLinear
进行试用。此处我们使用TensorFlow
进行实验
1 | import tensorflow as tf |
发现结果为:
1 | [[],[]] |
然后我们让数据通过网络,再来看看网络参数
1 | X = tf.random.uniform((2, 20)) |
结果如下:
1 | [(20, 256), (256,), (256, 10), (10,)] |
自定义层
不带参数的层
只需要继承基类并实现前向传播功能即可:
1 | import torch |
下面我们向该层提供一些数据:
1 | layer = CenteredLayer() |
1 | tensor([-2., -1., 0., 1., 2.]) |
接下来就能把该层作为组件合并到更复杂的模型中了:
1 | net = nn.Sequential(nn.Linear(8, 128), CenteredLayer()) |
作为额外的健全性检查,我们可以在向该网络发送随机数据后,检查均值是否为0。 由于我们处理的是浮点数,因为存储精度的原因,我们仍然可能会看到一个非常小的非零数。
1 | Y = net(torch.rand(4,8)) |
1 | tensor(0., grad_fn=<MeanBackward0>) |
带参数的层
除了不带参数的层,我们还可以通过内置函数来创建参数,这些函数提供了一些基本的管理功能,比如:管理访问呢、初始化、共享、保存和加载模型参数。这样我们就不需要为每个自定义层编写自定义的序列化程序。
1 | class MyLinear(nn.Module): |
接下来,我们实例化MyLinear类并访问其模型参数
1 | linear = MyLinear(5, 3) |
1 | Parameter containing: |
自定义层可以直接执行前向传播计算:
1 | print(linear(torch.rand(2, 5))) |
1 | tensor([[0.0000, 0.0000, 0.2187], |
也可以直接用于模型构建:
1 | net = nn.Sequential(MyLinear(64, 8), MyLinear(8, 1)) |
1 | tensor([[ 7.4571], |
读写文件
有时我们希望保存训练的模型, 以备将来在各种环境中使用(比如在部署中进行预测)。 此外,当运行一个耗时较长的训练过程时, 最佳的做法是定期保存中间结果, 以确保在服务器电源被不小心断掉时,我们不会损失几天的计算结果。 因此,需要学习如何加载和存储权重向量和整个模型了。
加载和保存张量
使用save(tensor,'name')
进行保存
并使用load('name')
进行加载
1 | import torch |
1 | tensor([0, 1, 2, 3]) |
save()
还能存储一张量列表
1 | y = torch.zeros(4) |
还能直接读取map<String, Tensor>
1 | mydict = {'x': x, 'y': y} |
得到结果
1 | {'x': array([0., 1., 2., 3.]), 'y': array([0., 0., 0., 0.])} |
保存模型参数
我们使用熟悉的三层MLP来进行实验:
1 | class MLP(nn.Module): |
接下来保存一下模型的参数:
1 | torch.save(net.state_dict(), 'mlp.params') |
然后我们实例化一个多层感知机模型的备份,并用刚刚存储的参数来设置参数:
1 | clone = MLP() |
1 | MLP( |
然后我们比较一下两个模型:
1 | Y_clone = clone(X) |
1 | tensor([[True, True, True, True, True, True, True, True, True, True], |
GPU训练
访问GPU
PyTorch
允许我们访问GPU的数量和单个GPU,我们使用如下代码来查看机器上可以用的GPU:
1 | import torch |
1 | 1 |
下面我们可以创建两个工具来保证我们不会访问空GPU:
1 | def try_gpu(i=0): #@save |
1 | (device(type='cuda', index=0), |
张量与GPU
PyTorch
中每个张量都具有device属性,用来记录其保存的位置:
1 | X = torch([1,2,3]) |
1 | device(type='cpu') |
当我们需要进行多张量操作时,我们必须保证这些张量位于同一个设备上。
我们可以使用刚刚准备好的两个工具来将张量放置在GPU上:
1 | X = torch.ones(2, 3, device=try_gpu()) |
1 | tensor([[1., 1., 1.], |
在GPU上创建的张量只消耗这个GPU的显存。 我们可以使用nvidia-smi
命令查看显存使用情况。 一般来说,我们需要确保不创建超过GPU显存限制的数据。
对于位于不同GPU上的张量,我们需要将其中一个张量复制到另一个GPU上才能进行相应操作:
1 | Z = X.cuda(1) |
得到结果:
1 | tensor([[1., 1., 1.], |
如果对已经存在第一个GPU上的张量调用cuda(0)
则会返回该张量本身,而不是它的复制:
1 | X.cuda(0) is Z |
1 | True |
神经网络与GPU
同样模型参数也可以放在GPU上:
1 | net = nn.Sequential(nn.Linear(3,1)) |
向网络输入张量时,模型将再同一GPU上计算结果
1 | print(net(X)) |
1 | tensor([[0.5737], |
让我们确认一下
1 | print(net[0].weight.data.device) |
1 | cuda:0 |
卷积神经网络
卷积的理解
当我们对图像进行处理时,通常每张照片具有百万级的像素,这就意味着网络的每次输入都会有一百万个维度,即使将隐藏层降低到1000个神经元,这个连接层也将有$10^6 \times 10^3 = 10^9$个参数。
但如今人类和机器都能很好地区分猫和狗,英文图像中本就拥有丰富的结构,而这些结构可以被人类和机器学习模型使用。
而卷积神经网络就是机器学习利用自然图像中一些一直结构的创造性方法。
首先我们思考在图像识别任务中,有哪些结构可以为我们的是被提供帮助。
事实上儿童游戏”沃尔多在哪里”为我们提供了不错的灵感:
- 平移不变性:不管检测对象在哪个位置,识别器将有同样的结果
- 局部性:识别器往往不需要全局的审视整张图片
下面我们先来看看如何把普通的全连接层变为能够处理二维图片的全连接层:
其中$W_{ij}$表示第i层全连接的第j个参数。
然后我们思考如何将两个原则融入其中:
首先是平移不变性
即我们不希望我们用于识别的权重会因为采用了不同的输入X而变得不同,即对于该神经元的所有输入X,我们使用相同的一个权重矩阵。
其次对于局部性:
我们只希望观察$x_{i,j}$这个像素周围小范围内的点,因此我们将区间$[i-\Delta, i + \Delta],[j-\Delta, j+\Delta]$范围外的权重设为0。
卷积层
所谓二维卷积层,就是该层神经元可以学习一个用于对输入进行卷积的矩阵:
此处需要注意的是交叉相关和卷积的区别
可见二维卷积中卷积核需要进行反转,但实际使用中卷积核都是对称的,因此没有区别
我们从数学的角度重新来看看对于连续函数的卷积:
$(f * g)(x) = \int f(z)g(x-z)dz$
即卷积将一个函数”翻转”并移位x时,测量f和g之间的重叠。当为离散对象时,积分则变成了求和
$(f*g)(i)=\sum _a f(a)g(i-a)$
对于二维张量:
$(f*g)(i,h) = \sum_a\sum_bf(a,b)g(i-a, j-b)$
可见从数学的角度,我们也可得到与上述结果相近的结果。
下面我们来实现一下二维互相关于运算:
1 | import torch |
1 | tensor([[19., 25.], |
接着我们借助互相关运算来实现一下卷积层,卷积层将包含一个需要学习的卷积核,和一个需要学习的偏置值,而前向传播函数就是二维互相关运算:
1 | class Conv2D(nn.Module): |
接下来我们尝试训练一个卷积层:
1 | # TODO: 模拟一个边缘检测任务 |
1 | epoch 2, loss 7.283 |
可以看到学习到的卷积核与自定义的卷积核十分接近
特征映射与感受野
对于上图中的卷积层,有时被成为特征映射(feature map),因为它可以视为一个输入映射到下一层的空间维度的转换器。
而对于某一层的任意元素x,其感受野(receptive field)是指在前向传播期间可能影响x计算的所有元素,例如对于输出元素19,它的感受野就是输入部分的四个蓝色区域,感受野大小为4,即卷积核大小。
同理如果我们在这一层的输出后再添加一层相同的卷积层,那么我们将得到单个元素z,z对于中间层Y的感受野则包括我们输出的全部四个元素19、25、37、43,而对于输入来说,其感受野则为全部9个元素。
因此,越深的特征,感受野越大,越浅的特征感受野越小
步长与填充(stride & padding)
对于32 * 32的图片,如果才有5 * 5的卷积核,那么得到的输出为:
- 第一层输出28 * 28
- 第七层输出4 * 4
可见,更大的卷积核将更快地减小输出大小,我们可以总结出如下规律
- 对于$n_h \times n_w$的输入图片,$k_h \times k_w$的卷积核
- 经过一次卷积的输出为$(n_h - k_h + 1) \times (n_w - k_w +1)$
这就意味着卷积核的大小限制了我们的网络层数
填充
为了解决问题,其中一种解决方式就是再输入周围添加额外的行和列
可见我们的输入甚至比以前更大了。
对于填充了$p_h$行$p_w$列的输入,我们的输出将变为
$(n_h -k_h + p_h + 1) \times (n_w - k_w + p_w + 1)$
通常我们取$p_h = k_h -1, p_w = k_w - 1$使得图片大小不变
- 当$k_h$为奇数时,在上下两侧填充$p_h/2$
- 为偶数时,在上侧填充$\lceil p_h / 2 \rceil$,下侧填充$\lfloor p_h/2 \rfloor$向下取整
步幅
但如果我们希望减小需要训练的参数,常规的思路是增大卷积核的大小,但事实上随着卷积核大小的增加,感受野也随之增加,那么关注的细节也将减少。(通常我们的卷积核大小会选择5或3)
这时我们可以通过增加步幅来解决这个问题。
通常情况下填充减小的输出大小与层数线性相关,当我们改变步幅,将破坏这种线性相关,甚至使其变为指数相关。
下面我们来综合考虑一下:
给定步幅为$s_h \times s_w$,则输出的形状为:
$\lfloor (n_h - k_h + p_h + s_h)/s_h \rfloor \times \lfloor (n_w - k_w + p_w + s_w)/s_w \rfloor$
如果$p_h = k_h -1, p_w = k_w - 1$
$\lfloor (n_h - 1 + s_h)/s_h \rfloor \times \lfloor (n_w - 1 + s_w)/s_w \rfloor$
如果输入高度和宽度可以被步幅整除
$(n_h/s_h) \times (n_w/s_w)$
下面我们来试试padding和stride的效果:
1 | import torch |
1 | torch.Size([8, 8]) |
1 | # TODO: 将步幅设置为2 |
1 | torch.Size([4, 4]) |
多输入多输出通道
通常我们的图片会由不同通道组成的,因此我们可以认为图片实际上是一个三维的张量,那么对于多通道的图片如何处理呢
多输入通道
- 每个通道都有一个卷积核,结果是所有通道卷积结果的和
多输出通道
- 我们可以有多个三维卷积核,每个核生成一个输出通道
即如果我们需要输出$c_o$个通道,那么我们需要在输出层设置$c_o$个三维卷积核。
1*1卷积层
对于$k_w = k_h = 1$的卷积层,它不识别空间的模式,而只是进行通道融合。
1*1的卷积层我们可以理解为一个全连接层
接下来让我们从总体审视一下二维卷积层:
代码实现
首先看看多输入的情况:
1 | import torch |
1 | tensor([[ 56., 72.], |
然后我们再加入多输出:
1 | # TODO: 对于多输出的情况 |
1 | torch.Size([3, 2, 2, 2]) |
池化层
事实上卷积对位置是敏感的,但我们并不希望卷积层对位置过于敏感,因为实际照片中通常回出现歪斜,模糊,昏暗,过曝等等情况。
因此为了解决这个问题,卷积层引入了池化层来辅助
二维最大池化
- 池化层与卷积层类似,同样具有填充和步幅
- 但没有可以学习的参数
- 在每个输入通道应用池化层以获得相应的输出通道
- 输出通道数=输入通道数
平均池化层
- 最大池化层:每个窗口中最强的模式信号
- 平均池化层:将最大池化层中的“最大”操作替换为“平均”
代码实现
1 | import torch |
1 | tensor([[4., 5.], |
使用Pytorch的接口可以设置步幅和填充
1 | # TODO: Pytorch步幅填充调节 |
1 | tensor([[[[ 0., 1., 2., 3.], |
对于多通道的情况:
1 | # TODO: 多通道 |
1 | tensor([[[[ 0., 1., 2., 3.], |