简介:面向本科毕业设计与期末大作业的中文文本分类实战项目,以垃圾短信识别为具体任务,完整覆盖自然语言处理中数据清洗、特征提取、模型训练与效果评估等关键环节,适合初学者快速上手。压缩包共8个文件,包含训练脚本、训练集与测试集文本、停用词表、特征提取结果文件与分类模型文件,同时配有说明文档及流程图,整体大小38.02MB,目前已有643人学习下载。项目代码提供详细注释,从模型构建到预测调用均有说明,便于理解每一个步骤;流程图和说明文档进一步降低了阅读门槛,可直接用于本科毕业设计、课程设计或期末大作业演示。读者也可在此基础上更换数据集,完成其他中文文本分类任务,具备较高的复用性和参考价值。
1. 垃圾短信识别:一个把中文NLP流程走完的最小闭环
短信分类的任务边界很清晰,但一个中文文本分类项目该经历的环节它全都有:文本读取、中文分词、停用词过滤、特征向量化、分类器训练、模型评估、上线预测。这是一个配合源码和文档的NLP毕业设计,核心实现是 TF-IDF 加线性 SVM,训练脚本train.py把从 txt 到 pkl 的完整流程串了起来,data目录下的train.txt、test.txt分别提供训练和测试样本,hit_stopwords.txt用于过滤无意义词汇,model目录里是可以直接加载的向量器和分类器。
刚拿到项目的人会习惯性点开svm_model.pkl,但真正决定模型能不能用的,是它前面的特征管线。分词粒度、停用词表、TF-IDF 参数、类别权重,每一个环节对最终效果的影响都超过调 SVM 的 C 值。新手可以照着train.py把全流程跑通;做毕业设计答辩可以直接拿它当脚手架,关键是能讲清楚每个 pkl 是怎么来的;有几年经验的人,看这套代码时注意力放在特征构建细节和决策阈值校准上,这两块恰恰是实验室和线上的分水岭。
2. 数据与预处理:从train.txt、hit_stopwords.txt到干净语料
2.1 数据格式与读取
data目录下的train.txt和test.txt是这个项目的地基,格式非常直白:每一行是一条样本,标签和正文之间用 Tab 分隔。用pandas读进来之后,后续所有处理都围绕这两列展开。
import pandas as pd train_df = pd.read_csv("data/train.txt", sep="\t", header=None, names=["label", "text"]) test_df = pd.read_csv("data/test.txt", sep="\t", header=None, names=["label", "text"]) print(train_df.head()) print(train_df["label"].value_counts())这里sep="\t"是指定 Tab 分隔,header=None表示原文件没有列名,names参数手动指定两列的含义。读完之后先head()看前几行,再用value_counts()看标签分布,这两步判断数据是否正常。
| 列名 | 类型 | 含义 |
|---|---|---|
| label | 整数 | 0 表示正常短信,1 表示垃圾短信 |
| text | 字符串 | 原始短信内容 |
有些版本的毕业设计数据集会用spam和ham做标签,看到字符串标签不要慌,读进来后统一映射成 0 和 1 就行。这类小数据集的常见问题是样本量不大,几千到几万条不等,正好适合 SVM 这类经典模型发挥。
2.2 分词与停用词过滤:中文NLP的第一步
中文文本和英文不同,词与词之间没有天然空格,必须先用jieba切词。hit_stopwords.txt里装的是「的、了、是、在」这类高频但对分类没有帮助的词,过滤掉它们可以降噪,也能让后续 TF-IDF 的特征维度明显下降。
import jieba stopwords = set() with open("data/hit_stopwords.txt", "r", encoding="utf-8") as f: for line in f: word = line.strip() if word: stopwords.add(word) def clean_and_split(text: str) -> str: words = [] for w in jieba.lcut(text): w = w.strip() if w and w not in stopwords and len(w) > 1: words.append(w) return " ".join(words)这里jieba.lcut返回的是分词后的列表,逐个判断是否为空串、是否在停用词表里,长度大于 1 是为了丢掉单字。最后用空格拼接成字符串返回,这一步很关键:TfidfVectorizer接收的是字符串,内部会按自己的 token 规则再切一遍,先拼成空格分隔的文本,能保证后续每个词边界清晰。
提示:环境里如果没有 jieba,直接
pip install jieba即可,它是纯 Python 实现,不需要编译。
2.3 类别分布与类别不平衡处理
垃圾短信识别天然存在类别不平衡:正常短信占多数,垃圾短信占少数。先看一眼数量再决定后面怎么处理。
| 标签 | 含义 | 常见占比 |
|---|---|---|
| 0 | 正常短信 | 约 85% - 90% |
| 1 | 垃圾短信 | 约 10% - 15% |
如果训练集里标签 1 的比例过低,模型很容易学成「全部预测为 0」,准确率看着很高,实际一点用没有。常见做法是两类策略:要么在训练时给模型传入class_weight="balanced",让 sklearn 按类别频率自动放大少数类的权重;要么对少数类做少量过采样。我一般先试class_weight,因为它不改变样本分布,也不会引入重复样本带来的过拟合风险。
3. TF-IDF特征构建:把中文短信变成SVM能吃的稀疏向量
3.1 为什么这个量级的文本分类还是选TF-IDF
这个项目选 TF-IDF 而不是 Word2Vec 或 BERT,本质上是数据量级和成本结构决定的。Word2Vec 这类分布式表示需要大规模语料才能学到稳定的词向量,几千条短信训出来的向量漂移很大,直接用预训练词向量又和短信领域的用词分布对不上。BERT 微调理论上效果更好,但在毕业设计这个场景下,数据增强、调参、GPU 资源都是门槛,而且深度模型的可解释性差,答辩时不好展开。
TF-IDF 的优点是直接、可控、可解释。每一条短信会被转换成一个固定长度的稀疏向量,每个维度对应一个词或一个词组,权重由词频和逆文档频率共同决定。SVM 在这样的高维稀疏特征空间里天然好用,训练快,预测也快,放在 CPU 上就能跑。这套组合在中小规模文本分类任务里到今天依然是性价比最高的基线之一。
3.2 TfidfVectorizer参数:从默认值开始调整
预处理完的文本要喂给TfidfVectorizer,参数直接决定特征空间长什么样。不要照抄默认值,要根据数据集规模调。
from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( token_pattern=r"(?u)\b\w+\b", # 兼容中文单字,避免默认pattern丢弃单字词 max_features=8000, ngram_range=(1, 2), min_df=2, max_df=0.8, sublinear_tf=True ) X_train = vectorizer.fit_transform(train_df["clean_text"])这里几个参数是配合使用的。token_pattern=r"(?u)\b\w+\b"是很多中文项目容易忽略的坑,sklearn 默认要求 token 至少两个字符,中文单字会被直接丢掉,显式指定这个 pattern 可以保留单字词。ngram_range=(1, 2)让特征同时包含单词和相邻词对,像「发票」「中奖」这类双字词搭配能被保留。min_df=2把只在一条短信里出现过的词丢掉,max_df=0.8把超过 80% 文档都出现的词丢掉,这两个参数一起控制低频噪声和高频噪声。sublinear_tf=True用 1+log(tf) 压缩词频差异,避免「免费」这种词在一条短信里出现 5 次就把权重顶得过高。
| 参数 | 取值 | 作用 |
|---|---|---|
| token_pattern | r"(?u)\b\w+\b" | 保留中文单字词 |
| max_features | 8000 | 限制最大特征维度 |
| ngram_range | (1, 2) | 单词加相邻词对 |
| min_df | 2 | 去掉只在 1 条样本出现的词 |
| max_df | 0.8 | 去掉在 80% 以上样本出现的词 |
| sublinear_tf | True | 用 log 压缩词频差异 |
max_features是最先要试的参数。我一般从 5000 开始,看验证集 F1 和内存占用,数据量大就往上加到 10000,数据量小就往下减。特征维度不是越高越好,超过一定量级后模型训练变慢,泛化能力反而下降。
3.3 特征构建的两个高频错误
第一个错误是把测试集也拿去fit_transform。TfidfVectorizer 的fit过程是在学习整个词表,如果测试集单独 fit,两边词表不一致,训练时的 8000 维特征和测试时的特征对不上,预测结果完全失真。正确做法是训练集fit_transform,测试集只transform。
第二个错误是忽略token_pattern。很多项目直接把默认参数跑起来,分词结果里的单词全部被丢,特征全是双字以上的词,单字实词一个不剩。垃圾短信里大量出现「奖、钱、免、购」这类单字关键词,丢掉它们等于自废武功。跑完fit_transform后打印一下vectorizer.get_feature_names_out(),扫一眼词表里有没有单字,这一步能省掉后面很多排查时间。
4. LinearSVC训练与pkl模型持久化:train.py的完整动作
4.1 线性SVM为什么是文本分类的经典搭配
SVM 在文本分类里的地位来自高维稀疏特征下的表现。TF-IDF 生成的特征维度经常是几千到几万,但每条样本非零元素很少,线性 SVM 在这种空间里能快速找到不错的分类超平面。这里用LinearSVC而不是SVC,是因为LinearSVC基于 liblinear 实现,时间复杂度随样本量线性增长,而带 RBF 核的SVC复杂度接近样本量的平方甚至立方,数据量一上来训练时间就不可接受了。
核 SVM 理论上能拟合更复杂的边界,但短信分类这个任务本身没有复杂到需要非线性边界,线性模型效果足够,而且LinearSVC的损失函数是 squared hinge,对噪声样本的敏感度更低。如果样本数大于特征数,记得设dual=False走原问题求解,训练速度会快不少。
4.2 train.py训练流程与模型持久化
train.py的核心动作可以浓缩成下面这段代码:加载数据、预处理、向量化、交叉验证、训练最终模型、保存两个 pkl 文件。
from sklearn.svm import LinearSVC from sklearn.model_selection import cross_val_score import joblib model = LinearSVC( C=1.0, class_weight="balanced", # 自动调整类别权重,缓解样本不平衡 dual=False, random_state=42 ) scores = cross_val_score(model, X_train, train_df["label"], cv=5, scoring="f1_macro") print("CV F1:", scores.mean()) model.fit(X_train, train_df["label"]) joblib.dump(vectorizer, "model/tfidf.pkl") joblib.dump(model, "model/svm_model.pkl")C=1.0是默认值,如果验证集表现差,优先检查特征工程而不是调 C。class_weight="balanced"对应前面提到的类别不平衡问题,它按类别频率自动计算权重,样本少的类权重更高。random_state=42固定随机种子,保证每次训练结果可复现,答辩时老师问起来也答得清楚。
先用 5 折交叉验证看稳定性,再在全部训练数据上 fit 一次拿最终模型,这样既不浪费数据,又对泛化能力有数。joblib.dump把向量器和模型分别存成tfidf.pkl和svm_model.pkl,注意两个文件要配套保存,不能拿着新训练的向量器去配旧模型,特征空间对不上,预测结果完全没有参考意义。
提示:pkl 文件只保存对象状态,不保存代码。换环境加载时,scikit-learn 和 jieba 的版本要尽量和训练时一致,否则可能报错或结果有细微差异。
4.3 分类报告与混淆矩阵:看指标不能只看accuracy
垃圾短信识别里只报准确率没有说服力。假设测试集里 90% 是正常短信,模型全预测为 0 也有 90% 准确率,但这个模型没有任何实用价值。要看分类报告和混淆矩阵。
from sklearn.metrics import classification_report, confusion_matrix X_test = vectorizer.transform(test_df["clean_text"]) # 注意只用 transform y_pred = model.predict(X_test) print(classification_report(test_df["label"], y_pred, target_names=["正常", "垃圾"])) print(confusion_matrix(test_df["label"], y_pred))classification_report里重点看第二行,也就是垃圾短信这一类的 precision、recall 和 f1-score。混淆矩阵是 2x2 的表格,四个格子分别对应四类预测结果。
| 实际 \ 预测 | 正常(预测0) | 垃圾(预测1) |
|---|---|---|
| 正常 | TN 正确放行 | FP 误拦 |
| 垃圾 | FN 漏拦 | TP 正确拦截 |
对垃圾短信识别来说,FN 意味着垃圾短信漏掉,用户继续被骚扰;FP 意味着正常短信被拦,可能是银行验证码、取件码,这个后果往往更严重。我一般先看 FP 的数量,确保它在一个可接受的绝对数值内,再追求 FN 的下降。调class_weight和调整下一步的决策阈值,是控制这两个数字的主要手段。
5. 决策阈值调优:让垃圾短信识别在误拦与漏拦之间可控
5.1 从decision_function看默认阈值
LinearSVC的predict方法本质上是看样本到分类超平面的符号,大于 0 判为垃圾,小于 0 判为正常。但 0 这个默认阈值未必是上线时性价比最高的点。decision_function返回的是带符号的距离分数,把测试集跑一遍,看分数分布,再决定阈值往哪边挪。
scores = model.decision_function(X_test) print(pd.Series(scores).describe()) threshold = 0.3 y_pred_adj = (scores > threshold).astype(int)阈值调大,模型更保守,只有分数明显偏向垃圾的短信才拦截,精确率上升但召回率下降;阈值调小,模型更激进,能拦下更多垃圾短信但误拦也会变多。这个分数不是概率,不能直接当置信度解释,只能用来做相对比较。
5.2 阈值调节的取舍
阈值和指标的关系是单调的,不同业务场景要选不同阈值。
| 阈值 | 精确率 | 召回率 | 适用场景 |
|---|---|---|---|
| 0.5 | 偏高 | 偏低 | 银行、物流类短信占比高,误拦代价大 |
| 0.0 | 中等 | 中等 | 默认基线,适合通用场景 |
| -1.0 | 偏低 | 偏高 | 广告拦截优先,能接受少量误拦 |
我一般会把阈值从 -2 到 1 按 0.1 的步长扫一遍,每个阈值下计算 precision 和 recall,画一条曲线,然后根据业务诉求选点。毕业设计里做一个这样的阈值扫描表格,比单纯报一个 95% 准确率有说服力得多。
5.3 把阈值做成可配置参数
训练和预测是两套流程,预测侧要把预处理、向量化、决策函数串成一条流水线,并且把阈值放到配置里,方便线上随时调整。
threshold = 0.3 # 从配置文件读取 def predict_sms(text: str) -> dict: clean_text = clean_and_split(text) vec = vectorizer.transform([clean_text]) score = model.decision_function(vec)[0] return {"score": float(score), "label": "spam" if score > threshold else "normal"}这里加载的是训练阶段保存的tfidf.pkl和svm_model.pkl,vectorizer.transform保证新文本走的是和训练时完全一致的特征空间。调整阈值不用重新训练模型,改完配置直接生效。把这套阈值扫描逻辑写进评估脚本,在测试集上找 F1 最高且误拦数量可控的那一个点,再决定最终上线参数。
本文还有配套的精品资源,点击获取