"""SPARSEUP (Linkup) recipe for Sentence Transformers. Architecture: MLM → log1p(ReLU(x − θ)) → per-position top-k → max-pool → case / space-marker folding. The ``[Q] `` / ``[D] `` prefixes are expected by the backbone but excluded from pooling. References: - https://huggingface.co/Linkup-Platform/linkup-sparseup-embed-v1 - https://www.linkup.so/blog/sparseup """ from __future__ import annotations import html as html_lib import json import logging import os import re from collections import defaultdict from pathlib import Path from typing import Any import torch from sentence_transformers.base.modules.module import Module logger = logging.getLogger(__name__) QUERY_PREFIX = "[Q] " DOCUMENT_PREFIX = "[D] " # --------------------------------------------------------------------------- # Vocab fold (ingredient #3) # --------------------------------------------------------------------------- def _surface_body(token: str) -> str | None: """Lowercase body of a BPE token, without the space marker ``Ġ`` / ``▁``.""" if not token or token.startswith(("[", "<", "Ġ[", "▁[")): return None body = token[1:] if token[:1] in {"Ġ", "▁"} else token body = body.lower() return body or None def _fold_rank(token: str, body: str) -> tuple[int, str]: """Prefer the lowercase form with a word-space marker, then the bare form.""" stripped = token[1:] if token[:1] in {"Ġ", "▁"} else token space = token[:1] if token[:1] in {"Ġ", "▁"} else "" if space == "▁" and stripped == body: return (0, token) if space == "Ġ" and stripped == body: return (1, token) if token == body: return (2, token) if stripped.lower() == body: return (3, token) return (4, token) def build_case_space_fold(tokenizer) -> torch.Tensor: """Map each id to a canonical id (lowercase + space marker).""" vocab = tokenizer.get_vocab() size = max(len(tokenizer), max(vocab.values(), default=-1) + 1) fold = torch.arange(size, dtype=torch.long) groups: dict[str, list[tuple[str, int]]] = defaultdict(list) for token, idx in vocab.items(): if idx < 0 or idx >= size: continue body = _surface_body(token) if body is None: continue groups[body].append((token, int(idx))) n_merged = 0 for body, members in groups.items(): if len(members) < 2: continue members.sort(key=lambda item: _fold_rank(item[0], body)) canon = members[0][1] for _, idx in members: fold[idx] = canon n_merged += len(members) - 1 logger.info( "vocab_fold case_space: %s ids → ~%s canonical (%s merged)", f"{size:,}", f"{size - n_merged:,}", f"{n_merged:,}", ) return fold def prefix_token_ids(tokenizer, prefixes: tuple[str, ...] = (QUERY_PREFIX, DOCUMENT_PREFIX)) -> list[list[int]]: ids = [] for prefix in prefixes: enc = tokenizer(prefix, add_special_tokens=False)["input_ids"] if enc: ids.append([int(x) for x in enc]) return ids def pooling_mask( input_ids: torch.Tensor, attention_mask: torch.Tensor, prefix_ids: list[list[int]] | None, special_ids: list[int] | None, ) -> torch.Tensor: """Pooling mask: drop specials + prefix at the start of the sequence.""" mask = attention_mask.clone() if special_ids: special = torch.tensor(special_ids, device=input_ids.device, dtype=input_ids.dtype) mask = mask.masked_fill(torch.isin(input_ids, special), 0) if not prefix_ids: return mask # If the first token is an already-masked special, the prefix starts at 1. starts_with_special = mask[:, 0] == 0 for pref in prefix_ids: n = len(pref) if n == 0 or n >= input_ids.size(1): continue pref_t = torch.tensor(pref, device=input_ids.device, dtype=input_ids.dtype) head0 = input_ids[:, :n] head1 = input_ids[:, 1 : 1 + n] match0 = (head0 == pref_t).all(dim=1) & ~starts_with_special match1 = (head1 == pref_t).all(dim=1) & starts_with_special if bool(match0.any()): mask[match0, :n] = 0 if bool(match1.any()): mask[match1, 1 : 1 + n] = 0 return mask # --------------------------------------------------------------------------- # SPARSEUP pooling # --------------------------------------------------------------------------- def _pair_mean_pool(weights: torch.Tensor, mask: torch.Tensor, factor: int) -> tuple[torch.Tensor, torch.Tensor]: """Average ``factor`` adjacent tokens (cheap hpool). Ward is not applicable in vocab dim.""" if factor <= 1: return weights, mask batch, length, vocab = weights.shape pad = (factor - length % factor) % factor if pad: weights = torch.nn.functional.pad(weights, (0, 0, 0, pad)) mask = torch.nn.functional.pad(mask, (0, pad)) groups = weights.size(1) // factor w = weights.view(batch, groups, factor, vocab) m = mask.view(batch, groups, factor, 1) denom = m.sum(dim=2).clamp_min(1.0) pooled = (w * m).sum(dim=2) / denom new_mask = (m.sum(dim=2).squeeze(-1) > 0).to(dtype=mask.dtype) return pooled, new_mask class SparseUpPooling(Module): """log1p(ReLU(logits − θ)) + per-position top-k + max-pool + vocab fold. ``pool_factor`` > 1: average adjacent tokens **before** max-pool (hpool documents, like KURE / LI). Queries: leave at 1. """ config_keys = [ "logit_shift", "position_top_k", "vocab_fold", "chunk_size", "prefix_token_ids", "special_token_ids", "embedding_dimension", "pool_factor", ] def __init__( self, logit_shift: float = 15.0, position_top_k: int | None = 12, vocab_fold: str | None = "case_space", chunk_size: int | None = 32, prefix_token_ids: list[list[int]] | None = None, special_token_ids: list[int] | None = None, embedding_dimension: int | None = None, vocab_fold_index: torch.Tensor | None = None, pool_factor: int = 1, ) -> None: super().__init__() self.logit_shift = float(logit_shift) self.position_top_k = int(position_top_k) if position_top_k else None self.vocab_fold = vocab_fold self.chunk_size = chunk_size self.prefix_token_ids = prefix_token_ids or [] self.special_token_ids = special_token_ids or [] self.embedding_dimension = embedding_dimension self.pool_factor = max(1, int(pool_factor)) self._hpool_enabled = False if vocab_fold_index is None: n = embedding_dimension or 1 vocab_fold_index = torch.arange(n, dtype=torch.long) self.register_buffer("vocab_fold_index", vocab_fold_index.long(), persistent=True) def get_embedding_dimension(self) -> int | None: return self.embedding_dimension def _activate(self, logits: torch.Tensor) -> torch.Tensor: weights = torch.log1p(torch.relu(logits - self.logit_shift)) k = self.position_top_k if k is not None and 0 < k < weights.size(-1): cutoff = weights.topk(k, dim=-1).values[..., -1:] weights = weights * (weights >= cutoff) return weights def _fold(self, sparse: torch.Tensor) -> torch.Tensor: if self.vocab_fold is None or self.vocab_fold_index.numel() != sparse.size(-1): return sparse index = self.vocab_fold_index.to(device=sparse.device, dtype=torch.long) if torch.equal(index, torch.arange(index.numel(), device=index.device)): return sparse index = index.unsqueeze(0).expand_as(sparse) return torch.zeros_like(sparse).scatter_reduce(1, index, sparse, reduce="amax", include_self=False) def _pool_mask(self, features: dict[str, torch.Tensor], length: int, device) -> torch.Tensor: attn = features.get("attention_mask") ids = features.get("input_ids") if attn is None: return torch.ones(ids.size(0), length, device=device) if ids is not None else None if ids is None: return attn return pooling_mask(ids, attn, self.prefix_token_ids, self.special_token_ids) def forward(self, features: dict[str, torch.Tensor], **kwargs) -> dict[str, torch.Tensor]: if "cu_seq_lens_q" in features: return self._forward_flattened(features) logits = features["token_embeddings"] batch, seq_len, vocab = logits.shape device = logits.device mask = self._pool_mask(features, seq_len, device) if mask is None: mask = torch.ones(batch, seq_len, device=device, dtype=logits.dtype) mask = mask.to(device=device, dtype=logits.dtype) pooled = torch.full((batch, vocab), float("-inf"), dtype=logits.dtype, device=device) factor = self.pool_factor if self._hpool_enabled else 1 step = seq_len if not self.chunk_size else max(1, int(self.chunk_size)) for start in range(0, seq_len, step): chunk = logits[:, start : start + step] chunk_mask = mask[:, start : start + step] weights = self._activate(chunk) * chunk_mask.unsqueeze(-1) if factor > 1: weights, chunk_mask = _pair_mean_pool(weights, chunk_mask, factor) weights = weights * chunk_mask.unsqueeze(-1) pooled = torch.maximum(pooled, weights.max(dim=1).values) pooled = torch.where(torch.isfinite(pooled), pooled, torch.zeros_like(pooled)) pooled = self._fold(pooled) if self.embedding_dimension is None: self.embedding_dimension = pooled.size(1) features["sentence_embedding"] = pooled return features def _forward_flattened(self, features: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: flat = features["token_embeddings"].squeeze(0) cu = features["cu_seq_lens_q"].tolist() ids = features.get("input_ids") attn = features.get("attention_mask") rows = [] for i, (start, end) in enumerate(zip(cu[:-1], cu[1:])): weights = self._activate(flat[start:end]) if ids is not None and attn is not None: row_ids = ids[i : i + 1, : end - start] row_attn = attn[i : i + 1, : end - start] # Unpadded tensors often don't have the padded shape; keep everything. _ = (row_ids, row_attn) if self._hpool_enabled and self.pool_factor > 1 and weights.dim() == 2: w = weights.unsqueeze(0) m = torch.ones(1, w.size(1), device=w.device, dtype=w.dtype) w, _ = _pair_mean_pool(w, m, self.pool_factor) weights = w.squeeze(0) rows.append(weights.max(dim=0).values) pooled = self._fold(torch.stack(rows)) if self.embedding_dimension is None: self.embedding_dimension = pooled.size(1) features["sentence_embedding"] = pooled return features def save(self, output_path: str, *args, safe_serialization: bool = True, **kwargs) -> None: os.makedirs(output_path, exist_ok=True) self.save_config(output_path) self.save_torch_weights(output_path, safe_serialization=safe_serialization) @classmethod def load(cls, model_name_or_path: str, subfolder: str = "", **kwargs): # Sentence Transformers passes trust_remote_code here; load_config does not accept it. config = cls.load_config( model_name_or_path, subfolder=subfolder, token=kwargs.get("token"), cache_folder=kwargs.get("cache_folder"), revision=kwargs.get("revision"), local_files_only=kwargs.get("local_files_only", False), ) module = cls(**config) try: cls.load_torch_weights( model_name_or_path, subfolder=subfolder, model=module, token=kwargs.get("token"), cache_folder=kwargs.get("cache_folder"), revision=kwargs.get("revision"), local_files_only=kwargs.get("local_files_only", False), ) except ValueError: logger.warning("No SparseUpPooling weights (identity fold).") return module def __repr__(self) -> str: return f"SparseUpPooling({self.get_config_dict()})" class hpool_documents: """Enable hpool only for ``encode_document`` (queries untouched).""" def __init__(self, module: SparseUpPooling | None): self.module = module self._prev = False def __enter__(self): if self.module is not None: self._prev = self.module._hpool_enabled self.module._hpool_enabled = True return self def __exit__(self, *exc): if self.module is not None: self.module._hpool_enabled = self._prev return False def build_pooling( tokenizer, logit_shift: float = 15.0, position_top_k: int | None = 12, vocab_fold: str | None = "case_space", chunk_size: int | None = 32, ) -> SparseUpPooling: fold = build_case_space_fold(tokenizer) if vocab_fold == "case_space" else None specials = sorted({int(i) for i in tokenizer.all_special_ids}) dim = int(len(tokenizer)) if fold is not None and fold.numel() < dim: padded = torch.arange(dim, dtype=torch.long) padded[: fold.numel()] = fold fold = padded elif fold is not None: dim = int(fold.numel()) return SparseUpPooling( logit_shift=logit_shift, position_top_k=position_top_k, vocab_fold=vocab_fold, chunk_size=chunk_size, prefix_token_ids=prefix_token_ids(tokenizer), special_token_ids=specials, embedding_dimension=dim, vocab_fold_index=fold, ) # --------------------------------------------------------------------------- # Grafting the MLM head (backbone ingredient) # --------------------------------------------------------------------------- def _encoder_module(model): if hasattr(model, "model") and hasattr(model.model, "embeddings"): return model.model if hasattr(model, "embeddings"): return model raise TypeError(f"Encoder not found on {type(model)}") def _copy_matching(dst: torch.nn.Module, src: torch.nn.Module) -> tuple[int, int]: dst_sd = dst.state_dict() src_sd = src.state_dict() merged = dict(dst_sd) n_full = n_partial = 0 for key, src_t in src_sd.items(): if key not in dst_sd: continue dst_t = dst_sd[key] if dst_t.shape == src_t.shape: merged[key] = src_t n_full += 1 continue if dst_t.ndim != src_t.ndim: continue sl = tuple(slice(0, min(a, b)) for a, b in zip(dst_t.shape, src_t.shape)) cloned = dst_t.clone() cloned[sl] = src_t[sl] merged[key] = cloned n_partial += 1 dst.load_state_dict(merged, strict=False) return n_full, n_partial def _ensure_qd_tokens(tokenizer, model) -> int: """Add ``[Q] `` / ``[D] `` if missing (like LateOn / mLateOn).""" extra = [QUERY_PREFIX, DOCUMENT_PREFIX] existing = set(tokenizer.all_special_tokens) to_add = [t for t in extra if t not in existing] if not to_add: return 0 n = tokenizer.add_special_tokens({"additional_special_tokens": to_add}) if n: model.resize_token_embeddings(len(tokenizer)) logger.info("Added tokens: %s (vocab %s)", to_add, len(tokenizer)) return n def prepare_bare_mlm(mlm_name: str, output_dir: str | Path) -> Path: """MLM checkpoint + ``[Q] `` / ``[D] `` prefixes, without the LateOn encoder.""" from transformers import AutoModelForMaskedLM, AutoTokenizer output_dir = Path(output_dir) marker = output_dir / "graft_meta.json" if marker.exists() and (output_dir / "config.json").exists(): meta = json.loads(marker.read_text()) if meta.get("late") is None and meta.get("mlm") == mlm_name: logger.info("MLM cache already present: %s", output_dir) return output_dir logger.info("Loading bare MLM %s", mlm_name) tokenizer = AutoTokenizer.from_pretrained(mlm_name) mlm = AutoModelForMaskedLM.from_pretrained(mlm_name, torch_dtype=torch.float32) _ensure_qd_tokens(tokenizer, mlm) if hasattr(mlm, "tie_weights"): mlm.tie_weights() output_dir.mkdir(parents=True, exist_ok=True) mlm.config.architectures = [mlm.__class__.__name__] mlm.save_pretrained(output_dir) tokenizer.save_pretrained(output_dir) marker.write_text( json.dumps({"late": None, "mlm": mlm_name, "vocab_size": int(mlm.config.vocab_size)}, indent=2) ) logger.info("Bare MLM → %s (vocab %s)", output_dir, mlm.config.vocab_size) del mlm return output_dir def graft_mlm_head( late_name: str, mlm_name: str, output_dir: str | Path, model_kwargs: dict[str, Any] | None = None, ) -> Path: """Graft the ``mlm_name`` MLM head onto the ``late_name`` LateOn encoder. ``decoder.weight`` stays tied to the LateOn embeddings (like SPARSEUP). ``head`` + ``decoder.bias`` come from the original MLM checkpoint. Both checkpoints must share the same tokenizer (up to the ``[Q]`` / ``[D]`` tokens). An mmBERT ↔ modernCamemBERT graft is rejected: 256k vs 32k. """ from sentence_transformers import MultiVectorEncoder from transformers import AutoModelForMaskedLM output_dir = Path(output_dir) marker = output_dir / "graft_meta.json" if marker.exists() and (output_dir / "config.json").exists(): meta = json.loads(marker.read_text()) if meta.get("late") == late_name and meta.get("mlm") == mlm_name: logger.info("Graft cache already present: %s", output_dir) return output_dir logger.info("Loading LateOn %s", late_name) late = MultiVectorEncoder(late_name, model_kwargs=model_kwargs or {"torch_dtype": "float32"}) late_enc = _encoder_module(late[0].auto_model) tokenizer = late[0].tokenizer late_vocab = int(late_enc.config.vocab_size) logger.info("Loading MLM head %s", mlm_name) mlm = AutoModelForMaskedLM.from_pretrained(mlm_name, torch_dtype=torch.float32) mlm_vocab = int(mlm.config.vocab_size) extra = late_vocab - mlm_vocab if extra < 0 or extra > 16: raise ValueError( f"Incompatible vocabularies for a SPARSEUP graft: " f"encoder {late_vocab} vs MLM head {mlm_vocab} ({mlm_name}). " "The head must come from the same tokenizer as the encoder " "(mmBERT-base with mLateOn, modernCamemBERT with a modernCamemBERT PFT). " "We do not graft a 32k head onto a 256k encoder: the ids no longer make sense." ) if late_vocab != mlm_vocab: logger.info("resize_token_embeddings %s → %s ([Q]/[D])", mlm_vocab, late_vocab) mlm.resize_token_embeddings(late_vocab) if hasattr(mlm, "decoder") and getattr(mlm.decoder, "bias", None) is not None: with torch.no_grad(): mlm.decoder.bias[mlm_vocab:] = 0.0 n_full, n_partial = _copy_matching(_encoder_module(mlm), late_enc) if hasattr(mlm, "tie_weights"): mlm.tie_weights() output_dir.mkdir(parents=True, exist_ok=True) mlm.config.architectures = [mlm.__class__.__name__] mlm.save_pretrained(output_dir) tokenizer.save_pretrained(output_dir) marker.write_text( json.dumps( { "late": late_name, "mlm": mlm_name, "copied_full": n_full, "copied_partial": n_partial, "vocab_size": late_vocab, }, indent=2, ) ) logger.info("Graft OK (%s full tensors, %s partial) → %s", n_full, n_partial, output_dir) del late, mlm return output_dir def build_sparseup_encoder( init_dir: str | Path, *, logit_shift: float = 15.0, position_top_k: int | None = 12, vocab_fold: str | None = "case_space", chunk_size: int | None = 32, query_length: int = 128, document_length: int = 512, model_kwargs: dict[str, Any] | None = None, ): from sentence_transformers.base.modules.transformer import Transformer from sentence_transformers.sparse_encoder import SparseEncoder from sentence_transformers.sparse_encoder.model_card import SparseEncoderModelCardData init_dir = str(init_dir) transformer = Transformer( init_dir, transformer_task="fill-mask", query_length=query_length, document_length=document_length, model_kwargs=model_kwargs or {"torch_dtype": "float32"}, ) pooling = build_pooling( transformer.tokenizer, logit_shift=logit_shift, position_top_k=position_top_k, vocab_fold=vocab_fold, chunk_size=chunk_size, ) model = SparseEncoder( modules=[transformer, pooling], prompts={"query": QUERY_PREFIX, "document": DOCUMENT_PREFIX}, similarity_fn_name="dot", model_card_data=SparseEncoderModelCardData( language=["fr"], license="apache-2.0", model_name="SPARSEUP-style French sparse encoder", ), ) return model # --------------------------------------------------------------------------- # Probes and reading bags # --------------------------------------------------------------------------- @torch.inference_mode() def probe_logits( model, texts: list[str], batch_size: int = 1, max_length: int = 64, max_tokens: int = 48, max_values: int = 400_000, ) -> dict[str, float]: """Percentiles of the MLM logits (before shift) to calibrate θ. We don't materialize the whole vocabulary: a ``[B, L, 256k]`` float32 tensor is enough to saturate RAM/GPU and freeze the cell. """ transformer = model[0] tokenizer = transformer.tokenizer device = next(transformer.parameters()).device cap = min(max_length, transformer.document_length or max_length) chunks: list[torch.Tensor] = [] n_seen = 0 print(f"Probing {len(texts)} texts (max_length={cap}, batch={batch_size})…", flush=True) for start in range(0, len(texts), batch_size): batch = texts[start : start + batch_size] enc = tokenizer( [DOCUMENT_PREFIX + t for t in batch], padding=True, truncation=True, max_length=cap, return_tensors="pt", ) enc = {k: v.to(device) for k, v in enc.items() if torch.is_tensor(v)} logits = transformer.auto_model(**enc).logits flat = logits[enc["attention_mask"].bool()].float() if flat.size(0) > max_tokens: pick = torch.randperm(flat.size(0), device=flat.device)[:max_tokens] flat = flat[pick] chunks.append(flat.reshape(-1).cpu()) n_seen += int(flat.size(0)) print(f" batch {start // batch_size + 1}: {flat.size(0)} tokens, vocab={logits.size(-1)}", flush=True) del logits, flat values = torch.cat(chunks) if values.numel() > max_values: values = values[torch.randperm(values.numel())[:max_values]] qs = torch.quantile(values, torch.tensor([0.01, 0.05, 0.25, 0.50, 0.75, 0.95, 0.99])) return { "n_tokens": n_seen, "n_sample": int(values.numel()), "min": float(values.min()), "p1": float(qs[0]), "p5": float(qs[1]), "p25": float(qs[2]), "median": float(qs[3]), "p75": float(qs[4]), "p95": float(qs[5]), "p99": float(qs[6]), "max": float(values.max()), "frac>0": float((values > 0).float().mean()), "frac>15": float((values > 15).float().mean()), } def l0(embeddings: torch.Tensor) -> torch.Tensor: if embeddings.is_sparse: embeddings = embeddings.to_dense() return (embeddings > 0).sum(dim=-1).float() def format_bag(pairs: list[tuple[str, float]], top_k: int = 20) -> str: if not pairs: return " (empty bag — lower LOGIT_SHIFT or check the pooling)" peak = max(pairs[0][1], 1e-6) lines = [] for token, weight in pairs[:top_k]: shown = token[1:] if token[:1] in {"Ġ", "▁"} else token bar = "█" * max(1, int(round(10 * weight / peak))) lines.append(f" {shown:20s} {weight:6.2f} {bar}") return "\n".join(lines) _WORD = re.compile(r"\S+") def highlight_html(text: str, bag: list[tuple[str, float]]) -> str: """Highlight the document words that (approximately) appear in the bag.""" weights = {} for token, weight in bag: body = _surface_body(token) or token.lower() weights[body] = max(weights.get(body, 0.0), float(weight)) peak = max(weights.values()) if weights else 1.0 parts = [] last = 0 for match in _WORD.finditer(text): parts.append(html_lib.escape(text[last : match.start()])) word = match.group() key = word.lower().strip(".,;:!?()[]\"'«»") w = weights.get(key, 0.0) safe = html_lib.escape(word) if w <= 0: parts.append(f"{safe}") else: alpha = min(0.85, 0.18 + 0.67 * (w / peak)) parts.append( f'{safe}' ) last = match.end() parts.append(html_lib.escape(text[last:])) return "
" + "".join(parts) + "
"