diff --git a/.gitignore b/.gitignore
index ff92181..55471a9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,5 @@ __pycache__/
.venv/
data/
outputs/**/*.pt
+outputs/notebook/
.DS_Store
diff --git a/README.md b/README.md
index beba5ec..424db9b 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,22 @@ python -m pip install -e '.[dev]'
pytest -q
```
+## 一键 Notebook 复现
+
+完整的论文链接、架构拆解、数据准备、过拟合训练、checkpoint 重载、推理指标和轨迹图都在:
+
+[`notebooks/pi0_minimal_reproduction.ipynb`](notebooks/pi0_minimal_reproduction.ipynb)
+
+```bash
+python3.12 -m venv .venv
+source .venv/bin/activate
+python -m pip install -U pip
+python -m pip install -e '.[notebook,dev]'
+python -m jupyter lab notebooks/pi0_minimal_reproduction.ipynb
+```
+
+Notebook 默认可直接 Run All:优先读取 `PI0_SOURCE_MANIFEST` 指定的已处理数据,其次尝试相邻 Alpamayo 项目,最后自动生成一个自包含 synthetic 样本。可通过 `PI0_PROFILE=smoke` 将训练缩短到 5 步,仅检查执行链路;默认 `full` 使用 600 步完成过拟合验证。
+
`tests/test_flow.py` 验证直线概率路径 `x_t=(1-t)·noise+t·action`、目标速度 `action-noise`、MSE 损失、随机可复现性和 Euler 积分。
## 过拟合训练与效果图
diff --git a/notebooks/pi0_minimal_reproduction.ipynb b/notebooks/pi0_minimal_reproduction.ipynb
new file mode 100644
index 0000000..6d9188a
--- /dev/null
+++ b/notebooks/pi0_minimal_reproduction.ipynb
@@ -0,0 +1,617 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "pi0-00",
+ "metadata": {},
+ "source": [
+ "# π₀ 最小复现:从论文架构到单样本过拟合\n",
+ "\n",
+ "本 Notebook 是本仓库的完整可执行入口。默认从上到下 Run All 即可完成环境安装、数据导入、结构检查、过拟合训练、checkpoint 重载、推理指标和轨迹绘图。\n",
+ "\n",
+ "## 目录\n",
+ "\n",
+ "- [1. 原论文、官方源码与复现边界](#paper)\n",
+ "- [2. π₀ 架构与阶段输入输出](#architecture)\n",
+ "- [3. 环境安装](#environment)\n",
+ "- [4. 运行配置、数据和权重导入](#data)\n",
+ "- [5. 样本与动作表示](#sample)\n",
+ "- [6. 三块 attention mask](#mask)\n",
+ "- [7. Flow matching 数学](#flow)\n",
+ "- [8. 模型实例化与张量形状](#model)\n",
+ "- [9. 最小过拟合训练](#train)\n",
+ "- [10. Checkpoint 重载、推理和实验结果](#results)\n",
+ "- [11. Run All 验收](#checklist)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "pi0-01",
+ "metadata": {},
+ "source": [
+ "\n",
+ "## 1. 原论文、官方源码与复现边界\n",
+ "\n",
+ "- 原论文:[π₀: A Vision-Language-Action Flow Model for General Robot Control](https://arxiv.org/abs/2410.24164)\n",
+ "- 官方项目介绍:[Physical Intelligence π₀](https://www.pi.website/blog/pi0)\n",
+ "- 官方源码:[Physical-Intelligence/openpi](https://github.com/Physical-Intelligence/openpi)\n",
+ "- 可选真实数据来源:[NVIDIA PhysicalAI Autonomous Vehicles](https://huggingface.co/datasets/nvidia/PhysicalAI-Autonomous-Vehicles)\n",
+ "\n",
+ "### 复现边界\n",
+ "\n",
+ "这个仓库验证的是 π₀ 最关键的信息流与优化闭环:视觉/语言 prefix、连续 state、独立 action expert、blockwise attention、flow matching 动作生成。它不是官方模型的数值复刻:\n",
+ "\n",
+ "- 小型 patch encoder 和 token embedding 代替预训练 PaliGemma。\n",
+ "- 固定语言 ID [1, 2] 代表一条极简指令,不含真实 tokenizer。\n",
+ "- 二维 16-step 驾驶轨迹代替机器人高维 action chunk。\n",
+ "- 参数量约 66 万,从头在一个样本的 4 个确定性增强版本上过拟合。\n",
+ "- 不下载也不声称使用官方 π₀ 权重。本地 checkpoint 只兼容本项目 toy model。\n",
+ "\n",
+ "成功代表结构和训练链路可运行、可记忆小数据,不代表官方能力、泛化能力或真实机器人安全性。"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "pi0-02",
+ "metadata": {},
+ "source": [
+ "\n",
+ "## 2. π₀ 架构与阶段输入输出\n",
+ "\n",
+ " 64×64 RGB ── patch encoder ─┐\n",
+ " 固定语言 ID ─ embedding ────┴─ VLM prefix expert ───────────────┐\n",
+ " 历史 XY [8,2] ─ state MLP ───── state token ────────────────────┤\n",
+ " noisy action [16,2] + time ─── action tokens ─ action expert ───┤\n",
+ " ↓\n",
+ " velocity [16,2]\n",
+ " ↓\n",
+ " Euler flow integration\n",
+ " ↓\n",
+ " future XY [16,2]\n",
+ "\n",
+ "| 阶段 | 输入 | 输出 | 本最小实现 |\n",
+ "|---|---|---|---|\n",
+ "| 视觉语言 prefix | RGB 图像、语言 token | image/language hidden tokens | 16 个 patch + 2 个固定语言 token |\n",
+ "| 连续 state 编码 | 历史轨迹 [8,2] | 1 个 state token | flatten 为 [16] 后过 MLP |\n",
+ "| Flow 加噪 | 真值 action [16,2]、噪声、t | noisy action、目标速度 | x_t=(1-t)noise+t action |\n",
+ "| Action/time 编码 | noisy action、Fourier time | 16 个 action tokens | 拼接后过 MLP |\n",
+ "| 双专家 Transformer | prefix、state、action | action hidden states | VLM/action 每层参数独立 |\n",
+ "| Vector-field head | action hidden | velocity [16,2] | flow velocity MSE |\n",
+ "| Euler 推理 | 高斯噪声、10 次预测 | normalized action | t=0 积分到 t=1 |\n",
+ "| 轨迹解码 | normalized XY delta | future XY [16,2] | 反归一化并累计 |\n",
+ "\n",
+ "三块可见性规则:\n",
+ "\n",
+ "| Query \\ Key | Image + Language | State | Action |\n",
+ "|---|---:|---:|---:|\n",
+ "| Image + Language | ✓ | ✗ | ✗ |\n",
+ "| State | ✓ | ✓ | ✗ |\n",
+ "| Action | ✓ | ✓ | ✓ |"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "pi0-03",
+ "metadata": {},
+ "source": [
+ "\n",
+ "## 3. 环境安装\n",
+ "\n",
+ "推荐在仓库根目录先创建 kernel:\n",
+ "\n",
+ " python3.12 -m venv .venv\n",
+ " source .venv/bin/activate\n",
+ " python -m pip install -U pip\n",
+ " python -m pip install -e '.[notebook,dev]'\n",
+ " python -m jupyter lab notebooks/pi0_minimal_reproduction.ipynb\n",
+ "\n",
+ "下面的第一个代码单元会再次执行 editable install,确保当前 kernel 引用的就是本仓库源码。已有 PyTorch 的环境不会重复下载相同版本。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-04",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from __future__ import annotations\n",
+ "\n",
+ "import os\n",
+ "import platform\n",
+ "import subprocess\n",
+ "import sys\n",
+ "from pathlib import Path\n",
+ "\n",
+ "\n",
+ "def find_repo_root(start: Path) -> Path:\n",
+ " for candidate in (start.resolve(), *start.resolve().parents):\n",
+ " if (candidate / \"pyproject.toml\").is_file() and (candidate / \"src/pi0mini\").is_dir():\n",
+ " return candidate\n",
+ " raise FileNotFoundError(\"请从 pi0-minimal-validation 仓库内启动 Notebook\")\n",
+ "\n",
+ "\n",
+ "ROOT = find_repo_root(Path.cwd())\n",
+ "os.chdir(ROOT)\n",
+ "subprocess.run(\n",
+ " [sys.executable, \"-m\", \"pip\", \"install\", \"--disable-pip-version-check\", \"-e\", str(ROOT)],\n",
+ " check=True,\n",
+ ")\n",
+ "print(\"Repository:\", ROOT)\n",
+ "print(\"Python:\", sys.version.split()[0])\n",
+ "print(\"Platform:\", platform.platform())"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-05",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import copy\n",
+ "import json\n",
+ "import shutil\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import numpy as np\n",
+ "import torch\n",
+ "import yaml\n",
+ "from IPython.display import Image as NotebookImage, display\n",
+ "from PIL import Image, ImageDraw\n",
+ "\n",
+ "from pi0mini.data import Pi0OverfitDataset, load_manifest, materialize_subset\n",
+ "from pi0mini.flow import FlowMatching\n",
+ "from pi0mini.infer import evaluate\n",
+ "from pi0mini.model import Pi0Config, Pi0Mini\n",
+ "from pi0mini.runtime import choose_device\n",
+ "from pi0mini.train import train\n",
+ "\n",
+ "print(\"torch:\", torch.__version__)\n",
+ "print(\"MPS available:\", torch.backends.mps.is_available())\n",
+ "print(\"CUDA available:\", torch.cuda.is_available())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "pi0-06",
+ "metadata": {},
+ "source": [
+ "\n",
+ "## 4. 运行配置、数据和权重导入\n",
+ "\n",
+ "| 变量 | 取值 | 默认行为 |\n",
+ "|---|---|---|\n",
+ "| PI0_PROFILE | full / smoke / reuse | full,训练 600 步 |\n",
+ "| PI0_DATA_MODE | auto / local / synthetic | auto |\n",
+ "| PI0_SOURCE_MANIFEST | manifest 绝对路径 | 未设置时自动探测 |\n",
+ "| PI0_CHECKPOINT | 本项目 checkpoint 路径 | 未设置时从头训练 |\n",
+ "| PI0_DEVICE | auto / cpu / mps / cuda | auto |\n",
+ "\n",
+ "数据优先级为:用户指定 manifest → 相邻 Alpamayo 已处理数据 → 本仓库已物化数据 → synthetic fallback。\n",
+ "\n",
+ "真实 PAI 数据是 gated dataset。请先在数据页面接受许可并在外部完成下载/预处理,再把 PI0_SOURCE_MANIFEST 指向包含 image_path 和 trajectory_path 的 JSONL。Notebook 不打印或保存 HF_TOKEN,也不把原始数据提交 Git。\n",
+ "\n",
+ "checkpoint 必须来自本仓库相同架构。若导入 checkpoint,推理数据也应与训练时一致;Notebook 会至少保证数据格式和固定 data/overfit 路径可用。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-07",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "PROFILE = os.environ.get(\"PI0_PROFILE\", \"full\").lower()\n",
+ "DATA_MODE = os.environ.get(\"PI0_DATA_MODE\", \"auto\").lower()\n",
+ "LOCAL_MANIFEST = os.environ.get(\"PI0_SOURCE_MANIFEST\", \"\").strip()\n",
+ "LOCAL_CHECKPOINT = os.environ.get(\"PI0_CHECKPOINT\", \"\").strip()\n",
+ "DEVICE_REQUEST = os.environ.get(\"PI0_DEVICE\", \"auto\").lower()\n",
+ "\n",
+ "if PROFILE not in {\"full\", \"smoke\", \"reuse\"}:\n",
+ " raise ValueError(\"PI0_PROFILE must be full, smoke, or reuse\")\n",
+ "if DATA_MODE not in {\"auto\", \"local\", \"synthetic\"}:\n",
+ " raise ValueError(\"PI0_DATA_MODE must be auto, local, or synthetic\")\n",
+ "\n",
+ "TRAIN_STEPS = {\"full\": 600, \"smoke\": 5, \"reuse\": 600}[PROFILE]\n",
+ "NOTEBOOK_OUTPUT = ROOT / \"outputs/notebook\"\n",
+ "STAGING = NOTEBOOK_OUTPUT / \"source_data\"\n",
+ "NOTEBOOK_OUTPUT.mkdir(parents=True, exist_ok=True)\n",
+ "print({\"profile\": PROFILE, \"data_mode\": DATA_MODE, \"steps_if_training\": TRAIN_STEPS,\n",
+ " \"device\": str(choose_device(DEVICE_REQUEST))})"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-08",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "def valid_manifest(path: Path) -> bool:\n",
+ " try:\n",
+ " rows = load_manifest(path)\n",
+ " return bool(rows and rows[0].image_path.is_file() and rows[0].trajectory_path.is_file())\n",
+ " except Exception:\n",
+ " return False\n",
+ "\n",
+ "\n",
+ "def create_synthetic_source(root: Path) -> Path:\n",
+ " if root.exists():\n",
+ " shutil.rmtree(root)\n",
+ " image_dir = root / \"images\"\n",
+ " trajectory_dir = root / \"trajectories\"\n",
+ " image_dir.mkdir(parents=True)\n",
+ " trajectory_dir.mkdir(parents=True)\n",
+ " height, width = 96, 160\n",
+ " pixels = np.zeros((height, width, 3), dtype=np.uint8)\n",
+ " pixels[..., 0] = 35\n",
+ " pixels[..., 1] = np.linspace(45, 135, width, dtype=np.uint8)[None, :]\n",
+ " pixels[..., 2] = np.linspace(120, 35, height, dtype=np.uint8)[:, None]\n",
+ " road = Image.fromarray(pixels)\n",
+ " draw = ImageDraw.Draw(road)\n",
+ " draw.polygon([(58, 95), (75, 48), (85, 48), (105, 95)], fill=(55, 55, 60))\n",
+ " draw.line([(80, 90), (80, 53)], fill=(240, 220, 80), width=2)\n",
+ " road.save(image_dir / \"synthetic_front.jpg\")\n",
+ "\n",
+ " history_x = np.linspace(-7.0, 0.0, 8, dtype=np.float32)\n",
+ " history = np.stack([history_x, 0.01 * history_x, np.zeros_like(history_x)], axis=-1)\n",
+ " future_x = np.linspace(1.0, 32.0, 16, dtype=np.float32)\n",
+ " future = np.stack([future_x, 0.002 * future_x**2, np.zeros_like(future_x)], axis=-1)\n",
+ " np.savez_compressed(trajectory_dir / \"synthetic.npz\",\n",
+ " history_xyz=history.astype(np.float32),\n",
+ " future_xyz=future.astype(np.float32))\n",
+ " manifest = root / \"overfit_manifest.jsonl\"\n",
+ " manifest.write_text(json.dumps({\n",
+ " \"clip_id\": \"synthetic-road\", \"t0_us\": 0,\n",
+ " \"image_path\": \"images/synthetic_front.jpg\",\n",
+ " \"trajectory_path\": \"trajectories/synthetic.npz\",\n",
+ " }) + \"\\n\", encoding=\"utf-8\")\n",
+ " return manifest\n",
+ "\n",
+ "\n",
+ "selected = None\n",
+ "selected_label = None\n",
+ "if DATA_MODE != \"synthetic\":\n",
+ " candidates = []\n",
+ " if LOCAL_MANIFEST:\n",
+ " candidates.append((\"PI0_SOURCE_MANIFEST\", Path(LOCAL_MANIFEST).expanduser()))\n",
+ " candidates.extend([\n",
+ " (\"neighbor Alpamayo\", ROOT.parent.parent / \"alpamayo/alpamayo-qwen05b-validation/data/processed/overfit_manifest.jsonl\"),\n",
+ " (\"existing materialized sample\", ROOT / \"data/overfit/overfit_manifest.jsonl\"),\n",
+ " ])\n",
+ " for label, candidate in candidates:\n",
+ " if valid_manifest(candidate):\n",
+ " selected, selected_label = candidate.resolve(), label\n",
+ " break\n",
+ "\n",
+ "if DATA_MODE == \"local\" and selected is None:\n",
+ " raise FileNotFoundError(\"PI0_DATA_MODE=local requires a valid PI0_SOURCE_MANIFEST\")\n",
+ "if selected is None:\n",
+ " SOURCE_MANIFEST = create_synthetic_source(STAGING)\n",
+ " selected_label = \"synthetic fallback\"\n",
+ "else:\n",
+ " if STAGING.exists():\n",
+ " shutil.rmtree(STAGING)\n",
+ " SOURCE_MANIFEST = materialize_subset(selected, STAGING)\n",
+ "\n",
+ "assert valid_manifest(SOURCE_MANIFEST)\n",
+ "print(\"Data source:\", selected_label)\n",
+ "print(\"Staged manifest:\", SOURCE_MANIFEST)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "pi0-09",
+ "metadata": {},
+ "source": [
+ "\n",
+ "## 5. 样本与动作表示\n",
+ "\n",
+ "Dataset 输出 image [3,64,64]、state [16]、归一化 actions [16,2]、history_xy [8,2] 和 future_xy [16,2]。下面验证 action encode/decode 可逆,并显示相机图像和轨迹真值。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-10",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "dataset = Pi0OverfitDataset(SOURCE_MANIFEST, image_size=64, augmentation_factor=4,\n",
+ " history_steps=8, action_horizon=16)\n",
+ "sample = dataset[0]\n",
+ "decoded = dataset.action_normalizer.decode(sample[\"actions\"], sample[\"history_xy\"])\n",
+ "torch.testing.assert_close(decoded, sample[\"future_xy\"], atol=1e-5, rtol=0)\n",
+ "print({key: tuple(value.shape) for key, value in sample.items() if torch.is_tensor(value)})\n",
+ "print(\"action statistics:\", dataset.action_normalizer.as_dict())\n",
+ "\n",
+ "fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))\n",
+ "axes[0].imshow(sample[\"image\"].permute(1, 2, 0))\n",
+ "axes[0].set_title(\"64×64 visual observation\")\n",
+ "axes[0].axis(\"off\")\n",
+ "axes[1].plot(sample[\"history_xy\"][:, 0], sample[\"history_xy\"][:, 1], \"o-\", label=\"history\")\n",
+ "axes[1].plot(sample[\"future_xy\"][:, 0], sample[\"future_xy\"][:, 1], \"o-\", label=\"future target\")\n",
+ "axes[1].scatter([0], [0], c=\"black\", marker=\"s\", label=\"ego\")\n",
+ "axes[1].axis(\"equal\")\n",
+ "axes[1].grid(alpha=0.3)\n",
+ "axes[1].legend()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "pi0-11",
+ "metadata": {},
+ "source": [
+ "\n",
+ "## 6. 三块 attention mask\n",
+ "\n",
+ "Prefix 内部双向可见;state 可以读取 prefix 和自身,但不能偷看 noisy future action;action 可以读取 prefix、state 和完整 action chunk。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-12",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "probe = Pi0Mini(Pi0Config(image_size=32, patch_size=16, max_text_tokens=4,\n",
+ " max_action_tokens=3, d_model=32, n_heads=4,\n",
+ " n_layers=1, time_feature_dim=8))\n",
+ "prefix_edges, suffix_edges = probe._masks(\n",
+ " batch=1, prefix_length=6, suffix_length=4, image_tokens=4,\n",
+ " attention_mask=torch.tensor([[1, 1]]), device=torch.device(\"cpu\"))\n",
+ "assert suffix_edges[0, 0, :7].all()\n",
+ "assert not suffix_edges[0, 0, 7:].any()\n",
+ "assert suffix_edges[0, 1:].all()\n",
+ "\n",
+ "plt.figure(figsize=(8, 3))\n",
+ "plt.imshow(suffix_edges[0].numpy(), cmap=\"Greys\", vmin=0, vmax=1)\n",
+ "plt.axvline(5.5, color=\"tab:red\", linewidth=1)\n",
+ "plt.axvline(6.5, color=\"tab:blue\", linewidth=1)\n",
+ "plt.yticks(range(4), [\"state\", \"action 0\", \"action 1\", \"action 2\"])\n",
+ "plt.xlabel(\"key: prefix (0..5) | state (6) | action (7..9)\")\n",
+ "plt.ylabel(\"suffix query\")\n",
+ "plt.title(\"Allowed attention edges\")\n",
+ "plt.colorbar()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "pi0-13",
+ "metadata": {},
+ "source": [
+ "\n",
+ "## 7. Flow matching 数学\n",
+ "\n",
+ "训练使用直线路径:\n",
+ "\n",
+ "$$x_t=(1-t)\\epsilon+t a, \\qquad v^*(x_t,t)=a-\\epsilon$$\n",
+ "\n",
+ "其中 a 是归一化 action,epsilon 是高斯噪声。模型回归 velocity;推理从 t=0 的噪声出发,用 Euler 积分到 t=1。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-14",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "flow = FlowMatching(inference_steps=10)\n",
+ "target = torch.randn(4, 16, 2)\n",
+ "generator = torch.Generator().manual_seed(7)\n",
+ "x_t, t, target_velocity = flow.training_batch(target, generator)\n",
+ "noise = target - target_velocity\n",
+ "expected = (1 - t[:, None, None]) * noise + t[:, None, None] * target\n",
+ "torch.testing.assert_close(x_t, expected)\n",
+ "print(\"sampled action-time t:\", t.tolist())\n",
+ "print(\"x_t shape:\", tuple(x_t.shape), \"target velocity shape:\", tuple(target_velocity.shape))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "pi0-15",
+ "metadata": {},
+ "source": [
+ "\n",
+ "## 8. 模型实例化与张量形状\n",
+ "\n",
+ "先生成 Notebook 专用 runtime config,再用一个 batch 检查图像、语言、state、noisy action 到 vector field 的完整形状。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-16",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "base_cfg = yaml.safe_load((ROOT / \"configs/overfit.yaml\").read_text(encoding=\"utf-8\"))\n",
+ "cfg = copy.deepcopy(base_cfg)\n",
+ "cfg[\"data\"][\"source_manifest\"] = str(SOURCE_MANIFEST.resolve())\n",
+ "cfg[\"train\"][\"steps\"] = TRAIN_STEPS\n",
+ "cfg[\"train\"][\"log_every\"] = 1 if PROFILE == \"smoke\" else cfg[\"train\"][\"log_every\"]\n",
+ "cfg[\"device\"] = DEVICE_REQUEST\n",
+ "cfg[\"output_dir\"] = \"outputs/notebook/overfit\"\n",
+ "RUNTIME_CONFIG = NOTEBOOK_OUTPUT / \"runtime_config.yaml\"\n",
+ "RUNTIME_CONFIG.write_text(yaml.safe_dump(cfg, sort_keys=False, allow_unicode=True), encoding=\"utf-8\")\n",
+ "print(RUNTIME_CONFIG.read_text(encoding=\"utf-8\"))"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-17",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "mc = cfg[\"model\"]\n",
+ "model = Pi0Mini(Pi0Config(\n",
+ " vocab_size=16, action_dim=2, state_dim=dataset.state_dim,\n",
+ " image_size=cfg[\"data\"][\"image_size\"], patch_size=mc[\"patch_size\"],\n",
+ " d_model=mc[\"d_model\"], n_heads=mc[\"n_heads\"], n_layers=mc[\"n_layers\"],\n",
+ " mlp_ratio=mc[\"mlp_ratio\"], max_text_tokens=8,\n",
+ " max_action_tokens=cfg[\"data\"][\"action_horizon\"], dropout=mc[\"dropout\"]))\n",
+ "batch_size = 2\n",
+ "images = torch.stack([dataset[i][\"image\"] for i in range(batch_size)])\n",
+ "states = torch.stack([dataset[i][\"state\"] for i in range(batch_size)])\n",
+ "actions = torch.stack([dataset[i][\"actions\"] for i in range(batch_size)])\n",
+ "noisy, timesteps, velocity_target = flow.training_batch(actions)\n",
+ "out = model(images, torch.tensor([[1, 2]]).expand(batch_size, -1), states,\n",
+ " noisy, timesteps, target_velocity=velocity_target)\n",
+ "print(\"parameters:\", f\"{sum(p.numel() for p in model.parameters()):,}\")\n",
+ "print({\"prefix_hidden\": tuple(out.prefix_hidden.shape),\n",
+ " \"state_hidden\": tuple(out.state_hidden.shape),\n",
+ " \"action_hidden\": tuple(out.action_hidden.shape),\n",
+ " \"vector_field\": tuple(out.vector_field.shape), \"loss\": float(out.loss)})\n",
+ "del model, out"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "pi0-18",
+ "metadata": {},
+ "source": [
+ "\n",
+ "## 9. 最小过拟合训练\n",
+ "\n",
+ "- full:默认 600 步,用于验证 loss 下降和轨迹拟合。\n",
+ "- smoke:5 步,只验证 Run All 管线,不能代表收敛。\n",
+ "- reuse:优先复用 PI0_CHECKPOINT 或 outputs/notebook/overfit/checkpoint.pt;找不到时仍会训练。\n",
+ "\n",
+ "如果显式设置 PI0_CHECKPOINT,Notebook 不会覆盖它。训练生成的 checkpoint、数据 staging 和 runtime config 都在 gitignore 范围内。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-19",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "expected_checkpoint = NOTEBOOK_OUTPUT / \"overfit/checkpoint.pt\"\n",
+ "provided_checkpoint = Path(LOCAL_CHECKPOINT).expanduser().resolve() if LOCAL_CHECKPOINT else None\n",
+ "if provided_checkpoint is not None:\n",
+ " if not provided_checkpoint.is_file():\n",
+ " raise FileNotFoundError(provided_checkpoint)\n",
+ " materialize_subset(SOURCE_MANIFEST, ROOT / \"data/overfit\")\n",
+ " checkpoint_path = provided_checkpoint\n",
+ " print(\"Using PI0_CHECKPOINT:\", checkpoint_path)\n",
+ "elif PROFILE == \"reuse\" and expected_checkpoint.is_file():\n",
+ " materialize_subset(SOURCE_MANIFEST, ROOT / \"data/overfit\")\n",
+ " checkpoint_path = expected_checkpoint\n",
+ " print(\"Reusing notebook checkpoint:\", checkpoint_path)\n",
+ "else:\n",
+ " checkpoint_path = train(str(RUNTIME_CONFIG))\n",
+ "\n",
+ "checkpoint_path = Path(checkpoint_path)\n",
+ "assert checkpoint_path.is_file()\n",
+ "print(\"Checkpoint:\", checkpoint_path)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "pi0-20",
+ "metadata": {},
+ "source": [
+ "\n",
+ "## 10. Checkpoint 重载、推理和实验结果\n",
+ "\n",
+ "evaluate() 会重新构造模型、加载 checkpoint,用固定噪声做 10-step Euler flow sampling,并对 4 个确定性增强样本分别计算 future trajectory MSE。\n",
+ "\n",
+ "本 Notebook 已在本地完成 600-step MPS Run All 验证:flow loss 从 2.262 降至 0.164,4 个增强样本平均 trajectory MSE 约 0.181 m²。具体数值会因设备、数据源和随机算子略有变化;下面显示当前这次 Run All 的真实结果。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-21",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "plot_path = evaluate(str(checkpoint_path))\n",
+ "output_dir = checkpoint_path.parent\n",
+ "summary = json.loads((output_dir / \"summary.json\").read_text(encoding=\"utf-8\"))\n",
+ "history = json.loads((output_dir / \"training_history.json\").read_text(encoding=\"utf-8\"))\n",
+ "evaluation = json.loads((output_dir / \"evaluation.json\").read_text(encoding=\"utf-8\"))\n",
+ "mean_mse = float(np.mean([row[\"trajectory_mse\"] for row in evaluation]))\n",
+ "print(json.dumps(summary, indent=2, ensure_ascii=False))\n",
+ "print(\"mean trajectory MSE:\", mean_mse)\n",
+ "\n",
+ "fig, ax = plt.subplots(figsize=(6, 3.5))\n",
+ "ax.plot([row[\"step\"] for row in history], [row[\"flow_loss\"] for row in history], \"o-\")\n",
+ "ax.set_yscale(\"log\")\n",
+ "ax.set_xlabel(\"training step\")\n",
+ "ax.set_ylabel(\"flow velocity MSE\")\n",
+ "ax.set_title(\"Overfit training curve\")\n",
+ "ax.grid(alpha=0.3)\n",
+ "plt.show()\n",
+ "display(NotebookImage(filename=str(plot_path)))"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-22",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "print(\"| sample | trajectory MSE |\")\n",
+ "print(\"|---|---:|\")\n",
+ "for row in evaluation:\n",
+ " print(f\"| {row['sample_id']} | {row['trajectory_mse']:.6f} |\")\n",
+ "print(f\"| mean | {mean_mse:.6f} |\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "pi0-23",
+ "metadata": {},
+ "source": [
+ "\n",
+ "## 11. Run All 验收\n",
+ "\n",
+ "最后一个单元检查数据、配置、checkpoint、训练历史、推理指标和轨迹图。全部通过即表示当前环境完成了最小复现闭环。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pi0-24",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert SOURCE_MANIFEST.is_file()\n",
+ "assert RUNTIME_CONFIG.is_file()\n",
+ "assert checkpoint_path.is_file()\n",
+ "assert plot_path.is_file()\n",
+ "assert (output_dir / \"training_history.json\").is_file()\n",
+ "assert (output_dir / \"evaluation.json\").is_file()\n",
+ "assert np.isfinite(mean_mse)\n",
+ "assert len(evaluation) == len(dataset)\n",
+ "print(\"✅ π₀ minimal reproduction Run All completed\")\n",
+ "print(\"profile:\", PROFILE)\n",
+ "print(\"data:\", selected_label)\n",
+ "print(\"checkpoint:\", checkpoint_path)\n",
+ "print(\"trajectory plot:\", plot_path)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.10"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/pyproject.toml b/pyproject.toml
index 20a1f04..26af438 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,11 @@ dependencies = [
[project.optional-dependencies]
dev = ["pytest>=8.0"]
+notebook = [
+ "ipykernel>=6.29",
+ "jupyterlab>=4.2",
+ "nbconvert>=7.16",
+]
[project.scripts]
pi0mini-train = "pi0mini.train:main"