389 lines
14 KiB
Python
389 lines
14 KiB
Python
"""Prompt and FAST-lite tokenization for the minimal pi0.7 policy.
|
|
|
|
pi0.7 conditions the low-level policy on a richer prompt than earlier pi
|
|
models: task and subtask language, episode speed/quality/mistake metadata,
|
|
and control mode. This tokenizer represents those concepts
|
|
with explicit marker/value pairs so the architecture can be validated without
|
|
shipping a full language tokenizer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass
|
|
from typing import Iterable, Literal
|
|
|
|
import torch
|
|
from torch import Tensor
|
|
|
|
|
|
ControlMode = Literal["joint", "ee"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EpisodeMetadata:
|
|
speed_steps: int | None = None
|
|
quality: int | None = None
|
|
mistake: bool | None = None
|
|
control_mode: ControlMode = "joint"
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.speed_steps is not None and self.speed_steps <= 0:
|
|
raise ValueError("speed_steps must be positive")
|
|
if self.quality is not None and not 1 <= self.quality <= 5:
|
|
raise ValueError("quality must be in the inclusive range 1..5")
|
|
if self.control_mode not in ("joint", "ee"):
|
|
raise ValueError("control_mode must be 'joint' or 'ee'")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PolicySequence:
|
|
input_ids: Tensor
|
|
labels: Tensor
|
|
action_prefix_mask: Tensor
|
|
generated_token_start: int
|
|
action_start_index: int
|
|
prompt_length: int
|
|
metadata_indices: tuple[int, ...]
|
|
|
|
|
|
class _LabelVocabulary:
|
|
def __init__(self, labels: tuple[str, ...], first_token_id: int) -> None:
|
|
self.labels = labels
|
|
self.first_token_id = first_token_id
|
|
self._to_token = {
|
|
label: first_token_id + index for index, label in enumerate(labels)
|
|
}
|
|
self._to_label = {token: label for label, token in self._to_token.items()}
|
|
|
|
@property
|
|
def token_ids(self) -> tuple[int, ...]:
|
|
return tuple(self._to_token[label] for label in self.labels)
|
|
|
|
def encode(self, label: str) -> int:
|
|
try:
|
|
return self._to_token[label]
|
|
except KeyError as error:
|
|
raise ValueError(f"unknown categorical label: {label}") from error
|
|
|
|
def decode(self, token: int | Tensor) -> str:
|
|
try:
|
|
return self._to_label[int(token)]
|
|
except KeyError as error:
|
|
raise ValueError(f"unknown categorical token: {int(token)}") from error
|
|
|
|
|
|
class MiniTokenizer:
|
|
"""Explicit rich-prompt tokens plus a DCT-based FAST-lite action stream."""
|
|
|
|
BOS = 256
|
|
TASK_MARKER = 257
|
|
SUBTASK_MARKER = 258
|
|
SPEED_MARKER = 259
|
|
QUALITY_MARKER = 260
|
|
MISTAKE_MARKER = 261
|
|
CONTROL_MARKER = 262
|
|
NEXT_SUBTASK_MARKER = 263
|
|
|
|
TASK_LABELS = (
|
|
"drive",
|
|
"pick_place",
|
|
"fold",
|
|
"open_close",
|
|
"clean",
|
|
"cook",
|
|
"unknown",
|
|
)
|
|
TASK_TOKEN_START = 264
|
|
TASK_TOKEN_END = TASK_TOKEN_START + len(TASK_LABELS) - 1
|
|
|
|
SUBTASK_LABELS = (
|
|
"keep_lane",
|
|
"steer_left",
|
|
"steer_right",
|
|
"slow_down",
|
|
"stop",
|
|
"accelerate",
|
|
"pick_place",
|
|
"fold",
|
|
"open_close",
|
|
"unknown",
|
|
)
|
|
SUBTASK_TOKEN_START = TASK_TOKEN_END + 1
|
|
SUBTASK_TOKEN_END = SUBTASK_TOKEN_START + len(SUBTASK_LABELS) - 1
|
|
|
|
SPEED_BINS = (500, 1000, 2000, 4000, 8000, 16000)
|
|
SPEED_TOKEN_START = SUBTASK_TOKEN_END + 1
|
|
SPEED_TOKEN_END = SPEED_TOKEN_START + len(SPEED_BINS) - 1
|
|
QUALITY_TOKEN_START = SPEED_TOKEN_END + 1
|
|
QUALITY_TOKEN_END = QUALITY_TOKEN_START + 4
|
|
MISTAKE_FALSE = QUALITY_TOKEN_END + 1
|
|
MISTAKE_TRUE = MISTAKE_FALSE + 1
|
|
CONTROL_JOINT = MISTAKE_TRUE + 1
|
|
CONTROL_EE = CONTROL_JOINT + 1
|
|
|
|
ACTION_START = CONTROL_EE + 1
|
|
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) -> None:
|
|
self.tasks = _LabelVocabulary(self.TASK_LABELS, self.TASK_TOKEN_START)
|
|
self.subtasks = _LabelVocabulary(self.SUBTASK_LABELS, self.SUBTASK_TOKEN_START)
|
|
|
|
@property
|
|
def vocab_size(self) -> int:
|
|
return self.ACTION_TOKEN_END + 1
|
|
|
|
@property
|
|
def action_token_ids(self) -> range:
|
|
return range(self.ACTION_TOKEN_START, self.ACTION_TOKEN_END + 1)
|
|
|
|
@staticmethod
|
|
def _classify_task(description: str) -> str:
|
|
text = description.casefold()
|
|
if any(word in text for word in ("drive", "lane", "road", "steer", "vehicle")):
|
|
return "drive"
|
|
if any(word in text for word in ("fold", "shirt", "towel", "laundry")):
|
|
return "fold"
|
|
if any(word in text for word in ("open", "close", "door", "drawer", "fridge")):
|
|
return "open_close"
|
|
if any(word in text for word in ("clean", "wipe", "wash", "tidy")):
|
|
return "clean"
|
|
if any(word in text for word in ("cook", "food", "recipe", "microwave")):
|
|
return "cook"
|
|
if any(word in text for word in ("pick", "place", "move", "put")):
|
|
return "pick_place"
|
|
return description if description in MiniTokenizer.TASK_LABELS else "unknown"
|
|
|
|
@staticmethod
|
|
def _classify_subtask(description: str) -> str:
|
|
if description in MiniTokenizer.SUBTASK_LABELS:
|
|
return description
|
|
text = description.casefold()
|
|
if any(word in text for word in ("stop", "halt", "stationary")):
|
|
return "stop"
|
|
if any(word in text for word in ("slow", "brake", "deceler")):
|
|
return "slow_down"
|
|
if any(word in text for word in ("accelerat", "speed up")):
|
|
return "accelerate"
|
|
if "left" in text:
|
|
return "steer_left"
|
|
if "right" in text:
|
|
return "steer_right"
|
|
if any(word in text for word in ("lane", "straight", "continue", "follow")):
|
|
return "keep_lane"
|
|
if "fold" in text:
|
|
return "fold"
|
|
if any(word in text for word in ("open", "close")):
|
|
return "open_close"
|
|
if any(word in text for word in ("pick", "place", "put")):
|
|
return "pick_place"
|
|
return "unknown"
|
|
|
|
def encode_prompt(
|
|
self,
|
|
task: str,
|
|
subtask: str | None,
|
|
metadata: EpisodeMetadata,
|
|
*,
|
|
include_episode_metadata: bool = True,
|
|
include_subtask: bool = True,
|
|
) -> tuple[Tensor, tuple[int, ...]]:
|
|
"""Encode prompt and return positions controlled by metadata CFG.
|
|
|
|
Control mode is deliberately retained even in the unconditional branch,
|
|
matching pi0.7 training where control-mode dropout is not applied.
|
|
"""
|
|
|
|
ids = [self.BOS, self.TASK_MARKER, self.tasks.encode(self._classify_task(task))]
|
|
if include_subtask and subtask is not None:
|
|
ids.extend(
|
|
(self.SUBTASK_MARKER, self.subtasks.encode(self._classify_subtask(subtask)))
|
|
)
|
|
|
|
metadata_indices: list[int] = []
|
|
|
|
def add_metadata(marker: int, value: int) -> None:
|
|
metadata_indices.extend((len(ids), len(ids) + 1))
|
|
ids.extend((marker, value))
|
|
|
|
if include_episode_metadata:
|
|
if metadata.speed_steps is not None:
|
|
bin_index = min(
|
|
range(len(self.SPEED_BINS)),
|
|
key=lambda index: abs(self.SPEED_BINS[index] - metadata.speed_steps),
|
|
)
|
|
add_metadata(self.SPEED_MARKER, self.SPEED_TOKEN_START + bin_index)
|
|
if metadata.quality is not None:
|
|
add_metadata(
|
|
self.QUALITY_MARKER, self.QUALITY_TOKEN_START + metadata.quality - 1
|
|
)
|
|
if metadata.mistake is not None:
|
|
add_metadata(
|
|
self.MISTAKE_MARKER,
|
|
self.MISTAKE_TRUE if metadata.mistake else self.MISTAKE_FALSE,
|
|
)
|
|
control = self.CONTROL_JOINT if metadata.control_mode == "joint" else self.CONTROL_EE
|
|
ids.extend((self.CONTROL_MARKER, control))
|
|
return torch.tensor(ids, dtype=torch.long), tuple(metadata_indices)
|
|
|
|
def encode_high_level_prompt(
|
|
self,
|
|
task: str,
|
|
past_subtasks: Iterable[str],
|
|
metadata: EpisodeMetadata,
|
|
) -> Tensor:
|
|
"""Encode task, past semantic decisions, metadata, and a next-subtask query."""
|
|
|
|
prompt, _ = self.encode_prompt(
|
|
task,
|
|
subtask=None,
|
|
metadata=metadata,
|
|
include_subtask=False,
|
|
)
|
|
control_pair = prompt[-2:]
|
|
body = prompt[:-2]
|
|
history: list[int] = []
|
|
for subtask in past_subtasks:
|
|
history.extend(
|
|
(self.SUBTASK_MARKER, self.subtasks.encode(self._classify_subtask(subtask)))
|
|
)
|
|
return torch.cat(
|
|
(
|
|
body,
|
|
torch.tensor(history, dtype=torch.long),
|
|
control_pair,
|
|
torch.tensor([self.NEXT_SUBTASK_MARKER], dtype=torch.long),
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _orthonormal_dct_matrix(
|
|
horizon: int, *, device: torch.device, dtype: torch.dtype
|
|
) -> Tensor:
|
|
if horizon <= 0:
|
|
raise ValueError("DCT horizon must be positive")
|
|
time = torch.arange(horizon, device=device, dtype=dtype)
|
|
frequency = torch.arange(horizon, device=device, dtype=dtype).unsqueeze(1)
|
|
matrix = torch.cos(math.pi / horizon * (time + 0.5) * frequency)
|
|
matrix[0] *= math.sqrt(1.0 / horizon)
|
|
if horizon > 1:
|
|
matrix[1:] *= math.sqrt(2.0 / horizon)
|
|
return matrix
|
|
|
|
def dct_actions(self, actions: Tensor | Iterable[Iterable[float]]) -> Tensor:
|
|
values = torch.as_tensor(actions, dtype=torch.float32)
|
|
if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0:
|
|
raise ValueError("actions must have non-empty shape [horizon, 2]")
|
|
transform = self._orthonormal_dct_matrix(
|
|
values.shape[0], device=values.device, dtype=values.dtype
|
|
)
|
|
return transform @ values
|
|
|
|
def idct_actions(self, coefficients: Tensor | Iterable[Iterable[float]]) -> Tensor:
|
|
values = torch.as_tensor(coefficients, dtype=torch.float32)
|
|
if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0:
|
|
raise ValueError("DCT coefficients must have non-empty shape [horizon, 2]")
|
|
transform = self._orthonormal_dct_matrix(
|
|
values.shape[0], device=values.device, dtype=values.dtype
|
|
)
|
|
return transform.transpose(0, 1) @ values
|
|
|
|
def encode_actions(self, actions: Tensor | Iterable[Iterable[float]]) -> Tensor:
|
|
values = torch.as_tensor(actions, dtype=torch.float32)
|
|
if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0:
|
|
raise ValueError("actions must have non-empty shape [horizon, 2]")
|
|
values = values.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM)
|
|
coefficients = self.dct_actions(values) / math.sqrt(values.shape[0])
|
|
coefficients = torch.where(
|
|
coefficients.abs() < 1e-6, torch.zeros_like(coefficients), coefficients
|
|
)
|
|
unit = (
|
|
coefficients.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM)
|
|
- self.ACTION_MINIMUM
|
|
) / (self.ACTION_MAXIMUM - self.ACTION_MINIMUM)
|
|
payload = torch.round(unit * (self.ACTION_BINS - 1)).long().reshape(-1)
|
|
payload = payload + self.ACTION_TOKEN_START
|
|
boundaries = torch.tensor(
|
|
(self.ACTION_START, self.ACTION_END), dtype=torch.long, device=payload.device
|
|
)
|
|
return torch.cat((boundaries[:1], payload, boundaries[1:]))
|
|
|
|
def decode_actions(self, tokens: Tensor | Iterable[int]) -> Tensor:
|
|
encoded = torch.as_tensor(tokens, dtype=torch.long)
|
|
if encoded.ndim != 1:
|
|
raise ValueError("action tokens must be a one-dimensional sequence")
|
|
if encoded.numel() < 4 or encoded[0] != self.ACTION_START or encoded[-1] != self.ACTION_END:
|
|
raise ValueError("action tokens must be enclosed by ACTION_START and ACTION_END")
|
|
payload = encoded[1:-1]
|
|
if payload.numel() % 2:
|
|
raise ValueError("action token payload must contain complete XY pairs")
|
|
if payload.min() < self.ACTION_TOKEN_START or payload.max() > self.ACTION_TOKEN_END:
|
|
raise ValueError("action token payload is outside the FAST-lite interval")
|
|
quantized = payload - self.ACTION_TOKEN_START
|
|
coefficients = (
|
|
quantized.float()
|
|
/ (self.ACTION_BINS - 1)
|
|
* (self.ACTION_MAXIMUM - self.ACTION_MINIMUM)
|
|
+ self.ACTION_MINIMUM
|
|
)
|
|
horizon = payload.numel() // 2
|
|
return self.idct_actions(coefficients.reshape(horizon, 2) * math.sqrt(horizon))
|
|
|
|
def build_policy_sequence(
|
|
self,
|
|
task: str,
|
|
subtask: str | None,
|
|
actions: Tensor,
|
|
metadata: EpisodeMetadata,
|
|
*,
|
|
include_episode_metadata: bool = True,
|
|
include_subtask: bool = True,
|
|
) -> PolicySequence:
|
|
prompt, metadata_indices = self.encode_prompt(
|
|
task,
|
|
subtask,
|
|
metadata,
|
|
include_episode_metadata=include_episode_metadata,
|
|
include_subtask=include_subtask,
|
|
)
|
|
action_tokens = self.encode_actions(actions).cpu()
|
|
input_ids = torch.cat((prompt, action_tokens))
|
|
action_start_index = prompt.numel()
|
|
labels = torch.full_like(input_ids, -100)
|
|
labels[action_start_index - 1 : -1] = input_ids[action_start_index:]
|
|
action_prefix_mask = torch.zeros_like(input_ids, dtype=torch.bool)
|
|
action_prefix_mask[:action_start_index] = True
|
|
return PolicySequence(
|
|
input_ids=input_ids,
|
|
labels=labels,
|
|
action_prefix_mask=action_prefix_mask,
|
|
generated_token_start=0,
|
|
action_start_index=action_start_index,
|
|
prompt_length=prompt.numel(),
|
|
metadata_indices=metadata_indices,
|
|
)
|
|
|
|
def build_metadata_cfg_pair(
|
|
self,
|
|
task: str,
|
|
subtask: str | None,
|
|
actions: Tensor,
|
|
metadata: EpisodeMetadata,
|
|
) -> tuple[PolicySequence, PolicySequence]:
|
|
"""Return conditioned and episode-metadata-dropped policy sequences."""
|
|
|
|
conditioned = self.build_policy_sequence(task, subtask, actions, metadata)
|
|
unconditional = self.build_policy_sequence(
|
|
task,
|
|
subtask,
|
|
actions,
|
|
metadata,
|
|
include_episode_metadata=False,
|
|
)
|
|
return conditioned, unconditional
|