Initial pi0.5 minimal validation

This commit is contained in:
seeker
2026-07-11 20:04:18 +08:00
commit ffbcca8d2b
20 changed files with 2354 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""Minimal pi0.5 architecture validation package."""
from .model import Pi05Config, Pi05Mini, Pi05Output, flow_matching_sample
__all__ = ["Pi05Config", "Pi05Mini", "Pi05Output", "flow_matching_sample"]
+316
View File
@@ -0,0 +1,316 @@
from __future__ import annotations
import json
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image, ImageEnhance
from torch.utils.data import Dataset
from .tokenizer import MiniTokenizer
@dataclass(frozen=True)
class SampleRecord:
clip_id: str
t0_us: int
reasoning: str
image_path: Path
trajectory_path: Path
@property
def sample_id(self) -> str:
return f"{self.clip_id}_{self.t0_us}"
@dataclass(frozen=True)
class ActionStatistics:
mean: torch.Tensor
scale: torch.Tensor
def as_dict(self) -> dict[str, list[float]]:
return {
"mean": [float(value) for value in self.mean],
"scale": [float(value) for value in self.scale],
}
class ActionNormalizer:
"""Normalize 2-D waypoint deltas and retain invertible dataset statistics."""
def __init__(self, mean: torch.Tensor, scale: torch.Tensor) -> None:
self.mean = torch.as_tensor(mean, dtype=torch.float32).reshape(2)
self.scale = torch.as_tensor(scale, dtype=torch.float32).reshape(2).clamp_min(0.05)
@classmethod
def fit(cls, histories: Sequence[torch.Tensor], futures: Sequence[torch.Tensor]) -> ActionNormalizer:
if not histories or len(histories) != len(futures):
raise ValueError("histories and futures must be non-empty sequences of equal length")
chunks = []
for history, future in zip(histories, futures, strict=True):
chunks.append(cls.to_deltas(history, future).reshape(-1, 2))
flat = torch.cat(chunks, dim=0).float()
return cls(flat.mean(dim=0), flat.std(dim=0, unbiased=False))
@classmethod
def from_dict(cls, values: dict[str, Sequence[float]]) -> ActionNormalizer:
return cls(torch.tensor(values["mean"]), torch.tensor(values["scale"]))
@property
def statistics(self) -> ActionStatistics:
return ActionStatistics(self.mean.clone(), self.scale.clone())
@staticmethod
def to_deltas(history_xy: torch.Tensor, future_xy: torch.Tensor) -> torch.Tensor:
previous = torch.cat((history_xy[-1:].float(), future_xy[:-1].float()), dim=0)
return future_xy.float() - previous
def encode(self, history_xy: torch.Tensor, future_xy: torch.Tensor) -> torch.Tensor:
return (self.to_deltas(history_xy, future_xy) - self.mean) / self.scale
def decode(self, actions: torch.Tensor, history_xy: torch.Tensor) -> torch.Tensor:
deltas = actions.float() * self.scale + self.mean
return history_xy[-1:].float() + torch.cumsum(deltas, dim=0)
def as_dict(self) -> dict[str, list[float]]:
return self.statistics.as_dict()
def save(self, path: str | Path) -> Path:
output = Path(path).expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(self.as_dict(), indent=2) + "\n", encoding="utf-8")
return output
def _resolve_data_path(manifest_path: Path, raw_path: str | Path) -> Path:
path = Path(raw_path).expanduser()
if path.is_absolute():
return path
candidates = [manifest_path.parent / path]
if len(manifest_path.parents) >= 3:
candidates.insert(0, manifest_path.parents[2] / path)
for candidate in candidates:
if candidate.exists():
return candidate.resolve()
return candidates[0].resolve()
def load_manifest(path: str | Path) -> list[SampleRecord]:
manifest_path = Path(path).expanduser().resolve()
records: list[SampleRecord] = []
for line in manifest_path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
row = json.loads(line)
records.append(
SampleRecord(
clip_id=str(row["clip_id"]),
t0_us=int(row["t0_us"]),
reasoning=str(row.get("reasoning", "")),
image_path=_resolve_data_path(manifest_path, row["image_path"]),
trajectory_path=_resolve_data_path(manifest_path, row["trajectory_path"]),
)
)
if not records:
raise ValueError(f"no samples found in {manifest_path}")
return records
def materialize_subset(
source_manifest: str | Path,
output_dir: str | Path,
max_samples: int = 3,
num_variants: int | None = None,
) -> Path:
"""Copy a self-contained PAI overfit subset out of the Alpamayo project."""
# ``num_variants`` is accepted as a compatibility alias used by the
# training entry point. Actual variants stay virtual and deterministic in
# Pi05OverfitDataset, avoiding redundant copies of the large JPEG.
if num_variants is not None:
if num_variants < 1:
raise ValueError("num_variants must be at least one")
max_samples = min(max_samples, num_variants)
if max_samples < 1:
raise ValueError("max_samples must be at least one")
records = load_manifest(source_manifest)[:max_samples]
destination = Path(output_dir).expanduser().resolve()
image_dir = destination / "images"
trajectory_dir = destination / "trajectories"
image_dir.mkdir(parents=True, exist_ok=True)
trajectory_dir.mkdir(parents=True, exist_ok=True)
rows: list[dict[str, Any]] = []
for index, record in enumerate(records):
stem = f"sample_{index:02d}_{record.sample_id}"
image_path = image_dir / f"{stem}{record.image_path.suffix.lower()}"
trajectory_path = trajectory_dir / f"{stem}.npz"
shutil.copy2(record.image_path, image_path)
shutil.copy2(record.trajectory_path, trajectory_path)
rows.append(
{
"clip_id": record.clip_id,
"t0_us": record.t0_us,
"reasoning": record.reasoning,
"image_path": str(image_path.relative_to(destination)),
"trajectory_path": str(trajectory_path.relative_to(destination)),
"history_steps": 8,
"expert_waypoints": 16,
}
)
manifest_path = destination / "overfit_manifest.jsonl"
manifest_path.write_text(
"".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
encoding="utf-8",
)
return manifest_path
def _resample_waypoints(xy: torch.Tensor, steps: int = 16) -> torch.Tensor:
if xy.shape[0] == steps:
return xy.float()
if xy.shape[0] < 2:
raise ValueError("future trajectory must contain at least two waypoints")
signal = xy.float().T.unsqueeze(0)
return F.interpolate(signal, size=steps, mode="linear", align_corners=True)[0].T
class Pi05OverfitDataset(Dataset[dict[str, Any]]):
"""Tiny deterministic PAI dataset for validating a pi0.5-style training loop."""
def __init__(
self,
manifest_path: str | Path,
*,
image_size: int | tuple[int, int] = (128, 224),
augmentation_factor: int = 4,
action_horizon: int = 16,
seed: int = 7,
tokenizer: MiniTokenizer | None = None,
action_statistics: ActionStatistics | dict[str, Sequence[float]] | None = None,
) -> None:
if augmentation_factor < 1:
raise ValueError("augmentation_factor must be at least one")
if isinstance(image_size, int):
image_size = (image_size, image_size)
if len(image_size) != 2 or min(image_size) < 1:
raise ValueError("image_size must be a positive (height, width) pair")
if action_horizon < 2:
raise ValueError("action_horizon must be at least two")
self.records = load_manifest(manifest_path)
self.image_size = (int(image_size[0]), int(image_size[1]))
self.augmentation_factor = int(augmentation_factor)
self.action_horizon = int(action_horizon)
self.seed = int(seed)
self.tokenizer = tokenizer or MiniTokenizer()
self.state_dim = 16
if action_statistics is None:
geometries = [
self._load_augmented_geometry(base_index, variant)
for base_index in range(len(self.records))
for variant in range(self.augmentation_factor)
]
histories = [geometry[0] for geometry in geometries]
futures = [geometry[1] for geometry in geometries]
self.action_normalizer = ActionNormalizer.fit(histories, futures)
elif isinstance(action_statistics, ActionStatistics):
self.action_normalizer = ActionNormalizer(
action_statistics.mean, action_statistics.scale
)
else:
self.action_normalizer = ActionNormalizer.from_dict(action_statistics)
@property
def action_statistics(self) -> ActionStatistics:
return self.action_normalizer.statistics
@property
def action_mean(self) -> torch.Tensor:
return self.action_normalizer.mean.clone()
@property
def action_std(self) -> torch.Tensor:
return self.action_normalizer.scale.clone()
def save_action_statistics(self, path: str | Path) -> Path:
return self.action_normalizer.save(path)
def __len__(self) -> int:
return len(self.records) * self.augmentation_factor
def _indices(self, index: int) -> tuple[int, int]:
if index < 0:
index += len(self)
if index < 0 or index >= len(self):
raise IndexError(index)
return divmod(index, self.augmentation_factor)
def _rng(self, base_index: int, variant: int) -> np.random.Generator:
return np.random.default_rng(
np.random.SeedSequence([self.seed, base_index, variant])
)
def _load_augmented_geometry(
self, base_index: int, variant: int
) -> tuple[torch.Tensor, torch.Tensor]:
record = self.records[base_index]
with np.load(record.trajectory_path) as arrays:
history = torch.from_numpy(np.asarray(arrays["history_xyz"], dtype=np.float32))[:, :2]
future = torch.from_numpy(np.asarray(arrays["future_xyz"], dtype=np.float32))[:, :2]
future = _resample_waypoints(future, steps=self.action_horizon)
if variant == 0:
return history, future
rng = self._rng(base_index, variant)
angle = float(rng.normal(0.0, 0.008))
scale = float(rng.normal(1.0, 0.006))
rotation = history.new_tensor(
[[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]]
)
history = scale * (history @ rotation.T)
future = scale * (future @ rotation.T)
# A tiny smooth lateral perturbation adds distinct trajectories while retaining the label.
curvature = float(rng.normal(0.0, 0.012))
progress = torch.linspace(0.0, 1.0, future.shape[0], dtype=future.dtype)
future[:, 1] += curvature * progress.square()
return history, future
def _load_image(self, base_index: int, variant: int) -> torch.Tensor:
with Image.open(self.records[base_index].image_path) as source:
image = source.convert("RGB")
if variant:
rng = self._rng(base_index, variant)
brightness = float(rng.uniform(0.97, 1.03))
image = ImageEnhance.Brightness(image).enhance(brightness)
height, width = self.image_size
image = image.resize((width, height), resample=Image.Resampling.BILINEAR)
array = np.asarray(image, dtype=np.float32).copy() / 255.0
return torch.from_numpy(array).permute(2, 0, 1).contiguous()
def __getitem__(self, index: int) -> dict[str, Any]:
base_index, variant = self._indices(index)
record = self.records[base_index]
history_xy, future_xy = self._load_augmented_geometry(base_index, variant)
reasoning = record.reasoning
target = self.tokenizer.encode_subtask(reasoning)
actions = self.action_normalizer.encode(history_xy, future_xy)
return {
"image": self._load_image(base_index, variant),
"prefix_tokens": self.tokenizer.build_prefix(history_xy),
"subtask_target": target,
"subtask_label": self.tokenizer.subtasks.decode(target),
"actions": actions,
"action_tokens": self.tokenizer.encode_actions(actions),
"state": history_xy.reshape(-1),
"history_xy": history_xy,
"future_xy": future_xy,
"reasoning": reasoning,
"sample_id": f"{record.sample_id}:aug{variant}",
}
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from collections.abc import Callable
import torch
from torch import Tensor
class FlowMatching:
"""Rectified-flow objective used by the minimal action expert."""
def __init__(self, inference_steps: int = 10, cutoff: float = 0.999) -> None:
self.inference_steps = inference_steps
self.cutoff = cutoff
def training_batch(
self, target: Tensor, generator: torch.Generator | None = None
) -> tuple[Tensor, Tensor, Tensor]:
batch = target.shape[0]
uniform = torch.rand(
batch, device=target.device, dtype=target.dtype, generator=generator
)
# Paper sampling: z~Beta(1.5,1), tau=s*(1-z), emphasizing noisy/low tau.
timestep = self.cutoff * (1.0 - uniform.pow(1.0 / 1.5))
noise = torch.randn(
target.shape,
device=target.device,
dtype=target.dtype,
generator=generator,
)
t = timestep[:, None, None]
noisy = t * target + (1.0 - t) * noise
return noisy, timestep, target - noise
@staticmethod
def loss(prediction: Tensor, target_velocity: Tensor) -> Tensor:
return torch.nn.functional.mse_loss(prediction.float(), target_velocity.float())
@torch.no_grad()
def sample(
self,
model_fn: Callable[[Tensor, Tensor], Tensor],
shape: tuple[int, ...],
device: torch.device | str,
steps: int | None = None,
generator: torch.Generator | None = None,
) -> Tensor:
count = steps or self.inference_steps
x = torch.randn(shape, device=device, generator=generator)
grid = torch.linspace(0.0, 1.0, count + 1, device=device)
for index in range(count):
timestep = grid[index].expand(shape[0])
x = x + (grid[index + 1] - grid[index]) * model_fn(x, timestep)
return x
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import argparse
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import torch
from .data import Pi05OverfitDataset
from .flow import FlowMatching
from .model import Pi05Config, Pi05Mini
from .runtime import choose_device, write_json
@torch.no_grad()
def evaluate(checkpoint_path: str, manifest: str | None = None) -> Path:
checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
cfg = checkpoint["config"]
root = Path(__file__).resolve().parents[2]
device = choose_device(cfg.get("device", "auto"))
data_manifest = Path(manifest) if manifest else root / "data/overfit/overfit_manifest.jsonl"
dataset = Pi05OverfitDataset(
data_manifest, image_size=cfg["data"]["image_size"],
action_horizon=cfg["data"]["action_horizon"],
action_statistics=checkpoint["action_statistics"]
)
model = Pi05Mini(Pi05Config(**checkpoint["model_config"]))
model.load_state_dict(checkpoint["model"])
model.to(device).eval()
flow = FlowMatching(cfg["inference"]["flow_steps"])
rows = []
figure, axes = plt.subplots(1, len(dataset), figsize=(4 * len(dataset), 4), squeeze=False)
for index, sample in enumerate(dataset):
image = sample["image"][None].to(device)
tokens = sample["prefix_tokens"][None].to(device)
# Autoregressively predict semantic subtask followed by the complete
# discretized action stream (ACTION_START, 32 scalar tokens, ACTION_END).
zero_action = torch.zeros(1, dataset.action_horizon, 2, device=device)
generated: list[int] = []
expected_tokens = 1 + 2 + dataset.action_horizon * 2
current_tokens = tokens
for _ in range(expected_tokens):
semantic = model(
image, current_tokens, zero_action,
torch.zeros(1, device=device),
generated_token_start=tokens.shape[1]
)
next_token = int(semantic.text_logits[0, -1].argmax().cpu())
generated.append(next_token)
current_tokens = torch.cat(
(current_tokens, torch.tensor([[next_token]], device=device)), dim=1
)
predicted_token = generated[0]
vlm_action_tokens = torch.tensor(generated[1:], dtype=torch.long)
try:
vlm_normalized = dataset.tokenizer.decode_actions(vlm_action_tokens)
vlm_xy = dataset.action_normalizer.decode(
vlm_normalized, sample["history_xy"]
).numpy()
vlm_valid = True
except ValueError:
vlm_xy = np.full_like(sample["future_xy"].numpy(), np.nan)
vlm_valid = False
conditioned_tokens = torch.cat(
(tokens, torch.tensor([[predicted_token]], device=device)), dim=1
)
def velocity(x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
return model(
image, conditioned_tokens, x, t,
generated_token_start=tokens.shape[1]
).vector_field
normalized = flow.sample(
velocity, (1, dataset.action_horizon, 2), device,
steps=cfg["inference"]["flow_steps"],
generator=None,
)[0].cpu()
predicted_xy = dataset.action_normalizer.decode(
normalized, sample["history_xy"]
).numpy()
target_xy = sample["future_xy"].numpy()
diffusion_mse = float(np.mean((predicted_xy - target_xy) ** 2))
vlm_mse = float(np.mean((vlm_xy - target_xy) ** 2)) if vlm_valid else None
target_label = dataset.tokenizer.subtasks.decode(sample["subtask_target"])
try:
predicted_label = dataset.tokenizer.subtasks.decode(predicted_token)
except ValueError:
predicted_label = f"token_{predicted_token}"
rows.append({
"sample_id": sample["sample_id"], "target_subtask": target_label,
"predicted_subtask": predicted_label,
"vlm_action_tokens_valid": vlm_valid,
"vlm_trajectory_mse": vlm_mse,
"diffusion_trajectory_mse": diffusion_mse,
})
ax = axes[0, index]
ax.plot(target_xy[:, 0], target_xy[:, 1], "o-", label="target")
ax.plot(vlm_xy[:, 0], vlm_xy[:, 1], "s--", label="VLM tokens")
ax.plot(predicted_xy[:, 0], predicted_xy[:, 1], "x--", label="diffusion")
ax.scatter([0], [0], c="black", marker="s", label="ego")
vlm_text = "invalid" if vlm_mse is None else f"{vlm_mse:.3f}"
ax.set_title(
f"{predicted_label}\nVLM={vlm_text}, diffusion={diffusion_mse:.3f}"
)
ax.set_xlabel("forward x (m)")
ax.set_ylabel("left y (m)")
ax.axis("equal")
ax.grid(True, alpha=.3)
ax.legend(fontsize=8)
figure.tight_layout()
output_dir = Path(checkpoint_path).parent
plot_path = output_dir / "trajectory_comparison.png"
figure.savefig(plot_path, dpi=160)
plt.close(figure)
write_json(output_dir / "evaluation.json", rows)
print(rows)
return plot_path
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--checkpoint", default="outputs/overfit/checkpoint.pt")
parser.add_argument("--manifest")
args = parser.parse_args()
evaluate(args.checkpoint, args.manifest)
if __name__ == "__main__":
main()
+396
View File
@@ -0,0 +1,396 @@
"""A compact, pure-PyTorch approximation of the pi0.5 dual-expert model.
The important architectural detail is that prefix and action tokens have
different parameters at every Transformer layer. Prefix queries only attend
to prefix keys, while action queries attend to the complete prefix and to all
action tokens. This is deliberately implemented explicitly (instead of
concatenating the streams into an ordinary Transformer) so the information
flow is easy to inspect in a minimal reproduction.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Optional
import torch
from torch import Tensor, nn
import torch.nn.functional as F
@dataclass
class Pi05Config:
vocab_size: int = 512
action_dim: int = 2
image_channels: int = 3
image_size: int = 64
patch_size: int = 16
d_model: int = 128
n_heads: int = 4
n_layers: int = 4
mlp_ratio: int = 4
max_text_tokens: int = 64
max_action_tokens: int = 32
dropout: float = 0.0
rms_eps: float = 1e-6
def __post_init__(self) -> None:
if self.d_model % self.n_heads:
raise ValueError("d_model must be divisible by n_heads")
if self.image_size % self.patch_size:
raise ValueError("image_size must be divisible by patch_size")
@property
def max_image_tokens(self) -> int:
return (self.image_size // self.patch_size) ** 2
@property
def max_prefix_tokens(self) -> int:
return self.max_image_tokens + self.max_text_tokens
@dataclass
class Pi05Output:
text_logits: Tensor
vector_field: Tensor
prefix_hidden: Tensor
action_hidden: Tensor
text_token_offset: int
loss: Optional[Tensor] = None
text_loss: Optional[Tensor] = None
flow_loss: Optional[Tensor] = None
class RMSNorm(nn.Module):
def __init__(self, dim: int, eps: float = 1e-6) -> None:
super().__init__()
self.weight = nn.Parameter(torch.ones(dim))
self.eps = eps
def forward(self, x: Tensor) -> Tensor:
# Accumulating the variance in fp32 avoids overflows under autocast.
rms = x.float().pow(2).mean(dim=-1, keepdim=True)
normalized = x * torch.rsqrt(rms.to(x.dtype) + self.eps)
return normalized * self.weight
class AdaRMSNorm(nn.Module):
"""RMSNorm modulated by a flow-time embedding."""
def __init__(self, dim: int, cond_dim: int, eps: float = 1e-6) -> None:
super().__init__()
self.norm = RMSNorm(dim, eps)
self.modulation = nn.Linear(cond_dim, 2 * dim)
# Start as an ordinary RMSNorm, which makes tiny-data training stable.
nn.init.zeros_(self.modulation.weight)
nn.init.zeros_(self.modulation.bias)
def forward(self, x: Tensor, condition: Tensor) -> Tensor:
shift, scale = self.modulation(condition).chunk(2, dim=-1)
return self.norm(x) * (1 + scale[:, None, :]) + shift[:, None, :]
class TimeEmbedding(nn.Module):
def __init__(self, dim: int) -> None:
super().__init__()
fourier_dim = max(8, dim // 2)
if fourier_dim % 2:
fourier_dim += 1
self.fourier_dim = fourier_dim
self.mlp = nn.Sequential(
nn.Linear(fourier_dim, dim), nn.SiLU(), nn.Linear(dim, dim)
)
def forward(self, timestep: Tensor) -> Tensor:
timestep = timestep.reshape(-1).float()
half = self.fourier_dim // 2
frequencies = torch.exp(
torch.linspace(math.log(1.0), math.log(1000.0), half,
device=timestep.device, dtype=timestep.dtype)
)
angles = 2 * math.pi * timestep[:, None] * frequencies[None, :]
embedding = torch.cat((angles.sin(), angles.cos()), dim=-1)
return self.mlp(embedding)
class AttentionWeights(nn.Module):
"""One expert's independent Q/K/V/output projections."""
def __init__(self, dim: int) -> None:
super().__init__()
self.q = nn.Linear(dim, dim, bias=False)
self.k = nn.Linear(dim, dim, bias=False)
self.v = nn.Linear(dim, dim, bias=False)
self.out = nn.Linear(dim, dim, bias=False)
class MLP(nn.Module):
def __init__(self, dim: int, ratio: int, dropout: float) -> None:
super().__init__()
hidden = dim * ratio
self.net = nn.Sequential(
nn.Linear(dim, hidden),
nn.GELU(approximate="tanh"),
nn.Dropout(dropout),
nn.Linear(hidden, dim),
nn.Dropout(dropout),
)
def forward(self, x: Tensor) -> Tensor:
return self.net(x)
def _attention(
query: Tensor,
key: Tensor,
value: Tensor,
n_heads: int,
allowed: Optional[Tensor],
dropout: float,
training: bool,
) -> Tensor:
batch, q_len, width = query.shape
k_len = key.shape[1]
head_dim = width // n_heads
def split(x: Tensor) -> Tensor:
return x.reshape(batch, -1, n_heads, head_dim).transpose(1, 2)
q, k, v = split(query), split(key), split(value)
scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(head_dim)
if allowed is not None:
# allowed is [B or 1, Q, K]; True means the edge exists.
scores = scores.masked_fill(~allowed[:, None, :, :], torch.finfo(scores.dtype).min)
probabilities = scores.float().softmax(dim=-1).to(scores.dtype)
probabilities = F.dropout(probabilities, p=dropout, training=training)
output = torch.matmul(probabilities, v)
return output.transpose(1, 2).contiguous().reshape(batch, q_len, width)
class DualExpertBlock(nn.Module):
def __init__(self, config: Pi05Config) -> None:
super().__init__()
d = config.d_model
self.n_heads = config.n_heads
self.dropout = config.dropout
# The two streams never share Transformer parameters.
self.vlm_norm1 = RMSNorm(d, config.rms_eps)
self.vlm_attention = AttentionWeights(d)
self.vlm_norm2 = RMSNorm(d, config.rms_eps)
self.vlm_mlp = MLP(d, config.mlp_ratio, config.dropout)
self.action_norm1 = AdaRMSNorm(d, d, config.rms_eps)
self.action_attention = AttentionWeights(d)
self.action_norm2 = AdaRMSNorm(d, d, config.rms_eps)
self.action_mlp = MLP(d, config.mlp_ratio, config.dropout)
def forward(
self,
prefix: Tensor,
action: Tensor,
time_condition: Tensor,
prefix_edges: Tensor,
action_edges: Tensor,
) -> tuple[Tensor, Tensor]:
prefix_input = self.vlm_norm1(prefix)
p = self.vlm_attention
prefix_update = _attention(
p.q(prefix_input), p.k(prefix_input), p.v(prefix_input),
self.n_heads, prefix_edges, self.dropout, self.training
)
prefix = prefix + p.out(prefix_update)
prefix = prefix + self.vlm_mlp(self.vlm_norm2(prefix))
action_input = self.action_norm1(action, time_condition)
a = self.action_attention
# Prefix KV uses the VLM expert's parameters; action KV uses the action
# expert's parameters. Thus action can read prefix without allowing a
# reverse information path from action into the prefix stream.
key = torch.cat((p.k(prefix_input), a.k(action_input)), dim=1)
value = torch.cat((p.v(prefix_input), a.v(action_input)), dim=1)
action_update = _attention(
a.q(action_input), key, value, self.n_heads, action_edges,
self.dropout, self.training
)
action = action + a.out(action_update)
action = action + self.action_mlp(self.action_norm2(action, time_condition))
return prefix, action
class Pi05Mini(nn.Module):
"""Minimal pi0.5-style model for architecture validation/overfitting."""
def __init__(self, config: Optional[Pi05Config] = None) -> None:
super().__init__()
self.config = config or Pi05Config()
c = self.config
self.patch_encoder = nn.Conv2d(
c.image_channels, c.d_model, c.patch_size, stride=c.patch_size
)
self.token_embedding = nn.Embedding(c.vocab_size, c.d_model)
self.action_projection = nn.Linear(c.action_dim, c.d_model)
self.prefix_position = nn.Parameter(
torch.randn(1, c.max_prefix_tokens, c.d_model) * 0.01
)
self.action_position = nn.Parameter(
torch.randn(1, c.max_action_tokens, c.d_model) * 0.01
)
self.time_embedding = TimeEmbedding(c.d_model)
self.layers = nn.ModuleList(DualExpertBlock(c) for _ in range(c.n_layers))
self.prefix_norm = RMSNorm(c.d_model, c.rms_eps)
self.action_norm = AdaRMSNorm(c.d_model, c.d_model, c.rms_eps)
self.text_head = nn.Linear(c.d_model, c.vocab_size, bias=False)
self.vector_field_head = nn.Linear(c.d_model, c.action_dim)
self.text_head.weight = self.token_embedding.weight
def encode_prefix(self, pixel_values: Tensor, input_ids: Tensor) -> tuple[Tensor, int]:
patches = self.patch_encoder(pixel_values).flatten(2).transpose(1, 2)
text_tokens = self.token_embedding(input_ids)
# pi0.5 represents state in the VLM vocabulary. There is deliberately
# no separate continuous-state projection in this model.
prefix = torch.cat((patches, text_tokens), dim=1)
if prefix.shape[1] > self.config.max_prefix_tokens:
raise ValueError("prefix exceeds configured maximum length")
prefix = prefix + self.prefix_position[:, : prefix.shape[1]]
return prefix, patches.shape[1]
def _masks(
self,
batch: int,
prefix_length: int,
action_length: int,
text_offset: int,
attention_mask: Optional[Tensor],
action_prefix_mask: Optional[Tensor],
generated_token_start: Optional[int],
device: torch.device,
) -> tuple[Tensor, Tensor]:
valid = torch.ones(batch, prefix_length, dtype=torch.bool, device=device)
if attention_mask is not None:
valid[:, text_offset:] = attention_mask.bool()
# Image plus observation/instruction/state tokens form a fully
# bidirectional observation block. Tokens generated after that block
# (subtask and FAST actions) read the observation and earlier generated
# tokens causally; observation queries cannot read generated tokens.
generated_start = prefix_length if generated_token_start is None else text_offset + generated_token_start
if not text_offset <= generated_start <= prefix_length:
raise ValueError("generated_token_start must index input_ids")
q = torch.arange(prefix_length, device=device)[:, None]
k = torch.arange(prefix_length, device=device)[None, :]
structural = torch.where(q < generated_start, k < generated_start, k <= q)
prefix_edges = structural[None] & valid[:, None, :]
# Padded prefix queries retain a harmless self edge to avoid all -inf
# rows; their outputs are not used by the text loss.
query_valid = valid[:, :, None]
identity = torch.eye(prefix_length, dtype=torch.bool, device=device)[None]
prefix_edges = torch.where(query_valid, prefix_edges, identity)
action_valid = valid
if action_prefix_mask is not None:
supplied = action_prefix_mask.bool()
if supplied.shape[1] == prefix_length - text_offset:
supplied = torch.cat(
(torch.ones(batch, text_offset, dtype=torch.bool, device=device), supplied),
dim=1,
)
if supplied.shape != valid.shape:
raise ValueError("action_prefix_mask must cover text tokens or the full prefix")
action_valid = action_valid & supplied
prefix_keys = action_valid[:, None, :].expand(batch, action_length, prefix_length)
action_keys = torch.ones(
batch, action_length, action_length, dtype=torch.bool, device=device
)
action_edges = torch.cat((prefix_keys, action_keys), dim=-1)
return prefix_edges, action_edges
def forward(
self,
pixel_values: Tensor,
input_ids: Tensor,
noisy_actions: Tensor,
timesteps: Tensor,
attention_mask: Optional[Tensor] = None,
action_prefix_mask: Optional[Tensor] = None,
generated_token_start: Optional[int] = None,
text_labels: Optional[Tensor] = None,
target_velocity: Optional[Tensor] = None,
text_loss_weight: float = 1.0,
flow_loss_weight: float = 1.0,
) -> Pi05Output:
"""Run both experts.
``timesteps`` is a scalar per batch item in [0, 1]. ``text_labels``
corresponds only to ``input_ids`` (not image prefix positions)
and may contain -100. ``target_velocity`` has the same shape as
``noisy_actions``.
"""
if noisy_actions.ndim != 3:
raise ValueError("noisy_actions must have shape [batch, horizon, action_dim]")
if noisy_actions.shape[1] > self.config.max_action_tokens:
raise ValueError("action horizon exceeds configured maximum")
prefix, text_offset = self.encode_prefix(pixel_values, input_ids)
horizon = noisy_actions.shape[1]
action = self.action_projection(noisy_actions)
action = action + self.action_position[:, :horizon]
condition = self.time_embedding(timesteps).to(action.dtype)
prefix_edges, action_edges = self._masks(
prefix.shape[0], prefix.shape[1], horizon, text_offset,
attention_mask, action_prefix_mask, generated_token_start, prefix.device
)
for layer in self.layers:
prefix, action = layer(
prefix, action, condition, prefix_edges, action_edges
)
prefix_hidden = self.prefix_norm(prefix)
action_hidden = self.action_norm(action, condition)
# Return logits only for actual text positions, which keeps downstream
# next-token loss/generation code uncomplicated.
text_logits = self.text_head(prefix_hidden[:, text_offset:])
vector_field = self.vector_field_head(action_hidden)
text_loss = None
if text_labels is not None:
text_loss = F.cross_entropy(
text_logits.reshape(-1, text_logits.shape[-1]),
text_labels.reshape(-1), ignore_index=-100
)
flow_loss = None
if target_velocity is not None:
flow_loss = F.mse_loss(vector_field, target_velocity)
loss = None
if text_loss is not None or flow_loss is not None:
loss = sum(
component for component in (
None if text_loss is None else text_loss_weight * text_loss,
None if flow_loss is None else flow_loss_weight * flow_loss,
) if component is not None
)
return Pi05Output(
text_logits=text_logits,
vector_field=vector_field,
prefix_hidden=prefix_hidden,
action_hidden=action_hidden,
text_token_offset=text_offset,
loss=loss,
text_loss=text_loss,
flow_loss=flow_loss,
)
def flow_matching_sample(actions: Tensor) -> tuple[Tensor, Tensor, Tensor]:
"""Construct pi-style conditional-flow training targets.
The path is x_t = (1-t) * noise + t * action, with constant target vector
field ``action - noise``.
"""
batch = actions.shape[0]
timestep = torch.rand(batch, device=actions.device, dtype=actions.dtype)
noise = torch.randn_like(actions)
broadcast_t = timestep.reshape(batch, *([1] * (actions.ndim - 1)))
noisy_actions = (1 - broadcast_t) * noise + broadcast_t * actions
target_velocity = actions - noise
return noisy_actions, timestep, target_velocity
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import json
import random
from pathlib import Path
import numpy as np
import torch
import yaml
def load_config(path: str | Path) -> dict:
with Path(path).open("r", encoding="utf-8") as handle:
return yaml.safe_load(handle)
def seed_everything(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
def choose_device(requested: str = "auto") -> torch.device:
if requested != "auto":
return torch.device(requested)
if torch.cuda.is_available():
return torch.device("cuda")
if torch.backends.mps.is_available():
return torch.device("mps")
return torch.device("cpu")
def write_json(path: str | Path, value: object) -> None:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(value, indent=2, ensure_ascii=False), encoding="utf-8")
+285
View File
@@ -0,0 +1,285 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Iterable
import torch
@dataclass(frozen=True)
class CoordinateRange:
minimum: float
maximum: float
def __post_init__(self) -> None:
if self.maximum <= self.minimum:
raise ValueError("coordinate maximum must be greater than minimum")
class CoordinateTokenizer:
"""Uniformly discretize XY state values into the literal token range 0..255."""
bins = 256
def __init__(
self,
x_range: tuple[float, float] = (-10.0, 10.0),
y_range: tuple[float, float] = (-5.0, 5.0),
) -> None:
self.ranges = (
CoordinateRange(*x_range),
CoordinateRange(*y_range),
)
def encode(self, coordinates: torch.Tensor | Iterable[Iterable[float]]) -> torch.Tensor:
values = torch.as_tensor(coordinates, dtype=torch.float32)
if values.shape[-1] != 2:
raise ValueError("coordinates must end in an XY dimension of size 2")
low = values.new_tensor([axis.minimum for axis in self.ranges])
high = values.new_tensor([axis.maximum for axis in self.ranges])
unit = ((values - low) / (high - low)).clamp(0.0, 1.0)
return torch.round(unit * (self.bins - 1)).to(torch.long)
def decode(self, tokens: torch.Tensor | Iterable[Iterable[int]]) -> torch.Tensor:
encoded = torch.as_tensor(tokens, dtype=torch.long)
if encoded.shape[-1] != 2:
raise ValueError("coordinate tokens must end in an XY dimension of size 2")
if encoded.numel() and (encoded.min() < 0 or encoded.max() >= self.bins):
raise ValueError("coordinate tokens must be in the inclusive range 0..255")
low = torch.tensor([axis.minimum for axis in self.ranges], dtype=torch.float32)
high = torch.tensor([axis.maximum for axis in self.ranges], dtype=torch.float32)
return low + encoded.float() / (self.bins - 1) * (high - low)
class SubtaskVocabulary:
"""Small semantic vocabulary standing in for pi0.5's generated language subtask."""
labels = (
"keep_lane",
"steer_left",
"steer_right",
"slow_down",
"stop",
"accelerate",
"unknown",
)
def __init__(self, first_token_id: int = 260) -> None:
self.first_token_id = int(first_token_id)
self._label_to_token = {
label: self.first_token_id + index for index, label in enumerate(self.labels)
}
self._token_to_label = {token: label for label, token in self._label_to_token.items()}
@property
def size(self) -> int:
return len(self.labels)
@property
def token_ids(self) -> tuple[int, ...]:
return tuple(self._label_to_token[label] for label in self.labels)
def classify(self, reasoning: str) -> str:
text = reasoning.casefold()
# More specific commands precede generic lane/follow language.
if any(term in text for term in ("stop", "halt", "stationary")):
return "stop"
if any(term in text for term in ("slow", "deceler", "reduce speed", "brake")):
return "slow_down"
if any(term in text for term in ("accelerat", "speed up")):
return "accelerate"
if any(term in text for term in ("left", "counterclockwise")):
return "steer_left"
if any(term in text for term in ("right", "clockwise")):
return "steer_right"
if any(term in text for term in ("lane", "straight", "follow", "continue", "proceed")):
return "keep_lane"
return "unknown"
def encode(self, label_or_reasoning: str) -> int:
label = (
label_or_reasoning
if label_or_reasoning in self._label_to_token
else self.classify(label_or_reasoning)
)
return self._label_to_token[label]
def decode(self, token_id: int | torch.Tensor) -> str:
token = int(token_id)
try:
return self._token_to_label[token]
except KeyError as error:
raise ValueError(f"unknown subtask token: {token}") from error
class MiniTokenizer:
"""Compose image/state, semantic, and FAST-lite action tokens.
Actions are z-score-normalized by the dataset before reaching this
tokenizer. A complete ``[horizon, 2]`` chunk is transformed along time
with an orthonormal DCT-II. All coefficients are retained and serialized
in frequency-major XY order, so low-frequency motion appears first in the
token stream. The coefficients are divided by ``sqrt(horizon)`` before
being uniformly quantized over ``[-4, 4]`` into 64 shared tokens. This
keeps the fixed quantization interval valid for any horizon when the input
actions have first been clipped to the same interval.
This intentionally implements only the central transform/order idea from
FAST. It does not learn dataset-specific coefficient bins or drop trailing
high-frequency coefficients.
"""
BOS = 256
IMAGE = 257
STATE = 258
SUBTASK_QUERY = 259
ACTION_START = SUBTASK_QUERY + 1 + len(SubtaskVocabulary.labels)
ACTION_END = ACTION_START + 1
ACTION_TOKEN_START = ACTION_END + 1
ACTION_BINS = 64
ACTION_TOKEN_END = ACTION_TOKEN_START + ACTION_BINS - 1
ACTION_MINIMUM = -4.0
ACTION_MAXIMUM = 4.0
def __init__(
self,
coordinate_tokenizer: CoordinateTokenizer | None = None,
subtasks: SubtaskVocabulary | None = None,
) -> None:
self.coordinates = coordinate_tokenizer or CoordinateTokenizer()
self.subtasks = subtasks or SubtaskVocabulary(first_token_id=self.SUBTASK_QUERY + 1)
@property
def vocab_size(self) -> int:
return max(max(self.subtasks.token_ids), self.ACTION_TOKEN_END) + 1
@property
def action_token_ids(self) -> range:
"""Contiguous vocabulary interval reserved for quantized DCT coefficients."""
return range(self.ACTION_TOKEN_START, self.ACTION_TOKEN_END + 1)
def build_prefix(self, history_xy: torch.Tensor) -> torch.Tensor:
coordinate_tokens = self.coordinates.encode(history_xy).reshape(-1)
specials = torch.tensor([self.BOS, self.IMAGE, self.STATE], dtype=torch.long)
query = torch.tensor([self.SUBTASK_QUERY], dtype=torch.long)
return torch.cat((specials, coordinate_tokens.cpu(), query), dim=0)
def encode_subtask(self, reasoning: str) -> torch.Tensor:
return torch.tensor(self.subtasks.encode(reasoning), dtype=torch.long)
@staticmethod
def _orthonormal_dct_matrix(
horizon: int,
*,
device: torch.device,
dtype: torch.dtype,
) -> torch.Tensor:
"""Return the orthonormal DCT-II matrix ``[frequency, time]``."""
if horizon <= 0:
raise ValueError("DCT horizon must be positive")
time = torch.arange(horizon, device=device, dtype=dtype)
frequency = torch.arange(horizon, device=device, dtype=dtype).unsqueeze(1)
matrix = torch.cos(math.pi / horizon * (time + 0.5) * frequency)
matrix[0] *= math.sqrt(1.0 / horizon)
if horizon > 1:
matrix[1:] *= math.sqrt(2.0 / horizon)
return matrix
def dct_actions(
self, actions: torch.Tensor | Iterable[Iterable[float]]
) -> torch.Tensor:
"""Apply an orthonormal DCT-II along an action chunk's time axis."""
values = torch.as_tensor(actions, dtype=torch.float32)
if values.ndim != 2 or values.shape[-1] != 2:
raise ValueError("actions must have shape [horizon, 2]")
if values.shape[0] == 0:
raise ValueError("actions must contain at least one timestep")
transform = self._orthonormal_dct_matrix(
values.shape[0], device=values.device, dtype=values.dtype
)
return transform @ values
def idct_actions(
self, coefficients: torch.Tensor | Iterable[Iterable[float]]
) -> torch.Tensor:
"""Invert :meth:`dct_actions` using the transpose of its basis."""
values = torch.as_tensor(coefficients, dtype=torch.float32)
if values.ndim != 2 or values.shape[-1] != 2:
raise ValueError("DCT coefficients must have shape [horizon, 2]")
if values.shape[0] == 0:
raise ValueError("DCT coefficients must contain at least one frequency")
transform = self._orthonormal_dct_matrix(
values.shape[0], device=values.device, dtype=values.dtype
)
return transform.transpose(0, 1) @ values
def encode_actions(
self, actions: torch.Tensor | Iterable[Iterable[float]]
) -> torch.Tensor:
"""DCT-transform and encode normalized actions as a marked token stream."""
values = torch.as_tensor(actions, dtype=torch.float32)
if values.ndim != 2 or values.shape[-1] != 2:
raise ValueError("actions must have shape [horizon, 2]")
if values.shape[0] == 0:
raise ValueError("actions must contain at least one timestep")
values = values.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM)
coefficients = self.dct_actions(values) / math.sqrt(values.shape[0])
# Exact zero is useful for a stable shared center bin. Tiny non-zero
# values here are only floating-point residue from the DCT basis.
coefficients = torch.where(
coefficients.abs() < 1e-6,
torch.zeros_like(coefficients),
coefficients,
)
unit = (
(
coefficients.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM)
- self.ACTION_MINIMUM
)
/ (self.ACTION_MAXIMUM - self.ACTION_MINIMUM)
)
quantized = torch.round(unit * (self.ACTION_BINS - 1)).to(torch.long)
# Row-major flattening is frequency-major: DC XY, frequency-1 XY, ...
action_tokens = quantized.reshape(-1) + self.ACTION_TOKEN_START
boundaries = torch.tensor(
[self.ACTION_START, self.ACTION_END],
dtype=torch.long,
device=action_tokens.device,
)
return torch.cat((boundaries[:1], action_tokens, boundaries[1:]))
def decode_actions(self, tokens: torch.Tensor | Iterable[int]) -> torch.Tensor:
"""Decode FAST-lite tokens back to normalized ``[horizon, 2]`` actions."""
encoded = torch.as_tensor(tokens, dtype=torch.long)
if encoded.ndim != 1:
raise ValueError("action tokens must be a one-dimensional sequence")
if (
encoded.numel() < 4
or encoded[0] != self.ACTION_START
or encoded[-1] != self.ACTION_END
):
raise ValueError("action tokens must be enclosed by ACTION_START and ACTION_END")
payload = encoded[1:-1]
if payload.numel() % 2:
raise ValueError("action token payload must contain complete XY pairs")
if payload.min() < self.ACTION_TOKEN_START or payload.max() > self.ACTION_TOKEN_END:
raise ValueError(
"action token payload is outside the reserved action token interval"
)
quantized = payload - self.ACTION_TOKEN_START
normalized_coefficients = (
quantized.float()
/ (self.ACTION_BINS - 1)
* (self.ACTION_MAXIMUM - self.ACTION_MINIMUM)
+ self.ACTION_MINIMUM
)
horizon = payload.numel() // 2
coefficients = normalized_coefficients.reshape(horizon, 2) * math.sqrt(horizon)
return self.idct_actions(coefficients)
+193
View File
@@ -0,0 +1,193 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
import torch
from torch.utils.data import DataLoader
from .data import Pi05OverfitDataset, materialize_subset
from .flow import FlowMatching
from .model import Pi05Config, Pi05Mini
from .runtime import choose_device, load_config, seed_everything, write_json
def _collate(rows: list[dict]) -> dict:
tensor_keys = (
"image", "prefix_tokens", "subtask_target", "actions", "action_tokens"
)
result = {key: torch.stack([row[key] for row in rows]) for key in tensor_keys}
result["sample_id"] = [row["sample_id"] for row in rows]
return result
def build_model(config: dict, dataset: Pi05OverfitDataset) -> Pi05Mini:
model_cfg = config["model"]
return Pi05Mini(Pi05Config(
vocab_size=dataset.tokenizer.vocab_size,
action_dim=2,
image_size=config["data"]["image_size"],
patch_size=model_cfg["patch_size"],
d_model=model_cfg["d_model"],
n_heads=model_cfg["n_heads"],
n_layers=model_cfg["n_layers"],
mlp_ratio=model_cfg["mlp_ratio"],
max_text_tokens=model_cfg["max_text_tokens"],
max_action_tokens=config["data"]["action_horizon"],
dropout=model_cfg["dropout"],
))
def train(config_path: str) -> Path:
cfg = load_config(config_path)
root = Path(__file__).resolve().parents[2]
seed_everything(cfg["seed"])
device = choose_device(cfg.get("device", "auto"))
manifest = materialize_subset(
source_manifest=cfg["data"]["source_manifest"],
output_dir=root / "data" / "overfit",
num_variants=cfg["data"]["num_variants"],
)
dataset = Pi05OverfitDataset(
manifest, image_size=cfg["data"]["image_size"],
action_horizon=cfg["data"]["action_horizon"]
)
loader = DataLoader(
dataset, batch_size=min(cfg["train"]["batch_size"], len(dataset)),
shuffle=True, collate_fn=_collate
)
model = build_model(cfg, dataset).to(device)
flow = FlowMatching(cfg["inference"]["flow_steps"])
output_dir = root / cfg["output_dir"]
output_dir.mkdir(parents=True, exist_ok=True)
history = []
def is_action_parameter(name: str) -> bool:
return any(part in name for part in (
"action_", "time_embedding", "vector_field_head"
))
# Stage 1 follows pi0.5 pre-training: train the VLM/token pathway with
# discrete state and FAST-lite actions, while the flow expert is untouched.
stage1_params = [p for name, p in model.named_parameters() if not is_action_parameter(name)]
optimizer = torch.optim.AdamW(
stage1_params, lr=cfg["train"]["learning_rate"],
weight_decay=cfg["train"]["weight_decay"]
)
def next_batch(iterator):
try:
return next(iterator), iterator
except StopIteration:
iterator = iter(loader)
return next(iterator), iterator
iterator = iter(loader)
model.train()
for step in range(1, cfg["train"]["pretrain_steps"] + 1):
batch, iterator = next_batch(iterator)
image = batch["image"].to(device)
tokens = batch["prefix_tokens"].to(device)
actions = batch["actions"].to(device)
subtask = batch["subtask_target"].to(device)[:, None]
action_tokens = batch["action_tokens"].to(device)
targets = torch.cat((subtask, action_tokens), dim=1)
# Teacher-forced autoregressive stream. The final target is predicted
# from the preceding token and therefore is not part of model_tokens.
model_tokens = torch.cat((tokens, targets[:, :-1]), dim=1)
model_labels = torch.full_like(model_tokens, -100)
query_index = tokens.shape[1] - 1
model_labels[:, query_index:] = targets
# Flow tokens may read observation/state and the generated semantic
# subtask, but not teacher-forced discrete action tokens.
zero_action = torch.zeros_like(actions)
output = model(
image, model_tokens, zero_action,
torch.zeros(image.shape[0], device=device),
generated_token_start=tokens.shape[1],
text_labels=model_labels,
)
optimizer.zero_grad(set_to_none=True)
output.loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), cfg["train"]["grad_clip_norm"])
optimizer.step()
if step == 1 or step % cfg["train"]["log_every"] == 0:
record = {
"stage": "pretrain",
"step": step,
"loss": float(output.loss.detach().cpu()),
"text_loss": float(output.text_loss.detach().cpu()),
"flow_loss": None,
}
history.append(record)
print(json.dumps(record))
torch.save(model.state_dict(), output_dir / "stage1_vlm.pt")
# Stage 2 follows post-training: add the still-random action expert and
# jointly retain token prediction while optimizing continuous flow.
optimizer = torch.optim.AdamW(
model.parameters(), lr=cfg["train"]["learning_rate"],
weight_decay=cfg["train"]["weight_decay"]
)
iterator = iter(loader)
for step in range(1, cfg["train"]["posttrain_steps"] + 1):
batch, iterator = next_batch(iterator)
image = batch["image"].to(device)
tokens = batch["prefix_tokens"].to(device)
actions = batch["actions"].to(device)
subtask = batch["subtask_target"].to(device)[:, None]
action_tokens = batch["action_tokens"].to(device)
targets = torch.cat((subtask, action_tokens), dim=1)
model_tokens = torch.cat((tokens, targets[:, :-1]), dim=1)
model_labels = torch.full_like(model_tokens, -100)
model_labels[:, tokens.shape[1] - 1:] = targets
action_prefix_mask = torch.zeros_like(model_tokens, dtype=torch.bool)
action_prefix_mask[:, : tokens.shape[1] + 1] = True
noisy, timestep, velocity = flow.training_batch(actions)
output = model(
image, model_tokens, noisy, timestep,
action_prefix_mask=action_prefix_mask,
generated_token_start=tokens.shape[1],
text_labels=model_labels, target_velocity=velocity,
text_loss_weight=cfg["train"]["semantic_loss_weight"],
flow_loss_weight=cfg["train"]["flow_loss_weight"],
)
optimizer.zero_grad(set_to_none=True)
output.loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), cfg["train"]["grad_clip_norm"])
optimizer.step()
if step == 1 or step % cfg["train"]["log_every"] == 0:
record = {
"stage": "posttrain", "step": step,
"loss": float(output.loss.detach().cpu()),
"text_loss": float(output.text_loss.detach().cpu()),
"flow_loss": float(output.flow_loss.detach().cpu()),
}
history.append(record)
print(json.dumps(record))
checkpoint = {
"model": model.state_dict(), "config": cfg,
"model_config": model.config.__dict__,
"action_statistics": dataset.action_normalizer.as_dict(),
}
torch.save(checkpoint, output_dir / "checkpoint.pt")
write_json(output_dir / "training_history.json", history)
write_json(output_dir / "summary.json", {
"device": str(device), "samples": len(dataset),
"parameters": sum(p.numel() for p in model.parameters()),
"initial": history[0], "final": history[-1],
})
return output_dir / "checkpoint.pt"
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--config", default="configs/overfit.yaml")
args = parser.parse_args()
train(args.config)
if __name__ == "__main__":
main()