Balanced K-Means Clusterer
Procedure: balanced k-means
Balanced k-means adds a size constraint to the plain algorithm — clusters are kept to roughly equal (or capped) membership rather than whatever size the natural geometry produces, which matters when a downstream process (for example, a review queue assigned by cluster) cannot tolerate one cluster swamping the others.
No common CRAN package implements size-constrained k-means either. Plain k-means below is the nearest honest R analogue, but — unlike the product’s actual behaviour — it enforces no size constraint at all, so its cluster sizes here are only balanced by coincidence of the synthetic data’s symmetric design.
set.seed(1)
n <- 90
x <- c(rnorm(n / 3, 10, 2), rnorm(n / 3, 40, 2), rnorm(n / 3, 70, 2))
y <- c(rnorm(n / 3, 10, 2), rnorm(n / 3, 45, 2), rnorm(n / 3, 15, 2))
merchants <- scale(data.frame(x, y))
model <- kmeans(merchants, centers = 3, nstart = 5, iter.max = 100)
print(model$centers)
print(model$size)
Console output:
x y
1 1.213805572 -0.5179427
2 -1.215769357 -0.8631565
3 0.001963785 1.3810992
[1] 30 30 30
Tool equivalent:
Jube.Service.Repository.AnalyticsTrainingRun.AdvancedAnalyticsBalancedKMeansClustererTrainingService / tool
SubmitBalancedKMeansClustererTraining — Kind = Clusterer, recall class BalancedKMeansClustererRecall. The DTO’s
Clusters, Seeding, MaximumIterations, Tolerance and Standardise are the same shape as plain k-means, but the
background worker’s fitting loop actually enforces the balance constraint the R snippet above cannot demonstrate — this
is a genuine “R/script-runner has no equivalent behaviour” case, not merely a missing package.
Code audit
SubmitTrainingAsync
(Jube.Service/Repository/AnalyticsTrainingRun/AdvancedAnalyticsBalancedKMeansClustererTrainingService.cs:76-143)
only validates the request and inserts a queued AdvancedAnalyticsTrainingRun row; it does no training itself.
AdvancedAnalyticsTrainingWorker constructs a BalancedKMeansClustererTrainingExecutor
(Jube.Engine/AdvancedAnalytics/Training/AdvancedAnalyticsTrainingWorker.cs:542-543) and calls its
ExecuteAsync (line 552), whose Trainer delegate defaults to BalancedKMeansClustererTrainer.Train
(Jube.Engine/AdvancedAnalytics/Training/BalancedKMeansClustererTrainingExecutor.cs:59-61). That trainer
(Jube.Engine/AdvancedAnalytics/Clustering/BalancedKMeansClustererTrainer.cs:156-172) instantiates
Accord.MachineLearning.BalancedKMeans (Accord.MachineLearning/Clustering/KMeans/BalancedKMeans.cs) and calls
Learn.
Balance is a hard constraint in the assignment step, not a post-hoc rebalancing pass
BalancedKMeans.Learn (Accord.MachineLearning/Clustering/KMeans/BalancedKMeans.cs:126-287) builds, on every
iteration, an x.Length × x.Length cost matrix (munkres = new Munkres(x.Length, x.Length) at line 195) and solves
it with the Munkres/Hungarian algorithm (munkres.Minimize(), line 211) — an exact bipartite perfect-matching solver,
not a heuristic. GetDistances (lines 305-317) fills row i, column j
of that matrix with the distance from data point j to centroid GetIndex(k, i) where
GetIndex(clusters, index) => (index + 1) % clusters (lines 319-325, citing Malinen’s thesis equation 6.6 in the
comment directly above). Because there are x.Length matrix rows but only k distinct centroid identities, the
(i + 1) % k mapping cycles every row through a fixed, pre-determined cluster label before the optimiser ever runs, so
the x.Length row “slots” are split into k groups of size
floor(x.Length / k) or ceil(x.Length / k) before matching starts. Solving the assignment problem only decides
which data point goes into which already-size-fixed slot, minimising total distance subject to that fixed slot
distribution (GetLabels, lines 289-303, reads the cluster identity for point j off the matched row i’s
GetIndex(clusters, i)). This is a hard, structural balance guarantee — cluster sizes can differ by at most one —
enforced inside every assignment step, not a heuristic that runs plain k-means and then reshuffles points afterwards to
even out sizes.
Initialisation and iteration
If Clusters.Centroids[0] is still unset, BalancedKMeans.Learn first runs plain KMeans.Learn on the same data to
seed the centroids (lines 178-181); Jube’s BalancedKMeansClustererTrainer sets UseSeeding
from the DTO’s Seeding field before this call (BalancedKMeansClustererTrainer.cs:160), so that initial plain-k-means
pass uses either uniform or k-means++ seeding as requested. After that, the algorithm alternates the Munkres assignment
above with ordinary centroid recomputation (mean of each cluster’s assigned points, lines 242-256) until
converged(centroids, newCentroids) reports the centroids have stopped moving by more than Tolerance (line 260).
MaxIterations is validated only as “must be greater than zero” (BalancedKMeans.cs:129-133) — if it is zero the
constructor throws rather than iterating unboundedly.
Warning the product itself records about this family
BalancedKMeansClustererTrainer.cs:179-182 adds a fixed IterationCountUnavailable warning to every run:
“The vendored balanced k-means implementation does not report how many assign-then-recentre iterations it took, and does
not enforce the maximum iterations as a hard stop; it only stops once the centroids settle within the tolerance.” This
is accurate to the Learn loop above — Iterations is set to 0 and never incremented anywhere in
BalancedKMeans.Learn, and the while (!shouldStop) loop (line 200) has no iteration-count exit condition at all, only
the converged check.
Row-count ceiling
Both BalancedKMeansClustererTrainer.ValidateData and TrainWithSplit reject a training split larger than
MaximumTrainingRows = 2000 (BalancedKMeansClustererTrainer.cs:34, 86-90, 471-475), with the stated reason that
“Balanced k-means solves an assignment problem whose cost grows with the cube of the row count” — the Munkres/Hungarian
algorithm is O(n^3) in the matrix dimension, and here the matrix dimension is the row count itself
(munkres = new Munkres(x.Length, x.Length)), so this cap is a direct, correctly-reasoned consequence of the
assignment-problem formulation, not an arbitrary limit.
