Chainer用コマンドラインツールChainerCMD

- サッとモデルとデータセットのコードだけ書いて何かを試してみたいときなどに毎度同じようなtrainingコードを書くのは面倒です。
- ChainerにはTrainerという便利なツールがありますが、それでも毎回同じようなExtensionを追加するコードを書いてtrain.pyを作るのは億劫です。
- 学習の設定やモデルファイル、データセットのファイルなどへのパスが書かれたYAMLファイルを引数で渡せば、あとはいつもの感じで学習を開始してくれるコマンドラインツールがあると楽やな、と思い作りました。
- 基本的に完全なるオレオレサボりツールなので、全く人に使われることは考えて作られてませんが、せっかくなので公開しました。

    ChainerCMD
    https://github.com/mitmul/chainercmd

## 1. インストール

```bash
$ pip install chainercmd
```

## 2. 使い方

```bash
$ chainer init
```

とすると以下の4つのファイルが作られます。

- config.yml
- custom_extension.yml
- dataset.py
- loss.py
- model.py

中身はこんな感じです。

```config.yml
stop_trigger: [20, "epoch"]
# max_workspace_size: 8388608  # default: 8 * 1024 * 1024

dataset:
  train:
    file: dataset.py
    name: MNIST
    batchsize: 128
    args:
      split: train
      ndim: 3
  valid:
    file: dataset.py
    name: MNIST
    batchsize: 64
    args:
      split: valid
      ndim: 3

model:
  file: model.py
  name: LeNet5
  args:
    n_class: 10

loss:
  module: chainer.links.model.classifier
  # file: loss.py  # If you use your own loss definition, remove "module" key above and use "file" key to specify the path to the file which describes the "name" class for a Loss link.
  name: Classifier

optimizer:
  method: MomentumSGD
  args:
    lr: 0.01
  weight_decay: 0.0001
  lr_drop_ratio: 0.1
  lr_drop_triggers:
    points: [10, 15]
    unit: epoch

# You can ommit this part
# updater_creator:
#   file: updater_creator.py
#   name: MyUpdaterCreator
#   args:
#     print: True

trainer_extension:
  - custom:
      file: custom_extension.py
      name: CustomExtension
      args:
          message: 'I am learning...'
  - LogReport:
      trigger: [1, "epoch"]
  - dump_graph:
      root_name: main/loss
      out_name: cg.dot
  - observe_lr:
      trigger: [1, "epoch"]
  - ParameterStatistics:
      links:
        - conv1
        - conv2
        - conv3
        - fc4
        - fc5
      report_params: True
      report_grads: True
      prefix: null
      trigger: [1, "epoch"]
  - Evaluator:
      module: chainer.training.extensions
      name: Evaluator
      trigger: [1, "epoch"]
      prefix: val
    # You can specify other evaluator like this:
    #   module: chainercv.extensions
    #   name: SemanticSegmentationEvaluator
    #   trigger: [1, "epoch"]
    #   prefix: val
  - PlotReport:
      y_keys:
        - conv1/W/data/mean
        - conv2/W/data/mean
        - conv3/W/data/mean
        - conv4/W/data/mean
        - fc5/W/data/mean
        - fc6/W/data/mean
      x_key: epoch
      file_name: parameter_mean.png
      trigger: [1, "epoch"]
  - PlotReport:
      y_keys:
        - conv1/W/data/std
        - conv2/W/data/std
        - conv3/W/data/std
        - conv4/W/data/std
        - fc5/W/data/std
        - fc6/W/data/std
      x_key: epoch
      file_name: parameter_std.png
      trigger: [1, "epoch"]
  - PlotReport:
      y_keys:
        - main/loss
        - val/main/loss
      x_key: epoch
      file_name: loss.png
      trigger: [1, "epoch"]
  - PlotReport:
      y_keys:
        - main/accuracy
        - val/main/accuracy
      x_key: epoch
      file_name: accuracy.png
      trigger: [1, "epoch"]
  - PrintReport:
      entries:
        - epoch
        - iteration
        - main/loss
        - main/accuracy
        - val/main/loss
        - val/main/accuracy
        - elapsed_time
        - lr
      trigger: [1, "epoch"]
  - ProgressBar:
      update_interval: 10
      trigger: [10, "iteration"]
  - snapshot:
      filename: trainer_{.updater.epoch}_epoch
      trigger: [10, "epoch"]
```

```custom_extension.py
import chainer


class CustomExtension(chainer.training.Extension):

    def __init__(self, message):
        self._message = message

    def initialize(self, trainer):
        self._message += ' and Trainer ID is: {}'.format(id(trainer))

    def __call__(self, trainer):
        pass

    def serialize(self, serializer):
        self._message = serializer('_message', self._message)
```

```dataset.py
import chainer


class Dataset(chainer.dataset.DatasetMixin):

    def __init__(self, split='train'):
        super().__init__()

    def __len__(self):
        pass

    def get_example(self, i):
        pass


# You can delete this
class MNIST(chainer.dataset.DatasetMixin):

    def __init__(self, split='train', ndim=3):
        super().__init__()
        train, valid = chainer.datasets.get_mnist(ndim=ndim)
        self.d = train if split == 'train' else valid

    def __len__(self):
        return len(self.d)

    def get_example(self, i):
        return self.d[i]
```

```loss.py
from chainer import link
from chainer import reporter
from chainer.functions.evaluation import accuracy
from chainer.functions.loss import softmax_cross_entropy


class MyLoss(link.Chain):

    def __init__(self, predictor):
        super().__init__()
        self.lossfun = softmax_cross_entropy.softmax_cross_entropy
        self.accfun = accuracy.accuracy
        self.y = None
        self.loss = None
        self.accuracy = None

        with self.init_scope():
            self.predictor = predictor

    def __call__(self, *args):
        assert len(args) >= 2
        x = args[:-1]
        t = args[-1]
        self.y = None
        self.loss = None
        self.accuracy = None
        self.y = self.predictor(*x)
        self.loss = self.lossfun(self.y, t)
        reporter.report({'loss': self.loss}, self)
        self.accuracy = self.accfun(self.y, t)
        reporter.report({'accuracy': self.accuracy}, self)
        return self.loss
```

```model.py
import chainer
import chainer.functions as F
import chainer.links as L


class Model(chainer.Chain):

    """Model definition.
    This is a template of model definition.
    """

    def __init__(self, n_class):
        super().__init__()
        with self.init_scope():
            pass

    def __call__(self, x):
        pass


# You can delete this! It's a sample model
class LeNet5(chainer.Chain):

    def __init__(self, n_class):
        super().__init__()
        with self.init_scope():
            self.conv1 = L.Convolution2D(None, 6, 5, 1)
            self.conv2 = L.Convolution2D(6, 16, 5, 1)
            self.conv3 = L.Convolution2D(16, 120, 4, 1)
            self.fc4 = L.Linear(None, 84)
            self.fc5 = L.Linear(84, n_class)

    def __call__(self, x):
        h = F.relu(self.conv1(x))
        h = F.max_pooling_2d(h, 2, 2)
        h = F.relu(self.conv2(h))
        h = F.max_pooling_2d(h, 2, 2)
        h = F.relu(self.conv3(h))
        h = F.relu(self.fc4(h))
        return self.fc5(h)
```

あとは基本的にこれを個別のタスク用に編集して、終わったら

```bash
$ chainer train config.yml --gpus 0
```

とやればGPU ID:0のデバイスを使って学習が始まります。複数のGPUを使いたい場合は、`--gpus 0 1 2 3` のようにスペース区切りで使いたいGPUのIDを指定してください。

## 3. 各ファイルの説明

### config.yml

学習の設定や、どのファイルをモデルファイルとして使うかなどが書いてあるファイルです。

#### dataset

```yaml
dataset:
  train:
    file: dataset.py
    name: MNIST
    batchsize: 128
    args:
      split: train
      ndim: 3
  valid:
    file: dataset.py
    name: MNIST
    batchsize: 64
    args:
      split: valid
      ndim: 3
```

学習データセットと検証用データセットの設定です。予めそれらを別のファイルに定義しておき、`file`キーでそのファイルまでのパスを、`name`でそのファイル中のデータセットクラスのクラス名を指定します。`args`の値は辞書である必要があり、これはそのまま `**args` のようにしてデータセットクラスのコンストラクタにキーワード引数として渡されます。`batchsize`は各データセットから作るミニバッチのサイズです。

#### model & loss

```yaml
model:
  file: model.py
  name: LeNet5
  args:
    n_class: 10
```

これもほぼ同様で、`file`で指定されたパスにあるファイル中の`name`という名前のクラスをインスタンス化してモデルを作ります。その際に`args`があれば、それをキーワード引数として渡します。

```yaml
loss:
  module: chainer.links
  # file: loss.py  # If you use your own loss definition, remove "module" key above and use "file" key to specify the path to the file which describes the "name" class for a Loss link.
  name: Classifier
```

`loss`のパートも基本的には同様で、ここでは`args`は省略されていますが、`model`パートと同様に`args`をキーとして辞書を指定すれば、それがロスを計算するためのクラスのコンストラクタにそのままキーワード引数として渡されます。`loss`に関しては、あらかじめChainerが用意してくれている `chainer.links.Classifier` なども使えるように，`module` というキーが使えるようになっています。`module` と `file` は同時に使うことはできません。

#### optimizer

```yaml
optimizer:
  method: MomentumSGD
  args:
    lr: 0.01
  weight_decay: 0.0001
  lr_drop_ratio: 0.1
  lr_drop_triggers:
    points: [10, 15]
    unit: epoch
```

Optimizerの設定部分です。`method`はそのままChainerの`optimizers`モジュール以下にあるクラス名になります。`args`はそのコンストラクタに渡すキーワード引数です。`weight_decay`キーが存在する場合は、Optimizer hookとしてWeight decayが追加されます。`lr_drop_ratio`と`lr_drop_triggers`が両方存在する場合は、ManualScheduleTriggerを使った学習率のdroppingが行われます。`lr_drop_triggers`に渡される辞書は、`points`がどのタイミングで学習率を`lr_drop_ratio`倍にするか、`unit`がそのタイミングの単位（`epoch`もしくは`iteration`が指定可能）です。上の例の場合は、MomentumSGDの学習率が10エポックで0.1倍、さらに15エポックでもう一度0.1倍になります。

### updater

Updaterは，iterator, optimizer, devicesをとってUpdaterオブジェクトを返すような関数を自分で用意して，config.ymlのupdater_createrキーで指定してやることでカスタマイズすることができます．

### その他のファイル

普通のChainerを使って書かれたデータセットクラス、モデルクラス、ロスを計算するラッパークラスです。`custom_extension.py`は自分で作ったExtensionクラスを書いておき，config.ymlから指定してそれをTrainerに追加したいときに書き換えます。

## 4. 学習する

`chainer init`してできるファイルには，最初からMNIST Exampleを実行するのに必要なモデルやデータセットなどが書かれており，さらに`config.yml`はそのままだとそれらのモデル・データセットなどを用いてMNIST exampleを実行するように準備されています．なので，あとは`chainer`コマンドを叩くだけです。サブコマンドに`train`を指定します。

```bash
$ chainer train config.yml --gpus 0
```

サブコマンドの`train`の次に使いたいコンフィグYAMLファイルのパスを指定します。ファイル名はconfig.ymlじゃなくてもなんでもOKです。GPUを使いたい場合は、`--gpus`オプションでデバイスIDを指定します。もし`--gpus 0 1 2 3`のように複数指定された場合は、自動的にParallelUpdaterもしくはMultiprocessParallelUpdater（NCCLが有効な場合）が選択されて、マルチGPUでの学習が行われます。

Ubuntu上でやる場合`MPLBACKEND=Agg`と環境変数をセットしておけばPlotReport周りのエラーを回避できます。

実際に回した結果が以下です。無駄に4GPUs使ってみました。

```bash
$ MPLBACKEND=Agg chainer train config.yml --gpus 0 1 2 3
```

    chainer version: 2.0.1
    cuda: True, cudnn: True, nccl: True
    result_dir: result/config_2017-07-18_23-26-41_0
    train: 60000
    valid: 10000
    /home/shunta/.pyenv/versions/anaconda3-4.4.0/lib/python3.6/site-packages/chainer/training/updaters/multiprocess_parallel_updater.py:142: UserWarning: optimizer.lr is changed to 0.0025 by MultiprocessParallelUpdater for new batch size.
      format(optimizer.lr))
    epoch       iteration   main/loss   validation/main/loss  main/accuracy  validation/main/accuracy  elapsed_time  lr
    1           118         0.890775    0.234464              0.739672       0.928896                  8.45449       0.0025
    2           235         0.198075    0.141786              0.939503       0.957476                  13.914        0.0025
    3           352         0.128017    0.120378              0.960737       0.960839                  19.1064       0.0025
    4           469         0.100555    0.0979902             0.96895        0.969739                  24.3107       0.0025
    5           586         0.0865762   0.077968              0.971888       0.97587                   29.2581       0.0025
    6           704         0.0734014   0.0672336             0.976562       0.978837                  34.3428       0.0025
    7           821         0.0683174   0.0618281             0.977564       0.979826                  39.1815       0.0025
    8           938         0.0617364   0.0748559             0.980235       0.976958                  44.0893       0.0025
    9           1055        0.0573468   0.0596004             0.981904       0.980024                  49.0457       0.0025
    10          1172        0.0531992   0.0578394             0.98364        0.982694                  54.3706       0.0025
    11          1290        0.047489    0.0485524             0.986096       0.984573                  59.3655       0.00025
    12          1407        0.0417473   0.0482626             0.987513       0.984968                  64.18         0.00025
    13          1524        0.0406346   0.0473873             0.987914       0.984771                  69.0114       0.00025
    14          1641        0.0405981   0.0479212             0.987847       0.985265                  74.0731       0.00025
    15          1758        0.0394898   0.0478847             0.988114       0.986155                  79.3369       0.00025
    16          1875        0.0394069   0.0472816             0.988181       0.984968                  84.2785       2.5e-05
    17          1993        0.0389244   0.0471326             0.988546       0.985166                  89.4715       2.5e-05
    18          2110        0.0391655   0.046991              0.988181       0.985463                  94.6602       2.5e-05
    19          2227        0.0390729   0.0468674             0.988381       0.985364                  99.7827       2.5e-05
    20          2344        0.038587    0.0471131             0.988315       0.985166                  104.962       2.5e-05

config.ymlで2つPlotReportを設定していたので、結果ディレクトリ（result以下にコンフィグファイルのbasenameと日付・時刻が連結されたような名前のディレクトリが作成されています）内にconfig.ymlで指定したファイル名の画像（loss.pngとaccuracy.png）ができています。

![loss.png](https://qiita-image-store.s3.amazonaws.com/0/17934/c4e9931b-5f15-f241-79ad-691560008a27.png)

![accuracy.png](https://qiita-image-store.s3.amazonaws.com/0/17934/9a70710e-279a-6f4f-a55b-f229f7d28d45.png)

## 6. 結果ディレクトリについて

`chainer`コマンドで学習を開始すると、自動的に指定したコンフィグファイルのファイル名（`config.yml`を指定した場合、拡張子を覗いた`config`の部分）と開始時点での時刻をもとにした結果ディレクトリが自動的に、実行した階層にできる`result`ディレクトリの下に作られ、そこにモデルファイル、ロスファイル、コンフィグファイルなどが勝手にコピーされます。そのディレクトリがTrainerの`out`に指定されるので、snapshotやログファイルもそこに書き込まれます。

## 7. おわりに

こういうツールでできることは非常に限られていますが、割と毎回同じtrain.pyを書くことが多かったので、適当に一般化してみました。ただNLPとかGANとかやる場合Updaterも指定できたりしないと厳しいので使えません。シンプルな画像認識系タスクなら使えるかもしれません。