MemER 方法原理讲解

MemER 方法原理讲解

1. 一句话理解 MemER

MemER 的核心思想是:

不要把机器人从开头到现在看到的所有画面都塞给模型,而是让一个高层视觉语言模型不断判断"现在该做哪个子任务"和"最近看到的哪些帧以后可能还要用",再把这些被挑出来的关键帧作为长期视觉记忆喂回模型。

它解决的是长时程机器人任务里的"我刚才看见过,但现在画面里没有"的问题。例如:

  • 机器人之前看过某个物体在右边箱子里,现在要找它时不应该重新翻左边箱子。
  • 机器人已经舀了两勺花生,当前画面只看到勺子,不一定能知道已经舀了几勺。
  • 机器人把架子上的物体拿走、擦完架子以后,需要记住物体原本放在哪一层。

普通策略如果只看当前帧,会缺记忆;如果直接看很长视频,会慢、贵、冗余,而且容易把无关历史当捷径。MemER 的做法是用"最近窗口 + 少量关键帧"代替完整历史。

2. 方法整体架构

MemER 是一个分层策略:

text 复制代码
高层策略 pi_h:
  输入: 总任务指令 + 长期关键帧记忆 K_t + 最近 N 帧 R_t
  输出: 当前应执行的语言子任务 l'_t + 最近 N 帧中应保存的候选关键帧 J_t

低层策略 pi_l:
  输入: 当前图像 I_t + 机器人状态 q_t + 高层给出的子任务 l'_t
  输出: 未来一小段机器人动作 A_t

论文把整体策略写成:

text 复制代码
pi(A_t | o_{0:t})
  = pi_l(A_t | I_t, q_t, l'_t)
    * pi_h(l'_t, J_t | I_{t-N+1:t}, K_t)

其中:

  • A_t 是未来动作 chunk。
  • I_t 是当前多相机图像。
  • q_t 是关节角、夹爪等 proprioception。
  • l_t 是用户给的总任务指令。
  • l'_t 是当前要交给低层策略执行的子任务。
  • R_t = I_{t-N+1:t} 是最近 N 帧。
  • K_t 是过去被选中的长期关键帧。
  • J_t 是高层模型从最近窗口里新提名的候选关键帧。

当前公开代码主要覆盖高层策略:数据导出、Qwen3-VL 微调格式、结构化推理、记忆更新、离线 rollout 评估和在线部署封装。低层 pi0.5 的训练代码不在这个仓库里。

3. 为什么需要"高层记忆策略"

直接把历史全喂给策略有三个问题:

  1. 太贵:视频帧是高维输入,长历史会让 VLM/VLA 的 token 和显存成本快速上涨。
  2. 太慢:机器人闭环控制需要低延迟。论文里 Long History baseline 用 32 帧大约 16 秒历史,推理延迟已经接近闭环可忍受上限。
  3. 太脆:长历史里包含大量无关动作,模型可能学到示范轨迹中的顺序捷径。一旦部署时低层策略卡顿、重试、失败恢复,历史分布就变了,模型更容易误判当前进度。

MemER 的设计取舍是:

  • 最近窗口保留局部动作上下文。
  • 关键帧记忆保留长期必要信息。
  • 关键帧由高层模型自己根据任务语义提名,不额外训练一个图像检索模型。
  • 最后用一个很简单的时间聚类器去掉重复提名,让记忆稳定。

4. 数据构建:从一段视频到很多道训练题

MemER 的高层数据构建,最简单的理解是:

把一整段机器人演示视频,切成很多道给 VLM 做的训练题。每道题都问模型两件事:现在应该执行哪个子任务?最近看到的几帧里,有没有以后要记住的关键画面?

完整流程是:

text 复制代码
原始机器人演示轨迹
  -> 每帧都有图像、总任务、当前子任务标签、机器人状态、动作
  -> 根据子任务标签和规则,找出整条轨迹里哪些帧值得记住
  -> 对每个时间步 t 单独构造一条高层训练样本
  -> 导出成 Qwen SFT 的 train.json + media/*.jpg

先把几个名字分清楚,后面就不会乱:

名字 它是什么 在训练样本里扮演什么角色
GT keyframes 整条演示轨迹里,规则认为应该记住的绝对帧号 离线生成监督信号
memory_indices 当前时间步以前、已经离开最近窗口的 GT keyframes 作为输入图片给模型看
context_indices 当前时间步附近的最近 N 个下采样帧 作为最近视频输入给模型看
keyframe_positions context_indices 里哪些位置应该被提名为新 keyframe 作为模型输出答案

一句话说:

text 复制代码
memory_indices 是"过去已经该记住的帧",放进输入;
keyframe_positions 是"最近刚出现、以后应该记住的帧",放进输出。

这个区别是数据构建里最关键的点。MemER 不是让模型直接输出一个历史帧号,而是训练模型在"最近窗口"里指出哪些帧值得保存;等这些帧未来离开最近窗口后,它们才会成为长期 memory。

4.1 原始演示数据

论文里的每条机器人轨迹可以理解成一串时间步:

text 复制代码
(I_t, q_t, l_t, l'_t, a_t)

含义是:

  • I_t:多相机图像,论文实验使用 wrist camera 和 third-person/base camera。
  • q_t:机器人自身状态。
  • l_t:总任务指令,例如"把架子上的物品拿下来,擦架子,再放回原位"。
  • l'_t:当前子任务标签,例如"dust bottom shelf"。
  • a_t:机器人动作。

子任务标签来自数据采集过程:操作者执行预先规定的子任务列表,完成一个子任务后按键切换到下一个子任务。因此每个时间步天然有当前子任务标签。

4.2 高层策略训练样本

高层策略不直接预测机器人动作,只预测:

json 复制代码
{
  "current_subtask": "...",
  "keyframe_positions": [1, 5]
}

其中:

  • current_subtask 是当前应该让低层策略执行的语言子任务。
  • keyframe_positions 是最近窗口中哪些帧值得保存,位置是 1-indexed,即 1 表示最近窗口第一帧,8 表示第八帧。

公开代码导出的 Qwen SFT 样本大致长这样:

json 复制代码
{
  "id": "episode_000000_t000120",
  "image": [
    "media/episode_000000/frame_000015.jpg",
    "media/episode_000000/frame_000050.jpg",
    "media/episode_000000/frame_000085.jpg",
    "media/episode_000000/frame_000090.jpg",
    "... recent frames ..."
  ],
  "conversations": [
    {"from": "system", "value": "...输出 JSON ..."},
    {"from": "human", "value": "Task: ... <image> ... <image> ..."},
    {"from": "gpt", "value": "{\"current_subtask\": \"...\", \"keyframe_positions\": [7]}"}
  ],
  "metadata": {
    "memory_indices": [15, 50],
    "context_indices": [85, 90, 95, 100, 105, 110, 115, 120],
    "answer": {
      "current_subtask": "...",
      "keyframe_positions": [7]
    }
  }
}

注意这里的图片顺序是:

text 复制代码
先放 memory keyframes,再放 recent context frames

prompt 文本会告诉模型前几张是"从完整历史中选出的重要帧",后几张是"最近动作视频"。

4.3 多相机图像怎样处理

公开代码支持单相机或多相机。多相机时,每个时间步的多个相机视角会被竖直拼成一张图:

text 复制代码
stacked_frame_t =
  [wrist image at t
   exterior image at t]

默认 Panda/Franka 配置是:

json 复制代码
{
  "camera_keys": [
    "observation.images.wrist_left",
    "observation.images.exterior_1_left"
  ],
  "view_width": 320,
  "view_height": 180
}

因此最终单帧输入宽度是 320,高度是 180 * 相机数。这个拼接方式保证训练、评估、部署看到的视角顺序一致。

5. 关键帧标签怎样得到

MemER 并不是让人工逐帧标所有关键帧。论文和代码采用的是半自动规则:

  1. 先有每一帧的子任务标签。
  2. 把连续相同子任务看成一个 segment。
  3. 人工为每类 subtask 设定规则:取 segment 第一帧、最后一帧、两者都取,或不取。
  4. 规则固定后自动应用到所有演示轨迹。

比如:

  • Object Search:look inside the <LOCATION> bin 取最后一帧,因为最后通常能看清箱子里有什么。
  • Counting:place a scoop of <OBJECT> in the <COLOR> bowl 取最后一帧,因为这代表一次计数完成。
  • Dust & Replace:移走物体、擦完某层、放回物体等动作取最后一帧,因为这些帧记录了"完成后的状态"。
  • reset 类动作通常不取,因为它们不是长期记忆所需信息。

伪代码:

python 复制代码
def build_rule_based_keyframes(labels, rules, default="none"):
    keyframes = []  # 存放最终选出的绝对帧索引
    i = 0           # 当前扫描到的轨迹帧位置

    while i < len(labels):
        label = labels[i]  # 当前 segment 的子任务名
        j = i + 1

        # 找到连续执行同一个子任务的区间 [i, j)
        while j < len(labels) and labels[j] == label:
            j += 1

        segment = [i, ..., j - 1]              # 这个子任务持续的帧区间
        select = match_rule(label, rules, default)  # 查规则:该子任务要取首帧、尾帧、都取还是不取

        if select == "first":
            keyframes.append(i)       # 记录子任务刚开始时的状态
        elif select == "last":
            keyframes.append(j - 1)   # 记录子任务完成后的状态,MemER 最常用这种
        elif select == "both":
            keyframes.extend([i, j - 1])  # 同时保留开始和结束状态
        elif select == "none":
            pass  # 该子任务不贡献长期记忆

        i = j  # 跳到下一个子任务 segment

    return unique_preserve_order(keyframes)  # 去掉重复帧,同时保持时间顺序

这些 keyframes 是训练时的 ground truth。模型训练时并不知道规则本身,只看到由规则产生的监督答案。

6. 训练样本怎样从轨迹切出来

这一节是数据构建的核心。先不要急着看公式,先看一个具体例子。

假设现在要为 t = 120 这一帧构造一条训练样本,配置是:

text 复制代码
frame_subsample = 5
recent_frames_length = 8
keyframes_length = 8
prediction_horizon = 2

再假设整条轨迹里,规则生成的 GT keyframes 是:

text 复制代码
GT keyframes = [30, 60, 95, 140]

t = 120 这道训练题,MemER 会这样构造:

text 复制代码
recent window = [85, 90, 95, 100, 105, 110, 115, 120]

memory_indices = [30, 60]
  因为 30 和 60 已经早于 recent window,可以作为历史记忆输入。

keyframe_positions = [3]
  因为 GT keyframe 95 正好在 recent window 里,而且是 recent 的第 3 张。

current_subtask = labels[130]
  因为 prediction_horizon = 2,frame_subsample = 5,所以目标子任务向前看 10 帧。

最终这条样本可以白话理解成:

text 复制代码
输入给模型:
  总任务指令
  frame_30.jpg、frame_60.jpg               # 旧的长期记忆
  frame_85.jpg ... frame_120.jpg           # 最近窗口

要求模型输出:
  {
    "current_subtask": labels[130],
    "keyframe_positions": [3]
  }

所以,训练数据不是"整段视频 -> 一个答案",而是"一段视频里的每个时间步 -> 一道小题"。模型通过大量这样的小题学会:哪些旧图应该参考,最近窗口里哪些新图应该保存,现在该给底层 VLA 什么语言指令。

设:

  • 原始时间步是 t
  • frame_subsample = s,公开示例默认 5
  • recent_frames_length = N,公开示例默认 8
  • keyframes_length = M,公开示例默认 8
  • prediction_horizon = h,公开示例默认 2

6.1 最近窗口

对每个时间步 t,代码先根据下采样步长构造到当前为止的候选上下文:

python 复制代码
start_offset = t % frame_subsample        # 让采样序列的最后一帧刚好落在当前 t
all_context = range(start_offset, t + 1, frame_subsample)
recent = last_N(all_context)              # 只保留最后 N 个下采样帧

这个 recent window 可以理解为"带当前帧的滑动采样窗口"。它不是永远取 [0, 5, 10, ...] 这种固定网格,而是会随着当前时间步平移,确保最后一张 recent image 就是当前帧。

例如 frame_subsample = 5, N = 8

text 复制代码
t = 120 -> recent = [85, 90, 95, 100, 105, 110, 115, 120]
t = 122 -> recent = [87, 92, 97, 102, 107, 112, 117, 122]

模型因此总能看到"当前发生到哪里了"。这个窗口对应多少真实秒数取决于数据集原始帧率;论文实验描述为相机 15Hz、层级策略输入下采样到约 2Hz。

6.2 训练时的 memory keyframes

训练时 memory 来自 ground-truth keyframes 中已经早于最近窗口的部分:

python 复制代码
memory_keyframes = keyframes in [start_offset, recent[0])
memory_keyframes = align_to_subsample_grid(memory_keyframes)
memory_keyframes = last_M(memory_keyframes)

也就是说,如果某个关键帧还在最近窗口里,它不会作为 memory 出现,而是应该通过 keyframe_positions 被模型提名;等它离开最近窗口后,才会作为长期记忆出现。

6.3 当前输出中的 keyframe_positions

训练目标里的 keyframe_positions 只允许指向最近窗口中的帧:

python 复制代码
candidate_keyframes = keyframes in [recent[0], t + 1)
candidate_keyframes = align_to_subsample_grid(candidate_keyframes)
relative_positions = [recent.index(k) + 1 for k in candidate_keyframes]

因此模型学到的是:

在最近 8 帧里,如果某些帧刚好包含以后需要记住的信息,就输出它们在最近窗口里的位置。

6.4 current_subtask 为什么预测未来一点

代码默认使用:

python 复制代码
target_idx = min(t + prediction_horizon * frame_subsample, total_steps - 1)
current_subtask = labels[target_idx]

也就是说,高层策略在时间 t 不一定预测 labels[t],而是预测稍微往前看的标签。这样做是为了弥补闭环系统中的反应延迟:高层模型推理、低层策略接收新语言指令、机器人实际动作执行之间都不是零延迟。提前一点预测可以让子任务切换更及时。

7. 高层模型结构

高层策略本质上是一个视觉语言模型,被 SFT 成结构化输出器。

论文实验中:

  • 高层策略:Qwen2.5-VL-7B-Instruct。
  • 低层策略:pi0.5。
  • 训练时冻结视觉编码器和视觉投影层,只训练 LLM backbone。
  • 还使用了 model merging,把预训练权重和微调权重线性插值。

当前公开代码中:

  • 高层训练格式面向 Qwen3-VL。
  • README 提供 dusting 任务的 Qwen3-VL-4B checkpoint。
  • 推理类使用 transformers.AutoModelForImageTextToTextAutoProcessor 加载 Qwen3-VL。
  • 视觉编码器冻结、优化器等训练细节由官方 Qwen finetune 仓库执行,不在本仓库训练代码里实现。

7.1 输入格式

高层模型每次收到:

text 复制代码
system prompt:
  你是机器人程序,输出 JSON:
  - current_subtask
  - keyframe_positions

user prompt:
  Task: <总任务指令>

  Here are selected frames from the full video:
  <memory image 1>
  <memory image 2>
  ...

  Here is a video of the most recent actions:
  <recent image 1>
  ...
  <recent image N>

如果没有 memory,memory 部分会省略。图片本身以 Qwen chat template 支持的 image content 传入。

7.2 输出格式

模型输出必须能解析成 JSON:

json 复制代码
{
  "current_subtask": "dust bottom shelf",
  "keyframe_positions": [5]
}

推理代码会比较宽容:

  • 可以从普通文本或 Markdown code fence 里提取第一个 JSON object。
  • current_subtask 也兼容旧字段名 current_primitive
  • keyframe_positions 必须是 list,元素必须能转成整数。
  • 如果解析失败,本步不更新 memory。

8. 高层训练 pipeline

公开可复现的高层训练流程是:

text 复制代码
LeRobot v3 dataset with subtasks
  -> generate_sft_data.py
  -> train.json + media/*.jpg
  -> 注册到 Qwen3-VL finetune 数据集
  -> 用 Qwen 官方训练脚本 SFT
  -> 得到高层 MemER checkpoint

8.1 数据导出

generate_sft_data.py 做这些事:

  1. 读取 LeRobot dataset metadata,拿到任务、子任务、相机键。
  2. 按 camera layout 导出每个时间步的 JPEG;多相机时先拼成 stacked image。
  3. 根据 subtask label 和 keyframe rules 生成全轨迹 ground-truth keyframe indices。
  4. 对每个时间步构造一个 Qwen SFT example。
  5. 写出:
text 复制代码
output_dir/
  train.json
  media/
    episode_000000/
      frame_000000.jpg
      frame_000001.jpg
      ...

8.2 Qwen3-VL 训练命令要点

公开训练文档给出的 reference 设置包括:

  • base model:Qwen/Qwen3-VL-4B-Instruct
  • 训练 1500 steps
  • tune_mm_vision False
  • tune_mm_mlp False
  • tune_mm_llm True
  • bf16
  • model_max_length 8192
  • batch size per device 8,gradient accumulation 8
  • 学习率 6e-5
  • cosine scheduler,warmup ratio 0.05
  • max_pixels 115200min_pixels 50176

这和论文附录中的原始实验设定不同:论文报告 Qwen2.5-VL-7B 高层策略训练 4500 gradient steps,batch size 256,冻结 vision encoder 和 projection layer。

9. Loss 定义

9.1 高层策略 loss

高层策略训练是标准自回归 SFT。给定 prompt x_t 和目标 JSON token 序列 y_t,优化目标是最大化目标 token 的条件似然:

text 复制代码
L_high = - sum_i log p_theta(y_{t,i} | x_t, y_{t,<i})

其中 y_t 是类似下面的字符串:

json 复制代码
{"current_subtask": "dust bottom shelf", "keyframe_positions": [5]}

关键点:

  • 代码里没有单独的分类 loss 来预测 subtask。
  • 代码里也没有单独的多标签 loss 来预测 keyframe positions。
  • subtask 和 keyframe positions 都被序列化成 JSON 文本,统一通过 next-token cross entropy 学。

换句话说,current_subtaskkeyframe_positions 的监督都藏在同一个语言建模 loss 里。

9.2 低层策略 loss

论文层面,低层策略学习:

text 复制代码
pi_l(A_t | I_t, q_t, l'_t)

也就是给当前图像、机器人状态和子任务语言,预测未来动作 chunk。

当前仓库没有 pi0.5 低层训练代码,因此不能从源码确认具体实现 loss。方法层面可以写成 imitation learning 的负对数似然:

text 复制代码
L_low = - log pi_phi(A_t^expert | I_t, q_t, l'_t)

如果某个具体低层 VLA 用连续动作回归、扩散、flow matching 或其他 action head,那么 L_low 会由该低层模型自己的训练框架定义。MemER 对低层策略的要求不是特定 loss,而是它能可靠执行语言子任务。

9.3 总体训练目标

论文方法是分开训练高层和低层,而不是端到端联合训练:

text 复制代码
min_theta L_high(theta)
min_phi   L_low(phi)

部署时两者组合:

text 复制代码
高层输出 l'_t -> 低层用 l'_t 生成动作
高层输出 J_t -> 记忆模块更新 K_{t+1}

10. 视觉记忆怎样构建

这是 MemER 最关键的机制。

高层模型每一步只输出最近窗口里的相对位置,比如 [7]。这些位置会被映射成真实时间索引:

python 复制代码
absolute_index = context_indices[position - 1]

然后加入候选 keyframe 池。候选池保留重复票数,因为同一个帧或相邻帧被多次提名,说明模型反复认为这段时间重要。

之后对所有候选帧索引做一维 single-linkage clustering:

  1. 把所有候选索引排序,重复值不去掉。
  2. 相邻候选索引距离 <= d 就放进同一个 cluster。
  3. 每个 cluster 取中位数索引作为代表帧。
  4. 只暴露最近 memory_length 个代表帧给模型。
  5. 如果代表帧仍在最近窗口里,则暂时不作为 memory 暴露,避免同一帧在 memory 和 recent 中重复出现。

伪代码:

python 复制代码
def build_visual_memory(candidate_indices, merge_distance, memory_length, current_context):
    # candidate_indices 是模型历次提名过的绝对帧号;重复值代表多次投票
    indices = sorted(candidate_indices)  # 按时间排序,但保留重复投票
    if not indices:
        return []  # 还没有任何候选关键帧时,memory 为空

    clusters = []              # 每个 cluster 表示一个被多次提名的"事件"
    current = [indices[0]]     # 当前正在累积的时间簇

    for idx in indices[1:]:
        # 如果相邻候选帧距离足够近,就认为它们描述同一个事件
        if idx - current[-1] <= merge_distance:
            current.append(idx)
        else:
            clusters.append(current)  # 距离太远,结束当前事件
            current = [idx]           # 开启新的事件簇
    clusters.append(current)          # 别忘了收尾最后一个簇

    representatives = []
    for cluster in clusters:
        # 用中位数帧作为该事件的代表;重复投票会影响中位数位置
        representatives.append(median_low(cluster))

    selected = representatives[-memory_length:]  # 只保留最近的 M 个长期记忆

    # 如果代表帧仍在 recent context 里,就暂时不作为 memory 重复展示
    visible = [idx for idx in selected if idx not in current_context]
    return visible[-memory_length:]  # 返回本次 prompt 真正可见的 memory 帧索引

10.1 为什么要聚类,而不是直接保存模型输出的帧

模型在相邻时间步看到的最近窗口高度重叠。一个重要事件可能在多次推理中被提名:

text 复制代码
t=100: 模型提名 frame 90
t=105: 模型提名 frame 90
t=110: 模型提名 frame 95
t=115: 模型提名 frame 95

如果直接保存,会得到很多重复或近重复图片。聚类后,它们会合并成一个事件,取中位数作为代表。这样 memory 更像"事件摘要",而不是"模型每次投票的原始日志"。

10.2 merge_distance 的单位

论文里 d=5 是在下采样后的时间步意义上讲的。公开代码的 rollout/deploy 会把它换算成原始帧距离:

python 复制代码
raw_merge_distance = merge_distance * frame_subsample

例如:

text 复制代码
merge_distance = 5
frame_subsample = 5
raw_merge_distance = 25 原始帧

这样无论内部候选索引用原始帧号表示,聚类距离仍然等价于"下采样后 5 步以内算同一事件"。

11. 推理流程

11.1 在线部署流程

在线部署由一个 stateful policy 维护:

  • 所有已经收到的帧 _frames
  • episodic memory _memory
  • 当前总任务 instruction
  • 相机拼接配置

每次调用 step(obs)

python 复制代码
def memer_step(obs):
    frame = preprocess_and_stack_cameras(obs)  # 把单/多相机观测转成一张 PIL 图
    frames.append(frame)                       # 在线部署时持续累积整段观测历史
    t = len(frames) - 1                        # 当前时间步的绝对帧索引

    # 从历史中取最近 N 个下采样帧,作为短期上下文
    context_indices = build_recent_context_indices(
        timestep=t,
        frame_subsample=s,
        recent_frames_length=N,
    )

    # 从长期记忆中取当前 prompt 可见的关键帧,避免和 recent context 重复
    memory_indices = memory.visible_indices(context_indices)

    # prompt 只写占位符数量;图片会按 memory + recent 的顺序传给 VLM
    prompt = build_prompt(
        instruction=instruction,
        memory_count=len(memory_indices),
        recent_count=len(context_indices),
    )

    images = [frames[i] for i in memory_indices + context_indices]  # 先长期记忆,再最近窗口

    prediction = qwen.predict(prompt, images)  # 输出 current_subtask 和 keyframe_positions

    if prediction.parse_ok:
        # 模型输出的是 recent window 中的 1-indexed 位置,需要映射回绝对帧号
        absolute_keyframes = map_relative_positions_to_absolute(
            prediction.keyframe_positions,
            context_indices,
        )
        memory.add_candidates(absolute_keyframes)  # 把候选帧加入投票池,供后续聚类

    return prediction.current_subtask  # 交给低层策略执行的语言子任务

返回值中还包含调试信息:

  • 当前 timestep
  • parse 是否成功
  • 模型输出的相对 keyframe positions
  • 映射后的绝对帧索引
  • 推理前后的 memory indices
  • 所有候选 keyframe votes
  • raw model text

11.2 和低层策略怎样配合

论文部署中:

  • 高层策略约 1Hz 运行。
  • 低层策略约 2Hz 运行。
  • 低层策略输出 15 个 15Hz 动作,执行前 8 个动作后重新规划。
  • 两个策略异步跑在不同 server 上。

直观上:

text 复制代码
高层慢一点,但负责想清楚"现在做什么"
低层快一点,持续执行最近一次高层给出的 subtask

当高层还在推理下一条 subtask 时,低层继续使用最近一次已经预测出的 subtask。这可以减少等待,提高机器人动作连贯性。

11.3 离线 rollout 评估

离线评估不是简单地对每个训练样本独立测试,而是模拟部署时的记忆更新:

python 复制代码
memory = EpisodicMemory(...)  # 每个 episode 单独维护一份滚动视觉记忆

for timestep in episode:
    context = recent frames up to timestep       # 当前时刻能看到的短期上下文
    memory_before = memory.visible_indices(context)  # 当前时刻可见的长期关键帧
    prediction = model(memory_before + context)  # 用"长期记忆 + 最近窗口"预测子任务和新候选帧

    compare prediction.current_subtask with target label  # 统计 subtask 准确率

    if parse_ok:
        keyframes = map prediction.keyframe_positions to absolute indices  # 相对位置转绝对帧号
        memory.add_candidates(keyframes)  # 用本步预测更新 memory,影响后续时间步

这很重要,因为 MemER 的预测会影响之后的 memory。如果早期乱提名很多无意义帧,后面模型看到的 memory 就会变差。因此 rollout 评估比静态 teacher-forcing 更接近真实部署。

12. 三个任务分别需要记住什么

论文用三个真实机器人任务评估 MemER,每个任务考察一种不同的长期记忆。

任务:三个不透明箱子里随机放 3-5 个物体,机器人依次收到三个目标物体,要找出并放到白色箱子。

需要记住:

  • 已经看过哪些箱子。
  • 每个箱子里有哪些物体。
  • 如果目标已经见过,直接去对应箱子,不要重复搜索。

关键帧规则:

  • 看箱子动作的最后一帧要记住。
  • 抓取并放置物体的动作不需要记住。

12.2 Counting Scoops

任务:把指定数量的两种食材舀到指定颜色碗里。

需要记住:

  • 每种食材已经舀了几勺。
  • 当前是否应该继续舀、reset scooper,还是结束。

关键帧规则:

  • 每次成功放入一勺的最后一帧要记住。
  • 拿勺子、reset、放下勺子通常不记。

12.3 Dust & Replace

任务:拿走上下层架子上的物体,擦架子,再把物体放回原位置。

需要记住:

  • 每个物体原本在上层还是下层。
  • 哪一层已经擦过。
  • 放回时应该放到哪里。

关键帧规则:

  • 移走物体、擦完某层、放回物体等完成状态要记住。
  • reset duster、put down duster 这类动作不记。

13. Model merging

论文还报告了一个实用技巧:高层模型微调很多步后,虽然更会说正确 subtask,但可能损失预训练 VLM 对非理想行为的鲁棒性。因为训练数据主要是专家演示,部署时低层策略可能出现卡顿、重试、失败恢复,分布会不一样。

论文使用线性权重合并:

text 复制代码
theta = (1 - alpha) * theta_pre + alpha * theta_ft

其中:

  • theta_pre 是原始 Qwen2.5-VL-Instruct 权重。
  • theta_ft 是 MemER 数据微调后的权重。
  • 论文使用 alpha = 0.8

直觉是:大部分采用任务微调能力,同时保留一部分预训练模型的开放域视频理解鲁棒性。

当前公开代码没有实现 model merging 脚本;它是论文方法中的训练后处理技巧。

14. 为什么视觉记忆比文本记忆更好

论文比较了三种记忆形式:

  • 只用短历史。
  • 短历史 + 文本 subtask 记忆。
  • MemER 图像关键帧。
  • MemER 图像关键帧 + 文本 subtask。

结果是视觉关键帧更稳。原因很直观:

  1. 文本 subtask 太粗。例如"look inside right bin"不能完整记录右箱子里所有物体。
  2. 文本可能错。一旦某次 subtask 预测错,把错误文本存进 memory 后会误导后续推理。
  3. 模型容易过度依赖文本 token,反而忽视视觉证据。
  4. 图像关键帧保留环境真实状态,尤其适合物体位置、数量、完成状态这些难以用短文本完整表达的信息。

所以 MemER 默认使用纯视觉 memory,而不是把历史 subtask 文本也一起塞进去。

15. 一个完整例子

以 Dust & Replace 为例:

  1. 总任务指令是:
text 复制代码
remove the items from the shelves, dust the shelves, and place the items back
  1. 刚开始没有 memory,模型只看最近 8 帧,预测:
json 复制代码
{"current_subtask": "remove the object on the bottom shelf", "keyframe_positions": []}
  1. 低层策略执行拿走下层物体。

  2. 到动作完成附近,最近窗口里出现"下层物体被拿走后原位置可见"的画面。模型输出:

json 复制代码
{"current_subtask": "remove the object on the top shelf", "keyframe_positions": [7]}
  1. 7 被映射成某个绝对帧号,比如 t=140,加入候选池。

  2. 后续几步模型可能又提名 140, 145, 145。聚类器发现它们都很近,合成一个 cluster,取中位数 145 作为代表 keyframe。

  3. 当这些帧离开最近窗口后,代表帧 145 会作为 memory image 出现在之后的 prompt 里。

  4. 擦完架子后,机器人需要放回物体。当前画面可能只看到空架子和手里的物体,但 memory 里有原位置关键帧,所以高层可以预测:

json 复制代码
{"current_subtask": "place the panda plushie on the bottom shelf", "keyframe_positions": []}

这个流程体现了 MemER 的本质:把"曾经发生过的重要视觉状态"变成长期可见的少量图片,而不是让模型记住完整视频。

16. 方法伪代码总览

16.1 训练数据生成

python 复制代码
def generate_sft_examples(episode):
    frames = export_stacked_camera_images(episode)  # 导出每个时间步的单图;多相机先竖直拼接
    labels = get_subtask_labels(episode)            # 每帧对应的 ground-truth subtask
    instructions = get_task_instructions(episode)   # 总任务指令,可来自数据集或命令行覆盖
    gt_keyframes = build_rule_based_keyframes(labels, rules)  # 根据子任务规则生成 GT 关键帧

    examples = []
    for t in range(len(frames)):
        # 最近窗口:截至 t 的最后 N 个下采样帧
        recent = build_recent_context_indices(t, frame_subsample, N)

        memory = gt_keyframes_before(recent[0])  # 已经离开 recent window 的 GT 关键帧
        memory = align_to_subsample_grid(memory) # 对齐到下采样帧格点,和 recent 索引体系一致
        memory = last_M(memory)                  # prompt 里最多放 M 张长期记忆图

        candidates = gt_keyframes_inside(recent)     # 当前 recent window 内应被提名的 GT 关键帧
        candidates = align_to_subsample_grid(candidates)
        keyframe_positions = [recent.index(k) + 1 for k in candidates]  # 转成 1-indexed 位置

        # 预测稍微未来一点的 subtask,用来补偿高层推理和低层执行延迟
        target_t = min(t + horizon * frame_subsample, len(frames) - 1)
        answer = {
            "current_subtask": labels[target_t],
            "keyframe_positions": keyframe_positions,
        }

        # Qwen SFT 样本:输入图片顺序是 memory + recent,输出是 JSON 字符串
        examples.append(qwen_sft_record(
            instruction=instructions[t],
            images=memory + recent,
            answer=answer,
        ))

    return examples  # 一个 episode 会产生 len(frames) 条高层 SFT 样本

16.2 高层推理与记忆更新

python 复制代码
def rollout_high_level_policy(frames, instruction):
    memory = EpisodicMemory(merge_distance=d, memory_length=M)  # 保存模型自己滚出来的视觉记忆

    for t, frame in enumerate(frames):
        context = build_recent_context_indices(t, frame_subsample, N)  # 本步 recent window
        visible_memory = memory.visible_indices(context)               # 本步可见的长期关键帧

        prediction = vlm_generate_json(
            instruction=instruction,
            images=frames[visible_memory] + frames[context],  # 图片顺序必须和 prompt 约定一致
        )

        subtask = prediction["current_subtask"]              # 给低层策略的语言命令
        relative_positions = prediction["keyframe_positions"] # recent window 中的 1-indexed 候选位置

        absolute_candidates = []
        for pos in relative_positions:
            # 丢弃越界位置;合法位置映射成轨迹中的绝对帧号
            if 1 <= pos <= len(context):
                absolute_candidates.append(context[pos - 1])

        memory.add_candidates(absolute_candidates)  # 加入候选投票池,下一步可能变成可见 memory

        yield subtask  # 当前时间步输出给低层控制器

16.3 和低层策略组合

python 复制代码
latest_subtask = None  # 低层策略会一直使用最近一次高层预测

while robot_is_running:
    obs = get_robot_observation()  # 当前相机图像和机器人状态

    # 慢频率或异步线程中运行
    if high_level_ready:
        # 高层只负责更新语言子任务和内部 memory,不直接输出关节动作
        latest_subtask = memer_high_level.step(obs).current_subtask

    # 快频率运行
    action_chunk = low_level_policy.predict(
        image=obs.image,          # 当前视觉输入
        robot_state=obs.q,        # 当前关节/夹爪状态
        language=latest_subtask,  # 高层给出的当前子任务
    )
    execute_first_actions(action_chunk)  # 执行动作 chunk 的前几步,然后重新规划