File size: 15,074 Bytes
6b3a024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6aa8fa0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6b3a024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6aa8fa0
 
 
68cf224
 
6aa8fa0
 
68cf224
 
6aa8fa0
68cf224
 
6aa8fa0
68cf224
6aa8fa0
 
 
 
 
 
 
 
6b3a024
6aa8fa0
6b3a024
 
6aa8fa0
6b3a024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# V-SPLADE
# Copyright (c) 2026-present NAVER Corp.
# Apache-2.0

"""
Encoder Layer — backbone model that produces hidden states.

V_SPLADE uses the VBert (BiModernVBERT) encoder as its sole backbone.
The encoder exposes a unified API:
    encode_passage(inputs) -> (hidden_states, attention_mask)
    encode_text(inputs)    -> (hidden_states, attention_mask)
"""

import torch
import torch.nn as nn
import torch.nn.functional as F
from enum import Enum
from typing import Optional, Tuple


class DecoupledEmbedding(nn.Embedding):
    """Embedding with a separate trainable additional vocabulary.

    Matches the V-SPLADE weight layout where tok_embeddings has both a
    main ``weight`` and an ``additional_embedding.weight`` for extra tokens.
    """

    def __init__(
        self,
        num_embeddings,
        num_additional_embeddings,
        embedding_dim,
        partially_freeze=False,
        device=None,
        dtype=None,
        padding_idx=None,
        **kwargs,
    ):
        if padding_idx is not None and padding_idx > num_embeddings:
            raise ValueError(f"padding_idx must be within num_embeddings. Got {padding_idx} and {num_embeddings}")
        super().__init__(
            num_embeddings=num_embeddings,
            embedding_dim=embedding_dim,
            device=device,
            dtype=dtype,
            padding_idx=padding_idx,
            **kwargs,
        )
        self.num_embeddings = num_embeddings
        self.num_additional_embeddings = num_additional_embeddings
        self.partially_freeze = partially_freeze
        if partially_freeze:
            self.weight.requires_grad_(False)
        if self.num_additional_embeddings > 0:
            self.additional_embedding = nn.Embedding(
                num_embeddings=num_additional_embeddings,
                embedding_dim=embedding_dim,
                device=device,
                dtype=dtype,
            )

    def forward(self, input_ids):
        if self.num_additional_embeddings == 0:
            return super().forward(input_ids)
        input_ids = input_ids.clone()
        additional_vocab_indices = torch.where(input_ids >= self.num_embeddings)
        input_ids_additional_vocab = input_ids[additional_vocab_indices]
        additional_embeddings = self.additional_embedding(input_ids_additional_vocab - self.num_embeddings)
        input_ids[additional_vocab_indices] = 0
        full_vector = F.embedding(input_ids, self.weight)
        full_vector[additional_vocab_indices] = additional_embeddings
        return full_vector


class EncoderType(Enum):
    VBERT = "vbert"


# --------------------------------------------------------------
# Abstract Base
# --------------------------------------------------------------

class BaseEncoder(nn.Module):
    """Abstract encoder base.

    Unified API:
        encode_passage(inputs) -> (hidden_states, attention_mask)
        encode_text(inputs)    -> (hidden_states, attention_mask)
    """

    vocab_size: int = 0
    hidden_size: int = 0

    def encode_passage(self, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]:
        raise NotImplementedError

    def encode_text(self, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]:
        raise NotImplementedError

    def gradient_checkpointing_enable(self, gradient_checkpointing_kwargs=None):
        pass

    def get_text_embeddings(self) -> Optional[nn.Embedding]:
        """Return the text embedding layer (for query-encoder initialization)."""
        return None


# --------------------------------------------------------------
# MLM head used by VBert
# --------------------------------------------------------------

class ModernVBertMLMHead(nn.Module):
    """MLM head: dense(768->768) -> GELU -> LayerNorm(768) -> decoder(768->50368)."""

    def __init__(self, hidden_size: int = 768, vocab_size: int = 50368):
        super().__init__()
        self.dense = nn.Linear(hidden_size, hidden_size)
        self.norm = nn.LayerNorm(hidden_size)
        self.decoder = nn.Linear(hidden_size, vocab_size)

    def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
        h = self.dense(hidden_states)
        h = F.gelu(h)
        h = self.norm(h)
        h = self.decoder(h)
        return h

    @classmethod
    def from_safetensors(cls, safetensors_path: str, **kwargs):
        from safetensors import safe_open
        head = cls(**kwargs)
        with safe_open(safetensors_path, framework="pt") as f:
            head.dense.weight.data.copy_(f.get_tensor("lm_head.head.dense.weight"))
            head.norm.weight.data.copy_(f.get_tensor("lm_head.head.norm.weight"))
            head.decoder.weight.data.copy_(f.get_tensor("lm_head.decoder.weight"))
            head.decoder.bias.data.copy_(f.get_tensor("lm_head.decoder.bias"))
        return head


# --------------------------------------------------------------
# VBert Encoder (BiModernVBERT)
# --------------------------------------------------------------

class VBertEncoder(BaseEncoder):
    """BiModernVBERT encoder + external MLM head, with optional LoRA."""

    def __init__(
        self,
        model_name: str = "ModernVBERT/bimodernvbert",
        lm_head_model: str = "ModernVBERT/ModernVBERT",
        lm_head_lora_r: int = 32,
        encoder_lora_r: int = 32,
        lm_head_full: bool = False,
        **kwargs,
    ):
        super().__init__()
        from peft import LoraConfig, get_peft_model
        from models.convert import ensure_compatible_backbone

        # 0. Auto-convert the backbone if it uses the upstream ModernVBERT layout.
        #    Compatible backbones (local or Hub) pass through unchanged; the raw
        #    upstream checkpoint is downloaded + converted once (cached) so that
        #    from_scratch training works directly from the Hub id.
        model_name = ensure_compatible_backbone(model_name)
        lm_head_model = ensure_compatible_backbone(lm_head_model) if lm_head_model else model_name

        # 1. Load encoder backbone.
        model_cls = self._resolve_model_cls(model_name)
        self.encoder = model_cls.from_pretrained(model_name, dtype=torch.bfloat16)

        # Disable compiled_mlp - FX tracing in gradient_checkpointing traces
        # both branches of the if/else in ModernBertEncoderLayer.forward(),
        # hitting compiled_mlp even when reference_compile is None/False.
        def _set_reference_compile_false(module):
            if hasattr(module, "config") and hasattr(module.config, "reference_compile"):
                module.config.reference_compile = False
        for m in self.encoder.modules():
            _set_reference_compile_false(m)

        # 2. Merge any existing LoRA adapters into base weights.
        has_lora = any("lora" in k for k in self.encoder.state_dict().keys())
        if has_lora:
            from peft.tuners.lora.layer import Linear as LoraLinear
            for _, mod in self.encoder.named_modules():
                if isinstance(mod, LoraLinear) and hasattr(mod, "merge"):
                    mod.merge()

        # 3. Apply a fresh full LoRA on encoder (all layers: attn + mlp).
        if encoder_lora_r > 0:
            self.encoder.model.text_model = get_peft_model(
                self.encoder.model.text_model,
                LoraConfig(
                    r=encoder_lora_r, lora_alpha=encoder_lora_r,
                    target_modules=["Wqkv", "Wo", "Wi"],
                    bias="none",
                ),
            )

        # 4. Load MLM head - from same model dir or separate model.
        import os as _os
        encoder_sf = _os.path.join(model_name, "model.safetensors")
        has_lm_head_in_encoder = False
        if _os.path.isfile(encoder_sf):
            from safetensors import safe_open as _safe_open
            with _safe_open(encoder_sf, framework="pt") as _f:
                has_lm_head_in_encoder = any("lm_head" in k for k in _f.keys())
        if has_lm_head_in_encoder:
            self.mlm_head = ModernVBertMLMHead.from_safetensors(
                encoder_sf, hidden_size=768, vocab_size=50368,
            ).to(torch.bfloat16)
        else:
            safetensors_path = self._find_safetensors(lm_head_model)
            self.mlm_head = ModernVBertMLMHead.from_safetensors(
                safetensors_path, hidden_size=768, vocab_size=50368,
            ).to(torch.bfloat16)

        # 5. Apply LoRA to MLM head (dense + decoder).
        if lm_head_lora_r > 0 and not lm_head_full:
            self.mlm_head = get_peft_model(self.mlm_head, LoraConfig(
                r=lm_head_lora_r, lora_alpha=lm_head_lora_r,
                target_modules=["dense", "decoder"], bias="none",
            ))

        self.vocab_size = 50368
        self.hidden_size = 768

        # Freeze base weights, keep LoRA trainable.
        for name, param in self.named_parameters():
            if "lora" in name.lower():
                param.requires_grad = True
            else:
                param.requires_grad = False

        # Optional full-parameter tuning for the MLM head (no LoRA).
        if lm_head_full:
            for param in self.mlm_head.parameters():
                param.requires_grad = True

    @classmethod
    def from_hf_export(cls, hf_dir: str, dtype: torch.dtype = torch.bfloat16) -> "VBertEncoder":
        """Build an empty VBertEncoder shell from a V-SPLADE HF export.

        Constructs `BiModernVBert(config)` + `ModernVBertMLMHead(...)` with
        randomly-initialized weights — the caller is expected to populate them
        via :func:`models.load_hf_export`. Used by `build_model(mode='inference_only')`.
        """
        from colpali_engine.models import BiModernVBert
        instance = cls.__new__(cls)
        nn.Module.__init__(instance)
        config = BiModernVBert.config_class.from_pretrained(hf_dir)
        instance.encoder  = BiModernVBert(config).to(dtype=dtype)

        # Replace text model embeddings with DecoupledEmbedding to match the
        # V-SPLADE weight layout (tok_embeddings.weight + additional_embedding.weight).
        # The native ModernBertModel uses a plain nn.Embedding with the FULL vocab
        # (50408). V-SPLADE splits this into main (50368) + additional (40).
        text_model = instance.encoder.model.text_model
        old_emb = text_model.get_input_embeddings()
        additional_vocab = getattr(config, "additional_vocab_size", 40)
        main_vocab = old_emb.num_embeddings - additional_vocab  # 50408 - 40 = 50368
        new_emb = DecoupledEmbedding(
            num_embeddings=main_vocab,
            num_additional_embeddings=additional_vocab,
            embedding_dim=old_emb.embedding_dim,
            padding_idx=old_emb.padding_idx if old_emb.padding_idx is not None and old_emb.padding_idx < main_vocab else None,
        ).to(dtype=dtype)
        text_model.set_input_embeddings(new_emb)

        # hidden_size may be at top-level (custom config) or under text_config
        # (native transformers 5.x ModernVBertConfig).
        hidden_size = getattr(config, "hidden_size", None)
        if hidden_size is None and hasattr(config, "text_config"):
            hidden_size = config.text_config.hidden_size
        instance.mlm_head = ModernVBertMLMHead(
            hidden_size=hidden_size, vocab_size=50368,
        ).to(dtype=dtype)
        instance.vocab_size = 50368
        instance.hidden_size = hidden_size
        # No grad needed at inference; trainer-side flags are not touched.
        for p in instance.parameters():
            p.requires_grad = False
        return instance

    @staticmethod
    def _resolve_model_cls(model_name: str):
        import json, os
        from colpali_engine.models import BiModernVBert

        config_path = os.path.join(model_name, "config.json")
        adapter_config_path = os.path.join(model_name, "adapter_config.json")

        if os.path.isfile(adapter_config_path):
            with open(adapter_config_path) as f:
                adapter_cfg = json.load(f)
            base_path = adapter_cfg.get("base_model_name_or_path", "")
            base_config = os.path.join(base_path, "config.json")
            if os.path.isfile(base_config):
                config_path = base_config

        if os.path.isfile(config_path):
            with open(config_path) as f:
                cfg = json.load(f)
            archs = cfg.get("architectures", [])
            # V_SPLADE only uses the bidirectional encoder variant.
            if "BiModernVBert" in archs:
                return BiModernVBert

        return BiModernVBert

    @staticmethod
    def _find_safetensors(model_name: str) -> str:
        import os
        local = os.path.join(model_name, "model.safetensors")
        if os.path.isfile(local):
            return local
        from huggingface_hub import hf_hub_download
        return hf_hub_download(model_name, "model.safetensors")

    def gradient_checkpointing_enable(self, gradient_checkpointing_kwargs=None):
        kwargs = gradient_checkpointing_kwargs or {"use_reentrant": False}
        text_model = self.encoder.model.text_model
        if hasattr(text_model, "gradient_checkpointing_enable"):
            text_model.gradient_checkpointing_enable(gradient_checkpointing_kwargs=kwargs)

    def _get_hidden_states(
        self,
        input_ids: torch.Tensor,
        attention_mask: torch.Tensor,
        pixel_values: Optional[torch.Tensor] = None,
        pixel_attention_mask: Optional[torch.Tensor] = None,
    ) -> torch.Tensor:
        kw = dict(input_ids=input_ids, attention_mask=attention_mask)
        if pixel_values is not None:
            kw["pixel_values"] = pixel_values
        if pixel_attention_mask is not None:
            kw["pixel_attention_mask"] = pixel_attention_mask
        outputs = self.encoder.model(**kw)
        return outputs[0]

    def encode_passage(self, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]:
        hidden = self._get_hidden_states(
            kwargs["input_ids"], kwargs["attention_mask"],
            kwargs.get("pixel_values"), kwargs.get("pixel_attention_mask"),
        )
        return hidden, kwargs["attention_mask"]

    def encode_text(self, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]:
        hidden = self._get_hidden_states(kwargs["input_ids"], kwargs["attention_mask"])
        return hidden, kwargs["attention_mask"]

    def get_lm_head(self):
        return self.mlm_head

    def get_text_embeddings(self) -> Optional[nn.Module]:
        return self.encoder.model.text_model.get_input_embeddings()

    @property
    def image_token_id(self) -> int:
        return 50407  # BiModernVBERT <image> token


# --------------------------------------------------------------
# Factory
# --------------------------------------------------------------

def build_encoder(encoder_type: str, **kwargs) -> BaseEncoder:
    """Build encoder by type string. V_SPLADE only ships the vbert backbone."""
    if encoder_type == "vbert":
        return VBertEncoder(**kwargs)
    raise ValueError(f"Unknown encoder_type: {encoder_type}. Choose: vbert")