一个晚上想给 Orbit Wars(一个 100×100 连续空间的实时策略对战赛,类 Planet Wars)造一个 AlphaZero 式的自博弈神经网络。结果大半夜都花在两堵墙上:第一堵是「策略永远学不动」,修了核心 bug 才过;第二堵修不过去,是方法论本身的天花板。
记录下来,因为这两堵墙对应着强化学习里一个很容易混的区分:「能不能学」和「学到的上限」是两个完全不同的问题。
背景:为什么是 attention,为什么是自博弈
这个游戏的状态本质是一组实体——若干行星(绕中心太阳公转或静止)、若干在途舰队(沿固定角度直线飞)。实体数量是变的。对「变长的一组东西」做决策,transformer encoder 是天然选择:置换不变、长度无关。
所以网络是一个 OrbitTransformer:把每个行星编码成一个 token,过几层 self-attention,然后用指针注意力做策略——我的每颗行星(query)去打分所有行星(key)作为发射目标,外加一个 HOLD(不发射)选项,softmax 成「每颗行星一个 categorical 分布」。价值头读全局 token 输出一个 tanh 标量(赢面)。
物理不让网络学。发射角度由一个引擎精确的解析器 fastest_intercept 算出(打公转目标要算提前量),网络只决定「打谁」。这样动作空间从「连续角度」离散成「源星→目标星」的指针选择。
训练用自博弈 + PPO,gen0 的对手是项目里已有的强 agent predictor(一个确定性时空树搜索,本质就是 MCTS,本地双循环 66.7% 胜过上一代冠军)。目标:每一代 NN 都要在真引擎里碾压上一代。
系统搭起来不难。env 用一个轻量前向模型(逐 tick 重放引擎物理,我还顺手用真引擎 make_env 做了逐拍 parity 测试,45 步精确匹配)、编码、模型、PPO、gate,一晚上能跑起来。
然后就撞墙了。
第一堵墙:熵死死卡在 19,策略永远不学
跑了 100 多个 iteration,训练曲线死平。最刺眼的是策略熵:
it 1 winrate 0.40 ent 18.9 pi +0.08it 30 winrate 0.37 ent 19.2 pi +0.00it 60 winrate 0.38 ent 20.1 pi -0.00熵稳在 ~19 一动不动,胜率不涨,policy loss 基本是 0。熵 ~19 意味着策略几乎是均匀随机——它根本没在收敛到任何具体打法。
排查的关键观察:价值头在学,策略头不学。value loss 从 0.02 一路掉到 0.004(价值越预测越准),但策略熵纹丝不动。一个在学、一个不学,这个反差是定位的钥匙——说明梯度的「采集→价值」这条路是通的,断点在策略梯度本身。
为什么因子化策略的策略梯度会消失?我盯着 PPO 的实现看:
# 出问题的写法:联合 ratiologp_joint = (per_planet_logp * src_mask).sum(1) # 把 ~8 颗源星的 log-prob 加起来ratio = torch.exp(logp_new - logp_old) # 一个标量 ratiosurr = torch.min(ratio * adv, torch.clamp(ratio, 1-0.2, 1+0.2) * adv)问题就在这。我的动作是因子化的——一回合里每颗行星各选一个目标,是 ~8 个独立的 categorical。我把它们的 log-prob 加成一个联合 log-prob,再算一个联合 ratio 去做 PPO 裁剪。
ratio = exp(Σ 每颗星的 logp 变化)。8 颗星,哪怕每颗的 log-prob 只变了 0.1,加起来就是 0.8,ratio = exp(0.8) ≈ 2.2——远远超出裁剪区间 [0.8, 1.2]。PPO 的 clip 在被裁剪的那一侧梯度是 0。结果就是:几乎每一个样本的联合 ratio 都被裁到区间外,梯度被裁成 0,策略基本不更新。唯一还在起作用的恒定梯度,是熵正则项——它把策略往均匀分布推。所以熵不降反升,稳在 ~19。
修复是标准的多维离散动作做法:per-factor 裁剪——每颗星单独算 ratio、单独裁剪,再对源星求和:
# 每颗星单独的 ratio,共享同一个 advantageratio = torch.exp(logp - old_lp) # [B, Pmax] 逐星surr1 = ratio * adv.unsqueeze(1)surr2 = torch.clamp(ratio, 1-clip, 1+clip) * adv.unsqueeze(1)pi_loss = -(torch.min(surr1, surr2) * src_mask).sum() / src_mask.sum()(为此要在采集时存逐星 old log-prob,不是联合的;HOLD 还得存成一个哨兵值再在 PPO 里重映射到当前 batch 的列——因为 padding 后每个 batch 的 HOLD 列位置不一样,这是另一个小坑。)
改完一跑,熵当场雪崩:
it 1 ent 0.41 pi -0.22it 5 ent 0.43 pi -0.18it17 ent 0.52 pi -0.05熵从 19 掉到 0.5,policy loss 终于是个实数。策略真的在学了。 这是从「死」到「活」的那一步。
NOTEPPO 的裁剪作用在 ratio 上,而 ratio 是 log-prob 差的 exp。把多个独立因子的 log-prob 加起来再 exp,等于把它们的 ratio 相乘——因子越多,联合 ratio 越容易爆出裁剪区间。多维离散/多智能体的 PPO 实现一般都是逐维度分别裁剪的,原因就在这。
第二堵墙:能学,但学不过深搜
修好之后我以为有戏了。又训了几十代,配上势能塑形(potential-based reward shaping,r_t = γΦ(s') - Φ(s),Φ = tanh(舰船差/100)——给稀疏的胜负奖励加一个稠密的引导信号;Ng 等人 1999 年证明过这类塑形不改变最优策略)、HOLD 偏置初始化(让随机策略默认多攒兵、少瞎发,否则每颗星每回合都发射、舰队爆炸把模拟拖死)、league 自博弈(对历史快照打,防止收敛到狭窄均衡)。
结果稳稳地 plateau 在 train winrate ~0.45。拆开看,对一个简单启发式对手 starter(μ821)大概 25% 胜率——比随机的 16% 强一点,但也就一点。而对真正的对手 predictor,gate 测试是:
[gate it 25] vs gen0: W/D/L 0/0/48 decisive=0.000 -> keep training0 胜 48 负。 一个从零自博弈出来的纯策略网,打深度树搜索的 agent,一局都赢不了。
这堵墙修不过去——因为它根本不是 bug,是方法的天花板。
纯策略网(推理时不带搜索)打不过深搜 agent,这正是 AlphaZero 要把网络和 MCTS 绑在一起的原因。 AlphaZero 不是「一个很强的策略网」,它是「策略价值网 + MCTS」:MCTS 用网络当先验和评估跑出一个比裸策略更强的招,网络再去模仿这个搜索改进后的招。强度来自搜索,网络只是给搜索提供越来越好的评估和先验。
放到这个项目里,结论很清楚:predictor 本质就是搜索分支(MCTS),我这个 NN 是策略价值分支。两个分开都不是终点,融合才是——把 NN 的 value 塞进 predictor 的搜索当评估函数(替掉它手写的 _score_leaf),自博弈那个融合体。那个东西本来就该赢 predictor:它是「已经很强的搜索」加上「一个会自我进化的眼睛」。
但融合是另一个工程量,那晚没动。NN 分支作为一个 branch 存档,没合并。
顺手踩的坑
中间还有几个坑,单拎出来都够浪费半小时:
stdout 缓冲假象。 python -m train > train.log 重定向时 stdout 是块缓冲,print 不刷新——训练明明在正常跑,train.log 里却一个 iteration 都看不到,看着像「卡死」。我为此误判了好几次「卡住」,还去查 CPU、查进程。真实进度其实在每个 iteration 刷新的 train_log.jsonl 里(dashboard 读的就是它,所以 dashboard 一直有数据)。解法:python -u,并且别信 stdout,信那个 jsonl。
孤儿 worker 累积。 ProcessPoolExecutor 的 worker 子进程 cmdline 里不含主脚本名,pkill -f train_loop 只杀得掉主进程,留下 24 个孤儿 worker。重部署几次,几十个僵尸 worker 把 28 核吃满,新训练被饿死。启动脚本必须额外 pkill -f multiprocessing。
pkill 自杀。 这个最阴。我的诊断命令字符串里含 “dl.train_loop”,于是 pkill -f dl.train_loop 把正在执行这条命令的 shell 自己也匹配killed了——命令在 echo 之前就自尽,全程零输出,我还以为远程连接挂了。要用一个不出现在杀手自身 cmdline 里的模式(比如带方括号的 orbit-dl/[.]venv)。
病态游戏卡死 gather。 早期随机策略疯狂发射,舰队累积到几百个,env.step 的扫描碰撞是 O(舰队×行星),单局慢到几分钟。work-stealing 的 gather barrier 要等最慢的那局,一局病态游戏就把整个 iteration 拖死。加了每局墙钟上限兜底,但真正的解是 HOLD 偏置——让策略稀疏发射,从源头不产生那么多舰队。
公转相位差一拍。 解析器算公转行星位置时,引擎的约定是「obs 在第 S 步时行星在 av*(S-1)」,我一开始超前了一拍。差值约 1.5 个单位,和行星半径同量级——擦边攻击在真引擎里会飞出界。用「预测落点 vs 前向模拟实际落点」的漂移指标验证,对齐后漂移从 1.5 降到 0.0000。
经验:分清「能不能学」和「学到的上限」
这一晚最值的一条,是把两个问题彻底分开。
「能不能学」是梯度问题。 per-factor 那个 bug 属于这一类——梯度被裁成 0,再好的方法、再多的算力也白搭,曲线就是死平。这类问题的信号很明确:熵不降、loss 不动、参数几乎不变。定位靠的是「价值在学、策略不学」这种部件间的反差——一个能动一个不能动,断点就在不能动的那条路上。这种 bug 不亲手撞一遍、不靠反差去逼,光看代码是看不出来的(它语法没错、跑得通、就是不学)。
「学到的上限」是方法问题。 0/48 属于这一类——梯度通了、策略也收敛了,但收敛到的策略就是弱。这不是 bug,调超参、加 league、训更久都顶多把 0.45 蹭高一点,突破不了。要破这个上限得换方法论本身:加搜索(MCTS)、换架构、换学习信号。
容易出事的地方在于:这两个问题的表象都是「不 work」,但处理方式完全相反。 把「能不能学」当成「上限」去调超参,是白费力气(梯度都没通,调啥都没用);把「上限」当成「能不能学」去找 bug,是找不到的(代码本来就对)。
那晚我先以为 plateau 是超参问题,调了半天 entropy、lr,没用——后来才意识到那是更底层的「梯度根本没流」(per-factor 的坑)。修完梯度,又一度以为还能靠调参冲上去——其实那已经是方法的天花板了。
先判断你撞的是哪一堵墙,再决定是去 debug 还是去换方法。
Sources: