Skip to content

Commit 3fc82a7

Browse files
committed
Add Perceptron binary classifier
1 parent 8abfcf7 commit 3fc82a7

2 files changed

Lines changed: 368 additions & 0 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package com.thealgorithms.machinelearning;
2+
3+
/**
4+
* A binary Perceptron classifier.
5+
*
6+
* <p>The Perceptron is a single-layer neural network that learns a linear
7+
* decision boundary. It updates its weights whenever a training sample is
8+
* misclassified. Convergence is guaranteed for linearly separable data, but
9+
* training stops after the configured epoch limit for non-separable data.
10+
* Labels must be either {@code 0} or {@code 1}.
11+
*
12+
* <p>The prediction rule is {@code 1} when the weighted sum plus bias is
13+
* greater than or equal to zero, and {@code 0} otherwise. For a
14+
* misclassified sample, the update is {@code weight += learningRate * error *
15+
* feature} and {@code bias += learningRate * error}, where {@code error} is
16+
* the true label minus the prediction.
17+
*
18+
* @see <a href="https://en.wikipedia.org/wiki/Perceptron">Perceptron</a>
19+
*/
20+
public final class Perceptron {
21+
private final double learningRate;
22+
private final int maxEpochs;
23+
private double[] weights;
24+
private double bias;
25+
private int numFeatures;
26+
private int epochsRun;
27+
private boolean converged;
28+
29+
/**
30+
* Constructs a Perceptron with the given training hyperparameters.
31+
*
32+
* @param learningRate positive step size used for each update
33+
* @param maxEpochs positive maximum number of passes over the training data
34+
* @throws IllegalArgumentException if a hyperparameter is invalid
35+
*/
36+
public Perceptron(double learningRate, int maxEpochs) {
37+
if (!Double.isFinite(learningRate) || learningRate <= 0.0) {
38+
throw new IllegalArgumentException("learningRate must be finite and greater than 0");
39+
}
40+
if (maxEpochs <= 0) {
41+
throw new IllegalArgumentException("maxEpochs must be greater than 0");
42+
}
43+
this.learningRate = learningRate;
44+
this.maxEpochs = maxEpochs;
45+
}
46+
47+
/**
48+
* Fits the classifier using binary training labels.
49+
*
50+
* <p>Fitting resets the weights and bias to zero before training. The
51+
* method records whether an entire epoch completed without an update.
52+
*
53+
* @param features training feature vectors
54+
* @param labels corresponding binary labels, each either {@code 0} or
55+
* {@code 1}
56+
* @throws IllegalArgumentException if the training data is invalid
57+
*/
58+
public void fit(double[][] features, int[] labels) {
59+
validateTrainingData(features, labels);
60+
61+
numFeatures = features[0].length;
62+
weights = new double[numFeatures];
63+
bias = 0.0;
64+
epochsRun = 0;
65+
converged = false;
66+
67+
for (int epoch = 0; epoch < maxEpochs; epoch++) {
68+
boolean updated = false;
69+
70+
for (int sampleIndex = 0; sampleIndex < features.length; sampleIndex++) {
71+
int prediction = predict(features[sampleIndex]);
72+
int error = labels[sampleIndex] - prediction;
73+
74+
if (error != 0) {
75+
update(features[sampleIndex], error);
76+
updated = true;
77+
}
78+
}
79+
80+
epochsRun = epoch + 1;
81+
if (!updated) {
82+
converged = true;
83+
break;
84+
}
85+
}
86+
}
87+
88+
/**
89+
* Predicts the binary label for one sample.
90+
*
91+
* @param sample feature vector to classify
92+
* @return {@code 0} or {@code 1}
93+
* @throws IllegalStateException if the classifier has not been fitted
94+
* @throws IllegalArgumentException if the sample is invalid
95+
*/
96+
public int predict(double[] sample) {
97+
ensureFitted();
98+
validateSample(sample);
99+
100+
double weightedSum = bias;
101+
for (int featureIndex = 0; featureIndex < numFeatures; featureIndex++) {
102+
weightedSum += weights[featureIndex] * sample[featureIndex];
103+
}
104+
return weightedSum >= 0.0 ? 1 : 0;
105+
}
106+
107+
/**
108+
* Predicts binary labels for a batch of samples.
109+
*
110+
* @param samples feature vectors to classify
111+
* @return one prediction for each sample
112+
* @throws IllegalStateException if the classifier has not been fitted
113+
* @throws IllegalArgumentException if the batch or one of its samples is
114+
* invalid
115+
*/
116+
public int[] predict(double[][] samples) {
117+
ensureFitted();
118+
if (samples == null) {
119+
throw new IllegalArgumentException("samples cannot be null");
120+
}
121+
122+
int[] predictions = new int[samples.length];
123+
for (int sampleIndex = 0; sampleIndex < samples.length; sampleIndex++) {
124+
predictions[sampleIndex] = predict(samples[sampleIndex]);
125+
}
126+
return predictions;
127+
}
128+
129+
/**
130+
* Returns a defensive copy of the learned feature weights.
131+
*
132+
* @return learned weights in feature order
133+
* @throws IllegalStateException if the classifier has not been fitted
134+
*/
135+
public double[] getWeights() {
136+
ensureFitted();
137+
return weights.clone();
138+
}
139+
140+
/**
141+
* Returns the learned bias term.
142+
*
143+
* @return learned bias
144+
* @throws IllegalStateException if the classifier has not been fitted
145+
*/
146+
public double getBias() {
147+
ensureFitted();
148+
return bias;
149+
}
150+
151+
/**
152+
* Reports whether training completed with an update-free epoch.
153+
*
154+
* @return {@code true} if an epoch completed without an update
155+
* @throws IllegalStateException if the classifier has not been fitted
156+
*/
157+
public boolean hasConverged() {
158+
ensureFitted();
159+
return converged;
160+
}
161+
162+
/**
163+
* Returns the number of epochs performed by the last fit.
164+
*
165+
* @return number of completed epochs
166+
* @throws IllegalStateException if the classifier has not been fitted
167+
*/
168+
public int getEpochsRun() {
169+
ensureFitted();
170+
return epochsRun;
171+
}
172+
173+
private void update(double[] sample, int error) {
174+
for (int featureIndex = 0; featureIndex < numFeatures; featureIndex++) {
175+
weights[featureIndex] += learningRate * error * sample[featureIndex];
176+
}
177+
bias += learningRate * error;
178+
}
179+
180+
private void ensureFitted() {
181+
if (weights == null) {
182+
throw new IllegalStateException("classifier has not been fitted");
183+
}
184+
}
185+
186+
private void validateTrainingData(double[][] features, int[] labels) {
187+
if (features == null || labels == null) {
188+
throw new IllegalArgumentException("features and labels cannot be null");
189+
}
190+
if (features.length == 0 || labels.length == 0) {
191+
throw new IllegalArgumentException("features and labels cannot be empty");
192+
}
193+
if (features.length != labels.length) {
194+
throw new IllegalArgumentException("features and labels must have the same length");
195+
}
196+
if (features[0] == null || features[0].length == 0) {
197+
throw new IllegalArgumentException("feature vectors cannot be null or empty");
198+
}
199+
200+
int featureCount = features[0].length;
201+
for (int sampleIndex = 0; sampleIndex < features.length; sampleIndex++) {
202+
double[] sample = features[sampleIndex];
203+
if (sample == null || sample.length != featureCount) {
204+
throw new IllegalArgumentException("all feature vectors must have the same dimension");
205+
}
206+
validateFiniteValues(sample);
207+
if (labels[sampleIndex] != 0 && labels[sampleIndex] != 1) {
208+
throw new IllegalArgumentException("labels must be either 0 or 1");
209+
}
210+
}
211+
}
212+
213+
private void validateSample(double[] sample) {
214+
if (sample == null || sample.length != numFeatures) {
215+
throw new IllegalArgumentException("sample must match the training feature dimension");
216+
}
217+
validateFiniteValues(sample);
218+
}
219+
220+
private static void validateFiniteValues(double[] values) {
221+
for (double value : values) {
222+
if (!Double.isFinite(value)) {
223+
throw new IllegalArgumentException("feature values must be finite");
224+
}
225+
}
226+
}
227+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.thealgorithms.machinelearning;
2+
3+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
import static org.junit.jupiter.api.Assertions.assertFalse;
6+
import static org.junit.jupiter.api.Assertions.assertThrows;
7+
import static org.junit.jupiter.api.Assertions.assertTrue;
8+
9+
import org.junit.jupiter.api.Test;
10+
11+
class PerceptronTest {
12+
13+
@Test
14+
void learnsAndFunction() {
15+
double[][] features = {{0, 0}, {0, 1}, {1, 0}, {1, 1}};
16+
int[] labels = {0, 0, 0, 1};
17+
18+
Perceptron perceptron = new Perceptron(1.0, 20);
19+
perceptron.fit(features, labels);
20+
21+
assertArrayEquals(labels, perceptron.predict(features));
22+
assertTrue(perceptron.hasConverged());
23+
assertTrue(perceptron.getEpochsRun() <= 20);
24+
}
25+
26+
@Test
27+
void predictsUnseenSamples() {
28+
double[][] features = {{-2, -1}, {-1, -2}, {1, 2}, {2, 1}};
29+
int[] labels = {0, 0, 1, 1};
30+
31+
Perceptron perceptron = new Perceptron(0.5, 20);
32+
perceptron.fit(features, labels);
33+
34+
assertEquals(0, perceptron.predict(new double[] {-3, -1}));
35+
assertEquals(1, perceptron.predict(new double[] {3, 1}));
36+
}
37+
38+
@Test
39+
void batchPredictionMatchesIndividualPredictions() {
40+
double[][] features = {{0, 0}, {0, 1}, {1, 0}, {1, 1}};
41+
int[] labels = {0, 0, 0, 1};
42+
double[][] samples = {{0, 0}, {1, 0}, {1, 1}};
43+
44+
Perceptron perceptron = new Perceptron(1.0, 20);
45+
perceptron.fit(features, labels);
46+
47+
assertArrayEquals(new int[] {0, 0, 1}, perceptron.predict(samples));
48+
int[] individualPredictions = {perceptron.predict(samples[0]), perceptron.predict(samples[1]), perceptron.predict(samples[2])};
49+
assertArrayEquals(individualPredictions, perceptron.predict(samples));
50+
}
51+
52+
@Test
53+
void emptyBatchProducesEmptyPrediction() {
54+
Perceptron perceptron = new Perceptron(1.0, 10);
55+
perceptron.fit(new double[][] {{0}}, new int[] {0});
56+
57+
assertArrayEquals(new int[] {}, perceptron.predict(new double[][] {}));
58+
}
59+
60+
@Test
61+
void nonSeparableDataStopsAtEpochLimitWithoutConverging() {
62+
double[][] features = {{0, 0}, {0, 1}, {1, 0}, {1, 1}};
63+
int[] labels = {0, 1, 1, 0};
64+
65+
Perceptron perceptron = new Perceptron(1.0, 8);
66+
perceptron.fit(features, labels);
67+
68+
assertFalse(perceptron.hasConverged());
69+
assertEquals(8, perceptron.getEpochsRun());
70+
}
71+
72+
@Test
73+
void fittingResetsPreviousModel() {
74+
Perceptron perceptron = new Perceptron(1.0, 20);
75+
perceptron.fit(new double[][] {{0}, {1}}, new int[] {0, 1});
76+
perceptron.fit(new double[][] {{0}, {1}}, new int[] {1, 0});
77+
78+
assertArrayEquals(new int[] {1, 0}, perceptron.predict(new double[][] {{0}, {1}}));
79+
}
80+
81+
@Test
82+
void weightsAreReturnedAsDefensiveCopy() {
83+
Perceptron perceptron = new Perceptron(1.0, 10);
84+
perceptron.fit(new double[][] {{0}, {1}}, new int[] {0, 1});
85+
86+
double[] weights = perceptron.getWeights();
87+
weights[0] = 1000;
88+
89+
assertEquals(1, perceptron.predict(new double[] {1}));
90+
}
91+
92+
@Test
93+
void predictionBeforeFitThrows() {
94+
Perceptron perceptron = new Perceptron(1.0, 10);
95+
96+
assertThrows(IllegalStateException.class, () -> perceptron.predict(new double[] {1}));
97+
assertThrows(IllegalStateException.class, () -> perceptron.predict(new double[][] {}));
98+
assertThrows(IllegalStateException.class, perceptron::getWeights);
99+
assertThrows(IllegalStateException.class, perceptron::getBias);
100+
assertThrows(IllegalStateException.class, perceptron::hasConverged);
101+
assertThrows(IllegalStateException.class, perceptron::getEpochsRun);
102+
}
103+
104+
@Test
105+
void invalidHyperparametersThrow() {
106+
assertThrows(IllegalArgumentException.class, () -> new Perceptron(0.0, 10));
107+
assertThrows(IllegalArgumentException.class, () -> new Perceptron(-1.0, 10));
108+
assertThrows(IllegalArgumentException.class, () -> new Perceptron(Double.NaN, 10));
109+
assertThrows(IllegalArgumentException.class, () -> new Perceptron(Double.POSITIVE_INFINITY, 10));
110+
assertThrows(IllegalArgumentException.class, () -> new Perceptron(1.0, 0));
111+
assertThrows(IllegalArgumentException.class, () -> new Perceptron(1.0, -1));
112+
}
113+
114+
@Test
115+
void invalidTrainingDataThrows() {
116+
Perceptron perceptron = new Perceptron(1.0, 10);
117+
118+
assertThrows(IllegalArgumentException.class, () -> perceptron.fit(null, new int[] {0}));
119+
assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}}, null));
120+
assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {}, new int[] {}));
121+
assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}}, new int[] {}));
122+
assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}, {1, 2}}, new int[] {0, 1}));
123+
assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {null}, new int[] {0}));
124+
assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{}}, new int[] {0}));
125+
assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{0}}, new int[] {2}));
126+
assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{Double.NaN}}, new int[] {0}));
127+
assertThrows(IllegalArgumentException.class, () -> perceptron.fit(new double[][] {{Double.POSITIVE_INFINITY}}, new int[] {0}));
128+
}
129+
130+
@Test
131+
void invalidPredictionDataThrows() {
132+
Perceptron perceptron = new Perceptron(1.0, 10);
133+
perceptron.fit(new double[][] {{0, 0}}, new int[] {0});
134+
135+
assertThrows(IllegalArgumentException.class, () -> perceptron.predict((double[]) null));
136+
assertThrows(IllegalArgumentException.class, () -> perceptron.predict(new double[] {0}));
137+
assertThrows(IllegalArgumentException.class, () -> perceptron.predict(new double[] {0, Double.NaN}));
138+
assertThrows(IllegalArgumentException.class, () -> perceptron.predict((double[][]) null));
139+
assertThrows(IllegalArgumentException.class, () -> perceptron.predict(new double[][] {{0, 0}, null}));
140+
}
141+
}

0 commit comments

Comments
 (0)