白猫のメモ帳

C#とかJavaとかJavaScriptとかHTMLとか機械学習とか。

単純パーセプトロンをJavaで作る その③

こんばんは。

関東甲信越も梅雨明けして、いよいよ夏本番ですね。
学生さんは夏休みを満喫していますでしょうか?

さて、単純パーセプトロンについての記事の3つ目です。

その① 機械学習の基本とパーセプトロンでできること
その② 単純パーセプトロンの仕組みを簡単に解説
その③ 単純パーセプトロンJavaでの実装    ←今回はコレ

仕組みについては前回確認したので、さっそくコードを見ていきましょう。

実装


実行にはこの記事から学習機の実装以外を用意してください。

public class SinglePerceptron implements LearningMachine {

    /** 認識したパターン */
    private final List<LearningData> learningDataList = new ArrayList<>();
    
    /** 最大更新回数 */
    private int maxIteration = 1000;
    
    /** 重みベクトル(式中の記号だとwと書かれることが多い) */
    private double[] weight;
    
    /** 学習率 */
    private final double learningRate = 0.3;
    
    /**
     * コンストラクタ
     * 
     * @param featureSize 入力(ベクトル)の次元
     */
    public SinglePerceptron(int featureSize) {
        this.weight = new double[featureSize + 1];  // +1はバイアスの重み
    }
    
    /**
     * 最大更新回数を設定
     */
    public SinglePerceptron setMaxIteration(int maxIteration) {
        this.maxIteration = maxIteration;
        return this;
    }
    
    /**
     * 教師データを追加
     */
    @Override
    public void add(int lavel, double[] feature) {
        this.learningDataList.add(new LearningData(lavel, feature));
    }
    
    /** 
     * 学習 
     */
    @Override
    public void learn() {
        
        // 非線形分離の場合、解なしなので上限あり
        for (int j = 0; j < maxIteration; j++) {
            
            boolean change = false;
            for (LearningData ld : this.learningDataList) {
                
                // 入力ベクトルをスケーリングしてバイアスを足す
                double[] input = this.addBias(this.scaling(ld.feature));

                // 出力してみる
                int answer = this.sign(this.dot(input, this.weight));
                
                // 出力とラベルが一致していれば更新する必要はない
                if (answer == ld.lavel) {
                    continue;
                }
                
                // 重みベクトルを更新
                for (int i = 0; i < this.weight.length; i++) {
                    this.weight[i] += this.learningRate * ld.lavel * input[i];
                }
                change = true;
            }
            
            // すべての重みが更新されなくなったら終了
            if (!change) {
                return;
            }
        }
        
        System.out.println("解なし");
    }
    
    /**
     * 評価
     */
    @Override
    public int predict(double[] feature) {
        return this.sign(this.dot(this.addBias(this.scaling(feature)), this.weight));
    }
    
    /**
     * 内積の計算
     */
    private double dot(double[] x, double[] y) {
        double res = 0;
        for (int i = 0; i < x.length; i++) {
            res += x[i] * y[i];
        }
        return res;
    }
    
    /**
     * 活性化関数
     */
    private int sign(double val) {
        return val >= 0 ? 1 : -1;
    }

    /**
     * 描画する
     */
    @Override
    public void draw(GraphicsContext gc) {
        
        int w = (int) gc.getCanvas().getWidth();
        int h = (int) gc.getCanvas().getHeight();
        
        for (int x = 0; x < w; x += 2) {
            for (int y = 0; y < h; y += 2) {
                int ans = this.predict(new double[]{x, y});
                gc.setFill(ans > 0 ? Color.BLUE : Color.RED);
                gc.fillOval(x, y, 1, 1);
            }
        }
    }
    
    /**
     * 特徴量をスケーリングする
     * 
     * <pre>
     * 手抜きスケーリング。
     * </pre>
     */
    private double[] scaling(double[] feature) {
        double[] res = new double[feature.length];
        for (int i = 0; i < feature.length; i++) {
            res[i] = feature[i] / 200 - 1;
        }
        return res;
    }
    
    /**
     * バイアスを追加
     * 
     * <pre>
     * バイアスは前でも後ろでもいい。
     * </pre>
     */
    private double[] addBias(double[] feature) {
        double[] d = new double[feature.length + 1];
        System.arraycopy(feature, 0, d, 0, feature.length);
        d[d.length - 1] = 1;
        return d;
    }

    /**
     * この学習機をリセット
     */
    @Override
    public void reset() {
        this.learningDataList.clear();
        this.weight = new double[this.weight.length];
    }
    
    /**
     * タイトル
     */
    @Override
    public String getTitle() {
        return "単純パーセプトロン";
    }
    
    /**
     * 学習データ
     */
    private static class LearningData {
        
        /** 分類ラベル(式中の記号だとtと書かれることが多い) */
        final int lavel;
        
        /** 特徴量(式中の記号だとxと書かれることが多い) */
        final double[] feature;
        
        /**
         * コンストラクタ
         * 
         * @param lavel     分類ラベル
         * @param feature   特徴量
         */
        LearningData(int lavel, double[] feature) {
            this.lavel = lavel;
            this.feature = feature;
        }
    }
}

認識したパターンは前回まではPairで持っていましたが、わかりづらいのでLearningDataというクラスにしました。

実行してみる


作ったものを実行してみます。

まずは線形分離可能。

f:id:Shiro-Neko:20160730153917p:plain

うまく分類できていますね。
また、一次関数近似なので直線で分類されていることが特徴です。

次に線形分離不可。

f:id:Shiro-Neko:20160730154236p:plain

全然だめですね。
単純パーセプトロンでは無理な問題なのでしょうがないです。

スケーリングって?


実装コードを見てみるとscalingという関数があって、特徴量に手を加えています。
これは何をしているのでしょうか?

コメントに「手抜きスケーリング」と書いていますが、特徴量の数値をすべて200で割って1を引いています。
これは特徴量の範囲が0~400なので、すべてのデータを-1~1の間に収まるようにする作業です。

何故このようなことをするかというと、バイアス値が1で固定なため、
そのままだと [183, 310, 1] のような特徴量になります。

重みの更新式は {w_n := w_n + ρtx_n}で、特徴量に比例するので、
バイアス項以外はあっという間に収束するにもかかわらず、
バイアスがいつまでたっても収束しないという事態に陥ります。

ちょっと例を見てみましょう。

横軸がループ回数、縦軸が重みです。
データは赤と青が入力の重み、緑がバイアスの重みです。

まずは1つ目の例ですが、これはちゃんとスケーリングを行った場合の重みの変位です。

f:id:Shiro-Neko:20160730155628p:plain

それぞれちょっとガタガタしながら40回程度で収束しました。

次に2つ目の例ですが、これはスケーリングを行っていない場合の重みの変位です。

f:id:Shiro-Neko:20160730155830p:plain

入力の重みはすぐに収束しているのですが、バイアス項の重みがなかなか収束せず、
1000回ループしても解が見つけられませんでした。

じゃあバイアスを大きくすればいいんじゃないの?と思うかも知れませんが、
特徴量1が0~1000、特徴量2が-0.5~0.5のように、特徴量によってその範囲が異なる場合もあります。
その場合にはそれぞれの特徴量の取りうる値の範囲の合わせてスケーリングをしなければなりません。

本来はここで手抜きをせずにちゃんとスケーリングをした方がいいのですが、
スケーリング部分のコードが長くなってしまいそうだったので、今回は手抜きにしました。すみません。

ちなみにスケーリングの方法としては、
 {
x_{scaling} = \frac{(x_{avg} - x)}{(x_{max} - x_{min})} \\
}
 {
x_{avg} : xの平均値 \\
x_{max} : xの最大値 \\
x_{min} : xの最小値
}

あたりが無難かと思われます。
言語によってはデフォルトで実装されているみたいですね。


さて、3回にわたって単純パーセプトロンについて説明してきましたが、いかがでしたでしょうか?

それではまた。