コンテンツにスキップ

Rule Agent Tuning — Optuna Stage

作成日: 2026-07-05

Rule-based agent の params.yamlOptuna (TPE + MedianPruner) で探索する実装。Grid stage の兄弟として pca.rule_agents.tuning に組み込まれている。

対応実装:

前提: rule-agent-tuning.md の全体設計と目的関数、Aggregator の重み付けを前提としている。

併読推奨:

なぜ 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_* に変換する:

  • Choicetrial.suggest_categorical(path, values)
  • Continuous bounds+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_trialevaluator.evaluateaggregator.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
}

stateCOMPLETE / PRUNED / (subprocess 失敗時は現状 stage 側で例外 → 未書き込み)。 warm_start: true の行が baseline (trial 0)。--parallel > 1 では完了順に追記されるため trial_number は昇順とは限らない。

MedianPruner の挙動

MedianPruner の仕組み:

  1. Startup trials (n_startup_trials=10) は無条件で完走。分布を作る母集団
  2. Warmup steps (n_warmup_steps=1) は各 trial 内で最初の N ステップ prune 対象外
  3. その後、各 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 並列用に予約) を推奨する。

テスト

既知の制約

  1. n_jobs=1 固定推奨: 上記の通り subprocess 並列と衝突するため。Optuna study の外側で config 単位に並列を張る場合は今後 grid 側で multiprocessing.Pool を追加する予定
  2. MedianPruner 依存の per_opponent: matchup CSV が生成されない場合 (自己対戦 100% など極端なケース) は pruner が事実上無効
  3. Trial 中の subprocess 失敗: 現在は例外を送出して trial 全体を止める。将来的には TrialState.FAIL として計上して他 trial を継続する挙動が望ましい
  4. Study の再開 unsupported: in-memory のため中断 → 再開はできない。trials.jsonl から history を復元して手動で続きから grid に持ち込むワークアラウンドは可能
  5. CPU 予算での n_trials 上限: 200 games × 数秒 = 15〜30 分/trial。50 trials で 12〜25 時間。バッチ実行の設計は future work
  6. 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。