← Back
17
Versions
RL
Domain
Self-Play
Method
PPO
Algorithm

01 · RL 训练管线

完整训练循环(一个 PPO update)

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())

Rollout 收集

64 局/update,每局 500 步。300 updates × 64 = 19,200 局。双方共用同一 OrbitPolicy 架构,训练方用 Categorical 采样探索,对手用 frozen copy argmax 贪心。

Frozen Opponent(Self-Play 机制)

对手是 policy 的冻结副本,每隔 25 updates 同步。双方用相同的 OrbitPolicy 网络架构,但行为不同:训练方用概率采样(探索),对手用 argmax(贪心、无探索)。类似 AlphaGo 的"和过去的自己对弈"——训练方进步后,25 轮后对手也追上,迫使训练方继续提升。这是 self-play 持续进化的核心。

OrbitPolicy 使用位置

唯一的神经网络,训练和推理复用:
训练 rollout — 两个实例并行(采样 vs argmax)
Kaggle 推理 — 单实例 argmax 决策,输出 JSON moves
评估对战 — 双方都用 argmax 公平对比

PPO 算法原理

Proximal Policy Optimization 是什么?

PPO 由 Schulman et al. (2017) 提出,是当前最广泛使用的策略梯度方法。它属于 actor-critic 架构:actor(策略网络)输出动作概率,critic(价值网络)估计状态好坏,两者共享底层编码器。

核心思路:普通的 policy gradient 用 `log_prob × advantage` 做梯度上升,但步长稍大就会让策略"跳过头"、性能崩溃。PPO 用 clipped surrogate objective 限制新旧策略的差异:

\[ L^{\text{CLIP}}(\theta) = \mathbb{E}_t\left[ \min\left( r_t(\theta)\,\hat{A}_t,\; \operatorname{clip}\!\big(r_t(\theta),\,1-\varepsilon,\,1+\varepsilon\big)\,\hat{A}_t \right) \right] \]

其中:\( 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 入门首选算法。

GAE 优势函数推导

为什么需要优势函数?

Policy gradient 需要知道每个动作的"好坏"——这就是优势 A(s,a) = Q(s,a) − V(s)。直接用蒙特卡洛回报(完整一局的累计奖励)无偏但方差巨大;用单步 TD 误差方差小但偏差大(短视)。GAE 用 λ 在这两者之间做指数加权平均。

第一步:TD 误差

\[ \delta_t = r_t + \gamma\,V(s_{t+1}) - V(s_t) \]

直观解释:实际奖励 r_t 加上下一状态的期望价值 γ·V(s_{t+1}),与当前价值估计 V(s_t) 的差距。δ_t > 0 意味着这一步比预期好。

第二步:n-step 优势估计

\[ \begin{aligned} \text{1-step:}\quad A_t^{(1)} &= \delta_t & &\text{(低方差, 有偏)} \\ \text{2-step:}\quad A_t^{(2)} &= \delta_t + \gamma\,\delta_{t+1} \[4pt] \infty\text{-step:}\quad A_t^{(\infty)} &= -V(s_t) + r_t + \gamma\,r_{t+1} + \gamma^2\,r_{t+2} + \cdots \\ &= \text{MC\_return} - V(s_t) & &\text{(无偏, 高方差)} \end{aligned} \]

第三步:λ 指数加权 → GAE

\[ A_t^{\text{GAE}} = (1-\lambda)\Big( A_t^{(1)} + \lambda A_t^{(2)} + \lambda^2 A_t^{(3)} + \cdots \Big) = \sum_{l=0}^{\infty} (\gamma\lambda)^l\,\delta_{t+l} \]

计算上反向递推(从最后一步往前算,\( 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 标准化,稳定梯度。

02 · 网络架构

OrbitPolicy: 多头编码 + 候选评分 + 价值估计

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

Self Features (11-dim)

1 + x/100 + y/100 + radius/5 + ships/max + production/max + is_rotating + 己方/敌方行星数 + 己方/敌方总船数。

Global Features (8-dim)

step/500 + 己方行星比例 + 敌方行星比例 + 中立行星比例 + 己方/敌方行星船数 + 己方/敌方舰队数。

Candidate Features (16-dim)

身份位(中立/己方/敌方) + 坐标/距离 + 船数 + 产量 + 是否旋转 + 是否穿日 + 源船数 + 敌舰队威胁 + 进攻成本

Action Space

每个己方星球选 8 个候选目标之一(5-8 号备选补位)。Action 0 = "不发射"。最终解码为己方星球 ID + 角度 + 舰船数。

03 · 版本迭代(从最新到最早)

V19 🔄 训练中 — 16-dim + 敌舰队感知 + 进攻成本 + 30% 4p
做了什么:cand_dim 14→16,增加 enemy_fleet_threat(周围敌舰队威胁度)和 attack_cost(进攻成本 = 驻军 + 产量×飞行步数)。30% rollout 用 4p。奖励调优:生产降0.05、占领升3.0、新增发兵奖励0.3。
核心思路:让神经网络"看到"更多战场信息(成本、威胁),并用 reward 引导激进攻击而非规则约束。从 V18 权重热启动。
V18 🔥 1v1 16W-0L (100%) vs V14 · 4p 7/8 优于 V14
唯一改动:旋转预测瞄准。之前舰队瞄准的是行星当前位置,飞行 20-40 步后行星已转走 → 飞出星系烧毁。V18 用 predict_position() 计算到达时位置再瞄准。
关键代码:pred_angle = cur_angle + angular_velocity × travel_steps。这一个改动消除了最大的系统性错误。从 V16 权重热启动,200 updates。
V17 ❌ 1v1 8W-8L (50%) vs V14 — 倒退
加了三个改动,全翻车:① FLEET_FRACTION=0.75(最多发 75% 船)→ 自断一臂;② cand_dim 14→15(+travel_steps 特征)→ 新维度未校准;③ SUN_CONE ±0.3rad → 过度保守。
教训:用规则约束代替 reward 引导是反模式。Limp-wristed constraints (FLEET_FRACTION) directly counter RL's core strength — learning the optimal balance through trial and error.
V16 1v1 11W-5L (68.8%) vs V14 · 4p 2/8 ⚠️
第一个 PPO 版本。V14 权重起步 → 300 PPO updates。ep_rwd 36→42。1v1 打赢 V14 验证了 PPO 探索的有效性。但 4p 弱——2p training 导致策略过拟合单挑场景。
突破点:证明 PPO + frozen opponent self-play 比 BC 蒸馏有效。但暴露了"单一对手训练"的泛化问题。
V15 1v1 7W-7L-2D vs V14 — 势均力敌
V14×V14 互搏 2000 局 → 收集 4,648,019 个决策样本 → BC 蒸馏训练。loss 0.089→0.042。但 H2H 无法超越 V14。
核心发现:BC 迭代 self-play 不能提升。V15 本质是 V14 的蒸馏拷贝——4.6M 样本教会了一个"更平滑的 V14",但没有学到任何新东西。没有探索机制(随机性/噪声/PPO sampling)就不会突破。
V14 vs V13 14W-2L (87.5%) · vs V12 10W-6L (62.5%)
从 BC 模仿学习升级为 Roman×Roman self-play RL:双方同时收集 decision 数据 → 200 epoch 训练策略网络。500 局收集 256 样本,loss 1.57→0.26。
Self-Play RL 完胜 BC 模仿学习(87.5%),证明在博弈环境中"和更强的自己对弈"比"复制冠军"更有效。
V13 ROMAN BC — 模仿冠军 replay(仅 2 局)
从冠军 replay 提取决策数据,Behavior Cloning 训练 OrbitPolicy(256)。样本量太小(2 局),完全无法泛化。
BC 本质是"记忆"——在有限数据上拟合只能学会数据中的模式,学不到应对未见局面的能力。
V8–V12 启发式参数调优 — 天花板确认
分析冠军 replay 后发现冠军"零防守全攻、大舰队 49-57 avg"——照抄参数(RESERVE_FACTOR=0)却惨败(4p avg=908)。修复了 3 个系统性缺陷(囤兵不敢攻、旋转行星射不出、舍近求远),但启发式参数 V3 即局部最优。
核心发现:冠军的激进策略依赖 RL 训练的多星球协调发兵能力——纯启发式无法复现。唯一路径:RL 训练。

04 · 核心洞察

🔑 旋转预测是最关键的一行代码

V16/V17 舰队"飞出星系"不是策略问题,是物理问题——瞄准当前位置,飞行期间行星转走。V18 加了 predict_position() 后 1v1 100% 胜率。证明:在旋转环境中,预测未来位置比任何复杂策略都重要。正确建模环境动力学有时比更好的算法有效。

🔑 BC 模仿 ≠ 理解。Self-Play 才有探索。

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

05 · 参考文献

  • [PPO] Schulman, J., Wolski, F., Dhariwal, P., Radford, A., & Klimov, O. (2017). Proximal Policy Optimization Algorithms. arXiv:1707.06347. → paper
  • [GAE] Schulman, J., Moritz, P., Levine, S., Jordan, M., & Abbeel, P. (2016). High-Dimensional Continuous Control Using Generalized Advantage Estimation. arXiv:1506.02438. → paper
  • [TRPO] Schulman, J., Levine, S., Abbeel, P., Jordan, M., & Moritz, P. (2015). Trust Region Policy Optimization. ICML 2015. arXiv:1502.05477. → paper
  • [Spinning Up] Achiam, J. (2018). Spinning Up in Deep RL. OpenAI. → PPO 教程 — 推荐的 RL 入门资源,含完整公式推导和 PyTorch/TF 实现
  • [AlphaGo] Silver, D. et al. (2016). Mastering the game of Go with deep neural networks and tree search. Nature 529, 484–489. → paper — Self-play + frozen opponent 范式的经典应用
  • [RLHF / PPO in LLMs] Ouyang, L. et al. (2022). Training language models to follow instructions with human feedback. NeurIPS 2022. arXiv:2203.02155. → paper — InstructGPT 用 PPO 做 RLHF,PPO 在大模型中的应用范例