简介:这是一份面向本科毕业设计的中文文本分类实战资源,以垃圾短信识别为任务,适合自然语言处理方向的学生快速完成从数据预处理、特征工程到模型训练的全流程。资源采用“标签\t文本”格式的标注数据集,正负样本分别对应垃圾短信与正常短信,并基于Python 3.6环境,借助jieba分词与Scikit-learn库实现;默认使用SVM支持向量机分类器,同时保留替换其他分类模型的接口,便于针对不同场景做对比实验。压缩包共8个文件,涵盖txt格式的训练/测试语料、pkl格式的TF-IDF特征与训练好的SVM模型、jpg流程示意图、md说明文档以及py训练脚本,整体大小约38.02MB,结构清晰可直接运行。目前已有4452人学习下载,适合需要快速搭建短信分类系统、完成毕业设计或入门文本分类的读者。
1. 垃圾短信识别不是二分类那么简单:NLP毕业设计该从哪切入
自然语言处理(NLP)方向的毕业设计里,中文文本分类是最容易上手、也最容易做出“完整可演示”项目的题目之一,垃圾短信识别正好卡在这个位置:标签明确、指标可量化、不需要领域知识,却能完整跑通数据清洗、分词、向量化、建模、评估、部署整条链路。但很多同学一上来就盯住BERT这类预训练模型,结果卡在算力和标注上两头受困。我通常建议反着来:先用一套轻量方案把基线和链路跑通,把踩坑的细节补齐,再决定要不要补一个深度模型做对比。这篇笔记就按这个顺序拆开讲,覆盖选型、数据准备、模型训练、阈值调整和收官技巧,适合正在做文本分类课设或毕设、并且真想把这套东西用起来的人。
2. 垃圾短信识别的方案选型:先弄清为什么中文短文本难做
直接套用英文垃圾邮件那套流程在中文短信上行不通。英文按空格分词,有成熟停用词表,垃圾邮件和正常邮件的篇幅差异明显;中文垃圾短信往往只有六到三十个字,口语化严重,变形词、谐音字、火星文到处都是,还经常混着链接、手机号和验证码。更麻烦的是,中文没有天然的空格分词边界,必须先分词再向量化,分词一旦错了,后面的特征工程全部白做。所以选型的第一步不是选模型,而是把这道题的真实难点列清楚。
2.1 问题定性:一道短文本二分类题
垃圾短信的技术本质是“发送方用低成本模板批量群发”,文本大多由营销短语加链接或电话组成。这带来三个直接影响后续选择的特点。第一,文本短,词频统计结果很稀疏,模型容易过拟合;第二,噪声高,正常短信里的验证码、快递取件码看起来和垃圾短信有相似的数字特征,标签边界模糊;第三,类别不平衡,正常短信通常占八成以上,垃圾短信可能只有百分之十几,用准确率评估很容易被虚假的高分欺骗。
所以这道题的评估指标不能只看准确率,必须同时盯住召回率和F1。垃圾短信漏掉一条,用户可能就会收到诈骗链接;误判一条正常短信,代价相对小一些。这一点决定了后续所有参数调整的方向:宁可牺牲一点精确率,也要把召回率拉上去。
2.2 三套方案的对比与适用边界
我见过不少毕设组在方案选型上反复纠结,其实对这道题来说,可以选的路线就三类。
| 方案 | 适用数据量 | 训练成本 | 优点 | 缺点 | 毕设定位 |
|---|---|---|---|---|---|
| TF-IDF + 朴素贝叶斯 / 逻辑回归 | 数千到数万条 | 分钟级,CPU 可跑 | 训练快、可解释、稳定 | 没有上下文语义,依赖分词质量 | 首选基线,必跑 |
| Word2Vec / 随机初始化 Embedding + TextCNN / BiLSTM | 数万条以上 | GPU 下分钟到小时级 | 能捕捉局部语义和词序 | 调参点较多,小样本未必占优 | 对比实验主力 |
| 中文预训练模型(BERT 等) | 最好十万级起 | GPU 小时级,显存要求高 | 语义能力强,上限高 | 推理慢、部署重、小数据易过拟合 | 锦上添花,可选 |
实际效果上有个反直觉的经验:在短文本、小样本场景里,TF-IDF 加逻辑回归往往和轻量深度学习模型打平,甚至更稳。原因是垃圾短信的判别特征高度集中在几个关键词和短语上,比如“秒到账”“免息”“加V”,这些词用词频就能抓住,不需要复杂的语义建模。深度模型在数据量不足时反而容易学偏。
2.3 毕设视角的选型原则
毕设选型要考虑的不只是效果,还有工作量、可写性和答辩说服力。常见做法是做一个三层递进:先用 TF-IDF 加逻辑回归跑通基线,再做一版 TextCNN 作为深度模型对比,时间和资源允许再补一个预训练模型。这样论文里能画出清晰的对比表,也能体现“从简单到复杂”的思考过程。
如果时间只剩一个月,我建议砍掉预训练模型这一档,保留逻辑回归和 TextCNN 两组就够了。报告里与其堆十个模型,不如把两组模型的结果差异、误判样例和阈值影响写透。还有一点值得提醒:毕设报告里最忌讳写“最终准确率99%”,这种数据很容易被评委追问一个“测试集怎么切的”就打回原形,把评估设计得合理比刷高分重要得多。
3. 数据准备与特征工程:把短信洗成模型认识的文本
数据决定上界,模型只是逼近这个上界。垃圾短信识别里,清洗和特征工程至少贡献七成的分数。很多同学拿到原始短信后直接丢给模型训练,结果模型学到的是“包含http的就是垃圾短信”这种脆弱规则,换一批数据立刻失效。这一章我把完整的数据处理流程拆成三段讲,每一段都直接关系到后续模型的稳定。
3.1 清洗与分词的第一个版本
垃圾短信里最常见的噪声是链接、连续数字、特殊符号和emoji。链接对分类有帮助,但它变化太快,直接保留会学出一堆低频特征;连续数字通常是手机号、验证码或编号,对判断垃圾与否贡献有限,建议直接清掉。分词我优先用jieba的精确模式,再配合自定义词典解决新词问题。
import re import jieba # 停用词表字段:每行一个词,包含常见语气词、助词、标点 STOP_WORDS = set(open("stopwords.txt", encoding="utf-8").read().split()) # 自定义词典字段:每行一个词,必要时可加词频和词性 jieba.load_userdict("sms_dict.txt") def clean_sms(text: str) -> str: # 先处理链接,避免后面的数字替换误伤 URL 里的数字 text = re.sub(r'(http|https)://[^\s]+', ' ', text) text = re.sub(r'www\.[^\s]+', ' ', text) # 连续数字串统一替换为空格,覆盖手机号、座机号、单号 text = re.sub(r'\d{3,}', ' ', text) # 丢符号,只保留中文、英文字母和数字 text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9]', ' ', text) # 精确模式分词,过滤停用词和单字词 words = [w for w in jieba.lcut(text) if w.strip() and w not in STOP_WORDS and len(w) > 1] return ' '.join(words)这段代码里,替换顺序是有讲究的:先清链接再清数字,否则“http://12345”会被数字替换切碎,残留的字母段变成噪声特征。\d{3,}覆盖三位以上连续数字,验证码、手机号、QQ号基本都在这个范围里;单字词直接过滤,因为中文里单字在字符级 n-gram 中会被重复计数,反而干扰词级特征。
提示:短信里的“VX加我”“Q我”这类字母缩写先不要急着删除,后面用了词级加字符级 n-gram 之后,模型能自己学会这些组合,效果往往比人工规则好。
3.2 TF-IDF向量化:中文短文本的参数怎么设
分词完成后,下一步是把文本转成向量。对短文本分类来说,TF-IDF 仍然是最稳的基线特征,关键是参数不能照搬英文长文档那套。我常用的参数组合是下面这组,每个参数都有明确用途。
from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( ngram_range=(1, 2), # 单个词加上相邻双词短语 min_df=2, # 只保留在至少2条短信里出现的词 max_features=8000, # 特征维度上限,控制过拟合 sublinear_tf=True, # 用 1 + log(tf) 平滑原始词频 ) X_train = vectorizer.fit_transform(train_texts) # 只在训练集上 fit X_test = vectorizer.transform(test_texts) # 测试集只做 transformngram_range=(1, 2)对中文短文本几乎是必设项,它可以缓解分词切错带来的负面影响:就算“秒到账”被切成“秒”和“到账”,二元组“秒到”“到账”仍然保留了一部分短语信息。min_df=2过滤掉只出现一次的词,这些词多半是错别字或随机数字组合,留它们只会让模型背噪声。sublinear_tf=True的作用是压缩高频词的词频差异,避免“的”“了”这类词偶尔没被停用词表拦掉时对结果产生过大影响。
这里最常见的错误是让测试数据参与fit,这会直接把测试集的词表信息泄漏到特征空间里,线下指标虚高。正确做法是先fit_transform训练集,再把训练好的向量器对测试集做transform,后面预测新短信时走的也是同一条路径。
3.3 训练集切分:随机切分是最大的坑
垃圾短信的发送特点是模板群发,同一模板经常加上微小变体反复使用。如果直接随机切分,同一个模板的另一次发送可能同时落入训练集和测试集,模型等于在背模板答案,线下 F1 能到 0.96,换一批真实新短信立刻跌到 0.7 以下。
我一般按时间切分,让早时间段的数据训练、晚时间段的数据测试,这样更接近真实上线场景。数据不带时间字段时,就先做相似度去重:对每个短信生成字符级别的 n-gram 集合,用 Jaccard 相似度聚类,同一个簇只保留一条样本,再随机切分。切分时保持正负比例一致,用分层采样。
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( texts, labels, test_size=0.3, stratify=labels, # 按标签分层,保证正负比例一致 random_state=42 )stratify=labels的意思是切分后训练集和测试集里垃圾短信的占比和原始数据基本一致。如果不做这一步,随机切分很可能把原本就很少的垃圾短信大部分分到训练集或测试集,造成评估结果忽高忽低。数据量再小一点的时候,建议把test_size降到 0.2,保证测试集里至少有两百条以上的正样本,否则 F1 的波动会大到没有任何参考价值。
4. 模型训练与评估:从基线到深度模型的实操顺序
这章按毕设答辩时最舒服的顺序来:先跑一个能解释清楚的基线,再做深度模型对比,最后解决评估指标和阈值问题。很多同学一上来就调深度学习模型,调了三天没效果,回头补基线才发现数据清洗早就出了问题。先跑基线不是浪费时间,而是给整个项目做体检。
4.1 先出一个能交代的基线:逻辑回归
逻辑回归在 TF-IDF 特征上的表现稳定,训练只要几十秒,每个特征还有对应的权重系数,答辩时可以直接展示“哪些词让模型判定为垃圾短信”。我建议把向量化和模型放进同一个 Pipeline 里,这样预测时只需要喂原始文本,不用手动走清洗和向量化流程。
from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression from sklearn.feature_extraction.text import TfidfVectorizer pipeline = Pipeline([ ('tfidf', TfidfVectorizer( ngram_range=(1, 2), min_df=2, max_features=8000 )), ('clf', LogisticRegression( C=5, # 正则强度的倒数,控制模型复杂度 max_iter=500, # 迭代上限,防止不收敛 class_weight='balanced' # 按类别频率自动加权 )) ]) pipeline.fit(X_train, y_train)class_weight='balanced'是对类别不平衡最简单有效的处理方式:它让逻辑回归在计算损失时,把小类别的错分代价放大到和大类别相当的程度。C=5在短文本场景通常效果比较稳,C 太大容易过拟合训练集里的噪声词,C 太小则特征权重被压得过于平滑。如果训练集只有几千条,可以把 C 降到 1 附近多试几组,画一条验证集 F1 随 C 变化的曲线放进报告里,这部分内容很加分。
4.2 加一个深度模型:TextCNN的最小实现要点
TextCNN 是短文本分类里最合适的深度模型,结构简单、训练快,三个卷积核天然对应短语级别的 n-gram 特征,和 TF-IDF 的思路有可比性。这里给一个 PyTorch 的最小实现,重点看结构,不用完整贴训练循环。
import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_classes=2): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, 64, kernel_size=k, padding=k // 2) for k in (2, 3, 4) # 2-gram、3-gram、4-gram 视野 ]) self.fc = nn.Linear(64 * 3, num_classes) def forward(self, x): emb = self.embedding(x).transpose(1, 2) # [B, embed_dim, L] pooled = [torch.max(conv(emb), dim=2).values for conv in self.convs] return self.fc(torch.cat(pooled, dim=1))三个尺寸的卷积核分别捕捉相邻两个词、三个词、四个词构成的局部模式,正好对应中文里最常见的短语长度。padding_idx=0让填充位置的词向量在训练时保持为零,不参与梯度更新。全局最大池化的含义是:每个卷积核在整条短信里提取一个最强的激活信号,相当于“这条短信里是否出现了这个短语模式”。
训练时有个容易被忽略的点:向量化方式要和 TF-IDF 保持一致,用同一个分词结果构造词表,而不是重新分词。TextCNN 不一定要用预训练词向量,随机初始化在数据量一般的时候效果差距很小,反而避开了一个常见的坑:预训练词向量覆盖不了垃圾短信里的新造词,清华发布的那个几十G的词向量文件,对“秒到账”“花贝套现”这种词根本没有稳定的向量表示。
4.3 评估与阈值:别让准确率骗了你
模型训练完成后,评估阶段是另一个重灾区。准确率在类别不平衡的数据上没有任何意义,必须用混淆矩阵加 F1 来做最终判断。另外,默认的 0.5 分类阈值假设“漏报”和“误报”代价相等,这在垃圾短信场景里明显不成立。
from sklearn.metrics import classification_report, precision_recall_curve y_prob = pipeline.predict_proba(X_test)[:, 1] prec, rec, thres = precision_recall_curve(y_test, y_prob) # 选一个让召回率优先达到 0.9 的阈值 candidate = [(r, t) for r, t in zip(rec, thres) if r >= 0.9] best_thr = candidate[0][1] if candidate else 0.5 y_pred = (y_prob >= best_thr).astype(int) print(classification_report(y_test, y_pred))predict_proba返回的是每个样本属于正类的概率,precision_recall_curve会按所有可能的阈值算出一组精确率和召回率的配对。上面这段代码找的是“召回率不低于 0.9 时对应概率阈值”,通常这个阈值在 0.3 到 0.45 之间。阈值选完要在验证集上固定下来,再拿去测试集报数,不能在测试集上反复调,否则调出来的指标是过拟合测试集的结果。
5. 垃圾短信识别里的5个经典坑与排查
这一章把我自己反复踩过的坑按“现象、原因、解决”三条写清楚。每一类问题我都见过不止一个翻车现场,而且这些问题在答辩时被评委提问的概率非常高。
5.1 坑1:类别不均衡把准确率喂到了95%
现象:模型在测试集上准确率 95%,看一眼混淆矩阵才发现垃圾短信几乎全被判成正常短信。原因:正样本只占数据集的 10%,模型只要把所有短信全部预测成“正常”就能拿 90% 的准确率,它根本不需要学习任何特征。解决:训练时给模型加class_weight='balanced',评估时只看 F1 和召回率;如果正样本少到只有几百条,先把数据量补到两千条以上再训练,任何模型在几十条正样本上都学不出稳定规律。
5.2 坑2:随机切分让模型背了模板答案
现象:训练时 F1 有 0.96,换一批真实新收到的短信,F1 掉到 0.7。原因:同一个垃圾短信模板的变体同时落进了训练集和测试集,模型背下来的是模板本身的文字,而不是“什么内容像垃圾短信”的泛化能力。解决:切分前先按字符 n-gram 的 Jaccard 相似度做聚类去重,或者按时间字段切分;报告里一定要写清楚切分方式,这一步本身就是毕设的加分项。反过来说,如果测试集是随便随机切的,那个 0.96 的 F1 基本是虚的。
5.3 坑3:jieba把新词切碎导致特征不稳定
现象:“花呗套现”被切成“花呗”和“套现”,“秒到账”被切成“秒”“到账”,模型训练时学到的词组证据被拆散,新短信一换说法就失效。原因:默认词库更新跟不上短信黑话的迭代速度。解决:把误判样本里的高频词统计出来,人工确认后写进自定义词典,每行一个词;同时让ngram_range=(1, 2)兜底,即使切错也能保留“花呗”“到账”“秒到”这样的二元组合特征。这个维护词典的动作会一直持续到项目结束,它不是一次性的。
5.4 坑4:阈值死守0.5使召回过低
现象:垃圾短信的召回率不到 60%,检查模型输出概率后发现大量垃圾短信的概率集中在 0.4 到 0.6 之间。原因:默认 0.5 阈值假设漏报和误报代价相等,但垃圾短信场景里漏掉一条诈骗链接的代价远高于误判一条促销短信。解决:在验证集上用precision_recall_curve选阈值,优先保证召回率不低于 0.9,再用固定阈值去测试集上做最终评估。阈值应当随着模型一起保存,每次加载模型时自动带上,避免环境切换后行为不一致。
5.5 坑5:反复用同一份测试集调参
现象:测试集上的 F1 越调越高,模型上线后表现纹丝不动甚至倒退。原因:测试集在调参循环里被反复“看”了很多次,它已经失去了代表真实数据的能力,调参过程变成了对测试集的过拟合。解决:一开始就把数据切分成 train、val、test 三份,验证集用来选阈值和超参数,测试集只能等到全部定稿后跑一次。这个纪律比任何调参技巧都重要,答辩时如果被问“你的测试集调了几次”,答不上来会非常尴尬。
6. 收官技巧:把模型装进一个函数,让毕设能当场演示
到了收尾阶段,最重要的一件事是把训练好的模型包装成一个能随时调用的函数。毕设演示时,评委最常见的动作是现场输入一条短信看模型反应,如果直接把训练代码翻出来现场跑,既慢又容易出岔子。把模型存成文件,预测封装成函数,整个演示流程能控制在十秒以内。
import joblib # 训练完成后保存整个 Pipeline,包含 TF-IDF 和分类器 joblib.dump(pipeline, "sms_clf.joblib") def predict_sms(raw_text: str, thr: float = 0.4) -> dict: model = joblib.load("sms_clf.joblib") # 按需加载,演示时可放到全局 prob = model.predict_proba([raw_text])[0, 1] pred = int(prob >= thr) return {"label": "垃圾短信" if pred else "正常短信", "spam_prob": round(prob, 3)} # 批量质检:逐行读取一个 txt 文件并输出判定结果 with open("inbox.txt", encoding="utf-8") as f: for line in f: line = line.strip() if line: print(line, predict_sms(line))这里有几个细节值得注意:保存的必须是整个 Pipeline 而不是单独的模型,因为加载后预测时要走同一套分词和向量化逻辑;清洗函数要和训练时完全一致,否则一字之差就会改变特征分布;阈值thr跟着模型一起存进配置,不要在预测函数里写死成 0.5。
还有一个我每次都会做的小动作:训练结束后,把测试集里误判的样本挑二十条打印出来,逐条标上“为什么错”。比如某条正常短信被误判成垃圾短信,可能是因为它包含“领取优惠券”和电话号码,而这两类特征在垃圾短信里太常见。把这一页典型误判分析放进毕设报告,比贴一张 99% 的指标截图有说服力得多,因为评委能从中看到你对误差来源的理解,而不是只报喜不报忧。模型永远会出错,但你能解释错在哪,说明你真的把这个项目做明白了。我一般把这一步放在写论文之前,跑完它再动笔,论文里的数据全是自己一步步调出来的,写起来很顺。希望帮到你。
本文还有配套的精品资源,点击获取