二、梯度下降算法优化初阶
- 归一化和学习率调度,是梯度下降算法优化的基本方法。
1. 数据归一化与梯度下降算法优化
- 数据准备
- 此处我们选取 Lesson 1 中的鲍鱼数据集,并且采用其中相关性比较强的几列进行建模分析。数据集读取过程如下:
aba_data = pd.read_csv("abalone.csv") aba_data
- 其中,我们选取鲍鱼数据集中的 Length(身体长度)和 Diameter(身体宽度/直径)作为特征,
Whole weight(体重)作为标签进行线性回归建模分析。
aba_value = aba_data.values aba_value #array([[ 1. , 0.455 , 0.365 , ..., 0.101 , 0.15 , 15. ], # [ 1. , 0.35 , 0.265 , ..., 0.0485, 0.07 , 7. ], # [-1. , 0.53 , 0.42 , ..., 0.1415, 0.21 , 9. ], # ..., # [ 1. , 0.6 , 0.475 , ..., 0.2875, 0.308 , 9. ], # [-1. , 0.625 , 0.485 , ..., 0.261 , 0.296 , 10. ], # [ 1. , 0.71 , 0.555 , ..., 0.3765, 0.495 , 12. ]]) features = aba_value[:, 1: 3] features #array([[0.455, 0.365], # [0.35 , 0.265], # [0.53 , 0.42 ], # ..., # [0.6 , 0.475], # [0.625, 0.485], # [0.71 , 0.555]]) labels = aba_value[:, 4:5] labels #array([[0.514 ], # [0.2255], # [0.677 ], # ..., # [1.176 ], # [1.0945], # [1.9485]])
- 然后分别准备一份原始数据与归一化后的数据。
features = np.concatenate((features, np.ones_like(labels)), axis=1) # 深拷贝features用于归一化 features_norm = np.copy(features) # 归一化处理 features_norm[:, :-1] = z_score(features_norm[:, :-1])
features #array([[0.455, 0.365, 1. ], # [0.35 , 0.265, 1. ], # [0.53 , 0.42 , 1. ], # ..., # [0.6 , 0.475, 1. ], # [0.625, 0.485, 1. ], # [0.71 , 0.555, 1. ]]) features_norm #array([[-0.57455813, -0.43214879, 1. ], # [-1.44898585, -1.439929 , 1. ], # [ 0.05003309, 0.12213032, 1. ], # ..., # [ 0.6329849 , 0.67640943, 1. ], # [ 0.84118198, 0.77718745, 1. ], # [ 1.54905203, 1.48263359, 1. ]]) features.shape #(4177, 3)
- 建模过程
- 首先是参数初始化与定义核心参数。
# 设置初始参数 np.random.seed(24) n = features.shape[1] w = np.random.randn(n, 1) w_norm = np.copy(w) # 记录迭代过程损失函数取值变化 Loss_l = [] Loss_norm_l = [] # 迭代次数/遍历数据集次数 epoch = 100 w #array([[ 1.32921217], # [-0.77003345], # [-0.31628036]])
- 接下来,首先进行梯度下降算法尝试。
for i in range(epoch): w = w_cal(features, w, labels, lr_gd, lr = 0.02, itera_times = 1) Loss_l.append(MSELoss(features, w, labels)) w_norm = w_cal(features_norm, w_norm, labels, lr_gd, lr = 0.02, itera_times = 1) Loss_norm_l.append(MSELoss(features_norm, w_norm, labels))
- 观察结果
- 我们可以通过损失函数变化曲线来观察梯度下降执行情况。
plt.plot(list(range(epoch)), np.array(Loss_l).flatten(), label='Loss_l') plt.plot(list(range(epoch)), np.array(Loss_norm_l).flatten(), label='Loss_norm_l') plt.xlabel('epochs') plt.ylabel('MSE') plt.legend(loc = 1)
- 经过归一化后的数据集,从损失函数变化图像上来看,收敛速度更快(损失函数下降速度更快),且最终收敛到一个更优的结果。
Loss_l[-1] #array([[0.12121076]]) Loss_norm_l[-1] #array([[0.05988496]]) w #array([[ 1.71641833], # [-0.46009876], # [ 0.13633803]]) w_norm #array([[ 1.22387436], # [-0.76710614], # [ 0.80942526]])
这里需要注意,由于我们没有对标签进行归优化,因此两个迭代过程可以直接进行绝对数值的大小关系比较。
对比全域最小值点
当然,由于上述损失函数是凸函数,因此我们可以用最小二乘法直接求解全域最优解。
这里需要注意,由于归一化只对数据进行平移和放缩而不改变数据分布规律,因此即使最小值点位置不同,但最终对应的对标签的预测结果应该保持一致,并且全域最小值点对应 MSE 数值也应该一致。
w1 = np.linalg.lstsq(features, labels, rcond=-1)[0] w1 #array([[ 1.87229017], # [ 2.33724788], # [-1.1056427 ]]) w2 = np.linalg.lstsq(features_norm, labels, rcond=-1)[0] w2 #array([[0.22482186], # [0.2319204 ], # [0.82874216]]) features.dot(w1) #array([[0.59934481], # [0.16902955], # [0.8683152 ], # ..., # [1.12792415], # [1.19810388], # [1.5208559 ]]) features_norm.dot(w2) #array([[0.59934481], # [0.16902955], # [0.8683152 ], # ..., # [1.12792415], # [1.19810388], # [1.5208559 ]]) MSELoss(features_norm, w2, labels) #array([[0.03318563]]) MSELoss(features, w1, labels) #array([[0.03318563]])
- 结论分析
- 通过上述计算结果,我们不难分析,其实在进行梯度下降计算过程中,在以 0.02 作为学习率进行迭代的过程中,两组模型都没有收敛到全域最小值点,也就是出现了类似如下情况:
plt.title('lr=0.001') show_trace(gd(lr=0.001))
但有趣的是,为何在相同学习率下,在归一化之后的数据集上进行梯度下降,却更加接近全域最小值点,这又是什么原因呢?
回顾此前我们所讨论的归一化对损失函数的影响,从等高线图上来看是等高线变得更加均匀,但实际上是整个损失函数在不同区域对应梯度都更加均匀,从而在靠近最小值点附近的梯度也比归一化之前的损失函数梯度要大,也就是说,虽然学习率相同,但由于归一化之后最小值点附近梯度要更大,
因此同样的迭代次,在归一化之后的损失函数上参数点将移动至更加靠近最小值地附近的点。也就类似如下情况:
def gd1(lr = 0.02, itera_times = 20, w = 10): """ 梯度下降计算函数 :param lr: 学习率 :param itera_times:迭代次数 :param w:参数初始取值 :return results:每一轮迭代的参数计算结果列表 """ results = [w] for i in range(itera_times): w -= lr * 28 * 2 * (w - 2) # gd函数系数是28 results.append(w) return results
def show_trace1(res): """ 梯度下降轨迹绘制函数 """ f_line = np.arange(-6, 10, 0.1) plt.plot(f_line, [28 * np.power(x-2, 2) for x in f_line]) plt.plot(res, [28 * np.power(x-2, 2) for x in res], '-o') plt.xlabel('x') plt.ylabel('Loss(x)')
plt.subplot(121) plt.title('28(x-2)**2') show_trace(gd(lr=0.001)) plt.subplot(122) plt.title('56(x-2)**2') show_trace1(gd1(lr=0.001))
- 学习率相同,迭代次数相同,但经过归一化的损失函数更加陡峭,靠近最小值点附近梯度更大,因此最终收敛到一个更加靠近最小值点附近的点。
- 而这个问题要如何解决,我们首先想到的是增加学习率,但问题是学习率增加多少才合适呢?试着增加 10 倍看下结果:
# 设置初始参数 np.random.seed(24) n = features.shape[1] w = np.random.randn(n, 1) w_norm = np.copy(w) # 记录迭代过程损失函数取值变化 Loss_l = [] Loss_norm_l = [] # 迭代次数/遍历数据集次数 epoch = 100
for i in range(epoch): w = w_cal(features, w, labels, lr_gd, lr = 0.2, itera_times = 1) Loss_l.append(MSELoss(features, w, labels)) w_norm = w_cal(features_norm, w_norm, labels, lr_gd, lr = 0.2, itera_times = 1) Loss_norm_l.append(MSELoss(features_norm, w_norm, labels)) plt.plot(list(range(epoch)), np.array(Loss_l).flatten(), label='Loss_l') plt.plot(list(range(epoch)), np.array(Loss_norm_l).flatten(), label='Loss_norm_l') plt.xlabel('epochs') plt.ylabel('MSE') plt.legend(loc = 1)
Loss_l[-1] #array([[0.06004797]]) Loss_norm_l[-1] #array([[0.04334334]])
我们发现,在提高学习率之后,梯度下降效果略有提升,能够收敛到一个更加趋近于全域最小值的点,并且归一化之后的损失函数收敛速度明显更快。
但以当前学习率,还是无法收敛止最小值点,此时我们可以不断尝试,直到“测出”最佳学习率为止。
当然,在 Scikit-Learn 中其实也提供了这种类似枚举去找出最佳超参数取值的方法,但如果是面对超大规模数据集的建模,受到计算资源的限制,我们其实是无法反复建模来找到最优学习率的,此时就需要采用一种更加先进的计算流程来解决这个问题。
伴随数据集复杂程度提升,寻找最小值点过程将越来越复杂。并且哪怕是凸函数,很多情况也无法用最小二乘法一步到位求出最优解,仍然需要依靠梯度下降来求解,此时能够使用梯度下降的优化算法来帮助进行顺利求解,就变得至关重要。
2. 学习率调度
- 基本概念
其实梯度下降优化的核心目标就是希望更快更好的找到最小值点,归一化是通过修改损失函数来达成这个目标,而所谓学习率调度,则是通过调整学习率来达到这个目标。
值得注意的是,此时找到一个确定的最优学习率并不是目标,更快更好找到最小值点才是目标,因此我们完全可以考虑在迭代过程动态调整学习率。而所谓学习率调度,也并不是一个寻找最佳学习率的方法,而是一种伴随迭代进行、不断调整学习率的策略。
学习率调度方法有很多种,目前流行的也达数十种之多,而其中一种最为通用的学习率调度方法是学习率衰减法,指的是在迭代开始时设置较大学习率,而伴随着迭代进行不断减小学习率。
通过这样的学习率设置,能够让梯度下降收敛速度更快、效果更好。
实践过程
例如在上述例子中,我们不妨设置这样的减速衰减的一个学习调度策略,衰减过程比例由如下函数计算得出:
lr_lambda = lambda epoch: 0.95 ** epoch lr_lambda(0) #1.0 lr_lambda(2) #0.9025 lr_l = [] for i in range(10): lr_l.append(lr_lambda(i)) lr_l #[1.0, # 0.95, # 0.9025, # 0.8573749999999999, # 0.8145062499999999, # 0.7737809374999998, # 0.7350918906249998, # 0.6983372960937497, # 0.6634204312890623, # 0.6302494097246091]
- 即假设初始学习率为 0.5,则第一次迭代时实际学习率为 0.5×1,第二轮迭代时学习率为 0.5×0.95,以此类推。
- 据此,我们可以优化梯度下降迭代过程,此时我们对比恒定学习率和学习率衰减的两个梯度下降过程,并且都采用归一化后的数据集进行计算:
# 设置初始参数 np.random.seed(24) n = features.shape[1] w = np.random.randn(n, 1) w_lr = np.copy(w) # 记录迭代过程损失函数取值变化 Loss_l = [] Loss_lr_l = [] # 迭代次数/遍历数据集次数 epoch = 20
for i in range(epoch): w = w_cal(features_norm, w, labels, lr_gd, lr = 0.2, itera_times = 10) Loss_l.append(MSELoss(features_norm, w, labels)) w_lr = w_cal(features_norm, w_lr, labels, lr_gd, lr = 0.5*lr_lambda(i), itera_times = 10) Loss_lr_l.append(MSELoss(features_norm, w_lr, labels)) plt.plot(list(range(epoch)), np.array(Loss_l).flatten(), label='Loss_l') plt.plot(list(range(epoch)), np.array(Loss_lr_l).flatten(), label='Loss_lr_l') plt.xlabel('epochs') plt.ylabel('MSE') plt.legend(loc = 1)
Loss_lr_l[-1] #array([[0.03416214]]) Loss_l[-1] #array([[0.03671235]])
这里有一点进行了微调,那就是我们实际上是令梯度下降计算过程中每迭代 10 次更新一次学习率,总共更新了 20 次学习率,即总共迭代了 200 次,最后10次更新时学习率为
0.5*lr_lambda(20) #0.17924296120427094
整体来看学习率变化区间横跨 0.18-0.5 之间,而最终上述学习率调度也确实起到了更好的效果。
算法评价
接下来,简单总结学习率调度的使用场景和注意事项。
首先,在很多海量数据处理场景下,学习率调度的重大价值在于能够提供对学习率超参数设置更大的容错空间。
在很多情况下,搜索出一个最佳学习率取值进而设置恒定学习率进行梯度下降,难度会远高于设置一组学习率衰减的参数。
有的时候,刚开始学习率设置过大其实也可以通过多轮迭代进行调整,其所消耗的算力也远低于反复训练模型寻找最佳恒定学习率。
其次,尽管上述例子我们是在梯度下降中使用学习率衰减这一调度策略,但实际上更为一般的情况是学习率调度和小批量梯度下降或者随机梯度下降来配合使用。
一般来说梯度下降的使用场景在于小规模数据集且损失函数较为简单的情况,此时可利用梯度下降 + 枚举找到最佳学习率的策略进行模型训练,其相关操作的技术门槛相对较低(枚举法可借助 Scikit-Learn 的网格搜索)。
对于更大规模的数据集且损失函数情况更加复杂时,则需要考虑小批量梯度下降+学习率调度方法来进行梯度下降求解损失函数。
3. 小批量梯度下降与迭代收敛速度
- 小批量梯度下降不仅可以帮助损失函数跨越局部最小值点,同时也能加快梯度下降的收敛速度。
- 例如以 0.02 作为学习率、batch_size 为 50 的情况下,测试梯度下降收敛过程:
# 设置初始参数 np.random.seed(24) n = features.shape[1] w = np.random.randn(n, 1) w_norm = np.copy(w) # 记录迭代过程损失函数取值变化 Loss_l = [] Loss_norm_l = [] # 迭代次数/遍历数据集次数 epoch = 50 np.random.seed(24) w = np.random.randn(3, 1) sgd_cal(Xtrain, w, ytrain, lr_gd, batch_size=1, epoch=3000, lr=0.02) #array([[ 0.76959334], # [-0.29175077], # [-0.17624047]]) # 执行迭代计算 for i in range(epoch): w = sgd_cal(features, w, labels, lr_gd, batch_size=50, epoch=1, lr=0.02) Loss_l.append(MSELoss(features, w, labels)) w_norm = sgd_cal(features_norm, w_norm, labels, lr_gd, batch_size=50, epoch=1, lr=0.02) Loss_norm_l.append(MSELoss(features_norm, w_norm, labels)) # 观察计算结果 plt.plot(list(range(epoch)), np.array(Loss_l).flatten(), label='Loss_l') plt.plot(list(range(epoch)), np.array(Loss_norm_l).flatten(), label='Loss_norm_l') plt.xlabel('epochs') plt.ylabel('MSE') plt.legend(loc = 1)
Loss_l[-1] #array([[0.03418411]])
4. 梯度下降组合优化策略
- 当然,无论是数据归一化、学习率调度还是采用小批量梯度下降,这些方法并不互斥,我们完全可以组合进行使用。
# 设置初始参数 np.random.seed(24) n = features.shape[1] w = np.random.randn(n, 1) w_opt = np.copy(w) # 记录迭代过程损失函数取值变化 Loss_l = [] Loss_opt_l = [] # 迭代次数/遍历数据集次数 epoch = 100 w #array([[ 1.32921217], # [-0.77003345], # [-0.31628036]]) # 执行迭代计算 for i in range(epoch): w = w_cal(features, w, labels, lr_gd, lr = 0.2, itera_times = 1) Loss_l.append(MSELoss(features, w, labels)) w_opt = sgd_cal(features_norm, w_opt, labels, lr_gd, batch_size=50, epoch=1, lr=0.5*lr_lambda(i)) Loss_opt_l.append(MSELoss(features_norm, w_opt, labels)) # 观察计算结果 plt.plot(list(range(epoch)), np.array(Loss_l).flatten(), label='Loss_l') plt.plot(list(range(epoch)), np.array(Loss_opt_l).flatten(), label='Loss_norm_l') plt.xlabel('epochs') plt.ylabel('MSE') plt.legend(loc = 1)
Loss_opt_l[-1] #array([[0.03318614]]) Loss_l[-1] #array([[0.06004797]])