diff --git a/src/main/java/com/thealgorithms/machinelearning/Clustering.java b/src/main/java/com/thealgorithms/machinelearning/Clustering.java new file mode 100644 index 000000000000..6d9eacda775e --- /dev/null +++ b/src/main/java/com/thealgorithms/machinelearning/Clustering.java @@ -0,0 +1,304 @@ +package com.thealgorithms.machinelearning; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Random; + +public final class Clustering { + + private Clustering() { + + } + /** + * Runs K-Means using explicit, caller-supplied initial centroids. Deterministic — the + * recommended entry point for reproducible results and tests. + * + * @param points the dataset to cluster; non-empty, consistent dimensionality + * @param initialCentroids exactly {@code k} initial centroids, matching {@code points}' + * dimensionality + * @param maxIterations maximum number of iterations; must be positive + * @param tolerance convergence tolerance on center movement; must be non-negative + * @return the clustering result + */ + + public static ClusteringResult kMeans(double[][] points, double[][] initialCentroids, int maxIterations, double tolerance) { + validateParameters(maxIterations, tolerance); + double[][] centroids = validateAndCopyCentroids(points, initialCentroids); + return run(points, centroids, maxIterations, tolerance, Clustering::squaredEuclideanDistance, Clustering::mean); + } + public static ClusteringResult kMeans(double[][] points, int k, long seed, int maxIterations, double tolerance) { + validateParameters(maxIterations, tolerance); + double[][] centroids = randomInitialCentroids(points, k, seed); + return run(points, centroids, maxIterations, tolerance, Clustering::squaredEuclideanDistance, Clustering::mean); + } + + /** + * Runs K-Medians using explicit, caller-supplied initial centers. Deterministic — the + * recommended entry point for reproducible results and tests. + * + * @param points the dataset to cluster; non-empty, consistent dimensionality + * @param initialCenters exactly {@code k} initial centers, matching {@code points}' + * dimensionality + * @param maxIterations maximum number of iterations; must be positive + * @param tolerance convergence tolerance on center movement; must be non-negative + * @return the clustering result + */ + + public static ClusteringResult kMedians(double[][] points, double[][] initialCenters, int maxIterations, double tolerance) { + validateParameters(maxIterations, tolerance); + double[][] centers = validateAndCopyCentroids(points, initialCenters); + return run(points, centers, maxIterations, tolerance, Clustering::manhattanDistance, Clustering::median); + } + + /** + * Runs K-Medians, sampling {@code k} distinct points from the dataset (via a seeded + * {@link Random}) as initial centers. Reproducible across runs given the same seed. + * + * @param points the dataset to cluster; non-empty, at least {@code k} points + * @param k the number of clusters; must be positive and ≤ number of points + * @param seed seed used to pick initial centers + * @param maxIterations maximum number of iterations; must be positive + * @param tolerance convergence tolerance on center movement; must be non-negative + * @return the clustering result + */ + + public static ClusteringResult kMedians(double[][] points, int k, long seed, int maxIterations, double tolerance) { + validateParameters(maxIterations, tolerance); + double[][] centers = randomInitialCentroids(points, k, seed); + return run(points, centers, maxIterations, tolerance, Clustering::manhattanDistance, Clustering::median); + } + + + @FunctionalInterface + private interface DistanceFunction { + double distance(double[] a, double[] b); + } + + @FunctionalInterface + private interface CenterFunction { + double[] center(List clusterPoints, int dimension); + } + + private static ClusteringResult run(double[][] points, double[][] initialCenters, int maxIterations, double tolerance, DistanceFunction assignmentDistance, CenterFunction centerFunction) { + int n = points.length; + int k = initialCenters.length; + int dimension = points[0].length; + double[][] centers = initialCenters; + int[] labels = new int[n]; + Arrays.fill(labels, -1); + + int iteration = 0; + boolean converged = false; + + while (iteration < maxIterations && !converged) { + boolean anyAssignmentChanged = assign(points, centers, labels, assignmentDistance); + double[][] newCenters = updateCenters(points, labels, centers, k, dimension, centerFunction); + double maxShift = maxCenterShift(centers, newCenters); + centers = newCenters; + iteration++; + converged = !anyAssignmentChanged || maxShift < tolerance; + } + + return new ClusteringResult(centers, labels, iteration, converged); + } + + private static boolean assign(double[][] points, double[][] centers, int[] labels, DistanceFunction distanceFunction) { + boolean changed = false; + for (int i = 0; i < points.length; i++) { + int best = 0; + double bestDist = distanceFunction.distance(points[i], centers[0]); + for (int c = 1; c < centers.length; c++) { + double dist = distanceFunction.distance(points[i], centers[c]); + if (dist < bestDist) { + bestDist = dist; + best = c; + } + } + if (labels[i] != best) { + labels[i] = best; + changed = true; + } + } + return changed; + } + + private static double[][] updateCenters(double[][] points, int[] labels, double[][] oldCenters, int k, int dimension, CenterFunction centerFunction) { + List> groups = new ArrayList<>(); + for (int c = 0; c < k; c++) { + groups.add(new ArrayList<>()); + } + for (int i = 0; i < points.length; i++) { + groups.get(labels[i]).add(points[i]); + } + double[][] newCenters = new double[k][]; + for (int c = 0; c < k; c++) { + if (groups.get(c).isEmpty()) { + // Keep the previous center if the cluster lost all its points. + newCenters[c] = Arrays.copyOf(oldCenters[c], dimension); + } else { + newCenters[c] = centerFunction.center(groups.get(c), dimension); + } + } + return newCenters; + } + + private static double maxCenterShift(double[][] oldCenters, double[][] newCenters) { + double max = 0.0; + for (int c = 0; c < oldCenters.length; c++) { + max = Math.max(max, euclideanDistance(oldCenters[c], newCenters[c])); + } + return max; + } + + private static double squaredEuclideanDistance(double[] a, double[] b) { + double sum = 0.0; + for (int d = 0; d < a.length; d++) { + double diff = a[d] - b[d]; + sum += diff * diff; + } + return sum; + } + + private static double euclideanDistance(double[] a, double[] b) { + return Math.sqrt(squaredEuclideanDistance(a, b)); + } + + private static double manhattanDistance(double[] a, double[] b) { + double sum = 0.0; + for (int d = 0; d < a.length; d++) { + sum += Math.abs(a[d] - b[d]); + } + return sum; + } + + private static double[] mean(List clusterPoints, int dimension) { + double[] result = new double[dimension]; + for (double[] p : clusterPoints) { + for (int d = 0; d < dimension; d++) { + result[d] += p[d]; + } + } + for (int d = 0; d < dimension; d++) { + result[d] /= clusterPoints.size(); + } + return result; + } + + private static double[] median(List clusterPoints, int dimension) { + int n = clusterPoints.size(); + double[] result = new double[dimension]; + double[] values = new double[n]; + for (int d = 0; d < dimension; d++) { + for (int i = 0; i < n; i++) { + values[i] = clusterPoints.get(i)[d]; + } + Arrays.sort(values); + if (n % 2 == 1) { + result[d] = values[n / 2]; + } else { + result[d] = (values[n / 2 - 1] + values[n / 2]) / 2.0; + } + } + return result; + } + + private static void validateParameters(int maxIterations, double tolerance) { + if (maxIterations <= 0) { + throw new IllegalArgumentException("maxIterations must be positive, got " + maxIterations); + } + if (tolerance < 0) { + throw new IllegalArgumentException("tolerance must be non-negative, got " + tolerance); + } + } + + private static void validatePoints(double[][] points, int k) { + if (points == null || points.length == 0) { + throw new IllegalArgumentException("Dataset must not be empty"); + } + if (k <= 0) { + throw new IllegalArgumentException("k must be positive, got " + k); + } + if (k > points.length) { + throw new IllegalArgumentException("k (" + k + ") cannot exceed the number of points (" + points.length + ")"); + } + int dimension = points[0].length; + if (dimension == 0) { + throw new IllegalArgumentException("Points must have at least one dimension"); + } + for (int i = 0; i < points.length; i++) { + if (points[i] == null || points[i].length != dimension) { + throw new IllegalArgumentException("All points must share the same dimensionality; point " + i + " does not match"); + } + } + } + + private static double[][] validateAndCopyCentroids(double[][] points, double[][] initialCenters) { + Objects.requireNonNull(initialCenters, "initial centers must not be null"); + validatePoints(points, initialCenters.length); + int dimension = points[0].length; + double[][] centers = new double[initialCenters.length][]; + for (int i = 0; i < initialCenters.length; i++) { + if (initialCenters[i] == null || initialCenters[i].length != dimension) { + throw new IllegalArgumentException("Initial center " + i + " has inconsistent dimensionality"); + } + centers[i] = Arrays.copyOf(initialCenters[i], dimension); + } + return centers; + } + + private static double[][] randomInitialCentroids(double[][] points, int k, long seed) { + validatePoints(points, k); + int[] indices = new int[points.length]; + for (int i = 0; i < indices.length; i++) { + indices[i] = i; + } + Random random = new Random(seed); + for (int i = indices.length - 1; i > 0; i--) { + int j = random.nextInt(i + 1); + int tmp = indices[i]; + indices[i] = indices[j]; + indices[j] = tmp; + } + double[][] centers = new double[k][]; + for (int i = 0; i < k; i++) { + centers[i] = Arrays.copyOf(points[indices[i]], points[indices[i]].length); + } + return centers; + } + + public static final class ClusteringResult { + private final double[][] centers; + private final int[] labels; + private final int iterations; + private final boolean converged; + + ClusteringResult(double[][] centers, int[] labels, int iterations, boolean converged) { + this.centers = centers; + this.labels = labels; + this.iterations = iterations; + this.converged = converged; + } + + public double[][] getCenters() { + double[][] copy = new double[centers.length][]; + for (int i = 0; i < centers.length; i++) { + copy[i] = Arrays.copyOf(centers[i], centers[i].length); + } + return copy; + } + + public int[] getLabels() { + return Arrays.copyOf(labels, labels.length); + } + + public int getIterations() { + return iterations; + } + + public boolean hasConverged() { + return converged; + } + } +} \ No newline at end of file diff --git a/src/test/java/com/thealgorithms/machinelearning/ClusteringTest.java b/src/test/java/com/thealgorithms/machinelearning/ClusteringTest.java new file mode 100644 index 000000000000..f7b82796bab0 --- /dev/null +++ b/src/test/java/com/thealgorithms/machinelearning/ClusteringTest.java @@ -0,0 +1,214 @@ +package com.thealgorithms.machinelearning; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.thealgorithms.machinelearning.Clustering.ClusteringResult; +import org.junit.jupiter.api.Test; + +class ClustringTest { + + // K-mean + + @Test + void kMeansClustersTwoWellSeparatedGroups() { + double[][] points = { + {0.0, 0.0}, + {0.5, 0.5}, + {1.0, 0.0}, + {10.0, 10.0}, + {10.5, 10.5}, + {11.0, 10.0}, + }; + double[][] initialCentroids = {{0.0, 0.0}, {10.0, 10.0}}; + + ClusteringResult result = Clustering.kMeans(points, initialCentroids, 100, 1e-9); + int[] labels = result.getLabels(); + + assertEquals(labels[0], labels[1]); + assertEquals(labels[0], labels[2]); + assertEquals(labels[3], labels[4]); + assertEquals(labels[3], labels[5]); + assertNotEquals(labels[0], labels[3]); + assertTrue(result.hasConverged()); + } + + @Test + void kMeansWithKEqualsOneReturnsMean() { + double[][] points = {{0.0, 0.0}, {2.0, 0.0}, {1.0, 3.0}}; + double[][] initialCentroids = {{0.0, 0.0}}; + + ClusteringResult result = Clustering.kMeans(points, initialCentroids, 50, 1e-9); + + assertArrayEquals(new int[] {0, 0, 0}, result.getLabels()); + assertArrayEquals(new double[] {1.0, 1.0}, result.getCenters()[0], 1e-9); + } + + @Test + void kMeansWithKEqualsNKeepsEveryPointItsOwnCluster() { + double[][] points = {{0.0, 0.0}, {5.0, 5.0}, {10.0, 10.0}}; + double[][] initialCentroids = {{0.0, 0.0}, {5.0, 5.0}, {10.0, 10.0}}; + + ClusteringResult result = Clustering.kMeans(points, initialCentroids, 50, 1e-9); + + assertArrayEquals(new int[] {0, 1, 2}, result.getLabels()); + assertEquals(1, result.getIterations()); + assertTrue(result.hasConverged()); + } + + @Test + void kMeansSeededRandomInitializationIsReproducible() { + double[][] points = { + {0.0, 0.0}, + {0.1, 0.2}, + {8.0, 8.0}, + {8.2, 7.9}, + {4.0, 0.0}, + {4.1, 0.1}, + }; + + ClusteringResult r1 = Clustering.kMeans(points, 3, 7L, 100, 1e-9); + ClusteringResult r2 = Clustering.kMeans(points, 3, 7L, 100, 1e-9); + + assertArrayEquals(r1.getLabels(), r2.getLabels()); + for (int c = 0; c < r1.getCenters().length; c++) { + assertArrayEquals(r1.getCenters()[c], r2.getCenters()[c], 1e-9); + } + } + + // K-Medians + + @Test + void kMediansClustersTwoWellSeparatedGroups() { + double[][] points = { + {0.0, 0.0}, + {0.5, 0.5}, + {1.0, 0.0}, + {10.0, 10.0}, + {10.5, 10.5}, + {11.0, 10.0}, + }; + double[][] initialCenters = {{0.0, 0.0}, {10.0, 10.0}}; + + ClusteringResult result = Clustering.kMedians(points, initialCenters, 100, 1e-9); + int[] labels = result.getLabels(); + + assertEquals(labels[0], labels[1]); + assertEquals(labels[0], labels[2]); + assertEquals(labels[3], labels[4]); + assertEquals(labels[3], labels[5]); + assertNotEquals(labels[0], labels[3]); + assertTrue(result.hasConverged()); + } + + @Test + void kMediansIsMoreRobustToOutliersThanKMeans() { + // One tight group plus a single extreme outlier attached to it. + double[][] points = { + {1.0, 1.0}, + {1.1, 0.9}, + {0.9, 1.1}, + {1.0, 1.0}, + {100.0, 100.0}, // outlier + }; + double[][] initialCenter = {{1.0, 1.0}}; + + ClusteringResult meansResult = Clustering.kMeans(points, initialCenter, 50, 1e-9); + ClusteringResult mediansResult = Clustering.kMedians(points, initialCenter, 50, 1e-9); + + // The mean is dragged noticeably toward the outlier; the median is not. + double meanX = meansResult.getCenters()[0][0]; + double medianX = mediansResult.getCenters()[0][0]; + + assertTrue(meanX > medianX); + assertEquals(1.0, medianX, 1e-9); + } + + @Test + void kMediansWithKEqualsNKeepsEveryPointItsOwnCluster() { + double[][] points = {{0.0, 0.0}, {5.0, 5.0}, {10.0, 10.0}}; + double[][] initialCenters = {{0.0, 0.0}, {5.0, 5.0}, {10.0, 10.0}}; + + ClusteringResult result = Clustering.kMedians(points, initialCenters, 50, 1e-9); + + assertArrayEquals(new int[] {0, 1, 2}, result.getLabels()); + assertTrue(result.hasConverged()); + } + + @Test + void kMediansSeededRandomInitializationIsReproducible() { + double[][] points = { + {0.0, 0.0}, + {0.1, 0.2}, + {8.0, 8.0}, + {8.2, 7.9}, + {4.0, 0.0}, + {4.1, 0.1}, + }; + + ClusteringResult r1 = Clustering.kMedians(points, 3, 11L, 100, 1e-9); + ClusteringResult r2 = Clustering.kMedians(points, 3, 11L, 100, 1e-9); + + assertArrayEquals(r1.getLabels(), r2.getLabels()); + for (int c = 0; c < r1.getCenters().length; c++) { + assertArrayEquals(r1.getCenters()[c], r2.getCenters()[c], 1e-9); + } + } + + // ------------------------------------------------------------------ + // Shared validation (exercised through kMeans; identical path for kMedians) + // ------------------------------------------------------------------ + + @Test + void rejectsNonPositiveMaxIterations() { + double[][] points = {{0.0, 0.0}, {1.0, 1.0}}; + double[][] centers = {{0.0, 0.0}}; + assertThrows(IllegalArgumentException.class, () -> Clustering.kMeans(points, centers, 0, 1e-9)); + } + + @Test + void rejectsNegativeTolerance() { + double[][] points = {{0.0, 0.0}, {1.0, 1.0}}; + double[][] centers = {{0.0, 0.0}}; + assertThrows(IllegalArgumentException.class, () -> Clustering.kMeans(points, centers, 10, -1.0)); + } + + @Test + void rejectsKGreaterThanNumberOfPoints() { + double[][] points = {{0.0, 0.0}, {1.0, 1.0}}; + assertThrows(IllegalArgumentException.class, () -> Clustering.kMeans(points, 3, 42L, 10, 1e-9)); + assertThrows(IllegalArgumentException.class, () -> Clustering.kMedians(points, 3, 42L, 10, 1e-9)); + } + + @Test + void rejectsEmptyDataset() { + double[][] points = {}; + assertThrows(IllegalArgumentException.class, () -> Clustering.kMeans(points, 1, 42L, 10, 1e-9)); + } + + @Test + void rejectsInconsistentDimensions() { + double[][] points = {{0.0, 0.0}, {1.0, 1.0, 1.0}}; + assertThrows(IllegalArgumentException.class, () -> Clustering.kMeans(points, 1, 42L, 10, 1e-9)); + } + + @Test + void rejectsEmptyInitialCenters() { + // k is derived from initialCenters.length, so a zero-length array means k = 0. + double[][] points = {{0.0, 0.0}, {1.0, 1.0}, {2.0, 2.0}}; + double[][] initialCenters = {}; + assertThrows(IllegalArgumentException.class, () -> Clustering.kMeans(points, initialCenters, 10, 1e-9)); + assertThrows(IllegalArgumentException.class, () -> Clustering.kMedians(points, initialCenters, 10, 1e-9)); + } + + @Test + void rejectsInitialCenterWithMismatchedDimension() { + double[][] points = {{0.0, 0.0}, {1.0, 1.0}, {2.0, 2.0}}; + double[][] initialCenters = {{0.0, 0.0}, {1.0, 1.0, 1.0}}; + assertThrows(IllegalArgumentException.class, () -> Clustering.kMeans(points, initialCenters, 10, 1e-9)); + assertThrows(IllegalArgumentException.class, () -> Clustering.kMedians(points, initialCenters, 10, 1e-9)); + } +} \ No newline at end of file