FACT / fact /model.py
zhicao's picture
Keep model code and checkpoints only
ae1feed verified
Raw History Blame Contribute Delete
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)
@dataclass
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
@property
def obs_tokens(self) -> int:
h, w = self.obs_grid
return (h // self.obs_patch) * (w // self.obs_patch)
@property
def obs_in_dim(self) -> int:
return self.obs_channels * self.obs_patch * self.obs_patch
@property
def fstate_tokens(self) -> int:
return self.future_state_horizon if self.predict_future_state else 0
@property
def tokens_per_block(self) -> int:
return self.obs_tokens + 1 + self.action_horizon + self.fstate_tokens
@property
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)
@classmethod
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
@property
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
@dataclass
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,
)
# ------------------------------------------------------------------
@property
def device(self) -> torch.device:
return self.coarse.action_encoder.weight.device
@property
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
@staticmethod
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,
}
# ------------------------------------------------------------------
@torch.no_grad()
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
@torch.no_grad()
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))
@classmethod
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",
]