Instructions to use BorisTM/loss-guided-static-multi with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- sentence-transformers
How to use BorisTM/loss-guided-static-multi with sentence-transformers:
from sentence_transformers import SentenceTransformer model = SentenceTransformer("BorisTM/loss-guided-static-multi") sentences = [ "The weather is lovely today.", "It's so sunny outside!", "He drove to the stadium." ] embeddings = model.encode(sentences) similarities = model.similarity(embeddings, embeddings) print(similarities.shape) # [3, 3] - Notebooks
- Google Colab
- Kaggle
File size: 5,029 Bytes
309d3a9 | 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 | """A second vocabulary for contested (language, token) pairs.
Every other conditioning mechanism in this repo failed for one measured reason:
the shared table rewrites about 85% of a row's magnitude in 3,000 steps, so it
absorbs any per-language transform a head could learn and leaves the head
redundant.
This does something different. A selected (language, token) pair gets its **own
row**, and a learned gate mixes it with the shared row:
E'[w, l] = (1 - g) * E_shared[w] + g * E_split[idx(l, w)]
The split row is free capacity, not a correction, so there is nothing for the
shared table to absorb. Split rows start as exact copies of their shared row, so
the model is numerically identical to the unconditioned one at step zero
whatever the gate says, and both paths receive gradient immediately — the
failure mode where a zero-initialised head never leaves zero cannot occur.
An L1 penalty on the gate makes a split row earn its keep. After training the
gate is the answer to the scientific question: which pairs actually needed
separating, and does that agree with the interference measured beforehand.
"""
from __future__ import annotations
import numpy as np
import torch
from torch import nn
class SplitVocabulary(nn.Module):
def __init__(self, index_path: str, vocab_size: int, dim: int, n_languages: int,
gate_init: float = 0.0) -> None:
super().__init__()
data = np.load(index_path)
language_index = torch.tensor(data["language_index"].astype(np.int64))
token_index = torch.tensor(data["token_index"].astype(np.int64))
self.n_rows = int(language_index.numel())
# (language, token) -> split row, as one flat lookup. -1 means "shared
# only", which is the vast majority of pairs.
lookup = torch.full((n_languages, vocab_size), -1, dtype=torch.long)
lookup[language_index, token_index] = torch.arange(self.n_rows)
self.register_buffer("lookup", lookup, persistent=False)
self.register_buffer("row_language", language_index, persistent=False)
self.register_buffer("row_token", token_index, persistent=False)
self.rows = nn.Parameter(torch.zeros(self.n_rows, dim))
self.gate_logit = nn.Parameter(torch.full((self.n_rows,), float(gate_init)))
self._initialised = False
@torch.no_grad()
def seed_from(self, table: torch.Tensor) -> None:
"""Copy each split row from the shared row it specialises."""
self.rows.copy_(table[self.row_token])
self._initialised = True
def gate(self) -> torch.Tensor:
return torch.sigmoid(self.gate_logit)
def penalty(self) -> torch.Tensor:
"""Mean gate opening — an L1 that pushes unused rows back to shared."""
return self.gate().mean()
def fusion_penalty(self) -> torch.Tensor:
"""Pull every language's row for a token toward the consensus for it.
This is the merging mechanism. Training starts with one private row per
(language, token) — full capacity, no interference — and this term makes
a row stay distinct only if the contrastive loss pays for it. Sweeping
its weight walks continuously from one vocabulary per language to a
single shared one, so the *effective* number of vocabularies is read off
rather than chosen.
Fusing toward the per-token mean is the O(L) relaxation of the O(L^2)
all-pairs fused lasso; rows that need not differ collapse onto the
centroid, and which languages stay off it together is the clustering.
"""
token = self.row_token
uniq, inverse = torch.unique(token, return_inverse=True)
total = torch.zeros(uniq.numel(), self.rows.shape[1],
dtype=self.rows.dtype, device=self.rows.device)
total.index_add_(0, inverse, self.rows)
count = torch.zeros(uniq.numel(), dtype=self.rows.dtype, device=self.rows.device)
count.index_add_(0, inverse, torch.ones_like(inverse, dtype=self.rows.dtype))
centroid = total / count.clamp(min=1.0).unsqueeze(1)
# Group lasso over languages: the L2 norm of each deviation, averaged.
return (self.rows - centroid[inverse]).norm(dim=1).mean()
def forward(self, token_ids: torch.Tensor, lang_per_token: torch.Tensor,
shared: torch.Tensor, use_gate: bool = True) -> torch.Tensor:
row = self.lookup[lang_per_token, token_ids]
has = row >= 0
if not bool(has.any()):
return shared
safe = row.clamp(min=0)
if not use_gate:
# Fusion mode: the private row replaces the shared one outright, and
# merging is driven by the penalty rather than by a mixing gate.
return torch.where(has.unsqueeze(1), self.rows[safe], shared)
g = self.gate()[safe].unsqueeze(1) * has.unsqueeze(1)
return shared * (1.0 - g) + self.rows[safe] * g
def extra_repr(self) -> str:
return f"rows={self.n_rows}"
|