diff --git a/.changeset/kmeans-cluster-sizes.md b/.changeset/kmeans-cluster-sizes.md new file mode 100644 index 00000000..474b64f9 --- /dev/null +++ b/.changeset/kmeans-cluster-sizes.md @@ -0,0 +1,5 @@ +--- +"chroma-js": patch +--- + +Fix `chroma.limits(data, 'k', n)` (k-means) dropping clusters. The cluster tally was incremented inside the nearest-centroid loop, so each point was counted up to `n` times against intermediate best-so-far indices, corrupting the centroid means. It is now counted once per point after the argmin, so well-separated data yields the expected breaks (`chroma.limits([0,1,2,50,51,52,100,101,102], 'k', 3)` returns `[0, 2, 52, 102]`). diff --git a/src/utils/analyze.js b/src/utils/analyze.js index ef0d25ff..581595f1 100644 --- a/src/utils/analyze.js +++ b/src/utils/analyze.js @@ -124,9 +124,11 @@ export function limits(data, mode = 'equal', num = 7) { mindist = dist; best = j; } - clusterSizes[best]++; - assignments[i] = best; } + // tally the winning cluster once per point, after the argmin + // over j; incrementing inside the loop corrupted clusterSizes + clusterSizes[best]++; + assignments[i] = best; } // update centroids step diff --git a/test/limits.test.js b/test/limits.test.js index 38c1428c..89627030 100644 --- a/test/limits.test.js +++ b/test/limits.test.js @@ -54,4 +54,10 @@ describe('Some tests for chroma.limits()', () => { it('logarithmic domain - non-positive values', () => { expect(() => limits([-1, 10000], 'log', 4)).toThrow('Logarithmic scales are only possible for values > 0'); }); + + it('k-means classifies all requested clusters', () => { + // three well-separated clusters must yield three class breaks; a + // corrupted cluster-size tally previously dropped the middle cluster + expect(limits([0, 1, 2, 50, 51, 52, 100, 101, 102], 'k', 3)).toEqual([0, 2, 52, 102]); + }); });