每日算法 — 使用java实现朴素贝叶斯:垃圾邮件过滤与概率推理

引言

朴素贝叶斯分类器是基于贝叶斯定理的概率分类算法。它假设特征之间相互独立(”朴素”的由来),这一假设大大简化了计算。尽管假设过于简化,朴素贝叶斯在文本分类、垃圾邮件过滤等场景下表现优异。本文将用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|为词汇表大小。

总结

朴素贝叶斯分类器虽然假设特征独立,但在文本分类任务中效果显著。其训练速度快、实现简单、对缺失数据不敏感,非常适合高维稀疏特征场景。除了垃圾邮件过滤,它还广泛应用于情感分析、新闻分类、文档聚类等任务。