简介这是一份面向本科毕业设计的中文文本分类实战资源以垃圾短信识别为任务适合自然语言处理方向的学生快速完成从数据预处理、特征工程到模型训练的全流程。资源采用“标签\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, encodingutf-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(rwww\.[^\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_df2, # 只保留在至少2条短信里出现的词 max_features8000, # 特征维度上限控制过拟合 sublinear_tfTrue, # 用 1 log(tf) 平滑原始词频 ) X_train vectorizer.fit_transform(train_texts) # 只在训练集上 fit X_test vectorizer.transform(test_texts) # 测试集只做 transformngram_range(1, 2)对中文短文本几乎是必设项它可以缓解分词切错带来的负面影响就算“秒到账”被切成“秒”和“到账”二元组“秒到”“到账”仍然保留了一部分短语信息。min_df2过滤掉只出现一次的词这些词多半是错别字或随机数字组合留它们只会让模型背噪声。sublinear_tfTrue的作用是压缩高频词的词频差异避免“的”“了”这类词偶尔没被停用词表拦掉时对结果产生过大影响。这里最常见的错误是让测试数据参与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_size0.3, stratifylabels, # 按标签分层保证正负比例一致 random_state42 )stratifylabels的意思是切分后训练集和测试集里垃圾短信的占比和原始数据基本一致。如果不做这一步随机切分很可能把原本就很少的垃圾短信大部分分到训练集或测试集造成评估结果忽高忽低。数据量再小一点的时候建议把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_df2, max_features8000 )), (clf, LogisticRegression( C5, # 正则强度的倒数控制模型复杂度 max_iter500, # 迭代上限防止不收敛 class_weightbalanced # 按类别频率自动加权 )) ]) pipeline.fit(X_train, y_train)class_weightbalanced是对类别不平衡最简单有效的处理方式它让逻辑回归在计算损失时把小类别的错分代价放大到和大类别相当的程度。C5在短文本场景通常效果比较稳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_dim128, num_classes2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, 64, kernel_sizek, paddingk // 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), dim2).values for conv in self.convs] return self.fc(torch.cat(pooled, dim1))三个尺寸的卷积核分别捕捉相邻两个词、三个词、四个词构成的局部模式正好对应中文里最常见的短语长度。padding_idx0让填充位置的词向量在训练时保持为零不参与梯度更新。全局最大池化的含义是每个卷积核在整条短信里提取一个最强的激活信号相当于“这条短信里是否出现了这个短语模式”。训练时有个容易被忽略的点向量化方式要和 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_weightbalanced评估时只看 F1 和召回率如果正样本少到只有几百条先把数据量补到两千条以上再训练任何模型在几十条正样本上都学不出稳定规律。5.2 坑2随机切分让模型背了模板答案现象训练时 F1 有 0.96换一批真实新收到的短信F1 掉到 0.7。原因同一个垃圾短信模板的变体同时落进了训练集和测试集模型背下来的是模板本身的文字而不是“什么内容像垃圾短信”的泛化能力。解决切分前先按字符 n-gram 的 Jaccard 相似度做聚类去重或者按时间字段切分报告里一定要写清楚切分方式这一步本身就是毕设的加分项。反过来说如果测试集是随便随机切的那个 0.96 的 F1 基本是虚的。5.3 坑3jieba把新词切碎导致特征不稳定现象“花呗套现”被切成“花呗”和“套现”“秒到账”被切成“秒”“到账”模型训练时学到的词组证据被拆散新短信一换说法就失效。原因默认词库更新跟不上短信黑话的迭代速度。解决把误判样本里的高频词统计出来人工确认后写进自定义词典每行一个词同时让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, encodingutf-8) as f: for line in f: line line.strip() if line: print(line, predict_sms(line))这里有几个细节值得注意保存的必须是整个 Pipeline 而不是单独的模型因为加载后预测时要走同一套分词和向量化逻辑清洗函数要和训练时完全一致否则一字之差就会改变特征分布阈值thr跟着模型一起存进配置不要在预测函数里写死成 0.5。还有一个我每次都会做的小动作训练结束后把测试集里误判的样本挑二十条打印出来逐条标上“为什么错”。比如某条正常短信被误判成垃圾短信可能是因为它包含“领取优惠券”和电话号码而这两类特征在垃圾短信里太常见。把这一页典型误判分析放进毕设报告比贴一张 99% 的指标截图有说服力得多因为评委能从中看到你对误差来源的理解而不是只报喜不报忧。模型永远会出错但你能解释错在哪说明你真的把这个项目做明白了。我一般把这一步放在写论文之前跑完它再动笔论文里的数据全是自己一步步调出来的写起来很顺。希望帮到你。本文还有配套的精品资源点击获取