revert: 移除全部机器学习代码,回退到无 ML 版本

删除 RL/HRL/训练前端及其文档、脚本、测试与依赖:
- src/mcm26b/{rl,hrl,training_ui}/、planner/{rl,hrl}_loop.py、process_control.py
- project/(PPO 策略、训练、环境、评估)
- scripts/{train_rl,train_hrl_*,benchmark_rl,benchmark_hrl,pretrain_*_expert 等}
- tests/{test_rl*,test_hrl*,test_training_ui*}、tests/js/training_ui_harness.cjs
- docs/{algo-rl,hrl-*,training-ui*} 与相关计划条目、论文 PDF 回退

CLI 恢复为 grid/seven/bayes + directional,移除 --strategy rl|hrl、
--q4-strategy、--rl-model、--hrl-* 等参数;pyproject 删除 rl 依赖组与
PyTorch CUDA index,uv.lock 同步重生成。

保留此后的非 ML 改动(referee 回放、Q1/Q3 脚本、代码清理等)。
验证:pytest 232 passed;Q3/Q4 --self-test 全清通过。
This commit is contained in:
Horldsence
2026-09-12 08:47:51 +08:00
parent d975309a81
commit 61fd22598a
84 changed files with 9 additions and 13159 deletions
-25
View File
@@ -1,25 +0,0 @@
"""HRL 消融入口(P2 待实现)。
预定义消融维度:时间目标 vs prompt 塑形、信息增益奖励系数、动作掩码取消范围、
课程学习、粒子统计特征。硬性协议守卫/证书/预算永远保留,不在消融中取消。
"""
from __future__ import annotations
import argparse
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--variants", default="time,prompt,no_info,no_mask,no_curriculum")
parser.add_argument("--manifest", default="output/hrl/manifests/final.json")
parser.add_argument("--output", default="output/hrl/p2-ablation")
parser.add_argument("--timesteps", type=int, default=2048)
parser.add_argument("--seeds", type=int, default=5)
parser.parse_args(argv)
raise NotImplementedError(
"P2 尚未实现:必须先完成专家 BC 与冻结场景对照,"
"再对相同训练步数/场景/种子逐项消融;P1 只保证 benchmark/training/CLI。")
if __name__ == "__main__":
raise SystemExit(main())
-428
View File
@@ -1,428 +0,0 @@
"""HRL/基线统一评测入口(P1)。
特点:
- 同一 manifest 中的真值场景被所有 variant 复用,保证配对比较;
- 真值仅在测试端用于构造 ``LocalSimulator`` 与最终核对,策略端只收到协议响应;
- 支持 ``hrl_heuristic``(高层启发式降级)、``bayes``、``directional``;
- 支持已训练高层模型 ``hrl``(需 ``--hrl-model`` 或 manifest 中的 variant 配置)。
输出目录(默认拒绝覆盖已有结果):
<output>/manifest.json
<output>/runs/<scene_id>/<variant>/summary.json
<output>/runs/<scene_id>/<variant>/session.jsonl
<output>/results.json
<output>/aggregate.json
<output>/aggregate.csv
示例:
python scripts/benchmark_hrl.py \
--variants hrl_heuristic,bayes,directional \
--manifest output/hrl/manifests/dev.json \
--output output/hrl/p1-heuristic
"""
from __future__ import annotations
import argparse
import csv
import json
import statistics
import sys
import time
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from mcm26b.config import ProblemConfig
from mcm26b.harness import LocalSimTransport, mixed_scene, random_omni_scene
from mcm26b.harness.scenes import referee_spec
from mcm26b.protocol import SimulatorClient
from mcm26b.protocol.log import SessionLog
try: # 测试端依赖;运行时代码不导入
from referee import LocalSimulator, Source
except ImportError as exc: # pragma: no cover
raise SystemExit("benchmark_hrl 需要 dev 依赖 referee。请先 `uv sync`。") from exc
# ---------------------------------------------------------------------------
# manifest / scene
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SceneSpec:
scene_id: str
seed: int
error_seed: int
problem: int
sources: list[dict[str, Any]]
@property
def truth_channels(self) -> tuple[int, ...]:
return tuple(sorted(int(src["channel"]) for src in self.sources))
def _source_dict(source: Source) -> dict[str, Any]:
return {
"channel": int(source.channel),
"x": float(source.x),
"y": float(source.y),
"r_eff_m": float(source.r_eff_m),
"directional": bool(source.directional),
"heading_deg": float(source.heading_deg),
}
def _sources_to_dicts(sources: list[Source]) -> list[dict[str, Any]]:
return [_source_dict(source) for source in sources]
def _source_from_dict(data: dict[str, Any]) -> Source:
return Source(
channel=int(data["channel"]),
x=float(data["x"]),
y=float(data["y"]),
r_eff_m=float(data["r_eff_m"]),
directional=bool(data.get("directional", False)),
heading_deg=float(data.get("heading_deg", 0.0)),
)
def make_default_manifest(*, problem: int, scenes: int, sources: int | None,
directional: int, start_seed: int,
config: ProblemConfig) -> dict[str, Any]:
"""按 CLI 参数生成可复现的固定场景清单。"""
entries: list[dict[str, Any]] = []
for index in range(scenes):
seed = start_seed + index
if problem == 4:
scene_sources = mixed_scene(seed=seed, count=sources,
directional=directional, config=config)
else:
scene_sources = random_omni_scene(seed=seed, count=sources,
config=config)
entries.append({
"scene_id": f"scene-{seed}",
"seed": seed,
"error_seed": seed + 10_000,
"problem": problem,
"sources": _sources_to_dicts(scene_sources),
})
return {"version": 1, "problem": problem, "scenes": entries}
def load_or_generate_manifest(path: Path, *, problem: int, scenes: int,
sources: int | None, directional: int,
start_seed: int,
config: ProblemConfig) -> dict[str, Any]:
if path.exists():
manifest = json.loads(path.read_text(encoding="utf-8"))
else:
manifest = make_default_manifest(
problem=problem, scenes=scenes, sources=sources,
directional=directional, start_seed=start_seed, config=config)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8")
if "scenes" not in manifest or not manifest["scenes"]:
raise ValueError(f"manifest 无 scenes: {path}")
for raw in manifest["scenes"]:
if not {"scene_id", "error_seed", "problem", "sources"} <= set(raw):
raise ValueError(f"manifest scene 字段缺失: {raw!r}")
if not raw["sources"]:
raise ValueError(f"scene {raw.get('scene_id')} 没有来源真值")
return manifest
def scenes_from_manifest(manifest: dict[str, Any]) -> list[SceneSpec]:
result: list[SceneSpec] = []
for raw in manifest["scenes"]:
result.append(SceneSpec(
scene_id=str(raw["scene_id"]),
seed=int(raw.get("seed", 0)),
error_seed=int(raw["error_seed"]),
problem=int(raw["problem"]),
sources=[dict(item) for item in raw["sources"]],
))
return result
# ---------------------------------------------------------------------------
# variants
# ---------------------------------------------------------------------------
def _build_loop(variant: str, client: SimulatorClient, config: ProblemConfig,
problem: int, *, hrl_model: str | None,
hrl_device: str = "auto"):
"""延迟导入变体实现,避免 benchmark 顶层拉起重依赖。"""
if variant == "bayes":
from mcm26b.planner.bayes_loop import BayesLoop
return BayesLoop(client, config)
if variant == "directional":
from mcm26b.planner import DirectionalExtension
return DirectionalExtension(client, config)
if variant == "hrl_heuristic":
from mcm26b.planner.hrl_loop import HRLLoop
return HRLLoop(client, config, problem=problem, high_policy=None)
if variant == "hrl":
if not hrl_model:
raise RuntimeError("variant=hrl 需要 --hrl-model")
from mcm26b.hrl.policy import HighLevelPolicy
from mcm26b.planner.hrl_loop import HRLLoop
policy = HighLevelPolicy(hrl_model, device=hrl_device, problem=problem)
return HRLLoop(client, config, problem=problem, high_policy=policy)
raise ValueError(f"未知 variant: {variant!r}")
def _validity(summary: dict[str, Any], truth_channels: tuple[int, ...]) -> dict[str, Any]:
"""测试端有效性审计。真值只在此处进入。"""
cleared = set(int(c) for c in summary.get("cleared_channels", []))
absent = set(int(c) for c in summary.get("absent_channels", []))
truth = set(truth_channels)
declared_absent = truth & absent
cleared_matches_truth = cleared == truth
no_source_declared_absent = not declared_absent
completed = bool(summary.get("completed"))
anomalies = list(summary.get("anomalies", []))
# 本地 benchmark 要求:清洗真值且无守恒性异常;缺席集合不得含真值。
valid = completed and cleared_matches_truth and no_source_declared_absent \
and not anomalies
return {
"valid": valid,
"completed": completed,
"cleared_matches_truth": cleared_matches_truth,
"no_source_declared_absent": no_source_declared_absent,
"truth_channels": sorted(truth),
"declared_absent_truth": sorted(declared_absent),
"anomaly_count": len(anomalies),
}
def _row_from_summary(*, scene: SceneSpec, variant: str, summary: dict[str, Any],
wall_s: float) -> dict[str, Any]:
validity = _validity(summary, scene.truth_channels)
cleared_count = len(summary.get("cleared_channels", []))
virtual_s = float(summary.get("virtual_time_s") or 0.0)
avg_s = summary.get("avg_locate_clear_time_s")
totals = summary.get("step_totals", {}) or {}
return {
"scene_id": scene.scene_id,
"variant": variant,
"problem": scene.problem,
"truth_count": len(scene.truth_channels),
"cleared_count": cleared_count,
"virtual_time_s": virtual_s,
"avg_locate_clear_time_s": (float(avg_s) if avg_s is not None else None),
"accounted_virtual_time_s": summary.get("accounted_virtual_time_s"),
"program_run_time_s": round(wall_s, 6),
"real_elapsed_total_s": totals.get("real_elapsed_total_s"),
"total_requests": summary.get("total_requests"),
"measures": summary.get("measures"),
"clears": summary.get("clears"),
"clear_success": summary.get("clear_success"),
"completed": bool(summary.get("completed")),
"stop_reason": summary.get("stop_reason"),
"exit_reason": summary.get("exit_reason"),
**validity,
}
def _error_row(scene: SceneSpec, variant: str, exc: BaseException) -> dict[str, Any]:
return {
"scene_id": scene.scene_id,
"variant": variant,
"problem": scene.problem,
"truth_count": len(scene.truth_channels),
"cleared_count": 0,
"virtual_time_s": None,
"avg_locate_clear_time_s": None,
"accounted_virtual_time_s": None,
"program_run_time_s": None,
"real_elapsed_total_s": None,
"total_requests": None,
"measures": None,
"clears": None,
"clear_success": None,
"completed": False,
"stop_reason": None,
"exit_reason": None,
"valid": False,
"cleared_matches_truth": False,
"no_source_declared_absent": False,
"truth_channels": sorted(scene.truth_channels),
"declared_absent_truth": [],
"anomaly_count": 0,
"error": f"{type(exc).__name__}: {exc}",
}
def run_scene_variant(scene: SceneSpec, variant: str, *, config: ProblemConfig,
hrl_model: str | None, run_dir: Path,
hrl_device: str = "auto") -> dict[str, Any]:
"""运行一个 scene × variant。返回可序列化结果行与 summary。"""
run_dir.mkdir(parents=True, exist_ok=True)
sources = [_source_from_dict(raw) for raw in scene.sources]
simulator = LocalSimulator(
sources,
robot_id="hrl-benchmark",
spec=referee_spec(config),
error_seed=scene.error_seed,
stress_mode=False,
)
# 真值只用于 LocalSimulator;策略端仅持有 client/loop,不读取 simulator.sources。
log_path = run_dir / "session.jsonl"
log = SessionLog(path=log_path)
client = SimulatorClient("hrl-benchmark", config=config,
transport=LocalSimTransport(simulator), log=log)
loop = _build_loop(variant, client, config, scene.problem,
hrl_model=hrl_model, hrl_device=hrl_device)
started = time.monotonic()
try:
summary = loop.run()
finally:
wall_s = time.monotonic() - started
row = _row_from_summary(scene=scene, variant=variant, summary=summary,
wall_s=wall_s)
# 完整 summary 可能很大;仍写入 runs 目录,供复算与异常审计。
(run_dir / "summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
return {"row": row, "summary": summary}
# ---------------------------------------------------------------------------
# aggregate
# ---------------------------------------------------------------------------
def _aggregate(rows: list[dict[str, Any]]) -> dict[str, Any]:
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
grouped[row["variant"]].append(row)
result: dict[str, Any] = {"variants": {}, "all_valid": True}
for variant, items in sorted(grouped.items()):
virtual = [float(item["virtual_time_s"]) for item in items
if item.get("virtual_time_s") is not None]
per_source = [float(item["avg_locate_clear_time_s"])
for item in items
if item.get("avg_locate_clear_time_s") is not None]
cleared = [int(item["cleared_count"] or 0) for item in items]
program = [float(item["program_run_time_s"]) for item in items
if item.get("program_run_time_s") is not None]
success = [1.0 if item.get("valid") else 0.0 for item in items]
result["variants"][variant] = {
"episodes": len(items),
"valid_episodes": int(sum(success)),
"valid_rate": (sum(success) / len(items)) if items else 0.0,
"success_rate": (sum(success) / len(items)) if items else 0.0,
"cleared_count_mean": statistics.fmean(cleared) if cleared else 0.0,
"cleared_count_std": (statistics.pstdev(cleared)
if len(cleared) > 1 else 0.0),
"virtual_time_mean_s": statistics.fmean(virtual) if virtual else 0.0,
"virtual_time_std_s": (statistics.pstdev(virtual)
if len(virtual) > 1 else 0.0),
"virtual_time_min_s": min(virtual) if virtual else 0.0,
"virtual_time_max_s": max(virtual) if virtual else 0.0,
"avg_locate_clear_time_mean_s": (statistics.fmean(per_source)
if per_source else None),
"program_run_time_mean_s": (statistics.fmean(program)
if program else None),
"total_requests_mean": statistics.fmean(
[int(item.get("total_requests") or 0) for item in items]),
}
if result["variants"][variant]["valid_rate"] < 1.0:
result["all_valid"] = False
return result
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--variants", default="hrl_heuristic,bayes,directional",
help="逗号分隔:hrl_heuristic,bayes,directional,hrl")
parser.add_argument("--manifest", default="output/hrl/manifests/dev.json")
parser.add_argument("--output", default="output/hrl/p1-benchmark")
parser.add_argument("--problem", type=int, choices=(3, 4), default=3)
parser.add_argument("--scenes", type=int, default=6,
help="manifest 不存在时生成的场景数")
parser.add_argument("--sources", type=int, default=None,
help="每局源数;默认按题面 10~16 随机")
parser.add_argument("--directional", type=int, default=3,
help="问题4定向源数量(生成 manifest 时使用)")
parser.add_argument("--start-seed", type=int, default=91_000)
parser.add_argument("--hrl-model", default=None)
parser.add_argument("--hrl-device", choices=("auto", "cpu", "cuda", "mps"),
default="auto")
parser.add_argument("--force", action="store_true",
help="允许覆盖已有输出目录")
parser.add_argument("--log", action="store_true",
help="为兼容 CLI 保留;当前始终写 session.jsonl")
args = parser.parse_args(argv)
config = ProblemConfig()
output = Path(args.output)
if output.exists() and any(output.iterdir()) and not args.force:
print(f"输出目录已存在且非空:{output};如需覆盖请加 --force。",
file=sys.stderr)
return 2
output.mkdir(parents=True, exist_ok=True)
manifest_path = Path(args.manifest)
manifest = load_or_generate_manifest(
manifest_path, problem=args.problem, scenes=args.scenes,
sources=args.sources, directional=args.directional,
start_seed=args.start_seed, config=config)
scenes = scenes_from_manifest(manifest)
variants = [item.strip() for item in args.variants.split(",") if item.strip()]
if not variants:
parser.error("--variants 不能为空")
if "hrl" in variants and not args.hrl_model:
# manifest 中也允许写明模型路径;此处先支持 CLI 显式路径。
print("variant=hrl 需要 --hrl-model(P1 冒烟模型路径)。", file=sys.stderr)
return 2
known = {"hrl_heuristic", "bayes", "directional", "hrl"}
unknown = sorted(set(variants) - known)
if unknown:
parser.error(f"未知 variant: {unknown}")
rows: list[dict[str, Any]] = []
runs_root = output / "runs"
wall_started = time.monotonic()
for scene in scenes:
for variant in variants:
run_dir = runs_root / scene.scene_id / variant
print(json.dumps({"event": "run_start", "scene": scene.scene_id,
"variant": variant}, ensure_ascii=False))
try:
result = run_scene_variant(
scene, variant, config=config, hrl_model=args.hrl_model,
run_dir=run_dir, hrl_device=args.hrl_device)
row = result["row"]
except Exception as exc: # 单场异常也进入聚合,all_valid=false
row = _error_row(scene, variant, exc)
rows.append(row)
print(json.dumps({"event": "run_done", **{
key: row.get(key) for key in
("scene_id", "variant", "cleared_count", "virtual_time_s",
"valid", "program_run_time_s", "error")}}, ensure_ascii=False))
aggregate = _aggregate(rows)
aggregate["wall_seconds"] = round(time.monotonic() - wall_started, 3)
aggregate["manifest"] = str(manifest_path)
aggregate["output"] = str(output)
aggregate["variants_requested"] = variants
aggregate["scenes"] = len(scenes)
(output / "results.json").write_text(
json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
(output / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
(output / "aggregate.json").write_text(
json.dumps(aggregate, ensure_ascii=False, indent=2), encoding="utf-8")
fieldnames = list(rows[0].keys())
for row in rows:
for key in row:
if key not in fieldnames:
fieldnames.append(key)
with (output / "aggregate.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
print(json.dumps(aggregate, ensure_ascii=False, indent=2))
return 0 if aggregate.get("all_valid") else 1
if __name__ == "__main__":
raise SystemExit(main())
-128
View File
@@ -1,128 +0,0 @@
"""问题3/4 RL 策略与既有策略的配对自建消融。
同一种子、同一场景分别跑基线(Q3 bayes / Q4 directional)与 RL 变体,
比较全部清除成功、虚拟总时间、检测次数与请求总数。结果写入
output/rl/benchmark.json。
用法:
uv sync --group rl --group dev
uv run python scripts/benchmark_rl.py --seeds 5
"""
from __future__ import annotations
import argparse
import json
import statistics
import time
from pathlib import Path
from mcm26b.config import ProblemConfig
from mcm26b.evaluation import recompute_virtual_time
from mcm26b.harness import (LocalSimTransport, mixed_scene, random_omni_scene,
referee_spec)
from mcm26b.planner import DirectionalExtension
from mcm26b.planner.bayes_loop import BayesLoop
from mcm26b.planner.rl_loop import RLBayesLoop, RLDirectionalLoop
from mcm26b.protocol.client import SimulatorClient
from referee import LocalSimulator
def run_one(loop_cls, sources, *, config, robot_id: str, **kwargs) -> dict:
simulator = LocalSimulator(sources, robot_id=robot_id,
spec=referee_spec(config, max_real_duration_s=3600.0),
stress_mode=True)
client = SimulatorClient(robot_id, config=config,
transport=LocalSimTransport(simulator))
started = time.monotonic()
summary = loop_cls(client, config, **kwargs).run() \
if kwargs else loop_cls(client, config).run()
events = client.log.action_events()
successful = {e['request']['channel'] for e in events
if e['response'].get('clear_result') == 'success'}
truth = {s.channel for s in sources}
return {
"completed": bool(summary["completed"]),
"all_cleared": successful == truth,
"correct_absence": not bool(set(summary['absent_channels']) & truth),
"anomalies": summary['anomalies'],
"timing_mismatches": len(recompute_virtual_time(events)['mismatches']),
"normal_exit": summary['exit_reason'] == 'user_exit',
"virtual_time_s": float(summary["virtual_time_s"]),
"measures": int(summary["measures"]),
"total_requests": int(summary["total_requests"]),
"move_distance_m": float(summary["move_distance_m"]),
"wall_s": round(time.monotonic() - started, 3),
}
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--seeds", type=int, default=5)
parser.add_argument("--sources", type=int, default=10)
parser.add_argument("--directional", type=int, default=3)
parser.add_argument("--output", default="output/rl/benchmark.json")
args = parser.parse_args(argv)
config = ProblemConfig()
robot_id = "2026001"
report = {"config": {"seeds": args.seeds, "sources": args.sources,
"directional": args.directional},
"q3": [], "q4": []}
for seed in range(20260910, 20260910 + args.seeds):
omni = random_omni_scene(seed=seed, count=args.sources, config=config)
mixed = mixed_scene(seed=seed, count=args.sources,
directional=args.directional, config=config)
q3_base = run_one(BayesLoop, omni, config=config, robot_id=robot_id)
q3_rl = run_one(RLBayesLoop, omni, config=config, robot_id=robot_id)
q4_base = run_one(DirectionalExtension, mixed, config=config,
robot_id=robot_id)
q4_rl = run_one(RLDirectionalLoop, mixed, config=config,
robot_id=robot_id)
report["q3"].append({"seed": seed, "bayes": q3_base, "rl": q3_rl})
report["q4"].append({"seed": seed, "directional": q4_base, "rl": q4_rl})
print(json.dumps({"event": "seed_done", "seed": seed,
"q3_base_s": q3_base["virtual_time_s"],
"q3_rl_s": q3_rl["virtual_time_s"],
"q4_base_s": q4_base["virtual_time_s"],
"q4_rl_s": q4_rl["virtual_time_s"]},
ensure_ascii=False))
for key, baseline in (("q3", "bayes"), ("q4", "directional")):
rows = report[key]
deltas = [row["rl"]["virtual_time_s"] - row[baseline]["virtual_time_s"]
for row in rows]
cleared = all(row["rl"]["all_cleared"] and row[baseline]["all_cleared"]
for row in rows)
valid = all(part['completed'] and part['all_cleared'] and part['correct_absence']
and part['normal_exit'] and not part['anomalies']
and not part['timing_mismatches']
for row in rows for part in (row[baseline],row['rl']))
report[key + "_summary"] = {
"mean_virtual_s": {
baseline: statistics.mean(row[baseline]["virtual_time_s"] for row in rows),
"rl": statistics.mean(row["rl"]["virtual_time_s"] for row in rows),
},
"mean_delta_s": statistics.mean(deltas),
"relative_delta_pct": 100.0 * statistics.mean(deltas)
/ statistics.mean(row[baseline]["virtual_time_s"] for row in rows),
"mean_measures": {
baseline: statistics.mean(row[baseline]["measures"] for row in rows),
"rl": statistics.mean(row["rl"]["measures"] for row in rows),
},
"all_cleared": cleared,
"all_valid": valid,
}
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8")
print(json.dumps({"event": "benchmark_done", "path": str(output),
"q3": report["q3_summary"],
"q4": report["q4_summary"]},
ensure_ascii=False, indent=2))
return 0 if report['q3_summary']['all_valid'] and report['q4_summary']['all_valid'] else 1
if __name__ == "__main__":
raise SystemExit(main())
-125
View File
@@ -1,125 +0,0 @@
"""同场景配对比较手写策略、旧PPO及修订模型;真值仅在评测端使用。"""
import argparse
from dataclasses import asdict
import hashlib
import json
import math
from pathlib import Path
from statistics import mean, median
from time import perf_counter
import torch
from mcm26b.config import ProblemConfig
from mcm26b.evaluation import recompute_virtual_time
from mcm26b.harness import LocalSimTransport, mixed_scene, random_omni_scene, referee_spec
from mcm26b.planner import DirectionalExtension
from mcm26b.planner.bayes_loop import BayesLoop
from mcm26b.planner.rl_loop import RLBayesLoop, RLDirectionalLoop
from mcm26b.protocol.client import SimulatorClient
from referee import LocalSimulator, Source
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--split', choices=('development','validation','stress'),
default='development')
parser.add_argument('--variants', default='baseline,legacy,revised')
parser.add_argument('--problems', default='3,4')
parser.add_argument('--model-dir', type=Path, default=Path('output/rl/review-20260911'))
parser.add_argument('--label', required=True)
args = parser.parse_args()
torch.set_num_threads(1)
config = ProblemConfig()
output = args.model_dir / 'evaluation' / args.split / args.label
output.mkdir(parents=True, exist_ok=False)
variants = args.variants.split(',')
rows = []
def save():
aggregates = {}
for problem in (3,4):
for variant in variants:
part = [r for r in rows if r['problem']==problem and r['variant']==variant]
if part:
aggregates[f'q{problem}-{variant}'] = dict(
runs=len(part), valid=sum(r['valid'] for r in part),
mean_virtual_s=mean(r['virtual_s'] for r in part),
median_virtual_s=median(r['virtual_s'] for r in part),
max_virtual_s=max(r['virtual_s'] for r in part),
failed_clears=sum(r['failed_clears'] for r in part),
repeated_measures=sum(r['repeated_measures'] for r in part))
result = dict(source='local_referee_only', config=asdict(config), split=args.split,
rows=rows, aggregates=aggregates)
(output/'results.json').write_text(
json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
return aggregates
for problem in map(int, args.problems.split(',')):
scenes = []
if args.split == 'stress':
for count in (10,16):
scenes.append((f'boundary-{count}', count, 99000+count, [
Source(c+1,1800*math.cos(2*math.pi*c/count),1800*math.sin(2*math.pi*c/count),
1000,directional=(problem==4 and c%2==0),
heading_deg=360*c/count if problem==4 and c%2==0 else 0)
for c in range(count)]))
else:
seeds = range(700,703) if args.split=='development' else range(91000,91006)
counts = (10,16) if args.split=='development' else (10,13,16)
for count in counts:
for seed in seeds:
directional = (1, count//2, count-1)[seed%3]
sources = (random_omni_scene(seed, count, config) if problem==3 else
mixed_scene(seed, count, directional, config))
scenes.append((f'{count}-{seed}',count,seed,sources))
for name,count,seed,sources in scenes:
scene_data = dict(sources=[asdict(s) for s in sources], error_seed=seed+2000000)
scene_path = output/f'q{problem}-{name}-scene.json'
scene_path.write_text(json.dumps(scene_data),encoding='utf-8')
for variant in variants:
model = None
cls = BayesLoop if problem==3 else DirectionalExtension
kwargs = {}
if variant != 'baseline':
cls = RLBayesLoop if problem==3 else RLDirectionalLoop
model = (Path(f'output/rl/q{problem}_ppo.zip') if variant=='legacy'
else args.model_dir/f'q{problem}_masked.zip')
kwargs = dict(model_path=model, device='cpu')
sim = LocalSimulator(sources,robot_id='RL-AUDIT',spec=referee_spec(config),
error_seed=seed+2000000)
client = SimulatorClient('RL-AUDIT',config=config,transport=LocalSimTransport(sim))
start = perf_counter()
summary = cls(client,config,**kwargs).run()
events = client.log.action_events()
truth = {s.channel for s in sources}
successful = {e['request']['channel'] for e in events
if e['response'].get('clear_result')=='success'}
seen = set()
repeats = 0
for e in events:
if e['path']=='/measure':
p=e['request']['position']
tag=(e['request']['channel'],round(p['x'],6),round(p['y'],6))
repeats += tag in seen
seen.add(tag)
mismatches = recompute_virtual_time(events)['mismatches']
valid = (successful==truth and summary['completed'] and not summary['anomalies']
and not set(summary['absent_channels']) & truth
and summary['exit_reason']=='user_exit' and not mismatches)
row = dict(case=name,problem=problem,variant=variant,valid=valid,
virtual_s=client.state.virtual_time_s,all_cleared=successful==truth,
measures=summary['measures'],
failed_clears=summary['clears']-len(successful),
repeated_measures=repeats,wall_s=perf_counter()-start,
model_sha256=(hashlib.sha256(model.read_bytes()).hexdigest()
if model else None))
rows.append(row)
(output/f'q{problem}-{name}-{variant}.json').write_text(json.dumps(
dict(row=row,summary=summary,events=events,timing_mismatches=mismatches),
ensure_ascii=False),encoding='utf-8')
save()
print(json.dumps(row),flush=True)
print(json.dumps(save(),indent=2),flush=True)
if __name__=='__main__':
main()
-24
View File
@@ -1,24 +0,0 @@
"""高层专家轨迹预训练入口(P2 待实现)。
按 docs/hrl-sol-execution.md,P2 才运行 `hrl_heuristic` 收集
(episode_id, state, candidates, mask, action),再按 episode 划分训练/验证集做 BC。
本文件在 P0+P1 阶段只提供命令行占位,避免把未实现的 BC 说成已交付。
"""
from __future__ import annotations
import argparse
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--problem", type=int, choices=(3, 4), default=3)
parser.add_argument("--episodes", type=int, default=3000)
parser.add_argument("--output", default="output/hrl/bc_high")
parser.parse_args(argv)
raise NotImplementedError(
"P2 尚未实现:专家轨迹收集/BC 需要先通过 P1 smoke;"
"当前请使用 scripts/benchmark_hrl.py --variants hrl_heuristic 做启发式骨架验证。")
if __name__ == "__main__":
raise SystemExit(main())
-255
View File
@@ -1,255 +0,0 @@
"""用自建模拟器的既有专家逻辑做行为克隆,初始化 PPO 策略网络。
思路:自建 referee 的观测模型与真值逻辑既快又可复现。先在它上面用
手写专家(Q3: worst-posterior 选点器;Q4: 遮挡梯子选点器)生成
(观测, 专家动作) 数据集,训练一个与 PPO policy_net 同构的 MLP 分类器,
再把权重拷入 PPO 模型作为初始化;随后 scripts/train_rl.py --init 继续
PPO 微调。这样 RL 从强先验出发,而不是从随机策略开始。
产物:
output/rl/bc/q3_expert.npz output/rl/bc/q3_bc.pt output/rl/q3_ppo_bc_init.zip
output/rl/bc/q4_expert.npz output/rl/bc/q4_bc.pt output/rl/q4_ppo_bc_init.zip
"""
from __future__ import annotations
import argparse
import json
import math
import time
from pathlib import Path
import numpy as np
import torch
from stable_baselines3 import PPO
from mcm26b.config import ProblemConfig
from mcm26b.planner import safe_disk_radius, select_measurement_point
from mcm26b.rl._constants import NUM_ACTIONS, OBS_DIM
from mcm26b.rl.env import LocalizationEnv
from mcm26b.rl.features import candidate_actions
HIDDEN = (128, 128)
def expert_action(env: LocalizationEnv) -> int:
"""当前状态的专家动作编号(与训练环境候选同源,就近映射)。"""
config = env.config
channel = env._channel
region = env._belief.candidate_set(channel)
position = env._client.state.position
blocked = env._blocked
candidates = candidate_actions(region, position, config, blocked)
if env.problem == 3:
expert_point = select_measurement_point(
region, position, config).position
else:
center, radius = region.mec()
safe = safe_disk_radius(region, config)
if not blocked:
expert_point = select_measurement_point(
region, position, config).position
elif len(blocked) == 1 and safe >= 0:
first = blocked[0]
dx, dy = center[0] - first[0], center[1] - first[1]
norm = math.hypot(dx, dy)
if norm <= 1e-9:
expert_point = (center[0] + safe, center[1])
else:
scale = min(1.0, safe / norm)
expert_point = (center[0] + dx * scale,
center[1] + dy * scale)
else:
first = blocked[0]
base = math.atan2(first[1] - center[1], first[0] - center[0])
angle = base + math.radians(45.0 * (len(blocked) - 1))
expert_point = (center[0] + radius * math.cos(angle),
center[1] + radius * math.sin(angle))
return min(range(len(candidates)),
key=lambda k: math.dist(candidates[k], expert_point))
class BCMlp(torch.nn.Module):
def __init__(self):
super().__init__()
self.net = torch.nn.Sequential(
torch.nn.Linear(OBS_DIM, HIDDEN[0]), torch.nn.Tanh(),
torch.nn.Linear(HIDDEN[0], HIDDEN[1]), torch.nn.Tanh(),
torch.nn.Linear(HIDDEN[1], NUM_ACTIONS))
def forward(self, x):
return self.net(x)
def collect(problem: int, episodes: int, seed: int, config: ProblemConfig):
env = LocalizationEnv(
config, problem=problem,
directional_probability=1.0 if problem == 4 else 0.0,
max_episode_steps=16)
observations, actions, groups = [], [], []
for episode in range(episodes):
obs, _info = env.reset(seed=seed + episode)
for _step in range(env.max_episode_steps + 2):
action = expert_action(env)
observations.append(obs)
actions.append(action)
groups.append(episode)
obs, _reward, terminated, truncated, _info = env.step(action)
if terminated or truncated:
break
return np.asarray(observations, dtype=np.float32), \
np.asarray(actions, dtype=np.int64), np.asarray(groups, dtype=np.int64)
def train_bc(x, y, *, epochs: int, batch_size: int, lr: float,
device: str, groups, valid_frac: float = 0.1):
model = BCMlp().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
episodes = np.unique(groups)
if len(episodes) < 2:
raise ValueError('行为克隆至少需要两个独立episode才能划分验证集')
valid_groups = np.random.default_rng(0).permutation(episodes)
valid_groups = valid_groups[:max(1, int(len(episodes)*valid_frac))]
valid = torch.as_tensor(np.isin(groups, valid_groups), device=device)
x_t, y_t = torch.as_tensor(x, device=device), torch.as_tensor(y, device=device)
x_v, y_v = x_t[valid], y_t[valid]
x_tr, y_tr = x_t[~valid], y_t[~valid]
loss_fn = torch.nn.CrossEntropyLoss()
for epoch in range(epochs):
order = torch.randperm(len(x_tr), device=device)
total = correct = 0.0
for start in range(0, len(order), batch_size):
idx = order[start:start + batch_size]
logits = model(x_tr[idx])
loss = loss_fn(logits, y_tr[idx])
optimizer.zero_grad()
loss.backward()
optimizer.step()
total += float(loss.item()) * len(idx)
correct += int((logits.argmax(1) == y_tr[idx]).sum().item())
train_acc = correct / len(x_tr)
with torch.no_grad():
valid_acc = float((model(x_v).argmax(1) == y_v).float().mean().item())
if epoch == 0 or (epoch + 1) % 5 == 0:
print(json.dumps({"event": "bc_epoch", "epoch": epoch + 1,
"loss": round(total / len(x_tr), 4),
"train_acc": round(train_acc, 4),
"valid_acc": round(valid_acc, 4)},
ensure_ascii=False))
return model, float(valid_acc)
def copy_into_ppo(problem: int, model: BCMlp, device: str) -> Path | None:
"""把 BC 网络权重按 SB3 的 policy_net/action_net 拆分拷入 PPO。"""
# 当前奖励/特征已修订,重新初始化critic,不能沿用旧任务的价值预测。
env = LocalizationEnv(problem=problem)
ppo = PPO('MlpPolicy', env, policy_kwargs={'net_arch': list(HIDDEN)},
n_steps=512, batch_size=128, gamma=1.0, device=device)
state = {key.removeprefix("net."): value
for key, value in model.state_dict().items()}
# SB3 MlpPolicy:policy_net 只含前两个 Linear(Tanh 激活),
# 输出层在 action_net。BC 的 Sequential 键为 0/2/4。
policy_state = {key: value for key, value in state.items()
if key in ("0.weight", "0.bias", "2.weight", "2.bias")}
action_state = {
"weight": state["4.weight"], "bias": state["4.bias"]}
ppo.policy.mlp_extractor.policy_net.load_state_dict(policy_state)
ppo.policy.action_net.load_state_dict(action_state)
# 数值检查整个前向映射,而不只是线性层形状。
probe = torch.randn((8, OBS_DIM), device=device)
with torch.no_grad():
copied = ppo.policy.action_net(ppo.policy.mlp_extractor.policy_net(probe))
if not torch.allclose(model(probe), copied, atol=1e-6):
raise RuntimeError('行为克隆与PPO激活函数/前向映射不一致')
init_path = Path(f"output/rl/q{problem}_ppo_bc_init.zip")
ppo.save(str(init_path))
env.close()
init_path.with_suffix('.json').write_text(json.dumps(
dict(algorithm='ppo', feature_version=2, problem=problem)), encoding='utf-8')
return init_path
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--problem", type=int, choices=(3, 4), required=True)
parser.add_argument("--episodes", type=int, default=3000)
parser.add_argument("--epochs", type=int, default=20)
parser.add_argument("--batch-size", type=int, default=256)
parser.add_argument("--lr", type=float, default=3e-4)
parser.add_argument("--seed", type=int, default=20260911)
parser.add_argument("--device", default="auto")
parser.add_argument("--copy-only", action="store_true",
help="跳过采集/训练,只把已有 q<problem>_bc.pt 拷入 PPO 初始化")
args = parser.parse_args(argv)
if args.device == "auto":
device = "cuda" if torch.cuda.is_available() else "cpu"
else:
device = args.device
print(json.dumps({"event": "bc_start", "problem": args.problem,
"episodes": args.episodes, "device": device,
"cuda": torch.cuda.is_available()},
ensure_ascii=False))
if args.copy_only:
bc_dir = Path("output/rl/bc")
checkpoint = torch.load(bc_dir / f"q{args.problem}_bc.pt",
map_location="cpu")
if checkpoint.get('activation') != 'Tanh' or checkpoint.get('feature_version') != 2:
parser.error('旧ReLU/旧特征BC检查点不能直接拷贝,请重新采集并训练')
model = BCMlp()
model.load_state_dict(checkpoint["state_dict"])
init_path = copy_into_ppo(args.problem, model, device)
dataset = np.load(bc_dir / f"q{args.problem}_expert.npz",
allow_pickle=False)
report = {
"problem": args.problem,
"samples": int(len(dataset["obs"])),
"valid_accuracy": checkpoint.get("valid_acc"),
"dataset": str(bc_dir / f"q{args.problem}_expert.npz"),
"bc_model": str(bc_dir / f"q{args.problem}_bc.pt"),
"ppo_init": str(init_path) if init_path is not None else None,
}
(bc_dir / f"q{args.problem}_bc.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"event": "bc_copy", "problem": args.problem,
"ppo_init": str(init_path), **report},
ensure_ascii=False, indent=2))
return 0 if init_path is not None else 2
config = ProblemConfig()
started = time.monotonic()
torch.manual_seed(args.seed)
x, y, groups = collect(args.problem, args.episodes, args.seed, config)
collect_s = time.monotonic() - started
bc_dir = Path("output/rl/bc")
bc_dir.mkdir(parents=True, exist_ok=True)
np.savez_compressed(bc_dir / f"q{args.problem}_expert.npz", obs=x, action=y, episode=groups)
model, valid_acc = train_bc(x, y, epochs=args.epochs,
batch_size=args.batch_size, lr=args.lr,
device=device, groups=groups)
torch.save({"state_dict": model.state_dict(), "obs_dim": OBS_DIM,
"actions": NUM_ACTIONS, "valid_acc": valid_acc,
"activation": "Tanh", "feature_version": 2},
bc_dir / f"q{args.problem}_bc.pt")
# 拷入现有 PPO 的 policy_net/action_net 作为初始化(critic 保持原权重)。
init_path = copy_into_ppo(args.problem, model, device)
report = {
"problem": args.problem, "episodes": args.episodes,
"samples": int(len(x)), "valid_accuracy": valid_acc,
"collect_seconds": round(collect_s, 1),
"train_seconds": round(time.monotonic() - started - collect_s, 1),
"dataset": str(bc_dir / f"q{args.problem}_expert.npz"),
"bc_model": str(bc_dir / f"q{args.problem}_bc.pt"),
"ppo_init": str(init_path) if init_path is not None else None,
}
(bc_dir / f"q{args.problem}_bc.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
-36
View File
@@ -1,36 +0,0 @@
"""从 Tee 输出的 CLI 文本中提取末尾 JSON,落盘为 summary.json。
官方演练目录由 run 命令产生 cli-output.txt(前面可能有 CUDA 警告),
本脚本只提取最后一个完整 JSON 对象,并核对 adjacent 的官方 result.json。
"""
import argparse
import json
from pathlib import Path
def extract(text: str) -> dict:
start = text.rfind("\n{")
if start < 0:
raise ValueError("未找到 JSON 对象")
return json.loads(text[start + 1:])
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("folder", type=Path)
args = parser.parse_args()
folder = args.folder
summary = extract((folder / "cli-output.txt").read_text(encoding="utf-8"))
(folder / "summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({
"folder": str(folder), "completed": summary["completed"],
"virtual_time_s": summary["virtual_time_s"],
"cleared_channels": summary["cleared_channels"],
"total_requests": summary["total_requests"],
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
-78
View File
@@ -1,78 +0,0 @@
"""汇总配对置信区间、历史官方证据与BC/特征错位探针,不连接模拟器。"""
from pathlib import Path
import json
import hashlib
import numpy as np
import torch
import verify_rl_practice
from mcm26b.rl.env import LocalizationEnv
from mcm26b.rl.features import build_observation
def main():
root = Path('output/rl/review-20260911')
records = json.loads((root/'evaluation/validation/final/results.json')
.read_text(encoding='utf-8'))
rng = np.random.default_rng(20260911)
comparisons = {}
for problem in (3,4):
part = [r for r in records['rows'] if r['problem']==problem]
for label in ('baseline','legacy'):
baseline = {r['case']:r for r in part if r['variant']==label}
selected = sorted((r for r in part if r['variant']=='revised'),key=lambda r:r['case'])
a = np.array([baseline[r['case']]['virtual_s'] for r in selected])
b = np.array([r['virtual_s'] for r in selected])
samples = rng.integers(0,len(a),(10000,len(a)))
changes = (b[samples].mean(axis=1)/a[samples].mean(axis=1)-1)*100
comparisons[f'q{problem}-revised-vs-{label}'] = dict(
relative_change_pct=(b.mean()/a.mean()-1)*100,
paired_bootstrap_95_pct=np.quantile(changes,[.025,.975]).tolist(),
faster_cases=int(sum(b<a)),runs=len(a))
# 重新计算原始官方日志的检查,派生报告单独保存,原始归档不写入。
verify_rl_practice.save_json = lambda path,value: None
official = [verify_rl_practice.audit(p.parent) for p in sorted(
Path('output/rl/official-practice').rglob('*.result.json'))]
env = LocalizationEnv(handoff_probability=0,boundary_probability=0)
fixed,_ = env.reset(seed=1)
# 复现旧版把整个BeliefState作为单频道对象传入的错误。
wrong = build_observation(env._belief.candidate_set(env._channel),env._client.state.position,
env._channel,env.config,env.problem,env._blocked,env._belief,
virtual_time_s=env._client.state.virtual_time_s)
features = dict(mismatched_indices=np.flatnonzero(fixed!=wrong).tolist(),
fixed_history_count=float(fixed[7]),old_history_count=float(wrong[7]))
env.close()
torch.set_num_threads(1)
torch.manual_seed(0)
bc = {}
for problem in (3,4):
# 使用旧检查点和原始专家输入,比对原ReLU网络与拷贝后的Tanh网络。
checkpoint = torch.load(f'output/rl/bc/q{problem}_bc.pt',map_location='cpu',
weights_only=True)
data = np.load(f'output/rl/bc/q{problem}_expert.npz',allow_pickle=False)
x = torch.from_numpy(data['obs'][::max(1,len(data['obs'])//1000)].copy())
def network(activation, checkpoint=checkpoint):
net = torch.nn.Sequential(torch.nn.Linear(401,128),activation(),
torch.nn.Linear(128,128),activation(),torch.nn.Linear(128,60))
state = {k.removeprefix('net.'):v for k,v in checkpoint['state_dict'].items()}
net.load_state_dict(state)
return net
with torch.no_grad():
before,after=network(torch.nn.ReLU)(x),network(torch.nn.Tanh)(x)
bc[f'q{problem}'] = dict(samples=len(x),
different_argmax_fraction=float((before.argmax(1)!=after.argmax(1)).float().mean()),
max_logit_difference=float((before-after).abs().max()))
report = dict(comparisons=comparisons,official=official,feature_probe=features,bc_probe=bc,
limitations='置信区间仅重采样固定模型的场景,不覆盖训练随机种子不确定性。',
code_sha256={str(p):hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(Path('src/mcm26b/rl').glob('*.py'))})
(root/'review_evidence.json').write_text(
json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps(dict(comparisons=comparisons,feature_probe=features,bc_probe=bc,
official=[dict(case=r['case_code'],passed=r['passed'])
for r in official]),indent=2))
if __name__=='__main__':
main()
-987
View File
@@ -1,987 +0,0 @@
"""高层 MaskablePPO 训练(P1)。
架构与边界:
- 环境 = :class:`mcm26b.hrl.hierarchical_env.HierarchicalEnv`,每一步是一个高层
宏动作(选择 0–119 槽位:clear/locate/station/fallback/保留);
- 训练/部署共用 :mod:`mcm26b.hrl.scheduler` 的候选、观测和动作掩码;
- 低层默认使用几何选点;``--local-selector rl`` 仅作为接口预留并记录,
当前 HTTP 执行路径仍由高层宏动作终点决定,不把八方向推演当正式收益。
落盘保证(长训必需):
- **急停可恢复**:``--recovery-every``(默认 2000 环境步)把一份滚动恢复点
``<模型目录>/checkpoints/<stem>_latest.zip`` 原子覆盖写;训练开始前先写一份
起步基线。因此即使进程被 SIGKILL / ``taskkill /F`` 强杀(没有任何 Python
收尾机会),也**最多只丢 ``--recovery-every`` 个环境步**,且磁盘上始终有一份
完整、可加载、可 ``--resume`` 的产物;
- **原子落盘**:模型 zip、旁挂 json 与 ``<run_dir>/resume.json`` 都是"临时文件 +
``os.replace``",保存途中被杀不会留下半截文件,也不会破坏上一份完整快照;
- ``--checkpoint-every`` 每 N 个环境步在 ``<模型目录>/checkpoints/`` 另留一份历史
checkpoint(``<stem>_<步数>.zip``),只保留最近 ``--checkpoint-keep`` 个,供
返祖对比与多档恢复;
- ``<run_dir>/resume.json`` 是恢复的**权威指针**:无论上一刻是滚动恢复点、历史
checkpoint、优雅收尾还是异常兜底,它总指向一份可续训产物,并记录增量语义
(``timesteps`` 是追加步数)与生效过的 PPO 超参;
- ``--control-file`` 是训练前端的**跨平台**控制通道:前端原子写 JSON
(``paused`` / ``stop``),本脚本每个 step 轮询。暂停时训练在 step 边界挂起,
停止时先保存模型与元数据再退出;
- 信号(SIGTERM/SIGINT,Windows 上另有 SIGBREAK)仍可用:直接 kill 本脚本或
前端发进程组信号时同样走优雅收尾;subproc worker 在 :func:`_make_env` 里忽略
这些信号,保证它们活到主进程 ``vec_env.close()`` 之后再退出;
- 任何异常都会先落一份 ``*_emergency.zip`` + ``resume.json`` 再抛出,不再出现
"跑了很久什么都没有"。
为什么暂停/停止不靠信号:Windows 没有 SIGSTOP,且 ``os.kill(pid, SIGTERM)`` 会
退化成 ``TerminateProcess`` 硬杀——收尾分支根本不会执行。控制文件两端都只是
普通文件 IO,POSIX 与 Windows 行为一致(见 :mod:`mcm26b.process_control`)。
P1 smoke 示例:
python scripts/train_hrl_high.py \
--problem 3 --stage 1 --timesteps 2048 --seed 320001 \
--envs 1 --vec-env dummy --device mps \
--config-profile production --model output/hrl/p1-smoke/q3.zip \
--eval-episodes 2 --verbose 0
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import platform
import random
import signal
import sys
import time
from pathlib import Path
from typing import Any
from mcm26b.config import ProblemConfig
from mcm26b.hrl.curriculum import STAGES
from mcm26b.hrl.constants import (ACTION_LAYOUT_VERSION, FEATURE_VERSION,
HIGH_OBS_DIM, N_CANDIDATES, REWARD_VERSION)
from mcm26b.process_control import ControlState, read_control
def _stage_from_index(index: int):
if not 1 <= int(index) <= len(STAGES):
raise ValueError(f"stage 必须在 1..{len(STAGES)}")
return STAGES[int(index) - 1]
def _profile_fidelity(profile: str) -> str:
if profile == "production":
return "production"
if profile == "coarse-pretrain":
return "fast"
raise ValueError(f"未知 config-profile: {profile}")
def _reward_from_profile(profile: str):
from mcm26b.hrl.hierarchical_env import HRLReward
if profile == "time":
return HRLReward.time_only()
if profile == "prompt":
return HRLReward()
raise ValueError(f"未知 reward-profile: {profile}")
def _stop_signals() -> list[int]:
"""本平台可用于"请求收尾"的信号集合。
Windows 上没有 SIGTERM 的软语义(``os.kill`` 直接 TerminateProcess),但
``SIGBREAK`` 可以由 ``CTRL_BREAK_EVENT`` 触发并进入 Python 处理器;POSIX 上
SIGTERM/SIGINT 均可捕获。控制文件才是主通道,这里只是让"直接 kill 脚本"
也尽量走优雅收尾。
"""
signals = [signal.SIGTERM, signal.SIGINT]
sigbreak = getattr(signal, "SIGBREAK", None)
if sigbreak is not None:
signals.append(sigbreak)
return signals
def _ignore_stop_signals_in_worker() -> None:
"""subproc worker 忽略收尾信号,保证主进程能优雅落盘。
无论信号来自进程组 SIGTERM 还是 Windows ``CTRL_BREAK_EVENT``,若 worker
同时退出,主进程会在保存模型之前就拿到断管/EOF 错误,整局训练白跑。worker
活在管道后面,主进程收尾时通过 ``vec_env.close()`` 的 ``close`` 指令正常
结束它们。
"""
try:
import multiprocessing
if multiprocessing.current_process().name == "MainProcess":
return
for signum in _stop_signals():
signal.signal(signum, signal.SIG_IGN)
except (ImportError, ValueError, OSError): # 非主线程/平台不支持:不影响训练
return
class _ControlPoll:
"""控制文件轮询状态机(纯标准库,可脱离 SB3 单测)。
``poll()`` 每 ``poll_every`` 次调用才真正读盘,避免每个 env step 都做一次
文件 IO;``wait_while_paused()`` 阻塞到暂停解除或收到 stop,并累计暂停秒数
(``--max-wall-s`` 要把这段扣掉,否则"挂起一小时回来就判超时")。
"""
def __init__(self, path: Path, *, poll_every: int = 32,
poll_s: float = 0.2):
self.path = Path(path)
self.poll_every = max(1, int(poll_every))
self.poll_s = max(0.02, float(poll_s))
self.paused_total_s = 0.0
self._calls = 0
self._state = ControlState()
def poll(self) -> ControlState:
"""按抽样间隔返回最近一次控制状态。"""
self._calls += 1
if self._calls % self.poll_every:
return self._state
self._state = read_control(self.path)
return self._state
def wait_while_paused(self, on_pause, on_resume) -> bool:
"""阻塞到继续或停止;返回 True 表示期间收到了 stop。
``on_pause`` / ``on_resume`` 用来向前端回显事件(训练脚本的 JSON 行)。
"""
on_pause()
started = time.monotonic()
stopped = False
while True:
time.sleep(self.poll_s)
self._state = read_control(self.path)
if self._state.stop:
stopped = True
break
if not self._state.paused:
break
self.paused_total_s += time.monotonic() - started
on_resume()
return stopped
def _make_env(problem: int, stage_index: int,
monitor_path: str | None, profile: str,
reward_profile: str):
"""顶层工厂:供 DummyVecEnv / SubprocVecEnv 调用(必须是可 pickle 的)。"""
from stable_baselines3.common.monitor import Monitor
from mcm26b.hrl.hierarchical_env import HierarchicalEnv
_ignore_stop_signals_in_worker()
stage = _stage_from_index(stage_index)
env = HierarchicalEnv(
problem=problem,
stage=stage,
fidelity=_profile_fidelity(profile),
max_episode_steps=300,
real_budget_s=1800.0,
reward=_reward_from_profile(reward_profile),
)
return Monitor(env, filename=monitor_path)
def _build_vec_env(args, monitor_paths: list[str]):
from stable_baselines3.common.vec_env import (DummyVecEnv, SubprocVecEnv)
from functools import partial
factories = [
partial(_make_env, args.problem, args.stage,
monitor_paths[rank], args.config_profile, args.reward_profile)
for rank in range(args.envs)
]
if args.vec_env == "subproc":
# 每个 worker 独立 Monitor 文件;spawn 入口在 __main__ 保护内。
return SubprocVecEnv(factories, start_method="spawn")
return DummyVecEnv(factories)
def _hash_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _policy_hyperparams(model) -> dict[str, Any]:
"""记录**实际生效**的 PPO 超参。
``--resume``/``--init`` 时 ``MaskablePPO.load`` 会一并恢复 checkpoint 内的
n_steps/batch_size/n_epochs 等,命令行同名参数被忽略;把它们写进元数据,
事后才能核对一局到底用什么配置训的。
"""
def _scalar(value):
# SB3 2.9 的 learning_rate/clip_range 可能是 Schedule 对象
current = getattr(value, "current_value", None)
if callable(current):
try:
value = current()
except Exception:
return str(value)
try:
return float(value)
except (TypeError, ValueError):
return str(value)
return {
"n_steps": int(getattr(model, "n_steps", 0) or 0),
"batch_size": int(getattr(model, "batch_size", 0) or 0),
"n_epochs": int(getattr(model, "n_epochs", 0) or 0),
"gamma": _scalar(getattr(model, "gamma", 0.0)),
"gae_lambda": _scalar(getattr(model, "gae_lambda", 0.0)),
"learning_rate": _scalar(getattr(model, "learning_rate", 0.0)),
"clip_range": _scalar(getattr(model, "clip_range", 0.0)),
"ent_coef": _scalar(getattr(model, "ent_coef", 0.0)),
"vf_coef": _scalar(getattr(model, "vf_coef", 0.0)),
"max_grad_norm": _scalar(getattr(model, "max_grad_norm", 0.0)),
}
def _progress_payload(model, start: int) -> dict[str, Any]:
"""组装 ``--progress-json`` 的单行负载。
纯函数(只读 model 的 logger / ep_info_buffer),便于脱离训练循环测试;
NaN / ±inf 一律折算成 None,保证 ``json.dumps(allow_nan=False)`` 不会炸。
"""
values = getattr(model.logger, "name_to_value", {}) or {}
def _num(key, default=None):
value = values.get(key)
if value is None:
return default
try:
value = float(value)
except (TypeError, ValueError):
return default
if not math.isfinite(value):
return default
return value
ep_rewards: list[float] = []
ep_lengths: list[float] = []
for item in getattr(model, "ep_info_buffer", []) or []:
try:
ep_rewards.append(float(item.get("r")))
ep_lengths.append(float(item.get("l")))
except (TypeError, ValueError, AttributeError):
continue
return {
"event": "progress",
"timesteps": int(model.num_timesteps),
"start_timesteps": int(start),
"n_updates": int(getattr(model, "_n_updates", 0) or 0),
"ep_rew_mean": (sum(ep_rewards) / len(ep_rewards)
if ep_rewards else None),
"ep_len_mean": (sum(ep_lengths) / len(ep_lengths)
if ep_lengths else None),
"value_loss": _num("train/value_loss"),
"policy_gradient_loss": _num("train/policy_gradient_loss"),
"approx_kl": _num("train/approx_kl"),
"entropy_loss": _num("train/entropy_loss"),
"explained_variance": _num("train/explained_variance"),
"fps": _num("time/fps"),
}
def _model_metadata(*, args, wall_s: float, n_envs: int,
actual_timesteps: int) -> dict[str, Any]:
import numpy
import stable_baselines3
import sb3_contrib
import torch
stage = _stage_from_index(args.stage)
return {
"level": "high",
"problem": int(args.problem),
"stage": int(args.stage),
"stage_name": stage.name,
"algorithm": "maskable_ppo",
"feature_version": FEATURE_VERSION,
"action_layout_version": ACTION_LAYOUT_VERSION,
"reward_version": REWARD_VERSION,
"obs_dim": HIGH_OBS_DIM,
"act_dim": N_CANDIDATES,
"seed": int(args.seed),
"requested_timesteps": int(args.timesteps),
"timesteps_semantics": ("additional" if (args.resume or args.init)
else "total"),
"actual_timesteps": int(actual_timesteps),
"n_envs": int(n_envs),
"vec_env": args.vec_env,
"config_profile": args.config_profile,
"fidelity": _profile_fidelity(args.config_profile),
"reward_profile": args.reward_profile,
"local_selector": args.local_selector,
"local_model": args.local_model,
"checkpoint_every": int(getattr(args, "checkpoint_every", 0) or 0),
"checkpoint_keep": int(getattr(args, "checkpoint_keep", 0) or 0),
"checkpoint_dir": getattr(args, "checkpoint_dir", None),
"recovery_every": int(getattr(args, "recovery_every", 0) or 0),
"device": args.device,
"wall_seconds": round(wall_s, 3),
"max_wall_s": float(args.max_wall_s),
"created_unix_s": time.time(),
"python": sys.version.split()[0],
"platform": platform.platform(),
"torch": torch.__version__,
"numpy": numpy.__version__,
"stable_baselines3": stable_baselines3.__version__,
"sb3_contrib": sb3_contrib.__version__,
"mps_available": bool(getattr(getattr(torch.backends, "mps", None),
"is_available", lambda: False)()),
}
def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
"""原子写 JSON:同目录 ``.tmp-`` 前缀临时文件 + ``os.replace``。
急停(SIGKILL / ``taskkill /F``)会绕过所有 Python 清理逻辑,保存途中被杀
是完全可能的。原子替换保证**真实文件名下要么是旧的完整文件、要么是新的
完整文件**,不会出现半截 JSON 让续训读到一半就崩。
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".tmp-{path.name}")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8")
os.replace(tmp, path)
def _save_model_atomic(model, target: Path) -> Path:
"""把 SB3 模型原子地写到 ``target``,返回实际落盘路径。
先写 ``.tmp-<name>``(同样以 ``.zip`` 结尾,SB3 不会再加后缀),完整写完后
``os.replace`` 覆盖目标。于是:
- 保存途中被强杀 → 目标路径仍是上一份**完整**的快照,可继续复训;
- 保存成功 → 目标路径立即变成新快照,不存在"看起来存在但打不开"的窗口。
临时名带 ``.tmp-`` 前缀,因此不会被 ``*_ckpt_*.zip`` 的清理逻辑误当成正式
checkpoint。
"""
target = Path(target)
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_name(f".tmp-{target.name}")
model.save(str(tmp))
if not tmp.exists(): # SB3 对未知后缀可能自行补 .zip
candidate = tmp.with_name(tmp.name + ".zip")
if candidate.exists():
tmp = candidate
os.replace(tmp, target)
return target
def _write_resume_hint(*, run_dir: Path, model_path: Path, timesteps: int,
reason: str, model=None) -> dict[str, Any]:
"""把"从哪继续"写进 ``<run_dir>/resume.json``。
这是急停恢复的**权威指针**:无论上一刻是周期 checkpoint、滚动恢复点、
优雅收尾还是异常兜底,这个文件总指向一份完整、可 ``--resume`` 的产物,
并带上续训语义(``timesteps`` 是增量步数)与生效过的 PPO 超参。
"""
hint: dict[str, Any] = {
"event": "resume_hint",
"model": str(model_path),
"timesteps": int(timesteps),
"timesteps_semantics": "additional",
"reason": str(reason),
"created_unix_s": time.time(),
"resume_args": ["--resume", "--model", str(model_path)],
}
if model is not None:
hint["ppo_hyperparams"] = _policy_hyperparams(model)
_write_json_atomic(run_dir / "resume.json", hint)
return hint
def _write_metadata(*, model_path: Path, args, wall_s: float, n_envs: int,
actual_timesteps: int, monitor_paths: list[str],
model=None,
extra: dict[str, Any] | None = None) -> dict[str, Any]:
"""写模型旁挂元数据(含 sha256),训练前端/审计都读它。"""
metadata = _model_metadata(args=args, wall_s=wall_s, n_envs=n_envs,
actual_timesteps=actual_timesteps)
if model is not None:
metadata["ppo_hyperparams"] = _policy_hyperparams(model)
metadata["model_sha256"] = _hash_file(model_path)
metadata["monitor_files"] = list(monitor_paths)
metadata["reward_note"] = (
"reward_profile=time 使用 HRLReward.time_only(P0.4 纯时间口径);"
"reward_profile=prompt 使用原始 §5.2 塑形。模型元数据已固化口径。")
if extra:
metadata.update(extra)
_write_json_atomic(model_path.with_suffix(".json"), metadata)
return metadata
def _prune_checkpoints(directory: Path, stem: str, keep: int) -> list[str]:
"""只保留最近 ``keep`` 个 checkpoint(zip+json 同删),返回被删的 zip 路径。
只匹配 ``<stem>_<步数>.zip``;滚动恢复点 ``<stem>_latest.zip`` 与临时文件
``.tmp-*.zip`` 都不匹配,因此永远不会被历史清理误删。
"""
files = sorted((path for path in directory.glob(f"{stem}_[0-9]*.zip")
if path.stem.rsplit("_", 1)[-1].isdigit()),
key=lambda path: path.stat().st_mtime, reverse=True)
removed: list[str] = []
for stale in files[max(1, int(keep)):]:
for candidate in (stale, stale.with_suffix(".json")):
if candidate.exists():
candidate.unlink()
removed.append(str(stale))
return removed
def _evaluate_env(model, *, problem: int, stage_index: int,
episodes: int, seed: int, profile: str,
reward_profile: str) -> dict[str, Any]:
from mcm26b.hrl.hierarchical_env import HierarchicalEnv
stage = _stage_from_index(stage_index)
env = HierarchicalEnv(problem=problem, stage=stage,
fidelity=_profile_fidelity(profile),
max_episode_steps=300, real_budget_s=1800.0,
reward=_reward_from_profile(reward_profile))
wins = 0
rows = []
if episodes <= 0:
env.close()
return {"episodes": 0, "successes": 0, "success_rate": None, "rows": []}
for episode in range(episodes):
obs, info = env.reset(seed=seed + episode)
done = False
steps = 0
while not done:
action, _state = model.predict(
obs, action_masks=env.action_masks(), deterministic=True)
obs, _, terminated, truncated, info = env.step(int(action))
done = bool(terminated or truncated)
steps += 1
# 课程阶段的小场景只需证明全部频道 accounted;完整阶段至少应清掉
# 场景最少的题面源数。这里不读取真值,只使用环境自身统计。
success = bool(
info.get("accounted", 0) >= 20
and info.get("cleared", 0) >= stage.sources_min
and not info.get("anomalies")
)
wins += int(success)
rows.append({
"episode": episode,
"seed": seed + episode,
"steps": steps,
"cleared": info.get("cleared", 0),
"accounted": info.get("accounted", 0),
"virtual_time_s": info.get("episode_virtual_cost_s"),
"stop_reason": info.get("stop_reason"),
"anomalies": info.get("anomalies"),
"success": success,
})
env.close()
return {
"episodes": len(rows),
"successes": wins,
"success_rate": wins / len(rows) if rows else 0.0,
"rows": rows,
}
def _evaluate_manifest(model_path: Path, manifest_path: str, *,
limit: int) -> dict[str, Any]:
"""可选:用 benchmark_hrl 的同一套真值隔离评测在固定 manifest 上跑已训模型。"""
scripts_dir = Path(__file__).resolve().parent
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
import benchmark_hrl as bench # scripts/benchmark_hrl.py
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
scenes = bench.scenes_from_manifest(manifest)
if limit > 0:
scenes = scenes[:limit]
rows = []
for scene in scenes:
run_dir = Path(model_path).parent / "eval-manifest" / scene.scene_id
result = bench.run_scene_variant(
scene, "hrl", config=ProblemConfig(),
hrl_model=str(model_path), run_dir=run_dir)
rows.append(result["row"])
aggregate = bench._aggregate(rows)
return aggregate
def run_training(args) -> int:
import numpy as np
import torch
from sb3_contrib import MaskablePPO
from stable_baselines3.common.callbacks import BaseCallback
from stable_baselines3.common.utils import set_random_seed
torch.set_num_threads(1)
set_random_seed(args.seed)
random.seed(args.seed)
np.random.seed(args.seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(args.seed)
model_path = Path(args.model)
if model_path.exists() and not (args.resume or args.force):
print(f"模型已存在:{model_path};如需覆盖请加 --force,续训请加 --resume。",
file=sys.stderr)
return 2
model_path.parent.mkdir(parents=True, exist_ok=True)
run_dir = model_path.parent
monitor_paths = [
str(run_dir / f"high_curve_{model_path.stem}_env{rank}.monitor.csv")
for rank in range(args.envs)
]
for path in monitor_paths:
Path(path).parent.mkdir(parents=True, exist_ok=True)
vec_env = _build_vec_env(args, monitor_paths)
if args.resume:
if not model_path.exists():
print(f"--resume 指定的模型不存在:{model_path}", file=sys.stderr)
return 2
model = MaskablePPO.load(str(model_path), env=vec_env,
device=args.device,
print_system_info=False)
elif args.init:
init_path = Path(args.init)
if not init_path.exists():
print(f"--init 指定的模型不存在:{init_path}", file=sys.stderr)
return 2
model = MaskablePPO.load(str(init_path), env=vec_env,
device=args.device,
print_system_info=False)
else:
model = MaskablePPO(
"MlpPolicy",
vec_env,
policy_kwargs={"net_arch": [128, 128]},
n_steps=args.rollout_steps,
batch_size=args.batch_size,
n_epochs=args.n_epochs,
gamma=1.0,
gae_lambda=0.95,
learning_rate=3e-4,
clip_range=0.2,
ent_coef=0.005,
vf_coef=0.5,
max_grad_norm=0.5,
seed=args.seed,
device=args.device,
verbose=args.verbose,
)
if args.init:
pass # 已在上面处理;此分支仅保持语义清晰
# resume/init 会恢复 checkpoint 内的 PPO 超参,命令行同名三项目前被忽略;
# 显式说出差异,避免"以为换了 n_steps 其实没换"。
if args.resume or args.init:
effective = _policy_hyperparams(model)
requested = {"n_steps": int(args.rollout_steps),
"batch_size": int(args.batch_size),
"n_epochs": int(args.n_epochs)}
if {key: effective[key] for key in requested} != requested:
print(json.dumps({
"event": "hyperparams_from_checkpoint",
"effective": {key: effective[key] for key in requested},
"requested": requested,
"note": ("resume/init 沿用 checkpoint 内的 PPO 超参,"
"命令行 rollout-steps/batch-size/n-epochs 被忽略")},
ensure_ascii=False), file=sys.stderr, flush=True)
# ------------------------------------------------------ 优雅暂停 / 停止
# 主通道是控制文件(跨平台);信号是兜底:直接 kill 本进程时也走同一段收尾。
stop_state: dict[str, Any] = {"requested": False, "signal": None}
previous_handlers: dict[int, Any] = {}
def _request_stop(source: str) -> None:
if stop_state["requested"]:
return
stop_state["requested"] = True
stop_state["signal"] = source
print(json.dumps({"event": "stop_requested", "signal": source}), flush=True)
def _on_stop_signal(signum, _frame):
_request_stop(signal.Signals(signum).name)
for _signum in _stop_signals():
try:
previous_handlers[_signum] = signal.signal(_signum, _on_stop_signal)
except (ValueError, OSError): # 非主线程/平台不支持:不阻塞训练
pass
class _ControlChannel(BaseCallback):
"""把 :class:`_ControlPoll` 接到 SB3 的 step 回调上。
为什么不用信号:Windows 没有 SIGSTOP,且 ``os.kill(SIGTERM)`` 会退化成
``TerminateProcess`` 硬杀,收尾分支根本不会执行。控制文件在 POSIX 与
Windows 上都只是普通文件 IO,语义完全一致。
暂停是**协作式**的:在 ``_on_step`` 内阻塞轮询,训练因此停在 step 边界,
进程仍存活、显存仍占用,但不消耗算力;收到 stop 立刻转入收尾落盘。
"""
def __init__(self, path: Path, *, poll_every: int = 32,
poll_s: float = 0.2):
super().__init__()
self.poller = _ControlPoll(path, poll_every=poll_every,
poll_s=poll_s)
def paused_seconds(self) -> float:
return self.poller.paused_total_s
def _event(self, name: str) -> None:
print(json.dumps({"event": name,
"timesteps": int(self.model.num_timesteps)}),
flush=True)
def _on_step(self) -> bool:
state = self.poller.poll()
if state.stop:
_request_stop("control_file")
return False
if state.paused:
stopped = self.poller.wait_while_paused(
lambda: self._event("paused"),
lambda: self._event("resumed"))
if stopped:
_request_stop("control_file")
return False
return not stop_state["requested"]
return True
control_path = getattr(args, "control_file", None)
control_cb = _ControlChannel(Path(control_path)) if control_path else None
class _GracefulStop(BaseCallback):
"""收到停止请求后在最近一次 step 边界结束 rollout,让模型能落盘。"""
def _on_step(self) -> bool:
if stop_state["requested"]:
print(json.dumps({"event": "graceful_stop",
"signal": stop_state["signal"],
"timesteps": int(self.model.num_timesteps)}),
flush=True)
return False
return True
class _WallClockStop(BaseCallback):
"""墙钟硬上限;暂停时间不计入,避免"挂起一小时回来就判超时"。"""
def __init__(self, max_wall_s: float, paused_clock=None):
super().__init__()
self.max_wall_s = max_wall_s
self.start = time.monotonic()
self.paused_clock = paused_clock
def paused_seconds(self) -> float:
return float(self.paused_clock()) if self.paused_clock else 0.0
def _on_step(self) -> bool:
elapsed = time.monotonic() - self.start - self.paused_seconds()
if elapsed >= self.max_wall_s:
print(json.dumps({"event": "wall_clock_stop",
"elapsed_s": elapsed}))
return False
return True
class _ProgressJSON(BaseCallback):
"""向训练前端输出单行 JSON 进度;不影响 SB3 自身 logger。
前端首个 curve 点越早出现越好,因此开头几步按更小间隔发送(前 8 个
进度点逐步放大到 ``every_steps``),之后稳定在 ``every_steps``。
这样短程 smoke 也能看到完整曲线,长训不会刷屏。
"""
def __init__(self, every_steps: int):
super().__init__()
self.every_steps = max(1, int(every_steps))
# init/resume 时计数器从上一阶段继续,前端要用增量算进度
self.start = int(model.num_timesteps)
self.last_emit = self.start
self.emitted = 0
def _next_gap(self) -> int:
warmup = max(0.2, 1.0 - self.emitted / 8.0)
return max(1, min(self.every_steps, int(round(self.every_steps * warmup))))
def _on_step(self) -> bool:
current = int(self.model.num_timesteps)
if current - self.last_emit < self._next_gap():
return True
self.last_emit = current
self.emitted += 1
payload = _progress_payload(self.model, self.start)
print(json.dumps(payload, ensure_ascii=False, allow_nan=False),
flush=True)
return True
started = time.monotonic()
checkpoints_dir = (Path(args.checkpoint_dir) if args.checkpoint_dir
else run_dir / "checkpoints")
checkpoint_stem = f"{model_path.stem}_ckpt"
class _CheckpointJSON(BaseCallback):
"""可续训落盘:滚动恢复点 + 周期 checkpoint + resume.json 指针。
- **滚动恢复点** ``<stem>_latest.zip``:每 ``recovery_every`` 个环境步
原子覆盖写一次,训练开始前也先写一份。它保证"急停"(SIGKILL /
``taskkill /F``,没有任何 Python 清理机会)之后**最多只丢
``recovery_every`` 个环境步**,而且文件始终完整可加载。
- **周期 checkpoint** ``<stem>_<步数>.zip``:每 ``every_steps`` 步留一份,
只保留最近 ``keep`` 个,用于返祖对比与多档恢复。
- 每次落盘都刷新 ``<run_dir>/resume.json``:无论怎么停,都有一份权威的
"从哪继续"指针(含增量语义与生效过的 PPO 超参)。
"""
def __init__(self, every_steps: int, keep: int, recovery_every: int):
super().__init__()
self.every_steps = max(0, int(every_steps))
self.keep = max(1, int(keep))
self.recovery_every = max(0, int(recovery_every))
self.last_saved = int(model.num_timesteps)
self.last_recovery = int(model.num_timesteps)
self.saved: list[str] = []
self.latest_path = checkpoints_dir / f"{checkpoint_stem}_latest.zip"
def write_recovery(self, reason: str) -> None:
"""立刻写一份滚动恢复点;训练开始前调用即"起步基线"。
用闭包里的 ``model`` 而不是 ``self.model``:``BaseCallback.model``
要等 ``learn()`` 时才被赋值,而起步基线恰恰在 ``learn()`` 之前写。
"""
current = int(model.num_timesteps)
self.last_recovery = current
self._write_recovery(current, reason)
def _on_step(self) -> bool:
current = int(model.num_timesteps)
if (self.recovery_every > 0
and current - self.last_recovery >= self.recovery_every):
self.last_recovery = current
self._write_recovery(current, "interval")
if (self.every_steps > 0
and current - self.last_saved >= self.every_steps):
self.last_saved = current
self._write_interval(current)
return True
def _persist(self, target: Path, current: int, reason: str,
extra: dict[str, Any]) -> None:
"""原子落盘 zip + 元数据,并刷新 resume.json 指针。"""
_save_model_atomic(model, target)
_write_metadata(
model_path=target, args=args,
wall_s=time.monotonic() - started, n_envs=args.envs,
actual_timesteps=current, monitor_paths=monitor_paths,
model=model,
extra={"checkpoint_timesteps": current,
"checkpoint_reason": reason,
"checkpoint_of": str(model_path), **extra})
_write_resume_hint(run_dir=run_dir, model_path=target,
timesteps=current, reason=reason,
model=model)
def _write_recovery(self, current: int, reason: str) -> None:
checkpoints_dir.mkdir(parents=True, exist_ok=True)
self._persist(self.latest_path, current, reason,
{"recovery": True})
print(json.dumps({"event": "recovery_checkpoint",
"reason": reason, "timesteps": current,
"model": str(self.latest_path)},
ensure_ascii=False), flush=True)
def _write_interval(self, current: int) -> None:
checkpoints_dir.mkdir(parents=True, exist_ok=True)
target = checkpoints_dir / f"{checkpoint_stem}_{current:09d}.zip"
self._persist(target, current, "interval", {"checkpoint": True})
removed = _prune_checkpoints(checkpoints_dir, checkpoint_stem,
self.keep)
self.saved.append(str(target))
print(json.dumps({"event": "checkpoint", "timesteps": current,
"model": str(target), "pruned": removed},
ensure_ascii=False), flush=True)
progress_every = (args.progress_every if args.progress_every > 0
else args.rollout_steps * args.envs)
checkpoint_cb = _CheckpointJSON(args.checkpoint_every, args.checkpoint_keep,
args.recovery_every)
if args.recovery_every > 0:
# 起步基线:训练还没跑就急停,也至少有一份可加载、可续训的产物
checkpoint_cb.write_recovery("baseline")
# 顺序有语义:控制通道最先(暂停/停止要在本步立刻生效),进度次之,
# 墙钟上限要能读到控制通道累计的暂停秒数,checkpoint 放最后。
callbacks: list[Any] = []
if control_cb is not None:
callbacks.append(control_cb)
if args.progress_json:
callbacks.append(_ProgressJSON(progress_every))
callbacks.append(_GracefulStop())
callbacks.append(_WallClockStop(
args.max_wall_s,
paused_clock=(control_cb.paused_seconds if control_cb is not None
else None)))
callbacks.append(checkpoint_cb)
actual_timesteps = int(model.num_timesteps)
try:
model.learn(total_timesteps=args.timesteps,
callback=callbacks if len(callbacks) > 1 else callbacks[0],
progress_bar=False,
reset_num_timesteps=not (args.resume or args.init))
except BaseException as exc: # 训练失败也必须留下可续训产物,不静默
failure = {
"event": "training_failed",
"error": f"{type(exc).__name__}: {exc}",
"model": str(model_path),
"timesteps": int(model.num_timesteps),
}
emergency = run_dir / f"{model_path.stem}_emergency.zip"
try:
_save_model_atomic(model, emergency)
_write_metadata(model_path=emergency, args=args,
wall_s=time.monotonic() - started,
n_envs=args.envs,
actual_timesteps=int(model.num_timesteps),
monitor_paths=monitor_paths, model=model,
extra={"emergency": True,
"error": failure["error"]})
_write_resume_hint(run_dir=run_dir, model_path=emergency,
timesteps=int(model.num_timesteps),
reason="emergency", model=model)
failure["emergency_model"] = str(emergency)
except Exception as save_exc: # 落盘失败不能再掩盖原始异常
failure["emergency_save_error"] = (
f"{type(save_exc).__name__}: {save_exc}")
_write_json_atomic(run_dir / "training_failure.json", failure)
print(json.dumps(failure, ensure_ascii=False), file=sys.stderr,
flush=True)
raise
wall_s = time.monotonic() - started
actual_timesteps = int(model.num_timesteps)
stopped_by_signal = bool(stop_state["requested"])
_save_model_atomic(model, model_path)
metadata = _write_metadata(
model_path=model_path, args=args, wall_s=wall_s, n_envs=args.envs,
actual_timesteps=actual_timesteps, monitor_paths=monitor_paths,
model=model,
extra={"stopped_by_signal": stopped_by_signal,
"stop_signal": stop_state["signal"],
"checkpoint_files": checkpoint_cb.saved,
"recovery_model": str(checkpoint_cb.latest_path)})
# 最终模型也刷新一次 resume.json:优雅收尾后"从哪继续"指向最终模型
_write_resume_hint(run_dir=run_dir, model_path=model_path,
timesteps=actual_timesteps,
reason=("stopped" if stopped_by_signal else "finished"),
model=model)
metadata_path = model_path.with_suffix(".json")
vec_env.close()
# 收尾阶段恢复默认处理器:再次 Ctrl+C / 停止可立即强杀,不再等待评估。
for _signum, _handler in previous_handlers.items():
try:
signal.signal(_signum, _handler)
except (ValueError, OSError):
pass
if stopped_by_signal:
print(json.dumps({"event": "saved_after_stop",
"model": str(model_path),
"timesteps": actual_timesteps}), flush=True)
evaluation = _evaluate_env(
model, problem=args.problem, stage_index=args.stage,
episodes=args.eval_episodes, seed=args.seed + 1_000_000,
profile=args.config_profile, reward_profile=args.reward_profile)
manifest_eval = None
if args.eval_manifest:
try:
manifest_eval = _evaluate_manifest(
model_path, args.eval_manifest, limit=args.eval_manifest_limit)
except Exception as exc:
manifest_eval = {"error": f"{type(exc).__name__}: {exc}"}
metadata["evaluation"] = evaluation
metadata["eval_manifest"] = manifest_eval
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2),
encoding="utf-8")
print(json.dumps(metadata, ensure_ascii=False, indent=2))
return 0
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--problem", type=int, choices=(3, 4), required=True)
parser.add_argument("--stage", type=int, choices=(1, 2, 3, 4),
default=4)
parser.add_argument("--timesteps", type=int, required=True)
parser.add_argument("--seed", type=int, default=320001)
parser.add_argument("--envs", type=int, default=1)
parser.add_argument("--vec-env", choices=("dummy", "subproc"), default="dummy")
parser.add_argument("--device", choices=("cpu", "cuda", "mps", "auto"),
default="auto")
parser.add_argument("--config-profile",
choices=("production", "coarse-pretrain"),
default="production")
parser.add_argument("--model", required=True)
parser.add_argument("--resume", action="store_true",
help="从 --model 续训(沿用 checkpoint 内的 PPO 超参)")
parser.add_argument("--init", default=None,
help="可选:Q3→Q4 或继续预训练权重路径")
parser.add_argument("--force", action="store_true")
parser.add_argument("--eval-manifest", default=None)
parser.add_argument("--eval-manifest-limit", type=int, default=0)
parser.add_argument("--eval-episodes", type=int, default=5)
parser.add_argument("--local-selector", choices=("geometry", "rl"),
default="geometry")
parser.add_argument("--local-model", default=None)
parser.add_argument("--rollout-steps", type=int, default=256)
parser.add_argument("--batch-size", type=int, default=256)
parser.add_argument("--n-epochs", type=int, default=10)
parser.add_argument("--max-wall-s", type=float, default=1200.0,
help="训练墙钟硬上限;默认 20 分钟")
parser.add_argument("--reward-profile", choices=("time", "prompt"),
default="time",
help="time=P0.4 纯时间口径;prompt=原始 §5.2 塑形对照")
parser.add_argument("--progress-json", action="store_true",
help="向训练前端输出单行 JSON 进度(UI 自动启用)")
parser.add_argument("--control-file", default=None,
help="训练前端控制文件(JSON:paused/stop);"
"跨平台暂停与优雅停止的主通道")
parser.add_argument("--progress-every", type=int, default=0,
help="JSON 进度间隔环境步数;0=每个 rollout")
parser.add_argument("--checkpoint-every", type=int, default=20000,
help="每 N 个环境步留一个历史 checkpoint;0=关闭(默认 20000)")
parser.add_argument("--recovery-every", type=int, default=2000,
help="每 N 个环境步原子覆盖写一份滚动恢复点 "
"<模型目录>/checkpoints/<stem>_latest.zip;"
"急停后最多丢 N 步。0=关闭(默认 2000)")
parser.add_argument("--checkpoint-keep", type=int, default=3,
help="checkpoint 保留最近 K 个(默认 3)")
parser.add_argument("--checkpoint-dir", default=None,
help="checkpoint 目录;默认 <模型目录>/checkpoints")
parser.add_argument("--verbose", type=int, default=1)
args = parser.parse_args(argv)
if args.timesteps <= 0 or args.envs <= 0:
parser.error("timesteps 与 envs 必须为正")
if args.envs * args.rollout_steps < 2:
parser.error("PPO rollout 至少需要 2 个样本")
if args.eval_episodes < 0:
parser.error("eval-episodes 不能为负")
if args.checkpoint_every < 0:
parser.error("checkpoint-every 不能为负")
if args.recovery_every < 0:
parser.error("recovery-every 不能为负")
if args.checkpoint_keep < 1:
parser.error("checkpoint-keep 至少为 1")
return run_training(args)
if __name__ == "__main__":
raise SystemExit(main())
-266
View File
@@ -1,266 +0,0 @@
"""低层 PPO 训练:8 方向到达任务(BC 直线专家热启动 + PPO 微调)。
产物:
output/hrl/hrl_low.zip / hrl_low.json PPO 模型(部署用)
output/hrl/bc_low_expert.npz / bc_low.pt 行为克隆数据与网络
output/hrl/hrl_low_curve_env*.monitor.csv 每个环境独立学习曲线
评估口径:到达成功率 + 路径效率(直线距离/实际路径长,1 为完美直线)。
这是独立导航实验:目前低层 PPO 仅供轨迹诊断,不影响正式 HTTP 任务。
示例:
uv run python scripts/train_hrl_low.py --timesteps 60000
uv run python scripts/train_hrl_low.py --skip-bc # 纯 PPO 对照
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import time
from pathlib import Path
from mcm26b.config import ProblemConfig
from mcm26b.hrl.constants import (FEATURE_VERSION, LOW_MAX_STEPS,
LOW_NUM_ACTIONS, LOW_OBS_DIM)
from mcm26b.hrl.low_level_env import LowLevelEnv
HIDDEN = (64, 64)
def _bc_module(obs_dim: int, n_actions: int, hidden: tuple[int, ...]):
import torch
class BCMlp(torch.nn.Module):
def __init__(self):
super().__init__()
layers = []
prev = obs_dim
for width in hidden:
layers += [torch.nn.Linear(prev, width), torch.nn.Tanh()]
prev = width
layers.append(torch.nn.Linear(prev, n_actions))
self.net = torch.nn.Sequential(*layers)
def forward(self, x):
return self.net(x)
return BCMlp
def collect(episodes: int, seed: int, config: ProblemConfig):
import numpy as np
env = LowLevelEnv(config=config, seed=seed)
observations, actions, episode_ids = [], [], []
for episode in range(episodes):
obs, _info = env.reset(seed=seed + episode)
for _step in range(LOW_MAX_STEPS + 2):
observations.append(obs)
actions.append(env.expert_action())
episode_ids.append(episode)
obs, _r, terminated, truncated, _info = env.step(env.expert_action())
if terminated or truncated:
break
env.close()
return (np.asarray(observations, dtype=np.float32),
np.asarray(actions, dtype=np.int64),
np.asarray(episode_ids, dtype=np.int64))
def bc_validation_mask(episode_ids, *, seed: int):
"""按整局留出,避免同一轨迹相邻状态跨训练/验证集。"""
import numpy as np
unique = np.unique(episode_ids)
if len(unique) < 2:
raise ValueError("BC 至少需要 2 局才能按整局划分训练/验证集")
rng = np.random.default_rng(seed)
validation_ids = rng.permutation(unique)[:max(1, len(unique) // 10)]
return np.isin(episode_ids, validation_ids)
def train_bc(x, y, episode_ids, *, epochs: int, batch_size: int, lr: float,
device: str, seed: int):
import torch
BCMlp = _bc_module(LOW_OBS_DIM, LOW_NUM_ACTIONS, HIDDEN)
model = BCMlp().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
valid = torch.as_tensor(
bc_validation_mask(episode_ids, seed=seed), device=device)
x_t = torch.as_tensor(x, device=device)
y_t = torch.as_tensor(y, device=device)
x_v, y_v, x_tr, y_tr = x_t[valid], y_t[valid], x_t[~valid], y_t[~valid]
loss_fn = torch.nn.CrossEntropyLoss()
for epoch in range(epochs):
order = torch.randperm(len(x_tr), device=device)
total = correct = 0.0
for start in range(0, len(order), batch_size):
idx = order[start:start + batch_size]
logits = model(x_tr[idx])
loss = loss_fn(logits, y_tr[idx])
optimizer.zero_grad()
loss.backward()
optimizer.step()
total += float(loss.item()) * len(idx)
correct += int((logits.argmax(1) == y_tr[idx]).sum().item())
if epoch == 0 or (epoch + 1) % 5 == 0:
with torch.no_grad():
valid_acc = float((model(x_v).argmax(1) == y_v).float().mean().item())
print(json.dumps({"event": "bc_epoch", "epoch": epoch + 1,
"loss": round(total / len(x_tr), 4),
"train_acc": round(correct / len(x_tr), 4),
"valid_acc": round(valid_acc, 4)},
ensure_ascii=False))
with torch.no_grad():
valid_acc = float((model(x_v).argmax(1) == y_v).float().mean().item())
return model, valid_acc
def copy_into_ppo(bc_model, device: str, env, *, seed: int,
n_steps: int = 512) -> object:
"""把 BC 权重拷入 MaskablePPO(policy_net 两层 + action_net),前向校验。"""
import torch
from sb3_contrib import MaskablePPO
ppo = MaskablePPO("MlpPolicy", env, policy_kwargs={"net_arch": list(HIDDEN)},
n_steps=n_steps, batch_size=128, n_epochs=6,
gamma=1.0, gae_lambda=0.95, learning_rate=3e-4,
clip_range=0.2, ent_coef=0.005, vf_coef=0.5,
max_grad_norm=0.5, seed=seed, device=device)
state = {key.removeprefix("net."): value
for key, value in bc_model.state_dict().items()}
policy_state = {key: value for key, value in state.items()
if key in ("0.weight", "0.bias", "2.weight", "2.bias")}
ppo.policy.mlp_extractor.policy_net.load_state_dict(policy_state)
ppo.policy.action_net.load_state_dict(
{"weight": state["4.weight"], "bias": state["4.bias"]})
probe = torch.randn((8, LOW_OBS_DIM), device=device)
with torch.no_grad():
copied = ppo.policy.action_net(ppo.policy.mlp_extractor.policy_net(probe))
if not torch.allclose(bc_model(probe), copied, atol=1e-6):
raise RuntimeError("行为克隆与 PPO 前向映射不一致")
return ppo
def evaluate(model, *, episodes: int, seed: int,
config: ProblemConfig) -> dict:
"""确定性策略评估:成功率与路径效率(对照直线)。"""
env = LowLevelEnv(config=config, seed=seed)
successes = 0
efficiencies = []
for episode in range(episodes):
obs, info = env.reset(seed=seed + episode)
start, goal = tuple(info["position"]), tuple(info["goal"])
straight = math.dist(start, goal)
steps = 0
for _ in range(LOW_MAX_STEPS + 2):
action, _state = model.predict(obs, deterministic=True)
obs, _r, terminated, truncated, info = env.step(int(action))
steps += 1
if terminated or truncated:
break
successes += int(terminated)
efficiencies.append(min(1.0, straight /
max(info["path_length_m"], 1e-9)))
env.close()
return {"episodes": episodes,
"success_rate": successes / episodes,
"mean_path_efficiency": sum(efficiencies) / len(efficiencies)}
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--timesteps", type=int, default=60_000)
parser.add_argument("--seed", type=int, default=20260911)
parser.add_argument("--bc-episodes", type=int, default=600)
parser.add_argument("--bc-epochs", type=int, default=12)
parser.add_argument("--envs", type=int, default=4)
parser.add_argument("--rollout-steps", type=int, default=512,
help="每环境每次 PPO rollout 长度;短 smoke 可设 8")
parser.add_argument("--device", default="cpu")
parser.add_argument("--model", default="output/hrl/hrl_low.zip")
parser.add_argument("--eval-episodes", type=int, default=40)
parser.add_argument("--skip-bc", action="store_true",
help="跳过行为克隆(纯 PPO 对照实验用)")
parser.add_argument("--verbose", type=int, default=1)
args = parser.parse_args(argv)
if min(args.envs, args.timesteps, args.eval_episodes,
args.rollout_steps) <= 0:
parser.error("envs/timesteps/eval-episodes/rollout-steps 必须为正数")
if args.envs * args.rollout_steps < 2:
parser.error("PPO rollout 至少需要 2 个样本")
if not args.skip_bc and (args.bc_episodes < 2 or args.bc_epochs < 1):
parser.error("BC 至少需要 2 局和 1 个 epoch")
import torch
torch.set_num_threads(1)
from sb3_contrib import MaskablePPO
from stable_baselines3.common.monitor import Monitor
from stable_baselines3.common.vec_env import DummyVecEnv
from stable_baselines3.common.utils import set_random_seed
set_random_seed(args.seed)
model_path = Path(args.model)
model_path.parent.mkdir(parents=True, exist_ok=True)
curve_paths = [model_path.with_name(
f"{model_path.stem}_curve_env{rank}.monitor.csv")
for rank in range(args.envs)]
def _factory(rank: int):
def _make():
env = Monitor(LowLevelEnv(seed=args.seed + rank),
filename=str(curve_paths[rank]))
return env
return _make
vec_env = DummyVecEnv([_factory(rank) for rank in range(args.envs)])
started = time.monotonic()
bc_valid_acc = None
if args.skip_bc:
model = MaskablePPO("MlpPolicy", vec_env,
policy_kwargs={"net_arch": list(HIDDEN)},
n_steps=args.rollout_steps, batch_size=128, n_epochs=6,
gamma=1.0, gae_lambda=0.95, learning_rate=3e-4,
clip_range=0.2, ent_coef=0.005, vf_coef=0.5,
max_grad_norm=0.5, seed=args.seed,
device=args.device, verbose=args.verbose)
else:
x, y, episode_ids = collect(args.bc_episodes, args.seed, ProblemConfig())
bc_model, bc_valid_acc = train_bc(
x, y, episode_ids, epochs=args.bc_epochs, batch_size=256,
lr=3e-4, device=args.device, seed=args.seed)
model = copy_into_ppo(bc_model, args.device, vec_env, seed=args.seed,
n_steps=args.rollout_steps)
bc_dir = model_path.parent / "bc_low.pt"
torch.save(bc_model.state_dict(), str(bc_dir))
print(json.dumps({"event": "bc_done", "samples": int(len(x))},
ensure_ascii=False))
model.learn(total_timesteps=args.timesteps,
progress_bar=args.verbose > 0)
elapsed_s = time.monotonic() - started
model.save(str(model_path))
eval_seed = args.seed + 1_000_000
evaluation = evaluate(model, episodes=args.eval_episodes, seed=eval_seed,
config=ProblemConfig())
report = {
"level": "low", "timesteps": args.timesteps, "seed": args.seed,
"device": args.device, "wall_seconds": round(elapsed_s, 1),
"model": str(model_path), "algorithm": "maskable_ppo",
"feature_version": FEATURE_VERSION, "bc_skipped": args.skip_bc,
"bc_valid_acc": bc_valid_acc, "evaluation_seed": eval_seed,
"monitor_files": [str(path) for path in curve_paths],
"training_role": "standalone_navigation_only",
"obs_dim": LOW_OBS_DIM, "act_dim": LOW_NUM_ACTIONS,
"model_sha256": hashlib.sha256(model_path.read_bytes()).hexdigest(),
**evaluation,
}
model_path.with_suffix(".json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
vec_env.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
-5
View File
@@ -1,5 +0,0 @@
"""启动 HRL 模型训练前端(薄入口,便于加入支撑材料命令)。"""
from mcm26b.training_ui.server import run_training_ui
if __name__ == "__main__":
raise SystemExit(run_training_ui())
-195
View File
@@ -1,195 +0,0 @@
"""问题3/4 强化学习选点策略训练(PPO + CUDA)。
训练环境 mcm26b.rl.env.LocalizationEnv 与运行闭环共享 features 观测/候选
布局。模型输出到 output/rl/q3_ppo.zip(问题3)或 q4_ppo.zip(问题4),
由 `mcm26b run --strategy rl` / `--q4-strategy rl` 加载。
示例:
uv sync --group rl --group dev
uv run python scripts/train_rl.py --problem 3 --timesteps 30000
uv run python scripts/train_rl.py --problem 4 --timesteps 30000
uv run mcm26b run --robot-id 2026001 --self-test --strategy rl
uv run mcm26b run --robot-id 2026001 --self-test --problem 4 --q4-strategy rl
"""
from __future__ import annotations
import argparse
import json
import time
from dataclasses import asdict
import hashlib
from pathlib import Path
from stable_baselines3 import PPO
from sb3_contrib import MaskablePPO
from stable_baselines3.common.vec_env import DummyVecEnv
from mcm26b.config import ProblemConfig
from mcm26b.rl.env import LocalizationEnv
def _make_env(problem: int, seed: int, config: ProblemConfig,
directional_probability: float):
def _factory():
env = LocalizationEnv(config, problem=problem,
directional_probability=directional_probability,
real_budget_s=3600.0)
env.reset(seed=seed)
return env
return _factory
def evaluate(model, problem: int, *, episodes: int = 24,
directional_probability: float = 0.5,
config: ProblemConfig = ProblemConfig()) -> dict:
"""独立场景评估:确定性策略的成功率、步数与平均回报。"""
env = LocalizationEnv(config, problem=problem,
directional_probability=directional_probability)
total_reward = 0.0
successes = 0
steps = []
costs = []
for episode in range(episodes):
obs, _info = env.reset(seed=1_000_000 + episode)
episode_reward = 0.0
for step in range(env.max_episode_steps + 2):
kwargs = {'action_masks': env.action_masks()} if isinstance(model, MaskablePPO) else {}
action, _state = model.predict(obs, deterministic=True, **kwargs)
obs, reward, terminated, truncated, info = env.step(int(action))
episode_reward += float(reward)
if terminated or truncated:
break
total_reward += episode_reward
cleared = bool(info.get("cleared"))
successes += int(cleared)
steps.append(step + 1)
costs.append(info['virtual_cost_s'])
env.close()
return {
"episodes": episodes,
"success_rate": successes / episodes,
"mean_steps": sum(steps) / episodes,
"mean_episode_reward": total_reward / episodes,
"successes": successes,
"mean_virtual_cost_s": sum(costs) / episodes,
"max_virtual_cost_s": max(costs),
}
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--problem", type=int, choices=(3, 4), default=3,
help="训练问题:3=全向源选点,4=含定向遮挡梯子")
parser.add_argument("--timesteps", type=int, default=30000,
help="PPO 总环境步数")
parser.add_argument("--seed", type=int, default=20260911)
parser.add_argument("--envs", type=int, default=2, help="并行环境份数(CPU 侧)")
parser.add_argument("--directional-probability", type=float, default=None,
help="问题4训练场景中定向源比例(默认 1.0,先学遮挡分支)")
parser.add_argument("--device", default="auto",
help="auto=优先 CUDA;或 cuda/cpu")
parser.add_argument("--model", default=None,
help="模型输出路径(默认 output/rl/q<problem>_ppo.zip)")
parser.add_argument("--init", default=None,
help="初始模型路径(如 output/rl/q3_ppo_bc_init.zip;"
"加载后继续 PPO 微调)")
parser.add_argument("--eval-episodes", type=int, default=24)
parser.add_argument("--verbose", type=int, default=1)
parser.add_argument('--algorithm', choices=('ppo', 'maskable_ppo'), default='maskable_ppo')
parser.add_argument('--torch-threads', type=int, default=1)
args = parser.parse_args(argv)
directional_probability = (args.directional_probability
if args.directional_probability is not None
else (1.0 if args.problem == 4 else 0.0))
if not 0.0 <= directional_probability <= 1.0:
parser.error("--directional-probability 须在 [0,1]")
import torch
torch.set_num_threads(args.torch_threads)
if args.device == "auto":
device = "cuda" if torch.cuda.is_available() else "cpu"
else:
device = args.device
if device == "cuda" and not torch.cuda.is_available():
parser.error("--device cuda 但当前环境检测不到 CUDA GPU")
print(json.dumps({
"event": "train_start", "problem": args.problem,
"timesteps": args.timesteps, "device": device,
"directional_probability": directional_probability,
"torch": torch.__version__,
"gpu": torch.cuda.get_device_name(0) if device == "cuda" else None,
"cuda_available": torch.cuda.is_available(),
}, ensure_ascii=False))
config = ProblemConfig()
model_cls = MaskablePPO if args.algorithm == 'maskable_ppo' else PPO
vec_env = DummyVecEnv(
[_make_env(args.problem, args.seed + rank, config,
directional_probability)
for rank in range(max(1, args.envs))])
if args.init:
metadata = json.loads(Path(args.init).with_suffix('.json').read_text(encoding='utf-8'))
if (metadata.get('algorithm', 'ppo') != args.algorithm
or metadata.get('feature_version') != 2):
parser.error('初始化模型的算法/特征版本与当前训练不符,请重训或提供v2初始化模型')
model = model_cls.load(args.init, env=vec_env, device=device)
else:
model = model_cls(
"MlpPolicy", vec_env,
policy_kwargs={"net_arch": [128, 128]},
n_steps=512,
batch_size=128,
n_epochs=6,
gamma=1.0,
gae_lambda=0.95,
learning_rate=3e-4,
clip_range=0.2,
ent_coef=0.005,
vf_coef=0.5,
max_grad_norm=0.5,
seed=args.seed,
device=device,
verbose=args.verbose,
)
started = time.monotonic()
model.learn(total_timesteps=args.timesteps,
progress_bar=args.verbose > 0)
elapsed_s = time.monotonic() - started
model_path = Path(args.model) if args.model \
else Path("output/rl") / f"q{args.problem}_ppo.zip"
model_path.parent.mkdir(parents=True, exist_ok=True)
model.save(str(model_path))
evaluation = evaluate(model, args.problem, episodes=args.eval_episodes,
directional_probability=directional_probability,
config=config)
report = {
"problem": args.problem,
"timesteps": args.timesteps,
"seed": args.seed,
"device": device,
"directional_probability": directional_probability,
"wall_seconds": round(elapsed_s, 1),
"model": str(model_path),
"init": args.init,
"algorithm": args.algorithm,
"feature_version": 2,
"reward": 'negative_virtual_cost_div100_success20_failure100',
"gamma": 1.0,
"config": asdict(config),
"model_sha256": hashlib.sha256(model_path.read_bytes()).hexdigest(),
**evaluation,
}
report_path = model_path.with_suffix(".json")
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
vec_env.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
-27
View File
@@ -1,27 +0,0 @@
"""HRL Optuna TPE 调参入口(P2 待实现)。
搜索空间按用户 Prompt §10,但只允许在不破坏覆盖证明/证书/预算的工程参数上搜索:
粒子数、重采样阈值、信息增益权重、移动成本权重、检测成本权重、候选点距离、
覆盖网格间距、停止连续无信号次数、清除距离阈值。目标 = 平均定位清除时间 +
未清除惩罚,每个配置至少 5 个随机种子。
"""
from __future__ import annotations
import argparse
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--problem", type=int, choices=(3, 4), default=3)
parser.add_argument("--trials", type=int, default=30)
parser.add_argument("--timesteps", type=int, default=2048)
parser.add_argument("--manifest", default="output/hrl/manifests/dev.json")
parser.add_argument("--output", default="output/hrl/p2-tuning")
parser.parse_args(argv)
raise NotImplementedError(
"P2 尚未实现:调参必须建立在 P1 smoke 与稳定基准之上,"
"且不能搜索真实误差上限或破坏覆盖证明的网格参数。")
if __name__ == "__main__":
raise SystemExit(main())
-123
View File
@@ -1,123 +0,0 @@
"""Offline audit of official RL practice runs (Q3/Q4); never connects to simulator.
Checks the copied official .result.json/.jlog, our session.jsonl and summary.json:
package sha256, enter/exit, accepted timestamps inside official window, unique
request ids, clear-success channel count == official jammer_count, summary
consistency, and virtual time recomputation with integer microsecond accumulation.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
from datetime import datetime
from pathlib import Path
def save_json(path: Path, value: dict) -> None:
path.write_text(json.dumps(value, ensure_ascii=False, indent=2,
allow_nan=False) + "\n", encoding="utf-8")
def audit(folder: Path) -> dict:
records = [json.loads(line) for line in
(folder / "session.jsonl").read_text(encoding="utf-8").splitlines()]
events = [e for e in records if e.get("event") == "accepted"]
summary = json.loads((folder / "summary.json").read_text(encoding="utf-8"))
official_paths = list(folder.glob("*.result.json"))
if len(official_paths) != 1 or not events:
raise ValueError("Expected one official result and nonempty session evidence")
official = json.loads(official_paths[0].read_text(encoding="utf-8"))
package = official_paths[0].with_name(
official_paths[0].name.replace(".result.json", ".jlog"))
checks: dict = {}
checks["official_problem_matches_summary"] = (
official["problem_no"] in (3, 4)
and summary["strategy"] == ("rl" if official["problem_no"] == 3 else "rl_q4")
and 10 <= official["jammer_count"] <= 16
and official["omnidirectional_jammer_count"] + official["directional_jammer_count"]
== official["jammer_count"])
checks["official_package_sha256_matches"] = (
hashlib.sha256(package.read_bytes()).hexdigest() == official["package_sha256"])
checks["normal_enter_and_exit"] = (
events[0]["path"] == "/enter" and events[-1]["path"] == "/exit"
and events[-1]["response"].get("exit_reason") == "user_exit")
start_ms = datetime.fromisoformat(official["window_started_at_utc"]).timestamp() * 1000
end_ms = datetime.fromisoformat(official["ended_at_utc"]).timestamp() * 1000
checks["responses_within_official_run_window"] = all(
start_ms <= e["response"]["real_timestamp_ms"] <= end_ms + 1 for e in events)
ids = [e["request"]["request_id"] for e in events]
checks["unique_accepted_request_ids"] = len(ids) == len(set(ids))
clears = [e for e in events if e["path"] == "/clear"]
successes = [e for e in clears if e["response"].get("clear_result") == "success"]
cleared = sorted({e["request"]["channel"] for e in successes})
checks["all_official_sources_cleared"] = len(cleared) == official["jammer_count"]
checks["summary_completed_and_consistent"] = (
bool(summary["completed"])
and sorted(summary["cleared_channels"]) == cleared
and summary["unresolved_channels"] == []
and summary["anomalies"] == [])
position, channel, virtual_us = (0.0, 0.0), 1, 0
mismatches = 0
for event in events:
request, response, path = event["request"], event["response"], event["path"]
distance, switches, action_s = 0.0, 0, 0
if path in ("/measure", "/clear"):
target = (request["position"]["x"], request["position"]["y"])
distance = math.dist(position, target)
position = target
if path == "/measure":
switches = int(channel != request["channel"])
channel = request["channel"]
action_s = 5
else:
action_s = 5 if response["clear_result"] == "success" else 3
virtual_us += round(distance / 5 * 1_000_000) + (switches + action_s) * 1_000_000
if round(response["virtual_time_s"] * 1_000_000) != virtual_us:
mismatches += 1
checks["all_timestamps_match_microsecond_recomputation"] = mismatches == 0
real_s = (events[-1]["response"]["real_timestamp_ms"]
- events[0]["response"]["real_timestamp_ms"]) / 1000
checks["within_real_and_virtual_limits"] = (
real_s < events[0]["response"]["remaining_real_duration_s"]
and virtual_us / 1_000_000 < events[0]["response"]["max_virtual_duration_s"])
report = {
"passed": all(checks.values()),
"source": "official_practice",
"strategy": summary["strategy"],
"problem": official["problem_no"],
"case_code": official["case_code"],
"official_jammer_count": official["jammer_count"],
"directional_jammer_count": official["directional_jammer_count"],
"cleared_channels": cleared,
"accepted_requests_including_enter_exit": len(events),
"measures": len([e for e in events if e["path"] == "/measure"]),
"clear_attempts": len(clears),
"clear_successes": len(successes),
"virtual_time_s": virtual_us / 1_000_000,
"real_elapsed_server_s": real_s,
"checks": checks,
"limits": "仅验证本官方演练案例;不触碰正式测试,不解读官方加密日志内容。",
}
save_json(folder / "verification.json", report)
manifest = {p.name: {"bytes": p.stat().st_size,
"sha256": hashlib.sha256(p.read_bytes()).hexdigest()}
for p in sorted(folder.iterdir()) if p.is_file()
and p.name != "manifest.json"}
save_json(folder / "manifest.json", manifest)
return report
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("folder", type=Path)
args = parser.parse_args()
result = audit(args.folder)
print(json.dumps({"passed": result["passed"], "case_code": result["case_code"],
"problem": result["problem"], "checks": result["checks"]},
ensure_ascii=False, indent=2))
raise SystemExit(0 if result["passed"] else 1)