🦉

オレオレKaggle実験管理

2021/02/26に公開

はじめに

こんにちは、fkubota(Kaggle Expert 20210225現在)です。今回はTwitterでも度々話題になる実験管理方法について書きたいと思います。以前Kaggle日記という戦い方という記事を書きましたがそれとも少し関連しますので、必要であればこちらも見ていただけると嬉しいです。

あとツヨツヨの人(KaggleMasterとか)にとっては、あまり有用な情報がないかもしれません。もっといい方法があれば教えてください。

今回の記事は、script(*.py)で実験を回す時にこうやってるよというのを紹介します。
早いことに参加したコンペは鳥カエルコンペで5回目になりました。これまでを振り返ると毎回反省がありその度に課題を解決するための取り組みをしてきました。5回目でようやく自分のお気に入りのスタイルに落ち着いたかなと思うので紹介させていただきます。

実験スクリプト管理パターン

実験のスクリプトを管理する時、3パターンほど考えました。それぞれ簡単に紹介します。

パターン1

src
├ config.yml
└ exp.py
lib
├ model.py
└ train.py

1つのスクリプトをバージョンで管理するパターン。スクリプトをどんどん育てていっていくようなイメージ。昔のバージョンに戻りたければ git checkout で特定のcommitで戻る。これはボツになりました。理由としては、

  • exp035 と exp022 のスクリプトを比べたいという時サッとできない。
  • 自分のgitのcommit管理があまり信用できない。
  • 再現性という意味で少し不安。

になります。

パターン2

lib
├ model.py
└ train.py
exp
├ exp001
│ ├ config.yml
│ └ exp.py
├ exp002
│ ├ config.yml
│ └ exp.py
└ ...

実験ごとにスクリプトを分けるパターン。観測した感じだとこのパターンは多そう。特徴は、すべてのexpXXXは共通の lib/* を参照している。これもボツになりました。

  • 実験を重ねる毎に lib/* はどんどん変更されていくので例えば exp020 の実験後に exp001が動く保証がない。
  • というか、動くことを保証する(後方互換性?)ことを考慮しながらスクリプト書くのしんどい
  • 結局、ある実験を再現するときは、 git checkout でそのcommitまで戻る必要がある
  • 異なるexpXXXのスクリプトを比較したいとき、XXXによって lib/* が違うので比較がめんどくさい(github を使うとか?)
  • 破壊的な変更に消極的になる。共通の lib/* を持つことで他の expXXX の存在は嫌でも意識しなければならず、パイプラインの構造上やりたくない変更が存在してしまう。 = 「大胆な実験やりづらい」

ここまでをまとめると僕は実験スクリプトの管理に以下を求めます。

  • 再現性
  • 破壊的な変更に消極的にならない
  • 過去の実験のスクリプトをすぐに参照できる

パターン3

結局、ダサいし業務では使わないだろうなと思われる形に落ち着きます。数ヶ月のコンペだからゆるされるかなと思ってます。

exp
├ exp001
│ ├ config.yml
│ ├ exp.py
│ ├ model.py
│ └ train.py
├ exp002
│ ├ config.yml
│ ├ exp.py
│ ├ model.py
│ └ train.py
└ ...

各実験が完全に独立したスクリプト管理です。これにより、アドボックなコードをいくらでも許容できるようになりました。捨てる可能性のあるアイデアを過去の実験のことなんてお構いなしに実装できます。破壊的な変更をいくらでも行う事ができ、その変更がだめであれば捨て去って次に進むことも容易です。exp033/train.pyexp134/train.py を比較したいときもすぐにできます。もちろん再現性もあります。賛否があるとは思いますが僕は今の所この管理が心地良いです。

普段のコーディングではvimを使っており、↓のような感じですぐに欲しい情報にアクセスしたいのですがこの管理だと都合がいいです。

exp002を開いていてexp001と比較する例
↓比較の様子

↓ diff(コマンド: :vert diffsplit ../exp001/exp.py)

あ、念の為ですがこのパターンとその感想はあくまでも僕の意見であって、パターン1, 2 だろうとうまく管理している人はきっといます。僕の実力だと難しいというだけですのであしからず。

実験の単位

例えば画像サイズの大きさを変える実験を考えましょう。img_size=100, 200, 300, 400と4パターン試す時、 exp001, exp002... とすることもできますが、これはちょっとかっこ悪いなと思いました。意味のあるまとまり一つを expXXX としてさらに小さな単位として runXXX を導入します。

exp001の実験の中に[run001, run002, run003, run004] があるようなイメージです。
runXXX はKaggle で言うところのsubmissionファイル1つ分になります。
1_exp:n_run:n_sub の単位で管理します。

簡単な例

この実験管理方法を理解しやすく真似しやすくするためにみんな大好き irisデータセットでサンプルを作ってみました。こちら です。以下このリポジトリの簡単な説明です。

話を戻します!
runを導入してもスクリプトの構成は変わりません。

exp
├ exp001
│ ├ config.yml
│ ├ exp.py
│ ├ model.py
│ └ train.py
├ exp002
│ ├ config.yml
│ ├ exp.py
│ ├ model.py
│ └ train.py
└ ...

exp.py の中でいくつかの run が走っているイメージです。
↓ざっくりとしたイメージ。

def run(i):
    hoge
  
def exp():
    for i in len(runs):
         run(i)

実際の実装がどうなっているか説明していきます。ここが重要です。
決定木系のモデルを実装した場合の config.yml を例にとります。

path:
  dir_save: ../../data/output/exp/

model:
  params:
    random_state: 42
    max_depth: 5

split:
  random_state: 42

この実験exp001では、max_depth を1,2,3と変化させます。そこで以下のようなコードを書いています。

def exp():
    list_config_str = [
        '''
        model:
            params:
                max_depth: 1
        ''',
        '''
        model:
            params:
                max_depth: 2
        ''',
        '''
        model:
            params:
                max_depth: 3
        ''',
    ]

    for i_run, config_str in enumerate(list_config_str, 1):
        config_update = yaml.safe_load(config_str)
        run_name = f'run{str(i_run).zfill(3)}'
        run(run_name, config_update)

やっていることとしては、

  1. configのParamのうち変更したい部分をリストにする
  2. yaml.safe_loadでstrからdictに変換し config_update とする
  3. run に config_update を渡す
  4. run の中で、config.yml を ロードし config_update 部分だけが書き換わる処理が行われる

こんな感じです。updateするParamには複数を同時していすることもできるため結構フレキシブルです。画像サイズを大きくすると同時にバッチサイズを小さくしないといけなかったりするため便利です。

list_config_str = [
    '''
    model:
        params:
            img_size: 100
    dataset:
        batch_size: 50
    ''',
    '''
    model:
        params:
            img_size: 400
    dataset:
        batch_size: 20
        ''',
    ]

すこしややこしくなってきました。ものすごーく抽象化した感じで書くとこうなります。

def run(update_cfg):
    new_cfg = update(base_cfg, update_cfg)
    train(cfg)

def exp():
    for update_cfg in [update_cfg1, update_cfg2, ...]:
        run(update_cfg)

それから、出力ももちろんexp, run単位で出しています。結果として以下のようなディレクトリ構成になっています。

example-exp-iris/
├── data
│   └── output
│       └── exp
│           ├── exp001
│           │   ├── run001
│           │   │   └── config_update.yml
│           │   ├── run002
│           │   │   └── config_update.yml
│           │   └── run003
│           │       └── config_update.yml
│           └── exp002
│               ├── run001
│               │   └── config_update.yml
│               ├── run002
│               │   └── config_update.yml
│               ├── run003
│               │   └── config_update.yml
│               └── run004
│                   └── config_update.yml
└── exp
    ├── exp001
    │   ├── config.yml
    │   ├── exp.py
    │   └── util.py
    └── exp002
        ├── config.yml
        ├── exp.py
        └── util.py

通常であればoutputにはmodelやらlogやらグラフやらが保存されていますが今回は簡単のためにupdateしたconfigを保存しています。あと、wandbも使ってました。めっちゃよかったです。今回は省略。

終わり

長々と書いてしまいましたが最後まで読んで頂いてありがとうございました。実験の管理は人それぞれいろんなスタイルがあると思います。できればいろんな人の管理を参考に自分にあったものを見つけたほうがいいと思うので、僕のはその一例ということで参考にしてください。僕も1年後には全然異なった管理になっている可能性もありますので。

以上です。ありがとうございました!

Discussion