| """PI_BEHAVIOR Model Configuration | |
| Configuration for PI_BEHAVIOR model on BEHAVIOR-1K challenge. | |
| """ | |
| import dataclasses | |
| import json | |
| import pathlib | |
| from typing import TYPE_CHECKING | |
| import flax.nnx as nnx | |
| import jax | |
| import jax.numpy as jnp | |
| from typing_extensions import override | |
| from openpi.models import model as _model | |
| from openpi.models import gemma as _gemma | |
| from openpi.shared import array_typing as at | |
| import openpi.shared.nnx_utils as nnx_utils | |
| from b1k.models.observation import Observation | |
| if TYPE_CHECKING: | |
| from b1k.models.pi_behavior import PiBehavior | |
| # Per-task stage counts (based on avg_episode_length / 900, capped between 5-15) | |
| # Use tuple for immutability and to avoid JAX device allocation at import time | |
| TASK_NUM_STAGES = ( | |
| 5, 6, 15, 15, 14, 12, 9, 15, 10, 15, # Tasks 0-9 | |
| 7, 13, 10, 15, 15, 15, 15, 11, 13, 12, # Tasks 10-19 | |
| 14, 15, 9, 15, 15, 15, 15, 15, 15, 15, # Tasks 20-29 | |
| 11, 10, 10, 13, 5, 5, 14, 6, 8, 10, # Tasks 30-39 | |
| 5, 15, 8, 15, 12, 11, 9, 14, 15, 15, # Tasks 40-49 | |
| 15, 9, 12, 14, 13, 11, 6, 5, 15, 7, # Tasks 50-59 | |
| 5, 13, 8, 5, 15, 13, 12, 8, 14, 5, # Tasks 60-69 | |
| 10, 15, 10, 15, 13, 13, 11, 5, 5, 12, # Tasks 70-79 | |
| 10, 10, 9, 8, 15, 14, 11, 12, 15, 5, # Tasks 80-89 | |
| 5, 11, 5, 5, 15, 15, 6, 15, 15, 9, # Tasks 90-99 | |
| ) | |
| MAX_NUM_STAGES = 15 # Maximum stages per task | |
| TOTAL_TASK_STAGE_EMBEDDINGS = sum(TASK_NUM_STAGES) # 1120 with the 100-task table | |
| # Cumulative offsets for indexing into task_stage_embeddings (as tuple) | |
| TASK_STAGE_OFFSETS = tuple([0] + [sum(TASK_NUM_STAGES[:i+1]) for i in range(len(TASK_NUM_STAGES) - 1)]) | |
| class B1KDA3Config: | |
| """DA3 spatial-language branch for PiBehavior (inline extraction; b1k cameras are square).""" | |
| enabled: bool = True | |
| num_views: int = 3 # zed head (main), left/right realsense (wrist branches) | |
| da3_channels: int = 1536 # GIANT embed dim | |
| da3_layers: int = 4 # out_layers (19, 26, 33, 39) | |
| grid_hw: tuple[int, int] = (16, 16) # 224x224 square DA3 input / patch 14 (= the data's native res) | |
| hidden_dim: int = 1024 # == action-expert width | |
| lang_dim: int = 1024 # ModernBERT-large (task-name embeddings) | |
| lang_max_len: int = 32 | |
| num_heads: int = 8 | |
| # 0 = OFF (default). Language enters the bank as a per-TASK embedding, so in single-task | |
| # fine-tuning it is identical for every sample and the stack can only inject a CONSTANT -- | |
| # for 63.5% of the bank builder's parameters (75.6 M of 119.1 M). Set back to 2 for genuine | |
| # multi-task training, where the per-task signal actually varies within a batch. | |
| lang_fusion_depth: int = 0 | |
| num_inject_layers: int = 6 # last 6 of 18 action-expert blocks | |
| spatial_scale: float = 2.0 | |
| # V2 "force-spatial-on" defaults (now that geometry is CORRECT). Zero-init lets the model learn to | |
| # IGNORE spatial (image path fits first, no gradient left to turn the injection on). Nonzero init + | |
| # per-head logit-gain keep the injection ACTIVE and the attention SHARP/learnable from step 0, so the | |
| # model must account for the (now-sane) banks. This only hurt before because geometry was garbage. | |
| spatial_init_std: float = 0.01 | |
| attn_logit_gain: bool = True | |
| attn_logit_gain_init: float = 3.0 # retuned for qk_norm: 20 eff tokens of 324 | |
| attn_logit_gain_max: float = 8.0 # gain 8 -> 2.5 eff tokens; hard ceiling | |
| bank_token_embed: bool = True | |
| perceiver_query_std: float = 0.05 | |
| # Perceiver-collapse fixes. Default False preserves the arch of existing checkpoints. | |
| # root cause: random-init queries -> q.k ~ 0 -> near-uniform softmax over 432 patches | |
| # -> every query reads the same mean(V) AND dL/dQ,K is starved (~1/432) so queries never | |
| # train; the shared output (||.||~500) then swamps query identity (||q||~1.6) ~300:1. | |
| perceiver_logit_gain: bool = False # sharpen attention at init -> diverse reads + live Q/K grads | |
| # --- 2026-07-22 attention-saturation fixes (see DA3_ATTENTION_SATURATION.md) --- | |
| qk_norm: bool = True # per-head RMSNorm on Q,K before the dot product | |
| perceiver_norm_out: bool = True # LayerNorm the perceiver output (was amplifying x1900) | |
| pos_emb_scale: float = 0.25 # constant pos_emb was rms 5.03 vs signal 4.38 | |
| perceiver_logit_gain_init: float = 3.0 # retuned for qk_norm (was 8 -> 2.5 eff tokens) | |
| perceiver_logit_gain_max: float = 8.0 | |
| perceiver_norm_attn_out: bool = False # LN attn-out before residual -> query identity survives | |
| # --- 2026-07-23 constant-collapse fix (see b1k-da3-frozenbase-verdict) --- | |
| # The bank was measured ~90% learned-constant (view/pos/lang/bank_token embeds) vs ~10% per-sample | |
| # DA3 content; the frozen base latched onto the constant (net-harmful: zeroing the bank cut loss 92%) | |
| # and never used geometry (shuffling banks across samples moved loss +0.2%). bank_center projects out | |
| # the batch-mean so a constant injects EXACTLY zero -- only per-sample deviation survives, forcing the | |
| # model to use geometry or nothing. NOTE: like batchnorm, needs bs>1; deploy at bs=1 needs an EMA of | |
| # the mean (TODO) -- the current-batch projection is for the "does geometry get used" experiment. | |
| bank_center: bool = False | |
| # --- 2026-07-23 aux geometry loss --- | |
| # Decode the perceiver token output back to per-patch log-depth (grid-pos queries attend the K | |
| # perceiver tokens). MSE against the DA3 depth FORCES the perceiver output to carry per-sample | |
| # geometry regardless of the action loss's incentive -- the guaranteed fix for "geometry unused". | |
| aux_geom_head: bool = False # build the decoder head | |
| aux_geom_weight: float = 0.0 # weight of the log-depth MSE in the total loss | |
| # Zero the log-depth INPUT channel (ray7 ch 6) so depth is target-only. Without this the aux task | |
| # is circular (depth in -> depth out, a trivial autoencoder); with it, predicting depth REQUIRES | |
| # reading it out of the DA3 features. Shape-compatible (channel zeroed, not removed). | |
| depth_target_only: bool = False | |
| # --- 2026-07-23 K/V split (address/payload separation in the perceiver) --- | |
| # payload (values) = DA3 latents + depth encoding; address (keys only) = pos_emb + ray_emb + | |
| # view_emb. Addresses steer routing but are structurally excluded from the value stream, so an | |
| # input-independent constant can no longer flow into (and dominate) the bank. depth_dropout | |
| # zeroes the depth encoding for that fraction of training samples so the DA3 features must carry | |
| # geometry redundantly. NOTE: kv_split changes the spatial arch (ray_mlp 7ch -> 6ch + depth_mlp); | |
| # spatial params are NOT checkpoint-compatible across this flag. | |
| kv_split: bool = False | |
| depth_dropout: float = 0.0 | |
| # --- 2026-07-24 spatial-bank upgrades --- | |
| # perc_locality: anchor each perceiver query to a grid region with a learnable -gamma*dist^2 logit | |
| # bias, so tokens are LOCAL descriptors (fixes over-averaging) instead of global scene means. | |
| # cross_view: after the per-view perceivers, add a camera-pose embed and self-attend across the | |
| # concatenated view tokens so the three views fuse into one 3D scene (then split back per view). | |
| # Perceiver downsampler. OFF by default: at 224 the grid is 16x16=256/view and the | |
| # perceiver compressed only 2:1 (designed for 3.4:1) for 25.5 M params, while being the | |
| # measured collapse mechanism (K constant queries + near-uniform attention -> all tokens | |
| # read mean(V)). With it off the bank is the patch grid: 256/view = 768 total, per-sample | |
| # by construction. True restores the old 128/96/96 path exactly. | |
| # --- spatial conditioning: bind WHERE (Fourier 3D position) to WHAT (DA3 latent) --- | |
| # token = LN(W[ s ; (1+gamma(s))*fused + beta(s) ]), s = MLP(spatial vector). | |
| # Fixes the kv_split defect where direction (ray) sat in the keys and magnitude (depth) in | |
| # the values, so no bank token could represent a position at all. | |
| spatial_vec: bool = True | |
| spatial_film: bool = True # multiplicative what-x-where term; gamma/beta zero-init | |
| spatial_use_da3: bool = True # False => geometry-only bank (the no-extractor ablation) | |
| fourier_bands: int = 10 # ~5 mm finest band at a 1.3 m half-range | |
| # MEASURED over 18,109 patch-points (4 tasks x 6 frames x 3 views). Per-axis centre, | |
| # isotropic scale. TODO ship these in the assets next to norm_stats.json -- a train/eval | |
| # mismatch shifts all geometry silently. | |
| point_centre: tuple[float, float, float] = (1.032, 0.527, 1.064) | |
| point_centre_ee_l: tuple[float, float, float] = (0.811, 0.278, 0.332) | |
| point_centre_ee_r: tuple[float, float, float] = (0.268, 0.895, 0.154) | |
| point_scale: float = 1.30 | |
| point_max_depth: float = 5.0 | |
| use_perceiver: bool = False | |
| perc_locality: bool = False | |
| cross_view: bool = False | |
| cross_view_depth: int = 2 | |
| bank_token_embed_query: bool = True # False = old post-fusion placement (faithful eval of old ckpts) | |
| # --- VGGT-Omega enrichments (v2): extra bank inputs harvested from the VGGT forward; all no-ops | |
| # unless the loader is the VGGT extractor (which supplies da3_depth_conf/pose_enc/cam_tokens). --- | |
| use_depth_conf: bool = False # add VGGT depth confidence as a payload reliability channel | |
| use_pose_enc: bool = False # add VGGT pose encoding to the cross-view camera feature | |
| use_cam_tokens: bool = False # append VGGT camera+register tokens as global bank tokens | |
| cam_token_dim: int = 2048 # channel width of da3_cam_tokens (VGGT 2*embed_dim) | |
| pose_enc_dim: int = 9 # VGGT pose_enc width (trans3+quat4+fov2) | |
| feat_input_norm: bool = False # LayerNorm raw backbone feats before projection (tames VGGT outliers) | |
| use_point_map: bool = False # metric 3D point map (ray x depth) into the payload (exploits GT depth) | |
| depth_aware_crossview: bool = False # per-token world-3D position into cross-view fusion | |
| # --- 2026-08-06 EE-anchored perceiver queries --- | |
| # The grid locality anchors are FIXED, so the bank summarizes the whole scene uniformly and can be | |
| # compressed into something near-constant per task. Geometry, however, only matters where the hands | |
| # are. The left/right realsense cameras are WRIST-mounted, so their camera centre (from the | |
| # robot2cam extrinsics) IS the end-effector position in the robot frame -- no forward kinematics | |
| # needed. ee_query_frac of each view's queries are re-anchored onto those two 3D points via a | |
| # per-sample -gamma*||p_patch - p_ee||^2 logit bias (metric, in the robot frame), so the bank is | |
| # STRUCTURALLY per-sample: a fixed grid can be averaged away, a hand-following read cannot. | |
| ee_anchor: bool = False | |
| ee_query_frac: float = 0.25 # fraction of each view's perceiver queries re-anchored to the EEs | |
| ee_gamma_init: float = 4.0 # init of the learnable per-head gamma (metres^-2) | |
| ee_max_dist2: float = 25.0 # clamp on ||p-p_ee||^2 so invalid/far depth cannot produce -inf bias | |
| # --- 2026-08-06 InfoNCE bank<->geometry specificity loss --- | |
| # Shuffle-damage was measured at a flat +3-4% for 40k steps: the bank is READ but used generically. | |
| # Nothing in the objective ever rewarded per-sample specificity -- it was only ever measured. This | |
| # trains it directly: the pooled bank of sample i must be identifiable against sample i's pooled | |
| # metric point map among all other samples in the batch (symmetric CLIP-style InfoNCE). | |
| infonce: bool = False | |
| infonce_weight: float = 0.0 # keep small (~0.01-0.05); this is a shaping term, not the objective | |
| infonce_temp: float = 0.07 | |
| infonce_dim: int = 128 # projection width for both sides | |
| infonce_pool_k: int = 16 # per-view 3D points pooled as the geometry target | |
| class PiBehaviorConfig(_model.BaseModelConfig): | |
| dtype: str = "bfloat16" | |
| paligemma_variant: _gemma.Variant = "gemma_2b" | |
| action_expert_variant: _gemma.Variant = "gemma_300m" | |
| # Set the model specific defaults. | |
| action_dim: int = 32 | |
| action_horizon: int = 30 | |
| max_token_len: int = 200 # Only used for compatibility, not for actual tokenization | |
| # Number of tasks in the behavior dataset | |
| num_tasks: int = 50 | |
| # Task embedding dimension - will match the paligemma width | |
| task_embedding_dim: int = None # type: ignore | |
| # Maximum number of subtask states across all tasks | |
| max_num_subtask_states: int = MAX_NUM_STAGES | |
| # Path to task data JSON file for initialization | |
| task_data_path: str = "b1k/BEHAVIOR-1K/docs/challenge/task_data.json" | |
| # Whether to use correlated noise matching action covariance structure | |
| # Requires correlation matrix in norm_stats (computed by compute_norm_stats.py) | |
| use_correlated_noise: bool = True | |
| # Shrinkage parameter for correlation regularization | |
| # Applied as: S_regularized = beta * S + (1-beta) * I | |
| # beta=1.0 means full correlation (no shrinkage) | |
| # beta=0.7 means 70% correlation + 30% independence (recommended for robustness) | |
| # beta=0.0 means independence (no correlation) | |
| correlation_beta: float = 0.5 | |
| # FAST auxiliary training configuration | |
| use_fast_auxiliary: bool = False # Enable FAST during training | |
| fast_loss_weight: float = 0.1 # Weight for FAST loss (vs flow loss) | |
| # Action dimensions to encode with FAST (default: 0:6, 7:23 = 22 dims) | |
| # Format: "0:6,7:23" or list of tuples [(0, 6), (7, 23)] | |
| fast_encoded_dims: str | list[tuple[int, int]] = "0:6,7:23" | |
| # FAST tokenizer vocab size | |
| fast_vocab_size: int = 1024 | |
| # Max FAST tokens to predict (truncate if exceeded) | |
| max_fast_tokens: int = 32 | |
| # FAST tokenizer path (set during initialization, relative to assets_dir/asset_id) | |
| fast_tokenizer_path: str | None = None | |
| # KV cache transformation for cross-layer attention between VLM and action expert | |
| # Allows each action expert layer to attend to a learned combination of all VLM layers | |
| use_kv_transform: bool = True | |
| # Knowledge insulation: stop action expert gradients from flowing to VLM backbone | |
| # VLM trains on FAST tokens only, action expert on flow matching with frozen VLM features | |
| # Implements approach from https://www.physicalintelligence.company/research/knowledge_insulation | |
| use_knowledge_insulation: bool = True | |
| # Subtask/stage prediction auxiliary loss weight (relative to action loss) | |
| # Higher values emphasize stage prediction accuracy at the expense of action quality | |
| subtask_loss_weight: float = 0.1 | |
| # Time threshold for inpainting during inference | |
| # Stop enforcing inpainting constraint when t < threshold (let model be free in final steps) | |
| time_threshold_inpaint: float = 0.3 | |
| # Vision backbone finetuning control | |
| freeze_vision_backbone: bool = True | |
| # DA3 spatial-language adapter. The DA3/ModernBERT branch is computed | |
| # offline and supplied as tokens in Observation.spatial_tokens. | |
| use_spatial_action_cross_attention: bool = False | |
| spatial_token_dim: int = 1024 | |
| spatial_num_tokens: int = 320 # perceiver bank 128+96+96; with use_perceiver=False the bank is 3 x grid (768 at 16x16) | |
| spatial_num_heads: int = 8 | |
| spatial_residual_scale: float = 1.0 | |
| # Full DA3 spatial-language branch (supersedes the flat spatial_tokens adapter above): | |
| # frozen DA3-GIANT runs INLINE in the data pipeline; the trainable bank builder + method-B | |
| # cross-attention injection (action-expert layers 12-17) live in the model. Proven on RoboReal. | |
| da3: "B1KDA3Config | None" = None | |
| def __post_init__(self): | |
| if self.task_embedding_dim is None: | |
| paligemma_config = _gemma.get_config(self.paligemma_variant) | |
| object.__setattr__(self, "task_embedding_dim", paligemma_config.width) | |
| def get_fast_dim_ranges(self) -> list[tuple[int, int]]: | |
| """Parse fast_encoded_dims into list of ranges.""" | |
| if isinstance(self.fast_encoded_dims, str): | |
| ranges = [] | |
| for range_str in self.fast_encoded_dims.split(','): | |
| start, end = map(int, range_str.strip().split(':')) | |
| ranges.append((start, end)) | |
| return ranges | |
| return self.fast_encoded_dims | |
| def get_total_fast_dims(self) -> int: | |
| """Get total number of dimensions encoded by FAST.""" | |
| return sum(end - start for start, end in self.get_fast_dim_ranges()) | |
| def model_type(self): | |
| return "pi_behavior" | |
| def create(self, rng: at.KeyArrayLike) -> "PiBehavior": | |
| from b1k.models.pi_behavior import PiBehavior | |
| return PiBehavior(self, rngs=nnx.Rngs(rng)) | |
| def inputs_spec(self, *, batch_size: int = 1) -> tuple["Observation", _model.Actions]: | |
| image_spec = jax.ShapeDtypeStruct([batch_size, *_model.IMAGE_RESOLUTION, 3], jnp.float32) | |
| image_mask_spec = jax.ShapeDtypeStruct([batch_size], jnp.bool_) | |
| with at.disable_typechecking(): | |
| obs_kwargs = { | |
| "images": { | |
| "base_0_rgb": image_spec, | |
| "left_wrist_0_rgb": image_spec, | |
| "right_wrist_0_rgb": image_spec, | |
| }, | |
| "image_masks": { | |
| "base_0_rgb": image_mask_spec, | |
| "left_wrist_0_rgb": image_mask_spec, | |
| "right_wrist_0_rgb": image_mask_spec, | |
| }, | |
| "state": jax.ShapeDtypeStruct([batch_size, self.action_dim], jnp.float32), | |
| "tokenized_prompt": jax.ShapeDtypeStruct([batch_size, 2], jnp.int32), | |
| "tokenized_prompt_mask": jax.ShapeDtypeStruct([batch_size, 2], bool), | |
| } | |
| if self.use_fast_auxiliary: | |
| obs_kwargs["fast_tokens"] = jax.ShapeDtypeStruct([batch_size, self.max_fast_tokens], jnp.int32) | |
| obs_kwargs["fast_token_mask"] = jax.ShapeDtypeStruct([batch_size, self.max_fast_tokens], bool) | |
| if self.da3 is not None and self.da3.enabled: | |
| d = self.da3 | |
| gh, gw = d.grid_hw | |
| obs_kwargs["da3_features"] = jax.ShapeDtypeStruct( | |
| [batch_size, d.da3_layers, d.num_views, d.da3_channels, gh, gw], jnp.uint16 | |
| ) | |
| obs_kwargs["da3_ray"] = jax.ShapeDtypeStruct([batch_size, d.num_views, 3, gh, gw], jnp.float32) | |
| obs_kwargs["da3_depth"] = jax.ShapeDtypeStruct([batch_size, d.num_views, 1, gh, gw], jnp.float32) | |
| obs_kwargs["camera_extrinsics"] = jax.ShapeDtypeStruct([batch_size, d.num_views, 4, 4], jnp.float32) | |
| obs_kwargs["lang_feat"] = jax.ShapeDtypeStruct([batch_size, d.lang_max_len, d.lang_dim], jnp.float32) | |
| obs_kwargs["lang_mask"] = jax.ShapeDtypeStruct([batch_size, d.lang_max_len], bool) | |
| if self.use_spatial_action_cross_attention: | |
| obs_kwargs["spatial_tokens"] = jax.ShapeDtypeStruct( | |
| [batch_size, self.spatial_num_tokens, self.spatial_token_dim], | |
| jnp.float32, | |
| ) | |
| obs_kwargs["spatial_token_mask"] = jax.ShapeDtypeStruct( | |
| [batch_size, self.spatial_num_tokens], | |
| bool, | |
| ) | |
| observation_spec = Observation(**obs_kwargs) | |
| action_spec = jax.ShapeDtypeStruct([batch_size, self.action_horizon, self.action_dim], jnp.float32) | |
| return observation_spec, action_spec | |