🧪

DVC のコア概念と Tips

に公開

はじめに

機械学習モデルの研究開発やオペレーションズ・リサーチでは、大量のデータや実験条件、実験結果を正確に紐付けて管理することが求められます。また、実行コストが高めの処理を多段階で行うことが基本になってきます。その際、

  • 処理フローの下流だけを変えたときに上流が再実行される無駄をなくしたい
  • 細粒度に切ったスクリプトを正しい順番で手動で実行するなどやっていられない

というジレンマに悩まされることになります。

これらに対するソリューションの一つが DVC (Data Version Control) です。DVC は Git の上での使用を前提とした開発言語非依存のデータ・実験管理ツールです。弊社の一部プロジェクトに DVC を導入してからしばらく経ちましたが、かなりいいツールだと思います。一方で、使い方の自由度が高く、下手に使うとうまくいかない感じもします。

本記事では、まずは上記問題たちを解決してくれる DVC のコア概念を解説します。そして、いろいろ試す中でしっくりきた使い方を Tips として紹介します。

コア概念

ステージ

DVC において最も基本となる概念はこの「ステージ」です。
ステージは実行の最小単位のことです。主には以下の構成要素からなります。

  • 実行コマンド
  • いくつかの依存先パス
  • いくつかの出力先パス

ステージは一つの実行コマンドに紐づきます。そして、その実行の挙動はいくつかのパスが示すファイルやディレクトリの内容に依存し、実行の結果はいくつかのパスが示すファイルやディレクトリに出力されます。

例えば、ある機械学習モデルの学習を行うステージであれば、実行の挙動はソースコードの書かれたファイルやデータの入ったファイルの内容に依存するでしょう。そして、モデルパラメータが入ったファイルや、損失の推移などを記録したファイルを出力するでしょう。

dvc.yaml というファイルにステージを定義していくことになります。

dvc.yaml
stages:
  train:
    cmd: python train.py
    deps:
      - train.py
      - data.txt
    outs:
      - model.pth
      - loss.txt

dvc commit

dvc commit というコマンドにより、dvc.yaml に登場する全てのパスの内容のハッシュ値が計算され、dvc.lock というファイルに記録されます。

dvc.lock
stages:
  train:
    cmd: python train.py
    deps:
    - path: data.txt
      hash: md5
      md5: 3024c76fe9c8e68b8cef6ea6a2a1d270
      size: 1969920
    - path: train.py
      hash: md5
      md5: 2c69f34deb21110c5a2030e4b3486318
      size: 11701
    outs:
    - path: loss.txt
      hash: md5
      md5: 8c056bfde0e2344039b8f11dc86c3349
      size: 10624
    - path: model.pth
      hash: md5
      md5: 1e2174f22b9815556dd508153a1e28da
      size: 65664

この dvc.lock を Git 管理することで、各コミットにはソースコードのスナップショットと共に、実質的にデータや実験結果の状態も記録することができます。

dvc repro

いくつかのステージが与えられたら、

  • あるステージの依存先だがどのステージの出力先でもないパスたち
  • ステージたち
  • あるステージの出力先だがどのステージの依存先でもないパスたち

を頂点とし、パスを辺とした有向グラフを考えることができます。
このグラフにはサイクルがあってはなりません。逆に、サイクルがなければ、ステージを実行すべき順序がトポロジカルソートによって自動的に決まります。

dvc repro は、DVC において実行のトリガーとなるコマンドです。dvc.yaml を参照し、あるべき順序でステージを実行していきます。

ただし、すべてのステージを必ず実行するわけではありません。あるステージの順番が来たら、依存先パスの内容のハッシュ値を求め、dvc.lock から変化があった場合のみ再実行します。このようにして無駄な再実行を避けることができます。

なお、dvc repro でも dvc.lock は自動的に更新されます。

Tips

依存先を上手く指定する

依存先の指定方法は、DVC を使っていて悩むことの一つです。ステージの依存先パスの内容が変わることを出力先パスの内容が変わることの必要十分条件になるべく近づけたいと考えるわけですが、これはそう簡単には達成できないのです。

例えば、もともとファイルシステム上にはない外部状態に依存させたい場合があります。このような場合は、まずその外部状態をファイルシステム上に反映させなければなりません。ステージには always_changed というプロパティがあり、これを true にすることで dvc repro において依存先云々関係なく必ず再実行されるようになるので、外部状態だけに依存してファイルを書き出すステージを切り出し、これを指定します。

単純にソースコードだけ見ても厄介です。実行の挙動に関わるソースコードは、コマンドに登場するエントリポイントだけでは当然ありません。突き詰めれば背後には巨大なライブラリ群があるわけで、ファイル単位で指定などやっていられませんし、粒度を上げてディレクトリ単位で指定すると、不要な依存が生まれてしまいます。

こういうのは使用言語やフレームワークに応じて頑張る必要があります。例えば Python であれば、スクリプトから import を自動的に辿って依存モジュールを列挙し、それらの内容をまとめたハッシュ値をファイルに吐き出すステージを作るといいでしょう。動的 import などがあったり、そもそもファイルという粒度が大きすぎるケースもあったりするので厳密な必要十分条件にはなりませんが、まあ実用上困らないくらいにはなります。

この場合、ソースコード自体もある程度 DVC を意識しながら書くといいでしょう。動的 import はわかりやすく避けるべき例ですが、直接的ではなくとも、例えば使う可能性があるものを全部 import しておいて、入力に応じて実際に使うものを選ぶような書き方は不要な依存関係を生みます。特にエントリポイントに近い部分ではなるべく避けるようにしましょう。

dvc.yaml を直接書かない

dvc.yaml には foreachmatrix のような書き方があり、共通部分のあるステージ群をまとめて定義することができます。

しかし、条件分岐が絡むなど、結局これらで表現できないルールでステージ群を生成したいことが多いです。したがって、提供されているものを利用することにこだわり、頑張って dvc.yaml を手書きするというのは個人的にはお勧めしません。

開発言語が Python なら Python でラップしてしまいましょう。リッチな埋め込み DSL などなくても、yaml くらい辞書オブジェクトから一発で作れます。たとえ foreachmatrix で表現可能でも、最初からリスト内包表記で直接ステージを作る書き方をすればよいです。これについて DVC が提供する方法を使うことの恩恵は特にありません。

まとめ

本記事では、DVC のコア概念と、いくつかの Tips を紹介しました。DVC は非常に強力なツールですが、上手くやらないと逆に面倒なことになってしまうこともあります。もう少し日本でも流行って知見が増えるといいなと思うので、ぜひあなたも使ってみてください。

mutex Tech Blog

Discussion