feat(embed): batched AutoModel encode + masked mean-pool + L2-norm (v0.71.36 Part A)

Completes the embedding kernel started by the pooling gate. embed_texts
lazily imports torch/transformers/numpy so the module stays importable on
the light core (pip install soup-cli without the [train] extra).

_mean_pool masks padding before averaging: an unmasked hidden.mean(dim=1)
averages pad positions too and silently drags every vector toward the pad
embedding. Pinned by a mutation-verified test (padded 999.0 -> unmasked
mean ~334 vs the correct 2.0).

resolve_pooling gates embed_texts before any download, so an unverified
model is refused rather than fetched and mis-pooled.
This commit is contained in:
Alpamys 2026-07-16 16:11:00 +05:00
parent 741aba6626
commit e49ee0479d
2 changed files with 206 additions and 0 deletions

View File

@ -104,3 +104,116 @@ def resolve_pooling(model_id: str) -> str:
f"{cleaned!r} does not declare mean-token pooling; refusing."
)
return "mean"
# ---------------------------------------------------------------------------
# Batched encode
# ---------------------------------------------------------------------------
_MAX_ROWS = 200_000
_MAX_CHARS_PER_ROW = 8_192
_MAX_BATCH_SIZE = 512
_MAX_SEQ_TOKENS = 512
def _mean_pool(hidden, attention_mask):
"""Attention-masked mean over the token axis. Padding contributes 0.
An unmasked ``hidden.mean(dim=1)`` averages padded positions too, which
silently drags every vector toward the pad embedding.
"""
mask = attention_mask.unsqueeze(-1).to(hidden.dtype)
summed = (hidden * mask).sum(dim=1)
counts = mask.sum(dim=1).clamp(min=1e-9) # never divide by zero
return summed / counts
def _l2_normalize(vectors):
"""Row-wise L2 normalize so cosine == dot product. Zero rows stay zero."""
import numpy as np
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
norms = np.where(norms < 1e-12, 1.0, norms)
return (vectors / norms).astype(np.float32)
def _validate_texts(texts: object) -> list:
"""Coerce + bound-check the input rows.
A bare ``str`` is rejected: it is a sequence of characters, so accepting
it would silently embed one row per letter.
"""
if isinstance(texts, (str, bytes)) or not hasattr(texts, "__len__"):
raise TypeError("texts must be a sequence of str")
items = list(texts)
if not items:
raise ValueError("texts must contain at least one text")
if len(items) > _MAX_ROWS:
raise ValueError(
f"too many texts ({len(items)}); cap is {_MAX_ROWS}. Sample the "
"dataset first (`soup data sample`) — Soup refuses rather than "
"silently subsampling."
)
for idx, item in enumerate(items):
if not isinstance(item, str):
raise TypeError(
f"texts[{idx}] must be str, got {type(item).__name__}"
)
return [item[:_MAX_CHARS_PER_ROW] for item in items]
def _require_batch_size(batch_size: object) -> int:
if isinstance(batch_size, bool) or not isinstance(batch_size, int):
raise TypeError(
f"batch_size must be int, got {type(batch_size).__name__}"
)
if batch_size < 1 or batch_size > _MAX_BATCH_SIZE:
raise ValueError(
f"batch_size must be in [1, {_MAX_BATCH_SIZE}], got {batch_size}"
)
return batch_size
def embed_texts(
texts,
*,
model_id: str = DEFAULT_EMBED_MODEL,
device: str = "auto",
batch_size: int = 32,
):
"""Embed ``texts`` -> an ``(n, d)`` float32 array with L2-normalized rows.
Torch / transformers / numpy are imported lazily so this module stays
importable on the light core (a ``pip install soup-cli`` without the
``[train]`` extra).
"""
items = _validate_texts(texts)
batch = _require_batch_size(batch_size)
# Refuse an unverified model BEFORE any download starts.
resolve_pooling(model_id)
import numpy as np
import torch
from transformers import AutoModel, AutoTokenizer
from soup_cli.utils.live_eval import resolve_device
dev = resolve_device(device)
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModel.from_pretrained(model_id).to(dev)
model.eval()
chunks = []
with torch.no_grad():
for start in range(0, len(items), batch):
encoded = tokenizer(
items[start: start + batch],
padding=True,
truncation=True,
max_length=_MAX_SEQ_TOKENS,
return_tensors="pt",
).to(dev)
out = model(**encoded)
pooled = _mean_pool(out.last_hidden_state, encoded["attention_mask"])
chunks.append(pooled.float().cpu().numpy())
return _l2_normalize(np.vstack(chunks))

View File

@ -117,3 +117,96 @@ class TestResolvePooling:
with pytest.raises((ValueError, TypeError)):
resolve_pooling(bad)
class TestEmbedTexts:
def test_rejects_over_cap(self):
from soup_cli.utils.embed import _MAX_ROWS, embed_texts
with pytest.raises(ValueError, match="too many texts"):
embed_texts(["x"] * (_MAX_ROWS + 1))
def test_rejects_empty_list(self):
from soup_cli.utils.embed import embed_texts
with pytest.raises(ValueError, match="at least one text"):
embed_texts([])
def test_rejects_non_string_row(self):
from soup_cli.utils.embed import embed_texts
with pytest.raises(TypeError, match=r"texts\[1\] must be str"):
embed_texts(["ok", 42])
@pytest.mark.parametrize("bad", [0, -1, True])
def test_rejects_bad_batch_size(self, bad):
from soup_cli.utils.embed import embed_texts
with pytest.raises((ValueError, TypeError)):
embed_texts(["x"], batch_size=bad)
def test_validate_texts_truncates_long_rows(self):
from soup_cli.utils.embed import _MAX_CHARS_PER_ROW, _validate_texts
out = _validate_texts(["a" * (_MAX_CHARS_PER_ROW + 500)])
assert len(out[0]) == _MAX_CHARS_PER_ROW
def test_validate_texts_rejects_bare_string(self):
"""A bare str is a sequence of chars — almost always a caller bug."""
from soup_cli.utils.embed import _validate_texts
with pytest.raises(TypeError, match="sequence of str"):
_validate_texts("not a list")
def test_mean_pool_masks_padding(self):
"""Padded positions must NOT contribute to the mean."""
import numpy as np
from soup_cli.utils.embed import _mean_pool
torch = pytest.importorskip("torch")
# 3 tokens; the last is padding carrying a huge value that would
# wreck an unmasked mean (999 -> ~334 instead of 2.0).
hidden = torch.tensor([[[1.0, 1.0], [3.0, 3.0], [999.0, 999.0]]])
mask = torch.tensor([[1, 1, 0]])
out = _mean_pool(hidden, mask).numpy()
np.testing.assert_allclose(out, np.array([[2.0, 2.0]]), rtol=1e-6)
def test_mean_pool_all_padding_does_not_divide_by_zero(self):
import numpy as np
from soup_cli.utils.embed import _mean_pool
torch = pytest.importorskip("torch")
hidden = torch.tensor([[[5.0, 5.0]]])
mask = torch.tensor([[0]])
out = _mean_pool(hidden, mask).numpy()
assert np.isfinite(out).all()
def test_l2_normalize_rows(self):
import numpy as np
from soup_cli.utils.embed import _l2_normalize
vecs = np.array([[3.0, 4.0], [0.0, 0.0]], dtype=np.float32)
out = _l2_normalize(vecs)
np.testing.assert_allclose(np.linalg.norm(out[0]), 1.0, rtol=1e-6)
# a zero vector must not become NaN
assert np.isfinite(out[1]).all()
def test_l2_normalize_makes_cosine_a_dot_product(self):
import numpy as np
from soup_cli.utils.embed import _l2_normalize
vecs = _l2_normalize(np.array([[2.0, 0.0], [0.0, 5.0]], dtype=np.float32))
assert float(vecs[0] @ vecs[1]) == pytest.approx(0.0, abs=1e-6)
assert float(vecs[0] @ vecs[0]) == pytest.approx(1.0, abs=1e-6)
def test_refuses_unverified_model_before_any_download(self, monkeypatch):
"""resolve_pooling must gate embed_texts BEFORE a model is fetched."""
from soup_cli.utils import embed
monkeypatch.setattr(embed, "_fetch_pooling_config", lambda mid: None)
with pytest.raises(ValueError, match="cannot verify pooling"):
embed.embed_texts(["hello"], model_id="org/unverified-encoder")