1. 说明文档README.md
# Monte Carlo 强化学习实战(GridWorld + Cliff Walking)
在 `rlhf_learning` 下增加 **tabular 强化学习** 案例:用 **First-Visit 蒙特卡洛 on-policy 控制**(Sutton & Barto §5.4)学策略,并带 **matplotlib / pygame 动态演示**。与 LLM RLHF 不同,这里直接看到「采样整条轨迹 → 用回报更新 Q」的循环,便于和 GRPO/DPO 的 rollout 直觉对照。
## 目录结构(按功能)
```
monte_carlo_rl/
├── README.md
├── requirements.txt
├── project_root.py # 脚本运行时把项目根加入 sys.path
├── envs/ # 环境 MDP
│ ├── cliff_walking.py # 4×12 悬崖行走
│ └── gridworld.py # 4×4 网格
├── algorithms/ # 强化学习算法
│ └── monte_carlo_control.py
├── visualization/ # 绘图与 pygame 渲染
│ ├── common.py
│ ├── matplotlib_grid.py
│ └── pygame_view.py
├── demos/ # 可执行演示入口
│ ├── demo_matplotlib.py # 策略热力图 + 学习曲线 GIF
│ ├── demo_reward_grid.py # 每格 reward / V(s) GIF
│ └── demo_pygame.py # 逐步播放 agent
├── scripts/
│ └── train.py # 无 GUI 训练 + 打印路径
└── outputs/ # 导出的 GIF 等资源
```
## 两个环境
| 环境 | 说明 | 默认 |
|------|------|------|
| **`cliff`** | 4×12 悬崖行走;踩崖 −100 回起点,**仅完整到 G 的 episode** 参与 MC 更新 | ✅ |
| **`grid`** | 4×4 小网格,短 episode,适合快速试算法 | 可选 |
## 环境依赖
```bash
cd /data/home_/py_workspace/rlhf_learning/monte_carlo_rl
pip install -r requirements.txt
```
## 运行方式
在 **项目根目录 `monte_carlo_rl/`** 下执行:
**Matplotlib 动画 / 导出 GIF:**
```bash
python demos/demo_matplotlib.py --save outputs/demo.gif
python demos/demo_matplotlib.py --env grid --episodes 2500 --save outputs/demo_grid.gif
```
**每格 reward / V(s) GIF:**
```bash
python demos/demo_reward_grid.py --save outputs/reward_cliff.gif
```
**Pygame 逐步播放:**
```bash
pip install pygame
python demos/demo_pygame.py --step-ms 120 --show-every 2
```
**命令行训练:**
```bash
python scripts/train.py --env cliff --episodes 50000 --epsilon 0.2 --epsilon-end 0.01
```
## 悬崖任务调参(接近最优 −13)
| 调整项 | 建议 |
|--------|------|
| `--episodes` | ≥ 50000 |
| `--epsilon` / `--epsilon-end` | 0.2 → 0.01 |
| 评估 | `scripts/train.py` 输出的 **Greedy rollout return**(常见 **−17**;贴崖 **−13** 更宜用 Q-learning 对比) |
## 参考
- [Sutton & Barto (2nd)](http://incompleteideas.net/book/the-book-2nd.html) §5.4、Example 6.6
- [Gymnasium CliffWalking-v1](https://gymnasium.farama.org/environments/toy_text/cliff_walking/)
2. 算法
monte_carlo_control.py
"""First-visit Monte Carlo on-policy control (ε-soft), Sutton & Barto §5.4."""
from __future__ import annotations
from collections import defaultdict
from typing import Callable, DefaultDict, List, Optional, Protocol, Tuple
import numpy as np
from envs.cliff_walking import Action, State
Episode = List[Tuple[State, Action, float]]
class TabularEnv(Protocol):
n_rows: int
n_cols: int
state: State
def reset(self, *, seed: Optional[int] = None) -> State: ...
def step(self, action: Action): ...
def epsilon_greedy_action(
q: np.ndarray,
state: State,
n_actions: int,
epsilon: float,
rng: np.random.Generator,
) -> Action:
if rng.random() < epsilon:
return int(rng.integers(0, n_actions))
q_s = q[state]
best = np.flatnonzero(q_s == q_s.max())
return int(rng.choice(best))
def generate_episode(
env: TabularEnv,
q: np.ndarray,
epsilon: float,
rng: np.random.Generator,
max_steps: int = 5000,
) -> Tuple[Episode, bool]:
env.reset()
episode: Episode = []
completed = False
for _ in range(max_steps):
s = env.state
a = epsilon_greedy_action(q, s, 4, epsilon, rng)
result = env.step(a)
episode.append((s, a, result.reward))
if result.terminated or result.truncated:
completed = result.terminated
break
return episode, completed
def epsilon_for_episode(ep: int, eps_start: float, eps_end: Optional[float], n_episodes: int) -> float:
if eps_end is None or eps_end == eps_start:
return eps_start
if n_episodes <= 1:
return eps_end
t = ep / (n_episodes - 1)
return eps_start + t * (eps_end - eps_start)
def first_visit_mc_control(
env: TabularEnv,
n_episodes: int = 5000,
gamma: float = 1.0,
epsilon: float = 0.1,
epsilon_end: Optional[float] = None,
seed: Optional[int] = None,
on_episode: Optional[Callable[[int, float, np.ndarray], None]] = None,
skip_incomplete: bool = False,
) -> Tuple[np.ndarray, np.ndarray, List[float]]:
"""Returns Q-table, greedy policy (state -> action), and per-episode returns."""
rng = np.random.default_rng(seed)
q = np.zeros((env.n_rows, env.n_cols, 4), dtype=np.float64)
returns_sum: DefaultDict[Tuple[State, Action], float] = defaultdict(float)
returns_count: DefaultDict[Tuple[State, Action], int] = defaultdict(int)
episode_returns: List[float] = []
ep_idx = 0
while ep_idx < n_episodes:
eps = epsilon_for_episode(ep_idx, epsilon, epsilon_end, n_episodes)
episode, completed = generate_episode(env, q, eps, rng)
if skip_incomplete and not completed:
continue
g = 0.0
visited: set[Tuple[State, Action]] = set()
for t in range(len(episode) - 1, -1, -1):
s, a, r = episode[t]
g = gamma * g + r
sa = (s, a)
if sa in visited:
continue
visited.add(sa)
returns_sum[sa] += g
returns_count[sa] += 1
q[s[0], s[1], a] = returns_sum[sa] / returns_count[sa]
ep_return = sum(r for _, _, r in episode)
episode_returns.append(ep_return)
if on_episode is not None:
on_episode(ep_idx, ep_return, q.copy())
ep_idx += 1
policy = np.argmax(q, axis=-1)
return q, policy, episode_returns
def greedy_rollout(
env: TabularEnv,
q: np.ndarray,
max_steps: int = 200,
) -> Tuple[List[State], float]:
env.reset()
path = [env.state]
total = 0.0
for _ in range(max_steps):
s = env.state
a = int(np.argmax(q[s]))
result = env.step(a)
total += result.reward
path.append(env.state)
if result.terminated or result.truncated:
break
return path, total
分辨 on-policy / off-policy 的核心看两点:数据(轨迹)是用哪个策略采样的,以及要评估/改进的是哪个策略。
1) 定义一句话版
On-policy:用同一个策略同时做两件事:
(1) 产生数据(behavior policy),(2) 被评估/被改进(target policy)。
即:behavior = target(或至少 target 就是 behavior 的 ε-soft 版本)。Off-policy:用一个策略产生数据(behavior),但学习/改进的是另一个策略(target)。
即:behavior ≠ target。常见是行为策略更“随机”,目标策略更“贪心”。
2) 实操判断方法(看到代码怎么判) 看代码里是否同时存在两套策略: A. 只有一个策略在选动作,并且用它来更新/变好 → on-policy 典型模式: a = epsilon_greedy(Q, s, epsilon) # 用这个策略采样 # ... 收集回报/TD误差 # 用采样到的 (s,a,r,...) 更新 Q 并且改进后的策略仍然是同一个 ε-greedy(Q) 去交互环境。 你给的 MC 控制代码就是这样: 采样: epsilon_greedy_action(q, s, eps, rng) 更新:用采到的 (s,a,return) 去更新同一张 q 下一回合继续用 epsilon_greedy(q) 采样 所以它是 on-policy ε-soft control。
B. 明确区分 behavior/target,采样用 behavior,但更新按 target 来 → off-policy 典型模式: a ~ b(a|s) # 行为策略 b 采样 # 更新时用 π(目标策略)对应的动作/概率 # 例如 importance sampling 或 max_a Q(s,a)
3) 强特征:是否需要“重要性采样”(importance sampling) 在蒙特卡洛里尤其明显: On-policy MC:直接用回报均值更新即可(不需要校正分布差异)。 Off-policy MC:因为数据分布来自 b,但你要评估 π,所以通常要用重要性采样比率
5) 一句话判断口诀 轨迹是谁跑的?behavior 更新/评估想学谁?
target 一样:on-policy;不一样:off-policy
3. demos
demo_matplotlib.py
#!/usr/bin/env python3
"""Matplotlib animation: policy grid + learning curve (export GIF or GUI)."""
from __future__ import annotations
import argparse
import sys
from typing import List, Optional
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation, PillowWriter
from pathlib import Path
import sys as _sys
_ROOT = Path(__file__).resolve().parents[1]
if str(_ROOT) not in _sys.path:
_sys.path.insert(0, str(_ROOT))
import project_root
project_root.setup()
from algorithms.monte_carlo_control import first_visit_mc_control, greedy_rollout
from visualization.common import make_env, state_values_from_q
from visualization.matplotlib_grid import draw_policy_grid
def run_demo(
env_name: str,
n_episodes: int,
epsilon: float,
seed: int,
update_every: int,
save_path: Optional[str],
epsilon_end: float = 0.02,
) -> None:
env, is_cliff = make_env(env_name)
frames: List[dict] = []
returns_window: List[float] = []
def on_episode(ep: int, ep_return: float, q: np.ndarray) -> None:
returns_window.append(ep_return)
if len(returns_window) > 100:
returns_window.pop(0)
if ep % update_every != 0 and ep != n_episodes - 1:
return
policy = np.argmax(q, axis=-1)
v = state_values_from_q(q)
path, greedy_ret = greedy_rollout(env, q)
avg100 = float(np.mean(returns_window)) if returns_window else ep_return
frames.append(
{
"ep": ep,
"v": v.copy(),
"policy": policy.copy(),
"path": path,
"avg100": avg100,
"greedy_ret": greedy_ret,
}
)
print(
f"Training first-visit MC control: env={env_name}, episodes={n_episodes}, "
f"epsilon={epsilon}->{epsilon_end}, seed={seed}"
)
q, policy, all_returns = first_visit_mc_control(
env,
n_episodes=n_episodes,
epsilon=epsilon,
epsilon_end=epsilon_end,
seed=seed,
on_episode=on_episode,
skip_incomplete=is_cliff,
)
if not frames:
frames.append(
{
"ep": n_episodes - 1,
"v": state_values_from_q(q),
"policy": policy,
"path": greedy_rollout(env, q)[0],
"avg100": float(np.mean(all_returns[-100:])),
"greedy_ret": greedy_rollout(env, q)[1],
}
)
fig, axes = plt.subplots(1, 2, figsize=(14, 4.5))
ax_grid, ax_curve = axes
def update(i: int):
fr = frames[min(i, len(frames) - 1)]
im = draw_policy_grid(
ax_grid,
env,
fr["v"],
fr["policy"],
fr["path"],
f"Ep {fr['ep']+1}/{n_episodes} | greedy return ~ {fr['greedy_ret']:.0f}",
is_cliff=is_cliff,
)
ax_curve.clear()
ax_curve.plot(all_returns, color="#444444", alpha=0.35, linewidth=0.8, label="return")
end = fr["ep"] + 1
ax_curve.plot(range(end), all_returns[:end], color="#1f77b4", linewidth=1.2)
ax_curve.axhline(fr["avg100"], color="#d62728", linestyle="--", label=f"avg100 ({fr['avg100']:.1f})")
ax_curve.set_xlabel("Episode")
ax_curve.set_ylabel("Return")
ax_curve.set_title("Monte Carlo learning curve")
ax_curve.legend(loc="lower right", fontsize=8)
ax_curve.set_xlim(0, n_episodes)
ymin = min(all_returns[:end]) if end else -150
floor = -130 if is_cliff else -25
ax_curve.set_ylim(min(ymin, floor), max(max(all_returns[:end], default=0), -6))
return [im]
interval_ms = 120 if save_path else 80
anim = FuncAnimation(fig, update, frames=len(frames), interval=interval_ms, repeat=True)
plt.tight_layout()
if save_path:
if save_path.endswith(".gif"):
anim.save(save_path, writer=PillowWriter(fps=8))
print(f"Saved animation: {save_path}")
else:
anim.save(save_path, dpi=120)
print(f"Saved figure: {save_path}")
plt.close(fig)
else:
print("Close the plot window to exit.")
plt.show()
def main() -> None:
parser = argparse.ArgumentParser(description="Monte Carlo control demo (grid / cliff)")
parser.add_argument("--env", choices=("grid", "cliff"), default="cliff")
parser.add_argument("--episodes", type=int, default=10000)
parser.add_argument("--epsilon", type=float, default=0.2)
parser.add_argument("--epsilon-end", type=float, default=0.02)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--update-every", type=int, default=150)
parser.add_argument("--save", type=str, default=None)
args = parser.parse_args()
if args.save and not args.save.endswith((".gif", ".png", ".mp4")):
print("--save must end with .gif, .png, or .mp4", file=sys.stderr)
sys.exit(1)
run_demo(
args.env,
args.episodes,
args.epsilon,
args.seed,
args.update_every,
args.save,
args.epsilon_end,
)
if __name__ == "__main__":
main()
下面按结构分析。 1) 入口与参数 main() 命令行参数: --env {grid,cliff} :选择环境 --episodes :训练回合数 --epsilon 、 --epsilon-end :ε 从起始线性退火到终止值 --seed :随机种子 --update-every :动画帧更新频率(每隔多少回合记录一帧) --save :保存路径( .gif/.png/.mp4 ),否则弹窗显示 并做了一个简单校验: --save 必须是 gif/png/mp4 结尾。 2) 路径与工程初始化( project_root.setup() ) 这一段把项目根目录插入 sys.path ,然后 project_root.setup() 可能做一些全局配置(比如 matplotlib 后端、随机种子、日志等)。这属于“工程化引导”,让脚本可以从任意位置运行并正确 import 你的包。 3) run_demo(...) 核心流程 3.1 创建环境 env, is_cliff = make_env(env_name) 推断: env 是一个网格或 cliff walking 环境 is_cliff 用来决定一些显示/训练策略(例如 cliff 环境中 skip truncated episode) 3.2 帧缓存 frames 与滑动平均窗口 returns_window frames: List[dict] :存动画每一帧要画的内容(避免每次 update 时再算 rollout/价值等) returns_window :最多保存最近 100 个 episode return,用来画 avg100 3.3 回调 on_episode 这个回调会传给 first_visit_mc_control(..., on_episode=on_episode) ,所以训练每回合结束都会调用一次(前提是你 MC control 里调用了它)。 它做了几件事: 更新最近100回合窗口 returns_window.append(ep_return) if len(returns_window) > 100: pop(0) 控制帧率:不是每回合都存一帧 if ep % update_every != 0 and ep != n_episodes - 1: return 只在: ep 是 update_every 的倍数,或 最后一回合 才存入 frames 。 构造用于可视化的数据 policy = argmax(q) :当前贪心策略 v = state_values_from_q(q) :由 Q 得到 V(通常 max_a Q(s,a) ) path, greedy_ret = greedy_rollout(env, q) :用贪心策略在环境里跑一遍,得到路径与回报 avg100 :最近100回合平均 return 然后把这些打包进 frames.append({...}) 。 注意点: greedy_rollout(env, q) 会调用 env.reset() 并 step 环境。因为训练也在用同一个 env ,这类“训练中途拿 env 来 rollout”的做法有潜在问题: 若训练代码强依赖 env.state 连续性或外部状态,rollout 会打断它。 但你的 MC control 的 generate_episode 每回合都会 env.reset() ,所以通常没问题。 更稳妥的工程写法是:可视化 rollout 用一个 env_eval (训练环境的拷贝)来避免互相干扰。 4) 调用训练: first_visit_mc_control(...) q, policy, all_returns = first_visit_mc_control( env, n_episodes=n_episodes, epsilon=epsilon, epsilon_end=epsilon_end, seed=seed, on_episode=on_episode, skip_incomplete=is_cliff, ) 关键点: 这仍然是 on-policy:episode 是用 ε-greedy(Q)
demo_pygame.py
#!/usr/bin/env python3
"""Pygame step-by-step playback while training Monte Carlo control."""
from __future__ import annotations
import argparse
import os
import sys
from collections import defaultdict
from pathlib import Path
from typing import DefaultDict, List, Tuple
import numpy as np
_ROOT = Path(__file__).resolve().parents[1]
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
import project_root
project_root.setup()
from algorithms.monte_carlo_control import generate_episode, greedy_rollout
from visualization.common import make_env
from visualization.pygame_view import GridRenderer, replay_episode
State = Tuple[int, int]
def configure_sdl(*, force_headless: bool) -> None:
os.environ.setdefault("XDG_RUNTIME_DIR", "/tmp")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
if force_headless or not has_display:
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
if not has_display and not force_headless:
print(
"Note: no DISPLAY — using SDL_VIDEODRIVER=dummy (no visible window).\n"
" • Server preview: python demos/demo_matplotlib.py --save outputs/demo.gif\n"
" • Or: xvfb-run -a python demos/demo_pygame.py ...",
file=sys.stderr,
)
def mc_update_episode(
q: np.ndarray,
episode: List[Tuple[State, int, float]],
returns_sum: DefaultDict,
returns_count: DefaultDict,
gamma: float = 1.0,
) -> None:
g = 0.0
visited: set = set()
for t in range(len(episode) - 1, -1, -1):
s, a, r = episode[t]
g = gamma * g + r
sa = (s, a)
if sa in visited:
continue
visited.add(sa)
returns_sum[sa] += g
returns_count[sa] += 1
q[s[0], s[1], a] = returns_sum[sa] / returns_count[sa]
def run_warmup(
env,
q: np.ndarray,
n: int,
epsilon: float,
seed: int,
skip_incomplete: bool,
) -> List[float]:
rng = np.random.default_rng(seed)
returns_sum: DefaultDict = defaultdict(float)
returns_count: DefaultDict = defaultdict(int)
rets: List[float] = []
done = 0
while done < n:
ep, completed = generate_episode(env, q, epsilon, rng)
if skip_incomplete and not completed:
continue
mc_update_episode(q, ep, returns_sum, returns_count)
rets.append(sum(r for _, _, r in ep))
done += 1
return rets
def main() -> None:
try:
import pygame
except ImportError:
print("Install pygame: pip install pygame", file=sys.stderr)
sys.exit(1)
parser = argparse.ArgumentParser(description="MC RL pygame step playback")
parser.add_argument("--env", choices=("grid", "cliff"), default="cliff")
parser.add_argument("--warmup", type=int, default=4000)
parser.add_argument("--episodes", type=int, default=200)
parser.add_argument("--epsilon", type=float, default=0.1)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--show-every", type=int, default=1)
parser.add_argument("--step-ms", type=int, default=280)
parser.add_argument("--no-greedy-at-end", action="store_true")
parser.add_argument("--headless", action="store_true")
args = parser.parse_args()
configure_sdl(force_headless=args.headless)
env, is_cliff = make_env(args.env)
skip_incomplete = is_cliff
q = np.zeros((env.n_rows, env.n_cols, 4), dtype=np.float64)
rng = np.random.default_rng(args.seed)
returns_sum: DefaultDict = defaultdict(float)
returns_count: DefaultDict = defaultdict(int)
all_returns: List[float] = []
pygame.init()
try:
renderer = GridRenderer(env)
except pygame.error as e:
print(
f"Pygame display failed: {e}\n"
"Try: xvfb-run -a python demos/demo_pygame.py ...",
file=sys.stderr,
)
sys.exit(1)
step_ms = args.step_ms
quit_all = False
if args.warmup > 0:
print(f"Warmup {args.warmup} episodes (no animation)...")
all_returns.extend(run_warmup(env, q, args.warmup, args.epsilon, args.seed, skip_incomplete))
ep_done = 0
while ep_done < args.episodes and not quit_all:
episode, completed = generate_episode(env, q, args.epsilon, rng)
if skip_incomplete and not completed:
continue
mc_update_episode(q, episode, returns_sum, returns_count)
ep_ret = sum(r for _, _, r in episode)
all_returns.append(ep_ret)
if (ep_done % args.show_every) == 0:
prefix = [
f"Env={args.env} Episode {ep_done + 1}/{args.episodes} return {ep_ret:+.0f}",
f"epsilon={args.epsilon} avg100={np.mean(all_returns[-100:]):+.1f}",
]
quit_all, step_ms = replay_episode(
renderer, env, episode, q, step_ms=step_ms, hud_prefix=prefix
)
ep_done += 1
if not quit_all and not args.no_greedy_at_end:
env.reset()
greedy_ep: List[Tuple[State, int, float]] = []
total = 0.0
for _ in range(500):
s = env.state
a = int(np.argmax(q[s]))
result = env.step(a)
greedy_ep.append((s, a, result.reward))
total += result.reward
if result.terminated or result.truncated:
break
prefix = [
"Greedy policy rollout (after training)",
f"Return {total:+.0f} steps {len(greedy_ep)}",
]
quit_all, step_ms = replay_episode(
renderer, env, greedy_ep, q, step_ms=step_ms, hud_prefix=prefix
)
if not quit_all:
path, _ = greedy_rollout(env, q)
while True:
renderer.draw_board(path[-1], q=q, highlight_path=path)
renderer.draw_hud(
[
"Done. Close window or press Q to exit.",
f"Last greedy path length: {len(path) - 1}",
]
)
renderer.flip()
q_req, _, _ = renderer.pump_events()
if q_req:
break
pygame.time.wait(50)
pygame.quit()
if __name__ == "__main__":
main()
demo_reward_grid.py
#!/usr/bin/env python3
"""Export GIF: env reward grid + learned V(s) per cell + return curve."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import List, Optional
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation, PillowWriter
from matplotlib.colors import TwoSlopeNorm
_ROOT = Path(__file__).resolve().parents[1]
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
import project_root
project_root.setup()
from algorithms.monte_carlo_control import first_visit_mc_control
from visualization.common import make_env, state_values_from_q
def env_reward_grid(env, is_cliff: bool) -> np.ndarray:
g = np.full((env.n_rows, env.n_cols), -1.0, dtype=np.float64)
if is_cliff:
for r, c in env.cliff_cells:
g[r, c] = env.cliff_reward
return g
def draw_reward_cells(
ax,
env,
values: np.ndarray,
*,
is_cliff: bool,
title: str,
vmin: float,
vmax: float,
fmt: str,
show_markers: bool = True,
) -> None:
ax.clear()
n_r, n_c = env.n_rows, env.n_cols
cliff = set(env.cliff_cells) if is_cliff else set()
display = values.copy()
for r, c in cliff:
display[r, c] = np.nan
norm = TwoSlopeNorm(vmin=vmin, vcenter=(vmin + vmax) / 2, vmax=vmax)
ax.imshow(display, cmap="RdYlGn", origin="upper", norm=norm)
for r in range(n_r):
for c in range(n_c):
if (r, c) in cliff:
ax.add_patch(
plt.Rectangle((c - 0.5, r - 0.5), 1, 1, fill=True, color="#8b0000", alpha=0.9)
)
ax.text(c, r, f"{env.cliff_reward:.0f}", ha="center", va="center", color="white", fontsize=9)
continue
val = values[r, c]
text = fmt.format(val)
ax.text(
c,
r,
text,
ha="center",
va="center",
color="black" if val > (vmin + vmax) / 2 else "white",
fontsize=11 if n_c <= 6 else 8,
fontweight="bold",
)
if show_markers:
if (r, c) == env.start:
ax.text(c, r - 0.32, "S", ha="center", va="center", color="#1b5e20", fontsize=8)
elif (r, c) == env.goal:
ax.text(c, r - 0.32, "G", ha="center", va="center", color="#0d47a1", fontsize=8)
ax.set_xticks(range(n_c))
ax.set_yticks(range(n_r))
ax.set_xticklabels([])
ax.set_yticklabels([])
ax.set_title(title, fontsize=10)
ax.set_xlim(-0.5, n_c - 0.5)
ax.set_ylim(n_r - 0.5, -0.5)
def run(
env_name: str,
n_episodes: int,
epsilon: float,
seed: int,
update_every: int,
save_path: Optional[str],
epsilon_end: float = 0.02,
) -> None:
env, is_cliff = make_env(env_name)
r_env = env_reward_grid(env, is_cliff)
frames: List[dict] = []
def on_episode(ep: int, ep_return: float, q: np.ndarray) -> None:
if ep % update_every != 0 and ep != n_episodes - 1:
return
v = state_values_from_q(q)
frames.append({"ep": ep, "v": v.copy(), "ret": ep_return})
print(f"MC training for reward-grid GIF: env={env_name}, episodes={n_episodes}")
q, _, all_returns = first_visit_mc_control(
env,
n_episodes=n_episodes,
epsilon=epsilon,
epsilon_end=epsilon_end,
seed=seed,
on_episode=on_episode,
skip_incomplete=is_cliff,
)
if not frames:
frames.append({"ep": n_episodes - 1, "v": state_values_from_q(q), "ret": all_returns[-1]})
vmin, vmax = (-120.0, 0.0) if is_cliff else (-20.0, 0.0)
fig = plt.figure(figsize=(14, 7))
gs = fig.add_gridspec(2, 2, height_ratios=[2.2, 1], hspace=0.35, wspace=0.25)
ax_env = fig.add_subplot(gs[0, 0])
ax_val = fig.add_subplot(gs[0, 1])
ax_curve = fig.add_subplot(gs[1, :])
draw_reward_cells(
ax_env,
env,
r_env,
is_cliff=is_cliff,
title="Env reward r(s) per cell (entering)",
vmin=vmin,
vmax=vmax,
fmt="{:.0f}",
show_markers=True,
)
def update(i: int):
fr = frames[min(i, len(frames) - 1)]
draw_reward_cells(
ax_val,
env,
fr["v"],
is_cliff=is_cliff,
title=f"Learned V(s)=max Q | Ep {fr['ep']+1}/{n_episodes}",
vmin=vmin,
vmax=vmax,
fmt="{:.0f}",
show_markers=True,
)
ax_curve.clear()
end = fr["ep"] + 1
ax_curve.plot(range(end), all_returns[:end], color="#1565c0", linewidth=1.0, label="episode return")
if end >= 10:
w = min(100, end)
kernel = np.ones(w) / w
smooth = np.convolve(all_returns[:end], kernel, mode="valid")
ax_curve.plot(range(w - 1, end), smooth, color="#c62828", linewidth=1.5, label=f"MA-{w}")
ax_curve.set_xlabel("Episode")
ax_curve.set_ylabel("Return (sum of step rewards)")
ax_curve.set_title("Training returns (sum of grid rewards along trajectory)")
ax_curve.legend(loc="lower right", fontsize=8)
ax_curve.set_xlim(0, n_episodes)
ylo = min(all_returns[:end]) if end else vmin
ax_curve.set_ylim(min(ylo, vmin * 0.3), 5)
return []
anim = FuncAnimation(fig, update, frames=len(frames), interval=130, repeat=True)
plt.tight_layout()
if save_path:
if not save_path.endswith(".gif"):
print("--save must be a .gif path", file=sys.stderr)
sys.exit(1)
anim.save(save_path, writer=PillowWriter(fps=7))
print(f"Saved: {save_path}")
plt.close(fig)
else:
plt.show()
def main() -> None:
p = argparse.ArgumentParser(description="Per-cell reward / value grid animation (GIF)")
p.add_argument("--env", choices=("grid", "cliff"), default="cliff")
p.add_argument("--episodes", type=int, default=10000)
p.add_argument("--epsilon", type=float, default=0.2)
p.add_argument("--epsilon-end", type=float, default=0.02)
p.add_argument("--seed", type=int, default=42)
p.add_argument("--update-every", type=int, default=150)
p.add_argument("--save", type=str, required=True)
args = p.parse_args()
run(
args.env,
args.episodes,
args.epsilon,
args.seed,
args.update_every,
args.save,
args.epsilon_end,
)
if __name__ == "__main__":
main()
这段脚本用于导出一个 GIF:上排左图固定显示环境每格 reward(cliff 会标红并显示 cliff reward),上排右图随训练更新显示学到的 V(s)=max_a Q(s,a),下排是训练回合 return 曲线(含滑动平均)。训练算法仍是你前面的 first_visit_mc_control (on-policy ε-soft MC)。 1) 入口与参数 main() 强制要求 --save ,且在 run() 里进一步要求必须以 .gif 结尾: --env grid|cliff --episodes 回合数 --epsilon 起始 ε, --epsilon-end 退火终值 --seed --update-every 每隔多少回合记录一帧 --save path.gif 必填 2) 环境 reward 网格: env_reward_grid g = full(-1) if is_cliff: for cliff cell: g[r,c] = env.cliff_reward 含义:每个格子“进入该格的即时奖励”可视化(通常 grid/cliff 里普通步子 -1,掉崖大负数)。 注意:这只是为了画图,不参与训练。 3) 画一个“数值格子图”: draw_reward_cells 这个函数是可视化核心,既用来画 reward grid,也用来画 V(s) grid。 关键行为: cliff 单元格特殊处理 display = values.copy() cliff 位置设为 np.nan ,然后用红色矩形 patch 盖住并写上 cliff reward(例如 -100)。 颜色映射 用 TwoSlopeNorm(vmin, vcenter, vmax) + "RdYlGn" 让颜色在某个中心值左右对称。这里 vcenter 用的是 (vmin+vmax)/2 ,相当于以区间中点为“黄”的分界。 格子内写文字 对非 cliff:写 fmt.format(values[r,c]) 字体颜色根据数值与中点比较来切黑/白,以保证可读性。 标记 S/G 如果 show_markers=True 且格子是 env.start 或 env.goal ,额外写 S / G 。 坐标轴设置为网格但不显示 tick label。 4) run(...) :训练并收集动画帧 4.1 帧缓存 frames 每帧存: {"ep": ep, "v": v.copy(), "ret": ep_return} 这里只存 V,不存 policy/path(相比你前一个 matplotlib policy-grid demo 更轻量)。 4.2 回调 on_episode 只在: ep % update_every == 0 或最后一回合 记录一帧: v = state_values_from_q(q) frames.append(...) 4.3 训练调用 q, _, all_returns = first_visit_mc_control(..., on_episode=on_episode, skip_incomplete=is_cliff) skip_incomplete=is_cliff :如果 cliff 环境 episode 由于 truncated 没“真正终止”,就跳过(这和你 MC 实现中 completed = terminated 的逻辑配套)。 all_returns 用来画学习曲线。 frames 用来做动画。 如果 frames 为空(例如 update_every 太大且回调没触发),兜底添加最后一帧。 4.4 设置 V 显示范围 vmin, vmax = (-120,0) if cliff else (-20,0) 用于颜色映射和文字颜色判断。这里是经验值;如果环境回报尺度变了,图可能会“糊成一片”(需要动态 min/max 或自适应)。 5) Matplotlib 布局与动画 5.1 布局 gs = 2x2 top row: ax_env, ax_val bottom row: ax_curve spans both columns ax_env :固定画一次 reward grid(不会在 update 里重画) ax_val :每帧更新 V(s) ax_curve :每帧
4. envs
cliff_walking.py
"""Cliff Walking — Sutton & Barto Example 6.6 (episodic MDP for MC control)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional, Tuple
import numpy as np
Action = int # 0=up, 1=right, 2=down, 3=left
State = Tuple[int, int]
ACTION_DELTAS = np.array([[-1, 0], [0, 1], [1, 0], [0, -1]], dtype=np.int32)
ACTION_NAMES = ("↑", "→", "↓", "←")
@dataclass
class StepResult:
next_state: State
reward: float
terminated: bool
truncated: bool
info: dict
class CliffWalkingEnv:
"""Grid world: start bottom-left, goal bottom-right, cliff on bottom row."""
def __init__(
self,
n_rows: int = 4,
n_cols: int = 12,
start: Optional[State] = None,
goal: Optional[State] = None,
step_reward: float = -1.0,
cliff_reward: float = -100.0,
) -> None:
self.n_rows = n_rows
self.n_cols = n_cols
self.start = start if start is not None else (n_rows - 1, 0)
self.goal = goal if goal is not None else (n_rows - 1, n_cols - 1)
self.step_reward = step_reward
self.cliff_reward = cliff_reward
self.state: State = self.start
@property
def cliff_cells(self) -> List[State]:
r = self.n_rows - 1
return [(r, c) for c in range(1, self.n_cols - 1)]
def reset(self, *, seed: Optional[int] = None) -> State:
if seed is not None:
np.random.seed(seed)
self.state = self.start
return self.state
def in_bounds(self, row: int, col: int) -> bool:
return 0 <= row < self.n_rows and 0 <= col < self.n_cols
def step(self, action: Action) -> StepResult:
dr, dc = ACTION_DELTAS[action]
r, c = self.state
nr, nc = r + dr, c + dc
if not self.in_bounds(nr, nc):
nr, nc = r, c
reward = self.step_reward
terminated = False
info: dict = {}
if (nr, nc) in self.cliff_cells:
reward = self.cliff_reward
info["fell_cliff"] = True
self.state = self.start
elif (nr, nc) == self.goal:
terminated = True
info["reached_goal"] = True
self.state = (nr, nc)
else:
self.state = (nr, nc)
return StepResult(
next_state=self.state,
reward=reward,
terminated=terminated,
truncated=False,
info=info,
)
def all_states(self) -> List[State]:
return [(r, c) for r in range(self.n_rows) for c in range(self.n_cols)]
def render_ascii(self, agent: Optional[State] = None) -> str:
agent = agent if agent is not None else self.state
cliff = set(self.cliff_cells)
lines = []
for r in range(self.n_rows):
row_chars = []
for c in range(self.n_cols):
pos = (r, c)
if pos == agent:
ch = "A"
elif pos == self.start and agent != self.start:
ch = "S"
elif pos == self.goal:
ch = "G"
elif pos in cliff:
ch = "C"
else:
ch = "."
row_chars.append(ch)
lines.append(" ".join(row_chars))
return "\n".join(lines)
这段代码实现了一个简化版的 Cliff Walking 环境(Sutton & Barto 例 6.6),用于 episodic 任务(到达 goal 终止)。它符合你前面 MC control 代码期望的接口: reset() 、 step() 、并维护 env.state 。 1) 状态、动作与转移 状态 State State = Tuple[int,int] # (row, col) 网格坐标, (0,0) 在左上,行向下增加,列向右增加。 动作 Action Action = int # 0=up, 1=right, 2=down, 3=left 动作到位移由 ACTION_DELTAS 给出: 0 上:(-1,0) 1 右:(0,+1) 2 下:(+1,0) 3 左:(0,-1) ACTION_NAMES 只是用于显示。 2) StepResult step() 返回一个 StepResult ,包含: next_state reward terminated :是否到达终点 truncated :这里永远 False(环境本身不做时间截断) info :字典,标记掉崖/到达终点 这与许多 gymnasium 风格兼容。 3) 环境结构:起点、终点、悬崖 初始化 默认参数与教材一致: n_rows=4, n_cols=12 start = (n_rows-1, 0) 左下角 goal = (n_rows-1, n_cols-1) 右下角 普通步 reward: step_reward=-1 掉崖 reward: cliff_reward=-100 并维护当前状态: self.state = self.start 悬崖格 cliff_cells r = self.n_rows - 1 return [(r,c) for c in range(1, n_cols-1)] 也就是底行中间一整条(不含 start 和 goal)。这是典型 cliff walking。 注意:这个属性每次访问都会新建一个 list;在 step() 里用 (nr,nc) in self.cliff_cells 会导致线性查找,虽然网格很小问题不大,但可以优化为 set 缓存(见后面改进点)。 4) reset() if seed is not None: np.random.seed(seed) self.state = self.start return self.state 几点说明: 这个环境本身是确定性的(没有随机性),所以 seed 基本没用。 并且这里用的是 np.random.seed(seed) (全局 RNG),不如使用局部 RNG(但同样因为无随机性影响不大)。 你的 MC 控制代码使用 np.random.default_rng(seed) 自己管理 RNG,更现代。 5) step() 逻辑(最关键) 5.1 边界处理 先根据 action 计算候选下一位置 (nr,nc) ,如果出界则“撞墙原地不动”: if not in_bounds: nr,nc = r,c 5.2 默认奖励与终止标志 reward = step_reward # -1 terminated = False info = {} 5.3 三种情况(按优先级) (1) 掉入悬崖 if (nr,nc) in cliff_cells: reward = cliff_reward # -100 info["fell_cliff"]=True self.state = self.start 重要:掉崖不会 terminated,只是把 agent 传送回 start,同时给一个大负奖励。这点和教材一致:episode 只在到达 goal 终止。 含义: 一个 episode 内可能多次掉崖,每次都回到 start,但 episode 继续。 所以如果你的策略很差,episode 可能会很长;你的 generate_episode 用 max_steps 防止无限跑。 (2) 到达终点 elif (nr,nc) == goal: terminated=True info["reached_goal"]=True self.state=(nr,nc)
gridworld.py
"""4×4 Grid World — Sutton & Barto Fig. 4.1 style episodic task (short episodes for MC)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional, Tuple
import numpy as np
from envs.cliff_walking import ACTION_DELTAS, Action, StepResult
State = Tuple[int, int]
@dataclass
class GridWorldEnv:
"""
Agent moves on a grid; each step −1 until reaching the goal.
Episodes are short (≤ 6 steps optimal), ideal for first-visit MC control demos.
"""
n_rows: int = 4
n_cols: int = 4
start: State = (0, 0)
goal: State = (3, 3)
step_reward: float = -1.0
def __post_init__(self) -> None:
self.state: State = self.start
def reset(self, *, seed: Optional[int] = None) -> State:
if seed is not None:
np.random.seed(seed)
self.state = self.start
return self.state
def in_bounds(self, row: int, col: int) -> bool:
return 0 <= row < self.n_rows and 0 <= col < self.n_cols
def step(self, action: Action) -> StepResult:
dr, dc = ACTION_DELTAS[action]
r, c = self.state
nr, nc = r + dr, c + dc
if not self.in_bounds(nr, nc):
nr, nc = r, c
self.state = (nr, nc)
terminated = self.state == self.goal
reward = self.step_reward
return StepResult(
next_state=self.state,
reward=reward,
terminated=terminated,
truncated=False,
info={"reached_goal": terminated},
)
def all_states(self) -> List[State]:
return [(r, c) for r in range(self.n_rows) for c in range(self.n_cols)]
def render_ascii(self, agent: Optional[State] = None) -> str:
agent = agent if agent is not None else self.state
lines = []
for r in range(self.n_rows):
row_chars = []
for c in range(self.n_cols):
if (r, c) == agent:
ch = "A"
elif (r, c) == self.goal:
ch = "G"
elif (r, c) == self.start:
ch = "S"
else:
ch = "."
row_chars.append(ch)
lines.append(" ".join(row_chars))
return "\n".join(lines)
这段代码实现了一个非常简洁的 4×4 Grid World 环境,目标是产生短 episode,方便做 first-visit Monte Carlo control 的演示。接口风格与前面的 CliffWalkingEnv 基本一致( reset/step/state/n_rows/n_cols ),所以能直接被你的 MC 控制算法复用。 1) 环境设定与状态/动作 State = (row, col) ,左上角 (0,0) ,右下角 (3,3) 。 动作 Action 以及动作位移 ACTION_DELTAS 复用 cliff_walking.py 里的定义: 0 上,1 右,2 下,3 左。 默认参数: n_rows=4, n_cols=4 start=(0,0) goal=(3,3) step_reward=-1 每走一步都是 -1,直到到达 goal 终止。 这意味着最优路径长度是 6(例如 3 次右 + 3 次下),最优回报是 -6(因为终止那一步也给 -1)。 2) 生命周期: __post_init__ 与 reset() __post_init__ dataclass 初始化后设定: self.state = self.start reset(seed=...) if seed is not None: np.random.seed(seed) self.state = self.start return state 说明: 环境本身确定性,无随机性,所以 seed 基本无意义。 用的是全局 np.random.seed ,不够“封装”,但因为无随机性影响很小。 3) 动作执行: step() 核心逻辑: 计算候选下一位置 dr, dc = ACTION_DELTAS[action] nr, nc = r+dr, c+dc 越界处理:越界则保持原地不动(撞墙) if not in_bounds(nr,nc): nr, nc = r, c 更新状态、计算终止与奖励 self.state = (nr,nc) terminated = (self.state == self.goal) reward = step_reward # 始终 -1 返回 StepResult truncated=False (环境不做时间截断) info={"reached_goal": terminated} 4) 与 CliffWalking 的关键差异(对 MC 学习表现影响很大) 没有 cliff / 大惩罚 / 传送回起点,所以 episode 很短、回报范围小,学习更稳定。 每步奖励恒定 -1,因此: 状态价值主要反映到 goal 的“距离”(在最优策略下几乎等于负的最短步数)。 终止只有一种:到达 goal。 因为环境不会产生 truncated,而你的 generate_episode 有 max_steps 保险,所以一般不会出现“跳过不完整 episode”的情况。 5) 代码层面可能的注意点/改进点 seed 的作用 如果以后你加随机性(比如随机起点、随机障碍),建议把 RNG 做成实例变量( self.rng = np.random.default_rng(seed) ),而不是 np.random.seed 全局设定。 step_reward 的终止处理 当前实现:到达 goal 的那一步仍给 -1。 如果你想让到达 goal 奖励为 0(某些版本的 gridworld 会这么设),需要在 terminated 时改 reward。 与 TabularEnv Protocol 的匹配 你的 MC 代码用 q = zeros((env.n_rows, env.n_cols, 4)) ,并用 q[state] 或 q[s[0],s[1],a] 索引,本环境完全匹配。 6) 对 first-visit MC control 的意义 这个环境是“非常适合 MC”的 demo 环境,因为: episode 短 → 回报方差小 更容易在较少回合内看到策略变好 相比 cliff walking,少了“很长 episode + 掉崖重置”的高方差情况 如果你用同一套 MC 控制代码在 grid 与 cliff 上训练,会明显观察到: grid 收敛更快
5. scripts
train.py
#!/usr/bin/env python3
"""Batch training without GUI — prints final greedy path and ASCII map."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import numpy as np
_ROOT = Path(__file__).resolve().parents[1]
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
import project_root
project_root.setup()
from algorithms.monte_carlo_control import first_visit_mc_control, greedy_rollout
from envs.cliff_walking import ACTION_NAMES, CliffWalkingEnv
from envs.gridworld import GridWorldEnv
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--env", choices=("grid", "cliff"), default="cliff")
p.add_argument("--episodes", type=int, default=8000)
p.add_argument("--epsilon", type=float, default=0.2)
p.add_argument("--epsilon-end", type=float, default=0.02)
p.add_argument("--seed", type=int, default=0)
args = p.parse_args()
if args.env == "grid":
env = GridWorldEnv()
skip = False
else:
env = CliffWalkingEnv()
skip = True
q, policy, returns = first_visit_mc_control(
env,
n_episodes=args.episodes,
epsilon=args.epsilon,
epsilon_end=args.epsilon_end,
seed=args.seed,
skip_incomplete=skip,
)
path, total = greedy_rollout(env, q)
print(f"Env: {args.env}, episodes: {args.episodes}, last-100 mean return: {np.mean(returns[-100:]):.2f}")
print(f"Greedy rollout return: {total:.0f}, steps: {len(path)-1}, reached_goal: {path[-1] == env.goal}")
print("Greedy path:")
for i, (r, c) in enumerate(path):
mark = ""
if i > 0:
a = int(policy[path[i - 1][0], path[i - 1][1]])
mark = f" --{ACTION_NAMES[a]}-->"
print(f" {i}: ({r},{c}){mark}")
print("\nASCII:")
env.state = path[-1]
print(env.render_ascii(agent=path[-1]))
if __name__ == "__main__":
main()
这段脚本是一个无 GUI 的批量训练入口:训练完之后用学到的 Q 表做一次贪心 rollout,然后把路径(含箭头动作)和 ASCII 地图打印出来,方便在终端快速验证“学没学到”。 1) 命令行参数与环境选择 参数: --env grid|cliff :选择 GridWorldEnv 或 CliffWalkingEnv --episodes :训练回合数,默认 8000 --epsilon 、 --epsilon-end :训练用 ε 及退火终值 --seed :传入 MC 控制的 RNG seed(注意:环境本身几乎无随机性,随机性主要来自 ε-greedy 选动作) 环境选择逻辑: if grid: env=GridWorldEnv(); skip=False else: env=CliffWalkingEnv(); skip=True skip 会传给 first_visit_mc_control(skip_incomplete=skip) : grid world episode 一般都会终止,不需要跳过 cliff walking 若 episode 很长可能撞到 generate_episode 的 max_steps 上限导致 truncated(虽然你的 cliff env 自己不产生 truncated,但 episode 生成器的循环上限等价于截断),因此选择跳过不完整 episode,让 MC 更新只用真正到达 goal 的轨迹(降低偏差/噪声) 2) 训练调用: first_visit_mc_control q, policy, returns = first_visit_mc_control(...) 返回: q : (rows, cols, 4) policy : (rows, cols) 的贪心动作 argmax returns : 每回合总回报(注意:这里的“回报”是 sum of rewards,不是折扣回报;你的 MC 更新内部用的是折扣回报 G,但这里记录是简单求和) 然后做一次评估: path, total = greedy_rollout(env, q) 这里的 greedy_rollout: env.reset() 后一直选 argmax(q[s]) 累积 reward 直到 terminated/truncated 或 max_steps 3) 打印内容解释 3.1 训练表现摘要 print(last-100 mean return) 用 np.mean(returns[-100:]) 做收敛情况的粗略指标(越接近最优越好;cliff 中通常是接近 -13 左右/或更差,取决于策略是否贴边走等) 3.2 贪心 rollout 摘要 print(f"Greedy rollout return: {total}, steps: {len(path)-1}, reached_goal: {path[-1]==env.goal}") total 是评估那条贪心路径的累计 reward steps 是走了多少步 reached_goal 通过末状态判断是否到达目标 3.3 逐步路径(带动作箭头) for i, (r,c) in enumerate(path): if i>0: a = policy[path[i-1]] mark = --ACTION_NAMES[a]--> 注意一个细节:这里动作是从 policy 查的,而不是从 path 推断的。通常一致,但如果 greedy_rollout 的动作选择与 policy 定义一致(都是 argmax),那么箭头会准确。 4) ASCII 渲染部分 env.state = path[-1] print(env.render_ascii(agent=path[-1])) 这里设置 env.state 其实不是必须的,因为 render_ascii 接受 agent 参数并优先用它;但设置一下也无妨。 输出图里: Cliff 环境可能显示 S/G/C/A/. 等(取决于 render_ascii 实现) Grid 环境显示 S/G/A/. 等 5) 可能的注意点 / 改进建议 skip_incomplete 的语义 你在 MC 实现里 completed = result.terminated ,并且 generate_episode 只有在达到 max_steps 时才会“自然结束但 completed=False”。 所以 skip=True 相当于:只用真正到达 goal 的 episode 更新。 这会减少偏差(避免把被强行截断的回报当作真实回报),但也可能
6. visualization
common.py
from __future__ import annotations
import numpy as np
from envs.cliff_walking import CliffWalkingEnv
from envs.gridworld import GridWorldEnv
def state_values_from_q(q: np.ndarray) -> np.ndarray:
return q.max(axis=-1)
def make_env(name: str):
if name == "cliff":
return CliffWalkingEnv(), True
if name == "grid":
return GridWorldEnv(), False
raise ValueError(f"Unknown env {name!r}, use grid or cliff")
这段代码是可视化/脚本层的两个小工具函数:把 Q 表转换成 V 表,以及根据字符串创建对应环境。 1) state_values_from_q(q) def state_values_from_q(q: np.ndarray) -> np.ndarray: return q.max(axis=-1) 含义:由动作价值函数 (Q(s,a)) 得到状态价值函数 (V(s)) 的一种常用方式: 定义贪心状态价值: [ V(s)=\max_a Q(s,a) ] 对应实现就是沿最后一维(动作维)取最大值。 适用前提: q 的 shape 是 (..., n_actions) ,例如你的项目里是 (n_rows, n_cols, 4) 。 若某些动作非法但仍在动作维里占位,这种直接 max 可能会把“非法动作的默认 0”当成最大值,需要用 mask 或 -inf 初始化非法动作(你这里 grid/cliff 动作都合法,所以没问题)。 2) make_env(name) def make_env(name: str): if name == "cliff": return CliffWalkingEnv(), True if name == "grid": return GridWorldEnv(), False raise ValueError(...) 作用: 根据字符串创建环境实例 同时返回一个布尔值(这里命名习惯是 is_cliff ) 这个 is_cliff 在你其他脚本里用于: skip_incomplete=is_cliff :cliff 任务 episode 可能很长/容易被 max_steps 截断,因此选择跳过截断 episode 可视化上采用不同的 y 轴范围、reward grid 标红 cliff 等 返回类型是一个二元组: env : CliffWalkingEnv 或 GridWorldEnv is_cliff : True/False 3) 小的工程建议(可选) 可以给 make_env 加上类型标注,例如: from typing import Tuple, Union def make_env(name: str) -> Tuple[Union[CliffWalkingEnv, GridWorldEnv], bool]: ... 如果未来环境增多,建议用 dict 映射代替 if-else,并把元数据(比如 is_cliff、推荐的 vmin/vmax、默认 epsilon 等)统一管理。 总体来说这段代码很直接,且与前面 MC control + 可视化脚本配合紧密: state_values_from_q 用于画 V(s), make_env 用于统一创建环境并传递“是否 cliff”这一任务标签。
matplotlib_grid.py
from __future__ import annotations
from typing import List, Optional
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import FancyArrowPatch
def draw_policy_grid(
ax,
env,
values: np.ndarray,
policy: Optional[np.ndarray],
path: Optional[List[tuple]],
title: str,
*,
is_cliff: bool,
) -> None:
ax.clear()
n_r, n_c = env.n_rows, env.n_cols
cliff = set(env.cliff_cells) if is_cliff else set()
grid = np.zeros((n_r, n_c))
for r in range(n_r):
for c in range(n_c):
if (r, c) in cliff:
grid[r, c] = np.nan
else:
grid[r, c] = values[r, c]
vmin, vmax = (-120, 0) if is_cliff else (-25, 0)
im = ax.imshow(grid, cmap="viridis", origin="upper", vmin=vmin, vmax=vmax)
for r in range(n_r):
for c in range(n_c):
if (r, c) in cliff:
ax.add_patch(plt.Rectangle((c - 0.5, r - 0.5), 1, 1, fill=True, color="#8b0000", alpha=0.85))
ax.text(c, r, "X", ha="center", va="center", color="white", fontsize=9)
elif (r, c) == env.start:
ax.text(c, r, "S", ha="center", va="center", color="white", fontsize=11, fontweight="bold")
elif (r, c) == env.goal:
ax.text(c, r, "G", ha="center", va="center", color="white", fontsize=11, fontweight="bold")
if policy is not None:
for r in range(n_r):
for c in range(n_c):
if (r, c) in cliff or (r, c) == env.goal:
continue
a = int(policy[r, c])
dx, dy = [(0, -0.35), (0.35, 0), (0, 0.35), (-0.35, 0)][a]
ax.add_patch(
FancyArrowPatch(
(c, r),
(c + dx, r + dy),
arrowstyle="-|>",
mutation_scale=12,
color="white",
linewidth=1.2,
alpha=0.9,
)
)
if path:
xs = [c for _, c in path]
ys = [r for r, _ in path]
ax.plot(xs, ys, "o-", color="#ffcc00", markersize=6, linewidth=2, label="greedy path")
ax.plot(xs[-1], ys[-1], "o", color="#ff3300", markersize=10)
ax.set_xticks(range(n_c))
ax.set_yticks(range(n_r))
ax.set_xticklabels([])
ax.set_yticklabels([])
ax.set_title(title)
ax.set_xlim(-0.5, n_c - 0.5)
ax.set_ylim(n_r - 0.5, -0.5)
return im
这段 draw_policy_grid 是一个 Matplotlib 可视化函数:在网格上画出 状态价值热力图(来自 values ),叠加 cliff/起点/终点标记,可选叠加 策略箭头(来自 policy ),以及可选叠加 一条路径(通常是 greedy rollout 得到的轨迹)。 1) 函数签名与输入含义 def draw_policy_grid(ax, env, values, policy, path, title, *, is_cliff) ax : matplotlib axes,函数会 ax.clear() 并重画整幅图 env : 环境对象,要求至少有: n_rows, n_cols start, goal 若 is_cliff=True ,还要求 env.cliff_cells values: np.ndarray : shape (n_rows, n_cols) ,一般是 V(s) (例如 state_values_from_q(q) ) policy: Optional[np.ndarray] : shape (n_rows, n_cols) ,每格一个动作编号 0..3; None 表示不画策略 path: Optional[List[tuple]] : 轨迹(list of (row,col) );若提供则画路径折线和终点点 title : 图标题 is_cliff : 是否 cliff 环境,用来决定 cliff 单元格处理和颜色范围 返回值: im (imshow 的 image 对象),便于动画函数返回 artist(FuncAnimation 常用)。 2) 价值网格的构造(cliff 设为 NaN) cliff = set(env.cliff_cells) if is_cliff else set() grid = np.zeros((n_r, n_c)) for each cell: if cliff: grid[r,c]=np.nan else: grid[r,c]=values[r,c] 目的: cliff 格子在 imshow 里用 NaN 让 colormap“断开”(不过不同 colormap 对 NaN 的显示取决于设置,很多情况下会透明/用 bad color)。 后面又额外用红色矩形覆盖 cliff,因此这里 NaN 主要是避免把 cliff 的 values 误画成正常颜色。 3) imshow 与颜色范围 vmin, vmax = (-120, 0) if is_cliff else (-25, 0) im = ax.imshow(grid, cmap="viridis", origin="upper", vmin=vmin, vmax=vmax) 固定 vmin/vmax :让不同帧之间颜色一致,便于对比学习进度。 cliff 的值域更负(可能到 -100 左右甚至更低),所以给 -120..0 gridworld 值域较小,所以 -25..0 潜在问题/注意: 如果你的回报尺度变化(比如 step_reward 改成 -0.1 或 cliff_reward 改成 -500),固定范围会导致画面全挤在一侧。可考虑动态范围或参数化范围。 4) 叠加标记:cliff / S / G 循环每个格子: cliff:画深红矩形 + 写 “X” start:写 “S” goal:写 “G” 这里文字用白色,且 start/goal 用更大更粗的字体。 5) 叠加策略箭头(如果 policy 非 None) if policy is not None: for each cell: if cliff or goal: continue a = policy[r,c] dx,dy = lookup[a] FancyArrowPatch((c,r),(c+dx,r+dy), ...) 注意坐标系: 画图时 x 对应列 c ,y 对应行 r (因此起点写在 (c,r)) origin="upper" 且最后 ax.set_ylim(n_r - 0.5, -0.5) 把 y 轴翻转,让 (0,0) 在左上,符合网格世界的行列直觉。 dx,dy 映射: 上(0): (0, -0.35) y 变小表示往上(在这种坐标设置下是正确的) 右(1): (+0.35, 0) 下(2): (0, +0.35)
pygame_view.py
"""Pygame renderer for grid / cliff tabular RL demos."""
from __future__ import annotations
from typing import List, Optional, Sequence, Tuple
import numpy as np
from envs.cliff_walking import ACTION_NAMES, Action, State
Color = Tuple[int, int, int]
COL_BG = (24, 28, 36)
COL_GRID = (45, 52, 64)
COL_CELL = (58, 68, 84)
COL_START = (46, 125, 50)
COL_GOAL = (21, 101, 192)
COL_CLIFF = (183, 28, 28)
COL_AGENT = (255, 193, 7)
COL_AGENT_OUTLINE = (255, 87, 34)
COL_TEXT = (230, 236, 245)
COL_ARROW = (200, 220, 255)
COL_PATH = (255, 235, 59)
def _has_cliff(env) -> bool:
return hasattr(env, "cliff_cells")
class GridRenderer:
def __init__(self, env, cell_size: Optional[int] = None) -> None:
import pygame
self.pygame = pygame
self.env = env
self.is_cliff = _has_cliff(env)
if cell_size is None:
cell_size = 72 if env.n_cols <= 6 else max(36, 720 // env.n_cols)
self.cell = cell_size
self.pad = 16
self.hud_h = 120
w = self.pad * 2 + env.n_cols * self.cell
h = self.pad * 2 + env.n_rows * self.cell + self.hud_h
self.screen = pygame.display.set_mode((w, h))
pygame.display.set_caption("Monte Carlo RL — step playback")
self.font = pygame.font.SysFont("dejavusans", 18)
self.font_sm = pygame.font.SysFont("dejavusans", 14)
self.clock = pygame.time.Clock()
def cell_center(self, state: State) -> Tuple[int, int]:
r, c = state
x = self.pad + c * self.cell + self.cell // 2
y = self.pad + r * self.cell + self.cell // 2
return x, y
def draw_board(
self,
agent: State,
q: Optional[np.ndarray] = None,
highlight_path: Optional[Sequence[State]] = None,
) -> None:
pg = self.pygame
env = self.env
cliff = set(env.cliff_cells) if self.is_cliff else set()
path_set = set(highlight_path or [])
self.screen.fill(COL_BG)
for r in range(env.n_rows):
for c in range(env.n_cols):
rect = pg.Rect(
self.pad + c * self.cell,
self.pad + r * self.cell,
self.cell,
self.cell,
)
pos = (r, c)
if pos in cliff:
color = COL_CLIFF
elif pos == env.goal:
color = COL_GOAL
elif pos == env.start:
color = COL_START
elif pos in path_set:
color = tuple(min(255, x + 25) for x in COL_CELL)
else:
color = COL_CELL
pg.draw.rect(self.screen, color, rect, border_radius=4)
pg.draw.rect(self.screen, COL_GRID, rect, 1, border_radius=4)
if q is not None and pos not in cliff and pos != env.goal:
v = float(q[r, c].max())
label = self.font_sm.render(f"{v:.0f}", True, COL_TEXT)
self.screen.blit(label, (rect.x + 4, rect.y + 4))
if q is not None:
policy = np.argmax(q, axis=-1)
for r in range(env.n_rows):
for c in range(env.n_cols):
if (r, c) in cliff or (r, c) == env.goal:
continue
a = int(policy[r, c])
cx, cy = self.cell_center((r, c))
dx, dy = [(0, -14), (14, 0), (0, 14), (-14, 0)][a]
pg.draw.line(
self.screen,
COL_ARROW,
(cx, cy),
(cx + dx, cy + dy),
3,
)
if highlight_path and len(highlight_path) > 1:
pts = [self.cell_center(s) for s in highlight_path]
pg.draw.lines(self.screen, COL_PATH, False, pts, 2)
ax, ay = self.cell_center(agent)
pg.draw.circle(self.screen, COL_AGENT_OUTLINE, (ax, ay), self.cell // 3 + 2)
pg.draw.circle(self.screen, COL_AGENT, (ax, ay), self.cell // 3)
labels_y = self.pad * 2 + env.n_rows * self.cell + 8
pg.draw.line(
self.screen,
COL_GRID,
(self.pad, labels_y - 4),
(self.screen.get_width() - self.pad, labels_y - 4),
1,
)
def draw_hud(self, lines: List[str]) -> None:
y0 = self.pad * 2 + self.env.n_rows * self.cell + 12
for i, line in enumerate(lines):
surf = self.font.render(line, True, COL_TEXT)
self.screen.blit(surf, (self.pad, y0 + i * 22))
def flip(self, fps: int = 60) -> None:
self.pygame.display.flip()
self.clock.tick(fps)
def pump_events(self) -> Tuple[bool, bool, int]:
pg = self.pygame
quit_requested = False
paused = False
speed_delta = 0
for event in pg.event.get():
if event.type == pg.QUIT:
quit_requested = True
elif event.type == pg.KEYDOWN:
if event.key in (pg.K_q, pg.K_ESCAPE):
quit_requested = True
elif event.key == pg.K_SPACE:
paused = True
elif event.key in (pg.K_PLUS, pg.K_EQUALS, pg.K_UP):
speed_delta = 1
elif event.key in (pg.K_MINUS, pg.K_DOWN):
speed_delta = -1
elif event.key == pg.K_n:
speed_delta = 99
return quit_requested, paused, speed_delta
def replay_episode(
renderer: GridRenderer,
env,
episode: List[Tuple[State, Action, float]],
q: np.ndarray,
*,
step_ms: int,
hud_prefix: List[str],
) -> Tuple[bool, int]:
pg = renderer.pygame
env.reset()
path: List[State] = [env.state]
total_r = 0.0
skip = False
for step_i, (s, a, r) in enumerate(episode):
if env.state != s:
env.state = s
while True:
renderer.draw_board(env.state, q=q, highlight_path=path)
hud = list(hud_prefix) + [
f"Step {step_i + 1}/{len(episode)} action {ACTION_NAMES[a]} reward {r:+.0f}",
f"Return so far: {total_r + r:+.0f} | ms/step: {step_ms} "
"[Space] pause [+/-] speed [N] skip ep [Q] quit",
]
renderer.draw_hud(hud)
renderer.flip()
quit_req, paused, spd = renderer.pump_events()
if quit_req:
return True, step_ms
if spd == 99:
skip = True
break
if spd != 0:
step_ms = int(np.clip(step_ms + spd * 40, 40, 2000))
if not paused:
break
pg.time.wait(30)
if skip:
break
total_r += r
result = env.step(a)
path.append(env.state)
t0 = pg.time.get_ticks()
while pg.time.get_ticks() - t0 < step_ms:
renderer.draw_board(env.state, q=q, highlight_path=path)
hud = list(hud_prefix) + [
f"Step {step_i + 1}/{len(episode)} -> {env.state} reward {r:+.0f}",
f"Return so far: {total_r:+.0f} | ms/step: {step_ms}",
]
renderer.draw_hud(hud)
renderer.flip()
quit_req, _, spd = renderer.pump_events()
if quit_req:
return True, step_ms
if spd == 99:
return False, step_ms
if spd != 0:
step_ms = int(np.clip(step_ms + spd * 40, 40, 2000))
if result.terminated:
break
return False, step_ms
这份代码提供了一个用于 grid/cliff tabular RL demo 的 Pygame 渲染器,以及一个“按 episode 逐步回放”的函数 replay_episode 。它不负责学习,只负责把环境、Q 值、策略、路径、HUD 信息可视化,并提供键盘交互控制播放速度/暂停/跳过/退出。 1) 结构概览 一组颜色常量:背景、格子、起点/终点、cliff、agent、路径、文字等。 _has_cliff(env) :通过 hasattr(env, "cliff_cells") 判断环境是否是 cliff 类。 GridRenderer : 初始化窗口尺寸、字体等 draw_board() 画棋盘+数值+箭头+路径+agent draw_hud() 画文字 flip() 更新屏幕并限制帧率 pump_events() 处理键盘/窗口事件 replay_episode() : 给定一个 episode((s,a,r)序列)在屏幕上逐步播放,并支持暂停/调速/跳过/退出。 2) GridRenderer.__init__ 窗口尺寸自适应 cell_size = 72 if env.n_cols <= 6 else max(36, 720 // env.n_cols) w = pad*2 + n_cols*cell h = pad*2 + n_rows*cell + hud_h 小网格(≤6列)每格 72px,较大网格按 720px 宽度缩放但不小于 36px。 HUD 固定高度 120。 资源初始化 pygame.display.set_mode((w,h)) 创建窗口 字体 SysFont("dejavusans", 18) 与小字体 14 clock 用于 tick(fps) 3) 坐标换算: cell_center(state) 把 (row,col) 转屏幕像素中心点 (x,y) : x = pad + col*cell + cell/2 y = pad + row*cell + cell/2 这与常见的矩阵索引一致:行映射 y,列映射 x。 4) 画棋盘: draw_board(agent, q=None, highlight_path=None) 4.1 每个格子的底色 按优先级选择颜色: cliff(若有): COL_CLIFF goal: COL_GOAL start: COL_START 在 highlight_path 中:把 COL_CELL 提亮(每个通道 +25,封顶 255) 默认: COL_CELL 然后画: 填充矩形(带圆角) 画一层边框网格线 4.2 显示数值(V) 若传入 q ,并且当前格不是 cliff 且不是 goal: v = float(q[r,c].max()) render(f"{v:.0f}") 即在格子左上角画 V(s)=max_a Q(s,a) 的整数形式。 注意:这里不显示 goal 的值;如果你希望显示也可以改条件。 4.3 画策略箭头(其实是短线段) 若 q is not None : policy = argmax(q, axis=-1) dx,dy = [(0,-14),(14,0),(0,14),(-14,0)][a] draw.line(center, center+(dx,dy), width=3) 这是简化版箭头(线段,不是带箭头的三角形),方向与动作对应。 4.4 画路径 若 highlight_path 长度>1,用 pg.draw.lines 连线。 4.5 画 agent(圆形) 画两层圆:外圈 COL_AGENT_OUTLINE ,内圈 COL_AGENT 。 4.6 HUD 分隔线 在棋盘下方画一条横线。 5) HUD: draw_hud(lines) 把传入的字符串列表逐行渲染到棋盘下方区域: 每行间隔 22px 6) 事件处理: pump_events() 返回三元组: (quit_requested, paused, speed_delta7) replay_episode(...) :逐步回放一个 episode(核心交互逻辑) 签名: replay_episode(renderer, env, episode, q, *, step_ms, hud_prefix) -> (quit_all, step_ms) episode :学习时记录的序列 [(s,a,r), ...] q :当前 Q 表,用来在格子里显示 V 值与策略线段 step_ms :每一步停留多少毫秒(可通过键盘动态调整) hud_prefix :HUD 前两行之类的固定信息(如 episode 编号、epsilon、avg100) 返回: quit_all :是否请求退出整个程序 step_ms :可能被用户调速后更新的步长 7.1 重置并准备状态 env.reset() path = [env.state] total_r = 0.0 skip = False 它会把环境重置到 start,准备按 episode 执行并累积路径与回报。 7.2 逐步执行 episode for step_i, (s, a, r) in enumerate(episode): (A) 同步环境状态到 episode 中记录的 s if env.state != s: env.state = s 这是个“保险”:因为环境可能被其他地方 reset/step 过(比如训练与回放共用 env),这里强制对齐回放用的状态。 注意:这属于“直接篡改 env.state”。对你自己的 tabular env 没问题,但如果换成更复杂环境(状态不是唯一内部变量),这种做法可能不安全。更稳妥的是回放时用一个独立环境实例,或让 env 提供 set_state() 。 (B) 第一段 while:在执行 action 前,先显示“将要执行”的信息,并允许暂停/调速/跳过 while True: draw_board(...) draw_hud([... Step i action ... reward r ...]) flip() quit_req, paused, spd = pump_events() ... if not paused: break wait(30) 键位语义由 pump_events() 给出: 退出: Q / Esc / 关闭窗口 暂停: Space (注意:这里的 paused 不是“切换状态”,而是“本次事件里按了 space 就暂停一次循环”,所以你要继续按 space 才会继续?实际上逻辑是:按下 space 时 paused=True ,循环会 wait(30) 并继续 while;下一轮如果你不再按 space, paused=False 就会 break。也就是说它更像“按住/连按 space 才暂停”,不是 toggle。若想 toggle,需要持久的 paused 状态变量。) 调速: + / = / ↑ 加速( speed_delta=1 ), - / ↓ 减速( speed_delta=-1 ) N : speed_delta=99 特判为“跳过本 episode 的后续回放” 调速实现: step_ms = clip(step_ms + spd*40, 40, 2000) 如果用户按 N : 设置 skip=True break 出 while,然后下面会 if skip: break 直接结束 episode 回放(但不会退出程序) (C) 执行该步:累积回报、env.step,并记录 path total_r += r result = env.step(a) path.append(env.state) 这里用的是 episode 中记录的 r 做 total_r 累积,然后再 env.step(a) 推进环境。 潜在一致性问题:如果 episode 数据与环境 step 逻辑不一致(比如环境改了、或 episode 来自别的 env),这里显示的 r 与 env.step(a) 得到的 reward 可能不同。但在你的项目里 episode 就是同一个 env 生成的,所以一致。 (D) 第二段 while:执行后停留 step_ms 毫秒显示“执行后的状态” t0 = get_ticks() while now - t0 < step_ms: draw_board(env.state,...) draw_hud([... -> next_state ...]) flip() quit_req, _, spd = pump_events() ...
7. monte_carlo_rl
project_root.py
"""Add monte_carlo_rl project root to sys.path for script entrypoints."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def setup() -> Path:
root_s = str(ROOT)
if root_s not in sys.path:
sys.path.insert(0, root_s)
return ROOT
8. 分析
策略评估(Policy Evaluation)在哪?
对应:用当前策略采样轨迹,估计 Q(s,a)(First-visit MC 增量均值)。
步骤 代码位置 做什么 按 当前 ε-软策略 采样
generate_episode()
39:57:algorithms/monte_carlo_control.py里每步epsilon_greedy_action(q, ...)从轨迹末尾算回报 G
first_visit_mc_control()
92:103:algorithms/monte_carlo_control.py反向累加g = gamma * g + r更新 Q(评估)
同上
First-visit:每个
(s,a)本局第一次出现时returns_sum/count→q[s,a]也就是说:评估 = 在固定「当前 Q + ε-贪心行为策略」下,用 MC 回报更新 Q,没有单独的
V(s)表,只有 Q 表。
策略提升(Policy Improvement)在哪?
对应:行为策略随 Q 变好而变(on-policy ε-贪心),以及训练结束后的 贪心策略。
形式 代码位置 做什么 在线提升(隐式)
epsilon_greedy_action()+ 下一局generate_episode()
25:36:algorithms/monte_carlo_control.pyQ 变大后,同一 ε-贪心会更多选高 Q 动作显式贪心策略
first_visit_mc_control()末尾
111:111:algorithms/monte_carlo_control.pypolicy = np.argmax(q, axis=-1)用贪心策略 rollout(评估用)
greedy_rollout()
115:131:algorithms/monte_carlo_control.py纯argmax Q走一局,给 demo /train.py看回报教材里「π' = greedy(Q)」在这里是:每局行为仍带 ε 探索;画图/打印时用
argmax Q(见demos/demo_matplotlib.py里np.argmax(q, axis=-1))。
和教材伪代码的对应
repeat forever:
(1) 用 π 生成 episode → generate_episode
(2) 对 episode 做 MC 更新 Q → first_visit_mc_control 内 backward loop
(3) π ← ε-greedy(Q) → 下一局 generate_episode 自动用新 Q
没有拆成「先 eval 到收敛再 improvement 一轮」的 MC Policy Iteration;只有 MC Control 这一版。
9. 输出
带悬崖的路径


不带悬崖的路径


本文详解蒙特卡洛(Monte Carlo)方法在强化学习中的on-policy控制实现,聚焦于first-visit MC control算法。通过cliff_walking和gridworld环境,结合pygame与matplotlib可视化,完成策略评估(Q表增量更新)与策略提升(ε-贪心改进)。代码结构清晰覆盖采样、更新、渲染全流程,强调behavior policy与target policy的一致性,对应经典MC Control伪代码,不采用分步式策略迭代。
228

被折叠的 条评论
为什么被折叠?



