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

こちらは[C++ Advent Calendar 2019](https://qiita.com/advent-calendar/2019/cpp)の23日目の記事になります．
昨日は@koyagiさんの「[赤鼻のトナカイで多いのは？赤鼻？トナカイ？サンタさん？](https://qiita.com/koyagi/items/4795493c95e2c05f64b0)」でした．

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

ありますよね？

そう，あるんですよ．
そこで[Sprout](https://github.com/bolero-MURAKAMI/Sprout)の[perceptronのexample](https://github.com/bolero-MURAKAMI/Sprout/tree/master/example/perceptron)や[constexpr-nn](https://github.com/ushitora-anqou/constexpr-nn)のコードを眺めるも，自分で使うには難しくて手が出せない...><なんて思ったことはありませんか？

ありますよね？

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

```cpp
#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でいえば，だいたい

```python
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`関数でデータを読み込むことができます．コードは以下です．

```cpp
#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実装](https://github.com/maxpumperla/deep_learning_and_the_game_of_go/blob/master/code/dlgo/nn/network.py)を参考にしました．`scenn::SequentialNetwork`は可変長コンストラクタを持ちます．第一引数はLoss関数で，そのあとにLayerが続きます．このLayerの保持に，`spourt::tuple`を用いています．これにより，Layerの個数が可変であることと，型もバラバラであることに対処しています．

ここで，`std::tuple`ではないのは，C++17では`operator=`が`constexpr`でないためです．

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

```cpp
#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 - コンパイル時の乱数生成](http://boleros.hateblo.jp/entry/20110930/1317355718)の改変です．これで50個の乱数をゲットできます．が，100000個ほしい！とかになるとはなしがかわります．コンパイルが通りません．結局事前に乱数を生成したcsvを用意し，`#include`で読み込むことにしました．ただ，clangにパッチを当てた場合は100000個でも上記のコードでコンパイルが通るかもしれません．
で，このcsvをLayerの重みの初期化に使っているのですが，そのままだとどのLayerも重みがかぶってしまいます．そこで`seed`を与え，csvを読むときにループカウントに`seed`を足しすことによって読み飛ばすようにして重みが全部かぶるのを防いでいます．~~これがdirty hackちゃんですか~~そのうち何とかしたいですね．
また学習時に何回かデータをシャッフルする必要があるのですが，毎回シャッフルする`seed`を変えるために，ループカウンタを`seed`に使っています．

### 配列の部分配列
```python
a = numpy.array([1,2,3])
b = a[0:2] # b == numpy.array([1,2])
```
みたいなのをコンパイル時にC++でもやりたいわけです．`sprout::sub_array`というのを見つけたのですがよくわかりませんでした．結局`sprout::copy`を使っているのですが，上の例でいう0や2がコンパイル時定数である必要が出てきてしまいました．つまり

```cpp
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クラスのメソッドとして，こんな感じで使われています．

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

### if constexpr + static_assert

```cpp
template <class T>
constexpr auto false_v = false;

static_assert(false_v<U>);
```
をずっと使っていましたが，

```cpp
static_assert([] { return false; }());
```
でよいのを最近知りました．参考：https://cpprefjp.github.io/lang/cpp17/if_constexpr.html

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