Task6:基于 SO-101 数据微调 π0.5
前面已经完成了 SO-101 的遥操作、数据采集和 Diffusion Policy 训练。本次继续尝试 π0.5,使用已有的 LeRobot 数据,通过 LoRA 微调让预训练模型适应自己的机械臂任务。
整个流程仍然是:准备数据 → 检查输入输出 → 加载预训练模型 → 微调 → 保存 checkpoint → 实机测试。不同之处在于,这次需要同时处理图像、机器人状态和任务文字,并在部署时正确加载 LoRA 权重。
本文的命令按固定版本的 LeRobot 源码重新整理,作为复现参考;原始命令未完整保留,下面的起步参数没有在本次 RTX 4090 环境中重新实测。最后的训练曲线和实机现象来自第六周实验记录,不能视为下方参考参数产生的结果。
一、任务介绍与实验环境
本次使用 SO-101 机械臂,训练设备为 RTX 4090。Mac 负责连接机械臂和摄像头,服务器负责训练与模型推理。远程部署继续使用前文的链路:
Mac:采集相机图像和机械臂状态,发送观测、接收动作 ↕ SSH 隧道 服务器:加载策略,根据观测生成动作π0.5 属于视觉语言动作模型。对本文任务而言,可以把一次策略调用理解为:输入当前画面、机械臂状态和任务描述,输出后续一段时间的动作。
LoRA 则用于减少微调时需要更新的参数:保留基础模型,在部分层上训练较小的适配器。基础权重仍需加载,图像和动作的计算也仍然占用显存,所以选择 LoRA 后,batch size 和相机数量依然需要根据设备调整。LeRobot PEFT 说明
二、沿用前文的数据集
示例使用前文公开的数据集feng0724821/so101_test_record。任务描述为:
Place the bottle next to the national flag即把瓶子放到国旗旁边。为了便于复现,本文固定数据 revision 为e78431cbd96856c32822adc73f94ead78ea3d4a1,该版本包含:
- 50 个 episode,共 26,710 帧,采集频率为 30 FPS。
- 一路腕部相机
observation.images.wrist,视频分辨率为 640×480。 - 六维机械臂状态
observation.state和六维动作action。 - LeRobot Dataset v3.0 格式,视频编码为 AV1。
这里的一个 episode 对应一段示范轨迹;一帧则包含某个时刻的状态、动作及对应图像,二者不能混为一谈。数据集元数据
状态和动作的维度顺序均为:
shoulder_pan.pos shoulder_lift.pos elbow_flex.pos wrist_flex.pos wrist_roll.pos gripper.pos前五维对应机械臂关节位置,最后一维对应夹爪。这里不是末端的六维位姿。训练时保留数据原有的动作语义,部署时使用一致的关节顺序、校准和单位,不根据数值范围自行转换成角度或弧度。
这个公开版本与后续实验需要区分:第五周报告记录过扩充到 80 段的数据,第六周还涉及双摄采集的方块、盒子任务。本文用前文公开的单摄版本讲解训练流程,后面单独讨论第六周的部署现象。
三、准备训练环境并检查数据
以下命令在 RTX 4090 所在的 Linux 服务器执行。建议建立独立环境,避免与前文 Diffusion Policy 的依赖互相影响。
conda create-nlerobot-pi05python=3.12-yconda activate lerobot-pi05 condainstall-cconda-forgeffmpeg=7.1.1-ygitclone https://github.com/huggingface/lerobot.git lerobot-pi05cdlerobot-pi05gitcheckout e0d50211ef236143ae867228662b7dfaba554f02 python-mpipinstall-e".[pi,peft,training]"nvidia-smi python-c'import torch; print(torch.__version__, torch.cuda.is_available())'其中,pi安装 π 系列模型依赖,peft提供 LoRA 支持,training安装训练相关依赖。固定源码版本是为了让后面的参数名、数据处理和 checkpoint 加载方式保持一致。torch.cuda.is_available()应输出True。该版本依赖配置
π0.5 使用 PaliGemma tokenizer。先在 Hugging Face 完成google/paligemma-3b-pt-224的访问授权,再执行hf auth login登录自己的账号。π0.5 官方说明
1. 下载基础模型
在 LeRobot 仓库目录执行:
exportSO101_DATASET="feng0724821/so101_test_record"exportSO101_REVISION="e78431cbd96856c32822adc73f94ead78ea3d4a1"exportPI05_BASE_DIR="$PWD/../pi05_models/pi05_base"python -<<'PY' import os from huggingface_hub import snapshot_download snapshot_download( repo_id="lerobot/pi05_base", revision="b211f3d44c36b6acfcf7ae94a64e8e96f75a64ba", local_dir=os.environ["PI05_BASE_DIR"], ) PYrepo_id指定基础模型,revision固定模型版本,local_dir指定本地保存位置。后续训练直接读取PI05_BASE_DIR,方便保证训练和 adapter 重载使用同一份基础权重。重新打开终端时,需要再次设置这三个环境变量。
2. 读取一条数据检查字段
沿用前文的数据检查方式,先确认当前环境能读数据、解码视频,再启动训练:
python -<<'PY' import os from lerobot.datasets.lerobot_dataset import LeRobotDataset ds = LeRobotDataset( os.environ["SO101_DATASET"], revision=os.environ["SO101_REVISION"], video_backend="pyav", ) print("episodes:", ds.num_episodes) print("frames:", ds.num_frames) print("fps:", ds.fps) print("cameras:", ds.meta.camera_keys) sample = ds[0] for key in ("observation.images.wrist", "observation.state", "action"): print(key, tuple(sample[key].shape), sample[key].dtype) print("task:", sample["task"]) assert sample["observation.state"].shape == (6,) assert sample["action"].shape == (6,) for key in ("observation.state", "action"): assert {"q01", "q99"} <= ds.meta.stats[key].keys() PY预期核对到 50 段、26,710 帧、30 FPS,以及单路wrist。视频读取后通常变为通道在前的(3, 480, 640)张量;状态和动作均为(6,)。
其中,video_backend="pyav"指定解码后端,后面的训练也保持一致;sample["task"]是送入模型的任务文字。最后的断言检查状态和动作是否带有q01、q99,即 1% 和 99% 分位数。π0.5 默认使用这些统计量进行归一化,这份数据已经包含,不必为了切换模型重复计算。已保存的统计量
四、启动 LoRA 微调
下面给出完整训练入口。第一次执行时,先按本节末尾的方法改成 100 步试跑,确认链路正常后再使用完整训练设置。
mkdir-poutputs/train_logsset-opipefail lerobot-train\--dataset.repo_id="$SO101_DATASET"\--dataset.revision="$SO101_REVISION"\--dataset.video_backend=pyav\--dataset.eval_split=0.1\--policy.type=pi05\--policy.pretrained_path="$PI05_BASE_DIR"\--policy.device=cuda\--policy.dtype=bfloat16\--policy.gradient_checkpointing=true\--policy.chunk_size=50\--policy.n_action_steps=10\--policy.optimizer_lr=1e-4\--policy.scheduler_decay_lr=1e-5\--policy.scheduler_warmup_steps=500\--policy.scheduler_decay_steps=10000\--peft.method_type=LORA\--peft.r=16\--peft.lora_alpha=32\--accelerator.mixed_precision=bf16\--batch_size=1\--num_workers=2\--steps=10000\--save_freq=1000\--log_freq=10\--eval_steps=1000\--max_eval_samples=128\--env_eval_freq=0\--policy.push_to_hub=false\--wandb.enable=false\--output_dir=outputs/train/pi05_so101_lora\2>&1|teeoutputs/train_logs/pi05_so101_lora.log其中,各组参数的作用如下。
1. 数据与模型参数
dataset.repo_id、dataset.revision:指定数据集及版本,避免仓库更新后示范数量、相机字段发生变化。dataset.eval_split=0.1:按 episode 留出 10% 数据。该版本会为这份单任务数据留下末尾 5 段做验证,其余 45 段训练,避免同一段轨迹被随意按帧切开。policy.type=pi05:选择 π0.5 策略。policy.pretrained_path:加载基础模型权重。配合policy.type使用时,输入输出字段从当前数据集解析,因此仍使用observation.images.wrist,不用为了套别的示例改成image或补出双摄。
SO-101 的六维动作由模型内部适配,不需要手工把数据改成其他机器人的七维动作。特征解析与加载实现
2. LoRA 参数
peft.method_type=LORA:启用 LoRA 微调。peft.r=16:设置低秩适配器的秩,影响可训练参数量和适配容量;增大后不一定能提高实机效果。peft.lora_alpha=32:控制 LoRA 更新的缩放。普通 LoRA 使用alpha/r,这里为 2,但不能把它直接理解成学习率。
当前 π0.5 默认在动作专家的部分注意力投影,以及动作、时间投影上加入适配器。基础权重被冻结,这不是全参数训练。新建训练时使用peft参数即可,不要额外把用于加载已有 adapter 的policy.use_peft设置为true。π0.5 默认适配层
3. 显存和数据加载参数
policy.device=cuda:使用服务器 GPU。policy.dtype=bfloat16和accelerator.mixed_precision=bf16:分别设置模型计算精度和训练自动混合精度。policy.gradient_checkpointing=true:用反向传播时的部分重计算换取较低的激活显存。batch_size=1:每次处理一个样本,作为 24GB 显存环境的起点。num_workers=2:使用两个数据加载进程,主要影响 CPU 侧读取速度和内存。
本例没有开启梯度累积。先通过 100 步试跑观察显存占用;更换相机数量或模型实现后,也要重新检查。运行配置
4. 动作长度与学习率
chunk_size=50:模型一次预测 50 个时间步的动作。n_action_steps=10:使用标准select_action接口时,执行其中 10 步后请求新预测。它不会把训练目标的长度改成 10,远程服务如果有独立动作队列,还要看服务端实现。optimizer_lr=1e-4:设置峰值学习率。scheduler_warmup_steps=500:前 500 步逐渐提高学习率。scheduler_decay_steps=10000、scheduler_decay_lr=1e-5:在给定训练长度内按调度衰减到较低学习率。steps=10000:本轮训练的迭代次数,不是采集了 10,000 段轨迹,也不是 10,000 个 epoch。
调整学习率和训练长度时,同时观察训练、验证 loss,并保留几个阶段的 checkpoint 做实机比较。π0.5 参数定义
5. 日志、验证与保存
log_freq=10表示每 10 步打印训练信息;save_freq=1000表示每 1000 步保存 checkpoint;eval_steps=1000表示每 1000 步计算保留数据上的 loss。max_eval_samples=128限制验证样本数量,适合快速观察趋势,但不能代表完整五段验证轨迹的表现。这个流程仍使用数据集已有的全量统计量,也不应称为严格独立的泛化评估。
env_eval_freq=0关闭仿真环境评估;policy.push_to_hub=false、wandb.enable=false让模型和日志保留在本地。2>&1 | tee ...同时显示并保存标准输出和错误信息,方便后续排查。训练配置
6. 先做 100 步试跑
复制上面的训练命令,将steps、save_freq、scheduler_decay_steps改为100,scheduler_warmup_steps改为5,eval_steps改为0。输出目录和日志名称分别换成outputs/train/pi05_so101_smoke、outputs/train_logs/pi05_so101_smoke.log。
试跑主要检查预训练权重能否加载、数据能否进入模型、loss 是否为有限值,以及能否完成反向传播和保存。确认日志出现All keys loaded successfully!;若出现Returning model without loading pretrained weights,先解决权重加载问题,不能因为训练开始输出 loss 就继续。权重加载实现
通过后,再用正式目录运行完整命令。每个新实验使用独立输出目录,保留对应日志,便于比较参数变化。
五、检查 checkpoint 并恢复训练
训练输出在outputs/train/pi05_so101_lora/,最新 checkpoint 可以通过checkpoints/last/找到:
checkpoints/last/ ├── pretrained_model/ │ ├── adapter_config.json │ ├── adapter_model.safetensors │ ├── config.json │ ├── train_config.json │ ├── policy_preprocessor.json │ ├── policy_postprocessor.json │ └── 处理器关联的统计量文件 └── training_state/其中,adapter_model.safetensors保存 LoRA 权重,adapter_config.json记录适配器配置及基础模型位置,处理器文件保存预处理、归一化等信息,training_state保存继续训练需要的状态。last指向最新保存结果,并不表示实机效果最好。checkpoint 保存实现
LoRA checkpoint 仍依赖基础模型,不能只拷贝一个 adapter 权重文件就部署。尤其本文使用本地基础模型目录,迁移服务器时要同时保留基础权重,并检查 adapter 中记录的路径。
如果训练中断,使用保存的训练配置恢复:
lerobot-train\--config_path=outputs/train/pi05_so101_lora/checkpoints/last/pretrained_model/train_config.json\--resume=true这里config_path指向原训练配置,resume=true恢复已有训练状态。它与仅加载权重开始一轮新训练不同,也不等于自动延长已经完成的训练计划。
六、推理前先做离线检查
接入机械臂前,可以先取数据集的一条观测,在服务器检查“图像和状态 → 模型 → 六维动作”的链路。以下代码不连接机械臂:
python -<<'PY' import os from pathlib import Path import torch from lerobot.configs import PreTrainedConfig from lerobot.datasets.lerobot_dataset import LeRobotDataset from lerobot.policies import make_policy, make_pre_post_processors ckpt = Path("outputs/train/pi05_so101_lora/checkpoints/last/pretrained_model") ds = LeRobotDataset( os.environ["SO101_DATASET"], revision=os.environ["SO101_REVISION"], video_backend="pyav", ) cfg = PreTrainedConfig.from_pretrained(ckpt) cfg.pretrained_path = ckpt cfg.pretrained_revision = None cfg.device = "cuda" assert cfg.use_peft policy = make_policy(cfg, ds_meta=ds.meta).eval() policy.reset() pre, post = make_pre_post_processors( cfg, pretrained_path=ckpt, preprocessor_overrides={"device_processor": {"device": "cuda"}}, ) sample = ds[0] obs = {k: v for k, v in sample.items() if k.startswith("observation.") or k == "task"} with torch.inference_mode(): action = post(policy.select_action(pre(obs))) print("action shape:", tuple(action.shape)) print("action:", action) assert tuple(action.shape) == (1, 6) assert torch.isfinite(action).all() PY其中,make_policy负责组合加载基础模型和 adapter;pre使用训练保存的处理器整理观测,post将模型输出转换回机器人的动作尺度;(1, 6)表示一个样本对应六维动作。代码通过只能说明离线调用链正常,不能说明动作已经准确。策略与处理器工厂
继续采用 Mac 客户端和服务器推理时,也要检查服务端是否使用兼容 LoRA 的加载方式。本文固定版本中的旧async_inference/policy_server.py直接调用策略类的from_pretrained(),没有经过上述 PEFT 加载分支,因此不能只把旧命令里的 checkpoint 换成 adapter 目录。旧服务端加载入口
原实验的服务端修改记录没有完整保留,这里不拼接一条未经验证的远程部署命令。迁移时应先让服务正确加载模型与处理器,再核对相机字段、关节顺序和控制频率。
七、第六周的训练结果与实机问题
下面是第六周汇报中保存的训练曲线:
训练 loss 从约 0.64 下降到 0.12 附近,图中标注的最佳验证点为第 4000 步,验证 loss 为 0.1130,曲线展示到了约 5600 步。这个结果可用于筛选待测试的 checkpoint,但不能直接换算成实机成功率,也没有记录能确认最终部署的就是第 4000 步模型。实验周报第 118 页
现有记录未保留这张曲线对应的完整数据版本,因此不据此比较单摄与双摄训练效果,也不把它标作前面公开 50 段数据的训练结果。
1. 双摄训练与单摄推理不一致
当时双摄采集的数据包含两个视角,但 Mac 通过扩展坞同时接入两个摄像头时会出现卡死,推理只能保留腕部wrist相机。实际现象是模型能看到蓝色方块,却难以定位盒子。
当时的排查记录认为,缺失相机通路被填成了黑图,输入与训练时的双摄画面不一致;同时腕部视野主要覆盖夹爪附近,没有有效看到桌面上的盒子。具体的填图和屏蔽行为取决于代码版本,但这个现象说明检查时需要查看模型真正收到的画面,不能只看客户端能否连接、服务器是否输出动作。
双摄训练后,简单删除一路摄像头不会自动得到适合单摄的策略。后续应先固定硬件配置,保证采集和推理的视角一致,并让目标物体和放置区域获得足够的视觉覆盖。实验周报第 117 页
2. 单视角实验仍有动作震荡
另一组实验使用单视角采集的数据。把物体放在视野中心时能够跑通任务,但动作仍有明显震荡,执行时间也较长。现有记录不足以把原因完全归到训练不足、过拟合或网络延迟上,需要分别检查数据覆盖、模型输出、通信耗时和动作执行过程。
下一轮可以固定物体初始位置做重复测试,再逐步改变位置,记录每次是否完成、完成时间以及失败阶段;同时比较不同 checkpoint。周报没有给出完整的成功次数和测试次数,本文因此只保留观察到的现象。实验周报第 118 页
这次实验已经跑通 π0.5 微调和实机调用。继续提高效果时,优先解决相机输入的一致性和任务视野,再通过重复测试判断需要增加什么样的数据、调整哪些训练参数。