第八章:视觉为王:处理像素输入的PPO
欢迎来到实战篇的第三个战场。在前几章,我们的智能体处理的“状态(State)”都是一维的向量,比如CartPole的角度、角速度,或者BipedalWalker的关节角度等。这些信息是经过高度提炼和结构化的。但真实世界充满了非结构化的原始数据,其中最典型的就是视觉信息。
一个能玩雅达利(Atari)游戏、在模拟驾驶中看懂路况、或者通过摄像头识别物体的机器人,都必须具备直接从像素中学习的能力。本章的使命,就是为我们的PPO智能体安上“眼睛”,让它从一个只能理解抽象数据的“盲人”,进化成能看懂世界的“玩家”。我们将学习如何使用卷积神经网络(CNN)处理图像输入,如何通过帧堆叠(Frame Stacking)让智能体感知动态,并最终亲手打造一个能玩经典Atari游戏的AI。
这个过程远比你想象的要简单,因为PPO算法的核心逻辑具有极好的通用性。我们真正需要做的,只是给它换一个更强大的“特征提取器”而已。
8.1 卷积神经网络(CNN)作为“眼睛”
问题:为什么之前的网络结构处理不了图像?
我们之前在CartPole和Pendulum任务中使用的网络,通常被称为多层感知机(Multi-Layer Perceptron, MLP)或全连接网络(Fully Connected Network)。它的工作方式是将输入向量中的每一个数字,与下一层的所有神经元进行连接。
想象一下,如果我们把一个 84×8484 \times 8484×84 像素的灰度游戏画面作为输入。这个输入向量的维度将是 84×84=705684 \times 84 = 705684×84=7056。如果第一层有512个神经元,那么仅从输入层到第一层的连接权重数量就高达 7056×512≈3607056 \times 512 \approx 3607056×512≈360 万个!这还仅仅是第一层。这样的网络不仅参数量巨大、训练极其缓慢,更致命的是,它完全忽略了图像的空间结构。
在MLP眼中,像素(0,0)像素(0,0)像素(0,0)和像素(0,1)像素(0,1)像素(0,1)之间的关系,与像素(0,0)像素(0,0)像素(0,0)和像素(83,83)像素(83,83)像素(83,83)之间的关系是等价的,这显然是荒谬的。图像中相邻的像素点包含了局部特征(如边缘、角落、纹理),而不同位置的相同特征(比如游戏中的“球”出现在屏幕左边和右边)应该被以类似的方式处理。MLP无法胜任这项工作。
解决方案:卷积神经网络(CNN)
CNN正是为解决这些问题而生的。它模仿了生物视觉皮层的工作原理,通过两种核心操作来高效地提取图像特征:
-
卷积(Convolution):CNN使用小的“卷积核”(Kernel/Filter),在整个图像上滑动。这个卷积核本质上是一个小型的权重矩阵,它只关注图像的一小块局部区域(例如 3×33 \times 33×3 或 5×55 \times 55×5 的像素块)。它的任务是检测特定的局部特征,比如一个垂直边缘、一个特定的颜色块等。通过在整个图像上共享同一个卷积核的权重,CNN极大地减少了参数量,并实现了平移不变性(Translation Invariance)——无论“球”出现在哪里,负责检测“球”的卷积核都能认出它。
-
池化(Pooling):在卷积操作之后,通常会进行池化操作(如最大池化, Max Pooling)。它将特征图的一个小区域(例如 2×22 \times 22×2)压缩成一个单一的值(取该区域的最大值)。这进一步降低了数据的维度,减少了计算量,并使得网络对特征的微小位移不那么敏感,增强了模型的鲁棒性。
一个典型的用于强化学习的CNN结构看起来像这样:
输入图像 -> 卷积层1 -> 激活函数(ReLU) -> 池化层1 -> 卷积层2 -> ... -> 展平(Flatten) -> 全连接层 -> 输出
这个结构的前半部分(卷积和池化层)扮演了自动特征提取器的角色,它将原始的像素矩阵转换成一个紧凑而信息丰富的特征向量。这个向量随后被送入后半部分的全连接层,就像我们之前做的那样,去计算策略(Policy)和价值(Value)。这相当于为我们的智能体安装了一双高效的“眼睛”,它负责“看懂”画面,并将“理解”后的内容汇报给大脑(全连接层)进行决策。
8.2 帧堆叠(Frame Stacking):让智能体拥有“短期记忆”
问题:单张图片足够吗?
让我们以经典游戏《Pong》(乒乓球)为例。假设我们把下面这张游戏截图交给智能体。

图8-1:Pong游戏单帧截图
只看这一张静态图片,你能判断出球在向哪个方向移动吗?它的速度有多快?
答案是:不能。
这正是智能体面临的困境。单个游戏画面是一个静态的观测,它缺少了关于动态(dynamics) 的关键信息,比如物体的运动方向和速度。如果智能体不知道球的运动轨迹,它就无法做出有效的预判和决策,学习过程将变得异常困难甚至不可能。这个问题在许多动态环境中都存在。
解决方案:帧堆叠(Frame Stacking)
解决这个问题最简单、最有效的方法之一就是帧堆叠。它的思想非常直观:我们将连续的几帧(通常是4帧)画面捆绑在一起,作为一个完整的“状态”输入给神经网络。
核心思想(文字描述)
“帧堆叠”的本质非常简单。想象一下,如果我只给你一张球在半空中的照片,你不知道球是往上飞还是往下掉。但如果我连续给你4张照片,你就能清楚地看到球的运动轨迹。
对AI来说也是一样:
我们不把单个静止的游戏画面给AI,而是把连续的4个画面像一叠扑克牌一样叠在一起,形成一个“有厚度”的、包含时间信息的输入。这样AI就能通过比较这4个画面的差异,自己“脑补”出小球的运动方向和速度。
+----------------+
| 游戏画面 t-3 | (最早的一帧)
+----------------+
│
↓
+----------------+
| 游戏画面 t-2 |
+----------------+
│
↓
+----------------+
| 游戏画面 t-1 |
+----------------+
│
↓
+----------------+
| 当前游戏画面 t | (最新的一帧)
+----------------+
│
└───────────┐
↓
+-----------------------------+
| 这4个画面合并成一个 |
| (4通道的)输入状态(State) |
+-----------------------------+
│
↓
(送入卷积神经网络CNN)
这张文字图清晰地展示了将四个时间上连续的、独立的帧,组合成一个单一的、多通道的输入,然后才喂给神经网络。
具体操作上,如果每帧图像是 84×8484 \times 8484×84 的灰度图,我们将连续的4帧在通道维度上进行堆叠,形成一个形状为 (4,84,84)(4, 84, 84)(4,84,84) 的张量(Tensor)。这个四通道的“超级图像”就成了我们新的状态表示。
通过观察这连续的4帧,神经网络现在可以轻易地推断出物体的动态信息:
- 方向:比较球在第1帧和第4帧的位置,可以清晰地看出它的移动方向。
- 速度:位置变化的距离揭示了球的移动速度。
- 加速度:如果速度在变化,网络甚至可能捕捉到加速度信息。
帧堆叠为智能体提供了一种廉价而高效的短期记忆,让它能够“看清”环境中的动态变化,从而做出更具前瞻性的决策。在处理绝大多数基于视频的游戏或模拟环境时,这都是一个至关重要的预处理步骤。幸运的是,Gymnasium 库及其封装器(Wrapper)让这个过程的实现变得异常简单。
8.3 代码改造:集成CNN到PPO模型中
现在,激动人心的部分来了:我们将亲手改造在第5章中构建的ActorCritic网络,让它能够处理带有多通道(来自帧堆叠)的图像输入。我们的目标是创建一个ActorCriticCNN模型。
你会惊喜地发现,PPO的核心算法部分(第5章的更新模块、GAE计算模块)一行代码都不需要修改。这完美地体现了算法与模型架构解耦的优势。我们只需替换掉那个负责接收状态并提取特征的“头”即可。
假设我们的输入状态是经过预处理和帧堆叠后的 4×84×844 \times 84 \times 844×84×84 的图像。下面是使用PyTorch实现ActorCriticCNN模型的代码。
回顾:第5章的MLP模型(简化版)
import torch.nn as nn
class ActorCriticMLP(nn.Module):
def __init__(self, obs_dim, action_dim):
super().__init__()
self.actor = nn.Sequential(
nn.Linear(obs_dim, 64),
nn.Tanh(),
nn.Linear(64, 64),
nn.Tanh(),
nn.Linear(64, action_dim)
)
self.critic = nn.Sequential(
nn.Linear(obs_dim, 64),
nn.Tanh(),
nn.Linear(64, 64),
nn.Tanh(),
nn.Linear(64, 1)
)
def forward(self, obs):
# ... (返回动作分布和价值)
改造:新的CNN模型
我们将把共享的MLP层替换为共享的CNN层。这个CNN结构的设计受到了DeepMind在DQN论文中使用的经典架构的启发。
import torch
import torch.nn as nn
from torch.distributions.categorical import Categorical
class ActorCriticCNN(nn.Module):
"""
一个用于处理图像输入的Actor-Critic模型,
共享CNN特征提取器,并拥有独立的Actor和Critic头。
"""
def __init__(self, num_inputs_ch, num_actions):
super(ActorCriticCNN, self).__init__()
# --- 共享的CNN特征提取器 ---
# 输入形状: (N, 4, 84, 84)
self.feature_extractor = nn.Sequential(
# 第一个卷积层
# 输入通道: 4 (来自帧堆叠), 输出通道: 32, 卷积核: 8x8, 步长: 4
nn.Conv2d(num_inputs_ch, 32, kernel_size=8, stride=4),
nn.ReLU(),
# 第二个卷积层
# 输入通道: 32, 输出通道: 64, 卷积核: 4x4, 步长: 2
nn.Conv2d(32, 64, kernel_size=4, stride=2),
nn.ReLU(),
# 第三个卷积层
# 输入通道: 64, 输出通道: 64, 卷积核: 3x3, 步长: 1
nn.Conv2d(64, 64, kernel_size=3, stride=1),
nn.ReLU(),
# 将多维特征图展平成一维向量
nn.Flatten(),
)
# 动态计算CNN输出的展平尺寸
# 我们创建一个虚拟输入,让它通过feature_extractor来获取输出尺寸
with torch.no_grad():
dummy_input = torch.zeros(1, num_inputs_ch, 84, 84)
feature_out_dim = self.feature_extractor(dummy_input).shape[1]
# --- Actor头 ---
# 输入: 展平后的特征向量, 输出: 每个动作的logits
self.actor_head = nn.Sequential(
nn.Linear(feature_out_dim, 512),
nn.ReLU(),
nn.Linear(512, num_actions)
)
# --- Critic头 ---
# 输入: 展平后的特征向量, 输出: 单个状态价值
self.critic_head = nn.Sequential(
nn.Linear(feature_out_dim, 512),
nn.ReLU(),
nn.Linear(512, 1)
)
def get_value(self, obs):
""" 给定一个观测,返回由Critic估计的状态价值 """
# 首先对输入进行归一化,将像素值从[0, 255]缩放到[0, 1]
features = self.feature_extractor(obs / 255.0)
return self.critic_head(features)
def get_action_and_value(self, obs, action=None):
"""
给定一个观测,返回动作、其对数概率以及状态价值
同时处理采样新动作和计算旧动作对数概率两种情况
"""
features = self.feature_extractor(obs / 255.0)
# Actor计算动作的logits
logits = self.actor_head(features)
# Critic计算状态价值
value = self.critic_head(features)
# 从logits创建动作的概率分布
probs = Categorical(logits=logits)
# 如果没有提供动作,就从分布中采样一个新动作
if action is None:
action = probs.sample()
# 返回采样/提供的动作、其对数概率和状态价值
return action, probs.log_prob(action), probs.entropy(), value
代码解析:
-
__init__:- 我们定义了一个
feature_extractor,它包含三个Conv2d层。注意每一层的输入/输出通道数、卷积核大小和步长,这是一个非常经典且有效的配置。 - 关键技巧:我们不再手动计算CNN输出展平后的维度(64×7×7=313664 \times 7 \times 7 = 313664×7×7=3136)。而是通过创建一个
dummy_input并实际执行一次前向传播来动态获取这个维度。这让我们的代码更具通用性,当调整CNN结构时无需手动修改后续层的输入维度。 actor_head和critic_head现在接收的是CNN提取出的特征向量,而不是原始状态。
- 我们定义了一个
-
get_value和get_action_and_value:- 这两个方法的核心逻辑和MLP版本完全一样!唯一的区别是,在将
obs送入网络之前,我们增加了一步obs / 255.0。 - 这是一个非常重要的标准化步骤。将输入的像素值从
[0, 255]的整数范围归一化到[0, 1]的浮点数范围,可以使神经网络的训练过程更加稳定和高效。 - 后续的流程——提取特征、分别送入Actor和Critic头、计算概率分布、返回所需的值——都保持不变。
- 这两个方法的核心逻辑和MLP版本完全一样!唯一的区别是,在将
通过这个改造,我们的PPO智能体已经成功装上了“眼睛”,准备好迎接来自像素世界的挑战了。
8.4 实战项目三:训练一个能玩Atari游戏(如Pong)的AI
理论和代码都已就位,现在让我们将它们付诸实践,完成一个令人兴奋的项目:从零开始训练一个能够玩转经典游戏《Pong》的AI。
第一步:环境搭建与封装(Wrapping)
Gymnasium库提供了大量Atari游戏环境,但直接使用原始环境进行训练效率很低。我们需要使用一系列的封装器(Wrappers) 来对环境进行预处理,使其更适合DRL算法。
import gymnasium as gym
# ... (在你的主训练脚本中)
def make_env(env_id, seed, idx, capture_video, run_name):
""" 创建并封装环境的工厂函数 """
def thunk():
# 创建Atari环境,并传入render_mode以支持视频录制
env = gym.make(env_id, render_mode="rgb_array")
# 使用gymnasium自带的封装器进行标准Atari预处理
env = gym.wrappers.RecordEpisodeStatistics(env)
if capture_video and idx == 0:
env = gym.wrappers.RecordVideo(env, f"videos/{run_name}")
# 这是核心预处理步骤
env = gym.wrappers.AtariPreprocessing(env,
noop_max=30, # 在游戏开始时执行随机数量的“无操作”动作
frame_skip=4, # 每隔4帧执行一次动作,并重复该动作
screen_size=84, # 将屏幕大小调整为84x84
terminal_on_life_loss=True, # 在丢失一条命时认为回合结束
grayscale_obs=True, # 将RGB图像转换为灰度图
grayscale_newaxis=True, # 增加一个通道维度
scale_obs=False # 不自动缩放到[0,1],我们在网络中处理
)
# 帧堆叠封装器
env = gym.wrappers.FrameStack(env, 4) # 堆叠4帧
env.action_space.seed(seed)
env.observation_space.seed(seed)
return env
return thunk
# 使用示例:
# envs = gym.vector.SyncVectorEnv([make_env("ALE/Pong-v5", 0, 0, True, "ppo-pong-test")])
# assert isinstance(envs.single_action_space, gym.spaces.Discrete), "只支持离散动作空间"
代码解析(封装器):
RecordEpisodeStatistics: 自动记录每个回合的奖励、长度等信息,方便我们监控训练进程。RecordVideo: 在训练或测试时录制智能体的表现。AtariPreprocessing: 这是一个功能强大的封装器,它完成了大部分脏活累活:- 灰度化和缩放:将210×160210 \times 160210×160的彩色图像变成84×8484 \times 8484×84的灰度图,并增加了通道维度,使其形状变为 (1,84,84)(1, 84, 84)(1,84,84)。
- 生命损失作为回合结束:在很多游戏中,一个回合(episode)包含多条生命(life)。将丢失一条命视为回合结束,可以提供更密集的学习信号。
- 帧跳过(Frame Skip): 让智能体每隔几帧(例如4帧)才决策一次,并在中间的帧重复执行上一个动作。这大大加快了游戏速度和数据采集效率。
FrameStack: 将AtariPreprocessing输出的 (1,84,84)(1, 84, 84)(1,84,84) 图像进行堆叠,最终生成我们模型所需的 (4,84,84)(4, 84, 84)(4,84,84) 状态。
第二步:模型实例化与训练
现在,我们只需要在主训练脚本中,将原来的ActorCriticMLP换成我们新定义的ActorCriticCNN即可。
# ... (在你的主训练脚本中)
# 获取环境的观测空间和动作空间信息
obs_shape = envs.single_observation_space.shape # (4, 84, 84)
action_dim = envs.single_action_space.n
# 实例化CNN版本的Actor-Critic模型
# 注意,我们将通道数 obs_shape[0] (即4) 传入
agent = ActorCriticCNN(num_inputs_ch=obs_shape[0], num_actions=action_dim).to(device)
optimizer = optim.Adam(agent.parameters(), lr=learning_rate, eps=1e-5)
# ... 后续的训练循环 (Training Loop) ...
# ... (数据收集、GAE计算、损失计算、梯度更新) ...
# ... 这部分代码与第5章完全相同!...
关键点:
你看到了吗?PPO算法的训练循环部分 完全没有改变。我们只是更换了agent这个模块。PPO的更新规则并不关心状态obs是一个向量还是一张图,它只关心从模型中得到的 log_prob、entropy 和 value。这就是模块化设计的力量。
第三步:超参数和预期结果
训练Atari游戏通常比简单环境需要更多的样本和时间。以下是一些适用于《Pong》的、经过验证的基准超参数:
learning_rate: 0.00025 (或 2.5e-4),通常需要一个较小的学习率。num_steps: 128,每个环境每次收集的步数。batch_size: 32 (num_envs) * 128 (num_steps) / 4 (num_minibatches) = 1024。num_epochs: 4,每次收集数据后,对数据进行优化的轮数。gamma: 0.99gae_lambda: 0.95clip_coef: 0.1ent_coef: 0.01,熵奖励系数,鼓励探索。vf_coef: 0.5,价值损失的系数。total_timesteps: 10,000,000,需要千万级别的总步数才能稳定收敛。
在训练过程中,你应该监控charts/episodic_return。对于《Pong》,这个值会从-21(一直输)逐渐上升,最终稳定在+18到+21之间(几乎全胜)。你会看到智能体从一开始的随机挥拍,到逐渐学会接球,再到学会利用角度打出让对手无法接到的球。当你看到录制的视频中,你的AI以刁钻的角度战胜对手时,恭喜你,你已经真正掌握了如何使用PPO解决基于视觉的复杂问题!
本章小结
在本章中,我们成功地为PPO智能体安装了“眼睛”,使其具备了从原始像素中学习的能力。我们理解了:
- 为何需要CNN:相比于全连接网络,CNN通过卷积和池化操作,能以更高效、更合理的方式提取图像的空间特征。
- 帧堆叠的重要性:通过将连续的帧堆叠起来,我们为智能体提供了感知运动方向和速度等动态信息的“短期记忆”。
- 如何改造代码:我们学习了如何构建一个CNN-based的Actor-Critic模型,并见证了PPO核心算法的强大通用性——训练循环无需任何改动。
- 如何应用于实践:我们通过一个完整的
Pong项目,学习了如何使用Gymnasium的封装器对Atari环境进行标准预处理,并了解了训练这类环境的关键超参数。
你现在掌握的技能,已经足以让你去挑战绝大多数基于视觉的强化学习任务。在下一章,我们将迎接终极挑战:不再使用现成的环境,而是学习如何将一个全新的、自定义的问题,从零开始建模成一个强化学习问题。这将是你从“算法使用者”蜕变为“问题解决者”的关键一步。
1190

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



