# Step-Level Reward for Embodied Trajectory Planning: GIGPO + OPSD 的融合设计与实测分析 > **用途**:论文写作参考文档。完整记录「在已有整条轨迹 reward 的基础上,借鉴 GIGPO(双层优势)+ OPSD(偷看 GT 的代理信号)给出 **step-level reward**」这条线的:动机、方法推导、两种实现变体、token 对齐工程、以及**上机实测踩出来的全部坑与结论**。 > > 与同目录 `Step-Level-Reward-Design.md`(早期设计笔记)的区别:本文档**额外**包含了实测阶段(forward_proxy 失败、KL 塌平、KL 无法判别对错、自蒸馏坍缩、GPU/driver 工程问题)的完整分析——这些是论文 Analysis / Ablation / Discussion 章节的核心素材。 > > 代码落点:`verl/trainer/{step_reward.py, teacher_proxy.py, step_viz.py, core_algos.py, ray_trainer.py, config.py}`、`verl/workers/reward/{function.py, verify.py}`。 --- ## 0. 摘要式总览(一段话) 任务是**离散动作规划**(embodied planning):模型在一张图 + 指令下,**一次性**生成完整 `executable_plan`(一串 `{action_name, action_id}`)。我们已有**整条轨迹的标量 reward**(verify 模型判分,∈[−1,1]),用标准 GRPO 优化。本工作的目标是在轨迹级信号之外,加入**逐 step 的细粒度信用分配**: ``` A_token = A^E + ω · A^S_k ``` - **A^E**(episode 级)= 现有 GRPO:整轨标量 reward 在同 prompt 的 N 个 rollout 内组归一化,广播到所有 token。**不变**。 - **A^S**(step 级,新增)= 偷看 GT、逐 step 打分,居中后只填到该 step 对应的 token 段。 - **ω** = step 权重(默认 1.0)。 **方法本质 = 集 GRPO + GIGPO + OPSD 三家之长**(详见 §1.5):以 **GRPO** 的组内归一化轨迹优势为 `A^E`(免 value/critic),套用 **GIGPO** 的「episode + step 双层优势 `A^E + ω·A^S`」框架,但把 GIGPO 依赖环境的 step 信号替换成 **OPSD** 式「偷看 GT」的离线 step 信号,并以 **reward shaping(叠加在 advantage 上、而非 KL loss)** 的方式融入——得到一个**无 value、无 critic、无环境、单次整轨即可**的 step-level RL 方案。 最终的实测结论(详见 §7)是一个对论文很有价值的**对比**:**OPSD 式 KL 代理信号(forward_proxy)原本塌平,换未微调 teacher(`Qwen2.5-VL-3B-Instruct`)+ 挪到有 GPU 的 reward actor 后信号复活(std 1–2,§7.4)**——但 KL「分布相似 ≠ 动作正确」的固有问题仍未实测排除(§7.3);而**规则匹配(action_id 比对)是最稳的 step reward**,作主方法,forward_proxy 作软信号变体 / ablation 对照。**⚠️ 注意「换 teacher 是信号复活的关键」是新近实测才确认的(§7.4),论文别把功劳全记给方法本身,也别过度声称 forward_proxy 已 work——它现在「活」但正确性待验证。** --- ## 1. 问题设定与现状(已核实的事实) | 维度 | 现状 | |---|---| | 数据布局 | **一行 = 一整条轨迹的 response**(单次生成,非多轮 env rollout) | | 生成内容 | 完整 JSON:`visual / reasoning / language_plan / executable_plan`,其中 `executable_plan` 是 `[{action_name, action_id}, ...]` | | reward | **标量**,写在最后一个 token:`reward_tensor[i, L-1] = score["overall"]` ∈[−1,1](verify 模型判分) | | GRPO | `scores = token_level_rewards.sum(-1)` → 按 `index = uid`(同 prompt 的 N rollout)组归一化 `(R−mean)/std` → 广播到整行所有 token | | GT | 在 `non_tensor_batch["gt_action_close"]` → `trajectory_actions = [{action_id, action_name}, ...]`;dataset 另把 GT 重打包进 `ground_truth`(`dataset.py:316/567`) | | 优势估计器 | 注册表式分发,`compute_advantage` 在 driver 上跑(`ray_trainer.py`) | **核心约束**:reward / advantage 最终都是 `(bs, response_length)` 的 **token 级**张量,优化的是每个 token 的 logprob。所以「第 k 步该得多少分」必须落实到「第 k 步对应 response 里哪几个 token」。 --- ## 1.5 方法定位:集 GRPO + GIGPO + OPSD 三家之长 本工作不是某一家的改良,而是**把三种方法各自的精华抽出来拼成一个适配「单次整轨预测 + 离散正确性」的方案**。先用一张表说清「我们从每家拿了什么、丢了什么」: | 来源 | 它的核心机制 | **我们取(精华)** | **我们丢(不适配处)** | |---|---|---|---| | **GRPO** | 同 prompt 的 N 个 rollout 组内归一化轨迹标量 reward `(R−mean)/std` 当 advantage,**免 critic/value** | **整套 episode 级优势 A^E**(我们已有的 verify 轨迹 reward 正是走这条),作为骨架直接当 `A^E` | 它**只有轨迹级一个标量、广播到所有 token** → 无法区分轨迹内部哪步对哪步错(信用分配太粗) | | **GIGPO** | episode + step **双层优势** `A = A^E + ω·A^S`,step 级靠「相同环境状态 anchor_obs 分组、组内比 return」造优势,**免 value** | **双层优势的框架 `A^E + ω·A^S`**:在轨迹优势之外再叠一层 step 优势,这正是我们要的细粒度信用分配 | 它的 step 信号依赖**多轮环境交互 + 反复路过同一状态**;我们是**单次整轨、无环境**,借不了 anchor-state 分组与折扣回报 | | **OPSD** | teacher **偷看 GT** 做 forward,用 teacher−student 的 token logits 差(KL)当稠密监督信号 | **「偷看 GT 拿 step 级代理信号」的思想**,以及同模型两次 forward 的实现机制(forward_proxy 变体) | 它把 KL 当**直接 loss**、摊到**每个 token**;我们改成 **step 级、折算成 reward 叠加(而非 loss)**,并躲开格式 token(§2、§5.5) | **一句话定位**: > **以 GRPO 的组内归一化轨迹优势为 A^E(免 value),套用 GIGPO 的「episode + step 双层优势」框架,但把 GIGPO 依赖环境的 step 信号替换为 OPSD 式「偷看 GT」的离线 step 信号——再把这个 step 信号以 reward shaping(叠加在 advantage 上、而非 KL loss)的方式融入,得到一个无 value、无 critic、无环境、单次整轨即可的 step-level RL 方案。** 对应到公式,三家正好各占一块: ``` A_token = A^E + ω · A^S └─ GRPO ─┘ └ GIGPO 框架 + OPSD/规则 信号 ┘ 组内归一化轨迹优势 在轨迹优势上叠加的 step 级优势 (免 value, 已有) (GIGPO 的双层结构, 信号来自偷看 GT) ``` > 论文用法:这一节可直接作为 Method 开篇的「我们的方法 = best of three」定位段;§4、§5.5、§10 是它的展开与论证。 --- ## 1.6 核心 idea 与动机链条(论文 Introduction 主线) > 这一节是论文 Introduction 的主线叙事——一条「现有工具各有短板 → 受 OPSD 启发提出核心 idea → 实测发现并解决关键障碍」的逻辑链。前面 §1.5 讲「方法是什么(三家之长)」,这一节讲「**为什么会想到这么做**」。 **出发点**:我们想要 **step 级的细粒度信用分配**(轨迹级 reward 太粗,分不清同一条轨迹里哪步对哪步错)。但现成的两个工具各有短板: 1. **GIGPO 没法很好地判断每一步的对错**。GIGPO 的 step 优势来自「相同环境状态下、不同动作比 return」,它依赖**多轮环境交互、反复路过同一状态**;本任务是**单次整轨预测、无环境**,根本没有「同状态重复」可比,**借不到它的 step 判别能力**。 2. **严格的规则匹配(action_id 逐位比对)虽然干净,但可能带来困惑**。它是硬 0/1:一步**语义对、但 id/位置对不齐**就被判错;只有「对/错」没有「有多对」;对顺序/措辞敏感、脆。对「一串离散决策」它能给骨架,但**缺乏对「接近正确」的容忍与分级**。 **核心 idea(本工作的 novelty)**:既然两条都不够,**受 OPSD 的 KL 信号启发——用 teacher 与 student 的 logits 对比所产生的「波动」,去对 reward 做 shaping(引导/加权),看能否影响性能**。具体做法: ``` OPSD 式 logits 对比(teacher 偷看 GT vs student 没看) → 逐 token 的 logp 差(波动) → 按 step 区间聚合 + squash 到 [−1,1] → 作为 step 级的软信号,对已有轨迹 reward 做 shaping:A = A^E + ω·A^S ``` 这就是核心 idea:**不把 KL 当 loss、也不靠硬规则,而是把 OPSD logits 对比的波动当成一个软的 step reward,叠加(shape)在轨迹 reward 上**——既比 GIGPO 更能定位到「这一步」,又比规则更软、能给分级/部分信用。 **实测发现的关键障碍 + 解法(这是故事的转折,也是论文该讲的 insight)**: - **障碍**:一开始 teacher 用的就是 student 自己(policy)。但 **student 经过 SFT + RL,对自己生成的轨迹太自信了**——它对自己采样出来的每个 token 概率都很高,加不加 GT 几乎不变 → **logits 波动极小(塌平)**,shaping 信号近乎为 0。 - **解法**:把 teacher **换成通用模型**(`Qwen2.5-VL-3B-Instruct`,没在这些轨迹上微调过)。**效果一下子就 work 了**——实测 `teacher−student logp diff` 的 std 从 ≈0 升到 **1–2**(§7.4)。 - **为什么换通用模型有效?** 两个层面: 1. **没过拟合到自己的轨迹** → 不像 policy 那样对自己 token 饱和自信,GT 一进上下文分布才会动(这是「信号能不能存在」的必要条件); 2. **通用模型的推理 / 判断能力更好** → 当它读到 GT 参考计划时,能更好地理解任务、对「该这么走」的 token 给出有意义的信念变化 → 波动**更可能朝正确方向**(这是「信号有没有用」的关键)。 > ⚠️ 诚实标注:第 2 点是我们对「为什么 work」的解释/假设。目前**已确认的是信号复活(波动出现,std 1–2)**;但「波动是否正确地奖对惩错」**尚未实测排除**——§7.3 里用通用文本模型(Qwen3-1.7B)做 demo 时,似是而非的错步仍拿到了正分。所以「通用模型判断更好 → 信号正确」是合理假设,但要在实际 VL Instruct teacher 上验证(看错步 A^S 是否真被压负),不能直接当结论。 > 论文用法:这一节就是 Introduction 的故事线——「GIGPO 判不准每步 + 规则太硬 → 用 OPSD logits 波动做 reward shaping(核心 idea)→ student 过自信致信号塌平 → 换通用模型(判断力更强)救活」。§2 是它的展开(为什么不用 KL-as-loss),§7 是实测证据。 --- ## 2. 动机:为什么不直接用 OPSD(logits / KL 蒸馏) 我们先在 `OPSD_V2_Embody` 上做过 logits/KL 蒸馏(teacher 偷看 GT,对比 teacher 与 student 的 token logits 得 KL,用作监督)。可视化调试时发现 KL 对 embodied planning **不合适**,这才转向 step reward。理由五条(前两条有实测热力图证据): 1. **KL 优化「分布像不像」≠「动作对不对」**。一条流畅但走错(该去 Sink 却去 Table)的轨迹,KL 可以很低。 2. **格式 token 抢梯度**。`find` / `a` / `the` / JSON 骨架 `{"action_id":` 占了绝大多数 token 却无决策信息,KL 把梯度浪费在上面(热力图实测:红色集中在 JSON 骨架,内容反而发白;clip 0.05 只是补丁)。 3. **决策单位是 step,KL 却摊到每个 token**。「去 Sink 还是 Table」是**一个决策**,不该被切成十几个 token 各自匹配。 4. **privileged-info leakage**:teacher 偷看答案,存在把 GT 信息直接泄漏进梯度的风险。 5. **KL 信号难解读**:红越多 ≠ 越好;大片黄/白究竟是收敛还是信号塌掉,分不清;正负、clip 都让解读困难。 > 论文用法:这一节是 Related Work / Motivation 里「为什么需要 step reward 而非 distillation」的直接论据。第 1、3 条尤其是 embodied 任务的本质论点。 --- ## 3. logits(KL)vs step reward:两个范式的根本区别 | | logits / KL(OPSD) | step reward(本工作) | |---|---|---| | 信号密度 | 每 token 一个完整词表分布,极稠密 | 每 step 一个标量,稀疏粗糙 | | 性质 | 监督式 teacher-forcing,**低方差、稳、样本高效** | RL,要采 N 条 + 组归一化,**高方差** | | 教什么 | 分布相似度(含置信度/备选项/calibration) | **动作对/错** | | 依赖 | 要 teacher(或自蒸馏),不需要 verifier/GT 对齐 | 要可靠的 GT 逐 step 匹配 | | 适合 | 没有可靠 step verifier、想稠密 bootstrap 基础能力 | **有 GT、任务是「一串离散正确决策」** | **结论**:两者**互补、常常串联**——KL/SFT 先把基础能力和格式喂出来(稠密、稳),再用 step reward RFT 做探索 + 细粒度纠错(直接对正确性)。embodied planning 有干净的 `action_id` GT、本质是离散正确决策,**step reward 直接命中目标,而 §2 列的 KL 缺点在此任务上全被放大**。 --- ## 4. GIGPO 怎么做 step reward,以及为什么不能照搬 参考实现:`verl-agent` 的 `gigpo/core_gigpo.py`。它的 step reward **不是跟 GT 比对**,而是「**同状态下比 return**」: 1. **数据单位**:每行 = 一个 step transition(一次 env turn)。 2. **环境给即时奖励** → `compute_step_discounted_returns`(`core_gigpo.py:87`):按 `traj_uid` 分组做 γ-折扣回报 `R_t = r_t + γ·R_{t+1}`。所以 GIGPO 的「step reward」其实是**该步往后的折扣回报**,不是 0/1 对错。 3. **anchor-state 分组(GIGPO 的灵魂)** → `build_step_group`(`:243`):在同 prompt 的 N 条轨迹里,把落到**相同环境状态(anchor_obs 相同)**的 step 聚成一组(Eq.6「same state, different action」)。 4. **同状态内归一化** → `step_norm_reward`(`:334`):组内 mean(-std) 归一化 → A^S,**无需 value network**。 5. **合并** → `compute_gigpo_outcome_advantage`(`:138`):`A = A^E + ω·A^S`。 **为什么不能直接套到我们这里:** | | GIGPO | 我们的设定 | |---|---|---| | 一行是什么 | 一个 step(独立 prompt+response) | **一整条轨迹一个 response**,step 是 response 内的 token 段 | | step reward 来源 | 环境即时奖励 → 折扣回报 | **GT 逐 step 匹配** / OPSD 代理 | | step 分组依据 | 相同环境状态 `anchor_obs` | **相同 step 位置 k** | | 要不要环境 | 要(多轮交互) | **不要**(离线、一次生成) | | advantage 广播 | 标量 × 整行 `response_mask` | 一条 response 内**不同 token 段不同 advantage** | GIGPO 的 anchor-state 分组依赖「多条轨迹反复路过同一状态」这个多轮交互事实,我们**一次性预测整轨、没有环境**,借不了。**真正可复用的只有最后一层** `A = A^E + ω·A^S` 的「双层优势归一化 + 组合」骨架;reward 来源换成 GT 匹配 / OPSD 代理,分组换成 step 位置 k,并且要把标量广播改成**分段广播**。 > 论文用法:这是 Method 里「我们与 GIGPO 的关系」一段——**借其双层优势框架,但因无环境而重写信号来源与分组**。 --- ## 5. 核心概念:step reward 怎么转成 advantage(A = reward − baseline) 这是整个设计最关键的概念点,也是最初卡住的地方。 **一句话:A = reward − baseline,两种 reward 唯一的区别是 baseline 取什么。** - **轨迹 reward 是裸标量**(success=1、语义分=0.7)。单看一个数不知道好坏,**必须**拿同 prompt 的 N 个 rollout 的均值当 baseline → `(R−mean)/std`(GRPO 组归一化)。**没有零点,所以必须组内比较。** - **step reward(GT 匹配)是绝对校准过的**:这一步 == GT → 对,!= → 错。**「对」该 push up,「错」该 push down,本身就有零点**,不需要跟别人比。所以最简单的转法就是**直接居中**: ``` match → +1 mismatch → −1 # 这已经是合法的 step advantage ``` 或 `A^S_k = r_k − 0.5`(r_k∈{0,1}),或 `r_k − 该轨迹所有步的均值`。**不需要 N 条对齐、不需要 std。** **数值示例**:GT = [find Ladle, pick Ladle, find Sink],预测 = [find Ladle ✓, pick Ladle ✓, find Table ✗]: | step | tokens | GT 匹配 | A^S | |---|---|---|---| | 1 | "find a Ladle" | ✓ | **+1** | | 2 | "pick up the Ladle" | ✓ | **+1** | | 3 | "find a Table" | ✗ | **−1** | 把 A^S 广播到各自 step 的 token 段,scaffolding token 给 0;A^E 是整轨标量组归一化后广播全 token。相加:即使整条 A^E 为正,**第 3 步那段 token 也会被 −1 拉下来** → 实现「同一条轨迹里,对的步强化、错的步惩罚」。 **可选升级(GIGPO 式组内归一化)**:若想进一步降方差,A^S 也可组内归一化,但 baseline 是「N 条轨迹在同一 step 位置 k」: ``` A^S_k^(i) = (r_k^(i) − mean_j r_k^(j)) / (std_j + eps) # j 遍历 N 条同 prompt rollout ``` 代价是要按 step 位置 k 对齐 N 条轨迹。简单起见可先不做,跑通再加。 --- ## 5.5 关键设计选择:为什么是「KL 信号叠加在 reward 上」 这是整个方法的 design choice,也是论文 Method 必须先讲清楚的论证。我们面对的是一个**三选一**:同样想用「OPSD 偷看 GT 的 KL 信号」,可以有三种用法—— - **(A) 直接 KL loss**(标准 OPSD / 知识蒸馏):`L = L_PG + λ·KL(teacher‖student)`,把 KL 当一个**独立的监督损失**加在策略梯度旁边。 - **(B) 规则匹配当 step reward**:`action_id` 逐位比对 → 硬 ±1,作为 advantage。 - **(C) 把 KL 折算成 step reward / advantage 叠加**(**本工作选的**):`A_token = A^E + ω·A^S`,`A^S = squash(logp_teacher − logp_student)` 逐 step。 下面分别说明 C 相对 A、相对 B 的动机。**核心一句话**:用户的本意是「**用 KL 来微调已有的轨迹 reward**」(原话,2026-05-31 18:22)——不是替换主目标,不是再开一个模仿损失,而是在**已有 reward 之上做 step 级的细粒度信用再分配**。这天然指向 C。 ### 5.5.1 C 相对 A(KL-as-reward vs KL-as-loss):为什么不直接拿 KL 当 loss | | (A) 直接 KL loss | (C) KL 叠加在 reward 上 | |---|---|---| | 目标个数 | **两个**:on-policy 策略梯度 + off-policy 监督蒸馏,性质/尺度不同,要靠 λ 小心平衡,易一个压过另一个 | **一个**:只有策略梯度;KL 折进 advantage,ω 只调它在优势里的占比,不和主目标打架 | | 对 policy 的作用 | 在**每个 token** 上直接最小化与 teacher 的散度 = **强行模仿** teacher 分布 | 只给 policy **自己采样出来**的 token 重新分配信用(哪步多加/扣分),policy 仍优化自己的轨迹 | | teacher 出错时 | policy 被拉向 teacher(哪怕 teacher 也错),**无探索** | KL 只调「轨迹内部各步的相对权重」,**探索结构不变** | | 和轨迹 reward 的关系 | 独立损失,**挂不到**已有的 verify/成功 reward 上 | **就是在微调轨迹 reward**:主 reward(verify 轨迹标量)回传不变,KL 只做步级再分配 | | 稳定化 | 裸散度项,无 baseline/clip | 走 PPO advantage 通道,**自动享受 GRPO 组归一化 + clip** | | 可消融 | 改 λ 改变整个目标构成,不模块化 | **ω=0 即退回纯 GRPO**,是干净的 ablation 轴 | | 格式 token | KL 摊到每个 token,**format token 抢梯度**(§2.2) | A^S 只填决策 token 段,scaffolding=0,**主动规避** | **一句话**:KL-as-loss 是「再开一个模仿目标和 RL 抢方向盘」;KL-as-reward 是「让 RL 还是那个 RL,只是把每步该拿多少优势调得更准」。后者保留探索、不被 teacher 带偏、挂在已有轨迹 reward 上、且模块化可关——完全契合「微调轨迹 reward」的本意。 ### 5.5.2 C 相对 B(KL 软信号 vs 规则匹配):为什么想要软的、而非硬 ±1 这是**动机**(注意 §7 给出的实测结果与动机相反,见下方 caveat): 1. **软、分级、给部分信用**:rule 是硬 ±1(非对即错),KL 是连续值,能表达「这一步有多对 / 偏多少」,对「接近但不完全对」的步给中间分,梯度更平滑。 2. **不需要精确的离散 GT 对齐**:rule 要 `action_id` 严格相等;当 action 措辞对但 id 不同、或任务根本没有干净标准 id 时,rule 会误判。KL 只要 teacher 能 condition 在 GT 上,**不做 id 比对**,理论上容忍措辞差异。 3. **复用已有 OPSD teacher-forward、无需可靠 step verifier**:在没有干净 step 级 verifier 的任务上,KL 是更通用的拿 step 信号的方式(不用为每个任务设计/训练一个 step 判分器)。 > **⚠️ 诚实 caveat(与动机相反的实测结论,见 §7)**:以上是「为什么想用 KL 代替 rule」的*动机*;但**本任务的 action 恰好是 0–207 的干净离散 id,rule 的前提完全满足**,而 KL 在实测中两头落空——自蒸馏塌平(§7.4)、且即便有波动也判不出对错(§7.3,似是而非的错步拿正分)。所以**最终 rule 胜出**。 > > 结论可写进论文:**当任务具备干净的离散 GT 时,rule 直接命中正确性,KL 的「软/通用」优势变不成实际收益,反而暴露「分布相似 ≠ 动作正确」的固有缺陷;KL(forward_proxy)的真正适用区间是「没有干净 step GT、只能要软信号」的任务**,在本工作中作为 ablation 对照存在。 ### 5.5.3 小结:三者定位 | 方案 | 本质 | 何时用 | |---|---|---| | (A) KL loss | 模仿 teacher 分布(再开一个目标) | 纯蒸馏 / SFT 打底阶段,不和 RL 混 | | (B) rule step reward | 硬正确性信用(±1) | **有干净离散 GT(本任务)→ 推荐** | | (C) KL step reward(本工作的 forward_proxy) | 软信用、微调轨迹 reward、单一 RL 目标 | 没有干净 step GT、要软信号;本任务作对照 | --- ## 6. 两种 step reward 变体 我们实现了**两套可由 config 一键切换**的 step reward 来源,共用同一个 `grpo_step` 估计器和同一套 token-span 对齐: ### 6.1 变体 A:规则匹配(rule) `compute_step_match_rewards`:预测每步的 `action_id` 与 GT 对应步逐位比较。 ``` r_i = 1 if pred_step_i.action_id == gt_step_i.action_id else 0 A^S = +1 (对) / −1 (错) / 0 (scaffolding) ``` 三种匹配口径:`exact`(严格逐位)/ `prefix`(最长正确前缀)/ `lcs`(最长公共子序列)。默认 `exact` + `action_id`。 - **优点**:100% 可靠、零额外开销、永远不平、真·对错。 - **适合**:action 空间离散、有标准 id(本任务正是 0–207 离散 id)。 ### 6.2 变体 B:OPSD 式 forward_proxy(teacher forward 代理信号) 不做 id 比对,而是**把 GT 喂给 teacher 当条件(偷看答案),对学生轨迹做 forward,从 logp 里按 step 区间聚合代理分数**。演化经历了三个版本(见 §7,对论文是关键 ablation): **最终正确形态 = OPSD 的同模型两次 forward**: ``` teacher context = image + [Reference Plan Begin/End 包住 GT] + 衔接语 + pred student context = image + [Problem + 指令] + pred (无 GT) A^S_k = squash( mean_{t∈step_k} [ logp_teacher(x_t) − logp_student(x_t) ] ) ``` - **同一个模型、两种 context**(不是两个不同模型),在**同一段 pred token** 上比 logp——这才是干净的 OPSD KL。 - `squash` 压到 [−1,1] 两种口径(config `step_squash`): - `prob` = `2·exp(mean teacher_logp) − 1`(teacher-only,**实测恒平,见 §7.1**) - `contrast` = `tanh(mean(teacher_logp − student_logp))`(**唯一有意义的口径**) - 模板逐字 copy 自 `OPSD_V2_Embody/data_collator.py`。 > **最终建议(来自实测)**:embodied planning 用 **rule**;forward_proxy 仅作为论文 ablation 的对照(展示 KL 为何不适用)。 --- ## 7. 实测分析(论文 Analysis / Ablation / Discussion 的核心素材) 这一节是把 forward_proxy 路线**一步步证伪**的完整过程。每一个坑都是一个独立的 finding。 ### 7.1 Finding 1:`prob`(teacher-only)模式天生是平的 teacher 条件在 GT 上、又是同一个模型,对学生写的**每个** token 概率都很高 → A^S ≈ +1 处处一样 → **无波动**。 **结论**:信号必须来自 student 与 teacher 的**差异**(KL),不是 teacher 的绝对概率。→ 改用 `contrast`。 ### 7.2 Finding 2:contrast 必须是「同模型两次 forward」,不能混 `old_log_probs` 第一版 contrast 错误地用「单独加载的 teacher 模型」 vs 「`old_log_probs`(live policy)」——**两个不同模型 + GT 混在一起,不是干净 KL**,所以也平。 修正:回到 OPSD 本意——**同一个模型**,student context(看 problem)vs teacher context(看 GT + 衔接语),同一段 pred 上比 logp。文本模型(Qwen3-1.7B, CPU)验证:diff 范围 [−12,+13]、std≈3.8,**确有强波动**。 ### 7.3 Finding 3(核心负结果):KL 无法判别「对错」——似是而非的错步拿正分 用真实文本模型跑 OPSD 两次 forward,逐 step 看 KL: | step | 对/错 | KL Δ | stepR | 问题 | |---|---|---|---|---| | find a Ladle | ✓ | +4.23 | +1.00 | 对 | | pick up the Ladle | ✓ | +2.22 | +0.98 | 对 | | **find a Sofa** | **✗(该是 DiningTable)** | +4.29 | **+1.00** | ❌ **错步却 +1** | | find a Sink | ✓ | +3.72 | +1.00 | 对 | | turn on the Faucet | ✗ | −0.40 | −0.38 | 勉强负 | 「find a Sofa」是错的,但 teacher 读了 GT(含「find a DiningTable」),觉得「find a 家具」这个**套路**合理 → 给 +1。**KL 衡量的是「知道 GT 后这个 token 变得多可信」,不是「对不对」**;只有结构上完全跑偏的(turn on Faucet)才勉强为负。这就是 §2 第 1 条「分布相似 ≠ 动作正确」用真实模型的证实。 ### 7.4 Finding 4:换 teacher 是 KL 信号「从塌平到复活」的关键(已实测证实) **问题(teacher = policy 时)**:真实 run 里 policy 是 **RobotGPT-R1,在这些轨迹上微调过 → 对自己生成的 token 极度自信** → teacher(=同模型+GT) ≈ student → 差 ≈ 0 → **advline 完全平**(自蒸馏坍缩)。没微调的 Qwen3-1.7B 才有波动;微调模型只会更平。 **修法 = 换 teacher**:teacher 改成**未微调的通用模型 `Qwen2.5-VL-3B-Instruct`**(policy 保持 RobotGPT 不变,tokenizer 同为 vocab 151665,response_ids 对齐没问题)。**为什么换通用模型有效(两个层面)**: - **① 没过拟合到自己的轨迹**(信号能否存在的必要条件):通用模型不像 SFT+RL 后的 policy 那样对自己 token 饱和自信,GT 一进上下文分布才会动 → 波动出现。 - **② 通用模型的推理 / 判断能力更好**(信号有没有用的关键):它读到 GT 参考计划时能更好理解任务,对「该这么走」的 token 给出有意义的信念变化 → 波动更可能朝正确方向。**注意**:换 teacher 后对比的是 `Instruct(看GT) − Instruct(不看GT)`,**整个信号现在完全压在这个通用模型的判断力上**(policy 的 logp 已不参与),所以它的推理能力直接决定信号质量。 **✅ 实测证实(这是 forward_proxy 信号复活的关键原因)**:脚本 `step_teacher_path=Qwen2.5-VL-3B-Instruct`(≠ policy 的 RobotGPT-R1)跑到 step 175,`step_adv_heatmaps/_diag.txt` 的 `teacher−student logp diff` **std ≈ 1.0–2.3、范围 ±15、mean ≈ +0.1~0.47**——**不再塌平**。Finding 4 的假设成立:**换未微调 teacher 是 KL 信号有波动的必要前提**。 **⚠️ 但三点必须说准(避免论文过度声称)**: 1. **换 teacher 不是当时 A^S=0 的直接原因**。当时 A^S **恰好等于 0** 的根因是 §7.6 的工程 bug(teacher forward 跑在无 GPU 的 driver 上崩进兜底)。**两个 bug 叠加**:换 teacher 治「塌平」+ 挪到 reward actor 治「崩溃」;只换 teacher 不挪卡照样恒 0。现在 std 1–2 是**两者都修好之后**的结果。 2. **「有波动」≠「判得出对错」**。std 1–2 只说明信号没死;§7.3 的核心负结果**换 teacher 治不了**——似是而非的错步(`find a Sofa` vs GT `DiningTable`)照样可能拿正分。波动是必要条件,不是「正确性信号」的充分条件。要证实这条,需拿「大部分对、个别错」的真实轨迹看错步 A^S 是否真被压负,光看 std 不够。 3. **语义变了**:teacher 换成独立 Instruct 后,对比的是 **`Instruct(看GT) − Instruct(不看GT)`**——**policy 自己的 logp 没进对比**。它不再是 OPSD 原本的「base/teacher vs policy」KL,而是「一个**冻结的外部裁判模型**的 KL-from-GT」。论文应明确这个设计区别。 ### 7.5 Finding 5:per-step 聚合的「填充词稀释」问题 对整段 action_name(如「find a Sofa」)**取均值**时,通用的「find a」把有区分度的物体词「Sofa」稀释了 → 错步被拉正。 **对策**:聚合**只取有区分度的决策 token**(物体名 / action_id 数字),或按 |teacher−student| 加权;别对整段平均。 ### 7.6 Finding 6(工程,非算法):driver 没有 GPU → forward_proxy 整段失败,A^S 恰好全 0 `_diag.txt` 的 traceback:`RuntimeError: No CUDA GPUs are available`。teacher forward 跑在 **driver 进程**,而 verl/Ray 里 **driver 无 GPU**(GPU 全在 worker actor 上),每个样本崩在 `.to("cuda")` → 走 except 兜底 → **A^S 恰好全 0**(呈现为「完全平的 0 线」,区别于「贴 0 的小噪声」)。 **修法**:把 teacher forward 挪到 **reward actor**(`function.py`,`num_gpus=0.3`,verify 模型就在那张卡)——即「复用 verify 那张卡」。新增远程方法 `compute_step_adv`,driver 用 `ray.get(reward_fn.compute_step_adv.remote(...))` 派活。rule 留在 driver(不需 GPU),forward_proxy 走 reward actor。 **新坑**:reward actor 要同时装 verify + Instruct teacher,0.3 卡上两个 3B bf16 ≈ 12GB 易 OOM;且重启多次老进程没杀干净会占卡 → vLLM 初始化 OOM。对策:`worker.reward.num_gpus=1.0`(独占一张)或 `worker.rollout.gpu_memory_utilization=0.5`。 ### 7.7 对照:verify(轨迹级 reward)是健康的强信号 同期把 verify reward(`Yes Logits`,∈[−1,1])画成曲线:2064 样本,mean=−0.37,**std=0.78(强波动,真信号)**,范围 [−1,+1],负样本占 ~70%(早期训练正常)。**这条轨迹级信号动得很厉害,正好反证 step reward 全平 = forward_proxy 坏了(GPU/OOM)+ KL 本身不适用,而非任务无信号。** ### 7.8 总结论(写进 Discussion) > **forward_proxy 信号现状(已更新)**:经两处修复——① 换未微调 teacher(治自蒸馏塌平,§7.4)+ ② 挪到有 GPU 的 reward actor(治崩溃恒 0,§7.6)——forward_proxy 现在**产出活信号**(std 1–2,非塌平,跑到 step 175)。所以**不能再说「KL 必然塌平/不可用」**;准确说法是「**teacher=policy 时塌平;换冻结外部 teacher 后信号复活**」。 > > **但目标与工具的错配仍在**:KL 衡量的是**分布相似**,不是**动作正确**。即便信号活了(有波动),§7.3 的问题——似是而非的错步可能拿正分——**换 teacher 治不了**,目前**尚未实测排除**。 > > **当前取舍**:**规则匹配(action_id 比对)是最稳的 step reward**(永远不平、真·对错、零开销、不占卡),作主方法;**forward_proxy(Instruct teacher)作为软信号变体 / ablation 对照**——它现在是「活的对照」而非「坏掉的对照」,但其正确性(错步是否真被压负、是否提升下游成功率)仍待验证。 --- ## 8. Token-span 对齐(唯一真正的工程量) reward 易分配 ≠ token 易对齐。`executable_plan` 给了**语义**的 step 切分,但模型生成的是 **token 序列**,JSON 元素边界与 token 边界不重合。需要 `map_steps_to_token_spans(response_ids, gt_steps) -> [(start,end,r_k), ...]`(约 40 行): 1. decode response 文本,容错 `json.loads` 拿 `executable_plan` list; 2. 对**实际 response_ids** 逐 token 增量 decode,建每 token 的 `[char_start, char_end)` 偏移表; 3. 对每个 step k,在文本里**顺序定位**子串(限定在 `"executable_plan"` 之后的区间,避免撞 `language_plan`),得字符区间; 4. 偏移表把字符区间映射回 token 下标区间 = step k 的 token span。 **4 个边界(务必处理)**: 1. **顺序定位,别用全局 find**——action_name 会重复(如 3 次 "pick up the Ladle"),必须从上一步结尾往后找,否则错位。 2. **边界 token 跨元素**——`},\n{` 常被合成一个 token,同时贴前后两个 step;定规则(归前一步 / 当 scaffolding 不给 step 优势)。 3. **scaffolding token**(`[`、`]`、`"action_name":`、`"action_id":`、逗号、空格、id 数字)**只给 A^S=0**,只把 action 内容 token 算进 step——正是要躲开「格式 token 抢梯度」。 4. **非法 JSON 兜底**——RL 早期生成可能不是合法 JSON,解析失败的样本整条退回纯 episode 优势(A^S 全 0),不能崩整个 batch。 5. **稳健降级**:token 对齐用「decode→重编码 round-trip 校验」,重编码不一致的样本安全跳过 A^S(=0),不错位不崩。 --- ## 9. 实现细节(改了哪些代码、每处怎么改、为什么这么接) > 这是本文档的核心之一。整套改动的设计原则是:**A^E 那条(GRPO + verify 轨迹 reward)一行不动;A^S 作为一个 `(bs, response_length)` 的逐 token 张量 `step_adv_raw` 旁路注入 batch,在 `compute_advantage` 时与 A^E 相加。** 这样 reward worker 的「单标量回传」不被破坏,新逻辑全部模块化、可由 `ω=0`/估计器切换关掉。 ### 9.0 端到端数据流(一图看清调用链) ``` ┌──────────────────────── DRIVER(无 GPU)────────────────────────┐ rollout (n>1) ──► verify reward ──► reward_tensor[i, L-1] = score ──► (A^E 这条不变) │ (轨迹标量 [-1,1]) │ │ if adv_estimator == grpo_step: │ gt_field = 选 (step_gt_field / gt_action_close / ground_truth) 第一个存在的 │ step_adv = self._build_step_adv(batch, gt_field) # ← A^S 入口 │ ├─ kind="rule": build_step_adv_raw(...) 本地算(无需 GPU) │ └─ kind="forward_proxy": │ ray.get( reward_fn.compute_step_adv.remote(batch, ...) ) ──────────┐ │ batch.batch["step_adv_raw"] = step_adv │ │ 打印 "[grpo_step] kind=... N/M responses got non-zero step advantage" │ │ │ │ compute_advantage(batch, adv_estimator=grpo_step, step_advantage_w=ω) │ │ → compute_grpo_step_outcome_advantage: │ │ A^E = compute_grpo_outcome_advantage(...) # 组内归一化轨迹优势 │ │ A = A^E + ω · (step_adv_raw * response_mask) │ │ _save_total_adv_heatmaps(batch) # 存 *_total.png / *_advline.png │ │ │ │ └───────────────────────────────────────────────────────────────────────────── │ ──────┘ │ ┌────────────── REWARD ACTOR(有 GPU = verify 那张卡, num_gpus=0.3)─────────▼──────┐ │ compute_step_adv(batch, teacher_path, squash, ...): │ │ lazy-load TeacherLogpScorer(teacher_path) # 同 policy 的 Qwen2.5-VL │ │ for i in batch: │ │ t_logp, s_logp = scorer.logp_teacher_student(instr, gt, image, resp_ids) │ │ └─ step_logp_vl ×2(teacher 看 GT / student 看 problem) │ │ out[i] = build_step_adv_vector_from_logp(resp, t_logp, s_logp, squash) │ │ return out.cpu(), diag_str # diag 写进 _diag.txt │ └─────────────────────────────────────────────────────────────────────────────────┘ ``` **为什么 rule 在 driver、forward_proxy 在 reward actor**:rule 只需 tokenizer + 字符串比对,driver 上即可;forward_proxy 要跑 teacher 的 VL forward **需要 GPU,而 verl/Ray 里 driver 进程没有 GPU**(GPU 全分给 worker actor)——这是 §7.6 实测踩出来的坑,所以把它派给本来就有卡、且跟 rollout 错开的 reward actor(verify 模型就住在那)。 ### 9.1 `verl/trainer/step_reward.py` — 自包含的打分核心(已单测) 整个「step → token → 分数」的纯逻辑都在这,**不依赖任何 reward manager**。关键函数: - **`extract_plan_steps(text)`** — 容错解析生成串里的 `executable_plan`:先 `_strip_suffix` 去掉尾部 `<|im_end|>`/`<|endoftext|>`,再剥 ```json 代码块,`json.loads` 失败时**用花括号配对**兜底抽第一个顶层 `{...}`;还兼容模型直接吐 bare list。返回 step list 或 `None`。 - **`gt_steps_from_close(gt_close)`** — 把 `gt_action_close` 解析成 `trajectory_actions` list(兼容 list / dict / JSON str)。 - **`compute_step_match_rewards(pred, gt, mode, key)`**(规则打分)— 按 `key`(默认 `action_id`,缺失回退 `action_name`)逐步比对,三种 mode: - `exact`:严格按位置 `pred[i]==gt[i]` → 0/1; - `prefix`:最长正确前缀(第一个错之后全 0); - `lcs`:DP 求最长公共子序列,标记参与对齐的预测位置。 - **`pred_step_token_indices(response_ids, valid_len, tokenizer)`**(**token 对齐的唯一真源**,rule 和 forward_proxy 共用): 1. decode 出文本、`extract_plan_steps` 拿步列表; 2. `_token_char_offsets`:decode→**重编码 round-trip 校验**(`tokenizer(text, return_offsets_mapping=True)`),若重编码 token 序列 ≠ 原 ids 则返回 `offsets=None` → 该行**安全跳过 A^S**(稳健降级,不错位不崩); 3. 对每步的 `action_name` 在文本里**从上一步结尾 `cursor` 往后顺序定位**(避免 action_name 重复时全局 find 错位,§8 边界1),字符区间再用 offset 表映射成 token 下标 `idxs`。返回 `(pred_steps, spans)`。 - **`build_step_adv_vector(...)`**(规则 A^S):对每步 span,匹配→`+1`、不匹配→`−1`,scaffolding/解析失败→`0`,填进 `(full_len,)` 张量。 - **`build_step_adv_vector_from_logp(..., teacher_logp_row, student_logp_row, squash)`**(forward_proxy A^S,**[−1,1] clamp**):对每步 span 内的 token logp 聚合: - `squash="contrast"`(**唯一有意义**):`A^S = tanh( mean_t (teacher_logp_t − student_logp_t) )`,缺 `student_logp` 时自动退回 prob 并失去波动; - `squash="prob"`(**实测恒平**,仅对照):`A^S = 2·exp(mean teacher_logp) − 1`。 - **`build_step_adv_raw(responses, response_mask, gt_close_list, tokenizer, ...)`** — 规则版的 batch 包装,driver 上直接调,返回 `(bs, response_length)`。 ### 9.2 `verl/workers/reward/verify.py` — 新增 `step_logp_vl`(保留视觉,仿 `ask_llm`) 在你原有的 verify `ask_llm` 旁边加一个**做 forward 取 logp**(而非 generate 出 Yes/No)的接口,复用同一套 `load_verify / load_processor / load_tokenizer`、同 dtype/attn: ``` step_logp_vl(model, processor, instruction, image, response_ids, device): messages = [{user: [image, text=instruction]}] # 视觉保留,和 ask_llm 一致 prompt_ids = processor(apply_chat_template(...), image) # (1,P),image token 已展开 input_ids = cat([prompt_ids, response_ids], dim=1) # (1, P+R) 把学生原 token 拼后面 position_ids = model.get_rope_index(input_ids, image_grid_thw, mask) # HF mrope 4D;失败回退 None logits = model(input_ids, attention_mask, position_ids, pixel_values, image_grid_thw).logits[0] pred_logits = logits[P-1 : P-1+R] # logits[t] 预测 token t+1 → response token r 由 logits[P+r-1] 预测 return log_softmax(pred_logits).gather(resp_ids) # (R,) 每个学生 token 的 logp,CPU ``` **关键对齐技巧**:把**学生原样的 `response_ids` 直接拼在 prompt 后面** → 模型对这些 token 的 logp 在 response 段和学生 token **1:1 对齐**,直接套上 §8 的 step span。**唯一没法本地验、需上机确认的点**就是 VL 的 `position_ids/pixel_values`(`get_rope_index` 那段,已加 try/except 回退)。 ### 9.3 `verl/trainer/teacher_proxy.py` — OPSD 式「同模型两次 forward」(§7.2 的修正版) `TeacherLogpScorer` 加载**一个**模型(= policy,Qwen2.5-VL),对**同一段 pred token** 跑两次 `step_logp_vl`: ``` logp_teacher_student(instruction, gt_text, image, response_ids): teacher_text = "Here is a reference successful language plan:\n=== Reference Plan Begin ===\n {GT}\n=== Reference Plan End ===\n{TRANSITION}" # 偷看答案 student_text = "Problem: {instruction}\n\n{STUDENT_INSTRUCTION}" # 不看 GT teacher_logp = step_logp_vl(model, ..., teacher_text, image, response_ids) student_logp = step_logp_vl(model, ..., student_text, image, response_ids) return teacher_logp, student_logp # 二者之差 = 干净的 OPSD KL(同模型,仅 context 不同) ``` 模板**逐字 copy 自 `OPSD_V2_Embody/data_collator.py`**(`STUDENT_INSTRUCTION` / `REFERENCE_BEGIN/END` / `TRANSITION`)。`policy_vocab_size` 不一致会 WARN(要求 teacher==policy 同 tokenizer 才能拼接对齐)。**这是 §7.2 的核心修正**:之前错用「独立 teacher 模型 vs `old_log_probs`」=两个模型混 GT;这里是「同一个模型、两种 context」=真 OPSD。 ### 9.4 `verl/workers/reward/function.py` — reward actor 上的远程方法 `compute_step_adv` active reward manager(`function.py`,reward actor `num_gpus=0.3`,verify 模型已在此卡)新增 `compute_step_adv(data, teacher_path, squash, gt_field, match_key)`: - **lazy-load** `TeacherLogpScorer(teacher_path, device="cuda:0")` 到 actor 自己的卡(`_teacher_scorer` 缓存); - 从 `non_tensor_batch` 取 `gt_field`(多 key 兜底)、`instruction`、`image`(兼容 `multi_modal_data`); - 逐样本两次 forward → `build_step_adv_vector_from_logp(squash)` → 攒成 `(bs, L)`; - **sample0 写诊断**:成功 `OK (teacher-student) logp diff: std=.. mean=.. min=.. max=..`,异常 `FAILED sample0 teacher forward: ` —— 这行写进 `step_adv_heatmaps/_diag.txt`(§7.6 就是靠它定位「driver 无 GPU」); - 返回 `(step_adv_raw.cpu(), diag)`,driver 用 `ray.get` 取回。 ### 9.5 `verl/trainer/core_algos.py` — 新优势估计器 `grpo_step` ``` AdvantageEstimator.GRPO_STEP = "grpo_step" @register_adv_estimator(GRPO_STEP) compute_grpo_step_outcome_advantage(token_level_rewards, response_mask, index, step_adv_raw=None, step_advantage_w=1.0): episode_adv = compute_grpo_outcome_advantage(token_level_rewards, response_mask, index) # A^E 完全复用 if step_adv_raw is None: return episode_adv, episode_adv # 无 step 数据 → 自动退化为纯 GRPO step_adv = step_adv_raw.to(...) * response_mask advantages = episode_adv + step_advantage_w * step_adv # A = A^E + ω·A^S return advantages, advantages ``` **设计要点**:A^E **直接调用现成的 `compute_grpo_outcome_advantage`**(不重写 GRPO),grpo_step 只是在它之上加一项;`step_adv_raw=None` 时**无损退回纯 GRPO**,所以即使 step 解析全失败训练也不会崩、只是少了 step 项。 ### 9.6 `verl/trainer/ray_trainer.py` — 驱动侧接线 - **`_build_step_adv(batch, gt_field)`**:分支 rule(driver 本地 `build_step_adv_raw`)/ forward_proxy(`ray.get(reward_fn.compute_step_adv.remote(...))`),算完顺手存 `*_stepReward.png` 热力图。 - **主循环**:`adv_estimator==grpo_step` 时,先选 `gt_field`(`step_gt_field → gt_action_close → ground_truth` 兜底,都没有则 WARN + 退纯 GRPO),算 `step_adv` 写进 `batch.batch["step_adv_raw"]`,打印 `N/M` 非零计数自检。 - **`compute_advantage`** 透传 `step_adv_raw`(`adv_inputs` 里带上)和 `step_advantage_w`。 - 算完 advantage 后 `_save_total_adv_heatmaps` 存 `*_total.png` / `*_advline.png`。 - `rollout.n>1` 的 GRPO 硬校验加上了 `GRPO_STEP`。 ### 9.7 `verl/trainer/step_viz.py` — 三种可视化 `save_step_adv_heatmap`:按 token 上色,`RdBu_r` + `TwoSlopeNorm(0 居中)` → **正→红 / 负→蓝 / 0→白**(§7 最后对调过一次配色),词上标数值、自动折行、带 colorbar;advline 两栏折线图(上:A^E 横线 + A 总线;下:A^S 单独线)。rule 和 forward_proxy **共用**(输入都是 adv 向量)。 ### 9.8 改动文件清单(速查) | 文件 | 改了什么 | 状态 | |---|---|---| | `verl/trainer/step_reward.py` | **新建**:解析 + 规则打分 + token-span 对齐 + 两种 A^S 构造 | ✅ 单测过 | | `verl/trainer/teacher_proxy.py` | **新建**:OPSD 同模型两次 forward(`logp_teacher_student`) | ⚠️ 对齐测过,VL forward 需上机 | | `verl/workers/reward/verify.py` | **新增** `step_logp_vl`(视觉保留,一次 forward 取 logp) | ⚠️ 需上机 | | `verl/workers/reward/function.py` | **新增**远程方法 `compute_step_adv`(reward actor 的卡上跑 teacher) | ✅ 接线完成 | | `verl/trainer/core_algos.py` | **新增** `GRPO_STEP` 枚举 + `compute_grpo_step_outcome_advantage` | ✅ 测过 | | `verl/trainer/ray_trainer.py` | `_build_step_adv` / 主循环接线 / `compute_advantage` 透传 / heatmap | ✅ | | `verl/trainer/step_viz.py` | **新建**:stepReward / total 热力图 + advline 折线图 | ✅ | | `verl/trainer/config.py` | `AlgorithmConfig` 新增 9 个 `step_*` 字段 | ✅ | ### Config 开关 ```yaml algorithm: adv_estimator: grpo_step # grpo → grpo_step step_reward_kind: rule # rule(推荐)/ forward_proxy step_advantage_w: 1.0 # ω # --- rule --- step_match_mode: exact # exact / prefix / lcs step_match_key: action_id step_gt_field: gt_action_close # 多 key 兜底:gt_action_close → ground_truth # --- forward_proxy --- step_squash: contrast # contrast(唯一有意义)/ prob(恒平,仅对照) step_teacher_path: <同 tokenizer 模型> # 如 Qwen2.5-VL-3B-Instruct step_teacher_device: cuda:0 # --- 可视化 --- step_heatmap_n: 4 # 每步存 N 条轨迹的图(0 关闭) ``` `rollout.n > 1` 是 GRPO 的硬要求(grpo_step 复用 GRPO)。 ### 自检日志 启动看:`[grpo_step] kind=... from 'gt_action_close': N/M responses got non-zero step advantage.` - `N/M` 接近满 → 解析+对齐正常;`0/M` → GT 字段/格式不符,调 `step_match_key` 或解析。 - forward_proxy 另看 `step_adv_heatmaps/_diag.txt`:`OK ... std=..` / `FAILED ... traceback`。 ### 可视化产物(每条轨迹三张,`step_adv_heatmaps/`) | 文件 | 内容 | 配色 | |---|---|---| | `*_stepReward.png` | step reward([−1,1]) | 正→红 / 负→蓝 / 0→白 | | `*_total.png` | 合并 advantage `A=A^E+ω·A^S` | 同上,标题带 `verify_reward=±x` | | `*_advline.png` | 上:A^E 横线 + A 总线;下:A^S 单独线 | — | > 注意:reward ∈[−1,1],**advantage(GRPO 归一化后)无界**。`*_stepReward` 画的是 [−1,1] 的 step reward;`*_total`/`*_advline` 画的是合并后的 advantage。verify 的轨迹标量 reward 不单独画(逐 token 均匀),归一化成 A^E 并进 total。 --- ## 10. 四家对比表:GRPO / GIGPO / OPSD / 本工作(写 Method / Related Work 直接用) | 维度 | GRPO | GIGPO | OPSD(logits/KL) | **本工作(grpo_step)** | |---|---|---|---|---| | 数据布局 | 整轨一行 | 每行一个 env step | 整轨 / 逐 token | 整轨一行,step 是 token 段 | | 信号粒度 | **仅轨迹级**(1 标量) | episode + step 双层 | token 级(KL) | **episode + step 双层** | | step 信号来源 | 无 | 环境即时奖励 → γ 折扣回报 | teacher−student token logits | **GT 逐 step 匹配(rule)/ OPSD 代理(fwd_proxy)** | | step 分组 | — | 相同环境状态 anchor_obs | 无(逐 token) | **相同 step 位置 k** | | 要环境 | 否 | **是**(多轮交互) | 否 | **否**(单次整轨) | | value/critic | 不要(组内归一化) | 不要 | 不要 | **不要**(复用 GRPO 组归一化) | | teacher | 不要 | 不要 | **要**(偷看 GT) | rule 不要 / fwd_proxy 要 | | 优势公式 | `A^E`(组归一化广播) | `A^E + ω·A^S` | KL 当 **loss** | **`A^E + ω·A^S`**(KL/规则当 **reward**) | | 信用分配 | 整轨同值(太粗) | step(状态对齐) | token(**格式 token 抢梯度**) | **step(按 token 段,scaffolding=0)** | | 适配本任务 | ⚠️ 粒度太粗 | ✗ 无环境借不了 anchor | ✗ 塌平+判不出对错(§7) | ✓ rule 干净;fwd_proxy 作对照 | **「集三家之长」的读法**(对应 §1.5): - **从 GRPO 拿** → 第「value/critic」「优势公式 A^E」两行:免 value 的组内归一化轨迹优势,直接当我们的 A^E。 - **从 GIGPO 拿** → 第「信号粒度」「优势公式」两行:`A^E + ω·A^S` 的 episode+step **双层结构**。 - **从 OPSD 拿** → 第「step 信号来源」「teacher」两行:**偷看 GT 拿 step 代理信号**的思想(forward_proxy 变体)。 - **本工作独有的取舍** → 第「要环境」「信用分配」「优势公式」三行:**无环境**(把 GIGPO 的环境 step 信号换成离线 GT 信号)、**step 而非 token**(躲开 OPSD 的格式 token 抢梯度)、**KL/规则当 reward 而非 loss**(§5.5)。 --- ## 11. 待定 / 可调参数 - **匹配口径**:`exact`(默认)/ `prefix` / `lcs`。 - **匹配键**:`action_id`(默认,离散纯净)/ `action_name`(同义不同字时)。 - **ω(step_advantage_w)**:默认 1.0。规则版 A^S=±1 与归一化后 A^E(≈±1)同量级,ω=1 平衡;forward_proxy 量级不固定,可能需调小或对 A^S 也做组归一化(**注意 §7.5 的尺度与稀释问题**)。 - **A^S 是否组内归一化**:默认否(rule 有零点不需要);要降方差再加。 - **teacher(forward_proxy)**:同模型自蒸馏(会坍缩,§7.4)vs 未微调 Instruct(有波动但语义改变)。 --- ## 12. 实验留白(本文档不展开) 本文档聚焦**动机与实现**,实验设计留给论文正文。仅记两个由 §7 实测自然得出的对照轴备忘:信号来源(rule vs forward_proxy vs 纯 GRPO)、信用粒度(step vs token vs trajectory);以及 ω 与聚合范围(整段均值 vs 仅决策 token,§7.5)两个敏感参数。可视化证据见 §9.7 的三类图。 --- ## 附录 A:关键公式速查 ``` # episode 优势(= 现有 GRPO,不变) A^E_i = (R_i − mean_{j∈group(i)} R_j) / (std + eps) # R = 整轨标量 reward, group = 同 uid 的 N rollout A^E_token = A^E_i × response_mask_i # 广播到整行 # step 优势(rule) r_k = 1[pred_k.action_id == gt_k.action_id] A^S_k = +1 (r_k=1) / −1 (r_k=0) / 0 (scaffolding) # 居中, 无需组归一化 A^S_token = A^S_{step(token)} if token∈某 step 的内容段 else 0 # step 优势(forward_proxy, contrast) A^S_k = tanh( mean_{t∈step_k 内容token} [ logp_teacher(x_t | GT) − logp_student(x_t | problem) ] ) # ∈(−1,1) # 合并 A_token = A^E_token + ω · A^S_token ``` ## 附录 B:本文档来源 整理自 2026-05-31 ~ 06-01 的设计与实测对话(session `3361bbf8`)。关键里程碑:12:40 读 GIGPO 文档 → 12:54 转向 EasyR1-V3-Embody 自有实现 → 13:21 写 grpo_step + rule → 16:40 转 forward_proxy → 17:15 squash[−1,1] → 18:22 发现 prob 平 → 18:30 改同模型两次 forward → 19:18 发现 KL 塌平 → 19:27 发现 KL 判不出对错 → 19:41 换 Instruct teacher → 20:30 定位 driver 无 GPU → 21:01 OOM 排查 → 06-01 verify reward 曲线对照。