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
21
22
23
24
25
xxx-project/
├── scripts/
│   ├── scaffold/         番号採番・テンプレコピーを自動化するスクリプト
│   ├── pipeline/         データ生成/加工の実処理コード(共有、experimentごとにコピーしない)
│   ├── demo/             実際に動かして触ってみるためのデモスクリプト
│   └── tools/            分析・調査に使う単発ツール
├── templates/            コピーして使う雛形(notes/manifests/code/docs)
├── checklist/            templates/を指すpointer + 評価チェック項目(eval_checks/)の本体
├── data/
│   ├── specs/<id>/       グレード定義・単語/フレーズリスト、シードやレシピみたいなもの
│   ├── raw/<id>/         生の長尺音声(未クリップ・未分類、collectionsの入力元の1つ)
│   ├── collections/<id>/ specs(またはraw)から生成/収集した実データ
│   ├── splits/<id>/      train/val/test等のロールへの分割
│   ├── aug/<id>/         splitでtrain判定されたクリップへのaug結果
│   ├── cache/<id>/       埋め込み抽出のキャッシュ
│   └── err/<id>/         評価runでFAしたクリップのエラー分析専用データ(学習には使わない)
├── backlog/<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/
    • 学習データの生成・収集パイプライン
    • specs → [raw] → collections → splits → aug → cacheの順に積み上がる
  • experiments/
    • 実際の実験本体と、その配下の個々のrun

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

横断設計

IDの命名規則

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

IDの引用表記(namespace#id)

IDを文章中で引用する表記は、以下の2通りにした。

  • namespace#id
    • 例: specs#01_xxx・experiments#01_baseline
    • #の左がnamespace(data/specsのようなフォルダの種類)、右が番号付きslug
    • 単体で番号が一意に決まるnamespaceに使う
  • namespace#id@parent_namespace#id
    • 例: run#01_xxx@experiment#01_xxx
    • runのように、親のIDに属する形でしか存在しないnamespaceに使う
    • 理由は、同じrun#01_xxxでもどのexperiment配下かで別物になり、親を省略すると取り違えが起きるため

番号はnamespaceごとに独立の通番なので、01とだけ書くと文章中ではどのnamespaceの01か分からなくなる。この表記はディレクトリパスの代わりではなく、あくまで文章中で手短に引用するための省略記法で、実際のパス(data/specs/01_xxx/等)はコードやmanifestではそのまま使う。この表記があるだけで、読み手が都度パスから逆算しなくて済む。

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

実験・run・データの各段階(specs/collections/splits/aug)、それぞれに2つのファイルを持たせる。

  • README.md — 仮説・理由・考察など人間が読む文章
  • manifest.yaml — 構造化データ(参照するデータのID・ハイパーパラメータ・git commit等)だけ

分離しないとどうなるか?

  • 理由をmanifestの1行コメントに押し込むと、後から読んでも意図が分からなくなる
  • 逆に理由の文章に構造化データを混ぜると、スクリプトから機械的に読み取れなくなる

rulesとchecklist

プロジェクトの「決め事」を書く場所を、抽象度で2つに分けている。

  • rules.md — 憲法のような、全体を貫く設計方針そのもの
    • なぜこの構造にしたか、判断の理由・トレードオフを文章で説明する
    • 新しい仕組みを追加する時にまず参照する、判断基準の源
  • checklist/ — 法律のような、個別具体的なチェック項目
    • 1項目1ファイルで、都度確認する具体的なルール
    • rules.mdの方針を踏まえた上での、実務レベルの確認事項
    • 理由は、個別に追加・削除・改訂できるようにするため

理由は、両方を1つのファイルに混ぜると、「そもそもなぜこの設計なのか」という背景と、「今回この項目を確認したか」という実務チェックが同じ場所に混在し、読みたい情報にたどり着きにくくなるため。方針を理解したい時と、作業中に確認したい時とでは、必要な粒度も読むタイミングも違う。

これは、前述の「README.mdとmanifest.yaml」の分離と同じ「なぜ」と「何を」の軸を、個々のデータ段階だけでなくプロジェクト全体のルールにも適用したものになっている。

データの設計

メタデータ

データのすべてのアイテムにはdata_item_idが振られ、role(train/val/test/calibration)というメタデータで管理される。

各stageの参照関係

データの生成は基本的にspecs → [raw] → collections → splits → aug → cacheの順で積み上がるが、各段階が参照するものは以下の通りで、親を全部知っているわけではない(例: cacheはsplitを知らない、splitsはroleをつけるだけでaugやcacheのことは知らない)。

stage参照するもの
specs-
rawspecs
collectionsspecs(またはraw)
splitscollection
augcollection + split(roleでtrainだけ抜き出すため)
cachecollection + aug(roleは無視、全部埋め込む)
runcache(複数可)+ split(複数可)——ここで初めてロールと埋め込みが突き合わされる

生データのための中間ステージ(raw)

素材によっては、TTS生成や直接録音のように「そのまま1件のクリップになる」形では手に入らないことがある(例: 動画から音声を集める場合、1本が数十分規模の生の長尺音声で、そこから後で複数のクリップを切り出す必要がある)。

  • specs → collectionsの間にrawという中間ステージを挟めるようにした(specs → raw → collections)
    • rawの責務は「未クリップ・未分類の生の長尺音声を保持すること」だけ
    • クリップ化やクラス分類は一切行わない、それは引き続きcollectionsの責務
  • rawを経由するかどうかは素材次第
    • TTS生成・直接録音のような、そのままクリップになる通常の素材はこれまで通りrawを経由しない
  • rawもspecsやcollectionsと同様にimmutable必須
    • 理由は、下流のcollectionsが参照するため

こうして「集めた素材の形(そのままクリップか、切り出しが必要な生データか)」による違いを、collectionsの責務を汚さずに吸収できるようにしている。

splitの決め方

splitは、位置([::2]のようなスライス)ではなく、元のdata_item_idに対する決定的ハッシュで分割する。

  • 同じdata_item_idなら、何度計算しても同じロールに落ちる
    • 理由は、再現性を保つため
  • stratify_by_class(推奨true)
    • collectionのクラスごとに層化してから、roles比率を適用するオプション
    • falseだと全data_item_idを一律にハッシュするだけなので、小さいクラスが特定のロールに偏ってしまうことがある(最悪、あるクラスがtrainに0件になる等)
    • 層化はロールという概念自体を持たないcollectionではなく、splits側の責務

train/val/testの役割

splitのロールは単に3分割すればよいわけではなく、それぞれ異なる役割を持たせている。

  • train — 実際に勾配を流して学習に使う
  • val — collectionと同じ生成過程から抜いた、学習には使わない検証用
    • 安く頻繁にチェックできる一方、生成過程に共通する癖への過学習を検知できない
  • test — 理想的には生成過程そのものが異なる、独立した「本当の物差し」
    • 理由は、同じ生成過程から均等に分けただけだと、trainとtestが同じ癖を共有してしまい、過学習していても高得点が出てしまうため
    • 別collectionを丸ごとtestにする(roles: {test: 1.0})ことでも表現できる
  • 必要ならcalibrationなど、任意のロール名を追加できる

valで良い数字が出たからといって、testでの確認を省略しない。

独立したdata/eval/ステージを作らなかった理由

当初はテスト専用データに独立したステージを設ける案もあったが、結局作らなかった。

  • テスト専用データのmanifestは、通常のcollectionとスキーマがほぼ同じで、違いは「学習に使ってよいか」という用途ラベルだけだった
    • 独立ステージにすると、ほぼ同じスキーマが2つに分かれてしまう
  • 代わりに、splitのrolesを任意個数・任意名に一般化し、roles: {test: 1.0}という1ロールだけのsplitとして表現することにした
    • 理由は、同じcollectionからtrain/val/test/calibrationを1回のsplit定義で切り出す場合と、別の生成過程のcollectionを丸ごとテスト専用にする場合を、同じ仕組みで表現できるため
  • 代わりに失うものもある
    • 独立フォルダという物理的な安全装置(「学習に使うな」を構造で強制する)がなくなるため、命名やレビューといった運用上の注意でカバーする必要がある

スキーマを1つ増やす前に、既存の仕組み(この場合はsplitのロール)を一般化できないか考える、という判断の実例になっている。

処理単位(item/chunk/batch)

データの最小の処理単位は、大きく3つある。

  • item: データのアイテムのファイルの単位(例えば、画像なら1枚の画像)
  • chunk: itemをまとめた単位
  • batch: itemをまとめた単位

chunkはitemをまとめた単位になるため、batch_sizeで割り切れない場合は決定的な結果にならない可能性があるので注意が必要(例えば、BatchNormをかけていたりGPUの丸め誤差など)。

stage単位
specsitem
collectionsitem
splitsitem
augitem
cachechunk(.pt)
runbatch

大きい配列のコピーはメモリ量を見積もってチャンク分割する

array[mask]やtensor[keep_idx]のようなfancy indexing(リスト/bool配列でのインデックス参照)は、参照ではなく新しい配列を丸ごと確保してコピーする。

  • 元データが数万件×埋め込み次元のような大きさの場合、「元の全件」「コピー後の配列」「その後の結合処理の結果」が一時的に同時にメモリへ乗る
    • 理由は、fancy indexingが毎回新規確保のコピーを作るため、ピーク時のメモリ使用量が実質2〜3倍になってしまうため
  • 対策は、一定件数(例: 2万件)ごとのチャンクに分けて、対象範囲へ書き込む形にすること
    • 理由は、一時コピーのサイズを小さく抑えられ、ピークメモリを大幅に下げられるため

大きい配列に対して新しくコピー処理を書くときは、件数×要素サイズがどれくらいのメモリになるか見積もり、必要ならチャンク分割する。

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

templates/とdata/は、どちらも「他から使われる元」だが、扱いが正反対になっている。

  • テンプレート
    • 毎回コピーして使う(experiment/runを作る時点で複製し、そこから先はコピー先が実体になる)
    • 自由に改訂してよい
      • 理由は、コピーした瞬間に元とは切り離され、改訂は過去の実験に影響しないため
  • データ(specs・collections・splits・aug)
    • 毎回コピーせず、IDによる参照で使う
    • 新しいバージョンが必要でも上書きせず、番号をincrementした新しいフォルダを切る
    • イミュータブルにする
      • 理由は、参照元が同じ実体を指し続けるため、元データを書き換えると参照している全experiment/runの再現性が同時に壊れるため

「コピーか参照か」を分けている基準はコピーのコストで、テンプレートはコストがほぼゼロ、データは数百MB〜数十GBになりうるのでコストが高い。整理すると、コピーは可変でよい、参照は不変でなければならない、という原則になる。

  • 例外: cache(埋め込み抽出のキャッシュ)
    • 参照される対象だがイミュータブル必須ではない
    • 理由は、「collection + aug + 特徴抽出器のバージョン」から機械的に再生成できる純粋な派生物で、壊れても再現でき、失われるのは計算時間だけのため

immutabilityを物理的に強制する(chmod)

「一度作ったら書き換えない」は、最初はドキュメントに書くだけの運用上の約束事だった。

  • 約束事だけだと、うっかり上書きしてしまう事故を防げない
  • そこで、各IDフォルダを、下流から参照され始める時点で読み取り専用にする(chmod -R a-w)
    • ロックのタイミングは生成した直後ではない
      • 理由は、生成〜確定までの間は決定性の検証等で何度も再実行したいことがあるため
    • 「下流が参照し始める(=事実上確定する)タイミング」でロックする
  • 読み取り専用にしても「削除して作り直す」操作は妨げられない
    • 削除はフォルダ側の権限で決まるため、通常の更新フロー(新しいIDを切る)には影響しない
  • data/cache/は対象に含めない
    • 理由は、再生成のたびに書き込む必要があるため
  • ハードリンク(cp -al)で実体を共有している場合、read-only化は特に重要
    • 上書き編集による意図しない連鎖破損を物理的に防げる
    • 削除の方向は安全(ハードリンクは参照カウント方式なので、他の参照が残っていれば実データは消えない)

実データはDVCで管理し、gitはmanifestだけ追跡する

data/配下の実データ・埋め込みそのものは大容量になりうるため、gitではなくDVCで管理する。

  • gitは各段階のmanifest.yaml(とsplitsの割り当てcsv)のような軽量なテキストだけを追跡する
    • 理由は、実データ自体は数百MB〜数十GBになりうるため、そのままgit管理するとリポジトリが肥大化するため
  • 実データそのものはDVCでバージョン管理し、manifestが参照するIDとひも付ける
    • 理由は、バイナリの実体を専用のデータバージョン管理ツールに任せることで、gitはコードと設定の差分管理に専念できるため

大きいバイナリと軽量な設定・コードを、同じgit管理の仕組みに混ぜないという判断になっている。

トップレベルに独立させる理由

data/をexperimentの配下ではなく、リポジトリのトップレベルに独立させている。

  • 理由は、データと実験の関係が1:Nだから
    • 1つのcollectionは複数のexperimentから参照されうる
    • 例: 同じデータで別のモデル構造を試す、同じデータで別のハイパラを試す
  • もしデータをexperiment配下に置くと、実質的に1:1の所有関係になってしまう
    • 別のexperimentから使い回すには、コピーするかシンボリックリンクのような回避策が必要になる

1:Nの関係を素直に表現するには、データを共有可能な場所(トップレベル)に独立させ、各experimentのmanifest.yamlから「どのdata/collections/<id>を使うか」をIDで参照する形にすればよい。

specsは目的ごとに分ける

specsは「何を集めるか」を定義するtaxonomyだが、同じspecsを複数の目的で兼用しない。

  • 評価用(実運用に近い性能を測るための物差し)と学習用(モデルの弱点を補強するための素材)のtaxonomyは、扱う現象やカテゴリ名が似ていても、必ず別のspecsとして新設する
    • 理由は、評価用specs由来のデータを学習にも使うと、「評価に使うのと同じデータで学習する」ことになり、データリークが起きるため
    • 評価用taxonomyは学習側から一切参照できないことが前提
    • カテゴリの定義が似ていても、収集元(動画・音源)は目的ごとに必ず別に収集し直す

判断基準はシンプルで、新しいspecsを作る前に「このtaxonomyは評価用か学習用か」を自問し、両方に使いたくなった場合は1つを共用せず、目的ごとに別のspecsを新設する。

新しいデータソースはパイロットで検証してから本格展開する

新しいデータソースを追加するときは、本格規模で収集する前に必ず少量のパイロットを作り、実際に効果があるか確認してから本格展開する。

  • 数本〜数十本規模の小さいパイロットをまず収集する
  • 既存の最良のcache/splitと組み合わせて、ミニマルに再学習・評価する
    • 理由は、大きいcollectionを毎回丸ごと作り直さなくても効果検証ができるため
  • 効果が確認できてから、本格規模の収集を依頼する
    • 理由は、パイロットで見えた効果が、規模を変えても同じ方向に働くとは限らないため
  • 本格展開後も同じ評価をやり直し、効果が維持・拡大しているか確認する
    • 理由は、規模を変えると逆効果になるケースがあるため(次項参照)

小さく検証してから広げるという当たり前の流れだが、パイロットの成功だけで本格投資を決めると、規模を変えたときに効果が消える・悪化するケースを見逃す。

class-balanced samplingではグループを分離する

class単位で重みを正規化するsamplingに新しいデータソースを混ぜる場合、既存の大きいグループにそのまま混ぜず、専用グループに分離するかを検討する。

  • 既存グループに混ぜると、追加したデータ自身の学習頻度が意図と逆に下がることがある
    • 理由は、重みがグループ全体の件数で正規化されるため、グループが大きくなるほど新しく足した分の1件あたりの重みも薄まるため
    • 結果として、対策のつもりで追加したデータの絶対件数は増えても、実際に学習で使われる頻度はむしろ下がる
  • 対策は、追加したデータソースを示すタグを検出し、既存の{class}_{source}のようなグループとは独立した専用グループとして扱う
    • 理由は、他のソースの件数に関係なく、追加分自身の学習頻度を保つため

新しいデータを追加するときは、「専用グループにするか、既存グループに混ぜるか」を最初に決めておく。既存グループへの混入は、追加する件数が既存グループに対して相対的に大きいほど効果を弱めるため、後から気づきにくい。

複数ソースをマージする時はコンテンツ重複に注意する

複数のソースからデータをマージしてcollectionを作る場合、パスが違うだけで中身が同一のファイルが紛れ込むことがある。

  • 本体の一部を間引いた「サブセットビュー」を、別ソースとして安易にマージしない
    • 理由は、間引いたものは本体に対して新しい情報を追加しないため、別枠として件数に加算すると二重カウントになるため
  • データ移行やマージにはcp -alのようなハードリンクを使う
    • 理由は、cp -rのような複製だと、後から見て「独立ファイルか、元々同じものか」が分からなくなるため
    • ハードリンクなら、同一内容が複数箇所にできてしまってもinode照合で低コストに検出できる
  • マージ後は必ずコンテンツ重複の自動チェックを実行する
    • 理由は、splitsはdata_item_id単位でハッシュ分割するため、同一データが別IDとして存在すると、同じ内容がtrain/val/testの異なるロールに分裂してしまうため

複製の混入は気づきにくいが、split後のtrain/testリークという実害につながるため、マージのたびに機械的にチェックする。

自動抽出のデータには再検証ステップを設計する

精度優先の自動抽出(字幕マッチング等)でデータを収集する場合、抽出されなかった残りを別のクラス(例: negative)と決めつけない。

  • 自動抽出は網羅的ではなく、精度を優先した手法ほど取りこぼしが多い
    • 理由は、ヒットしなかったからといって、対象が実際に含まれていないとは限らないため
  • 「マッチしなかった残りは全部negative」のような推論をする場合は、別途再検証ステップを設計する
    • 例: より網羅的な別手法で、対象が本当に含まれていないかを再確認する

自動化した抽出パイプラインほど、取りこぼしがそのままラベル誤りとしてデータに混入するリスクがあることを前提にする。

エラー分析専用のステージ(err)

評価runで誤発火(FA: False Alarm)したクリップを実際に聴いて原因を調べるため、data/err/<id>/(manifest.yaml+README.md+clips/)という専用のステージを追加した。

  • err由来のクリップは、学習には絶対に使わない
    • 理由は、評価専用collectionから切り出したものなので、学習データに混ぜると評価データそのものが学習に漏れてしまうため
    • collections・splits・aug・cacheのいずれにもerr由来のクリップを取り込まない
  • 分析で見つかったパターンを対策データとして使いたい場合、そのクリップ自体は再利用しない
    • 同じパターンの素材を別途独立に収集し直す

これも、前述の「評価用と学習用を混同しない」という原則の一種になっている。

生成順序でバグを消す(splitをaugより先に)

データ生成のパイプラインをspecs → [raw] → collections → splits → aug → cacheの順に固定した。

  • ポイントは「splitをaugより先に行う」こと
    • 先にtrain/val/testに分割する
    • train判定されたクリップにだけdata augmentationをかける
  • こうすると、val/testロールのクリップには最初からaugが一切かからなくなる
    • 学習コード側に「val/testはaugを除外する」という特別な条件分岐を書く必要がなくなる

「気をつけて実装する」ではなく「その状態しか作れないようにする」ことでバグの芽を摘む、という考え方になっている。

experimentとrunの設計

分ける理由

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

experimentの種類

「experiment」と一口に言っても、目的によって性質がかなり異なる。

  • 精度向上のための実験 — 実際にモデルを学習し、指標そのものを改善することが目的
  • ベースライン実験 — 比較の基準点そのものを作る
  • 比較実験 — 複数のモデル/手法/設定を同じ条件で横並びに比較する
  • アブレーション — ある要素(データソース・機構・ハイパーパラメータ)を1つだけ足し引きし、その要素単体の寄与を切り分ける
  • ハイパーパラメータ探索 — 学習率・バッチサイズ等の設定を振って良い設定を探す
  • 診断的実験 — 「なぜこの現象が起きるか」という原因の特定そのものが目的で、精度向上を直接の目的としない
  • エラー分析 — 誤検知したデータを実際に調べて原因を特定する(前述のerrステージ参照)
  • 決定性・再現性の検証 — モデルの学習は行わず、パイプラインが決定的かどうかだけを確認する
  • 汎化性・頑健性の検証 — 生成過程が異なるデータやドメインシフトに対して頑健かを確認する
  • 評価専用の実験 — 学習は行わず、既存モデルに対して評価パイプラインだけを繰り返し実行する

このうち診断的実験・決定性検証・評価専用は、モデルを学習しない点で「精度向上のための実験」とは性質が異なるが、同じexperiment/runという構造の中にそのまま収まる。仮説・手法・結果・考察という同じ形式で記録できれば十分で、種類ごとに別の入れ物を用意する必要はない。

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

前述の「コピーのコストで決める」とは別の軸で、コードにも同じコピー/共有の判断が出てくる。

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

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

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

運用の自動化

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

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

この2つを徹底すると、「記録されている内容」と「実際に実行された内容」が原理的にズレなくなる。

チェックリストをフェーズで分ける(データ作成時 / 評価後)

チェック観点をまとめるchecklist/は、確認するタイミングによって2つのネームスペースに分けている。

  • eval_checks/ — 評価runを実行した後に確認する観点
    • クラスバランス・信頼区間・データの不整合・splitのリークなど
  • data_checks/ — データを作る/追加する時に確認する観点
    • パイロット先行・グループ分離・コンテンツ重複・immutabilityなど(前述の各項目)
    • 理由は、評価後に気づくのでは手遅れな観点(データ収集方針そのものの間違い等)を、作る段階でチェックできるようにするため

どちらも1項目1ファイルで管理し、個別に追加・削除・改訂できるようにしている。

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

評価観点はchecklist/に1項目1ファイルで管理し、随時追加・改訂できるようにしている。

  • 客観的に判定できるもの(クラスバランス・サンプル数・信頼区間・データの不整合・splitのリーク)
    • 自動テスト化し、runのたびに機械的に実行する
  • 解釈が必要なもの(サブクラス間の相関、仮説との差異、汎化の妥当性)
    • 自動化せず、run単体のREADME.mdに人が文章で書く
  • 横断的な良化/悪化の比較はleaderboard.mdに1行ずつ追記していく
    • 理由は、退行に気づけるようにするため

何でも自動化しようとせず、「機械的に白黒つけられるか」で線引きする。

対策より先に、原因を特定する(エラー分析のワークフロー)

指標が悪化した/改善しないと分かった時点で、いきなり「データを増やす」対策に飛びつかず、まず原因を特定するところまでを標準の手順にした。

  • 誤検知したデータを専用ステージ(err)に抽出する
    • 学習には混ぜない(前述の通り)
  • 聴く・見る・文字起こしするなどして、内容面での共通点を探す
  • テキストだけで説明できない場合、モデルの特徴量空間でnearest neighbor分析を行う
    • 理由は、声質やチャンネルの偏りのような、言語化しにくい非言語的な要因を検出するため
  • 原因に対してピンポイントな対策データを設計し、同じ評価データで効果を検証する
  • 改善しなければ、同じ手順を繰り返す
    • 理由は、原因の特定が不十分なまま対策を打っても、的外れな対策になるか、既存の仕組み(class-balanced samplingのグループ分け等)との相互作用で意図しない逆効果になりうるため

反復的な調査記録は、モデル学習を行わない検証専用のexperimentとして残す。

  • 理由は、通常のexperimentと同じ形式(README・manifest)で調査の系譜を追えるようにするため

数値の良化/悪化だけを見て次のパラメータを試すやり方では、ラベル誤りや学習データの偏りに起因するショートカット学習のような根本原因までは見つからない。

backlog(課題を残す場所)

作業中に気づいた課題や違和感を、その場で対応するかどうか判断せずに、まずbacklog/<id>/README.mdとして残す。

  • IDの命名規則は他の番号付きnamespaceと同じ({ゼロパディング数字}_{slug})
    • 引用表記もbacklog#01_xxxのように使える
  • 目的は「今すぐ着手しない」と「忘れる」を分けること
    • 逐一対応すると本来の作業が進まない、その場限りのメモだと後で誰も覚えていない
  • README.mdだけの軽量な形
    • 理由は、data/やexperiments/のように厳密なmanifestやimmutabilityを必要としないため

あくまで「後で読み返して着手判断する」ためのメモであり、何かを参照される実体ではない。

決定性・再現性の検証

安定化モードの例

以下が安定化コードの例で、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")

非決定性は下流に伝播するため、パイプラインの全段階を検証する

specs → [raw] → collections → splits → aug → cacheのどこか1段階でも非決定的な処理が混ざると、それ以降の段階は全て非決定的になる。

  • 各段階を個別に複数回re-runし、byte-for-byteで同一の結果になるか確認する
    • 理由は、後段(例: cache)だけを確認しても、非決定性がどの段階で混入したかは特定できないため
  • 自前のコードだけでなく、使っている外部ライブラリ(音声処理・特徴抽出器等)が内部で非決定的な処理(並列化・GPU上の演算順序等)を行っていないかも確認する
  • 処理順序を変えて確認する(元の順・逆順・並列実行)
    • 理由は、浮動小数点演算は結合則が成り立たないため、足し合わせる順序やバッチの構成が変わるだけで結果がわずかにブレることがあり、順序を変えて初めて顕在化するため

1段階でも見落とすと、その後どれだけ丁寧に決定性を検証しても意味がなくなる。

最初にやるべき実験

最初にやるべきなのは、確実に決定的になるかの試験と信頼区間を出すこと。実験の全ての前提は、データと実験の安定性にかかっている。

  • データのsplitが決定的になるかを確認する
  • cacheを作る時にcacheが決定的になるかを確認する
    • 特に高速化のために並列処理を入れると、決定的にならないことがある
  • 安定化コードを入れて、同じデータで学習を複数回行い、byte-for-byteレベルで決定的な結果になるかを確認する
  • それらを複数回評価して、評価コード自体が決定的かどうかを確認する
  • seedを変えたときのばらつき、dropoutを入れたときのばらつきを見る
  • 決定的にならなかった場合は、クラス・サブクラス・グレードなどの区分でどれくらい信頼区間があるかを確認する
  • ソートも一意になるように第二・第三ソートキーまで指定する

結局のところ、seed値・データ・キャッシュ・学習・評価のどこから不確実性が入り、再現性が取れなくなるのかを明らかにする必要がある。CUDAの設定を決定論的にしていても、キャッシュレベルで不確実性が入っていることがあるので注意が必要。

実例:cacheの決定性チェックで見つかったバグ

実際に決定性チェック用の実験(experiments/02_cache_determinism)を作って検証したところ、2つのバグが見つかった。

  • fit_windowのjitterが共有rng依存になっていて、非決定的だった
  • GPU上でのバッチ構成に依存して、浮動小数点の計算結果がブレていた

対策として、cacheのmanifest.yamlに以下を明示的に持たせて固定・記録するようにした。

  • seed(ハッシュ導出の種)
  • chunk_size(チェックポイント区切り)
  • batch_size(特徴抽出器へ一度に渡す件数。バッチ構成がGPUの計算順序に影響するため、固定・記録が必要)

「関数呼び出し時の引数では気軽に変えられないようにする」ことで、後から知らないうちに条件が変わってしまうのを防いでいる。この実例が示すのは、決定性は「CUDAの設定を決定論的にすれば十分」ではなく、キャッシュ生成のような一見地味な処理にも不確実性が紛れ込みうるということ。

並列処理でも決定的にする(共有RNGを使わない)

同じseedを使っていても、並列処理のworker数や処理順序が変わると結果がズレることがある。

  • 原因は、複数のworkerが1つの共有RNG(乱数生成器)を順番に消費する実装になっていると、どのアイテムがどのタイミングで乱数を引くかがworker数やスケジューリングに依存してしまうため
  • 対策は、各アイテムに固有の決定的なseedを、そのアイテムのIDから導出すること
    • 例: hash(seed, data_item_id)のように、グローバルなseedとアイテムのIDを合わせてハッシュ化し、そのアイテム専用のseedとして使う
    • 理由は、この方式ならどのworkerがいつそのアイテムを処理しても、同じアイテムには常に同じ乱数列が使われるため
  • jitter付与・ランダムクロップなど、並列実行の順序やworker数に依存しうる処理はこの方式に置き換えて検証する
  • 外部ライブラリを使う場合も、呼び出し直前にアイテム固有のseedでローカルにreseedすれば、ライブラリ内部の実装に関わらず同じ決定性を確保できる
    • 理由は、ライブラリ側の乱数状態を外から一時的に固定してしまえば、そのライブラリ自体が共有RNGを使っていても影響を受けないため

「seedを固定する」だけでは不十分で、「どこからその都度の乱数を引いているか」まで見ないと、並列化した瞬間に再現性が崩れる。これは特定のライブラリの機能ではなく、状態を持たない乱数の一般的な作法で、JAXのPRNGKey(内部にグローバルな状態を持たず、明示的に受け渡す純粋関数型の乱数生成)・NumPyのSeedSequence.spawn()・PyTorch DataLoaderのworkerごとのseedingも、同じ発想の実装になっている。

実験のCheckability

作業は、特に中間生成物を残させるようにしなければならない。例えばWhisperで作業を数時間かけてもらう時は、必ず次の3つのことをやる。

  • 小さなセットで事前にdry-run
  • 中間生成物(Whisperの結果)はファイルに逐次保存
  • 他のAgentにDouble Checkしてもらう

Whisperの処理には時間がたくさんかかり、数時間も待つ羽目になるため、中間生成物は必ずファイルに保存しておく。こうすることで、リトライや再検証などができるようになる。

まとめ

実験管理を設計し直す中で、ディレクトリ構成そのものより「なぜそう分けたか」という判断基準の方が、他のプロジェクトにも使い回せると感じた。挙げた基準は、次の通り。

  • 方針転換(experiment)と細かい試行(run)を別の粒度として分ける
  • 理由(文章)と構造化データを別ファイルに分ける
  • コピーは可変でよく、参照は不変でなければならない、という原則でミュータブル/イミュータブルを線引きする
  • コピーか参照かは、コピーのコストで決める(安ければコピー、高ければ参照)
  • 「書き換えない」という約束事は、ドキュメントに書くだけでなくchmodで物理的に強制する
  • 1:Nで参照される対象は、所有関係を1:1にしないようトップレベルの共有領域に独立させる
  • 気をつけて実装するのではなく、間違った状態そのものを作れなくする(splitをaugより先に等)
  • スキーマを増やす前に、既存の仕組みを一般化できないか考える
  • 実験対象のコードは複製して固定化し、実験対象でないコードは共有して育てる
  • 手作業を自動化し、記録と実行がズレない構造にする
  • 自動化できる評価と、人が書くべき考察を最初から切り分ける
  • 新しいデータは小さく検証してから広げ、指標が悪化したら原因の特定まで追う
  • 「気をつけて運用する」のではなく、間違いにくい状態しか作れないようにする(scaffoldスクリプト・chmod等)
  • 特に全てのコードをCheckするのは無理なので、道を外さず、方向性を間違わないようにガードレースを作っていくイメージ

どれも当たり前に見えるが、後から気づいて直すのは大変。次に新しいプロジェクトを始めるときも、同じ基準で最初から作っていきたい。また、もっとやる場合は、そもそもxxx-project-dataとxxx-project-trainとかに分けてもいいのかもしれない。

Built with Hugo
テーマ Stack は Jimmy によって設計されています。