LoginSignup
8
3

More than 3 years have passed since last update.

コンパイル時ニューラルネットワークライブラリを書いた

Posted at

こちらはC++ Advent Calendar 2019の23日目の記事になります.
昨日は@koyagiさんの「赤鼻のトナカイで多いのは?赤鼻?トナカイ?サンタさん?」でした.

ねこです.よろしくおねがいします.
ところで皆さんは,コンパイル時にニューラルネットワークを学習させたいと思ったことはありませんか?
「あ~,コンパイル時にXORを学習させたい」とか,「あ~,コンパイル時にMNISTを使った分類がしたい」とか思ったことはありませんか?

ありますよね?

そう,あるんですよ.
そこでSproutperceptronのexampleconstexpr-nnのコードを眺めるも,自分で使うには難しくて手が出せない...><なんて思ったことはありませんか?

ありますよね?

そう,あるんですよ.
そこで今回,簡単にコンパイル時ニューラルネットワークが書けるライブラリ,scennをご紹介します.
それではXORを学習させてみましょう.コードは以下の通りです.

#include <iostream>
#include <scenn/activation.hpp>
#include <scenn/dataset.hpp>
#include <scenn/layer.hpp>
#include <scenn/loss.hpp>
#include <scenn/model/sequential_network.hpp>

SCENN_CONSTEXPR auto test() {
  using namespace scenn;
  double X_arr[4][2] = {{0, 0}, {0, 1}, {1, 0}, {1, 1}};
  double Y_arr[4][2] = {{1, 0}, {0, 1}, {0, 1}, {1, 0}};
  auto dataset = Dataset(std::move(X_arr), std::move(Y_arr));
  auto trained_model =
      SequentialNetwork(BinaryCrossEntropy(), DenseLayer<2, 4, double>(),
                        ActivationLayer<4, double>(Sigmoid()),
                        DenseLayer<4, 2, double>(10),
                        ActivationLayer<2, double>(Sigmoid()))
          .train<2>(dataset, 2000, 0.1);
  return std::move(trained_model).evaluate(std::move(dataset));
}

int main() {
  SCENN_CONSTEXPR auto evaluation = test();
  std::cout << evaluation << std::endl; // We see 4 (of 4)
}

これはKerasでいえば,だいたい

import numpy
from keras.models import Sequential
from keras.layers import Dense, Activation
from keras.optimizers import SGD

X_train = numpy.array([[0, 0], [0, 1], [1, 0], [1, 1]])
Y_train = numpy.array([[1, 0], [0, 1], [0, 1], [1, 0]])
model = Sequential([
  Dense(4, input_dim=2),
  Activation('sigmoid'),
  Dense(2, input_dim=4),
  Activation('sigmoid')
])
model.compile(loss='binary_crossentropy', optimizer=SGD(0.1))
model.fit(X_train, Y_train, batch_size=2, epochs=2000)
print(sum([numpy.argmax(ans) == numpy.argmax(pre) for ans, pre in zip(Y_train, model.predict(X_train))])

相当です(input_dim=4はなくても動きますが,比較のために書いています).
ではこのコードを動かしてみましょう.まずどこのご家庭にもあるclangとSproutをご用意ください.次にscennをリポジトリからcloneし,パスを通します.Sproutを切らしている場合は,scennと一緒にcloneしましょう.

git clone https://github.com/bolero-MURAKAMI/Sprout.git
git clone https://github.com/Catminusminus/scenn.git

export SPROUT_PATH=./Sprout/
export SCENN_PATH=./scenn/

とかしてください.
そうしたら

clang++ ./scenn/examples/xor.cpp -Wall -Wextra -I$SPROUT_PATH -I$SCENN_PATH -std=gnu++2a -fconstexpr-steps=-1

としてください.
コンパイルが終わるまでしばらくお待ちください.
コンパイルが終わると,学習と評価が終わった,a.outの出来上がりです.
それでは./a.outと実行してみましょう.4と表示されたら成功です.以上今日の3分コンパイル時計算でした(コンパイル自体には3分以上かかると思います).

さて,MNISTを使った画像分類を行うためには,-fconstexpr-steps=-1でも制限に引っかかってしまうので,clangにパッチを当てる必要があります.scennのリポジトリにclang-patch.diffがあるので,これを使ってください.一例ですが,

git clone https://github.com/llvm/llvm-project.git
cd llvm-project
mv <path/to/clang-patch.diff> ./
patch -p0 < clang-patch.diff>
mkdir build
cd build
cmake -G "Unix Makefiles" -DLLVM_ENABLE_PROJECTS=clang -DCMAKE_BUILD_TYPE=Release ../llvm
make

とするとパッチの当てられたclangがbuild/binにできます.多分ninjaの方が良いと思いますが,まだそちらで試してはいません.
さらに,フルのデータセットでは学習に時間とメモリがかかりすぎるので,scennではMNISTからサブデータセットを作るツールと,それをloadする関数があります.

wget https://s3.amazonaws.com/img-datasets/mnist.npz -O mnist.npz

でMNISTデータセットをダウンロードしたら,tools/generate_mini_mnist.pyを使うと(pipenvで環境が作成できます),0, 1, 2の3クラス,訓練データ100個,テストデータ10個のプリプロセス済みサブデータセットが作成されます.これが作成されるとload_mini_mnist関数でデータを読み込むことができます.コードは以下です.

#include <iostream>
#include <scenn/load/mini_mnist.hpp>
#include <scenn/scenn.hpp>

SCENN_CONSTEXPR auto mini_mnist_test() {
  using namespace scenn;
  auto [train_data, test_data] = load_mini_mnist_data<double>();
  auto evaluation =
      SequentialNetwork(CrossEntropy(), DenseLayer<784, 196, double>(),
                        ActivationLayer<196, double>(Sigmoid()),
                        DenseLayer<196, 3, double>(),
                        ActivationLayer<3, double>(Softmax()))
          .train<100>(std::move(train_data), 20, 0.1)
          .evaluate(std::move(test_data));
  return evaluation;
}

int main() {
  SCENN_CONSTEXPR auto evaluation = mini_mnist_test();
  std::cout << evaluation << std::endl; // We see 8 (of 10)
}

私の環境ではコンパイルに半日程度かかりました.じっくりコトコトコンパイルしましょう.

まじめなはなし

SCENNは,dlgoのNNにインスパイアされています.
また内部ではconstexpr-nnのMatrixを拡張したものが使われています.
さて,筆者はC++初心者なので,実装を行う上でいろいろ基本的なところで苦労したところがあります.

レイヤーの保持

SCENNを実装するにあたり,最大の難所はLayer(の種類と個数)を可変にすることでした.実際,固定にするならばSproutのperceptronやconstexpr-nn,あるいは「ゼロから作るDeep Learning」を参考にかけます.しかし,その場合はまさしく0からNNの構造やtrain関数相当をユーザーが書く必要があります.そこでdlgoのNN実装を参考にしました.scenn::SequentialNetworkは可変長コンストラクタを持ちます.第一引数はLoss関数で,そのあとにLayerが続きます.このLayerの保持に,spourt::tupleを用いています.これにより,Layerの個数が可変であることと,型もバラバラであることに対処しています.

ここで,std::tupleではないのは,C++17ではoperator=constexprでないためです.

コンパイル時乱数

NNにはさまざまなところで乱数が必要になります.Layerの重みの初期化も乱数を使いますし,学習時にもデータをランダムにとる(並び変える)必要があります.さて,コンパイル時に乱数の配列が欲しい場合,どうすればよいでしょうか.Sproutを使うのが一つの手です.

#include <sprout/array.hpp>
#include <sprout/random.hpp>
#include <sprout/random/unique_seed.hpp>

template <typename T, std::size_t N, typename Random, typename... Args>
constexpr auto
random_array_impl(const Random &random, const Args &... args)
{
    if constexpr (sizeof...(Args) < N - 1)
    {
        return random_array_impl<T, N>(random(), args..., *random);
    }
    return sprout::array<T, N>{{args..., *random}};
}

template <typename T, std::size_t N, typename Generator>
constexpr auto random_array(const Generator &generator)
{
    return random_array_impl<T, N>(generator());
}

int main() {
    constexpr auto seed = SPROUT_UNIQUE_SEED;
    constexpr auto engine = sprout::random::minstd_rand0(seed);
    constexpr auto distribution = sprout::random::uniform_smallint<int>(1, 100);
    constexpr auto random = random_array<int, 50>(sprout::random::combine(engine, distribution));
}

コードはSprout.Random - コンパイル時の乱数生成の改変です.これで50個の乱数をゲットできます.が,100000個ほしい!とかになるとはなしがかわります.コンパイルが通りません.結局事前に乱数を生成したcsvを用意し,#includeで読み込むことにしました.ただ,clangにパッチを当てた場合は100000個でも上記のコードでコンパイルが通るかもしれません.
で,このcsvをLayerの重みの初期化に使っているのですが,そのままだとどのLayerも重みがかぶってしまいます.そこでseedを与え,csvを読むときにループカウントにseedを足しすことによって読み飛ばすようにして重みが全部かぶるのを防いでいます.これがdirty hackちゃんですかそのうち何とかしたいですね.
また学習時に何回かデータをシャッフルする必要があるのですが,毎回シャッフルするseedを変えるために,ループカウンタをseedに使っています.

配列の部分配列

a = numpy.array([1,2,3])
b = a[0:2] # b == numpy.array([1,2])

みたいなのをコンパイル時にC++でもやりたいわけです.sprout::sub_arrayというのを見つけたのですがよくわかりませんでした.結局sprout::copyを使っているのですが,上の例でいう0や2がコンパイル時定数である必要が出てきてしまいました.つまり

template <std::size_t I, std::size_t J, class T>
constexpr auto f(T arr) {
  if constexpr (std::tuple_size<T> < J) static_assert([] {return false; }());
  return sprout::copy<std::array<typename T::value_type, J - I>, decltype(std::cbegin(arr))>(std::cbegin(arr) + I, std::cbegin(arr) + J);
}

みたいな感じです.
実際には上のような関数がDatasetクラスのメソッドとして,こんな感じで使われています.

auto data = training_data.template slice<Index, Index + Interval>();
// 本当は auto data = training_data.template slice(index, index + interval); としたかった

if constexpr + static_assert

template <class T>
constexpr auto false_v = false;

static_assert(false_v<U>);

をずっと使っていましたが,

static_assert([] { return false; }());

でよいのを最近知りました.参考:https://cpprefjp.github.io/lang/cpp17/if_constexpr.html

おわりに

Kerasを知った時からこんな感じでコンパイル時にやりたいと思っていたので,作りたかったものが出来て良かったです.dlgoとconstexpr-nnを鍋に入れて煮込んだだけですが...
CNNが出来たらまた記事を書きます.ねこでした.

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3