Rule Agent Tuning — Optuna Stage¶
作成日: 2026-07-05
Rule-based agent の params.yaml を Optuna (TPE + MedianPruner) で探索する実装。Grid
stage の兄弟として pca.rule_agents.tuning に組み込まれている。
対応実装:
src/pca/rule_agents/tuning/optuna_stage.py— Optuna 統合本体src/pca/rule_agents/tuning/evaluator.py— Evaluator プロトコル、StubEvaluator/SelfplayEvaluatorsrc/pca/rule_agents/tuning/override_dir.py— 一時 override params.yaml を書き出すヘルパーsrc/pca/rule_agents/config.py—PCA_PARAMS_OVERRIDE_DIR環境変数対応src/pca/rule_agents/tuning/__main__.py— CLI (--stage optuna)
前提: rule-agent-tuning.md の全体設計と目的関数、Aggregator の重み付けを前提としている。
併読推奨:
- rule-agent-tuning-algorithm.md — TPE / MedianPruner のアルゴリズム詳細 (l/g 比、KDE、中央値判定)
- rule-agent-tuning-flow.md — どのファイルが、いつ、どういう仕組みで更新されるか (env var、subprocess フロー、run vs apply)
- ../decisions/0003-tuning-tpe-vs-grid.md — なぜ TPE を採用したか (ADR)
なぜ Optuna か¶
前回議論で得た結論 (詳細は docs/research/2026-07-05-rule-agents-redesign.md 参照):
- 1 config 評価が高コスト (200 games × 数秒 = 15〜30 分/config)
- 勝率ノイズ ±0.03
- 並列化はしやすい (config 間は独立)
上記の下で:
- Grid: 4 dim × 3 値 = 108 config × 200 games = 21,600 games
- Optuna (TPE): 50 trials × 200 games = 10,000 games
- 同精度で計算量 1/2 以下、次元数が増えるほど差が広がる
GA / CMA-ES と比べると:
- TPE は少サンプル (50〜100 trial) で収束する ため 1 評価の高コストと相性が良い
- CMA-ES は 500 trial 以上を前提とするため、我々の予算では回りきらない
- 遺伝的アルゴリズムは population 50 × 30 世代 = 1500 evaluations、同工数で劣る
全体像¶
flowchart TB
CLI["--stage optuna --n-trials 50"]:::cli
SPACE["space.yaml"]:::input
PARAMS0["params.yaml (baseline)"]:::input
POOL["opponent pool"]:::input
RUN["run_optuna()"]:::proc
SPTRIAL["space_to_trial()"]:::proc
EVAL["Evaluator.evaluate(overrides, ...)"]:::proc
REPORT["trial.report(intermediate)"]:::proc
PRUNE{"MedianPruner.should_prune()"}:::proc
OBJ["Aggregator.objective(result)"]:::proc
JSONL["trials.jsonl"]:::output
AGG["Aggregator.write_reports()"]:::output
OUTPUTS["history.csv / top_k.yaml / best.yaml / manifest.json"]:::output
CLI --> RUN
SPACE --> RUN
RUN --> SPTRIAL --> EVAL
PARAMS0 --> EVAL
POOL --> EVAL
EVAL --> REPORT --> PRUNE
PRUNE -- "keep" --> OBJ --> RUN
PRUNE -- "prune" --> JSONL
RUN --> AGG --> OUTPUTS
OBJ -.-> JSONL
classDef cli fill:#e3f2fd,stroke:#1976d2,color:#000
classDef input fill:#fff9c4,stroke:#f9a825,color:#000
classDef proc fill:#fff3e0,stroke:#f57c00,color:#000
classDef output fill:#e8f5e9,stroke:#388e3c,color:#000
主要 API¶
space_to_trial(space, trial)¶
SearchSpace の各次元を Optuna trial 上の suggest_* に変換する:
Choice→trial.suggest_categorical(path, values)Continuousbounds+step が整数 →trial.suggest_int(path, low, high, step)Continuousそれ以外 →trial.suggest_float(path, low, high, step=step)
run_optuna(...)¶
def run_optuna(
space: SearchSpace,
evaluator: Evaluator,
opponents: list[str],
games_per_config: int,
aggregator: Aggregator,
n_trials: int,
seed: int,
parallel: int,
trials_jsonl_path: Path,
pruner_n_startup: int = 10,
pruner_n_warmup: int = 1,
max_configs: int | None = None,
) -> list[EvaluationResult]: ...
- in-memory study (
optuna.create_study(...)) を作成 - 各 trial で
space_to_trial→evaluator.evaluate→aggregator.objectiveを評価 per_opponentが埋まっている場合は opponent 単位でtrial.report(rate, step)を呼び MedianPruner を有効化- 各 trial 結果を
trials.jsonlに追記書き出し TrialPrunedの場合も JSONL にstate: "PRUNED"として記録- 最終的に
list[EvaluationResult]を Aggregator に渡すため返す
コマンドラインからの実行¶
uv sync --group tuning
uv run python -m pca.rule_agents.tuning run \
--agent raging_bolt_ogerpon \
--stage optuna \
--space configs/rule_agents/tuning/raging_bolt_space.optuna.yaml \
--n-trials 80 \
--games-per-config 200 \
--evaluator selfplay \
--parallel 1 \
--confirm-top-k 3 \
--selfplay-workers 8 \
--output data/rule_tuning/raging_bolt/2026-07-09-optuna/
対戦相手プール: 既定 (--opponents auto) では configs/rule_agents.yaml
の enabled かつ match_weight > 0 の agent 全てが tuning 対象と対戦する。明示指定したい場合は
--opponents opp1,opp2,opp3 の形 (opp1:0.5 で objective 重みも指定可)。
主要フラグ:
| フラグ | 意味 | デフォルト |
|---|---|---|
--stage optuna |
Optuna stage 有効化 | (grid) |
--n-trials |
Trial 数 | 50 |
--pruner-n-startup |
MedianPruner の startup trials (それまで prune しない) | 10 |
--pruner-n-warmup |
MedianPruner の warmup steps (per-trial 内で最初の N ステップは prune しない) | 1 |
--trials-jsonl |
Trial JSONL 出力先 | <output>/trials.jsonl |
--no-warm-start |
trial 0 に現行 params.yaml を enqueue しない | (warm-start ON) |
--seed-mode |
fixed = 全 trial 同一 seed / vary = seed + trial 番号 |
fixed |
--confirm-top-k |
探索後に top-K + baseline を高 games で再評価し best.yaml を確定 | 0 (無効) |
--selfplay-workers |
selfplay subprocess の --workers (seed 固定運用では定数に固定) |
(selfplay 既定) |
基準値を初期候補に含める¶
--stage optuna の既定動作として、現行 params.yaml の値を探索空間へ射影した override が trial
0 に enqueue される (optuna_stage.baseline_trial_params)。
- study が必ず「現状」を基準点として含むため、ベースラインより悪い best を選ぶ事故が構造的に起きない (確認ランと組で使うと winner_source=baselineとして検出される)
- TPE は trial 0 の結果も学習に使うので、ランダム初期化より立ち上がりが速い
射影時のサニタイズ (enqueue_trial は値を検証せず、範囲外は trial 実行時に ValueError になるため):
| ケース | 扱い |
|---|---|
| params.yaml にキーが無い | その次元を skip + 警告 (partial enqueue) |
| Choice の values に現在値が無い | skip + 警告 (近傍へ丸めない — baseline の意味が変わる) |
| Continuous の範囲外 | clamp + 警告 |
| Continuous の step 格子から外れ | 最寄り格子点へ snap + 警告 |
探索空間を書くときは choice の values に現行値を必ず含めること (warm
start が完全になる)。skip された次元は manifest の warm_start.warnings に記録される。
Seed mode と共通乱数の限界¶
--seed-mode fixed
(default) は全 trial を同じ seed で評価する。これにより trial 間のスコア差から「デッキマッチアップの引きの差」が消え、パラメータ差が見えやすくなる。
ただし共通乱数 (CRN) としては部分的である。selfplay の --seed
が固定するのは opponent デッキサンプリング系列のみで、ゲーム内の山札シャッフルは
cg.game.battle_start(deck0, deck1) → lib.BattleStart
に seed 引数がなく、libcg.so 内部の RNG で決まる (seed 系のエクスポートシンボルも無い)。つまり同一 seed でも初手やサイド落ちは trial ごとに変わる。分散削減の本命は seed 固定ではなく
確認ラン (--confirm-top-k) と games 数である。
注意: worker ごとのデッキサンプリング RNG は seed + worker_index で派生するため、trial 間で
--selfplay-workers を固定しないとマッチアップ系列も固定されない。
自己対戦による評価¶
Optuna stage と組で使う本命 evaluator。仕組み:
1. workdir/<agent_id>/params.yaml に base + overrides を書き出す
2. rule_agents.yaml を tuning target + opponents だけに絞って workdir に配置
3. subprocess で pca.training.selfplay を呼ぶ
env["PCA_PARAMS_OVERRIDE_DIR"] = workdir
4. 生成された agent-summary.csv / agent-matchup-summary.csv をパース
5. EvaluationResult に詰めて返す
PCA_PARAMS_OVERRIDE_DIR の仕組み: pca.rule_agents.config.load_params_alongside
が起動時にこの環境変数を尊重し、<override_dir>/<agent_id>/params.yaml
が存在すればそれを優先する (config.py)。これにより
agent の main.py も PortedRuleAgent も既存コードのままで、tuning ランナーから params を差し替えられる。
Trial JSONL 形式¶
{
"trial_number": 3,
"state": "COMPLETE",
"overrides": {
"supporter_tier.critical": 22000,
"attach_energy.main_attacker_type_match_bonus": 5000
},
"win_rate": 0.548,
"unfinished_rate": 0.05,
"mean_prize_diff": 0.6,
"deck_out_loss_rate": 0.02,
"per_opponent": { "opp1": 0.6, "opp2": 0.55, "opp3": 0.49 },
"objective": 0.523,
"duration_sec": 320.5,
"eval_seed": 0,
"warm_start": false
}
state は COMPLETE / PRUNED / (subprocess 失敗時は現状 stage 側で例外 → 未書き込み)。
warm_start: true の行が baseline (trial 0)。--parallel > 1
では完了順に追記されるため trial_number は昇順とは限らない。
MedianPruner の挙動¶
MedianPruner の仕組み:
- Startup trials (
n_startup_trials=10) は無条件で完走。分布を作る母集団 - Warmup steps (
n_warmup_steps=1) は各 trial 内で最初の N ステップ prune 対象外 - その後、各 step で「これまでの trial の同 step の値の median」と比較。下回れば prune
per_opponent を step とみなす。opponent 3 種類なら trial 内で最大 3 回 report、opponent
2 個目で median 未満なら 3 個目を待たず打ち切り。
制約: per_opponent が空の場合 (matchup CSV が生成されなかった等) は intermediate
reporting が発生せず、MedianPruner は事実上無効化される (TrialState.COMPLETE のみで最終値を比較)。
設計判断¶
なぜ in-memory study か¶
SQLite 永続化 (optuna.create_study(storage="sqlite://...")) を使えば中断から再開できる。しかし本プロジェクトの用途では:
- 中断からの再開頻度は低い (1 セッション 数時間で完走想定)
- SQLite ファイルを乱発するとリポジトリ状態がうるさい
- Trial history 分析は
trials.jsonlで十分カバー可能
そこで in-memory (default) + trials.jsonl での逐次追記という組み合わせにした。中断した場合、完走した trial の記録は JSONL に残るため事後分析可能。
なぜ intermediate reporting を per-opponent 単位にするか¶
自然な単位。1 対戦相手を評価し終えた時点で "そこそこ弱いなら以降の相手は無駄" と判定できる。ゲーム単位まで細分化すると overhead が増え、trial ごとのオーバーヘッドが計算量を圧迫する。
なぜ n_jobs > 1 を推奨しないか¶
Optuna の n_jobs > 1 は同一プロセス内で複数 trial を並列実行するが、 SelfplayEvaluator は 1
trial あたり subprocess を 1 個起動する。両方を並列にすると CPU コアの取り合いになり、各 subprocess が遅くなる。当面
n_jobs=1 にし、subprocess レベルで並列を稼ぐ運用 (--parallel 8
は将来の grid 並列用に予約) を推奨する。
テスト¶
tests/rule_agents/test_tuning_evaluator.py— StubEvaluator / SelfplayEvaluator (subprocess mock) / summary CSV parsertests/rule_agents/test_tuning_override_dir.py— override dir 書き出し / cleanuptests/rule_agents/test_tuning_optuna.py—space_to_trial/run_optunaE2E (skipUnless(optuna_available))tests/rule_agents/test_config.py—PCA_PARAMS_OVERRIDE_DIR優先ロード
既知の制約¶
n_jobs=1固定推奨: 上記の通り subprocess 並列と衝突するため。Optuna study の外側で config 単位に並列を張る場合は今後 grid 側で multiprocessing.Pool を追加する予定- MedianPruner 依存の per_opponent: matchup CSV が生成されない場合 (自己対戦 100% など極端なケース) は pruner が事実上無効
- Trial 中の subprocess 失敗: 現在は例外を送出して trial 全体を止める。将来的には
TrialState.FAILとして計上して他 trial を継続する挙動が望ましい - Study の再開 unsupported: in-memory のため中断 → 再開はできない。trials.jsonl から history を復元して手動で続きから grid に持ち込むワークアラウンドは可能
- CPU 予算での n_trials 上限: 200 games × 数秒 = 15〜30 分/trial。50 trials で 12〜25 時間。バッチ実行の設計は future work
- seed 固定 ≠ 完全 CRN: ゲーム内シャッフルは libcg.so 内部 RNG で seed 不可 (上述)。trial 間の分散は games 数と確認ランで抑える
今後の拡張¶
- games 数の multi-fidelity 化: 30 → 100 → 200
games と段階評価し、Wilson 区間で見込みのない config を途中打ち切り (
trial.reportを games 段階に載せ替える。Evaluator protocol の拡張が必要) - Hyperband / BOHB: MedianPruner の代わりに
optuna.pruners.HyperbandPrunerを使い、min/max budget を段階的にスケールアップ - CMA-ES sampler:
optuna.samplers.CmaEsSamplerに差し替え可能 (連続次元だけの空間になったら TPE より収束が速い) - Study 永続化 (optional):
--storage sqlite://<path>を追加、中断復帰 + optuna-dashboard 可視化 n_jobs > 1の許可: subprocess を coroutine 化して I/O 待ちを隠す- 多目的化 (NSGA-II): win_rate と unfinished_rate の Pareto front
変更履歴¶
- 2026-07-09: baseline warm-start (
enqueue_trial+ サニタイズ)、--seed-mode fixed既定化、--selfplay-workers、trials.jsonl に eval_seed / warm_start キー追加。CRN の限界 (libcg シャッフル非 seed) を明記。 - 2026-07-05: 初版。
run_optuna/space_to_trial/ trials.jsonl。TPESampler + MedianPruner。