はじめに
深層学習フレームワークPyTorchのインターフェースはPythonであるが、バックエンドはC++である。現在、C++のインターフェース(C++11)も整備されつつある。前々回と前回からPyTorch C++(LibTorch 1.2)による実装例の解説を行っている。今回は第3回目、最終回である。
コードの説明
本シリーズの目次は以下の通り。今回はこの内5以降を全て説明する。全ソースはここにある。
- コード全体の概説
- 引数の抽出
- デバイスの選択
- モデルの定義
- データセットの読み込み
- 最適化器の準備
- 訓練済みモデルのロード
- モデルの訓練
- モデルの保存
データセットの読み込み
関数main
内の該当部分は以下の通り。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
//_/_/_/ Load the Data const auto train_dataset = load_dataset(TRAIN_PATHS). map(torch::data::transforms::Normalize<>(0, 255.0)). map(torch::data::transforms::Stack<>()); const size_t train_dataset_size = train_dataset.size().value(); const auto test_dataset = load_dataset(TEST_PATHS). map(torch::data::transforms::Normalize<>(0, 255.0)). map(torch::data::transforms::Stack<>()); const size_t test_dataset_size = test_dataset.size().value(); const auto train_loader = torch::data::make_data_loader<torch::data::samplers::RandomSampler>( std::move(train_dataset), torch::data::DataLoaderOptions().batch_size(batch_size).workers(2)); const auto test_loader = torch::data::make_data_loader<torch::data::samplers::SequentialSampler>( std::move(test_dataset), torch::data::DataLoaderOptions().batch_size(batch_size).workers(2)); |
3行目の関数load_dataset
の返り値はクラスCustomDataset
のインスタンスtrain_dataset
である。このインスタンを引数にして、13行目でtrain_loader
を作成する。関数torch::data::make_data_loader
は、テンプレート関数であり、テンプレート引数として、RandomSampler
とSequentialSampler
を取り得る。前者は、データをランダムに返すので、訓練時に使うことができる。後者はデータをそのまま返すので、評価時に使うことができる(17行目)。
クラスCustomDataset
は、ユーザ定義のデータセットを使用するため定義したクラスである(custom_dataset.h)。その中身は以下の通り。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
class CustomDataset : public torch::data::Dataset<CustomDataset> { private: // Declare 2 vectors of tensors for images and labels std::vector<torch::Tensor> images_; std::vector<torch::Tensor> labels_; public: // Constructor CustomDataset(std::vector<std::ifstream>& ifs); // Override get() function to return tensor at location index torch::data::Example<> get(std::size_t index) override { torch::Tensor sample_img = images_.at(index); torch::Tensor sample_label = labels_.at(index); return {sample_img.clone(), sample_label.clone()}; }; // Return the length of data torch::optional<std::size_t> size() const override { return labels_.size(); }; }; |
データセットを扱うクラスはテンプレートクラスtorch::data::Dataset
をpublic継承し、2つの仮想関数get
とsize
を実装しなければならない。前者は指定したインデックスにおける画像とラベルを対にしたものを返す関数、後者はデータ数を返す関数である。前者の値を返す部分(16行目)でclone
を使っていることに注意する。クラスtorch::Tensor
のコピーコンストラクタは浅いコピーを行う実装になっている。深いコピーを返す必要がある場合は明示的にclone
を呼ぶ必要がある。
ところで、上の親クラスのテンプレート引数が子クラス自身になっていることに気付いただろうか。これはC++の有名なidiomであり、Curiously Recurring Template Pattern(CRTP)と言う名前が付いている。静的にポリモーフィズムを実現する仕組みであり、例えば以下のように使うことができる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
template<typename Derived> class Base { public: void interface() { static_cast<Derived*>(this)->implementation(); } void implementation() { std::cout << "Base::implementation" << std::endl; } }; class Derived_1 : public Base<Derived_1> { public: void implementation() { std::cout << "Derived_1::implementation" << std::endl; } }; class Derived_2 : public Base<Derived_2> { public: }; |
これらを以下のコードで実行すると
1 2 3 4 |
Derived_1 d1{}; d1.interface(); Derived_2 d2{}; d2.interface(); |
以下の出力を得る。
1 2 |
Derived_1::implementation Base::implementation |
子クラスにimplementation
が実装されていればそれが実行され、実装されていなければ親クラスの実装が実行される。つまり、動的ポリモーフィズムと同じ振る舞いを静的に実現できるのである。動的ポリモーフィズムと異なり、仮想関数テーブルなどのオーバヘッドが存在しないので、高速に動作する。
クラスCustomDataset
の実装部分(custom_dataset.cpp)では、CIFAR10からダウンロードしたバイナリファイルを読み込む作業を行う。煩雑なので掲載は割愛する。ソースを見て欲しい。
話が長くなったのでもう一度関数main
内のデータ読み込み部分を示す。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
//_/_/_/ Load the Data const auto train_dataset = load_dataset(TRAIN_PATHS). map(torch::data::transforms::Normalize<>(0, 255.0)). map(torch::data::transforms::Stack<>()); const size_t train_dataset_size = train_dataset.size().value(); const auto test_dataset = load_dataset(TEST_PATHS). map(torch::data::transforms::Normalize<>(0, 255.0)). map(torch::data::transforms::Stack<>()); const size_t test_dataset_size = test_dataset.size().value(); const auto train_loader = torch::data::make_data_loader<torch::data::samplers::RandomSampler>( std::move(train_dataset), torch::data::DataLoaderOptions().batch_size(batch_size).workers(2)); const auto test_loader = torch::data::make_data_loader<torch::data::samplers::SequentialSampler>( std::move(test_dataset), torch::data::DataLoaderOptions().batch_size(batch_size).workers(2)); |
関数load_dataset
の返り値に対し、関数map
を2度呼び出している(4,5行目)。最初のmap
は、データの値を255で割り、値を[0,1]に収める処理である。2番目のmap
は、バッチ単位のデータセットの持ち方を指定している。上のようにStack
を呼び出すと、画像をバッチ数だけ集めたコンテナAとラベルをバッチ数だけ集めたコンテナBをペアにした構造が作られる。一方、2番目のmap
を呼ばない場合、画像とラベルのペアをバッチ数だけ集めた構造が作られる。前者の方が訓練時のコードが書きやすい。3行目のtrain_dataset
が出来上がったら、torch::data::make_data_loader
を使って、データローダtrain_loader
を作る(13行目)。データローダでデータを取り出しながら訓練を行うことになる。torch::data::make_data_loader
の第2引数にはバッチサイズとスレッド数が渡されている。
最適化器の準備
関数main
の該当部分は以下の通り。
1 2 3 4 5 6 |
//_/_/_/ Configure a optimizer torch::optim::Adam optimizer { model->parameters(), torch::optim::AdamOptions(LEARNING_RATE) }; |
第1引数でモデルの全パラメータを、第2引数に学習率LEARNING_RATE
を渡している。
訓練済みモデルのロード
関数main
の該当部分は以下の通り。
1 2 3 4 5 6 7 8 |
if (resumes) { std::cout << "resume training!" << std::endl; const auto trained_model_path = extract_parameter<std::string>("trained_model_path", "invalid path", vm); const auto trained_opt_path = extract_parameter<std::string>("trained_opt_path", "invalid path", vm); torch::load(model, trained_model_path); torch::load(optimizer, trained_opt_path); } |
訓練済みモデルを読み込んで訓練の続きを行う場合は、モデルだけでなく、最適化器のインスタンスoptimizer
も読み込む必要があることに注意する。
モデルの訓練
関数main
の該当部分は以下の通り。
1 2 3 4 5 |
for (auto epoch = 1; epoch <= epochs; ++epoch) { train(epoch, model, device, *train_loader, optimizer, train_dataset_size, verbose); test(model, device, *test_loader, test_dataset_size, verbose); } |
関数train
の中身は以下の通り(main.cpp)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
template<typename DataLoader> void train( size_t epoch, Architecture& model, torch::Device device, DataLoader& data_loader, torch::optim::Optimizer& optimizer, size_t dataset_size, bool verbose) { model->train(); size_t batch_idx {0}; for (auto& batch : data_loader) { auto data = batch.data.to(device); auto targets = batch.target.to(device); optimizer.zero_grad(); auto output = model->forward(data); auto pred = output.argmax(1); auto batch_size = output.size(0); targets = targets.reshape({batch_size}); auto correct = pred.eq(targets).sum().template item<int64_t>(); auto loss = torch::nll_loss(output, targets); // we should be able to use cross_entropy_loss -> unfortunately, cross_entropy_loss is not implemented in c++. AT_ASSERT(!std::isnan(loss.template item<float>())); loss.backward(); optimizer.step(); if (verbose && (batch_idx % LOG_INTERVAL == 0)) { std::printf("\rTrain Epoch: %ld [%5ld/%5ld] Loss: %.4f | Accuracy: %.3f", epoch, batch_idx * batch.data.size(0), dataset_size, loss.template item<float>(), static_cast<double>(correct) / batch_size); } ++batch_idx; } } |
- 11行目:訓練モードに設定する。他に評価モードが存在する(後述)。
- 13行目:データローダからバッチ単位でデータセットを取り出す。
- 15,16行目:画像とラベルをGPUデバイス側へ転送する。
- 17行目:最適化器の初期化。
- 19行目:ネットワークに入力を与え順伝播させる。
- 20行目:出力(10次元ベクトル)の最大要素のインデックスを求める。これが予測したラベルである。
- 24行目:正解ラベルと予測ラベルの一致度を計算する。一致すれば1、不一致なら0をバッチ数分だけ足す。あとでバッチ数で割り、平均値が計算される。
- 26行目:
torch::nll_loss
はNegative Log Likelihood Loss関数である。これが、最小にすべき目的関数である。 - 29,30行目:偏微分を行い、誤差逆伝播を行う。
- 32行目から40行目:ログ出力
関数test
の中身は以下の通り。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
template<typename DataLoader> void test( Architecture& model, torch::Device device, DataLoader& data_loader, size_t dataset_size, bool verbose) { torch::NoGradGuard no_grad{}; model->eval(); double test_loss {0}; int32_t correct {0}; for (const auto& batch : data_loader) { auto data = batch.data.to(device); auto targets = batch.target.to(device); auto output = model->forward(data); auto batch_size = output.size(0); targets = targets.reshape({batch_size}); test_loss += torch::nll_loss( output, targets, {}, Reduction::Reduction::Sum).template item<float>(); auto pred = output.argmax(1); correct += pred.eq(targets).sum().template item<int64_t>(); } test_loss /= dataset_size; if (verbose) { std::printf( "\nTest set: Average loss: %.4f | Accuracy: %.3f\n\n", test_loss, static_cast<double>(correct) / dataset_size); } } |
- 10行目:評価モードに設定する。
- 13行目:データローダからバッチ単位でデータセットを取り出す。
- 15,16行目:画像とラベルをGPUデバイス側へ転送する。
- 17行目:ネットワークに入力を与え順伝播させる。
- 20行目から24行目:損失を計算する。
- 25行目:予測ラベルを求める。
- 26行目:予測値と正解値が一致した数を累積する。
- 29行目:損失値のバッチサイズでの平均値を求める。
モデルの保存
関数main
内の該当部分は以下の通り。
1 2 3 4 |
//_/_/_/ Save the model torch::save(model, model_path); torch::save(optimizer, opt_path); |
後で学習を再開するにはmodel
だけでなく、optimizer
も保存しなければならない。
まとめ
今回を含めて3回に渡ってPyTorch C++による実装例を見てきた。ホームページを見ると、Pythonインタフェースから予測できるようなC++インタフェースを整備していると書かれており、確かにその印象を受けた。「これどうやって書くんだ?」と思った時は大抵、Pythonインタフェースからの推測で当たることが多い。
クラスのインスタンスがstd::shared_ptrで所有者管理されること、データセットを扱うときCRTPと呼ばれる静的ポリモーフィズムが使われることなど、C++としても興味深い機能が採用されていることを見た。実務に使うか否かは別にして、C++で深層学習を行う1つのツールとして紹介した。ただし、コード記述と実行の間にコンパイル作業が入るので、スクリプト言語に慣れた身にとっては面倒臭いことは確かである。