MNISTローダとMLPの学習コードをC++/Eigenで作成する(要素技術メモ編)

IT関連

こんにちは、novです。

最近、「ゼロから作るDeep Learning」の内容をC++で書き直すということをやっています。

目的としては

  • C++の扱いに慣れる
  • C++のEigenライブラリの使い方に習熟する
  • Pythonではない言語で実装しなおすことで、細かい部分の設計まで理解する

の3点があります。

さて、この書籍は「ゼロから作る」というタイトルではあるのですが、厳密には一部のモジュールはgithubからダウンロードしてきて使うことを前提にしています。
3分クッキングでいうところの「~したものがこちらになります」状態ですね。

お膳立てをしてくれているのは初学者には良いと思います。が、実のところ実務で機械学習・深層学習に関わるときは「データを使える形にする」のが一番手間なのですよね。。。

この書籍としてはアルゴリズムの解説がメインでその他の部分は本筋ではないということなのでしょうが、ここが気になる人もいるのではないでしょうか(少なくとも自分がそうです)。

今回のMNISTについては既にアノテーションが完了しており、読み出すだけなのでそこまで大変ではないです。
が、一応バイナリのファイルということもあり、MNISTのフォーマットとC++での読み出し方法を知らないと地味に苦労します。
実際自分が実装するときには結構時間がかかりました。

ということで、今回の記事ではMNISTローダの実装に当たって自分が調べた内容を備忘録としてまとめます。
また、訓練ができることの確認にシンプルな多層パーセプトロン(MLP)も実装したので、その際に必要だったEigenの知識もまとめます。
PythonユーザならNumpyとの比較があると便利そうなので、比較する形で載せようと思います。

作成したローダ・MLPでの学習コードはgithubに公開しているので、気になる人はそちらもどうぞ。

potedo/MNIST_loader_sample
Contribute to potedo/MNIST_loader_sample development by creating an account on GitHub.

次回記事では、作成したコードの解説と、公式ページのPythonコード(mnist.py)についてもざっくり説明しようかと思います。

では本題に入っていきましょう!

作成するMNISTローダの要件

今回作成するMNISTローダの最低限の要件は以下の通りです。

  • EigenのMatrixでの読み出しに対応していること
  • 内部ですべてのデータを展開するのではなく、next_train()のようなメソッドを呼び出したときに必要なだけデータを読み出すこと
  • ランダムな順番での読み出しに対応していること

1つ目については、MLPの行列計算の実装をEigenベースで行うためです。
2つ目については、読み出しの結果スタックオーバーフローを起こさないようにするためです。
一応対応策として

  • 逐次メモリに読み込む
  • コンパイル時にスタックサイズを十分確保するよう設定する

の2種類がありますが、メモリ消費は減らした方が良い(今回は速度も重視しない)ため、このようにしました。
なお、訓練データの場合

60000 × 28 × 28 = 約47MB

のサイズがあります。全部読みだす際はこれ+αの領域を確保する必要があります。

3つ目については、SGDのためにランダムに読み出す機能が欲しいということになります。

以上が最低限の要件と、その理由です。

これらの実現のために今回使用するライブラリは以下の通りです。

  • fstream
  • random
  • algorithm

順に

  • ファイルI/O
  • シャッフル用の乱数生成
  • 読み出しインデックスシャッフル用

となります。以下、MNISTローダ実装で使用した使い方についてまとめていきます。

ifstream でのランダム読み出し

fstreamのうち、今回使用するのはInputのfile streamである std::ifstreamです。

特に、そのうち使用するメソッドは

  • read()
  • seekg()
  • tellg()

の3つです。
また、ifstreamのインスタンス作成時にmode指定を行いますが、その際に使用するフラグは

  • std::ios::in
  • std::ios::binary

の二つです。このフラグについては「bitに1を立てている」と考えると理解しやすいです。
要は、「mode」を指定する引数は、1byte中のどこが”1″になっているかでモードを判断しているということです。

イメージは以下ですね

openmode = 1 0 0 0 0 1 0 0

例えば一番左のbitが「std::ios::in」に対応し、左から6番目のbitが「std::ios::binary」に対応している場合、このモード設定は「入力かつバイナリ」ということになります。なので、モード設定ではビット演算を使用するわけですね。上記の例では

openmode = std::ios::in | std::ios::binary

と設定すればOKということになります。
※ この例はあくまでイメージなので、実際の実装とは中身が異なることに注意してください

以上を踏まえると、ifstreamのインスタンス作成は次のようになります。

std::ifstream fin("path to datafile", std::ios::in | std::ios::binary);

さて、使用するメソッドについての解説です。

read()

APIは以下の通りです。

read(char_type* s, streamsize n);

これだけでは良く分からないのでもう少しかみ砕くと、

  • 第一引数:char*を指定しておけばよい(読み出しをchar型の配列に格納していくという宣言)
  • 第二引数:読み出しbyte数を指定

となります。
つまり、現在位置からint型の変数に4byte分読み出して保存したい場合は

int hoge;
ifs.read((char *)&hoge, sizeof(hoge));

とすれば良いことになります。
※ なお、int型は処理系によってbyte数が異なるので注意

seekg(), tellg()

ifstreamで開いているファイルのファイルポインタを操作・取得するメソッドです。

リファレンスでは

seekg(ifstream::pos_type pos);
seekg(ifstream::off_type off, seekdir dir);

となっています。pos_type、off_typeは「byte数」を表し、実際に使う際にはint型の数値でも問題なく動作します。
第二引数に指定できるものは、以下の通りです。

  • ios_base::beg:ファイルの先頭位置
  • ios_base::cur:ファイルポインタの現在位置
  • ios_base::end:ファイルの終了位置

また、tellg()は現在のファイルポインタの位置をpos_typeとして返してくれるものになります。

以上を組み合わせると、ファイル内の任意の位置へseekしたい場合には以下のようにすればOKです。

  1. 基準となるファイル内の位置をifs.tellg()で取得し、保存
  2. ifs.seekg("1.の内容を保存している変数")として、基準位置にファイルポインタを移動
  3. ifs.seekg("移動したいbyte数", ios_base::cur)として、基準位置から所望の位置まで移動

こうすることで、任意の位置へのファイルシークが実現できます。
※ もっといい方法があるかもしれません。ご存知の方が居れば教えていただきたいです。

ランダム読み出しの実装

基本的には以下の考え方で実装することにしました。

  1. 0 ~ 59999(テストデータでは9999) のインデックスリストをstd::vectorで作成
  2. 1.で作成したインデックスリストをシャッフル
  3. 前章で説明した方法で、インデックスに対応したデータの位置までファイルシークし、読み込み

vectorを使用しているのはランダムアクセスの時間がO(1)だからです。
vectorで作成したインデックスのリストのシャッフルは以下の手順で行います。

#include <random>
#include <algorithm>

int main()
{
    std::vector<int> indices;

    for (int i = 0; i < 60000; i++)
    {
        indices.push_back(i);
    }

    std::mt19937_64 get_rand_mt;
    std::shuffle(indices.begin(), indices.end(), get_rand_mt);

}

ramdomライブラリから mt19937_64を使用して乱数を生成し、algorithmライブラリからshuffleメソッドを使用して、インデックスリストをシャッフルするという流れです。

Eigenライブラリの使い方 @ MLP実装

ここからは主にMLPを実装する際に必要になるEigenの使い方の開設になります。
一部はMNISTのローダ内でも使用するので、併せて確認してもらえればと思います。

  • ブロードキャスト演算
  • アダマール積(要素積)
  • sum()の使い方
  • 行列内最大・最小の要素およびそのインデックス取得

おおよそこれらのことを知っておく必要があります。
以下、それぞれのやり方について、PythonのNumpyの記法と比較しながら解説します。

ブロードキャスト演算

ブロードキャスト演算とは、以下の図のように形状の異なる行列同士の演算を行う際、適切なサイズになるよう片方の行列をコピーして計算するものです。

例えば、上記の演算を行う際にnumpyならば以下のように書けます。

import numpy as np

A = np.array([[1, 2, 3, 4],
              [5, 6, 7, 8],
              [9,10,11,12],
              [13,14,15,16]])

B = np.array([[1],
              [2],
              [3],
              [4])

C = A + B

# C = [[ 2, 3, 4, 5],
#      [ 7, 8, 9,10],
#      [12,13,14,15],
#      [17,18,19,20]]

特に何も指定しなくても、ndarrayの側で適切な形にブロードキャストしてくれます。

これをEigenで実現するには、次のように記述します。

#include <Eigen/Dense>

using namespace Eigen;

int main(){

    MatrixXd A, C;
    VectorXd B = VectorXd::Zero(4);

    A = MatrixXd::Zero(4,4);

    A <<  1, 2, 3, 4,
          5, 6, 7, 8,
          9,10,11,12,
          13,14,15,16;

    B <<  1, 2, 3, 4;


    C = A.colwise() + B;
}

// C = [[ 2, 3, 4, 5],
//      [ 7, 8, 9,10],
//      [12,13,14,15],
//      [17,18,19,20]]

主な違いは、行列Aに対し、colwise()というメソッドが入っていることです。
Eigenの場合は、どの方向にブロードキャストして演算するかは明示してやる必要があるということです。
上記の例では、列ベクトルをコピーして計算するため、colwise()を使用しました。
行ベクトルをコピーしてブロードキャストする際にはrowwise()を使います。
また、ベクトルをブロードキャストする際にはMatrixではなくVector型を使用する必要があります。

ちなみに、行列に対してスカラーをブロードキャスト演算する場合には特に小細工をせず、そのまま計算すればOKです。

アダマール積(要素積)

同じ形状の行列同士で、それぞれの要素同士で積をとるというのがアダマール積です。記号では次のように書いたりします。

\( A \odot B \)

ニューラルネットの計算ではよく登場しますが、これをNumpyで実装すると次のようになります。

import numpy as np

A = np.array([[1,2,3],
              [4,5,6]])

B = np.array([[1,2,1],
              [2,1,2]])

C = A * B

# C = [[1, 4, 3],
#      [8, 5,12]]

これもブロードキャスト演算と異なり、特に何もしなくてOKです。
(行列積をとるときにはdot()メソッドを使います)

上記をEigenで実現するにはarray()メソッドでMatrixを配列として扱ってやる必要があります。

#include <Eigen/Dense>

using namespace Eigen;

int main(){

    MatrixXd A, B, C;
    A = MatrixXd::Zero(2,3);
    B = MatrixXd::Zero(2,3);

    A << 1, 2, 3,
         4, 5, 6;

    B << 1, 2, 1,
         2, 1, 2;

    C = A.array() * B.array();
}
// C = [[1, 4, 3],
//      [8, 5,12]]

array()メソッドを使う以外、特に違いはありません。なお、Eigenで行列積を取る場合は、単に「*」で積を取ればOKです。

sum()の使い方

NumpyでもEigenでも、sum()を単に使うだけでは行列の要素の総和を取って終わりです。
この場合の使い方には特に違いはないのですが、「行方向」「列方向」のみの総和を取りたい場合には書き方が変わってきます。
具体的には、softmax関数の実装や全結合層の誤差逆伝播におけるバイアス項の勾配計算で登場します。

import numpy as np

A = np.array([[1,2,3],
              [4,5,6])

a = A.sum() # a = 21

B = A.sum(axis=0) # [5, 7, 9]
C = A.sum(axis=1) # [6, 15]

Numpyの場合は上記のようになります。行方向に総和を取りたいときはaxis=0, 列方向に総和を取りたいときはaxis=1を指定します。
※ 厳密には、一番外側のリストで縮約するか、内側のリストで縮約するかの指定です。

これと同様の計算をEigenで書くと以下のようになります。

#include <Eigen/Dense>

using namespace Eigen;

int main()
{
    MatrixXd A, B, C;
    double a;

    A = MatrixXd::Zero(2, 3);

    A << 1, 2, 3,
         4, 5, 6;

    a = A.sum(); // a = 21

    B = A.colwise().sum(); // [5, 7, 9]
    C = A.rowwise().sum(); // [6, 15]
}

ブロードキャストの時と同じく、rowwise(), colwise()というメソッドを使用します。

行列内最大・最小の要素およびそのインデックス取得

これは主にsotfmax関数・accuracy関数の実装に必要になります。
Numpyの場合はmax()where(条件式)を使用すればOKです。

import numpy as np

A = np.array([1,2,3,4])

max_A = A.max() # 4
row, col = np.where(A==max_A) # row = 0, col = 3

これをEigenで実現する場合は、maxCoeff()を次のように使用すればOKです。

#include <Eigen/Dense>

using namespace Eigen;

int main()
{
    MatrixXd A = MatrixXd::Zero(1, 4);
    double max_A
    int row, col;

    A << 1, 2, 3, 4;

    max_A = A.maxCoeff(&row, &col);

}

// max_A = 4
// row = 0
// col = 3

C系の言語では複数要素を返り値に持てないので、インデックスは引数で取得するようなAPIになっていると思われます。

まとめ

今回の記事では、C++で作成するMNISTローダの要件と、実装に必要となるライブラリの使い方について個人的な備忘録を兼ねてまとめてみました。
次回はこれらの知識をベースにMNISTのローダ、MLPの学習コードの設計についてサンプルを交えて解説する予定です。

コメント

タイトルとURLをコピーしました