简介:这是一份可直接运行的Python中文垃圾短信识别毕业设计项目,内置手写分类器实现,适合计算机相关专业学生用于课程设计或期末大作业。项目覆盖中文分词、文本向量化、朴素贝叶斯、逻辑回归、感知机及SVM等多种分类模型,并提供带标签与不带标签短信语料。资源共23个文件,以12个Python源码、7个pkl格式训练模型文件为主,附带说明文档与语料txt,整体压缩包约47.94MB,结构模块化,便于按需调参和二次开发。目前已有52人学习下载。代码完整、注释清晰,并配有文档说明,即使基础薄弱也能参照复现整个训练与预测流程,适合作为毕业设计或项目实战练习的完整参考。
1. 中文垃圾短信识别这套毕业设计源码,值得复现的地方在哪
我见过不少同学拿到“基于 Python 的中文垃圾短信识别源码+文档说明(手写分类器)”之后,第一反应是先去找 sklearn 里的现成接口,把“手写”两个字替换掉。这个思路能跑通,但毕业设计答辩时你会发现自己讲不出十分钟。手写分类器在这类项目里的价值,不是比调库模型更准,而是让你把一条短信从原始文本到最终判定为垃圾的全部路径,都拆成自己能解释的步骤:分词、抽特征、算概率、定阈值,每一步都是拿得出手的内容。
这套东西适合两类人:一类是正在做毕业设计、需要快速复现并改造成自己版本的学生;另一类是刚入行做内容风控的新手,想理解朴素贝叶斯这类老牌算法在短文本分类里到底有几斤几两。本文不打算复述项目里的每一行代码,而是按照我从数据准备到分类器落地的一线顺序,讲清楚怎么搭、参数怎么调、哪些坑一定会踩。识别这类任务的关键不在于模型有多复杂,而在于前置处理是否干净,以及你愿不愿意把阈值当作超参数去调。
2. 从原始短信到特征矩阵:数据清洗、分词与手写词频特征
2.1 先统一 CSV 编码、字段和分训练/验证/测试集
垃圾短信识别的第一步不是写分类器,而是把数据弄干净。常见的原始数据是几个文本文件或 CSV,字段往往不统一,有的叫“短信内容”,有的只有内容没有标签。我一般会先读成两张表:一张放正常短信,一张放垃圾短信,然后统一加上 0/1 标签,而不是用字符串“正常”“垃圾”,否则后面写特征时要到处做字符串判断。
import numpy as np import pandas as pd def load_dataset(spam_path, normal_path): # 统一用数字标签,0 表示正常短信,1 表示垃圾短信 spam = pd.read_csv(spam_path, header=None, names=["content"], sep="\t") normal = pd.read_csv(normal_path, header=None, names=["content"], sep="\t") spam["label"] = 1 normal["label"] = 0 data = pd.concat([spam, normal], ignore_index=True) # 垃圾短信数据集里重复数据比例很高,直接按内容去重 data.drop_duplicates(subset="content", inplace=True) data = data[data["content"].notna()] return data def split_by_index(data, ratio=(0.7, 0.15, 0.15)): # 手写分层切分,不依赖 sklearn,避免把随机种子藏起来 rng = np.random.RandomState(2024) idx = rng.permutation(len(data)) n_train = int(len(idx) * ratio[0]) n_dev = int(len(idx) * ratio[1]) train = data.iloc[idx[:n_train]].reset_index(drop=True) dev = data.iloc[idx[n_train:n_train + n_dev]].reset_index(drop=True) test = data.iloc[idx[n_train + n_dev:]].reset_index(drop=True) return train, dev, test这里有个容易被忽略的点:我特意保留了 dev 验证集和 test 测试集的区分。调参阶段只看 dev,test 要留到最后一次性使用,否则你反复用测试集试参数,测出来的分数会虚高,答辩时被老师一问就露馅。RandomState(2024)固定了随机种子,保证每次切分结果一致,这也是毕业设计里必须养成的习惯,不然今天跑出 0.9 明天跑出 0.7,根本没法解释。
2.2 分词策略:jieba 为主、短文本字符 n-gram 兜底、URL 先抽象
中文短信没法像英文那样按空格分词,常见做法是用 jieba 做词粒度切分,但对短信这种口语化、短文本居多的语料,jieba 的效果并不总是理想。比如“秒到1”这种话,分词器可能切得很碎,甚至把数字和汉字拆开。我的策略是先把 URL 和数字抽象成占位符,再做分词,最后对过短的文本走字符 n-gram 兜底。
import jieba import re URL_PATTERN = re.compile(r"https?://\S+|www\.\S+|[a-zA-Z0-9]+\.[a-z]{2,}") def cut_text(text, max_len=200): # 把链接替换成占位符,避免分词器把域名切碎 text = URL_PATTERN.sub(" [URL] ", str(text)) text = text[:max_len] words = [w.strip() for w in jieba.cut(text) if w.strip()] return words def cut_text_with_char_fallback(text, min_n=2, max_n=4): words = cut_text(text) if len(words) >= 2: return words # 短文本直接做字符 n-gram,保留“秒到”“到账”这类片断 text = re.sub(r"\s+", "", text) return [text[i:i + n] for n in range(min_n, max_n + 1) for i in range(len(text) - n + 1)][URL]这个占位符很有用。垃圾短信里的链接域名经常变,但“短信里出现了链接”这件事本身是一个强区分信号。把 URL 抽象成一个固定记号,分类器就能学到“带链接的短信更可疑”这一层统计规律,而不是被不断更换的域名带偏。max_len=200是我推荐的截断长度,绝大多数短信在 100 字以内,尾部多是签名或标点,留着只会增加计算量。
2.3 手写词表与向量化:min_df、max_features 和长度归一化
很多教程直接教你调 sklearn 的 CountVectorizer,但手写分类器项目里最好把这层也自己实现。核心逻辑很简单:先统计每个词出现在多少条文本里,过滤掉只出现一两次的噪声词,再按频次排序截断成一个固定大小的词表。我习惯把每条文本转成一个长度为词表大小的稀疏向量,分量就是该词在这条短信里出现的次数。
from collections import Counter def build_vocab(texts, min_df=2, max_features=5000, tokenizer=cut_text_with_char_fallback): counter = Counter() for text in texts: tokens = set(tokenizer(text)) # 同一文本内重复词只记一次 counter.update(tokens) # min_df 过滤低频词,再按文档频率降序截断 ordered = sorted(counter.items(), key=lambda x: (-x[1], x[0])) vocab = [w for w, c in ordered if c >= min_df][:max_features] return {w: i for i, w in enumerate(vocab)} def text_to_vector(text, vocab, tokenizer=cut_text_with_char_fallback): vec = np.zeros(len(vocab), dtype=np.float32) for word in tokenizer(text): idx = vocab.get(word) if idx is not None: vec[idx] += 1 return vec这里的min_df=2表示一个词至少要在两条不同短信里出现,否则就当作噪声丢弃。max_features=5000控制词表长度,太大了会把长尾词和拼写错误当成宝贝,太小了又装不下“贷款”“秒到账”这类核心触发词。向量化之后,我还会对向量做一步长度归一化,常见写法是把原始词频取log1p再除以向量模长:
# 词频做对数压缩,再按文本长度归一化,避免长短信天然占优势 raw_vec = text_to_vector(text, vocab) tf_vec = np.log1p(raw_vec) tf_vec = tf_vec / (np.sqrt((tf_vec ** 2).sum()) + 1e-8)这一步会显著改善分类稳定性,因为有些正常通知类短信本身就很长,词频总和天然比短垃圾短信大,不归一化的话,模型会优先按“字数多”来判正常,这是新手最容易翻车的地方。
3. 手写朴素贝叶斯分类器:条件概率、平滑与预测阈值怎么设
3.1 朴素贝叶斯在稀疏中文文本上为什么可用
朴素贝叶斯的“朴素”体现在一个很强的假设:特征之间相互独立。中文分词后,词与词之间明明有语序关系,“先转账后发货”和“先发货后转账”意思完全不同,为什么这种模型还能用?原因是垃圾短信识别的任务目标不是理解语义,而是捕捉触发词的统计规律。垃圾短信里的“中奖”“贷款”“加微信”“点击链接”这些词,出现频率显著高于正常短信,即使不考虑它们在句子中的位置,也能把绝大多数垃圾短信区分出来。
这个模型计算的是后验概率:给定一条短信的特征向量,分别计算它属于垃圾类的概率和属于正常类的概率,哪个大就判哪个。具体实现时会用贝叶斯公式,把后验概率拆成先验概率和条件概率的乘积。由于特征多、概率值小,直接连乘会下溢成 0,所以实际工程里都在 log 空间做加法。
3.2 fit 和 predict 源码:拉普拉斯平滑放在 log 空间里做
手写分类器的核心类大致长这样:fit负责从训练集估计先验概率和每个词在各类别下的条件概率;predict负责把输入向量转成对数后验,再通过阈值输出 0/1 判断。我强调一句:这一步不要用 sklearn 的 MultinomialNB,否则项目标题里的“手写”就没有落点,答辩时也说不出拟合过程。
class NaiveBayesSpam: def __init__(self, alpha=1.0): self.alpha = alpha def fit(self, X, y): n_total, n_features = X.shape n_spam = int(np.sum(y == 1)) # 先验概率 P(垃圾),后续统一在 log 空间计算 self.prior_ = np.log(n_spam / n_total) X_spam = X[y == 1] X_normal = X[y == 0] # 拉普拉斯平滑:分子加 alpha,分母加 alpha*特征数 # 防止某个词在训练集里只出现在正常短信中,导致垃圾侧概率为 0 spam_cnt = X_spam.sum(axis=0) + self.alpha normal_cnt = X_normal.sum(axis=0) + self.alpha self.cond_spam_ = np.log(spam_cnt / spam_cnt.sum()) self.cond_normal_ = np.log(normal_cnt / normal_cnt.sum()) def predict_proba(self, X): # log P(垃圾|X) 正比于 log P(垃圾) + sum(log P(词|垃圾)) log_spam = self.prior_ + X @ self.cond_spam_ log_normal = np.log1p(-np.exp(self.prior_)) + X @ self.cond_normal_ # 利用 logsumexp 归一化,保证输出可以解释为 0~1 的概率 log_sum = np.logaddexp(log_spam, log_normal) return np.exp(log_spam - log_sum) def predict(self, X, threshold=0.5): proba = self.predict_proba(X) return (proba >= threshold).astype(int), proba这里最容易调坏的是alpha。alpha=1.0是经典平滑默认值,但垃圾短信数据往往类别不均衡、词汇集中在少数触发词上,平滑系数过大时,低频词的条件概率会被拉向一个平庸值,相当于把“贷款”和“明天”的区分度磨平了。我一般在 0.01 到 1.0 之间做小网格搜索,而不是死守默认值。
predict的参数threshold也很关键。默认 0.5 意味着“垃圾概率过半才判垃圾”,但如果训练集中垃圾短信只占 10%,先验概率会把绝大多数样本推向正常侧,此时 0.5 并不是最优决策边界。把这个阈值留给后面的调参环节,不要写死。
3.3 源码里的文档与模块怎么组织,答辩时讲什么
一份能交付的毕业设计源码,不应该只有一个分类器文件。我一般会按功能拆成data_loader.py、tokenizer.py、features.py、naive_bayes.py、evaluate.py,再加一个README.md说明运行步骤和参数含义。数据放data/目录,训练出来的模型参数存成npz或pkl,方便预测脚本直接加载。
答辩时讲手写分类器的优势,重点讲“如何把后验概率拆成先验加条件概率”和“拉普拉斯平滑为什么能防止零概率”。这两个点是自己写代码后能真正讲清楚的,也是调库选手最容易含糊的地方。文档里还可以画一张简明的流程链:原始短信进入分词模块,经 URL 占位和 n-gram 处理变成词序列,再映射成词频向量,最后经朴素贝叶斯算概率和阈值比较输出结果。
4. 评估与调参:混淆矩阵、推荐参数区间与手写网格搜索
4.1 手写混淆矩阵,别用准确率掩盖漏判
第一版模型跑出来时,准确率可能高达 90% 以上,但这未必是好事。如果数据集里正常短信占 85%,模型把所有短信都判成正常,准确率也有 85%。真正要看的指标是垃圾短信的召回率和精确率,召回率低意味着垃圾短信漏过了漏斗,精确率低则意味着正常短信被误杀。手写一套混淆矩阵统计是很值得的:
def evaluate(model, X_test, y_test, threshold=0.5): pred, _ = model.predict(X_test, threshold=threshold) tp = int(np.sum((pred == 1) & (y_test == 1))) # 垃圾被判垃圾 tn = int(np.sum((pred == 0) & (y_test == 0))) # 正常被判正常 fp = int(np.sum((pred == 1) & (y_test == 0))) # 正常被判垃圾 fn = int(np.sum((pred == 0) & (y_test == 1))) # 垃圾被判正常 acc = (tp + tn) / len(y_test) precision = tp / (tp + fp + 1e-9) recall = tp / (tp + fn + 1e-9) f1 = 2 * precision * recall / (precision + recall + 1e-9) return {"acc": acc, "precision": precision, "recall": recall, "f1": f1}在垃圾短信识别里,我一般优先保证召回率不要过低,因为用户收到垃圾短信的烦恼,远大于个别正常推广短信被拦下。但毕业设计答辩时老师更愿意看到你解释四个指标的关系,而不是甩一个准确率出来。evaluate.py里还应该把预测错的样本单独导出,方便人工查看是哪一类短信在翻车。
4.2 关于 alpha、词表大小、截断长度和阈值的建议区间
手写分类器的参数不多,但每个都值得单独调。我把最常调的五个整理成表,方便你照着试:
| 参数 | 作用 | 建议范围 | 调大后现象 |
|---|---|---|---|
| alpha 平滑系数 | 控制低频词的条件概率 | 0.01 ~ 1.0 | 太大则各类别概率被拉平 |
| max_features | 词表长度 | 3000 ~ 15000 | 太大引入低频噪声 |
| min_df | 词最少出现文档数 | 1 ~ 5 | 太大丢特色触发词 |
| max_len | 文本截断长度 | 150 ~ 200 | 太短丢尾部关键词 |
| threshold | 垃圾判定阈值 | 0.2 ~ 0.5 | 太高漏垃圾,太低误杀正常 |
这几个参数不是独立作用的。比如把max_features从 5000 放到 15000,低频词变多,alpha就需要适当调大来压制噪声。我见过不少人在答辩里被问“为什么 max_features 选 5000”,答案应该是“在小规模验证集上 5000 比 3000 和 10000 的 F1 都好”,而不是“觉得这个数顺眼”。
4.3 参数网格搜索必须在 dev 上做,test 留到最后
调参最忌讳的是拿 test 集反复试参数,试出来一个漂亮数字就当作最终结果。正确做法是把 test 当成黑匣子,只在验证集 dev 上搜索参数,定稿后再测一次 test。我习惯写一个极简的网格搜索,把核心参数穷举一遍:
best = {"f1": 0.0, "params": None} for alpha in [0.01, 0.1, 0.5, 1.0]: for threshold in [0.2, 0.3, 0.4, 0.5]: model = NaiveBayesSpam(alpha=alpha) model.fit(X_train, y_train) metrics = evaluate(model, X_dev, y_dev, threshold=threshold) print(f"alpha={alpha}, thr={threshold}, f1={metrics['f1']:.3f}") if metrics["f1"] > best["f1"]: best = {"f1": metrics["f1"]} best["params"] = {"alpha": alpha, "threshold": threshold}这个代码看起来简单,但对应了一个重要的方法论:调参本身是有监督的模型选择,若拿测试集来做,测试集就被污染了,最终分数不具备可复现性。小网格搜索跑在 1 万条训练样本上,用我这个朴素贝叶斯实现,也就几秒钟到几十秒的事,完全没必要上复杂框架,这是手写分类器的一大优势。
5. 中文垃圾短信识别避坑:编码错乱、样本倾斜与验证码式误判
5.1 现象:pd.read_csv 一读就 UnicodeDecodeError,训练半天全是废数据
很多原始短信数据集是直接从网页或旧系统导出的,在 Windows 上常用 GBK 或 GB2312 编码,而 Python 默认用 UTF-8 读取,第一条数据就崩给你看。更隐蔽的情况是文件前半段是正常的,读几千行后混进一条非法编码的短信用例,导致加载到一半报错。
原因也很简单:CSV 没有内嵌编码声明,读取时只能靠猜。解决方法是先指定encoding="gbk"读取,失败再回退到 UTF-8,同时把加载环节包一层异常处理。数据加载稳定后,再统一转存成 UTF-8 格式,后面所有代码就只用 UTF-8 一种编码,少踩很多坑。这条我建议写进 README,因为换一台电脑环境就很容易重犯。
5.2 现象:垃圾短信只占 10%,模型学成“全判正常”
当训练集里正常短信和垃圾短信比例悬殊时,朴素贝叶斯会被先验概率带跑。由于P(垃圾)本身很低,模型在 test 集上即使把每条短信都判成正常,准确率也有 90% 左右,但垃圾短信的召回率几乎为 0。这时候看整体准确率毫无意义,必须拆出混淆矩阵看那一列垃圾短信的命中情况。
解决思路有两条。一条是调整判定阈值,把阈值从 0.5 降到 0.2 或 0.3,让垃圾概率稍占优势时就触发拦截;另一条是训练时对正常类做欠采样,比如只保留与垃圾短信相当数量的正常样本,让模型在训练阶段看到 1:1 的类别比例。我通常先用阈值调,因为欠采样会丢掉正常短信里的长尾表达,反而增加误杀风险。
5.3 现象:“验证码+短链”在两类短信里同时出现,特征怎么撞车
这是垃圾短信识别里最经典的误判场景:正常短信说“您的验证码是 123456,请勿泄露”,垃圾短信也说“点击链接领取验证金,秒到账”。两类短信里都出现了“验证码”和“链接”相关概念,字面词频特征高度重合,模型很容易把正常验证码短信误判为垃圾。
解决的核心是“把特征抽象到类别差异上”,而不是逐字匹配。我在分词前先把链接统一替换成[URL]占位符,这样模型学到的是“含链接”这个事件概率,而不是某个具体域名。同时可以往特征向量里补充两个手工特征:文本长度和数字占比。垃圾短信里的数字占比往往比正常验证码短信高,因为要留联系电话和金额信息。
def add_meta_features(text, tf_vec): # 数字占比和文本长度是两个便宜但有效的补充特征 digits = sum(ch.isdigit() for ch in text) digit_ratio = digits / (len(text) + 1e-9) meta = np.array([len(text), digit_ratio], dtype=np.float32) return np.concatenate([tf_vec, meta])加了这两个特征之后,向量会变成 5002 维,分类器不需要改变结构,只是特征矩阵多了两列。调参时留意别再让这两列被max_features截断掉,向量化阶段要在截断之后拼接。
5.4 现象:手工加入“加微信”特征,正常客服短信开始被误杀
很多人的第一反应是总结一批“垃圾词”,比如“加微信”“秒到账”“免息贷款”,然后把这些词当作硬规则加入特征。这个方向没有错,但会被善用同一类措辞的正常服务短信误伤,尤其是电商客服和快递通知,他们的用语和垃圾短信经常只有一线之差。一旦手工规则加得太硬,模型反而失去从概率角度权衡的能力。
原因在于硬规则把某个词的权重变成了一票否决。而朴素贝叶斯的本意是让每个词通过条件概率贡献 log 增量,垃圾词出现时加分,正常短信高频词出现时减分,最后由整体分数决定。所以我更建议把手工词只用作特征构造的起点,不直接参与排序。比如“加微信”这种词,可以在垃圾样本里加强其在垃圾类别的条件概率,但不能让它单独触发“一票垃圾”的判定。把这个原则写进文档,答辩时也是一个经得起问的取舍。
6. 进阶:高置信度自学习、黑白名单和手写分类器的适用边界
6.1 把高置信度预测结果回流到训练集,让分类器继续长跑
毕业设计做完了,项目不能停在交付那天。真实场景里垃圾短信演变很快,今天靠“中奖”特征拦截,明天对方就换成“积分兑换现金”。常见做法是周期性把预测概率极高和极低的样本挑出来,经人工抽查确认后回流到训练集,增量更新词频和条件概率。这种自学习方式对手写分类器特别友好,因为重新 fit 的成本极低,不需要加载庞大模型。
def merge_feedback(proba, texts, high=0.95, low=0.05): # 只回流非常有把握的样本,中间灰色地带留给人工判断 new_spam = [texts[i] for i, p in enumerate(proba) if p >= high] new_normal = [texts[i] for i, p in enumerate(proba) if p <= low] return new_spam, new_normal这个回流策略必须加一道人工抽检,否则模型会在自己的盲区里一遍遍自我强化,我见过有人连续回流三轮后,把一批正常验证码短信全学坏,判成垃圾。概率高不等于标签对,这一点拿不准的时候就不回流。
6.2 加一个轻量黑白名单,拦下实时短信里的高命中词
在手写分类器的解码前加一个黑白名单小表,是这个任务里性价比最高的兜底方案。黑名单里放“免息”“秒到账”这类高命中词,命中就打标为垃圾;白名单里放“验证码”“快递”这类在正常短信里高频出现的词,命中时即使分类器给了较高垃圾概率,也值得再复核一轮。黑白名单的作用不是替代分类器,而是用几十条规则解决长尾样本,让贝叶斯模型专心处理更精细的判断。
6.3 什么场景不值得上手写分类器
这套方案最大的优点是可以完全脱离 sklearn 运行,只在requirements.txt里写上numpy、jieba、pandas,适合做教学、毕业设计和轻量级内容过滤。但如果你的目标是处理百万级实时短信,或者短信内容包含大量语义混淆的表达,比如同音字、繁体穿插、口语化闲聊,手写词袋分类器会明显吃力。写这块两年,我养成的习惯是:先做数据分析,把编码、类别比例和误判热点摸清楚,再决定要不要升级模型,而不是一开始就追着新框架跑。
希望这套“数据清洗到分类器落地”的路径能帮你在复现“中文垃圾短信识别”项目时少走点弯路。手写分类器这条路看起来绕,但它给你留下的是能讲清楚的原理和能继续改的代码,这才是一个毕业设计项目真正值钱的部分。
本文还有配套的精品资源,点击获取