"BOKU"のITな日常

62歳・文系システムエンジニアの”BOKU”は日々勉強を楽しんでます

ディープラーニングの学習時の「誤差逆伝播法」の正体について整理してみる

今回は、ディープラーニングの学習の説明に必ず登場する「誤差逆伝播法(バックブロゲーション)」とは何だろうということを整理してみます。

f:id:arakan_no_boku:20200109220616p:plain

 

誤差逆伝播

 

ディープラーニングを勉強してると、学習時のウエイトやバイアスの更新に使う勾配の値を求めるのに、「誤差逆伝播法 またはバックプロパゲーション( Backpropagation)」を使うと書いてあります。

これは一体どういうものなのか?

これが今回のテーマです。

 

どうも「微分」のことらしい

 

誤差逆伝播法の説明を見てみます。

今回はこちらのサイトを参考にしました。

fresopiya.com

説明をよーく見ると、誤差逆伝播法の説明の前に微分の「連鎖律」を説明されていて。

連鎖律とは、複数の関数によって構成される関数の微分は、それぞれの関数の微分の積によって表すことができるといった性質のことです。

後ろの方には

各重みパラメータに関する損失関数の勾配を、連鎖律を利用して求める

とか

出力から入力側に向かって順に学習パラメータを更新していくことから、誤差逆伝播法と呼ばれ、各パラメータの偏微分の計算を効率化します。

などと書かれています。

つまり。

学習時に各重みパラメータを更新に使う勾配を求める方法は「微分」である。

でも、普通に微分計算(算術微分)すると、計算効率が悪く恐ろしく遅い。

なので、効率的かつ高速に微分計算ができるよう工夫した方法が「誤差逆伝播法」なんだということのようです。

 

numpy+算術微分だけで学習させてみよう

 

誤差逆伝播法は、微分を高速に計算できるように工夫した方法である・・が正しいかどうか試すには、誤差逆伝播法を使わず、普通の算術微分を使って同じ様に学習できるかをやってみればよいということになります。

そのために、tensorflowなどのフレームワークを使わず、numpyだけでニューラルネットワークモデルを作って、算術微分だけを使って学習させてみます。

Pythonのソース全文です。

import numpy as np


# サンプルデータの生成クラス
class Data:
    def __init__(self, size):
        self.rasio = 10
        self.size = size

    def data_create(self, num, padding):
        datasets = []
        labels = []
        for i in range(1, num + 1):
            rt = i % 2
            if (rt == 0):
                datasets.append(np.random.randn(self.size))
            else:
                datasets.append([((i + padding) %
                                  100) / self.rasio for k in range(self.size)])
            labels.append(rt)
        return np.array(datasets), np.array(labels)


# 誤差逆伝播法を使わない「なんちゃってニューラルネットワーク」サンプルクラス
class SampleNet:

    def __init__(
            self,
            input_size,
            hidden_size,
            output_size,
            weight_init_std=0.01):
        # 重みとバイアスの初期化
        self.params = {}
        self.params['W1'] = weight_init_std * \
            np.random.randn(input_size, hidden_size)
        self.params['b1'] = np.zeros(hidden_size)
        self.params['W2'] = weight_init_std * \
            np.random.randn(hidden_size, output_size)
        self.params['b2'] = np.zeros(output_size)

    # 順方向に計算処理を行って、ラベル毎の確率を求める
    def predict(self, x):
        W1 = self.params['W1']
        W2 = self.params['W2']
        b1 = self.params['b1']
        b2 = self.params['b2']
        # Affine層
        a1 = np.dot(x, W1) + b1
        # Sigmoid層
        z1 = 1 / (1 + np.exp(a1))
        # Affine層
        a2 = np.dot(z1, W2) + b2
        # Softmax層
        a2t = a2.T
        a2t = a2t - np.max(a2t, axis=0)
        yy = np.exp(a2t) / np.sum(np.exp(a2t), axis=0)
        y = yy.T
        return y

    # x:入力データ, t:教師データでロス率を求める
    def loss(self, x, t):
        y = self.predict(x)
        batch_size = y.shape[0]
        # クロスエントロピーです。
        return -np.sum(np.log(y[np.arange(batch_size), t])) / batch_size

    # 正解率を求める
    def accuracy(self, x, t):
        y = self.predict(x)
        y = np.argmax(y, axis=1)
        accuracy = np.sum(y == t) / float(x.shape[0])
        return accuracy

    # x:入力データ, t:教師データ
    def gradient(self, x, t):
        def loss_W(W):
            return self.loss(x, t)

        grads = {}
        grads['W1'] = self.__bibun_np__(loss_W, self.params['W1'])
        grads['b1'] = self.__bibun_np__(loss_W, self.params['b1'])
        grads['W2'] = self.__bibun_np__(loss_W, self.params['W2'])
        grads['b2'] = self.__bibun_np__(loss_W, self.params['b2'])
        return grads

    # 行列を微分する関数
    def __bibun_np__(self, f, x):
        h = 1e-4  # 0.0001
        grad = np.zeros_like(x)
        it = np.nditer(x, flags=['multi_index'], op_flags=['readwrite'])
        while not it.finished:
            idx = it.multi_index
            tmp_val = x[idx]
            x[idx] = float(tmp_val) + h
            fxh1 = f(x)  # f(x+h)
            x[idx] = float(tmp_val) - h
            fxh2 = f(x)  # f(x-h)
            grad[idx] = (fxh1 - fxh2) / (2 * h)
            x[idx] = tmp_val
            it.iternext()
        return grad


iters_num = 300
# 繰り返しの回数を適宜設定する
test_size = 200
size = 256
# データを生成する。奇数を1000で割った数字G=1、偶数を1000で割ったG=0 としてカテゴライズ。
d = Data(size)
x_train, t_train = d.data_create(iters_num, 0)
x_test, t_test = d.data_create(test_size, size + 1)

# ネットワークを構築する
network = SampleNet(input_size=size, hidden_size=64, output_size=2)
train_size = x_train.shape[0]

# ミニバッチ(一回のループで処理する数)
batch_size = 100
# 学習率
learning_rate = 0.1

train_loss_list = []
train_acc_list = []
test_acc_list = []

# 繰り返しの数の1単位を計算する
iter_per_epoch = max(train_size / batch_size, 1)

for i in range(int(iter_per_epoch * 5)):
    batch_mask = np.random.choice(train_size, batch_size)
    x_batch = x_train[batch_mask]
    t_batch = t_train[batch_mask]

    # 勾配を算術微分を使って更新する
    grad = network.gradient(x_batch, t_batch)

    # パラメータの更新
    for key in ('W1', 'b1', 'W2', 'b2'):
        network.params[key] -= learning_rate * grad[key]

    # 更新したパラメータで推論を行いロス率を求める
    loss = network.loss(x_batch, t_batch)
    train_loss_list.append(loss)

    # 更新したパラメータでの推論結果を評価する
    train_acc = network.accuracy(x_train, t_train)
    test_acc = network.accuracy(x_test, t_test)
    train_acc_list.append(train_acc)
    test_acc_list.append(test_acc)
    print(
        "loss:" +
        str(loss) +
        "/train acc:" +
        str(train_acc) +
        "/test acc:" +
        str(test_acc))

ちょっと長いですが、ご容赦を。

補足します。

まず、ニューラルネットワークの各レイヤー(層)は、シンプルに計算式をそのままPyhonの式に展開して書いてます。

  • Affine層(全結合層): np.dot(x, W1) + b1
  • Sigmoid層(活性化関数):1 / (1 + np.exp(a1))
  • SoftMax層:np.exp(a2t) / np.sum(np.exp(a2t), axis=0)
  • 損失関数(クロスエントロピー):-np.sum(np.log(y[np.arange(batch_size), t])) / batch_size

勾配は、誤差逆伝播を使わず、素の微分(__bibun_np__関数)を使って求めてます。

関数名に__がついているのは、クラス内部でのみ使う命名規則に従ってるだけです。

悲しいかな。

このやり方(素の微分を使う)だと、ものすごく遅いです。

MNISTなどのありものの画像データを使うと、正直、いつ終わるかわかりません。

なので。

今回は、無理くり、小さなデータセットを生成して使ってます。

ラベルは「0」と「1」の2つにしました。

片方は

f:id:arakan_no_boku:20200118143632p:plain

もう一方は

f:id:arakan_no_boku:20200118143707p:plain

みたいな感じで、同じ数字ばかりで構成されたデータと、ランダムな数字で構成されたデータという、あからさまに分類しやすい(笑)データにしてみました。

これを実行してみます。

毎回、データが変わるので結果は変化します。

以下は、ある回のログです。

loss:0.6122528366269887/train acc:0.5/test acc:0.5
loss:0.6171530277844487/train acc:0.85/test acc:0.85
loss:0.5603608447954526/train acc:0.89/test acc:0.89
loss:0.49351229689788956/train acc:0.9133333333333333/test acc:0.89
loss:0.4756502123891248/train acc:0.95/test acc:0.95
loss:0.4287426828536864/train acc:0.94/test acc:0.93
loss:0.377243078578998/train acc:0.97/test acc:0.96
loss:0.35549749153641685/train acc:0.95/test acc:0.95
loss:0.34160940378343/train acc:0.96/test acc:0.95
loss:0.31175165373977165/train acc:0.95/test acc:0.95
loss:0.2643838977401827/train acc:0.96/test acc:0.95
loss:0.2630312131351989/train acc:0.97/test acc:0.96
loss:0.23896073382588487/train acc:0.96/test acc:0.96
loss:0.23168796117264637/train acc:0.97/test acc:0.96
loss:0.19577869153575023/train acc:0.96/test acc:0.96

最初、0.5(まったくでたらめ)の状態から、0.96(96%正解)までいってます。

学習はできていると思って良さげです。

まあ。

こんなものは、学習用というか頭の整理用以外の何の役にも立ちません。

だけど。

誤差逆伝播法を使わなくても、まがりなりにも学習ができました。

ということは、やっぱ根本のところは「微分」なんだな・・と納得もできました。

 

ちなみに微分について

 

念のため、微分についても補足しておきます。

微分とは「変数の微小な変化に対応する、関数の変化の割合の極限(=微分係数)を求めること。」などと説明されますが。

正直。

自分も学校で習った時にはちんぷんかんぷんでした。

だけど、プログラムで書くと多少はわかりやすくなるのが面白いところです。

ここはPythonの疑似プログラムで書いてみます。

def bibun(fx):
    h = 1e-4
    y1 = f(x + h)
    y2 = f(x - h)
    return (y1 - y2) / (2 * h)

 こんな感じですね。

ちょっとだけ変化させてその差をとって計算するので、hに極めて小さな数(1e-4=1.0 × 0.0001)の値を足し引きして、関数(f)で値をもとめて、差をとることで極限をもとめる・・、というわけです。

で・・。

$$y = x^3$$

Pythonで書くと

def function1(x):
    return x**3

y = function1(x)

みたいになるので、この式の微分を求める式は

yy = bibun(function1, x)

 になります。

ためしに、x=10でやってみると、1000(10の3乗)で、微分の結果が300(3×10の2乗・・微分の公式)になりますから・・、あってます。

微分は、よくグラフなんかで傾きとか勾配とか呼ばれます。

同じように、ディープラーニングの学習でも「どこくらい増減させるか」を決めるのに、微分の計算値を使っているということらしいです。 

実際には。

上記みたいに、ひとつだけの値を求めるということはあまりありません。

INPUTの「x」は行列データになり、微分のプログラムもこんな感じになります。

import numpy as np

def bibun_np(f, x):
    h = 1e-4  # 0.0001
    grad = np.zeros_like(x)
    it = np.nditer(x, flags=['multi_index'], op_flags=['readwrite'])
    while not it.finished:
        idx = it.multi_index
        tmp_val = x[idx]
        fxh1 = f(float(tmp_val) + h)  # f(x+h)
        fxh2 = f(tmp_val - h)  # f(x-h)
        grad[idx] = (fxh1 - fxh2) / (2 * h)
        it.iternext()
    return grad

行列を扱いやすいように「numpy」を使ってます。

ループをすっきり書こうとして「np.nditer」なんかを使っているのと、行列データをひとつずつ取り出して微分計算をしているので複雑に見えますけど、基本やっていることは上記のひとつの値の場合と一緒です。

ためしに、インプットを行列でやってみると。

def function1(x):
    return x**3


x = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0])
grad = []
grad = bibun_np(function1, x)
print(grad)

結果は

[ 3.00000001 12.00000001 27.00000001 48.00000001 75.00000001
108.00000001 147.00000001 192.00000001 243.00000001 300.00000001]

みたいに行列ででてきます。

 

まとめ

 

誤差逆伝播法を文系チックに説明しようとしたのですが、さすがに無理がありました。

なんか、ゴリゴリPythonのプログラムを書いて胡麻化したみたいになってます。

すいません。

でも、誤差逆伝播法が、実は微分計算を効率的・高速にやっているのに近い・・というのを知ったとき、自分的にはすごく興奮しました。

なので。

それを少しでも共有できれば・・と思って書いてみました。

今回はこんなところで。

ではでは。