Spaces:
Running on Zero
Running on Zero
| # V-SPLADE | |
| # Copyright (c) 2026-present NAVER Corp. | |
| # Apache-2.0 | |
| """ | |
| Backbone conversion: upstream ModernVBERT -> V-SPLADE-compatible layout. | |
| The public ``ModernVBERT/modernvbert`` checkpoint stores its embeddings/LM-head | |
| in a layout the decoupled-embedding model can't load (combined 50408 embedding, | |
| plain LM head, double-nested vision keys, no decoupled-vocab config). This | |
| module re-packages it. | |
| It is used two ways: | |
| * ``ensure_compatible_backbone(ref)`` β called automatically by the training | |
| loader; converts on the fly and caches, so ``from_scratch`` "just works" | |
| with the upstream Hub id. | |
| * ``scripts/convert_modernvbert_backbone.py`` β a thin CLI over the same code. | |
| Verified transform (every tensor reproduced bit-identically from upstream): | |
| tok_embeddings.weight <- upstream[:50368] | |
| tok_embeddings.additional_embedding.weight <- upstream[50368:50408] | |
| lm_head.decoder.weight / .bias <- upstream lm_head.weight/bias[:50368] | |
| additional_fc.weight <- upstream lm_head.weight[50368:50408] | |
| lm_head.head.dense.weight <- upstream projection_head.dense.weight | |
| lm_head.head.norm.weight <- upstream projection_head.norm.weight | |
| model.vision_model.X <- upstream model.vision_model.vision_model.X | |
| model.connector.modality_projection.proj.weight <- upstream ...modality_projection.weight | |
| (all other model.text_model.* keys copied unchanged) | |
| """ | |
| import json | |
| import os | |
| import shutil | |
| from pathlib import Path | |
| import torch | |
| from safetensors.torch import load_file, save_file | |
| MAIN_VOCAB = 50368 | |
| FULL_VOCAB = 50408 | |
| ADDITIONAL_VOCAB = FULL_VOCAB - MAIN_VOCAB # 40 | |
| UPSTREAM_REPO = "ModernVBERT/modernvbert" | |
| TOKENIZER_REPO = "jhu-clsp/ettin-encoder-150m" | |
| PREPROCESSOR_CONFIG = { | |
| "do_convert_rgb": True, "do_image_splitting": True, "do_normalize": True, | |
| "do_pad": True, "do_rescale": True, "do_resize": True, | |
| "image_mean": [0.5, 0.5, 0.5], "image_std": [0.5, 0.5, 0.5], | |
| "image_processor_type": "Idefics3ImageProcessor", | |
| "processor_class": "Idefics3Processor", | |
| "max_image_size": {"longest_edge": 512}, "resample": 1, | |
| "rescale_factor": 0.00392156862745098, "size": {"longest_edge": 2048}, | |
| } | |
| PROCESSOR_CONFIG = {"image_seq_len": 64, "processor_class": "Idefics3Processor"} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Core transform | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def transform_state_dict(u: dict) -> dict: | |
| """Apply the verified upstream -> V-SPLADE backbone weight recipe.""" | |
| out = {} | |
| tok = u["model.text_model.embeddings.tok_embeddings.weight"] | |
| assert tok.shape[0] == FULL_VOCAB, f"unexpected embedding rows: {tuple(tok.shape)}" | |
| out["model.text_model.embeddings.tok_embeddings.weight"] = tok[:MAIN_VOCAB].clone() | |
| out["model.text_model.embeddings.tok_embeddings.additional_embedding.weight"] = \ | |
| tok[MAIN_VOCAB:FULL_VOCAB].clone() | |
| lw, lb = u["lm_head.weight"], u["lm_head.bias"] | |
| out["lm_head.decoder.weight"] = lw[:MAIN_VOCAB].clone() | |
| out["lm_head.decoder.bias"] = lb[:MAIN_VOCAB].clone() | |
| out["additional_fc.weight"] = lw[MAIN_VOCAB:FULL_VOCAB].clone() | |
| out["lm_head.head.dense.weight"] = u["projection_head.dense.weight"].clone() | |
| out["lm_head.head.norm.weight"] = u["projection_head.norm.weight"].clone() | |
| out["model.connector.modality_projection.proj.weight"] = \ | |
| u["model.connector.modality_projection.weight"].clone() | |
| vp = "model.vision_model.vision_model." | |
| for k, v in u.items(): | |
| if k.startswith(vp): | |
| out["model.vision_model." + k[len(vp):]] = v.clone() | |
| for k, v in u.items(): | |
| if k.startswith("model.text_model.") and "tok_embeddings" not in k: | |
| out[k] = v.clone() | |
| return out | |
| def build_config(u_cfg: dict) -> dict: | |
| """Patch the upstream config into the decoupled-embedding V-SPLADE layout.""" | |
| cfg = json.loads(json.dumps(u_cfg)) | |
| cfg["vocab_size"] = MAIN_VOCAB | |
| cfg["additional_vocab_size"] = ADDITIONAL_VOCAB | |
| cfg["freeze_config"] = { | |
| "freeze_lm_head": True, "freeze_text_layers": True, "freeze_vision_layers": True, | |
| } | |
| cfg.setdefault("architectures", ["BiModernVBert"]) | |
| if "text_config" in cfg: | |
| cfg["text_config"]["vocab_size"] = MAIN_VOCAB | |
| return cfg | |
| def is_compatible_config(cfg: dict) -> bool: | |
| """True if the config already uses the decoupled-embedding V-SPLADE layout.""" | |
| return cfg.get("freeze_config") is not None and cfg.get("additional_vocab_size") is not None | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Conversion + auto-ensure | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _load_config(ref: str) -> dict: | |
| if os.path.isdir(ref): | |
| return json.load(open(os.path.join(ref, "config.json"))) | |
| from huggingface_hub import hf_hub_download | |
| return json.load(open(hf_hub_download(ref, "config.json"))) | |
| def convert_backbone(ref: str, out_dir, tokenizer_repo: str = TOKENIZER_REPO, | |
| with_tokenizer: bool = True) -> str: | |
| """Convert backbone ``ref`` (Hub id or local dir) into ``out_dir``. Returns out_dir.""" | |
| out = Path(out_dir); out.mkdir(parents=True, exist_ok=True) | |
| if os.path.isdir(ref): | |
| sd_path = os.path.join(ref, "model.safetensors") | |
| cfg = json.load(open(os.path.join(ref, "config.json"))) | |
| else: | |
| from huggingface_hub import hf_hub_download | |
| sd_path = hf_hub_download(ref, "model.safetensors") | |
| cfg = json.load(open(hf_hub_download(ref, "config.json"))) | |
| save_file(transform_state_dict(load_file(sd_path)), | |
| str(out / "model.safetensors"), metadata={"format": "pt"}) | |
| json.dump(build_config(cfg), open(out / "config.json", "w"), indent=2) | |
| if with_tokenizer: | |
| from huggingface_hub import hf_hub_download | |
| for fn in ["tokenizer.json", "tokenizer_config.json", "special_tokens_map.json"]: | |
| try: | |
| shutil.copy2(hf_hub_download(tokenizer_repo, fn), out / fn) | |
| except Exception: | |
| pass | |
| json.dump(PREPROCESSOR_CONFIG, open(out / "preprocessor_config.json", "w"), indent=2) | |
| json.dump(PROCESSOR_CONFIG, open(out / "processor_config.json", "w"), indent=2) | |
| return str(out) | |
| def _cache_root() -> Path: | |
| root = os.environ.get("VSPLADE_BACKBONE_CACHE") | |
| if root: | |
| return Path(root) | |
| return Path.home() / ".cache" / "v-splade" / "backbones" | |
| def ensure_compatible_backbone(ref: str, tokenizer_repo: str = TOKENIZER_REPO, | |
| verbose: bool = True) -> str: | |
| """Return a local path to a V-SPLADE-compatible backbone for ``ref``. | |
| If ``ref`` already uses the decoupled layout, it is returned unchanged | |
| (``from_pretrained`` will download a Hub id as usual). Otherwise the upstream | |
| checkpoint is converted once into a cache directory and that path is returned, | |
| so ``from_scratch`` training works directly from the raw upstream Hub id. | |
| """ | |
| try: | |
| cfg = _load_config(ref) | |
| except Exception: | |
| return ref # can't introspect (offline/unknown) β let from_pretrained handle it | |
| if is_compatible_config(cfg): | |
| return ref | |
| out = _cache_root() / ref.replace("/", "__") | |
| if (out / "model.safetensors").is_file() and (out / "config.json").is_file(): | |
| if verbose: | |
| print(f"[convert] using cached converted backbone: {out}") | |
| return str(out) | |
| if verbose: | |
| print(f"[convert] '{ref}' is an upstream-layout backbone; " | |
| f"converting once -> {out}") | |
| return convert_backbone(ref, out, tokenizer_repo=tokenizer_repo) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Double-check (used by the CLI) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def double_check(out_dir, ref_dir) -> bool: | |
| out_dir, ref_dir = Path(out_dir), Path(ref_dir) | |
| print(f"\n[verify] comparing {out_dir} vs reference {ref_dir}") | |
| o = load_file(out_dir / "model.safetensors") | |
| r = load_file(ref_dir / "model.safetensors") | |
| ok = True | |
| if set(o) != set(r): | |
| ok = False | |
| print(f" [FAIL] key sets differ " | |
| f"(only_out={sorted(set(o)-set(r))[:3]} only_ref={sorted(set(r)-set(o))[:3]})") | |
| else: | |
| print(f" [ok] key sets match ({len(o)} tensors)") | |
| mismatched = [k for k in set(o) & set(r) | |
| if o[k].shape != r[k].shape or not torch.equal(o[k].float(), r[k].float())] | |
| if mismatched: | |
| ok = False | |
| print(f" [FAIL] {len(mismatched)} tensor(s) differ, e.g. {mismatched[:5]}") | |
| else: | |
| print(f" [ok] all {len(set(o) & set(r))} shared tensors bit-identical") | |
| oc, rc = json.load(open(out_dir / "config.json")), json.load(open(ref_dir / "config.json")) | |
| for f in ["vocab_size", "additional_vocab_size", "freeze_config"]: | |
| if oc.get(f) != rc.get(f): | |
| ok = False; print(f" [FAIL] config.{f}: {oc.get(f)} != {rc.get(f)}") | |
| else: | |
| print(f" [ok] config.{f} == {oc.get(f)}") | |
| print(f"\n[verify] {'PASSED' if ok else 'FAILED'}.") | |
| return ok | |