Gradient Descent、Momentum、Nesterov的實現及直覺對比
GradientDescent、Momentum(動量)、Nesterov(牛頓動量)的直覺含義對比:
Gradient Descent
def gd(x_start, step, g):#gradient descent x = np.array(x_start, dtype='float64') # print(x) passing_dot = [x.copy()]#training record for i in range(50): grad = g(x) x -= grad * step passing_dot.append(x.copy()) print('[ epoch {0} ] grad={1}, x={2}'.format(i, grad, x)) if abs(sum(grad)) < 1e-6:#early stop break return x, passing_dot
就是有一步走一步,走到哪算哪,比如本例走個之字(zigzag),初期縱向步子大,上下來回繞(如果學習率再大點就不收斂了),後期縱向收斂。但是橫向步子小(因為橫向縱向梯度不一樣,縱向梯度大,橫向梯度小),最後沒有什麼更新動力,最終在50步內沒有到達中心點。
Momentum
def momentum(x_start, step, g, discount = 0.7): x = np.array(x_start, dtype='float64') passing_dot = [x.copy()] pre_grad = np.zeros_like(x) for i in range(50): grad = g(x) pre_grad = pre_grad * discount + grad x -= pre_grad * step passing_dot.append(x.copy()) print('[ Epoch {0} ] grad = {1}, x = {2}'.format(i,grad,x)) if abs(sum(grad)) < 1e-6: break return x, passing_dot
Momentum會保留之前步子的趨勢(動量),相比Gradient Descent走過頭直接就往回返,Momentum返回時也會拉你一把,讓你不那麼容易回去。這樣看好像不如不加這個動量,往回的速度慢了。但是累加起來以後會越來越穩,最後直接但是這個把你往外跳的趨勢也給拉沒了。所以,其實動量演算法的抗噪聲能力很強。
剛才的例子不明顯,下邊增加一下學習率:同樣學習率下,Gradient Descent可能不收斂,而Momentum還能收斂,並且需要很少的步子就能辦到。而在橫軸方向,Momentum也因為動量累積效應,很容易達到了中心點。這是同樣條件下Gradient Descent所沒有辦到的。
動量衰減的大小意味著之前的趨勢是否難以撼動。如果動量衰減很小,也就是discount數值很大,也是不容易收斂的,但是隨著步數積累,動量衰減的冪次也增多,還是有收斂的趨勢的。
本例比較簡單,條件不極端,極端情況下,同方向累積步數過多,如果動量衰減程度低,反而要比Gradient Descent波動還大,所以超引數discount的選擇也很重要。
左圖,小學習率同方向積累多步情況下,過大discount導致不易收斂;右圖,同學習率下,普通Gradient Descent縱軸早已收斂(因為橫縱比例問題,橫向停留,前邊提過)。
可變的discount也可考慮,初期需要的discount小,防止加速逃逸,後期需要的discount大,穩定步伐、加速收斂。
順便在同學習率下,劇透一個Nesterov效果:
Nesterov
def nesterov(x_start, step, g, discount = 0.7):
x = np.array(x_start, dtype='float64')
passing_dot = [x.copy()]
pre_grad = np.zeros_like(x)
for i in range(50):
x_future = x - step * discount * pre_grad
grad = g(x_future)
pre_grad = pre_grad * discount + grad
x -= pre_grad * step
passing_dot.append(x.copy())
print('[ Epoch {0} ] grad = {1}, x = {2}'.format(i,grad,x))
if abs(sum(grad)) < 1e-6:
break
return x, passing_dot
Nesterov是Momentum的變種,或者叫Nesterov動量,是受Nesterov演算法啟發改進的Momentum演算法。它是先走到你下一步將要到的那個點,然後把那個“未來的點”的梯度計算出來(取代當前點的地位),直接更新動量和x。
這個特性就非常有意思了,進行一步之後,如果“第三步”和運動方向不一致,如本例,就會在第二步就提前產生反向的糾正,把x拉回去;如果第三步和運動方向一致,也不會產生放大效應,不會有double位移,因為跳過了第二步,只算“第三步”或者叫“新二步”吧。那麼再迭代一次,頂多也就是用新分支下的“新四步”來代替“新三步”成為“新新三步”(因為還是要產生新分支),然後是“新新新四步”,以此類推。
好處:學習率合適,如果下一步趨勢不變,可以看做等價替換;如果下一步有加速、減速或者掉頭的趨勢,又能提前實現。
左圖Nesterov、右圖Momentum
右圖向量2是按Momentum本該有的行進路線,3是2結束後的下一步行進路線。1結束後就有了向下的動量,2帶著1的向下的趨勢多走了一段,“拉不回來”可以算是Momentum的一個劣勢。但是Nesterov就不同了,它是“預判加截停”,知道你要去哪個方向,直接繞你前邊往回打一巴掌。也就是從向量2的終點去找向量3,近似的看作向量3平移(不是2+3)成了向量4,也就是左圖的向量2。這個演算法是對Momentum的進一步優化,算是一個修正。本例,直覺地說,前期走過站的操作更容易拉回來了。
下邊是Nesterov的第二種寫法,這種寫法更像《深度學習》演算法8.3,而且看著更簡潔。
不過兩種寫法最終效果一樣,區別只是pre_grad(動量v)是先乘過step還是在更新x時再乘step。
def nesterov2(x_start, step, g, discount = 0.7):
x = np.array(x_start, dtype='float64')
passing_dot = [x.copy()]
pre_grad = np.zeros_like(x)
for i in range(50):
x_future = x - discount * pre_grad
grad = g(x_future)
pre_grad = pre_grad * discount + grad * step
x -= pre_grad
passing_dot.append(x.copy())
print('[ Epoch {0} ] grad = {1}, x = {2}'.format(i,grad,x))
if abs(sum(grad)) < 1e-6:
break
return x, passing_dot
# res, x_arr = nesterov([150,75], 0.012, g)
res, x_arr = nesterov2([150,75], 0.0034, g)
contour(X,Y,Z,x_arr)