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})); + } +}