Initial pi0.6 minimal validation

This commit is contained in:
seeker
2026-07-11 22:35:54 +08:00
commit 53ef020b46
27 changed files with 3459 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
+184
View File
@@ -0,0 +1,184 @@
# π0.6 / RECAP Minimal Validation
这是一个用于**小样本过拟合验证**的 π0.6 / `π*0.6` 风格项目。它把 π0.5
已有的“高层 subtask + FAST 离散动作 + flow action expert”扩展为一个最小
RECAP 闭环:收集示范、失败与人工纠正经验,训练分布式 value function,估计
advantage,再用正/负 advantage 条件训练策略,并在推理时比较普通策略与
classifier-free guidance(CFG)策略。
它不是 Physical Intelligence 官方实现,也不能用于真实车辆或机器人控制。
## 公开资料边界
本项目以公开的 **“π*0.6: a VLA That Learns From Experience”** RECAP
论文和 π0.6 model card 为主要依据。π0.6 没有一篇单独公开、包含全部训练细节的
基础模型论文;截至本项目实现时,公开 `openpi` 仓库中也没有对应的官方
π0.6/`π*0.6` 训练源码、权重或完整 RECAP 数据流水线。因此:
- 论文明确给出的 RECAP 顺序、201-bin value distribution、二值 advantage
条件、30% 条件 dropout 和 CFG 被保留;
- 没有公开的部分采用最小且可测试的替代设计;
- 本项目只验证信息流和优化目标能否在几个样本上工作,不声称数值等价。
## 模型阶段与输入输出
| 阶段 | 输入 | 模块 | 输出 | 监督目标 |
|---|---|---|---|---|
| 1. Base policy | `64×64` 图像、离散历史 XY、任务提示 | 小型 VLM policy + stop-gradient flow expert | subtask、FAST-lite tokens、连续 `16×2` action chunk | token CE + flow matching |
| 2. Experience | 一条真实示范轨迹 | 确定性经验构造 | 成功示范、understeer、提前停止、人工纠正 4 类 episode | 无梯度,仅构造 buffer |
| 3. Value | 图像、离散状态、任务、episode return | 小型独立 value VLM | `201` 个 return bins,期望 value 位于 `[-1,0]` | distributional CE |
| 4. Advantage | reward、当前/未来 value、intervention mask | n-step estimator + quantile threshold | positive/negative indicator;人工纠正强制 positive | 无梯度,生成策略条件 |
| 5. Mixed warm-up | 示范与合成经验、advantage 条件 | advantage-conditioned policy | 条件化离散动作和连续 flow | token CE + flow matching |
| 6. RECAP extraction | 更新后的 value、重新标注的全部经验 | policy 从 base checkpoint 再训练 | 改进后的 `π*0.6`-style policy | 条件化 token CE + flow matching |
| 7. CFG inference | 同一 observation 分别使用 positive 和 omitted 条件 | 两次 flow 前向 + CFG,默认 `β=2` | guided 连续 action chunk | 与真值及 unguided 输出比较 |
最关键的信息顺序是:
```text
observation → predicted subtask → advantage indicator → actions
```
advantage token 位于 subtask 之后、动作之前,因此它只改变动作分布,不应改变
高层 subtask。flow expert 可以读取 observation、subtask 和 advantage,但不能
读取 teacher-forced FAST-lite 真值动作。flow loss 对 policy/VLM prefix 使用
stop-gradient,以最小形式模拟论文的 Knowledge Insulation(KI)训练原则。
## 与 π0.5 最小项目的重点差异
| π0.5 minimal | π0.6 / `π*0.6` minimal |
|---|---|
| 只使用示范样本 | 混合示范、autonomous failures 和 intervention corrections |
| 没有 critic | 独立的 201-bin distributional value model |
| 动作只由 observation/subtask 条件化 | 动作额外由 positive/negative/omitted advantage 条件化 |
| 一次离散预训练 + flow post-training | base → value → mixed → RECAP policy extraction |
| 普通 flow sampling | 支持 conditional/unconditional 双前向 CFG |
官方 π0.6 还把 VLM 更新为 Gemma 3 4B、action expert 扩展到约 860M,并增加
更多机器人数据。本项目按“最小结构验证”的目标使用从零训练的小模型:policy
width 为 96,value width 为 64;没有复现这些规模和数据改进。
## 最小经验与 RECAP 近似
一个 PAI 轨迹被确定性地转换为四种经验:
- `demo_success`:原始专家轨迹;
- `autonomous_understeer`:横向转向不足;
- `autonomous_stop`:中途停止;
- `intervention_correction`:先偏离,再由“人工”轨迹拉回。
默认用 4-step bootstrapped advantage,`gamma=0.99`。训练集的 advantage
第 70 百分位作为 positive 阈值,即约 30% 普通样本为 positive;intervention
动作无条件标为 positive。这个合成 buffer 只用于检验 RECAP 接口,不能替代
真实机器人 rollout、任务成功标签或在线人工干预。
## 安装
建议使用 Python 3.10 或更新版本:
```bash
cd pi06-minimal-validation
python -m venv .venv
source .venv/bin/activate
python -m pip install -U pip
python -m pip install -e '.[dev]'
```
默认配置读取:
```text
../../alpamayo/alpamayo-qwen05b-validation/data/processed/overfit_manifest.jsonl
```
若本地数据路径不同,请修改 `configs/overfit.yaml` 中的
`data.source_manifest`。仓库不包含或重新分发原始 PAI 数据。
## 测试
```bash
pytest -q
```
测试应覆盖 flow matching、FAST-lite 往返、三块/防泄漏 attention、return 与
n-step advantage、intervention 强制 positive、condition dropout、CFG 公式以及
value/policy 前向形状。
## 四阶段过拟合训练
```bash
pi06mini-train --config configs/overfit.yaml
```
或:
```bash
python -m pi06mini.train --config configs/overfit.yaml
```
默认训练预算刻意很小:
| 阶段 | 步数 | 目的 |
|---|---:|---|
| base | 300 | 先记住示范的 subtask、FAST-lite 和连续动作 |
| value | 400 | 记住成功、失败和纠正 episode 的 201-bin return |
| mixed | 300 | 让策略学会 positive/negative/omitted 三种条件 |
| recap | 700 | 使用合成经验中的 n-step advantage 标签与 30% 条件 dropout 提取最终策略 |
训练应保存 checkpoint、每阶段 loss、value/advantage 诊断和最终评估。CPU 可以
运行;Apple Silicon 自动使用 MPS,CUDA 环境自动使用 GPU。
## 推理与效果图
```bash
pi06mini-infer --checkpoint outputs/overfit/checkpoint.pt
```
或:
```bash
python -m pi06mini.infer --checkpoint outputs/overfit/checkpoint.pt
```
效果图应至少同时显示:
- ground truth;
- base/unguided flow;
- positive advantage flow;
- `β=2` CFG-guided flow;
- 必要时附 FAST-lite 离散轨迹作为独立校验。
在极小数据过拟合场景中,目标是看到 value 能区分合成成功/失败,positive 或
CFG 策略比 negative/unguided 更接近专家轨迹。它不代表模型具备泛化能力。
## 关键配置
- 图像大小:`64`;action horizon:`16`;
- policy width:`96`;value width:`64`;
- value distribution:`201` bins,范围 `[-1,0]`;
- advantage condition dropout:`0.3`;
- CFG `beta`:`2.0`,位于论文建议的温和范围内;
- positive quantile:`0.7`;n-step:`4`。
## 安全限制
- 本模型只在几个训练样本及人为构造的失败轨迹上验证记忆能力。
- 严禁连接车辆、机械臂、移动机器人或任何能造成物理影响的执行器。
- 轨迹重合、value 排序正确或 loss 下降均不代表安全性、鲁棒性或任务成功率。
- 高 CFG 会把动作推向数据支持边界并产生激进行为;本项目即便使用温和
`β=2` 也只允许离线可视化。
- 真实部署至少需要真实 rollout、独立验证集、分布外测试、动作约束、碰撞
检查、故障降级、急停和人工监督。
- PyTorch checkpoint 使用 pickle 机制;只加载可信来源的 checkpoint。
## 预期结论
成功的实验最多能说明:这个独立实现中的 π0.5-style 双输出 policy、
distributional value function、advantage relabeling、条件 dropout 与 CFG 可以
组成一条可训练的最小 RECAP 链路,并在合成小经验集上过拟合。它不能证明与
官方 π0.6/`π*0.6` 数值等价,也不能证明强化学习改进能够迁移到真实系统。
## 公开参考资料
- [π*0.6 / RECAP 研究页面](https://pi.website/blog/pistar06)
- [π*0.6 / RECAP 论文 PDF](https://pi.website/download/pistar06.pdf)
- [π0.6 model card](https://website.pi-asset.com/pi06star/PI06_model_card.pdf)
- [Physical Intelligence/openpi](https://github.com/Physical-Intelligence/openpi)
+32
View File
@@ -0,0 +1,32 @@
# Third-party notices
This repository is an independent educational implementation. It does not
contain code, weights, datasets, or other assets copied from Physical
Intelligence.
The design is informed by the following public research artifacts:
- Physical Intelligence, **“π*0.6: a VLA That Learns From Experience”**
(RECAP paper): <https://pi.website/download/pistar06.pdf>.
- Physical Intelligence, **π0.6 model card**:
<https://website.pi-asset.com/pi06star/PI06_model_card.pdf>.
- Physical Intelligence, **openpi**, used only as a public reference for the
earlier π0/π0.5 family; the inspected repository did not provide an official
π0.6/RECAP training implementation or checkpoint.
- Pertsch et al., **“FAST: Efficient Action Tokenization for
Vision-Language-Action Models.”** This project implements a small
DCT-and-quantization `FAST-lite` teaching approximation, not the official
tokenizer implementation.
Runtime dependencies retain their own licenses and copyright notices:
- PyTorch
- NumPy
- Pillow
- Matplotlib
- PyYAML
- pytest (development only)
Users are responsible for complying with the licenses of any external data or
checkpoints that they choose to supply. The default configuration points to a
local manifest and does not redistribute its source images.
+54
View File
@@ -0,0 +1,54 @@
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
experience:
augmentation_factor: 1
gamma: 0.99
n_step: 4
positive_quantile: 0.7
model:
patch_size: 16
n_heads: 4
n_layers: 3
mlp_ratio: 3
policy_d_model: 96
value_d_model: 64
max_text_tokens: 64
max_action_tokens: 32
action_start_token_id: 269
dropout: 0.0
value_bins: 201
value_min: -1.0
value_max: 0.0
train:
base_steps: 300
value_steps: 400
mixed_steps: 300
recap_steps: 700
batch_size: 4
learning_rate: 0.001
value_learning_rate: 0.001
weight_decay: 0.0
grad_clip_norm: 1.0
token_loss_weight: 1.0
flow_loss_weight: 2.0
value_loss_weight: 1.0
log_every: 20
recap:
condition_dropout: 0.3
cfg_beta: 2.0
force_interventions_positive: true
inference:
flow_steps: 10
cfg_beta: 2.0
+32
View File
@@ -0,0 +1,32 @@
{
"trajectory_metrics": {
"mixed": 100.86174011230469,
"unconditional": 21.51112937927246,
"positive": 0.061239250004291534,
"negative": 55.823570251464844,
"cfg": 0.24478793144226074,
"negative_to_failure_mse": 48.71984100341797
},
"values": [
{
"source_type": "demo_success",
"target": 0.0,
"prediction": -0.007052943576127291
},
{
"source_type": "autonomous_understeer",
"target": -0.03799404203891754,
"prediction": -0.04714635759592056
},
{
"source_type": "autonomous_stop",
"target": -0.9509902000427246,
"prediction": -0.9405871629714966
},
{
"source_type": "intervention_correction",
"target": -0.0051981499418616295,
"prediction": -0.0118478462100029
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+12
View File
@@ -0,0 +1,12 @@
{
"device": "mps",
"experience_samples": 4,
"policy_parameters": 817058,
"value_parameters": 171977,
"sources": [
"demo_success",
"autonomous_understeer",
"autonomous_stop",
"intervention_correction"
]
}
+625
View File
@@ -0,0 +1,625 @@
[
{
"stage": "base",
"step": 1,
"loss": 72.41638946533203,
"text_loss": 68.23114013671875,
"flow_loss": 2.0926246643066406
},
{
"stage": "base",
"step": 20,
"loss": 4.305421829223633,
"text_loss": 1.9023208618164062,
"flow_loss": 1.2015506029129028
},
{
"stage": "base",
"step": 40,
"loss": 2.912196159362793,
"text_loss": 0.8887173533439636,
"flow_loss": 1.0117393732070923
},
{
"stage": "base",
"step": 60,
"loss": 2.184612512588501,
"text_loss": 0.5364341735839844,
"flow_loss": 0.8240891695022583
},
{
"stage": "base",
"step": 80,
"loss": 1.578565239906311,
"text_loss": 0.23946531116962433,
"flow_loss": 0.6695499420166016
},
{
"stage": "base",
"step": 100,
"loss": 1.5856558084487915,
"text_loss": 0.04227600246667862,
"flow_loss": 0.7716898918151855
},
{
"stage": "base",
"step": 120,
"loss": 0.3423895239830017,
"text_loss": 0.0004414900322444737,
"flow_loss": 0.1709740161895752
},
{
"stage": "base",
"step": 140,
"loss": 0.24176472425460815,
"text_loss": 1.8706961782299913e-05,
"flow_loss": 0.12087301164865494
},
{
"stage": "base",
"step": 160,
"loss": 0.3000774383544922,
"text_loss": 7.637506496394053e-05,
"flow_loss": 0.15000052750110626
},
{
"stage": "base",
"step": 180,
"loss": 0.6410971879959106,
"text_loss": 1.6521895304322243e-05,
"flow_loss": 0.32054033875465393
},
{
"stage": "base",
"step": 200,
"loss": 0.6132829189300537,
"text_loss": 9.676185072748922e-06,
"flow_loss": 0.30663663148880005
},
{
"stage": "base",
"step": 220,
"loss": 0.3431224524974823,
"text_loss": 7.356796231761109e-06,
"flow_loss": 0.17155754566192627
},
{
"stage": "base",
"step": 240,
"loss": 0.28047603368759155,
"text_loss": 5.987634267512476e-06,
"flow_loss": 0.1402350217103958
},
{
"stage": "base",
"step": 260,
"loss": 0.13324393332004547,
"text_loss": 4.9011541705112904e-06,
"flow_loss": 0.06661951541900635
},
{
"stage": "base",
"step": 280,
"loss": 0.1635851263999939,
"text_loss": 4.0462705328536686e-06,
"flow_loss": 0.08179053664207458
},
{
"stage": "base",
"step": 300,
"loss": 0.15081387758255005,
"text_loss": 3.4570459774840856e-06,
"flow_loss": 0.07540521025657654
},
{
"stage": "value",
"step": 1,
"loss": 5.427414894104004,
"prediction": -0.5049723982810974,
"target": 0.0
},
{
"stage": "value",
"step": 20,
"loss": 2.24937105178833,
"prediction": -0.3925527334213257,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 40,
"loss": 1.728512167930603,
"prediction": -0.32079368829727173,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 60,
"loss": 1.4823004007339478,
"prediction": -0.3057250678539276,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 80,
"loss": 1.1117331981658936,
"prediction": -0.3553512394428253,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 100,
"loss": 0.2580283582210541,
"prediction": -0.11316870898008347,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 120,
"loss": 0.12432077527046204,
"prediction": -0.054820768535137177,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 140,
"loss": 0.08916354924440384,
"prediction": -0.04263461381196976,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 160,
"loss": 0.07165656983852386,
"prediction": -0.034443676471710205,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 180,
"loss": 0.05974685773253441,
"prediction": -0.029927225783467293,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 200,
"loss": 0.05075548216700554,
"prediction": -0.026268303394317627,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 220,
"loss": 0.043695785105228424,
"prediction": -0.023351771757006645,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 240,
"loss": 0.03802856057882309,
"prediction": -0.021020548418164253,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 260,
"loss": 0.03340449184179306,
"prediction": -0.019105376675724983,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 280,
"loss": 0.02958041802048683,
"prediction": -0.017514187842607498,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 300,
"loss": 0.02638067863881588,
"prediction": -0.016178548336029053,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 320,
"loss": 0.023675711825489998,
"prediction": -0.01504577323794365,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 340,
"loss": 0.021368399262428284,
"prediction": -0.014076616615056992,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 360,
"loss": 0.019383536651730537,
"prediction": -0.013240991160273552,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 380,
"loss": 0.017663555219769478,
"prediction": -0.01251534465700388,
"target": -0.0051981499418616295
},
{
"stage": "value",
"step": 400,
"loss": 0.016163267195224762,
"prediction": -0.011881118640303612,
"target": -0.0051981499418616295
},
{
"stage": "mixed",
"step": 1,
"loss": 0.11788154393434525,
"text_loss": 3.4366105410299497e-06,
"flow_loss": 0.05893905460834503
},
{
"stage": "mixed",
"step": 20,
"loss": 4.7238688468933105,
"text_loss": 2.6195578575134277,
"flow_loss": 1.0521554946899414
},
{
"stage": "mixed",
"step": 40,
"loss": 5.051268577575684,
"text_loss": 1.2949998378753662,
"flow_loss": 1.8781343698501587
},
{
"stage": "mixed",
"step": 60,
"loss": 1.6957576274871826,
"text_loss": 0.8319522142410278,
"flow_loss": 0.4319027364253998
},
{
"stage": "mixed",
"step": 80,
"loss": 2.1900432109832764,
"text_loss": 0.8061568140983582,
"flow_loss": 0.6919432282447815
},
{
"stage": "mixed",
"step": 100,
"loss": 2.2935876846313477,
"text_loss": 0.5968648791313171,
"flow_loss": 0.8483614325523376
},
{
"stage": "mixed",
"step": 120,
"loss": 1.4990849494934082,
"text_loss": 0.6506502032279968,
"flow_loss": 0.4242174029350281
},
{
"stage": "mixed",
"step": 140,
"loss": 1.6056029796600342,
"text_loss": 0.5977785587310791,
"flow_loss": 0.5039122104644775
},
{
"stage": "mixed",
"step": 160,
"loss": 1.3810169696807861,
"text_loss": 0.48245665431022644,
"flow_loss": 0.44928014278411865
},
{
"stage": "mixed",
"step": 180,
"loss": 1.1724852323532104,
"text_loss": 0.3958587944507599,
"flow_loss": 0.3883132338523865
},
{
"stage": "mixed",
"step": 200,
"loss": 1.2829020023345947,
"text_loss": 0.18238645792007446,
"flow_loss": 0.5502578020095825
},
{
"stage": "mixed",
"step": 220,
"loss": 0.7328482866287231,
"text_loss": 0.1418272703886032,
"flow_loss": 0.29551050066947937
},
{
"stage": "mixed",
"step": 240,
"loss": 1.196341872215271,
"text_loss": 0.28424081206321716,
"flow_loss": 0.4560505449771881
},
{
"stage": "mixed",
"step": 260,
"loss": 4.298240661621094,
"text_loss": 0.257877379655838,
"flow_loss": 2.020181655883789
},
{
"stage": "mixed",
"step": 280,
"loss": 1.3184027671813965,
"text_loss": 0.07380203157663345,
"flow_loss": 0.622300386428833
},
{
"stage": "mixed",
"step": 300,
"loss": 2.4886393547058105,
"text_loss": 0.30716001987457275,
"flow_loss": 1.0907396078109741
},
{
"stage": "recap",
"step": 1,
"loss": 10.11633586883545,
"text_loss": 9.866013526916504,
"flow_loss": 0.12516121566295624
},
{
"stage": "recap",
"step": 20,
"loss": 3.1665196418762207,
"text_loss": 2.23919415473938,
"flow_loss": 0.4636627733707428
},
{
"stage": "recap",
"step": 40,
"loss": 3.1297008991241455,
"text_loss": 1.4351346492767334,
"flow_loss": 0.847283124923706
},
{
"stage": "recap",
"step": 60,
"loss": 2.608584403991699,
"text_loss": 1.171854853630066,
"flow_loss": 0.7183647751808167
},
{
"stage": "recap",
"step": 80,
"loss": 3.5118634700775146,
"text_loss": 0.9280703067779541,
"flow_loss": 1.2918965816497803
},
{
"stage": "recap",
"step": 100,
"loss": 2.7196712493896484,
"text_loss": 0.8991937041282654,
"flow_loss": 0.9102387428283691
},
{
"stage": "recap",
"step": 120,
"loss": 1.1335762739181519,
"text_loss": 0.7848832011222839,
"flow_loss": 0.17434655129909515
},
{
"stage": "recap",
"step": 140,
"loss": 2.5729737281799316,
"text_loss": 0.5885628461837769,
"flow_loss": 0.9922055006027222
},
{
"stage": "recap",
"step": 160,
"loss": 1.9800522327423096,
"text_loss": 0.6485287547111511,
"flow_loss": 0.6657617688179016
},
{
"stage": "recap",
"step": 180,
"loss": 1.5937684774398804,
"text_loss": 0.6764931678771973,
"flow_loss": 0.45863765478134155
},
{
"stage": "recap",
"step": 200,
"loss": 1.588905692100525,
"text_loss": 1.1673603057861328,
"flow_loss": 0.21077267825603485
},
{
"stage": "recap",
"step": 220,
"loss": 1.1653995513916016,
"text_loss": 0.5840165019035339,
"flow_loss": 0.2906915545463562
},
{
"stage": "recap",
"step": 240,
"loss": 2.6636130809783936,
"text_loss": 1.3158458471298218,
"flow_loss": 0.6738836169242859
},
{
"stage": "recap",
"step": 260,
"loss": 0.5980206727981567,
"text_loss": 0.42834123969078064,
"flow_loss": 0.08483970910310745
},
{
"stage": "recap",
"step": 280,
"loss": 1.0666983127593994,
"text_loss": 0.47541406750679016,
"flow_loss": 0.2956421375274658
},
{
"stage": "recap",
"step": 300,
"loss": 0.8535268306732178,
"text_loss": 0.4074874520301819,
"flow_loss": 0.22301967442035675
},
{
"stage": "recap",
"step": 320,
"loss": 5.488016605377197,
"text_loss": 0.851451575756073,
"flow_loss": 2.3182826042175293
},
{
"stage": "recap",
"step": 340,
"loss": 0.7862600088119507,
"text_loss": 0.37299302220344543,
"flow_loss": 0.20663349330425262
},
{
"stage": "recap",
"step": 360,
"loss": 1.3418667316436768,
"text_loss": 0.7838777899742126,
"flow_loss": 0.27899447083473206
},
{
"stage": "recap",
"step": 380,
"loss": 0.7067349553108215,
"text_loss": 0.15938280522823334,
"flow_loss": 0.2736760675907135
},
{
"stage": "recap",
"step": 400,
"loss": 0.4985463619232178,
"text_loss": 0.2566893398761749,
"flow_loss": 0.12092851102352142
},
{
"stage": "recap",
"step": 420,
"loss": 0.5810034871101379,
"text_loss": 0.24579037725925446,
"flow_loss": 0.16760656237602234
},
{
"stage": "recap",
"step": 440,
"loss": 0.40272825956344604,
"text_loss": 0.19410930573940277,
"flow_loss": 0.10430946946144104
},
{
"stage": "recap",
"step": 460,
"loss": 0.7170483469963074,
"text_loss": 0.32609468698501587,
"flow_loss": 0.19547683000564575
},
{
"stage": "recap",
"step": 480,
"loss": 0.3233756422996521,
"text_loss": 0.08512496948242188,
"flow_loss": 0.11912534385919571
},
{
"stage": "recap",
"step": 500,
"loss": 0.625529408454895,
"text_loss": 0.23650097846984863,
"flow_loss": 0.1945142149925232
},
{
"stage": "recap",
"step": 520,
"loss": 1.645598292350769,
"text_loss": 0.874957263469696,
"flow_loss": 0.3853205144405365
},
{
"stage": "recap",
"step": 540,
"loss": 0.8086838722229004,
"text_loss": 0.31192541122436523,
"flow_loss": 0.24837924540042877
},
{
"stage": "recap",
"step": 560,
"loss": 1.9226100444793701,
"text_loss": 0.8500325083732605,
"flow_loss": 0.5362887382507324
},
{
"stage": "recap",
"step": 580,
"loss": 2.138998031616211,
"text_loss": 0.7942134141921997,
"flow_loss": 0.6723923683166504
},
{
"stage": "recap",
"step": 600,
"loss": 0.504305362701416,
"text_loss": 0.13201656937599182,
"flow_loss": 0.1861443817615509
},
{
"stage": "recap",
"step": 620,
"loss": 0.5794067978858948,
"text_loss": 0.26564836502075195,
"flow_loss": 0.1568792164325714
},
{
"stage": "recap",
"step": 640,
"loss": 0.8541183471679688,
"text_loss": 0.154631569981575,
"flow_loss": 0.34974339604377747
},
{
"stage": "recap",
"step": 660,
"loss": 0.16945119202136993,
"text_loss": 0.056921038776636124,
"flow_loss": 0.056265074759721756
},
{
"stage": "recap",
"step": 680,
"loss": 0.47458726167678833,
"text_loss": 0.04364287480711937,
"flow_loss": 0.21547219157218933
},
{
"stage": "recap",
"step": 700,
"loss": 0.18534913659095764,
"text_loss": 0.05316398665308952,
"flow_loss": 0.06609257310628891
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+38
View File
@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "pi06-minimal-validation"
version = "0.1.0"
description = "A tiny educational pi0.6 and RECAP overfitting experiment"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "pi06 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]
pi06mini-train = "pi06mini.train:main"
pi06mini-infer = "pi06mini.infer:main"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
+32
View File
@@ -0,0 +1,32 @@
"""Minimal pi0.6 / Recap architecture validation package."""
from .model import Pi06Config, Pi06Mini, Pi06Output, flow_matching_sample
from .tokenizer import (
Advantage,
CoordinateTokenizer,
MiniTokenizer,
PolicySequence,
SubtaskVocabulary,
)
from .value import (
DistributionalValueModel,
ValueConfig,
ValueModel,
ValueOutput,
)
__all__ = [
"Advantage",
"CoordinateTokenizer",
"DistributionalValueModel",
"MiniTokenizer",
"Pi06Config",
"Pi06Mini",
"Pi06Output",
"PolicySequence",
"SubtaskVocabulary",
"ValueConfig",
"ValueModel",
"ValueOutput",
"flow_matching_sample",
]
+354
View File
@@ -0,0 +1,354 @@
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 .experience import (
ExperienceEpisode,
build_synthetic_experience,
discounted_returns,
n_step_advantages,
normalize_returns_to_unit_interval,
quantile_advantage_labels,
stack_episode_field,
)
DEFAULT_SOURCE_MANIFEST = (
Path(__file__).resolve().parents[4]
/ "alpamayo"
/ "alpamayo-qwen05b-validation"
/ "data"
/ "processed"
/ "overfit_manifest.jsonl"
)
@dataclass(frozen=True)
class SampleRecord:
clip_id: str
t0_us: int
image_path: Path
trajectory_path: Path
reasoning: str = ""
@property
def sample_id(self) -> str:
return f"{self.clip_id}_{self.t0_us}"
@dataclass(frozen=True)
class ActionStatistics:
mean: torch.Tensor
scale: torch.Tensor
def as_dict(self) -> dict[str, list[float]]:
return {
"mean": [float(value) for value in self.mean],
"scale": [float(value) for value in self.scale],
}
class ActionNormalizer:
"""Invertibly normalize XY waypoint deltas for flow matching."""
def __init__(self, mean: torch.Tensor, scale: torch.Tensor) -> None:
self.mean = torch.as_tensor(mean, dtype=torch.float32).reshape(2)
self.scale = torch.as_tensor(scale, dtype=torch.float32).reshape(2).clamp_min(0.05)
@staticmethod
def to_deltas(history_xy: torch.Tensor, future_xy: torch.Tensor) -> torch.Tensor:
history = torch.as_tensor(history_xy, dtype=torch.float32)
future = torch.as_tensor(future_xy, dtype=torch.float32)
if history.ndim != 2 or history.shape[-1] != 2 or len(history) < 1:
raise ValueError("history_xy must have shape [history_steps, 2]")
if future.ndim != 2 or future.shape[-1] != 2 or len(future) < 1:
raise ValueError("future_xy must have shape [action_horizon, 2]")
previous = torch.cat((history[-1:], future[:-1]), dim=0)
return future - previous
@classmethod
def fit(
cls, histories: Sequence[torch.Tensor], futures: Sequence[torch.Tensor]
) -> ActionNormalizer:
if not histories or len(histories) != len(futures):
raise ValueError("histories and futures must be non-empty and equally sized")
flat = torch.cat(
[
cls.to_deltas(history, future).reshape(-1, 2)
for history, future in zip(histories, futures, strict=True)
],
dim=0,
)
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())
def encode(self, history_xy: torch.Tensor, future_xy: torch.Tensor) -> torch.Tensor:
return (self.to_deltas(history_xy, future_xy) - self.mean) / self.scale
def decode(self, actions: torch.Tensor, history_xy: torch.Tensor) -> torch.Tensor:
actions = torch.as_tensor(actions, dtype=torch.float32)
history = torch.as_tensor(history_xy, dtype=torch.float32)
deltas = actions * self.scale.to(actions.device) + self.mean.to(actions.device)
return history[-1:].to(actions.device) + 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:
destination = Path(path).expanduser().resolve()
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(self.as_dict(), indent=2) + "\n", encoding="utf-8")
return destination
def _resolve_data_path(manifest_path: Path, raw_path: str | Path) -> Path:
path = Path(raw_path).expanduser()
if path.is_absolute():
return path.resolve()
candidates = [manifest_path.parent / path]
if len(manifest_path.parents) >= 3:
candidates.insert(0, manifest_path.parents[2] / path)
for candidate in candidates:
if candidate.exists():
return candidate.resolve()
return candidates[0].resolve()
def load_manifest(path: str | Path) -> list[SampleRecord]:
manifest_path = Path(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"]),
image_path=_resolve_data_path(manifest_path, row["image_path"]),
trajectory_path=_resolve_data_path(manifest_path, row["trajectory_path"]),
reasoning=str(row.get("reasoning", "")),
)
)
if not records:
raise ValueError(f"no samples found in {manifest_path}")
return records
def materialize_subset(
source_manifest: str | Path = DEFAULT_SOURCE_MANIFEST,
output_dir: str | Path = "data/overfit",
*,
max_samples: int = 1,
) -> Path:
"""Copy a self-contained PAI seed demonstration for synthetic experience."""
if max_samples < 1:
raise ValueError("max_samples must be positive")
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(load_manifest(source_manifest)[:max_samples]):
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)),
}
)
manifest = destination / "overfit_manifest.jsonl"
manifest.write_text(
"".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
encoding="utf-8",
)
return manifest
def _resample_xy(xy: torch.Tensor, steps: int) -> torch.Tensor:
xy = torch.as_tensor(xy, dtype=torch.float32)
if xy.ndim != 2 or xy.shape[-1] != 2 or len(xy) < 2:
raise ValueError("trajectory must contain at least two XY waypoints")
if len(xy) == steps:
return xy
return F.interpolate(
xy.T.unsqueeze(0), size=steps, mode="linear", align_corners=True
)[0].T
class Pi06ExperienceDataset(Dataset[dict[str, Any]]):
"""Four-source ReCAP overfit buffer synthesized from each PAI demonstration."""
def __init__(
self,
manifest_path: str | Path,
*,
image_size: int = 64,
augmentation_factor: int = 1,
history_steps: int = 8,
action_horizon: int = 16,
n_step: int = 4,
gamma: float = 0.99,
positive_quantile: float = 0.7,
seed: int = 7,
action_statistics: ActionStatistics | dict[str, Sequence[float]] | None = None,
) -> None:
if image_size < 1 or augmentation_factor < 1:
raise ValueError("image_size and augmentation_factor must be positive")
self.records = load_manifest(manifest_path)
self.image_size = int(image_size)
self.augmentation_factor = int(augmentation_factor)
self.history_steps = int(history_steps)
self.action_horizon = int(action_horizon)
self.seed = int(seed)
self.state_dim = self.history_steps * 2
self.action_dim = 2
episodes: list[ExperienceEpisode] = []
episode_record_indices: list[int] = []
for record_index, record in enumerate(self.records):
with np.load(record.trajectory_path) as arrays:
history = _resample_xy(
torch.from_numpy(np.asarray(arrays["history_xyz"], dtype=np.float32))[:, :2],
self.history_steps,
)
future = _resample_xy(
torch.from_numpy(np.asarray(arrays["future_xyz"], dtype=np.float32))[:, :2],
self.action_horizon,
)
generated = build_synthetic_experience(
history, future, episode_prefix=record.sample_id
)
episodes.extend(generated)
episode_record_indices.extend([record_index] * len(generated))
self.episodes = episodes
self._episode_record_indices = episode_record_indices
raw_rewards = stack_episode_field(episodes, "rewards")
raw_returns = discounted_returns(raw_rewards, gamma=gamma)
self.normalized_returns = normalize_returns_to_unit_interval(raw_returns)
baseline = raw_returns.mean(dim=0, keepdim=True).expand_as(raw_returns)
dones = torch.zeros_like(raw_rewards, dtype=torch.bool)
dones[:, -1] = True
self.advantages = n_step_advantages(
raw_rewards, baseline, n_step=n_step, gamma=gamma, dones=dones
)
intervention_masks = stack_episode_field(episodes, "intervention_mask").bool()
self.positive_steps, self.advantage_threshold = quantile_advantage_labels(
self.advantages,
quantile=positive_quantile,
intervention_mask=intervention_masks,
)
episode_advantages = self.advantages.mean(dim=-1)
episode_interventions = intervention_masks.any(dim=-1)
self.positive_episodes, self.episode_advantage_threshold = (
quantile_advantage_labels(
episode_advantages,
quantile=positive_quantile,
intervention_mask=episode_interventions,
)
)
if action_statistics is None:
self.action_normalizer = ActionNormalizer.fit(
[episode.history_xy for episode in episodes],
[episode.future_xy for episode in episodes],
)
elif isinstance(action_statistics, ActionStatistics):
self.action_normalizer = ActionNormalizer(
action_statistics.mean, action_statistics.scale
)
else:
self.action_normalizer = ActionNormalizer.from_dict(action_statistics)
@property
def action_statistics(self) -> ActionStatistics:
return self.action_normalizer.statistics
def save_action_statistics(self, path: str | Path) -> Path:
return self.action_normalizer.save(path)
def __len__(self) -> int:
return len(self.episodes) * 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 _load_image(self, record_index: int, variant: int) -> torch.Tensor:
with Image.open(self.records[record_index].image_path) as source:
image = source.convert("RGB")
if variant:
rng = np.random.default_rng(
np.random.SeedSequence([self.seed, record_index, variant])
)
image = ImageEnhance.Brightness(image).enhance(float(rng.uniform(0.96, 1.04)))
image = ImageEnhance.Contrast(image).enhance(float(rng.uniform(0.97, 1.03)))
image = image.resize(
(self.image_size, self.image_size), 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]:
episode_index, variant = self._indices(index)
episode = self.episodes[episode_index]
record_index = self._episode_record_indices[episode_index]
actions = self.action_normalizer.encode(
episode.history_xy, episode.future_xy
)
return {
"image": self._load_image(record_index, variant),
"state": episode.history_xy.reshape(-1),
"actions": actions,
"history_xy": episode.history_xy.clone(),
"future_xy": episode.future_xy.clone(),
"rewards": episode.rewards.clone(),
"returns": self.normalized_returns[episode_index].clone(),
# The critic predicts the beginning-of-episode return for this
# minimal trajectory-level buffer; the full vector remains
# available for diagnostics and advantage recomputation.
"return_target": self.normalized_returns[episode_index, 0].clone(),
"value_target": self.normalized_returns[episode_index, 0].clone(),
"advantages": self.advantages[episode_index].clone(),
"positive_steps": self.positive_steps[episode_index].clone(),
"advantage": self.advantages[episode_index].mean().clone(),
"positive_condition": self.positive_episodes[episode_index].clone(),
"advantage_label": (
"positive" if bool(self.positive_episodes[episode_index]) else "negative"
),
"intervention_mask": episode.intervention_mask.clone(),
"is_intervention": bool(episode.intervention_mask.any()),
"source_type": episode.source_type,
"reasoning": self.records[record_index].reasoning,
"sample_id": f"{episode.episode_id}:aug{variant}",
}
+252
View File
@@ -0,0 +1,252 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal, Sequence
import torch
ExperienceSource = Literal[
"demo_success",
"autonomous_understeer",
"autonomous_stop",
"intervention_correction",
]
@dataclass(frozen=True)
class ExperienceEpisode:
"""A tiny trajectory-level stand-in for the heterogeneous pi0.6 experience buffer."""
episode_id: str
source_type: ExperienceSource
history_xy: torch.Tensor
future_xy: torch.Tensor
rewards: torch.Tensor
intervention_mask: torch.Tensor
metadata: dict[str, float | str] = field(default_factory=dict)
def __post_init__(self) -> None:
history = torch.as_tensor(self.history_xy, dtype=torch.float32)
future = torch.as_tensor(self.future_xy, dtype=torch.float32)
rewards = torch.as_tensor(self.rewards, dtype=torch.float32)
intervention = torch.as_tensor(self.intervention_mask, dtype=torch.bool)
if history.ndim != 2 or history.shape[-1] != 2 or len(history) < 1:
raise ValueError("history_xy must have shape [history_steps, 2]")
if future.ndim != 2 or future.shape[-1] != 2 or len(future) < 2:
raise ValueError("future_xy must have shape [horizon, 2]")
if rewards.shape != (len(future),):
raise ValueError("rewards must have one value per future waypoint")
if intervention.shape != rewards.shape:
raise ValueError("intervention_mask must match rewards")
object.__setattr__(self, "history_xy", history)
object.__setattr__(self, "future_xy", future)
object.__setattr__(self, "rewards", rewards)
object.__setattr__(self, "intervention_mask", intervention)
def discounted_returns(rewards: torch.Tensor, gamma: float = 0.99) -> torch.Tensor:
"""Compute reward-to-go while preserving arbitrary leading batch dimensions."""
if not 0.0 <= gamma <= 1.0:
raise ValueError("gamma must lie in [0, 1]")
rewards = torch.as_tensor(rewards, dtype=torch.float32)
if rewards.ndim < 1 or rewards.shape[-1] < 1:
raise ValueError("rewards must have a non-empty time dimension")
returns = torch.empty_like(rewards)
running = torch.zeros_like(rewards[..., 0])
for index in range(rewards.shape[-1] - 1, -1, -1):
running = rewards[..., index] + gamma * running
returns[..., index] = running
return returns
def normalize_returns_to_unit_interval(returns: torch.Tensor) -> torch.Tensor:
"""Map the worst observed return to -1 and the best to 0.
pi0.6 uses non-positive reward conditioning. A constant collection is
treated as equally optimal and therefore maps to zero.
"""
returns = torch.as_tensor(returns, dtype=torch.float32)
if returns.numel() == 0:
raise ValueError("returns must be non-empty")
minimum = returns.amin()
maximum = returns.amax()
span = maximum - minimum
if float(span) <= torch.finfo(returns.dtype).eps:
return torch.zeros_like(returns)
return ((returns - maximum) / span).clamp(-1.0, 0.0)
def n_step_advantages(
rewards: torch.Tensor,
values: torch.Tensor,
*,
n_step: int = 4,
gamma: float = 0.99,
dones: torch.Tensor | None = None,
) -> torch.Tensor:
"""Estimate A(s_t,a_t) from an n-step bootstrapped target.
``values`` may contain T values (the final bootstrap is zero) or T+1
values. ``dones`` stops both reward accumulation and bootstrapping.
"""
rewards = torch.as_tensor(rewards, dtype=torch.float32)
values = torch.as_tensor(values, dtype=torch.float32, device=rewards.device)
if rewards.ndim < 1 or rewards.shape[-1] < 1:
raise ValueError("rewards must have a non-empty time dimension")
if n_step < 1:
raise ValueError("n_step must be positive")
if not 0.0 <= gamma <= 1.0:
raise ValueError("gamma must lie in [0, 1]")
horizon = rewards.shape[-1]
if values.shape[:-1] != rewards.shape[:-1] or values.shape[-1] not in {
horizon,
horizon + 1,
}:
raise ValueError("values must have shape [..., T] or [..., T+1]")
if values.shape[-1] == horizon:
values = torch.cat((values, torch.zeros_like(values[..., :1])), dim=-1)
if dones is None:
dones = torch.zeros_like(rewards, dtype=torch.bool)
dones[..., -1] = True
else:
dones = torch.as_tensor(dones, dtype=torch.bool, device=rewards.device)
if dones.shape != rewards.shape:
raise ValueError("dones must match rewards")
advantages = torch.empty_like(rewards)
for start in range(horizon):
target = torch.zeros_like(rewards[..., 0])
alive = torch.ones_like(target)
discount = 1.0
stop = min(start + n_step, horizon)
for index in range(start, stop):
target = target + discount * alive * rewards[..., index]
alive = alive * (~dones[..., index]).to(rewards.dtype)
discount *= gamma
# These inputs are complete episodes rather than replay fragments, so
# reaching the final recorded step is terminal even if callers omit a
# final done flag. Bootstrap only when the n-step window ends earlier.
if stop < horizon:
target = target + discount * alive * values[..., stop]
advantages[..., start] = target - values[..., start]
return advantages
def quantile_advantage_labels(
advantages: torch.Tensor,
*,
quantile: float = 0.7,
intervention_mask: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Convert advantages into ReCAP positive/negative conditions.
Intervention actions are always positive, even when their estimated
numerical advantage falls below the empirical threshold.
"""
advantages = torch.as_tensor(advantages, dtype=torch.float32)
if advantages.numel() == 0:
raise ValueError("advantages must be non-empty")
if not 0.0 <= quantile <= 1.0:
raise ValueError("quantile must lie in [0, 1]")
threshold = torch.quantile(advantages.reshape(-1), quantile)
labels = advantages >= threshold
if intervention_mask is not None:
intervention_mask = torch.as_tensor(
intervention_mask, dtype=torch.bool, device=advantages.device
)
if intervention_mask.shape != advantages.shape:
raise ValueError("intervention_mask must match advantages")
labels = labels | intervention_mask
return labels, threshold
def _trajectory_rewards(candidate: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
error = torch.linalg.vector_norm(candidate - target, dim=-1)
scale = torch.linalg.vector_norm(target - target[:1], dim=-1).amax().clamp_min(1.0)
return -(error / scale).clamp(0.0, 1.0)
def build_synthetic_experience(
history_xy: torch.Tensor,
expert_future_xy: torch.Tensor,
*,
episode_prefix: str = "sample",
) -> list[ExperienceEpisode]:
"""Build four deterministic experience sources from one PAI demonstration.
The small buffer deliberately includes successful demonstrations, two
autonomous failure modes, and a human correction. It is only intended to
validate the pi0.6/ReCAP data path and overfit behavior.
"""
history = torch.as_tensor(history_xy, dtype=torch.float32)
expert = torch.as_tensor(expert_future_xy, dtype=torch.float32)
if history.ndim != 2 or history.shape[-1] != 2 or len(history) < 1:
raise ValueError("history_xy must have shape [history_steps, 2]")
if expert.ndim != 2 or expert.shape[-1] != 2 or len(expert) < 4:
raise ValueError("expert_future_xy must have shape [horizon>=4, 2]")
horizon = len(expert)
anchor = history[-1]
displacement = expert - anchor
understeer = anchor + displacement * expert.new_tensor([1.0, 0.2])
stop_index = max(2, horizon // 3)
stopped = expert.clone()
stopped[stop_index:] = stopped[stop_index - 1]
progress = torch.linspace(0.0, 1.0, horizon, dtype=expert.dtype).unsqueeze(-1)
correction_weight = ((progress - 0.2) / 0.55).clamp(0.0, 1.0)
correction = understeer * (1.0 - correction_weight) + expert * correction_weight
intervention = torch.arange(horizon) >= max(1, horizon // 4)
zero_rewards = torch.zeros(horizon, dtype=expert.dtype)
return [
ExperienceEpisode(
f"{episode_prefix}:demo",
"demo_success",
history,
expert,
zero_rewards,
torch.zeros(horizon, dtype=torch.bool),
),
ExperienceEpisode(
f"{episode_prefix}:understeer",
"autonomous_understeer",
history,
understeer,
_trajectory_rewards(understeer, expert),
torch.zeros(horizon, dtype=torch.bool),
{"failure": "understeer"},
),
ExperienceEpisode(
f"{episode_prefix}:stop",
"autonomous_stop",
history,
stopped,
_trajectory_rewards(stopped, expert),
torch.zeros(horizon, dtype=torch.bool),
{"failure": "premature_stop"},
),
ExperienceEpisode(
f"{episode_prefix}:intervention",
"intervention_correction",
history,
correction,
_trajectory_rewards(correction, expert),
intervention,
{"failure": "understeer_then_corrected"},
),
]
def stack_episode_field(
episodes: Sequence[ExperienceEpisode], field_name: str
) -> torch.Tensor:
if not episodes:
raise ValueError("episodes must be non-empty")
return torch.stack([torch.as_tensor(getattr(item, field_name)) for item in episodes])
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from collections.abc import Callable
import torch
from torch import Tensor
class FlowMatching:
"""Low-timestep-biased rectified flow with pi*0.6 noise weighting."""
def __init__(self, inference_steps: int = 5, cutoff: float = 0.999) -> None:
self.inference_steps = inference_steps
self.cutoff = cutoff
def training_batch(self, target: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]:
batch = target.shape[0]
uniform = torch.rand(batch, device=target.device, dtype=target.dtype)
timestep = self.cutoff * (1.0 - uniform.pow(1.0 / 1.5))
noise = torch.randn_like(target)
noisy = timestep[:, None, None] * target + (1 - timestep[:, None, None]) * noise
velocity = target - noise
weight = torch.exp(-timestep / 2.0)
return noisy, timestep, velocity, weight
@staticmethod
def weighted_loss(prediction: Tensor, target: Tensor, weight: Tensor) -> Tensor:
per_example = (prediction.float() - target.float()).square().mean(dim=(1, 2))
return (per_example * weight.float()).mean()
@torch.no_grad()
def sample(
self,
model_fn: Callable[[Tensor, Tensor], Tensor],
shape: tuple[int, ...],
device: torch.device | str,
steps: int | None = None,
) -> Tensor:
count = steps or self.inference_steps
x = torch.randn(shape, device=device)
grid = torch.linspace(0.0, 1.0, count + 1, device=device)
for index in range(count):
t = grid[index].expand(shape[0])
x = x + (grid[index + 1] - grid[index]) * model_fn(x, t)
return x
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import argparse
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import torch
from .data import Pi06ExperienceDataset
from .flow import FlowMatching
from .model import Pi06Config, Pi06Mini
from .recap import classifier_free_guidance
from .runtime import choose_device, write_json
from .tokenizer import MiniTokenizer
from .value import ValueConfig, ValueModel
@torch.no_grad()
def evaluate(checkpoint_path: str) -> tuple[Path, 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")); tokenizer=MiniTokenizer()
dataset=Pi06ExperienceDataset(
root/"data/overfit/overfit_manifest.jsonl",image_size=cfg["data"]["image_size"],
augmentation_factor=cfg["experience"]["augmentation_factor"],
action_horizon=cfg["data"]["action_horizon"],n_step=cfg["experience"]["n_step"],
gamma=cfg["experience"]["gamma"],positive_quantile=cfg["experience"]["positive_quantile"],
action_statistics=checkpoint["action_statistics"],
)
mixed=Pi06Mini(Pi06Config(**checkpoint["policy_config"]));mixed.load_state_dict(checkpoint["mixed"]);mixed.to(device).eval()
recap=Pi06Mini(Pi06Config(**checkpoint["policy_config"]));recap.load_state_dict(checkpoint["recap"]);recap.to(device).eval()
value=ValueModel(ValueConfig(**checkpoint["value_config"]));value.load_state_dict(checkpoint["value"]);value.to(device).eval()
flow=FlowMatching(cfg["inference"]["flow_steps"])
demo=next(dataset[i] for i in range(len(dataset)) if dataset[i]["source_type"]=="demo_success")
failure=next(dataset[i] for i in range(len(dataset)) if "stop" in dataset[i]["source_type"])
image=demo["image"][None].to(device)
def condition(model:Pi06Mini, advantage:str|None, x:torch.Tensor,t:torch.Tensor)->torch.Tensor:
seq=tokenizer.build_policy_sequence(
demo["history_xy"],demo["reasoning"],demo["actions"],advantage,
metadata=None,
)
ids=seq.input_ids[:seq.action_start_index][None].to(device)
return model(image,ids,x,t,generated_token_start=seq.generated_token_start).vector_field
def sample_field(fn,seed:int=2):
torch.manual_seed(seed)
return flow.sample(fn,(1,dataset.action_horizon,2),device,cfg["inference"]["flow_steps"])[0].cpu()
mixed_actions=sample_field(lambda x,t:condition(mixed,None,x,t))
uncond_actions=sample_field(lambda x,t:condition(recap,None,x,t))
positive_actions=sample_field(lambda x,t:condition(recap,"positive",x,t))
negative_actions=sample_field(lambda x,t:condition(recap,"negative",x,t))
def cfg_field(x,t):
return classifier_free_guidance(
condition(recap,"positive",x,t),condition(recap,None,x,t),
guidance_scale=cfg["inference"]["cfg_beta"],
)
cfg_actions=sample_field(cfg_field)
decoded={
"mixed":dataset.action_normalizer.decode(mixed_actions,demo["history_xy"]).numpy(),
"unconditional":dataset.action_normalizer.decode(uncond_actions,demo["history_xy"]).numpy(),
"positive":dataset.action_normalizer.decode(positive_actions,demo["history_xy"]).numpy(),
"negative":dataset.action_normalizer.decode(negative_actions,demo["history_xy"]).numpy(),
"cfg":dataset.action_normalizer.decode(cfg_actions,demo["history_xy"]).numpy(),
}
target=demo["future_xy"].numpy(); failure_xy=failure["future_xy"].numpy()
metrics={name:float(np.mean((xy-target)**2)) for name,xy in decoded.items()}
metrics["negative_to_failure_mse"]=float(np.mean((decoded["negative"]-failure_xy)**2))
fig,ax=plt.subplots(figsize=(9,6));ax.plot(target[:,0],target[:,1],"o-",label="success target",linewidth=2)
ax.plot(failure_xy[:,0],failure_xy[:,1],":",label="failure experience")
for name,color in [("mixed","gray"),("unconditional","purple"),("positive","green"),("negative","red"),("cfg","orange")]:
xy=decoded[name];ax.plot(xy[:,0],xy[:,1],"x--",label=f"{name} ({metrics[name]:.3f})",color=color)
ax.scatter([0],[0],c="black",marker="s",label="ego");ax.axis("equal");ax.grid(True,alpha=.3);ax.legend(fontsize=8)
ax.set_xlabel("forward x (m)");ax.set_ylabel("left y (m)");ax.set_title("π*0.6 Recap advantage-conditioned trajectories")
outdir=Path(checkpoint_path).parent;trajectory_path=outdir/"recap_trajectory_comparison.png";fig.tight_layout();fig.savefig(trajectory_path,dpi=170);plt.close(fig)
value_rows=[]
for i in range(len(dataset)):
s=dataset[i];ids=tokenizer.build_value_input(s["history_xy"],s["source_type"])[None].to(device)
prediction=float(value(s["image"][None].to(device),ids).expectation.cpu())
value_rows.append({"source_type":s["source_type"],"target":float(s["value_target"]),"prediction":prediction})
fig,ax=plt.subplots(figsize=(8,4));x=np.arange(len(value_rows));w=.35
ax.bar(x-w/2,[r["target"] for r in value_rows],w,label="return target")
ax.bar(x+w/2,[r["prediction"] for r in value_rows],w,label="value prediction")
ax.set_xticks(x,[r["source_type"] for r in value_rows],rotation=20,ha="right");ax.set_ylim(-1.05,.05);ax.grid(True,axis="y",alpha=.3);ax.legend();ax.set_title("201-bin distributional value overfit")
value_path=outdir/"value_comparison.png";fig.tight_layout();fig.savefig(value_path,dpi=170);plt.close(fig)
write_json(outdir/"evaluation.json",{"trajectory_metrics":metrics,"values":value_rows})
print({"trajectory_metrics":metrics,"values":value_rows})
return trajectory_path,value_path
def main():
parser=argparse.ArgumentParser();parser.add_argument("--checkpoint",default="outputs/overfit/checkpoint.pt")
evaluate(parser.parse_args().checkpoint)
if __name__=="__main__":main()
+444
View File
@@ -0,0 +1,444 @@
r"""Minimal :math:`\pi^*_{0.6}` policy with Knowledge Insulation.
This is intentionally a small, pure-PyTorch architecture test rather than a
scale reproduction. It preserves the paper's important information paths:
* a VLM stream predicts subtask and FAST-lite action tokens;
* an optional positive/negative advantage token sits after the subtask and
before both action representations;
* a separately parameterized flow expert reads observation/subtask/advantage;
* flow queries never read teacher-forced FAST-lite tokens; and
* VLM keys and values are stop-gradient inputs to the flow expert (Knowledge
Insulation), so continuous-action loss cannot update the VLM.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Iterator, Optional
import torch
from torch import Tensor, nn
import torch.nn.functional as F
@dataclass
class Pi06Config:
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 = 96
max_action_tokens: int = 32
action_start_token_id: int = 269
dropout: float = 0.0
rms_eps: float = 1e-6
def __post_init__(self) -> None:
if self.d_model % self.n_heads:
raise ValueError("d_model must be divisible by n_heads")
if self.image_size % self.patch_size:
raise ValueError("image_size must be divisible by patch_size")
if not 0 <= self.action_start_token_id < self.vocab_size:
raise ValueError("action_start_token_id must be inside the vocabulary")
@property
def 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 Pi06Output:
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:
rms = x.float().pow(2).mean(dim=-1, keepdim=True)
return x * torch.rsqrt(rms.to(x.dtype) + self.eps) * self.weight
class AdaRMSNorm(nn.Module):
"""RMSNorm modulated at every flow-expert layer by the flow timestep."""
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)
nn.init.zeros_(self.modulation.weight)
nn.init.zeros_(self.modulation.bias)
def forward(self, x: Tensor, condition: Tensor) -> Tensor:
shift, scale = self.modulation(condition).chunk(2, dim=-1)
return self.norm(x) * (1 + scale[:, None]) + shift[:, None]
class TimeEmbedding(nn.Module):
def __init__(self, dim: int) -> None:
super().__init__()
fourier_dim = max(8, dim // 2)
self.fourier_dim = fourier_dim + fourier_dim % 2
self.mlp = nn.Sequential(
nn.Linear(self.fourier_dim, dim), nn.SiLU(), nn.Linear(dim, dim)
)
def forward(self, timestep: Tensor) -> Tensor:
timestep = timestep.reshape(-1).float()
half = self.fourier_dim // 2
frequencies = torch.exp(
torch.linspace(
math.log(1.0), math.log(1000.0), half,
device=timestep.device, dtype=timestep.dtype,
)
)
angles = 2 * math.pi * timestep[:, None] * frequencies[None]
return self.mlp(torch.cat((angles.sin(), angles.cos()), dim=-1))
class AttentionWeights(nn.Module):
"""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
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:
scores = scores.masked_fill(
~allowed[:, None], torch.finfo(scores.dtype).min
)
probabilities = scores.float().softmax(dim=-1).to(scores.dtype)
probabilities = F.dropout(probabilities, p=dropout, training=training)
output = probabilities @ v
return output.transpose(1, 2).contiguous().reshape(batch, q_len, width)
class KnowledgeInsulatedBlock(nn.Module):
"""A dual-expert block whose VLM-to-flow connection is stop-gradient."""
def __init__(self, config: Pi06Config) -> None:
super().__init__()
d = config.d_model
self.n_heads = config.n_heads
self.dropout = config.dropout
self.vlm_norm1 = RMSNorm(d, config.rms_eps)
self.vlm_attention = AttentionWeights(d)
self.vlm_norm2 = RMSNorm(d, config.rms_eps)
self.vlm_mlp = MLP(d, config.mlp_ratio, config.dropout)
self.action_norm1 = AdaRMSNorm(d, d, config.rms_eps)
self.action_attention = AttentionWeights(d)
self.action_norm2 = AdaRMSNorm(d, d, config.rms_eps)
self.action_mlp = MLP(d, config.mlp_ratio, config.dropout)
def forward(
self,
prefix: Tensor,
action: Tensor,
time_condition: Tensor,
prefix_edges: Tensor,
action_edges: Tensor,
) -> tuple[Tensor, Tensor]:
prefix_input = self.vlm_norm1(prefix)
p = self.vlm_attention
prefix_update = attention(
p.q(prefix_input), p.k(prefix_input), p.v(prefix_input),
self.n_heads, prefix_edges, self.dropout, self.training,
)
prefix = prefix + p.out(prefix_update)
prefix = prefix + self.vlm_mlp(self.vlm_norm2(prefix))
action_input = self.action_norm1(action, time_condition)
a = self.action_attention
# Knowledge Insulation: the flow expert may consume VLM activations,
# but the flow loss cannot alter either those activations or the VLM's
# K/V projection weights. Text loss still trains the VLM normally.
prefix_key = p.k(prefix_input).detach()
prefix_value = p.v(prefix_input).detach()
key = torch.cat((prefix_key, a.k(action_input)), dim=1)
value = torch.cat((prefix_value, 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 Pi06Mini(nn.Module):
r"""Small policy retaining the structural contracts of :math:`\pi^*_{0.6}`."""
def __init__(self, config: Optional[Pi06Config] = None) -> None:
super().__init__()
self.config = config or Pi06Config()
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(
KnowledgeInsulatedBlock(c) for _ in range(c.n_layers)
)
self.prefix_norm = RMSNorm(c.d_model, c.rms_eps)
self.action_norm = AdaRMSNorm(c.d_model, c.d_model, c.rms_eps)
self.text_head = nn.Linear(c.d_model, c.vocab_size, bias=False)
self.vector_field_head = nn.Linear(c.d_model, c.action_dim)
self.text_head.weight = self.token_embedding.weight
def encode_prefix(self, pixel_values: Tensor, input_ids: Tensor) -> tuple[Tensor, int]:
patches = self.patch_encoder(pixel_values).flatten(2).transpose(1, 2)
prefix = torch.cat((patches, self.token_embedding(input_ids)), dim=1)
if prefix.shape[1] > self.config.max_prefix_tokens:
raise ValueError("prefix exceeds configured maximum length")
return prefix + self.prefix_position[:, : prefix.shape[1]], patches.shape[1]
def _derive_action_prefix_mask(self, input_ids: Tensor) -> Tensor:
"""Allow flow to read every token strictly before the first ACTION_START."""
is_action = input_ids.eq(self.config.action_start_token_id)
# If there is no teacher-forced FAST stream (normal flow inference),
# every supplied observation/subtask/advantage token is readable.
return is_action.long().cumsum(dim=1).eq(0)
def _masks(
self,
batch: int,
prefix_length: int,
action_length: int,
text_offset: int,
attention_mask: Optional[Tensor],
action_prefix_mask: 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()
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]
identity = torch.eye(prefix_length, dtype=torch.bool, device=device)[None]
prefix_edges = torch.where(valid[:, :, None], prefix_edges, identity)
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")
readable = valid & supplied
prefix_keys = readable[:, None].expand(batch, action_length, prefix_length)
action_keys = torch.ones(
batch, action_length, action_length, dtype=torch.bool, device=device
)
return prefix_edges, torch.cat((prefix_keys, action_keys), dim=-1)
def vlm_parameters(self) -> Iterator[nn.Parameter]:
"""Iterate unique parameters protected from flow-loss gradients by KI."""
modules: tuple[nn.Module, ...] = (
self.patch_encoder,
self.token_embedding,
self.prefix_norm,
self.text_head,
)
seen: set[int] = set()
for parameter in (self.prefix_position,):
seen.add(id(parameter))
yield parameter
for layer in self.layers:
modules += (
layer.vlm_norm1,
layer.vlm_attention,
layer.vlm_norm2,
layer.vlm_mlp,
)
for module in modules:
for parameter in module.parameters():
if id(parameter) not in seen:
seen.add(id(parameter))
yield parameter
def action_parameters(self) -> Iterator[nn.Parameter]:
"""Iterate parameters trained by the continuous flow loss."""
modules: list[nn.Module] = [
self.action_projection,
self.time_embedding,
self.action_norm,
self.vector_field_head,
]
for layer in self.layers:
modules.extend(
(
layer.action_norm1,
layer.action_attention,
layer.action_norm2,
layer.action_mlp,
)
)
yield self.action_position
for module in modules:
yield from module.parameters()
def forward(
self,
pixel_values: Tensor,
input_ids: Tensor,
noisy_actions: Tensor,
timesteps: Tensor,
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,
) -> Pi06Output:
if noisy_actions.ndim != 3:
raise ValueError("noisy_actions must have shape [batch, horizon, action_dim]")
if noisy_actions.shape[-1] != self.config.action_dim:
raise ValueError("noisy_actions has the wrong action dimension")
if noisy_actions.shape[1] > self.config.max_action_tokens:
raise ValueError("action horizon exceeds configured maximum")
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)
if action_prefix_mask is None:
action_prefix_mask = self._derive_action_prefix_mask(input_ids)
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)
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)
terms = [
term for term in (
None if text_loss is None else text_loss_weight * text_loss,
None if flow_loss is None else flow_loss_weight * flow_loss,
) if term is not None
]
loss = sum(terms) if terms else None
return Pi06Output(
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 the linear conditional-flow target used by the policy."""
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
return noisy_actions, timestep, actions - noise
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
import torch
def condition_dropout_mask(
batch_size: int,
*,
drop_probability: float = 0.3,
device: torch.device | str | None = None,
generator: torch.Generator | None = None,
) -> torch.Tensor:
"""Sample the 30% advantage-condition dropout used for CFG training."""
if batch_size < 1:
raise ValueError("batch_size must be positive")
if not 0.0 <= drop_probability <= 1.0:
raise ValueError("drop_probability must lie in [0, 1]")
return torch.rand(batch_size, device=device, generator=generator) < drop_probability
def apply_condition_dropout(
condition: torch.Tensor,
null_condition: torch.Tensor | float | int = 0,
*,
drop_probability: float = 0.3,
mask: torch.Tensor | None = None,
generator: torch.Generator | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Replace complete per-example ReCAP conditions with a null condition."""
condition = torch.as_tensor(condition)
if condition.ndim < 1 or condition.shape[0] < 1:
raise ValueError("condition must have a non-empty batch dimension")
if mask is None:
mask = condition_dropout_mask(
condition.shape[0],
drop_probability=drop_probability,
device=condition.device,
generator=generator,
)
else:
mask = torch.as_tensor(mask, dtype=torch.bool, device=condition.device)
if mask.shape != (condition.shape[0],):
raise ValueError("mask must have shape [batch_size]")
null = torch.as_tensor(null_condition, dtype=condition.dtype, device=condition.device)
batch_mask = mask
while mask.ndim < condition.ndim:
mask = mask.unsqueeze(-1)
dropped = torch.where(mask, null, condition)
return dropped, batch_mask
def classifier_free_guidance(
conditional_prediction: torch.Tensor,
unconditional_prediction: torch.Tensor,
*,
guidance_scale: float = 1.5,
) -> torch.Tensor:
"""Combine conditional and null-condition flow fields.
A scale of zero returns the unconditional prediction and a scale of one
returns the ordinary positive-condition prediction.
"""
conditional_prediction = torch.as_tensor(conditional_prediction)
unconditional_prediction = torch.as_tensor(
unconditional_prediction,
dtype=conditional_prediction.dtype,
device=conditional_prediction.device,
)
if conditional_prediction.shape != unconditional_prediction.shape:
raise ValueError("conditional and unconditional predictions must have equal shape")
if guidance_scale < 0.0:
raise ValueError("guidance_scale must be non-negative")
return unconditional_prediction + guidance_scale * (
conditional_prediction - unconditional_prediction
)
+29
View File
@@ -0,0 +1,29 @@
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(name: str = "auto") -> torch.device:
if name != "auto": return torch.device(name)
if torch.cuda.is_available(): return torch.device("cuda")
if torch.backends.mps.is_available(): return torch.device("mps")
return torch.device("cpu")
def write_json(path: 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")
+330
View File
@@ -0,0 +1,330 @@
r"""Tiny discrete state, subtask, advantage, and FAST-lite tokenization.
The layout mirrors the ordering used by :math:`\pi^*_{0.6}`: the model first
predicts a subtask, then receives an optional advantage indicator, and only
then predicts the discrete action representation. ``omitted`` is represented
by the physical absence of an indicator token, as required for unconditional
classifier-free guidance training.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Iterable, Literal
import torch
from torch import Tensor
Advantage = Literal["positive", "negative", "omitted"]
@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 literal byte tokens 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: Tensor | Iterable[Iterable[float]]) -> 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)).long()
def decode(self, tokens: Tensor | Iterable[Iterable[int]]) -> 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])
high = torch.tensor([axis.maximum for axis in self.ranges])
return low + encoded.float() / (self.bins - 1) * (high - low)
class SubtaskVocabulary:
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 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()
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 | Tensor) -> str:
try:
return self._token_to_label[int(token_id)]
except KeyError as error:
raise ValueError(f"unknown subtask token: {int(token_id)}") from error
@dataclass(frozen=True)
class PolicySequence:
"""One policy token sequence plus masks needed by ``Pi06Mini``.
``labels`` is already shifted for next-token prediction. The advantage
token itself is never a prediction target because it is externally
supplied conditioning. ``action_prefix_mask`` allows the continuous
expert to read observation, predicted subtask, and advantage (if present),
while insulating it from teacher-forced FAST-lite tokens.
"""
input_ids: Tensor
labels: Tensor
generated_token_start: int
action_prefix_mask: Tensor
subtask_index: int
advantage_index: int | None
action_start_index: int
class MiniTokenizer:
r"""Compose the minimal :math:`\pi^*_{0.6}` policy vocabulary."""
BOS = 256
IMAGE = 257
STATE = 258
SUBTASK_QUERY = 259
ADVANTAGE_POSITIVE = SUBTASK_QUERY + 1 + len(SubtaskVocabulary.labels)
ADVANTAGE_NEGATIVE = ADVANTAGE_POSITIVE + 1
ACTION_START = ADVANTAGE_NEGATIVE + 1
ACTION_END = ACTION_START + 1
ACTION_TOKEN_START = ACTION_END + 1
ACTION_BINS = 64
ACTION_TOKEN_END = ACTION_TOKEN_START + ACTION_BINS - 1
METADATA_DEMO = ACTION_TOKEN_END + 1
METADATA_UNDERSTEER = METADATA_DEMO + 1
METADATA_STOP = METADATA_DEMO + 2
METADATA_INTERVENTION = METADATA_DEMO + 3
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 self.METADATA_INTERVENTION + 1
def encode_metadata(self, source_type: str | None) -> Tensor | None:
if source_type is None:
return None
if source_type == "demo_success": return torch.tensor(self.METADATA_DEMO)
if "understeer" in source_type: return torch.tensor(self.METADATA_UNDERSTEER)
if "stop" in source_type: return torch.tensor(self.METADATA_STOP)
if "intervention" in source_type: return torch.tensor(self.METADATA_INTERVENTION)
raise ValueError(f"unknown metadata source_type: {source_type}")
@property
def action_token_ids(self) -> range:
return range(self.ACTION_TOKEN_START, self.ACTION_TOKEN_END + 1)
def build_observation(self, history_xy: Tensor, metadata: str | None = None) -> Tensor:
coordinate_tokens = self.coordinates.encode(history_xy).reshape(-1).cpu()
specials = torch.tensor([self.BOS, self.IMAGE, self.STATE], dtype=torch.long)
query = torch.tensor([self.SUBTASK_QUERY], dtype=torch.long)
parts = [specials, coordinate_tokens]
metadata_token = self.encode_metadata(metadata)
if metadata_token is not None: parts.append(metadata_token.reshape(1))
parts.append(query)
return torch.cat(parts)
def build_value_input(self, history_xy: Tensor, metadata: str | None = None) -> Tensor:
"""Return image marker plus discretized state, without policy query tokens."""
coordinate_tokens = self.coordinates.encode(history_xy).reshape(-1).cpu()
specials = torch.tensor([self.BOS, self.IMAGE, self.STATE], dtype=torch.long)
parts = [specials, coordinate_tokens]
metadata_token = self.encode_metadata(metadata)
if metadata_token is not None: parts.append(metadata_token.reshape(1))
return torch.cat(parts)
# Backward-compatible name used by the pi0.5 data utilities.
build_prefix = build_observation
def encode_subtask(self, reasoning: str) -> Tensor:
return torch.tensor(self.subtasks.encode(reasoning), dtype=torch.long)
def encode_advantage(self, advantage: Advantage | bool | None) -> Tensor | None:
if advantage is True or advantage == "positive":
return torch.tensor(self.ADVANTAGE_POSITIVE, dtype=torch.long)
if advantage is False or advantage == "negative":
return torch.tensor(self.ADVANTAGE_NEGATIVE, dtype=torch.long)
if advantage is None or advantage == "omitted":
return None
raise ValueError("advantage must be positive, negative, omitted, bool, or None")
@staticmethod
def _orthonormal_dct_matrix(
horizon: int, *, device: torch.device, dtype: torch.dtype
) -> Tensor:
if horizon <= 0:
raise ValueError("DCT horizon must be positive")
time = torch.arange(horizon, device=device, dtype=dtype)
frequency = torch.arange(horizon, device=device, dtype=dtype).unsqueeze(1)
matrix = torch.cos(math.pi / horizon * (time + 0.5) * frequency)
matrix[0] *= math.sqrt(1.0 / horizon)
if horizon > 1:
matrix[1:] *= math.sqrt(2.0 / horizon)
return matrix
def dct_actions(self, actions: Tensor | Iterable[Iterable[float]]) -> Tensor:
values = torch.as_tensor(actions, dtype=torch.float32)
if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0:
raise ValueError("actions must have non-empty shape [horizon, 2]")
transform = self._orthonormal_dct_matrix(
values.shape[0], device=values.device, dtype=values.dtype
)
return transform @ values
def idct_actions(self, coefficients: Tensor | Iterable[Iterable[float]]) -> Tensor:
values = torch.as_tensor(coefficients, dtype=torch.float32)
if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0:
raise ValueError("DCT coefficients must have non-empty shape [horizon, 2]")
transform = self._orthonormal_dct_matrix(
values.shape[0], device=values.device, dtype=values.dtype
)
return transform.transpose(0, 1) @ values
def encode_actions(self, actions: Tensor | Iterable[Iterable[float]]) -> Tensor:
values = torch.as_tensor(actions, dtype=torch.float32)
if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0:
raise ValueError("actions must have non-empty shape [horizon, 2]")
values = values.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM)
coefficients = self.dct_actions(values) / math.sqrt(values.shape[0])
coefficients = torch.where(
coefficients.abs() < 1e-6, torch.zeros_like(coefficients), coefficients
)
unit = (
coefficients.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM)
- self.ACTION_MINIMUM
) / (self.ACTION_MAXIMUM - self.ACTION_MINIMUM)
payload = torch.round(unit * (self.ACTION_BINS - 1)).long().reshape(-1)
payload = payload + self.ACTION_TOKEN_START
boundaries = torch.tensor(
[self.ACTION_START, self.ACTION_END], dtype=torch.long, device=payload.device
)
return torch.cat((boundaries[:1], payload, boundaries[1:]))
def decode_actions(self, tokens: Tensor | Iterable[int]) -> Tensor:
encoded = torch.as_tensor(tokens, dtype=torch.long)
if encoded.ndim != 1:
raise ValueError("action tokens must be a one-dimensional sequence")
if encoded.numel() < 4 or encoded[0] != self.ACTION_START or encoded[-1] != self.ACTION_END:
raise ValueError("action tokens must be enclosed by ACTION_START and ACTION_END")
payload = encoded[1:-1]
if payload.numel() % 2:
raise ValueError("action token payload must contain complete XY pairs")
if payload.min() < self.ACTION_TOKEN_START or payload.max() > self.ACTION_TOKEN_END:
raise ValueError("action token payload is outside the reserved action token interval")
quantized = payload - self.ACTION_TOKEN_START
coefficients = (
quantized.float() / (self.ACTION_BINS - 1)
* (self.ACTION_MAXIMUM - self.ACTION_MINIMUM)
+ self.ACTION_MINIMUM
)
horizon = payload.numel() // 2
return self.idct_actions(coefficients.reshape(horizon, 2) * math.sqrt(horizon))
def build_policy_sequence(
self,
history_xy: Tensor,
subtask: str,
actions: Tensor,
advantage: Advantage | bool | None,
metadata: str | None = None,
) -> PolicySequence:
observation = self.build_observation(history_xy, metadata)
subtask_token = self.encode_subtask(subtask).reshape(1)
advantage_token = self.encode_advantage(advantage)
action_tokens = self.encode_actions(actions).cpu()
pieces = [observation, subtask_token]
if advantage_token is not None:
pieces.append(advantage_token.reshape(1))
pieces.append(action_tokens)
input_ids = torch.cat(pieces)
subtask_index = observation.numel()
advantage_index = subtask_index + 1 if advantage_token is not None else None
action_start_index = subtask_index + 1 + int(advantage_token is not None)
labels = torch.full_like(input_ids, -100)
# SUBTASK_QUERY predicts the subtask. The external advantage indicator
# is deliberately skipped as a target. Advantage/subtask then predicts
# ACTION_START and the remaining FAST-lite stream autoregressively.
labels[subtask_index - 1] = input_ids[subtask_index]
labels[action_start_index - 1 : -1] = input_ids[action_start_index:]
action_prefix_mask = torch.zeros_like(input_ids, dtype=torch.bool)
action_prefix_mask[:action_start_index] = True
return PolicySequence(
input_ids=input_ids,
labels=labels,
generated_token_start=subtask_index,
action_prefix_mask=action_prefix_mask,
subtask_index=subtask_index,
advantage_index=advantage_index,
action_start_index=action_start_index,
)
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import argparse
import copy
import json
from pathlib import Path
import torch
from .data import Pi06ExperienceDataset, materialize_subset
from .flow import FlowMatching
from .model import Pi06Config, Pi06Mini
from .recap import apply_condition_dropout
from .runtime import choose_device, load_config, seed_everything, write_json
from .tokenizer import MiniTokenizer
from .value import ValueConfig, ValueModel
def build_models(cfg: dict, tokenizer: MiniTokenizer) -> tuple[Pi06Mini, ValueModel]:
m = cfg["model"]
policy = Pi06Mini(Pi06Config(
vocab_size=tokenizer.vocab_size, action_dim=2,
image_size=cfg["data"]["image_size"], patch_size=m["patch_size"],
d_model=m["policy_d_model"], n_heads=m["n_heads"], n_layers=m["n_layers"],
mlp_ratio=m["mlp_ratio"], max_text_tokens=m["max_text_tokens"],
max_action_tokens=cfg["data"]["action_horizon"],
action_start_token_id=tokenizer.ACTION_START, dropout=m["dropout"],
))
value = ValueModel(ValueConfig(
vocab_size=tokenizer.vocab_size, image_size=cfg["data"]["image_size"],
patch_size=m["patch_size"], d_model=m["value_d_model"], n_heads=m["n_heads"],
n_layers=2, mlp_ratio=m["mlp_ratio"], max_text_tokens=m["max_text_tokens"],
num_bins=m["value_bins"], value_min=m["value_min"], value_max=m["value_max"],
))
return policy, value
def policy_step(
model: Pi06Mini, sample: dict, tokenizer: MiniTokenizer, flow: FlowMatching,
device: torch.device, advantage: str | None, cfg: dict,
) -> tuple[torch.Tensor, dict[str, float]]:
sequence = tokenizer.build_policy_sequence(
sample["history_xy"], sample["reasoning"], sample["actions"], advantage,
# Outcome/source metadata is reserved for the critic in this synthetic
# proxy. The policy must distinguish behavior through advantage alone.
metadata=None,
)
image = sample["image"][None].to(device)
ids = sequence.input_ids[None].to(device)
labels = sequence.labels[None].to(device)
actions = sample["actions"][None].to(device)
noisy, timestep, velocity, weight = flow.training_batch(actions)
output = model(
image, ids, noisy, timestep,
action_prefix_mask=sequence.action_prefix_mask[None].to(device),
generated_token_start=sequence.generated_token_start,
text_labels=labels,
)
flow_loss = flow.weighted_loss(output.vector_field, velocity, weight)
loss = cfg["train"]["token_loss_weight"] * output.text_loss + cfg["train"]["flow_loss_weight"] * flow_loss
return loss, {
"text_loss": float(output.text_loss.detach().cpu()),
"flow_loss": float(flow_loss.detach().cpu()),
}
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(cfg["data"]["source_manifest"], root / "data/overfit")
dataset = Pi06ExperienceDataset(
manifest, image_size=cfg["data"]["image_size"],
augmentation_factor=cfg["experience"]["augmentation_factor"],
action_horizon=cfg["data"]["action_horizon"], n_step=cfg["experience"]["n_step"],
gamma=cfg["experience"]["gamma"], positive_quantile=cfg["experience"]["positive_quantile"],
)
tokenizer = MiniTokenizer()
base, value = build_models(cfg, tokenizer)
base, value = base.to(device), value.to(device)
flow = FlowMatching(cfg["inference"]["flow_steps"])
outdir = root / cfg["output_dir"]; outdir.mkdir(parents=True, exist_ok=True)
history: list[dict] = []
demos = [dataset[i] for i in range(len(dataset)) if dataset[i]["source_type"] == "demo_success"]
optimizer = torch.optim.AdamW(base.parameters(), lr=cfg["train"]["learning_rate"])
base.train()
for step in range(1, cfg["train"]["base_steps"] + 1):
sample = demos[(step - 1) % len(demos)]
loss, metrics = policy_step(base, sample, tokenizer, flow, device, None, cfg)
optimizer.zero_grad(set_to_none=True); loss.backward()
torch.nn.utils.clip_grad_norm_(base.parameters(), cfg["train"]["grad_clip_norm"]); optimizer.step()
if step == 1 or step % cfg["train"]["log_every"] == 0:
row={"stage":"base","step":step,"loss":float(loss.detach().cpu()),**metrics};history.append(row);print(json.dumps(row))
base_state = copy.deepcopy(base.state_dict())
torch.save(base_state, outdir / "pi06_base.pt")
value_opt = torch.optim.AdamW(value.parameters(), lr=cfg["train"]["value_learning_rate"])
value.train()
for step in range(1, cfg["train"]["value_steps"] + 1):
sample = dataset[(step - 1) % len(dataset)]
ids = tokenizer.build_value_input(sample["history_xy"], sample["source_type"])[None].to(device)
output = value(sample["image"][None].to(device), ids, target_values=sample["value_target"][None].to(device))
value_opt.zero_grad(set_to_none=True); output.loss.backward(); value_opt.step()
if step == 1 or step % cfg["train"]["log_every"] == 0:
row={"stage":"value","step":step,"loss":float(output.loss.detach().cpu()),"prediction":float(output.expectation.detach().cpu()),"target":float(sample["value_target"])};history.append(row);print(json.dumps(row))
def fit_policy(stage: str, steps: int, conditioned: bool) -> Pi06Mini:
model, _ = build_models(cfg, tokenizer); model.load_state_dict(base_state); model.to(device).train()
opt = torch.optim.AdamW(model.parameters(), lr=cfg["train"]["learning_rate"])
for step in range(1, steps + 1):
sample = dataset[(step - 1) % len(dataset)]
advantage = None
if conditioned:
dropped = bool(apply_condition_dropout(
torch.tensor([sample["positive_condition"]]),
drop_probability=cfg["recap"]["condition_dropout"],
)[1][0])
advantage = None if dropped else sample["advantage_label"]
loss, metrics = policy_step(model, sample, tokenizer, flow, device, advantage, cfg)
opt.zero_grad(set_to_none=True); loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), cfg["train"]["grad_clip_norm"]); opt.step()
if step == 1 or step % cfg["train"]["log_every"] == 0:
row={"stage":stage,"step":step,"loss":float(loss.detach().cpu()),**metrics};history.append(row);print(json.dumps(row))
return model
mixed = fit_policy("mixed", cfg["train"]["mixed_steps"], conditioned=False)
recap = fit_policy("recap", cfg["train"]["recap_steps"], conditioned=True)
torch.save(mixed.state_dict(), outdir / "mixed_sft.pt")
torch.save(recap.state_dict(), outdir / "pi06star_recap.pt")
checkpoint = {
"base": base_state, "mixed": mixed.state_dict(), "recap": recap.state_dict(),
"value": value.state_dict(), "policy_config": base.config.__dict__,
"value_config": value.config.__dict__, "config": cfg,
"action_statistics": dataset.action_normalizer.as_dict(),
}
torch.save(checkpoint, outdir / "checkpoint.pt")
write_json(outdir / "training_history.json", history)
write_json(outdir / "summary.json", {
"device": str(device), "experience_samples": len(dataset),
"policy_parameters": sum(p.numel() for p in base.parameters()),
"value_parameters": sum(p.numel() for p in value.parameters()),
"sources": [dataset[i]["source_type"] for i in range(len(dataset))],
})
return outdir / "checkpoint.pt"
def main() -> None:
parser=argparse.ArgumentParser();parser.add_argument("--config",default="configs/overfit.yaml")
train(parser.parse_args().config)
if __name__ == "__main__": main()
+188
View File
@@ -0,0 +1,188 @@
"""Independent 201-bin distributional value model for minimal Recap training."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import torch
from torch import Tensor, nn
import torch.nn.functional as F
from .model import AttentionWeights, MLP, RMSNorm, attention
@dataclass
class ValueConfig:
vocab_size: int = 512
image_channels: int = 3
image_size: int = 64
patch_size: int = 16
d_model: int = 96
n_heads: int = 4
n_layers: int = 3
mlp_ratio: int = 4
max_text_tokens: int = 64
num_bins: int = 201
value_min: float = -1.0
value_max: float = 0.0
dropout: float = 0.0
rms_eps: float = 1e-6
def __post_init__(self) -> None:
if self.d_model % self.n_heads:
raise ValueError("d_model must be divisible by n_heads")
if self.image_size % self.patch_size:
raise ValueError("image_size must be divisible by patch_size")
if self.num_bins < 2:
raise ValueError("num_bins must be at least two")
if self.value_max <= self.value_min:
raise ValueError("value_max must be greater than value_min")
@property
def max_image_tokens(self) -> int:
return (self.image_size // self.patch_size) ** 2
@property
def max_sequence_tokens(self) -> int:
# One learned pooling token plus image and discrete-state tokens.
return 1 + self.max_image_tokens + self.max_text_tokens
@dataclass
class ValueOutput:
logits: Tensor
probabilities: Tensor
expectation: Tensor
hidden: Tensor
loss: Optional[Tensor] = None
target_bins: Optional[Tensor] = None
class ValueBlock(nn.Module):
def __init__(self, config: ValueConfig) -> None:
super().__init__()
self.n_heads = config.n_heads
self.dropout = config.dropout
self.norm1 = RMSNorm(config.d_model, config.rms_eps)
self.attention = AttentionWeights(config.d_model)
self.norm2 = RMSNorm(config.d_model, config.rms_eps)
self.mlp = MLP(config.d_model, config.mlp_ratio, config.dropout)
def forward(self, x: Tensor, edges: Tensor) -> Tensor:
normalized = self.norm1(x)
weights = self.attention
update = attention(
weights.q(normalized),
weights.k(normalized),
weights.v(normalized),
self.n_heads,
edges,
self.dropout,
self.training,
)
x = x + weights.out(update)
return x + self.mlp(self.norm2(x))
class DistributionalValueModel(nn.Module):
"""Predict ``p(V | image, discrete state)`` over 201 return bins.
The value network is deliberately a separate model: it shares no
parameters with ``Pi06Mini``. By default its support is the paper's
task-normalized interval ``[-1, 0]``.
"""
def __init__(self, config: Optional[ValueConfig] = None) -> None:
super().__init__()
self.config = config or ValueConfig()
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.value_token = nn.Parameter(torch.randn(1, 1, c.d_model) * 0.01)
self.position = nn.Parameter(
torch.randn(1, c.max_sequence_tokens, c.d_model) * 0.01
)
self.layers = nn.ModuleList(ValueBlock(c) for _ in range(c.n_layers))
self.norm = RMSNorm(c.d_model, c.rms_eps)
self.value_head = nn.Linear(c.d_model, c.num_bins)
self.register_buffer(
"support",
torch.linspace(c.value_min, c.value_max, c.num_bins),
persistent=True,
)
def values_to_bins(self, values: Tensor) -> Tensor:
"""Nearest-bin discretization of normalized Monte-Carlo returns."""
values = torch.as_tensor(values, dtype=self.support.dtype, device=self.support.device)
unit = (values.clamp(self.config.value_min, self.config.value_max) - self.config.value_min)
unit = unit / (self.config.value_max - self.config.value_min)
return torch.round(unit * (self.config.num_bins - 1)).long()
def bins_to_values(self, bins: Tensor) -> Tensor:
bins = torch.as_tensor(bins, dtype=torch.long, device=self.support.device)
if bins.numel() and (bins.min() < 0 or bins.max() >= self.config.num_bins):
raise ValueError("value bins are outside the configured support")
return self.support[bins]
def expectation_from_logits(self, logits: Tensor) -> Tensor:
return (logits.softmax(dim=-1) * self.support.to(logits.dtype)).sum(dim=-1)
def forward(
self,
pixel_values: Tensor,
input_ids: Tensor,
attention_mask: Optional[Tensor] = None,
target_values: Optional[Tensor] = None,
target_bins: Optional[Tensor] = None,
) -> ValueOutput:
if target_values is not None and target_bins is not None:
raise ValueError("provide target_values or target_bins, not both")
patches = self.patch_encoder(pixel_values).flatten(2).transpose(1, 2)
text = self.token_embedding(input_ids)
batch = input_ids.shape[0]
pooling = self.value_token.expand(batch, -1, -1)
hidden = torch.cat((pooling, patches, text), dim=1)
if hidden.shape[1] > self.config.max_sequence_tokens:
raise ValueError("value-model sequence exceeds configured maximum length")
hidden = hidden + self.position[:, : hidden.shape[1]]
valid = torch.ones(batch, hidden.shape[1], dtype=torch.bool, device=hidden.device)
if attention_mask is not None:
if attention_mask.shape != input_ids.shape:
raise ValueError("attention_mask must have the same shape as input_ids")
valid[:, 1 + patches.shape[1] :] = attention_mask.bool()
edges = valid[:, None, :].expand(batch, hidden.shape[1], hidden.shape[1])
identity = torch.eye(hidden.shape[1], dtype=torch.bool, device=hidden.device)[None]
edges = torch.where(valid[:, :, None], edges, identity)
for layer in self.layers:
hidden = layer(hidden, edges)
hidden = self.norm(hidden)
logits = self.value_head(hidden[:, 0])
probabilities = logits.softmax(dim=-1)
expectation = (probabilities * self.support.to(logits.dtype)).sum(dim=-1)
bins = None
if target_values is not None:
bins = self.values_to_bins(target_values).to(logits.device)
elif target_bins is not None:
bins = torch.as_tensor(target_bins, dtype=torch.long, device=logits.device)
if bins.numel() and (bins.min() < 0 or bins.max() >= self.config.num_bins):
raise ValueError("target_bins are outside the configured support")
loss = None if bins is None else F.cross_entropy(logits, bins.reshape(-1))
return ValueOutput(
logits=logits,
probabilities=probabilities,
expectation=expectation,
hidden=hidden,
loss=loss,
target_bins=bins,
)
# Short name convenient in configs and training scripts.
ValueModel = DistributionalValueModel
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import torch
from pi06mini.experience import (
build_synthetic_experience,
discounted_returns,
n_step_advantages,
normalize_returns_to_unit_interval,
quantile_advantage_labels,
)
def _geometry() -> tuple[torch.Tensor, torch.Tensor]:
history = torch.stack((torch.linspace(-7.0, 0.0, 8), torch.zeros(8)), dim=-1)
x = torch.linspace(1.0, 16.0, 16)
future = torch.stack((x, 0.025 * x.square()), dim=-1)
return history, future
def test_synthetic_buffer_has_all_experience_sources_and_failure_geometry() -> None:
history, future = _geometry()
episodes = build_synthetic_experience(history, future, episode_prefix="pai")
assert [episode.source_type for episode in episodes] == [
"demo_success",
"autonomous_understeer",
"autonomous_stop",
"intervention_correction",
]
demo, understeer, stopped, correction = episodes
torch.testing.assert_close(demo.future_xy, future)
assert understeer.future_xy[-1, 1] < future[-1, 1]
assert torch.equal(stopped.future_xy[-1], stopped.future_xy[-2])
assert correction.intervention_mask.any()
assert not correction.intervention_mask[0]
torch.testing.assert_close(correction.future_xy[-1], future[-1])
assert torch.all(demo.rewards == 0)
assert float(understeer.rewards.min()) < 0
assert float(stopped.rewards.min()) < 0
def test_returns_are_normalized_to_closed_negative_unit_interval() -> None:
rewards = torch.tensor([[0.0, 0.0, 0.0], [-0.2, -0.4, -1.0]])
returns = discounted_returns(rewards, gamma=1.0)
normalized = normalize_returns_to_unit_interval(returns)
assert normalized.shape == rewards.shape
assert float(normalized.min()) == -1.0
assert float(normalized.max()) == 0.0
assert torch.all(normalized <= 0.0)
assert torch.all(normalized >= -1.0)
assert torch.equal(
normalize_returns_to_unit_interval(torch.ones(3)), torch.zeros(3)
)
def test_n_step_advantage_bootstraps_and_stops_at_terminal() -> None:
rewards = torch.tensor([1.0, 2.0, 3.0])
values = torch.tensor([0.5, 1.0, 1.5, 8.0])
dones = torch.tensor([False, True, False])
advantages = n_step_advantages(
rewards, values, n_step=3, gamma=0.5, dones=dones
)
# t=0 sees r0 + gamma*r1, then the terminal blocks the large bootstrap.
assert torch.isclose(advantages[0], torch.tensor(1.5))
assert torch.isclose(advantages[1], torch.tensor(1.0))
# t=2 reaches the explicit end and therefore has no bootstrap.
assert torch.isclose(advantages[2], torch.tensor(1.5))
def test_quantile_labels_force_interventions_positive() -> None:
advantages = torch.tensor([-3.0, -2.0, -1.0, 2.0])
interventions = torch.tensor([True, False, False, False])
labels, threshold = quantile_advantage_labels(
advantages, quantile=0.75, intervention_mask=interventions
)
assert labels.tolist() == [True, False, False, True]
assert float(threshold) > -1.0
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import torch
from PIL import Image
from pi06mini.data import Pi06ExperienceDataset, load_manifest, materialize_subset
def _make_source_project(root: Path) -> Path:
processed = root / "data" / "processed"
(processed / "images").mkdir(parents=True)
(processed / "trajectories").mkdir(parents=True)
pixels = np.zeros((40, 64, 3), dtype=np.uint8)
pixels[..., 0] = 80
pixels[..., 1] = np.arange(64, dtype=np.uint8)[None, :]
Image.fromarray(pixels).save(processed / "images" / "sample.jpg")
hx = np.linspace(-7.0, 0.0, 8, dtype=np.float32)
history = np.stack((hx, 0.01 * hx, np.zeros(8)), axis=-1)
fx = np.linspace(1.0, 24.0, 16, dtype=np.float32)
future = np.stack((fx, 0.015 * fx**2, np.zeros(16)), axis=-1)
np.savez_compressed(
processed / "trajectories" / "sample.npz",
history_xyz=history,
future_xyz=future,
)
manifest = processed / "overfit_manifest.jsonl"
manifest.write_text(
json.dumps(
{
"clip_id": "clip-a",
"t0_us": 123,
"reasoning": "Steer left.",
"image_path": "data/processed/images/sample.jpg",
"trajectory_path": "data/processed/trajectories/sample.npz",
}
)
+ "\n",
encoding="utf-8",
)
return manifest
def test_experience_dataset_contract_and_intervention_label(tmp_path: Path) -> None:
source = _make_source_project(tmp_path / "source")
manifest = materialize_subset(source, tmp_path / "target")
assert load_manifest(manifest)[0].image_path.is_file()
dataset = Pi06ExperienceDataset(manifest, image_size=32)
assert len(dataset) == 4
assert {item.source_type for item in dataset.episodes} == {
"demo_success",
"autonomous_understeer",
"autonomous_stop",
"intervention_correction",
}
assert float(dataset.normalized_returns.min()) == -1.0
assert float(dataset.normalized_returns.max()) == 0.0
items = [dataset[index] for index in range(len(dataset))]
intervention = next(item for item in items if item["is_intervention"])
assert bool(intervention["positive_condition"])
assert torch.all(intervention["positive_steps"][intervention["intervention_mask"]])
assert intervention["image"].shape == (3, 32, 32)
assert intervention["state"].shape == (16,)
assert intervention["actions"].shape == (16, 2)
assert intervention["returns"].shape == (16,)
assert intervention["return_target"].shape == ()
assert intervention["value_target"].shape == ()
assert intervention["advantages"].shape == (16,)
assert intervention["advantage_label"] == "positive"
assert intervention["reasoning"] == "Steer left."
decoded = dataset.action_normalizer.decode(
intervention["actions"], intervention["history_xy"]
)
torch.testing.assert_close(decoded, intervention["future_xy"], atol=1e-5, rtol=1e-5)
+123
View File
@@ -0,0 +1,123 @@
import torch
from pi06mini.model import Pi06Config, Pi06Mini
from pi06mini.tokenizer import MiniTokenizer
def tiny_model() -> Pi06Mini:
tokenizer = MiniTokenizer()
return Pi06Mini(
Pi06Config(
vocab_size=tokenizer.vocab_size,
image_size=16,
patch_size=16,
d_model=32,
n_heads=4,
n_layers=1,
max_text_tokens=32,
max_action_tokens=4,
action_start_token_id=tokenizer.ACTION_START,
)
)
def test_flow_reads_subtask_and_advantage_but_not_fast_targets() -> None:
tokenizer = MiniTokenizer()
sequence = tokenizer.build_policy_sequence(
torch.zeros(2, 2), "steer_left", torch.zeros(2, 2), "positive"
)
model = tiny_model()
input_ids = sequence.input_ids[None]
derived = model._derive_action_prefix_mask(input_ids)
torch.testing.assert_close(derived[0], sequence.action_prefix_mask)
text_offset = 1
prefix, flow = model._masks(
batch=1,
prefix_length=1 + input_ids.shape[1],
action_length=2,
text_offset=text_offset,
attention_mask=torch.ones_like(input_ids, dtype=torch.bool),
action_prefix_mask=derived,
generated_token_start=sequence.generated_token_start,
device=torch.device("cpu"),
)
generated_start = text_offset + sequence.generated_token_start
assert prefix[0, 0, :generated_start].all()
assert not prefix[0, 0, generated_start:].any()
# Flow sees image plus all text strictly before ACTION_START.
readable_prefix = text_offset + sequence.action_start_index
assert flow[0, :, :readable_prefix].all()
assert not flow[0, :, readable_prefix : 1 + input_ids.shape[1]].any()
assert flow[0, :, 1 + input_ids.shape[1] :].all()
def test_flow_loss_is_knowledge_insulated_from_every_vlm_parameter() -> None:
torch.manual_seed(4)
tokenizer = MiniTokenizer()
sequence = tokenizer.build_policy_sequence(
torch.zeros(2, 2), "steer_left", torch.zeros(2, 2), "positive"
)
model = tiny_model()
output = model(
pixel_values=torch.randn(1, 3, 16, 16),
input_ids=sequence.input_ids[None],
noisy_actions=torch.randn(1, 2, 2),
timesteps=torch.rand(1),
action_prefix_mask=sequence.action_prefix_mask[None],
generated_token_start=sequence.generated_token_start,
target_velocity=torch.randn(1, 2, 2),
)
assert output.flow_loss is not None
output.flow_loss.backward()
vlm_gradients = [parameter.grad for parameter in model.vlm_parameters()]
assert vlm_gradients
assert all(gradient is None or torch.count_nonzero(gradient) == 0 for gradient in vlm_gradients)
action_gradients = [parameter.grad for parameter in model.action_parameters()]
assert any(gradient is not None and torch.count_nonzero(gradient) > 0 for gradient in action_gradients)
def test_knowledge_insulation_stops_gradients_not_conditioning() -> None:
torch.manual_seed(8)
tokenizer = MiniTokenizer()
model = tiny_model().eval()
positive = tokenizer.build_policy_sequence(
torch.zeros(2, 2), "keep_lane", torch.zeros(2, 2), "positive"
)
negative = tokenizer.build_policy_sequence(
torch.zeros(2, 2), "keep_lane", torch.zeros(2, 2), "negative"
)
image = torch.randn(1, 3, 16, 16)
noise = torch.randn(1, 2, 2)
timestep = torch.full((1,), 0.5)
with torch.no_grad():
pos = model(
image, positive.input_ids[None], noise, timestep,
generated_token_start=positive.generated_token_start,
).vector_field
neg = model(
image, negative.input_ids[None], noise, timestep,
generated_token_start=negative.generated_token_start,
).vector_field
assert not torch.allclose(pos, neg)
def test_joint_loss_still_trains_vlm_through_token_objective() -> None:
tokenizer = MiniTokenizer()
sequence = tokenizer.build_policy_sequence(
torch.zeros(2, 2), "stop", torch.zeros(2, 2), "negative"
)
model = tiny_model()
output = model(
torch.randn(1, 3, 16, 16),
sequence.input_ids[None],
torch.randn(1, 2, 2),
torch.rand(1),
generated_token_start=sequence.generated_token_start,
text_labels=sequence.labels[None],
target_velocity=torch.randn(1, 2, 2),
)
output.loss.backward()
assert model.token_embedding.weight.grad is not None
assert torch.count_nonzero(model.token_embedding.weight.grad) > 0
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import torch
from pi06mini.recap import (
apply_condition_dropout,
classifier_free_guidance,
condition_dropout_mask,
)
def test_default_condition_dropout_is_statistically_thirty_percent() -> None:
generator = torch.Generator().manual_seed(11)
mask = condition_dropout_mask(20_000, generator=generator)
assert mask.dtype == torch.bool
assert abs(float(mask.float().mean()) - 0.3) < 0.015
def test_condition_dropout_replaces_whole_examples() -> None:
conditions = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
dropped, mask = apply_condition_dropout(
conditions,
null_condition=-1.0,
mask=torch.tensor([False, True, False]),
)
assert mask.tolist() == [False, True, False]
torch.testing.assert_close(dropped[0], conditions[0])
torch.testing.assert_close(dropped[1], torch.tensor([-1.0, -1.0]))
torch.testing.assert_close(dropped[2], conditions[2])
def test_cfg_interpolates_and_extrapolates_flow_predictions() -> None:
conditional = torch.tensor([[2.0, 4.0]])
unconditional = torch.tensor([[0.0, 2.0]])
torch.testing.assert_close(
classifier_free_guidance(conditional, unconditional, guidance_scale=0.0),
unconditional,
)
torch.testing.assert_close(
classifier_free_guidance(conditional, unconditional, guidance_scale=1.0),
conditional,
)
torch.testing.assert_close(
classifier_free_guidance(conditional, unconditional, guidance_scale=1.5),
torch.tensor([[3.0, 5.0]]),
)
+65
View File
@@ -0,0 +1,65 @@
import torch
from pi06mini.tokenizer import MiniTokenizer
def test_advantage_condition_is_between_subtask_and_actions() -> None:
tokenizer = MiniTokenizer()
history = torch.tensor([[0.0, 0.0], [1.0, 0.2]])
actions = torch.tensor([[0.1, 0.0], [0.2, -0.1]])
positive = tokenizer.build_policy_sequence(
history, "steer_left", actions, "positive"
)
assert positive.advantage_index == positive.subtask_index + 1
assert positive.action_start_index == positive.advantage_index + 1
assert positive.input_ids[positive.advantage_index] == tokenizer.ADVANTAGE_POSITIVE
assert positive.input_ids[positive.action_start_index] == tokenizer.ACTION_START
assert positive.action_prefix_mask[: positive.action_start_index].all()
assert not positive.action_prefix_mask[positive.action_start_index :].any()
negative = tokenizer.build_policy_sequence(
history, "steer_left", actions, "negative"
)
assert negative.input_ids[negative.advantage_index] == tokenizer.ADVANTAGE_NEGATIVE
def test_omitted_advantage_is_absent_not_a_third_condition_token() -> None:
tokenizer = MiniTokenizer()
sequence = tokenizer.build_policy_sequence(
torch.zeros(2, 2), "keep_lane", torch.zeros(2, 2), "omitted"
)
assert sequence.advantage_index is None
assert sequence.action_start_index == sequence.subtask_index + 1
assert sequence.input_ids[sequence.action_start_index] == tokenizer.ACTION_START
assert tokenizer.ADVANTAGE_POSITIVE not in sequence.input_ids
assert tokenizer.ADVANTAGE_NEGATIVE not in sequence.input_ids
def test_labels_predict_subtask_and_fast_but_not_external_advantage() -> None:
tokenizer = MiniTokenizer()
positive = tokenizer.build_policy_sequence(
torch.zeros(2, 2), "stop", torch.zeros(2, 2), True
)
assert positive.labels[positive.subtask_index - 1] == positive.input_ids[positive.subtask_index]
assert positive.labels[positive.subtask_index] == -100
assert positive.labels[positive.advantage_index] == tokenizer.ACTION_START
omitted = tokenizer.build_policy_sequence(
torch.zeros(2, 2), "stop", torch.zeros(2, 2), None
)
assert omitted.labels[omitted.subtask_index] == tokenizer.ACTION_START
def test_fast_lite_dct_round_trip_has_bounded_error() -> None:
tokenizer = MiniTokenizer()
actions = torch.tensor(
[[0.0, 0.0], [0.2, -0.1], [0.5, -0.2], [0.8, -0.1]],
dtype=torch.float32,
)
exact = tokenizer.idct_actions(tokenizer.dct_actions(actions))
torch.testing.assert_close(exact, actions, atol=1e-5, rtol=1e-5)
decoded = tokenizer.decode_actions(tokenizer.encode_actions(actions))
assert torch.sqrt(torch.mean((decoded - actions) ** 2)) < 0.3
+73
View File
@@ -0,0 +1,73 @@
import torch
from pi06mini.model import Pi06Config, Pi06Mini
from pi06mini.tokenizer import MiniTokenizer
from pi06mini.value import DistributionalValueModel, ValueConfig
def tiny_value_model() -> DistributionalValueModel:
tokenizer = MiniTokenizer()
return DistributionalValueModel(
ValueConfig(
vocab_size=tokenizer.vocab_size,
image_size=16,
patch_size=16,
d_model=32,
n_heads=4,
n_layers=1,
max_text_tokens=16,
)
)
def test_distributional_value_has_201_bins_and_expected_value() -> None:
tokenizer = MiniTokenizer()
model = tiny_value_model()
state = tokenizer.build_value_input(torch.zeros(2, 2))[None].repeat(2, 1)
output = model(
torch.randn(2, 3, 16, 16),
state,
target_values=torch.tensor([-1.0, 0.0]),
)
assert output.logits.shape == (2, 201)
assert output.probabilities.shape == (2, 201)
torch.testing.assert_close(output.probabilities.sum(-1), torch.ones(2))
assert torch.all((-1.0 <= output.expectation) & (output.expectation <= 0.0))
assert output.target_bins.tolist() == [0, 200]
assert output.loss is not None and output.loss.ndim == 0
def test_value_discretization_and_expectation_use_configured_support() -> None:
model = tiny_value_model()
bins = model.values_to_bins(torch.tensor([-2.0, -0.5, 1.0]))
assert bins.tolist() == [0, 100, 200]
torch.testing.assert_close(
model.bins_to_values(torch.tensor([0, 100, 200])),
torch.tensor([-1.0, -0.5, 0.0]),
)
logits = torch.full((3, 201), -100.0)
logits[0, 0] = logits[1, 100] = logits[2, 200] = 100.0
torch.testing.assert_close(
model.expectation_from_logits(logits),
torch.tensor([-1.0, -0.5, 0.0]),
)
def test_value_model_and_policy_share_no_parameters() -> None:
tokenizer = MiniTokenizer()
policy = Pi06Mini(
Pi06Config(
vocab_size=tokenizer.vocab_size,
image_size=16,
patch_size=16,
d_model=32,
n_heads=4,
n_layers=1,
action_start_token_id=tokenizer.ACTION_START,
)
)
value = tiny_value_model()
policy_ids = {id(parameter) for parameter in policy.parameters()}
value_ids = {id(parameter) for parameter in value.parameters()}
assert policy_ids.isdisjoint(value_ids)