Few-shot fine-tune an LLM to emit a latent into a bespoke geometry you define with text — and drop the result into SetFit as a Sentence-Transformer body.
Project description
langset — read text, answer in a vector
langset turns a language model into your own bespoke embedding space, few-shot. Bolt a tiny vector head onto a pretrained LLM, describe the axis you want in words, and it learns to read text and emit a latent into a geometry defined by those descriptions — using the LLM's world knowledge to do the reading. The latent lives in the model's own space (it's your embedding, not a re-projected off-the-shelf one), and it drops straight into SetFit as a Sentence-Transformer body.
The one idea
The target_text is the geometry. Whatever your target descriptions describe becomes the axis your
latent space measures — and nothing else. Describe instrumentation and the space clusters by instrumentation;
describe vocals and emotion and it clusters by vocals and emotion. You don't discover the geometry, you
define it — and you re-steer it by rewriting the target text, no model changes.
What makes langset different:
- 🎯 Latent out, not a label. You define the output space; the model answers in a vector. Retrieval, "find similar", ranking, clustering — classification is just one thing you can do downstream.
- 🧭 You design the axis in words. The target text defines the geometry. Point it at the signal you care about (and at something the input text can't trivially regenerate, or you're just distilling a text encoder).
- 🧠 World knowledge does the work. It's a generative LLM, so it generalizes from hundreds of examples, not millions — it reads the input rather than pattern-matching surface tokens.
- 🪞 Your own embedding. The latent lives in the model's own hidden space; the geometry comes from a self-contrastive objective against your target text — no external encoder in the loop.
Install
pip install langset
Usage
A langset dataset is rows of input_text → target_text. Pick an LLM backbone; langset trains the mapping.
from langset import LangSetModel, Trainer, TrainingArguments
rows = [ # what you'll have at inference -> a description that DEFINES where it should land
{"input_text": "an hour-long track of detuned riffs that never break stride, moving at the pace of continental drift",
"target_text": "glacial detuned doom-metal, sludgy and hypnotic, buried roared vocals"},
{"input_text": "chopped vocal ghosts drifting over vinyl crackle and the hiss of a city at 3am",
"target_text": "crackly nocturnal UK garage, pitched vocal ghosts, wistful and restless"},
# ...
]
model = LangSetModel.from_pretrained("HuggingFaceTB/SmolLM2-135M") # any HF causal LM
Trainer(model, TrainingArguments(), train_dataset=rows).train()
z = model.encode(["a wall of downtuned fuzz that buries the vocals under sheer volume"])
print(z.shape) # (1, 576) — a latent in the backbone's own space
See examples/sounds_like/ for the full reference task (album review → "how it
sounds" latent).
How it works
- Self-contrastive. For each row,
emit(input_text)is trained to matchemit(target_text)— both emitted through the model into its own space — against in-batch negatives. The target text defines where each item lands; the negatives force different items apart (so the space can't collapse). - Grounding aux. A light reconstruction term makes the latent also decode the target text, tying it to the words. A light uniformity term keeps the space spread on the sphere.
- Collapse-aware selection. langset early-stops on held-out input↔target retrieval and reconstruction, with a hard penalty on any collapse of the geometry — never on the training loss (which collapse can game).
Dataset contract
| column | meaning |
|---|---|
input_text |
what you'll have at inference (a name, a query, a review) |
target_text |
a description of the same item that defines where it lands (the geometry) |
Trainer accepts a datasets.Dataset or list[dict]; use column_mapping to rename your columns.
Using with SetFit
The name is the chain: lang·set·fit — a language model emits into the set geometry (langset, usable on
its own), which then fits a classifier. model.as_sentence_transformer() is a drop-in
SetFit model_body, so you can train a few-shot classifier directly
on your bespoke geometry.
The clean distinction: SetFit answers with a label; langset answers with a latent.
| reach for SetFit | reach for langset | |
|---|---|---|
| your answer is | a label (fixed classes) | a point in a space — retrieval, "find similar", ranking, clustering |
| you define the target by | enumerating classes | a description of the geometry ("how it sounds") |
| your input | text to classify | text or an identifier — leans on the LLM's world knowledge |
- Use SetFit alone for plain few-shot classification — you won't beat it by bolting on langset.
- Use langset when the answer is a geometry, not a label (you'll retrieve / rank / cluster in it).
- Use langset → SetFit when a task-shaped body helps the classifier.
pip install "langset[setfit]" # pins the verified composition window (below)
from sklearn.linear_model import LogisticRegression
from setfit import SetFitModel
clf = SetFitModel(model_body=langset_model.as_sentence_transformer(),
model_head=LogisticRegression(max_iter=2000), # direct construction needs an explicit head
labels=[...])
clf.fit(x_train, y_train, num_epochs=1) # frozen body + head — the robust path
clf.predict(["..."])
Dependency alignment. SetFit's pins are loose, so versions matter:
| install | transformers / torch | Python | use |
|---|---|---|---|
langset |
latest (≥4.41) | 3.10+ | modern backbones incl. Qwen3; no SetFit |
langset[setfit] |
4.46.x / <2.5 | 3.10–3.12 | verified SetFit composition |
SetFit imports transformers.training_args.default_logdir (removed after 4.46), and 4.46 + torch≥2.5 trips a
torch.distributed.tensor bug — hence the cap. Use the frozen-body SetFitModel.fit/predict path above; the
full setfit.Trainer (fine-tunes the body) is fragile in this window.
Multi-latent — one input, a set of latents
Everything above emits one latent per input. Multi-latent emits a variable-length set — one latent per distinct item in the input — autoregressively, with the model deciding how many via a learned STOP. Each latent lands in the same bespoke geometry, so you decode it the same way you'd decode a single one (nearest neighbor against a bank, a downstream head, whatever).
Why: a single vector is the wrong shape whenever one input contains an unknown number of things. Collapse "Apple and Microsoft partnered in California" into one embedding and you've blended three items into one blurry point. Multi-latent keeps them separate — three latents, each retrievable on its own — and, unlike a fixed-slot head, it doesn't need you to know the count in advance.
Where it fits:
- Multi-item extraction — entities, keyphrases, skills, ingredients: read a document, emit a latent per
item, retrieve each against a reference bank. (
examples/ner-multi-latent/does exactly this for named entities.) - Multi-vector retrieval — represent a query or document as a set of latents (ColBERT-style late interaction) instead of one averaged vector, for finer-grained matching.
- Multi-aspect / multi-label — one latent per facet (a product's
{brand, category, material}) or per applicable label, instead of one vector forced to mean several things at once. - Multi-intent parsing — an utterance carrying several intents → a latent each.
from langset import LangSetModel
import torch.nn.functional as F
m = LangSetModel.load("path/to/checkpoint", device="cpu")
# a reference bank you retrieve emitted latents against (any short label works)
bank = ["PER: Barack Obama", "LOC: Berlin", "PER: Angela Merkel", "ORG: Apple", "LOC: California"]
zb = F.normalize(m.emit(bank).float(), dim=-1) # [N, d]
# emit a VARIABLE-length set from one input — the count is decided by a learned STOP
lat = F.normalize(m.rollout("Barack Obama visited Berlin to meet Angela Merkel.").float(), dim=-1)
for v in lat: # -> one latent per entity
print(bank[int((v @ zb.T).argmax())]) # PER: Barack Obama / LOC: Berlin / PER: Angela Merkel
Under the hood each latent is finite-scalar-quantized (FSQ) into per-dimension digits the model predicts, an EMA target twin keeps the set from collapsing, and every emitted latent is fed back into the stream so the next one is conditioned on those already emitted.
Status
v0.3 — adds multi-latent (variable-length latent-set emission via FSQ). The core engine is validated on a real task (album review → "how it sounds" latent) with a downstream SetFit composition; multi-latent is validated on CoNLL-2003 multi-entity extraction across SmolLM and Qwen backbones. Apache-2.0.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file langset-0.3.0.tar.gz.
File metadata
- Download URL: langset-0.3.0.tar.gz
- Upload date:
- Size: 65.5 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dce5bc75911c7805e8c61535547a4ed5339f51c3b4da7082787cd734fda0c007
|
|
| MD5 |
4aad7edff22d726e7d5aa360ccf69fc3
|
|
| BLAKE2b-256 |
0b521610b9facbb614525c0c5fb042ed996baf2c0f4bc7786f4a36f4b1b5530e
|
File details
Details for the file langset-0.3.0-py3-none-any.whl.
File metadata
- Download URL: langset-0.3.0-py3-none-any.whl
- Upload date:
- Size: 25.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
953eff565f3135535bfdfe04c1593cb15875962fd4192178496a91b88562962c
|
|
| MD5 |
995270d07cfeb7b819bd39d2ef0a953e
|
|
| BLAKE2b-256 |
f8b19e78539e8346abdf6518aced9e95c34c8dcb0a7cca451c68bf7ec31cb278
|