引言
朴素贝叶斯分类器是基于贝叶斯定理的概率分类算法。它假设特征之间相互独立(”朴素”的由来),这一假设大大简化了计算。尽管假设过于简化,朴素贝叶斯在文本分类、垃圾邮件过滤等场景下表现优异。本文将用Java实现一个完整的朴素贝叶斯垃圾邮件过滤器。
贝叶斯定理回顾
贝叶斯定理描述了在已知某些条件下,某事件发生的概率:
$$P(C|X) = \frac{P(X|C) \cdot P(C)}{P(X)}$$
在分类问题中,我们比较每个类别C的后验概率P(C|X),选择概率最大的类别作为预测结果。
算法设计
垃圾邮件过滤的核心是计算一封邮件属于”垃圾邮件”和”正常邮件”的概率。我们将邮件拆分为单词(特征),统计每个单词在不同类别下的出现频率。
import java.util.*;
/**
* 邮件样本类
*/
public class EmailSample {
public final String content; // 邮件内容
public final boolean isSpam; // true=垃圾邮件, false=正常邮件
public EmailSample(String content, boolean isSpam) {
this.content = content;
this.isSpam = isSpam;
}
}
朴素贝叶斯分类器实现
/**
* 朴素贝叶斯垃圾邮件分类器
* 使用多项式模型处理词频特征
*/
public class NaiveBayesClassifier {
// 先验概率
private double spamPrior;
private double hamPrior;
// 条件概率:P(单词|类别)
private Map<String, Double> spamWordProbs;
private Map<String, Double> hamWordProbs;
// 词频统计
private Map<String, Integer> spamWordCounts;
private Map<String, Integer> hamWordCounts;
private int spamTotalWords;
private int hamTotalWords;
private int vocabSize;
// 拉普拉斯平滑参数
private static final double ALPHA = 1.0;
/**
* 训练分类器
*/
public void train(List<EmailSample> trainSet) {
int spamCount = 0;
spamWordCounts = new HashMap<>();
hamWordCounts = new HashMap<>();
spamTotalWords = 0;
hamTotalWords = 0;
Set<String> vocab = new HashSet<>();
for (EmailSample email : trainSet) {
String[] words = tokenize(email.content);
if (email.isSpam) {
spamCount++;
for (String word : words) {
spamWordCounts.merge(word, 1, Integer::sum);
spamTotalWords++;
vocab.add(word);
}
} else {
for (String word : words) {
hamWordCounts.merge(word, 1, Integer::sum);
hamTotalWords++;
vocab.add(word);
}
}
}
vocabSize = vocab.size();
// 计算先验概率(取对数避免下溢)
spamPrior = Math.log((double) spamCount / trainSet.size());
hamPrior = Math.log((double) (trainSet.size() - spamCount) / trainSet.size());
// 预计算条件概率(带拉普拉斯平滑)
spamWordProbs = new HashMap<>();
hamWordProbs = new HashMap<>();
for (String word : vocab) {
// P(单词|垃圾邮件) = (count + alpha) / (total + alpha * vocabSize)
int spamCount_word = spamWordCounts.getOrDefault(word, 0);
int hamCount_word = hamWordCounts.getOrDefault(word, 0);
spamWordProbs.put(word, Math.log((spamCount_word + ALPHA) / (spamTotalWords + ALPHA * vocabSize)));
hamWordProbs.put(word, Math.log((hamCount_word + ALPHA) / (hamTotalWords + ALPHA * vocabSize)));
}
}
/**
* 预测邮件类别
* 返回true表示垃圾邮件
*/
public boolean predict(String content) {
String[] words = tokenize(content);
double spamScore = spamPrior;
double hamScore = hamPrior;
for (String word : words) {
// 如果单词在训练集中未出现,使用平滑后的默认概率
double spamProb = spamWordProbs.getOrDefault(word,
Math.log(ALPHA / (spamTotalWords + ALPHA * vocabSize)));
double hamProb = hamWordProbs.getOrDefault(word,
Math.log(ALPHA / (hamTotalWords + ALPHA * vocabSize)));
spamScore += spamProb;
hamScore += hamProb;
}
return spamScore > hamScore;
}
/**
* 获取邮件是垃圾邮件的概率分数(用于展示)
*/
public double getSpamProbability(String content) {
String[] words = tokenize(content);
double spamScore = spamPrior;
double hamScore = hamPrior;
for (String word : words) {
double spamProb = spamWordProbs.getOrDefault(word,
Math.log(ALPHA / (spamTotalWords + ALPHA * vocabSize)));
double hamProb = hamWordProbs.getOrDefault(word,
Math.log(ALPHA / (hamTotalWords + ALPHA * vocabSize)));
spamScore += spamProb;
hamScore += hamProb;
}
// 将对数概率转换回概率空间
double spamExp = Math.exp(spamScore);
double hamExp = Math.exp(hamScore);
return spamExp / (spamExp + hamExp);
}
/**
* 分词处理:转换为小写,去除标点,按空格分割
*/
private String[] tokenize(String text) {
return text.toLowerCase()
.replaceAll("[^a-z\\s]", " ")
.split("\\s+");
}
/**
* 评估模型性能
*/
public void evaluate(List<EmailSample> testSet) {
int tp = 0, fp = 0, tn = 0, fn = 0;
for (EmailSample email : testSet) {
boolean pred = predict(email.content);
if (pred && email.isSpam) tp++;
else if (pred && !email.isSpam) fp++;
else if (!pred && !email.isSpam) tn++;
else fn++;
}
double accuracy = (double) (tp + tn) / testSet.size();
double precision = (double) tp / (tp + fp);
double recall = (double) tp / (tp + fn);
double f1 = 2 * precision * recall / (precision + recall);
System.out.printf("准确率: %.2f%%%n", accuracy * 100);
System.out.printf("精确率: %.2f%%%n", precision * 100);
System.out.printf("召回率: %.2f%%%n", recall * 100);
System.out.printf("F1分数: %.4f%n", f1);
}
}
模拟数据集生成
/**
* 模拟邮件数据集生成器
*/
public class EmailDataset {
private static final String[] SPAM_KEYWORDS = {
"free", "win", "winner", "cash", "prize", "click", "buy", "cheap",
"discount", "offer", "limited", "urgent", "act", "now", "money",
"credit", "loan", "debt", "earn", "extra", "income", "million",
"dollars", "nigerian", "viagra", "weight", "lose", "miracle"
};
private static final String[] HAM_KEYWORDS = {
"meeting", "project", "report", "team", "schedule", "deadline",
"review", "update", "feedback", "conference", "call", "discuss",
"proposal", "budget", "client", "contract", "agreement", "invoice",
"payment", "receipt", "delivery", "shipment", "order", "product"
};
private static final String[] FILLER_WORDS = {
"the", "and", "is", "are", "was", "were", "be", "been", "have",
"has", "had", "do", "does", "did", "will", "would", "could",
"should", "may", "might", "must", "shall", "can", "need", "dare"
};
/**
* 生成模拟邮件数据集
*/
public static List<EmailSample> generateDataset(int spamCount, int hamCount, Random rand) {
List<EmailSample> dataset = new ArrayList<>();
for (int i = 0; i < spamCount; i++) {
dataset.add(new EmailSample(generateEmail(true, rand), true));
}
for (int i = 0; i < hamCount; i++) {
dataset.add(new EmailSample(generateEmail(false, rand), false));
}
Collections.shuffle(dataset, rand);
return dataset;
}
private static String generateEmail(boolean isSpam, Random rand) {
StringBuilder sb = new StringBuilder();
String[] keywords = isSpam ? SPAM_KEYWORDS : HAM_KEYWORDS;
// 每封邮件包含10-20个关键词
int keywordCount = 10 + rand.nextInt(11);
for (int i = 0; i < keywordCount; i++) {
if (rand.nextDouble() < 0.7) {
sb.append(keywords[rand.nextInt(keywords.length)]);
} else {
sb.append(FILLER_WORDS[rand.nextInt(FILLER_WORDS.length)]);
}
sb.append(" ");
}
return sb.toString().trim();
}
}
主程序
public class NaiveBayesMain {
public static void main(String[] args) {
Random rand = new Random(42);
// 生成数据集:800封垃圾邮件,800封正常邮件
List<EmailSample> allData = EmailDataset.generateDataset(800, 800, rand);
// 划分训练集(80%)和测试集(20%)
int split = (int) (allData.size() * 0.8);
List<EmailSample> trainSet = new ArrayList<>(allData.subList(0, split));
List<EmailSample> testSet = new ArrayList<>(allData.subList(split, allData.size()));
System.out.println("训练集: " + trainSet.size() + " 封邮件");
System.out.println("测试集: " + testSet.size() + " 封邮件");
// 训练并评估
NaiveBayesClassifier classifier = new NaiveBayesClassifier();
classifier.train(trainSet);
classifier.evaluate(testSet);
// 测试单封邮件
String testEmail = "Congratulations! You have won a free prize. " +
"Click here to claim your cash reward now. Limited offer!";
boolean isSpam = classifier.predict(testEmail);
double prob = classifier.getSpamProbability(testEmail);
System.out.printf("%n测试邮件: %s%n", testEmail);
System.out.printf("预测结果: %s (垃圾邮件概率: %.2f%%)%n",
isSpam ? "垃圾邮件" : "正常邮件", prob * 100);
}
}
拉普拉斯平滑详解
在实际应用中,训练集中不可能覆盖所有可能出现的单词。如果某个单词在训练集中未出现,其条件概率为0,会导致整个后验概率为0。拉普拉斯平滑通过给每个单词的计数加一个小常数来解决这个问题:
$$P(w_i|C) = \frac{count(w_i, C) + \alpha}{\sum_j count(w_j, C) + \alpha \cdot |V|}$$
其中α为平滑参数(通常取1),|V|为词汇表大小。这样既避免了零概率问题,又保持了概率的相对大小关系。
复杂度分析
| 阶段 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 训练 | O(n * L) | O( |
| 预测 | O(L) | O(1) |
其中n为训练样本数,L为邮件平均长度(单词数),|V|为词汇表大小。
总结
朴素贝叶斯分类器虽然假设特征独立,但在文本分类任务中效果显著。其训练速度快、实现简单、对缺失数据不敏感,非常适合高维稀疏特征场景。除了垃圾邮件过滤,它还广泛应用于情感分析、新闻分类、文档聚类等任务。