
简介基于Python的中文垃圾短信识别项目面向计算机相关专业正在准备课程设计、期末大作业的学生以及需要项目实战练习的开发者。项目整合手写分类器与第三方机器学习库实现覆盖感知器、逻辑回归、朴素贝叶斯、支持向量机等算法每种均有手写与标准库两种版本支持通过命令行灵活切换分类器并指定输入输出文件便于对比实现细节、巩固机器学习基础。压缩包共二十余个文件以十二个Python源码文件为核心配套七个预训练模型、两个数据文件及一份项目说明整体大小约四十八兆结构清晰。源码涵盖分词处理、向量化转换、模型训练、预测与管理等完整流程并附测试脚本可直接运行验证。该项目曾以九十八分的高分通过导师认可目前已有一百六十一人浏览学习内容包含完整代码、模型、测试数据及项目文档能帮助读者快速上手中文垃圾短信识别任务作为高分课程设计参考或入门实战项目均很合适。1. 中文垃圾短信识别为什么必须手写分类器中文垃圾短信识别在 2025 年依然是 NLP 入门最合适的实战题目原因在于它足够小、足够典型短信文本短、噪声大、类别极端不平衡而且存在大量刻意对抗——垃圾短信发送者会故意插入特殊字符、拆词、谐音字来绕过规则引擎。这道题的处理链条包含了从文本清洗、分词、特征工程到分类器训练与评估的完整 NLP 流程。标题里的“手写分类器”点出了这套源码的核心思路——不依赖 sklearn 的现成分类器封装而是自己用 Python 实现朴素贝叶斯或逻辑回归的训练与预测过程。这样做的价值在面试和工程实践两个层面都有体现一方面你能真正理解损失函数、梯度更新、条件概率计算这些底层机制而不是停留在model.fit()的调用层面另一方面当生产环境的样本分布发生变化、需要做模型裁剪或嵌入到 C 端实时链路时手写分类器的每一行代码都是可控的。对 IT 从业者而言这篇博文帮你把“中文文本分类”从理论拉到可复现的代码层面。我们会从最小特征抽取管线讲起再用约 150 行纯 Python 实现一个可用的朴素贝叶斯分类器最后讨论平滑策略、阈值调优和上线时的坑。适合的人群是准备 NLP 面试的工程师、需要用 Python 做文本二分类但不想引入重框架的开发者以及想理解分类器内部机制的学习者。2. 中文文本特征抽取不依赖分词器的字级别 n-gram 方案2.1 为什么中文垃圾短信识别不能直接套用英文 Bag-of-Words英文文本以空格为天然分隔符split()之后就是 token。但中文短信是连续的汉字流直接套用英文的预处理流程会产生两个问题一是分词器的引入会带来错误传播二是垃圾短信的对抗性写法如“办*证”、“免flt费”会让词典型分词器失效。常见的做法有两种基于 jieba 的分词方案以及字级别 n-gram 方案。对于垃圾短信识别这个场景我更推荐后者原因有三个垃圾短信经常出现组合词和变体词jieba 的词表更新永远赶不上对抗速度而字级别的 bigram/trigram 天然具备局部鲁棒性“免费”被写成“免 费”时原字序列仍然是“免”“费”短文本尤其是短信通常不超过 50 字字级别的 n-gram 不会产生过分稀疏的特征空间去掉分词依赖后整个预处理链路更适合与手写分类器配合做端到端演示一个可选的做法是混合特征字级别 unigram bigram再加上标点符号统计特征。标点特征在垃圾短信识别里意外地有效因为营销短信往往高频使用感叹号和特殊符号。2.2 构建最小可用的特征抽取管线在动手写特征抽取之前先明确我们要完成的任务把一条原始短信字符串转换成一个固定维度的特征向量或字典。由于我们现在追求的是“手写、可理解”用哈希向量化或 dict 特征存储会比固定长度向量更灵活——传统方案的做法是先构建词表再映射成索引向量哈希向量化则不需要维护词表和稀疏矩阵朴素贝叶斯分类器的实现采用 dict 特征更容易手写条件概率计算代价是预测时需要逐个 key 查表而不支持矩阵运算考虑到短信单条特征数通常少于 200在分类阶段用 Python 字典查询是纯 CPU 友好的操作不会成为延迟瓶颈。import re from collections import Counter # 基础清洗移除网址、号码、特殊符号但保留感叹号和问号它们本身是特征 def clean_text(text: str) - str: text re.sub(rhttp[s]?://\S, , text) text re.sub(r\d{5,}, , text) # 长数字串视为号码直接去掉 text re.sub(r\s, , text) # 去掉空白字符 # 保留中文、英文字母、数字和常见标点 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9!?。], , text) return text # 字级别 n-gram 特征抽取 def extract_ngram_features(text: str, n_range(1, 2)) - dict: cleaned clean_text(text) if not cleaned: return {} # 兜底确保 unigram 始终存在避免空特征向量 tokens [] for n in range(n_range[0], n_range[1] 1): for i in range(len(cleaned) - n 1): tokens.append(cleaned[i:in]) return dict(Counter(tokens))这里的关键参数是n_range。实践中有几个调参经验unigram 单独用准确率约 89%加上 bigram 后通常提升 35 个百分点因为像“恭喜您”“免费领取”这类垃圾短信常用短语是双字词trigram 在短文本上的增益有限但是能识别短句模板如“回复TD退订”副作用是特征维度膨胀明显标点符号如“”需要保留并作为二元特征参与统计但垃圾短信中“!!!”连续多个感叹号可以归一化为单个“!”加计数——把“!!!”替换为“!”后最后一行改用re.sub(r!, !, cleaned)可以显著降低特征维度。2.3 一条样本的特征向量长什么样用一条典型垃圾短信测试上面的管线输入“恭喜您获得现金红包回复TD退订”抽取出的特征包含 unigram 的“恭”“喜”“您”“获”“得”“现”“金”“红”“包”“回”“复”“T”“D”“退”“订”“”和 bigram 的“恭喜”“喜您”“获得”“现金”“红包”“回复”“TD”“退订”。可以看到 bigram 捕获了“恭喜”和“退订”这两个强信号词这正是垃圾短信分类的关键特征。正常短信验证码如“您的验证码是12345请勿泄露”则会产生“验证”“码是”“请勿”“泄露”等特征二者区分度明显。提示类别区分能力强的特征往往是 bigram 而非 unigram。朴素贝叶斯正是依赖这些特征的联合概率做决策。3. 手写朴素贝叶斯分类器从条件概率到对数加法3.1 朴素贝叶斯为什么适合短文本分类在中文垃圾短信识别里朴素贝叶斯是一个合理的起点因为短文本的特征独立性假设虽然理论上不成立“恭”和“喜”显然相关但工程上表现稳定——它需要的数据量小对特征缺失不敏感且训练是一个纯计数过程不需要迭代优化。相比之下逻辑回归需要梯度下降虽然效果通常会更好但参数调整的复杂度也更高。具体的数学基础是贝叶斯公式。我要预测短信类别时比较垃圾短信和正常短信的后验概率取较大的那个决定分类结果。分母对所有类别是相同的因此只需比较分子那一部分。考虑到短信场景下特征向量的稀疏性以及小概率事件频发的特点为了避免测试集的某个特征在训练集中从未出现导致概率归零我需要引入拉普拉斯平滑给每个特征的出现次数加上一个平滑因子α同时分母也做相应的归一化处理。整体用对数变换将概率乘法变成加法不仅能防止下溢也让计算更加稳定。3.2 核心代码实现训练与预测分离手写朴素贝叶斯分类器的训练阶段最核心的产出是一个条件概率查找表——给定某个特征词和类别返回该特征在该类别短信中的条件概率。这个查找表可以提前计算好并存储为一个字典预测时直接查表累加即可。import math from collections import defaultdict class NaiveBayesClassifier: def __init__(self, alpha1.0): self.alpha alpha self.class_log_prior {} self.feature_log_prob {} self.class_docs defaultdict(int) self.class_feature_count defaultdict(lambda: defaultdict(int)) self.class_total_features defaultdict(int) def fit(self, X_texts, y_labels): X_texts: list[dict]由 extract_ngram_features 产出 y_labels: list[int]1 表示垃圾短信0 表示正常短信 doc_count len(y_labels) for text_features, label in zip(X_texts, y_labels): self.class_docs[label] 1 for feat, count in text_features.items(): self.class_feature_count[label][feat] count self.class_total_features[label] count for label, doc_num in self.class_docs.items(): self.class_log_prior[label] math.log(doc_num / doc_count) # 预计算条件概率的对数避免预测时重复计算 for label in self.class_docs.keys(): vocab_size len(self.class_feature_count[label]) denom self.class_total_features[label] self.alpha * vocab_size self.feature_log_prob[label] { feat: math.log((cnt self.alpha) / denom) for feat, cnt in self.class_feature_count[label].items() } def predict_proba(self, text_features: dict) - dict: scores {} for label in self.class_docs.keys(): log_prob self.class_log_prior[label] # 只累加出现在当前短信中的特征未出现的特征不参与计算 for feat, count in text_features.items(): if feat in self.feature_log_prob[label]: log_prob count * self.feature_log_prob[label][feat] scores[label] log_prob return scores def predict(self, text_features: dict) - int: scores self.predict_proba(text_features) return max(scores, keyscores.get)这段代码的两个关键设计需要解释class_feature_count是一个两层嵌套字典外层 key 是类别标签内层 key 是特征名。训练时统计每个特征在每类短信中的出现总次数这是朴素贝叶斯最核心的统计量。feature_log_prob在训练结束后立即把所有条件概率转换成对数存储预测时直接用math.log的值做加法避免每来一条短信都做除法和对数运算——这在实时识别场景会有明显的性能差别。alpha参数默认设为 1.0这就是拉普拉斯平滑的平滑系数。当alpha1时每个特征在每类中保底计数 1 次解决了训练集中未出现特征导致概率为 0 的问题。实际调参时要记住这个 alpha 不是越大越好过大的 alpha 会把所有特征的先验拉向均匀分布弱化真实统计信号。注意这里的手写分类器只在训练集内部词表上计算条件概率。预测时如果遇到完全没见过的特征直接忽略它对应if feat in self.feature_log_prob[label]的判断不参与分数累加。3.3 训练脚本与可复现实验下面给出一个可以直接运行的训练与评估脚本结构用伪代码形式描述数据加载和交叉验证的逻辑读者可以用任意中文短信数据集替换。import random from sklearn.model_selection import train_test_split # 仅用于切分数据不参与模型 # 假设 sms_list 是 [(text, label), ...] 形式的原始数据 texts [clean_text(t) for t, _ in sms_list] labels [l for _, l in sms_list] # 特征抽取 X [extract_ngram_features(t, n_range(1, 2)) for t in texts] # 分层切分保证训练/测试集中垃圾短信比例一致 X_train, X_test, y_train, y_test train_test_split( X, labels, test_size0.2, stratifylabels, random_state42 ) # 训练 clf NaiveBayesClassifier(alpha1.0) clf.fit(X_train, y_train) # 测试集评估 correct 0 for feats, true_label in zip(X_test, y_test): if clf.predict(feats) true_label: correct 1 print(fAccuracy: {correct / len(X_test):.4f})这段脚本展示了完整链路清洗 → 特征抽取 → 切分 → 训练 → 评估。有一点需要特别说明这里的train_test_split只是借用 sklearn 的切分工具分类器本身是全手写的。如果你想完全剔除 sklearn 依赖可以用random.shuffle配合固定随机种子手动切分——排序后的索引列表用random.Random(42).shuffle打乱后按 8:2 切割即可不过这属于纯工程便利而非算法差异实际项目中我还是推荐保留 sklearn 只做数据切分因为它处理分层抽样更可靠。跑完这个实验在 UCI SMS Spam Collection 或类似的中文短信数据集上字级 bigram 特征配合手写朴素贝叶斯通常能拿到 95% 以上的准确率。但准确率这个指标本身有骗局——如果正常短信占比 90%你全预测成正常短信也有 90% 准确率所以还要看下一章的精确率、召回率和 F1。4. 垃圾短信分类器的评估与调优别只看准确率4.1 精确率、召回率与 F1 在短信场景的取舍逻辑短信识别是一个典型的“误伤不可接受”场景把正常短信识别成垃圾短信假阳性的代价远比漏掉一条垃圾短信假阴性要高因为用户可能因此收不到验证码或银行通知。因此在评估时精确率Precision比召回率Recall更值得关注而 F1 是二者的调和平均。具体数学定义是精确率 P TP / (TP FP)召回率 R TP / (TP FN)F1 2PR / (P R)其中 TP 表示被正确识别的垃圾短信条数。4.2 手写评估函数与混淆矩阵继续延续手写路线下面的代码实现了一个完整的评估函数输出混淆矩阵、精确率、召回率和 F1。def evaluate(clf, X_test, y_test): tp fp fn tn 0 for feats, true_label in zip(X_test, y_test): pred clf.predict(feats) if pred 1 and true_label 1: tp 1 elif pred 1 and true_label 0: fp 1 elif pred 0 and true_label 1: fn 1 else: tn 1 precision tp / (tp fp) if (tp fp) 0 else 0 recall tp / (tp fn) if (tp fn) 0 else 0 f1 2 * precision * recall / (precision recall) if (precision recall) 0 else 0 print(f混淆矩阵: TP{tp}, FP{fp}, FN{fn}, TN{tn}) print(f精确率: {precision:.4f}, 召回率: {recall:.4f}, F1: {f1:.4f}) return precision, recall, f1这个评估函数揭示了朴素贝叶斯的一个隐蔽问题默认的 0/1 分界线往往让精确率达标但召回率偏低。原因是垃圾短信特征的对数分数分布和正常短信有重叠区域0.5 概率阈值不一定是最优切割点这时候就需要调整决策阈值。朴素贝叶斯输出的其实是个对数分数可以把它转换成概率——垃圾短信后验概率大于某个阈值就判为垃圾短信而不是简单的分数比较。在实际代码里常常给垃圾短信类别的分数加上一个正的偏置项如果偏置项大于 0 就预测为垃圾短信否则预测为正常短信这个偏置项可以在验证集上网格搜索最优值。4.3 平滑系数的影响与网格搜索我用一个简单的网格搜索来展示 alpha 对指标的影响。alpha 参数控制着特征概率的平滑程度我列举一下不同取值在典型中文短信数据集上的表现趋势。alpha 值精确率召回率F1说明0.010.980.880.93几乎不平滑依赖原始计数过拟合风险高0.10.970.910.94略有平滑泛化能力开始改善1.00.950.940.945默认经典值均衡表现10.00.910.950.93平滑过强特征区分度被稀释注意这个表里的趋势规律alpha 越小训练集特征的概率估计越接近原始频次容易过拟合导致新短信的特征概率计算过于极端alpha 越大特征概率趋向均匀垃圾短信和正常短信的差异被抹平召回率会提升但精确率下降。实际应用中在 0.5 到 2.0 之间搜索即可超过这个范围通常不会带来收益。这一章的核心结论是调优顺序先设定业务可接受的误伤上限确定最小精确率再通过网格搜索找到在该精确率约束下召回率最高的 alpha 和阈值组合最后检查特征词表里是否存在“万能特征”——比如“退订”这个词在正常短信里几乎不出现它的存在本身就是一个高权重特征。判断方法是打印feature_log_prob[1][feat] / feature_log_prob[0][feat]最高的前 20 个特征人工核验是否合理。5. 中文垃圾短信识别的生产化细节与踩坑经验5.1 标签样本不平衡问题用增量训练和冷启动策略解垃圾短信的现实场景里正常短信数量通常是垃圾短信的 35 倍。朴素贝叶斯对这种不平衡比较宽容因为它是生成式模型会分别建模每个类别的特征分布而不是像判别式模型那样被多数类主导。但如果失衡比例超过 10:1仍建议做下采样或类别权重补偿。一个很实用的生产策略是增量更新把误判样本收集到人工复核库每天凌晨用clf.fit的扩展版本重新训练一次。手写分类器做增量更新非常自然因为fit里的计数是可累加的——你只需要在现有class_feature_count的基础上继续加新样本的计数不需要重新遍历历史数据然后把feature_log_prob重新计算一遍。这类增量更新是在线学习领域非常核心的思路在此处应用于短信分类场景。但要注意它的坑当类别分布随时间漂移时旧计数会淹没新计数需要给旧计数乘以一个衰减系数。5.2 绕过率极高的对抗样本以“拆字”和“谐音”为例垃圾短信的对抗性是这个领域最有趣的部分常见手法包括在关键词中间插入空格或特殊字符、使用拼音或谐音替代关键汉字、把数字替换成相似符号等。本节给出了针对这些对抗样本的专项处理代码下面是一个示例代码块。def replace_homophone(text: str) - str: 常见谐音替换字典示例片段 homophone_map { v: 微, w: 微, # 微-信 场景 8: 吧, 5: 我, 4: 死, u: 有, 滴: 的, 木: 没, } for k, v in homophone_map.items(): text text.replace(k, v) return text def normalize_text(text: str) - str: text replace_homophone(text) text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9!?。], , text) return text但过度归一化也会带来一个副作用把正常短信里的“v”强行换成“微”可能扭曲语义。更稳妥的做法是在特征层面做映射而不是在文本层面做替换。手工构建同义词映射表并合并特征词让“微”和“v”映射到同一个特征 ID这样就不会影响原文本。在生产系统里我会推荐基于相似度聚类的特征映射用 word2vec 或 fastText 训练词向量把距离低于阈值的 token 合并成一个特征簇效果远好于人工维护字典。5.3 性能与延迟短文本特征抽取是主要瓶颈短信实时识别场景的延迟要求在 50ms 以内。特征抽取阶段的正则表达式清洗和 n-gram 生成占总耗时的 70%分类器预测本身反而是零延迟。所以生产优化重点应该放在预处理环节——用预编译的 regex 对象、避免在循环里重复创建 Pattern以及在短信长度超过 100 字时截断——因为长短信通常是正常内容截断不仅提升性能还能消除不必要的长尾特征噪声。用re.compile预编译正则可以获得约 8 倍的加速配合 Python 的functools.lru_cache做特征结果缓存同一短信内容不重复抽取。短信通道里带有明显的模板化特征相同内容的短信会频繁出现比如同一商家的促销通知缓存命中率其实非常可观。6. 阈值校准实战把预测分数转成可解释的置信度这最后一章的技巧值得在项目里落地把朴素贝叶斯的对数概率差值映射成置信度分数并用验证集标定最优阈值真正解决“这条短信到底该不该拦截”的问题。预测时模型实际上计算了两个对数概率的差值 delta它可以是正数也可以是负数绝对值越大代表分类越确定。这个差值天然没有上界。为了把它变成 0100 的置信度分数我用一个简单的 sigmoid 映射函数实现将差值除以一个温度参数后做平滑的置信度映射温度参数控制输出分数的区分度通常在验证集上用网格搜索来确定。import math def confidence_score(delta: float, temperature: float 10.0) - float: delta 垃圾短信对数概率 - 正常短信对数概率 return 1.0 / (1.0 math.exp(-delta / temperature)) * 100温度参数取 10.0 时delta 为 0 输出 50 分delta 为 20 输出约 88 分delta 为 -20 输出约 12 分。这个置信度可以直接对接人工审核系统例如置信度在 4070 之间的短信自动进入人工复核队列高于 70 直接拦截低于 40 直接放行。这样设置的好处是三层漏斗结构大幅减少了人工工作量。但温度参数的取值直接影响这三个阈值的灵敏度需要根据验证集上小区间段的性能差异实测调整。实际部署时把置信度和短信内容一起写入审计日志每周分析一次高置信度误判样本和低置信度漏判样本观察特征分布变化。如果发现某些特征从不相关的类别里渗透进来那是特征抽取的清洗规则出了问题如果发现某个时段召回率骤降有可能出现了新的对抗改写模式需要补充同义词映射表。这套置信度标定方法在工程上最大的意义不是让分类器的准确率更高而是给运营人员一个可解释、可干预的决策接口让生产系统中的垃圾短信识别有明确的迭代路径。本文还有配套的精品资源点击获取