前言
文章性质:学习笔记 📖
学习资料:吴茂贵《 Python 深度学习基于 PyTorch ( 第 2 版 ) 》【ISBN】978-7-111-71880-2
主要内容:根据学习资料撰写的学习笔记,该篇主要介绍了如何选择合适的激活函数、损失函数和优化器。
一、选择合适的激活函数
激活函数 在神经网络中的作用有很多,主要作用是给神经网络提供 非线性建模能力 。如果没有激活函数,那么再多层的神经网络也只能处理线性可分问题。作为神经网络的激活函数,通常需要满足如下 3 个条件:
- 非线性:为提高模型的学习能力,如果是线性,那么再多层都相当于只有两层效果。
- 可微性:有时可以弱化,在一些点存在偏导即可。
- 单调性:保证模型简单。
常用的激活函数有 sigmoid 、tanh 、ReLU 、softmax 等。它们的图形、表达式、导数等信息如表 5-2 所示。
在搭建神经网络时,如何选择激活函数?如果搭建的神经网络层数不多,选择 sigmoid 、tanh 、ReLU 、softmax 都可以;如果搭建的网络层次比较多,选择不当就可能导致梯度消失问题。此时一般不宜选择 sigmoid 、tanh 激活函数,因为其导数都小于 1 ,尤其是 sigmoid 的导数在 [ 0, 1/4 ] 之间,多层叠加后,根据微积分链式法则,随着层数增多,导数或偏导将会指数级变小。所以层数较多时,激活函数需要考虑其导数不宜小于 1 也不能大于 1 ,大于 1 将导致梯度爆炸,导数为 1 最好,激活函数 ReLU 正好满足这个条件。也就是说,搭建比较深的神经网络时,一般使用 ReLU 激活函数,当然一般神经网络也可使用。除此之外,由于激活函数 softmax 的 ,softmax 常用于多分类神经网络输出层。激活函数在 PyTorch 中的使用示例:
m = nn.Sigmoid()
input = torch.randn(2)
output = m(input)
激活函数的输入维度与输出维度是一样的,其输入维度一般包括批量数 N ,即输入数据的维度一般是 4 维,如 ( N, C, W, H ) 。
二、选择合适的损失函数
损失函数(Loss Function)在机器学习中非常重要,因为 训练模型的过程实际就是优化损失函数的过程 。损失函数对每个参数的偏导数就是梯度下降中提到的梯度,防止过拟合时添加的正则化项也是加在损失函数后面。损失函数用于衡量模型的好坏,损失函数值越小说明模型和参数越符合训练样本。任何能够衡量模型预测值与真实值之间的差异的函数都可以叫做损失函数。
在机器学习中常用的损失函数有两种,即 交叉熵 Cross Entropy 和 均方误差 Mean Squared Error ,分别对应机器学习中的 分类问题 和 回归问题 。对分类问题的损失函数一般采用交叉熵,交叉熵反映了两个概率分布的距离(不是欧氏距离)。分类问题进一步又可分为多目标分类,如一次判断 100 张图是否包含 10 种动物,或单目标分类。回归问题预测的不是类别,而是一个任意实数。在神经网络中一般只有一个输出节点,该输出值就是预测值。其反映的预测值与实际值之间的距离可以用欧氏距离来表示,所以对这类问题我们通常使用均方差作为损失函数,均方差的定义如下:
PyTorch 中已经集成了多种损失函数,这里介绍两个经典的损失函数,其他损失函数基本上是在它们的基础上的变种或延伸。
1、torch.nn.MSELoss
具体格式:torch.nn.MSELoss(size_average=None, reduce=None, reduction='mean')
计算公式:
其中,N 是批量大小。如果参数 reduction 为非 None(默认值为‘mean’),则:
公式说明:x 和 y 是任意形状的张量,每个张量都有 n 个元素,若 reduction 取 none 则 L(x, y) 将不是标量;若取 sum 则 L(x, y) 只是差平方的和,但不会除以 n 。
参数说明:size_average,reduce 在 PyTorch 官方的后续版本中将移除,主要看参数 reduction,可以取 'none', 'mean', 'sum',其默认值为 'mean' 。如果 size_average,reduce 都取了值,则将覆盖 reduction 的值。
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(10)
loss = nn.MSELoss(reduction='mean')
input = torch.randn(1, 2, requires_grad=True)
print(input)
target = torch.randn(1, 2)
print(target)
output = loss(input, target)
print(output)
output.backward()
2、torch.nn.CrossEntropyLoss
交叉熵损失(Cross-Entropy Loss)又称为对数似然损失、对数损失,二分类时还可称为逻辑回归损失。在 PyTroch 里,它不是严格意义上的交叉熵损失函数,而是先将输入 input 经过 softmax 激活函数,将向量 “ 归一化 ” 为概率形式,然后再与实际值 target 计算严格意义上的交叉熵损失。在多分类任务中,经常采用 softmax 激活函数 + 交叉熵损失函数,因为交叉熵描述了两个概率分布的差异,然而神经网络输出的是向量,并不是概率分布的形式。所以需要 softmax 激活函数将一个向量进行 “ 归一化 ” 成概率分布的形式,再采用交叉熵损失函数计算损失值。
具体格式:torch.nn.CrossEntropyLoss(weight=None, size_average=None, ignore_index=-100, reduce=None, reduction='mean')
计算公式:
如果带上权重参数 weight ,则:
其中,weight 表示每个类别的损失设置权值,常用于类别不均衡问题。weight 必须是 float 类型的张量,其长度要与类别 C 一致,即每一个类别都要设置 weight 。代码示例:
import torch
import torch.nn as nn
torch.manual_seed(10)
loss = nn.CrossEntropyLoss()
# 假设类别数为 5
input = torch.randn(3, 5, requires_grad=True)
# 每个样本对应的类别索引,其值范围为 [0,4]
target = torch.empty(3, dtype=torch.long).random_(5)
output = loss(input, target)
output.backward()
三、选择合适的优化器
优化器 在机器学习、深度学习中往往起着举足轻重的作用,同一个模型,因选择的优化器不同,性能有可能相差很大,甚至导致模型无法训练。因此了解各种优化器的基本原理非常必要。本节重点介绍各种优化器或算法的主要原理,及其各自的优点或不足。
1、传统梯度优化算法
传统梯度优化算法 为最常见、最简单的一种参数更新策略。其基本思想是:先设定一个学习率 𝜆 ,参数沿梯度的反方向移动。假设基于损失函数 𝐿 (𝑓(𝑥, 𝜃), 𝑦) ,其中 𝜃 表示需要更新的参数,梯度为 g ,则其更新策略的伪代码如下所示:
这种梯度优化算法简洁,当学习率取值恰当时,可以收敛到全面最优点(凸函数)或局部最优点(非凸函数)。其不足也很明显:对超参数学习率比较敏感,过小导致收敛速度过慢,过大又越过极值点,如图 5-12 的 c 所示。在比较平坦的区域,因梯度接近 0 ,易导致提前终止训练,如图 5-12 的 a 所示。要选中一个恰当的学习速率往往要花费不少时间。
学习率不仅敏感,有时还会因其在迭代过程中保持不变,很容易造成算法被卡在鞍点的位置,如图 5-13 所示。
除此之外,在较平坦的区域,因梯度接近于 0 ,优化算法往往因误判还未到达极值点就提前结束迭代,如图 5-14 所示。
传统梯度优化算法的这些不足,在深度学习中会更加明显。为此,人们自然想到要克服这些不足。从前文更新策略的伪代码可知,影响优化的主要因素有:训练数据集的大小、梯度方向、学习率。很多优化方法从这些方面入手:
- 从数据集优化方面入手,采用批量随机梯度下降方法;
- 从梯度方向优化方面入手,采用动量更新策略;
- 从学习率入手,涉及自适应问题;
- 还有从两方面同时入手等方法。
接下来我们将具体介绍这些方法。
2、批量随机梯度下降法
梯度下降法是非常经典的算法,训练时如果使用全训练集,虽然可获得较稳定的值,但比较耗费资源,尤其当训练数据比较大时;另一个极端时,每次训练时用一个样本(又称为随机梯度下降法),这种训练方法振幅较大,也比较耗时,如图 5-15 所示。
这种方法虽然资源消耗较少,但非常消耗时间,因此无法充分发挥深度学习程序库中高度优化的矩阵运算的优势。为了更有效地训练模型,我们采用一种折中方法,即 批量随机梯度下降法 。这种梯度下降方法有两个特点:批量 ,随机性 。
如何实现批量随机梯度下降呢? 其伪代码如下:
其中 和小批量数据集的所有元素都是从训练集中随机抽出的,这样梯度的预期将保持不变。相对于随机梯度下降,批量随机梯度下降降低了收敛波动性,即降低了参数更新的方差,使得更新更加稳定,有利于提升其收敛效果,如图 5-16 所示。
3、动量算法
梯度下降法在遇到平坦或高曲率区域时,学习过程有时很慢。利用 动量算法 能比较好地解决这个问题。
我们以求解函数 极值为例,使用梯度下降法和动量算法分别进行迭代求解。
由图 5-17 可知,不使用动量算法的梯度下降法的学习速度比较慢,振幅比较大。由图 5-18 可知,使用动量算法的振幅较小,而且较快到达极值点。动量算法是如何做到这点的呢?动量(Momentum)是模拟物理里动量的概念,具有物理上惯性的含义。一个物体在运动时具有惯性,把这个思想运用到梯度下降法中,可以增加算法的收敛速度和稳定性,具体实现如图 5-19 所示。
由图 5-19 所示,动量算法每下降一步都是由前面下降方向的一个累积和当前点的梯度方向组合而成。
含动量的随机梯度下降法的算法伪代码如下:
动量算法的 PyTorch 代码实现如下:
def sgd_momentum(parameters, vs, lr, gamma):
for param, v in zip(parameters, vs):
v[:] = gamma * v + lr * param.grad
param.data -= v
param.grad.data.zero_()
其中 parameters 是模型参数,假设模型为 model ,则 parameters 为 model. parameters() 。
具体使用动量算法时,动量项的计算公式如下:
如果按时间展开,则第 k 次迭代使用了从 1 到 k 次迭代的所有负梯度值,且负梯度按动量系数 α 指数级衰减,相当于使用了移动指数加权平均。假设每个时刻的梯度 相似,则得到:
由此可知,当在比较平缓处,但 α = 0.5 , 0.9 时,梯度值将分别是梯度下降法的 2 倍、10 倍。使用动量算法,不但可以加快迭代速度,还可以跨过局部最优找到全局最优,如图 5-20 所示。
4、Nesterov 动量算法
既然每一步都要将两个梯度方向(历史梯度、当前梯度)做一个合并再下降,那为什么不先按照历史梯度往前走那么一小步,按照前面一小步位置的 “ 超前梯度 ” 来做梯度合并呢?可以先往前走一步,在靠前一点的位置(图 5-21 中的 C 点)看到梯度,然后按照那个位置来修正这一步的梯度方向,如图 5-21 所示。
这就得到动量算法的一种改进算法,即 NAG(Nesterov Accelerated Gradient)算法,也称 Nesterov 动量算法。这种预更新方法能防止大幅振荡,不会错过最小值,并对参数更新更加敏感。如图 5-22 所示。
NAG 动量算法的伪代码如下:
NAG 动量算法的 PyTorch 代码实现如下:
def sgd_nag(parameters, vs, lr, gamma):
for param, v in zip(parameters, vs):
v[:]*= gamma
v[:]-= lr * param.grad
param.data += gamma * gamma * v
param.data -= (1 + gamma) * lr * param.grad
param.grad.data.zero_()
NAG 动量算法和经典动量算法的差别就在 B 点和 C 点梯度的不同。动量算法更关注梯度下降方法的优化,如果能从方向和学习率同时优化,效果或许更理想。事实也确实如此,而且这些优化在深度学习中显得尤为重要。
接下来我们介绍几种自适应优化算法,这些算法可以同时从梯度方向及学习率进行优化,效果非常好。
5、AdaGrad 算法
传统梯度下降算法对学习率这个超参数非常敏感,难以驾驭;对参数空间的某些方向也没有很好的方法。这些不足在深度学习中,因高维空间、多层神经网络等因素,常会出现平坦、鞍点、悬崖等问题,因此,传统梯度下降法在深度学习中显得力不从心。还好现在已有很多解决这些问题的有效方法。上节介绍的动量算法在一定程度上缓解了参数空间某些方向的问题,但是需要新增一个参数,而且对学习率的控制还不是很理想。为了更好地驾驭这个超参数,人们想出来多种自适应优化算法,使用自适应优化算法,学习率不再是一个固定不变值,它会根据不同情况自动调整学习率以适用实际情况。这些算法使深度学习向前迈出一大步!下面我们将介绍几种自适应优化算法。先来看 AdaGrad 算法。
AdaGrad 算法 通过参数来调整合适的学习率 𝜆 ,能独立地自动调整模型参数的学习率,对稀疏参数进行大幅更新,对频繁参数进行小幅更新,如图 5-23 所示。因此,Adagrad 方法非常适合处理稀疏数据。AdaGrad 算法在某些深度学习模型上效果不错。但还有些不足,可能因其累积梯度平方导致学习率过早或过量地减少所致。
AdaGrad 算法的伪代码如下:
由上面算法的伪代码可知:
1. 随着迭代时间越长,累积梯度参数 𝑟 越大,学习率 𝜆 / ( 𝛿+√𝑟) 会越小,在接近目标值时,不会因为学习速率过大而越过极值点。
2. 不同参数之间学习速率不同,因此,与前面的固定学习率相比,不容易在鞍点卡住。
3. 如果梯度累加参数 𝑟 比较小,则学习率会比较大,所以参数迭代的步长就会比较大。反之亦然。
AdaGrad 算法的 PyTorch 代码实现如下:
def sgd_adagrad(parameters, s, lr):
eps = 1e-10
for param, s in zip(parameters, s):
s[:] = s + (param.grad) ** 2
div = lr / torch.sqrt(s + eps) * param.grad
param.data = param.data - div
param.grad.data.zero_()
6、RMSProp 算法
RMSProp 算法 修改自 AdaGrad 算法,其在非凸背景下的效果更好,在凸函数中振幅可能较大,如图 5-24 所示。对梯度平方和累计越来越大的问题,RMSProp 算法用指数加权的移动平均代替梯度平方和。为了使用移动平均,RMSProp 算法引入了一个新的超参数 ρ ,用来控制移动平均的长度范围。
RMSProp 算法的伪代码如下:
RMSProp 算法的 PyTorch 代码实现如下:
def rmsprop(parameters, s, lr, alpha):
eps = 1e-10
for param, sqr in zip(parameters, s):
sqr[:] = alpha * sqr + (1 - alpha) * param.grad ** 2
div = lr / torch.sqrt(sqr + eps) * param.grad
param.data = param.data - div
param.grad.data.zero_()
7、Adam 算法
Adam(Adaptive Moment Estimation,自适应矩估计)算法 本质上是带有动量项的 RMSprop 算法,它利用梯度的一阶矩估计和二阶矩估计动态调整每个参数的学习率。Adam 的优点主要在于经过偏置校正后,每一次迭代学习率都有 1 个确定范围,使得参数比较平稳,如图 5-25 所示。
Adam 算法的伪代码如下:
Adam 算法的 PyTorch 代码实现如下:
def adam(parameters, vs, s, lr, t, beta1=0.9, beta2=0.999):
eps = 1e-8
for param, v, sqr in zip(parameters, vs, s):
v[:] = beta1 * v + (1 - beta1) * param.grad
sqr[:] = beta2 * sqr + (1 - beta2) * param.grad ** 2
v_hat = v / (1 - beta1 ** t)
s_hat = sqr / (1 - beta2 ** t)
param.data = param.data - lr * v_hat / (torch.sqrt(s_hat) + eps)
param.grad.data.zero_()
8、Yogi 算法
Adam 算法综合了动量算法及自适应算法的优点,是深度学习常用的算法,但也存在一些问题:即使在凸环境下,当 𝑟 的第二阶矩估计值爆炸时,它可能无法收敛。为此可通过改进 𝑟 和优化参数初始化等方法来解决。 其中通过改进 𝑟 是一种有效方法:
Yogi 算法的 PyTorch 代码实现如下:
def yogi(parameters, vs, s, lr, t, beta1=0.9, beta2=0.999):
eps = 1e-8
for param, v, sqr in zip(parameters, vs, s):
v[:] = beta1 * v + (1 - beta1) * param.grad
#sqr[:] = beta2 * sqr + (1 - beta2) * param.grad ** 2
sqr[:] = sqr + (1 - beta2) * torch.sign(torch.square(param.grad) - sqr) * torch.square(param.grad)
v_hat = v / (1 - beta1 ** t)
s_hat = sqr / (1 - beta2 ** t)
param.data = param.data - lr * v_hat / (torch.sqrt(s_hat) + eps)
param.grad.data.zero_()
9、使用优化算法实例
前面介绍了深度学习的正则化方法,它是深度学习的核心;优化算法也是深度学习的核心。优化算法很多,如随机梯度下降法、自适应优化算法等,那么具体使用时该如何选择呢?RMSprop 、Nesterov 、Adadelta 和 Adam 被认为是自适应优化算法,因为它们会自动更新学习率。而使用 SGD 时,必须手动选择学习率和动量参数,因此通常会随着时间的推移而降低学习率。有时可以考虑综合使用这些优化算法,如先用 Adam 算法,再用 SGD 优化方法。实际上,由于在训练的早期阶段,SGD 对参数调整和初始化非常敏感,我们可以通过先使用 Adam 优化算法进行训练(这将大大节省训练时间,且不必担心初始化和参数调整),待用 Adam 训练获得较好的参数后,再切换到 SGD + 动量优化,以达到最佳性能。采用这种方法有时能达到很好效果,如图 5-26 所示,迭代次数超过 150 后,SGD 的效果好于 Adam 的效果。
实例:基于 MNIST 数据集,使用自定义的优化算法实现图像的分类任务。为便于比较,使用梯度下降法及动量算法两种优化算法。
参考代码:feiguyunai/Python-DL-PyTorch2/pytorch-05/pytorch-05-04.ipynb at main · Wumg3000/feiguyunai · GitHub
1)算法定义。
# 梯度下降法
def sgd(parameters, lr):
for param in parameters:
param.data -= lr * param.grad
param.grad.data.zero_()
# 动量梯度下降法
def sgd_momentum(parameters, vs, lr, gamma):
for param, v in zip(parameters, vs):
v[:] = gamma * v + lr * param.grad
param.data -= v
param.grad.data.zero_()
2)定义模型。
net = nn.Sequential(
nn.Linear(784, 200),
nn.ReLU(),
nn.Linear(200, 10),
)
3)定义损失函数。
loss_sgd = nn.CrossEntropyLoss()
loss_sgd_mom = nn.CrossEntropyLoss()
4)加载数据。这里使用批量大小为 128 的训练数据集。
# 定义预处理函数
transform = transforms.Compose([transforms.ToTensor(),transforms.Normalize([0.5], [0.5])])
# 下载数据,并对数据进行预处理
train_dataset = mnist.MNIST('./data', train=True, transform=transform, download=False)
test_dataset = mnist.MNIST('./data', train=False, transform=transform)
# 得到数据生成器
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False)
5)训练模型。
# 初始化梯度平方项
s = []
for param in net.parameters():
s.append(torch.zeros_like(param.data))
# 开始训练
losses0 = []
# losses1 = []
idx = 0
start = time.time()
for e in range(5):
train_loss = 0
for img, label in train_loader:
# 展平 img
img=img.view(img.size(0), -1)
# 正向传播
out = net(img)
loss = loss_sgd(out, label)
# loss = loss_sgd_mom(out, label)
# 反向传播
net.zero_grad()
loss.backward()
sgd(net.parameters(), 1e-2)
# sgd_momentum(net.parameters(), vs, 1e-2, 0.9)
# 记录误差
train_loss += loss.item()
if idx % 30 == 0:
losses0.append(loss.item())
# losses1.append(loss.item())
idx += 1
print('epoch: {}, Train Loss: {:.6f}'.format(e, train_loss / len(train_loader)))
end = time.time()
print('使用时间: {:.5f} s'.format(end - start))
如果使用动量算法,只要把 sgd(net.parameters(),1e-2) 改为 sgd_momentum(net.parameters(), vs, 1e-2, 0.9) 即可。
6)可视化两种优化算法的运行结果
x_axis = np.linspace(0, 5, len(losses0), endpoint=True)
plt.semilogy(x_axis, losses0, label='sgd')
plt.semilogy(x_axis, losses1, label='momentum')
plt.legend(loc='best')
根据运行结果图可知,动量算法的优势还是非常明显的。