电子邮件分类实战:XGBoost建模、模型评估与混淆矩阵

发布时间:2026/10/25 10:11:33
电子邮件分类实战:XGBoost建模、模型评估与混淆矩阵

电子邮件分类这个题目,十个人里有八个的做法是:读入数据、TF-IDF 一把梭、XGBoost 默认参数直接 fit、打印 accuracy,看到 0.98 就关掉 Notebook 收工。我第一次做这个任务时也是这么干的,直到把这套模型接到一个真实的收件箱里跑了两周,才发现那个漂亮的 0.98 背后躺着几十封被误判的正常邮件——客户询价、面试通知、账单提醒,全被丢进了垃圾箱。而垃圾邮件漏进来几封,用户顶多骂一句,误杀一封重要邮件,可能就是一单生意没了。

所以这篇东西不打算再给你一份"调用 API 的说明书"。标题里那串关键词——电子邮件分类、XGBoost 建模、可视化、模型评估、混淆矩阵——拆开看每个都不难,难的是把它们串成一条能经得起真实数据考验的流水线:文本怎么变成树模型吃得下的特征、类别不平衡时阈值该怎么定、混淆矩阵那四个格子到底对应多少真金白银的代价、哪些图值得画哪些图纯属自娱自乐。下面这套流程是我在几个类似的邮件/短信文本二分类任务里反复打磨过的版本,代码不复杂,但每一步的取舍都有理由,你可以直接抄,也可以按自己的场景改。

1. 邮件分类任务的真实难点:为什么"跑个 XGBoost 就完事"是错觉

1.1 从标题到任务定义:spam/ham 到底在分什么

先把任务定义钉死,这一步比写代码重要十倍。邮件分类最常见的形态是二分类:一封邮件是 spam(垃圾/广告/钓鱼)还是 ham(正常)。标签只有两个值,看起来简单到不需要思考,但"简单"恰恰是坑的来源——因为它太容易让人跳过定义环节直接开始调参。

真正需要提前确认的是三件事。第一,判定标准由谁定。一封来自陌生域名、正文只有一个短链接的邮件,是营销还是正常业务通知?这在不同业务里答案完全不同,而这个答案会直接决定你的标注口径,进而决定模型学到的东西。第二,代价是否对称。把正常邮件判成垃圾(误杀,False Positive 视角下取决于你怎么定义正类)和把垃圾邮件放进收件箱(漏放),这两件事的代价差着数量级。第三,线上分布会不会漂移。垃圾邮件的发送方是会进化的,你今年训练的模型,明年可能就抓不住新话术,这一点必须在方案设计时就留出重训的口子。

我在做第一个版本时犯的错是:拿到公开的垃圾邮件文本数据集就直接开跑,完全没想清楚正类到底是哪一类。结果后面画混淆矩阵时,行列顺序和业务口径对不上,解释图表的时候自己都绕晕了。后来我养成一个习惯——在代码最上面写三行注释:正类是 spam、负类是 ham、主要指标是 spam 类的召回率,同时要求精确率不低于某个阈值。这三行注释比任何超参数都值钱。

另外要提醒一句,公开数据集上的 0.98、0.99 这种数字,参考价值有限。那些数据集往往是清洗过的、类别分布相对规整、话术也比较"古典"。真实收件箱里的邮件长度跨度极大、编码混乱、夹杂大量 HTML 残留和多语言混排,同一个模型上去掉三到五个点的 F1 是常态。把公开数据当成流程验证的沙盒,别把它当成效果承诺。

1.2 文本特征与树模型之间的"错位感"

这里有个很多人没意识到的结构性矛盾:TF-IDF 输出的特征维度动辄几万到几十万,而且是极端稀疏的——一封邮件可能只命中几百个词,其余全是 0。而 XGBoost 这类树模型的工作方式是逐特征找分裂点,它擅长的是"在某个稠密数值特征上切一刀",不是"在十万维稀疏向量里挑几个非零项"。

这带来两个直接后果。一是训练慢,特征维度上去以后,每一轮找分裂点都要扫一遍候选特征,几万维的稀疏矩阵会让训练时间线性上涨。二是效果未必比线性模型好——在纯高维稀疏文本上,逻辑回归、线性 SVM 经常和树模型打平甚至略胜,因为它们天然适合处理这种"每个词一票"的结构。

那为什么还要用 XGBoost?因为真实场景里的信号从来不只在词上。邮件的字符长度、大写字母占比、感叹号密度、链接数量、是否含金额数字、发件时间、是否有附件、主题行和正文的长度比……这些稠密的、可解释的统计量,恰恰是树模型的主场。XGBoost 的价值不在于替代 TF-IDF,而在于把稀疏的文本特征和稠密的手工特征放进同一个模型里联合建模,让树自己去决定"什么时候该看词、什么时候该看长度"。

所以我的策略一直是:TF-IDF 负责兜住语义层面的信号,手工统计特征负责提供树模型能高效利用的结构化信号,两者拼接后喂给 XGBoost。这个组合在多个类似任务上都能稳定超过单用其中一种。如果你只打算用 TF-IDF,那我建议你先拿逻辑回归跑个基线,说不定省事还更好。

1.3 评估口径要在建模之前定死

还有一个顺序问题:很多人是先训模型,训完了再想"我该看哪个指标"。这个顺序反了。指标应该在看到任何结果之前就定好,否则你会不自觉地挑一个让数字好看的指标来汇报——这是人性,不是道德问题。

我的做法是固定一套三层评估:第一层看 spam 类的精确率和召回率这对组合,不看 accuracy;第二层看混淆矩阵的四个绝对计数,确认没有某一类被系统性牺牲;第三层看不同阈值下的 P-R 曲线,为线上调阈值留出依据。三层都过,模型才算可用。这套口径一旦定下来,后面所有的对比实验都用同一把尺子,你才敢说新版比旧版好。

2. 数据进场:清洗、切分与防泄漏的三道闸

2.1 原始邮件文本的脏数据清单

真实邮件文本的脏,是那种你没见过就想象不到的脏。我整理过一份必查清单,每次拿到新数据先按这个过一遍:HTML 标签残留(<br>、<div>、内联样式)、HTML 实体转义(&nbsp;、&amp;)、被截断的超长邮件(有些数据集单条上限几千字符,超长部分直接被砍,导致正文语义断裂)、多语言混排、全角半角混用、连续空白与换行、电话号码和金额等数字串、以及重复样本。

重复样本这件事值得单独说。文本数据集里"完全相同的两封邮件出现多次"非常普遍,尤其是模板化的广告邮件。如果不做去重,同一个样本可能同时落进训练集和验证集,你的验证分数会虚高得离谱,而且这种虚高极难自查——因为每一行看起来都是合法数据。我的处理是:在切分之前,先对"清洗后的正文 + 标签"做一次 drop_duplicates,这一步经常能干掉几个百分点的数据量。

清洗的力度也要克制。我见过有人为了"干净"把所有数字、所有标点全部删掉,结果把"限时 3 折""仅需 99 元"这类强信号一起删了。垃圾邮件的判别信号里,符号密度和数字模式占了相当大的比重,所以我倾向于只做轻量清洗:去 HTML 标签、反转义实体、压缩连续空白、统一小写(仅对文本特征列,手工特征列保留原始大小写信息)。剩下的交给模型。

import re import html def clean_text(s: str) -> str: if not isinstance(s, str): return "" s = html.unescape(s) # &nbsp; &amp; 之类 s = re.sub(r"<[^>]+>", " ", s) # 粗暴去标签,够用 s = re.sub(r"\s+", " ", s) # 压缩空白 return s.strip().lower()

注意:re.sub(r"<[^>]+>", ...)这种正则去标签对结构良好的 HTML 够用,但遇到残缺标签会误伤正文里的a < b这类表达式。如果你的语料里技术类内容多,建议换成解析器或者把正则收紧。

2.2 切分顺序:先切分,再 fit 向量化器

这是文本分类里最经典、也最容易被忽略的泄漏点。TfidfVectorizer在fit的时候会统计整个语料的词频和文档频率,用来计算 IDF 和筛选词表。如果你先在整个数据集上fit_transform,然后再切分训练/验证,那么验证集的词汇信息已经通过 IDF 权重"渗"进了特征构造过程。结果就是验证集分数虚高,上线后断崖式下跌。

正确顺序只有一种:先切分,只在训练集上fit,然后用同一个已 fit 好的向量化器去transform验证集和测试集。在交叉验证里同理——必须在每一折内部重新 fit,不能在外面 fit 一次然后循环 transform。这也是为什么我强烈建议用Pipeline来组织,而不是手写流程:手写的时候,只要有一处fit_transform落在了全量数据上,整个评估就废了,而且它不会报错。

from sklearn.model_selection import train_test_split from sklearn.feature_extraction.text import TfidfVectorizer X_tr, X_te, y_tr, y_te = train_test_split( df["text_clean"], df["label"], test_size=0.2, random_state=42, stratify=df["label"] ) vec = TfidfVectorizer(ngram_range=(1, 2), min_df=3, max_features=60000) X_tr_tfidf = vec.fit_transform(X_tr) # 只在训练集上 fit X_te_tfidf = vec.transform(X_te) # 验证/测试只 transform

记住一个判断标准:如果某一步操作"看到了全量数据的统计量",那它就必须在切分之后、只在训练集上做。标准化、特征选择、过采样、目标编码,全部适用。

2.3 类别失衡的量化与分层抽样

垃圾邮件数据集的不平衡程度差异很大,有的接近 1:1,有的能到 1:20 甚至更极端。动手之前先把这个比例算出来,因为它直接决定后面要不要用scale_pos_weight、阈值该怎么设、该看 ROC 还是 PR 曲线。

ratio = (y_tr == 1).sum() / (y_tr == 0).sum() print(f"正负样本比: {ratio:.3f}")

切分时务必加stratify=y。不加的话,在一千多条的样本量下,正类在验证集里的比例可能和训练集差出好几个百分点,而正类恰好是你最关心的那类,这个偏差会直接歪掉你的召回率估计。分层抽样不能保证每折完全一致,但能把波动压到可接受范围。

如果你的数据量小到几百条,还要额外注意一件事:单次 8:2 切分的评估结果波动会非常大,换个random_state分数可能差三五个点。这种情况下不要相信任何单次划分的结果,直接用 StratifiedKFold 跑五折,报均值加减标准差。我吃过这个亏——曾经因为一次切分运气好,把一个实际很一般的模型当成了突破。

3. 特征工程:把一封邮件压成 XGBoost 能读懂的向量

3.1 TF-IDF 的三个参数:ngram、min_df、max_features

TfidfVectorizer的参数不多,但每一个都有明确的作用,调之前得知道自己在动什么。

ngram_range控制词组粒度。(1,1)只取单词,维度低、泛化好;(1,2)加上二元词组,能捕捉"点击这里""限时优惠""账户异常"这类短语信号,对垃圾邮件识别帮助明显,代价是维度膨胀几倍。我的经验是邮件场景用(1,2)收益最大,上到三元组就开始被噪声拖累,因为三元组的共现频率太低,在几千条的样本量下基本学不到东西。

min_df是文档频率下限,用来砍掉只在一两封邮件里出现的词。这个参数在小样本场景下极其重要——不加限制的话,词表里会塞满拼写错误、随机字符串、一次性的人名,这些特征在训练集上完美区分,在测试集上完全没见过,典型的过拟合来源。数据量几千条时我一般设min_df=3,上万条时可以放宽到 2。

max_features是词表上限,按词频排序截断。它的作用主要是控制内存和训练时间,对效果的影响相对温和。六万左右通常是个合理的起点,如果特征重要性图显示大量特征的重要性都接近 0,那就说明截多了或者min_df设得太松。

这三个参数不该拍脑袋定。我通常的做法是固定其他参数,只扫一个,在交叉验证下看 F1 的变化曲线,找一个拐点。这个过程不优雅但有效,而且能帮你建立对数据的直觉——比如你可能会发现min_df从 1 调到 3 就能带来两个点的提升,那说明你的数据里噪声词占比很高。

3.2 手工统计特征:树模型真正的主场

前面说过,树模型最擅长的是稠密数值特征。所以我一般会补一组人工构造的统计量,直接对原始文本(清洗前)计算:

特征名计算方式直觉解释
char_len字符长度广告邮件常极端短或极端长
word_cnt分词后词数与长度互补,排除凑字数的空白
upper_ratio大写字母数 / 字母总数全大写标题是强促销信号
digit_ratio数字字符占比价格、折扣、验证码
excl_cnt感叹号个数情绪强度
question_cnt问号个数钓鱼邮件常用引导式提问
punct_ratio标点总数 / 字符总数符号堆砌
url_cnt正则匹配 http/https/www 的次数链接密度是最强信号之一
money_kw是否命中"元/折/免费/中奖"等词业务先验的直接编码
subject_len主题行长度有主题行字段时可用

这些特征的好处是可解释性极强。当你回头向别人解释"为什么这封邮件被判成垃圾"时,说"因为它有 12 个链接、7 个感叹号、正文 90% 是大写",比说"因为 TF-IDF 第 38472 维的权重是 0.83"有用得多。而且当模型误判时,这类特征能帮你快速定位问题——有一次我发现模型对一批短邮件系统性误判,查下来是char_len这个特征在训练集里和标签形成了虚假相关(那批短邮件恰好都是某个来源的广告),加了一个来源维度的交叉特征后才缓解。

构造这类特征时有个小技巧:尽量用比值而不是绝对数。绝对数会随邮件长度变化,比值更稳定。比如url_cnt不如url_cnt / max(word_cnt, 1)稳健,因为一封超长邮件里有 5 个链接很正常,一封 20 个词的邮件里有 5 个链接就很可疑了。

3.3 稀疏矩阵拼接与内存控制

TF-IDF 输出的是scipy.sparse矩阵,手工特征是稠密的numpy数组。两者要拼在一起,用scipy.sparse.hstack,注意把稠密部分先转成稀疏格式,否则会触发内存爆炸。

import numpy as np from scipy.sparse import hstack, csr_matrix hand = csr_matrix(np.asarray(X_tr_hand, dtype=np.float32)) X_tr_all = hstack([X_tr_tfidf, hand]).tocsr().astype(np.float32)

两个细节值得强调。第一,统一用 float32。sklearn 的 TF-IDF 默认输出 float64,转成 float32 能省一半内存,对精度的影响在这个任务上基本可以忽略。第二,拼接顺序必须固定。训练时是"TF-IDF 在前、手工特征在后",预测时也必须是这个顺序,一旦顺序错了,模型不会报错,只会静默地给出错误结果——这是我在第 7 节要展开讲的坑之一。

如果维度上到十几万,还要留意DMatrix的构造内存。XGBoost 会把稀疏矩阵转成自己的内部格式,这个转换过程会临时占用接近两倍的内存。机器内存吃紧的时候,可以先del掉不再需要的中间变量再构造。

4. XGBoost 建模:参数不是抄来的,是推出来的

4.1 先立基线:最小可跑通版本

我坚持任何模型都先跑一个"丑但能跑"的基线。基线的作用不是出成绩,而是给你一个参照系——后面所有的调优都要能证明自己比基线好,否则就是在自我感动。

基线版本我一般这样写:默认参数、不做任何不平衡处理、固定random_state、五折交叉验证、记录每折的精确率/召回率/F1。跑完你会得到一组很朴素的数字,比如 F1 0.94、召回 0.91、精确 0.97。这组数字就是你的地板。

import xgboost as xgb from sklearn.model_selection import StratifiedKFold from sklearn.metrics import precision_score, recall_score, f1_score skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for fold, (tr_idx, va_idx) in enumerate(skf.split(X_all, y)): dtr = xgb.DMatrix(X_all[tr_idx], label=y[tr_idx]) dva = xgb.DMatrix(X_all[va_idx], label=y[va_idx]) params = {"objective": "binary:logistic", "eval_metric": "auc", "tree_method": "hist", "seed": 42} bst = xgb.train(params, dtr, num_boost_round=400, evals=[(dva, "valid")], early_stopping_rounds=40, verbose_eval=False) pred = (bst.predict(dva, iteration_range=(0, bst.best_iteration + 1)) > 0.5).astype(int) print(fold, precision_score(y[va_idx], pred), recall_score(y[va_idx], pred), f1_score(y[va_idx], pred))

注意predict里那个iteration_range。用了早停之后,bst内部保留的是"最佳轮次 + 额外训练的那些轮",如果不指定范围直接predict,你用的其实是最后一轮而不是最佳轮,指标会莫名其妙地差一截。这个细节不注意,能让你在调参上白折腾半天。

4.2 树深、学习率、子采样这几个参数的实际作用

XGBoost 的参数表很长,但真正影响文本分类效果的其实就那么几个。我把它们分成三组来理解。

控制模型复杂度的一组:max_depth和min_child_weight。文本特征维度高、样本相对少,深树非常容易记住噪声。我一般从max_depth=4或5起步,很少超过 8。min_child_weight默认是 1,在样本量几千条的情况下建议提到 3 到 5,它要求每个叶子节点至少覆盖一定权重和,能有效抑制对孤立样本的过拟合。这两个参数是一对,调深了树就得同步提min_child_weight。

控制训练过程的一组:eta(学习率)和num_boost_round。这两个必须联合看。eta小、轮数多,通常效果更好但更慢;eta大、轮数少,收敛快但容易停在次优点。我的做法是先用eta=0.1配合早停找到大概的轮数,再把eta降到 0.05、把num_boost_round上限抬高,看最佳轮次的指标有没有提升。有提升就保留,没提升就用回 0.1,不必死磕。

控制随机性的一组:subsample和colsample_bytree。前者按行采样,后者按列采样。文本特征列高度相关("免费"和"免费领取"几乎同现),colsample_bytree调到 0.6 到 0.8 能明显降低过拟合,是我在这个任务上最常用的一招。subsample一般 0.8 左右就够。

还有tree_method。数据量不大时用hist就够了,它在大多数场景下速度最优;exact更精确但慢得多,文本任务上几乎没必要。

4.3 scale_pos_weight 到底怎么算

这个参数很多人抄了个数字就用了,其实它的含义很明确:正类样本的权重放大倍数,用来补偿类别不平衡。标准的计算方式是负类数量除以正类数量:

neg = (y_tr == 0).sum() pos = (y_tr == 1).sum() spw = neg / pos

但我不建议无条件套用这个公式,原因有两个。第一,它只调整了损失函数的加权,会系统性地把预测概率往上推,如果你的下游是按固定阈值(比如 0.5)做决策,加完权重后精确率会掉、召回率会涨,这个交换是不是你想要的,得看业务。第二,如果正类(垃圾邮件)反而是少数类,那么加权会提升召回但牺牲精确,也就是更多正常邮件被误杀——这往往是最不能接受的代价。

所以我的处理顺序是:先不加权重跑基线,看 PR 曲线;如果发现召回明显不够、且精确率还有富余,再引入scale_pos_weight,并且从neg/pos的一半开始试,而不是直接用满。同时把阈值调优和权重调优分开做——两个变量一起动,你永远不知道收益来自哪一个。

4.4 交叉验证与早停的正确姿势

早停的验证集必须是"模型完全没见过"的数据,这一点在交叉验证里容易搞错。正确的做法是:在每一折内部,从训练折里再切一小块出来做早停监控,而不是用验证折。如果用验证折做早停,那你报告的验证指标就是被早停"挑选过"的,偏乐观。

实现上可以这样:五折循环内部,把tr_idx再按 9:1 分成fit_idx和es_idx,用es_idx做evals监控,用va_idx做最终评估。这样做多了一层切分,训练数据略少,但评估结果可信度高一档。样本量特别小的时候可以省掉这层,用固定的num_boost_round配平均学习曲线来定轮数。

另外记得固定seed,XGBoost 在hist模式下有随机性(列采样、行采样),不同 seed 的结果可能差零点几个点。做对比实验时,我一般跑三个 seed 取均值,虽然费时间,但能避免把噪声当提升。

5. 可视化:看图的顺序比图本身重要

5.1 先看类别分布和文本长度分布

可视化不是建模之后的装饰环节,它应该贯穿整个过程。我的习惯是拿到数据先画三张图,这三张图决定了后面所有的技术选择。

第一张是类别分布条形图。它告诉你不平衡有多严重,直接决定要不要处理权重、该看 ROC 还是 PR。第二张是两类邮件的文本长度分布(重叠直方图或箱线图)。这张图信息量很大——如果两类在长度上就有明显分离,说明长度本身是个强特征,手工特征那一步的收益会很高;如果完全重叠,那说明信号主要在词上,TF-IDF 的参数就更值得调。第三张是文本长度的长尾情况,看看有没有极端值需要截断。

画这两张图用 seaborn 的histplot加hue参数就够了,几行代码的事。但我见过不少人直接跳过这一步,上来就调参,结果模型训完才发现数据里有一批长度为零的空文本(清洗时被正则吃掉了),白白浪费一整轮实验。

5.2 两类的关键词差异:用计数而不是词云

词云图很好看,但我基本不用它,原因很简单:词云的字体大小和词频不是线性关系,而且布局随机、每次刷新都不一样,你没法从两张词云里做严谨的对比。想知道两类邮件的用词差异,直接算词频对比更靠谱。

我的做法是:分别统计两类的高频词,取 spam 类词频排名靠前、但在 ham 类里排名靠后的词,反过来也做一遍,列成一张对比表。更严谨一点可以用对数几率比(log odds ratio)配合平滑,避免低频词的比值爆炸。这个方法能直接告诉你哪些词是判别性的,而且这些信息可以反过来指导你的手工特征——如果"免费""中奖""点击"排在最前面,那就说明你构造的money_kw这类特征方向是对的;如果排在前面的全是些奇怪的拼写变体,那说明数据里可能有大量模板化重复样本,需要回头查去重。

还有个小技巧:把停用词去掉之前,先看看不去停用词的高频词表。有时候停用词的组合模式本身就是信号,比如异常多的"您"和"立即"共同出现,这在正常商务邮件里反而少见。

5.3 特征重要性图的三个读法陷阱

XGBoost 提供了好几种重要性计算方式,weight(被用作分裂特征的次数)、gain(平均增益)、cover(平均覆盖样本数),默认是weight。这三个结果经常不一致,而大多数人只看默认的那个,然后得出错误结论。

第一个陷阱:weight偏向高基数特征。一个取值丰富的连续特征(比如char_len)有更多可能的分裂点,被选中的次数天然就多,但这不代表它更重要。我更倾向看gain,它衡量的是这个特征带来的平均增益,更接近"贡献度"的本意。

第二个陷阱:重要性高不等于因果。某个特征重要性排第一,只说明它在这批数据上对区分有帮助,不说明它和标签有因果关系。前面提到的那个例子——短邮件恰好都来自某个来源的广告——char_len的重要性会虚高,换了数据分布就失效。

第三个陷阱:TF-IDF 特征和手工特征混在一起看没有意义。一个单词特征的重要性是 0.8,一个手工特征的重要性也是 0.8,这两者的可比性很弱,因为它们的尺度和分布完全不同。我的做法是分组统计:把所有 TF-IDF 特征的 gain 加起来,把所有手工特征的 gain 加起来,看两组各占多少。如果手工特征(通常只有十几维)能占到 20% 以上,说明这组特征的设计是成功的,值得继续扩充。

imp = bst.get_score(importance_type="gain") n_tfidf = len(vec.get_feature_names_out()) tfidf_gain = sum(v for k, v in imp.items() if int(k[1:]) < n_tfidf) hand_gain = sum(v for k, v in imp.items() if int(k[1:]) >= n_tfidf) print("TF-IDF 占比:", tfidf_gain / (tfidf_gain + hand_gain))

5.4 学习曲线:判断是缺特征还是过拟合

把每一轮迭代的训练集和验证集指标画在同一张图上,这条曲线能告诉你很多事。两条线一起上升且贴合,说明模型还没学够,可以加轮数或者加特征;训练集继续上升而验证集走平甚至下降,说明开始过拟合,该降max_depth、提min_child_weight、或者加大colsample_bytree;两条线都在低位就走平,说明特征表达能力不够,加参数没用,得回去做特征工程。

我特别喜欢在加了新特征之后重新画这张曲线,对比一下验证集曲线的位置有没有整体上移。上移了就保留,没动就删掉——这个判断方法比看单点指标更稳,因为它排除了随机波动的影响。

6. 混淆矩阵:四个格子里的真实代价

6.1 先看计数,再看比例,最后看业务含义

混淆矩阵是这篇标题里最实在的一个词,也是最容易被草草带过的。标准的两行两列,行是真实标签、列是预测标签(注意 sklearn 的confusion_matrix默认就是这个顺序,别搞反):

预测 ham预测 spam
真实 hamTNFP(误杀)
真实 spamFN(漏放)TP

绝大多数人画完热力图就完了,看到对角线颜色深就放心了。我建议按三步读。第一步,看绝对计数。比例会掩盖规模——1% 的错误率在 100 条数据上是 1 条,在 100 万条上是 1 万条。第二步,看哪一类被牺牲。如果 FP 明显多于 FN,说明模型偏向于把邮件判成垃圾,这在垃圾邮件场景里通常是不可接受的。第三步,把计数翻译成业务后果:FP 是 37 封的话,意味着有 37 个人可能因为没收到邮件而流失,这个数字要拿给能做决策的人看。

还有一个常被忽略的点:混淆矩阵的标签顺序必须和其他指标的计算口径一致。confusion_matrix(y_true, y_pred, labels=[0,1])里labels参数的顺序决定了行列顺序,precision_score(pos_label=1)又是一种口径,两者对不上,你看着矩阵得出的结论可能就是反的。我踩过这个坑,后面第 7 节细说。

6.2 阈值不是 0.5:按误杀成本移动决策线

predict()默认用 0.5 做切分,这个默认值没有任何业务依据,纯粹是数学上方便。而现实中,误杀和漏放的代价几乎从来不相等。

做法很直接:拿predict_proba输出的概率,自己扫一遍阈值,把每个阈值对应的混淆矩阵和指标都算出来,做成一张表。

import numpy as np from sklearn.metrics import confusion_matrix, precision_score, recall_score proba = bst.predict(dva, iteration_range=(0, bst.best_iteration + 1)) rows = [] for th in np.arange(0.20, 0.81, 0.05): pred = (proba >= th).astype(int) tn, fp, fn, tp = confusion_matrix(y[va_idx], pred).ravel() rows.append({"th": round(th, 2), "FP": fp, "FN": fn, "precision": precision_score(y[va_idx], pred), "recall": recall_score(y[va_idx], pred)})

拿到这张表之后,决策逻辑就变成一句话:在 FP 不超过某个预算的前提下,最大化 FN 的减少量。比如业务方说"每周误杀不能超过 5 封",那你就从高阈值往下调,找到 FP 刚好到 5 的那条线,看那时的召回是多少。这个过程不需要重训模型,只要改一个比较运算符。

6.3 ROC 与 PR 曲线在失衡数据下的分歧

ROC 曲线用 TPR 对 FPR,PR 曲线用精确率对召回率。在类别平衡的数据上两者结论基本一致,但在严重失衡的数据上,ROC 会显得过于乐观。

原因在于 FPR 的分母是负类总数(通常是海量的 ham),当负类极多时,即使 FP 的绝对数量已经很大,FPR 仍然很小,ROC 曲线照样贴得很高。而 PR 曲线的精确率分母是"被你判为正类的样本数",FP 一多精确率立刻掉下来,敏感得多。

所以我的习惯是:数据不平衡比例超过 1:5 时,以 PR 曲线和 PR-AUC 为准,ROC 只作为参考。报告里两个都给,但主结论基于 PR。另外图表上要标出当前阈值对应的那个点,让人一眼看清你的工作点在曲线的什么位置——很多"我们的 AUC 有 0.99"的说法,工作实际点可能落在召回只有 0.6 的位置上。

6.4 交叉验证下的混淆矩阵,而不是单次划分

单次划分算出来的混淆矩阵,每个格子都带着随机性。尤其 FN 这种数量本来就少的格子,换个random_state可能从 8 变成 15。如果你拿单次的结果去和别人汇报"我们只漏了 8 封",公信力是存疑的。

我的做法是:五折交叉验证,用cross_val_predict拿到每个样本的折外预测概率,然后在这个完整向量上算一次混淆矩阵和所有指标。这样每个样本都被恰好预测了一次,矩阵的格子数字稳定得多。同时把五折各自的指标也记录下来,报"均值 ± 标准差",比如召回 0.93 ± 0.02,这比单个数字诚实得多,也更能反映模型的实际稳定性。

再补一个小细节:混淆矩阵的数量级差异大时,原始计数的热力图会因为颜色拉伸而看不出小格子。这时候用annot=True显示数字、或者画两张图(一张原始计数、一张行归一化比例)应对。

7. 几个真实翻车现场与排查链路

7.1 指标突然涨到 0.99:先怀疑泄漏

有一次我把 F1 从 0.94 调到了 0.99,兴奋了大概十分钟,然后开始自查,果然抓到了泄漏。原因是那版代码里我把去重和向量化放在了切分前面——fit_transform在全集上做了一遍,然后切分。验证集的词频信息通过 IDF 权重泄漏到了特征里。

排查链路是这样的:先看指标跳变的幅度,超过 3 个点就要警觉;再检查所有fit、fit_transform、fit_resample的调用位置,凡是作用在全量数据上的都是嫌疑对象;然后做一个"污染测试"——把标签随机打乱,重新跑一遍整个流程,如果分数还是显著高于随机水平(比如 AUC 0.7 以上),那说明流程里一定有泄漏,因为纯噪声数据不可能被学到东西。这个标签打乱的测试方法很好用,五分钟就能做完,建议每个新流程上线前都跑一次。

7.2 特征名错位导致的重要性图完全失真

DMatrix从 numpy 数组构造时,特征名默认是f0、f1……。如果你训练时用了一种拼接顺序,预测时换了另一种(比如先拼手工特征再拼 TF-IDF),模型不会报任何错,因为维度数是一样的,但它会把手工特征当成某个词特征来用,结果静默地错。

这个错误极难发现,因为所有指标都会下降但不会崩,你会以为是参数没调好,一直往调参的方向使劲。我的预防措施是给所有特征起名字,构造DMatrix时传feature_names,然后在预测前断言名字列表完全一致。

feat_names = list(vec.get_feature_names_out()) + hand_cols dtr = xgb.DMatrix(X_tr_all, label=y_tr, feature_names=feat_names) # 预测时复用同一个 feat_names,顺序错会直接报错

显式传名字还有个附带好处:plot_importance出来的图会显示真实特征名,而不是一堆f3847,可读性天差地别。

7.3 混淆矩阵行列反了,结论就完全相反

sklearn.metrics.confusion_matrix的返回是[[TN, FP], [FN, TP]],前提是标签是 0/1 且正类是 1。如果你在某个环节把标签编码成了字符串("spam"/"ham"),那么矩阵的行列顺序会按字母序排,"ham"在前、"spam"在后,位置关系变了。如果这时候你还按记忆里的tn, fp, fn, tp = cm.ravel()去解包,四个变量全错位。

这个错误的杀伤力在于:你可能会把 30 个误杀解读成 30 个漏放,然后做出完全相反的调优决策。防御方法很简单——永远显式传labels=,并在解包后断言tn + fp + fn + tp == len(y_true),再加一句打印出每类的样本数核对。多写两行,省掉半天。

7.4 权重叠加导致概率分布整体漂移

有一版模型我同时用了scale_pos_weight和 sklearn 接口里的sample_weight,两个都往正类上加权,效果叠加了两次。模型输出的概率整体被推高,原本 0.3 的样本变成了 0.6,用 0.5 做阈值的话大量正常邮件被误杀,召回率确实上去了,精确率掉得很难看。

这个问题出在概念混淆:scale_pos_weight是 XGBoost 内部的类别加权机制,sample_weight是样本级权重,两者作用在同一个损失函数上,叠加使用等于加权了两次。选一个用就行。我现在的做法是统一用sample_weight,因为它更灵活——可以按样本精细设置,而不只是按类别。设完之后一定要重新扫一遍阈值,因为概率分布已经变了,原来的阈值不再适用。

排查这类问题的通用思路是看概率分布的直方图:把正负样本的预测概率分别画成两张分布图,如果两类分布重叠区域异常大或者整体偏移,多半是权重或阈值设置有问题。这个图我每次调完权重都会看一眼,比看指标直观。

8. 把 Notebook 折成一个能跑批的推理脚本

Notebook 里的模型和能上线的模型之间,隔着的不是算法,是工程细节。我一般会做三件事把它折成可复用的形态。

第一,把预处理和模型打包成一个对象。向量化器、手工特征列名、阈值、booster,全部存进一个字典,用pickle或joblib落盘。千万不要只存 booster 不存向量化器——没有配对的向量化器,booster 就是一堆无法解读的数字。文件名里带上训练日期,方便回溯。

第二,推理脚本自己做特征构造。最忌讳的是推理时手工拼特征,因为人工操作必然出错。把clean_text、手工特征函数、hstack顺序全部封成一个transform_one(text)函数,训练和推理共用同一份代码。这是防止训练/推理不一致的最有效手段。

第三,做输入校验和兜底。空字符串、超长文本、非 UTF-8 编码、缺失字段,这些在批量推理时一定会遇到。我的处理是:空文本直接判 ham(宁可放过不可错杀),超长文本截断到训练集的最大长度(避免分布外),解析失败记日志并走默认分支。这些兜底逻辑不优雅,但能让脚本在半夜跑批的时候不崩。

def predict_batch(texts, bundle, threshold=None): th = threshold if threshold is not None else bundle["threshold"] cleaned = [clean_text(t) for t in texts] X = hstack([bundle["vec"].transform(cleaned), csr_matrix(build_hand_features(texts))]).tocsr().astype(np.float32) p = bundle["booster"].predict(xgb.DMatrix(X, feature_names=bundle["feat_names"]), iteration_range=(0, bundle["best_iter"] + 1)) return (p >= th).astype(int), p

最后一个提醒:模型上线之后要持续采样人工复核,把误判样本回收进训练集。垃圾邮件的话术是有人专门在迭代的,你今天训出来的模型,三个月后效果掉几个点是必然的。重训的触发条件我一般设两条——误杀率连续一周超过阈值,或者累计回收的误判样本超过训练集的 10%。这两条任意一条触发就重训,比按固定周期重训更贴合实际。

我个人在这个任务上摸爬滚打下来最大的体会是:模型分数从来不是难点,难点在于搞清楚"错哪一类更疼"。把这件事想明白,阈值怎么定、指标看哪个、混淆矩阵的哪个格子要死盯,全都是自然推导出来的结果。反过来,先调参后想业务,往往调到最后发现自己优化的方向和真正重要的方向是反的。