让机器人自己学会走路:一篇讲透 PPO 的大白话笔记
一个格子、一个机器人、两个小神经网络,把强化学习里最主流的算法 PPO 从头讲清楚。不堆公式,但每一步都经得起推敲。
如果你听过"人形机器人靠强化学习学会走路"“AlphaZero 自我对弈变强”,却始终没搞懂这中间到底发生了什么——这篇就是写给你的。
我们不碰真机器人那么复杂的东西,而是用一个5×5 的格子地图,让一个机器人从起点走到目标。麻雀虽小,五脏俱全:真实人形机器人用的 PPO,和这个小玩具是同一套算法,只是把格子换成传感器、把上下左右换成几十个关节而已。
一、先把舞台搭好:机器人格子世界
列→ 0 1 2 3 4
行 ┌─────┬─────┬─────┬─────┬─────┐
0 │ │ │ │ │ 🎯 │ 🎯 目标:+10,回合结束
├─────┼─────┼─────┼─────┼─────┤
1 │ │ │ │ ⚠️ │ │ ⚠️ 陷阱:−10,回合结束
├─────┼─────┼─────┼─────┼─────┤
2 │ │ │ ⚠️ │ │ │
├─────┼─────┼─────┼─────┼─────┤
3 │ │ │ 🧱 │ │ │ 🧱 墙:进不去,原地不动
├─────┼─────┼─────┼─────┼─────┤
4 │ 🤖 │ │ │ │ │ 🤖 起点
└─────┴─────┴─────┴─────┴─────┘
每走一步 −1 动作:上 / 下 / 左 / 右
规则很朴素,但每一条都有用:
- 状态:机器人当前在哪个格子,用
(行, 列)表示,一共 25 个。 - 动作:上、下、左、右四个。撞墙或走出边界就停在原地,但这一步照样算(照样扣分)。
- 奖励(环境给的即时反馈):走到目标 +10,掉进陷阱 −10,其余每走一步 −1。
- 终止:到目标或掉陷阱,本回合结束,机器人回到起点重来。
- 折扣
γ = 0.9:越远的未来收益,折算到现在越不值钱。
那个不起眼的"每步 −1"是精髓——它逼着机器人走最短路,磨蹭越久扣得越多。这就是"奖励设计"在悄悄地教机器人:别乱逛,快点到。
我们的目标:让机器人学出一套"在每个格子该往哪走"的本事,使从起点到目标的总得分最高(= 避开陷阱、绕过墙、走最短路)。
二、两个主角:演员和评论家,其实是两个神经网络
强化学习里有个经典搭档叫 Actor-Critic(演员-评论家)。名字很传神:
- 演员(Actor)= 策略网络:真正做决定的人。给它当前格子,它输出"上下左右各该走的概率"。
- 评论家(Critic)= 价值网络:旁边的解说员。给它同一个格子,它输出一个分数,估计"从这格出发,往后大概能拿多少分"。
它们俩都是小小的多层感知机(MLP),几层全连接加激活函数而已:
| 演员 = 策略网络 | 评论家 = 价值网络 | |
|---|---|---|
| 输入 | 格子编码成的向量 | 同一个向量 |
| 输出 | 4 个数,过 Softmax → 四个方向的概率 | 1 个数 → 这格的预估分 |
| 干什么 | 决定往哪走 | 估计这局面值多少 |
| 上线部署 | ✅ 用它 | ❌ 训练完丢掉 |
一个真实的锚点:AlphaZero 就是这个结构——一个大网络长两个头,策略头输出走法概率(演员),价值头输出局面评分(评论家)。我们只是把大网络换成小 MLP、把棋盘换成格子。
特别要记住一件事:评论家只看"局面",不看"动作"。 它从不说"你往右走这一步是好是坏",它只说"你现在这个格子值多少分"。那动作的好坏谁来判?马上讲。
三、核心思想:一个不断重复的循环
PPO 说白了,就是让机器人"反复练习、越练越好",每一轮都是同样四步:
- 上场试:按现在的水平,真的去地图上走一趟,带点随机去探索。
- 复盘:回过头算每一步"比预期好还是差"。
- 调整:把"好动作"的概率调高、"坏动作"调低;顺便让评论家估得更准。
- 换新:把这一轮的记录扔掉,用进步后的自己重新来。
只有第 1 步碰环境,第 3 步是关起门"做题"。PPO 就是"出去试一批" 和 “回来练一批” 反复交替。
下面我们把两轮走完,每个数字都摆出来。
四、关键的一把尺子:优势(Advantage)
在动手之前,先讲清楚 PPO 怎么判断"一步走得好不好"。用的量叫优势:
一步的优势 = 即时得分 + 0.9 ×(走到的新格预估分) − (当前格预估分)
掉坑、到目标这种终止格,它的"预估分"记 0(后面没有未来了)。
翻译成大白话:走完这一步,“到手的分 + 新格子的前景” 比 “原来格子的前景” 高多少。 高,就是好步(优势为正);低,就是差步(优势为负)。
这正是解说员"预估分的一次跳动":评论家没直接夸你,但你走完一步,他对新格子的预估分往上跳,你就知道走对了。动作的好坏,是从预估分的跳动里读出来的,而不是评论家亲口说的。 这也解答了上一节留的问题。
严谨补充:这是"单步版"优势(术语叫 TD 残差)。还有一种"整段版"(用整轮的实际回报减预估分)。两者是同一家族(GAE)的两个极端,真实 PPO 在中间取平衡。格子导航用单步版最直观——它能把"掉坑"这口锅精准甩到该背的那一步头上。第九节会用同一步的数字,把这两种算法讲透、说清它们的关系。
五、第 1 轮:机器人还没学会,一头栽进陷阱
第 1 步:上场试(收集数据)
机器人从起点出发。每到一个格子,都走完整的一套流程:评论家报预估分 → 策略网络报四方向概率 → 按概率采样一个方向 → 记下所选方向的概率 → 执行动作、落到新格 → 环境根据落到的格子给即时得分(掉坑 −10、到目标 +10、其余 −1)。
顺序上要注意:即时得分是"落到新格"之后才定的——因为得分多少取决于你落到哪个格子(是坑、是目标、还是普通格)。所以先移动、再结算得分。
| 步 | 格子 | 评论家预估分 | 四方向概率(上/下/左/右) | 采样到 | 记下的概率 | 落到新格 | 即时得分 |
|---|---|---|---|---|---|---|---|
| 0 | (4,0) | −4.0 | 27 / 23 / 23 / 27 | 上 | 27% | (3,0) | −1 |
| 1 | (3,0) | −3.2 | 27 / 23 / 23 / 27 | 上 | 27% | (2,0) | −1 |
| 2 | (2,0) | −2.4 | 25 / 23 / 22 / 30 | 右 | 30% | (2,1) | −1 |
| 3 | (2,1) | −3.5 | 26 / 22 / 22 / 30 | 右 | 30% | ⚠️(2,2) 掉坑,结束 | −10 |
这里有两个容易被忽略、但很重要的点:
- 采样,不是取最大。策略网络给的是一个概率分布,机器人从里面随机抽一个方向。带点随机,才能探索到没走过的方向。
- 要记下所选动作的概率(那一列)。这是给下一步的"更新"当基准的——等策略网络变了,拿"新概率"跟这个"旧概率"比,才知道策略改了多少、并据此限制"每次别改太狠"。
第 2 步:复盘,逐步算优势
套用上面的公式(终止格预估分记 0):
| 步 | 这一步 | 算式 | 优势 | 判定 |
|---|---|---|---|---|
| 0 | 上 @ (4,0) | −1 + 0.9×(−3.2) − (−4.0) | +0.12 | 好步,往目标靠 |
| 1 | 上 @ (3,0) | −1 + 0.9×(−2.4) − (−3.2) | +0.04 | 基本持平 |
| 2 | 右 @ (2,0) | −1 + 0.9×(−3.5) − (−2.4) | −1.75 | 差,往危险区拐 |
| 3 | 右 @ (2,1) | −10 + 0.9×0 − (−3.5) | −6.5 | 大错,掉坑! |
看这个功过分配有多干净:两个"上"几乎持平(它们没错),罪魁是"右"——尤其最后掉坑那步被狠狠罚了 −6.5。单步优势把锅精准地甩给了该背的那一步。
第 3 步:调整两个网络(不再上场走,只改网络)
这一步完全不碰环境,就是拿刚才那批固定的记录改网络,和图像分类改网络没两样。
调策略网络(按优势正负,朝对应方向挪,但每次只挪一小步):
- 上 @ (4,0):+0.12 → 上 27% → 28%(略升)
- 上 @ (3,0):+0.04 → 上 27% → 27%(几乎不动)
- 右 @ (2,0):−1.75 → 右 30% → 24%(调低)
- 右 @ (2,1):−6.5 → 右 30% → 24%
最后这条要划重点:掉坑那步优势高达 −6.5,但一次更新最多把它的概率降到约 24%,不许一步归零。 这就是 PPO 的"裁剪(clip)"——它把每次策略的改动幅度死死限制在一个小范围(概率比不超过约 0.8~1.2 倍)。为什么要限制?因为这批数据是"旧策略"跑出来的,一步把策略改得面目全非,数据就不作数了,容易训崩。想彻底压下这个坏动作,得靠好几轮慢慢来。稳,是 PPO 能大规模训练的根本。
调价值网络(让每格预估分逼近"即时得分 + 0.9×新格预估分"):
- V(2,1):目标 = −10 → 从 −3.5 大幅下调(学到这格通往坑,危险)
- V(2,0):目标 ≈ −4.15 → 从 −2.4 下调(这条路没想的好)
- V(4,0)、V(3,0):目标 ≈ −3.88 / −3.16 → 各微升
最后,把这一轮的记录全部扔掉。 因为机器人已经变了,旧记录是"过去的自己"走的,不能再用来指导现在。这个"数据用完即弃、每轮重新采集"的规矩,叫 on-policy,是它区别于普通监督学习的关键。
六、第 2 轮:学乖了,走安全路线
用调整后的网络,机器人这次不往中间的危险区拐了,而是沿左边一列一路向上。
第 1 步:上场试(收集数据)
和第 1 轮一样,机器人从起点出发,每到一个格子都走完整的一套流程:评论家报预估分 → 策略网络报四方向概率 → 按概率采样一个方向 → 记下所选方向的概率 → 执行动作、落到新格 → 环境根据落到的格子给即时得分(掉坑 −10、到目标 +10、其余 −1)。唯一不同的是,这次用的是上一轮调整过的网络。
| 步 | 格子 | 评论家预估分 | 四方向概率(上/下/左/右) | 采样到 | 记下的概率 | 落到新格 | 即时得分 |
|---|---|---|---|---|---|---|---|
| 0 | (4,0) | −4.0 | 28 / 23 / 22 / 27 | 上 | 28% | (3,0) | −1 |
| 1 | (3,0) | −3.3 | 27 / 23 / 23 / 27 | 上 | 27% | (2,0) | −1 |
| 2 | (2,0) | −2.6 | 28 / 23 / 25 / 24 | 上 | 28% | (1,0) | −1 |
| 3 | (1,0) | −1.9 | 30 / 22 / 23 / 25 | 上 | 30% | (0,0),走满 4 步收工 | −1 |
注意第 2 步:(2,0) 处的"右"已经从上一轮的 30% 降到了 24%,“上"升到了 28%——机器人这次自然地选了"上”,绕开了通往陷阱的方向。上一轮那记 −6.5 的教训,起效了。
(价值网络会跟着策略一起调整,这里给的是它当前较协调的一组预估分。)
第 2 步:复盘,逐步算优势
套用同一个公式(优势 = 即时得分 + 0.9×下一格预估分 − 当前格预估分。这一轮没掉坑也没到目标,末步就用到达格 (0,0) 的预估分 −1.2 接续):
| 步 | 这一步 | 算式 | 优势 | 判定 |
|---|---|---|---|---|
| 0 | 上 @ (4,0) | −1 + 0.9×(−3.3) − (−4.0) | +0.03 | 稳 |
| 1 | 上 @ (3,0) | −1 + 0.9×(−2.6) − (−3.3) | −0.04 | 稳 |
| 2 | 上 @ (2,0) | −1 + 0.9×(−1.9) − (−2.6) | −0.11 | 稳 |
| 3 | 上 @ (1,0) | −1 + 0.9×(−1.2) − (−1.9) | −0.18 | 稳(末步用到达格 (0,0) 的预估分接续) |
关键对比:第 1 轮有个 −6.5 的灾难,这一轮所有优势都挤在 ±0.2 的小范围里。没有大惊喜,恰恰说明机器人走得稳、评论家估得准。优势整体趋近于零,就是"快练成了"的信号。
第 3 步:调整两个网络(不再上场走,只改网络)
和第 1 轮一样,这一步不碰环境,只拿刚才那批固定记录改网络。
调策略网络(按优势正负,每次只挪一小步;这一轮优势都很小,所以改动也都很小):
- 上 @ (4,0):+0.03 → 上 28% → 28%(几乎不变)
- 上 @ (3,0):−0.04 → 上 27% → 27%(几乎不变)
- 上 @ (2,0):−0.11 → 上 28% → 27%(略降)
- 上 @ (1,0):−0.18 → 上 30% → 29%(略降)
对比第 1 轮那记 −6.5 逼出的大改动,这一轮所有改动都是"毫米级"的微调——优势小、改动小,正是策略快收敛的表现。
调价值网络(让每格预估分逼近"即时得分 + 0.9×下一格预估分"):
- V(4,0):目标 = −1 + 0.9×(−3.3) = −3.97 → 从 −4.0 几乎不用动
- V(3,0):目标 = −1 + 0.9×(−2.6) = −3.34 → 从 −3.3 微调
- V(2,0):目标 = −1 + 0.9×(−1.9) = −2.71 → 从 −2.6 微调
- V(1,0):目标 = −1 + 0.9×(−1.2) = −2.08 → 从 −1.9 微调
每个目标都和当前预估分很接近,说明评论家已经估得挺准了,只需小修。
最后,照例扔掉这一轮的全部记录(on-policy:网络已经变了,旧数据不能再用),用更新后的网络进入第 3 轮……
七、把两轮连起来看
| 关键量 | 第 1 轮 | 第 2 轮 | 趋势 |
|---|---|---|---|
| (2,0) 处"右"的概率 | 30% → 24% | 24%(持平) | 学会不往危险区拐 |
| 掉坑那步的优势 | −6.5(重罚) | 不再发生 | 危险动作被压制 |
| 各步优势的幅度 | 混着 −6.5 的大值 | 全在 ±0.2 | 平稳 = 收敛 |
| 本回合结局 | 掉进陷阱 ⚠️ | 安全上行,靠近目标 | 明显变好 |
再跑几十上百轮,机器人就能稳定地绕开陷阱和墙、沿最短路一步到位走到目标。它的"智慧"不在网络结构(就俩小 MLP),而在这一轮轮打磨出来的网络权重里。
八、练成之后:关掉探索,一口气走到目标
你可能注意到:上面两轮,机器人都没走到目标 🎯。这不是没学会,而是为了把每一步讲清楚,前面每轮只演示了 4 步——而从起点 (4,0) 到目标 (0,4) 最短要 8 步(4 步向上 + 4 步向右)。真实训练里,一个回合会一直走到掉坑、到目标、或走满一个足够大的步数上限,不会这么早收工。
那练成之后是什么样?训练几十上百轮、优势都趋近于零后,策略就稳定了。这时进入部署模式:只加载演员(策略网络),关掉探索的随机性,每一步直接选概率最高的方向(不再采样)。于是机器人一口气走完整条最优路径:
| 步 | 格子 | 选的方向(概率最高) | 落到新格 | 即时得分 |
|---|---|---|---|---|
| 0 | (4,0) | 上 | (3,0) | −1 |
| 1 | (3,0) | 上 | (2,0) | −1 |
| 2 | (2,0) | 上 | (1,0) | −1 |
| 3 | (1,0) | 上 | (0,0) | −1 |
| 4 | (0,0) | 右 | (0,1) | −1 |
| 5 | (0,1) | 右 | (0,2) | −1 |
| 6 | (0,2) | 右 | (0,3) | −1 |
| 7 | (0,3) | 右 | 🎯 (0,4) | +10 |
一整趟下来:前 7 步各 −1,最后一步踩上目标 +10,总得分 = +3——这正是这张地图能拿到的最高分(少一步都到不了,多一步就多扣 1)。机器人稳稳绕开了两个陷阱 ⚠️ 和那堵墙 🧱,沿最短路一步不多地到达目标。
这一趟和训练时有四点关键不同:
- 只用演员,评论家下岗:部署时只加载策略网络(演员)——给它当前格子,它输出方向,就这么简单。价值网络(评论家)只是训练时用来算优势的"陪练",训练一结束就被丢掉,上线根本不参与。所以上面这张表里,也没有了训练时那一列"评论家预估分"。
- 不采样、取最高概率:探索的随机性关掉了,每步走最有把握的方向。这就是"部署时取均值"——训练要探索,上线要确定。
- 没有复盘、没有更新:训练已经结束,演员的参数固定了,这一趟只是纯推理(前向传播),不再改任何权重。
- 能走完全程:因为策略已经收敛,每个格子都知道该往哪走,不再掉坑、不再绕路。
这就是那两个小网络反复练几十上百轮之后的成果。你在真实人形机器人身上看到的"从原地抽搐到稳步行走",本质就是这一幕的放大版。
九、优势的两种算法:乘"实际回报"还是乘"预估分"?
细心的你可能会发现一个矛盾。前面两轮,我们算优势用的是单步版:
优势 = 即时得分 + 0.9 × 下一格的预估分 − 当前格预估分
这里 0.9 乘的是评论家的预估分。但按折扣因子的本意,0.9 不是应该乘"后面实际拿到的回报"吗?没错,那是另一种算法——实际回报版:
优势 = 实际未来回报 − 当前格预估分
(实际未来回报 = 把这一步之后真跑出来的奖励,按 0.9 折扣累加)
两个都对。 关键是看懂:它们填的是同一个坑——“从下一格往后,还能拿多少”。一个用评论家的预测填,一个用真跑出来的账填。为什么能互换?因为评论家的预估分,按定义就是"未来回报的估计"——拿它替代实际回报,等于让评论家替你把后面那本账先估出来。
同一步,两种算法,亲眼看差别。 用第 1 轮的数据(奖励依次 −1、−1、−1、−10,倒着按 0.9 累加,每一步的实际回报恰好都是 −10):
| 步 | 这一步 | 单步版(乘预估分) | 实际回报版(乘实际回报) |
|---|---|---|---|
| 0 | 上 @ (4,0) | +0.12 | −10 − (−4.0) = −6.0 |
| 1 | 上 @ (3,0) | +0.04 | −10 − (−3.2) = −6.8 |
| 2 | 右 @ (2,0) | −1.75 | −10 − (−2.4) = −7.6 |
| 3 | 右 @ (2,1) | −6.5 | −10 − (−3.5) = −6.5 |
看两个极端:
- 掉坑那步(第 3 步),两种算法完全相等(都 −6.5)。因为它是终止步,后面没有"未来"可争议,预测和实际都是 0。
- 开头那步"上"(第 0 步),两者天差地别:单步版说 +0.12(这一步没毛病,往目标靠了),实际回报版说 −6.0(把后面掉坑的锅也算到它头上了)。
差别的根源:实际回报版把"整轮的最终结局"拖回来、摊到每一步头上——这一轮结局是掉坑,于是连开头那步好动作(往目标靠的那步)也被连坐。单步版只往前看一格,用评论家的预估判断"就这一步,局面变好还是变差",所以能干净地只罚该罚的那步。
那到底该信谁?各有取舍:
| 实际回报版 | 单步版 | |
|---|---|---|
| "后面的账"怎么来 | 真跑出来的(实测) | 评论家预测的 |
| 准不准 | 平均而言无偏 | 评论家不准就有偏 |
| 稳不稳 | 方差大(一次运气就摊到每一步) | 方差小(只看一步) |
| 要求 | 得有完整回合 | 回合没走完也能算 |
| 何时相等 | 评论家对下一格的预估 = 实际未来回报时 | 二者相等(终止步必满足,故第 3 步一致) |
机器人训练里回合长、随机性大:纯实际回报版方差太高、还得等回合走完;纯单步版方差小但太依赖评论家。所以真实 PPO 用 GAE,拿一个参数 λ 在两者之间平滑取中——λ=1 就是实际回报版,λ=0 就是单步版,中间兼顾两者。这就是为什么你会在不同地方看到不同的优势公式:它们是同一把尺子的两种刻度,不是矛盾。
十、一个绕不开的疑问:这怎么这么像监督学习?
很多人看到"调网络"那步会愣一下:算个损失、反向传播,这不就是图像分类那套吗?
没错,而且是故意的。 PPO 最聪明的地方,就是把强化学习"打包"成一个监督学习式的问题,交给成熟的 SGD/Adam 去解:
- 评论家那部分,就是纯监督回归:输入格子,预测分数,拿"即时得分 + 0.9×新格预估分"当标签,算均方误差。和"预测房价、拿真实房价当标签"一模一样。
- 演员那部分,长得像但多了个花样:它没有"标准答案"当标签,而是拿优势当权重——优势为正的动作,推高它的概率;为负的,压低。它优化的不是"往正确答案靠",而是"把自己试过的、结果好的动作,变得更爱做"。
那它凭什么还叫强化学习,而不是监督学习?就差一条,但这条要命:
监督学习的数据集是死的、一次给定的;PPO 的"数据集"是机器人自己跑出来的,而且每一轮都要用最新策略重新跑一遍、然后扔掉旧的。
这个"边试边学、数据自我更新"的外循环,才是它的灵魂。更新的那一小段(关起门做题)确实是监督学习;但外面套着的"上场试 → 复盘 → 更新 → 扔掉重来",让它成了强化学习。
顺便澄清一个常被混淆的点:在"更新"这一段里,机器人不碰环境、不走新步、不产生新格子。 它只是把已经走过的那些格子重新喂进网络,评估"当初那个动作现在的概率是多少"。想尝试新方向?得等下一轮重新上场时。
十一、用代码把它跑起来:两百来行的完整实现
前面全是讲道理。现在把这个格子机器人变成能真跑的代码——你会发现,讲了这么多的每一个概念,都能在这一份文件里一一对上。完整文件是 gridworld_ppo.py,只依赖 numpy 和 PyTorch。下面先拆成几块看,文末给出可直接运行的完整代码。
① 环境:格子世界的规则
GRID = 5
START = (4, 0); GOAL = (0, 4)
TRAPS = {(1, 3), (2, 2)}; WALL = {(3, 2)}
MOVE = {0: (-1, 0), 1: (1, 0), 2: (0, -1), 3: (0, 1)} # 上/下/左/右
def step(s, a):
dr, dc = MOVE[a]
ns = (s[0] + dr, s[1] + dc)
if not (0 <= ns[0] < GRID and 0 <= ns[1] < GRID) or ns in WALL:
ns = s # 出界或撞墙 → 原地不动
if ns == GOAL: return ns, 10.0, True # 到目标 +10
if ns in TRAPS: return ns, -10.0, True # 掉陷阱 −10
return ns, -1.0, False # 普通一步 −1
这就是第一节那张地图和规则的代码版。注意奖励由落到的格子决定,和第五节强调的顺序一致。
② 演员和评论家:两个小 MLP
class ActorCritic(nn.Module):
def __init__(self):
super().__init__()
self.actor = nn.Sequential(nn.Linear(25, 64), nn.Tanh(), nn.Linear(64, 4))
self.critic = nn.Sequential(nn.Linear(25, 64), nn.Tanh(), nn.Linear(64, 1))
def dist(self, x): return torch.distributions.Categorical(logits=self.actor(x)) # 四方向概率
def value(self, x): return self.critic(x).squeeze(-1) # 预估分
状态是格子的 25 维 one-hot。演员输出四方向概率(第二节的策略网络),评论家输出一个预估分(价值网络)。
③ 上场试 + 复盘:采集回合、逐步算优势
def run_episode(): # 第五节的"上场试"
traj, s = [], START
for _ in range(HORIZON):
x = torch.from_numpy(encode(s))
d = net.dist(x); a = d.sample() # 按概率采样一个方向(探索)
ns, r, done = step(s, int(a))
traj.append((x, a, d.log_prob(a), r, net.value(x).item())) # 记下 5 样东西
s = ns
if done: break
return traj
def gae(traj): # "复盘":逐步算优势
adv, last = [0.0] * len(traj), 0.0
for t in reversed(range(len(traj))):
v_next = traj[t + 1][4] if t + 1 < len(traj) else 0.0 # 末步无未来 → 接 0
delta = traj[t][3] + GAMMA * v_next - traj[t][4] # 单步优势(第四节的公式)
last = delta + GAMMA * LAM * last # GAE 累积(第九节的 λ)
adv[t] = last
ret = [adv[t] + traj[t][4] for t in range(len(traj))] # 评论家的回归目标
return adv, ret
run_episode 是"上场试":每步采样方向、记下五样东西。gae 是"复盘":那行 delta 正是第四节的单步优势公式,GAE 再把它累积起来。
④ 调整:PPO 裁剪更新
for _ in range(K_EPOCHS):
d = net.dist(X)
ratio = (d.log_prob(A) - LP).exp() # 新旧策略概率比
s1 = ratio * ADV
s2 = torch.clamp(ratio, 1 - CLIP, 1 + CLIP) * ADV # 裁剪(安全带)
policy_loss = -torch.min(s1, s2).mean() # 演员:裁剪目标,取悲观值
value_loss = (net.value(X) - RET).pow(2).mean() # 评论家:回归到回报
loss = policy_loss + 0.5 * value_loss - ENT_COEF * d.entropy().mean()
opt.zero_grad(); loss.backward(); opt.step()
这就是第五、六节"调整"那步:clamp 是裁剪安全带,min 取悲观值,演员按优势升降概率、评论家逼近回报。每轮更新完就丢掉数据(on-policy),用新网络重采。
⑤ 部署:只用演员,取最高概率
@torch.no_grad()
def deploy(): # 第八节的"练成之后"
s = START
for _ in range(HORIZON):
x = torch.from_numpy(encode(s))
a = int(net.dist(x).probs.argmax()) # 取概率最高的方向,不采样、不碰评论家
s, r, done = step(s, a)
if done: break
对应第八节:关掉探索、取概率最高的方向,只用演员(代码里这里根本没出现 net.value)。
⑥ 真跑一遍的结果
训练时,平均回合得分从一开始的乱撞一路涨到最优:
第 0 轮 平均回合得分 = -36.47 ← 一开始乱撞、掉坑
第 20 轮 平均回合得分 = 2.77
第 40 轮 平均回合得分 = 3.00 ← 收敛到最优(+3)
...
第 199 轮 平均回合得分 = 3.00
训练完,部署模式让它一口气走到目标:
在 (4,0) → 右 → (4,1) −1
在 (4,1) → 右 → (4,2) −1
在 (4,2) → 右 → (4,3) −1
在 (4,3) → 右 → (4,4) −1
在 (4,4) → 上 → (3,4) −1
在 (3,4) → 上 → (2,4) −1
在 (2,4) → 上 → (1,4) −1
在 (1,4) → 上 → (0,4) +10
到达目标:是 🎯 总得分 = +3
它绕开了两个陷阱和那堵墙,用 8 步走到目标,总分 +3——正是这张图能拿到的最高分。
小提醒:最短路不止一条。第八节我手画的是"先上后右",代码这次学到的是"先右后上"(沿底边走到最右列,再上到目标)——两条都是 8 步、+3 的最优解,PPO 找到哪一条取决于随机初始化和探索。
⑦ 完整代码(复制即可运行)
上面是拆开讲的片段;下面是拼在一起的完整文件 gridworld_ppo.py(约 220 行,含注释)。存下来后执行 python gridworld_ppo.py,就能看到它训练、收敛、并走到目标:
# -*- coding: utf-8 -*-
"""5x5 格子机器人的完整 PPO 实现(可运行,带详细注释)。
任务:机器人从 (4,0) 出发,避开陷阱 (1,3)/(2,2) 和墙 (3,2),走到目标 (0,4)。
角色:
- 演员(策略网络):状态 -> 四方向概率,负责"做决定"。部署时只用它。
- 评论家(价值网络):状态 -> 预估分,负责"估局面",只在训练时当尺子。
流程(PPO 一轮):采集回合 -> 逐步算优势(GAE)-> 裁剪目标更新 -> 丢弃数据 -> 重来。
坐标约定:格子用 (行, 列),行 0 在最上、列 0 在最左;起点在左下、目标在右上。
"""
import numpy as np
import torch
import torch.nn as nn
# 固定随机种子,让每次运行结果可复现(网络初始化、动作采样都受它影响)
torch.manual_seed(0)
np.random.seed(0)
# ═══════════════ 1. 环境:5x5 格子世界 ═══════════════
GRID = 5
START = (4, 0) # 起点(左下)
GOAL = (0, 4) # 目标(右上),+10,终止
TRAPS = {(1, 3), (2, 2)} # 两个陷阱,−10,终止
WALL = {(3, 2)} # 墙,进不去
# 动作编号 -> 行列增量:0=上(行−1) 1=下(行+1) 2=左(列−1) 3=右(列+1)
MOVE = {0: (-1, 0), 1: (1, 0), 2: (0, -1), 3: (0, 1)}
ARROW = {0: "上", 1: "下", 2: "左", 3: "右"} # 仅用于打印
def step(s, a):
"""执行动作,返回 (新格子, 即时得分, 是否结束)。奖励由落到的格子决定。"""
dr, dc = MOVE[a]
nr, nc = s[0] + dr, s[1] + dc
ns = (nr, nc)
# 出界或撞墙 → 停在原地(这一步照样算,照样扣分)
if not (0 <= nr < GRID and 0 <= nc < GRID) or ns in WALL:
ns = s
if ns == GOAL:
return ns, 10.0, True # 到目标:+10,回合结束
if ns in TRAPS:
return ns, -10.0, True # 掉陷阱:−10,回合结束
return ns, -1.0, False # 普通一步:−1,继续
def encode(s):
"""把格子 (行,列) 编码成 25 维 one-hot 向量喂给网络。"""
v = np.zeros(GRID * GRID, dtype=np.float32)
v[s[0] * GRID + s[1]] = 1.0
return v
# ═══════════════ 2. 演员 + 评论家(两个小 MLP)═══════════════
class ActorCritic(nn.Module):
def __init__(self):
super().__init__()
# 演员:25 -> 64 -> 4,配 Categorical 就是四方向概率分布
self.actor = nn.Sequential(nn.Linear(25, 64), nn.Tanh(), nn.Linear(64, 4))
# 评论家:25 -> 64 -> 1,输出一个标量预估分 V(s)
self.critic = nn.Sequential(nn.Linear(25, 64), nn.Tanh(), nn.Linear(64, 1))
def dist(self, x): # 离散动作分布(logits 经 softmax 变四方向概率)
return torch.distributions.Categorical(logits=self.actor(x))
def value(self, x): # squeeze(-1):(...,1) -> (...,) 标量预估分
return self.critic(x).squeeze(-1)
# ═══════════════ 3. 超参数 ═══════════════
GAMMA, LAM = 0.9, 0.95 # 折扣因子、GAE 平滑系数
CLIP, LR = 0.2, 3e-3 # PPO 裁剪幅度、Adam 学习率
K_EPOCHS, ENT_COEF = 10, 0.01 # 每批数据更新次数、熵奖励权重
ITERS, EPISODES_PER_ITER, HORIZON = 200, 30, 60 # 训练轮数、每轮回合数、单回合步数上限
net = ActorCritic()
opt = torch.optim.Adam(net.parameters(), lr=LR)
# ═══════════════ 4. 上场试:采集一个回合 ═══════════════
def run_episode():
"""跑一个回合,记下每步的 (状态, 动作, 旧log概率, 奖励, 预估分)。"""
traj, s = [], START
for _ in range(HORIZON):
x = torch.from_numpy(encode(s))
with torch.no_grad(): # 采数据不需要梯度
d = net.dist(x) # 演员给四方向概率
a = d.sample() # 采样一个方向(探索)
lp = d.log_prob(a) # 记下所选方向的对数概率
v = net.value(x) # 评论家的预估分
ns, r, done = step(s, int(a))
traj.append((x, a, lp, r, v.item()))
s = ns
if done:
break
return traj
# ═══════════════ 5. 复盘:逐步算优势(GAE)和回报目标 ═══════════════
def gae(traj):
T = len(traj)
adv = [0.0] * T
last = 0.0
for t in reversed(range(T)): # 倒着算
v_t = traj[t][4]
v_next = traj[t + 1][4] if t + 1 < T else 0.0 # 末步无未来 -> 接 0
delta = traj[t][3] + GAMMA * v_next - v_t # 单步优势
last = delta + GAMMA * LAM * last # GAE 累积
adv[t] = last
ret = [adv[t] + traj[t][4] for t in range(T)] # 评论家回归目标
return adv, ret
# ═══════════════ 6. 训练主循环 ═══════════════
def train():
for it in range(ITERS):
# ① 采集一批回合。下面 6 个列表把所有回合所有步的数据攒到一起:
# X=状态 A=动作 LP=旧动作对数概率 ADV=优势 RET=回报目标
# ep_scores=每回合总得分(仅打印用)
X, A, LP, ADV, RET, ep_scores = [], [], [], [], [], []
for _ in range(EPISODES_PER_ITER):
traj = run_episode()
adv, ret = gae(traj)
for k, (x, a, lp, r, v) in enumerate(traj):
X.append(x); A.append(a); LP.append(lp)
ADV.append(adv[k]); RET.append(ret[k])
ep_scores.append(sum(s[3] for s in traj))
X = torch.stack(X); A = torch.stack(A); LP = torch.stack(LP)
ADV = torch.tensor(ADV, dtype=torch.float32)
RET = torch.tensor(RET, dtype=torch.float32)
ADV = (ADV - ADV.mean()) / (ADV.std() + 1e-8) # 优势标准化
# ② 用同一批固定数据更新 K 次(不再碰环境)
for _ in range(K_EPOCHS):
d = net.dist(X)
ratio = (d.log_prob(A) - LP).exp() # 新旧策略概率比
s1 = ratio * ADV
s2 = torch.clamp(ratio, 1 - CLIP, 1 + CLIP) * ADV # 裁剪(安全带)
policy_loss = -torch.min(s1, s2).mean() # 演员:裁剪目标
value_loss = (net.value(X) - RET).pow(2).mean() # 评论家:回归
entropy = d.entropy().mean()
loss = policy_loss + 0.5 * value_loss - ENT_COEF * entropy
opt.zero_grad(); loss.backward(); opt.step()
# ③ 本轮结束,数据自然作废;下一轮用新网络重采(on-policy)
if it % 20 == 0 or it == ITERS - 1:
print(f" 第 {it:3d} 轮 平均回合得分 = {np.mean(ep_scores):6.2f}")
# ═══════════════ 7. 部署:只用演员,取概率最高的方向 ═══════════════
@torch.no_grad()
def deploy():
print("\n部署(只用演员,取概率最高的方向):")
s = START
path = [s]
total = 0.0
for _ in range(HORIZON):
x = torch.from_numpy(encode(s))
a = int(net.dist(x).probs.argmax()) # 取最大概率,不采样、不碰评论家
ns, r, done = step(s, a)
print(f" 在 {s} → {ARROW[a]} → 落到 {ns} 得分 {r:+.0f}")
total += r
s = ns
path.append(s)
if done:
break
print(f" 路径:{' → '.join(str(p) for p in path)}")
print(f" 到达目标:{'是 🎯' if s == GOAL else '否'} 总得分 = {total:+.0f}")
if __name__ == "__main__":
print("训练中(平均回合得分,越高越好,最高约 +3):")
train()
deploy()
全篇讲过的每一个概念——状态、动作、奖励、演员、评论家、优势、GAE、裁剪、on-policy 丢弃、部署取最高概率——都在这两百来行里真正跑起来了。 你可以自己改着玩:把陷阱挪个位置、把折扣 GAMMA 调小、把裁剪 CLIP 调大,看它学出的路线和收敛速度怎么变。
十二、从格子到真机器人:只是放大
这个 5×5 的小玩具,和训练宇树、特斯拉 Optimus 那种人形机器人的 PPO,是同一套算法。差别只在量级:
| 格子机器人 | 真实人形机器人 | |
|---|---|---|
| 状态 | 25 个格子 | 上百维(IMU、关节角、速度、指令……) |
| 动作 | 上下左右 4 个 | 二十几个关节的连续力矩 |
| 策略网络 | 小 MLP + Softmax | 小 MLP + 高斯分布 |
| 环境 | 格子地图 | GPU 上的物理仿真(如 Isaac Lab) |
| 并行 | 1 个机器人 | 几千个机器人同时跑 |
| 训练量 | 几十轮 | 几十亿步、几小时 |
| 核心循环 | 试 → 复盘 → 更新 → 重来 | 完全一样 |
连续动作的机器人,策略网络输出的不再是"四个方向的概率",而是"每个关节该转多少"的一个高斯分布:训练时从分布里采样(探索),部署时直接取均值(确定动作)。除此之外,优势、裁剪、on-policy、演员评论家配合——全都一字不差。
所以你刚才看着那个格子机器人从"一头栽进陷阱"练到"安全绕行",和人形机器人从"原地抽搐"练到"稳步行走",本质是同一件事,同一套 PPO,只是规模差了几百万倍。
十三、一句话收尾
PPO 训练一个智能体,就是让它反复地:按当前策略带点随机地跑一趟、记下每一步的状态/动作/概率/奖励;复盘时逐步算出"这步比预期好还是差"的优势;然后关起门把好动作的概率调高、坏动作调低(每次只挪一小步,防止训崩),同时让价值网络把局面估得更准;最后扔掉这批数据,用进步后的自己重新跑。如此反复,策略越来越准、评估越来越稳,直到智能体学会那件你用奖励"暗示"给它的事——无论是走出格子迷宫,还是迈开双腿走路。
演员负责做,评论家负责估,优势当尺子,裁剪当安全带,数据用完即弃。记住这五样,你就抓住了 PPO 的全部。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)