简介:基于SVM的中文文本分类项目,以垃圾短信识别为例,面向自然语言处理初学者与需要快速搭建文本分类基线的开发者。压缩包内含可直接运行的训练脚本、已训练好的支持向量机模型与TF-IDF向量化模型,并提供带标签的短信训练集和测试集,正样本标记为1代表垃圾短信,负样本标记为0代表正常短信;同时附停用词表、流程图与说明文档,覆盖从中文分词、特征提取到模型训练与评估的完整流程。在指定Python环境中安装分词与机器学习依赖后执行训练脚本即可复现实验,分类算法基于支持向量机,也可按需替换为其他模型做对比。资源共8个文件,包括3个文本数据及词表、2个模型序列化文件、1个主脚本、1个流程图与1个说明文档,包体约36.23MB,目录结构清晰。已有345人学习下载,适合作为短文本分类入门参考、课程设计或后续算法优化的起点。
1. 基于SVM的中文文本分类:从压缩包到垃圾短信识别落地
一套 NLP 源码包能不能直接用,我从来不看它在网盘里挂了多久、文件排得多整齐,只看一件事:照着 README 第一条命令跑,能不能在自己的机器上复现出结果。这套“基于 SVM 的中文文本分类(垃圾短信识别)”就属于这种能直接落地的资源,训练集、测试集、停用词表、TF-IDF 特征文件、SVM 模型、训练脚本全部打包在一个 zip 里,场景非常具体:正样本标签为 1 表示垃圾短信,负样本标签为 0 表示正常短信,文本按“标签\t文本”的格式组织。
算法路线也是中文 NLP 分类里最经典的一套:jieba 分词 → TF-IDF 特征加权 → 支持向量机。它不新潮,但胜在成熟可靠、可解释性强,尤其适合课程设计、毕业设计,以及刚进入自然语言处理领域想快速跑通全流程的从业者。你不需要懂多深的数学,能看懂 Python 和 sklearn 的基本调用就能跑起来;想改参数、换模型,后面也有足够的改法空间。
2. 数据读取与中文预处理:先把 txt 变成 SVM 能用得上的词序列
很多新手拿到压缩包第一件事就是执行python train.py,这其实是最容易翻车的顺序。建模之前先盘数据,这个习惯能帮你省下后面至少两小时的排查时间。底子没打好的话,后面所有步骤都是在错误的地基上盖楼。
2.1 项目结构与数据集格式:先别急着跑 train.py
先把压缩包里的文件认一遍,每一块在管线里的位置心中有数,再动手。这个包的目录布局很主流,和绝大多数 sklearn 项目的组织方式一致:
| 文件 | 作用 |
|---|---|
data/train.txt | 训练集,每行一条“标签\t文本”,1 为垃圾短信,0 为正常短信 |
data/test.txt | 测试集,格式同上,用于训练后的评估 |
hit_stopwords.txt | 哈工大停用词表,预处理时过滤标点和虚词 |
model/tfidf.pkl | 训练好的 TfidfVectorizer,预测阶段必须复用 |
model/svm_model.pkl | 训练好的 SVM 分类器,与 tfidf.pkl 配对使用 |
train.py | 训练入口,跑主流程 |
flow chart.jpg | 流程图,快速理解管线 |
README.md | 使用说明,环境依赖和运行命令 |
第一步不是训练,而是看训练集的标签分布。垃圾短信场景里正负样本通常是不均衡的,这直接决定后面要不要给 SVM 加class_weight。先跑一段小代码摸清家底:
from collections import Counter label_counter = Counter() with open('data/train.txt', 'r', encoding='utf-8') as f: for line in f: line = line.rstrip('\n') if not line: continue label = line.split('\t', 1)[0] label_counter[label] += 1 print('训练集标签分布:', dict(label_counter))这里有一个很容易忽略的细节:split('\t', 1)里的1是maxsplit参数,意思是只在第一个制表符处切一刀。短信正文里万一出现了\t字符,这个参数能保证标签只取第一列,文本保留原始完整性,不会把正文拆乱。如果写成不带第二个参数的split('\t'),正文里的制表符会把一行切成几段,标签和文本双双错位,训练出来的模型基本没法用。用len(parts) != 2直接跳过异常行,是读取这类 tab 分隔数据最容易做到也最容易漏掉的防线。
2.2 中文分词与停用词:预处理决定模型上限
SVM 本身完全不理解语言,它只能处理数值向量。中文句子不像英文那样天然以空格分词,“恭喜您中奖了”必须被切成“恭喜 / 您 / 中奖 / 了”这样的词序列,再映射为 TF-IDF 特征,SVM 才能计算这条短信和超平面之间的距离。所以分词质量直接决定了模型的上限,后面的 TF-IDF 和 SVM 都是在这个基础上做文章。
分词我直接用 jieba 的精确模式,配合哈工大停用词表过滤掉“的、了、也、在”这类没有判别能力的词。短信里大量出现的网址是另一个关键点:每个链接都是唯一的,直接进词表只会产生一堆只出现一次的稀疏维度,对分类毫无帮助。我一般会先把 URL 统一替换成占位符再切词:
# preprocess.py import re import jieba URL_PATTERN = re.compile(r'https?://\S+|www\.\S+') def load_stopwords(path='hit_stopwords.txt'): with open(path, 'r', encoding='utf-8') as f: return {line.strip() for line in f if line.strip() and not line.startswith('#')} STOP_WORDS = load_stopwords() def clean_text(text: str) -> str: """去掉首尾空白,并把URL统一替换为占位符""" return URL_PATTERN.sub('URL', text.strip()) def cut_and_filter(text: str) -> list: """jieba精确模式切词,过滤停用词和空白""" text = clean_text(text) words = jieba.lcut(text, cut_all=False) kept = [] for w in words: w = w.strip() if w and w not in STOP_WORDS and not w.isspace(): kept.append(w) return kept if __name__ == '__main__': print(cut_and_filter('恭喜您中奖了,点击链接领取奖品 https://t.cn/xxx'))这段代码做了三件事:URL 归一化、jieba 精确切词、停用词过滤。cut_all=False是精确模式,不会像全模式那样把“中奖”同时切出“中”和“奖”这种冗余组合;返回列表而不是字符串,是为了后面直接作为 TfidfVectorizer 的tokenizer入参。运行后你会在控制台看到类似['恭喜', '中奖', '点击', '链接', '领取', '奖品', 'URL']的输出。
提示:如果发现“不”“没”这类否定词被停用词表误删,模型会明显偏向某一个分类方向。哈工大标准停用词表本身不含这些词,但部分扩展版本人为加进去了,遇到分类结果反常时先回查停用词表。
3. TF-IDF 与 SVM 训练:核心脚本拆解与参数取舍
预处理是基础,真正决定模型行为的是 TF-IDF 的特征构造方式和 SVM 的目标函数。这一章把 train.py 从入口到保存模型逐行拆开讲,顺便说清楚每个参数为什么这么设、改成什么值会有什么后果。
3.1 TF-IDF:把分好词的中文变成 SVM 认识的数值
TF-IDF 的直觉很朴素:一个词在当前短信里出现次数越多(TF 高),同时在全语料中出现越少(IDF 高),它对这条短信的辨识度就越强。“中奖”在一万条短信里只在二十条出现过,IDF 就高;“的”几乎每条都有,IDF 就接近零,再乘以 TF 也被压下来了。输出的是一行短信对应一列特征的稀疏矩阵,行数等于短信条数,列数等于词表大小。
对这个场景,我按下面这组参数初始化向量化器:
| 参数 | 取值 | 作用 |
|---|---|---|
tokenizer | cut_and_filter | 接入 jieba 分词,向量化器直接吃原始文本 |
ngram_range | (1, 2) | 同时保留单词和相邻双词组合 |
max_features | 30000 | 全局词表上限,防止维度膨胀 |
min_df | 2 | 出现次数少于 2 的词直接丢弃 |
sublinear_tf | True | 用1 + log(tf)取代原始词频,压低高频词优势 |
ngram_range=(1, 2)值得单独说两句。垃圾短信里很多强特征其实是短语,比如“点击链接”“领取奖品”,单个词“点击”只要出现在正常短信里也会被误判。二元词特征能抓住“点击链接”这种邻近关系,短文本上尤其明显,代价是词表变大,所以要用max_features兜底。sublinear_tf=True则是在保护稀有词——如果一条短信里“中奖”出现五次,原始词频会让这个特征值飙得很高,SVM 会过度关注这一条样本的强度,反而不利于泛化。
3.2 train.py:训练主流程按行拆
完整的训练脚本不长,核心逻辑就四步:读数据 → 向量化 → 训练 SVM → 保存模型。下面这份代码和包里的 train.py 功能一致,我补全了数据校验和输出:
# train.py import pickle import jieba # 确保分词函数所在模块被加载 from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.svm import LinearSVC from sklearn.metrics import classification_report from preprocess import cut_and_filter def load_data(path): texts, labels = [], [] with open(path, 'r', encoding='utf-8') as f: for line_no, line in enumerate(f, 1): line = line.rstrip('\n') if not line: continue parts = line.split('\t', 1) if len(parts) < 2: print(f'跳过第 {line_no} 行:缺少标签或文本') continue labels.append(int(parts[0])) texts.append(parts[1]) return texts, labels # 1. 读取训练集 train_texts, train_labels = load_data('data/train.txt') print('样本量:', len(train_texts)) # 2. 向量化:tokenizer直接吞原始文本,内部完成分词 vectorizer = TfidfVectorizer( tokenizer=cut_and_filter, ngram_range=(1, 2), max_features=30000, min_df=2, sublinear_tf=True, ) X_train = vectorizer.fit_transform(train_texts) # 3. 训练线性SVM clf = LinearSVC(C=1.0, class_weight='balanced') clf.fit(X_train, train_labels) # 4. 保存特征器和模型,两者必须配对使用 with open('model/tfidf.pkl', 'wb') as f: pickle.dump(vectorizer, f) with open('model/svm_model.pkl', 'wb') as f: pickle.dump(clf, f) # 5. 训练集上快速看一眼 test_texts, test_labels = load_data('data/test.txt') X_test = vectorizer.transform(test_texts) pred = clf.predict(X_test) print(classification_report(test_labels, pred))两个选型关键点。第一,分类器用LinearSVC而不是SVC(kernel='rbf'):文本经过 TF-IDF 后维度通常在万级,特征空间本身就是高维稀疏的,线性核在这种空间下已经够用,RBF 核的非线性映射非但带不来明显收益,训练耗时还会成倍上涨。第二,class_weight='balanced':垃圾短信往往是少数类,SVM 优化目标天生偏向多数类,这个参数会让损失函数按类别比例加权,少数类错了罚得更重,recall 才有救。
注意:代码里
fit_transform之后,测试集用的是transform而不是fit_transform。一旦对测试集重新 fit,词表和 IDF 都会被重构,向量维度对不上,predict 直接报错。
3.3 模型保存与单条预测:pkl 文件必须配对使用
tfidf.pkl存的是训练完成的 TfidfVectorizer,包含完整的词表、IDF 值、ngram 配置;svm_model.pkl存的是训练好的 LinearSVC。两个文件是一对,预测时不能用 A 模型配 B 特征器,否则维度权重的含义完全错位。
我一般会单独写一个预测脚本做冒烟测试:
# predict_one.py import pickle with open('model/tfidf.pkl', 'rb') as f: vectorizer = pickle.load(f) with open('model/svm_model.pkl', 'rb') as f: clf = pickle.load(f) while True: msg = input('输入短信: ') vec = vectorizer.transform([msg]) # 注意:单条文本也要传列表 label = clf.predict(vec)[0] print('预测类别:', '垃圾短信' if label == 1 else '正常短信') print('距超平面距离:', clf.decision_function(vec)[0])decision_function输出的是样本点到 SVM 超平面的带符号距离,正负对应分类方向,绝对值越大表示模型越自信。这个值后面调阈值时会用到,现在先把它打印出来,跑通流程的同时建立对输出分布的直觉。
4. 避坑清单:五个最常见的翻车点
这个项目最大的成本不在训练,而在复现。同样的代码,换台机器换套环境,或者换一批数据重新跑,总会冒出各种匪夷所思的问题。我拆过的带 pkl 的 NLP 包里,下面五个坑几乎人人都会踩,提前说清楚能省一半排查时间。
4.1 数据读取与预处理阶段
1. 标签与文本错位导致模型不收敛
现象:训练过程不报错,模型也保存了,但交叉验证的 f1 偶尔高偶尔低,换一次数据分布就崩一次。
原因:某个环节用了不带maxsplit的split('\t'),短信正文里自带的制表符把一行切成了多段,第一个残片被当成标签,后面的碎片被当成文本。
解决:统一用line.split('\t', 1),用len(parts) >= 2做合法性校验,不满足的行打印行号后跳过。这只是读取层面的修复,真正排查时还要检查原始数据本身有没有污染。
2. 双重分词导致特征维度错乱
现象:同一个测试句子,昨天的模型预测正常,今天的模型预测全是同一类,或者一 predict 就报维度不匹配。
原因:训练时先把语料用 jieba 切好,以空格分隔写入文件,然后 TfidfVectorizer 又配了tokenizer=cut_and_filter,把已经切好的空格串再切一遍,词全部碎掉,词表完全不对。
解决:分词做在哪一层只能选一个。要么让 TfidfVectorizer 的tokenizer参数接管分词,直接喂原始文本;要么先分词、空格拼接存成中间语料,向量化器用默认的按空格切分。最忌讳两头都做。
3. 全量文本直接进 TF-IDF 内存爆掉
现象:训练到一半进程被杀,或者报MemoryError。
原因:没设max_features和min_df,短信里的促销码、随机编号、网址碎片全部进词表,维度膨胀到几十万上百万。
解决:先把max_features压到 20000~30000 跑通全流程,再用min_df=2过滤掉那些只出现一次的孤词。对大语料,先小参数验证再逐级放开才是常规操作。
4.2 模型训练与加载阶段
4. 换环境后 pickle 加载失败
现象:项目在旧机器上一切正常,新机器上pickle.load直接报错,常见的是ModuleNotFoundError或者EOFError。
原因:pickle 序列化的不仅是模型参数,还有类和环境的元信息。sklearn 或 Python 跨了大版本后,反序列化时找不到匹配的类定义,自然加载失败。
解决:项目里补一个 requirements.txt,锁住 Python 和 scikit-learn 版本,换机器先重建环境再跑。同一个 sklearn 版本下,优先用joblib.dump/joblib.load替代 pickle,跨平台兼容性更好,包格式也更紧凑。
5. 类别不均衡导致 recall 崩盘
现象:测试集 accuracy 很好看,能到 90% 以上,但垃圾短信的 recall 只有零点几,大量垃圾短信被当成正常短信放过。
原因:正常短信远多于垃圾短信,SVM 发现把所有样本都归为多数类,损失最小,于是少数类边界被严重压缩。
解决:训练时给LinearSVC加class_weight='balanced'。评估时也别盯着 accuracy,重点看正类的 precision、recall、f1,这三个指标才能真正反映垃圾短信的拦截效果。
5. 评估与调参:在 test.txt 上拿到可信结果
训练完不等于能上线。模型打得准不准、阈值要不要动、C 值该调多大,这些都得靠 test.txt 上的指标说话。这一章讲三件事:怎么看评估报告、怎么用网格搜索联动调参、以及最容易犯的数据泄漏问题。
5.1 一组指标看穿模型的真实水平
独立写一个评估脚本,加载 pkl 而不是重新训练,这样模型是模型、数据是数据,互不干扰:
# evaluate.py import pickle from sklearn.metrics import classification_report, confusion_matrix from train import load_data with open('model/tfidf.pkl', 'rb') as f: vectorizer = pickle.load(f) with open('model/svm_model.pkl', 'rb') as f: clf = pickle.load(f) test_texts, test_labels = load_data('data/test.txt') X_test = vectorizer.transform(test_texts) pred = clf.predict(X_test) print(classification_report(test_labels, pred, target_names=['正常短信', '垃圾短信'])) print(confusion_matrix(test_labels, pred))对垃圾短信识别这个场景,指标阅读顺序是这样的:先看垃圾短信这一行的 recall,它代表拦截率,recall 低意味着大量垃圾短信漏网;再看 precision,它代表误杀率,precision 低意味着大量正常短信被拦。这两个指标此消彼长,业务方要“宁可多拦也不能漏”,就往高 recall 调;要“宁可漏过也不想误扰”,就往高 precision 调。f1-score 是二者的调和平均,适合对比不同模型时的单值指标。accuracy 在这种标签倾斜的场景里基本是黑匣子,单独看它没有意义。confusion_matrix 则能告诉你具体错在哪儿:垃圾被当成正常是漏拦,正常被当成垃圾是误扰,两类错误代价完全不同。
5.2 网格搜索:C 值、词表裁剪与类别权重联动调优
SVM 的 C、TfidfVectorizer 的max_features、class_weight这三个参数是相互影响的。C 控制正则强度,词表上限控制特征量,类别权重控制少数类的惩罚力度,手动一个个试既慢又容易错过组合空间。用 GridSearchCV 可以一把梭,但有个常见的错误必须先避掉:如果先对全量语料做fit_transform,再切交叉验证折,TF-IDF 的词表和 IDF 就已经看过了整个训练集的分布,每一折的验证数据被泄漏进特征构建,调出来的参数虚高。正确做法是把特征器和分类器包在 Pipeline 里,让每一折都在训练子集上重新 fit 词表:
from sklearn.pipeline import Pipeline from sklearn.model_selection import GridSearchCV pipe = Pipeline([ ('vect', TfidfVectorizer( tokenizer=cut_and_filter, ngram_range=(1, 2), sublinear_tf=True, )), ('clf', LinearSVC()), ]) param_grid = { 'vect__max_features': [20000, 30000], 'clf__C': [0.1, 1.0, 10.0], 'clf__class_weight': [None, 'balanced'], } grid = GridSearchCV( pipe, param_grid, cv=3, scoring='f1', n_jobs=-1, verbose=1, ) grid.fit(train_texts, train_labels) print(grid.best_params_) print(grid.best_score_)注意参数名里的双下划线,vect__max_features表示 Pipeline 中名为vect的组件的max_features参数,这是 sklearn Pipeline 固定的传参语法。scoring='f1'是在提醒网格搜索:别用 accuracy 当目标,否则它会把多数类的一边倒当成最优解。还有一个实操中非常实用的经验:语料大时不要直接全量网格搜索,先随机抽 2000 条跑出量级,再用全量训最终模型。短文本场景下 C 很少需要超过 10,过大的 C 会让 SVM 对个别垃圾短信过度拟合,验证集 f1 反而掉头向下。
6. 模型换装与上线技巧:一条管线多跑几个分类器
6.1 换分类器,成本比你想象低
TF-IDF 和分词这半条管线不需要动,分类器替换只改两行。比如换成朴素贝叶斯:
from sklearn.naive_bayes import MultinomialNB clf = MultinomialNB(alpha=1.0) clf.fit(X_train, train_labels)或者换成逻辑回归:
from sklearn.linear_model import LogisticRegression clf = LogisticRegression(C=1.0, max_iter=1000, class_weight='balanced') clf.fit(X_train, train_labels)保存时换一个 pkl 文件名,评估脚本里的加载路径跟着改即可。我自己的经验是,MultinomialNB 在短文本上经常和 SVM 打平,而且predict_proba直接输出概率,给业务方解释“为什么判定为垃圾短信”时更顺手;SVM 的优势则在面对大量噪声特征时更扛得住,边界更硬,不会因为个别特征值偏高被带偏。因此我会先拿 SVM 跑一版,再用朴素贝叶斯交叉验证一次,选 f1 高的那版交付。
6.2 阈值移动,给产品留一个调节旋钮
SVM 的predict默认以 0 为分界,距离大于 0 判为正类。但业务方经常要求“更激进一点”或者“更保守一点”,这时候不必重训模型,直接读decision_function调整判定阈值:
scores = clf.decision_function(X_test) pred_custom = [1 if s > 0.3 else 0 for s in scores]阈值抬高,precision 上升、recall 下降;阈值降低,recall 上升、precision 下降。上线前拿一小批带标注的短信扫一遍不同阈值下的指标变化,挑一版最贴合业务诉求的。最后再唠叨一件小事:模型换环境时,务必先跑一遍“加载 pkl 并对 test.txt 前 50 行 predict”的冒烟脚本,确认无报错再继续。我吃过一次亏,给同事迁移项目,模型在旧机器上好好的,新机器上pickle.load就报错,排查了半天才发现 sklearn 跨了大版本。从那以后,每次换机器我都强制走完这个冒烟流程,两分钟的成本,换掉半天排错的时间。希望这条经验对你也有用。
本文还有配套的精品资源,点击获取