🤖

ニューラルネットが分散表現を構築する本当の理由

2021/06/21に公開

はじめに

この記事は前回の記事「ニューラルネットの重み行列は理解不能なブラックボックスではない」の続きですが、極力前回の記事は読まなくても良いよう努めます。

前回の記事では、ニューラルネット、というよりパーセプトロンの各層の重み行列は各層の入力に似た形になるだろう、という仮定のもとで、そうなる理由を説明しました。

しかし検証コードを書いた結果、単層である場合や、多層パーセプトロンの最終層については確かにそうなりそうなのですが、中間層についてはどうなんだこれは?という結果が出てしまいました。

・上、単層なパーセプトロンの、0を入力したときに一番高い相関を示した重み行列の一部
・中、3層パーセプトロンの中間層で同様に取った重み行列の一部
・下、出力層で同様に取った重み行列の一部(128次元を無理やり画像化)

中間層の重みが期待どおり(入力に近い値)にならなかった理由を考えた結果、これこそが世間的に言われる分散表現を構築したためであるという事に気付いたので、今回の内容「ニューラルネットが分散表現を構築する本当の理由」について語らせて頂きます。

前回のおさらい

前回、全結合層やCNN層の根源は行列積計算であり、行列積計算は相互相関関数と同等なので、パーセプトロンの肝は異なるデータ間で一種の類似度を計算する処理であると説明しました。

簡単におさらいすると、全結合層の出力

Outputs = matmul(Inputs, W)

が構成するのは、入力行ベクトルと重み列ベクトルの内積値をニューロンごとに計算した値で、

Outputs = \begin{pmatrix} Neuron_{00}, Neuron_{01}, ..., Neuron_{0n} \\ Neuron_{10}, Neuron_{11}, ..., Neuron_{1n} \\ ... \\ Neuron_{b0}, Neuron_{b1}, ..., Neuron_{bn} \\ \end{pmatrix}

ここで、Inputsは(バッチ,データ)次元の行列、Wは(データ,ニューロン)次元の行列であり、Outputsは(バッチ,ニューロン)次元の行列です。

個々の Neuron_{xx} は先ほど述べたように、入力行ベクトルと重み列ベクトルの内積値です。

a = \sum_{i=0}^n u_{i} v_{i}

内積値は、基本的にはベクトル同士の形状が似ているほど大きな値を取ります。

u_{i}v_{i} が 正同士なら正、負同士でも正、正と負あるいは負と正なら負です。

さて、話を重み行列の学習手順に移します。理想的な出力である教師信号が正解ラベルのビットを立てたone-hotなベクトルで与えられるとき、最終層のOutputsはたとえば、

Outputs = \begin{pmatrix} 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 \\ 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 \\ ... \\ 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 \\ \end{pmatrix}

のようになることが期待され、そうなるように重みが調整されます。上記のOutputs例の一行目を取り出すと、

\begin{pmatrix} Neuron_{00}, Neuron_{01}, ..., Neuron_{0n} \end{pmatrix}
\begin{pmatrix} 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 \end{pmatrix}

なので上記の例では、Neuron_{00} が1、その他の Neuron_{01} から Neuron_{0n} が0になるように学習は進められます。

Neuron_{00} は 入力の行0と重み行列の列0なベクトル同士の内積でしたので、この内積が1になるよう学習するということは、入力と重みの一部がある程度の正の相関を示すように重みを更新するということです。

また他のニューロンについて、この内積が0になるように学習するということは入力と重みの一部が無相関を示すように重みを更新するということです。

これは言い換えれば、重み行列の一部を現在の入力と似るように、他の箇所を入力と似ないように更新するのがパーセプトロンである、ということです。

ここまでが前回のおさらいとなります。

中間層は?

上でも述べましたが、このような仮説のもとに検証コードを書いた結果、以下のような結果が出ました。

一応再度説明すると、

・上、単層なパーセプトロンの、0を入力したときに一番高い相関を示した重み行列の一部
・中、3層パーセプトロンの中間層で同様に取った重み行列の一部
・下、出力層で同様に取った重み行列の一部(128次元を無理やり画像化)

です。

単層の場合は入力(=画像そのもの)と出力のone-hotなビット列を直接的に結びつけたので、確かに期待どおり、入力そのものと似た重みが検出されています。

10クラス分類を行う単層のパーセプトロンは、ニューロンが10個しか無いので、理想出力のone-hotなビット列と1:1で対応したニューロンが仮説に従った成長を示し、このような結果になっていると考えられます。

さて、中間層ですが、なんだか0の形の一部分のみに反応したような不完全なパターンが検出されていますよね。

これは、前回も軽く触れましたが、中間層にとっての理想出力である教師信号が、1つだけ1の立ったone-hotなビット列のように明快なパターンでなく、後ろの層の重みや逆伝播してきた誤差を考慮した複雑かつ中途半端な値であるためと考えられます。

中間層にとっての教師信号を、逆伝播する誤差doutを用いて説明すると以下のようになります。

dout_i = matmul(dout_{i+1}, W_{i+1}^T)
dout_i = Outputs_i - 教師信号_i
教師信号_i = Outputs_i - dout_i = Outputs_i - matmul(dout_{i+1}, W_{i+1}^T)

詳細は重要ではないんですが、中間層にとっての教師信号が後ろの層の doutW に依存するというところだけ確認して下さい。

これはたとえば、中間層にとっての理想出力は、もはやひとつの1と複数の0でなく、0.1や0.3などを複数含む形になっているということです。たとえば以下のような値です。

Outputs = \begin{pmatrix} 0.1, 0.3, 0.2, 0.0, 0.2, 0.1, 0.3, 0.4, 0.8, 0.2 \\ 0.3, 0.3, 0.1, 0.2, 0.0, 0.0, 0.1, 0.3, 0.4, 0.8 \\ ... \\ 0.8, 0.2, 0.1, 0.2, 0.0, 0.6, 0.1, 0.2, 0.3, 0.0 \\ \end{pmatrix}
\begin{pmatrix} Neuron_{00}, Neuron_{01}, ..., Neuron_{0n} \end{pmatrix}
\begin{pmatrix} 0.1, 0.3, 0.2, 0.0, 0.2, 0.1, 0。3, 0.4, 0.8, 0.2 \end{pmatrix}

Neuron_{00} は 入力の行0と重み行列の列0なベクトル同士の内積でしたので、この内積が0.1になるよう学習するということは、列0のニューロンが現在の入力と一部(0.1)だけ似るように重みを更新するということです。

他のニューロンも同様で、たとえば内積が0.3になるよう学習するということは、該当ニューロンが現在の入力と一部(0.3)だけ似るように重みを更新するということです。

つまりは、あらゆる入力に対して、あらゆるニューロンが一部ずつ似るように学習されるということです。

要するに入力を複数の表現に分解し、個々の小さな表現との合致度合いを0と1のビットに収まらない連続値で記憶しているという意味で捉えることが出来ますが、これは一般的な分散表現の定義そのものであるといえます。

世間的に分散表現と言えば単語の分散表現が連想されるかと思いますが、本来はこのように、画像が入力でも説明がつくのが分散表現です。

このようにして、中間層の重みは入力と丸々似た値でなく、部分部分が少しづつ似た値を複数に分けて学習するのでした。これで先の画像のような結果が出たのも納得できますね。

分散表現はどこから来るの?

さて、ここまでで中間層が重みとして分散表現を獲得するのだ、というのはわかりました。しかしこの分散表現は一体誰がどのように設計しているのでしょうか。またその分散表現は常に適切なものとなる保証があるのでしょうか。

「ニューラルネットが自動的に良い感じの特徴量を抽出してくれる」?答えはノーです。

パーセプトロンの立場に立ってみれば、実際に分類精度が出るので、ここで生成される分散表現は良い感じの特徴量と言えるかもしれませんが、人間にとってみればこれは悪夢のようなものです。

何故悪夢であるかというと、パーセプトロンの構築する分散表現は、自然に導かれる絶対的な指標ではなく、単に行列のランダム初期重みに依存して決定される、パーセプトロンごとに互換性のない固有の表現であり、値の再現性も人間の理解も全く考慮していません。

単層のパーセプトロンに話を戻すと、単層のパーセプトロンはある入力に対する出力が理想的なビット列になるように重みを調整するのでした。

Outputs = \begin{pmatrix} 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 \\ 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 \\ ... \\ 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 \\ \end{pmatrix}

このとき、単層のパーセプトロンが見ているのは、おそらく既存のデータとして、途中で変更の加えられることのない静的な入力と、同様に訓練ラベルデータという静的な出力です。

静的な入力を静的な出力に結びつけているだけなので、何度学習しても同じような重みを形成します。(学習時のデータシャッフル等により多少変化はします。)

しかし、多層パーセプトロンの中間層はどうだったでしょうか。

Outputs = \begin{pmatrix} 0.1, 0.3, 0.2, 0.0, 0.2, 0.1, 0.3, 0.4, 0.8, 0.2 \\ 0.3, 0.3, 0.1, 0.2, 0.0, 0.0, 0.1, 0.3, 0.4, 0.8 \\ ... \\ 0.8, 0.2, 0.1, 0.2, 0.0, 0.6, 0.1, 0.2, 0.3, 0.0 \\ \end{pmatrix}
dout_i = matmul(dout_{i+1}, W_{i+1}^T)
dout_i = Outputs_i - 教師信号_i
教師信号_i = Outputs_i - dout_i = Outputs_i - matmul(dout_{i+1}, W_{i+1}^T)

中間層の理想出力は後ろの層の重みと出力誤差によって決まるのでした。出力誤差も間接的に各層の出力、ひいては各層の重みに依存して決まります。そして根本となる各層の最初の重みは、一般的に「乱数で初期化された値」です。

最終的にランダムな初期重みは、誤差逆伝播により前後の層を間接的に見て何らかの値に落ち着きますが、その値は「ランダム初期状態からはじまった入出力に各層が適合した」結果であり、それ以外の外的要因により修正されるチャンスはありません。

つまり、中間層の学習後に得られる分散表現もランダム初期値に依存した形になる、ということです。

コード

分散表現は実際に初期重みに強く依存した値になるか?ということを確認するため、「中間層の初期重みとしていくつかの訓練画像そのものを設定するような多層パーセプトロン」を作成して確認してみました。

結果の画像がこちらです。

各画像は、以前のものと同様に

・上、単層なパーセプトロンの、0を入力したときに一番高い相関を示した重み行列の一部
・中、3層パーセプトロンの中間層で同様に取った重み行列の一部
・下、出力層で同様に取った重み行列の一部(512次元を無理やり画像化)

です。

もはや分散表現というより、もろに画像がテンプレートとしてそのまま残っているのがわかりますね。

ちなみに分類精度は単層パーセプトロンより低くなってしまっています。

python3 tensorflow2.5

slp_mlp_image_init.py
import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt


class ImagesLayer(tf.keras.layers.Layer):
    def __init__(self, out_dim, images, activation="relu"):
        super(ImagesLayer, self).__init__()
        self.out_dim = out_dim
        self.W = None
        self.b = None
        self.images = images
        self.activation = tf.keras.layers.Activation(activation)

    def build(self, input_shape):
        in_dim = input_shape[-1]
        C = tf.constant_initializer(self.images[0:self.out_dim].T)
        self.W = self.add_weight("W", (in_dim, self.out_dim), initializer=C)
        self.b = self.add_weight("b", (1, self.out_dim))

    def call(self, x, *args, **kwargs):
        x = tf.matmul(x, self.W) + self.b
        return self.activation(x)


if __name__ == '__main__':
    tf.random.set_seed(12345)

    epochs = 20
    batch_size = 500

    dataset = tf.keras.datasets.mnist
    (train_images, train_labels), (test_images, test_labels) = dataset.load_data()

    train_images = train_images / 255.0
    test_images = test_images / 255.0

    train_images = train_images.reshape((-1, 28 * 28))
    test_images = test_images.reshape((-1, 28 * 28))

    slp_0 = tf.keras.layers.Dense(10, activation='softmax')
    slp = tf.keras.Sequential([
        tf.keras.layers.InputLayer(train_images[0].shape),
        slp_0
    ])

    slp.compile(optimizer='adam',
                loss='sparse_categorical_crossentropy',
                metrics=['accuracy'])

    mid = 512
    mlp_0 = ImagesLayer(mid, train_images, activation='relu')
    mlp_1 = tf.keras.layers.Dense(10, activation='softmax')
    mlp = tf.keras.Sequential([
        tf.keras.layers.InputLayer(train_images[0].shape),
        mlp_0,
        mlp_1
    ])

    mlp.compile(optimizer='adam',
                loss='sparse_categorical_crossentropy',
                metrics=['accuracy'])

    slp.fit(train_images, train_labels, epochs=epochs, validation_split=0.02, batch_size=batch_size)
    mlp.fit(train_images, train_labels, epochs=epochs, validation_split=0.02, batch_size=batch_size)

    x = train_images[1:2]
    y_slp = slp_0(x).numpy()
    winner_neuron_idx_slp = np.argmax(y_slp)  # 最も強い相関を示したニューロンのインデックスを取得
    winner_neuron_slp = slp_0.get_weights()[0][:, winner_neuron_idx_slp]  # 最も強い相関を示したニューロンの重みを取得

    y_mlp_0 = mlp_0(x)
    y_mlp_1 = mlp_1(y_mlp_0).numpy()
    y_mlp_0 = y_mlp_0.numpy()

    winner_neuron_idx_mlp_0 = np.argmax(y_mlp_0)
    winner_neuron_mlp_0 = mlp_0.get_weights()[0][:, winner_neuron_idx_mlp_0]
    winner_neuron_idx_mlp_1 = np.argmax(y_mlp_1)
    winner_neuron_mlp_1 = mlp_1.get_weights()[0][:, winner_neuron_idx_mlp_1]

    plt.subplot(3, 2, 1)
    plt.imshow(x.reshape((28, 28)))
    plt.subplot(3, 2, 2)
    plt.imshow(winner_neuron_slp.reshape((28, 28)))

    plt.subplot(3, 2, 3)
    plt.imshow(x.reshape((28, 28)))
    plt.subplot(3, 2, 4)
    plt.imshow(winner_neuron_mlp_0.reshape((28, 28)))

    plt.subplot(3, 2, 5)
    plt.imshow(y_mlp_0.reshape((mid // 16, 16)))  # 128次元を無理やり画像化
    plt.subplot(3, 2, 6)
    plt.imshow(winner_neuron_mlp_1.reshape((mid // 16, 16)))

    plt.show()

おわりに

いかがでしたでしょうか。

前回の記事で検討しきれなかった、中間層の重みが分散表現になる理由について説明しました。

今回の内容を簡潔に表すと、ニューラルネットが分散表現を構築する本当の理由は、中間層にとっての理想出力が後ろの層の重みと出力に応じた複雑な値を取るためであり、各層はその理想出力を入力と重みの類似度計算で表そうとするため。と言えるかと思います。

前回、重み行列はブラックボックスではないという話をしたのに、結局のところ分散表現として人間の理解を考慮しない値を取るという結論になってしまいました。この辺りの無知は笑って流していただけると幸いです。

もしかしたら大きく間違った解釈をしてしまっているかもしれませんが、この記事が誰かの何かのヒントになれば。

Discussion