本项目是腾讯 AI Arena — 峡谷追猎赛道的参赛方案,由 狮心会 战队开发,最终取得 第九名(9/152) 的成绩。
峡谷追猎是一个典型的部分可观测马尔可夫决策过程(POMDP):英雄在 128×128 的地图上躲避两只怪物的追击,同时收集宝箱与加速 buff,目标是在生存尽可能久的前提下最大化资源收集。环境的核心难点在于:
- 动态威胁:两只怪物会随时间加速,且追击策略不简单是最近邻,而是带有包围倾向。
- 多目标冲突:"苟活"与"吃宝箱"常常互斥 —— 贪宝箱容易被抓,纯逃跑则资源收益低。
- 高维观测:地图、怪物位置、资源分布、英雄状态等多源信息需要被有效融合。
- 关键决策:闪现(Flash)是唯一的位移技能,CD 长达 2000 步,用得好可以穿墙脱险,用不好等于浪费。
面对这些问题,我们设计了一套多目标分层决策框架:将任务拆解为"生存"、"收集"、"探索"三条独立目标,各自配备专用 Critic 评估;Actor 则采用三头结构,将动作决策解耦为"是否闪现"、"移动方向"、"闪现方向"三个层次,降低动作空间的耦合度。
📌 方案定位:本方案不是暴力堆算力的"大模型"路线,而是通过精细的特征工程、合理的网络结构设计和多目标解耦的奖励机制,在资源有限的情况下(单卡 RTX 4090)打出一套可解释、可复现的强基线。
在峡谷追猎中,"活得久"和"吃得多"是两个本质不同的优化目标,且它们的最优策略常常冲突。传统的单头 Critic 只能输出一个标量价值,难以同时兼顾这两个维度。我们用三头 Critic 将它们显式分离:
| Critic 头 | 负责评估什么 | 为什么独立? |
|---|---|---|
| Survive | 当前局面的生存价值 | 生存是硬约束,必须独立优化 |
| Collect | 收集宝箱/buff 的资源价值 | 与生存常冲突,独立头让模型学会权衡 |
| Explore | 地图探索与防绕圈的价值 | 探索是长期收益,单独评估防止短视 |
为什么不用单头? 单头 Critic 在训练早期容易被生存信号主导(因为死亡惩罚太大),导致模型完全不敢靠近宝箱,陷入"纯逃跑"的局部最优。三头独立后,Collect 和 Explore 有各自的价值估计和梯度,即使 Survive 头在早期主导,另外两头仍能通过自己的 Advantage 信号推动策略向资源收集方向探索。
动作空间共 16 维(8 移动方向 + 8 闪现方向)。如果直接用 16 维 softmax 输出,会面临两个问题:
- 耦合性:移动和闪现是互斥的(同一时刻只能做一种),但 16 维 softmax 把它们当成平级选项,模型需要额外学习"互斥关系"。
- 稀疏性:闪现只有在 CD 转好时才可用,大部分时间只有 8 个移动方向合法,导致 16 维输出中 8 维长期处于"被 mask 掉"的状态,梯度浪费。
我们的解法是三头分层:
head_flash_gate (2维): [不闪, 闪]
head_move_dir (8维): [右, 右上, 上, 左上, 左, 左下, 下, 右下]
head_flash_dir (8维): [右, 右上, 上, 左上, 左, 左下, 下, 右下]
推理时,先采样 gate:如果不闪,再从 move_dir 采样;如果闪,再从 flash_dir 采样。这样联合概率自然分解为 p(gate) × p(dir|gate),与动作本身的物理结构一致。从信息论角度看,这相当于在策略网络中引入了先验结构(inductive bias),减少了策略搜索空间。
这是本方案 reward 设计的核心创新。我们定义了一个压力分数(pressure),实时衡量当前局面的危险程度:
pressure = clip(0.4×shrink_rate + 0.3×sandwich + 0.2×danger_level + 0.1×escape_loss, 0, 1)
- shrink_rate:怪物与英雄距离的收缩速度(距离在快速缩小 → 压力高)
- sandwich:两怪是否形成夹击(夹角 > 120° → 压力高)
- danger_level:怪物距离归一化后的危险等级
- escape_loss:最优逃跑方向与实际移动方向的偏离度
压力分数用于动态调整三组奖励的权重:
| 奖励组 | 权重公式 | 含义 |
|---|---|---|
| Survive | 1.0 + 1.5 × pressure |
压力越大,生存奖励越重要(最高 2.5 倍) |
| Collect | 1.0 - 0.7 × pressure |
压力越大,越不鼓励贪宝箱(最低 0.3 倍) |
| Explore | 1.0 - 0.6 × pressure |
压力越大,探索奖励也降低(最低 0.4 倍) |
为什么这样设计? 这模仿了人类玩家的直觉:怪物贴脸时(高压力),脑子里想的只有"跑";安全时(低压力),才考虑"吃宝箱"和"逛地图"。如果不做这种动态调制,模型会在高压力时仍然被"吃宝箱"的奖励吸引,导致频繁送人头。压力调制相当于给奖励函数装了一个"情境感知开关"。
特征向量共 1899 维,按语义切分为 20 个字段,最终归并为 8 个 Token 送入 Transformer。以下是逐段解释:
| 索引范围 | 维度 | 内容 | 对应的决策问题 | 归属 Token |
|---|---|---|---|---|
| [0:4] | 4 | 英雄位置(xy) + 闪现 CD + buff 剩余时间 | "我现在是什么状态" | self |
| [4:14] | 10 | 2 只怪物各 5 维(视野/位置/速度/距离) | "危险从哪来" | monster |
| [14:26] | 12 | 最近 3 个宝箱各 4 维(存在/距离/方向) | "哪个资源值得追" | treasure |
| [26:34] | 8 | 最近 2 个 buff 各 4 维(存在/距离/方向) | "加速道具在哪" | buff |
| [34:1798] | 1764 | 4 通道 21×21 多通道地图 | "地形+实体空间关系" | map |
| [1798:1806] | 8 | 8 方向逃跑深度 | "哪个方向有退路" | escape |
| [1806:1814] | 8 | 8 方向怪物感知安全分 | "考虑怪物后哪里最安全" | escape |
| [1814:1817] | 3 | 局部拓扑(开阔率/可走方向数/死角标记) | "这里是不是死角" | escape |
| [1817:1825] | 8 | 怪物短期记忆(2 只 × 4 维) | "怪物消失前在哪" | monster |
| [1825:1833] | 8 | 8 方向闪现穿墙机会评分 | "闪现能不能穿墙" | flash |
| [1833:1843] | 10 | 死胡同分析(BFS 版) | "我是不是在死胡同里" | escape |
| [1843:1859] | 16 | 合法动作掩码 | "哪些动作可用" | self |
| [1859:1862] | 3 | 进度(步数/是否加速/加速倒计时) | "游戏到什么阶段" | self |
| [1862:1874] | 12 | 探索特征(位移+陌生度+资源安全+buff 进度) | "哪里没去过" | explore |
| [1874:1882] | 8 | 闪现落点安全分 | "闪往哪里不会死" | flash |
| [1882:1884] | 2 | 被困状态 + 能否闪现脱险 | "被困了吗" | flash |
| [1884:1892] | 8 | 闪现净距离改善量 | "朝这方向闪值不值" | flash |
| [1892:1894] | 2 | 反绕圈(是否新格/覆盖率) | "在绕圈吗" | explore |
| [1894:1897] | 3 | 压力特征(shrink/sandwich/loss) | "当前压力多大" | escape |
| [1897:1899] | 2 | 闪现战略特征(价值/机会成本) | "这闪现在全局值不值" | flash |
这是特征中维度最大的部分,但它是空间感知的核心。
- Channel 0(通行性):1=可走,0=墙。告诉模型哪里是路、哪里是障碍。
- Channel 1(怪物位置):可见怪物的栅格标记为 1。让模型在 CNN 中直接感知威胁的空间分布。
- Channel 2(宝箱位置):可拾取宝箱标记为 1。
- Channel 3(buff 位置):可拾取 buff 标记为 1。
为什么是 21×21? 这是环境返回的原生视野尺寸。我们不做裁剪,保持完整视野,因为怪物可能从视野边缘突然出现,裁剪会丢失关键信息。
为什么用 4 通道而不是 1 通道? 单通道只能表示"有什么",无法区分"怪物"和"宝箱"。多通道让 CNN 可以学习"怪物靠近宝箱"这种组合模式。
环境返回的宝箱/buff 列表包含 21×21 视野内的所有物件,即使中间有墙挡着也会出现在列表里。如果不处理,模型就会"透视"看到墙后面的宝箱,做出"朝墙后宝箱移动"的愚蠢决策。
我们在预处理中加入了 DDA(Digital Differential Analysis)射线检测:从英雄位置到物件位置画一条线,检查中间是否有墙体阻断。如果被挡住,该物件直接从特征中剔除。这相当于给模型装上了"视线判断",确保输入信息符合物理规律。
环境只返回当前可见的怪物信息。当怪物被墙体遮挡时,模型会突然"丢失"对怪物的感知,导致决策剧烈抖动(比如从"逃跑"瞬间变为"探索")。
我们设计了短期记忆机制:记录怪物最后出现的位置、方向和有效帧数。即使怪物当前不可见,记忆特征仍然会告诉模型"怪物大概在这个方向,80 步内曾出现过"。这有效缓解了部分可观测性带来的决策抖动。
用 BFS 从英雄位置向外搜索,计算:
- 死胡同得分(当前位置是否是死胡同)
- 8 个方向是否是出口
- 被困度(周围可通行方向的数量)
为什么重要? 模型如果不理解"死胡同"这个概念,会频繁走进只有单一出口的狭窄通道,被怪物堵死。BFS 死胡同分析相当于给模型提供了一个拓扑层面的先验知识,让它学会"不要走进死路"。
obs(1899维) ──切片重组──┬── self(23) ──→ MLP ──→ 128 ── token0
├── monster(18) ──→ MLP ──→ 128 ── token1
├── treasure(12) ──→ MLP ──→ 128 ── token2
├── buff(8) ──→ MLP ──→ 128 ── token3
├── map(1764) ──→ FPN CNN → 128 ── token4
├── escape(32) ──→ MLP ──→ 128 ── token5
├── flash(28) ──→ MLP ──→ 128 ── token6
└── explore(14) ──→ MLP ──→ 128 ── token7
│
Transformer × 2 (MHA 8头 + FFN 256)
│
fusion 1024 → 256 → 64
│
┌── 三头 Actor (64→32→N) ────┬──────────────┬──────────────┐
│ gate(2) move_dir(8) flash_dir(8)
│
└── 三头 Critic (64→32→1) ───┬──────────────┬──────────────┐
survive(bias=+1.0) collect explore
特征来自 8 个截然不同的语义来源(英雄、怪物、地图、闪现…),它们之间的关系是动态的、上下文依赖的:
- 怪物在地图上的位置会影响"逃跑深度"的解读;
- 闪现 CD 状态会影响"怪物记忆"的紧急程度;
- 死胡同拓扑会改变"宝箱吸引力"的权重。
如果用简单的全连接层拼接所有特征,模型只能学到固定的加权组合,无法适应这些动态关系。Transformer 的自注意力机制(Self-Attention)让不同 Token 之间可以动态地、根据当前局面调整彼此的影响力。例如,monster token 可以通过注意力机制告诉 escape token:"怪物在东边,所以东边的逃跑深度要打折扣"。
为什么是 2 层而不是更深? 实验发现 2 层 Transformer 已经足以捕捉 Token 间的二阶交互关系(如"怪物位置 + 地图障碍 = 有效逃跑方向")。更深的网络(4+ 层)在这个任务上收益递减,但参数量和训练时间显著增加。在资源有限的情况下,2 层是性价比最高的选择。
我们为 8 个 Token 各自分配了一个可学习的类型嵌入(Type Embedding),加到对应 Token 的 128 维特征上。
为什么需要这个? 自注意力机制本身是对称的:它不知道"token0 是 self,token1 是 monster"。Type Embedding 相当于给每个 Token 打上一个"身份标签",让注意力层知道"我正在看的是一个怪物特征,还是一个地图特征"。这类似于 NLP 中的 Position Embedding,但这里是语义位置而非空间位置。
21×21 的 4 通道地图通过 3 层卷积 + 自适应池化提取特征:
Conv1: 4 → 32, 保持 21×21
Conv2: 32 → 32, 下采样到 11×11
Conv3: 32 → 32, 下采样到 6×6
→ 每层后接 AdaptiveAvgPool2d(1) → 32 维
→ 拼接 3×32=96 维 → FC → 128 维
为什么用 FPN 而不是单层 CNN? 不同尺度的信息对决策的重要性不同:
- 大尺度(21×21):判断"整体地形是开阔还是狭窄";
- 中尺度(11×11):判断"附近有没有怪物/宝箱";
- 小尺度(6×6):判断"精确的位置关系"。
FPN 同时保留三个尺度的信息,避免了下采样导致的细节丢失。
在 critic_survive 的最后一层,我们将 bias 初始化为 +1.0。
为什么? 这是为了让模型在训练初期就相信"活着是有价值的"。如果 bias 从 0 开始,模型在早期可能会低估生存价值(因为死亡是稀疏事件,大部分步的 survival reward 只有 +0.002),导致策略过于冒险。+1.0 的先天偏置相当于给模型一个"保守先验",帮助它在训练早期就建立起"先活下来"的基本意识。
奖励被分为 三组 + 独立项,每组乘上压力调制权重后,累加成三路 reward 喂给三头 Critic:
[survive_reward, collect_reward, explore_reward]
| 奖励项 | 说明 | 系数 | 设计理由 |
|---|---|---|---|
| 基础生存 | 每活一步 +0.002 | 0.002 | 保证模型始终收到"活着"的微弱正反馈 |
| 怪物距离 shaping | 远离怪物=正,靠近=负 | 0.12 | 引导模型保持安全距离 |
| 逃跑深度变化 | 移到可跑更远的位置=正 | 0.15 | 鼓励去开阔区域,不要自断退路 |
| 怪物感知安全分变化 | 移到更安全方向=正 | 0.12 | 综合考虑怪物方向后的安全评估 |
| 死角惩罚 | 站在 ≤1 个出口的位置扣 0.08 | -0.08 | 惩罚走进死角 |
| 危险方向选择 | 怪物近时朝安全方向跑有奖励 | 0.06 | 高压时的方向引导 |
| 开阔度奖励 | 位置越开阔奖励越高 | 0.02 | 鼓励去大空间拉扯 |
| 包夹惩罚 | 两怪夹角 >120° 时惩罚 | -0.06 | 惩罚被包围的局面 |
| 闪现脱险 | 闪现后怪物距离明显拉开 | 0.12 | 奖励"有效闪现" |
| 闪现浪费惩罚 | 闪现后距离没改善 | -0.08 | 惩罚"乱按闪现" |
| 被困闪现脱险 | 被困时用闪现逃出生天 | 独立项 | 强信号,直接累加 |
| 跨怪闪现 | 闪现穿过怪物 | 独立项 | 高风险高回报的操作 |
| 加速前缓冲惩罚 | 怪物即将加速时还贴太近 | 0.04 | 提前拉开距离 |
| 第二怪靠近惩罚 | 第二只怪距离过近 | -0.03 | 防止被第二只怪偷袭 |
| 奖励项 | 说明 | 系数 | 设计理由 |
|---|---|---|---|
| 靠近宝箱 | 朝最近宝箱移动 | 0.16 | 引导模型主动找宝箱 |
| 拾取宝箱 | 实际吃到宝箱(稀疏) | 4.0 | 稀疏大奖,明确信号 |
| 拾取 buff | 实际吃到加速 buff | 1.0 | 加速是重要战略资源 |
| 靠近 buff | 朝 buff 移动 | 0.12 | 引导收集 buff |
| 无怪宝箱 boost | 安全时靠近宝箱倍率 ×2.5 | 2.5 | 安全时鼓励大胆吃 |
为什么拾取宝箱是 4.0 而不是更大? 早期尝试过 10.0,但会导致 collect Critic 的 value 估计过大,梯度爆炸,进而影响整个训练稳定性。4.0 是一个平衡点:足够让"吃宝箱"成为明确目标,又不至于压垮 Critic。
| 奖励项 | 说明 | 系数 | 设计理由 |
|---|---|---|---|
| 首次到达新格 | 探索新区域 | 0.04 | 好奇心驱动,防止绕圈 |
| 朝宝箱方向探索 | 向资源方向移动 | 0.05 | 把探索和收集结合 |
| 原地不动惩罚 | 站着不动每步扣 0.03 | -0.03 | 逼模型动起来 |
| 撞墙惩罚 | 选了方向但没位移 | -0.04 | 惩罚无效动作 |
| 重复位置惩罚 | 100 步内频繁回同一位置 | -0.06 | 防止绕圈 |
| 后期漂移硬约束 | coverage < 20% 时线性扣分 | -0.40 | 强制走出绕圈窝 |
| 朝边界移动奖励 | _coverage 低时去边界 | 0.10 | 把模型从中心推向边缘 |
为什么有"后期漂移硬约束"? 模型在训练中期容易学会"绕小圈"——在一个小区域内来回移动,怪物追不上,但 coverage(探索覆盖率)极低。这种行为在生存指标上表现不错,但收集和探索指标很差。我们在 200 步后加入了 coverage 硬约束:如果 200 步窗口内的唯一格子比例 < 20%,就按缺口线性扣分。这相当于给模型装了一个"防摆烂"机制。
- 被困闪现脱险:当
trap_status=1(被困)且使用闪现成功脱离时,给予固定正奖励。 - 跨怪闪现:闪现落点穿过怪物当前位置,高风险操作给予额外奖励。
为什么独立? 这些是极端场景下的关键操作,信号必须不受压力调制削弱。如果它们也被乘上 w_survive,在高压力时会被放大,但低压力时也会被缩小,导致模型学不到"被困时必须闪现"这一普适策略。
标准 PPO 只算一组 Advantage。我们对 Survive / Collect / Explore 三路 reward 各自运行 GAE:
# 对每一路独立计算 delta 和 gae
delta = reward + gamma * next_value - value
gae = gae * gamma * lambda + delta为什么独立? 三路 reward 的量级和稀疏性完全不同:Survive 是密集信号(每步都有),Collect 是稀疏信号(吃到才有),Explore 是中期信号(几十步才体现)。如果混在一起算 GAE,稀疏信号会被密集信号的噪声淹没。独立 GAE 保证了每一路的 Advantage 都有自己的清晰信噪比。
三路的 Advantage 在送入 Policy Loss 前,先各自做独立归一化(减去均值除以标准差),再按权重合成:
adv = 1.0 * norm(adv_survive) + 1.0 * norm(adv_collect) + 1.0 * norm(adv_explore)为什么归一化? Collect 的 Advantage 天然比 Survive 大(因为稀疏奖励的 GAE 波动更剧烈)。如果不归一化,Collect 会"吃掉"Policy Gradient 的主导权,导致策略只关注收集而忽视生存。归一化后,三路 Advantage 在同一量级上竞争,模型才能学会真正的权衡。
标准 PPO 的 Value Clip 范围通常是 [-0.2, +0.2](与 Policy Ratio 的 clip 参数共用)。我们发现这个范围对三头 Critic 来说太小了:Critic 需要快速学习不同局面的价值差异,0.2 的 clip 会把 value 更新卡死。
我们将 Value Clip 单独配置为 2.0(是标准值的 10 倍):
v_clip = old_value + (new_value - old_value).clamp(-2.0, +2.0)为什么可以这么大? Value 的学习目标是 TD-Return,它的波动范围天然比 Policy Ratio 大得多。Policy Ratio 接近 1.0,所以 clip 0.2 是合理的;但 Value 可能从 0.5 变到 5.0(比如从早期到加速后期),0.2 的 clip 会让 Critic 学得太慢。独立的 VALUE_CLIP_RANGE=2.0 让 Critic 有足够的学习自由度。
三头 Actor 的输出通过概率分解合成 16 维联合动作概率:
joint_prob[0:8] = p_gate[0] * p_move # 不闪,选移动方向
joint_prob[8:16] = p_gate[1] * p_flash_dir # 闪,选闪现方向每一头内部使用 Masked Softmax:不合法的动作(如闪现 CD 中时的 8 个闪现方向)被设为极小 logit,softmax 后概率为 0。
为什么用 Masked Softmax 而不是直接在采样时过滤? 在训练阶段,我们需要计算 new_prob / old_prob(PPO 的 ratio)。如果直接在采样时过滤,不合法动作的概率是不确定的,会导致 ratio 计算错误。Masked Softmax 保证了不合法动作的概率严格为 0,且合法动作的概率之和严格为 1,数学上自洽。
Entropy 系数 Beta 从 0.003 线性衰减到 0.0003,共 30000 步。
为什么衰减? 训练早期需要高探索(大 Beta),让模型充分尝试各种动作;训练后期需要低探索(小 Beta),让模型收敛到稳定策略。线性衰减是最简单有效的策略,避免了手动调参。
| 参数 | 值 | 说明 |
|---|---|---|
| Gamma | 0.995 | 高折扣因子,重视长期收益(生存游戏必须看长远) |
| Lambda | 0.95 | GAE 参数,平衡偏差与方差 |
| Learning Rate | 2e-4 | Adam,对 Transformer 来说偏保守,防止震荡 |
| Clip Param | 0.2 | PPO 标准值 |
| Grad Clip | 1.0 | 从 0.5 提到 1.0,避免 Transformer 梯度被卡死 |
| VF Coefficient | 1.0 | Value Loss 权重,与 Policy Loss 同等重要 |
| Batch Size | 64 | 单卡 4090 上的稳定配置 |
训练时每 20 秒上报一次监控:
value_loss_survive/collect/explore:三头 Critic 各自的学习进度explained_variance_survive/collect/explore:Critic 对 TD-Return 的解释力(越接近 1 越好)raw_entropy:策略的随机性(应随训练逐渐下降)reward:平均奖励(反映整体表现趋势)
🏆 第 9 名 / 152 支队伍
在没有使用超大模型、没有多卡分布式训练、没有复杂搜索算法的情况下,仅凭精细的特征工程和合理的网络结构设计,我们在 152 支队伍中进入前 10。这说明在资源受限的场景下,对任务本质的理解和工程细节的打磨,比盲目堆算力更有效。
- 压力动态调制是本项目最核心的创新。它让模型学会"看碟下菜":高压时保守,低压时激进。这一机制显著降低了早期死亡率和无效探索。
- 三头 Critic 解耦有效缓解了多目标冲突。Survive、Collect、Explore 各自有独立的价值估计,策略可以在这三者之间做显式权衡。
- DDA 射线遮挡和怪物短期记忆修复了环境的部分可观测性问题,输入信息更加可靠。
- 地图 CNN 可以更深:当前只有 3 层轻量 FPN,如果加深到 ResNet-18 级别,空间特征提取能力会更强。
- 闪现决策可以专门优化:闪现是最高杠杆的操作,可以考虑为闪现设计一个独立的"闪现 Critic",专门评估闪现的时机和方向。
- Transformer 可以尝试 4 层:当前 2 层是性价比选择,但如果有更多算力,4 层 Transformer 可能捕捉到更复杂的 Token 交互模式。
- 压力分数可以学习:当前压力分数是 handcrafted 的(手动设计的权重)。如果让网络自己学一个"压力嵌入",可能更灵活。
# 安装依赖
pip install -r requirements.txt
# 训练(默认 PPO)
python train_test.py
# 切换算法(如需对比 DIY 基线)
# 修改 train_test.py 中 algorithm_name = "diy"狮心会 —— 队名取自江南小说《龙族》中的学生组织。就像小说里那群在暗处守护同伴的家伙一样,我们信奉的是:把每一个工程细节打磨到极致,把每一个设计决策做到自洽。没有暴力堆算力的资源,就靠对问题的深刻理解和对代码的极致打磨去赢。
