Featured image of post 機械学習の実験管理の為の設計

機械学習の実験管理の為の設計

目次

背景

  • あるプロジェクトで検知モデルを作っていて色々な課題が出てきた
    • 「このrunはどのデータで学習したんだっけ」
    • 「このmanifestの数字は本当に実行時の設定と一致しているんだっけ」
  • 一度、実験管理そのものを設計し直すことにした
  • ディレクトリ構成そのものより、そこに至った判断基準の方が汎用的で使い回せる
  • 考え方の部分だけを抜き出してまとめる

設計

全体像

まず、最終的にどう分割したかを先に示す

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
xxx-project/
├── scripts/
│   ├── scaffold/         番号採番・テンプレコピーを自動化するスクリプト
│   └── pipeline/         データ生成/加工の実処理コード(共有、experimentごとにコピーしない)
├── templates/            コピーして使う雛形(notes/manifests/code/docs)
├── checklist/            templates/を指すpointer + 評価チェック項目(eval_checks/)の本体
├── data/
│   ├── seeds/<id>/       グレード定義・単語/フレーズリスト
│   ├── collections/<id>/ seedsから生成/収集した実データ
│   ├── splits/<id>/      train/val/test等のロールへの分割
│   ├── aug/<id>/         splitでtrain判定されたクリップへのaug結果
│   └── cache/<id>/       埋め込み抽出のキャッシュ
├── leaderboard.md        experiment/run横断の指標一覧(退行チェック用)
└── experiments/<id>/
    ├── README.md         仮説・作成理由・配下runの一覧
    ├── manifest.yaml      parent_experiment・デフォルト設定
    └── runs/<id>/
        ├── manifest.yaml  実際に何を実行したか(cache/splits/hyperparams/seed/git commit)
        ├── output/        学習済みモデル等
        └── README.md      run単体の「論文」(背景・手法・結果・考察)
  • scripts/templates/checklist/
    • 実験を作るための土台
    • 採番の自動化・雛形・チェックリスト
  • data/
    • 学習データの生成・収集パイプライン
    • seeds → collections → splits → aug → cacheの順に積み上がる
  • experiments/
    • 実際の実験本体と、その配下の個々のrun

以下の各節で、この形に至った判断理由を掘り下げる

安定化モードの例

  • 以下が安定化コードの例
  • runのmanifest.yamlなどにseed:42などを埋めて使う
 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
"""experimentを作るときにコピーして使う「安定化コード」(安定化モード)——学習の再現性
(reproducibility)を保証するための決定論設定。共有importにせず、experimentごとにコピーして
始める(過去のexperimentの再現性を守るため。詳細はrules.mdの`code/`節参照)。
"""

import random

import numpy as np
import torch


def set_seed(seed: int):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)


def enable_determinism(strict: bool):
    """runの再現性を検証するための厳格決定論モード。TF32・cudnn自動チューニング・
    非決定的なCUDA kernelを全て無効化する(有効なままだとhardwareや実行順で結果が
    僅かに揺れ、seed固定だけでは同一runの再現性を保証できない)。strict=Trueだと
    決定論的実装が無いopが例外で落ちる(warn_only=False)。"""
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False
    torch.backends.cuda.matmul.allow_tf32 = False
    torch.backends.cudnn.allow_tf32 = False
    torch.use_deterministic_algorithms(strict, warn_only=not strict)
    print(f"use_deterministic_algorithms(strict={strict}, warn_only={not strict})  "
          f"+ TF32 無効化 + cudnn.deterministic=True")

IDの命名規則

  • 番号付きフォルダ/ファイルは {ゼロパディング数字}_{slug} の形式
    • 例: 01_front_prefix
  • 番号は、同じ階層・同じネームスペース内で独立の通番
    • 例: checklist/eval_checks/内の01〜17と、data/seeds/内の01〜は無関係
  • 番号を振るもの
    • data/seeds/data/collections/experiments/runs/のように、実際に複数作られていくもの
  • 番号を振らないもの
    • templates/notes|manifests|code|docs/
    • 種類ごとに1つずつしかない固定スロットなので、増えていく連番ではない
  • checklist/配下は番号を振る
    • eval_checks/(相互参照・随時追加のリスト)はもちろん
    • notes|manifests|code|docs/(templates/を指すpointer)も含めて番号を振る

データの構造と関連性

  • まず、データの全てのデータにはdata_item_idというのが振られる
  • そして、仕組みは、role(train/val/test/calibration)というメタデータをうまく使う
  • データの生成は、基本的には、seeds → collections → splits → aug → cacheという順番でデータをつくる
  • しかし、親を全部知っているわけではなく、例えば、cachesplitをしらない
  • 他方、augtrainの時のみ必要なので、splitを知っている(augはtrainのために必要だから)
  • また、splitsもdata_item_idに対するroleをつけているのみ(つまり、augcacheは知らない)
  • そして、実行時にrunmanifest.yamlからcachesplitを指定してどのデータを使うのかを知る
  • つまり、それぞれのdatarunなどのオブジェクトがmanifest.yamlをベースに粗結合につながっているということ
  • RDBにするまででもないが、リレーションはちゃんと持っておきたいのでこの構造になった
stage参照するもの
seeds-
collectionsseeds
splitscollection
augcollection + split(roleでtrainだけ抜き出すため)
cachecollection + aug(roleは無視、全部埋め込む)
runcache(複数可)+ split(複数可)——ここで初めてロールと埋め込みが突き合わされる

experimentとrunを分ける理由

  • experiment
    • 実験単位
    • 「何を検証したいか」という仮説・方針のレベル
  • run
    • そのexperimentの中で実際に回した1回1回の試行
    • ハイパーパラメータ・seed・参照するsplit等の条件を変える
  • 1つのexperimentの配下に複数のrunがぶら下がる、experiment:run = 1:N の関係
  • 2階層に分ける理由
    • 「大きな方針転換(experiment)」と「同じ方針内の細かい調整(run)」を同じ粒度で並べると、後から見てどちらの変化か読み取れなくなる
  • 系譜のリンクもparent_experiment(方針の系譜)とparent_run(試行の系譜)で軸を分けている(詳しくは後述)

「なぜ」と「何を」を分離する

  • 実験・run・データの各段階(seeds/collections/splits/aug)、それぞれに2つのファイルを持たせる
    • README.md — 仮説・理由・考察など人間が読む文章
    • manifest.yaml — 構造化データ(参照するデータのID・ハイパーパラメータ・git commit等)だけ
  • 分離しないとどうなるか
    • 理由をmanifestの1行コメントに押し込むと、後から読んでも意図が分からなくなる
    • 逆に理由の文章に構造化データを混ぜると、スクリプトから機械的に読み取れなくなる

コピーと参照、ミュータブルとイミュータブルの関係

  • templates/data/は、どちらも「他から使われる元」だが扱いが正反対
  • テンプレート
    • 毎回コピーして使う
    • experiment/runを作る時点でファイルの中身を複製する
    • そこから先はコピー先が実体になる
    • テンプレート自身は自由に改訂してよい
    • その改訂は過去にコピーして作った実験には一切影響しない
  • データ(seeds・collections・splits・aug)
    • 毎回コピーせず、IDによる参照で使う
    • 新しいバージョンが必要でも上書きしない
    • 番号をincrementした新しいフォルダを切る
    • 既存のIDはそのまま指し続ける
  • 「コピーか参照か」を分けている基準は、コピーのコスト
    • テンプレートはただのテキストファイルでコピーのコストがほぼゼロ、気軽にコピーしてよい
    • データは数百MB〜数十GBになりうるのでコピーのコストが高い、だから複製せず参照にする
  • コピーか参照かで、ミュータブルにしてよいかも決まる
    • コピーする対象(テンプレート)
      • コピーした瞬間に元とは切り離される
      • 元をミュータブルにしても過去のexperiment/runには影響しない
    • 参照する対象(データ)
      • 参照元が同じ実体を指し続ける、元データを書き換えると参照している全てのexperiment/runの再現性が同時に壊れる
      • だからイミュータブルにする
  • 整理すると、コピーは可変でよい、参照は不変でなければならない、という原則になる
  • 例外: cache(埋め込み抽出のキャッシュ)
    • 参照される対象だが、イミュータブル必須にはしていない
    • 「collection + aug + 特徴抽出器のバージョン」から機械的に再生成できる純粋な派生物
    • 人間の判断が入っていない
    • 壊れても再現でき、失われるのは計算時間だけ

データをトップレベルに独立させる理由

  • data/をexperimentの配下ではなく、リポジトリのトップレベルに独立させている
  • 理由は、データと実験の関係が1:Nだから
    • 1つのcollectionは、複数のexperimentから参照されうる
    • 例: 同じデータで別のモデル構造を試す、同じデータで別のハイパラを試す
  • もしデータをexperiment配下に置くと
    • 実質的に1:1の所有関係になる
    • 別のexperimentから使い回すには、コピーするかシンボリックリンクのような回避策が必要になる
  • 1:Nの関係を素直に表現するには
    • データを共有可能な場所(トップレベル)に独立させる
    • 各experimentのmanifest.yamlから「どのdata/collections/<id>を使うか」をIDで参照する

手順の順序自体でバグを消す

  • データ生成のパイプラインを seeds → collections → splits → aug → cache の順に固定した
  • ポイントは「splitをaugより先に行う」こと
    • 先にtrain/val/testに分割する
    • train判定されたクリップにだけdata augmentationをかける
  • こうすると、val/testロールのクリップには最初からaugが一切かからなくなる
    • 学習コード側に「val/testはaugを除外する」という特別な条件分岐を書く必要がなくなる
  • 「気をつけて実装する」ではなく「その状態しか作れないようにする」ことでバグの芽を摘む、という考え方

コピーで固定化する範囲と、共有する範囲を分ける

  • 前述の「コピーのコストで決める」とは別の軸で、コードにも同じコピー/共有の判断が出てくる
  • experimentのコード
    • テンプレートからコピーして各experimentフォルダに複製する(共有importにしない)
  • データ生成のパイプラインコード(collection.py・splits.py・aug.py・cache.py)
    • コピーせず、リポジトリ全体で共有する
  • 判断基準は「それ自体が実験対象かどうか」
    • モデルのコードは実験そのもの、後からリファクタリングして過去のexperimentの再現性を壊すわけにいかない
    • データ生成コードは実験対象ではない、変わるのは主に引数(単語リストやseed)の方なので共有コードにして育てる
    • どのコード状態で生成したかは、各段階のmanifestにgit_commitを自動記録して担保する

手作業を自動化して、記録と実行のズレを防ぐ

  • 新しいseeds・collection・experiment・runを作るときは、必ず採番用のシェルスクリプト(scaffold_*.sh)を使う
    • 手でフォルダをコピーして番号を振ると、ID重複やgit_commit書き忘れのようなミスが起きる
  • manifest.yamlは「事後にまとめたメモ」ではなく、学習・評価スクリプトが実際に読み込む設定そのものとして扱う
  • この2つを徹底すると、「記録されている内容」と「実際に実行された内容」が原理的にズレなくなる

系譜を2つの軸に分けて追う

  • parent_experimentparent_runという、別々の系譜リンクを持たせている
    • parent_experiment — 研究アイデア・実験方針の系譜(前のexperimentから何を変えたか)
    • parent_run — 同じexperiment内での具体的な試行の系譜(ハイパラを変えた、seedを変えた等)
  • 「大きな方針転換」と「同じ方針内の細かい試行錯誤」を1本の履歴に混ぜると、後から辿るときにどちらの変化か分からなくなる
  • 最初から軸を分けておく

評価は自動化と人の考察を分ける

  • 評価観点はchecklist/に1項目1ファイルで管理し、随時追加・改訂できるようにしている
  • 客観的に判定できるもの(クラスバランス・サンプル数・信頼区間・データの不整合・splitのリーク)
    • 自動テスト化し、runのたびに機械的に実行する
  • 解釈が必要なもの(サブクラス間の相関、仮説との差異、汎化の妥当性)
    • 自動化せず、run単体のREADME.mdに人が文章で書く
  • 何でも自動化しようとせず、「機械的に白黒つけられるか」で線引きする
    • 判定できないものは無理に数値化せず考察として残す
  • 横断的な良化/悪化の比較はleaderboard.mdに1行ずつ追記していく方式にして、退行に気づけるようにしている

最初にやるべき実験

  • 最初にやるべきなのは確実に決定的になるかの試験と信頼区間を出すこと
  • 特に、実験の全ての前提はデータと実験の安定性にかかっている
  • まずデータのsplitは決定的になるのかをCheckする
  • そして、cacheを作る時にcacheは決定的になるのかをCheckする
    • 特に高速にCacheを作るために並列にするなどすると決定的にならないことがある
  • そして、安定化コードを入れて、同じデータで学習を複数回行い決定的な結果にbyte-for-byteレベルでなるのかをcheckする
  • さらに、それらを複数回評価して、評価コードが決定的なのかをCheckする
  • その上で、seedを変えてばらつきがどのぐらい生まれるのか
  • dropoutを入れてばらつきがどのぐらいになるのか
  • さらに、もし決定的にならなかった場合は、クラス、サブクラス、グレードなどの区分でどのぐらい信頼区間があるのかをCheckする
  • 結局の所、seed値、データ、キャッシュ、学習、評価でどこから不確実性が入り再現性が取れなくなるのかを明らかにする必要がある
  • ソートもユニークになるように第二第三ソートまで指定する
  • たとえ、CUDAの設定で決定的にしていても、実はキャッシュレベルで不確実性が入っていましただと後々の前提が崩れるから

まとめ

  • 実験管理を設計し直す中で、ディレクトリ構成そのものより「なぜそう分けたか」という判断基準の方が他のプロジェクトにも使い回せると感じた
  • 挙げた基準
    • 方針転換(experiment)と細かい試行(run)を別の粒度として分ける
    • 理由(文章)と構造化データを別ファイルに分ける
    • コピーは可変でよく、参照は不変でなければならない、という原則でミュータブル/イミュータブルを線引きする
    • コピーか参照かは、コピーのコストで決める(安ければコピー、高ければ参照)
    • 1:Nで参照される対象は、所有関係を1:1にしないようトップレベルの共有領域に独立させる
    • 気をつけて実装するのではなく、間違った状態そのものを作れなくする(splitをaugより先に等)
    • スキーマを増やす前に、既存の仕組みを一般化できないか考える
    • 実験対象のコードは複製して固定化し、実験対象でないコードは共有して育てる
    • 手作業を自動化し、記録と実行がズレない構造にする
    • 自動化できる評価と、人が書くべき考察を最初から切り分ける
  • どれも当たり前に見えるが、後から気づいて直すのは大変
  • 次に新しいプロジェクトを始めるときも同じ基準で最初から作っていきたい
  • また、もっとやる場合は、そもそもxxx-project-dataxxx-project-trainとかに分けてもいいのかも
Built with Hugo
テーマ StackJimmy によって設計されています。