每日算法 — 使用java实现朴素贝叶斯:垃圾邮件智能过滤系统

朴素贝叶斯分类器是机器学习中最基础且高效的算法之一。本文以垃圾邮件过滤为实际场景,深入讲解朴素贝叶斯的原理、数学推导,并给出完整的Java实现,包括文本预处理、特征提取和分类决策。

贝叶斯定理回顾

贝叶斯定理描述了在已知某些条件下,事件发生的概率如何更新:

$$P(A|B) = \frac{P(B|A) \cdot P(A)}{P(B)}$$

在分类问题中,我们要求的是给定特征 $X$ 时,类别 $C$ 的后验概率:

$$P(C|X) = \frac{P(X|C) \cdot P(C)}{P(X)}$$

“朴素”的假设

朴素贝叶斯的核心假设是特征之间相互独立。对于文本分类,这意味着每个词的出现与否独立于其他词:

$$P(X|C) = P(x_1|C) \cdot P(x_2|C) \cdot … \cdot P(x_n|C)$$

虽然这个假设在现实中并不成立(词语之间往往有关联),但朴素贝叶斯在实践中表现优异,尤其在文本分类领域。

算法流程

训练阶段

  1. 文本预处理:分词、去停用词、小写化
  2. 统计词频:计算每个词在各类别中出现的次数
  3. 计算先验概率:$P(C) = \frac{该类别文档数}{总文档数}$
  4. 计算条件概率:$P(w|C) = \frac{词w在类别C中的出现次数 + 1}{类别C的总词数 + 词汇表大小}$(拉普拉斯平滑)

预测阶段

  1. 对输入文本进行相同的预处理
  2. 计算属于每个类别的后验概率(取对数避免下溢)
  3. 选择概率最大的类别

Java实现

import java.util.*;
import java.util.regex.*;
import java.io.*;

/**
 * 朴素贝叶斯垃圾邮件分类器
 * 基于词袋模型(Bag of Words)和多项式朴素贝叶斯
 */
public class NaiveBayesSpamFilter {

    /**
     * 存储一个词在各类别中的统计信息
     */
    static class WordStats {
        int spamCount = 0;      // 在垃圾邮件中出现次数
        int hamCount = 0;       // 在正常邮件中出现次数

        int getCount(boolean isSpam) {
            return isSpam ? spamCount : hamCount;
        }
    }

    // 模型参数
    private Map<String, WordStats> wordStats = new HashMap<>();
    private int totalSpamWords = 0;     // 垃圾邮件中的总词数
    private int totalHamWords = 0;      // 正常邮件中的总词数
    private int spamDocCount = 0;       // 垃圾邮件文档数
    private int hamDocCount = 0;        // 正常邮件文档数
    private Set<String> vocabulary = new HashSet<>();  // 词汇表

    // 超参数
    private static final double LAPLACE_SMOOTHING = 1.0;
    private static final Set<String> STOP_WORDS = new HashSet<>(Arrays.asList(
        "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
        "have", "has", "had", "do", "does", "did", "will", "would", "could",
        "should", "may", "might", "must", "shall", "can", "need", "dare",
        "ought", "used", "to", "of", "in", "for", "on", "with", "at", "by",
        "from", "as", "into", "through", "during", "before", "after", "above",
        "below", "between", "under", "and", "but", "or", "yet", "so", "if",
        "because", "although", "though", "while", "where", "when", "that",
        "which", "who", "whom", "whose", "what", "this", "these", "those"
    ));

    /**
     * 文本预处理:分词并过滤
     */
    private List<String> tokenize(String text) {
        List<String> tokens = new ArrayList<>();
        // 使用正则表达式提取单词(只保留字母)
        Matcher matcher = Pattern.compile("[a-zA-Z]+").matcher(text.toLowerCase());
        while (matcher.find()) {
            String word = matcher.group();
            // 过滤停用词和短词
            if (!STOP_WORDS.contains(word) && word.length() > 2) {
                tokens.add(word);
            }
        }
        return tokens;
    }

    /**
     * 训练模型:学习一组已标注的邮件
     * @param documents 邮件文本列表
     * @param labels    对应标签(true=垃圾邮件,false=正常邮件)
     */
    public void train(List<String> documents, List<Boolean> labels) {
        if (documents.size() != labels.size()) {
            throw new IllegalArgumentException("文档数与标签数不匹配");
        }

        for (int i = 0; i < documents.size(); i++) {
            String doc = documents.get(i);
            boolean isSpam = labels.get(i);

            List<String> words = tokenize(doc);
            Set<String> uniqueWords = new HashSet<>(words); // 去重,使用词集模型

            if (isSpam) {
                spamDocCount++;
            } else {
                hamDocCount++;
            }

            for (String word : uniqueWords) {
                vocabulary.add(word);
                WordStats stats = wordStats.computeIfAbsent(word, k -> new WordStats());

                if (isSpam) {
                    stats.spamCount++;
                    totalSpamWords++;
                } else {
                    stats.hamCount++;
                    totalHamWords++;
                }
            }
        }

        System.out.println("训练完成:");
        System.out.println("  垃圾邮件: " + spamDocCount + " 封");
        System.out.println("  正常邮件: " + hamDocCount + " 封");
        System.out.println("  词汇表大小: " + vocabulary.size());
    }

    /**
     * 计算对数条件概率 log(P(word|class))
     * 使用拉普拉斯平滑避免零概率问题
     */
    private double logLikelihood(String word, boolean isSpam) {
        WordStats stats = wordStats.get(word);
        int wordCount = (stats != null) ? stats.getCount(isSpam) : 0;
        int totalWords = isSpam ? totalSpamWords : totalHamWords;
        int vocabSize = vocabulary.size();

        // 拉普拉斯平滑:P(w|C) = (count + 1) / (total + vocabSize)
        return Math.log((wordCount + LAPLACE_SMOOTHING) / (totalWords + vocabSize));
    }

    /**
     * 预测单封邮件的类别
     * @param text 邮件文本
     * @return 预测结果(true=垃圾邮件,false=正常邮件)
     */
    public boolean predict(String text) {
        List<String> words = tokenize(text);

        // 计算先验概率的对数
        double totalDocs = spamDocCount + hamDocCount;
        double logSpamPrior = Math.log(spamDocCount / totalDocs);
        double logHamPrior = Math.log(hamDocCount / totalDocs);

        double logSpamProb = logSpamPrior;
        double logHamProb = logHamPrior;

        // 累加各词的条件概率(取对数将乘法转为加法)
        for (String word : words) {
            if (vocabulary.contains(word)) {
                logSpamProb += logLikelihood(word, true);
                logHamProb += logLikelihood(word, false);
            }
        }

        return logSpamProb > logHamProb;
    }

    /**
     * 预测并返回概率分数(用于评估置信度)
     */
    public double predictScore(String text) {
        List<String> words = tokenize(text);

        double totalDocs = spamDocCount + hamDocCount;
        double logSpamPrior = Math.log(spamDocCount / totalDocs);
        double logHamPrior = Math.log(hamDocCount / totalDocs);

        double logSpamProb = logSpamPrior;
        double logHamProb = logHamPrior;

        for (String word : words) {
            if (vocabulary.contains(word)) {
                logSpamProb += logLikelihood(word, true);
                logHamProb += logLikelihood(word, false);
            }
        }

        // 返回垃圾邮件的概率(使用softmax转换)
        double maxLog = Math.max(logSpamProb, logHamProb);
        double spamExp = Math.exp(logSpamProb - maxLog);
        double hamExp = Math.exp(logHamProb - maxLog);
        return spamExp / (spamExp + hamExp);
    }

    /**
     * 获取模型中最重要的特征词(区分度最高的词)
     */
    public List<Map.Entry<String, Double>> getTopFeatures(int n) {
        Map<String, Double> scores = new HashMap<>();

        for (String word : vocabulary) {
            WordStats stats = wordStats.get(word);
            // 计算 spam/ham 比率作为区分度指标
            double spamRatio = (stats.spamCount + 0.1) / (spamDocCount + 0.2);
            double hamRatio = (stats.hamCount + 0.1) / (hamDocCount + 0.2);
            double score = Math.abs(Math.log(spamRatio / hamRatio));
            scores.put(word, score);
        }

        return scores.entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(n)
            .toList();
    }

    public static void main(String[] args) {
        NaiveBayesSpamFilter filter = new NaiveBayesSpamFilter();

        // 构建训练数据集
        List<String> trainDocs = new ArrayList<>();
        List<Boolean> trainLabels = new ArrayList<>();

        // 垃圾邮件样本
        String[] spamSamples = {
            "Congratulations! You have won a free iPhone. Click here to claim your prize now!",
            "URGENT: Your account has been compromised. Verify your password immediately.",
            "Get rich quick! Earn $5000 per week working from home. Limited time offer!",
            "You are the lucky winner of our lottery. Contact us to receive your cash reward.",
            "Buy cheap viagra pills online. Best prices guaranteed. Order now!",
            "Free gift waiting for you. Act now before this amazing deal expires today.",
            "Your PayPal account has been suspended. Click the link below to restore access.",
            "Make money fast with this secret investment strategy. Guaranteed returns!",
            "Weight loss miracle pill. Lose 20 pounds in one week without exercise!",
            "Dear customer, your package delivery failed. Pay shipping fee to reschedule."
        };

        // 正常邮件样本
        String[] hamSamples = {
            "Hi John, can we reschedule our meeting to Thursday afternoon? Let me know.",
            "The quarterly report is attached for your review. Please send feedback by Friday.",
            "Thank you for your order. Your package will arrive within 3-5 business days.",
            "Reminder: Team lunch tomorrow at noon in the conference room.",
            "I have updated the project documentation. Please review the latest changes.",
            "Hello, I am writing to inquire about the software engineer position posted online.",
            "The server maintenance is scheduled for this weekend. Services may be temporarily unavailable.",
            "Please find the meeting minutes from yesterday attached below.",
            "Happy birthday! Wishing you a wonderful year ahead filled with success.",
            "Your subscription renewal is due next month. Please update your payment information."
        };

        for (String doc : spamSamples) {
            trainDocs.add(doc);
            trainLabels.add(true);
        }
        for (String doc : hamSamples) {
            trainDocs.add(doc);
            trainLabels.add(false);
        }

        // 训练模型
        filter.train(trainDocs, trainLabels);

        // 测试预测
        System.out.println("\\n=== 预测测试 ===");
        String[] testMails = {
            "Congratulations winner! Click here to claim your free prize money now!",
            "Can we discuss the budget proposal in tomorrow's meeting?",
            "URGENT: Win cash reward by clicking this exclusive link today!",
            "The source code has been committed to the repository for review."
        };

        for (String mail : testMails) {
            boolean isSpam = filter.predict(mail);
            double score = filter.predictScore(mail);
            System.out.printf("[%s] 置信度: %.2f%% - %.50s...%n", 
                isSpam ? "垃圾邮件" : "正常邮件", 
                score * 100, 
                mail);
        }

        // 输出最重要的特征词
        System.out.println("\\n=== 最重要的区分特征 ===");
        var topFeatures = filter.getTopFeatures(10);
        for (var entry : topFeatures) {
            WordStats stats = filter.wordStats.get(entry.getKey());
            System.out.printf("  %s (垃圾:%d, 正常:%d, 区分度:%.2f)%n",
                entry.getKey(), stats.spamCount, stats.hamCount, entry.getValue());
        }
    }
}

算法优化技巧

1. 对数转换避免下溢

当文档很长时,大量概率相乘会导致浮点数下溢为0。通过对数转换将乘法变为加法:

$$\log P(C|X) = \log P(C) + \sum_{i} \log P(x_i|C)$$

2. 拉普拉斯平滑

对于训练集中未出现过的词,直接使用0概率会导致整个后验概率为0。拉普拉斯平滑:

$$P(w|C) = \frac{\text{count}(w, C) + 1}{\text{total}(C) + |V|}$$

3. 停用词过滤

高频但无区分度的词(如 the, is, a)会引入噪声,应预先过滤。

复杂度分析

阶段 时间复杂度 空间复杂度
训练 O(N · L) O(
预测 O(L) O(1)

其中 N 为训练文档数,L 为平均文档长度,|V| 为词汇表大小。

应用场景扩展

朴素贝叶斯不仅适用于垃圾邮件过滤,还广泛应用于:
– 情感分析:判断评论是正面还是负面
– 新闻分类:将文章自动归类到不同主题
– 医学诊断:根据症状预测疾病概率
– 垃圾评论检测:识别社交媒体中的垃圾信息

朴素贝叶斯以其简单、高效、可解释性强的特点,成为文本分类任务的首选基线算法。