Extract shared maze code into common package (Gymnasium-style env)

This commit is contained in:
2026-09-01 15:55:14 +08:00
parent d2dc1f0910
commit d54072ede4
5 changed files with 665 additions and 284 deletions
+128
View File
@@ -0,0 +1,128 @@
# DQN 走迷宫:原理与推导
> 对应代码 `dqn/dqn_maze.py`PyTorch 实现)。本文不含代码,只讲数学。行内公式用 `$...$`、独立公式用 `$$...$$`Gitea 可直接渲染。
---
## 一、为什么需要 DQN
Q-Learning 把 Q 值存在一张表里,需要为每个 (状态, 动作) 单独维护一个数。状态空间一大(比如用整张地图做状态),表就装不下、也学不到没见过的格子。
DQN 的思路:**用神经网络把 Q 值"拟合"出来**,而不是逐格记录
$$Q(s,a)\;\approx\;Q(s,a;\theta)$$
其中 $\theta$ 是网络参数。输入状态 $s$,输出各动作的价值。见过 $(s,a)$ 后学到的规律会被网络**泛化**到相近的状态(虽然本迷宫是离散格,但组件完全通用)。
迷宫的问题设定(状态、动作、奖励、转移)与 Q-Learning 完全相同,见 `q-learning.md` 第一、二节;下面直接讲 DQN 自己的部分。
---
## 二、损失函数:把 TD 更新变成监督学习
Q-Learning 的更新是 $Q(s,a)\leftarrow Q(s,a)+\alpha\,\delta$,其中 TD 误差
$$\delta = r+\gamma\max_{a'}Q(s',a')-Q(s,a)$$
DQN 把它改写成**最小化均方误差**的目标:
$$L(\theta)=\mathbb{E}_{(s,a,r,s')}\Big[\big(Y-Q(s,a;\theta)\big)^{2}\Big]$$
其中目标值 $Y$ 用"冻结"的参数 $\theta^{-}$(目标网络)计算:
$$Y=r+\gamma\max_{a'}Q(s',a';\theta^{-})$$
对 $\theta$ 求梯度($Y$ 视为常数,不做梯度):
$$\nabla_{\theta}L=\mathbb{E}\Big[-2\big(Y-Q(s,a;\theta)\big)\,\nabla_{\theta}Q(s,a;\theta)\Big]$$
这正是把贝尔曼最优方程的**样本近似**当成监督学习的"标签"$Y$ 当标签、$Q(s,a;\theta)$ 当预测,用梯度下降让预测逼近标签。$Y$ 本身又依赖旧参数 $\theta^{-}$,所以这是"自举式"bootstrap)的监督——目标会随学习移动。
---
## 三、两个关键技巧
### 3.1 经验回放(Replay Buffer
把每条经历 $(s,a,r,s',done)$ 存入一个容量有限的缓冲池,训练时**随机抽一个小批量**更新。
**为什么必须这么做**:TD 目标依赖下一个状态,而连续几步的状态高度相关。若直接用当前轨迹在线更新,梯度在时间上强相关,损失会剧烈震荡甚至发散;随机抽样把这些样本打乱成近似独立同分布,相当于稳定的"数据集",梯度才像普通监督学习那样平稳下降。此外一条经验可被多次复用,样本效率更高。
### 3.2 目标网络(Target Network
若 $Y$ 也用同一个网络 $\theta$ 计算,则"标签"和"预测"同时变化,更新目标一直在追着自己跑(自举放大),训练极易振荡。
解决办法:维护一份**滞后副本** $\theta^{-}$,用 $\theta^{-}$ 算 $Y$,而 $\theta$ 只负责预测并求梯度;每隔固定步数 $C$ 把 $\theta$ 复制给 $\theta^{-}$。这保证了一个更新周期内标签固定,等价于把损失函数稳定下来再优化。
---
## 四、探索与状态表示
- **探索**:与 Q-Learning 相同,用 ε-greedy:以概率 $\varepsilon$ 均匀随机、否则取 $\arg\max_{a}Q(s,a;\theta)$;ε 按 $\varepsilon_k=\max(\varepsilon_{\min},\varepsilon_0\lambda^{k})$ 指数衰减。
- **状态表示**:网络输入需要数字向量。本实现把格子坐标编码成 one-hot 向量(长度 $n\times n$,当前位置为 1),可扩展到"把整张地图作为网格图像 + 卷积"的标准 DQN 形式。
---
## 五、与 Q-Learning 的对比
| | Q-Learning | DQN |
|---|---|---|
| Q 的载体 | 表格 $Q[r,c,a]$ | 神经网络 $Q(s,a;\theta)$ |
| 更新目标 | $r+\gamma\max_{a'}Q(s',a')$(查表)| 同上,但用目标网络 $\theta^{-}$ |
| 优化手段 | 直接加法修正 | 对 MSE 损失做梯度下降 |
| 额外组件 | 无 | 经验回放、目标网络 |
| 泛化 | 无(没见过的格子没值)| 有(近似相近状态)|
| 收敛性 | 有理论保证(Watkins)| **无严格保证**,靠工程技巧稳定 |
两者本质相同:都在做贝尔曼最优方程的随机近似,只是函数逼近方式不同。
---
## 六、超参数(默认值)
| 超参数 | 默认 | 说明 |
|---|---|---|
| 网络宽度 hidden | 128 | 两层隐层 |
| 学习率 lr | 1e-3 | Adam 优化器 |
| 批量大小 batch | 64 | 每次从回放池抽样数 |
| 回放容量 | 20000 | 过大滞后、过小样本相关 |
| γ | 0.99 | 折扣因子 |
| ε 衰减 | 1.0 → 0.02 | 每局 ×0.995 |
| 目标更新 C | 200 步 | 同步频率 |
---
## 七、运行与观测
运行参数:`--rows/--cols`(默认 9,须奇数)、`--seed``--episodes`(默认 800)、`--braid``--hidden``--plot`。输出:迷宫、策略箭头、贪心路径、成功率统计;`--plot` 生成两张图。
| 图 | 判读 |
|---|---|
| 逐局回报 | 总体上升(噪声大,看滑动平均)|
| 滑动成功率 | 趋近 1 |
| 单局步数 vs BFS 红线 | 降到红线 = 学会最短路 |
| TD 损失(对数轴)| 下降即 $Q(s,a;\theta)$ 逼近 $Q^{*}$;平台或回升提示超参/技巧问题 |
| V(s) 快照 | 价值从终点回传的过程 |
常见问题:
| 现象 | 原因 | 调整 |
|---|---|---|
| 损失震荡不降 | 无目标网络或回放太小 | 检查目标网络、增大回放容量 |
| 学不到终点 | 探索不足或 γ 太小 | 提高 ε 下限、γ 取 0.99 |
| 前期好后期崩 | 回放池被旧数据稀释、学习率过大 | 降 lr、增容量 |
| 结果不稳定 | DQN 本身随机性强 | 固定 seed、多看几次 |
---
## 八、小结
DQN = Q-Learning 的公式 + 神经网络的表达力 + 两条工程稳定技巧。核心仍是那条损失
$$L(\theta)=\mathbb{E}\big[(r+\gamma\max_{a'}Q(s',a';\theta^{-})-Q(s,a;\theta))^{2}\big]$$
吃透这条式子,再理解回放和目标网络各自解决的"相关性与自举"两个病,DQN 的骨架就齐了——换成连续动作空间(DDPG)或图像输入(CNN)只是改编码器和输出头。
---
*配套阅读:《Q-Learning公式推导详解.md》(理论)、《q-learning.md》(表格法原理)。*
+202
View File
@@ -0,0 +1,202 @@
import argparse
from collections import deque
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from common.maze import (
WALL,
MazeEnv,
evaluate,
generate_maze,
plot_snapshots,
plot_training,
show_maze,
show_path,
show_policy,
)
class DQN(nn.Module):
"""输入: 状态编码(one-hot 位置向量); 输出: 4 个动作的 Q 值。"""
def __init__(self, n_states, n_actions, hidden=128):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_states, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, n_actions),
)
def forward(self, x):
return self.net(x)
class ReplayBuffer:
"""经验回放池: 存储 (s, a, r, s', done), 随机抽样打破时序相关性。"""
def __init__(self, capacity, seed=0):
self.buf = deque(maxlen=capacity)
self.rng = np.random.default_rng(seed)
def push(self, *transition):
self.buf.append(transition)
def sample(self, batch_size):
idx = self.rng.choice(len(self.buf), batch_size, replace=False)
batch = [self.buf[i] for i in idx]
return tuple(np.stack(col) for col in zip(*batch))
def __len__(self):
return len(self.buf)
def encode_state(pos, n_states, n_cols):
"""把格子坐标 (r, c) 编码成 one-hot 向量。"""
one = np.zeros(n_states, dtype=np.float32)
one[pos[0] * n_cols + pos[1]] = 1.0
return one
def optimize(net, target, opt, buffer, batch_size, gamma):
"""一次梯度步: 最小化 (r + γ·max Q(s',·;θ⁻) Q(s,a;θ))²"""
s, a, r, s2, done = buffer.sample(batch_size)
s = torch.as_tensor(s)
a = torch.as_tensor(a, dtype=torch.long).unsqueeze(1)
r = torch.as_tensor(r, dtype=torch.float32)
s2 = torch.as_tensor(s2, dtype=torch.float32)
done = torch.as_tensor(done, dtype=torch.float32)
q = net(s).gather(1, a).squeeze(1)
with torch.no_grad():
y = r + gamma * target(s2).max(1).values * (1.0 - done)
loss = F.mse_loss(q, y)
opt.zero_grad()
loss.backward()
opt.step()
return float(loss.item())
def value_map(env, net, encode):
"""计算每个格子的 V(s)=max_a Q(s,a), 墙为 nan。"""
rows, cols = env.shape
V = np.full((rows, cols), np.nan)
with torch.no_grad():
for r in range(rows):
for c in range(cols):
if env.maze[r, c] == WALL:
continue
x = torch.as_tensor(encode((r, c))).unsqueeze(0)
V[r, c] = float(net(x).max().item())
return V
def train(env, episodes=800, hidden=128, batch_size=64, buffer_capacity=20000,
gamma=0.99, lr=1e-3, epsilon=1.0, eps_min=0.02, eps_decay=0.995,
target_update=200, seed=0, snap_at=()):
torch.manual_seed(seed)
np.random.seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
n_states = env.shape[0] * env.shape[1]
net = DQN(n_states, env.n_actions, hidden).to(device)
target = DQN(n_states, env.n_actions, hidden).to(device)
target.load_state_dict(net.state_dict())
opt = torch.optim.Adam(net.parameters(), lr=lr)
buffer = ReplayBuffer(buffer_capacity, seed=seed)
rng = np.random.default_rng(seed + 1)
def encode(pos):
return encode_state(pos, n_states, env.shape[1])
losses, rewards, successes, ep_steps, eps_hist = [], [], [], [], []
snapshots = []
snap_at = sorted({int(e) for e in snap_at if 0 < int(e) <= episodes})
si = 0
total_steps = 0
for ep in range(1, episodes + 1):
pos, _ = env.reset()
total_r, ep_loss, n_loss = 0.0, 0.0, 0
terminated = truncated = False
while not (terminated or truncated):
# ε-greedy
if rng.random() < epsilon:
a = int(rng.integers(env.n_actions))
else:
with torch.no_grad():
x = torch.as_tensor(encode(pos)).unsqueeze(0).to(device)
a = int(net(x).argmax().item())
nxt, r, terminated, truncated, _ = env.step(a)
# 超时截断(truncated)不代表环境终止, TD 目标不该被截断, 故只存 terminated
buffer.push(encode(pos), a, r, encode(nxt), terminated)
if len(buffer) >= batch_size:
ep_loss += optimize(net, target, opt, buffer, batch_size, gamma)
n_loss += 1
total_steps += 1
if total_steps % target_update == 0:
target.load_state_dict(net.state_dict())
pos, total_r = nxt, total_r + r
epsilon = max(eps_min, epsilon * eps_decay)
losses.append(ep_loss / n_loss if n_loss else float("nan"))
rewards.append(total_r)
successes.append(terminated)
ep_steps.append(env.steps)
eps_hist.append(epsilon)
if si < len(snap_at) and ep == snap_at[si]:
snapshots.append((ep, value_map(env, net, encode)))
si += 1
return net, device, encode, (losses, rewards, successes, ep_steps, eps_hist, snapshots)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="DQN 走迷宫(PyTorch 深度强化学习实现)")
parser.add_argument("--seed", type=int, default=0, help="随机种子")
parser.add_argument("--rows", type=int, default=9, help="迷宫行数(奇数)")
parser.add_argument("--cols", type=int, default=9, help="迷宫列数(奇数)")
parser.add_argument("--episodes", type=int, default=800, help="训练局数")
parser.add_argument("--braid", type=float, default=0.0, help="拆墙成环概率 0~1")
parser.add_argument("--hidden", type=int, default=128, help="隐藏层宽度")
parser.add_argument("--plot", action="store_true",
help="生成 dqn_training.png 与 dqn_snapshots.png")
args = parser.parse_args()
env = MazeEnv(generate_maze(args.rows, args.cols, seed=args.seed, braid=args.braid))
show_maze(env)
snap_at = (1, 20, 100, 300, 500) if args.plot else ()
net, device, encode, hist = train(
env, episodes=args.episodes, hidden=args.hidden, seed=args.seed, snap_at=snap_at)
losses, rewards, successes, ep_steps, eps_hist, snapshots = hist
def act(pos):
with torch.no_grad():
x = torch.as_tensor(encode(pos)).unsqueeze(0).to(device)
return int(net(x).argmax().item())
show_policy(env, act)
show_path(env, act)
rate, avg_steps = evaluate(env, act)
print(f"\n前50局平均回报: {np.mean(rewards[:50]):6.1f} "
f"后50局平均回报: {np.mean(rewards[-50:]):6.1f}")
print(f"后50局成功率: {np.mean(successes[-50:]):.0%}")
print(f"评测(100局贪心): 成功率 {rate:.0%},平均 {avg_steps:.1f} 步到达终点")
if args.plot:
plot_training(env, rewards, successes, ep_steps,
extra=(losses, "TD 损失 (MSE, log)", "TD 损失(下降即逼近 Q*", True),
out="dqn_training.png", title="DQN 走迷宫训练过程")
plot_snapshots(env, snapshots, value_map(env, net, encode),
out="dqn_snapshots.png",
title="DQN 学到的 V(s)=max Q 随训练的变化(价值从 G 回传)")