TensorFlow や Numpy を使わずにニューラルネットワークを実装する 分類編(2)

今回は、前回準備した分類問題用のニューラルネットワークで、実際に、分類問題を学習していきたいと思います。

学習する問題

水平方向にx軸、垂直方向にy軸というxy平面を考えます。x軸方向は-3から3まで、y軸方向は-1から1までとし、このxy平面に等間隔に配置された400個の点(座標)を用意します。このxy平面を y = sin(x) という、いわゆるサインカーブで2つの領域(サインカーブの上か下か)に区切ります。400個の点(座標)がこの2つの領域のどちらにあるのかを学習していきます。文字だけではイメージが湧きにくいかと思いますので、学習前の点(座標)が分類されていない状態の図を用意しました。今回も学習のみになります。

準備(分類用ユーティリティ関数)

学習を始める前に、訓練・教師データの作成や、学習経過を表示するための関数を作成します。

import math
import random

import matplotlib.pyplot as plt

target_func = lambda x: math.sin(x)

def cross_entropy_error(outputs, teachings):
    """クロスエントロピー誤差を計算する

    :param teaching: 教師データ
    :param output: 出力値
    :return: クロスエントロピー誤差
    """
    sum = 0.0
    for output, teaching in zip(outputs, teachings):
        big = max([output, 1e-7])
        sum += teaching * math.log(big)

    return -sum


def create_learning_data():
    """学習用データを作成する

    :return:  訓練データのリストと教師データのリスト
    """
    x = linspace(-3, 3, 20)
    y = linspace(-1, 1, 20)
    random.shuffle(x)
    random.shuffle(y)

    trainings_list = []
    teachings_list = []
    for _x in x:
        value = target_func(_x)

        for _y in y:
            trainings_list.append([_x, _y])
            teaching = [1, 0] if _y >= value else [0, 1]
            teachings_list.append(teaching)

    return trainings_list, teachings_list


def calculate_output_by_current_weights(
    inputs_list, input_layer, middle_layer, output_layer
):
    """現時点の重みで出力を計算する

    :param inputs_list: 入力値のリスト
    :param input_layer: 入力層インスタンス
    :param middle_layer: 中間層インスタンス
    :param output_layer: 出力層インスタンス
    :return: 出力値のリスト
    """
    outputs_list = []
    for inputs in inputs_list:
        input_layer.set_neurons(inputs)
        middle_layer.calculate_output(input_layer.neurons)
        output_layer.calculate_output(middle_layer.neurons)
        outputs_list.append(output_layer.neurons)
    return outputs_list


def create_result_distribution_chart(inputs_list, outputs_list, path):
    """結果の散布図を作成する

    :param inputs_list: 入力値のリスト
    :param outputs_list: 出力値のリスト
    :param path: 散布図を出力するファイルパス
    """
    fig = plt.figure()
    ax = fig.add_subplot(1, 1, 1)

    x1, y1, x2, y2 = [], [], [], []

    for inputs, outputs in zip(inputs_list, outputs_list):
        _x, _y = inputs
        # どちらに判定されているかで分類する
        if outputs[0] >= outputs[1]:
            x1.append(_x)
            y1.append(_y)
        else:
            x2.append(_x)
            y2.append(_y)

    ax.scatter(x1, y1, s=20, c="blue", marker="*")
    ax.scatter(x2, y2, s=20, c="green")

    # 境界となる線を書く
    pi = 3.141592  # 円周率
    x = linspace(-pi, pi, 100)
    y = [target_func(_x) for _x in x]
    ax.plot(x, y, color="orange")

    plt.savefig(path)

一つ一つ説明していきます。

def cross_entropy_error(outputs, teachings):
    """クロスエントロピー誤差を計算する

    :param teaching: 教師データ
    :param output: 出力値
    :return: クロスエントロピー誤差
    """
    sum = 0.0
    for output, teaching in zip(outputs, teachings):
        big = max([output, 1e-7])
        sum += teaching * math.log(big)

    return -sum

分類問題では、誤差関数としてクロスエントロピー誤差を利用するので、その実装になります。数式通りの実装ですが、オーバーフロー対策がしてあります。

def create_learning_data():
    """学習用データを作成する

    :return:  訓練データのリストと教師データのリスト
    """
    x = linspace(-3, 3, 20)
    y = linspace(-1, 1, 20)
    random.shuffle(x)
    random.shuffle(y)

    trainings_list = []
    teachings_list = []
    for _x in x:
        value = target_func(_x)

        for _y in y:
            trainings_list.append([_x, _y])
            teaching = [1, 0] if _y >= value else [0, 1]
            teachings_list.append(teaching)

    return trainings_list, teachings_list

学習用に訓練データと教師データを作成する関数です。x軸方向に-3から3まで等間隔に20個の点(座標)を生成し、そのx軸の点(座標)それぞれに対してy軸方向に-1から1まで等間隔に20個の点(座標)を生成します。すなわち格子状に等間隔に20 × 20 = 400個の点(座標)を作成するイメージです。順番はランダムにしておきます。生成した点(座標)のy座標と、x座標を target_func() (今回は sin(x))に通した値とを比較(つまり、サインカーブの上にあるか下にあるか)して、教師データを生成します。点(座標)(x, y) がサインカーブより上にあれば、教師データとして[1, 0]を生成し、下にあれば[0, 1]を生成します。これらのデータを trainings_list, teachings_list に詰めて返します。

def create_result_distribution_chart(inputs_list, outputs_list, path):
    """結果の散布図を作成する

    :param inputs_list: 入力値のリスト
    :param outputs_list: 出力値のリスト
    :param path: 散布図を出力するファイルパス
    """
    fig = plt.figure()
    ax = fig.add_subplot(1, 1, 1)

    x1, y1, x2, y2 = [], [], [], []

    for inputs, outputs in zip(inputs_list, outputs_list):
        _x, _y = inputs
        # どちらに判定されているかで分類する
        if outputs[0] >= outputs[1]:
            x1.append(_x)
            y1.append(_y)
        else:
            x2.append(_x)
            y2.append(_y)

    ax.scatter(x1, y1, s=20, c="blue", marker="*")
    ax.scatter(x2, y2, s=20, c="green")

    # 境界となる線を書く
    pi = 3.141592  # 円周率
    x = linspace(-pi, pi, 100)
    y = [target_func(_x) for _x in x]
    ax.plot(x, y, color="orange")

    plt.savefig(path)

学習の途中経過を散布図で出力するための関数です。matplotlib の説明はここではしません。inputs_list は入力値のリスト(つまり点(座標))、outputs_list は出力値のリストで calculate_output_by_current_weights() で計算・出力された値になります。forループでoutputs_list の中身を取り出して、ニューラルネットワークがどちらに分類したのかを判断していきます。教師データをサインカーブより上にあれば[1, 0]、下にあれば[0, 1]というように生成したので、outputs[0] >= outputs[1] ならば、入力データ点(座標)(x, y)はサインカーブより上、outputs[0] < outputs[1] ならばサインカーブより下、とニューラルネットワークが判定したことになります。入力値のx座標・y座標を2つのグループ、上グループ(x1, y1)と 下グループ(x2, y2) に振り分けていきます。そして、この2グループを散布図として描画し、わかりやすいように境界となるサインカーブも合わせて描画して、ファイルに出力します。

学習

それでは実際に学習していきます。

if __name__ == "__main__":
    input_num = 2    # 入力層のニューロン数
    middle_num = 20  # 中間層のニューロン数
    output_num = 2   # 出力層のニューロン数
    loop_num = 250   # ループ回数 -> 学習回数は loop_num * 学習データ数 になる

    input_layer = InputLayer(input_num)
    middle_layer = MiddleLayer(middle_num, input_num)
    output_layer = OutputLayer(output_num, middle_num)

    trainings_list, teachings_list = create_learning_data()

    indexes, errors = [], []
    index = 0

    for _ in range(loop_num):
        for trainings, teachings in zip(trainings_list, teachings_list):
            # 順伝播
            input_layer.set_neurons(trainings)
            middle_layer.calculate_output(input_layer.neurons)
            output_layer.calculate_output(middle_layer.neurons)

            # 逆伝播
            output_layer.calculate_delta(teachings)
            output_layer.update_weights(middle_layer.neurons)
            output_layer.update_biases()

            middle_layer.calculate_delta(output_layer.deltas, output_layer.weights)
            middle_layer.update_weights(input_layer.neurons)
            middle_layer.update_biases()

            index += 1

            # 途中結果保存
            if index == 1 or index % 10000 == 0:
                indexes.append(index)
                error = cross_entropy_error(output_layer.neurons, teachings)
                errors.append(error)

                # 現時点の重みで計算し結果を散布図にする
                outputs_list = calculate_output_by_current_weights(
                    trainings_list, input_layer, middle_layer, output_layer
                )
                create_result_distribution_chart(
                    trainings_list, outputs_list, f"png/{index}.png"
                )

    # 誤差の推移を出力する
    create_line_graph(indexes, errors, "png/result.png")

前々回の回帰問題とほとんど変わらないソースなので、違う部分のみを記載します。まずは各層のニューロン数ですが、入力は点のx座標とy座標なので2つ、出力は、対象の点(座標)がサインカーブの上か下かどちらに分類したのかを表すものなので2つ、中間層は良い結果が出そうな値(今回は20)を設定します。前々回の回帰問題では生成した学習用データの個数分だけ学習が行われましたが、今回の分類問題では、学習用データが400個しか生成されないため、それをそのまま使うだけでは学習が足りません。そのため、学習データを繰り返し使うためのforループを用意し、その繰り返し回数を loop_num で制御しています。また、誤差関数はクロスエントロピー誤差を利用し、学習の途中経過を散布図で描画していきます。

学習結果

今回の学習回数は、学習データ 400 × ループ回数 250 = 100000回になります。途中経過は10000回毎に出力しています。この設定で学習結果は下記の通りになりました。まずは誤差の推移です。

学習回数10000回くらいまでで、誤差がガクッと落ち、その後も少しずつ誤差が小さくなっていくのがわかります。

続いて学習の経過です。上から順に、学習前・20000万回・40000万回・60000万回・80000万回・100000万回学習した時点のニューラルネットワークによる分類結果です。

学習前は緑色の丸のみで、何も分類されていません。20000万回学習後だとかなり分類されていますが、x座標が-3や3のような端っこは、サインカーブの下に青の星があったり、サインカーブの上に緑の丸があったりします。その後学習が進むにつれて、x座標-3あたりのサインカーブ下の青い星が、緑の丸に変わっていき、x座標3あたりのサインカーブ上の緑の丸が、青い星に変わっていく様子がわかります。このように学習が進むにつれ、少しずつ正解に近づいていく様子がわかります。