ZibinDong's picture
Publish audited ActionCodec2 release files
ce32ca3 verified
Raw History Blame Contribute Delete
27.1 kB
"""Action-space-bound ActionCodec2Sparse frontend for independent action chunks."""
from __future__ import annotations
import json
import sys
import warnings
from collections.abc import Mapping
from pathlib import Path
from typing import Any, TextIO
import numpy as np
import torch
from transformers.feature_extraction_utils import BatchFeature
from ...backends.actioncodec2 import ActionCodec2Config, PresetConfig
from ...backends.actioncodec2.defaults import builtin_action_spaces
from ...backends.actioncodec2.hf import (
GENERATED_CARD_MARKER,
PROCESSOR_CONFIG_NAME,
resolve_pretrained_directory,
)
from ...backends.actioncodec2.preprocessing import PreprocessingSpec
from ...backends.actioncodec2.release import MANIFEST_NAME
from ...exporting import save_runtime
from ...backends.actioncodec2.semantic import resampled_horizon
from ...backends.actioncodec2.sparse import (
ActionCodec2SparseProcessor,
ActionCodec2SparseRouter,
SparseLayout,
SparseProfileTrainer,
SparseVocabulary,
sparse_profile_config,
)
from ...backends.actioncodec2.sparse.routing import SPARSE_ROUTER_CONFIG_NAME, save_router_metadata
from ...backends.actioncodec2.training import build_primitives
from ..conversion import (
_numpy,
_numpy_token_rows,
restore_source_layout,
)
from ..base_action_tokenizer import BaseActionTokenizer
from ..layout import ActionSpaceMixin
from ..training import TrainingMixin
from .loading import describe_release, load_release_router
ALLOW_PATTERNS = [
SPARSE_ROUTER_CONFIG_NAME,
PROCESSOR_CONFIG_NAME,
"config.json",
MANIFEST_NAME,
"profiles/**",
"joint/**",
"eef/**",
"fit_report.json",
"README.md",
]
_ASSETS = Path(__file__).absolute().parents[2] / "backends" / "actioncodec2" / "assets"
def _profile_config(raw) -> ActionCodec2Config:
if isinstance(raw, ActionCodec2Config):
return raw
if isinstance(raw, Mapping):
return ActionCodec2Config.from_dict(raw)
return ActionCodec2Config.from_yaml(raw)
def build_fresh_router(
profile_configs=None, primitive_order: int = 1, vocab_size: int = 4096
) -> ActionCodec2SparseRouter:
"""Unfitted joint/EEF profiles; defaults derive from the packaged profiles of that order."""
if type(primitive_order) is not int or primitive_order not in (1, 2):
raise ValueError("primitive_order must be 1 or 2")
if type(vocab_size) is not int or vocab_size < 1:
raise ValueError("vocab_size must be a positive integer")
if profile_configs is not None and vocab_size != 4096:
raise ValueError("set vocabulary budgets in profile_configs when providing custom profiles")
configs = dict(profile_configs or {})
if configs.keys() - {"joint", "eef"}:
raise ValueError("profile_configs keys must be 'joint' or 'eef'")
suffix = "" if primitive_order == 1 else "_second_order"
processors, budgets = {}, {}
for name in ("joint", "eef"):
if name in configs:
config = _profile_config(configs[name])
else:
default = ActionCodec2Config.from_yaml(_ASSETS / "profiles" / f"{name}{suffix}.yaml")
config = sparse_profile_config(default, vocab_size=vocab_size)
if config.train is None or config.train.sparse is None:
raise ValueError(f"{name} profile config requires train.sparse")
primitives = build_primitives(config)
layout = SparseLayout.from_config(config, primitives)
vocabulary = SparseVocabulary(layout, (), (), config.set_bpe.lookahead)
processors[name] = ActionCodec2SparseProcessor(config, primitives, vocabulary)
budgets[name] = config.train.bpe.vocab_size
return ActionCodec2SparseRouter.from_processors(
processors, token_budgets=budgets, presets=list(builtin_action_spaces().values())
)
class ActionCodec2Sparse(ActionSpaceMixin, TrainingMixin, BaseActionTokenizer):
"""ActionCodec2 physics with a zero-free token layer, bound to one action space."""
trainer_class = SparseProfileTrainer
model_type = "actioncodec2_sparse"
encode_kwargs = frozenset({"fps", "current_state", "previous_first_order"})
def __init__(
self,
backend: ActionCodec2SparseRouter | None = None,
*,
action_space: str,
primitive_order: int = 1,
vocab_size: int = 4096,
profile_configs: Mapping[str, object] | None = None,
) -> None:
fresh = backend is None
if fresh:
backend = build_fresh_router(profile_configs, primitive_order, vocab_size)
elif profile_configs is not None or primitive_order != 1 or vocab_size != 4096:
raise ValueError("profile construction options cannot accompany an existing backend")
if not isinstance(backend, ActionCodec2SparseRouter):
raise TypeError(
f"ActionCodec2Sparse requires ActionCodec2SparseRouter, got {type(backend).__name__}"
)
preset = backend.preset(action_space)
if preset.target_hz != 15.0:
raise ValueError("ActionCodec2Sparse artifacts require target_hz=15")
self.action_tokenizer = backend
self._action_space = preset
self._action_vocab_size = int(backend.vocab_size)
self._new_action_tokens = backend.new_action_tokens
self.action_dim = preset.input_dim
self.requires_previous_first_order = bool(backend.second_order_boundaries(preset))
self.max_action_token_len = None
self.fixed_action_token_len = None
self._warned_default_fps = False
super().__init__(append_bar_sentinels=False)
self._fitted_profiles = frozenset() if fresh else frozenset(backend.profiles)
self.training_report = None
self.preprocessing: dict[str, PreprocessingSpec] = {}
self.release_metadata: dict[str, Any] | None = None
def __repr__(self) -> str:
return (
f"ActionCodec2Sparse(action_space={self.action_space.name!r}, "
f"D={self.action_dim}, vocab={self.vocab_size})"
)
def _describe_action_spaces(self, action_dim: int | None = None) -> str:
fitted = ", ".join(sorted(self.fitted_profiles)) or "none; call codec.fit(episodes)"
layouts = self.action_tokenizer.describe_action_spaces(
selected=self.action_space.name, action_dim=action_dim
)
return f"Fitted profiles: {fitted}\n{layouts}"
@staticmethod
def builtin_action_spaces():
"""Return the immutable named layout definitions shipped in the package."""
return builtin_action_spaces()
def print_action_spaces(self, *, action_dim: int | None = None, file: TextIO | None = None):
"""Print all layouts or candidates matching ``action_dim``."""
print(self._describe_action_spaces(action_dim), file=sys.stdout if file is None else file)
def for_action_space(self, name: str) -> ActionCodec2Sparse:
"""Share current weights with a new binding."""
codec = type(self)(self.action_tokenizer, action_space=name)
codec._fitted_profiles = self.fitted_profiles
codec.training_report = self.training_report
codec.preprocessing = dict(self.preprocessing)
codec.release_metadata = self.release_metadata
return codec
@property
def fitted_profiles(self) -> frozenset[str]:
"""Names of profiles fitted on data; persisted by save_pretrained()."""
return self._fitted_profiles
@property
def is_fitted(self) -> bool:
"""Whether every profile used by the selected action space is fitted."""
return all(c.space in self.fitted_profiles for c in self.action_space.components)
def _require_fitted(self) -> None:
missing = {c.space for c in self.action_space.components} - self.fitted_profiles
if missing:
raise RuntimeError(
f"Unfitted profiles {sorted(missing)}; call codec.fit(episodes) first"
)
def _source_fps(self, fps, preset=None) -> float:
preset = self.action_space if preset is None else preset
if fps is None:
if not self._warned_default_fps:
warnings.warn(
f"fps was omitted; assuming {preset.source_hz:g} Hz from action_space "
f"{preset.name!r}. Pass the actual fps explicitly to avoid changing action timing.",
UserWarning,
stacklevel=3,
)
self._warned_default_fps = True
return float(preset.source_hz)
return float(fps)
def codec_horizon(self, horizon: int, *, fps: float | None = None) -> int:
"""Codec frames (15 Hz) of a ``horizon``-frame source chunk at ``fps``."""
if isinstance(horizon, bool) or not isinstance(horizon, int) or horizon < 1:
raise ValueError("horizon must be a positive integer")
return resampled_horizon(
horizon, source_hz=self._source_fps(fps), target_hz=self.action_space.target_hz
)
def max_tokens_for_horizon(self, horizon: int, *, fps: float | None = None) -> int:
"""Upper bound on the tokens of one encoded ``horizon``-frame source chunk."""
frames = self.codec_horizon(horizon, fps=fps)
components = self.action_space.components
width = sum(len(c.active_dims) + 1 for c in components)
return frames * width + len(components) - 1
def grammar(self, horizon: int, *, fps: float | None = None):
"""Constrained-generation grammar for one ``horizon``-frame source chunk."""
self._require_fitted()
return self.action_tokenizer.grammar(
preset=self.action_space.name, horizon=self.codec_horizon(horizon, fps=fps)
)
def logits_processor(
self, horizon, *, fps, prompt_length, eos_token_id, token_offset=0, pad_token_id=None
):
"""Create a Transformers LogitsProcessor for one ``horizon``-frame chunk."""
from .generation import ActionCodec2SparseLogitsProcessor
return ActionCodec2SparseLogitsProcessor(
self,
horizon,
fps=fps,
prompt_length=prompt_length,
eos_token_id=eos_token_id,
token_offset=token_offset,
pad_token_id=pad_token_id,
)
def _check_state(self, current_state, action: str):
name = self.action_space.name
if self.action_space.input == "absolute" and current_state is None:
raise ValueError(
f"action space {name!r} is absolute; {action} requires current_state with shape [B, D]"
)
if self.action_space.input == "delta" and current_state is not None:
raise ValueError(f"action space {name!r} is delta; omit current_state")
return _numpy(current_state)
def encode(
self, action, *, fps=None, current_state=None, previous_first_order=None, encode_kwargs=None
) -> list[list[int]]:
"""Encode ``(B,T,D)`` or ``(T,D)`` source chunks into ragged token rows."""
kwargs = self._check_encode_kwargs(encode_kwargs)
direct = {
"fps": fps,
"current_state": current_state,
"previous_first_order": previous_first_order,
}
duplicates = sorted(k for k, v in direct.items() if v is not None and k in kwargs)
if duplicates:
raise ValueError(f"encode arguments provided twice: {duplicates}")
kwargs.update({k: v for k, v in direct.items() if v is not None})
values = torch.as_tensor(action)
if values.ndim == 2:
values = values.unsqueeze(0)
return self._encode_action_indices(values, encode_kwargs=kwargs)
def __call__(
self,
action,
*,
fps=None,
current_state=None,
previous_first_order=None,
encode_kwargs=None,
return_tensors=None,
) -> BatchFeature:
rows = self.encode(
action,
fps=fps,
current_state=current_state,
previous_first_order=previous_first_order,
encode_kwargs=encode_kwargs,
)
return BatchFeature({"input_ids": rows}, tensor_type=return_tensors)
def _encode_action_indices(self, action, encode_kwargs=None):
self._require_fitted()
kwargs = dict(encode_kwargs or {})
current_state = self._check_state(kwargs.get("current_state"), "encode")
values = _numpy(action)
if values.ndim != 3 or values.shape[2] != self.action_dim:
raise ValueError(f"actions must have shape [B, T_source, {self.action_dim}]")
return self.action_tokenizer.encode_batch(
values,
preset=self.action_space.name,
fps=self._source_fps(kwargs.get("fps")),
current_state=current_state,
previous_first_order=self._numpy_boundary(kwargs.get("previous_first_order")),
)
def decode(
self,
action_token_ids,
*,
horizon: int,
fps=None,
current_state=None,
previous_first_order=None,
) -> torch.Tensor:
"""Decode rows of one ``horizon``-frame source chunk to a CPU ``(B,T,D)`` tensor."""
return self.decode_token_ids_to_actions_batch(
action_token_ids,
time_horizon=horizon,
decode_kwargs={
"fps": fps,
"current_state": current_state,
"previous_first_order": previous_first_order,
},
)
def decode_token_ids_to_actions_batch(
self, action_token_ids, *, time_horizon=None, action_dim=None, decode_kwargs=None
) -> torch.Tensor:
"""Decode integer rows (ragged or ``(B,L)``) of one source ``time_horizon``."""
rows = [
self._checked_decode_row(row).tolist() for row in _numpy_token_rows(action_token_ids)
]
actions, _ = self._decode_action_indices(
rows, time_horizon=time_horizon, action_dim=action_dim, decode_kwargs=decode_kwargs
)
return torch.from_numpy(actions)
def _decode_action_indices(
self, action_indices, *, time_horizon=None, action_dim=None, decode_kwargs=None
):
self._require_fitted()
if time_horizon is None:
raise ValueError("ActionCodec2Sparse decode requires the chunk horizon")
if action_dim is not None and int(action_dim) != self.action_dim:
raise ValueError(
f"action_dim={action_dim} disagrees with action space D={self.action_dim}"
)
kwargs = dict(decode_kwargs or {})
unknown = sorted(set(kwargs) - self.encode_kwargs)
if unknown:
raise ValueError(f"ActionCodec2Sparse does not consume decode_kwargs {unknown}")
fps = self._source_fps(kwargs.get("fps"))
decoded = self.action_tokenizer.decode_batch(
action_indices,
preset=self.action_space.name,
horizon=self.codec_horizon(time_horizon, fps=fps),
fps=fps,
current_state=self._check_state(kwargs.get("current_state"), "decode"),
previous_first_order=self._numpy_boundary(kwargs.get("previous_first_order")),
)
actions = restore_source_layout(self.action_space, decoded)
return actions, [set() for _ in range(len(actions))]
def batch_decode(
self,
token_rows,
*,
horizon: int,
fps=None,
current_state=None,
previous_first_order=None,
errors="raise",
):
"""Decode independent rows of one horizon, isolating invalid rows."""
if errors not in ("raise", "return"):
raise ValueError("errors must be 'raise' or 'return'")
rows = list(token_rows)
current = _numpy(current_state)
if current is not None and np.shape(current) != (len(rows), self.action_dim):
raise ValueError(f"current_state must have shape {(len(rows), self.action_dim)}")
previous = self._numpy_boundary(previous_first_order)
if previous is not None and any(len(v) != len(rows) for v in previous.values()):
raise ValueError(
"previous_first_order arrays must have batch dimension equal to token rows"
)
result = []
for index, row in enumerate(rows):
try:
state = None if current is None else current[index : index + 1]
boundary = (
None
if previous is None
else {k: v[index : index + 1] for k, v in previous.items()}
)
result.append(
self.decode(
[row],
horizon=horizon,
fps=fps,
current_state=state,
previous_first_order=boundary,
)
)
except ValueError as error:
error = ValueError(f"token row {index}: {error}")
if errors == "raise":
raise error from None
result.append(error)
return result
def decode_prefix(
self,
action_token_ids,
*,
horizon: int,
steps: int,
fps=None,
current_state=None,
previous_first_order=None,
):
"""Decode the first ``steps`` 15 Hz frames of streaming prefixes to ``(B,steps,D)``."""
self._require_fitted()
fps = self._source_fps(fps)
if fps != self.action_space.target_hz:
raise ValueError("prefix decode requires fps equal to the 15 Hz codec clock")
frames = self.codec_horizon(horizon, fps=fps)
if isinstance(steps, bool) or not isinstance(steps, int) or not 1 <= steps <= frames:
raise ValueError("steps must be an integer within the chunk horizon")
rows = [self._checked_decode_row(row).tolist() for row in action_token_ids]
grammar = self.action_tokenizer.grammar(preset=self.action_space.name, horizon=frames)
if any(grammar.covered_steps(row) < steps for row in rows):
raise ValueError(f"action prefix does not fix the first {steps} frames")
decoded = self.action_tokenizer.decode_batch(
rows,
preset=self.action_space.name,
horizon=frames,
fps=fps,
current_state=self._check_state(_numpy(current_state), "decode"),
previous_first_order=self._numpy_boundary(previous_first_order),
)
return torch.from_numpy(restore_source_layout(self.action_space, decoded)[:, :steps])
def final_first_order(
self,
action_token_ids,
*,
horizon: int,
previous_first_order,
fps=None,
executed_steps: int | None = None,
) -> dict[str, np.ndarray]:
"""Second-order component boundaries ``{component: (B,D_active)}`` for the next chunk."""
self._require_fitted()
return self.action_tokenizer.final_first_order(
[self._checked_decode_row(row).tolist() for row in action_token_ids],
preset=self.action_space.name,
horizon=self.codec_horizon(horizon, fps=fps),
previous_first_order=self._numpy_boundary(previous_first_order),
executed_steps=executed_steps,
)
@classmethod
def describe_pretrained(
cls,
pretrained_model_name_or_path,
*,
action_dim=None,
gripper_mode="2bin",
primitive_order: int = 1,
**kwargs,
) -> str:
"""Report a release's or saved artifact's layouts from manifests alone."""
directory = resolve_pretrained_directory(
pretrained_model_name_or_path, allow_patterns=ALLOW_PATTERNS, **kwargs
)
if (directory / MANIFEST_NAME).is_file():
return describe_release(
directory,
action_dim=action_dim,
gripper_mode=gripper_mode,
primitive_order=primitive_order,
)
specs, presets = ActionCodec2SparseRouter.read_manifest(directory)
metadata = _saved_metadata(directory)
fitted = ", ".join(metadata.get("fitted_profiles", [s.name for s in specs]))
from ...backends.actioncodec2.presets import format_action_spaces
layouts = format_action_spaces(
{p.name: p for p in presets},
selected=metadata.get("action_space"),
action_dim=action_dim,
codec_name="ActionCodec2Sparse",
chunk_horizon_decode=True,
)
return f"Fitted profiles: {fitted}\n{layouts}"
@classmethod
def from_pretrained(
cls,
pretrained_model_name_or_path,
*,
action_space=None,
gripper_mode: str | None = None,
primitive_order: int | None = None,
verify_gates: bool = False,
**kwargs,
) -> ActionCodec2Sparse:
"""Load a release package or saved frontend and bind an action space."""
if cls is ActionCodec2Sparse and ("variant" in kwargs or (Path(pretrained_model_name_or_path) / "variants.json").is_file()
or (Path(pretrained_model_name_or_path) / "artifact_manifest.json").is_file()):
from ...catalog import ActionCodec2 as Catalog
return Catalog.from_pretrained(pretrained_model_name_or_path, action_space=action_space, **kwargs)
directory = resolve_pretrained_directory(
pretrained_model_name_or_path, allow_patterns=ALLOW_PATTERNS, **kwargs
)
metadata = _saved_metadata(directory)
action_space = metadata.get("action_space") if action_space is None else action_space
if action_space is None:
raise ValueError(
"action_space is required; inspect layouts with "
"print(ActionCodec2Sparse.describe_pretrained(path))"
)
if (directory / MANIFEST_NAME).is_file():
router, preprocessing, release = load_release_router(
directory,
gripper_mode=gripper_mode or "2bin",
primitive_order=1 if primitive_order is None else primitive_order,
verify_gates=verify_gates,
)
codec = cls(router, action_space=_register(router, action_space))
codec.preprocessing, codec.release_metadata = preprocessing, release
return codec
if gripper_mode is not None or primitive_order is not None or verify_gates:
raise ValueError(
"gripper_mode, primitive_order and verify_gates select release variants only"
)
router = ActionCodec2SparseRouter.from_pretrained(directory)
codec = cls(router, action_space=_register(router, action_space))
fitted = metadata.get("fitted_profiles", list(router.profiles))
if not isinstance(fitted, list) or set(fitted) - router.profiles.keys():
raise ValueError("invalid fitted_profiles in processor_config.json")
codec._fitted_profiles = frozenset(fitted)
codec.preprocessing = {
name: PreprocessingSpec.from_dict(spec)
for name, spec in metadata.get("preprocessing", {}).items()
}
codec.release_metadata = metadata.get("release")
report = directory / "fit_report.json"
if report.is_file():
codec.training_report = json.loads(report.read_text(encoding="utf-8"))
return codec
def to_dict(self) -> dict[str, object]:
"""Lightweight discovery metadata saved as ``processor_config.json``."""
payload = {
"action_space": self.action_space.name,
"fitted_profiles": sorted(self.fitted_profiles),
}
if self.preprocessing:
payload["preprocessing"] = {k: v.to_dict() for k, v in self.preprocessing.items()}
return payload
def save_pretrained(
self, save_directory: str | Path, *, include_training_report=False
) -> list[str]:
"""Save inference assets; include the local fit report only when explicitly requested."""
directory = Path(save_directory)
files = self.action_tokenizer.save_pretrained(directory)
files.extend(map(str, save_router_metadata(directory, **self.to_dict())))
files.append(str(_save_model_card(directory, self)))
report = directory / "fit_report.json"
if include_training_report and self.training_report is not None:
report.write_text(json.dumps(self.training_report, indent=2) + "\n", encoding="utf-8")
files.append(str(report))
elif report.exists():
report.unlink()
files.extend(save_runtime(directory))
return files
def eval(self) -> ActionCodec2Sparse:
return self
def _save_model_card(directory: Path, codec: ActionCodec2Sparse) -> Path:
path = directory / "README.md"
if path.exists() and GENERATED_CARD_MARKER not in path.read_text(encoding="utf-8"):
return path
context = 'context = {"fps": 15.0}\n'
if codec.action_space.input == "absolute":
context += 'context["current_state"] = current_state # observed (B, D) source state\n'
if codec.requires_previous_first_order:
context += 'context["previous_first_order"] = previous_first_order # canonical 15 Hz increments by component\n'
path.write_text(
"---\nlibrary_name: actioncodec2\nlicense: apache-2.0\ntags:\n- robotics\n- action-tokenizer\n---\n\n"
f"{GENERATED_CARD_MARKER}\n\n# ActionCodec2Sparse\n\n"
"Install actioncodec2 >= 0.1.0 before loading this artifact.\n\n"
"```python\nfrom actioncodec2 import ActionCodec2Sparse\n\n"
"codec = ActionCodec2Sparse.from_pretrained('./my_codec')\n"
+ context
+ "rows = codec.encode(actions, **context)\n"
"restored = codec.decode(rows, horizon=actions.shape[-2], **context)\n```\n\n"
"Supply actions and any required boundary observations in the declared layout.\n"
"Rows are independent chunks; decode and grammar take the chunk horizon.\n\n"
"## Layouts\n\n```text\n" + str(codec) + "\n```\n",
encoding="utf-8",
)
return path
def _saved_metadata(directory: Path) -> dict:
path = directory / PROCESSOR_CONFIG_NAME
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
def _register(router: ActionCodec2SparseRouter, action_space) -> str:
if isinstance(action_space, (PresetConfig, Mapping, Path)):
return router.register_action_space(action_space).name
if isinstance(action_space, str):
if Path(action_space).is_file():
return router.register_action_space(Path(action_space)).name
return action_space
raise TypeError("action_space must be a registered name, PresetConfig, mapping, or YAML path")