Unsupervised methods in LLM work
By the end: You can use clustering and near-duplicate detection on text and embeddings to understand what users ask and to clean data.
- What are users actually asking?
- Naming clusters with a model
- Near-duplicates with MinHash
- Finding unusual inputs for your eval set
New to unsupervised learning? Supervised learning needs examples with the right answer attached. Unsupervised learning is what you do when nobody has attached one: you hand over a pile of data and ask what structure is already in it. Clustering groups items that resemble each other; dimensionality reduction squashes data with hundreds of measurements per item down to two, so you can plot it and look at it. Why it matters
These methods earn their place in language work on two jobs in particular. One is finding out what people are actually asking your product, by embedding a month of real questions and grouping them, which tends to contradict what the team assumed. The other is cleaning data: spotting near-duplicates at scale, and surfacing the unusual inputs that belong in an eval set precisely because nobody thought of them.
What are users asking?#
Embed a month of user questions, cluster the embeddings, and count cluster sizes. You get a map of real demand: which topics are common, which are growing, which your product never anticipated. It is one of the most useful analyses an AI engineer can run on a live product.
emb = embed(questions) # [n, d], normalised
labels = HDBSCAN(min_cluster_size=25).fit_predict(emb)
for c in sorted(set(labels) - {-1}, key=lambda c: -(labels == c).sum())[:15]:
sample = [q for q, l in zip(questions, labels) if l == c][:20]
name = ask(f"Give a short name for what these questions have in common:\n{sample}")
print((labels == c).sum(), name)Having a model name clusters from samples is fast and usually good. Read a few clusters yourself to check the names are honest.
Two habits make the difference between an interesting picture and something a team acts on. Rank clusters by size and by growth rate between two time windows, not by size alone: a cluster that is fifth largest and has tripled this month is the one to take to a product meeting. And keep the noise label rather than forcing every question into a cluster, because the questions that fit nowhere are where the new use cases and the abuse both live.
Near-duplicates#
from datasketch import MinHash, MinHashLSH
def signature(text):
m = MinHash(num_perm=128)
for shingle in {text[i:i + 5] for i in range(len(text) - 4)}:
m.update(shingle.encode())
return m
lsh = MinHashLSH(threshold=0.8, num_perm=128)
for doc_id, text in docs.items():
sig = signature(text)
if lsh.query(sig):
continue # near-duplicate of something already kept
lsh.insert(doc_id, sig)That code is worth understanding rather than pasting, because it rests on two ideas that are each one line of arithmetic. The first is the measure: Jaccard similarity is the size of the overlap between two sets divided by the size of their union. Cut each document into overlapping five-character pieces, called shingles, and two documents differing by one word share almost all their shingles.
The second is the trick that makes it cheap. Comparing every pair of a million documents is half a trillion comparisons. Instead, hash every shingle of a document with 128 different hash functions and keep the smallest result from each — that list of 128 numbers is the signature. The probability that two documents give the same minimum under one hash function is exactly their Jaccard similarity, so the fraction of the 128 positions where their signatures agree estimates it. Two short lists compared instead of two full documents.
import hashlib
import random
def shingles(text, k=5):
return {text[i:i + k] for i in range(len(text) - k + 1)}
def jaccard(a, b):
return len(a & b) / len(a | b)
def signature(shingle_set, permutations=128, seed=0):
rng = random.Random(seed)
salts = [rng.randrange(1 << 30) for _ in range(permutations)]
return [min(int(hashlib.md5((str(salt) + s).encode()).hexdigest()[:8], 16)
for s in shingle_set)
for salt in salts]
def estimate(sig_a, sig_b):
return sum(x == y for x, y in zip(sig_a, sig_b)) / len(sig_a)
d1 = "the quick brown fox jumps over the lazy dog"
d2 = "the quick brown fox jumped over the lazy dog" # one word changed
d3 = "completely different text about machine learning models"
s1, s2, s3 = shingles(d1), shingles(d2), shingles(d3)
print(round(jaccard(s1, s2), 4), round(jaccard(s1, s3), 4)) # 0.7556 0.0
print(estimate(signature(s1), signature(s2)),
estimate(signature(s1), signature(s3))) # 0.6953125 0.0So choose the threshold knowing it is fuzzy. At 0.8 with 128 permutations you will keep some documents that are really 0.85 similar and drop some that are really 0.75. For deduplicating pretraining data that is fine, because the cost of either error is small and the alternative is not deduplicating at all. For deciding whether two customer records are the same person it is not fine, and you should be using the estimate to shortlist candidates and something exact to decide.
Unusual inputs make good test cases#
Points that fall in no cluster, or far from every cluster centre, are the odd requests: other languages, pasted logs, attempts to misuse the system. They are exactly what an eval set usually lacks.
Selecting them is four lines once the embeddings exist, and it is worth doing deliberately rather than taking whatever HDBSCAN happened to label as noise. Score every item by its distance to the nearest cluster centre, take the top few hundred, then sample across the range rather than only the extreme: the very furthest items are often broken input rather than interesting input.
import numpy as np
def outliers(emb, labels, per_bucket=10, buckets=5):
"""Rank by distance to the nearest cluster centre, then sample across the range."""
centres = np.array([emb[labels == c].mean(axis=0) for c in sorted(set(labels) - {-1})])
distance = np.linalg.norm(emb[:, None, :] - centres[None, :, :], axis=2).min(axis=1)
order = np.argsort(-distance)[:per_bucket * buckets * 4] # the odd tail
picked = []
for chunk in np.array_split(order, buckets):
picked.extend(chunk[:per_bucket])
return picked, distanceWhat this is worth, honestly#
None of these three techniques produces a number you can report. Clustering gives you a map to read, deduplication gives you a smaller corpus, outlier selection gives you a list of cases to look at. That is the right expectation to hold: unsupervised methods in language work are instruments for looking at data, and their output is always a thing a person reads next. The failure mode is treating a cluster id as a label and building a pipeline on top of it, at which point an arbitrary partition of your data becomes a fact about your product.
Where people go wrong
Reading only the largest clusters. The small, growing ones are often the new use case or the new abuse.
If this comes up in an interview
How would you find out what users are asking your AI assistant, at scale?
Embed the questions, cluster them with a method that tolerates noise, rank clusters by size and growth, name them from samples with a model, and verify a few by reading. Then compare against what the product was designed for and add the big unexpected clusters to the eval set.
Build this: Take a few thousand real or public questions, cluster their embeddings, name the top fifteen clusters with a model, check five by hand, and pull twenty outliers into an eval set.
Watch it explained
This is one section of Unsupervised learning, 16 hours across 4 sections. Read it in full to get the whole topic in order.