年末のアドベントカレンダーでニューラルネットワークのライブラリをひとつ紹介するのが恒例になっている自分ですが, 今年は遂に自分でライブラリを開発してしまいました. 本記事では, その新しいライブラリ primitiv1 の紹介をします.
primitiv の特徴
primitiv は元々 NICT 翻訳研で職務の一環として 5 月頃に開発を始めたツールですが, 現在は OSS 化に伴って NAIST の学生などが中心に開発を継続しています. 元々自分が DyNet の開発に後方で関与していたのもあり, DyNet から多くの特徴を受け継いでいます2. 具体的には下記のような特徴があります.
シンプルさ
primitiv は「シンプルなネットワークはシンプルに書ける」ことを目標に設計されています3. また, 各言語ごとの使用法があまり遠くならないように注意しています.多言語対応
primitiv 本体は C++11 で記述されており, バインディング用に C の API を用意しています. 現状では Python3 向けのバインディングが Cython 経由で実装されており, ほぼ全ての機能が使用可能です4. また, C API 経由で Rust, Java, Ruby 向けのバインディングを開発中, もしくは開発予定です. Rust 版については @chantera さんの記事「primitiv-rust でディープラーニングする」が参考になります.動的グラフ構築 (define-by-run)
primitiv は Chainer や PyTorch, DyNet と同じく, 所謂 define-by-run 方式のネットワーク構築を行います. つまり, 実行したプログラムのフローがそのまま計算グラフになります. このような方式は, データごとに計算グラフの形状が異なる自然言語処理やデータ生成, 強化学習などで大きな力を発揮します. 計算グラフの構築自体は実際の値の計算と比較してかなり高速に動作するため, 毎回グラフを作り直すことによるオーバーヘッドはさほど気にならないと思います.ミニバッチ隠蔽
ミニバッチに対する計算の並列化が自動で行われます. このため処理中のデータの形状のみに注意を払えば良く, 論文上の式とほぼ同じ書式で計算を記述することができます.遅延評価5
実際のネットワークの計算は, ユーザが計算結果を取得しようとしたとき初めて実行されます. このとき, 計算グラフに不要な部分があった場合は無視されます. また, 一度計算された結果はキャッシュされ, 無駄な再計算が発生することはありません.デバイス非依存のフロントエンド
ユーザがネットワークの定義, 実行を行うフロントエンド部分と, 実際の個々の計算を行うバックエンド部分のコードが完全に分離しています. このため, ネットワーク部分のコードの修正なしに, 同じネットワークをCPUで計算したりCUDAで計算したり, それ以外の何かで計算したりすることが可能です. 未知のデバイス上で動作させたい場合も, 基本的にはバックエンドの開発のみで対応可能です6. 現状では通常のCPU上で動作するバックエンド2種類と, CUDA, OpenCL用のバックエンドをそれぞれ提供しています7.最小限のソフトウェア依存
CUDA などの特殊なデバイスを使用しない最小構成の場合, gcc/Clang と CMake 3.1 だけあればビルド可能です8. デバイスを追加する際も各オプションは独立しているので, 自分に必要なソフトウェア依存性のみを選択してビルドすることができます.
primitiv の使い方
ここでは C++ と Python3 の両方で primitiv の使用方法を記述します. 本記事執筆時点の公開バージョンは 0.3.1 であり, 下記コードはこのバージョンに準拠しています.
インストール
C++ 版の場合, 基本的には CMake による一般的なインストール方法が使えます. ビルド時のオプションに関してはドキュメントを参照のこと. 環境変数などは適宜変更して下さい.
# (ソースから)
$ git clone https://github.com/primitiv/primitiv
$ cd primitiv
$ mkdir build
$ cd build
$ cmake .. [-D(options)]
$ make
$ make install
Python3 版ではインストール時に C++ のライブラリを内部でコンパイルするため, 少なくとも C++ コンパイラが別途必要です9. また, NumPy などいくつかのライブラリに対する依存があります. オプションなどについては README を参照のこと.
# (PyPIから)
$ pip3 install numpy cython cmake scikit-buil.
$ pip3 install primitiv [--options]
# (ソースから)
$ pip3 install numpy cython cmake scikit-build
$ git clone https://github.com/primitiv/primitiv-python
$ cd primitiv-python
$ git submodule update --init
$ python3 ./setup.py build [--options]
$ python3 ./setup.py install [--options]
初期化
primitiv はライブラリ自身に関する初期化処理は必要ありません. C++ ではヘッダのインクルード, Python3 では import
のみで使用可能になります. このとき, 長い名前空間(特にprimitiv::functions
)に別名を定義しておくと何かと便利です.
C++ ではコンパイル時に -std=c++11
と -lprimitiv
の指定が必要になります.
#include <primitiv/primitiv.h>
using namespace primitiv;
namespace D = primitiv::devices;
namespace F = primitiv::functions;
namespace I = primitiv::initializers;
namespace O = primitiv::optimizers;
int main() {
// 何か処理
return 0;
}
import numpy as np # NumPy が必要
from primitiv import *
from primitiv import devices as D
from primitiv import functions as F
from primitiv import initializers as I
from primitiv import optimizers as O
# 何か処理
primitiv を使用するにあたって最初に行う仕事は, Device
オブジェクトと Graph
オブジェクトの生成と登録です.
Device
はバックエンドを管理するクラスで, 初期化する Device
の種類でどのバックエンドを使用して計算するのかを切り替えることになります. とりあえず, ここでは常に使用可能な Naive
デバイスを初期化します. その後, Device.set_default()
で生成したオブジェクトをデフォルトのバックエンドとして登録します.
Graph
は計算グラフを管理するクラスです. これも Device
と同様に登録します.
// 関数の定義は省略. main() の内部だと思って下さい. 以下同様.
D::Naive dev;
Device::set_default(dev);
Graph g;
Graph::set_default(g);
dev = D.Naive()
Device.set_default(dev)
g = Graph()
Graph.set_default(g)
この記事では詳しく紹介しませんが, Device
と Graph
はユーザが好きなだけ生成して, 用途に応じて使い分けることができます. たとえば, GPU ごとに異なる Device
を生成することで複数の GPU を使った処理を記述できますし, Device
ごとに個別の Graph
を用意するなどすれば data-parallel 処理が記述可能になります10.
簡単な計算
では, 実際に計算をしてみましょう. 列ベクトル を 倍し, 要素ごとに を足すコードは次のようになります.
auto x = F::input<Node>(Shape({3}, 1) /* shpae */, {1, 2, 3} /* value */);
auto y = 2 * x + 3;
// 表示
// #include <iostream> が必要です.
for (float val : y.to_vector()) {
std::cout << val << std::endl;
}
5
7
9
x = F.input(np.array([[1], [2], [3]]))
y = 2 * x + 3
# 表示
for val in y.to_list():
print(val)
5.0
7.0
9.0
primitiv::functions::input()
は数値を primitiv にロードする関数です. C++ 版では, この関数は 2 つの引数 shape と value を必要とします. shape はデータの形状とミニバッチサイズを決める値で, ここではミニバッチサイズ 1 の 3 次元列ベクトルを表す Shape({3}, 1)
を渡しています. なお, ミニバッチサイズが 1 の場合, shape の記述を Shape({3})
, または単に {3}
と省略することができます. value はロードする値のリストで, 具体的には std::vector<float>
などを渡すことになります.
Python3 版の input
は NumPy の ndarray か, ndarray のリストを受け取ります. shape は ndarray の情報から自動的に取得されるため, ユーザが指定する必要はありません11. リストを渡した場合は, それがそのままミニバッチとして読み込まれます.
input
の戻り値は Node
というオブジェクトであり, 仮想的な計算結果を表現します. Node
同士, また Node
と float
の間での通常の算術をサポートしており, また primitiv::functions
名前空間に Node
のための数学関数や, Node
の形状を操作する関数が定義されています. Node
から値を取り出すときは to_<型名>()
という関数を使用します. ここで, Node
に対して実行した計算の履歴は全て Graph
に記録され, これにより primitiv 内部に自動的にニューラルネットワークが構築されていきます.
行列との積はどうなるでしょうか. 次の式
は, primitiv では次のように書けます.
// 2x2 行列 [1, 2; 1, 2] と列ベクトルの積
auto x = F::input<Node>({2}, {1, 2});
auto a = F::input<Node>({2, 2}, {1, 1, 2, 2}); // column-major order
auto y = F::matmul(a, x);
// 表示
for (float val : y.to_vector()) { // column-major order
std::cout << val << std::endl;
}
5
5
x = F.input(np.array([[1], [2]]))
a = F.input(np.array([[1, 2], [1, 2]]))
y = a @ x # または, F.matmul(a, x)
# 表示
for val in y.to_list():
print(val)
5.0
5.0
ここではミニバッチを省略した形で shape を記述しました. 多次元データの場合, C++ では column-major order(Fortran 方式)で value を指定します. Python では配列の並びに関して特に気にする必要はありません.
学習可能なニューラルネットワークを書く
primitiv が簡単な電卓として使えることは分かりました. 次は, 実際に学習可能な識別器を書いてみましょう. primitiv の場合, Parameter
オブジェクトをネットワーク計算に組み込むことで, 勾配法で学習可能なネットワークを記述することができます.
ネットワークを書く前に, 学習させる問題を決めましょう. ここでは次に示すような, オーソドックスな XOR 問題を解く関数 を学習することにします.
学習データはとりあえず次のようにします. 毎回ランダムに発生させても良いかもしれません.
// 入力データ
std::vector<float> input_data {
1, 1, // 第一象限
-1, 1, // 第二象限
-1, -1, // 第三象限
1, -1, // 第四象限
};
// 対応する正解
std::vector<float> label_data {
1, // 第一象限
-1, // 第二象限
1, // 第三象限
-1, // 第四象限
};
# 入力データ
input_data = [
np.array([[ 1], [ 1]]), # 第一象限
np.array([[-1], [ 1]]), # 第二象限
np.array([[-1], [-1]]), # 第三象限
np.array([[ 1], [-1]]), # 第四象限
]
# 対応する正解
label_data = [
np.array([ 1]), # 第一象限
np.array([-1]), # 第二象限
np.array([ 1]), # 第三象限
np.array([-1]), # 第四象限
]
ネットワーク構造は, 次のような多層パーセプトロンにします.
ここで , , で, は適当な隠れ層の次元数(今回は )とします. このネットワークには4つのパラメータ , , , が存在するので, まずはこれらのパラメータに対応する Parameter
オブジェクトを定義します.
const unsigned N = 8;
Parameter pw({1, N}, I::XavierUniform());
Parameter pb({}, I::Constant(0));
Parameter pu({N, 2}, I::XavierUniform());
Parameter pc({N}, I::Constant(0));
N = 8
pw = Parameter([1, N], I.XavierUniform())
pb = Parameter([], I.Constant(0))
pu = Parameter([N, 2], I.XavierUniform())
pc = Parameter([N], I.Constant(0))
Parameter
は shape と initializer を引数にして初期化を行います. shape に指定する数字は, 最後が 1 で終わる場合は省略することが可能です12. また, Parameter
に指定する shape のミニバッチサイズは常に 1 です. initializer としては, 行列に対して Xavier の手法, バイアスに対して定数 を設定しています.
次に, パラメータの学習を担当する Optimizer
オブジェクトを生成します. ここでは SGD を使うことにします. Optimizer
に対して学習対象の Parameter
を登録することで, Optimizer
が管理すべきパラメータを教えてやります.
O::SGD optimizer(0.5);
optimizer.add(pw, pb, pu, pc);
optimizer = O.SGD(0.5)
optimizer.add(pw, pb, pu, pc)
このとき登録しなかった Parameter
は更新されないので, 一部のパラメータを固定するような学習方法にも対応できます. Optimizer
を途中で変更したい場合は, 単に新たな Optimizer
オブジェクトを生成して, パラメータの登録をやり直すことで対応できます.
では, 具体的なネットワークを定義しましょう. 今回は Node
を返す関数の形でネットワークを記述します.
auto build_graph = [&] {
auto x = F::input<Node>(Shape({2}, 4), input_data);
auto w = F::parameter<Node>(pw);
auto b = F::parameter<Node>(pb);
auto u = F::parameter<Node>(pu);
auto c = F::parameter<Node>(pc);
auto h = F::tanh(F::matmul(u, x) + c);
return F::tanh(F::matmul(w, h) + b);
};
def build_graph():
x = F.input(input_data)
w = F.parameter(pw)
b = F.parameter(pb)
u = F.parameter(pu)
c = F.parameter(pc)
h = F.tanh(u @ x + c)
return F.tanh(w @ h + b)
x
のミニバッチサイズが 4 になっていることに注意して下さい. ここでは 2 次元の列ベクトル 4 個を入力として Node
を生成しています. ユーザがミニバッチサイズを気にする必要があるのはデータを入力するときだけで, ネットワークの計算では自動的にミニバッチの調整が行われます13.
入力データと同様, Parameter
を使用する際にもまず Node
に変換する必要があります. これには primitiv::functions::parameter()
を使用します.
なお, ここでは簡単のために関数の中で直接 input
に値を入れてしまいましたが, 実際のプログラムではデータを外から渡すことになると思います.
損失関数も定義しましょう. 今回は正解データとの二乗誤差を使用します.
auto calc_loss = [&](Node y) {
auto t = F::input<Node>(Shape({}, 4), label_data);
auto diff = y - t;
return F::batch::mean(diff * diff);
};
def calc_loss(y):
t = F.input(label_data)
diff = y - t
return F.batch.mean(diff * diff)
primitiv::functions::batch::mean
はミニバッチを平均して一つにまとめる関数です. これにより, calc_loss
が返す Node
は最終的に単一のスカラ値(つまり, Shape({}, 1)
)になります.
一通り材料が揃いました. これらを用いて学習ループを書きましょう.
for (int epoch = 0; epoch < 20; ++epoch) {
std::cout << epoch << ' ';
// グラフの初期化
g.clear();
// 出力の計算
auto y = build_graph();
for (float val : y.to_vector()) {
std::printf("%+.6f, ", val);
}
// 損失の計算
auto loss = calc_loss(y);
std::printf("loss=%.6f", loss.to_float());
std::cout << std::endl;
// 勾配の計算・パラメータの更新
optimizer.reset_gradients();
loss.backward();
optimizer.update();
}
for epoch in range(20):
print(epoch, end=' ')
# グラフの初期化
g.clear()
# 出力の計算
y = build_graph()
for val in y.to_list():
print('{:+.6f},'.format(val), end=' ')
# 損失の計算
loss = calc_loss(y)
print('loss={:.6f}'.format(loss.to_float()))
# 勾配の計算・パラメータの更新
optimizer.reset_gradients()
loss.backward()
optimizer.update()
まず g.clear()
で計算グラフをリセットし, 上で定義した build_graph()
と calc_loss()
を呼び出して新たな計算グラフを構築しています. ここで, y
の値を取得した後に loss
に関する計算を追加していますが, このように必要に応じてネットワークを追加することも可能です. このような書き方は encoder-decoder のような, 計算が終了するタイミングが実行するまで分からないようなモデルでは特に有用となります. また loss
から値を取得するときに使用している to_float()
は, ミニバッチサイズ 1 のスカラに対してのみ使用することが可能です.
最後の 3 行で, 勾配の初期化, loss
を起点とした誤差逆伝播, その結果の勾配を用いたパラメータの更新を行っています. このあたりの書き方は他のツールと大差ないと思います.
これで一つのプログラムが完成しました. 最後に改めて完全なコードと, これらの実行結果を掲載します.
// 実行方法:
// g++ -std=c++11 xor.cc -lprimitiv && ./a.out
#include <cstdio>
#include <iostream>
#include <primitiv/primitiv.h>
using namespace primitiv;
namespace D = primitiv::devices;
namespace F = primitiv::functions;
namespace I = primitiv::initializers;
namespace O = primitiv::optimizers;
int main() {
// デバイスと計算グラフの設定
devices::Naive dev;
Device::set_default(dev);
Graph g;
Graph::set_default(g);
// 入力データ
std::vector<float> input_data {
1, 1, // 第一象限
-1, 1, // 第二象限
-1, -1, // 第三象限
1, -1, // 第四象限
};
// 対応する正解
std::vector<float> label_data {
1, // 第一象限
-1, // 第二象限
1, // 第三象限
-1, // 第四象限
};
// パラメータ
const int N = 8;
Parameter pw({1, N}, I::XavierUniform());
Parameter pb({}, I::Constant(0));
Parameter pu({N, 2}, I::XavierUniform());
Parameter pc({N}, I::Constant(0));
// 学習器
O::SGD optimizer(0.5);
optimizer.add(pw, pb, pu, pc);
// ネットワークの定義
auto build_graph = [&] {
auto x = F::input<Node>(Shape({2}, 4), input_data);
auto w = F::parameter<Node>(pw);
auto b = F::parameter<Node>(pb);
auto u = F::parameter<Node>(pu);
auto c = F::parameter<Node>(pc);
auto h = F::tanh(F::matmul(u, x) + c);
return F::tanh(F::matmul(w, h) + b);
};
// 損失の定義
auto calc_loss = [&](Node y) {
auto t = F::input<Node>(Shape({}, 4), label_data);
auto diff = y - t;
return F::batch::mean(diff * diff);
};
// 学習ループ
for (int epoch = 0; epoch < 20; ++epoch) {
std::cout << epoch << ' ';
// グラフの初期化
g.clear();
// 出力の計算
auto y = build_graph();
for (float val : y.to_vector()) {
std::printf("%+.6f, ", val);
}
// 損失の計算
auto loss = calc_loss(y);
std::printf("loss=%.6f", loss.to_float());
std::cout << std::endl;
// 勾配の計算・パラメータの更新
optimizer.reset_gradients();
loss.backward();
optimizer.update();
}
return 0;
}
#!/usr/bin/env python3
# 実行方法: ./xor.py
import numpy as np
from primitiv import *
from primitiv import devices as D
from primitiv import functions as F
from primitiv import initializers as I
from primitiv import optimizers as O
# デバイスと計算グラフの設定
dev = devices.Naive()
Device.set_default(dev)
g = Graph()
Graph.set_default(g)
# 入力データ
input_data = [
np.array([[ 1], [ 1]]), # 第一象限
np.array([[-1], [ 1]]), # 第二象限
np.array([[-1], [-1]]), # 第三象限
np.array([[ 1], [-1]]), # 第四象限
]
# 対応する正解
label_data = [
np.array([ 1]), # 第一象限
np.array([-1]), # 第二象限
np.array([ 1]), # 第三象限
np.array([-1]), # 第四象限
]
# パラメータ
N = 8
pw = Parameter([1, N], I.XavierUniform())
pb = Parameter([], I.Constant(0))
pu = Parameter([N, 2], I.XavierUniform())
pc = Parameter([N], I.Constant(0))
# 学習器
optimizer = O.SGD(0.5)
optimizer.add(pw, pb, pu, pc)
# ネットワークの定義
def build_graph():
x = F.input(input_data)
w = F.parameter(pw)
b = F.parameter(pb)
u = F.parameter(pu)
c = F.parameter(pc)
h = F.tanh(u @ x + c)
return F.tanh(w @ h + b)
# 損失の定義
def calc_loss(y):
t = F.input(label_data)
diff = y - t
return F.batch.mean(diff * diff)
# 学習ループ
for epoch in range(20):
print(epoch, end=' ')
# グラフの初期化
g.clear()
# 出力の計算
y = build_graph()
for val in y.to_list():
print('{:+.6f},'.format(val), end=' ')
# 損失の計算
loss = calc_loss(y)
print('loss={:.6f}'.format(loss.to_float()))
# 勾配の計算・パラメータの更新
optimizer.reset_gradients()
loss.backward()
optimizer.update()
0 +0.396988, +0.413777, -0.396988, -0.413777, loss=1.164405
1 -0.002260, -0.229380, +0.061146, +0.233151, loss=1.000122
2 +0.102931, +0.167220, +0.047214, -0.136625, loss=0.955088
3 +0.087380, -0.186245, +0.028897, +0.093281, loss=0.908344
4 +0.145716, +0.031858, +0.061776, -0.139635, loss=0.853756
5 +0.204497, -0.263683, +0.079878, +0.050589, loss=0.781337
6 +0.244772, -0.047943, +0.131750, -0.269428, loss=0.691094
7 +0.396851, -0.444518, +0.218414, +0.049852, loss=0.596353
8 +0.308767, -0.104111, +0.214134, -0.583495, loss=0.517871
9 +0.676878, -0.662284, +0.479927, +0.222877, loss=0.496091
10 +0.258607, -0.270388, +0.121530, -0.860052, loss=0.468323
11 +0.863217, -0.604535, +0.733412, +0.086187, loss=0.356493
12 +0.523267, -0.519437, +0.130772, -0.917997, loss=0.305124
13 +0.814626, -0.519769, +0.839389, -0.510235, loss=0.132663
14 +0.741903, -0.762642, +0.653468, -0.750398, loss=0.076335
15 +0.811904, -0.746898, +0.737637, -0.718511, loss=0.061878
16 +0.824272, -0.764224, +0.745015, -0.747868, loss=0.053765
17 +0.836803, -0.777914, +0.760631, -0.762859, loss=0.047372
18 +0.846981, -0.789947, +0.774075, -0.776146, loss=0.042172
19 +0.855658, -0.800531, +0.785868, -0.787765, loss=0.037880
パラメータの初期化がランダムなため, 出力はプログラムを起動する度に変化します. いずれにせよ, 損失が減少し, 各入力に対する出力が と に徐々に近付いていくことが分かります.
その他の機能
以上で, primitiv の基本的な使用方法について述べました. 他にもいくつか必要・有用な機能があるため, 以下で簡潔に述べます(記事が長くなりすぎるので, ここからは C++ の例のみ掲載します. Python の例に関してはサンプルコードなどを参照して下さい).
パラメータとアルゴリズムの統合(Model
)
パラメータ集合をまとめて管理するための Model
という機能が備わっています. たとえば, 上記 XOR の例で 2 度のアフィン変換を直接記述していますが, これは以下のように書き直すことができます.
// アフィン変換を表現する Model クラス
class Affine : public Model {
Parameter pw_, pb_;
public:
Affine() {
// パラメータの登録
add("w", pw_);
add("b", pb_);
}
void init(unsigned input_size, unsigned output_size) {
// パラメータの初期化
pw_.init({output_size, input_size}, I::XavierUniform());
pb_.init({output_size}, I::Constant(0));
}
Node forward(Node x) {
// 計算の実行
auto w = F::parameter<Node>(pw_);
auto b = F::parameter<Node>(pb_);
return F::matmul(w, x) + b;
}
};
// 2 つの Affine を定義
const int N = 8;
Affine u, w;
u.init(2, N);
w.init(N, 1);
// Optimizier に登録
O::SGD optimizer(0.5);
optimizer.add(u, w);
// Affine を使用したネットワーク
auto build_graph = [&] {
auto x = F::input<Node>(Shape({2}, 4), input_data);
auto h = F::tanh(u.forward(x));
return F::tanh(w.forward(h));
};
Model
に対し他の Model
オブジェクトを階層的に保持させることも可能であり, うまく使うことで複雑なネットワークを容易に記述できるようになります.
セーブ・ロード
Parameter
と Model
はデータのファイル書き出し・読み込みが可能です. 下記のように使用します.
class Foo : public Model { ... };
Parameter p;
Foo m;
// 書き出し
p.save("param.data");
m.save("foo.data");
// 読み込み
p.load("param.data");
m.load("foo.data");
対応フォーマットは今のところ MessagePack ベースの独自形式ですが, 他のデータ形式への要望があれば対応可能と思います.
ネットワークの先行評価(即時計算)
ここまでのサンプルでは Node
クラスを用いてネットワークの計算を行いましたが, 実は primitiv にはもう一つ, Tensor
というクラスが存在します. Tensor
は基本的には Node
と同じように使えますが, 以下の点が異なります.
-
Node
は仮想的な計算結果を表しますが,Tensor
は計算結果の実体を表し, 値を直接保持しています. -
Tensor
に関する式が実行されたタイミングで, 直ちに実際の値が計算されます. -
Tensor
の値はキャッシュされません.Tensor
オブジェクトが破棄されるタイミングで値も消滅します. -
Tensor
は計算グラフを構築しません. つまり,Graph
に関する処理を必要としません. -
Tensor
では勾配を求めることができません.
たとえば, この記事の最初に書いた「Node
に を掛けて を足す」処理は, Tensor
でも同様に書くことができます.
auto x = F::input<Tensor>({3}, {1, 2, 3});
auto y = 2 * x + 3;
for (float val : y.to_vector()) {
std::cout << val << std::endl;
}
5
7
9
この場合, x
や y
といった変数で保持されていない一時的な Tensor
オブジェクトは, 計算の終了と同時に直ちに破棄されます.
Node
と比較すると制約の強い Tensor
ですが, 計算グラフを構築するオーバーヘッドが発生しない, 不要な値が直ちに消滅するためメモリ消費量が少ないといった特徴があり, テストやデプロイ環境など, 計算グラフが不要な場面で効率よくネットワークを計算することができます. また, Node
と Tensor
の両方で使用可能な Model
や関数をテンプレートを用いて記述することが可能です. 公式の encoder-decoder サンプルで LSTM や encoder-decoder モデルをこのように実装していますので, 興味のある方はご覧下さい14.
今後の予定など
primitiv はまだ開発版であり, いくつかの面では物足りない点が残っています. 特に, 現状では開発を優先しているため, ドキュメントの整備が中々追い付いていません. 疑問点に関しては, 開発グループの人間に質問して頂ければ随時お答えします.
未実装の重要な関数も残っています. たとえば, 現状では畳み込みやプーリングといった関数は実装されていません. これは単純に開発者が NLP 屋ばかりであるためで, 自然と実装の優先順位が下がってしまっています. 正式版(v1.0.0)までには実装されると思います.
計算グラフに関する最適化, 及び分散処理などへの拡張も行う予定です. 特に, DyNet15 に実装されている 自動ミニバッチ化 は複雑な計算グラフに対する非常に強力な最適化手法ですので, 近日中に実装する予定です.
-
primitiv というのは開発上のコードネームですが, この名称で既に色々登録してしまっているので, もうこのままでもいいかなぁと思っていたりします. 一般語なので検索性能が悪い, 他の用語と衝突しやすいという問題はありますが. ↩
-
DyNet の開発サイドとは緩く協力しており, primitiv で良い機能が開発された場合は DyNet にも反映されると思います. 逆もしかり. ↩
-
名称の由来はこの辺りにあります. フロントエンドの使い方が複雑怪奇・環境依存になるほど初学者が辛い思いをするので, 最初に覚える機能は最小限に留めるべき, という思想に基づいています. ↩
-
今のところ Python2 に対応する予定はありません. ↩
-
遅延評価というと色々語弊がありそうですが, 本記事ではとりあえず「計算グラフだけ先に構築して, 実際のデータは後から必要に応じて計算する」ような計算方式を指して使用しています. ↩
-
他のデバイス向けのバックエンドに関しては, 我々がそのデバイスを入手可能であれば公式で対応できるかもしれません. ↩
-
OpenCL デバイス(
primitiv::devices::OpenCL
)については, AMD や Intel の GPU 上でも動作することを確認しています. ↩ -
ただし, デフォルトで使用可能なデバイス (
primitiv::devices::Naive
) は現状極めて計算が遅いので, 個人的には最低でも Eigen 対応版のデバイスを追加することをオススメします. ↩ -
これはPyPIのバイナリ配布に関する方針(PEP 513)が関係しています. 他のツールでも同様の問題があり, 対処方法は様々なようです. ↩
-
現状では一部並列処理に未対応の部分があり, 今後のバージョンで正式に対応する予定です. ↩
-
C++ 版の
input
に対応するraw_input
という関数もあります. ↩ -
このため, 列ベクトルである
{N}
と, 列数 1 の行列である{N, 1}
は等価に扱われます. ↩ -
具体的には, 2 個の
Node
に対する何らかの二項演算(算術, 冪乗, 行列積など)が発生したとき, どちらかのNode
のミニバッチサイズが 1 であれば, 自動的にもう一方のミニバッチサイズになるようブロードキャストが行われます. 双方のミニバッチサイズが 1 でなく, また値が異なる場合はエラーとなります. ↩ -
このサンプルは単体でニューラル翻訳のツールとして使用できます. Attentional encoder-decoder のサンプルは, 試した NICT の職員いわく「Nematus より性能がいい(場合がある)」らしいです. ↩
-
完全に余談ですが, Chris Dyer 先生による DyNet の 2 番目のコミット(当時はまだ cnn という仮名だった)を見ると, 昨今 define-by-run と呼ばれるネットワーク定義方式が既に採用されていることが分かります. また, DyNet が最初から遅延評価方式であったことも分かります. これは Chainer の最初のコミットから遡ること 2 か月前の話で, 日本で当時の DyNet を知るのは Graham Neubig 先生だけであった気がします(自分が Chainer で NMT を書き始めた頃, Graham さんにコードを見せたら「cnn(DyNet)と似ているね」というような会話がありました). 日本では Chainer が define-by-run の旗手として極めて流行しましたが, もしかしたら DyNet が一時代を築く世界もあったのかもしれません. ↩