FEATURED · 精选文章

朴素贝叶斯实战:垃圾短信分类器从原理到工程实现

发布时间 / 2026/8/26 12:02:51
来源 / 创域科博编辑部
栏目 / 资讯中心
朴素贝叶斯实战:垃圾短信分类器从原理到工程实现 简介在机器学习与自然语言处理领域文本分类是一项基础任务而朴素贝叶斯算法凭借其简洁的概率原理与高效的训练推理能力成为短文本分类的经典方案。该算法基于贝叶斯定理通过特征条件独立假设简化计算在中文分词和特征工程的支持下能有效处理短信等短文本数据。垃圾短信识别是典型应用场景对实时性和可解释性要求较高朴素贝叶斯能够以极低成本达到接近深度模型的效果。本文围绕这一场景系统讲解数据清洗、jieba分词、向量化、模型选型与评估并给出完整工程化实现适合机器学习实践与课程设计参考。 先说一下这个项目是干嘛的用朴素贝叶斯算法训练一个能自动判别短信是否为垃圾短信的分类器完整代码加数据集打包成一个 zip作为期末大作业提交。很多人做类似题目时踩的坑其实不在算法本身而在数据预处理、中文分词、特征选择以及最后的工程化呈现。这篇文章我会把整条链路从头到尾拆开讲清楚从朴素贝叶斯的原理为什么适合短文本分类到中文语料怎么清洗、分词、向量化再到评估指标怎么选、答辩时老师会追问哪些问题最后给出一个可以直接抄的工程目录结构。适合正在做机器学习期末大作业、毕业设计或者想快速跑通一个自然语言处理小项目的同学参考。1. 为什么期末大作业选朴素贝叶斯做短信分类1.1 这个课题的天然优势朴素贝叶斯在文本分类里属于“出道即巅峰”的经典算法。短信分类这个任务数据是短文本类别只有两个正常/垃圾词汇量相对可控而且对训练速度和推理速度都有要求——总不能每条短信过来都要等几百毫秒才判断完。这几个特点叠加在一起朴素贝叶斯几乎是量身定做的方案。对比一下其他算法你就明白了算法训练速度推理速度可解释性小样本表现代码复杂度朴素贝叶斯极快极快强概率可解释好低逻辑回归快快较强一般中SVM慢快弱一般中LSTM/BERT慢慢弱差高老师给学生设计大作业题目时最看重的是“你能不能把原理讲清楚 能不能完整复现 有没有实际应用价值”。朴素贝叶斯三个全占。从原理上说它基于贝叶斯定理这个定理本身有明确的数学推导课堂上不会超纲。从实现上说用 scikit-learn 几行代码就能训练。从效果上说在短文本分类场景下它的准确率并不比深度学习方法差太多——因为短信这种短文本里垃圾短信往往有强特征词比如“中奖”“点击链接”“退款”“加V”这类词朴素贝叶斯对这种强特征词特别敏感。1.2 环境准备与项目文件组织我建议的 Python 版本是 3.8 到 3.11 之间。太老的版本3.6 以下有些依赖装不上太新的版本3.12部分库可能还没适配。我这边实测用的是 Python 3.10。依赖库装这几个就够了pip install pandas numpy jieba scikit-learn joblib flask不需要装深度学习框架CPU 跑完全没问题。scikit-learn 的版本建议 1.0 以上如果用的是老版本API 差别不大但有些参数名在新版里废弃了网上找资料容易踩坑。项目目录结构我直接给一个可以照抄的版本spam_classifier/ ├── data/ │ ├── sms_spam.csv # 原始数据集 │ └── stopwords.txt # 停用词表 ├── output/ │ ├── model.pkl # 训练好的模型 │ └── vectorizer.pkl # 向量化器 ├── src/ │ ├── preprocess.py # 数据清洗与分词 │ ├── train.py # 模型训练与评估 │ ├── predict.py # 单条短信预测 │ └── app.py # Flask 演示接口 ├── README.md └── requirements.txt这个结构看上去很简单但每个文件都有明确职责。大作业最忌讳把所有代码堆在一个 Jupyter Notebook 里老师打开都费劲。分成模块后代码可读性高答辩时也方便逐个模块讲。2. 数据集处理短信语料的清洗与分词2.1 数据集从哪里来做这个项目最常用的公开数据集是 UCI 的 SMS Spam Collection包含 5574 条英文短信其中垃圾短信 747 条正常短信 4827 条。英文数据集的好处是预处理简单不需要分词直接按空格切分就行。但如果你在国内高校交作业用英文数据会让答辩老师觉得“有点取巧”——国内的场景当然要用中文短信。中文短信数据集不像英文那么好找。常见的选择有中文垃圾短信语料库网上有公开的 json 或 txt 版本通常几万条某高校开源的中文短信语料自己手动标注不推荐除非你有时间去采集而且标注质量没法保证我用的这份数据集大概 2.7 万条类别分布大约是正常短信 2.1 万条垃圾短信 6000 条垃圾短信占比约 22%。这个比例符合真实场景——毕竟现实中垃圾短信占比通常不会超过 30%。数据格式很简单一行一条字段之间用制表符分隔第一列是 labelham 表示正常spam 表示垃圾第二列是短信内容。2.2 清洗和分词的完整流程拿到原始数据后第一步是看数据长什么样不要直接拿去训练。用 pandas 读进来import pandas as pd df pd.read_csv(data/sms_spam.csv, sep\t, headerNone, names[label, text]) print(df.shape) print(df[label].value_counts())写代码的时候注意sep\t我一开始用的默认逗号分隔读进来只有一列浪费了半小时排查。这种低级错误一定要避免拿到数据先看前几行永远是最稳妥的。接下来是清洗。短信内容里乱七八糟的东西很多网址、HTML标签、数字、表情符号、邮箱地址。这些对垃圾短信识别有价值但也有干扰。比如“https://t.cn/A6f3k”这种短链接在垃圾短信里频繁出现但直接保留会让特征空间变大而且模型很难从这么长的字符串里学到有效信息。我建议的清洗规则是这样import re def clean_text(text): # 去除网址 text re.sub(rhttp\S|www\.\S, , text) # 去除HTML标签 text re.sub(r.*?, , text) # 去除邮箱地址 text re.sub(r\w\w\.\w, , text) # 去除数字但保留中文字符和英文字母 text re.sub(r\d, , text) # 只保留中文、英文字母和常规标点 text re.sub(r[^\u4e00-\u9fa5a-zA-Z], , text) return text.strip()最后一个正则把所有非中英文的字符替换成空格好处是把标点和表情统一清理掉坏处是句子的结构信息会丢失。短信分类这种短文本任务句法信息相对次要去掉可以接受。清洗完之后做分词。中文不像英文天然有空格分词必须借助工具。我用的是 jieba在中文分词里算是普及度最高的库。import jieba def tokenize(text): return .join(jieba.cut(text))需要注意一个细节jieba.cut返回的是生成器直接 join 没问题但如果你要多次遍历最好先转成列表。另外jieba 的默认词典对短信里的网络用语覆盖不够比如“u”“8”“mdzz”这种可以在自定义词典里补充。如果你觉得评论区、短信里常见的“【】”这类符号会让特征变得奇怪可以在清洗阶段单独处理。比如text text.replace(【, ).replace(】, )清洗之后再把分词结果写回 DataFrame方便后续处理。停用词表这个环节也很关键。中文里有大量高频但信息量低的词比如“的”“了”“吗”“在”“是”如果不加处理直接进入模型它们会占据很大的特征权重却没有判别能力。我用的是网上公开的中文停用词表大概 1200 个词配合自定义的短信领域停用词比如“回复”“请”“您好”——这些词在正常短信和垃圾短信里都可能出现留着只会增加噪音。stopwords set(open(data/stopwords.txt, encodingutf-8).read().split()) def remove_stopwords(tokens): return [w for w in tokens if w not in stopwords and len(w) 1]最后一步是划分训练集和测试集。注意一定要用分层抽样from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( df[text], df[label], test_size0.2, random_state42, stratifydf[label] )stratify参数保证训练集和测试集里正常短信与垃圾短信的比例保持一致避免随机划分导致测试集里垃圾短信占比异常。这个参数是我在做这个项目时反复实验得到的教训——不加分层抽样测试集 F1 值波动可以到 5 个百分点以上。3. 特征工程与朴素贝叶斯原理3.1 贝叶斯定理和“朴素”到底朴素在哪模型训练之前先把原理讲透。朴素贝叶斯分类的核心就是贝叶斯定理P(类别 | 文本) P(文本 | 类别) * P(类别) / P(文本)用一句话解释已知一条短信的内容我们要计算它是垃圾短信的概率和它是正常短信的概率哪个概率大就判断为哪类。这里有两个先验可以算P(垃圾短信) 和 P(正常短信)直接在训练集里统计类别占比就行。P(文本 | 类别) 就没那么好算了——如果一条短信有 20 个词那么 P(词1, 词2, ..., 词20 | 类别) 是一个联合概率需要海量数据才能估计准确。于是就有了“朴素”假设假设文本里的每个词在给定类别下是条件独立的。也就是说P(词1, 词2, ..., 词20 | 类别) ≈ P(词1 | 类别) * P(词2 | 类别) * ... * P(词20 | 类别)这个假设在很多情况下是“错误”的——比如“免费”和“领取”往往会一起出现条件独立假设忽略了这种词间关系。但神奇的是在文本分类任务里即使假设不成立朴素贝叶斯的表现依然很好。原因在于实际判断时我们只关心两者概率的比值即使概率估计不准确同一个方向的偏差在两类之间会部分抵消最终的类别判定仍然正确。这就是为什么我建议大作业不要选复杂的算法——朴素贝叶斯的“错误假设”反而让它具备了惊人的稳定性和计算效率。你可以在文档里把这个点写清楚老师看到你理解了这个层次分数不会低。3.2 CountVectorizer 与 TF-IDF 的取舍文本不能直接喂给模型要先把文本转换成一串数字。最常用的方法是词袋模型Bag of Words用CountVectorizer实现。from sklearn.feature_extraction.text import CountVectorizer vectorizer CountVectorizer(max_features5000, min_df2, ngram_range(1, 2)) X_train_vec vectorizer.fit_transform(X_train_tokenized) X_test_vec vectorizer.transform(X_test_tokenized)三个参数都值得展开说。max_features5000表示只保留出现频率最高的 5000 个词。这是为了控制特征维度。如果语料里有 5 万个不同的词但大部分只出现一两次这些低频词既占内存又容易过拟合。保留下高频词模型的可解释性和稳定性都会更好。min_df2表示一个词至少在训练集里出现 2 次才保留。它和max_features是一对组合拳专门过滤低频噪音。ngram_range(1, 2)表示不仅考虑单个词还考虑相邻两个词的组合。比如“免费”和“领”分别作为特征“免费领”作为一个整体特征。这个参数对短信分类特别有效因为很多垃圾短信的特征短语是两个词组成的。我实测下来加不加 bigramF1 值大约能提升 1 到 2 个百分点。既然CountVectorizer已经够用那 TF-IDF 呢有同学会在答辩时被问到“你为什么不选 TF-IDF”。TF-IDF 的思路是一个词在特定文本里出现得多但在整个语料里出现得少说明它对这个文本有很强的区分能力。公式是TF-IDF 词频 * log(文档总数 / 包含该词的文档数)在短信分类里TF-IDF 和词袋的区别并不大。因为短信特别短词频特征本来就稀疏TF-IDF 带来的改善有限。而且朴素贝叶斯是基于概率的模型它天然对词频有建模能力用 CountVectorizer 得到的整数计数更符合多项式分布的假设。当然如果老师追问“你试过 TF-IDF 吗”你可以说我做过对比实验发现两者 F1 差异在 0.5% 以内但 CountVectorizer 训练速度更快。这就是一个非常漂亮的答辩回答——既展示你做了实验又说明了选择理由。3.3 三种朴素贝叶斯变体怎么选scikit-learn给了三种朴素贝叶斯GaussianNB、MultinomialNB和BernoulliNB。GaussianNB假设特征是连续变量且服从正态分布适合数值型特征不适合文本词频。MultinomialNB假设特征表示的是事件发生的次数即词频适合 CountVectorizer 的输出是文本分类的默认选择。BernoulliNB假设特征值是二元的词出现或不出现适合短文本中“词是否存在”比“出现多少次”更重要的场景。我做了个对比实验三种模型在同一个训练集上的表现模型准确率F1垃圾短信MultinomialNB0.9810.952BernoulliNB0.9720.931GaussianNB0.6230.415GaussianNB 的表现非常差因为词频数据不满足正态分布假设。这个实验数据值得写进报告直接展示了你对不同模型的横向对比比单纯写一个模型有说服力得多。最终选 MultinomialNB训练代码极其简洁from sklearn.naive_bayes import MultinomialNB model MultinomialNB(alpha1.0) model.fit(X_train_vec, y_train)alpha是拉普拉斯平滑的平滑系数后面讲。4. 模型评估与调参别只盯着准确率4.1 垃圾短信场景下的关键指标很多初学者拿到模型先看准确率。但准确率在这个场景下是有欺骗性的。如果测试集里正常短信占 78%、垃圾短信占 22%那我写一个“永远判定为正常”的模型准确率也有 78%。这显然没有意义。垃圾短信分类更关注三个指标精确率判为垃圾短信的里面有多少真的是垃圾短信。召回率真实的垃圾短信里有多少被正确识别出来。F1 值精确率和召回率的调和平均数。举一个更实际的例子如果把一条正常短信误判为垃圾短信召回率高但精确率低用户收不到银行验证码这个代价比漏掉一条垃圾短信大得多。所以在实际业务里我们往往希望精确率优先。但大作业场景老师看的是你懂不懂这些指标而不是你真的要优化到多完美。所以评估代码里一定要输出混淆矩阵和完整的分类报告from sklearn.metrics import classification_report, confusion_matrix y_pred model.predict(X_test_vec) print(classification_report(y_test, y_pred)) print(confusion_matrix(y_test, y_pred))我用这份中文数据集跑出来的结果precision recall f1-score support ham 0.99 0.99 0.99 4200 spam 0.98 0.94 0.96 1200垃圾短信的召回率是 94%意味着 6% 的垃圾短信漏掉了正常短信的召回率是 99%意味着 1% 的正常短信被误伤。在演示环节我会优先展示正常短信的召回率——它代表“这个模型不会乱杀无辜”。4.2 拉普拉斯平滑和常见调参手段MultinomialNB(alpha1.0)里的 alpha 就是拉普拉斯平滑系数。为什么要平滑因为有些词在训练集的垃圾短信里从来没有出现过但在测试集的垃圾短信里出现了。如果按原始频率计算P(这个词 | 垃圾短信) 0整条短信的概率直接乘成 0模型会直接把这短信判成正常——这是非常糟糕的。拉普拉斯平滑给每个特征即使没出现过的词一个极小但非零的概率。alpha 越大平滑力度越强所有概率向均匀分布靠拢alpha 越小越信任训练集里出现的统计频率。我建议做一组的 alpha 扫描实验for alpha in [0.01, 0.1, 0.5, 1.0, 2.0, 5.0]: model MultinomialNB(alphaalpha) model.fit(X_train_vec, y_train) score model.score(X_test_vec, y_test) print(falpha{alpha}, test accuracy{score:.4f})我实测的结果是 alpha 在 0.5 到 1.0 之间表现最好alpha 超过 2 之后准确率明显下降因为过度平滑把真实的概率差异抹平了。如果发现模型过拟合可以考虑这几件事增加min_df过滤更多低频词减少max_features缩小特征空间不做ngram_range用单个词就够了反过来如果欠拟合说明特征不够可以加大max_features或者引入 TF-IDF。4.3 一份更优秀的对比实验朴素贝叶斯 vs 逻辑回归答辩时很可能被问为什么不用逻辑回归你可以做一个简单的对比实验代码不需要太多from sklearn.linear_model import LogisticRegression lr LogisticRegression(max_iter1000) lr.fit(X_train_vec, y_train) lr_pred lr.predict(X_test_vec) print(classification_report(y_test, lr_pred))在这个任务上逻辑回归和朴素贝叶斯的准确率非常接近甚至朴素贝叶斯在某些数据集上表现更好。原因是短文本特征空间稀疏朴素的概率独立性假设在稀疏特征下不容易被严重违背而逻辑回归在稀疏高维特征上容易欠拟合需要更多调参。这部分内容放进报告可以展现你超出课程要求的学习主动性。我当年期末报告就因为加了一个对比实验被老师点名表扬了。5. 工程化从训练脚本到可演示的完整项目5.1 模型持久化保存训练好的模型如果不保存关掉终端就全没了期末演示的时候还得重新训练浪费时间且不可控。我强烈建议把模型和向量化器都保存下来import joblib joblib.dump(model, output/model.pkl) joblib.dump(vectorizer, output/vectorizer.pkl)有人用 pickle遇到 scikit-learn 版本升级可能会报错。joblib对包含大量 numpy 数组的对象处理更稳定是 scikit-learn 官方推荐的序列化工具。加载的时候这样写model joblib.load(output/model.pkl) vectorizer joblib.load(output/vectorizer.pkl)需要注意模型和向量化器必须配套使用。你加载模型的时候输入给它的特征必须是用同一个 vectorizer 转换后的结果否则特征维度对不上报错或者预测结果完全不对。5.2 命令行预测脚本演示的时候不一定要有界面命令行工具也可以很专业。写一个predict.py支持两种用法读取文件批量预测、交互式输入单条短信。批量预测的代码def batch_predict(input_file): df pd.read_csv(input_file, sep\t, headerNone, names[text]) texts df[text].apply(clean_and_tokenize) X_vec vectorizer.transform(texts) preds model.predict(X_vec) for text, pred in zip(df[text], preds): label 垃圾短信 if pred spam else 正常短信 print(f[{label}] {text})交互式预测的代码def interactive_predict(): print(输入短信内容进行判断输入 q 退出) while True: text input( ) if text q: break processed clean_and_tokenize(text) vec vectorizer.transform([processed]) pred model.predict(vec)[0] prob model.predict_proba(vec)[0] print(f分类结果: {垃圾短信 if pred spam else 正常短信}) print(f垃圾概率: {prob[1]:.2f}, 正常概率: {prob[0]:.2f})第二段代码里我用predict_proba输出了概率这个信息很有价值。答辩演示时你输入一条短信模型不仅告诉你“这是垃圾短信”还告诉你“垃圾概率是 98%”展示效果完全不同。这也是朴素贝叶斯的天然优势——其他很多算法给不出清晰可解释的概率。5.3 Flask 网页演示接口如果想让项目看起来更完整可以加一个简单的 Flask 接口。不需要前端工程化直接用一个 HTML 页面就行。from flask import Flask, request, render_template_string app Flask(__name__) HTML !DOCTYPE html html headtitle垃圾短信分类器/title/head body h2垃圾短信分类器/h2 form methodpost textarea namesms rows4 cols60 placeholder输入短信内容.../textarea brbr button typesubmit判断/button /form {% if result %} h3分类结果: {{ result }}/h3 p垃圾短信概率: {{ spam_prob }}/p {% endif %} /body /html app.route(/, methods[GET, POST]) def index(): result None spam_prob None if request.method POST: sms request.form[sms] processed clean_and_tokenize(sms) vec vectorizer.transform([processed]) pred model.predict(vec)[0] prob model.predict_proba(vec)[0] result 垃圾短信 if pred spam else 正常短信 spam_prob f{prob[1]:.2%} return render_template_string(HTML, resultresult, spam_probspam_prob) if __name__ __main__: app.run(debugTrue, port5000)跑起来之后浏览器打开http://127.0.0.1:5000输入一条短信就能看到分类结果。Flask 代码本身不复杂但这个环节让项目从“一个算法”变成了“一个系统”在期末大作业里的加分效果非常明显。5.4 打包与提交的细节提交 zip 之前有几个文件必须检查README.md写清楚项目介绍、运行环境、依赖安装命令、运行步骤、预期结果requirements.txt列出所有依赖库及版本代码注释关键函数要有 docstringrequirements.txt用以下命令生成pip freeze requirements.txt建议在 README 里强调“代码需要在 Python 3.8 环境下运行运行前执行pip install -r requirements.txt”。老师拿到手大概率不会真的去跑但看到项目工程化这么完善印象分立刻就上来了。6. 踩坑实录与答辩高频问题速查6.1 我从这个项目中踩过的坑先来一段真实经历。我第一次跑通整个流程发现垃圾短信准确率高达 99.5%简直不敢相信自己的眼睛。后来仔细检查发现训练脚本里忘了做去重——数据集里有很多完全重复的短信尤其是各种营销短信都是一模一样的内容这些重复数据同时存在于训练集和测试集里导致模型在测试集上表现虚高。解决办法是在清洗之前先df.drop_duplicates(subset[text])保证测试集里每条短信在训练集里没有一个完全相同的副本。第二个坑是编码问题。一开始用 pandas 读文件默认编码是 UTF-8但数据集是从网上扒下来的实际上是 GBK 编码读出来全是乱码。解决办法是在read_csv里显式指定encodinggbk或者先用文本编辑器打开原始文件确认编码。这件事看似小事但会浪费不少时间。第三个坑是标签不一致。数据集的 label 列不是纯spam和ham还有的写成SPAM、Spam、normal如果不统一模型训练时会把这些当成不同的类别出现三分类甚至四分类的诡异结果。必须做标签映射统一df[label] df[label].str.lower().map({spam: spam, ham: ham})第四个坑是 jieba 分词后的结果含有大量单字“的”“了”“啊”看似正常但会让特征矩阵非常稀疏。我加了一个过滤逻辑单字词除非在自定义词典里否则直接丢掉。实测之后准确率甚至还有小幅提升因为噪音减少了。6.2 答辩老师可能追问的问题根据我个人经验老师拿到这个项目通常会问以下 5 个问题提前准备一下朴素贝叶斯的“朴素”是什么意思为什么在特征不独立的情况下还能有效 答朴素假设特征之间条件独立简化了联合概率计算。实际中即使假设不成立由于只关心判定结果而非精确概率误差会被抵消一部分因此在文本分类中依然稳健。为什么选 MultinomialNB 而不是 GaussianNB 答词频是离散整数计数符合多项式分布GaussianNB 假设正态分布用于计数数据时效果差。拉普拉斯平滑是怎么解决零概率问题的 答给每个特征的计数加上 alpha避免某个特征在类别下概率为 0 导致整条短信概率为 0。如果你要提升模型效果下一步会怎么改 答可以做 TF-IDF 对比实验、尝试逻辑回归 / SVM、加大语料规模、做五折交叉验证、用词向量做特征。模型误判了哪些短信有什么规律 答主要误判集中在包含大量数字和链接的中性短信比如“您的验证码为 123456”这其实是正常短信但数字特征让它看起来像垃圾短信。准备好这几个问题答辩环节基本不会卡壳。6.3 后续还能往哪些方向扩展如果你不满足于现状想让这个项目有更深的延展空间可以从这几个方向走增量学习用partial_fit方法让模型在线上持续更新适应垃圾短信的新话术实时告警把模型接到短信网关实现真正的实时过滤特征增强融合短信发送时间、号码特征、发送频率等元数据做成多模态分类模型对比加一个 fastText 基准看看朴素贝叶斯和新一代文本分类算法差距有多大我自己在这个项目的基础上往接入短信网关的方向做了一些尝试发现朴素贝叶斯在实时性上的优势确实不可替代。毕竟一条短信进来要在毫秒级时间内返回判断结果这个场景里深度学习方法反而不一定比它合适。在做大作业时我给自己的要求是“每个结论都要有实验支撑”。对比实验做过了才能写“经对比选择 MultinomialNB 作为最终模型”不做实验这句话就是凭空臆造。这点建议同样给你——期末大作业的成绩往往就体现在这些细节里。你在实验室把每个步骤的数据和代码都跑通答辩游刃有余项目写成什么样全在平时踩过的坑和填坑的记录里。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻