简介:本资源为基于Python的垃圾短信分类课程设计完整资料包,面向正在学习机器学习、模式识别或自然语言处理的高校学生与自学者,可帮助解决文本分类入门实践中从数据预处理到建模预测的全流程问题。包内共17个文件,以m脚本、csv数据集、py源码、docx实验报告为主,另含md说明与license文件,压缩包约6.19MB,目录结构清晰,便于按模块查阅与复现。目前已有347人学习下载。资源围绕lintcode垃圾短信分类题目展开,完整呈现了单词标准化、Snowball词干提取、TF-IDF特征向量转换以及逻辑回归建模预测的技术链路,并附有模式识别作业报告与可视化脚本,读者可据此理解文本分类的核心思路,对照代码完成实验复现、撰写课程报告或迁移到相似的自然语言处理任务中。
1. 垃圾短信分类到底在分什么:从一条“中奖通知”说起
你手机里大概率躺着这样一条短信:“尊敬的客户,您已获得XX平台幸运用户资格,点击链接领取……”它和正常验证码、快递通知混在同一个收件箱里,肉眼一眼能分辨,但要让程序自动分辨,就没那么简单了。基于Python的垃圾短信分类,做的就是这件事:把一条短信文本喂给模型,输出“垃圾”或“正常”两个标签。它属于文本二分类任务,核心难点不在模型多深,而在中文短信短、口语化、夹杂符号和变体词,特征稀疏得厉害。适合谁做?刚学完Python基础语法、想找一个端到端NLP小项目练手的人;也适合需要给业务系统加一道短信过滤兜底策略的工程师。这篇笔记按“数据怎么来→特征怎么提→模型怎么选→怎么评估→怎么避坑”的顺序讲,每一步都给可复现的代码和参数说明,你照着跑就能得到一个能用的分类器。
2. 数据准备与中文分词:把原始短信变成模型能吃的格式
2.1 短信数据集长什么样,从哪来
公开的中文垃圾短信数据集不算多,常见做法是找一份带标签的CSV,两列:label和message。label一般是ham(正常)和spam(垃圾),也有用0/1的。如果你手头没有现成数据,我一般会先用几百条自己标注的短信跑通流程,再逐步扩量。数据量上,二分类任务起步建议至少3000条,正负样本比例别太悬殊,垃圾短信占比在15%到40%之间比较健康。如果垃圾样本太少,后面评估指标会失真。
拿到数据先做一次体检,看缺失值、重复值和标签分布。重复短信在真实数据里很常见,尤其是营销短信模板化严重,不去重会导致训练集和测试集泄漏,评估分数虚高。
import pandas as pd # 读取数据,假设文件是 utf-8 编码的 csv df = pd.read_csv("sms.csv", encoding="utf-8") # 统一列名,方便后续处理 df.columns = ["label", "message"] # 看基本信息 print(df.shape) print(df["label"].value_counts()) print(df.isnull().sum()) # 去重:完全相同的短信只保留一条 df = df.drop_duplicates(subset=["message"]).reset_index(drop=True) # 去掉空短信 df = df[df["message"].str.strip().astype(bool)].reset_index(drop=True) print("去重后:", df.shape)这段代码做了四件事:统一列名、看标签分布、按短信内容去重、剔除空白行。drop_duplicates的subset参数指定只按message列判重,因为同一条短信可能被标了不同标签,那种情况要单独处理。去重后如果样本量掉得厉害,说明原始数据模板化严重,需要考虑数据增强或者换数据源。
2.2 中文分词:jieba的三种模式和自定义词典
英文短信按空格切词就行,中文不行。“免费领取”是一个词还是四个字,直接影响特征质量。Python里做中文分词,jieba是最常用的选择,安装就一句pip install jieba。jieba有三种模式:精确模式、全模式、搜索引擎模式。短信分类我一般用精确模式,因为它切分结果最干净,不会产生大量冗余碎片。
但jieba的默认词典对网络新词和营销话术覆盖不够,“薅羊毛”“秒杀”“返现”这类词可能被切碎。解决办法是加载自定义词典,把领域词加进去。
import jieba # 加载自定义词典,每行一个词,可带词频和词性(可选) jieba.load_userdict("user_dict.txt") # 精确模式分词示例 text = "恭喜您获得秒杀资格,点击链接返现50元" words = jieba.lcut(text, cut_all=False) print(words) # 输出类似:['恭喜', '您', '获得', '秒杀', '资格', ',', '点击', '链接', '返现', '50', '元'] # 停用词表,过滤掉对分类无意义的词 stopwords = set() with open("stopwords.txt", encoding="utf-8") as f: for line in f: stopwords.add(line.strip()) # 过滤停用词和单字 cleaned = [w for w in words if w not in stopwords and len(w) > 1] print(cleaned)load_userdict加载的词典文件格式是每行一个词,可以只写词,也可以写“词 词频 词性”。停用词表网上有很多现成的,但建议自己过一遍,把“的”“了”“啊”这类去掉,同时注意别把“不”“没”这种否定词误删,它们在短信里可能携带关键语义。过滤单字是因为单个汉字在短信里噪声太大,保留双字及以上词效果通常更好。
分词之后,每条短信就变成了一个词列表。接下来要把词列表转成数值特征,才能喂给模型。
3. 特征工程:TF-IDF和词袋模型怎么选、参数怎么调
3.1 词袋模型与TF-IDF的差别
词袋模型(Bag of Words)只统计每个词在短信里出现了几次,完全不考虑词序。TF-IDF在词频基础上乘了一个逆文档频率,降低那些在所有短信里都高频出现的词的权重。举个例子,“您”在正常短信和垃圾短信里都常见,TF-IDF会给它较低权重;“返现”只在垃圾短信里高频出现,TF-IDF会给它较高权重。所以短信分类我默认用TF-IDF,效果通常比纯词袋好一截。
scikit-learn的TfidfVectorizer把分词、过滤、向量化串在一起,但中文需要先自己分词再传进去,或者传一个自定义的tokenizer。我一般先分好词存成一列,再用TfidfVectorizer的analyzer参数配合lambda处理。
from sklearn.feature_extraction.text import TfidfVectorizer # 假设 df["words"] 是分好词后用空格拼接的字符串 df["words"] = df["message"].apply(lambda x: " ".join(jieba.lcut(x))) vectorizer = TfidfVectorizer( max_features=5000, # 最多保留5000个词,控制维度 min_df=2, # 至少在2条短信里出现才保留 max_df=0.9, # 出现在超过90%短信里的词丢掉 ngram_range=(1, 2), # 同时考虑单字词和双字词组合 sublinear_tf=True # 对词频做对数平滑,抑制超高频词 ) X = vectorizer.fit_transform(df["words"]) y = df["label"].map({"ham": 0, "spam": 1}) print(X.shape) # (样本数, 特征维度)max_features=5000是维度和信息量的折中,短信短,5000个特征通常够用。min_df=2过滤掉只出现一次的词,减少噪声。max_df=0.9去掉近乎全量的词,比如“我”“你”。ngram_range=(1,2)让模型能捕捉“点击链接”这种双词组合,对短信分类有正向帮助,但会增大特征维度,配合max_features一起用。sublinear_tf=True把词频从线性变成对数,避免某个词重复出现十几次就主导权重。
3.2 参数调整的实操判断
调参不是盲调,要看验证集表现。我一般先固定max_features=5000,然后单独调ngram_range,从(1,1)到(1,2)看F1有没有提升。如果提升不明显,就退回(1,1)省内存。min_df从1调到2再调到3,观察过拟合有没有缓解。max_df一般不动,0.9是个安全值。
还有一个容易忽略的点:TF-IDF必须在训练集上fit,然后对测试集只做transform。如果全量数据一起fit,测试集的词频信息就泄漏到训练过程里了,评估分数会偏高。正确做法是先切分数据,再分别处理。
from sklearn.model_selection import train_test_split X_train_text, X_test_text, y_train, y_test = train_test_split( df["words"], y, test_size=0.2, random_state=42, stratify=y ) # 只在训练集上 fit vectorizer = TfidfVectorizer(max_features=5000, min_df=2, max_df=0.9, ngram_range=(1, 2), sublinear_tf=True) X_train = vectorizer.fit_transform(X_train_text) X_test = vectorizer.transform(X_test_text)stratify=y保证切分后正负样本比例和原始一致,短信分类里垃圾样本少的时候尤其重要。random_state=42固定随机种子,方便复现。切分比例8:2是常规起点,数据量过万可以调到9:1。
4. 模型训练与评估:朴素贝叶斯、SVM和逻辑回归的实战对比
4.1 三个基线模型怎么选
短信分类属于高维稀疏文本分类,朴素贝叶斯、线性SVM和逻辑回归是三个最常用的基线。朴素贝叶斯假设特征独立,虽然这个假设在文本里明显不成立,但它计算快、对小数据友好,经常能给出不错的基线。线性SVM在高维空间找最大间隔,文本分类里表现稳定。逻辑回归输出概率,方便后续调阈值。
我一般三个都跑一遍,用交叉验证看F1,再决定用哪个。不要一上来就上深度学习,几千条短信的数据量,BERT微调未必比TF-IDF加线性模型好,而且训练成本高得多。
from sklearn.naive_bayes import MultinomialNB from sklearn.svm import LinearSVC from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score import numpy as np models = { "NB": MultinomialNB(alpha=1.0), "SVM": LinearSVC(C=1.0, max_iter=5000), "LR": LogisticRegression(C=1.0, max_iter=1000) } for name, model in models.items(): scores = cross_val_score(model, X_train, y_train, cv=5, scoring="f1") print(f"{name}: F1={scores.mean():.4f} (+/- {scores.std():.4f})")MultinomialNB的alpha是平滑参数,默认1.0,数据稀疏时可以调到0.1到0.5。LinearSVC的C控制正则强度,C越大越容易过拟合,短信分类我一般从1.0开始试。LogisticRegression的C同理,max_iter要设大一点,否则可能不收敛。cross_val_score的cv=5做五折交叉验证,scoring="f1"关注正类(垃圾短信)的F1,因为业务上更怕漏判垃圾短信。
4.2 评估指标:为什么准确率会骗人
如果垃圾短信只占10%,一个把所有短信都判为正常的模型准确率也有90%,但它一条垃圾短信都抓不到。所以短信分类不能只看准确率,要看精确率、召回率和F1。精确率是“判为垃圾的里面有多少真的是垃圾”,召回率是“真的垃圾里面有多少被找出来了”。业务上如果不想误拦正常短信,就优先保精确率;如果不想漏掉垃圾短信,就优先保召回率。
from sklearn.metrics import classification_report, confusion_matrix # 用SVM做示例 svm = LinearSVC(C=1.0, max_iter=5000) svm.fit(X_train, y_train) y_pred = svm.predict(X_test) print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred, target_names=["正常", "垃圾"]))confusion_matrix输出四个数:真正常、假垃圾、假正常、真垃圾。classification_report给出每个类的精确率、召回率、F1和支持度。重点看“垃圾”那一行的召回率,如果低于0.85,说明漏判较多,需要调整模型或特征。如果精确率低,说明误拦多,可以调高分类阈值或增加正常短信的样本权重。
4.3 模型持久化与推理封装
训练完的模型和向量器要存下来,不然每次推理都重新训练不现实。用joblib保存,加载后封装成一个预测函数。
import joblib # 保存 joblib.dump(vectorizer, "tfidf.pkl") joblib.dump(svm, "svm_model.pkl") # 加载并推理 vectorizer = joblib.load("tfidf.pkl") model = joblib.load("svm_model.pkl") def predict_sms(text): words = " ".join(jieba.lcut(text)) vec = vectorizer.transform([words]) pred = model.predict(vec)[0] return "垃圾" if pred == 1 else "正常" print(predict_sms("恭喜您中奖了,点击领取"))推理时注意,分词和向量化必须和训练时完全一致,包括自定义词典和停用词表。如果训练时用了user_dict.txt,推理时也要加载同一个词典,否则分词结果不一致,特征对不上,预测就会出错。这个坑我踩过,模型离线评估很好,上线后效果差一截,排查半天才发现是词典没同步。
5. 避坑与排查:短信分类项目里最容易翻车的五个地方
5.1 数据泄漏:去重没做导致分数虚高
现象:交叉验证F1到0.98,上线后实际只有0.7左右。原因:训练集和测试集里有大量重复短信,模型记住了这些样本,评估时等于开卷考试。解决:切分数据之前先按短信内容去重,切分时用stratify保持标签比例,并且确保同一条短信不会同时出现在训练集和测试集。如果数据本身模板化严重,可以考虑按模板分组切分。
5.2 分词不一致:训练和推理用了不同词典
现象:离线评估正常,线上预测结果随机。原因:训练时加载了自定义词典,推理服务没加载,同一个词被切成了不同片段,TF-IDF特征对不上。解决:把分词配置(词典路径、停用词表、jieba版本)固化到配置文件里,训练和推理共用同一份。更稳妥的做法是把分词结果和向量化打包成一个Pipeline,用joblib整体保存。
5.3 类别不平衡:垃圾样本太少导致召回率低
现象:模型把大部分短信判为正常,垃圾短信召回率不到0.5。原因:垃圾样本占比太低,模型倾向于预测多数类。解决:三种做法——对垃圾样本过采样、对正常样本欠采样、或者在模型里设class_weight="balanced"。逻辑回归和SVM都支持class_weight参数,设置后模型会自动调整权重。过采样可以用imblearn的RandomOverSampler,但要注意只在训练集上做,别动测试集。
5.4 新词和变体词:模型没见过就抓不住
现象:垃圾短信里出现“薇信”“扣扣”这种谐音变体,模型判为正常。原因:训练数据里没有这些变体,TF-IDF词典里也没有,模型完全没见过。解决:维护一个变体词映射表,在分词前做归一化替换,比如把“薇信”替换成“微信”。另外定期用新数据增量训练,让词典覆盖新出现的营销话术。这个没有一劳永逸的办法,只能持续迭代。
5.5 阈值默认0.5:业务需求不同阈值要调
现象:模型输出的概率在0.4到0.6之间时,判为垃圾还是正常很模糊。原因:默认分类阈值是0.5,但业务上可能更怕误拦或更怕漏判。解决:用predict_proba拿到概率后,自己设阈值。比如宁可误拦也不漏判,就把阈值降到0.3;宁可漏判也不误拦,就升到0.7。阈值要在验证集上按业务指标调,不能拍脑袋。
# 以逻辑回归为例,调整阈值 lr = LogisticRegression(C=1.0, max_iter=1000, class_weight="balanced") lr.fit(X_train, y_train) proba = lr.predict_proba(X_test)[:, 1] for thresh in [0.3, 0.4, 0.5, 0.6, 0.7]: y_pred_thresh = (proba >= thresh).astype(int) print(f"阈值={thresh}") print(classification_report(y_test, y_pred_thresh, target_names=["正常", "垃圾"]))这段代码遍历几个阈值,分别输出评估报告,你可以根据业务需求选一个最合适的。注意class_weight="balanced"会让模型更关注少数类,配合阈值调整效果更明显。
6. 进阶技巧:用Pipeline串起全流程和增量更新策略
把分词、向量化、模型训练串成一个Pipeline,能避免很多手动同步的坑。scikit-learn的Pipeline支持自定义转换器,你可以把jieba分词封装成一个Transformer。
from sklearn.base import BaseEstimator, TransformerMixin from sklearn.pipeline import Pipeline class JiebaTokenizer(BaseEstimator, TransformerMixin): def __init__(self, user_dict=None): self.user_dict = user_dict def fit(self, X, y=None): if self.user_dict: jieba.load_userdict(self.user_dict) return self def transform(self, X): return [" ".join(jieba.lcut(text)) for text in X] pipe = Pipeline([ ("tokenizer", JiebaTokenizer(user_dict="user_dict.txt")), ("tfidf", TfidfVectorizer(max_features=5000, min_df=2, ngram_range=(1, 2), sublinear_tf=True)), ("clf", LogisticRegression(C=1.0, max_iter=1000, class_weight="balanced")) ]) pipe.fit(X_train_text, y_train) print(pipe.score(X_test_text, y_test)) # 整体保存,推理时直接加载 joblib.dump(pipe, "sms_pipeline.pkl")Pipeline的好处是fit和predict的输入都是原始短信文本,分词和向量化在内部自动完成,不会出现训练和推理不一致的问题。保存时整个Pipeline一起序列化,加载后直接predict原始文本即可。
增量更新方面,短信话术变化快,模型不能一劳永逸。我一般每季度用新标注的数据重新训练一次,或者用partial_fit做在线学习。但partial_fit对TF-IDF不友好,因为词典会变。更实际的做法是定期全量重训,把新数据合并进训练集,重新fit向量器和模型。重训前记得在新数据的验证集上评估,确认没有退化再上线。
最后说一个我自己的习惯:每次模型上线前,我会手动构造20条“刁钻”短信——包含谐音变体、夹杂符号、中英混合、超短文本——跑一遍预测,看有没有明显翻车。这个习惯帮我提前发现过好几次词典缺失和阈值不合理的问题。希望帮到你。
本文还有配套的精品资源,点击获取