简介:面向腾讯开悟赛题与深度强化学习实践者的「重返秘境」模型包,聚焦“仅到终点”这一目标,提供可参考的DQN训练与推理实现,适合想理解智能体决策流程、复现实验结果或对照改进的中高级学习者。压缩包共56个文件、约4.69MB,以31个Python源码为主,配合11个pyc缓存、6个toml与2个yaml配置,以及pkl检查点、json和sign等元数据,覆盖agent、model、train_workflow及target_dqn、dqn、diy等模块。资源中可见模型检查点与多套训练部署配置,便于拆解状态定义、动作空间、网络结构和参数设置,也能作为赛题方案骨架用于继续训练与排错。已有2599人学习下载,描述显示其平均分约800,可作为重返秘境任务效果的参照。
1. 只判终点的赛题:为什么八成队伍卡在第一条曲线
第一次接触这类「秘境寻宝」式对弈赛题的人,通常会先搭一个 PPO,挂机跑一晚上,第二天打开曲线发现回报一直是 0。原因不复杂:赛题把奖励卡死在终点,走对九十九步和原地转圈拿到的分数一模一样,这种「仅到终点」的稀疏奖励设定,再叠上多智能体互相卡位,等于把探索难度乘了两遍。这一篇讲的是我从零把一个「能真正跑到终点」的模型做出来的路径:观测怎么裁、动作怎么掩、奖励怎么在不改评测口径的前提下补信号、对手池怎么配、以及上线前用什么口径判断它是真的会走还是碰巧走到。适合已经能跑通环境、但训练曲线长期贴着地板的从业者。
2. 把赛题拆成 MDP:观测、动作、奖励三件套怎么定
动手写网络之前,先把赛题翻译成一个干净的马尔可夫决策过程。这一步偷懒,后面所有调参都是在给错误的建模打补丁。我的习惯是把三件套写成一张纸:状态里有什么、能做什么、什么算好。写不出来就别急着开训练脚本。
2.1 观测该喂什么:从原始整图到局部视野加相对坐标
平台给的原始观测通常是全局栅格加上若干实体坐标。直接整图喂进去有两个后果:一是输入维度随地图尺寸变化,网络没法复用;二是模型会记住「左上角那堵墙」,换个地图就崩。我一般裁一个固定边长的局部视野,再拼上少量标量特征。
import numpy as np VIEW = 7 # 局部视野边长,奇数好取中心 CH = 4 # 通道:可通行 / 障碍 / 目标 / 对手 SCALAR = 4 # 相对目标向量(2) + 归一化步数(1) + 上次动作是否合法(1) ACT_N = 9 # 动作维度,见 2.2 OBS_DIM = CH * VIEW * VIEW + SCALAR + ACT_N def build_obs(raw, last_act_onehot, step_ratio): """raw 由环境返回:grid(H,W)、pos(x,y)、goal(x,y)、opponents(list)。 只使用评测允许的字段,多一个都不用,避免训练/评测信息不对称。""" grid, (x, y), (gx, gy) = raw["grid"], raw["pos"], raw["goal"] r = VIEW // 2 # 越界处补 -1,让网络能区分「墙外未知」和「空地」 padded = np.pad(grid, r, mode="constant", constant_values=-1) patch = padded[y:y + VIEW, x:x + VIEW].astype(np.float32) channels = np.zeros((CH, VIEW, VIEW), dtype=np.float32) channels[0] = (patch == 0) # 可通行 channels[1] = (patch == 1) # 障碍 channels[2] = (patch == 2) # 目标 for ox, oy in raw["opponents"]: # 对手投影到同一局部视野 dx, dy = ox - x + r, oy - y + r if 0 <= dx < VIEW and 0 <= dy < VIEW: channels[3, dy, dx] = 1.0 # 相对坐标而不是绝对坐标,换图不失效 rel = np.array([(gx - x) / 20.0, (gy - y) / 20.0], dtype=np.float32) scalars = np.concatenate([rel, [step_ratio, 1.0]]) return np.concatenate([channels.ravel(), scalars, last_act_onehot]) assert build_obs print(OBS_DIM) # 4*49 + 4 + 9 = 209,固定维度,跨地图复用代码里最关键的两行不是卷积,而是越界补-1和相对坐标。补-1是给「视野外的世界」留一个显式的编码位,否则模型会把补零当成空地,贴着地图边缘就开始瞎走。相对坐标除以 20 是个经验缩放,目的是把量级压到网络好学的区间,具体除数按你们地图的最大跨度取值,不必照抄。
提示:观测白名单一定要和环境方的字段说明逐字对齐。我见过最冤的翻车是训练时顺手读了全局地图,本地回报漂亮得不像话,提交后成绩直接归零。
2.2 动作空间与合法动作掩码:9 维离散加 masked logits
这类寻宝对弈赛题的动作通常是四向或八向移动,外加一个「停留/交互」。我用 9 维离散:8 个方向加 1 个停留。停留不是废动作,它在对抗里是等对手先动的手段,删掉会明显掉胜率。
真正要小心的是非法动作。撞墙、越界这些在环境里会被吞掉,但策略网络不知道,它会持续给非法动作高分,采出来的轨迹全部原地打转。
import torch NEG = -1e9 def masked_logits(logits, legal_mask): """logits: (B, 9) legal_mask: (B, 9) bool,True 表示可执行。 非法位置填极大负数,softmax 后概率趋近 0,梯度不会流向非法动作。""" return logits.masked_fill(~legal_mask, NEG) def act(policy, obs, legal_mask): logits, value = policy(obs) dist = torch.distributions.Categorical(logits=masked_logits(logits, legal_mask)) a = dist.sample() # 存 log_prob 时必须用 mask 后的分布,否则更新时重要性比会算错 return a, dist.log_prob(a), value def env_legal_mask(raw): """由环境给出可通行方向;不要自己用观测去猜,容易和真实规则不一致。""" m = torch.zeros(9, dtype=torch.bool) for i, (dx, dy) in enumerate([(0,-1),(1,-1),(1,0),(1,1),(0,1),(-1,1),(-1,0),(-1,-1)]): nx, ny = raw["pos"][0] + dx, raw["pos"][1] + dy m[i] = raw["grid"][ny, nx] == 0 m[8] = True # 停留永远合法 return m参数说明:NEG不要用-inf,混进float32后做 softmax 容易出 NaN,-1e9足够把概率压到 1e-30 量级。采样和计算log_prob必须是同一个 masked 分布,这是 PPO 里最常见的隐形 bug——训练不报错,但重要性比悄悄偏离,策略更新方向就飘了。
2.3 奖励:把「仅到终点」原样保留在评测侧,只在训练侧做文章
评测口径不能动,动了就是自欺欺人。我的做法是把奖励拆成两层:评测层严格保持「到终点给 1,其余给 0」,训练层在外面套一个 wrapper 补塑形信号,评估时把 wrapper 关掉,用裸奖励算指标。
| 信号层 | 到达终点 | 超时 | 撞墙 | 靠近目标 | 用途 |
|---|---|---|---|---|---|
| 评测裸奖励 | +1.0 | 0 | 0 | 0 | 打分、验收 |
| 训练塑形 | +1.0 | 0 | -0.01 | 见 4.2 | 只用于梯度 |
| 辅助头 | 交叉熵监督 | — | — | — | 加速早期收敛 |
撞墙给-0.01这种极小惩罚,目的不是教它别撞墙(掩码已经管了),而是给「卡住不动」一个微弱负梯度,让它别在原地耗到超时。数值一定不能大,大了模型会学会「宁可绕远也不贴墙」,路径效率直接崩。
到这里 MDP 就定完了。写不出来的时候我会强迫自己回答一个问题:给我这个观测,一个熟练的人类玩家能不能做出决策?不能就说明观测漏了信息;能但网络学不会,才是算法的问题。
3. 训练管线落地:从随机乱走到稳定终点的四步
MDP 定完,接下来是工程。这一章的东西不玄学,全是把吞吐、稳定性、对手质量这三件事摁住。
3.1 环境封装与并行采样:单机 16 进程的吞吐口径
这类赛题单局步数长、稀疏回报,采样效率就是命。我一般开 16 个进程,每进程 rollout 256 步,一次更新约 4k 样本。再多进程收益递减,因为环境本身有锁。
from multiprocessing import Pool import numpy as np class VecEnv: """极简并行封装:每进程持有一份环境,主进程只做拼接。""" def __init__(self, make_env, n=16, rollout=256): self.pool = Pool(n) self.envs = self.pool.map(lambda _: make_env(), range(n)) self.rollout = rollout self.last_obs = [e.reset(seed=1000 + i) for i, e in enumerate(self.envs)] def step(self, actions): # 用 map 而不是 apply_async,保证顺序和 env 索引严格对应 results = self.pool.starmap(_step_one, [(e, a) for e, a in zip(self.envs, actions)]) obs, rew, done, info = zip(*results) # 终止的环境立刻 reset,不要让 None 混进 batch obs = [o if not d else e.reset()[0] for o, d, e in zip(obs, done, self.envs)] return list(obs), list(rew), list(done), list(info) def _step_one(env, a): o, r, d, info = env.step(a) return o, r, d, info def make_env(): raise NotImplementedError("换成赛题环境的构造函数")逻辑说明:starmap保证返回顺序和进程索引一一对应,这是后面算 GAE 时按 env 分组的前提。终止即 reset 这一步别省,稀疏任务里一局结束很快,不 reset 会让 batch 里混进无效步。参数上n=16、rollout=256是我在单机 8 核上的稳态值,核数少的机器降到 8 进程,rollout 相应加长到 512,保证每次更新的样本量不低于 3k。
3.2 PPO 关键参数:稀疏终点任务里 γ 才是那个隐藏开关
普通任务抄一套 PPO 默认参数能跑,稀疏终点任务不行。核心矛盾是:回报只在最后一步出现,折扣因子太小,前面几十步的梯度信号会被折没了。
| 参数 | 取值 | 为什么这么设 |
|---|---|---|
| γ(折扣) | 0.995 | 500 步有效视野,够覆盖大半张图 |
| GAE λ | 0.95 | 方差和偏差折中,稀疏任务别低于 0.9 |
| clip ε | 0.2 | 常规值,策略崩的时候先降到 0.1 |
| 熵系数 | 0.02 → 0.003 线性退火 | 前期鼓励探索,后期收敛 |
| 学习率 | 3e-4,plateau 时减半 | 别用余弦,稀疏任务曲线抖动大 |
| 更新轮数 | 4 | 超过 6 容易过拟合当前 batch |
| 价值损失系数 | 0.5 | 回报尺度小,价值头别压过策略头 |
γ 从 0.99 调到 0.995 是我血泪经验里最值钱的一条。0.99 的有效视野只有 100 步,地图一大,终点奖励传不回起点,曲线永远是零;0.999 又会让价值函数方差爆炸,学出来的 V 值忽高忽低。0.995 是个中间点,具体按你赛题的最优路径长度调,经验公式是 γ ≈ 1 - 3/平均最优步数。
注意:改 γ 的时候必须同步改价值目标,否则优势和回报对不上,训练会出现「loss 正常但策略越来越差」的黑匣子现象。
3.3 对手池与自对战:别让模型只学会打败自己
多智能体赛题里,对手池的构成比网络结构重要得多。全用当前策略自对战,两个智能体会收敛到一套互相默契的「躺平」策略,谁都不去终点,胜率看着有 50%,实际一个球都没进。
import random from collections import deque class OpponentPool: def __init__(self, cap=20): self.snapshots = deque(maxlen=cap) # 历史快照,先进先出 self.scripted = [] # 规则/搜索类对手,兜底 def add(self, state_dict, episode): if episode % 50 == 0: # 每 50 局存一次,别存太密 self.snapshots.append(state_dict) def sample(self): r = random.random() if r < 0.5 and self.snapshots: return ("snapshot", random.choice(self.snapshots)) if r < 0.8 and self.scripted: return ("scripted", random.choice(self.scripted)) return ("self", None) # 剩 20% 打自己,保持策略新鲜配比是 50% 历史快照、30% 规则对手、20% 当前策略。规则对手不需要很强,它的作用是提供分布外的行为,防止策略在自对战的封闭分布里过拟合。快照池容量 20 左右,太小退化成只打最近的自己,太大又会让早期弱策略一直拖后腿。
评估的时候一定要分开算:对快照的到达率、对规则对手的到达率、对当前策略的到达率。三个数一起看,任何一个塌了都说明池子配比有问题。
4. 稀疏终点奖励的破局:四条不改评测口径的工程解法
这一章是整篇的核心。前面说了评测口径不能动,那怎么让模型在只有终点给分的情况下学会走路?靠四条互补的手段,从易到难叠上去。
4.1 课程学习:从 5×5 小图到完整地图的难度阶梯
最省事也最有效的一条。先把地图缩到 5×5,障碍随机撒 10%,最优路径 4 到 6 步,模型几十万步就能摸到终点,先把「朝目标走」这件事学会,再逐步放大。
| 阶段 | 地图尺寸 | 障碍比例 | 对手数量 | 晋级标准(近 100 局到达率) |
|---|---|---|---|---|
| P0 | 5×5 | 10% | 0 | ≥ 0.8 |
| P1 | 9×9 | 15% | 0 | ≥ 0.7 |
| P2 | 13×13 | 20% | 1(规则) | ≥ 0.6 |
| P3 | 15×15 | 25% | 1(池内) | ≥ 0.5 |
| P4 | 完整地图 | 原始配置 | 1(池内) | ≥ 0.4 |
晋级标准不要看平均回报,看到达率。回报在稀疏任务里就两种值,均值信息量极低。另外每次晋级都要保留上一阶段的环境实例做混采,比例 7:3,防止模型「忘了怎么走小图」。
阶梯设计里最容易踩的坑是跳级。P0 直接干到 P3,模型大概率卡死不回头;反过来在 P0 待太久,会过拟合到「只要五步内没终点就放弃」的短视策略。
4.2 势函数塑形与奖励收缩:训练用塑形,评测关塑形
课程解决「学不会」,塑形解决「学得慢」。原理是基于势函数的奖励塑形:用一个只和状态有关、且与评测最优策略一致的势函数 Φ,把γΦ(s') - Φ(s)加到每步奖励上。这种塑形在理论上不改变最优策略,所以不会把模型带偏。
from collections import deque import numpy as np def bfs_distance(grid, goal): """从终点做一次 BFS,得到每个格到目标的最短步数,作为势函数的基础。 只在 episode 开始算一次,缓存起来,别每步都算。""" H, W = grid.shape dist = np.full((H, W), 1e6, dtype=np.float32) q = deque([goal]); dist[goal[1], goal[0]] = 0 while q: x, y = q.popleft() for dx, dy in ((0,-1),(1,0),(0,1),(-1,0)): nx, ny = x + dx, y + dy if 0 <= nx < W and 0 <= ny < H and grid[ny, nx] == 0 and dist[ny, nx] > 1e6: dist[ny, nx] = dist[y, x] + 1 q.append((nx, ny)) return dist def shaped_reward(r, s, s2, dist, gamma=0.995, beta=0.1): """beta 是塑形强度,episode 数相关的退火系数在外层做,别写死。""" phi_s = -dist[s[1], s[0]] phi_s2 = -dist[s2[1], s2[0]] return r + beta * (gamma * phi_s2 - phi_s)三个参数要盯住:beta从 0.1 线性退到 0.01,训练后段基本关掉;gamma必须和 PPO 里的折扣一致,不一致就会给最优策略偷偷搬家;dist里障碍用1e6表示不可达,塑形项要跳过这些格子,否则模型会被推动去撞死路。
塑形的风险是奖励黑客。如果 Φ 用了「离目标的曼哈顿距离」这种和真实路径不一致的度量,模型会沿着对角线贴墙走,看起来离目标很近,实际绕远。BFS 距离场是唯一稳妥的选择,因为它在可通行图上就是真实最短步数。
4.3 事后回放与目标重标注:把失败轨迹变成正样本
稀疏任务里正样本极其稀缺。HER(Hindsight Experience Replay)的思路是:一局没到终点不要紧,把这一局实际走过的某个状态当成「假装的目标」,重新算回报,于是失败轨迹也能提供「朝某个目标走过去」的监督。
def relabel(episode, k=4): """episode: 包含 obs/act/rew/next_obs/done 的轨迹列表。 从轨迹里采样 k 个中间状态作为伪目标,重算每步奖励。""" out = [] T = len(episode) for _ in range(k): t = np.random.randint(T // 2, T) # 只在后半段取,前半段太远 pseudo_goal = episode[t]["next_state_pos"] for i in range(t): fake_done = (episode[i + 1]["state_pos"] == pseudo_goal) fake_r = 1.0 if fake_done else 0.0 out.append({**episode[i], "goal": pseudo_goal, "reward": fake_r, "done": fake_done}) return out说明:只从轨迹后半段采伪目标,是因为前半段离起点太近,重标注出来的任务太简单,学不到长程导航。伪样本和真实样本比例控制在 1:2,全用伪样本会让价值头的回报尺度失真——伪目标给的是 1,真实终点也是 1,但两者难度完全不同。实际做法是给伪样本的一个折扣权重,或者干脆只在训练早期用,后期逐步关掉。
这四条不是并列关系,是叠加关系:课程给难度、塑形给方向、重标注给样本量、对手池给对抗强度。缺哪条都能跑,但组合起来收敛最快。
5. 避坑:只判终点最容易翻车的五个地方
5.1 训练回报一直是零,但评估偶尔能到终点
现象很迷惑:训练日志里平均回报是 0.000,隔几十局蹦一个 1.0,评估跑一百局能进两三次。原因多半是熵系数和折扣因子配错了——γ=0.99让奖励传不回来,同时熵又给得太大,采样几乎纯随机,靠运气偶尔撞到终点。
解决办法:先把 γ 提到 0.995 以上,再确认 GAE 的 λ 不低于 0.9。还不动就把课程阶梯加进来,从 5×5 开始。判断标准不是「有没有一次到终点」,而是「最近 100 局的到达率是否单调上升」。我一般会给到达率单独画一条线,和回报分开看,回报在这类任务里信息量太低。
5.2 加了塑形之后模型学会绕圈刷分
塑形一开,回报曲线蹭蹭往上涨,看着很爽,但把它丢进评测环境直接归零。这是典型的奖励黑客:势函数用了曼哈顿距离,模型发现贴着对角线走可以持续缩小「距离」,却永远到不了终点。
解决办法有三条同时上:势函数换成 BFS 真实距离场;塑形系数随训练退火,后段基本关掉;评估时必须用不带塑形的裸奖励。第三条是后悔药,别指望靠肉眼看曲线判断有没有被黑客。
5.3 换了张地图,到达率从 0.6 掉到 0.05
现象是训练集地图上跑得很好,验证地图上直接崩。原因通常是观测里混进了绝对坐标或者地图尺寸相关的东西,模型记住了「终点在左上角附近」这类伪相关。这类 bug 不好查,因为训练日志上一切正常。
解决办法:观测构造函数里加一条断言,只允许白名单字段进入,坐标一律转成相对值,视野一律裁成固定边长。换图测试要当成回归测试的固定项,每次改完观测都跑一遍。
5.4 自对战打久了,两个智能体集体躺平
现象是对战胜率稳定在 50% 左右,看起来「均势」,但到达率一起往下掉。原因是双方都在优化「不输」,而不是「去终点」——在稀疏奖励下, conserving 策略比探索策略更容易获得局部最优。
解决办法:对手池里混入规则对手,占比别低于 30%;定期冻结快照,别让当前策略一直打自己;对局里加一个「超时双方都不给分」的设定,逼着模型在有限步内做决策。
5.5 本地能跑通,提交后成绩为零
现象是本地评测到达率 0.7,正式环境 0。原因一般是训练用到了评测侧不提供的信息,比如全局地图、对手的下一动作,或者依赖了某个只在本地存在的字段。这类问题在观测里藏得很深,因为环境不会报错,字段就是默默返回 None。
解决办法:写一个assert_obs_whitelist函数,在每个 episode 的开始跑一次,把所有字段和官方说明逐一对齐;另外在本地模拟一份「裁剪版环境」,只暴露评测允许的字段,日常训练就用它。多花半天做这事,能省掉一整轮提交名额。
6. 验证与收尾技巧:怎么确认它真会走,而不是碰巧走到
训练跑通之后,最危险的心态是「评估跑了几十局好像还行」。稀疏终点任务的随机性极大,几十局的到达率波动能到 ±0.2,根本没有区分度。我后来固定了一套评估口径,跑一次大概五分钟,但能挡掉绝大部分虚假提升。
第一个口径是固定种子回放集。每张地图生成 200 个固定随机种子的对局,种子表存成文件,版本管理起来。所有模型——包括改动前的基线——都在同一批种子上跑,跑完记录四个数:到达率、平均步数、路径效率(最优步数除以实际步数)、对规则对手的到达率。四个数一起看,避免「到达率涨了但路径效率掉一半」这种偷鸡式进步。
import json, numpy as np def evaluate(policy, seeds, env_factory, n_roll=1): metrics = {"reach": [], "steps": [], "efficiency": []} for s in seeds: env = env_factory() obs = env.reset(seed=s) done, steps = False, 0 while not done and steps < env.max_steps: a = policy.act(obs) # 贪心,不采样,减少方差 obs, r, done, info = env.step(a) steps += 1 metrics["reach"].append(1.0 if info["reached_goal"] else 0.0) metrics["steps"].append(steps) if info["reached_goal"]: metrics["efficiency"].append(info["optimal_steps"] / steps) return {k: float(np.mean(v)) for k, v in metrics.items() if v} # 种子表随代码一起提交,保证任何人跑出来的数一致第二个口径是轨迹快照回归。每次发现一个「能到终点」的好对局,把初始种子和完整轨迹存下来,放进回归集。后面每改一版模型,先跑这批种子,任何一条从「到」变「不到」都要停下来查。这比看曲线灵敏得多,稀疏任务里一条回归就可能是整批崩掉的前兆。
第三个技巧是关塑形复评。训练时开着的所有辅助信号,评估时一律关掉,只用裸奖励。这一步能立刻暴露奖励黑客,我踩过一次坑之后再也不敢省。
说句实话,这类赛题里最贵的不是算力也不是模型结构,是评估口径。口径不稳,你所有调参都是在噪声上做决策,改十版也说不清哪版真好。我现在的习惯是:任何一个新想法,先在固定种子集上跑一遍,四个指标都过线才允许进训练队列。慢,但省心。希望帮到你。
本文还有配套的精品资源,点击获取