学生による学生のためのデータサイエンス勉強会

【ゼロから作るディープラーニング#12】パラメータの更新【p165-177】

こんにちは、日沢です。
今回は「6.1パラメータの更新」についてまとめました。
パラメータの更新の手法を紹介していきます。

最適化について

これまでで、誤差逆伝播法を使ってニューラルネットワークを学習させるところまで勉強してきました。今回は、パラメータをうまく更新していく手法について見ていきます。
ニューラルネットワークは損失関数をできるだけ小さくするパラメータを見つけることが目的です。最適なパラメータを見つけることを最適化(optimization)といいます。

勾配降下法

今までやってきた最適化は、パラメータの勾配を使って勾配方向にパラメータを更新するという方法です。この手法を勾配降下法といいます。
勾配降下法にはいくつか種類があります。
最急降下法(Gradient Descent):学習データすべての誤差を計算した後にパラメータを更新する。
確率的勾配降下法(Stochastic Gradient Descent, SGD):ランダムに一つの学習データを選び、誤差を計算し、パラメータを更新する。
ミニバッチ確率的勾配降下法(Minibatch SGD, MSGD):ランダムにいくつかの学習データを選び、誤差を計算し、パラメータを更新する。

確率的勾配降下法(SGD)

これまでの学習は、無作為に選んだデータ(ミニバッチ)を用いて学習しているので、確率的勾配降下法(以下、SGD)で最適化しています。ここでSGDについて復習しておきましょう。

SGDは数式で書くと、次のように表せます。
$$ W = W – \eta\frac{\partial L}{\partial W} $$
$$W: 更新するパラメータ、\eta: 学習率、\frac{\partial L}{\partial W}: Wに関する損失関数の勾配$$
つまり、もとのパラメータを勾配方向にずらしたものを新しいパラメータとするのです。ここで、左辺の2項目がマイナスであるのは、勾配の正負とパラメータをずらす方向は逆になるためです。学習率はどのくらい大きく勾配方向にずらすかを決めるハイパーパラメータです。

SGDの実装

では、SGDをクラスとして実装してみましょう。

#SGDの実装
class SGD:

    def __init__(self, lr=0.01):
        self.lr = lr
        
    def update(self, params, grads):
        for key in params.keys():
            params[key] -= self.lr * grads[key] 

lrが学習率(学習係数:learning rate)、paramsがパラメータ、gradが勾配です。keyはどのパラメータかを表しています。一番下は先ほどの式と同じ計算を行っています。
かなり単純なつくりであることがわかると思います。

SGDの欠点

単純で実装も簡単ですが、問題によっては非効率な場合があります。
ここで、次の関数の最小値を求める問題を考えます。
$$f(x, y) = \frac{1}{20}x^2 + y^2$$
この関数をグラフにする図1のようになります。

図1 f(x,y)=(1/20)x^2+y^2
y方向に比べ、x方向の傾きが非常に小さいです。

この関数に対してSGDを適応させてみます。
(x, y)=(-7.0, 2.0)から探索を始めます。

図2 SGDの更新経路
緑、青、紫の線は先ほどの関数の等高線です。赤い点と線が最適化の経路です。

ジグザグしていて非効率です。
なぜこうなってしまうのでしょうか。

SGDは学習率×勾配をもとのパラメータに加えることでパラメータを更新しています。つまり、パラメータ毎の勾配の大きさに差があると、パラメータの更新速度に大きな差がついてしまいます。勾配が小さいパラメータに合わせた学習率にすると、勾配が大きいパラメータで図2のようにジグザグになってしまいます。逆に勾配が大きいパラメータに合わせるとx軸方向への進みが遅くなります(図3)。

図3 SGDの更新経路 学習率小さめ

以下ではSGDを改善した手法を紹介していきます。

Momentum

ジグザグで非効率的な更新経路を改善するために考えられたのが、次に説明するMomentum(モーメンタム)です。Momentumでは、もとのパラメータと新しく計算されたパラメータを足し合わせたパラメータが学習されます。

早速、式を見てみましょう。
$$v = \alpha v – \eta \frac{\partial L}{\partial W}$$
$$W = W + v$$
変数は基本的にSGDと同じですが、新しくvという変数が出てきました。これは、SGDにおける更新されたパラメータWにあたります。つまり、Momentumは、SGDで出したパラメータをさらに、もとのパラメータに足したものを学習しているのです。
αは勾配がなかった際に、徐々にパラメータを収束させるための変数です。0.9などの値を入れます。

Momentumとは

物理を勉強した人なら知っているかもしれませんが、Momentumは「運動量」のことです。「運動量=質量×速度」と定義されています。Momentumでは、新しい変数vを速度に対応させています。αvの項は慣性を表していると考えるとわかりやすいかもしれません(実際はαがあるので慣性ではないですが)。

では、実装してみましょう。

#Momentumの実装
class Momentum:

    """Momentum SGD"""

    def __init__(self, lr=0.01, momentum=0.9):
        self.lr = lr
        self.momentum = momentum
        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)   #各パラメータのvに0を入れて初期化
                
        for key in params.keys():
            self.v[key] = self.momentum*self.v[key] - self.lr*grads[key] 
            params[key] += self.v[key]

それでは、SGDと同様の問題を解いてもらいます。

図4 Momentumの更新経路

SGDと比べてジグザグが軽減されています。
x軸では、小さいけれど同じ方向の勾配が何度もvに加えられるため、だんだん加速していきます。一方、y軸では、正負が交互に出てくるため、一つ前のvに打ち消され、なかなか加速しません。

Nesterov(ネステロフ)

Momentumと似た手法で、Nesterov(ネステロフ)というものがあります。Nesterovの更新式はMomentumとほぼ変わりませんが、Nesterovではもとのパラメータが正しいかどうかを確認してからMomentumと同様の計算を行うようです。

AdaGrad

Momentumは損失関数上で今までの動きを考慮することでSGDを改良していました。AdaGradは学習率を調節することでSGDの効率を上げています。

AdaGradの式を見てみましょう。
$$h = h + (\frac{\partial L}{\partial W})^2$$
$$W = W – \eta \frac{1}{\sqrt{h}} \frac{\partial L}{\partial W}$$

ほとんどSGDと変数は同じで、新たにhという変数が出てきました。これが学習率に影響します。hは勾配の2乗を足すことで更新します。ここで注意するのは、2乗は行列の要素ごとで行うという点です。この2乗は、単に正負を無くすためのものなので、気を付けてください。
下の式では、hの平方根の逆数が学習率となっています。それ以外は、SGDと変わりません。

では、実装してみましょう。

#AdaGradの実装
class AdaGrad:

    """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)   #各パラメータのhを初期化
            
        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)

複雑な点はないですが、ポイントとして学習率に1e-7という小さな値が加えられています。これは、hが0であった場合に0で除算することを防ぐためのものです。

では、上の2つの手法同様、問題を解いてもらいます。

図5 AdaGradの更新経路

はじめは勾配に合わせて大きく動いています。学習が進むにつれ、hにどんどん加算されていくので、学習率は小さくなっていきます。また、勾配の小さいx軸のほうが学習率が大きいことが、はじめの方からわかると思います。

RMSProp

AdaGradは学習を進めるほど学習率が小さくなるので、無限に学習した場合、全く更新されなくなります。これを改善したのがRMSPropです。だんだん過去の勾配を忘れ、新しい勾配が大きく反映されるようになっているようです。

Adam

Momentumでは今までの動きを反映させることで効率を上げました。AdaGradは学習率を変化させることで効率を上げました。Adamはこの二つを組み合わせた手法で、ハイパーパラメータのバイアス補正も組み込まれています。

式にすれば、以下のようになります。
$$m_t = \beta_1 m_{t-1} + (1 – \beta_1) g_t .$$
$$v_t = \beta_2 v_{t-1} + (1 – \beta_2) g_t^2 .$$
$$\hat{m}_t = \dfrac{m_t}{1 – \beta^t_1} .$$
$$\hat{v}_t = \dfrac{v_t}{1 – \beta^t_2} .$$
$$\theta_{t+1} = \theta_{t} – \dfrac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t.$$

では、実装してみます。

class Adam:

    """Adam (http://arxiv.org/abs/1412.6980v8)"""

    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)

上の3手法と同様に、問題を解いてもらいます。

図6 Adamの更新経路

ちょうど、MomentumとAdaGradの中間のような結果になりました。

どの手法を用いるか?

これまで4つの手法を紹介してきました。
SGDから改善されていったと説明しましたが、SGDが優れていないわけではありません。問題によって、SGDがいいとか、AdaGradがいいとかがあります。

例えば、『ゼロから作るディープラーニング』ではMNISTのデータを使って比較しています。図7は損失関数の値をグラフにしています。

図7 MNISTデータでの比較

ここではAdaGradが一番良かったようです。二番はAdamのように見えます。

せっかくなので、他のデータではどうなるかを比較してみました。
まずは、SGDが得意そうなf(x, y) = x^2 /4 +y^2 /4です。

ここではAdaGradが一番よさそうです。二番がSDGですね。
おそらくMomentumがこの問題を苦手としているようなので、Adamはそれにつられていますね。

次は、f(x, y) = xsinx + ysinyです。これには特に理由はないです。

ここでもAdaGradが一番いいですね。(どこが最小値なのかはわかりませんが。)二番目はAdamです。SGDはx軸方向に揺れてるので、もう少し学習率を小さくすれば、効率は悪いかもしれませんが、上の等高線の真ん中にたどり着ける気がします。

あまりいい関数を知らないので、この程度しかできませんでしたが、もっと面白い形の関数でやってみたいです。
私調べでは、今のところAdaGradが一番いいですね。今度はAdaGradが苦手な関数を見つけてきたいです。

『ゼロから作るディープラーニング』では主にSGDやAdamを使うようですが、皆さんも好きな手法で試してみてほしいと思います。

また、Lasagneというディープラーニングのフレームワークに最適化手法がまとめて関数として実装されています。下記のURLから見れます。
https://github.com/Lasagne/Lasagne


読んでいただきありがとうございました。

参考
・【2020決定版 】スーパーわかりやすい最適化アルゴリズム -損失関数からAdamとニュートン法- – Qiita https://qiita.com/omiita/items/1735c1d048fe5f611f80
・Python/matplotlib3Dプロット!面と散布図を作成 | WATLAB -Python, 信号処理, AI- https://watlab-blog.com/2019/12/01/matplotlib-3dplot/
・勾配降下法の最適化アルゴリズムを概観する | POSTD https://postd.cc/optimizing-gradient-descent/

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です