Download src/evaluate.py from lin267/ppo-LunarLander-cleanrl: direct link, hf CLI and curl.
- Browser
- Download file 10.1 kB
-
https://huggingface.co/lin267/ppo-LunarLander-cleanrl/resolve/main/src/evaluate.py
- Command line
-
hf download hf://lin267/ppo-LunarLander-cleanrl/src/evaluate.py
-
curl -L -o evaluate.py https://huggingface.co/lin267/ppo-LunarLander-cleanrl/resolve/main/src/evaluate.py
10.1 kB
| # -*- coding: utf-8 -*- | |
| """ | |
| 评估脚本 —— 加载训练好的模型,输出指标 + 演示视频 + 关键帧 + GIF | |
| ================================================================= | |
| 用法:: | |
| # 100 局严格评估(argmax 部署策略),并录 3 个回合的视频 | |
| python evaluate.py --model runs/main_2.5m/model_best.pth --episodes 100 \ | |
| --video-episodes 3 --out artifacts | |
| 产物: | |
| artifacts/eval_results.json 所有逐局回报 + 统计指标(含随机策略对照) | |
| artifacts/videos/replay_ep*.mp4 | |
| artifacts/keyframes.png 关键帧拼图 | |
| artifacts/replay.gif 模型卡里可直接显示的动图 | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import numpy as np | |
| import torch | |
| import gymnasium as gym | |
| from PIL import Image, ImageDraw, ImageFont | |
| from ppo import build_agent, summarize, VideoRecorder | |
| # --------------------------------------------------------------------------- # | |
| # 模型加载 | |
| # --------------------------------------------------------------------------- # | |
| def load_agent(path, device): | |
| ckpt = torch.load(path, map_location=device, weights_only=False) | |
| agent = build_agent(ckpt["obs_dim"], ckpt["action_dim"], ckpt.get("hidden", 64)).to(device) | |
| agent.load_state_dict(ckpt["state_dict"]) | |
| agent.eval() | |
| return agent, ckpt | |
| # --------------------------------------------------------------------------- # | |
| # 跑回合(agent=None 时是随机策略基线) | |
| # --------------------------------------------------------------------------- # | |
| def run_episodes(agent, env_id, device, episodes, deterministic=True, seed=1000, collect_frames=0): | |
| env = gym.make(env_id, render_mode="rgb_array" if collect_frames else None) | |
| returns, lengths, frames_episodes = [], [], [] | |
| for ep in range(episodes): | |
| obs, _ = env.reset(seed=seed + ep) | |
| done, ret, ln = False, 0.0, 0 | |
| frames = [] | |
| while not done: | |
| if collect_frames and ep < collect_frames: | |
| f = env.render() | |
| if f is not None: | |
| frames.append(np.asarray(f, dtype=np.uint8)) | |
| t = torch.as_tensor(obs, dtype=torch.float32, device=device).unsqueeze(0) | |
| if agent is None: | |
| action = int(env.action_space.sample()) | |
| elif deterministic: | |
| action = int(torch.argmax(agent.actor(t), dim=-1).item()) | |
| else: | |
| action = int(agent.get_action_and_value(t)[0].item()) | |
| obs, r, terminated, truncated, _ = env.step(action) | |
| ret += float(r) | |
| ln += 1 | |
| done = bool(terminated or truncated) | |
| returns.append(ret) | |
| lengths.append(ln) | |
| if collect_frames and ep < collect_frames: | |
| frames_episodes.append(frames) | |
| env.close() | |
| return returns, lengths, frames_episodes | |
| # --------------------------------------------------------------------------- # | |
| # 可视化小工具 | |
| # --------------------------------------------------------------------------- # | |
| def _font(size): | |
| for name in ("arial.ttf", "segoeui.ttf", "msyh.ttc"): | |
| try: | |
| return ImageFont.truetype(name, size) | |
| except Exception: | |
| continue | |
| return ImageFont.load_default() | |
| def make_keyframes(frames_list, path, title="LunarLander PPO — evaluation keyframes"): | |
| """把若干个回合均匀抽帧,拼成 2×3 关键帧图。""" | |
| if not frames_list: | |
| return None | |
| # 每个回合取中间均匀 3 帧,最多 6 帧 | |
| picks = [] | |
| for frames in frames_list: | |
| if not frames: | |
| continue | |
| idxs = np.linspace(0, len(frames) - 1, 3).astype(int) | |
| picks.extend([frames[i] for i in idxs]) | |
| picks = picks[:6] | |
| if not picks: | |
| return None | |
| cell_w = 400 | |
| h0, w0 = picks[0].shape[:2] | |
| cell_h = int(h0 * cell_w / w0) | |
| cols, rows = 3, 2 | |
| pad, top = 12, 56 | |
| W = cols * cell_w + (cols + 1) * pad | |
| H = top + rows * (cell_h + 28) + pad | |
| canvas = Image.new("RGB", (W, H), (24, 26, 32)) | |
| draw = ImageDraw.Draw(canvas) | |
| draw.text((pad, 16), title, font=_font(22), fill=(240, 240, 240)) | |
| for i, f in enumerate(picks): | |
| img = Image.fromarray(f).resize((cell_w, cell_h), Image.LANCZOS) | |
| r, c = divmod(i, cols) | |
| x = pad + c * (cell_w + pad) | |
| y = top + r * (cell_h + 28) | |
| canvas.paste(img, (x, y)) | |
| draw.text((x + 4, y + cell_h + 4), f"frame {i + 1}", font=_font(14), fill=(180, 180, 180)) | |
| canvas.save(path) | |
| return path | |
| def make_gif(frames, path, scale=0.5, stride=2, duration=40): | |
| """把回合帧序列压成 gif(便于在模型卡里直接显示)。""" | |
| if not frames: | |
| return None | |
| small = [Image.fromarray(f).resize( | |
| (int(f.shape[1] * scale), int(f.shape[0] * scale)), Image.LANCZOS) for f in frames[::stride]] | |
| if not small: | |
| return None | |
| small[0].save(path, save_all=True, append_images=small[1:], | |
| duration=duration, loop=0, optimize=True) | |
| return path | |
| # --------------------------------------------------------------------------- # | |
| # 主流程 | |
| # --------------------------------------------------------------------------- # | |
| def main(): | |
| p = argparse.ArgumentParser(description="评估 LunarLander PPO 智能体") | |
| p.add_argument("--model", type=str, default="runs/main_2.5m/model_best.pth") | |
| p.add_argument("--env-id", type=str, default="LunarLander-v3") | |
| p.add_argument("--episodes", type=int, default=100, help="评估回合数(官方标准 100 局)") | |
| p.add_argument("--seed", type=int, default=2024) | |
| p.add_argument("--stochastic", action="store_true", help="用随机策略而不是 argmax") | |
| p.add_argument("--video-episodes", type=int, default=3, help="录几个回合的视频") | |
| p.add_argument("--out", type=str, default="artifacts", help="输出目录") | |
| p.add_argument("--baseline-random", action="store_true", default=True, | |
| help="同时评估随机策略作为对照") | |
| p.add_argument("--no-baseline", dest="baseline_random", action="store_false") | |
| p.add_argument("--torch-threads", type=int, default=8) | |
| args = p.parse_args() | |
| torch.set_num_threads(args.torch_threads) | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| os.makedirs(args.out, exist_ok=True) | |
| os.makedirs(os.path.join(args.out, "videos"), exist_ok=True) | |
| agent, ckpt = load_agent(args.model, device) | |
| print(f"[评估] 模型: {args.model}") | |
| print(f"[评估] 训练信息: iteration={ckpt.get('iteration')} " | |
| f"step={ckpt.get('global_step')} eval_mean={ckpt.get('eval_mean')}") | |
| print(f"[评估] 环境={args.env_id} 回合数={args.episodes} " | |
| f"策略={'随机采样' if args.stochastic else 'argmax(确定性)'}") | |
| # ---- 主评估 ---- | |
| returns, lengths, frames_list = run_episodes( | |
| agent, args.env_id, device, args.episodes, | |
| deterministic=not args.stochastic, seed=args.seed, | |
| collect_frames=max(args.video_episodes, 2), | |
| ) | |
| ppo_stats = summarize(returns) | |
| print(f"\n[结果] PPO 智能体 {args.episodes} 局: " | |
| f"平均={ppo_stats['mean']:.2f} ± {ppo_stats['std']:.2f} | " | |
| f"中位数={ppo_stats['median']:.1f} | min={ppo_stats['min']:.1f} max={ppo_stats['max']:.1f} | " | |
| f"着陆率(>=200)={ppo_stats['landing_rate']*100:.0f}%") | |
| # ---- 随机策略对照 ---- | |
| random_stats = None | |
| if args.baseline_random: | |
| r_returns, r_lengths, _ = run_episodes( | |
| None, args.env_id, device, args.episodes, | |
| deterministic=False, seed=args.seed, collect_frames=0) | |
| random_stats = summarize(r_returns) | |
| print(f"[对照] 随机策略 {args.episodes} 局: " | |
| f"平均={random_stats['mean']:.2f} ± {random_stats['std']:.2f} | " | |
| f"着陆率={random_stats['landing_rate']*100:.0f}%") | |
| # ---- 录像 ---- | |
| videos, gif_path = [], None | |
| if args.video_episodes > 0: | |
| n_vid = min(args.video_episodes, len(frames_list)) | |
| for i in range(n_vid): | |
| path = os.path.join(args.out, "videos", f"replay_ep{i + 1}.mp4") | |
| rec = VideoRecorder(path, fps=30) | |
| for f in frames_list[i]: | |
| rec.add(f) | |
| rec.save() | |
| videos.append({"path": path, "episode_return": returns[i], | |
| "episode_length": lengths[i], "frames": len(frames_list[i])}) | |
| print(f"[录像] {path} (回报 {returns[i]:.1f}, {lengths[i]} 步)") | |
| # 选回报最高的一局做 gif | |
| best_i = int(np.argmax(returns[:len(frames_list)])) | |
| gif_path = make_gif(frames_list[best_i], os.path.join(args.out, "replay.gif")) | |
| print(f"[动图] {gif_path}") | |
| keyframes = make_keyframes(frames_list, os.path.join(args.out, "keyframes.png")) | |
| # ---- 保存结果 ---- | |
| result = { | |
| "model": os.path.abspath(args.model), | |
| "env_id": args.env_id, | |
| "policy": "stochastic" if args.stochastic else "argmax(deterministic)", | |
| "episodes": args.episodes, | |
| "seed": args.seed, | |
| "solved_threshold": 200.0, | |
| "solved": bool(ppo_stats["mean"] >= 200.0), | |
| "ppo": {**ppo_stats, "returns": [float(x) for x in returns], | |
| "lengths": [int(x) for x in lengths]}, | |
| "random_baseline": None if random_stats is None else | |
| {**random_stats, "returns": [float(x) for x in r_returns]}, | |
| "videos": videos, | |
| "checkpoint": {k: ckpt.get(k) for k in ("iteration", "global_step", "eval_mean", "hidden")}, | |
| } | |
| out_json = os.path.join(args.out, "eval_results.json") | |
| with open(out_json, "w", encoding="utf-8") as f: | |
| json.dump(result, f, indent=2, ensure_ascii=False) | |
| print(f"\n[完成] 指标 -> {out_json}") | |
| if random_stats is not None: | |
| gain = ppo_stats["mean"] - random_stats["mean"] | |
| print(f"[对比] 随机 {random_stats['mean']:.1f} -> PPO {ppo_stats['mean']:.1f} " | |
| f"(提升 {gain:+.1f} 分)") | |
| if __name__ == "__main__": | |
| main() | |