1. 基本强化学习算法主要包含哪些部分?
可以分成三个层次:交互要素、数学基础、训练与评估系统。只记住算法名称,无法完成可靠的强化学习实验。
1.1 六个交互要素
| 要素 | 含义 | 迷宫例子 | CartPole 例子 |
|---|---|---|---|
| 智能体 Agent | 做决策并学习的主体 | 寻路机器人 | 控制小车的程序 |
| 环境 Environment | 接收动作、返回观测与奖励的系统 | 网格与障碍 | 小车和杆的动力学 |
| 状态 State / 观测 Observation | 决策所需信息 / 实际可见信息 | 当前位置 | 位置、速度、杆角度、角速度 |
| 动作 Action | 智能体能实施的操作 | 上下左右 | 向左或向右施力 |
| 奖励 Reward | 每次交互的数值反馈 | 每步 -1,到终点 +10 | 通常每维持一步 +1 |
| 策略 Policy | 状态到动作或动作分布的映射 | 选择最大 Q 值动作 | 输出左右动作概率 |
状态与观测并不总相同。摄像头画面可能无法直接体现速度,单帧观测也可能不满足马尔可夫性质;可使用历史帧、状态估计或循环网络。
交互闭环是:
观测 s_t → 根据策略选 a_t → 环境执行 → 得到 r_t、s_{t+1} ↑ ↓ └──────── 学习并更新策略或价值函数 ──────┘奖励可以每一步产生,也可以非常稀疏;强化学习并不要求只有回合结束才得到奖励。它与监督学习的关键区别是:行动会影响后续数据,通常没有逐状态的正确动作标签,目标是长期回报。
1.2 数学基础:MDP、回报、价值与优势
马尔可夫决策过程写作 。 为状态转移, 描述奖励, 为折扣因子。马尔可夫性质表示:给定充分的当前状态与动作,下一状态分布不再需要额外历史信息。这里的转移同时依赖状态和动作。
采用 表示执行 后得到的奖励:
有限回合可以取 ;无限时域使用 有利于使有界奖励的回报有限。 还改变任务对远期收益的偏好,并非越大越好。
回答“处于这个状态有多好”, 回答“在这里做这个动作有多好”, 回答“该动作比该状态下平均决策好多少”。价值不是即时奖励,也不是动作概率。
贝尔曼最优方程把长期价值拆成即时奖励与下一状态价值:
真实终止状态的后续价值取零。一般时间限制导致的采样截断不等于任务终止,需要保留 bootstrap;如果时限本身是任务的一部分,应将剩余时间纳入状态并按有限时域任务建模。Gymnasium 终止与截断说明
1.3 一个可用训练系统的七个环节
- 任务建模:定义状态、动作、奖励、终止条件和真实评价指标。
- 表示选择:Q 表、Q 网络、策略网络、价值网络或世界模型。
- 数据采集:探索策略、环境交互、并行采样与回合重置。
- 数据存储:DQN 的经验回放池或 PPO 的当前轮轨迹缓冲区。
- 学习目标:TD 目标、策略梯度、优势估计与相应损失。
- 稳定机制:目标网络、裁剪、归一化、梯度控制与 KL 监控。
- 独立评估:固定预算、多随机种子、保存配置、检查失败与泛化。
1.4 算法分类的三个独立维度
| 分类维度 | 类型 | 含义及例子 |
|---|---|---|
| 学什么 | Value-based | 学价值,再提取策略:Q-learning、DQN |
| 学什么 | Policy-based | 直接优化策略:REINFORCE |
| 学什么 | Actor-Critic | 策略负责动作,价值提供学习信号:PPO、SAC |
| 用谁的数据 | On-policy | 主要学习当前采样策略产生的轨迹:标准 PPO |
| 用谁的数据 | Off-policy | 学习目标策略可不同于采样策略,常复用历史数据:Q-learning、DQN、SAC |
| 是否显式建模 | Model-free | 学习时不显式学习/使用环境转移模型:本文三个算法 |
| 是否显式建模 | Model-based | 用已知或学习的转移模型规划或训练:Dreamer、TD-MPC2 |
Off-policy 不等于 Offline RL。DQN 通常边交互边学;Offline RL 则从固定历史数据集学习,需处理未见动作的外推误差。PPO 可以在同一轮数据上更新多次,但标准实现不会无限复用很久以前的轨迹。
2. Q-learning:用一张表学习动作价值
2.1 原理与公式
对有限离散状态、离散动作,维护 。行为策略通常使用 -greedy:以 概率随机探索,否则选择当前最大 Q 值动作。
表示真实终止, 为更新步长, 是 TD 误差。这是用一个交互样本近似贝尔曼更新,不需要知道转移概率。
例如旧值 、奖励为 1、下一状态最大 Q 值为 5,取 ,则目标为 ,新 Q 值为 。如果此动作已终止,目标变成即时奖励 1。
Q-learning 的目标使用下一状态最大 Q 值,而不使用下一步实际探索动作,所以属于 Off-policy。SARSA 则使用实际下一动作 的 ,是理解 On-policy 的良好对照。
2.2 优点、限制与应用
它结构简单、可解释、资源需求小,适合小型迷宫、有限状态控制器、教学和少量离散超参数组合的选择。存储量约为 ;状态维度升高或连续化后,离散网格会迅速膨胀,未访问状态也不能自动泛化。
表格 Q-learning 的经典收敛结论需要有限 MDP、充分访问状态动作、合适的递减学习率等条件。固定学习率、有限训练次数和深度函数逼近不自动继承这些保证。下面的固定学习率示例是教学实验,不是收敛证明。
3. Q 网络与 DQN:模型和算法的区别
3.1 Q 网络是什么?
Q 网络用参数 近似动作价值 。对于少量离散动作,常输入状态,一次输出所有动作价值:
CartPole 中网络输入为 4 个状态量,输出为 2 个 Q 值。图像输入可使用 CNN;向量输入可使用 MLP;部分可观测任务可以增加历史编码。
输出层通常保持线性,不用 softmax:Q 值可以为负,可以大于 1;其尺度由奖励和折扣决定。只有根据 Q 值构造探索分布时,才可能额外使用 softmax。
连续动作也可以建立输入 、输出标量的 Q 网络,但最大化连续动作需要另外的优化机制或 Actor。标准 DQN 的“枚举所有动作后 argmax”不能直接扩展到任意连续动作。
神经网络提供参数共享与泛化机会,不能保证相邻状态动作相同,也不能保证总是比 Q 表更省样本。网络可能泛化错误,需要独立验证。
3.2 DQN 的完整组成
DQN = Q 网络 + TD 学习 + 经验回放 + 目标网络 + 探索策略。
在线网络 执行动作、接受梯度更新;目标网络 较慢更新,提供 TD 目标:
可以是平方误差或 Huber 损失。目标计算不反向传播;经验池 保存转移,随机抽样可降低连续样本相关性并复用交互数据。目标网络定期复制在线网络,或使用软更新 。
这些机制改善训练稳定性,但不能保证收敛。奖励尺度、探索不足、函数逼近误差仍可能让训练失败。DQN 原始论文
观察状态 → ε-greedy 动作 → 环境转移 → 保存到经验池 ↓ 随机抽样在线网络 Q(s,a) ← Huber 损失 ← 目标网络构造 TD 目标 ↓ 周期性复制 目标网络3.3 Double DQN 解决什么问题?
标准 DQN 使用同一组目标估计选出最大动作并评价它,噪声会带来高估倾向。Double DQN 分离选择与评价:
它通常是值得尝试的低成本改进,仍不能消除所有估计误差。注意:有两个网络的标准 DQN 不等于 Double DQN;关键在 TD 目标是否分离动作选择与评价。Double DQN 论文

图 1:标准 DQN 用目标网络计算下一状态的最大动作价值;Double DQN 用在线网络选动作、目标网络评价该动作。两种目标都使用下一状态 s′,d 表示真实终止。
3.4 场景与限制
适合离散动作游戏、少量离散控制操作、选路或资源分配动作。连续高维状态可以输入网络;连续高维动作则优先考虑 SAC、TD3 或 PPO。推荐、调度等应用需要正确的反馈与环境建模,不能仅因“动作是离散的”就直接套 DQN。巨大动作集合使枚举昂贵,非法动作还需要一致的动作掩码。
4. PPO:直接学习策略,并抑制过大的更新
4.1 Actor、Critic 与策略梯度
Actor 输出 ,Critic 估计 。离散动作可用分类分布;连续动作可用高斯分布。PPO 不局限于连续动作。
策略梯度的基本形式为:
优势为正,提高动作概率;优势为负,降低动作概率。Critic 可降低估计方差,而不是替 Actor 直接决定动作。
4.2 PPO-Clip
固定一轮用于采样的旧策略,保存动作的旧对数概率。定义比率 ,避免与奖励符号 混淆:
例如 ,两项分别为 3 和 2.4,取 2.4,继续提高此样本的比率不再增加这部分收益。若 ,两项为 -1 和 -1.6,取 -1.6,避免因过度降低坏动作概率而得到过多收益。
裁剪是目标函数中的抑制机制,不把新策略概率硬性限制在区间内,也不严格保证 KL 上界。共享参数与其他样本梯度仍能推动某些动作比率越界,因此还要检查学习率、KL、裁剪比例与评估回报。PPO 的 KL 惩罚版本与 Clip 版本是不同设计,不应理解为完全等效。PPO 原论文

图 2:优势为正时抑制过度提高动作概率的收益,优势为负时抑制过度降低动作概率的收益。ρ 是同一状态动作的新旧策略概率比;裁剪作用于目标函数,不是策略概率的硬约束。
最小化形式的联合损失通常为:
价值损失让 Critic 拟合回报目标,熵项鼓励探索;熵系数过大会妨碍策略变得明确。共享网络与分离网络都可使用。
4.3 GAE:兼顾偏差与方差
是真实终止, 是回合边界,包括真实终止与时间截断。这样,截断时 TD 目标保留下一状态价值,GAE 却不会递归到 reset 后的新回合。采样块结束时也停止递归,最后一步的 已包含下一状态价值。 越大通常保留更多长期误差信号,方差也可能更大。
4.4 训练流程与适用场景
先固定采样策略收集一批轨迹,计算优势和价值目标,再对同一批数据做若干轮小批量更新,丢弃该批数据并重新采样。旧 log probability、优势与价值目标在更新期间必须固定,不随梯度更新重新计算。
PPO 适合能够大量并行采样的仿真、机器人控制、游戏和复杂策略训练,离散与连续动作均可处理。代价是采样需求常较大;真实交互昂贵时,应与更能复用数据的算法比较。裁剪增加实用稳定性,但奖励设计错误、过多更新轮数或优势计算错误都可能导致失败。

图 3:PPO 在固定的当前轮轨迹上多次更新,旧 log_prob 保持不变,之后重新采样。边界信息在代码中分别表示真实终止与回合边界,以正确处理时间截断;优势和价值目标在本轮更新期间也保持固定。
5. 横向比较与算法选型
| 维度 | 表格 Q-learning | Q 网络 | DQN / Double DQN | PPO-Clip |
|---|---|---|---|---|
| 本质 | 学习算法 | 价值函数模型 | 深度价值学习算法 | 策略优化算法 |
| 学习对象 | Q 表 | ,训练方式另定 | 动作价值 | 策略及状态价值 |
| 状态空间 | 小规模离散 | 可为连续或高维 | 可为连续或高维 | 可为连续或高维 |
| 动作空间 | 基础形式为离散 | 取决于模型与优化方式 | 标准形式为离散 | 离散或连续 |
| 数据方式 | Off-policy | 本身不决定 | Off-policy,长期回放 | 标准为 On-policy,当前轮复用 |
| 行动方式 | 最大 Q 值 + 探索 | 本身不规定 | 最大 Q 值 + 探索 | 从策略分布采样 |
| 稳定机制 | 合适步长与充分访问 | 本身没有 | 回放、目标网络、梯度控制 | Clip、GAE、KL 监控 |
| 主要成本 | 表大小 | 取决于网络 | 回放内存与训练计算 | 大量轨迹采样与多轮更新 |
| 典型问题 | 状态爆炸、探索不足 | 价值拟合误差 | 高估、训练震荡 | 高方差、策略漂移 |
| 学习价值 | 理解 TD 和探索 | 理解函数逼近 | 理解深度 RL 稳定机制 | 理解 Actor-Critic 与策略梯度 |
不能脱离任务断言“PPO 比 DQN 好”或“DQN 一定收敛更快”。比较环境交互次数、墙钟时间、最终性能与稳定性会得出不同排序。
| 任务条件 | 合理起点 | 选型理由 |
|---|---|---|
| 小离散状态与动作 | Q-learning | 简单透明,便于建立基线 |
| 连续观测、少量离散动作 | DQN / Double DQN,另加 PPO 基线 | Q 网络适应连续观测,可复用经验 |
| 连续控制、交互较昂贵 | SAC / TD3 与 PPO 对照 | Off-policy 复用数据;具体效率需实测 |
| 并行仿真充分 | PPO | 实现成熟,适合批量轨迹采样 |
| 只有固定历史数据 | 专门的 Offline RL | 避免直接对未覆盖动作进行乐观估计 |
| 部分可观测 | 历史编码 / 循环策略 | 先补充决策信息,再选算法 |
这些是实验起点,不是性能保证。SB3 实验建议
6. 实际案例代码:从零实现与运行
6.1 依赖、运行与验收
以下三个程序均为完整脚本,复制相应代码块到同名 .py 文件即可使用。Python 建议使用 3.10 或更高版本。Q-learning 仅用标准库;DQN/PPO 需要 PyTorch、NumPy、Gymnasium。
在已有或自行准备的 Python 环境中运行:
# 仅 DQN/PPO 需要此安装步骤;本文未自动安装这些依赖。python -m pip install torch numpy "gymnasium[classic-control]"python -m pip freeze > requirements.lock.txt
python q_learning.py --episodes 3000 --seed 42python dqn_cartpole.py --steps 100000 --seed 42 --device cpupython dqn_cartpole.py --steps 100000 --seed 42 --double --output runs/double_dqnpython ppo_cartpole.py --steps 100000 --seed 42 --device cpu这些命令下载依赖,但脚本本身不会下载模型或数据。默认使用 CPU;指定 CUDA 前先确认所用 PyTorch 构建和显卡支持。种子有助于复现,但跨设备、驱动或库版本仍可能出现差异,不能保证位级一致。
迷宫验收:抵达终点、无非法位置,当前障碍布局的最短路径是 8 步。CartPole-v1 每回合最多 500 步;本文建议的教学验收为独立 20 回合平均回报达到 450,且在多个训练种子下稳定。这个标准是本文的实验标准,100000 步训练预算并不保证达到标准。
先使用 --steps 2048 做深度算法冒烟检查,验证环境交互、更新、存储与评估能执行;不能用此短预算判断算法优劣。Q-learning 与神经网络示例的环境不同,不能直接把它们的回报数值横向比较。
6.2 Q-learning:5×5 迷宫
该案例沿用参考书的网格寻路主题,环境重新编写为无需 GUI 的教学实现。障碍为不可进入的墙,撞墙留在原地;不是参考书陷阱终止语义的逐行复刻。
"""无第三方依赖的 Q-learning:5×5 迷宫。Python 3.10+。"""import argparseimport jsonimport randomfrom pathlib import Path
SIZE = 5START, GOAL = (0, 0), (4, 4)WALLS = {(1, 1), (1, 3), (2, 3), (3, 1)}MOVES = ((-1, 0), (1, 0), (0, -1), (0, 1)) # 上、下、左、右
def transition(state, action): """撞墙留在原地;每步付出成本,鼓励找到短路径。""" # 用 row * SIZE + col 将二维坐标编码为 Q 表的行号。 row, col = divmod(state, SIZE) dr, dc = MOVES[action] nxt = (row + dr, col + dc) if not (0 <= nxt[0] < SIZE and 0 <= nxt[1] < SIZE) or nxt in WALLS: nxt = (row, col) terminated = nxt == GOAL return nxt[0] * SIZE + nxt[1], 10.0 if terminated else -1.0, terminated
def greedy(values, rng): # 随机打破并列,避免全零初始化时始终选择第一个动作。 best = max(values) return rng.choice([i for i, v in enumerate(values) if v == best])
def train(episodes=3000, seed=42): if episodes <= 0: raise ValueError("episodes 必须为正整数") rng = random.Random(seed) # 每个状态占一行,四列分别对应上下左右动作的长期价值估计。 q = [[0.0] * 4 for _ in range(SIZE * SIZE)] # alpha 控制 TD 更新步长;gamma 决定未来奖励的折扣程度。 alpha, gamma = 0.1, 0.95 for episode in range(episodes): state = 0 # 随训练减少随机探索,但保留至少 5% 的探索机会。 epsilon = max(0.05, 1.0 - episode / (episodes * 0.8)) for _ in range(100): action = rng.randrange(4) if rng.random() < epsilon else greedy(q[state], rng) nxt, reward, terminated = transition(state, action) # 真实终止不 bootstrap;100 步是采样保护上限,不属于任务终止。 target = reward + (0.0 if terminated else gamma * max(q[nxt])) # target - Q(s,a) 是 TD 误差;只更新本次访问的状态动作。 q[state][action] += alpha * (target - q[state][action]) state = nxt if terminated: break return q
def evaluate(q, seed=10042): """使用独立随机流,不探索、不更新 Q 表。""" rng = random.Random(seed) state, reward_sum = 0, 0.0 route = [START] for _ in range(100): state, reward, terminated = transition(state, greedy(q[state], rng)) route.append(divmod(state, SIZE)) reward_sum += reward if terminated: return {"success": True, "steps": len(route) - 1, "return": reward_sum, "route": route} return {"success": False, "steps": 100, "return": reward_sum, "route": route}
if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--episodes", type=int, default=3000) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--output", type=Path, default=Path("q_table.json")) args = parser.parse_args() table = train(args.episodes, args.seed) # 用独立的随机流打破并列动作,不让评估消耗训练的随机序列。 result = evaluate(table, args.seed + 10000) args.output.write_text(json.dumps({"seed": args.seed, "q": table, "evaluation": result}, indent=2), encoding="utf-8") print(json.dumps(result, ensure_ascii=False))6.3 DQN 与 Double DQN:CartPole
4 维连续观测、2 个离散动作,是理解“连续状态不等于连续动作”的案例。程序使用回放池、目标网络、Huber 损失与梯度裁剪;--double 只改变下一状态目标的计算方式。
"""教学版 DQN / Double DQN;状态为连续向量,动作只有左右两个。"""import argparseimport jsonimport randomfrom collections import dequefrom pathlib import Path
import gymnasium as gymimport numpy as npimport torchfrom torch import nn
class QNetwork(nn.Module): def __init__(self, obs_dim, actions): super().__init__() # 输出是无界 Q 值,不是概率,最后一层不能使用 softmax。 self.layers = nn.Sequential(nn.Linear(obs_dim, 64), nn.ReLU(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, actions))
def forward(self, states): return self.layers(states)
def evaluate(net, device, seed, episodes=20): env = gym.make("CartPole-v1") # 单独的评估环境,避免改变训练状态。 scores = [] net.eval() with torch.no_grad(): for episode in range(episodes): obs, _ = env.reset(seed=seed + episode) score = 0.0 while True: x = torch.as_tensor(obs, dtype=torch.float32, device=device).unsqueeze(0) action = net(x).argmax(dim=1).item() # 评估关闭 epsilon 探索。 obs, reward, terminated, truncated, _ = env.step(action) score += reward if terminated or truncated: break scores.append(score) env.close() net.train() return {"mean": float(np.mean(scores)), "std": float(np.std(scores)), "scores": scores}
def main(args): if args.steps <= 0: raise ValueError("steps 必须为正整数") random.seed(args.seed) np.random.seed(args.seed) torch.manual_seed(args.seed) torch.set_num_threads(1) # 小网络在 CPU 上通常不需要大量线程。 device = torch.device(args.device) env = gym.make("CartPole-v1") env.action_space.seed(args.seed) obs, _ = env.reset(seed=args.seed) online = QNetwork(4, 2).to(device) target = QNetwork(4, 2).to(device) # 两个网络从相同参数出发;只有 online 的参数交给优化器更新。 target.load_state_dict(online.state_dict()) target.eval() optimizer = torch.optim.Adam(online.parameters(), lr=1e-3) # 有界经验池自动淘汰最旧转移,限制内存并复用历史交互数据。 replay = deque(maxlen=50000) batch_size, gamma = 64, 0.99 history = [] episode_return = 0.0 for step in range(1, args.steps + 1): # 前 20000 个环境步逐渐从随机探索转向利用,之后保持 5% 探索。 epsilon = max(0.05, 1.0 - 0.95 * step / 20000) if random.random() < epsilon: action = env.action_space.sample() else: with torch.no_grad(): action = online(torch.as_tensor(obs, dtype=torch.float32, device=device).unsqueeze(0)).argmax(dim=1).item() next_obs, reward, terminated, truncated, _ = env.step(action) # 保存 reset 之前的 next_obs。只用 terminated 掩蔽 bootstrap。 replay.append((obs.copy(), action, reward, next_obs.copy(), terminated)) episode_return += reward obs = next_obs if terminated or truncated: history.append(episode_return) episode_return = 0.0 obs, _ = env.reset() # 不要每回合都用相同 seed 重置。 # 先积累 1000 条经验;之后每四个环境步做一次小批量更新。 if step >= 1000 and step % 4 == 0: # 随机抽样降低连续转移的相关性;B 表示 batch_size。 states, actions, rewards, next_states, terminals = zip(*random.sample(replay, batch_size)) states = torch.as_tensor(np.array(states), dtype=torch.float32, device=device) next_states = torch.as_tensor(np.array(next_states), dtype=torch.float32, device=device) # actions 为 [B,1] 的整数索引,供 gather 选择每条经验的动作列。 actions = torch.as_tensor(actions, dtype=torch.int64, device=device).unsqueeze(1) rewards = torch.as_tensor(rewards, dtype=torch.float32, device=device) terminals = torch.as_tensor(terminals, dtype=torch.float32, device=device) # 网络输出 [B,2];取实际执行动作的 Q 值,得到 [B]。 prediction = online(states).gather(1, actions).squeeze(1) # TD 目标是固定监督信号,不对下一状态的网络输出反向传播。 with torch.no_grad(): if args.double: # Double DQN:在线网络选动作,目标网络评价该动作。 next_actions = online(next_states).argmax(dim=1, keepdim=True) next_q = target(next_states).gather(1, next_actions).squeeze(1) else: next_q = target(next_states).max(dim=1).values td_target = rewards + gamma * (1.0 - terminals) * next_q # Huber 损失减弱大 TD 误差的影响,比平方误差更不易受异常值支配。 loss = nn.functional.smooth_l1_loss(prediction, td_target) optimizer.zero_grad() loss.backward() # 限制梯度范数,降低单次更新过大的风险;不裁剪 Q 值本身。 nn.utils.clip_grad_norm_(online.parameters(), 10.0) optimizer.step() if step % 1000 == 0: # 此处单位为环境步,而不是梯度更新次数。 target.load_state_dict(online.state_dict()) if step % 10000 == 0: print(f"step={step} epsilon={epsilon:.3f} train_mean={np.mean(history[-20:]):.1f}") # 最终评估关闭随机探索;训练回报含探索噪声,两者应分别记录。 result = evaluate(online, device, args.seed + 10000) args.output.mkdir(parents=True, exist_ok=True) # 这里只保存推理权重;恢复训练还需优化器、经验池及随机状态。 torch.save(online.state_dict(), args.output / "q_network.pt") (args.output / "metrics.json").write_text(json.dumps({ "algorithm": "Double DQN" if args.double else "DQN", "seed": args.seed, "env": "CartPole-v1", "steps": args.steps, "device": str(device), "torch": torch.__version__, "gymnasium": gym.__version__, "config": {"lr": 1e-3, "gamma": gamma, "batch": batch_size, "buffer": 50000, "warmup": 1000, "train_freq": 4, "target_interval": 1000, "epsilon_decay_steps": 20000}, "train_returns": history, "evaluation": result}, indent=2), encoding="utf-8") env.close() print(result)
if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--steps", type=int, default=100000) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--device", default="cpu") parser.add_argument("--double", action="store_true") parser.add_argument("--output", type=Path, default=Path("runs/dqn")) main(parser.parse_args())注意 terminated or truncated 决定何时重置环境;TD 目标中只有 terminated 屏蔽下一状态价值。当前代码采用单环境且在 reset 前保存下一观测;使用自动重置的向量环境时必须根据其接口读取真正的 final observation。Gymnasium 环境接口
6.4 PPO-Clip + GAE:同一 CartPole 环境
为降低环境安装复杂度、便于与 DQN 比较,本例使用 CartPole,而不是参考书的月球着陆主题。程序明确展示旧动作概率、两个 GAE 掩码、优势归一化、裁剪损失、价值损失、熵和 KL 提前停止。
"""从零实现离散动作 PPO-Clip + GAE;便于与 DQN 在同一环境比较。"""import argparseimport jsonfrom pathlib import Path
import gymnasium as gymimport numpy as npimport torchfrom torch import nnfrom torch.distributions import Categorical
class ActorCritic(nn.Module): def __init__(self): super().__init__() # Actor 输出两个动作的 logits;Critic 输出一个状态价值,参数分开学习。 self.actor = nn.Sequential(nn.Linear(4, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, 2)) self.critic = nn.Sequential(nn.Linear(4, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, 1))
def forward(self, states): # logits 交给 Categorical,内部稳定地计算归一化概率。 return Categorical(logits=self.actor(states)), self.critic(states).squeeze(-1)
def gae(rewards, values, next_values, terminals, boundaries, gamma=0.99, lam=0.95): """两个掩码:TD 在真实终止停止;GAE 在终止或截断停止跨回合传播。""" advantage = np.zeros(len(rewards), dtype=np.float32) # 从采样块末端反向递推;末步的 delta 已通过 next_values 保留 bootstrap。 carry = 0.0 for t in reversed(range(len(rewards))): # 真实终止将下一状态价值置零;纯时间截断仍使用其价值。 delta = rewards[t] + gamma * (1.0 - terminals[t]) * next_values[t] - values[t] # lam 调节多步误差权重;边界掩码防止传播到 reset 后的新回合。 carry = delta + gamma * lam * (1.0 - boundaries[t]) * carry advantage[t] = carry # 用未归一化优势加旧价值估计,构造 Critic 的固定回归目标。 return advantage, advantage + np.asarray(values, dtype=np.float32)
def evaluate(model, device, seed, episodes=20): env = gym.make("CartPole-v1") scores = [] with torch.no_grad(): for episode in range(episodes): obs, _ = env.reset(seed=seed + episode) score = 0.0 while True: x = torch.as_tensor(obs, dtype=torch.float32, device=device).unsqueeze(0) distribution, _ = model(x) # 评估选择最高概率动作;训练时则从分布采样以保留探索。 action = distribution.probs.argmax(dim=1).item() obs, reward, terminated, truncated, _ = env.step(action) score += reward if terminated or truncated: break scores.append(score) env.close() return {"mean": float(np.mean(scores)), "std": float(np.std(scores)), "scores": scores}
def main(args): if args.steps <= 0: raise ValueError("steps 必须为正整数") np.random.seed(args.seed) torch.manual_seed(args.seed) torch.set_num_threads(1) device = torch.device(args.device) env = gym.make("CartPole-v1") obs, _ = env.reset(seed=args.seed) model = ActorCritic().to(device) optimizer = torch.optim.Adam(model.parameters(), lr=3e-4) # 一轮最多采集 1024 步,每批约 64 条;同一轮轨迹最多复用 10 遍。 rollout_size, minibatch, epochs = 1024, 64, 10 clip, target_kl = 0.2, 0.02 total_steps, episode_return = 0, 0.0 history, updates = [], [] while total_steps < args.steps: # 每轮建立新 buffer;更新结束后丢弃,不能充当长期经验回放。 states, actions, old_logs = [], [], [] rewards, values, next_values, terminals, boundaries = [], [], [], [], [] for _ in range(min(rollout_size, args.steps - total_steps)): x = torch.as_tensor(obs, dtype=torch.float32, device=device).unsqueeze(0) # 采样阶段不建梯度图;策略在整轮采样期间保持不变。 with torch.no_grad(): distribution, value = model(x) action = distribution.sample() log_prob = distribution.log_prob(action) next_obs, reward, terminated, truncated, _ = env.step(action.item()) with torch.no_grad(): # 必须在 reset 前评估下一状态;超时仍保留下一状态价值。 _, next_value = model(torch.as_tensor(next_obs, dtype=torch.float32, device=device).unsqueeze(0)) states.append(obs.copy()) actions.append(action.item()) # 保存采样时的旧 log_prob;本轮优化时始终作为固定的比率分母。 old_logs.append(log_prob.item()) rewards.append(reward) values.append(value.item()) next_values.append(next_value.item()) terminals.append(float(terminated)) boundaries.append(float(terminated or truncated)) total_steps += 1 episode_return += reward obs = next_obs if terminated or truncated: history.append(episode_return) episode_return = 0.0 obs, _ = env.reset() adv, returns = gae(rewards, values, next_values, terminals, boundaries) # 先算价值回归目标,再归一化优势,不能用归一化优势构造 returns。 adv = (adv - adv.mean()) / (adv.std() + 1e-8) s = torch.as_tensor(np.array(states), dtype=torch.float32, device=device) a = torch.as_tensor(actions, dtype=torch.int64, device=device) old_log = torch.as_tensor(old_logs, dtype=torch.float32, device=device) advantages = torch.as_tensor(adv, device=device) targets = torch.as_tensor(returns, device=device) stop = False # 每遍重新打乱样本顺序;优势、价值目标与旧 log_prob 都不重算。 for _ in range(epochs): for indices in np.array_split(np.random.permutation(len(states)), max(1, (len(states) + minibatch - 1) // minibatch)): idx = torch.as_tensor(indices, dtype=torch.int64, device=device) distribution, predicted_values = model(s[idx]) # 用对数概率差计算新旧概率比,避免直接相除小概率。 log_ratio = distribution.log_prob(a[idx]) - old_log[idx] ratio = log_ratio.exp() with torch.no_grad(): # 当前小批量上估计旧策略到新策略的 KL,用于提前停止。 approximate_kl = ((ratio - 1) - log_ratio).mean().item() if approximate_kl > 1.5 * target_kl: stop = True # 辅助保护;clip 本身不能严格保证 KL 上界。 break unclipped = ratio * advantages[idx] clipped = ratio.clamp(1 - clip, 1 + clip) * advantages[idx] # 优化器做最小化;加负号才对应最大化 PPO 的裁剪目标。 policy_loss = -torch.minimum(unclipped, clipped).mean() value_loss = (predicted_values - targets[idx]).square().mean() entropy = distribution.entropy().mean() # 价值误差要最小化;熵项取负,鼓励策略保留适当探索。 loss = policy_loss + 0.5 * value_loss - 0.01 * entropy optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 0.5) optimizer.step() # KL 超阈值时同时退出剩余 epochs,然后收集新策略的数据。 if stop: break updates.append({"steps": total_steps, "approx_kl": approximate_kl, "early_stop": stop}) if len(updates) % 10 == 0: print(f"step={total_steps} train_mean={np.mean(history[-20:]):.1f} kl={approximate_kl:.4f}") result = evaluate(model, device, args.seed + 10000) args.output.mkdir(parents=True, exist_ok=True) # 保存 Actor/Critic 权重与下方实验配置;此文件不含续训所需的优化器状态。 torch.save(model.state_dict(), args.output / "actor_critic.pt") (args.output / "metrics.json").write_text(json.dumps({ "algorithm": "PPO-Clip", "seed": args.seed, "env": "CartPole-v1", "steps": total_steps, "device": str(device), "torch": torch.__version__, "gymnasium": gym.__version__, "config": {"lr": 3e-4, "gamma": 0.99, "gae_lambda": 0.95, "rollout": rollout_size, "batch": minibatch, "epochs": epochs, "clip": clip, "target_kl": target_kl, "vf_coef": 0.5, "ent_coef": 0.01, "max_grad_norm": 0.5}, "train_returns": history, "updates": updates, "evaluation": result}, indent=2), encoding="utf-8") env.close() print(result)
if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--steps", type=int, default=100000) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--device", default="cpu") parser.add_argument("--output", type=Path, default=Path("runs/ppo")) main(parser.parse_args())这是离散动作教学版。迁移到连续动作时,Actor 应输出高斯分布参数,正确处理动作边界与 log probability;若使用 tanh 压缩动作,需要概率密度的变换修正,不能只替换输出维度。初学者可先使用成熟库的连续动作实现。
6.5 用成熟库建立对照基线
从零实现便于理解,成熟实现便于排查错误。以下可另存为 sb3_baseline.py,依赖 stable-baselines3;它是额外对照,不是上面手写代码的必要依赖。
import gymnasium as gymfrom stable_baselines3 import PPOfrom stable_baselines3.common.evaluation import evaluate_policy
# 显式创建训练环境,记录模型和环境版本。train_env = gym.make("CartPole-v1")# MlpPolicy 适用于向量观测;下列参数与手写 PPO 的关键配置保持可比较。model = PPO("MlpPolicy", train_env, seed=42, device="cpu", learning_rate=3e-4, n_steps=1024, batch_size=64, n_epochs=10, gamma=0.99, gae_lambda=0.95, clip_range=0.2, ent_coef=0.01, target_kl=0.02, verbose=1)# total_timesteps 是环境交互预算;库可能为完成最后一批轨迹而略超预算。model.learn(total_timesteps=100000)# 独立环境评估,避免评估改变训练环境的状态。eval_env = gym.make("CartPole-v1")eval_env.reset(seed=10042)# deterministic=True 关闭动作采样;std 是回合间波动,不是训练种子间波动。mean, std = evaluate_policy(model, eval_env, n_eval_episodes=20, deterministic=True, warn=False)print(f"evaluation: {mean:.1f} ± {std:.1f}")model.save("ppo_cartpole_sb3")eval_env.close()train_env.close()SB3 的 PPO 实现还有额外工程细节,不能认为与教学代码逐项等价;其标准 DQN 实现也没有自动包含 Double DQN、Dueling 或 PER。对照时核实实际实现,而不只看算法名字。SB3 PPO、SB3 DQN
7. 调参优化:先诊断,再搜索
7.1 共同原则
先检查环境、奖励和边界处理,再调整学习率与探索,最后扩大网络。一个错误的奖励方向或终止掩码不能通过扩大模型可靠地修复。
记录训练回报、独立评估回报、回合长度、总环境步数、训练时间与失败次数。环境有约束时还应记录碰撞率、违约率或资源消耗;平均奖励不能替代这些指标。
观测尺度差异很大时,可依据训练数据估计均值与方差。评估使用冻结的训练统计,不重新拟合。奖励缩放影响价值目标尺度;奖励裁剪则可能改变任务目标,应说明其后果。
7.2 建议起点与搜索范围
以下范围是低维教学任务的实验候选,不是所有环境的默认最优值。
| 算法 | 参数 | 建议候选 | 增大/减小的影响 |
|---|---|---|---|
| Q-learning | 0.05、0.1、0.2 | 大时适应快但受噪声影响更大 | |
| Q-learning | 0.9、0.95、0.99 | 大时更看重远期收益,传播更慢 | |
| Q-learning | 探索衰减 | 在预算的 50%–80% 内衰减 | 太快容易锁定较差路径 |
| DQN | 学习率 | 太大可能 Q 值震荡,太小学习缓慢 | |
| DQN | batch | 32、64、128 | 大批降低梯度噪声,也增加成本 |
| DQN | 目标更新间隔 | 500、1000、2000 环境步 | 太快目标变化大,太慢目标滞后 |
| DQN | 回放容量 | 10000、50000、100000 | 太小经验相关,太大可能保留过时经验 |
| DQN | 下限 | 0.01、0.05、0.1 | 高时探索多,但训练回报可能更低 |
| PPO | 学习率 | 高时可能过快偏离旧策略 | |
| PPO | clip range | 0.1、0.2、0.3 | 小时约束更保守,但不保证严格 KL 上界 |
| PPO | 每轮轨迹量 | 512、1024、2048 | 增大常降低估计噪声,也延迟更新 |
| PPO | 更新 epochs | 3、5、10 | 太多易对当前批数据过度更新 |
| PPO | GAE | 0.9、0.95、0.98 | 大时更依赖长期信号,方差可能更高 |
| PPO | 熵系数 | 0、0.001、0.01 | 太大不易收敛,太小可能过早确定策略 |
目标更新间隔必须写明单位。PPO 的总 rollout 数通常为 n_steps × n_envs;增加并行环境时应重新检查批大小、更新轮数和总交互预算。DQN 还需记录每环境步对应多少梯度更新。
7.3 常见症状及排查顺序
| 症状 | 可能原因 | 最小排查与调整 |
|---|---|---|
| Q-learning 反复撞墙 | 并列动作偏置、探索不足、奖励不合理 | 随机打破并列,延长探索,检查转移 |
| DQN Q 值持续放大、回报下降 | 目标实现错误、高估、学习率大 | 查终止掩码和 detach,再降学习率/试 Double DQN |
| DQN 训练均值低、评估却较好 | epsilon 探索拖低训练成绩 | 同时展示独立无探索评估 |
| PPO 更新后回报骤降 | KL 大、学习率或 epochs 太大 | 查旧 log probability,降学习率/epochs,监控 KL |
| PPO 熵很早接近零 | 探索不足或优势异常 | 查优势统计,再适度增加熵系数 |
| Critic 损失高 | 奖励尺度大、目标错或容量不足 | 查 GAE/终止处理,检查目标尺度与解释方差 |
| 同 seed 也不同结果 | 库、硬件、并行或 RNG 不同 | 锁版本、记录设备与随机流;不要只记录一个 seed |
价值损失数值越小不必然代表策略越好;奖励尺度会影响它。裁剪比例和 KL 是诊断指标,也没有普适最佳值。
7.4 公平的实验与超参数搜索
先选少量关键参数做随机搜索,所有候选使用相同环境步预算和预先确定的训练种子。用验证种子筛选配置,最终结果使用未参与调参的评估种子与任务实例,避免在测试集上反复挑配置。
例如训练 seed 为 0–4,每个训练模型独立评估 20 回合,展示 5 个模型平均回报的均值与标准差,而不仅展示单个模型回合间标准差。记录最终回报、达到阈值所需步数、曲线面积、训练时间;性能曲线应展示多个训练种子的波动。
foreach ($seed in 0..4) { python dqn_cartpole.py --seed $seed --steps 100000 --output "runs/dqn_$seed" python ppo_cartpole.py --seed $seed --steps 100000 --output "runs/ppo_$seed"}教学脚本把关键参数固定以便阅读;做搜索时,可将所选参数逐项增加为命令行参数,并写入已有 metrics.json,避免只修改代码而漏记配置。
8. 改进方法:每个改进对应一个问题
| 方法 | 针对问题 | 思路与注意事项 |
|---|---|---|
| Double DQN | Q 值高估 | 分离选动作与评价,本文提供开关 |
| Dueling Network | 很多动作价值相近 | 分离状态价值与动作优势;通常用减优势均值消除不可辨识性 |
| Prioritized Replay | 均匀采样忽略高 TD 误差样本 | 按优先级采样,用重要性权重缓解分布偏差 |
| 多步 TD | 稀疏奖励传播慢 | 聚合多步奖励;需正确处理终止与截断 |
| Distributional RL | 仅建模期望忽略回报分布 | 学回报分布;并不自动等于风险安全 |
| Noisy Networks | 单一随机动作探索效率低 | 在参数中引入可学习噪声,需验证探索效果 |
| Rainbow | 多种 DQN 缺陷并存 | 集成若干改进,应做消融而不是盲目堆叠 |
| PPO KL 提前停止 | 一轮内策略偏移过大 | 达到 KL 阈值停止当前更新轮,仍需独立评估 |
| 学习率调度 | 后期更新太激进 | 随预算衰减学习率;也可能让后期适应变慢 |
| 循环策略 / 历史状态 | 部分可观测 | 补足速度、历史等信息;训练需保留序列语义 |
| 课程学习 / HER | 很难遇到成功或稀疏目标奖励 | 逐步提高难度或重标记目标,要求任务结构适配 |
| 势函数奖励塑形 | 奖励传播困难 | 使用 ,在适用条件下保持最优策略 |
Rainbow 包括 Double Q、优先回放、Dueling、多步学习、回报分布建模与 Noisy Networks;回报分布建模指 distributional RL。Rainbow 论文
改进应有基线:每次引入一个变化,比较固定预算下多种子的表现。增加辅助奖励可能改变最优行为;尤其不能把“中间指标改善”当成“任务成功率提高”。
9. 应用场景:如何从案例迁移到科研问题
9.1 迷宫与路径规划
状态为位置或局部地图,动作为离散移动,奖励综合到达、步数和碰撞。简单已知静态迷宫优先与 BFS/A* 比较:如果没有未知动态或交互学习需求,传统搜索通常更直接。RL 更适合存在未知转移、动态障碍或需要从大量类似任务中学习策略的情况。
9.2 游戏与控制
CartPole 是基础控制教学任务,不是实际机器人性能的证明。图像游戏可以将 MLP 换成 CNN,并检查帧堆叠与奖励预处理。机器人控制需要处理动作边界、控制频率、噪声、约束与仿真到真实的差异;实物验证前先在仿真中评估失败和约束指标。
9.3 参考书的旅行商问题:RL 调节遗传算法
参考书第 8.6 节采用 Q-learning 选择遗传算法的交叉/变异参数,不能混同为“直接学习城市访问路线”。这是两层优化:外层 RL 选择参数,内层遗传算法更新种群并求解 TSP。
| 建模项 | 可操作的定义 |
|---|---|
| 状态 | 归一化平均/最佳路径长度、种群多样性、停滞代数、剩余预算 |
| 动作 | 离散 组合 |
| 转移 | 用选定参数执行若干代遗传算法 |
| 奖励 | 最佳路径长度下降幅度,必要时扣除计算成本 |
| 终止 | 用尽预先给定的评估或代数预算 |
| 评价 | 固定相同计算预算下最终路径长度、时间和跨实例表现 |
最小化路线长度 时,可用 , 由训练实例预先确定。若要最大化“适应度”,应明确其是否为 等定义,不能直接把正的路径长度作为奖励最大化。
加入“剩余预算”可减少有限时域状态缺失;只有平均适应度通常不足以表达种群动态。若改为直接构造城市路线,状态至少包含当前位置和已访问城市集合,并约束非法重复访问,问题维度会显著增加。
公平基线包括固定参数遗传算法、随机参数选择和预设参数调度;统一种群规模、适应度评估次数与城市实例。不能让 RL 方案多运行遗传算法,再把改进全部归因于学习能力。上述内容是对教材案例的建模与实验建议,本教程未复现其完整 TSP 求解器。
10. 当前主流与值得关注的方向
这里区分长期常用基线与有代表性的研究方向。下面是依据所列官方实现和论文做的应用判断,没有声称已统计全领域部署份额。
| 领域 | 常用基线 / 代表方向 | 与本文的关系 |
|---|---|---|
| 离散动作控制与游戏 | DQN 及 Double、Dueling、分布回报等改进;PPO | DQN 是价值学习基础,任务复杂时考虑增强版 |
| 连续控制 | PPO、SAC、TD3 | PPO 便于并行采样;SAC/TD3 复用经验 |
| 基于世界模型的控制 | DreamerV3、TD-MPC2 | 学环境模型,在想象轨迹中学习或进行规划 |
| 固定历史数据 | CQL、IQL 等 Offline RL | 控制分布外动作估值,需数据覆盖分析 |
| 多智能体协作 | MAPPO、QMIX 等 | 单智能体方法需处理非平稳性与协作信息 |
| 大模型后训练与推理 | PPO、GRPO 及相关变体 | 动作为 token/回答,奖励与采样成本特殊 |
| 安全、泛化与迁移 | 约束 RL、鲁棒训练、模仿与 RL 结合 | 最终需要任务约束和分布外评估 |
SAC 把策略熵纳入目标,通常通过双 Q 网络等机制提高连续控制的实用性;TD3 通过双 Critic、延迟策略更新与目标动作平滑抑制函数逼近误差。它们是扩展学习的重要方向,具体优势需在相同交互预算下测试。SAC 论文、TD3 论文
DreamerV3 学习世界模型并在模型想象的轨迹中训练行为;TD-MPC2 利用潜在模型进行连续控制规划。它们是代表性研究路线,模型误差、训练成本和工程复杂度仍需评估,不能理解为已经普遍替代 PPO 或 SAC。DreamerV3 官方仓库、TD-MPC2 论文
CQL 通过保守的价值学习缓解 Offline RL 对分布外动作的乐观估计;IQL 是另一类离线学习方法。固定日志训练时,不能仅把 DQN 的 replay buffer 换成日志文件就认为问题已解决。CQL 论文、IQL 论文
GRPO 利用同一提示下多个回答的组内奖励构造相对优势,典型形式可省去单独的价值 Critic;DeepSeek-R1 是其代表性应用。它仍依赖可靠奖励、有效组内差异和采样预算,并不意味着比 PPO 在所有任务更好。DPO 是从偏好数据直接优化策略的相关路线,其典型训练流程不需要在线 RL 采样循环,不应与标准 PPO 混为一谈。DeepSeek-R1 论文、DPO 论文
11. 推荐学习与复现实验路线
- 运行迷宫,手算一次 TD 更新;将探索关闭,观察是否更容易陷入差路径。
- 用 DQN 训练 CartPole,检查输出形状、TD 目标和终止掩码。
- 开启 Double DQN,固定预算和种子,比较评估均值与波动。
- 运行 PPO,核查旧 log probability 是否固定,以及 GAE 是否跨过回合边界。
- 用 SB3 建立对照;若成熟库学会而手写代码学不会,先检查实现再调参。
- 根据自己任务的动作空间、数据来源与交互成本,扩展到 SAC、离线学习或世界模型。
12. 参考资料与来源边界
主要参考:《科研综合训练实践教程》2026.04.30 合稿,第八章。定位采用 PDF 文件页序号:基础与 Q-learning 从 PDF 第 226–238 页附近展开;DQN 第 238–245 页;PPO 第 245–253 页;调参/TSP 第 253–258 页;附件索引第 259 页。主体印刷页码约为 217–250,个别页面显示异常,因此以 PDF 页序号定位更可靠。
本文使用教材的六要素、基础算法路线与案例主题;公式澄清、独立评估、多随机种子、时间截断处理、完整教学代码和现代方法对照为重新组织与补充。教材中的作业、安装提示和文档内指令仅作为参考内容,未作为对本任务的操作授权。
进一步阅读:
- Sutton & Barto:Reinforcement Learning: An Introduction:系统学习 MDP、TD、价值与策略梯度。
- DQN 原论文、Double DQN、Rainbow:价值学习路线。
- PPO 原论文、GAE:策略优化与优势估计。
- Gymnasium 文档:环境与终止接口。
- SB3 算法支持表:按动作空间检查实现支持。
- Q-learning 收敛论文:表格算法收敛条件。
- MAPPO 论文、QMIX 论文:多智能体协作的代表性基线。
13. 本次代码验证状态
- 已检查三个完整脚本及额外 SB3 示例的 Python 语法。
- Q-learning:训练 seed 0–4,每个 3000 回合,独立评估均找到 8 步最短路径,未进入障碍,回报为 3(7 次 -1,终点 +10)。
- 边界检查:越界移动留在原地、撞墙留在原地、抵达终点、拒绝非正训练回合数。
- GAE 纯数值检查:真实终止关闭 bootstrap;截断保留下一状态价值但切断跨回合递归;采样块边界正确 bootstrap。
- 当前验证运行时未安装 PyTorch、Gymnasium、Stable-Baselines3,因此未执行 DQN/PPO/SB3 的环境冒烟与完整训练,不提供未经实测的收敛曲线或成绩。
- 待具备依赖后运行:
python dqn_cartpole.py --steps 2048、python ppo_cartpole.py --steps 2048,再按第 6、7 节执行完整多种子训练。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
