Add 0.5B Alpamayo validation

This commit is contained in:
seeker
2026-07-11 13:08:42 +08:00
commit fb1bd51046
34 changed files with 6125 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
.venv/
.cache/
.ruff_cache/
models/
data/raw/
data/processed/
outputs/
references/
*.egg-info/
*.pyc
__pycache__/
.pytest_cache/
.DS_Store
.env
.env.*
wandb/
*.safetensors
*.pt
*.pth
*.parquet
*.npz
*.log
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+341
View File
@@ -0,0 +1,341 @@
# alpamayo-0.5B
`alpamayo-0.5B` 是一个单机、单样本的 Alpamayo 接口验证工程。它把一个带
Qwen2.5-0.5B 文本骨干的 VLM 接到缩小版 diffusion expert,验证以下链路能够实际
训练、保存、重新加载和推理:
1. 图像、自然语言和 ego-relative 历史坐标进入 VLM;
2. VLM 生成驾驶文字,也可以在未截断验证分支中生成结构化 CoT、meta-action 和
离散轨迹 token;
3. VLM 的 24 层 past-KV 作为 expert 的前缀;
4. flow-matching diffusion 预测连续 action,再积分回未来 XY 位置。
这是针对连接方式和输出契约的最小过拟合实验,不是 NVIDIA Alpamayo-R1 的完整复现,
也不是可部署的驾驶模型。
## 架构
```mermaid
flowchart LR
A["前视图像"] --> V["LLaVA VLM"]
B["驾驶问题与历史 XY"] --> V
Q["CLIP vision tower"] --> V
L["Qwen2.5-0.5B text backbone"] --> V
V --> T["驾驶文字 / structured tokens"]
V --> K["24-layer past-KV"]
N["noisy normalized XY-delta"] --> E["105.6M Qwen2 expert"]
S["diffusion timestep"] --> E
K --> E
E --> F["flow-matching vector field"]
F --> D["10-step denoising"]
D --> X["16 x 2 continuous action"]
X --> P["ego-relative future XY"]
```
连接契约不是按 hidden size 复制 VLM。expert 采用自己的 `hidden_size=512`,但保持与
VLM KV cache 相同的 24 层、2 个 KV heads 和 `head_dim=64`,因此可以直接消费缓存。
expert 没有词嵌入层;noisy action 和 timestep 经 Fourier/MLP 投影成 token embeddings,
最后由线性层输出二维 vector field。
| 组件 | 本地配置 |
| --- | --- |
| VLM checkpoint | `thkim0305/qwen2.5_0.5B_vl`,revision `c318c4b464753d86ddffe065ba6df255ae354f41` |
| VLM 类型 | `LlavaForConditionalGeneration`;CLIP vision tower + Qwen2.5-0.5B text backbone |
| VLM 参数量 | 799,262,336(“0.5B”指文本骨干;加入视觉塔后完整 VLM 约 0.80B) |
| VLM text cache | 24 层,2 KV heads,`head_dim=64` |
| Expert | 105,575,170 参数;24 层,hidden 512,intermediate 2432,8 query heads,2 KV heads,non-causal |
| Diffusion 输出 | 16 个二维 normalized XY-delta action;10 个 flow-matching inference steps |
| 运行精度 | 当前产物为 MPS + float16;代码也可选择可用的 CUDA 或 CPU |
主配置位于 [`configs/local_overfit.yaml`](configs/local_overfit.yaml)。
## 单样本 PAI 数据
实验只使用 `nvidia/PhysicalAI-Autonomous-Vehicles` 的一个 `train` 事件。PAI 是 gated
dataset,使用前需要在 Hugging Face 接受其许可。
| 字段 | 值 |
| --- | --- |
| Clip ID | `314f59c2-e420-4fcd-9df2-a51d572e0c20` |
| `t0_us` | `1503899` |
| Camera | `camera_front_wide_120fov` |
| Event cluster | `WORK_ZONES_TEMP_TRAFFIC_CONTROL` |
| Reasoning source | `reasoning/ood_reasoning.parquet` 中的 `coc` 字段 |
| Reasoning | “Steer left following the temporary lane delineated by traffic cones through the construction zone.” |
| History | 8 个点,最后一个点为当前 ego-local 原点 |
| Future source | egomotion 的 64 个未来点,时间步 0.1 s |
| Expert target | 从 64 点以索引 `[0,4,8,12,16,21,25,29,33,37,42,46,50,54,58,63]` 取 16 点 |
[`scripts/materialize_pai_subset.py`](scripts/materialize_pai_subset.py) 将世界坐标平移、旋转到
`t0` ego-relative frame,并把单样本 manifest、图像和轨迹写入本地 `data/processed/`。
原始/处理后的 PAI 数据都被 `.gitignore` 排除,不应提交到仓库。
## 训练阶段
### Stage 1:自然语言驾驶输出
[`scripts/train_stage1_vlm.py`](scripts/train_stage1_vlm.py) 冻结 vision tower、multimodal
projector 和 LM head,只训练最后 8 个 Qwen2 language layers 及 final norm。当前 checkpoint
训练 80 steps,共 119,299,968 个可训练参数:loss 从 `3.9338069` 降至
`0.0000216427`,greedy 输出与数据集 reasoning 逐字一致。
Stage 1 输出到本地 `outputs/stage1_vlm/`,供后续两个分支使用:
- plain-text KV 分支训练现有 diffusion expert;
- structured VLM 分支继续训练轨迹 logits。
### VLM structured 未截断轨迹-token 分支
[`scripts/train_vlm_trajectory_logits.py`](scripts/train_vlm_trajectory_logits.py) 从 Stage 1
checkpoint 继续训练,并添加:
- 128 个连续 token:`<i0>` … `<i127>`;
- `<|cot_start|>`、`<|cot_end|>`;
- `<|meta_action_start|>`、`<|meta_action_end|>`;
- `<|traj_future_start|>`、`<|traj_future_end|>`。
当前 Qwen LM head 已预留 151,936 行,而原 tokenizer 长度为 151,666;扩展后的 151,800
个 token 不需要增大权重矩阵。每个 16 x 2 normalized XY-delta action 被均匀量化到
`[-4, 4]` 的 128 bins,形成 32 个监督 token。
当前结构化 checkpoint 训练 160 steps,loss 从 `14.17349` 降至 `0.0000347825`,最终
训练 accuracy 为 1.0。未截断 greedy generation 生成 32/32 个正确轨迹 token,其完整
目标/输出格式为:
```text
<|cot_start|>Steer left following the temporary lane delineated by traffic cones through the construction zone.<|cot_end|>
<|meta_action_start|>STEER_LEFT<|meta_action_end|>
<|traj_future_start|><i9><i37><i71><i38><i70><i41><i68><i46><i66><i49><i84><i63><i63><i63><i65><i67><i66><i69><i65><i67><i82><i80><i61><i77><i60><i79><i59><i78><i56><i76><i72><i86><|traj_future_end|><|im_end|>
```
另有 source-style handoff 验证:generation 到 `<|traj_future_start|>` 后屏蔽全部 128 个
trajectory logits,并把 exact generation cache 交给 diffusion。这个模式验证了 KV 接口,
但不会把 VLM 离散轨迹 token 作为 diffusion 分支的可见输出。
### Stage 2:KV-conditioned diffusion expert
[`scripts/train_stage2_expert.py`](scripts/train_stage2_expert.py) 使用 Stage 1 plain-text prefix
的 past-KV,训练 105.6M expert 预测 flow-matching vector field。目标 action 是按单样本统计量
标准化的相邻 waypoint XY delta,预测 action 经反标准化和累积求和恢复未来轨迹。
当前产物记录为 1,000 total steps:早期 250-step 权重缺少 optimizer state,之后以 legacy
warm start 运行了剩余 750 steps。因此它是 1,000-step 参数训练记录,但不是严格连续、
optimizer 完整恢复的 1,000-step run。新 checkpoint 已保存 expert、optimizer、step 和 RNG,
后续中断可用 `--resume` 精确恢复。
普通 reload evaluation 仍使用训练 expert 时的旧 plain-text prefix(sequence length 770)。
structured exact-cache handoff 的 prefix 长度为 777;接口验证成功,但旧 expert 没有在这个
新 prefix 分布上联合训练,所以该分支的轨迹质量只作诊断。
## 目录
```text
alpamayo-0.5B/
├── configs/ # 单样本实验配置
├── scripts/ # 下载、数据准备、训练、评估和比较入口
├── src/alpamayo_lite/ # VLM、action space、expert、diffusion 实现
├── tests/ # 单元测试和产物一致性测试
├── results/complete_comparison/ # 仓库中保留的轻量比较 JSON、CSV 和三方图
├── references/alpamayo-upstream/# 可选的本地只读上游 checkout;gitignored
├── models/ # 本地模型;gitignored
├── data/ # gated/raw/processed 数据;gitignored
├── outputs/ # checkpoint 和评估产物;gitignored
└── .venv/ # Python 3.12 虚拟环境;gitignored
```
## 安装
需要 Python 3.12。以下命令把环境和所有运行产物留在仓库目录内:
```bash
git clone <repository-url> alpamayo-0.5B
cd alpamayo-0.5B
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e '.[dev]'
```
登录 Hugging Face 时使用交互式认证,不要把 access token 写进配置、脚本、README、日志或
shell history:
```bash
hf auth login
```
## 运行
所有命令均从仓库根目录执行。
下载 0.5B VLM,并记录 checkpoint provenance:
```bash
python scripts/download_model.py --config configs/local_overfit.yaml
```
下载并物化一个 PAI 样本,然后验证 VLM KV 与 expert 的形状契约:
```bash
python scripts/materialize_pai_subset.py --config configs/local_overfit.yaml
python scripts/verify_connection.py --config configs/local_overfit.yaml
```
训练 Stage 1 自然语言 VLM:
```bash
python scripts/train_stage1_vlm.py \
--config configs/local_overfit.yaml \
--steps 80
```
训练并验证 VLM 的 structured/untruncated trajectory logits:
```bash
python scripts/train_vlm_trajectory_logits.py \
--config configs/local_overfit.yaml \
--output-dir outputs/stage1_vlm_trajectory \
--steps 160 \
--trajectory-vocab-size 128 \
--action-min -4 \
--action-max 4 \
--max-new-tokens 96
```
从头训练 Stage 2 到 1,000 total steps:
```bash
python scripts/train_stage2_expert.py \
--config configs/local_overfit.yaml \
--steps 1000
```
若 `outputs/stage2_expert/checkpoints/latest/` 中已有完整 checkpoint,则精确续训到总计
1,000 steps:
```bash
python scripts/train_stage2_expert.py \
--config configs/local_overfit.yaml \
--steps 1000 \
--resume
```
重新加载 VLM/expert,固定从 seed 12 开始做 16 个 diffusion rollouts:
```bash
python scripts/evaluate_pipeline.py \
--config configs/local_overfit.yaml \
--stage2-dir outputs/stage2_expert \
--num-rollouts 16 \
--base-seed 12 \
--output outputs/reload_evaluation.json
```
验证 source-style structured VLM exact cache 能直接进入旧 expert:
```bash
python scripts/verify_structured_vlm_handoff.py \
--config configs/local_overfit.yaml \
--vlm-dir outputs/stage1_vlm_trajectory \
--stage2-dir outputs/stage2_expert \
--output-dir outputs/structured_vlm_handoff
```
汇总 GT、VLM direct trajectory 和 diffusion seed 12:
```bash
python scripts/build_complete_comparison.py \
--diffusion-seed 12 \
--output-dir outputs/complete_comparison
```
## 当前结果
两条预测使用逐字节相同的 GT action 和 GT XY。下表由保存的 NPZ 重新计算;action MSE 的
单位是 normalized-action squared,轨迹指标单位是米。
| 输出 | 解码协议 | Action MSE | ADE | FDE | Max error |
| --- | --- | ---: | ---: | ---: | ---: |
| Ground truth | GT 对自身 | 0 | 0 m | 0 m | 0 m |
| VLM trajectory tokens | greedy、未截断、128-bin | 0.0003439452 | 0.026104664 m | 0.045444489 m | 0.054956669 m |
| Diffusion | flow matching、seed 12、10 steps | 0.7453363983 | 1.125946622 m | 0.258275141 m | 2.925430649 m |
VLM 的 32 个生成 token 与量化 GT 全部匹配,因此其非零轨迹误差完全来自 128-bin
反量化。Diffusion 是随机采样;seed 12 只代表一个 rollout。16 个 seed 的平均 ADE 为
`1.530302633 m`,不应把 seed 12 当作总体均值。
![GT、VLM 与 diffusion 三方比较](results/complete_comparison/trajectory_comparison.png)
### 文字对比
| 字段 | Ground truth | 模型输出 | Exact match |
| --- | --- | --- | ---: |
| Reasoning / CoT | Steer left following the temporary lane delineated by traffic cones through the construction zone. | Steer left following the temporary lane delineated by traffic cones through the construction zone. | 是 |
| Meta-action | `STEER_LEFT`(由 reasoning 规则派生) | `STEER_LEFT` | 是 |
| Diffusion conditioning text | 同上方 reasoning | 同上方 reasoning | 是 |
## 本地产物
仓库保留以下不含权重或 gated 样本的轻量结果,可直接审计完整三方对比:
- [GT/VLM/diffusion 完整比较 JSON](results/complete_comparison/comparison.json)
- [16 个 waypoint 的完整 CSV](results/complete_comparison/waypoints.csv)
- [GT/VLM/diffusion 三方比较图](results/complete_comparison/trajectory_comparison.png)
`models/`、`data/` 和 `outputs/` 默认不进入 Git。运行完成后,以下链接仅在本地工作区存在;
不要为了让它们在 GitHub 页面可用而提交 gated data 或 checkpoint 权重。
- [Stage 1 文字训练指标](outputs/stage1_vlm/stage1_metrics.json)
- [Structured VLM 训练、logits、cache 与生成指标](outputs/stage1_vlm_trajectory/trajectory_logits_metrics.json)
- [Structured VLM trajectory vocabulary](outputs/stage1_vlm_trajectory/trajectory_vocabulary.json)
- [Structured VLM 轨迹图](outputs/stage1_vlm_trajectory/vlm_discrete_trajectory.png)
- [Stage 2 训练指标](outputs/stage2_expert/stage2_metrics.json)
- [Stage 2 expert 配置](outputs/stage2_expert/expert_config.json)
- [Stage 2 seed-12 reload 轨迹图](outputs/stage2_expert/reload_trajectory.png)
- [16-seed reload 评估](outputs/reload_evaluation.json)
- [Structured exact-cache handoff 指标](outputs/structured_vlm_handoff/handoff_metrics.json)
- [Structured exact-cache handoff 轨迹图](outputs/structured_vlm_handoff/handoff_trajectory.png)
- [本地 GT/VLM/diffusion 完整比较 JSON](outputs/complete_comparison/comparison.json)
- [本地 16 个 waypoint CSV](outputs/complete_comparison/waypoints.csv)
- [本地 GT/VLM/diffusion 比较图](outputs/complete_comparison/trajectory_comparison.png)
没有列出或链接任何 checkpoint 权重文件。
## 测试状态
发布或提交结果前运行:
```bash
pytest -q
ruff check src scripts tests
```
| 检查 | 最近结果 |
| --- | --- |
| `pytest -q` | `11 passed` |
| `ruff check src scripts tests` | `All checks passed` |
## 已知限制与安全边界
- 这是一个 PAI `train` 样本的显式过拟合实验,没有验证跨 clip、跨场景或真实驾驶泛化。
- 本地 VLM 轨迹词表只有 128 bins,action 是 normalized per-waypoint XY delta;它不是上游
使用的 768-token vocabulary 或约 4,000-bin delta-tokenizer 配置,也不是上游
unicycle acceleration/curvature action space。
- action mean/scale 由同一个单样本目标轨迹拟合;VLM direct trajectory 的 32 个监督 token
也来自该目标,因此其近零误差只证明记忆和输出链路。
- `STEER_LEFT` 是 [`_infer_meta_action`](scripts/train_vlm_trajectory_logits.py) 从 reasoning
文字规则派生的标签,不是 PAI 中独立提供的 meta-action ground truth。
- 当前 expert 用旧 plain-text `"Continuous action:"` prefix 训练,并未与 structured VLM
prefix 或 exact generation cache 联合训练。structured handoff 证明 24-layer KV 兼容,
不代表该 prefix 下的轨迹质量已经收敛。
- source-style handoff 会在 `<|traj_future_start|>` 屏蔽 VLM 离散轨迹 logits;未截断 VLM
轨迹输出是一个单独的能力验证分支。
- 当前 1,000-step expert 包含一次缺少 optimizer state 的 legacy warm start;需要全新、严格
连续的训练曲线时应删除本地旧产物并从头运行。
- 模型输出和本项目都不能用于控制真实车辆或做安全决策。
- 不要提交或发布 Hugging Face access token、认证缓存、gated PAI 原始/处理数据、下载的模型
权重、训练 checkpoint 或可能包含私有路径/凭据的日志。公开前应再次检查 `git status` 和
Git 历史;仅提交有权再分发的代码与文档。
本项目代码按 [Apache-2.0](LICENSE) 发布。上游归属、修改说明及不随仓库分发的模型/数据
声明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。本地工作区可另行检出
`references/alpamayo-upstream/` 进行接口研究,但该目录不会随本仓库分发。
+26
View File
@@ -0,0 +1,26 @@
# Third-party notices
This repository is an unofficial, Alpamayo-inspired local validation project. It is not
affiliated with, endorsed by, or a release of NVIDIA Corporation.
## NVlabs/alpamayo
Parts of the local expert connection pattern and flow-matching implementation were derived
from or informed by [`NVlabs/alpamayo`](https://github.com/NVlabs/alpamayo), inspected at
commit `4b96c546ce3416e7b1e4d86e7649662852c3c1ac`.
- Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES.
- Licensed under the Apache License, Version 2.0.
- Locally modified files retain their SPDX copyright and license headers.
The upstream source checkout is not redistributed in this repository.
## Models and datasets
The code references the Hugging Face checkpoint `thkim0305/qwen2.5_0.5B_vl` and the gated
dataset `nvidia/PhysicalAI-Autonomous-Vehicles`. Neither downloaded model weights nor raw or
processed dataset samples are redistributed here. Their respective repository terms and
licenses apply independently from this project's Apache-2.0 code license.
Users must obtain their own authorized access, review the applicable terms, and avoid using
this research smoke test for real-vehicle control or safety decisions.
+56
View File
@@ -0,0 +1,56 @@
seed: 11
paths:
model_dir: models/qwen2.5_0.5B_vl
pai_raw_dir: data/raw/pai
manifest: data/processed/overfit_manifest.jsonl
stage1_dir: outputs/stage1_vlm
stage2_dir: outputs/stage2_expert
vlm:
repo_id: thkim0305/qwen2.5_0.5B_vl
dtype: float16
attn_implementation: eager
max_reasoning_tokens: 48
num_additional_image_tokens: 1
data:
repo_id: nvidia/PhysicalAI-Autonomous-Vehicles
num_clips: 1
camera: camera_front_wide_120fov
num_frames: 1
history_steps: 8
source_future_steps: 64
expert_waypoints: 16
time_step: 0.1
stage1:
steps: 80
learning_rate: 0.0001
weight_decay: 0.0
adam_epsilon: 0.0001
freeze_vision_tower: true
freeze_multimodal_projector: true
train_last_n_language_layers: 8
train_final_norm: true
train_lm_head: false
gradient_checkpointing: true
log_every: 5
stage2:
steps: 1000
learning_rate: 0.0001
weight_decay: 0.0
adam_epsilon: 0.0001
expert_hidden_size: 512
expert_intermediate_size: 2432
expert_num_layers: 24
expert_num_attention_heads: 8
expert_num_key_value_heads: 2
expert_head_dim: 64
expert_non_causal_attention: true
action_projection_hidden_size: 256
action_fourier_features: 20
inference_steps: 10
log_every: 50
checkpoint_every: 50
+47
View File
@@ -0,0 +1,47 @@
[build-system]
requires = ["setuptools>=75", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "alpamayo-qwen05b-validation"
version = "0.1.0"
description = "Two-stage local Alpamayo validation with a 0.5B Qwen VLM and a KV-conditioned flow-matching expert."
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.12,<3.13"
dependencies = [
"accelerate==1.12.0",
"av>=16.0.1",
"einops>=0.8.1",
"huggingface-hub==0.36.0",
"matplotlib>=3.9",
"numpy>=2.1,<3",
"pandas>=2.2",
"physical-ai-av>=0.2.0",
"pillow>=11",
"psutil>=6",
"pyarrow>=18",
"pyyaml>=6",
"safetensors>=0.5",
"scipy>=1.14",
"torch==2.8.0",
"torchvision==0.23.0",
"tqdm>=4.67",
"transformers==4.57.1",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"ruff>=0.9",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
extend-exclude = ["references"]
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

+17
View File
@@ -0,0 +1,17 @@
waypoint_index,source_future_index,time_s,gt_action_x,gt_action_y,vlm_action_x,vlm_action_y,diffusion_action_x,diffusion_action_y,vlm_action_error_l2,diffusion_action_error_l2,gt_x_m,gt_y_m,vlm_x_m,vlm_y_m,diffusion_x_m,diffusion_y_m,vlm_position_error_m,diffusion_position_error_m
1,0,0.1,-3.4397106170654297,-1.6662895679473877,-3.4330708980560303,-1.6692912578582764,-3.84375,-0.443359375,0.00728670095756034,1.2879464584308167,0.9489696025848389,-0.00039592807297594845,0.9537601470947266,-0.0006286799907684326,0.657459020614624,0.09442976862192154,0.004796195383473365,0.30654580759535116
2,4,0.5,0.46647655963897705,-1.6230756044387817,0.4724407196044922,-1.6062991619110107,-0.68017578125,-1.453125,0.01780506186402779,1.1591785017050988,4.71621561050415,0.002558943582698703,4.725309371948242,0.0036270394921302795,3.5974063873291016,0.11056257039308548,0.009156272498882664,1.1240101695561981
3,8,0.9,0.3931064307689667,-1.3917449712753296,0.40944862365722656,-1.4173228740692139,-0.204345703125,-2.953125,0.03035286444028776,1.6717824758008162,8.430525779724121,0.023451128974556923,8.451410293579102,0.02253592759370804,6.880660533905029,0.010385759174823761,0.02090455721909409,1.5499203153987877
4,12,1.3,0.26794740557670593,-1.1304054260253906,0.2834644317626953,-1.1023621559143066,1.0224609375,-0.82275390625,0.032050009363179546,0.8148252128373736,12.054534912109375,0.06460751593112946,12.086614608764648,0.06586679071187973,11.049043655395508,0.07539737224578857,0.03210440328783258,1.0055491476438747
5,16,1.7000000000000002,0.1750280112028122,-0.931678295135498,0.15748023986816406,-0.9133858680725098,-0.126953125,-0.8857421875,0.025348316840923476,0.3054549600301843,15.611503601074219,0.12117315828800201,15.630922317504883,0.12385082244873047,14.38813591003418,0.1355248987674713,0.019602459875540927,1.2234518707065785
6,21,2.2,1.3138797283172607,-0.017029158771038055,1.2913384437561035,-0.03149604797363281,2.5078125,-0.9423828125,0.02678433110733247,1.5105478634480827,19.990142822265625,0.24866046011447906,19.993297576904297,0.25021636486053467,19.628185272216797,0.19126051664352417,0.003517572516526143,0.36648058822783336
7,25,2.6,-0.0019493416184559464,-0.05992039293050766,-0.03149604797363281,-0.03149604797363281,0.2435302734375,-0.045867919921875,0.04099940539405987,0.24588150277253198,23.419424057006836,0.37282198667526245,23.401260375976562,0.37658190727233887,23.234577178955078,0.31651169061660767,0.018548754984252572,0.19323358343645852
8,29,3.0,0.07472366094589233,0.23706550896167755,0.09448814392089844,0.2204723358154297,1.005859375,0.17138671875,0.025806359338943326,0.9334492066902851,26.904024124145508,0.5200117230415344,26.90011978149414,0.5224850177764893,27.39098358154297,0.4586087167263031,0.004621804667577402,0.49081548705534744
9,33,3.4000000000000004,0.13519389927387238,0.3722502589225769,0.15748023986816406,0.34645652770996094,0.12469482421875,0.52001953125,0.034088085117141044,0.14814178485892907,30.432252883911133,0.6776836514472961,30.444427490234375,0.6781569123268127,30.911636352539062,0.6277386546134949,0.01218380133562631,0.4819782284527732
10,37,3.8000000000000003,0.06780000776052475,0.21779732406139374,0.09448814392089844,0.2204723358154297,0.432373046875,-0.6748046875,0.026821862343970004,0.9641845528178778,33.91185760498047,0.8233793377876282,33.94328689575195,0.8240600228309631,34.65427780151367,0.7042222023010254,0.03143666093157371,0.7519216522735415
11,42,4.3,1.1512386798858643,1.035483717918396,1.1653542518615723,1.039370059967041,1.95703125,0.78125,0.014640800071050068,0.84494760155749,38.173152923583984,1.0324782133102417,38.21476745605469,1.033460259437561,39.496944427490234,0.8936078548431396,0.04162611833154021,1.331055566937441
12,46,4.7,-0.15684714913368225,0.8499115109443665,-0.15748023986816406,0.8503937721252441,2.060546875,1.314453125,0.0007958515720084112,2.2655319837630143,41.49067687988281,1.2271878719329834,41.53183364868164,1.2282073497772217,44.41429901123047,1.124337911605835,0.04116939340134956,2.925430648852423
13,50,5.1000000000000005,-0.2395608276128769,0.9665660858154297,-0.2204723358154297,0.9763779640197754,-0.9697265625,2.46875,0.021462606388743272,1.6702390584698135,44.7485237121582,1.4309428930282593,44.80345153808594,1.4327231645584106,47.14533615112305,1.4445719718933105,0.054956668638742615,2.3968511884068464
14,54,5.5,-0.2766667306423187,0.9057282209396362,-0.2834646701812744,0.9133858680725098,-0.52783203125,1.48046875,0.010239704174774942,0.6272245881452143,47.97959899902344,1.6299805641174316,48.029624938964844,1.6323546171188354,50.19519805908203,1.688174843788147,0.050082240312057325,2.2163631852922747
15,58,5.9,-0.44114238023757935,0.7915734648704529,-0.4724409580230713,0.7874016761779785,-1.5810546875,0.258056640625,0.03157538269426274,1.2585866160107093,51.09200668334961,1.8201667070388794,51.11945343017578,1.8222172260284424,52.48516845703125,1.8369922637939453,0.027523236355242352,1.393263373166715
16,63,6.4,0.5104826092720032,1.4437676668167114,0.535433292388916,1.4173226356506348,-1.0625,1.236328125,0.03635761627740968,1.5866018569828055,54.8910026550293,2.0609238147735596,54.93644714355469,2.0609238147735596,55.14927673339844,2.061664581298828,0.045444488525390625,0.2582751406784516
1 waypoint_index source_future_index time_s gt_action_x gt_action_y vlm_action_x vlm_action_y diffusion_action_x diffusion_action_y vlm_action_error_l2 diffusion_action_error_l2 gt_x_m gt_y_m vlm_x_m vlm_y_m diffusion_x_m diffusion_y_m vlm_position_error_m diffusion_position_error_m
2 1 0 0.1 -3.4397106170654297 -1.6662895679473877 -3.4330708980560303 -1.6692912578582764 -3.84375 -0.443359375 0.00728670095756034 1.2879464584308167 0.9489696025848389 -0.00039592807297594845 0.9537601470947266 -0.0006286799907684326 0.657459020614624 0.09442976862192154 0.004796195383473365 0.30654580759535116
3 2 4 0.5 0.46647655963897705 -1.6230756044387817 0.4724407196044922 -1.6062991619110107 -0.68017578125 -1.453125 0.01780506186402779 1.1591785017050988 4.71621561050415 0.002558943582698703 4.725309371948242 0.0036270394921302795 3.5974063873291016 0.11056257039308548 0.009156272498882664 1.1240101695561981
4 3 8 0.9 0.3931064307689667 -1.3917449712753296 0.40944862365722656 -1.4173228740692139 -0.204345703125 -2.953125 0.03035286444028776 1.6717824758008162 8.430525779724121 0.023451128974556923 8.451410293579102 0.02253592759370804 6.880660533905029 0.010385759174823761 0.02090455721909409 1.5499203153987877
5 4 12 1.3 0.26794740557670593 -1.1304054260253906 0.2834644317626953 -1.1023621559143066 1.0224609375 -0.82275390625 0.032050009363179546 0.8148252128373736 12.054534912109375 0.06460751593112946 12.086614608764648 0.06586679071187973 11.049043655395508 0.07539737224578857 0.03210440328783258 1.0055491476438747
6 5 16 1.7000000000000002 0.1750280112028122 -0.931678295135498 0.15748023986816406 -0.9133858680725098 -0.126953125 -0.8857421875 0.025348316840923476 0.3054549600301843 15.611503601074219 0.12117315828800201 15.630922317504883 0.12385082244873047 14.38813591003418 0.1355248987674713 0.019602459875540927 1.2234518707065785
7 6 21 2.2 1.3138797283172607 -0.017029158771038055 1.2913384437561035 -0.03149604797363281 2.5078125 -0.9423828125 0.02678433110733247 1.5105478634480827 19.990142822265625 0.24866046011447906 19.993297576904297 0.25021636486053467 19.628185272216797 0.19126051664352417 0.003517572516526143 0.36648058822783336
8 7 25 2.6 -0.0019493416184559464 -0.05992039293050766 -0.03149604797363281 -0.03149604797363281 0.2435302734375 -0.045867919921875 0.04099940539405987 0.24588150277253198 23.419424057006836 0.37282198667526245 23.401260375976562 0.37658190727233887 23.234577178955078 0.31651169061660767 0.018548754984252572 0.19323358343645852
9 8 29 3.0 0.07472366094589233 0.23706550896167755 0.09448814392089844 0.2204723358154297 1.005859375 0.17138671875 0.025806359338943326 0.9334492066902851 26.904024124145508 0.5200117230415344 26.90011978149414 0.5224850177764893 27.39098358154297 0.4586087167263031 0.004621804667577402 0.49081548705534744
10 9 33 3.4000000000000004 0.13519389927387238 0.3722502589225769 0.15748023986816406 0.34645652770996094 0.12469482421875 0.52001953125 0.034088085117141044 0.14814178485892907 30.432252883911133 0.6776836514472961 30.444427490234375 0.6781569123268127 30.911636352539062 0.6277386546134949 0.01218380133562631 0.4819782284527732
11 10 37 3.8000000000000003 0.06780000776052475 0.21779732406139374 0.09448814392089844 0.2204723358154297 0.432373046875 -0.6748046875 0.026821862343970004 0.9641845528178778 33.91185760498047 0.8233793377876282 33.94328689575195 0.8240600228309631 34.65427780151367 0.7042222023010254 0.03143666093157371 0.7519216522735415
12 11 42 4.3 1.1512386798858643 1.035483717918396 1.1653542518615723 1.039370059967041 1.95703125 0.78125 0.014640800071050068 0.84494760155749 38.173152923583984 1.0324782133102417 38.21476745605469 1.033460259437561 39.496944427490234 0.8936078548431396 0.04162611833154021 1.331055566937441
13 12 46 4.7 -0.15684714913368225 0.8499115109443665 -0.15748023986816406 0.8503937721252441 2.060546875 1.314453125 0.0007958515720084112 2.2655319837630143 41.49067687988281 1.2271878719329834 41.53183364868164 1.2282073497772217 44.41429901123047 1.124337911605835 0.04116939340134956 2.925430648852423
14 13 50 5.1000000000000005 -0.2395608276128769 0.9665660858154297 -0.2204723358154297 0.9763779640197754 -0.9697265625 2.46875 0.021462606388743272 1.6702390584698135 44.7485237121582 1.4309428930282593 44.80345153808594 1.4327231645584106 47.14533615112305 1.4445719718933105 0.054956668638742615 2.3968511884068464
15 14 54 5.5 -0.2766667306423187 0.9057282209396362 -0.2834646701812744 0.9133858680725098 -0.52783203125 1.48046875 0.010239704174774942 0.6272245881452143 47.97959899902344 1.6299805641174316 48.029624938964844 1.6323546171188354 50.19519805908203 1.688174843788147 0.050082240312057325 2.2163631852922747
16 15 58 5.9 -0.44114238023757935 0.7915734648704529 -0.4724409580230713 0.7874016761779785 -1.5810546875 0.258056640625 0.03157538269426274 1.2585866160107093 51.09200668334961 1.8201667070388794 51.11945343017578 1.8222172260284424 52.48516845703125 1.8369922637939453 0.027523236355242352 1.393263373166715
17 16 63 6.4 0.5104826092720032 1.4437676668167114 0.535433292388916 1.4173226356506348 -1.0625 1.236328125 0.03635761627740968 1.5866018569828055 54.8910026550293 2.0609238147735596 54.93644714355469 2.0609238147735596 55.14927673339844 2.061664581298828 0.045444488525390625 0.2582751406784516
+722
View File
@@ -0,0 +1,722 @@
#!/usr/bin/env python3
"""Build one artifact set comparing ground truth, VLM, and diffusion outputs."""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import textwrap
from pathlib import Path
from typing import Any
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
PROJECT_ROOT = Path(__file__).resolve().parents[1]
METRIC_NAMES = ("action_mse", "ade_m", "fde_m", "max_error_m")
def _resolve(path: Path) -> Path:
return path.expanduser().resolve() if path.is_absolute() else (PROJECT_ROOT / path).resolve()
def _require_file(path: Path) -> Path:
resolved = _resolve(path)
if not resolved.is_file():
raise FileNotFoundError(f"Required input does not exist: {resolved}")
return resolved
def _read_json(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"Expected a JSON object in {path}")
return value
def _read_manifest_row(path: Path, index: int) -> dict[str, Any]:
rows = [line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
if not 0 <= index < len(rows):
raise IndexError(f"Manifest index {index} is outside 0..{len(rows) - 1}: {path}")
value = json.loads(rows[index])
if not isinstance(value, dict):
raise ValueError(f"Expected a JSON object at manifest row {index}: {path}")
return value
def _load_npz(path: Path) -> dict[str, np.ndarray]:
with np.load(path, allow_pickle=False) as archive:
return {key: np.asarray(archive[key]) for key in archive.files}
def _require_array(
arrays: dict[str, np.ndarray], key: str, source: Path, *, allow_batch: bool = True
) -> np.ndarray:
if key not in arrays:
raise KeyError(f"Missing NPZ key {key!r} in {source}")
value = arrays[key]
if allow_batch and value.ndim == 3 and value.shape[0] == 1:
value = value[0]
if value.ndim != 2 or value.shape[1] != 2:
raise ValueError(f"{source}:{key} must have shape (N, 2) or (1, N, 2), got {value.shape}")
if not np.issubdtype(value.dtype, np.number) or not np.isfinite(value).all():
raise ValueError(f"{source}:{key} must contain only finite numeric values")
return value.astype(np.float64, copy=False)
def _require_xy_prefix(arrays: dict[str, np.ndarray], key: str, source: Path) -> np.ndarray:
if key not in arrays:
raise KeyError(f"Missing NPZ key {key!r} in {source}")
value = arrays[key]
if value.ndim != 2 or value.shape[1] < 2:
raise ValueError(f"{source}:{key} must have shape (N, >=2), got {value.shape}")
if not np.issubdtype(value.dtype, np.number) or not np.isfinite(value).all():
raise ValueError(f"{source}:{key} must contain only finite numeric values")
return value[:, :2].astype(np.float64, copy=False)
def _require_token_values(arrays: dict[str, np.ndarray], key: str, source: Path) -> np.ndarray:
if key not in arrays:
raise KeyError(f"Missing NPZ key {key!r} in {source}")
value = np.asarray(arrays[key]).reshape(-1)
if not np.issubdtype(value.dtype, np.integer):
raise ValueError(f"{source}:{key} must contain integer token values")
return value.astype(np.int64, copy=False)
def _assert_same_truth(
name: str,
vlm_truth: np.ndarray,
diffusion_truth: np.ndarray,
vlm_path: Path,
diffusion_path: Path,
) -> float:
if vlm_truth.shape != diffusion_truth.shape:
raise ValueError(
f"Ground-truth {name} shape mismatch: {vlm_path} has {vlm_truth.shape}, "
f"{diffusion_path} has {diffusion_truth.shape}"
)
max_abs_difference = float(np.max(np.abs(vlm_truth - diffusion_truth), initial=0.0))
if not np.array_equal(vlm_truth, diffusion_truth):
raise ValueError(
f"Ground-truth {name} mismatch between {vlm_path} and {diffusion_path}; "
f"max_abs_difference={max_abs_difference:.9g}"
)
return max_abs_difference
def _extract_component(text: str, component: str) -> str:
start = f"<|{component}_start|>"
end = f"<|{component}_end|>"
if text.count(start) != 1 or text.count(end) != 1:
raise ValueError(f"Structured VLM text must contain exactly one {start} and one {end}")
start_index = text.find(start)
content_start = start_index + len(start)
end_index = text.find(end, content_start)
if start_index < 0 or end_index < content_start:
raise ValueError(f"Structured VLM markers are out of order for {component}")
value = text[content_start:end_index].strip()
if not value:
raise ValueError(f"Structured VLM component {component!r} is empty")
return value
def _prediction_metrics(
predicted_action: np.ndarray,
predicted_xy: np.ndarray,
target_action: np.ndarray,
target_xy: np.ndarray,
) -> dict[str, float]:
position_errors = np.linalg.norm(predicted_xy - target_xy, axis=-1)
return {
"action_mse": float(np.mean(np.square(predicted_action - target_action))),
"ade_m": float(np.mean(position_errors)),
"fde_m": float(position_errors[-1]),
"max_error_m": float(np.max(position_errors)),
}
def _reported_metrics(metrics: dict[str, Any], section: str | None = None) -> dict[str, float]:
source: Any = metrics if section is None else metrics.get(section)
if not isinstance(source, dict):
raise KeyError(f"Missing metrics section {section!r}")
result: dict[str, float] = {}
for name in METRIC_NAMES:
if name not in source:
raise KeyError(f"Missing reported metric {name!r} in section {section!r}")
result[name] = float(source[name])
return result
def _metric_validation(
computed: dict[str, float], reported: dict[str, float], tolerance: float = 1e-5
) -> dict[str, Any]:
absolute_differences = {name: abs(computed[name] - reported[name]) for name in METRIC_NAMES}
consistent = all(difference <= tolerance for difference in absolute_differences.values())
if not consistent:
raise ValueError(
"Computed metrics do not match the metrics JSON: "
f"absolute_differences={absolute_differences}, tolerance={tolerance}"
)
return {
"consistent": True,
"absolute_tolerance": tolerance,
"absolute_differences": absolute_differences,
}
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _display_path(path: Path) -> str:
try:
return str(path.relative_to(PROJECT_ROOT))
except ValueError:
return str(path)
def _source(path: Path, *, keys: list[str] | None = None) -> dict[str, Any]:
result: dict[str, Any] = {"path": _display_path(path), "sha256": _sha256(path)}
if keys is not None:
result["keys"] = keys
return result
def _decode_action(
action: np.ndarray,
history_xy: np.ndarray,
mean: np.ndarray,
scale: np.ndarray,
) -> np.ndarray:
deltas = action * scale + mean
return history_xy[-1] + np.cumsum(deltas, axis=0)
def _validate_decoded_xy(
name: str,
decoded_xy: np.ndarray,
stored_xy: np.ndarray,
tolerance: float = 1e-5,
) -> float:
max_abs_difference = float(np.max(np.abs(decoded_xy - stored_xy), initial=0.0))
if not np.allclose(decoded_xy, stored_xy, rtol=0.0, atol=tolerance):
raise ValueError(
f"Decoded {name} action does not reproduce stored XY; "
f"max_abs_difference={max_abs_difference:.9g}, tolerance={tolerance}"
)
return max_abs_difference
def _write_waypoints(path: Path, waypoints: list[dict[str, Any]]) -> None:
fieldnames = [
"waypoint_index",
"source_future_index",
"time_s",
"gt_action_x",
"gt_action_y",
"vlm_action_x",
"vlm_action_y",
"diffusion_action_x",
"diffusion_action_y",
"vlm_action_error_l2",
"diffusion_action_error_l2",
"gt_x_m",
"gt_y_m",
"vlm_x_m",
"vlm_y_m",
"diffusion_x_m",
"diffusion_y_m",
"vlm_position_error_m",
"diffusion_position_error_m",
]
with path.open("w", encoding="utf-8", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=fieldnames, lineterminator="\n")
writer.writeheader()
for waypoint in waypoints:
writer.writerow(
{
"waypoint_index": waypoint["index"],
"source_future_index": waypoint["source_future_index"],
"time_s": waypoint["time_s"],
"gt_action_x": waypoint["action"]["ground_truth"][0],
"gt_action_y": waypoint["action"]["ground_truth"][1],
"vlm_action_x": waypoint["action"]["vlm"][0],
"vlm_action_y": waypoint["action"]["vlm"][1],
"diffusion_action_x": waypoint["action"]["diffusion"][0],
"diffusion_action_y": waypoint["action"]["diffusion"][1],
"vlm_action_error_l2": waypoint["action_error_l2"]["vlm"],
"diffusion_action_error_l2": waypoint["action_error_l2"]["diffusion"],
"gt_x_m": waypoint["xy_m"]["ground_truth"][0],
"gt_y_m": waypoint["xy_m"]["ground_truth"][1],
"vlm_x_m": waypoint["xy_m"]["vlm"][0],
"vlm_y_m": waypoint["xy_m"]["vlm"][1],
"diffusion_x_m": waypoint["xy_m"]["diffusion"][0],
"diffusion_y_m": waypoint["xy_m"]["diffusion"][1],
"vlm_position_error_m": waypoint["position_error_m"]["vlm"],
"diffusion_position_error_m": waypoint["position_error_m"]["diffusion"],
}
)
def _save_plot(
path: Path,
history_xy: np.ndarray,
target_xy: np.ndarray,
vlm_xy: np.ndarray,
diffusion_xy: np.ndarray,
target_action: np.ndarray,
vlm_action: np.ndarray,
diffusion_action: np.ndarray,
waypoint_times_s: np.ndarray,
vlm_errors: np.ndarray,
diffusion_errors: np.ndarray,
ground_truth_reasoning: str,
vlm_reasoning: str,
) -> None:
figure, axes = plt.subplots(2, 2, figsize=(14, 10))
trajectory_axis, action_x_axis, action_y_axis, error_axis = axes.reshape(-1)
trajectory_axis.plot(history_xy[:, 0], history_xy[:, 1], "o-", color="#555555", label="history")
trajectory_axis.plot(
target_xy[:, 0], target_xy[:, 1], "o-", color="#d62728", label="ground truth"
)
trajectory_axis.plot(
vlm_xy[:, 0],
vlm_xy[:, 1],
"o--",
color="#1f77b4",
label=f"VLM discrete (ADE {vlm_errors.mean():.3f} m)",
)
trajectory_axis.plot(
diffusion_xy[:, 0],
diffusion_xy[:, 1],
"o-.",
color="#ff7f0e",
label=f"diffusion (ADE {diffusion_errors.mean():.3f} m)",
)
trajectory_axis.scatter([0.0], [0.0], marker="x", color="black", label="current ego")
trajectory_axis.set_xlabel("ego-relative x (m)")
trajectory_axis.set_ylabel("ego-relative y (m)")
trajectory_axis.set_title("XY trajectory (axes independently scaled)")
trajectory_axis.grid(alpha=0.25)
trajectory_axis.legend()
action_series = (
("ground truth", target_action, "#d62728", "o-"),
("VLM discrete", vlm_action, "#1f77b4", "o--"),
("diffusion", diffusion_action, "#ff7f0e", "o-."),
)
for label, action, color, style in action_series:
action_x_axis.plot(waypoint_times_s, action[:, 0], style, color=color, label=label)
action_y_axis.plot(waypoint_times_s, action[:, 1], style, color=color, label=label)
for axis, component in ((action_x_axis, "x"), (action_y_axis, "y")):
axis.set_xlabel("future time (s)")
axis.set_ylabel(f"normalized action {component}")
axis.set_title(f"Normalized action {component}")
axis.grid(alpha=0.25)
action_x_axis.legend()
error_axis.plot(waypoint_times_s, vlm_errors, "o--", color="#1f77b4", label="VLM discrete")
error_axis.plot(waypoint_times_s, diffusion_errors, "o-.", color="#ff7f0e", label="diffusion")
error_axis.set_xlabel("future time (s)")
error_axis.set_ylabel("position error (m)")
error_axis.set_title("Per-waypoint position error")
error_axis.grid(alpha=0.25)
error_axis.legend()
match = "yes" if vlm_reasoning == ground_truth_reasoning else "no"
text_block = (
f"Ground truth text: {ground_truth_reasoning}\n"
f"VLM text: {vlm_reasoning}\n"
f"Exact match: {match}"
)
figure.suptitle("Ground truth vs VLM logits vs diffusion")
figure.text(0.02, 0.015, textwrap.fill(text_block, width=150), va="bottom")
figure.tight_layout(rect=(0.0, 0.10, 1.0, 0.97))
figure.savefig(path, dpi=180)
plt.close(figure)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--vlm-npz",
type=Path,
default=Path("outputs/stage1_vlm_trajectory/trajectory_logits.npz"),
)
parser.add_argument(
"--vlm-metrics",
type=Path,
default=Path("outputs/stage1_vlm_trajectory/trajectory_logits_metrics.json"),
)
parser.add_argument(
"--diffusion-npz",
type=Path,
default=Path("outputs/stage2_expert/reload_prediction.npz"),
)
parser.add_argument(
"--diffusion-metrics",
type=Path,
default=Path("outputs/stage2_expert/stage2_metrics.json"),
)
parser.add_argument(
"--expert-config",
type=Path,
default=Path("outputs/stage2_expert/expert_config.json"),
)
parser.add_argument(
"--manifest", type=Path, default=Path("data/processed/overfit_manifest.jsonl")
)
parser.add_argument("--manifest-index", type=int, default=0)
parser.add_argument("--diffusion-seed", type=int, default=12)
parser.add_argument("--time-step-s", type=float, default=0.1)
parser.add_argument("--output-dir", type=Path, default=Path("outputs/complete_comparison"))
args = parser.parse_args()
if args.time_step_s <= 0:
raise ValueError("--time-step-s must be positive")
vlm_npz_path = _require_file(args.vlm_npz)
vlm_metrics_path = _require_file(args.vlm_metrics)
diffusion_npz_path = _require_file(args.diffusion_npz)
diffusion_metrics_path = _require_file(args.diffusion_metrics)
expert_config_path = _require_file(args.expert_config)
manifest_path = _require_file(args.manifest)
output_dir = _resolve(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
vlm_arrays = _load_npz(vlm_npz_path)
diffusion_arrays = _load_npz(diffusion_npz_path)
vlm_metrics = _read_json(vlm_metrics_path)
diffusion_metrics = _read_json(diffusion_metrics_path)
expert_config = _read_json(expert_config_path)
manifest = _read_manifest_row(manifest_path, args.manifest_index)
trajectory_value = manifest.get("trajectory_path")
if not isinstance(trajectory_value, str):
raise KeyError("Manifest row lacks string field 'trajectory_path'")
trajectory_path = _require_file(Path(trajectory_value))
trajectory_arrays = _load_npz(trajectory_path)
vlm_target_action = _require_array(vlm_arrays, "target_action", vlm_npz_path)
vlm_target_xy = _require_array(vlm_arrays, "target_xy", vlm_npz_path)
vlm_action = _require_array(vlm_arrays, "generated_action", vlm_npz_path)
vlm_xy = _require_array(vlm_arrays, "generated_xy", vlm_npz_path)
diffusion_target_action = _require_array(diffusion_arrays, "target_action", diffusion_npz_path)
diffusion_target_xy = _require_array(diffusion_arrays, "target_xy", diffusion_npz_path)
diffusion_action = _require_array(diffusion_arrays, "predicted_action", diffusion_npz_path)
diffusion_xy = _require_array(diffusion_arrays, "predicted_xy", diffusion_npz_path)
history_xy = _require_array(
diffusion_arrays, "history_xy", diffusion_npz_path, allow_batch=False
)
target_token_values = _require_token_values(vlm_arrays, "quantized_target_values", vlm_npz_path)
generated_token_values = _require_token_values(vlm_arrays, "generated_values", vlm_npz_path)
dataset_history_xy = _require_xy_prefix(trajectory_arrays, "history_xyz", trajectory_path)
dataset_target_xy = _require_xy_prefix(trajectory_arrays, "future_xyz", trajectory_path)
if "waypoint_indices" not in trajectory_arrays:
raise KeyError(f"Missing NPZ key 'waypoint_indices' in {trajectory_path}")
source_future_indices = np.asarray(trajectory_arrays["waypoint_indices"]).reshape(-1)
if not np.issubdtype(source_future_indices.dtype, np.integer):
raise ValueError(f"{trajectory_path}:waypoint_indices must contain integers")
action_truth_difference = _assert_same_truth(
"action", vlm_target_action, diffusion_target_action, vlm_npz_path, diffusion_npz_path
)
xy_truth_difference = _assert_same_truth(
"XY", vlm_target_xy, diffusion_target_xy, vlm_npz_path, diffusion_npz_path
)
dataset_xy_difference = _assert_same_truth(
"dataset XY", vlm_target_xy, dataset_target_xy, vlm_npz_path, trajectory_path
)
history_xy_difference = _assert_same_truth(
"history XY", history_xy, dataset_history_xy, diffusion_npz_path, trajectory_path
)
target_action = vlm_target_action
target_xy = vlm_target_xy
expected_shape = target_action.shape
for name, value in {
"target_xy": target_xy,
"vlm_action": vlm_action,
"vlm_xy": vlm_xy,
"diffusion_action": diffusion_action,
"diffusion_xy": diffusion_xy,
}.items():
if value.shape != expected_shape:
raise ValueError(f"{name} shape {value.shape} does not match target {expected_shape}")
if source_future_indices.shape != (target_xy.shape[0],):
raise ValueError(
"waypoint_indices length does not match the compared trajectory: "
f"{source_future_indices.shape} vs {target_xy.shape[0]}"
)
if target_token_values.shape != generated_token_values.shape:
raise ValueError(
"VLM generated and target trajectory token shapes differ: "
f"{generated_token_values.shape} vs {target_token_values.shape}"
)
if target_token_values.size != target_action.size:
raise ValueError(
f"Expected {target_action.size} trajectory tokens, got {target_token_values.size}"
)
token_matches = generated_token_values == target_token_values
waypoint_times_s = (source_future_indices.astype(np.float64) + 1.0) * args.time_step_s
action_mean = np.asarray(expert_config.get("action_mean"), dtype=np.float64)
action_scale = np.asarray(expert_config.get("action_scale"), dtype=np.float64)
if action_mean.shape != (2,) or action_scale.shape != (2,):
raise ValueError("expert_config action_mean and action_scale must both have shape (2,)")
if not np.isfinite(action_mean).all() or not np.isfinite(action_scale).all():
raise ValueError("expert_config action statistics must be finite")
if np.any(action_scale <= 0):
raise ValueError("expert_config action_scale must be positive")
action_decode_differences = {
"ground_truth": _validate_decoded_xy(
"ground-truth",
_decode_action(target_action, history_xy, action_mean, action_scale),
target_xy,
),
"vlm": _validate_decoded_xy(
"VLM",
_decode_action(vlm_action, history_xy, action_mean, action_scale),
vlm_xy,
),
"diffusion": _validate_decoded_xy(
"diffusion",
_decode_action(diffusion_action, history_xy, action_mean, action_scale),
diffusion_xy,
),
}
untruncated = vlm_metrics.get("untruncated_vlm_generation")
if not isinstance(untruncated, dict) or not isinstance(untruncated.get("raw_text"), str):
raise KeyError("VLM metrics lack untruncated_vlm_generation.raw_text")
vlm_structured_text = untruncated["raw_text"]
target_structured_text = vlm_metrics.get("structured_target")
if not isinstance(target_structured_text, str):
raise KeyError("VLM metrics lack structured_target")
vlm_reasoning = _extract_component(vlm_structured_text, "cot")
vlm_meta_action = _extract_component(vlm_structured_text, "meta_action")
vlm_target_reasoning = _extract_component(target_structured_text, "cot")
vlm_target_meta_action = _extract_component(target_structured_text, "meta_action")
ground_truth_reasoning = manifest.get("reasoning")
if not isinstance(ground_truth_reasoning, str):
raise KeyError("Manifest row lacks string field 'reasoning'")
diffusion_target_reasoning = diffusion_metrics.get("target_reasoning")
if vlm_target_reasoning != ground_truth_reasoning:
raise ValueError("VLM target reasoning does not match the manifest ground truth")
if diffusion_target_reasoning != ground_truth_reasoning:
raise ValueError("Diffusion target reasoning does not match the manifest ground truth")
if vlm_metrics.get("target_meta_action") != vlm_target_meta_action:
raise ValueError("Structured target meta-action does not match VLM metrics")
vlm_position_errors = np.linalg.norm(vlm_xy - target_xy, axis=-1)
diffusion_position_errors = np.linalg.norm(diffusion_xy - target_xy, axis=-1)
vlm_action_errors = np.linalg.norm(vlm_action - target_action, axis=-1)
diffusion_action_errors = np.linalg.norm(diffusion_action - target_action, axis=-1)
vlm_computed_metrics = _prediction_metrics(vlm_action, vlm_xy, target_action, target_xy)
diffusion_computed_metrics = _prediction_metrics(
diffusion_action, diffusion_xy, target_action, target_xy
)
vlm_reported_metrics = _reported_metrics(vlm_metrics, section="untruncated_vlm_generation")
diffusion_reported_metrics = _reported_metrics(diffusion_metrics)
vlm_metric_validation = _metric_validation(vlm_computed_metrics, vlm_reported_metrics)
diffusion_metric_validation = _metric_validation(
diffusion_computed_metrics, diffusion_reported_metrics
)
waypoints: list[dict[str, Any]] = []
for index in range(target_xy.shape[0]):
waypoints.append(
{
"index": index + 1,
"source_future_index": int(source_future_indices[index]),
"time_s": float(waypoint_times_s[index]),
"action": {
"ground_truth": target_action[index].tolist(),
"vlm": vlm_action[index].tolist(),
"diffusion": diffusion_action[index].tolist(),
},
"action_error_l2": {
"vlm": float(vlm_action_errors[index]),
"diffusion": float(diffusion_action_errors[index]),
},
"xy_m": {
"ground_truth": target_xy[index].tolist(),
"vlm": vlm_xy[index].tolist(),
"diffusion": diffusion_xy[index].tolist(),
},
"position_error_m": {
"vlm": float(vlm_position_errors[index]),
"diffusion": float(diffusion_position_errors[index]),
},
}
)
comparison = {
"schema_version": 1,
"sample": {
"manifest_index": args.manifest_index,
"clip_id": manifest.get("clip_id"),
"t0_us": manifest.get("t0_us"),
"camera": manifest.get("camera"),
"history_steps": manifest.get("history_steps"),
"expert_waypoints": manifest.get("expert_waypoints"),
"source_time_step_s": args.time_step_s,
"image_path": _display_path(_resolve(Path(str(manifest.get("image_path"))))),
"trajectory_path": _display_path(trajectory_path),
},
"prediction_protocol": {
"vlm": {
"decoding": "greedy untruncated structured generation",
"trajectory_representation": "32 discrete normalized-XY-delta tokens",
"trajectory_vocabulary_bins": int(vlm_metrics["vocabulary"]["size"]),
},
"diffusion": {
"decoding": "flow-matching sample",
"seed": args.diffusion_seed,
"inference_steps": expert_config.get("inference_steps"),
},
"units": {
"action_mse": "normalized_action_squared",
"trajectory": "metres",
},
},
"text": {
"ground_truth_reasoning": ground_truth_reasoning,
"vlm_reasoning": vlm_reasoning,
"vlm_reasoning_exact_match": vlm_reasoning == ground_truth_reasoning,
"vlm_meta_action": vlm_meta_action,
"target_meta_action": vlm_target_meta_action,
"vlm_meta_action_exact_match": vlm_meta_action == vlm_target_meta_action,
"vlm_structured_output": vlm_structured_text,
"target_structured_output": target_structured_text,
"vlm_structured_exact_match": vlm_structured_text == target_structured_text,
"diffusion_conditioning_reasoning": diffusion_metrics.get("generated_reasoning"),
"diffusion_reasoning_exact_match": diffusion_metrics.get("generated_reasoning")
== ground_truth_reasoning,
},
"arrays": {
"ground_truth": {
"action": target_action.tolist(),
"xy_m": target_xy.tolist(),
"discrete_token_values": target_token_values.tolist(),
},
"vlm": {
"action": vlm_action.tolist(),
"xy_m": vlm_xy.tolist(),
"discrete_token_values": generated_token_values.tolist(),
},
"diffusion": {
"action": diffusion_action.tolist(),
"xy_m": diffusion_xy.tolist(),
"seed": args.diffusion_seed,
},
"history_xy_m": history_xy.tolist(),
"source_future_indices": source_future_indices.tolist(),
"waypoint_times_s": waypoint_times_s.tolist(),
},
"waypoints": waypoints,
"metrics": {
"vlm": {
"computed_from_npz": vlm_computed_metrics,
"reported_in_metrics_json": vlm_reported_metrics,
"trajectory_token_matches": int(token_matches.sum()),
"trajectory_token_count": int(token_matches.size),
"trajectory_token_accuracy": float(token_matches.mean()),
},
"diffusion": {
"computed_from_npz": diffusion_computed_metrics,
"reported_in_metrics_json": diffusion_reported_metrics,
"seed": args.diffusion_seed,
},
"diffusion_minus_vlm": {
name: diffusion_computed_metrics[name] - vlm_computed_metrics[name]
for name in METRIC_NAMES
},
},
"validation": {
"ground_truth_action_exact_match": True,
"ground_truth_action_max_abs_difference": action_truth_difference,
"ground_truth_xy_exact_match": True,
"ground_truth_xy_max_abs_difference": xy_truth_difference,
"dataset_target_xy_exact_match": True,
"dataset_target_xy_max_abs_difference": dataset_xy_difference,
"dataset_history_xy_exact_match": True,
"dataset_history_xy_max_abs_difference": history_xy_difference,
"ground_truth_text_matches_manifest": True,
"vlm_discrete_tokens_exact_match": bool(token_matches.all()),
"action_decode_max_abs_differences": action_decode_differences,
"vlm_reported_metrics_match_npz": vlm_metric_validation,
"diffusion_reported_metrics_match_npz": diffusion_metric_validation,
},
"data_sources": {
"manifest": _source(manifest_path),
"vlm_npz": _source(vlm_npz_path, keys=sorted(vlm_arrays)),
"vlm_metrics": _source(vlm_metrics_path),
"diffusion_npz": _source(diffusion_npz_path, keys=sorted(diffusion_arrays)),
"diffusion_metrics": _source(diffusion_metrics_path),
"expert_config": _source(expert_config_path),
"trajectory_npz": _source(trajectory_path, keys=sorted(trajectory_arrays)),
},
"artifacts": {
"comparison_json": _display_path(output_dir / "comparison.json"),
"waypoints_csv": _display_path(output_dir / "waypoints.csv"),
"trajectory_plot": _display_path(output_dir / "trajectory_comparison.png"),
},
}
comparison_path = output_dir / "comparison.json"
waypoints_path = output_dir / "waypoints.csv"
plot_path = output_dir / "trajectory_comparison.png"
comparison_path.write_text(
json.dumps(comparison, indent=2, ensure_ascii=False, allow_nan=False) + "\n",
encoding="utf-8",
)
_write_waypoints(waypoints_path, waypoints)
_save_plot(
plot_path,
history_xy,
target_xy,
vlm_xy,
diffusion_xy,
target_action,
vlm_action,
diffusion_action,
waypoint_times_s,
vlm_position_errors,
diffusion_position_errors,
ground_truth_reasoning,
vlm_reasoning,
)
print(
json.dumps(
{
"comparison_json": str(comparison_path),
"waypoints_csv": str(waypoints_path),
"trajectory_plot": str(plot_path),
"vlm_metrics": vlm_computed_metrics,
"diffusion_metrics": diffusion_computed_metrics,
"diffusion_seed": args.diffusion_seed,
"vlm_trajectory_tokens": {
"matches": int(token_matches.sum()),
"count": int(token_matches.size),
},
"vlm_reasoning_exact_match": vlm_reasoning == ground_truth_reasoning,
"ground_truth_consistent": True,
},
indent=2,
ensure_ascii=False,
)
)
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from huggingface_hub import model_info, snapshot_download
from alpamayo_lite.config import load_config
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--config", default="configs/local_overfit.yaml")
args = parser.parse_args()
cfg = load_config(args.config)
output_dir = cfg.resolve(cfg.paths.model_dir)
output_dir.mkdir(parents=True, exist_ok=True)
info = model_info(cfg.vlm.repo_id, files_metadata=True)
total = info.safetensors.total if info.safetensors is not None else None
if total is not None and not (700_000_000 <= total <= 900_000_000):
raise RuntimeError(f"Unexpected model size for {cfg.vlm.repo_id}: {total:,} parameters")
snapshot_download(
repo_id=cfg.vlm.repo_id,
local_dir=output_dir,
allow_patterns=[
"*.json",
"*.txt",
"*.safetensors",
],
)
provenance = {
"repo_id": cfg.vlm.repo_id,
"revision": info.sha,
"safetensors_parameters": total,
"pipeline_tag": info.pipeline_tag,
"library_name": info.library_name,
}
(output_dir / "download_provenance.json").write_text(
json.dumps(provenance, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
print(json.dumps(provenance, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import statistics
from functools import partial
from pathlib import Path
from typing import Any
import torch
from safetensors.torch import load_file
from alpamayo_lite.action_space import RelativeDeltaActionSpace
from alpamayo_lite.config import ExperimentConfig, load_config
from alpamayo_lite.data import OverfitSample, load_manifest
from alpamayo_lite.diffusion import FlowMatching
from alpamayo_lite.evaluation import (
save_prediction_arrays,
save_trajectory_plot,
trajectory_metrics,
)
from alpamayo_lite.expert import ExpertArchitecture, KVCachedDiffusionExpert, PrefixKV
from alpamayo_lite.prompts import build_expert_prefix, build_vlm_prefix
from alpamayo_lite.runtime import (
empty_device_cache,
parameter_counts,
resolve_dtype,
seed_everything,
select_device,
write_json,
)
from alpamayo_lite.vlm import (
extract_prefix_kv,
generate_reasoning,
load_vlm,
vlm_text_config,
)
def _resolve(cfg: ExperimentConfig, value: str) -> Path:
path = Path(value).expanduser()
return path.resolve() if path.is_absolute() else (cfg.root / path).resolve()
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _summary(values: list[float]) -> dict[str, float]:
return {
"mean": statistics.fmean(values),
"std": statistics.pstdev(values),
"min": min(values),
"max": max(values),
}
def _evaluate_expert(
stage2_dir: Path,
vlm: Any,
prefix: PrefixKV,
sample: OverfitSample,
device: torch.device,
dtype: torch.dtype,
num_rollouts: int,
base_seed: int,
) -> dict[str, Any]:
expert_config = _read_json(stage2_dir / "expert_config.json")
metrics_path = stage2_dir / "stage2_metrics.json"
saved_metrics = _read_json(metrics_path) if metrics_path.exists() else {}
architecture = ExpertArchitecture(**expert_config["architecture"])
expert = KVCachedDiffusionExpert(vlm_text_config(vlm), architecture).to(
device=device, dtype=dtype
)
expert.load_state_dict(load_file(stage2_dir / "expert.safetensors"), strict=True)
expert.requires_grad_(False)
expert.eval()
action_shape = tuple(int(value) for value in expert_config["action_shape"])
if len(action_shape) != 2:
raise ValueError(f"Invalid action shape in {stage2_dir}: {action_shape}")
flow = FlowMatching(
action_shape=action_shape,
inference_steps=int(expert_config["inference_steps"]),
)
action_space = RelativeDeltaActionSpace(
torch.tensor(expert_config["action_mean"], device=device),
torch.tensor(expert_config["action_scale"], device=device),
).to(device)
history, future = sample.load_trajectory()
history_xy = history[None, :, :2].to(device)
target_xy = future[None, :, :2].to(device)
target_action = action_space.trajectory_to_action(history_xy, target_xy).float()
rollouts: list[dict[str, float | int]] = []
first_prediction: tuple[torch.Tensor, torch.Tensor] | None = None
for index in range(num_rollouts):
seed = base_seed + index
seed_everything(seed)
predicted_action = flow.sample(
batch_size=1,
step_fn=partial(expert, prefix=prefix),
device=device,
dtype=dtype,
).float()
predicted_xy = action_space.action_to_trajectory(predicted_action, history_xy)
record: dict[str, float | int] = {
"seed": seed,
"action_mse": float(
torch.nn.functional.mse_loss(predicted_action, target_action).cpu()
),
**trajectory_metrics(predicted_xy, target_xy),
}
if not torch.isfinite(predicted_action).all() or not torch.isfinite(predicted_xy).all():
raise RuntimeError(f"Non-finite reload prediction for seed {seed}")
rollouts.append(record)
if first_prediction is None:
first_prediction = (predicted_action, predicted_xy)
assert first_prediction is not None
first_action, first_xy = first_prediction
save_prediction_arrays(
stage2_dir / "reload_prediction.npz",
history[:, :2],
target_action[0],
first_action[0],
target_xy[0],
first_xy[0],
)
save_trajectory_plot(
stage2_dir / "reload_trajectory.png",
history[:, :2],
target_xy[0],
first_xy[0],
)
metric_names = ("action_mse", "ade_m", "fde_m", "max_error_m")
result = {
"stage2_dir": str(stage2_dir),
"checkpoint_steps": int(
expert_config.get("completed_steps", saved_metrics.get("steps", 0))
),
"expert_parameters": parameter_counts(expert),
"architecture": expert_config["architecture"],
"action_mean": expert_config["action_mean"],
"action_scale": expert_config["action_scale"],
"action_shape": list(action_shape),
"inference_steps": flow.inference_steps,
"num_rollouts": num_rollouts,
"rollouts": rollouts,
"summary": {
name: _summary([float(record[name]) for record in rollouts])
for name in metric_names
},
}
del expert
empty_device_cache(device)
return result
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--config", default="configs/local_overfit.yaml")
parser.add_argument(
"--stage2-dir",
action="append",
dest="stage2_dirs",
help="Stage-2 directory to reload; repeat to compare checkpoints.",
)
parser.add_argument("--num-rollouts", type=int, default=16)
parser.add_argument("--base-seed", type=int, default=None)
parser.add_argument("--output", default="outputs/reload_evaluation.json")
args = parser.parse_args()
if args.num_rollouts <= 0:
raise ValueError("--num-rollouts must be positive")
cfg = load_config(args.config)
seed_everything(cfg.seed)
device = select_device()
dtype = resolve_dtype(cfg.vlm.dtype, device)
stage2_values = args.stage2_dirs or [cfg.paths.stage2_dir]
stage2_dirs = [_resolve(cfg, value) for value in stage2_values]
sample = load_manifest(cfg.resolve(cfg.paths.manifest))[0]
history, _ = sample.load_trajectory()
stage1_dir = cfg.resolve(cfg.paths.stage1_dir)
processor, vlm = load_vlm(stage1_dir, cfg.vlm, device, dtype)
vlm.requires_grad_(False)
vlm.eval()
generated_reasoning = generate_reasoning(
vlm,
processor,
sample.load_image(),
build_vlm_prefix(history),
device,
dtype,
cfg.vlm.max_reasoning_tokens,
)
prefix = extract_prefix_kv(
vlm,
processor,
sample.load_image(),
build_expert_prefix(history, generated_reasoning),
device,
dtype,
)
base_seed = args.base_seed if args.base_seed is not None else cfg.seed + 1
models = [
_evaluate_expert(
stage2_dir,
vlm,
prefix,
sample,
device,
dtype,
args.num_rollouts,
base_seed,
)
for stage2_dir in stage2_dirs
]
result = {
"stage": "reloaded_pipeline_evaluation",
"device": str(device),
"dtype": str(dtype),
"stage1_dir": str(stage1_dir),
"vlm_parameters": parameter_counts(vlm),
"generated_reasoning": generated_reasoning,
"target_reasoning": sample.reasoning,
"reasoning_exact_match": generated_reasoning == sample.reasoning,
"prefix_layers": prefix.num_layers,
"prefix_sequence_length": prefix.sequence_length,
"prefix_key_shape": list(prefix.keys[0].shape),
"models": models,
}
output = _resolve(cfg, args.output)
write_json(output, result)
print(json.dumps(result, indent=2, ensure_ascii=False))
del vlm
empty_device_cache(device)
if __name__ == "__main__":
main()
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
from huggingface_hub import get_token, hf_hub_download
from physical_ai_av import PhysicalAIAVDatasetInterface
from PIL import Image
from scipy.spatial.transform import Rotation
from alpamayo_lite.config import load_config
from alpamayo_lite.prompts import clean_reasoning_text
CLIP_DURATION_US = 20_000_000
def parse_events(cell: object) -> list[dict[str, Any]]:
if cell is None or (np.isscalar(cell) and pd.isna(cell)):
return []
parsed = json.loads(cell) if isinstance(cell, str) else cell
if not isinstance(parsed, (list, tuple)):
return []
return [event for event in parsed if isinstance(event, dict)]
def collect_candidates(
reasoning_path: Path,
history_seconds: float,
future_seconds: float,
) -> list[dict[str, Any]]:
frame = pd.read_parquet(reasoning_path)
candidates: list[dict[str, Any]] = []
for clip_id, events_cell in frame["events"].items():
for event in parse_events(events_cell):
if "event_start_timestamp" not in event:
continue
t0_us = int(event["event_start_timestamp"])
if t0_us < int(history_seconds * 1_000_000):
continue
if t0_us + int(future_seconds * 1_000_000) > CLIP_DURATION_US:
continue
# Current PAI releases call the driving rationale/command `coc`.
# Keep `cot` as a compatibility fallback for older metadata shards.
reasoning = clean_reasoning_text(str(event.get("coc") or event.get("cot") or ""))
candidates.append({"clip_id": str(clip_id), "t0_us": t0_us, "reasoning": reasoning})
if not candidates:
raise RuntimeError("No valid reasoning events remain after trajectory safety filtering")
return candidates
def load_local_trajectory(
interface: PhysicalAIAVDatasetInterface,
clip_id: str,
t0_us: int,
history_steps: int,
future_steps: int,
time_step: float,
) -> tuple[np.ndarray, np.ndarray]:
egomotion = interface.get_clip_feature(
clip_id, interface.features.LABELS.EGOMOTION, maybe_stream=True
)
history_offsets = np.arange(-(history_steps - 1), 1, dtype=np.int64)
history_timestamps = t0_us + history_offsets * int(time_step * 1_000_000)
future_offsets = np.arange(1, future_steps + 1, dtype=np.int64)
future_timestamps = t0_us + future_offsets * int(time_step * 1_000_000)
history_state = egomotion(history_timestamps)
future_state = egomotion(future_timestamps)
history_xyz = history_state.pose.translation
future_xyz = future_state.pose.translation
t0_rotation = Rotation.from_quat(history_state.pose.rotation.as_quat()[-1])
inverse = t0_rotation.inv()
origin = history_xyz[-1].copy()
history_local = inverse.apply(history_xyz - origin).astype(np.float32)
future_local = inverse.apply(future_xyz - origin).astype(np.float32)
return history_local, future_local
def load_camera_frame(
interface: PhysicalAIAVDatasetInterface,
clip_id: str,
t0_us: int,
camera_name: str,
) -> np.ndarray:
camera_feature = getattr(interface.features.CAMERA, camera_name.upper())
reader = interface.get_clip_feature(clip_id, camera_feature, maybe_stream=True)
frames, _ = reader.decode_images_from_timestamps(np.asarray([t0_us], dtype=np.int64))
return frames[0]
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--config", default="configs/local_overfit.yaml")
args = parser.parse_args()
cfg = load_config(args.config)
token = os.environ.get("HF_TOKEN") or get_token()
if not token:
raise SystemExit(
"PAI is gated. Set HF_TOKEN for an account that accepted the dataset license."
)
raw_dir = cfg.resolve(cfg.paths.pai_raw_dir)
raw_dir.mkdir(parents=True, exist_ok=True)
cache_dir = cfg.root / ".cache" / "huggingface"
reasoning_file = Path(
hf_hub_download(
repo_id=cfg.data.repo_id,
repo_type="dataset",
filename="reasoning/ood_reasoning.parquet",
token=token,
cache_dir=cache_dir,
local_dir=raw_dir,
)
)
candidates = collect_candidates(
reasoning_file,
history_seconds=(cfg.data.history_steps - 1) * cfg.data.time_step,
future_seconds=cfg.data.source_future_steps * cfg.data.time_step,
)
rng = np.random.default_rng(cfg.seed)
selected_indices = rng.choice(
len(candidates), size=min(cfg.data.num_clips, len(candidates)), replace=False
)
selected = [candidates[int(index)] for index in selected_indices]
interface = PhysicalAIAVDatasetInterface(
token=token,
cache_dir=cache_dir,
local_dir=raw_dir,
confirm_download_threshold_gb=1000.0,
)
processed_dir = cfg.resolve(cfg.paths.manifest).parent
image_dir = processed_dir / "images"
trajectory_dir = processed_dir / "trajectories"
image_dir.mkdir(parents=True, exist_ok=True)
trajectory_dir.mkdir(parents=True, exist_ok=True)
rows: list[dict[str, Any]] = []
for sample_index, event in enumerate(selected):
clip_id = event["clip_id"]
t0_us = event["t0_us"]
history, future_full = load_local_trajectory(
interface=interface,
clip_id=clip_id,
t0_us=t0_us,
history_steps=cfg.data.history_steps,
future_steps=cfg.data.source_future_steps,
time_step=cfg.data.time_step,
)
waypoint_indices = np.linspace(
0,
cfg.data.source_future_steps - 1,
cfg.data.expert_waypoints,
dtype=np.int64,
)
future = future_full[waypoint_indices]
frame = load_camera_frame(
interface=interface,
clip_id=clip_id,
t0_us=t0_us,
camera_name=cfg.data.camera,
)
stem = f"sample_{sample_index:02d}_{clip_id}_{t0_us}"
image_path = image_dir / f"{stem}.jpg"
trajectory_path = trajectory_dir / f"{stem}.npz"
Image.fromarray(frame).save(image_path, quality=95)
np.savez_compressed(
trajectory_path,
history_xyz=history,
future_xyz=future,
source_future_xyz=future_full,
waypoint_indices=waypoint_indices,
)
rows.append(
{
"clip_id": clip_id,
"t0_us": t0_us,
"reasoning": event["reasoning"],
"image_path": str(image_path.relative_to(cfg.root)),
"trajectory_path": str(trajectory_path.relative_to(cfg.root)),
"camera": cfg.data.camera,
"history_steps": cfg.data.history_steps,
"expert_waypoints": cfg.data.expert_waypoints,
}
)
manifest_path = cfg.resolve(cfg.paths.manifest)
manifest_path.write_text(
"".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
encoding="utf-8",
)
print(json.dumps({"manifest": str(manifest_path), "samples": rows}, indent=2))
if __name__ == "__main__":
main()
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import time
import torch
from tqdm import trange
from alpamayo_lite.config import load_config
from alpamayo_lite.data import load_manifest
from alpamayo_lite.prompts import build_stage1_text, build_vlm_prefix
from alpamayo_lite.runtime import (
empty_device_cache,
resolve_dtype,
seed_everything,
select_device,
write_json,
)
from alpamayo_lite.vlm import (
configure_stage1_trainability,
enforce_frozen_module_eval,
generate_reasoning,
load_vlm,
prepare_stage1_batch,
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--config", default="configs/local_overfit.yaml")
parser.add_argument("--steps", type=int, default=None)
args = parser.parse_args()
cfg = load_config(args.config)
seed_everything(cfg.seed)
device = select_device()
dtype = resolve_dtype(cfg.vlm.dtype, device)
samples = load_manifest(cfg.resolve(cfg.paths.manifest))
processor, model = load_vlm(cfg.resolve(cfg.paths.model_dir), cfg.vlm, device, dtype)
first = samples[0]
first_history, _ = first.load_trajectory()
baseline = generate_reasoning(
model,
processor,
first.load_image(),
build_vlm_prefix(first_history),
device,
dtype,
cfg.vlm.max_reasoning_tokens,
)
trainability = configure_stage1_trainability(model, cfg.stage1)
print({"device": str(device), "dtype": str(dtype), "trainability": trainability})
batches = []
for sample in samples:
history, _ = sample.load_trajectory()
prefix, full_text = build_stage1_text(history, sample.reasoning)
inputs, labels, prefix_length = prepare_stage1_batch(
processor,
sample.load_image(),
prefix,
full_text,
device,
dtype,
)
batches.append((inputs, labels, prefix_length))
optimizer = torch.optim.AdamW(
[parameter for parameter in model.parameters() if parameter.requires_grad],
lr=cfg.stage1.learning_rate,
weight_decay=cfg.stage1.weight_decay,
eps=cfg.stage1.adam_epsilon,
)
steps = args.steps if args.steps is not None else cfg.stage1.steps
losses: list[float] = []
started = time.perf_counter()
model.train()
enforce_frozen_module_eval(model)
for step in trange(steps, desc="stage1-vlm"):
inputs, labels, _ = batches[step % len(batches)]
optimizer.zero_grad(set_to_none=True)
outputs = model(**inputs, labels=labels, use_cache=False, return_dict=True)
loss = outputs.loss
if loss is None or not torch.isfinite(loss):
raise RuntimeError(f"Non-finite Stage-1 loss at step {step}: {loss}")
loss.backward()
torch.nn.utils.clip_grad_norm_(
[parameter for parameter in model.parameters() if parameter.requires_grad], 1.0
)
optimizer.step()
loss_value = float(loss.detach().cpu())
losses.append(loss_value)
if step == 0 or (step + 1) % cfg.stage1.log_every == 0:
print(f"stage1 step={step + 1} loss={loss_value:.6f}")
elapsed = time.perf_counter() - started
model.gradient_checkpointing_disable()
model.config.use_cache = True
model.model.language_model.config.use_cache = True
output_dir = cfg.resolve(cfg.paths.stage1_dir)
output_dir.mkdir(parents=True, exist_ok=True)
model.save_pretrained(output_dir, safe_serialization=True, max_shard_size="2GB")
processor.save_pretrained(output_dir)
trained = generate_reasoning(
model,
processor,
first.load_image(),
build_vlm_prefix(first_history),
device,
dtype,
cfg.vlm.max_reasoning_tokens,
)
metrics = {
"stage": "stage1_vlm",
"device": str(device),
"dtype": str(dtype),
"trainability": trainability,
"steps": steps,
"elapsed_seconds": elapsed,
"initial_loss": losses[0],
"final_loss": losses[-1],
"minimum_loss": min(losses),
"losses": losses,
"baseline_text": baseline,
"trained_text": trained,
"target_text": first.reasoning,
}
write_json(output_dir / "stage1_metrics.json", metrics)
print(metrics)
del optimizer, model
empty_device_cache(device)
if __name__ == "__main__":
main()
+451
View File
@@ -0,0 +1,451 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import time
from dataclasses import asdict
from functools import partial
from pathlib import Path
from typing import Any
import torch
from safetensors.torch import load_file, save_file
from tqdm import trange
from alpamayo_lite.action_space import RelativeDeltaActionSpace
from alpamayo_lite.config import load_config
from alpamayo_lite.data import load_manifest
from alpamayo_lite.diffusion import FlowMatching
from alpamayo_lite.evaluation import (
save_prediction_arrays,
save_trajectory_plot,
trajectory_metrics,
)
from alpamayo_lite.expert import ExpertArchitecture, KVCachedDiffusionExpert
from alpamayo_lite.prompts import build_expert_prefix, build_vlm_prefix
from alpamayo_lite.runtime import (
empty_device_cache,
parameter_counts,
resolve_dtype,
seed_everything,
select_device,
write_json,
)
from alpamayo_lite.vlm import (
extract_prefix_kv,
generate_reasoning,
load_vlm,
vlm_text_config,
)
def _cpu_copy(value: Any) -> Any:
if isinstance(value, torch.Tensor):
return value.detach().cpu()
if isinstance(value, dict):
return {key: _cpu_copy(item) for key, item in value.items()}
if isinstance(value, list):
return [_cpu_copy(item) for item in value]
if isinstance(value, tuple):
return tuple(_cpu_copy(item) for item in value)
return value
def _optimizer_to(optimizer: torch.optim.Optimizer, device: torch.device) -> None:
for state in optimizer.state.values():
for key, value in state.items():
if isinstance(value, torch.Tensor):
state[key] = value.to(device)
def _atomic_save_safetensors(module: torch.nn.Module, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.tmp")
save_file(
{key: value.detach().cpu().contiguous() for key, value in module.state_dict().items()},
temporary,
)
os.replace(temporary, path)
def _atomic_torch_save(value: Any, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.tmp")
torch.save(value, temporary)
os.replace(temporary, path)
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _save_training_checkpoint(
checkpoint_dir: Path,
expert: KVCachedDiffusionExpert,
optimizer: torch.optim.Optimizer,
architecture: ExpertArchitecture,
completed_steps: int,
losses: list[float],
elapsed_seconds: float,
device: torch.device,
) -> None:
checkpoint_dir.mkdir(parents=True, exist_ok=True)
_atomic_save_safetensors(expert, checkpoint_dir / "expert.safetensors")
state: dict[str, Any] = {
"completed_steps": completed_steps,
"losses": losses,
"elapsed_seconds": elapsed_seconds,
"architecture": asdict(architecture),
"optimizer": _cpu_copy(optimizer.state_dict()),
"torch_rng_state": torch.get_rng_state(),
}
if device.type == "mps" and hasattr(torch.mps, "get_rng_state"):
state["device_rng_state"] = torch.mps.get_rng_state().cpu()
elif device.type == "cuda":
state["device_rng_state"] = torch.cuda.get_rng_state(device).cpu()
_atomic_torch_save(state, checkpoint_dir / "training_state.pt")
write_json(
checkpoint_dir / "progress.json",
{
"completed_steps": completed_steps,
"latest_loss": losses[-1],
"minimum_loss": min(losses),
"elapsed_seconds": elapsed_seconds,
"architecture": asdict(architecture),
},
)
def _load_resume_state(
output_dir: Path,
expert: KVCachedDiffusionExpert,
optimizer: torch.optim.Optimizer,
architecture: ExpertArchitecture,
device: torch.device,
) -> tuple[int, list[float], float, bool, str]:
checkpoint_dir = output_dir / "checkpoints" / "latest"
checkpoint_weights = checkpoint_dir / "expert.safetensors"
checkpoint_state = checkpoint_dir / "training_state.pt"
if checkpoint_weights.exists() and checkpoint_state.exists():
state = torch.load(checkpoint_state, map_location="cpu", weights_only=True)
if state.get("architecture") != asdict(architecture):
raise RuntimeError("Checkpoint architecture does not match the current config")
expert.load_state_dict(load_file(checkpoint_weights), strict=True)
optimizer.load_state_dict(state["optimizer"])
_optimizer_to(optimizer, device)
torch.set_rng_state(state["torch_rng_state"])
device_rng_state = state.get("device_rng_state")
if device_rng_state is not None and device.type == "mps":
torch.mps.set_rng_state(device_rng_state)
elif device_rng_state is not None and device.type == "cuda":
torch.cuda.set_rng_state(device_rng_state, device)
return (
int(state["completed_steps"]),
[float(value) for value in state["losses"]],
float(state.get("elapsed_seconds", 0.0)),
True,
str(checkpoint_dir),
)
final_weights = output_dir / "expert.safetensors"
final_metrics = output_dir / "stage2_metrics.json"
final_config = output_dir / "expert_config.json"
if not (final_weights.exists() and final_metrics.exists() and final_config.exists()):
raise FileNotFoundError(
"No resumable Stage-2 checkpoint found. Expected either checkpoints/latest or "
"the legacy expert.safetensors + stage2_metrics.json + expert_config.json files."
)
saved_config = _read_json(final_config)
if saved_config.get("architecture") != asdict(architecture):
raise RuntimeError("Legacy expert architecture does not match the current config")
metrics = _read_json(final_metrics)
expert.load_state_dict(load_file(final_weights), strict=True)
completed_steps = int(metrics["steps"])
losses = [float(value) for value in metrics["losses"]]
if completed_steps != len(losses):
raise RuntimeError(
f"Legacy metrics are inconsistent: steps={completed_steps}, losses={len(losses)}"
)
return (
completed_steps,
losses,
float(metrics.get("elapsed_seconds", 0.0)),
False,
str(final_weights),
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--config", default="configs/local_overfit.yaml")
parser.add_argument(
"--steps",
type=int,
default=None,
help="Total target steps. With --resume, only the missing steps are run.",
)
parser.add_argument(
"--resume",
action="store_true",
help="Resume from checkpoints/latest, or warm-start from the legacy final expert.",
)
args = parser.parse_args()
cfg = load_config(args.config)
seed_everything(cfg.seed)
device = select_device()
dtype = resolve_dtype(cfg.vlm.dtype, device)
samples = load_manifest(cfg.resolve(cfg.paths.manifest))
stage1_dir = cfg.resolve(cfg.paths.stage1_dir)
if not (stage1_dir / "stage1_metrics.json").exists():
raise SystemExit("Stage-1 checkpoint is missing; run train_stage1_vlm.py first")
processor, vlm = load_vlm(stage1_dir, cfg.vlm, device, dtype)
vlm.requires_grad_(False)
vlm.eval()
vlm_counts = parameter_counts(vlm)
if vlm_counts["trainable"] != 0:
raise RuntimeError(f"Stage-2 VLM must be fully frozen: {vlm_counts}")
histories = []
futures = []
images = []
prefixes = []
for sample in samples:
history, future = sample.load_trajectory()
histories.append(history[:, :2])
futures.append(future[:, :2])
images.append(sample.load_image())
prefixes.append(
extract_prefix_kv(
vlm,
processor,
images[-1],
build_expert_prefix(history, sample.reasoning),
device,
dtype,
)
)
history_tensor = torch.stack(histories).to(device=device, dtype=torch.float32)
future_tensor = torch.stack(futures).to(device=device, dtype=torch.float32)
statistics = RelativeDeltaActionSpace.fit(history_tensor, future_tensor)
action_space = RelativeDeltaActionSpace(statistics.mean, statistics.scale).to(device)
target_actions = action_space.trajectory_to_action(history_tensor, future_tensor).to(dtype)
stage2 = cfg.stage2
architecture = ExpertArchitecture(
hidden_size=stage2.expert_hidden_size,
intermediate_size=stage2.expert_intermediate_size,
num_layers=stage2.expert_num_layers,
num_attention_heads=stage2.expert_num_attention_heads,
num_key_value_heads=stage2.expert_num_key_value_heads,
head_dim=stage2.expert_head_dim,
action_projection_hidden_size=stage2.action_projection_hidden_size,
action_fourier_features=stage2.action_fourier_features,
non_causal_attention=stage2.expert_non_causal_attention,
)
expert = KVCachedDiffusionExpert(vlm_text_config(vlm), architecture).to(
device=device, dtype=dtype
)
expert_counts = parameter_counts(expert)
if not (70_000_000 <= expert_counts["total"] <= 120_000_000):
raise RuntimeError(
f"Scaled expert parameter count is outside the expected range: {expert_counts}"
)
flow = FlowMatching(
action_shape=(cfg.data.expert_waypoints, 2),
inference_steps=stage2.inference_steps,
)
optimizer = torch.optim.AdamW(
expert.parameters(),
lr=stage2.learning_rate,
weight_decay=stage2.weight_decay,
eps=stage2.adam_epsilon,
)
target_steps = args.steps if args.steps is not None else stage2.steps
if target_steps <= 0:
raise ValueError("Stage-2 target steps must be positive")
if stage2.checkpoint_every <= 0:
raise ValueError("stage2.checkpoint_every must be positive")
output_dir = cfg.resolve(cfg.paths.stage2_dir)
output_dir.mkdir(parents=True, exist_ok=True)
checkpoint_dir = output_dir / "checkpoints" / "latest"
completed_steps = 0
losses: list[float] = []
previous_elapsed = 0.0
optimizer_state_restored = False
resume_source: str | None = None
if args.resume:
(
completed_steps,
losses,
previous_elapsed,
optimizer_state_restored,
resume_source,
) = _load_resume_state(output_dir, expert, optimizer, architecture, device)
if completed_steps > target_steps:
raise ValueError(
f"Checkpoint already has {completed_steps} steps, beyond target {target_steps}"
)
mode = "exact checkpoint resume" if optimizer_state_restored else "legacy warm start"
print(
f"stage2 {mode}: source={resume_source} completed={completed_steps} "
f"remaining={target_steps - completed_steps}"
)
new_losses: list[float] = []
started = time.perf_counter()
expert.train()
progress = trange(
completed_steps,
target_steps,
desc="stage2-expert",
initial=completed_steps,
total=target_steps,
)
for step in progress:
sample_index = step % len(samples)
target = target_actions[sample_index : sample_index + 1]
training_data = flow.construct_training_data(target)
optimizer.zero_grad(set_to_none=True)
prediction = expert(
noisy_action=training_data["noisy_action"],
timestep=training_data["timestep"],
prefix=prefixes[sample_index],
)
loss = flow.loss(training_data, prediction)
if not torch.isfinite(loss):
raise RuntimeError(f"Non-finite Stage-2 loss at step {step}: {loss}")
loss.backward()
torch.nn.utils.clip_grad_norm_(expert.parameters(), 1.0)
optimizer.step()
value = float(loss.detach().cpu())
losses.append(value)
new_losses.append(value)
if step == 0 or (step + 1) % stage2.log_every == 0:
print(f"stage2 step={step + 1} loss={value:.6f}")
if (step + 1) % stage2.checkpoint_every == 0:
elapsed_so_far = previous_elapsed + (time.perf_counter() - started)
_save_training_checkpoint(
checkpoint_dir,
expert,
optimizer,
architecture,
step + 1,
losses,
elapsed_so_far,
device,
)
elapsed_this_run = time.perf_counter() - started
elapsed = previous_elapsed + elapsed_this_run
_save_training_checkpoint(
checkpoint_dir,
expert,
optimizer,
architecture,
target_steps,
losses,
elapsed,
device,
)
_atomic_save_safetensors(expert, output_dir / "expert.safetensors")
write_json(
output_dir / "expert_config.json",
{
"architecture": asdict(architecture),
"action_mean": action_space.mean.detach().cpu().tolist(),
"action_scale": action_space.scale.detach().cpu().tolist(),
"action_shape": [cfg.data.expert_waypoints, 2],
"inference_steps": stage2.inference_steps,
"stage1_checkpoint": str(stage1_dir),
"completed_steps": target_steps,
},
)
first = samples[0]
first_history, first_future = first.load_trajectory()
generated_reasoning = generate_reasoning(
vlm,
processor,
first.load_image(),
build_vlm_prefix(first_history),
device,
dtype,
cfg.vlm.max_reasoning_tokens,
)
generated_prefix = extract_prefix_kv(
vlm,
processor,
first.load_image(),
build_expert_prefix(first_history, generated_reasoning),
device,
dtype,
)
expert.eval()
seed_everything(cfg.seed + 1)
predicted_action = flow.sample(
batch_size=1,
step_fn=partial(expert, prefix=generated_prefix),
device=device,
dtype=dtype,
).float()
predicted_xy = action_space.action_to_trajectory(
predicted_action, first_history[None, :, :2].to(device)
)
target_xy = first_future[None, :, :2].to(device)
target_action = action_space.trajectory_to_action(
first_history[None, :, :2].to(device), target_xy
)
metrics = {
"stage": "stage2_expert",
"device": str(device),
"dtype": str(dtype),
"vlm_parameters": vlm_counts,
"expert_parameters": expert_counts,
"architecture": asdict(architecture),
"steps": target_steps,
"additional_steps": len(new_losses),
"resumed_from_step": completed_steps,
"resume_source": resume_source,
"optimizer_state_restored": optimizer_state_restored,
"learning_rate": stage2.learning_rate,
"elapsed_seconds": elapsed,
"elapsed_seconds_this_run": elapsed_this_run,
"initial_loss": losses[0],
"resume_initial_loss": new_losses[0] if new_losses else None,
"final_loss": losses[-1],
"minimum_loss": min(losses),
"losses": losses,
"generated_reasoning": generated_reasoning,
"target_reasoning": first.reasoning,
"action_mse": float(
torch.nn.functional.mse_loss(predicted_action, target_action.float()).cpu()
),
**trajectory_metrics(predicted_xy, target_xy),
}
write_json(output_dir / "stage2_metrics.json", metrics)
save_prediction_arrays(
output_dir / "prediction.npz",
first_history[:, :2],
target_action[0],
predicted_action[0],
target_xy[0],
predicted_xy[0],
)
save_trajectory_plot(
output_dir / "trajectory.png",
first_history[:, :2],
target_xy[0],
predicted_xy[0],
)
print(metrics)
del optimizer, expert, vlm
empty_device_cache(device)
if __name__ == "__main__":
main()
+662
View File
@@ -0,0 +1,662 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any
import numpy as np
import torch
from transformers import LogitsProcessorList, StoppingCriteriaList
from tqdm import trange
from alpamayo_lite.action_space import RelativeDeltaActionSpace
from alpamayo_lite.config import load_config
from alpamayo_lite.data import load_manifest
from alpamayo_lite.evaluation import save_trajectory_plot, trajectory_metrics
from alpamayo_lite.expert import PrefixKV
from alpamayo_lite.prompts import build_vlm_prefix
from alpamayo_lite.runtime import (
empty_device_cache,
parameter_counts,
resolve_dtype,
seed_everything,
select_device,
write_json,
)
from alpamayo_lite.trajectory_tokens import (
DiscreteTrajectoryMask,
StopOneTokenAfterBoundary,
TrajectoryVocabulary,
add_trajectory_vocabulary,
build_structured_target,
dequantize_action,
extract_trajectory_values,
mask_after_boundary,
quantize_action,
)
from alpamayo_lite.vlm import (
configure_stage1_trainability,
enforce_frozen_module_eval,
load_vlm,
prepare_stage1_batch,
process_image_text,
)
def _resolve(root: Path, value: str) -> Path:
path = Path(value).expanduser()
return path.resolve() if path.is_absolute() else (root / path).resolve()
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _infer_meta_action(reasoning: str) -> str:
lowered = reasoning.lower()
for phrase, label in (
("steer left", "STEER_LEFT"),
("turn left", "TURN_LEFT"),
("steer right", "STEER_RIGHT"),
("turn right", "TURN_RIGHT"),
("stop", "STOP"),
("slow", "SLOW_DOWN"),
):
if phrase in lowered:
return label
return "CONTINUE"
def _vocabulary_from_tokenizer(tokenizer: Any, size: int) -> TrajectoryVocabulary:
start_id = int(tokenizer.convert_tokens_to_ids("<i0>"))
end_id = int(tokenizer.convert_tokens_to_ids(f"<i{size - 1}>"))
if end_id - start_id + 1 != size:
raise RuntimeError("Reloaded trajectory vocabulary is not contiguous")
special_tokens = (
"<|cot_start|>",
"<|cot_end|>",
"<|meta_action_start|>",
"<|meta_action_end|>",
"<|traj_future_start|>",
"<|traj_future_end|>",
)
special_token_ids = {
token: int(tokenizer.convert_tokens_to_ids(token)) for token in special_tokens
}
return TrajectoryVocabulary(size, start_id, end_id, special_token_ids)
def _cache_summary(cache: object) -> dict[str, Any]:
prefix = PrefixKV.from_transformers_cache(cache)
first_key = prefix.keys[0]
sample = first_key[..., : min(8, first_key.shape[-2]), : min(8, first_key.shape[-1])]
return {
"layers": prefix.num_layers,
"sequence_length": prefix.sequence_length,
"first_key_shape": list(first_key.shape),
"first_key_sample_sum": float(sample.float().sum().cpu()),
"all_finite": bool(
all(torch.isfinite(key).all() and torch.isfinite(value).all() for key, value in zip(prefix.keys, prefix.values))
),
}
def _top_tokens(logits: torch.Tensor, tokenizer: Any, count: int = 5) -> list[dict[str, Any]]:
values, ids = torch.topk(logits.float(), k=count)
return [
{
"id": int(token_id),
"token": tokenizer.convert_ids_to_tokens(int(token_id)),
"logit": float(value),
}
for value, token_id in zip(values.cpu(), ids.cpu())
]
@torch.no_grad()
def _generate(
model: Any,
processor: Any,
image: Any,
prefix_text: str,
device: torch.device,
dtype: torch.dtype,
max_new_tokens: int,
logits_processor: LogitsProcessorList | None = None,
stopping_criteria: StoppingCriteriaList | None = None,
) -> tuple[dict[str, Any], torch.Tensor, Any]:
inputs = process_image_text(processor, image, prefix_text, device, dtype)
prompt_length = inputs["input_ids"].shape[1]
generated = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
use_cache=True,
return_dict_in_generate=True,
output_logits=True,
output_scores=True,
logits_processor=logits_processor,
stopping_criteria=stopping_criteria,
pad_token_id=processor.tokenizer.pad_token_id,
eos_token_id=processor.tokenizer.eos_token_id,
)
new_ids = generated.sequences[:, prompt_length:]
step_records = []
returned_logits = generated.logits or ()
for index, token_id in enumerate(new_ids[0].tolist()):
if index >= len(returned_logits):
break
logits = returned_logits[index][0]
log_probability = torch.log_softmax(logits.float(), dim=-1)[token_id]
step_records.append(
{
"index": index,
"id": int(token_id),
"token": processor.tokenizer.convert_ids_to_tokens(int(token_id)),
"selected_logit": float(logits[token_id].float().cpu()),
"selected_log_probability": float(log_probability.cpu()),
"top5": _top_tokens(logits, processor.tokenizer),
}
)
result = {
"prompt_length": prompt_length,
"new_token_ids": new_ids[0].detach().cpu().tolist(),
"raw_text": processor.tokenizer.decode(new_ids[0], skip_special_tokens=False),
"clean_text": processor.tokenizer.decode(new_ids[0], skip_special_tokens=True),
"steps": step_records,
"cache": _cache_summary(generated.past_key_values),
}
return result, new_ids, generated
@torch.no_grad()
def _teacher_forcing_probe(
model: Any,
inputs: dict[str, torch.Tensor],
labels: torch.Tensor,
vocabulary: TrajectoryVocabulary,
tokenizer: Any,
) -> tuple[dict[str, Any], np.ndarray, np.ndarray]:
outputs = model(**inputs, use_cache=False, return_dict=True)
logits = outputs.logits[:, :-1]
next_ids = inputs["input_ids"][:, 1:]
supervised = labels[:, 1:] != -100
trajectory = (
(next_ids >= vocabulary.start_id)
& (next_ids <= vocabulary.end_id)
& supervised
)
supervised_logits = logits[supervised].float()
supervised_ids = next_ids[supervised]
trajectory_logits = logits[trajectory].float()
trajectory_ids = next_ids[trajectory]
if trajectory_logits.shape[0] == 0:
raise RuntimeError("No supervised trajectory tokens were found")
target_logits = trajectory_logits.gather(1, trajectory_ids[:, None]).squeeze(1)
ranks = (trajectory_logits > target_logits[:, None]).sum(dim=1) + 1
probabilities = torch.softmax(trajectory_logits, dim=-1).gather(
1, trajectory_ids[:, None]
).squeeze(1)
top_values, top_ids = torch.topk(trajectory_logits, k=5, dim=-1)
records = []
for index in range(trajectory_logits.shape[0]):
records.append(
{
"index": index,
"target_id": int(trajectory_ids[index]),
"target_token": tokenizer.convert_ids_to_tokens(int(trajectory_ids[index])),
"target_logit": float(target_logits[index].cpu()),
"target_probability": float(probabilities[index].cpu()),
"target_rank": int(ranks[index].cpu()),
"top5": [
{
"id": int(token_id),
"token": tokenizer.convert_ids_to_tokens(int(token_id)),
"logit": float(value),
}
for value, token_id in zip(top_values[index].cpu(), top_ids[index].cpu())
],
}
)
discrete_slice = trajectory_logits[:, vocabulary.start_id : vocabulary.end_id + 1]
result = {
"supervised_token_count": int(supervised.sum()),
"trajectory_supervised_token_count": int(trajectory.sum()),
"next_token_accuracy": float(
(supervised_logits.argmax(dim=-1) == supervised_ids).float().mean().cpu()
),
"trajectory_next_token_accuracy": float(
(trajectory_logits.argmax(dim=-1) == trajectory_ids).float().mean().cpu()
),
"trajectory_mean_target_probability": float(probabilities.mean().cpu()),
"trajectory_max_target_rank": int(ranks.max().cpu()),
"trajectory_records": records,
}
return (
result,
discrete_slice.detach().cpu().numpy(),
(trajectory_ids - vocabulary.start_id).detach().cpu().numpy(),
)
@torch.no_grad()
def _handoff_probe(
model: Any,
processor: Any,
image: Any,
handoff_text: str,
first_target_id: int,
vocabulary: TrajectoryVocabulary,
device: torch.device,
dtype: torch.dtype,
) -> tuple[dict[str, Any], np.ndarray, np.ndarray]:
inputs = process_image_text(processor, image, handoff_text, device, dtype)
outputs = model(
**inputs,
use_cache=True,
return_dict=True,
logits_to_keep=1,
)
raw = outputs.logits[0, -1].float()
masked = DiscreteTrajectoryMask(vocabulary.start_id, vocabulary.size)(
inputs["input_ids"], raw[None].clone()
)[0]
target_logit = raw[first_target_id]
target_rank = int((raw > target_logit).sum().cpu()) + 1
outside = torch.ones_like(raw, dtype=torch.bool)
outside[vocabulary.start_id : vocabulary.end_id + 1] = False
result = {
"first_target_id": first_target_id,
"first_target_token": processor.tokenizer.convert_ids_to_tokens(first_target_id),
"raw_first_target_logit": float(target_logit.cpu()),
"raw_first_target_rank": target_rank,
"raw_top5": _top_tokens(raw, processor.tokenizer),
"processed_top5": _top_tokens(masked, processor.tokenizer),
"raw_trajectory_finite_count": int(
torch.isfinite(raw[vocabulary.start_id : vocabulary.end_id + 1]).sum().cpu()
),
"processed_trajectory_negative_infinity_count": int(
torch.isneginf(masked[vocabulary.start_id : vocabulary.end_id + 1]).sum().cpu()
),
"outside_range_max_absolute_change": float((masked[outside] - raw[outside]).abs().max().cpu()),
"cache": _cache_summary(outputs.past_key_values),
}
return (
result,
raw[vocabulary.start_id : vocabulary.end_id + 1].detach().cpu().numpy(),
masked[vocabulary.start_id : vocabulary.end_id + 1].detach().cpu().numpy(),
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--config", default="configs/local_overfit.yaml")
parser.add_argument("--output-dir", default="outputs/stage1_vlm_trajectory")
parser.add_argument("--steps", type=int, default=120)
parser.add_argument("--learning-rate", type=float, default=1e-4)
parser.add_argument("--trajectory-vocab-size", type=int, default=128)
parser.add_argument("--action-min", type=float, default=-4.0)
parser.add_argument("--action-max", type=float, default=4.0)
parser.add_argument("--max-new-tokens", type=int, default=96)
parser.add_argument("--early-stop-loss", type=float, default=1e-4)
parser.add_argument("--early-stop-patience", type=int, default=5)
args = parser.parse_args()
if args.steps <= 0:
raise ValueError("--steps must be positive")
if args.early_stop_loss < 0:
raise ValueError("--early-stop-loss must be non-negative")
if args.early_stop_patience <= 0:
raise ValueError("--early-stop-patience must be positive")
cfg = load_config(args.config)
seed_everything(cfg.seed)
device = select_device()
dtype = resolve_dtype(cfg.vlm.dtype, device)
samples = load_manifest(cfg.resolve(cfg.paths.manifest))
if len(samples) != 1:
raise ValueError("This local trajectory-logit validation expects exactly one sample")
sample = samples[0]
history, future = sample.load_trajectory()
image = sample.load_image()
processor, model = load_vlm(cfg.resolve(cfg.paths.stage1_dir), cfg.vlm, device, dtype)
tokenizer = processor.tokenizer
original_tokenizer_length = len(tokenizer)
vocabulary = add_trajectory_vocabulary(tokenizer, args.trajectory_vocab_size)
model_vocab_size = model.get_output_embeddings().weight.shape[0]
if len(tokenizer) > model_vocab_size:
raise RuntimeError(
f"Expanded tokenizer ({len(tokenizer)}) exceeds preallocated LM head ({model_vocab_size})"
)
if model.get_input_embeddings().weight.data_ptr() != model.get_output_embeddings().weight.data_ptr():
raise RuntimeError("Trajectory validation expects tied input/output embeddings")
expert_config = _read_json(cfg.resolve(cfg.paths.stage2_dir) / "expert_config.json")
action_space = RelativeDeltaActionSpace(
torch.tensor(expert_config["action_mean"], device=device),
torch.tensor(expert_config["action_scale"], device=device),
).to(device)
target_xy = future[None, :, :2].to(device)
history_xy = history[None, :, :2].to(device)
target_action = action_space.trajectory_to_action(history_xy, target_xy)
quantized = quantize_action(
target_action,
args.trajectory_vocab_size,
args.action_min,
args.action_max,
)
meta_action = _infer_meta_action(sample.reasoning)
structured_target = build_structured_target(sample.reasoning, meta_action, quantized)
prefix_text = build_vlm_prefix(history)
full_text = prefix_text + structured_target
baseline, _, baseline_output = _generate(
model,
processor,
image,
prefix_text,
device,
dtype,
args.max_new_tokens,
)
del baseline_output
trainability = configure_stage1_trainability(model, cfg.stage1)
embedding_weight = model.get_input_embeddings().weight
embedding_weight.requires_grad_(True)
trainable_rows = list(range(vocabulary.start_id, vocabulary.end_id + 1)) + list(
vocabulary.special_token_ids.values()
)
gradient_mask = torch.zeros(
embedding_weight.shape[0], 1, device=embedding_weight.device, dtype=embedding_weight.dtype
)
gradient_mask[trainable_rows] = 1
gradient_hook = embedding_weight.register_hook(lambda gradient: gradient * gradient_mask)
inputs, labels, prefix_length = prepare_stage1_batch(
processor,
image,
prefix_text,
full_text,
device,
dtype,
)
target_ids = inputs["input_ids"][:, prefix_length:]
trajectory_supervision_count = int(
(
(target_ids >= vocabulary.start_id)
& (target_ids <= vocabulary.end_id)
).sum().cpu()
)
if trajectory_supervision_count != quantized.numel():
raise RuntimeError(
f"Expected {quantized.numel()} trajectory labels, found {trajectory_supervision_count}"
)
optimizer = torch.optim.AdamW(
[parameter for parameter in model.parameters() if parameter.requires_grad],
lr=args.learning_rate,
weight_decay=cfg.stage1.weight_decay,
eps=cfg.stage1.adam_epsilon,
)
losses: list[float] = []
accuracies: list[float] = []
converged_steps = 0
started = time.perf_counter()
model.train()
enforce_frozen_module_eval(model)
for step in trange(args.steps, desc="vlm-trajectory-logits"):
optimizer.zero_grad(set_to_none=True)
outputs = model(**inputs, labels=labels, use_cache=False, return_dict=True)
loss = outputs.loss
if loss is None or not torch.isfinite(loss):
raise RuntimeError(f"Non-finite trajectory-logit loss at step {step}: {loss}")
loss.backward()
torch.nn.utils.clip_grad_norm_(
[parameter for parameter in model.parameters() if parameter.requires_grad], 1.0
)
optimizer.step()
value = float(loss.detach().cpu())
next_predictions = outputs.logits[:, :-1].argmax(dim=-1)
shifted_labels = labels[:, 1:]
supervised = shifted_labels != -100
accuracy = float(
(next_predictions[supervised] == shifted_labels[supervised]).float().mean().cpu()
)
losses.append(value)
accuracies.append(accuracy)
if step == 0 or (step + 1) % cfg.stage1.log_every == 0:
print(f"trajectory-vlm step={step + 1} loss={value:.6f} accuracy={accuracy:.4f}")
del outputs, next_predictions
if value <= args.early_stop_loss and accuracy == 1.0:
converged_steps += 1
else:
converged_steps = 0
if converged_steps >= args.early_stop_patience:
print(
f"trajectory-vlm converged at step={step + 1}: "
f"loss={value:.6f} accuracy={accuracy:.4f}"
)
break
elapsed = time.perf_counter() - started
model.gradient_checkpointing_disable()
model.config.use_cache = True
model.model.language_model.config.use_cache = True
output_dir = _resolve(cfg.root, args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
model.save_pretrained(output_dir, safe_serialization=True, max_shard_size="2GB")
processor.save_pretrained(output_dir)
write_json(
output_dir / "trajectory_vocabulary.json",
{
**asdict(vocabulary),
"original_tokenizer_length": original_tokenizer_length,
"expanded_tokenizer_length": len(tokenizer),
"model_vocab_size": model_vocab_size,
"preallocated_rows_used": True,
"action_min": args.action_min,
"action_max": args.action_max,
"action_mean": expert_config["action_mean"],
"action_scale": expert_config["action_scale"],
"action_shape": expert_config["action_shape"],
},
)
gradient_hook.remove()
del optimizer, model, processor
empty_device_cache(device)
processor, model = load_vlm(output_dir, cfg.vlm, device, dtype)
model.requires_grad_(False)
model.eval()
vocabulary = _vocabulary_from_tokenizer(processor.tokenizer, args.trajectory_vocab_size)
inputs, labels, prefix_length = prepare_stage1_batch(
processor,
image,
prefix_text,
full_text,
device,
dtype,
)
teacher, teacher_logits, teacher_values = _teacher_forcing_probe(
model, inputs, labels, vocabulary, processor.tokenizer
)
cot_and_meta = (
f"<|cot_start|>{sample.reasoning}<|cot_end|>\n"
f"<|meta_action_start|>{meta_action}<|meta_action_end|>\n"
)
handoff_text = prefix_text + cot_and_meta + "<|traj_future_start|>"
first_target_id = vocabulary.start_id + int(quantized.reshape(-1)[0].cpu())
handoff, boundary_raw, boundary_masked = _handoff_probe(
model,
processor,
image,
handoff_text,
first_target_id,
vocabulary,
device,
dtype,
)
full_generation, full_ids, full_output = _generate(
model,
processor,
image,
prefix_text,
device,
dtype,
args.max_new_tokens,
)
generated_values = extract_trajectory_values(full_ids[0], vocabulary)
expected_values = quantized.detach().cpu().reshape(-1)
generated_token_accuracy = (
float((generated_values.cpu() == expected_values).float().mean())
if generated_values.numel() == expected_values.numel()
else 0.0
)
if generated_values.numel() != target_action.numel():
raise RuntimeError(
f"Generated {generated_values.numel()} trajectory tokens, expected {target_action.numel()}"
)
generated_action = dequantize_action(
generated_values.reshape_as(target_action).to(device),
args.trajectory_vocab_size,
args.action_min,
args.action_max,
)
generated_xy = action_space.action_to_trajectory(generated_action, history_xy)
quantized_target_action = dequantize_action(
quantized,
args.trajectory_vocab_size,
args.action_min,
args.action_max,
)
quantized_target_xy = action_space.action_to_trajectory(quantized_target_action, history_xy)
del full_output
boundary_id = vocabulary.special_token_ids["<|traj_future_start|>"]
source_mask = DiscreteTrajectoryMask(vocabulary.start_id, vocabulary.size)
source_stop = StopOneTokenAfterBoundary(boundary_id)
truncated_generation, truncated_ids, truncated_output = _generate(
model,
processor,
image,
prefix_text,
device,
dtype,
args.max_new_tokens,
logits_processor=LogitsProcessorList([source_mask]),
stopping_criteria=StoppingCriteriaList([source_stop]),
)
visible_ids = mask_after_boundary(
truncated_output.sequences,
boundary_id,
processor.tokenizer.pad_token_id,
)
truncated_generation["visible_new_token_ids"] = visible_ids[
:, truncated_generation["prompt_length"] :
][0].cpu().tolist()
truncated_generation["visible_raw_text"] = processor.tokenizer.decode(
visible_ids[:, truncated_generation["prompt_length"] :][0],
skip_special_tokens=False,
)
truncated_generation["boundary_generated"] = bool((truncated_ids == boundary_id).any())
truncated_generation["trajectory_tokens_visible"] = int(
(
(visible_ids >= vocabulary.start_id)
& (visible_ids <= vocabulary.end_id)
).sum().cpu()
)
boundary_positions = torch.nonzero(truncated_ids[0] == boundary_id, as_tuple=False).flatten()
post_boundary_logit_index = (
int(boundary_positions[0]) + 1 if boundary_positions.numel() else -1
)
if 0 <= post_boundary_logit_index < len(truncated_output.logits or ()):
post_boundary_logits = truncated_output.logits[post_boundary_logit_index][0]
truncated_generation["returned_post_boundary_trajectory_negative_infinity_count"] = int(
torch.isneginf(
post_boundary_logits[vocabulary.start_id : vocabulary.end_id + 1]
).sum().cpu()
)
del truncated_output
save_trajectory_plot(
output_dir / "vlm_discrete_trajectory.png",
history[:, :2],
target_xy[0],
generated_xy[0],
prediction_label="VLM discrete tokens",
title="VLM discrete-trajectory logits",
)
np.savez_compressed(
output_dir / "trajectory_logits.npz",
teacher_trajectory_logits=teacher_logits,
teacher_target_values=teacher_values,
boundary_raw_trajectory_logits=boundary_raw,
boundary_masked_trajectory_logits=boundary_masked,
target_action=target_action.detach().cpu().numpy(),
quantized_target_values=quantized.detach().cpu().numpy(),
generated_values=generated_values.detach().cpu().numpy(),
generated_action=generated_action.detach().cpu().numpy(),
target_xy=target_xy.detach().cpu().numpy(),
generated_xy=generated_xy.detach().cpu().numpy(),
)
metrics = {
"stage": "vlm_discrete_trajectory_logits",
"device": str(device),
"dtype": str(dtype),
"model_parameters": parameter_counts(model),
"vocabulary": asdict(vocabulary),
"original_tokenizer_length": original_tokenizer_length,
"expanded_tokenizer_length": len(processor.tokenizer),
"model_vocab_size": model_vocab_size,
"semantic_trajectory_supervision_count": trajectory_supervision_count,
"structured_target": structured_target,
"target_meta_action": meta_action,
"training": {
"steps": len(losses),
"requested_steps": args.steps,
"learning_rate": args.learning_rate,
"elapsed_seconds": elapsed,
"initial_loss": losses[0],
"final_loss": losses[-1],
"minimum_loss": min(losses),
"final_training_accuracy": accuracies[-1],
"losses": losses,
"accuracies": accuracies,
"base_trainability": trainability,
"trainable_token_rows": len(trainable_rows),
},
"baseline_generation_before_structured_training": baseline,
"teacher_forcing": teacher,
"raw_handoff_logits_and_cache": handoff,
"untruncated_vlm_generation": {
**full_generation,
"trajectory_token_count": int(generated_values.numel()),
"trajectory_token_accuracy": generated_token_accuracy,
"generated_values": generated_values.cpu().tolist(),
"target_values": expected_values.tolist(),
"action_mse": float(
torch.nn.functional.mse_loss(generated_action, target_action).cpu()
),
**trajectory_metrics(generated_xy, target_xy),
},
"quantization_only": trajectory_metrics(quantized_target_xy, target_xy),
"source_style_diffusion_handoff_generation": truncated_generation,
}
write_json(output_dir / "trajectory_logits_metrics.json", metrics)
print(json.dumps(metrics, indent=2, ensure_ascii=False))
del model, processor
empty_device_cache(device)
if __name__ == "__main__":
main()
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from dataclasses import asdict
import torch
from alpamayo_lite.config import load_config
from alpamayo_lite.data import load_manifest
from alpamayo_lite.expert import ExpertArchitecture, KVCachedDiffusionExpert
from alpamayo_lite.prompts import build_expert_prefix, build_vlm_prefix
from alpamayo_lite.runtime import (
empty_device_cache,
parameter_counts,
resolve_dtype,
seed_everything,
select_device,
write_json,
)
from alpamayo_lite.vlm import (
configure_stage1_trainability,
extract_prefix_kv,
generate_reasoning,
load_vlm,
vlm_text_config,
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--config", default="configs/local_overfit.yaml")
args = parser.parse_args()
cfg = load_config(args.config)
seed_everything(cfg.seed)
device = select_device()
dtype = resolve_dtype(cfg.vlm.dtype, device)
sample = load_manifest(cfg.resolve(cfg.paths.manifest))[0]
history, _ = sample.load_trajectory()
processor, vlm = load_vlm(cfg.resolve(cfg.paths.model_dir), cfg.vlm, device, dtype)
vlm.eval()
baseline_text = generate_reasoning(
vlm,
processor,
sample.load_image(),
build_vlm_prefix(history),
device,
dtype,
cfg.vlm.max_reasoning_tokens,
)
prefix = extract_prefix_kv(
vlm,
processor,
sample.load_image(),
build_expert_prefix(history, sample.reasoning),
device,
dtype,
)
stage2 = cfg.stage2
architecture = ExpertArchitecture(
hidden_size=stage2.expert_hidden_size,
intermediate_size=stage2.expert_intermediate_size,
num_layers=stage2.expert_num_layers,
num_attention_heads=stage2.expert_num_attention_heads,
num_key_value_heads=stage2.expert_num_key_value_heads,
head_dim=stage2.expert_head_dim,
action_projection_hidden_size=stage2.action_projection_hidden_size,
action_fourier_features=stage2.action_fourier_features,
non_causal_attention=stage2.expert_non_causal_attention,
)
expert = KVCachedDiffusionExpert(vlm_text_config(vlm), architecture).to(
device=device, dtype=dtype
)
with torch.no_grad():
predicted_velocity = expert(
noisy_action=torch.randn(1, cfg.data.expert_waypoints, 2, device=device, dtype=dtype),
timestep=torch.full((1, 1, 1), 0.5, device=device, dtype=dtype),
prefix=prefix,
)
trainability = configure_stage1_trainability(vlm, cfg.stage1)
result = {
"device": str(device),
"dtype": str(dtype),
"vlm_parameters": parameter_counts(vlm),
"stage1_trainability": trainability,
"expert_parameters": parameter_counts(expert),
"expert_architecture": asdict(architecture),
"prefix_layers": prefix.num_layers,
"prefix_sequence_length": prefix.sequence_length,
"prefix_key_shape": list(prefix.keys[0].shape),
"expert_output_shape": list(predicted_velocity.shape),
"expert_output_finite": bool(torch.isfinite(predicted_velocity).all().item()),
"baseline_text": baseline_text,
"target_text": sample.reasoning,
}
write_json(cfg.root / "outputs" / "connection_test.json", result)
print(result)
del expert, vlm
empty_device_cache(device)
if __name__ == "__main__":
main()
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from dataclasses import asdict
from functools import partial
from pathlib import Path
from typing import Any
import torch
from safetensors.torch import load_file
from transformers import LogitsProcessorList, StoppingCriteriaList
from alpamayo_lite.action_space import RelativeDeltaActionSpace
from alpamayo_lite.config import load_config
from alpamayo_lite.data import load_manifest
from alpamayo_lite.diffusion import FlowMatching
from alpamayo_lite.evaluation import (
save_prediction_arrays,
save_trajectory_plot,
trajectory_metrics,
)
from alpamayo_lite.expert import ExpertArchitecture, KVCachedDiffusionExpert, PrefixKV
from alpamayo_lite.prompts import build_vlm_prefix
from alpamayo_lite.runtime import (
empty_device_cache,
parameter_counts,
resolve_dtype,
seed_everything,
select_device,
write_json,
)
from alpamayo_lite.trajectory_tokens import (
DiscreteTrajectoryMask,
StopOneTokenAfterBoundary,
mask_after_boundary,
)
from alpamayo_lite.vlm import load_vlm, process_image_text, vlm_text_config
def _resolve(root: Path, value: str) -> Path:
path = Path(value).expanduser()
return path.resolve() if path.is_absolute() else (root / path).resolve()
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--config", default="configs/local_overfit.yaml")
parser.add_argument("--vlm-dir", default="outputs/stage1_vlm_trajectory")
parser.add_argument("--stage2-dir", default="outputs/stage2_expert")
parser.add_argument("--output-dir", default="outputs/structured_vlm_handoff")
parser.add_argument("--max-new-tokens", type=int, default=96)
args = parser.parse_args()
cfg = load_config(args.config)
seed_everything(cfg.seed)
device = select_device()
dtype = resolve_dtype(cfg.vlm.dtype, device)
vlm_dir = _resolve(cfg.root, args.vlm_dir)
stage2_dir = _resolve(cfg.root, args.stage2_dir)
output_dir = _resolve(cfg.root, args.output_dir)
sample = load_manifest(cfg.resolve(cfg.paths.manifest))[0]
history, future = sample.load_trajectory()
vocabulary = _read_json(vlm_dir / "trajectory_vocabulary.json")
trajectory_start = int(vocabulary["start_id"])
trajectory_size = int(vocabulary["size"])
boundary_id = int(vocabulary["special_token_ids"]["<|traj_future_start|>"])
processor, vlm = load_vlm(vlm_dir, cfg.vlm, device, dtype)
vlm.requires_grad_(False)
vlm.eval()
inputs = process_image_text(
processor,
sample.load_image(),
build_vlm_prefix(history),
device,
dtype,
)
prompt_length = inputs["input_ids"].shape[1]
generated = vlm.generate(
**inputs,
max_new_tokens=args.max_new_tokens,
do_sample=False,
use_cache=True,
return_dict_in_generate=True,
output_logits=True,
logits_processor=LogitsProcessorList(
[DiscreteTrajectoryMask(trajectory_start, trajectory_size)]
),
stopping_criteria=StoppingCriteriaList([StopOneTokenAfterBoundary(boundary_id)]),
pad_token_id=processor.tokenizer.pad_token_id,
eos_token_id=processor.tokenizer.eos_token_id,
)
new_ids = generated.sequences[:, prompt_length:]
visible_sequences = mask_after_boundary(
generated.sequences,
boundary_id,
processor.tokenizer.pad_token_id,
)
visible_new_ids = visible_sequences[:, prompt_length:]
prefix = PrefixKV.from_transformers_cache(generated.past_key_values)
boundary_positions = torch.nonzero(new_ids[0] == boundary_id, as_tuple=False).flatten()
if boundary_positions.numel() == 0:
raise RuntimeError("Structured VLM did not generate the diffusion handoff boundary")
boundary_index = int(boundary_positions[0])
expected_prefix_length = prompt_length + boundary_index + 1
if prefix.sequence_length != expected_prefix_length:
raise RuntimeError(
f"Generation cache does not end at the handoff boundary: "
f"cache={prefix.sequence_length}, expected={expected_prefix_length}"
)
expert_config = _read_json(stage2_dir / "expert_config.json")
architecture = ExpertArchitecture(**expert_config["architecture"])
expert = KVCachedDiffusionExpert(vlm_text_config(vlm), architecture).to(
device=device, dtype=dtype
)
expert.load_state_dict(load_file(stage2_dir / "expert.safetensors"), strict=True)
expert.requires_grad_(False)
expert.eval()
flow = FlowMatching(
action_shape=tuple(expert_config["action_shape"]),
inference_steps=int(expert_config["inference_steps"]),
)
action_space = RelativeDeltaActionSpace(
torch.tensor(expert_config["action_mean"], device=device),
torch.tensor(expert_config["action_scale"], device=device),
).to(device)
seed_everything(cfg.seed + 1)
predicted_action = flow.sample(
batch_size=1,
step_fn=partial(expert, prefix=prefix),
device=device,
dtype=dtype,
).float()
history_xy = history[None, :, :2].to(device)
target_xy = future[None, :, :2].to(device)
target_action = action_space.trajectory_to_action(history_xy, target_xy)
predicted_xy = action_space.action_to_trajectory(predicted_action, history_xy)
post_boundary_index = boundary_index + 1
returned_masked_count = None
if 0 <= post_boundary_index < len(generated.logits or ()):
post_boundary_logits = generated.logits[post_boundary_index][0]
returned_masked_count = int(
torch.isneginf(
post_boundary_logits[trajectory_start : trajectory_start + trajectory_size]
).sum().cpu()
)
result = {
"stage": "structured_vlm_to_diffusion_handoff",
"device": str(device),
"dtype": str(dtype),
"vlm_dir": str(vlm_dir),
"stage2_dir": str(stage2_dir),
"prompt_length": prompt_length,
"generated_token_count_including_one_cache_update_token": int(new_ids.shape[1]),
"boundary_index_in_generated_tokens": boundary_index,
"expected_cache_length_through_boundary": expected_prefix_length,
"visible_vlm_output": processor.tokenizer.decode(
visible_new_ids[0], skip_special_tokens=False
),
"boundary_generated": bool((new_ids == boundary_id).any()),
"visible_discrete_trajectory_tokens": int(
(
(visible_new_ids >= trajectory_start)
& (visible_new_ids < trajectory_start + trajectory_size)
).sum().cpu()
),
"returned_post_boundary_masked_logits": returned_masked_count,
"handoff_prefix": {
"layers": prefix.num_layers,
"sequence_length": prefix.sequence_length,
"first_key_shape": list(prefix.keys[0].shape),
"all_finite": bool(
all(
torch.isfinite(key).all() and torch.isfinite(value).all()
for key, value in zip(prefix.keys, prefix.values)
)
),
},
"expert_architecture": asdict(architecture),
"expert_parameters": parameter_counts(expert),
"diffusion_output_shape": list(predicted_action.shape),
"diffusion_output_finite": bool(torch.isfinite(predicted_action).all()),
"action_mse": float(
torch.nn.functional.mse_loss(predicted_action, target_action).cpu()
),
**trajectory_metrics(predicted_xy, target_xy),
"quality_note": (
"This verifies exact structured-generation KV compatibility. The expert was trained "
"with the earlier plain-text Stage-1 prefix, so trajectory quality is diagnostic only."
),
}
output_dir.mkdir(parents=True, exist_ok=True)
write_json(output_dir / "handoff_metrics.json", result)
save_prediction_arrays(
output_dir / "handoff_prediction.npz",
history[:, :2],
target_action[0],
predicted_action[0],
target_xy[0],
predicted_xy[0],
)
save_trajectory_plot(
output_dir / "handoff_trajectory.png",
history[:, :2],
target_xy[0],
predicted_xy[0],
prediction_label="diffusion from structured VLM KV",
title="Structured VLM handoff to diffusion",
)
print(json.dumps(result, indent=2, ensure_ascii=False))
del expert, vlm
empty_device_cache(device)
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
"""Small, local two-stage Alpamayo architecture validation."""
from .config import ExperimentConfig, load_config
__all__ = ["ExperimentConfig", "load_config"]
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
from dataclasses import dataclass
import torch
from torch import nn
@dataclass(frozen=True)
class ActionStatistics:
mean: torch.Tensor
scale: torch.Tensor
class RelativeDeltaActionSpace(nn.Module):
"""Invertible local validation action space.
Alpamayo's release model predicts normalized acceleration and curvature. For the
architecture smoke test we use normalized per-waypoint XY deltas. It preserves
the important contract: diffusion predicts continuous actions, and a deterministic
decoder integrates those actions back to relative positions.
"""
def __init__(self, mean: torch.Tensor | None = None, scale: torch.Tensor | None = None):
super().__init__()
if mean is None:
mean = torch.zeros(2, dtype=torch.float32)
if scale is None:
scale = torch.ones(2, dtype=torch.float32)
self.register_buffer("mean", mean.float().reshape(2))
self.register_buffer("scale", scale.float().reshape(2).clamp_min(1e-3))
@staticmethod
def fit(history_xy: torch.Tensor, future_xy: torch.Tensor) -> ActionStatistics:
if history_xy.shape[-1] != 2 or future_xy.shape[-1] != 2:
raise ValueError("history_xy and future_xy must end in XY coordinates")
start = history_xy[..., -1:, :]
previous = torch.cat([start, future_xy[..., :-1, :]], dim=-2)
deltas = future_xy - previous
flat = deltas.reshape(-1, 2).float()
mean = flat.mean(dim=0)
# A floor keeps a one-clip overfit target numerically well conditioned.
scale = flat.std(dim=0, unbiased=False).clamp_min(0.05)
return ActionStatistics(mean=mean, scale=scale)
def trajectory_to_action(
self, history_xy: torch.Tensor, future_xy: torch.Tensor
) -> torch.Tensor:
start = history_xy[..., -1:, :]
previous = torch.cat([start, future_xy[..., :-1, :]], dim=-2)
deltas = future_xy - previous
return (deltas - self.mean) / self.scale
def action_to_trajectory(self, action: torch.Tensor, history_xy: torch.Tensor) -> torch.Tensor:
deltas = action * self.scale + self.mean
return history_xy[..., -1:, :] + torch.cumsum(deltas, dim=-2)
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
from dataclasses import dataclass, fields
from pathlib import Path
from typing import Any, TypeVar
import yaml
@dataclass(frozen=True)
class PathsConfig:
model_dir: str
pai_raw_dir: str
manifest: str
stage1_dir: str
stage2_dir: str
@dataclass(frozen=True)
class VLMConfig:
repo_id: str
dtype: str = "float16"
attn_implementation: str = "eager"
max_reasoning_tokens: int = 48
num_additional_image_tokens: int = 1
@dataclass(frozen=True)
class DataConfig:
repo_id: str
num_clips: int = 1
camera: str = "camera_front_wide_120fov"
num_frames: int = 1
history_steps: int = 8
source_future_steps: int = 64
expert_waypoints: int = 16
time_step: float = 0.1
@dataclass(frozen=True)
class Stage1Config:
steps: int = 80
learning_rate: float = 1e-4
weight_decay: float = 0.0
adam_epsilon: float = 1e-4
freeze_vision_tower: bool = True
freeze_multimodal_projector: bool = True
train_last_n_language_layers: int = 8
train_final_norm: bool = True
train_lm_head: bool = False
gradient_checkpointing: bool = True
log_every: int = 5
@dataclass(frozen=True)
class Stage2Config:
steps: int = 250
learning_rate: float = 3e-4
weight_decay: float = 0.0
adam_epsilon: float = 1e-4
expert_hidden_size: int = 448
expert_intermediate_size: int = 2432
expert_num_layers: int = 24
expert_num_attention_heads: int = 7
expert_num_key_value_heads: int = 2
expert_head_dim: int = 64
expert_non_causal_attention: bool = True
action_projection_hidden_size: int = 256
action_fourier_features: int = 20
inference_steps: int = 10
log_every: int = 10
checkpoint_every: int = 50
@dataclass(frozen=True)
class ExperimentConfig:
seed: int
paths: PathsConfig
vlm: VLMConfig
data: DataConfig
stage1: Stage1Config
stage2: Stage2Config
root: Path
def resolve(self, value: str) -> Path:
path = Path(value)
return path if path.is_absolute() else self.root / path
T = TypeVar("T")
def _construct(cls: type[T], values: dict[str, Any]) -> T:
known = {item.name for item in fields(cls)}
unknown = set(values) - known
if unknown:
raise ValueError(f"Unknown {cls.__name__} keys: {sorted(unknown)}")
return cls(**values)
def load_config(path: str | Path) -> ExperimentConfig:
config_path = Path(path).expanduser().resolve()
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
return ExperimentConfig(
seed=int(raw["seed"]),
paths=_construct(PathsConfig, raw["paths"]),
vlm=_construct(VLMConfig, raw["vlm"]),
data=_construct(DataConfig, raw["data"]),
stage1=_construct(Stage1Config, raw["stage1"]),
stage2=_construct(Stage2Config, raw["stage2"]),
root=config_path.parent.parent,
)
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import torch
from PIL import Image
@dataclass(frozen=True)
class OverfitSample:
clip_id: str
t0_us: int
reasoning: str
image_path: Path
trajectory_path: Path
def load_image(self) -> Image.Image:
with Image.open(self.image_path) as image:
return image.convert("RGB")
def load_trajectory(self) -> tuple[torch.Tensor, torch.Tensor]:
with np.load(self.trajectory_path) as arrays:
history = torch.from_numpy(arrays["history_xyz"]).float()
future = torch.from_numpy(arrays["future_xyz"]).float()
return history, future
def load_manifest(path: str | Path) -> list[OverfitSample]:
manifest_path = Path(path).expanduser().resolve()
root = manifest_path.parent.parent.parent
samples: list[OverfitSample] = []
for line in manifest_path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
row = json.loads(line)
image_path = Path(row["image_path"])
trajectory_path = Path(row["trajectory_path"])
samples.append(
OverfitSample(
clip_id=str(row["clip_id"]),
t0_us=int(row["t0_us"]),
reasoning=str(row["reasoning"]),
image_path=image_path if image_path.is_absolute() else root / image_path,
trajectory_path=(
trajectory_path if trajectory_path.is_absolute() else root / trajectory_path
),
)
)
if not samples:
raise ValueError(f"No samples found in {manifest_path}")
return samples
+57
View File
@@ -0,0 +1,57 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES.
# SPDX-License-Identifier: Apache-2.0
"""Small flow-matching implementation derived from NVlabs/alpamayo."""
from __future__ import annotations
from collections.abc import Callable
import torch
from torch import nn
class FlowMatching(nn.Module):
def __init__(self, action_shape: tuple[int, int], inference_steps: int = 10):
super().__init__()
self.action_shape = action_shape
self.inference_steps = inference_steps
def construct_training_data(self, target_action: torch.Tensor) -> dict[str, torch.Tensor]:
batch_size = target_action.shape[0]
t = torch.rand(batch_size, 1, 1, device=target_action.device)
noise = torch.randn_like(target_action)
noisy_action = t * target_action + (1.0 - t) * noise
return {
"target_action": target_action,
"noisy_action": noisy_action,
"timestep": t,
"noise": noise,
}
@staticmethod
def loss(training_data: dict[str, torch.Tensor], prediction: torch.Tensor) -> torch.Tensor:
target_velocity = training_data["target_action"] - training_data["noise"]
return torch.nn.functional.mse_loss(prediction.float(), target_velocity.float())
@torch.no_grad()
def sample(
self,
batch_size: int,
step_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor],
device: torch.device,
dtype: torch.dtype,
generator: torch.Generator | None = None,
) -> torch.Tensor:
x = torch.randn(
batch_size,
*self.action_shape,
device=device,
dtype=dtype,
generator=generator,
)
time_steps = torch.linspace(0.0, 1.0, self.inference_steps + 1, device=device)
for index in range(self.inference_steps):
t = time_steps[index].expand(batch_size).reshape(batch_size, 1, 1)
dt = time_steps[index + 1] - time_steps[index]
x = x + dt * step_fn(x, t)
return x
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import torch
def trajectory_metrics(predicted_xy: torch.Tensor, target_xy: torch.Tensor) -> dict[str, float]:
distances = torch.linalg.vector_norm(predicted_xy.float() - target_xy.float(), dim=-1)
return {
"ade_m": float(distances.mean().cpu()),
"fde_m": float(distances[..., -1].mean().cpu()),
"max_error_m": float(distances.max().cpu()),
}
def save_trajectory_plot(
path: str | Path,
history_xy: torch.Tensor,
target_xy: torch.Tensor,
predicted_xy: torch.Tensor,
*,
prediction_label: str = "diffusion",
title: str = "PAI overfit trajectory",
) -> None:
output = Path(path)
output.parent.mkdir(parents=True, exist_ok=True)
history = history_xy.detach().cpu().numpy()
target = target_xy.detach().cpu().numpy()
predicted = predicted_xy.detach().cpu().numpy()
figure, axis = plt.subplots(figsize=(6, 5))
axis.plot(history[:, 0], history[:, 1], "o-", label="history", color="#555555")
axis.plot(target[:, 0], target[:, 1], "o-", label="target", color="#d62728")
axis.plot(
predicted[:, 0],
predicted[:, 1],
"o-",
label=prediction_label,
color="#1f77b4",
)
axis.scatter([0], [0], marker="x", color="black", label="current ego")
axis.set_xlabel("ego-relative x (m)")
axis.set_ylabel("ego-relative y (m)")
axis.set_title(title)
axis.axis("equal")
axis.grid(alpha=0.25)
axis.legend()
figure.tight_layout()
figure.savefig(output, dpi=160)
plt.close(figure)
def save_prediction_arrays(
path: str | Path,
history_xy: torch.Tensor,
target_action: torch.Tensor,
predicted_action: torch.Tensor,
target_xy: torch.Tensor,
predicted_xy: torch.Tensor,
) -> None:
output = Path(path)
output.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(
output,
history_xy=history_xy.detach().cpu().numpy(),
target_action=target_action.detach().cpu().numpy(),
predicted_action=predicted_action.detach().cpu().numpy(),
target_xy=target_xy.detach().cpu().numpy(),
predicted_xy=predicted_xy.detach().cpu().numpy(),
)
+237
View File
@@ -0,0 +1,237 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES.
# SPDX-License-Identifier: Apache-2.0
"""Past-KV-conditioned Qwen expert, following the Alpamayo connection pattern."""
from __future__ import annotations
import copy
import math
from dataclasses import dataclass
import torch
from torch import nn
from transformers import Qwen2Config
from transformers.cache_utils import DynamicCache
from transformers.models.qwen2.modeling_qwen2 import Qwen2Model
class FourierEncoder(nn.Module):
def __init__(self, dim: int, max_frequency: float = 100.0):
super().__init__()
if dim % 2:
raise ValueError("Fourier feature dimension must be even")
frequencies = torch.logspace(0, math.log10(max_frequency), steps=dim // 2)
self.register_buffer("frequencies", frequencies)
@property
def output_dim(self) -> int:
return self.frequencies.numel() * 2
def forward(self, values: torch.Tensor) -> torch.Tensor:
angles = values[..., None].float() * self.frequencies * (2.0 * torch.pi)
return torch.cat([torch.sin(angles), torch.cos(angles)], dim=-1) * math.sqrt(2.0)
class ActionTimeProjection(nn.Module):
def __init__(
self,
action_dim: int,
output_dim: int,
hidden_size: int,
fourier_features: int,
):
super().__init__()
self.action_encoders = nn.ModuleList(
[FourierEncoder(fourier_features) for _ in range(action_dim)]
)
self.time_encoder = FourierEncoder(fourier_features)
input_dim = (action_dim + 1) * fourier_features
self.network = nn.Sequential(
nn.Linear(input_dim, hidden_size),
nn.SiLU(),
nn.LayerNorm(hidden_size),
nn.Linear(hidden_size, output_dim),
nn.LayerNorm(output_dim),
)
def forward(self, action: torch.Tensor, timestep: torch.Tensor) -> torch.Tensor:
batch_size, num_waypoints, _ = action.shape
action_features = torch.cat(
[encoder(action[..., index]) for index, encoder in enumerate(self.action_encoders)],
dim=-1,
)
scalar_timestep = timestep.reshape(batch_size, -1)[:, 0]
time_features = self.time_encoder(scalar_timestep)[:, None, :]
time_features = time_features.expand(batch_size, num_waypoints, -1)
features = torch.cat([action_features, time_features], dim=-1)
return self.network(features.to(dtype=self.network[0].weight.dtype))
@dataclass(frozen=True)
class PrefixKV:
keys: tuple[torch.Tensor, ...]
values: tuple[torch.Tensor, ...]
@property
def num_layers(self) -> int:
return len(self.keys)
@property
def sequence_length(self) -> int:
return int(self.keys[0].shape[-2])
@classmethod
def from_transformers_cache(cls, cache: object) -> "PrefixKV":
if hasattr(cache, "layers"):
keys = tuple(layer.keys.detach() for layer in cache.layers)
values = tuple(layer.values.detach() for layer in cache.layers)
else:
legacy = tuple(cache)
keys = tuple(item[0].detach() for item in legacy)
values = tuple(item[1].detach() for item in legacy)
return cls(keys=keys, values=values)
def dynamic_copy(self) -> DynamicCache:
entries = [(key.clone(), value.clone()) for key, value in zip(self.keys, self.values)]
try:
return DynamicCache(ddp_cache_data=entries)
except TypeError:
cache = DynamicCache()
for layer_index, (key, value) in enumerate(entries):
cache.update(key, value, layer_index)
return cache
@dataclass(frozen=True)
class ExpertArchitecture:
hidden_size: int
intermediate_size: int
num_layers: int
num_attention_heads: int
num_key_value_heads: int
head_dim: int
action_projection_hidden_size: int
action_fourier_features: int
non_causal_attention: bool = True
class KVCachedDiffusionExpert(nn.Module):
def __init__(
self,
vlm_text_config: Qwen2Config,
architecture: ExpertArchitecture,
action_dim: int = 2,
):
super().__init__()
if architecture.hidden_size != architecture.num_attention_heads * architecture.head_dim:
raise ValueError("expert hidden_size must equal num_attention_heads * head_dim")
if architecture.num_layers != vlm_text_config.num_hidden_layers:
raise ValueError("direct past-KV reuse requires the same number of layers")
if architecture.num_key_value_heads != vlm_text_config.num_key_value_heads:
raise ValueError("direct past-KV reuse requires the same number of KV heads")
vlm_head_dim = getattr(
vlm_text_config,
"head_dim",
vlm_text_config.hidden_size // vlm_text_config.num_attention_heads,
)
if architecture.head_dim != vlm_head_dim:
raise ValueError("direct past-KV reuse requires the same attention head dimension")
expert_config = copy.deepcopy(vlm_text_config)
expert_config.hidden_size = architecture.hidden_size
expert_config.intermediate_size = architecture.intermediate_size
expert_config.num_hidden_layers = architecture.num_layers
expert_config.num_attention_heads = architecture.num_attention_heads
expert_config.num_key_value_heads = architecture.num_key_value_heads
expert_config.head_dim = architecture.head_dim
expert_config.vocab_size = 1
expert_config.tie_word_embeddings = False
expert_config.use_cache = True
expert_config._attn_implementation = "eager"
self.architecture = architecture
self.expert = Qwen2Model(expert_config)
# Inputs are always continuous action embeddings, exactly as in Alpamayo.
self.expert.embed_tokens = None
self.action_projection = ActionTimeProjection(
action_dim=action_dim,
output_dim=architecture.hidden_size,
hidden_size=architecture.action_projection_hidden_size,
fourier_features=architecture.action_fourier_features,
)
self.action_output = nn.Linear(architecture.hidden_size, action_dim)
def validate_prefix(self, prefix: PrefixKV) -> None:
if prefix.num_layers != self.architecture.num_layers:
raise ValueError(
f"KV layer mismatch: prefix={prefix.num_layers}, expert={self.architecture.num_layers}"
)
first_key = prefix.keys[0]
if first_key.shape[-3] != self.architecture.num_key_value_heads:
raise ValueError(
f"KV-head mismatch: prefix={first_key.shape[-3]}, "
f"expert={self.architecture.num_key_value_heads}"
)
if first_key.shape[-1] != self.architecture.head_dim:
raise ValueError(
f"head-dim mismatch: prefix={first_key.shape[-1]}, "
f"expert={self.architecture.head_dim}"
)
def forward(
self,
noisy_action: torch.Tensor,
timestep: torch.Tensor,
prefix: PrefixKV,
) -> torch.Tensor:
self.validate_prefix(prefix)
batch_size, num_waypoints, _ = noisy_action.shape
if prefix.keys[0].shape[0] != batch_size:
raise ValueError("Prefix KV batch size does not match action batch size")
action_embeddings = self.action_projection(noisy_action, timestep)
prefix_length = prefix.sequence_length
position_ids = torch.arange(
prefix_length,
prefix_length + num_waypoints,
device=noisy_action.device,
)[None, :].expand(batch_size, -1)
if self.architecture.non_causal_attention:
# A prepared 4-D additive mask is returned unchanged by Transformers'
# mask builder. This lets all diffusion tokens attend to one another,
# while still giving every query access to the frozen VLM prefix KV.
attention_mask = torch.zeros(
batch_size,
1,
num_waypoints,
prefix_length + num_waypoints,
dtype=action_embeddings.dtype,
device=noisy_action.device,
)
else:
attention_mask = torch.ones(
batch_size,
prefix_length + num_waypoints,
dtype=torch.long,
device=noisy_action.device,
)
cache_position = torch.arange(
prefix_length,
prefix_length + num_waypoints,
device=noisy_action.device,
)
kwargs: dict[str, object] = {}
if self.architecture.non_causal_attention:
kwargs["is_causal"] = False
outputs = self.expert(
inputs_embeds=action_embeddings,
attention_mask=attention_mask,
position_ids=position_ids,
past_key_values=prefix.dynamic_copy(),
cache_position=cache_position,
use_cache=False,
**kwargs,
)
hidden = outputs.last_hidden_state[:, -num_waypoints:, :]
return self.action_output(hidden)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import re
import torch
def format_history(history_xyz: torch.Tensor) -> str:
history = history_xyz.detach().cpu().float()
return "; ".join(
f"t-{history.shape[-2] - 1 - index}:({point[0]:.2f},{point[1]:.2f})"
for index, point in enumerate(history)
)
def clean_reasoning_text(text: str, max_characters: int = 320) -> str:
cleaned = re.sub(r"\s+", " ", text).strip()
if not cleaned:
return "Continue safely while following the visible lane."
return cleaned[:max_characters].rstrip()
def build_vlm_prefix(history_xyz: torch.Tensor) -> str:
history_text = format_history(history_xyz)
return (
"<|im_start|>system\n"
"You are a driving vision-language model. Describe the immediate driving decision "
"briefly and concretely.<|im_end|>\n"
"<|im_start|>user\n"
"<image>\n"
f"Recent ego positions in the current ego-relative XY frame: {history_text}.\n"
"What should the vehicle do next based on the scene?<|im_end|>\n"
"<|im_start|>assistant\n"
)
def build_stage1_text(history_xyz: torch.Tensor, reasoning: str) -> tuple[str, str]:
prefix = build_vlm_prefix(history_xyz)
target = clean_reasoning_text(reasoning) + "<|im_end|>"
return prefix, prefix + target
def build_expert_prefix(history_xyz: torch.Tensor, reasoning: str) -> str:
return build_vlm_prefix(history_xyz) + clean_reasoning_text(reasoning) + "\nContinuous action:"
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import json
import random
from pathlib import Path
from typing import Any
import numpy as np
import torch
def seed_everything(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.backends.mps.is_available():
torch.mps.manual_seed(seed)
def select_device() -> torch.device:
if torch.backends.mps.is_available():
return torch.device("mps")
if torch.cuda.is_available():
return torch.device("cuda")
return torch.device("cpu")
def resolve_dtype(name: str, device: torch.device) -> torch.dtype:
mapping = {
"float32": torch.float32,
"float16": torch.float16,
"bfloat16": torch.bfloat16,
}
if name not in mapping:
raise ValueError(f"Unsupported dtype: {name}")
dtype = mapping[name]
if device.type == "cpu" and dtype == torch.float16:
return torch.float32
return dtype
def parameter_counts(module: torch.nn.Module) -> dict[str, int]:
total = sum(parameter.numel() for parameter in module.parameters())
trainable = sum(
parameter.numel() for parameter in module.parameters() if parameter.requires_grad
)
return {"total": total, "trainable": trainable, "frozen": total - trainable}
def write_json(path: str | Path, value: Any) -> None:
output = Path(path)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def empty_device_cache(device: torch.device) -> None:
if device.type == "mps":
torch.mps.empty_cache()
elif device.type == "cuda":
torch.cuda.empty_cache()
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import torch
from transformers import LogitsProcessor, StoppingCriteria
STRUCTURED_SPECIAL_TOKENS = (
"<|cot_start|>",
"<|cot_end|>",
"<|meta_action_start|>",
"<|meta_action_end|>",
"<|traj_future_start|>",
"<|traj_future_end|>",
)
@dataclass(frozen=True)
class TrajectoryVocabulary:
size: int
start_id: int
end_id: int
special_token_ids: dict[str, int]
def token_id(self, value: int) -> int:
if not 0 <= value < self.size:
raise ValueError(f"Trajectory token value is outside 0..{self.size - 1}: {value}")
return self.start_id + value
def trajectory_token(value: int) -> str:
return f"<i{value}>"
def add_trajectory_vocabulary(tokenizer: Any, size: int) -> TrajectoryVocabulary:
if size < 2:
raise ValueError("Trajectory vocabulary must contain at least two bins")
discrete_tokens = [trajectory_token(value) for value in range(size)]
existing = set(tokenizer.get_vocab())
collisions = [token for token in (*discrete_tokens, *STRUCTURED_SPECIAL_TOKENS) if token in existing]
if collisions:
raise ValueError(f"Trajectory tokens already exist in tokenizer: {collisions[:4]}")
added_discrete = tokenizer.add_tokens(discrete_tokens)
if added_discrete != size:
raise RuntimeError(f"Expected to add {size} trajectory tokens, added {added_discrete}")
tokenizer.add_tokens(list(STRUCTURED_SPECIAL_TOKENS), special_tokens=True)
discrete_ids = tokenizer.convert_tokens_to_ids(discrete_tokens)
start_id = int(discrete_ids[0])
expected_ids = list(range(start_id, start_id + size))
if list(discrete_ids) != expected_ids:
raise RuntimeError("Trajectory token IDs are not contiguous")
special_token_ids = {
token: int(tokenizer.convert_tokens_to_ids(token)) for token in STRUCTURED_SPECIAL_TOKENS
}
return TrajectoryVocabulary(
size=size,
start_id=start_id,
end_id=start_id + size - 1,
special_token_ids=special_token_ids,
)
def quantize_action(
action: torch.Tensor,
size: int,
minimum: float,
maximum: float,
) -> torch.LongTensor:
if maximum <= minimum:
raise ValueError("maximum must be greater than minimum")
normalized = (action.float().clamp(minimum, maximum) - minimum) / (maximum - minimum)
return (normalized * (size - 1)).round().long()
def dequantize_action(
tokens: torch.Tensor,
size: int,
minimum: float,
maximum: float,
) -> torch.Tensor:
if maximum <= minimum:
raise ValueError("maximum must be greater than minimum")
if torch.any(tokens < 0) or torch.any(tokens >= size):
raise ValueError("Trajectory token values are outside the configured vocabulary")
return tokens.float() / (size - 1) * (maximum - minimum) + minimum
def build_structured_target(
reasoning: str,
meta_action: str,
token_values: torch.Tensor,
) -> str:
flattened = token_values.detach().cpu().reshape(-1).tolist()
trajectory = "".join(trajectory_token(int(value)) for value in flattened)
return (
f"<|cot_start|>{reasoning}<|cot_end|>\n"
f"<|meta_action_start|>{meta_action}<|meta_action_end|>\n"
f"<|traj_future_start|>{trajectory}<|traj_future_end|><|im_end|>"
)
def extract_trajectory_values(
token_ids: torch.Tensor,
vocabulary: TrajectoryVocabulary,
) -> torch.LongTensor:
sequence = token_ids.detach().reshape(-1)
start_token_id = vocabulary.special_token_ids["<|traj_future_start|>"]
end_token_id = vocabulary.special_token_ids["<|traj_future_end|>"]
start_positions = torch.nonzero(sequence == start_token_id, as_tuple=False).flatten()
if start_positions.numel() == 0:
raise ValueError("Generated sequence does not contain <|traj_future_start|>")
start = int(start_positions[-1]) + 1
end_positions = torch.nonzero(sequence[start:] == end_token_id, as_tuple=False).flatten()
if end_positions.numel() == 0:
raise ValueError("Generated sequence does not contain <|traj_future_end|>")
end = start + int(end_positions[0])
values = sequence[start:end] - vocabulary.start_id
if torch.any(values < 0) or torch.any(values >= vocabulary.size):
raise ValueError("Generated trajectory block contains non-trajectory tokens")
return values.long()
class DiscreteTrajectoryMask(LogitsProcessor):
"""Source-style mask that prevents the VLM from sampling discrete trajectory tokens."""
def __init__(self, start_id: int, size: int):
self.start_id = start_id
self.size = size
def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
del input_ids
scores[:, self.start_id : self.start_id + self.size] = float("-inf")
return scores
class StopOneTokenAfterBoundary(StoppingCriteria):
"""Match Alpamayo's handoff behavior so generation updates the cache once after the marker."""
def __init__(self, boundary_token_id: int):
self.boundary_token_id = boundary_token_id
self.boundary_found: torch.BoolTensor | None = None
def __call__(
self,
input_ids: torch.LongTensor,
scores: torch.FloatTensor,
**kwargs: Any,
) -> torch.BoolTensor:
del scores, kwargs
if self.boundary_found is None:
self.boundary_found = torch.zeros(
input_ids.shape[0], dtype=torch.bool, device=input_ids.device
)
if self.boundary_found.all():
return torch.ones_like(self.boundary_found)
self.boundary_found |= input_ids[:, -1] == self.boundary_token_id
return torch.zeros_like(self.boundary_found)
def mask_after_boundary(
token_ids: torch.Tensor,
boundary_token_id: int,
pad_token_id: int,
) -> torch.Tensor:
result = token_ids.clone()
boundary_mask = result == boundary_token_id
sequence_length = result.shape[-1]
positions = torch.arange(sequence_length, device=result.device).expand_as(result)
first_boundary = torch.where(
boundary_mask,
positions,
torch.full_like(positions, sequence_length),
).min(dim=-1, keepdim=True).values
result[positions > first_boundary] = pad_token_id
return result
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import torch
from PIL import Image
from transformers import AutoProcessor, LlavaForConditionalGeneration
from .config import Stage1Config, VLMConfig
from .expert import PrefixKV
from .runtime import parameter_counts
def load_vlm(
model_path: str | Path,
config: VLMConfig,
device: torch.device,
dtype: torch.dtype,
) -> tuple[Any, LlavaForConditionalGeneration]:
processor = AutoProcessor.from_pretrained(model_path, use_fast=False)
# The selected third-party checkpoint was saved with this value set to 0,
# although its CLIP tower has one CLS token. LLaVA's "default" feature
# selection removes CLS after counting it, so the processor must start at 1
# to expand <image> into the same 576 tokens produced by the vision tower.
processor.num_additional_image_tokens = config.num_additional_image_tokens
model = LlavaForConditionalGeneration.from_pretrained(
model_path,
dtype=dtype,
attn_implementation=config.attn_implementation,
low_cpu_mem_usage=True,
)
model.to(device)
return processor, model
def _core_modules(model: LlavaForConditionalGeneration) -> tuple[Any, Any, Any, Any]:
core = model.model
vision_tower = core.vision_tower
projector = core.multi_modal_projector
language_model = core.language_model
language_backbone = language_model
return vision_tower, projector, language_model, language_backbone
def configure_stage1_trainability(
model: LlavaForConditionalGeneration, config: Stage1Config
) -> dict[str, Any]:
model.requires_grad_(False)
vision_tower, projector, language_model, backbone = _core_modules(model)
layers = backbone.layers
train_last = config.train_last_n_language_layers
if not (1 <= train_last <= len(layers)):
raise ValueError(f"train_last_n_language_layers must be within 1..{len(layers)}")
first_trainable = len(layers) - train_last
for layer in layers[first_trainable:]:
layer.requires_grad_(True)
if config.train_final_norm:
backbone.norm.requires_grad_(True)
if config.train_lm_head:
model.lm_head.requires_grad_(True)
if config.freeze_vision_tower:
vision_tower.requires_grad_(False)
if config.freeze_multimodal_projector:
projector.requires_grad_(False)
if config.gradient_checkpointing:
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
model.config.use_cache = False
language_model.config.use_cache = False
counts = parameter_counts(model)
counts.update(
{
"language_layers_total": len(layers),
"language_layers_trainable": train_last,
"first_trainable_language_layer": first_trainable,
"vision_trainable": any(p.requires_grad for p in vision_tower.parameters()),
"projector_trainable": any(p.requires_grad for p in projector.parameters()),
"lm_head_trainable": any(p.requires_grad for p in model.lm_head.parameters()),
}
)
return counts
def enforce_frozen_module_eval(model: LlavaForConditionalGeneration) -> None:
vision_tower, projector, _, _ = _core_modules(model)
vision_tower.eval()
projector.eval()
def _move_inputs(
inputs: dict[str, torch.Tensor], device: torch.device, dtype: torch.dtype
) -> dict[str, torch.Tensor]:
moved: dict[str, torch.Tensor] = {}
for key, value in inputs.items():
if not isinstance(value, torch.Tensor):
moved[key] = value
elif value.is_floating_point():
moved[key] = value.to(device=device, dtype=dtype)
else:
moved[key] = value.to(device=device)
return moved
def process_image_text(
processor: Any,
image: Image.Image,
text: str,
device: torch.device,
dtype: torch.dtype,
) -> dict[str, torch.Tensor]:
encoded = processor(text=text, images=image, return_tensors="pt")
return _move_inputs(dict(encoded), device=device, dtype=dtype)
def prepare_stage1_batch(
processor: Any,
image: Image.Image,
prefix: str,
full_text: str,
device: torch.device,
dtype: torch.dtype,
) -> tuple[dict[str, torch.Tensor], torch.Tensor, int]:
prefix_inputs = process_image_text(processor, image, prefix, device, dtype)
full_inputs = process_image_text(processor, image, full_text, device, dtype)
prefix_length = prefix_inputs["input_ids"].shape[1]
labels = full_inputs["input_ids"].clone()
labels[:, :prefix_length] = -100
if "attention_mask" in full_inputs:
labels[full_inputs["attention_mask"] == 0] = -100
return full_inputs, labels, prefix_length
@torch.no_grad()
def generate_reasoning(
model: LlavaForConditionalGeneration,
processor: Any,
image: Image.Image,
prefix: str,
device: torch.device,
dtype: torch.dtype,
max_new_tokens: int,
) -> str:
model.eval()
inputs = process_image_text(processor, image, prefix, device, dtype)
prompt_length = inputs["input_ids"].shape[1]
generated = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
use_cache=True,
pad_token_id=processor.tokenizer.pad_token_id,
eos_token_id=processor.tokenizer.eos_token_id,
)
new_tokens = generated[:, prompt_length:]
return processor.batch_decode(new_tokens, skip_special_tokens=True)[0].strip()
@torch.no_grad()
def extract_prefix_kv(
model: LlavaForConditionalGeneration,
processor: Any,
image: Image.Image,
text: str,
device: torch.device,
dtype: torch.dtype,
) -> PrefixKV:
model.eval()
model.config.use_cache = True
model.model.language_model.config.use_cache = True
inputs = process_image_text(processor, image, text, device, dtype)
outputs = model(**inputs, use_cache=True, return_dict=True)
if outputs.past_key_values is None:
raise RuntimeError("VLM did not return past_key_values")
return PrefixKV.from_transformers_cache(outputs.past_key_values)
def vlm_text_config(model: LlavaForConditionalGeneration) -> Any:
return model.config.text_config
+13
View File
@@ -0,0 +1,13 @@
import torch
from alpamayo_lite.action_space import RelativeDeltaActionSpace
def test_action_round_trip() -> None:
history = torch.tensor([[[-1.0, 0.0], [0.0, 0.0]]])
future = torch.tensor([[[1.0, 0.2], [2.1, 0.5], [3.2, 0.9]]])
statistics = RelativeDeltaActionSpace.fit(history, future)
action_space = RelativeDeltaActionSpace(statistics.mean, statistics.scale)
action = action_space.trajectory_to_action(history, future)
reconstructed = action_space.action_to_trajectory(action, history)
torch.testing.assert_close(reconstructed, future)
+46
View File
@@ -0,0 +1,46 @@
import runpy
from pathlib import Path
import numpy as np
import pytest
SCRIPT = runpy.run_path(
str(Path(__file__).resolve().parents[1] / "scripts" / "build_complete_comparison.py")
)
_decode_action = SCRIPT["_decode_action"]
_extract_component = SCRIPT["_extract_component"]
_prediction_metrics = SCRIPT["_prediction_metrics"]
def test_decode_action_uses_statistics_and_nonzero_history_anchor() -> None:
history_xy = np.array([[8.0, -4.0], [10.0, -3.0]])
action = np.array([[1.0, -1.0], [0.0, 2.0]])
decoded = _decode_action(
action,
history_xy,
mean=np.array([0.5, 0.25]),
scale=np.array([2.0, 0.5]),
)
np.testing.assert_allclose(decoded, np.array([[12.5, -3.25], [13.0, -2.0]]))
def test_extract_component_requires_one_ordered_nonempty_pair() -> None:
text = "<|cot_start|>keep left<|cot_end|>"
assert _extract_component(text, "cot") == "keep left"
with pytest.raises(ValueError):
_extract_component(text + text, "cot")
with pytest.raises(ValueError):
_extract_component("<|cot_start|><|cot_end|>", "cot")
def test_prediction_metrics_use_normalized_action_and_euclidean_xy() -> None:
target_action = np.zeros((2, 2))
predicted_action = np.ones((2, 2))
target_xy = np.zeros((2, 2))
predicted_xy = np.array([[3.0, 4.0], [0.0, 0.0]])
assert _prediction_metrics(predicted_action, predicted_xy, target_action, target_xy) == {
"action_mse": 1.0,
"ade_m": 2.5,
"fde_m": 0.0,
"max_error_m": 5.0,
}
+11
View File
@@ -0,0 +1,11 @@
import torch
from alpamayo_lite.diffusion import FlowMatching
def test_flow_matching_target_velocity() -> None:
flow = FlowMatching((4, 2), inference_steps=2)
target = torch.ones(1, 4, 2)
data = flow.construct_training_data(target)
exact_velocity = data["target_action"] - data["noise"]
assert float(flow.loss(data, exact_velocity)) == 0.0
+69
View File
@@ -0,0 +1,69 @@
import torch
from transformers import Qwen2Config
from alpamayo_lite.expert import ExpertArchitecture, KVCachedDiffusionExpert, PrefixKV
def tiny_expert(non_causal_attention: bool = False) -> KVCachedDiffusionExpert:
vlm_config = Qwen2Config(
vocab_size=32,
hidden_size=64,
intermediate_size=128,
num_hidden_layers=2,
num_attention_heads=4,
num_key_value_heads=2,
max_position_embeddings=128,
)
architecture = ExpertArchitecture(
hidden_size=32,
intermediate_size=64,
num_layers=2,
num_attention_heads=2,
num_key_value_heads=2,
head_dim=16,
action_projection_hidden_size=16,
action_fourier_features=4,
non_causal_attention=non_causal_attention,
)
return KVCachedDiffusionExpert(vlm_config, architecture)
def test_expert_consumes_compatible_past_kv() -> None:
expert = tiny_expert()
prefix = PrefixKV(
keys=tuple(torch.randn(1, 2, 5, 16) for _ in range(2)),
values=tuple(torch.randn(1, 2, 5, 16) for _ in range(2)),
)
output = expert(torch.randn(1, 3, 2), torch.rand(1, 1, 1), prefix)
assert output.shape == (1, 3, 2)
def test_expert_rejects_incompatible_kv_heads() -> None:
expert = tiny_expert()
prefix = PrefixKV(
keys=tuple(torch.randn(1, 1, 5, 16) for _ in range(2)),
values=tuple(torch.randn(1, 1, 5, 16) for _ in range(2)),
)
try:
expert.validate_prefix(prefix)
except ValueError as error:
assert "KV-head mismatch" in str(error)
else:
raise AssertionError("Expected incompatible KV heads to be rejected")
def test_non_causal_expert_allows_future_action_attention() -> None:
torch.manual_seed(3)
expert = tiny_expert(non_causal_attention=True).eval()
prefix = PrefixKV(
keys=tuple(torch.randn(1, 2, 5, 16) for _ in range(2)),
values=tuple(torch.randn(1, 2, 5, 16) for _ in range(2)),
)
first = torch.randn(1, 3, 2)
changed_future = first.clone()
changed_future[:, -1] += 0.137
timestep = torch.rand(1, 1, 1)
with torch.no_grad():
first_output = expert(first, timestep, prefix)
second_output = expert(changed_future, timestep, prefix)
assert not torch.allclose(first_output[:, 0], second_output[:, 0])
+39
View File
@@ -0,0 +1,39 @@
import torch
from alpamayo_lite.trajectory_tokens import (
DiscreteTrajectoryMask,
TrajectoryVocabulary,
dequantize_action,
extract_trajectory_values,
quantize_action,
)
def test_quantized_action_round_trip_is_bounded() -> None:
action = torch.tensor([[-4.0, -1.25], [0.0, 1.5], [3.9, 4.0]])
tokens = quantize_action(action, size=128, minimum=-4.0, maximum=4.0)
reconstructed = dequantize_action(tokens, size=128, minimum=-4.0, maximum=4.0)
assert float((reconstructed - action).abs().max()) <= 8.0 / 127.0
def test_extract_trajectory_values_between_markers() -> None:
vocabulary = TrajectoryVocabulary(
size=8,
start_id=100,
end_id=107,
special_token_ids={"<|traj_future_start|>": 200, "<|traj_future_end|>": 201},
)
sequence = torch.tensor([1, 200, 103, 107, 100, 201, 2])
torch.testing.assert_close(
extract_trajectory_values(sequence, vocabulary), torch.tensor([3, 7, 0])
)
def test_discrete_trajectory_mask_changes_only_trajectory_range() -> None:
input_ids = torch.tensor([[1, 2]])
scores = torch.randn(1, 16)
original = scores.clone()
masked = DiscreteTrajectoryMask(start_id=5, size=4)(input_ids, scores)
assert torch.isneginf(masked[:, 5:9]).all()
torch.testing.assert_close(masked[:, :5], original[:, :5])
torch.testing.assert_close(masked[:, 9:], original[:, 9:])