From 3fc82a77311a993af61ff22856f31f480d1abb2e Mon Sep 17 00:00:00 2001 From: th-shivam Date: Thu, 10 Sep 2026 00:11:24 +0530 Subject: [PATCH 1/3] Add Perceptron binary classifier --- .../machinelearning/Perceptron.java | 227 ++++++++++++++++++ .../machinelearning/PerceptronTest.java | 141 +++++++++++ 2 files changed, 368 insertions(+) create mode 100644 src/main/java/com/thealgorithms/machinelearning/Perceptron.java create mode 100644 src/test/java/com/thealgorithms/machinelearning/PerceptronTest.java diff --git a/src/main/java/com/thealgorithms/machinelearning/Perceptron.java b/src/main/java/com/thealgorithms/machinelearning/Perceptron.java new file mode 100644 index 000000000000..094d40432d1f --- /dev/null +++ b/src/main/java/com/thealgorithms/machinelearning/Perceptron.java @@ -0,0 +1,227 @@ +package com.thealgorithms.machinelearning; + +/** + * A binary Perceptron classifier. + * + *

The Perceptron is a single-layer neural network that learns a linear + * decision boundary. It updates its weights whenever a training sample is + * misclassified. Convergence is guaranteed for linearly separable data, but + * training stops after the configured epoch limit for non-separable data. + * Labels must be either {@code 0} or {@code 1}. + * + *

The prediction rule is {@code 1} when the weighted sum plus bias is + * greater than or equal to zero, and {@code 0} otherwise. For a + * misclassified sample, the update is {@code weight += learningRate * error * + * feature} and {@code bias += learningRate * error}, where {@code error} is + * the true label minus the prediction. + * + * @see Perceptron + */ +public final class Perceptron { + private final double learningRate; + private final int maxEpochs; + private double[] weights; + private double bias; + private int numFeatures; + private int epochsRun; + private boolean converged; + + /** + * Constructs a Perceptron with the given training hyperparameters. + * + * @param learningRate positive step size used for each update + * @param maxEpochs positive maximum number of passes over the training data + * @throws IllegalArgumentException if a hyperparameter is invalid + */ + public Perceptron(double learningRate, int maxEpochs) { + if (!Double.isFinite(learningRate) || learningRate <= 0.0) { + throw new IllegalArgumentException("learningRate must be finite and greater than 0"); + } + if (maxEpochs <= 0) { + throw new IllegalArgumentException("maxEpochs must be greater than 0"); + } + this.learningRate = learningRate; + this.maxEpochs = maxEpochs; + } + + /** + * Fits the classifier using binary training labels. + * + *

Fitting resets the weights and bias to zero before training. The + * method records whether an entire epoch completed without an update. + * + * @param features training feature vectors + * @param labels corresponding binary labels, each either {@code 0} or + * {@code 1} + * @throws IllegalArgumentException if the training data is invalid + */ + public void fit(double[][] features, int[] labels) { + validateTrainingData(features, labels); + + numFeatures = features[0].length; + weights = new double[numFeatures]; + bias = 0.0; + epochsRun = 0; + converged = false; + + for (int epoch = 0; epoch < maxEpochs; epoch++) { + boolean updated = false; + + for (int sampleIndex = 0; sampleIndex < features.length; sampleIndex++) { + int prediction = predict(features[sampleIndex]); + int error = labels[sampleIndex] - prediction; + + if (error != 0) { + update(features[sampleIndex], error); + updated = true; + } + } + + epochsRun = epoch + 1; + if (!updated) { + converged = true; + break; + } + } + } + + /** + * Predicts the binary label for one sample. + * + * @param sample feature vector to classify + * @return {@code 0} or {@code 1} + * @throws IllegalStateException if the classifier has not been fitted + * @throws IllegalArgumentException if the sample is invalid + */ + public int predict(double[] sample) { + ensureFitted(); + validateSample(sample); + + double weightedSum = bias; + for (int featureIndex = 0; featureIndex < numFeatures; featureIndex++) { + weightedSum += weights[featureIndex] * sample[featureIndex]; + } + return weightedSum >= 0.0 ? 1 : 0; + } + + /** + * Predicts binary labels for a batch of samples. + * + * @param samples feature vectors to classify + * @return one prediction for each sample + * @throws IllegalStateException if the classifier has not been fitted + * @throws IllegalArgumentException if the batch or one of its samples is + * invalid + */ + public int[] predict(double[][] samples) { + ensureFitted(); + if (samples == null) { + throw new IllegalArgumentException("samples cannot be null"); + } + + int[] predictions = new int[samples.length]; + for (int sampleIndex = 0; sampleIndex < samples.length; sampleIndex++) { + predictions[sampleIndex] = predict(samples[sampleIndex]); + } + return predictions; + } + + /** + * Returns a defensive copy of the learned feature weights. + * + * @return learned weights in feature order + * @throws IllegalStateException if the classifier has not been fitted + */ + public double[] getWeights() { + ensureFitted(); + return weights.clone(); + } + + /** + * Returns the learned bias term. + * + * @return learned bias + * @throws IllegalStateException if the classifier has not been fitted + */ + public double getBias() { + ensureFitted(); + return bias; + } + + /** + * Reports whether training completed with an update-free epoch. + * + * @return {@code true} if an epoch completed without an update + * @throws IllegalStateException if the classifier has not been fitted + */ + public boolean hasConverged() { + ensureFitted(); + return converged; + } + + /** + * Returns the number of epochs performed by the last fit. + * + * @return number of completed epochs + * @throws IllegalStateException if the classifier has not been fitted + */ + public int getEpochsRun() { + ensureFitted(); + return epochsRun; + } + + private void update(double[] sample, int error) { + for (int featureIndex = 0; featureIndex < numFeatures; featureIndex++) { + weights[featureIndex] += learningRate * error * sample[featureIndex]; + } + bias += learningRate * error; + } + + private void ensureFitted() { + if (weights == null) { + throw new IllegalStateException("classifier has not been fitted"); + } + } + + private void validateTrainingData(double[][] features, int[] labels) { + if (features == null || labels == null) { + throw new IllegalArgumentException("features and labels cannot be null"); + } + if (features.length == 0 || labels.length == 0) { + throw new IllegalArgumentException("features and labels cannot be empty"); + } + if (features.length != labels.length) { + throw new IllegalArgumentException("features and labels must have the same length"); + } + if (features[0] == null || features[0].length == 0) { + throw new IllegalArgumentException("feature vectors cannot be null or empty"); + } + + int featureCount = features[0].length; + for (int sampleIndex = 0; sampleIndex < features.length; sampleIndex++) { + double[] sample = features[sampleIndex]; + if (sample == null || sample.length != featureCount) { + throw new IllegalArgumentException("all feature vectors must have the same dimension"); + } + validateFiniteValues(sample); + if (labels[sampleIndex] != 0 && labels[sampleIndex] != 1) { + throw new IllegalArgumentException("labels must be either 0 or 1"); + } + } + } + + private void validateSample(double[] sample) { + if (sample == null || sample.length != numFeatures) { + throw new IllegalArgumentException("sample must match the training feature dimension"); + } + validateFiniteValues(sample); + } + + private static void validateFiniteValues(double[] values) { + for (double value : values) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("feature values must be finite"); + } + } + } +} diff --git a/src/test/java/com/thealgorithms/machinelearning/PerceptronTest.java b/src/test/java/com/thealgorithms/machinelearning/PerceptronTest.java new file mode 100644 index 000000000000..0a78e91a3dfb --- /dev/null +++ b/src/test/java/com/thealgorithms/machinelearning/PerceptronTest.java @@ -0,0 +1,141 @@ +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class PerceptronTest { + + @Test + void learnsAndFunction() { + double[][] features = {{0, 0}, {0, 1}, {1, 0}, {1, 1}}; + int[] labels = {0, 0, 0, 1}; + + Perceptron perceptron = new Perceptron(1.0, 20); + perceptron.fit(features, labels); + + assertArrayEquals(labels, perceptron.predict(features)); + assertTrue(perceptron.hasConverged()); + assertTrue(perceptron.getEpochsRun() <= 20); + } + + @Test + void predictsUnseenSamples() { + double[][] features = {{-2, -1}, {-1, -2}, {1, 2}, {2, 1}}; + int[] labels = {0, 0, 1, 1}; + + Perceptron perceptron = new Perceptron(0.5, 20); + perceptron.fit(features, labels); + + assertEquals(0, perceptron.predict(new double[] {-3, -1})); + assertEquals(1, perceptron.predict(new double[] {3, 1})); + } + + @Test + void batchPredictionMatchesIndividualPredictions() { + double[][] features = {{0, 0}, {0, 1}, {1, 0}, {1, 1}}; + int[] labels = {0, 0, 0, 1}; + double[][] samples = {{0, 0}, {1, 0}, {1, 1}}; + + Perceptron perceptron = new Perceptron(1.0, 20); + perceptron.fit(features, labels); + + assertArrayEquals(new int[] {0, 0, 1}, perceptron.predict(samples)); + int[] individualPredictions = {perceptron.predict(samples[0]), perceptron.predict(samples[1]), perceptron.predict(samples[2])}; + assertArrayEquals(individualPredictions, perceptron.predict(samples)); + } + + @Test + void emptyBatchProducesEmptyPrediction() { + Perceptron perceptron = new Perceptron(1.0, 10); + perceptron.fit(new double[][] {{0}}, new int[] {0}); + + assertArrayEquals(new int[] {}, perceptron.predict(new double[][] {})); + } + + @Test + void nonSeparableDataStopsAtEpochLimitWithoutConverging() { + double[][] features = {{0, 0}, {0, 1}, {1, 0}, {1, 1}}; + int[] labels = {0, 1, 1, 0}; + + Perceptron perceptron = new Perceptron(1.0, 8); + perceptron.fit(features, labels); + + assertFalse(perceptron.hasConverged()); + assertEquals(8, perceptron.getEpochsRun()); + } + + @Test + void fittingResetsPreviousModel() { + Perceptron perceptron = new Perceptron(1.0, 20); + perceptron.fit(new double[][] {{0}, {1}}, new int[] {0, 1}); + perceptron.fit(new double[][] {{0}, {1}}, new int[] {1, 0}); + + assertArrayEquals(new int[] {1, 0}, perceptron.predict(new double[][] {{0}, {1}})); + } + + @Test + void weightsAreReturnedAsDefensiveCopy() { + Perceptron perceptron = new Perceptron(1.0, 10); + perceptron.fit(new double[][] {{0}, {1}}, new int[] {0, 1}); + + double[] weights = perceptron.getWeights(); + weights[0] = 1000; + + assertEquals(1, perceptron.predict(new double[] {1})); + } + + @Test + void predictionBeforeFitThrows() { + Perceptron perceptron = new Perceptron(1.0, 10); + + assertThrows(IllegalStateException.class, () -> perceptron.predict(new double[] {1})); + assertThrows(IllegalStateException.class, () -> perceptron.predict(new double[][] {})); + assertThrows(IllegalStateException.class, perceptron::getWeights); + assertThrows(IllegalStateException.class, perceptron::getBias); + assertThrows(IllegalStateException.class, perceptron::hasConverged); + assertThrows(IllegalStateException.class, perceptron::getEpochsRun); + } + + @Test + void invalidHyperparametersThrow() { + assertThrows(IllegalArgumentException.class, () -> new Perceptron(0.0, 10)); + assertThrows(IllegalArgumentException.class, () -> new Perceptron(-1.0, 10)); + assertThrows(IllegalArgumentException.class, () -> new Perceptron(Double.NaN, 10)); + assertThrows(IllegalArgumentException.class, () -> new Perceptron(Double.POSITIVE_INFINITY, 10)); + assertThrows(IllegalArgumentException.class, () -> new Perceptron(1.0, 0)); + assertThrows(IllegalArgumentException.class, () -> new Perceptron(1.0, -1)); + } + + @Test + void invalidTrainingDataThrows() { + Perceptron perceptron = new Perceptron(1.0, 10); + + assertThrows(IllegalArgumentException.class, () -> perceptron.fit(null, new int[] {0})); + assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}}, null)); + assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {}, new int[] {})); + assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}}, new int[] {})); + assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}, {1, 2}}, new int[] {0, 1})); + assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {null}, new int[] {0})); + assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{}}, new int[] {0})); + assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}}, new int[] {2})); + assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{Double.NaN}}, new int[] {0})); + assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{Double.POSITIVE_INFINITY}}, new int[] {0})); + } + + @Test + void invalidPredictionDataThrows() { + Perceptron perceptron = new Perceptron(1.0, 10); + perceptron.fit(new double[][] {{0, 0}}, new int[] {0}); + + assertThrows(IllegalArgumentException.class, () -> perceptron.predict((double[]) null)); + assertThrows(IllegalArgumentException.class, () -> perceptron.predict(new double[] {0})); + assertThrows(IllegalArgumentException.class, () -> perceptron.predict(new double[] {0, Double.NaN})); + assertThrows(IllegalArgumentException.class, () -> perceptron.predict((double[][]) null)); + assertThrows(IllegalArgumentException.class, () -> perceptron.predict(new double[][] {{0, 0}, null})); + } +} From 9cdd0519782c52b54c68faece28cb8a82c50b54f Mon Sep 17 00:00:00 2001 From: th-shivam Date: Fri, 25 Sep 2026 17:35:13 +0530 Subject: [PATCH 2/3] Fixed related errors --- .../machinelearning/Perceptron.java | 60 ++++++++++++++----- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/thealgorithms/machinelearning/Perceptron.java b/src/main/java/com/thealgorithms/machinelearning/Perceptron.java index 094d40432d1f..7b5e5c4b5429 100644 --- a/src/main/java/com/thealgorithms/machinelearning/Perceptron.java +++ b/src/main/java/com/thealgorithms/machinelearning/Perceptron.java @@ -13,7 +13,9 @@ * greater than or equal to zero, and {@code 0} otherwise. For a * misclassified sample, the update is {@code weight += learningRate * error * * feature} and {@code bias += learningRate * error}, where {@code error} is - * the true label minus the prediction. + * the true label minus the prediction. Samples are visited one at a time, so + * each prediction uses the parameters produced by the preceding updates of + * the same epoch. * * @see Perceptron */ @@ -22,7 +24,6 @@ public final class Perceptron { private final int maxEpochs; private double[] weights; private double bias; - private int numFeatures; private int epochsRun; private boolean converged; @@ -48,18 +49,23 @@ public Perceptron(double learningRate, int maxEpochs) { * Fits the classifier using binary training labels. * *

Fitting resets the weights and bias to zero before training. The - * method records whether an entire epoch completed without an update. + * method records whether an entire epoch completed without an update. A + * large {@code learningRate} combined with large feature values can push + * the parameters past the range of {@code double}; the classifier then + * returns to its unfitted state instead of reporting predictions derived + * from non-finite parameters. * * @param features training feature vectors * @param labels corresponding binary labels, each either {@code 0} or * {@code 1} * @throws IllegalArgumentException if the training data is invalid + * @throws ArithmeticException if training diverges and the learned + * parameters stop being finite */ public void fit(double[][] features, int[] labels) { validateTrainingData(features, labels); - numFeatures = features[0].length; - weights = new double[numFeatures]; + weights = new double[features[0].length]; bias = 0.0; epochsRun = 0; converged = false; @@ -68,7 +74,7 @@ public void fit(double[][] features, int[] labels) { boolean updated = false; for (int sampleIndex = 0; sampleIndex < features.length; sampleIndex++) { - int prediction = predict(features[sampleIndex]); + int prediction = rawPredict(features[sampleIndex]); int error = labels[sampleIndex] - prediction; if (error != 0) { @@ -83,6 +89,8 @@ public void fit(double[][] features, int[] labels) { break; } } + + ensureParametersAreFinite(); } /** @@ -96,12 +104,7 @@ public void fit(double[][] features, int[] labels) { public int predict(double[] sample) { ensureFitted(); validateSample(sample); - - double weightedSum = bias; - for (int featureIndex = 0; featureIndex < numFeatures; featureIndex++) { - weightedSum += weights[featureIndex] * sample[featureIndex]; - } - return weightedSum >= 0.0 ? 1 : 0; + return rawPredict(sample); } /** @@ -170,13 +173,28 @@ public int getEpochsRun() { return epochsRun; } + private int rawPredict(double[] sample) { + double weightedSum = bias; + for (int featureIndex = 0; featureIndex < weights.length; featureIndex++) { + weightedSum += weights[featureIndex] * sample[featureIndex]; + } + return weightedSum >= 0.0 ? 1 : 0; + } + private void update(double[] sample, int error) { - for (int featureIndex = 0; featureIndex < numFeatures; featureIndex++) { + for (int featureIndex = 0; featureIndex < weights.length; featureIndex++) { weights[featureIndex] += learningRate * error * sample[featureIndex]; } bias += learningRate * error; } + private void ensureParametersAreFinite() { + if (!Double.isFinite(bias) || !isFinite(weights)) { + weights = null; + throw new ArithmeticException("training diverged; try a smaller learningRate or scaled features"); + } + } + private void ensureFitted() { if (weights == null) { throw new IllegalStateException("classifier has not been fitted"); @@ -200,7 +218,10 @@ private void validateTrainingData(double[][] features, int[] labels) { int featureCount = features[0].length; for (int sampleIndex = 0; sampleIndex < features.length; sampleIndex++) { double[] sample = features[sampleIndex]; - if (sample == null || sample.length != featureCount) { + if (sample == null) { + throw new IllegalArgumentException("feature vectors cannot be null or empty"); + } + if (sample.length != featureCount) { throw new IllegalArgumentException("all feature vectors must have the same dimension"); } validateFiniteValues(sample); @@ -211,17 +232,24 @@ private void validateTrainingData(double[][] features, int[] labels) { } private void validateSample(double[] sample) { - if (sample == null || sample.length != numFeatures) { + if (sample == null || sample.length != weights.length) { throw new IllegalArgumentException("sample must match the training feature dimension"); } validateFiniteValues(sample); } private static void validateFiniteValues(double[] values) { + if (!isFinite(values)) { + throw new IllegalArgumentException("feature values must be finite"); + } + } + + private static boolean isFinite(double[] values) { for (double value : values) { if (!Double.isFinite(value)) { - throw new IllegalArgumentException("feature values must be finite"); + return false; } } + return true; } } From 8ade55d11e3996fc8e17633901624236ca7d9739 Mon Sep 17 00:00:00 2001 From: th-shivam Date: Fri, 25 Sep 2026 17:52:30 +0530 Subject: [PATCH 3/3] Fix PMD static import violation and cover Perceptron gaps Reduce static imports to PMD's limit, assert the learned bias and weights, and cover the length-mismatch, null-vector, and divergence paths. --- .../machinelearning/PerceptronTest.java | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/thealgorithms/machinelearning/PerceptronTest.java b/src/test/java/com/thealgorithms/machinelearning/PerceptronTest.java index 0a78e91a3dfb..2dce0a0642ca 100644 --- a/src/test/java/com/thealgorithms/machinelearning/PerceptronTest.java +++ b/src/test/java/com/thealgorithms/machinelearning/PerceptronTest.java @@ -2,10 +2,10 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; class PerceptronTest { @@ -23,6 +23,17 @@ void learnsAndFunction() { assertTrue(perceptron.getEpochsRun() <= 20); } + @Test + void learnsExpectedWeightsAndBias() { + Perceptron perceptron = new Perceptron(1.0, 20); + perceptron.fit(new double[][] {{0}, {1}}, new int[] {0, 1}); + + assertArrayEquals(new double[] {1.0}, perceptron.getWeights()); + assertEquals(-1.0, perceptron.getBias()); + assertTrue(perceptron.hasConverged()); + assertEquals(3, perceptron.getEpochsRun()); + } + @Test void predictsUnseenSamples() { double[][] features = {{-2, -1}, {-1, -2}, {1, 2}, {2, 1}}; @@ -65,7 +76,7 @@ void nonSeparableDataStopsAtEpochLimitWithoutConverging() { Perceptron perceptron = new Perceptron(1.0, 8); perceptron.fit(features, labels); - assertFalse(perceptron.hasConverged()); + Assertions.assertFalse(perceptron.hasConverged()); assertEquals(8, perceptron.getEpochsRun()); } @@ -119,8 +130,10 @@ void invalidTrainingDataThrows() { assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}}, null)); assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {}, new int[] {})); assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}}, new int[] {})); + assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}, {1}}, new int[] {0})); assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}, {1, 2}}, new int[] {0, 1})); assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {null}, new int[] {0})); + assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}, null}, new int[] {0, 1})); assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{}}, new int[] {0})); assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}}, new int[] {2})); assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{Double.NaN}}, new int[] {0})); @@ -138,4 +151,13 @@ void invalidPredictionDataThrows() { assertThrows(IllegalArgumentException.class, () -> perceptron.predict((double[][]) null)); assertThrows(IllegalArgumentException.class, () -> perceptron.predict(new double[][] {{0, 0}, null})); } + + @Test + void divergingTrainingIsRejectedAndLeavesClassifierUnfitted() { + Perceptron perceptron = new Perceptron(1.0e300, 5); + + assertThrows(ArithmeticException.class, () -> perceptron.fit(new double[][] {{1.0e300}, {1.0e300}}, new int[] {0, 1})); + assertThrows(IllegalStateException.class, perceptron::getWeights); + assertThrows(IllegalStateException.class, perceptron::getBias); + } }