初等解析学 (微分積分学) 入門 番外編 I

2019/1/12
@tk

連鎖律と誤差逆伝播法: 1 次元の場合

こちらの投稿で紹介した連鎖律 (合成関数の微分公式) が、(階層型) ニューラルネットワークにおける自動微分法の一種である誤差逆伝播法 (backpropagation) においてどのように活用されているのか、その雰囲気を味わうために、ここで「一次元のとても単純なニューラルネットワーク (パーセプトロン) による教師あり学習」を取り上げて見ていきます。なお、以下で登場する 等の関数は全て 上で微分可能であると仮定しておきます。

 

概要

入力データ とそれに対応する「正解」を意味する教師データ (ラベルデータ) が与えられたとします。例えば、手書き数字画像の学習用のデータセットとして良く知られている MNIST では、 の中のいずれかの数字を描いた手書きの画像に、 がその正解となる数字に対応しています1

ニューラルネットワークのモデルは、入力データ が与えられるとその予測値 を返す何らかの関数として与えられます。もしニューラルネットワークの精度が「完璧」であるならば、どんな が与えられても常に が成立している (つまり、入力データ に対する予測値 が常に正解 と等しい) 事になります。ニューラルネットワークにおける (教師あり) 学習とは、与えられたデータセット に対して「 の誤差が出来るだけ小さくなるように」 の関数形を調整 (最適化) していく事を意味します2

ここでは、 の形状を特徴付けるパラメーターが一つしかないような最も単純な一次元のパーセプトロンのモデルを考えてみます3。まず入力データ が入力層に与えられると、モデルは内部パラメーター を使って と変換します。通常、 は線形変換 (一次関数) として与えられ、生物の神経細胞の如く数多くの細胞の複雑な結び付きを表現し、その重み付けのために大量のモデルパラメーターを持つのですが、今は「パラメーターは 一つのみ」という単純な状況しか考えません (よって、非常に簡単な問題にしか適用出来ません)。

次に、モデルは活性化関数と呼ばれる非線形関数 を通して と変換します。ここでも触れたように、 やこれをスケール変換したシグモイド関数は活性化関数の代表的な例となっています。

に対する予測値であり、これと が一致していれば「予測が正しい」と言えますが、勿論予測が外れる事もありあす。そこで、「予測値の正解に対する誤差 (損失)」を評価するために損失関数 を用いて「 に対する誤差 (損失)」を と評価します。ここで は「 が近い値ならば小さい値となる」という性質を持っており、例えば の二乗誤差 が代表例です4

以上をまとめると、各 に対して、入力データ と教師データ が与えられた時にモデルが算出する損失は という手順で計算され、全データに対する損失の平均値は で与えられます。ところで、上で登場する関数 は二変数関数となっていますが、我々は (本編の §13 の段階で) まだ一変数の関数しかきちんと扱っていない…という事で、「 は変数ではなくあくまで所与のデータである」と考えて と表す事にします。すると上の手順は と、一変数関数の合成によって表す事が出来て、全体の損失も と表せます。 を計算する手順を図示すると以下のようになります。

 

最適化・学習

さて、 という値は「各 における教師データとモデルの予測値の誤差 (損失) を集めたもの」であり、基本的にはこれの値が小さい程、モデルの精度が高くなると考えられます。よって、モデルの精度を上げるためには を最小化するように を調整すれば良さそうです。詳しくは本編 §15 で扱う予定ですが、「 を最小にするような 」を計算するために、適当な初期値 から始めて、次の更新式 で定義される数列 の極限を取る、という方法が知られています ( は所与の定数)。実際、ある程度の数学的な仮定の下で、 が存在して となる事が証明出来ます (§15, 16 で紹介する予定です)。つまり、モデルパラメーターを に設定すれば精度の高いモデルを構築出来そうです (実際には十分大きな に対する によって代用する事になります)。

(3) は の微分係数 を含んでいるので、 を更新する度に の微分を計算しなければなりません。そこで、まず (2) から具体的に を計算してみます。連鎖律 (ここの定理 1) より となりますが、更に (1) の記号を使うと と表されます。すると、 が与えられた時に を計算するためには以下の順番で計算すれば良さそうです。

1. (1) を使って、 から以下を順番に計算する (順伝播)。 2. 以下を順番に計算する (逆伝播)。 3. 以下で を計算し、(3) によって に更新する。 つまり、 が得られた後、まずは「入力データ ⇒ 損失関数」の方向 (順方向) で 等の値を計算しておき、今度は「損失関数 ⇒ 入力データ」の方向 (逆方向) で「 を計算 ⇒ 計算結果を 倍 ⇒ 計算結果を 倍」と順々に計算していけば、効率良く を計算出来る、という仕組みです。これが誤差逆伝播法の大まかな計算の流れとなります。順方向の計算は既に図示されていますが、逆方向の計算は以下のように図示出来ます。

より一般に、 として における微分を考えてみると、まず と計算し (順伝播)、次に連鎖律を使って を計算するのですが (便宜上 としました)、上式右辺は「 に対して後ろ向きに を順番に掛け合わしてやる (逆伝播)」事で計算されています。

今は一変数の連鎖律しか使っていないので、そこまで大きな恩恵を感じられないかもしれませんが、実は多変数関数の偏微分においても同様の連鎖律が成立し、上記の「順伝播 ⇒ 逆伝播」の手順によって、パラメーターがたくさん存在する場合にも「各パラメーターに関する偏微分係数」を計算出来ます。そして、やはり (3) のような更新式によってパラメーターを更新 (学習) する事が出来ます (但し、モデルに含まれるパラメーターの総数を とすると、 に該当するもの (勾配 (gradient)) も共に 次元ベクトルとなります)。

複数の層からなる大量のパラメーターを持つ複雑なニューラルネットワークモデルに対して、各パラメーターに関する の (偏) 微分を一つ一つ書き下すのは大変ですが、しかしニューラルネットワークモデルの各パーツそれぞれは線形関数や , シグモイド関数等のように「簡単に微分を書き下せる比較的単純な関数」になっているので、パーツ部分の導関数さえ与えておけば、後は順伝播・逆伝播の手順によってシステマティックに微分の計算を行う事が出来ます。

 

具体例: 線形二値分類

折角ですので、今回扱った最も単純なニューラルネットワークを使って具体的な問題を解いてみましょう。但し以下はあくまでデモンストレーションのための実装であり、より効率良く問題を解くための方法は他にいくらでも存在する事を先に注意しておきます。

-平面5にランダムに与えられたデータ に対して、ラベルデータ によって与えてやります6。言い換えると、 が直線 よりも上にあるならば , そうでなければ という事です。 は未知パラメーターであり、機械学習によって の近似値を導くのがここでの目的です7

さて、(1) で与えられる一次元のニューラルネットワークモデルにおいて、具体的に以下のように関数形を定めてやります。 各関数の導関数を計算すると となるので、(5) で定義される損失の微分は となります。ここで は (1) で与えられるものですが、 の微分においてちょうど という関係が成り立っているので最下段の式のように計算する事が出来ます8

具体的に、Python を用いて計算してみましょう9。今回の機械学習モデルを Simple_NN というクラスで実装してみます。まずコンストラクタを定義します。ここで引数 theta は学習前のパラメーターの初期値 を表しており、(真の値 が分からないので) 呼び出し時に適当に与えます (numpy.randomSimple_NN の中では使いませんが、後の準備のために読み込んでおきます)。

import numpy as np
import numpy.random as nr

class Simple_NN():
    def __init__(self, theta, learning_rate):
        self.theta = theta
        self.learning_rate = learning_rate
        self.x = None
        self.y = None
        self.z = None
        self.t = None
        self.loss = None
        self.grad = None

ここで theta はこれから学習していく の初期値 を表しています。learning_rate は学習率パラメーター ((3) の ) であり、呼び出し時に適当な小さな値を設定します10self.x, self.t はそれぞれ入力データ , 教師データ に対応しており、また self.y, self.z はそれぞれ (パラメーター self.theta の時の) , を表しています。self.loss, self.grad にそれぞれ対応していますが、いずれの変数も今後の学習の過程で与えられるものであり、今は単に入れ物を用意しているだけです。

の更新手順 1.~3. に相当するものは、Simple_NN クラスのメソッドとしてそれぞれ以下のように与えられます (正確に言うと、(5) の の計算は以下のコードでは「2. 逆伝播」の中で行われています)。

  1. 順伝播
    def forward(self, x, t):
        self.x = x
        self.t = t
        self.y = self.theta * self.x[:, 0] + self.x[:, 1]
        self.z = np.tanh(self.y)
        self.loss = np.average((self.z - self.t) ** 2 / 2.0)
  1. 逆伝播
    def backward(self):
        grad  = self.z - self.t
        grad *= 1.0 - self.z ** 2
        grad *= self.x[:, 0]
        self.grad = np.average(grad)
  1. 更新
    def update(self):
        self.theta -= self.learning_rate * self.grad

なお、self.x 等の変数には NumPy の配列変数として 等がそのまま格納されますが、forwardbackward 等の実装においては NumPy のブロードキャスト機能が適用されるので、コード上では各変数が配列である事をあまり意識する事無く、各 毎の計算をしているかの如く実装する事が出来ます。

さて、forward メソッドを計算すれば損失 の値 self.loss が計算され、これが学習の進行状況を示す一つの指標となっていますが、それとは別の精度指標として更に以下を定義します。 ここで、 は符号関数 (sign function) と呼ばれるものであり、 の符号に応じて のどれかの値を返します (NumPy では sign として実装されています)。 とは「モデルパラメーターが の時の、入力 に対する出力値 」を表していますが、これが正の値ならば (あるいは負の値ならば) モデルは「 のラベルデータは だろう (あるいは だろう)」と予測していると考えられます。すると、 の部分は「モデルの予測値が正解である と一致しているかどうか」を表す事となり、一致していれば , していなければ の値を取る事になります。つまり、 とは「モデルパラメーターが の時の、モデル予測値の正解率 (正解数データ総数)」を表しています。Simple_NN のメソッドとしては以下で定義されます (実行の前に forward を呼び出してパラメーターを更新しておく必要があります)。

    def accuracy(self):
        num = 0
        for i in range(self.x.shape[0]):
            if np.sign(self.z[i]) == self.t[i]:
                num += 1
        return num / self.x.shape[0]

さて、学習に用いるデータとして に値を取る二次元の一様乱数の集合 を準備し、各 に対して (6) によって教師データ (ラベルデータ) を与えます。ここで theta0 は真のパラメーター に対応しており、やはり事前に値を設定しておきます。12345 は乱数のシードであり、これも事前に与えてやる事で、今後同じプログラムを実行する際に乱数発生パターンを揃える事が出来ます (逆に、シードの値を変えれば乱数のパターンが変わります)。

data_size = 1000
theta0 = 0.5

nr.seed(12345)

input_data = nr.uniform(-1.0, 1.0, data_size * 2).reshape(data_size, 2)

label_data = np.ones((data_size, ))
for i, x in enumerate(input_data):
    if theta0 * x[0] + x[1] <= 0.0:
        label_data[i] = -1.0

図示すると以下のようになります。青が のデータ、緑が の時のデータであり、これらを分類している境目となる赤線は という集合で与えられます (ここでは )。

いよいよ Simple_NN を使って学習 ( の更新) をしていきます。とりあえず初期値として とし、学習率も適当に と設定しました。まず学習を何もしない状態で損失 と精度 (正解率) を計算してみます。

model = Simple_NN(5.0, 0.05)
model.forward(input_data, label_data)
print(model.loss, model.accuracy())

出力結果は以下のようになりました。

0.5767329577766804 0.665

精度が という事は、1000 個のデータのうち 個だけが正しく分類出来、それ以外の 個は間違えた、という事です。

次に繰り返し の更新をしていきますが、繰り返しの終了条件として、ここでは「 の誤差が 以下になったら終了」とします。最初に model を与えた時に と設定したので、model.theta には既に という値が入っています。「一つ前の の値」のために変数 theta_b を用意して、最初にダミーの値を入れておきます。

theta_b = 0.0
while np.abs(model.theta - theta_b) > 0.1 ** 8:
    theta_b = model.theta
    model.forward(input_data, label_data)
    model.backward()
    model.update()

学習が終わった後にもう一度

model.forward(input_data, label_data)
print(model.loss, model.accuracy())

及び

print(model.theta)

を実行してみると以下のようになりました。

0.1833357783694903 0.977
0.5771048692410227

正解率は まで上昇し、損失も最初の 割程度まで低下しましたが、学習によって得られたパラメーターの値は であり、真の値である とは若干乖離してしまっています。図示すると以下の通りで、赤線とのずれがやや目立ちます。

ここで、右図の Prediction は、各 に対して計算されるモデルの出力値 の符号 を取ったものであり、値が であれば青に、 であれば緑に塗り分けたものです。なお符号関数 を取らずに -値のままで予測値の可視化をすると下図のようになります。

右図の白色の部分は出力値 に近い領域となっているのですが、真の境界を与える赤線はこの白い領域に概ね含まれているように見えます。真値とのずれが生じた原因はこのあたりにありそうです。

ここで、 を動かした時の (loss) と (acc) の変動の様子を見てみます。

この図を見ると、 の時に acc が最大 となっているのに対して、loss は から少しずれた位置 で最小値を取っています。つまり、Simple_NN における学習は誤差逆伝播法によって確かに損失関数を最小化出来ていたのですが、「損失が最小となる 」が必ずしも「分類精度が最高となる 」と一致していなかった事が見て取れます。そしてそのずれが生じた要因として、「活性化関数の原点近傍での挙動」と「損失関数の性質」があるようです。

モデル予測の正解率である の計算の上では、予測値が正か負かのみが重要であり、予測値が であっても であっても の計算上では影響がありません。しかし の値を計算する上では、たとえ予測の結果が当たっていた (教師データ でありモデル出力値も正だった) としても、予測値が に近いか に近いかによって に与える影響にも違いが生じます。例えば に対してモデル予測値が であったならば二乗誤差は と大きいですが、予測値が の時には にしかなりません。

同様に、予測値が に近い値であった場合、 ではその値が正か負のどちらであるかが大きな意味を持つのに対して、 に対しては影響度が小さくなります。例えば予測値が であった場合、 の時の二乗誤差は であるのに対して の時の二乗誤差は であり、正解でも不正解でもあまり大きな違いはありません。そのため、今の損失関数の設計では「二つのグループの境界線」近辺の影響が相対的に小さく見積もられる事となります。これらの背景から「二乗誤差を最小化する 」「正確に二値分類が出来る (近辺)11」といった事態が生じたわけです。

もし活性化関数が でなくて「 なら常に であり なら常に となる」ような関数であったならば、 の性質の違いを気にする必要はなくなります。実際、(脚注 3 にもあるように) 最初期の単純パーセプトロンではそのような設計 (活性化関数が階段関数) になっており、これを使って (線形に分離可能なデータ群に対してならば) 有限回の学習ステップによって正確な分類を与えられる事が示されています。その学習方法は (3) の勾配法に良く似ているのですが、しかしこの関数は原点で微分不可能である上に原点以外での微分係数が常に となってしまうので、勾配法そのものを適用出来るわけではありません。

Simple_NN の改善方法として、「活性化関数にメリハリを利かせる (原点から離れたらすぐに に近付くようにする)」「損失関数にメリハリを利かせる」等が考えられます。

前者の例として、例えば活性化関数を として を適当に大きな値としておく事が考えられます。下図は 及び の時の関数の形状を比較したものです。

Simple_NN において活性化関数を に置き換えてみると、学習後のパラメーターの値は以下のように真値に近付きました。

print(model.theta)

<出力結果>

0.497510384550426

後者の例として、損失関数を二乗誤差ではなく 乗誤差 (但し ) に置き換えるという方法が考えられます。 二乗の場合と同様、 で割る事には本質的な意味はありません。例えば とした場合の結果は以下のようになりました。

print(model.theta)

<出力結果>

0.5000097150577828

また今の問題は線形分離問題であるので、実は単に と活性化関数を置き換えるだけでも十分良い結果が得られますし、その他、機械学習の応用において幅広く用いられているランプ関数 (ReLU 関数) を用いる事も考えられます。この は厳密には原点で微分可能ではありませんが、原点以外では常に微分可能であり となります。それ以外にも、サポートベクターマシン (support vector machine; SVM) を使う12、グリッドサーチで直接 の最大値を探索する13…等、様々な方法が考えられます。

もっと根本的な事を言ってしまえば、脚注 7 に従うと「 となる の範囲」を簡単に求める事が出来てしまいます。次のコードを実行してみると…14

plist = []
nlist = []
for i, x in enumerate(input_data):
    ratio = -x[1]/x[0]
    if (label_data[i] == 1.0) ^ (x[0] < 0.0):
        plist.append(ratio)
    else:
        nlist.append(ratio)
print('({}, {})'.format(np.max(plist), np.min(nlist)))

結果は

(0.4913004302307686, 0.5013806368656244)

となりました。

というわけで、そもそも Simple_NN を使う事自体が本問題を解く上で必ずしも効率的というわけではないのかもしれませんが、「ニューラルネットワークを使った分類問題の解法」として最も単純な場合における最小限のコーディングをしてみた事で、機械学習の雰囲気を少しでも味わっていただけたなら何よりです。

 

まとめ

ニューラルネットワークにおける学習の要となる誤差逆伝播法について、その本質的な部分を出来る限り簡単なモデルで観測すべく、一次元のモデルを通してその仕組みを解説しました。一般 (多次元) の場合には損失関数の微分 (勾配) を数式で表そうとすると和の記号や添字が多くなり煩雑になってしまう場合が多いのですが、一次元の場合にはそれ程数式が煩雑になる事も無く、システマティックな計算手順で微分の計算が可能である様を見て取る事が出来るかと思います。実践的にはあまり意味の無い具体例となってしまいましたが、Simple_NN の実装例を見ると、「順伝播で を計算」「逆伝播で ( を使って) を計算」「 を更新」という流れの合理性も読み取る事が出来ます。

多次元 (・多層) ニューラルネットワークを用いれば、線形分類に限らず非線形な分類問題を解く事も可能であり、その場合も (「多変数関数の偏微分」についてもう少し考えなければならない事が増えますが概ね) 今回作った Simple_NN のような考え方に基づいて実装する事が可能です。そしてこれを「 次元の実ベクトルデータを 個のグループに分類する」という場合にまで拡張すれば、MNIST のデータをニューラルネットワークで学習する事も出来るようになります。画像認識において有用性が高いと言われている畳み込みニューラルネットワーク (convolutional neural network; CNN) は更に中身が複雑となりますが、それでも「順伝播 ⇒ 連鎖律を使って逆伝播 ⇒ 更新 (学習)」の手順は同じです。全体としては複雑でも、ネットワークを構築している各パーツにおいてはそうではなく、パーツ毎に切り分けて「順伝播」「逆伝播」それぞれの計算をすれば良いので、パーツ毎のアルゴリズムさえ与えてやれば、後はそれを並べる事によって大規模なネットワークの構築が出来てしまいます15。尤も、複雑なネットワークにおいては学習に時間がかかる上に現実的な課題も多いため、効率的な学習のためのテクニックも更に必要となるのですが。

もう少し一般的なニューラルネットワークに関しても、今回の計算を足がかりにしてまた別の機会に扱ってみたいと思います。


  1. 教師データ (正解ラベル) に値を取るので分かりやすいですが、入力データ は画像のままでは扱いにくいので、まずこれを有限次元ベクトルに変換する必要があります。詳細は説明しませんが、MNIST では画像サイズが縦横共に ピクセルであるため、「各ピクセルの色の濃さ」を実数化してやると 次元の実ベクトルとして特徴付ける事が出来ます (正確に言うと、MNIST データは画像形式ではなく最初からこの形式で作成されています)。なお、 についても -値とするのではなく -値のベクトル (one-hot 形式) に変換する事もあります。
  2. 尤も、 を既知とするのならば としてしまえば「所与のデータセットに対しては完璧な 」を作る事が出来てしまいますが、これでは「所与のデータセットに含まれていないデータ」に対しては何ら予測する事が出来ません。重要なのは「所与のデータセットに含まれていない が与えられたとしてもいつも となっている」という汎用性 (汎化性) であり、そのためにデータセットを「学習用」と「検証用」に分けて「学習用データだけを使って学習させ、精度検証は (学習に用いていない) 検証用データに対して行う」とするのが一般的です。とは言え今回はモデルの簡略化の都合上、データの分割は行わず全てを学習に用います。
  3. 所謂単純パーセプトロンと呼ばれるもの (の次元を に落として更に単純化したもの) に近いですが、ここでは更に微分可能な活性化関数を与えています (最初期の単純パーセプトロンモデルでは、本稿における活性化関数 を Heaviside 関数に置き換えたものに相当します)。
  4. で割ったのは慣習によるものであり、本質的な意味はありません。
  5. 入力データまで一次元にすると本当に当たり前の事しか出来なくなってしまうので (見かけ) 二次元データとさせていただきました。また記号の都合上、 ではなく上付きの添字 によって各座標成分を表す事にします。
  6. 二値分類のための教師データは -値とする事も多いのですが、今回は折角本編 §13 で扱った関数 を使いたかったので、 の関数の形状に合わせて -値としました。
  7. 繰り返しながら、わざわざニューラルネットワークの手法を持ち出さなくても今の場合はより簡単に解けますが、あくまで誤差逆伝播法のデモンストレーションという事で…そもそも今扱っているのは実質的には一次元の問題であり、( の時に) である事に注意すれば、単に「入力データを変数変換して閾値を探すだけ」の問題になってしまいます。しかしそれではあまりにシンプル過ぎて逆に分かりにくくなってしまうかと思い、見かけだけ二次元にしています。
  8. 記号が分かり辛くて恐縮ですが、上式において は「 乗」であるのに対して はそれぞれ「 次元ベクトル の第 成分」であり、 は「 乗」という意味ではありません。
  9. ここではバージョン 3.6 のものを使っています。
  10. 今回は最もシンプルな勾配降下法 (最急降下法) のみを扱う事とし、学習率を動的に変化させるような工夫は施しません。またミニバッチ学習も扱いません。
  11. 「近辺」と書いたのは、何も「全てのデータを正しく分類出来る 」は 一つのみに限らないためです。本来、有限個のデータからだけでは一般に二つのグループを分類する直線 (今は分離直線と呼ぶ事にしますが、一般には分離平面や分離超平面と呼ばれます) を唯一に絞り込めないので「所与のデータから を正確に推計する」事も不可能なのですが、今回は「データ数 が十分多く、分離直線はほとんど一つに定まる」と考えています。
  12. SVM を使うとかえって問題が複雑になってしまうかもしれませんが…実装という観点からは、Python の scikit-learn ライブラリを使えば SVM を適用する事自体は簡単です。ご参考まで、(scikit-learn ではないのですが) ハードマージン SVM で本問題を解いたところ となりました。
  13. グリッドサーチとは、平たく言ってしまえば「 が存在するであろう範囲 において、 なる に対して片っ端から を計算して最大値を探す」という方法であり、具体的には十分大きな に対して として を与える の近似値とします。目的関数 (今の場合は ) に対して何ら数学的仮定を必要としないので汎用性が高いのですが、パラメーター数が多い場合には計算量が非常に多くなってしまい実用的ではなくなります。なお、脚注 10 にもある通り今回は分離直線がほとんど一つに決まると考えているので気にしなくて良い (事にしている) のですが、通常は過学習 (overfitting) を避けるために「何が何でも に近付けたい」というような考え方にあまりこだわるべきではありません。
  14. コード中の ^ は排他的論理和 (所謂 XOR) を表しています。また、(今回の乱数シードでは問題ありませんが) 乱数を使用している都合上、たまたま input_data[i, 0] == 0.0 となるような i が存在してしまい、ゼロ割りが発生してしまう事があるかもしれませんが、その場合もリストに追加されるのは inf または -inf となり、計算結果に問題は生じません。
  15. 更に、Keras や PyTorch といったライブラリを利用すれば、より複雑なネットワークモデルでも比較的簡単に構築する事が出来、また (環境さえ整えば) GPU の利用も容易です。

※ AMFiL Blog の記事を含む、本ウェブサイトで公開されている全てのコンテンツについての著作権は、一般社団法人数理ファイナンス研究所 (AMFiL) 及びブログ記事の寄稿者に帰属します。いかなる目的であれ、無断での複製、転送、改編、修正、追加等の行為を禁止します。