デフォルトパラメータとどう付き合うか

概要

  • ロボットや機械学習では大量のパラメータを扱うことになる
    • Architect(アーキテクチャを考える人) 1 : developer 10 : User 100 ぐらいの比率であることを考えた時、パラメータはこの100の人々が触ることになるのでかなり慎重に設計する必要がある
  • 一方でパラメータの構成はソフトウェアでの制約条件が緩く、性善説運用されがち
    • 最終的に大概ぐちゃぐちゃになって、何がなんなのかわからなくなる
    • 基本はルールで縛ることが多くなってしまう
    • が変なルールで縛ると開発者体験が非常に悪くなってしまい、開発者の心が離れてしまう
      • User 100を優先した結果、developer 10の開発スピードが悪くなる、みたいのもあるあるな話
  • 大体のパラメータは「書き換えをしていくタイプのパラメータの構成」になっていくことが多いが、雑に扱うと意図しない挙動を生みがち
    • いわゆる"デフォルトパラメータ"であったり、上位インタフェースのパラメータの書き換えであったり
    • パラメータの扱いが適当なケースはままあると思うが、この話題に絞ってどのようにparameterを構成していくかを考えてみる
    • 基本的には使うソフトウェアで何かしらの成約を受けていることが多いと思われる
      • 例えばデフォルトパラメータは基本的に使えないようなプログラミング言語も存在する
      • 絶対的な正解は無くソフトウェアの依存に大きく依存するので、ケースバイケースで考える必要があるのは大前提
  • このあたりを一回色々考える必要に駆られたのでそのメモを残していく
    • 未来にも使えそうだなと思ったので方針として考えられる候補を書き出してまとめておく
    • 思いつき次第追記していくつもりではある

勘案

1. デフォルトパラメータの書き換えをそのものを避け、全てのパラメータを受け渡しの地点で全て確定させる

  • ROS2 のlaunch fileを例にとってみて、以下のようなlaunch fileとpackageのlaunch fileの例を考えてみる
    • launch fileでは何のnode(1アプリケーション)を、どのパラメータで動かすかを一緒に指定できる
<!-- main.launch.xml-->
<launch>
  <arg name="input/image" default="~/image_raw" />

  <include file="$(find-pkg-share package_a)/launch/package_a.launch.xml">
    <arg name="input/image" value="$(var input/image)" />
  </include>
</launch>
<!-- package_a.launch.xml-->
<launch>
  <arg name="input/image" default="~/image_compressed" />

  <!-- node -->
</launch>
  • このような構成の場合、各ファイルでdefault parameterが異なるため argを消したりすると挙動が異なってしまう
    • 誰かの変更によって、意図しない処理が走る可能性がある状態と言える
    • parameterのargを消しても、意図しないdefault parameterによって埋められるのでエラーが起きないことが多い
      • ソフトウェアが肥大化してくると、バグの発見が遅れたり、デバッグが困難になりがちな状態になってしまう
      • 治そうとするときもどこで書き換えが起こっているのかを特定するために、階層の分だけparameterを遡る必要があるためデバッグに時間がかかりがち
<!-- main.launch.xml-->
<launch>
  <arg name="input/image" default="~/image_raw" />

  <include file="$(find-pkg-share package_a)/launch/package_a.launch.xml">
    <!--<arg name="input/image" value="$(var input/image)" />-->
    <!-- The parameter changes from "~/image_raw" into "~/image_compressed" -->
  </include>
</launch>
  • そのためdefault parameterを消して一番上位レイヤーからしかparameter を指定できないようにすると比較的マシになる
    • ソフトウェアが意図しない処理のときにfailするようになっていれば、「想定と異なる挙動」はしないだけマシ
    • 一方で上位レイヤー側が肥大化して管理が面倒になりがちというデメリットがある
      • 「よく修正するところを直したいだけなのに、使いもしない大量のパラメータの海から探すのが面倒」みたいな構図になりがち
    • (もちろんconfig file化などはするが、config fileが肥大化するので結局同じ問題には帰着する)
<!-- main.launch.xml-->
<launch>
  <arg name="input/image" default="~/image_raw" />

  <include file="$(find-pkg-share package_a)/launch/package_a.launch.xml">
    <arg name="input/image" value="$(var input/image)" />
    <!-- If this arg is commented out, then running will fail -->
  </include>
</launch>
<!-- package_a.launch.xml-->
<launch>
  <arg name="input/image" />
  <!-- omit -->
</launch>
  • ROS2では起動するソフトウェア(launch file)の中にparameterが記述されるというソフトウェアの成約が存在するので command {起動するファイル} {parameterファイル} のような構成を取れないので、あくまで性善説運用になってしまう
  • なので基本的に「デフォルトパラメータの書き換えをそのものを避け、全てのパラメータを受け渡しの地点で全て確定させる」というスタンスを取る時は、ソフトウェアが大規模にならないことが確定している時にしか使いにくいとも言える
    • このあたりは「運用を考えた時のソフトウェア選定」とも言える

2. デフォルトパラメータ用のファイル + ユーザーが頻繁に変えるところのファイルのみで書き換えを行うようにする

_base_ = ['co_dino_5scale_swin_l_lsj_16xb1_1x_coco.py']
model = dict(backbone=dict(drop_path_rate=0.5))
param_scheduler = [dict(type='MultiStepLR', milestones=[30])]
train_cfg = dict(max_epochs=36)
  • このようなparameterの書き換えは(遡るのが少し面倒とは言え)比較的アーキテクチャの切れ目ごとに定義されており、ある程度configの設計指針を感じられるので問題は起こりにくい
  • 一方で適当に開発しているプロトタイプ開発などでは、(特に機械学習の研究開発のような場面で)以下のようなファイルが作られがちだったりする
# model_a.py
model = dict(type='Model_A')
# Default parameter is defined in class objects.
# class Model_A(BaseDetector):
#     def __init__(
#             self,
#             parameter_a = 2,
#             parameter_b = 3):
epoch = 10
# model_a_v2.py
_base_ = ['model_a.py']
model = dict(parameter_a=1, parameter_b=2)
# model_a_v3.py
_base_ = ['model_a_v2.py']
model = dict(parameter_a=2)
epoch = 20
# model_a_v4.py
_base_ = ['model_a_v3.py']
model = dict(parameter_b=10)
  • このような場合最終的にトレーサビリティがどうなっているのか把握しづらくなっていき、一体今どのパラメータで実行されているのかどんどん把握が難しくなっていく
    • (mmdetectionの場合は最終的に定義されたparameter fileが作られており、一応パラメータの再現自体は可能ではあるので「再現性」を問題にするのは微妙にズレていて、あくまで開発者体験が悪くなっているだけというのが正確だと思われる)
  • 一方で書き換え禁止も辛いものがある
    • 共通のところは _base_ を使って共通化していきたいのと、「書き換えをそのものを避ける」のはソフトウェアの仕様上結局reviewなどで指摘する方法ぐらいしかない
  • 運用でカバーするためには
    • v1 -> v2 -> v3 -> v4のような書き換えでなく、全てのパラメータの変更をbase fileからの変更を書き加えるようにして、base -> v1, v2, v3, v4 の継承の仕方をして、なるべく書き換えを減らすようにすると多少問題は緩和できる
    • ただここはシステム上で弾くとかは結構用意するのが面倒で、いわゆる「運用でカバー」になり、reviewer側の負担はどうしても大きくなる
    • 共通化の処理は同じ処理にして、パラメータを書き換える場所を固めておくとユーザーフレンドリーだと思う
    • つまるところ「木構造において深さをなるべく減らすようなパラメータファイル構成にする」を意識する感じになる
      • あくまで"意識する"という性善説ではあり、適当に開発する人がいると管理しきれなくなる
  • このようになるべく層が浅い構成にすれば、比較的把握しやすいパラメータ管理になるはず
# model definition
class Model_A(BaseDetector):
    def __init__(
            self,
            parameter_a: Optional[int],
            parameter_b: Optional[int],
            parameter_c: int = 0):
# model_a_base.py
model = dict(type='Model_A', parameter_a=None, parameter_b=None)
# If parameter_c is fixed, it may be OK to use by default parameter.
epoch = None
# model_a_v2.py
_base_ = ['model_a_base.py']

# User parameter
parameter_a=1
parameter_b=2
epoch = 10

# Overwrite process
model = dict(parameter_a=parameter_a, parameter_b=parameter_b)
# model_a_v3.py
_base_ = ['model_a_base.py']

# User parameter
parameter_a=2
parameter_b=2
epoch = 20

# Overwrite process
model = dict(parameter_a=parameter_a, parameter_b=parameter_b)
# model_a_v4.py
_base_ = ['model_a_base.py']

# User parameter
parameter_a=2
parameter_b=10
epoch = 20

# Overwrite process
model = dict(parameter_a=parameter_a, parameter_b=parameter_b)
  • そもそもパラメータのオーバーライドはなるべくせず、使っていないパラメータは破棄してくのが正しいという話もあるが、これもreviewを頑張っていくしか無いとも言える
    • あと「デフォルトパラメータはclassの定義地点のみ使う、パラメータファイルでは上書きするもののみ」という構図が一番綺麗なようには感じる
    • 多少の肥大化にも耐えられる構成と言えそう

3. ディレクトリ階層そのものに親と子の関係を持たせる

  • BEVFusion などで使われている形式
    • 階層そのものがデフォルトパラメータ -> 上書きの構成を持っているもの
  • 以下の階層の場合でconfigs/bevfusion/parameter_1.yaml を指定した場合
      1. configs/default.yaml
      1. configs/bevfusion/default.yaml
      1. configs/bevfusion/parameter_1.yaml
    • の順にパラメータが読み込まれて、上書きされていく
- configs/
  - default.yaml
  - bevfusion
    - default.yaml
    - parameter_1.yaml
  • 利点としては command {parameter file} の形式かつ、parameterのトレースはしやすいという点
  • 使いにくい点としては、階層構造に縛られるのでそのパラメータから異なる階層のパラメータは読み込むことができない(この制約条件のおかげでトレースがしやすいとも言える)

今の所のtemplate案

  • 「ディレクトリ階層そのものに親と子の関係を持たせる」と「デフォルトパラメータ用のファイル + ユーザーが頻繁に変えるところのファイルのみで書き換えを行うようにする」を足し算する構成が今の所丸いのでは、というのが一時的な結論
  • 1 componentとして動作するconfig + それを読み込むデフォルトパラメータ + ユーザーが書き換えるパラメータ の構成
    • ユーザーが管理していくところは「ディレクトリ階層そのものに親と子の関係を持たせる」を運用ルールとして定める
  • 構成例
- configs/
  - component/
    - component_a.py
- projects/
  - project_a/
    - configs/
      - default.py
      - model_a/
        - default.py
        - experiment_1.py
        - experiment_2.py
      - model_b/
        - default.py
# projects/project_a/configs/model_a/default.py

_base_ = ['../../../../configs/component/component_a.py']

# default parameter
parameter_a = 3
parameter_b = 4
parameter_c = 5
# projects/project_a/configs/model_a/experiment_2.py

_base_ = ['./default.py']

# User parameter
parameter_a = 2
parameter_b = 2
epoch = 20