Skip to content

fix: count k-means cluster sizes once per point - #397

Open
spokodev wants to merge 1 commit into
gka:mainfrom
spokodev:fix/kmeans-cluster-sizes
Open

fix: count k-means cluster sizes once per point#397
spokodev wants to merge 1 commit into
gka:mainfrom
spokodev:fix/kmeans-cluster-sizes

Conversation

@spokodev

Copy link
Copy Markdown

What

chroma.limits(data, 'k', n) (k-means classification) computes wrong class breaks:

chroma.limits([0, 1, 2, 50, 51, 52, 100, 101, 102], 'k', 3)
// actual:   [0, 2, 102]      -> only two non-empty classes; {50,51,52} is dropped
// expected: [0, 2, 52, 102]  -> the three natural clusters

In the assignment step, clusterSizes[best]++ and assignments[i] = best are inside the inner nearest-centroid loop (for (let j = 0; j < num; j++)), so for each point the tally is incremented up to num times using the intermediate best-so-far index. The centroid-update step then does newCentroids[j] *= 1 / clusterSizes[j], dividing each cluster's value-sum by a corrupted count, so the "centroids" are not the cluster means and k-means converges to wrong clusters. Across 200 random datasets the output differs from correct 1-D k-means on ~168.

Fix

Move the tally and assignment out of the inner loop, so the winning cluster is counted exactly once per point after the argmin over j. newCentroids[j] = sum / clusterSizes[j] is then the true cluster mean.

Tests

Added a limits() case asserting three well-separated clusters yield [0, 2, 52, 102]. Fails on main, passes with the fix. Full suite green (2520/2520), prettier + eslint clean.

In the k-means path of `limits()`, `clusterSizes[best]++` and
`assignments[i] = best` sat inside the inner nearest-centroid loop
(`for j`), so for every point the tally was incremented up to `num` times
using the intermediate best-so-far index. The centroid update then divides
each cluster's value-sum by that corrupted count
(`newCentroids[j] *= 1 / clusterSizes[j]`), so the "centroids" are not the
cluster means and k-means converges to wrong breaks:

  chroma.limits([0,1,2, 50,51,52, 100,101,102], 'k', 3)
  // [0, 2, 102]  -> the middle cluster {50,51,52} is dropped
  // expected [0, 2, 52, 102]

Move the tally and assignment out of the inner loop so the winning cluster
is counted exactly once per point, after the argmin over j.
@spokodev
spokodev requested a review from gka as a code owner July 23, 2026 12:55
@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 8daa656

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant