从零构建强化学习 Agent 的完整实录 — 17 个版本迭代,从启发式规则到 PPO self-play
for update in range(300):
buffer.clear()
for _ in range(64): # 每轮 64 局 rollout
env = make("orbit_wars")
obs = env.reset(num_agents=2)
while not done:
# 1. 特征编码: 原始观测 → 神经网络输入
encoded = encode_state(obs)
self_t = to_tensor(encoded["self"]) # (N_planets, 11)
cand_t = to_tensor(encoded["candidates"]) # (N_planets, 8, 16)
global_t= to_tensor(encoded["global"]) # (N_planets, 8)
mask_t = to_tensor(encoded["mask"]) # (N_planets, 8)
# 2. 策略前向: 输出每个候选目标的 logits + 状态价值
logits, value = policy(self_t, cand_t, global_t, mask_t)
# 3. 采样动作: Categorical 分布采样(训练时用采样,推理用 argmax)
dist = Categorical(logits=logits)
action = dist.sample()
log_prob = dist.log_prob(action)
# 4. 存储到 rollout buffer(每步每星球一条记录)
buffer.store(self_t, cand_t, global_t, mask_t, action, log_prob, value)
# 5. 解码动作为 Kaggle API 格式 + 执行
moves = decode_action(encoded, action)
obs = env.step(moves)
# 6. 奖励塑形
reward = production_delta * 0.05 + captures * 3.0 + launches * 0.3
# 7. 计算 GAE 优势函数
advantages, returns = compute_gae(buffer.rewards, buffer.values, gamma=1.0)
# 8. PPO 策略更新(4 epoch,mini-batch)
for epoch in range(4):
for batch in minibatches:
new_logits, new_values = policy(batch)
ratio = exp(new_log_prob - old_log_prob)
policy_loss = -min(ratio*adv, clip(ratio,0.8,1.2)*adv)
value_loss = mse(new_values, returns)
loss = policy_loss + 0.5*value_loss - 0.02*entropy
optimizer.zero_grad(); loss.backward(); optimizer.step()
# 9. 每 25 轮更新 frozen opponent
if update % 25 == 0:
opponent.load_state_dict(policy.state_dict())
64 局/update,每局 500 步。300 updates × 64 = 19,200 局。双方共用同一 OrbitPolicy 架构,训练方用 Categorical 采样探索,对手用 frozen copy argmax 贪心。
对手是 policy 的冻结副本,每隔 25 updates 同步。双方用相同的 OrbitPolicy 网络架构,但行为不同:训练方用概率采样(探索),对手用 argmax(贪心、无探索)。类似 AlphaGo 的"和过去的自己对弈"——训练方进步后,25 轮后对手也追上,迫使训练方继续提升。这是 self-play 持续进化的核心。
唯一的神经网络,训练和推理复用:
• 训练 rollout — 两个实例并行(采样 vs argmax)
• Kaggle 推理 — 单实例 argmax 决策,输出 JSON moves
• 评估对战 — 双方都用 argmax 公平对比
PPO 由 Schulman et al. (2017) 提出,是当前最广泛使用的策略梯度方法。它属于 actor-critic 架构:actor(策略网络)输出动作概率,critic(价值网络)估计状态好坏,两者共享底层编码器。
核心思路:普通的 policy gradient 用 `log_prob × advantage` 做梯度上升,但步长稍大就会让策略"跳过头"、性能崩溃。PPO 用 clipped surrogate objective 限制新旧策略的差异:
其中:\( r_t(\theta) = \frac{\pi_\theta(a_t|s_t)}{\pi_{\text{old}}(a_t|s_t)} \)(新旧策略概率比) · \( \hat{A}_t \) = GAE 优势估计 · \( \varepsilon = 0.2 \)(clip 范围)
\( \hat{A}_t > 0 \)(好动作):ratio clip 到 ≤ 1.2,防止过度增加概率
\( \hat{A}_t < 0 \)(坏动作):ratio clip 到 ≥ 0.8,防止过度降低概率
→ 效果:每次 update 最多偏离旧策略 20%,保证训练稳定。
对比 TRPO:TRPO (Schulman 2015) 用 KL 散度二阶约束实现同样的"信任域"效果,但实现复杂、计算量大。PPO 用简单的 clip 操作替代——实践中性能相当,代码量却少一个数量级。这也是为什么 PPO 成为 RL 入门首选算法。
Policy gradient 需要知道每个动作的"好坏"——这就是优势 A(s,a) = Q(s,a) − V(s)。直接用蒙特卡洛回报(完整一局的累计奖励)无偏但方差巨大;用单步 TD 误差方差小但偏差大(短视)。GAE 用 λ 在这两者之间做指数加权平均。
直观解释:实际奖励 r_t 加上下一状态的期望价值 γ·V(s_{t+1}),与当前价值估计 V(s_t) 的差距。δ_t > 0 意味着这一步比预期好。
计算上反向递推(从最后一步往前算,\( O(n) \) 高效):
\[ \begin{aligned} A_T &= 0 \\ A_t &= \delta_t + \gamma\lambda\,A_{t+1} \end{aligned} \]
• λ = 0 → A_t = δ_t(单步 TD,低方差高偏差)
• λ = 1 → A_t = 蒙特卡洛(无偏但高方差)
• λ = 0.95 → 推荐值,有效平衡 bias-variance tradeoff
γ = 1.0(无折扣)—— 游戏固定 500 步终点,每步对胜负同等重要,不需要折扣近期奖励。
λ = 0.95 —— 略微偏向低方差,适合 rollout 噪声较大的博弈环境。
优势归一化:计算完所有 A_t 后在 batch 内做 z-score 标准化,稳定梯度。
class OrbitPolicy(nn.Module):
def __init__(self):
# Three encoders — each is 256 → ReLU → 256
self.self_enc = MLP(11 → 256 → 256) # 每个己方星球
self.global_enc = MLP(8 → 256 → 256) # 全局态势
self.cand_enc = MLP(16 → 256 → 256) # 每个候选目标
# Fusion head: [self + global + candidate] → value / logits
self.target_head = Linear(256×3 → 256 → 1) # 候选评分
self.value_head = Linear(256×3 → 256 → 1) # 状态价值
def forward(self, self_feat, cand_feat, global_feat, mask):
sh = self_enc(self_feat) # (B, 256) — "我的星球怎么样"
ch = cand_enc(cand_feat) # (B, 8, 256) — "每个候选目标怎么样"
gh = global_enc(global_feat) # (B, 256) — "全局局势怎么样"
# 广播拼接: 让每个候选看到自我+全局+自身特征
combined = cat([sh.expand(), gh.expand(), ch], dim=-1) # (B, 8, 768)
logits = target_head(combined).squeeze(-1) # (B, 8)
logits = masked_fill(logits, ~mask, -1e9) # 屏蔽无效候选
value = value_head(cat([sh, gh, ch.mean(1)], dim=-1)) # (B,)
return logits, value
1 + x/100 + y/100 + radius/5 + ships/max + production/max + is_rotating + 己方/敌方行星数 + 己方/敌方总船数。
step/500 + 己方行星比例 + 敌方行星比例 + 中立行星比例 + 己方/敌方行星船数 + 己方/敌方舰队数。
身份位(中立/己方/敌方) + 坐标/距离 + 船数 + 产量 + 是否旋转 + 是否穿日 + 源船数 + 敌舰队威胁 + 进攻成本。
每个己方星球选 8 个候选目标之一(5-8 号备选补位)。Action 0 = "不发射"。最终解码为己方星球 ID + 角度 + 舰船数。
enemy_fleet_threat(周围敌舰队威胁度)和 attack_cost(进攻成本 = 驻军 + 产量×飞行步数)。30% rollout 用 4p。奖励调优:生产降0.05、占领升3.0、新增发兵奖励0.3。predict_position() 计算到达时位置再瞄准。pred_angle = cur_angle + angular_velocity × travel_steps。这一个改动消除了最大的系统性错误。从 V16 权重热启动,200 updates。V16/V17 舰队"飞出星系"不是策略问题,是物理问题——瞄准当前位置,飞行期间行星转走。V18 加了 predict_position() 后 1v1 100% 胜率。证明:在旋转环境中,预测未来位置比任何复杂策略都重要。正确建模环境动力学有时比更好的算法有效。
V13 BC 拷贝冠军 → V14 Self-Play 碾压 (87.5%)。V15 BC 蒸馏 4.6M 样本 → 依然无法超越 V14。BC 只是更平滑的"记忆"——Self-Play + PPO sampling 才是"理解"。
V17 用 FLEET_FRACTION=0.75 硬限制发兵 → 倒退 50%。V19 去掉约束,改用"降低生产奖励 + 增加发兵奖励 + 增加占领奖励"让 RL 自己学会平衡防守和进攻。核心原则:Tell the agent WHAT to optimize, not HOW to act.
V16 仅 2p 训练 → 1v1 68.8% 但 4p 只有 2/8。V19 加入 30% 4p 混训。在博弈论环境中,训练对手的多样性直接决定策略的泛化能力。
V19 cand_dim 16:不是"越多越好",而是"什么信息是神经网络无法从已有特征推断的"。敌舰队威胁(谁在攻击我?)和进攻成本(打这个目标值不值?)是从现有坐标/船数特征中不可直接导出但决策必需的信息。
Framework: PyTorch + Kaggle Environments | Training: ~19,200 局 × 500 步 = 960 万步 | Network: MLP 29M params | Reward: production + capture + launch + terminal