2012/02/20

Processingで魔法使いの組分け帽子を作る

今日はちょっとしたパターン認識をやってみようと思います。あ、タイトルは釣りですよ。

とりあえずデモ動画をご覧ください。



こんな感じで、ある「特徴」を手掛かりにして正体をつきとめる処理は「パターン認識」と呼ばれており、文字認識などに応用されている技術です。

現在に至るまで、様々な認識手法(というか識別手法)が提案されていますが、今日はフィードフォワード型のニューラルネットワーク(3層パーセプトロン)にフォーカスを当てたいと思います。



【ニューラルネットワークの構成】

ここでは、ニューラルネットワークの理論的背景について、石井 健一郎ほか著『わかりやすいパターン認識』の 3 章に準拠して解説したいと思います。前提知識として、「パターン認識とは何か」という根本的な理解と、単純パーセプトロンを実装できる程度の能力があるとよいと思います。

まず、ニューロンと呼ばれる神経細胞の働きを、以下に示す閾値論理ユニットでモデル化します。これは、適当なウェイト \( \vect{w} = \left( w_0,\,\cdots,\,w_d\right)^t\) によって重みづけした入力信号 \(\vect{x} = \left(x_0,\,\cdots,\,x_d\right)^t\) の総和を基に、閾値処理を行う単純な仕組みです。出力 \(g_i(\vect{x})\) は 0 か 1 となります。

閾値論理ユニット

ニューラルネットワークは、複数の閾値論理ユニット(以下、ユニット)を含む層を、入力層から出力層まで多数並べたネットワークの事です。各ユニットは隣接する層とだけで結合しており、かつ入力層から出力層へ向かう一方向のネットワーク構成です(下図参照)。


ニューラルネットワークのある層における \(j\) 番目のユニットに注目し、これをユニット \(j\) と呼ぶ事にします。また、ユニット \(j\) の1階層前の \(i\) 番目のユニットをユニット \(i\)、1階層後ろの \(k\) 番目のユニットをユニット \(k\) と呼び、さらにユニット \(i\) からユニット \(j\) への結合の重みを \(w_{ij}\)、ユニット \(j\) からユニット \(k\) への結合の重みを \(w_{jk} \) で表します。

ここで、ネットワークにある学習パターン \(\vect{x}_p\) を入力したとき、ユニット \(i\) からの出力を \(g_{ip}\)、ユニット \(j\) への入力を \(h_{jp}\) とすると、\(h_{jp}\) は \(j\) に結合している1階層前の層内のユニットからの出力の重み付き信号の総和、つまり
\begin{align}
h_{jp}=\sum_{i}w_{ij}g_{ip}
\end{align}となります。

ユニット \(j\) の出力 \(g_{jp}\) は閾値関数 \(f_j\) を用いて
\begin{align}
g_{jp}=f_j(h_{jp})
\end{align}と表すものとします。



【誤差の定式化と重みの更新】

ニューラルネットワークを構築したら、理想的な識別結果が既知の入力データを用いて学習(重みの調整)を行う必要があります。

ある学習パターン \(\vect{x}_p\) に対する二乗誤差は、出力層のユニット \(l\) の出力 \( g_{lp} \) と、ユニット \(l\) に対する教師信号(理想的な識別結果)を \(b_{lp}\) を用いて、以下のように表す事ができます。
\begin{align}
J_p = \frac{1}{2}\sum_l \left( g_{lp} - b_{lp} \right)^2
\end{align}従って、全学習パターンに対する二乗誤差 \(J\) は
\begin{align}
J=\sum_p J_p
\end{align}となります。

識別性能を高めるためには、この誤差 \(J\) を最小化するようにユニット間の重みを調整する必要があります。つまり、ある重み \(w_{ij}\) に対して、以下の更新式を適用する事になります。
\begin{align}
w'_{ij} &= w_{ij} - \rho \frac{\partial J_p}{\partial w_{ij}} \\
&= w_{ij} - \rho \underbrace{ \frac{\partial J_p}{\partial h_{jp}}}_{\varepsilon_{jp}} \cdot \underbrace{\frac{\partial h_{jp}}{\partial w_{ij}}}_{g_{ip}} \\
&= w_{ij} - \rho \varepsilon_{jp}g_{ip}
\end{align}ここで \(\rho\) を学習係数と呼び、任意の正定数とします。

また、上式右辺の \(\varepsilon_{jp}\) は、合成関数の微分を用いて以下のように式変形できます。
\begin{align}
\varepsilon_{jp} &= \frac{\partial J_p}{\partial h_{jp}} = \frac{\partial J_p}{\partial g_{jp}} \cdot \frac{\partial g_{jp}}{\partial h_{jp}} \\
&= \frac{\partial J_p}{\partial g_{jp}} \cdot f'_j(h_{jp})
\end{align}なお、上式中の \( \dfrac{\partial J_p}{\partial g_{jp}} \) は、ユニット \(j\) が中間層か出力層かで以下のように場合分けされます。
\begin{align}
\frac{\partial J_p}{\partial g_{jp}} =
\begin{cases}
g_{jp} - b_{jp} & \textrm{(出力層)} \\
\sum_{k}\varepsilon_{kp}w_{jk} & \textrm{(中間層)}
\end{cases}
\end{align}


【閾値関数の選定】

最後に、閾値関数 \(f_j\) を考えます。ここまでの議論で、この関数が微分可能でなければならない事にはお気付きでしょう。

通常、\(f_j\) には以下のようなシグモイド関数 \(S(u)\) が選ばれます。
\begin{align}
S(u) = \frac{1}{1 + \exp(-u)}
\end{align}
この関数は、微分すると \(S'(u) = S(u)\left( 1 - S(u)\right)\) になりますから、\(f'_j\) は
\begin{align}
f'_j (h_{jp}) = g_{jp} (1 - g_{jp})
\end{align}となります。

以上を整理すると、\(\varepsilon_{jp}\) に関する再帰式を以下のように得る事ができます。
\begin{align}
\varepsilon_{jp} =
\begin{cases}
\left( g_{jp} - b_{jp} \right) g_{jp} \left( 1 - g_{jp} \right) & \textrm{(出力層)} \\
\left( \sum_k \varepsilon_{kp} w_{jk} \right) g_{jp} \left( 1 - g_{jp} \right) & \textrm{(中間層)}
\end{cases}
\end{align}上式において、\(0 < g_{jp} \left( 1 - g_{jp} \right) < 1\) であり、ユニットの出力値 \(g_{jp} \) が 0.5 のときに重みの修正量が最も大きく、0 か 1 に近づくほど修正量が小さくなります。

学習パターンが入力されると出力層で各ユニットの出力と教師信号の誤差が計算され、それに基づいて \(\varepsilon_{jp}\) が求められ、出力層での重みの修正が行われます。

この結果によって、1つ前の階層でも重みの修正が行われます。これを同様に繰り返す事で、すべての層で重みが修正されます。 このように、出力層から入力層に向かって誤差を伝播させる学習こそが、誤差逆伝播法と呼ばれる所以です。



【実装】

3層から成るフィードフォワード型ニューラルネットワークの実装は以下の通りです。こちらも、David M.Bourg, Glenn Seemann 著『ゲーム開発者のための AI 入門』を参考にしました(そこそこ長いので展開してご覧ください)
class NeuralNetworkLayer {
  int        numberOfNodes;       // 当該レイヤのノード数
  int        numberOfChildNodes;  // 子レイヤのノード数
  int        numberOfParentNodes; // 親レイヤのノード数
  
  double[][] weights;             // 親子レイヤを接続するノードの重み値
  double[][] weightChanges;       // 重み値の調整に使われる値
  
  double[]   neuronValues;        // 計算によって得られたニューロンの値
  double[]   desiredValues;       // 目的となるニューロンの値
  double[]   errors;              // 各ニューロンに関連するエラー値
  double[]   biasWeights;         // 各ニューロンに接続するバイアス重み
  double[]   biasValues;          // バイアス値 通常は +1 または -1
  double     learningRate;        // 重みの調整を計算する学習率
  
  boolean    linearOutput;        // 線型活性化関数を使用するかどうか
                                  // false ならロジスティック関数
  boolean    useMomentum;         // 重みの調整にモーメンタムを使用するかどうか
                                  // デフォルトはfalse
  double     momentumFactor;      // モーメンタム因数
  
  NeuralNetworkLayer parentLayer; // 親レイヤへの参照 入力層の場合は null
  NeuralNetworkLayer childLayer;  // 子レイヤへの参照 出力層の場合は null
  
  // コンストラクタ
  NeuralNetworkLayer() {
    parentLayer    = null;
    childLayer     = null;
    linearOutput   = false;
    momentumFactor = 0.9;
  }
  
  // 初期化メソッド
  void initialize( // int numNodes, 
                  NeuralNetworkLayer parent, 
                  NeuralNetworkLayer child) {

    neuronValues  = new double[numberOfNodes];
    desiredValues = new double[numberOfNodes];
    errors        = new double[numberOfNodes];
    
    if(parent != null) {
      parentLayer = parent;
    }
    
    if(child != null) {
      childLayer = child;
      
      weights       = new double[numberOfNodes][numberOfChildNodes];
      weightChanges = new double[numberOfNodes][numberOfChildNodes];

      biasValues = new double[numberOfChildNodes];
      biasWeights = new double[numberOfChildNodes];
      
    }
    
    // すべてに0が含まれるようにする
    for(int i = 0; i < numberOfNodes; i++) {
      neuronValues[i] = 0;
      desiredValues[i] = 0;
      errors[i] = 0;
      
      if(childLayer != null)
        for(int j = 0; j < numberOfChildNodes; j++) {
          weights[i][j] = 0;
          weightChanges[i][j] = 0;
        }
    }
    
    // バイアス値と重みを初期化する
    if(childLayer != null)
      for(int j = 0; j < numberOfChildNodes; j++) {
        biasValues[j]  = -1;
        biasWeights[j] = 0;
      }
  }
  
  void randomizeWeights() {
    for(int i = 0; i < numberOfNodes; i++) {
      for(int j = 0; j < numberOfChildNodes; j++) {
        weights[i][j] = random(-1.0, 1.0);
      }
    }
    
    for(int j = 0; j < numberOfChildNodes; j++) {
      biasWeights[j] = random(-1.0, 1.0);
    }   
  }
  
  void calculateNeuronValues() {
    if(parentLayer != null) {
      for(int j = 0; j < numberOfNodes; j++) {
        double x = 0;
        for(int i = 0; i < numberOfParentNodes; i++) {
          x += parentLayer.neuronValues[i] * parentLayer.weights[i][j];
        }
        x += parentLayer.biasValues[j] * parentLayer.biasWeights[j];
        
        if((childLayer == null) && linearOutput)
          neuronValues[j] = x;
        else
          neuronValues[j] = 1.0 / (1.0 + Math.exp(-x));
      }
    }
  }
  
  void calculateErrors() {
    if(childLayer == null) {
      // 出力層
      for(int i = 0; i < numberOfNodes; i++) {
        errors[i] = (desiredValues[i] - neuronValues[i]) *
                    neuronValues[i] * (1.0f - neuronValues[i]);
      }
    } else if (parentLayer == null) {
      // 入力層
      for(int i = 0; i < numberOfNodes; i++) {
        errors[i] = 0.0f;
      }
    } else {
      // 隠れ層
      for(int i = 0; i < numberOfNodes; i++) {
        double sum = 0;
        for(int j = 0; j < numberOfChildNodes; j++) {
          sum += childLayer.errors[j] * weights[i][j];
        }
        errors[i] = sum * neuronValues[i] * (1.0f - neuronValues[i]);
      }
    }
  }
  
  void adjustWeights() {
    if(childLayer != null) {
      for(int i = 0; i < numberOfNodes; i++) {
        for(int j = 0; j < numberOfChildNodes; j++) {
          double dw = learningRate * childLayer.errors[j] * neuronValues[i];
          if(useMomentum) {
            weights[i][j] += dw + momentumFactor * weightChanges[i][j];
            weightChanges[i][j] = dw;
          } else {
            weights[i][j] += dw;
          }
        }
      }
      
      for(int j = 0; j < numberOfChildNodes; j++) {
        biasWeights[j] += learningRate * childLayer.errors[j] * biasValues[j];
      }
    }
  }
}

class NeuralNetwork {
  NeuralNetworkLayer inputLayer;
  NeuralNetworkLayer hiddenLayer;
  NeuralNetworkLayer outputLayer;
  
  void initialize(int nNodesInput, int nNodesHidden, int nNodesOutput) {
    inputLayer  = new NeuralNetworkLayer();
    hiddenLayer = new NeuralNetworkLayer();
    outputLayer = new NeuralNetworkLayer();
    
    inputLayer.numberOfNodes       = nNodesInput;
    inputLayer.numberOfChildNodes  = nNodesHidden;
    inputLayer.numberOfParentNodes = 0;
    inputLayer.initialize(null, hiddenLayer);
    inputLayer.randomizeWeights();
    
    hiddenLayer.numberOfNodes       = nNodesHidden;
    hiddenLayer.numberOfChildNodes  = nNodesOutput;
    hiddenLayer.numberOfParentNodes = nNodesInput;
    hiddenLayer.initialize(inputLayer, outputLayer);
    hiddenLayer.randomizeWeights();
    
    outputLayer.numberOfNodes       = nNodesOutput;
    outputLayer.numberOfChildNodes  = 0;
    outputLayer.numberOfParentNodes = nNodesHidden;
    outputLayer.initialize(hiddenLayer, null);
  }
  
  void setInput(int i, double value) {
    if((i >= 0) && ( i < inputLayer.numberOfNodes)) {
      inputLayer.neuronValues[i] = value;
    }
  }
  
  double getOutput(int i) {
    if((i >= 0) && (i < outputLayer.numberOfNodes)) {
      return outputLayer.neuronValues[i];
    }
    
    return Double.MAX_VALUE;  // エラーを示す
  }
  
  void setDesiredOutput(int i, double value) {
    if((i >= 0) && (i < outputLayer.numberOfNodes)) {
      outputLayer.desiredValues[i] = value;
    }
  }
  
  void feedForward() {
    inputLayer.calculateNeuronValues();
    hiddenLayer.calculateNeuronValues();
    outputLayer.calculateNeuronValues();
  }
  
  void backPropagate() {
    outputLayer.calculateErrors();
    hiddenLayer.calculateErrors();
    
    hiddenLayer.adjustWeights();
    inputLayer.adjustWeights();
  }
  
  int getMaxOutputID() {
    double maxval = outputLayer.neuronValues[0];
    int id = 0;
    for(int i = 1; i < outputLayer.numberOfNodes; i++) {
      if(outputLayer.neuronValues[i] > maxval) {
        maxval = outputLayer.neuronValues[i];
        id = i;
      }
    }
    return id;
  }
  
  double calculateError() {
    double error = 0;
    
    for(int i = 0; i < outputLayer.numberOfNodes; i++) {
      error += Math.pow(outputLayer.neuronValues[i] - outputLayer.desiredValues[i], 2);
    }
    
    error = error / outputLayer.numberOfNodes;
    return error;
  }
  
  void setLearningRate(double rate) {
    inputLayer.learningRate  = rate;
    hiddenLayer.learningRate = rate;
    outputLayer.learningRate = rate;
  }
  
  void setLinearOutput(boolean useLinear) {
    inputLayer.linearOutput  = useLinear;
    hiddenLayer.linearOutput = useLinear;
    outputLayer.linearOutput = useLinear;
  }
  
  void setMomentum(boolean useMomentum, double factor) {
    inputLayer.useMomentum  = useMomentum;
    hiddenLayer.useMomentum = useMomentum;
    outputLayer.useMomentum = useMomentum;
    
    inputLayer.momentumFactor  = factor;
    hiddenLayer.momentumFactor = factor;
    outputLayer.momentumFactor = factor;
  }
  
  void dumpData(String filename) {
    List<String> data = new ArrayList<String>();
    data.add("--------------------------------------------------------");
    data.add("Input Layer");
    data.add("--------------------------------------------------------");
    data.add("");
    data.add("Node Values:");
    for(int i = 0; i < inputLayer.numberOfNodes; i++)
      data.add("(" + i + ") = " + inputLayer.neuronValues[i]);
    data.add("");
    data.add("Weights:");
    data.add("");
    for(int i = 0; i < inputLayer.numberOfNodes; i++)
      for(int j = 0; j < inputLayer.numberOfChildNodes; j++) 
        data.add("(" + i + ", " + j + ") = " + inputLayer.weights[i][j]);
    data.add("");
    data.add("Bias Weights:");
    for(int j = 0; j < inputLayer.numberOfChildNodes; j++)
      data.add("(" + j + ") = " + inputLayer.biasWeights[j]);
    
    data.add("");
    data.add("");
    
    data.add("--------------------------------------------------------");
    data.add("Hidden Layer");
    data.add("--------------------------------------------------------");
    data.add("");
    data.add("Weights:");
    data.add("");
    for(int i = 0; i < hiddenLayer.numberOfNodes; i++)
      for(int j = 0; j < hiddenLayer.numberOfChildNodes; j++) 
        data.add("(" + i + ", " + j + ") = " + hiddenLayer.weights[i][j]);
    data.add("");
    data.add("Bias Weights:");
    for(int j = 0; j < hiddenLayer.numberOfChildNodes; j++)
      data.add("(" + j + ") = " + hiddenLayer.biasWeights[j]);

    data.add("");
    data.add("");
    
    data.add("--------------------------------------------------------");
    data.add("Output Layer");
    data.add("--------------------------------------------------------");
    data.add("");
    data.add("Node Values:");
    for(int i = 0; i < outputLayer.numberOfNodes; i++)
      data.add("(" + i + ") = " + outputLayer.neuronValues[i]);
    data.add("");
    
    String[] strings = new String[data.size()];
    data.toArray(strings);
    saveStrings(filename, strings);
  }
}

で、使い方はこんな感じです。

// --------------------------------------
// ======================================
// ニューラルネットワークを用いた
//                  2クラス識別のサンプル
// ======================================
// --------------------------------------

// NeuralNetworkオブジェクトを宣言
NeuralNetwork theBrain;

// 学習用データセット
List<PVector> class1;
List<PVector> class2;

void setup() {
  size(400, 300);
  
  // ======================================
  // 学習用データ(教師信号)のセットアップ
  // ======================================
  class1 = new ArrayList<PVector>();
  class2 = new ArrayList<PVector>();
  
  for(int i = 0; i < 10; i++) {
    class1.add(new PVector(random(width / 2),        random(height)));
    class2.add(new PVector(random(width / 2, width), random(height)));
  }
  
  
  // ======================================
  // ニューラルネットワークのセットアップ
  // ======================================
  theBrain = new NeuralNetwork();
  
  // 入力層、隠れ層、出力層の数をそれぞれ指定
  theBrain.initialize(2, 10, 3);
  
  // 学習係数を0.2に設定
  theBrain.setLearningRate(0.2);
  
  // 局所解回避のためのモーメンタムを使用
  theBrain.setMomentum(true, 0.9);
  
  // ======================================
  // 学習
  // ======================================
  double error = 1;
  long c = 0;

  // 収束するか、一定の回数に達するまで反復
  while((error > 0.01) && (c < 50000)) {
    error = 0;
    c++;

    // ------------------------------------
    // クラス1の学習
    // ------------------------------------
    for(int i = 0; i < class1.size(); i++) {
      // 入力層に、インデックスとデータの組を与える
      // ※ データは必ず 0 ~ 1 の範囲
      theBrain.setInput(0, (float)class1.get(i).x / width);
      theBrain.setInput(1, (float)class1.get(i).y / height);
      
      // 出力層に、インデックスとデータの組を与える
      // ※ シグモイド関数を使用しているため、
      //      0.1 → 不活性
      //      0.9 → 活性
      //    となるようにデータを与える
      theBrain.setDesiredOutput(0, 0.9);
      theBrain.setDesiredOutput(1, 0.1);
      
      // 誤差の計算
      theBrain.feedForward();
      error += theBrain.calculateError();
      
      // 誤差逆伝播
      theBrain.backPropagate();
    }
    
    // ------------------------------------
    // クラス2の学習
    // ------------------------------------
    for(int i = 0; i < class2.size(); i++) {
      theBrain.setInput(0, (float)class2.get(i).x / width);
      theBrain.setInput(1, (float)class2.get(i).y / height);
      
      theBrain.setDesiredOutput(0, 0.1);
      theBrain.setDesiredOutput(1, 0.9);
      
      theBrain.feedForward();
      error += theBrain.calculateError();
      theBrain.backPropagate();
    }
    
    // 誤差の平均化
    error = error / (float)(class1.size() + class2.size());
  }  
}


void draw() {
  background(0);
  
  // ======================================
  // 識別境界の視覚化
  // ======================================
  for(int i = 0; i < width; i++) {
    for(int j = 0; j < height; j++) {
      
      // ------------------------------------
      // データ入力と識別
      // ------------------------------------
      theBrain.setInput(0, (float)i / width);
      theBrain.setInput(1, (float)j / height);
      theBrain.feedForward();
      
      // 識別結果の取得
      int ret = theBrain.getMaxOutputID();
      
      if(ret == 0)      stroke(0xFFFF7F7F);  // クラス1の場合
      else if(ret == 1) stroke(0xFF7F7FFF);  // クラス2の場合
      
      // 識別境界の表示
      point(i, j);
    }
  }
  
  // 学習データの表示
  pushStyle();
  strokeWeight(3);
  
  stroke(0xFFFF0000);
  for(PVector c1 : class1) point(c1.x, c1.y);

  stroke(0xFF0000FF);
  for(PVector c2 : class2) point(c2.x, c2.y);

  popStyle();
}

0 件のコメント:

コメントを投稿

ひとことどうぞφ(・ω・,,)