5.1 动手:摇骰子赌博机
想象你走进一家赌场,面前只有一台老旧的赌博机——两个摇臂,一个红色一个蓝色。红色摇臂摇中概率 30%,蓝色摇臂摇中概率 70%。每次只能选一个摇,摇中就赢 1 块钱,没摇中什么都不给。
一个理性的人会怎么玩?当然是永远摇蓝色——70% 的赢率碾压 30%。但如果是一个 AI 呢?它不知道哪个摇臂更好,它需要通过试错自己发现这个事实。
这就是我们本章的实验场——一个极简的赌博机。只有两个动作,没有"状态"(这是一个无状态环境),规则一句话就能说清楚。但就是这么简单的一个场景,能让策略网络从"什么都不知道"进化到"学会选最优动作"。更重要的是,它能让你亲眼看到策略梯度的核心——也是唯一的——学习信号:"好结果强化对应动作的概率"。
这和第 3 章的猜硬币游戏有本质区别。猜硬币中我们手写了策略("永远猜正"),而这里我们要让 AI 自己学出策略。
环境:两臂赌博机
┌──────────────────────────────────┐
│ │
│ ┌───┐ ┌───┐ │
│ │ A │ │ B │ │
│ │🔴│ │🔵│ │
│ └─┬─┘ └─┬─┘ │
│ │ 赢率 30% │ 赢率 70% │
│ │ │ │
│ └───────────────┘ │
│ │
│ 规则:选一个摇臂,摇中 +1 分 │
│ 目标:让 AI 自己发现 B 更好 │
└──────────────────────────────────┘用 PyTorch 实现策略网络
我们的策略网络极其简单——只有一个 Softmax 层。输入是一个常数(因为无状态),输出是两个动作的概率:
import torch
import torch.nn as nn
import torch.optim as optim
import random
import numpy as np
# ==========================================
# 1. 策略网络:只有一个 Softmax 层
# ==========================================
class PolicyNetwork(nn.Module):
def __init__(self):
super().__init__()
self.linear = nn.Linear(1, 2) # 1个输入(常数),2个输出(A和B的概率)
def forward(self, x):
logits = self.linear(x)
return torch.softmax(logits, dim=-1) # Softmax 保证输出是概率分布
policy = PolicyNetwork()
optimizer = optim.Adam(policy.parameters(), lr=0.01)
# ==========================================
# 2. 环境:两臂赌博机
# ==========================================
win_probs = [0.3, 0.7] # A: 30%, B: 70%
def pull_arm(action):
return 1.0 if random.random() < win_probs[action] else 0.0
# ==========================================
# 3. REINFORCE 训练(先看效果,原理下一节详解)
# ==========================================
prob_history = []
num_episodes = 300
for ep in range(num_episodes):
state = torch.tensor([1.0])
# 策略网络输出动作概率,按概率采样
probs = policy(state)
dist = torch.distributions.Categorical(probs)
action = dist.sample() # 按概率随机选一个动作
log_prob = dist.log_prob(action) # log π(a|s)
# 执行动作,获取奖励
reward = pull_arm(action.item())
# REINFORCE 核心:好结果 → 增加概率
loss = -log_prob * reward
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 记录策略概率
with torch.no_grad():
prob_history.append(policy(state)[1].item()) # 选择 B 的概率
print(f"初始 P(B): {prob_history[0]:.3f}")
print(f"最终 P(B): {prob_history[-1]:.3f}")这段代码的核心就是 loss = -log_prob * reward 这一行。直觉上:如果这次选的动作带来了好结果(reward=1),-log_prob * 1 会产生一个梯度,让这个动作被选中的概率增加。如果结果不好(reward=0),梯度为零,概率不变。负号是因为 PyTorch 默认做梯度下降(最小化损失),而我们要做梯度上升(最大化奖励)。
你会看到什么?
运行代码后,策略概率的演化过程大致如下:
选择 B 的概率演化
1.0 ┤
│ ╱━━━━━━━━━━━━━━━━━━ ← 收敛:稳定在 0.85-0.95
0.9 ┤ ╱━╱
│ ╱╱╱╱╱
0.8 ┤ ╱╱╱╱
│ ╱╱╱╱ ← 爬升:发现 B 更好
0.7 ┤ ╱╱╱╱
│╱╱╱╱
0.6 ┤╲╱
│ ╲ ╱╲ ╱
0.5 ┤─╲╱╲╱╲╱╲──────────── ← 开局:在 0.5 附近波动(两个都试试)
└────────────────────────────────────────
0 50 100 150 200 250 300
Episode三个阶段很清晰:开局策略接近均匀分布("两个都试试",探索为主);然后选择 B 的概率逐渐上升("发现 B 拿到奖励的次数更多");最后稳定在高概率区间("就选 B 了")。
但你也注意到了:曲线不是平滑上升的,而是有明显的锯齿和波动。这就是策略梯度的核心痛点——高方差。
为什么会震荡?——梯度噪声
策略梯度的更新依赖于采样。每次更新时,网络只能看到这一次采样到的结果:
- 如果某次恰好摇 B 也输了(30% 概率),网络收到 "reward = 0" 的信号,降低选 B 的概率——但 B 其实是好选择
- 如果某次恰好摇 A 也赢了(30% 概率),网络收到 "reward = 1" 的信号,增加选 A 的概率——但 A 其实是差选择
这些"运气不好"的采样会导致梯度估计出错,让策略在正确方向上来回摇摆。就像你蒙着眼睛走路——方向大体是对的,但每一步都歪歪扭扭。
把学习率从 0.01 改成 0.1,你会看到策略在 A 和 B 之间剧烈摇摆——某次采样到 A 赢了就大幅偏向 A,下次采样到 B 赢了又大幅偏向 B,永远无法稳定收敛。这就像开车时方向盘打得太猛——每次修正都过了头,反而在目标两侧来回摆动。
探索与利用的权衡
训练曲线的震荡还揭示了 RL 最核心的张力——探索与利用:
- 训练初期:策略接近均匀分布(探索为主)——"两个都试试看"
- 训练后期:策略收敛到确定动作(利用为主)——"就选 B 了"
- 过渡必须平稳:太快收敛可能只找到局部最优,太慢则浪费数据
这和人类学习的过程很像。刚开始学做饭,你会尝试各种菜谱(探索);当你发现某道菜特别好吃,你就会反复做(利用)。但如果过早锁定一道菜,你就可能错过更好吃的。
第 6 章 PPO 中的 entropy bonus(熵奖励)就是强制策略保持探索的机制——它在损失函数里加了一个惩罚项,防止策略过早变得"太确定"。
思考题:如果 B 的赢率只有 55%(而不是 70%),策略还能学会吗?
能学会,但学得更慢、震荡更大。因为 A 和 B 的差距更小(55% vs 30%),采样到"误导性数据"的概率更高。这再次说明策略梯度的核心挑战——当"好"和"坏"的差距不大时,高方差会让学习变得极其困难。这也是为什么后续我们需要引入基线(Baseline)来降低方差。
现在你已经亲眼看到策略梯度能让网络学会选择最优动作。但为什么 -log_prob * reward 这个公式能做到这件事?背后的数学原理是什么?让我们在下一节——策略梯度定理与 REINFORCE——中逐块拆解。