CoolFace
Modelpublic

Linkup-Platform/linkup-sparseup-embed-v1

sourceHugging Faceapache-2.0updated 3d agoView on Hugging Face
51likes1.7kdownloads
modeling_splade.py402 linesDownload Raw Back to root
1"""SPLADE head over a ModernBERT/LateOn MLM backbone.2 3Sparse vector = fold(max-pool(top_k-gate(log1p(relu(logits - shift))) * pooling_mask))4where the instruction prefix ("[Q] " / "[D] ") is attended by the backbone but5excluded from pooling. Scores are dot products. Load with:6 7    model = AutoModel.from_pretrained(repo_id, trust_remote_code=True)8    q = model.encode(["a query"], kind="query")                # [N, V] float329    d = model.encode(["a document"])                           # [N, V] float3210    model.score(q, d)                                          # [Nq, Nd] dot11    model.encode_to_dict(["a query"], kind="query", top_k=20)  # {token: weight}12    model.attribute(["a query"], kind="query")                 # + winning input token13    print(model.render(["a query"], kind="query"))             # terminal bar chart14    print(model.highlight(["a document"]))                     # text, fired words lit15"""16 17from __future__ import annotations18 19import sys20 21import torch22import torch.nn.functional as F23from transformers import AutoTokenizer, ModernBertConfig, ModernBertForMaskedLM24 25 26class SpladeConfig(ModernBertConfig):27    def __init__(28        self,29        logit_shift: float = 0.0,30        position_top_k: int | None = None,31        vocab_fold: str | None = None,32        query_prefix: str = "",33        document_prefix: str = "",34        query_max_length: int = 128,35        doc_max_length: int = 512,36        **kwargs,37    ):38        super().__init__(**kwargs)39        self.logit_shift = logit_shift40        self.position_top_k = position_top_k41        self.vocab_fold = vocab_fold42        self.query_prefix = query_prefix43        self.document_prefix = document_prefix44        self.query_max_length = query_max_length45        self.doc_max_length = doc_max_length46 47 48class SpladeModel(ModernBertForMaskedLM):49    config_class = SpladeConfig50 51    def __init__(self, config: SpladeConfig):52        super().__init__(config)53        # [V] canonical-id map for vocab folding, computed once at export from54        # the tokenizer and stored in the checkpoint (identity when unused).55        self.register_buffer(56            "vocab_fold_index", torch.arange(config.vocab_size), persistent=True57        )58        self._tokenizer = None59 60    # -- forward path ---------------------------------------------------------61 62    def _token_weights(63        self, input_ids: torch.Tensor, attention_mask: torch.Tensor64    ) -> torch.Tensor:65        """[B, L] tokens -> [B, L, V] per-position activations (pre-pooling)."""66        cfg = self.config67        logits = super().forward(input_ids=input_ids, attention_mask=attention_mask).logits68        weights = torch.log1p(F.relu(logits - cfg.logit_shift))69        if cfg.position_top_k is not None and cfg.position_top_k < weights.shape[-1]:70            # Keep each position's k largest dims (ties keep more than k).71            cutoff = weights.topk(cfg.position_top_k, dim=-1).values[..., -1:]72            weights = weights * (weights >= cutoff)73        return weights74 75    def _fold(76        self, sparse: torch.Tensor, source_indices: torch.Tensor | None = None77    ) -> tuple[torch.Tensor, torch.Tensor | None]:78        """Reroute each fold group's mass onto its canonical vocab dim.79 80        `source_indices` [B, V] (max-pool argmax positions) follows the winning81        group member so attribution keeps pointing at a real input position.82        """83        index = self.vocab_fold_index.unsqueeze(0).expand_as(sparse)84        folded = torch.zeros_like(sparse).scatter_reduce(85            1, index, sparse, reduce="amax", include_self=False86        )87        if source_indices is None:88            return folded, None89        winner = (sparse == folded.gather(1, index)) & (sparse > 0)90        folded_sources = torch.full_like(source_indices, -1).scatter_reduce(91            1, index, torch.where(winner, source_indices, -1), reduce="amax", include_self=False92        )93        return folded, folded_sources.clamp_min_(0)94 95    def forward(96        self,97        input_ids: torch.Tensor,98        attention_mask: torch.Tensor,99        pooling_mask: torch.Tensor | None = None,100        **kwargs,101    ) -> torch.Tensor:102        """[B, L] tokens -> [B, V] sparse activations (dot-product scoring)."""103        if pooling_mask is None:104            pooling_mask = attention_mask105        weights = self._token_weights(input_ids, attention_mask)106        weights = weights * pooling_mask.unsqueeze(-1).to(weights.dtype)107        sparse = weights.max(dim=1).values108        if self.config.vocab_fold is not None:109            sparse, _ = self._fold(sparse)110        return sparse111 112    @staticmethod113    def score(queries: torch.Tensor, documents: torch.Tensor) -> torch.Tensor:114        """Dot-product relevance scores: [Nq, V] x [Nd, V] -> [Nq, Nd]."""115        return queries @ documents.T116 117    # -- tokenization ---------------------------------------------------------118 119    def _get_tokenizer(self):120        if self._tokenizer is None:121            self._tokenizer = AutoTokenizer.from_pretrained(self.config._name_or_path)122        return self._tokenizer123 124    def _encode_args(self, kind: str, max_length: int | None) -> tuple[str, int]:125        cfg = self.config126        if kind == "query":127            return cfg.query_prefix, max_length or cfg.query_max_length128        if kind == "document":129            return cfg.document_prefix, max_length or cfg.doc_max_length130        raise ValueError("`kind` must be 'query' or 'document'.")131 132    def _tokenize(self, texts: list[str], prefix: str, max_length: int):133        """-> (input_ids, attention_mask, pooling_mask, char_offsets)."""134        enc = self._get_tokenizer()(135            [prefix + t for t in texts],136            padding=True,137            truncation=True,138            max_length=max_length,139            return_tensors="pt",140            return_offsets_mapping=True,141            return_special_tokens_mask=True,142        )143        pooling_mask = enc["attention_mask"]144        if prefix:145            # Prefix tokens are attended but dropped from pooling: a token is146            # prefix iff its char span starts inside the prefix string.147            # Specials carry a zero-width (0, 0) span, so guard them.148            in_prefix = (enc["offset_mapping"][..., 0] < len(prefix)) & ~enc[149                "special_tokens_mask"150            ].bool()151            pooling_mask = pooling_mask.masked_fill(in_prefix, 0)152        return enc["input_ids"], enc["attention_mask"], pooling_mask, enc["offset_mapping"]153 154    def _encode_with_sources(self, texts: list[str], prefix: str, max_length: int):155        """One batch through the model, keeping max-pool source positions.156 157        -> (input_ids, pooling_mask, char_offsets, sparse [B, V], sources [B, V])158        """159        device = next(self.parameters()).device160        ids, attn, pool, offsets = self._tokenize(texts, prefix, max_length)161        ids, attn, pool = ids.to(device), attn.to(device), pool.to(device)162        weights = self._token_weights(ids, attn)163        weights = weights * pool.unsqueeze(-1).to(weights.dtype)164        pooled = weights.max(dim=1)165        sparse, sources = pooled.values, pooled.indices166        if self.config.vocab_fold is not None:167            sparse, sources = self._fold(sparse, sources)168        return ids, pool, offsets, sparse, sources169 170    # -- encoding APIs --------------------------------------------------------171 172    @torch.inference_mode()173    def encode(174        self,175        texts: list[str],176        kind: str = "document",177        batch_size: int = 32,178        max_length: int | None = None,179    ) -> torch.Tensor:180        """Encode raw texts -> [N, V] float32 sparse vectors on CPU.181 182        `kind` ("query" | "document") selects the instruction prefix and the183        default max length.184        """185        prefix, max_length = self._encode_args(kind, max_length)186        device = next(self.parameters()).device187        rows = []188        for start in range(0, len(texts), batch_size):189            ids, attn, pool, _ = self._tokenize(190                texts[start : start + batch_size], prefix, max_length191            )192            rows.append(193                self(ids.to(device), attn.to(device), pool.to(device)).float().cpu()194            )195        return torch.cat(rows)196 197    @torch.inference_mode()198    def attribute(199        self,200        texts: list[str],201        kind: str = "document",202        top_k: int | None = 25,203        batch_size: int = 32,204        max_length: int | None = None,205        round_to: int = 4,206    ) -> list[list[dict]]:207        """Encode texts and attribute each output dim to its input subtoken.208 209        Per text, a weight-sorted list of entries210        `{"token", "weight", "source", "position", "expansion"}`:211        `source`/`position` name the input subtoken whose activation won the212        max for that vocab dim (after folding); `expansion` is True when the213        output term is not the source token's own (folded) dim.214        """215        prefix, max_length = self._encode_args(kind, max_length)216        tokenizer = self._get_tokenizer()217        fold_index = self.vocab_fold_index.cpu()218        results = []219        for start in range(0, len(texts), batch_size):220            ids, _, _, sparse, sources = self._encode_with_sources(221                texts[start : start + batch_size], prefix, max_length222            )223            for row, row_sources, row_ids in zip(224                sparse.float().cpu(), sources.cpu(), ids.cpu()225            ):226                dims = torch.nonzero(row, as_tuple=False).flatten()227                order = torch.argsort(row[dims], descending=True)[:top_k]228                entries = []229                for dim in dims[order].tolist():230                    pos = int(row_sources[dim])231                    src_id = int(row_ids[pos])232                    entries.append(233                        {234                            "token": tokenizer.convert_ids_to_tokens(dim),235                            "weight": round(float(row[dim]), round_to),236                            "source": tokenizer.convert_ids_to_tokens(src_id),237                            "position": pos,238                            "expansion": int(fold_index[src_id]) != dim,239                        }240                    )241                results.append(entries)242        return results243 244    def encode_to_dict(245        self, texts: list[str], kind: str = "document", top_k: int | None = None, **kwargs246    ) -> list[dict[str, float]]:247        """Encode texts -> {token: weight} dicts sorted by descending weight."""248        return [249            {e["token"]: e["weight"] for e in entries}250            for entries in self.attribute(texts, kind=kind, top_k=top_k, **kwargs)251        ]252 253    # -- terminal displays ----------------------------------------------------254 255    _FADE = "▓▒░"256    _HEAT = (196, 202, 208, 214, 220, 190, 108, 66, 60, 241)  # ANSI-256, hot -> cold257 258    def _heat(self, ratio: float) -> int:259        return self._HEAT[min(int((1 - ratio) * len(self._HEAT)), len(self._HEAT) - 1)]260 261    @staticmethod262    def _display(token: str) -> str:263        """Strip the Ġ word marker; dot-prefix continuation pieces."""264        if token.startswith("Ġ"):265            return token[1:]266        if token.startswith("["):  # specials: [CLS], [SEP], [Q], [D]267            return token268        return "·" + token269 270    def render(271        self,272        texts: list[str],273        kind: str = "document",274        top_k: int | None = 25,275        width: int = 36,276        color: bool | None = None,277        **attribute_kwargs,278    ) -> str:279        """Terminal bar chart of the sparse expansions, with attributions.280 281        One block per text: an `L0` line with the total number of active dims,282        then bars proportional to weight (peak-normalized), each line ending283        with the input subtoken that produced the dim and `<exp>` for pure284        expansions. `color=None` auto-detects a TTY.285        """286        if color is None:287            color = sys.stdout.isatty()288        blocks = []289        for text, entries in zip(290            texts, self.attribute(texts, kind=kind, top_k=None, **attribute_kwargs)291        ):292            shown = text if len(text) <= 70 else text[:67] + "..."293            if not entries:294                blocks.append(f"{kind} · {shown}\n  (empty vector)")295                continue296            total = len(entries)297            entries = entries[:top_k]298            l0 = f"L0 = {total} active dims"299            if len(entries) < total:300                l0 += f" (showing {len(entries)})"301            peak = entries[0]["weight"]302            name_width = max(len(self._display(e["token"])) for e in entries)303            lines = [f"{kind} · {shown}", f"\033[2m{l0}\033[0m" if color else l0]304            for e in entries:305                ratio = e["weight"] / peak306                cells = max(1, round(ratio * width))307                bar = ("█" * cells)[:-3] + self._FADE if cells > 3 else self._FADE[3 - cells :]308                pad = " " * (width - cells)309                token = self._display(e["token"]).rjust(name_width)310                attrib = f"<- {self._display(e['source'])}@{e['position']}"311                if e["expansion"]:312                    attrib += " <exp>"313                if color:314                    heat = self._heat(ratio)315                    token = f"\033[38;5;{heat}m{token}\033[0m"316                    bar = f"\033[38;5;{heat}m{bar}\033[0m"317                    attrib = f"\033[2m{attrib}\033[0m"318                lines.append(f"{token} {bar}{pad} {e['weight']:>6.2f}  {attrib}")319            blocks.append("\n".join(lines))320        return "\n\n".join(blocks)321 322    @torch.inference_mode()323    def highlight(324        self,325        texts: list[str],326        kind: str = "document",327        reduce: str = "sum",328        color: bool | None = None,329        batch_size: int = 32,330        max_length: int | None = None,331    ) -> str:332        """Render each text with its firing words lit up.333 334        A word fires when one of its subtokens wins the max for at least one335        output dim; intensity is the total mass it contributes (`reduce="sum"`,336        default) or the largest single weight it wins (`reduce="max"`). TTY:337        reverse-video heat colors. Plain: tiered markers `⟦strong⟧ «mid» ‹weak›`.338        """339        if reduce not in ("sum", "max"):340            raise ValueError("`reduce` must be 'sum' or 'max'.")341        if color is None:342            color = sys.stdout.isatty()343        prefix, max_length = self._encode_args(kind, max_length)344        blocks = []345        for start in range(0, len(texts), batch_size):346            batch = texts[start : start + batch_size]347            ids, pool, offsets, sparse, sources = self._encode_with_sources(348                batch, prefix, max_length349            )350            # [B, L] per-position intensity, reduced over the dims each position351            # won. Zero-weight dims carry a clamped position 0 but contribute 0,352            # so they can't corrupt the reduction.353            intensity = torch.zeros(354                ids.shape, dtype=sparse.dtype, device=sparse.device355            ).scatter_reduce(356                1, sources, sparse, reduce="sum" if reduce == "sum" else "amax", include_self=False357            )358            for text, row_int, row_off, row_pool in zip(359                batch, intensity.float().cpu(), offsets, pool.cpu()360            ):361                blocks.append(362                    self._paint(text, row_int, row_off, row_pool, len(prefix), color, reduce)363                )364        return "\n\n".join(blocks)365 366    def _paint(self, text, intensity, offsets, pooling_mask, prefix_len, color, reduce) -> str:367        """Wrap fired char spans of `text` in intensity markers."""368        # Byte-BPE offsets include the word's leading space: trim it, so merging369        # only fuses glued subtokens of the same word (one span per word).370        spans: list[list] = []371        for pos in range(len(offsets)):372            w = intensity[pos].item()373            if w <= 0 or pooling_mask[pos] == 0:374                continue375            s, e = int(offsets[pos][0]) - prefix_len, int(offsets[pos][1]) - prefix_len376            s = max(s, 0)377            while s < e and text[s].isspace():378                s += 1379            if e <= s:  # zero-width specials / whitespace-only380                continue381            if spans and s == spans[-1][1]:382                spans[-1][1] = e383                spans[-1][2] = spans[-1][2] + w if reduce == "sum" else max(spans[-1][2], w)384            else:385                spans.append([s, e, w])386        if not spans:387            return text388        peak = max(w for _, _, w in spans)  # after merging, so summed words stay <= 1389        out, cursor = [], 0390        for s, e, w in spans:391            ratio = w / peak392            out.append(text[cursor:s])393            if color:394                # Reverse video with heat foreground = heat-colored highlighter.395                out.append(f"\033[7;38;5;{self._heat(ratio)}m{text[s:e]}\033[0m")396            else:397                marks = "⟦⟧" if ratio > 0.66 else "«»" if ratio > 0.33 else "‹›"398                out.append(f"{marks[0]}{text[s:e]}{marks[1]}")399            cursor = e400        out.append(text[cursor:])401        return "".join(out)402