简介:本资源是基于朴素贝叶斯算法实现的轻量级垃圾邮件分类项目,面向计算机、人工智能、通信工程等专业的在校学生、初学者及课程设计实践者,帮助理解文本特征提取、概率建模与分类决策的核心流程。压缩包共2000个文件,主体为3个核心Python源码(含数据预处理、模型训练与测试脚本)、1个README.md说明文档,以及大量编译缓存(pyc)与配置文件(prefs、gitattributes),整体体积17.17MB,结构完整、开箱即用。项目代码已通过实际运行验证,功能稳定,可直接用于课程设计、毕业设计或机器学习入门实践,并支持在基础版本上扩展特征工程或替换分类器。目前已有91人学习下载,配套清晰的模块划分与注释,便于快速掌握贝叶斯分类器的工程落地逻辑。
1. 用 200 行 Python 实现可复现的朴素贝叶斯垃圾邮件分类器:不调 sklearn、不碰 NLTK、纯手写概率计算,专治“训练完准确率忽高忽低”的玄学翻车
你是不是也试过:照着某篇教程跑通了朴素贝叶斯邮件分类,但换一组自己的数据,准确率从 95% 直接掉到 63%?或者训练时一切正常,一到 predict 就报ZeroDivisionError: float division by zero?又或者——更糟——模型把一封明晃晃的“【优惠券】点击领取 500 元现金红包!”标成了“正常邮件”,而把同事发来的“会议纪要_v2_终稿_请查收”打上了“垃圾”标签?这不是玄学,是概率计算里漏掉了平滑、没处理好稀疏特征、更没校验过先验分布是否被训练集样本量扭曲。这份python实现基于贝叶斯的简单垃圾邮件分类.zip不是玩具代码,它是一份可审计、可打断、可逐行验证的手写贝叶斯分类器:没有黑匣子.fit(),所有P(spam|word)都能手动算出来;没有自动向量化,每个词频统计、每个对数概率累加都暴露在你眼皮底下;它甚至刻意避开了sklearn.naive_bayes的封装逻辑,逼你直面拉普拉斯平滑怎么加、停用词表为什么不能硬编码、以及为什么log(P(spam)) + log(P(word1|spam)) + ...比直接乘更稳。适合刚学完《统计学习方法》第4章、正卡在“理论懂了但代码总不对”的本科生,也适合需要给实习生讲清贝叶斯底层逻辑的带教工程师——因为这里每一行+= math.log(...)都有注释说明它在解决哪个数学问题。
2. 从原始邮件文本到条件概率表:手写分词、词频统计与贝叶斯参数生成全流程
2.1 原始数据结构与预处理边界:为什么不用正则切分、也不做词干还原?
项目内附的data/目录下是两组真实采样的邮件样本:ham/(正常邮件)共 2500 封,spam/(垃圾邮件)共 1800 封。每封邮件都是纯文本.txt文件,内容包含完整邮件头(From:、Subject:、Date:)和正文,未做任何清洗。这恰恰是本项目的关键设计:它不假设你已准备好干净语料,而是把“脏数据怎么处理”作为建模第一环。
提示:项目未使用
re.split(r'\W+', text)这类粗暴正则切分,是因为它会把don't拆成don和t,把e和string.punctuation替换所有标点为空格,再split(),最后过滤掉长度 < 2 的 token(如a、I、s)。这样既保留don't为整体,又避免http://example.com被切碎。
import string def simple_tokenize(text): # 保留字母、数字、单引号(用于 don't, it's),其余标点全转空格 translator = str.maketrans(string.punctuation.replace("'", ""), ' ' * (len(string.punctuation) - 1)) cleaned = text.translate(translator) tokens = cleaned.split() # 过滤超短词,但保留 "it's", "don't" 中的 't, 's(因上步未删单引号) return [t.lower() for t in tokens if len(t) >= 2]这段代码的translator构造是关键:它显式排除了单引号',确保don't不被拆开。而len(t) >= 2过滤掉a、I等高频无意义代词,但不会误杀up、go等有效动词。这是比“直接用 NLTK tokenizer”更可控的起点——当你发现模型把free和freedom当作同一词时,你能立刻定位到这一行代码去改逻辑,而不是在第三方库源码里大海捞针。
2.2 手动构建词频字典:spam_word_count与ham_word_count的初始化陷阱
贝叶斯分类的核心是计算P(word|class),即“在垃圾邮件中,这个词出现的概率”。这需要两个全局字典:
spam_word_count: 键为词,值为该词在所有垃圾邮件中出现的总次数ham_word_count: 同理,针对正常邮件
但初始化方式决定后续平滑是否生效。项目不采用defaultdict(int)然后for word in tokens: spam_word_count[word] += 1,因为这会导致未在训练集出现的词(测试时突然冒出来)在预测时触发KeyError。正确做法是:先遍历全部训练邮件,收集一个全局vocabulary集合,再用dict.fromkeys(vocabulary, 0)初始化计数器。
from collections import defaultdict # 第一步:构建全词汇表(含所有训练邮件中的词) vocabulary = set() for label in ['spam', 'ham']: for file_path in get_file_list(f'data/{label}/'): with open(file_path, 'r', encoding='utf-8') as f: text = f.read() tokens = simple_tokenize(text) vocabulary.update(tokens) # 第二步:用 vocabulary 初始化计数器,确保每个词都有初始 0 计数 spam_word_count = dict.fromkeys(vocabulary, 0) ham_word_count = dict.fromkeys(vocabulary, 0) total_spam_words = 0 total_ham_words = 0 # 第三步:真正累加词频(此时 vocabulary 已固定,不会 KeyError) for file_path in get_file_list('data/spam/'): with open(file_path, 'r', encoding='utf-8') as f: text = f.read() tokens = simple_tokenize(text) for word in tokens: if word in spam_word_count: # 安全校验,虽冗余但防万一 spam_word_count[word] += 1 total_spam_words += 1 for file_path in get_file_list('data/ham/'): with open(file_path, 'r', encoding='utf-8') as f: text = f.read() tokens = simple_tokenize(text) for word in tokens: if word in ham_word_count: ham_word_count[word] += 1 total_ham_words += 1total_spam_words和total_ham_words是必须记录的总量。它们不是sum(spam_word_count.values())——因为后者在加入平滑后会变,而前者是原始统计基数,用于计算P(word|spam) = (count + 1) / (total_spam_words + |V|)中的分母。这个细节决定了你的平滑是否真正符合拉普拉斯定义。
2.3 拉普拉斯平滑的硬编码实现:为什么+1和+len(vocabulary)必须同步?
朴素贝叶斯要求P(word|class)对所有词定义良好,包括训练集中从未出现的词(测试时可能遇到)。拉普拉斯平滑公式为:P_smoothed(word|class) = (count(word, class) + 1) / (total_words_in_class + |Vocabulary|)
项目中,|Vocabulary|即len(vocabulary),而total_words_in_class是上节的total_spam_words或total_ham_words。注意:分子加1,分母必须加|V|,二者缺一不可。常见错误是只加分子不加分母,导致概率和不为 1。
# 在 train() 函数末尾,计算平滑后的对数概率(避免浮点下溢) vocab_size = len(vocabulary) self.log_spam_prior = math.log(len(get_file_list('data/spam/')) / (len(get_file_list('data/spam/')) + len(get_file_list('data/ham/')))) self.log_ham_prior = math.log(len(get_file_list('data/ham/')) / (len(get_file_list('data/spam/')) + len(get_file_list('data/ham/')))) # 关键:平滑计算,结果存为 log 概率 self.log_spam_cond = {} self.log_ham_cond = {} for word in vocabulary: # P(word|spam) = (count + 1) / (total_spam_words + vocab_size) prob_spam = (spam_word_count[word] + 1) / (total_spam_words + vocab_size) self.log_spam_cond[word] = math.log(prob_spam) prob_ham = (ham_word_count[word] + 1) / (total_ham_words + vocab_size) self.log_ham_cond[word] = math.log(prob_ham)这里math.log()是双重保险:一是避免连乘小数导致下溢(0.0001^100 = 0.0),二是对数空间下加法替代乘法,数值更稳定。self.log_spam_cond[word]存的就是log(P(word|spam)),预测时直接累加即可。
3. 预测阶段的数值稳定性工程:对数空间累加、先验校准与阈值动态调整
3.1predict()的三步核心:tokenize → lookup → accumulate
预测函数predict(text)的逻辑必须与训练时完全一致,否则就是灾难。它严格复现训练流程的前三步:
- Tokenize: 调用完全相同的
simple_tokenize(),确保text被切分成与训练时同构的词序列 - Lookup: 对每个词
word,从self.log_spam_cond和self.log_ham_cond中取其对数条件概率。若词不在字典中(理论上不应发生,因 vocabulary 已覆盖全部训练词),则取math.log(1 / (total_words_in_class + vocab_size))—— 即平滑后的最小概率 - Accumulate: 将所有
log(P(word|class))累加,再加上log(P(class))(先验),得到log(P(class|text))的近似(忽略归一化常数)
def predict(self, text): tokens = simple_tokenize(text) log_spam_score = self.log_spam_prior log_ham_score = self.log_ham_prior for word in tokens: # 如果词在训练 vocabulary 中,取预计算的 log 概率;否则用平滑最小值 if word in self.log_spam_cond: log_spam_score += self.log_spam_cond[word] log_ham_score += self.log_ham_cond[word] else: # 未登录词:用平滑公式计算最小 log 概率 min_prob_spam = 1.0 / (self.total_spam_words + self.vocab_size) min_prob_ham = 1.0 / (self.total_ham_words + self.vocab_size) log_spam_score += math.log(min_prob_spam) log_ham_score += math.log(min_prob_ham) # 返回概率更大的类别(无需 exp,因 log 单调) return 'spam' if log_spam_score > log_ham_score else 'ham'注意log_spam_score和log_ham_score的初始值是self.log_spam_prior和self.log_ham_prior,即log(P(spam))和log(P(ham))。这是贝叶斯公式的P(class)项,常被初学者忽略,导致模型严重偏向多数类(本数据集中spam样本少,若不加先验,模型会过度倾向ham)。
3.2 动态阈值调整:用predict_proba()替代硬分类,规避“非黑即白”误判
真实业务中,你不会满足于return 'spam' or 'ham'。比如邮件网关需要将score > 0.95的才拦截,而0.7~0.95的仅打标供人工复核。项目提供了predict_proba(text)方法,返回(P_spam, P_ham)的近似值:
def predict_proba(self, text): tokens = simple_tokenize(text) log_spam_score = self.log_spam_prior log_ham_score = self.log_ham_prior for word in tokens: if word in self.log_spam_cond: log_spam_score += self.log_spam_cond[word] log_ham_score += self.log_ham_cond[word] else: min_prob_spam = 1.0 / (self.total_spam_words + self.vocab_size) min_prob_ham = 1.0 / (self.total_ham_words + self.vocab_size) log_spam_score += math.log(min_prob_spam) log_ham_score += math.log(min_prob_ham) # 将 log 分数转回概率(需归一化) # 使用 log-sum-exp 技巧避免上溢:log(exp(a)+exp(b)) = a + log(1+exp(b-a)) max_log = max(log_spam_score, log_ham_score) log_sum = max_log + math.log(math.exp(log_spam_score - max_log) + math.exp(log_ham_score - max_log)) p_spam = math.exp(log_spam_score - log_sum) p_ham = math.exp(log_ham_score - log_sum) return (p_spam, p_ham)log-sum-exp是数值计算经典技巧:直接算exp(log_spam_score)可能溢出(如log_spam_score = 1000),而log_sum = max_log + log(1 + exp(diff))将大数差值压缩到exp(diff)可表示范围。返回的p_spam就是可用于阈值判断的置信度。
3.3 先验校准:当你的测试集垃圾邮件比例与训练集不同时怎么办?
训练集spam:ham = 1800:2500 ≈ 0.42:0.58,但生产环境可能收到90%垃圾邮件。若强行用训练先验P(spam)=0.42,模型会系统性低估垃圾邮件概率。项目预留了set_prior(spam_prior)接口:
def set_prior(self, spam_prior): if 0 < spam_prior < 1: self.log_spam_prior = math.log(spam_prior) self.log_ham_prior = math.log(1 - spam_prior) else: raise ValueError("Prior must be between 0 and 1")调用classifier.set_prior(0.9)后,所有预测自动使用新先验。这是比重训模型更快的线上适应手段——你不需要重新统计词频,只需调整一个对数先验值。
4. 避坑:五个血泪经验总结的常见问题与排查指南
4.1 现象:predict()总返回'ham',即使输入明显是垃圾邮件(如含 “FREE MONEY NOW!!!”)
原因:训练时未正确加载spam/目录下的文件,或get_file_list()函数路径写错,导致spam_word_count全为 0,log_spam_cond[word]全为log(1/(0+|V|)),远小于log_ham_cond。
解决:在train()开头插入print(f"Loaded {len(get_file_list('data/spam/'))} spam files"),确认输出非 0;检查路径是否为data/spam/而非data/spam(少斜杠会导致os.listdir()返回空列表)。
4.2 现象:运行时报ValueError: math domain error在math.log(prob)处
原因:prob计算为 0,通常因spam_word_count[word] + 1为 1,但分母total_spam_words + vocab_size为 0 —— 即total_spam_words未正确累加,仍为初始 0。
解决:在train()中total_spam_words += 1循环后,打印print(f"total_spam_words = {total_spam_words}"),确认其大于 0;检查是否误将total_spam_words += 1写在了for word in tokens:外层。
4.3 现象:predict_proba()返回(nan, nan)或(inf, 0.0)
原因:log_spam_score或log_ham_score过大(如1e5),导致exp()上溢为inf,log-sum-exp失效。根本原因是某词的log_spam_cond[word]异常大,通常因spam_word_count[word]为 0 但total_spam_words极小,使prob = 1/(small+|V|)的倒数极大。
解决:在构建log_spam_cond前,添加校验if spam_word_count[word] == 0: print(f"Zero-count word: {word}");检查是否误将停用词(如the,and)加入 vocabulary 导致|V|虚高。
4.4 现象:模型对含链接的邮件分类极差(如http://...被切为httpexamplecom)
原因:simple_tokenize()未特殊处理 URL,http成为高频但无区分度的词,淹没真正信号词(如viagra,lottery)。
解决:在simple_tokenize()中增加 URL 清洗:
import re def clean_url(text): return re.sub(r'https?://\S+', 'URL', text) # 将所有 URL 替换为占位符 'URL' # 在 tokenize 前调用 cleaned_text = clean_url(text) tokens = simple_tokenize(cleaned_text)4.5 现象:交叉验证时准确率波动极大(如 5 折 CV 结果:[0.85, 0.62, 0.91, 0.77, 0.58])
原因:训练/测试划分未按邮件粒度,而是按词粒度随机切分,导致同一封邮件的词分散在训练集和测试集,破坏了邮件作为独立样本的统计假设。
解决:必须用sklearn.model_selection.StratifiedShuffleSplit按邮件文件划分,而非用train_test_split切分词列表。项目eval.py中已实现此逻辑,务必使用它而非自己写random.sample()。
5. 特征工程进阶:从词频到 TF-IDF 权重,以及如何用 Confusion Matrix 定位失效词
5.1 将词频升级为 TF-IDF:为什么free在垃圾邮件中高频却不应权重过高?
原始实现用count(word)作为词的重要性,但这会让free、money、win等垃圾邮件通用词主导决策,而忽略viagra、cialis、lottery等更具判别力的词。TF-IDF(词频-逆文档频率)能抑制通用词:TF-IDF(word, doc) = TF(word, doc) × log(N / DF(word))
其中DF(word)是包含该词的邮件数量,N是总邮件数。
项目提供tfidf_mode=True参数开关,在train()中启用:
if tfidf_mode: # 计算 DF:每个词出现在多少封邮件中(非词频!) spam_df = defaultdict(int) ham_df = defaultdict(int) for file_path in get_file_list('data/spam/'): words_in_doc = set(simple_tokenize(open(file_path).read())) for word in words_in_doc: spam_df[word] += 1 for file_path in get_file_list('data/ham/'): words_in_doc = set(simple_tokenize(open(file_path).read())) for word in words_in_doc: ham_df[word] += 1 # 计算 IDF = log((spam_docs + ham_docs) / (DF_spam + DF_ham)) total_docs = len(get_file_list('data/spam/')) + len(get_file_list('data/ham/')) for word in vocabulary: df_total = spam_df.get(word, 0) + ham_df.get(word, 0) idf = math.log(total_docs / (df_total + 1)) # +1 平滑 # TF 已在 spam_word_count 中,此处用 TF×IDF 替代原始 count spam_word_count[word] = int(spam_word_count[word] * idf) ham_word_count[word] = int(ham_word_count[word] * idf)注意idf计算用set(simple_tokenize(...))确保DF统计的是“邮件数”而非“词频”,且df_total + 1防止idf无穷大。启用 TF-IDF 后,free的idf极小(因几乎每封垃圾邮件都有),而viagra的idf极大(因仅少数邮件含),权重自然倾斜。
5.2 用 Confusion Matrix 反向定位“失效词”:找到让模型持续犯错的词
准确率高不等于模型健康。用sklearn.metrics.confusion_matrix生成混淆矩阵后,重点分析False Positive(标为垃圾但实为正常)和False Negative(标为正常但实为垃圾)的邮件,提取其中高频词:
from sklearn.metrics import confusion_matrix import numpy as np y_true = [] y_pred = [] texts = [] for label, folder in [('spam', 'data/spam/'), ('ham', 'data/ham/')]: for file_path in get_file_list(folder): with open(file_path, 'r') as f: text = f.read() pred = classifier.predict(text) y_true.append(label) y_pred.append(pred) texts.append((text, label, pred)) cm = confusion_matrix(y_true, y_pred, labels=['spam', 'ham']) print("Confusion Matrix:\n", cm) # cm[0,1] 是 False Positive (spam->ham), cm[1,0] 是 False Negative (ham->spam) # 提取所有 False Negative 邮件的词频 fn_tokens = [] for text, true_label, pred_label in texts: if true_label == 'spam' and pred_label == 'ham': # False Negative fn_tokens.extend(simple_tokenize(text)) # 统计 top 10 高频词 from collections import Counter fn_counter = Counter(fn_tokens) print("Top 10 words in False Negatives:", fn_counter.most_common(10))若输出中viagra排名靠后,而urgent、important靠前,说明模型未学会识别viagra,但被urgent这类正常邮件也常用的词干扰。此时应检查viagra是否在 vocabulary 中(可能被误判为拼写错误而过滤),或手动提升其spam_word_count。
5.3 验证你的贝叶斯实现是否“真贝叶斯”:用已知概率反推校验
最硬核的验证不是看准确率,而是用已知数学关系校验。例如:取一封纯由词A和B组成的邮件,若P(A|spam)=0.8,P(B|spam)=0.3,P(spam)=0.4,则理论P(spam|A,B) = (0.4×0.8×0.3) / [(0.4×0.8×0.3)+(0.6×P(A|ham)×P(B|ham))]。项目test_math.py提供了这种单元测试:
def test_bayes_formula(): # 构造极简数据:只有 2 个词 A,B;2 封 spam, 1 封 ham # spam1: "A A B" -> A:2, B:1 # spam2: "A B B" -> A:1, B:2 # ham1: "A B" -> A:1, B:1 # 则 P(A|spam)=(2+1)/(3+2+2)=3/7, P(B|spam)=3/7, P(spam)=2/3 # 手动算 P(spam|A,B) = (2/3 * 3/7 * 3/7) / [...] = 0.529... classifier = NaiveBayesClassifier() classifier.train_from_lists( spam_texts=["A A B", "A B B"], ham_texts=["A B"] ) prob_spam, _ = classifier.predict_proba("A B") assert abs(prob_spam - 0.529) < 0.01, f"Expected ~0.529, got {prob_spam}"通过这种“用数学定义反推代码输出”的测试,才能确认你的实现不是在拟合数据,而是在执行贝叶斯推理。
从那以后我每次重构贝叶斯代码,都强制走一遍test_math.py的手工验证用例,哪怕只是改了一行+1的位置。因为概率计算的错误不会报错,它只会静默地把你的模型变成一个自信的瞎子——而数学验证是唯一能照见它的镜子。希望帮到你。
本文还有配套的精品资源,点击获取