356 lines
15 KiB
Python
356 lines
15 KiB
Python
r"""Tiny discrete state, subtask, advantage, and FAST-lite tokenization.
|
|
|
|
The layout mirrors the ordering used by :math:`\pi^*_{0.6}`: the model first
|
|
predicts a subtask, then receives an optional advantage indicator, and only
|
|
then predicts the discrete action representation. ``omitted`` is represented
|
|
by the physical absence of an indicator token, as required for unconditional
|
|
classifier-free guidance training.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass
|
|
from typing import Iterable, Literal
|
|
|
|
import torch
|
|
from torch import Tensor
|
|
|
|
|
|
Advantage = Literal["positive", "negative", "omitted"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CoordinateRange:
|
|
minimum: float
|
|
maximum: float
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.maximum <= self.minimum:
|
|
raise ValueError("coordinate maximum must be greater than minimum")
|
|
|
|
|
|
class CoordinateTokenizer:
|
|
"""Uniformly discretize XY state values into literal byte tokens 0..255."""
|
|
|
|
bins = 256
|
|
|
|
def __init__(
|
|
self,
|
|
x_range: tuple[float, float] = (-10.0, 10.0),
|
|
y_range: tuple[float, float] = (-5.0, 5.0),
|
|
) -> None:
|
|
self.ranges = (CoordinateRange(*x_range), CoordinateRange(*y_range))
|
|
|
|
def encode(self, coordinates: Tensor | Iterable[Iterable[float]]) -> Tensor:
|
|
values = torch.as_tensor(coordinates, dtype=torch.float32)
|
|
if values.shape[-1] != 2:
|
|
raise ValueError("coordinates must end in an XY dimension of size 2")
|
|
low = values.new_tensor([axis.minimum for axis in self.ranges])
|
|
high = values.new_tensor([axis.maximum for axis in self.ranges])
|
|
unit = ((values - low) / (high - low)).clamp(0.0, 1.0)
|
|
return torch.round(unit * (self.bins - 1)).long()
|
|
|
|
def decode(self, tokens: Tensor | Iterable[Iterable[int]]) -> Tensor:
|
|
encoded = torch.as_tensor(tokens, dtype=torch.long)
|
|
if encoded.shape[-1] != 2:
|
|
raise ValueError("coordinate tokens must end in an XY dimension of size 2")
|
|
if encoded.numel() and (encoded.min() < 0 or encoded.max() >= self.bins):
|
|
raise ValueError("coordinate tokens must be in the inclusive range 0..255")
|
|
low = torch.tensor([axis.minimum for axis in self.ranges])
|
|
high = torch.tensor([axis.maximum for axis in self.ranges])
|
|
return low + encoded.float() / (self.bins - 1) * (high - low)
|
|
|
|
|
|
class SubtaskVocabulary:
|
|
labels = (
|
|
"keep_lane",
|
|
"steer_left",
|
|
"steer_right",
|
|
"slow_down",
|
|
"stop",
|
|
"accelerate",
|
|
"unknown",
|
|
)
|
|
|
|
def __init__(self, first_token_id: int = 260) -> None:
|
|
self.first_token_id = int(first_token_id)
|
|
self._label_to_token = {
|
|
label: self.first_token_id + index for index, label in enumerate(self.labels)
|
|
}
|
|
self._token_to_label = {token: label for label, token in self._label_to_token.items()}
|
|
|
|
@property
|
|
def token_ids(self) -> tuple[int, ...]:
|
|
return tuple(self._label_to_token[label] for label in self.labels)
|
|
|
|
def classify(self, reasoning: str) -> str:
|
|
text = reasoning.casefold()
|
|
if any(term in text for term in ("stop", "halt", "stationary")):
|
|
return "stop"
|
|
if any(term in text for term in ("slow", "deceler", "reduce speed", "brake")):
|
|
return "slow_down"
|
|
if any(term in text for term in ("accelerat", "speed up")):
|
|
return "accelerate"
|
|
if any(term in text for term in ("left", "counterclockwise")):
|
|
return "steer_left"
|
|
if any(term in text for term in ("right", "clockwise")):
|
|
return "steer_right"
|
|
if any(term in text for term in ("lane", "straight", "follow", "continue", "proceed")):
|
|
return "keep_lane"
|
|
return "unknown"
|
|
|
|
def encode(self, label_or_reasoning: str) -> int:
|
|
label = (
|
|
label_or_reasoning
|
|
if label_or_reasoning in self._label_to_token
|
|
else self.classify(label_or_reasoning)
|
|
)
|
|
return self._label_to_token[label]
|
|
|
|
def decode(self, token_id: int | Tensor) -> str:
|
|
try:
|
|
return self._token_to_label[int(token_id)]
|
|
except KeyError as error:
|
|
raise ValueError(f"unknown subtask token: {int(token_id)}") from error
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PolicySequence:
|
|
"""One policy token sequence plus masks needed by ``Pi06Mini``.
|
|
|
|
``labels`` is already shifted for next-token prediction. The advantage
|
|
token itself is never a prediction target because it is externally
|
|
supplied conditioning. ``action_prefix_mask`` allows the continuous
|
|
expert to read observation, predicted subtask, and advantage (if present),
|
|
while insulating it from teacher-forced FAST-lite tokens.
|
|
"""
|
|
|
|
input_ids: Tensor
|
|
labels: Tensor
|
|
generated_token_start: int
|
|
action_prefix_mask: Tensor
|
|
subtask_index: int
|
|
advantage_index: int | None
|
|
action_start_index: int
|
|
|
|
|
|
class MiniTokenizer:
|
|
r"""Compose the minimal :math:`\pi^*_{0.6}` policy vocabulary."""
|
|
|
|
BOS = 256
|
|
IMAGE = 257
|
|
STATE = 258
|
|
SUBTASK_QUERY = 259
|
|
ADVANTAGE_POSITIVE = SUBTASK_QUERY + 1 + len(SubtaskVocabulary.labels)
|
|
ADVANTAGE_NEGATIVE = ADVANTAGE_POSITIVE + 1
|
|
ACTION_START = ADVANTAGE_NEGATIVE + 1
|
|
ACTION_END = ACTION_START + 1
|
|
ACTION_TOKEN_START = ACTION_END + 1
|
|
ACTION_BINS = 64
|
|
ACTION_TOKEN_END = ACTION_TOKEN_START + ACTION_BINS - 1
|
|
METADATA_DEMO = ACTION_TOKEN_END + 1
|
|
METADATA_UNDERSTEER = METADATA_DEMO + 1
|
|
METADATA_STOP = METADATA_DEMO + 2
|
|
METADATA_INTERVENTION = METADATA_DEMO + 3
|
|
ACTION_MINIMUM = -4.0
|
|
ACTION_MAXIMUM = 4.0
|
|
|
|
def __init__(
|
|
self,
|
|
coordinate_tokenizer: CoordinateTokenizer | None = None,
|
|
subtasks: SubtaskVocabulary | None = None,
|
|
) -> None:
|
|
self.coordinates = coordinate_tokenizer or CoordinateTokenizer()
|
|
self.subtasks = subtasks or SubtaskVocabulary(first_token_id=self.SUBTASK_QUERY + 1)
|
|
|
|
@property
|
|
def vocab_size(self) -> int:
|
|
return self.METADATA_INTERVENTION + 1
|
|
|
|
def encode_metadata(self, source_type: str | None) -> Tensor | None:
|
|
if source_type is None:
|
|
return None
|
|
if source_type == "demo_success": return torch.tensor(self.METADATA_DEMO)
|
|
if "understeer" in source_type: return torch.tensor(self.METADATA_UNDERSTEER)
|
|
if "stop" in source_type: return torch.tensor(self.METADATA_STOP)
|
|
if "intervention" in source_type: return torch.tensor(self.METADATA_INTERVENTION)
|
|
raise ValueError(f"unknown metadata source_type: {source_type}")
|
|
|
|
@property
|
|
def action_token_ids(self) -> range:
|
|
return range(self.ACTION_TOKEN_START, self.ACTION_TOKEN_END + 1)
|
|
|
|
def build_observation(self, history_xy: Tensor, metadata: str | None = None) -> Tensor:
|
|
coordinate_tokens = self.coordinates.encode(history_xy).reshape(-1).cpu()
|
|
specials = torch.tensor([self.BOS, self.IMAGE, self.STATE], dtype=torch.long)
|
|
query = torch.tensor([self.SUBTASK_QUERY], dtype=torch.long)
|
|
parts = [specials, coordinate_tokens]
|
|
metadata_token = self.encode_metadata(metadata)
|
|
if metadata_token is not None: parts.append(metadata_token.reshape(1))
|
|
parts.append(query)
|
|
return torch.cat(parts)
|
|
|
|
def build_value_input(self, history_xy: Tensor) -> Tensor:
|
|
"""Return observation tokens for the critic without outcome metadata.
|
|
|
|
RECAP's value function estimates ``V(o, task)``. Experience provenance
|
|
and success/failure labels are training targets, not observable inputs;
|
|
including them would let the critic solve the toy buffer by leakage.
|
|
The single-task minimal experiment keeps the task command implicit.
|
|
"""
|
|
|
|
coordinate_tokens = self.coordinates.encode(history_xy).reshape(-1).cpu()
|
|
specials = torch.tensor([self.BOS, self.IMAGE, self.STATE], dtype=torch.long)
|
|
return torch.cat((specials, coordinate_tokens))
|
|
|
|
# Backward-compatible name used by the pi0.5 data utilities.
|
|
build_prefix = build_observation
|
|
|
|
def encode_subtask(self, reasoning: str) -> Tensor:
|
|
return torch.tensor(self.subtasks.encode(reasoning), dtype=torch.long)
|
|
|
|
def encode_advantage(self, advantage: Advantage | bool | None) -> Tensor | None:
|
|
if advantage is True or advantage == "positive":
|
|
return torch.tensor(self.ADVANTAGE_POSITIVE, dtype=torch.long)
|
|
if advantage is False or advantage == "negative":
|
|
return torch.tensor(self.ADVANTAGE_NEGATIVE, dtype=torch.long)
|
|
if advantage is None or advantage == "omitted":
|
|
return None
|
|
raise ValueError("advantage must be positive, negative, omitted, bool, or None")
|
|
|
|
def build_subtask_query(self, history_xy: Tensor) -> Tensor:
|
|
"""Observation prefix whose final token predicts the next subtask."""
|
|
|
|
return self.build_observation(history_xy, metadata=None)
|
|
|
|
def build_action_condition(
|
|
self,
|
|
history_xy: Tensor,
|
|
subtask_token: int | Tensor,
|
|
advantage: Advantage | bool | None,
|
|
) -> Tensor:
|
|
"""Build the inference prefix ``observation -> subtask -> advantage``."""
|
|
|
|
pieces = [
|
|
self.build_observation(history_xy, metadata=None),
|
|
torch.as_tensor(subtask_token, dtype=torch.long).reshape(1).cpu(),
|
|
]
|
|
advantage_token = self.encode_advantage(advantage)
|
|
if advantage_token is not None:
|
|
pieces.append(advantage_token.reshape(1))
|
|
return torch.cat(pieces)
|
|
|
|
@staticmethod
|
|
def _orthonormal_dct_matrix(
|
|
horizon: int, *, device: torch.device, dtype: torch.dtype
|
|
) -> Tensor:
|
|
if horizon <= 0:
|
|
raise ValueError("DCT horizon must be positive")
|
|
time = torch.arange(horizon, device=device, dtype=dtype)
|
|
frequency = torch.arange(horizon, device=device, dtype=dtype).unsqueeze(1)
|
|
matrix = torch.cos(math.pi / horizon * (time + 0.5) * frequency)
|
|
matrix[0] *= math.sqrt(1.0 / horizon)
|
|
if horizon > 1:
|
|
matrix[1:] *= math.sqrt(2.0 / horizon)
|
|
return matrix
|
|
|
|
def dct_actions(self, actions: Tensor | Iterable[Iterable[float]]) -> Tensor:
|
|
values = torch.as_tensor(actions, dtype=torch.float32)
|
|
if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0:
|
|
raise ValueError("actions must have non-empty shape [horizon, 2]")
|
|
transform = self._orthonormal_dct_matrix(
|
|
values.shape[0], device=values.device, dtype=values.dtype
|
|
)
|
|
return transform @ values
|
|
|
|
def idct_actions(self, coefficients: Tensor | Iterable[Iterable[float]]) -> Tensor:
|
|
values = torch.as_tensor(coefficients, dtype=torch.float32)
|
|
if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0:
|
|
raise ValueError("DCT coefficients must have non-empty shape [horizon, 2]")
|
|
transform = self._orthonormal_dct_matrix(
|
|
values.shape[0], device=values.device, dtype=values.dtype
|
|
)
|
|
return transform.transpose(0, 1) @ values
|
|
|
|
def encode_actions(self, actions: Tensor | Iterable[Iterable[float]]) -> Tensor:
|
|
values = torch.as_tensor(actions, dtype=torch.float32)
|
|
if values.ndim != 2 or values.shape[-1] != 2 or values.shape[0] == 0:
|
|
raise ValueError("actions must have non-empty shape [horizon, 2]")
|
|
values = values.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM)
|
|
coefficients = self.dct_actions(values) / math.sqrt(values.shape[0])
|
|
coefficients = torch.where(
|
|
coefficients.abs() < 1e-6, torch.zeros_like(coefficients), coefficients
|
|
)
|
|
unit = (
|
|
coefficients.clamp(self.ACTION_MINIMUM, self.ACTION_MAXIMUM)
|
|
- self.ACTION_MINIMUM
|
|
) / (self.ACTION_MAXIMUM - self.ACTION_MINIMUM)
|
|
payload = torch.round(unit * (self.ACTION_BINS - 1)).long().reshape(-1)
|
|
payload = payload + self.ACTION_TOKEN_START
|
|
boundaries = torch.tensor(
|
|
[self.ACTION_START, self.ACTION_END], dtype=torch.long, device=payload.device
|
|
)
|
|
return torch.cat((boundaries[:1], payload, boundaries[1:]))
|
|
|
|
def decode_actions(self, tokens: Tensor | Iterable[int]) -> Tensor:
|
|
encoded = torch.as_tensor(tokens, dtype=torch.long)
|
|
if encoded.ndim != 1:
|
|
raise ValueError("action tokens must be a one-dimensional sequence")
|
|
if encoded.numel() < 4 or encoded[0] != self.ACTION_START or encoded[-1] != self.ACTION_END:
|
|
raise ValueError("action tokens must be enclosed by ACTION_START and ACTION_END")
|
|
payload = encoded[1:-1]
|
|
if payload.numel() % 2:
|
|
raise ValueError("action token payload must contain complete XY pairs")
|
|
if payload.min() < self.ACTION_TOKEN_START or payload.max() > self.ACTION_TOKEN_END:
|
|
raise ValueError("action token payload is outside the reserved action token interval")
|
|
quantized = payload - self.ACTION_TOKEN_START
|
|
coefficients = (
|
|
quantized.float() / (self.ACTION_BINS - 1)
|
|
* (self.ACTION_MAXIMUM - self.ACTION_MINIMUM)
|
|
+ self.ACTION_MINIMUM
|
|
)
|
|
horizon = payload.numel() // 2
|
|
return self.idct_actions(coefficients.reshape(horizon, 2) * math.sqrt(horizon))
|
|
|
|
def build_policy_sequence(
|
|
self,
|
|
history_xy: Tensor,
|
|
subtask: str,
|
|
actions: Tensor,
|
|
advantage: Advantage | bool | None,
|
|
metadata: str | None = None,
|
|
) -> PolicySequence:
|
|
observation = self.build_observation(history_xy, metadata)
|
|
subtask_token = self.encode_subtask(subtask).reshape(1)
|
|
advantage_token = self.encode_advantage(advantage)
|
|
action_tokens = self.encode_actions(actions).cpu()
|
|
pieces = [observation, subtask_token]
|
|
if advantage_token is not None:
|
|
pieces.append(advantage_token.reshape(1))
|
|
pieces.append(action_tokens)
|
|
input_ids = torch.cat(pieces)
|
|
|
|
subtask_index = observation.numel()
|
|
advantage_index = subtask_index + 1 if advantage_token is not None else None
|
|
action_start_index = subtask_index + 1 + int(advantage_token is not None)
|
|
|
|
labels = torch.full_like(input_ids, -100)
|
|
# SUBTASK_QUERY predicts the subtask. The external advantage indicator
|
|
# is deliberately skipped as a target. Advantage/subtask then predicts
|
|
# ACTION_START and the remaining FAST-lite stream autoregressively.
|
|
labels[subtask_index - 1] = input_ids[subtask_index]
|
|
labels[action_start_index - 1 : -1] = input_ids[action_start_index:]
|
|
|
|
action_prefix_mask = torch.zeros_like(input_ids, dtype=torch.bool)
|
|
action_prefix_mask[:action_start_index] = True
|
|
return PolicySequence(
|
|
input_ids=input_ids,
|
|
labels=labels,
|
|
generated_token_start=subtask_index,
|
|
action_prefix_mask=action_prefix_mask,
|
|
subtask_index=subtask_index,
|
|
advantage_index=advantage_index,
|
|
action_start_index=action_start_index,
|
|
)
|