Initial pi0.5 minimal validation
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
data/
|
||||||
|
outputs/**/*.pt
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# π0.5 Minimal Validation
|
||||||
|
|
||||||
|
这是一个 **π0.5 风格的最小、可运行架构验证项目**。它从相邻 Alpamayo 验证项目准备的 PAI 子集中读取 1 个驾驶样本,生成 4 个确定性视觉增强变体做过拟合,目标是检查高层语义预测、blockwise attention、action expert 与 flow matching action chunk 能否组成完整训练和推理链路。
|
||||||
|
|
||||||
|
它不是 Physical Intelligence 官方代码,也不声称复现论文中的规模、数据或机器人能力。
|
||||||
|
|
||||||
|
## 验证内容
|
||||||
|
|
||||||
|
```text
|
||||||
|
图像 + 本体状态
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
小型视觉/语言主干 ──→ 高层语义 subtask
|
||||||
|
│ │
|
||||||
|
└──── semantic prefix┘
|
||||||
|
│
|
||||||
|
noisy action + time │
|
||||||
|
└──────────▼
|
||||||
|
action expert(双向 action attention)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
flow velocity → action chunk
|
||||||
|
```
|
||||||
|
|
||||||
|
默认任务使用 `64×64` 前视相机图像、离散化历史 XY 状态和一句 subtask,预测未来 `16×2` 的二维轨迹。样本少且重复训练,因此成功标准是“能够记忆训练集”,而不是泛化。
|
||||||
|
|
||||||
|
## 与 π0.5 的对应关系
|
||||||
|
|
||||||
|
保留的关键思想:
|
||||||
|
|
||||||
|
- 视觉/语言语义主干与独立 action expert;
|
||||||
|
- observation/text 与 action token 采用 blockwise attention 信息流;
|
||||||
|
- action token 内部采用双向 attention,一次预测整个 action chunk;
|
||||||
|
- flow matching 训练连续动作,推理时从高斯噪声做 Euler 积分;
|
||||||
|
- timestep 通过 adaptive RMSNorm 调制 action expert;
|
||||||
|
- 同时优化高层语义目标和低层连续动作目标。
|
||||||
|
- VLM 自回归输出高层 subtask 以及 64-bin 离散动作 token;
|
||||||
|
- 推理时分别还原 VLM token 轨迹和 flow/diffusion 轨迹,与真值三方比较。
|
||||||
|
|
||||||
|
为了单机快速验证而简化的部分:
|
||||||
|
|
||||||
|
- 小型自定义视觉编码器和 Transformer 取代 PaliGemma 2B;
|
||||||
|
- 隐藏宽度、层数、图像尺寸和 action horizon 大幅缩小;
|
||||||
|
- 使用固定小词表的 subtask 分类/生成,而非开放式语言理解;
|
||||||
|
- 使用二维驾驶轨迹,取代多机器人、多相机和高维关节控制;
|
||||||
|
- 默认使用 1 个 PAI 驾驶样本的 4 个视觉增强版本,没有机器人或网页联合训练数据;
|
||||||
|
- 使用正交 DCT-II、低频优先排列和 64-bin 量化实现 FAST-lite;保留全部频率系数,因此只验证表示与训练接口,不复现官方压缩率;
|
||||||
|
- Euler sampler 用固定步长,不包含面向部署的实时控制与安全系统。
|
||||||
|
|
||||||
|
因此,本项目适合验证架构接口和数学链路,不适合比较论文指标。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
建议使用 Python 3.10 或更新版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi05-minimal-validation
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install -U pip
|
||||||
|
python -m pip install -e '.[dev]'
|
||||||
|
```
|
||||||
|
|
||||||
|
默认实现只依赖 PyTorch、NumPy、Pillow、Matplotlib 和 PyYAML,不需要下载预训练权重。默认配置会读取:
|
||||||
|
|
||||||
|
```text
|
||||||
|
../../alpamayo/alpamayo-qwen05b-validation/data/processed/overfit_manifest.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
如果该路径不同,请先修改 `configs/overfit.yaml` 的 `data.source_manifest`。
|
||||||
|
|
||||||
|
## 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi05-minimal-validation
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/test_flow.py` 单独验证:
|
||||||
|
|
||||||
|
- 直线路径 `x_t=(1-t)·noise+t·action`;
|
||||||
|
- 目标速度 `v=action-noise`;
|
||||||
|
- velocity MSE;
|
||||||
|
- 固定目标的精确 vector field 经 Euler 积分后能到达目标;
|
||||||
|
- 使用相同 `torch.Generator` 时采样可复现。
|
||||||
|
|
||||||
|
## 过拟合训练
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi05-minimal-validation
|
||||||
|
pi05mini-train --config configs/overfit.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以直接使用模块入口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pi05mini.train --config configs/overfit.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
配置默认先进行 300 步离散 VLM/FAST-lite 预训练,再进行 500 步联合 post-training。Stage 1 不更新 flow action expert;Stage 2 联合优化 token CE 与 flow loss。CPU 可以运行;Apple Silicon 可自动选择 MPS,CUDA 可自动选择 GPU。
|
||||||
|
|
||||||
|
## 推理与效果图
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi05mini-infer --checkpoint outputs/overfit/checkpoint.pt
|
||||||
|
```
|
||||||
|
|
||||||
|
输出图 `outputs/overfit/trajectory_comparison.png` 会叠加 ground truth、VLM 离散动作 token 解码轨迹和 diffusion/flow 轨迹;逐样本指标写入 `evaluation.json`。训练过程另存为 `training_history.json`,训练摘要为 `summary.json`。
|
||||||
|
|
||||||
|
## 配置重点
|
||||||
|
|
||||||
|
`configs/overfit.yaml` 中最常调整的参数:
|
||||||
|
|
||||||
|
- `data.num_variants`:默认 4,刻意保持很小;
|
||||||
|
- `model.d_model`、`n_layers`:控制最小模型容量;
|
||||||
|
- `train.steps`:过拟合不充分时增加;
|
||||||
|
- `train.semantic_loss_weight` 与 `flow_loss_weight`:两个训练目标的权重;
|
||||||
|
- `inference.flow_steps`:Euler 积分步数,越大通常越慢且更平滑。
|
||||||
|
|
||||||
|
## 安全限制
|
||||||
|
|
||||||
|
- 该模型只在合成样本上验证记忆能力,输出不具备现实世界有效性。
|
||||||
|
- 不得用于车辆、机器人、机械臂或任何会造成物理影响的设备控制。
|
||||||
|
- 不得把 loss 下降或单样本轨迹重合解释为安全性、鲁棒性或泛化能力。
|
||||||
|
- 真实部署至少还需要独立验证集、分布外和扰动测试、控制约束、碰撞检查、故障降级、急停以及人工监督。
|
||||||
|
- checkpoint 和配置可能加载本地路径;只运行可信来源的文件,不加载未知 PyTorch pickle checkpoint。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
pi05-minimal-validation/
|
||||||
|
├── configs/overfit.yaml # 4 样本过拟合配置
|
||||||
|
├── src/pi05mini/ # 模型、flow、数据、训练与推理
|
||||||
|
├── tests/test_flow.py # flow matching 数学测试
|
||||||
|
├── pyproject.toml
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 预期结论
|
||||||
|
|
||||||
|
若最小实验成功,它只能说明:π0.5 风格的语义主干、blockwise 双专家交互和 flow action head 在此实现中能够端到端反向传播并记忆几个样本。它不能证明实现与官方模型数值等价,也不能证明在真实驾驶或机器人任务中有效。
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
seed: 7
|
||||||
|
device: auto
|
||||||
|
output_dir: outputs/overfit
|
||||||
|
|
||||||
|
data:
|
||||||
|
source_manifest: ../../alpamayo/alpamayo-qwen05b-validation/data/processed/overfit_manifest.jsonl
|
||||||
|
num_variants: 4
|
||||||
|
image_size: 64
|
||||||
|
action_horizon: 16
|
||||||
|
|
||||||
|
model:
|
||||||
|
d_model: 96
|
||||||
|
n_heads: 4
|
||||||
|
n_layers: 3
|
||||||
|
mlp_ratio: 3
|
||||||
|
patch_size: 16
|
||||||
|
max_text_tokens: 64
|
||||||
|
dropout: 0.0
|
||||||
|
|
||||||
|
train:
|
||||||
|
pretrain_steps: 300
|
||||||
|
posttrain_steps: 500
|
||||||
|
batch_size: 4
|
||||||
|
learning_rate: 0.001
|
||||||
|
weight_decay: 0.0
|
||||||
|
grad_clip_norm: 1.0
|
||||||
|
semantic_loss_weight: 1.0
|
||||||
|
flow_loss_weight: 2.0
|
||||||
|
log_every: 20
|
||||||
|
|
||||||
|
inference:
|
||||||
|
flow_steps: 10
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"sample_id": "314f59c2-e420-4fcd-9df2-a51d572e0c20_1503899:aug0",
|
||||||
|
"target_subtask": "steer_left",
|
||||||
|
"predicted_subtask": "steer_left",
|
||||||
|
"vlm_action_tokens_valid": true,
|
||||||
|
"vlm_trajectory_mse": 0.07854792475700378,
|
||||||
|
"diffusion_trajectory_mse": 0.27604275941848755
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sample_id": "314f59c2-e420-4fcd-9df2-a51d572e0c20_1503899:aug1",
|
||||||
|
"target_subtask": "steer_left",
|
||||||
|
"predicted_subtask": "steer_left",
|
||||||
|
"vlm_action_tokens_valid": true,
|
||||||
|
"vlm_trajectory_mse": 0.025470610707998276,
|
||||||
|
"diffusion_trajectory_mse": 0.20049458742141724
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sample_id": "314f59c2-e420-4fcd-9df2-a51d572e0c20_1503899:aug2",
|
||||||
|
"target_subtask": "steer_left",
|
||||||
|
"predicted_subtask": "steer_left",
|
||||||
|
"vlm_action_tokens_valid": true,
|
||||||
|
"vlm_trajectory_mse": 0.013774838298559189,
|
||||||
|
"diffusion_trajectory_mse": 0.03129641339182854
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sample_id": "314f59c2-e420-4fcd-9df2-a51d572e0c20_1503899:aug3",
|
||||||
|
"target_subtask": "steer_left",
|
||||||
|
"predicted_subtask": "steer_left",
|
||||||
|
"vlm_action_tokens_valid": true,
|
||||||
|
"vlm_trajectory_mse": 0.059195056557655334,
|
||||||
|
"diffusion_trajectory_mse": 0.11321215331554413
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"device": "mps",
|
||||||
|
"samples": 4,
|
||||||
|
"parameters": 816482,
|
||||||
|
"initial": {
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 1,
|
||||||
|
"loss": 78.87449645996094,
|
||||||
|
"text_loss": 78.87449645996094,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
"final": {
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 500,
|
||||||
|
"loss": 0.12353827804327011,
|
||||||
|
"text_loss": 3.1691649837739533e-06,
|
||||||
|
"flow_loss": 0.06176755577325821
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 1,
|
||||||
|
"loss": 78.87449645996094,
|
||||||
|
"text_loss": 78.87449645996094,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 20,
|
||||||
|
"loss": 2.475086212158203,
|
||||||
|
"text_loss": 2.475086212158203,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 40,
|
||||||
|
"loss": 1.1025534868240356,
|
||||||
|
"text_loss": 1.1025534868240356,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 60,
|
||||||
|
"loss": 0.601564884185791,
|
||||||
|
"text_loss": 0.601564884185791,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 80,
|
||||||
|
"loss": 0.25571808218955994,
|
||||||
|
"text_loss": 0.25571808218955994,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 100,
|
||||||
|
"loss": 0.03314117714762688,
|
||||||
|
"text_loss": 0.03314117714762688,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 120,
|
||||||
|
"loss": 0.005310459993779659,
|
||||||
|
"text_loss": 0.005310459993779659,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 140,
|
||||||
|
"loss": 3.0251239877543412e-05,
|
||||||
|
"text_loss": 3.0251239877543412e-05,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 160,
|
||||||
|
"loss": 1.1354763955750968e-05,
|
||||||
|
"text_loss": 1.1354763955750968e-05,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 180,
|
||||||
|
"loss": 6.638075319642667e-06,
|
||||||
|
"text_loss": 6.638075319642667e-06,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 200,
|
||||||
|
"loss": 5.348983449948719e-06,
|
||||||
|
"text_loss": 5.348983449948719e-06,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 220,
|
||||||
|
"loss": 4.736786195280729e-06,
|
||||||
|
"text_loss": 4.736786195280729e-06,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 240,
|
||||||
|
"loss": 4.316162630857434e-06,
|
||||||
|
"text_loss": 4.316162630857434e-06,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 260,
|
||||||
|
"loss": 3.975574600190157e-06,
|
||||||
|
"text_loss": 3.975574600190157e-06,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 280,
|
||||||
|
"loss": 3.6877775073662633e-06,
|
||||||
|
"text_loss": 3.6877775073662633e-06,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": 300,
|
||||||
|
"loss": 3.4408508327032905e-06,
|
||||||
|
"text_loss": 3.4408508327032905e-06,
|
||||||
|
"flow_loss": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 1,
|
||||||
|
"loss": 8.707193374633789,
|
||||||
|
"text_loss": 3.428078571232618e-06,
|
||||||
|
"flow_loss": 4.353594779968262
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 20,
|
||||||
|
"loss": 3.533726453781128,
|
||||||
|
"text_loss": 0.14151602983474731,
|
||||||
|
"flow_loss": 1.6961052417755127
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 40,
|
||||||
|
"loss": 3.0850143432617188,
|
||||||
|
"text_loss": 0.00014649856893811375,
|
||||||
|
"flow_loss": 1.5424339771270752
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 60,
|
||||||
|
"loss": 1.3556954860687256,
|
||||||
|
"text_loss": 0.00011281041224719957,
|
||||||
|
"flow_loss": 0.6777913570404053
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 80,
|
||||||
|
"loss": 1.4165724515914917,
|
||||||
|
"text_loss": 6.599419066333212e-06,
|
||||||
|
"flow_loss": 0.7082829475402832
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 100,
|
||||||
|
"loss": 0.8609042167663574,
|
||||||
|
"text_loss": 8.11900736152893e-06,
|
||||||
|
"flow_loss": 0.430448055267334
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 120,
|
||||||
|
"loss": 0.5642163753509521,
|
||||||
|
"text_loss": 7.576879397674929e-06,
|
||||||
|
"flow_loss": 0.28210440278053284
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 140,
|
||||||
|
"loss": 0.7110214233398438,
|
||||||
|
"text_loss": 6.249698344618082e-06,
|
||||||
|
"flow_loss": 0.35550758242607117
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 160,
|
||||||
|
"loss": 0.18725578486919403,
|
||||||
|
"text_loss": 2.876289045161684e-06,
|
||||||
|
"flow_loss": 0.09362645447254181
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 180,
|
||||||
|
"loss": 0.33923929929733276,
|
||||||
|
"text_loss": 6.748496616637567e-06,
|
||||||
|
"flow_loss": 0.16961628198623657
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 200,
|
||||||
|
"loss": 0.3003162443637848,
|
||||||
|
"text_loss": 9.495882295595948e-06,
|
||||||
|
"flow_loss": 0.15015336871147156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 220,
|
||||||
|
"loss": 0.23716998100280762,
|
||||||
|
"text_loss": 5.660519946104614e-06,
|
||||||
|
"flow_loss": 0.11858215928077698
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 240,
|
||||||
|
"loss": 0.42717865109443665,
|
||||||
|
"text_loss": 8.400202204938978e-06,
|
||||||
|
"flow_loss": 0.21358512341976166
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 260,
|
||||||
|
"loss": 0.11702287942171097,
|
||||||
|
"text_loss": 4.122769951209193e-06,
|
||||||
|
"flow_loss": 0.058509379625320435
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 280,
|
||||||
|
"loss": 0.19245445728302002,
|
||||||
|
"text_loss": 5.941245035501197e-06,
|
||||||
|
"flow_loss": 0.09622425585985184
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 300,
|
||||||
|
"loss": 0.18475297093391418,
|
||||||
|
"text_loss": 4.744139005197212e-06,
|
||||||
|
"flow_loss": 0.09237411618232727
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 320,
|
||||||
|
"loss": 0.38481318950653076,
|
||||||
|
"text_loss": 3.1461640901397914e-06,
|
||||||
|
"flow_loss": 0.19240501523017883
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 340,
|
||||||
|
"loss": 0.2163764089345932,
|
||||||
|
"text_loss": 4.1934181354008615e-06,
|
||||||
|
"flow_loss": 0.10818611085414886
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 360,
|
||||||
|
"loss": 0.23533868789672852,
|
||||||
|
"text_loss": 3.3019753118423978e-06,
|
||||||
|
"flow_loss": 0.11766768991947174
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 380,
|
||||||
|
"loss": 0.08397223055362701,
|
||||||
|
"text_loss": 8.292082384286914e-06,
|
||||||
|
"flow_loss": 0.04198196902871132
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 400,
|
||||||
|
"loss": 0.13249839842319489,
|
||||||
|
"text_loss": 1.0871653103095014e-05,
|
||||||
|
"flow_loss": 0.06624376028776169
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 420,
|
||||||
|
"loss": 0.4479130506515503,
|
||||||
|
"text_loss": 1.0128642315976322e-05,
|
||||||
|
"flow_loss": 0.22395145893096924
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 440,
|
||||||
|
"loss": 0.22416232526302338,
|
||||||
|
"text_loss": 2.6114355478057405e-06,
|
||||||
|
"flow_loss": 0.11207985877990723
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 460,
|
||||||
|
"loss": 0.06346385926008224,
|
||||||
|
"text_loss": 3.1035808660817565e-06,
|
||||||
|
"flow_loss": 0.031730376183986664
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 480,
|
||||||
|
"loss": 0.385486900806427,
|
||||||
|
"text_loss": 2.184032382501755e-06,
|
||||||
|
"flow_loss": 0.19274236261844635
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "posttrain",
|
||||||
|
"step": 500,
|
||||||
|
"loss": 0.12353827804327011,
|
||||||
|
"text_loss": 3.1691649837739533e-06,
|
||||||
|
"flow_loss": 0.06176755577325821
|
||||||
|
}
|
||||||
|
]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "pi05-minimal-validation"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A tiny, educational pi0.5-style hierarchical VLA overfitting experiment"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
authors = [{ name = "pi05 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]
|
||||||
|
pi05mini-train = "pi05mini.train:main"
|
||||||
|
pi05mini-infer = "pi05mini.infer:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = { "" = "src" }
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-ra"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Minimal pi0.5 architecture validation package."""
|
||||||
|
|
||||||
|
from .model import Pi05Config, Pi05Mini, Pi05Output, flow_matching_sample
|
||||||
|
|
||||||
|
__all__ = ["Pi05Config", "Pi05Mini", "Pi05Output", "flow_matching_sample"]
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, 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 .tokenizer import MiniTokenizer
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SampleRecord:
|
||||||
|
clip_id: str
|
||||||
|
t0_us: int
|
||||||
|
reasoning: str
|
||||||
|
image_path: Path
|
||||||
|
trajectory_path: Path
|
||||||
|
|
||||||
|
@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:
|
||||||
|
"""Normalize 2-D waypoint deltas and retain invertible dataset statistics."""
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
@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 sequences of equal length")
|
||||||
|
chunks = []
|
||||||
|
for history, future in zip(histories, futures, strict=True):
|
||||||
|
chunks.append(cls.to_deltas(history, future).reshape(-1, 2))
|
||||||
|
flat = torch.cat(chunks, dim=0).float()
|
||||||
|
return cls(flat.mean(dim=0), flat.std(dim=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())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_deltas(history_xy: torch.Tensor, future_xy: torch.Tensor) -> torch.Tensor:
|
||||||
|
previous = torch.cat((history_xy[-1:].float(), future_xy[:-1].float()), dim=0)
|
||||||
|
return future_xy.float() - previous
|
||||||
|
|
||||||
|
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:
|
||||||
|
deltas = actions.float() * self.scale + self.mean
|
||||||
|
return history_xy[-1:].float() + torch.cumsum(deltas, dim=0)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, list[float]]:
|
||||||
|
return self.statistics.as_dict()
|
||||||
|
|
||||||
|
def save(self, path: str | Path) -> Path:
|
||||||
|
output = Path(path).expanduser().resolve()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(json.dumps(self.as_dict(), indent=2) + "\n", encoding="utf-8")
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_data_path(manifest_path: Path, raw_path: str | Path) -> Path:
|
||||||
|
path = Path(raw_path).expanduser()
|
||||||
|
if path.is_absolute():
|
||||||
|
return path
|
||||||
|
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(path).expanduser().resolve()
|
||||||
|
records: list[SampleRecord] = []
|
||||||
|
for line in manifest_path.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"]),
|
||||||
|
reasoning=str(row.get("reasoning", "")),
|
||||||
|
image_path=_resolve_data_path(manifest_path, row["image_path"]),
|
||||||
|
trajectory_path=_resolve_data_path(manifest_path, row["trajectory_path"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not records:
|
||||||
|
raise ValueError(f"no samples found in {manifest_path}")
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_subset(
|
||||||
|
source_manifest: str | Path,
|
||||||
|
output_dir: str | Path,
|
||||||
|
max_samples: int = 3,
|
||||||
|
num_variants: int | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Copy a self-contained PAI overfit subset out of the Alpamayo project."""
|
||||||
|
|
||||||
|
# ``num_variants`` is accepted as a compatibility alias used by the
|
||||||
|
# training entry point. Actual variants stay virtual and deterministic in
|
||||||
|
# Pi05OverfitDataset, avoiding redundant copies of the large JPEG.
|
||||||
|
if num_variants is not None:
|
||||||
|
if num_variants < 1:
|
||||||
|
raise ValueError("num_variants must be at least one")
|
||||||
|
max_samples = min(max_samples, num_variants)
|
||||||
|
if max_samples < 1:
|
||||||
|
raise ValueError("max_samples must be at least one")
|
||||||
|
records = load_manifest(source_manifest)[:max_samples]
|
||||||
|
destination = Path(output_dir).expanduser().resolve()
|
||||||
|
image_dir = destination / "images"
|
||||||
|
trajectory_dir = destination / "trajectories"
|
||||||
|
image_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
trajectory_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for index, record in enumerate(records):
|
||||||
|
stem = f"sample_{index:02d}_{record.sample_id}"
|
||||||
|
image_path = image_dir / f"{stem}{record.image_path.suffix.lower()}"
|
||||||
|
trajectory_path = trajectory_dir / 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)),
|
||||||
|
"history_steps": 8,
|
||||||
|
"expert_waypoints": 16,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest_path = destination / "overfit_manifest.jsonl"
|
||||||
|
manifest_path.write_text(
|
||||||
|
"".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return manifest_path
|
||||||
|
|
||||||
|
|
||||||
|
def _resample_waypoints(xy: torch.Tensor, steps: int = 16) -> torch.Tensor:
|
||||||
|
if xy.shape[0] == steps:
|
||||||
|
return xy.float()
|
||||||
|
if xy.shape[0] < 2:
|
||||||
|
raise ValueError("future trajectory must contain at least two waypoints")
|
||||||
|
signal = xy.float().T.unsqueeze(0)
|
||||||
|
return F.interpolate(signal, size=steps, mode="linear", align_corners=True)[0].T
|
||||||
|
|
||||||
|
|
||||||
|
class Pi05OverfitDataset(Dataset[dict[str, Any]]):
|
||||||
|
"""Tiny deterministic PAI dataset for validating a pi0.5-style training loop."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
manifest_path: str | Path,
|
||||||
|
*,
|
||||||
|
image_size: int | tuple[int, int] = (128, 224),
|
||||||
|
augmentation_factor: int = 4,
|
||||||
|
action_horizon: int = 16,
|
||||||
|
seed: int = 7,
|
||||||
|
tokenizer: MiniTokenizer | None = None,
|
||||||
|
action_statistics: ActionStatistics | dict[str, Sequence[float]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if augmentation_factor < 1:
|
||||||
|
raise ValueError("augmentation_factor must be at least one")
|
||||||
|
if isinstance(image_size, int):
|
||||||
|
image_size = (image_size, image_size)
|
||||||
|
if len(image_size) != 2 or min(image_size) < 1:
|
||||||
|
raise ValueError("image_size must be a positive (height, width) pair")
|
||||||
|
if action_horizon < 2:
|
||||||
|
raise ValueError("action_horizon must be at least two")
|
||||||
|
self.records = load_manifest(manifest_path)
|
||||||
|
self.image_size = (int(image_size[0]), int(image_size[1]))
|
||||||
|
self.augmentation_factor = int(augmentation_factor)
|
||||||
|
self.action_horizon = int(action_horizon)
|
||||||
|
self.seed = int(seed)
|
||||||
|
self.tokenizer = tokenizer or MiniTokenizer()
|
||||||
|
self.state_dim = 16
|
||||||
|
|
||||||
|
if action_statistics is None:
|
||||||
|
geometries = [
|
||||||
|
self._load_augmented_geometry(base_index, variant)
|
||||||
|
for base_index in range(len(self.records))
|
||||||
|
for variant in range(self.augmentation_factor)
|
||||||
|
]
|
||||||
|
histories = [geometry[0] for geometry in geometries]
|
||||||
|
futures = [geometry[1] for geometry in geometries]
|
||||||
|
self.action_normalizer = ActionNormalizer.fit(histories, futures)
|
||||||
|
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
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_mean(self) -> torch.Tensor:
|
||||||
|
return self.action_normalizer.mean.clone()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_std(self) -> torch.Tensor:
|
||||||
|
return self.action_normalizer.scale.clone()
|
||||||
|
|
||||||
|
def save_action_statistics(self, path: str | Path) -> Path:
|
||||||
|
return self.action_normalizer.save(path)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.records) * self.augmentation_factor
|
||||||
|
|
||||||
|
def _indices(self, index: int) -> tuple[int, int]:
|
||||||
|
if index < 0:
|
||||||
|
index += len(self)
|
||||||
|
if index < 0 or index >= len(self):
|
||||||
|
raise IndexError(index)
|
||||||
|
return divmod(index, self.augmentation_factor)
|
||||||
|
|
||||||
|
def _rng(self, base_index: int, variant: int) -> np.random.Generator:
|
||||||
|
return np.random.default_rng(
|
||||||
|
np.random.SeedSequence([self.seed, base_index, variant])
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_augmented_geometry(
|
||||||
|
self, base_index: int, variant: int
|
||||||
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||||
|
record = self.records[base_index]
|
||||||
|
with np.load(record.trajectory_path) as arrays:
|
||||||
|
history = torch.from_numpy(np.asarray(arrays["history_xyz"], dtype=np.float32))[:, :2]
|
||||||
|
future = torch.from_numpy(np.asarray(arrays["future_xyz"], dtype=np.float32))[:, :2]
|
||||||
|
future = _resample_waypoints(future, steps=self.action_horizon)
|
||||||
|
if variant == 0:
|
||||||
|
return history, future
|
||||||
|
|
||||||
|
rng = self._rng(base_index, variant)
|
||||||
|
angle = float(rng.normal(0.0, 0.008))
|
||||||
|
scale = float(rng.normal(1.0, 0.006))
|
||||||
|
rotation = history.new_tensor(
|
||||||
|
[[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]]
|
||||||
|
)
|
||||||
|
history = scale * (history @ rotation.T)
|
||||||
|
future = scale * (future @ rotation.T)
|
||||||
|
# A tiny smooth lateral perturbation adds distinct trajectories while retaining the label.
|
||||||
|
curvature = float(rng.normal(0.0, 0.012))
|
||||||
|
progress = torch.linspace(0.0, 1.0, future.shape[0], dtype=future.dtype)
|
||||||
|
future[:, 1] += curvature * progress.square()
|
||||||
|
return history, future
|
||||||
|
|
||||||
|
def _load_image(self, base_index: int, variant: int) -> torch.Tensor:
|
||||||
|
with Image.open(self.records[base_index].image_path) as source:
|
||||||
|
image = source.convert("RGB")
|
||||||
|
if variant:
|
||||||
|
rng = self._rng(base_index, variant)
|
||||||
|
brightness = float(rng.uniform(0.97, 1.03))
|
||||||
|
image = ImageEnhance.Brightness(image).enhance(brightness)
|
||||||
|
height, width = self.image_size
|
||||||
|
image = image.resize((width, height), resample=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]:
|
||||||
|
base_index, variant = self._indices(index)
|
||||||
|
record = self.records[base_index]
|
||||||
|
history_xy, future_xy = self._load_augmented_geometry(base_index, variant)
|
||||||
|
reasoning = record.reasoning
|
||||||
|
target = self.tokenizer.encode_subtask(reasoning)
|
||||||
|
actions = self.action_normalizer.encode(history_xy, future_xy)
|
||||||
|
return {
|
||||||
|
"image": self._load_image(base_index, variant),
|
||||||
|
"prefix_tokens": self.tokenizer.build_prefix(history_xy),
|
||||||
|
"subtask_target": target,
|
||||||
|
"subtask_label": self.tokenizer.subtasks.decode(target),
|
||||||
|
"actions": actions,
|
||||||
|
"action_tokens": self.tokenizer.encode_actions(actions),
|
||||||
|
"state": history_xy.reshape(-1),
|
||||||
|
"history_xy": history_xy,
|
||||||
|
"future_xy": future_xy,
|
||||||
|
"reasoning": reasoning,
|
||||||
|
"sample_id": f"{record.sample_id}:aug{variant}",
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from torch import Tensor
|
||||||
|
|
||||||
|
|
||||||
|
class FlowMatching:
|
||||||
|
"""Rectified-flow objective used by the minimal action expert."""
|
||||||
|
|
||||||
|
def __init__(self, inference_steps: int = 10, cutoff: float = 0.999) -> None:
|
||||||
|
self.inference_steps = inference_steps
|
||||||
|
self.cutoff = cutoff
|
||||||
|
|
||||||
|
def training_batch(
|
||||||
|
self, target: Tensor, generator: torch.Generator | None = None
|
||||||
|
) -> tuple[Tensor, Tensor, Tensor]:
|
||||||
|
batch = target.shape[0]
|
||||||
|
uniform = torch.rand(
|
||||||
|
batch, device=target.device, dtype=target.dtype, generator=generator
|
||||||
|
)
|
||||||
|
# Paper sampling: z~Beta(1.5,1), tau=s*(1-z), emphasizing noisy/low tau.
|
||||||
|
timestep = self.cutoff * (1.0 - uniform.pow(1.0 / 1.5))
|
||||||
|
noise = torch.randn(
|
||||||
|
target.shape,
|
||||||
|
device=target.device,
|
||||||
|
dtype=target.dtype,
|
||||||
|
generator=generator,
|
||||||
|
)
|
||||||
|
t = timestep[:, None, None]
|
||||||
|
noisy = t * target + (1.0 - t) * noise
|
||||||
|
return noisy, timestep, target - noise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def loss(prediction: Tensor, target_velocity: Tensor) -> Tensor:
|
||||||
|
return torch.nn.functional.mse_loss(prediction.float(), target_velocity.float())
|
||||||
|
|
||||||
|
@torch.no_grad()
|
||||||
|
def sample(
|
||||||
|
self,
|
||||||
|
model_fn: Callable[[Tensor, Tensor], Tensor],
|
||||||
|
shape: tuple[int, ...],
|
||||||
|
device: torch.device | str,
|
||||||
|
steps: int | None = None,
|
||||||
|
generator: torch.Generator | None = None,
|
||||||
|
) -> Tensor:
|
||||||
|
count = steps or self.inference_steps
|
||||||
|
x = torch.randn(shape, device=device, generator=generator)
|
||||||
|
grid = torch.linspace(0.0, 1.0, count + 1, device=device)
|
||||||
|
for index in range(count):
|
||||||
|
timestep = grid[index].expand(shape[0])
|
||||||
|
x = x + (grid[index + 1] - grid[index]) * model_fn(x, timestep)
|
||||||
|
return x
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from .data import Pi05OverfitDataset
|
||||||
|
from .flow import FlowMatching
|
||||||
|
from .model import Pi05Config, Pi05Mini
|
||||||
|
from .runtime import choose_device, write_json
|
||||||
|
|
||||||
|
|
||||||
|
@torch.no_grad()
|
||||||
|
def evaluate(checkpoint_path: str, manifest: str | None = None) -> Path:
|
||||||
|
checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
||||||
|
cfg = checkpoint["config"]
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
device = choose_device(cfg.get("device", "auto"))
|
||||||
|
data_manifest = Path(manifest) if manifest else root / "data/overfit/overfit_manifest.jsonl"
|
||||||
|
dataset = Pi05OverfitDataset(
|
||||||
|
data_manifest, image_size=cfg["data"]["image_size"],
|
||||||
|
action_horizon=cfg["data"]["action_horizon"],
|
||||||
|
action_statistics=checkpoint["action_statistics"]
|
||||||
|
)
|
||||||
|
model = Pi05Mini(Pi05Config(**checkpoint["model_config"]))
|
||||||
|
model.load_state_dict(checkpoint["model"])
|
||||||
|
model.to(device).eval()
|
||||||
|
flow = FlowMatching(cfg["inference"]["flow_steps"])
|
||||||
|
rows = []
|
||||||
|
figure, axes = plt.subplots(1, len(dataset), figsize=(4 * len(dataset), 4), squeeze=False)
|
||||||
|
for index, sample in enumerate(dataset):
|
||||||
|
image = sample["image"][None].to(device)
|
||||||
|
tokens = sample["prefix_tokens"][None].to(device)
|
||||||
|
# Autoregressively predict semantic subtask followed by the complete
|
||||||
|
# discretized action stream (ACTION_START, 32 scalar tokens, ACTION_END).
|
||||||
|
zero_action = torch.zeros(1, dataset.action_horizon, 2, device=device)
|
||||||
|
generated: list[int] = []
|
||||||
|
expected_tokens = 1 + 2 + dataset.action_horizon * 2
|
||||||
|
current_tokens = tokens
|
||||||
|
for _ in range(expected_tokens):
|
||||||
|
semantic = model(
|
||||||
|
image, current_tokens, zero_action,
|
||||||
|
torch.zeros(1, device=device),
|
||||||
|
generated_token_start=tokens.shape[1]
|
||||||
|
)
|
||||||
|
next_token = int(semantic.text_logits[0, -1].argmax().cpu())
|
||||||
|
generated.append(next_token)
|
||||||
|
current_tokens = torch.cat(
|
||||||
|
(current_tokens, torch.tensor([[next_token]], device=device)), dim=1
|
||||||
|
)
|
||||||
|
predicted_token = generated[0]
|
||||||
|
vlm_action_tokens = torch.tensor(generated[1:], dtype=torch.long)
|
||||||
|
try:
|
||||||
|
vlm_normalized = dataset.tokenizer.decode_actions(vlm_action_tokens)
|
||||||
|
vlm_xy = dataset.action_normalizer.decode(
|
||||||
|
vlm_normalized, sample["history_xy"]
|
||||||
|
).numpy()
|
||||||
|
vlm_valid = True
|
||||||
|
except ValueError:
|
||||||
|
vlm_xy = np.full_like(sample["future_xy"].numpy(), np.nan)
|
||||||
|
vlm_valid = False
|
||||||
|
conditioned_tokens = torch.cat(
|
||||||
|
(tokens, torch.tensor([[predicted_token]], device=device)), dim=1
|
||||||
|
)
|
||||||
|
def velocity(x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
|
||||||
|
return model(
|
||||||
|
image, conditioned_tokens, x, t,
|
||||||
|
generated_token_start=tokens.shape[1]
|
||||||
|
).vector_field
|
||||||
|
normalized = flow.sample(
|
||||||
|
velocity, (1, dataset.action_horizon, 2), device,
|
||||||
|
steps=cfg["inference"]["flow_steps"],
|
||||||
|
generator=None,
|
||||||
|
)[0].cpu()
|
||||||
|
predicted_xy = dataset.action_normalizer.decode(
|
||||||
|
normalized, sample["history_xy"]
|
||||||
|
).numpy()
|
||||||
|
target_xy = sample["future_xy"].numpy()
|
||||||
|
diffusion_mse = float(np.mean((predicted_xy - target_xy) ** 2))
|
||||||
|
vlm_mse = float(np.mean((vlm_xy - target_xy) ** 2)) if vlm_valid else None
|
||||||
|
target_label = dataset.tokenizer.subtasks.decode(sample["subtask_target"])
|
||||||
|
try:
|
||||||
|
predicted_label = dataset.tokenizer.subtasks.decode(predicted_token)
|
||||||
|
except ValueError:
|
||||||
|
predicted_label = f"token_{predicted_token}"
|
||||||
|
rows.append({
|
||||||
|
"sample_id": sample["sample_id"], "target_subtask": target_label,
|
||||||
|
"predicted_subtask": predicted_label,
|
||||||
|
"vlm_action_tokens_valid": vlm_valid,
|
||||||
|
"vlm_trajectory_mse": vlm_mse,
|
||||||
|
"diffusion_trajectory_mse": diffusion_mse,
|
||||||
|
})
|
||||||
|
ax = axes[0, index]
|
||||||
|
ax.plot(target_xy[:, 0], target_xy[:, 1], "o-", label="target")
|
||||||
|
ax.plot(vlm_xy[:, 0], vlm_xy[:, 1], "s--", label="VLM tokens")
|
||||||
|
ax.plot(predicted_xy[:, 0], predicted_xy[:, 1], "x--", label="diffusion")
|
||||||
|
ax.scatter([0], [0], c="black", marker="s", label="ego")
|
||||||
|
vlm_text = "invalid" if vlm_mse is None else f"{vlm_mse:.3f}"
|
||||||
|
ax.set_title(
|
||||||
|
f"{predicted_label}\nVLM={vlm_text}, diffusion={diffusion_mse:.3f}"
|
||||||
|
)
|
||||||
|
ax.set_xlabel("forward x (m)")
|
||||||
|
ax.set_ylabel("left y (m)")
|
||||||
|
ax.axis("equal")
|
||||||
|
ax.grid(True, alpha=.3)
|
||||||
|
ax.legend(fontsize=8)
|
||||||
|
figure.tight_layout()
|
||||||
|
output_dir = Path(checkpoint_path).parent
|
||||||
|
plot_path = output_dir / "trajectory_comparison.png"
|
||||||
|
figure.savefig(plot_path, dpi=160)
|
||||||
|
plt.close(figure)
|
||||||
|
write_json(output_dir / "evaluation.json", rows)
|
||||||
|
print(rows)
|
||||||
|
return plot_path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--checkpoint", default="outputs/overfit/checkpoint.pt")
|
||||||
|
parser.add_argument("--manifest")
|
||||||
|
args = parser.parse_args()
|
||||||
|
evaluate(args.checkpoint, args.manifest)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
"""A compact, pure-PyTorch approximation of the pi0.5 dual-expert model.
|
||||||
|
|
||||||
|
The important architectural detail is that prefix and action tokens have
|
||||||
|
different parameters at every Transformer layer. Prefix queries only attend
|
||||||
|
to prefix keys, while action queries attend to the complete prefix and to all
|
||||||
|
action tokens. This is deliberately implemented explicitly (instead of
|
||||||
|
concatenating the streams into an ordinary Transformer) so the information
|
||||||
|
flow is easy to inspect in a minimal reproduction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from torch import Tensor, nn
|
||||||
|
import torch.nn.functional as F
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Pi05Config:
|
||||||
|
vocab_size: int = 512
|
||||||
|
action_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
|
||||||
|
mlp_ratio: int = 4
|
||||||
|
max_text_tokens: int = 64
|
||||||
|
max_action_tokens: int = 32
|
||||||
|
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")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_image_tokens(self) -> int:
|
||||||
|
return (self.image_size // self.patch_size) ** 2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_prefix_tokens(self) -> int:
|
||||||
|
return self.max_image_tokens + self.max_text_tokens
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Pi05Output:
|
||||||
|
text_logits: Tensor
|
||||||
|
vector_field: Tensor
|
||||||
|
prefix_hidden: Tensor
|
||||||
|
action_hidden: Tensor
|
||||||
|
text_token_offset: int
|
||||||
|
loss: Optional[Tensor] = None
|
||||||
|
text_loss: Optional[Tensor] = None
|
||||||
|
flow_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:
|
||||||
|
# Accumulating the variance in fp32 avoids overflows under autocast.
|
||||||
|
rms = x.float().pow(2).mean(dim=-1, keepdim=True)
|
||||||
|
normalized = x * torch.rsqrt(rms.to(x.dtype) + self.eps)
|
||||||
|
return normalized * self.weight
|
||||||
|
|
||||||
|
|
||||||
|
class AdaRMSNorm(nn.Module):
|
||||||
|
"""RMSNorm modulated by a flow-time embedding."""
|
||||||
|
|
||||||
|
def __init__(self, dim: int, cond_dim: int, eps: float = 1e-6) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.norm = RMSNorm(dim, eps)
|
||||||
|
self.modulation = nn.Linear(cond_dim, 2 * dim)
|
||||||
|
# Start as an ordinary RMSNorm, which makes tiny-data training stable.
|
||||||
|
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)
|
||||||
|
if fourier_dim % 2:
|
||||||
|
fourier_dim += 1
|
||||||
|
self.fourier_dim = fourier_dim
|
||||||
|
self.mlp = nn.Sequential(
|
||||||
|
nn.Linear(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, :]
|
||||||
|
embedding = torch.cat((angles.sin(), angles.cos()), dim=-1)
|
||||||
|
return self.mlp(embedding)
|
||||||
|
|
||||||
|
|
||||||
|
class AttentionWeights(nn.Module):
|
||||||
|
"""One expert's independent Q/K/V/output projections."""
|
||||||
|
|
||||||
|
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: Optional[Tensor],
|
||||||
|
dropout: float,
|
||||||
|
training: bool,
|
||||||
|
) -> Tensor:
|
||||||
|
batch, q_len, width = query.shape
|
||||||
|
k_len = key.shape[1]
|
||||||
|
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 = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(head_dim)
|
||||||
|
if allowed is not None:
|
||||||
|
# allowed is [B or 1, Q, K]; True means the edge exists.
|
||||||
|
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 = torch.matmul(probabilities, v)
|
||||||
|
return output.transpose(1, 2).contiguous().reshape(batch, q_len, width)
|
||||||
|
|
||||||
|
|
||||||
|
class DualExpertBlock(nn.Module):
|
||||||
|
def __init__(self, config: Pi05Config) -> None:
|
||||||
|
super().__init__()
|
||||||
|
d = config.d_model
|
||||||
|
self.n_heads = config.n_heads
|
||||||
|
self.dropout = config.dropout
|
||||||
|
|
||||||
|
# The two streams never share Transformer parameters.
|
||||||
|
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
|
||||||
|
# Prefix KV uses the VLM expert's parameters; action KV uses the action
|
||||||
|
# expert's parameters. Thus action can read prefix without allowing a
|
||||||
|
# reverse information path from action into the prefix stream.
|
||||||
|
key = torch.cat((p.k(prefix_input), a.k(action_input)), dim=1)
|
||||||
|
value = torch.cat((p.v(prefix_input), 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 Pi05Mini(nn.Module):
|
||||||
|
"""Minimal pi0.5-style model for architecture validation/overfitting."""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[Pi05Config] = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.config = config or Pi05Config()
|
||||||
|
c = self.config
|
||||||
|
self.patch_encoder = nn.Conv2d(
|
||||||
|
c.image_channels, c.d_model, c.patch_size, stride=c.patch_size
|
||||||
|
)
|
||||||
|
self.token_embedding = nn.Embedding(c.vocab_size, c.d_model)
|
||||||
|
self.action_projection = nn.Linear(c.action_dim, c.d_model)
|
||||||
|
self.prefix_position = nn.Parameter(
|
||||||
|
torch.randn(1, c.max_prefix_tokens, c.d_model) * 0.01
|
||||||
|
)
|
||||||
|
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(DualExpertBlock(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 encode_prefix(self, pixel_values: Tensor, input_ids: Tensor) -> tuple[Tensor, int]:
|
||||||
|
patches = self.patch_encoder(pixel_values).flatten(2).transpose(1, 2)
|
||||||
|
text_tokens = self.token_embedding(input_ids)
|
||||||
|
# pi0.5 represents state in the VLM vocabulary. There is deliberately
|
||||||
|
# no separate continuous-state projection in this model.
|
||||||
|
prefix = torch.cat((patches, text_tokens), dim=1)
|
||||||
|
if prefix.shape[1] > self.config.max_prefix_tokens:
|
||||||
|
raise ValueError("prefix exceeds configured maximum length")
|
||||||
|
prefix = prefix + self.prefix_position[:, : prefix.shape[1]]
|
||||||
|
return prefix, patches.shape[1]
|
||||||
|
|
||||||
|
def _masks(
|
||||||
|
self,
|
||||||
|
batch: int,
|
||||||
|
prefix_length: int,
|
||||||
|
action_length: int,
|
||||||
|
text_offset: int,
|
||||||
|
attention_mask: Optional[Tensor],
|
||||||
|
action_prefix_mask: Optional[Tensor],
|
||||||
|
generated_token_start: Optional[int],
|
||||||
|
device: torch.device,
|
||||||
|
) -> tuple[Tensor, Tensor]:
|
||||||
|
valid = torch.ones(batch, prefix_length, dtype=torch.bool, device=device)
|
||||||
|
if attention_mask is not None:
|
||||||
|
valid[:, text_offset:] = attention_mask.bool()
|
||||||
|
# Image plus observation/instruction/state tokens form a fully
|
||||||
|
# bidirectional observation block. Tokens generated after that block
|
||||||
|
# (subtask and FAST actions) read the observation and earlier generated
|
||||||
|
# tokens causally; observation queries cannot read generated tokens.
|
||||||
|
generated_start = prefix_length if generated_token_start is None else text_offset + generated_token_start
|
||||||
|
if not text_offset <= generated_start <= prefix_length:
|
||||||
|
raise ValueError("generated_token_start must index input_ids")
|
||||||
|
q = torch.arange(prefix_length, device=device)[:, None]
|
||||||
|
k = torch.arange(prefix_length, device=device)[None, :]
|
||||||
|
structural = torch.where(q < generated_start, k < generated_start, k <= q)
|
||||||
|
prefix_edges = structural[None] & valid[:, None, :]
|
||||||
|
# Padded prefix queries retain a harmless self edge to avoid all -inf
|
||||||
|
# rows; their outputs are not used by the text loss.
|
||||||
|
query_valid = valid[:, :, None]
|
||||||
|
identity = torch.eye(prefix_length, dtype=torch.bool, device=device)[None]
|
||||||
|
prefix_edges = torch.where(query_valid, prefix_edges, identity)
|
||||||
|
|
||||||
|
action_valid = valid
|
||||||
|
if action_prefix_mask is not None:
|
||||||
|
supplied = action_prefix_mask.bool()
|
||||||
|
if supplied.shape[1] == prefix_length - text_offset:
|
||||||
|
supplied = torch.cat(
|
||||||
|
(torch.ones(batch, text_offset, dtype=torch.bool, device=device), supplied),
|
||||||
|
dim=1,
|
||||||
|
)
|
||||||
|
if supplied.shape != valid.shape:
|
||||||
|
raise ValueError("action_prefix_mask must cover text tokens or the full prefix")
|
||||||
|
action_valid = action_valid & supplied
|
||||||
|
prefix_keys = action_valid[:, None, :].expand(batch, action_length, prefix_length)
|
||||||
|
action_keys = torch.ones(
|
||||||
|
batch, action_length, action_length, dtype=torch.bool, device=device
|
||||||
|
)
|
||||||
|
action_edges = torch.cat((prefix_keys, action_keys), dim=-1)
|
||||||
|
return prefix_edges, action_edges
|
||||||
|
|
||||||
|
def forward(
|
||||||
|
self,
|
||||||
|
pixel_values: Tensor,
|
||||||
|
input_ids: Tensor,
|
||||||
|
noisy_actions: Tensor,
|
||||||
|
timesteps: Tensor,
|
||||||
|
attention_mask: Optional[Tensor] = None,
|
||||||
|
action_prefix_mask: Optional[Tensor] = None,
|
||||||
|
generated_token_start: Optional[int] = None,
|
||||||
|
text_labels: Optional[Tensor] = None,
|
||||||
|
target_velocity: Optional[Tensor] = None,
|
||||||
|
text_loss_weight: float = 1.0,
|
||||||
|
flow_loss_weight: float = 1.0,
|
||||||
|
) -> Pi05Output:
|
||||||
|
"""Run both experts.
|
||||||
|
|
||||||
|
``timesteps`` is a scalar per batch item in [0, 1]. ``text_labels``
|
||||||
|
corresponds only to ``input_ids`` (not image prefix positions)
|
||||||
|
and may contain -100. ``target_velocity`` has the same shape as
|
||||||
|
``noisy_actions``.
|
||||||
|
"""
|
||||||
|
if noisy_actions.ndim != 3:
|
||||||
|
raise ValueError("noisy_actions must have shape [batch, horizon, action_dim]")
|
||||||
|
if noisy_actions.shape[1] > self.config.max_action_tokens:
|
||||||
|
raise ValueError("action horizon exceeds configured maximum")
|
||||||
|
|
||||||
|
prefix, text_offset = self.encode_prefix(pixel_values, input_ids)
|
||||||
|
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)
|
||||||
|
prefix_edges, action_edges = self._masks(
|
||||||
|
prefix.shape[0], prefix.shape[1], horizon, text_offset,
|
||||||
|
attention_mask, action_prefix_mask, generated_token_start, prefix.device
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
# Return logits only for actual text positions, which keeps downstream
|
||||||
|
# next-token loss/generation code uncomplicated.
|
||||||
|
text_logits = self.text_head(prefix_hidden[:, text_offset:])
|
||||||
|
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)
|
||||||
|
loss = None
|
||||||
|
if text_loss is not None or flow_loss is not None:
|
||||||
|
loss = sum(
|
||||||
|
component for component 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 component is not None
|
||||||
|
)
|
||||||
|
return Pi05Output(
|
||||||
|
text_logits=text_logits,
|
||||||
|
vector_field=vector_field,
|
||||||
|
prefix_hidden=prefix_hidden,
|
||||||
|
action_hidden=action_hidden,
|
||||||
|
text_token_offset=text_offset,
|
||||||
|
loss=loss,
|
||||||
|
text_loss=text_loss,
|
||||||
|
flow_loss=flow_loss,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def flow_matching_sample(actions: Tensor) -> tuple[Tensor, Tensor, Tensor]:
|
||||||
|
"""Construct pi-style conditional-flow training targets.
|
||||||
|
|
||||||
|
The path is x_t = (1-t) * noise + t * action, with constant target vector
|
||||||
|
field ``action - noise``.
|
||||||
|
"""
|
||||||
|
batch = actions.shape[0]
|
||||||
|
timestep = torch.rand(batch, device=actions.device, dtype=actions.dtype)
|
||||||
|
noise = torch.randn_like(actions)
|
||||||
|
broadcast_t = timestep.reshape(batch, *([1] * (actions.ndim - 1)))
|
||||||
|
noisy_actions = (1 - broadcast_t) * noise + broadcast_t * actions
|
||||||
|
target_velocity = actions - noise
|
||||||
|
return noisy_actions, timestep, target_velocity
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str | Path) -> dict:
|
||||||
|
with Path(path).open("r", encoding="utf-8") as handle:
|
||||||
|
return yaml.safe_load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_everything(seed: int) -> None:
|
||||||
|
random.seed(seed)
|
||||||
|
np.random.seed(seed)
|
||||||
|
torch.manual_seed(seed)
|
||||||
|
|
||||||
|
|
||||||
|
def choose_device(requested: str = "auto") -> torch.device:
|
||||||
|
if requested != "auto":
|
||||||
|
return torch.device(requested)
|
||||||
|
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: str | Path, value: object) -> None:
|
||||||
|
target = Path(path)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(json.dumps(value, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CoordinateRange:
|
||||||
|
minimum: float
|
||||||
|
maximum: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.maximum <= self.minimum:
|
||||||
|
raise ValueError("coordinate maximum must be greater than minimum")
|
||||||
|
|
||||||
|
|
||||||
|
class CoordinateTokenizer:
|
||||||
|
"""Uniformly discretize XY state values into the literal token range 0..255."""
|
||||||
|
|
||||||
|
bins = 256
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
x_range: tuple[float, float] = (-10.0, 10.0),
|
||||||
|
y_range: tuple[float, float] = (-5.0, 5.0),
|
||||||
|
) -> None:
|
||||||
|
self.ranges = (
|
||||||
|
CoordinateRange(*x_range),
|
||||||
|
CoordinateRange(*y_range),
|
||||||
|
)
|
||||||
|
|
||||||
|
def encode(self, coordinates: torch.Tensor | Iterable[Iterable[float]]) -> torch.Tensor:
|
||||||
|
values = torch.as_tensor(coordinates, dtype=torch.float32)
|
||||||
|
if values.shape[-1] != 2:
|
||||||
|
raise ValueError("coordinates must end in an XY dimension of size 2")
|
||||||
|
low = values.new_tensor([axis.minimum for axis in self.ranges])
|
||||||
|
high = values.new_tensor([axis.maximum for axis in self.ranges])
|
||||||
|
unit = ((values - low) / (high - low)).clamp(0.0, 1.0)
|
||||||
|
return torch.round(unit * (self.bins - 1)).to(torch.long)
|
||||||
|
|
||||||
|
def decode(self, tokens: torch.Tensor | Iterable[Iterable[int]]) -> torch.Tensor:
|
||||||
|
encoded = torch.as_tensor(tokens, dtype=torch.long)
|
||||||
|
if encoded.shape[-1] != 2:
|
||||||
|
raise ValueError("coordinate tokens must end in an XY dimension of size 2")
|
||||||
|
if encoded.numel() and (encoded.min() < 0 or encoded.max() >= self.bins):
|
||||||
|
raise ValueError("coordinate tokens must be in the inclusive range 0..255")
|
||||||
|
low = torch.tensor([axis.minimum for axis in self.ranges], dtype=torch.float32)
|
||||||
|
high = torch.tensor([axis.maximum for axis in self.ranges], dtype=torch.float32)
|
||||||
|
return low + encoded.float() / (self.bins - 1) * (high - low)
|
||||||
|
|
||||||
|
|
||||||
|
class SubtaskVocabulary:
|
||||||
|
"""Small semantic vocabulary standing in for pi0.5's generated language subtask."""
|
||||||
|
|
||||||
|
labels = (
|
||||||
|
"keep_lane",
|
||||||
|
"steer_left",
|
||||||
|
"steer_right",
|
||||||
|
"slow_down",
|
||||||
|
"stop",
|
||||||
|
"accelerate",
|
||||||
|
"unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, first_token_id: int = 260) -> None:
|
||||||
|
self.first_token_id = int(first_token_id)
|
||||||
|
self._label_to_token = {
|
||||||
|
label: self.first_token_id + index for index, label in enumerate(self.labels)
|
||||||
|
}
|
||||||
|
self._token_to_label = {token: label for label, token in self._label_to_token.items()}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self) -> int:
|
||||||
|
return len(self.labels)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token_ids(self) -> tuple[int, ...]:
|
||||||
|
return tuple(self._label_to_token[label] for label in self.labels)
|
||||||
|
|
||||||
|
def classify(self, reasoning: str) -> str:
|
||||||
|
text = reasoning.casefold()
|
||||||
|
# More specific commands precede generic lane/follow language.
|
||||||
|
if any(term in text for term in ("stop", "halt", "stationary")):
|
||||||
|
return "stop"
|
||||||
|
if any(term in text for term in ("slow", "deceler", "reduce speed", "brake")):
|
||||||
|
return "slow_down"
|
||||||
|
if any(term in text for term in ("accelerat", "speed up")):
|
||||||
|
return "accelerate"
|
||||||
|
if any(term in text for term in ("left", "counterclockwise")):
|
||||||
|
return "steer_left"
|
||||||
|
if any(term in text for term in ("right", "clockwise")):
|
||||||
|
return "steer_right"
|
||||||
|
if any(term in text for term in ("lane", "straight", "follow", "continue", "proceed")):
|
||||||
|
return "keep_lane"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
def encode(self, label_or_reasoning: str) -> int:
|
||||||
|
label = (
|
||||||
|
label_or_reasoning
|
||||||
|
if label_or_reasoning in self._label_to_token
|
||||||
|
else self.classify(label_or_reasoning)
|
||||||
|
)
|
||||||
|
return self._label_to_token[label]
|
||||||
|
|
||||||
|
def decode(self, token_id: int | torch.Tensor) -> str:
|
||||||
|
token = int(token_id)
|
||||||
|
try:
|
||||||
|
return self._token_to_label[token]
|
||||||
|
except KeyError as error:
|
||||||
|
raise ValueError(f"unknown subtask token: {token}") from error
|
||||||
|
|
||||||
|
|
||||||
|
class MiniTokenizer:
|
||||||
|
"""Compose image/state, semantic, and FAST-lite action tokens.
|
||||||
|
|
||||||
|
Actions are z-score-normalized by the dataset before reaching this
|
||||||
|
tokenizer. A complete ``[horizon, 2]`` chunk is transformed along time
|
||||||
|
with an orthonormal DCT-II. All coefficients are retained and serialized
|
||||||
|
in frequency-major XY order, so low-frequency motion appears first in the
|
||||||
|
token stream. The coefficients are divided by ``sqrt(horizon)`` before
|
||||||
|
being uniformly quantized over ``[-4, 4]`` into 64 shared tokens. This
|
||||||
|
keeps the fixed quantization interval valid for any horizon when the input
|
||||||
|
actions have first been clipped to the same interval.
|
||||||
|
|
||||||
|
This intentionally implements only the central transform/order idea from
|
||||||
|
FAST. It does not learn dataset-specific coefficient bins or drop trailing
|
||||||
|
high-frequency coefficients.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BOS = 256
|
||||||
|
IMAGE = 257
|
||||||
|
STATE = 258
|
||||||
|
SUBTASK_QUERY = 259
|
||||||
|
ACTION_START = SUBTASK_QUERY + 1 + len(SubtaskVocabulary.labels)
|
||||||
|
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,
|
||||||
|
coordinate_tokenizer: CoordinateTokenizer | None = None,
|
||||||
|
subtasks: SubtaskVocabulary | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.coordinates = coordinate_tokenizer or CoordinateTokenizer()
|
||||||
|
self.subtasks = subtasks or SubtaskVocabulary(first_token_id=self.SUBTASK_QUERY + 1)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vocab_size(self) -> int:
|
||||||
|
return max(max(self.subtasks.token_ids), self.ACTION_TOKEN_END) + 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_token_ids(self) -> range:
|
||||||
|
"""Contiguous vocabulary interval reserved for quantized DCT coefficients."""
|
||||||
|
|
||||||
|
return range(self.ACTION_TOKEN_START, self.ACTION_TOKEN_END + 1)
|
||||||
|
|
||||||
|
def build_prefix(self, history_xy: torch.Tensor) -> torch.Tensor:
|
||||||
|
coordinate_tokens = self.coordinates.encode(history_xy).reshape(-1)
|
||||||
|
specials = torch.tensor([self.BOS, self.IMAGE, self.STATE], dtype=torch.long)
|
||||||
|
query = torch.tensor([self.SUBTASK_QUERY], dtype=torch.long)
|
||||||
|
return torch.cat((specials, coordinate_tokens.cpu(), query), dim=0)
|
||||||
|
|
||||||
|
def encode_subtask(self, reasoning: str) -> torch.Tensor:
|
||||||
|
return torch.tensor(self.subtasks.encode(reasoning), dtype=torch.long)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _orthonormal_dct_matrix(
|
||||||
|
horizon: int,
|
||||||
|
*,
|
||||||
|
device: torch.device,
|
||||||
|
dtype: torch.dtype,
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Return the orthonormal DCT-II matrix ``[frequency, time]``."""
|
||||||
|
|
||||||
|
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: torch.Tensor | Iterable[Iterable[float]]
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Apply an orthonormal DCT-II along an action chunk's time axis."""
|
||||||
|
|
||||||
|
values = torch.as_tensor(actions, dtype=torch.float32)
|
||||||
|
if values.ndim != 2 or values.shape[-1] != 2:
|
||||||
|
raise ValueError("actions must have shape [horizon, 2]")
|
||||||
|
if values.shape[0] == 0:
|
||||||
|
raise ValueError("actions must contain at least one timestep")
|
||||||
|
transform = self._orthonormal_dct_matrix(
|
||||||
|
values.shape[0], device=values.device, dtype=values.dtype
|
||||||
|
)
|
||||||
|
return transform @ values
|
||||||
|
|
||||||
|
def idct_actions(
|
||||||
|
self, coefficients: torch.Tensor | Iterable[Iterable[float]]
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Invert :meth:`dct_actions` using the transpose of its basis."""
|
||||||
|
|
||||||
|
values = torch.as_tensor(coefficients, dtype=torch.float32)
|
||||||
|
if values.ndim != 2 or values.shape[-1] != 2:
|
||||||
|
raise ValueError("DCT coefficients must have shape [horizon, 2]")
|
||||||
|
if values.shape[0] == 0:
|
||||||
|
raise ValueError("DCT coefficients must contain at least one frequency")
|
||||||
|
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: torch.Tensor | Iterable[Iterable[float]]
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""DCT-transform and encode normalized actions as a marked token stream."""
|
||||||
|
|
||||||
|
values = torch.as_tensor(actions, dtype=torch.float32)
|
||||||
|
if values.ndim != 2 or values.shape[-1] != 2:
|
||||||
|
raise ValueError("actions must have shape [horizon, 2]")
|
||||||
|
if values.shape[0] == 0:
|
||||||
|
raise ValueError("actions must contain at least one timestep")
|
||||||
|
values = values.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM)
|
||||||
|
coefficients = self.dct_actions(values) / math.sqrt(values.shape[0])
|
||||||
|
# Exact zero is useful for a stable shared center bin. Tiny non-zero
|
||||||
|
# values here are only floating-point residue from the DCT basis.
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
quantized = torch.round(unit * (self.ACTION_BINS - 1)).to(torch.long)
|
||||||
|
# Row-major flattening is frequency-major: DC XY, frequency-1 XY, ...
|
||||||
|
action_tokens = quantized.reshape(-1) + self.ACTION_TOKEN_START
|
||||||
|
boundaries = torch.tensor(
|
||||||
|
[self.ACTION_START, self.ACTION_END],
|
||||||
|
dtype=torch.long,
|
||||||
|
device=action_tokens.device,
|
||||||
|
)
|
||||||
|
return torch.cat((boundaries[:1], action_tokens, boundaries[1:]))
|
||||||
|
|
||||||
|
def decode_actions(self, tokens: torch.Tensor | Iterable[int]) -> torch.Tensor:
|
||||||
|
"""Decode FAST-lite tokens back to normalized ``[horizon, 2]`` actions."""
|
||||||
|
|
||||||
|
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 reserved action token interval"
|
||||||
|
)
|
||||||
|
quantized = payload - self.ACTION_TOKEN_START
|
||||||
|
normalized_coefficients = (
|
||||||
|
quantized.float()
|
||||||
|
/ (self.ACTION_BINS - 1)
|
||||||
|
* (self.ACTION_MAXIMUM - self.ACTION_MINIMUM)
|
||||||
|
+ self.ACTION_MINIMUM
|
||||||
|
)
|
||||||
|
horizon = payload.numel() // 2
|
||||||
|
coefficients = normalized_coefficients.reshape(horizon, 2) * math.sqrt(horizon)
|
||||||
|
return self.idct_actions(coefficients)
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from torch.utils.data import DataLoader
|
||||||
|
|
||||||
|
from .data import Pi05OverfitDataset, materialize_subset
|
||||||
|
from .flow import FlowMatching
|
||||||
|
from .model import Pi05Config, Pi05Mini
|
||||||
|
from .runtime import choose_device, load_config, seed_everything, write_json
|
||||||
|
|
||||||
|
|
||||||
|
def _collate(rows: list[dict]) -> dict:
|
||||||
|
tensor_keys = (
|
||||||
|
"image", "prefix_tokens", "subtask_target", "actions", "action_tokens"
|
||||||
|
)
|
||||||
|
result = {key: torch.stack([row[key] for row in rows]) for key in tensor_keys}
|
||||||
|
result["sample_id"] = [row["sample_id"] for row in rows]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_model(config: dict, dataset: Pi05OverfitDataset) -> Pi05Mini:
|
||||||
|
model_cfg = config["model"]
|
||||||
|
return Pi05Mini(Pi05Config(
|
||||||
|
vocab_size=dataset.tokenizer.vocab_size,
|
||||||
|
action_dim=2,
|
||||||
|
image_size=config["data"]["image_size"],
|
||||||
|
patch_size=model_cfg["patch_size"],
|
||||||
|
d_model=model_cfg["d_model"],
|
||||||
|
n_heads=model_cfg["n_heads"],
|
||||||
|
n_layers=model_cfg["n_layers"],
|
||||||
|
mlp_ratio=model_cfg["mlp_ratio"],
|
||||||
|
max_text_tokens=model_cfg["max_text_tokens"],
|
||||||
|
max_action_tokens=config["data"]["action_horizon"],
|
||||||
|
dropout=model_cfg["dropout"],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def train(config_path: str) -> Path:
|
||||||
|
cfg = load_config(config_path)
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
seed_everything(cfg["seed"])
|
||||||
|
device = choose_device(cfg.get("device", "auto"))
|
||||||
|
manifest = materialize_subset(
|
||||||
|
source_manifest=cfg["data"]["source_manifest"],
|
||||||
|
output_dir=root / "data" / "overfit",
|
||||||
|
num_variants=cfg["data"]["num_variants"],
|
||||||
|
)
|
||||||
|
dataset = Pi05OverfitDataset(
|
||||||
|
manifest, image_size=cfg["data"]["image_size"],
|
||||||
|
action_horizon=cfg["data"]["action_horizon"]
|
||||||
|
)
|
||||||
|
loader = DataLoader(
|
||||||
|
dataset, batch_size=min(cfg["train"]["batch_size"], len(dataset)),
|
||||||
|
shuffle=True, collate_fn=_collate
|
||||||
|
)
|
||||||
|
model = build_model(cfg, dataset).to(device)
|
||||||
|
flow = FlowMatching(cfg["inference"]["flow_steps"])
|
||||||
|
output_dir = root / cfg["output_dir"]
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
history = []
|
||||||
|
|
||||||
|
def is_action_parameter(name: str) -> bool:
|
||||||
|
return any(part in name for part in (
|
||||||
|
"action_", "time_embedding", "vector_field_head"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Stage 1 follows pi0.5 pre-training: train the VLM/token pathway with
|
||||||
|
# discrete state and FAST-lite actions, while the flow expert is untouched.
|
||||||
|
stage1_params = [p for name, p in model.named_parameters() if not is_action_parameter(name)]
|
||||||
|
optimizer = torch.optim.AdamW(
|
||||||
|
stage1_params, lr=cfg["train"]["learning_rate"],
|
||||||
|
weight_decay=cfg["train"]["weight_decay"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def next_batch(iterator):
|
||||||
|
try:
|
||||||
|
return next(iterator), iterator
|
||||||
|
except StopIteration:
|
||||||
|
iterator = iter(loader)
|
||||||
|
return next(iterator), iterator
|
||||||
|
|
||||||
|
iterator = iter(loader)
|
||||||
|
model.train()
|
||||||
|
for step in range(1, cfg["train"]["pretrain_steps"] + 1):
|
||||||
|
batch, iterator = next_batch(iterator)
|
||||||
|
image = batch["image"].to(device)
|
||||||
|
tokens = batch["prefix_tokens"].to(device)
|
||||||
|
actions = batch["actions"].to(device)
|
||||||
|
subtask = batch["subtask_target"].to(device)[:, None]
|
||||||
|
action_tokens = batch["action_tokens"].to(device)
|
||||||
|
targets = torch.cat((subtask, action_tokens), dim=1)
|
||||||
|
# Teacher-forced autoregressive stream. The final target is predicted
|
||||||
|
# from the preceding token and therefore is not part of model_tokens.
|
||||||
|
model_tokens = torch.cat((tokens, targets[:, :-1]), dim=1)
|
||||||
|
model_labels = torch.full_like(model_tokens, -100)
|
||||||
|
query_index = tokens.shape[1] - 1
|
||||||
|
model_labels[:, query_index:] = targets
|
||||||
|
# Flow tokens may read observation/state and the generated semantic
|
||||||
|
# subtask, but not teacher-forced discrete action tokens.
|
||||||
|
zero_action = torch.zeros_like(actions)
|
||||||
|
output = model(
|
||||||
|
image, model_tokens, zero_action,
|
||||||
|
torch.zeros(image.shape[0], device=device),
|
||||||
|
generated_token_start=tokens.shape[1],
|
||||||
|
text_labels=model_labels,
|
||||||
|
)
|
||||||
|
optimizer.zero_grad(set_to_none=True)
|
||||||
|
output.loss.backward()
|
||||||
|
torch.nn.utils.clip_grad_norm_(model.parameters(), cfg["train"]["grad_clip_norm"])
|
||||||
|
optimizer.step()
|
||||||
|
if step == 1 or step % cfg["train"]["log_every"] == 0:
|
||||||
|
record = {
|
||||||
|
"stage": "pretrain",
|
||||||
|
"step": step,
|
||||||
|
"loss": float(output.loss.detach().cpu()),
|
||||||
|
"text_loss": float(output.text_loss.detach().cpu()),
|
||||||
|
"flow_loss": None,
|
||||||
|
}
|
||||||
|
history.append(record)
|
||||||
|
print(json.dumps(record))
|
||||||
|
|
||||||
|
torch.save(model.state_dict(), output_dir / "stage1_vlm.pt")
|
||||||
|
|
||||||
|
# Stage 2 follows post-training: add the still-random action expert and
|
||||||
|
# jointly retain token prediction while optimizing continuous flow.
|
||||||
|
optimizer = torch.optim.AdamW(
|
||||||
|
model.parameters(), lr=cfg["train"]["learning_rate"],
|
||||||
|
weight_decay=cfg["train"]["weight_decay"]
|
||||||
|
)
|
||||||
|
iterator = iter(loader)
|
||||||
|
for step in range(1, cfg["train"]["posttrain_steps"] + 1):
|
||||||
|
batch, iterator = next_batch(iterator)
|
||||||
|
image = batch["image"].to(device)
|
||||||
|
tokens = batch["prefix_tokens"].to(device)
|
||||||
|
actions = batch["actions"].to(device)
|
||||||
|
subtask = batch["subtask_target"].to(device)[:, None]
|
||||||
|
action_tokens = batch["action_tokens"].to(device)
|
||||||
|
targets = torch.cat((subtask, action_tokens), dim=1)
|
||||||
|
model_tokens = torch.cat((tokens, targets[:, :-1]), dim=1)
|
||||||
|
model_labels = torch.full_like(model_tokens, -100)
|
||||||
|
model_labels[:, tokens.shape[1] - 1:] = targets
|
||||||
|
action_prefix_mask = torch.zeros_like(model_tokens, dtype=torch.bool)
|
||||||
|
action_prefix_mask[:, : tokens.shape[1] + 1] = True
|
||||||
|
noisy, timestep, velocity = flow.training_batch(actions)
|
||||||
|
output = model(
|
||||||
|
image, model_tokens, noisy, timestep,
|
||||||
|
action_prefix_mask=action_prefix_mask,
|
||||||
|
generated_token_start=tokens.shape[1],
|
||||||
|
text_labels=model_labels, target_velocity=velocity,
|
||||||
|
text_loss_weight=cfg["train"]["semantic_loss_weight"],
|
||||||
|
flow_loss_weight=cfg["train"]["flow_loss_weight"],
|
||||||
|
)
|
||||||
|
optimizer.zero_grad(set_to_none=True)
|
||||||
|
output.loss.backward()
|
||||||
|
torch.nn.utils.clip_grad_norm_(model.parameters(), cfg["train"]["grad_clip_norm"])
|
||||||
|
optimizer.step()
|
||||||
|
if step == 1 or step % cfg["train"]["log_every"] == 0:
|
||||||
|
record = {
|
||||||
|
"stage": "posttrain", "step": step,
|
||||||
|
"loss": float(output.loss.detach().cpu()),
|
||||||
|
"text_loss": float(output.text_loss.detach().cpu()),
|
||||||
|
"flow_loss": float(output.flow_loss.detach().cpu()),
|
||||||
|
}
|
||||||
|
history.append(record)
|
||||||
|
print(json.dumps(record))
|
||||||
|
checkpoint = {
|
||||||
|
"model": model.state_dict(), "config": cfg,
|
||||||
|
"model_config": model.config.__dict__,
|
||||||
|
"action_statistics": dataset.action_normalizer.as_dict(),
|
||||||
|
}
|
||||||
|
torch.save(checkpoint, output_dir / "checkpoint.pt")
|
||||||
|
write_json(output_dir / "training_history.json", history)
|
||||||
|
write_json(output_dir / "summary.json", {
|
||||||
|
"device": str(device), "samples": len(dataset),
|
||||||
|
"parameters": sum(p.numel() for p in model.parameters()),
|
||||||
|
"initial": history[0], "final": history[-1],
|
||||||
|
})
|
||||||
|
return output_dir / "checkpoint.pt"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--config", default="configs/overfit.yaml")
|
||||||
|
args = parser.parse_args()
|
||||||
|
train(args.config)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from pi05mini.tokenizer import MiniTokenizer
|
||||||
|
|
||||||
|
|
||||||
|
def test_orthonormal_dct_round_trip() -> None:
|
||||||
|
tokenizer = MiniTokenizer()
|
||||||
|
actions = torch.tensor(
|
||||||
|
[
|
||||||
|
[-4.0, -2.75],
|
||||||
|
[-0.25, 0.0],
|
||||||
|
[1.375, 3.9],
|
||||||
|
[4.0, 2.125],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
coefficients = tokenizer.dct_actions(actions)
|
||||||
|
reconstructed = tokenizer.idct_actions(coefficients)
|
||||||
|
|
||||||
|
assert coefficients.shape == actions.shape
|
||||||
|
torch.testing.assert_close(reconstructed, actions, atol=1e-5, rtol=1e-5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fast_lite_tokens_round_trip_with_bounded_quantization_error() -> None:
|
||||||
|
tokenizer = MiniTokenizer()
|
||||||
|
actions = torch.tensor(
|
||||||
|
[
|
||||||
|
[-3.5, -2.75],
|
||||||
|
[-0.25, 0.0],
|
||||||
|
[1.375, 3.5],
|
||||||
|
[3.75, 2.125],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
tokens = tokenizer.encode_actions(actions)
|
||||||
|
reconstructed = tokenizer.decode_actions(tokens)
|
||||||
|
|
||||||
|
bin_width = (
|
||||||
|
tokenizer.ACTION_MAXIMUM - tokenizer.ACTION_MINIMUM
|
||||||
|
) / (tokenizer.ACTION_BINS - 1)
|
||||||
|
assert reconstructed.shape == actions.shape
|
||||||
|
# Orthonormal IDCT preserves total coefficient error. Coefficients are
|
||||||
|
# quantized after division by sqrt(horizon), yielding this RMSE bound.
|
||||||
|
rmse = torch.sqrt(torch.mean((reconstructed - actions).square()))
|
||||||
|
assert float(rmse) <= bin_width / 2 * actions.shape[0] ** 0.5 + 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_tokens_are_serialized_low_frequency_first() -> None:
|
||||||
|
tokenizer = MiniTokenizer()
|
||||||
|
horizon = 4
|
||||||
|
coefficients = torch.zeros(horizon, 2)
|
||||||
|
coefficients[0, 0] = 1.5 * horizon**0.5 # positive X DC coefficient
|
||||||
|
coefficients[1, 1] = -1.5 * horizon**0.5 # negative Y first harmonic
|
||||||
|
actions = tokenizer.idct_actions(coefficients)
|
||||||
|
|
||||||
|
payload = tokenizer.encode_actions(actions)[1:-1]
|
||||||
|
zero_token = tokenizer.ACTION_TOKEN_START + round(
|
||||||
|
(0.0 - tokenizer.ACTION_MINIMUM)
|
||||||
|
/ (tokenizer.ACTION_MAXIMUM - tokenizer.ACTION_MINIMUM)
|
||||||
|
* (tokenizer.ACTION_BINS - 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Frequency-major XY order is [DC-x, DC-y, f1-x, f1-y, ...].
|
||||||
|
assert payload[0].item() > zero_token
|
||||||
|
assert payload[1].item() == zero_token
|
||||||
|
assert payload[2].item() == zero_token
|
||||||
|
assert payload[3].item() < zero_token
|
||||||
|
assert torch.all(payload[4:] == zero_token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_token_ids_are_contiguous_reserved_vocabulary() -> None:
|
||||||
|
tokenizer = MiniTokenizer()
|
||||||
|
edge_actions = torch.tensor([[-4.0, 4.0], [0.0, 1.0]])
|
||||||
|
tokens = tokenizer.encode_actions(edge_actions)
|
||||||
|
|
||||||
|
assert tokens[0].item() == tokenizer.ACTION_START
|
||||||
|
assert tokens[-1].item() == tokenizer.ACTION_END
|
||||||
|
assert tokens[1:-1].min().item() >= tokenizer.ACTION_TOKEN_START
|
||||||
|
assert tokens[1:-1].max().item() <= tokenizer.ACTION_TOKEN_END
|
||||||
|
assert tuple(tokenizer.action_token_ids) == tuple(
|
||||||
|
range(tokenizer.ACTION_TOKEN_START, tokenizer.ACTION_TOKEN_END + 1)
|
||||||
|
)
|
||||||
|
assert tokenizer.vocab_size == tokenizer.ACTION_TOKEN_END + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_actions_rejects_missing_boundaries_and_non_action_ids() -> None:
|
||||||
|
tokenizer = MiniTokenizer()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="enclosed"):
|
||||||
|
tokenizer.decode_actions(torch.tensor([tokenizer.ACTION_TOKEN_START] * 2))
|
||||||
|
with pytest.raises(ValueError, match="reserved"):
|
||||||
|
tokenizer.decode_actions(
|
||||||
|
torch.tensor(
|
||||||
|
[
|
||||||
|
tokenizer.ACTION_START,
|
||||||
|
tokenizer.BOS,
|
||||||
|
tokenizer.ACTION_TOKEN_START,
|
||||||
|
tokenizer.ACTION_END,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pi05mini.data import Pi05OverfitDataset, load_manifest, materialize_subset
|
||||||
|
from pi05mini.tokenizer import CoordinateTokenizer, MiniTokenizer, SubtaskVocabulary
|
||||||
|
|
||||||
|
|
||||||
|
def _make_source_project(root: Path) -> Path:
|
||||||
|
processed = root / "data" / "processed"
|
||||||
|
image_dir = processed / "images"
|
||||||
|
trajectory_dir = processed / "trajectories"
|
||||||
|
image_dir.mkdir(parents=True)
|
||||||
|
trajectory_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
image_path = image_dir / "sample.jpg"
|
||||||
|
pixels = np.zeros((48, 80, 3), dtype=np.uint8)
|
||||||
|
pixels[..., 0] = 64
|
||||||
|
pixels[..., 1] = np.arange(80, dtype=np.uint8)[None, :]
|
||||||
|
Image.fromarray(pixels).save(image_path)
|
||||||
|
|
||||||
|
history_x = np.linspace(-7.0, 0.0, 8, dtype=np.float32)
|
||||||
|
history = np.stack((history_x, 0.02 * history_x, np.zeros(8)), axis=-1)
|
||||||
|
future_x = np.linspace(1.0, 32.0, 16, dtype=np.float32)
|
||||||
|
future = np.stack((future_x, 0.002 * future_x**2, np.zeros(16)), axis=-1)
|
||||||
|
trajectory_path = trajectory_dir / "sample.npz"
|
||||||
|
np.savez_compressed(trajectory_path, history_xyz=history, future_xyz=future)
|
||||||
|
|
||||||
|
manifest = processed / "overfit_manifest.jsonl"
|
||||||
|
row = {
|
||||||
|
"clip_id": "clip-a",
|
||||||
|
"t0_us": 1234,
|
||||||
|
"reasoning": "Steer left through the construction lane.",
|
||||||
|
"image_path": "data/processed/images/sample.jpg",
|
||||||
|
"trajectory_path": "data/processed/trajectories/sample.npz",
|
||||||
|
}
|
||||||
|
manifest.write_text(json.dumps(row) + "\n", encoding="utf-8")
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinate_tokens_are_literal_bytes_and_round_trip() -> None:
|
||||||
|
tokenizer = CoordinateTokenizer(x_range=(-10.0, 10.0), y_range=(-5.0, 5.0))
|
||||||
|
coordinates = torch.tensor([[-10.0, -5.0], [0.0, 0.0], [10.0, 5.0]])
|
||||||
|
tokens = tokenizer.encode(coordinates)
|
||||||
|
|
||||||
|
assert tokens.dtype == torch.long
|
||||||
|
assert tokens.tolist()[0] == [0, 0]
|
||||||
|
assert tokens.tolist()[-1] == [255, 255]
|
||||||
|
assert 0 <= int(tokens.min()) <= int(tokens.max()) <= 255
|
||||||
|
decoded = tokenizer.decode(tokens)
|
||||||
|
assert torch.allclose(decoded, coordinates, atol=10.1 / 255.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_subtask_vocabulary_and_prefix_contract() -> None:
|
||||||
|
tokenizer = MiniTokenizer()
|
||||||
|
history = torch.zeros(8, 2)
|
||||||
|
prefix = tokenizer.build_prefix(history)
|
||||||
|
|
||||||
|
assert prefix.shape == (20,)
|
||||||
|
assert prefix[:3].tolist() == [tokenizer.BOS, tokenizer.IMAGE, tokenizer.STATE]
|
||||||
|
assert prefix[-1].item() == tokenizer.SUBTASK_QUERY
|
||||||
|
assert prefix[3:-1].min() >= 0 and prefix[3:-1].max() <= 255
|
||||||
|
target = tokenizer.encode_subtask("Steer left around the cones")
|
||||||
|
assert tokenizer.subtasks.decode(target) == "steer_left"
|
||||||
|
assert SubtaskVocabulary().classify("Slow down for a pedestrian") == "slow_down"
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_and_dataset_are_self_contained_and_deterministic(tmp_path: Path) -> None:
|
||||||
|
source_manifest = _make_source_project(tmp_path / "source")
|
||||||
|
copied_manifest = materialize_subset(source_manifest, tmp_path / "target", max_samples=1)
|
||||||
|
records = load_manifest(copied_manifest)
|
||||||
|
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0].image_path.is_file()
|
||||||
|
assert records[0].trajectory_path.is_file()
|
||||||
|
|
||||||
|
dataset = Pi05OverfitDataset(
|
||||||
|
copied_manifest,
|
||||||
|
image_size=(32, 48),
|
||||||
|
augmentation_factor=3,
|
||||||
|
seed=19,
|
||||||
|
)
|
||||||
|
same_dataset = Pi05OverfitDataset(
|
||||||
|
copied_manifest,
|
||||||
|
image_size=(32, 48),
|
||||||
|
augmentation_factor=3,
|
||||||
|
seed=19,
|
||||||
|
action_statistics=dataset.action_normalizer.as_dict(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(dataset) == 3
|
||||||
|
original = dataset[0]
|
||||||
|
augmented = dataset[1]
|
||||||
|
repeated = same_dataset[1]
|
||||||
|
assert original["image"].shape == (3, 32, 48)
|
||||||
|
assert original["prefix_tokens"].shape == (20,)
|
||||||
|
assert original["actions"].shape == (16, 2)
|
||||||
|
assert original["subtask_target"].shape == ()
|
||||||
|
assert original["subtask_label"] == "steer_left"
|
||||||
|
assert original["reasoning"].startswith("Steer left")
|
||||||
|
assert original["sample_id"].endswith(":aug0")
|
||||||
|
assert augmented["sample_id"].endswith(":aug1")
|
||||||
|
assert not torch.equal(original["future_xy"], augmented["future_xy"])
|
||||||
|
assert torch.equal(augmented["future_xy"], repeated["future_xy"])
|
||||||
|
assert torch.equal(augmented["image"], repeated["image"])
|
||||||
|
|
||||||
|
reconstructed = dataset.action_normalizer.decode(
|
||||||
|
original["actions"], original["history_xy"]
|
||||||
|
)
|
||||||
|
assert torch.allclose(reconstructed, original["future_xy"], atol=1e-5)
|
||||||
|
|
||||||
|
statistics_path = dataset.save_action_statistics(tmp_path / "action_stats.json")
|
||||||
|
statistics = json.loads(statistics_path.read_text(encoding="utf-8"))
|
||||||
|
assert len(statistics["mean"]) == 2
|
||||||
|
assert len(statistics["scale"]) == 2
|
||||||
|
assert min(statistics["scale"]) >= 0.05
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import torch
|
||||||
|
|
||||||
|
from pi05mini.flow import FlowMatching
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_batch_time(t: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
|
||||||
|
"""Expand a per-example time tensor over non-batch dimensions."""
|
||||||
|
return t.reshape(t.shape[0], *([1] * (target.ndim - 1)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_batch_obeys_linear_flow_equations() -> None:
|
||||||
|
flow = FlowMatching()
|
||||||
|
target = torch.tensor(
|
||||||
|
[
|
||||||
|
[[1.0, -2.0], [0.5, 3.0]],
|
||||||
|
[[-1.0, 0.0], [2.5, -0.5]],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
generator = torch.Generator().manual_seed(123)
|
||||||
|
|
||||||
|
x_t, t, target_velocity = flow.training_batch(target, generator=generator)
|
||||||
|
|
||||||
|
assert x_t.shape == target.shape
|
||||||
|
assert target_velocity.shape == target.shape
|
||||||
|
assert t.shape[0] == target.shape[0]
|
||||||
|
assert torch.all((t >= 0.0) & (t <= 1.0))
|
||||||
|
|
||||||
|
t_view = _expand_batch_time(t, target)
|
||||||
|
# v = target - noise, so the sampled noise is exactly recoverable.
|
||||||
|
noise = target - target_velocity
|
||||||
|
expected_x_t = (1.0 - t_view) * noise + t_view * target
|
||||||
|
torch.testing.assert_close(x_t, expected_x_t)
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_batch_is_reproducible_with_generator() -> None:
|
||||||
|
flow = FlowMatching()
|
||||||
|
target = torch.randn(3, 5, 2)
|
||||||
|
|
||||||
|
batch_a = flow.training_batch(
|
||||||
|
target, generator=torch.Generator().manual_seed(99)
|
||||||
|
)
|
||||||
|
batch_b = flow.training_batch(
|
||||||
|
target, generator=torch.Generator().manual_seed(99)
|
||||||
|
)
|
||||||
|
|
||||||
|
for tensor_a, tensor_b in zip(batch_a, batch_b):
|
||||||
|
torch.testing.assert_close(tensor_a, tensor_b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_loss_is_mean_squared_velocity_error() -> None:
|
||||||
|
flow = FlowMatching()
|
||||||
|
target_velocity = torch.tensor([[[1.0, -1.0], [0.0, 2.0]]])
|
||||||
|
prediction = target_velocity + torch.tensor([[[1.0, 1.0], [2.0, 0.0]]])
|
||||||
|
|
||||||
|
# Squared errors are [1, 1, 4, 0], whose mean is 1.5.
|
||||||
|
torch.testing.assert_close(flow.loss(prediction, target_velocity), torch.tensor(1.5))
|
||||||
|
torch.testing.assert_close(
|
||||||
|
flow.loss(target_velocity, target_velocity), torch.tensor(0.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_euler_sampler_reaches_fixed_target_for_exact_vector_field() -> None:
|
||||||
|
flow = FlowMatching()
|
||||||
|
target = torch.tensor(
|
||||||
|
[
|
||||||
|
[[0.25, -0.5], [1.0, 2.0]],
|
||||||
|
[[-2.0, 0.75], [0.0, 0.5]],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def exact_model(x_t: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
|
||||||
|
t_view = _expand_batch_time(t, x_t)
|
||||||
|
# Straight conditional path to target. The sampler evaluates t < 1.
|
||||||
|
return (target.to(x_t) - x_t) / (1.0 - t_view)
|
||||||
|
|
||||||
|
torch.manual_seed(5)
|
||||||
|
sampled = flow.sample(
|
||||||
|
exact_model,
|
||||||
|
shape=tuple(target.shape),
|
||||||
|
device=target.device,
|
||||||
|
steps=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
torch.testing.assert_close(sampled, target, atol=1e-5, rtol=1e-5)
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import torch
|
||||||
|
|
||||||
|
from pi05mini.model import Pi05Config, Pi05Mini
|
||||||
|
|
||||||
|
|
||||||
|
def tiny_model() -> Pi05Mini:
|
||||||
|
return Pi05Mini(Pi05Config(
|
||||||
|
vocab_size=300,
|
||||||
|
image_size=16,
|
||||||
|
patch_size=16,
|
||||||
|
d_model=32,
|
||||||
|
n_heads=4,
|
||||||
|
n_layers=1,
|
||||||
|
max_text_tokens=16,
|
||||||
|
max_action_tokens=4,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pi05_has_no_continuous_state_projection() -> None:
|
||||||
|
model = tiny_model()
|
||||||
|
assert not hasattr(model, "state_projection")
|
||||||
|
output = model(
|
||||||
|
torch.randn(2, 3, 16, 16),
|
||||||
|
torch.randint(0, 300, (2, 7)),
|
||||||
|
torch.randn(2, 4, 2),
|
||||||
|
torch.rand(2),
|
||||||
|
generated_token_start=4,
|
||||||
|
)
|
||||||
|
assert output.text_logits.shape == (2, 7, 300)
|
||||||
|
assert output.vector_field.shape == (2, 4, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_observation_generated_and_flow_attention_partition() -> None:
|
||||||
|
model = tiny_model()
|
||||||
|
# One image patch followed by four observation tokens and three generated
|
||||||
|
# tokens. Flow may read observation plus the first generated (subtask)
|
||||||
|
# token, but not the two teacher-forced FAST tokens.
|
||||||
|
action_prefix_mask = torch.tensor([[1, 1, 1, 1, 1, 1, 0, 0]], dtype=torch.bool)
|
||||||
|
prefix, action = model._masks(
|
||||||
|
batch=1,
|
||||||
|
prefix_length=8,
|
||||||
|
action_length=2,
|
||||||
|
text_offset=1,
|
||||||
|
attention_mask=torch.ones(1, 7, dtype=torch.bool),
|
||||||
|
action_prefix_mask=action_prefix_mask,
|
||||||
|
generated_token_start=4,
|
||||||
|
device=torch.device("cpu"),
|
||||||
|
)
|
||||||
|
|
||||||
|
generated_start = 5 # image patch + four observation text tokens
|
||||||
|
# Observation block is fully bidirectional and cannot see generated tokens.
|
||||||
|
assert prefix[0, 0, :generated_start].all()
|
||||||
|
assert not prefix[0, 0, generated_start:].any()
|
||||||
|
assert prefix[0, 4, :generated_start].all()
|
||||||
|
# Generated tokens see all observation tokens and only prior/self generated tokens.
|
||||||
|
assert prefix[0, 5, :6].all() and not prefix[0, 5, 6:].any()
|
||||||
|
assert prefix[0, 6, :7].all() and not prefix[0, 6, 7]
|
||||||
|
# Flow reads allowed prefix keys and all flow-action keys bidirectionally.
|
||||||
|
assert action[0, :, :6].all()
|
||||||
|
assert not action[0, :, 6:8].any()
|
||||||
|
assert action[0, :, 8:].all()
|
||||||
Reference in New Issue
Block a user