简介:这是一个基于朴素贝叶斯与支持向量机(SVM)的垃圾邮件识别系统,适合机器学习入门者、毕业设计及分类项目实战人群。项目通过对正常邮件和垃圾邮件进行语料训练,完成词频统计与模型对比,实现邮件自动分类。资源共2000个文件,以80张jpg图片、5个py源码文件为主,另含zip、txt、pkl等类型,整体压缩包28.64MB,目录覆盖数据模块、模型构建和附加功能。代码在Python 3.6以上环境运行,可选用Anaconda或Linux虚拟机;附加模块需要对接pytesseract文字引擎和百度云图像文字识别服务,便于扩展OCR识别场景。已有1359人学习下载,适合借此掌握朴素贝叶斯特征统计、SVM分类器训练、模型保存与调用等完整开发思路。1. 垃圾邮件识别:让朴素贝叶斯和 SVM 同场竞技的一个可运行 Python 工程做垃圾邮件识别,最怕的不是没算法,而是有一堆算法代码却不知道整个流程怎么串起来。这个工程把机器学习里最经典的两类分类算法——朴素贝叶斯和支持向量机(SVM),从文本预处理、特征工程到模型评估完整地组织进一个 Python 工程。它不做花哨的深度模型,反而把分类算法的核心链路讲得明白,适合正在入门人工智能分类算法、或需要一个能跑通的分类项目做二次开发的人。只要把邮件数据换成你自己的语料,改一个文件路径就能重新训练,并拿到两份模型的评估报告。我的整体感觉是:跑通不难,要改得顺手,得先看懂它的目录和数据流。2. 工程解剖:目录结构、数据流和它为什么选这两类算法2.1 技术选型:为什么同时放朴素贝叶斯和 SVM一个垃圾邮件识别项目里同时放两种“对立”的算法,不是功能冗余,而是为了对比。朴素贝叶斯是生成式模型,它假设特征之间相互独立,文本场景下这个“独立”假设并不完全成立,但实际效果依然稳定,而且训练快、可解释性强。SVM 是判别式模型,直接在特征空间中找最大间隔分类面,在特征维度很高时效果往往更好,但训练成本也更高。两种算法互补,放在同一个工程里正好能对比出当前数据集上的难点在哪里。工程把两者的公共部分,也就是文本清洗、分词、向量化,统一封装成独立模块;算法部分只保留差异点。这种做法的好处是数据流只走一遍,换算法不至于把整个流程复制一份。技术选型上,工程用 scikit-learn 做模型训练和评估,jieba 做中文分词,pandas 处理原始 CSV,joblib 保存训练好的模型。整套选型非常常规,但每一层职责都清楚,这也是它能被拿来做课程设计、比赛 baseline 和业务预研的原因。为什么不自己手写朴素贝叶斯或者 SMO?手写一遍对理解公式有帮助,但要处理平滑、稀疏矩阵、交叉验证和特征对齐,工作量其实不小。这个工程的重心是完整流程,直接在 scikit-learn 基础上做工程化封装更合适。在这个场景下,深度学习也不是首选:邮件文本维度不高、样本量通常有限,传统分类器训练快、可解释性强,不容易过拟合。2.2 目录结构:每个文件负责什么spam_classifier/ ├── data/ │ ├── raw/ # 原始邮件数据,放 emails.csv │ └── processed/ # 清洗后的中间结果 ├── models/ # 保存训练好的模型和向量化器 ├── src/ │ ├── config.py # 全局配置:路径、随机种子、测试集比例 │ ├── preprocess.py # 清洗文本、分词 │ ├── feature.py # TF-IDF 向量化封装 │ ├── train_model.py # 训练入口,--model 参数切换 nb/svm │ ├── evaluate.py # 评估入口,输出分类报告和混淆矩阵 │ ├── predict.py # 单条文本预测 │ └── utils.py # 通用工具:保存模型、加载模型 ├── requirements.txt └── README.md这个目录结构把数据、训练产物和代码分开,是我比较习惯的工程分层。data/raw 只放原始数据,processed 放清洗结果,这样回放实验时能知道每一步的输入是什么;models 单独抽出来,避免训练完的 pkl 文件散落在代码目录里。src 下面按职责拆模块,不按算法拆目录,训练脚本只有两个,但代码不重复。值得单独说的是 config.py。很多项目喜欢把参数散落在各处,这个工程把路径、随机种子和测试集比例集中到一个文件里,改实验配置不用翻代码。config.py 里的关键内容大概是这样的:RANDOM_STATE = 42 TEST_SIZE = 0.2 DATA_PATH = "data/raw/emails.csv" PROCESSED_PATH = "data/processed/emails_clean.csv" MODEL_DIR = "models/" LABEL_SPAM = 1 LABEL_HAM = 0 TFIDF_MAX_FEATURES = 50000 NB_ALPHA = 0.5 SVM_C = 1.0RANDOM_STATE 固定随机种子,保证多次训练切分出的训练集和测试集一致,实验可复现;TEST_SIZE 控制测试集比例,数据量小时可以调到 0.3。MODEL_DIR 是模型输出目录,换数据集时只需要改 DATA_PATH,不用动任何训练代码。TFIDF_MAX_FEATURES、NB_ALPHA、SVM_C 分别对应特征词表上限、朴素贝叶斯平滑系数和 SVM 正则化参数,这几个值是从调参环节沉淀下来的默认配置,后面会细说它们各自影响什么。2.3 数据流:从一封邮件到一份分类报告整个工程的数据流可以拆成五个阶段,每个阶段都有明确的输入和输出。原始数据从 data/raw/emails.csv 进入,经过 preprocess.py 清洗和分词后写到 data/processed;feature.py 把分词后的文本转成 TF-IDF 矩阵;train_model.py 用这份矩阵分别训练两个模型;evaluate.py 在预留的测试集上输出指标。链路可以概括为:阶段输入输出关键模块读取原始数据emails.csvDataFrame(text、label 两列)pandas文本预处理DataFrame清洗分词后的文本preprocess.py特征工程分词文本TF-IDF 稀疏矩阵feature.py模型训练特征矩阵models/ 下的 pkl 文件train_model.py评估预测测试集分类报告、混淆矩阵evaluate.py、predict.py各阶段之间通过中间文件或内存对象传递,边界清晰。训练时读写的是 processed 目录里的清洗结果,不会像有些脚本那样把正则清洗逻辑混在训练代码里,排查问题会方便很多。换数据集时,真正要改的只有 emails.csv 的列名格式,也就是 text 和 label 两列,其余代码可以保持不动。数据流里有一个关键细节:向量化器只 fit 训练集,再 transform 测试集。如果拿全部数据的 TF-IDF 去 fit,测试集的信息在训练阶段就已经被模型间接看到,评估结果会虚高,这就是典型的数据泄漏。工程在 train_model.py 里用 train_test_split 先切分再 fit 向量化器,属于完全正确的做法。后面你想做交叉验证时,也要小心这个顺序:GridSearchCV 的 Pipeline 会自动避免泄漏,但如果在搜索之前就 fit 了向量化器,那结果同样不可信。3. 核心算法模块拆解:从文本到分类报告的完整实现3.1 文本预处理:清洗邮件内容、中文分词邮件原文不能直接丢给算法。它包含 HTML 标签、URL、数字和各类符号,这些内容对判断垃圾邮件不是一点用没有,但直接用原始字符串很难让模型学到稳定规律。preprocess.py 里最核心的清洗函数大概长这样:import re import jieba def clean_text(raw: str) -> str: # 去掉 HTML 标签和实体 text = re.sub(r"<[^>]+>", "", raw) text = re.sub(r"&[a-zA-Z]+;", "", text) # 去掉 URL 和邮箱地址 text = re.sub(r"https?://\S+|www\.\S+", "", text) text = re.sub(r"\S+@\S+", "", text) # 去掉多余空白并转小写 text = re.sub(r"\s+", " ", text).strip() return text.lower() def tokenize(text: str) -> list: cleaned = clean_text(text) # 只保留英文字母、数字和中文汉字,去掉标点 cleaned = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fa5]", " ", cleaned) return [w for w in jieba.lcut(cleaned) if len(w.strip()) > 1]clean_text 用多个正则依次处理不同噪音:先删 HTML 标签,避免<a>这类内容干扰;再删 URL 和邮箱地址,这两类内容在正常邮件里也有,在垃圾邮件里更多,但直接作为特征会让模型记住具体链接而不是通用模式;最后统一转小写,降低同一个词的大小写变体对词表的影响。tokenize 里有两个关键过滤条件:len(w.strip()) > 1会丢掉单字词,对中文来说“奖”“钱”这类单字有区分力,但也带来噪声,工程默认保留长度大于 1 的词;阈值在数据量大时可以调,如果你发现模型对“送”“领”这类单字不敏感,可以考虑把这个长度条件放宽到 1。另外,清洗和分词要分开看待,清洗负责去噪音,分词负责把连续文本切成词。jieba.lcut 返回的是词列表,后续 TF-IDF 向量化器可以直接把它作为 tokenizer 传入,保证训练和预测用的是同一套切词逻辑,这一点非常关键。3.2 特征工程:TF-IDF 向量化和关键参数分词后的句子没法直接训练,必须变成数值向量。工程用 TfidfVectorizer,它不只是数词频,还通过 IDF 降低“的”“是”这类高频词的权重。特征工程的封装写在 feature.py 里:from sklearn.feature_extraction.text import TfidfVectorizer def build_vectorizer(): return TfidfVectorizer( tokenizer=jieba.lcut, ngram_range=(1, 2), max_features=50000, min_df=2, sublinear_tf=True, )tokenizer 直接传 jieba.lcut,让向量化器内部切词和预处理阶段保持一致;如果不传,TfidfVectorizer 默认用空格切分,对中文句子基本是废的。ngram_range=(1, 2) 表示同时保留单个词和相邻两个词的组合,像“领取”“点击领取”都能成为特征,能捕捉一部分短语级别的信息。min_df=2 过滤掉只出现一次的词汇,减少词表噪声;sublinear_tf=True 让词频用 1 + log(tf) 表示,防止某个词在一封长邮件里反复出现导致数值过大。TF-IDF 的 fit 只能作用于训练集。如果对整个语料 fit,测试集的词频和文档频率信息都参与了训练,评估结果会有水分。工程的做法是先切分,再 vectorizer.fit_transform(X_train),最后 transform(X_test)。这个细节在交叉验证里更容易被忽略,很多人直接把 TfidfVectorizer 放在整个 DataFrame 上 fit 完再做交叉验证,结果就是每一折都用了全量数据的信息,F1 虚高得自己都不信。3.3 朴素贝叶斯实现:MultinomialNB 与 Pipeline 封装朴素贝叶斯的原理一句话能说清:计算“这封邮件是垃圾”在给定词序列下的后验概率,分母对所有类别相同,所以只需看先验概率 P(垃圾) 乘上每个词在垃圾邮件中的条件概率乘积。sklearn 的 MultinomialNB 会对每个词的条件概率做平滑,避免某个词在训练集中没出现就把整个概率乘成 0。训练部分封装在 train_model.py 里:from sklearn.pipeline import Pipeline from sklearn.naive_bayes import MultinomialNB from feature import build_vectorizer def build_nb_pipeline(): return Pipeline([ ("tfidf", build_vectorizer()), ("clf", MultinomialNB(alpha=0.5)), ])把向量化和分类器放进同一个 Pipeline,训练时 vectorizer.fit 和 model.fit 顺序执行,预测时 pipeline.predict 会自动用相同的 vectorizer 做 transform,不会出现“训练时用 TF-IDF、预测时忘了向量化”的低级错误。alpha 是平滑系数,默认值是 1.0,这里调成 0.5 意味着给词频计数加的伪计数更小,模型更依赖真实数据分布。alpha 太小容易过拟合,太大会模糊不同特征之间的差异,通常可以在 0.1、0.5、1.0 之间做网格搜索。训练朴素贝叶斯的命令是python src/train_model.py --model nb,脚本内部分三步:读入清洗后的数据、切分训练集和测试集、fit pipeline。训练完成后把整个 pipeline 保存为 models/nb_pipeline.pkl,预测时只加载这一个文件。想深入理解公式的人可以在 scikit-learn 之外再试着手写一版多项式贝叶斯,数值结果直接和这里的 baseline 对比。3.4 SVM 分类器:LinearSVC、参数 C 与评估报告SVM 部分用的是 LinearSVC,也就是线性核的支持向量分类器。为什么不选默认的 SVC?文本向量化后的特征维度动辄几万,数据高度稀疏,这种情况下线性分类面通常已经够用,再用高斯核计算相似度矩阵会非常慢。工程里 SVM 的 pipeline 和 NB 结构一样,只有分类器不同:from sklearn.svm import LinearSVC def build_svm_pipeline(): return Pipeline([ ("tfidf", build_vectorizer()), ("clf", LinearSVC(C=1.0, class_weight=None)), ])C 是正则化强度的倒数:C 越小对误分类的惩罚越轻,模型更偏向简单的分类面,泛化效果更好;C 越大模型越努力拟合训练集,容易过拟合。垃圾邮件任务里先拿 C=1.0 跑基线,再用 GridSearchCV 在 0.1 到 10 之间搜索是常规流程。class_weight 在邮件类别不均衡时非常有用,当垃圾邮件远比正常邮件少时,把它设成 balanced 会让模型自动提高少数类的误分类代价,从而提升垃圾邮件的召回率。训练完成后,evaluate.py 会在测试集上输出评估结果:from sklearn.metrics import classification_report, confusion_matrix def evaluate(pipeline, X_test, y_test): y_pred = pipeline.predict(X_test) report = classification_report( y_test, y_pred, target_names=["正常邮件", "垃圾邮件"] ) cm = confusion_matrix(y_test, y_pred) return report, cmclassification_report 会同时给出精确率、召回率和 F1 值。这里要特别关注“垃圾邮件”这一类,而不是整体准确率:正常邮件占多数时,模型即使完全不识别垃圾邮件,整体准确率也能很高,但业务上漏掉一封垃圾邮件的代价往往比误伤一封正常邮件更大。confusion_matrix 返回一个 2x2 矩阵,行是真值、列是预测值,四个格子的含义分别是正常邮件判对、正常邮件误判为垃圾、垃圾邮件漏判、垃圾邮件判对。每次改完参数先看这四个数字的变化,比只看一条准确率曲线直观得多。再补一个工程细节:LinearSVC 和 MultinomialNB 的输出接口不一样,NB 可以用 predict_proba 拿到概率,SVM 只有 decision_function 的分数。分数大于 0 判垃圾,小于 0 判正常。这个分数不是概率,但可以作为阈值调整的依据。二次开发时如果想给不确定的邮件做人工复核,可以按这个分数划分灰色区间。4. 跑通与排坑:环境准备、训练评估和三处高频翻车点4.1 环境准备与依赖安装先把环境独立出来。直接在全局 Python 环境里装依赖,后面装其他项目时容易冲突。在项目根目录执行:python -m venv venvLinux 或 macOS 下激活虚拟环境用source venv/bin/activate,Windows 下用venv\Scripts\activate。激活后命令行前面会出现 (venv) 标识,这时候再安装依赖就只会装进当前虚拟环境里。安装依赖只需要一条命令:pip install -r requirements.txtrequirements.txt 里包括 scikit-learn、jieba、pandas、joblib、numpy 这几个核心包。joblib 用来保存和加载训练好的模型,numpy 是 sklearn 和 pandas 的底层依赖,显式写出来保证环境一致性。安装完成后可以用一段小代码验证 sklearn 能正常导入,然后再继续。如果网络下载慢,换成你熟悉的国内 PyPI 镜像源会快很多,这一步不改动项目本身,只是环境准备时的常见操作。4.2 训练与评估:按步骤复现完整流程数据格式要符合工程约定:emails.csv 必须包含两列,一列 text 是邮件正文,一列 label,垃圾邮件标 1,正常邮件标 0。把文件放到 data/raw/ 下后,先执行预处理:python src/preprocess.py脚本会读取原始邮件、清洗文本、分词,并把处理结果写到 data/processed/emails_clean.csv。把清洗和训练分开是有意为之:之后调参不需要每轮都重新清洗原始数据,省时间也避免清洗逻辑不一致的问题。接下来训练两个模型:python src/train_model.py --model nb python src/train_model.py --model svm第一条命令训练朴素贝叶斯,第二条训练 SVM,训练产物分别保存为 models/nb_pipeline.pkl 和 models/svm_pipeline.pkl。不传 --model 时默认训练 nb。训练完成后用 evaluate.py 输出评估报告:python src/evaluate.py --model svm评估脚本会加载训练阶段留下的测试集并重新预测,输出 classification_report。只看报告不够直观,最好再用 predict.py 手测几条典型文本:python src/predict.py --text "恭喜您获得万元大奖,点击链接领取" --model svm python src/predict.py --text "本周例会安排在周三下午三点" --model svmpredict.py 内部会加载对应的 pipeline,对文本做清洗和向量化后预测,并输出类别和 decision_function 分数。第一类文本训练正常时应该判为垃圾,第二类应该判为正常。如果连这种明显差异的样本都预测错,优先查数据格式和预处理脚本,而不是急着调参。推荐把这类冒烟测试文本写成一个固定的 smoke_test.txt,每次训练完都跑一遍,确认链路没有断。4.3 避坑记录:四个高频问题和定位思路第一个高频坑是维度不匹配。现象:模型训练和保存都很顺利,加载模型预测单条文本时报特征维度不一致。原因:训练时用了自定义 tokenizer,也就是 jieba.lcut,预测时却重新 new 了一个 TfidfVectorizer 而没有 fit,或者没有走同一套向量化配置。解决:把向量化和模型封装成 Pipeline,预测时直接加载整个 pkl 文件,不单独调用 vectorizer.transform。用 Pipeline 之后这个坑基本不会再遇到。第二个坑是类别不均衡导致准确率虚高。现象:测试准确率看起来挺高,但垃圾邮件的召回率很低,大量垃圾邮件被漏掉。原因:训练集里正常邮件占绝大多数,模型只要把所有样本都判成正常就能拿到高准确率。解决:训练前先打印 y 的类别分布确认占比;train_test_split 时加 stratify=y,保证切分前后分布一致;LinearSVC 设置 class_weight="balanced";评估时重点看 classification_report 中垃圾邮件那一行的召回率和 F1。第三个坑是中文编码问题。现象:pandas 读 CSV 后文本乱码,或者预处理后所有文本长度异常短。原因:数据文件是 GBK 编码或带 BOM 头,而默认读取用的是 UTF-8。解决:读取时指定 encoding="utf-8-sig",能兼容带 BOM 的 UTF-8 文件;更稳妥的做法是把读入函数封装一次,先尝试 utf-8,失败后尝试 gbk,后续所有脚本统一调用这个封装。第四个坑是 SVM 训练慢。现象:数据量稍微大一点,训练 SVM 时等待时间明显变长。原因:用了默认的 SVC,它默认采用 RBF 核,需要计算样本两两之间的核矩阵,复杂度接近 O(n²),在几万维稀疏文本特征下完全没必要。解决:换成 LinearSVC,或者 SVC(kernel="linear"),训练时间会下降一个量级,文本分类效果基本不变。这个项目里默认选 LinearSVC 就是为了提前规避这个问题。如果发现朴素贝叶斯把所有样本都预测成垃圾邮件,优先检查清洗阶段是否把停用词过滤做过了头,导致特征词太少,模型只能依赖先验概率。处理办法是把过滤条件放宽松,或者用 ngram_range=(1, 2) 补充组合特征。5. 二次开发指南:从离线训练到可调阈值的过滤服务这套工程跑通只是起点,把模型真正用到邮件过滤里需要做三件事:调阈值、增量更新、包成批量预测接口。先说阈值。NB 输出的概率和 SVM 的 decision_function 分数都反映“垃圾程度”,但默认的 0.5 概率和 0 分不一定是最优截断点。改动思路是:在测试集上遍历阈值,画出召回率随阈值变化的曲线,选一个“宁可多召回几封垃圾邮件、允许少量误报”的阈值,再通过参数暴露出来。工程上可以写一个统一的预测函数:def predict_with_score(text, pipeline, threshold=0.0): # threshold 对 NB 是概率阈值,通常 0.5;对 SVM 是分数阈值,通常 0.0 if hasattr(pipeline.named_steps["clf"], "decision_function"): score = pipeline.decision_function([text])[0] return int(score >= threshold), score proba = pipeline.predict_proba([text])[0][1] return int(proba >= threshold), probathreshold 参数对两种模型含义不同,但都可以通过遍历测试集找到适合自己的截断点。第二个方向是增量更新。垃圾邮件话术变化很快,隔一段时间就会出现大量新词,模型会逐渐失效。朴素贝叶斯支持 partial_fit,可以分批喂入新标注数据,但要注意 TF-IDF 向量化器的词表不会随之更新,新词会被忽略。常见做法是每天定时收集新样本做全量重训练,遇到突发的垃圾邮件活动先用规则词库临时拦截,等全量模型上线后再撤规则。SVM 的增量学习接口比较受限,工程里一般直接用全量重训兜底。第三个方向是把分类器包成批量预筛接口,输入一封或多封邮件,输出带分数的结构化结果,方便其他模块消费。改造时保持 train_model.py 产出的模型格式不变,在 utils.py 里新增一个批量预测函数即可。验证每次改动的标准方法是固定随机种子,用同一份测试集对比新旧模型的 F1 和混淆矩阵,确保改动效果不是因为数据切分变化带来的巧合。我在这个项目上最大的教训是:改完阈值或特征后,忘了重新固定随机种子就让 train_test_split 重新切分了一次,结果新模型“效果变好了”其实是测试集碰巧更容易了。从那以后我每次改完代码都会强制检查一遍 RANDOM_STATE 和数据切分逻辑,确认对比是在同一份数据上进行的。这个习惯帮我避开了不少自我欺骗式的优化,希望帮到你。本文还有配套的精品资源,点击获取
简介:这是一个基于朴素贝叶斯与支持向量机(SVM)的垃圾邮件识别系统,适合机器学习入门者、毕业设计及分类项目实战人群。项目通过对正常邮件和垃圾邮件进行语料训练,完成词频统计与模型对比,实现邮件自动分类。资源共2000个文件,以80张jpg图片、5个py源码文件为主,另含zip、txt、pkl等类型,整体压缩包28.64MB,目录覆盖数据模块、模型构建和附加功能。代码在Python 3.6以上环境运行,可选用Anaconda或Linux虚拟机;附加模块需要对接pytesseract文字引擎和百度云图像文字识别服务,便于扩展OCR识别场景。已有1359人学习下载,适合借此掌握朴素贝叶斯特征统计、SVM分类器训练、模型保存与调用等完整开发思路。1. 垃圾邮件识别:让朴素贝叶斯和 SVM 同场竞技的一个可运行 Python 工程做垃圾邮件识别,最怕的不是没算法,而是有一堆算法代码却不知道整个流程怎么串起来。这个工程把机器学习里最经典的两类分类算法——朴素贝叶斯和支持向量机(SVM),从文本预处理、特征工程到模型评估完整地组织进一个 Python 工程。它不做花哨的深度模型,反而把分类算法的核心链路讲得明白,适合正在入门人工智能分类算法、或需要一个能跑通的分类项目做二次开发的人。只要把邮件数据换成你自己的语料,改一个文件路径就能重新训练,并拿到两份模型的评估报告。我的整体感觉是:跑通不难,要改得顺手,得先看懂它的目录和数据流。2. 工程解剖:目录结构、数据流和它为什么选这两类算法2.1 技术选型:为什么同时放朴素贝叶斯和 SVM一个垃圾邮件识别项目里同时放两种“对立”的算法,不是功能冗余,而是为了对比。朴素贝叶斯是生成式模型,它假设特征之间相互独立,文本场景下这个“独立”假设并不完全成立,但实际效果依然稳定,而且训练快、可解释性强。SVM 是判别式模型,直接在特征空间中找最大间隔分类面,在特征维度很高时效果往往更好,但训练成本也更高。两种算法互补,放在同一个工程里正好能对比出当前数据集上的难点在哪里。工程把两者的公共部分,也就是文本清洗、分词、向量化,统一封装成独立模块;算法部分只保留差异点。这种做法的好处是数据流只走一遍,换算法不至于把整个流程复制一份。技术选型上,工程用 scikit-learn 做模型训练和评估,jieba 做中文分词,pandas 处理原始 CSV,joblib 保存训练好的模型。整套选型非常常规,但每一层职责都清楚,这也是它能被拿来做课程设计、比赛 baseline 和业务预研的原因。为什么不自己手写朴素贝叶斯或者 SMO?手写一遍对理解公式有帮助,但要处理平滑、稀疏矩阵、交叉验证和特征对齐,工作量其实不小。这个工程的重心是完整流程,直接在 scikit-learn 基础上做工程化封装更合适。在这个场景下,深度学习也不是首选:邮件文本维度不高、样本量通常有限,传统分类器训练快、可解释性强,不容易过拟合。2.2 目录结构:每个文件负责什么spam_classifier/ ├── data/ │ ├── raw/ # 原始邮件数据,放 emails.csv │ └── processed/ # 清洗后的中间结果 ├── models/ # 保存训练好的模型和向量化器 ├── src/ │ ├── config.py # 全局配置:路径、随机种子、测试集比例 │ ├── preprocess.py # 清洗文本、分词 │ ├── feature.py # TF-IDF 向量化封装 │ ├── train_model.py # 训练入口,--model 参数切换 nb/svm │ ├── evaluate.py # 评估入口,输出分类报告和混淆矩阵 │ ├── predict.py # 单条文本预测 │ └── utils.py # 通用工具:保存模型、加载模型 ├── requirements.txt └── README.md这个目录结构把数据、训练产物和代码分开,是我比较习惯的工程分层。data/raw 只放原始数据,processed 放清洗结果,这样回放实验时能知道每一步的输入是什么;models 单独抽出来,避免训练完的 pkl 文件散落在代码目录里。src 下面按职责拆模块,不按算法拆目录,训练脚本只有两个,但代码不重复。值得单独说的是 config.py。很多项目喜欢把参数散落在各处,这个工程把路径、随机种子和测试集比例集中到一个文件里,改实验配置不用翻代码。config.py 里的关键内容大概是这样的:RANDOM_STATE = 42 TEST_SIZE = 0.2 DATA_PATH = "data/raw/emails.csv" PROCESSED_PATH = "data/processed/emails_clean.csv" MODEL_DIR = "models/" LABEL_SPAM = 1 LABEL_HAM = 0 TFIDF_MAX_FEATURES = 50000 NB_ALPHA = 0.5 SVM_C = 1.0RANDOM_STATE 固定随机种子,保证多次训练切分出的训练集和测试集一致,实验可复现;TEST_SIZE 控制测试集比例,数据量小时可以调到 0.3。MODEL_DIR 是模型输出目录,换数据集时只需要改 DATA_PATH,不用动任何训练代码。TFIDF_MAX_FEATURES、NB_ALPHA、SVM_C 分别对应特征词表上限、朴素贝叶斯平滑系数和 SVM 正则化参数,这几个值是从调参环节沉淀下来的默认配置,后面会细说它们各自影响什么。2.3 数据流:从一封邮件到一份分类报告整个工程的数据流可以拆成五个阶段,每个阶段都有明确的输入和输出。原始数据从 data/raw/emails.csv 进入,经过 preprocess.py 清洗和分词后写到 data/processed;feature.py 把分词后的文本转成 TF-IDF 矩阵;train_model.py 用这份矩阵分别训练两个模型;evaluate.py 在预留的测试集上输出指标。链路可以概括为:阶段输入输出关键模块读取原始数据emails.csvDataFrame(text、label 两列)pandas文本预处理DataFrame清洗分词后的文本preprocess.py特征工程分词文本TF-IDF 稀疏矩阵feature.py模型训练特征矩阵models/ 下的 pkl 文件train_model.py评估预测测试集分类报告、混淆矩阵evaluate.py、predict.py各阶段之间通过中间文件或内存对象传递,边界清晰。训练时读写的是 processed 目录里的清洗结果,不会像有些脚本那样把正则清洗逻辑混在训练代码里,排查问题会方便很多。换数据集时,真正要改的只有 emails.csv 的列名格式,也就是 text 和 label 两列,其余代码可以保持不动。数据流里有一个关键细节:向量化器只 fit 训练集,再 transform 测试集。如果拿全部数据的 TF-IDF 去 fit,测试集的信息在训练阶段就已经被模型间接看到,评估结果会虚高,这就是典型的数据泄漏。工程在 train_model.py 里用 train_test_split 先切分再 fit 向量化器,属于完全正确的做法。后面你想做交叉验证时,也要小心这个顺序:GridSearchCV 的 Pipeline 会自动避免泄漏,但如果在搜索之前就 fit 了向量化器,那结果同样不可信。3. 核心算法模块拆解:从文本到分类报告的完整实现3.1 文本预处理:清洗邮件内容、中文分词邮件原文不能直接丢给算法。它包含 HTML 标签、URL、数字和各类符号,这些内容对判断垃圾邮件不是一点用没有,但直接用原始字符串很难让模型学到稳定规律。preprocess.py 里最核心的清洗函数大概长这样:import re import jieba def clean_text(raw: str) -> str: # 去掉 HTML 标签和实体 text = re.sub(r"<[^>]+>", "", raw) text = re.sub(r"&[a-zA-Z]+;", "", text) # 去掉 URL 和邮箱地址 text = re.sub(r"https?://\S+|www\.\S+", "", text) text = re.sub(r"\S+@\S+", "", text) # 去掉多余空白并转小写 text = re.sub(r"\s+", " ", text).strip() return text.lower() def tokenize(text: str) -> list: cleaned = clean_text(text) # 只保留英文字母、数字和中文汉字,去掉标点 cleaned = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fa5]", " ", cleaned) return [w for w in jieba.lcut(cleaned) if len(w.strip()) > 1]clean_text 用多个正则依次处理不同噪音:先删 HTML 标签,避免<a>这类内容干扰;再删 URL 和邮箱地址,这两类内容在正常邮件里也有,在垃圾邮件里更多,但直接作为特征会让模型记住具体链接而不是通用模式;最后统一转小写,降低同一个词的大小写变体对词表的影响。tokenize 里有两个关键过滤条件:len(w.strip()) > 1会丢掉单字词,对中文来说“奖”“钱”这类单字有区分力,但也带来噪声,工程默认保留长度大于 1 的词;阈值在数据量大时可以调,如果你发现模型对“送”“领”这类单字不敏感,可以考虑把这个长度条件放宽到 1。另外,清洗和分词要分开看待,清洗负责去噪音,分词负责把连续文本切成词。jieba.lcut 返回的是词列表,后续 TF-IDF 向量化器可以直接把它作为 tokenizer 传入,保证训练和预测用的是同一套切词逻辑,这一点非常关键。3.2 特征工程:TF-IDF 向量化和关键参数分词后的句子没法直接训练,必须变成数值向量。工程用 TfidfVectorizer,它不只是数词频,还通过 IDF 降低“的”“是”这类高频词的权重。特征工程的封装写在 feature.py 里:from sklearn.feature_extraction.text import TfidfVectorizer def build_vectorizer(): return TfidfVectorizer( tokenizer=jieba.lcut, ngram_range=(1, 2), max_features=50000, min_df=2, sublinear_tf=True, )tokenizer 直接传 jieba.lcut,让向量化器内部切词和预处理阶段保持一致;如果不传,TfidfVectorizer 默认用空格切分,对中文句子基本是废的。ngram_range=(1, 2) 表示同时保留单个词和相邻两个词的组合,像“领取”“点击领取”都能成为特征,能捕捉一部分短语级别的信息。min_df=2 过滤掉只出现一次的词汇,减少词表噪声;sublinear_tf=True 让词频用 1 + log(tf) 表示,防止某个词在一封长邮件里反复出现导致数值过大。TF-IDF 的 fit 只能作用于训练集。如果对整个语料 fit,测试集的词频和文档频率信息都参与了训练,评估结果会有水分。工程的做法是先切分,再 vectorizer.fit_transform(X_train),最后 transform(X_test)。这个细节在交叉验证里更容易被忽略,很多人直接把 TfidfVectorizer 放在整个 DataFrame 上 fit 完再做交叉验证,结果就是每一折都用了全量数据的信息,F1 虚高得自己都不信。3.3 朴素贝叶斯实现:MultinomialNB 与 Pipeline 封装朴素贝叶斯的原理一句话能说清:计算“这封邮件是垃圾”在给定词序列下的后验概率,分母对所有类别相同,所以只需看先验概率 P(垃圾) 乘上每个词在垃圾邮件中的条件概率乘积。sklearn 的 MultinomialNB 会对每个词的条件概率做平滑,避免某个词在训练集中没出现就把整个概率乘成 0。训练部分封装在 train_model.py 里:from sklearn.pipeline import Pipeline from sklearn.naive_bayes import MultinomialNB from feature import build_vectorizer def build_nb_pipeline(): return Pipeline([ ("tfidf", build_vectorizer()), ("clf", MultinomialNB(alpha=0.5)), ])把向量化和分类器放进同一个 Pipeline,训练时 vectorizer.fit 和 model.fit 顺序执行,预测时 pipeline.predict 会自动用相同的 vectorizer 做 transform,不会出现“训练时用 TF-IDF、预测时忘了向量化”的低级错误。alpha 是平滑系数,默认值是 1.0,这里调成 0.5 意味着给词频计数加的伪计数更小,模型更依赖真实数据分布。alpha 太小容易过拟合,太大会模糊不同特征之间的差异,通常可以在 0.1、0.5、1.0 之间做网格搜索。训练朴素贝叶斯的命令是python src/train_model.py --model nb,脚本内部分三步:读入清洗后的数据、切分训练集和测试集、fit pipeline。训练完成后把整个 pipeline 保存为 models/nb_pipeline.pkl,预测时只加载这一个文件。想深入理解公式的人可以在 scikit-learn 之外再试着手写一版多项式贝叶斯,数值结果直接和这里的 baseline 对比。3.4 SVM 分类器:LinearSVC、参数 C 与评估报告SVM 部分用的是 LinearSVC,也就是线性核的支持向量分类器。为什么不选默认的 SVC?文本向量化后的特征维度动辄几万,数据高度稀疏,这种情况下线性分类面通常已经够用,再用高斯核计算相似度矩阵会非常慢。工程里 SVM 的 pipeline 和 NB 结构一样,只有分类器不同:from sklearn.svm import LinearSVC def build_svm_pipeline(): return Pipeline([ ("tfidf", build_vectorizer()), ("clf", LinearSVC(C=1.0, class_weight=None)), ])C 是正则化强度的倒数:C 越小对误分类的惩罚越轻,模型更偏向简单的分类面,泛化效果更好;C 越大模型越努力拟合训练集,容易过拟合。垃圾邮件任务里先拿 C=1.0 跑基线,再用 GridSearchCV 在 0.1 到 10 之间搜索是常规流程。class_weight 在邮件类别不均衡时非常有用,当垃圾邮件远比正常邮件少时,把它设成 balanced 会让模型自动提高少数类的误分类代价,从而提升垃圾邮件的召回率。训练完成后,evaluate.py 会在测试集上输出评估结果:from sklearn.metrics import classification_report, confusion_matrix def evaluate(pipeline, X_test, y_test): y_pred = pipeline.predict(X_test) report = classification_report( y_test, y_pred, target_names=["正常邮件", "垃圾邮件"] ) cm = confusion_matrix(y_test, y_pred) return report, cmclassification_report 会同时给出精确率、召回率和 F1 值。这里要特别关注“垃圾邮件”这一类,而不是整体准确率:正常邮件占多数时,模型即使完全不识别垃圾邮件,整体准确率也能很高,但业务上漏掉一封垃圾邮件的代价往往比误伤一封正常邮件更大。confusion_matrix 返回一个 2x2 矩阵,行是真值、列是预测值,四个格子的含义分别是正常邮件判对、正常邮件误判为垃圾、垃圾邮件漏判、垃圾邮件判对。每次改完参数先看这四个数字的变化,比只看一条准确率曲线直观得多。再补一个工程细节:LinearSVC 和 MultinomialNB 的输出接口不一样,NB 可以用 predict_proba 拿到概率,SVM 只有 decision_function 的分数。分数大于 0 判垃圾,小于 0 判正常。这个分数不是概率,但可以作为阈值调整的依据。二次开发时如果想给不确定的邮件做人工复核,可以按这个分数划分灰色区间。4. 跑通与排坑:环境准备、训练评估和三处高频翻车点4.1 环境准备与依赖安装先把环境独立出来。直接在全局 Python 环境里装依赖,后面装其他项目时容易冲突。在项目根目录执行:python -m venv venvLinux 或 macOS 下激活虚拟环境用source venv/bin/activate,Windows 下用venv\Scripts\activate。激活后命令行前面会出现 (venv) 标识,这时候再安装依赖就只会装进当前虚拟环境里。安装依赖只需要一条命令:pip install -r requirements.txtrequirements.txt 里包括 scikit-learn、jieba、pandas、joblib、numpy 这几个核心包。joblib 用来保存和加载训练好的模型,numpy 是 sklearn 和 pandas 的底层依赖,显式写出来保证环境一致性。安装完成后可以用一段小代码验证 sklearn 能正常导入,然后再继续。如果网络下载慢,换成你熟悉的国内 PyPI 镜像源会快很多,这一步不改动项目本身,只是环境准备时的常见操作。4.2 训练与评估:按步骤复现完整流程数据格式要符合工程约定:emails.csv 必须包含两列,一列 text 是邮件正文,一列 label,垃圾邮件标 1,正常邮件标 0。把文件放到 data/raw/ 下后,先执行预处理:python src/preprocess.py脚本会读取原始邮件、清洗文本、分词,并把处理结果写到 data/processed/emails_clean.csv。把清洗和训练分开是有意为之:之后调参不需要每轮都重新清洗原始数据,省时间也避免清洗逻辑不一致的问题。接下来训练两个模型:python src/train_model.py --model nb python src/train_model.py --model svm第一条命令训练朴素贝叶斯,第二条训练 SVM,训练产物分别保存为 models/nb_pipeline.pkl 和 models/svm_pipeline.pkl。不传 --model 时默认训练 nb。训练完成后用 evaluate.py 输出评估报告:python src/evaluate.py --model svm评估脚本会加载训练阶段留下的测试集并重新预测,输出 classification_report。只看报告不够直观,最好再用 predict.py 手测几条典型文本:python src/predict.py --text "恭喜您获得万元大奖,点击链接领取" --model svm python src/predict.py --text "本周例会安排在周三下午三点" --model svmpredict.py 内部会加载对应的 pipeline,对文本做清洗和向量化后预测,并输出类别和 decision_function 分数。第一类文本训练正常时应该判为垃圾,第二类应该判为正常。如果连这种明显差异的样本都预测错,优先查数据格式和预处理脚本,而不是急着调参。推荐把这类冒烟测试文本写成一个固定的 smoke_test.txt,每次训练完都跑一遍,确认链路没有断。4.3 避坑记录:四个高频问题和定位思路第一个高频坑是维度不匹配。现象:模型训练和保存都很顺利,加载模型预测单条文本时报特征维度不一致。原因:训练时用了自定义 tokenizer,也就是 jieba.lcut,预测时却重新 new 了一个 TfidfVectorizer 而没有 fit,或者没有走同一套向量化配置。解决:把向量化和模型封装成 Pipeline,预测时直接加载整个 pkl 文件,不单独调用 vectorizer.transform。用 Pipeline 之后这个坑基本不会再遇到。第二个坑是类别不均衡导致准确率虚高。现象:测试准确率看起来挺高,但垃圾邮件的召回率很低,大量垃圾邮件被漏掉。原因:训练集里正常邮件占绝大多数,模型只要把所有样本都判成正常就能拿到高准确率。解决:训练前先打印 y 的类别分布确认占比;train_test_split 时加 stratify=y,保证切分前后分布一致;LinearSVC 设置 class_weight="balanced";评估时重点看 classification_report 中垃圾邮件那一行的召回率和 F1。第三个坑是中文编码问题。现象:pandas 读 CSV 后文本乱码,或者预处理后所有文本长度异常短。原因:数据文件是 GBK 编码或带 BOM 头,而默认读取用的是 UTF-8。解决:读取时指定 encoding="utf-8-sig",能兼容带 BOM 的 UTF-8 文件;更稳妥的做法是把读入函数封装一次,先尝试 utf-8,失败后尝试 gbk,后续所有脚本统一调用这个封装。第四个坑是 SVM 训练慢。现象:数据量稍微大一点,训练 SVM 时等待时间明显变长。原因:用了默认的 SVC,它默认采用 RBF 核,需要计算样本两两之间的核矩阵,复杂度接近 O(n²),在几万维稀疏文本特征下完全没必要。解决:换成 LinearSVC,或者 SVC(kernel="linear"),训练时间会下降一个量级,文本分类效果基本不变。这个项目里默认选 LinearSVC 就是为了提前规避这个问题。如果发现朴素贝叶斯把所有样本都预测成垃圾邮件,优先检查清洗阶段是否把停用词过滤做过了头,导致特征词太少,模型只能依赖先验概率。处理办法是把过滤条件放宽松,或者用 ngram_range=(1, 2) 补充组合特征。5. 二次开发指南:从离线训练到可调阈值的过滤服务这套工程跑通只是起点,把模型真正用到邮件过滤里需要做三件事:调阈值、增量更新、包成批量预测接口。先说阈值。NB 输出的概率和 SVM 的 decision_function 分数都反映“垃圾程度”,但默认的 0.5 概率和 0 分不一定是最优截断点。改动思路是:在测试集上遍历阈值,画出召回率随阈值变化的曲线,选一个“宁可多召回几封垃圾邮件、允许少量误报”的阈值,再通过参数暴露出来。工程上可以写一个统一的预测函数:def predict_with_score(text, pipeline, threshold=0.0): # threshold 对 NB 是概率阈值,通常 0.5;对 SVM 是分数阈值,通常 0.0 if hasattr(pipeline.named_steps["clf"], "decision_function"): score = pipeline.decision_function([text])[0] return int(score >= threshold), score proba = pipeline.predict_proba([text])[0][1] return int(proba >= threshold), probathreshold 参数对两种模型含义不同,但都可以通过遍历测试集找到适合自己的截断点。第二个方向是增量更新。垃圾邮件话术变化很快,隔一段时间就会出现大量新词,模型会逐渐失效。朴素贝叶斯支持 partial_fit,可以分批喂入新标注数据,但要注意 TF-IDF 向量化器的词表不会随之更新,新词会被忽略。常见做法是每天定时收集新样本做全量重训练,遇到突发的垃圾邮件活动先用规则词库临时拦截,等全量模型上线后再撤规则。SVM 的增量学习接口比较受限,工程里一般直接用全量重训兜底。第三个方向是把分类器包成批量预筛接口,输入一封或多封邮件,输出带分数的结构化结果,方便其他模块消费。改造时保持 train_model.py 产出的模型格式不变,在 utils.py 里新增一个批量预测函数即可。验证每次改动的标准方法是固定随机种子,用同一份测试集对比新旧模型的 F1 和混淆矩阵,确保改动效果不是因为数据切分变化带来的巧合。我在这个项目上最大的教训是:改完阈值或特征后,忘了重新固定随机种子就让 train_test_split 重新切分了一次,结果新模型“效果变好了”其实是测试集碰巧更容易了。从那以后我每次改完代码都会强制检查一遍 RANDOM_STATE 和数据切分逻辑,确认对比是在同一份数据上进行的。这个习惯帮我避开了不少自我欺骗式的优化,希望帮到你。本文还有配套的精品资源,点击获取