Download src/ppo.py from lin267/ppo-LunarLander-cleanrl: direct link, hf CLI and curl.
- Browser
- Download file 27.7 kB
-
https://huggingface.co/lin267/ppo-LunarLander-cleanrl/resolve/main/src/ppo.py
- Command line
-
hf download hf://lin267/ppo-LunarLander-cleanrl/src/ppo.py
-
curl -L -o ppo.py https://huggingface.co/lin267/ppo-LunarLander-cleanrl/resolve/main/src/ppo.py
27.7 kB
| # -*- coding: utf-8 -*- | |
| """ | |
| PPO 训练脚本 —— LunarLander(第 4 周 · 第一个智能体) | |
| ===================================================== | |
| 对应课件《第4周-强化学习进阶实践内容》第 2~14 页:用 PPO 训练一个月球着陆器智能体。 | |
| 与第一周(Stable-Baselines3 现成库)不同,本脚本**从零手写 PPO 的全部核心组件**, | |
| 每个组件都能在代码里直接看到: | |
| 1. Actor-Critic 网络 —— get_action_and_value() | |
| 2. rollout 采样 —— 与环境交互收集经验 | |
| 3. GAE 广义优势估计 —— compute_gae() | |
| 4. PPO-Clip 损失 —— 策略/价值/熵三项加权 | |
| 5. 评估、存盘、视频录制、日志 | |
| 快速开始 | |
| -------- | |
| 冒烟测试(约 20 秒):: | |
| python ppo.py --total-timesteps 16384 --num-envs 8 --eval-interval 5 \ | |
| --eval-episodes 4 --out-dir runs --run-name smoke | |
| 正式训练(CPU 约 15~20 分钟,可达到官方 "solved" 标准 200 分):: | |
| python ppo.py --total-timesteps 2500000 --num-envs 16 --num-steps 256 \ | |
| --gamma 0.999 --gae-lambda 0.98 --num-minibatches 8 --update-epochs 10 \ | |
| --eval-interval 15 --eval-episodes 16 --capture-video \ | |
| --out-dir runs --run-name main_2.5m | |
| 复现课件命令(5 万步,会失败——这正是课件想演示的"步数不够"):: | |
| python ppo.py --total-timesteps 50000 --num-envs 4 --num-steps 128 \ | |
| --out-dir runs --run-name baseline_50k | |
| """ | |
| import argparse | |
| import csv | |
| import json | |
| import os | |
| import random | |
| import time | |
| from collections import deque | |
| import numpy as np | |
| import torch | |
| import torch.nn as nn | |
| import torch.optim as optim | |
| from torch.distributions.categorical import Categorical | |
| import gymnasium as gym | |
| # =========================================================================== # | |
| # 1. 基础工具 | |
| # =========================================================================== # | |
| def layer_init(layer, std=np.sqrt(2), bias_const=0.0): | |
| """正交初始化(PPO 的常规做法,训练更稳定)。""" | |
| nn.init.orthogonal_(layer.weight, std) | |
| nn.init.constant_(layer.bias, bias_const) | |
| return layer | |
| def make_env(env_id, seed, idx, render_mode=None): | |
| """创建一个环境;用 seed+idx 保证每个并行环境轨迹不同但可复现。""" | |
| return gym.make(env_id, render_mode=render_mode) | |
| # =========================================================================== # | |
| # 2. 向量化环境(手写,行为透明) | |
| # =========================================================================== # | |
| class VecEnv: | |
| """N 个环境并排前进(gymnasium API:reset -> (obs, info), step -> 5 元组)。""" | |
| def __init__(self, envs): | |
| self.envs = envs | |
| self.num_envs = len(envs) | |
| self.episode_returns = deque(maxlen=200) | |
| self.episode_lengths = deque(maxlen=200) | |
| self._cur_ret = np.zeros(self.num_envs, dtype=np.float64) | |
| self._cur_len = np.zeros(self.num_envs, dtype=np.int64) | |
| self.obs_dim = int(np.prod(envs[0].observation_space.shape)) | |
| self.action_dim = int(envs[0].action_space.n) | |
| def reset(self, seeds=None): | |
| obs = np.zeros((self.num_envs, self.obs_dim), dtype=np.float32) | |
| for i, env in enumerate(self.envs): | |
| seed = None if seeds is None else int(seeds[i]) | |
| o, _ = env.reset(seed=seed) | |
| obs[i] = np.asarray(o, dtype=np.float32).ravel() | |
| self._cur_ret[i] = 0.0 | |
| self._cur_len[i] = 0 | |
| return obs | |
| def step(self, actions): | |
| """同时推进一步;回合结束后自动 reset(不重新播种,保留随机性)。""" | |
| obs = np.zeros((self.num_envs, self.obs_dim), dtype=np.float32) | |
| rewards = np.zeros(self.num_envs, dtype=np.float32) | |
| dones = np.zeros(self.num_envs, dtype=np.float32) | |
| infos = [] | |
| for i, env in enumerate(self.envs): | |
| o, r, terminated, truncated, info = env.step(int(actions[i])) | |
| done = bool(terminated or truncated) | |
| self._cur_ret[i] += r | |
| self._cur_len[i] += 1 | |
| info = dict(info) if isinstance(info, dict) else {} | |
| if done: | |
| info["episode_return"] = float(self._cur_ret[i]) | |
| info["episode_length"] = int(self._cur_len[i]) | |
| self.episode_returns.append(info["episode_return"]) | |
| self.episode_lengths.append(info["episode_length"]) | |
| self._cur_ret[i] = 0.0 | |
| self._cur_len[i] = 0 | |
| o, _ = env.reset() | |
| obs[i] = np.asarray(o, dtype=np.float32).ravel() | |
| rewards[i] = r | |
| dones[i] = float(done) | |
| infos.append(info) | |
| return obs, rewards, dones, infos | |
| def close(self): | |
| for env in self.envs: | |
| try: | |
| env.close() | |
| except Exception: | |
| pass | |
| # =========================================================================== # | |
| # 3. Video:用 imageio + imageio-ffmpeg 直接写 mp4(不依赖 moviepy) | |
| # =========================================================================== # | |
| class VideoRecorder: | |
| def __init__(self, path, fps=30): | |
| self.path = path | |
| self.fps = fps | |
| self.frames = [] | |
| def add(self, frame): | |
| if frame is not None: | |
| self.frames.append(np.asarray(frame, dtype=np.uint8)) | |
| def save(self): | |
| if not self.frames: | |
| return None | |
| import imageio | |
| os.makedirs(os.path.dirname(os.path.abspath(self.path)), exist_ok=True) | |
| writer = imageio.get_writer(self.path, fps=self.fps, codec="libx264", | |
| quality=8, macro_block_size=1) | |
| for frame in self.frames: | |
| writer.append_data(frame) | |
| writer.close() | |
| return self.path | |
| # =========================================================================== # | |
| # 4. Actor-Critic 网络(课件第 12 页 get_action_and_value 的完整实现) | |
| # =========================================================================== # | |
| def build_agent(obs_dim, action_dim, hidden=64): | |
| class Agent(nn.Module): | |
| def __init__(self): | |
| super().__init__() | |
| # Critic:估计状态价值 V(s) | |
| self.critic = nn.Sequential( | |
| layer_init(nn.Linear(obs_dim, hidden)), nn.Tanh(), | |
| layer_init(nn.Linear(hidden, hidden)), nn.Tanh(), | |
| layer_init(nn.Linear(hidden, 1), std=1.0), | |
| ) | |
| # Actor:输出每个动作的 logits | |
| self.actor = nn.Sequential( | |
| layer_init(nn.Linear(obs_dim, hidden)), nn.Tanh(), | |
| layer_init(nn.Linear(hidden, hidden)), nn.Tanh(), | |
| layer_init(nn.Linear(hidden, action_dim), std=0.01), | |
| ) | |
| def get_value(self, x): | |
| return self.critic(x) | |
| def get_action_and_value(self, x, action=None): | |
| """给定状态 -> (动作, 对数概率, 熵, 状态价值)。""" | |
| logits = self.actor(x) | |
| probs = Categorical(logits=logits) | |
| if action is None: | |
| action = probs.sample() # 训练采样 | |
| value = self.critic(x).view(-1) | |
| return action, probs.log_prob(action), probs.entropy(), value | |
| return Agent() | |
| # =========================================================================== # | |
| # 5. GAE(课件第 13 页公式的完整实现) | |
| # =========================================================================== # | |
| def compute_gae(rewards, values, dones, next_done, next_value, gamma, gae_lambda, num_steps): | |
| advantages = torch.zeros_like(rewards) | |
| lastgaelam = 0 | |
| for t in reversed(range(num_steps)): | |
| if t == num_steps - 1: | |
| nextnonterminal = 1.0 - next_done | |
| nextvalues = next_value | |
| else: | |
| nextnonterminal = 1.0 - dones[t + 1] | |
| nextvalues = values[t + 1] | |
| # TD 残差 | |
| delta = rewards[t] + gamma * nextvalues * nextnonterminal - values[t] | |
| # 指数加权平均 | |
| advantages[t] = lastgaelam = delta + gamma * gae_lambda * nextnonterminal * lastgaelam | |
| returns = advantages + values | |
| return advantages, returns | |
| # =========================================================================== # | |
| # 6. 评估与录像 | |
| # =========================================================================== # | |
| def evaluate(agent, env_id, device, episodes=10, deterministic=False, seed=12345): | |
| """跑若干完整回合,返回 (回报列表, 回合长度列表)。""" | |
| env = gym.make(env_id) | |
| returns, lengths = [], [] | |
| for ep in range(episodes): | |
| obs, _ = env.reset(seed=seed + ep) | |
| done, ret, ln = False, 0.0, 0 | |
| while not done: | |
| obs_t = torch.as_tensor(obs, dtype=torch.float32, device=device).unsqueeze(0) | |
| if deterministic: | |
| action = int(torch.argmax(agent.actor(obs_t), dim=-1).item()) | |
| else: | |
| action = int(agent.get_action_and_value(obs_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) | |
| env.close() | |
| return returns, lengths | |
| def record_episode(agent, env_id, device, path, seed=999, deterministic=True, fps=30): | |
| """录制一个完整回合为 mp4,返回 {episode_return, episode_length, frames, video}。""" | |
| env = gym.make(env_id, render_mode="rgb_array") | |
| obs, _ = env.reset(seed=seed) | |
| rec = VideoRecorder(path, fps=fps) | |
| done, ret, ln = False, 0.0, 0 | |
| while not done and ln < 4000: | |
| frame = env.render() | |
| if frame is not None: | |
| rec.add(frame) | |
| obs_t = torch.as_tensor(obs, dtype=torch.float32, device=device).unsqueeze(0) | |
| if deterministic: | |
| action = int(torch.argmax(agent.actor(obs_t), dim=-1).item()) | |
| else: | |
| action = int(agent.get_action_and_value(obs_t)[0].item()) | |
| obs, r, terminated, truncated, _ = env.step(action) | |
| ret += float(r) | |
| ln += 1 | |
| done = bool(terminated or truncated) | |
| env.close() | |
| rec.save() | |
| return {"episode_return": ret, "episode_length": ln, "frames": len(rec.frames), "video": path} | |
| def summarize(returns): | |
| arr = np.asarray(returns, dtype=np.float64) | |
| return { | |
| "episodes": int(arr.size), | |
| "mean": float(arr.mean()), | |
| "std": float(arr.std()), | |
| "min": float(arr.min()), | |
| "max": float(arr.max()), | |
| "median": float(np.median(arr)), | |
| "landing_rate": float(np.mean(arr >= 200.0)), # 官方 solved 标准:平均 >= 200 | |
| "p25": float(np.percentile(arr, 25)), | |
| "p75": float(np.percentile(arr, 75)), | |
| } | |
| # =========================================================================== # | |
| # 7. 参数 | |
| # =========================================================================== # | |
| def parse_args(): | |
| p = argparse.ArgumentParser(description="从零手写 PPO 训练 LunarLander") | |
| # ---- 实验管理 ---- | |
| p.add_argument("--env-id", type=str, default="LunarLander-v3", | |
| help="环境 ID(gymnasium 中 LunarLander 只有 v3)") | |
| p.add_argument("--seed", type=int, default=1, help="随机种子(默认 1)") | |
| p.add_argument("--total-timesteps", type=int, default=1_000_000, help="总训练步数") | |
| p.add_argument("--out-dir", type=str, default="runs", help="结果根目录") | |
| p.add_argument("--run-name", type=str, default="", help="本次实验名(默认自动生成)") | |
| p.add_argument("--no-upload", action="store_true", default=False, | |
| help="不上传 HuggingFace(上传用 upload_to_hf.py)") | |
| p.add_argument("--repo-id", type=str, default=None, help="HuggingFace 仓库 ID(可选)") | |
| # ---- 环境 ---- | |
| p.add_argument("--num-envs", type=int, default=8, help="并行环境数(默认 8)") | |
| p.add_argument("--num-steps", type=int, default=128, help="每个环境每次采样多少步") | |
| p.add_argument("--capture-video", action="store_true", help="录制训练过程中的学习视频") | |
| # ---- PPO 超参数 ---- | |
| p.add_argument("--learning-rate", type=float, default=2.5e-4, help="学习率(默认 2.5e-4)") | |
| p.add_argument("--anneal-lr", action="store_true", default=True, help="学习率线性衰减") | |
| p.add_argument("--no-anneal-lr", dest="anneal_lr", action="store_false") | |
| p.add_argument("--gamma", type=float, default=0.99, help="折扣因子") | |
| p.add_argument("--gae-lambda", type=float, default=0.95, help="GAE lambda") | |
| p.add_argument("--num-minibatches", type=int, default=4, help="mini-batch 数量") | |
| p.add_argument("--update-epochs", type=int, default=4, help="每个 rollout 更新几次") | |
| p.add_argument("--norm-adv", action="store_true", default=True, help="优势标准化") | |
| p.add_argument("--no-norm-adv", dest="norm_adv", action="store_false") | |
| p.add_argument("--clip-coef", type=float, default=0.2, help="PPO 裁剪系数") | |
| p.add_argument("--clip-vloss", action="store_true", default=True, help="价值函数裁剪") | |
| p.add_argument("--no-clip-vloss", dest="clip_vloss", action="store_false") | |
| p.add_argument("--ent-coef", type=float, default=0.01, help="熵奖励系数") | |
| p.add_argument("--vf-coef", type=float, default=0.5, help="价值损失系数") | |
| p.add_argument("--max-grad-norm", type=float, default=0.5, help="梯度裁剪范数") | |
| p.add_argument("--hidden", type=int, default=64, help="隐藏层宽度") | |
| # ---- 评估与日志 ---- | |
| p.add_argument("--eval-interval", type=int, default=20, help="每多少次 update 评估一次") | |
| p.add_argument("--eval-episodes", type=int, default=10, help="每次评估跑多少回合") | |
| p.add_argument("--eval-greedy", action="store_true", default=True, | |
| help="评估用 argmax(部署策略);训练采样仍用随机策略") | |
| p.add_argument("--eval-stochastic", dest="eval_greedy", action="store_false") | |
| p.add_argument("--save-interval", type=int, default=50, help="每多少次 update 存一次盘") | |
| p.add_argument("--log-interval", type=int, default=10, help="控制台打印间隔") | |
| p.add_argument("--torch-threads", type=int, default=8, help="torch 线程数") | |
| return p.parse_args() | |
| # =========================================================================== # | |
| # 8. 主训练循环 | |
| # =========================================================================== # | |
| def main(): | |
| args = parse_args() | |
| torch.set_num_threads(args.torch_threads) | |
| random.seed(args.seed) | |
| np.random.seed(args.seed) | |
| torch.manual_seed(args.seed) | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| run_name = args.run_name or f"PPO-{args.env_id}-{int(time.time())}" | |
| run_dir = os.path.join(args.out_dir, run_name) | |
| os.makedirs(run_dir, exist_ok=True) | |
| with open(os.path.join(run_dir, "config.json"), "w", encoding="utf-8") as f: | |
| json.dump(vars(args), f, indent=2, ensure_ascii=False) | |
| # ---- 环境与网络 ---- | |
| envs = [make_env(args.env_id, args.seed, i) for i in range(args.num_envs)] | |
| vec = VecEnv(envs) | |
| obs_dim, action_dim = vec.obs_dim, vec.action_dim | |
| agent = build_agent(obs_dim, action_dim, args.hidden).to(device) | |
| optimizer = optim.Adam(agent.parameters(), lr=args.learning_rate, eps=1e-5) | |
| batch_size = args.num_envs * args.num_steps | |
| minibatch_size = batch_size // args.num_minibatches | |
| num_iterations = max(1, args.total_timesteps // batch_size) | |
| print(f"[配置] 环境={args.env_id} 设备={device} 并行环境={args.num_envs} " | |
| f"每轮步数={batch_size} 迭代={num_iterations} 总步数={args.total_timesteps}") | |
| print(f"[配置] obs_dim={obs_dim} action_dim={action_dim} minibatch={minibatch_size}") | |
| # ---- 缓冲区 ---- | |
| obs_buf = torch.zeros((args.num_steps, args.num_envs, obs_dim), dtype=torch.float32, device=device) | |
| act_buf = torch.zeros((args.num_steps, args.num_envs), dtype=torch.float32, device=device) | |
| logp_buf = torch.zeros((args.num_steps, args.num_envs), dtype=torch.float32, device=device) | |
| rew_buf = torch.zeros((args.num_steps, args.num_envs), dtype=torch.float32, device=device) | |
| done_buf = torch.zeros((args.num_steps, args.num_envs), dtype=torch.float32, device=device) | |
| val_buf = torch.zeros((args.num_steps, args.num_envs), dtype=torch.float32, device=device) | |
| next_obs = torch.as_tensor(vec.reset(seeds=[args.seed + i for i in range(args.num_envs)]), | |
| dtype=torch.float32, device=device) | |
| next_done = torch.zeros(args.num_envs, dtype=torch.float32, device=device) | |
| # ---- 日志文件 ---- | |
| train_csv = open(os.path.join(run_dir, "train_log.csv"), "w", newline="", encoding="utf-8") | |
| train_writer = csv.writer(train_csv) | |
| train_writer.writerow(["iteration", "global_step", "sps", "episodes_in_window", | |
| "mean_return", "std_return", "min_return", "max_return", | |
| "mean_ep_length", "policy_loss", "value_loss", "entropy", | |
| "approx_kl", "clipfrac", "learning_rate"]) | |
| eval_csv = open(os.path.join(run_dir, "eval_log.csv"), "w", newline="", encoding="utf-8") | |
| eval_writer = csv.writer(eval_csv) | |
| eval_writer.writerow(["iteration", "global_step", "eval_mean", "eval_std", | |
| "eval_min", "eval_max", "landing_rate"]) | |
| video_points = set() | |
| if args.capture_video: | |
| video_points = {max(1, int(num_iterations * p)) for p in (0.1, 0.25, 0.4, 0.55, 0.7, 0.85, 1.0)} | |
| global_step = 0 | |
| t0 = time.time() | |
| best_eval = -float("inf") | |
| recent_returns = deque(maxlen=100) | |
| for iteration in range(1, num_iterations + 1): | |
| # ---------- 学习率衰减 ---------- | |
| if args.anneal_lr: | |
| frac = 1.0 - (iteration - 1.0) / num_iterations | |
| lr_now = frac * args.learning_rate | |
| optimizer.param_groups[0]["lr"] = lr_now | |
| else: | |
| lr_now = args.learning_rate | |
| # ---------- 采样 ---------- | |
| step_returns = [] | |
| for t in range(args.num_steps): | |
| global_step += args.num_envs | |
| obs_buf[t] = next_obs | |
| done_buf[t] = next_done | |
| with torch.no_grad(): | |
| action, logprob, _, value = agent.get_action_and_value(next_obs) | |
| val_buf[t] = value.flatten() | |
| act_buf[t] = action.float() | |
| logp_buf[t] = logprob | |
| next_obs_np, reward, done, infos = vec.step(action.cpu().numpy()) | |
| rew_buf[t] = torch.as_tensor(reward, dtype=torch.float32, device=device) | |
| next_obs = torch.as_tensor(next_obs_np, dtype=torch.float32, device=device) | |
| next_done = torch.as_tensor(done, dtype=torch.float32, device=device) | |
| for info in infos: | |
| if "episode_return" in info: | |
| step_returns.append(info["episode_return"]) | |
| recent_returns.append(info["episode_return"]) | |
| # ---------- GAE ---------- | |
| with torch.no_grad(): | |
| next_value = agent.get_value(next_obs).reshape(1, -1) | |
| advantages, returns = compute_gae( | |
| rew_buf, val_buf, done_buf, next_done, next_value, | |
| args.gamma, args.gae_lambda, args.num_steps, | |
| ) | |
| b_obs = obs_buf.reshape((-1, obs_dim)) | |
| b_logprobs = logp_buf.reshape(-1) | |
| b_actions = act_buf.reshape(-1) | |
| b_advantages = advantages.reshape(-1) | |
| b_returns = returns.reshape(-1) | |
| b_values = val_buf.reshape(-1) | |
| # ---------- PPO 更新 ---------- | |
| b_inds = np.arange(batch_size) | |
| clipfracs = [] | |
| pg_loss = torch.tensor(0.0) | |
| v_loss = torch.tensor(0.0) | |
| entropy = torch.tensor(0.0) | |
| approx_kl = torch.tensor(0.0) | |
| for _ in range(args.update_epochs): | |
| np.random.shuffle(b_inds) | |
| for start in range(0, batch_size, minibatch_size): | |
| mb = b_inds[start:start + minibatch_size] | |
| _, newlogprob, newentropy, newvalue = agent.get_action_and_value( | |
| b_obs[mb], b_actions.long()[mb]) | |
| logratio = newlogprob - b_logprobs[mb] | |
| ratio = logratio.exp() | |
| with torch.no_grad(): | |
| approx_kl = ((ratio - 1) - logratio).mean() | |
| clipfracs.append(((ratio - 1.0).abs() > args.clip_coef).float().mean().item()) | |
| mb_adv = b_advantages[mb] | |
| if args.norm_adv: | |
| mb_adv = (mb_adv - mb_adv.mean()) / (mb_adv.std() + 1e-8) | |
| # ① 策略损失(PPO-Clip) | |
| pg1 = -mb_adv * ratio | |
| pg2 = -mb_adv * torch.clamp(ratio, 1 - args.clip_coef, 1 + args.clip_coef) | |
| pg_loss = torch.max(pg1, pg2).mean() | |
| # ② 价值损失(可选裁剪) | |
| newvalue = newvalue.view(-1) | |
| if args.clip_vloss: | |
| v_unclipped = (newvalue - b_returns[mb]) ** 2 | |
| v_clipped = b_values[mb] + torch.clamp( | |
| newvalue - b_values[mb], -args.clip_coef, args.clip_coef) | |
| v_loss = 0.5 * torch.max(v_unclipped, (v_clipped - b_returns[mb]) ** 2).mean() | |
| else: | |
| v_loss = 0.5 * ((newvalue - b_returns[mb]) ** 2).mean() | |
| # ③ 熵奖励 | |
| entropy = newentropy.mean() | |
| # ④ 总损失 | |
| loss = pg_loss - args.ent_coef * entropy + args.vf_coef * v_loss | |
| optimizer.zero_grad() | |
| loss.backward() | |
| nn.utils.clip_grad_norm_(agent.parameters(), args.max_grad_norm) | |
| optimizer.step() | |
| # ---------- 日志 ---------- | |
| sps = int(global_step / max(1e-9, time.time() - t0)) | |
| mean_ret = float(np.mean(step_returns)) if step_returns else float("nan") | |
| std_ret = float(np.std(step_returns)) if step_returns else float("nan") | |
| min_ret = float(np.min(step_returns)) if step_returns else float("nan") | |
| max_ret = float(np.max(step_returns)) if step_returns else float("nan") | |
| mean_len = float(np.mean(vec.episode_lengths)) if len(vec.episode_lengths) else float("nan") | |
| train_writer.writerow([ | |
| iteration, global_step, sps, len(step_returns), | |
| f"{mean_ret:.3f}", f"{std_ret:.3f}", f"{min_ret:.3f}", f"{max_ret:.3f}", | |
| f"{mean_len:.1f}", f"{float(pg_loss.detach()):.5f}", f"{float(v_loss.detach()):.5f}", | |
| f"{float(entropy.detach()):.5f}", f"{float(approx_kl.detach()):.5f}", | |
| f"{float(np.mean(clipfracs)):.4f}", f"{lr_now:.6g}", | |
| ]) | |
| train_csv.flush() | |
| # ---------- 定期评估 ---------- | |
| eval_stats = None | |
| if iteration % args.eval_interval == 0 or iteration == num_iterations: | |
| rets, _ = evaluate(agent, args.env_id, device, | |
| episodes=args.eval_episodes, | |
| deterministic=args.eval_greedy, | |
| seed=int(args.seed * 1000 + iteration)) | |
| eval_stats = summarize(rets) | |
| eval_writer.writerow([ | |
| iteration, global_step, | |
| f"{eval_stats['mean']:.2f}", f"{eval_stats['std']:.2f}", | |
| f"{eval_stats['min']:.2f}", f"{eval_stats['max']:.2f}", | |
| f"{eval_stats['landing_rate']:.3f}", | |
| ]) | |
| eval_csv.flush() | |
| if eval_stats["mean"] > best_eval: | |
| best_eval = eval_stats["mean"] | |
| torch.save({ | |
| "state_dict": agent.state_dict(), "obs_dim": obs_dim, | |
| "action_dim": action_dim, "hidden": args.hidden, | |
| "iteration": iteration, "global_step": global_step, | |
| "eval_mean": eval_stats["mean"], "config": vars(args), | |
| "eval_stats": eval_stats, | |
| }, os.path.join(run_dir, "model_best.pth")) | |
| if iteration % args.log_interval == 0 or iteration == 1 or iteration == num_iterations: | |
| ev = "" if eval_stats is None else f" | eval={eval_stats['mean']:.1f}" | |
| print(f"[{iteration:4d}/{num_iterations}] step={global_step:8d} " | |
| f"train_ret={mean_ret:8.2f} ep={len(step_returns):3d} " | |
| f"len={mean_len:6.1f} pg={float(pg_loss.detach()):7.4f} " | |
| f"vl={float(v_loss.detach()):7.4f} " | |
| f"ent={float(entropy.detach()):6.3f} sps={sps:5d}{ev}", flush=True) | |
| # ---------- 存盘 ---------- | |
| if iteration % args.save_interval == 0 or iteration == num_iterations: | |
| torch.save({ | |
| "state_dict": agent.state_dict(), "obs_dim": obs_dim, | |
| "action_dim": action_dim, "hidden": args.hidden, | |
| "iteration": iteration, "global_step": global_step, | |
| "config": vars(args), | |
| }, os.path.join(run_dir, "model_ckpt.pth")) | |
| # ---------- 学习过程视频 ---------- | |
| if args.capture_video and iteration in video_points: | |
| path = os.path.join(run_dir, "videos", f"learning_{global_step:08d}.mp4") | |
| try: | |
| info = record_episode(agent, args.env_id, device, path, | |
| seed=args.seed * 7 + iteration, deterministic=True) | |
| print(f" [视频] step={global_step} 回报={info['episode_return']:.1f} -> {path}", flush=True) | |
| except Exception as e: | |
| print(f" [视频] 失败: {e}", flush=True) | |
| # ---------- 收尾 ---------- | |
| torch.save({ | |
| "state_dict": agent.state_dict(), "obs_dim": obs_dim, | |
| "action_dim": action_dim, "hidden": args.hidden, | |
| "iteration": num_iterations, "global_step": global_step, | |
| "config": vars(args), | |
| }, os.path.join(run_dir, "model_final.pth")) | |
| final_rets, _ = evaluate(agent, args.env_id, device, | |
| episodes=min(50, max(10, args.eval_episodes)), | |
| deterministic=True, seed=args.seed * 999 + 7) | |
| summary = { | |
| "run_name": run_name, | |
| "run_dir": run_dir, | |
| "env_id": args.env_id, | |
| "seed": args.seed, | |
| "total_timesteps": args.total_timesteps, | |
| "num_envs": args.num_envs, | |
| "num_steps": args.num_steps, | |
| "gamma": args.gamma, | |
| "gae_lambda": args.gae_lambda, | |
| "num_minibatches": args.num_minibatches, | |
| "update_epochs": args.update_epochs, | |
| "learning_rate": args.learning_rate, | |
| "device": str(device), | |
| "train_seconds": round(time.time() - t0, 1), | |
| "final_eval": summarize(final_rets), | |
| "best_eval_mean": best_eval, | |
| "train_mean_return_last100": float(np.mean(recent_returns)) if recent_returns else None, | |
| } | |
| with open(os.path.join(run_dir, "summary.json"), "w", encoding="utf-8") as f: | |
| json.dump(summary, f, indent=2, ensure_ascii=False) | |
| train_csv.close() | |
| eval_csv.close() | |
| vec.close() | |
| print(f"\n[完成] 用时 {summary['train_seconds']/60:.1f} 分钟 | " | |
| f"最终评估({summary['final_eval']['episodes']}局,argmax) = " | |
| f"{summary['final_eval']['mean']:.2f} ± {summary['final_eval']['std']:.2f} | " | |
| f"着陆率 {summary['final_eval']['landing_rate']*100:.0f}%") | |
| print(f"[完成] 结果目录: {run_dir}") | |
| if __name__ == "__main__": | |
| main() | |