K-means is one of the most classic unsupervised learning algorithms in machine learning. It relies on no human labels, and instead divides data into clusters automatically based on the distances between samples.
This article works directly from two real files:
Iris.csv: the classic iris datasetIris_sort_K_mean.c: a C implementation of K-means you can compile and run directly
The focus here is not only on getting the program to run, but on making these points clear:
- Why K-means is designed the way it is
- What each function in the code is actually responsible for
- Why standardisation, initialisation and repeated restarts directly affect the result
- How to turn the final clustering result into a readable conclusion
1. What K-means is
The goal of K-means is:
Given a dataset and a cluster count
K, divide every sample intoKgroups so that samples within a group are as close together as possible.
It repeats two things continuously:
- Assign each sample to the nearest cluster centre
- Recompute the cluster centres from the new grouping
It is easier to follow if you think of it as a process of continuous correction:
Guess a few centres, let the samples find their nearest centre; once the samples are assigned, move each centre to the average position of its members.
2. Why the Iris dataset suits learning clustering
Iris.csv holds 150 samples. Each sample carries 4 numeric features:
- sepal length
SepalLengthCm - sepal width
SepalWidthCm - petal length
PetalLengthCm - petal width
PetalWidthCm
It also carries true labels:
Iris-setosaIris-versicolorIris-virginica
One key point deserves emphasis: K-means does not use these labels while clustering. It sees only the numeric features and the distances between samples. The labels are kept purely to interpret the result after clustering finishes.
That makes this example particularly good for learning two things:
- how an algorithm groups data with no labels available
- how far the resulting clusters sit from the true classification
3. What this C program does overall
Iris_sort_K_mean.c is not a minimal version consisting of one for loop and a distance function; it splits the full K-means execution path into several readable functions. The overall flow is:
- Read the CSV and build the sample array
- Standardise every feature
- Initialise the cluster centres with K-means++
- Repeat “assign samples / update centres”
- Restart randomly many times and keep the round with the smallest SSE
- Print the cluster distribution, the label distribution and each sample’s cluster
Iris_sort_K_mean.c: load the data, then standardise, initialise, iterate, compute SSE, and finally keep the best clustering result.4. How samples are represented in the program
Each sample is stored in a Sample struct:
typedef struct {
int id;
double x[FEATURES];
double x_scaled[FEATURES];
char species[32];
int cluster;
} Sample;
Every field has a clear responsibility:
id: sample number, matching the first CSV columnx: the raw 4-dimensional featuresx_scaled: the standardised 4-dimensional featuresspecies: the true species labelcluster: the cluster number the program finally assigns
The benefit of this design is that raw data, preprocessed data, true labels and clustering results are all stored separately, so when reading the code later you never confuse the values used for computation with the values used for display.
5. Step one: reading the CSV data
The current download checks the fixed header in load_iris(), splits six unquoted fields in parse_sample(), and validates the ID and four finite numbers with strtol() and strtod(). Malformed rows, duplicate IDs, unknown species and more than 150 rows cause an error rather than silently skipping or truncating data.
You can think of this function as a text-to-struct process:
- the file holds one line of comma-separated text
- after reading it becomes one
Sample - all samples end up in the array
data[]
The entry point accepts a data file path from the command line:
const char *filename = (argc > 1) ? argv[1] : "Iris.csv";
So you can run it directly:
./iris_kmeans Iris.csv
A replacement must keep the same column order and species names, with K to 150 rows. This is not a general CSV importer: quoted commas, multiline fields and automatic missing-value imputation are unsupported.
6. Step two: how standardisation changes distance
The core of K-means is comparing distances. If the numeric scales of different features vary widely, the feature with larger values carries more weight in the Euclidean distance.
An intuitive example: if one dimension routinely varies by 5 units while another usually varies by only 0.2 units, the first will look “more important” in the distance calculation. That does not necessarily match what we intended.
So the program runs standardize_features() before clustering, using the standard score:
x_scaled = (x - mean) / std
Internally that function does three things:
- compute the mean
meanof each feature column - compute the standard deviation
stdof each feature column - convert each sample’s raw values into standardised values
Variance is divided by n (ddof=0). Nonconstant columns have approximately zero mean and unit standard deviation; constant columns use a divisor of 1 and become zero. Values are not restricted to [-1,1]. Each column receives a new squared-distance weight of 1/std².
7. Step three: why initialisation cannot be arbitrary
If K-means simply picks 3 random samples as centres, the algorithm still runs, but the result is usually unstable. The reasons are:
- the initial centres may sit too close together
- some regions may have no centre covering them at the start
- the algorithm converges early to a poor local solution
To reduce this problem, the program uses the K-means++ idea inside init_centroids():
- pick one centre at random first
- draw the later centres preferentially from samples further away from the existing centres
The initial centres then tend to be more spread out, closer to the intuition that each cluster should claim a position first.
Sampling weights use the minimum squared distance D² to an existing centre: D²/sum(D²), not D. The current implementation uses a [0,1) draw, a strictly greater cumulative threshold and skips zero weights, avoiding reselection of an occupied centre when the draw is exactly zero. C rand() does not guarantee identical sequences across runtimes or global optimality.
8. Step four: how samples get assigned to clusters
assign_clusters() does the single most central thing in K-means: for each sample, compute its distance to every centre and assign it to the nearest one.
The distance function it calls is:
double distance_sq(const double a[], const double b[])
Note that this computes the squared Euclidean distance, not the true distance after taking a square root. That is entirely reasonable, because:
- deciding which is nearer only requires comparing the squared values
- skipping the square root avoids some pointless computation
The return value has another key role: it tells the main loop whether any sample changed cluster this round. If one did, the algorithm has not converged; if none did, the current result is stable.
9. Step five: why the centres are recomputed
Once samples have been regrouped, the old centre positions are usually no longer reasonable. The new cluster members are now fixed, so the centre should move to their average position.
update_centroids() does exactly that:
- collect which samples belong to each cluster
- sum the 4 features across all samples in each cluster
- divide by the number of samples in that cluster to get the new centre coordinates
This is what “means” in the name refers to: each cluster is represented by the mean centre of the samples inside it.
An empty cluster retains its old centre; it is not reseeded. K=3 therefore does not guarantee three populated groups. Three identical coordinate vectors all enter cluster 0 under the tie rule, giving SSE=0 and two empty clusters, not evidence of three meaningful populations.
10. Step six: when the algorithm stops
The program sets two stopping conditions:
- if no sample changed cluster this round, it has converged and can stop
- if the iteration count reaches
MAX_ITER, it must also stop, to prevent an infinite loop in extreme cases
#define MAX_ITER 1000
The current version prints converged=yes/no. Reaching the limit is not convergence: updated centres are member means, but members need not still have the nearest centre. Executed steps are counted separately; MAX_ITER=1 reports one step and nonconvergence, rather than an incorrect two steps.
11. Why it runs 2000 times
Even with K-means++, the result is still affected by random initialisation. So the program does not run once; it defines:
#define RESTARTS 2000
The program makes 2000 initialisations along one pseudorandom sequence and keeps the smallest SSE. This is an example search budget, not a minimum requirement for Iris. More restarts do not prove global optimality.
SSE means:
the sum of squared distances from every sample to the centre it belongs to.
Lower SSE improves the same objective only with fixed samples, K and scaling. Raw-centimetre and standardised SSE values cannot be directly ranked. Changing K or the dataset also changes the comparison conditions.
12. What the program prints
When Iris_sort_K_mean.c finishes, it prints three kinds of information:
- the iteration count and SSE of the best result
- the cluster centres in standardised space
- the sample count and true label distribution of each cluster
This excerpt comes from running the current download with ./iris_kmeans Iris.csv 42 standard on macOS with Apple clang 21.0.0. Complete output and all 150 assignments are in reference/, rather than separately invented example numbers:
K-means 重启次数: 2000
最佳结果迭代次数: 5
最佳 SSE: 140.96581663074701
Cluster 0: total=53, setosa=0, versicolor=39, virginica=14
Cluster 1: total=47, setosa=0, versicolor=11, virginica=36
Cluster 2: total=50, setosa=50, versicolor=0, virginica=0
The Chinese headings report restarts, iterations of the selected run, and SSE. A cluster number is an arbitrary identifier, not a species code.
This result shows that:
setosacan be separated out almost cleanlyversicolorandvirginicaoverlap to some degree
This is the most classic phenomenon in the Iris dataset: setosa is highly separable, while the other two classes overlap more easily in feature space.
13. Plotting the result makes it clearer
Numbers alone are not intuitive enough. To make the cluster boundaries easier to understand, the samples are projected onto a two-dimensional plane with PetalLengthCm on the horizontal axis and PetalWidthCm on the vertical axis, coloured by K-means cluster number.
The plot shows three things quickly:
- the clearly separated small cluster corresponds to
setosa - the other two groups are broadly separable, but their boundaries overlap
- K-means handles roughly spherical, well-separated clusters best, and has no magic correction for overlapping regions
In other words, K-means learns a distance structure, not the definition of a species. That is why clustering results do not necessarily match the true classification.
14. The parameters most worth changing yourself
If you download this program, three parameters are most worth experimenting with:
K: the cluster count, currently set to 3RESTARTS: the restart count; larger is more stable but takes longerMAX_ITER: the maximum iterations of a single run
A few experiments to try:
- change
Kto 2 or 4 and see how the cluster structure changes - reduce
RESTARTSand observe whether SSE fluctuates more - hold the seed fixed and compare
standardwithrawwithout assuming which must agree better with labels
Experiments like these are far more useful than memorising definitions, because you actually see how algorithm parameters and data preprocessing change the outcome.
15. What the download area provides
The download area now holds a complete set of study materials:
Iris.csv: the raw datasetIris_sort_K_mean.c: the tidied C implementationiris-kmeans-flowchart.svg: the flowchartiris-kmeans-cluster-visual.svg: the clustering visualisation- Download the verified experiment package: source, original CSV, checks, dependency versions and full results
16. How to compile and run it
On macOS or Linux you can compile it directly:
cc -std=c11 -O2 -Wall -Wextra -Werror -pedantic Iris_sort_K_mean.c -lm -o iris_kmeans
./iris_kmeans Iris.csv
If the data file sits in the same directory as the program, you can also just run:
./iris_kmeans
The default reads Iris.csv and prints a time-based seed. Reproduce with ./iris_kmeans Iris.csv 42 standard, or use ./iris_kmeans Iris.csv 42 raw for the unscaled comparison. Record input order, compiler and C runtime; a fixed seed does not promise identical cross-platform output.
17. What this example is really worth learning
Treating this article as algorithm study material, what is worth taking away is not any single line of C, but these ideas:
- K-means is fundamentally “assign to the nearest centre, update the centre to the mean”
- distance-based algorithms depend heavily on data preprocessing, and standardisation is critical
- K-means++ noticeably improves initialisation quality
- clustering algorithms with randomness usually need multiple restarts before choosing the best
- the final result must be interpreted alongside the output distribution and the visualisation
18. Experiment audit table
K-means output can easily look finished, but unsupervised learning needs its experimental conditions recorded even more carefully. The table below is for re-checking whether this Iris clustering experiment genuinely explains its result, rather than only presenting an attractive scatter plot.
| Audit item | What this experiment does | Why it affects the conclusion |
|---|---|---|
| Feature scaling | Standardises all 4 numeric features. | Scaling changes distance weights. SSE values in different spaces are not directly ranked; retaining raw units depends on the task. |
| Initialisation | Uses K-means++ to pick more widely spread initial centres. | Initial centres that sit too close lead to poor local solutions, changing both SSE and the cluster distribution. |
| Random restarts | RESTARTS = 2000, keeping the result with the smallest SSE. |
A single run cannot represent stable behaviour; multiple restarts reduce the role of chance. |
| Comparison against true labels | species is not used during clustering, only counted at output time. |
This separates two questions: unsupervised cluster structure, and true species classification. |
| Two-dimensional visualisation | Projects a scatter plot using petal length and petal width. | The plot shows only two dimensions and cannot replace the SSE explanation in the full four-dimensional space. |
| Failure modes | States that versicolor and virginica overlap. |
Clustering cannot magically recover labels; overlapping regions need explaining through distributions and samples. |
19. Why your SSE may differ from another Iris tutorial
Check the data before increasing restarts. The site’s CSV has SHA-256 600ac44f23c2e6e0ae37daac8ceb2baba4df963efa580eb31b3b576b28e34c55; its bytes are preserved. An element-by-element comparison with scikit-learn 1.7.2 load_iris() finds only these two differing records:
| Record Id | Site CSV: four features | scikit-learn 1.7.2 |
|---|---|---|
| 35 | 4.9, 3.1, 1.5, 0.1 | 4.9, 3.1, 1.5, 0.2 |
| 38 | 4.9, 3.1, 1.5, 0.1 | 4.9, 3.6, 1.4, 0.1 |
The UCI Iris documentation also identifies these historical differences. Ids are one-based and exclude the header: the name “Iris” does not establish identical input. The check exports the scikit-learn version into a temporary directory without overwriting the original CSV.
| Input and distance space | SSE / best one-to-one label match | ARI |
|---|---|---|
| Site CSV, standard | 140.965817 / 125/150 | 0.620135 |
| scikit-learn, standard | 139.820496 / 125/150 | 0.620135 |
| Site CSV, raw | 78.940841 / 134/150 | 0.730238 |
| scikit-learn, raw | 78.851441 / 134/150 | 0.730238 |
All four runs use the same C source, seed=42, K=3 and 2000 restarts. The data version changes SSE even though label-agreement summaries are unchanged here. ARI does not depend on cluster numbering. The matched counts enumerate all 3! cluster-to-species mappings; they are not held-out classification accuracy. All 150 samples participate in clustering. Labels describe the result afterwards and do not select centres or the winning restart. Better agreement with raw distances is this experiment’s observation, not a universal preprocessing rule.
Verify individual claims instead of trusting a success message
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
python audit_iris.py --output my-audit
The reference environment uses Python 3.13.9, NumPy 2.3.5 and scikit-learn 1.7.2. The check compiles the shipped C file, parses every assignment and full-precision centre, and uses NumPy to recalculate scaling, means, nearest centres, SSE and the contingency table. It does not substitute another C solver. Rotating only species labels leaves assignments unchanged for fixed coordinates and seed.
reference/download-standard-assignments.csvprovides 150 traceable assignments;reference/audit.jsonrecords hashes, environment, four runs and check scope.- Twelve malformed CSV cases and four invalid seeds must fail. The previous program accepted NaN, printed
SSE: nanand returned exit status 0; the current loader rejects it. - Constant columns, duplicate coordinates and empty clusters do not produce NaN.
MAX_ITER=1reports nonconvergence; K=2 and K=4 preserve assignment/centre invariants. - The full Iris run passes AddressSanitizer and UndefinedBehaviorSanitizer. A missing compatible compiler or sanitizer produces a failure, not a successful verification.
These checks cover the teaching dataset and listed boundaries, not global optimality for arbitrary data, identical random trajectories across platforms, or production robustness. See the K-means documentation for algorithm background and load_iris 1.7 for the dataset interface.
20. Summary
Working from Iris.csv and Iris_sort_K_mean.c together, the complete K-means flow becomes very clear:
- read the data
- standardise it
- initialise the centres with K-means++
- repeat “assign samples / update centres” until convergence
- run multiple restarts and select the result with the smallest SSE
For a beginner, the greatest value of this example is that it keeps the core logic of the algorithm while staying close enough to a real program. You do not only understand the concept; you see how the concept turns into code and results.
If you are just starting with unsupervised learning, I would strongly suggest compiling it once yourself, changing a few parameters, and reading the flowchart and the scatter plot alongside the output. That builds real intuition far better than reading the algorithm definition once.