JavaScriptだけでOK!シンプルな機械学習の実装&解説

2022/09/30
JSで機械学習

機械学習について勉強していたところ、誤差逆伝播法を使った機械学習についてわかりやすく解説されているサイトがあり、そちらを参考にコードをいじりながらいろいろと実験していました。

解説されているコードはPythonでしたが、せっかくならブラウザでも気軽に機械学習してみたい…!ということでJavaScriptバージョンを実装してみました。例のごとくバニラJavaScriptで、ライブラリ等は一切使っていません。

本記事では機械学習の実装を通して私が学んだことや詰まったポイントなどについて、できるだけ詳しく解説を交えながらご紹介します。

機械学習とは?

巷でよく聞くAIや機械学習ですが、そもそもそれらはどういったものなのでしょうか?

AIとは知的な機械やプログラムのことで、機械学習やディープラーニングを含む幅広い概念です。そのため、人間が何らかの数式や計算規則を大量に与えておくことで問題を解き、答えを返すようなものも(たとえ機械自身で学習と呼べる操作をしていなくても)AIと呼ばれます。

一方、機械学習とは与えられたデータをもとに誤差逆伝播法などの手法で機械自身が学習を行い、データの特徴を法則化(モデル化)していく学習手法です。人間側であれこれルールを与えるのではなく、機械自身が繰り返し学習を行うことで自ら精度を高めていくという点が大きな特徴です。

機械学習のすごい所は、まさに先ほど述べた"法則化"の力にあります。人間には一見してわからないデータの特徴を拾い上げて法則化し、未知のデータに対しても正確な予測を与えてくれます。

事前準備

実装に入る前に、機械学習を理解するうえで必要な知識について整理しておきます。詳しい説明は私が参考にさせて頂いた以下のサイトにもありますが、こちらでも1から説明します。

今回扱う機械学習のモデルはニューラルネットワークと呼ばれるもので、脳科学の知見をヒントに作られた学習モデルです。入力に応じて各ユニット(ニューロン)の活性度が変化し、それらの活性状態が伝播していくことで、入力データに対する出力結果が活性度の高低で表されます。

ニューラルネットワーク

各ニューロンの重みなどはランダム値で初期化されるため最初は当てずっぽう状態ですが、正解データ(教師データ)をもとに出力のずれを修正していくことにより、正確な出力ができるようになっていきます。

形式ニューロン

ニューラルネットワークにおける1つのニューロンを表すものとして、以下の形式ニューロンがあります。

形式ニューロン

各ニューロンは上の図のように外部から入力 $x_i$ を受け取り、それぞれに対して重み $w_i$ をかけ、その和 $\sum_{i=1}^n x_i w_i$ を活性化関数 $f$ にかけることで最終的な出力値 $o$ を出力します。

$i$ を最終的な入力値、$o$ を出力値とすると、1つのニューロンにおける計算式は以下のように表されます。 \begin{eqnarray} i &=& \sum_{i=1}^n x_i w_i \\ o &=& f(i) \end{eqnarray}

活性化関数

先ほど形式ニューロンの説明で"活性化関数"という言葉が出てきました。活性化関数とは、文字通りニューロンの活性化状態を計算する関数です。

活性化関数にはtanh関数やReLUなどいくつか種類がありますが、今回は参考記事と同じくシグモイド関数を使用します。シグモイド関数は以下の形で表される関数です。

\[ f(x) = \frac{1}{1 + e^{-\varepsilon x}} \]

今回の実装では $\varepsilon = 1.0$ とします。この場合のシグモイド関数のグラフは以下の通りです。

シグモイド関数

グラフを見て頂くとわかるように、シグモイド関数は $x$ が正方向に大きくなると $1$ に、負方向に大きくなると $0$ に限りなく近づいていくという性質をもっています。この性質により、入力値に応じて $0$ から $1$ の間の値を返します。これが活性度にあたるもので、入力値が大きいほど活性度が高く($1$ に近く)なります。

シグモイド関数を使う理由は、参考記事にもある通り微分可能な関数であり、誤差逆伝播法を利用するのに適しているからです。シグモイド関数を微分すると以下のようになります。

\[ f'(x) = \varepsilon (1 - f(x)) f(x) \]

このように、微分した値の中にもシグモイド関数が現れる形となっており、計算が簡単にできるというのも便利なポイントです。

誤差関数

機械学習において重要な要素の1つが、出力と正解データの誤差を示す誤差関数です。誤差関数の値が大きい=誤差が大きいということなので、誤差関数の値を最小化することが機械学習の大きな目的となります。

今回使用するのは「二乗誤差関数」と呼ばれる以下の形で表される誤差関数です。

\[ E = \frac{1}{2} \sum_{i=1}^{n_m} (t_i - o_i^m)^2 \]

ここで $o_i^m$ は最終層(第 $m$ 層)$i$ 番目ニューロンの出力値で、$t_i$ はそれに対応する教師データです。

二乗誤差関数の他に、クロスエントロピーと呼ばれる誤差関数が使われることもあります。

誤差逆伝播法

さて、いよいよ今回の機械学習の要となる誤差逆伝播法についてです。誤差逆伝播法は、誤差関数 $E$ を最小化するニューロン間の重み $w$ を計算する学習方法です。

誤差関数の値を小さくするために、勾配降下法(gradient descent)と呼ばれる手法を用います。勾配をもとに $E$ が最小となる方向へグイーンと落ちていくイメージなので、このような名前が付けられています。以下の図が勾配降下法の様子を表したものです。

勾配降下法

ここで $\frac{\partial E}{\partial w}$ は誤差関数 $E$ を重み $w$ で偏微分した値であり、ある $w$ の値における誤差関数$E$ の傾きを表しています。

微分記号のせいで難しそうに見えますが、考え方はとてもシンプル。要するに、ある点における誤差関数の傾きが負ならば正の方向へ、正ならば負の方向へ $w$ の値を近づけていくことで、(誤差関数の値が最小となるような)いい感じの重み $w$ が取れるんじゃね?ということです。

このことから、誤差逆伝播法における重みの更新式は以下のように表されます。

\[ \Delta w_{i,j}^{k-1,k} = - \eta \frac{\partial E}{\partial w_{i,j}^{k-1,k}} \] \[ \left(w_{i,j}^{k-1,k} = w_{i,j}^{k-1,k} - \eta \frac{\partial E}{\partial w_{i,j}^{k-1,k}}\right) \]

ここで $\eta$ は学習の進行速度を表す学習率、$w_{i,j}^{k-1,k}$ は 第 $k-1$ 層 $i$ 番目ニューロンと第 $k$ 層 $j$ 番目ニューロンの間の重みです。

ニューロン間の重み

今のままでは実装できないので、微分の連鎖率を使って $\Delta w_{i,j}^{k-1,k}$ の式をどんどん展開していきます。偏微分の記号がいっぱい出てきて面食らうかもしれませんが、基本はシンプルな式変形なので怖がらずについてきてください。では、早速やっていきましょう。

\begin{eqnarray} \Delta w_{i,j}^{k-1,k} &=& -\eta \frac{\partial E}{\partial w_{i,j}^{k-1,k}} \\ &=& -\eta \frac{\partial E}{\partial i_j^k} \frac{\partial i_j^k}{\partial w_{i,j}^{k-1,k}} \\ &=& -\eta \frac{\partial E}{\partial i_j^k} \frac{\partial \sum_{l=1}^{n_{k-1}} w_{l,j}^{k-1,k} o_l^{k-1}}{\partial w_{i,j}^{k-1,k}} \\ &=& -\eta \frac{\partial E}{\partial i_j^k} o_i^{k-1} \end{eqnarray}

ここで $\delta_j^k := -\frac{\partial E}{\partial i_j^k}$ (第 $k$ 層の $j$ 番目ニューロンの誤差)とおくと、

\[ \Delta w_{i,j}^{k-1,k} = \eta \delta_j^k o_i^{k-1} \]

と書き直すことができます。あとは $\delta_j^k$ を具体的に求めていきます。

\begin{eqnarray} \delta_j^k &=& -\frac{\partial E}{\partial i_j^k} \\ &=& -\frac{\partial E}{\partial o_j^k} \frac{\partial o_j^k}{\partial i_j^k} \\ &=& -\frac{\partial E}{\partial o_j^k} f'(i_j^k) \end{eqnarray}

ここからは最終層(第 $m$ 層)と中間層(第 $k$ 層)の場合に分けて計算を行います。

最終層の場合

誤差関数が \[ E = \frac{1}{2} \sum_{i=1}^{n_m} (t_i - o_i^m)^2 \] という形をしているので、 \[ \frac{\partial E}{\partial o_j^m} = o_j^m - t_j \] から $\delta_j^m$ は \[ \delta_j^m = -(o_j^m - t_j) f'(i_j^m) \] となります。

中間層の場合

$k \neq m$ なので \begin{eqnarray} \frac{\partial E}{\partial o_j^k} &=& \sum_{l=1}^{n_{k+1}} \left(\frac{\partial E}{\partial i_l^{k+1}} \frac{\partial i_l^{k+1}}{\partial o_j^k}\right) \\ &=& \sum_{l=1}^{n_{k+1}} \left(\frac{\partial E}{\partial i_l^{k+1}} \frac{\partial (\sum_{h=1}^{n_k} w_{h,l}^{k,k+1} o_h^k)}{\partial o_j^k}\right) \\ &=& \sum_{l=1}^{n_{k+1}} \left(\frac{\partial E}{\partial i_l^{k+1}} w_{j,l}^{k,k+1}\right) \\ &=& -\sum_{l=1}^{n_{k+1}} \left(\delta_l^{k+1} w_{j,l}^{k,k+1}\right) \end{eqnarray} であり、 \[ \delta_j^k = f'(i_j^k) \sum_{l=1}^{n_{k+1}} \left(\delta_l^{k+1} w_{j,l}^{k,k+1}\right) \] となります。

誤差逆伝播法の重み更新式

以上の結果から、シグモイド関数を使う場合の重みの更新式は

\[ \Delta w_{i,j}^{k-1,k} = \eta \delta_j^k o_i^{k-1} \]

・最終層(第 $m$ 層)のとき \[ \delta_j^m = -\varepsilon (o_j^m - t_j)(1 - o_j^m)o_j^m \] ・中間層(第 $k$ 層)のとき \[ \delta_j^k = \varepsilon (1 - o_j^k)o_j^k \sum_{l=1}^{n_{k+1}} \left(\delta_l^{k+1} w_{j,l}^{k,k+1}\right) \]

と求められます。見て頂くとわかるように、中間層では $\delta_j^k$ の更新式に1つ後ろの層の情報 $\delta_l^{k+1}$ が含まれています。そのため、最終層から誤差情報が逆向きに伝わり計算されていく…という形になっています。これが「誤差逆伝播法」という名前の由縁です。

実装&解説

事前準備が整ったので、ここからは実装についてお話ししていきます。

設定など

まず、今回実装したニューラルネットワークの設定などについて軽くご紹介します。

ネットワーク構成

今回のニューラルネットワークは参考記事と同じく誤差逆伝播法を用いたもので、活性化関数には $\varepsilon = 1.0$ のシグモイド関数を使用しています。

ネットワークは可変長引数により自由に構成を設定できるようになっており、今回の場合は入力層4ニューロン、中間層20ニューロン、出力層3ニューロンの計3層からなるネットワークになっています。

使用データセット

今回の機械学習で使用するデータセットは、有名なフィッシャーのirisデータセットです。

irisデータセットは3種類のあやめ(setosa, versicolor, verginica)をがく片の幅と長さ、花弁の幅と長さという4種類の特徴によって分類したデータセットで、以下のような150行5列のデータからなっています。

     sepal_length  sepal_width  petal_length  petal_width    species
0             5.1          3.5           1.4          0.2     setosa
1             4.9          3.0           1.4          0.2     setosa
2             4.7          3.2           1.3          0.2     setosa
3             4.6          3.1           1.5          0.2     setosa
4             5.0          3.6           1.4          0.2     setosa
..            ...          ...           ...          ...        ...
145           6.7          3.0           5.2          2.3  virginica
146           6.3          2.5           5.0          1.9  virginica
147           6.5          3.0           5.2          2.0  virginica
148           6.2          3.4           5.4          2.3  virginica
149           5.9          3.0           5.1          1.8  virginica

散布図行列はこちらのようになります。

散布図行列

上の図から、setosaは花弁の幅(petal_width)または長さ(petal_length)のみで簡単に判別できることがわかります。一方、versicolorとverginicaは重なっている部分がありますが、花弁の幅・長さが大きくなるほどverginicaの割合が多いという傾向のあるデータになっています。

今回の機械学習では、この4種類の特徴の数値データを与えたとき、どの種類のあやめかを当てられるようになってもらいます。

データの前準備

JavaScriptではirisデータセットをそのまま持ってくることができないので、以下のようにPythonで取得したirisデータセットを加工し、表示された結果をコピーしてJavaScript用のirisデータセットとします。

import seaborn as sns

df = sns.load_dataset('iris')
a = df.to_numpy()
data = []
for i in range(len(a)):
    data_tmp = []
    for j in range(len(a[i])):
        data_tmp.append(a[i][j])
    data.append(data_tmp)

print(data)

以下がJavaScript用のirisデータセットです。

コードを確認

実装コード

お待たせしました。参考記事をもとに作成したJavaScript版の実装コードがこちらです。

class Network {
  static rate = 0.1; // 学習率
  static decay = 0.1; // 学習率減衰
  static per = 50; // 学習率の減衰周期
  static epsilon = 1.0; // シグモイド関数の傾き

  constructor(...args) {
    this.layers = args;
    this.weights = []; // 各層間の重み
    this.patterns = []; // 入力パターン
    this.labels = []; // ラベルデータ
    this.outputs = []; // 各層の出力
    this.errors = []; // エラー率データ
  };

  // 重みの初期化
  init_weights(a = 0.0, b = 1.0) {
    for (let i = 0; i < this.layers.length; i++) {
      if (i + 1 >= this.layers.length) {
        break;
      }
      let mat = [];
      for (let j = 0; j < this.layers[i + 1]; j++) {
        let mat_tmp = [];
        for (let k = 0; k < this.layers[i]; k++) {
          mat_tmp.push((b - a) * Math.random() + a);
        }
        mat.push(mat_tmp);
      }
      this.weights.push(mat);
    }
  }

  // データ読み込み
  load_data(data) {
    let labels = data.map(a => a.pop());
    this.patterns = data;
    for (let i = 0; i < labels.length; i++) {
      if (labels[i] == 'setosa') {
        this.labels.push(0);
      } else if (labels[i] == 'versicolor') {
        this.labels.push(1);
      } else {
        this.labels.push(2);
      }
    }
  }

  // 入力と重みの行列積
  input_sum(inputs, weights) {
    return matMul(inputs, weights);
  }

  // シグモイド関数
  sigmoid(x) {
    return 1.0 / (1.0 + Math.exp(-Network.epsilon * x));
  }

  // 出力
  output(inputs, weights) {
    return this.sigmoid(this.input_sum(inputs, weights));
  }

  // 順方向処理
  forward(pattern) {
    this.outputs = []; // 全体の出力情報をクリア
    let out = []; // 前層ニューロンの出力
    this.outputs.push(pattern); // 入力層ニューロンの出力(入力パターン)を追加

    for (let i = 0; i < this.layers.length; i++) {
      out = [];
      if (i > 0) {
        for (let j = 0; j < this.layers[i]; j++) {
          out.push(this.output(this.outputs[this.outputs.length - 1], matT(this.weights[i - 1][j])));
        }
        this.outputs.push(out);
      }
    }

    return out;
  }

  // 誤差逆伝搬学習
  backward(idx) {
    let deltas = this.calc_delta(idx);
    for (let l = this.layers.length - 1; l > 0; l--) { // ネットワークを逆順処理
      for (let j = 0; j < this.outputs[l].length; j++) {
        for (let i = 0; i < this.outputs[l - 1].length; i++) {
          this.weights[l - 1][j][i] += Network.rate * deltas[l - 1][j] * this.outputs[l - 1][i]; // 重みを更新
        }
      }
    }
  }

  // δの計算
  calc_delta(idx) {
    let teacher = [0.1, 0.1, 0.1]; // 教師ニューロン
    teacher[this.labels[idx]] = 0.9; // 正解ラベルのニューロンの出力を0.9に
    let deltas = []; // 全ニューロンのδ

    for (let l = this.layers.length - 1; l > 0; l--) { // ネットワークを逆順処理
      let delta_tmp = [];
      for (let j = 0; j < this.layers[l]; j++) {
        if (l == this.layers.length - 1) {
          let delta = Network.epsilon * (teacher[j] - this.outputs[l][j]) * (1 - this.outputs[l][j]) * this.outputs[l][j];
          delta_tmp.push(delta);
        } else {
          let t_weights = matT(this.weights[l]);
          let delta = Network.epsilon * (1 - this.outputs[l][j]) * this.outputs[l][j] * matMul(deltas[deltas.length - 1], matT(t_weights[j]));
          delta_tmp.push(delta);
        }
      }
      deltas.push(delta_tmp);
    }

    return deltas.reverse();
  }

  train(epoch) {
    for (let i = 0; i < epoch; i++) {
      let error = 100 - this.test();
      for (let idx = 0; idx < this.patterns.length; idx++) {
        this.forward(this.patterns[idx]);
        this.backward(idx);
      }
      console.log((i + 1) + '/' + epoch + ' epoch.  [ error : ' + error + '% ]');
      if ((i + 1) % Network.per == 0) {
        Network.rate *= Network.decay;
      }
      this.errors.push(error);
    }
  }

  test() {
    let correct = 0;
    let last = this.layers.length - 1;
    for (let idx = 0; idx < this.patterns.length; idx++) {
      this.forward(this.patterns[idx]);
      let max = 0; // 最大出力値
      let ans = -1; // 最大出力値のラベル
      for (let i = 0; i < this.outputs[last].length; i++) {
        if (this.outputs[last][i] > max) {
          max = this.outputs[last][i];
          ans = i;
        }
      }
      if (ans == this.labels[idx]) { // ラベルが一致していれば正解
        correct++;
      }
    }
    let accuracy = correct / this.patterns.length * 100;
    return accuracy;
  }
}

// 行列のドット積
function matMul(m1, m2) {
  m1 = matFormat(m1);
  m2 = matFormat(m2);
  let m = [];
  for (let i = 0; i < m1.length; i++) {
    let m_tmp = [];
    for (let j = 0; j < m2[0].length; j++) {
      m_tmp.push(0);
    }
    m.push(m_tmp);
  }
  for (let i = 0; i < m1.length; i++) {
    for (let j = 0; j < m2[0].length; j++) {
      for (let k = 0; k < m2.length; k++) {
        m[i][j] += m1[i][k] * m2[k][j];
      }
    }
  }
  if (m.length == 1 && m[0].length == 1) {
    m = m[0][0];
  }
  return m;
}

// 行列の転置
function matT(m) {
  m = matFormat(m);
  let t_m =[];
  for (let j = 0; j < m[0].length; j++) {
    let m_tmp = [];
    for (let i = 0; i < m.length; i++) {
      m_tmp.push(m[i][j]);
    }
    t_m.push(m_tmp);
  }
  return t_m;
}

// 行列の整形
function matFormat(m) {
  if (m[0].length == undefined) {
    let a = [];
    a.push(m);
    m = a;
  }
  return m;
}

// 実行
let net = new Network(4, 20, 3);
net.init_weights(-1.0, 1.0);
net.load_data(iris);
net.train(300);
let acc = net.test();
console.log('Accuracy: ' + acc + '%');

内容は基本的に参考記事のものと同じですが、データの読み込み(と加工)や行列計算など、JavaScript独自の対応が必要な部分は専用のコードを加えています。

データ読み込み部分

読み込んだデータはそのままでは学習に使えないので、学習に適した形に加工します。

例えばirisデータセットの最初のデータは[5.1, 3.5, 1.4, 0.2, 'setosa']となっているので、[5.1, 3.5, 1.4, 0.2]部分は入力パターンthis.patternsに、'setosa'は0に変換してラベルデータthis.labelsに追加します。('versicolor'は1, 'verginica'は2に変換)

データ変換

これにより、もともと1つのデータを学習用の入力データthis.patternsとラベルデータthis.labelsに分けて扱うことができます。

行列計算

地味に苦労したのが行列計算の実装でした。PythonではNumPyをインポートしておけば行列計算もよしなにやってくれますが、JavaScriptはそうはいきません。

例えば[1, 2, 3]のような普通の配列の状態だと行数が取得できないので、行数・列数を正しく取得できるように以下のようなメソッドを作って整形してあげる必要があります。

function matFormat(m) {
  if (m[0].length == undefined) {
    let a = [];
    a.push(m);
    m = a;
  }
  return m;
}

上のコードは何をしているかというと、先ほど挙げた[1, 2, 3]のような1次元配列を2次元配列[[1, 2, 3]]に変換しています。こうすることで行数・列数をそれぞれm.length, m[0].lengthで取得できるようになるというわけです。ちょっと無理やり感のある方法ですが、上手くいっているので良しとします。

あとは行列のドット積が1×1行列の場合に中の数値のみを返すようにしたり、Pythonの行列計算に近い挙動になるように工夫しています。

各種データの違いに注意

添え字を見ていると混乱してくるのですが、各データは違った形をしているので注意してください。どういうことかというと、例えば各層間の重みthis.weightsと $\delta$ のdeltasは層の間に存在するので、全3層の場合は要素数が2個となります。一方、各層の出力this.outputsは各層のニューロンの出力値であるため、要素数は3個となります。

ニューラルネットワークの各データ

こういう部分、なんとなく見ているとハマりがちなので要注意です。

実行してみる

F12キーを押して開発者タブを開き、「Console」部分にirisデータセットと本体をまとめた以下のコードを貼り付けます。

コードを確認

ペタッ…

コンソール画像1

そしてEnterキーを押すと、その瞬間にリアルタイムで学習が行われ、学習後のテスト結果が表示されます。執筆時に試した際は画像のように精度約97.3%という結果になりました。

コンソール画像2

何回か試しましたが、参考記事とほぼ同じ精度97~98%程度で落ち着いています(^^b

ちなみに、学習の様子がわかりやすいようデモページを作ってみました。ページを開いた瞬間に先ほどのコードが実行されるので少し読み込みが遅れますが、エラー率の推移などが可視化されていて確認しやすくなっています。

機械学習デモページ

Python vs JavaScript

コンソールで実行してみて「おやっ」と思われた方もいるかもしれませんが、JavaScriptかなり速くないですか??Pythonは学習部分でわりともたついている印象なので、JavaScriptの速さがなおさら目につきます。

というわけで、それぞれの学習部分の実行時間を比較してみました。ちなみにPython版のコードは以下の通りです。(本家コードに一部変更を加えています。)

コードを確認

JavaScriptはperformance.now()、Pythonはtimeモジュールのperf_counter()で、それぞれnet.train(300)部分の処理時間のみを計測しました。5回計測した平均はJavaScriptが1.534秒、Pythonが14.234秒という結果になりました。JavaScriptの圧勝です(^^;

やはり、Pythonは柔軟な書き方ができて便利なぶん実行時間が犠牲になっているのでしょうか。他にもC言語などで実行時間の違いを試してみたいところですね。

あとがき

最初はちゃんと仕上がるか心配でしたが、無事形になってくれて良かったです。今回はシンプルなニューラルネットワークでしたが、今度はCNNなど他の機械学習モデルにも挑戦してみたいですね。

最後に、私が機械学習関連の勉強で観ていたMITのディープラーニング入門コースの講義動画をご紹介します。MITと聞くと難しそうなイメージがありますが、説明がわかりやすくて面白いのでおすすめです。興味がある方はチェックしてみてください。

MIT Deep Learning 6.S191

MIT's introductory course on deep learning methods and applications.

コメントはまだありません