Files
2026-09-12 01:56:34 +08:00

196 lines
7.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""问题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())