機械学習や研究の「実験設定」をちゃんと管理できているか?をテーマに、
@dataclassと config ファイルを活用してシンプルかつ再現性のある実験管理を実現する方法を紹介します。
MLflow や Weights & Biases を入れる前に、まずは Python 標準機能だけでどこまで整えられるか、という視点で書いています。
なぜ config を外出しするのか
実験コードの中に直接ハイパーパラメータやパスを書いていると、次のような問題が起きます。
- 再現できない: あのときの設定を思い出せない
- 共有しづらい: チームで比較やレビューができない
- 管理が煩雑: 試行錯誤のたびにスクリプトをコピー&改変してカオス化
そこで「設定をコードから分離」することで、以下のようなメリットが得られます。
この記事では、コード本体と設定ファイルを分けることを便宜上「外出し」と呼びます。
具体的には configs/baseline.yaml のように設定を外部ファイルに保存して、プログラム起動時に読み込みます。この外出しによって以下のメリットが得られます。
✅ 再現性: config ファイルさえ残っていれば実験を完全に再現できる
✅ 共有性: YAML / TOML / JSON ファイルで他人と設定を比較・管理できる
✅ 透明性: 実験ごとの差分が明確になる
✅ 安全性: コードと設定が独立しているのでミスを防ぎやすい
特に差分レビューや比較が config ファイルだけで完結するようになると、「あの実験の奇跡の結果は何を変えたときだっけ?」を後から追う負担がかなり減ります。
@dataclass が便利な理由
config を yaml.safe_load で読み込んで素の dict で扱うだけだと、config["train"]["lr"] のようなアクセスになります。
この方法だと
- 補完が効きにくい
- キータイプミスが実行時まで見つからない
- 型の間違いも実行してみるまで分からない
といった地味なストレスが積み上がります。
そこで @dataclass の出番です。これを使って「型付きの設定オブジェクト」にしておくと、扱いやすさが格段に上がります。
ネスト構造のメリット(再利用性・視認性・ YAML 対応)
実験設定には、データ・モデル・学習など性質の異なる設定が混在します。
@dataclass を使うと、これらを カテゴリごとに階層(ネスト)構造で整理 できます。
例
from dataclasses import dataclass, field
@dataclass
class DataConfig:
dataset: str = "cifar10"
root: str = "./data"
@dataclass
class ModelConfig:
name: str = "resnet18"
hidden_dim: int = 512
@dataclass
class TrainConfig:
lr: float = 1e-3
batch_size: int = 64
@dataclass
class ExperimentConfig:
# 初心者は default_factory を使っておくと安全
data: DataConfig = field(default_factory=DataConfig)
model: ModelConfig = field(default_factory=ModelConfig)
train: TrainConfig = field(default_factory=TrainConfig)
使用例
cfg = ExperimentConfig()
print(cfg.data.dataset) # "cifar10"
print(cfg.model.name) # "resnet18"
print(cfg.train.lr) # 0.001
YAML 側も自然に対応
data:
dataset: cifar10
root: ./data
model:
name: resnet18
hidden_dim: 512
train:
lr: 0.001
batch_size: 64
このように @dataclass は、カテゴリごとの設定をネスト構造で自然に表現できる のが大きな利点です。
見通しがよく、YAML との相性も良好です。
上の ExperimentConfig で field(default_factory=...) を使っているのは、インスタンス間で同じオブジェクトを共有しないための安全策です。
data: DataConfig = DataConfig() のような書き方を @dataclass で行うと、クラス定義時に「ミュータブルなデフォルト値は禁止」という趣旨の ValueError が発生します。デフォルトインスタンスを持たせたいときは field(default_factory=DataConfig) のように書く必要があります(通常のクラスや関数引数のデフォルトで同じことをすると、実際にインスタンス共有バグになります)。
このように Python 標準ライブラリの dataclasses モジュールを使うと、設定の型安全性・可読性・保守性が大幅に向上します。
ポイント
- 型ヒントがそのままドキュメント代わりになる
- デフォルト値を安全に定義できる
- ネストした設定を自然に表現できる
-
asdict()/replace()などの変換が使える
設定を Python クラスとして表現しておくと、補完が効き、IDE 上でも構造が見えます。
補足: @ と型ヒント
@ はデコレータを付けるための記号です。
関数やクラス定義の直前に 1 行書くと、その定義に“機能を被せる”糖衣構文になります(@decorator は実質 obj = decorator(obj))。
Python 2.4 で導入された公式の記法です。
デザインパターンの Decorator(ラッパで振る舞いを重ねる)と発想が近いので、連想して覚えると理解が早いです。
🐣 糖衣って言われると子どもの頃処方された薬が連想されませんか?
次に lr: float = 1e-3 のように 名前: 型 と書くのが型ヒント(type hint)です。
Python は動的型ですが、型ヒントを書くとエディタ補完や静的チェッカ(mypy など)が賢くなります。
関数引数だけでなく、変数やクラスのフィールドにも書けます。
本来 Python には型ヒントは必要ではなく、実行時に強制されない説明タグです。ただし @dataclass は型ヒントがある変数しか拾ってくれないため、そこには注意しましょう。
@dataclass の基礎
from dataclasses import dataclass, field
from typing import List
@dataclass
class TrainConfig:
seed: int = 42
epochs: int = 10
batch_size: int = 32
lr: float = 1e-3
devices: List[int] = field(default_factory=lambda: [0]) # list 型は default_factory
よくあるミス
ミュータブルなデフォルト値の罠
# ❌ NG
from dataclasses import dataclass
from typing import List
@dataclass
class TrainConfig:
devices: List[int] = [0] # dataclass ではクラス定義時に ValueError になる
@dataclass では、このようなミュータブルなデフォルト値はクラス定義の時点で ValueError になります。
これは、本来であれば「全インスタンスで同じリストを共有してしまう」という典型的なバグになり得るパターンを、あらかじめ禁止しているためです。
default_factory を使えば、インスタンスごとに独立したリストが生成されます。
また frozen=True にすれば実行中の変更を防げるため、再現性を重視する際にも便利です。
初期値あり・なしの並べ方のミス
Python の関数定義の規則であり @dataclass 固有の制約ではありませんが、変数は初期値なし → 初期値ありの順に並べるのがルールです。
from dataclasses import dataclass
# ✅ 正しい: 初期値なし → あり
@dataclass
class CFG:
epochs: int # なし
lr: float = 1e-3 # あり
# ❌ 間違い: あり → なし(この並びはエラー)
@dataclass
class Bad:
lr: float = 1e-3 # あり
epochs: int # なし <- これが許されない
config の使い方のイメージとフォルダ構成例
設定ファイルは YAML / TOML / JSON などで記述します。
YAML 例
version: "1.0.0"
data:
dataset: cifar10
root: ./data
model:
name: resnet34
train:
seed: 123
lr: 0.0005
epochs: 50
batch_size: 128
Python 側
from dataclasses import dataclass
@dataclass
class DataConfig:
dataset: str
root: str
@dataclass
class ModelConfig:
name: str
@dataclass
class TrainConfig:
seed: int
lr: float
epochs: int
batch_size: int
@dataclass
class ExperimentConfig:
version: str
data: DataConfig
model: ModelConfig
train: TrainConfig
実際の YAML 読み込みとマッピングのコードは、後の「使い方の具体例」でまとめて示します。
フォルダ構成例
project/
├─ configs/
│ ├─ baseline.yaml
│ └─ sweep.toml
├─ src/
│ ├─ config_schema.py
│ ├─ main.py
│ ├─ config_io.py
│ └─ snapshot.py
└─ outputs/
└─ ...(実験ごとの結果)
使い方の具体例
基本の流れ
- config ファイルを読み込む
- 必要なら CLI 引数や環境変数で上書き(今回は割愛)
- 実験ごとにハッシュ化してスナップショット保存
from dataclasses import asdict
import json, hashlib, yaml, pathlib
from config_schema import ExperimentConfig, DataConfig, ModelConfig, TrainConfig
# 1. YAML 読み込み
cfg_dict = yaml.safe_load(open("configs/baseline.yaml", "r", encoding="utf-8"))
cfg = ExperimentConfig(
version=cfg_dict["version"],
data=DataConfig(**cfg_dict["data"]),
model=ModelConfig(**cfg_dict["model"]),
train=TrainConfig(**cfg_dict["train"]),
)
# 2. 実験 ID を生成
cfg_json = json.dumps(asdict(cfg), sort_keys=True, ensure_ascii=False)
run_id = hashlib.sha256(cfg_json.encode()).hexdigest()[:8]
# 3. スナップショット保存
out_dir = pathlib.Path("outputs") / run_id
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "config.json").write_text(cfg_json, encoding="utf-8")
print(f"Experiment ID: {run_id}")
これで outputs/<hash>/config.json に実験設定が保存され、再現性を担保できます。
追加で git commit のハッシュやライブラリのバージョンを同じフォルダに書き出しておくと、比較・再実行がさらに楽になります。
まとめ
今回の記事では以下の内容について解説してみました。
- 設定はコードから切り離してファイル化
-
@dataclassで構造化し、IDE 補完・型安全・再現性を確保 - 実験時には config をハッシュ化し、スナップショット保存で履歴を残す
オブジェクト指向プログラミングで出てくるインターフェースは、実装の本体と表層的な部分を切り離す有効な手段ですが、@dataclass も同じ分離系のテクニックと見なせるかもしれません。「設定」という概念を実装から切り離す視点は、規模が大きくなるほど効いてきます。
🐣 その逆が JTC でお馴染みの属人化と言えるかもしれませんね…
ではまた次の記事でお会いしましょう。