源文件:chapter7/SpatialReasoning/README.md VIRL-VL: Vision-Language Navigation with Reinforcement Learning 本文档详细介绍基于 V-IRL 平台的视觉-语言导航强化学习实验的设计、实现和评估方法。 V-IRL (Virtual Intelligence in Real Life) 是一个用于构建和测试虚拟智能体的开源平台,使智能体能够利用真实的地理空间数据和街景图像在虚拟的真实世界环境中交互。
源文件:chapter7/SpatialReasoning/README.md
本文档详细介绍基于 V-IRL 平台的视觉-语言导航强化学习实验的设计、实现和评估方法。
V-IRL (Virtual Intelligence in Real Life) 是一个用于构建和测试虚拟智能体的开源平台,使智能体能够利用真实的地理空间数据和街景图像在虚拟的真实世界环境中交互。
本实验基于 V-IRL (Virtual Intelligence in Real Life) 平台实现视觉-语言导航任务,旨在验证强化学习(RL)相比监督微调(SFT)在视觉泛化能力上的优势。
V-IRL 平台简介:
本实验核心发现:
论文出处:SFT Memorizes, RL Generalizes
代码仓库:
回答以下核心问题:
控制变量:
自变量:
根据论文 Figure 1 和实验结果:
| 指标 | SFT | RL (PPO) | 提升 |
|---|---|---|---|
| In-Distribution Per-Step Accuracy | ~85% | ~90% | +5% |
| Rule OOD Per-Step Accuracy | ~15% | ~70% | +55% |
| Visual OOD Generalization | 失败 (<10%) | 成功 (~60%) | +50% |
| V-IRL Mini Benchmark | 44.0% | 77.8% | +33.8% |
RL 实现视觉泛化:
RL 提升视觉识别能力:
SFT 的必要性:
# 硬件要求 - GPU: 8×H100/H800/A100 (80GB) for training - Memory: 1000GB RAM for training - Storage: ~500GB for NYC route data + street views + checkpoints # 软件环境 - Python: 3.13.0
# 1. Clone repository(使用修复后的 fork 版本) git clone https://github.com/bojieli/SFTvsRL.git cd SFTvsRL # 2. Create conda environment conda create -n SFTvsRL python==3.13 -y conda activate SFTvsRL # 3. Install dependencies pip install -r requirements.txt # 4. Install gym environments cd gym pip install -e . cd .. # 5. Download data from HuggingFace huggingface-cli download tianzhechu/SFTvsRL_Data --local-dir ./data # 6. Login to wandb (create an account on wandb.ai to obtain API key) wandb login
问题描述:
官方 LeslieTrue/SFTvsRL 仓库存在一个严重的 bug,导致训练完成后无法保存 checkpoint:
# rl/trainer/base_trainer.py (官方版本,第 38 行) def __init__(self, ..., save_every=None, ...): ... self.save_ckpt = save_ckpt self.save_every = None # ❌ BUG: 硬编码为 None,忽略配置参数!
Bug 影响:
# 训练循环中 for update in range(self.num_updates): if self.save_ckpt: save_model = (update + 1) % self.save_every == 0 # ❌ TypeError! # 因为 self.save_every = None,无法进行模运算
即使在配置文件中设置了 save_every: 1,训练时也会报错:
TypeError: unsupported operand type(s) for %: 'int' and 'NoneType'
修复方案:
bojieli/SFTvsRL fork 修复了这个问题:
# rl/trainer/base_trainer.py (修复后,第 38 行) def __init__(self, ..., save_every=None, ...): ... self.save_ckpt = save_ckpt self.save_every = save_every # ✅ 正确使用配置参数
修复的文件:
rl/trainer/base_trainer.py:修复 save_every 参数传递rl/configs/llama_virl_vl.yaml:添加缺失的 save_every: 1 配置为什么需要这个修复?
训练 15 小时后: - 官方版本:❌ 无法保存 checkpoint,损失训练进度 - 修复版本:✓ 成功保存 checkpoint,可以进行评估和继续训练
# 1. 创建数据目录 mkdir -p /root/SFTvsRL_Data cd /root/SFTvsRL_Data # 2. 下载 VIRL 数据(从 HuggingFace) huggingface-cli download tianzhechu/SFTvsRL_Data \ --include "VIRL_routes/*" \ --local-dir . # 3. 解压数据 cd VIRL_routes unzip nyc_1k_routes.zip unzip VLN_mini.zip # 用于 Visual OOD 评估 # 4. 验证目录结构 ls -la nyc_1k_routes/ # 应该看到: # - route_infos.json # - gps_pano_mapping.pkl # - street_views/
/root/SFTvsRL_Data/ └── VIRL_routes/ ├── nyc_1k_routes/ # NYC 训练数据 │ ├── route_infos.json # 路线信息 │ ├── gps_pano_mapping.pkl # GPS 到全景 ID 映射 │ └── street_views/ # 街景图片目录 │ ├── pano_XXX_h000.jpg │ ├── pano_XXX_h090.jpg │ └── ... ├── VLN_mini/ # San Francisco OOD 数据 │ ├── route_infos.json │ ├── gps_pano_mapping.pkl │ └── street_views/ └── ...
如果你没有权限访问 /root,请在 scripts/virl_training/vl_train.sh 中配置路径:
BASE_DIR="/root/SFTvsRL_Data/VIRL_routes"
完成数据下载和配置后,可以直接运行训练:
# 1. 进入代码目录 cd /root/SFTvsRL # 2. 激活环境 conda activate SFTvsRL # 3. 启动训练(使用已配置好的脚本) bash scripts/virl_training/vl_train.sh
训练脚本自动使用以下路径(已在脚本中配置):
BASE_DIR="/root/SFTvsRL_Data/VIRL_routes" ROUTE_INFO="${BASE_DIR}/nyc_1k_routes/route_infos.json" GPS_TO_PANO="${BASE_DIR}/nyc_1k_routes/gps_pano_mapping.pkl" STREETVIEWS="${BASE_DIR}/nyc_1k_routes/street_views/"
预期输出:
Parsed instruction: ['First, turn right to face north.', ...] Collecting Trajectories: 100%|████████| 256/256 [40:25<00:00] PPO Training Epoch 0/4: 100%|████████| 256/256 [05:12<00:00] PPO Training Epoch 1/4: 100%|████████| 256/256 [05:10<00:00] ... Saving checkpoint to: train_ckpt/virl_vl/checkpoint-epoch-4/
训练完成后检查 checkpoint:
ls -lh train_ckpt/virl_vl/ # 应该看到(如果 save_every=5): # checkpoint-epoch-4/ # checkpoint-epoch-9/ # checkpoint-epoch-14/
任务:智能体(VLM)需要根据自然语言指令,在真实世界街景中导航到目标地点。
输入:
全局指令(Global Instruction):完整的导航路线描述
1. First, turn left to face south. 2. Move forward until you reach next intersection where Battery Park is nearby. 3. Turn right to face west. 4. Move forward until you reach destination.
视觉观察(Visual Observation):2×2 街景图片网格(4个方向)
┌─────────┬─────────┐ │ Front │ Right │ ├─────────┼─────────┤ │ Back │ Left │ └─────────┴─────────┘
历史序列(Observation-Action Sequence):
O_0: "No landmarks nearby; You observe an intersection" A_0: "turn_direction(south)" O_1: "Battery Park on your right; No intersection" A_1: "forward()" ...
输出:结构化 JSON 格式的动作
{ "current observation": "Battery Park on your right; You observe an intersection", "current instruction": "Turn right to face west", "action": "turn_direction(west)" }
一个 episode 成功需满足:
stop() 在正确位置)训练时使用的默认动作空间:
ACTION_SPACE = [ "forward()", # 向前移动一步 "turn_direction(north)", # 转向正北(0°) "turn_direction(northeast)", # 转向东北(45°) "turn_direction(east)", # 转向正东(90°) "turn_direction(southeast)", # 转向东南(135°) "turn_direction(south)", # 转向正南(180°) "turn_direction(southwest)", # 转向西南(225°) "turn_direction(west)", # 转向正西(270°) "turn_direction(northwest)", # 转向西北(315°) "stop()" # 停止(到达目的地) ]
特点:
用于 Rule OOD 评估的动作空间:
ACTION_SPACE_RELATIVE = [ "forward()", # 向前移动一步 "turn_direction(left)", # 左转(~-90°) "turn_direction(right)", # 右转(~+90°) "turn_direction(slightly left)", # 微左转(-45° to 0°) "turn_direction(slightly right)", # 微右转(0° to +45°) "stop()" # 停止 ]
特点:
在配置文件中设置:
# rl/configs/llama_virl_vl.yaml env_config: absolute_action: true # True: Absolute, False: Relative
在训练/评估脚本中覆盖:
# Training with absolute actions --env_config.absolute_action=True # Evaluation with relative actions (Rule OOD) --env_config.absolute_action=False
VIRL 使用 OpenAI Gym 接口实现:
class NavigationEnvironment(gym.Env): """ V-IRL 导航环境 主要组件: - Platform: Google Street View 接口 - Ground Truth Rail: 预计算的正确路径 - Verification System: 动作验证与反馈机制 """ def __init__(self, route_info_path, # 路线数据路径 resolution=1200, # 图片分辨率 verify_iter=2, # 每个 waypoint 的尝试次数 absolute_action=True, # 动作空间类型 relocation=True, # GPS 重定位到最近的全景点 drop_rate=0.5, # waypoint 之间插值点的丢弃率 ... )
环境预先计算一条 "rail"(参考轨迹),包含:
密集 waypoints:
每个 waypoint 包含:
waypoint = { 'geocode': [40.758, -73.985], # GPS 坐标 'heading': 180, # 朝向(度数) 'gt_action': 'turn_direction(south)', # 正确动作 'observation': 'Battery Park on right', # 地标描述 'intersection_observation': 'You observe an intersection', 'instruction': 'Turn left to face south', # 当前执行的指令 'instruction_idx': 1 # 指令索引 }
多次尝试机制(verify_iter=2):
# 步骤 1: 智能体输出动作 agent_action = model.generate(obs, instruction, history) # 步骤 2: 与 ground truth 比较 if agent_action == gt_action: reward = +1 # CORRECT_ACTION move_to_next_waypoint() remaining_attempts = verify_iter # 重置尝试次数 else: reward = -1 # INCORRECT_ACTION remaining_attempts -= 1 if remaining_attempts > 0: # 保持在当前位置,给予反馈,允许重试 feedback = f"Incorrect action. Expected {gt_action}" stay_at_current_position() else: # 尝试次数用尽,强制移动到下一个 waypoint(惩罚) reward = -1 force_move_to_next_waypoint()
Reward Function:
REWARD_FN_VIRL = { "CORRECT_ACTION": +1, # 动作正确 "INCORRECT_ACTION": -1, # 动作错误 "INCORRECT_OBS": -1.5, # 观察描述错误(错误检测路口) "INCORRECT_INSTRUCTION": -1.75 # 指令理解错误 }
Episode 结束于以下任一情况:
成功:done=True, is_success=True
stop()失败:truncated=True, is_success=False
强制前进:is_success=False
# rl/configs/llama_virl_vl.yaml env_config: id: 'gym_virl/Navigation-v0' route_info_path: "..." # 路线数据 resolution: 1200 # 街景图片分辨率 verify_iter: 2 # 验证尝试次数 absolute_action: true # 动作空间类型 relocation: true # GPS 重定位 drop_rate: 0.5 # waypoint 采样率 straight_line_length: 5 # 两个交叉口间插值点数 platform_cfg: STREET_VIEW: SIZE: [640, 640] # 单张街景尺寸 HEADING: 0 # 默认朝向 PITCH: 0 # 俯仰角 FOV: 90 # 视场角 SOURCE: outdoor # 街景来源 OFFLINE: ENABLED: True # 使用离线缓存 PANORAMA_DIR: "..." # 街景图片目录 GPS_TO_PANO_PATH: "..." # GPS 到全景 ID 映射 MAPPING_RADIUS: 20 # 重定位搜索半径(米)
每个 update 收集 256 steps(非 episodes):
def collect_trajectories(self): """ 收集 256 个环境交互步骤 可能横跨多个 episodes(routes) """ obs, info = self.env.reset() # 初始化第一条路线 for step in range(256): # 1. 构造 prompt prompt = format_prompt( global_instruction=info['global_instruction'], obs_act_seq=info['obs_act_seq'], current_obs=obs ) # 2. 模型生成动作(inference) with torch.no_grad(): # 处理 4 张街景图片 obs_image = convert_to_2x2_grid(obs) # [2400, 2400, 3] # VLM 前向传播 values, io_dict, output_text, action_log_prob = \ actor_critic.act_oneline( inputs=(obs_image, prompt), temperature=0.2, max_new_tokens=512 ) # 解析 JSON 输出 action = parse_json(output_text)['action'] # 3. 执行动作 obs_next, reward, done, truncated, info = env.step(output_text) # 4. 存储到 rollout buffer rollouts.insert( obs={"image": obs, "io_dict": io_dict}, action_log_prob=action_log_prob, value=values, reward=reward, mask=1-done ) running_reward += reward # 5. Episode 管理 if done or truncated: # 当前 episode 结束,开始新 episode log_episode_reward(running_reward) running_reward = 0 obs, info = env.reset() # 加载新路线 else: obs = obs_next return rollouts # 256 步的数据
关键点:
Update 1: 收集 256 steps ├─ Episode 1 (Route A, 14 steps): Success ✓ │ └─ Steps 0-13: [turn_direction(south), forward(), ..., stop()] │ ├─ Episode 2 (Route B, 22 steps): Success ✓ │ └─ Steps 14-35: [...] │ ├─ Episode 3 (Route C, 18 steps): Failed ✗ │ └─ Steps 36-53: [...] (exceeded attempts at waypoint 12) │ ├─ Episode 4 (Route D, 16 steps): Success ✓ │ └─ Steps 54-69: [...] │ ├─ ... │ └─ Episode N (Route X, partial): Truncated └─ Steps 240-255: [...] (episode未完成,但数据仍用于训练)
LLM 输入示例:
<|begin_of_text|><|start_header_id|>user<|end_header_id|> <|image|> [Task Description] You are an expert in navigation. You will receive a sequence of instructions to follow while observing your surrounding stree tviews. You are also provided with your observation and action history in text. Your goal is to first analyze the instruction and identify the next sentence to be executed. Then, you need to provide the action to be taken based on the current observation and instruction. [Instruction] 1. First, turn left to face northeast. 2. Move forward until you reach next intersection where Battery Playscape is on your right behind. 3. Turn right to face north. 4. Move forward until you reach next intersection. 5. Turn slightly left to face northwest. 6. Move forward until you reach next intersection. 7. Turn left to face north. 8. Move forward until you reach next intersection. 9. Turn right to face southeast. 10. Move forward until you reach next intersection. 11. Turn right to face south. 12. Move forward until you reach destination where The destination Cafe De Novo is on your right. [Observation format] You observe a 2x2 grid of streetview images with the following headings: [front, right back, left] You need to identify if any of the landmarks in the instruction are visible in the street view grid. [Action space] "forward()": indicates moving forward one step "turn_direction(x)": indicates adjust the ego agent direction towards x direction. x could be any following 8 directions ['north', 'northeast', 'east', 'southeast', 'south', 'southwest', 'west', 'northwest'] "stop()": indicates the navigation is finished. [Observations and actions sequence] O_1: No landmarks nearby; A_1: turn_direction(northeast) O_2: No landmarks nearby; A_2: forward() O_3: No landmarks nearby; A_3: forward() O_4: Battery Playscape is on your right behind; You observe an intersection A_4: turn_direction(north) O_5: No landmark nearby; You observe an intersection A_5: turn_direction(northwest) O_6: No landmarks nearby; A_6: forward() O_7: No landmarks nearby; A_7: forward() O_8: No landmarks nearby; A_8: forward() O_9: No landmark nearby; You observe an intersection A_9: turn_direction(north) O_10: No landmarks nearby; A_10: forward() O_11: No landmarks nearby; A_11: forward() O_12: No landmarks nearby; A_12: forward() O_13: You observe an image of 4 views; You observe an intersection A_13: [Output] { "current observation": latest observation from the street view grid, "current instruction": analyze the full instruction and identify the sentence to be executed, "action": the action to be taken chosen from the action space, } <|eot_id|><|start_header_id|>assistant<|end_header_id|>
LLM 输出示例:
{ "current observation": "No landmark nearby; You observe an intersection", "current instruction": "Turn right to face southeast.", "action": "turn_direction(southeast)", }
NYC 1K Routes:
数据来源:Google Maps API 采集 路线数量:1,000 条 覆盖区域:纽约市 Manhattan, Brooklyn, Queens 总 waypoints:~20,000-30,000 个 街景图片:~100,000 张(640×640, 4 方向/位置)
数据结构:
// route_infos.json [ [ // 路线列表 { "route_id": "nyc_001", "start_place": { "name": "Times Square", "geocode": [40.758, -73.985], "relocated_geocode": [40.7580, -73.9855] }, "dest_place": { "name": "Central Park South", "geocode": [40.767, -73.979] }, "init_heading": 0, "milestone_info": "Turn left to face south. Move forward...", "route_results": { "geocode_list": [[40.760, -73.984], ...], "landmark_list": ["Battery Park", "Plaza Hotel", ...] } }, ... ], 1000 // 路线总数 ]
Street View 缓存:
nyc_1k_routes/street_views/ ├─ pano_XXX_h000.jpg # Heading 0° (Front) ├─ pano_XXX_h090.jpg # Heading 90° (Right) ├─ pano_XXX_h180.jpg # Heading 180° (Back) └─ pano_XXX_h270.jpg # Heading 270° (Left)
GPS 映射:
# gps_pano_mapping.pkl { (40.758, -73.985): "pano_ABC123", # GPS -> Panorama ID (40.759, -73.984): "pano_DEF456", ... }
| 数据集 | 类型 | 路线数 | 数据路径 | 用途 |
|---|---|---|---|---|
| NYC Test (In-Dist) | In-Distribution | 48 | /root/SFTvsRL_Data/VIRL_routes/nyc_1k_routes/ |
测试训练分布性能 |
| NYC Test (Rule OOD) | Rule OOD | 48 | /root/SFTvsRL_Data/VIRL_routes/nyc_1k_routes/ |
测试相对动作泛化 |
| SF Routes (Visual OOD) | Visual OOD | 18 | /root/SFTvsRL_Data/VIRL_routes/VLN_mini/ |
测试视觉环境泛化 |
Visual OOD 数据特点(San Francisco,VLN_mini):
NYC 1K Routes (训练 + In-Dist/Rule OOD 评估): - 位置: /root/SFTvsRL_Data/VIRL_routes/nyc_1k_routes/ - 路线数: 1,000 条 - 街景图片: ~100,000 张 - 数据大小: ~30-40 GB VLN_mini (Visual OOD 评估): - 位置: /root/SFTvsRL_Data/VIRL_routes/VLN_mini/ - 路线数: 18 条(San Francisco) - 街景图片: ~2,000 张 - 数据大小: ~1-2 GB
问题:Google Street View API 提供 360° 全景图(equirectangular panorama),为什么要转换成 4 个方向的静态图片?
计算效率
模型输入限制
任务相关性
数据增强灵活性
与 V-IRL 原始设计一致
| 方案 | 全景图 | 4 方向静态图 |
|---|---|---|
| 分辨率 | 2048×1024 | 4 × 640×640 |
| 文件大小 | 2-6 MB | 1.2 MB |
| FOV 覆盖 | 360° × 180° | 4 × 90° = 360° (水平) |
| 处理复杂度 | 需要 equirectangular 投影处理 | 直接使用 |
| 模型适配 | 需要特殊处理 | 标准 2D CNN/ViT |
| 存储成本 | 高 (100K × 6MB = 600GB) | 低 (100K × 1.2MB = 120GB) |
Distortion(畸变):
信息冗余:
计算开销:
def _get_visual_observation(self): """ 获取当前位置的 4 方向街景图片 Returns: np.array: [2405, 2405, 3] 的 RGB 图片 """ # 1. 从 Platform 获取 4 张图片 image_list = self.platform.get_all_streetview_from_geocode( geocode=self.current_geocode, cur_heading=self.current_heading ) # image_list = [front, right, back, left] (each 640×640) # 2. 调整为统一分辨率 resized_images = [ image.resize((self.resolution, self.resolution)) # 1200×1200 for image in image_list ] # 3. 拼接为 2×2 网格 line_width = 5 # 黑色分隔线 canvas = Image.new('RGB', (self.resolution * 2 + line_width, # 2405 self.resolution * 2 + line_width), # 2405 (0, 0, 0)) # 黑色背景 # 放置图片: # [0,0] -> (0, 0) Front # [1,0] -> (1205, 0) Right # [0,1] -> (0, 1205) Back # [1,1] -> (1205, 1205) Left for i, image in enumerate(resized_images): x = (i % 2) * (self.resolution + line_width) y = (i // 2) * (self.resolution + line_width) canvas.paste(image, (x, y)) return np.array(canvas) # [2405, 2405, 3]
2×2 Street View Grid (2405 × 2405 pixels) ┌─────────────────────┬─────────────────────┐ │ │ │ │ Front View │ Right View │ │ (1200×1200) │ (1200×1200) │ │ │ │ │ Heading: 0° │ Heading: 90° │ │ │ │ ├─────────────────────┼─────────────────────┤ │ │ │ │ Back View │ Left View │ │ (1200×1200) │ (1200×1200) │ │ │ │ │ Heading: 180° │ Heading: 270° │ │ │ │ └─────────────────────┴─────────────────────┘ 黑色分隔线:5 pixels
def formulate_payload(self, question, obs=None): """ 构造 Llama-3.2-Vision 的输入格式 Args: question: 文本 prompt obs: PIL.Image or np.array """ self.payload = [ { "role": "user", "content": [{"type": "text", "text": question}] } ] if obs is not None: # 转换为 PIL Image if isinstance(obs, np.ndarray): obs = Image.fromarray(obs) # 插入到 content 最前面(Llama 格式要求) self.payload[0]['content'].insert(0, { "type": "image", "image": obs }) def process_input(self, obs, prompt): """ 使用 processor 处理输入 """ # 1. Apply chat template input_text = self.processor.apply_chat_template( self.payload, add_generation_prompt=True ) # 2. Process image + text inputs = self.processor( obs, # PIL Image input_text, # Formatted prompt return_tensors="pt", add_special_tokens=False ).to(self.model.device) # inputs = { # 'input_ids': tensor([[..., image_tokens, ..., text_tokens]]), # 'attention_mask': tensor([[1, 1, ..., 1]]), # 'pixel_values': tensor([[[...]]]), # 处理后的图片特征 # 'cross_attention_mask': tensor([[[...]]]) # } return inputs
Input Image (2405×2405×3) ↓ Llama-3.2-Vision Processor ↓ ├─ Image Processor: │ ├─ Resize to model input size │ ├─ Normalize (mean=[0.48145466, 0.4578275, 0.40821073]) │ └─ Convert to tensor │ └─ Vision Encoder (CLIP-based): ├─ Patch Embedding (16×16 patches) ├─ Vision Transformer Layers └─ Output: Visual tokens (sequence length ~1000) ↓ Cross-Attention with Language Model ↓ Language Decoder generates action JSON
# 图片获取配置 platform_cfg: STREET_VIEW: SIZE: [640, 640] # 单张原始图片尺寸 FOV: 90 # 视场角(degrees) PITCH: 0 # 俯仰角(水平) SOURCE: outdoor # 室外街景 # 环境配置 env_config: resolution: 1200 # 每张图片调整后的尺寸 # 最终输入:2×1200 + 5 = 2405 pixels # 模型配置(Llama-3.2-Vision 内置) model: vision_encoder: image_size: 560 # 模型输入尺寸(自动调整) patch_size: 14 # Patch embedding size hidden_size: 1280 # Vision hidden dimension
SFT 初始化 ↓ ┌────────────────────────────────────────┐ │ RL Training Loop (15 Updates) │ │ │ │ For update in [0, 1, ..., 14]: │ │ │ │ ┌──────────────────────────────────┐ │ │ │ Phase 1: Rollout (256 steps) │ │ │ │ ├─ 多个 episodes │ │ │ │ ├─ 收集 (obs, action, reward) │ │ │ │ └─ 计算 value predictions │ │ │ └──────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────┐ │ │ │ Phase 2: PPO Training (4 epochs)│ │ │ │ ├─ Compute advantages (GAE) │ │ │ │ ├─ 4 epochs × 256 samples │ │ │ │ ├─ Update value network │ │ │ │ └─ Update learning rate │ │ │ └──────────────────────────────────┘ │ │ │ └────────────────────────────────────────┘ ↓ 保存 Final Checkpoint
PPO 是一种 on-policy 强化学习算法,由 OpenAI 在 2017 年提出。它通过限制策略更新的幅度来保证训练稳定性,是目前最流行的 RL 算法之一。
问题背景:
在策略梯度方法中,我们希望最大化期望回报:
传统的 Policy Gradient 方法(如 REINFORCE)直接用梯度上升更新策略,但存在两个问题:
PPO 的解决方案:
PPO 通过引入 importance sampling 实现数据复用,同时使用 clipping 机制限制策略更新幅度,在样本效率和训练稳定性之间取得平衡。
核心问题:如何用旧策略 \pi_{\theta_{old}} 采集的数据来更新新策略 \pi_\theta?
Importance Sampling 公式:
其中,\frac{\pi_\theta(a|s)}{\pi_{\theta_{old}}(a|s)} 称为 importance ratio(重要性比率)。
在 RL 中的应用:
我们可以用旧策略收集的轨迹来估计新策略的期望回报:
问题:当 \pi_\theta 与 \pi_{\theta_{old}} 差异过大时,importance ratio 方差爆炸,导致训练不稳定。
PPO 通过 clipping 机制限制 importance ratio 的范围,防止策略更新过大。
定义 ratio:
PPO-Clip 目标函数:
其中:
直觉理解:
当 Advantage > 0(好的动作):
当 Advantage < 0(坏的动作):
数学表达:
这保证了策略不会偏离旧策略太远,从而维持训练稳定性。
Advantage 函数定义:
它衡量在状态 s_t 执行动作 a_t 相比平均水平的优劣。
问题:我们没有真实的 Q 和 V,需要估计。
Generalized Advantage Estimation (GAE):
GAE 是一种平衡偏差(bias)和方差(variance)的 advantage 估计方法:
其中:
递归计算(从后往前):
直觉理解:
Return 计算:
这个 \hat{R}_t 用于训练 value network。
除了策略损失,PPO 还需要训练 value network 来估计状态价值:
其中:
Clipping 的作用:
防止 value function 更新过大,与 policy clipping 类似的稳定性考虑。
为了鼓励探索,PPO 添加 entropy bonus:
其中 H 是策略分布的熵(entropy)。熵越大,策略越随机,探索性越强。
PPO 的最终损失函数是三项的加权和:
在本实验中:
训练过程:
ppo_config: clip_param: 0.1 # ε = 0.1,限制 ratio 在 [0.9, 1.1] ppo_epoch: 4 # 每批数据训练 4 个 epochs mini_batch_size: 1 # 逐样本训练(因为 VLM 内存占用大) value_loss_coef: 0.5 # c_1,value loss 权重 entropy_coef: 0.01 # c_2,entropy bonus 权重 max_grad_norm: 0.01 # 梯度裁剪阈值(非常小,保证稳定)
def compute_returns(self, next_value, gamma=0.9, gae_lambda=0.95): """ 使用 Generalized Advantage Estimation (GAE) 计算每步的 return 和 advantage Args: next_value: 最后一步的 value prediction gamma: 折扣因子 gae_lambda: GAE λ 参数 """ self.value_preds[-1] = next_value gae = 0 # 从后向前计算 for step in reversed(range(self.num_steps)): # 255 → 0 # TD error delta = (self.rewards[step] + gamma * self.value_preds[step + 1] * self.masks[step + 1] - self.value_preds[step]) # GAE 累积 gae = delta + gamma * gae_lambda * self.masks[step + 1] * gae # Return = Advantage + Value self.returns[step] = gae + self.value_preds[step] # 标准化 advantages(用于训练稳定) advantages = self.returns[:-1] - self.value_preds[:-1] advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-5) return advantages
def ppo_update(self, rollouts): """ PPO 训练:4 epochs × 256 samples 每个 epoch 遍历所有 256 个样本(mini_batch_size=1) """ advantages = compute_advantages(rollouts) for epoch in range(4): # ppo_epoch = 4 for sample_idx in range(256): # num_steps = 256 # 1. 获取样本 obs_batch = rollouts.obs[sample_idx] old_action_log_prob = rollouts.action_log_probs[sample_idx] return_batch = rollouts.returns[sample_idx] value_pred_old = rollouts.value_preds[sample_idx] advantage = advantages[sample_idx] # 2. 重新评估(带梯度) new_value, new_action_log_prob = actor_critic.evaluate_actions( **obs_batch['io_dict'] ) # 3. Compute probability ratio ratio = torch.exp(new_action_log_prob - old_action_log_prob) # 4. Policy Loss (Clipped Surrogate Objective) surr1 = ratio * advantage surr2 = torch.clamp(ratio, 1.0 - clip_param, 1.0 + clip_param) * advantage # Ratio clipping protection (防止梯度爆炸) if torch.any(ratio > 10): policy_loss = -surr2.mean() else: policy_loss = -torch.min(surr1, surr2).mean() # 5. Value Loss (Clipped) value_pred_clipped = (value_pred_old + torch.clamp(new_value - value_pred_old, -clip_param, clip_param)) value_losses = (new_value - return_batch).pow(2) value_losses_clipped = (value_pred_clipped - return_batch).pow(2) value_loss = 0.5 * torch.max(value_losses, value_losses_clipped).mean() # 6. Total Loss loss = (value_loss * value_loss_coef + # 0.5 policy_loss + entropy_loss * entropy_coef) # 0.01 # 7. Backward & Update accelerator.backward(loss) if accelerator.sync_gradients: accelerator.clip_grad_norm_( actor_critic.parameters(), max_grad_norm # 0.01 ) optimizer.step() optimizer.zero_grad()
class VLMValue(nn.Module): """ Value Network: 用于估计状态价值 """ def __init__(self, base): super().__init__() self.base = base # Llama-3.2-11B-Vision (冻结 generation 路径) # 3-layer MLP value head self.value_head = nn.Sequential( nn.Linear(4096, 1024), nn.ReLU(), nn.Linear(1024, 512), nn.ReLU(), nn.Linear(512, 1) ).to(base.device, dtype=torch.bfloat16) def forward(self, inputs): # 前向传播获取 hidden states outputs = self.base(**inputs, output_hidden_states=True) hidden_states = outputs.hidden_states # All layers # 使用最后一层的最后一个 token last_hidden = hidden_states[-1][:, -1] # [batch, 4096] # Value prediction values = self.value_head(last_hidden) # [batch, 1] return values class VLMPolicy(nn.Module): """ Policy Network: 包装 Value Network + Generation """ def __init__(self, tokenizer, value_model, generation_config): super().__init__() self.tokenizer = tokenizer self.value_model = value_model self.base = value_model.base self.temperature = generation_config.temperature self.max_new_tokens = generation_config.max_new_tokens def act_oneline(self, inputs, obs=None): """ 生成动作(inference,无梯度) Returns: values: 状态价值估计 io_dict: 输入输出字典(用于后续训练) output_text: 解码后的 JSON 文本 action_log_prob: 动作对数概率 """ with torch.no_grad(): # 1. Generation outputs = self.base.generate( **inputs, max_new_tokens=self.max_new_tokens, temperature=self.temperature, output_scores=True, output_hidden_states=True, return_dict_in_generate=True ) output_ids = outputs['sequences'][:, inputs['input_ids'].shape[1]:] output_text = self.tokenizer.decode(output_ids[0], skip_special_tokens=True) # 2. 拼接 input + output cated_io = torch.cat((inputs['input_ids'], output_ids), dim=1) # 3. Prepare inputs for evaluation io_dict = self._prepare_io_dict(inputs, output_ids) # 4. Evaluate to get value & log_prob values, sum_log_prob, action_tokens_log_prob = \ self.evaluate(**io_dict, inference=True) return values, io_dict, output_text, sum_log_prob, action_tokens_log_prob def evaluate_actions(self, **io_dict): """ 重新评估动作(training,带梯度) Returns: values: 新的 value 估计 action_log_probs: 新的 log 概率 """ # 1. Forward pass with gradients outputs = self.base( **io_dict['new_inputs'], output_hidden_states=True ) # 2. Compute value hidden_states = outputs.hidden_states[-1][:, -1] values = self.value_model.value_head(hidden_states) # 3. Compute log probabilities logits = outputs.logits output_ids = io_dict['io_pair'][1] # Generated tokens action_log_probs = self._compute_log_probs(logits, output_ids) return values, action_log_probs
本实验使用 DeepSpeed ZeRO Stage 2 在 8 张 GPU 上进行分布式训练。DeepSpeed 是微软开发的深度学习优化库,ZeRO(Zero Redundancy Optimizer)是其核心技术。
内存挑战:
训练 11B 参数的 Llama-3.2-Vision 模型面临巨大的内存压力:
模型参数: - Base Model: 11B × 2 bytes (bf16) = 22 GB - Value Head: 4096×1024 + 1024×512 + 512×1 ≈ 5M × 2 = 10 MB 优化器状态(Adam): - Momentum: 11B × 4 bytes (fp32) = 44 GB - Variance: 11B × 4 bytes (fp32) = 44 GB 梯度: - Gradients: 11B × 2 bytes (bf16) = 22 GB 激活值(Activations): - Forward pass: ~20-40 GB (取决于 batch size) - Backward pass: ~20-40 GB 总计:~170-190 GB
单张 H800 GPU 只有 80 GB 内存,无法容纳完整的训练状态!
传统数据并行的问题:
传统 DDP(Distributed Data Parallel)在每张 GPU 上保存完整的模型副本:
GPU 0: [完整模型 + 完整优化器状态 + 梯度] = 170 GB ✗ GPU 1: [完整模型 + 完整优化器状态 + 梯度] = 170 GB ✗ ... GPU 7: [完整模型 + 完整优化器状态 + 梯度] = 170 GB ✗
内存冗余问题:
Zero Redundancy Optimizer 通过消除冗余来减少内存占用:
核心原理:分片存储(Partitioning)+ 通信重建(Communication)
不存储冗余数据,而是: 1. 将数据分片存储在不同 GPU 上 2. 需要时通过通信(All-Gather)重建完整数据 3. 用通信时间换内存空间
ZeRO 的三个阶段:
| Stage | 分片内容 | 内存节省 | 通信开销 |
|---|---|---|---|
| ZeRO-1 | Optimizer States | ~4× | 低 |
| ZeRO-2 | + Gradients | ~8× | 中等 |
| ZeRO-3 | + Parameters | ~64× | 高 |
本实验使用 ZeRO Stage 2,在内存节省和通信开销间取得平衡。
内存分片策略:
# 8 张 GPU,每张 GPU 只存储 1/8 的优化器状态和梯度 GPU 0: - 完整模型参数 (22 GB) - Optimizer States [0:N/8] (44/8 = 5.5 GB) - Gradients [0:N/8] (22/8 = 2.75 GB) - Activations (~20 GB) → Total: ~50 GB ✓ GPU 1: - 完整模型参数 (22 GB) - Optimizer States [N/8:2N/8] (5.5 GB) - Gradients [N/8:2N/8] (2.75 GB) - Activations (~20 GB) → Total: ~50 GB ✓ ... (GPU 2-7 类似)
训练流程:
Forward Pass(前向传播)
每张 GPU 独立计算: - 输入:各自的 batch(总 batch / 8) - 使用:完整模型参数(所有 GPU 相同) - 输出:各自的 loss 和 activations
Backward Pass(反向传播)
每张 GPU 独立计算梯度: GPU i: ∂L/∂θ (完整梯度) 然后 Reduce-Scatter: GPU i: 只保留 ∂L/∂θ[i×N/8:(i+1)×N/8] → 每张 GPU 只存储 1/8 的梯度
Optimizer Step(参数更新)
每张 GPU 更新自己负责的参数分片: GPU 0: θ[0:N/8] ← θ[0:N/8] - lr × ∂L/∂θ[0:N/8] GPU 1: θ[N/8:2N/8] ← θ[N/8:2N/8] - lr × ∂L/∂θ[N/8:2N/8] ... 然后 All-Gather: 所有 GPU 广播自己更新的参数,重建完整模型
关键通信操作:
Reduce-Scatter(梯度聚合 + 分片)
输入:每张 GPU 的完整梯度 操作:求和并分片 输出:每张 GPU 得到 1/8 的聚合梯度 时间复杂度:O(N/P) where P=8
All-Gather(参数重建)
输入:每张 GPU 的 1/8 参数 操作:收集并广播 输出:每张 GPU 得到完整参数 时间复杂度:O(N/P)
本实验还使用了 CPU Offloading:
deepspeed_config: offload_optimizer_device: cpu # Optimizer 状态卸载到 CPU offload_param_device: none # 参数不卸载
工作原理:
训练时: 1. Optimizer States 存储在 CPU 内存(便宜且大容量) 2. 需要更新时,传输到 GPU 计算 3. 更新完成后,传回 CPU 内存分布: GPU: 模型参数 (22 GB) + 梯度 (2.75 GB) + Activations (20 GB) ≈ 45 GB ✓ CPU: Optimizer States (5.5 GB per GPU) → 不占用 GPU 内存
权衡:
对于 11B 模型,这个权衡是值得的,因为避免了 OOM(Out of Memory)。
配合 ZeRO,本实验使用 梯度累积 128 步:
grad_accum_steps: 128
目的:模拟更大的 batch size
实际流程: for i in range(128): # Forward & Backward(不更新参数) loss = model(batch_i) loss.backward() # 梯度累积 # 累积 128 次后才更新 optimizer.step() # 使用累积的梯度 optimizer.zero_grad()
等效 Batch Size:
Per-GPU Batch Size: 1 Num GPUs: 8 Grad Accum Steps: 128 Effective Batch Size = 1 × 8 × 128 = 1024
为什么需要梯度累积?
mixed_precision: bf16 downcast_bf16: 'yes'
BFloat16 vs Float32:
| 类型 | 位数 | 指数位 | 尾数位 | 范围 | 精度 |
|---|---|---|---|---|---|
| FP32 | 32 | 8 | 23 | ±3.4×10³⁸ | 高 |
| BF16 | 16 | 8 | 7 | ±3.4×10³⁸ | 中 |
| FP16 | 16 | 5 | 10 | ±6.5×10⁴ | 中 |
BF16 优势:
训练流程:
# Forward & Backward in BF16 with autocast(dtype=torch.bfloat16): output = model(input) loss = criterion(output, target) loss.backward() # 梯度 in BF16 # Optimizer in FP32(Master Weights) optimizer.step() # 更新 FP32 参数 model.to(torch.bfloat16) # 转回 BF16 供下次前向
单 GPU 内存分布(ZeRO-2 + Offload + BF16):
模型参数(BF16): 11B × 2 bytes = 22 GB Gradients(BF16, 1/8): 11B × 2 / 8 = 2.75 GB Activations(BF16): ~15-20 GB Optimizer States(CPU Offload): 0 GB (在 CPU 上) 临时缓冲区: ~5 GB Total per GPU: ~45-50 GB / 80 GB = 56-62% 占用率 ✓
对比不同配置:
| 配置 | 内存占用 | 是否可行 |
|---|---|---|
| 单 GPU,无优化 | 170 GB | ✗ OOM |
| DDP,8 GPU | 170 GB each | ✗ OOM |
| ZeRO-2,8 GPU | 55 GB each | ✓ |
| ZeRO-2 + Offload | 50 GB each | ✓ |
| ZeRO-3 | 30 GB each | ✓(但通信慢) |
通信开销:
# scripts/config_zero2_8gpu.yaml compute_environment: LOCAL_MACHINE distributed_type: DEEPSPEED # 核心配置 deepspeed_config: zero_stage: 2 # ZeRO Stage 2 offload_optimizer_device: cpu # Optimizer → CPU offload_param_device: none # 参数保留在 GPU zero3_init_flag: false # 不使用 ZeRO-3 初始化 overlap_comm: false # 不重叠通信与计算(更稳定) # 精度配置 mixed_precision: bf16 # BFloat16 混合精度 downcast_bf16: 'yes' # 自动转换 # 分布式配置 num_machines: 1 # 单节点 num_processes: 8 # 8 GPUs rdzv_backend: static # 静态拓扑(无动态加入) same_network: true # 同一网络(低延迟)
为什么不用 ZeRO-3?
ZeRO-3 节省更多内存但通信开销大:
| Stage | 通信次数/步 | 通信量/步 | 训练速度 |
|---|---|---|---|
| ZeRO-2 | 2 次 | 22 GB | 1.0× |
| ZeRO-3 | 4 次 | 44 GB | 0.6× |
对于 11B 模型,ZeRO-2 已经足够,无需 ZeRO-3 的额外开销。
吞吐量:
Rollout Phase(256 steps): - 时间:~40 分钟 - 速度:6.4 steps/min - 瓶颈:环境交互 + 模型推理 PPO Training Phase(4 epochs × 256 samples): - 时间:~20 分钟 - 速度:51.2 samples/min - 瓶颈:梯度计算 + 通信 Total per Update: ~60 分钟 Total Training (15 updates): ~15 小时
扩展性分析:
| GPU 数量 | 理论加速比 | 实际加速比 | 效率 |
|---|---|---|---|
| 1 | 1.0× | 1.0× | 100% |
| 2 | 2.0× | 1.8× | 90% |
| 4 | 4.0× | 3.4× | 85% |
| 8 | 8.0× | 6.2× | 78% |
效率损失主要来自通信开销,但仍然实现了 6× 加速。
WandB 记录的关键指标:
wandb.log({ # 训练进度 'total_num_steps': total_steps, 'compute_tokens': token_count, # Loss 'value_loss': value_loss, 'action_loss': policy_loss, 'dist_entropy': entropy, # Reward 统计 'reward.mean': rewards.mean(), 'reward.std': rewards.std(), 'reward.max': rewards.max(), 'reward.min': rewards.min(), # Value 统计 'value.mean': values.mean(), 'value.std': values.std(), # Return 统计 'return.mean': returns.mean(), 'return.std': returns.std(), # Episode 统计 'episode_rewards.mean': np.mean(episode_rewards), 'success_rate': success_rate, 'per_step_accuracy': step_accuracy })
| 类别 | 参数 | 值 | 说明 |
|---|---|---|---|
| 训练规模 | num_updates |
15 | 总训练轮数 |
num_steps |
256 | 每轮收集步数 | |
ppo_epoch |
4 | PPO 训练 epochs | |
grad_accum_steps |
128 | 梯度累积步数 | |
| 学习率 | init_lr |
1e-7 | 初始学习率 |
lr_max_steps |
20 | LR 调度器总步数 | |
end_lr |
1e-9 | 最终学习率 | |
| PPO | clip_param |
0.1 | PPO clip 范围 |
value_loss_coef |
0.5 | Value loss 系数 | |
entropy_coef |
0.01 | Entropy 系数 | |
max_grad_norm |
0.01 | 梯度裁剪阈值 | |
| GAE | gamma |
0.9 | 折扣因子 |
gae_lambda |
0.95 | GAE λ | |
| 环境 | verify_iter |
2 | 验证尝试次数 |
resolution |
1200 | 图片分辨率 | |
| 生成 | temperature |
0.2 | 生成温度 |
max_new_tokens |
512 | 最大生成长度 |
# rl/configs/llama_virl_vl.yaml trainer: LlamaTrainer # 梯度累积配置 grad_accum_steps: 128 # 优化器配置 optimizer_config: init_lr: !!float 1e-6 # 会被脚本覆盖为 1e-7 eps: !!float 1e-7 weight_decay: 0 lr_max_steps: 100 # 会被脚本覆盖为 20 end_lr: !!float 1e-9 # PPO 配置 ppo_config: clip_param: 0.1 # ε in PPO clip ppo_epoch: 4 # 每轮 PPO 训练 epochs mini_batch_size: 1 # 批量大小 value_loss_coef: 0.5 # Value loss 权重 entropy_coef: 0.01 # Entropy bonus 权重 max_grad_norm: 0.01 # 梯度裁剪 # Return 计算配置 compute_return_kwargs: use_gae: true # 使用 GAE gamma: 0.9 # 折扣因子 γ gae_lambda: 0.95 # GAE λ use_proper_time_limits: False # 训练配置 report_to: wandb # 记录到 WandB run_name: "virl_vl_training" num_steps: 512 # 会被脚本覆盖为 256 num_processes: 1 num_updates: 20 # 会被脚本覆盖为 15 # 环境配置 env_config: id: 'gym_virl/Navigation-v0' route_info_path: "" resolution: 1200 verify_iter: 2 absolute_action: true relocation: true drop_rate: 0.5 straight_line_length: 5 platform_cfg: STREET_VIEW: SIZE: [640, 640] HEADING: 0 PITCH: 0 FOV: 90 SOURCE: outdoor OFFLINE: ENABLED: True PANORAMA_DIR: "" GPS_TO_PANO_PATH: "" MAPPING_RADIUS: 20 platform_save_dir: "./logs/" # 模型配置 model: llama model_path: "" # Prompt 配置 prompt_config: relocation: true use_vision: true use_language: false enable_verification: true prompt_vision: ["Q_VIRL_VL"] pattern_vision: ["action"] # 生成配置 generation_config: temperature: 0.2 max_tokens: 300 max_new_tokens: 512 thought_prob_coef: 0.5 num_beams: 1 # 输出配置 output_dir: logs/train.jsonl seed: 42 save_ckpt: False save_every: 1
# scripts/virl_training/vl_train.sh # 训练参数 LR=1e-7 save_model=True save_every=5 # 每 5 个 updates 保存一次 CKPT_NAME="tianzhechu/VIRL-VL-Init" PORT=$((RANDOM % 10000 + 1000)) # 数据路径(使用绝对路径) BASE_DIR="/root/SFTvsRL_Data/VIRL_routes" ROUTE_INFO="${BASE_DIR}/nyc_1k_routes/route_infos.json" GPS_TO_PANO="${BASE_DIR}/nyc_1k_routes/gps_pano_mapping.pkl" STREETVIEWS="${BASE_DIR}/nyc_1k_routes/street_views/" # 启动训练 DS_SKIP_CUDA_CHECK=1 TOKENIZERS_PARALLELISM=false \ accelerate launch \ --config_file scripts/config_zero2_8gpu.yaml \ --main_process_port ${PORT} -m rl.launcher \ -f rl/configs/llama_virl_vl.yaml \ --output_dir=train_ckpt/virl_vl/ \ --optimizer_config.init_lr=${LR} \ --optimizer_config.lr_max_steps=20 \ --prompt_config.enable_verification=True \ --num_updates=15 \ --num_steps=256 \ --model_path=${CKPT_NAME} \ --save_ckpt=${save_model} \ --save_every=${save_every} \ --env_config.route_info_path=${ROUTE_INFO} \ --env_config.platform_cfg.OFFLINE.PANORAMA_DIR=${STREETVIEWS} \ --env_config.platform_cfg.OFFLINE.GPS_TO_PANO_PATH=${GPS_TO_PANO}
# scripts/config_zero2_8gpu.yaml compute_environment: LOCAL_MACHINE deepspeed_config: offload_optimizer_device: cpu # Optimizer offload 到 CPU offload_param_device: none # 参数不 offload zero3_init_flag: false zero_stage: 2 # ZeRO Stage 2 overlap_comm: false distributed_type: DEEPSPEED downcast_bf16: 'yes' # BF16 混合精度 machine_rank: 0 main_training_function: main mixed_precision: bf16 num_machines: 1 num_processes: 8 # 8 GPUs rdzv_backend: static same_network: true use_cpu: false
为什么 lr=1e-7 这么小?
为什么 lr_max_steps=20 这么短?
lr_scheduler.step()为什么 max_grad_norm=0.01 这么小?
为什么 verify_iter=2?
# 1. In-Distribution Evaluation bash scripts/virl_evaluation/vl_indist_eval.sh # 2. Rule OOD Evaluation bash scripts/virl_evaluation/vl_rule_ood_eval.sh # 3. Visual OOD Evaluation bash scripts/virl_evaluation/vl_visual_ood_eval.sh
| 配置项 | In-Dist | Rule OOD | Visual OOD |
|---|---|---|---|
num_traj |
48 | 48 | 18 |
absolute_action |
True | False | True |
route_info_path |
NYC routes | NYC routes | SF routes |
verify_iter |
2 | 2 | 2 |
| GPU 数量 | 1 | 1 | 1 |
定义:单步动作正确率
per_step_accuracy = ( num_correct_actions / total_actions ) × 100%
示例:
Route 1: 15 steps, 13 correct → 13/15 = 86.7% Route 2: 20 steps, 18 correct → 18/20 = 90.0% ... Route 48: 12 steps, 10 correct → 10/12 = 83.3% Overall Per-Step Accuracy = (13 + 18 + ... + 10) / (15 + 20 + ... + 12) = 87.5%
定义:完整路线成功率
success_rate = ( num_successful_routes / total_routes ) × 100%
成功条件:
示例:
48 routes: - 35 routes: 成功 ✓ - 10 routes: 部分失败(某些 waypoint 错误)✗ - 3 routes: 完全失败(未到达目的地)✗ Success Rate = 35 / 48 = 72.9%
metrics = { 'mean_reward': 平均每条路线的总奖励, 'std_reward': 奖励标准差, 'mean_steps': 平均步数, 'mean_verification_steps': 平均验证步数(包括重试) }
// logs/virl_vl_indist_verify_2/virl_vl_indist.jsonl // Route 1, Step 0 {"sample_id": 0, "veri_step": 0, "output": "{\"action\": \"turn_direction(south)\"}", "reward": 1, "info": {...}} // Route 1, Step 1 {"sample_id": 0, "veri_step": 1, "output": "{\"action\": \"forward()\"}", "reward": 1, "info": {...}} // ... more steps ... // Route 1, Final step {"sample_id": 0, "veri_step": 14, "output": "{\"action\": \"stop()\"}", "reward": 1, "info": {...}} // Route 1 Summary {"Success": true, "sample_id": 0, "output": "{\"action\": \"stop()\"}", "reward": 15, "info": {...}} {"Split": "===================="} // Route 2, Step 0 (Failed attempt) {"sample_id": 1, "veri_step": 0, "output": "{\"action\": \"turn_direction(north)\"}", "reward": -1, "info": {"Verify Info": "Incorrect action..."}} // Route 2, Step 1 (Retry, Success) {"sample_id": 1, "veri_step": 1, "output": "{\"action\": \"turn_direction(south)\"}", "reward": 1, "info": {...}} // ... 47 more routes ... // Overall Statistics { "mean_reward": 12.5, "std_reward": 3.2, "success_rate": 0.729, "per_step_accuracy": 0.875, "mean_steps": 14.2, "mean_verification_steps": 1.15 }
#!/bin/bash # scripts/virl_evaluation/vl_indist_eval.sh VITER=2 # 验证尝试次数 ENABLE=True # 启用验证机制 ABS=True # 使用绝对动作空间 NUM_TRAJ=48 # 评估 48 条路线 CKPT_NAME="train_ckpt/virl_vl/checkpoint-epoch-14" # 训练后的 checkpoint OUTPUT_FOLDER="logs/virl_vl_indist_verify_${VITER}" PORT=$((RANDOM % 10000 + 2000)) # 数据路径(使用绝对路径) BASE_DIR="/root/SFTvsRL_Data/VIRL_routes" ROUTE_INFO="${BASE_DIR}/nyc_1k_routes/route_infos.json" GPS_TO_PANO="${BASE_DIR}/nyc_1k_routes/gps_pano_mapping.pkl" STREETVIEWS="${BASE_DIR}/nyc_1k_routes/street_views/" # 使用 1 GPU 进行评估 DS_SKIP_CUDA_CHECK=1 accelerate launch \ --config_file scripts/config_zero2_1gpu.yaml \ --main_process_port ${PORT} \ -m evaluation.launcher \ -f evaluation/configs/llama_virl_vl.yaml \ --model_path=${CKPT_NAME} \ --output_dir=${OUTPUT_FOLDER}/virl_vl_indist.jsonl \ --env_config.route_info_path=${ROUTE_INFO} \ --env_config.platform_cfg.OFFLINE.PANORAMA_DIR=${STREETVIEWS} \ --env_config.platform_cfg.OFFLINE.GPS_TO_PANO_PATH=${GPS_TO_PANO} \ --prompt_config.enable_verification=${ENABLE} \ --env_config.verify_iter=${VITER} \ --env_config.absolute_action=${ABS} \ --num_traj=${NUM_TRAJ}
根据论文 Figure 1 和实验数据:
| 模型 | Per-Step Accuracy | Success Rate |
|---|---|---|
| SFT | ~85% | ~60% |
| RL (PPO) | ~90% | ~75% |
结论:RL 在训练分布上也优于 SFT(+5% step accuracy)
| 模型 | In-Dist | Rule OOD | Generalization Gap |
|---|---|---|---|
| SFT | 85% | 15% | -70% |
| RL | 90% | 70% | -20% |
关键发现:
| 模型 | NYC (In-Dist) | SF (Visual OOD) | Generalization Gap |
|---|---|---|---|
| SFT | 85% | <10% | -75% |
| RL | 90% | ~60% | -30% |
关键发现:
论文通过消融实验分析了 RL 的泛化机制:
实验设计:
结果:
原理:
Process Reward: Step 1: +1 (correct action, but maybe wrong reasoning) → 模型可能依赖 shortcuts(如记忆模式) Outcome Reward: All steps: 0, 0, 0, ..., +10 (final success) → 模型必须学习端到端推理,包括视觉理解
实验:直接用 RL 从 base model 训练
结果:失败(见论文 Figure 20)
结论:
Per-Step Accuracy over Training SFT: Update 0-5: 快速上升 (0% → 80%) Update 5-10: 继续上升 (80% → 85%) Update 10-20: 过拟合开始 (85% → 85%) Rule OOD: 持续下降 (80% → 15%) → 记忆训练规则 RL: Update 0-5: 稳定上升 (85% → 88%) Update 5-10: 继续上升 (88% → 90%) Update 10-15: 保持稳定 (90% → 90%) Rule OOD: 同步上升 (60% → 70%) → 学习可泛化规则
论文在 V-IRL 官方 benchmark 上达到 SOTA:
| 方法 | Success Rate |
|---|---|
| GPT-4V (Yang et al., 2024) | 44.0% |
| RL (Ours) | 77.8% |
| 提升 | +33.8% |
说明:
论文提供了两类失败案例:
问题:直接 RL 训练生成非结构化输出 示例输出: "To solve this problem, we can use a brute force approach by generating all possible combinations... [生成 Python 代码]" 原因:Base model 未经过指令微调,不理解任务格式
问题:从严重过拟合的 SFT checkpoint 开始 RL 示例: Rule: Relative actions Model Output: "turn_direction(northwest)" # Still using absolute! 原因:SFT 过拟合太深,RL 无法纠正
启示:
主论文:Chu, T., Zhai, Y., Yang, J., et al. (2025). SFT Memorizes, RL Generalizes: A Comparative Study of Foundation Model Post-training. ICML 2025. arXiv:2501.17161
V-IRL 环境:Yang, J., et al. (2024). V-IRL: Grounding Virtual Intelligence in Real Life. V-IRL Platform
RL4VLM:Zhai, Y., et al. (2024). Fine-Tuning Large Vision-Language Models as Decision-Making Agents via Reinforcement Learning. RL4VLM
⚠️ 注意:请使用 bojieli fork 版本,它修复了官方版本中导致无法保存 checkpoint 的严重 bug(详见 4.3 节)。
Q0: 训练报错 TypeError: unsupported operand type(s) for %: 'int' and 'NoneType'?
git clone https://github.com/bojieli/SFTvsRL.git
Q1: 为什么 RL 训练这么慢?
Q2: 可以用更少的 GPUs 训练吗?
grad_accum_steps 保持有效 batch sizegrad_accum_steps 翻倍(128 → 256)Q3: 如何复现论文结果?
tianzhechu/VIRL-VL-Init)Q4: 为什么需要 SFT 初始化?
运行训练前确认:
git clone https://github.com/bojieli/SFTvsRL.git)⭐grep "self.save_every = save_every" rl/trainer/base_trainer.py)pip install -r requirements.txt && cd gym && pip install -e .)/root/SFTvsRL_Data/VIRL_routes/
nyc_1k_routes.zipVLN_mini.zip(用于 Visual OOD 评估)route_infos.json, gps_pano_mapping.pkl, street_views/tianzhechu/VIRL-VL-Init)ROUTE_INFO="/root/SFTvsRL_Data/VIRL_routes/nyc_1k_routes/route_infos.json"GPS_TO_PANO="/root/SFTvsRL_Data/VIRL_routes/nyc_1k_routes/gps_pano_mapping.pkl"STREETVIEWS="/root/SFTvsRL_Data/VIRL_routes/nyc_1k_routes/street_views/"wandb login)运行评估前确认:
train_ckpt/virl_vl/checkpoint-epoch-*)/root/SFTvsRL_Data/VIRL_routes/nyc_1k_routes//root/SFTvsRL_Data/VIRL_routes/VLN_mini/CKPT_NAME="train_ckpt/virl_vl/checkpoint-epoch-14"BASE_DIR="/root/SFTvsRL_Data/VIRL_routes"ROUTE_INFO 路径