ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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)这类粗暴正则切分是因为它会把dont拆成don和t把e-mail变成e和mail严重破坏语义。实际采用的是基于空格标点保留的保守分词先用string.punctuation替换所有标点为空格再split()最后过滤掉长度 2 的 token如a、I、s。这样既保留dont为整体又避免http://example.com被切碎。import string def simple_tokenize(text): # 保留字母、数字、单引号用于 dont, its其余标点全转空格 translator str.maketrans(string.punctuation.replace(, ), * (len(string.punctuation) - 1)) cleaned text.translate(translator) tokens cleaned.split() # 过滤超短词但保留 its, dont 中的 t, s因上步未删单引号 return [t.lower() for t in tokens if len(t) 2]这段代码的translator构造是关键它显式排除了单引号确保dont不被拆开。而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(fdata/{label}/): with open(file_path, r, encodingutf-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, encodingutf-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, encodingutf-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(1exp(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全为 0log_spam_cond[word]全为log(1/(0|V|))远小于log_ham_cond。解决在train()开头插入print(fLoaded {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(ftotal_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()上溢为inflog-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(fZero-count word: {word})检查是否误将停用词如the,and加入 vocabulary 导致|V|虚高。4.4 现象模型对含链接的邮件分类极差如http://...被切为httpexamplecom原因simple_tokenize()未特殊处理 URLhttp成为高频但无区分度的词淹没真正信号词如viagra,lottery。解决在simple_tokenize()中增加 URL 清洗import re def clean_url(text): return re.sub(rhttps?://\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_modeTrue参数开关在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,B2 封 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)(21)/(322)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, fExpected ~0.529, got {prob_spam}通过这种“用数学定义反推代码输出”的测试才能确认你的实现不是在拟合数据而是在执行贝叶斯推理。从那以后我每次重构贝叶斯代码都强制走一遍test_math.py的手工验证用例哪怕只是改了一行1的位置。因为概率计算的错误不会报错它只会静默地把你的模型变成一个自信的瞎子——而数学验证是唯一能照见它的镜子。希望帮到你。本文还有配套的精品资源点击获取
返回列表