feat(q3): 消融基准(14场景-19.5%)+Optuna调参脚本+文档;run 默认策略切 bayes
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""BayesLoop 的 Optuna 离线调参(TPE),无 Optuna 时退化为随机搜索。
|
||||
|
||||
目标:场景子集的平均虚拟时间(秒)+ 10^6 × 失败惩罚(未全清/未完成/
|
||||
异常/缺席误判任一即失败)。场景与误差种子固定但多样,防止对单一
|
||||
随机种子过拟合;最优参数写入 JSON,人工复跑 benchmark_q3_bayes.py
|
||||
确认后再回填 config.BayesConfig 默认值。
|
||||
|
||||
Run: uv run python scripts/tune_q3_bayes.py --n-trials 100
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from random import Random
|
||||
from statistics import mean
|
||||
from time import perf_counter
|
||||
|
||||
from mcm26b.config import ProblemConfig
|
||||
from mcm26b.planner.bayes_loop import BayesLoop
|
||||
from mcm26b.protocol.client import SimulatorClient
|
||||
from mcm26b.sim import LocalSimulator, LocalSimTransport, Source
|
||||
from mcm26b.sim.scenes import random_omni_scene
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FAIL_PENALTY = 1.0e6
|
||||
|
||||
BASE = replace(ProblemConfig(),
|
||||
search=replace(ProblemConfig().search, persistent_search=False))
|
||||
|
||||
# 调参场景:多种子 × 两档源数 × 边界场景(误差种子与场景种子解耦)
|
||||
SCENES = [(f"tune-{n}-{s}", random_omni_scene(s, n, config=BASE), 20260910 + s)
|
||||
for n in (10, 16) for s in (0, 1, 2)]
|
||||
SCENES.append(("tune-boundary-10",
|
||||
[Source(c + 1, 1800 * math.cos(math.pi / 6 + 2 * math.pi * c / 10),
|
||||
1800 * math.sin(math.pi / 6 + 2 * math.pi * c / 10), 1000)
|
||||
for c in range(10)], 20260977))
|
||||
|
||||
|
||||
def evaluate(bayes_params: dict) -> tuple[float, dict]:
|
||||
config = replace(BASE, bayes=replace(BASE.bayes, **bayes_params))
|
||||
times = []
|
||||
failures = 0
|
||||
for name, sources, error_seed in SCENES:
|
||||
sim = LocalSimulator(sources, robot_id="TUNE", config=config,
|
||||
error_seed=error_seed)
|
||||
client = SimulatorClient("TUNE", config=config,
|
||||
transport=LocalSimTransport(sim))
|
||||
summary = BayesLoop(client, config).run()
|
||||
truth = {s.channel for s in sources}
|
||||
ok = (sorted(summary["cleared_channels"]) == sorted(truth)
|
||||
and summary["completed"]
|
||||
and not summary["anomalies"]
|
||||
and set(summary["absent_channels"]) <= set(range(1, 21)) - truth)
|
||||
if not ok:
|
||||
failures += 1
|
||||
continue
|
||||
times.append(summary["virtual_time_s"])
|
||||
objective = mean(times) if times else FAIL_PENALTY
|
||||
return objective + FAIL_PENALTY * failures, {
|
||||
"failures": failures, "mean_virtual_s": objective, "times": times}
|
||||
|
||||
|
||||
def suggest(trial, rng: Random):
|
||||
"""搜索空间:Optuna trial 或(退化时)rng 随机采样,二者同构。"""
|
||||
if trial is not None:
|
||||
return {
|
||||
"lambda_move_s": trial.suggest_float("lambda_move_s", 0.01, 0.6, log=True),
|
||||
"mc_representatives": trial.suggest_int("mc_representatives", 3, 12),
|
||||
"score_subsample": trial.suggest_categorical("score_subsample",
|
||||
[300, 500, 800, 1200]),
|
||||
"particle_spacing_m": trial.suggest_float("particle_spacing_m", 12.0, 40.0),
|
||||
"station_ring_radius_m": trial.suggest_float("station_ring_radius_m",
|
||||
1100.0, 1450.0),
|
||||
"two_opt_passes": trial.suggest_int("two_opt_passes", 1, 6),
|
||||
"score_mode": trial.suggest_categorical("score_mode",
|
||||
["radius", "entropy"]),
|
||||
}
|
||||
return {
|
||||
"lambda_move_s": math.exp(rng.uniform(math.log(0.01), math.log(0.6))),
|
||||
"mc_representatives": rng.randint(3, 12),
|
||||
"score_subsample": rng.choice([300, 500, 800, 1200]),
|
||||
"particle_spacing_m": rng.uniform(12.0, 40.0),
|
||||
"station_ring_radius_m": rng.uniform(1100.0, 1450.0),
|
||||
"two_opt_passes": rng.randint(1, 6),
|
||||
"score_mode": rng.choice(["radius", "entropy"]),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--n-trials", type=int, default=60)
|
||||
parser.add_argument("--out", type=Path,
|
||||
default=ROOT / "output/q3/bayes-tuning")
|
||||
args = parser.parse_args()
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
start = perf_counter()
|
||||
|
||||
try:
|
||||
import optuna
|
||||
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
||||
|
||||
def objective(trial):
|
||||
value, _detail = evaluate(suggest(trial, None))
|
||||
return value
|
||||
|
||||
study = optuna.create_study(direction="minimize",
|
||||
sampler=optuna.samplers.TPESampler(seed=7))
|
||||
study.optimize(objective, n_trials=args.n_trials)
|
||||
best_params, best_value = study.best_params, study.best_value
|
||||
sampler = "tpe"
|
||||
except ImportError:
|
||||
rng = Random(7)
|
||||
best_params, best_value, sampler = None, float("inf"), "random_fallback"
|
||||
for _ in range(args.n_trials):
|
||||
params = suggest(None, rng)
|
||||
value, _detail = evaluate(params)
|
||||
if value < best_value:
|
||||
best_params, best_value = params, value
|
||||
|
||||
result = {"sampler": sampler, "n_trials": args.n_trials,
|
||||
"best_value_s": best_value, "best_params": best_params,
|
||||
"scenes": [name for name, _s, _e in SCENES],
|
||||
"wall_s": perf_counter() - start,
|
||||
"note": "回填前先以 benchmark_q3_bayes.py 全量复跑确认。"}
|
||||
(args.out / "best_params.json").write_text(
|
||||
json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user