lab-vla / src /transforms /fast_action.py
multimodalart's picture
multimodalart HF Staff
Upload folder using huggingface_hub
34f3bc9 verified
Raw
History Blame Contribute Delete
8.71 kB
"""DataLoader-worker transform: encode normalized action chunk into FAST tokens.
Must run AFTER:
- NormalizeTransformFn (action in [-1, 1])
- ComposeFieldsTransformFn / source-local action field assembly
Must run BEFORE:
- PadStateAndActionTransformFn
Running after pad would encode zero-filled trailing action dimensions into
FAST tokens and pollute the CE target.
Output keys added in-place into sample dict:
- fast_action_tokens: LongTensor(max_length,)
- fast_action_mask: BoolTensor(max_length,)
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, replace
from typing import Any
import numpy as np
import torch
from src.transforms.core import DataTransformFn, source_fast_max_length
_TOKENIZER_CACHE: dict = {} # process-local
def _get_tokenizer(path: str, vocab_size: int, max_length: int):
key = (path, int(vocab_size), int(max_length))
if key not in _TOKENIZER_CACHE:
# Import stays inside the function so merely importing fast_action
# doesn't pull transformers/scipy until FAST is actually used.
from src.transforms.fast_tokenizer import FastTokenizerWrapper
_TOKENIZER_CACHE[key] = FastTokenizerWrapper(path, vocab_size, max_length)
return _TOKENIZER_CACHE[key]
@DataTransformFn.register_subclass("fast_action_encode")
@dataclass
class FastActionEncodeTransformFn(DataTransformFn):
# Library-level default is env-overridable so a relocated FAST asset doesn't
# require a code edit (config/launch layers always pass `path` explicitly;
# this default only serves ad-hoc/library callers).
path: str = os.environ.get(
"LABVLA_FAST_TOKENIZER_PATH", "physical-intelligence/fast"
)
vocab_size: int = 2048
max_length: int = 256
source_shape_convergence: bool = False
trim_to_mask: bool = False
def __post_init__(self):
# Eagerly preload in the parent process so all fork'd DataLoader workers
# inherit the loaded tokenizer via copy-on-write, avoiding a 192-worker
# scipy/BPE cold-load storm on first sample pull.
_get_tokenizer(self.path, self.vocab_size, self.max_length)
def __call__(self, data: dict[str, Any]) -> dict[str, Any]:
if "action" not in data:
return data
a = data["action"]
if isinstance(a, torch.Tensor):
a_np = a.detach().cpu().numpy()
else:
a_np = np.asarray(a)
if a_np.ndim != 2:
# Default-strict: a non-chunk action means an earlier transform
# reshaped action to 1D/0D, which would silently drop FAST CE
# supervision while training continues (weaker vlm_pretrain run).
# Opt out only when intentionally mixing un-chunked action (rare).
from src.utils import env_flags as _env_flags
if _env_flags.get("LABVLA_FAST_ACTION_NON_CHUNK_SKIP") == "1":
return data
raise ValueError(
f"FastActionEncodeTransformFn: expected 2-D action chunk "
f"(T, D) but got ndim={a_np.ndim}, shape={a_np.shape}. An "
"earlier transform stripped the chunk dim, or the adapter "
"emitted a single-frame action without chunking. Set "
"LABVLA_FAST_ACTION_NON_CHUNK_SKIP=1 to fall back to the "
"legacy silent-skip behaviour."
)
# Zero-fill padded frames before FAST encoding. FAST = DCT + BPE;
# feeding clipped-to-last-frame action through DCT produces a distinct
# "padded-tail" coefficient pattern that BPE then codes into specific
# tokens. The CE branch would learn to *predict* those tokens, i.e.
# learn to emit padded action chunks at inference — a leaked training
# artifact. Masking padded frames to zero keeps the pre-pad signal
# intact while removing the padded tail's distinctive DCT fingerprint.
is_pad = data.get("action_is_pad")
pad_np = None
if is_pad is not None:
pad_np = is_pad.detach().cpu().numpy() if isinstance(is_pad, torch.Tensor) else np.asarray(is_pad)
# action shape may be (T, D) after adapter unwraps per-sample batch;
# is_pad can arrive as (T,) or (1, T). Align to (T,) then broadcast.
if pad_np.ndim == 2 and pad_np.shape[0] == 1:
pad_np = pad_np[0]
# VQA-only samples (robointer_vqa_adapter) emit
# ``action=zeros`` + ``action_is_pad=ones`` (every frame padded).
# If we tokenize anyway, FAST encodes the all-zero chunk into a
# specific BPE pattern and the model learns to predict it for VQA
# samples — fabricated supervision on samples that have no real
# action. Detect "every frame is padded" and skip emitting
# fast_action_tokens / fast_action_mask entirely. Downstream
# _forward_vlm_pretrain treats the absence of these keys as
# "this sample contributes no FAST CE term".
if pad_np.shape[0] == a_np.shape[0] and bool(pad_np.astype(bool).all()):
return data
if pad_np.shape[0] == a_np.shape[0]:
a_np = a_np.copy()
a_np[pad_np.astype(bool)] = 0.0
else:
# action_is_pad length does not match the action chunk length T.
# A length-mismatched mask means upstream drift; fail loud rather
# than encode the raw action, whose padded tail's DCT/BPE
# fingerprint would leak into the FAST CE target (the same
# fabricated-supervision artifact the zero-fill branch prevents).
raise ValueError(
f"FastActionEncodeTransformFn: action_is_pad length "
f"{pad_np.shape[0]} != action chunk length {a_np.shape[0]} "
f"(action shape={a_np.shape}, pad shape={pad_np.shape}). "
"An upstream transform desynced the pad mask from the "
"action chunk; refusing to encode the raw action because a "
"padded tail would leak into the FAST CE target."
)
tok = _get_tokenizer(self.path, self.vocab_size, self.max_length)
tokens, mask = tok.encode(a_np)
if self.trim_to_mask:
valid = np.asarray(mask).astype(bool)
if valid.ndim == 1 and valid.size > 1:
if bool(valid.any()):
keep_len = int(np.nonzero(valid)[0].max()) + 1
else:
keep_len = 1
if keep_len < tokens.shape[0]:
tokens = tokens[:keep_len].copy()
mask = mask[:keep_len].copy()
data["fast_action_tokens"] = torch.from_numpy(tokens)
data["fast_action_mask"] = torch.from_numpy(mask)
return data
def hydrate(self, ctx) -> "FastActionEncodeTransformFn":
# Tighten the source-local max_length once the dataset schema is known
# (source_shape_convergence runs).
schema = ctx.schema
source_action_dim = int(sum(schema.action_dims or ()))
original_max_length = int(self.max_length)
effective_max_length = original_max_length
if getattr(self, "source_shape_convergence", False):
effective_max_length = source_fast_max_length(
schema.schema_id,
original_max_length,
)
t = self
if effective_max_length != original_max_length:
t = replace(self, max_length=effective_max_length)
logging.info(
f"Hydrated {t.__class__.__name__} max_length={t.max_length} "
f"source_action_dim={source_action_dim} "
f"trim_to_mask={getattr(t, 'trim_to_mask', False)} "
f"source_shape_convergence={getattr(t, 'source_shape_convergence', False)} "
f"({schema.schema_id})"
)
return t
@DataTransformFn.register_subclass("drop_fast_action_supervision")
@dataclass
class DropFastActionSupervisionTransformFn(DataTransformFn):
"""Remove FAST CE targets after they were built by the normal pipeline.
This is an explicit per-repository escape hatch for VLM-pretrain mixes that
should keep text/annotation supervision from a source but must not train on
its action-token targets. Missing FAST keys are already treated downstream
as "no action CE for this sample".
"""
reason: str = ""
def __call__(self, data: dict[str, Any]) -> dict[str, Any]:
data.pop("fast_action_tokens", None)
data.pop("fast_action_mask", None)
return data