The lesson in one minute
What you'll be able to explain
- k-means alternates "assign to nearest centre" and "move centre to the mean"; you choose k, using the silhouette or the elbow.
- DBSCAN/HDBSCAN find crowds by density, choose the number of clusters themselves, and mark loners as noise; better for messy text.
- PCA gives an honest but lossy 2-D shadow; UMAP/t-SNE keep neighbours but distort distances and sizes.
- Near-duplicates, routing, anomaly detection and semantic caching are all "embed, compare, threshold".
- Semantic caches need a strict threshold, a context key and an expiry.
Level 1
The practitioner's guide
In one sentence
Clustering groups texts by meaning without being told what the groups are, and the move behind it, one distance and one threshold, also powers four everyday jobs: spotting near-duplicates, routing requests, flagging anomalies and caching answers.
When you need it
You need clustering when you have a pile of unlabeled text (support tickets, feedback, logs, documents) and want to know what kinds of things are in it before anyone has named the kinds. You need the everyday uses whenever the question is "have I seen something like this before?": the same ticket filed twice, a request that belongs to another team, an entry that looks like nothing else, a question already answered an hour ago. You don't need clustering when the categories are already known and labeled: that is classification, and a centroid per class or a trained model does it directly. The tell: someone is reading tickets one by one to find out what people are asking about, or a model is being called again for a question it has already answered.
Your options
From the simplest to the most flexible:
| Option | What it does | What it gives you | What it costs | Where it lives |
|---|---|---|---|---|
| One distance and a threshold | Embeds the new item and compares it with what is already stored | Near-duplicate detection, routing, anomaly scores and a semantic cache, with no clustering at all | A threshold to calibrate on labeled pairs, plus the guards below | Your code over any embedding model; semantic-router, GPTCache |
| k-means | Alternates "assign each point to its nearest centre" and "move each centre to the mean of its points" | k crisp groups with a centre each; fast, and scikit-learn lists it for very large collections | You choose k, and it assumes round, similar-sized clusters | scikit-learn, FAISS (it is how IVF indexes cluster a corpus) |
| DBSCAN | Grows clusters through points with enough neighbours within a reach ε, leaving the rest as noise | The number of clusters found for you, and loners marked as noise | A reach no single value gets right; kinds chain together as it widens | scikit-learn |
| HDBSCAN | Runs DBSCAN at every reach at once and keeps the clusters that persist longest | Clusters of different densities, no ε to set, noise labelled | A minimum cluster size to pick; listed for large rather than very large collections | scikit-learn, the hdbscan library, BERTopic |
| Agglomerative (hierarchical) | Merges the closest pair of groups again and again into a tree | Many clusters, a cut at any level, any distance | You still choose where to cut the tree | scikit-learn |
How to choose
Start from whether you know how many groups there are and whether every item belongs somewhere.
- You know the number of groups, or want a fixed number of buckets (topics for a dashboard, cells for a vector index): k-means, seeded with k-means++. Pick k by the silhouette, not the inertia: inertia falls at every k, while the silhouette peaks at the true number (k = 5 for this lesson's five ticket kinds).
- You don't know how many groups exist, some items belong nowhere, and the groups differ in shape and density (real tickets, logs): HDBSCAN. Plain DBSCAN is the same idea with one reach you have to guess, and on this lesson's tickets no reach gets everything right.
- You want a picture: PCA for an honest but lossy shadow (its title tells you what share of the variation you are seeing); UMAP or t-SNE for a prettier map of what is near what, never for measuring distances or cluster sizes.
- You want an action per item rather than groups: the threshold pattern. Flag near-duplicate pairs above a calibrated cosine, send a request to the route whose centroid clears the threshold, score an anomaly by its distance to the nearest centre, return a cached answer only above a strict threshold.
- Whatever you pick, calibrate every threshold on labeled pairs from your own data, check the clusters against a sample you have read, and keep the fallback: a router without one sends every unanswerable question somewhere.
What it costs
Everything here runs on embeddings you already have, so the model cost is one embedding per item. k-means is the cheap one: a few rounds of assign-and-move, restarted a few times, with the total squared distance only ever falling. DBSCAN's naive form needs every pairwise distance, which scikit-learn notes costs n² floats of memory when its tree structures can't be used; HDBSCAN does more work than k-means for its freedom from ε. The everyday uses cost one comparison against what is stored, and a semantic cache pays for itself by skipping a model call on every hit, at the risk of a wrong hit: GPTCache's documentation says plainly that a semantic cache produces false positives on hits and false negatives on misses. The real expense is labeling: a few dozen labeled pairs to calibrate a threshold, and a sample of items read by a person to check that the clusters mean what you think.
What breaks
- The wrong k. Inertia keeps falling as k grows, so the lowest value is useless. Use the silhouette, or the elbow where inertia stops falling steeply.
- Chaining. Widen DBSCAN's reach until real tickets stop being noise and kinds merge through chains of near neighbours: at ε = 0.6 here the noise is exactly the two off-topic tickets, but VPN and printer tickets share a cluster. HDBSCAN keeps the clusters that persist across reaches instead.
- Loners forced into a group. k-means gives every point a cluster, including the coffee-machine and dog tickets that belong to none. Use a density method, or score anomalies by distance to the nearest centre.
- Distances read off a UMAP or t-SNE plot. Gaps between clusters and cluster sizes there don't reflect the real space. Use the map for intuition, measure in the original space.
- A cache that answers a different question. "How do I reset my VPN?" is about 0.46 similar to "How do I reset my password?" here: related, and wrong. Keep the threshold strict and calibrated.
- A cache that leaks across users. "What is my PTO balance?" means something different for each person. Filter by user, tenant and permission scope before the similarity search, so no threshold setting can leak an answer.
- Stale hits. Give every entry a time-to-live so answers age out when the facts change.
- A router without a fallback. Below the threshold, hand the request to a person or a general assistant rather than the least-bad route.
In the wild
scikit-learn ships k-means, DBSCAN, HDBSCAN and agglomerative clustering with a guide to which fits which data (k-means for even cluster sizes and flat geometry, DBSCAN and HDBSCAN for uneven sizes and outlier removal, HDBSCAN when densities vary); the hdbscan library and umap-learn are the reference implementations of their papers. FAISS runs k-means to build the cells of an IVF index. BERTopic discovers topics by chaining sentence-transformers embeddings, UMAP, HDBSCAN and a class-based TF-IDF. semantic-router defines each route by example utterances, embeds them, and returns no route at all when nothing matches, with thresholds you can train. GPTCache embeds each query, searches a vector store and lets a similarity evaluator decide the hit. The papers behind this lesson (k-means++, DBSCAN, HDBSCAN and UMAP) are listed at the end, and Distill's How to Use t-SNE Effectively shows the misreadings the map warning above is about.
Go deeper
Level 2 builds k-means, the silhouette, DBSCAN, PCA and all four everyday uses by hand, first on four points you can check with a pencil and then on thirty support tickets, with every formula decoded and every figure explained. If you only needed to choose a method and set its thresholds, you are done.
Level 2
How it works, from scratch
Level 2 builds each of those methods from nothing, starting with a sack of mail.
The everyday picture. Tip a sack of unlabeled mail onto a table and sort it into piles by what each letter is about. Nobody gave you the pile names; you notice that some letters are about passwords and others about holidays, and similar letters end up together. That's clustering: finding groups in data without being told what the groups are.
Embeddings make it possible for text. Every ticket, email or document gets
coordinates on a map of meaning (primer.ml.embeddings.similarity), so
"similar" becomes "close", and sorting mail becomes finding crowds of nearby
points. The same closeness also powers four everyday jobs: spotting
near-duplicates, routing requests to the right team, flagging
anomalies, and caching answers to questions already asked.
This lesson uses 30 short IT, HR and finance support tickets (five kinds, six
of each) and two off-topic ones, embedded with the repo's toy embedder
(primer.common.embedder).
Figure 1 · Drawn from the lesson's code
Tickets of the same kind form five bright squares along the diagonal, and the two off-topic tickets are dark almost everywhere
In code: ticket_embeddings returns the tickets' unit vectors and texts,
and ticket_kinds their true kinds, which the clustering never sees.
Chapter 1
k-means: k meeting points
Everyday picture a town wants k post boxes placed so that everyone's walk to their nearest box is as short as possible. Start with the boxes anywhere. Everyone walks to their nearest box; then each box moves to the middle of the people who chose it. Repeat until nobody switches box.
Tiny worked example four points, (0, 0), (0, 1), (10, 0), (10, 1), and k = 2. However the boxes start, the two left points pick one box and the two right points the other. Each box moves to the middle of its pair: (0, 0.5) and (10, 0.5). Every point is now 0.5 away from its box, so the total squared walk is 4 × 0.5² = 1.0, and nothing changes on the next round.
Figure 3 · Diagram
flowchart LR
I["Place k centres<br/>(k-means++: spread out)"] --> A["Assign: each point<br/>joins its nearest centre"]
A --> M["Move: each centre goes to<br/>the mean of its points"]
M --> C{Did any centre move?}
C -->|yes| A
C -->|no| D[Done: labels + centres]
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Shape / range |
|---|---|---|
| J | the total squared distance, called inertia; k-means makes it small | ≥ 0 |
| N | number of points | 4 in the example; 30 tickets |
| i | which point | 1 to N |
| xᵢ | the i-th point (an embedding) | d numbers |
| c(i) | the cluster point i is assigned to | 1 to k |
| μ_c (mu) | the centroid of cluster c: the mean of its points | d numbers |
| ‖·‖² | squared Euclidean distance | ≥ 0 |
| Σ | add up over all points |
In words: add up, over every point, the squared distance from the point to the centre of its cluster.
On the example: four points each 0.5 from their centre: J = 4 × 0.25 = 1.0.
In Python:
x = [(0, 0), (0, 1), (10, 0), (10, 1)]
# the two centroids
mu = [(0, 0.5), (10, 0.5)]
# c(i): the cluster each point joined
c = [0, 0, 1, 1]
# ‖x_i - μ_c(i)‖²
J = sum((x_i[0] - mu[c_i][0]) ** 2 + (x_i[1] - mu[c_i][1]) ** 2
# Σ over every point
for x_i, c_i in zip(x, c))
J # → 1.0
Figure 2 · Drawn from the lesson's code
k-means centres drift into the middle of their crowds round by round while the total squared distance only goes down
In code: kmeans alternates assign and move from k-means++ starts and
returns the labels, centres and inertia J of the best of several restarts;
kmeans_history records one run round by round, which is what this figure
draws.
Why it matters k-means is fast and simple, and it's inside things you
use: IVF vector indexes cluster the corpus with it (primer.ml.embeddings.ann),
and topic discovery over tickets or documents often starts with it. Its
weaknesses are that you must choose k and that it assumes round, similar-sized
clusters.
Chapter 2
Choosing k: the elbow and the silhouette
Everyday picture at a party, you're in the right group if the people in your group are much closer to you than the people in the next group over.
Tiny worked example the four points again, split into the two pairs. For (0, 0): its partner is a = 1 away; the other pair is 10 and √101 = 10.05 away, on average b = 10.025. Its score is (b − a) / b = 9.025 / 10.025 = 0.900, and by symmetry every point scores the same, so the silhouette is 0.900.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Range |
|---|---|---|
| s(i) | silhouette score of point i | −1 to 1 |
| a(i) | average distance from i to the other points in its own cluster | ≥ 0 |
| b(i) | average distance from i to the points of the nearest other cluster | ≥ 0 |
| max(a, b) | the larger of the two, to scale the score into −1 … 1 |
In words: how much farther the nearest other group is than your own, as a share of the larger distance. The silhouette of a clustering is the average over all points: near 1 is crisp, near 0 is overlapping, negative means points sit in the wrong cluster.
On the example: (10.025 − 1) / 10.025 = 0.900.
In Python:
import math
# a(i): distance to its partner
a = math.dist((0, 0), (0, 1))
# b(i): the other pair, averaged
b = (math.dist((0, 0), (10, 0)) + math.dist((0, 0), (10, 1))) / 2
a, round(b, 3) # → (1.0, 10.025)
# s(i)
round((b - a) / max(a, b), 3) # → 0.9
Figure 4 · Drawn from the lesson's code
Inertia falls at every k, so it cannot pick k, while the silhouette peaks clearly at the true k of 5
In code: silhouette averages s(i) over every point, and
best_k_by_silhouette runs kmeans for each candidate k and keeps the one
with the highest silhouette.
Chapter 3
Density clustering: DBSCAN and HDBSCAN
Everyday picture a festival seen from a drone. A crowd is wherever people stand shoulder to shoulder; a loner by the fence belongs to no crowd. You don't decide in advance how many crowds there are.
Tiny worked example points on a line at 0, 0.1, 0.2, 5.0, 5.1, 5.2 and 20, with reach ε = 0.15 and "a crowd needs at least 2". 0, 0.1 and 0.2 chain together (each within 0.15 of the next); so do 5.0, 5.1 and 5.2. 20 has nobody within 0.15. Result: two clusters and one noise point, labels 0 0 0 1 1 1 −1.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here |
|---|---|
| x, y | points |
| dist | distance: Euclidean, or 1 − cosine for embeddings |
| ε (epsilon) | the reach: how close counts as "shoulder to shoulder" |
| N_ε(x) | the ε-neighbourhood: every point within reach of x (x included) |
| {… : …} | "the set of … such that …" |
| |·| | how many points are in the set |
| minPts | how many points within reach make x a core point |
In words: a point's neighbourhood is everything within reach; a point with enough neighbours is a core point; clusters grow outward from core points, and anything no core point can reach is noise.
On the example: N₀.₁₅(0.1) = {0, 0.1, 0.2}, 3 ≥ 2, so 0.1 is core; N₀.₁₅(20) = {20}, 1 < 2, so 20 is noise.
In Python:
points = [0, 0.1, 0.2, 5.0, 5.1, 5.2, 20]
eps, minPts = 0.15, 2
# N_ε(x): every point within reach of x
def N(x):
return [y for y in points if abs(x - y) <= eps]
# a core point
N(0.1), len(N(0.1)) >= minPts # → ([0, 0.1, 0.2], True)
# nobody within reach: noise
N(20), len(N(20)) >= minPts # → ([20], False)
Figure 7 · Diagram
flowchart TD
P[Take an unvisited point] --> N{At least minPts<br/>within ε?}
N -->|no| L[Leave it as noise for now]
N -->|yes| S[Start a new cluster]
S --> G[Add every neighbour;<br/>if a neighbour is also core,<br/>add its neighbours too]
G --> G
G --> P
L --> P
In code: dbscan finds the core points, floods each cluster outward
through them, and labels everything unreached −1 (noise).
Figure 5 · Drawn from the lesson's code
DBSCAN at reach 0.6 marks exactly the two off-topic tickets as noise but merges the VPN and printer tickets into one cluster
Figure 6 · Drawn from the lesson's code
No reach gets DBSCAN right: small reaches strand real tickets as noise, and by the time noise is only the off-topic pair the kinds have begun chaining together
That's the problem HDBSCAN solves. It effectively runs DBSCAN at every reach at once, builds a tree of how crowds merge as the reach grows, and keeps the crowds that persist over the widest range of reaches. It handles clusters of different densities, needs no ε, and is the usual choice for exploring messy real text such as support tickets.
Chapter 4
Seeing the map: PCA (and why UMAP and t-SNE mislead on distance)
Everyday picture a shadow on a wall. A 3-D object casts a 2-D shadow; turn the object and the shadow changes. PCA (principal component analysis) turns the object so its shadow is as spread out as possible, keeping as much of the original variation as a flat picture can.
Tiny worked example three points on a straight line, (1, 1), (2, 2), (3, 3). All their variation runs along the diagonal, so the first direction explains 100% of it and the second 0%. Along that direction the points sit √2 = 1.414 apart, exactly as in the original.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Range |
|---|---|---|
| j | which direction (principal component) | 1 to d |
| σⱼ (sigma) | the j-th singular value of the centred data, from the SVD (see primer.notation) |
≥ 0, largest first |
| σⱼ² | proportional to the variance (average squared spread) along direction j | ≥ 0 |
| Σₖ σₖ² | the total spread over all directions k | |
| explainedⱼ | the share of all the spread that direction j captures | 0 to 1 |
In words: the share of all the spread that direction j captures.
On the example: centred, the points are (−1, −1), (0, 0), (1, 1). Their singular values are 2 along the diagonal and 0 across it, so the shares are 2² / (2² + 0²) = 1 and 0 / 4 = 0.
In Python:
import math
centred = [(-1, -1), (0, 0), (1, 1)]
# along the diagonal
u = [(1 / math.sqrt(2), 1 / math.sqrt(2)),
# across it (the SVD finds these; here we know them)
(1 / math.sqrt(2), -1 / math.sqrt(2))]
# spread along u_j
sigma = [math.sqrt(sum((x * u_j[0] + y * u_j[1]) ** 2 for x, y in centred))
for u_j in u]
[round(sigma_j, 3) for sigma_j in sigma] # → [2.0, 0.0]
[round(sigma_j ** 2 / sum(sigma_k ** 2 for sigma_k in sigma), 3) for sigma_j in sigma] # → [1.0, 0.0]
Figure 8 · Drawn from the lesson's code
Squashed to 2-D, each kind of ticket forms its own patch with its k-means centre inside it
In code: pca centres the data, projects it onto the top n directions
from the SVD, and returns each direction's explained share.
UMAP and t-SNE draw prettier maps by a different rule: keep each point's neighbours next to it, and let distances elsewhere stretch. Like a subway map, they're great for "what's near what" and misleading for "how far" or "how big": gaps between clusters and cluster sizes in those plots don't reflect the real space. Use them for intuition, never for measurement.
Chapter 5
Everyday uses
Figure 10 · Diagram
flowchart LR
T[New text] --> E[Embed]
E --> D{Nearly identical to<br/>something stored?}
D -->|yes| DUP[Near-duplicate:<br/>merge or skip]
E --> R{Closest route centroid<br/>above threshold?}
R -->|yes| ROUTE[Send to that team/tool]
R -->|no| HUMAN[Fallback: human or<br/>general assistant]
E --> A{Far from every<br/>cluster centre?}
A -->|yes| ANOM[Flag as anomaly]
E --> C{Cached question, same context,<br/>not expired, above threshold?}
C -->|yes| HIT[Return cached answer]
Near-duplicates. Photocopies with a sticky note on them: nearly
identical vectors. "password reset link expired" and "The password reset
link has expired!" score cosine 1.0 here; flag pairs above a threshold
calibrated on labeled pairs (primer.ml.embeddings.similarity).
In code: near_duplicates embeds a list of texts and returns every pair
whose cosine reaches the threshold.
Routing. A receptionist listening to a request and pointing to the right desk. Each route (a team, a tool, an agent) is represented by the centroid of a few example requests; a new request goes to the closest centroid, or to a fallback when nothing is close:
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Range |
|---|---|---|
| q | the incoming request's embedding | unit vector |
| r | a route: a team, tool or agent | it_helpdesk, finance, hr |
| μᵣ (mu) | route r's centroid: the mean of its example requests, rescaled to length 1 | unit vector |
| cos(q, μᵣ) | cosine similarity of the request with that route | −1 to 1 |
| arg maxᵣ | the route that gives the largest value | |
| θ (theta) | the confidence threshold | 0.3 here |
In words: send the request to the route it's most similar to, unless even the best match is weak, in which case hand it off.
On the example: "my vpn tunnel drops when I work remote" scores highest against the IT helpdesk; "what is the capital of france" is near 0 against every route, below θ = 0.3, so it goes to the fallback.
With the numbers: a toy version with three routes in four dimensions: μ_it = (1, 0, 0, 0), μ_finance = (0, 1, 0, 0), μ_hr = (0, 0, 1, 0). The request q = (0.8, 0.6, 0, 0) has cosines 0.8, 0.6 and 0.0 with them; the best, 0.8, clears θ = 0.3, so it goes to it_helpdesk. The request q = (0.1, 0.2, 0, 1) points mostly where no route lies: its cosines are about 0.1, 0.2 and 0.0, all below θ, so it goes to the fallback.
In Python:
import math
def cos(a, b):
dot = sum(a_k * b_k for a_k, b_k in zip(a, b))
return dot / (math.sqrt(sum(a_k ** 2 for a_k in a)) * math.sqrt(sum(b_k ** 2 for b_k in b)))
mu = {"it_helpdesk": (1, 0, 0, 0), "finance": (0, 1, 0, 0), "hr": (0, 0, 1, 0)}
theta = 0.3
def route(q):
scores = {r: cos(q, mu_r) for r, mu_r in mu.items()}
# arg max_r cos(q, μ_r)
best = max(scores, key=scores.get)
return best if scores[best] >= theta else "fallback"
[round(cos((0.8, 0.6, 0, 0), mu_r), 2) for mu_r in mu.values()], route((0.8, 0.6, 0, 0)) # → ([0.8, 0.6, 0.0], 'it_helpdesk')
[round(cos((0.1, 0.2, 0, 1), mu_r), 2) for mu_r in mu.values()], route((0.1, 0.2, 0, 1)) # → ([0.1, 0.2, 0.0], 'fallback')
Figure 9 · Drawn from the lesson's code
The VPN complaint clears the threshold only for IT, the receipt question only for finance, and the off-topic question for no route, so it goes to a human
In code: Router holds one unit-length centroid per route;
Router.scores gives a request's cosine with each, and Router.route
applies θ and the fallback. support_router builds the lesson's three
routes.
Anomaly detection. A stranger at a party is far from every group. Score each item by its distance to the nearest cluster centre; the largest scores are the unusual items. Here the coffee machine and the dog come out on top.
In code: anomaly_scores returns each point's distance to its nearest
cluster centre.
Semantic caching. An FAQ desk that remembers answers. If a new question means the same as one already answered, return the stored answer and skip the model call, saving cost and latency. Three guards keep it safe:
- A strict, calibrated threshold. "How do I reset my VPN?" is only about 0.46 similar to "How do I reset my password?" here: related, but a different question.
- A context key. "What is my PTO balance?" means something different for Alice and Bob. Answers are only reused within the same user, tenant and permission scope.
- Expiry. A time-to-live, so answers age out when the facts change.
Figure 11 · Diagram
flowchart LR
Q[Question + context] --> F[Keep entries with the<br/>same context, not expired]
F --> S[Most similar entry]
S --> T{cosine ≥ threshold?}
T -->|yes| H[Hit: return stored answer]
T -->|no| M[Miss: call the model,<br/>store the new answer]
In code: SemanticCache.put stores a question, its answer and its
context; SemanticCache.lookup filters by context and expiry, then finds the
most similar entry, and SemanticCache.get returns its answer only above the
threshold.
Test yourself
5 questions
Answer each one out loud or on paper before you open it. If you can explain it, you know it.
Question 1Q: How does k-means work, and what are its limitations?Think it through, then reveal
Alternate assigning each point to its nearest centre and moving each centre to the mean of its points until nothing changes. You must choose k, results depend on the start (k-means++ helps), and it assumes round, similar-sized clusters.
Question 2Q: When would you choose HDBSCAN over k-means for text embeddings?Think it through, then reveal
When you don't know how many groups exist, clusters have different shapes and densities, and some items belong nowhere (support tickets, logs). It finds the number of clusters itself and labels outliers as noise.
Question 3Q: Why can't you trust distances in a UMAP or t-SNE plot?Think it through, then reveal
They preserve local neighbourhoods and deliberately distort everything else, so gaps between clusters and cluster sizes don't reflect the real space.
Question 4Q: How would you route requests to the right agent or tool with embeddings?Think it through, then reveal
Represent each route by the centroid of example requests, send each new request to the most similar centroid, and fall back to a human or general assistant when the best similarity is below a calibrated threshold.
Question 5Q: What can go wrong with a semantic cache?Think it through, then reveal
A loose threshold returns the answer to a different question; missing context keys leak one user's or tenant's answer to another; missing expiry serves stale facts. Filter by context first, then match strictly, and expire entries.
Primary sources
The papers behind this lesson
Showed that spreading out the starting centres makes k-means provably close to optimal and much faster to converge.
The paper ↗Defined clusters as dense regions reachable through core points, with everything else as noise.
The paper ↗Removed DBSCAN's single reach parameter by building a hierarchy over all reaches and keeping the most persistent clusters.
The paper ↗A fast neighbour-preserving projection now standard for visualizing embeddings.
The paper ↗Researcher's shelf
Further reading
- scikit-learn user guide, clustering: https://scikit-learn.org/stable/modules/clustering.html
- hdbscan documentation, How HDBSCAN Works: https://hdbscan.readthedocs.io/en/latest/how_hdbscan_works.html
- UMAP documentation: https://umap-learn.readthedocs.io/
- Wattenberg, Viégas and Johnson, How to Use t-SNE Effectively (Distill): https://distill.pub/2016/misread-tsne/
About this lesson. This is the illustrated edition of a lesson from the open-source AI Primer. Its text, figures and numbers are generated from the Primer's source at commit 048aeaa, so the two always agree: the explanation, the code that builds it and the tests that prove it.