3. 动作(Action)空间与动作类型归纳总结
动作空间,说白了就是智能体可以做的所有事的集合。你告诉它“向前走”、“向左转”,它得能听懂。我一直觉得,搞清楚动作空间,比调参数更重要。参数调不好顶多慢点,动作空间定义错了,那模型根本学不会正确的行为。
3.1 什么是动作空间?
在马尔可夫决策过程里,动作(Action)是连接状态与状态之间的桥梁。你站在状态 \( s \) 上,执行某个动作 \( a \),系统进入下一状态 \( s' \)。所有可能的动作组成了一个集合,这个集合就是动作空间。
我习惯把动作空间归纳为两大阵营:离散和连续。这两种空间的建模方式、算法选择、工程实现完全不同。
| 特性 | 离散动作空间 | 连续动作空间 |
|---|---|---|
| 输出形式 | 从有限个候选里选择一个 | 输出一个或多个连续的数值 |
| 适用场景 | 走迷宫、选技能、交易决策 | 机器人控制、自动驾驶、核反应堆 |
| 常见算法 | DQN、Double DQN、Rainbow | DDPG、SAC、TD3、PPO |
| 维度爆炸风险 | 组合数爆炸,需降维 | 数值精度与探索困境 |
3.2 离散动作空间详解
最常见的情形。我记得第一次上手写环境时,就是这种空间——上下左右四个动作,简单直接。
它的定义方式在代码里极度简单:
import gym
env = gym.make("CartPole-v1")
print("动作空间:", env.action_space)
# 输出:Discrete(2)
print("合法动作数:", env.action_space.n)
# 输出:2
看到没?Discrete(2) 表示只有两个动作。这种情况下,智能体每次只需要从 0 或 1 里选一个,输出一层 softmax 或 argmax 就搞定了。
这里有个坑我踩过,得提醒你一下。
动作索引溢出
我曾经在一个自定义环境里,手滑把动作数写成了 Discrete(3),但实际逻辑只定义了动作 0 和 1。结果智能体在某个时刻输出动作 2,环境直接报错 IndexError。训了一整晚的模型直接作废。相信我,每次改完环境,务必 assert action in range(env.action_space.n) 自检。
离散空间最大的问题是什么?维数灾难。假如你做一个策略游戏,智能体能同时决定“攻击” + “使用道具” + “移动方向”,这三个维度各自离散:
actions = {
"attack": ["无", "普攻", "技能"],
"item": ["无", "血瓶", "法力药"],
"direction": ["上", "下", "左", "右"]
}
# 组合动作数 = 3 * 3 * 4 = 36
36 个动作看起来还好,但维度一多,比如 5 个决策维度各 10 个选择,那就是 10 万量级。输出层直接爆炸。
我的做法是:能拆就拆,能用分层结构就用分层结构,别一股脑全压在一个网络里。
3.3 连续动作空间
连续空间更麻烦,也更常见。机器人控制、自动驾驶、PID 参数调优——全是连续的。智能体输出的是一个向量,每个分量不是整数,而是 [-1.0, 1.0] 这样的浮点数。
env = gym.make("Pendulum-v1")
print("动作空间:", env.action_space)
# 输出:Box(-2.0, 2.0, (1,), float32)
Box 就是连续空间的标配。它的含义很直白:动作是 1 维向量,每个分量在 [-2.0, 2.0] 之间取值。智能体输出的参数必须经过缩放和裁剪才能进去。
关于动作缩放的忠告
我在项目里经常看到队友直接拿网络输出往环境里怼。网络输出可能是 [-100, 100],环境期望的是 [-2, 2]。你想想看,不缩放的后果就是智能体每次都尝试超大动作,环境要么崩溃,要么瞬间失败。我个人习惯在策略网络最后一层接一个 tanh,把输出压缩到 [-1, 1],然后乘以边界值。代码大概是这样:
import torch.nn as nn
import torch.nn.functional as F
class ContinuousPolicy(nn.Module):
def __init__(self, state_dim, action_dim, action_bound=2.0):
super().__init__()
self.fc1 = nn.Linear(state_dim, 256)
self.fc2 = nn.Linear(256, action_dim)
self.action_bound = action_bound
def forward(self, state):
x = F.relu(self.fc1(state))
# 压缩 + 缩放
raw = torch.tanh(self.fc2(x))
return raw * self.action_bound
连续空间里的另一个难题是探索。离散空间可以用 ε-greedy 随机选一个动作,连续空间加随机噪声?加多少?你仔细琢磨一下,噪声太大可能一直在乱跳,噪声太小可能跑不出局部最优。我常用的方案是 Ornstein-Uhlenbeck 噪声,它自带时间相关性,比纯高斯噪声平滑。
小技巧:在 SAC 算法里,策略本身就是随机分布,所以你不需要额外加探索噪声。但用 DDPG 时,OU 噪声绝对是首选。
3.4 动作类型归纳
不管离散还是连续,实际项目里的动作类型可以进一步归纳。我整理了 5 种常见模式:
- 无动作:占位符,允许智能体选择不做任何事。常见于等待场景,比如机器人等待指令。
- 单一确定动作:每次从 N 个候选里必须选一个。如 CartPole 的左推 / 右推。
- 多选一组合动作:多个离散维度,但语法上形成一个元组。如前面提到的“攻击 + 道具 + 方向”。
这种组合必须用
MultiDiscrete或展开后再合并,别用单个Discrete硬编。 - 连续控制动作:输出连续值,可能一维也可能多维。比如机器人关节力矩控制 (Box(..., shape=(n_joints,)))。
- 参数化动作:先选“做什么”,再决定“怎么做”。例如选择“移动”,再选移动的方向和距离。这种结构需要 Hybrid 空间,Gym 里可以用
Dict实现。
为什么要把动作类型分这么清楚?因为不同的类型对应着完全不同的网络输出结构和损失函数。离散动作用交叉熵,连续动作用 MSE 或高斯对数概率。你一旦搞错了,整个训练过程都会跑偏。我在做工业项目时,光动作类型的调研就花了两天,但后续代码几乎没有返工过。
3.5 整体知识脉络
下面这张图把动作空间的分类和类型画得很清楚,看完你就知道从哪下手了。
3.6 动作空间选择的工程经验
你可能会问:我到底该用离散还是连续?我的建议是——
- 如果任务能天然拆成有限的“按键”,比如游戏手柄的 A/B/X/Y,那就用离散。简单、稳、快。
- 如果动作天然是连续值,比如机器手关节角度,别硬往离散里套。你非要用离散去模拟连续的精度,动作数会大到离谱,训练效率极低。
另外,我遇到过一种情况:一个强化学习项目里,智能体需要控制机械臂抓取物体。动作空间是 6 维连续空间(位置 + 旋转)。我一开始直接上 DDPG,训练了两个星期效果还是不好。后来我把动作拆成两个层级:第一层是离散的“靠近/抓取/放下”,第二层才是连续的轨迹规划。效果立竿见影。
这说明一个问题:动作空间的工程化设计,有时比算法本身更重要。
好,本节的内容到这里。动作空间理解了,下一步才能谈转移概率和奖励函数怎么设计。嗯,慢慢来,后面你会越来越顺的。