Download src/plot_curves.py from lin267/ppo-LunarLander-cleanrl: direct link, hf CLI and curl.
- Browser
- Download file 17.1 kB
-
https://huggingface.co/lin267/ppo-LunarLander-cleanrl/resolve/main/src/plot_curves.py
- Command line
-
hf download hf://lin267/ppo-LunarLander-cleanrl/src/plot_curves.py
-
curl -L -o plot_curves.py https://huggingface.co/lin267/ppo-LunarLander-cleanrl/resolve/main/src/plot_curves.py
17.1 kB
| # -*- coding: utf-8 -*- | |
| """ | |
| 绘图脚本 —— 用 PIL 手绘学习曲线 / 损失曲线 / 评估分布 / 基线对比 | |
| ================================================================= | |
| (工程环境里没有 matplotlib,所以用 PIL 直接画图,零额外依赖) | |
| 用法:: | |
| python plot_curves.py --run runs/main_2.5m --baseline runs/baseline_50k --out artifacts | |
| 产物(artifacts/ 下): | |
| learning_curve.png 训练回报 + 评估回报随步数变化(含 200 分"解决"线) | |
| losses.png policy loss / value loss / entropy / KL | |
| eval_progress.png 每次评估的均值 ± 标准差 | |
| eval_histogram.png 100 局评估的回报分布(对比随机策略) | |
| compare_baseline.png 课件 5 万步命令 vs 正式训练的对比 | |
| """ | |
| import argparse | |
| import csv | |
| import json | |
| import os | |
| import numpy as np | |
| from PIL import Image, ImageDraw, ImageFont | |
| C = { | |
| "blue": (86, 156, 255), "green": (80, 200, 120), "orange": (255, 165, 60), | |
| "red": (240, 90, 90), "purple": (170, 120, 240), "gray": (150, 150, 160), | |
| "cyan": (90, 210, 220), "yellow": (240, 210, 80), | |
| } | |
| BG = (18, 20, 25) | |
| PANEL_BG = (30, 33, 40) | |
| GRID = (55, 60, 70) | |
| TXT = (235, 235, 240) | |
| TXT_DIM = (190, 190, 195) | |
| # --------------------------------------------------------------------------- # | |
| # 基础绘图工具 | |
| # --------------------------------------------------------------------------- # | |
| 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 _rot_text(img, xy, text, font, fill): | |
| """把文字旋转 90° 画到 img 上(用于 y 轴标题)。""" | |
| tmp = Image.new("RGBA", (240, 30), (0, 0, 0, 0)) | |
| ImageDraw.Draw(tmp).text((0, 0), text, font=font, fill=fill) | |
| tmp = tmp.crop(tmp.getbbox() or (0, 0, 1, 1)).rotate(90, expand=True) | |
| img.paste(tmp, (int(xy[0]), int(xy[1])), tmp) | |
| def _nan_safe(vals): | |
| return [v if np.isfinite(v) else 0.0 for v in vals] | |
| def draw_panel(img, box, series, title, xlabel, ylabel, | |
| hline=None, legend=True, xticklabels=None, ylim=None): | |
| """在 box=(x0,y0,x1,y1) 里画坐标系。 | |
| series 元素:{"name","xs","ys","color","kind":"line"/"scatter"/"bar","width"} | |
| xticklabels: 给定则 x 轴用分类标签(xs 为 0..n-1)。 | |
| ylim: (ymin, ymax) 强制 y 轴范围(超出范围的点不画,避免极端离群点压扁曲线)。 | |
| """ | |
| d = ImageDraw.Draw(img) | |
| x0, y0, x1, y1 = box | |
| pad_l, pad_r, pad_t, pad_b = 78, 18, 48, 48 | |
| px0, py0 = x0 + pad_l, y0 + pad_t | |
| px1, py1 = x1 - pad_r, y1 - pad_b | |
| d.rectangle([px0, py0, px1, py1], fill=PANEL_BG) | |
| xs_all, ys_all = [], [] | |
| for s in series: | |
| xs_all.extend(s["xs"]) | |
| ys_all.extend(s["ys"]) | |
| if hline is not None: | |
| ys_all.append(hline[0]) | |
| if not xs_all: | |
| return | |
| xmin, xmax = float(min(xs_all)), float(max(xs_all)) | |
| if ylim is not None: | |
| ymin, ymax = float(ylim[0]), float(ylim[1]) | |
| else: | |
| ymin, ymax = float(min(ys_all)), float(max(ys_all)) | |
| pad_y = (ymax - ymin) * 0.10 | |
| ymin, ymax = ymin - pad_y, ymax + pad_y | |
| if xmax == xmin: | |
| xmax = xmin + 1 | |
| if ymax == ymin: | |
| ymax = ymin + 1 | |
| def px(xv): | |
| return px0 + (xv - xmin) / (xmax - xmin) * (px1 - px0) | |
| def py(yv): | |
| return py1 - (yv - ymin) / (ymax - ymin) * (py1 - py0) | |
| # 网格 | |
| for i in range(5): | |
| yv = ymin + (ymax - ymin) * i / 4 | |
| y = py(yv) | |
| d.line([px0, y, px1, y], fill=GRID, width=1) | |
| label = f"{yv:,.0f}" if abs(yv) >= 1 else f"{yv:.2f}" | |
| d.text((x0 + 30, y - 8), label, font=_font(13), fill=TXT_DIM) | |
| if xticklabels is None: | |
| xv = xmin + (xmax - xmin) * i / 4 | |
| x = px(xv) | |
| d.line([x, py0, x, py1], fill=GRID, width=1) | |
| lab = f"{xv/1e6:.1f}M" if abs(xv) >= 1e6 else ( | |
| f"{xv/1e3:.0f}k" if abs(xv) >= 1e3 else ( | |
| f"{xv:.0f}" if abs(xv) >= 10 else f"{xv:.1f}")) | |
| d.text((x - 14, py1 + 10), lab, font=_font(13), fill=TXT_DIM) | |
| # 分类 x 轴 | |
| if xticklabels is not None: | |
| n = len(xticklabels) | |
| width = (px1 - px0) / max(1, n) | |
| for i, lab in enumerate(xticklabels): | |
| x = px0 + width * (i + 0.5) | |
| d.text((x - d.textlength(lab, font=_font(13)) / 2, py1 + 10), | |
| lab, font=_font(13), fill=TXT_DIM) | |
| # 参考线 | |
| if hline is not None: | |
| yv, label = hline[0], hline[1] | |
| color = hline[2] if len(hline) > 2 else C["red"] | |
| y = py(yv) | |
| for xx in range(int(px0), int(px1), 14): | |
| d.line([xx, y, min(xx + 7, px1), y], fill=color, width=2) | |
| d.text((px1 - d.textlength(label, font=_font(13)) - 6, y - 19), | |
| label, font=_font(13), fill=color) | |
| # 数据(超出 ylim 的点直接不画,避免极端离群点拖垮坐标轴) | |
| for s in series: | |
| color = C.get(s.get("color", "blue"), C["blue"]) | |
| kind, width = s.get("kind", "line"), s.get("width", 2) | |
| raw = [(xv, yv) for xv, yv in zip(s["xs"], s["ys"]) if ymin <= yv <= ymax] | |
| pts = [(px(xv), py(yv)) for xv, yv in raw] | |
| if kind == "bar": | |
| if xticklabels is not None: | |
| slot = (px1 - px0) / len(xticklabels) | |
| bw = slot * 0.5 | |
| centers = [px0 + slot * (i + 0.5) for i in range(len(s["ys"]))] | |
| else: | |
| bw = max(4, (px1 - px0) / max(1, len(pts)) * 0.7) | |
| centers = [x for x, _ in pts] | |
| for cx, (_, cy) in zip(centers, pts): | |
| d.rectangle([cx - bw / 2, min(cy, py1), cx + bw / 2, max(cy, py1)], fill=color) | |
| elif kind == "scatter": | |
| for (cx, cy) in pts: | |
| d.ellipse([cx - 3, cy - 3, cx + 3, cy + 3], fill=color) | |
| else: | |
| if len(pts) > 1: | |
| d.line(pts, fill=color, width=width, joint="curve") | |
| # 标题 / 轴标题 | |
| d.text((x0 + pad_l, y0 + 10), title, font=_font(19), fill=TXT) | |
| d.text((px0 + (px1 - px0) / 2 - d.textlength(xlabel, font=_font(13)) / 2, y1 - 20), | |
| xlabel, font=_font(13), fill=TXT_DIM) | |
| _rot_text(img, (x0 + 3, (py0 + py1) / 2 - 60), ylabel, _font(13), TXT_DIM) | |
| # 图例 | |
| if legend: | |
| entries = [(s["name"], s.get("color", "blue")) for s in series] | |
| lh = 22 * len(entries) + 10 | |
| lw = 16 + max(d.textlength(n, font=_font(13)) for n, _ in entries) + 34 | |
| d.rectangle([px0 + 8, py0 + 8, px0 + 8 + lw, py0 + 8 + lh], fill=(24, 27, 33)) | |
| ly = py0 + 14 | |
| for name, color in entries: | |
| d.line([px0 + 16, ly + 7, px0 + 38, ly + 7], fill=C[color], width=3) | |
| d.text((px0 + 46, ly), name, font=_font(13), fill=TXT) | |
| ly += 22 | |
| def moving_average(xs, ys, window): | |
| if len(ys) <= window: | |
| return xs, ys | |
| ys = _nan_safe(ys) | |
| out = [float(np.mean(ys[max(0, i - window + 1):i + 1])) for i in range(len(ys))] | |
| return xs, out | |
| # --------------------------------------------------------------------------- # | |
| # 数据 | |
| # --------------------------------------------------------------------------- # | |
| def read_csv_rows(path): | |
| if not os.path.exists(path): | |
| return [] | |
| with open(path, "r", encoding="utf-8-sig") as f: | |
| return list(csv.DictReader(f)) | |
| def load_run(run_dir): | |
| train = read_csv_rows(os.path.join(run_dir, "train_log.csv")) | |
| evals = read_csv_rows(os.path.join(run_dir, "eval_log.csv")) | |
| summary = {} | |
| sp = os.path.join(run_dir, "summary.json") | |
| if os.path.exists(sp): | |
| with open(sp, "r", encoding="utf-8") as f: | |
| summary = json.load(f) | |
| return train, evals, summary | |
| # --------------------------------------------------------------------------- # | |
| # 图 1:学习曲线 | |
| # --------------------------------------------------------------------------- # | |
| def plot_learning_curve(run_dir, out_path, title): | |
| train, evals, _ = load_run(run_dir) | |
| if not train: | |
| print(f"[跳过] 缺 {run_dir}/train_log.csv") | |
| return | |
| steps = [int(r["global_step"]) for r in train] | |
| rets = [float(r["mean_return"]) for r in train] | |
| series = [] | |
| if any(np.isfinite(rets)): | |
| series.append({"name": "train return (raw)", "xs": steps, "ys": rets, | |
| "color": "gray", "kind": "line", "width": 1}) | |
| mx, my = moving_average(steps, rets, max(5, len(steps) // 60)) | |
| series.append({"name": "train return (moving avg)", "xs": mx, "ys": my, | |
| "color": "blue", "kind": "line", "width": 3}) | |
| if evals: | |
| ex = [int(r["global_step"]) for r in evals] | |
| ey = [float(r["eval_mean"]) for r in evals] | |
| series.append({"name": "eval (argmax policy)", "xs": ex, "ys": ey, | |
| "color": "green", "kind": "line", "width": 3}) | |
| series.append({"name": "eval points", "xs": ex, "ys": ey, | |
| "color": "green", "kind": "scatter"}) | |
| W, H = 1150, 580 | |
| img = Image.new("RGB", (W, H), BG) | |
| draw_panel(img, (10, 10, W - 10, H - 10), series, title, | |
| "environment steps", "episode return", | |
| hline=(200.0, "solved threshold = 200", C["red"]), | |
| ylim=(-320, 330)) | |
| d = ImageDraw.Draw(img) | |
| d.text((100, 44), | |
| "note: the first 2 argmax evals (-1983 / -142) are off-scale — early policy not yet deterministic, see README", | |
| font=_font(12), fill=(150, 150, 160)) | |
| img.save(out_path) | |
| print(f"[图] {out_path}") | |
| # --------------------------------------------------------------------------- # | |
| # 图 2:损失曲线(2×2) | |
| # --------------------------------------------------------------------------- # | |
| def plot_losses(run_dir, out_path): | |
| train, _, _ = load_run(run_dir) | |
| if not train: | |
| return | |
| steps = [int(r["global_step"]) for r in train] | |
| W, H = 1150, 800 | |
| img = Image.new("RGB", (W, H), BG) | |
| panels = [("policy_loss", "1) policy loss (PPO-Clip)", "orange"), | |
| ("value_loss", "2) value loss", "purple"), | |
| ("entropy", "3) policy entropy", "cyan"), | |
| ("approx_kl", "4) approximate KL", "green")] | |
| for i, (key, title, color) in enumerate(panels): | |
| ys = [float(r[key]) for r in train] | |
| r, c = divmod(i, 2) | |
| box = (10 + c * (W // 2), 10 + r * (H // 2), 10 + (c + 1) * (W // 2) - 10, | |
| 10 + (r + 1) * (H // 2) - 10) | |
| mx, my = moving_average(steps, ys, max(3, len(ys) // 80)) | |
| draw_panel(img, box, | |
| [{"name": "raw", "xs": steps, "ys": _nan_safe(ys), "color": "gray", "width": 1}, | |
| {"name": "moving avg", "xs": mx, "ys": my, "color": color, "width": 2}], | |
| title, "environment steps", key) | |
| img.save(out_path) | |
| print(f"[图] {out_path}") | |
| # --------------------------------------------------------------------------- # | |
| # 图 3:训练中的评估进度(均值 ± 标准差) | |
| # --------------------------------------------------------------------------- # | |
| def plot_eval_progress(run_dir, out_path): | |
| _, evals, _ = load_run(run_dir) | |
| if not evals: | |
| return | |
| steps = [int(r["global_step"]) for r in evals] | |
| means = [float(r["eval_mean"]) for r in evals] | |
| stds = [float(r["eval_std"]) for r in evals] | |
| W, H = 1150, 540 | |
| img = Image.new("RGB", (W, H), BG) | |
| d = ImageDraw.Draw(img) | |
| ylo, yhi = -260.0, 340.0 | |
| draw_panel(img, (10, 10, W - 10, H - 10), | |
| [{"name": "eval mean", "xs": steps, "ys": means, "color": "green", "width": 3}], | |
| "evaluation during training (argmax policy)", "environment steps", "eval return", | |
| hline=(200.0, "solved threshold = 200", C["red"]), ylim=(ylo, yhi)) | |
| # 手动画误差棒(与坐标轴同一范围) | |
| px0, py0, px1, py1 = 10 + 78, 10 + 48, W - 10 - 18, H - 10 - 48 | |
| xmin, xmax = min(steps), max(steps) | |
| for xv, m, s in zip(steps, means, stds): | |
| x = px0 + (xv - xmin) / max(1, xmax - xmin) * (px1 - px0) | |
| hi_, lo_ = m + s, m - s | |
| y_hi = py1 - (min(max(hi_, ylo), yhi) - ylo) / (yhi - ylo) * (py1 - py0) | |
| y_lo = py1 - (min(max(lo_, ylo), yhi) - ylo) / (yhi - ylo) * (py1 - py0) | |
| d.line([x, y_hi, x, y_lo], fill=C["blue"], width=2) | |
| d.line([x - 4, y_hi, x + 4, y_hi], fill=C["blue"], width=2) | |
| d.line([x - 4, y_lo, x + 4, y_lo], fill=C["blue"], width=2) | |
| img.save(out_path) | |
| print(f"[图] {out_path}") | |
| # --------------------------------------------------------------------------- # | |
| # 图 4:评估回报分布 | |
| # --------------------------------------------------------------------------- # | |
| def plot_eval_histogram(eval_json, out_path): | |
| if not os.path.exists(eval_json): | |
| return | |
| with open(eval_json, "r", encoding="utf-8") as f: | |
| data = json.load(f) | |
| returns = np.asarray(data["ppo"]["returns"], dtype=float) | |
| rand = data.get("random_baseline") | |
| lo, hi = float(returns.min()), float(returns.max()) | |
| pad = max(5.0, (hi - lo) * 0.05) | |
| bins = np.linspace(lo - pad, hi + pad, 15) | |
| hist, edges = np.histogram(returns, bins=bins) | |
| centers = (edges[:-1] + edges[1:]) / 2 | |
| W, H = 1150, 540 | |
| img = Image.new("RGB", (W, H), BG) | |
| draw_panel(img, (10, 10, W - 10, H - 10), | |
| [{"name": f"PPO agent ({len(returns)} episodes, argmax)", | |
| "xs": centers.tolist(), "ys": hist.tolist(), "color": "blue", "kind": "bar"}], | |
| "evaluation return distribution (argmax policy)", "episode return", | |
| "number of episodes", | |
| hline=(200.0, "solved threshold = 200", C["red"]), | |
| ylim=(0, max(1, int(hist.max()) * 1.25))) | |
| if rand: | |
| d = ImageDraw.Draw(img) | |
| txt = (f"random policy baseline: mean {rand['mean']:.0f} ± {rand['std']:.0f}, " | |
| f"landing rate {rand['landing_rate']*100:.0f}% (far to the left, off-scale)") | |
| d.text((100, 84), txt, font=_font(13), fill=C["orange"]) | |
| img.save(out_path) | |
| print(f"[图] {out_path}") | |
| # --------------------------------------------------------------------------- # | |
| # 图 5:课件命令基线 vs 正式训练(横向柱状图) | |
| # --------------------------------------------------------------------------- # | |
| def plot_compare(run_dir, baseline_dir, out_path): | |
| _, evals, summary = load_run(run_dir) | |
| _, base_evals, base_summary = load_run(baseline_dir) | |
| if not evals: | |
| return | |
| items = [] | |
| if base_evals: | |
| items.append((f"course command\n{int(base_summary.get('total_timesteps', 0))/1000:.0f}k steps", | |
| float(base_evals[-1]["eval_mean"]))) | |
| items.append((f"this run\n{summary.get('total_timesteps', 0)/1e6:.1f}M steps", | |
| float(evals[-1]["eval_mean"]))) | |
| W, H = 980, 460 | |
| img = Image.new("RGB", (W, H), BG) | |
| d = ImageDraw.Draw(img) | |
| d.text((24, 16), "course command baseline vs. full PPO training", font=_font(19), fill=TXT) | |
| left, right, top = 180, W - 150, 80 | |
| vmin = min(v for _, v in items + [("", 0.0)]) | |
| vmax = max(v for _, v in items + [("", 220.0)]) | |
| span = vmax - vmin | |
| def vx(v): | |
| return left + (v - vmin) / span * (right - left) | |
| rowh = 78 | |
| for i, (label, value) in enumerate(items): | |
| y = top + i * rowh | |
| d.text((20, y + 10), label, font=_font(14), fill=TXT_DIM) | |
| color = C["gray"] if i == 0 and len(items) > 1 else C["green"] | |
| x0, x1 = sorted([vx(0), vx(value)]) | |
| d.rectangle([x0, y, x1, y + 40], fill=color) | |
| tx = max(vx(value), vx(0.0)) + 8 | |
| d.text((tx, y + 10), f"{value:+.1f}", font=_font(16), fill=TXT) | |
| # 0 线与 200 线 | |
| for val, color, lab in [(0.0, C["gray"], "0"), (200.0, C["red"], "solved = 200")]: | |
| x = vx(val) | |
| d.line([x, top - 12, x, top + len(items) * rowh], fill=color, width=2) | |
| d.text((x - 20, top - 34), lab, font=_font(13), fill=color) | |
| img.save(out_path) | |
| print(f"[图] {out_path}") | |
| def main(): | |
| p = argparse.ArgumentParser(description="画学习曲线与评估图") | |
| p.add_argument("--run", type=str, default="runs/main_2.5m") | |
| p.add_argument("--baseline", type=str, default="runs/baseline_50k") | |
| p.add_argument("--out", type=str, default="artifacts") | |
| p.add_argument("--title", type=str, default="PPO on LunarLander — implemented from scratch") | |
| p.add_argument("--eval-json", type=str, default="") | |
| args = p.parse_args() | |
| os.makedirs(args.out, exist_ok=True) | |
| plot_learning_curve(args.run, os.path.join(args.out, "learning_curve.png"), args.title) | |
| plot_losses(args.run, os.path.join(args.out, "losses.png")) | |
| plot_eval_progress(args.run, os.path.join(args.out, "eval_progress.png")) | |
| plot_compare(args.run, args.baseline, os.path.join(args.out, "compare_baseline.png")) | |
| plot_eval_histogram(args.eval_json or os.path.join(args.out, "eval_results.json"), | |
| os.path.join(args.out, "eval_histogram.png")) | |
| if __name__ == "__main__": | |
| main() | |