Download fact/model.py from zhicao/FACT: direct link, hf CLI and curl.
- Browser
- Download file 34.7 kB
-
https://huggingface.co/zhicao/FACT/resolve/main/fact/model.py
- Command line
-
hf download hf://zhicao/FACT/fact/model.py
-
curl -L -o model.py https://huggingface.co/zhicao/FACT/resolve/main/fact/model.py
34.7 kB
| """FACT: Force-Aware Causal Transformer policy. | |
| A from-scratch block-causal policy that | |
| * drops the 250-point track pathway entirely (no track tokens / encoder / | |
| decoder / loss), | |
| * replaces future-video (Wan world-model) prediction with *future-state* | |
| prediction: the 16 proprioceptive EEF/hand states that follow the 16-step | |
| action chunk are denoised jointly with the actions, | |
| * uses a two-stage flow schedule: a coarse joint flow | |
| (tau 1 -> 0.4) on [action, future state], followed by the force-only | |
| tactile refinement (tau 0.4 -> 0), which is re-used unchanged from | |
| ``fact.force.ForceOnlyTransformer``. | |
| The coarse denoiser is a small DiT-style block-causal transformer. Frozen | |
| perception components run offline: the Wan2.2 VAE as a fixed image tokenizer | |
| (48-channel latent of the current 3-view frame -> 50 tokens) and the Wan | |
| umT5-XXL encoder whose token embeddings enter as clean prefix tokens | |
| (``use_text``). | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import math | |
| from dataclasses import asdict, dataclass, field | |
| from pathlib import Path | |
| from typing import Any | |
| import torch | |
| from torch import nn | |
| from torch.nn import functional as F | |
| from .force import ( | |
| ACTION_HORIZON, | |
| FORCE_OFFSETS, | |
| PADDED_ACTION_DIM, | |
| PHYSICAL_ACTION_DIM, | |
| ForceOnlyOutput, | |
| ForceOnlyTransformer, | |
| TwoStageFlowSchedule, | |
| euler_flow_step, | |
| pad_action_62_to_64, | |
| sample_trex_timestep, | |
| sinusoidal_tau_embedding, | |
| ) | |
| # Token type ids inside the packed sequence. | |
| TYPE_OBS, TYPE_STATE, TYPE_ACTION, TYPE_FSTATE, TYPE_TASK, TYPE_TEXT = range(6) | |
| class FactConfig: | |
| # coarse transformer | |
| dim: int = 1024 | |
| num_layers: int = 12 | |
| num_heads: int = 16 | |
| ffn_dim: int = 4096 | |
| dropout: float = 0.0 | |
| gradient_checkpointing: bool = False | |
| # observation tokenizer (Wan2.2 VAE latent of the 160x320 3-view grid) | |
| obs_channels: int = 48 | |
| obs_patch: int = 2 | |
| obs_grid: tuple[int, int] = (10, 20) | |
| # action / state | |
| action_dim: int = PADDED_ACTION_DIM | |
| physical_action_dim: int = PHYSICAL_ACTION_DIM | |
| action_horizon: int = ACTION_HORIZON | |
| state_dim: int = PADDED_ACTION_DIM | |
| physical_state_dim: int = PHYSICAL_ACTION_DIM | |
| # future-state prediction (replaces future-video prediction) | |
| predict_future_state: bool = True | |
| future_state_horizon: int = ACTION_HORIZON | |
| # autoregressive block memory | |
| max_blocks: int = 32 | |
| window: int = 4 | |
| use_task_token: bool = True | |
| time_dim: int = 256 | |
| # language conditioning: frozen umT5-XXL token embeddings as clean prefix | |
| use_text: bool = False | |
| text_dim: int = 4096 | |
| max_text_tokens: int = 64 | |
| # tactile force stage (force-only refiner) | |
| use_force: bool = True | |
| force_dim: int = 768 | |
| force_layers: int = 6 | |
| force_heads: int = 12 | |
| force_ffn: int = 3072 | |
| vq_codebook_size: int = 64 | |
| vq_code_dim: int = 256 | |
| tactile_dropout: float = 0.1 | |
| # two-stage schedule | |
| coarse_steps: int = 6 | |
| force_steps: int = 4 | |
| tau_split: float = 0.4 | |
| # loss weights | |
| action_loss_weight: float = 1.0 | |
| state_loss_weight: float = 1.0 | |
| force_loss_weight: float = 1.0 | |
| vq_loss_weight: float = 1.0 | |
| vq_commitment_weight: float = 0.25 | |
| def obs_tokens(self) -> int: | |
| h, w = self.obs_grid | |
| return (h // self.obs_patch) * (w // self.obs_patch) | |
| def obs_in_dim(self) -> int: | |
| return self.obs_channels * self.obs_patch * self.obs_patch | |
| def fstate_tokens(self) -> int: | |
| return self.future_state_horizon if self.predict_future_state else 0 | |
| def tokens_per_block(self) -> int: | |
| return self.obs_tokens + 1 + self.action_horizon + self.fstate_tokens | |
| def prefix_tokens(self) -> int: | |
| return (1 if self.use_task_token else 0) + (self.max_text_tokens if self.use_text else 0) | |
| def to_json(self) -> str: | |
| return json.dumps(asdict(self), indent=1) | |
| def from_json(cls, text: str) -> "FactConfig": | |
| data = json.loads(text) | |
| if "obs_grid" in data: | |
| data["obs_grid"] = tuple(data["obs_grid"]) | |
| return cls(**data) | |
| class FactLayout: | |
| """Index bookkeeping for one packed [task | text | K x block] sequence.""" | |
| def __init__(self, cfg: FactConfig, num_blocks: int) -> None: | |
| self.cfg = cfg | |
| self.num_blocks = num_blocks | |
| self.task = 1 if cfg.use_task_token else 0 | |
| self.text = cfg.max_text_tokens if cfg.use_text else 0 | |
| self.prefix = self.task + self.text | |
| self.obs = cfg.obs_tokens | |
| self.action = cfg.action_horizon | |
| self.fstate = cfg.fstate_tokens | |
| self.per_block = cfg.tokens_per_block | |
| self.length = self.prefix + num_blocks * self.per_block | |
| def text_slice(self) -> slice: | |
| return slice(self.task, self.task + self.text) | |
| def block_start(self, k: int) -> int: | |
| return self.prefix + k * self.per_block | |
| def obs_slice(self, k: int) -> slice: | |
| s = self.block_start(k) | |
| return slice(s, s + self.obs) | |
| def state_slice(self, k: int) -> slice: | |
| s = self.block_start(k) + self.obs | |
| return slice(s, s + 1) | |
| def action_slice(self, k: int) -> slice: | |
| s = self.block_start(k) + self.obs + 1 | |
| return slice(s, s + self.action) | |
| def fstate_slice(self, k: int) -> slice: | |
| s = self.block_start(k) + self.obs + 1 + self.action | |
| return slice(s, s + self.fstate) | |
| def token_metadata(self, device: torch.device) -> dict[str, torch.Tensor]: | |
| slot = torch.zeros(self.length, dtype=torch.long) | |
| block = torch.zeros(self.length, dtype=torch.long) | |
| ttype = torch.zeros(self.length, dtype=torch.long) | |
| if self.task: | |
| ttype[0] = TYPE_TASK | |
| if self.text: | |
| ttype[self.text_slice] = TYPE_TEXT | |
| per_slot_type = ( | |
| [TYPE_OBS] * self.obs | |
| + [TYPE_STATE] | |
| + [TYPE_ACTION] * self.action | |
| + [TYPE_FSTATE] * self.fstate | |
| ) | |
| per_slot_type_t = torch.tensor(per_slot_type, dtype=torch.long) | |
| for k in range(self.num_blocks): | |
| s = self.block_start(k) | |
| slot[s : s + self.per_block] = torch.arange(self.per_block) | |
| block[s : s + self.per_block] = k | |
| ttype[s : s + self.per_block] = per_slot_type_t | |
| return { | |
| "slot": slot.to(device), | |
| "block": block.to(device), | |
| "type": ttype.to(device), | |
| } | |
| def allow_matrix( | |
| self, device: torch.device, text_mask: torch.Tensor | None = None | |
| ) -> torch.Tensor: | |
| """Block-causal mask: block k reads blocks [k-window, k] and the prefix. | |
| Returns [L, L] (no text padding) or [B, 1, L, L] when ``text_mask`` | |
| [B, T] marks valid text tokens. | |
| """ | |
| meta = self.token_metadata(device) | |
| block = meta["block"] | |
| is_prefix = torch.zeros(self.length, dtype=torch.bool, device=device) | |
| is_prefix[: self.prefix] = True | |
| qb = block[:, None] | |
| kb = block[None, :] | |
| allow = (kb <= qb) & (kb >= qb - self.cfg.window) | |
| allow = allow | is_prefix[None, :] | |
| # Prefix tokens are clean conditions: they read only the prefix. | |
| allow[is_prefix] = False | |
| allow[is_prefix[:, None] & is_prefix[None, :]] = True | |
| if text_mask is None or not self.text: | |
| return allow | |
| valid = torch.ones(text_mask.shape[0], self.length, dtype=torch.bool, device=device) | |
| valid[:, self.text_slice] = text_mask.to(device, torch.bool) | |
| allow_b = allow[None] & valid[:, None, :] | |
| eye = torch.eye(self.length, dtype=torch.bool, device=device) | |
| allow_b = allow_b | eye[None] # padded tokens still attend to themselves | |
| return allow_b[:, None] | |
| def memory_indices(self, k: int) -> torch.Tensor: | |
| first = max(0, k - self.cfg.window) | |
| pieces = [] | |
| if self.prefix: | |
| pieces.append(torch.arange(0, self.prefix)) | |
| pieces.append( | |
| torch.arange(self.block_start(first), self.block_start(k) + self.per_block) | |
| ) | |
| return torch.cat(pieces) | |
| class FactBlock(nn.Module): | |
| """Pre-norm transformer block with per-token adaLN-zero modulation.""" | |
| def __init__(self, dim: int, num_heads: int, ffn_dim: int, dropout: float) -> None: | |
| super().__init__() | |
| if dim % num_heads: | |
| raise ValueError("dim must be divisible by num_heads") | |
| self.dim = dim | |
| self.num_heads = num_heads | |
| self.head_dim = dim // num_heads | |
| self.norm1 = nn.LayerNorm(dim, elementwise_affine=False, eps=1e-6) | |
| self.qkv = nn.Linear(dim, 3 * dim) | |
| self.q_norm = nn.RMSNorm(self.head_dim, eps=1e-6) | |
| self.k_norm = nn.RMSNorm(self.head_dim, eps=1e-6) | |
| self.proj = nn.Linear(dim, dim) | |
| self.norm2 = nn.LayerNorm(dim, elementwise_affine=False, eps=1e-6) | |
| self.ffn = nn.Sequential( | |
| nn.Linear(dim, ffn_dim), | |
| nn.GELU(approximate="tanh"), | |
| nn.Linear(ffn_dim, dim), | |
| ) | |
| self.dropout = nn.Dropout(dropout) | |
| self.modulation = nn.Parameter(torch.zeros(1, 1, 6, dim)) | |
| def forward( | |
| self, x: torch.Tensor, e: torch.Tensor, allow: torch.Tensor | |
| ) -> torch.Tensor: | |
| batch, length, _ = x.shape | |
| m = (e + self.modulation).unbind(2) | |
| shift_a, scale_a, gate_a, shift_f, scale_f, gate_f = m | |
| h = self.norm1(x) * (1 + scale_a) + shift_a | |
| qkv = self.qkv(h).view(batch, length, 3, self.num_heads, self.head_dim) | |
| q, k, v = qkv.unbind(2) | |
| q = self.q_norm(q).transpose(1, 2) | |
| k = self.k_norm(k).transpose(1, 2) | |
| v = v.transpose(1, 2) | |
| a = F.scaled_dot_product_attention(q, k, v, attn_mask=allow) | |
| a = a.transpose(1, 2).reshape(batch, length, self.dim) | |
| x = x + gate_a * self.dropout(self.proj(a)) | |
| h = self.norm2(x) * (1 + scale_f) + shift_f | |
| x = x + gate_f * self.dropout(self.ffn(h)) | |
| return x | |
| class FactCoarseOutput: | |
| action_flow: torch.Tensor | |
| fstate_flow: torch.Tensor | None | |
| hidden: torch.Tensor | |
| memory_by_block: torch.Tensor # [B, K, M, d] | |
| memory_mask_by_block: torch.Tensor # [B, K, M] | |
| layout: FactLayout | |
| class FactCoarseTransformer(nn.Module): | |
| """Block-causal joint denoiser for [action, future state] tokens.""" | |
| def __init__(self, cfg: FactConfig) -> None: | |
| super().__init__() | |
| self.cfg = cfg | |
| dim = cfg.dim | |
| self.obs_encoder = nn.Linear(cfg.obs_in_dim, dim) | |
| self.state_encoder = nn.Sequential( | |
| nn.Linear(cfg.state_dim, dim), nn.SiLU(), nn.Linear(dim, dim) | |
| ) | |
| self.action_encoder = nn.Linear(cfg.action_dim, dim) | |
| if cfg.predict_future_state: | |
| self.fstate_encoder = nn.Linear(cfg.state_dim, dim) | |
| self.fstate_decoder = nn.Linear(dim, cfg.state_dim) | |
| nn.init.zeros_(self.fstate_decoder.weight) | |
| nn.init.zeros_(self.fstate_decoder.bias) | |
| self.slot_embedding = nn.Embedding(cfg.tokens_per_block, dim) | |
| self.block_embedding = nn.Embedding(cfg.max_blocks, dim) | |
| self.type_embedding = nn.Embedding(6, dim) | |
| if cfg.use_task_token: | |
| self.task_token = nn.Parameter(torch.randn(1, 1, dim) * 0.02) | |
| if cfg.use_text: | |
| self.text_encoder = nn.Sequential( | |
| nn.LayerNorm(cfg.text_dim), nn.Linear(cfg.text_dim, dim) | |
| ) | |
| self.text_position = nn.Embedding(cfg.max_text_tokens, dim) | |
| nn.init.normal_(self.text_position.weight, std=0.02) | |
| self.time_embedding = nn.Sequential( | |
| nn.Linear(cfg.time_dim, dim), nn.SiLU(), nn.Linear(dim, dim) | |
| ) | |
| self.time_projection = nn.Sequential(nn.SiLU(), nn.Linear(dim, 6 * dim)) | |
| nn.init.zeros_(self.time_projection[1].weight) | |
| nn.init.zeros_(self.time_projection[1].bias) | |
| self.blocks = nn.ModuleList( | |
| [ | |
| FactBlock(dim, cfg.num_heads, cfg.ffn_dim, cfg.dropout) | |
| for _ in range(cfg.num_layers) | |
| ] | |
| ) | |
| self.final_norm = nn.LayerNorm(dim, elementwise_affine=False, eps=1e-6) | |
| self.final_modulation = nn.Sequential(nn.SiLU(), nn.Linear(dim, 2 * dim)) | |
| nn.init.zeros_(self.final_modulation[1].weight) | |
| nn.init.zeros_(self.final_modulation[1].bias) | |
| self.action_decoder = nn.Linear(dim, cfg.action_dim) | |
| nn.init.zeros_(self.action_decoder.weight) | |
| nn.init.zeros_(self.action_decoder.bias) | |
| nn.init.normal_(self.slot_embedding.weight, std=0.02) | |
| nn.init.normal_(self.block_embedding.weight, std=0.02) | |
| nn.init.normal_(self.type_embedding.weight, std=0.02) | |
| # ------------------------------------------------------------------ | |
| def patchify_obs(self, latents: torch.Tensor) -> torch.Tensor: | |
| """[B,K,C,H,W] latents -> [B,K,obs_tokens,C*p*p].""" | |
| b, k, c, h, w = latents.shape | |
| p = self.cfg.obs_patch | |
| if (h, w) != tuple(self.cfg.obs_grid): | |
| raise ValueError(f"obs latent grid {(h, w)} != {self.cfg.obs_grid}") | |
| x = latents.view(b, k, c, h // p, p, w // p, p) | |
| x = x.permute(0, 1, 3, 5, 2, 4, 6).reshape(b, k, (h // p) * (w // p), c * p * p) | |
| return x | |
| def forward( | |
| self, | |
| *, | |
| obs_latents: torch.Tensor, | |
| state: torch.Tensor, | |
| action: torch.Tensor, | |
| tau: torch.Tensor, | |
| future_state: torch.Tensor | None = None, | |
| fstate_tau: torch.Tensor | None = None, | |
| text_tokens: torch.Tensor | None = None, | |
| text_mask: torch.Tensor | None = None, | |
| ) -> FactCoarseOutput: | |
| cfg = self.cfg | |
| batch, num_blocks = action.shape[:2] | |
| device = action.device | |
| dtype = self.action_encoder.weight.dtype | |
| layout = FactLayout(cfg, num_blocks) | |
| if tau.shape != (batch, num_blocks): | |
| raise ValueError("tau must be [B,K]") | |
| if fstate_tau is None: | |
| fstate_tau = tau | |
| obs = self.obs_encoder(self.patchify_obs(obs_latents.to(device, dtype))) | |
| st = self.state_encoder(state.to(device, dtype)).unsqueeze(2) | |
| act = self.action_encoder(action.to(device, dtype)) | |
| pieces = [obs, st, act] | |
| if cfg.predict_future_state: | |
| if future_state is None: | |
| raise ValueError("future_state is required when predict_future_state") | |
| pieces.append(self.fstate_encoder(future_state.to(device, dtype))) | |
| x = torch.cat(pieces, dim=2) # [B,K,per_block,d] | |
| meta = layout.token_metadata(device) | |
| x = x + self.slot_embedding.weight[None, None] | |
| x = x + self.block_embedding(torch.arange(num_blocks, device=device))[None, :, None] | |
| x = x.reshape(batch, num_blocks * layout.per_block, cfg.dim) | |
| prefix: list[torch.Tensor] = [] | |
| if layout.task: | |
| prefix.append(self.task_token.to(dtype).expand(batch, -1, -1)) | |
| if layout.text: | |
| if text_tokens is None: | |
| raise ValueError("use_text=True requires text_tokens [B,T,text_dim]") | |
| tt = text_tokens.to(device, dtype) | |
| if tt.shape[1] != layout.text: | |
| raise ValueError(f"text_tokens must have {layout.text} tokens, got {tt.shape[1]}") | |
| if text_mask is None: | |
| text_mask = torch.ones(batch, layout.text, dtype=torch.bool, device=device) | |
| tt = self.text_encoder(tt) + self.text_position.weight[None] | |
| tt = tt * text_mask.to(device, dtype)[..., None] | |
| prefix.append(tt) | |
| if prefix: | |
| x = torch.cat(prefix + [x], dim=1) | |
| x = x + self.type_embedding(meta["type"])[None] | |
| # per-token flow time: clean conditions sit at tau=0 | |
| token_tau = torch.zeros(batch, layout.length, device=device, dtype=torch.float32) | |
| for k in range(num_blocks): | |
| token_tau[:, layout.action_slice(k)] = tau[:, k : k + 1].float() | |
| if cfg.predict_future_state: | |
| token_tau[:, layout.fstate_slice(k)] = fstate_tau[:, k : k + 1].float() | |
| t = self.time_embedding( | |
| sinusoidal_tau_embedding(token_tau.flatten(), cfg.time_dim).to(dtype) | |
| ).view(batch, layout.length, cfg.dim) | |
| e = self.time_projection(t).view(batch, layout.length, 6, cfg.dim) | |
| allow = layout.allow_matrix(device, text_mask if layout.text else None) | |
| for block in self.blocks: | |
| if cfg.gradient_checkpointing and self.training and torch.is_grad_enabled(): | |
| x = torch.utils.checkpoint.checkpoint(block, x, e, allow, use_reentrant=False) | |
| else: | |
| x = block(x, e, allow) | |
| hidden = x | |
| shift, scale = self.final_modulation(t).chunk(2, dim=-1) | |
| y = self.final_norm(x) * (1 + scale) + shift | |
| action_flow = torch.stack( | |
| [self.action_decoder(y[:, layout.action_slice(k)]) for k in range(num_blocks)], | |
| dim=1, | |
| ) | |
| fstate_flow = None | |
| if cfg.predict_future_state: | |
| fstate_flow = torch.stack( | |
| [self.fstate_decoder(y[:, layout.fstate_slice(k)]) for k in range(num_blocks)], | |
| dim=1, | |
| ) | |
| # causal coarse memory for the force stage: everything block k may read | |
| token_valid = torch.ones(batch, layout.length, dtype=torch.bool, device=device) | |
| if layout.text and text_mask is not None: | |
| token_valid[:, layout.text_slice] = text_mask.to(device, torch.bool) | |
| indices = [layout.memory_indices(k).to(device) for k in range(num_blocks)] | |
| max_len = max(int(i.numel()) for i in indices) | |
| memories, masks = [], [] | |
| for idx in indices: | |
| mem = hidden.index_select(1, idx) | |
| mask = token_valid.index_select(1, idx) | |
| valid = idx.numel() | |
| if valid < max_len: | |
| mem = F.pad(mem, (0, 0, 0, max_len - valid)) | |
| mask = F.pad(mask, (0, max_len - valid), value=False) | |
| memories.append(mem) | |
| masks.append(mask) | |
| return FactCoarseOutput( | |
| action_flow=action_flow, | |
| fstate_flow=fstate_flow, | |
| hidden=hidden, | |
| memory_by_block=torch.stack(memories, dim=1), | |
| memory_mask_by_block=torch.stack(masks, dim=1), | |
| layout=layout, | |
| ) | |
| class FactPolicy(nn.Module): | |
| """Coarse joint [action, future-state] flow + tactile suffix refinement.""" | |
| def __init__(self, cfg: FactConfig) -> None: | |
| super().__init__() | |
| self.cfg = cfg | |
| self.coarse = FactCoarseTransformer(cfg) | |
| self.force: ForceOnlyTransformer | None = None | |
| if cfg.use_force: | |
| self.force = ForceOnlyTransformer( | |
| dim=cfg.force_dim, | |
| num_heads=cfg.force_heads, | |
| num_layers=cfg.force_layers, | |
| ffn_dim=cfg.force_ffn, | |
| action_horizon=cfg.action_horizon, | |
| action_dim=cfg.action_dim, | |
| vq_codebook_size=cfg.vq_codebook_size, | |
| vq_code_dim=cfg.vq_code_dim, | |
| coarse_memory_dim=cfg.dim, | |
| ) | |
| self.schedule = TwoStageFlowSchedule( | |
| coarse_steps=cfg.coarse_steps, | |
| force_steps=cfg.force_steps, | |
| tau_split=cfg.tau_split, | |
| action_horizon=cfg.action_horizon, | |
| ) | |
| # ------------------------------------------------------------------ | |
| def device(self) -> torch.device: | |
| return self.coarse.action_encoder.weight.device | |
| def dtype(self) -> torch.dtype: | |
| return self.coarse.action_encoder.weight.dtype | |
| def _action_mask(self, like: torch.Tensor) -> torch.Tensor: | |
| mask = torch.zeros_like(like, dtype=torch.bool) | |
| mask[..., : self.cfg.physical_action_dim] = True | |
| return mask | |
| def _state_mask(self, like: torch.Tensor) -> torch.Tensor: | |
| mask = torch.zeros_like(like, dtype=torch.bool) | |
| mask[..., : self.cfg.physical_state_dim] = True | |
| return mask | |
| def _noise( | |
| clean: torch.Tensor, tau: torch.Tensor, noise: torch.Tensor | None = None | |
| ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: | |
| if noise is None: | |
| noise = torch.randn_like(clean) | |
| t = tau.to(clean.dtype) | |
| while t.ndim < clean.ndim: | |
| t = t.unsqueeze(-1) | |
| noisy = (1 - t) * clean + t * noise | |
| target = noise - clean | |
| return noisy, target, noise | |
| def _text_kwargs(self, batch: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: | |
| if not self.cfg.use_text: | |
| return {} | |
| if "text_tokens" not in batch: | |
| raise KeyError("use_text=True but the batch has no text_tokens") | |
| out = {"text_tokens": batch["text_tokens"].to(self.device, self.dtype)} | |
| if "text_mask" in batch: | |
| out["text_mask"] = batch["text_mask"].to(self.device, torch.bool) | |
| return out | |
| # ------------------------------------------------------------------ | |
| def forward( | |
| self, batch: dict[str, torch.Tensor], *, tau: torch.Tensor | None = None | |
| ) -> dict[str, torch.Tensor]: | |
| """nn.Module entry point (so DDP wrappers sync gradients): training losses.""" | |
| return self.forward_core(batch, tau=tau) | |
| def forward_core( | |
| self, batch: dict[str, torch.Tensor], *, tau: torch.Tensor | None = None | |
| ) -> dict[str, torch.Tensor]: | |
| cfg = self.cfg | |
| dev, dt = self.device, self.dtype | |
| action_clean = batch["action"].to(dev, dt) | |
| state = batch["state"].to(dev, dt) | |
| obs = batch["obs_latents"].to(dev, dt) | |
| text_kwargs = self._text_kwargs(batch) | |
| B, K = action_clean.shape[:2] | |
| if tau is None: | |
| tau = sample_trex_timestep((B, K), device=dev, dtype=dt) | |
| else: | |
| tau = tau.to(dev, dt) | |
| if tau.ndim == 1: | |
| tau = tau[:, None].expand(-1, K) | |
| amask = self._action_mask(action_clean).to(dt) | |
| action_noise = torch.randn_like(action_clean) * amask | |
| action_noisy, action_target, _ = self._noise(action_clean, tau, action_noise) | |
| fstate_clean = fstate_noisy = fstate_target = None | |
| smask = None | |
| if cfg.predict_future_state: | |
| fstate_clean = batch["future_state"].to(dev, dt) | |
| smask = self._state_mask(fstate_clean).to(dt) | |
| fstate_noisy, fstate_target, _ = self._noise( | |
| fstate_clean, tau, torch.randn_like(fstate_clean) * smask | |
| ) | |
| out = self.coarse( | |
| obs_latents=obs, | |
| state=state, | |
| action=action_noisy, | |
| tau=tau, | |
| future_state=fstate_noisy, | |
| **text_kwargs, | |
| ) | |
| action_loss = ((out.action_flow - action_target).square() * amask).sum() / amask.sum().clamp_min(1) | |
| zero = action_loss.new_zeros(()) | |
| state_loss = zero | |
| if cfg.predict_future_state: | |
| state_loss = ((out.fstate_flow - fstate_target).square() * smask).sum() / smask.sum().clamp_min(1) | |
| force_loss = vq_loss = commitment_loss = zero | |
| perplexity = active_codes = zero | |
| if self.force is not None and "force_history" in batch: | |
| current_force = batch["current_force"].to(dev, dt) # [B,K,S,10,6] | |
| history = batch["force_history"].to(dev, dt) # [B,K,S,16,10,6] | |
| valid = batch.get("force_history_valid") | |
| if valid is not None: | |
| valid = valid.to(dev, torch.bool) | |
| S = current_force.shape[2] | |
| force_tau = sample_trex_timestep((B, K, S), device=dev, dtype=dt, upper=cfg.tau_split) | |
| force_clean = action_clean[:, :, None].expand(-1, -1, S, -1, -1) | |
| noise_base = torch.randn_like(action_clean) * amask | |
| force_noisy, force_target, _ = self._noise( | |
| force_clean, force_tau, noise_base[:, :, None].expand_as(force_clean) | |
| ) | |
| split_tau = torch.full_like(tau, cfg.tau_split) | |
| action_at_split, _, _ = self._noise(action_clean, split_tau, noise_base) | |
| fstate_at_split = None | |
| if cfg.predict_future_state: | |
| fstate_at_split, _, _ = self._noise( | |
| fstate_clean, split_tau, torch.randn_like(fstate_clean) * smask | |
| ) | |
| with torch.no_grad(): | |
| split_out = self.coarse( | |
| obs_latents=obs, | |
| state=state, | |
| action=action_at_split, | |
| tau=split_tau, | |
| future_state=fstate_at_split, | |
| **text_kwargs, | |
| ) | |
| memory = ( | |
| split_out.memory_by_block.detach()[:, :, None] | |
| .expand(-1, -1, S, -1, -1) | |
| .flatten(0, 2) | |
| ) | |
| memory_mask = ( | |
| split_out.memory_mask_by_block[:, :, None] | |
| .expand(-1, -1, S, -1) | |
| .flatten(0, 2) | |
| ) | |
| offsets = torch.as_tensor(self.schedule.offsets[:S], device=dev, dtype=torch.long) | |
| offsets = offsets.view(1, 1, S).expand(B, K, -1).flatten() | |
| keep = torch.rand(B * K * S, device=dev) >= cfg.tactile_dropout | |
| fo = self.force( | |
| force_noisy.flatten(0, 2), | |
| force_tau.flatten(), | |
| current_force.flatten(0, 2), | |
| history.flatten(0, 2), | |
| coarse_memory=memory, | |
| coarse_memory_mask=memory_mask, | |
| update_offset=offsets, | |
| tactile_keep_mask=keep, | |
| tactile_history_valid_mask=None if valid is None else valid.flatten(0, 2), | |
| return_details=True, | |
| ) | |
| assert isinstance(fo, ForceOnlyOutput) | |
| pred = fo.flow.view_as(force_target) | |
| positions = torch.arange(cfg.action_horizon, device=dev).view(1, 1, 1, -1, 1) | |
| suffix = positions >= offsets.view(B, K, S, 1, 1) | |
| fmask = amask[:, :, None] * suffix | |
| force_loss = ((pred - force_target).square() * fmask).sum() / fmask.sum().clamp_min(1) | |
| vq_loss = fo.vq_loss | |
| commitment_loss = fo.commitment_loss | |
| if fo.tactile_perplexity is not None: | |
| perplexity = fo.tactile_perplexity | |
| if fo.tactile_active_codes is not None: | |
| active_codes = fo.tactile_active_codes.to(dt) | |
| loss = ( | |
| cfg.action_loss_weight * action_loss | |
| + cfg.state_loss_weight * state_loss | |
| + cfg.force_loss_weight * force_loss | |
| + cfg.vq_loss_weight * (vq_loss + cfg.vq_commitment_weight * commitment_loss) | |
| ) | |
| return { | |
| "loss": loss, | |
| "action_loss": action_loss, | |
| "state_loss": state_loss, | |
| "force_loss": force_loss, | |
| "vq_loss": vq_loss, | |
| "commitment_loss": commitment_loss, | |
| "tactile_perplexity": perplexity, | |
| "tactile_active_codes": active_codes, | |
| } | |
| # ------------------------------------------------------------------ | |
| def refine_action_suffix( | |
| self, | |
| coarse_action_at_split: torch.Tensor, | |
| coarse_memory: torch.Tensor, | |
| current_force: torch.Tensor, | |
| history: torch.Tensor, | |
| *, | |
| update_offset: int = 0, | |
| history_valid: torch.Tensor | None = None, | |
| coarse_memory_mask: torch.Tensor | None = None, | |
| tactile_keep_mask: torch.Tensor | None = None, | |
| ) -> torch.Tensor: | |
| assert self.force is not None | |
| action = pad_action_62_to_64(coarse_action_at_split).to(self.device, self.dtype).clone() | |
| action[..., self.cfg.physical_action_dim :] = 0 | |
| for step in self.schedule.iter_steps("force"): | |
| flow = self.force( | |
| action, | |
| step.tau, | |
| current_force.to(self.device, self.dtype), | |
| history.to(self.device), | |
| coarse_memory=coarse_memory, | |
| coarse_memory_mask=coarse_memory_mask, | |
| update_offset=update_offset, | |
| tactile_history_valid_mask=history_valid, | |
| tactile_keep_mask=tactile_keep_mask, | |
| ) | |
| proposed = euler_flow_step(action, flow, step.tau, step.tau_next) | |
| action[:, update_offset:] = proposed[:, update_offset:] | |
| action[..., self.cfg.physical_action_dim :] = 0 | |
| return action | |
| def sample( | |
| self, | |
| batch: dict[str, torch.Tensor], | |
| *, | |
| seed: int | None = None, | |
| mode: str = "cascade", | |
| ) -> dict[str, torch.Tensor]: | |
| """One-block open-loop sampling. | |
| mode: | |
| cascade 6 coarse Euler steps to tau=0.4, then 4 tactile steps. | |
| coarse_only the coarse action read out at tau=0.4 (the baseline | |
| eval's ``coarse_only`` semantics: no refinement). | |
| coarse_to_zero coarse model carries the flow to tau=0 (10 steps). | |
| """ | |
| cfg = self.cfg | |
| dev, dt = self.device, self.dtype | |
| state = batch["state"].to(dev, dt) | |
| obs = batch["obs_latents"].to(dev, dt) | |
| text_kwargs = self._text_kwargs(batch) | |
| if state.ndim == 2: | |
| state = state[:, None] | |
| if obs.ndim == 4: | |
| obs = obs[:, None] | |
| B = state.shape[0] | |
| if state.shape[1] != 1: | |
| raise ValueError("online sampling consumes exactly one block") | |
| gen = None | |
| if seed is not None: | |
| gen = torch.Generator(device=dev).manual_seed(seed) | |
| action = torch.randn(B, 1, cfg.action_horizon, cfg.action_dim, device=dev, dtype=dt, generator=gen) | |
| action[..., cfg.physical_action_dim :] = 0 | |
| fstate = None | |
| if cfg.predict_future_state: | |
| fstate = torch.randn(B, 1, cfg.future_state_horizon, cfg.state_dim, device=dev, dtype=dt, generator=gen) | |
| fstate[..., cfg.physical_state_dim :] = 0 | |
| def run(tau_value: float) -> FactCoarseOutput: | |
| tau = torch.full((B, 1), tau_value, device=dev, dtype=dt) | |
| return self.coarse( | |
| obs_latents=obs, state=state, action=action, tau=tau, future_state=fstate, **text_kwargs | |
| ) | |
| for step in self.schedule.iter_steps("coarse"): | |
| out = run(step.tau) | |
| action = euler_flow_step(action, out.action_flow, step.tau, step.tau_next) | |
| action[..., cfg.physical_action_dim :] = 0 | |
| if fstate is not None: | |
| fstate = euler_flow_step(fstate, out.fstate_flow, step.tau, step.tau_next) | |
| fstate[..., cfg.physical_state_dim :] = 0 | |
| out = run(cfg.tau_split) | |
| coarse_action = action[:, 0] | |
| memory = out.memory_by_block[:, 0] | |
| memory_mask = out.memory_mask_by_block[:, 0] | |
| fstate_pred = None | |
| if fstate is not None: | |
| fstate_pred = euler_flow_step(fstate, out.fstate_flow, cfg.tau_split, 0.0)[:, 0] | |
| result: dict[str, torch.Tensor] = { | |
| "coarse_action_at_split": coarse_action, | |
| "coarse_memory": memory, | |
| "coarse_memory_mask": memory_mask, | |
| } | |
| if fstate_pred is not None: | |
| result["future_state_pred"] = fstate_pred | |
| if mode == "cascade": | |
| if self.force is None: | |
| raise RuntimeError("cascade sampling requires the force stage") | |
| current_force = batch["current_force"].to(dev, dt) | |
| history = batch["force_history"].to(dev) | |
| valid = batch.get("force_history_valid") | |
| # [B,K,S,...] -> slot 0 of block 0 | |
| while current_force.ndim > 3: | |
| current_force = current_force[:, 0] | |
| while history.ndim > 4: | |
| history = history[:, 0] | |
| if valid is not None: | |
| valid = valid.to(dev, torch.bool) | |
| while valid.ndim > 2: | |
| valid = valid[:, 0] | |
| result["action_pred"] = self.refine_action_suffix( | |
| coarse_action, memory, current_force, history, | |
| update_offset=0, history_valid=valid, coarse_memory_mask=memory_mask, | |
| ) | |
| elif mode == "coarse_only": | |
| result["action_pred"] = coarse_action.clone() | |
| elif mode == "coarse_to_zero": | |
| for step in self.schedule.iter_steps("force"): | |
| out = run(step.tau) | |
| action = euler_flow_step(action, out.action_flow, step.tau, step.tau_next) | |
| action[..., cfg.physical_action_dim :] = 0 | |
| if fstate is not None: | |
| fstate = euler_flow_step(fstate, out.fstate_flow, step.tau, step.tau_next) | |
| fstate[..., cfg.physical_state_dim :] = 0 | |
| result["action_pred"] = action[:, 0] | |
| else: | |
| raise ValueError(f"unknown sampling mode {mode!r}") | |
| return result | |
| # ------------------------------------------------------------------ | |
| def save(self, directory: str | Path, extra: dict[str, Any] | None = None) -> None: | |
| directory = Path(directory) | |
| directory.mkdir(parents=True, exist_ok=True) | |
| (directory / "fact_config.json").write_text(self.cfg.to_json()) | |
| torch.save(self.state_dict(), directory / "fact_model.pt") | |
| if extra: | |
| (directory / "extra.json").write_text(json.dumps(extra, indent=1)) | |
| def load(cls, directory: str | Path, map_location: str = "cpu") -> "FactPolicy": | |
| directory = Path(directory) | |
| cfg = FactConfig.from_json((directory / "fact_config.json").read_text()) | |
| model = cls(cfg) | |
| state = torch.load(directory / "fact_model.pt", map_location=map_location) | |
| # Backward compatibility: checkpoints saved before the text prefix was | |
| # added have 5 token-type rows; the TEXT row is appended (unused). | |
| key = "coarse.type_embedding.weight" | |
| current = model.state_dict()[key] | |
| if key in state and state[key].shape[0] < current.shape[0]: | |
| padded = current.clone() | |
| padded[: state[key].shape[0]] = state[key] | |
| state[key] = padded | |
| model.load_state_dict(state, strict=True) | |
| return model | |
| def count_parameters(module: nn.Module, trainable_only: bool = False) -> int: | |
| return sum( | |
| p.numel() for p in module.parameters() if (p.requires_grad or not trainable_only) | |
| ) | |
| __all__ = [ | |
| "FactBlock", | |
| "FactCoarseOutput", | |
| "FactCoarseTransformer", | |
| "FactConfig", | |
| "FactLayout", | |
| "FactPolicy", | |
| "count_parameters", | |
| ] | |