我们知道,神经网络的学习的目的就是寻找合适的参数,使得损失函数的值尽可能小。解决这个问题的过程为称为最优化。解决这个问题使用的算法叫做优化器。
1. SGD
在前面我们实现的神经网络中所使用的优化方法是随机梯度下降法(Stachastic gradient desent 简称 SGD)。SGD 的想法就是沿着梯度的方向前进一定距离。用数学的语言来描述的话可以写成下式:
这里面, 表示需要更新的权重, 表示损失函数关于 的梯度(准确点来说这是一个 Jacobian 矩阵), 表示学习率, 表示使用右边的值更新左边的值。下面我们先给出一个 python 实现:
class SGD:
def __init__(self, lr=0.01):
self.lr = lr
def update(self, params, grades):
for key in params.keys():
params[key] -= self.lr * grades[key]
将优化器实现为一个类是一个很好的做法, python 是动态语言的原因,我们在实现的时候只要类里面都有 update 方法,解释器就会正常执行。例如我们可以将之前的学习过程写成这样:重点在于加了 # 号的那几行。
# 导入数据
(x_train, t_train), (x_test, t_test) = load_mnist(normalize=True, one_hot_label=True)
# 全局变量
train_loss_list = []
train_acc_list = []
test_acc_list = []
# 超参数
iter_num = 10000
train_size = x_train.shape[0]
batch_size = 100
learning_rate = 0.1
iter_per_epoch = max(train_size / batch_size, 1)
# 生成模型
net_work = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)
optimizer = SGD(learning_rate) ########
# 模型的训练
for i in range(iter_num):
# 获取 mini_batch
batch_mask = np.random.choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]
# 计算梯度
grads = net_work.gradient(x_batch, t_batch)
# 更新参数
params = net_work.params
optimizer.update(params, grads) ##########
# 记录学习过程
if i % iter_per_epoch == 0:
loss = net_work.loss(x_train, t_train)
train_loss_list.append(loss)
train_acc = net_work.accuracy(x_train, t_train)
train_acc_list.append(train_acc)
test_acc = net_work.accuracy(x_test, t_test)
test_acc_list.append(test_acc)
print('运行中... 损失:{} 训练集准确度:{} 测试集准确度:{}'.format(loss, train_acc, test_acc))
SGD 的优点就是简单,容易实现。但是其缺点就是低效,因为有的时候梯度的方向并没有指向最小值的方向。低效的原因有两大方面:
- 函数呈延伸状,梯度指向了’谷底‘。(文章的最后有一个呈延伸状函数的图片)
这使得损失函数值不停的在震荡。
- 函数呈延伸状,梯度指向了’谷底‘。(文章的最后有一个呈延伸状函数的图片)
- 梯度方向指向了极小值,或者鞍点方向,
因为所有维度的梯度在这附近都接近于 0,这使得损失函数在这里变化的很慢。
- 梯度方向指向了极小值,或者鞍点方向,
下面的方法就全是针对这两大方面对 SGD 进行改进。
2. Momentum
这种方法主要是为了解决第一种情况,当函数呈延伸状的情况下,梯度指向了谷底,而不是直接指向了最低点,函数值在学习过程中会来回震荡,但是向最低点移动的却很小。
上面的叙述中提到了两个方向,一个是纵向的震动,一个是横向的向最低点移动,如下图,如果我们可以避免或者减少震荡,加快横向的向最低点移动,那么就加快了学习。
实际上震荡是不可避免的,所以我们只能考虑减轻震荡。
我们还是先看一下数学描述:
Momentum 在 SGD 的基础上引入了一个变量 - 速度 和一个超参数 - 指数衰减平均 。
我们分别来看一下他们的含义。为了便于理解,我们先将 去掉,或者说是设为 1。首先将 初始化为零矩阵,然后进行第一次迭代, 保存的就是上一次的梯度,如果方向没改变,再一次迭代的时候梯度会被累加,加快学习。如果方向改变了(符号发生了变化),那么就减少了这次的学习,这样其实就实现了我们的目的:使得梯度方向不变的维度上速度变快,梯度方向有所改变的维度上的更新速度变慢,这样就可以加快收敛并减小震荡。
接下来我们再来看一下超参数 ,它是描述之前梯度对现在影响的参数。 越大表示之前梯度对现在的影响越大。 一般被设定为 0.9。
说了这么多理论,我们还是要实现一下这个优化器:
class Momentum:
def __init__(self, lr=0.01, alpha=0.9):
self.lr = lr
self.alpha = alpha
self.v = None
def update(self, params, grads):
if self.v is None:
self.v = {}
for key, val in params.items():
self.v[key] = np.zeros_like(val)
for key in params.keys():
self.v[key] = self.alpha*self.v[key] - self.lr*grads[key]
params[key] += self.v[key]
如果想使用 Momentum ,只需要将上面代码中的 optimizer = SGD(learning_rate)
修改为 optimizer = Momentum(learning_rate)
即可。
保持其他参数不变,训练的结果是:
SGD: 损失:0.5450568343946544 训练集准确度:0.8639 测试集准确度:0.8672
Momentum: 损失:0.1830601846034292 训练集准确度:0.94755 测试集准确度:0.9444
所以总结一句,优点:使得梯度方向不变的维度上速度变快,梯度方向有所改变的维度上的更新速度变慢,可以加快收敛并减小震荡。
但是也有缺点:这种方法相当于小球从山上滚下来时是在盲目地沿着坡滚,如果它能具备一些先知,例如快要上坡时,就知道需要减速了的话,适应性会更好。根据这个改进的优化器叫做:NAG(Nesterov Accelerated Gradient)
3. AdaGrad
这种方法主要是为了解决 SGD 遇到鞍点或者极小值点后学习变慢的问题。我们知道超参数学习率是一个很重要的参数,不同的参数对学习结果的影响很大,如果设置的值较小,会导致学习花费较多的时间,学习率大了就会导致学习发散而不能正常的进行。所以我们可以考虑避免人为的介入,根据需要让程序自己动态地设置学习率。例如对于遇到鞍点的情况,参数变化很小,基本不会改变,那么这个方法就会设置一个较大的学习率,跨过鞍点。
在神经网络中有一种方法经常被使用:学习率衰减方法(learning rate decay),也就是说随着学习的进行,使学习率逐渐减少。AdaGrade 进一步发展了这个想法,它会为参数的每一个元素设当的调整学习率。
我们还是看一下数学的描述:
这里新出现了一个变量 ,它保存了之前所有梯度的平方和,在更新参数的时候通过乘以 就可以调整学习的尺度。
我们还是尝试实现它:
class AdaGrad:
def __init__(self, lr=0.01):
self.lr = lr
self.h = None
def update(self, params, grads):
if self.h is None:
self.h = {}
for key, val in params.items():
self.h[key] = np.zeros_like(val)
for key in params.keys():
self.h[key] += grads[key] * grads[key]
params[key] -= self.lr * grads[key] / (np.sqrt(self.h[key]) + 1e-7)
结果为:
AdaGrad: 损失:0.20811779864341037 训练集准确度:0.9411 测试集准确度:0.936
AdaGrad 的优点是可以动态的调整学习率,
缺点是 AdaGrad 会记录过去所有的梯度平方和,最后有可能不再更新,
针对这个问题有一个方法叫做 RMSProp 进行了优化。
4. Adam
Adam 直观的来讲就是融合了 Momentum 和 AdaGrad 方法,详细可以参考原版论文
详细回头再更,,
class Adam:
def __init__(self, lr=0.001, beta1=0.9, beta2=0.999):
self.lr = lr
self.beta1 = beta1
self.beta2 = beta2
self.iter = 0
self.m = None
self.v = None
def update(self, params, grads):
if self.m is None:
self.m, self.v = {}, {}
for key, val in params.items():
self.m[key] = np.zeros_like(val)
self.v[key] = np.zeros_like(val)
self.iter += 1
lr_t = self.lr * np.sqrt(1.0 - self.beta2**self.iter) / (1.0 - self.beta1**self.iter)
for key in params.keys():
#self.m[key] = self.beta1*self.m[key] + (1-self.beta1)*grads[key]
#self.v[key] = self.beta2*self.v[key] + (1-self.beta2)*(grads[key]**2)
self.m[key] += (1 - self.beta1) * (grads[key] - self.m[key])
self.v[key] += (1 - self.beta2) * (grads[key]**2 - self.v[key])
params[key] -= lr_t * self.m[key] / (np.sqrt(self.v[key]) + 1e-7)
#unbias_m += (1 - self.beta1) * (grads[key] - self.m[key]) # correct bias
#unbisa_b += (1 - self.beta2) * (grads[key]*grads[key] - self.v[key]) # correct bias
#params[key] += self.lr * unbias_m / (np.sqrt(unbisa_b) + 1e-7)
最后看两个动态的优化过程,图片来自网络,侵删。
第一个是存在鞍点和局部极小值的情况。
第二个是损失函数呈延伸状的情况。
图片的绘制可以参考https://github.com/dream-catcher/learning_blogs