From e3a8329c4e79810008fff27c14af3f4833d149f8 Mon Sep 17 00:00:00 2001 From: Rosander0 Date: Mon, 20 Jul 2026 17:33:43 +0530 Subject: [PATCH] Add MultinomialNaiveBayesClassifier Implementation --- .../MultinomialNaiveBayesClassifier.java | 141 ++++++++++++++++++ .../MultinomialNaiveBayesClassifierTest.java | 116 ++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 src/main/java/com/thealgorithms/machinelearning/MultinomialNaiveBayesClassifier.java create mode 100644 src/test/java/com/thealgorithms/machinelearning/MultinomialNaiveBayesClassifierTest.java diff --git a/src/main/java/com/thealgorithms/machinelearning/MultinomialNaiveBayesClassifier.java b/src/main/java/com/thealgorithms/machinelearning/MultinomialNaiveBayesClassifier.java new file mode 100644 index 000000000000..5826d7108caa --- /dev/null +++ b/src/main/java/com/thealgorithms/machinelearning/MultinomialNaiveBayesClassifier.java @@ -0,0 +1,141 @@ +package com.thealgorithms.machinelearning; + +import java.util.HashMap; +import java.util.Map; + +/** + * Multinomial Naive Bayes classifier. + * + *

Suited to discrete, count-based features (e.g. word frequencies in text + * classification). Class priors and feature likelihoods are estimated from + * training data with Laplace (add-alpha) smoothing to avoid zero + * probabilities for unseen feature/class combinations. Predictions are made + * by comparing summed log-probabilities across classes, which avoids the + * numerical underflow that repeated multiplication of small probabilities + * would cause. + * + *

Reference: + * Naive Bayes classifier + * + * @author Vraj Prajapati(Rosander0) + */ +public final class MultinomialNaiveBayesClassifier { + + private final double alpha; + private final Map logPriors; + private final Map logLikelihoods; + private int numFeatures; + + /** + * Constructs a classifier with the given Laplace smoothing parameter. + * + * @param alpha smoothing constant; must be greater than 0. A value of 1.0 + * corresponds to standard Laplace smoothing. + */ + public MultinomialNaiveBayesClassifier(double alpha) { + if (alpha <= 0) { + throw new IllegalArgumentException("alpha must be greater than 0"); + } + this.alpha = alpha; + this.logPriors = new HashMap<>(); + this.logLikelihoods = new HashMap<>(); + } + + /** Constructs a classifier using the standard Laplace smoothing constant of 1.0. */ + public MultinomialNaiveBayesClassifier() { + this(1.0); + } + + /** + * Fits the classifier on the given feature matrix and labels. + * + * @param features training samples, each row a vector of non-negative + * feature counts + * @param labels class label for each row of {@code features} + */ + public void fit(double[][] features, int[] labels) { + if (features.length == 0 || features.length != labels.length) { + throw new IllegalArgumentException("features and labels must be non-empty and of equal length"); + } + numFeatures = features[0].length; + + Map classCounts = new HashMap<>(); + Map featureSums = new HashMap<>(); + Map totalFeatureCount = new HashMap<>(); + + for (int i = 0; i < features.length; i++) { + int label = labels[i]; + classCounts.merge(label, 1, Integer::sum); + double[] sums = featureSums.computeIfAbsent(label, k -> new double[numFeatures]); + double total = totalFeatureCount.getOrDefault(label, 0.0); + for (int j = 0; j < numFeatures; j++) { + sums[j] += features[i][j]; + total += features[i][j]; + } + totalFeatureCount.put(label, total); + } + + int totalSamples = features.length; + for (Map.Entry entry : featureSums.entrySet()) { + int label = entry.getKey(); + double[] sums = entry.getValue(); + int count = classCounts.getOrDefault(label, 0); + double total = totalFeatureCount.getOrDefault(label, 0.0); + + logPriors.put(label, Math.log((double) count / totalSamples)); + + double denom = total + alpha * numFeatures; + double[] logLikelihood = new double[numFeatures]; + for (int j = 0; j < numFeatures; j++) { + logLikelihood[j] = Math.log((sums[j] + alpha) / denom); + } + logLikelihoods.put(label, logLikelihood); + } + } + + /** + * Predicts the most likely class for a single sample. + * + * @param sample feature vector of non-negative counts + * @return the predicted class label + */ + public int predict(double[] sample) { + if (logPriors.isEmpty()) { + throw new IllegalStateException("classifier has not been fitted"); + } + if (sample.length != numFeatures) { + throw new IllegalArgumentException("sample length must match training feature count"); + } + + int bestLabel = -1; + double bestScore = Double.NEGATIVE_INFINITY; + + for (Map.Entry entry : logLikelihoods.entrySet()) { + int label = entry.getKey(); + double[] logLikelihood = entry.getValue(); + double score = logPriors.getOrDefault(label, Double.NEGATIVE_INFINITY); + for (int j = 0; j < numFeatures; j++) { + score += sample[j] * logLikelihood[j]; + } + if (score > bestScore) { + bestScore = score; + bestLabel = label; + } + } + return bestLabel; + } + + /** + * Predicts class labels for a batch of samples. + * + * @param samples feature vectors of non-negative counts + * @return predicted class label for each row of {@code samples} + */ + public int[] predict(double[][] samples) { + int[] predictions = new int[samples.length]; + for (int i = 0; i < samples.length; i++) { + predictions[i] = predict(samples[i]); + } + return predictions; + } +} diff --git a/src/test/java/com/thealgorithms/machinelearning/MultinomialNaiveBayesClassifierTest.java b/src/test/java/com/thealgorithms/machinelearning/MultinomialNaiveBayesClassifierTest.java new file mode 100644 index 000000000000..fa268d3bd44a --- /dev/null +++ b/src/test/java/com/thealgorithms/machinelearning/MultinomialNaiveBayesClassifierTest.java @@ -0,0 +1,116 @@ +package com.thealgorithms.machinelearning; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class MultinomialNaiveBayesClassifierTest { + + @Test + void predictsCorrectClassOnSeparableToyDataset() { + // Class 0 samples are dominated by feature 0; class 1 samples by feature 1. + double[][] features = { + {5, 1}, + {6, 0}, + {4, 1}, + {1, 5}, + {0, 6}, + {1, 4}, + }; + int[] labels = {0, 0, 0, 1, 1, 1}; + + MultinomialNaiveBayesClassifier classifier = new MultinomialNaiveBayesClassifier(); + classifier.fit(features, labels); + + assertEquals(0, classifier.predict(new double[] {5, 0})); + assertEquals(1, classifier.predict(new double[] {0, 5})); + } + + @Test + void predictBatchMatchesIndividualPredictions() { + double[][] features = { + {3, 0}, + {2, 0}, + {0, 3}, + {0, 2}, + }; + int[] labels = {0, 0, 1, 1}; + + MultinomialNaiveBayesClassifier classifier = new MultinomialNaiveBayesClassifier(); + classifier.fit(features, labels); + + double[][] samples = {{4, 0}, {0, 4}}; + int[] predictions = classifier.predict(samples); + + assertEquals(classifier.predict(samples[0]), predictions[0]); + assertEquals(classifier.predict(samples[1]), predictions[1]); + } + + @Test + void laplaceSmoothingKeepsZeroCountFeatureLogProbabilityFinite() { + // Feature index 1 never appears for class 0 in training data. + double[][] features = { + {2, 0}, + {3, 0}, + {0, 2}, + {0, 3}, + }; + int[] labels = {0, 0, 1, 1}; + + MultinomialNaiveBayesClassifier classifier = new MultinomialNaiveBayesClassifier(); + classifier.fit(features, labels); + + // A sample that hits class 0's zero-count feature should still produce + // a finite, usable prediction instead of -Infinity collapsing the score. + int prediction = classifier.predict(new double[] {1, 1}); + assertTrue(prediction == 0 || prediction == 1); + } + + @Test + void predictBeforeFitThrowsIllegalStateException() { + MultinomialNaiveBayesClassifier classifier = new MultinomialNaiveBayesClassifier(); + assertThrows(IllegalStateException.class, () -> classifier.predict(new double[] {1, 2})); + } + + @Test + void nonPositiveAlphaThrowsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> new MultinomialNaiveBayesClassifier(0.0)); + assertThrows(IllegalArgumentException.class, () -> new MultinomialNaiveBayesClassifier(-1.0)); + } + + @Test + void mismatchedSampleLengthThrowsIllegalArgumentException() { + double[][] features = { + {1, 2}, + {3, 4}, + }; + int[] labels = {0, 1}; + + MultinomialNaiveBayesClassifier classifier = new MultinomialNaiveBayesClassifier(); + classifier.fit(features, labels); + + assertThrows(IllegalArgumentException.class, () -> classifier.predict(new double[] {1, 2, 3})); + } + + @Test + void mismatchedFeatureAndLabelLengthsThrowsIllegalArgumentException() { + double[][] features = { + {1, 2}, + {3, 4}, + }; + int[] labels = {0}; + + MultinomialNaiveBayesClassifier classifier = new MultinomialNaiveBayesClassifier(); + assertThrows(IllegalArgumentException.class, () -> classifier.fit(features, labels)); + } + + @Test + void emptyFeaturesArrayThrowsIllegalArgumentException() { + double[][] features = {}; + int[] labels = {}; + MultinomialNaiveBayesClassifier classifier = new MultinomialNaiveBayesClassifier(); + assertThrows(IllegalArgumentException.class, () -> classifier.fit(features, labels)); + } +}