multimodalart HF Staff commited on
Commit
6b3a024
Β·
verified Β·
1 Parent(s): f8ec7cd

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ sample_page.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,24 @@
1
  ---
2
- title: V Splade Document Retrieval
3
- emoji: πŸ†
4
- colorFrom: yellow
5
- colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.19.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: V-SPLADE Quality Document Retrieval
3
+ emoji: πŸ”
4
+ colorFrom: pink
5
+ colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.19.0
 
8
  app_file: app.py
9
+ short_description: Visual document retrieval via sparse lexical vectors
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 30m
12
  ---
13
 
14
+ # V-SPLADE Quality β€” Visual Document Retrieval
15
+
16
+ This Space demonstrates **V-SPLADE Quality** ([naver/v-splade-quality](https://huggingface.co/naver/v-splade-quality)),
17
+ a 0.25B inference-free sparse retriever for visual document retrieval.
18
+
19
+ Upload a document page image and enter text queries to see:
20
+ - The **top activated vocabulary tokens** β€” the sparse lexical representation of the image
21
+ - **Similarity scores** between each query and the document, with the top contributing tokens
22
+
23
+ V-SPLADE encodes document pages directly into sparse vocabulary vectors without OCR or captioning,
24
+ enabling retrieval 20Γ— faster than caption-based pipelines.
app.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """V-SPLADE Quality β€” Visual Document Retrieval Demo.
2
+
3
+ Upload a document page image and enter text queries to see:
4
+ 1. The top activated vocabulary tokens (sparse representation)
5
+ 2. Similarity scores between each query and the document image
6
+ """
7
+
8
+ import os
9
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
10
+
11
+ import spaces # MUST be first (before torch)
12
+ import torch
13
+ import gradio as gr
14
+ from PIL import Image
15
+ from transformers import AutoProcessor
16
+
17
+ # Make the v-splade train/models package importable
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ _TRAIN_DIR = Path(__file__).resolve().parent / "train"
22
+ if str(_TRAIN_DIR) not in sys.path:
23
+ sys.path.insert(0, str(_TRAIN_DIR))
24
+
25
+ from models import build_model
26
+
27
+ MODEL_ID = "naver/v-splade-quality"
28
+
29
+
30
+ # ── Module-scope model load (eager, as required by ZeroGPU) ──────────────────
31
+ processor = AutoProcessor.from_pretrained(MODEL_ID)
32
+ model = build_model(MODEL_ID, mode="inference_only", dtype=torch.bfloat16).to("cuda")
33
+ # Pre-build the Li-LSR vocab lookup table so the first query is fast.
34
+ model.query_encoder.to("cuda", dtype=torch.bfloat16)
35
+ model.query_encoder.build_lookup_table()
36
+ tokenizer = processor.tokenizer
37
+
38
+
39
+ @spaces.GPU(duration=60)
40
+ def retrieve(image: Image.Image, queries: str, topk: int = 15) -> tuple:
41
+ """Encode a document image and score it against text queries.
42
+
43
+ Args:
44
+ image: A document page image (PNG/JPEG).
45
+ queries: Newline-separated text queries to score against the image.
46
+ topk: Number of top activated vocabulary tokens to display.
47
+
48
+ Returns:
49
+ A tuple of (top tokens HTML, query scores HTML, sparse stats text).
50
+ """
51
+ if image is None:
52
+ return "<p style='color:red'>Please upload a document image.</p>", "", ""
53
+ if not queries.strip():
54
+ return "", "<p style='color:red'>Please enter at least one query.</p>", ""
55
+
56
+ # ── Encode image β†’ sparse embedding ──────────────────────────────────
57
+ chat = [{"role": "user",
58
+ "content": [{"type": "image"}, {"type": "text", "text": ""}]}]
59
+ prompt = processor.apply_chat_template(chat, add_generation_prompt=True)
60
+ inputs = processor(images=[image.convert("RGB")], text=prompt,
61
+ return_tensors="pt")
62
+ inputs = {k: v.to("cuda") if torch.is_tensor(v) else v
63
+ for k, v in inputs.items()}
64
+ if "pixel_values" in inputs:
65
+ inputs["pixel_values"] = inputs["pixel_values"].to(torch.bfloat16)
66
+ doc_vec = model.encode_passage(**inputs)[0].cpu()
67
+
68
+ nnz = int((doc_vec > 0).sum())
69
+ max_val = float(doc_vec.max())
70
+
71
+ # ── Top-k activated vocabulary tokens ────────────────────────────────
72
+ top_w, top_ids = torch.topk(doc_vec.float(), k=min(topk, doc_vec.shape[0]))
73
+ tokens_html = "<table style='width:100%; border-collapse:collapse;'>"
74
+ tokens_html += "<tr style='background:#f0f0f0;'><th style='padding:6px; text-align:left;'>Rank</th><th style='padding:6px; text-align:left;'>Token</th><th style='padding:6px; text-align:right;'>Weight</th></tr>"
75
+ for rank, (idx, w) in enumerate(zip(top_ids, top_w), 1):
76
+ tok_str = tokenizer.decode([int(idx)]).strip() or f"<id:{int(idx)}>"
77
+ bar_width = max(2, int(float(w) / max_val * 200))
78
+ tokens_html += (
79
+ f"<tr><td style='padding:4px;'>{rank}</td>"
80
+ f"<td style='padding:4px; font-family:monospace;'>{tok_str}</td>"
81
+ f"<td style='padding:4px; text-align:right;'>"
82
+ f"<div style='display:flex; align-items:center; gap:8px;'>"
83
+ f"<div style='background:linear-gradient(90deg, #ff6b9d, #8b5cf6); width:{bar_width}px; height:16px; border-radius:3px;'></div>"
84
+ f"<span>{float(w):.4f}</span></div></td></tr>"
85
+ )
86
+ tokens_html += "</table>"
87
+
88
+ # ── Query similarity scores ──────────────────────────────────────────
89
+ query_list = [q.strip() for q in queries.strip().split("\n") if q.strip()]
90
+ scores_html = "<table style='width:100%; border-collapse:collapse;'>"
91
+ scores_html += "<tr style='background:#f0f0f0;'><th style='padding:6px; text-align:left;'>Query</th><th style='padding:6px; text-align:right;'>Score</th><th style='padding:6px;'>Top matching tokens</th></tr>"
92
+
93
+ max_score = 0.0
94
+ results = []
95
+ for q in query_list:
96
+ tok = tokenizer(q, return_tensors="pt", add_special_tokens=False)
97
+ q_vec = model.encode_query(
98
+ tok["input_ids"].to("cuda"),
99
+ tok["attention_mask"].to("cuda"),
100
+ )[0].cpu()
101
+ score = float((q_vec.float() * doc_vec.float()).sum())
102
+ max_score = max(max_score, abs(score))
103
+ # Top contributing tokens
104
+ contrib = (q_vec.float() * doc_vec.float()).cpu()
105
+ top_cw, top_cids = torch.topk(contrib, k=5)
106
+ contribs = ", ".join(
107
+ f"{tokenizer.decode([int(i)]).strip()}({float(w):.3f})"
108
+ for i, w in zip(top_cids, top_cw) if float(w) > 0
109
+ )
110
+ results.append((q, score, contribs))
111
+
112
+ for q, score, contribs in results:
113
+ bar_width = max(2, int(abs(score) / max(max_score, 1e-6) * 200))
114
+ color = "#22c55e" if score > 0 else "#ef4444"
115
+ scores_html += (
116
+ f"<tr><td style='padding:4px;'>{q}</td>"
117
+ f"<td style='padding:4px; text-align:right;'><b>{score:.4f}</b></td>"
118
+ f"<td style='padding:4px; font-size:0.9em; font-family:monospace;'>{contribs or 'β€”'}</td></tr>"
119
+ )
120
+ scores_html += "</table>"
121
+
122
+ stats = (
123
+ f"Sparse vector: vocab_size={doc_vec.shape[0]}, nnz={nnz} "
124
+ f"({nnz/doc_vec.shape[0]*100:.1f}% active), max={max_val:.4f}"
125
+ )
126
+
127
+ return tokens_html, scores_html, stats
128
+
129
+
130
+ CSS = """
131
+ #col-container { max-width: 1100px; margin: 0 auto; }
132
+ .dark .gradio-container { color: var(--body-text-color); }
133
+ """
134
+
135
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
136
+ gr.Markdown(
137
+ "# πŸ” V-SPLADE Quality β€” Visual Document Retrieval\n"
138
+ "Upload a document page image and enter text queries to see the "
139
+ "sparse lexical representation and similarity scores in real-time. "
140
+ "[Model card](https://huggingface.co/naver/v-splade-quality) Β· "
141
+ "[Paper](https://arxiv.org/abs/2605.30917) Β· "
142
+ "[Code](https://github.com/naver/v-splade)"
143
+ )
144
+
145
+ with gr.Column(elem_id="col-container"):
146
+ with gr.Row():
147
+ with gr.Column(scale=1):
148
+ image_input = gr.Image(
149
+ label="Document page", type="pil", height=400
150
+ )
151
+ queries_input = gr.Textbox(
152
+ label="Queries (one per line)",
153
+ value="financial summary\nmarket share\ncarbon emissions",
154
+ lines=5,
155
+ placeholder="Enter one query per line…",
156
+ )
157
+ run_btn = gr.Button("Retrieve", variant="primary")
158
+ with gr.Column(scale=1):
159
+ stats_output = gr.Textbox(label="Sparse vector stats", interactive=False)
160
+ tokens_output = gr.HTML(label="Top activated vocabulary tokens")
161
+ scores_output = gr.HTML(label="Query similarity scores")
162
+
163
+ with gr.Accordion("Advanced settings", open=False):
164
+ topk_slider = gr.Slider(
165
+ minimum=5, maximum=50, value=15, step=1,
166
+ label="Top-k tokens to display",
167
+ )
168
+
169
+ gr.Examples(
170
+ examples=[
171
+ ["sample_page.png", "financial summary\nmarket share\ncarbon emissions", 15],
172
+ ["sample_page.png", "annual revenue 2024\nboard of directors", 15],
173
+ ["sample_page.png", "risk factors\nstrategic priorities", 20],
174
+ ],
175
+ inputs=[image_input, queries_input, topk_slider],
176
+ outputs=[tokens_output, scores_output, stats_output],
177
+ fn=retrieve,
178
+ cache_examples=True,
179
+ cache_mode="lazy",
180
+ )
181
+
182
+ run_btn.click(
183
+ fn=retrieve,
184
+ inputs=[image_input, queries_input, topk_slider],
185
+ outputs=[tokens_output, scores_output, stats_output],
186
+ api_name="retrieve",
187
+ )
188
+
189
+ if __name__ == "__main__":
190
+ demo.launch(mcp_server=True)
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch==2.8.0
2
+ torchvision
3
+ transformers==4.57.6
4
+ colpali-engine==0.3.13
5
+ peft
6
+ accelerate
7
+ safetensors
8
+ Pillow
9
+ numpy
10
+ scipy
11
+ einops
12
+ https://huggingface.co/datasets/multimodalart/zerogpu-blackwell-wheels/resolve/main/wheels/pt28-cu128-cp312/flash_attn-2.8.3-cp312-cp312-linux_x86_64.whl
sample_page.png ADDED

Git LFS Details

  • SHA256: 88d8ef02be929b65903fb296127246b80a25e7d84a6d999d3bccd916c87e8e20
  • Pointer size: 132 Bytes
  • Size of remote file: 1.91 MB
train/models/__init__.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # V-SPLADE
2
+ # Copyright (c) 2026-present NAVER Corp.
3
+ # Apache-2.0
4
+
5
+ """
6
+ V_SPLADE modular components.
7
+
8
+ A V_SPLADE retriever is composed of:
9
+ Encoder + Pooling + SparseHead + (BOW | Li-LSR) QueryEncoder + Losses
10
+ """
11
+
12
+ from pathlib import Path
13
+
14
+ import torch
15
+
16
+ from models.model import UnifiedRetriever, RetrievalOutput, compute_logits
17
+ from models.encoder import EncoderType, build_encoder
18
+ from models.pooling import PoolingType, Pooling
19
+ from models.head import HeadType, build_head, SparseHead
20
+ from models.query_encoder import (
21
+ QueryEncoderType,
22
+ build_query_encoder,
23
+ BOWQueryEncoder,
24
+ InferenceFreeQueryEncoder,
25
+ )
26
+ from models.losses import FLOPSLoss, NCELoss, CaptionPushUpLoss
27
+
28
+
29
+ DEFAULT_POOLING = {
30
+ "vbert": "max",
31
+ }
32
+
33
+
34
+ def build_model(
35
+ path: str = None,
36
+ mode: str = "inference_only",
37
+ *,
38
+ encoder_type: str = "vbert",
39
+ head_type: str = "sparse",
40
+ query_encoder_type: str = "li_lsr",
41
+ pooling_type: str = None,
42
+ query_lsr_lora_r: int = 0,
43
+ query_lsr_activation: str = "softplus",
44
+ dtype: torch.dtype = torch.bfloat16,
45
+ **kwargs,
46
+ ) -> UnifiedRetriever:
47
+ """Factory: build a V-SPLADE retriever in one of two modes.
48
+
49
+ ``mode='inference_only'`` (default):
50
+ ``path`` is a V-SPLADE HF export directory containing
51
+ ``model.safetensors`` + ``config.json``. The retriever is constructed
52
+ as an empty shell and every weight (backbone + SPLADE head + Li-LSR
53
+ query head) is dispatched from the export in a single pass. No base
54
+ model download, no LoRA wrapping.
55
+
56
+ ``mode='from_scratch'``:
57
+ ``path`` is the base BiModernVBert backbone directory (e.g. the
58
+ canonical ``ModernVBERT/modernvbert`` checkpoint). The retriever is
59
+ built for training β€” encoder/LM-head LoRA, fresh query head,
60
+ loss/regularizer hooks. Extra ``**kwargs`` are forwarded to
61
+ :class:`UnifiedRetriever`.
62
+ """
63
+ if mode == "inference_only":
64
+ if path is None:
65
+ raise ValueError("inference_only mode requires path= to the HF export dir")
66
+ model = UnifiedRetriever.from_hf_export(
67
+ path,
68
+ query_lsr_activation=query_lsr_activation,
69
+ dtype=dtype,
70
+ )
71
+ load_hf_export(model, path, dtype=dtype)
72
+ return model
73
+
74
+ if mode == "from_scratch":
75
+ if pooling_type is None:
76
+ pooling_type = DEFAULT_POOLING.get(encoder_type, "max")
77
+ if path is not None:
78
+ kwargs.setdefault("model_name", path)
79
+ return UnifiedRetriever(
80
+ encoder_type=encoder_type,
81
+ pooling_type=pooling_type,
82
+ head_type=head_type,
83
+ query_encoder_type=query_encoder_type,
84
+ query_lsr_lora_r=query_lsr_lora_r,
85
+ query_lsr_activation=query_lsr_activation,
86
+ **kwargs,
87
+ )
88
+
89
+ raise ValueError(f"Unknown mode: {mode!r}. Choose 'inference_only' or 'from_scratch'.")
90
+
91
+
92
+ def _resolve_export_file(hf_dir: str, filename: str) -> str:
93
+ """Resolve a file from a V-SPLADE HF export given as a local dir or Hub id.
94
+
95
+ Returns a local filesystem path: the file under ``hf_dir`` if it exists,
96
+ otherwise the result of downloading ``filename`` from the Hub repo
97
+ ``hf_dir`` (cached by huggingface_hub).
98
+ """
99
+ local = Path(hf_dir) / filename
100
+ if local.is_file():
101
+ return str(local)
102
+ from huggingface_hub import hf_hub_download
103
+ return hf_hub_download(repo_id=str(hf_dir), filename=filename)
104
+
105
+
106
+ def load_hf_export(model: UnifiedRetriever, hf_dir: str,
107
+ dtype: torch.dtype = torch.bfloat16) -> None:
108
+ """Load a V-SPLADE HF export ``model.safetensors`` into ``model``.
109
+
110
+ Dispatches the export's three logical slices to the right sub-modules
111
+ by stripping the training-wrapper prefix:
112
+
113
+ encoder.encoder.model.* -> model.encoder.encoder.model.*
114
+ encoder.mlm_head.* -> model.encoder.mlm_head.*
115
+ query_encoder.* -> model.query_encoder.*
116
+
117
+ ``hf_dir`` may be a local directory or a HuggingFace Hub repo id; in the
118
+ latter case ``model.safetensors`` is downloaded automatically.
119
+
120
+ Raises if any safetensors tensor is not consumed by these three slices.
121
+ """
122
+ from safetensors.torch import load_file
123
+
124
+ full_sd = load_file(_resolve_export_file(hf_dir, "model.safetensors"))
125
+
126
+ dispatch = [
127
+ (model.encoder, "encoder."),
128
+ (model.query_encoder, "query_encoder."),
129
+ ]
130
+ consumed = set()
131
+ for module, prefix in dispatch:
132
+ slice_sd = {k[len(prefix):]: v.to(dtype)
133
+ for k, v in full_sd.items() if k.startswith(prefix)}
134
+ module.load_state_dict(slice_sd, strict=False)
135
+ consumed.update(prefix + k for k in slice_sd)
136
+
137
+ leftover = set(full_sd) - consumed
138
+ if leftover:
139
+ raise RuntimeError(
140
+ f"{len(leftover)} tensor(s) in {hf_dir}/model.safetensors were not "
141
+ f"dispatched to any sub-module. First few: {sorted(leftover)[:3]}"
142
+ )
train/models/convert.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # V-SPLADE
2
+ # Copyright (c) 2026-present NAVER Corp.
3
+ # Apache-2.0
4
+
5
+ """
6
+ Backbone conversion: upstream ModernVBERT -> V-SPLADE-compatible layout.
7
+
8
+ The public ``ModernVBERT/modernvbert`` checkpoint stores its embeddings/LM-head
9
+ in a layout the decoupled-embedding model can't load (combined 50408 embedding,
10
+ plain LM head, double-nested vision keys, no decoupled-vocab config). This
11
+ module re-packages it.
12
+
13
+ It is used two ways:
14
+ * ``ensure_compatible_backbone(ref)`` β€” called automatically by the training
15
+ loader; converts on the fly and caches, so ``from_scratch`` "just works"
16
+ with the upstream Hub id.
17
+ * ``scripts/convert_modernvbert_backbone.py`` β€” a thin CLI over the same code.
18
+
19
+ Verified transform (every tensor reproduced bit-identically from upstream):
20
+
21
+ tok_embeddings.weight <- upstream[:50368]
22
+ tok_embeddings.additional_embedding.weight <- upstream[50368:50408]
23
+ lm_head.decoder.weight / .bias <- upstream lm_head.weight/bias[:50368]
24
+ additional_fc.weight <- upstream lm_head.weight[50368:50408]
25
+ lm_head.head.dense.weight <- upstream projection_head.dense.weight
26
+ lm_head.head.norm.weight <- upstream projection_head.norm.weight
27
+ model.vision_model.X <- upstream model.vision_model.vision_model.X
28
+ model.connector.modality_projection.proj.weight <- upstream ...modality_projection.weight
29
+ (all other model.text_model.* keys copied unchanged)
30
+ """
31
+
32
+ import json
33
+ import os
34
+ import shutil
35
+ from pathlib import Path
36
+
37
+ import torch
38
+ from safetensors.torch import load_file, save_file
39
+
40
+ MAIN_VOCAB = 50368
41
+ FULL_VOCAB = 50408
42
+ ADDITIONAL_VOCAB = FULL_VOCAB - MAIN_VOCAB # 40
43
+
44
+ UPSTREAM_REPO = "ModernVBERT/modernvbert"
45
+ TOKENIZER_REPO = "jhu-clsp/ettin-encoder-150m"
46
+
47
+ PREPROCESSOR_CONFIG = {
48
+ "do_convert_rgb": True, "do_image_splitting": True, "do_normalize": True,
49
+ "do_pad": True, "do_rescale": True, "do_resize": True,
50
+ "image_mean": [0.5, 0.5, 0.5], "image_std": [0.5, 0.5, 0.5],
51
+ "image_processor_type": "Idefics3ImageProcessor",
52
+ "processor_class": "Idefics3Processor",
53
+ "max_image_size": {"longest_edge": 512}, "resample": 1,
54
+ "rescale_factor": 0.00392156862745098, "size": {"longest_edge": 2048},
55
+ }
56
+ PROCESSOR_CONFIG = {"image_seq_len": 64, "processor_class": "Idefics3Processor"}
57
+
58
+
59
+ # ──────────────────────────────────────────────────────────────────────────────
60
+ # Core transform
61
+ # ──────────────────────────────────────────────────────────────────────────────
62
+ def transform_state_dict(u: dict) -> dict:
63
+ """Apply the verified upstream -> V-SPLADE backbone weight recipe."""
64
+ out = {}
65
+ tok = u["model.text_model.embeddings.tok_embeddings.weight"]
66
+ assert tok.shape[0] == FULL_VOCAB, f"unexpected embedding rows: {tuple(tok.shape)}"
67
+ out["model.text_model.embeddings.tok_embeddings.weight"] = tok[:MAIN_VOCAB].clone()
68
+ out["model.text_model.embeddings.tok_embeddings.additional_embedding.weight"] = \
69
+ tok[MAIN_VOCAB:FULL_VOCAB].clone()
70
+
71
+ lw, lb = u["lm_head.weight"], u["lm_head.bias"]
72
+ out["lm_head.decoder.weight"] = lw[:MAIN_VOCAB].clone()
73
+ out["lm_head.decoder.bias"] = lb[:MAIN_VOCAB].clone()
74
+ out["additional_fc.weight"] = lw[MAIN_VOCAB:FULL_VOCAB].clone()
75
+
76
+ out["lm_head.head.dense.weight"] = u["projection_head.dense.weight"].clone()
77
+ out["lm_head.head.norm.weight"] = u["projection_head.norm.weight"].clone()
78
+
79
+ out["model.connector.modality_projection.proj.weight"] = \
80
+ u["model.connector.modality_projection.weight"].clone()
81
+
82
+ vp = "model.vision_model.vision_model."
83
+ for k, v in u.items():
84
+ if k.startswith(vp):
85
+ out["model.vision_model." + k[len(vp):]] = v.clone()
86
+
87
+ for k, v in u.items():
88
+ if k.startswith("model.text_model.") and "tok_embeddings" not in k:
89
+ out[k] = v.clone()
90
+ return out
91
+
92
+
93
+ def build_config(u_cfg: dict) -> dict:
94
+ """Patch the upstream config into the decoupled-embedding V-SPLADE layout."""
95
+ cfg = json.loads(json.dumps(u_cfg))
96
+ cfg["vocab_size"] = MAIN_VOCAB
97
+ cfg["additional_vocab_size"] = ADDITIONAL_VOCAB
98
+ cfg["freeze_config"] = {
99
+ "freeze_lm_head": True, "freeze_text_layers": True, "freeze_vision_layers": True,
100
+ }
101
+ cfg.setdefault("architectures", ["BiModernVBert"])
102
+ if "text_config" in cfg:
103
+ cfg["text_config"]["vocab_size"] = MAIN_VOCAB
104
+ return cfg
105
+
106
+
107
+ def is_compatible_config(cfg: dict) -> bool:
108
+ """True if the config already uses the decoupled-embedding V-SPLADE layout."""
109
+ return cfg.get("freeze_config") is not None and cfg.get("additional_vocab_size") is not None
110
+
111
+
112
+ # ─��────────────────────────────────────────────────────────────────────────────
113
+ # Conversion + auto-ensure
114
+ # ──────────────────────────────────────────────────────────────────────────────
115
+ def _load_config(ref: str) -> dict:
116
+ if os.path.isdir(ref):
117
+ return json.load(open(os.path.join(ref, "config.json")))
118
+ from huggingface_hub import hf_hub_download
119
+ return json.load(open(hf_hub_download(ref, "config.json")))
120
+
121
+
122
+ def convert_backbone(ref: str, out_dir, tokenizer_repo: str = TOKENIZER_REPO,
123
+ with_tokenizer: bool = True) -> str:
124
+ """Convert backbone ``ref`` (Hub id or local dir) into ``out_dir``. Returns out_dir."""
125
+ out = Path(out_dir); out.mkdir(parents=True, exist_ok=True)
126
+
127
+ if os.path.isdir(ref):
128
+ sd_path = os.path.join(ref, "model.safetensors")
129
+ cfg = json.load(open(os.path.join(ref, "config.json")))
130
+ else:
131
+ from huggingface_hub import hf_hub_download
132
+ sd_path = hf_hub_download(ref, "model.safetensors")
133
+ cfg = json.load(open(hf_hub_download(ref, "config.json")))
134
+
135
+ save_file(transform_state_dict(load_file(sd_path)),
136
+ str(out / "model.safetensors"), metadata={"format": "pt"})
137
+ json.dump(build_config(cfg), open(out / "config.json", "w"), indent=2)
138
+
139
+ if with_tokenizer:
140
+ from huggingface_hub import hf_hub_download
141
+ for fn in ["tokenizer.json", "tokenizer_config.json", "special_tokens_map.json"]:
142
+ try:
143
+ shutil.copy2(hf_hub_download(tokenizer_repo, fn), out / fn)
144
+ except Exception:
145
+ pass
146
+ json.dump(PREPROCESSOR_CONFIG, open(out / "preprocessor_config.json", "w"), indent=2)
147
+ json.dump(PROCESSOR_CONFIG, open(out / "processor_config.json", "w"), indent=2)
148
+ return str(out)
149
+
150
+
151
+ def _cache_root() -> Path:
152
+ root = os.environ.get("VSPLADE_BACKBONE_CACHE")
153
+ if root:
154
+ return Path(root)
155
+ return Path.home() / ".cache" / "v-splade" / "backbones"
156
+
157
+
158
+ def ensure_compatible_backbone(ref: str, tokenizer_repo: str = TOKENIZER_REPO,
159
+ verbose: bool = True) -> str:
160
+ """Return a local path to a V-SPLADE-compatible backbone for ``ref``.
161
+
162
+ If ``ref`` already uses the decoupled layout, it is returned unchanged
163
+ (``from_pretrained`` will download a Hub id as usual). Otherwise the upstream
164
+ checkpoint is converted once into a cache directory and that path is returned,
165
+ so ``from_scratch`` training works directly from the raw upstream Hub id.
166
+ """
167
+ try:
168
+ cfg = _load_config(ref)
169
+ except Exception:
170
+ return ref # can't introspect (offline/unknown) β€” let from_pretrained handle it
171
+ if is_compatible_config(cfg):
172
+ return ref
173
+
174
+ out = _cache_root() / ref.replace("/", "__")
175
+ if (out / "model.safetensors").is_file() and (out / "config.json").is_file():
176
+ if verbose:
177
+ print(f"[convert] using cached converted backbone: {out}")
178
+ return str(out)
179
+ if verbose:
180
+ print(f"[convert] '{ref}' is an upstream-layout backbone; "
181
+ f"converting once -> {out}")
182
+ return convert_backbone(ref, out, tokenizer_repo=tokenizer_repo)
183
+
184
+
185
+ # ──────────────────────────────────────────────────────────────────────────────
186
+ # Double-check (used by the CLI)
187
+ # ──────────────────────────────────────────────────────────────────────────────
188
+ def double_check(out_dir, ref_dir) -> bool:
189
+ out_dir, ref_dir = Path(out_dir), Path(ref_dir)
190
+ print(f"\n[verify] comparing {out_dir} vs reference {ref_dir}")
191
+ o = load_file(out_dir / "model.safetensors")
192
+ r = load_file(ref_dir / "model.safetensors")
193
+ ok = True
194
+ if set(o) != set(r):
195
+ ok = False
196
+ print(f" [FAIL] key sets differ "
197
+ f"(only_out={sorted(set(o)-set(r))[:3]} only_ref={sorted(set(r)-set(o))[:3]})")
198
+ else:
199
+ print(f" [ok] key sets match ({len(o)} tensors)")
200
+ mismatched = [k for k in set(o) & set(r)
201
+ if o[k].shape != r[k].shape or not torch.equal(o[k].float(), r[k].float())]
202
+ if mismatched:
203
+ ok = False
204
+ print(f" [FAIL] {len(mismatched)} tensor(s) differ, e.g. {mismatched[:5]}")
205
+ else:
206
+ print(f" [ok] all {len(set(o) & set(r))} shared tensors bit-identical")
207
+ oc, rc = json.load(open(out_dir / "config.json")), json.load(open(ref_dir / "config.json"))
208
+ for f in ["vocab_size", "additional_vocab_size", "freeze_config"]:
209
+ if oc.get(f) != rc.get(f):
210
+ ok = False; print(f" [FAIL] config.{f}: {oc.get(f)} != {rc.get(f)}")
211
+ else:
212
+ print(f" [ok] config.{f} == {oc.get(f)}")
213
+ print(f"\n[verify] {'PASSED' if ok else 'FAILED'}.")
214
+ return ok
train/models/encoder.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # V-SPLADE
2
+ # Copyright (c) 2026-present NAVER Corp.
3
+ # Apache-2.0
4
+
5
+ """
6
+ Encoder Layer β€” backbone model that produces hidden states.
7
+
8
+ V_SPLADE uses the VBert (BiModernVBERT) encoder as its sole backbone.
9
+ The encoder exposes a unified API:
10
+ encode_passage(inputs) -> (hidden_states, attention_mask)
11
+ encode_text(inputs) -> (hidden_states, attention_mask)
12
+ """
13
+
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+ from enum import Enum
18
+ from typing import Optional, Tuple
19
+
20
+
21
+ class EncoderType(Enum):
22
+ VBERT = "vbert"
23
+
24
+
25
+ # --------------------------------------------------------------
26
+ # Abstract Base
27
+ # --------------------------------------------------------------
28
+
29
+ class BaseEncoder(nn.Module):
30
+ """Abstract encoder base.
31
+
32
+ Unified API:
33
+ encode_passage(inputs) -> (hidden_states, attention_mask)
34
+ encode_text(inputs) -> (hidden_states, attention_mask)
35
+ """
36
+
37
+ vocab_size: int = 0
38
+ hidden_size: int = 0
39
+
40
+ def encode_passage(self, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]:
41
+ raise NotImplementedError
42
+
43
+ def encode_text(self, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]:
44
+ raise NotImplementedError
45
+
46
+ def gradient_checkpointing_enable(self, gradient_checkpointing_kwargs=None):
47
+ pass
48
+
49
+ def get_text_embeddings(self) -> Optional[nn.Embedding]:
50
+ """Return the text embedding layer (for query-encoder initialization)."""
51
+ return None
52
+
53
+
54
+ # --------------------------------------------------------------
55
+ # MLM head used by VBert
56
+ # --------------------------------------------------------------
57
+
58
+ class ModernVBertMLMHead(nn.Module):
59
+ """MLM head: dense(768->768) -> GELU -> LayerNorm(768) -> decoder(768->50368)."""
60
+
61
+ def __init__(self, hidden_size: int = 768, vocab_size: int = 50368):
62
+ super().__init__()
63
+ self.dense = nn.Linear(hidden_size, hidden_size)
64
+ self.norm = nn.LayerNorm(hidden_size)
65
+ self.decoder = nn.Linear(hidden_size, vocab_size)
66
+
67
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
68
+ h = self.dense(hidden_states)
69
+ h = F.gelu(h)
70
+ h = self.norm(h)
71
+ h = self.decoder(h)
72
+ return h
73
+
74
+ @classmethod
75
+ def from_safetensors(cls, safetensors_path: str, **kwargs):
76
+ from safetensors import safe_open
77
+ head = cls(**kwargs)
78
+ with safe_open(safetensors_path, framework="pt") as f:
79
+ head.dense.weight.data.copy_(f.get_tensor("lm_head.head.dense.weight"))
80
+ head.norm.weight.data.copy_(f.get_tensor("lm_head.head.norm.weight"))
81
+ head.decoder.weight.data.copy_(f.get_tensor("lm_head.decoder.weight"))
82
+ head.decoder.bias.data.copy_(f.get_tensor("lm_head.decoder.bias"))
83
+ return head
84
+
85
+
86
+ # --------------------------------------------------------------
87
+ # VBert Encoder (BiModernVBERT)
88
+ # --------------------------------------------------------------
89
+
90
+ class VBertEncoder(BaseEncoder):
91
+ """BiModernVBERT encoder + external MLM head, with optional LoRA."""
92
+
93
+ def __init__(
94
+ self,
95
+ model_name: str = "ModernVBERT/bimodernvbert",
96
+ lm_head_model: str = "ModernVBERT/ModernVBERT",
97
+ lm_head_lora_r: int = 32,
98
+ encoder_lora_r: int = 32,
99
+ lm_head_full: bool = False,
100
+ **kwargs,
101
+ ):
102
+ super().__init__()
103
+ from peft import LoraConfig, get_peft_model
104
+ from models.convert import ensure_compatible_backbone
105
+
106
+ # 0. Auto-convert the backbone if it uses the upstream ModernVBERT layout.
107
+ # Compatible backbones (local or Hub) pass through unchanged; the raw
108
+ # upstream checkpoint is downloaded + converted once (cached) so that
109
+ # from_scratch training works directly from the Hub id.
110
+ model_name = ensure_compatible_backbone(model_name)
111
+ lm_head_model = ensure_compatible_backbone(lm_head_model) if lm_head_model else model_name
112
+
113
+ # 1. Load encoder backbone.
114
+ model_cls = self._resolve_model_cls(model_name)
115
+ self.encoder = model_cls.from_pretrained(model_name, dtype=torch.bfloat16)
116
+
117
+ # Disable compiled_mlp - FX tracing in gradient_checkpointing traces
118
+ # both branches of the if/else in ModernBertEncoderLayer.forward(),
119
+ # hitting compiled_mlp even when reference_compile is None/False.
120
+ def _set_reference_compile_false(module):
121
+ if hasattr(module, "config") and hasattr(module.config, "reference_compile"):
122
+ module.config.reference_compile = False
123
+ for m in self.encoder.modules():
124
+ _set_reference_compile_false(m)
125
+
126
+ # 2. Merge any existing LoRA adapters into base weights.
127
+ has_lora = any("lora" in k for k in self.encoder.state_dict().keys())
128
+ if has_lora:
129
+ from peft.tuners.lora.layer import Linear as LoraLinear
130
+ for _, mod in self.encoder.named_modules():
131
+ if isinstance(mod, LoraLinear) and hasattr(mod, "merge"):
132
+ mod.merge()
133
+
134
+ # 3. Apply a fresh full LoRA on encoder (all layers: attn + mlp).
135
+ if encoder_lora_r > 0:
136
+ self.encoder.model.text_model = get_peft_model(
137
+ self.encoder.model.text_model,
138
+ LoraConfig(
139
+ r=encoder_lora_r, lora_alpha=encoder_lora_r,
140
+ target_modules=["Wqkv", "Wo", "Wi"],
141
+ bias="none",
142
+ ),
143
+ )
144
+
145
+ # 4. Load MLM head - from same model dir or separate model.
146
+ import os as _os
147
+ encoder_sf = _os.path.join(model_name, "model.safetensors")
148
+ has_lm_head_in_encoder = False
149
+ if _os.path.isfile(encoder_sf):
150
+ from safetensors import safe_open as _safe_open
151
+ with _safe_open(encoder_sf, framework="pt") as _f:
152
+ has_lm_head_in_encoder = any("lm_head" in k for k in _f.keys())
153
+ if has_lm_head_in_encoder:
154
+ self.mlm_head = ModernVBertMLMHead.from_safetensors(
155
+ encoder_sf, hidden_size=768, vocab_size=50368,
156
+ ).to(torch.bfloat16)
157
+ else:
158
+ safetensors_path = self._find_safetensors(lm_head_model)
159
+ self.mlm_head = ModernVBertMLMHead.from_safetensors(
160
+ safetensors_path, hidden_size=768, vocab_size=50368,
161
+ ).to(torch.bfloat16)
162
+
163
+ # 5. Apply LoRA to MLM head (dense + decoder).
164
+ if lm_head_lora_r > 0 and not lm_head_full:
165
+ self.mlm_head = get_peft_model(self.mlm_head, LoraConfig(
166
+ r=lm_head_lora_r, lora_alpha=lm_head_lora_r,
167
+ target_modules=["dense", "decoder"], bias="none",
168
+ ))
169
+
170
+ self.vocab_size = 50368
171
+ self.hidden_size = 768
172
+
173
+ # Freeze base weights, keep LoRA trainable.
174
+ for name, param in self.named_parameters():
175
+ if "lora" in name.lower():
176
+ param.requires_grad = True
177
+ else:
178
+ param.requires_grad = False
179
+
180
+ # Optional full-parameter tuning for the MLM head (no LoRA).
181
+ if lm_head_full:
182
+ for param in self.mlm_head.parameters():
183
+ param.requires_grad = True
184
+
185
+ @classmethod
186
+ def from_hf_export(cls, hf_dir: str, dtype: torch.dtype = torch.bfloat16) -> "VBertEncoder":
187
+ """Build an empty VBertEncoder shell from a V-SPLADE HF export.
188
+
189
+ Constructs `BiModernVBert(config)` + `ModernVBertMLMHead(...)` with
190
+ randomly-initialized weights β€” the caller is expected to populate them
191
+ via :func:`models.load_hf_export`. Used by `build_model(mode='inference_only')`.
192
+ """
193
+ from colpali_engine.models import BiModernVBert
194
+ instance = cls.__new__(cls)
195
+ nn.Module.__init__(instance)
196
+ config = BiModernVBert.config_class.from_pretrained(hf_dir)
197
+ instance.encoder = BiModernVBert(config).to(dtype=dtype)
198
+ instance.mlm_head = ModernVBertMLMHead(
199
+ hidden_size=config.hidden_size, vocab_size=50368,
200
+ ).to(dtype=dtype)
201
+ instance.vocab_size = 50368
202
+ instance.hidden_size = config.hidden_size
203
+ # No grad needed at inference; trainer-side flags are not touched.
204
+ for p in instance.parameters():
205
+ p.requires_grad = False
206
+ return instance
207
+
208
+ @staticmethod
209
+ def _resolve_model_cls(model_name: str):
210
+ import json, os
211
+ from colpali_engine.models import BiModernVBert
212
+
213
+ config_path = os.path.join(model_name, "config.json")
214
+ adapter_config_path = os.path.join(model_name, "adapter_config.json")
215
+
216
+ if os.path.isfile(adapter_config_path):
217
+ with open(adapter_config_path) as f:
218
+ adapter_cfg = json.load(f)
219
+ base_path = adapter_cfg.get("base_model_name_or_path", "")
220
+ base_config = os.path.join(base_path, "config.json")
221
+ if os.path.isfile(base_config):
222
+ config_path = base_config
223
+
224
+ if os.path.isfile(config_path):
225
+ with open(config_path) as f:
226
+ cfg = json.load(f)
227
+ archs = cfg.get("architectures", [])
228
+ # V_SPLADE only uses the bidirectional encoder variant.
229
+ if "BiModernVBert" in archs:
230
+ return BiModernVBert
231
+
232
+ return BiModernVBert
233
+
234
+ @staticmethod
235
+ def _find_safetensors(model_name: str) -> str:
236
+ import os
237
+ local = os.path.join(model_name, "model.safetensors")
238
+ if os.path.isfile(local):
239
+ return local
240
+ from huggingface_hub import hf_hub_download
241
+ return hf_hub_download(model_name, "model.safetensors")
242
+
243
+ def gradient_checkpointing_enable(self, gradient_checkpointing_kwargs=None):
244
+ kwargs = gradient_checkpointing_kwargs or {"use_reentrant": False}
245
+ text_model = self.encoder.model.text_model
246
+ if hasattr(text_model, "gradient_checkpointing_enable"):
247
+ text_model.gradient_checkpointing_enable(gradient_checkpointing_kwargs=kwargs)
248
+
249
+ def _get_hidden_states(
250
+ self,
251
+ input_ids: torch.Tensor,
252
+ attention_mask: torch.Tensor,
253
+ pixel_values: Optional[torch.Tensor] = None,
254
+ pixel_attention_mask: Optional[torch.Tensor] = None,
255
+ ) -> torch.Tensor:
256
+ kw = dict(input_ids=input_ids, attention_mask=attention_mask)
257
+ if pixel_values is not None:
258
+ kw["pixel_values"] = pixel_values
259
+ if pixel_attention_mask is not None:
260
+ kw["pixel_attention_mask"] = pixel_attention_mask
261
+ outputs = self.encoder.model(**kw)
262
+ return outputs[0]
263
+
264
+ def encode_passage(self, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]:
265
+ hidden = self._get_hidden_states(
266
+ kwargs["input_ids"], kwargs["attention_mask"],
267
+ kwargs.get("pixel_values"), kwargs.get("pixel_attention_mask"),
268
+ )
269
+ return hidden, kwargs["attention_mask"]
270
+
271
+ def encode_text(self, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]:
272
+ hidden = self._get_hidden_states(kwargs["input_ids"], kwargs["attention_mask"])
273
+ return hidden, kwargs["attention_mask"]
274
+
275
+ def get_lm_head(self):
276
+ return self.mlm_head
277
+
278
+ def get_text_embeddings(self) -> Optional[nn.Module]:
279
+ return self.encoder.model.text_model.get_input_embeddings()
280
+
281
+ @property
282
+ def image_token_id(self) -> int:
283
+ return 50407 # BiModernVBERT <image> token
284
+
285
+
286
+ # --------------------------------------------------------------
287
+ # Factory
288
+ # --------------------------------------------------------------
289
+
290
+ def build_encoder(encoder_type: str, **kwargs) -> BaseEncoder:
291
+ """Build encoder by type string. V_SPLADE only ships the vbert backbone."""
292
+ if encoder_type == "vbert":
293
+ return VBertEncoder(**kwargs)
294
+ raise ValueError(f"Unknown encoder_type: {encoder_type}. Choose: vbert")
train/models/head.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # V-SPLADE
2
+ # Copyright (c) 2026-present NAVER Corp.
3
+ # Apache-2.0
4
+
5
+ """
6
+ Output head β€” projects encoder representations into the retrieval space.
7
+
8
+ V_SPLADE uses SparseHead: the encoder's LM head is reused to project
9
+ hidden states to vocab-dim sparse weights via log1p(relu(.)).
10
+ """
11
+
12
+ import torch
13
+ import torch.nn as nn
14
+ import torch.nn.functional as F
15
+ from enum import Enum
16
+ from typing import Tuple
17
+
18
+
19
+ class HeadType(Enum):
20
+ DENSE = "dense"
21
+ SPARSE = "sparse"
22
+
23
+
24
+ class DenseHead(nn.Module):
25
+ """L2-normalized dense embedding."""
26
+
27
+ def forward(self, pooled: torch.Tensor) -> torch.Tensor:
28
+ return F.normalize(pooled, dim=-1)
29
+
30
+
31
+ class SparseHead(nn.Module):
32
+ """Hidden states β†’ LM head β†’ log1p(relu) β†’ sparse vocab-dim weights.
33
+
34
+ Returns (h_raw, w_sparse). The LM head is held as a *reference* (not a
35
+ registered sub-module) to avoid duplicated state-dict keys, since the
36
+ encoder already owns the same module.
37
+ """
38
+
39
+ def __init__(self, lm_head: nn.Module, hidden_size: int):
40
+ super().__init__()
41
+ object.__setattr__(self, "_lm_head_ref", lm_head)
42
+ self.scale = hidden_size ** -0.25
43
+
44
+ @property
45
+ def lm_head(self):
46
+ return self._lm_head_ref
47
+
48
+ def forward(self, hidden_states: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
49
+ h = self._lm_head_ref(hidden_states) * self.scale
50
+ w = torch.log1p(torch.relu(h))
51
+ return h, w
52
+
53
+
54
+ def build_head(head_type: str, lm_head=None, hidden_size: int = 768, **kwargs) -> nn.Module:
55
+ if head_type == "dense":
56
+ return DenseHead()
57
+ if head_type == "sparse":
58
+ if lm_head is None:
59
+ raise ValueError("sparse head requires lm_head")
60
+ return SparseHead(lm_head, hidden_size)
61
+ raise ValueError(f"Unknown head_type: {head_type}. Choose: dense | sparse")
train/models/losses.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # V-SPLADE
2
+ # Copyright (c) 2026-present NAVER Corp.
3
+ # Apache-2.0
4
+
5
+ """
6
+ V_SPLADE loss functions.
7
+
8
+ - FLOPSLoss: Expected retrieval cost regularization (vocab-wise L2).
9
+ - NCELoss: InfoNCE contrastive loss with in-batch + hard negatives.
10
+ - CaptionPushUpLoss: Caption-gated token supervision loss β€” biases passage
11
+ sparse activations toward the vocab positions activated
12
+ by the paired caption.
13
+ - ZipfianPushUpLoss: Same as ``CaptionPushUpLoss`` with a temperature
14
+ applied to the overlap distribution
15
+ (``push_focus_tau``): Ο„<1 hard-mines a few
16
+ strongly-weighted tokens; Ο„=1 recovers
17
+ ``CaptionPushUpLoss``.
18
+ """
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+ import torch.nn.functional as F
23
+
24
+
25
+ class FLOPSLoss(nn.Module):
26
+ """Expected retrieval cost: sum_v mean(|w|_v)^2."""
27
+
28
+ def forward(self, reps: torch.Tensor) -> torch.Tensor:
29
+ return torch.sum(torch.mean(torch.abs(reps), dim=0) ** 2)
30
+
31
+
32
+ class NCELoss(nn.Module):
33
+ """InfoNCE contrastive loss with in-batch negatives + optional hard negatives."""
34
+
35
+ def __init__(self, temperature: float = 1.0):
36
+ super().__init__()
37
+ self.temperature = temperature
38
+ self.loss_fn = nn.CrossEntropyLoss()
39
+
40
+ def forward(
41
+ self,
42
+ q_reps: torch.Tensor,
43
+ p_reps: torch.Tensor,
44
+ hn_reps: torch.Tensor = None,
45
+ ) -> torch.Tensor:
46
+ B = q_reps.size(0)
47
+ device = q_reps.device
48
+ all_p = torch.cat([p_reps, hn_reps], dim=0) if hn_reps is not None else p_reps
49
+ logits = (q_reps @ all_p.t()) / self.temperature
50
+ labels = torch.arange(B, device=device)
51
+ return self.loss_fn(logits, labels)
52
+
53
+
54
+ class CaptionPushUpLoss(nn.Module):
55
+ """Caption-gated token supervision loss on passage sparse activations.
56
+
57
+ Given the passage's sparse weights p_w (post log1p(relu)) and the
58
+ caption's sparse weights cap_w (detached), an overlap distribution is
59
+ formed from (p_w + alpha * mean(p_w)) * cap_w and renormalized. The
60
+ loss then maximizes the expected log-prob of activation under that
61
+ distribution, biasing the passage to put mass on the vocab positions
62
+ activated by the paired caption while remaining sparse elsewhere.
63
+ """
64
+
65
+ def __init__(self, cap_loss_mode: str = "logsigmoid_h", p_mean_alpha: float = 1.0):
66
+ super().__init__()
67
+ assert cap_loss_mode in ("logsigmoid_h", "raw_h", "raw_reps")
68
+ self.cap_loss_mode = cap_loss_mode
69
+ self.p_mean_alpha = p_mean_alpha
70
+
71
+ def forward(
72
+ self,
73
+ p_h: torch.Tensor,
74
+ p_reps: torch.Tensor,
75
+ cap_sparse: torch.Tensor,
76
+ overlap_type: str = "passage_mean",
77
+ ) -> torch.Tensor:
78
+ cap_w = cap_sparse.detach()
79
+ p_w = p_reps.detach()
80
+
81
+ if overlap_type == "passage_mean":
82
+ p_mean = p_w.mean(dim=-1, keepdim=True)
83
+ overlap = (p_w + self.p_mean_alpha * p_mean) * cap_w
84
+ else:
85
+ overlap = p_w * cap_w
86
+
87
+ overlap = overlap / (overlap.sum(dim=-1, keepdim=True) + 1e-8)
88
+
89
+ if self.cap_loss_mode == "raw_h":
90
+ push_up_target_rep = p_h
91
+ elif self.cap_loss_mode == "raw_reps":
92
+ push_up_target_rep = p_reps
93
+ else: # logsigmoid_h (default) β€” BCE-style log P(active)
94
+ push_up_target_rep = F.logsigmoid(p_h)
95
+
96
+ per_sample = -(overlap * push_up_target_rep).sum(dim=-1)
97
+ return per_sample.mean()
98
+
99
+
100
+ class ZipfianPushUpLoss(nn.Module):
101
+ """Caption-gated token supervision β€” identical objective to
102
+ ``CaptionPushUpLoss`` with a temperature on the overlap distribution.
103
+ The name is a dev-time convenience for distinguishing the tempered
104
+ variant in experiment configs.
105
+
106
+ Applies a per-sample log-space softmax with ``push_focus_tau`` to the
107
+ overlap distribution:
108
+
109
+ o_sharp[v] = overlap[v]^(1/Ο„) / Ξ£_v' overlap[v']^(1/Ο„)
110
+
111
+ - ``push_focus_tau < 1``: hard-mines a few strongly-weighted tokens.
112
+ - ``push_focus_tau = 1``: identical to ``CaptionPushUpLoss``.
113
+
114
+ The rest of the push-up pipeline matches ``CaptionPushUpLoss``.
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ push_focus_tau: float = 1.0,
120
+ p_mean_alpha: float = 1.0,
121
+ cap_loss_mode: str = "logsigmoid_h",
122
+ ):
123
+ super().__init__()
124
+ assert cap_loss_mode in ("logsigmoid_h", "raw_h", "raw_reps")
125
+ self.push_focus_tau = push_focus_tau
126
+ self.p_mean_alpha = p_mean_alpha
127
+ self.cap_loss_mode = cap_loss_mode
128
+
129
+ def forward(
130
+ self,
131
+ p_h: torch.Tensor,
132
+ p_reps: torch.Tensor,
133
+ cap_sparse: torch.Tensor,
134
+ overlap_type: str = "passage_mean",
135
+ ) -> torch.Tensor:
136
+ cap_w = cap_sparse.detach()
137
+ p_w = p_reps.detach()
138
+
139
+ if overlap_type == "passage_mean":
140
+ p_mean = p_w.mean(dim=-1, keepdim=True)
141
+ overlap = (p_w + self.p_mean_alpha * p_mean) * cap_w
142
+ else: # "passage"
143
+ overlap = p_w * cap_w
144
+ # overlap is non-negative and zero wherever cap_w == 0.
145
+
146
+ # Target sharpening via push_focus_tau (log-space, numerically stable).
147
+ mask = (overlap > 0)
148
+ neg_inf = torch.full_like(overlap, float("-inf"))
149
+ log_overlap = torch.where(mask, torch.log(overlap.clamp(min=1e-30)), neg_inf)
150
+ log_o_scaled = (1.0 / self.push_focus_tau) * log_overlap
151
+ lse = torch.logsumexp(log_o_scaled, dim=-1, keepdim=True)
152
+ o_sharp = torch.exp(log_o_scaled - lse)
153
+ # Rows with no caption-active tokens β†’ all -inf β†’ exp(nan) β†’ 0.
154
+ o_sharp = torch.nan_to_num(o_sharp, nan=0.0, posinf=0.0, neginf=0.0)
155
+
156
+ if self.cap_loss_mode == "raw_h":
157
+ push_up_target_rep = p_h
158
+ elif self.cap_loss_mode == "raw_reps":
159
+ push_up_target_rep = p_reps
160
+ else: # logsigmoid_h (default)
161
+ push_up_target_rep = F.logsigmoid(p_h)
162
+
163
+ per_sample = -(o_sharp * push_up_target_rep).sum(dim=-1)
164
+ return per_sample.mean()
165
+
166
+
167
+ def all_gather_2d_with_grad(local_tensor: torch.Tensor) -> torch.Tensor:
168
+ """All-gather (B, D) across ranks, keeping gradient on the local slot.
169
+
170
+ Used to give each query access to all ranks' passages as in-batch
171
+ negatives during contrastive training. Returns the input unchanged
172
+ when distributed is not initialized or world_size <= 1.
173
+ """
174
+ import torch.distributed as dist
175
+
176
+ if not dist.is_initialized() or dist.get_world_size() <= 1:
177
+ return local_tensor
178
+ world_size = dist.get_world_size()
179
+ rank = dist.get_rank()
180
+ gathered = [torch.zeros_like(local_tensor) for _ in range(world_size)]
181
+ dist.all_gather(gathered, local_tensor)
182
+ gathered[rank] = local_tensor # preserve grad on local slot
183
+ return torch.cat(gathered, dim=0)
train/models/model.py ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # V-SPLADE
2
+ # Copyright (c) 2026-present NAVER Corp.
3
+ # Apache-2.0
4
+
5
+ """
6
+ UnifiedRetriever -- V_SPLADE core model.
7
+
8
+ Combines:
9
+ VBert encoder + Sparse head (SPLADE) + BOW query encoder + Losses
10
+
11
+ Training objective (per forward call):
12
+ L_total = NCE(q, p, hn) + lambda_p * FLOPS(p) + lambda_cap * CaptionPushUp
13
+
14
+ The model expects a passage (image) and an optional caption; the query is
15
+ encoded as a Bag-of-Words vocabulary indicator. The sparse passage
16
+ representation is supervised by:
17
+ - NCE in-batch ranking against the query (and hard negatives, if any)
18
+ - FLOPS regularization to encourage sparsity
19
+ - Caption-gated token supervision: pull passage activations toward overlapping
20
+ caption-vocabulary tokens.
21
+ """
22
+
23
+ import torch
24
+ import torch.nn as nn
25
+ import torch.nn.functional as F
26
+ from dataclasses import dataclass
27
+ from typing import Optional
28
+
29
+ from models.encoder import build_encoder
30
+ from models.pooling import Pooling
31
+ from models.head import SparseHead
32
+ from models.query_encoder import (
33
+ build_query_encoder,
34
+ BOWQueryEncoder,
35
+ InferenceFreeQueryEncoder,
36
+ )
37
+ from models.losses import FLOPSLoss, NCELoss, CaptionPushUpLoss, ZipfianPushUpLoss # noqa: F401
38
+
39
+
40
+ def compute_logits(q, p, hn, temperature):
41
+ """Compute in-batch negative logits and labels.
42
+
43
+ Args:
44
+ q: (B, V) query sparse reps
45
+ p: (B, V) passage sparse reps
46
+ hn: (B*num_hn, V) hard-negative passage reps, or None
47
+ temperature: scaling factor for the inner product
48
+
49
+ Returns:
50
+ logits: (B, B [+ B*num_hn]) score matrix
51
+ labels: (B,) target index for cross-entropy
52
+ """
53
+ B = q.size(0)
54
+ device = q.device
55
+ all_p = torch.cat([p, hn], dim=0) if hn is not None else p
56
+ logits = (q @ all_p.t()) / temperature
57
+ logits = logits.clamp(min=-100, max=100) # prevent exp() overflow in cross_entropy
58
+ labels = torch.arange(B, device=device)
59
+ return logits, labels
60
+
61
+
62
+ # Default pooling for V_SPLADE (single backbone): max over sequence positions.
63
+ DEFAULT_POOLING = "max"
64
+
65
+
66
+ @dataclass
67
+ class RetrievalOutput:
68
+ """Container for forward() outputs."""
69
+ loss: Optional[torch.Tensor] = None
70
+ rank_loss: Optional[torch.Tensor] = None
71
+ reg_loss: Optional[torch.Tensor] = None # total reg (passage FLOPS + caption FLOPS)
72
+ reg_loss_p: Optional[torch.Tensor] = None # passage FLOPS only
73
+ reg_loss_cap: Optional[torch.Tensor] = None # caption FLOPS only
74
+ cap_loss: Optional[torch.Tensor] = None # caption-gated token supervision loss
75
+ cap_sparse_rank_loss: Optional[torch.Tensor] = None # caption-gated ranking loss
76
+ # Sparsity / retrieval-cost diagnostics (no_grad, mean over batch).
77
+ train_p_nnz: Optional[torch.Tensor] = None # avg nonzero per passage
78
+ train_p_max: Optional[torch.Tensor] = None # avg max|p| per passage
79
+ train_flops_qd: Optional[torch.Tensor] = None # sum_v mean|q|_v * mean|p|_v
80
+ train_cap_bow_killed_pct: Optional[torch.Tensor] = None # % bow-active tokens zeroed by model
81
+ query_reps: Optional[torch.Tensor] = None
82
+ passage_reps: Optional[torch.Tensor] = None
83
+ hard_neg_reps: Optional[torch.Tensor] = None
84
+ debug_tokens: Optional[dict] = None
85
+
86
+
87
+ class UnifiedRetriever(nn.Module):
88
+ """V_SPLADE retriever: VBert encoder + SPLADE sparse head + (BOW | Li-LSR) query encoder."""
89
+
90
+ def __init__(
91
+ self,
92
+ encoder_type: str = "vbert",
93
+ pooling_type: str = None,
94
+ head_type: str = "sparse",
95
+ query_encoder_type: str = "bow",
96
+ # Model paths
97
+ model_name: str = "",
98
+ lm_head_model: str = None,
99
+ # Training config
100
+ temperature: float = 1.0,
101
+ # Caption-gated token supervision
102
+ cap_weight: float = 0.0,
103
+ cap_sparse_rank_weight: float = 0.0,
104
+ cap_loss_mode: str = "logsigmoid_h",
105
+ overlap_type: str = "passage_mean",
106
+ p_mean_alpha: float = 1.0,
107
+ use_zipfian_pushup: bool = False,
108
+ push_focus_tau: float = 1.0,
109
+ # Regularization weights
110
+ reg_weight_p: float = 0.0,
111
+ reg_weight_cap: float = 0.0,
112
+ # SPLADE pooling
113
+ splade_pooling: str = "max",
114
+ # Encoder-specific kwargs
115
+ lm_head_lora_r: int = 32,
116
+ encoder_lora_r: int = 32,
117
+ lm_head_full: bool = False,
118
+ # Li-LSR query encoder kwargs
119
+ query_lsr_lora_r: int = 0,
120
+ query_lsr_activation: str = "relu",
121
+ # Misc
122
+ **kwargs,
123
+ ):
124
+ super().__init__()
125
+
126
+ # V_SPLADE only supports the sparse head with a single (vbert) backbone.
127
+ assert encoder_type == "vbert", "V_SPLADE only supports the vbert encoder."
128
+ assert head_type == "sparse", "V_SPLADE only supports the sparse head."
129
+
130
+ if pooling_type is None:
131
+ pooling_type = DEFAULT_POOLING
132
+
133
+ self.encoder_type = encoder_type
134
+ self.head_type = head_type
135
+ self.query_encoder_type = query_encoder_type
136
+ self.splade_pooling = splade_pooling
137
+
138
+ # Build encoder.
139
+ encoder_kwargs = dict(
140
+ model_name=model_name,
141
+ lm_head_model=lm_head_model or model_name,
142
+ lm_head_lora_r=lm_head_lora_r,
143
+ encoder_lora_r=encoder_lora_r,
144
+ lm_head_full=lm_head_full,
145
+ **kwargs,
146
+ )
147
+ self.encoder = build_encoder(encoder_type, **encoder_kwargs)
148
+
149
+ # Build pooling and sparse head.
150
+ self.pooling = Pooling(pooling_type)
151
+ lm_head = self.encoder.get_lm_head()
152
+ self.head = SparseHead(lm_head, self.encoder.hidden_size)
153
+
154
+ self.vocab_size = self.encoder.vocab_size
155
+ self.hidden_size = self.encoder.hidden_size
156
+
157
+ # Build query encoder: BOW (cheapest) or Li-LSR (inference-free learned).
158
+ assert query_encoder_type in ("bow", "li_lsr"), (
159
+ "V_SPLADE only supports the BOW or Li-LSR query encoders."
160
+ )
161
+ if query_encoder_type == "li_lsr":
162
+ embed_layer = self.encoder.get_text_embeddings()
163
+ if embed_layer is not None:
164
+ embed_weight = embed_layer.weight
165
+ else:
166
+ # Fallback: use lm_head weights (tied with embed_tokens in causal LMs).
167
+ _lm_head = self.encoder.get_lm_head()
168
+ embed_weight = _lm_head.weight if _lm_head is not None else None
169
+ self.query_encoder = build_query_encoder(
170
+ "li_lsr",
171
+ vocab_size=self.vocab_size,
172
+ hidden_size=self.hidden_size,
173
+ embed_weight=embed_weight,
174
+ lora_r=query_lsr_lora_r,
175
+ activation=query_lsr_activation,
176
+ )
177
+ else:
178
+ self.query_encoder = build_query_encoder("bow", vocab_size=self.vocab_size)
179
+
180
+ # Loss / regularization config.
181
+ self.temperature = temperature
182
+ self.cap_weight = cap_weight
183
+ self.cap_sparse_rank_weight = cap_sparse_rank_weight
184
+ self.cap_loss_mode = cap_loss_mode
185
+ self.overlap_type = overlap_type
186
+ self.reg_weight_p = reg_weight_p
187
+ self.reg_weight_cap = reg_weight_cap
188
+ self.loss_fn = nn.CrossEntropyLoss()
189
+ self.reg_fn = FLOPSLoss()
190
+ if cap_weight > 0:
191
+ if use_zipfian_pushup:
192
+ self.cap_push_up = ZipfianPushUpLoss(
193
+ push_focus_tau=push_focus_tau,
194
+ p_mean_alpha=p_mean_alpha,
195
+ cap_loss_mode=cap_loss_mode,
196
+ )
197
+ else:
198
+ self.cap_push_up = CaptionPushUpLoss(
199
+ cap_loss_mode, p_mean_alpha=p_mean_alpha,
200
+ )
201
+ else:
202
+ self.cap_push_up = None
203
+
204
+ # Special-token mask for the VBert vocabulary. Prevents tokens such as
205
+ # [CLS]/[SEP]/[PAD]/[MASK] (and out-of-MLM image tokens) from activating
206
+ # in the sparse representation.
207
+ _special_ids = [
208
+ 50280, # [UNK]
209
+ 50281, # [CLS]
210
+ 50282, # [SEP]
211
+ 50283, # [PAD]
212
+ 50284, # [MASK]
213
+ # Additional image/layout tokens beyond MLM head vocab (50368);
214
+ # kept here for safety in case downstream code changes vocab_size.
215
+ *range(50368, 50408),
216
+ ]
217
+ mask = torch.ones(self.vocab_size)
218
+ for sid in _special_ids:
219
+ if sid < self.vocab_size:
220
+ mask[sid] = 0.0
221
+ self.register_buffer("special_token_mask", mask, persistent=False)
222
+
223
+ @classmethod
224
+ def from_hf_export(cls, hf_dir: str,
225
+ query_lsr_activation: str = "softplus",
226
+ dtype: torch.dtype = torch.bfloat16) -> "UnifiedRetriever":
227
+ """Build an empty V-SPLADE retriever shell from a V-SPLADE HF export.
228
+
229
+ Only the module *structure* is created (random weights); call
230
+ :func:`models.load_hf_export` afterwards to populate every tensor
231
+ from the export's `model.safetensors` in one pass.
232
+ """
233
+ from models.encoder import VBertEncoder
234
+
235
+ instance = cls.__new__(cls)
236
+ nn.Module.__init__(instance)
237
+ instance.encoder_type = "vbert"
238
+ instance.head_type = "sparse"
239
+ instance.query_encoder_type = "li_lsr"
240
+ instance.splade_pooling = "max"
241
+ instance.temperature = 1.0
242
+ instance.cap_weight = 0.0
243
+ instance.cap_sparse_rank_weight = 0.0
244
+ instance.cap_loss_mode = "logsigmoid_h"
245
+ instance.overlap_type = "passage_mean"
246
+ instance.reg_weight_p = 0.0
247
+ instance.reg_weight_cap = 0.0
248
+ instance.cap_push_up = None
249
+ instance.loss_fn = nn.CrossEntropyLoss()
250
+ instance.reg_fn = FLOPSLoss()
251
+
252
+ # Encoder + sparse head (shares the encoder's LM head via SparseHead).
253
+ instance.encoder = VBertEncoder.from_hf_export(hf_dir, dtype=dtype)
254
+ instance.vocab_size = instance.encoder.vocab_size
255
+ instance.hidden_size = instance.encoder.hidden_size
256
+ instance.pooling = Pooling("max")
257
+ instance.head = SparseHead(instance.encoder.get_lm_head(),
258
+ instance.encoder.hidden_size)
259
+
260
+ # Li-LSR inference-free query encoder (softplus activation, LoRA off).
261
+ embed_layer = instance.encoder.get_text_embeddings()
262
+ embed_weight = (embed_layer.weight if embed_layer is not None
263
+ else instance.encoder.get_lm_head().weight)
264
+ instance.query_encoder = build_query_encoder(
265
+ "li_lsr",
266
+ vocab_size=instance.vocab_size,
267
+ hidden_size=instance.hidden_size,
268
+ embed_weight=embed_weight,
269
+ lora_r=0,
270
+ activation=query_lsr_activation,
271
+ )
272
+
273
+ # Special-token mask (same as training).
274
+ _special_ids = [50280, 50281, 50282, 50283, 50284, *range(50368, 50408)]
275
+ mask = torch.ones(instance.vocab_size)
276
+ for sid in _special_ids:
277
+ if sid < instance.vocab_size:
278
+ mask[sid] = 0.0
279
+ instance.register_buffer("special_token_mask", mask, persistent=False)
280
+
281
+ for p in instance.parameters():
282
+ p.requires_grad = False
283
+ return instance.eval()
284
+
285
+ def gradient_checkpointing_enable(self, gradient_checkpointing_kwargs=None):
286
+ self.encoder.gradient_checkpointing_enable(gradient_checkpointing_kwargs)
287
+
288
+ # ---- Encoding helpers ----------------------------------------------------
289
+
290
+ def _encode_passage_sparse(self, **kwargs):
291
+ """Encode passage -> (h_raw, w_sparse) via encoder + sparse head."""
292
+ hidden, mask = self.encoder.encode_passage(**kwargs)
293
+ return self._apply_sparse_head(hidden, mask)
294
+
295
+ def _encode_text_sparse(self, **kwargs):
296
+ """Encode text (e.g. caption) -> (h_raw, w_sparse)."""
297
+ hidden, mask = self.encoder.encode_text(**kwargs)
298
+ return self._apply_sparse_head(hidden, mask)
299
+
300
+ def _apply_sparse_head(self, hidden_states, attention_mask):
301
+ """SPLADE sparse head: LM head (per position) -> max/mean pool over sequence."""
302
+ scale = self.hidden_size ** -0.25
303
+
304
+ # max/mean pooling: apply LM head over all positions, then pool.
305
+ h = self.head.lm_head(hidden_states) * scale # (B, seq, vocab)
306
+ w = torch.log1p(torch.relu(h))
307
+ mask = attention_mask.unsqueeze(-1).to(w.dtype)
308
+
309
+ if self.splade_pooling == "max":
310
+ w_out = (w * mask).max(dim=1).values
311
+ h_masked = h * mask + (~mask.bool()) * (-1e9)
312
+ h_out = h_masked.max(dim=1).values
313
+ else: # mean
314
+ seq_len = mask.sum(dim=1, keepdim=True).clamp(min=1)
315
+ w_out = (w * mask).sum(dim=1) / seq_len.squeeze(1)
316
+ h_out = (h * mask).sum(dim=1) / seq_len.squeeze(1)
317
+
318
+ # Mask special tokens.
319
+ w_out = w_out * self.special_token_mask.to(w_out.dtype)
320
+ return h_out, w_out
321
+
322
+ def _encode_query(self, input_ids, attention_mask):
323
+ """Encode query via BOW or Li-LSR (training: forward, eval: lookup)."""
324
+ if isinstance(self.query_encoder, InferenceFreeQueryEncoder):
325
+ if self.training:
326
+ q = self.query_encoder(input_ids, attention_mask)
327
+ else:
328
+ q = self.query_encoder.encode_with_lookup(input_ids, attention_mask)
329
+ else:
330
+ q = self.query_encoder(input_ids, attention_mask)
331
+ # Apply special-token mask to the query output.
332
+ if q.dim() == 2 and q.size(-1) == self.special_token_mask.size(0):
333
+ q = q * self.special_token_mask.to(q.dtype).to(q.device)
334
+ return q
335
+
336
+ def encode_query(self, input_ids, attention_mask) -> torch.Tensor:
337
+ """Public API for query encoding."""
338
+ return self._encode_query(input_ids, attention_mask)
339
+
340
+ def encode_passage(
341
+ self, input_ids=None, attention_mask=None,
342
+ pixel_values=None, pixel_attention_mask=None,
343
+ ) -> torch.Tensor:
344
+ """Public API for passage encoding."""
345
+ kwargs = {}
346
+ if input_ids is not None:
347
+ kwargs["input_ids"] = input_ids
348
+ if attention_mask is not None:
349
+ kwargs["attention_mask"] = attention_mask
350
+ if pixel_values is not None:
351
+ kwargs["pixel_values"] = pixel_values
352
+ if pixel_attention_mask is not None:
353
+ kwargs["pixel_attention_mask"] = pixel_attention_mask
354
+ _, w = self._encode_passage_sparse(**kwargs)
355
+ return w
356
+
357
+ # ---- Forward -------------------------------------------------------------
358
+
359
+ def forward(
360
+ self,
361
+ query_input_ids, query_attention_mask,
362
+ passage_input_ids, passage_attention_mask,
363
+ passage_pixel_values=None, passage_pixel_attention_mask=None,
364
+ caption_input_ids=None, caption_attention_mask=None,
365
+ hard_neg_passage_input_ids=None, hard_neg_passage_attention_mask=None,
366
+ hard_neg_passage_pixel_values=None, hard_neg_passage_pixel_attention_mask=None,
367
+ **kwargs,
368
+ ) -> RetrievalOutput:
369
+ """V_SPLADE forward pass.
370
+
371
+ Computes NCE rank loss + FLOPS regularization + (optional) caption-gated token
372
+ loss. Multi-vector / self-distillation / dense / multi-backbone paths
373
+ are out of scope for this code release.
374
+ """
375
+ has_caption = caption_input_ids is not None
376
+ use_cap = (
377
+ self.cap_sparse_rank_weight > 0
378
+ or self.cap_weight > 0
379
+ or self.reg_weight_cap > 0
380
+ ) and has_caption
381
+
382
+ # ---- Query (BOW) ----
383
+ q_reps = self._encode_query(query_input_ids, query_attention_mask)
384
+
385
+ # ---- Positive passage ----
386
+ p_kwargs = dict(input_ids=passage_input_ids, attention_mask=passage_attention_mask)
387
+ if passage_pixel_values is not None:
388
+ p_kwargs["pixel_values"] = passage_pixel_values
389
+ if passage_pixel_attention_mask is not None:
390
+ p_kwargs["pixel_attention_mask"] = passage_pixel_attention_mask
391
+ p_h, p_reps = self._encode_passage_sparse(**p_kwargs)
392
+
393
+ # ---- Hard negatives (optional) ----
394
+ hn_reps = None
395
+ if hard_neg_passage_input_ids is not None:
396
+ if hard_neg_passage_pixel_values is not None:
397
+ # Image hard negatives.
398
+ hn_kwargs = dict(
399
+ input_ids=hard_neg_passage_input_ids,
400
+ attention_mask=hard_neg_passage_attention_mask,
401
+ pixel_values=hard_neg_passage_pixel_values,
402
+ pixel_attention_mask=hard_neg_passage_pixel_attention_mask,
403
+ )
404
+ _, hn_reps = self._encode_passage_sparse(**hn_kwargs)
405
+ else:
406
+ # Text hard negatives.
407
+ _, hn_reps = self._encode_text_sparse(
408
+ input_ids=hard_neg_passage_input_ids,
409
+ attention_mask=hard_neg_passage_attention_mask,
410
+ )
411
+
412
+ # ---- Caption encoding ----
413
+ cap_sparse = None
414
+ cap_sparse_raw = None # kept for diagnostics
415
+ cap_bow = None
416
+ if use_cap:
417
+ _, cap_sparse_raw = self._encode_text_sparse(
418
+ input_ids=caption_input_ids, attention_mask=caption_attention_mask,
419
+ )
420
+ # Use plain caption tokens for BOW if available (so that any instruction
421
+ # tokens added by a caption prompt are not counted in the BOW mask).
422
+ bow_ids = kwargs.get("caption_bow_input_ids", caption_input_ids)
423
+ bow_mask = kwargs.get("caption_bow_attention_mask", caption_attention_mask)
424
+ cap_bow = BOWQueryEncoder(self.vocab_size).to(q_reps.device)(bow_ids, bow_mask)
425
+ cap_bow = cap_bow * self.special_token_mask.to(cap_bow.dtype).to(cap_bow.device)
426
+ cap_sparse = cap_sparse_raw * cap_bow.to(cap_sparse_raw.dtype)
427
+
428
+ # ---- dtype alignment (BOW query is float32, passage is bf16) ----
429
+ if q_reps.dtype != p_reps.dtype:
430
+ q_reps = q_reps.to(p_reps.dtype)
431
+
432
+ # ---- Ranking loss (NCE) β€” cross-GPU gather for in-batch negatives ----
433
+ # Symmetric gather across DDP ranks: every gathered query sees
434
+ # ``world_size Γ— B`` passages as negatives. The local rank's slice
435
+ # keeps gradient via ``all_gather_2d_with_grad``; other ranks are
436
+ # detached. Asymmetric gathers (only p) under-train the passage
437
+ # encoder because each p sees gradients from only ``B`` queries.
438
+ import torch.distributed as _dist_nce
439
+ if _dist_nce.is_initialized() and _dist_nce.get_world_size() > 1:
440
+ from models.losses import all_gather_2d_with_grad as _gather_nce
441
+ q_reps_g = _gather_nce(q_reps)
442
+ p_reps_g = _gather_nce(p_reps)
443
+ hn_reps_g = _gather_nce(hn_reps) if hn_reps is not None else None
444
+ logits, labels = compute_logits(
445
+ q_reps_g, p_reps_g, hn_reps_g, self.temperature,
446
+ )
447
+ else:
448
+ logits, labels = compute_logits(
449
+ q_reps, p_reps, hn_reps, self.temperature,
450
+ )
451
+ rank_loss = self.loss_fn(logits, labels)
452
+
453
+ # ---- FLOPS regularization ----
454
+ reg_loss_p = self.reg_weight_p * self.reg_fn(p_reps)
455
+ if hn_reps is not None:
456
+ reg_loss_p = reg_loss_p + self.reg_weight_p * self.reg_fn(hn_reps)
457
+ reg_loss_cap = q_reps.new_tensor(0.0)
458
+ if cap_sparse is not None:
459
+ reg_loss_cap = self.reg_weight_cap * self.reg_fn(cap_sparse)
460
+ reg_loss = reg_loss_p + reg_loss_cap
461
+
462
+ # ---- Caption loss ----
463
+ cap_loss = q_reps.new_tensor(0.0)
464
+ cap_sparse_rank_loss = q_reps.new_tensor(0.0)
465
+
466
+ if use_cap and self.cap_sparse_rank_weight > 0 and cap_sparse is not None:
467
+ # Cross-GPU gather for caption-side ranking too (parallel to the
468
+ # passage-side NCE above): caption push-up still uses LOCAL p_reps.
469
+ import torch.distributed as _dist_capr
470
+ if _dist_capr.is_initialized() and _dist_capr.get_world_size() > 1:
471
+ from models.losses import all_gather_2d_with_grad as _gather_capr
472
+ q_reps_capr = _gather_capr(q_reps)
473
+ cap_sparse_capr = _gather_capr(cap_sparse)
474
+ else:
475
+ q_reps_capr = q_reps
476
+ cap_sparse_capr = cap_sparse
477
+ cap_s_logits, cap_s_labels = compute_logits(
478
+ q_reps_capr, cap_sparse_capr, None, self.temperature,
479
+ )
480
+ cap_sparse_rank_loss = self.loss_fn(cap_s_logits, cap_s_labels) * self.cap_sparse_rank_weight
481
+
482
+ if use_cap and self.cap_weight > 0 and cap_sparse is not None and self.cap_push_up is not None:
483
+ cap_loss = self.cap_push_up(p_h, p_reps, cap_sparse, self.overlap_type) * self.cap_weight
484
+
485
+ # ---- Total loss ----
486
+ loss = rank_loss + reg_loss + cap_loss + cap_sparse_rank_loss
487
+
488
+ # ---- Diagnostics (no_grad) ----
489
+ debug_tokens = None
490
+ train_p_nnz = train_p_max = train_flops_qd = None
491
+ train_cap_bow_killed_pct = None
492
+ with torch.no_grad():
493
+ def _topk_lowk(vec, k=5):
494
+ """Return (top-k values, indices, low-k nonzero values, indices)."""
495
+ nz_mask = vec > 0
496
+ nz_n = int(nz_mask.sum().item())
497
+ if nz_n == 0:
498
+ return (torch.empty(0), torch.empty(0, dtype=torch.long),
499
+ torch.empty(0), torch.empty(0, dtype=torch.long))
500
+ t_k = min(k, vec.size(0))
501
+ l_k = min(k, nz_n)
502
+ top_v, top_i = vec.topk(t_k)
503
+ masked = torch.where(nz_mask, vec, torch.full_like(vec, float('inf')))
504
+ low_vals_asc, low_idx_asc = masked.topk(l_k, largest=False)
505
+ return top_v.cpu(), top_i.cpu(), low_vals_asc.cpu(), low_idx_asc.cpu()
506
+
507
+ q0, p0 = q_reps[0], p_reps[0]
508
+ q_top_v, q_top_i, q_low_v, q_low_i = _topk_lowk(q0)
509
+ p_top_v, p_top_i, p_low_v, p_low_i = _topk_lowk(p0)
510
+ debug_tokens = {
511
+ "q_indices": q_top_i, "q_values": q_top_v,
512
+ "q_low_indices": q_low_i, "q_low_values": q_low_v,
513
+ "p_indices": p_top_i, "p_values": p_top_v,
514
+ "p_low_indices": p_low_i, "p_low_values": p_low_v,
515
+ "q_input_ids": query_input_ids[0].cpu(),
516
+ "q_attention_mask": query_attention_mask[0].cpu(),
517
+ "q_nonzero": (q0 > 0).sum().item(),
518
+ "p_nonzero": (p0 > 0).sum().item(),
519
+ }
520
+ # Batch sparsity / FLOPs diagnostics.
521
+ train_p_nnz = (p_reps > 0).float().sum(-1).mean().detach()
522
+ train_p_max = p_reps.max(dim=-1).values.mean().detach()
523
+ train_flops_qd = (q_reps.abs().mean(dim=0) * p_reps.abs().mean(dim=0)).sum().detach()
524
+
525
+ if cap_sparse is not None:
526
+ cap0 = cap_sparse[0]
527
+ c_top_v, c_top_i, c_low_v, c_low_i = _topk_lowk(cap0)
528
+ debug_tokens["cap_indices"] = c_top_i
529
+ debug_tokens["cap_values"] = c_top_v
530
+ debug_tokens["cap_low_indices"] = c_low_i
531
+ debug_tokens["cap_low_values"] = c_low_v
532
+ debug_tokens["cap_nonzero"] = (cap0 > 0).sum().item()
533
+
534
+ # Push-up overlap top-5 / low-5.
535
+ p_w_d = p0.detach()
536
+ p_mean_d = p_w_d.mean(dim=-1, keepdim=True)
537
+ alpha = self.cap_push_up.p_mean_alpha if self.cap_push_up is not None else 1.0
538
+ overlap = (p_w_d + alpha * p_mean_d) * cap0.detach()
539
+ overlap_norm = overlap / (overlap.sum() + 1e-8)
540
+ ov_top_v, ov_top_i, ov_low_v, ov_low_i = _topk_lowk(overlap_norm)
541
+ debug_tokens["pushup_indices"] = ov_top_i
542
+ debug_tokens["pushup_values"] = ov_top_v
543
+ debug_tokens["pushup_low_indices"] = ov_low_i
544
+ debug_tokens["pushup_low_values"] = ov_low_v
545
+
546
+ # cap_bow_killed_pct: among bow-active caption tokens, what fraction
547
+ # did the SPLADE head zero out? (Higher = model treats more cap
548
+ # tokens as noise.)
549
+ if cap_bow is not None and cap_sparse_raw is not None:
550
+ bow_active = (cap_bow > 0).float()
551
+ raw_active = (cap_sparse_raw > 0).float()
552
+ killed = bow_active * (1.0 - raw_active)
553
+ denom = bow_active.sum(-1).clamp(min=1.0)
554
+ train_cap_bow_killed_pct = (killed.sum(-1) / denom).mean().detach()
555
+
556
+ return RetrievalOutput(
557
+ loss=loss,
558
+ rank_loss=rank_loss,
559
+ reg_loss=reg_loss,
560
+ reg_loss_p=reg_loss_p,
561
+ reg_loss_cap=reg_loss_cap,
562
+ cap_loss=cap_loss,
563
+ cap_sparse_rank_loss=cap_sparse_rank_loss,
564
+ train_p_nnz=train_p_nnz,
565
+ train_p_max=train_p_max,
566
+ train_flops_qd=train_flops_qd,
567
+ train_cap_bow_killed_pct=train_cap_bow_killed_pct,
568
+ query_reps=q_reps,
569
+ passage_reps=p_reps,
570
+ hard_neg_reps=hn_reps,
571
+ debug_tokens=debug_tokens,
572
+ )
train/models/pooling.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # V-SPLADE
2
+ # Copyright (c) 2026-present NAVER Corp.
3
+ # Apache-2.0
4
+
5
+ """
6
+ Pooling layer: (B, L, D) β†’ (B, D).
7
+
8
+ V_SPLADE uses MAX pooling (SPLADE-style position-wise max over the
9
+ sequence). Other strategies are provided for completeness.
10
+ """
11
+
12
+ import torch
13
+ import torch.nn as nn
14
+ from enum import Enum
15
+
16
+
17
+ class PoolingType(Enum):
18
+ MAX = "max"
19
+ MEAN = "mean"
20
+ EOS = "eos"
21
+ CLS = "cls"
22
+
23
+
24
+ class Pooling(nn.Module):
25
+ """Pool a sequence of hidden states to a single vector."""
26
+
27
+ def __init__(self, pooling_type: str):
28
+ super().__init__()
29
+ self.pooling_type = PoolingType(pooling_type)
30
+
31
+ def forward(
32
+ self,
33
+ hidden_states: torch.Tensor,
34
+ attention_mask: torch.Tensor,
35
+ ) -> torch.Tensor:
36
+ if self.pooling_type == PoolingType.MAX:
37
+ return self._max_pool(hidden_states, attention_mask)
38
+ if self.pooling_type == PoolingType.MEAN:
39
+ return self._mean_pool(hidden_states, attention_mask)
40
+ if self.pooling_type == PoolingType.EOS:
41
+ return self._eos_pool(hidden_states, attention_mask)
42
+ if self.pooling_type == PoolingType.CLS:
43
+ return hidden_states[:, 0]
44
+ raise ValueError(f"Unknown pooling type: {self.pooling_type}")
45
+
46
+ @staticmethod
47
+ def _max_pool(hidden_states, attention_mask):
48
+ mask = attention_mask.unsqueeze(-1).to(hidden_states.dtype)
49
+ h_masked = hidden_states * mask + (~mask.bool()) * (-1e9)
50
+ return h_masked.max(dim=1).values
51
+
52
+ @staticmethod
53
+ def _mean_pool(hidden_states, attention_mask):
54
+ mask = attention_mask.unsqueeze(-1).to(hidden_states.dtype)
55
+ seq_len = mask.sum(dim=1, keepdim=True).clamp(min=1)
56
+ return (hidden_states * mask).sum(dim=1) / seq_len.squeeze(1)
57
+
58
+ @staticmethod
59
+ def _eos_pool(hidden_states, attention_mask):
60
+ seq_lengths = (attention_mask.sum(dim=1) - 1).clamp(min=0)
61
+ batch_idx = torch.arange(hidden_states.size(0), device=hidden_states.device)
62
+ return hidden_states[batch_idx, seq_lengths]
train/models/query_encoder.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # V-SPLADE
2
+ # Copyright (c) 2026-present NAVER Corp.
3
+ # Apache-2.0
4
+
5
+ """
6
+ Query encoder strategies.
7
+
8
+ V_SPLADE quality variant uses an inference-free *Learned Sparse Retriever*
9
+ query encoder (``li_lsr``): a frozen copy of the backbone word embeddings,
10
+ optionally adapted with a low-rank LoRA, followed by a 1-dim projection
11
+ and an activation. At inference time the projection becomes a
12
+ ``vocab_size``-entry lookup table, so encoding a query is just an
13
+ ``Embedding(input_ids)`` β€” no transformer forward.
14
+
15
+ The simpler ``bow`` encoder turns the query into a binary token-presence
16
+ indicator over the vocab. Useful for smoke tests and the cheapest
17
+ inference-time setting.
18
+ """
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+ from enum import Enum
23
+ from typing import Optional
24
+
25
+
26
+ class QueryEncoderType(Enum):
27
+ BOW = "bow"
28
+ LI_LSR = "li_lsr"
29
+
30
+
31
+ class InferenceFreeQueryEncoder(nn.Module):
32
+ """Li-LSR style inference-free sparse query encoder.
33
+
34
+ Training: token_id → embedding (frozen + LoRA) → projection(D→1) →
35
+ activation β†’ scatter into a vocab-dim vector.
36
+ Inference: token_id β†’ precomputed lookup table (no forward pass).
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ embed_weight: torch.Tensor,
42
+ vocab_size: int,
43
+ hidden_size: int,
44
+ lora_r: int = 0,
45
+ activation: str = "relu",
46
+ ):
47
+ super().__init__()
48
+ assert activation in ("relu", "softplus"), f"Unknown activation: {activation}"
49
+ self.activation = activation
50
+
51
+ # Frozen copy of backbone word embeddings.
52
+ self.embeddings = nn.Embedding(vocab_size, hidden_size)
53
+ self.embeddings.weight.data.copy_(embed_weight.detach())
54
+ self.embeddings.weight.requires_grad = False
55
+
56
+ # Optional LoRA on the embedding lookup.
57
+ self.use_lora = lora_r > 0
58
+ if self.use_lora:
59
+ self.lora_A = nn.Parameter(torch.randn(vocab_size, lora_r) * 0.01)
60
+ self.lora_B = nn.Parameter(torch.zeros(lora_r, hidden_size))
61
+
62
+ # Projection: hidden_size β†’ 1 (fully tuned).
63
+ self.projection = nn.Linear(hidden_size, 1, bias=True)
64
+
65
+ self.vocab_size = vocab_size
66
+ self._lookup_table: Optional[torch.Tensor] = None
67
+
68
+ def _activate(self, x: torch.Tensor) -> torch.Tensor:
69
+ if self.activation == "relu":
70
+ return torch.log1p(torch.relu(x))
71
+ return torch.nn.functional.softplus(x)
72
+
73
+ def _valid_mask(self, input_ids: torch.Tensor) -> torch.Tensor:
74
+ return input_ids < self.vocab_size
75
+
76
+ def _get_embed(self, input_ids: torch.Tensor) -> torch.Tensor:
77
+ safe_ids = input_ids.clamp(max=self.vocab_size - 1)
78
+ e = self.embeddings(safe_ids)
79
+ if self.use_lora:
80
+ e = e + self.lora_A[safe_ids] @ self.lora_B
81
+ return e
82
+
83
+ def forward(
84
+ self, input_ids: torch.Tensor, attention_mask: torch.Tensor,
85
+ ) -> torch.Tensor:
86
+ """Training forward. Returns (B, vocab_size) sparse vector."""
87
+ valid = self._valid_mask(input_ids)
88
+ mask = attention_mask.float() * valid.float()
89
+ scores = self.projection(self._get_embed(input_ids)).squeeze(-1)
90
+ scores = self._activate(scores) * mask
91
+ B = input_ids.size(0)
92
+ safe_ids = input_ids.clamp(max=self.vocab_size - 1)
93
+ out = torch.zeros(B, self.vocab_size, device=input_ids.device, dtype=scores.dtype)
94
+ out.scatter_add_(1, safe_ids, scores)
95
+ return out
96
+
97
+ @torch.no_grad()
98
+ def build_lookup_table(self) -> torch.Tensor:
99
+ device = self.embeddings.weight.device
100
+ all_ids = torch.arange(self.vocab_size, device=device)
101
+ e = self._get_embed(all_ids.unsqueeze(0))
102
+ s = self.projection(e).squeeze(-1).squeeze(0)
103
+ self._lookup_table = self._activate(s)
104
+ return self._lookup_table
105
+
106
+ def encode_with_lookup(
107
+ self, input_ids: torch.Tensor, attention_mask: torch.Tensor,
108
+ ) -> torch.Tensor:
109
+ """Inference: pure lookup, no embedding/projection forward."""
110
+ if self._lookup_table is None:
111
+ self.build_lookup_table()
112
+ valid = self._valid_mask(input_ids)
113
+ mask = attention_mask.float() * valid.float()
114
+ safe_ids = input_ids.clamp(max=self.vocab_size - 1)
115
+ scores = self._lookup_table.to(input_ids.device)[safe_ids] * mask
116
+ B = input_ids.size(0)
117
+ out = torch.zeros(B, self.vocab_size, device=input_ids.device, dtype=scores.dtype)
118
+ out.scatter_add_(1, safe_ids, scores)
119
+ return out
120
+
121
+
122
+ class BOWQueryEncoder(nn.Module):
123
+ """Bag-of-Words: binary token indicator over the vocab."""
124
+
125
+ def __init__(self, vocab_size: int):
126
+ super().__init__()
127
+ self.vocab_size = vocab_size
128
+
129
+ def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
130
+ B, _ = input_ids.shape
131
+ device = input_ids.device
132
+ bow = torch.zeros(B, self.vocab_size, device=device, dtype=torch.bfloat16)
133
+ safe_ids = input_ids.clamp(0, self.vocab_size - 1) * attention_mask.long()
134
+ bow.scatter_(1, safe_ids, 1.0)
135
+ bow[:, 0] = 0.0 # clear padding token (id=0) accumulation
136
+ return bow
137
+
138
+
139
+ def build_query_encoder(
140
+ query_encoder_type: str,
141
+ vocab_size: int,
142
+ hidden_size: int = 768,
143
+ embed_weight: Optional[torch.Tensor] = None,
144
+ lora_r: int = 0,
145
+ activation: str = "relu",
146
+ **kwargs,
147
+ ) -> nn.Module:
148
+ if query_encoder_type == "bow":
149
+ return BOWQueryEncoder(vocab_size)
150
+ if query_encoder_type == "li_lsr":
151
+ if embed_weight is None:
152
+ raise ValueError("li_lsr requires embed_weight from the encoder")
153
+ return InferenceFreeQueryEncoder(
154
+ embed_weight, vocab_size, hidden_size,
155
+ lora_r=lora_r, activation=activation,
156
+ )
157
+ raise ValueError(
158
+ f"Unknown query_encoder_type: {query_encoder_type}. Choose: bow | li_lsr"
159
+ )