🧠

Typescriptでゼロから作るニューラルネット

12 min read

1.はじめに

作る経験はコピーできない。技術書を読むだけではわからなかったことが、「実際に手を動かし、作る」という作業を経て、一本の線がつながるように理解できるようになった。そんな経験は、エンジニアであれば誰もが一度はあると思います。

タイトルにもあるとおり、今回取り組んだことはTypescriptによるニューラルネットのスクラッチ実装( データセットはMNIST )です。最近、機械学習の理論的背景をより深く理解したいなぁ、と思っていたので、勉強がてらやってみることにしました。以下は実装したリポジトリになります。

https://github.com/YadaYuki/neural_net_typescript

2.ゼロから作るDeep Learning

リポジトリのREADMEにも記述されていますが、今回実装したニューラルネットの実装・設計は「ゼロから作るDeep Learning」に掲載されているPythonによるニューラルネットの実装に強く影響を受けています。

ここで、蛇足かもしれませんが「ゼロから作るDeep Learning」という書籍に関して説明していきます。

https://www.oreilly.co.jp/books/9784873117584/

ゼロから作るDeep Learning、通称「ゼロD」は2021年9月時点で発行部数が20万を突破するなど、技術書としては異例の人気を誇っています。

この本のコンセプトは外部のライブラリに頼らずに、Python 3によってゼロからディープラーニングを作ること。Pythonの文法や数学的な基礎知識から始まり、本書全体を通して、DNNやCNNといったアルゴリズムをゼロから作ることができるように構成されています。

本書は、僕のように新しい技術を習得する際、実際に手を動かした方が効率良く学べるというようなタイプにはうってつけです。この本を写経するだけでも、多くの知見が得られると思います。しかし、それを公開したところで全く面白くありませんし、ただ写経するというのは漫然となりがちで、より多くの知見を得るためには、もっと応用的なことに取り組む必要があります。

「この本の内容をより深く理解するためにはどうすれば良いか...」考えた末に出した結論は 機械学習のデファクトであるPython以外の言語を用いてニューラルネットの自作をすること でした。そして、今回採用したのはTypescriptです。

3.なぜTypescript?

ここでは使用言語として、Typescriptを採用した理由についてです。

フロントエンドの開発をすることが多いので、使い慣れているというのも大きな理由の一つですが、他の理由としては

  • 行列演算における次元周辺のミスを型によって静的に回避したかった。
  • オブジェクト指向な実装が可能であり、Deep Learningとの相性が良い。
  • numjsやtensorflow.js,math.jsなど行列演算が可能なライブラリが多く存在する(今回はnumjsを採用)

といった点が挙げられます。

以上の背景から、Typescriptを使用することを決定し、全結合型の二層ニューラルネットワークと畳み込みニューラルネットワークという2種類のニューラルネットの実装に取り組みました。次章以降ではそれぞれの構造・実装・実行結果について、紹介していきます。

4.二層ニューラルネットワーク(TwoLayerNet)

まず、二層ニューラルネットワークについてです。今回実装した二層ニューラルネットは以下のような構造をしています。

二層ニューラルネットワーク

m=50の隠れ層が一つあるのみという非常にシンプルな構造の全結合型ニューラルネットワークです。なお、活性化関数は中間層はRelu関数、出力層はSoftmax関数を用いています。

  • Relu
    y = \left\{\begin{array}{ll}x & (x \gt 0) \\0 & (x \leq 0)\end{array}\right.

  • Softmax
y_k = \frac{\exp(a_k)}{\sum_{i=1}^{n} \exp(a_i)}

次に、誤差関数ですが、こちらは交差エントロピー誤差(Cross Entropy Error)を用いています。

  • Cross Entropy Error
E = - \sum_{k} t_k \log y_k

それではTypescriptによるTwoLayerNetの実装を見てみましょう。

import nj from 'numjs';
import { Layer } from '../layers/base';
import { Affine } from '../layers/affine';
import { Relu } from '../layers/relu';
import { SoftmaxWithLoss } from '../layers/softmaxWithLoss';
import { softmax, softmaxBatch } from '../utils/activation';

export class TwoLayerNet {
  W1: nj.NdArray<number[]>;
  b1: nj.NdArray<number>;
  W2: nj.NdArray<number[]>;
  b2: nj.NdArray<number>;
  layers: Layer[];
  lossLayer: Layer;

  constructor(inputSize: number, hiddenSize: number, outputSize: number) {
    
    // 入力層 → 中間層 への重み行列・バイアスベクトル
    this.W1 = nj
      .random([inputSize * hiddenSize])
      .multiply(0.01)
      .reshape(inputSize, hiddenSize) as nj.NdArray<number[]>;
    this.b1 = nj.zeros([hiddenSize]);

    // 中間層 → 出力層 への重み行列・バイアスベクトル
    this.W2 = nj
      .random([hiddenSize * outputSize])
      .multiply(0.01)
      .reshape(hiddenSize, outputSize) as nj.NdArray<number[]>;
    this.b2 = nj.zeros([outputSize]);
    
    // ニューラルネット本体
    this.layers = [
      new Affine(this.W1, this.b1),
      new Relu(),
      new Affine(this.W2, this.b2),
    ];
    this.lossLayer = new SoftmaxWithLoss();
  }

  // 予測
  predict(x: nj.NdArray<number>): nj.NdArray<number> {
    let output = x;
    for (const layer of this.layers) {
      output = layer.forward(output);
    }
    return softmax(output);
  }

  // 順伝搬
  forward(xBatch: nj.NdArray<number[]>, tBatch: nj.NdArray<number[]>): number {
    let scoreBatch: nj.NdArray<number[]> = xBatch;
    for (const layer of this.layers) {
      scoreBatch = layer.forwardBatch(scoreBatch);
    }
    const loss = this.lossLayer.forwardBatch(scoreBatch, tBatch);
    return loss;
  }

  // 逆伝搬
  backward(): void {
    let dout: nj.NdArray<number[]> = this.lossLayer.backwardBatch();
    const reversedLayers = this.layers.slice().reverse();
    for (const layer of reversedLayers) {
      dout = layer.backwardBatch(dout);
    }
  }

  // パラメータの更新
  update(learningRate = 0.1): void {
    const { dW1, db1, dW2, db2 } = this.gradient();
    this.W1 = this.W1.subtract(dW1.multiply(learningRate));
    this.b1 = this.b1.subtract(db1.multiply(learningRate));
    this.W2 = this.W2.subtract(dW2.multiply(learningRate));
    this.b2 = this.b2.subtract(db2.multiply(learningRate));
    (this.layers[0] as Affine).W = this.W1;
    (this.layers[0] as Affine).b = this.b1;
    (this.layers[2] as Affine).W = this.W2;
    (this.layers[2] as Affine).b = this.b2;
  }

  // 各パラメータの勾配を返す関数
  gradient(): {
    dW1: nj.NdArray<number[]>;
    db1: nj.NdArray<number>;
    dW2: nj.NdArray<number[]>;
    db2: nj.NdArray<number>;
  } {
    const affine1 = this.layers[0] as Affine;
    const affine2 = this.layers[2] as Affine;
    return {
      dW1: affine1.dW,
      db1: affine1.db,
      dW2: affine2.dW,
      db2: affine2.db,
    };
  }
}

TwoLayerNetにmnistのデータを学習させてみます。学習には以下のコマンドを実行します。

$ yarn learn:tnn

学習用のコードはここでは省略しますが、n=100(バッチサイズ)、\alpha=0.1(学習率)という条件によるミニバッチ学習を行っており、100イテレーションごとにその時点での誤差関数の出力を表示するようにしています。それでは実行結果を見てみましょう。

$ yarn learn:tnn

Learn TwoLayer Neural Network...

iteration:1
2.301157570863233 # 誤差関数の出力

iteration:101
1.6163854318773305

iteration:201
0.6387995662968232

iteration:301
0.4657332402247479

iteration:401
0.470693806907251

iteration:501
0.3244541415721461

iteration:601
0.27824362472848635

...

誤差関数の出力がイテレーションを重ねるほどに減少していることから、正しく学習できていることがわかります。

5.畳み込みニューラルネットワーク(SimpleConvNet)

次に畳み込みニューラルネットワークについてです。今回実装した畳み込みニューラルネットは以下のような構造をしています。

畳み込みニューラルネットワーク

畳み込み層とプーリング層が一つずつ、全結合層を2つ持つ計四層の畳み込みニューラルネットです。

なお畳み込み層とプーリング層のパラメータは以下の通りになります。

  • 畳み込み層

出力チャネル数:30
畳み行列のサイズ:5
ストライド:1
パディング:0

  • プーリング層

ウィンドウサイズ: 2 * 2
ストライド:2
パディング:0

また、TwoLayerNetと同様、活性化関数は中間層はRelu関数、出力層はSoftmax関数を用いており、誤差関数は交差エントロピー誤差(Cross Entropy Error)を用いています。

それでは、以上のことを踏まえて、今回実装した畳み込みニューラルネットワーク(SimpleConvNet)の実装を見てみましょう

import nj from 'numjs';
import { Layer } from '../layers/base';
import { Affine } from '../layers/affine';
import { Relu } from '../layers/relu';
import { ImageRelu } from '../layers/imageRelu';
import { SoftmaxWithLoss } from '../layers/softmaxWithLoss';
import { Convolution } from '../layers/convolution';
import { Pooling } from '../layers/pooling';

export class SimpleConvNet {
  convW: nj.NdArray<number[][][]>;
  convB: nj.NdArray<number>;
  W1: nj.NdArray<number[]>;
  b1: nj.NdArray<number>;
  W2: nj.NdArray<number[]>;
  b2: nj.NdArray<number>;
  layers: Layer[];
  lossLayer: Layer;
  constructor(
    inputDim = { C: 1, Y: 28, X: 28 } as const, // MNISTの入力次元
    convParam = { filterNum: 30, filterSize: 5, pad: 0, stride: 1 } as const, // 畳み込み層のパラメータ
    poolingParam = { poolH: 2, poolW: 2, pad: 0, stride: 2 } as const, // プーリング層のパラメータ
    hiddenSize = 100,
    outputSize = 10,
    weightInitStd = 0.01
  ) {
    const convOutputSize =
      (inputDim.Y - convParam.filterSize + 2 * convParam.pad) /
        convParam.stride +
      1;
    const poolOutputSize = Math.floor(
      convParam.filterNum * (convOutputSize / 2) * (convOutputSize / 2)
    );

    // 畳み込み層の重み行列・バイアスベクトル
    this.convW = nj
      .random([
        convParam.filterNum,
        inputDim.C,
        convParam.filterSize,
        convParam.filterSize,
      ])
      .multiply(weightInitStd)
      .reshape(
        convParam.filterNum,
        inputDim.C,
        convParam.filterSize,
        convParam.filterSize
      );
    this.convB = nj.random(convParam.filterNum).multiply(weightInitStd);

    // プーリング層 → 中間層への重み行列・バイアスベクトル
    this.W1 = nj
      .random([poolOutputSize * hiddenSize])
      .multiply(weightInitStd)
      .reshape(poolOutputSize, hiddenSize) as nj.NdArray<number[]>;
    this.b1 = nj.zeros([hiddenSize]);

    // 中間層 → 出力層への重み行列・バイアスベクトル
    this.W2 = nj
      .random([hiddenSize * outputSize])
      .multiply(weightInitStd)
      .reshape(hiddenSize, outputSize) as nj.NdArray<number[]>;
    this.b2 = nj.zeros([outputSize]);

    // ニューラルネット本体。
    this.layers = [
      new Convolution(this.convW, this.convB, convParam.stride, convParam.pad),
      new ImageRelu(),
      new Pooling(
        poolingParam.poolH,
        poolingParam.poolW,
        poolingParam.stride,
        poolingParam.pad
      ),
      new Affine(this.W1, this.b1),
      new Relu(),
      new Affine(this.W2, this.b2),
    ];
    this.lossLayer = new SoftmaxWithLoss();
  }

  // 順伝搬
  forward(
    xBatch: nj.NdArray<number[][][]>,
    tBatch: nj.NdArray<number[]>
  ): number {
    let scoreBatch: nj.NdArray<number[][][] | number[]> = xBatch;
    for (const layer of this.layers) {
      scoreBatch = layer.forwardBatch(scoreBatch);
    }
    const loss = this.lossLayer.forwardBatch(scoreBatch, tBatch);
    return loss;
  }

  // 逆伝搬
  backward(): void {
    let dout: nj.NdArray<number[] | number[][][]> =
      this.lossLayer.backwardBatch();
    const reversedLayers = this.layers.slice().reverse();
    for (const layer of reversedLayers) {
      dout = layer.backwardBatch(dout);
    }
  }

  // パラメータの更新
  update(learningRate = 0.1): void {
    const { dConvW, dConvB, dW1, db1, dW2, db2 } = this.gradient();
    this.convW = this.convW.subtract(dConvW.multiply(learningRate));
    this.convB = this.convB.subtract(dConvB.multiply(learningRate));
    this.W1 = this.W1.subtract(dW1.multiply(learningRate));
    this.b1 = this.b1.subtract(db1.multiply(learningRate));
    this.W2 = this.W2.subtract(dW2.multiply(learningRate));
    this.b2 = this.b2.subtract(db2.multiply(learningRate));
    (this.layers[0] as Convolution).W = this.convW;
    (this.layers[0] as Convolution).b = this.convB;
    (this.layers[3] as Affine).W = this.W1;
    (this.layers[3] as Affine).b = this.b1;
    (this.layers[5] as Affine).W = this.W2;
    (this.layers[5] as Affine).b = this.b2;
  }

  // 各パラメータの勾配を返す関数
  gradient(): {
    dConvW: nj.NdArray<number[][][]>;
    dConvB: nj.NdArray<number>;
    dW1: nj.NdArray<number[]>;
    db1: nj.NdArray<number>;
    dW2: nj.NdArray<number[]>;
    db2: nj.NdArray<number>;
  } {
    const conv = this.layers[0] as Convolution;
    const affine1 = this.layers[3] as Affine;
    const affine2 = this.layers[5] as Affine;
    return {
      dConvW: conv.dW,
      dConvB: conv.db,
      dW1: affine1.dW,
      db1: affine1.db,
      dW2: affine2.dW,
      db2: affine2.db,
    };
  }
}

畳み込みニューラルネットの学習も実行してみます。学習はyarn learn:cnnを実行することで動作します

$ yarn learn:cnn

Learn Convolutional Neural Network...

iteration: 1
2.302419697001273 # 誤差関数の出力

iteration: 101
2.1932988463612713

iteration: 201
1.9089445618023353

iteration: 301
1.001005840921999

iteration: 401
0.42583633641717195

iteration: 501
0.38454650943764324

iteration: 601
0.43363565201204224

iteration: 701
0.19343118291118838

...

誤差関数の出力がイテレーションを重ねるほどに減少していることから、畳み込みニューラルネットに関しても、正しく学習できていることがわかります。

6.まとめ

今回はTypescriptを用いた全結合型の二層ニューラルネットワークと畳み込みニューラルネットワークの実装に取り組みました。

型安全性を求め、Typescript・numjsという技術選定をしましたが、実行速度や演算処理がより簡潔に記述できたりする点でPython・numpyの方が優れており、機械学習分野におけるPython一強はしばらく続きそうです。

しかし、ゼロからニューラルネットを実装したことは、自分の中で、機械学習の理論な知識の整理に大きく貢献してくれたように感じています。ゼロから作るDeep Learningは読むだけでも大変勉強になりますが、そこで得た知識をベースとした他言語でのスクラッチ実装も非常に面白い作業なので、興味がある方は是非試してみてください。

7.参考文献

https://www.oreilly.co.jp/books/9784873117584/

https://tutorials.chainer.org/ja/13_Basics_of_Neural_Networks.html
GitHubで編集を提案

Discussion

ログインするとコメントできます