课程
第7章作业:基于 SVM 与 Logistic Regression 的文本分类
第7章作业:基于 SVM 与 Logistic Regression 的文本分类
文本分类 —— 基于 SVM 与 Logistic Regression 的中文垃圾邮件分类
数据集和分词逻辑沿用了实验四的代码,分类器换成了 SVM 和逻辑回归。
一、数据集和预处理
数据集用了实验四的中文邮件,一共 156 个 txt 文件。前 151 封(0~150)是有标注的:0 到 126 号是垃圾邮件,127 到 150 号是正常邮件;剩下 151 到 155 号是没标注的。
这个数据集有个比较明显的问题——垃圾邮件占了 127 封,正常邮件才 24 封,差不多 5:1 的比例。这种不均衡后面做评估的时候会有点问题,就算模型闭着眼全猜成 spam,准确率也能到 84%。所以光看准确率不太够,得结合精确率、召回率、F1 这些一起看。
二、代码逐步讲解
整个代码流程大概是这样:先读邮件文件,用 jieba 分词,然后扔进 TfidfVectorizer 变成特征向量,再分别训练 SVM 和逻辑回归两个模型,最后看评估结果和预测效果。下面按模块一个个说。
2.1 导入依赖
除了标准库的 re 和 pathlib,主要用了 jieba 来分词,sklearn 来处理特征提取、数据划分、模型训练和评估。下面这些 import 基本上把后面要用的都覆盖了:
import re
from pathlib import Path
import jieba
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.model_selection import train_test_split
from sklearn.svm import LinearSVC
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
classification_report, confusion_matrix, accuracy_score,
)
from sklearn.pipeline import Pipeline
2.2 分词 —— tokenize 函数
中文不像英文有天然空格,所以第一步得先分词。这里用的 jieba 精确模式分词,跟实验四一样。分词之前先用正则把标点、数字、特殊符号去掉,分完之后再把单个字的词过滤掉——像’的”了’这种单字词满篇都是但根本分不出是不是垃圾邮件,留着反而增加噪声。
def tokenize(text: str) -> list[str]:
1. 去掉标点、数字、特殊符号和空白
text = re.sub(r’[.【】0-9、——。,!~*\n\r\t\s]’, ”, text)
2. jieba 精确模式分词
tokens = jieba.lcut(text)
3. 只要长度大于1的词
tokens = [t for t in tokens if len(t) > 1]
return tokens
这里正则去噪和过滤单字词这两步挺关键的——试了一下如果不过滤,特征维度会膨胀很多,而且很多高频单字词完全没区分度,模型容易过拟合。这个思路基本就是实验四 classify.py 里 get_words 的做法。
2.3 读邮件文件 —— load_email_files
邮件文件夹里有 0.txt 到 155.txt,需要把它们读进来。我写了两个函数:load_email_files 读 0150 号并打好标签(<=126 的是 spam,>126 的是 ham),load_test_files 读 151155 号待预测的:
def load_email_files(email_dir: Path):
"""读 0~150.txt,返回 (文本列表, 标签列表)"""
texts, labels = [], []
for i in range(151):
file_path = email_dir / f’{i}.txt’
with file_path.open(‘r’, encoding=‘utf-8-sig’) as f:
content = f.read().strip()
texts.append(content)
labels.append(1 if i <= 126 else 0)
return texts, labels
def load_test_files(email_dir: Path):
"""读 151~155.txt 待预测邮件"""
test_texts = []
for i in range(151, 156):
file_path = email_dir / f’{i}.txt’
if file_path.exists():
with file_path.open(‘r’, encoding=‘utf-8-sig’) as f:
test_texts.append(f.read().strip())
return test_texts
有个问题:有些 txt 文件开头带了 BOM 头(\ufeff),用 utf-8 直接读会把那个字符也读进去,后面 print 的时候 Windows 控制台会报 UnicodeEncodeError。所以我用了 utf-8-sig 编码,它会自动处理掉 BOM。
2.4 特征提取 —— TfidfVectorizer
分词之后还不能直接喂给模型,得先转成数值特征。我用的 TF-IDF(词频-逆文档频率),这个比单纯数词频好一些——它不光看一个词出现了多少次,还会用 IDF 来惩罚那些到处都有的词。比如’期刊’如果只在垃圾邮件里高频出现,它的 IDF 值就高,TF-IDF 权重就大;而’我们’这种满篇都是的词 IDF 很低,权重自然就压下去了。
TfidfVectorizer 我设了几个参数:tokenizer 传了我们自己写的分词函数,token_pattern 得设成 None 不然 sklearn 会用默认的正则再切一遍。max_df=0.9 把出现在九成以上文档里的高频词忽略掉(相当于自带停用词)。min_df=2 过滤掉只在一个文档里出现的低频词,那种大概率是噪声。ngram_range=(1,2) 的意思是一元词和二元词都提取——比如’免费 领取’这个二元组合,比单独的’免费’或者’领取’更能体现推广类垃圾邮件的特征。
2.5 Pipeline —— 把 TF-IDF 和分类器包在一起
这里用了 sklearn 的 Pipeline,把 TfidfVectorizer 和后面的分类器串成一条流水线。好处是 fit 的时候自动先 fit_transform 特征层再 fit 分类器,predict 的时候自动只 transform 不会重新 fit——省得自己写代码的时候不小心在测试集上做了 fit 导致特征泄漏。
def build_pipeline(classifier):
return Pipeline([
(“tfidf”, TfidfVectorizer(
tokenizer=tokenize,
token_pattern=None,
max_df=0.9,
min_df=2,
ngram_range=(1, 2),
)),
(“clf”, classifier),
])
这个函数的参数 classifier 可以随便换——后面我分别传了 LinearSVC 和 LogisticRegression,代码几乎一模一样,对比起来很方便。
2.6 训练和评估 —— train_and_evaluate
训练就一句 pipeline.fit(),预测一句 pipeline.predict(),很简单。评估这里我用了三个指标:准确率是最直观的但前面说了类别不均衡时不太可信;混淆矩阵能直接看到有多少 ham 被误判成 spam、多少 spam 被误判成 ham;classification_report 会打印每个类别的精确率、召回率和 F1,还有 macro 和 weighted 平均。macro avg 在类别不均衡时比 accuracy 更有参考价值——它是对每个类别算指标然后直接平均,不会因为 spam 数量多就被带偏。
def train_and_evaluate(model_name, pipeline,
x_train, x_test, y_train, y_test):
pipeline.fit(x_train, y_train) # 一步训练
y_pred = pipeline.predict(x_test) # 一步预测
acc = accuracy_score(y_test, y_pred)
cm = confusion_matrix(y_test, y_pred)
report = classification_report(
y_test, y_pred,
target_names=[“ham(普通)”, “spam(垃圾)”],
zero_division=0
)
print(f’准确率: {acc:.4f}’)
print(f’混淆矩阵:\n{cm}’)
print(report)
return {
“model”: model_name,
“accuracy”: acc,
“confusion_matrix”: cm.tolist(),
}
2.7 主流程 —— main
main 函数把前面所有步骤串起来:加载数据、用 train_test_split 分训练集和测试集(stratify=labels 保证切完两边的类别比例一致)、分别训练 SVM 和逻辑回归、最后拿 151~155 号未标注邮件和几条自己写的句子做预测。为了让结果可复现,random_state 设成了 42。
def main():
加载 151 封已标注邮件
texts, labels = load_email_files(EMAIL_DIR)
分层划分(保证 train/test 中 spam/ham 比例一样)
x_train, x_test, y_train, y_test = train_test_split(
texts, labels,
test_size=0.3,
random_state=42,
stratify=labels,
)
---------- SVM ----------
svm_pipeline = build_pipeline(
LinearSVC(C=1.0, max_iter=3000,
random_state=42, dual=False)
)
train_and_evaluate(
“SVM (LinearSVC)”, svm_pipeline,
x_train, x_test, y_train, y_test,
)
---------- Logistic Regression ----------
lr_pipeline = build_pipeline(
LogisticRegression(C=1.0, max_iter=3000,
random_state=42)
)
train_and_evaluate(
“Logistic Regression”, lr_pipeline,
x_train, x_test, y_train, y_test,
)
对 151~155.txt 做预测
for text in load_test_files(EMAIL_DIR):
pred = svm_pipeline.predict([text])[0]
label = ‘垃圾邮件’ if pred == 1 else ‘普通邮件’
print(f’[{label}] …’)
SVM 这边选 LinearSVC 而不是 SVC(kernel=‘linear’),主要是因为 LinearSVC 专为线性场景优化过,速度更快。参数 C=1.0 控制正则化,dual=False 是因为样本数(151)少于特征数(分词后的 TF-IDF 维度),用 primal 形式求解更高效。逻辑回归的参数基本一样,C=1.0、max_iter=3000,两边参数对齐才好公平对比。
三、跑出来的结果
3.1 运行环境
Python: 3.x
scikit-learn: 1.8.0
jieba: 0.42.1
数据集: 151 封标注邮件 (127 spam + 24 ham)
训练集: 105 封 | 测试集: 46 封 (30%)
3.2 SVM 的结果
准确率 89.13%,混淆矩阵和分类报告如下:
混淆矩阵:
预测ham 预测spam
真实ham 2 5
真实spam 0 39
分类报告:
precision recall f1-score support
ham(普通) 1.00 0.29 0.44 7
spam(垃圾) 0.89 1.00 0.94 39
accuracy 0.89 46
macro avg 0.94 0.64 0.69 46
weighted avg 0.90 0.89 0.86 46
3.3 逻辑回归的结果
准确率 84.78%:
混淆矩阵:
预测ham 预测spam
真实ham 0 7
真实spam 0 39
分类报告:
precision recall f1-score support
ham(普通) 0.00 0.00 0.00 7
spam(垃圾) 0.85 1.00 0.92 39
accuracy 0.85 46
macro avg 0.42 0.50 0.46 46
weighted avg 0.72 0.85 0.78 46
一眼就能看出来,两个模型在 spam 上的 recall 都是 1.00(39 封垃圾邮件全识别出来了),但 ham 的 recall 一个只有 0.29,一个直接是 0。准确率看着还行(89% 和 85%),但仔细看混淆矩阵就知道,逻辑回归干脆把所有测试样本都判成了 spam——这就是我前面说的,在这个 5:1 不均衡的数据上,光看准确率是不行的。
3.4 对 151~155 号邮件的预测
SVM 和逻辑回归对这 5 封的预测结果完全一样,全判成了垃圾邮件:
151.txt -> 垃圾邮件 (内容:代开发票广告)
152.txt -> 垃圾邮件 (内容:期刊征稿广告)
153.txt -> 垃圾邮件 (内容:李白《侠客行》)
154.txt -> 垃圾邮件 (内容:新西兰奥克兰旅游介绍)
155.txt -> 垃圾邮件 (内容:格林童话介绍)
151 和 152 号很明显是广告,判成 spam 没毛病。但 153 往后就有问题了——153 是李白《侠客行》的诗,154 在讲新西兰旅游,155 在介绍格林童话,这些怎么看都不像垃圾邮件。但模型为什么全判成 spam 呢?因为训练集里的正常邮件基本全是组会通知、课程讨论、快递代签这种日常对话,而这几封虽然内容正常,但风格上更像是群发的文章推送——原训练集里的垃圾邮件大多也是期刊征稿、广告宣传这种群发内容,所以模型把’较长、非对话、像是批量发送的’这个模式当成了 spam 的特征。这个偏差其实挺能说明问题的——当数据集比较小而且定义不够清晰的时候,模型抓到的模式可能不是我们期望的那个。
3.5 用自己写的句子测一下
为了看看模型在没见过的话术上怎么样,我写了四条让 SVM 来判:
SVM 预测:
[垃圾邮件] 免费领取内部资料并加入股票群,名额有限速来
[普通邮件] 老师您好,明天下午三点在学院会议室开组会,请准时参加
[垃圾邮件] 恭喜您获得苹果手机一部,点击链接填写收货地址即可领取
[垃圾邮件] 实验报告我已经写好了,麻烦老师帮我看一下有没有问题
前三条都对了——‘免费领取”点击链接’这些词确实是垃圾短信的标志,‘老师”开组会’一看就是正常邮件。但第四条出问题了,‘实验报告”麻烦帮我看看’明明是很正常的对话,模型却判成了 spam。我想了一下,大概是因为:(1) ham 总共才 24 封,模型见过的正常表达太少了;(2) ‘麻烦”有没有问题’这些话在期刊征稿邮件里也会出现(像’如有打扰请谅解’),光靠词频确实不太容易把这些边界 case 分对。
四、两个模型怎么比,以及跟实验四的朴素贝叶斯比怎么样
从准确率看,SVM(89.13%)比逻辑回归(84.78%)高了大概 4 个点。更关键的区别在混淆矩阵:SVM 好歹还认出了 7 封 ham 中的 2 封,逻辑回归直接把 7 封全错过了。这说明在这个不均衡数据上,SVM 的 hinge loss 对少数类的边界样本比逻辑回归的交叉熵更敏感一些——逻辑回归追着 105 封训练样本里占绝对多数的 spam 去优化了,几乎不管 ham。
不过,两个模型 ham 的 recall 都不怎么样(0.29 vs 0.00),根子上的问题还是数据——ham 总共才 24 封,切到训练集就剩约 17 封,模型根本没见过多少正常邮件的表达方式。
和实验四的朴素贝叶斯比一下:实验四用的是 MultinomialNB + 词频向量,在那个模型里每个词是独立的(条件独立假设),所以它不需要太多数据也能跑得比较稳——这个假设虽然在真实文本里不成立(词之间肯定有关联),但数据少的时候反而成了一个很有效的’偏见’。SVM 和逻辑回归不做这个假设,理论上更能利用词之间的共现关系,但前提是得有足够的数据——这个实验里 ham 太少了,优势没怎么发挥出来。
如果要继续改进的话,我觉得可以从几个方向试:加一些正常邮件的样本(最根本的),或者给分类器设 class_weight=‘balanced’ 让它自动给少数类更大的权重,也可以加一些别的特征比如文本长度、特殊符号比例之类的。后面如果有时间,用交叉验证跑一下应该能得到更稳的结果。
五、总结
跑下来的话,SVM 的准确率 89% 比逻辑回归的 85% 好一些,但两个模型在识别正常邮件这个任务上都不太行,主要还是 ham 的样本实在太少了。TF-IDF 加线性分类器这个组合虽然不算什么新方法,但胜在简单、跑得快、结果一目了然,作为文本分类的 baseline 还是挺靠谱的。













