简介:这份资源是面向计算机相关专业学生与项目实战学习者的中文垃圾短信识别毕业设计项目,基于Python实现,采用手写分类器方案,可帮助读者完成课程设计、期末大作业或毕业设计中的文本分类任务。压缩包共23个文件,以12个py源码文件为核心,涵盖分词处理、模型管理与垃圾短信判定等模块,另含7个pkl模型文件、2个txt短信数据集及md说明文档,整体约47.94MB,目录结构清晰,便于按模块阅读与调试。项目已获导师指导并认可,评审分99分,代码完整可运行,对新手较为友好。读者可从中获得完整的手写分类器实现思路,包括朴素贝叶斯、逻辑回归与感知机等模型的训练与对比,以及短信分词、特征保存和测试判定的具体代码,同时配套文档说明有助于快速理解项目结构与运行流程。目前已有52人学习,适合需要文本分类实战经验或毕业设计参考的学习者。
1. 中文垃圾短信识别:从一条“中奖通知”说起
你手机里一定收到过这种短信:“恭喜您被抽中一等奖,点击链接领取”。它可能是最经典的垃圾短信样本,也是几乎所有中文垃圾短信识别项目里第一个被拿来测试的案例。基于 Python 的中文垃圾短信识别,核心任务就一件事:给一条短信,判断它是正常短信还是垃圾短信。而“手写分类器”意味着不直接调用现成的深度学习模型,而是从分词、特征提取到分类算法全部自己实现一遍。这恰恰是毕业设计项目最该做的事——你得讲清楚每一步为什么这么做,而不是调个库就交差。这个方向适合计算机、软件工程、大数据专业的毕业生,也适合想入门 NLP 的 Python 学习者。它不要求 GPU,一台普通笔记本就能跑完全流程,但能让你真正理解文本分类的完整链路。
2. 手写分类器到底“手写”什么:拆开中文短信识别的四层结构
2.1 为什么中文短信不能直接套用英文文本分类流程
英文文本天然以空格分词,“You won a prize”直接split()就能得到三个词。中文不行。“恭喜您中奖了”如果按字切分,“中”和“奖”单独看都没有“中奖”这个整体含义;如果按词切分,“恭喜”、“您”、“中奖”才是合理的语义单元。所以中文垃圾短信识别的第一步必须是分词,而分词质量直接决定后续特征的好坏。
另一个差异是短信文本极短。一条短信通常 20 到 70 个字,去掉停用词后有效特征可能只剩十几个。这意味着特征工程必须做得足够精细,不能像长文本分类那样依赖词频统计的“大数定律”。常见做法是:分词后保留名词、动词、形容词,过滤掉“的”“了”“吗”这类高频但无区分度的词,再结合 TF-IDF 或词袋模型做向量化。
还有一点容易被忽略:垃圾短信有很强的模板化特征。比如“点击链接”“退订回T”“限时领取”这些短语反复出现。手写分类器的优势就在于,你可以针对这些领域特征做定制化的特征提取,而不是把一切交给预训练模型。
2.2 从原始短信到特征向量:分词、去停用词、向量化三步走
先安装依赖。jieba用于中文分词,scikit-learn用于特征提取和分类器评估,pandas用于数据管理。
pip install jieba scikit-learn pandas numpy如果你用的是 PyCharm 或 VSCode,在项目根目录建一个requirements.txt,把上面四行写进去,后续换机器直接pip install -r requirements.txt就能恢复环境。这是毕业设计项目文档里必须写清楚的一步,答辩老师很可能会问你“换台电脑怎么跑”。
接下来是分词和去停用词的核心代码:
import jieba import re # 加载停用词表,每行一个词 def load_stopwords(path='stopwords.txt'): with open(path, 'r', encoding='utf-8') as f: return set(line.strip() for line in f if line.strip()) STOPWORDS = load_stopwords() def preprocess(text): # 去掉 URL、电话号码、邮箱等噪声 text = re.sub(r'http[s]?://\S+', '', text) text = re.sub(r'\d{11}', '', text) text = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+', '', text) # 只保留中文和基本标点 text = re.sub(r'[^\u4e00-\u9fa5,。!?]', '', text) # jieba 精确模式分词 words = jieba.lcut(text) # 过滤停用词和单字 words = [w for w in words if w not in STOPWORDS and len(w) > 1] return ' '.join(words)这段代码做了四件事:去 URL、去手机号、去邮箱、去非中文字符。为什么要去这些?因为垃圾短信里的链接和号码是强特征,但它们的值每次都变,直接作为特征会导致模型只记住“有链接就是垃圾”,换一批数据就失效。更好的做法是在特征工程阶段单独构造“是否包含链接”这样的二值特征,而不是把链接本身当词。
jieba.lcut用的是精确模式,适合短信这种短文本。如果你发现某些领域词被切碎了,可以用jieba.add_word('退订回T')手动添加自定义词典。停用词表可以从网上找现成的中文停用词表,也可以自己根据短信语料统计高频无意义词。
向量化用 TF-IDF:
from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( max_features=5000, # 最多保留 5000 个特征词 ngram_range=(1, 2), # 同时考虑单字词和双字词组合 min_df=2, # 至少在 2 条短信中出现过才保留 max_df=0.9 # 出现在 90% 以上短信中的词丢弃 ) X = vectorizer.fit_transform(corpus)ngram_range=(1,2)是关键参数。中文里“中奖”是一个词,但“中奖了”和“中奖啦”可能被切成不同形式,用二元组可以部分缓解这个问题。min_df=2过滤掉只出现一次的生僻词,max_df=0.9过滤掉“你好”“谢谢”这类几乎所有短信都有的词。这三个参数没有绝对最优值,需要根据你的数据集规模调整。数据量小于 1000 条时,min_df可以设为 1,否则特征太稀疏。
2.3 手写朴素贝叶斯分类器:公式、拉普拉斯平滑与代码实现
朴素贝叶斯是文本分类里最适合“手写”的算法。它的假设很简单:给定类别,各个特征之间相互独立。虽然这个假设在真实语言里不成立,但在短文本分类任务上效果出奇地稳。
核心公式是:
P(垃圾|短信) ∝ P(垃圾) × ∏ P(词i|垃圾)
取对数后变成加法,避免下溢:
log P(垃圾|短信) = log P(垃圾) + Σ log P(词i|垃圾)
手写实现的关键在于计算每个词在垃圾短信和正常短信中的条件概率,并且必须做拉普拉斯平滑,否则遇到训练集里没出现过的词,概率会变成 0,整个乘积归零。
import numpy as np from collections import defaultdict class NaiveBayesClassifier: def __init__(self, alpha=1.0): self.alpha = alpha # 拉普拉斯平滑系数 self.class_prior = {} self.word_prob = {} self.vocab = set() def fit(self, X_words, y): # X_words: 列表,每个元素是分词后的词列表 # y: 标签列表,0 正常,1 垃圾 n = len(y) classes = set(y) # 统计每个类别的先验概率 for c in classes: self.class_prior[c] = (y.count(c) + self.alpha) / (n + self.alpha * len(classes)) # 统计每个类别下每个词的出现次数 word_count = {c: defaultdict(int) for c in classes} total_count = {c: 0 for c in classes} for words, label in zip(X_words, y): for w in words: word_count[label][w] += 1 total_count[label] += 1 self.vocab.add(w) # 计算条件概率,带拉普拉斯平滑 V = len(self.vocab) for c in classes: self.word_prob[c] = {} for w in self.vocab: self.word_prob[c][w] = (word_count[c][w] + self.alpha) / (total_count[c] + self.alpha * V) def predict(self, words): scores = {} for c in self.class_prior: score = np.log(self.class_prior[c]) for w in words: if w in self.vocab: score += np.log(self.word_prob[c][w]) scores[c] = score return max(scores, key=scores.get)alpha=1.0就是标准拉普拉斯平滑。如果你发现模型对训练集里没见过的词过于敏感,可以把alpha调大,比如 2.0 或 5.0,相当于给每个词一个更强的先验。但调太大也会让所有词的概率趋于均匀,区分度下降。一般从 1.0 开始试。
预测时只累加在词表里出现过的词,没见过的词直接跳过。这是工程上的简化,严格来说应该给未知词一个很小的概率,但实际效果差别不大。
2.4 用混淆矩阵和 F1 值验证分类器到底能不能用
训练完不能只看准确率。垃圾短信识别是典型的不平衡分类问题,正常短信通常远多于垃圾短信。如果 95% 的短信是正常的,你全猜“正常”也有 95% 准确率,但这样的模型毫无用处。
必须看混淆矩阵和 F1 值:
from sklearn.metrics import confusion_matrix, classification_report y_pred = [model.predict(words) for words in X_test_words] print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred, target_names=['正常', '垃圾']))重点看垃圾短信那一类的召回率(recall)。召回率低意味着大量垃圾短信被漏判。在短信过滤场景里,漏判比误判更严重——用户被骚扰一次可能就卸载你的应用了。如果召回率低于 0.85,优先调整特征:增加ngram_range到(1,3),或者手动添加领域关键词到自定义词典。
3. 数据从哪来、怎么洗:毕业设计里最容易被卡住的一步
3.1 公开中文短信数据集的获取与格式统一
常见做法是使用公开的中文短信数据集,通常以 CSV 或 TSV 格式提供,包含两列:标签和短信文本。标签一般用 0/1 或“正常/垃圾”表示。拿到数据后第一件事是统一格式:
import pandas as pd df = pd.read_csv('sms_dataset.csv', encoding='utf-8') # 统一列名 df.columns = ['label', 'text'] # 标签映射:正常->0,垃圾->1 label_map = {'正常': 0, '垃圾': 1, 'ham': 0, 'spam': 1, 0: 0, 1: 1} df['label'] = df['label'].map(label_map) # 去掉空值和重复值 df = df.dropna(subset=['text', 'label']) df = df.drop_duplicates(subset=['text']) print(df['label'].value_counts())value_counts()必须打印。如果垃圾短信只占 5%,你就要考虑过采样或调整类别权重。手写分类器里可以通过修改class_prior的计算方式来加权,比如给垃圾类一个更大的先验。
3.2 短信文本特有的清洗规则:链接、号码、签名
除了前面preprocess函数里的通用清洗,短信还有两个特殊结构:签名和退订指令。
签名通常是短信末尾的【某某公司】,它对判断垃圾短信有一定参考价值,但具体公司名每次都不同。建议提取“是否包含签名”作为一个二值特征,而不是保留签名文本。
退订指令如“退订回T”“回复TD退订”是垃圾短信的强特征,因为正常短信很少带这个。可以在预处理阶段检测这些关键词,生成一个布尔特征列。
def extract_extra_features(text): features = {} features['has_url'] = 1 if re.search(r'http[s]?://', text) else 0 features['has_phone'] = 1 if re.search(r'\d{11}', text) else 0 features['has_unsubscribe'] = 1 if re.search(r'退订|回T|TD', text) else 0 features['has_signature'] = 1 if re.search(r'【.*?】', text) else 0 features['length'] = len(text) return features这些特征可以和 TF-IDF 向量拼接,一起送入分类器。拼接时注意归一化,length的数值范围和其他 0/1 特征差异很大,可以用MinMaxScaler缩放到 [0,1]。
3.3 训练集/测试集划分与类别不平衡的处理
划分数据集时用分层采样,保证训练集和测试集里垃圾短信的比例一致:
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( df['text'], df['label'], test_size=0.2, random_state=42, stratify=df['label'] )stratify参数是关键。不加它,可能测试集里垃圾短信特别少,评估结果波动极大。random_state=42保证每次运行划分结果一致,方便调试和复现。
如果垃圾短信比例低于 10%,可以在训练时给垃圾类更高的权重。手写朴素贝叶斯里,把class_prior的计算改成:
# 给垃圾类加权,weight 根据不平衡比例调整 weight = {0: 1.0, 1: 3.0} for c in classes: count = y.count(c) * weight[c] total = sum(y.count(k) * weight[k] for k in classes) self.class_prior[c] = (count + self.alpha) / (total + self.alpha * len(classes))weight[1]=3.0表示垃圾类的先验概率被放大三倍。具体倍数看你的数据,一般设为正常类与垃圾类样本数比值的平方根左右。
4. 避坑与排查:手写分类器最容易翻车的五个地方
4.1 分词结果里全是单字,特征矩阵稀疏到无法训练
现象:打印分词结果发现“恭喜您中奖”被切成['恭', '喜', '您', '中', '奖'],每个词长度都是 1,被len(w) > 1全部过滤掉,最终每条短信的特征为空。
原因:jieba默认词典对短信领域词汇覆盖不足,尤其是网络新词和变体写法。
解决:加载自定义词典。把训练集里高频出现的短语统计出来,人工筛选后加入userdict.txt,每行格式为词语 词频 词性。然后在分词前调用jieba.load_userdict('userdict.txt')。另外把len(w) > 1改成len(w) >= 1,但这样会引入大量单字噪声,需要配合更严格的停用词表。
4.2 测试集准确率 99%,换一批短信就崩
现象:在自己的测试集上准确率很高,但拿几条新短信手动测试,结果全错。
原因:过拟合。模型记住了训练集里的具体词汇组合,而不是学到泛化特征。常见于max_features设得太大、min_df=1的情况。
解决:降低max_features到 2000 以下,提高min_df到 3 或 5,增加max_df到 0.95。同时用交叉验证代替单次划分:
from sklearn.model_selection import cross_val_score scores = cross_val_score(pipeline, X_train, y_train, cv=5, scoring='f1') print(scores.mean(), scores.std())如果交叉验证的 F1 标准差超过 0.05,说明模型不稳定,需要进一步简化特征。
4.3 拉普拉斯平滑系数设错,垃圾短信全部漏判
现象:混淆矩阵显示垃圾短信召回率为 0,所有短信都被判为正常。
原因:alpha设得过大,比如 10.0,导致所有词的条件概率趋近于 1/V,垃圾类和正常类的得分几乎没有差异,先验概率中正常类占优,于是全部判正常。
解决:把alpha调回 1.0 或更小。如果数据量很大(超过 10 万条),可以用alpha=0.1。判断标准是看垃圾类召回率,如果低于 0.5,优先降alpha。
4.4 停用词表把“不”“没”“别”过滤掉了,语义完全反转
现象:“不要点击链接”和“要点击链接”分词后特征几乎一样。
原因:停用词表里包含了否定词。
解决:检查停用词表,确保不、没、别、无、非这些否定词不在里面。如果它们被过滤了,手动从停用词表中删除。更好的做法是保留否定词,并在特征工程阶段构造“否定词+动词”的二元组特征。
4.5 用 accuracy 评估模型,答辩时被老师问住
现象:答辩老师问“你的模型在垃圾短信上的召回率是多少”,你答不上来。
原因:只看了accuracy_score,没有输出分类报告。
解决:养成习惯,每次评估必须打印classification_report。把垃圾类的 precision、recall、f1-score 三个指标都记下来。如果老师追问“为什么 recall 比 precision 低”,你要能解释:模型偏向于把不确定的短信判为正常,导致漏判多。然后说明你打算怎么改进——比如调整类别权重或增加特征。
5. 让手写分类器再稳一点:两个进阶技巧和一套验证习惯
第一个技巧是特征拼接。把 TF-IDF 向量和手工构造的统计特征拼在一起,往往能提升 2 到 5 个百分点的 F1。具体做法是用scipy.sparse.hstack把稀疏矩阵和归一化后的稠密特征合并:
from scipy.sparse import hstack from sklearn.preprocessing import MinMaxScaler # extra_features 是 DataFrame,每行对应一条短信 scaler = MinMaxScaler() extra_scaled = scaler.fit_transform(extra_features) # X_tfidf 是 TfidfVectorizer 的输出 X_combined = hstack([X_tfidf, extra_scaled])注意extra_scaled要转成稀疏矩阵再拼接,否则内存会爆。hstack要求两个矩阵行数一致,所以特征提取和向量化必须用同一批数据、同一个顺序。
第二个技巧是阈值调整。朴素贝叶斯输出的是对数概率,你可以通过调整判定阈值来平衡 precision 和 recall。默认是垃圾类得分大于正常类就判垃圾,等价于阈值 0.5。如果想把召回率提上去,把阈值降到 0.3:
def predict_with_threshold(words, threshold=0.5): scores = {} for c in self.class_prior: score = np.log(self.class_prior[c]) for w in words: if w in self.vocab: score += np.log(self.word_prob[c][w]) scores[c] = score # 计算垃圾类的相对概率 diff = scores[1] - scores[0] return 1 if diff > np.log(threshold / (1 - threshold)) else 0阈值从 0.5 降到 0.3,意味着垃圾类只需要相对概率达到 0.3 就被判为垃圾,召回率会上升,但误判也会增加。具体设多少,看你的业务容忍度。毕业设计里可以画一条 precision-recall 曲线,把不同阈值下的指标列成表格,答辩时展示你对模型行为的理解。
验证习惯方面,我一般会固定一个“回归测试集”:从数据里挑 50 条典型短信,包括正常、垃圾、边界模糊三类,每次改完代码都跑一遍,看预测结果有没有变化。这个习惯能帮你快速发现“改了一个参数,结果把之前对的搞错了”这类问题。另外,把每次实验的max_features、alpha、min_df和对应的 F1 值记在一个表格里,不用很复杂,Excel 就行。到写论文的时候,这张表就是你的实验对比章节。
最后说一个我自己的教训:一开始我觉得手写分类器“太简单”,想直接上 LSTM。后来发现数据量只有几千条,LSTM 训完还不如朴素贝叶斯。手写分类器的价值不在于性能上限,而在于你对每一行代码都有控制权,知道模型为什么做出某个判断。毕业设计答辩时,老师更想听你讲清楚“为什么拉普拉斯平滑能解决零概率问题”,而不是“我调了一个预训练模型”。把简单方法做扎实,比堆复杂模型更有说服力。希望帮到你。
本文还有配套的精品资源,点击获取