C++
DeepLearning
ディープラーニング
CNN
CIFAR-10

C++だけでディープラーニング(CIFAR-10)

More than 1 year has passed since last update.

ディープラーニングが流行ってましたね。
そろそろ各社の経営層が慌てて取り組もうとして
末端の開発者を困らせていることでしょう。

前置き

皆さんもう Chainer は使ってみたでしょうか。
Chainer はとても便利ですが、ある程度 Python を覚えなくてはなりません。
また、環境によっては Chainer を組み込むことが難しい場合もあるでしょう。
もっと手軽に、気楽にディープラーニングしたいものです。

nanikanizer

そこでこちらのこちらのライブラリを用意しました。
nanikanizer

ナニカナイザと読みます。
C++ 用のライブラリで、インクルードするだけで使えます。

まずは簡単な example を紹介します。

カーブフィッティング

curve_fitting
#include <iostream>
#include <iomanip>
#include <nanikanizer/nanikanizer.hpp>

int main(int /*argc*/, char* /*argv*/[])
{
    try
    {
        nnk::expression<double> x =
        {
            0.0,
            1.0,
            2.0,
            3.0,
            4.0,
            5.0,
        };

        nnk::expression<double> y =
        {
            1.0,
            -4.0,
            -5.0,
            -2.0,
            5.0,
            16.0,
        };

        nnk::variable<double> a = { 0.0 };
        nnk::variable<double> b = { 0.0 };
        nnk::variable<double> c = { 0.0 };

        auto fx = a.expr() * x * x + b.expr() * x + c.expr();
        auto loss = nnk::norm_sq(fx - y);

        nnk::evaluator<double> ev(loss);

        nnk::adam_optimizer optimizer(0.1);
        optimizer.add_parameter(a);
        optimizer.add_parameter(b);
        optimizer.add_parameter(c);

        for (std::size_t i = 0; i < 1500; ++i)
        {
            optimizer.zero_grads();

            ev.forward();
            ev.backward();

            optimizer.update();

            std::cout << std::fixed << std::setprecision(8);
            std::cout
                << "a = " << a.value()[0] << ", "
                << "b = " << b.value()[0] << ", "
                << "c = " << c.value()[0] << std::endl;
        }
    }
    catch (std::exception& e)
    {
        std::cerr << e.what() << std::endl;
        return -1;
    }

    return 0;
}

これはカーブフィッティングの例です。
まだ CNN でもディープラーニングでもありません。

2次元の点列 (x, y) が与えられるので、
$y = ax^2 + bx + c$
という数式に当てはめ、$a$, $b$, $c$ を求めています。

variable<T> という型が、変数を表します。
この例では $a$, $b$, $c$ ともにスカラーですが、多次元のベクトルを表すこともできます。

auto fx = a.expr() * x * x + b.expr() * x + c.expr();
auto loss = nnk::norm_sq(fx - y);

ここで、最小化したいもの=損失関数 (loss) を求めています。
今回は、$f(x) = ax^2 + bx + c$ と $y$ との差の二乗和を使います。

nnk::evaluator<double> ev(loss);

evaluator<T> は、与えられた数式を評価するための仕組みです。
この中で式の構造をグラフに変換し、トポロジカルソートで計算順序を決定しています。
forward() で順伝播、backward() で逆伝播を計算します。

nnk::adam_optimizer optimizer(0.1);
optimizer.add_parameter(a);
optimizer.add_parameter(b);
optimizer.add_parameter(c);

みんな大好き Adam です。
パラメータとして a, b, c を設定しています。
optimizer は、与えられたパラメータを変更することで損失関数を最小化します。

evaluator<T>backward() による逆伝播の計算は累積されていきます。
累積をリセットするには optimizer.zero_grads() を呼び出します。

optimizer.zero_grads();

ev.forward();
ev.backward();

optimizer.update();

ここで順伝播と逆伝播を計算し、optimizer.update() でパラメータを更新します。
これを繰り返すことで、精度が上がっていきます。

どうでしょうか。
なんとなく Chainer に似ている部分もあると思います。

次に、タイトルにあります CIFAR-10 を試してみます。

CIFAR-10

cifar_10
std::vector<std::string> data_files =
{
    "data_batch_1.bin",
    "data_batch_2.bin",
    "data_batch_3.bin",
    "data_batch_4.bin",
    "data_batch_5.bin",
};

std::string test_file = "test_batch.bin";

std::vector<nnk::cifar_10::tagged_image> data_images;
std::vector<nnk::cifar_10::tagged_image> test_images;

for (const std::string& file : data_files)
    nnk::cifar_10::load_images(file, true, std::back_inserter(data_images));

nnk::cifar_10::load_images(test_file, true, std::back_inserter(test_images));

std::size_t id_size = 10;

auto ids = nnk::make_ids(id_size, 0.0f, 1.0f);

nnk::linear_layer<float> l1(27, 32);
nnk::linear_layer<float> l2(288, 32);
nnk::linear_layer<float> l3(288, 32);
nnk::linear_layer<float> l4(288, 32);
nnk::linear_layer<float> l5(288, 32);
nnk::linear_layer<float> l6(288, 32);
nnk::linear_layer<float> l7(512, 512);
nnk::linear_layer<float> l8(512, id_size);

nnk::dropout_layer<float> drop;

std::size_t batch_size = 128;

nnk::variable<float> x0;
nnk::variable<float> y;

nnk::expression<float> x = x0.expr();

// 32 * 32 * 3
x = nnk::padding_2d(x, 32, 32, 3, 1, 1);
// 34 * 34 * 3
x = nnk::convolution_2d(x, 34, 34, 3, 3, 3);
// 32 * 32 * 27
x = nnk::relu(l1.forward(x));

// 32 * 32 * 32
x = nnk::padding_2d(x, 32, 32, 32, 1, 1);
// 34 * 34 * 32
x = nnk::convolution_2d(x, 34, 34, 32, 3, 3);
// 32 * 32 * 288
x = nnk::relu(l2.forward(x));

// 32 * 32 * 32
x = nnk::max_pooling_2d(x, 32, 32, 32, 2, 2);

// 16 * 16 * 32
x = nnk::padding_2d(x, 16, 16, 32, 1, 1);
// 18 * 18 * 32
x = nnk::convolution_2d(x, 18, 18, 32, 3, 3);
// 16 * 16 * 288
x = nnk::relu(l3.forward(x));

// 16 * 16 * 32
x = nnk::padding_2d(x, 16, 16, 32, 1, 1);
// 18 * 18 * 32
x = nnk::convolution_2d(x, 18, 18, 32, 3, 3);
// 16 * 16 * 288
x = nnk::relu(l4.forward(x));

// 16 * 16 * 32
x = nnk::max_pooling_2d(x, 16, 16, 32, 2, 2);

// 8 * 8 * 32
x = nnk::padding_2d(x, 8, 8, 32, 1, 1);
// 10 * 10 * 32
x = nnk::convolution_2d(x, 10, 10, 32, 3, 3);
// 8 * 8 * 288
x = nnk::relu(l5.forward(x));

// 8 * 8 * 32
x = nnk::padding_2d(x, 8, 8, 32, 1, 1);
// 10 * 10 * 32
x = nnk::convolution_2d(x, 10, 10, 32, 3, 3);
// 8 * 8 * 288
x = nnk::relu(l6.forward(x));

// 8 * 8 * 32
x = nnk::max_pooling_2d(x, 8, 8, 32, 2, 2);

// 4 * 4 * 32 = 512
x = drop.forward(x);
// 512
x = nnk::relu(l7.forward(x));
// 10
x = nnk::softmax(l8.forward(x), id_size);
// 10

auto loss = nnk::cross_entropy(x - y.expr());

auto get_answer = [&](std::size_t index)
{
    const auto& r = x.root()->output();
    auto begin = &r[index * id_size];
    auto end = begin + id_size;
    auto it = std::max_element(begin, end);
    return it - begin;
};

nnk::evaluator<float> ev(loss);

nnk::adam_optimizer optimizer;

optimizer.add_parameter(l1);
optimizer.add_parameter(l2);
optimizer.add_parameter(l3);
optimizer.add_parameter(l4);
optimizer.add_parameter(l5);
optimizer.add_parameter(l6);
optimizer.add_parameter(l7);

std::mt19937 generator;
std::uniform_int<std::size_t> index_generator(0, data_images.size() - 1);

std::cout << "Loss,Rate" << std::endl;
std::cout << std::fixed << std::setprecision(5);

for (std::size_t i = 0; i < 100; ++i)
{
    drop.train() = true;

    x0.value().resize(batch_size * nnk::cifar_10::whole_size);
    y.value().resize(batch_size * id_size);

    float last_loss = 0.0f;

    for (std::size_t j = 0; j < 100; ++j)
    {
        for (std::size_t k = 0; k < batch_size; ++k)
        {
            std::size_t index = index_generator(generator);
            const auto& image = data_images[index];

            for (std::size_t l = 0; l < nnk::cifar_10::whole_size; ++l)
                x0.value()[k * nnk::cifar_10::whole_size + l] = image.second[l];

            for (std::size_t l = 0; l < id_size; ++l)
                y.value()[k * id_size + l] = ids[image.first][l];
        }

        optimizer.zero_grads();

        last_loss = ev.forward()[0];
        ev.backward();

        optimizer.update();
    }

    drop.train() = false;

    std::size_t count_ok = 0;

    for (const auto& image : test_images)
    {
        x0.value() = image.second;

        ev.forward();

        if (get_answer(0) == image.first)
            ++count_ok;
    }

    double rate = static_cast<double>(count_ok) / static_cast<double>(test_images.size());
    std::cout << last_loss << "," << rate << std::endl;
}

ネットワークの形は以下のサイトを参考にさせて頂きました。
chainerの畳み込みニューラルネットワークで10種類の画像を識別(CIFAR-10)

nnk::linear_layer<float> l1(27, 32);
nnk::linear_layer<float> l2(288, 32);
nnk::linear_layer<float> l3(288, 32);
nnk::linear_layer<float> l4(288, 32);
nnk::linear_layer<float> l5(288, 32);
nnk::linear_layer<float> l6(288, 32);
nnk::linear_layer<float> l7(512, 512);
nnk::linear_layer<float> l8(512, id_size);

nnk::dropout_layer<float> drop;

新しく linear_layer<T>dropout_layer<T> が出てきました。
各 layer は変数と処理を組み合わせたもので、
linear_layer<T> は線形関数 ( $y=Wx+b$ )
dropout_layer<T> はドロップアウトを表します。

// 32 * 32 * 3
x = nnk::padding_2d(x, 32, 32, 3, 1, 1);
// 34 * 34 * 3
x = nnk::convolution_2d(x, 34, 34, 3, 3, 3);
// 32 * 32 * 27
x = nnk::relu(l1.forward(x));

Chainer は多次元配列を管理してくれましたが、
nanikanizer では 高さ×幅×チャンネル数 の大きさを持った1次元の配列を扱います。
1次元の配列では各次元の要素数が分からないので、
各処理に高さ、幅、チャンネル数を伝える必要があります。

padding_2d で上下左右に1ピクセルずつ追加し、
convolution_2d で畳み込み(正確には畳み込みの前処理)、
l1.forward で線形変換、最後に relu で非線形変換を行っています。

このような処理を何段も重ねて Deep な構造を表現します。

optimizer.add_parameter(l1);
optimizer.add_parameter(l2);
optimizer.add_parameter(l3);
optimizer.add_parameter(l4);
optimizer.add_parameter(l5);
optimizer.add_parameter(l6);
optimizer.add_parameter(l7);

各 layer は、variable<T> 同様、
optimizeradd_parameter に渡すことができます。

今回はドロップアウトを使用していますので、学習時には以下の設定を
drop.train() = true;
テスト時には以下の設定を行う必要があります。
drop.train() = false;

結果

結果は以下のようになりました。
正答率.png

ソースコード

今回紹介した examples は全て以下の場所で公開しています。
nanikanizer/examples