multimodalart's picture
multimodalart HF Staff
Upload folder using huggingface_hub
6b3a024 verified
Raw
History Blame Contribute Delete
25.1 kB
# V-SPLADE
# Copyright (c) 2026-present NAVER Corp.
# Apache-2.0
"""
UnifiedRetriever -- V_SPLADE core model.
Combines:
VBert encoder + Sparse head (SPLADE) + BOW query encoder + Losses
Training objective (per forward call):
L_total = NCE(q, p, hn) + lambda_p * FLOPS(p) + lambda_cap * CaptionPushUp
The model expects a passage (image) and an optional caption; the query is
encoded as a Bag-of-Words vocabulary indicator. The sparse passage
representation is supervised by:
- NCE in-batch ranking against the query (and hard negatives, if any)
- FLOPS regularization to encourage sparsity
- Caption-gated token supervision: pull passage activations toward overlapping
caption-vocabulary tokens.
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
from dataclasses import dataclass
from typing import Optional
from models.encoder import build_encoder
from models.pooling import Pooling
from models.head import SparseHead
from models.query_encoder import (
build_query_encoder,
BOWQueryEncoder,
InferenceFreeQueryEncoder,
)
from models.losses import FLOPSLoss, NCELoss, CaptionPushUpLoss, ZipfianPushUpLoss # noqa: F401
def compute_logits(q, p, hn, temperature):
"""Compute in-batch negative logits and labels.
Args:
q: (B, V) query sparse reps
p: (B, V) passage sparse reps
hn: (B*num_hn, V) hard-negative passage reps, or None
temperature: scaling factor for the inner product
Returns:
logits: (B, B [+ B*num_hn]) score matrix
labels: (B,) target index for cross-entropy
"""
B = q.size(0)
device = q.device
all_p = torch.cat([p, hn], dim=0) if hn is not None else p
logits = (q @ all_p.t()) / temperature
logits = logits.clamp(min=-100, max=100) # prevent exp() overflow in cross_entropy
labels = torch.arange(B, device=device)
return logits, labels
# Default pooling for V_SPLADE (single backbone): max over sequence positions.
DEFAULT_POOLING = "max"
@dataclass
class RetrievalOutput:
"""Container for forward() outputs."""
loss: Optional[torch.Tensor] = None
rank_loss: Optional[torch.Tensor] = None
reg_loss: Optional[torch.Tensor] = None # total reg (passage FLOPS + caption FLOPS)
reg_loss_p: Optional[torch.Tensor] = None # passage FLOPS only
reg_loss_cap: Optional[torch.Tensor] = None # caption FLOPS only
cap_loss: Optional[torch.Tensor] = None # caption-gated token supervision loss
cap_sparse_rank_loss: Optional[torch.Tensor] = None # caption-gated ranking loss
# Sparsity / retrieval-cost diagnostics (no_grad, mean over batch).
train_p_nnz: Optional[torch.Tensor] = None # avg nonzero per passage
train_p_max: Optional[torch.Tensor] = None # avg max|p| per passage
train_flops_qd: Optional[torch.Tensor] = None # sum_v mean|q|_v * mean|p|_v
train_cap_bow_killed_pct: Optional[torch.Tensor] = None # % bow-active tokens zeroed by model
query_reps: Optional[torch.Tensor] = None
passage_reps: Optional[torch.Tensor] = None
hard_neg_reps: Optional[torch.Tensor] = None
debug_tokens: Optional[dict] = None
class UnifiedRetriever(nn.Module):
"""V_SPLADE retriever: VBert encoder + SPLADE sparse head + (BOW | Li-LSR) query encoder."""
def __init__(
self,
encoder_type: str = "vbert",
pooling_type: str = None,
head_type: str = "sparse",
query_encoder_type: str = "bow",
# Model paths
model_name: str = "",
lm_head_model: str = None,
# Training config
temperature: float = 1.0,
# Caption-gated token supervision
cap_weight: float = 0.0,
cap_sparse_rank_weight: float = 0.0,
cap_loss_mode: str = "logsigmoid_h",
overlap_type: str = "passage_mean",
p_mean_alpha: float = 1.0,
use_zipfian_pushup: bool = False,
push_focus_tau: float = 1.0,
# Regularization weights
reg_weight_p: float = 0.0,
reg_weight_cap: float = 0.0,
# SPLADE pooling
splade_pooling: str = "max",
# Encoder-specific kwargs
lm_head_lora_r: int = 32,
encoder_lora_r: int = 32,
lm_head_full: bool = False,
# Li-LSR query encoder kwargs
query_lsr_lora_r: int = 0,
query_lsr_activation: str = "relu",
# Misc
**kwargs,
):
super().__init__()
# V_SPLADE only supports the sparse head with a single (vbert) backbone.
assert encoder_type == "vbert", "V_SPLADE only supports the vbert encoder."
assert head_type == "sparse", "V_SPLADE only supports the sparse head."
if pooling_type is None:
pooling_type = DEFAULT_POOLING
self.encoder_type = encoder_type
self.head_type = head_type
self.query_encoder_type = query_encoder_type
self.splade_pooling = splade_pooling
# Build encoder.
encoder_kwargs = dict(
model_name=model_name,
lm_head_model=lm_head_model or model_name,
lm_head_lora_r=lm_head_lora_r,
encoder_lora_r=encoder_lora_r,
lm_head_full=lm_head_full,
**kwargs,
)
self.encoder = build_encoder(encoder_type, **encoder_kwargs)
# Build pooling and sparse head.
self.pooling = Pooling(pooling_type)
lm_head = self.encoder.get_lm_head()
self.head = SparseHead(lm_head, self.encoder.hidden_size)
self.vocab_size = self.encoder.vocab_size
self.hidden_size = self.encoder.hidden_size
# Build query encoder: BOW (cheapest) or Li-LSR (inference-free learned).
assert query_encoder_type in ("bow", "li_lsr"), (
"V_SPLADE only supports the BOW or Li-LSR query encoders."
)
if query_encoder_type == "li_lsr":
embed_layer = self.encoder.get_text_embeddings()
if embed_layer is not None:
embed_weight = embed_layer.weight
else:
# Fallback: use lm_head weights (tied with embed_tokens in causal LMs).
_lm_head = self.encoder.get_lm_head()
embed_weight = _lm_head.weight if _lm_head is not None else None
self.query_encoder = build_query_encoder(
"li_lsr",
vocab_size=self.vocab_size,
hidden_size=self.hidden_size,
embed_weight=embed_weight,
lora_r=query_lsr_lora_r,
activation=query_lsr_activation,
)
else:
self.query_encoder = build_query_encoder("bow", vocab_size=self.vocab_size)
# Loss / regularization config.
self.temperature = temperature
self.cap_weight = cap_weight
self.cap_sparse_rank_weight = cap_sparse_rank_weight
self.cap_loss_mode = cap_loss_mode
self.overlap_type = overlap_type
self.reg_weight_p = reg_weight_p
self.reg_weight_cap = reg_weight_cap
self.loss_fn = nn.CrossEntropyLoss()
self.reg_fn = FLOPSLoss()
if cap_weight > 0:
if use_zipfian_pushup:
self.cap_push_up = ZipfianPushUpLoss(
push_focus_tau=push_focus_tau,
p_mean_alpha=p_mean_alpha,
cap_loss_mode=cap_loss_mode,
)
else:
self.cap_push_up = CaptionPushUpLoss(
cap_loss_mode, p_mean_alpha=p_mean_alpha,
)
else:
self.cap_push_up = None
# Special-token mask for the VBert vocabulary. Prevents tokens such as
# [CLS]/[SEP]/[PAD]/[MASK] (and out-of-MLM image tokens) from activating
# in the sparse representation.
_special_ids = [
50280, # [UNK]
50281, # [CLS]
50282, # [SEP]
50283, # [PAD]
50284, # [MASK]
# Additional image/layout tokens beyond MLM head vocab (50368);
# kept here for safety in case downstream code changes vocab_size.
*range(50368, 50408),
]
mask = torch.ones(self.vocab_size)
for sid in _special_ids:
if sid < self.vocab_size:
mask[sid] = 0.0
self.register_buffer("special_token_mask", mask, persistent=False)
@classmethod
def from_hf_export(cls, hf_dir: str,
query_lsr_activation: str = "softplus",
dtype: torch.dtype = torch.bfloat16) -> "UnifiedRetriever":
"""Build an empty V-SPLADE retriever shell from a V-SPLADE HF export.
Only the module *structure* is created (random weights); call
:func:`models.load_hf_export` afterwards to populate every tensor
from the export's `model.safetensors` in one pass.
"""
from models.encoder import VBertEncoder
instance = cls.__new__(cls)
nn.Module.__init__(instance)
instance.encoder_type = "vbert"
instance.head_type = "sparse"
instance.query_encoder_type = "li_lsr"
instance.splade_pooling = "max"
instance.temperature = 1.0
instance.cap_weight = 0.0
instance.cap_sparse_rank_weight = 0.0
instance.cap_loss_mode = "logsigmoid_h"
instance.overlap_type = "passage_mean"
instance.reg_weight_p = 0.0
instance.reg_weight_cap = 0.0
instance.cap_push_up = None
instance.loss_fn = nn.CrossEntropyLoss()
instance.reg_fn = FLOPSLoss()
# Encoder + sparse head (shares the encoder's LM head via SparseHead).
instance.encoder = VBertEncoder.from_hf_export(hf_dir, dtype=dtype)
instance.vocab_size = instance.encoder.vocab_size
instance.hidden_size = instance.encoder.hidden_size
instance.pooling = Pooling("max")
instance.head = SparseHead(instance.encoder.get_lm_head(),
instance.encoder.hidden_size)
# Li-LSR inference-free query encoder (softplus activation, LoRA off).
embed_layer = instance.encoder.get_text_embeddings()
embed_weight = (embed_layer.weight if embed_layer is not None
else instance.encoder.get_lm_head().weight)
instance.query_encoder = build_query_encoder(
"li_lsr",
vocab_size=instance.vocab_size,
hidden_size=instance.hidden_size,
embed_weight=embed_weight,
lora_r=0,
activation=query_lsr_activation,
)
# Special-token mask (same as training).
_special_ids = [50280, 50281, 50282, 50283, 50284, *range(50368, 50408)]
mask = torch.ones(instance.vocab_size)
for sid in _special_ids:
if sid < instance.vocab_size:
mask[sid] = 0.0
instance.register_buffer("special_token_mask", mask, persistent=False)
for p in instance.parameters():
p.requires_grad = False
return instance.eval()
def gradient_checkpointing_enable(self, gradient_checkpointing_kwargs=None):
self.encoder.gradient_checkpointing_enable(gradient_checkpointing_kwargs)
# ---- Encoding helpers ----------------------------------------------------
def _encode_passage_sparse(self, **kwargs):
"""Encode passage -> (h_raw, w_sparse) via encoder + sparse head."""
hidden, mask = self.encoder.encode_passage(**kwargs)
return self._apply_sparse_head(hidden, mask)
def _encode_text_sparse(self, **kwargs):
"""Encode text (e.g. caption) -> (h_raw, w_sparse)."""
hidden, mask = self.encoder.encode_text(**kwargs)
return self._apply_sparse_head(hidden, mask)
def _apply_sparse_head(self, hidden_states, attention_mask):
"""SPLADE sparse head: LM head (per position) -> max/mean pool over sequence."""
scale = self.hidden_size ** -0.25
# max/mean pooling: apply LM head over all positions, then pool.
h = self.head.lm_head(hidden_states) * scale # (B, seq, vocab)
w = torch.log1p(torch.relu(h))
mask = attention_mask.unsqueeze(-1).to(w.dtype)
if self.splade_pooling == "max":
w_out = (w * mask).max(dim=1).values
h_masked = h * mask + (~mask.bool()) * (-1e9)
h_out = h_masked.max(dim=1).values
else: # mean
seq_len = mask.sum(dim=1, keepdim=True).clamp(min=1)
w_out = (w * mask).sum(dim=1) / seq_len.squeeze(1)
h_out = (h * mask).sum(dim=1) / seq_len.squeeze(1)
# Mask special tokens.
w_out = w_out * self.special_token_mask.to(w_out.dtype)
return h_out, w_out
def _encode_query(self, input_ids, attention_mask):
"""Encode query via BOW or Li-LSR (training: forward, eval: lookup)."""
if isinstance(self.query_encoder, InferenceFreeQueryEncoder):
if self.training:
q = self.query_encoder(input_ids, attention_mask)
else:
q = self.query_encoder.encode_with_lookup(input_ids, attention_mask)
else:
q = self.query_encoder(input_ids, attention_mask)
# Apply special-token mask to the query output.
if q.dim() == 2 and q.size(-1) == self.special_token_mask.size(0):
q = q * self.special_token_mask.to(q.dtype).to(q.device)
return q
def encode_query(self, input_ids, attention_mask) -> torch.Tensor:
"""Public API for query encoding."""
return self._encode_query(input_ids, attention_mask)
def encode_passage(
self, input_ids=None, attention_mask=None,
pixel_values=None, pixel_attention_mask=None,
) -> torch.Tensor:
"""Public API for passage encoding."""
kwargs = {}
if input_ids is not None:
kwargs["input_ids"] = input_ids
if attention_mask is not None:
kwargs["attention_mask"] = attention_mask
if pixel_values is not None:
kwargs["pixel_values"] = pixel_values
if pixel_attention_mask is not None:
kwargs["pixel_attention_mask"] = pixel_attention_mask
_, w = self._encode_passage_sparse(**kwargs)
return w
# ---- Forward -------------------------------------------------------------
def forward(
self,
query_input_ids, query_attention_mask,
passage_input_ids, passage_attention_mask,
passage_pixel_values=None, passage_pixel_attention_mask=None,
caption_input_ids=None, caption_attention_mask=None,
hard_neg_passage_input_ids=None, hard_neg_passage_attention_mask=None,
hard_neg_passage_pixel_values=None, hard_neg_passage_pixel_attention_mask=None,
**kwargs,
) -> RetrievalOutput:
"""V_SPLADE forward pass.
Computes NCE rank loss + FLOPS regularization + (optional) caption-gated token
loss. Multi-vector / self-distillation / dense / multi-backbone paths
are out of scope for this code release.
"""
has_caption = caption_input_ids is not None
use_cap = (
self.cap_sparse_rank_weight > 0
or self.cap_weight > 0
or self.reg_weight_cap > 0
) and has_caption
# ---- Query (BOW) ----
q_reps = self._encode_query(query_input_ids, query_attention_mask)
# ---- Positive passage ----
p_kwargs = dict(input_ids=passage_input_ids, attention_mask=passage_attention_mask)
if passage_pixel_values is not None:
p_kwargs["pixel_values"] = passage_pixel_values
if passage_pixel_attention_mask is not None:
p_kwargs["pixel_attention_mask"] = passage_pixel_attention_mask
p_h, p_reps = self._encode_passage_sparse(**p_kwargs)
# ---- Hard negatives (optional) ----
hn_reps = None
if hard_neg_passage_input_ids is not None:
if hard_neg_passage_pixel_values is not None:
# Image hard negatives.
hn_kwargs = dict(
input_ids=hard_neg_passage_input_ids,
attention_mask=hard_neg_passage_attention_mask,
pixel_values=hard_neg_passage_pixel_values,
pixel_attention_mask=hard_neg_passage_pixel_attention_mask,
)
_, hn_reps = self._encode_passage_sparse(**hn_kwargs)
else:
# Text hard negatives.
_, hn_reps = self._encode_text_sparse(
input_ids=hard_neg_passage_input_ids,
attention_mask=hard_neg_passage_attention_mask,
)
# ---- Caption encoding ----
cap_sparse = None
cap_sparse_raw = None # kept for diagnostics
cap_bow = None
if use_cap:
_, cap_sparse_raw = self._encode_text_sparse(
input_ids=caption_input_ids, attention_mask=caption_attention_mask,
)
# Use plain caption tokens for BOW if available (so that any instruction
# tokens added by a caption prompt are not counted in the BOW mask).
bow_ids = kwargs.get("caption_bow_input_ids", caption_input_ids)
bow_mask = kwargs.get("caption_bow_attention_mask", caption_attention_mask)
cap_bow = BOWQueryEncoder(self.vocab_size).to(q_reps.device)(bow_ids, bow_mask)
cap_bow = cap_bow * self.special_token_mask.to(cap_bow.dtype).to(cap_bow.device)
cap_sparse = cap_sparse_raw * cap_bow.to(cap_sparse_raw.dtype)
# ---- dtype alignment (BOW query is float32, passage is bf16) ----
if q_reps.dtype != p_reps.dtype:
q_reps = q_reps.to(p_reps.dtype)
# ---- Ranking loss (NCE) — cross-GPU gather for in-batch negatives ----
# Symmetric gather across DDP ranks: every gathered query sees
# ``world_size × B`` passages as negatives. The local rank's slice
# keeps gradient via ``all_gather_2d_with_grad``; other ranks are
# detached. Asymmetric gathers (only p) under-train the passage
# encoder because each p sees gradients from only ``B`` queries.
import torch.distributed as _dist_nce
if _dist_nce.is_initialized() and _dist_nce.get_world_size() > 1:
from models.losses import all_gather_2d_with_grad as _gather_nce
q_reps_g = _gather_nce(q_reps)
p_reps_g = _gather_nce(p_reps)
hn_reps_g = _gather_nce(hn_reps) if hn_reps is not None else None
logits, labels = compute_logits(
q_reps_g, p_reps_g, hn_reps_g, self.temperature,
)
else:
logits, labels = compute_logits(
q_reps, p_reps, hn_reps, self.temperature,
)
rank_loss = self.loss_fn(logits, labels)
# ---- FLOPS regularization ----
reg_loss_p = self.reg_weight_p * self.reg_fn(p_reps)
if hn_reps is not None:
reg_loss_p = reg_loss_p + self.reg_weight_p * self.reg_fn(hn_reps)
reg_loss_cap = q_reps.new_tensor(0.0)
if cap_sparse is not None:
reg_loss_cap = self.reg_weight_cap * self.reg_fn(cap_sparse)
reg_loss = reg_loss_p + reg_loss_cap
# ---- Caption loss ----
cap_loss = q_reps.new_tensor(0.0)
cap_sparse_rank_loss = q_reps.new_tensor(0.0)
if use_cap and self.cap_sparse_rank_weight > 0 and cap_sparse is not None:
# Cross-GPU gather for caption-side ranking too (parallel to the
# passage-side NCE above): caption push-up still uses LOCAL p_reps.
import torch.distributed as _dist_capr
if _dist_capr.is_initialized() and _dist_capr.get_world_size() > 1:
from models.losses import all_gather_2d_with_grad as _gather_capr
q_reps_capr = _gather_capr(q_reps)
cap_sparse_capr = _gather_capr(cap_sparse)
else:
q_reps_capr = q_reps
cap_sparse_capr = cap_sparse
cap_s_logits, cap_s_labels = compute_logits(
q_reps_capr, cap_sparse_capr, None, self.temperature,
)
cap_sparse_rank_loss = self.loss_fn(cap_s_logits, cap_s_labels) * self.cap_sparse_rank_weight
if use_cap and self.cap_weight > 0 and cap_sparse is not None and self.cap_push_up is not None:
cap_loss = self.cap_push_up(p_h, p_reps, cap_sparse, self.overlap_type) * self.cap_weight
# ---- Total loss ----
loss = rank_loss + reg_loss + cap_loss + cap_sparse_rank_loss
# ---- Diagnostics (no_grad) ----
debug_tokens = None
train_p_nnz = train_p_max = train_flops_qd = None
train_cap_bow_killed_pct = None
with torch.no_grad():
def _topk_lowk(vec, k=5):
"""Return (top-k values, indices, low-k nonzero values, indices)."""
nz_mask = vec > 0
nz_n = int(nz_mask.sum().item())
if nz_n == 0:
return (torch.empty(0), torch.empty(0, dtype=torch.long),
torch.empty(0), torch.empty(0, dtype=torch.long))
t_k = min(k, vec.size(0))
l_k = min(k, nz_n)
top_v, top_i = vec.topk(t_k)
masked = torch.where(nz_mask, vec, torch.full_like(vec, float('inf')))
low_vals_asc, low_idx_asc = masked.topk(l_k, largest=False)
return top_v.cpu(), top_i.cpu(), low_vals_asc.cpu(), low_idx_asc.cpu()
q0, p0 = q_reps[0], p_reps[0]
q_top_v, q_top_i, q_low_v, q_low_i = _topk_lowk(q0)
p_top_v, p_top_i, p_low_v, p_low_i = _topk_lowk(p0)
debug_tokens = {
"q_indices": q_top_i, "q_values": q_top_v,
"q_low_indices": q_low_i, "q_low_values": q_low_v,
"p_indices": p_top_i, "p_values": p_top_v,
"p_low_indices": p_low_i, "p_low_values": p_low_v,
"q_input_ids": query_input_ids[0].cpu(),
"q_attention_mask": query_attention_mask[0].cpu(),
"q_nonzero": (q0 > 0).sum().item(),
"p_nonzero": (p0 > 0).sum().item(),
}
# Batch sparsity / FLOPs diagnostics.
train_p_nnz = (p_reps > 0).float().sum(-1).mean().detach()
train_p_max = p_reps.max(dim=-1).values.mean().detach()
train_flops_qd = (q_reps.abs().mean(dim=0) * p_reps.abs().mean(dim=0)).sum().detach()
if cap_sparse is not None:
cap0 = cap_sparse[0]
c_top_v, c_top_i, c_low_v, c_low_i = _topk_lowk(cap0)
debug_tokens["cap_indices"] = c_top_i
debug_tokens["cap_values"] = c_top_v
debug_tokens["cap_low_indices"] = c_low_i
debug_tokens["cap_low_values"] = c_low_v
debug_tokens["cap_nonzero"] = (cap0 > 0).sum().item()
# Push-up overlap top-5 / low-5.
p_w_d = p0.detach()
p_mean_d = p_w_d.mean(dim=-1, keepdim=True)
alpha = self.cap_push_up.p_mean_alpha if self.cap_push_up is not None else 1.0
overlap = (p_w_d + alpha * p_mean_d) * cap0.detach()
overlap_norm = overlap / (overlap.sum() + 1e-8)
ov_top_v, ov_top_i, ov_low_v, ov_low_i = _topk_lowk(overlap_norm)
debug_tokens["pushup_indices"] = ov_top_i
debug_tokens["pushup_values"] = ov_top_v
debug_tokens["pushup_low_indices"] = ov_low_i
debug_tokens["pushup_low_values"] = ov_low_v
# cap_bow_killed_pct: among bow-active caption tokens, what fraction
# did the SPLADE head zero out? (Higher = model treats more cap
# tokens as noise.)
if cap_bow is not None and cap_sparse_raw is not None:
bow_active = (cap_bow > 0).float()
raw_active = (cap_sparse_raw > 0).float()
killed = bow_active * (1.0 - raw_active)
denom = bow_active.sum(-1).clamp(min=1.0)
train_cap_bow_killed_pct = (killed.sum(-1) / denom).mean().detach()
return RetrievalOutput(
loss=loss,
rank_loss=rank_loss,
reg_loss=reg_loss,
reg_loss_p=reg_loss_p,
reg_loss_cap=reg_loss_cap,
cap_loss=cap_loss,
cap_sparse_rank_loss=cap_sparse_rank_loss,
train_p_nnz=train_p_nnz,
train_p_max=train_p_max,
train_flops_qd=train_flops_qd,
train_cap_bow_killed_pct=train_cap_bow_killed_pct,
query_reps=q_reps,
passage_reps=p_reps,
hard_neg_reps=hn_reps,
debug_tokens=debug_tokens,
)