ニューラルネットのフレームワークDyNetの紹介

  • 40
    いいね
  • 0
    コメント

本記事はDeepLearning Advent Calendar 12月5日の記事です。

本記事ではニューラルネットワークのフレームワークの一つであるDyNetを簡単に紹介します。
このフレームワークは米カーネギーメロン大学言語技術研究所が中心となって開発しているライブラリで、少し縁があって自分も裏で開発に参加しています。自然言語処理の国際会議であるEMNLPのチュートリアルでも使用されて、こちらでも割と人気があったようです。
DyNetの特徴としては:

  • C++とPythonで使用可能
  • インクリメンタルなネットワーク計算が可能
  • ミニバッチの隠蔽

が挙げられます。
2つ目の特徴を持つライブラリとしてはPFNのChainerなども挙げられ、主にrecurrent/recursive neural network系の実装が必要となる自然言語処理等の分野で力を発揮します。
3つ目の特徴はかなり強力で、ユーザが投げたデータに応じてミニバッチ処理かそうでないか、ミニバッチ処理ならバッチの大きさ等を自動的に判別するので、ネットワークを記述するコード上ではそれを区別する必要がありません。論文に書いてある式をそのまま書けば、基本的には自動的にミニバッチ処理に対応したコードになるわけです。
2016/12/29 追記:現状は入力部分だけミニバッチサイズを指定する必要がある場合があります。(下記コード参照)
メインの対応言語がC++なので、低レベルな領域で他のライブラリと連携したい場合には良い選択肢になるでしょう。DyNet自体はインターフェイスが高度に抽象化されているため、ネットワーク構築部分の見通しは他のフレームワークと比較しても遜色ないと思います。

とりあえずインストールは済んでいるものとして、以降ではC++上でのDyNetの基本的な使い方を解説します。

初期化・後片付け

DyNetは最初に内部で指定した量のメモリとデバイスを確保し、それを最後まで使い続けます。このため最初にdynet::initialize()を呼び出してライブラリを初期化してやる必要があります。
これには2種類の方法があります。一つはDynetParams構造体に必要な値をセットして渡す方法、もう一つはargcargvを渡してコマンドライン引数をDyNetに処理してもらう方法です。後者は前者のラッパーとして実装されているので、以下では構造体で明示的に初期化する例を載せています。

処理が終わったらdynet::cleanup()を呼び出して後片付けをすることになりますが、他の処理にメモリが必要等の理由がなければ、この関数は呼び出す必要はありません。その場合、プログラム終了時に自動的に後片付けが行われます。

#include <vector>
#include <dynet/init.h>

int main(int argc, char *argv[]) {
  dynet::DynetParams params;

  // 確保するメモリ量を3つ指定する(MB単位)。
  // それぞれforward-path, backward-path, パラメータで使用する量。
  params.mem_descriptor = "1024,1024,2048";
  // 下記のように1つだけ指定することも可能。
  // その場合は内部的に3等分したものがそれぞれに使用される。
  //params.mem_descriptor = "4096";

  params.random_seed = 0;           // ランダムシード。0なら適当に初期化
  params.weight_decay = 0.0f;       // L2正則化の強さ
  params.shared_parameters = false; // パラメータをshared memory上に置くかどうか

#if HAVE_CUDA
  // 以下はCUDAに関連付けてインストールした場合の設定。
  // 下記のように設定するとメモリに余裕があるGPUを1個勝手に選んで使う。
  // 複数GPUには今のところ未対応。
  const unsigned MAX_GPUS = 1024; // 適当に大きな値。挿さっているGPUの枚数以上にすればよい。
  params.ngpus_requested = false;
  params.ids_requested = false;
  params.requested_gpus = -1;
  params.gpus_mask = std::vector<int>(MAX_GPUS, 0);
#endif

  // なんか色々書きましたが、基本的にデフォルト値で大丈夫だと思います。dynet/init.h参照。
  // ただしgpus_maskは上記のような初期化が必要なので注意。

  // 実際に初期化。
  dynet::initialize(params);

  {
    //
    // ここにやりたい処理を書く。
    //
  }

  // 後片付け
  // この時点でDyNet関係のオブジェクトが全て消滅している必要がある。
  // それ以外の場合に呼び出すとエラーの原因になる。
  // 特に必要がなければ呼び出さなくてよい。
  dynet::cleanup();

  return 0;
}

コンパイルには-std=c++11が必要です。また実行ファイルはCPUで計算させる場合はlibdynet.so、CUDAで計算させる場合はlibgdynet.solibdynetcuda.soにリンクする必要があります。場合によってはCUDA関係のライブラリにも自分でリンクする必要があるかもしれません。

簡単な例

まず、学習の必要がない(パラメータが存在しない)ネットワークを作って計算させてみます。以下の例は入力値を2倍し、3を足した結果をDyNetで計算しています。

#include <vector>
#include <dynet/dynet.h> // ComputationGraph
#include <dynet/expr.h>
#include <dynet/init.h>
#include <dynet/tensor.h> // as_scalar, as_vector

using namespace std;
namespace DE = dynet::expr;

// ベクトルをprintするためのヘルパ
void print_vector(const vector<dynet::real> & values) {
  for (const auto val : values) {
    cout << static_cast<float>(val) << ' ';
  }
  cout << endl;
}

void run() {
  {
    // スカラ
    dynet::ComputationGraph cg;
    DE::Expression input_expr = DE::input(cg, 1.0f);
    DE::Expression output_expr = 2.0f * input_expr + 3.0f;
    // dynet::realは今のところfloatと等価。
    dynet::real output_value = dynet::as_scalar(cg.forward(output_expr));
    cout << static_cast<float>(output_value) << endl;
  }

  {
    // ベクトル
    dynet::ComputationGraph cg;
    vector<float> input_values {1.0f, 2.0f, 3.0f};
    // 第2引数で次元を指定する。
    // 1次元の場合は縦ベクトルになる。
    DE::Expression input_expr = DE::input(cg, {3}, input_values);
    DE::Expression output_expr = 2.0f * input_expr + 3.0f;
    vector<dynet::real> output_values = dynet::as_vector(cg.forward(output_expr));
    ::print_vector(output_values);
  }

  {
    // 行列の場合はベクトルにpackした値をやりとりする。
    // 値は縦方向に入る。つまり:
    // [[1, 3],
    //  [2, 4]]
    dynet::ComputationGraph cg;
    vector<float> input_values {1.0f, 2.0f, 3.0f, 4.0f};
    DE::Expression input_expr = DE::input(cg, {2, 2}, input_values);
    DE::Expression output_expr = 2.0f * input_expr + 3.0f;
    vector<dynet::real> output_values = dynet::as_vector(cg.forward(output_expr));
    ::print_vector(output_values);
  }
}

int main(int argc, char *argv[]) {
  // 初期化
  ::run();
  // 後片付け
}

Expression型は計算情報だけを保持した仮想的な型で、この変数に対して通常のC++と同じ方法で計算式を記述します。
Chainerとは異なり、Expressionを生成した段階では実際の計算はまだ行われていません。最終的に必要な値を記述するExpressionに対してComputationGraph::forward()を呼び出すことで実際の値を計算させます。

odashi@lab $ g++ -std=c++11 -I/path/to/eigen -I/path/to/dynet -L/path/to/dynet/build/dynet sample.cc -ldynet
odashi@lab $ ./a.out
[dynet] random seed: 269777318
[dynet] allocating memory: 1024,1024,2048MB
[dynet] memory allocation done.
5
5 7 9
5 7 9 11

ベクトルや行列同士の演算も可能です。値の初期化には、適切な順序で要素の値を格納したvectorを渡します。
vectorを初期化できれば何でもよいので、initializer_listを直接投げてもOKです。

// ...
void run() {
  dynet::ComputationGraph cg;
  // [[1],
  //  [2]]
  vector<float> in_val1 {1.0f, 2.0f};
  auto in1 = DE::input(cg, {2}, in_val1);

  // [[1, 3],
  //  [2, 4]]
  vector<float> in_val2 {1.0f, 2.0f, 3.0f, 4.0f};
  auto in2 = DE::input(cg, {2, 2}, in_val2);

  // [[10,   0],
  //  [ 0, 100]]
  auto mul = DE::input(cg, {2, 2}, {10.0f, 0.0f, 0.0f, 100.0f});

  // 行列 * 縦ベクトル
  // [[ 10],
  //  [200]]
  auto out1 = mul * in1;
  auto out_val1 = dynet::as_vector(cg.incremental_forward(out1));
  ::print_vector(out_val1);

  // 横ベクトル * 行列
  // [[10, 200]]
  auto out2 = DE::transpose(in1) * mul;
  auto out_val2 = dynet::as_vector(cg.incremental_forward(out2));
  ::print_vector(out_val2);

  // 行列 * 行列
  // [[10, 300],
  //  [20, 400]]
  auto out3 = in2 * mul;
  auto out_val3 = dynet::as_vector(cg.incremental_forward(out3));
  ::print_vector(out_val3);

  // [[ 10,  30],
  //  [200, 400]]
  auto out4 = mul * in2;
  auto out_val4 = dynet::as_vector(cg.incremental_forward(out4));
  ::print_vector(out_val4);

  // [[60100, 120300],
  //  [80200, 160600]]
  auto out5 = out3 * out4;
  auto out_val5 = dynet::as_vector(cg.incremental_forward(out5));
  ::print_vector(out_val5);
}
// ...
odashi@lab $ ./a.out
[dynet] ...
10 200
10 200
10 20 300 400
10 200 30 400
60100 80200 120300 160600

ここでforward()ではなくincremental_forward()を使っています。forward()は今までに登録された全ての式を毎回計算対象としますが、incremental_forward()では既に計算された値については内部に保持しており、新たに追加された部分のみの計算を行います。これは逐次的な出力を必要とする言語モデルや翻訳モデルの実装に役立ちます。

パラメータの学習

ここまでで示したのは定数同士の演算例ですが、実際のネットワークには変更可能なパラメータが存在して、特定の学習器でそれらを調整することになります。DyNetではModelクラスがパラメータと学習器の対応付けの面倒を見ることになっています。コード全体の流れとしては、

  1. ModelTrainerを登録
  2. Modelに各種Parameterを登録
  3. ComputationGraph上にネットワークを構築
  4. ComputationGraph::forward()ComputationGraph::backward()Trainer::update()
  5. 3.〜4.を必要なだけ繰り返し

のようになります。
以下は2変数XOR問題を多層パーセプトロンで解く例です。

#include <vector>
#include <dynet/dynet.h> // ComputationGraph
#include <dynet/expr.h>
#include <dynet/init.h>
#include <dynet/tensor.h> // as_scalar
#include <dynet/training.h> // 各種Trainer
#include <dynet/model.h> // Model

using namespace std;
namespace DE = dynet::expr;

void run() {
  // 学習器とパラメータを管理するオブジェクト
  dynet::Model model;

  // 学習に普通のSGDを使う
  const float e0 = 0.1;
  const float edecay = 0.1; // eta = e0 / (1 + epoch * edecay)
  dynet::SimpleSGDTrainer trainer(model, e0, edecay);

  // パラメータ
  // auto == dynet::Parameter
  const unsigned HIDDEN = 8;
  auto p_xh_w = model.add_parameters({HIDDEN, 2});
  auto p_xh_b = model.add_parameters({HIDDEN});
  auto p_hy_w = model.add_parameters({1, HIDDEN});
  auto p_hy_b = model.add_parameters({1});

  // 入力 (dynet::realは内部的にfloatと等価)
  vector<dynet::real> input_values {
     1.0,  1.0,
     1.0, -1.0,
    -1.0,  1.0,
    -1.0, -1.0,
  };
  // 正解
  vector<dynet::real> output_values {
    -1.0,
     1.0,
     1.0,
    -1.0,
  };

  dynet::ComputationGraph cg;

  // ネットワークを構築
  // auto == DE::Expression
  //auto x = DE::input(cg, {2}, input_values); 2016/12/29: こちらは間違い(仕様で通ってしまう。注意)
  auto x = DE::input(cg, dynet::Dim({2}, 4), input_values);  // 入力層 (ミニバッチ==4)
  auto xh_w = DE::parameter(cg, p_xh_w);
  auto xh_b = DE::parameter(cg, p_xh_b);
  auto h = DE::tanh(xh_w * x + xh_b);         // 隠れ層
  auto hy_w = DE::parameter(cg, p_hy_w);
  auto hy_b = DE::parameter(cg, p_hy_b);
  auto y = hy_w * h + hy_b;                   // 出力層
  //auto t = DE::input(cg, {1}, output_values); 2016/12/29: こちらは間違い
  auto t = DE::input(cg, dynet::Dim({1}, 4), output_values); // 正解 (ミニバッチ==4)
  auto loss = DE::squared_distance(t, y);     // との2乗誤差
  auto sum_loss = DE::sum_batches(loss);      // の総和

  // パラメータを10回更新
  for (int i = 0; i < 10; ++i) {
    // forward: 実際の計算結果を生成
    float avg_loss = dynet::as_scalar(cg.forward(sum_loss)) / 4.0;
    // backward: 勾配の計算
    cg.backward(sum_loss);
    // パラメータの更新
    trainer.update();
    // 世代の更新(edecay==0なら不要)
    trainer.update_epoch();
    cout << "epoch=" << (i + 1) << ", loss=" << avg_loss << endl;
  }
}

int main(int argc, char *argv[]) {
  // 初期化
  ::run();
  // 後片付け
}
odashi@lab $ ./a.out
[dynet] ...
epoch=1, loss=1.13717
epoch=2, loss=0.714576
epoch=3, loss=0.582772
epoch=4, loss=0.45872
epoch=5, loss=0.354475
epoch=6, loss=0.270747
epoch=7, loss=0.205377
epoch=8, loss=0.154995
epoch=9, loss=0.116605
epoch=10, loss=0.0876631

パラメータはランダムに初期化される(ように設定している)ので、実行結果は毎回変わります。

注目したいのはネットワークを構築している部分で、input()がデータを自動的に整形してミニバッチ化しています。生成されるExpressionオブジェクトはミニバッチを完全に隠蔽しているので、後の計算では実際の数式を記述することに専念できます。
2016/12/29 追記:現状の実装ではDimオブジェクトに明示的にミニバッチサイズを渡してやる必要があります。渡すvectorのサイズが一致しなくても受理されてしまうようなので注意。
ミニバッチサイズを自動的に判別する実装はそこまで難しくないし、サイズ不一致で受理されてしまうのはちょっと怖いので、可能なら修正しておこうと思います。

また、このコード例では入出力が常に同じなので、ネットワークを1度だけ生成して学習時に使い回しています。入出力が変化する場合はその都度新たなComputationGraph上にネットワークを構築する必要があります。この辺りの挙動はChainerと基本的に同じで、ユーザが自分の好きなタイミングで明示的にforwardする点に差異があります。

2016/12/30 追記:ミニバッチサイズを指定する必要があるかどうかは、入力した値を共有したいかどうかに依存します。下記の例では、ミニバッチごとに異なる行列、または共通の行列を乗じています。

void run() {
  dynet::ComputationGraph cg;

  auto x = DE::input(cg, dynet::Dim({2}, 3), {
      1, 2,
      3, 4,
      5, 6});

  auto w1 = DE::input(cg, dynet::Dim({2, 2}, 3), {
      1, 0, 0, 1,
      0, 1, 1, 0,
      1, 2, 1, 2});
  auto y1 = w1 * x;
  for (const float val : dynet::as_vector(cg.forward(y1))) {
    cout << val << ' ';
  }
  cout << endl;

  auto w2 = DE::input(cg, {2, 2}, {0, 1, 1, 0});
  auto y2 = w2 * x;
  for (const float val : dynet::as_vector(cg.forward(y2))) {
    cout << val << ' ';
  }
  cout << endl;
}
odashi@lab $ ./a.out
[dynet] ...
1 2 4 3 11 22 
2 1 4 3 6 5 

その他

他にもRNNを使用する例などが公式のexamplesに転がっているので、興味のある方は読んでみましょう。
現時点では大分後発のフレームワークなので、実行速度やメモリ使用量の点で他のフレームワークより大雑把な動き方をしたり、細かいバグが残っていたりと若干の難点はあります。開発陣の対応はかなり速い部類だと思うので、使ってみて文句があればissuesに投げてみるといいかもしれません。

というか日本人でメインに使っているのが自分しかいない気がします(泣)
C++使える人にはマジ便利なので使ってね!

おまけ

何番煎じか分かりませんが、ニューラル翻訳するツールを書きました。バックエンドが全部DyNetになっています。
こちらについてはまた今度書く予定です。

この投稿は DeepLearning Advent Calendar 20165日目の記事です。