该篇笔记整理自余庭嵩的讲解。如果主要看优化器在pytorch中如何使用则可以直接看optim.SGD这一部分的讲解,该部分对优化器pytorch实现时的各变量解释详细,并且其他优化器的变量与其大同小异,在其他部分里就不再重新说明了。本文重点是探讨其中一种优化器的全部数学原理,其他优化器都是基于不同程度上的改进,最后一部分进行了罗列,如果需要详细了解原理可根据所给论文去阅读。
基本概念
优化器的功能:管理并更新模型中可学习参数的值,使得模型输出更接近真实标签。
这里的可学习参数一般就是指权值和偏置了,管理指优化器可以修改哪一部分参数。更新就是优化器的更新策略,每个不同的优化器会采取不同的策略去更新参数的值,这里策略通常是梯度下降。
在展开讨论之前,先明确下面几个基本概念:
- 导数:函数在指定坐标轴上的变化率
- 方向导数:指定方向上的变化率
- 梯度:一个向量。方向为使得方向导数取得最大值的方向(各个方向上变化率最大的那个方向),模长就是这个最大的变化率。
- 梯度下降:一种策略。梯度是增长最快的方向,所以取梯度的负方向就是下降最快的那个方向。函数值就是loss值,所以用梯度下降来更新权值,使得函数值最快地降低。
PyTorch实现与机制
优化器的属性
在pytorch中优化器类名定义为Optimizer,其继承于object父类。优化器中包括如下几个主要基本属性:
- defaults:优化器的超参数。里面通常存储学习率,momentum的值以及decay的值等等
- state:用于存储参数的缓存。例如momentum会使用到前几次更新时所使用的梯度,那前几次的梯度就需要缓存在state中
- param_groups:管理的参数组。以list形式存储,list中的每一个元素是一个字典,字典的key(源代码里是’params’)对应的value才是真正的参数,这里的value其实又是一个list,里面包含了多个参数值。前面基本概念中提到了管理的概念,param_groups中存储的参数就是优化器可以修改的全部参数
- _step_count:记录更新次数,学习率调整中会使用到。例如100轮下降一次学习率,200轮再下降一次学习率,下降了几次就保存在这个属性中
优化器的方法
还包含如下几个主要的基本方法(函数):
- zero_grad():清空所管理参数的梯度。参数都是tensor类型,tensor中会自动存储梯度的信息,而tensor的梯度信息是不会自动清零的。每次反向传播会把梯度值加到tensor的grad里面(累加),因此需要在求导前或者更新后进行清零。该函数判断优化器管理的参数组每个张量的梯度是否是None,如果不是就清零,是就不操作。
- step():执行一步更新。当计算得loss,再用loss.backward()反向传播计算得到梯度之后,就需要使用step()进行权值参数的更新。更新的策略根据优化器的变化而变化。
- add_param_group():添加参数组。优化器管理的参数是分组的,对于不同组的参数有不同的超参数的设置。例如NLP文本特征提取部分的参数,我们希望其学习得慢一些,后面自己添加的全连接层,我们希望其学习得快一些,由此就可以分两组参数,对这两组分别设置不同的学习率或者别的超参数,因此需要参数组的概念。
- state_dict():获取优化器当前状态信息字典。这个函数的返回值是一个字典,字典中只有两个key。一个是state,一个是param_groups。
- load_state_dict():将状态信息字典加载到优化器当中。这个函数和state_dict()函数使得模型可以断点续训练。比如训练模型需要一个月,如果写了隔几个epoch执行一次state_dict(),那断电也没事,开机找到存好的dict用load加载进来就可以接着前面的进度继续训练了。
优化器方法使用实例
首先我们手动创建一个可学习的参数,给其梯度赋值,然后定义好随机梯度下降优化器准备以其为例:
weight = torch.randn((2, 2), requires_grad=True)
weight.grad = torch.ones((2, 2))
optimizer = optim.SGD([weight], lr=0.1)
之后我们依次观察优化器的每个方法都做了些什么。
optimizer.step()
通过下面代码构造使用实例
print("weight before step:{}".format(weight.data))
optimizer.step() # 修改lr=1 0.1观察结果
print("weight after step:{}".format(weight.data))
输出结果为
weight before step:tensor([[0.6614, 0.2669],
[0.0617, 0.6213]])
weight after step:tensor([[ 0.5614, 0.1669],
[-0.0383, 0.5213]])
以第一个值为例,0.6614变成了0.5614,为什么?因为梯度设置的是1,学习率是0.1,所以一次更新1*0.1就等于0.1,由于更新策略定义了是随机梯度下降,所以要在梯度的反方向上更新,所以就在原权值的基础上减去0.1,由此得到最后结果。这里这么简单是因为我们直接赋值好了梯度,实际情况中梯度是要通过求解得出来的。具体的求解机制可粗浅理解入下,矩阵中每一个元素都影响着下一步的几个特定的输出,那矩阵中某个特定元素的梯度其实就是计算其影响的每个输出对其的偏导之和。
总结一下,step就是利用已经求好的梯度对权值进行一次更新。
optimizer.zero_grad()
通过下面代码构造使用实例
print("weight before step:{}".format(weight.data))
optimizer.step()
print("weight after step:{}".format(weight.data))
print("weight in optimizer:{}\nweight in weight:{}\n".format(id(optimizer.param_groups[0]['params'][0]), id(weight)))
print("weight.grad is {}\n".format(weight.grad))
optimizer.zero_grad()
print("after optimizer.zero_grad(), weight.grad is\n{}".format(weight.grad))
输出结果为
weight before step:tensor([[0.6614, 0.2669],
[0.0617, 0.6213]])
weight after step:tensor([[ 0.5614, 0.1669],
[-0.0383, 0.5213]])
weight in optimizer:1587976042392
weight in weight:1587976042392
weight.grad is tensor([[1., 1.],
[1., 1.]])
after optimizer.zero_grad(), weight.grad is
tensor([[0., 0.],
[0., 0.]])
输出结果中值得注意的是,优化器中的权重地址和实际的权重地址是一样的,所以优化器中的权重和实际权重共享内存。
optimizer.add_param_group()
通过下面代码构造使用实例,给优化器以字典的形式添加一组参数
print("optimizer.param_groups is\n{}".format(optimizer.param_groups))
w2 = torch.randn((3, 3), requires_grad=True)
optimizer.add_param_group({
"params": w2, 'lr': 0.0001})
print("optimizer.param_groups is\n{}".format(optimizer.param_groups))
输出结果为
optimizer.param_groups is
[{'params': [tensor([[0.6614, 0.2669],
[0.0617, 0.6213]], requires_grad=True)], 'lr': 0.1, 'momentum': 0, 'dampening': 0, 'weight_decay': 0, 'nesterov': False}]
optimizer.param_groups is
[{'params': [tensor([[0.6614, 0.2669],
[0.0617, 0.6213]], requires_grad=True)], 'lr': 0.1, 'momentum': 0, 'dampening': 0, 'weight_decay': 0, 'nesterov': False},
{'params': [tensor([[-0.4519, -0.1661, -1.5228],
[ 0.3817, -1.0276, -0.5631],
[-0.8923, -0.0583, -0.1955]], requires_grad=True)], 'lr': 0.0001, 'momentum': 0, 'dampening': 0, 'weight_decay': 0, 'nesterov': False}]
就可以看见param_group中有两个字典了。
optimizer.state_dict()
通过代码构造下面使用实例:
optimizer = optim.SGD([weight], lr=0.1, momentum=0.9)
opt_state_dict = optimizer.state_dict()
print("state_dict before step:\n", opt_state_dict)
for i in range(10):
optimizer.step()
print("state_dict after step:\n", optimizer.state_dict())
torch.save(optimizer.state_dict(), os.path.join(DIR, "optimizer_state_dict.pkl"))
输出结果为
state_dict before step:
{'state': {}, 'param_groups': [{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'params': [1636409304984]}]}
state_dict after step:
{'state': {1636409304984: {'momentum_buffer': tensor([[6.5132, 6.5132],
[6.5132, 6.5132]])}}, 'param_groups': [{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'params': [1636409304984]}]}
可见,初次定义state_dict时,里面的state栏还没有信息。进行了十步更新之后,state_dict中就随时存储信息了。之后用torch.save把训练十步之后的优化器信息存在电脑硬盘上,后面就可以随时加载了。
optimizer.load_state_dict()
通过代码构造下面使用实例,构建一个优化器,读取进来pkl,之后调用函数:
optimizer = optim.SGD([weight], lr=0.1, momentum=0.9)
state_dict = torch.load(os.path.join(BASE_DIR, "optimizer_state_dict.pkl"))
print("state_dict before load state:\n", optimizer.state_dict())
optimizer.load_state_dict(state_dict)
print("state_dict after load state:\n", optimizer.state_dict())
输出结果为
state_dict before load state:
{'state': {}, 'param_groups': [{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'params': [2225753515928]}]}
state_dict after load state:
{'state': {2225753515928: {'momentum_buffer': tensor([[6.5132, 6.5132],
[6.5132, 6.5132]])}}, 'param_groups': [{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'params': [2225753515928]}]}
可见该函数加载进来了信息,可以接着这个信息进行训练了。
优化器数学原理
学习率
直接通过实例来理解学习率,例如目标函数如下:
y = 4 x 2 y=4x^2 y=4x2
把这里的x看成是权重,y看成是loss,下面通过代码来理解学习率的作用。
首先构造这个函数:
def func(x_t):
return torch.pow(2*x_t, 2)
x = torch.tensor([2.], requires_grad=True)
然后画出-10到10区间上的均匀分布的500个点的函数图像如下:
接下来模拟一下优化的过程,假如不考虑学习率的概念,直接令其为1即可,迭代四次。
# 记录loss迭代次数,画曲线
iter_rec, loss_rec, x_rec = list(), list(), list()
lr = 1
max_iteration = 4
for i in range(max_iteration):
y = func(x) # 得出loss值
y.backward() # 计算x的梯度
print("Iter:{}, X:{:8}, X.grad:{:8}, loss:{:10}".format(
i, x.detach().numpy()[0], x.grad.detach().numpy()[0], y.item()))
x_rec.append(x.item())
# 更新参数
x.data.sub_(lr * x.grad) #x = x - x.grad
x.grad.zero_()
iter_rec.append(i)
loss_rec.append(y)
plt.subplot(121).plot(iter_rec, loss_rec, '-ro')
plt.xlabel("Iteration")
plt.ylabel("Loss value")
x_t = torch.linspace(-3, 3, 100)
y = func(x_t)
plt.subplot(122).plot(x_t.numpy(), y.numpy(), label="y = 4*x^2")
plt.grid()
y_rec = [func(torch.tensor(i)).item() for i in x_rec]
plt.subplot(122).plot(x_rec, y_rec, '-ro')
plt.legend()
plt.show()
观察loss值(也就是函数值的变化):
Iter:0, X: 2.0, X.grad: 16.0, loss: 16.0
Iter:1, X: -14.0, X.grad: -112.0, loss: 784.0
Iter:2, X: 98.0, X.grad: 784.0, loss: 38416.0
Iter:3, X: -686.0, X.grad: -5488.0, loss: 1882384.0
可以发现,仅仅迭代四次,loss值就已经涨到了一个及其庞大的数值。说明每一次的更新公式x=x-x.grad是不合理的,因为会更新过头,一旦过头,这个式子只会让x的值更大,而x更大梯度就越大,如此往复就形成了函数值越来越大的情况。
由此就引入了学习率的概念,一次更新先别更新那么多,只按着梯度的方向更新一小步,那怎么表示沿着梯度的方向更新一小步的概念?由此想到在梯度前面乘以数值对更新的程度进行缩小,这个数值就是学习率,如果令其为0.2,再更新四次,loss值和更新过程的曲线图如下所示:
这就快要更新到那个最优值了,如果再增加迭代次数就变得收敛了,把迭代次数增加至20次,可得:
那如果把学习率设置成0.125呢?这时候就会发现,只需要一步迭代,就直接迭代到了最优点,后面梯度为0,权值就不再更新了。那么由此就产生了疑问,不同的学习率对于优化的过程到底怎样的影响呢?为观察影响,设置不同的学习率来作出对比曲线图,看0.01到0.2上均匀分布的10个学习率对优化过程的影响曲线,代码如下:
iteration = 100
num_lr = 10
lr_min, lr_max =