简介:面向强化学习初学者与进阶开发者,这份压缩包为在 Python 中实践强化学习算法提供了完整参考,覆盖从马尔可夫决策过程、Q学习到深度Q网络、策略梯度、演员-评论家等深度强化学习方法的代码实现与理论说明,适合游戏AI、机器人控制、资源调度等场景学习与项目复现。包内共384个文件,以Python脚本、文本说明、TensorFlow事件文件等为主,还包括模型权重、图表、Markdown文档、数据文件及配置文件,整体约86.26MB,目录结构清晰,便于按模块查阅。目前已有193人浏览学习。资源里既提供可直接运行的算法代码,也附带环境模拟器接口、训练结果可视化工具和记录学习曲线的TensorBoard日志,文档部分则解释代码结构与基础理论,能够帮助读者系统掌握从经典强化学习到深度强化学习的实现思路,并通过调试优化提升智能体性能,是一份可动手上手的实战型资料。 说实话,我见过太多人学强化学习的时候,算法推导画了一整页纸,结果一到自己写代码,连一个最基础的gym环境都跑不通。这不是算法没学会,而是从公式到实现之间的实操细节,几乎没有人系统讲过。这篇文章我打算以强化学习代码实现以及文档说明为主线,把我在实际项目里验证过的一条完整路线讲清楚——任务如何变成环境接口,actor-critic网络和rollout采集怎么写,训练循环里哪些参数最容易让你怀疑人生,以及最后那一份让项目真正可复现的文档说明到底应该包含什么。
如果你刚接触深度强化学习,这篇能帮你把PPO这类算法从"看得懂公式"推进到"能真正跑起来";如果你已经跑通过现成库、想自己写一套可控的训练代码,那里面提到的很多坑也都是我实际踩过的。后面我还会用"基于强化学习的PID控制"和"机械臂逆向运动学"两个案例,把从仿真训练到C++部署的整个路径串起来。
1. 动手写代码前,先把"任务定义"翻译成环境接口
1.1 环境接口是强化学习的"交互主板"
写强化学习代码,很多人一上来就找算法库,这个顺序其实是反的。不管PPO还是SAC,算法最终只关心一件事:给它一个观测,它给一个动作,然后拿到下一个观测和奖励。这个循环只要通了,后面的网络结构、loss函数、调参技巧才有意义。所以代码实现的第一步,是把自己的任务按照gymnasium的Env接口封装起来。
gymnasium的接口其实就几个核心方法:reset()返回初始观测,step(action)返回下一个观测、奖励、是否结束、额外信息。如果你要做一个倒立摆、机械臂控制或者PID参数整定任务,最朴素的做法都是先实现这样一个类:
import gymnasium as gym from gymnasium import spaces import numpy as np class SimpleBalanceEnv(gym.Env): def __init__(self): super().__init__() self.observation_space = spaces.Box( low=-1.0, high=1.0, shape=(4,), dtype=np.float32 ) self.action_space = spaces.Box( low=-2.0, high=2.0, shape=(1,), dtype=np.float32 ) self.state = None def reset(self, *, seed=None, options=None): super().reset(seed=seed) self.state = np.zeros(4, dtype=np.float32) return self.state, {} def step(self, action): # 这里写你的系统动力学或仿真更新逻辑 self.state = self.state + 0.01 * action reward = -float(np.sum(self.state ** 2)) terminated = False truncated = False return self.state, reward, terminated, truncated, {}这个骨架看起来很基础,但实际项目里最容易出问题的地方恰恰在这里。我自己的项目里就被observation_space的范围坑过:如果某个维度写成[-inf, inf],部分算法在计算分布归一化时会出现奇怪的数值;如果action_space的dtype是float64而网络输出是float32,训练时又会冒出类型不匹配的报错,还特别难排查。所以环境接口的每一行都应该尽量明确:观测的每个分量物理上是什么范围,动作的每个分量是连续量还是离散量。
环境写完之后,我建议第一时间用一个随机策略去跑几十步,把观测和奖励的分布打印出来。这一步几乎不花时间,但能提前发现绝大多数接口问题,比如状态更新写反、奖励爆炸成NaN、action的下界比上界还大。很多人后面训练失败浪费一整天,回头一看都是这种低级问题。
1.2 奖励函数:写多了是灾难,写少了是玄学
环境接口里对训练效果影响最大的其实是奖励。很多教程会告诉你"奖励设计很重要",但很少告诉你它具体怎么影响行为。我举一个真实例子:倒立摆任务。如果只按"角度偏差"给奖励,agent会很快学会一种疯狂抖动的解法,虽然表象上也在维持平衡,实际上是在钻奖励函数的空子,这就是常说的reward hacking。
规避reward hacking的办法,不是把奖励设计得越来越精细,而是尽量直接使用任务本身天然提供的信号。比如机械臂抓取任务,直接用末端到目标的距离;PID整定任务,直接用误差平方的负值。这些信号来自物理过程的自然反馈,agent很难通过取巧来刷分。相反,如果你在上面叠加一堆"动作要平滑"、"加速度要小"这类人为惩罚项,每一项都是在引导agent走一条你可能没想到过的路径。
还有一点很容易被忽略:奖励的尺度。同一个任务,奖励值如果普遍在几百上千的量级,而另一个任务的奖励在0到1之间,那它们对学习率的要求是完全不同的。碰到训练不收敛,先看一下reward的统计分布,如果均值特别大或者方差特别大,建议先做reward scaling,或者直接使用环境本身的原始奖励再看训练曲线。这也是我在做基于强化学习的PID控制案例时踩过的坑——一开始把误差平方当成奖励,量级动辄几百,loss直接冲高爆掉,后来改成对误差做归一化,训练才恢复正常。
2. Actor-Critic 代码骨架:策略网络、价值网络和 rollout 采集
2.1 网络结构怎么定,决定了探索能力的上限
现在单智能体算法里,PPO几乎是默认选择,因为超参数没那么敏感、实现又相对简单。而PPO这类actor-critic算法的核心,是同时维护两个网络:策略网络actor负责输出动作分布,价值网络critic负责估计状态价值。两者可以共享一部分特征提取层,也可以完全独立,具体看任务复杂度。
这里有个新手最容易忽略的点:actor网络不是直接输出动作,而是输出一个概率分布的参数。对于连续动作空间,通常输出均值mean和一个log_std参数,两者一起构成高斯分布,再从分布里采样得到动作。代码骨架大概长这样:
import torch import torch.nn as nn import torch.nn.functional as F class ActorCritic(nn.Module): def __init__(self, obs_dim, act_dim): super().__init__() self.shared = nn.Sequential( nn.Linear(obs_dim, 256), nn.Tanh(), nn.Linear(256, 256), nn.Tanh(), ) self.actor_mean = nn.Linear(256, act_dim) self.actor_log_std = nn.Parameter(torch.zeros(act_dim)) self.critic = nn.Linear(256, 1) def get_dist(self, obs): h = self.shared(obs) mean = self.actor_mean(h) std = torch.exp(self.actor_log_std.clamp(-2.0, 0.5)) return torch.distributions.Normal(mean, std) def forward(self, obs): value = self.critic(self.shared(obs)) return value为什么是输出分布而不是直接用mean当动作?因为强化学习需要探索,如果actor只输出一个确定动作,训练基本就退化成了贪心搜索。log_std的初始值也很讲究,设置得太小,探索不足,策略很容易陷入局部最优;设置得太大,策略更新噪音太大,收敛慢。我实际训练时会像上面那样把log_std限制在[-2.0, 0.5]之间,防止它跑到极端分布区域。
网络层数不是越多越好。对于大多数控制类任务,两个256维的隐藏层已经非常够了。机械臂这种高维状态空间可以加到512或者上CNN/Transformer,但那是后话。先把小网络跑通,再去堆容量,否则调参时你会分不清是网络容量不够还是训练流程有问题。
2.2 收集rollout数据的正确姿势
rollout这个过程,说人话就是让当前策略和环境交互一段轨迹,把每一步的观测、动作、log概率、奖励、是否结束全部存下来。代码往往不起眼,但它决定了后续训练数据的质量。一个典型实现长这样:
obs, _ = env.reset() for step in range(steps_per_rollout): with torch.no_grad(): dist = agent.get_dist(obs) action = dist.sample() log_prob = dist.log_prob(action).sum(-1) next_obs, reward, terminated, truncated, info = env.step(action.cpu().numpy()) buffer.store(obs, action, log_prob, reward, terminated) obs = next_obs这里有几个细节值得强调。sample()一定要包在torch.no_grad()里,否则采样路径会被加入计算图,显存直接爆炸。buffer里存的是log_prob,不是动作概率本身,因为后面PPO更新要计算新旧策略的比值,需要log概率。另外,terminated和truncated一定要区分:真正完成任务是terminated,因为超时被截断是truncated,两者在GAE计算里的处理方式完全不同,混在一起会让优势估计乱掉。
还有一个更隐蔽的坑:机器人环境的动作通常有上下限,step()内部会对动作做clip,但你在buffer里存的action是clip之前的还是之后的值?在仿真阶段区别不大,但从仿真转到实物部署,这个不一致会导致策略在真实设备上表现完全走样。所以我的习惯是,一切进入环境实际执行的action,都以环境接收后的版本为准,然后再用它去算log概率,保证数据一致性。
如果你留意到热词里有人搜"使用C++训练强化学习actor-critic",我的建议是:不要一上来就在C++里写整套训练逻辑。C++的数值库和自动求导生态虽然已经不错,但调试成本高很多。最稳妥的路线是Python里完成训练和验证,导出模型参数,然后在C++里实现前向推理和部署。如果真的需要在C++里训练,优先考虑LibTorch,但做好心理准备,调试体验和Python完全不是一个量级。
3. 训练循环里的稳定性细节:GAE、clip、熵正则的实战调参
3.1 GAE为什么容易算爆,以及正确的计算顺序
GAE(广义优势估计)几乎是PPO的标配,作用是把一条轨迹上的奖励逐步回溯成每个时间步的"优势",让策略知道哪些动作比平均水平更好。计算公式看着不复杂,但实现时细节非常多:
def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): advantages = torch.zeros_like(rewards) gae = 0.0 next_value = 0.0 for t in reversed(range(len(rewards))): delta = rewards[t] + gamma * next_value * (1 - dones[t]) - values[t] gae = delta + gamma * lam * (1 - dones[t]) * gae advantages[t] = gae next_value = values[t] return advantages这里的values必须是价值网络在torch.no_grad()下输出的结果,而且不能在计算图里累积梯度。新手最常见的报错就是values带着梯度进入这个循环,导致整个反向传播的图异常复杂,显存直接爆掉。dones的处理也很关键:如果当前步dones[t]=1,说明一个episode结束了,下一步的价值应该置零,也就是代码里的(1 - dones[t]),否则会把下一个全新episode的价值错误地算进当前episode里。
gamma控制长期回报的权重,lam控制偏差和方差的折中。这两个参数不是拍脑袋定的,gamma越接近1,策略越看重远期收益,适合目标在很远的任务;lam越大,优势估计的方差越大但偏差较小。我实际跑控制类任务时,gamma=0.99, lam=0.95是一个非常稳的起点,基本不用大动。
3.2 PPO更新里的ratio、clip和熵正则
GAE算完之后,就到了PPO的核心更新逻辑。它的目标函数核心是新旧策略的比值,再用clip限制单次更新的步子不能迈太大:
ratio = torch.exp(new_log_prob - old_log_prob) surr1 = ratio * advantage surr2 = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps) * advantage policy_loss = -torch.min(surr1, surr2).mean() entropy = dist.entropy().mean() value_loss = F.mse_loss(new_value, returns) total_loss = policy_loss + 0.5 * value_loss - 0.01 * entropy每个符号背后都有讲究。ratio超过1 + clip_eps说明新策略在这个动作上比旧策略激进太多,要被clip拦住,防止一步更新把策略推到悬崖边。value loss通常带一个0.5系数,让价值网络别跟得那么着急。entropy项是鼓励探索的,它前面的系数虽然小,但影响很大。
如果看到训练早期熵值快速掉到接近0,基本可以断定策略陷入了局部最优。这时候最有效的做法是加大entropy系数到0.05甚至0.1,或者把log_std的下界从-2.0放宽到-1.0,让探索更充分一些。反过来,如果训练一直很随机、回报曲线贴着0走,说明探索太过了,把entropy系数降到0.001以下。这个系数没有一个通用最优值,但"看完训练曲线再调"是唯一靠谱的路线。
3.3 训练不收敛时的快速排查清单
我自己训练过程中遇到不收敛,很少直接去动网络结构,而是先按一个固定顺序排查问题,这里整理成一张表供你参考:
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| loss直接变成NaN | reward或advantage里有Inf | 检查奖励是否溢出,对advantage做标准化 |
| 策略回报忽高忽低 | 学习率太大 | 降到3e-4量级,观察曲线是否变平滑 |
| 训练卡住但熵持续下降 | 探索不足、局部最优 | 提高entropy系数,放宽log_std范围 |
| value loss居高不下 | GAE的dones处理错误,或奖励尺度太大 | 检查truncated/terminated是否区分,做reward归一化 |
| 单步更新后策略剧烈变化 | 没有做gradient clipping | 加torch.nn.utils.clip_grad_norm_(params, max_norm=0.5) |
其中advantage标准化是我强烈建议加的一行:advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)。这行代码单独跑不会改变相对大小,但能显著提升数值稳定性。很多人在小任务上离了它能跑,一换到大规模任务就崩,加了之后往往会稳很多。
4. 文档说明:让三个月后的自己和同事都能快速接手的写作方法
4.1 README是项目的"门面",也是给未来的自己留的提示
标题里"文档说明"四个字分量很重,但被太多人忽略了。代码写得再漂亮,三个月后你自己都未必记得当初为什么这么设计。我的习惯是,项目一开始就写README,然后随代码同步更新。一个及格的强化学习项目README至少应该包含这些部分:
# 项目名称 一句话说明:这个项目解决了什么问题,用了什么算法。 ## 环境依赖 - Python 3.10+ - gymnasium 0.29+ - torch 2.0+ - 安装命令:pip install -r requirements.txt ## 快速开始 - 训练:python train.py --config configs/ppo_balance.yaml - 评估:python evaluate.py --checkpoint checkpoint/ppo_balance.pt ## 配置文件说明 - gamma=0.99: 折扣因子 - lam=0.95: GAE lambda参数 - clip_eps=0.2: PPO clip范围 ## 结果 - 训练曲线截图 - 最终测试回报 - 复现结果的操作步骤很多人觉得README是给别的看的,其实它最大的作用是让"未来的自己"能在十分钟内接上手。我见过不止一次,实验做完了、代码扔在一个命名为final_v2_really_final的文件夹里,等过两个月想重新跑一下,连参数都要猜半天。这种时间的浪费,远比当时写文档花掉的那半小时多。
4.2 注释只写"为什么",不写"是什么"
代码注释这件事,我的原则很明确:只注释为什么这么做,不注释这行代码在做什么。比如gamma * next_value * (1 - dones[t]),如果注释写"计算折扣价值",那等于没写;如果写"这里必须用(1-dones)把终局后的价值清零,否则会串episode",这个注释才有真正的价值。
对于强化学习项目,还有一类特别值得注释的内容:超参数的选择依据。比如clip_eps=0.2,如果你经过实验发现0.1时训练更稳但慢,0.3时波动大但上限高,你把这句话写进注释,能帮未来的自己省掉重新做一遍实验的时间。这类"实验结论型注释"是普通代码注释之外强化学习项目独有的财富。
超参数本身我建议集中放在yaml或dataclass配置里,不要在代码各处散落魔法数字。一个小技巧是:每次实验保存模型checkpoint时,把对应的超参数json一起保存。这样回头分析结果时,模型文件本身就是完整的实验记录,不用再去翻历史代码。
4.3 实验记录:训练曲线之外,还需要一张结果表
强化学习的实验结果,如果只保留一张loss曲线,很多信息是丢失的。我现在的习惯是每个项目维护一个简单的实验记录表,记录日期、算法、环境、关键超参数、最终回报、以及备注。格式不需要复杂,Markdown表格就够用:
| 日期 | 算法 | 环境 | 学习率 | 最终回报 | 备注 |
|---|---|---|---|---|---|
| 2025-01-10 | PPO | Pendulum-v1 | 3e-4 | -180 | 基础版本 |
| 2025-01-11 | PPO | Pendulum-v1 | 1e-3 | -320 | 学习率太大,不稳定 |
这样记的好处是,等你想对比不同方案时,不需要重新跑实验,直接看表格就能定位问题。如果你想记录得更细,可以接wandb或者tensorboard,但别忘了它们只是记录工具,真正做决策的还是你自己。文档的本质是降低决策成本,无论是给未来的自己还是给团队里的其他人。
5. 一个案例串起来的完整路径:从仿真训练到部署验证
5.1 用"基于强化学习的PID控制"练手最合适
如果你只想通过一个项目把整条链路打通,我强烈推荐"基于强化学习的PID控制"。这个任务的被控对象可以是电机、水箱或者一个简单的温控系统,动作是控制器输出或PID参数,奖励直接用误差平方的负值。状态空间小、可解释性强、训练速度快,特别适合验证代码框架是否正确。
我当时的做法是:用离散传递函数模拟被控对象,封装成gym环境,用PPO训练一个控制器。第一步用固定PID参数生成随机扰动数据,观察环境是否稳定;第二步用随机策略跑200步,确认观测和奖励范围合理;第三步才改上PPO。整个过程也就一个晚上,但把环境封装、rollout、GAE、PPO更新、checkpoint保存、文档记录全部跑通了。后面再上机械臂、无人机,只需要替换环境和网络结构,训练主流程几乎不用动。
5.2 机械臂逆向运动学、MuJoCo和PPO的常见组合
机械臂逆向运动学(IK)是另一个很适合进阶的方向。你可以把IK问题当成强化学习任务:观测是机械臂当前关节角和目标末端位置,动作是关节角的增量,奖励是末端离目标距离的负值。MuJoCo提供了支持接触和物理仿真的环境,配合PPO可以完成"从关节空间直接学习末端跟踪"的策略。
但这个案例比PID控制器复杂很多,最明显的问题是稀疏奖励和局部最优。距离奖励虽然连续,但机械臂自由度一高,初期随机探索很难离目标足够近。我实际的做法是先用运动学逆解给一小段专家轨迹做演示,或者对距离设定一个阈值,超过阈值给额外奖励,把agent先"引"到目标附近。这里要注意,专家引导的比例要逐步降低,否则策略永远学不会自己探索。
训练完成后,把PyTorch模型权重导出为TorchScript,再用LibTorch在C++里加载并做前向推理。这一步对接的是热词里提到的"C++部署"场景。Python训练、C++推理这套流程,能兼顾开发效率和部署性能。如果你非要在C++里做整个训练流程,那就要自己管理rollout存储、自动求导和优化器,工程量会翻好几倍,除非有很强的理由,我不建议这么做。
5.3 后面可以往哪些方向延展
一旦你已经具备"定义环境、实现PPO、训练稳定、写清文档"这套基本功,后面的路就宽了。可以往多智能体强化学习方向走,研究多个agent之间的协作和竞争;可以做基于模型的强化学习,让agent利用学到的动力学模型减少真实交互次数;也可以考虑离线强化学习,比如IQL,利用已有数据集训练策略,避免在线采样成本。
无人机控制是深度强化学习应用里的一个热门落地方向,但实物部署的坑比仿真更多:状态估计噪声、通信延迟、安全边界,每一项都可能让仿真里稳定的策略在真机上失效。我个人的建议是,仿真到实物的跨越应该从最保守的设置开始,先做半实物测试,再逐步放开边界。这个过程里,前期写好的文档说明会救你命——它能帮你快速定位哪些参数来自仿真假设、哪些来自真实平台标定,而不是靠记忆一点点猜。
如果现在有人问我强化学习代码实现的起步建议,我会说:先别追求算法多新、网络多大,找一个像PID控制这样的小任务,把环境、算法、训练、文档这四件事完整跑一遍。这个过程里收获的调试直觉和工程习惯,比多学十个算法都管用。
本文还有配套的精品资源,点击获取