JackLiu0406 commited on
Commit
2bda127
·
verified ·
1 Parent(s): 62ab3a8

Add source_code/ training snapshot + depth/ray provenance + reference distributions

Browse files
pibehavior_da3_clean_up_your_desk_40k/source_code/README.md ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # source_code — training snapshot for `pibehavior_da3_clean_up_your_desk_40k`
2
+
3
+ Exact source used to produce the checkpoint, so eval doesn't have to reconstruct architecture from
4
+ Orbax metadata. **Read the "Answers" section first — it settles the train/eval mismatch questions.**
5
+
6
+ ---
7
+
8
+ ## Answers to the questions that matter
9
+
10
+ ### 1. Where do `da3_depth` and `da3_ray` come from? (the critical one)
11
+ **Both are DA3 model predictions. Neither comes from the simulator.**
12
+
13
+ - **`da3_depth` = DA3's own predicted METRIC depth.** From the Nested-GIANT `da3_metric` branch
14
+ (`out["depth"]`, `out["is_metric"]`) — see `da3/da3_for_geostack.py`.
15
+ - **`da3_ray` = DA3's ray-head output**, captured by a forward hook on the final aux conv of DA3's
16
+ DualDPT. Direction-only, unit-normalized (verified: per-pixel L2 norm = 1.0000).
17
+ - **The simulator's `depth_linear` is NEVER fed to the model.** It was used *only offline*, to
18
+ calibrate the extrinsics convention (that is how the GL2CV flip + wxyz + camera-in-robot ordering
19
+ were derived). **Do not feed `depth_linear` at eval** — run the same DA3 forward and use its outputs.
20
+ - `use_ray_pose=False`: the extrinsics/intrinsics drive the **camera encoder (`cam_enc`)** that
21
+ conditions the features, *not* the ray head. This is why wrong poses corrupt the **features**
22
+ themselves rather than merely an auxiliary channel.
23
+
24
+ ⚠️ **Silent-zeros trap.** `da3_extractor.py` contains:
25
+ ```python
26
+ depth = out["depth"]
27
+ if depth is None:
28
+ depth = torch.zeros(...) # silently all-zeros
29
+ ```
30
+ If your wrapper fails to surface depth you get **zeros with no error**. We verified this fallback was
31
+ **NOT** active in training (`ALL_ZERO=False`, `frac_zero=0.0000`). Assert on this at eval.
32
+
33
+ ### 2. Reference distributions — validate your eval against these
34
+ Measured on **real b1k frames with real `robot2cam_pose`**, batch of 4, `clean_up_your_desk`:
35
+
36
+ ```
37
+ da3_features (B,4,3,1536,18,18) uint16 # uint16 = bf16 BITS, not floats
38
+ da3_ray (B,3,3,18,18) float32 L2 norm per pixel = 1.0000
39
+ da3_depth (B,3,1,18,18) float32
40
+ min=0.2110 max=2.0765 mean=0.9492 std=0.3715
41
+ p1=0.309 p25=0.617 p50=0.979 p75=1.244 p99=1.782
42
+ per-view depth mean: head 1.310 (0.81–2.08) | left_wrist 0.723 (0.21–1.48) | right_wrist 0.815 (0.37–1.59)
43
+ ```
44
+ **Sanity checks for your eval:**
45
+ - depth must be **non-zero** and roughly **0.2–2.1 m**
46
+ - **head depth > wrist depth** (head views the scene; wrists sit near manipulated objects).
47
+ If this ordering inverts, your **view order is wrong** (must be `head, left_wrist, right_wrist`).
48
+ - ray L2 norm must be **1.0**. If not, you're feeding the wrong tensor.
49
+ - On structureless input DA3 degenerates to near-constant depth (~0.93, std 0.006) — if you see that
50
+ on real frames, images aren't reaching the extractor.
51
+
52
+ ### 3. Bank tokens are 320 = 128 + 96 + 96
53
+ `pi_behavior_config.py`: `spatial_num_tokens: int = 320 # DA3 perc bank default: 128 + 96 + 96`.
54
+ (Not 32/24/24.)
55
+
56
+ ### 4. Feature wire format
57
+ bf16 bit-packed into uint16 (torch and JAX share no bf16 interchange dtype):
58
+ ```python
59
+ # torch: feats.to(torch.bfloat16).view(torch.uint16)
60
+ # jax: jax.lax.bitcast_convert_type(feats, jnp.bfloat16)
61
+ ```
62
+ **Do not cast to float32** — the model branches on dtype (see `PiBehavior._compute_banks`).
63
+
64
+ ---
65
+
66
+ ## Contents
67
+
68
+ ```
69
+ model/ pi_behavior.py policy + _compute_banks + spatial action conditioning
70
+ pi_behavior_config.py B1KDA3Config (spatial_num_tokens=320, init_std, logit_gain...)
71
+ spatial_da3.py bank builder + cross-attention injection
72
+ observation.py Observation dataclass (da3_features / da3_ray / da3_depth fields)
73
+ training/ da3_extractor.py DA3InlineExtractor <-- produces the DA3 tensors
74
+ b1k_da3.py dataset + da3_fields() (extrinsics/intrinsics) + batch_transform
75
+ b1k_2026.py BehaviorV3Dataset (video decode)
76
+ train.py multi-group LR (vlm/core/geom) + train step
77
+ train_2026.py entrypoint / env -> config
78
+ _da3_contrib_check.py bank-ablation sanity check (RUN THIS, see below)
79
+ openpi/ gemma.py spatial injection into last 6 of 18 action-expert layers
80
+ data_loader.py collator + DLPack/async staging
81
+ optimizer.py LR schedules
82
+ sharding.py B1K_HOLDOUT_GPU0 mesh holdout
83
+ weight_loaders.py PiBehaviorWeightLoader (param key merge)
84
+ da3/ da3_for_geostack.py DA3 wrapper: forward_multi_view, ray hook, depth surfacing
85
+ launch/ launch_b1k_robopro.sh EXACT env/hyperparameters that produced this checkpoint
86
+ ```
87
+
88
+ **DA3 upstream:** `Depth-Anything-3` @ commit **`4173623`**, sources at `<repo>/src`, loaded via
89
+ `_DA3_SRC` in `da3_extractor.py`. Weights: `depth-anything/DA3NESTED-GIANT-LARGE-1.1`,
90
+ `out_layers=(19,26,33,39)`, `patch_size=14`, `use_bf16=True`, frozen (`no_grad()` + `eval()`).
91
+
92
+ ## Verify your integration before trusting results
93
+ Run the **bank ablation** (`training/_da3_contrib_check.py`): compute action loss normally, then with
94
+ the spatial banks zeroed. On this checkpoint the gap is large (at 14k it was 0.301 → 4.300, **+1329%**).
95
+ **A near-zero gap means the spatial branch isn't actually engaged** — and note the model *silently
96
+ falls back to base behavior* when `da3_features` is `None`, so a wiring bug looks like "DA3 doesn't
97
+ help", not like a crash.
98
+
99
+ ## Training configuration (as shipped in `launch/`)
100
+ ```
101
+ BS=128 FLOW=15 STEPS=50000 warmup=1000 decay=50000
102
+ LR: vlm 2.5e-5->2.5e-6 | core 5e-4->5e-5 | geom 5e-4->1e-4 (DA3_LR_GROUPS=1)
103
+ DA3: spatial_init_std=0.01 attn_logit_gain=1 (init 32.0) spatial_scale=2.0
104
+ da3_hw=(252,252) -> 18x18 grid; VLM images 224x224; views: head, left_wrist, right_wrist
105
+ ```
106
+ `spatial_init_std` is deliberately **non-zero**: with zero-init the model learned to *ignore* the
107
+ spatial branch entirely.
pibehavior_da3_clean_up_your_desk_40k/source_code/da3/da3_for_geostack.py ADDED
@@ -0,0 +1,639 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX: same as DA3-XVLA repo
2
+ """
3
+ DA3-Large frozen feature extractor for GeoStack-XVLA v2B.
4
+
5
+ This wrapper around `depth_anything_3.api.DepthAnything3`:
6
+ * Loads DA3-Large-1.1 and freezes ALL parameters.
7
+ * Receives ImageNet-normalized RGB at any input dimensions (typically the
8
+ already-square 224×224 that the X-VLA processor produces for Florence).
9
+ * Bilinearly resizes to the configured (da3_input_h, da3_input_w) — typically
10
+ a 4:3-aspect rectangle like 252×336 that recovers the original aspect after
11
+ Florence's stretch. NO PADDING — both dims must be multiples of patch_size.
12
+ * Returns a list of N feature levels at the configured DPT taps (default
13
+ out_layers=[11, 19, 23] for DA3-Large = shallow, mid, deep), each as
14
+ [B, C, h_grid, w_grid] with (h_grid, w_grid) = (input_h/14, input_w/14).
15
+ * Returns the DA3 ray-head output (3D direction unit vector per pixel),
16
+ captured via a forward hook on `head.scratch.output_conv2_aux[-1]` and
17
+ resampled to (h_grid, w_grid).
18
+
19
+ Spatial-bias coordinate alignment:
20
+ * Florence sees a stretched 224×224 → its token-grid (e.g. 7×7) maps linearly
21
+ back to the ORIGINAL image's normalized [0,1]² coords (no aspect correction
22
+ needed; the stretch is bijective per axis).
23
+ * DA3 sees the same content but at the aspect-correct dims (e.g. 252×336)
24
+ → its token-grid (e.g. 18×24) ALSO maps linearly to the ORIGINAL image's
25
+ normalized [0,1]² coords.
26
+ * Both grids therefore live in the same normalized coord space, and the
27
+ spatial-distance bias `-λ · ||pos_vlm - pos_da3||²` is well-defined.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from typing import Dict, List, Tuple
32
+
33
+ import logging
34
+
35
+ import torch
36
+ import torch.nn as nn
37
+ import torch.nn.functional as F
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # DA3-Large fallback (used if hub model has no `backbone.out_layers` attribute).
42
+ _DA3_LARGE_OUT_LAYERS = (11, 15, 19, 23)
43
+ _DA3_LARGE_EMBED_DIM = 1024
44
+
45
+
46
+ class DA3LargeForGeoStack(nn.Module):
47
+ """Frozen DA3-Large feature + ray-head extractor for GeoStack.
48
+
49
+ Usage:
50
+ da3 = DA3LargeForGeoStack(
51
+ model_name="depth-anything/DA3-Large-1.1",
52
+ out_layers=(11, 19, 23),
53
+ da3_input_h=252, da3_input_w=336, # aspect-correct 4:3 (~1.05× of 240×320)
54
+ )
55
+ # pixel_values: [B, 3, H_in, W_in] in ImageNet-normalized space
56
+ # (typically H_in=W_in=224 — the X-VLA processor's stretched square).
57
+ out = da3(pixel_values)
58
+ # out["feats"]: list[N] of [B, C, h_grid, w_grid]
59
+ # out["ray"]: [B, 3, h_grid, w_grid] (direction-only, unit-normalized)
60
+ # out["h_grid"]: int
61
+ # out["w_grid"]: int
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ model_name: str = "depth-anything/DA3-Large-1.1",
67
+ out_layers: Tuple[int, ...] = (11, 19, 23),
68
+ da3_input_h: int = 252,
69
+ da3_input_w: int = 336,
70
+ patch_size: int = 14,
71
+ use_bf16: bool = True,
72
+ ):
73
+ super().__init__()
74
+ try:
75
+ from depth_anything_3.api import DepthAnything3
76
+ except Exception as exc:
77
+ raise ImportError(
78
+ "depth_anything_3 not installed; install third_party/Depth-Anything-3"
79
+ ) from exc
80
+
81
+ self.model_name = str(model_name)
82
+ self.out_layers = tuple(int(i) for i in out_layers)
83
+ self.patch_size = int(patch_size)
84
+ self.use_bf16 = bool(use_bf16)
85
+ self.da3_input_h = int(da3_input_h)
86
+ self.da3_input_w = int(da3_input_w)
87
+ if self.da3_input_h % self.patch_size != 0 or self.da3_input_w % self.patch_size != 0:
88
+ raise ValueError(
89
+ f"DA3 input dims must be multiples of patch_size={self.patch_size}; "
90
+ f"got ({self.da3_input_h}, {self.da3_input_w})"
91
+ )
92
+ self.h_grid = self.da3_input_h // self.patch_size # e.g. 252/14 = 18
93
+ self.w_grid = self.da3_input_w // self.patch_size # e.g. 336/14 = 24
94
+
95
+ # Load DA3 + freeze
96
+ self.model = DepthAnything3.from_pretrained(self.model_name)
97
+ for p in self.model.parameters():
98
+ p.requires_grad_(False)
99
+ self.model.eval()
100
+
101
+ # Channel dim: introspect the loaded backbone rather than assuming
102
+ # DA3-Large. DA3-Large=1024, DA3NESTED-GIANT-LARGE-1.1=1536, etc.
103
+ # Downstream perceiver/projection is sized from self.embed_dim, so this
104
+ # MUST match the real feature width or K/V dims mismatch.
105
+ self.embed_dim = self._infer_embed_dim()
106
+
107
+ # Ray-head hook capture (after DA3's DualDPT output_conv2_aux).
108
+ self._ray_capture: Dict[str, torch.Tensor] = {}
109
+ self._register_ray_hook()
110
+
111
+ # v4.1 OPT-IN: capture depth output (main head) via a second hook on
112
+ # head.scratch.output_conv2. Off by default — only enabled when the
113
+ # depth-distillation aux head is configured.
114
+ self._depth_capture: Dict[str, torch.Tensor] = {}
115
+ self._depth_hook = None
116
+ self._capture_depth = False
117
+
118
+ # ---------- backbone feature-dim introspection (GIANT-safe) ----------
119
+
120
+ def _infer_embed_dim(self) -> int:
121
+ """Read the DINOv2 backbone hidden width from the loaded DA3 model.
122
+
123
+ Walks common attribute paths to a `.embed_dim`/`.num_features`; falls
124
+ back to inspecting a transformer block's LayerNorm, then to the
125
+ DA3-Large constant. DA3-Large=1024, DA3NESTED-GIANT-LARGE-1.1=1536.
126
+ """
127
+ obj = self.model
128
+ for path in (("model", "da3", "backbone", "pretrained"),
129
+ ("da3", "backbone", "pretrained"),
130
+ ("backbone", "pretrained"),
131
+ ("backbone",)):
132
+ cur = obj
133
+ ok = True
134
+ for a in path:
135
+ if hasattr(cur, a):
136
+ cur = getattr(cur, a)
137
+ else:
138
+ ok = False
139
+ break
140
+ if ok:
141
+ for a in ("embed_dim", "num_features", "hidden_size", "n_embd"):
142
+ if hasattr(cur, a):
143
+ try:
144
+ return int(getattr(cur, a))
145
+ except Exception:
146
+ pass
147
+ # last-ditch: infer from a block's norm weight length
148
+ for n, p in self.model.named_parameters():
149
+ if "blocks.0." in n and n.endswith("norm1.weight") and p.ndim == 1:
150
+ return int(p.shape[0])
151
+ logger.warning("[da3_for_geostack] could not introspect embed_dim; "
152
+ "falling back to DA3-Large=%d", int(_DA3_LARGE_EMBED_DIM))
153
+ return int(_DA3_LARGE_EMBED_DIM)
154
+
155
+ # ---------- post-load reload of DA3 weights ----------
156
+
157
+ def reload_pretrained_weights(self) -> None:
158
+ """Reload DA3-Large pretrained weights AFTER ``XVLA.from_pretrained``.
159
+
160
+ HF's ``from_pretrained`` treats every key absent from the loaded
161
+ checkpoint as a "missing" param and overwrites it via ``_init_weights``.
162
+ That zeroes/random-inits the DA3-Large weights we loaded inside
163
+ ``__init__``, producing garbage geometry features as soon as alpha > 0.
164
+
165
+ Call this once right after ``XVLA.from_pretrained(...)`` to restore the
166
+ actual DA3 weights. Same pattern as ``DA3InlineEncoder.reload_pretrained_weights``.
167
+ """
168
+ try:
169
+ from depth_anything_3.api import DepthAnything3
170
+ except Exception as exc:
171
+ raise ImportError("depth_anything_3 not installed") from exc
172
+ device = next(self.model.parameters()).device
173
+ dtype = next(self.model.parameters()).dtype
174
+ fresh = DepthAnything3.from_pretrained(self.model_name)
175
+ self.model.load_state_dict(fresh.state_dict(), strict=True)
176
+ self.model.to(device=device, dtype=dtype)
177
+ for p in self.model.parameters():
178
+ p.requires_grad_(False)
179
+ self.model.eval()
180
+ # Re-attach the ray hook defensively (load_state_dict copies weights
181
+ # into existing modules so the hook target is preserved, but if any
182
+ # downstream change re-creates head modules this re-attach saves us).
183
+ try:
184
+ if hasattr(self, "_ray_hook"):
185
+ self._ray_hook.remove()
186
+ except Exception:
187
+ pass
188
+ self._register_ray_hook()
189
+
190
+ # ---------- head-path resolution (DA3-Large vs NESTED) ----------
191
+
192
+ def _find_head_scratch(self):
193
+ """Return the DPT ``head.scratch`` module across DA3 variants.
194
+
195
+ DA3-Large: ``self.model.model.head.scratch``.
196
+ DA3NESTED-GIANT-LARGE: the encoder is wrapped one level deeper as
197
+ ``self.model.model.da3.head.scratch`` (net children = da3 / da3_metric).
198
+ """
199
+ for path in (("model", "head", "scratch"),
200
+ ("model", "da3", "head", "scratch"),
201
+ ("da3", "head", "scratch"),
202
+ ("head", "scratch")):
203
+ cur = self.model
204
+ ok = True
205
+ for a in path:
206
+ if hasattr(cur, a):
207
+ cur = getattr(cur, a)
208
+ else:
209
+ ok = False
210
+ break
211
+ if ok:
212
+ return cur
213
+ return None
214
+
215
+ # ---------- ray-head hook ----------
216
+
217
+ def _register_ray_hook(self) -> None:
218
+ """Capture the ray-head pre-activation output via a forward hook on the
219
+ final aux conv in DA3's DualDPT. Output shape: [B*V, 7, H_ray, W_ray]
220
+ where channels are [dir_x, dir_y, dir_z, ori_x, ori_y, ori_z, conf].
221
+
222
+ DA3's ``head.scratch.output_conv2_aux`` may be an nn.Sequential or a
223
+ single Conv2d — hook whichever leaf module is at the tail.
224
+ """
225
+ scratch = self._find_head_scratch()
226
+ if scratch is None or not hasattr(scratch, "output_conv2_aux"):
227
+ raise RuntimeError(
228
+ "DA3LargeForGeoStack: could not locate head.scratch.output_conv2_aux "
229
+ "(this DA3 variant may not have a DualDPT head)"
230
+ )
231
+ aux = scratch.output_conv2_aux
232
+ # DA3's DualDPT defines output_conv2_aux as an nn.ModuleList of length
233
+ # aux_pyramid_levels (default 4). The forward call site uses
234
+ # `scratch.output_conv2_aux[-1](last_aux)` (dualdpt.py:255) — i.e., only
235
+ # the LAST module is actually called. Hook it.
236
+ if isinstance(aux, (nn.Sequential, nn.ModuleList)):
237
+ if len(aux) == 0:
238
+ raise RuntimeError("DA3LargeForGeoStack: output_conv2_aux is empty")
239
+ target = aux[-1]
240
+ else:
241
+ target = aux
242
+
243
+ def _hook(module, inputs, output):
244
+ self._ray_capture["ray_raw"] = output
245
+
246
+ self._ray_hook = target.register_forward_hook(_hook)
247
+
248
+ # ---------- depth-head hook (v4.1 opt-in) ----------
249
+
250
+ def enable_depth_capture(self, enabled: bool = True) -> None:
251
+ """Turn on capture of DA3's main depth output (from output_conv2).
252
+ Idempotent — safe to call multiple times.
253
+ """
254
+ if enabled and self._depth_hook is None:
255
+ scratch = self._find_head_scratch()
256
+ if scratch is None or not hasattr(scratch, "output_conv2"):
257
+ raise RuntimeError(
258
+ "DA3LargeForGeoStack: could not locate head.scratch.output_conv2 for depth hook"
259
+ )
260
+ target = scratch.output_conv2
261
+ def _hook(module, inputs, output):
262
+ self._depth_capture["depth_raw"] = output
263
+ self._depth_hook = target.register_forward_hook(_hook)
264
+ elif not enabled and self._depth_hook is not None:
265
+ self._depth_hook.remove()
266
+ self._depth_hook = None
267
+ self._capture_depth = bool(enabled)
268
+
269
+ # ---------- forward ----------
270
+
271
+ @torch.no_grad()
272
+ def _da3_forward(self, x: torch.Tensor) -> Dict[str, torch.Tensor]:
273
+ """Run DA3 inner forward and return the aux dict.
274
+
275
+ x: [B, V, 3, H, W] in ImageNet-normalized space (already resized to
276
+ (da3_input_h, da3_input_w)).
277
+ """
278
+ inner = self.model.model
279
+ out = inner(
280
+ x, None, None, # no extrinsics/intrinsics
281
+ list(self.out_layers), # export these feat layers
282
+ False, # infer_gs
283
+ False, # use_ray_pose
284
+ "saddle_balanced", # ref_view_strategy
285
+ )
286
+ if isinstance(out, dict):
287
+ aux = out.get("aux") or {}
288
+ else:
289
+ aux = getattr(out, "aux", None) or {}
290
+ return aux
291
+
292
+ @torch.no_grad()
293
+ def _da3_forward_posed(
294
+ self,
295
+ x: torch.Tensor, # [B, V, 3, H, W]
296
+ extrinsics: torch.Tensor, # [B, V, 4, 4] OpenCV world-to-camera
297
+ intrinsics: torch.Tensor, # [B, V, 3, 3] rescaled to (H, W)
298
+ ) -> Dict[str, torch.Tensor]:
299
+ """Joint posed multi-view DA3 forward: ALL views in one inner() call
300
+ with camera extrinsics + intrinsics, so DA3's camera encoder fuses pose
301
+ tokens into the backbone and returns per-view features that are
302
+ geometrically consistent ACROSS views. Mirrors da3_inline's posed call
303
+ (use_ray_pose=False; poses drive cam_enc, not the ray head)."""
304
+ inner = self.model.model
305
+ out = inner(
306
+ x,
307
+ extrinsics.to(device=x.device, dtype=torch.float32),
308
+ intrinsics.to(device=x.device, dtype=torch.float32),
309
+ list(self.out_layers), # export these feat layers
310
+ False, # infer_gs
311
+ False, # use_ray_pose
312
+ "saddle_balanced", # ref_view_strategy
313
+ )
314
+ # Nested GIANT returns a top-level metric-depth map (da3_metric branch):
315
+ # out["depth"] = [B,V,H,W], out["is_metric"]. Surface it alongside aux so
316
+ # the spatial tokens can carry ray + DEPTH (scale-aware ray).
317
+ if isinstance(out, dict):
318
+ aux = out.get("aux") or {}
319
+ depth = out.get("depth", None)
320
+ else:
321
+ aux = getattr(out, "aux", None) or {}
322
+ depth = getattr(out, "depth", None)
323
+ return aux, depth
324
+
325
+ @staticmethod
326
+ def _rescale_intrinsics(
327
+ K: torch.Tensor, src_hw: Tuple[int, int], dst_hw: Tuple[int, int]
328
+ ) -> torch.Tensor:
329
+ """Rescale pixel intrinsics [..., 3, 3] from (src_H, src_W) to
330
+ (dst_H, dst_W). fx, cx scale with the width ratio; fy, cy with height.
331
+ Matches da3_inline._batched_call so the pose math sees K aligned to the
332
+ actual tensor DA3 receives. Identity when src == dst."""
333
+ sh, sw = int(src_hw[0]), int(src_hw[1])
334
+ dh, dw = int(dst_hw[0]), int(dst_hw[1])
335
+ if (sh, sw) == (dh, dw):
336
+ return K.to(torch.float32)
337
+ sx = float(dw) / float(sw) # width scale
338
+ sy = float(dh) / float(sh) # height scale
339
+ K = K.clone().to(torch.float32)
340
+ K[..., 0, 0] *= sx # fx
341
+ K[..., 1, 1] *= sy # fy
342
+ K[..., 0, 2] *= sx # cx
343
+ K[..., 1, 2] *= sy # cy
344
+ return K
345
+
346
+ def _to_bchw(self, feat: torch.Tensor, B: int) -> torch.Tensor:
347
+ """Normalize an aux entry to [B*V, C, h, w]. V=1 for GeoStack."""
348
+ if feat.dim() == 6: # [B, V, s, h, w, C]
349
+ feat = feat.mean(dim=2)
350
+ if feat.dim() == 5: # [B, V, h, w, C]
351
+ return feat.permute(0, 1, 4, 2, 3).reshape(-1, feat.shape[4], feat.shape[2], feat.shape[3])
352
+ if feat.dim() == 4: # [B, V, N, C] or [B*V, C, h, w]
353
+ if feat.shape[0] == B: # heuristic: [B, V, N, C]
354
+ B_, V_, N, C_ = feat.shape
355
+ s = max(1, int(round(N ** 0.5)))
356
+ return feat[:, :, : s * s].permute(0, 1, 3, 2).reshape(B_ * V_, C_, s, s)
357
+ return feat # already [B*V, C, h, w]
358
+ raise RuntimeError(f"unexpected DA3 feat tensor rank: {feat.dim()}, shape={tuple(feat.shape)}")
359
+
360
+ def forward(
361
+ self,
362
+ pixel_values: torch.Tensor,
363
+ target_input_hw: Tuple[int, int] | None = None,
364
+ ) -> Dict[str, object]:
365
+ """Run DA3 forward on a batch with aspect-correct resize.
366
+
367
+ Args:
368
+ pixel_values: [B, 3, H_in, W_in] ImageNet-normalized float. Any input
369
+ resolution accepted; bilinearly resized to (h, w) before DA3 forward.
370
+ target_input_hw: optional (h, w) override for DA3 input size. When set,
371
+ this overrides the configured (da3_input_h, da3_input_w) — useful
372
+ for running the same DA3 at different resolutions per view
373
+ (e.g., main camera at 252×336, wrist at 168×224). Both dims must
374
+ be multiples of patch_size. The returned feats and ray use a
375
+ token grid derived from the target dims, NOT the configured ones.
376
+
377
+ Returns:
378
+ dict with keys:
379
+ feats: List[torch.Tensor] one per out_layer, each [B, C, h_grid, w_grid]
380
+ ray: torch.Tensor [B, 3, h_grid, w_grid], unit-normalized direction
381
+ h_grid: int
382
+ w_grid: int
383
+ """
384
+ if pixel_values.dim() != 4:
385
+ raise ValueError(f"pixel_values must be [B, 3, H, W]; got shape {tuple(pixel_values.shape)}")
386
+ B = pixel_values.shape[0]
387
+ device = pixel_values.device
388
+ x = pixel_values.to(torch.float32)
389
+
390
+ # Resolve input dims + corresponding token grid.
391
+ if target_input_hw is None:
392
+ in_h, in_w = self.da3_input_h, self.da3_input_w
393
+ h_grid, w_grid = self.h_grid, self.w_grid
394
+ else:
395
+ in_h, in_w = int(target_input_hw[0]), int(target_input_hw[1])
396
+ if in_h % self.patch_size != 0 or in_w % self.patch_size != 0:
397
+ raise ValueError(
398
+ f"target_input_hw=({in_h},{in_w}) must be multiples of patch_size={self.patch_size}"
399
+ )
400
+ h_grid, w_grid = in_h // self.patch_size, in_w // self.patch_size
401
+
402
+ # Aspect-correct resize (bilinear). NO PADDING — input dims chosen so
403
+ # both are multiples of patch_size.
404
+ x = F.interpolate(x, size=(in_h, in_w), mode="bilinear", align_corners=False)
405
+ # Expand to V=1 view dim (DA3 expects [B, V, 3, H, W])
406
+ x = x.unsqueeze(1)
407
+
408
+ # Clear stale ray capture (and depth if enabled), run DA3
409
+ self._ray_capture.clear()
410
+ if self._capture_depth:
411
+ self._depth_capture.clear()
412
+ if self.use_bf16:
413
+ with torch.autocast(device_type=device.type, dtype=torch.bfloat16):
414
+ aux = self._da3_forward(x)
415
+ else:
416
+ aux = self._da3_forward(x)
417
+
418
+ feats, ray_at_feat, depth_at_feat = self._finalize(aux, B, h_grid, w_grid)
419
+ return {
420
+ "feats": feats, # list of [B, C, h_grid, w_grid]
421
+ "ray": ray_at_feat, # [B, 3, h_grid, w_grid]
422
+ "depth": depth_at_feat, # [B, 1, h_grid, w_grid] or None
423
+ "h_grid": int(h_grid),
424
+ "w_grid": int(w_grid),
425
+ }
426
+
427
+ def _finalize(self, aux, B: int, h_grid: int, w_grid: int):
428
+ """Pull per-layer feats + ray + optional depth out of a completed DA3
429
+ forward. Returns tensors whose leading dim is B*V (V baked in by DA3's
430
+ aux layout): feats as list of [B*V, C, h_grid, w_grid], ray as
431
+ [B*V, 3, h_grid, w_grid], depth as [B*V, 1, h_grid, w_grid] or None.
432
+ The single-view ``forward`` (V=1) gets [B, ...]; the joint multi-view
433
+ path unflattens the leading dim back to (B, V).
434
+ """
435
+ # Extract per-level feats at native (h_grid, w_grid). DA3 may emit them
436
+ # at the backbone resolution already matching (h_grid, w_grid) since
437
+ # in_h/in_w = h_grid*patch × w_grid*patch.
438
+ feats: List[torch.Tensor] = []
439
+ for li in self.out_layers:
440
+ key = f"feat_layer_{li}"
441
+ fi = aux.get(key)
442
+ if fi is None:
443
+ raise RuntimeError(
444
+ f"DA3LargeForGeoStack: aux missing key '{key}'; available={list(aux.keys())[:8]}"
445
+ )
446
+ fi = self._to_bchw(fi, B=B) # [B*V, C, h, w]
447
+ if fi.shape[-1] != w_grid or fi.shape[-2] != h_grid:
448
+ # Defensive resize in case DA3 produced a different grid.
449
+ fi = F.interpolate(
450
+ fi.float(), size=(h_grid, w_grid),
451
+ mode="bilinear", align_corners=False,
452
+ )
453
+ feats.append(fi)
454
+
455
+ # Extract ray-head output (3D direction only, normalized)
456
+ ray_raw = self._ray_capture.get("ray_raw", None)
457
+ if ray_raw is None:
458
+ raise RuntimeError(
459
+ "DA3LargeForGeoStack: ray-head hook did not fire; DA3 forward did not run "
460
+ "the aux/ray branch. Check head.scratch.output_conv2_aux exists."
461
+ )
462
+ if ray_raw.dim() == 4:
463
+ ray_dir = ray_raw[:, :3, :, :]
464
+ elif ray_raw.dim() == 5: # [B, V, C, H, W]
465
+ ray_dir = ray_raw[:, :, :3, :, :].reshape(-1, 3, ray_raw.shape[-2], ray_raw.shape[-1])
466
+ else:
467
+ raise RuntimeError(f"Unexpected ray tensor rank: {ray_raw.dim()}, shape={tuple(ray_raw.shape)}")
468
+ ray_at_feat = F.interpolate(
469
+ ray_dir.float(), size=(h_grid, w_grid),
470
+ mode="bilinear", align_corners=False,
471
+ )
472
+ ray_at_feat = ray_at_feat / (ray_at_feat.norm(dim=1, keepdim=True) + 1e-6)
473
+ self._ray_capture.clear()
474
+
475
+ # Optional depth (v4.1) — extract DA3's main-head depth output at patch grid
476
+ depth_at_feat = None
477
+ if self._capture_depth:
478
+ depth_raw = self._depth_capture.get("depth_raw", None)
479
+ if depth_raw is not None:
480
+ # depth_raw: [B*V, C_out, H, W] or [B, V, C_out, H, W]
481
+ if depth_raw.dim() == 5:
482
+ depth_raw = depth_raw.reshape(-1, depth_raw.shape[-3], depth_raw.shape[-2], depth_raw.shape[-1])
483
+ # First channel = raw depth logits (DPT convention)
484
+ depth_scalar = depth_raw[:, :1].float()
485
+ depth_at_feat = F.interpolate(
486
+ depth_scalar, size=(h_grid, w_grid),
487
+ mode="bilinear", align_corners=False,
488
+ )
489
+ self._depth_capture.clear()
490
+
491
+ return feats, ray_at_feat, depth_at_feat
492
+
493
+
494
+ # ---------- multi-view forward (v2C) ----------
495
+
496
+ def forward_multi_view(
497
+ self,
498
+ pixel_values: torch.Tensor, # [B, V, 3, H_in, W_in]
499
+ target_input_hw: Tuple[int, int] | None = None,
500
+ extrinsics: torch.Tensor | None = None, # [B, V, 4, 4] OpenCV w2c
501
+ intrinsics: torch.Tensor | None = None, # [B, V, 3, 3] @ (H_in, W_in)
502
+ ) -> Dict[str, object]:
503
+ """Process multiple views, returning per-view feats/rays.
504
+
505
+ Two modes, same output contract:
506
+ * Unposed (extrinsics/intrinsics=None): flatten [B,V]→[B*V] and run
507
+ each view through DA3 INDEPENDENTLY (legacy v2C wrist-depth path).
508
+ * Posed joint (both provided): feed ALL V views TOGETHER into one DA3
509
+ inner() call with camera extrinsics + intrinsics, so DA3's camera
510
+ encoder makes the per-view features geometrically consistent across
511
+ views. Downstream per-view routing/cross-attention is unchanged —
512
+ only the feature content becomes cross-view-aware.
513
+
514
+ Args:
515
+ pixel_values: [B, V, 3, H_in, W_in] ImageNet-normalized float.
516
+ target_input_hw: optional (h, w) override (e.g., 224×224 for wrists).
517
+ extrinsics: optional [B, V, 4, 4] OpenCV world-to-camera.
518
+ intrinsics: optional [B, V, 3, 3] calibrated to (H_in, W_in); DA3
519
+ resizes views to (in_h, in_w) internally so K is rescaled to match.
520
+
521
+ Returns:
522
+ dict with keys:
523
+ feats: List[torch.Tensor] one per out_layer, each [B, V, C, h_grid, w_grid]
524
+ ray: torch.Tensor [B, V, 3, h_grid, w_grid]
525
+ depth: torch.Tensor | None [B, V, 1, h_grid, w_grid]
526
+ h_grid: int
527
+ w_grid: int
528
+ """
529
+ if pixel_values.dim() != 5:
530
+ raise ValueError(
531
+ f"forward_multi_view expects [B,V,3,H,W]; got {tuple(pixel_values.shape)}"
532
+ )
533
+ B, V = pixel_values.shape[:2]
534
+
535
+ # ---- Posed joint multi-view path ----
536
+ if extrinsics is not None and intrinsics is not None:
537
+ return self._forward_multi_view_posed(
538
+ pixel_values, extrinsics, intrinsics, target_input_hw
539
+ )
540
+
541
+ # ---- Legacy unposed per-view path ----
542
+ flat = pixel_values.flatten(0, 1) # [B*V, 3, H_in, W_in]
543
+ out_flat = self.forward(flat, target_input_hw=target_input_hw)
544
+ feats_flat = out_flat["feats"] # list of [B*V, C, h, w]
545
+ ray_flat = out_flat["ray"] # [B*V, 3, h, w]
546
+ h_grid = int(out_flat["h_grid"])
547
+ w_grid = int(out_flat["w_grid"])
548
+ feats = [f.unflatten(0, (B, V)) for f in feats_flat] # list of [B, V, C, h, w]
549
+ ray = ray_flat.unflatten(0, (B, V)) # [B, V, 3, h, w]
550
+ result = {
551
+ "feats": feats,
552
+ "ray": ray,
553
+ "h_grid": h_grid,
554
+ "w_grid": w_grid,
555
+ }
556
+ # v4.1: forward multi-view depth if the depth hook is on
557
+ depth_flat = out_flat.get("depth", None)
558
+ if depth_flat is not None:
559
+ result["depth"] = depth_flat.unflatten(0, (B, V)) # [B, V, 1, h, w]
560
+ else:
561
+ result["depth"] = None
562
+ return result
563
+
564
+ def _forward_multi_view_posed(
565
+ self,
566
+ pixel_values: torch.Tensor, # [B, V, 3, H_in, W_in]
567
+ extrinsics: torch.Tensor, # [B, V, 4, 4] OpenCV w2c
568
+ intrinsics: torch.Tensor, # [B, V, 3, 3] @ (H_in, W_in)
569
+ target_input_hw: Tuple[int, int] | None = None,
570
+ ) -> Dict[str, object]:
571
+ """Joint posed multi-view DA3: one inner() call over all V views with
572
+ camera pose conditioning → cross-view-consistent per-view features.
573
+ Same return contract as forward_multi_view (unposed)."""
574
+ B, V, _, H_in, W_in = pixel_values.shape
575
+ device = pixel_values.device
576
+
577
+ # Resolve DA3 input dims + the resulting token grid.
578
+ if target_input_hw is None:
579
+ in_h, in_w = self.da3_input_h, self.da3_input_w
580
+ h_grid, w_grid = self.h_grid, self.w_grid
581
+ else:
582
+ in_h, in_w = int(target_input_hw[0]), int(target_input_hw[1])
583
+ if in_h % self.patch_size != 0 or in_w % self.patch_size != 0:
584
+ raise ValueError(
585
+ f"target_input_hw=({in_h},{in_w}) must be multiples of patch_size={self.patch_size}"
586
+ )
587
+ h_grid, w_grid = in_h // self.patch_size, in_w // self.patch_size
588
+
589
+ # Aspect-correct resize of every view to (in_h, in_w).
590
+ x = pixel_values.to(torch.float32).flatten(0, 1) # [B*V, 3, H_in, W_in]
591
+ if (H_in, W_in) != (in_h, in_w):
592
+ x = F.interpolate(x, size=(in_h, in_w), mode="bilinear", align_corners=False)
593
+ x = x.unflatten(0, (B, V)) # [B, V, 3, in_h, in_w]
594
+
595
+ # Rescale intrinsics from the incoming tensor resolution to (in_h, in_w)
596
+ # so DA3's pose math sees K aligned with the pixels it actually gets.
597
+ K = self._rescale_intrinsics(intrinsics, (H_in, W_in), (in_h, in_w))
598
+
599
+ # Clear stale captures, run ONE joint posed DA3 forward.
600
+ self._ray_capture.clear()
601
+ if self._capture_depth:
602
+ self._depth_capture.clear()
603
+ if self.use_bf16:
604
+ with torch.autocast(device_type=device.type, dtype=torch.bfloat16):
605
+ aux, depth_full = self._da3_forward_posed(x, extrinsics, K)
606
+ else:
607
+ aux, depth_full = self._da3_forward_posed(x, extrinsics, K)
608
+
609
+ # Shared extraction — leading dim is B*V (V baked into DA3's aux layout).
610
+ feats_flat, ray_flat, depth_hook = self._finalize(aux, B, h_grid, w_grid)
611
+ feats = [f.unflatten(0, (B, V)) for f in feats_flat] # list of [B, V, C, h, w]
612
+ ray = ray_flat.unflatten(0, (B, V)) # [B, V, 3, h, w]
613
+
614
+ # Metric depth from the top-level output (preferred over the DPT hook):
615
+ # [B,V,H,W] → resize to the token grid → [B,V,1,h,w].
616
+ if depth_full is not None:
617
+ df = depth_full.float()
618
+ if df.dim() == 4: # [B, V, H, W]
619
+ df = df.flatten(0, 1).unsqueeze(1) # [B*V, 1, H, W]
620
+ elif df.dim() == 5: # [B, V, 1, H, W]
621
+ df = df.flatten(0, 1) # [B*V, 1, H, W]
622
+ df = F.interpolate(df, size=(h_grid, w_grid), mode="bilinear", align_corners=False)
623
+ depth_grid = df.unflatten(0, (B, V)) # [B, V, 1, h, w]
624
+ elif depth_hook is not None:
625
+ depth_grid = depth_hook.unflatten(0, (B, V))
626
+ else:
627
+ depth_grid = None
628
+
629
+ result: Dict[str, object] = {
630
+ "feats": feats,
631
+ "ray": ray,
632
+ "h_grid": int(h_grid),
633
+ "w_grid": int(w_grid),
634
+ "depth": depth_grid, # [B, V, 1, h, w] metric
635
+ }
636
+ return result
637
+
638
+
639
+ __all__ = ["DA3LargeForGeoStack"]
pibehavior_da3_clean_up_your_desk_40k/source_code/launch/launch_b1k_robopro.sh ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Launch/resume the b1k DA3 robopro-exact run. Arg1 = physical GPU list (e.g. "4,5,6,7").
3
+ # Auto-resumes from the latest checkpoint if one exists; else fresh from behavior_50t.
4
+ set -uo pipefail
5
+ GPUS="${1:-4,5,6,7}"
6
+ cd /work/jack/behavior-1k-solution
7
+ # Auto mode: 8 GPUs => dedicated extraction (hold out first 4 for the frozen DA3 forward, train on last 4);
8
+ # 4 GPUs => inline (extraction shares the training GPUs). Extraction always pins cuda:0-3 (the first 4 given).
9
+ NGPU=$(echo "$GPUS" | tr ',' '\n' | grep -c .)
10
+ if [ "$NGPU" -ge 8 ]; then
11
+ # 8-GPU: 4 train + 4 extract. Training stays 4-way DP to MATCH the checkpoint's mesh — changing the
12
+ # #training-GPUs on resume reshapes the sharding and breaks restore, so we hold it fixed at 4.
13
+ HOLDOUT=4; QDEPTH=6; EXTRACT_DEVS="cuda:0,cuda:1,cuda:2,cuda:3"; BSZ=128; GMODE="8-GPU: 4 train + 4 extract"
14
+ else
15
+ HOLDOUT=0; QDEPTH=2; EXTRACT_DEVS="cuda:0,cuda:1,cuda:2,cuda:3"; BSZ=128; GMODE="${NGPU}-GPU inline"
16
+ fi
17
+ LOG=/work/jack/b1k_da3_robopro_desk_train.log
18
+ CKDIR=outputs/checkpoints/pi_behavior_b1k_fast/b1k_da3_robopro_desk
19
+ # resume if a numeric checkpoint exists, else fresh
20
+ if ls "$CKDIR" 2>/dev/null | grep -qE '^[0-9]+$'; then RES=1; OVW=0; MODE="RESUME from step $(ls "$CKDIR"|grep -E '^[0-9]+$'|sort -n|tail -1)"; else RES=0; OVW=1; MODE="FRESH"; fi
21
+ echo "[$(date +%H:%M)] launching ($MODE, $GMODE) on GPUs $GPUS" >> /work/jack/b1k_babysitter.log
22
+ CUDA_VISIBLE_DEVICES="$GPUS" \
23
+ XLA_PYTHON_CLIENT_MEM_FRACTION=0.80 XLA_PYTHON_CLIENT_ALLOCATOR=platform \
24
+ XLA_FLAGS="--xla_gpu_enable_latency_hiding_scheduler=true --xla_gpu_all_reduce_combine_threshold_bytes=8388608 --xla_gpu_enable_highest_priority_async_stream=true" \
25
+ PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
26
+ B1K_HOLDOUT_GPU0=$HOLDOUT \
27
+ B1K_EXTRACT_DEVICES=$EXTRACT_DEVS B1K_DECODE_THREADS=1 \
28
+ B1K_SHARED_DECODE=1 \
29
+ B1K_DLPACK=1 \
30
+ B1K_DA3_FWD_CHUNK=${B1K_DA3_FWD_CHUNK:-32} \
31
+ B1K_EXTRACT_QUEUE=$QDEPTH \
32
+ RAYON_NUM_THREADS=1 OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 \
33
+ NUMEXPR_NUM_THREADS=1 POLARS_MAX_THREADS=1 TOKENIZERS_PARALLELISM=false \
34
+ HF_HOME=/work/jack/da3xvla_workspace/hf_cache HF_HUB_OFFLINE=1 \
35
+ B1K_2026_ROOT=/work/jack/behavior1k/data/behavior_2026_task29_42 \
36
+ USE_DA3_FULL=1 DA3_INIT_STD=0.01 DA3_LOGIT_GAIN=1 DA3_SCALE=2.0 DA3_LR_GROUPS=1 \
37
+ LR_VLM_PEAK=2.5e-5 LR_VLM_END=2.5e-6 LR_CORE_PEAK=5e-4 LR_CORE_END=5e-5 LR_GEOM_PEAK=5e-4 LR_GEOM_END=1e-4 \
38
+ B1K_ACTIVITIES=clean_up_your_desk \
39
+ B1K_INIT_PARAMS=/work/jack/behavior1k/checkpoints/behavior_50t_checkpoint/params \
40
+ EXP=b1k_da3_robopro_desk \
41
+ STEPS=50000 BS=$BSZ FLOW=15 NW=${NW:-32} SHUFFLE=1 RESUME=$RES OVERWRITE=$OVW \
42
+ SAVE_INTERVAL=2000 KEEP_PERIOD=2000 LOG_INTERVAL=50 \
43
+ LR_WARMUP=1000 LR_DECAY_STEPS=50000 \
44
+ nohup .venv/bin/python scripts/train_2026.py >> "$LOG" 2>&1 &
45
+ echo "LAUNCHED pid $! ($MODE) on GPUs $GPUS"
pibehavior_da3_clean_up_your_desk_40k/source_code/model/observation.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Observation class and preprocessing with FAST auxiliary fields support.
2
+
3
+ Based on openpi with FAST fields added for PI_BEHAVIOR model.
4
+
5
+ Reference: https://github.com/wensi-ai/openpi/blob/behavior/src/openpi/models/model.py
6
+ """
7
+
8
+ from collections.abc import Sequence
9
+ from typing import Generic, TypeVar
10
+ import dataclasses
11
+
12
+ import augmax
13
+ from flax import struct
14
+ import jax
15
+ import jax.numpy as jnp
16
+ import numpy as np
17
+ import torch
18
+
19
+ from openpi.shared import image_tools
20
+ from openpi.shared import array_typing as at
21
+
22
+ ArrayT = TypeVar("ArrayT", bound=jax.Array | torch.Tensor | np.ndarray)
23
+
24
+ IMAGE_KEYS = (
25
+ "base_0_rgb",
26
+ "left_wrist_0_rgb",
27
+ "right_wrist_0_rgb",
28
+ )
29
+ IMAGE_RESOLUTION = (224, 224)
30
+
31
+
32
+ @at.typecheck
33
+ @struct.dataclass
34
+ class Observation(Generic[ArrayT]):
35
+ """Observation with FAST auxiliary fields."""
36
+
37
+ images: dict[str, at.Float[ArrayT, "*b h w c"]]
38
+ image_masks: dict[str, at.Bool[ArrayT, "*b"]]
39
+ state: at.Float[ArrayT, "*b s"]
40
+ tokenized_prompt: at.Int[ArrayT, "*b l"] | None = None
41
+ tokenized_prompt_mask: at.Bool[ArrayT, "*b l"] | None = None
42
+ token_ar_mask: at.Int[ArrayT, "*b l"] | None = None
43
+ token_loss_mask: at.Bool[ArrayT, "*b l"] | None = None
44
+
45
+ fast_tokens: at.Int[ArrayT, "*b t"] | None = None
46
+ fast_token_mask: at.Bool[ArrayT, "*b t"] | None = None
47
+ spatial_tokens: at.Float[ArrayT, "*b n d"] | None = None
48
+ spatial_token_mask: at.Bool[ArrayT, "*b n"] | None = None
49
+ # DA3 inline spatial inputs (frozen DA3-GIANT features + geometry; consumed by the trainable
50
+ # bank builder in PiBehavior). da3_features ship as raw bits: uint16=bf16 bits, uint8=fp8 bytes.
51
+ da3_features: at.Num[ArrayT, "*b dl v dc gh gw"] | None = None
52
+ da3_ray: at.Float[ArrayT, "*b v three gh gw"] | None = None
53
+ da3_depth: at.Float[ArrayT, "*b v one gh gw"] | None = None
54
+ camera_extrinsics: at.Float[ArrayT, "*b v four four2"] | None = None
55
+ lang_feat: at.Float[ArrayT, "*b lt ld"] | None = None
56
+ lang_mask: at.Bool[ArrayT, "*b lt"] | None = None
57
+
58
+ @classmethod
59
+ def from_dict(cls, data: at.PyTree[ArrayT]) -> "Observation[ArrayT]":
60
+ """Convert dict to Observation."""
61
+ if ("tokenized_prompt" in data) != ("tokenized_prompt_mask" in data):
62
+ raise ValueError("tokenized_prompt and tokenized_prompt_mask must be provided together.")
63
+
64
+ # Convert uint8 images to float32 [-1, 1]
65
+ for key in data["image"]:
66
+ if data["image"][key].dtype == np.uint8:
67
+ data["image"][key] = data["image"][key].astype(np.float32) / 255.0 * 2.0 - 1.0
68
+ elif hasattr(data["image"][key], "dtype") and data["image"][key].dtype == torch.uint8:
69
+ data["image"][key] = data["image"][key].to(torch.float32).permute(0, 3, 1, 2) / 255.0 * 2.0 - 1.0
70
+
71
+ return cls(
72
+ images=data["image"],
73
+ image_masks=data["image_mask"],
74
+ state=data["state"],
75
+ tokenized_prompt=data.get("tokenized_prompt"),
76
+ tokenized_prompt_mask=data.get("tokenized_prompt_mask"),
77
+ token_ar_mask=data.get("token_ar_mask"),
78
+ token_loss_mask=data.get("token_loss_mask"),
79
+ fast_tokens=data.get("fast_tokens"),
80
+ fast_token_mask=data.get("fast_token_mask"),
81
+ spatial_tokens=data.get("spatial_tokens"),
82
+ spatial_token_mask=data.get("spatial_token_mask"),
83
+ da3_features=data.get("da3_features"),
84
+ da3_ray=data.get("da3_ray"),
85
+ da3_depth=data.get("da3_depth"),
86
+ camera_extrinsics=data.get("camera_extrinsics"),
87
+ lang_feat=data.get("lang_feat"),
88
+ lang_mask=data.get("lang_mask"),
89
+ )
90
+
91
+ def to_dict(self) -> at.PyTree[ArrayT]:
92
+ """Convert Observation to dict."""
93
+ result = dataclasses.asdict(self)
94
+ result["image"] = result.pop("images")
95
+ result["image_mask"] = result.pop("image_masks")
96
+ return result
97
+
98
+
99
+ def preprocess_observation(
100
+ rng: at.KeyArrayLike | None,
101
+ observation: Observation,
102
+ *,
103
+ train: bool = False,
104
+ image_keys: Sequence[str] = IMAGE_KEYS,
105
+ image_resolution: tuple[int, int] = IMAGE_RESOLUTION,
106
+ ) -> Observation:
107
+ """Preprocess observations with image augmentation and FAST fields preservation."""
108
+ if not set(image_keys).issubset(observation.images):
109
+ raise ValueError(f"images dict missing keys: expected {image_keys}, got {list(observation.images)}")
110
+
111
+ batch_shape = observation.state.shape[:-1]
112
+
113
+ out_images = {}
114
+ for key in image_keys:
115
+ image = observation.images[key]
116
+ if image.shape[1:3] != image_resolution:
117
+ image = image_tools.resize_with_pad(image, *image_resolution)
118
+
119
+ if train:
120
+ # Convert from [-1, 1] to [0, 1] for augmax
121
+ image = image / 2.0 + 0.5
122
+
123
+ transforms = []
124
+ if "wrist" not in key:
125
+ height, width = image.shape[1:3]
126
+ transforms += [
127
+ augmax.RandomCrop(int(width * 0.95), int(height * 0.95)),
128
+ augmax.Resize(width, height),
129
+ augmax.Rotate((-5, 5)),
130
+ ]
131
+ transforms += [
132
+ augmax.ColorJitter(brightness=0.3, contrast=0.4, saturation=0.5),
133
+ ]
134
+ sub_rngs = jax.random.split(rng, image.shape[0])
135
+ image = jax.vmap(augmax.Chain(*transforms))(sub_rngs, image)
136
+
137
+ # Back to [-1, 1]
138
+ image = image * 2.0 - 1.0
139
+
140
+ out_images[key] = image
141
+
142
+ # Obtain masks
143
+ out_masks = {}
144
+ for key in out_images:
145
+ if key not in observation.image_masks:
146
+ out_masks[key] = jnp.ones(batch_shape, dtype=jnp.bool)
147
+ else:
148
+ out_masks[key] = jnp.asarray(observation.image_masks[key])
149
+
150
+ return Observation(
151
+ images=out_images,
152
+ image_masks=out_masks,
153
+ state=observation.state,
154
+ tokenized_prompt=observation.tokenized_prompt,
155
+ tokenized_prompt_mask=observation.tokenized_prompt_mask,
156
+ token_ar_mask=observation.token_ar_mask,
157
+ token_loss_mask=observation.token_loss_mask,
158
+ fast_tokens=getattr(observation, 'fast_tokens', None),
159
+ fast_token_mask=getattr(observation, 'fast_token_mask', None),
160
+ spatial_tokens=getattr(observation, 'spatial_tokens', None),
161
+ da3_features=getattr(observation, 'da3_features', None),
162
+ da3_ray=getattr(observation, 'da3_ray', None),
163
+ da3_depth=getattr(observation, 'da3_depth', None),
164
+ camera_extrinsics=getattr(observation, 'camera_extrinsics', None),
165
+ lang_feat=getattr(observation, 'lang_feat', None),
166
+ lang_mask=getattr(observation, 'lang_mask', None),
167
+ spatial_token_mask=getattr(observation, 'spatial_token_mask', None),
168
+ )
pibehavior_da3_clean_up_your_desk_40k/source_code/model/pi_behavior.py ADDED
@@ -0,0 +1,1270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The main model for BEHAVIOR-1K challenge.
2
+
3
+ Based on Pi0.5 implementation from PhysicalIntelligence/openpi
4
+ """
5
+
6
+ import logging
7
+ import pathlib
8
+
9
+ import einops
10
+ import flax.linen as nn
11
+ import flax.nnx as nnx
12
+ import flax.nnx.bridge as nnx_bridge
13
+ import jax
14
+ import jax.numpy as jnp
15
+ from typing_extensions import override
16
+
17
+ from openpi.models import model as _model
18
+ from openpi.models import gemma as _gemma
19
+ from b1k.models import spatial_da3 as _spatial_da3
20
+ from openpi.models import siglip as _siglip
21
+ from openpi.models.pi0 import make_attn_mask, posemb_sincos
22
+ from openpi.shared import array_typing as at
23
+
24
+ # Import from our custom modules
25
+ from b1k.models import pi_behavior_config
26
+ from b1k.models.observation import Observation, preprocess_observation
27
+ from b1k.models.pi_behavior_config import (
28
+ TASK_NUM_STAGES,
29
+ MAX_NUM_STAGES,
30
+ TOTAL_TASK_STAGE_EMBEDDINGS,
31
+ TASK_STAGE_OFFSETS
32
+ )
33
+
34
+ logger = logging.getLogger("b1k")
35
+
36
+
37
+ class KVCacheTransform(nnx.Module):
38
+ """Transforms prefix KV cache by mixing across layers.
39
+
40
+ Each destination layer's K and V become learnable linear combinations
41
+ of all source layers' K and V, plus a bias term. This allows the action
42
+ expert to attend to learned combinations of VLM layers rather than being
43
+ forced to attend layer-by-layer.
44
+
45
+ Initialized as identity transform (k_coeffs = I, bias = 0) so the model
46
+ starts with the same behavior as without transformation.
47
+ """
48
+
49
+ def __init__(self, num_layers: int, head_dim: int, num_kv_heads: int, rngs: nnx.Rngs):
50
+ # K transformation: [dest_layer, src_layer]
51
+ # Initialize as identity so transformation is initially a no-op
52
+ self.k_coeffs = nnx.Param(jnp.eye(num_layers, dtype=jnp.float32))
53
+
54
+ # K bias: [layer, num_kv_heads, head_dim]
55
+ # Initialize as zeros
56
+ self.k_bias = nnx.Param(jnp.zeros((num_layers, num_kv_heads, head_dim), dtype=jnp.float32))
57
+
58
+ # V transformation (independent from K)
59
+ self.v_coeffs = nnx.Param(jnp.eye(num_layers, dtype=jnp.float32))
60
+ self.v_bias = nnx.Param(jnp.zeros((num_layers, num_kv_heads, head_dim), dtype=jnp.float32))
61
+
62
+ def __call__(self, kv_cache: tuple[jnp.ndarray, jnp.ndarray]) -> tuple[jnp.ndarray, jnp.ndarray]:
63
+ """Transform KV cache by mixing across layers.
64
+
65
+ Args:
66
+ kv_cache: Tuple of (cache_k, cache_v) where each has shape
67
+ [num_layers, batch, seq_len, num_kv_heads, head_dim]
68
+
69
+ Returns:
70
+ Transformed (k_new, v_new) with same shape and dtype as input
71
+ """
72
+ cache_k, cache_v = kv_cache
73
+ # Shape: [layers, batch, seq_len, num_kv_heads, head_dim]
74
+
75
+ # Preserve original dtype (important for bfloat16 training)
76
+ original_dtype = cache_k.dtype
77
+
78
+ # Transform K: each destination layer is a weighted combination of all source layers
79
+ # k_new[dest] = sum_src(k_coeffs[dest, src] * cache_k[src]) + k_bias[dest]
80
+ # Einsum: [dest, src] @ [src, batch, seq, heads, dim] -> [dest, batch, seq, heads, dim]
81
+ k_new = jnp.einsum('ds,sbtkh->dbtkh', self.k_coeffs.value, cache_k)
82
+ k_new = k_new + self.k_bias.value[:, None, None, :, :] # Add bias
83
+
84
+ # Transform V (same operation, independent parameters)
85
+ v_new = jnp.einsum('ds,sbtkh->dbtkh', self.v_coeffs.value, cache_v)
86
+ v_new = v_new + self.v_bias.value[:, None, None, :, :]
87
+
88
+ # Cast back to original dtype
89
+ k_new = k_new.astype(original_dtype)
90
+ v_new = v_new.astype(original_dtype)
91
+
92
+ return (k_new, v_new)
93
+
94
+
95
+ class SpatialActionCrossAttention(nnx.Module):
96
+ """Residual cross-attention from action tokens to precomputed DA3 spatial tokens."""
97
+
98
+ def __init__(
99
+ self,
100
+ action_width: int,
101
+ spatial_width: int,
102
+ num_heads: int,
103
+ rngs: nnx.Rngs,
104
+ ):
105
+ if action_width % num_heads != 0:
106
+ raise ValueError(f"action_width={action_width} must be divisible by num_heads={num_heads}")
107
+
108
+ self.num_heads = num_heads
109
+ self.head_dim = action_width // num_heads
110
+ self.q_proj = nnx.Linear(action_width, action_width, use_bias=False, rngs=rngs)
111
+ self.k_proj = nnx.Linear(spatial_width, action_width, use_bias=False, rngs=rngs)
112
+ self.v_proj = nnx.Linear(spatial_width, action_width, use_bias=False, rngs=rngs)
113
+ self.out_proj = nnx.Linear(
114
+ action_width,
115
+ action_width,
116
+ kernel_init=nn.initializers.zeros,
117
+ bias_init=nn.initializers.zeros,
118
+ rngs=rngs,
119
+ )
120
+
121
+ def __call__(
122
+ self,
123
+ action_tokens: jnp.ndarray,
124
+ spatial_tokens: jnp.ndarray,
125
+ spatial_token_mask: jnp.ndarray | None = None,
126
+ *,
127
+ residual_scale: float = 1.0,
128
+ ) -> jnp.ndarray:
129
+ original_dtype = action_tokens.dtype
130
+ q = self.q_proj(action_tokens)
131
+ k = self.k_proj(spatial_tokens.astype(action_tokens.dtype))
132
+ v = self.v_proj(spatial_tokens.astype(action_tokens.dtype))
133
+
134
+ q = einops.rearrange(q, "b t (h d) -> b h t d", h=self.num_heads)
135
+ k = einops.rearrange(k, "b s (h d) -> b h s d", h=self.num_heads)
136
+ v = einops.rearrange(v, "b s (h d) -> b h s d", h=self.num_heads)
137
+
138
+ logits = jnp.einsum("bhtd,bhsd->bhts", q, k, preferred_element_type=jnp.float32)
139
+ logits = logits * (self.head_dim ** -0.5)
140
+
141
+ if spatial_token_mask is not None:
142
+ big_neg = -2.3819763e38
143
+ logits = jnp.where(spatial_token_mask[:, None, None, :], logits, big_neg)
144
+
145
+ probs = jax.nn.softmax(logits, axis=-1).astype(original_dtype)
146
+ context = jnp.einsum("bhts,bhsd->bhtd", probs, v)
147
+ context = einops.rearrange(context, "b h t d -> b t (h d)")
148
+ delta = self.out_proj(context).astype(original_dtype)
149
+ return action_tokens + residual_scale * delta
150
+
151
+
152
+ class PiBehavior(_model.BaseModel):
153
+ def __init__(self, config: pi_behavior_config.PiBehaviorConfig, rngs: nnx.Rngs):
154
+ super().__init__(config.action_dim, config.action_horizon, config.max_token_len)
155
+
156
+ # Store config for later use
157
+ self.config = config
158
+
159
+ paligemma_config = _gemma.get_config(config.paligemma_variant)
160
+ action_expert_config = _gemma.get_config(config.action_expert_variant)
161
+
162
+ # Initialize Gemma models with AdaRMS (Pi05 style)
163
+ spatial_inject = getattr(config, "da3", None) is not None and config.da3.enabled
164
+ self.da3_cfg = getattr(config, "da3", None)
165
+ llm = nnx_bridge.ToNNX(
166
+ _gemma.Module(
167
+ configs=[paligemma_config, action_expert_config],
168
+ embed_dtype=config.dtype,
169
+ adarms=True,
170
+ spatial_inject=spatial_inject,
171
+ num_spatial_layers=config.da3.num_inject_layers if spatial_inject else 6,
172
+ spatial_scale=config.da3.spatial_scale if spatial_inject else 2.0,
173
+ spatial_init_std=config.da3.spatial_init_std if spatial_inject else 0.0,
174
+ spatial_logit_gain=config.da3.attn_logit_gain if spatial_inject else False,
175
+ spatial_logit_gain_init=config.da3.attn_logit_gain_init if spatial_inject else 1.0,
176
+ )
177
+ )
178
+ llm.lazy_init(rngs=rngs, method="init", use_adarms=[False, True])
179
+
180
+ # Initialize vision model
181
+ img = nnx_bridge.ToNNX(
182
+ _siglip.Module(
183
+ num_classes=paligemma_config.width,
184
+ variant="So400m/14",
185
+ pool_type="none",
186
+ scan=True,
187
+ dtype_mm=config.dtype,
188
+ )
189
+ )
190
+ img.lazy_init(next(iter(config.fake_obs().images.values())), train=False, rngs=rngs)
191
+
192
+ self.PaliGemma = nnx.Dict(llm=llm, img=img)
193
+
194
+ # DA3 spatial-language bank builder (trainable; frozen DA3 runs inline in the data pipeline).
195
+ self.spatial_bank_builder = None
196
+ if spatial_inject:
197
+ d = config.da3
198
+ self.spatial_bank_builder = _spatial_da3.SpatialBankBuilder(
199
+ hidden_dim=d.hidden_dim,
200
+ da3_channels=d.da3_channels,
201
+ num_layers=d.da3_layers,
202
+ grid_hw=d.grid_hw,
203
+ lang_dim=d.lang_dim,
204
+ num_heads=d.num_heads,
205
+ lang_fusion_depth=d.lang_fusion_depth,
206
+ perceiver_query_std=d.perceiver_query_std,
207
+ bank_token_embed=d.bank_token_embed,
208
+ rngs=rngs,
209
+ )
210
+
211
+ # KV cache transformation for cross-layer attention
212
+ # Allows action expert to attend to learned combinations of VLM layers
213
+ if config.use_kv_transform:
214
+ self.kv_transform = KVCacheTransform(
215
+ num_layers=paligemma_config.depth,
216
+ head_dim=paligemma_config.head_dim,
217
+ num_kv_heads=paligemma_config.num_kv_heads,
218
+ rngs=rngs
219
+ )
220
+ else:
221
+ self.kv_transform = None
222
+
223
+ # Task embeddings table - trainable embeddings for each task
224
+ self.task_embeddings = nnx.Embed(
225
+ num_embeddings=config.num_tasks,
226
+ features=config.task_embedding_dim,
227
+ rngs=rngs,
228
+ )
229
+
230
+ # Stage predictor - predicts stage from VLM output of base task token
231
+ # Outputs MAX_NUM_STAGES logits, but invalid stages are masked per task
232
+ self.stage_pred_from_vlm = nnx.Linear(paligemma_config.width, MAX_NUM_STAGES, rngs=rngs)
233
+
234
+ # Task + subtask fusion layers
235
+ # Combines task embedding + cos/sin encoded subtask state
236
+ self.subtask_encoding_dim = config.task_embedding_dim // 2 # Half of task embedding dim (1024)
237
+
238
+ # Task-specific stage embeddings (one per stage per task)
239
+ # Total embeddings = sum of stages across all tasks (596 for 5-15 stages per task)
240
+ self.task_stage_embeddings = nnx.Embed(
241
+ num_embeddings=TOTAL_TASK_STAGE_EMBEDDINGS,
242
+ features=self.subtask_encoding_dim,
243
+ rngs=rngs,
244
+ )
245
+
246
+ # Gated fusion layers
247
+ # Input: task_embedding + sincos + task_stage_emb = task_dim + 2*subtask_dim
248
+ fusion_input_dim = config.task_embedding_dim + 2 * self.subtask_encoding_dim
249
+
250
+ # Gate networks to learn how to combine different signals
251
+ self.gate_sincos = nnx.Linear(fusion_input_dim, self.subtask_encoding_dim, rngs=rngs)
252
+ self.gate_task_stage = nnx.Linear(fusion_input_dim, self.subtask_encoding_dim, rngs=rngs)
253
+ self.gate_task = nnx.Linear(fusion_input_dim, config.task_embedding_dim, rngs=rngs)
254
+
255
+ # Fusion networks to create multiple conditioned vectors
256
+ self.fusion_layer1 = nnx.Linear(fusion_input_dim, config.task_embedding_dim * 2, rngs=rngs)
257
+ self.fusion_layer2 = nnx.Linear(config.task_embedding_dim * 2, config.task_embedding_dim, rngs=rngs)
258
+
259
+ # Additional projection for stage-dominant representation (2 signals now)
260
+ self.stage_projection = nnx.Linear(2 * self.subtask_encoding_dim, config.task_embedding_dim, rngs=rngs)
261
+
262
+ # Pi05 style layers
263
+ self.action_in_proj = nnx.Linear(config.action_dim, action_expert_config.width, rngs=rngs)
264
+ self.time_mlp_in = nnx.Linear(action_expert_config.width, action_expert_config.width, rngs=rngs)
265
+ self.time_mlp_out = nnx.Linear(action_expert_config.width, action_expert_config.width, rngs=rngs)
266
+ self.action_out_proj = nnx.Linear(action_expert_config.width, config.action_dim, rngs=rngs)
267
+ if config.use_spatial_action_cross_attention:
268
+ self.spatial_action_xattn = SpatialActionCrossAttention(
269
+ action_width=action_expert_config.width,
270
+ spatial_width=config.spatial_token_dim,
271
+ num_heads=config.spatial_num_heads,
272
+ rngs=rngs,
273
+ )
274
+ logger.info(
275
+ "DA3 spatial action cross-attention enabled: tokens=%s dim=%s heads=%s scale=%s",
276
+ config.spatial_num_tokens,
277
+ config.spatial_token_dim,
278
+ config.spatial_num_heads,
279
+ config.spatial_residual_scale,
280
+ )
281
+ else:
282
+ self.spatial_action_xattn = None
283
+
284
+ # Correlated noise generation
285
+ # Initialize as NNX Intermediate (excluded from checkpoints, loaded from norm_stats)
286
+ # Full correlation matrix with beta shrinkage for robustness
287
+ flat_dim = config.action_horizon * config.action_dim
288
+ self.action_correlation_cholesky = nnx.Intermediate(
289
+ jnp.eye(flat_dim), # Identity matrix as placeholder
290
+ )
291
+ self.correlation_loaded = False # Track if correlation matrix has been loaded
292
+ self.use_correlated_noise = config.use_correlated_noise
293
+ self.correlation_beta = config.correlation_beta # Shrinkage parameter for regularization
294
+
295
+ # Inpainting cache: stores precomputed matrices for simple correlation-based inpainting
296
+ # Key: num_inpainted_steps (length of inpainted sequence)
297
+ # Value: dict with {O_indices, U_indices, Sigma_UO_SOOinv}
298
+ self.inpainting_cache = {}
299
+
300
+ # FAST auxiliary training components
301
+ if config.use_fast_auxiliary:
302
+ # FAST embedding layer (vocab_size → paligemma_width)
303
+ # Use paligemma width (2048) to match other prefix tokens
304
+ self.fast_token_embedding = nnx.Embed(
305
+ num_embeddings=config.fast_vocab_size,
306
+ features=paligemma_config.width,
307
+ rngs=rngs
308
+ )
309
+
310
+ # FAST projection head (paligemma_width → vocab_size)
311
+ self.fast_token_proj = nnx.Linear(
312
+ paligemma_config.width,
313
+ config.fast_vocab_size,
314
+ rngs=rngs
315
+ )
316
+
317
+ logger.info(f"FAST auxiliary enabled, vocab_size={config.fast_vocab_size}")
318
+
319
+ # This attribute gets automatically set by model.train() and model.eval().
320
+ self.deterministic = True
321
+
322
+ def _compute_banks(self, observation):
323
+ """Build the per-view DA3 spatial banks once per forward (timestep-independent; reused
324
+ across all flow samples / denoise steps)."""
325
+ if self.spatial_bank_builder is None or getattr(observation, "da3_features", None) is None:
326
+ return None
327
+ feats = observation.da3_features
328
+ # Features arrive as raw BITS to minimize host<->device transfer; decode on-device to bf16.
329
+ # uint16 = bf16 bits (inline extractor); uint8 = fp8-e4m3fn bytes (legacy cache).
330
+ if feats.dtype == jnp.uint8:
331
+ feats = jax.lax.bitcast_convert_type(feats, jnp.float8_e4m3fn).astype(jnp.bfloat16)
332
+ elif feats.dtype == jnp.uint16:
333
+ feats = jax.lax.bitcast_convert_type(feats, jnp.bfloat16)
334
+ else:
335
+ feats = feats.astype(jnp.bfloat16)
336
+ return self.spatial_bank_builder(
337
+ feats,
338
+ observation.da3_ray,
339
+ observation.da3_depth,
340
+ observation.camera_extrinsics,
341
+ observation.lang_feat,
342
+ observation.lang_mask,
343
+ )
344
+
345
+ def apply_spatial_action_conditioning(self, observation: Observation, action_tokens: jnp.ndarray) -> jnp.ndarray:
346
+ """Inject precomputed DA3 spatial tokens into action-token hidden states."""
347
+ if self.spatial_action_xattn is None or observation.spatial_tokens is None:
348
+ return action_tokens
349
+
350
+ return self.spatial_action_xattn(
351
+ action_tokens,
352
+ observation.spatial_tokens,
353
+ observation.spatial_token_mask,
354
+ residual_scale=self.config.spatial_residual_scale,
355
+ )
356
+
357
+ def encode_subtask_state(
358
+ self,
359
+ subtask_state: at.Int[at.Array, " b"],
360
+ task_ids: at.Int[at.Array, " b"]
361
+ ) -> at.Float[at.Array, "b {self.subtask_encoding_dim}"]:
362
+ """Encode subtask state using cos/sin positional encoding, scaled per task.
363
+
364
+ Args:
365
+ subtask_state: Current stage for each sample [B]
366
+ task_ids: Task ID for each sample [B]
367
+
368
+ Returns:
369
+ Positional encodings scaled to [0, 1] range based on task-specific stage count [B, 1024]
370
+ """
371
+ # Get number of stages for each task in batch using JAX array indexing
372
+ # Convert tuple to JAX array inside function to avoid import-time device allocation
373
+ task_num_stages_array = jnp.array(TASK_NUM_STAGES, dtype=jnp.int32)
374
+ task_num_stages = task_num_stages_array[task_ids] # [B] - JAX array indexing
375
+
376
+ # Normalize: stage 0 → 0.0, last stage → 1.0 (per-task scaling)
377
+ # Add maximum to avoid division by zero for edge cases
378
+ normalized_state = subtask_state.astype(jnp.float32) / jnp.maximum(task_num_stages.astype(jnp.float32) - 1.0, 1.0)
379
+
380
+ # Use cos/sin encoding similar to timestep encoding
381
+ return posemb_sincos(
382
+ normalized_state,
383
+ self.subtask_encoding_dim,
384
+ min_period=1e-3,
385
+ max_period=1.0
386
+ )
387
+
388
+ def load_correlation_matrix(self, norm_stats: dict):
389
+ """Load full correlation matrix from normalization statistics and apply shrinkage.
390
+
391
+ This should be called after model initialization when norm_stats are available.
392
+ Applies shrinkage regularization: S_reg = beta * S + (1-beta) * I for robustness.
393
+
394
+ Args:
395
+ norm_stats: Dictionary containing normalization statistics (from normalize.load()),
396
+ with 'actions' key containing NormStats with action_correlation_cholesky field.
397
+
398
+ Raises:
399
+ ValueError: If use_correlated_noise=True but correlation matrix is missing.
400
+ TypeError: If norm_stats structure is incorrect.
401
+ """
402
+ if not self.use_correlated_noise:
403
+ logger.info("Correlated noise disabled in config, skipping correlation matrix loading")
404
+ return
405
+
406
+ # Validate norm_stats is a dict
407
+ if not isinstance(norm_stats, dict):
408
+ raise TypeError(
409
+ f"norm_stats must be a dict, got {type(norm_stats).__name__}. "
410
+ "Ensure norm_stats are loaded using openpi.shared.normalize.load()."
411
+ )
412
+
413
+ # Check 'actions' key exists
414
+ if 'actions' not in norm_stats:
415
+ raise ValueError(
416
+ "use_correlated_noise=True but 'actions' key not found in norm_stats. "
417
+ f"Found keys: {list(norm_stats.keys())}. "
418
+ "Run compute_norm_stats.py with --correlation flag to generate correlation matrix."
419
+ )
420
+
421
+ actions_stats = norm_stats['actions']
422
+
423
+ # Extract correlation matrix (support both dict and attribute access for flexibility)
424
+ if isinstance(actions_stats, dict):
425
+ chol_matrix = actions_stats.get('action_correlation_cholesky')
426
+ access_method = "dict"
427
+ elif hasattr(actions_stats, 'action_correlation_cholesky'):
428
+ chol_matrix = actions_stats.action_correlation_cholesky
429
+ access_method = "attribute"
430
+ else:
431
+ raise TypeError(
432
+ f"norm_stats['actions'] has unexpected type {type(actions_stats).__name__} "
433
+ f"and cannot access 'action_correlation_cholesky'. "
434
+ "Ensure norm_stats are loaded using openpi.shared.normalize.load()."
435
+ )
436
+
437
+ # Strict validation: correlation matrix must exist and be non-None
438
+ if chol_matrix is None:
439
+ raise ValueError(
440
+ "use_correlated_noise=True but 'action_correlation_cholesky' is None in norm_stats['actions']. "
441
+ "This means the correlation matrix was not computed during norm_stats generation. "
442
+ "Run compute_norm_stats.py with --correlation flag to generate correlation matrix."
443
+ )
444
+
445
+ logger.info(f"Successfully accessed correlation matrix via {access_method} access")
446
+
447
+ # Validate correlation matrix shape
448
+ expected_dim = self.action_horizon * self.action_dim
449
+ try:
450
+ L = jnp.array(chol_matrix)
451
+ except Exception as e:
452
+ raise ValueError(
453
+ f"Failed to convert action_correlation_cholesky to array: {e}. "
454
+ "The correlation matrix may be corrupted or in an invalid format."
455
+ )
456
+
457
+ if L.ndim != 2 or L.shape[0] != L.shape[1]:
458
+ raise ValueError(
459
+ f"action_correlation_cholesky must be a square 2D matrix, got shape {L.shape}. "
460
+ f"Expected shape: ({expected_dim}, {expected_dim})"
461
+ )
462
+
463
+ if L.shape[0] != expected_dim:
464
+ raise ValueError(
465
+ f"action_correlation_cholesky has wrong dimensions: {L.shape[0]}x{L.shape[0]}. "
466
+ f"Expected {expected_dim}x{expected_dim} (action_horizon={self.action_horizon} * action_dim={self.action_dim}). "
467
+ "This indicates the correlation matrix was computed for a different action space configuration."
468
+ )
469
+
470
+ # Reconstruct covariance matrix from Cholesky
471
+ Sigma = L @ L.T
472
+
473
+ # Apply shrinkage regularization: Σ_reg = beta * Σ + (1-beta) * I
474
+ beta = self.correlation_beta
475
+ logger.info(f"Applying shrinkage regularization with beta={beta:.2f}")
476
+
477
+ Sigma_reg = beta * Sigma + (1 - beta) * jnp.eye(Sigma.shape[0])
478
+
479
+ # Compute Cholesky decomposition of regularized covariance
480
+ try:
481
+ L_reg = jnp.linalg.cholesky(Sigma_reg)
482
+ except Exception as e:
483
+ raise RuntimeError(
484
+ f"Cholesky decomposition failed on regularized covariance: {e}. "
485
+ "This indicates the regularized correlation matrix is not positive definite. "
486
+ f"Current beta={beta:.2f}. Try decreasing correlation_beta closer to 0.0 for more shrinkage/regularization."
487
+ )
488
+
489
+ # Update the Intermediate value
490
+ self.action_correlation_cholesky.value = L_reg
491
+ self.correlation_loaded = True
492
+
493
+ logger.info(
494
+ f"✓ Loaded correlation matrix with shape {L_reg.shape} "
495
+ f"(beta={beta:.2f} shrinkage applied)"
496
+ )
497
+ logger.info(
498
+ f" Memory usage: {L_reg.nbytes / 1024 / 1024:.2f} MB"
499
+ )
500
+
501
+ def generate_correlated_noise(
502
+ self,
503
+ rng: at.KeyArrayLike,
504
+ batch_size: int,
505
+ ) -> at.Float[at.Array, "b {self.action_horizon} {self.action_dim}"]:
506
+ """Generate correlated noise matching action covariance structure.
507
+
508
+ Uses full correlation matrix with optional beta shrinkage for robustness.
509
+
510
+ Args:
511
+ rng: Random key for noise generation
512
+ batch_size: Number of noise samples to generate
513
+
514
+ Returns:
515
+ Correlated noise with shape [batch_size, action_horizon, action_dim]
516
+
517
+ Raises:
518
+ RuntimeError: If use_correlated_noise=True but correlation matrix not loaded.
519
+ """
520
+ if not self.use_correlated_noise:
521
+ # Independent Gaussian noise when correlated noise is disabled
522
+ return jax.random.normal(rng, (batch_size, self.action_horizon, self.action_dim))
523
+
524
+ if not self.correlation_loaded:
525
+ raise RuntimeError(
526
+ "use_correlated_noise=True but correlation matrix is not loaded. "
527
+ "Ensure load_correlation_matrix() was called during model initialization. "
528
+ "Run compute_norm_stats.py with --correlation flag to generate correlation matrix."
529
+ )
530
+
531
+ # Generate standard correlated noise using Cholesky decomposition
532
+ flat_dim = self.action_horizon * self.action_dim
533
+ standard_normal = jax.random.normal(rng, (batch_size, flat_dim))
534
+ correlated_flat = standard_normal @ self.action_correlation_cholesky.value.T
535
+ correlated_noise = correlated_flat.reshape(batch_size, self.action_horizon, self.action_dim)
536
+ return correlated_noise
537
+
538
+ def _precompute_correction_matrix(
539
+ self,
540
+ O_indices: at.Int[at.Array, " nO"],
541
+ U_indices: at.Int[at.Array, " nU"],
542
+ ) -> dict:
543
+ """Precompute matrix for correlation-aware inpainting correction.
544
+
545
+ Computes Σ_{UO}Σ_{OO}^{-1} which propagates corrections from O to U
546
+ while preserving correlation structure.
547
+
548
+ Args:
549
+ O_indices: Flat indices of inpainted dimensions [|O|]
550
+ U_indices: Flat indices of free dimensions [|U|]
551
+
552
+ Returns:
553
+ Dictionary with {O_indices, U_indices, correction_matrix}
554
+
555
+ Raises:
556
+ RuntimeError: If correlation matrix is not loaded
557
+ """
558
+ if not self.correlation_loaded:
559
+ raise RuntimeError(
560
+ "Cannot precompute correction matrix: correlation matrix not loaded. "
561
+ "Call load_correlation_matrix() first."
562
+ )
563
+
564
+ L = self.action_correlation_cholesky.value
565
+ Sigma = L @ L.T # Full covariance matrix [hd, hd]
566
+
567
+ # Extract submatrices
568
+ Sigma_OO = Sigma[jnp.ix_(O_indices, O_indices)] # [|O|, |O|]
569
+ Sigma_UO = Sigma[jnp.ix_(U_indices, O_indices)] # [|U|, |O|]
570
+
571
+ # Compute correction matrix: Σ_{UO} @ Σ_{OO}^{-1}
572
+ # This propagates corrections from O to U
573
+ eps_OO = 1e-6 * jnp.maximum(jnp.mean(jnp.diag(Sigma_OO)), 1.0)
574
+ Sigma_OO_reg = Sigma_OO + eps_OO * jnp.eye(Sigma_OO.shape[0])
575
+
576
+ # Solve Σ_{OO}_reg @ X = Σ_{UO}.T for X, then transpose
577
+ correction_matrix = jax.scipy.linalg.solve(
578
+ Sigma_OO_reg, Sigma_UO.T, assume_a='pos'
579
+ ).T # [|U|, |O|]
580
+
581
+ return {
582
+ 'O_indices': O_indices,
583
+ 'U_indices': U_indices,
584
+ 'correction_matrix': correction_matrix, # Σ_{UO}Σ_{OO}^{-1}
585
+ }
586
+
587
+ def fuse_task_and_subtask(
588
+ self, task_embedding: at.Float[at.Array, "b d"], task_ids: at.Int[at.Array, " b"], subtask_state: at.Int[at.Array, " b"]
589
+ ) -> at.Float[at.Array, "b n d"]:
590
+ """Fuse task embedding with subtask state encoding using multiple representations.
591
+
592
+ Returns multiple vectors that are differently conditioned by the subtask state:
593
+ 1. Task-gated representation (task embedding modulated by subtask)
594
+ 2. Balanced fusion (task + subtask combined)
595
+ 3. Stage-dominant representation (subtask features projected to task space)
596
+ 4. Pure stage representation (concatenated learned embeddings)
597
+
598
+ All output representations have dimension 2048 (task_embedding_dim).
599
+
600
+ Args:
601
+ task_embedding: Base task embedding [b, 2048]
602
+ task_ids: Task IDs for task-specific stage embeddings [b]
603
+ subtask_state: Subtask state indices [b]
604
+
605
+ Returns:
606
+ Multiple fused embeddings [b, 4, 2048]
607
+ """
608
+ # Get subtask representations
609
+ sincos_encoding = self.encode_subtask_state(subtask_state, task_ids) # [b, 1024]
610
+
611
+ # Task-specific stage embedding with corrected indexing
612
+ # Use vectorized lookup: offset + stage for each task
613
+ # Convert tuple to JAX array inside function to avoid import-time device allocation
614
+ task_stage_offsets_array = jnp.array(TASK_STAGE_OFFSETS, dtype=jnp.int32)
615
+ task_stage_offsets = task_stage_offsets_array[task_ids] # [b] - JAX array indexing
616
+ task_stage_idx = task_stage_offsets + subtask_state # [b]
617
+ task_stage_embedding = self.task_stage_embeddings(task_stage_idx) # [b, 1024]
618
+
619
+ # Concatenate inputs for gating: task (2048) + sincos (1024) + task_stage (1024) = 4096
620
+ all_inputs = jnp.concatenate([
621
+ task_embedding, # [b, 2048]
622
+ sincos_encoding, # [b, 1024]
623
+ task_stage_embedding # [b, 1024]
624
+ ], axis=-1) # [b, 4096]
625
+
626
+ # Learn gates for each component (sigmoid to get 0-1 scaling)
627
+ gate_sincos = nnx.sigmoid(self.gate_sincos(all_inputs)) # [b, 1024]
628
+ gate_task_stage = nnx.sigmoid(self.gate_task_stage(all_inputs)) # [b, 1024]
629
+ gate_task = nnx.sigmoid(self.gate_task(all_inputs)) # [b, 2048]
630
+
631
+ # 1. Task-gated representation: task embedding modulated by subtask info [b, 2048]
632
+ task_gated = task_embedding * gate_task
633
+
634
+ # 2. Balanced fusion: combine all signals through fusion network [b, 2048]
635
+ x = self.fusion_layer1(all_inputs) # [b, 4096]
636
+ x = nnx.relu(x)
637
+ balanced_fusion = self.fusion_layer2(x) # [b, 2048]
638
+
639
+ # 3. Stage-dominant: weighted combination of stage signals, then project [b, 2048]
640
+ gated_stage_features = jnp.concatenate([
641
+ sincos_encoding * gate_sincos, # [b, 1024]
642
+ task_stage_embedding * gate_task_stage # [b, 1024]
643
+ ], axis=-1) # [b, 2048]
644
+ stage_dominant = self.stage_projection(gated_stage_features) # [b, 2048]
645
+
646
+ # 4. Pure stage: concatenate the embeddings (already 2048) [b, 2048]
647
+ pure_stage = jnp.concatenate([sincos_encoding, task_stage_embedding], axis=-1)
648
+
649
+ # Stack all four representations [b, 4, 2048]
650
+ fused_embeddings = jnp.stack([task_gated, balanced_fusion, stage_dominant, pure_stage], axis=1)
651
+
652
+ return fused_embeddings
653
+
654
+ @at.typecheck
655
+ def embed_prefix(
656
+ self,
657
+ obs: Observation
658
+ ) -> tuple[
659
+ at.Float[at.Array, "b s emb"],
660
+ at.Bool[at.Array, "b s"],
661
+ at.Bool[at.Array, " s"]
662
+ ]:
663
+ """
664
+ Embed prefix: images + task + state + FAST_tokens (if provided).
665
+
666
+ Args:
667
+ obs: Observation (may include fast_tokens and fast_token_mask)
668
+
669
+ Returns:
670
+ tokens, input_mask, ar_mask
671
+ """
672
+ input_mask = []
673
+ ar_mask = []
674
+ tokens = []
675
+
676
+ # Embed images
677
+ image_token_list = []
678
+ # Respect freeze_vision_backbone config: if frozen, always use train=False
679
+ # If not frozen, use the model's training state (self.deterministic)
680
+ vision_train_mode = (not self.deterministic) and (not self.config.freeze_vision_backbone)
681
+
682
+ for name in obs.images:
683
+ image_tokens, _ = self.PaliGemma.img(obs.images[name], train=vision_train_mode)
684
+ image_token_list.append(image_tokens) # Store for subtask prediction
685
+
686
+ tokens.append(image_tokens)
687
+ input_mask.append(
688
+ einops.repeat(
689
+ obs.image_masks[name],
690
+ "b -> b s",
691
+ s=image_tokens.shape[1],
692
+ )
693
+ )
694
+ # Image tokens attend to each other
695
+ ar_mask += [False] * image_tokens.shape[1]
696
+
697
+ # Add task embeddings with subtask state fusion
698
+ if obs.tokenized_prompt is not None:
699
+ # obs.tokenized_prompt now contains task_ids (shape: [batch_size, 2])
700
+ task_ids = obs.tokenized_prompt[:, 0] # Extract task_id: [batch_size]
701
+ base_task_embedding = self.task_embeddings(task_ids) # shape: [batch_size, embed_dim]
702
+
703
+ # ALWAYS use the input subtask state - never use predicted state inside model
704
+ if obs.tokenized_prompt.shape[1] > 1: # If we have [task_id, subtask_state]
705
+ subtask_state = obs.tokenized_prompt[:, 1] # Use input subtask state
706
+ else:
707
+ raise ValueError("subtask_state must be provided in tokenized_prompt for PI_BEHAVIOR model")
708
+
709
+ # Fuse task embedding with subtask state - returns [b, 4, d] with multiple representations
710
+ fused_task_embeddings = self.fuse_task_and_subtask(base_task_embedding, task_ids, subtask_state)
711
+
712
+ # Create task token sequence: [base_task, task_gated, balanced_fusion, stage_dominant, pure_stage]
713
+ task_sequence = jnp.concatenate([
714
+ base_task_embedding[:, None, :], # [b, 1, d] - base task token
715
+ fused_task_embeddings # [b, 4, d] - stage-conditioned tokens
716
+ ], axis=1) # [b, 5, d]
717
+
718
+ tokens.append(task_sequence)
719
+ # All task tokens are valid
720
+ task_mask = jnp.ones((obs.tokenized_prompt.shape[0], 5), dtype=jnp.bool_)
721
+ input_mask.append(task_mask)
722
+ # Hierarchical attention: base task (False) then stage tokens (True, False, False, False)
723
+ # Base task attends to images bidirectionally
724
+ # Stage tokens attend to images+task but not vice versa
725
+ ar_mask += [False] + [True, False, False, False]
726
+
727
+ # Add state as discrete tokens (Pi05 style)
728
+ # Discretize state into bins
729
+ discretized_state = jnp.digitize(obs.state, bins=jnp.linspace(-1, 1, 256 + 1)[:-1]) - 1
730
+ discretized_state = jnp.clip(discretized_state, 0, 255) # Ensure valid range
731
+
732
+ # Embed each dimension of the discretized state
733
+ state_tokens = []
734
+ for i in range(obs.state.shape[-1]):
735
+ state_dim_tokens = self.PaliGemma.llm(discretized_state[:, i:i+1], method="embed")
736
+ state_tokens.append(state_dim_tokens)
737
+
738
+ if state_tokens:
739
+ state_tokens = jnp.concatenate(state_tokens, axis=1) # shape: [batch_size, state_dim, embed_dim]
740
+ tokens.append(state_tokens)
741
+ input_mask.append(jnp.ones((obs.state.shape[0], obs.state.shape[-1]), dtype=jnp.bool_))
742
+ # State tokens have full bidirectional attention with all prefix tokens
743
+ # (images, task, stages, and other state tokens)
744
+ ar_mask += [False] * state_tokens.shape[1]
745
+
746
+ # FAST tokens (from observation if provided)
747
+ if self.config.use_fast_auxiliary and obs.fast_tokens is not None:
748
+ fast_tokens = obs.fast_tokens # [B, T]
749
+ fast_token_mask = obs.fast_token_mask # [B, T]
750
+
751
+ # Teacher forcing: shift right [BOS, tok0, tok1, ..., tok_{T-1}]
752
+ bos_token = jnp.zeros((fast_tokens.shape[0], 1), dtype=jnp.int32)
753
+ shifted_tokens = jnp.concatenate([bos_token, fast_tokens[:, :-1]], axis=1)
754
+
755
+ # Shift mask too: [True, mask_0, mask_1, ..., mask_{T-1}]
756
+ bos_mask = jnp.ones((fast_tokens.shape[0], 1), dtype=jnp.bool_)
757
+ shifted_mask = jnp.concatenate([bos_mask, fast_token_mask[:, :-1]], axis=1)
758
+
759
+ # Embed using FAST embedding layer (NOT Paligemma!)
760
+ fast_token_emb = self.fast_token_embedding(shifted_tokens) # [B, T, D]
761
+
762
+ tokens.append(fast_token_emb)
763
+ input_mask.append(shifted_mask) # Use the actual token mask
764
+ # Causal for FAST: ALL tokens are causal (pure autoregressive)
765
+ ar_mask += [True] * shifted_tokens.shape[1]
766
+
767
+ tokens = jnp.concatenate(tokens, axis=1)
768
+ input_mask = jnp.concatenate(input_mask, axis=1)
769
+ ar_mask = jnp.array(ar_mask)
770
+ return tokens, input_mask, ar_mask
771
+
772
+ @at.typecheck
773
+ def embed_suffix(
774
+ self, obs: Observation, noisy_actions: _model.Actions, timestep: at.Float[at.Array, " b"]
775
+ ) -> tuple[
776
+ at.Float[at.Array, "b s emb"],
777
+ at.Bool[at.Array, "b s"],
778
+ at.Bool[at.Array, " s"],
779
+ at.Float[at.Array, "b emb"],
780
+ ]:
781
+ input_mask = []
782
+ ar_mask = []
783
+ tokens = []
784
+
785
+ # Pi05 style: no explicit state token in suffix (it's in prefix as discrete tokens)
786
+
787
+ action_tokens = self.action_in_proj(noisy_actions)
788
+ # Embed timestep using sine-cosine positional encoding
789
+ time_emb = posemb_sincos(timestep, self.action_in_proj.out_features, min_period=4e-3, max_period=4.0)
790
+
791
+ # Pi05 style: time MLP for adaRMS
792
+ time_emb = self.time_mlp_in(time_emb)
793
+ time_emb = nnx.swish(time_emb)
794
+ time_emb = self.time_mlp_out(time_emb)
795
+ time_emb = nnx.swish(time_emb)
796
+ action_expert_tokens = action_tokens
797
+ adarms_cond = time_emb
798
+
799
+ tokens.append(action_expert_tokens)
800
+ input_mask.append(jnp.ones(action_expert_tokens.shape[:2], dtype=jnp.bool_))
801
+
802
+ # image/task/state inputs do not attend to action tokens
803
+ ar_mask += [True] + ([False] * (self.action_horizon - 1))
804
+
805
+ tokens = jnp.concatenate(tokens, axis=1)
806
+ input_mask = jnp.concatenate(input_mask, axis=1)
807
+ ar_mask = jnp.array(ar_mask)
808
+ return tokens, input_mask, ar_mask, adarms_cond
809
+
810
+ @override
811
+ def compute_loss(
812
+ self, rng: at.KeyArrayLike, observation: Observation, actions: _model.Actions, *, train: bool = False
813
+ ) -> at.Float[at.Array, "*b ah"]:
814
+ """Not used - we only use compute_detailed_loss() for training."""
815
+ raise NotImplementedError("Use compute_detailed_loss() instead")
816
+
817
+ @override
818
+ def compute_detailed_loss(
819
+ self, rng: at.KeyArrayLike, observation: Observation, actions: _model.Actions, *, train: bool = False, num_flow_samples: int = 1
820
+ ) -> dict[str, at.Float[at.Array, "*b"]]:
821
+ """
822
+ Compute detailed loss with multiple flow matching samples.
823
+
824
+ Simplified approach using KV cache:
825
+ - Compute prefix KV cache once (with FAST tokens)
826
+ - Remove FAST tokens from cache (action expert doesn't attend to FAST)
827
+ - Process N flow samples independently, each reusing the same cached prefix
828
+ - Each sample has different noise and different time
829
+ - Average losses across samples
830
+ """
831
+ losses = {}
832
+
833
+ preprocess_rng, rng = jax.random.split(rng)
834
+ observation = preprocess_observation(preprocess_rng, observation, train=train)
835
+
836
+ batch_size = actions.shape[0]
837
+
838
+ # 1. Embed prefix once (includes FAST tokens if provided in observation)
839
+ prefix_tokens, prefix_mask, prefix_ar_mask = self.embed_prefix(observation)
840
+
841
+ # 2. Compute prefix KV cache
842
+ prefix_attn_mask = make_attn_mask(prefix_mask, prefix_ar_mask)
843
+ positions_prefix = jnp.cumsum(prefix_mask, axis=1) - 1
844
+ (prefix_out, _), kv_cache_full = self.PaliGemma.llm(
845
+ [prefix_tokens, None],
846
+ mask=prefix_attn_mask,
847
+ positions=positions_prefix
848
+ )
849
+
850
+ # DA3 banks: timestep-independent, computed ONCE and closure-captured by the vmapped
851
+ # flow-sample fn (vmap broadcasts them across the N samples).
852
+ spatial_banks = self._compute_banks(observation)
853
+
854
+ # 3. Predict stage from VLM output of base task token
855
+ # Base task token is the first token after all image tokens
856
+ # Image tokens all have ar_mask=False, task starts with ar_mask=False (base) then True (stage tokens)
857
+ # Structure: [images (all False)] [base_task (False)] [stages (True, False, False, False)]
858
+ # Find first True (first stage token), base task is at that index - 1
859
+ first_stage_token_idx = jnp.argmax(prefix_ar_mask) # Returns index of first True
860
+ base_task_token_idx = first_stage_token_idx - 1
861
+ base_task_output = prefix_out[:, base_task_token_idx, :]
862
+ subtask_logits = self.stage_pred_from_vlm(base_task_output) # [B, MAX_NUM_STAGES]
863
+
864
+ # Mask out invalid stages for each task (vectorized JAX operations)
865
+ task_ids = observation.tokenized_prompt[:, 0] # [B]
866
+ task_num_stages_array = jnp.array(TASK_NUM_STAGES, dtype=jnp.int32)
867
+ task_num_stages = task_num_stages_array[task_ids] # [B] - JAX array indexing
868
+ stage_range = jnp.arange(MAX_NUM_STAGES) # [15]
869
+ valid_mask = stage_range[None, :] < task_num_stages[:, None] # [B, 15]
870
+ subtask_logits = jnp.where(valid_mask, subtask_logits, -jnp.inf) # Mask invalid stages
871
+
872
+ # 4. Extract FAST loss from prefix output (before removing from cache)
873
+ fast_loss_value = 0.0
874
+ fast_len = 0
875
+ fast_targets = observation.fast_tokens
876
+ fast_token_mask = observation.fast_token_mask
877
+
878
+ if self.config.use_fast_auxiliary and fast_targets is not None:
879
+ fast_len = fast_targets.shape[1]
880
+ fast_start_idx = prefix_tokens.shape[1] - fast_len
881
+ fast_outputs = prefix_out[:, fast_start_idx:, :] # [B, T, D]
882
+
883
+ # Project to FAST vocab
884
+ fast_logits = self.fast_token_proj(fast_outputs) # [B, T, vocab_size]
885
+
886
+ # Cross-entropy loss with teacher forcing
887
+ pred_logits = fast_logits # [B, T, vocab]
888
+ target_tokens = fast_targets # [B, T]
889
+ loss_mask = fast_token_mask # [B, T]
890
+
891
+ log_probs = jax.nn.log_softmax(pred_logits, axis=-1)
892
+ target_log_probs = jnp.take_along_axis(
893
+ log_probs,
894
+ target_tokens[:, :, None],
895
+ axis=-1
896
+ ).squeeze(-1) # [B, T]
897
+
898
+ fast_token_loss = -target_log_probs # [B, T]
899
+
900
+ # Apply mask and normalize by number of valid tokens
901
+ masked_loss = fast_token_loss * loss_mask # [B, T]
902
+ num_valid_tokens = jnp.maximum(jnp.sum(loss_mask, axis=-1), 1) # [B]
903
+ losses["fast_loss"] = jnp.sum(masked_loss, axis=-1) / num_valid_tokens # [B]
904
+
905
+ # Accuracy (only on valid tokens)
906
+ pred_tokens = jnp.argmax(pred_logits, axis=-1)
907
+ correct = (pred_tokens == target_tokens) * loss_mask
908
+ losses["fast_accuracy"] = jnp.sum(correct, axis=-1) / num_valid_tokens
909
+
910
+ fast_loss_value = self.config.fast_loss_weight * jnp.mean(losses["fast_loss"])
911
+ elif fast_targets is not None:
912
+ # FAST auxiliary is disabled but data contains FAST tokens
913
+ raise ValueError(
914
+ "use_fast_auxiliary=False but observation contains fast_tokens. "
915
+ "Either enable use_fast_auxiliary in config or ensure data doesn't contain fast_tokens."
916
+ )
917
+
918
+ # 5. Remove FAST tokens from KV cache (action expert doesn't attend to FAST)
919
+ # KV cache shape: [layers, batch, seq_len, num_kv_heads, head_dim]
920
+ if fast_len > 0:
921
+ cache_k, cache_v = kv_cache_full
922
+ # Remove last fast_len tokens from sequence dimension
923
+ cache_k = cache_k[:, :, :-fast_len, :, :]
924
+ cache_v = cache_v[:, :, :-fast_len, :, :]
925
+ kv_cache_for_actions = (cache_k, cache_v)
926
+ prefix_len_for_actions = prefix_tokens.shape[1] - fast_len
927
+ # Truncate prefix mask and ar_mask for action expert
928
+ prefix_mask_for_actions = prefix_mask[:, :-fast_len]
929
+ prefix_ar_mask_for_actions = prefix_ar_mask[:-fast_len]
930
+ else:
931
+ kv_cache_for_actions = kv_cache_full
932
+ prefix_len_for_actions = prefix_tokens.shape[1]
933
+ prefix_mask_for_actions = prefix_mask
934
+ prefix_ar_mask_for_actions = prefix_ar_mask
935
+
936
+ # 6. Knowledge insulation: stop gradients from action expert to VLM
937
+ # This must happen BEFORE kv_transform so transform still receives gradients
938
+ if self.config.use_knowledge_insulation:
939
+ kv_cache_for_actions = jax.tree.map(jax.lax.stop_gradient, kv_cache_for_actions)
940
+
941
+ # 7. Transform KV cache (after stop_gradient, so it receives action expert gradients)
942
+ if self.kv_transform is not None:
943
+ kv_cache_for_actions = self.kv_transform(kv_cache_for_actions)
944
+
945
+ # 8. Define single flow sample processing
946
+ def process_one_flow_sample(sample_rng):
947
+ """Process one flow sample using the original cached prefix."""
948
+ noise_rng, time_rng = jax.random.split(sample_rng)
949
+
950
+ # Generate different noise and time for this sample
951
+ noise = self.generate_correlated_noise(noise_rng, batch_size)
952
+ time = jax.random.beta(time_rng, 1.5, 1, (batch_size,)) * 0.999 + 0.001
953
+
954
+ # Compute noisy actions and target velocity
955
+ time_expanded = time[:, None, None]
956
+ x_t = time_expanded * noise + (1 - time_expanded) * actions
957
+ u_t = noise - actions
958
+
959
+ # Embed suffix for this sample
960
+ suffix_tokens, suffix_mask, suffix_ar_mask, adarms_cond = self.embed_suffix(
961
+ observation, x_t, time
962
+ )
963
+
964
+ # Build attention mask: suffix attends to prefix (without FAST) + itself
965
+ # When using KV cache, mask shape should be [batch, suffix_len, prefix_len + suffix_len]
966
+ suffix_attn_mask = make_attn_mask(suffix_mask, suffix_ar_mask)
967
+ prefix_attn_mask = einops.repeat(
968
+ prefix_mask_for_actions, "b p -> b s p", s=suffix_tokens.shape[1]
969
+ )
970
+ full_attn_mask = jnp.concatenate([prefix_attn_mask, suffix_attn_mask], axis=-1)
971
+
972
+ # Positions for suffix start after cached prefix
973
+ suffix_positions = prefix_len_for_actions + jnp.cumsum(suffix_mask, axis=-1) - 1
974
+
975
+ # Forward pass with cached prefix (discard returned cache - don't modify original!)
976
+ (_, suffix_out), _ = self.PaliGemma.llm(
977
+ [None, suffix_tokens],
978
+ mask=full_attn_mask,
979
+ positions=suffix_positions,
980
+ kv_cache=kv_cache_for_actions, # Original cache, reused for all samples
981
+ adarms_cond=[None, adarms_cond],
982
+ banks=spatial_banks,
983
+ )
984
+
985
+ # Compute velocity and loss
986
+ action_hidden = self.apply_spatial_action_conditioning(
987
+ observation,
988
+ suffix_out[:, -self.action_horizon:],
989
+ )
990
+ v_t = self.action_out_proj(action_hidden)
991
+ action_loss = jnp.square(v_t - u_t) # [B, H, D]
992
+
993
+ return action_loss
994
+
995
+ # 9. Vectorize over N flow samples
996
+ # Disable type checking inside vmap (jaxtyping doesn't handle traced values well)
997
+ flow_rngs = jax.random.split(rng, num_flow_samples)
998
+ with at.disable_typechecking():
999
+ all_action_losses = jax.vmap(process_one_flow_sample)(flow_rngs) # [N, B, H, D]
1000
+
1001
+ # 10. Average over flow samples
1002
+ action_loss = jnp.mean(all_action_losses, axis=0) # [B, H, D]
1003
+
1004
+ # 11. Build per-dimension action losses
1005
+ # Base velocity (x,y,z)
1006
+ losses["action_loss_base_vel_x"] = jnp.mean(action_loss[..., 0], axis=-1)
1007
+ losses["action_loss_base_vel_y"] = jnp.mean(action_loss[..., 1], axis=-1)
1008
+ losses["action_loss_base_vel_z"] = jnp.mean(action_loss[..., 2], axis=-1)
1009
+
1010
+ # Trunk joints (4)
1011
+ for i in range(4):
1012
+ losses[f"action_loss_trunk_{i}"] = jnp.mean(action_loss[..., 3+i], axis=-1)
1013
+
1014
+ # Left arm joints (7)
1015
+ for i in range(7):
1016
+ losses[f"action_loss_left_arm_{i}"] = jnp.mean(action_loss[..., 7+i], axis=-1)
1017
+
1018
+ # Left gripper
1019
+ losses["action_loss_left_gripper"] = jnp.mean(action_loss[..., 14], axis=-1)
1020
+
1021
+ # Right arm joints (7)
1022
+ for i in range(7):
1023
+ losses[f"action_loss_right_arm_{i}"] = jnp.mean(action_loss[..., 15+i], axis=-1)
1024
+
1025
+ # Right gripper
1026
+ losses["action_loss_right_gripper"] = jnp.mean(action_loss[..., 22], axis=-1)
1027
+
1028
+ # Total action loss: mean over horizon (H) and action dims (D) -> [B]
1029
+ losses["action_loss"] = jnp.mean(action_loss, axis=(-2, -1))
1030
+
1031
+ # 12. Add subtask loss during training
1032
+ subtask_loss_value = 0.0
1033
+ if train and observation.tokenized_prompt.shape[1] > 1:
1034
+ ground_truth_subtask = observation.tokenized_prompt[:, 1]
1035
+ subtask_loss = -jax.nn.log_softmax(subtask_logits)[
1036
+ jnp.arange(ground_truth_subtask.shape[0]), ground_truth_subtask
1037
+ ]
1038
+ losses["subtask_loss"] = jnp.mean(subtask_loss)
1039
+ losses["subtask_accuracy"] = jnp.mean(
1040
+ jnp.argmax(subtask_logits, axis=-1) == ground_truth_subtask
1041
+ )
1042
+ subtask_loss_value = self.config.subtask_loss_weight * jnp.mean(subtask_loss)
1043
+
1044
+ # 13. Total loss
1045
+ losses["total_loss"] = losses["action_loss"] + subtask_loss_value + fast_loss_value
1046
+
1047
+ return losses
1048
+
1049
+ @override
1050
+ def sample_actions(
1051
+ self,
1052
+ rng: at.KeyArrayLike,
1053
+ observation: Observation,
1054
+ *,
1055
+ num_steps: int | at.Int[at.Array, ""] = 20,
1056
+ noise: at.Float[at.Array, "b ah ad"] | None = None,
1057
+ initial_actions: at.Float[at.Array, "b n ad"] | None = None,
1058
+ ) -> _model.Actions:
1059
+ observation = preprocess_observation(None, observation, train=False)
1060
+ # Note that we use the convention more common in diffusion literature, where t=1 is noise and t=0 is the target
1061
+ # distribution. yes, this is the opposite of the pi0 paper, and I'm sorry.
1062
+ dt = -1.0 / num_steps
1063
+ batch_size = observation.state.shape[0]
1064
+
1065
+ # Generate or constrain noise based on inpainting requirements
1066
+ if initial_actions is not None:
1067
+ # INPAINTING PATH: Construct constrained noise z that satisfies initial_actions
1068
+ num_initial_actions = initial_actions.shape[1]
1069
+ input_action_dim = initial_actions.shape[2]
1070
+
1071
+ # Pad initial_actions to full model dimensions (32D) and action_horizon (30)
1072
+ if input_action_dim < self.action_dim:
1073
+ action_padding = jnp.zeros((batch_size, num_initial_actions, self.action_dim - input_action_dim))
1074
+ initial_actions_full_dim = jnp.concatenate([initial_actions, action_padding], axis=2)
1075
+ else:
1076
+ initial_actions_full_dim = initial_actions[:, :, :self.action_dim]
1077
+
1078
+ if num_initial_actions < self.action_horizon:
1079
+ seq_padding = jnp.zeros((batch_size, self.action_horizon - num_initial_actions, self.action_dim))
1080
+ initial_actions_padded = jnp.concatenate([initial_actions_full_dim, seq_padding], axis=1)
1081
+ else:
1082
+ initial_actions_padded = initial_actions_full_dim[:, :self.action_horizon]
1083
+
1084
+ # Compute O and U indices for inpainting (JIT-safe: static list comprehensions)
1085
+ flat_dim = self.action_horizon * self.action_dim
1086
+
1087
+ # Build O_indices: first num_initial_actions timesteps, first input_action_dim dimensions
1088
+ O_indices = jnp.array([
1089
+ t * self.action_dim + d
1090
+ for t in range(num_initial_actions)
1091
+ for d in range(input_action_dim)
1092
+ ], dtype=jnp.int32)
1093
+
1094
+ # Build U_indices: all other indices (JIT-safe: static list comprehension)
1095
+ # Python set operations happen at trace time (before JIT), so this is safe
1096
+ O_set = {t * self.action_dim + d for t in range(num_initial_actions) for d in range(input_action_dim)}
1097
+ U_indices = jnp.array([
1098
+ i for i in range(flat_dim) if i not in O_set
1099
+ ], dtype=jnp.int32)
1100
+
1101
+ # Generate noise
1102
+ rng, noise_rng = jax.random.split(rng)
1103
+
1104
+ if self.correlation_loaded:
1105
+ # CORRELATED NOISE: Sample with correlation matrix
1106
+ noise = self.generate_correlated_noise(noise_rng, batch_size)
1107
+ else:
1108
+ # FALLBACK: Independent noise
1109
+ noise = jax.random.normal(noise_rng, (batch_size, self.action_horizon, self.action_dim))
1110
+
1111
+ # Extract fixed z_O and x0_O for constraint enforcement
1112
+ noise_flat = noise.reshape(batch_size, flat_dim)
1113
+ fixed_z_O = noise_flat[:, O_indices] # [b, |O|] - fixed noise for inpainting
1114
+ x0_O = initial_actions_padded.reshape(batch_size, flat_dim)[:, O_indices] # [b, |O|] - target actions
1115
+
1116
+ # Precompute correction matrix for correlation-aware inpainting
1117
+ inpainting_cache = None
1118
+ if self.correlation_loaded:
1119
+ cache_key = (num_initial_actions, input_action_dim)
1120
+ if cache_key not in self.inpainting_cache:
1121
+ logger.info(f"Computing correction matrix for {num_initial_actions} steps, {input_action_dim} dims...")
1122
+ self.inpainting_cache[cache_key] = self._precompute_correction_matrix(O_indices, U_indices)
1123
+ inpainting_cache = self.inpainting_cache[cache_key]
1124
+
1125
+ else:
1126
+ # NO INPAINTING: Standard noise generation
1127
+ if noise is None:
1128
+ rng, noise_rng = jax.random.split(rng)
1129
+ noise = self.generate_correlated_noise(noise_rng, batch_size)
1130
+
1131
+ fixed_z_O = None
1132
+ x0_O = None
1133
+ O_indices = None
1134
+ inpainting_cache = None
1135
+
1136
+ # Split RNG for step loop
1137
+ rng, step_rng = jax.random.split(rng)
1138
+
1139
+ # Ensure FAST tokens are never used during inference
1140
+ if observation.fast_tokens is not None:
1141
+ raise ValueError(
1142
+ "FAST tokens must not be provided during inference (sample_actions). "
1143
+ "FAST tokens are only used during training for auxiliary loss. "
1144
+ "Set observation.fast_tokens=None before calling sample_actions."
1145
+ )
1146
+
1147
+ # First fill KV cache with a forward pass of the prefix (no FAST tokens during inference)
1148
+ prefix_tokens, prefix_mask, prefix_ar_mask = self.embed_prefix(observation)
1149
+ prefix_attn_mask = make_attn_mask(prefix_mask, prefix_ar_mask)
1150
+ positions = jnp.cumsum(prefix_mask, axis=1) - 1
1151
+ (prefix_out, _), kv_cache = self.PaliGemma.llm([prefix_tokens, None], mask=prefix_attn_mask, positions=positions)
1152
+
1153
+ # DA3 banks: computed once, reused across all denoise steps.
1154
+ spatial_banks = self._compute_banks(observation)
1155
+
1156
+ # Predict stage from VLM output of base task token
1157
+ # Find base task token position (same logic as in compute_detailed_loss)
1158
+ first_stage_token_idx = jnp.argmax(prefix_ar_mask) # Returns index of first True
1159
+ base_task_token_idx = first_stage_token_idx - 1
1160
+ base_task_output = prefix_out[:, base_task_token_idx, :]
1161
+ subtask_logits = self.stage_pred_from_vlm(base_task_output) # [B, MAX_NUM_STAGES]
1162
+
1163
+ # Mask out invalid stages for each task (vectorized JAX operations)
1164
+ task_ids = observation.tokenized_prompt[:, 0] # [B]
1165
+ task_num_stages_array = jnp.array(TASK_NUM_STAGES, dtype=jnp.int32)
1166
+ task_num_stages = task_num_stages_array[task_ids] # [B] - JAX array indexing
1167
+ stage_range = jnp.arange(MAX_NUM_STAGES) # [15]
1168
+ valid_mask = stage_range[None, :] < task_num_stages[:, None] # [B, 15]
1169
+ subtask_logits = jnp.where(valid_mask, subtask_logits, -jnp.inf)
1170
+
1171
+ # Transform KV cache for cross-layer attention
1172
+ if self.kv_transform is not None:
1173
+ kv_cache = self.kv_transform(kv_cache)
1174
+
1175
+ def step(carry):
1176
+ x_t, time, step_rng = carry
1177
+
1178
+ # Use config value for time threshold
1179
+ TIME_THRESHOLD_INPAINT = self.config.time_threshold_inpaint
1180
+
1181
+ # Model forward pass
1182
+ suffix_tokens, suffix_mask, suffix_ar_mask, adarms_cond = self.embed_suffix(
1183
+ observation, x_t, jnp.broadcast_to(time, batch_size)
1184
+ )
1185
+ suffix_attn_mask = make_attn_mask(suffix_mask, suffix_ar_mask)
1186
+ prefix_attn_mask = einops.repeat(prefix_mask, "b p -> b s p", s=suffix_tokens.shape[1])
1187
+ full_attn_mask = jnp.concatenate([prefix_attn_mask, suffix_attn_mask], axis=-1)
1188
+ assert full_attn_mask.shape == (
1189
+ batch_size,
1190
+ suffix_tokens.shape[1],
1191
+ prefix_tokens.shape[1] + suffix_tokens.shape[1],
1192
+ )
1193
+ positions = jnp.sum(prefix_mask, axis=-1)[:, None] + jnp.cumsum(suffix_mask, axis=-1) - 1
1194
+
1195
+ (prefix_out, suffix_out), _ = self.PaliGemma.llm(
1196
+ [None, suffix_tokens],
1197
+ mask=full_attn_mask,
1198
+ positions=positions,
1199
+ kv_cache=kv_cache,
1200
+ adarms_cond=[None, adarms_cond],
1201
+ banks=spatial_banks,
1202
+ )
1203
+ assert prefix_out is None
1204
+ action_hidden = self.apply_spatial_action_conditioning(
1205
+ observation,
1206
+ suffix_out[:, -self.action_horizon :],
1207
+ )
1208
+ v_t = self.action_out_proj(action_hidden)
1209
+
1210
+ # Euler step: x_{t+dt} = x_t + dt * v_t
1211
+ x_t_new = x_t + dt * v_t
1212
+
1213
+ # Apply correlation-aware inpainting correction
1214
+ # Only enforce when time > TIME_THRESHOLD_INPAINT (let model be free in final steps)
1215
+ if fixed_z_O is not None:
1216
+ time_new = time + dt
1217
+
1218
+ def apply_correlated_correction(x):
1219
+ x_flat = x.reshape(batch_size, -1)
1220
+
1221
+ # Compute desired state at O: x_t[O] = (1-t)*x0[O] + t*z_O
1222
+ x_desired_O = (1.0 - time_new) * x0_O + time_new * fixed_z_O # [b, |O|]
1223
+
1224
+ # Compute correction at O
1225
+ delta_O = x_desired_O - x_flat[:, O_indices] # [b, |O|]
1226
+
1227
+ # Apply hard constraint at O
1228
+ x_flat = x_flat.at[:, O_indices].set(x_desired_O)
1229
+
1230
+ # If correlation matrix available, propagate correction to U
1231
+ if inpainting_cache is not None:
1232
+ correction_matrix = inpainting_cache['correction_matrix'] # [|U|, |O|]
1233
+ U_indices_cached = inpainting_cache['U_indices']
1234
+
1235
+ # Compute correlated correction: δ_U = Σ_{UO}Σ_{OO}^{-1} @ δ_O
1236
+ delta_U = delta_O @ correction_matrix.T # [b, |U|]
1237
+
1238
+ # Skip if correction too large (indicates instability)
1239
+ max_correction = jnp.max(jnp.abs(delta_U))
1240
+ x_flat = jax.lax.cond(
1241
+ # Prevents exploding corrections in case of noisy out of distribution initial actions
1242
+ max_correction <= 1.0,
1243
+ lambda x: x.at[:, U_indices_cached].add(delta_U),
1244
+ lambda x: x,
1245
+ x_flat
1246
+ )
1247
+
1248
+ # Sanity check: if Σ = I, correction_matrix = 0, so delta_U = 0 that is correct
1249
+ # If the correlation is 1 everywhere we will go to the flat prediction that is correct
1250
+
1251
+ return x_flat.reshape(batch_size, self.action_horizon, self.action_dim)
1252
+
1253
+ # Only apply correction when NEW time > threshold
1254
+ x_t_new = jax.lax.cond(
1255
+ time_new > TIME_THRESHOLD_INPAINT,
1256
+ apply_correlated_correction,
1257
+ lambda x: x,
1258
+ x_t_new
1259
+ )
1260
+
1261
+ return x_t_new, time + dt, step_rng
1262
+
1263
+ def cond(carry):
1264
+ x_t, time, step_rng = carry
1265
+ # Robust to floating-point error
1266
+ return time >= -dt / 2
1267
+
1268
+ x_0, _, _ = jax.lax.while_loop(cond, step, (noise, 1.0, step_rng))
1269
+
1270
+ return x_0, subtask_logits
pibehavior_da3_clean_up_your_desk_40k/source_code/model/pi_behavior_config.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PI_BEHAVIOR Model Configuration
2
+
3
+ Configuration for PI_BEHAVIOR model on BEHAVIOR-1K challenge.
4
+ """
5
+
6
+ import dataclasses
7
+ import json
8
+ import pathlib
9
+ from typing import TYPE_CHECKING
10
+
11
+ import flax.nnx as nnx
12
+ import jax
13
+ import jax.numpy as jnp
14
+ from typing_extensions import override
15
+
16
+ from openpi.models import model as _model
17
+ from openpi.models import gemma as _gemma
18
+ from openpi.shared import array_typing as at
19
+ import openpi.shared.nnx_utils as nnx_utils
20
+
21
+ from b1k.models.observation import Observation
22
+
23
+ if TYPE_CHECKING:
24
+ from b1k.models.pi_behavior import PiBehavior
25
+
26
+
27
+ # Per-task stage counts (based on avg_episode_length / 900, capped between 5-15)
28
+ # Use tuple for immutability and to avoid JAX device allocation at import time
29
+ TASK_NUM_STAGES = (
30
+ 5, 6, 15, 15, 14, 12, 9, 15, 10, 15, # Tasks 0-9
31
+ 7, 13, 10, 15, 15, 15, 15, 11, 13, 12, # Tasks 10-19
32
+ 14, 15, 9, 15, 15, 15, 15, 15, 15, 15, # Tasks 20-29
33
+ 11, 10, 10, 13, 5, 5, 14, 6, 8, 10, # Tasks 30-39
34
+ 5, 15, 8, 15, 12, 11, 9, 14, 15, 15, # Tasks 40-49
35
+ )
36
+
37
+ MAX_NUM_STAGES = 15 # Maximum stages per task
38
+ TOTAL_TASK_STAGE_EMBEDDINGS = sum(TASK_NUM_STAGES) # 596 total embeddings
39
+
40
+ # Cumulative offsets for indexing into task_stage_embeddings (as tuple)
41
+ TASK_STAGE_OFFSETS = tuple([0] + [sum(TASK_NUM_STAGES[:i+1]) for i in range(len(TASK_NUM_STAGES) - 1)])
42
+
43
+
44
+ @dataclasses.dataclass(frozen=True)
45
+ class B1KDA3Config:
46
+ """DA3 spatial-language branch for PiBehavior (inline extraction; b1k cameras are square)."""
47
+
48
+ enabled: bool = True
49
+ num_views: int = 3 # zed head (main), left/right realsense (wrist branches)
50
+ da3_channels: int = 1536 # GIANT embed dim
51
+ da3_layers: int = 4 # out_layers (19, 26, 33, 39)
52
+ grid_hw: tuple[int, int] = (18, 18) # 252x252 square DA3 input / patch 14
53
+ hidden_dim: int = 1024 # == action-expert width
54
+ lang_dim: int = 1024 # ModernBERT-large (task-name embeddings)
55
+ lang_max_len: int = 32
56
+ num_heads: int = 8
57
+ lang_fusion_depth: int = 2
58
+ num_inject_layers: int = 6 # last 6 of 18 action-expert blocks
59
+ spatial_scale: float = 2.0
60
+ # V2 "force-spatial-on" defaults (now that geometry is CORRECT). Zero-init lets the model learn to
61
+ # IGNORE spatial (image path fits first, no gradient left to turn the injection on). Nonzero init +
62
+ # per-head logit-gain keep the injection ACTIVE and the attention SHARP/learnable from step 0, so the
63
+ # model must account for the (now-sane) banks. This only hurt before because geometry was garbage.
64
+ spatial_init_std: float = 0.01
65
+ attn_logit_gain: bool = True
66
+ attn_logit_gain_init: float = 32.0
67
+ bank_token_embed: bool = True
68
+ perceiver_query_std: float = 0.05
69
+
70
+
71
+ @dataclasses.dataclass(frozen=True)
72
+ class PiBehaviorConfig(_model.BaseModelConfig):
73
+ dtype: str = "bfloat16"
74
+ paligemma_variant: _gemma.Variant = "gemma_2b"
75
+ action_expert_variant: _gemma.Variant = "gemma_300m"
76
+
77
+ # Set the model specific defaults.
78
+ action_dim: int = 32
79
+ action_horizon: int = 30
80
+ max_token_len: int = 200 # Only used for compatibility, not for actual tokenization
81
+
82
+ # Number of tasks in the behavior dataset
83
+ num_tasks: int = 50
84
+ # Task embedding dimension - will match the paligemma width
85
+ task_embedding_dim: int = None # type: ignore
86
+ # Maximum number of subtask states across all tasks
87
+ max_num_subtask_states: int = MAX_NUM_STAGES
88
+
89
+ # Path to task data JSON file for initialization
90
+ task_data_path: str = "b1k/BEHAVIOR-1K/docs/challenge/task_data.json"
91
+
92
+ # Whether to use correlated noise matching action covariance structure
93
+ # Requires correlation matrix in norm_stats (computed by compute_norm_stats.py)
94
+ use_correlated_noise: bool = True
95
+
96
+ # Shrinkage parameter for correlation regularization
97
+ # Applied as: S_regularized = beta * S + (1-beta) * I
98
+ # beta=1.0 means full correlation (no shrinkage)
99
+ # beta=0.7 means 70% correlation + 30% independence (recommended for robustness)
100
+ # beta=0.0 means independence (no correlation)
101
+ correlation_beta: float = 0.5
102
+
103
+ # FAST auxiliary training configuration
104
+ use_fast_auxiliary: bool = False # Enable FAST during training
105
+ fast_loss_weight: float = 0.1 # Weight for FAST loss (vs flow loss)
106
+
107
+ # Action dimensions to encode with FAST (default: 0:6, 7:23 = 22 dims)
108
+ # Format: "0:6,7:23" or list of tuples [(0, 6), (7, 23)]
109
+ fast_encoded_dims: str | list[tuple[int, int]] = "0:6,7:23"
110
+
111
+ # FAST tokenizer vocab size
112
+ fast_vocab_size: int = 1024
113
+
114
+ # Max FAST tokens to predict (truncate if exceeded)
115
+ max_fast_tokens: int = 32
116
+
117
+ # FAST tokenizer path (set during initialization, relative to assets_dir/asset_id)
118
+ fast_tokenizer_path: str | None = None
119
+
120
+ # KV cache transformation for cross-layer attention between VLM and action expert
121
+ # Allows each action expert layer to attend to a learned combination of all VLM layers
122
+ use_kv_transform: bool = True
123
+
124
+ # Knowledge insulation: stop action expert gradients from flowing to VLM backbone
125
+ # VLM trains on FAST tokens only, action expert on flow matching with frozen VLM features
126
+ # Implements approach from https://www.physicalintelligence.company/research/knowledge_insulation
127
+ use_knowledge_insulation: bool = True
128
+
129
+ # Subtask/stage prediction auxiliary loss weight (relative to action loss)
130
+ # Higher values emphasize stage prediction accuracy at the expense of action quality
131
+ subtask_loss_weight: float = 0.1
132
+
133
+ # Time threshold for inpainting during inference
134
+ # Stop enforcing inpainting constraint when t < threshold (let model be free in final steps)
135
+ time_threshold_inpaint: float = 0.3
136
+
137
+ # Vision backbone finetuning control
138
+ freeze_vision_backbone: bool = True
139
+
140
+ # DA3 spatial-language adapter. The DA3/ModernBERT branch is computed
141
+ # offline and supplied as tokens in Observation.spatial_tokens.
142
+ use_spatial_action_cross_attention: bool = False
143
+ spatial_token_dim: int = 1024
144
+ spatial_num_tokens: int = 320 # DA3 perc bank default: 128 + 96 + 96
145
+ spatial_num_heads: int = 8
146
+ spatial_residual_scale: float = 1.0
147
+
148
+ # Full DA3 spatial-language branch (supersedes the flat spatial_tokens adapter above):
149
+ # frozen DA3-GIANT runs INLINE in the data pipeline; the trainable bank builder + method-B
150
+ # cross-attention injection (action-expert layers 12-17) live in the model. Proven on RoboReal.
151
+ da3: "B1KDA3Config | None" = None
152
+
153
+ def __post_init__(self):
154
+ if self.task_embedding_dim is None:
155
+ paligemma_config = _gemma.get_config(self.paligemma_variant)
156
+ object.__setattr__(self, "task_embedding_dim", paligemma_config.width)
157
+
158
+ def get_fast_dim_ranges(self) -> list[tuple[int, int]]:
159
+ """Parse fast_encoded_dims into list of ranges."""
160
+ if isinstance(self.fast_encoded_dims, str):
161
+ ranges = []
162
+ for range_str in self.fast_encoded_dims.split(','):
163
+ start, end = map(int, range_str.strip().split(':'))
164
+ ranges.append((start, end))
165
+ return ranges
166
+ return self.fast_encoded_dims
167
+
168
+ def get_total_fast_dims(self) -> int:
169
+ """Get total number of dimensions encoded by FAST."""
170
+ return sum(end - start for start, end in self.get_fast_dim_ranges())
171
+
172
+ @property
173
+ @override
174
+ def model_type(self):
175
+ return "pi_behavior"
176
+
177
+ @override
178
+ def create(self, rng: at.KeyArrayLike) -> "PiBehavior":
179
+ from b1k.models.pi_behavior import PiBehavior
180
+
181
+ return PiBehavior(self, rngs=nnx.Rngs(rng))
182
+
183
+ @override
184
+ def inputs_spec(self, *, batch_size: int = 1) -> tuple["Observation", _model.Actions]:
185
+ image_spec = jax.ShapeDtypeStruct([batch_size, *_model.IMAGE_RESOLUTION, 3], jnp.float32)
186
+ image_mask_spec = jax.ShapeDtypeStruct([batch_size], jnp.bool_)
187
+
188
+ with at.disable_typechecking():
189
+ obs_kwargs = {
190
+ "images": {
191
+ "base_0_rgb": image_spec,
192
+ "left_wrist_0_rgb": image_spec,
193
+ "right_wrist_0_rgb": image_spec,
194
+ },
195
+ "image_masks": {
196
+ "base_0_rgb": image_mask_spec,
197
+ "left_wrist_0_rgb": image_mask_spec,
198
+ "right_wrist_0_rgb": image_mask_spec,
199
+ },
200
+ "state": jax.ShapeDtypeStruct([batch_size, self.action_dim], jnp.float32),
201
+ "tokenized_prompt": jax.ShapeDtypeStruct([batch_size, 2], jnp.int32),
202
+ "tokenized_prompt_mask": jax.ShapeDtypeStruct([batch_size, 2], bool),
203
+ }
204
+
205
+ if self.use_fast_auxiliary:
206
+ obs_kwargs["fast_tokens"] = jax.ShapeDtypeStruct([batch_size, self.max_fast_tokens], jnp.int32)
207
+ obs_kwargs["fast_token_mask"] = jax.ShapeDtypeStruct([batch_size, self.max_fast_tokens], bool)
208
+
209
+ if self.da3 is not None and self.da3.enabled:
210
+ d = self.da3
211
+ gh, gw = d.grid_hw
212
+ obs_kwargs["da3_features"] = jax.ShapeDtypeStruct(
213
+ [batch_size, d.da3_layers, d.num_views, d.da3_channels, gh, gw], jnp.uint16
214
+ )
215
+ obs_kwargs["da3_ray"] = jax.ShapeDtypeStruct([batch_size, d.num_views, 3, gh, gw], jnp.float32)
216
+ obs_kwargs["da3_depth"] = jax.ShapeDtypeStruct([batch_size, d.num_views, 1, gh, gw], jnp.float32)
217
+ obs_kwargs["camera_extrinsics"] = jax.ShapeDtypeStruct([batch_size, d.num_views, 4, 4], jnp.float32)
218
+ obs_kwargs["lang_feat"] = jax.ShapeDtypeStruct([batch_size, d.lang_max_len, d.lang_dim], jnp.float32)
219
+ obs_kwargs["lang_mask"] = jax.ShapeDtypeStruct([batch_size, d.lang_max_len], bool)
220
+
221
+ if self.use_spatial_action_cross_attention:
222
+ obs_kwargs["spatial_tokens"] = jax.ShapeDtypeStruct(
223
+ [batch_size, self.spatial_num_tokens, self.spatial_token_dim],
224
+ jnp.float32,
225
+ )
226
+ obs_kwargs["spatial_token_mask"] = jax.ShapeDtypeStruct(
227
+ [batch_size, self.spatial_num_tokens],
228
+ bool,
229
+ )
230
+
231
+ observation_spec = Observation(**obs_kwargs)
232
+
233
+ action_spec = jax.ShapeDtypeStruct([batch_size, self.action_horizon, self.action_dim], jnp.float32)
234
+ return observation_spec, action_spec
pibehavior_da3_clean_up_your_desk_40k/source_code/model/spatial_da3.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DA3 spatial-language branch for pi0.5 (JAX/Flax nnx port of the X-VLA addon).
2
+
3
+ Faithful reimplementation of the TRAINABLE modules from
4
+ `DA3-XVLA-cache/models/spatial_language.py` (H=1024, GIANT C=1536, grid 18x24=432,
5
+ perceiver tokens 128/96/96, 7-ch scale-aware ray, ModernBERT language fusion).
6
+
7
+ The FROZEN DA3 backbone + ModernBERT run offline (features precached); this module
8
+ consumes their outputs as arrays and produces per-view "banks" that are cross-attended
9
+ into the action-expert's late blocks (see gemma.py `SpatialActionInjection`).
10
+
11
+ Only the bank BUILDER lives here (nnx, a submodule of Pi0). The injection layer lives
12
+ in gemma.py (linen, inside the action-expert scan). Both use identical X-VLA math.
13
+
14
+ Reference math (verified by the understand-phase spec):
15
+ - ResidualCrossAttention: out = q_hidden + scale * MHA(LN_q(q_hidden), LN_kv(kv), LN_kv(kv))
16
+ - MHA matches torch nn.MultiheadAttention: separate q/k/v/out Linears w/ bias, 1/sqrt(head_dim).
17
+ - GELU is the tanh approximation everywhere; LayerNorm eps=1e-5.
18
+ - Perceiver residual adds the RAW learned query (not the normalized one).
19
+ - View order everywhere: 0=main/countertop, 1=left wrist, 2=right wrist.
20
+ """
21
+
22
+ import einops
23
+ import flax.nnx as nnx
24
+ import jax
25
+ import jax.numpy as jnp
26
+
27
+ import openpi.shared.array_typing as at
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # primitives
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def _gelu(x):
35
+ return nnx.gelu(x, approximate=True) # tanh approximation (matches torch GELU(approximate="tanh"))
36
+
37
+
38
+ class MHACrossAttn(nnx.Module):
39
+ """Multi-head cross-attention matching torch nn.MultiheadAttention math (no residual, no norm)."""
40
+
41
+ def __init__(self, dim: int, num_heads: int, *, rngs: nnx.Rngs):
42
+ assert dim % num_heads == 0
43
+ self.num_heads = num_heads
44
+ self.head_dim = dim // num_heads
45
+ self.q_proj = nnx.Linear(dim, dim, rngs=rngs)
46
+ self.k_proj = nnx.Linear(dim, dim, rngs=rngs)
47
+ self.v_proj = nnx.Linear(dim, dim, rngs=rngs)
48
+ self.out_proj = nnx.Linear(dim, dim, rngs=rngs)
49
+
50
+ def __call__(self, q, kv, key_pad_mask=None):
51
+ # q:[b,Lq,d] kv:[b,Lk,d] key_pad_mask:[b,Lk] True=pad (ignored)
52
+ h = self.num_heads
53
+ Q = einops.rearrange(self.q_proj(q), "b l (h d) -> b h l d", h=h)
54
+ K = einops.rearrange(self.k_proj(kv), "b l (h d) -> b h l d", h=h)
55
+ V = einops.rearrange(self.v_proj(kv), "b l (h d) -> b h l d", h=h)
56
+ logits = jnp.einsum("bhqd,bhkd->bhqk", Q, K) * (self.head_dim**-0.5)
57
+ if key_pad_mask is not None:
58
+ logits = jnp.where(key_pad_mask[:, None, None, :], jnp.asarray(-1e30, logits.dtype), logits)
59
+ probs = jax.nn.softmax(logits.astype(jnp.float32), axis=-1).astype(logits.dtype)
60
+ ctx = jnp.einsum("bhqk,bhkd->bhqd", probs, V)
61
+ ctx = einops.rearrange(ctx, "b h q d -> b q (h d)")
62
+ return self.out_proj(ctx)
63
+
64
+
65
+ class ResidualCrossAttn(nnx.Module):
66
+ """Pre-LN residual cross-attention: out = q_hidden + scale * MHA(LN_q(q_hidden), LN_kv(kv))."""
67
+
68
+ def __init__(self, dim: int, num_heads: int, *, rngs: nnx.Rngs):
69
+ self.q_norm = nnx.LayerNorm(dim, epsilon=1e-5, rngs=rngs)
70
+ self.kv_norm = nnx.LayerNorm(dim, epsilon=1e-5, rngs=rngs)
71
+ self.attn = MHACrossAttn(dim, num_heads, rngs=rngs)
72
+
73
+ def __call__(self, q_hidden, kv_hidden, key_pad_mask=None, residual_scale: float = 1.0):
74
+ q = self.q_norm(q_hidden)
75
+ kv = self.kv_norm(kv_hidden)
76
+ out = self.attn(q, kv, key_pad_mask=key_pad_mask)
77
+ return q_hidden + residual_scale * out
78
+
79
+
80
+ class ResidualMlp(nnx.Module):
81
+ """Pre-LN residual MLP: x + Linear2(gelu(Linear1(LN(x))))."""
82
+
83
+ def __init__(self, dim: int, mlp_ratio: float, *, rngs: nnx.Rngs):
84
+ hidden = int(dim * mlp_ratio)
85
+ self.norm = nnx.LayerNorm(dim, epsilon=1e-5, rngs=rngs)
86
+ self.fc1 = nnx.Linear(dim, hidden, rngs=rngs)
87
+ self.fc2 = nnx.Linear(hidden, dim, rngs=rngs)
88
+
89
+ def __call__(self, x):
90
+ return x + self.fc2(_gelu(self.fc1(self.norm(x))))
91
+
92
+
93
+ class ProjLN(nnx.Module):
94
+ """Linear(in->H) -> gelu -> Linear(H->H) -> LayerNorm(H). Used for layer projectors & t5_projector."""
95
+
96
+ def __init__(self, in_dim: int, dim: int, *, rngs: nnx.Rngs):
97
+ self.fc1 = nnx.Linear(in_dim, dim, rngs=rngs)
98
+ self.fc2 = nnx.Linear(dim, dim, rngs=rngs)
99
+ self.norm = nnx.LayerNorm(dim, epsilon=1e-5, rngs=rngs)
100
+
101
+ def __call__(self, x):
102
+ return self.norm(self.fc2(_gelu(self.fc1(x))))
103
+
104
+
105
+ class Mlp2(nnx.Module):
106
+ """Linear(in->hidden) -> gelu -> Linear(hidden->out). Used for ray_mlp & pos2d_mlp (no LN)."""
107
+
108
+ def __init__(self, in_dim: int, hidden: int, out_dim: int, *, rngs: nnx.Rngs):
109
+ self.fc1 = nnx.Linear(in_dim, hidden, rngs=rngs)
110
+ self.fc2 = nnx.Linear(hidden, out_dim, rngs=rngs)
111
+
112
+ def __call__(self, x):
113
+ return self.fc2(_gelu(self.fc1(x)))
114
+
115
+
116
+ class PerceiverDownsampler(nnx.Module):
117
+ """432 grid tokens -> K learned-query tokens (single cross-attn + residual MLP)."""
118
+
119
+ def __init__(self, dim: int, num_queries: int, num_heads: int, *, query_std: float = 0.02, rngs: nnx.Rngs):
120
+ key = rngs.params()
121
+ self.query = nnx.Param(jax.random.normal(key, (1, num_queries, dim)) * query_std)
122
+ self.xattn = ResidualCrossAttn(dim, num_heads, rngs=rngs)
123
+ self.mlp = ResidualMlp(dim, mlp_ratio=2.0, rngs=rngs)
124
+
125
+ def __call__(self, tokens):
126
+ b = tokens.shape[0]
127
+ q = jnp.broadcast_to(self.query.value, (b, *self.query.value.shape[1:]))
128
+ z = self.xattn(q, tokens, residual_scale=1.0) # residual adds RAW q (matches X-VLA)
129
+ return self.mlp(z)
130
+
131
+
132
+ class LanguageFusionStack(nnx.Module):
133
+ """N x [cross-attn(bank, lang) + residual-MLP], with language padding mask."""
134
+
135
+ def __init__(self, dim: int, depth: int, num_heads: int, *, rngs: nnx.Rngs):
136
+ self.layers = [
137
+ (ResidualCrossAttn(dim, num_heads, rngs=rngs), ResidualMlp(dim, mlp_ratio=4.0, rngs=rngs))
138
+ for _ in range(depth)
139
+ ]
140
+
141
+ def __call__(self, geo, lang_tokens, lang_pad_mask):
142
+ for xattn, mlp in self.layers:
143
+ geo = xattn(geo, lang_tokens, key_pad_mask=lang_pad_mask, residual_scale=1.0)
144
+ geo = mlp(geo)
145
+ return geo
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # geometry helpers
150
+ # ---------------------------------------------------------------------------
151
+
152
+
153
+ def compute_world_ray_6d(ray_local, ext_w2c):
154
+ """ray_local [b,3,h,w] cam-local unit dir; ext_w2c [b,4,4] OpenCV world->cam.
155
+
156
+ Returns [b,6,h,w] = concat([origin_world(camera center), dir_world]).
157
+ """
158
+ R_w2c = ext_w2c[:, :3, :3] # [b,3,3]
159
+ t_w2c = ext_w2c[:, :3, 3] # [b,3]
160
+ R_c2w = jnp.swapaxes(R_w2c, -1, -2)
161
+ pos_world = -jnp.einsum("bij,bj->bi", R_c2w, t_w2c) # [b,3] camera center in world
162
+ b, _, h, w = ray_local.shape
163
+ dir_world = jnp.einsum("bij,bjk->bik", R_c2w, ray_local.reshape(b, 3, h * w)).reshape(b, 3, h, w)
164
+ origin = jnp.broadcast_to(pos_world[:, :, None, None], (b, 3, h, w))
165
+ return jnp.concatenate([origin, dir_world], axis=1) # [b,6,h,w]
166
+
167
+
168
+ def _grid_coords(h: int, w: int):
169
+ v = 2.0 * jnp.arange(h) / (h - 1) - 1.0
170
+ u = 2.0 * jnp.arange(w) / (w - 1) - 1.0
171
+ yy, xx = jnp.meshgrid(v, u, indexing="ij")
172
+ return jnp.stack([xx, yy], axis=-1).reshape(1, h * w, 2) # [1,432,2] (x=u, y=v), row-major
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # bank builder
177
+ # ---------------------------------------------------------------------------
178
+
179
+ _VIEWS = (("main", 0, 128), ("left", 1, 96), ("right", 2, 96))
180
+
181
+
182
+ class SpatialBankBuilder(nnx.Module):
183
+ """Cached DA3 (feats/ray/depth) + extrinsics + ModernBERT feats -> 3 per-view banks."""
184
+
185
+ def __init__(
186
+ self,
187
+ *,
188
+ hidden_dim: int = 1024,
189
+ da3_channels: int = 1536,
190
+ num_layers: int = 4,
191
+ grid_hw: tuple[int, int] = (18, 24),
192
+ lang_dim: int = 1024, # ModernBERT-large last_hidden width (768) -> set by config
193
+ num_heads: int = 8,
194
+ lang_fusion_depth: int = 2,
195
+ perceiver_query_std: float = 0.02,
196
+ bank_token_embed: bool = False,
197
+ rngs: nnx.Rngs,
198
+ ):
199
+ H = hidden_dim
200
+ self.hidden_dim = H
201
+ self.num_layers = num_layers
202
+ self.grid_hw = grid_hw
203
+ # (a) per-tap projectors + layer embed + fuse
204
+ self.layer_projectors = [ProjLN(da3_channels, H, rngs=rngs) for _ in range(num_layers)]
205
+ self.layer_embed = nnx.Param(jax.random.normal(rngs.params(), (num_layers, H)) * 0.02)
206
+ self.layer_fuse = nnx.Linear(num_layers * H, H, rngs=rngs)
207
+ # (b) scale-aware ray (Plucker-6 + log-depth = 7)
208
+ self.ray_mlp = Mlp2(7, 256, H, rngs=rngs)
209
+ # (c) 2D grid pos + per-view embedding
210
+ self.pos2d_mlp = Mlp2(2, 256, H, rngs=rngs)
211
+ self.view_embed = nnx.Embed(3, H, rngs=rngs)
212
+ # (d) language projector (ModernBERT feat -> H)
213
+ self.t5_projector = ProjLN(lang_dim, H, rngs=rngs)
214
+ # (e) per-view perceiver + language fusion
215
+ self.perceivers = {
216
+ name: PerceiverDownsampler(H, k, num_heads, query_std=perceiver_query_std, rngs=rngs)
217
+ for name, _, k in _VIEWS
218
+ }
219
+ self.lang_fusers = {name: LanguageFusionStack(H, lang_fusion_depth, num_heads, rngs=rngs) for name, _, _ in _VIEWS}
220
+ # (f) v2: learned per-token embedding added to each view's FINAL bank tokens. Guarantees
221
+ # persistent cross-token diversity — the quantity that drives softmax gradients to the
222
+ # injection's Q/K (shared content cancels in the softmax jacobian, so without this the
223
+ # attention pattern barely trains; measured ~1000x slower than V/out in v1).
224
+ self.bank_token_embeds = (
225
+ {name: nnx.Param(jax.random.normal(rngs.params(), (1, k, H)) * 0.05) for name, _, k in _VIEWS}
226
+ if bank_token_embed
227
+ else None
228
+ )
229
+
230
+ def _fuse_layers(self, feats_v):
231
+ # feats_v: [b, num_layers, C, h, w] -> [b, 432, H]
232
+ b, L, C, h, w = feats_v.shape
233
+ parts = []
234
+ for li in range(self.num_layers):
235
+ flat = einops.rearrange(feats_v[:, li], "b c h w -> b (h w) c") # row-major
236
+ p = self.layer_projectors[li](flat) + self.layer_embed.value[li][None, None, :]
237
+ parts.append(p)
238
+ return self.layer_fuse(jnp.concatenate(parts, axis=-1))
239
+
240
+ def _ray7(self, ray_v, depth_v, ext_v):
241
+ # ray_v [b,3,h,w], depth_v [b,1,h,w], ext_v [b,4,4] -> [b,432,7]
242
+ ray6 = compute_world_ray_6d(ray_v, ext_v) # [b,6,h,w]
243
+ logd = jnp.log(jnp.clip(depth_v.astype(jnp.float32), a_min=1e-3)).astype(ray6.dtype) # [b,1,h,w]
244
+ ray7 = jnp.concatenate([ray6, logd], axis=1) # [b,7,h,w]
245
+ return einops.rearrange(ray7, "b c h w -> b (h w) c")
246
+
247
+ def __call__(self, feats, ray, depth, extrinsics, lang_feat, lang_mask):
248
+ # feats [b,L,V,C,h,w]; ray [b,V,3,h,w]; depth [b,V,1,h,w]; extrinsics [b,V,4,4]
249
+ # lang_feat [b,Lt,lang_dim]; lang_mask [b,Lt] True=real token
250
+ h, w = self.grid_hw
251
+ pos_emb = self.pos2d_mlp(_grid_coords(h, w).astype(feats.dtype)) # [1,432,H]
252
+ lang_tokens = self.t5_projector(lang_feat) # [b,Lt,H]
253
+ lang_pad = jnp.logical_not(lang_mask) # True=pad
254
+ banks = {}
255
+ for name, vidx, _k in _VIEWS:
256
+ fused = self._fuse_layers(feats[:, :, vidx]) # [b,432,H]
257
+ ray_flat = self._ray7(ray[:, vidx], depth[:, vidx], extrinsics[:, vidx]) # [b,432,7]
258
+ ray_emb = self.ray_mlp(ray_flat.astype(feats.dtype)) # [b,432,H]
259
+ view_emb = self.view_embed(jnp.asarray(vidx))[None, None, :] # [1,1,H]
260
+ spatial = fused + view_emb + pos_emb + ray_emb # [b,432,H]
261
+ geo = self.perceivers[name](spatial) # [b,K,H]
262
+ bank = self.lang_fusers[name](geo, lang_tokens, lang_pad) # [b,K,H]
263
+ if self.bank_token_embeds is not None:
264
+ bank = bank + self.bank_token_embeds[name].value
265
+ banks[name] = bank
266
+ return banks
pibehavior_da3_clean_up_your_desk_40k/source_code/openpi/data_loader.py ADDED
@@ -0,0 +1,676 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Iterator, Sequence
2
+ import logging
3
+ import multiprocessing
4
+ import os
5
+ import queue
6
+ import threading
7
+ import typing
8
+ from typing import Literal, Protocol, SupportsIndex, TypeVar
9
+
10
+ import jax
11
+ import jax.numpy as jnp
12
+ import lerobot.datasets.lerobot_dataset as lerobot_dataset
13
+ import numpy as np
14
+ import torch
15
+
16
+ import openpi.models.model as _model
17
+ import openpi.training.config as _config
18
+ from openpi.training.droid_rlds_dataset import DroidRldsDataset
19
+ import openpi.transforms as _transforms
20
+
21
+ T_co = TypeVar("T_co", covariant=True)
22
+
23
+
24
+ class Dataset(Protocol[T_co]):
25
+ """Interface for a dataset with random access."""
26
+
27
+ def __getitem__(self, index: SupportsIndex) -> T_co:
28
+ raise NotImplementedError("Subclasses of Dataset should implement __getitem__.")
29
+
30
+ def __len__(self) -> int:
31
+ raise NotImplementedError("Subclasses of Dataset should implement __len__.")
32
+
33
+
34
+ class IterableDataset(Protocol[T_co]):
35
+ """Interface for an iterable dataset."""
36
+
37
+ def __iter__(self) -> Iterator[T_co]:
38
+ raise NotImplementedError("Subclasses of IterableDataset should implement __iter__.")
39
+
40
+ def __len__(self) -> int:
41
+ raise NotImplementedError("Subclasses of Dataset should implement __len__.")
42
+
43
+
44
+ class DataLoader(Protocol[T_co]):
45
+ """Interface for a data loader."""
46
+
47
+ def data_config(self) -> _config.DataConfig:
48
+ """Get the data config for this data loader."""
49
+ raise NotImplementedError("Subclasses of DataLoader should implement data_config.")
50
+
51
+ def __iter__(self) -> Iterator[T_co]:
52
+ raise NotImplementedError("Subclasses of DataLoader should implement __iter__.")
53
+
54
+
55
+ class TransformedDataset(Dataset[T_co]):
56
+ def __init__(self, dataset: Dataset, transforms: Sequence[_transforms.DataTransformFn]):
57
+ self._dataset = dataset
58
+ self._transform = _transforms.compose(transforms)
59
+
60
+ def __getitem__(self, index: SupportsIndex) -> T_co:
61
+ return self._transform(self._dataset[index])
62
+
63
+ def __len__(self) -> int:
64
+ return len(self._dataset)
65
+
66
+
67
+ class IterableTransformedDataset(IterableDataset[T_co]):
68
+ def __init__(
69
+ self,
70
+ dataset: IterableDataset,
71
+ transforms: Sequence[_transforms.DataTransformFn],
72
+ *,
73
+ is_batched: bool = False,
74
+ ):
75
+ self._dataset = dataset
76
+ self._transform = _transforms.compose(transforms)
77
+ self._is_batched = is_batched
78
+
79
+ def __iter__(self):
80
+ for sample in self._dataset:
81
+ if self._is_batched:
82
+ # Transforms are designed to be applied to individual samples. So we need to split the batch into
83
+ # individual samples and apply the transform to each sample individually.
84
+ batch_size = next(v.shape[0] for v in sample.values())
85
+
86
+ # Split batch into individual samples using tree_map
87
+ individual_samples = [jax.tree.map(lambda x: x[i], sample) for i in range(batch_size)] # noqa: B023
88
+
89
+ # Transform each sample
90
+ transformed = [self._transform(s) for s in individual_samples]
91
+
92
+ # Recombine batch with tree_map
93
+ yield jax.tree.map(lambda *x: np.stack(x, axis=0), *transformed)
94
+ else:
95
+ yield self._transform(sample)
96
+
97
+ def __len__(self) -> int:
98
+ return len(self._dataset)
99
+
100
+
101
+ class FakeDataset(Dataset):
102
+ def __init__(self, model_config: _model.BaseModelConfig, num_samples: int):
103
+ self._num_samples = num_samples
104
+ self._observation_spec, self._action_spec = model_config.inputs_spec()
105
+
106
+ def __getitem__(self, index: SupportsIndex) -> dict:
107
+ rng = jax.random.key(index.__index__())
108
+
109
+ def make_from_spec(spec: jax.ShapeDtypeStruct):
110
+ nonlocal rng
111
+ rng, data_rng = jax.random.split(rng)
112
+ # Remove the batch dimension.
113
+ shape = spec.shape[1:]
114
+ if spec.dtype == jnp.float32:
115
+ return jax.random.uniform(data_rng, shape=shape, minval=-1.0, maxval=1.0)
116
+ if spec.dtype == jnp.int32:
117
+ return jax.random.randint(data_rng, shape=shape, minval=0, maxval=2048)
118
+ return jnp.zeros(shape=shape, dtype=spec.dtype)
119
+
120
+ observation = jax.tree.map(make_from_spec, self._observation_spec)
121
+ action = jax.tree.map(make_from_spec, self._action_spec)
122
+
123
+ return {
124
+ **observation.to_dict(),
125
+ "actions": action,
126
+ }
127
+
128
+ def __len__(self) -> int:
129
+ return self._num_samples
130
+
131
+
132
+ def create_behavior_dataset(data_config: _config.DataConfig, action_horizon: int) -> Dataset:
133
+ """Create a dataset for training."""
134
+ from omnigibson.learning.datas.lerobot_dataset import BehaviorLeRobotDataset
135
+
136
+ dataset = BehaviorLeRobotDataset(
137
+ repo_id=data_config.repo_id,
138
+ root=data_config.behavior_dataset_root,
139
+ tasks=["turning_on_radio"],
140
+ modalities=["rgb"],
141
+ local_only=True,
142
+ delta_timestamps={
143
+ key: [t / 30.0 for t in range(action_horizon)] for key in data_config.action_sequence_keys
144
+ },
145
+ episodes=data_config.episodes_index,
146
+ chunk_streaming_using_keyframe=True,
147
+ shuffle=True,
148
+ )
149
+
150
+ if data_config.prompt_from_task:
151
+ dataset = TransformedDataset(dataset, [_transforms.PromptFromLeRobotTask(dataset.meta.tasks)])
152
+
153
+ return dataset
154
+
155
+
156
+ def create_torch_dataset(
157
+ data_config: _config.DataConfig, action_horizon: int, model_config: _model.BaseModelConfig
158
+ ) -> Dataset:
159
+ """Create a dataset for training."""
160
+ repo_id = data_config.repo_id
161
+ if repo_id is None:
162
+ raise ValueError("Repo ID is not set. Cannot create dataset.")
163
+ if repo_id == "fake":
164
+ return FakeDataset(model_config, num_samples=1024)
165
+
166
+ dataset_meta = lerobot_dataset.LeRobotDatasetMetadata(repo_id)
167
+ dataset = lerobot_dataset.LeRobotDataset(
168
+ data_config.repo_id,
169
+ delta_timestamps={
170
+ key: [t / dataset_meta.fps for t in range(action_horizon)] for key in data_config.action_sequence_keys
171
+ },
172
+ episodes=data_config.episodes_index,
173
+ )
174
+
175
+ if data_config.prompt_from_task:
176
+ dataset = TransformedDataset(dataset, [_transforms.PromptFromLeRobotTask(dataset_meta.tasks)])
177
+
178
+ return dataset
179
+
180
+
181
+ def create_rlds_dataset(
182
+ data_config: _config.DataConfig,
183
+ action_horizon: int,
184
+ batch_size: int,
185
+ *,
186
+ shuffle: bool = False,
187
+ ) -> Dataset:
188
+ # At the moment, we only support DROID for RLDS datasets.
189
+ return DroidRldsDataset(
190
+ data_dir=data_config.rlds_data_dir,
191
+ batch_size=batch_size,
192
+ shuffle=shuffle,
193
+ action_chunk_size=action_horizon,
194
+ action_space=data_config.action_space,
195
+ filter_dict_path=data_config.filter_dict_path,
196
+ )
197
+
198
+
199
+ def transform_dataset(dataset: Dataset, data_config: _config.DataConfig, *, skip_norm_stats: bool = False) -> Dataset:
200
+ """Transform the dataset by applying the data transforms."""
201
+ norm_stats = {}
202
+ if data_config.repo_id != "fake" and not skip_norm_stats:
203
+ if data_config.norm_stats is None:
204
+ raise ValueError(
205
+ "Normalization stats not found. "
206
+ "Make sure to run `scripts/compute_norm_stats.py --config-name=<your-config>`."
207
+ )
208
+ norm_stats = data_config.norm_stats
209
+
210
+ return TransformedDataset(
211
+ dataset,
212
+ [
213
+ *data_config.repack_transforms.inputs,
214
+ *data_config.data_transforms.inputs,
215
+ _transforms.Normalize(norm_stats, use_quantiles=data_config.use_quantile_norm),
216
+ *data_config.model_transforms.inputs,
217
+ ],
218
+ )
219
+
220
+
221
+ def transform_iterable_dataset(
222
+ dataset: IterableDataset,
223
+ data_config: _config.DataConfig,
224
+ *,
225
+ skip_norm_stats: bool = False,
226
+ is_batched: bool = False,
227
+ ) -> IterableDataset:
228
+ """Transform the dataset by applying the data transforms."""
229
+ norm_stats = {}
230
+ if data_config.repo_id != "fake" and not skip_norm_stats:
231
+ if data_config.norm_stats is None:
232
+ raise ValueError(
233
+ "Normalization stats not found. "
234
+ "Make sure to run `scripts/compute_norm_stats.py --config-name=<your-config>`."
235
+ )
236
+ norm_stats = data_config.norm_stats
237
+
238
+ return IterableTransformedDataset(
239
+ dataset,
240
+ [
241
+ *data_config.repack_transforms.inputs,
242
+ *data_config.data_transforms.inputs,
243
+ _transforms.Normalize(norm_stats, use_quantiles=data_config.use_quantile_norm),
244
+ *data_config.model_transforms.inputs,
245
+ ],
246
+ is_batched=is_batched,
247
+ )
248
+
249
+
250
+ def create_data_loader(
251
+ config: _config.TrainConfig,
252
+ *,
253
+ sharding: jax.sharding.Sharding | None = None,
254
+ shuffle: bool = False,
255
+ num_batches: int | None = None,
256
+ skip_norm_stats: bool = False,
257
+ framework: Literal["jax", "pytorch"] = "jax",
258
+ ) -> DataLoader[tuple[_model.Observation, _model.Actions]]:
259
+ """Create a data loader for training.
260
+
261
+ Args:
262
+ config: The training configuration.
263
+ sharding: The sharding to use for the data loader (JAX only).
264
+ shuffle: Whether to shuffle the data.
265
+ num_batches: Determines the number of batches to return.
266
+ skip_norm_stats: Whether to skip data normalization.
267
+ framework: The framework to use ("jax" or "pytorch").
268
+ """
269
+ data_config = config.data.create(config.assets_dirs, config.model)
270
+ logging.info(f"data_config: {data_config}")
271
+
272
+ if data_config.rlds_data_dir is not None:
273
+ return create_rlds_data_loader(
274
+ data_config,
275
+ action_horizon=config.model.action_horizon,
276
+ batch_size=config.batch_size,
277
+ sharding=sharding,
278
+ shuffle=shuffle,
279
+ num_batches=num_batches,
280
+ skip_norm_stats=skip_norm_stats,
281
+ framework=framework,
282
+ )
283
+ return create_torch_data_loader(
284
+ data_config,
285
+ model_config=config.model,
286
+ action_horizon=config.model.action_horizon,
287
+ batch_size=config.batch_size,
288
+ sharding=sharding,
289
+ shuffle=shuffle,
290
+ num_batches=num_batches,
291
+ num_workers=config.num_workers,
292
+ seed=config.seed,
293
+ skip_norm_stats=skip_norm_stats,
294
+ framework=framework,
295
+ )
296
+
297
+
298
+ def create_behavior_data_loader(
299
+ config: _config.TrainConfig,
300
+ *,
301
+ sharding: jax.sharding.Sharding | None = None,
302
+ shuffle: bool = False,
303
+ num_batches: int | None = None,
304
+ skip_norm_stats: bool = False,
305
+ ) -> DataLoader[tuple[_model.Observation, _model.Actions]]:
306
+ data_config = config.data.create(config.assets_dirs, config.model)
307
+ dataset = create_behavior_dataset(data_config, action_horizon=config.model.action_horizon)
308
+ dataset = transform_dataset(dataset, data_config, skip_norm_stats=skip_norm_stats)
309
+
310
+ data_loader = TorchDataLoader(
311
+ dataset,
312
+ local_batch_size=config.batch_size // jax.process_count(),
313
+ sharding=sharding,
314
+ shuffle=shuffle,
315
+ num_batches=num_batches,
316
+ num_workers=config.num_workers,
317
+ seed=config.seed,
318
+ )
319
+
320
+ return DataLoaderImpl(data_config, data_loader)
321
+
322
+
323
+ def create_torch_data_loader(
324
+ data_config: _config.DataConfig,
325
+ model_config: _model.BaseModelConfig,
326
+ action_horizon: int,
327
+ batch_size: int,
328
+ *,
329
+ sharding: jax.sharding.Sharding | None = None,
330
+ skip_norm_stats: bool = False,
331
+ shuffle: bool = False,
332
+ num_batches: int | None = None,
333
+ num_workers: int = 0,
334
+ seed: int = 0,
335
+ framework: str = "jax",
336
+ ) -> DataLoader[tuple[_model.Observation, _model.Actions]]:
337
+ """Create a data loader for training.
338
+
339
+ Args:
340
+ data_config: The data configuration.
341
+ action_horizon: The action horizon.
342
+ batch_size: The batch size.
343
+ sharding: The sharding to use for the data loader. If None, the data loader will
344
+ use a single device sharding.
345
+ skip_norm_stats: Whether to skip data normalization.
346
+ shuffle: Whether to shuffle the data.
347
+ num_batches: Determines the number of batches to return. If the number exceeds the
348
+ number of batches in the dataset, the data loader will loop over the dataset.
349
+ If not provided, will iterate over the dataset indefinitely.
350
+ num_workers: The number of worker processes to use. If zero, the data loader will
351
+ execute in the main process.
352
+ seed: The seed to use for shuffling the data.
353
+ """
354
+ dataset = create_torch_dataset(data_config, action_horizon, model_config)
355
+ dataset = transform_dataset(dataset, data_config, skip_norm_stats=skip_norm_stats)
356
+
357
+ # Use TorchDataLoader for both frameworks
358
+ # For PyTorch DDP, create DistributedSampler and divide batch size by world size
359
+ # For JAX, divide by process count
360
+ sampler = None
361
+ if framework == "pytorch":
362
+ if torch.distributed.is_initialized():
363
+ sampler = torch.utils.data.distributed.DistributedSampler(
364
+ dataset,
365
+ num_replicas=torch.distributed.get_world_size(),
366
+ rank=torch.distributed.get_rank(),
367
+ shuffle=shuffle,
368
+ drop_last=True,
369
+ )
370
+ local_batch_size = batch_size // torch.distributed.get_world_size()
371
+ else:
372
+ local_batch_size = batch_size
373
+ else:
374
+ local_batch_size = batch_size // jax.process_count()
375
+
376
+ logging.info(f"local_batch_size: {local_batch_size}")
377
+ data_loader = TorchDataLoader(
378
+ dataset,
379
+ local_batch_size=local_batch_size,
380
+ sharding=None if framework == "pytorch" else sharding,
381
+ shuffle=(sampler is None and shuffle), # Don't shuffle if using sampler
382
+ sampler=sampler,
383
+ num_batches=num_batches,
384
+ num_workers=num_workers,
385
+ seed=seed,
386
+ framework=framework,
387
+ )
388
+
389
+ return DataLoaderImpl(data_config, data_loader)
390
+
391
+
392
+ def create_rlds_data_loader(
393
+ data_config: _config.DataConfig,
394
+ action_horizon: int,
395
+ batch_size: int,
396
+ *,
397
+ sharding: jax.sharding.Sharding | None = None,
398
+ skip_norm_stats: bool = False,
399
+ shuffle: bool = False,
400
+ num_batches: int | None = None,
401
+ framework: str = "jax",
402
+ ) -> DataLoader[tuple[_model.Observation, _model.Actions]]:
403
+ """Create an RLDS data loader for training.
404
+
405
+ Note: This data loader requires some extra dependencies -- see examples/droid/README_train.md
406
+
407
+ Args:
408
+ data_config: The data configuration.
409
+ action_horizon: The action horizon.
410
+ batch_size: The batch size.
411
+ sharding: The sharding to use for the data loader. If None, the data loader will
412
+ use a single device sharding.
413
+ skip_norm_stats: Whether to skip data normalization.
414
+ shuffle: Whether to shuffle the data.
415
+ num_batches: Determines the number of batches to return. If the number exceeds the
416
+ number of batches in the dataset, the data loader will loop over the dataset.
417
+ If not provided, will iterate over the dataset indefinitely.
418
+ """
419
+ if framework == "pytorch":
420
+ raise NotImplementedError("PyTorch RLDS data loader is not supported yet")
421
+ dataset = create_rlds_dataset(data_config, action_horizon, batch_size, shuffle=shuffle)
422
+ dataset = transform_iterable_dataset(dataset, data_config, skip_norm_stats=skip_norm_stats, is_batched=True)
423
+
424
+ data_loader = RLDSDataLoader(
425
+ dataset,
426
+ sharding=sharding,
427
+ num_batches=num_batches,
428
+ )
429
+
430
+ return DataLoaderImpl(data_config, data_loader)
431
+
432
+
433
+ class TorchDataLoader:
434
+ """Torch data loader implementation."""
435
+
436
+ def __init__(
437
+ self,
438
+ dataset,
439
+ local_batch_size: int,
440
+ *,
441
+ sharding: jax.sharding.Sharding | None = None,
442
+ shuffle: bool = False,
443
+ sampler: torch.utils.data.Sampler | None = None,
444
+ num_batches: int | None = None,
445
+ num_workers: int = 0,
446
+ seed: int = 0,
447
+ framework: str = "jax",
448
+ batch_transform=None,
449
+ ):
450
+ """Create a PyTorch data loader.
451
+
452
+ Args:
453
+ dataset: The dataset to load.
454
+ local_batch_size: The local batch size for each process.
455
+ sharding: The sharding to use for the data loader.
456
+ shuffle: Whether to shuffle the data.
457
+ num_batches: If provided, determines the number of returned batches. If the
458
+ number is larger than the number of batches in the dataset, the data loader
459
+ will loop over the dataset. If not provided, will iterate over the dataset
460
+ indefinitely.
461
+ num_workers: The number of worker processes to use. If zero, the data loader will
462
+ execute in the main process.
463
+ seed: The seed to use for shuffling the data.
464
+ """
465
+ if jax.process_count() > 1:
466
+ raise NotImplementedError("Data loading with multiple processes is not supported.")
467
+
468
+ if len(dataset) < local_batch_size:
469
+ raise ValueError(f"Local batch size ({local_batch_size}) is larger than the dataset size ({len(dataset)}).")
470
+
471
+ # Store sharding - None for PyTorch, JAX sharding for JAX
472
+ self._sharding = sharding
473
+ if sharding is None and framework == "jax":
474
+ # Use data parallel sharding by default for JAX only.
475
+ self._sharding = jax.sharding.NamedSharding(
476
+ jax.sharding.Mesh(jax.devices(), ("B",)),
477
+ jax.sharding.PartitionSpec("B"),
478
+ )
479
+ self._num_batches = num_batches
480
+ # optional per-batch hook applied to the (numpy) batch before sharding — used for inline
481
+ # DA3 feature extraction (runs the frozen GIANT on the batch's raw frames on GPU).
482
+ self._batch_transform = batch_transform
483
+
484
+ mp_context = None
485
+ if num_workers > 0:
486
+ mp_context = multiprocessing.get_context("spawn")
487
+
488
+ generator = torch.Generator()
489
+ generator.manual_seed(seed)
490
+ data_loader_kwargs = {}
491
+ if num_workers > 0:
492
+ prefetch_factor = int(os.environ.get("B1K_PREFETCH_FACTOR", "2"))
493
+ if prefetch_factor > 0:
494
+ data_loader_kwargs["prefetch_factor"] = prefetch_factor
495
+ if _TORCH_COLLATE:
496
+ # Only meaningful for torch tensors; pins in the loader's pin thread so the
497
+ # extractor's H2D can be a true async DMA (non_blocking) instead of a pageable copy.
498
+ data_loader_kwargs["pin_memory"] = True
499
+ self._data_loader = torch.utils.data.DataLoader(
500
+ typing.cast(torch.utils.data.Dataset, dataset),
501
+ batch_size=local_batch_size,
502
+ shuffle=(sampler is None and shuffle), # Don't shuffle if using sampler
503
+ sampler=sampler,
504
+ num_workers=num_workers,
505
+ multiprocessing_context=mp_context,
506
+ persistent_workers=num_workers > 0,
507
+ collate_fn=_collate_fn,
508
+ worker_init_fn=_worker_init_fn,
509
+ drop_last=True,
510
+ generator=generator,
511
+ **data_loader_kwargs,
512
+ )
513
+
514
+ @property
515
+ def torch_loader(self) -> torch.utils.data.DataLoader:
516
+ return self._data_loader
517
+
518
+ def _transformed_batches(self):
519
+ """Yields batch_transform-ed batches, looping over the dataset indefinitely.
520
+
521
+ When a batch_transform is set (inline DA3 GPU extraction), it runs ONE BATCH AHEAD in a
522
+ background thread, so the frozen DA3 forward for batch N+1 overlaps the train step for
523
+ batch N instead of serializing with it (torch GPU kernels release the GIL).
524
+ """
525
+ def epochs():
526
+ epoch = 0
527
+ sampler = getattr(self._data_loader, "sampler", None)
528
+ while True:
529
+ if hasattr(sampler, "set_epoch"):
530
+ sampler.set_epoch(epoch)
531
+ yield from self._data_loader
532
+ epoch += 1
533
+
534
+ if self._batch_transform is None:
535
+ yield from epochs()
536
+ return
537
+
538
+ # Depth = how many extracted batches can be buffered ahead of training. Default 2 (unchanged).
539
+ # On a dedicated-extraction-GPU setup (extract on spare GPUs, train on others) bump this via
540
+ # B1K_EXTRACT_QUEUE=3-4 so extraction runs further ahead and smooths over per-batch variance.
541
+ q: queue.Queue = queue.Queue(maxsize=int(os.environ.get("B1K_EXTRACT_QUEUE", "2")))
542
+
543
+ def producer():
544
+ try:
545
+ for raw in epochs():
546
+ b = self._batch_transform(raw)
547
+ # Stage the REMAINING host fields (base RGB / actions / state) onto the training
548
+ # GPUs here in the producer instead of on the main thread at consume time. Same
549
+ # make_array, just moved off the train loop's critical path so the host->GPU copy
550
+ # overlaps the in-flight train step. (DA3 fields are already jax.Array via DLPack.)
551
+ if self._sharding is not None:
552
+ b = jax.tree.map(
553
+ lambda x: x if isinstance(x, jax.Array)
554
+ else jax.make_array_from_process_local_data(self._sharding, _host_np(x)), b)
555
+ q.put(b)
556
+ except BaseException as e: # noqa: BLE001 — propagate any failure to the consumer
557
+ q.put(e)
558
+
559
+ threading.Thread(target=producer, daemon=True, name="batch-transform-prefetch").start()
560
+ while True:
561
+ item = q.get()
562
+ if isinstance(item, BaseException):
563
+ raise item
564
+ yield item
565
+
566
+ def __iter__(self):
567
+ num_items = 0
568
+ batches = self._transformed_batches()
569
+ while True:
570
+ # Check BEFORE pulling: the generator prefetches, and pulling past num_batches would
571
+ # trigger (and discard) a whole extra batch across the epoch boundary.
572
+ if self._num_batches is not None and num_items >= self._num_batches:
573
+ return
574
+ batch = next(batches)
575
+ num_items += 1
576
+ # For JAX, convert to sharded arrays; for PyTorch, return torch tensors.
577
+ # Fields already placed as jax.Array (DLPack GPU->GPU handoff) pass through untouched.
578
+ if self._sharding is not None:
579
+ yield jax.tree.map(
580
+ lambda x: x if isinstance(x, jax.Array)
581
+ else jax.make_array_from_process_local_data(self._sharding, _host_np(x)), batch)
582
+ else:
583
+ yield jax.tree.map(torch.as_tensor, batch)
584
+
585
+
586
+ _TORCH_COLLATE = os.environ.get("B1K_TORCH_COLLATE") == "1"
587
+
588
+
589
+ def _host_np(x):
590
+ """torch CPU tensor -> numpy (zero-copy view); pass anything else through unchanged."""
591
+ return x.numpy() if isinstance(x, torch.Tensor) else x
592
+
593
+
594
+ def _collate_fn(items):
595
+ """Collate the batch elements into batched arrays.
596
+
597
+ With B1K_TORCH_COLLATE=1 the batch is built as torch tensors instead of numpy. The DataLoader
598
+ hands torch tensors worker->main through SHARED MEMORY (fd passing) rather than pickling and
599
+ copying them, which is a real saving at ~131MB/batch, and it makes pin_memory (hence async
600
+ non_blocking H2D) possible. Values are bit-identical either way.
601
+ """
602
+ if _TORCH_COLLATE:
603
+ def _stack(*xs):
604
+ a = [np.asarray(x) for x in xs]
605
+ if a[0].dtype.kind in "biufc": # numeric -> torch tensor (shared-memory IPC + pinnable)
606
+ return torch.stack([torch.as_tensor(v) for v in a], 0)
607
+ return np.stack(a, axis=0) # strings/objects have no torch equivalent; keep numpy
608
+ return jax.tree.map(_stack, *items)
609
+ # Make sure to convert to numpy arrays before stacking since some of the incoming elements
610
+ # may be JAX arrays.
611
+ return jax.tree.map(lambda *xs: np.stack([np.asarray(x) for x in xs], axis=0), *items)
612
+
613
+
614
+ def _worker_init_fn(worker_id: int) -> None:
615
+ """Tell JAX inside the worker process not to preallocate the GPU memory."""
616
+ # NOTE: This is called after jax is imported inside the worker process. This
617
+ # means that this approach will not work for selecting the backend.
618
+ os.environ["XLA_PYTHON_CLIENT_PREALLOCATE"] = "false"
619
+ os.environ["XLA_PYTHON_CLIENT_ALLOCATOR"] = "platform"
620
+
621
+
622
+ class RLDSDataLoader:
623
+ """Shallow wrapper around the DROID data loader to make it compatible with openpi.
624
+
625
+ All batching already happens in the DROID dataset, so we don't need to do anything here.
626
+ """
627
+
628
+ def __init__(
629
+ self,
630
+ dataset: DroidRldsDataset,
631
+ *,
632
+ sharding: jax.sharding.Sharding | None = None,
633
+ num_batches: int | None = None,
634
+ ):
635
+ self._dataset = dataset
636
+ self._num_batches = num_batches
637
+
638
+ if jax.process_count() > 1:
639
+ raise NotImplementedError("Data loading with multiple processes is not supported.")
640
+
641
+ if sharding is None:
642
+ # Use data parallel sharding by default.
643
+ sharding = jax.sharding.NamedSharding(
644
+ jax.sharding.Mesh(jax.devices(), ("B",)),
645
+ jax.sharding.PartitionSpec("B"),
646
+ )
647
+
648
+ self._sharding = sharding
649
+ self._num_batches = num_batches
650
+
651
+ def __iter__(self):
652
+ num_items = 0
653
+ while True:
654
+ data_iter = iter(self._dataset)
655
+ while True:
656
+ if self._num_batches is not None and num_items >= self._num_batches:
657
+ return
658
+ try:
659
+ batch = next(data_iter)
660
+ except StopIteration:
661
+ break # We've exhausted the dataset. Create a new iterator and start over.
662
+ num_items += 1
663
+ yield jax.tree.map(lambda x: jax.make_array_from_process_local_data(self._sharding, x), batch)
664
+
665
+
666
+ class DataLoaderImpl(DataLoader):
667
+ def __init__(self, data_config: _config.DataConfig, data_loader: TorchDataLoader | RLDSDataLoader):
668
+ self._data_config = data_config
669
+ self._data_loader = data_loader
670
+
671
+ def data_config(self) -> _config.DataConfig:
672
+ return self._data_config
673
+
674
+ def __iter__(self):
675
+ for batch in self._data_loader:
676
+ yield _model.Observation.from_dict(batch), batch["actions"]
pibehavior_da3_clean_up_your_desk_40k/source_code/openpi/gemma.py ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 Big Vision Authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Gemma adaptation for Pi, taken from big_vision.
16
+
17
+ We follow this einsum axis naming convention:
18
+ B: batch
19
+ T: query length
20
+ S: k/v length
21
+ N: num query heads
22
+ K: num k/v heads
23
+ G: num query heads per k/v head
24
+ H: head dim
25
+ D: d_model ("features")
26
+ """
27
+
28
+ from collections.abc import Sequence
29
+ import dataclasses
30
+ from typing import Literal, TypeAlias
31
+
32
+ import os
33
+ import einops
34
+ import flax.linen as nn
35
+ import jax
36
+ import jax.numpy as jnp
37
+
38
+ import openpi.models.lora as lora
39
+ import openpi.shared.array_typing as at
40
+ import openpi.training.sharding as sharding
41
+
42
+ PALIGEMMA_VOCAB_SIZE = 257_152
43
+
44
+
45
+ @dataclasses.dataclass
46
+ class Config:
47
+ width: int
48
+ depth: int
49
+ mlp_dim: int
50
+ num_heads: int
51
+ num_kv_heads: int
52
+ head_dim: int
53
+ lora_configs: dict[str, lora.LoRAConfig] = dataclasses.field(default_factory=dict)
54
+
55
+
56
+ Variant = Literal["dummy", "gemma_300m", "gemma_300m_lora", "gemma_2b", "gemma_2b_lora"]
57
+
58
+
59
+ def get_config(variant: Variant) -> Config:
60
+ """Returns config for specified gemma variant."""
61
+ if variant == "dummy":
62
+ return Config(
63
+ width=64,
64
+ depth=4,
65
+ mlp_dim=128,
66
+ num_heads=8,
67
+ num_kv_heads=1,
68
+ head_dim=16,
69
+ )
70
+ if variant == "gemma_300m":
71
+ # 311M params
72
+ return Config(
73
+ width=1024,
74
+ depth=18,
75
+ mlp_dim=4096,
76
+ num_heads=8,
77
+ num_kv_heads=1,
78
+ head_dim=256,
79
+ )
80
+ if variant == "gemma_2b":
81
+ return Config(
82
+ width=2048,
83
+ depth=18,
84
+ mlp_dim=16_384,
85
+ num_heads=8,
86
+ num_kv_heads=1,
87
+ head_dim=256,
88
+ )
89
+ if variant == "gemma_2b_lora":
90
+ return Config(
91
+ width=2048,
92
+ depth=18,
93
+ mlp_dim=16_384,
94
+ num_heads=8,
95
+ num_kv_heads=1,
96
+ head_dim=256,
97
+ lora_configs={"attn": lora.LoRAConfig(rank=16, alpha=16.0), "ffn": lora.LoRAConfig(rank=16, alpha=16.0)},
98
+ )
99
+ if variant == "gemma_2b_lora_32":
100
+ return Config(
101
+ width=2048,
102
+ depth=18,
103
+ mlp_dim=16_384,
104
+ num_heads=8,
105
+ num_kv_heads=1,
106
+ head_dim=256,
107
+ lora_configs={"attn": lora.LoRAConfig(rank=32, alpha=32.0), "ffn": lora.LoRAConfig(rank=32, alpha=32.0)},
108
+ )
109
+ if variant == "gemma_300m_lora":
110
+ # 311M params
111
+ return Config(
112
+ width=1024,
113
+ depth=18,
114
+ mlp_dim=4096,
115
+ num_heads=8,
116
+ num_kv_heads=1,
117
+ head_dim=256,
118
+ lora_configs={"attn": lora.LoRAConfig(rank=32, alpha=32.0), "ffn": lora.LoRAConfig(rank=32, alpha=32.0)},
119
+ )
120
+ raise ValueError(f"Unknown variant: {variant}")
121
+
122
+
123
+ @at.typecheck
124
+ class RMSNorm(nn.Module):
125
+ @nn.compact
126
+ def __call__(self, x, cond):
127
+ dtype = x.dtype # original dtype, could be half-precision
128
+ var = jnp.mean(jnp.square(x.astype(jnp.float32)), axis=-1, keepdims=True) # compute variance in float32
129
+ normed_inputs = jnp.asarray(x * jnp.reciprocal(jnp.sqrt(var + 1e-06))) # compute normalization in float32
130
+ if cond is None:
131
+ # regular RMSNorm
132
+ scale = self.param("scale", nn.initializers.zeros_init(), (x.shape[-1]))
133
+ normed_inputs = normed_inputs * (
134
+ 1 + scale
135
+ ) # scale by learned parameter in float32 (matches Flax implementation)
136
+ return normed_inputs.astype(dtype), None # return in original dtype
137
+
138
+ # adaptive RMSNorm
139
+ modulation = nn.Dense(x.shape[-1] * 3, kernel_init=nn.initializers.zeros, dtype=dtype)(cond)
140
+ scale, shift, gate = jnp.split(modulation[:, None, :], 3, axis=-1)
141
+ normed_inputs = normed_inputs * (1 + scale) + shift # scale and shift in float32
142
+ return normed_inputs.astype(dtype), gate
143
+
144
+
145
+ @at.typecheck
146
+ class Embedder(nn.Module):
147
+ """Embedder module."""
148
+
149
+ vocab_size: int
150
+ embed_dim: int
151
+
152
+ def setup(self):
153
+ self.input_embedding_table = self.param(
154
+ "input_embedding",
155
+ nn.initializers.normal(),
156
+ (self.vocab_size, self.embed_dim),
157
+ )
158
+
159
+ def encode(self, x):
160
+ x = self.input_embedding_table[(x,)]
161
+ x *= jnp.sqrt(self.embed_dim).astype(x.dtype)
162
+ return x
163
+
164
+ def decode(self, x):
165
+ return jnp.dot(x, self.input_embedding_table.T)
166
+
167
+
168
+ @at.typecheck
169
+ class Attention(nn.Module):
170
+ """Attention module."""
171
+
172
+ configs: Sequence[Config]
173
+
174
+ @nn.compact
175
+ def __call__(self, xs, positions, attn_mask, kv_cache):
176
+ # all experts must share the same head dim, num heads, and num kv heads for self-attention to work
177
+ assert all(config.head_dim == self.configs[0].head_dim for config in self.configs)
178
+ assert all(config.num_heads == self.configs[0].num_heads for config in self.configs)
179
+ assert all(config.num_kv_heads == self.configs[0].num_kv_heads for config in self.configs)
180
+
181
+ dtype = next(x.dtype for x in xs if x is not None) # original dtype, could be half-precision
182
+
183
+ qkvs = []
184
+ for i, (x, config) in enumerate(zip(xs, self.configs, strict=True)):
185
+ if x is None:
186
+ continue
187
+ if config.num_kv_heads == config.num_heads:
188
+ qkv_einsum = lora.Einsum(
189
+ shape=(3, config.num_heads, config.width, config.head_dim),
190
+ name=_name("qkv_einsum", i),
191
+ init_fn=nn.initializers.lecun_normal(in_axis=-2, out_axis=-1, batch_axis=(0, 1)),
192
+ lora_config=config.lora_configs.get("attn"),
193
+ )
194
+ qkvs.append(qkv_einsum("BSD,3KDH->3BSKH", x))
195
+ else:
196
+ q_einsum = lora.Einsum(
197
+ shape=(config.num_heads, config.width, config.head_dim),
198
+ name=_name("q_einsum", i),
199
+ init_fn=nn.initializers.lecun_normal(in_axis=-2, out_axis=-1, batch_axis=(0,)),
200
+ lora_config=config.lora_configs.get("attn"),
201
+ )
202
+ q = q_einsum("BTD,NDH->BTNH", x)
203
+ kv_einsum = lora.Einsum(
204
+ shape=(2, config.num_kv_heads, config.width, config.head_dim),
205
+ name=_name("kv_einsum", i),
206
+ init_fn=nn.initializers.lecun_normal(in_axis=-2, out_axis=-1, batch_axis=(0, 1)),
207
+ lora_config=config.lora_configs.get("attn"),
208
+ )
209
+ k, v = kv_einsum("BSD,2KDH->2BSKH", x)
210
+ qkvs.append((q, k, v))
211
+
212
+ q, k, v = (jnp.concatenate(y, axis=1) for y in zip(*qkvs, strict=True))
213
+
214
+ q = _apply_rope(q, positions=positions)
215
+ q *= self.configs[0].head_dim ** -0.5
216
+
217
+ k = _apply_rope(k, positions=positions)
218
+
219
+ # should still be half-precision here (if input was half-precision)
220
+ assert q.dtype == k.dtype == v.dtype == dtype
221
+
222
+ if kv_cache is not None:
223
+ cache_k, cache_v = kv_cache
224
+ k = jnp.concatenate([cache_k, k], axis=1)
225
+ v = jnp.concatenate([cache_v, v], axis=1)
226
+
227
+ q = einops.rearrange(q, "B T (K G) H -> B T K G H", K=self.configs[0].num_kv_heads)
228
+ logits = jnp.einsum("BTKGH,BSKH->BKGTS", q, k, preferred_element_type=jnp.float32)
229
+
230
+ if attn_mask.shape != (q.shape[0], 1, q.shape[1], k.shape[1]):
231
+ raise ValueError(
232
+ f"Attention mask with shape {attn_mask.shape} but shapes for q and k are: {q.shape} and {k.shape}"
233
+ )
234
+
235
+ # big_neg = jnp.finfo(logits.dtype).min
236
+ big_neg = -2.3819763e38 # See gemma/modules.py
237
+ masked_logits = jnp.where(attn_mask[:, :, None, :, :], logits, big_neg)
238
+
239
+ probs = jax.nn.softmax(masked_logits, axis=-1).astype(dtype)
240
+
241
+ encoded = jnp.einsum("BKGTS,BSKH->BTKGH", probs, v)
242
+ encoded = einops.rearrange(encoded, "B T K G H -> B T (K G) H")
243
+
244
+ out = []
245
+ start = 0
246
+ for i, (x, config) in enumerate(zip(xs, self.configs, strict=True)):
247
+ if x is not None:
248
+ end = start + x.shape[1]
249
+ out_einsum = lora.Einsum(
250
+ shape=(config.num_heads, config.head_dim, config.width),
251
+ name=_name("attn_vec_einsum", i),
252
+ init_fn=nn.initializers.lecun_normal(in_axis=(-3, -2), out_axis=-1),
253
+ lora_config=config.lora_configs.get("attn"),
254
+ )
255
+ out.append(out_einsum("BTNH,NHD->BTD", encoded[:, start:end]))
256
+ start = end
257
+ else:
258
+ out.append(None)
259
+
260
+ return out, (k, v)
261
+
262
+
263
+ @at.typecheck
264
+ class FeedForward(nn.Module):
265
+ """Feed forward module."""
266
+
267
+ features: int
268
+ hidden_dim: int
269
+
270
+ @nn.compact
271
+ def __call__(self, x):
272
+ dtype = x.dtype # original dtype, could be half-precision
273
+ w_gating = self.param(
274
+ "gating_einsum",
275
+ nn.initializers.lecun_normal(in_axis=-2, out_axis=-1, batch_axis=(0,)),
276
+ (2, self.features, self.hidden_dim),
277
+ ).astype(dtype)
278
+ ff_gate = jnp.dot(x, w_gating[0])
279
+ gate_value = nn.gelu(ff_gate)
280
+
281
+ ff1 = jnp.dot(x, w_gating[1])
282
+ activations = gate_value * ff1
283
+
284
+ w_linear = self.param(
285
+ "linear",
286
+ nn.initializers.lecun_normal(in_axis=-2, out_axis=-1),
287
+ (self.hidden_dim, self.features),
288
+ ).astype(dtype)
289
+ outputs = jnp.dot(activations, w_linear)
290
+ assert outputs.dtype == dtype
291
+ return outputs
292
+
293
+
294
+ class _LinenCrossAttn(nn.Module):
295
+ """Pre-LN cross-attention. out_proj is zero-init by default (identity at step 0); out_init_std>0
296
+ gives it a small random init so q/k/v receive gradient from step 0 (with zero-init their grads
297
+ are zero until out_proj grows off zero, LoRA-style bootstrap)."""
298
+
299
+ d_model: int
300
+ num_heads: int
301
+ out_init_std: float = 0.0
302
+ logit_gain: bool = False # learnable per-head gain on the attention logits (v2 fix)
303
+ logit_gain_init: float = 1.0 # initial gain value; >1 = sharper attention at init (bigger softmax
304
+ # jacobian => stronger, more consistent Q/K gradients; uniform attention scales them by ~1/K)
305
+
306
+ @nn.compact
307
+ def __call__(self, q_hidden, kv):
308
+ hd = self.d_model // self.num_heads
309
+ q = nn.LayerNorm(epsilon=1e-5, name="q_norm")(q_hidden)
310
+ kvn = nn.LayerNorm(epsilon=1e-5, name="kv_norm")(kv)
311
+ Q = einops.rearrange(nn.Dense(self.d_model, name="q_proj")(q), "b l (h d) -> b h l d", h=self.num_heads)
312
+ K = einops.rearrange(nn.Dense(self.d_model, name="k_proj")(kvn), "b l (h d) -> b h l d", h=self.num_heads)
313
+ V = einops.rearrange(nn.Dense(self.d_model, name="v_proj")(kvn), "b l (h d) -> b h l d", h=self.num_heads)
314
+ logits = jnp.einsum("bhqd,bhkd->bhqk", Q, K) * (hd**-0.5)
315
+ if self.logit_gain:
316
+ # exp-parameterized per-head gain, init logit_gain_init (v2 uses 32); multiplies dL/dQ,K so
317
+ # the attention pattern can actually train (v1 measured Q/K ~1000x slower than V/out).
318
+ log_gain = self.param(
319
+ "log_gain", nn.initializers.constant(jnp.log(self.logit_gain_init)), (self.num_heads,)
320
+ )
321
+ logits = logits * jnp.exp(log_gain)[None, :, None, None].astype(logits.dtype)
322
+ probs = jax.nn.softmax(logits.astype(jnp.float32), axis=-1).astype(Q.dtype)
323
+ ctx = einops.rearrange(jnp.einsum("bhqk,bhkd->bhqd", probs, V), "b h q d -> b q (h d)")
324
+ out_init = nn.initializers.normal(stddev=self.out_init_std) if self.out_init_std > 0 else nn.initializers.zeros
325
+ return nn.Dense(
326
+ self.d_model, name="out_proj", kernel_init=out_init, bias_init=nn.initializers.zeros
327
+ )(ctx)
328
+
329
+
330
+ class SpatialActionInjection(nn.Module):
331
+ """X-VLA Method-B injection (main x-attn -> left/right branches -> merge), scaled residual.
332
+
333
+ init_std=0 (default): output projections zero-init => returns 0 (exact identity at step 0, pi05_base
334
+ reproduced bit-exactly). init_std>0: small normal init on the xattn out_projs and merge_proj => a
335
+ small nonzero spatial delta from step 0 and immediate gradient flow to all injection weights.
336
+ Returns the DELTA to add to the action-token stream (external per-layer gate selects it).
337
+ """
338
+
339
+ d_model: int
340
+ num_heads: int = 8
341
+ spatial_scale: float = 2.0
342
+ init_std: float = 0.0
343
+ logit_gain: bool = False
344
+ logit_gain_init: float = 1.0
345
+
346
+ @nn.compact
347
+ def __call__(self, h0, banks):
348
+ s = self.spatial_scale
349
+ std = self.init_std
350
+ g = self.logit_gain
351
+ gi = self.logit_gain_init
352
+ h1 = h0 + s * _LinenCrossAttn(self.d_model, self.num_heads, std, g, gi, name="main_xattn")(h0, banks["main"])
353
+ hL = nn.Dense(self.d_model, name="left_branch")(h1)
354
+ hR = nn.Dense(self.d_model, name="right_branch")(h1)
355
+ hL = hL + s * _LinenCrossAttn(self.d_model, self.num_heads, std, g, gi, name="left_xattn")(hL, banks["left"])
356
+ hR = hR + s * _LinenCrossAttn(self.d_model, self.num_heads, std, g, gi, name="right_xattn")(hR, banks["right"])
357
+ merge_init = nn.initializers.normal(stddev=std) if std > 0 else nn.initializers.zeros
358
+ merge = nn.Dense(
359
+ self.d_model, name="merge_proj", kernel_init=merge_init, bias_init=nn.initializers.zeros
360
+ )(jnp.concatenate([hL, hR], axis=-1))
361
+ out = h1 + s * merge
362
+ return out - h0
363
+
364
+
365
+ @at.typecheck
366
+ class Block(nn.Module):
367
+ """Transformer block."""
368
+
369
+ configs: tuple[Config, ...]
370
+
371
+ dropout: float = 0.0
372
+ dropout_bdims: tuple[int, ...] = ()
373
+ spatial_scale: float = 2.0
374
+ spatial_init_std: float = 0.0
375
+ spatial_logit_gain: bool = False
376
+ spatial_logit_gain_init: float = 1.0
377
+
378
+ @nn.compact
379
+ def __call__( # noqa: FBT002
380
+ self, xs, kv_cache, positions, attn_mask, adarms_cond, deterministic=True, banks=None, layer_gate=None
381
+ ):
382
+ xs = sharding.activation_sharding_constraint(xs)
383
+ drop = nn.Dropout(self.dropout, self.dropout_bdims) if self.dropout else lambda x, _: x
384
+
385
+ attn = Attention(configs=self.configs, name="attn")
386
+
387
+ pre_attn = []
388
+ gates = []
389
+ for i, x in enumerate(xs):
390
+ if x is not None:
391
+ x, gate = RMSNorm(name=_name("pre_attention_norm", i))(x, adarms_cond[i]) # noqa: PLW2901
392
+ pre_attn.append(x)
393
+ gates.append(gate if x is not None else None)
394
+
395
+ pre_attn = sharding.activation_sharding_constraint(pre_attn)
396
+ post_attn, kv_cache = attn(pre_attn, positions, attn_mask, kv_cache)
397
+ post_attn = jax.tree.map(lambda x: drop(x, deterministic), post_attn)
398
+ post_attn = sharding.activation_sharding_constraint(post_attn)
399
+ xs = [_gated_residual(x, y, gate) for x, y, gate in zip(xs, post_attn, gates, strict=True)]
400
+ xs = sharding.activation_sharding_constraint(xs)
401
+
402
+ out = []
403
+ gates = []
404
+ for i, (x, config) in enumerate(zip(xs, self.configs, strict=True)):
405
+ if x is not None:
406
+ x, gate = RMSNorm(name=_name("pre_ffw_norm", i))(x, adarms_cond[i]) # noqa: PLW2901
407
+ x = lora.FeedForward( # noqa: PLW2901
408
+ features=config.width,
409
+ hidden_dim=config.mlp_dim,
410
+ name=_name("mlp", i),
411
+ lora_config=config.lora_configs.get("ffn"),
412
+ )(x)
413
+ out.append(x)
414
+ gates.append(gate if x is not None else None)
415
+
416
+ out = sharding.activation_sharding_constraint(out)
417
+ out = jax.tree.map(lambda x: drop(x, deterministic), out)
418
+ xs = [_gated_residual(x, y, gate) for x, y, gate in zip(xs, out, gates, strict=True)]
419
+ xs = sharding.activation_sharding_constraint(xs)
420
+
421
+ # ---- DA3 spatial injection on the SUFFIX (action-expert, expert 1) stream ONLY ----
422
+ # layer_gate is a per-layer scalar (0 for early blocks, 1 for the last N); with init_std=0 the
423
+ # injection delta is 0 (zero-init output) so this is an exact no-op regardless of the gate.
424
+ if banks is not None and len(xs) > 1 and xs[1] is not None:
425
+ suf = xs[1]
426
+ delta = SpatialActionInjection(
427
+ d_model=self.configs[1].width,
428
+ spatial_scale=self.spatial_scale,
429
+ init_std=self.spatial_init_std,
430
+ logit_gain=self.spatial_logit_gain,
431
+ logit_gain_init=self.spatial_logit_gain_init,
432
+ name="spatial_inject_1",
433
+ )(suf, banks)
434
+ # cast back to the suffix (carry) dtype: the injection runs in fp32 but the scan carry is bf16
435
+ xs = [xs[0], (suf + layer_gate.astype(delta.dtype) * delta).astype(suf.dtype)]
436
+ xs = sharding.activation_sharding_constraint(xs)
437
+
438
+ return xs, kv_cache
439
+
440
+
441
+ KVCache: TypeAlias = tuple[at.Float[at.Array, "l b _t _k _h"], at.Float[at.Array, "l b _t _v _h"]]
442
+
443
+
444
+ @at.typecheck
445
+ class Module(nn.Module):
446
+ """Transformer model, supporting a mixture of different weights for different tokens."""
447
+
448
+ configs: Sequence[Config] # list of configs, one for each expert
449
+ embed_dtype: str
450
+
451
+ dropout: float = 0.0
452
+ dropout_bdims: tuple[int, ...] = () # Every float is dropped independently.
453
+ adarms: bool = False
454
+ # DA3 spatial injection: inject per-view banks into the SUFFIX (action-expert) stream at the
455
+ # last `num_spatial_layers` blocks, via a gated cross-attention inside the same scan.
456
+ spatial_inject: bool = False
457
+ num_spatial_layers: int = 6
458
+ spatial_scale: float = 2.0
459
+ spatial_init_std: float = 0.0
460
+ spatial_logit_gain: bool = False # learnable per-head attention-logit gain in the injection (v2)
461
+ spatial_logit_gain_init: float = 1.0 # v2 load-bearing fix: init the per-head gain to 32
462
+
463
+ def setup(self):
464
+ # all experts must have the same depth
465
+ assert all(config.depth == self.configs[0].depth for config in self.configs)
466
+
467
+ self.embedder = Embedder(
468
+ vocab_size=PALIGEMMA_VOCAB_SIZE,
469
+ embed_dim=self.configs[0].width, # embedder for first expert only
470
+ name="embedder",
471
+ )
472
+ # Rematerialization trades memory for compute: activations are recomputed in the backward
473
+ # pass instead of stored. `nothing_saveable` (the default here) is the MOST aggressive
474
+ # setting -- minimum memory, maximum recompute (~30% extra FLOPs). Relaxing it is
475
+ # NUMERICALLY IDENTICAL (it only changes how gradients are computed, not their values), so
476
+ # where VRAM allows, a lighter policy trains faster:
477
+ # B1K_REMAT=full (default) -> nothing_saveable : least memory, slowest
478
+ # B1K_REMAT=dots -> dots_saveable : keeps matmul outputs, much less recompute
479
+ # B1K_REMAT=none -> no remat : most memory, fastest
480
+ _remat = os.environ.get("B1K_REMAT", "full").lower()
481
+ if _remat == "none":
482
+ block_cls = Block
483
+ else:
484
+ block_cls = nn.remat(
485
+ Block,
486
+ prevent_cse=False,
487
+ static_argnums=(5,), # 0=self, 6=deterministic
488
+ policy=jax.checkpoint_policies.dots_saveable
489
+ if _remat == "dots"
490
+ else jax.checkpoint_policies.nothing_saveable,
491
+ )
492
+ # banks are broadcast (same for all blocks, computed once); layer_gate is scanned on axis 0
493
+ # so each block receives its own scalar gate[l] (0 for early blocks, 1 for the last N).
494
+ base_in_axes = (0, nn.broadcast, nn.broadcast, nn.broadcast, nn.broadcast)
495
+ in_axes = (*base_in_axes, nn.broadcast, 0) if self.spatial_inject else base_in_axes
496
+ self.layers = nn.scan(
497
+ block_cls,
498
+ variable_axes={"params": 0},
499
+ split_rngs={"params": True, "dropout": True},
500
+ in_axes=in_axes,
501
+ length=self.configs[0].depth,
502
+ )(
503
+ configs=self.configs,
504
+ dropout=self.dropout,
505
+ dropout_bdims=self.dropout_bdims,
506
+ spatial_scale=self.spatial_scale,
507
+ spatial_init_std=self.spatial_init_std,
508
+ spatial_logit_gain=self.spatial_logit_gain,
509
+ spatial_logit_gain_init=self.spatial_logit_gain_init,
510
+ )
511
+ self.final_norms = [RMSNorm(name=_name("final_norm", i)) for i in range(len(self.configs))]
512
+
513
+ @at.typecheck
514
+ def embed(self, tokens: at.Int[at.Array, "b t"]) -> at.Float[at.Array, "b t d"]:
515
+ return self.embedder.encode(tokens).astype(self.embed_dtype)
516
+
517
+ @at.typecheck
518
+ def __call__(
519
+ self,
520
+ # list of token arrays, one for each expert, or None if that expert should not be run
521
+ embedded: Sequence[at.Float[at.Array, "b _t _d"] | None],
522
+ positions: at.Int[at.Array, "b t"],
523
+ mask: at.Bool[at.Array, "b t s"],
524
+ adarms_cond: Sequence[at.Float[at.Array, "b _d"] | None] | None = None,
525
+ *,
526
+ kv_cache: KVCache | None = None,
527
+ deterministic: bool = True,
528
+ banks: dict | None = None,
529
+ ) -> tuple[Sequence[at.Float[at.Array, "b _t _d"] | None], KVCache]:
530
+ embedded = jax.tree.map(lambda e: e.astype(self.embed_dtype), embedded)
531
+ mask = jnp.asarray(mask)[:, None, :, :]
532
+ if adarms_cond is None:
533
+ adarms_cond = [None] * len(self.configs)
534
+
535
+ if self.spatial_inject:
536
+ depth = self.configs[0].depth
537
+ if banks is None:
538
+ # Prefix-only passes carry no banks. The scan's in_axes arity is fixed at build time,
539
+ # so feed DUMMY banks + a zero gate — the Block skips injection anyway when the
540
+ # suffix stream (xs[1]) is None, and the zero gate kills it otherwise.
541
+ b = next(e.shape[0] for e in embedded if e is not None)
542
+ w = self.configs[1].width
543
+ banks = {k: jnp.zeros((b, 1, w), dtype=jnp.dtype(self.embed_dtype)) for k in ("main", "left", "right")}
544
+ layer_gate = jnp.zeros((depth,), jnp.float32)
545
+ else:
546
+ # gate[l] = 1 for the last num_spatial_layers blocks, else 0 (per-layer scalar via scan axis 0)
547
+ layer_gate = (jnp.arange(depth) >= depth - self.num_spatial_layers).astype(jnp.float32)
548
+ embedded, kv_cache = self.layers(
549
+ embedded, kv_cache, positions, mask, adarms_cond, deterministic, banks, layer_gate
550
+ )
551
+ else:
552
+ embedded, kv_cache = self.layers(embedded, kv_cache, positions, mask, adarms_cond, deterministic)
553
+
554
+ assert all(e.dtype == jnp.dtype(self.embed_dtype) for e in embedded if e is not None)
555
+
556
+ return [
557
+ f(e, a)[0] if e is not None else e for f, e, a in zip(self.final_norms, embedded, adarms_cond, strict=True)
558
+ ], kv_cache
559
+
560
+ def init(self, use_adarms: Sequence[bool]):
561
+ """Convenience method for initializing all parameters, necessary due to the quirks of linen."""
562
+ self.embed(jnp.zeros((1, 1), dtype=jnp.int32))
563
+ # dummy banks so the spatial-injection params are materialized during lazy_init (bank dim
564
+ # == action-expert width == configs[1].width); 1 dummy token per view is enough.
565
+ banks = None
566
+ if self.spatial_inject:
567
+ w = self.configs[1].width
568
+ banks = {k: jnp.zeros((1, 1, w)) for k in ("main", "left", "right")}
569
+ self(
570
+ [jnp.zeros((1, 1, c.width)) for c in self.configs],
571
+ jnp.zeros((1, len(self.configs)), dtype=jnp.int32),
572
+ jnp.zeros((1, len(self.configs), len(self.configs)), dtype=bool),
573
+ adarms_cond=[jnp.zeros((1, c.width)) if u else None for u, c in zip(use_adarms, self.configs, strict=True)],
574
+ banks=banks,
575
+ )
576
+
577
+
578
+ def _apply_rope(x, *, positions, max_wavelength=10_000):
579
+ """Applies RoPE positions [B, L] to x [B, L, H, D]."""
580
+ freq_exponents = (2.0 / x.shape[-1]) * jnp.arange(x.shape[-1] // 2, dtype=jnp.float32)
581
+ timescale = max_wavelength**freq_exponents
582
+ radians = positions[..., None] / timescale[None, None, :]
583
+ radians = radians[..., None, :]
584
+ assert radians.dtype == jnp.float32
585
+ # radians.shape = [...,L,1,d=D/2]
586
+ sin, cos = jnp.sin(radians), jnp.cos(radians)
587
+ x1, x2 = jnp.split(x, 2, axis=-1)
588
+ res = jnp.concatenate([x1 * cos - x2 * sin, x2 * cos + x1 * sin], axis=-1)
589
+ assert res.dtype == jnp.float32
590
+ # The original bigvision impl allows RoPE to upcast to float32. It is then immediately downcast again to the cache
591
+ # dtype when in inference mode (but not in training mode). I don't think any of this was intentional. Based on the
592
+ # original DeepMind impl, as well as the widely-used transformers impl, it is ok to always downcast back to bfloat16
593
+ # here.
594
+ return res.astype(x.dtype)
595
+
596
+
597
+ def _name(name, i):
598
+ # we name layers like this because we want the first expert's weights to have no suffix (e.g., "attn"), so that they
599
+ # can be loaded seamlessly from the existing PaliGemma checkpoint. subsequent experts will have a suffix (e.g.,
600
+ # "attn_1") and their weights will be initialized from scratch. in practice, we only use two experts -- PaliGemma,
601
+ # and the action expert.
602
+ if i == 0:
603
+ return name
604
+ return f"{name}_{i}"
605
+
606
+
607
+ def _gated_residual(x, y, gate):
608
+ assert (x is None) == (y is None)
609
+ if x is None:
610
+ return None
611
+ if gate is None:
612
+ return x + y
613
+ return x + y * gate
pibehavior_da3_clean_up_your_desk_40k/source_code/openpi/optimizer.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import dataclasses
2
+ from typing import Protocol, runtime_checkable
3
+
4
+ import jax.numpy as jnp
5
+ import optax
6
+
7
+ import openpi.shared.array_typing as at
8
+
9
+
10
+ @runtime_checkable
11
+ class LRScheduleConfig(Protocol):
12
+ def create(self) -> optax.Schedule: ...
13
+
14
+
15
+ @dataclasses.dataclass(frozen=True)
16
+ class CosineDecaySchedule(LRScheduleConfig):
17
+ """Cosine decay schedule with warmup."""
18
+
19
+ warmup_steps: int = 1_000
20
+ peak_lr: float = 2.5e-5
21
+ decay_steps: int = 30_000
22
+ decay_lr: float = 2.5e-6
23
+
24
+ def create(self) -> optax.Schedule:
25
+ return optax.warmup_cosine_decay_schedule(
26
+ init_value=self.peak_lr / (self.warmup_steps + 1),
27
+ peak_value=self.peak_lr,
28
+ warmup_steps=self.warmup_steps,
29
+ decay_steps=self.decay_steps,
30
+ end_value=self.decay_lr,
31
+ )
32
+
33
+
34
+ @dataclasses.dataclass(frozen=True)
35
+ class DelayedRampCosineSchedule(LRScheduleConfig):
36
+ """Hold at exactly 0 for `delay_steps`, linearly ramp 0 -> peak over `ramp_steps`, then cosine
37
+ decay to `decay_lr` at `decay_steps` (total).
38
+
39
+ For the DA3 *geometry* group: the randomly-initialized ray-MLP is held frozen while the
40
+ pretrained trunk takes the initial adaptation, then phased in slowly instead of hitting peak LR
41
+ alongside everything else. Geometry modules are sensitive to being driven hard from init.
42
+ """
43
+
44
+ delay_steps: int = 500
45
+ ramp_steps: int = 10_000
46
+ peak_lr: float = 7e-4
47
+ decay_steps: int = 25_000
48
+ decay_lr: float = 1e-4
49
+
50
+ def create(self) -> optax.Schedule:
51
+ tail = max(1, self.decay_steps - self.delay_steps - self.ramp_steps)
52
+ return optax.join_schedules(
53
+ [
54
+ optax.constant_schedule(0.0), # frozen: geometry group takes no updates
55
+ optax.linear_schedule(init_value=0.0, end_value=self.peak_lr, transition_steps=self.ramp_steps),
56
+ optax.cosine_decay_schedule(
57
+ init_value=self.peak_lr, decay_steps=tail, alpha=self.decay_lr / self.peak_lr
58
+ ),
59
+ ],
60
+ [self.delay_steps, self.delay_steps + self.ramp_steps],
61
+ )
62
+
63
+
64
+ @dataclasses.dataclass(frozen=True)
65
+ class RsqrtDecaySchedule(LRScheduleConfig):
66
+ """Inverse square root decay schedule with warmup."""
67
+
68
+ warmup_steps: int = 1_000
69
+ peak_lr: float = 5e-5
70
+ timescale: float = 10_000
71
+
72
+ def create(self) -> optax.Schedule:
73
+ return optax.join_schedules(
74
+ [
75
+ optax.linear_schedule(
76
+ init_value=self.peak_lr / (self.warmup_steps + 1),
77
+ end_value=self.peak_lr,
78
+ transition_steps=self.warmup_steps,
79
+ ),
80
+ lambda step: self.peak_lr / jnp.sqrt((self.timescale + step) / self.timescale),
81
+ ],
82
+ [self.warmup_steps],
83
+ )
84
+
85
+
86
+ @runtime_checkable
87
+ class OptimizerConfig(Protocol):
88
+ def create(
89
+ self,
90
+ lr: optax.ScalarOrSchedule,
91
+ weight_decay_mask: at.PyTree | None = None,
92
+ ) -> optax.GradientTransformation: ...
93
+
94
+
95
+ @dataclasses.dataclass(frozen=True)
96
+ class AdamW(OptimizerConfig):
97
+ """AdamW optimizer."""
98
+
99
+ b1: float = 0.9
100
+ b2: float = 0.95
101
+ eps: float = 1e-8
102
+ # Changing this to 0 can cause out-of-memory errors for some reason, so we set it to a negligible value.
103
+ weight_decay: float = 1e-10
104
+ clip_gradient_norm: float = 1.0
105
+
106
+ def create(
107
+ self,
108
+ lr: optax.ScalarOrSchedule,
109
+ weight_decay_mask: at.PyTree | None = None,
110
+ ) -> optax.GradientTransformation:
111
+ tx = optax.adamw(
112
+ lr, b1=self.b1, b2=self.b2, eps=self.eps, weight_decay=self.weight_decay, mask=weight_decay_mask
113
+ )
114
+
115
+ return optax.chain(optax.clip_by_global_norm(self.clip_gradient_norm), tx)
116
+
117
+
118
+ @dataclasses.dataclass(frozen=True)
119
+ class SGD(OptimizerConfig):
120
+ """SGD optimizer."""
121
+
122
+ lr: float = 5e-5
123
+ momentum: float = 0.9
124
+ nesterov: bool = False
125
+
126
+ def create(
127
+ self,
128
+ lr: optax.ScalarOrSchedule,
129
+ weight_decay_mask: at.PyTree | None = None,
130
+ ) -> optax.GradientTransformation:
131
+ assert weight_decay_mask is None, "Weight decay is not supported for SGD"
132
+ return optax.sgd(lr, momentum=self.momentum, nesterov=self.nesterov)
133
+
134
+
135
+ def create_optimizer(
136
+ optimizer: OptimizerConfig, lr_schedule: LRScheduleConfig, weight_decay_mask: at.PyTree | None = None
137
+ ) -> optax.GradientTransformation:
138
+ lr = lr_schedule.create()
139
+ return optimizer.create(lr, weight_decay_mask=weight_decay_mask)
140
+
141
+
142
+ def spatial_group_labels(params: at.PyTree) -> at.PyTree:
143
+ """Label each param leaf 'geom' | 'core' | 'vlm' by its keypath, for the DA3 multi-LR setup.
144
+
145
+ geom = the DA3 geometry encoder (ray MLP); core = the trainable spatial branch (bank builder +
146
+ cross-attn injection); vlm = the PiBehavior backbone (VLM + action expert + task/stage/kv heads).
147
+ """
148
+ import jax
149
+
150
+ def label(kp, _leaf):
151
+ s = jax.tree_util.keystr(kp)
152
+ if "ray_mlp" in s:
153
+ return "geom"
154
+ if "spatial_bank_builder" in s or "spatial_inject" in s:
155
+ return "core"
156
+ return "vlm"
157
+
158
+ return jax.tree_util.tree_map_with_path(label, params)
159
+
160
+
161
+ def create_multi_group_optimizer(
162
+ optimizer: OptimizerConfig,
163
+ lr_schedules: dict[str, LRScheduleConfig],
164
+ label_fn,
165
+ weight_decay_mask: at.PyTree | None = None,
166
+ ) -> optax.GradientTransformation:
167
+ """Per-parameter-group optimizer: a distinct LR schedule per group via optax.multi_transform.
168
+
169
+ `lr_schedules` maps group name -> schedule; `label_fn(params)` returns a matching-structure tree of
170
+ group names. Every leaf must map to a group present in `lr_schedules`.
171
+ """
172
+ txs = {
173
+ group: optimizer.create(sched.create(), weight_decay_mask=weight_decay_mask)
174
+ for group, sched in lr_schedules.items()
175
+ }
176
+ return optax.multi_transform(txs, label_fn)
pibehavior_da3_clean_up_your_desk_40k/source_code/openpi/sharding.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import contextlib
2
+ import logging
3
+ import os
4
+
5
+ import jax
6
+ import numpy as np
7
+
8
+ BATCH_AXIS = "batch"
9
+ FSDP_AXIS = "fsdp"
10
+ # In FSDP, we shard the data across both the batch and FSDP axes.
11
+ DATA_AXIS = (BATCH_AXIS, FSDP_AXIS)
12
+
13
+
14
+ class _MeshState:
15
+ active_mesh: jax.sharding.Mesh | None = None
16
+
17
+
18
+ def make_mesh(num_fsdp_devices: int) -> jax.sharding.Mesh:
19
+ devices = jax.devices()
20
+ # B1K DA3 inline-extraction speedup: hold GPU 0 out of the training mesh so the frozen DA3-GIANT
21
+ # extractor (pinned to cuda:0) runs there WITHOUT contending with the JAX train step. The prefetch
22
+ # thread then overlaps extraction of batch N+1 with the training of batch N on GPUs 1..N.
23
+ holdout = int(os.environ.get("B1K_HOLDOUT_GPU0", "0"))
24
+ if holdout > 0:
25
+ devices = devices[holdout:]
26
+ logging.info("make_mesh: holding out %d device(s) for extraction; training on %d devices %s",
27
+ holdout, len(devices), [d.id for d in devices])
28
+ n = len(devices)
29
+ if n % num_fsdp_devices != 0:
30
+ raise ValueError(
31
+ f"Number of training devices {n} must be divisible by the number of FSDP devices {num_fsdp_devices}."
32
+ )
33
+ mesh_shape = (n // num_fsdp_devices, num_fsdp_devices)
34
+ mesh_devices = np.array(devices).reshape(mesh_shape)
35
+ return jax.sharding.Mesh(mesh_devices, (BATCH_AXIS, FSDP_AXIS))
36
+
37
+
38
+ @contextlib.contextmanager
39
+ def set_mesh(mesh: jax.sharding.Mesh):
40
+ """Plumbing the mesh deep into the module tree is extremeley cumbersome; until the JAX team lands a better API, a
41
+ custom context manager like this one is the recommended way to maintain a reference to a global mesh. This is only used
42
+ in `activation_sharding_constraint` below."""
43
+ if _MeshState.active_mesh is not None:
44
+ raise ValueError("Cannot nest set_mesh context managers.")
45
+ _MeshState.active_mesh = mesh
46
+ try:
47
+ yield
48
+ finally:
49
+ _MeshState.active_mesh = None
50
+
51
+
52
+ def activation_sharding_constraint(pytree):
53
+ if _MeshState.active_mesh is None:
54
+ return pytree
55
+ return jax.lax.with_sharding_constraint(
56
+ pytree, jax.sharding.NamedSharding(_MeshState.active_mesh, jax.sharding.PartitionSpec(DATA_AXIS))
57
+ )
58
+
59
+
60
+ def fsdp_sharding(
61
+ pytree,
62
+ mesh: jax.sharding.Mesh,
63
+ *,
64
+ min_size_mbytes: int = 4, # 4 MiB
65
+ log: bool = False,
66
+ ):
67
+ """Apply FSDP sharding to a pytree of arrays based on the mesh shape.
68
+
69
+ Args:
70
+ pytree: A pytree to be apply sharding specified by the mesh, note that only array types (eg. contains .shape attr)
71
+ will be considered for sharding.
72
+ mesh: The mesh being used for applying sharding on to pytree.
73
+ min_size_mbytes: The minimum size of the array in MiB to be considered for sharding, any array smaller than this
74
+ will be replicated.
75
+ log: If true, will log the sharding decisions for arrays that are being considered for sharding.
76
+
77
+ Returns:
78
+ The sharded pytree.
79
+ """
80
+ min_size_bytes = min_size_mbytes * 2**20
81
+
82
+ def _shard_arr(kp, array: jax.ShapeDtypeStruct):
83
+ # if fsdp is not actually going to be used, replicate everything to avoid extraneous logging
84
+ if mesh.shape[FSDP_AXIS] == 1:
85
+ return jax.sharding.NamedSharding(mesh, jax.sharding.PartitionSpec())
86
+ # replicate scalar and vector arrays
87
+ if not hasattr(array, "shape"):
88
+ return jax.sharding.NamedSharding(mesh, jax.sharding.PartitionSpec())
89
+ if len(array.shape) < 2:
90
+ return jax.sharding.NamedSharding(mesh, jax.sharding.PartitionSpec())
91
+ # replicate small arrays
92
+ if (arr_size := np.prod(array.shape) * np.dtype(array.dtype).itemsize) < min_size_bytes:
93
+ return jax.sharding.NamedSharding(mesh, jax.sharding.PartitionSpec())
94
+
95
+ # shard matrices and larger tensors along the largest axis that is divisible by the fsdp dimension
96
+ axes = np.argsort(array.shape)[::-1]
97
+ spec = [None] * len(axes)
98
+ for i in axes:
99
+ if array.shape[i] % mesh.shape[FSDP_AXIS] == 0:
100
+ if log:
101
+ logging.info(
102
+ f"Sharding {jax.tree_util.keystr(kp)} of shape {array.shape} ({arr_size / 2**20:.2f} MiB) along axis {i}"
103
+ )
104
+ spec[i] = FSDP_AXIS
105
+ return jax.sharding.NamedSharding(mesh, jax.sharding.PartitionSpec(*spec))
106
+
107
+ # replicate if no valid sharding was found
108
+ if log:
109
+ logging.warning(
110
+ f"Could not find a valid sharding for {jax.tree_util.keystr(kp)} of shape {array.shape} with mesh of shape {mesh.shape}"
111
+ )
112
+ return jax.sharding.NamedSharding(mesh, jax.sharding.PartitionSpec())
113
+
114
+ return jax.tree_util.tree_map_with_path(_shard_arr, pytree)
pibehavior_da3_clean_up_your_desk_40k/source_code/openpi/weight_loaders.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import dataclasses
2
+ import logging
3
+ import re
4
+ from typing import Protocol, runtime_checkable
5
+
6
+ import flax.traverse_util
7
+ import numpy as np
8
+
9
+ import openpi.models.model as _model
10
+ import openpi.shared.array_typing as at
11
+ import openpi.shared.download as download
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @runtime_checkable
17
+ class WeightLoader(Protocol):
18
+ def load(self, params: at.Params) -> at.Params:
19
+ """Loads the model weights.
20
+
21
+ Args:
22
+ params: Parameters of the model. This is a nested structure of array-like objects that
23
+ represent the model's parameters.
24
+
25
+ Returns:
26
+ Loaded parameters. The structure must be identical to `params`. If returning a subset of
27
+ the parameters the loader must merge the loaded parameters with `params`.
28
+ """
29
+
30
+
31
+ @dataclasses.dataclass(frozen=True)
32
+ class NoOpWeightLoader(WeightLoader):
33
+ def load(self, params: at.Params) -> at.Params:
34
+ return params
35
+
36
+
37
+ @dataclasses.dataclass(frozen=True)
38
+ class CheckpointWeightLoader(WeightLoader):
39
+ """Loads an entire set of weights from a checkpoint.
40
+
41
+ Compatible with:
42
+ trained checkpoints:
43
+ example: "./checkpoints/<config>/<exp>/<step>/params"
44
+ released checkpoints:
45
+ example: "gs://openpi-assets/checkpoints/<model>/params"
46
+ """
47
+
48
+ params_path: str
49
+
50
+ def load(self, params: at.Params) -> at.Params:
51
+ # We are loading np.ndarray and relying on the training code to properly convert and shard the params.
52
+ loaded_params = _model.restore_params(download.maybe_download(self.params_path), restore_type=np.ndarray)
53
+ # Add all missing LoRA weights.
54
+ return _merge_params(loaded_params, params, missing_regex=".*lora.*")
55
+
56
+
57
+ @dataclasses.dataclass(frozen=True)
58
+ class PaliGemmaWeightLoader(WeightLoader):
59
+ """Loads weights from the official PaliGemma checkpoint.
60
+
61
+ This will overwrite existing weights with similar names while keeping all extra weights intact.
62
+ This allows us to support the action expert which is used by the Pi0 model.
63
+ """
64
+
65
+ def load(self, params: at.Params) -> at.Params:
66
+ path = download.maybe_download(
67
+ "gs://vertex-model-garden-paligemma-us/paligemma/pt_224.npz", gs={"token": "anon"}
68
+ )
69
+ with path.open("rb") as f:
70
+ flat_params = dict(np.load(f, allow_pickle=False))
71
+ loaded_params = {"PaliGemma": flax.traverse_util.unflatten_dict(flat_params, sep="/")["params"]}
72
+ # Add all missing weights.
73
+ return _merge_params(loaded_params, params, missing_regex=".*")
74
+
75
+
76
+ def _merge_params(loaded_params: at.Params, params: at.Params, *, missing_regex: str) -> at.Params:
77
+ """Merges the loaded parameters with the reference parameters.
78
+
79
+ Args:
80
+ loaded_params: The parameters to merge.
81
+ params: The reference parameters.
82
+ missing_regex: A regex pattern for all missing keys that should be merged from the reference parameters.
83
+
84
+ Returns:
85
+ A new dictionary with the merged parameters.
86
+ """
87
+ # Flatten with TUPLE keys (no sep-join): some modules (e.g. the DA3 spatial bank builder) store
88
+ # submodules in lists, which nnx encodes with INTEGER path elements — sep="/" would crash on
89
+ # `"/".join(path)`. The string path is built only for the regex match below.
90
+ flat_ref = flax.traverse_util.flatten_dict(params)
91
+ flat_loaded = flax.traverse_util.flatten_dict(loaded_params)
92
+
93
+ # First, take all weights that are a subset of the reference weights.
94
+ result = {}
95
+ for k, v in flat_loaded.items():
96
+ if k in flat_ref:
97
+ if v.dtype == flat_ref[k].dtype:
98
+ result[k] = v
99
+ else:
100
+ print(f"Warning: {k} has dtype {v.dtype} but reference has dtype {flat_ref[k].dtype}")
101
+ result[k] = v.astype(flat_ref[k].dtype)
102
+ flat_loaded.clear()
103
+
104
+ # Then, merge any missing weights as defined by the missing regex.
105
+ pattern = re.compile(missing_regex)
106
+ for k in {k for k in flat_ref if pattern.fullmatch("/".join(str(p) for p in k))}:
107
+ if k not in result:
108
+ result[k] = flat_ref[k]
109
+
110
+ return flax.traverse_util.unflatten_dict(result)
pibehavior_da3_clean_up_your_desk_40k/source_code/training/_da3_contrib_check.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Measure DA3 contribution on a trained checkpoint, on CORRECTED geometry.
2
+ CPU-only (extractor on CPU) so it never touches the training GPUs.
3
+
4
+ Generates one real batch through the fixed-geometry loader, loads the checkpoint, then compares
5
+ loss WITH banks vs banks-zeroed (same noise). If delta ~ 0 => the model IGNORES spatial (the
6
+ zero-init failure mode). If delta > 0 => DA3 is load-bearing.
7
+ """
8
+ import os, sys, dataclasses, json
9
+ import numpy as np, jax, jax.numpy as jnp, flax.nnx as nnx
10
+ sys.path.insert(0, "scripts")
11
+
12
+ CKPT = os.environ.get("CONTRIB_CKPT",
13
+ "/work/jack/behavior-1k-solution/outputs/checkpoints/pi_behavior_b1k_fast/b1k_da3_robopro_desk/14000/params")
14
+
15
+
16
+ def main():
17
+ os.environ.update(USE_DA3_FULL="1", DA3_INIT_STD="0.01", DA3_LOGIT_GAIN="1",
18
+ B1K_ACTIVITIES="clean_up_your_desk", B1K_EXTRACT_DEVICES="cpu",
19
+ B1K_2026_ROOT="/work/jack/behavior1k/data/behavior_2026_task29_42",
20
+ B1K_INIT_PARAMS=CKPT)
21
+ import train_2026 as t26
22
+ from b1k.training.b1k_da3 import create_v3_behavior_da3_loader
23
+
24
+ config = dataclasses.replace(t26.build_config(), batch_size=2, num_workers=0)
25
+ import jax as _j
26
+ sharding = _j.sharding.SingleDeviceSharding(_j.devices("cpu")[0])
27
+ print("[1] building CPU loader (extractor on CPU, corrected geometry)...", flush=True)
28
+ loader = create_v3_behavior_da3_loader(
29
+ config, os.environ["B1K_2026_ROOT"], ["clean_up_your_desk"], "/work/jack/behavior1k/task_data.json",
30
+ lang_cache="/work/jack/behavior1k/modernbert_b1k_tasks.pkl", sharding=sharding, shuffle=True, num_workers=0)
31
+ obs, actions = next(iter(loader))
32
+ print("[2] got one fixed-geometry batch; loading checkpoint", flush=True)
33
+
34
+ model = config.model.create(jax.random.key(0))
35
+ loaded = config.weight_loader.load(jax.tree.map(np.asarray, nnx.state(model).to_pure_dict()))
36
+ gd, st = nnx.split(model); st.replace_by_pure_dict(loaded); model = nnx.merge(gd, st)
37
+ raw = json.load(open("/work/jack/behavior1k/checkpoints/behavior_50t_checkpoint/assets/IliaLarchenko/behavior_224_rgb/norm_stats.json"))["norm_stats"]
38
+ model.load_correlation_matrix({"actions": {k: (np.asarray(v, np.float32) if isinstance(v, list) else v) for k, v in raw["actions"].items()}})
39
+
40
+ rng = jax.random.key(7); NFS = 8
41
+ ld_with = model.compute_detailed_loss(rng, obs, actions, train=False, num_flow_samples=NFS)
42
+ orig = model._compute_banks; model._compute_banks = lambda o: None
43
+ ld_wo = model.compute_detailed_loss(rng, obs, actions, train=False, num_flow_samples=NFS)
44
+ model._compute_banks = orig
45
+ aw, ao = float(jnp.mean(ld_with["action_loss"])), float(jnp.mean(ld_wo["action_loss"]))
46
+ tw, to = float(jnp.mean(ld_with["total_loss"])), float(jnp.mean(ld_wo["total_loss"]))
47
+ print(f"[3] action_loss WITH banks={aw:.5f} WITHOUT(zeroed)={ao:.5f} delta={ao-aw:+.5f} ({100*(ao-aw)/max(aw,1e-6):+.1f}%)", flush=True)
48
+ print(f"[3] total_loss WITH banks={tw:.5f} WITHOUT(zeroed)={to:.5f} delta={to-tw:+.5f}", flush=True)
49
+ # inspect how far the injection out_proj has grown off zero-init
50
+ flat = nnx.state(model, nnx.Param).flat_state()
51
+ import math
52
+ def gnorm(sub):
53
+ return math.sqrt(sum(float((np.asarray(v.value)**2).sum()) for k,v in flat.items() if sub in "/".join(str(p) for p in k)))
54
+ print(f"[4] injection out_proj L2={gnorm('out_proj'):.4f} merge_proj L2={gnorm('merge_proj'):.4f} "
55
+ f"(near 0 => injection stayed off = model ignoring spatial)", flush=True)
56
+ verdict = ("DA3 IGNORED (zero-init failure — switch to nonzero init)" if (ao-aw) < 0.005 else
57
+ "DA3 load-bearing" if (ao-aw) > 0.02 else "DA3 weakly contributing")
58
+ print(f"[VERDICT] {verdict}", flush=True)
59
+ print("CONTRIB CHECK DONE", flush=True)
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
pibehavior_da3_clean_up_your_desk_40k/source_code/training/b1k_2026.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BEHAVIOR-1K 2026 (LeRobot v3.0) data loading for the 2025 PiBehavior model.
2
+
3
+ The 2025 winner's loader targets OmniGibson's v2.1 `BehaviorLeRobotDataset`
4
+ (task-partitioned, 256-dim proprio). The 2026 challenge dataset is LeRobot
5
+ **v3.0** (chunk-based `data/chunk-XXX/file-XXX.parquet`, RGB in videos, 61-dim
6
+ proprio, 100 tasks). The pinned lerobot (v2.1) cannot read it, so this module
7
+ provides a self-contained v3 reader that yields items in the exact dict format
8
+ the existing transform pipeline expects, plus the two remaps needed:
9
+
10
+ * camera keys: zed_link -> head, left/right_realsense -> left/right_wrist
11
+ * task_index: 2026 index -> the 2025 index the checkpoint's task-embedding
12
+ table is keyed on (via activity name <-> task_data.json)
13
+
14
+ Videos are decoded with PyAV (torchcodec's ffmpeg libs are absent here).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import functools
20
+ import glob
21
+ import hashlib
22
+ import json
23
+ import logging
24
+ import os
25
+ import time
26
+ from collections import OrderedDict
27
+ from typing import Dict, List, Optional
28
+
29
+ import av
30
+ import einops
31
+ import numpy as np
32
+ import pandas as pd
33
+ import torch
34
+
35
+ from openpi import transforms
36
+ from openpi.models import model as _model
37
+
38
+ logger = logging.getLogger("b1k.2026")
39
+
40
+ # ---- 2026 R1Pro proprioception layout (61-dim), from BEHAVIOR-1K main
41
+ # OmniGibson/omnigibson/eval/utils/eval_utils.py::PROPRIOCEPTION_INDICES["R1Pro"]
42
+ PROP_2026 = {
43
+ "base_qvel": slice(0, 3),
44
+ "arm_left_qpos": slice(3, 10),
45
+ "gripper_left_qpos": slice(24, 26),
46
+ "arm_right_qpos": slice(28, 35),
47
+ "gripper_right_qpos": slice(49, 51),
48
+ "trunk_qpos": slice(53, 57),
49
+ }
50
+ MAX_GRIPPER_WIDTH = 0.1 # matches the 2025 gripper normalization
51
+
52
+
53
+ def extract_state_2026(proprio: np.ndarray) -> np.ndarray:
54
+ """61-dim 2026 proprio -> 23-dim model state, in the SAME field order the
55
+ 2025 `extract_state_from_proprio` produced (base_qvel, trunk, arm_left,
56
+ gripper_left, arm_right, gripper_right)."""
57
+ p = np.asarray(proprio, dtype=np.float32)
58
+ base_qvel = p[..., PROP_2026["base_qvel"]] # 3
59
+ trunk_qpos = p[..., PROP_2026["trunk_qpos"]] # 4
60
+ arm_left = p[..., PROP_2026["arm_left_qpos"]] # 7
61
+ arm_right = p[..., PROP_2026["arm_right_qpos"]] # 7
62
+ lg = p[..., PROP_2026["gripper_left_qpos"]].sum(-1, keepdims=True)
63
+ rg = p[..., PROP_2026["gripper_right_qpos"]].sum(-1, keepdims=True)
64
+ lg = 2.0 * (lg / MAX_GRIPPER_WIDTH) - 1.0 # -> [-1,1]
65
+ rg = 2.0 * (rg / MAX_GRIPPER_WIDTH) - 1.0
66
+ return np.concatenate([base_qvel, trunk_qpos, arm_left, lg, arm_right, rg], axis=-1)
67
+
68
+
69
+ @dataclasses.dataclass(frozen=True)
70
+ class B1kInputs2026(transforms.DataTransformFn):
71
+ """Same as b1k_policy.B1kInputs but with 2026 61-dim state extraction and
72
+ no OmniGibson dependency."""
73
+ model_type: object = _model.ModelType.PI0
74
+
75
+ def __call__(self, data: dict) -> dict:
76
+ state = extract_state_2026(data["observation/state"])
77
+
78
+ def _img(x):
79
+ x = np.asarray(x)
80
+ if np.issubdtype(x.dtype, np.floating):
81
+ x = (255 * x).astype(np.uint8)
82
+ if x.shape[0] == 3:
83
+ x = einops.rearrange(x, "c h w -> h w c")
84
+ return x
85
+
86
+ names = ("base_0_rgb", "left_wrist_0_rgb", "right_wrist_0_rgb")
87
+ imgs = (_img(data["observation/egocentric_camera"]),
88
+ _img(data["observation/wrist_image_left"]),
89
+ _img(data["observation/wrist_image_right"]))
90
+ out = {
91
+ "state": state,
92
+ "image": dict(zip(names, imgs, strict=True)),
93
+ "image_mask": {n: np.True_ for n in names},
94
+ }
95
+ for k in ("actions", "task_index", "timestamp", "episode_index",
96
+ "tokenized_prompt", "tokenized_prompt_mask", "subtask_state"):
97
+ if k in data:
98
+ out[k] = data[k]
99
+ return out
100
+
101
+
102
+ class _V3Meta:
103
+ """Minimal `dataset.meta` shim for ComputeSubtaskStateFromMeta.
104
+ `.episodes` maps episode_index -> {'length': int}."""
105
+ def __init__(self, episodes: Dict[int, dict]):
106
+ self.episodes = episodes
107
+
108
+
109
+ # --------------------------------------------------------------------------- #
110
+ # task-index remapping: 2026 index -> 2025 index (what the checkpoint knows)
111
+ # --------------------------------------------------------------------------- #
112
+ def build_task_index_maps(root_2026: str, task_data_json: str):
113
+ """Return (name->2025idx, 2026idx->2025idx, name->2026idx)."""
114
+ td = json.load(open(task_data_json))["tasks"]
115
+ name2025 = {t["id"]: i for i, t in enumerate(td)} # activity -> 2025 idx
116
+ dt = pd.read_parquet(os.path.join(root_2026, "meta", "tasks.parquet"))
117
+ # tasks.parquet: index = activity name, column task_index (2026)
118
+ name2026 = {name: int(row["task_index"]) for name, row in dt.iterrows()}
119
+ idx2026_to_2025 = {name2026[n]: name2025[n] for n in name2026 if n in name2025}
120
+ return name2025, idx2026_to_2025, name2026
121
+
122
+
123
+ class BehaviorV3Dataset(torch.utils.data.Dataset):
124
+ """LeRobot v3.0 reader for a subset of activities. Yields per-frame items in
125
+ the 2025-style LeRobot dict format (pre-repack keys), with task_index already
126
+ remapped to the 2025 index and RGB decoded to uint8 HWC."""
127
+
128
+ RGB_KEYS = OrderedDict([
129
+ ("observation.images.rgb.head", "observation.rgb.zed_link_camera_0"),
130
+ ("observation.images.rgb.left_wrist", "observation.rgb.left_realsense_link_camera_0"),
131
+ ("observation.images.rgb.right_wrist", "observation.rgb.right_realsense_link_camera_0"),
132
+ ])
133
+
134
+ def __init__(self, root: str, activities: List[str], action_horizon: int,
135
+ task_data_json: str, seed: int = 0, parquet_cache: int = 16):
136
+ self.root = root
137
+ self.H = int(action_horizon)
138
+ self.fps = float(json.load(open(os.path.join(root, "meta", "info.json")))["fps"])
139
+ _, self.idx2026_to_2025, self.name2026 = build_task_index_maps(root, task_data_json)
140
+ acts = set(activities)
141
+
142
+ # episode metadata (filtered to our activities)
143
+ ep = pd.concat([pd.read_parquet(f) for f in sorted(
144
+ glob.glob(os.path.join(root, "meta", "episodes", "**", "*.parquet"), recursive=True))],
145
+ ignore_index=True)
146
+ ep["task0"] = ep["tasks"].apply(lambda v: v[0] if hasattr(v, "__len__") and not isinstance(v, str) else v)
147
+ ep = ep[ep["task0"].isin(acts)].reset_index(drop=True)
148
+
149
+ self.episodes: List[dict] = []
150
+ meta_eps: Dict[int, dict] = {}
151
+ samples: List[tuple] = []
152
+ for _, r in ep.iterrows():
153
+ E = int(r["episode_index"]); L = int(r["length"])
154
+ rec = {
155
+ "episode_index": E, "length": L, "task0": r["task0"],
156
+ "data": os.path.join(root, "data", f"chunk-{int(r['data/chunk_index']):03d}",
157
+ f"file-{int(r['data/file_index']):03d}.parquet"),
158
+ "video": {}, "from_ts": {},
159
+ }
160
+ for dst, src in self.RGB_KEYS.items():
161
+ rec["video"][dst] = os.path.join(
162
+ root, "videos", src,
163
+ f"chunk-{int(r[f'videos/{src}/chunk_index']):03d}",
164
+ f"file-{int(r[f'videos/{src}/file_index']):03d}.mp4")
165
+ rec["from_ts"][dst] = float(r[f"videos/{src}/from_timestamp"])
166
+ ei = len(self.episodes)
167
+ self.episodes.append(rec)
168
+ meta_eps[E] = {"length": L}
169
+ # only frames with a full future action window
170
+ for t in range(max(1, L - self.H)):
171
+ samples.append((ei, t))
172
+ self.samples = samples
173
+ self.meta = _V3Meta(meta_eps)
174
+ self._pq_cache: "OrderedDict[str, pd.DataFrame]" = OrderedDict()
175
+ self._pq_cache_max = parquet_cache
176
+ self._video_cache: "OrderedDict[str, av.container.InputContainer]" = OrderedDict()
177
+ self._video_cache_max = int(os.environ.get("B1K_VIDEO_CACHE_SIZE", "12"))
178
+ self._decode_resize = int(os.environ.get("B1K_DECODE_RESIZE", "224"))
179
+ self._frame_cache_dir = os.environ.get("B1K_FRAME_CACHE_DIR")
180
+ self._frame_cache_max_bytes = int(float(os.environ.get("B1K_FRAME_CACHE_MAX_GB", "4")) * (1024 ** 3))
181
+ self._frame_cache_prune_every = max(1, int(os.environ.get("B1K_FRAME_CACHE_PRUNE_EVERY", "2048")))
182
+ self._frame_cache_checks = 0
183
+ self._frame_mem_cache: "OrderedDict[tuple[str, int], np.ndarray]" = OrderedDict()
184
+ self._frame_mem_cache_bytes = 0
185
+ self._frame_mem_cache_max_bytes = int(float(os.environ.get("B1K_FRAME_MEM_CACHE_GB", "0")) * (1024 ** 3))
186
+ self._frame_cache_touch_disk = os.environ.get("B1K_FRAME_CACHE_TOUCH", "0") == "1"
187
+ if self._frame_cache_dir:
188
+ os.makedirs(self._frame_cache_dir, exist_ok=True)
189
+ if os.environ.get("B1K_FRAME_CACHE_PRUNE_ON_INIT", "0") == "1":
190
+ self._prune_frame_cache(force=True)
191
+ logger.info("BehaviorV3Dataset: %d episodes, %d frame-samples, %d activities",
192
+ len(self.episodes), len(self.samples), len(acts))
193
+
194
+ def __getstate__(self):
195
+ state = self.__dict__.copy()
196
+ state["_pq_cache"] = OrderedDict()
197
+ state["_video_cache"] = OrderedDict()
198
+ state["_frame_mem_cache"] = OrderedDict()
199
+ state["_frame_mem_cache_bytes"] = 0
200
+ return state
201
+
202
+ def __del__(self):
203
+ for container in getattr(self, "_video_cache", {}).values():
204
+ try:
205
+ container.close()
206
+ except Exception:
207
+ pass
208
+
209
+ def __len__(self):
210
+ return len(self.samples)
211
+
212
+ def _episode_frames(self, rec) -> pd.DataFrame:
213
+ """Cached per-episode frame table (state, action, timestamp), sorted."""
214
+ key = rec["data"]
215
+ if key not in self._pq_cache:
216
+ df = pd.read_parquet(key, columns=["episode_index", "frame_index",
217
+ "observation.state", "action",
218
+ "timestamp", "task_index"])
219
+ self._pq_cache[key] = df
220
+ if len(self._pq_cache) > self._pq_cache_max:
221
+ self._pq_cache.popitem(last=False)
222
+ df = self._pq_cache[key]
223
+ sub = df[df["episode_index"] == rec["episode_index"]].sort_values("frame_index")
224
+ return sub
225
+
226
+ def _cached_container(self, path: str):
227
+ container = self._video_cache.get(path)
228
+ if container is not None:
229
+ self._video_cache.move_to_end(path)
230
+ return container
231
+
232
+ container = av.open(path)
233
+ # Cap ffmpeg decode threads per stream. HEVC's default thread_type=AUTO spawns up to ncores
234
+ # (240 here) threads PER container; with many workers x 6 containers/sample this blows past the
235
+ # kernel/cgroup thread ceiling ("can't start new thread"). A small fixed count is plenty since
236
+ # parallelism comes from the dataloader workers, not per-decode threads.
237
+ _dt = int(os.environ.get("B1K_DECODE_THREADS", "1"))
238
+ try:
239
+ vs0 = container.streams.video[0]
240
+ vs0.thread_count = _dt
241
+ vs0.thread_type = "NONE" if _dt <= 1 else "FRAME"
242
+ except Exception:
243
+ pass
244
+ self._video_cache[path] = container
245
+ if len(self._video_cache) > self._video_cache_max:
246
+ _, old = self._video_cache.popitem(last=False)
247
+ old.close()
248
+ return container
249
+
250
+ def _frame_cache_path(self, path: str, frame_idx: int) -> str | None:
251
+ if not self._frame_cache_dir:
252
+ return None
253
+ key = hashlib.blake2b(f"{path}|{frame_idx}|{self._decode_resize}".encode(), digest_size=16).hexdigest()
254
+ return os.path.join(self._frame_cache_dir, f"{key}.npy")
255
+
256
+ def _get_frame_mem_cache(self, key: tuple[str, int]) -> np.ndarray | None:
257
+ if self._frame_mem_cache_max_bytes <= 0:
258
+ return None
259
+ img = self._frame_mem_cache.get(key)
260
+ if img is None:
261
+ return None
262
+ self._frame_mem_cache.move_to_end(key)
263
+ return img
264
+
265
+ def _put_frame_mem_cache(self, key: tuple[str, int], img: np.ndarray) -> None:
266
+ if self._frame_mem_cache_max_bytes <= 0:
267
+ return
268
+ old = self._frame_mem_cache.pop(key, None)
269
+ if old is not None:
270
+ self._frame_mem_cache_bytes -= old.nbytes
271
+ self._frame_mem_cache[key] = img
272
+ self._frame_mem_cache_bytes += img.nbytes
273
+ while self._frame_mem_cache_bytes > self._frame_mem_cache_max_bytes and self._frame_mem_cache:
274
+ _, evicted = self._frame_mem_cache.popitem(last=False)
275
+ self._frame_mem_cache_bytes -= evicted.nbytes
276
+
277
+ def _prune_frame_cache(self, *, force: bool = False):
278
+ if not self._frame_cache_dir or self._frame_cache_max_bytes <= 0:
279
+ return
280
+ self._frame_cache_checks += 1
281
+ if not force and self._frame_cache_checks % self._frame_cache_prune_every:
282
+ return
283
+ files = []
284
+ total = 0
285
+ for p in glob.glob(os.path.join(self._frame_cache_dir, "*.npy")):
286
+ try:
287
+ st = os.stat(p)
288
+ except FileNotFoundError:
289
+ continue
290
+ total += st.st_size
291
+ files.append((st.st_mtime, st.st_size, p))
292
+ if total <= self._frame_cache_max_bytes:
293
+ return
294
+ for _, size, p in sorted(files):
295
+ try:
296
+ os.remove(p)
297
+ total -= size
298
+ except FileNotFoundError:
299
+ pass
300
+ if total <= int(self._frame_cache_max_bytes * 0.85):
301
+ break
302
+
303
+ def _decode_rgb(self, path: str, ts: float) -> np.ndarray:
304
+ frame_idx = int(round(ts * self.fps))
305
+ mem_key = (path, frame_idx)
306
+ cached_img = self._get_frame_mem_cache(mem_key)
307
+ if cached_img is not None:
308
+ return cached_img
309
+ cache_path = self._frame_cache_path(path, frame_idx)
310
+ if cache_path and os.path.exists(cache_path):
311
+ try:
312
+ if self._frame_cache_touch_disk:
313
+ os.utime(cache_path, None)
314
+ img = np.load(cache_path)
315
+ self._put_frame_mem_cache(mem_key, img)
316
+ return img
317
+ except Exception:
318
+ try:
319
+ os.remove(cache_path)
320
+ except FileNotFoundError:
321
+ pass
322
+
323
+ container = self._cached_container(path)
324
+ vs = container.streams.video[0]
325
+ container.seek(int(max(0.0, ts) / vs.time_base), stream=vs, backward=True)
326
+ frame = None
327
+ for fr in container.decode(vs):
328
+ if fr.time is not None and fr.time >= ts - 1e-3:
329
+ frame = fr
330
+ break
331
+ if frame is None: # ts past end — take last decoded
332
+ container.seek(int(max(0.0, ts) / vs.time_base), stream=vs, backward=True)
333
+ for fr in container.decode(vs):
334
+ frame = fr
335
+ if self._decode_resize > 0:
336
+ frame = frame.reformat(width=self._decode_resize, height=self._decode_resize, format="rgb24")
337
+ img = frame.to_ndarray(format="rgb24")
338
+ self._put_frame_mem_cache(mem_key, img)
339
+ if cache_path:
340
+ self._prune_frame_cache()
341
+ tmp = f"{cache_path}.{os.getpid()}.{time.time_ns()}.tmp"
342
+ try:
343
+ with open(tmp, "wb") as f:
344
+ np.save(f, img)
345
+ os.replace(tmp, cache_path)
346
+ except Exception:
347
+ try:
348
+ os.remove(tmp)
349
+ except FileNotFoundError:
350
+ pass
351
+ return img # HWC uint8
352
+
353
+ def __getitem__(self, i):
354
+ ei, t = self.samples[i]
355
+ rec = self.episodes[ei]
356
+ sub = self._episode_frames(rec)
357
+ states = np.stack(sub["observation.state"].to_numpy()) # [L,61]
358
+ actions = np.stack(sub["action"].to_numpy()) # [L,23]
359
+ ts = float(sub["timestamp"].iloc[t])
360
+ act_win = actions[t:t + self.H] # [H,23]
361
+ item = {
362
+ "observation.state": states[t].astype(np.float32), # raw 61-dim
363
+ "action": act_win.astype(np.float32),
364
+ "task_index": np.int64(self.idx2026_to_2025[int(sub["task_index"].iloc[t])]),
365
+ "timestamp": np.float32(ts),
366
+ "episode_index": np.int64(rec["episode_index"]),
367
+ "index": np.int64(i),
368
+ }
369
+ for dst in self.RGB_KEYS:
370
+ frame_ts = rec["from_ts"][dst] + t / self.fps
371
+ item[dst] = self._decode_rgb(rec["video"][dst], frame_ts) # HWC uint8
372
+ return item
373
+
374
+
375
+ def create_v3_behavior_data_loader(config, root_2026: str, activities: List[str],
376
+ task_data_json: str, *, sharding=None,
377
+ shuffle: bool = True, num_workers: Optional[int] = None,
378
+ seed: int = 0):
379
+ """Build a training data loader over the 2026 v3 subset, reusing the 2025
380
+ transform pipeline but with 2026 state extraction (B1kInputs2026)."""
381
+ import jax
382
+ import dataclasses as _dc
383
+ from b1k.policies import b1k_policy
384
+ from b1k.training.data_loader import transform_dataset, DataLoaderImpl
385
+ from openpi.training.data_loader import TorchDataLoader
386
+
387
+ data_config = config.data.create(config.assets_dirs, config.model)
388
+ # Swap the OmniGibson-dependent B1kInputs for the 2026 61-dim variant.
389
+ new_inputs = tuple(
390
+ B1kInputs2026(model_type=config.model.model_type)
391
+ if isinstance(x, b1k_policy.B1kInputs) else x
392
+ for x in data_config.data_transforms.inputs
393
+ )
394
+ data_config = _dc.replace(
395
+ data_config,
396
+ data_transforms=_dc.replace(data_config.data_transforms, inputs=new_inputs),
397
+ )
398
+
399
+ ds = BehaviorV3Dataset(root_2026, activities=activities,
400
+ action_horizon=config.model.action_horizon,
401
+ task_data_json=task_data_json, seed=seed)
402
+ ds = transform_dataset(ds, data_config) # adds dataset-aware subtask + per-ts norm
403
+ loader = TorchDataLoader(
404
+ ds,
405
+ local_batch_size=config.batch_size // jax.process_count(),
406
+ sharding=sharding, shuffle=shuffle,
407
+ num_workers=config.num_workers if num_workers is None else num_workers,
408
+ seed=seed,
409
+ )
410
+ return DataLoaderImpl(data_config, loader)
411
+
412
+
413
+ __all__ = ["BehaviorV3Dataset", "B1kInputs2026", "extract_state_2026",
414
+ "build_task_index_maps", "PROP_2026", "create_v3_behavior_data_loader"]
pibehavior_da3_clean_up_your_desk_40k/source_code/training/b1k_da3.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DA3 spatial inputs for the 2026 v3 pipeline.
2
+
3
+ Extends BehaviorV3Dataset with per-frame DA3 inputs (3 cams at DA3 resolution + robot->cam
4
+ OpenCV extrinsics + intrinsics + ModernBERT task-language), and provides a data-loader factory
5
+ that runs the frozen DA3-GIANT extractor once per BATCH (GPU) via the loader's batch hook.
6
+
7
+ Geometry (empirically calibrated against GT depth, see /work/jack/behavior1k/calib):
8
+ * robot2cam_pose[7] = [pos(3), quat_wxyz(4)] = the CAMERA POSE IN THE ROBOT FRAME,
9
+ already OpenCV-convention (+Z optical axis). robot->cam = inv(pose_matrix).
10
+ * intrinsics: fx = fy = W * 17.0/20.995 (OmniGibson VisionSensor defaults), cx=cy=W/2.
11
+ """
12
+ import logging
13
+ import os
14
+ import pickle
15
+
16
+ import numpy as np
17
+
18
+ from b1k.training.b1k_2026 import BehaviorV3Dataset, B1kInputs2026
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ FOCAL_RATIO = 17.0 / 20.995 # OmniGibson VisionSensor default focal/aperture
23
+
24
+ # dst rgb key -> pose parquet column (same camera)
25
+ POSE_COLS = {
26
+ "observation.images.rgb.head": "observation.robot2cam_pose.zed_link_camera_0",
27
+ "observation.images.rgb.left_wrist": "observation.robot2cam_pose.left_realsense_link_camera_0",
28
+ "observation.images.rgb.right_wrist": "observation.robot2cam_pose.right_realsense_link_camera_0",
29
+ }
30
+ # view order MUST match the bank builder: 0=main(head), 1=left, 2=right
31
+ VIEW_ORDER = (
32
+ "observation.images.rgb.head",
33
+ "observation.images.rgb.left_wrist",
34
+ "observation.images.rgb.right_wrist",
35
+ )
36
+
37
+
38
+ def quat_wxyz_to_R(q):
39
+ w, x, y, z = q / (np.linalg.norm(q) + 1e-12)
40
+ return np.array([
41
+ [1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
42
+ [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
43
+ [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)],
44
+ ])
45
+
46
+
47
+ # OmniGibson cameras use the OpenGL optical convention (-Z forward, +Y up); the DA3 pinhole
48
+ # projection assumes OpenCV (+Z forward, +Y down). This diag(1,-1,-1) flips the camera Y/Z axes.
49
+ # WITHOUT it, cross-view GT-depth reprojection is 0.00 (cameras point the wrong way); WITH it, 0.20+
50
+ # (best of all 8 conventions), and the head camera lands at its true +1.56 m height. (calib/calibrate_v3.py)
51
+ _GL2CV = np.diag([1.0, -1.0, -1.0, 1.0]).astype(np.float32)
52
+
53
+
54
+ def pose7_to_robot2cam(p7: np.ndarray) -> np.ndarray:
55
+ """[pos3, quat_wxyz] camera-pose-in-robot-frame -> 4x4 robot->cam (OpenCV).
56
+
57
+ Convention (validated in calib/): quat=wxyz, pose is the CAMERA-IN-ROBOT transform so
58
+ robot->cam = inv(T), then GL->CV optical flip.
59
+ """
60
+ T = np.eye(4, dtype=np.float32)
61
+ T[:3, :3] = quat_wxyz_to_R(np.asarray(p7[3:], np.float64))
62
+ T[:3, 3] = p7[:3]
63
+ return (_GL2CV @ np.linalg.inv(T)).astype(np.float32)
64
+
65
+
66
+ class BehaviorV3DA3Dataset(BehaviorV3Dataset):
67
+ """BehaviorV3Dataset + DA3 inputs (frames @ da3_hw, extrinsics, intrinsics, task language)."""
68
+
69
+ def __init__(self, *args, da3_hw=(252, 252), lang_cache: str | None = None, lang_max_len: int = 32, **kwargs):
70
+ super().__init__(*args, **kwargs)
71
+ self._da3_hw = tuple(da3_hw)
72
+ self._lang_max_len = int(lang_max_len)
73
+ self._lang = None
74
+ if lang_cache:
75
+ with open(lang_cache, "rb") as f:
76
+ self._lang = pickle.load(f)
77
+ logger.info("DA3 lang cache: %d tasks from %s", len(self._lang), lang_cache)
78
+
79
+ def _episode_poses(self, rec):
80
+ """Cached per-episode pose table (the base reader's parquet cache omits pose columns)."""
81
+ import pandas as pd
82
+ if not hasattr(self, "_pose_cache"):
83
+ from collections import OrderedDict
84
+ self._pose_cache = OrderedDict()
85
+ key = rec["data"]
86
+ if key not in self._pose_cache:
87
+ df = pd.read_parquet(key, columns=["episode_index", "frame_index", *POSE_COLS.values()])
88
+ self._pose_cache[key] = df
89
+ if len(self._pose_cache) > 8:
90
+ self._pose_cache.popitem(last=False)
91
+ df = self._pose_cache[key]
92
+ return df[df["episode_index"] == rec["episode_index"]].sort_values("frame_index")
93
+
94
+ def _decode_native(self, path: str, ts: float):
95
+ """Decode ONE frame at NATIVE resolution, cached by (path, frame_idx). Returns (HWC uint8, native_w).
96
+ Shared by the base (224) and DA3 (252) decode so each frame is decoded ONCE, not twice.
97
+ Enabled only when B1K_SHARED_DECODE=1 (default off => original two-decode behavior)."""
98
+ if not hasattr(self, "_native_cache"):
99
+ from collections import OrderedDict
100
+ self._native_cache = OrderedDict()
101
+ fidx = int(round(ts * self.fps))
102
+ key = (path, fidx)
103
+ hit = self._native_cache.get(key)
104
+ if hit is not None:
105
+ self._native_cache.move_to_end(key)
106
+ return hit
107
+ container = self._cached_container(path)
108
+ vs = container.streams.video[0]
109
+ container.seek(int(max(0.0, ts) / vs.time_base), stream=vs, backward=True)
110
+ frame = None
111
+ for fr in container.decode(vs):
112
+ if fr.time is not None and fr.time >= ts - 1e-3:
113
+ frame = fr
114
+ break
115
+ if frame is None:
116
+ container.seek(int(max(0.0, ts) / vs.time_base), stream=vs, backward=True)
117
+ for fr in container.decode(vs):
118
+ frame = fr
119
+ out = (frame.to_ndarray(format="rgb24"), int(frame.width)) # native HWC uint8, no resize
120
+ self._native_cache[key] = out
121
+ if len(self._native_cache) > 12: # a few cams x a couple frames in flight
122
+ self._native_cache.popitem(last=False)
123
+ return out
124
+
125
+ def _decode_rgb(self, path: str, ts: float) -> np.ndarray:
126
+ """Base VLM (224) frame. With shared-decode, resize from the single native decode (no 2nd decode)."""
127
+ if os.environ.get("B1K_SHARED_DECODE") != "1":
128
+ return super()._decode_rgb(path, ts)
129
+ import cv2
130
+ native, _ = self._decode_native(path, ts)
131
+ r = self._decode_resize
132
+ return cv2.resize(native, (r, r), interpolation=cv2.INTER_AREA) if r > 0 else native
133
+
134
+ def _decode_da3(self, path: str, ts: float) -> np.ndarray:
135
+ """Decode one frame at DA3 resolution (252). HWC uint8 + native width (for intrinsics)."""
136
+ if os.environ.get("B1K_SHARED_DECODE") == "1":
137
+ import cv2
138
+ native, native_w = self._decode_native(path, ts) # reuses the base decode (no 2nd decode)
139
+ h, w = self._da3_hw
140
+ return cv2.resize(native, (w, h), interpolation=cv2.INTER_AREA), native_w
141
+ container = self._cached_container(path)
142
+ vs = container.streams.video[0]
143
+ container.seek(int(max(0.0, ts) / vs.time_base), stream=vs, backward=True)
144
+ frame = None
145
+ for fr in container.decode(vs):
146
+ if fr.time is not None and fr.time >= ts - 1e-3:
147
+ frame = fr
148
+ break
149
+ if frame is None:
150
+ container.seek(int(max(0.0, ts) / vs.time_base), stream=vs, backward=True)
151
+ for fr in container.decode(vs):
152
+ frame = fr
153
+ h, w = self._da3_hw
154
+ native_w = frame.width
155
+ frame = frame.reformat(width=w, height=h, format="rgb24")
156
+ img = frame.to_ndarray(format="rgb24")
157
+ return img, native_w
158
+
159
+ def _lang_entry(self, task_name: str):
160
+ if self._lang is None:
161
+ L = self._lang_max_len
162
+ return np.zeros((L, 1024), np.float32), np.zeros((L,), bool)
163
+ feat, mask = self._lang[task_name]
164
+ return np.asarray(feat, np.float32), np.asarray(mask, bool)
165
+
166
+ def da3_fields(self, i):
167
+ """Compute ONLY the DA3 input fields for sample i (attached AFTER the transform stack,
168
+ which constructs fresh dicts and would drop unknown keys)."""
169
+ item = {}
170
+ ei, t = self.samples[i]
171
+ rec = self.episodes[ei]
172
+ sub = self._episode_poses(rec)
173
+
174
+ h, w = self._da3_hw
175
+ imgs, extr, intr = [], [], []
176
+ for dst in VIEW_ORDER:
177
+ frame_ts = rec["from_ts"][dst] + t / self.fps
178
+ img, native_w = self._decode_da3(rec["video"][dst], frame_ts)
179
+ imgs.append(img)
180
+ p7 = np.asarray(sub[POSE_COLS[dst]].iloc[t], np.float64)
181
+ extr.append(pose7_to_robot2cam(p7))
182
+ f_native = FOCAL_RATIO * native_w
183
+ # native (square) -> da3_hw rescale: fx,cx scale by w/native_w; fy,cy by h/native_h(=native_w)
184
+ K = np.array([
185
+ [f_native * w / native_w, 0, (native_w / 2) * w / native_w],
186
+ [0, f_native * h / native_w, (native_w / 2) * h / native_w],
187
+ [0, 0, 1],
188
+ ], np.float32)
189
+ intr.append(K)
190
+
191
+ item["da3_images"] = np.stack(imgs, 0) # [V,252,252,3] uint8
192
+ item["camera_extrinsics"] = np.stack(extr, 0) # [V,4,4] robot->cam OpenCV
193
+ item["camera_intrinsics"] = np.stack(intr, 0) # [V,3,3] @ da3_hw
194
+ lf, lm = self._lang_entry(rec["task0"])
195
+ item["lang_feat"] = lf
196
+ item["lang_mask"] = lm
197
+ return item
198
+
199
+
200
+ class _AttachDA3Fields:
201
+ """Wraps the TRANSFORMED dataset; merges the raw dataset's DA3 fields into each sample."""
202
+
203
+ def __init__(self, transformed, raw: BehaviorV3DA3Dataset):
204
+ self._transformed = transformed
205
+ self._raw = raw
206
+
207
+ def __len__(self):
208
+ return len(self._transformed)
209
+
210
+ def __getitem__(self, i):
211
+ out = dict(self._transformed[i])
212
+ out.update(self._raw.da3_fields(i))
213
+ return out
214
+
215
+
216
+ def create_v3_behavior_da3_loader(config, root_2026, activities, task_data_json, *,
217
+ lang_cache, sharding=None, shuffle=True,
218
+ num_workers=None, seed=0, da3_hw=(252, 252)):
219
+ """v3 loader with DA3 inputs + a per-batch frozen DA3-GIANT extraction hook (GPU)."""
220
+ import jax
221
+ import dataclasses as _dc
222
+ from b1k.policies import b1k_policy
223
+ from b1k.training.data_loader import transform_dataset, DataLoaderImpl
224
+ from openpi.training.data_loader import TorchDataLoader
225
+ from b1k.training import da3_extractor as _ex
226
+
227
+ data_config = config.data.create(config.assets_dirs, config.model)
228
+ new_inputs = tuple(
229
+ B1kInputs2026(model_type=config.model.model_type)
230
+ if isinstance(x, b1k_policy.B1kInputs) else x
231
+ for x in data_config.data_transforms.inputs
232
+ )
233
+ data_config = _dc.replace(
234
+ data_config, data_transforms=_dc.replace(data_config.data_transforms, inputs=new_inputs))
235
+
236
+ ds = BehaviorV3DA3Dataset(
237
+ root_2026, activities=activities, action_horizon=config.model.action_horizon,
238
+ task_data_json=task_data_json, seed=seed,
239
+ da3_hw=da3_hw, lang_cache=lang_cache,
240
+ lang_max_len=config.model.da3.lang_max_len,
241
+ )
242
+ tds = transform_dataset(ds, data_config)
243
+ tds = _AttachDA3Fields(tds, ds)
244
+
245
+ logger.info("Building inline DA3-GIANT extractor (da3_hw=%s) ...", da3_hw)
246
+ # Extraction devices: default single-GPU (cuda:0). Set B1K_EXTRACT_DEVICES to spread the frozen
247
+ # DA3-GIANT forward across GPUs (one replica per device, batch split, run concurrently) so the
248
+ # ~2.7s single-GPU extraction shrinks and better overlaps the JAX train step.
249
+ _dev_env = os.environ.get("B1K_EXTRACT_DEVICES", "").strip()
250
+ _devices = [d.strip() for d in _dev_env.split(",") if d.strip()] or None
251
+ _fchunk = int(os.environ.get("B1K_DA3_FWD_CHUNK", "16"))
252
+ logger.info("DA3 extractor: devices=%s forward_chunk=%d", _devices or ["cuda:0"], _fchunk)
253
+ extractor = _ex.DA3InlineExtractor(da3_hw=da3_hw, forward_chunk=_fchunk, devices=_devices)
254
+
255
+ # DLPack GPU->GPU handoff: skip the ~2.6s/batch host round-trip by moving extractor features
256
+ # straight from the extraction GPUs to the training GPUs over NVLink. Requires CUDA extraction
257
+ # devices whose count matches the training mesh size (contiguous batch split aligns 1:1).
258
+ # Holds the last few batches' torch source shards alive so the async NVLink copies (device_put)
259
+ # can never read freed memory — replaces a blocking block_until_ready that serialized the producer.
260
+ import collections as _collections
261
+ _keepalive = _collections.deque(maxlen=4)
262
+
263
+ def _dlpack_ok():
264
+ try:
265
+ m = getattr(sharding, "mesh", None)
266
+ return (os.environ.get("B1K_DLPACK") == "1" and m is not None
267
+ and len(list(m.devices.flat)) == len(extractor.devices)
268
+ and all(str(d).startswith("cuda") for d in extractor.devices))
269
+ except Exception:
270
+ return False
271
+
272
+ def batch_transform(batch):
273
+ if _dlpack_ok():
274
+ import jax
275
+ parts = extractor.extract_shards_torch(
276
+ batch["da3_images"], batch["camera_extrinsics"], batch["camera_intrinsics"])
277
+ tdevs = list(sharding.mesh.devices.flat) # training devices, batch-chunk k -> tdevs[k]
278
+
279
+ def _asm(fi): # assemble per-shard torch tensors (field fi) into one sharded jax array
280
+ js = [jax.device_put(jax.dlpack.from_dlpack(parts[k][fi]), tdevs[k]) for k in range(len(parts))]
281
+ gshape = (sum(int(s.shape[0]) for s in js),) + tuple(int(d) for d in js[0].shape[1:])
282
+ return jax.make_array_from_single_device_arrays(gshape, sharding, js)
283
+
284
+ fa, ra, da = _asm(0), _asm(1), _asm(2)
285
+ # Do NOT block here: the device_put queues behind the in-flight train step on the target
286
+ # GPUs, so blocking would serialize the producer with training (killing the overlap).
287
+ # Instead keep the torch source shards referenced for a few batches so the async NVLink
288
+ # copy can't read freed memory.
289
+ _keepalive.append(parts)
290
+ batch["da3_features"], batch["da3_ray"], batch["da3_depth"] = fa, ra, da
291
+ else:
292
+ feats, ray, depth = extractor.extract(
293
+ batch["da3_images"], batch["camera_extrinsics"], batch["camera_intrinsics"])
294
+ batch["da3_features"] = feats # uint16 bf16-bits
295
+ batch["da3_ray"] = ray
296
+ batch["da3_depth"] = depth
297
+ batch.pop("da3_images", None)
298
+ batch.pop("camera_intrinsics", None)
299
+ return batch
300
+
301
+ loader = TorchDataLoader(
302
+ tds, local_batch_size=config.batch_size // jax.process_count(),
303
+ sharding=sharding, shuffle=shuffle,
304
+ num_workers=config.num_workers if num_workers is None else num_workers,
305
+ seed=seed, batch_transform=batch_transform,
306
+ )
307
+ return DataLoaderImpl(data_config, loader)
pibehavior_da3_clean_up_your_desk_40k/source_code/training/da3_extractor.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inline DA3-GIANT feature extractor (PyTorch, runs in the openpi venv).
2
+
3
+ Produces the SAME feats/ray/depth as the offline cache, but on-the-fly at train time — so
4
+ no precached features are needed (essential for datasets where caching is infeasible, e.g.
5
+ b1k at 30 Hz would be ~3 PB). Frozen, no_grad; the output tensors are handed to the JAX
6
+ model via dlpack.
7
+
8
+ depth_anything_3.api transitively imports rendering/SfM utils (moviepy/gsplat/pycolmap/
9
+ trimesh/evo) that (a) aren't needed for feature extraction and (b) pin numpy<2 (conflicts
10
+ with openpi's numpy 2.x). We stub those modules so nothing gets installed/downgraded.
11
+ """
12
+
13
+ import concurrent.futures
14
+ import contextlib
15
+ import importlib.util
16
+ import sys
17
+ import types
18
+
19
+ import numpy as np
20
+ import torch
21
+
22
+ # The frozen extractor is pure GPU work; torch's CPU thread pools only add GIL/scheduler contention
23
+ # with the JAX train loop in the same process (~1000 threads observed). Pin them to 1.
24
+ try:
25
+ torch.set_num_threads(1)
26
+ torch.set_num_interop_threads(1) # only settable before any parallel work; ignore if already set
27
+ except Exception: # noqa: BLE001
28
+ pass
29
+
30
+
31
+ def _to_dev(a, device, dtype=None):
32
+ """Host array/tensor -> device tensor. A torch tensor (pinned by the DataLoader when
33
+ B1K_TORCH_COLLATE=1) is copied with non_blocking=True so the H2D is an async DMA that overlaps
34
+ compute, instead of a synchronous pageable copy. Numpy input keeps the original behavior."""
35
+ if isinstance(a, torch.Tensor):
36
+ t = a.to(device, non_blocking=True)
37
+ return t.to(dtype) if dtype is not None and t.dtype != dtype else t
38
+ return torch.as_tensor(a, device=device, dtype=dtype)
39
+
40
+ _DA3_SRC = "/work/jack/projects/Depth-Anything-3/src"
41
+ _GEOSTACK = "/work/jack/da3xvla_src/DA3-XVLA-cache/models/da3_for_geostack.py"
42
+ _IMAGENET_MEAN = torch.tensor([0.485, 0.456, 0.406]).view(1, 1, 3, 1, 1)
43
+ _IMAGENET_STD = torch.tensor([0.229, 0.224, 0.225]).view(1, 1, 3, 1, 1)
44
+
45
+
46
+ class _AutoStub(types.ModuleType):
47
+ """Module stub that returns a dummy callable for any non-dunder attribute + acts as a package."""
48
+
49
+ __path__: list = []
50
+
51
+ def __getattr__(self, name):
52
+ if name.startswith("__"):
53
+ raise AttributeError(name)
54
+ return lambda *a, **k: None
55
+
56
+
57
+ def _install_stubs():
58
+ for name in ("moviepy", "moviepy.editor", "gsplat", "pycolmap", "trimesh",
59
+ "depth_anything_3.utils.export", "depth_anything_3.utils.pose_align"):
60
+ sys.modules.setdefault(name, _AutoStub(name))
61
+ if hasattr(sys.modules["moviepy"], "__dict__"):
62
+ sys.modules["moviepy"].editor = sys.modules["moviepy.editor"]
63
+ if _DA3_SRC not in sys.path:
64
+ sys.path.insert(0, _DA3_SRC)
65
+
66
+
67
+ def _load_da3_class():
68
+ _install_stubs()
69
+ spec = importlib.util.spec_from_file_location("_da3_for_geostack", _GEOSTACK)
70
+ mod = importlib.util.module_from_spec(spec)
71
+ spec.loader.exec_module(mod)
72
+ return mod.DA3LargeForGeoStack
73
+
74
+
75
+ def rescale_intrinsics(intr: np.ndarray, src_hw, dst_hw) -> np.ndarray:
76
+ """Rescale pixel K [.,3,3] from src (H,W) to dst (H,W). fx,cx by W-ratio; fy,cy by H-ratio."""
77
+ sh, sw = src_hw
78
+ dh, dw = dst_hw
79
+ rw, rh = dw / sw, dh / sh
80
+ out = np.array(intr, dtype=np.float32, copy=True)
81
+ out[..., 0, 0] *= rw
82
+ out[..., 0, 2] *= rw
83
+ out[..., 1, 1] *= rh
84
+ out[..., 1, 2] *= rh
85
+ return out
86
+
87
+
88
+ class DA3InlineExtractor:
89
+ """Frozen DA3-GIANT posed multi-view extractor, REPLICATED one-per-GPU.
90
+
91
+ Each visible GPU holds its own frozen DA3-GIANT copy and runs the forward on ONLY its slice of the
92
+ batch, all GPUs concurrently (one thread per device; CUDA kernels are async per device, and torch
93
+ releases the GIL during them). This removes the single-GPU serial bottleneck of the old design, so
94
+ inline extraction scales with GPU count to match the JAX data-parallel training step.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ model_name: str = "depth-anything/DA3NESTED-GIANT-LARGE-1.1",
100
+ out_layers=(19, 26, 33, 39),
101
+ da3_hw=(252, 336),
102
+ devices=None,
103
+ forward_chunk: int = 16,
104
+ ):
105
+ DA3 = _load_da3_class()
106
+ if devices is None:
107
+ # Default SINGLE-GPU (cuda:0): the multi-replica path is correct but thread-based, and the
108
+ # Python GIL serializes the DA3 forward's many kernel launches (~18% concurrency efficiency),
109
+ # so replicating across GPUs doesn't speed it up — it only wastes memory. Pass `devices`
110
+ # explicitly (e.g. for a future multiprocess extractor) to override.
111
+ devices = ["cuda:0" if torch.cuda.is_available() else "cpu"]
112
+ self.devices = list(devices)
113
+ self.da3_hw = tuple(da3_hw)
114
+ self.forward_chunk = int(forward_chunk)
115
+ self.replicas = []
116
+ for dev in self.devices:
117
+ m = (
118
+ DA3(model_name=model_name, out_layers=tuple(out_layers),
119
+ da3_input_h=da3_hw[0], da3_input_w=da3_hw[1], patch_size=14, use_bf16=True)
120
+ .to(dev)
121
+ .eval()
122
+ )
123
+ for p in m.parameters():
124
+ p.requires_grad_(False)
125
+ self.replicas.append(m)
126
+ self._pool = concurrent.futures.ThreadPoolExecutor(max_workers=max(1, len(self.devices)))
127
+
128
+ def _preprocess(self, images: np.ndarray, device) -> torch.Tensor:
129
+ """images: [B,V,H,W,3] uint8 (or float [0,1]) -> [B,V,3,252,336] ImageNet-normalized on `device`."""
130
+ x = _to_dev(images, device)
131
+ if x.dtype == torch.uint8:
132
+ x = x.float() / 255.0
133
+ elif x.max() > 1.5: # already float but in [0,255]
134
+ x = x.float() / 255.0
135
+ x = x.permute(0, 1, 4, 2, 3) # [B,V,3,H,W]
136
+ x = torch.nn.functional.interpolate(
137
+ x.flatten(0, 1), size=self.da3_hw, mode="bicubic", align_corners=False, antialias=True
138
+ ).view(*x.shape[:2], 3, *self.da3_hw)
139
+ x = (x - _IMAGENET_MEAN.to(x)) / _IMAGENET_STD.to(x)
140
+ return x
141
+
142
+ def _run_shard(self, di: int, images: np.ndarray, extrinsics: np.ndarray, intrinsics: np.ndarray,
143
+ return_torch: bool = False):
144
+ """Run replica `di` over its (pre-sliced) shard, in chunks to bound activation memory.
145
+ return_torch=True keeps the result as torch tensors ON cuda:di (no host round-trip) for the
146
+ DLPack handoff; default returns numpy (host) as before."""
147
+ dev = self.devices[di]
148
+ dev_idx = int(dev.split(":")[1]) if ":" in dev else None
149
+ replica = self.replicas[di]
150
+ chunk = self.forward_chunk
151
+ fc, rc, dc = [], [], []
152
+ ctx = torch.cuda.device(dev_idx) if dev_idx is not None else contextlib.nullcontext()
153
+ with ctx, torch.no_grad(): # set current device so implicit-device tensors land on the right GPU
154
+ for i in range(0, images.shape[0], chunk):
155
+ x = self._preprocess(images[i : i + chunk], dev)
156
+ e = _to_dev(extrinsics[i : i + chunk], dev, torch.float32)
157
+ k = _to_dev(intrinsics[i : i + chunk], dev, torch.float32)
158
+ out = replica.forward_multi_view(x, extrinsics=e, intrinsics=k)
159
+ # Ship feats as bf16 BITS (uint16): the model casts to bf16 anyway (see
160
+ # Pi0._compute_banks), so this is numerically identical to shipping f32 while
161
+ # halving the GPU->CPU->GPU transfer and the collate copies.
162
+ feats = torch.stack(list(out["feats"]), dim=1).to(torch.bfloat16) # [b,4,V,C,h,w]
163
+ ray = out["ray"].float() # [b,V,3,h,w]
164
+ depth = out["depth"]
165
+ if depth is None:
166
+ depth = torch.zeros(ray.shape[0], ray.shape[1], 1, ray.shape[3], ray.shape[4], device=ray.device)
167
+ if return_torch:
168
+ fc.append(feats.view(torch.uint16)); rc.append(ray); dc.append(depth.float())
169
+ else:
170
+ fc.append(feats.view(torch.uint16).cpu().numpy())
171
+ rc.append(ray.cpu().numpy())
172
+ dc.append(depth.float().cpu().numpy())
173
+ if return_torch:
174
+ return torch.cat(fc, 0), torch.cat(rc, 0), torch.cat(dc, 0) # torch tensors on cuda:di
175
+ return np.concatenate(fc, 0), np.concatenate(rc, 0), np.concatenate(dc, 0)
176
+
177
+ def extract_shards_torch(self, images: np.ndarray, extrinsics: np.ndarray, intrinsics: np.ndarray):
178
+ """Like extract() but returns per-shard torch GPU tensors (feats,ray,depth) each on cuda:di,
179
+ WITHOUT the host round-trip. The caller hands them to JAX via DLPack (GPU->GPU over NVLink).
180
+ Returns a list of len(devices) tuples, shard k on self.devices[k]."""
181
+ b = int(images.shape[0])
182
+ nd = len(self.devices)
183
+ bounds = [round(i * b / nd) for i in range(nd + 1)]
184
+ futs = {}
185
+ for di in range(nd):
186
+ s, e = bounds[di], bounds[di + 1]
187
+ if s >= e:
188
+ continue
189
+ futs[di] = self._pool.submit(self._run_shard, di, images[s:e], extrinsics[s:e], intrinsics[s:e], True)
190
+ return [futs[di].result() for di in sorted(futs)]
191
+
192
+ def extract(self, images: np.ndarray, extrinsics: np.ndarray, intrinsics: np.ndarray):
193
+ """images [B,V,H,W,3]; extrinsics [B,V,4,4] w2c; intrinsics [B,V,3,3] AT 252x336.
194
+
195
+ Splits the batch across all replicas/GPUs and runs the forwards concurrently.
196
+ Returns numpy: feats [B,4,V,1536,18,24] f32, ray [B,V,3,18,24] f32, depth [B,V,1,18,24] f32.
197
+ """
198
+ b = int(images.shape[0])
199
+ nd = len(self.devices)
200
+ bounds = [round(i * b / nd) for i in range(nd + 1)]
201
+ futs = {}
202
+ for di in range(nd):
203
+ s, e = bounds[di], bounds[di + 1]
204
+ if s >= e:
205
+ continue
206
+ futs[di] = self._pool.submit(self._run_shard, di, images[s:e], extrinsics[s:e], intrinsics[s:e])
207
+ parts = [futs[di].result() for di in sorted(futs)]
208
+ return (
209
+ np.concatenate([p[0] for p in parts], axis=0),
210
+ np.concatenate([p[1] for p in parts], axis=0),
211
+ np.concatenate([p[2] for p in parts], axis=0),
212
+ )
pibehavior_da3_clean_up_your_desk_40k/source_code/training/train.py ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Training script for BEHAVIOR-1K solution.
3
+
4
+ Based on https://github.com/PhysicalIntelligence/openpi/blob/behavior/openpi/scripts/train.py with custom modifications.
5
+ """
6
+
7
+ import dataclasses
8
+ import functools
9
+ import logging
10
+ import os
11
+ import platform
12
+ import time
13
+ from typing import Any
14
+
15
+ import etils.epath as epath
16
+ import flax.nnx as nnx
17
+ from flax.training import common_utils
18
+ import flax.traverse_util as traverse_util
19
+ import jax
20
+ import jax.experimental
21
+ import jax.numpy as jnp
22
+ import numpy as np
23
+ import optax
24
+ import tqdm_loggable.auto as tqdm
25
+ import wandb
26
+
27
+ # Configure JAX memory allocation to prevent OOM errors
28
+ os.environ.setdefault('XLA_PYTHON_CLIENT_MEM_FRACTION', '0.9')
29
+ os.environ.setdefault('XLA_PYTHON_CLIENT_ALLOCATOR', 'platform')
30
+
31
+ # Configure OpenBLAS to prevent thread creation errors
32
+ os.environ.setdefault('OPENBLAS_NUM_THREADS', '16')
33
+ os.environ.setdefault('MKL_NUM_THREADS', '16')
34
+
35
+ import openpi.models.model as _model
36
+ import openpi.shared.array_typing as at
37
+ import openpi.shared.nnx_utils as nnx_utils
38
+ import openpi.training.optimizer as _optimizer
39
+ import openpi.training.sharding as sharding
40
+ import openpi.training.utils as training_utils
41
+
42
+ # Import B1K-specific modules
43
+ from b1k.training import checkpoints as _checkpoints # Use our custom checkpoints (not openpi's!)
44
+ from b1k.training import config as _config
45
+ from b1k.training import data_loader as _data_loader
46
+ from b1k.training import weight_loaders as _weight_loaders
47
+ from b1k.models.pi_behavior import PiBehavior
48
+ from b1k.models.pi_behavior_config import PiBehaviorConfig
49
+ from b1k.models.observation import Observation
50
+
51
+
52
+ def init_logging():
53
+ """Custom logging format for better readability."""
54
+ level_mapping = {"DEBUG": "D", "INFO": "I", "WARNING": "W", "ERROR": "E", "CRITICAL": "C"}
55
+
56
+ class CustomFormatter(logging.Formatter):
57
+ def format(self, record):
58
+ record.levelname = level_mapping.get(record.levelname, record.levelname)
59
+ return super().format(record)
60
+
61
+ formatter = CustomFormatter(
62
+ fmt="%(asctime)s.%(msecs)03d [%(levelname)s] %(message)-80s (%(process)d:%(filename)s:%(lineno)s)",
63
+ datefmt="%H:%M:%S",
64
+ )
65
+
66
+ logger = logging.getLogger()
67
+ logger.setLevel(logging.INFO)
68
+ logger.handlers[0].setFormatter(formatter)
69
+
70
+
71
+ def init_wandb(config: _config.TrainConfig, *, resuming: bool, log_code: bool = False, enabled: bool = True):
72
+ if not enabled:
73
+ wandb.init(mode="disabled")
74
+ return
75
+
76
+ ckpt_dir = config.checkpoint_dir
77
+ if not ckpt_dir.exists():
78
+ raise FileNotFoundError(f"Checkpoint directory {ckpt_dir} does not exist.")
79
+ if resuming:
80
+ run_id = (ckpt_dir / "wandb_id.txt").read_text().strip()
81
+ wandb.init(id=run_id, resume="must", project=config.project_name)
82
+ else:
83
+ wandb.init(
84
+ name=config.exp_name,
85
+ config=dataclasses.asdict(config),
86
+ project=config.project_name,
87
+ )
88
+ (ckpt_dir / "wandb_id.txt").write_text(wandb.run.id)
89
+
90
+ if log_code:
91
+ wandb.run.log_code(epath.Path(__file__).parent.parent)
92
+
93
+
94
+ def _load_weights_and_validate(loader: _weight_loaders.WeightLoader, params_shape: at.Params) -> at.Params:
95
+ """Loads and validates the weights. Returns a loaded subset of the weights."""
96
+ loaded_params = loader.load(params_shape)
97
+
98
+ # Filter out nnx.Intermediate fields from both sides (they're not params, excluded from checkpoints)
99
+ # This allows loading old checkpoints that didn't have these fields
100
+ def filter_intermediate_fields(params_dict):
101
+ flat = traverse_util.flatten_dict(params_dict)
102
+ # List of field names that are nnx.Intermediate (excluded from checkpoints)
103
+ intermediate_field_names = [
104
+ 'action_correlation_cholesky', # Legacy full correlation matrix
105
+ 'L_spatial', # Separable spatial correlation
106
+ 'L_temporal', # Separable temporal correlation
107
+ 'cached_num_inpaint_actions', # Conditional sampling cache
108
+ 'cached_input_action_dim', # Conditional sampling cache
109
+ 'cached_Sigma_uo_Sigma_oo_inv', # Conditional sampling cache
110
+ 'cached_L_cond_free', # Conditional sampling cache
111
+ 'cached_Sigma_ou_Sigma_uu_inv', # Conditional sampling cache
112
+ 'cached_L_cond_inp', # Conditional sampling cache
113
+ ]
114
+ filtered = {k: v for k, v in flat.items()
115
+ if not any(field in str(k) for field in intermediate_field_names)}
116
+ return traverse_util.unflatten_dict(filtered)
117
+
118
+ # Validate loaded params structure
119
+ params_shape_filtered = filter_intermediate_fields(params_shape)
120
+ loaded_params_filtered = filter_intermediate_fields(loaded_params)
121
+ at.check_pytree_equality(expected=params_shape_filtered, got=loaded_params_filtered, check_shapes=True, check_dtypes=True)
122
+
123
+ # Remove jax.ShapeDtypeStruct and Intermediate fields from the loaded params
124
+ def should_exclude(k, v):
125
+ if isinstance(v, jax.ShapeDtypeStruct):
126
+ return True
127
+ # Exclude all intermediate fields
128
+ intermediate_field_names = [
129
+ 'action_correlation_cholesky', 'L_spatial', 'L_temporal',
130
+ 'cached_num_inpaint_actions', 'cached_input_action_dim',
131
+ 'cached_Sigma_uo_Sigma_oo_inv', 'cached_L_cond_free',
132
+ 'cached_Sigma_ou_Sigma_uu_inv', 'cached_L_cond_inp',
133
+ ]
134
+ return any(field in str(k) for field in intermediate_field_names)
135
+
136
+ return traverse_util.unflatten_dict(
137
+ {k: v for k, v in traverse_util.flatten_dict(loaded_params).items()
138
+ if not should_exclude(k, v)}
139
+ )
140
+
141
+
142
+ @at.typecheck
143
+ def init_train_state(
144
+ config: _config.TrainConfig,
145
+ init_rng: at.KeyArrayLike,
146
+ mesh: jax.sharding.Mesh,
147
+ *,
148
+ resume: bool,
149
+ norm_stats: dict | None = None
150
+ ) -> tuple[training_utils.TrainState, Any]:
151
+ # DA3 v2 multi-group LR: the fresh spatial modules (bank builder + injection) and the geometry
152
+ # ray-MLP need a much higher LR than the pretrained trunk, or they barely move off init. Keyed on
153
+ # the same env the DA3 entrypoint sets; falls back to the single-group optimizer otherwise.
154
+ if os.environ.get("USE_DA3_FULL") == "1" and os.environ.get("DA3_LR_GROUPS", "1") == "1":
155
+ base = config.lr_schedule
156
+ def _sched(peak, end):
157
+ return dataclasses.replace(base, peak_lr=float(peak), decay_lr=float(end))
158
+ lr_groups = {
159
+ # pretrained SigLIP + gemma trunk + action expert: openpi pi0.5 finetune recipe
160
+ "vlm": _sched(os.environ.get("LR_VLM_PEAK", 2.5e-5), os.environ.get("LR_VLM_END", 2.5e-6)),
161
+ # fresh spatial branch (bank builder + cross-attn injection)
162
+ "core": _sched(os.environ.get("LR_CORE_PEAK", 5e-4), os.environ.get("LR_CORE_END", 5e-5)),
163
+ # fresh geometry ray-MLP
164
+ "geom": _sched(os.environ.get("LR_GEOM_PEAK", 5e-4), os.environ.get("LR_GEOM_END", 1e-4)),
165
+ }
166
+ # Optional delayed/slow phase-in for the geometry group: hold at 0 for LR_GEOM_DELAY steps
167
+ # (while vlm/core do their normal warmup), then ramp 0->peak over LR_GEOM_RAMP steps before
168
+ # the usual cosine decay. Off unless LR_GEOM_RAMP is set, so existing recipes are unchanged.
169
+ _g_ramp = int(os.environ.get("LR_GEOM_RAMP", "0"))
170
+ if _g_ramp > 0:
171
+ lr_groups["geom"] = _optimizer.DelayedRampCosineSchedule(
172
+ delay_steps=int(os.environ.get("LR_GEOM_DELAY", "0")),
173
+ ramp_steps=_g_ramp,
174
+ peak_lr=float(os.environ.get("LR_GEOM_PEAK", 5e-4)),
175
+ decay_steps=base.decay_steps,
176
+ decay_lr=float(os.environ.get("LR_GEOM_END", 1e-4)),
177
+ )
178
+ logging.info("DA3 geom LR: delayed ramp (0 for %d steps, 0->peak over %d, then cosine to %s)",
179
+ int(os.environ.get("LR_GEOM_DELAY", "0")), _g_ramp,
180
+ os.environ.get("LR_GEOM_END", 1e-4))
181
+ logging.info("DA3 multi-group LR: vlm=%s core=%s geom=%s",
182
+ lr_groups["vlm"].peak_lr, lr_groups["core"].peak_lr, lr_groups["geom"].peak_lr)
183
+ tx = _optimizer.create_multi_group_optimizer(
184
+ config.optimizer, lr_groups, _optimizer.spatial_group_labels, weight_decay_mask=None)
185
+ else:
186
+ tx = _optimizer.create_optimizer(config.optimizer, config.lr_schedule, weight_decay_mask=None)
187
+
188
+ def init(rng: at.KeyArrayLike, partial_params: at.Params | None = None) -> training_utils.TrainState:
189
+ rng, model_rng = jax.random.split(rng)
190
+ # initialize the model (and its parameters).
191
+ model = config.model.create(model_rng)
192
+
193
+ # Load correlation matrix into PiBehavior models BEFORE creating graphdef
194
+ if isinstance(model, PiBehavior) and norm_stats is not None:
195
+ model.load_correlation_matrix(norm_stats)
196
+ logging.info("Loaded correlation matrix during model initialization")
197
+
198
+ # Merge the partial params into the model.
199
+ if partial_params is not None:
200
+ graphdef, state = nnx.split(model)
201
+ # This will produce an error if the partial params are not a subset of the state.
202
+ state.replace_by_pure_dict(partial_params)
203
+ model = nnx.merge(graphdef, state)
204
+
205
+ params = nnx.state(model)
206
+ params = nnx_utils.state_map(params, config.freeze_filter, lambda p: p.replace(p.value.astype(jnp.bfloat16)))
207
+ return training_utils.TrainState(
208
+ step=0,
209
+ params=params,
210
+ model_def=nnx.graphdef(model),
211
+ tx=tx,
212
+ opt_state=tx.init(params.filter(config.trainable_filter)),
213
+ ema_decay=config.ema_decay,
214
+ ema_params=None if config.ema_decay is None else params,
215
+ )
216
+
217
+ train_state_shape = jax.eval_shape(init, init_rng)
218
+ state_sharding = sharding.fsdp_sharding(train_state_shape, mesh, log=True)
219
+
220
+ if resume:
221
+ return train_state_shape, state_sharding
222
+
223
+ partial_params = _load_weights_and_validate(config.weight_loader, train_state_shape.params.to_pure_dict())
224
+ replicated_sharding = jax.sharding.NamedSharding(mesh, jax.sharding.PartitionSpec())
225
+
226
+ # Initialize the train state and mix in the partial params.
227
+ train_state = jax.jit(
228
+ init,
229
+ donate_argnums=(1,), # donate the partial params buffer.
230
+ in_shardings=replicated_sharding,
231
+ out_shardings=state_sharding,
232
+ )(init_rng, partial_params)
233
+
234
+ # Log KV transform coefficients for PiBehavior models
235
+ model = nnx.merge(train_state.model_def, train_state.params)
236
+ if isinstance(model, PiBehavior) and hasattr(model, 'kv_transform') and model.kv_transform is not None:
237
+ logging.info("KV Transform Coefficients (after loading):")
238
+ logging.info("=" * 80)
239
+
240
+ k_coeffs = model.kv_transform.k_coeffs.value
241
+ v_coeffs = model.kv_transform.v_coeffs.value
242
+
243
+ logging.info("K Coefficients (each layer attends to all VLM layers):")
244
+ for i in range(k_coeffs.shape[0]):
245
+ coeffs_str = ", ".join([f"{float(c):.2f}" for c in k_coeffs[i]])
246
+ logging.info(f" Layer {i:2d}: [{coeffs_str}]")
247
+
248
+ logging.info("")
249
+ logging.info("V Coefficients (each layer attends to all VLM layers):")
250
+ for i in range(v_coeffs.shape[0]):
251
+ coeffs_str = ", ".join([f"{float(c):.2f}" for c in v_coeffs[i]])
252
+ logging.info(f" Layer {i:2d}: [{coeffs_str}]")
253
+
254
+ logging.info("=" * 80)
255
+
256
+ return train_state, state_sharding
257
+
258
+
259
+ @at.typecheck
260
+ def train_step(
261
+ config: _config.TrainConfig,
262
+ rng: at.KeyArrayLike,
263
+ state: training_utils.TrainState,
264
+ batch: tuple[Observation, _model.Actions],
265
+ ) -> tuple[training_utils.TrainState, dict[str, at.Array]]:
266
+ model = nnx.merge(state.model_def, state.params)
267
+ model.train()
268
+
269
+ @at.typecheck
270
+ def loss_fn(
271
+ model: PiBehavior, rng: at.KeyArrayLike, observation: Observation, actions: _model.Actions
272
+ ):
273
+ losses_dict = model.compute_detailed_loss(rng, observation, actions, train=True, num_flow_samples=config.num_flow_samples)
274
+ total_loss = jnp.mean(losses_dict["total_loss"])
275
+ return total_loss, losses_dict
276
+
277
+ train_rng = jax.random.fold_in(rng, state.step)
278
+ observation, actions = batch
279
+
280
+ # Filter out frozen params.
281
+ diff_state = nnx.DiffState(0, config.trainable_filter)
282
+ (loss, losses_dict), grads = nnx.value_and_grad(loss_fn, argnums=diff_state, has_aux=True)(model, train_rng, observation, actions)
283
+
284
+ # Knowledge insulation gradient monitoring
285
+ if config.model.use_knowledge_insulation:
286
+ # Helper functions to identify parameter groups
287
+ def is_action_expert_param(path_str):
288
+ # Action expert parameters:
289
+ # - Second LLM expert (300M params, marked with _1 suffix)
290
+ # - Action projections, time MLPs, kv_transform
291
+ return any(x in path_str for x in [
292
+ "_1", # All second expert parameters
293
+ "action_in_proj",
294
+ "action_out_proj",
295
+ "time_mlp_in",
296
+ "time_mlp_out",
297
+ "kv_transform"
298
+ ])
299
+
300
+ def is_vlm_param(path_str):
301
+ # VLM parameters: everything else (first expert, img, FAST, task modules)
302
+ return not is_action_expert_param(path_str)
303
+
304
+ # Compute gradient norms for monitoring only (no scaling applied)
305
+ def compute_group_norm(grads_state, predicate):
306
+ """Compute norm for gradients matching predicate."""
307
+ flat_grads = []
308
+ for path, value in jax.tree_util.tree_flatten_with_path(grads_state.to_pure_dict())[0]:
309
+ path_str = "/".join(str(k) for k in path)
310
+ if predicate(path_str):
311
+ if hasattr(value, 'value'):
312
+ flat_grads.append(value.value if hasattr(value, 'value') else value)
313
+ else:
314
+ flat_grads.append(value)
315
+
316
+ if flat_grads:
317
+ return jnp.sqrt(sum(jnp.sum(jnp.square(g)) for g in flat_grads))
318
+ return 0.0
319
+
320
+ grad_norm_vlm = compute_group_norm(grads, is_vlm_param)
321
+ grad_norm_action = compute_group_norm(grads, is_action_expert_param)
322
+ else:
323
+ grad_norm_vlm = None
324
+ grad_norm_action = None
325
+
326
+ params = state.params.filter(config.trainable_filter)
327
+ updates, new_opt_state = state.tx.update(grads, state.opt_state, params)
328
+ new_params = optax.apply_updates(params, updates)
329
+
330
+ # Update the model in place and return the new full state.
331
+ nnx.update(model, new_params)
332
+ new_params = nnx.state(model)
333
+
334
+ new_state = dataclasses.replace(state, step=state.step + 1, params=new_params, opt_state=new_opt_state)
335
+ if state.ema_decay is not None:
336
+ new_state = dataclasses.replace(
337
+ new_state,
338
+ ema_params=jax.tree.map(
339
+ lambda old, new: state.ema_decay * old + (1 - state.ema_decay) * new, state.ema_params, new_params
340
+ ),
341
+ )
342
+
343
+ # Filter out params that aren't kernels.
344
+ kernel_params = nnx.state(
345
+ model,
346
+ nnx.All(
347
+ nnx.Param,
348
+ nnx.Not(nnx_utils.PathRegex(".*/(bias|scale|pos_embedding|input_embedding)")),
349
+ lambda _, x: x.value.ndim > 1,
350
+ ),
351
+ )
352
+ info = {
353
+ "loss": loss,
354
+ "grad_norm": optax.global_norm(grads),
355
+ "param_norm": optax.global_norm(kernel_params),
356
+ }
357
+
358
+ # Add gradient norm breakdown for knowledge insulation monitoring
359
+ if grad_norm_vlm is not None:
360
+ info["grad_norm_vlm"] = grad_norm_vlm
361
+ info["grad_norm_action_expert"] = grad_norm_action
362
+
363
+ # Add detailed loss components to info
364
+ for key, value in losses_dict.items():
365
+ if isinstance(value, (float, int)) or (hasattr(value, 'ndim') and value.ndim == 0):
366
+ info[key] = value
367
+ else:
368
+ info[key] = jnp.mean(value)
369
+ return new_state, info
370
+
371
+
372
+ def main(config: _config.TrainConfig):
373
+ init_logging()
374
+ logging.info(f"Running on: {platform.node()}")
375
+
376
+ if config.batch_size % jax.device_count() != 0:
377
+ raise ValueError(
378
+ f"Batch size {config.batch_size} must be divisible by the number of devices {jax.device_count()}."
379
+ )
380
+
381
+ jax.config.update("jax_compilation_cache_dir", str(epath.Path("~/.cache/jax").expanduser()))
382
+
383
+ # Generate random seed if not provided
384
+ seed = config.seed
385
+ if seed is None:
386
+ seed = int(time.time() * 1000) % (2**32)
387
+ logging.info(f"Using random seed for JAX RNG: {seed}")
388
+
389
+ rng = jax.random.key(seed)
390
+ train_rng, init_rng = jax.random.split(rng)
391
+
392
+ mesh = sharding.make_mesh(config.fsdp_devices)
393
+ data_sharding = jax.sharding.NamedSharding(mesh, jax.sharding.PartitionSpec(sharding.DATA_AXIS))
394
+ replicated_sharding = jax.sharding.NamedSharding(mesh, jax.sharding.PartitionSpec())
395
+
396
+ checkpoint_manager, resuming = _checkpoints.initialize_checkpoint_dir(
397
+ config.checkpoint_dir,
398
+ keep_period=config.keep_period,
399
+ overwrite=config.overwrite,
400
+ resume=config.resume,
401
+ )
402
+ init_wandb(config, resuming=resuming, enabled=config.wandb_enabled)
403
+
404
+ data_loader = _data_loader.create_behavior_data_loader(
405
+ config,
406
+ sharding=data_sharding,
407
+ shuffle=True,
408
+ )
409
+
410
+ data_iter = iter(data_loader)
411
+ batch = next(data_iter)
412
+ logging.info(f"Initialized data loader:\n{training_utils.array_tree_to_info(batch)}")
413
+
414
+ if os.environ.get("SKIP_IMAGE_LOG", "0") != "1":
415
+ # Log images from first batch to sanity check.
416
+ images_to_log = [
417
+ wandb.Image(np.concatenate([np.array(img[i]) for img in batch[0].images.values()], axis=1))
418
+ for i in range(min(5, len(next(iter(batch[0].images.values())))))
419
+ ]
420
+ wandb.log({"camera_views": images_to_log}, step=0)
421
+
422
+ # Get norm_stats for correlation matrix loading
423
+ data_config = data_loader.data_config()
424
+ if data_config.norm_stats is None:
425
+ raise ValueError(
426
+ "norm_stats not found. Run compute_norm_stats.py to generate normalization statistics."
427
+ )
428
+ norm_stats = data_config.norm_stats
429
+
430
+ train_state, train_state_sharding = init_train_state(
431
+ config, init_rng, mesh, resume=resuming, norm_stats=norm_stats
432
+ )
433
+ jax.block_until_ready(train_state)
434
+ logging.info(f"Initialized train state:\n{training_utils.array_tree_to_info(train_state.params)}")
435
+
436
+ if resuming:
437
+ train_state = _checkpoints.restore_state(checkpoint_manager, train_state, data_loader)
438
+
439
+ # Reload correlation matrix after restore
440
+ model = nnx.merge(train_state.model_def, train_state.params)
441
+ model.load_correlation_matrix(norm_stats)
442
+ logging.info("Reloaded correlation matrix after checkpoint restore")
443
+ train_state = dataclasses.replace(train_state, model_def=nnx.graphdef(model))
444
+
445
+ ptrain_step = jax.jit(
446
+ functools.partial(train_step, config),
447
+ in_shardings=(replicated_sharding, train_state_sharding, data_sharding),
448
+ out_shardings=(train_state_sharding, replicated_sharding),
449
+ donate_argnums=(1,),
450
+ )
451
+
452
+ start_step = int(train_state.step)
453
+ pbar = tqdm.tqdm(
454
+ range(start_step, config.num_train_steps),
455
+ initial=start_step,
456
+ total=config.num_train_steps,
457
+ dynamic_ncols=True,
458
+ )
459
+
460
+ infos = []
461
+ for step in pbar:
462
+ with sharding.set_mesh(mesh):
463
+ train_state, info = ptrain_step(train_rng, train_state, batch)
464
+ infos.append(info)
465
+ if step % config.log_interval == 0:
466
+ stacked_infos = common_utils.stack_forest(infos)
467
+ reduced_info = jax.device_get(jax.tree.map(jnp.mean, stacked_infos))
468
+
469
+ # Create a concise console log with main metrics
470
+ main_metrics = {k: v for k, v in reduced_info.items()
471
+ if "loss" in k or "accuracy" in k or k in ["grad_norm", "param_norm", "grad_norm_vlm", "grad_norm_action_expert"]}
472
+ info_str = ", ".join(f"{k}={v:.4f}" for k, v in main_metrics.items())
473
+ pbar.write(f"Step {step}: {info_str}")
474
+ wandb.log(reduced_info, step=step)
475
+ infos = []
476
+ batch = next(data_iter)
477
+
478
+ skip_final_save = os.environ.get("SKIP_FINAL_SAVE", "0") == "1"
479
+ should_save_interval = step % config.save_interval == 0 and step > start_step
480
+ should_save_final = step == config.num_train_steps - 1 and not skip_final_save
481
+ if should_save_interval or should_save_final:
482
+ _checkpoints.save_state(checkpoint_manager, train_state, data_loader, step)
483
+
484
+ logging.info("Waiting for checkpoint manager to finish")
485
+ checkpoint_manager.wait_until_finished()
486
+
487
+
488
+ if __name__ == "__main__":
489
+ main(_config.cli())
pibehavior_da3_clean_up_your_desk_40k/source_code/training/train_2026.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train / correctness-gate the 2025 PiBehavior model on the 2026 v3 subset.
2
+
3
+ Reuses scripts/train.py's main() but swaps the data loader for the v3 reader
4
+ (b1k.training.b1k_2026) and initializes weights from a 2025 checkpoint.
5
+
6
+ Env knobs:
7
+ B1K_2026_ROOT dataset root (default: checkpoint_3's 13-task subset)
8
+ B1K_INIT_PARAMS 2025 checkpoint params dir to init from (default: checkpoint_3)
9
+ B1K_ACTIVITIES comma-separated activity ids to train/evaluate (default: ck3 subset)
10
+ B1K_BASE_CONFIG training config to clone (default: pi_behavior_b1k_fast)
11
+ USE_DA3_SPATIAL Enable precomputed DA3 spatial-token adapter (default 0)
12
+ DA3_SPATIAL_TOKENS Number of precomputed DA3 tokens per sample (default 320)
13
+ DA3_SPATIAL_DIM Feature width of each DA3 token (default 1024)
14
+ DA3_SPATIAL_HEADS Cross-attention heads (default 8)
15
+ DA3_SPATIAL_SCALE Residual scale for the DA3 adapter (default 1.0)
16
+ BS global batch size (must be divisible by #devices; default 16)
17
+ FSDP_DEVICES number of devices for FSDP sharding (default: repo config)
18
+ NW dataloader workers (default 24)
19
+ STEPS num_train_steps (default 40 — a gate, not a full run)
20
+ FLOW num_flow_samples (default 4; paper uses 15)
21
+ LR_WARMUP cosine LR warmup steps (default: repo config)
22
+ LR_PEAK cosine peak LR (default: repo config)
23
+ LR_DECAY_STEPS cosine decay steps (default: repo config)
24
+ LR_DECAY cosine final LR (default: repo config)
25
+ SAVE_INTERVAL checkpoint interval (default: disabled during gates)
26
+ KEEP_PERIOD checkpoint keep period (default: repo config)
27
+ RESUME resume existing checkpoint directory (default 0)
28
+ OVERWRITE overwrite checkpoint directory (default 1 unless RESUME=1)
29
+ LOG_INTERVAL metric logging interval (default 10)
30
+ SKIP_IMAGE_LOG skip first-batch image logging (default 1)
31
+ """
32
+ import os
33
+ import sys
34
+ import json
35
+ import dataclasses
36
+
37
+ # JAX-friendly + headless defaults
38
+ os.environ.setdefault("WANDB_MODE", "disabled")
39
+ os.environ.setdefault("XLA_PYTHON_CLIENT_MEM_FRACTION", "0.9")
40
+ os.environ.setdefault("SKIP_IMAGE_LOG", "1")
41
+
42
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # so `import train` (scripts/train.py) works
43
+
44
+ from b1k.training import config as _config
45
+ from b1k.training import data_loader as _data_loader
46
+ from b1k.training import weight_loaders
47
+ from b1k.training.b1k_2026 import create_v3_behavior_data_loader
48
+ from b1k.training.b1k_da3 import create_v3_behavior_da3_loader
49
+
50
+ ROOT = os.environ.get("B1K_2026_ROOT", "/work/jack/behavior1k/data/behavior_2026_ck3")
51
+ INIT_PARAMS = os.environ.get("B1K_INIT_PARAMS", "/work/jack/behavior1k/checkpoints/checkpoint_3/params")
52
+ BASE_CONFIG = os.environ.get("B1K_BASE_CONFIG", "pi_behavior_b1k_fast")
53
+ TASK_DATA_JSON = os.environ.get("B1K_TASK_DATA_JSON", "/work/jack/behavior1k/task_data.json")
54
+ if os.environ.get("B1K_ACTIVITIES"):
55
+ ACTIVITIES = [x.strip() for x in os.environ["B1K_ACTIVITIES"].split(",") if x.strip()]
56
+ else:
57
+ ACTIVITIES = json.load(open("/work/jack/behavior1k/subset_plan.json"))["ck3_names"]
58
+
59
+
60
+ def _v3_loader(config, *, sharding=None, shuffle=False, num_batches=None, skip_norm_stats=False):
61
+ if "SHUFFLE" in os.environ:
62
+ shuffle = bool(int(os.environ["SHUFFLE"]))
63
+ if getattr(config.model, "da3", None) is not None and config.model.da3.enabled:
64
+ return create_v3_behavior_da3_loader(
65
+ config, ROOT, ACTIVITIES, TASK_DATA_JSON,
66
+ lang_cache=os.environ.get("DA3_LANG_CACHE", "/work/jack/behavior1k/modernbert_b1k_tasks.pkl"),
67
+ sharding=sharding, shuffle=shuffle, num_workers=config.num_workers, seed=config.seed or 0,
68
+ )
69
+ return create_v3_behavior_data_loader(
70
+ config, ROOT, ACTIVITIES, TASK_DATA_JSON,
71
+ sharding=sharding, shuffle=shuffle, num_workers=config.num_workers, seed=config.seed or 0,
72
+ )
73
+
74
+
75
+ # Swap the loader everywhere main() reaches it.
76
+ _data_loader.create_behavior_data_loader = _v3_loader
77
+ import train # scripts/train.py — defines main()
78
+ train._data_loader.create_behavior_data_loader = _v3_loader
79
+
80
+
81
+ def build_config() -> _config.TrainConfig:
82
+ c = _config.get_config(BASE_CONFIG)
83
+ model = c.model
84
+ lr_schedule = c.lr_schedule
85
+ if any(k in os.environ for k in ("LR_WARMUP", "LR_PEAK", "LR_DECAY_STEPS", "LR_DECAY")):
86
+ lr_schedule = _config._optimizer.CosineDecaySchedule(
87
+ warmup_steps=int(os.environ.get("LR_WARMUP", str(lr_schedule.warmup_steps))),
88
+ peak_lr=float(os.environ.get("LR_PEAK", str(lr_schedule.peak_lr))),
89
+ decay_steps=int(os.environ.get("LR_DECAY_STEPS", str(lr_schedule.decay_steps))),
90
+ decay_lr=float(os.environ.get("LR_DECAY", str(lr_schedule.decay_lr))),
91
+ )
92
+ if bool(int(os.environ.get("USE_DA3_FULL", "0"))):
93
+ from b1k.models.pi_behavior_config import B1KDA3Config
94
+ model = dataclasses.replace(
95
+ model,
96
+ da3=B1KDA3Config(
97
+ spatial_scale=float(os.environ.get("DA3_SCALE", "2.0")),
98
+ spatial_init_std=float(os.environ.get("DA3_INIT_STD", "0.01")),
99
+ attn_logit_gain=bool(int(os.environ.get("DA3_LOGIT_GAIN", "1"))),
100
+ ),
101
+ )
102
+ if bool(int(os.environ.get("USE_DA3_SPATIAL", "0"))):
103
+ model = dataclasses.replace(
104
+ model,
105
+ use_spatial_action_cross_attention=True,
106
+ spatial_num_tokens=int(os.environ.get("DA3_SPATIAL_TOKENS", "320")),
107
+ spatial_token_dim=int(os.environ.get("DA3_SPATIAL_DIM", "1024")),
108
+ spatial_num_heads=int(os.environ.get("DA3_SPATIAL_HEADS", "8")),
109
+ spatial_residual_scale=float(os.environ.get("DA3_SPATIAL_SCALE", "1.0")),
110
+ )
111
+ return dataclasses.replace(
112
+ c,
113
+ exp_name=os.environ.get("EXP", "v3_ck3_gate"),
114
+ model=model,
115
+ lr_schedule=lr_schedule,
116
+ weight_loader=weight_loaders.PiBehaviorWeightLoader(INIT_PARAMS),
117
+ wandb_enabled=False,
118
+ overwrite=bool(int(os.environ.get("OVERWRITE", "0" if os.environ.get("RESUME", "0") == "1" else "1"))),
119
+ resume=bool(int(os.environ.get("RESUME", "0"))),
120
+ batch_size=int(os.environ.get("BS", "16")),
121
+ fsdp_devices=int(os.environ.get("FSDP_DEVICES", str(c.fsdp_devices))),
122
+ num_workers=int(os.environ.get("NW", "24")),
123
+ num_train_steps=int(os.environ.get("STEPS", "40")),
124
+ num_flow_samples=int(os.environ.get("FLOW", "4")),
125
+ log_interval=int(os.environ.get("LOG_INTERVAL", "10")),
126
+ save_interval=int(os.environ.get("SAVE_INTERVAL", "10000000")), # disabled during gates by default
127
+ keep_period=int(os.environ.get("KEEP_PERIOD", str(c.keep_period))),
128
+ seed=0,
129
+ assets_base_dir="./outputs/assets",
130
+ checkpoint_base_dir="./outputs/checkpoints",
131
+ )
132
+
133
+
134
+ if __name__ == "__main__":
135
+ train.main(build_config())