Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.thealgorithms.machinelearning;

import java.util.HashMap;
import java.util.Map;

/**
* Multinomial Naive Bayes classifier.
*
* <p>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.
*
* <p>Reference: <a href="https://en.wikipedia.org/wiki/Naive_Bayes_classifier">
* Naive Bayes classifier</a>
*
* @author Vraj Prajapati(Rosander0)
*/
public final class MultinomialNaiveBayesClassifier {

private final double alpha;
private final Map<Integer, Double> logPriors;
private final Map<Integer, double[]> 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<Integer, Integer> classCounts = new HashMap<>();
Map<Integer, double[]> featureSums = new HashMap<>();
Map<Integer, Double> 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<Integer, double[]> 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<Integer, double[]> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading