news 2026/10/3 2:44:23

200行纯Python手写朴素贝叶斯垃圾邮件分类器

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
200行纯Python手写朴素贝叶斯垃圾邮件分类器

简介:本资源是基于朴素贝叶斯算法实现的轻量级垃圾邮件分类项目,面向计算机、人工智能、通信工程等专业的在校学生、初学者及课程设计实践者,帮助理解文本特征提取、概率建模与分类决策的核心流程。压缩包共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-mail变成e和mail,严重破坏语义。实际采用的是基于空格+标点保留的保守分词:先用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 += 1

total_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)的逻辑必须与训练时完全一致,否则就是灾难。它严格复现训练流程的前三步:

  1. Tokenize: 调用完全相同的simple_tokenize(),确保text被切分成与训练时同构的词序列
  2. Lookup: 对每个词word,从self.log_spam_cond和self.log_ham_cond中取其对数条件概率。若词不在字典中(理论上不应发生,因 vocabulary 已覆盖全部训练词),则取math.log(1 / (total_words_in_class + vocab_size))—— 即平滑后的最小概率
  3. 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的位置。因为概率计算的错误不会报错,它只会静默地把你的模型变成一个自信的瞎子——而数学验证是唯一能照见它的镜子。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 2:44:22

工业设备RUL预测与故障诊断端到端工程实践

简介&#xff1a;本资源是一套面向工业智能运维领域的Python剩余使用寿命&#xff08;RUL&#xff09;预测与故障诊断代码框架&#xff0c;适用于具备基础Python和机器学习知识的工程师、研究生及科研人员&#xff0c;解决设备退化建模、早期故障识别与预测性维护等实际工程问题…

作者头像 李华
网站建设 2026/10/3 2:44:20

网页文本分类实战:HTML清洗、NLPIR分词与TF-IDF+SVM流水线

简介&#xff1a;本资源是一个面向Python初学者与NLP入门者的文本分类实践项目&#xff0c;聚焦自然语言处理中的核心任务——文本自动归类&#xff0c;适用于课程设计、竞赛备赛及小型业务场景&#xff08;如新闻分类、评论情感判别&#xff09;。压缩包共30个文件&#xff0c…

作者头像 李华
网站建设 2026/10/3 2:44:19

Spark 3.0从入门到精通:核心组件、环境搭建与性能调优实战指南

简介&#xff1a;面向零基础或刚接触大数据开发的读者&#xff0c;这份课程代码与笔记以2020年发布的最新稳定版Spark为核心&#xff0c;用1至8天学习路线串起集群环境搭建、Spark Core核心计算、Spark Streaming流式处理、Structured Streaming结构化流、Spark SQL分析、多语言…

作者头像 李华
网站建设 2026/10/3 2:43:36

Java实战:同城按摩养生系统的订单状态机与LBS派单设计

做同城服务项目这几年&#xff0c;我越来越觉得“按摩养生系统”这类本地生活项目&#xff0c;是最适合拿来练手Java实战落地的场景之一。它不像电商那样纯拼并发&#xff0c;也不像企业级OA那样追求流程堆砌&#xff0c;而是把预约、派单、支付、会员、位置服务、订单状态机这…

作者头像 李华
网站建设 2026/10/3 2:41:23

读《前线部署工程师》笔记(一):FDE,就是把工程师送到问题旁边

概述 这一两年,FDE(Forward Deployed Engineer,前线部署工程师)突然火了:招聘平台上的相关岗位一年涨了七倍多,OpenAI、Anthropic 都在抢人,有风投直接称它为"科技行业最热门的岗位"。一边是企业 AI 项目大面积"成功上线却没人用",一边是这个岗位…

作者头像 李华
网站建设 2026/10/3 2:40:49

【股票交易】第 6 章 汇率、美元与全球资本流动

回到目录 文章目录 6.1 汇率是一种相对价格 先确认汇率的报价方向 换一种报价方向,百分比也会改变 货币强弱需要明确比较对象 名义汇率与实际相对价格 资产回报与汇率回报如何合并 6.2 国际收支如何连接贸易与投资 经常账户与金融账户记录什么 经常账户逆差与对外净融资 净融资…

作者头像 李华