LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnML Algorithms & EvaluationClustering and PCA
📐MediumEmbeddings & Vector Search

Clustering and PCA

Inspect unlabeled developer-message embeddings with k-means and PCA, then stress-test whether apparent neighborhoods survive scale, metric, and compression choices.

30 min read
Learning path
Step 39 of 196 in the full curriculum
Conformal Prediction and UncertaintyProbabilistic Generative Models

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

A developer-support team is preparing semantic search over billing, login, and deploy-policy messages. The embedding model has produced vectors, but nobody has reviewed their neighborhoods. When someone searches for “I was charged twice,” will the nearest message be another billing case or an unrelated deploy note?

Without reviewed query-document pairs, we can't yet score retrieval relevance. We can start by inspecting the vectors: k-means groups nearby points, while principal component analysis (PCA) gives them fewer coordinates for inspection or compression. Neither method reads the messages. This chapter builds their core update rules from scratch, connects them to coarse vector search indexing, and tests whether changing units, similarity metrics, or dimensions breaks a useful neighbor relationship.

Scatter plot of six unlabeled developer-message embeddings in two dimensions: billing_A and billing_B sit near (1, 1), login_A and login_B near (4, 4), and deploy_A and deploy_B near (8, 1). A paired text audit shows sampled wording and only then proposes billing, login, and deploy as reviewed names.
The six points form three geometric pairs. Topic names appear only after you read sampled messages; cluster integers still aren't labels.

Start with six messages, not six labels

We'll use six invented two-number embeddings, chosen to make the arithmetic readable. Real embeddings often contain hundreds or thousands of coordinates; here we can see every coordinate and check every distance. The examples use NumPy and assume you're comfortable with arrays and coordinate-wise averages.

Before looking for groups, identify the pairs you'd expect from reading the messages. We can then check whether distance agrees with that judgment.

Message IDMessage textxxxyyy
billing_A"I was charged twice."1.01.0
billing_B"Please undo this duplicate payment."1.21.8
login_A"My reset link never arrives."4.04.2
login_B"Two-factor code keeps failing."5.03.8
deploy_A"Which runbook handles rollback?"8.01.0
deploy_B"The deploy gate is blocking again."9.01.8

Names in the first column serve our audit rather than feeding into the algorithm. In practice, the clustering logic receives only six coordinate pairs without ever seeing billing, login, or deploy.

Looking at the billing pair, the points differ by 0.2 horizontally and 0.8 vertically. Straight-line distance between them is 0.22+0.82=0.68≈0.82\sqrt{0.2^2+0.8^2}=\sqrt{0.68}\approx0.820.22+0.82​=0.68​≈0.82. For any two points a=(ax,ay)a=(a_x,a_y)a=(ax​,ay​) and b=(bx,by)b=(b_x,b_y)b=(bx​,by​), that Euclidean distance is:

d(a,b)=(ax−bx)2+(ay−by)2d(a,b)=\sqrt{(a_x-b_x)^2 + (a_y-b_y)^2}d(a,b)=(ax​−bx​)2+(ay​−by​)2​

Evaluating this formula measures separation in the representation we currently have. Run it before clustering. A sensible local neighborhood gives the later groups something concrete to explain.

Our code computes all 36 pairwise distances. Broadcasting creates a (6, 6, 2) array of coordinate differences; axis=2 reduces each coordinate pair to one distance. Setting the diagonal to infinity prevents each message from selecting itself. This all-pairs approach is for a small audit, not a large search index.

nearest-message-neighbors.py
1import numpy as np 2 3names = np.array(["billing_A", "billing_B", "login_A", "login_B", "deploy_A", "deploy_B"]) 4vectors = np.array([ 5 [1.0, 1.0], 6 [1.2, 1.8], 7 [4.0, 4.2], 8 [5.0, 3.8], 9 [8.0, 1.0], 10 [9.0, 1.8], 11]) 12 13distances = np.linalg.norm(vectors[:, None, :] - vectors[None, :, :], axis=2) 14np.fill_diagonal(distances, np.inf) 15 16for row, name in enumerate(names): 17 neighbor = distances[row].argmin() 18 print(f"{name:10} -> {names[neighbor]:10} distance={distances[row, neighbor]:.2f}")
Output
1billing_A -> billing_B distance=0.82 2billing_B -> billing_A distance=0.82 3login_A -> login_B distance=1.08 4login_B -> login_A distance=1.08 5deploy_A -> deploy_B distance=1.28 6deploy_B -> deploy_A distance=1.28

Each message's closest neighbor belongs to the same readable theme. That's useful evidence about this representation, not a promise about future messages. Keep that distinction in view as we move from pairs to groups.

Partition unlabeled vectors with Lloyd's algorithm

Search doesn't require clusters. Here, clustering helps us select groups of messages to review together. On these six unlabeled vectors, k-means receives the coordinates and a requested number of groups, kkk. A centroid is a cluster's center, calculated by averaging its points.

The algorithm minimizes the within-cluster sum of squares (WCSS), commonly referred to as inertia:[1]Reference 1The Elements of Statistical Learning.https://hastie.su.domains/ElemStatLearn/[2]Reference 2Pattern Recognition and Machine Learning.https://www.microsoft.com/en-us/research/publication/pattern-recognition-machine-learning/

J=∑k=1K∑i∈Sk∥xi−μk∥22J = \sum_{k=1}^{K} \sum_{i \in S_k} \left\lVert x_i - \mu_k \right\rVert_2^2J=k=1∑K​i∈Sk​∑​∥xi​−μk​∥22​

Here, xix_ixi​ is message vector iii, SkS_kSk​ is the set of points assigned to cluster kkk, and μk\mu_kμk​ is the centroid of cluster kkk. Lloyd's algorithm optimizes this objective through coordinate descent, alternating between two steps until convergence:[3]Reference 3Clusteringhttps://scikit-learn.org/stable/modules/clustering.html

  1. Assignment step: Hold the centroids μk\mu_kμk​ fixed. Assign each vector xix_ixi​ to its nearest centroid:
zi=arg⁡min⁡k∈{1,…,K}∥xi−μk∥22z_i = \arg\min_{k \in \{1,\dots,K\}} \left\lVert x_i - \mu_k \right\rVert_2^2zi​=argk∈{1,…,K}min​∥xi​−μk​∥22​

This minimizes JJJ with respect to the assignments zzz while keeping centroids stationary.

  1. Update step: Hold the assignments SkS_kSk​ fixed. Recompute each centroid μk\mu_kμk​ to minimize JJJ:
∂J∂μk=−2∑i∈Sk(xi−μk)=0  ⟹  μk=1∣Sk∣∑i∈Skxi\frac{\partial J}{\partial \mu_k} = -2 \sum_{i \in S_k} (x_i - \mu_k) = 0 \implies \mu_k = \frac{1}{|S_k|} \sum_{i \in S_k} x_i∂μk​∂J​=−2i∈Sk​∑​(xi​−μk​)=0⟹μk​=∣Sk​∣1​i∈Sk​∑​xi​

Taking the arithmetic mean isn't an arbitrary choice. The derivative shows that the sample mean is the unique point that minimizes the sum of squared Euclidean distances to all members of the set.

Because both steps strictly decrease or preserve JJJ, the sequence of objective values is monotonically non-increasing. Since there are only finitely many ways to partition nnn points into kkk clusters, Lloyd's algorithm is guaranteed to terminate. It settles on a local minimum, not necessarily the global optimum.

Start with k=3k=3k=3 and pick one seed from each visible area:

  • c0=(1.0,1.0)c_0=(1.0,1.0)c0​=(1.0,1.0) from billing_A
  • c1=(4.0,4.2)c_1=(4.0,4.2)c1​=(4.0,4.2) from login_A
  • c2=(8.0,1.0)c_2=(8.0,1.0)c2​=(8.0,1.0) from deploy_A

Before any update, the initial inertia evaluated at these seeds is:

Jseed=(02+02)+(0.22+0.82)+(02+02)+(1.02+(−0.4)2)+(02+02)+(1.02+0.82)=0.68+1.16+1.64=3.48J_{\text{seed}} = (0^2 + 0^2) + (0.2^2 + 0.8^2) + (0^2 + 0^2) + (1.0^2 + (-0.4)^2) + (0^2 + 0^2) + (1.0^2 + 0.8^2) = 0.68 + 1.16 + 1.64 = 3.48Jseed​=(02+02)+(0.22+0.82)+(02+02)+(1.02+(−0.4)2)+(02+02)+(1.02+0.82)=0.68+1.16+1.64=3.48

After assignment, each pair stays with its local seed. Averaging the coordinates in each pair produces:

Candidate groupAssigned pointsUpdated centroid
left groupbilling_A, billing_B(1.1,1.4)(1.1, 1.4)(1.1,1.4)
upper grouplogin_A, login_B(4.5,4.0)(4.5, 4.0)(4.5,4.0)
right groupdeploy_A, deploy_B(8.5,1.4)(8.5, 1.4)(8.5,1.4)

Evaluating the objective around these updated means cuts inertia in half:

Jupdated=2⋅(0.12+0.42)+2⋅(0.52+0.22)+2⋅(0.52+0.42)=0.34+0.58+0.82=1.74J_{\text{updated}} = 2 \cdot (0.1^2 + 0.4^2) + 2 \cdot (0.5^2 + 0.2^2) + 2 \cdot (0.5^2 + 0.4^2) = 0.34 + 0.58 + 0.82 = 1.74Jupdated​=2⋅(0.12+0.42)+2⋅(0.52+0.22)+2⋅(0.52+0.42)=0.34+0.58+0.82=1.74
one-kmeans-update.py
1import numpy as np 2 3vectors = np.array([ 4 [1.0, 1.0], [1.2, 1.8], 5 [4.0, 4.2], [5.0, 3.8], 6 [8.0, 1.0], [9.0, 1.8], 7]) 8centroids = vectors[[0, 2, 4]].copy() 9 10squared_distance = ((vectors[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=2) 11cluster = squared_distance.argmin(axis=1) 12updated = np.vstack([vectors[cluster == index].mean(axis=0) for index in range(3)]) 13inertia = ((vectors - updated[cluster]) ** 2).sum() 14 15print("assignment:", cluster.tolist()) 16print("updated centroids:", np.round(updated, 2).tolist()) 17print(f"inertia after update: {inertia:.2f}")
Output
1assignment: [0, 0, 1, 1, 2, 2] 2updated centroids: [[1.1, 1.4], [4.5, 4.0], [8.5, 1.4]] 3inertia after update: 1.74

Why can't you write cluster 0 = billing into a long-lived dashboard?

Answer

Cluster integers have no stable semantic meaning. Another initialization or retraining run can number the same groups differently. Persist a run ID, inspect current members, and attach a reviewed human label separately.

Two coordinate plots of the six message embeddings. On the left, each point assigns to the nearest seed at (1.0, 1.0), (4.0, 4.2), and (8.0, 1.0). On the right, those seeds move to the pair means (1.1, 1.4), (4.5, 4.0), and (8.5, 1.4), cutting inertia from 3.48 to 1.74.
One assignment/update pass moves each seed to its pair's mean and cuts this toy objective from 3.48 to 1.74. The numeric IDs remain arbitrary; only source-message review can justify tentative topic names.

Build the k-means loop and prevent initialization traps

Production libraries provide careful seeding and convergence controls. Our scratch implementation accepts a matrix of vectors and initial centers. Iteration stops when cluster assignments repeat, which guarantees that the calculated means remain unchanged too. Halting on stable assignments avoids relying on arbitrary coordinate tolerances that might stop prematurely.

kmeans-from-scratch.py
1import numpy as np 2 3names = np.array(["billing_A", "billing_B", "login_A", "login_B", "deploy_A", "deploy_B"]) 4vectors = np.array([ 5 [1.0, 1.0], [1.2, 1.8], 6 [4.0, 4.2], [5.0, 3.8], 7 [8.0, 1.0], [9.0, 1.8], 8]) 9 10def kmeans(x: np.ndarray, initial: np.ndarray, max_steps: int = 20): 11 centers = initial.astype(float).copy() 12 previous_labels = None 13 for step in range(1, max_steps + 1): 14 squared = ((x[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2) 15 labels = squared.argmin(axis=1) 16 next_centers = [] 17 for index in range(len(centers)): 18 members = x[labels == index] 19 if members.shape[0] == 0: 20 raise ValueError(f"cluster {index} is empty") 21 next_centers.append(members.mean(axis=0)) 22 next_centers = np.vstack(next_centers) 23 if np.array_equal(labels, previous_labels): 24 inertia = ((x - next_centers[labels]) ** 2).sum() 25 return labels, next_centers, inertia, step 26 centers = next_centers 27 previous_labels = labels.copy() 28 raise RuntimeError("k-means did not converge") 29 30labels, centers, inertia, steps = kmeans(vectors, vectors[[0, 2, 4]]) 31for index in range(3): 32 members = names[labels == index].tolist() 33 print(f"cluster {index}: {members} center={centers[index].round(2).tolist()}") 34print(f"converged in {steps} steps with inertia={inertia:.2f}")
Output
1cluster 0: ['billing_A', 'billing_B'] center=[1.1, 1.4] 2cluster 1: ['login_A', 'login_B'] center=[4.5, 4.0] 3cluster 2: ['deploy_A', 'deploy_B'] center=[8.5, 1.4] 4converged in 2 steps with inertia=1.74

The initialization trap: why k-means++ matters

Uniform random initialization frequently picks multiple seeds that sit within the same dense cluster. When that happens, two seeds split a single coherent topic while another distant region is left without any seed at all.

Arthur and Vassilvitskii addressed this vulnerability with k-means++ initialization.[3]Reference 3Clusteringhttps://scikit-learn.org/stable/modules/clustering.html Rather than sampling centroids uniformly, k-means++ spreads initial seeds across the feature space:

  1. Select the first centroid μ1\mu_1μ1​ uniformly at random from the dataset XXX.
  2. For each point x∈Xx \in Xx∈X, compute D(x)D(x)D(x), the shortest Euclidean distance from xxx to any already chosen centroid:
D(x)=min⁡j∈{1,…,m}∥x−μj∥2D(x) = \min_{j \in \{1,\dots,m\}} \|x - \mu_j\|_2D(x)=j∈{1,…,m}min​∥x−μj​∥2​
  1. Sample the next centroid from XXX with probability proportional to the squared distance:
P(x)=D(x)2∑x′∈XD(x′)2P(x) = \frac{D(x)^2}{\sum_{x' \in X} D(x')^2}P(x)=∑x′∈X​D(x′)2D(x)2​
  1. Repeat steps 2 and 3 until all KKK centroids have been selected.

Points close to existing centroids have small D(x)2D(x)^2D(x)2, making duplicate seeding in the same neighborhood improbable. Points far away have large D(x)2D(x)^2D(x)2, drawing the next centroid toward unexplored clusters. This simple stochastic heuristic guarantees an expected bound of O(log⁡K)O(\log K)O(logK) times the optimal WCSS.

We can see this probability distribution in action on our developer messages. Suppose billing_A was chosen as the first seed:

kmeans-plus-plus-sampling.py
1import numpy as np 2 3vectors = np.array([ 4 [1.0, 1.0], [1.2, 1.8], 5 [4.0, 4.2], [5.0, 3.8], 6 [8.0, 1.0], [9.0, 1.8], 7]) 8names = ["billing_A", "billing_B", "login_A", "login_B", "deploy_A", "deploy_B"] 9 10# First seed picked at index 0 (billing_A) 11seed_0 = vectors[0] 12d2 = ((vectors - seed_0) ** 2).sum(axis=1) 13prob = d2 / d2.sum() 14 15for name, dist2, p in zip(names, d2, prob): 16 print(f"{name:10} D(x)^2={dist2:5.2f} P(selection)={p:5.3f}")
Output
1billing_A D(x)^2= 0.00 P(selection)=0.000 2billing_B D(x)^2= 0.68 P(selection)=0.004 3login_A D(x)^2=19.24 P(selection)=0.122 4login_B D(x)^2=23.84 P(selection)=0.151 5deploy_A D(x)^2=49.00 P(selection)=0.311 6deploy_B D(x)^2=64.64 P(selection)=0.411

The neighboring billing message has less than a 0.5% chance of being selected as the next seed. The distant deploy messages receive over 72% of the cumulative selection probability.

The loop also exposes a failure that a library call can hide. If a center gets no assigned vectors, mean is undefined. The guard raises instead of writing nan into the next iterate:

empty-cluster-assignment.py
1import numpy as np 2 3vectors = np.array([ 4 [1.0, 1.0], [1.2, 1.8], 5 [4.0, 4.2], [5.0, 3.8], 6 [8.0, 1.0], [9.0, 1.8], 7]) 8centers = np.array([[1.0, 1.0], [1.2, 1.8], [100.0, 100.0]]) 9squared = ((vectors[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2) 10labels = squared.argmin(axis=1) 11for index in range(3): 12 print(f"cluster {index}: {(labels == index).sum()} points")
Output
1cluster 0: 1 points 2cluster 1: 5 points 3cluster 2: 0 points

Don't average an empty slice. A production implementation needs an explicit repair policy, such as reseeding the empty cluster at the point farthest from any current center.

One embedding dimension ranges from 0 to 1, while another ranges from 0 to 10,000. What happens if Euclidean k-means uses them without scaling?

Answer

The large-range dimension can dominate distance and manufacture clusters around its units. Standardize or otherwise justify feature scales before interpreting the assignments.

Evaluate cluster geometry with elbow curves and silhouette scores

The toy map made k=3k=3k=3 feel natural because it contained three distinct pairs. A production corpus won't hand you its correct cluster count. Treat kkk as an architectural decision to evaluate, not a fixed constant.

A cluster audit combines two quantitative diagnostic tools with human validation:

  1. The elbow method: Plot inertia J(k)J(k)J(k) as a function of kkk. Since adding more centroids grants extra degrees of freedom, the best achievable inertia can't increase as kkk grows, reaching zero when k=nk=nk=n. Look for the inflection point (the "elbow") where the rate of improvement drops:
ΔJ(k)=J(k)−J(k+1)\Delta J(k) = J(k) - J(k+1)ΔJ(k)=J(k)−J(k+1)
  1. Silhouette analysis: For each vector xix_ixi​, compute its intra-cluster cohesion a(i)a(i)a(i) (the average distance to all other points in its assigned cluster):
a(i)=1∣Szi∣−1∑j∈Szi,j≠i∥xi−xj∥2a(i) = \frac{1}{|S_{z_i}| - 1} \sum_{j \in S_{z_i}, j \neq i} \left\lVert x_i - x_j \right\rVert_2a(i)=∣Szi​​∣−11​j∈Szi​​,j=i∑​∥xi​−xj​∥2​

Next, find its nearest neighbor cluster and compute separation b(i)b(i)b(i) (the smallest mean distance to any other cluster):

b(i)=min⁡k≠zi1∣Sk∣∑j∈Sk∥xi−xj∥2b(i) = \min_{k \neq z_i} \frac{1}{|S_k|} \sum_{j \in S_k} \left\lVert x_i - x_j \right\rVert_2b(i)=k=zi​min​∣Sk​∣1​j∈Sk​∑​∥xi​−xj​∥2​

The silhouette score for point iii is:[4]Reference 4silhouette_scorehttps://scikit-learn.org/stable/modules/generated/sklearn.metrics.silhouette_score.html

s(i)=b(i)−a(i)max⁡(a(i),b(i))s(i) = \frac{b(i) - a(i)}{\max(a(i), b(i))}s(i)=max(a(i),b(i))b(i)−a(i)​

Scores near +1+1+1 indicate that a point is well inside its cluster and far from neighbors. Scores near 000 mean the point lies on a cluster boundary. Negative scores mean the point is closer to a neighboring cluster on average than to its assigned center.

Watch how adding a fourth cluster continues lowering inertia while degrading silhouette:

compare-candidate-cluster-counts.py
1from itertools import combinations 2 3import numpy as np 4 5vectors = np.array([ 6 [1.0, 1.0], [1.2, 1.8], 7 [4.0, 4.2], [5.0, 3.8], 8 [8.0, 1.0], [9.0, 1.8], 9]) 10 11def kmeans(x: np.ndarray, initial: np.ndarray, max_steps: int = 20): 12 centers = initial.astype(float).copy() 13 previous_labels = None 14 for _ in range(max_steps): 15 squared = ((x[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2) 16 labels = squared.argmin(axis=1) 17 next_centers = [] 18 for index in range(len(centers)): 19 members = x[labels == index] 20 if members.shape[0] == 0: 21 return None 22 next_centers.append(members.mean(axis=0)) 23 next_centers = np.vstack(next_centers) 24 if np.array_equal(labels, previous_labels): 25 inertia = ((x - next_centers[labels]) ** 2).sum() 26 return labels, inertia 27 centers = next_centers 28 previous_labels = labels.copy() 29 return None 30 31def mean_silhouette(x: np.ndarray, labels: np.ndarray) -> float: 32 if not 2 <= len(np.unique(labels)) <= len(x) - 1: 33 raise ValueError("silhouette needs 2 through n-1 nonempty clusters") 34 scores = [] 35 for i, point in enumerate(x): 36 same = np.where(labels == labels[i])[0] 37 others = same[same != i] 38 if others.size == 0: 39 scores.append(0.0) 40 continue 41 cohesion = np.linalg.norm(x[others] - point, axis=1).mean() 42 separation = min( 43 np.linalg.norm(x[labels == cluster] - point, axis=1).mean() 44 for cluster in np.unique(labels) if cluster != labels[i] 45 ) 46 scale = max(cohesion, separation) 47 scores.append((separation - cohesion) / scale if scale > 0 else 0.0) 48 return float(np.mean(scores)) 49 50for k in (2, 3, 4): 51 best = None 52 for idx in combinations(range(len(vectors)), k): 53 result = kmeans(vectors, vectors[list(idx)]) 54 if result is None: 55 continue 56 labels, inertia = result 57 if best is None or inertia < best[0]: 58 best = (inertia, labels) 59 if best is None: 60 raise RuntimeError(f"no converged nonempty partition for k={k}") 61 inertia, labels = best 62 print(f"k={k}: inertia={inertia:.2f}, silhouette={mean_silhouette(vectors, labels):.2f}")
Output
1k=2: inertia=20.06, silhouette=0.56 2k=3: inertia=1.74, silhouette=0.76 3k=4: inertia=0.92, silhouette=0.51

Moving from k=2k=2k=2 to k=3k=3k=3 creates a steep drop in inertia (from 20.06 down to 1.74) while driving silhouette up to 0.76. Moving to k=4k=4k=4 reduces inertia only marginally (from 1.74 down to 0.92) while splitting a natural pair, which pulls silhouette down to 0.51.

Equal scores can still tell different stories

An objective can be stable while the semantic interpretation remains ambiguous. Place four messages at the vertices of a square. Two orthogonal two-cluster partitions yield the exact same inertia: split left from right, or split bottom from top.

same-inertia-different-story.py
1import numpy as np 2 3square = np.array([[0.0, 0.0], [0.0, 2.0], [2.0, 0.0], [2.0, 2.0]]) 4seeds = { 5 "left/right": np.array([[0.0, 1.0], [2.0, 1.0]]), 6 "bottom/top": np.array([[1.0, 0.0], [1.0, 2.0]]), 7} 8 9for name, seed in seeds.items(): 10 squared = ((square[:, None, :] - seed[None, :, :]) ** 2).sum(axis=2) 11 labels = squared.argmin(axis=1) 12 centers = np.vstack([square[labels == i].mean(axis=0) for i in range(2)]) 13 inertia = ((square - centers[labels]) ** 2).sum() 14 groups = [np.where(labels == i)[0].tolist() for i in range(2)] 15 print(f"{name:10} groups={groups} inertia={inertia:.1f}")
Output
1left/right groups=[[0, 1], [2, 3]] inertia=4.0 2bottom/top groups=[[0, 2], [1, 3]] inertia=4.0

Both groupings optimize the objective equally well. If horizontal separation represented user tier and vertical separation represented issue severity, geometry alone wouldn't tell you which grouping your support team needs. Equal objective values don't make the product stories interchangeable.

Compress representations with principal component analysis

K-means assigns vectors to discrete categories. PCA addresses a complementary question: can we describe the cloud with fewer continuous coordinates while preserving as much variance as possible?

PCA has two equivalent mathematical foundations:[2]Reference 2Pattern Recognition and Machine Learning.https://www.microsoft.com/en-us/research/publication/pattern-recognition-machine-learning/

  1. Maximum variance formulation: Find an orthonormal unit vector www (∥w∥2=1\|w\|_2=1∥w∥2​=1) such that projecting the centered data onto www maximizes the empirical variance of the projections:
max⁡∥w∥=11n−1∑i=1n(xi⊤w)2=max⁡∥w∥=1w⊤Cw\max_{\|w\|=1} \frac{1}{n-1} \sum_{i=1}^n (x_i^\top w)^2 = \max_{\|w\|=1} w^\top C w∥w∥=1max​n−11​i=1∑n​(xi⊤​w)2=∥w∥=1max​w⊤Cw

where C=1n−1Xc⊤XcC = \frac{1}{n-1} X_c^\top X_cC=n−11​Xc⊤​Xc​ is the sample covariance matrix.

  1. Minimum reconstruction error formulation: Find an orthonormal basis www that minimizes the sum of squared reconstruction errors when projecting points back to the original space:
min⁡∥w∥=1∑i=1n∥xi−(xi⊤w)w∥22\min_{\|w\|=1} \sum_{i=1}^n \left\lVert x_i - (x_i^\top w) w \right\rVert_2^2∥w∥=1min​i=1∑n​​xi​−(xi⊤​w)w​22​

These two views are identical because of the Pythagorean theorem for orthogonal projections. For any point xix_ixi​ and unit direction www:

∥xi∥22=∥(xi⊤w)w∥22+∥xi−(xi⊤w)w∥22\|x_i\|_2^2 = \|(x_i^\top w) w\|_2^2 + \|x_i - (x_i^\top w) w\|_2^2∥xi​∥22​=∥(xi⊤​w)w∥22​+∥xi​−(xi⊤​w)w∥22​

Summing over all points in the centered dataset yields:

Total Variance=Projected Variance+Reconstruction Error\text{Total Variance} = \text{Projected Variance} + \text{Reconstruction Error}Total Variance=Projected Variance+Reconstruction Error

Because total variance is fixed for any given dataset, maximizing projected variance automatically minimizes reconstruction error.

Covariance eigendecomposition versus singular value decomposition

First center the matrix by subtracting the column-wise mean vector μ\muμ:

Xc=X−1μ⊤X_c = X - \mathbf{1} \mu^\topXc​=X−1μ⊤

From XcX_cXc​, two computational routes lead to the same principal components:[5]Reference 5sklearn.decomposition.PCAhttps://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html

  • Route A: Eigendecomposition of the covariance matrix. Form C=1n−1Xc⊤XcC = \frac{1}{n-1} X_c^\top X_cC=n−11​Xc⊤​Xc​, then solve the eigenvalue equation:
Cvj=λjvjC v_j = \lambda_j v_jCvj​=λj​vj​

The eigenvectors vjv_jvj​ form the principal directions, and the eigenvalues λj\lambda_jλj​ represent the variance captured along each axis.

  • Route B: Singular Value Decomposition (SVD) directly on XcX_cXc​. Factor the centered data matrix into:
Xc=UΣV⊤X_c = U \Sigma V^\topXc​=UΣV⊤

Plugging this factorization into the covariance definition reveals the exact equivalence:

Xc⊤Xc=(VΣ⊤U⊤)(UΣV⊤)=VΣ2V⊤X_c^\top X_c = (V \Sigma^\top U^\top)(U \Sigma V^\top) = V \Sigma^2 V^\topXc⊤​Xc​=(VΣ⊤U⊤)(UΣV⊤)=VΣ2V⊤

Because U⊤U=IU^\top U = IU⊤U=I, the right singular vectors VVV are precisely the eigenvectors of Xc⊤XcX_c^\top X_cXc⊤​Xc​, and the singular values σj\sigma_jσj​ relate directly to the covariance eigenvalues:

λj=σj2n−1\lambda_j = \frac{\sigma_j^2}{n-1}λj​=n−1σj2​​

Production libraries (such as scikit-learn and PyTorch) compute PCA via SVD rather than explicit covariance eigendecomposition for two numerical reasons:

  1. Condition number squaring: Forming Xc⊤XcX_c^\top X_cXc⊤​Xc​ squares the condition number of the data matrix: κ(Xc⊤Xc)=κ(Xc)2\kappa(X_c^\top X_c) = \kappa(X_c)^2κ(Xc⊤​Xc​)=κ(Xc​)2. If a feature has small singular values, squaring them can push values below machine precision, causing catastrophic numerical cancellation.
  2. Memory and computation at scale: If you have 100 documents represented by 1536-dimensional embeddings (n≪dn \ll dn≪d), forming Xc⊤XcX_c^\top X_cXc⊤​Xc​ requires allocating and operating on a 1536×15361536 \times 15361536×1536 covariance matrix. Truncated SVD computes the top kkk principal components in O(n⋅d⋅k)O(n \cdot d \cdot k)O(n⋅d⋅k) without ever materializing the d×dd \times dd×d covariance matrix.
svd-vs-covariance-equivalence.py
1import numpy as np 2 3vectors = np.array([ 4 [1.0, 1.0], [1.2, 1.8], 5 [4.0, 4.2], [5.0, 3.8], 6 [8.0, 1.0], [9.0, 1.8], 7]) 8n = len(vectors) 9centered = vectors - vectors.mean(axis=0) 10 11# Route A: SVD on centered data 12_, s, vt = np.linalg.svd(centered, full_matrices=False) 13eigenvalues_svd = (s ** 2) / (n - 1) 14 15# Route B: Eigendecomposition of sample covariance matrix 16cov = (centered.T @ centered) / (n - 1) 17eigenvalues_cov = np.sort(np.linalg.eigvalsh(cov))[::-1] 18 19print("SVD eigenvalues: ", np.round(eigenvalues_svd, 4).tolist()) 20print("Covariance eigenvalues:", np.round(eigenvalues_cov, 4).tolist()) 21print("Eigenvalues match: ", np.allclose(eigenvalues_svd, eigenvalues_cov))
Output
1SVD eigenvalues: [11.1825, 1.9442] 2Covariance eigenvalues: [11.1825, 1.9442] 3Eigenvalues match: True

The explained variance ratio measures the proportion of total linear spread retained by each component:

Ratioj=λj∑k=1dλk=σj2∑k=1dσk2\text{Ratio}_j = \frac{\lambda_j}{\sum_{k=1}^d \lambda_k} = \frac{\sigma_j^2}{\sum_{k=1}^d \sigma_k^2}Ratioj​=∑k=1d​λk​λj​​=∑k=1d​σk2​σj2​​

For our six developer messages, PC1 runs almost horizontally, accounting for 85.2% of the total geometric variance:

pca-from-svd.py
1import numpy as np 2 3names = np.array(["billing_A", "billing_B", "login_A", "login_B", "deploy_A", "deploy_B"]) 4vectors = np.array([ 5 [1.0, 1.0], [1.2, 1.8], 6 [4.0, 4.2], [5.0, 3.8], 7 [8.0, 1.0], [9.0, 1.8], 8]) 9 10centered = vectors - vectors.mean(axis=0) 11_, singular_values, vt = np.linalg.svd(centered, full_matrices=False) 12pc1 = vt[0] 13if pc1[0] < 0: 14 pc1 = -pc1 15projection = centered @ pc1 16ratio = singular_values**2 / (singular_values**2).sum() 17 18print("mean:", np.round(vectors.mean(axis=0), 2).tolist()) 19print("pc1:", np.round(pc1, 3).tolist()) 20print("explained variance:", np.round(ratio, 3).tolist()) 21for name, value in zip(names, projection): 22 print(f"{name:10} pc1={value:5.2f}")
Output
1mean: [4.7, 2.27] 2pc1: [1.0, -0.016] 3explained variance: [0.852, 0.148] 4billing_A pc1=-3.68 5billing_B pc1=-3.49 6login_A pc1=-0.73 7login_B pc1= 0.27 8deploy_A pc1= 3.32 9deploy_B pc1= 4.31

PCA produces a single coordinate, pc1, as a linear combination of original dimensions. Sign orientation is arbitrary: flipping the sign of an eigenvector reverses coordinates without affecting pairwise distances or reconstruction error.

PCA on the six developer-message vectors: original x-y points, a near-horizontal PC1 axis through the mean (4.7, 2.27), drop lines onto that axis, PC1 scores from -3.68 to 4.31, and 85.2 percent of variance kept versus 14.8 percent residual.
Each point drops onto the near-horizontal PC1 axis through the mean. The six PC1 scores keep 85.2% of the linear spread; the drop length is the residual that one component discards.

⚠️ Common mistake: Cluster a 2D PCA plot and assume you've recovered the neighborhoods in the full embedding. PCA before clustering can help remove noise or reduce computation, but it changes the space being grouped. Compare it with clustering the original vectors and evaluate the result against the task. A clean plot alone doesn't justify the transform.

Measure what compression discards

Connecting explained variance to reconstruction error demonstrates the trade-off. Project the data onto one component, reconstruct it in the original coordinate system, and measure the squared residual:

X^=(Xcv1)v1⊤+1μ⊤\hat{X} = (X_c v_1) v_1^\top + \mathbf{1} \mu^\topX^=(Xc​v1​)v1⊤​+1μ⊤
pca-reconstruction-error.py
1import numpy as np 2 3vectors = np.array([ 4 [1.0, 1.0], [1.2, 1.8], 5 [4.0, 4.2], [5.0, 3.8], 6 [8.0, 1.0], [9.0, 1.8], 7]) 8mean = vectors.mean(axis=0) 9centered = vectors - mean 10_, singular_values, vt = np.linalg.svd(centered, full_matrices=False) 11 12for components in (1, 2): 13 basis = vt[:components] 14 reconstructed = (centered @ basis.T) @ basis + mean 15 error = ((vectors - reconstructed) ** 2).sum() 16 kept = (singular_values[:components] ** 2).sum() / (singular_values ** 2).sum() 17 print(f"components={components}: variance_kept={kept:.3f}, squared_error={error:.2f}")
Output
1components=1: variance_kept=0.852, squared_error=9.72 2components=2: variance_kept=1.000, squared_error=0.00

The boundary is concrete: explained variance measures geometry, not semantic importance. A subtle feature separating critical edge cases might carry low total variance, yet determine whether a query succeeds or fails.

Two principal components explain 95% of total variance. Does that prove they preserve retrieval quality?

Answer

No. PCA preserves high-variance directions, not task relevance. A low-variance direction may still carry the signal needed for a query, so measure downstream neighbors or retrieval metrics after projection.

Connect clustering to vector search: inverted file indexing

Clustering isn't just an exploratory diagnostic. In production vector databases (such as Faiss, Milvus, Qdrant, and pgvector), k-means forms the foundation of the Inverted File Index (IVF) for fast approximate nearest neighbor (ANN) search.[6]Reference 6Billion-scale similarity search with GPUs.https://arxiv.org/abs/1702.08734[7]Reference 7Faiss indexeshttps://github.com/facebookresearch/faiss/wiki/Faiss-indexes

When serving millions of ddd-dimensional embeddings, exhaustive brute-force search (a flat index) compares the query against every stored vector, requiring O(N⋅d)O(N \cdot d)O(N⋅d) operations per query. Under strict latency budgets (often under 10 milliseconds), brute-force scanning fails at scale.

IVF turns this linear search into a sublinear candidate lookup using k-means:

  1. Training phase: Run k-means over the document embeddings to learn KKK coarse centroids (often K=4096K = 4096K=4096 or K≈NK \approx \sqrt{N}K≈N​), creating a Voronoi partition of the vector space.
  2. Index building: Assign each document vector to its closest centroid. Instead of storing a flat array, the index maintains an inverted posting list for each centroid containing its assigned document IDs.
  3. Query phase:
    • Compute distances from the incoming query vector qqq to the KKK coarse centroids.
    • Select the nprobenprobenprobe closest centroids (typically nprobe≪Knprobe \ll Knprobe≪K, such as nprobe=16nprobe = 16nprobe=16).
    • Scan only the document vectors in the inverted posting lists belonging to those nprobenprobenprobe cells.

This prunes (1−nprobeK)(1 - \frac{nprobe}{K})(1−Knprobe​) of the search space. If K=4096K=4096K=4096 and nprobe=16nprobe=16nprobe=16, the engine skips 99.6% of the database during retrieval.

Inverted File Index (IVF) coarse clustering: 2D embedding space partitioned into Voronoi cells with centroids C0, C1, C2, and C3. A query vector compares against the centroids, selects the nearest cell C1 (nprobe=1), and scans only C1 inverted posting list while pruning the remaining cells.
Vector search engines use k-means coarse quantization to build inverted file indexes. A query only checks candidate documents in the nearest centroid posting lists, pruning the vast majority of vectors.

We can simulate an IVF coarse index over our developer messages:

ivf-coarse-quantization-search.py
1import numpy as np 2 3vectors = np.array([ 4 [1.0, 1.0], [1.2, 1.8], # billing 5 [4.0, 4.2], [5.0, 3.8], # login 6 [8.0, 1.0], [9.0, 1.8], # deploy 7]) 8names = ["billing_A", "billing_B", "login_A", "login_B", "deploy_A", "deploy_B"] 9 10# Centroids learned during index training (K=3) 11centroids = np.array([ 12 [1.1, 1.4], # C0: billing 13 [4.5, 4.0], # C1: login 14 [8.5, 1.4], # C2: deploy 15]) 16 17# Build inverted lists 18assignments = ((vectors[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=2).argmin(axis=1) 19inverted_lists = {cluster_id: [] for cluster_id in range(3)} 20for doc_idx, cluster_id in enumerate(assignments): 21 inverted_lists[cluster_id].append(doc_idx) 22 23# Incoming user query: "Cannot authenticate with my 2FA token" 24query = np.array([4.2, 4.0]) 25 26# Step 1: Coarse search over 3 centroids 27centroid_distances = np.linalg.norm(centroids - query, axis=1) 28nprobe = 1 29probed_clusters = centroid_distances.argsort()[:nprobe] 30 31# Step 2: Fine search only within probed inverted lists 32candidate_indices = [idx for c in probed_clusters for idx in inverted_lists[c]] 33candidate_distances = np.linalg.norm(vectors[candidate_indices] - query, axis=1) 34best_match = candidate_indices[candidate_distances.argmin()] 35 36print("probed clusters:", probed_clusters.tolist()) 37print("scanned docs: ", [names[i] for i in candidate_indices]) 38print("nearest match: ", names[best_match], f"(distance={candidate_distances.min():.2f})") 39print(f"pruning ratio: {len(names) - len(candidate_indices)} of {len(names)} vectors skipped")
Output
1probed clusters: [1] 2scanned docs: ['login_A', 'login_B'] 3nearest match: login_A (distance=0.28) 4pruning ratio: 4 of 6 vectors skipped

The nprobenprobenprobe parameter governs the trade-off between search latency and recall:

  • Low nprobenprobenprobe (nprobe=1nprobe=1nprobe=1): Maximal query throughput. If a document lies close to a Voronoi boundary, the query might land in the adjacent cell, causing a boundary recall miss.
  • High nprobenprobenprobe (nprobe→Knprobe \to Knprobe→K): High recall approaching exact brute-force search, but at the cost of evaluating more candidates.

Failure test 1: let units manufacture the clusters

An embedding often sits beside tabular metadata such as message length, incident count, or timestamps. Mixing unlike measurements into one matrix causes k-means or PCA to track the largest raw numerical units rather than semantic meaning.

Here an invented topic coordinate separates billing and deploy, while message length varies across both. In raw units, a short billing note is closer to a short deploy note than to a long billing note. We'll run the same two-center loop twice, always seeding from bill_100 and dep_110. Only feature scaling changes.

scale-can-overrule-topic.py
1import numpy as np 2 3# Column 0 carries topic; column 1 is message length in characters. 4names = ["bill_100", "bill_500", "bill_900", "dep_110", "dep_510", "dep_910"] 5features = np.array([ 6 [-3.2, 100.0], [-3.0, 500.0], [-2.8, 900.0], 7 [ 2.8, 110.0], [ 3.0, 510.0], [ 3.2, 910.0], 8]) 9 10def cluster(x: np.ndarray) -> list[int]: 11 centers = x[[0, 3]].copy() # Same source rows in both runs. 12 previous = None 13 for _ in range(20): 14 squared = ((x[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2) 15 labels = squared.argmin(axis=1) 16 if np.array_equal(labels, previous): 17 return labels.tolist() 18 groups = [x[labels == i] for i in range(2)] 19 if any(len(group) == 0 for group in groups): 20 raise ValueError("empty cluster") 21 centers = np.vstack([group.mean(axis=0) for group in groups]) 22 previous = labels.copy() 23 raise RuntimeError("k-means did not converge") 24 25print("raw dist bill_100 to dep_110:", round(float(np.linalg.norm(features[0] - features[3])), 1)) 26print("raw dist bill_100 to bill_900:", round(float(np.linalg.norm(features[0] - features[2])), 1)) 27 28raw_labels = cluster(features) 29print("raw groups, seeds bill_100 and dep_110:") 30for index in range(2): 31 members = [names[i] for i, label in enumerate(raw_labels) if label == index] 32 print(f" cluster {index}: {members}") 33 34scaled = (features - features.mean(axis=0)) / features.std(axis=0) 35scaled_labels = cluster(scaled) 36print("scaled groups, same seed rows:") 37for index in range(2): 38 members = [names[i] for i, label in enumerate(scaled_labels) if label == index] 39 print(f" cluster {index}: {members}")
Output
1raw dist bill_100 to dep_110: 11.7 2raw dist bill_100 to bill_900: 800.0 3raw groups, seeds bill_100 and dep_110: 4 cluster 0: ['bill_100', 'dep_110'] 5 cluster 1: ['bill_500', 'bill_900', 'dep_510', 'dep_910'] 6scaled groups, same seed rows: 7 cluster 0: ['bill_100', 'bill_500', 'bill_900'] 8 cluster 1: ['dep_110', 'dep_510', 'dep_910']

The raw 800-character gap dwarfs the topic difference of 6: the two short messages group together, regardless of topic. With zero mean and unit variance, the loop recovers the intended topic split.

Don't standardize every embedding blindly. If a model's vectors come with a specified normalization contract, follow it. When fitting a scaler or PCA, learn its parameters on the development corpus and reuse that transform for held-out queries. As with supervised validation, fitting scalers on the evaluation set causes data leakage.

Failure test 2: let the similarity rule change neighbors

Text retrieval often compares vector directions with cosine similarity, while ordinary k-means minimizes Euclidean distance. Dot product rewards magnitude as well as angular alignment.

OpenAI's embedding models return vectors normalized to length 1.[8]Reference 8Vector embeddingshttps://developers.openai.com/api/docs/guides/embeddings Under that contract, cosine similarity equals the dot product, and squared Euclidean distance is an inverted linear transform of cosine similarity:

∥u−v∥22=∥u∥22+∥v∥22−2(u⋅v)=2−2cos⁡(u,v)\|u - v\|_2^2 = \|u\|_2^2 + \|v\|_2^2 - 2 (u \cdot v) = 2 - 2 \cos(u, v)∥u−v∥22​=∥u∥22​+∥v∥22​−2(u⋅v)=2−2cos(u,v)

Because the relation is strictly monotonic, cosine ranking and Euclidean nearest-neighbor ranking produce identical orderings for unit vectors. Mix in unnormalized metadata or switch to an unnormalized model, and that shortcut fails.

metric-changes-the-neighbor.py
1import numpy as np 2 3query = np.array([1.0, 0.0]) 4names = ["duplicate_charge_policy", "very_long_deploy_page"] 5documents = np.array([ 6 [1.0, 0.1], 7 [8.0, 4.0], 8]) 9 10dot = documents @ query 11cosine = dot / (np.linalg.norm(documents, axis=1) * np.linalg.norm(query)) 12 13print("dot-product winner:", names[int(dot.argmax())]) 14print("cosine winner: ", names[int(cosine.argmax())]) 15print("cosine scores: ", np.round(cosine, 3).tolist())
Output
1dot-product winner: very_long_deploy_page 2cosine winner: duplicate_charge_policy 3cosine scores: [0.995, 0.894]

The equivalence applies to nearest neighbors, not standard k-means centroids. Standard k-means computes the coordinate mean of assigned points. The mean of several unit vectors is generally not unit length, causing centroids to drift off the hypersphere:

mean-leaves-the-unit-sphere.py
1import numpy as np 2 3a = np.array([1.0, 0.0]) 4b = np.array([0.0, 1.0]) 5mean = (a + b) / 2 6print("mean:", np.round(mean, 3).tolist()) 7print(f"mean L2 norm: {np.linalg.norm(mean):.3f}")
Output
1mean: [0.5, 0.5] 2mean L2 norm: 0.707

If you cluster the same vectors you'll cosine-search later, align the algorithm with the serving contract:[9]Reference 9Faiss FAQ: k-means for the cosine similarityhttps://github.com/facebookresearch/faiss/wiki/FAQ#k-means-for-the-cosine-similarity

  • Run spherical k-means: Assign points by cosine similarity (maximum dot product on unit vectors), then project centroids back to the unit sphere (μk←μk/∥μk∥2\mu_k \leftarrow \mu_k / \|\mu_k\|_2μk​←μk​/∥μk​∥2​) after each update step.
  • Or treat running Euclidean k-means on normalized vectors and normalizing centroids only at inference time as an approximation. Validate held-out recall against a spherical baseline before deploying it.

K-means also favors roughly spherical clusters of comparable size. Elongated manifolds or nested topics score poorly under inertia even when they represent meaningful semantic groupings.

Failure test 3: centering changes cosine geometry

Cosine similarity measures the angle between two vectors from the origin:

cosine_similarity(u,v)=u⋅v∥u∥∥v∥\text{cosine\_similarity}(u, v) = \frac{u \cdot v}{\|u\| \|v\|}cosine_similarity(u,v)=∥u∥∥v∥u⋅v​

PCA introduces an origin trap. It centers the dataset by subtracting the column-wise mean from each vector, shifting the origin to the center of the data cloud.[5]Reference 5sklearn.decomposition.PCAhttps://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html If we then perform cosine retrieval on the centered vectors, we're measuring angles from an entirely different point.

Shifting the origin alters angles between vectors, so original cosine distances and neighbor rankings aren't preserved. Two vectors pointing in nearly identical directions from the original origin can point in opposite directions after centering.

Fit the mean on corpus documents, then apply it to the query:

cosine-centering-bias.py
1import numpy as np 2 3query = np.array([1.0, 2.0]) 4doc1 = np.array([1.1, 2.1]) # Close to query direction 5doc2 = np.array([2.0, 1.0]) # Further away in direction 6 7def cos_sim(u, v): 8 return float(np.dot(u, v) / (np.linalg.norm(u) * np.linalg.norm(v))) 9 10orig_cos1 = cos_sim(query, doc1) 11orig_cos2 = cos_sim(query, doc2) 12 13documents = np.array([doc1, doc2]) 14mean = documents.mean(axis=0) 15q_centered = query - mean 16d1_centered = doc1 - mean 17d2_centered = doc2 - mean 18 19centered_cos1 = cos_sim(q_centered, d1_centered) 20centered_cos2 = cos_sim(q_centered, d2_centered) 21 22print(f"Original cos(query, doc1): {orig_cos1:.4f}") 23print(f"Original cos(query, doc2): {orig_cos2:.4f}") 24print(f"Centered cos(query, doc1): {centered_cos1:.4f}") 25print(f"Centered cos(query, doc2): {centered_cos2:.4f}") 26 27assert orig_cos1 > orig_cos2 28assert centered_cos1 > 0 29assert centered_cos2 < 0
Output
1Original cos(query, doc1): 0.9998 2Original cos(query, doc2): 0.8000 3Centered cos(query, doc1): 0.9802 4Centered cos(query, doc2): -0.9802

After centering, doc2 sits more than 90 degrees from the query. The transformation altered the geometric question the angle answers. If you reduce dimensions for cosine retrieval, treat the compressed space as a new representation and validate retrieval metrics directly.

Failure test 4: PCA can hide the dimension your query needs

A retrieval system doesn't need a globally optimal low-rank approximation. It needs the correct document for a specific query. Suppose horizontal variation across billing and deploy policies dominates corpus variance, while the distinction between password-reset and general billing pages lies in a quieter vertical direction.

Project documents to one principal component, then execute nearest-document lookup for a login question:

compression-can-change-retrieval.py
1import numpy as np 2 3names = np.array(["billing_policy", "billing_help", "login_2fa", "deploy_policy", "runbook_note"]) 4documents = np.array([ 5 [-20.0, 0.0], 6 [ 0.0, 0.0], 7 [ 0.0, 3.0], 8 [20.0, 0.0], 9 [ 0.0, -3.0], 10]) 11query = np.array([0.0, 3.2]) 12 13full_distance = np.linalg.norm(documents - query, axis=1) 14full_winner = names[full_distance.argmin()] 15 16mean = documents.mean(axis=0) 17centered = documents - mean 18singular_values, vt = np.linalg.svd(centered, full_matrices=False)[1:] 19pc1 = vt[:1] 20compressed_documents = centered @ pc1.T 21compressed_query = (query - mean) @ pc1.T 22compressed_distance = np.linalg.norm(compressed_documents - compressed_query, axis=1) 23compressed_ties = names[np.isclose(compressed_distance, compressed_distance.min())].tolist() 24kept = float((singular_values ** 2)[0] / (singular_values ** 2).sum()) 25 26print(f"PC1 variance kept: {kept:.3f}") 27print("full-space nearest: ", full_winner) 28print("one-PC nearest ties: ", compressed_ties)
Output
1PC1 variance kept: 0.978 2full-space nearest: login_2fa 3one-PC nearest ties: ['billing_help', 'login_2fa', 'runbook_note']

After projection, billing_help, login_2fa, and runbook_note collapse to the exact same one-dimensional coordinate. PCA eliminated the quieter vertical dimension that separated the correct login document from tied alternatives. An argmin call now returns whichever candidate appears first in storage.

Reconstruction error and query relevance answer different questions. Measure retrieval metrics (like Recall@K and NDCG) on labeled query sets before deploying compressed embeddings.

Turn every plot into a representation audit

An unsupervised plot earns its keep when it generates testable hypotheses. Before claiming that an embedding space is organized, compile an audit record:

Audit questionEvidence to collect
Do local neighbors discuss the same issue?Read nearest-message pairs sampled across the corpus.
Do proposed clusters have coherent content?Review messages nearest each centroid and near boundaries.
Is a large axis merely formatting or length?Compare PCA coordinates against metadata such as length and source.
Does an embedding metric match later search?Run cosine or dot-product checks according to model contract.
Can compression hurt a rare but important query?Evaluate retrieval before and after PCA on held-out query-document pairs.

Use reviewed examples as an audit set. If you repeatedly tune your pipeline after inspecting them, treat them as development data, not an untouched evaluation benchmark.

write-a-cluster-audit-summary.py
1import numpy as np 2 3names = np.array(["billing_A", "billing_B", "login_A", "login_B", "deploy_A", "deploy_B"]) 4reviewed_topic = np.array(["billing", "billing", "login", "login", "deploy", "deploy"]) 5vectors = np.array([ 6 [1.0, 1.0], [1.2, 1.8], 7 [4.0, 4.2], [5.0, 3.8], 8 [8.0, 1.0], [9.0, 1.8], 9]) 10 11squared = ((vectors[:, None, :] - vectors[[0, 2, 4]][None, :, :]) ** 2).sum(axis=2) 12labels = squared.argmin(axis=1) 13centers = np.vstack([vectors[labels == index].mean(axis=0) for index in range(3)]) 14labels = ((vectors[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2).argmin(axis=1) 15 16for cluster in range(3): 17 topics, counts = np.unique(reviewed_topic[labels == cluster], return_counts=True) 18 dominant = topics[counts.argmax()] 19 purity = counts.max() / counts.sum() 20 members = names[labels == cluster].tolist() 21 print(f"cluster {cluster}: proposed={dominant:8} purity={purity:.2f} members={members}")
Output
1cluster 0: proposed=billing purity=1.00 members=['billing_A', 'billing_B'] 2cluster 1: proposed=login purity=1.00 members=['login_A', 'login_B'] 3cluster 2: proposed=deploy purity=1.00 members=['deploy_A', 'deploy_B']

On this teaching set, purity is 1.0 because the coordinates were designed to be clean. A production audit must evaluate boundary edge cases, multilingual queries, and ambiguous intents.

Practice: try to break the representation

Use the clean six-message run as a baseline, then perturb one decision at a time:

  1. Empty center repair. Modify the initial seeds in kmeans-from-scratch.py so one cluster empties. Implement a reseed policy that restarts the orphaned centroid at the point having the highest current squared distance from any center.
  2. K-means++ restart test. Implement the full k-means++ selection loop. Compare the variance of final inertia across 50 random restarts between uniform random seeding and k-means++ seeding.
  3. Competing units. Add a metadata column (like character count) with values in [100,1000][100, 1000][100,1000]. Compare cluster assignments before and after z-score standardization.
  4. IVF recall boundary. In ivf-coarse-quantization-search.py, move the query point to [3.0,3.0][3.0, 3.0][3.0,3.0] (equidistant between billing and login). Measure whether nprobe=1nprobe=1nprobe=1 misses the true nearest neighbor and check if nprobe=2nprobe=2nprobe=2 recovers it.
  5. Lost query signal. Retain both principal components in compression-can-change-retrieval.py. Confirm that the login query recovers login_2fa, and explain why high explained variance didn't prevent the rank-1 tie.

What to look for

  • An empty mean(axis=0) produces an invalid center. Detect members.shape[0] == 0 and trigger an explicit reseed rather than propagating NaN.
  • K-means++ yields consistently lower inertia and faster convergence across restarts by avoiding bunched seeds.
  • When unscaled metadata dominates distance, clustering reflects units rather than semantics. Verify feature normalization before interpreting clusters.
  • On Voronoi boundaries, setting nprobe=1nprobe=1nprobe=1 causes recall drops. Production vector indexes calibrate nprobenprobenprobe against target Recall@K.
  • PCA preserves global variance, not task labels. Evaluating downstream retrieval metrics on held-out pairs is mandatory before deploying compressed representations.
Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.In Lloyd's algorithm, why does the centroid update step replace each cluster center with the arithmetic mean of its assigned points?

Correct answer: The arithmetic mean is the exact analytical minimizer of the within-cluster sum of squared Euclidean distances.

Setting the gradient of the within-cluster sum of squares objective J with respect to centroid mu_k to zero yields -2 sum (x_i - mu_k) = 0, which solves directly to the coordinate-wise arithmetic mean. It's the analytical minimizer of squared Euclidean distance, not an arbitrary heuristic.

2.How does k-means++ initialization select the second centroid after choosing the first centroid uniformly at random?

Correct answer: It samples candidate points with probability proportional to their squared distance D(x)^2 to the first centroid.

K-means++ computes the distance D(x) from each point to the closest already-selected centroid, then samples the next seed with probability proportional to D(x)^2. This spreads initial seeds across distant clusters and avoids placing multiple seeds in the same local neighborhood.

3.A six-message audit reports these k-means results: k=2 has inertia 20.06 and silhouette 0.56, k=3 has inertia 1.74 and silhouette 0.76, and k=4 has inertia 0.92 and silhouette 0.51. What conclusion follows for this corpus?

Correct answer: Treat k=3 as the strongest candidate, then inspect sampled messages because the metrics judge geometry rather than topic meaning.

Inertia naturally drops as k increases, so lowest inertia alone isn't decisive. Here k=3 forms an elbow with a sharp inertia drop and achieves the highest silhouette score. Inspecting sampled messages is still necessary because geometric metrics don't assign semantic business meaning.

4.Why do modern ML frameworks compute PCA via Singular Value Decomposition (SVD) on centered data X_c rather than eigendecomposition of the covariance matrix X_c^T X_c?

Correct answer: Forming X_c^T X_c squares the condition number, causing numerical instability, and allocating d x d memory is wasteful when n << d.

Explicitly computing X_c^T X_c squares condition numbers (kappa(X^T X) = kappa(X)^2), leading to loss of precision for small singular values. In high dimensions with fewer samples (n << d), allocating a d x d covariance matrix is computationally inefficient; truncated SVD solves the problem in O(n * d * k) without materializing the covariance matrix.

5.In an Inverted File Index (IVF), what is the role of k-means clustering and how does the nprobe parameter affect retrieval?

Correct answer: K-means clusters vectors into coarse Voronoi cells; nprobe sets how many centroid posting lists are scanned during search.

IVF uses k-means as a coarse quantizer to partition the vector space into Voronoi cells, storing document vectors in inverted posting lists per centroid. At query time, the engine compares the query to coarse centroids and searches only documents inside the nprobe closest centroid lists, trading latency against recall.

6.A retrieval diagnostic keeps one PCA component with 0.978 explained variance. In full space, a login query is nearest to login_2fa, but after projection billing_help, login_2fa, and runbook_note tie at the same one-dimensional coordinate. What should the team conclude?

Correct answer: Projection lost a dimension needed by this query; test retrieval metrics or keep more components.

Explained variance measures overall linear spread across the entire dataset. It doesn't guarantee that a low-variance direction is unimportant for a specific query. In this failure case, the quieter vertical separation between login_2fa and the other documents was discarded by 1D PCA.

7.A feature matrix combines a topic coordinate near -3 for billing and +3 for deploy messages with a message-length column ranging from 100 to 900 characters. Raw Euclidean k-means groups mostly by length, while standardizing each column recovers the topic split. What caused the raw failure?

Correct answer: Raw distances followed length because its scale dominated the topic coordinate.

K-means computes squared Euclidean distances in the feature space it receives. A large-unit metadata column spans hundreds of units, completely swamping a topic separation of 6 units. Normalizing features balances coordinate contributions.

8.A query vector is (1.0, 0.0). Two document vectors are duplicate_charge_policy = (1.0, 0.1) and very_long_deploy_page = (8.0, 4.0). Dot product ranks very_long_deploy_page higher, while cosine ranks duplicate_charge_policy higher. What explains the disagreement?

Correct answer: Dot product favors both alignment and vector norm; cosine normalizes norms and compares direction.

The deploy vector has a much larger magnitude, producing a larger dot product with the query despite being angled farther away. The billing document points almost directly along the query vector, giving it higher cosine similarity.

9.PCA subtracts the column-wise mean before SVD. A query and two documents had cosine similarities 0.9998 and 0.8000 from the original origin; after centering, the second document's cosine with the query is -0.9802. What should a cosine retrieval team do?

Correct answer: Re-validate held-out cosine rankings in the reduced space, because shifting the origin changes angles and neighbor rankings.

Cosine similarity measures angles from the coordinate origin. PCA's centering step moves the origin to the data mean, which alters angular relationships across vectors. Reduced coordinates form a new representation that requires separate retrieval evaluation.

10.A dashboard shows k-means colors on a 2D PCA scatter of 1536-dimensional message embeddings. Where should the team run k-means if the goal is discovering topic neighborhoods for search?

Correct answer: On the original 1536-dimensional vectors, because projecting to 2D discards high-dimensional neighbor structure.

PCA is a visualization lens that discards residual dimensions. Running k-means on 2D coordinates groups the compressed visualization rather than the full embedding space that search will query. Cluster in the native embedding space and project to 2D only for visual inspection.

10 questions remaining.

Next Step
Continue to Probabilistic Generative Models

You can now partition unlabeled vectors with Lloyd's algorithm, evaluate cluster geometry with silhouette scores, and inspect compressed subspaces with PCA. Next you'll model how latent Gaussian distributions generate observations, replacing hard cluster assignments with expectation-maximization soft responsibilities.

PreviousConformal Prediction and Uncertainty
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

The Elements of Statistical Learning.

Hastie, T., Tibshirani, R., Friedman, J. · 2009

https://hastie.su.domains/ElemStatLearn/

Pattern Recognition and Machine Learning.

Bishop, C. M. · 2006

https://www.microsoft.com/en-us/research/publication/pattern-recognition-machine-learning/

Clustering

scikit-learn Developers · 2026

https://scikit-learn.org/stable/modules/clustering.html

silhouette_score

scikit-learn Developers · 2026

https://scikit-learn.org/stable/modules/generated/sklearn.metrics.silhouette_score.html

sklearn.decomposition.PCA

Scikit-learn Developers · 2026 · Official documentation

https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html

Billion-scale similarity search with GPUs.

Johnson, J., Douze, M., & Jégou, H. · 2019 · IEEE Transactions on Big Data

https://arxiv.org/abs/1702.08734

Faiss indexes

Meta, Faiss Contributors · 2026

https://github.com/facebookresearch/faiss/wiki/Faiss-indexes

Vector embeddings

OpenAI · 2024

https://developers.openai.com/api/docs/guides/embeddings

Faiss FAQ: k-means for the cosine similarity

Meta, Faiss Contributors · 2026

https://github.com/facebookresearch/faiss/wiki/FAQ#k-means-for-the-cosine-similarity

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.