Initial pi0.7 minimal validation

This commit is contained in:
seeker
2026-07-12 00:52:00 +08:00
commit b4c8db846b
27 changed files with 3724 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
*.egg-info/
.venv/
data/
outputs/**/*.pt
.DS_Store
+246
View File
@@ -0,0 +1,246 @@
# π0.7 Minimal Validation
这是一个面向**结构验证和小样本过拟合**的 π0.7-style 教学项目。它在前面的
π0.6/RECAP 最小实验之上,重点验证一条可解释的 steerable policy 链路:利用
短时历史形成 memory,在高层预测视觉 subgoal,通过可独立缺省的 policy
metadata 控制动作风格,并在存在控制延迟时用 RTC 对齐动作 chunk。
本项目不是 Physical Intelligence 官方实现,也不能用于真实车辆或机器人控制。
## 公开资料边界
截至本项目实现时,作者没有在 Physical Intelligence 官方网站、公开论文索引或
`openpi` 仓库中找到可核验的 π0.7 原始论文、官方训练源码、checkpoint 或完整
model card。因此这里不能声称“逐层复现论文”,也不会虚构官方模块尺寸、损失、
数据配方或实验数字。
本仓库所说的 **paper-faithful toy** 有严格边界:若 π0.7 的公开描述强调 memory、
goal generation、high-level control、steerable metadata、CFG 和 real-time
chunking,本项目保留这些模块的输入输出关系与必要消融;所有未公开细节都使用
最小、明确标注且有测试的本地替代。`TinyMEMEncoder`、`TinyGoalGenerator`、
`HighLevelPolicy` 和 `Pi07Mini` 都是本仓库命名,不代表官方内部类名。
若后续公开原始论文或源码,应重新审计本项目,而不是把当前实现当成事实来源。
## 模型阶段与输入输出
| 阶段 | 输入 | 模块 | 输出 | 训练或验证目标 |
|---|---|---|---|---|
| 1. 历史构造 | 当前图像、过去 XY;不接受 future | deterministic history proxy | `6` 帧 oldest-to-newest 图像、历史状态、共享当前状态的 ambiguous history pair | 检查无未来轨迹泄漏 |
| 2. Temporal memory | 历史图像与历史状态 | `TinyMEMEncoder` | memory tokens / pooled memory embedding | 区分当前状态相同、过去路径不同的样本 |
| 3. Goal generation | 当前 observation、memory、任务文本 | `TinyGoalGenerator` | endpoint heatmap / subgoal XY | 只监督 future endpoint,不把完整真值轨迹输入 policy |
| 4. High-level policy | observation、memory、任务与可见 metadata | `HighLevelPolicy` | high-level context、goal/metadata logits | goal loss 与 high-level classification loss |
| 5. Steerable flow policy | high-level context、预测 goal、metadata、noisy action、flow time | `Pi07Mini` low-level action expert | 连续 action vector field `[16,2]` | flow-matching loss |
| 6. RTC alignment | action chunk、随机 delay `d∈[0,12]`、已执行 action prefix | real-time chunking target alignment | 从 `action[d]` 开始的 target 与有效位置 mask | 仅在有效位置计算 masked flow loss |
| 7. CFG inference | 同一 observation 的 conditional 与 metadata-omitted 两次前向 | classifier-free guidance | guided action chunk | 与普通 conditional、unconditional 和真值同图比较 |
| 8. 轨迹恢复 | normalized action deltas、历史终点、训练统计 | denormalize + cumulative sum | 未来 XY 轨迹 | 轨迹 MSE 与可视化,不等同安全指标 |
核心信息流:
```text
history frames + history states
↓
TinyMEMEncoder ───────────────┐
│
current image + task ────────────────┼→ TinyGoalGenerator → predicted subgoal
│
optional speed/quality/mistake/control metadata
↓
HighLevelPolicy
↓
noisy action + flow time + RTC prefix → Pi07Mini action expert
↓
continuous action chunk
```
## 重点结构
### 1. MEM:短时历史而非单帧
PAI 的极小 manifest 只有一张相机图像,本项目依据已观测的过去 XY 对该图像做
确定性的微小平移和曝光变化,构造短时视觉 history proxy。它不是实际多帧视频,
只能验证 temporal memory 的接口。
`ambiguous_history_pair` 刻意构造两个“当前状态相同、历史路径不同”的序列。
完整模型应比 `no_history` 消融更能区分它们。这里的 MEM 仅表示本仓库的 temporal
memory encoder;在官方资料缺失时,不推断该缩写的官方全称。
### 2. Goal generator:只用 endpoint 监督
训练目标是 future trajectory 的最后一个 XY endpoint,经高斯渲染得到 heatmap。
API 只接受 endpoint,不接受整条 future trajectory,从而避免把低层动作答案作为
视觉 subgoal 条件泄漏给 action expert。推理和最终评估必须使用**模型预测的 goal**;
真值 heatmap 只可用于训练监督和 oracle 诊断。
### 3. High-level 与 steerable metadata
最小数据由一条 expert demonstration 确定性构造五种 policy mode:
| Mode | speed | quality | mistake | control |
|---|---|---|---|---|
| expert | normal | expert | none | human |
| fast | fast | good | none | autonomous |
| slow | slow | good | none | autonomous |
| understeer | normal | poor | understeer | autonomous |
| stop | slow | poor | stop | autonomous |
训练时 `speed`、`quality`、`mistake`、`control` 独立做 metadata dropout,任务文本
始终保留。这既检验属性是否能单独控制行为,也提供 CFG 所需的 omitted condition。
这些合成标签不是人类偏好数据,也不能证明真实世界可控性。
### 4. RTC:控制延迟下的 action chunk 对齐
若推理耗时等价于已经执行 `d` 个 action,模型不应再次预测旧 chunk 的开头:
```text
original target: a0 a1 ... a15
delay d=4: a4 a5 ... a15 PAD PAD PAD PAD
valid mask: 1 1 ... 1 0 0 0 0
```
默认随机采样 `d=0..12`,返回已执行 prefix,并对 padding tail 屏蔽损失。RTC 在
这里表示训练/评估时的离线对齐机制,不包含实际控制器、异步推理线程或硬实时保证。
### 5. CFG
推理时在相同 observation、goal、memory 和初始噪声下计算 metadata conditional
与 metadata-omitted 预测:
```text
v_cfg = v_uncond + beta * (v_cond - v_uncond)
```
默认 `beta=2.0`。必须同时报告 `beta=0,1,2`,因为高 guidance 可能放大过拟合、
产生分布外动作,不能只展示最好看的曲线。
## 数据与防泄漏约束
- history proxy 和 ambiguous pair 只依赖当前图像与过去状态;
- subgoal condition 只使用 endpoint target,不能编码完整 action chunk;
- flow expert 训练时不能读取 teacher-forced 真值动作 token;
- 所有模型对比使用同一 initial noise、归一化统计和 RTC delay;
- 数据增强的随机数由 sample index 和 seed 决定,保证复现实验;
- 仓库不包含或重新分发原始 PAI 数据。
## 安装
```bash
cd pi07-minimal-validation
python -m venv .venv
source .venv/bin/activate
python -m pip install -U pip
python -m pip install -e '.[dev]'
```
默认数据路径:
```text
../../alpamayo/alpamayo-qwen05b-validation/data/processed/overfit_manifest.jsonl
```
若路径不同,请修改 `configs/overfit.yaml` 的 `data.source_manifest`。
## 测试
```bash
pytest -q
```
测试至少应覆盖:history/goal 无 future 泄漏、ambiguous history、metadata 独立
dropout、五类策略构造、action normalization、flow matching、RTC shift/mask、
MEM/goal/high-level/policy 前向形状、CFG 公式和 checkpoint reload。
## 三阶段过拟合训练
```bash
pi07mini-train --config configs/overfit.yaml
```
或:
```bash
python -m pi07mini.train --config configs/overfit.yaml
```
| 阶段 | 默认步数 | 更新内容 | 目的 |
|---|---:|---|---|
| goal | 300 | MEM + goal generator | 先证明 history-conditioned endpoint heatmap 可被记忆 |
| high-level | 200 | MEM + high-level policy | 记住五类 metadata/goal 高层条件 |
| policy | 900 | 完整 `Pi07Mini` | 联合训练 metadata dropout、flow action 与 RTC masked target |
默认预算面向极小样本过拟合,不代表官方训练 schedule。训练完成后应保存实际
训练会生成 loss history、checkpoint、evaluation JSON 和消融效果图。
## 当前最小实验结果
固定相同 flow 初始噪声时,高层策略预测 `steer_left`。轨迹 MSE 如下:
| 条件 | MSE |
|---|---:|
| Full context + oracle endpoint goal | 0.470 |
| Full context + generated goal | 0.491 |
| No metadata | 2.458 |
| No goal | 4.043 |
| No history | 2.159 |
| Metadata CFG beta=2 | 10.576 |
完整上下文明显优于移除 metadata、goal 或 history 的消融。TinyGoalGenerator 的像素级结果仍较粗糙;生成 goal 在当前 policy 上可提供有效条件,但不能视为真实未来图像生成能力。较强 CFG 在此极小数据实验中造成退化。
## 推理、画图与必要消融
```bash
pi07mini-infer --checkpoint outputs/overfit/checkpoint.pt
```
或:
```bash
python -m pi07mini.infer --checkpoint outputs/overfit/checkpoint.pt
```
最终展示不能只有 full model。相同样本、noise seed 和 RTC delay 下至少比较:
| 实验 | 移除内容 | 要回答的问题 |
|---|---|---|
| `full` | 无 | 完整链路能否过拟合并重建对应 mode 的轨迹? |
| `no_metadata` | speed/quality/mistake/control | policy mode 差异是否真的来自 steerable condition? |
| `no_goal` | predicted goal | endpoint condition 是否改善几何方向和终点? |
| `no_history` | MEM history,只保留当前 observation | temporal history 是否解决相同当前状态的歧义? |
还必须提供:
- predicted goal heatmap 与 ground-truth endpoint heatmap;
- expert/fast/slow/understeer/stop 的真值与预测轨迹;
- CFG `beta=0,1,2`;
- RTC delay `0,4,8,12` 的 masked trajectory error;
- 每个 ablation 的轨迹 MSE,以及 goal endpoint error;
- full/no-history 在 ambiguous history pair 上的差异指标。
小样本过拟合中,某个消融偶然更好并不证明模块无效;应展示全部固定设置结果,
不调换 noise seed 或只挑选最佳样本。
## 安全限制
- 严禁连接车辆、机械臂、移动机器人或任何会造成物理影响的执行器。
- 合成 fast/understeer/stop 轨迹只用于离线可控性探针,不是安全驾驶示范。
- endpoint 命中、轨迹 MSE 下降、loss 收敛和 CFG 改善都不代表闭环稳定性。
- history proxy 不是视频;RTC target shift 也不是硬实时系统验证。
- metadata 可控性可能放大危险属性,尤其是 `fast`、`poor` 或高 CFG beta。
- 真实部署至少需要真实时序传感器、闭环 rollout、OOD 与延迟压力测试、动力学
约束、碰撞检查、冗余控制器、故障降级、急停和人工监督。
- PyTorch checkpoint 使用 pickle 机制,只加载可信来源的文件。
## 能得出的结论
成功实验最多能说明:本仓库定义的 memory、endpoint goal、high-level metadata、
conditional flow、CFG 与 RTC 对齐可以在几个合成 PAI trajectory mode 上形成一条
可训练、可消融的最小链路。它不能证明与未公开 π0.7 的结构或数值等价,不能
证明 emergent capability,也不能支持任何真实世界安全或泛化主张。
## 可核验背景资料
- [Physical Intelligence](https://www.pi.website/)
- [Physical-Intelligence/openpi](https://github.com/Physical-Intelligence/openpi)
在原始 π0.7 论文或官方源码可核验前,本 README 不提供推测性的论文链接、arXiv
编号或官方结果表。
+31
View File
@@ -0,0 +1,31 @@
# Third-party notices
This repository is an independent educational implementation. It does not
contain code, weights, datasets, or other assets copied from Physical
Intelligence.
At the time this project was prepared, no original pi0.7 paper, official
pi0.7 training implementation, checkpoint, or complete technical model card
was available in the public Physical Intelligence/openpi materials inspected
by the authors. Consequently, names such as `TinyMEMEncoder`,
`TinyGoalGenerator`, `HighLevelPolicy`, and `Pi07Mini` describe this
repository's own teaching implementation. They must not be interpreted as
official class names or proof of the unpublished system's internals.
The project uses the earlier public pi-family literature and public openpi
repository only as background for vision-language-action policies, action
chunking, and flow matching. No source file from openpi is vendored here.
Runtime dependencies retain their own licenses and copyright notices:
- PyTorch
- NumPy
- Pillow
- Matplotlib
- PyYAML
- pytest (development only)
Users are responsible for complying with the licenses and terms of any
external images, trajectories, datasets, or checkpoints they supply. The
default configuration points to a local manifest and this repository does not
redistribute the source PAI data.
+98
View File
@@ -0,0 +1,98 @@
seed: 7
device: auto
output_dir: outputs/overfit
data:
source_manifest: ../../alpamayo/alpamayo-qwen05b-validation/data/processed/overfit_manifest.jsonl
max_samples: 1
augmentation_factor: 1
image_size: 64
history_frames: 6
action_horizon: 16
policy_variants:
- expert
- fast
- slow
- understeer
- stop
model:
patch_size: 16
d_model: 96
mem_d_model: 64
goal_d_model: 64
high_level_d_model: 64
n_heads: 4
n_layers: 3
mlp_ratio: 3
dropout: 0.0
max_text_tokens: 96
action_dim: 2
action_horizon: 16
goal_heatmap_size: 32
memory:
enabled: true
history_frames: 6
ambiguous_pair_weight: 0.25
goal:
enabled: true
heatmap_size: 32
heatmap_sigma: 1.5
xy_extent: [-8.0, 64.0, -16.0, 16.0]
endpoint_only_target: true
high_level:
enabled: true
predict_goal: true
predict_metadata: true
metadata_fields:
- speed
- quality
- mistake
- control
steering:
metadata_dropout: 0.25
preserve_task_prompt: true
cfg_training_dropout: 0.25
cfg_beta: 2.0
rtc:
enabled: true
max_delay_steps: 12
include_executed_prefix: true
masked_loss: true
flow:
timestep_beta_alpha: 1.5
timestep_beta_beta: 1.0
integration_steps: 10
train:
goal_steps: 300
high_level_steps: 200
policy_steps: 900
batch_size: 5
learning_rate: 0.001
weight_decay: 0.0
grad_clip_norm: 1.0
goal_loss_weight: 1.0
high_level_loss_weight: 1.0
flow_loss_weight: 2.0
rtc_loss_weight: 1.0
log_every: 20
evaluation:
fixed_noise_seed: 1
cfg_betas: [0.0, 1.0, 2.0]
ablations:
- full
- no_metadata
- no_goal
- no_history
compare_rtc_delays: [0, 4, 8, 12]
save_trajectory_plot: true
save_goal_plot: true
save_ablation_plot: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"predicted_subtask": "steer_left",
"trajectory_metrics": {
"full_oracle_goal": 0.47031369805336,
"generated_goal": 0.4905388057231903,
"no_metadata": 2.457864284515381,
"no_goal": 4.042560577392578,
"no_history": 2.1593737602233887,
"cfg_beta2": 10.576497077941895
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+7
View File
@@ -0,0 +1,7 @@
{
"device": "mps",
"samples": 5,
"policy_parameters": 1088930,
"goal_parameters": 75731,
"high_level_parameters": 652800
}
+459
View File
@@ -0,0 +1,459 @@
[
{
"stage": "goal",
"step": 1,
"loss": 1.0040572881698608
},
{
"stage": "goal",
"step": 20,
"loss": 0.34230467677116394
},
{
"stage": "goal",
"step": 40,
"loss": 0.37451064586639404
},
{
"stage": "goal",
"step": 60,
"loss": 0.26792070269584656
},
{
"stage": "goal",
"step": 80,
"loss": 0.10219617933034897
},
{
"stage": "goal",
"step": 100,
"loss": 0.2711445987224579
},
{
"stage": "goal",
"step": 120,
"loss": 0.2289590686559677
},
{
"stage": "goal",
"step": 140,
"loss": 0.36456483602523804
},
{
"stage": "goal",
"step": 160,
"loss": 0.0842418223619461
},
{
"stage": "goal",
"step": 180,
"loss": 0.9889341592788696
},
{
"stage": "goal",
"step": 200,
"loss": 0.5776903629302979
},
{
"stage": "goal",
"step": 220,
"loss": 0.44620272517204285
},
{
"stage": "goal",
"step": 240,
"loss": 0.05634145438671112
},
{
"stage": "goal",
"step": 260,
"loss": 0.05701519176363945
},
{
"stage": "goal",
"step": 280,
"loss": 0.06764699518680573
},
{
"stage": "goal",
"step": 300,
"loss": 0.10973378270864487
},
{
"stage": "high_level",
"step": 1,
"loss": 76.15934753417969
},
{
"stage": "high_level",
"step": 20,
"loss": -0.0
},
{
"stage": "high_level",
"step": 40,
"loss": -0.0
},
{
"stage": "high_level",
"step": 60,
"loss": -0.0
},
{
"stage": "high_level",
"step": 80,
"loss": -0.0
},
{
"stage": "high_level",
"step": 100,
"loss": -0.0
},
{
"stage": "high_level",
"step": 120,
"loss": -0.0
},
{
"stage": "high_level",
"step": 140,
"loss": -0.0
},
{
"stage": "high_level",
"step": 160,
"loss": -0.0
},
{
"stage": "high_level",
"step": 180,
"loss": -0.0
},
{
"stage": "high_level",
"step": 200,
"loss": -0.0
},
{
"stage": "policy",
"step": 1,
"loss": 51.04338836669922,
"text_loss": 44.21609115600586,
"flow_loss": 3.4136481285095215
},
{
"stage": "policy",
"step": 20,
"loss": 26.20608901977539,
"text_loss": 5.128565311431885,
"flow_loss": 10.538762092590332
},
{
"stage": "policy",
"step": 40,
"loss": 13.018914222717285,
"text_loss": 3.846059799194336,
"flow_loss": 4.586427211761475
},
{
"stage": "policy",
"step": 60,
"loss": 11.344329833984375,
"text_loss": 3.335771083831787,
"flow_loss": 4.004279136657715
},
{
"stage": "policy",
"step": 80,
"loss": 6.586862087249756,
"text_loss": 2.5455708503723145,
"flow_loss": 2.0206456184387207
},
{
"stage": "policy",
"step": 100,
"loss": 11.200895309448242,
"text_loss": 2.1739449501037598,
"flow_loss": 4.513474941253662
},
{
"stage": "policy",
"step": 120,
"loss": 17.273035049438477,
"text_loss": 2.1746408939361572,
"flow_loss": 7.549196720123291
},
{
"stage": "policy",
"step": 140,
"loss": 11.353687286376953,
"text_loss": 2.2218563556671143,
"flow_loss": 4.565915584564209
},
{
"stage": "policy",
"step": 160,
"loss": 14.110220909118652,
"text_loss": 2.3123977184295654,
"flow_loss": 5.898911476135254
},
{
"stage": "policy",
"step": 180,
"loss": 19.999370574951172,
"text_loss": 2.749119997024536,
"flow_loss": 8.62512493133545
},
{
"stage": "policy",
"step": 200,
"loss": 8.102465629577637,
"text_loss": 1.631801962852478,
"flow_loss": 3.2353317737579346
},
{
"stage": "policy",
"step": 220,
"loss": 21.903854370117188,
"text_loss": 1.6063504219055176,
"flow_loss": 10.148752212524414
},
{
"stage": "policy",
"step": 240,
"loss": 10.197904586791992,
"text_loss": 1.5640149116516113,
"flow_loss": 4.3169450759887695
},
{
"stage": "policy",
"step": 260,
"loss": 6.899936676025391,
"text_loss": 1.3081820011138916,
"flow_loss": 2.795877456665039
},
{
"stage": "policy",
"step": 280,
"loss": 10.051774978637695,
"text_loss": 1.2130903005599976,
"flow_loss": 4.419342517852783
},
{
"stage": "policy",
"step": 300,
"loss": 7.963553428649902,
"text_loss": 1.1586928367614746,
"flow_loss": 3.402430295944214
},
{
"stage": "policy",
"step": 320,
"loss": 5.177718639373779,
"text_loss": 1.2673476934432983,
"flow_loss": 1.9551855325698853
},
{
"stage": "policy",
"step": 340,
"loss": 4.487771034240723,
"text_loss": 1.0361082553863525,
"flow_loss": 1.7258312702178955
},
{
"stage": "policy",
"step": 360,
"loss": 4.356203556060791,
"text_loss": 1.3223515748977661,
"flow_loss": 1.5169259309768677
},
{
"stage": "policy",
"step": 380,
"loss": 5.289548397064209,
"text_loss": 1.4994667768478394,
"flow_loss": 1.89504075050354
},
{
"stage": "policy",
"step": 400,
"loss": 8.761286735534668,
"text_loss": 1.4391229152679443,
"flow_loss": 3.6610820293426514
},
{
"stage": "policy",
"step": 420,
"loss": 2.400188446044922,
"text_loss": 1.0106937885284424,
"flow_loss": 0.694747269153595
},
{
"stage": "policy",
"step": 440,
"loss": 9.683706283569336,
"text_loss": 1.044368863105774,
"flow_loss": 4.319668769836426
},
{
"stage": "policy",
"step": 460,
"loss": 10.443679809570312,
"text_loss": 0.7045990228652954,
"flow_loss": 4.869540214538574
},
{
"stage": "policy",
"step": 480,
"loss": 6.514384746551514,
"text_loss": 1.185524582862854,
"flow_loss": 2.6644301414489746
},
{
"stage": "policy",
"step": 500,
"loss": 2.255455255508423,
"text_loss": 1.0136079788208008,
"flow_loss": 0.620923638343811
},
{
"stage": "policy",
"step": 520,
"loss": 4.1700439453125,
"text_loss": 1.1829472780227661,
"flow_loss": 1.4935483932495117
},
{
"stage": "policy",
"step": 540,
"loss": 6.956455230712891,
"text_loss": 1.1096038818359375,
"flow_loss": 2.9234256744384766
},
{
"stage": "policy",
"step": 560,
"loss": 4.5965800285339355,
"text_loss": 1.5257400274276733,
"flow_loss": 1.5354200601577759
},
{
"stage": "policy",
"step": 580,
"loss": 1.7948781251907349,
"text_loss": 0.835124135017395,
"flow_loss": 0.4798769950866699
},
{
"stage": "policy",
"step": 600,
"loss": 11.411609649658203,
"text_loss": 0.7508105635643005,
"flow_loss": 5.330399513244629
},
{
"stage": "policy",
"step": 620,
"loss": 14.173189163208008,
"text_loss": 0.553065836429596,
"flow_loss": 6.810061454772949
},
{
"stage": "policy",
"step": 640,
"loss": 2.423476457595825,
"text_loss": 0.6566628813743591,
"flow_loss": 0.8834067583084106
},
{
"stage": "policy",
"step": 660,
"loss": 7.33753776550293,
"text_loss": 0.5569484829902649,
"flow_loss": 3.3902945518493652
},
{
"stage": "policy",
"step": 680,
"loss": 7.2152018547058105,
"text_loss": 0.6606324911117554,
"flow_loss": 3.277284622192383
},
{
"stage": "policy",
"step": 700,
"loss": 2.5858616828918457,
"text_loss": 0.471678227186203,
"flow_loss": 1.0570917129516602
},
{
"stage": "policy",
"step": 720,
"loss": 5.87769889831543,
"text_loss": 0.5711548924446106,
"flow_loss": 2.6532719135284424
},
{
"stage": "policy",
"step": 740,
"loss": 6.787323951721191,
"text_loss": 0.5064371228218079,
"flow_loss": 3.1404433250427246
},
{
"stage": "policy",
"step": 760,
"loss": 11.166234970092773,
"text_loss": 0.5802748203277588,
"flow_loss": 5.292980194091797
},
{
"stage": "policy",
"step": 780,
"loss": 3.563460350036621,
"text_loss": 0.6946841478347778,
"flow_loss": 1.4343880414962769
},
{
"stage": "policy",
"step": 800,
"loss": 3.2471487522125244,
"text_loss": 0.44556593894958496,
"flow_loss": 1.4007914066314697
},
{
"stage": "policy",
"step": 820,
"loss": 3.1103086471557617,
"text_loss": 0.7132699489593506,
"flow_loss": 1.1985193490982056
},
{
"stage": "policy",
"step": 840,
"loss": 4.753307342529297,
"text_loss": 0.48397523164749146,
"flow_loss": 2.1346659660339355
},
{
"stage": "policy",
"step": 860,
"loss": 3.5492496490478516,
"text_loss": 0.366057813167572,
"flow_loss": 1.5915958881378174
},
{
"stage": "policy",
"step": 880,
"loss": 4.0379414558410645,
"text_loss": 0.6511648893356323,
"flow_loss": 1.6933883428573608
},
{
"stage": "policy",
"step": 900,
"loss": 1.7170453071594238,
"text_loss": 0.2673894464969635,
"flow_loss": 0.7248279452323914
}
]
+38
View File
@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "pi07-minimal-validation"
version = "0.1.0"
description = "A tiny, safety-scoped pi0.7-style steerable policy validation experiment"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "pi07 minimal validation contributors" }]
dependencies = [
"matplotlib>=3.8",
"numpy>=1.26",
"Pillow>=10.0",
"PyYAML>=6.0",
"torch>=2.2",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
]
[project.scripts]
pi07mini-train = "pi07mini.train:main"
pi07mini-infer = "pi07mini.infer:main"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
+32
View File
@@ -0,0 +1,32 @@
"""Minimal pi0.7 architecture validation package."""
from .memory import MEMConfig, MEMVideoEncoder
from .model import (
GoalGeneratorOutput,
HighLevelOutput,
HighLevelPolicy,
Pi07Config,
Pi07Mini,
Pi07Output,
PrefixLayout,
TinyGoalGenerator,
flow_matching_sample,
)
from .tokenizer import EpisodeMetadata, MiniTokenizer, PolicySequence
__all__ = [
"EpisodeMetadata",
"GoalGeneratorOutput",
"HighLevelOutput",
"HighLevelPolicy",
"MEMConfig",
"MEMVideoEncoder",
"MiniTokenizer",
"Pi07Config",
"Pi07Mini",
"Pi07Output",
"PolicySequence",
"PrefixLayout",
"TinyGoalGenerator",
"flow_matching_sample",
]
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping, Sequence
import torch
import torch.nn.functional as F
PROMPT_COMPONENT_ORDER = ("task", "speed", "quality", "mistake", "control")
@dataclass(frozen=True)
class PolicyMetadata:
"""Steerable policy attributes used by the tiny pi0.7 experiment."""
speed: str
quality: str
mistake: str
control: str
speed_steps: int = 1_000
quality_score: int = 3
control_mode: str = "ee"
source_type: str = "autonomous"
def __post_init__(self) -> None:
if self.speed_steps <= 0:
raise ValueError("speed_steps must be positive")
if not 1 <= self.quality_score <= 5:
raise ValueError("quality_score must lie in [1,5]")
if self.control_mode not in {"joint", "ee"}:
raise ValueError("control_mode must be joint or ee")
def as_dict(self) -> dict[str, str]:
return {
"speed": self.speed,
"quality": self.quality,
"mistake": self.mistake,
"control": self.control,
}
def as_token_metadata(self) -> dict[str, int | bool | str]:
"""Return the scalar metadata contract consumed by MiniTokenizer."""
return {
"speed_steps": self.speed_steps,
"quality": self.quality_score,
"mistake": self.mistake != "none",
"control_mode": self.control_mode,
"source_type": self.source_type,
}
def prompt_components(
metadata: PolicyMetadata,
*,
task: str = "Follow the visible road and predict the next waypoint chunk.",
) -> dict[str, str]:
return {"task": task, **metadata.as_dict()}
def drop_prompt_components(
components: Mapping[str, str],
*,
drop_probability: float,
generator: torch.Generator | None = None,
preserve: Sequence[str] = ("task",),
) -> tuple[dict[str, str], torch.Tensor]:
"""Apply classifier-free-style dropout to independent prompt attributes.
The returned mask follows ``PROMPT_COMPONENT_ORDER`` and marks components
that remain visible. Task text is preserved by default, while metadata can
be independently omitted so the policy cannot require every attribute.
"""
if not 0.0 <= drop_probability <= 1.0:
raise ValueError("drop_probability must lie in [0, 1]")
preserved = set(preserve)
visible: dict[str, str] = {}
mask = torch.zeros(len(PROMPT_COMPONENT_ORDER), dtype=torch.bool)
for index, name in enumerate(PROMPT_COMPONENT_ORDER):
if name not in components:
continue
keep = name in preserved or bool(
torch.rand((), generator=generator) >= drop_probability
)
if keep:
visible[name] = str(components[name])
mask[index] = True
return visible, mask
def format_policy_prompt(components: Mapping[str, str]) -> str:
"""Format components without inventing values for dropped attributes."""
pieces: list[str] = []
if task := components.get("task"):
pieces.append(str(task).strip())
attributes = [
f"{name}={components[name]}"
for name in PROMPT_COMPONENT_ORDER[1:]
if name in components
]
if attributes:
pieces.append("Policy metadata: " + ", ".join(attributes) + ".")
return "\n".join(pieces)
def endpoint_subgoal_heatmap(
endpoint_xy: torch.Tensor,
*,
size: int = 32,
sigma: float = 1.5,
xy_extent: tuple[float, float, float, float] = (-8.0, 64.0, -16.0, 16.0),
) -> torch.Tensor:
"""Render a normalized Gaussian subgoal heatmap from one endpoint only.
It deliberately accepts no trajectory tensor. This API makes it difficult
to accidentally leak the entire ground-truth action chunk into the visual
subgoal condition.
"""
endpoint = torch.as_tensor(endpoint_xy, dtype=torch.float32).reshape(2)
if size < 2 or sigma <= 0.0:
raise ValueError("size must be >=2 and sigma must be positive")
x_min, x_max, y_min, y_max = xy_extent
if not x_min < x_max or not y_min < y_max:
raise ValueError("xy_extent must contain increasing bounds")
px = (endpoint[0] - x_min) / (x_max - x_min) * (size - 1)
py = (y_max - endpoint[1]) / (y_max - y_min) * (size - 1)
yy, xx = torch.meshgrid(
torch.arange(size, dtype=torch.float32),
torch.arange(size, dtype=torch.float32),
indexing="ij",
)
heatmap = torch.exp(-((xx - px) ** 2 + (yy - py) ** 2) / (2.0 * sigma**2))
heatmap = heatmap / heatmap.amax().clamp_min(torch.finfo(heatmap.dtype).eps)
return heatmap.unsqueeze(0)
def make_history_proxy_frames(
image: torch.Tensor,
history_xy: torch.Tensor,
*,
frames: int = 6,
) -> torch.Tensor:
"""Create oldest-to-newest history frames from one PAI image and past XY.
PAI's tiny overfit manifest only exposes one camera frame. We therefore
synthesize a deterministic visual-history proxy by rolling that frame from
past ego displacement and applying a very small temporal exposure change.
No future waypoint is accepted by this function.
"""
image = torch.as_tensor(image, dtype=torch.float32)
history = torch.as_tensor(history_xy, dtype=torch.float32)
if image.ndim != 3 or image.shape[0] not in {1, 3}:
raise ValueError("image must have shape [C,H,W]")
if history.ndim != 2 or history.shape[-1] != 2 or len(history) < 2:
raise ValueError("history_xy must have shape [T>=2,2]")
if frames < 2:
raise ValueError("frames must be >=2")
sampled = F.interpolate(
history.T.unsqueeze(0), size=frames, mode="linear", align_corners=True
)[0].T
relative = sampled - sampled[-1]
spatial_scale = max(image.shape[-2:]) / 24.0
output: list[torch.Tensor] = []
for index, point in enumerate(relative):
dx = int(torch.round(point[1] * spatial_scale).item())
dy = int(torch.round(-point[0] * spatial_scale * 0.25).item())
dx = max(-image.shape[-1] // 6, min(image.shape[-1] // 6, dx))
dy = max(-image.shape[-2] // 6, min(image.shape[-2] // 6, dy))
# The separate current image is appended by MEM, so even the newest
# proxy frame remains a slightly older exposure rather than an exact
# duplicate of current_pixel_values.
exposure = 0.90 + 0.08 * index / (frames - 1)
output.append(torch.roll(image, shifts=(dy, dx), dims=(-2, -1)) * exposure)
return torch.stack(output).clamp(0.0, 1.0)
def build_ambiguous_history_pair(
history_xy: torch.Tensor, *, frames: int = 6
) -> torch.Tensor:
"""Return two distinct histories that share exactly the same current state.
This supplies a minimal history-disambiguation probe: a current-frame-only
policy sees the same endpoint, whereas a memory encoder can distinguish the
two past paths. Construction depends only on observed history.
"""
history = torch.as_tensor(history_xy, dtype=torch.float32)
if history.ndim != 2 or history.shape[-1] != 2 or len(history) < 2:
raise ValueError("history_xy must have shape [T>=2,2]")
sampled = F.interpolate(
history.T.unsqueeze(0), size=frames, mode="linear", align_corners=True
)[0].T
alpha = torch.linspace(0.0, 1.0, frames, dtype=sampled.dtype).unsqueeze(-1)
chord = sampled[:1] + alpha * (sampled[-1:] - sampled[:1])
mirrored = 2.0 * chord - sampled
if torch.allclose(mirrored, sampled, atol=1e-4, rtol=0.0):
direction = sampled[-1] - sampled[0]
perpendicular = torch.stack((-direction[1], direction[0]))
perpendicular = perpendicular / torch.linalg.vector_norm(perpendicular).clamp_min(1e-6)
amplitude = torch.linalg.vector_norm(direction).clamp_min(1.0) * 0.08
hump = torch.sin(torch.linspace(0.0, torch.pi, frames)).unsqueeze(-1)
mirrored = sampled + hump * perpendicular * amplitude
mirrored[-1] = sampled[-1]
return torch.stack((sampled, mirrored))
+418
View File
@@ -0,0 +1,418 @@
from __future__ import annotations
import json
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, Sequence
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image, ImageEnhance
from torch.utils.data import Dataset
from .context import (
PolicyMetadata,
build_ambiguous_history_pair,
drop_prompt_components,
endpoint_subgoal_heatmap,
format_policy_prompt,
make_history_proxy_frames,
prompt_components,
)
from .rtc import MAX_RTC_DELAY_STEPS, align_actions_for_rtc, sample_rtc_delay
DEFAULT_SOURCE_MANIFEST = (
Path(__file__).resolve().parents[4]
/ "alpamayo"
/ "alpamayo-qwen05b-validation"
/ "data"
/ "processed"
/ "overfit_manifest.jsonl"
)
PolicyKind = Literal["expert", "fast", "slow", "understeer", "stop"]
@dataclass(frozen=True)
class SampleRecord:
clip_id: str
t0_us: int
image_path: Path
trajectory_path: Path
reasoning: str = ""
@property
def sample_id(self) -> str:
return f"{self.clip_id}_{self.t0_us}"
@dataclass(frozen=True)
class ActionStatistics:
mean: torch.Tensor
scale: torch.Tensor
def as_dict(self) -> dict[str, list[float]]:
return {
"mean": [float(value) for value in self.mean],
"scale": [float(value) for value in self.scale],
}
class ActionNormalizer:
def __init__(self, mean: torch.Tensor, scale: torch.Tensor) -> None:
self.mean = torch.as_tensor(mean, dtype=torch.float32).reshape(2)
self.scale = torch.as_tensor(scale, dtype=torch.float32).reshape(2).clamp_min(0.05)
@staticmethod
def to_deltas(history_xy: torch.Tensor, future_xy: torch.Tensor) -> torch.Tensor:
history = torch.as_tensor(history_xy, dtype=torch.float32)
future = torch.as_tensor(future_xy, dtype=torch.float32)
previous = torch.cat((history[-1:], future[:-1]), dim=0)
return future - previous
@classmethod
def fit(
cls, histories: Sequence[torch.Tensor], futures: Sequence[torch.Tensor]
) -> "ActionNormalizer":
if not histories or len(histories) != len(futures):
raise ValueError("histories and futures must be non-empty and equally sized")
flat = torch.cat(
[
cls.to_deltas(history, future).reshape(-1, 2)
for history, future in zip(histories, futures, strict=True)
]
)
return cls(flat.mean(0), flat.std(0, unbiased=False))
@classmethod
def from_dict(cls, values: dict[str, Sequence[float]]) -> "ActionNormalizer":
return cls(torch.tensor(values["mean"]), torch.tensor(values["scale"]))
@property
def statistics(self) -> ActionStatistics:
return ActionStatistics(self.mean.clone(), self.scale.clone())
def encode(self, history_xy: torch.Tensor, future_xy: torch.Tensor) -> torch.Tensor:
return (self.to_deltas(history_xy, future_xy) - self.mean) / self.scale
def decode(self, actions: torch.Tensor, history_xy: torch.Tensor) -> torch.Tensor:
actions = torch.as_tensor(actions, dtype=torch.float32)
deltas = actions * self.scale.to(actions.device) + self.mean.to(actions.device)
start = torch.as_tensor(history_xy, dtype=torch.float32, device=actions.device)[-1:]
return start + torch.cumsum(deltas, dim=-2)
def as_dict(self) -> dict[str, list[float]]:
return self.statistics.as_dict()
@dataclass(frozen=True)
class PolicyTrajectory:
kind: PolicyKind
history_xy: torch.Tensor
future_xy: torch.Tensor
metadata: PolicyMetadata
def _resolve_data_path(manifest_path: Path, raw_path: str | Path) -> Path:
path = Path(raw_path).expanduser()
if path.is_absolute():
return path.resolve()
candidates = [manifest_path.parent / path]
if len(manifest_path.parents) >= 3:
candidates.insert(0, manifest_path.parents[2] / path)
for candidate in candidates:
if candidate.exists():
return candidate.resolve()
return candidates[0].resolve()
def load_manifest(path: str | Path) -> list[SampleRecord]:
manifest = Path(path).expanduser().resolve()
records: list[SampleRecord] = []
for line in manifest.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
row = json.loads(line)
records.append(
SampleRecord(
clip_id=str(row["clip_id"]),
t0_us=int(row["t0_us"]),
image_path=_resolve_data_path(manifest, row["image_path"]),
trajectory_path=_resolve_data_path(manifest, row["trajectory_path"]),
reasoning=str(row.get("reasoning", "")),
)
)
if not records:
raise ValueError(f"no samples found in {manifest}")
return records
def materialize_subset(
source_manifest: str | Path = DEFAULT_SOURCE_MANIFEST,
output_dir: str | Path = "data/overfit",
*,
max_samples: int = 1,
) -> Path:
if max_samples < 1:
raise ValueError("max_samples must be positive")
destination = Path(output_dir).expanduser().resolve()
(destination / "images").mkdir(parents=True, exist_ok=True)
(destination / "trajectories").mkdir(parents=True, exist_ok=True)
rows: list[dict[str, Any]] = []
for index, record in enumerate(load_manifest(source_manifest)[:max_samples]):
stem = f"sample_{index:02d}_{record.sample_id}"
image_path = destination / "images" / f"{stem}{record.image_path.suffix.lower()}"
trajectory_path = destination / "trajectories" / f"{stem}.npz"
shutil.copy2(record.image_path, image_path)
shutil.copy2(record.trajectory_path, trajectory_path)
rows.append(
{
"clip_id": record.clip_id,
"t0_us": record.t0_us,
"reasoning": record.reasoning,
"image_path": str(image_path.relative_to(destination)),
"trajectory_path": str(trajectory_path.relative_to(destination)),
}
)
manifest = destination / "overfit_manifest.jsonl"
manifest.write_text(
"".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
encoding="utf-8",
)
return manifest
def _resample_xy(xy: torch.Tensor, steps: int) -> torch.Tensor:
xy = torch.as_tensor(xy, dtype=torch.float32)
if xy.ndim != 2 or xy.shape[-1] != 2 or len(xy) < 2:
raise ValueError("trajectory must contain at least two XY waypoints")
return F.interpolate(xy.T.unsqueeze(0), size=steps, mode="linear", align_corners=True)[0].T
def build_policy_trajectories(
history_xy: torch.Tensor,
expert_future_xy: torch.Tensor,
) -> list[PolicyTrajectory]:
"""Create steerable expert/speed/failure modes from one PAI demonstration."""
history = torch.as_tensor(history_xy, dtype=torch.float32)
expert = torch.as_tensor(expert_future_xy, dtype=torch.float32)
if history.ndim != 2 or history.shape[-1] != 2 or len(history) < 2:
raise ValueError("history_xy must have shape [T>=2,2]")
if expert.ndim != 2 or expert.shape[-1] != 2 or len(expert) < 4:
raise ValueError("expert_future_xy must have shape [H>=4,2]")
anchor = history[-1]
displacement = expert - anchor
progress = torch.linspace(0.0, 1.0, len(expert)).unsqueeze(-1)
# Speed steering changes progress along the same demonstrated geometry.
fast_index = (progress.squeeze(-1) * 1.22).clamp(max=1.0) * (len(expert) - 1)
slow_index = progress.squeeze(-1) * 0.68 * (len(expert) - 1)
coordinates = torch.arange(len(expert), dtype=expert.dtype)
def interpolate_at(indices: torch.Tensor) -> torch.Tensor:
left = indices.floor().long().clamp(max=len(expert) - 1)
right = (left + 1).clamp(max=len(expert) - 1)
weight = (indices - left).unsqueeze(-1)
return expert[left] * (1.0 - weight) + expert[right] * weight
del coordinates # documents that interpolation is index based, not metric based
fast = interpolate_at(fast_index)
slow = interpolate_at(slow_index)
understeer = anchor + displacement * expert.new_tensor([1.0, 0.22])
stop_index = max(2, len(expert) // 3)
stopped = expert.clone()
stopped[stop_index:] = stopped[stop_index - 1]
return [
PolicyTrajectory(
"expert",
history,
expert,
PolicyMetadata(
"normal", "expert", "none", "human", 1_000, 5, "ee", "demonstration"
),
),
PolicyTrajectory(
"fast",
history,
fast,
PolicyMetadata(
"fast", "good", "none", "autonomous", 500, 4, "ee", "autonomous"
),
),
PolicyTrajectory(
"slow",
history,
slow,
PolicyMetadata(
"slow", "good", "none", "autonomous", 2_000, 4, "ee", "autonomous"
),
),
PolicyTrajectory(
"understeer",
history,
understeer,
PolicyMetadata(
"normal", "poor", "understeer", "autonomous", 1_000, 1, "ee", "autonomous"
),
),
PolicyTrajectory(
"stop",
history,
stopped,
PolicyMetadata(
"slow", "poor", "stop", "autonomous", 2_000, 1, "ee", "autonomous"
),
),
]
class Pi07SteerableDataset(Dataset[dict[str, Any]]):
"""Five-policy PAI overfit set with history, subgoal, prompt and RTC inputs."""
def __init__(
self,
manifest_path: str | Path,
*,
image_size: int = 64,
augmentation_factor: int = 1,
history_frames: int = 6,
action_horizon: int = 16,
prompt_dropout: float = 0.25,
max_rtc_delay_steps: int = MAX_RTC_DELAY_STEPS,
seed: int = 7,
action_statistics: ActionStatistics | dict[str, Sequence[float]] | None = None,
) -> None:
if image_size < 2 or augmentation_factor < 1 or history_frames < 2:
raise ValueError("image_size, augmentation_factor and history_frames are invalid")
self.records = load_manifest(manifest_path)
self.image_size = int(image_size)
self.augmentation_factor = int(augmentation_factor)
self.history_frames = int(history_frames)
self.action_horizon = int(action_horizon)
self.prompt_dropout = float(prompt_dropout)
self.max_rtc_delay_steps = int(max_rtc_delay_steps)
self.seed = int(seed)
policies: list[PolicyTrajectory] = []
record_indices: list[int] = []
for record_index, record in enumerate(self.records):
with np.load(record.trajectory_path) as arrays:
history = _resample_xy(
torch.from_numpy(np.asarray(arrays["history_xyz"], dtype=np.float32))[:, :2],
self.history_frames,
)
future = _resample_xy(
torch.from_numpy(np.asarray(arrays["future_xyz"], dtype=np.float32))[:, :2],
self.action_horizon,
)
generated = build_policy_trajectories(history, future)
policies.extend(generated)
record_indices.extend([record_index] * len(generated))
self.policies = policies
self._record_indices = record_indices
if action_statistics is None:
self.action_normalizer = ActionNormalizer.fit(
[policy.history_xy for policy in policies],
[policy.future_xy for policy in policies],
)
elif isinstance(action_statistics, ActionStatistics):
self.action_normalizer = ActionNormalizer(action_statistics.mean, action_statistics.scale)
else:
self.action_normalizer = ActionNormalizer.from_dict(action_statistics)
@property
def action_statistics(self) -> ActionStatistics:
return self.action_normalizer.statistics
def __len__(self) -> int:
return len(self.policies) * self.augmentation_factor
def _indices(self, index: int) -> tuple[int, int]:
if index < 0:
index += len(self)
if not 0 <= index < len(self):
raise IndexError(index)
return divmod(index, self.augmentation_factor)
def _load_image(self, record_index: int, variant: int) -> torch.Tensor:
with Image.open(self.records[record_index].image_path) as source:
image = source.convert("RGB")
if variant:
rng = np.random.default_rng(np.random.SeedSequence([self.seed, record_index, variant]))
image = ImageEnhance.Brightness(image).enhance(float(rng.uniform(0.96, 1.04)))
image = ImageEnhance.Contrast(image).enhance(float(rng.uniform(0.97, 1.03)))
image = image.resize((self.image_size, self.image_size), Image.Resampling.BILINEAR)
array = np.asarray(image, dtype=np.float32).copy() / 255.0
return torch.from_numpy(array).permute(2, 0, 1).contiguous()
def __getitem__(self, index: int) -> dict[str, Any]:
policy_index, variant = self._indices(index)
policy = self.policies[policy_index]
record_index = self._record_indices[policy_index]
image = self._load_image(record_index, variant)
actions = self.action_normalizer.encode(policy.history_xy, policy.future_xy)
generator = torch.Generator().manual_seed(self.seed * 100_003 + index)
components = prompt_components(policy.metadata)
visible_components, component_mask = drop_prompt_components(
components,
drop_probability=self.prompt_dropout,
generator=generator,
)
delay = sample_rtc_delay(
generator=generator,
max_delay_steps=self.max_rtc_delay_steps,
action_horizon=self.action_horizon,
)
rtc = align_actions_for_rtc(
actions, delay, max_delay_steps=self.max_rtc_delay_steps
)
subgoal = endpoint_subgoal_heatmap(
policy.future_xy[-1], size=self.image_size, sigma=max(1.0, self.image_size / 24.0)
)
history_pair = build_ambiguous_history_pair(
policy.history_xy, frames=self.history_frames
)
history_images = make_history_proxy_frames(
image, policy.history_xy, frames=self.history_frames
)
return {
"image": image,
"pixel_values": image,
"history_images": history_images,
"history_pixel_values": history_images,
"history_xy": policy.history_xy.clone(),
"history_states": policy.history_xy.clone(),
"current_state": policy.history_xy[-1].clone(),
"ambiguous_history_pair": history_pair,
"ambiguous_pair_id": f"{self.records[record_index].sample_id}:current-state",
"future_xy": policy.future_xy.clone(),
"actions": actions,
"policy_kind": policy.kind,
"metadata": policy.metadata.as_dict(),
"token_metadata": policy.metadata.as_token_metadata(),
"speed": policy.metadata.speed,
"quality": policy.metadata.quality,
"mistake": policy.metadata.mistake,
"control": policy.metadata.control,
"speed_steps": policy.metadata.speed_steps,
"quality_score": policy.metadata.quality_score,
"has_mistake": policy.metadata.mistake != "none",
"control_mode": policy.metadata.control_mode,
"source_type": policy.metadata.source_type,
"prompt_components": visible_components,
"prompt_component_mask": component_mask,
"prompt_text": format_policy_prompt(visible_components),
"subgoal_xy": policy.future_xy[-1].clone(),
"subgoal_heatmap": subgoal,
"subgoal_pixel_values": subgoal.expand(3, -1, -1).clone(),
"rtc_delay_steps": delay,
"rtc_target_actions": rtc.delayed_target_actions,
"rtc_valid_mask": rtc.valid_mask,
"rtc_executed_prefix_actions": rtc.executed_prefix_actions,
"reasoning": self.records[record_index].reasoning,
"sample_id": f"{self.records[record_index].sample_id}:{policy.kind}:aug{variant}",
}
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from collections.abc import Callable
import torch
from torch import Tensor
class FlowMatching:
def __init__(self, inference_steps: int = 5, cutoff: float = 0.999) -> None:
self.inference_steps=inference_steps; self.cutoff=cutoff
def training_batch(self,target:Tensor)->tuple[Tensor,Tensor,Tensor]:
u=torch.rand(target.shape[0],device=target.device,dtype=target.dtype)
t=self.cutoff*(1-u.pow(1/1.5));noise=torch.randn_like(target)
return t[:,None,None]*target+(1-t[:,None,None])*noise,t,target-noise
@torch.no_grad()
def sample(self,fn:Callable[[Tensor,Tensor],Tensor],shape:tuple[int,...],device,steps:int|None=None)->Tensor:
count=steps or self.inference_steps;x=torch.randn(shape,device=device);grid=torch.linspace(0,1,count+1,device=device)
for i in range(count): x=x+(grid[i+1]-grid[i])*fn(x,grid[i].expand(shape[0]))
return x
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import argparse
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np,torch
from .data import Pi07SteerableDataset
from .flow import FlowMatching
from .model import Pi07Config,Pi07Mini,HighLevelPolicy,TinyGoalGenerator
from .runtime import load_config,choose_device,write_json
from .tokenizer import MiniTokenizer,EpisodeMetadata
@torch.no_grad()
def evaluate(checkpoint_path):
ck=torch.load(checkpoint_path,map_location='cpu',weights_only=False);cfg=ck['config'];root=Path(__file__).resolve().parents[2];dev=choose_device(cfg['device']);tok=MiniTokenizer();ds=Pi07SteerableDataset(root/'data/overfit/overfit_manifest.jsonl',image_size=cfg['data']['image_size'],history_frames=cfg['data']['history_frames'],action_horizon=cfg['data']['action_horizon'],action_statistics=ck['action_statistics'])
pcfg=Pi07Config(**ck['policy_config']);policy=Pi07Mini(pcfg);policy.load_state_dict(ck['policy']);policy.to(dev).eval();high=HighLevelPolicy(pcfg);high.load_state_dict(ck['high_level']);high.to(dev).eval();goal=TinyGoalGenerator(tok.vocab_size);goal.load_state_dict(ck['goal']);goal.to(dev).eval();flow=FlowMatching(cfg['flow']['integration_steps']);s=next(ds[i] for i in range(len(ds)) if ds[i]['policy_kind']=='expert');meta=EpisodeMetadata(**s['token_metadata']);image=s['image'][None].to(dev)
prompt,_=tok.encode_prompt('drive safely',None,meta,include_subtask=False);hl=high(image,prompt[None].to(dev),s['history_images'][None].to(dev),s['history_states'][None].to(dev),s['current_state'][None].to(dev));pred_subtask=tok.subtasks.decode(int(hl.logits.argmax(-1).cpu()))
full=tok.build_policy_sequence('drive safely',s['reasoning'],s['actions'],meta);ids=full.input_ids[:full.action_start_index][None].to(dev)
torch.manual_seed(cfg['seed']);g=torch.randn_like(s['subgoal_pixel_values'][None]).to(dev);grid=torch.linspace(0,1,6,device=dev)
for i in range(5): g=g+(grid[i+1]-grid[i])*goal(image,ids,g,grid[i].expand(1)).vector_field
generated_goal=g.cpu()[0]
def sample(name,include_meta=True,use_goal=True,use_history=True,cfg_beta=None,generated=False):
seq=tok.build_policy_sequence('drive safely',s['reasoning'],s['actions'],meta,include_episode_metadata=include_meta);xids=seq.input_ids[:seq.action_start_index][None].to(dev)
goal_pixels=(generated_goal if generated else s['subgoal_pixel_values'])[None].to(dev) if use_goal else None
common=dict(pixel_values=image,history_pixel_values=s['history_images'][None].to(dev) if use_history else None,history_states=s['history_states'][None].to(dev) if use_history else None,current_state=s['current_state'][None].to(dev),subgoal_pixel_values=goal_pixels)
def field(x,t):
if cfg_beta is None:return policy(input_ids=xids,noisy_actions=x,timesteps=t,**common).vector_field
un=tok.build_policy_sequence('drive safely',s['reasoning'],s['actions'],meta,include_episode_metadata=False);uids=un.input_ids[:un.action_start_index][None].to(dev)
return policy.metadata_cfg(xids,uids,guidance_scale=cfg_beta,noisy_actions=x,timesteps=t,**common)
torch.manual_seed(cfg['evaluation']['fixed_noise_seed']);a=flow.sample(field,(1,ds.action_horizon,2),dev,cfg['flow']['integration_steps'])[0].cpu();return ds.action_normalizer.decode(a,s['history_xy']).numpy()
curves={'full_oracle_goal':sample('full'),'generated_goal':sample('generated',generated=True),'no_metadata':sample('no_metadata',False),'no_goal':sample('no_goal',use_goal=False),'no_history':sample('no_history',use_history=False),'cfg_beta2':sample('cfg',cfg_beta=2.0)}
target=ds.action_normalizer.decode(s['rtc_target_actions'],s['history_xy']).numpy();valid=s['rtc_valid_mask'].numpy().astype(bool);metrics={k:float(np.mean((v[valid]-target[valid])**2)) for k,v in curves.items()}
out=Path(checkpoint_path).parent;fig,ax=plt.subplots(figsize=(9,6));ax.plot(target[:,0],target[:,1],'o-',label='expert target',linewidth=2)
for k,v in curves.items():ax.plot(v[:,0],v[:,1],'x--',label=f'{k} ({metrics[k]:.3f})')
ax.axis('equal');ax.grid(True,alpha=.3);ax.legend(fontsize=8);ax.set_xlabel('forward x (m)');ax.set_ylabel('left y (m)');ax.set_title('π0.7 context steering and ablations');fig.tight_layout();traj=out/'context_ablation.png';fig.savefig(traj,dpi=170);plt.close(fig)
fig,axes=plt.subplots(1,3,figsize=(10,3));axes[0].imshow(image[0].cpu().permute(1,2,0));axes[0].set_title('current observation');axes[1].imshow(s['subgoal_pixel_values'].permute(1,2,0));axes[1].set_title('endpoint goal target');axes[2].imshow(generated_goal.permute(1,2,0).clamp(0,1));axes[2].set_title('generated goal');
for a in axes:a.axis('off')
fig.tight_layout();goalpath=out/'goal_generation.png';fig.savefig(goalpath,dpi=170);plt.close(fig);write_json(out/'evaluation.json',{'predicted_subtask':pred_subtask,'trajectory_metrics':metrics});print({'predicted_subtask':pred_subtask,'trajectory_metrics':metrics});return traj,goalpath
def main():
p=argparse.ArgumentParser();p.add_argument('--checkpoint',default='outputs/overfit/checkpoint.pt');evaluate(p.parse_args().checkpoint)
if __name__=='__main__':main()
+215
View File
@@ -0,0 +1,215 @@
"""A tiny MEM-style video encoder used by the pi0.7 architecture test.
The real MEM encoder modifies a pretrained ViT by alternating spatial
attention within each frame and causal temporal attention across the same
patch in different frames. It then discards all past-frame representations
and forwards only the temporally enriched current-frame patches to the VLM.
This module preserves those information-flow contracts in pure PyTorch. It
is intentionally small and does not attempt to reproduce the pretrained ViT.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import torch
from torch import Tensor, nn
@dataclass(frozen=True)
class MEMConfig:
image_channels: int = 3
image_size: int = 64
patch_size: int = 16
d_model: int = 128
n_heads: int = 4
n_layers: int = 2
mlp_ratio: int = 4
max_history_frames: int = 6
dropout: float = 0.0
def __post_init__(self) -> None:
if self.image_size % self.patch_size:
raise ValueError("image_size must be divisible by patch_size")
if self.d_model % self.n_heads:
raise ValueError("d_model must be divisible by n_heads")
if self.max_history_frames < 0:
raise ValueError("max_history_frames must be non-negative")
@property
def output_tokens(self) -> int:
return (self.image_size // self.patch_size) ** 2
class _MEMLayer(nn.Module):
"""Factorized spatial-bidirectional and temporal-causal attention."""
def __init__(self, config: MEMConfig) -> None:
super().__init__()
d = config.d_model
self.n_heads = config.n_heads
self.spatial_norm = nn.LayerNorm(d)
self.spatial_attention = nn.MultiheadAttention(
d, config.n_heads, dropout=config.dropout, batch_first=True
)
self.temporal_norm = nn.LayerNorm(d)
self.temporal_attention = nn.MultiheadAttention(
d, config.n_heads, dropout=config.dropout, batch_first=True
)
hidden = d * config.mlp_ratio
self.mlp_norm = nn.LayerNorm(d)
self.mlp = nn.Sequential(
nn.Linear(d, hidden),
nn.GELU(approximate="tanh"),
nn.Dropout(config.dropout),
nn.Linear(hidden, d),
nn.Dropout(config.dropout),
)
def forward(self, tokens: Tensor, frame_valid: Tensor) -> Tensor:
batch, frames, patches, width = tokens.shape
# Standard ViT spatial attention is bidirectional inside each frame.
spatial = self.spatial_norm(tokens).reshape(batch * frames, patches, width)
spatial_update, _ = self.spatial_attention(spatial, spatial, spatial, need_weights=False)
tokens = tokens + spatial_update.reshape(batch, frames, patches, width)
# MEM factorizes time: each spatial patch attends causally over frames.
temporal = self.temporal_norm(tokens).permute(0, 2, 1, 3)
temporal = temporal.reshape(batch * patches, frames, width)
causal_allowed = torch.tril(
torch.ones(frames, frames, dtype=torch.bool, device=tokens.device)
)
allowed = causal_allowed[None] & frame_valid[:, None]
# An invalid query receives a harmless self edge, preventing all-masked
# rows from producing NaNs. Its output is zeroed after the layer.
identity = torch.eye(frames, dtype=torch.bool, device=tokens.device)[None]
allowed = torch.where(frame_valid[:, :, None], allowed, identity)
blocked = (~allowed)[:, None].expand(batch, patches, frames, frames)
blocked = blocked.reshape(batch * patches, frames, frames)
blocked = blocked[:, None].expand(-1, self.n_heads, -1, -1)
blocked = blocked.reshape(batch * patches * self.n_heads, frames, frames)
temporal_update, _ = self.temporal_attention(
temporal,
temporal,
temporal,
attn_mask=blocked,
need_weights=False,
)
temporal_update = temporal_update.reshape(batch, patches, frames, width)
tokens = tokens + temporal_update.permute(0, 2, 1, 3)
tokens = tokens + self.mlp(self.mlp_norm(tokens))
# Invalid history frames must not leak through residual paths.
return tokens * frame_valid[:, :, None, None].to(tokens.dtype)
class MEMVideoEncoder(nn.Module):
"""Compress arbitrary valid history lengths to one frame's patch count.
``history_pixel_values`` is ordered oldest-to-newest. The current image is
appended internally and is always valid. A fixed sinusoidal temporal
encoding and causal temporal attention make the representation sensitive
to frame order, while returning only current-frame tokens keeps the output
size independent of history length.
"""
def __init__(self, config: MEMConfig) -> None:
super().__init__()
self.config = config
self.patch_encoder = nn.Conv2d(
config.image_channels,
config.d_model,
config.patch_size,
stride=config.patch_size,
)
self.spatial_position = nn.Parameter(
torch.randn(1, 1, config.output_tokens, config.d_model) * 0.01
)
self.layers = nn.ModuleList(_MEMLayer(config) for _ in range(config.n_layers))
self.output_norm = nn.LayerNorm(config.d_model)
@staticmethod
def temporal_position(ages: Tensor, width: int) -> Tensor:
"""Fixed sinusoidal encoding whose current-frame value (age 0) is zero."""
if width <= 0:
raise ValueError("temporal embedding width must be positive")
half = (width + 1) // 2
exponent = torch.arange(half, device=ages.device, dtype=torch.float32)
denominator = torch.exp(exponent * (-math.log(10_000.0) / max(half - 1, 1)))
angles = ages.float()[:, None] * denominator[None]
# Subtracting one from cosine ensures a zero embedding at age zero,
# matching MEM's invariant single-frame initialization contract.
embedding = torch.cat((angles.sin(), angles.cos() - 1.0), dim=-1)
return embedding[:, :width]
def _validate_images(self, images: Tensor, name: str) -> None:
if images.ndim != 4:
raise ValueError(f"{name} must have shape [batch, channels, height, width]")
expected = (self.config.image_channels, self.config.image_size, self.config.image_size)
if tuple(images.shape[1:]) != expected:
raise ValueError(f"{name} must end in shape {expected}")
def forward(
self,
current_pixel_values: Tensor,
history_pixel_values: Tensor | None = None,
history_mask: Tensor | None = None,
) -> Tensor:
self._validate_images(current_pixel_values, "current_pixel_values")
batch = current_pixel_values.shape[0]
if history_pixel_values is None:
history_pixel_values = current_pixel_values.new_empty(
batch,
0,
self.config.image_channels,
self.config.image_size,
self.config.image_size,
)
if history_pixel_values.ndim != 5:
raise ValueError(
"history_pixel_values must have shape [batch, time, channels, height, width]"
)
if history_pixel_values.shape[0] != batch:
raise ValueError("history and current images must have the same batch size")
if tuple(history_pixel_values.shape[2:]) != tuple(current_pixel_values.shape[1:]):
raise ValueError("history and current images must have the same image shape")
history = history_pixel_values.shape[1]
if history > self.config.max_history_frames:
raise ValueError("history length exceeds configured maximum")
if history_mask is None:
history_mask = torch.ones(
batch, history, dtype=torch.bool, device=current_pixel_values.device
)
if history_mask.shape != (batch, history):
raise ValueError("history_mask must have shape [batch, history]")
history_mask = history_mask.bool()
current_valid = torch.ones(batch, 1, dtype=torch.bool, device=current_pixel_values.device)
frame_valid = torch.cat((history_mask, current_valid), dim=1)
frames = torch.cat((history_pixel_values, current_pixel_values[:, None]), dim=1)
flat = frames.reshape(
batch * (history + 1),
self.config.image_channels,
self.config.image_size,
self.config.image_size,
)
tokens = self.patch_encoder(flat).flatten(2).transpose(1, 2)
tokens = tokens.reshape(batch, history + 1, self.config.output_tokens, -1)
tokens = tokens + self.spatial_position
# Oldest frame has the largest age; current frame has age zero.
ages = torch.arange(history, -1, -1, device=tokens.device)
temporal_position = self.temporal_position(ages, self.config.d_model)
tokens = tokens + temporal_position[None, :, None].to(tokens.dtype)
tokens = tokens * frame_valid[:, :, None, None].to(tokens.dtype)
for layer in self.layers:
tokens = layer(tokens, frame_valid)
# Fixed number of tokens regardless of how many history frames entered.
return self.output_norm(tokens[:, -1])
+816
View File
@@ -0,0 +1,816 @@
r"""Minimal :math:`\pi_{0.7}` policy with MEM and diverse context.
The implementation is a small architecture validation model, not a scaled
reproduction. It preserves the paper's essential paths:
* a MEM-style encoder compresses ordered visual history to a fixed token count;
* current and historical continuous proprioceptive states are individual tokens;
* observations, visual subgoals, and causal text form distinct attention blocks;
* the VLM predicts FAST-lite tokens with cross entropy;
* an AdaRMSNorm flow expert predicts continuous actions;
* FAST tokens and flow actions cannot read one another; and
* flow-to-VLM gradients are stopped by Knowledge Insulation.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Iterator, Optional
import torch
from torch import Tensor, nn
import torch.nn.functional as F
from .memory import MEMConfig, MEMVideoEncoder
@dataclass
class Pi07Config:
vocab_size: int = 368
action_dim: int = 2
state_dim: int = 2
image_channels: int = 3
image_size: int = 64
patch_size: int = 16
d_model: int = 128
n_heads: int = 4
n_layers: int = 4
memory_layers: int = 2
mlp_ratio: int = 4
max_history_frames: int = 6
max_text_tokens: int = 96
max_action_tokens: int = 50
action_start_token_id: int = 302
dropout: float = 0.0
rms_eps: float = 1e-6
def __post_init__(self) -> None:
if self.d_model % self.n_heads:
raise ValueError("d_model must be divisible by n_heads")
if self.image_size % self.patch_size:
raise ValueError("image_size must be divisible by patch_size")
if not 0 <= self.action_start_token_id < self.vocab_size:
raise ValueError("action_start_token_id must be inside the vocabulary")
@property
def image_tokens(self) -> int:
return (self.image_size // self.patch_size) ** 2
@property
def max_prefix_tokens(self) -> int:
# MEM observation + all states + one subgoal view + textual prompt.
return (
self.image_tokens
+ self.max_history_frames
+ 1
+ self.image_tokens
+ self.max_text_tokens
)
@dataclass(frozen=True)
class PrefixLayout:
observation_tokens: int
goal_tokens: int
text_tokens: int
@property
def goal_start(self) -> int:
return self.observation_tokens
@property
def text_start(self) -> int:
return self.observation_tokens + self.goal_tokens
@property
def total_tokens(self) -> int:
return self.text_start + self.text_tokens
@dataclass
class Pi07Output:
text_logits: Tensor
vector_field: Tensor
prefix_hidden: Tensor
action_hidden: Tensor
layout: PrefixLayout
loss: Optional[Tensor] = None
text_loss: Optional[Tensor] = None
flow_loss: Optional[Tensor] = None
@dataclass
class HighLevelOutput:
logits: Tensor
loss: Optional[Tensor] = None
@dataclass
class GoalGeneratorOutput:
vector_field: Tensor
loss: Optional[Tensor] = None
class RMSNorm(nn.Module):
def __init__(self, dim: int, eps: float = 1e-6) -> None:
super().__init__()
self.weight = nn.Parameter(torch.ones(dim))
self.eps = eps
def forward(self, x: Tensor) -> Tensor:
rms = x.float().pow(2).mean(dim=-1, keepdim=True)
return x * torch.rsqrt(rms.to(x.dtype) + self.eps) * self.weight
class AdaRMSNorm(nn.Module):
def __init__(self, dim: int, condition_dim: int, eps: float = 1e-6) -> None:
super().__init__()
self.norm = RMSNorm(dim, eps)
self.modulation = nn.Linear(condition_dim, 2 * dim)
nn.init.zeros_(self.modulation.weight)
nn.init.zeros_(self.modulation.bias)
def forward(self, x: Tensor, condition: Tensor) -> Tensor:
shift, scale = self.modulation(condition).chunk(2, dim=-1)
return self.norm(x) * (1 + scale[:, None]) + shift[:, None]
class TimeEmbedding(nn.Module):
def __init__(self, dim: int) -> None:
super().__init__()
fourier_dim = max(8, dim // 2)
self.fourier_dim = fourier_dim + fourier_dim % 2
self.mlp = nn.Sequential(
nn.Linear(self.fourier_dim, dim), nn.SiLU(), nn.Linear(dim, dim)
)
def forward(self, timestep: Tensor) -> Tensor:
timestep = timestep.reshape(-1).float()
half = self.fourier_dim // 2
frequencies = torch.exp(
torch.linspace(
math.log(1.0),
math.log(1000.0),
half,
device=timestep.device,
dtype=timestep.dtype,
)
)
angles = 2 * math.pi * timestep[:, None] * frequencies[None]
return self.mlp(torch.cat((angles.sin(), angles.cos()), dim=-1))
class AttentionWeights(nn.Module):
def __init__(self, dim: int) -> None:
super().__init__()
self.q = nn.Linear(dim, dim, bias=False)
self.k = nn.Linear(dim, dim, bias=False)
self.v = nn.Linear(dim, dim, bias=False)
self.out = nn.Linear(dim, dim, bias=False)
class MLP(nn.Module):
def __init__(self, dim: int, ratio: int, dropout: float) -> None:
super().__init__()
hidden = dim * ratio
self.net = nn.Sequential(
nn.Linear(dim, hidden),
nn.GELU(approximate="tanh"),
nn.Dropout(dropout),
nn.Linear(hidden, dim),
nn.Dropout(dropout),
)
def forward(self, x: Tensor) -> Tensor:
return self.net(x)
def attention(
query: Tensor,
key: Tensor,
value: Tensor,
n_heads: int,
allowed: Tensor,
dropout: float,
training: bool,
) -> Tensor:
batch, query_length, width = query.shape
head_dim = width // n_heads
def split(x: Tensor) -> Tensor:
return x.reshape(batch, -1, n_heads, head_dim).transpose(1, 2)
q, k, v = split(query), split(key), split(value)
scores = q @ k.transpose(-2, -1) / math.sqrt(head_dim)
scores = scores.masked_fill(~allowed[:, None], torch.finfo(scores.dtype).min)
probabilities = scores.float().softmax(dim=-1).to(scores.dtype)
probabilities = F.dropout(probabilities, p=dropout, training=training)
output = probabilities @ v
return output.transpose(1, 2).contiguous().reshape(batch, query_length, width)
class KnowledgeInsulatedBlock(nn.Module):
"""Independent VLM/flow weights with stop-gradient VLM keys and values."""
def __init__(self, config: Pi07Config) -> None:
super().__init__()
d = config.d_model
self.n_heads = config.n_heads
self.dropout = config.dropout
self.vlm_norm1 = RMSNorm(d, config.rms_eps)
self.vlm_attention = AttentionWeights(d)
self.vlm_norm2 = RMSNorm(d, config.rms_eps)
self.vlm_mlp = MLP(d, config.mlp_ratio, config.dropout)
self.action_norm1 = AdaRMSNorm(d, d, config.rms_eps)
self.action_attention = AttentionWeights(d)
self.action_norm2 = AdaRMSNorm(d, d, config.rms_eps)
self.action_mlp = MLP(d, config.mlp_ratio, config.dropout)
def forward(
self,
prefix: Tensor,
action: Tensor,
time_condition: Tensor,
prefix_edges: Tensor,
action_edges: Tensor,
) -> tuple[Tensor, Tensor]:
prefix_input = self.vlm_norm1(prefix)
p = self.vlm_attention
prefix_update = attention(
p.q(prefix_input),
p.k(prefix_input),
p.v(prefix_input),
self.n_heads,
prefix_edges,
self.dropout,
self.training,
)
prefix = prefix + p.out(prefix_update)
prefix = prefix + self.vlm_mlp(self.vlm_norm2(prefix))
action_input = self.action_norm1(action, time_condition)
a = self.action_attention
# Knowledge Insulation: continuous flow loss can condition on VLM
# features but cannot update the VLM or its K/V projections.
key = torch.cat((p.k(prefix_input).detach(), a.k(action_input)), dim=1)
value = torch.cat((p.v(prefix_input).detach(), a.v(action_input)), dim=1)
action_update = attention(
a.q(action_input),
key,
value,
self.n_heads,
action_edges,
self.dropout,
self.training,
)
action = action + a.out(action_update)
action = action + self.action_mlp(self.action_norm2(action, time_condition))
return prefix, action
class Pi07Mini(nn.Module):
"""Small pi0.7-style VLA for structural and overfitting experiments."""
def __init__(self, config: Optional[Pi07Config] = None) -> None:
super().__init__()
self.config = config or Pi07Config()
c = self.config
self.memory_encoder = MEMVideoEncoder(
MEMConfig(
image_channels=c.image_channels,
image_size=c.image_size,
patch_size=c.patch_size,
d_model=c.d_model,
n_heads=c.n_heads,
n_layers=c.memory_layers,
mlp_ratio=c.mlp_ratio,
max_history_frames=c.max_history_frames,
dropout=c.dropout,
)
)
self.state_projection = nn.Linear(c.state_dim, c.d_model)
self.token_embedding = nn.Embedding(c.vocab_size, c.d_model)
self.segment_embedding = nn.Embedding(3, c.d_model)
self.prefix_position = nn.Parameter(
torch.randn(1, c.max_prefix_tokens, c.d_model) * 0.01
)
self.action_projection = nn.Linear(c.action_dim, c.d_model)
self.action_position = nn.Parameter(
torch.randn(1, c.max_action_tokens, c.d_model) * 0.01
)
self.time_embedding = TimeEmbedding(c.d_model)
self.layers = nn.ModuleList(KnowledgeInsulatedBlock(c) for _ in range(c.n_layers))
self.prefix_norm = RMSNorm(c.d_model, c.rms_eps)
self.action_norm = AdaRMSNorm(c.d_model, c.d_model, c.rms_eps)
self.text_head = nn.Linear(c.d_model, c.vocab_size, bias=False)
self.vector_field_head = nn.Linear(c.d_model, c.action_dim)
self.text_head.weight = self.token_embedding.weight
def _adapt_goal_channels(self, pixels: Tensor) -> Tensor:
if pixels.shape[1] == self.config.image_channels:
return pixels
if pixels.shape[1] == 1 and self.config.image_channels == 3:
return pixels.expand(-1, 3, -1, -1)
raise ValueError("subgoal image has unsupported channel count")
def encode_prefix(
self,
pixel_values: Tensor,
history_pixel_values: Tensor | None,
history_states: Tensor | None,
current_state: Tensor,
input_ids: Tensor,
subgoal_pixel_values: Tensor | None = None,
history_mask: Tensor | None = None,
subgoal_mask: Tensor | None = None,
attention_mask: Tensor | None = None,
) -> tuple[Tensor, Tensor, PrefixLayout]:
observation_images = self.memory_encoder(
pixel_values, history_pixel_values, history_mask
)
batch = pixel_values.shape[0]
if current_state.shape != (batch, self.config.state_dim):
raise ValueError("current_state must have shape [batch, state_dim]")
if history_states is None:
history_states = current_state.new_empty(batch, 0, self.config.state_dim)
if history_states.ndim != 3 or history_states.shape[0] != batch:
raise ValueError("history_states must have shape [batch, time, state_dim]")
if history_states.shape[-1] != self.config.state_dim:
raise ValueError("history_states has the wrong state dimension")
history_length = history_states.shape[1]
if history_length > self.config.max_history_frames:
raise ValueError("state history exceeds configured maximum")
if history_pixel_values is not None and history_pixel_values.shape[1] != history_length:
raise ValueError("visual and proprioceptive history lengths must match")
if history_mask is None:
history_mask = torch.ones(
batch, history_length, dtype=torch.bool, device=pixel_values.device
)
if history_mask.shape != (batch, history_length):
raise ValueError("history_mask must have shape [batch, history]")
all_states = torch.cat((history_states, current_state[:, None]), dim=1)
state_tokens = self.state_projection(all_states)
ages = torch.arange(history_length, -1, -1, device=state_tokens.device)
state_tokens = state_tokens + MEMVideoEncoder.temporal_position(
ages, self.config.d_model
)[None].to(state_tokens.dtype)
state_valid = torch.cat(
(
history_mask.bool(),
torch.ones(batch, 1, dtype=torch.bool, device=pixel_values.device),
),
dim=1,
)
state_tokens = state_tokens * state_valid[:, :, None].to(state_tokens.dtype)
observation = torch.cat((observation_images, state_tokens), dim=1)
observation_valid = torch.cat(
(
torch.ones(
batch,
observation_images.shape[1],
dtype=torch.bool,
device=pixel_values.device,
),
state_valid,
),
dim=1,
)
if subgoal_pixel_values is None:
goal = observation.new_empty(batch, 0, self.config.d_model)
goal_valid = torch.empty(batch, 0, dtype=torch.bool, device=pixel_values.device)
else:
goal_pixels = self._adapt_goal_channels(subgoal_pixel_values)
goal = self.memory_encoder(goal_pixels)
if subgoal_mask is None:
subgoal_mask = torch.ones(batch, dtype=torch.bool, device=pixel_values.device)
if subgoal_mask.shape != (batch,):
raise ValueError("subgoal_mask must have shape [batch]")
goal_valid = subgoal_mask.bool()[:, None].expand(batch, goal.shape[1])
goal = goal * goal_valid[:, :, None].to(goal.dtype)
if input_ids.ndim != 2 or input_ids.shape[0] != batch:
raise ValueError("input_ids must have shape [batch, text]")
text = self.token_embedding(input_ids)
if attention_mask is None:
attention_mask = torch.ones_like(input_ids, dtype=torch.bool)
if attention_mask.shape != input_ids.shape:
raise ValueError("attention_mask must match input_ids")
text_valid = attention_mask.bool()
layout = PrefixLayout(observation.shape[1], goal.shape[1], text.shape[1])
if layout.total_tokens > self.config.max_prefix_tokens:
raise ValueError("prefix exceeds configured maximum length")
prefix = torch.cat((observation, goal, text), dim=1)
segments = torch.cat(
(
torch.zeros(layout.observation_tokens, dtype=torch.long, device=prefix.device),
torch.ones(layout.goal_tokens, dtype=torch.long, device=prefix.device),
torch.full(
(layout.text_tokens,), 2, dtype=torch.long, device=prefix.device
),
)
)
prefix = prefix + self.segment_embedding(segments)[None]
prefix = prefix + self.prefix_position[:, : layout.total_tokens]
valid = torch.cat((observation_valid, goal_valid, text_valid), dim=1)
return prefix, valid, layout
@staticmethod
def build_prefix_attention_mask(layout: PrefixLayout, valid: Tensor) -> Tensor:
"""Build obs -> goal -> causal-text block attention.
Observation queries only read observations. Goal-image queries read
observations and all goal tokens bidirectionally. Text queries read
both visual blocks and earlier text tokens causally.
"""
length = layout.total_tokens
if valid.shape[1] != length:
raise ValueError("valid mask does not match PrefixLayout")
device = valid.device
q = torch.arange(length, device=device)[:, None]
k = torch.arange(length, device=device)[None]
obs = q < layout.goal_start
goal = (q >= layout.goal_start) & (q < layout.text_start)
text = q >= layout.text_start
structural = (
(obs & (k < layout.goal_start))
| (goal & (k < layout.text_start))
| (text & ((k < layout.text_start) | ((k >= layout.text_start) & (k <= q))))
)
edges = structural[None] & valid[:, None]
identity = torch.eye(length, dtype=torch.bool, device=device)[None]
return torch.where(valid[:, :, None], edges, identity)
def _derive_action_prefix_mask(self, input_ids: Tensor) -> Tensor:
is_fast = input_ids.eq(self.config.action_start_token_id)
return is_fast.long().cumsum(dim=1).eq(0)
def _action_attention_mask(
self,
valid: Tensor,
layout: PrefixLayout,
action_length: int,
action_prefix_mask: Tensor,
) -> Tensor:
batch = valid.shape[0]
supplied = action_prefix_mask.bool()
if supplied.shape == (batch, layout.text_tokens):
supplied = torch.cat(
(
torch.ones(
batch,
layout.text_start,
dtype=torch.bool,
device=valid.device,
),
supplied,
),
dim=1,
)
if supplied.shape != valid.shape:
raise ValueError("action_prefix_mask must cover text or the full prefix")
readable = valid & supplied
prefix_keys = readable[:, None].expand(batch, action_length, layout.total_tokens)
action_keys = torch.ones(
batch, action_length, action_length, dtype=torch.bool, device=valid.device
)
return torch.cat((prefix_keys, action_keys), dim=-1)
def vlm_parameters(self) -> Iterator[nn.Parameter]:
modules: list[nn.Module] = [
self.memory_encoder,
self.state_projection,
self.token_embedding,
self.segment_embedding,
self.prefix_norm,
self.text_head,
]
for layer in self.layers:
modules.extend(
(layer.vlm_norm1, layer.vlm_attention, layer.vlm_norm2, layer.vlm_mlp)
)
seen: set[int] = set()
if id(self.prefix_position) not in seen:
seen.add(id(self.prefix_position))
yield self.prefix_position
for module in modules:
for parameter in module.parameters():
if id(parameter) not in seen:
seen.add(id(parameter))
yield parameter
def action_parameters(self) -> Iterator[nn.Parameter]:
modules: list[nn.Module] = [
self.action_projection,
self.time_embedding,
self.action_norm,
self.vector_field_head,
]
for layer in self.layers:
modules.extend(
(
layer.action_norm1,
layer.action_attention,
layer.action_norm2,
layer.action_mlp,
)
)
yield self.action_position
for module in modules:
yield from module.parameters()
def forward(
self,
pixel_values: Tensor,
input_ids: Tensor,
noisy_actions: Tensor,
timesteps: Tensor,
history_pixel_values: Tensor | None = None,
history_states: Tensor | None = None,
current_state: Tensor | None = None,
subgoal_pixel_values: Tensor | None = None,
history_mask: Tensor | None = None,
subgoal_mask: Tensor | None = None,
attention_mask: Tensor | None = None,
action_prefix_mask: Tensor | None = None,
generated_token_start: int | None = None,
text_labels: Tensor | None = None,
target_velocity: Tensor | None = None,
text_loss_weight: float = 1.0,
flow_loss_weight: float = 1.0,
) -> Pi07Output:
if generated_token_start not in (None, 0):
raise ValueError("pi0.7 prompt text is causal from its first token")
if noisy_actions.ndim != 3:
raise ValueError("noisy_actions must have shape [batch, horizon, action_dim]")
if noisy_actions.shape[-1] != self.config.action_dim:
raise ValueError("noisy_actions has the wrong action dimension")
if noisy_actions.shape[1] > self.config.max_action_tokens:
raise ValueError("action horizon exceeds configured maximum")
if current_state is None:
current_state = noisy_actions.new_zeros(
noisy_actions.shape[0], self.config.state_dim
)
prefix, valid, layout = self.encode_prefix(
pixel_values,
history_pixel_values,
history_states,
current_state,
input_ids,
subgoal_pixel_values,
history_mask,
subgoal_mask,
attention_mask,
)
horizon = noisy_actions.shape[1]
action = self.action_projection(noisy_actions)
action = action + self.action_position[:, :horizon]
condition = self.time_embedding(timesteps).to(action.dtype)
if action_prefix_mask is None:
action_prefix_mask = self._derive_action_prefix_mask(input_ids)
prefix_edges = self.build_prefix_attention_mask(layout, valid)
action_edges = self._action_attention_mask(
valid, layout, horizon, action_prefix_mask
)
for layer in self.layers:
prefix, action = layer(
prefix, action, condition, prefix_edges, action_edges
)
prefix_hidden = self.prefix_norm(prefix)
action_hidden = self.action_norm(action, condition)
text_logits = self.text_head(prefix_hidden[:, layout.text_start :])
vector_field = self.vector_field_head(action_hidden)
text_loss = None
if text_labels is not None:
text_loss = F.cross_entropy(
text_logits.reshape(-1, text_logits.shape[-1]),
text_labels.reshape(-1),
ignore_index=-100,
)
flow_loss = None
if target_velocity is not None:
flow_loss = F.mse_loss(vector_field, target_velocity)
terms = [
term
for term in (
None if text_loss is None else text_loss_weight * text_loss,
None if flow_loss is None else flow_loss_weight * flow_loss,
)
if term is not None
]
loss = sum(terms) if terms else None
return Pi07Output(
text_logits=text_logits,
vector_field=vector_field,
prefix_hidden=prefix_hidden,
action_hidden=action_hidden,
layout=layout,
loss=loss,
text_loss=text_loss,
flow_loss=flow_loss,
)
@staticmethod
def combine_metadata_cfg(
conditioned: Tensor, unconditioned: Tensor, guidance_scale: float = 1.5
) -> Tensor:
"""pi0.7 metadata CFG: conditional + beta*(conditional-unconditional)."""
if guidance_scale < 0:
raise ValueError("guidance_scale must be non-negative")
if conditioned.shape != unconditioned.shape:
raise ValueError("conditioned and unconditioned predictions must match")
return conditioned + guidance_scale * (conditioned - unconditioned)
def metadata_cfg(
self,
conditioned_input_ids: Tensor,
unconditioned_input_ids: Tensor,
*,
guidance_scale: float = 1.5,
conditioned_attention_mask: Tensor | None = None,
unconditioned_attention_mask: Tensor | None = None,
conditioned_action_prefix_mask: Tensor | None = None,
unconditioned_action_prefix_mask: Tensor | None = None,
**common_inputs: Tensor | None,
) -> Tensor:
"""Run metadata-positive and metadata-dropped branches and combine them."""
conditioned = self(
input_ids=conditioned_input_ids,
attention_mask=conditioned_attention_mask,
action_prefix_mask=conditioned_action_prefix_mask,
**common_inputs,
).vector_field
unconditioned = self(
input_ids=unconditioned_input_ids,
attention_mask=unconditioned_attention_mask,
action_prefix_mask=unconditioned_action_prefix_mask,
**common_inputs,
).vector_field
return self.combine_metadata_cfg(conditioned, unconditioned, guidance_scale)
def flow_matching_sample(actions: Tensor) -> tuple[Tensor, Tensor, Tensor]:
"""Construct the linear conditional-flow target used by the action expert."""
batch = actions.shape[0]
timestep = torch.rand(batch, device=actions.device, dtype=actions.dtype)
noise = torch.randn_like(actions)
broadcast = timestep.reshape(batch, *([1] * (actions.ndim - 1)))
noisy_actions = (1 - broadcast) * noise + broadcast * actions
return noisy_actions, timestep, actions - noise
class HighLevelPolicy(nn.Module):
"""Tiny semantic policy that predicts the next subtask prompt token.
The paper uses a separate policy based on the same VLM family. This proxy
shares the same MEM input contract while replacing the 4B VLM with a small
Transformer encoder suitable for a few-example overfit test.
"""
def __init__(self, config: Optional[Pi07Config] = None) -> None:
super().__init__()
self.config = config or Pi07Config()
c = self.config
self.memory_encoder = MEMVideoEncoder(
MEMConfig(
image_channels=c.image_channels,
image_size=c.image_size,
patch_size=c.patch_size,
d_model=c.d_model,
n_heads=c.n_heads,
n_layers=c.memory_layers,
mlp_ratio=c.mlp_ratio,
max_history_frames=c.max_history_frames,
dropout=c.dropout,
)
)
self.state_projection = nn.Linear(c.state_dim, c.d_model)
self.token_embedding = nn.Embedding(c.vocab_size, c.d_model)
layer = nn.TransformerEncoderLayer(
c.d_model,
c.n_heads,
c.d_model * c.mlp_ratio,
c.dropout,
activation="gelu",
batch_first=True,
norm_first=True,
)
self.transformer = nn.TransformerEncoder(layer, c.n_layers)
self.norm = nn.LayerNorm(c.d_model)
self.head = nn.Linear(c.d_model, c.vocab_size, bias=False)
self.head.weight = self.token_embedding.weight
def forward(
self,
pixel_values: Tensor,
input_ids: Tensor,
history_pixel_values: Tensor | None = None,
history_states: Tensor | None = None,
current_state: Tensor | None = None,
history_mask: Tensor | None = None,
attention_mask: Tensor | None = None,
target_token: Tensor | None = None,
) -> HighLevelOutput:
batch = pixel_values.shape[0]
if current_state is None:
current_state = pixel_values.new_zeros(batch, self.config.state_dim)
visual = self.memory_encoder(pixel_values, history_pixel_values, history_mask)
if history_states is None:
history_states = current_state.new_empty(batch, 0, self.config.state_dim)
states = self.state_projection(torch.cat((history_states, current_state[:, None]), 1))
text = self.token_embedding(input_ids)
tokens = torch.cat((visual, states, text), dim=1)
observation_length = visual.shape[1] + states.shape[1]
length = tokens.shape[1]
q = torch.arange(length, device=tokens.device)[:, None]
k = torch.arange(length, device=tokens.device)[None]
allowed = torch.where(q < observation_length, k < observation_length, k <= q)
blocked = ~allowed
if attention_mask is None:
attention_mask = torch.ones_like(input_ids, dtype=torch.bool)
state_valid = torch.ones(batch, states.shape[1], dtype=torch.bool, device=tokens.device)
if history_mask is not None and states.shape[1] > 1:
state_valid[:, :-1] = history_mask.bool()
valid = torch.cat(
(
torch.ones(batch, visual.shape[1], dtype=torch.bool, device=tokens.device),
state_valid,
attention_mask.bool(),
),
dim=1,
)
hidden = self.transformer(tokens, mask=blocked, src_key_padding_mask=~valid)
text_hidden = self.norm(hidden[:, observation_length:])
lengths = attention_mask.long().sum(dim=1).clamp_min(1) - 1
pooled = text_hidden[torch.arange(batch, device=tokens.device), lengths]
logits = self.head(pooled)
loss = None if target_token is None else F.cross_entropy(logits, target_token.long())
return HighLevelOutput(logits, loss)
class TinyGoalGenerator(nn.Module):
"""Small conditional-flow image model standing in for the BAGEL world model."""
def __init__(
self,
vocab_size: int,
image_channels: int = 3,
hidden_dim: int = 48,
text_dim: int = 64,
) -> None:
super().__init__()
self.image_channels = image_channels
self.token_embedding = nn.Embedding(vocab_size, text_dim)
self.text_projection = nn.Linear(text_dim, hidden_dim)
self.time_embedding = TimeEmbedding(hidden_dim)
self.input_projection = nn.Conv2d(2 * image_channels, hidden_dim, 3, padding=1)
self.network = nn.Sequential(
nn.SiLU(),
nn.Conv2d(hidden_dim, hidden_dim, 3, padding=1),
nn.SiLU(),
nn.Conv2d(hidden_dim, hidden_dim, 3, padding=1),
nn.SiLU(),
nn.Conv2d(hidden_dim, image_channels, 3, padding=1),
)
def forward(
self,
current_pixel_values: Tensor,
input_ids: Tensor,
noisy_subgoal: Tensor,
timesteps: Tensor,
attention_mask: Tensor | None = None,
target_velocity: Tensor | None = None,
) -> GoalGeneratorOutput:
if current_pixel_values.shape != noisy_subgoal.shape:
raise ValueError("current image and noisy subgoal must have identical shapes")
if current_pixel_values.ndim != 4 or current_pixel_values.shape[1] != self.image_channels:
raise ValueError("goal-generator images have the wrong shape")
if attention_mask is None:
attention_mask = torch.ones_like(input_ids, dtype=torch.bool)
embedded = self.token_embedding(input_ids)
weights = attention_mask.to(embedded.dtype)[..., None]
pooled = (embedded * weights).sum(1) / weights.sum(1).clamp_min(1)
condition = self.text_projection(pooled) + self.time_embedding(timesteps)
hidden = self.input_projection(
torch.cat((current_pixel_values, noisy_subgoal), dim=1)
)
hidden = hidden + condition[:, :, None, None].to(hidden.dtype)
vector_field = self.network(hidden)
loss = None
if target_velocity is not None:
loss = F.mse_loss(vector_field, target_velocity)
return GoalGeneratorOutput(vector_field, loss)
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass
import torch
MAX_RTC_DELAY_STEPS = 12
@dataclass(frozen=True)
class RTCAlignment:
"""A fixed-width target aligned to an inference/control delay."""
delayed_target_actions: torch.Tensor
valid_mask: torch.Tensor
executed_prefix_actions: torch.Tensor
delay_steps: int
def align_actions_for_rtc(
actions: torch.Tensor,
delay_steps: int,
*,
max_delay_steps: int = MAX_RTC_DELAY_STEPS,
) -> RTCAlignment:
"""Shift targets left so prediction step zero starts after control delay.
For delay ``d``, target position ``i`` supervises original action ``i+d``.
Vacated tail positions are zero padded and excluded by ``valid_mask``.
The already-executed prefix is returned for optional action-prefix input.
"""
actions = torch.as_tensor(actions, dtype=torch.float32)
if actions.ndim < 2 or actions.shape[-2] < 1:
raise ValueError("actions must have shape [..., horizon, action_dim]")
if not 0 <= delay_steps <= max_delay_steps:
raise ValueError(f"delay_steps must lie in [0, {max_delay_steps}]")
horizon = actions.shape[-2]
if delay_steps >= horizon:
raise ValueError("delay_steps must be smaller than the action horizon")
aligned = torch.zeros_like(actions)
valid = torch.zeros(actions.shape[:-1], dtype=torch.bool, device=actions.device)
remaining = horizon - delay_steps
aligned[..., :remaining, :] = actions[..., delay_steps:, :]
valid[..., :remaining] = True
prefix = actions[..., :delay_steps, :].clone()
return RTCAlignment(aligned, valid, prefix, int(delay_steps))
def sample_rtc_delay(
*,
generator: torch.Generator | None = None,
max_delay_steps: int = MAX_RTC_DELAY_STEPS,
action_horizon: int,
) -> int:
if action_horizon < 1:
raise ValueError("action_horizon must be positive")
maximum = min(int(max_delay_steps), action_horizon - 1)
if maximum < 0:
raise ValueError("no valid delay for an empty action horizon")
return int(torch.randint(maximum + 1, (), generator=generator).item())
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
import json,random
from pathlib import Path
import numpy as np,torch,yaml
def load_config(path):
with Path(path).open() as f:return yaml.safe_load(f)
def seed_everything(seed:int):random.seed(seed);np.random.seed(seed);torch.manual_seed(seed)
def choose_device(name="auto"):
if name!="auto":return torch.device(name)
if torch.cuda.is_available():return torch.device("cuda")
if torch.backends.mps.is_available():return torch.device("mps")
return torch.device("cpu")
def write_json(path,value):
p=Path(path);p.parent.mkdir(parents=True,exist_ok=True);p.write_text(json.dumps(value,indent=2,ensure_ascii=False))
+393
View File
@@ -0,0 +1,393 @@
"""Prompt and FAST-lite tokenization for the minimal pi0.7 policy.
pi0.7 conditions the low-level policy on a richer prompt than earlier pi
models: task and subtask language, episode speed/quality/mistake metadata,
control mode, and data provenance. This tokenizer represents those concepts
with explicit marker/value pairs so the architecture can be validated without
shipping a full language tokenizer.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Iterable, Literal
import torch
from torch import Tensor
ControlMode = Literal["joint", "ee"]
@dataclass(frozen=True)
class EpisodeMetadata:
speed_steps: int | None = None
quality: int | None = None
mistake: bool | None = None
control_mode: ControlMode = "joint"
source_type: str | None = None
def __post_init__(self) -> None:
if self.speed_steps is not None and self.speed_steps <= 0:
raise ValueError("speed_steps must be positive")
if self.quality is not None and not 1 <= self.quality <= 5:
raise ValueError("quality must be in the inclusive range 1..5")
if self.control_mode not in ("joint", "ee"):
raise ValueError("control_mode must be 'joint' or 'ee'")
@dataclass(frozen=True)
class PolicySequence:
input_ids: Tensor
labels: Tensor
action_prefix_mask: Tensor
generated_token_start: int
action_start_index: int
prompt_length: int
metadata_indices: tuple[int, ...]
class _LabelVocabulary:
def __init__(self, labels: tuple[str, ...], first_token_id: int) -> None:
self.labels = labels
self.first_token_id = first_token_id
self._to_token = {
label: first_token_id + index for index, label in enumerate(labels)
}
self._to_label = {token: label for label, token in self._to_token.items()}
@property
def token_ids(self) -> tuple[int, ...]:
return tuple(self._to_token[label] for label in self.labels)
def encode(self, label: str) -> int:
try:
return self._to_token[label]
except KeyError as error:
raise ValueError(f"unknown categorical label: {label}") from error
def decode(self, token: int | Tensor) -> str:
try:
return self._to_label[int(token)]
except KeyError as error:
raise ValueError(f"unknown categorical token: {int(token)}") from error
class MiniTokenizer:
"""Explicit rich-prompt tokens plus a DCT-based FAST-lite action stream."""
BOS = 256
TASK_MARKER = 257
SUBTASK_MARKER = 258
SPEED_MARKER = 259
QUALITY_MARKER = 260
MISTAKE_MARKER = 261
CONTROL_MARKER = 262
SOURCE_MARKER = 263
TASK_LABELS = (
"drive",
"pick_place",
"fold",
"open_close",
"clean",
"cook",
"unknown",
)
TASK_TOKEN_START = 264
TASK_TOKEN_END = TASK_TOKEN_START + len(TASK_LABELS) - 1
SUBTASK_LABELS = (
"keep_lane",
"steer_left",
"steer_right",
"slow_down",
"stop",
"accelerate",
"pick_place",
"fold",
"open_close",
"unknown",
)
SUBTASK_TOKEN_START = TASK_TOKEN_END + 1
SUBTASK_TOKEN_END = SUBTASK_TOKEN_START + len(SUBTASK_LABELS) - 1
SPEED_BINS = (500, 1000, 2000, 4000, 8000, 16000)
SPEED_TOKEN_START = SUBTASK_TOKEN_END + 1
SPEED_TOKEN_END = SPEED_TOKEN_START + len(SPEED_BINS) - 1
QUALITY_TOKEN_START = SPEED_TOKEN_END + 1
QUALITY_TOKEN_END = QUALITY_TOKEN_START + 4
MISTAKE_FALSE = QUALITY_TOKEN_END + 1
MISTAKE_TRUE = MISTAKE_FALSE + 1
CONTROL_JOINT = MISTAKE_TRUE + 1
CONTROL_EE = CONTROL_JOINT + 1
SOURCE_LABELS = (
"demonstration",
"autonomous",
"intervention",
"human_video",
"web",
"unknown",
)
SOURCE_TOKEN_START = CONTROL_EE + 1
SOURCE_TOKEN_END = SOURCE_TOKEN_START + len(SOURCE_LABELS) - 1
ACTION_START = SOURCE_TOKEN_END + 1
ACTION_END = ACTION_START + 1
ACTION_TOKEN_START = ACTION_END + 1
ACTION_BINS = 64
ACTION_TOKEN_END = ACTION_TOKEN_START + ACTION_BINS - 1
ACTION_MINIMUM = -4.0
ACTION_MAXIMUM = 4.0
def __init__(self) -> None:
self.tasks = _LabelVocabulary(self.TASK_LABELS, self.TASK_TOKEN_START)
self.subtasks = _LabelVocabulary(self.SUBTASK_LABELS, self.SUBTASK_TOKEN_START)
self.sources = _LabelVocabulary(self.SOURCE_LABELS, self.SOURCE_TOKEN_START)
@property
def vocab_size(self) -> int:
return self.ACTION_TOKEN_END + 1
@property
def action_token_ids(self) -> range:
return range(self.ACTION_TOKEN_START, self.ACTION_TOKEN_END + 1)
@staticmethod
def _classify_task(description: str) -> str:
text = description.casefold()
if any(word in text for word in ("drive", "lane", "road", "steer", "vehicle")):
return "drive"
if any(word in text for word in ("fold", "shirt", "towel", "laundry")):
return "fold"
if any(word in text for word in ("open", "close", "door", "drawer", "fridge")):
return "open_close"
if any(word in text for word in ("clean", "wipe", "wash", "tidy")):
return "clean"
if any(word in text for word in ("cook", "food", "recipe", "microwave")):
return "cook"
if any(word in text for word in ("pick", "place", "move", "put")):
return "pick_place"
return description if description in MiniTokenizer.TASK_LABELS else "unknown"
@staticmethod
def _classify_subtask(description: str) -> str:
if description in MiniTokenizer.SUBTASK_LABELS:
return description
text = description.casefold()
if any(word in text for word in ("stop", "halt", "stationary")):
return "stop"
if any(word in text for word in ("slow", "brake", "deceler")):
return "slow_down"
if any(word in text for word in ("accelerat", "speed up")):
return "accelerate"
if "left" in text:
return "steer_left"
if "right" in text:
return "steer_right"
if any(word in text for word in ("lane", "straight", "continue", "follow")):
return "keep_lane"
if "fold" in text:
return "fold"
if any(word in text for word in ("open", "close")):
return "open_close"
if any(word in text for word in ("pick", "place", "put")):
return "pick_place"
return "unknown"
@staticmethod
def _classify_source(source: str) -> str:
if source in MiniTokenizer.SOURCE_LABELS:
return source
text = source.casefold()
if any(word in text for word in ("demo", "teleop")):
return "demonstration"
if any(word in text for word in ("autonomous", "rollout", "policy")):
return "autonomous"
if any(word in text for word in ("intervention", "correction")):
return "intervention"
if any(word in text for word in ("human", "ego", "video")):
return "human_video"
if any(word in text for word in ("web", "internet")):
return "web"
return "unknown"
def encode_prompt(
self,
task: str,
subtask: str | None,
metadata: EpisodeMetadata,
*,
include_episode_metadata: bool = True,
include_subtask: bool = True,
) -> tuple[Tensor, tuple[int, ...]]:
"""Encode prompt and return positions controlled by metadata CFG.
Control mode is deliberately retained even in the unconditional branch,
matching pi0.7 training where control-mode dropout is not applied.
"""
ids = [self.BOS, self.TASK_MARKER, self.tasks.encode(self._classify_task(task))]
if include_subtask and subtask is not None:
ids.extend(
(self.SUBTASK_MARKER, self.subtasks.encode(self._classify_subtask(subtask)))
)
metadata_indices: list[int] = []
def add_metadata(marker: int, value: int) -> None:
metadata_indices.extend((len(ids), len(ids) + 1))
ids.extend((marker, value))
if include_episode_metadata:
if metadata.speed_steps is not None:
bin_index = min(
range(len(self.SPEED_BINS)),
key=lambda index: abs(self.SPEED_BINS[index] - metadata.speed_steps),
)
add_metadata(self.SPEED_MARKER, self.SPEED_TOKEN_START + bin_index)
if metadata.quality is not None:
add_metadata(
self.QUALITY_MARKER, self.QUALITY_TOKEN_START + metadata.quality - 1
)
if metadata.mistake is not None:
add_metadata(
self.MISTAKE_MARKER,
self.MISTAKE_TRUE if metadata.mistake else self.MISTAKE_FALSE,
)
if metadata.source_type is not None:
source = self._classify_source(metadata.source_type)
add_metadata(self.SOURCE_MARKER, self.sources.encode(source))
control = self.CONTROL_JOINT if metadata.control_mode == "joint" else self.CONTROL_EE
ids.extend((self.CONTROL_MARKER, control))
return torch.tensor(ids, dtype=torch.long), tuple(metadata_indices)
@staticmethod
def _orthonormal_dct_matrix(
horizon: int, *, device: torch.device, dtype: torch.dtype
) -> Tensor:
if horizon <= 0:
raise ValueError("DCT horizon must be positive")
time = torch.arange(horizon, device=device, dtype=dtype)
frequency = torch.arange(horizon, device=device, dtype=dtype).unsqueeze(1)
matrix = torch.cos(math.pi / horizon * (time + 0.5) * frequency)
matrix[0] *= math.sqrt(1.0 / horizon)
if horizon > 1:
matrix[1:] *= math.sqrt(2.0 / horizon)
return matrix
def dct_actions(self, actions: Tensor | Iterable[Iterable[float]]) -> Tensor:
values = torch.as_tensor(actions, dtype=torch.float32)
if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0:
raise ValueError("actions must have non-empty shape [horizon, 2]")
transform = self._orthonormal_dct_matrix(
values.shape[0], device=values.device, dtype=values.dtype
)
return transform @ values
def idct_actions(self, coefficients: Tensor | Iterable[Iterable[float]]) -> Tensor:
values = torch.as_tensor(coefficients, dtype=torch.float32)
if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0:
raise ValueError("DCT coefficients must have non-empty shape [horizon, 2]")
transform = self._orthonormal_dct_matrix(
values.shape[0], device=values.device, dtype=values.dtype
)
return transform.transpose(0, 1) @ values
def encode_actions(self, actions: Tensor | Iterable[Iterable[float]]) -> Tensor:
values = torch.as_tensor(actions, dtype=torch.float32)
if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0:
raise ValueError("actions must have non-empty shape [horizon, 2]")
values = values.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM)
coefficients = self.dct_actions(values) / math.sqrt(values.shape[0])
coefficients = torch.where(
coefficients.abs() < 1e-6, torch.zeros_like(coefficients), coefficients
)
unit = (
coefficients.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM)
- self.ACTION_MINIMUM
) / (self.ACTION_MAXIMUM - self.ACTION_MINIMUM)
payload = torch.round(unit * (self.ACTION_BINS - 1)).long().reshape(-1)
payload = payload + self.ACTION_TOKEN_START
boundaries = torch.tensor(
(self.ACTION_START, self.ACTION_END), dtype=torch.long, device=payload.device
)
return torch.cat((boundaries[:1], payload, boundaries[1:]))
def decode_actions(self, tokens: Tensor | Iterable[int]) -> Tensor:
encoded = torch.as_tensor(tokens, dtype=torch.long)
if encoded.ndim != 1:
raise ValueError("action tokens must be a one-dimensional sequence")
if encoded.numel() < 4 or encoded[0] != self.ACTION_START or encoded[-1] != self.ACTION_END:
raise ValueError("action tokens must be enclosed by ACTION_START and ACTION_END")
payload = encoded[1:-1]
if payload.numel() % 2:
raise ValueError("action token payload must contain complete XY pairs")
if payload.min() < self.ACTION_TOKEN_START or payload.max() > self.ACTION_TOKEN_END:
raise ValueError("action token payload is outside the FAST-lite interval")
quantized = payload - self.ACTION_TOKEN_START
coefficients = (
quantized.float()
/ (self.ACTION_BINS - 1)
* (self.ACTION_MAXIMUM - self.ACTION_MINIMUM)
+ self.ACTION_MINIMUM
)
horizon = payload.numel() // 2
return self.idct_actions(coefficients.reshape(horizon, 2) * math.sqrt(horizon))
def build_policy_sequence(
self,
task: str,
subtask: str | None,
actions: Tensor,
metadata: EpisodeMetadata,
*,
include_episode_metadata: bool = True,
include_subtask: bool = True,
) -> PolicySequence:
prompt, metadata_indices = self.encode_prompt(
task,
subtask,
metadata,
include_episode_metadata=include_episode_metadata,
include_subtask=include_subtask,
)
action_tokens = self.encode_actions(actions).cpu()
input_ids = torch.cat((prompt, action_tokens))
action_start_index = prompt.numel()
labels = torch.full_like(input_ids, -100)
labels[action_start_index - 1 : -1] = input_ids[action_start_index:]
action_prefix_mask = torch.zeros_like(input_ids, dtype=torch.bool)
action_prefix_mask[:action_start_index] = True
return PolicySequence(
input_ids=input_ids,
labels=labels,
action_prefix_mask=action_prefix_mask,
generated_token_start=0,
action_start_index=action_start_index,
prompt_length=prompt.numel(),
metadata_indices=metadata_indices,
)
def build_metadata_cfg_pair(
self,
task: str,
subtask: str | None,
actions: Tensor,
metadata: EpisodeMetadata,
) -> tuple[PolicySequence, PolicySequence]:
"""Return conditioned and episode-metadata-dropped policy sequences."""
conditioned = self.build_policy_sequence(task, subtask, actions, metadata)
unconditional = self.build_policy_sequence(
task,
subtask,
actions,
metadata,
include_episode_metadata=False,
)
return conditioned, unconditional
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import argparse,copy,json
from pathlib import Path
import torch
from .data import Pi07SteerableDataset,materialize_subset
from .flow import FlowMatching
from .model import Pi07Config,Pi07Mini,HighLevelPolicy,TinyGoalGenerator
from .runtime import load_config,seed_everything,choose_device,write_json
from .tokenizer import MiniTokenizer,EpisodeMetadata
def build_config(cfg,tok):
m=cfg['model'];return Pi07Config(vocab_size=tok.vocab_size,action_dim=2,state_dim=2,image_size=cfg['data']['image_size'],patch_size=m['patch_size'],d_model=m['d_model'],n_heads=m['n_heads'],n_layers=m['n_layers'],mlp_ratio=m['mlp_ratio'],max_history_frames=cfg['data']['history_frames'],max_text_tokens=m['max_text_tokens'],max_action_tokens=cfg['data']['action_horizon'],action_start_token_id=tok.ACTION_START)
def train(config_path):
cfg=load_config(config_path);root=Path(__file__).resolve().parents[2];seed_everything(cfg['seed']);dev=choose_device(cfg['device'])
manifest=materialize_subset(cfg['data']['source_manifest'],root/'data/overfit',max_samples=cfg['data']['max_samples'])
ds=Pi07SteerableDataset(manifest,image_size=cfg['data']['image_size'],history_frames=cfg['data']['history_frames'],action_horizon=cfg['data']['action_horizon'],augmentation_factor=cfg['data']['augmentation_factor'])
tok=MiniTokenizer();pcfg=build_config(cfg,tok);policy=Pi07Mini(pcfg).to(dev);high=HighLevelPolicy(pcfg).to(dev);goal=TinyGoalGenerator(tok.vocab_size).to(dev);flow=FlowMatching(cfg['flow']['integration_steps']);out=root/cfg['output_dir'];out.mkdir(parents=True,exist_ok=True);history=[]
# Goal generator
opt=torch.optim.AdamW(goal.parameters(),lr=cfg['train']['learning_rate'])
for step in range(1,cfg['train']['goal_steps']+1):
s=ds[(step-1)%len(ds)];meta=EpisodeMetadata(**s['token_metadata']);seq=tok.build_policy_sequence('drive safely',s['reasoning'],s['actions'],meta);ids=seq.input_ids[:seq.action_start_index][None].to(dev);target=s['subgoal_pixel_values'][None].to(dev);t=torch.rand(1,device=dev);noise=torch.randn_like(target);noisy=t[:,None,None,None]*target+(1-t[:,None,None,None])*noise;o=goal(s['image'][None].to(dev),ids,noisy,t,target_velocity=target-noise);opt.zero_grad();o.loss.backward();opt.step()
if step==1 or step%cfg['train']['log_every']==0:row={'stage':'goal','step':step,'loss':float(o.loss.detach().cpu())};history.append(row);print(json.dumps(row))
# High-level semantic policy
opt=torch.optim.AdamW(high.parameters(),lr=cfg['train']['learning_rate'])
for step in range(1,cfg['train']['high_level_steps']+1):
s=ds[(step-1)%len(ds)];meta=EpisodeMetadata(**s['token_metadata']);prompt,_=tok.encode_prompt('drive safely',None,meta,include_subtask=False);target=torch.tensor([tok.subtasks.encode(tok._classify_subtask(s['reasoning']))],device=dev);o=high(s['image'][None].to(dev),prompt[None].to(dev),s['history_images'][None].to(dev),s['history_states'][None].to(dev),s['current_state'][None].to(dev),target_token=target);opt.zero_grad();o.loss.backward();opt.step()
if step==1 or step%cfg['train']['log_every']==0:row={'stage':'high_level','step':step,'loss':float(o.loss.detach().cpu())};history.append(row);print(json.dumps(row))
# Full steerable policy, alternating prompt-dropout ablations and RTC targets.
opt=torch.optim.AdamW(policy.parameters(),lr=cfg['train']['learning_rate'])
for step in range(1,cfg['train']['policy_steps']+1):
s=ds[(step-1)%len(ds)];meta=EpisodeMetadata(**s['token_metadata']);include_meta=(step%4)!=0;seq=tok.build_policy_sequence('drive safely',s['reasoning'],s['actions'],meta,include_episode_metadata=include_meta);ids=seq.input_ids[None].to(dev);labels=seq.labels[None].to(dev);target=s['rtc_target_actions'][None].to(dev);noisy,t,vel=flow.training_batch(target);use_goal=(step%4)!=1;use_history=(step%5)!=1
o=policy(s['image'][None].to(dev),ids,noisy,t,history_pixel_values=s['history_images'][None].to(dev) if use_history else None,history_states=s['history_states'][None].to(dev) if use_history else None,current_state=s['current_state'][None].to(dev),subgoal_pixel_values=s['subgoal_pixel_values'][None].to(dev) if use_goal else None,action_prefix_mask=seq.action_prefix_mask[None].to(dev),text_labels=labels)
valid=s['rtc_valid_mask'][None].to(dev)[:,:,None];flow_loss=((o.vector_field-vel).square()*valid).sum()/valid.sum().clamp_min(1);loss=o.text_loss+cfg['train']['flow_loss_weight']*flow_loss;opt.zero_grad();loss.backward();torch.nn.utils.clip_grad_norm_(policy.parameters(),cfg['train']['grad_clip_norm']);opt.step()
if step==1 or step%cfg['train']['log_every']==0:row={'stage':'policy','step':step,'loss':float(loss.detach().cpu()),'text_loss':float(o.text_loss.detach().cpu()),'flow_loss':float(flow_loss.detach().cpu())};history.append(row);print(json.dumps(row))
ck={'policy':policy.state_dict(),'high_level':high.state_dict(),'goal':goal.state_dict(),'policy_config':pcfg.__dict__,'config':cfg,'action_statistics':ds.action_normalizer.as_dict()};torch.save(ck,out/'checkpoint.pt');write_json(out/'training_history.json',history);write_json(out/'summary.json',{'device':str(dev),'samples':len(ds),'policy_parameters':sum(p.numel() for p in policy.parameters()),'goal_parameters':sum(p.numel() for p in goal.parameters()),'high_level_parameters':sum(p.numel() for p in high.parameters())});return out/'checkpoint.pt'
def main():
p=argparse.ArgumentParser();p.add_argument('--config',default='configs/overfit.yaml');train(p.parse_args().config)
if __name__=='__main__':main()
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import torch
from pi07mini.context import (
PolicyMetadata,
build_ambiguous_history_pair,
drop_prompt_components,
endpoint_subgoal_heatmap,
format_policy_prompt,
make_history_proxy_frames,
prompt_components,
)
def _history() -> torch.Tensor:
x = torch.linspace(-8.0, 0.0, 8)
return torch.stack((x, 0.04 * x.square()), -1)
def test_subgoal_heatmap_depends_on_endpoint_only() -> None:
endpoint = torch.tensor([20.0, 4.0])
first = endpoint_subgoal_heatmap(endpoint, size=24)
# Two future paths with radically different interiors produce exactly the
# same condition because only their shared endpoint is passed to the API.
path_a = torch.stack((torch.linspace(0.0, 20.0, 16), torch.linspace(0.0, 4.0, 16)), -1)
path_b = path_a.clone()
path_b[1:-1, 1] += 100.0
second = endpoint_subgoal_heatmap(path_b[-1], size=24)
torch.testing.assert_close(first, second, atol=0.0, rtol=0.0)
assert first.shape == (1, 24, 24)
assert float(first.max()) == 1.0
assert torch.isfinite(first).all()
def test_history_proxy_is_oldest_to_newest_and_future_free() -> None:
image = torch.linspace(0.0, 1.0, 3 * 20 * 20).reshape(3, 20, 20)
frames = make_history_proxy_frames(image, _history(), frames=6)
assert frames.shape == (6, 3, 20, 20)
torch.testing.assert_close(frames[-1], image * 0.98)
assert not torch.equal(frames[0], frames[-1])
def test_ambiguous_histories_are_distinct_but_share_current_state() -> None:
pair = build_ambiguous_history_pair(_history(), frames=6)
assert pair.shape == (2, 6, 2)
torch.testing.assert_close(pair[0, -1], pair[1, -1])
assert not torch.allclose(pair[0, :-1], pair[1, :-1])
def test_prompt_component_dropout_is_independent_and_deterministic() -> None:
metadata = PolicyMetadata("fast", "good", "none", "autonomous")
components = prompt_components(metadata, task="Drive safely.")
generator_a = torch.Generator().manual_seed(42)
generator_b = torch.Generator().manual_seed(42)
visible_a, mask_a = drop_prompt_components(
components, drop_probability=0.5, generator=generator_a
)
visible_b, mask_b = drop_prompt_components(
components, drop_probability=0.5, generator=generator_b
)
assert visible_a == visible_b
torch.testing.assert_close(mask_a, mask_b)
assert visible_a["task"] == "Drive safely."
assert mask_a.shape == (5,)
text = format_policy_prompt(visible_a)
assert "Drive safely." in text
for dropped in set(components) - set(visible_a):
assert f"{dropped}=" not in text
def test_full_metadata_dropout_keeps_only_task() -> None:
components = prompt_components(PolicyMetadata("slow", "poor", "stop", "autonomous"))
visible, mask = drop_prompt_components(components, drop_probability=1.0)
assert set(visible) == {"task"}
assert mask.tolist() == [True, False, False, False, False]
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import torch
from PIL import Image
from pi07mini.data import (
Pi07SteerableDataset,
build_policy_trajectories,
load_manifest,
materialize_subset,
)
def _make_source_project(root: Path) -> Path:
processed = root / "data" / "processed"
(processed / "images").mkdir(parents=True)
(processed / "trajectories").mkdir(parents=True)
pixels = np.zeros((40, 64, 3), dtype=np.uint8)
pixels[..., 0] = 80
pixels[..., 1] = np.arange(64, dtype=np.uint8)[None, :]
Image.fromarray(pixels).save(processed / "images" / "sample.jpg")
hx = np.linspace(-7.0, 0.0, 8, dtype=np.float32)
history = np.stack((hx, 0.01 * hx, np.zeros(8)), axis=-1)
fx = np.linspace(1.0, 24.0, 16, dtype=np.float32)
future = np.stack((fx, 0.015 * fx**2, np.zeros(16)), axis=-1)
np.savez_compressed(
processed / "trajectories" / "sample.npz",
history_xyz=history,
future_xyz=future,
)
manifest = processed / "overfit_manifest.jsonl"
manifest.write_text(
json.dumps(
{
"clip_id": "clip-a",
"t0_us": 123,
"reasoning": "Steer left.",
"image_path": "data/processed/images/sample.jpg",
"trajectory_path": "data/processed/trajectories/sample.npz",
}
)
+ "\n",
encoding="utf-8",
)
return manifest
def test_policy_variants_and_metadata() -> None:
x = torch.linspace(-7.0, 0.0, 8)
history = torch.stack((x, torch.zeros_like(x)), -1)
fx = torch.linspace(1.0, 24.0, 16)
future = torch.stack((fx, 0.02 * fx.square()), -1)
policies = build_policy_trajectories(history, future)
assert [policy.kind for policy in policies] == [
"expert",
"fast",
"slow",
"understeer",
"stop",
]
metadata = {policy.kind: policy.metadata.as_dict() for policy in policies}
assert metadata["expert"] == {
"speed": "normal",
"quality": "expert",
"mistake": "none",
"control": "human",
}
assert metadata["understeer"]["mistake"] == "understeer"
assert metadata["stop"]["quality"] == "poor"
assert policies[0].metadata.as_token_metadata() == {
"speed_steps": 1_000,
"quality": 5,
"mistake": False,
"control_mode": "ee",
"source_type": "demonstration",
}
assert torch.allclose(policies[-1].future_xy[-1], policies[-1].future_xy[4])
def test_dataset_contract_and_roundtrip(tmp_path: Path) -> None:
source = _make_source_project(tmp_path / "source")
manifest = materialize_subset(source, tmp_path / "target")
assert load_manifest(manifest)[0].image_path.is_file()
dataset = Pi07SteerableDataset(
manifest,
image_size=32,
prompt_dropout=0.0,
max_rtc_delay_steps=12,
)
assert len(dataset) == 5
assert {policy.kind for policy in dataset.policies} == {
"expert",
"fast",
"slow",
"understeer",
"stop",
}
item = dataset[0]
assert item["pixel_values"].shape == (3, 32, 32)
assert item["history_pixel_values"].shape == (6, 3, 32, 32)
assert item["history_states"].shape == (6, 2)
assert item["current_state"].shape == (2,)
assert item["actions"].shape == (16, 2)
assert item["subgoal_heatmap"].shape == (1, 32, 32)
assert item["subgoal_pixel_values"].shape == (3, 32, 32)
assert item["ambiguous_history_pair"].shape == (2, 6, 2)
assert 0 <= item["rtc_delay_steps"] <= 12
assert item["rtc_target_actions"].shape == (16, 2)
assert item["rtc_valid_mask"].shape == (16,)
assert item["reasoning"] == "Steer left."
assert item["prompt_component_mask"].all()
assert item["token_metadata"]["quality"] == 5
assert item["control_mode"] == "ee"
decoded = dataset.action_normalizer.decode(item["actions"], item["history_xy"])
torch.testing.assert_close(decoded, item["future_xy"], atol=1e-5, rtol=1e-5)
def test_dataset_is_deterministic_per_index(tmp_path: Path) -> None:
source = _make_source_project(tmp_path / "source")
dataset = Pi07SteerableDataset(
source,
image_size=24,
prompt_dropout=0.5,
seed=11,
)
first = dataset[3]
second = dataset[3]
assert first["prompt_components"] == second["prompt_components"]
assert first["rtc_delay_steps"] == second["rtc_delay_steps"]
torch.testing.assert_close(first["rtc_target_actions"], second["rtc_target_actions"])
+63
View File
@@ -0,0 +1,63 @@
import torch
from pi07mini.memory import MEMConfig, MEMVideoEncoder
def tiny_encoder() -> MEMVideoEncoder:
return MEMVideoEncoder(
MEMConfig(
image_size=16,
patch_size=8,
d_model=16,
n_heads=4,
n_layers=2,
max_history_frames=6,
)
).eval()
def test_mem_output_token_count_is_independent_of_history_length() -> None:
encoder = tiny_encoder()
current = torch.randn(2, 3, 16, 16)
with torch.no_grad():
no_history = encoder(current)
long_history = encoder(current, torch.randn(2, 6, 3, 16, 16))
assert no_history.shape == (2, 4, 16)
assert long_history.shape == no_history.shape
def test_mem_is_sensitive_to_temporal_order() -> None:
torch.manual_seed(7)
encoder = tiny_encoder()
current = torch.randn(1, 3, 16, 16)
history = torch.stack(
(
torch.zeros(3, 16, 16),
torch.ones(3, 16, 16),
torch.full((3, 16, 16), 2.0),
)
)[None]
with torch.no_grad():
chronological = encoder(current, history)
reversed_history = encoder(current, history.flip(1))
assert not torch.allclose(chronological, reversed_history)
def test_masked_history_frame_cannot_change_memory() -> None:
torch.manual_seed(9)
encoder = tiny_encoder()
current = torch.randn(1, 3, 16, 16)
history = torch.randn(1, 2, 3, 16, 16)
changed = history.clone()
changed[:, 0] = 1000.0
mask = torch.tensor([[False, True]])
with torch.no_grad():
first = encoder(current, history, mask)
second = encoder(current, changed, mask)
torch.testing.assert_close(first, second)
def test_current_frame_temporal_position_is_exactly_zero() -> None:
encoding = MEMVideoEncoder.temporal_position(torch.tensor([3, 2, 1, 0]), 15)
assert torch.count_nonzero(encoding[-1]) == 0
+180
View File
@@ -0,0 +1,180 @@
import torch
from pi07mini.model import (
HighLevelPolicy,
Pi07Config,
Pi07Mini,
PrefixLayout,
TinyGoalGenerator,
)
from pi07mini.tokenizer import EpisodeMetadata, MiniTokenizer
def tiny_model() -> Pi07Mini:
tokenizer = MiniTokenizer()
return Pi07Mini(
Pi07Config(
vocab_size=tokenizer.vocab_size,
image_size=16,
patch_size=16,
d_model=32,
n_heads=4,
n_layers=1,
memory_layers=1,
max_history_frames=3,
max_text_tokens=64,
max_action_tokens=4,
action_start_token_id=tokenizer.ACTION_START,
)
)
def sample_sequence():
tokenizer = MiniTokenizer()
metadata = EpisodeMetadata(2000, 5, False, "ee", "demonstration")
return tokenizer, tokenizer.build_policy_sequence(
"drive", "steer_left", torch.zeros(2, 2), metadata
)
def common_inputs(sequence):
return dict(
pixel_values=torch.randn(1, 3, 16, 16),
history_pixel_values=torch.randn(1, 2, 3, 16, 16),
history_states=torch.randn(1, 2, 2),
current_state=torch.randn(1, 2),
subgoal_pixel_values=torch.randn(1, 3, 16, 16),
input_ids=sequence.input_ids[None],
noisy_actions=torch.randn(1, 2, 2),
timesteps=torch.rand(1),
action_prefix_mask=sequence.action_prefix_mask[None],
)
def test_block_causal_attention_matches_pi07_modal_order() -> None:
layout = PrefixLayout(observation_tokens=2, goal_tokens=2, text_tokens=3)
valid = torch.ones(1, layout.total_tokens, dtype=torch.bool)
edges = Pi07Mini.build_prefix_attention_mask(layout, valid)[0]
assert edges[0, :2].all()
assert not edges[0, 2:].any() # observation cannot read goal or text
assert edges[2, :4].all()
assert not edges[2, 4:].any() # goal reads observation + goal, not text
assert edges[4, :5].all()
assert not edges[4, 5:].any() # first text token is causal
assert edges[6].all()
def test_prefix_has_fixed_mem_tokens_and_one_token_per_state() -> None:
_, sequence = sample_sequence()
model = tiny_model()
inputs = common_inputs(sequence)
prefix, _, layout = model.encode_prefix(
inputs["pixel_values"],
inputs["history_pixel_values"],
inputs["history_states"],
inputs["current_state"],
inputs["input_ids"],
inputs["subgoal_pixel_values"],
)
assert layout.observation_tokens == 1 + 2 + 1 # MEM + history states + current
assert layout.goal_tokens == 1
assert prefix.shape[1] == layout.total_tokens
def test_flow_cannot_read_teacher_forced_fast_tokens() -> None:
torch.manual_seed(12)
tokenizer, sequence = sample_sequence()
model = tiny_model().eval()
changed_ids = sequence.input_ids.clone()
changed_ids[sequence.action_start_index + 1 : -1] = tokenizer.ACTION_TOKEN_START
inputs = common_inputs(sequence)
with torch.no_grad():
first = model(**inputs).vector_field
inputs["input_ids"] = changed_ids[None]
second = model(**inputs).vector_field
torch.testing.assert_close(first, second)
def test_flow_loss_is_knowledge_insulated_from_all_vlm_parameters() -> None:
torch.manual_seed(13)
_, sequence = sample_sequence()
model = tiny_model()
output = model(
**common_inputs(sequence), target_velocity=torch.randn(1, 2, 2)
)
assert output.flow_loss is not None
output.flow_loss.backward()
assert all(
parameter.grad is None or torch.count_nonzero(parameter.grad) == 0
for parameter in model.vlm_parameters()
)
assert any(
parameter.grad is not None and torch.count_nonzero(parameter.grad) > 0
for parameter in model.action_parameters()
)
def test_fast_cross_entropy_still_trains_vlm() -> None:
_, sequence = sample_sequence()
model = tiny_model()
output = model(
**common_inputs(sequence), text_labels=sequence.labels[None]
)
assert output.text_loss is not None
output.text_loss.backward()
assert model.token_embedding.weight.grad is not None
assert torch.count_nonzero(model.token_embedding.weight.grad) > 0
def test_visual_subgoal_conditions_flow_expert() -> None:
torch.manual_seed(14)
_, sequence = sample_sequence()
model = tiny_model().eval()
inputs = common_inputs(sequence)
with torch.no_grad():
first = model(**inputs).vector_field
inputs["subgoal_pixel_values"] = inputs["subgoal_pixel_values"] + 3.0
second = model(**inputs).vector_field
assert not torch.allclose(first, second)
def test_metadata_cfg_uses_paper_extrapolation_formula() -> None:
conditioned = torch.tensor([1.0, 2.0])
unconditional = torch.tensor([0.5, 1.5])
torch.testing.assert_close(
Pi07Mini.combine_metadata_cfg(conditioned, unconditional, 0.0), conditioned
)
torch.testing.assert_close(
Pi07Mini.combine_metadata_cfg(conditioned, unconditional, 1.0),
torch.tensor([1.5, 2.5]),
)
def test_high_level_policy_predicts_next_subtask_token() -> None:
model = HighLevelPolicy(tiny_model().config)
output = model(
pixel_values=torch.randn(2, 3, 16, 16),
history_pixel_values=torch.randn(2, 2, 3, 16, 16),
history_states=torch.randn(2, 2, 2),
current_state=torch.randn(2, 2),
input_ids=torch.tensor([[256, 257, 264], [256, 257, 264]]),
target_token=torch.tensor([271, 272]),
)
assert output.logits.shape == (2, tiny_model().config.vocab_size)
assert output.loss is not None
def test_tiny_goal_generator_predicts_image_flow() -> None:
tokenizer = MiniTokenizer()
generator = TinyGoalGenerator(tokenizer.vocab_size)
current = torch.randn(2, 3, 16, 16)
noisy = torch.randn_like(current)
output = generator(
current,
torch.tensor([[256, 257, 264], [256, 257, 264]]),
noisy,
torch.rand(2),
target_velocity=torch.randn_like(current),
)
assert output.vector_field.shape == current.shape
assert output.loss is not None
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import pytest
import torch
from pi07mini.rtc import align_actions_for_rtc, sample_rtc_delay
def _actions() -> torch.Tensor:
return torch.arange(32, dtype=torch.float32).reshape(16, 2)
def test_zero_delay_preserves_entire_chunk() -> None:
actions = _actions()
aligned = align_actions_for_rtc(actions, 0)
torch.testing.assert_close(aligned.delayed_target_actions, actions)
assert aligned.valid_mask.all()
assert aligned.executed_prefix_actions.shape == (0, 2)
def test_delay_shifts_target_and_masks_padded_tail() -> None:
actions = _actions()
aligned = align_actions_for_rtc(actions, 12)
torch.testing.assert_close(aligned.delayed_target_actions[:4], actions[12:])
assert torch.count_nonzero(aligned.delayed_target_actions[4:]) == 0
assert aligned.valid_mask.tolist() == [True] * 4 + [False] * 12
torch.testing.assert_close(aligned.executed_prefix_actions, actions[:12])
def test_rtc_alignment_preserves_batch_dimensions() -> None:
actions = torch.stack((_actions(), _actions() + 100.0))
aligned = align_actions_for_rtc(actions, 3)
assert aligned.delayed_target_actions.shape == (2, 16, 2)
assert aligned.valid_mask.shape == (2, 16)
torch.testing.assert_close(aligned.delayed_target_actions[:, :13], actions[:, 3:])
def test_delay_sampling_covers_closed_zero_to_twelve_range() -> None:
generator = torch.Generator().manual_seed(9)
values = {
sample_rtc_delay(generator=generator, action_horizon=16) for _ in range(300)
}
assert min(values) == 0
assert max(values) == 12
@pytest.mark.parametrize("delay", [-1, 13, 16])
def test_invalid_delay_is_rejected(delay: int) -> None:
with pytest.raises(ValueError):
align_actions_for_rtc(_actions(), delay)
+65
View File
@@ -0,0 +1,65 @@
import torch
from pi07mini.tokenizer import EpisodeMetadata, MiniTokenizer
def metadata() -> EpisodeMetadata:
return EpisodeMetadata(
speed_steps=2100,
quality=5,
mistake=False,
control_mode="ee",
source_type="autonomous_rollout",
)
def test_prompt_contains_rich_metadata_and_control_tokens() -> None:
tokenizer = MiniTokenizer()
prompt, metadata_indices = tokenizer.encode_prompt(
"drive on the road", "steer_left", metadata()
)
assert tokenizer.SPEED_MARKER in prompt
assert tokenizer.QUALITY_MARKER in prompt
assert tokenizer.MISTAKE_MARKER in prompt
assert tokenizer.SOURCE_MARKER in prompt
assert tokenizer.CONTROL_MARKER in prompt
assert tokenizer.CONTROL_EE in prompt
assert len(metadata_indices) == 8
def test_metadata_cfg_pair_drops_metadata_but_retains_control_mode() -> None:
tokenizer = MiniTokenizer()
actions = torch.zeros(3, 2)
conditioned, unconditional = tokenizer.build_metadata_cfg_pair(
"drive", "keep_lane", actions, metadata()
)
assert tokenizer.QUALITY_MARKER in conditioned.input_ids
assert tokenizer.QUALITY_MARKER not in unconditional.input_ids
assert tokenizer.CONTROL_EE in conditioned.input_ids
assert tokenizer.CONTROL_EE in unconditional.input_ids
assert conditioned.metadata_indices
assert not unconditional.metadata_indices
def test_flow_prefix_ends_before_fast_stream() -> None:
tokenizer = MiniTokenizer()
sequence = tokenizer.build_policy_sequence(
"drive", "keep_lane", torch.zeros(4, 2), metadata()
)
assert sequence.input_ids[sequence.action_start_index] == tokenizer.ACTION_START
assert sequence.action_prefix_mask[: sequence.action_start_index].all()
assert not sequence.action_prefix_mask[sequence.action_start_index :].any()
assert sequence.labels[sequence.action_start_index - 1] == tokenizer.ACTION_START
def test_fast_lite_dct_round_trip_has_bounded_error() -> None:
tokenizer = MiniTokenizer()
actions = torch.tensor(
[[0.0, 0.0], [0.2, -0.1], [0.5, -0.2], [0.8, -0.1]],
dtype=torch.float32,
)
exact = tokenizer.idct_actions(tokenizer.dct_actions(actions))
torch.testing.assert_close(exact, actions, atol=1e-5, rtol=1e-5)
decoded = tokenizer.decode_actions(tokenizer.encode_actions(actions))
assert torch.sqrt(torch.mean((decoded - actions) ** 2)) < 0.3