Initial pi0.5 minimal validation

This commit is contained in:
seeker
2026-07-11 20:04:18 +08:00
commit ffbcca8d2b
20 changed files with 2354 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
*.egg-info/
.venv/
data/
outputs/**/*.pt
.DS_Store
+141
View File
@@ -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 在此实现中能够端到端反向传播并记忆几个样本。它不能证明实现与官方模型数值等价,也不能证明在真实驾驶或机器人任务中有效。
+32
View File
@@ -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
+34
View File
@@ -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
}
]
+19
View File
@@ -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
}
}
+296
View File
@@ -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

+38
View File
@@ -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"
+5
View File
@@ -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"]
+316
View File
@@ -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}",
}
+54
View File
@@ -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
+129
View File
@@ -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()
+396
View File
@@ -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
+36
View File
@@ -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")
+285
View File
@@ -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)
+193
View File
@@ -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()
+104
View File
@@ -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,
]
)
)
+121
View File
@@ -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
+85
View File
@@ -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)
+61
View File
@@ -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()