feat(q3): 消融基准(14场景-19.5%)+Optuna调参脚本+文档;run 默认策略切 bayes

This commit is contained in:
Horldsence
2026-09-11 09:11:21 +08:00
parent e0665c73fb
commit 9c9ab451e5
52 changed files with 6613 additions and 11 deletions
+145
View File
@@ -0,0 +1,145 @@
"""BayesLoop 与 SevenBatch 的配对消融(离线,自建模拟器)。
同一场景、同一误差场下配对比较两种策略,输出逐场景行与聚合统计:
all_cleared / correct_absence / anomalies / 虚拟时间 / 检测数 / 距离 /
实机计算墙钟。基准配置 persistent_search=False(比较"首次证明找全"的
虚拟时间口径;persistent 复扫到限时的行为与策略质量无关)。
Run: uv run python scripts/benchmark_q3_bayes.py --seeds 0,1,2,3,4,5
"""
from __future__ import annotations
import argparse
from dataclasses import asdict, replace
import hashlib
import json
import math
from pathlib import Path
from statistics import mean, median
from time import perf_counter
from mcm26b.config import ProblemConfig
from mcm26b.planner.bayes_loop import BayesLoop
from mcm26b.planner.seven import SevenBatch
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]
VARIANTS = {"seven_batch": SevenBatch, "bayes": BayesLoop}
def run_case(name, sources, error_seed, config, out):
scene_path = out / "scenes" / f"{name}.json"
scene_path.parent.mkdir(parents=True, exist_ok=True)
scene_path.write_text(json.dumps(
{"sources": [asdict(s) for s in sources], "error_seed": error_seed},
indent=2), encoding="utf-8")
rows = []
for label, cls in VARIANTS.items():
sim = LocalSimulator(sources, robot_id="Q3-BAYES", config=config,
error_seed=error_seed)
client = SimulatorClient("Q3-BAYES", config=config,
transport=LocalSimTransport(sim))
loop = cls(client, config)
start = perf_counter()
summary = loop.run()
elapsed = perf_counter() - start
actions = client.log.action_events()
log_path = out / "actions" / f"{name}-{label}.jsonl"
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("w", encoding="utf-8") as handle:
for event in actions:
handle.write(json.dumps(event, ensure_ascii=False) + "\n")
truth = {s.channel for s in sources}
absent = set(range(1, 21)) - truth
# 缺席证明有效性:PF 缺席以幸存集为空为据,无源频道缺席即为正确
correct_absence = set(summary["absent_channels"]) <= absent
accounted = summary.get("accounted_virtual_time_s") or summary["virtual_time_s"]
row = {"case": name, "variant": label, "source_count": len(sources),
"all_cleared": sim.cleared == truth, "completed": summary["completed"],
"correct_absence": correct_absence,
"normal_exit": summary["exit_reason"] == "user_exit",
"virtual_time_s": summary["virtual_time_s"],
"accounted_virtual_time_s": accounted,
"measures": summary["measures"], "clears": summary["clears"],
"failed_clears": summary["clears"] - len(sim.cleared),
"distance_m": summary["move_distance_m"],
"skipped_measures": summary.get("skipped_measures", 0),
"no_shrink_measures": summary.get("no_shrink_measures", 0),
"compute_wall_s": elapsed, "anomalies": summary["anomalies"],
"stop_reason": summary["stop_reason"],
"actions_sha256": hashlib.sha256(log_path.read_bytes()).hexdigest()}
rows.append(row)
print(f"{name} {label}: cleared={row['all_cleared']} "
f"T={row['virtual_time_s']:.0f} M={row['measures']} "
f"D={row['distance_m']:.0f} wall={elapsed:.1f}s", flush=True)
return rows
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--seeds", default="0,1,2,3,4,5")
parser.add_argument("--counts", default="10,16")
parser.add_argument("--out", type=Path, default=ROOT / "output/q3/bayes-ablation")
parser.add_argument("--bearing-margin-deg", type=float, default=0.005)
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
base = ProblemConfig()
config = replace(base,
search=replace(base.search,
bearing_rounding_margin_deg=args.bearing_margin_deg,
persistent_search=False))
rows = []
seeds = [int(s) for s in args.seeds.split(",")]
counts = [int(c) for c in args.counts.split(",")]
for count in counts:
for seed in seeds:
rows.extend(run_case(f"random-{count}-seed{seed}",
random_omni_scene(seed, count, config=config),
20260910 + seed, config, args.out))
# 圆域边界源(r_eff=1000,扇区接缝处):最刁钻的发现与定位场景
edge = [Source(c + 1, 1800 * math.cos(math.pi / 6 + 2 * math.pi * c / count),
1800 * math.sin(math.pi / 6 + 2 * math.pi * c / count), 1000)
for c in range(count)]
rows.extend(run_case(f"boundary-{count}", edge, 20260910, config, args.out))
aggregates = {}
for label in VARIANTS:
part = [r for r in rows if r["variant"] == label]
baseline = {r["case"]: r for r in rows if r["variant"] == "seven_batch"}
savings = [1 - r["virtual_time_s"] / baseline[r["case"]]["virtual_time_s"]
for r in part]
valid = sum(r["all_cleared"] and r["completed"] and r["correct_absence"]
and r["normal_exit"] and not r["anomalies"] for r in part)
aggregates[label] = {
"runs": len(part), "all_cleared_runs": sum(r["all_cleared"] for r in part),
"valid_runs": valid,
"mean_virtual_s": mean(r["virtual_time_s"] for r in part),
"median_virtual_s": median(r["virtual_time_s"] for r in part),
"max_virtual_s": max(r["virtual_time_s"] for r in part),
"mean_measures": mean(r["measures"] for r in part),
"mean_distance_m": mean(r["distance_m"] for r in part),
"mean_skipped_measures": mean(r["skipped_measures"] for r in part),
"mean_compute_wall_s": mean(r["compute_wall_s"] for r in part),
"mean_paired_time_reduction": mean(savings),
"min_paired_time_reduction": min(savings),
"max_paired_time_reduction": max(savings),
"failed_clears": sum(r["failed_clears"] for r in part),
}
result = {"source": "local_simulator_only", "seeds": args.seeds,
"counts": args.counts, "config": asdict(config),
"aggregates": aggregates, "runs": rows,
"limitations": "自建模拟器合成数据;persistent 关闭以对齐找全口径。"}
(args.out / "benchmark.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(aggregates, ensure_ascii=False, indent=2))
ok = all(a["valid_runs"] == a["runs"] for a in aggregates.values()) \
and aggregates["bayes"]["mean_paired_time_reduction"] > 0
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
+135
View File
@@ -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())