最もシンプルな「学習するプログラム」を実装してみた

f:id:shogonir:20190901135442p:plain

目次

  1. 最もシンプルな「学習するプログラム」
  2. 空前のAIブームと単純パーセプトロン
  3. ニューロン神経細胞
  4. 単純パーセプトロン
  5. パーセプトロンの出力を計算する
  6. AND演算ができるパーセプトロンを考える
  7. パーセプトロンの学習
  8. 実装
  9. 実際に学習させる
  10. まとめ

最もシンプルな「学習するプログラム」

今回この記事で実装するのは「単純パーセプトロン」というものです。
これは、生物の神経細胞ニューロン)を模倣した人工ニューロンで問題を解く手法です。
簡単に言うと、「人の脳を参考にすればなんか賢いことできるんじゃね?」ということです。

単純パーセプトロンは生物の脳と同じく学習することができます。
試行錯誤を繰り返し、失敗を補正することで徐々に正解を出せる様に成長します。

詳細は後ほど解説しますが、深くは踏み込まないようにしたいと思います。
あくまで「なんとなくAIのことを知りたい」方に向けた記事にします。
もっと踏み込んだことが知りたい方は、もう少し堅めの記事や論文を参照してください。

空前のAIブームと単純パーセプトロン

最近は空前のAIブームで、すでにAIが実用化されたサービスにも多く触れることができます。
実は、「今のAIブームは第3次AIブームである」ことをご存知でしたでしょうか。
今まで1, 2次のAIブームがあり、「これはダメだ」と諦めムードになった歴史があります。

しかし、ディープラーニングの発明により「やっぱりいける!」となっているのが今のブームです。
ディープラーニング機械学習の手法の1つで、根本的にはパーセプトロンを進化させたものです。

ニューロン神経細胞

単純パーセプトロンニューロンを模倣したものなので、
ここで先にニューロンの説明をしておきたいと思います。

ニューロンは下記の図のような構造になっています。

 

f:id:shogonir:20190901140916p:plain

 

樹状突起は、他のニューロンから電気信号を受け取るためのものです。
軸索は、他のニューロンに電気信号を送るためのものです。
この樹状突起と軸索でニューロンは繋がり、複雑なネットワークも構成できます。
人間の脳は、ニューロンが千数百億個もあつまって出来ていると言われています。

 

f:id:shogonir:20190901141941p:plain

 

ニューロンの仕事は、軸索で接続している次のニューロンに信号を送ることですが、
常に次のニューロンに信号を送るわけではありません。
樹状突起から受け取った信号が特定のパターンのときに、信号を送ります。

しかもニューロンによってこのパターンは異なり、
同じニューロンでも時間の経過によってパターンやネットワークが変化します。

このパターンやネットワークの変化こそが、学習であると言われています。
例えば、われわれが年号を覚えられるのも、XXXX年という入力に対して、
〇〇の乱を連想できるように、ニューロンのパターンやネットワークが変化しているのです。

さて、ニューロンの話が長くなってしまいました。
なんとなく動物の学習がどのように行われているのかの、
イメージが湧くようになっていたら幸いです。
そろそろこのニューロンを模倣したパーセプトロンの話に移りましょう。

単純パーセプトロン

単純パーセプトロンは1つのニューロンを模倣したものです。
図にすると以下のようになります。

 

f:id:shogonir:20190901142955p:plain

 

図を見ると、ニューロンに似ているのがわかると思います。
似ていますが、飽くまで模倣ですので相違点もあります。

ニューロンは電気信号を入力・出力しますが、
パーセプトロンが入力・出力するのは数値です。

パーセプトロンが出力する数値は、重みと呼ばれる数値から計算します。

さて、パーセプトロンニューロンを比較しました。
なんとなくパーセプトロンのことが分かってきたと思います。
入力された数値から出力する数値を計算する部品のようなものです。

さて次は、パーセプトロンが出力を計算する方法を説明します。

パーセプトロンの出力を計算する

パーセプトロンの出力の計算方法はすごく単純です。
まず入力に重みをかけて足していき、最後に0から1の間になるように調整するだけです。
この最後の調整に使う関数を活性化関数と言います。

まずは入力に重みをかける部分を説明します。
パーセプトロンは入力をいくつ受け取るかは自由なので、
ここでは入力をN個受け取ることにします。
入力は {x_1, x_2, x_3, ... , x_N} と書くことにします。
入力に対応する重みを {w_1, w_2, w_3, ... , w_N} と書くことにします。
すると入力に重みをかけて足したaは以下の様に計算します。

{} $$ a = x_1 w_1 + x_2 w_2 + x_3 w_3 + ... + x_N w_N $$

{x_1 w_1} と書いているのは {x_1}{w_1} の積、掛け算です。
なので、これはシグマ記号でも表現できますね。

{} $$ a = \sum_{i=1}^{N}x_i w_i $$

ここで計算したaは1よりも大きくなる可能性があるので、
次に活性化関数で調整する部分を説明します。
活性化関数は何種類もあるのですが、ここでは標準シグモイド関数を紹介します。

{} $$ f(x) = \frac{1}{1 + e^{-x}} $$

 

f:id:shogonir:20190910230514p:plain

 

これで入力から出力が計算できるようになりました。
パーセプトロンについては、あと学習する方法を説明するだけになりました。
ですがここで一旦、このパーセプトロンでAND演算ができるか考えます。
その方が学習の説明が理解しやすくなると思います。

AND演算ができるパーセプトロンを考える

AND演算というと大層に聞こえますが、ようは「AかつB」ということです。
yesとyesを受け取ったらyes、noが含まれたらnoを返せばよいのです。

パーセプトロンは数値をうけとって数値を出力するので、yesやnoを扱えません。
そこで、yesを1、noを0と置き換えてパーセプトロンに渡すことにします。
パーセプトロンに1と1を渡して(ほぼ)1を返し、0と何かを渡して(ほぼ)0を返せば、
そのパーセプトロンはAND演算をマスターしたと言えるでしょう。

AND演算は2つの値を渡すので、{N = 2}となりそうですが、そうすると問題があります。
計算するとわかるのですがNが2だと、入力がnoとnoの時に絶対に0.5しか出力できません。
入力が両方0になると、 {a=0} となり、 {f(a)=0.5} となってしまいます。
これでは、出力がyesなのかnoなのかわかりにくいので、 {N = 3} として、
常に入力が1になる {x_3} を考えて解決しようと思います。

たとえば {w_1 = 0, w_2 = 0, w_3 = 0}パーセプトロンについて考えます。
yesとyesを渡した時の出力がいくつになるか計算してみます。
まずaは {a = x_1 w_1 + x_2 w_2 + x_3 w_3 = 0}{a = 0} です。
出力yは {y = f(0) = 0.5}{y = 0.5} となりました。
他のどんな入力に対しても出力は0.5(yesかnoかわからない)になります。
つまり、全ての重みが0のパーセプトロンはAND演算ができません。
また、{w_3} が3だと、せっかく導入した {x3} を活かせないこともわかります。

次に {w_1=1, w_2=1, w_3=-1.5}パーセプトロンについて考えます。
yesとyesを渡した時の出力を計算してみます。
まずaは {a = x_1 w_1 + x_2 w_2 + x_3 w_3 = 0.5} となります。
出力yは {y = f(0.5) \fallingdotseq 0.6225} となります。
これはどちらかというと1(yes)に近いので、答えはyesです。
さっきよりはいいパーセプトロンになっています。

他の組み合わせの入力に対して0(no)を返せればOKです。
noとnoを渡した時は、 {a = -1.5, y = 0.1824} でnoとなります。
yesとnoを渡した時は、 {a = -0.5, y = 0.3775} でnoとなります。
noとyesを渡した時は、 {a = -0.5, y = 0.3775} でnoとなります。
これで {w_1=1, w_2=1, w_3=-1.5}パーセプトロン
AND演算を計算できることが分かりました。

このように、最適な重みを計算するのは結構面倒です。
パーセプトロンのすごいところは、この重みを勝手に探してくれるところです。
次に、パーセプトロンの学習について説明します。

パーセプトロンの学習

パーセプトロンはその重みを調整することで学習します。
学習するためには、入力の例とその時の正しい出力が必要になります。
このデータのことを学習データといいます。

学習データをもらった時に、重みをどの程度修正するかは計算で求めます。
学習データの入力から出力を計算して、正解の出力と差を計算します。
ほかにも、入力の値や活性化関数の微分も加味して修正量を決定します。

まず学習データを次の様にまとめます。

{} $$ \boldsymbol{x} = \left[ \begin{array}{ccc} 0 & 0 & 1 \\
0 & 1 & 1 \\
1 & 0 & 1 \\
1 & 1 & 1 \\
\end{array} \right] \quad $$

{} $$ \boldsymbol{y} = \left[ \begin{array}{c} 0 \\
0 \\
0 \\
1 \\
\end{array} \right] \quad $$

{x_{ij}} と書いた時は、{x} の上から {i} 行目 左から {j} 列目の要素を指します。
{y_i} と書いた時は、 {y} の上から {i} 行目の要素を指します。

{} $$ a_i = \sum_{j=1}^{3} x_{ij} w_j = x_{i1} w_1 + x_{i2} w_2 + x_{i3} w_3 $$

{a_i}{x}{i} 行目の入力から計算した重みとの積和です。
これを活性化関数に通すことでパーセプトロンの出力になります。

これでやっと重みの修正量の計算を説明できます。

{} $$ w_j \leftarrow w_j - \alpha\sum_{i=1}^{4}\left(y_i^{'} - y_i\right) f^{'}(a_i) x_{ij} $$

{\alpha} は学習率で、0.1とか0.5とかを指定します。
{y^{'}} は今のパーセプロトンで計算した出力です。
{f^{'}(a)} は活性化関数の微分{a} を通して計算した値です。

なぜこの数式になるのかが気になる方は別で記事を投稿しましたので、
よければそちらを是非参照してください。

blog.shogonir.jp

{} $$ f^{'}(x) = f(x) \left( 1 - f(x) \right) $$

この重みの修正を繰り返すことで正解を出せる様になります。
というわけで、ついに実装を始めたいと思います。

実装

ではパーセプトロンを実装します。
言語は最近個人的に気に入っているTypeScriptを使います。

レポジトリはこちら。

github.com

まずは Neuron を実装します。

import Numbers from "../utils/Numbers";
import MathUtil from "../utils/MathUtil";

export default class Neuron {

  numberOfInputs: number

  weightList: number[]

  constructor(numberOfInputs: number) {
    this.numberOfInputs = numberOfInputs
    this.weightList = Numbers.all(0, numberOfInputs)
  }

  calculate(input: number[]): number {
    const sum = this.sumProducts(input)
    return MathUtil.normalSigmoid(sum)
  }

  private sumProducts(input: number[]): number {
    let sum = 0
    input.forEach((value, index) => {
      sum += value * this.weightList[index]
    })
    return sum
  }

  train(inputData: number[][], outputList: number[], times: number, learningRate: number = 0.1): void {
    Numbers.range(0, times).forEach(() => this.oneTrain(inputData, outputList, learningRate))
  }

  private oneTrain(inputData: number[][], outputList: number[], learningRate: number) {
    const deltaBaseList: number[] = []
    inputData.forEach((input: number[], index: number) => {
      const calculatedOutput = this.calculate(input)
      const correctOutput = outputList[index]
      const sumProducts = this.sumProducts(input)
      const differentiatedOutput = MathUtil.differentiatedNormalSigmoid(sumProducts)
      deltaBaseList.push((calculatedOutput - correctOutput) * differentiatedOutput)
    })

    this.weightList.forEach((weight: number, weightIndex: number) => {
      let sum = 0
      inputData.forEach((input: number[], inputIndex: number) => {
        sum += deltaBaseList[inputIndex] * input[weightIndex]
      })
      this.weightList[weightIndex] = weight - (sum * learningRate)
    })
  }
}

calculate() は入力か出力を計算するメソッドです。
private sumProducts() は入力と重みの積和を計算します。
train() は試行回数と学習率を指定して学習させるメソッドです。
private oneTrain() は一回の重み更新を行うメソッドです。

こちらを実装するために Numbers, MathUtil も実装しました。

export default class Numbers {

  static range(start: number, stop: number, step: number = 1) : number[]{
    const range = []
    for (let i = start; i < stop; i += step) {
      range.push(i)
    }
    return range
  }
  
  static all(value: number, length: number): number[] {
    const all = []
    Numbers.range(0, length).forEach(() => all.push(value))
    return all
  }
}
export default class MathUtil {

  static normalSigmoid(x: number): number {
    return 1 / (1 + Math.pow(Math.E, -x))
  }

  static differentiatedNormalSigmoid(x: number): number {
    return MathUtil.normalSigmoid(x) * (1.0 - MathUtil.normalSigmoid(x))
  }
}

実際に学習させる

ついに実際に学習させてみます。
ソースコードはこちら。

import Neuron from "./models/Neuron";

const neuron: Neuron = new Neuron(3)
const inputData: number[][] = [
  [1, 0, 0],
  [1, 0, 1],
  [1, 1, 0],
  [1, 1, 1]
]
const outputList: number[] = [
  0,
  0,
  0,
  1
]

inputData.forEach((input) => {
  console.log(`${JSON.stringify(input)} -> ${neuron.calculate(input)}`)
})

console.log()
console.log('training...')

neuron.train(inputData, outputList, 1000, 0.5)

console.log('complete training')
console.log()

inputData.forEach((input) => {
  console.log(`${JSON.stringify(input)} -> ${neuron.calculate(input).toFixed(4)}`)
})

console.log('finally weight is as below')
console.log(neuron.weightList)

inputData, outputList は学習データです。
inputData には常に入力が1になる1列目も準備しています。

学習は試行回数1000回、学習率は0.5としました。
学習前と学習後で、入力を実際に計算させた結果を表示しています。
これを実行した際の出力結果は下記の様になります。

[1,0,0] -> 0.5
[1,0,1] -> 0.5
[1,1,0] -> 0.5
[1,1,1] -> 0.5

training...
complete training

[1,0,0] -> 0.0009
[1,0,1] -> 0.0817
[1,1,0] -> 0.0817
[1,1,1] -> 0.9025
finally weight is as below
[ -7.064355312177234, 4.644645834118489, 4.644645834118489 ]

最初は重みが全て0なので、出力が全て0.5になっていますが、
学習が完了するとちゃんとAND演算ができていることが分かります。
最終的な重みは {(w_1, w_2, w_3) = (-7.064, 4.644, 4.644)} になりました。

実装的な話でいうと、もう少し依存性注入とか設計を綺麗にしたいです。

まとめ

今回はパーセプトロンを実装しました。
今回はただAND演算が可能であることを確認しただけでしたが、
このパーセプトロンが進化した技術が今のAIブームを支えています。

機械学習ってどうやって学習しているんだろう?」と思っていた人の 疑問が少しでも解消されたのであれば幸いです。

このシリーズで、多重パーセプトロンでXORを解くというのも書いてみたいと思います。