mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-24 02:06:15 +00:00
feat(recap): align value function with pi0.6 Gemma3 VLM
- use 448px SigLIP2 inputs with 2x2 pooling - adopt native Gemma3 projector and attention masking - support aligned VLM checkpoints - update preprocessing, targets, and tests
This commit is contained in:
+22
-15
@@ -16,17 +16,19 @@
|
|||||||
|
|
||||||
Paper: "π*0.6: a VLA That Learns From Experience" (Physical Intelligence, 2025)
|
Paper: "π*0.6: a VLA That Learns From Experience" (Physical Intelligence, 2025)
|
||||||
https://pi.website/blog/pistar06
|
https://pi.website/blog/pistar06
|
||||||
|
Architecture source of truth: "π0.6 Model Card", Section 2 (Model Design)
|
||||||
|
https://website.pi-asset.com/pi06star/PI06_model_card.pdf
|
||||||
|
|
||||||
Distributional value function V^{pi_ref}(o_t, l) (Section IV-A).
|
Distributional value function V^{pi_ref}(o_t, l) (Section IV-A).
|
||||||
|
|
||||||
Architecture (~670M params):
|
Architecture (~670M params):
|
||||||
Vision: SigLIP2-so400m — 27 layers, 1152-dim, 256 patches/image
|
Vision: SigLIP2-so400m — 27 layers, 1152-dim, 1024 patches/image at 448px
|
||||||
LM: Gemma3-270M — 18 layers, 640-dim
|
LM: Gemma3-270M — 18 layers, 640-dim
|
||||||
Proj: Linear(1152, 640) fresh init
|
Proj: 2x2 pool → RMSNorm → Linear(1152, 640), 256 soft tokens/image
|
||||||
Head: [CLS] → Linear(640→320) → LN → GELU → Dropout → Linear(320→201)
|
Readout: one-way learned value query → 2-layer MLP → 201 bins
|
||||||
|
|
||||||
Inputs: multi-camera images (3 x 256 patches) + ``"Task: {task}."`` prompt
|
Inputs: multi-camera images (3 x 256 soft tokens) + ``"Task: {task}."`` prompt
|
||||||
Targets: MC returns in [-1, 0], cross-entropy on HL-Gauss (default) or Dirac delta
|
Targets: MC returns in [-1, 0], cross-entropy on Dirac delta (default) or HL-Gauss
|
||||||
Init: SigLIP2 + Gemma3 from pretrained HF checkpoints; head normal_(std=0.02)
|
Init: SigLIP2 + Gemma3 from pretrained HF checkpoints; head normal_(std=0.02)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -43,16 +45,23 @@ class DistributionalVFConfig(RewardModelConfig):
|
|||||||
"""Configuration for RECAP's distributional value function.
|
"""Configuration for RECAP's distributional value function.
|
||||||
|
|
||||||
Predicts V^{pi_ref}(o_t, l) as a categorical distribution over B=201 bins in [-1, 0].
|
Predicts V^{pi_ref}(o_t, l) as a categorical distribution over B=201 bins in [-1, 0].
|
||||||
Trained with cross-entropy on HL-Gauss soft targets (default) or Dirac delta (C51),
|
Trained with cross-entropy on Dirac delta (C51, default) or HL-Gauss soft targets,
|
||||||
with optional one-hot targets for terminal states.
|
with optional one-hot targets for terminal states.
|
||||||
|
|
||||||
Architecture: monolithic VLM — SigLIP2-so400m (vision) + Gemma3-270M (language),
|
Architecture: adapted from the native Gemma3 multimodal VLM design and
|
||||||
bidirectional prefix attention, one-way [CLS] readout, 2-layer MLP value head.
|
scaled to π0.6's ~670M value backbone:
|
||||||
|
448px SigLIP2-so400m images are pooled from 1024 patches to 256 soft
|
||||||
|
tokens, RMS-normalized, projected into Gemma3-270M, and followed by a
|
||||||
|
one-way learned value-query token. Image tokens attend bidirectionally;
|
||||||
|
text and the value query remain causal.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Backbone pretrained paths
|
# Backbone pretrained paths
|
||||||
siglip_path: str = "google/siglip2-so400m-patch14-224"
|
siglip_path: str = "google/siglip2-so400m-patch14-384"
|
||||||
gemma3_path: str = "google/gemma-3-270m"
|
gemma3_path: str = "google/gemma-3-270m"
|
||||||
|
# Optional standard Gemma3ForConditionalGeneration checkpoint produced by
|
||||||
|
# standalone VLM alignment. When set, it supplies vision, connector, and LM.
|
||||||
|
vlm_pretrained_path: str | None = None
|
||||||
|
|
||||||
# Distributional head
|
# Distributional head
|
||||||
num_value_bins: int = 201
|
num_value_bins: int = 201
|
||||||
@@ -60,14 +69,15 @@ class DistributionalVFConfig(RewardModelConfig):
|
|||||||
value_support_max: float = 0.0
|
value_support_max: float = 0.0
|
||||||
hl_gauss_sigma_ratio: float = 5.0
|
hl_gauss_sigma_ratio: float = 5.0
|
||||||
|
|
||||||
# Target distribution method: "hl_gauss" (default, soft) or "dirac_delta" (C51, hard)
|
# Target distribution method: "dirac_delta" (paper-faithful C51) or "hl_gauss" (soft)
|
||||||
target_method: str = "hl_gauss"
|
target_method: str = "dirac_delta"
|
||||||
|
|
||||||
# Whether to use one-hot targets for terminal states (exact return, no smoothing).
|
# Whether to use one-hot targets for terminal states (exact return, no smoothing).
|
||||||
use_one_hot_terminal: bool = True
|
use_one_hot_terminal: bool = True
|
||||||
|
|
||||||
# Image
|
# Image
|
||||||
image_resolution: tuple[int, int] = (224, 224)
|
image_resolution: tuple[int, int] = (448, 448)
|
||||||
|
num_image_tokens: int = 256
|
||||||
|
|
||||||
# Tokenizer (uses Gemma3's tokenizer)
|
# Tokenizer (uses Gemma3's tokenizer)
|
||||||
tokenizer_max_length: int = 200
|
tokenizer_max_length: int = 200
|
||||||
@@ -79,9 +89,6 @@ class DistributionalVFConfig(RewardModelConfig):
|
|||||||
stop_gradient_to_vlm: bool = False
|
stop_gradient_to_vlm: bool = False
|
||||||
vision_encoder_lr_multiplier: float = 0.5
|
vision_encoder_lr_multiplier: float = 0.5
|
||||||
|
|
||||||
# Readout: "mean_pool" (average all tokens) or "last_token" (causal LM last position)
|
|
||||||
readout: str = "mean_pool"
|
|
||||||
|
|
||||||
# Normalization
|
# Normalization
|
||||||
normalization_mapping: dict[str, NormalizationMode] = field(
|
normalization_mapping: dict[str, NormalizationMode] = field(
|
||||||
default_factory=lambda: {
|
default_factory=lambda: {
|
||||||
|
|||||||
+159
-67
@@ -16,18 +16,19 @@
|
|||||||
|
|
||||||
Paper: "π*0.6: a VLA That Learns From Experience" (Physical Intelligence, 2025)
|
Paper: "π*0.6: a VLA That Learns From Experience" (Physical Intelligence, 2025)
|
||||||
https://pi.website/blog/pistar06
|
https://pi.website/blog/pistar06
|
||||||
|
Architecture source of truth: "π0.6 Model Card", Section 2 (Model Design)
|
||||||
|
https://website.pi-asset.com/pi06star/PI06_model_card.pdf
|
||||||
|
|
||||||
Implements the distributional value function V^{pi_ref}(o_t, l) from Section IV-A.
|
Implements the distributional value function V^{pi_ref}(o_t, l) from Section IV-A.
|
||||||
Architecture: the paper uses a 670M-parameter Gemma 3 VLM (Figure 3) —
|
It adapts the native Gemma3 multimodal VLM design to π0.6's smaller ~670M scale:
|
||||||
SigLIP2-so400m (27 layers, 1152-dim) + Gemma3-270M (18 layers, 640-dim),
|
448px SigLIP images are pooled to 256 soft tokens, RMS-normalized, projected
|
||||||
with a [CLS] token readout predicting a categorical distribution over
|
into Gemma3-270M, and processed with bidirectional image / causal text
|
||||||
B=201 discrete value bins in [-1, 0]. This implementation uses a 2-layer
|
attention. A final learned value-query token supplies the 201-bin readout.
|
||||||
MLP value head (Linear→LN→GELU→Dropout→Linear) inspired by Robometer
|
|
||||||
(Chen et al., 2025).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import math
|
import math
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -40,7 +41,6 @@ from lerobot.rewards.pretrained import PreTrainedRewardModel
|
|||||||
from lerobot.utils.constants import (
|
from lerobot.utils.constants import (
|
||||||
OBS_LANGUAGE_ATTENTION_MASK,
|
OBS_LANGUAGE_ATTENTION_MASK,
|
||||||
OBS_LANGUAGE_TOKENS,
|
OBS_LANGUAGE_TOKENS,
|
||||||
OPENPI_ATTENTION_MASK_VALUE as _ATTENTION_MASK_VALUE,
|
|
||||||
)
|
)
|
||||||
from lerobot.utils.import_utils import _transformers_available, require_package
|
from lerobot.utils.import_utils import _transformers_available, require_package
|
||||||
|
|
||||||
@@ -48,16 +48,33 @@ from .configuration_distributional_value_function import DistributionalVFConfig
|
|||||||
from .processor_distributional_value_function import IMAGE_MASK_SUFFIX
|
from .processor_distributional_value_function import IMAGE_MASK_SUFFIX
|
||||||
|
|
||||||
if TYPE_CHECKING or _transformers_available:
|
if TYPE_CHECKING or _transformers_available:
|
||||||
from transformers import Gemma3ForCausalLM, SiglipVisionModel
|
from transformers import (
|
||||||
|
Gemma3Config,
|
||||||
|
Gemma3ForCausalLM,
|
||||||
|
Gemma3ForConditionalGeneration,
|
||||||
|
SiglipVisionModel,
|
||||||
|
)
|
||||||
|
from transformers.models.gemma3.modeling_gemma3 import (
|
||||||
|
Gemma3MultiModalProjector,
|
||||||
|
create_causal_mask_mapping,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
|
Gemma3Config = None # type: ignore[assignment]
|
||||||
Gemma3ForCausalLM = None # type: ignore[assignment]
|
Gemma3ForCausalLM = None # type: ignore[assignment]
|
||||||
|
Gemma3ForConditionalGeneration = None # type: ignore[assignment]
|
||||||
|
Gemma3MultiModalProjector = None # type: ignore[assignment]
|
||||||
SiglipVisionModel = None # type: ignore[assignment]
|
SiglipVisionModel = None # type: ignore[assignment]
|
||||||
|
create_causal_mask_mapping = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
class ValueHead(nn.Module):
|
class ValueHead(nn.Module):
|
||||||
"""Categorical value projection: hidden state → bin logits.
|
"""Categorical value projection: hidden state → bin logits.
|
||||||
|
|
||||||
2-layer MLP: Linear → LayerNorm → GELU → Dropout → Linear.
|
The 2-layer MLP topology is adapted from Robometer's prediction head:
|
||||||
|
Linear → LayerNorm → GELU → Dropout → Linear. Unlike Robometer's progress
|
||||||
|
and success heads, this head predicts RECAP's 201-bin MC-return
|
||||||
|
distribution over [-1, 0] from the final value-query representation.
|
||||||
|
|
||||||
Also holds the ``bin_centers`` buffer used to compute E[V] = Σ p_i · c_i.
|
Also holds the ``bin_centers`` buffer used to compute E[V] = Σ p_i · c_i.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -96,12 +113,11 @@ class DistributionalVFRewardModel(PreTrainedRewardModel):
|
|||||||
Trained with cross-entropy on HL-Gauss or Dirac delta targets centered on
|
Trained with cross-entropy on HL-Gauss or Dirac delta targets centered on
|
||||||
per-task normalized Monte Carlo returns.
|
per-task normalized Monte Carlo returns.
|
||||||
|
|
||||||
Architecture: SigLIP2-so400m + Linear(1152→640) + Gemma3-270M.
|
Architecture: adapted from the native Gemma3 multimodal VLM using a
|
||||||
Multi-camera images are encoded by SigLIP2 (256 patches each), projected to
|
448px SigLIP2-so400m vision tower, Gemma3 multimodal projector, and
|
||||||
Gemma3's hidden dim, concatenated with tokenized language, and processed by
|
Gemma3-270M language backbone. Each camera is represented by 256 soft
|
||||||
all 18 Gemma3 transformer layers.
|
image tokens. Image tokens are bidirectional, text is causal, and a final
|
||||||
|
one-way value query supplies the hidden state consumed by the value head.
|
||||||
Mean-pooled last-layer hidden states are read out through a 2-layer MLP value head.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "distributional_value_function"
|
name = "distributional_value_function"
|
||||||
@@ -112,18 +128,42 @@ class DistributionalVFRewardModel(PreTrainedRewardModel):
|
|||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
|
if config.vlm_pretrained_path:
|
||||||
|
aligned_vlm = Gemma3ForConditionalGeneration.from_pretrained(config.vlm_pretrained_path)
|
||||||
|
self._vlm_config = aligned_vlm.config
|
||||||
|
self.vision_encoder = aligned_vlm.model.vision_tower
|
||||||
|
self.multi_modal_projector = aligned_vlm.model.multi_modal_projector
|
||||||
|
self.language_model = aligned_vlm.model.language_model
|
||||||
|
else:
|
||||||
self.vision_encoder = SiglipVisionModel.from_pretrained(config.siglip_path)
|
self.vision_encoder = SiglipVisionModel.from_pretrained(config.siglip_path)
|
||||||
siglip_hidden = self.vision_encoder.config.hidden_size # 1152
|
gemma3 = Gemma3ForCausalLM.from_pretrained(config.gemma3_path)
|
||||||
|
self.language_model = gemma3.model
|
||||||
|
|
||||||
self.gemma3 = Gemma3ForCausalLM.from_pretrained(config.gemma3_path)
|
# Adapt Gemma3's native multimodal connector to the 270M text
|
||||||
self.gemma3_hidden = self.gemma3.config.hidden_size # 640
|
# backbone and π0.6's 448px input layout.
|
||||||
|
vision_config = copy.deepcopy(self.vision_encoder.config)
|
||||||
|
vision_config.image_size = config.image_resolution[0]
|
||||||
|
text_config = copy.deepcopy(gemma3.config)
|
||||||
|
self._vlm_config = Gemma3Config(
|
||||||
|
vision_config=vision_config,
|
||||||
|
text_config=text_config,
|
||||||
|
mm_tokens_per_image=config.num_image_tokens,
|
||||||
|
)
|
||||||
|
self.multi_modal_projector = Gemma3MultiModalProjector(self._vlm_config)
|
||||||
|
nn.init.normal_(
|
||||||
|
self.multi_modal_projector.mm_input_projection_weight,
|
||||||
|
mean=0.0,
|
||||||
|
std=self._vlm_config.initializer_range,
|
||||||
|
)
|
||||||
|
|
||||||
# Fresh image projection: SigLIP2 1152-dim → Gemma3 640-dim
|
self._validate_vlm_config()
|
||||||
self.image_proj = nn.Linear(siglip_hidden, self.gemma3_hidden, bias=True)
|
self.gemma3_hidden = self._vlm_config.text_config.hidden_size # 640
|
||||||
nn.init.normal_(self.image_proj.weight, std=0.02)
|
|
||||||
nn.init.zeros_(self.image_proj.bias)
|
|
||||||
|
|
||||||
# Value head: last-token hidden state → MLP → num_bins logits
|
# One-way suffix query, analogous to PI05's suffix/action tokens.
|
||||||
|
self.value_query = nn.Embedding(1, self.gemma3_hidden)
|
||||||
|
nn.init.normal_(self.value_query.weight, std=0.02)
|
||||||
|
|
||||||
|
# Value head: value-query hidden state → MLP → num_bins logits
|
||||||
self.value_head = ValueHead(
|
self.value_head = ValueHead(
|
||||||
hidden_size=self.gemma3_hidden,
|
hidden_size=self.gemma3_hidden,
|
||||||
num_bins=config.num_value_bins,
|
num_bins=config.num_value_bins,
|
||||||
@@ -139,6 +179,35 @@ class DistributionalVFRewardModel(PreTrainedRewardModel):
|
|||||||
# Apply freezing
|
# Apply freezing
|
||||||
self._set_requires_grad()
|
self._set_requires_grad()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gemma3(self) -> nn.Module:
|
||||||
|
"""Backward-compatible access to the Gemma3 text backbone."""
|
||||||
|
return self.language_model
|
||||||
|
|
||||||
|
def _validate_vlm_config(self) -> None:
|
||||||
|
"""Validate the π0.6 448px → 256-token multimodal layout."""
|
||||||
|
vision_config = self._vlm_config.vision_config
|
||||||
|
image_size = self.config.image_resolution[0]
|
||||||
|
if vision_config.image_size != image_size:
|
||||||
|
raise ValueError(
|
||||||
|
f"VLM vision image_size ({vision_config.image_size}) does not match "
|
||||||
|
f"DistributionalVFConfig.image_resolution ({image_size})"
|
||||||
|
)
|
||||||
|
|
||||||
|
patches_per_side = image_size // vision_config.patch_size
|
||||||
|
tokens_per_side = int(self.config.num_image_tokens**0.5)
|
||||||
|
if tokens_per_side**2 != self.config.num_image_tokens:
|
||||||
|
raise ValueError("num_image_tokens must be a perfect square")
|
||||||
|
if patches_per_side % tokens_per_side:
|
||||||
|
raise ValueError(
|
||||||
|
f"{patches_per_side} patches/side cannot be evenly pooled to {tokens_per_side} tokens/side"
|
||||||
|
)
|
||||||
|
if self._vlm_config.mm_tokens_per_image != self.config.num_image_tokens:
|
||||||
|
raise ValueError(
|
||||||
|
f"VLM emits {self._vlm_config.mm_tokens_per_image} image tokens, "
|
||||||
|
f"expected {self.config.num_image_tokens}"
|
||||||
|
)
|
||||||
|
|
||||||
def _set_requires_grad(self) -> None:
|
def _set_requires_grad(self) -> None:
|
||||||
if self.config.freeze_vision_encoder:
|
if self.config.freeze_vision_encoder:
|
||||||
for param in self.vision_encoder.parameters():
|
for param in self.vision_encoder.parameters():
|
||||||
@@ -146,16 +215,16 @@ class DistributionalVFRewardModel(PreTrainedRewardModel):
|
|||||||
self.vision_encoder.eval()
|
self.vision_encoder.eval()
|
||||||
|
|
||||||
if self.config.freeze_language_model:
|
if self.config.freeze_language_model:
|
||||||
for param in self.gemma3.parameters():
|
for param in self.language_model.parameters():
|
||||||
param.requires_grad = False
|
param.requires_grad = False
|
||||||
self.gemma3.eval()
|
self.language_model.eval()
|
||||||
|
|
||||||
def train(self, mode: bool = True):
|
def train(self, mode: bool = True):
|
||||||
super().train(mode)
|
super().train(mode)
|
||||||
if self.config.freeze_vision_encoder:
|
if self.config.freeze_vision_encoder:
|
||||||
self.vision_encoder.eval()
|
self.vision_encoder.eval()
|
||||||
if self.config.freeze_language_model:
|
if self.config.freeze_language_model:
|
||||||
self.gemma3.eval()
|
self.language_model.eval()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def get_optim_params(self) -> list[dict]:
|
def get_optim_params(self) -> list[dict]:
|
||||||
@@ -178,18 +247,25 @@ class DistributionalVFRewardModel(PreTrainedRewardModel):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def embed_image(self, image: Tensor) -> Tensor:
|
def embed_image(self, image: Tensor) -> Tensor:
|
||||||
"""Embed images: SigLIP2 → projection → [B, num_patches, gemma3_hidden].
|
"""Embed images with π0.6's Gemma3 visual connector.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image: [batch_size, channels, height, width] preprocessed image in [-1, 1].
|
image: [batch_size, channels, height, width] preprocessed image in [-1, 1].
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
[B, 256, gemma3_hidden] projected image features.
|
[B, 256, gemma3_hidden] pooled, normalized, projected features.
|
||||||
"""
|
"""
|
||||||
if image.dtype != torch.float32:
|
vision_dtype = next(self.vision_encoder.parameters()).dtype
|
||||||
image = image.to(torch.float32)
|
image_features = self.vision_encoder(
|
||||||
feats = self.vision_encoder(pixel_values=image).last_hidden_state
|
pixel_values=image.to(dtype=vision_dtype),
|
||||||
return self.image_proj(feats)
|
interpolate_pos_encoding=True,
|
||||||
|
).last_hidden_state
|
||||||
|
projected_features = self.multi_modal_projector(image_features)
|
||||||
|
if projected_features.shape[1] != self.config.num_image_tokens:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Expected {self.config.num_image_tokens} image tokens, got {projected_features.shape[1]}"
|
||||||
|
)
|
||||||
|
return projected_features
|
||||||
|
|
||||||
def embed_text(self, token_ids: Tensor) -> Tensor:
|
def embed_text(self, token_ids: Tensor) -> Tensor:
|
||||||
"""Embed text using Gemma3's embedding table (includes sqrt(d) scaling).
|
"""Embed text using Gemma3's embedding table (includes sqrt(d) scaling).
|
||||||
@@ -200,7 +276,7 @@ class DistributionalVFRewardModel(PreTrainedRewardModel):
|
|||||||
Returns:
|
Returns:
|
||||||
[B, seq_len, gemma3_hidden] text embeddings.
|
[B, seq_len, gemma3_hidden] text embeddings.
|
||||||
"""
|
"""
|
||||||
return self.gemma3.model.embed_tokens(token_ids)
|
return self.language_model.embed_tokens(token_ids)
|
||||||
|
|
||||||
def embed_prefix(
|
def embed_prefix(
|
||||||
self,
|
self,
|
||||||
@@ -208,28 +284,30 @@ class DistributionalVFRewardModel(PreTrainedRewardModel):
|
|||||||
img_masks: list[Tensor],
|
img_masks: list[Tensor],
|
||||||
text_embeddings: Tensor,
|
text_embeddings: Tensor,
|
||||||
text_padding_mask: Tensor,
|
text_padding_mask: Tensor,
|
||||||
) -> tuple[Tensor, Tensor]:
|
) -> tuple[Tensor, Tensor, Tensor]:
|
||||||
"""Build prefix: [img1_patches, img2_patches, ..., lang_tokens].
|
"""Build [image soft tokens..., text] plus masks.
|
||||||
|
|
||||||
All prefix tokens use bidirectional attention (att_mask=0).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
embs: [B, total_prefix_len, hidden_dim]
|
embs: [B, total_prefix_len, hidden_dim]
|
||||||
pad_masks: [B, total_prefix_len] boolean
|
pad_masks: [B, total_prefix_len] boolean
|
||||||
|
token_type_ids: [B, total_prefix_len], 1=image and 0=text
|
||||||
"""
|
"""
|
||||||
embs: list[Tensor] = []
|
embs: list[Tensor] = []
|
||||||
pad_masks: list[Tensor] = []
|
pad_masks: list[Tensor] = []
|
||||||
|
token_types: list[Tensor] = []
|
||||||
|
|
||||||
for img, img_mask in zip(images, img_masks, strict=True):
|
for img, img_mask in zip(images, img_masks, strict=True):
|
||||||
img_emb = self.embed_image(img)
|
img_emb = self.embed_image(img)
|
||||||
bsize, num_patches = img_emb.shape[:2]
|
bsize, num_image_tokens = img_emb.shape[:2]
|
||||||
embs.append(img_emb)
|
embs.append(img_emb)
|
||||||
pad_masks.append(img_mask[:, None].expand(bsize, num_patches))
|
pad_masks.append(img_mask[:, None].expand(bsize, num_image_tokens))
|
||||||
|
token_types.append(torch.ones(bsize, num_image_tokens, dtype=torch.long, device=img_emb.device))
|
||||||
|
|
||||||
embs.append(text_embeddings)
|
embs.append(text_embeddings)
|
||||||
pad_masks.append(text_padding_mask)
|
pad_masks.append(text_padding_mask)
|
||||||
|
token_types.append(torch.zeros_like(text_padding_mask, dtype=torch.long))
|
||||||
|
|
||||||
return torch.cat(embs, dim=1), torch.cat(pad_masks, dim=1)
|
return torch.cat(embs, dim=1), torch.cat(pad_masks, dim=1), torch.cat(token_types, dim=1)
|
||||||
|
|
||||||
def hl_gauss_target(self, target_value: Tensor) -> Tensor:
|
def hl_gauss_target(self, target_value: Tensor) -> Tensor:
|
||||||
"""HL-Gauss soft target distribution.
|
"""HL-Gauss soft target distribution.
|
||||||
@@ -371,50 +449,64 @@ class DistributionalVFRewardModel(PreTrainedRewardModel):
|
|||||||
terminal_distribution = self.one_hot_target(target_value)
|
terminal_distribution = self.one_hot_target(target_value)
|
||||||
return torch.where(is_terminal[:, None].bool(), terminal_distribution, base_distribution)
|
return torch.where(is_terminal[:, None].bool(), terminal_distribution, base_distribution)
|
||||||
|
|
||||||
def _vlm_forward(self, batch: dict[str, Tensor]) -> tuple[Tensor, Tensor]:
|
def _get_vlm_readout(self, batch: dict[str, Tensor]) -> Tensor:
|
||||||
"""Shared VLM forward: images + text → Gemma3 → last-token hidden → logits.
|
"""Run Gemma3 image-bidirectional/text-causal attention plus value query."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
(value_logits [B, num_bins], predicted_value [B, 1])
|
|
||||||
"""
|
|
||||||
images, img_masks, token_ids, text_pad_mask = self._get_model_inputs(batch)
|
images, img_masks, token_ids, text_pad_mask = self._get_model_inputs(batch)
|
||||||
|
|
||||||
text_embs = self.embed_text(token_ids)
|
text_embs = self.embed_text(token_ids)
|
||||||
prefix_embs, prefix_pad_masks = self.embed_prefix(images, img_masks, text_embs, text_pad_mask)
|
prefix_embs, prefix_pad_masks, prefix_token_types = self.embed_prefix(
|
||||||
|
images, img_masks, text_embs, text_pad_mask
|
||||||
|
)
|
||||||
|
|
||||||
if self.config.stop_gradient_to_vlm:
|
if self.config.stop_gradient_to_vlm:
|
||||||
prefix_embs = prefix_embs.detach()
|
prefix_embs = prefix_embs.detach()
|
||||||
|
|
||||||
|
batch_size, prefix_len = prefix_pad_masks.shape
|
||||||
device = prefix_embs.device
|
device = prefix_embs.device
|
||||||
model_dtype = next(self.gemma3.parameters()).dtype
|
model_dtype = next(self.language_model.parameters()).dtype
|
||||||
|
|
||||||
# Bidirectional attention: every valid token attends to every valid token
|
query_ids = torch.zeros(batch_size, 1, dtype=torch.long, device=device)
|
||||||
att_2d = prefix_pad_masks[:, None, :] * prefix_pad_masks[:, :, None]
|
query_emb = self.value_query(query_ids)
|
||||||
att_4d = torch.where(
|
hidden_states = torch.cat([prefix_embs, query_emb], dim=1)
|
||||||
att_2d[:, None, :, :],
|
|
||||||
torch.tensor(0.0, dtype=model_dtype, device=device),
|
query_pad_mask = torch.ones(batch_size, 1, dtype=torch.bool, device=device)
|
||||||
torch.tensor(_ATTENTION_MASK_VALUE, dtype=model_dtype, device=device),
|
pad_masks = torch.cat([prefix_pad_masks, query_pad_mask], dim=1)
|
||||||
|
token_type_ids = torch.cat(
|
||||||
|
[prefix_token_types, torch.zeros(batch_size, 1, dtype=torch.long, device=device)],
|
||||||
|
dim=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
position_ids = torch.cumsum(prefix_pad_masks.long(), dim=1) - 1
|
prefix_position_ids = torch.cumsum(prefix_pad_masks.long(), dim=1) - 1
|
||||||
|
query_position_ids = prefix_pad_masks.sum(dim=1, keepdim=True).long()
|
||||||
|
position_ids = torch.cat([prefix_position_ids, query_position_ids], dim=1).clamp_min(0)
|
||||||
|
|
||||||
if prefix_embs.dtype != model_dtype:
|
if hidden_states.dtype != model_dtype:
|
||||||
prefix_embs = prefix_embs.to(model_dtype)
|
hidden_states = hidden_states.to(model_dtype)
|
||||||
|
|
||||||
outputs = self.gemma3.model(
|
attention_masks = create_causal_mask_mapping(
|
||||||
inputs_embeds=prefix_embs,
|
self._vlm_config,
|
||||||
attention_mask=att_4d,
|
inputs_embeds=hidden_states,
|
||||||
|
attention_mask=pad_masks,
|
||||||
|
past_key_values=None,
|
||||||
position_ids=position_ids,
|
position_ids=position_ids,
|
||||||
|
token_type_ids=token_type_ids,
|
||||||
|
is_training=self.training,
|
||||||
)
|
)
|
||||||
|
outputs = self.language_model(
|
||||||
|
inputs_embeds=hidden_states,
|
||||||
|
attention_mask=attention_masks,
|
||||||
|
position_ids=position_ids,
|
||||||
|
use_cache=False,
|
||||||
|
)
|
||||||
|
return outputs.last_hidden_state[:, -1, :]
|
||||||
|
|
||||||
# Readout from last hidden layer
|
def _vlm_forward(self, batch: dict[str, Tensor]) -> tuple[Tensor, Tensor]:
|
||||||
if self.config.readout == "mean_pool":
|
"""Run the VLM and value head.
|
||||||
hidden = outputs.last_hidden_state
|
|
||||||
mask = prefix_pad_masks.unsqueeze(-1).to(dtype=hidden.dtype)
|
|
||||||
readout = (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1)
|
|
||||||
else:
|
|
||||||
readout = outputs.last_hidden_state[:, -1, :]
|
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(value_logits [B, num_bins], predicted_value [B, 1])
|
||||||
|
"""
|
||||||
|
readout = self._get_vlm_readout(batch)
|
||||||
value_logits = self.value_head(readout)
|
value_logits = self.value_head(readout)
|
||||||
value_probs = F.softmax(value_logits, dim=-1)
|
value_probs = F.softmax(value_logits, dim=-1)
|
||||||
predicted_value = (value_probs * self.value_head.bin_centers.to(dtype=value_probs.dtype)).sum(
|
predicted_value = (value_probs * self.value_head.bin_centers.to(dtype=value_probs.dtype)).sum(
|
||||||
|
|||||||
+8
-7
@@ -18,7 +18,7 @@ Paper: "π*0.6: a VLA That Learns From Experience" (Physical Intelligence, 2025)
|
|||||||
https://pi.website/blog/pistar06
|
https://pi.website/blog/pistar06
|
||||||
|
|
||||||
Prepares inputs for V^{pi_ref}(o_t, l):
|
Prepares inputs for V^{pi_ref}(o_t, l):
|
||||||
1. Resize multi-camera images to 224x224 (with aspect-preserving padding)
|
1. Resize multi-camera images to 448x448 (with aspect-preserving padding)
|
||||||
2. Normalize images from [0,1] → [-1,1] (SigLIP standard)
|
2. Normalize images from [0,1] → [-1,1] (SigLIP standard)
|
||||||
3. Handle missing cameras (placeholder + mask)
|
3. Handle missing cameras (placeholder + mask)
|
||||||
4. Format task prompt: ``"Task: {task}."``
|
4. Format task prompt: ``"Task: {task}."``
|
||||||
@@ -132,12 +132,13 @@ def resize_with_pad_torch(
|
|||||||
class DistributionalVFImagePreprocessorStep(ProcessorStep):
|
class DistributionalVFImagePreprocessorStep(ProcessorStep):
|
||||||
"""Resize and normalize multi-camera images for the VF.
|
"""Resize and normalize multi-camera images for the VF.
|
||||||
|
|
||||||
|
Expects LeRobot's standard float image range [0, 1].
|
||||||
Produces [B, 3, H, W] tensors in [-1, 1] for each camera, plus boolean
|
Produces [B, 3, H, W] tensors in [-1, 1] for each camera, plus boolean
|
||||||
masks indicating which cameras are present. Missing cameras get a black
|
masks indicating which cameras are present. Missing cameras get a black
|
||||||
placeholder image and mask=False.
|
placeholder image and mask=False.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
image_resolution: tuple[int, int] = (224, 224)
|
image_resolution: tuple[int, int] = (448, 448)
|
||||||
image_keys: tuple[str, ...] = ()
|
image_keys: tuple[str, ...] = ()
|
||||||
|
|
||||||
def __call__(self, transition: EnvTransition) -> EnvTransition:
|
def __call__(self, transition: EnvTransition) -> EnvTransition:
|
||||||
@@ -154,12 +155,12 @@ class DistributionalVFImagePreprocessorStep(ProcessorStep):
|
|||||||
if is_channels_first:
|
if is_channels_first:
|
||||||
img = img.permute(0, 2, 3, 1) # BCHW → BHWC
|
img = img.permute(0, 2, 3, 1) # BCHW → BHWC
|
||||||
|
|
||||||
|
# Gemma3's SigLIP vision tower expects [-1, 1].
|
||||||
|
img = img * 2.0 - 1.0
|
||||||
|
|
||||||
if img.shape[1:3] != self.image_resolution:
|
if img.shape[1:3] != self.image_resolution:
|
||||||
img = resize_with_pad_torch(img, *self.image_resolution)
|
img = resize_with_pad_torch(img, *self.image_resolution)
|
||||||
|
|
||||||
if img.min() >= 0.0 and img.max() <= 1.0:
|
|
||||||
img = img * 2.0 - 1.0
|
|
||||||
|
|
||||||
observation[key] = img.permute(0, 3, 1, 2) # BHWC → BCHW
|
observation[key] = img.permute(0, 3, 1, 2) # BHWC → BCHW
|
||||||
observation[key + IMAGE_MASK_SUFFIX] = torch.ones(
|
observation[key + IMAGE_MASK_SUFFIX] = torch.ones(
|
||||||
img.shape[0], dtype=torch.bool, device=img.device
|
img.shape[0], dtype=torch.bool, device=img.device
|
||||||
@@ -240,7 +241,7 @@ def make_distributional_vf_pre_post_processors(
|
|||||||
1. Rename observations (no-op by default)
|
1. Rename observations (no-op by default)
|
||||||
2. Add a batch dimension
|
2. Add a batch dimension
|
||||||
3. Normalize features (identity for images)
|
3. Normalize features (identity for images)
|
||||||
4. Resize + normalize images → [B, 3, 224, 224] in [-1, 1]
|
4. Resize + normalize images → [B, 3, 448, 448] in [-1, 1]
|
||||||
5. Format task prompt: ``"Task: {task}."``
|
5. Format task prompt: ``"Task: {task}."``
|
||||||
6. Tokenize with Gemma3 tokenizer
|
6. Tokenize with Gemma3 tokenizer
|
||||||
7. Move tensors to the configured device
|
7. Move tensors to the configured device
|
||||||
@@ -265,7 +266,7 @@ def make_distributional_vf_pre_post_processors(
|
|||||||
),
|
),
|
||||||
DistributionalVFPrepareTaskPromptStep(),
|
DistributionalVFPrepareTaskPromptStep(),
|
||||||
TokenizerProcessorStep(
|
TokenizerProcessorStep(
|
||||||
tokenizer_name=config.gemma3_path,
|
tokenizer_name=config.vlm_pretrained_path or config.gemma3_path,
|
||||||
max_length=config.tokenizer_max_length,
|
max_length=config.tokenizer_max_length,
|
||||||
padding_side="right",
|
padding_side="right",
|
||||||
padding="max_length",
|
padding="max_length",
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ from lerobot.types import TransitionKey
|
|||||||
from lerobot.utils.constants import OBS_IMAGES
|
from lerobot.utils.constants import OBS_IMAGES
|
||||||
from tests.utils import skip_if_package_missing
|
from tests.utils import skip_if_package_missing
|
||||||
|
|
||||||
BATCH_SIZE = 4
|
BATCH_SIZE = 1
|
||||||
NUM_BINS = 201
|
NUM_BINS = 201
|
||||||
|
IMAGE_SIZE = 448
|
||||||
IMAGE_KEY = f"{OBS_IMAGES}.top"
|
IMAGE_KEY = f"{OBS_IMAGES}.top"
|
||||||
IMAGE_KEY_WRIST_LEFT = f"{OBS_IMAGES}.wrist_left"
|
IMAGE_KEY_WRIST_LEFT = f"{OBS_IMAGES}.wrist_left"
|
||||||
IMAGE_KEY_WRIST_RIGHT = f"{OBS_IMAGES}.wrist_right"
|
IMAGE_KEY_WRIST_RIGHT = f"{OBS_IMAGES}.wrist_right"
|
||||||
@@ -38,14 +39,14 @@ IMAGE_KEY_WRIST_RIGHT = f"{OBS_IMAGES}.wrist_right"
|
|||||||
def _make_config(**overrides) -> DistributionalVFConfig:
|
def _make_config(**overrides) -> DistributionalVFConfig:
|
||||||
defaults = {
|
defaults = {
|
||||||
"device": "cpu",
|
"device": "cpu",
|
||||||
"image_resolution": (224, 224),
|
"image_resolution": (IMAGE_SIZE, IMAGE_SIZE),
|
||||||
}
|
}
|
||||||
defaults.update(overrides)
|
defaults.update(overrides)
|
||||||
config = DistributionalVFConfig(**defaults)
|
config = DistributionalVFConfig(**defaults)
|
||||||
config.input_features = {
|
config.input_features = {
|
||||||
IMAGE_KEY: PolicyFeature(type=FeatureType.VISUAL, shape=(3, 224, 224)),
|
IMAGE_KEY: PolicyFeature(type=FeatureType.VISUAL, shape=(3, IMAGE_SIZE, IMAGE_SIZE)),
|
||||||
IMAGE_KEY_WRIST_LEFT: PolicyFeature(type=FeatureType.VISUAL, shape=(3, 224, 224)),
|
IMAGE_KEY_WRIST_LEFT: PolicyFeature(type=FeatureType.VISUAL, shape=(3, IMAGE_SIZE, IMAGE_SIZE)),
|
||||||
IMAGE_KEY_WRIST_RIGHT: PolicyFeature(type=FeatureType.VISUAL, shape=(3, 224, 224)),
|
IMAGE_KEY_WRIST_RIGHT: PolicyFeature(type=FeatureType.VISUAL, shape=(3, IMAGE_SIZE, IMAGE_SIZE)),
|
||||||
}
|
}
|
||||||
config.output_features = {}
|
config.output_features = {}
|
||||||
config.normalization_mapping = {
|
config.normalization_mapping = {
|
||||||
@@ -69,11 +70,11 @@ def _make_batch(batch_size: int = BATCH_SIZE, device: str = "cpu") -> dict[str,
|
|||||||
from lerobot.utils.constants import OBS_LANGUAGE_ATTENTION_MASK, OBS_LANGUAGE_TOKENS
|
from lerobot.utils.constants import OBS_LANGUAGE_ATTENTION_MASK, OBS_LANGUAGE_TOKENS
|
||||||
|
|
||||||
return {
|
return {
|
||||||
IMAGE_KEY: torch.rand(batch_size, 3, 224, 224, device=device) * 2 - 1,
|
IMAGE_KEY: torch.rand(batch_size, 3, IMAGE_SIZE, IMAGE_SIZE, device=device) * 2 - 1,
|
||||||
IMAGE_KEY + IMAGE_MASK_SUFFIX: torch.ones(batch_size, dtype=torch.bool, device=device),
|
IMAGE_KEY + IMAGE_MASK_SUFFIX: torch.ones(batch_size, dtype=torch.bool, device=device),
|
||||||
IMAGE_KEY_WRIST_LEFT: torch.rand(batch_size, 3, 224, 224, device=device) * 2 - 1,
|
IMAGE_KEY_WRIST_LEFT: torch.rand(batch_size, 3, IMAGE_SIZE, IMAGE_SIZE, device=device) * 2 - 1,
|
||||||
IMAGE_KEY_WRIST_LEFT + IMAGE_MASK_SUFFIX: torch.ones(batch_size, dtype=torch.bool, device=device),
|
IMAGE_KEY_WRIST_LEFT + IMAGE_MASK_SUFFIX: torch.ones(batch_size, dtype=torch.bool, device=device),
|
||||||
IMAGE_KEY_WRIST_RIGHT: torch.rand(batch_size, 3, 224, 224, device=device) * 2 - 1,
|
IMAGE_KEY_WRIST_RIGHT: torch.rand(batch_size, 3, IMAGE_SIZE, IMAGE_SIZE, device=device) * 2 - 1,
|
||||||
IMAGE_KEY_WRIST_RIGHT + IMAGE_MASK_SUFFIX: torch.ones(batch_size, dtype=torch.bool, device=device),
|
IMAGE_KEY_WRIST_RIGHT + IMAGE_MASK_SUFFIX: torch.ones(batch_size, dtype=torch.bool, device=device),
|
||||||
OBS_LANGUAGE_TOKENS: torch.randint(0, 1000, (batch_size, 200), device=device),
|
OBS_LANGUAGE_TOKENS: torch.randint(0, 1000, (batch_size, 200), device=device),
|
||||||
OBS_LANGUAGE_ATTENTION_MASK: torch.ones(batch_size, 200, dtype=torch.bool, device=device),
|
OBS_LANGUAGE_ATTENTION_MASK: torch.ones(batch_size, 200, dtype=torch.bool, device=device),
|
||||||
@@ -114,6 +115,13 @@ def test_make_reward_model_config_factory():
|
|||||||
assert config.num_value_bins == 101
|
assert config.num_value_bins == 101
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_defaults_match_pi06_gemma3_layout():
|
||||||
|
config = DistributionalVFConfig()
|
||||||
|
assert config.image_resolution == (448, 448)
|
||||||
|
assert config.num_image_tokens == 256
|
||||||
|
assert config.target_method == "dirac_delta"
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Target distribution tests (HL-Gauss, Dirac delta, one-hot)
|
# Target distribution tests (HL-Gauss, Dirac delta, one-hot)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -276,9 +284,9 @@ def test_model_has_expected_components():
|
|||||||
|
|
||||||
assert hasattr(model, "vision_encoder")
|
assert hasattr(model, "vision_encoder")
|
||||||
assert hasattr(model, "gemma3")
|
assert hasattr(model, "gemma3")
|
||||||
assert hasattr(model, "image_proj")
|
assert hasattr(model, "multi_modal_projector")
|
||||||
assert hasattr(model, "value_head")
|
assert hasattr(model, "value_head")
|
||||||
assert hasattr(model, "cls_embedding")
|
assert hasattr(model, "value_query")
|
||||||
assert hasattr(model.value_head, "mlp")
|
assert hasattr(model.value_head, "mlp")
|
||||||
assert hasattr(model.value_head, "bin_centers")
|
assert hasattr(model.value_head, "bin_centers")
|
||||||
|
|
||||||
@@ -298,23 +306,26 @@ def test_value_head_output_dim():
|
|||||||
|
|
||||||
|
|
||||||
@skip_if_package_missing("transformers")
|
@skip_if_package_missing("transformers")
|
||||||
def test_cls_embedding_is_nn_embedding():
|
def test_value_query_is_nn_embedding():
|
||||||
"""CLS is nn.Embedding (FSDP-safe) with correct shape."""
|
"""Value query is nn.Embedding (FSDP-safe) with correct shape."""
|
||||||
model = _make_model()
|
model = _make_model()
|
||||||
from torch import nn
|
from torch import nn
|
||||||
|
|
||||||
assert isinstance(model.cls_embedding, nn.Embedding)
|
assert isinstance(model.value_query, nn.Embedding)
|
||||||
assert model.cls_embedding.num_embeddings == 1
|
assert model.value_query.num_embeddings == 1
|
||||||
assert model.cls_embedding.embedding_dim == model.gemma3_hidden
|
assert model.value_query.embedding_dim == model.gemma3_hidden
|
||||||
|
|
||||||
|
|
||||||
@skip_if_package_missing("transformers")
|
@skip_if_package_missing("transformers")
|
||||||
def test_image_proj_dimensions():
|
def test_multimodal_projector_dimensions_and_pooling():
|
||||||
"""Image projection maps SigLIP2 hidden to Gemma3 hidden."""
|
"""Gemma3 connector pools 448px patches to 256 tokens and projects to LM width."""
|
||||||
model = _make_model()
|
model = _make_model()
|
||||||
siglip_hidden = model.vision_encoder.config.hidden_size
|
siglip_hidden = model.vision_encoder.config.hidden_size
|
||||||
assert model.image_proj.in_features == siglip_hidden
|
projector = model.multi_modal_projector
|
||||||
assert model.image_proj.out_features == model.gemma3_hidden
|
assert projector.mm_input_projection_weight.shape == (siglip_hidden, model.gemma3_hidden)
|
||||||
|
assert projector.patches_per_image == 32
|
||||||
|
assert projector.tokens_per_side == 16
|
||||||
|
assert projector.kernel_size == 2
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -356,12 +367,12 @@ def test_compute_reward_returns_correct_shape():
|
|||||||
"""compute_reward returns [batch_size] tensor of finite float32 values."""
|
"""compute_reward returns [batch_size] tensor of finite float32 values."""
|
||||||
model = _make_model()
|
model = _make_model()
|
||||||
model.eval()
|
model.eval()
|
||||||
batch = _make_batch(batch_size=3)
|
batch = _make_batch(batch_size=1)
|
||||||
|
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
values = model.compute_reward(batch)
|
values = model.compute_reward(batch)
|
||||||
|
|
||||||
assert values.shape == (3,)
|
assert values.shape == (1,)
|
||||||
assert values.dtype == torch.float32
|
assert values.dtype == torch.float32
|
||||||
assert torch.isfinite(values).all()
|
assert torch.isfinite(values).all()
|
||||||
|
|
||||||
@@ -371,7 +382,7 @@ def test_compute_reward_values_in_support_range():
|
|||||||
"""Predicted values lie within [value_support_min, value_support_max]."""
|
"""Predicted values lie within [value_support_min, value_support_max]."""
|
||||||
model = _make_model()
|
model = _make_model()
|
||||||
model.eval()
|
model.eval()
|
||||||
batch = _make_batch(batch_size=8)
|
batch = _make_batch(batch_size=1)
|
||||||
|
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
values = model.compute_reward(batch)
|
values = model.compute_reward(batch)
|
||||||
@@ -400,8 +411,8 @@ def test_gradient_flows_through_value_head():
|
|||||||
|
|
||||||
|
|
||||||
@skip_if_package_missing("transformers")
|
@skip_if_package_missing("transformers")
|
||||||
def test_gradient_flows_through_cls_embedding():
|
def test_gradient_flows_through_value_query():
|
||||||
"""Backprop produces non-zero gradients on the learned [CLS] embedding."""
|
"""Backprop produces non-zero gradients on the learned value query."""
|
||||||
model = _make_model()
|
model = _make_model()
|
||||||
model.train()
|
model.train()
|
||||||
batch = _make_batch()
|
batch = _make_batch()
|
||||||
@@ -409,13 +420,13 @@ def test_gradient_flows_through_cls_embedding():
|
|||||||
loss, _ = model.forward(batch)
|
loss, _ = model.forward(batch)
|
||||||
loss.backward()
|
loss.backward()
|
||||||
|
|
||||||
assert model.cls_embedding.weight.grad is not None
|
assert model.value_query.weight.grad is not None
|
||||||
assert not torch.all(model.cls_embedding.weight.grad == 0)
|
assert not torch.all(model.value_query.weight.grad == 0)
|
||||||
|
|
||||||
|
|
||||||
@skip_if_package_missing("transformers")
|
@skip_if_package_missing("transformers")
|
||||||
def test_gradient_flows_through_image_proj():
|
def test_gradient_flows_through_multimodal_projector():
|
||||||
"""Backprop produces non-zero gradients on the image projection."""
|
"""Backprop produces non-zero gradients on the Gemma3 multimodal projection."""
|
||||||
model = _make_model()
|
model = _make_model()
|
||||||
model.train()
|
model.train()
|
||||||
batch = _make_batch()
|
batch = _make_batch()
|
||||||
@@ -423,8 +434,9 @@ def test_gradient_flows_through_image_proj():
|
|||||||
loss, _ = model.forward(batch)
|
loss, _ = model.forward(batch)
|
||||||
loss.backward()
|
loss.backward()
|
||||||
|
|
||||||
assert model.image_proj.weight.grad is not None
|
projector_weight = model.multi_modal_projector.mm_input_projection_weight
|
||||||
assert not torch.all(model.image_proj.weight.grad == 0)
|
assert projector_weight.grad is not None
|
||||||
|
assert not torch.all(projector_weight.grad == 0)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -459,8 +471,8 @@ def test_freeze_language_model():
|
|||||||
|
|
||||||
|
|
||||||
@skip_if_package_missing("transformers")
|
@skip_if_package_missing("transformers")
|
||||||
def test_stop_gradient_to_vlm_preserves_cls_grad():
|
def test_stop_gradient_to_vlm_preserves_value_query_grad():
|
||||||
"""With stop_gradient_to_vlm, CLS embedding still gets gradients."""
|
"""With stop_gradient_to_vlm, the value query still gets gradients."""
|
||||||
config = _make_config(stop_gradient_to_vlm=True)
|
config = _make_config(stop_gradient_to_vlm=True)
|
||||||
from lerobot.rewards.distributional_value_function.modeling_distributional_value_function import (
|
from lerobot.rewards.distributional_value_function.modeling_distributional_value_function import (
|
||||||
DistributionalVFRewardModel,
|
DistributionalVFRewardModel,
|
||||||
@@ -473,8 +485,8 @@ def test_stop_gradient_to_vlm_preserves_cls_grad():
|
|||||||
loss, _ = model.forward(batch)
|
loss, _ = model.forward(batch)
|
||||||
loss.backward()
|
loss.backward()
|
||||||
|
|
||||||
assert model.cls_embedding.weight.grad is not None
|
assert model.value_query.weight.grad is not None
|
||||||
assert not torch.all(model.cls_embedding.weight.grad == 0)
|
assert not torch.all(model.value_query.weight.grad == 0)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -533,7 +545,7 @@ def test_processor_pipeline_produces_expected_keys():
|
|||||||
assert IMAGE_KEY_WRIST_RIGHT + IMAGE_MASK_SUFFIX in processed
|
assert IMAGE_KEY_WRIST_RIGHT + IMAGE_MASK_SUFFIX in processed
|
||||||
|
|
||||||
img = processed[IMAGE_KEY]
|
img = processed[IMAGE_KEY]
|
||||||
assert img.shape == (1, 3, 224, 224)
|
assert img.shape == (1, 3, IMAGE_SIZE, IMAGE_SIZE)
|
||||||
assert img.min() >= -1.0 - 1e-5
|
assert img.min() >= -1.0 - 1e-5
|
||||||
assert img.max() <= 1.0 + 1e-5
|
assert img.max() <= 1.0 + 1e-5
|
||||||
|
|
||||||
@@ -604,7 +616,7 @@ def test_image_preprocessor_resize_and_normalize():
|
|||||||
|
|
||||||
transition = {
|
transition = {
|
||||||
TransitionKey.OBSERVATION: {
|
TransitionKey.OBSERVATION: {
|
||||||
IMAGE_KEY: torch.rand(2, 3, 320, 240), # non-square, [0, 1]
|
IMAGE_KEY: torch.full((2, 3, 320, 240), 0.5), # non-square, [0, 1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,6 +626,9 @@ def test_image_preprocessor_resize_and_normalize():
|
|||||||
assert obs[IMAGE_KEY].shape == (2, 3, 224, 224)
|
assert obs[IMAGE_KEY].shape == (2, 3, 224, 224)
|
||||||
assert obs[IMAGE_KEY].min() >= -1.0 - 1e-5
|
assert obs[IMAGE_KEY].min() >= -1.0 - 1e-5
|
||||||
assert obs[IMAGE_KEY].max() <= 1.0 + 1e-5
|
assert obs[IMAGE_KEY].max() <= 1.0 + 1e-5
|
||||||
|
# Content value 0.5 must map to 0.0. This also verifies normalization
|
||||||
|
# happens before resize padding introduces -1 values.
|
||||||
|
assert torch.allclose(obs[IMAGE_KEY][:, :, 112, 112], torch.zeros(2, 3), atol=1e-5)
|
||||||
assert obs[IMAGE_KEY + IMAGE_MASK_SUFFIX].all()
|
assert obs[IMAGE_KEY + IMAGE_MASK_SUFFIX].all()
|
||||||
|
|
||||||
|
|
||||||
@@ -668,29 +683,6 @@ def test_save_load_pretrained_roundtrip(tmp_path):
|
|||||||
torch.testing.assert_close(orig_sd[key], loaded_sd[key], msg=f"Mismatch in {key}")
|
torch.testing.assert_close(orig_sd[key], loaded_sd[key], msg=f"Mismatch in {key}")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Attention mask utility test
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@skip_if_package_missing("transformers")
|
|
||||||
def test_make_att_2d_masks():
|
|
||||||
"""Verify attention mask construction for prefix + CLS."""
|
|
||||||
from lerobot.rewards.distributional_value_function.modeling_distributional_value_function import (
|
|
||||||
make_att_2d_masks,
|
|
||||||
)
|
|
||||||
|
|
||||||
pad = torch.ones(1, 4, dtype=torch.bool)
|
|
||||||
att = torch.tensor([[0, 0, 0, 1]])
|
|
||||||
mask = make_att_2d_masks(pad, att)[0]
|
|
||||||
|
|
||||||
assert mask[0, 0] # prefix sees prefix
|
|
||||||
assert mask[1, 2] # prefix sees prefix
|
|
||||||
assert not mask[0, 3] # prefix does NOT see CLS
|
|
||||||
assert mask[3, 0] # CLS sees prefix
|
|
||||||
assert mask[3, 3] # CLS sees itself
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Categorical metrics test
|
# Categorical metrics test
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user