资讯动态

垃圾邮件识别实战:TF-IDF、XGBoost与混淆矩阵阈值优化

发布时间:2026/10/26 15:06:16 来源:尧图企业网站定制
垃圾邮件识别是我见过被低估得最厉害的练手项目。很多人第一次做文本分类拿到一份邮件数据集随手CountVectorizer加个MultinomialNB跑出 0.98 的准确率就以为这事儿结束了。真正上手做业务的人都知道0.98 这个数字放在类别极不平衡的邮件场景里含金量可能还不如一个老老实实的 0.93。原因很简单垃圾邮件通常只占全部邮件的百分之十几甚至百分之几模型只要把所有邮件都判成“正常”准确率照样能刷到 0.9 以上可这种模型上线一天就会被用户投诉到下架。所以这篇内容我不打算只给你一段能跑通的代码而是想把邮件分类这条链路上的每一个决策点摊开讲特征怎么造、XGBoost 为什么在这个场景里既好用又容易踩坑、混淆矩阵到底该怎么读、阈值该定在哪。适合谁看如果你已经会用 pandas 读数据、知道什么是训练集验证集但对“为什么我的模型 AUC 很高实际却很难用”这件事感到困惑那这篇基本是为你写的。如果你是完全新手也没关系我会把 TF-IDF、稀疏矩阵、早停这些概念用生活化的方式过一遍代码都是可以直接抄下来改路径就运行的。1. 项目整体设计与思路拆解1.1 先把“邮件分类”这件事的真实难点摆出来大部分人理解的任务是给一段文本判断它是垃圾邮件还是正常邮件。这句话没错但漏掉了三个决定成败的细节。第一个细节是误判代价严重不对称。把一封垃圾邮件放进收件箱用户最多骂一句然后手动删掉把一封客户的合同邮件判成垃圾邮件扔进垃圾箱可能是几十万的损失。这意味着模型优化的目标函数不该是准确率而是加权后的错误代价。你在调阈值的时候这个认知会直接改变你的决策。第二个细节是文本信号的结构化程度很低。邮件正文里有 HTML 标签、有转发链、有签名档、有大量模板化的免责声明。这些内容对分类毫无帮助甚至会误导模型。一份真实的邮件语料清洗掉噪声之后有效信息可能只剩原来的六成。第三个细节是分布漂移。垃圾邮件的发送方会不断变换话术来绕过过滤器你在三个月前的数据上训出来的模型半年后效果会明显衰减。所以整套流程里必须留出能快速重训、快速评估的接口而不是跑一次就完事。我一般会把这类任务拆成四个环节清洗归一到“可读文本”特征工程把文本变成数字矩阵XGBoost 学出决策边界最后用混淆矩阵和阈值曲线把模型翻译成业务能听懂的语言。这四步里前两步决定了效果上限第三步只是逼近上限第四步决定了这个模型能不能真的被用起来。很多人把八成精力花在调参上结果卡在 0.94 上不去其实问题出在第二步的特征根本没造对。1.2 为什么选 XGBoost以及它在文本任务上的诚实短板选 XGBoost 有充分理由它对缺失值友好、对特征量纲不敏感、能自动捕捉特征之间的交互、训练速度快、还能输出特征重要性给业务方看。在表格数据领域它几乎是默认选项这一点没什么争议。但我要说一句可能不太讨好但很实在的话如果你只把 TF-IDF 矩阵原封不动喂给 XGBoost它大概率打不过一个逻辑回归或者线性 SVM。原因是结构性的。TF-IDF 出来的矩阵动辄两三万维、极度稀疏一封邮件可能只有几十个非零位置。树模型的分裂逻辑是“挑一个特征找一个阈值”而在这两万维里绝大多数特征在 95% 以上的样本上都是 0。树在分裂时很难找到一个真正有区分度的切分点深度一深就开始记噪声。线性模型则完全不同它给每个词独立分配一个权重稀疏高维恰恰是它的主场这也是为什么文本分类的经典基线一直是线性模型。那 XGBoost 的优势在哪在异构特征的融合上。邮件分类里有一类极强的信号是 TF-IDF 表达不出来的正文里 URL 的数量、大写字母占比、感叹号密度、是否包含“紧急”“限时”这类词、正文长度、附件数量、发件时间段。这些是数值型特征和稀疏文本特征拼在一起之后树模型能学到“大写比例高 且 含链接 且 正文短”这种组合规则线性模型就吃力了。所以我的标准做法是TF-IDF 走一条线做基线逻辑回归XGBoost 走另一条线吃“降维后的文本表示 手工元特征”两条线对比着看谁好用谁。1.3 评估体系为什么要以混淆矩阵为核心这里我得强调一下评估指标的选择逻辑。准确率在不平衡数据上基本没有参考价值这个大家都知道。ROC-AUC 呢它也有陷阱——ROC 曲线用的是 TPR 和 FPR而 FPR 的分母是负样本总数。当负样本极多时即使误报的绝对数量很大FPR 也可能被压得很低导致 AUC 看起来很美实际业务上误杀一片。真正贴合邮件场景的是PR 曲线精确率-召回率曲线因为它只关心正例垃圾邮件的识别质量不受海量负样本的稀释。而 PR 曲线上的每一个点背后都对应一个具体的阈值和一个具体的混淆矩阵。所以你看到的不该是“我的模型 PR-AUC 是 0.96”这样一个孤立数字而应该是“当阈值取 0.62 时我抓住 93% 的垃圾邮件代价是误杀 0.8% 的正常邮件”。后者才是能拿去做决策的信息。我在项目里固定会产出三样东西给到协作方一张归一化前后的双联混淆矩阵图、一张阈值-代价曲线、一份不同阈值下的业务指标对照表。这三样加起来比任何单一指标都有说服力。2. 数据准备与特征工程把邮件变成 XGBoost 吃得下的数字2.1 数据探查阶段必须搞清楚的四件事拿到数据第一件事不是写模型是写df.info()和df[label].value_counts(normalizeTrue)。我关注的四个点样本总量、正负比例、文本长度分布、有没有重复样本。样本总量决定你能不能用复杂模型。低于五千条XGBoost 很容易过拟合老老实实用线性模型更稳。正负比例决定你要不要做重采样或者设scale_pos_weight。文本长度分布决定了max_features和截断策略——如果 95% 的邮件正文在两千字符以内那少数几封超长邮件就是噪声源直接截断到三千字符不会有损失。重复样本是最阴险的一个坑。邮件数据集里经常出现同一封模板邮件被多次采集的情况如果不做去重这些样本很可能同时落在训练集和验证集里你的验证分数会虚高好几个点上线后原形毕露。我的做法是对正文做哈希去重df.drop_duplicates(subset[text_hash])五秒钟的事能省掉后面半天的困惑。import pandas as pd import numpy as np import hashlib df pd.read_csv(mail_data.csv) print(df.shape) print(df[label].value_counts(normalizeTrue)) df[text_hash] df[text].apply( lambda x: hashlib.md5(str(x).encode(utf-8)).hexdigest() ) before len(df) df df.drop_duplicates(subset[text_hash]).reset_index(dropTrue) print(f去重前 {before} 条去重后 {len(df)} 条) df[char_len] df[text].str.len() print(df.groupby(label)[char_len].describe())2.2 文本清洗留住信号干掉噪声清洗的目标不是把文本搞得越干净越好而是去掉那些与分类无关、却会稀释信号的成分。这一点很多人搞反了上来就把停用词表整个套上去连 “not”“no”“without” 一起干掉结果“not free”和“free”在模型眼里变成一样的东西误判率直线上升。我的清洗顺序是这样的先做 HTML 标签剥离和实体解码再去掉邮件头部的转发信息以 “From:”“Sent:”“Subject:” 开头的连续行然后用正则把 URL 替换成统一占位符URL把邮箱地址替换成EMAIL把连续数字替换成NUM。为什么要替换而不是删掉因为“这封邮件里有链接”本身就是强信号你需要保留“有链接”这个事实只是不需要知道链接具体长什么样。大小写我建议保留原始信息另开一个特征记录大写比例而不是简单.lower()一了百了。全大写的邮件正文在垃圾邮件里出现的频率高得离谱这个信号丢了可惜。同理感叹号、问号、货币符号的密度都是值得单独拎出来做特征的。import re from html import unescape def clean_text(s): if not isinstance(s, str): return s unescape(s) s re.sub(r[^], , s) # HTML 标签 s re.sub(r\bFrom:.*?\n, , s, flagsre.I) # 转发头 s re.sub(rhttps?://\S|www\.\S, URL , s) # 链接占位 s re.sub(r[\w\.-][\w\.-]\.\w, EMAIL , s) # 邮箱占位 s re.sub(r\d, NUM , s) # 数字占位 s re.sub(r\s, , s).strip() return s[:3000] # 截断超长正文 df[clean] df[text].apply(clean_text)注意截断阈值不要凭感觉定。先看一遍长度分位数取 95 分位到 99 分位之间的值既保住了绝大多数有效信息又避免了极端长文本把内存撑爆。2.3 TF-IDF 的参数不是一个一个试出来的TF-IDF 里每个参数都有明确的物理含义理解了就不用网格搜索瞎试。max_features控制词表大小。取值太小会丢掉长尾词取值太大则矩阵膨胀、训练变慢。我的经验值样本量一万以内取 20000五万左右取 30000 到 50000。内存怎么估稀疏矩阵的存储开销约等于nnz × 12字节索引 4 字节 值 8 字节。假设一万条样本、每条平均 80 个非零词nnz 约 80 万占用不到 10MB完全无压力。但如果开了ngram_range(1,2)非零项数量通常涨三到五倍这时就得重新算一遍。min_df2的作用是砍掉只出现一次的词。这类词在语料里多半是拼写错误、随机字符串、或者只有一封邮件才用的专有名词对泛化毫无帮助却会白白撑大词表。max_df0.9则是反向操作砍掉在九成以上文档里都出现的词它们的信息量接近零。这两个参数配合起来相当于对停用词做了一次数据驱动的自动筛选比手写停用词表靠谱得多。ngram_range(1,2)值不值得开看你的语料。如果垃圾邮件里有大量“click here”“act now”“limited offer”这种固定搭配二元组能带来肉眼可见的提升。但代价是矩阵维度和训练时间都上一个台阶。我的建议是先只跑 unigram 做基线确认基线稳定后再加 bigram 对比别一上来就全开。sublinear_tfTrue这个开关我基本必开。它的作用是把词频从tf换成1 log(tf)压制那些在一封邮件里疯狂重复的词。垃圾邮件特别喜欢重复关键词来“强化”信号不处理的话这些词会主导整个向量。from sklearn.feature_extraction.text import TfidfVectorizer tfidf TfidfVectorizer( max_features30000, ngram_range(1, 2), min_df2, max_df0.9, sublinear_tfTrue, strip_accentsunicode, token_patternr(?u)\b\w\b|URL|EMAIL|NUM )注意最后那行token_pattern如果不写默认的\w\w会把URL这类占位符拆碎你辛苦埋的信号就废了。2.4 手工元特征XGBoost 真正的主场前面说过树模型在纯稀疏文本上不占优那就在它擅长的地方补足。我通常会造这么一组数值特征每个都有明确的业务直觉特征名计算方式业务直觉char_len清洗后字符数极短邮件可疑性高word_count分词后词数配合字符数看模板化程度upper_ratio大写字母占比全大写常见于营销/诈骗digit_ratio数字占比大量数字常见于促销、验证码excl_count感叹号数量强情绪词密度url_count链接数量多链接是垃圾邮件典型特征email_count邮箱地址数量群发痕迹money_flag是否含货币符号促销类信号has_unsub是否含“unsubscribe”正规营销 vs 垃圾的边界信号avg_word_len平均词长乱码、拼凑词的间接指标这十个特征加起来只有十列但它们在树模型里能组合出成百上千种规则这是线性模型做不到的。我实测过在纯 TF-IDF 上加这么一组特征XGBoost 的 PR-AUC 通常能往上走两到四个点效果比调半天max_depth明显得多。2.5 稀疏矩阵拼接与数据泄漏防范把 TF-IDF 稀疏矩阵和元特征稠密矩阵拼起来用scipy.sparse.hstack。注意两点一是稠密部分必须先转成csr_matrix二是拼接顺序要固定后面看特征重要性时才知道哪一列对应哪个特征。from scipy.sparse import hstack, csr_matrix def build_meta_features(s): return [ len(s), len(s.split()), sum(c.isupper() for c in s) / max(len(s), 1), sum(c.isdigit() for c in s) / max(len(s), 1), s.count(!), s.count(URL), s.count(EMAIL), int(any(c in s for c in $€£¥)), int(unsubscribe in s.lower()), np.mean([len(w) for w in s.split()]) if s.split() else 0, ] META_COLS [char_len,word_count,upper_ratio,digit_ratio, excl_count,url_count,email_count,money_flag, has_unsub,avg_word_len] meta np.array([build_meta_features(s) for s in df[clean]], dtypenp.float32)数据泄漏是这条链路上最容易犯的错误而且犯了还不容易发现。具体表现是你在全量数据上fit_transform了 TF-IDF然后切分训练验证集。这时验证集的词表和 IDF 权重里已经混入了验证集自身的信息评估分数会虚高。正确做法只有一条先在训练集上fit再对训练集和验证集分别transform。这个规矩在标准化、降维、任何有 fit 环节的变换上同样适用。3. XGBoost 建模与调参的完整走一遍3.1 先把基线跑通再谈优化我见过太多人一上来就写两百行调参代码结果连基线是多少都不知道调完之后也不知道有没有变好。正确顺序是极简配置跑一遍记下分数然后以此为锚点做增量改进。基线配置我会这么设n_estimators300、max_depth4、learning_rate0.1、subsample0.8、colsample_bytree0.8、tree_methodhist。tree_methodhist是必须的在三万维的稀疏矩阵上精确贪心算法慢得让人怀疑人生直方图近似能把训练时间压到十分之一精度损失通常可以忽略。评估指标用aucprPR 曲线下面积比auc更贴合不平衡场景。早停是这个环节的标配。留出验证集设early_stopping_rounds50让模型自己在验证集指标连续 50 轮不提升时停下来。这样你就不用纠结n_estimators到底设多少交给数据决定。import xgboost as xgb from sklearn.model_selection import train_test_split X_train_txt, X_val_txt, y_train, y_val train_test_split( df[clean], df[label], test_size0.2, stratifydf[label], random_state42 ) tfidf TfidfVectorizer(max_features30000, ngram_range(1,2), min_df2, max_df0.9, sublinear_tfTrue, token_patternr(?u)\b\w\b|URL|EMAIL|NUM) Xtr_tfidf tfidf.fit_transform(X_train_txt) # 只在训练集 fit Xva_tfidf tfidf.transform(X_val_txt) meta_tr np.array([build_meta_features(s) for s in X_train_txt], dtypenp.float32) meta_va np.array([build_meta_features(s) for s in X_val_txt], dtypenp.float32) Xtr hstack([Xtr_tfidf, csr_matrix(meta_tr)]).tocsr() Xva hstack([Xva_tfidf, csr_matrix(meta_va)]).tocsr() dtrain xgb.DMatrix(Xtr, labely_train) dval xgb.DMatrix(Xva, labely_val) params { objective: binary:logistic, eval_metric: aucpr, max_depth: 4, eta: 0.1, subsample: 0.8, colsample_bytree: 0.8, min_child_weight: 3, tree_method: hist, seed: 42 } bst xgb.train(params, dtrain, num_boost_round1000, evals[(dval, val)], early_stopping_rounds50, verbose_eval50)3.2 参数之间的耦合关系比单个参数的最优值更重要调参这件事最忌讳的是把参数当独立变量一个个刷。它们之间是强耦合的理解这层耦合关系能省掉你一大半的网格搜索时间。max_depth和min_child_weight是一对。深度控制模型能学多复杂的规则min_child_weight控制一个叶子节点最少要覆盖多少样本权重。文本特征稀疏如果min_child_weight设得太小默认 1树会疯狂在只有一两个样本的稀疏特征上分裂那是百分之百的过拟合。我通常从 3 到 5 起步正样本少的时候可以加到 10。learning_rate和n_estimators是一对。学习率小、树多模型更平滑、泛化更好但训练慢。我的一般路径是先用eta0.1把结构参数调好确定max_depth和min_child_weight的量级最后把eta降到 0.03 到 0.05让早停自己决定树的数量。别一上来就用 0.01训练时间翻五倍效果不一定好。subsample和colsample_bytree是在做随机性注入本质是给模型加正则。文本特征维度高colsample_bytree0.5甚至 0.3 往往比 0.8 效果好因为每次分裂只看一部分特征能强制模型不去依赖某几个高频词。正则项reg_alphaL1和reg_lambdaL2我通常在结构参数定下来之后再动。L2 对树模型的权重收缩效果更平滑一般先调reg_lambda从 1 往上试到 10。L1 会让很多叶子权重直接归零效果更激进只在明显过拟合时才加。scale_pos_weight专门对付不平衡。设成负样本数除以正样本数能让模型在优化时对正例更敏感。但我一般不在一开始就设它而是先看基线在不设的情况下的召回率如果召回明显偏低比如只有 0.6再把这个值加上去。因为过早设置会破坏概率校准后面调阈值时你会很痛苦。3.3 交叉验证单次划分的分数会骗人单次 train/val 划分得到的分数波动可能有两三个点。样本量不大时这个波动足以让你做出错误判断。我固定会用StratifiedKFold分层是必须的否则某一折里正样本可能只剩个位数跑五折看均值和标准差。交叉验证的另一个价值是能暴露数据泄漏。如果五折的分数方差特别大比如 0.92、0.71、0.88、0.95、0.68那基本可以断定数据里存在分布不一致或者重复样本的问题这时候该回头查数据而不是继续调参。from sklearn.model_selection import StratifiedKFold from sklearn.metrics import average_precision_score skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) scores [] for fold, (tr_idx, va_idx) in enumerate(skf.split(df[clean], df[label])): tf TfidfVectorizer(max_features30000, ngram_range(1,2), min_df2, max_df0.9, sublinear_tfTrue, token_patternr(?u)\b\w\b|URL|EMAIL|NUM) Xtr_f tf.fit_transform(df[clean].iloc[tr_idx]) Xva_f tf.transform(df[clean].iloc[va_idx]) mtr np.array([build_meta_features(s) for s in df[clean].iloc[tr_idx]], dtypenp.float32) mva np.array([build_meta_features(s) for s in df[clean].iloc[va_idx]], dtypenp.float32) dtr xgb.DMatrix(hstack([Xtr_f, csr_matrix(mtr)]).tocsr(), labeldf[label].iloc[tr_idx]) dva xgb.DMatrix(hstack([Xva_f, csr_matrix(mva)]).tocsr(), labeldf[label].iloc[va_idx]) b xgb.train(params, dtr, num_boost_round1000, evals[(dva, val)], early_stopping_rounds50, verbose_evalFalse) pred b.predict(dva, iteration_range(0, b.best_iteration 1)) s average_precision_score(df[label].iloc[va_idx], pred) scores.append(s) print(fPR-AUC: {np.mean(scores):.4f} ± {np.std(scores):.4f})3.4 阈值这件事别交给默认的 0.5模型输出的是概率落到“垃圾”还是“正常”需要一个人为的阈值。sklearn 默认 0.5但这个数字在业务上没有任何依据纯粹是数学上的中点。确定阈值的方法取决于你的目标。如果目标是最大化 F1那就遍历阈值找 F1 峰值。如果业务方给得出代价比如“漏判一封垃圾邮件的损失是 1 个单位误杀一封正常邮件是 10 个单位”那就构造一个期望代价函数找让它最小的阈值。后者更贴近真实决策也更难跟人解释我一般两个都算把两条曲线画在同一张图上让决策者自己看。实测下来的规律是类别不平衡时最优阈值往往明显低于 0.5因为模型见到的正例少输出概率整体偏保守。有时候最优阈值会落在 0.2 到 0.3 之间这时候还硬用 0.5召回率会白白损失十几个点。from sklearn.metrics import precision_recall_curve proba bst.predict(dval, iteration_range(0, bst.best_iteration 1)) prec, rec, thr precision_recall_curve(y_val, proba) f1 2 * prec[:-1] * rec[:-1] / (prec[:-1] rec[:-1] 1e-9) best_idx np.argmax(f1) best_thr thr[best_idx] print(f最佳阈值 {best_thr:.3f} 精确率 {prec[best_idx]:.4f} 召回率 {rec[best_idx]:.4f}) # 按业务代价找阈值误杀代价是漏判的 10 倍 cost 10 * (1 - prec[:-1]) * 0 1 * (1 - rec[:-1]) # 简化示意实际按混淆矩阵四格算上面这段代价计算我写得很简略实际使用时应该按混淆矩阵的四个格子算代价 C_FN × FN C_FP × FP其中 FP 是误杀数量、FN 是漏判数量两者都随阈值变化。把它展开成完整公式再遍历阈值取最小值得到的才是真正的最优决策点。4. 模型评估与混淆矩阵可视化4.1 混淆矩阵的四个格子每个都对应一笔钱混淆矩阵是 2×2 的表格行是真实标签列是预测标签。四个格子分别是预测为垃圾预测为正常真实为垃圾TP正确拦截FN漏判进了收件箱真实为正常FP误杀进了垃圾箱TN正确放行很多人读混淆矩阵的方式是“看对角线越长越好”这个理解太浅。真正要读的是每一格背后的业务后果。TP 是模型创造的价值FP 是模型造成的损害FN 是模型漏掉的收益TN 是模型的沉默成本。你把这几格分别乘以对应的单位代价加总起来才是模型的真实价值。举个例子验证集一万封邮件垃圾邮件一千封。模型 TP930、FN70、FP15、TN8985。准确率是 0.9915看着很美。但如果你知道误杀一封重要邮件的代价相当于漏判二十封垃圾邮件那这 15 次误杀的损失就相当于 300 封漏判总代价 370对应的阈值显然不是最优的。归一化版本的混淆矩阵也一定要看。原始数值的矩阵会被大类别主导一眼看不出比例关系按行归一化之后每一格显示的是该类别的召回率和误判率直观得多。我通常把两个版本并排画出来左边看绝对量右边看比例。4.2 画一张能直接放进报告的混淆矩阵图sklearn.metrics.confusion_matrix负责算seaborn.heatmap负责画。这里面有几个细节值得单独说。第一个是中文显示问题。matplotlib 默认字体不含中文字形直接写中文标签会变成一堆方块加警告。需要显式指定字体plt.rcParams[font.sans-serif] [SimHei]在多数环境下有效macOS 下可能要换成Heiti TC或者Arial Unicode MS。同时把axes.unicode_minus设成 False否则负号也会出问题。第二个是颜色方案。热力图的默认蓝色系在打印或者投影时对比度不够我一般用Blues或者自定义的绿色渐变深色代表数值大。这样领导扫一眼就知道哪个格子人多。第三个是在格子里同时标注数量和百分比。单纯标数量读者要在脑子里做除法单纯标百分比又不知道样本基数。两个都写形如930\n93.0%信息最完整。import matplotlib.pyplot as plt import seaborn as sns from sklearn.metrics import confusion_matrix plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False y_pred (proba best_thr).astype(int) cm confusion_matrix(y_val, y_pred) cm_norm confusion_matrix(y_val, y_pred, normalizetrue) labels [正常邮件, 垃圾邮件] fig, axes plt.subplots(1, 2, figsize(13, 5)) for ax, data, title, fmt in zip( axes, [cm, cm_norm], [混淆矩阵绝对数量, 混淆矩阵按行归一化], [d, .2%] ): sns.heatmap(data, annotTrue, fmtfmt, cmapBlues, cbarFalse, xticklabelslabels, yticklabelslabels, axax, annot_kws{size: 14}) ax.set_xlabel(预测标签, fontsize12) ax.set_ylabel(真实标签, fontsize12) ax.set_title(title, fontsize13) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150, bbox_inchestight) plt.show()跑出来之后我习惯再加一步在图上直接标注出 TP、FP、FN、TN 四个缩写在格子里。对不熟悉术语的人这一步能省掉一轮解释。提示confusion_matrix的normalize参数在不同版本里行为略有差异老版本接受true/pred/all字符串更老的版本只有normalizeTrue。如果你在别人的环境里跑报错先查一下 sklearn 版本号这比改代码快。4.3 光有混淆矩阵还不够配套的三张图混淆矩阵是某个阈值下的切片它回答不了“换个阈值会怎样”。所以我的评估包里通常还带三张图。第一张是 PR 曲线横轴召回率、纵轴精确率曲线上标出当前阈值所在的位置。这张图能直观展示两个指标的此消彼长也让业务方明白“要抓得更全就必然误伤更多”。第二张是阈值扫描曲线横轴阈值、纵轴分别画精确率、召回率、F1。三条曲线交叉的地方就是平衡点一眼就能找到。如果业务方给了代价比例我还会叠加一条期望代价曲线标出代价最低的阈值位置。第三张是校准曲线reliability curve。它检查的是模型输出的概率是否可信。如果模型说“这封邮件有 80% 的概率是垃圾”那在所有被判定为 80% 的样本里应该有大约 80% 真的是垃圾。XGBoost 在不平衡数据上训练时概率往往偏保守或偏激进校准曲线能暴露这个问题。如果偏离严重可以用CalibratedClassifierCV做一次 isotonic 或 sigmoid 校准虽然对排序指标没帮助但能让阈值有实际意义。fig, ax plt.subplots(figsize(8, 4)) ax.plot(thr, prec[:-1], label精确率, lw2) ax.plot(thr, rec[:-1], label召回率, lw2) ax.plot(thr, f1, labelF1, lw2, ls--) ax.axvline(best_thr, colorgray, ls:, labelf最优阈值 {best_thr:.2f}) ax.set_xlabel(判定阈值) ax.set_ylabel(指标值) ax.set_title(阈值扫描精确率 / 召回率 / F1) ax.legend() plt.tight_layout() plt.show()4.4 特征重要性怎么看才有意义XGBoost 提供了三种重要性度量weight被用于分裂的次数、gain平均信息增益、cover平均覆盖样本数。默认是weight但我几乎不用它——一个特征被分裂很多次不代表它重要可能只是它取值多、容易切。gain更靠谱它衡量的是每次使用这个特征带来的平均增益。不过这里有个陷阱文本任务里TF-IDF 的几万个词特征每个单独看增益都很小重要性排名前列的往往是你手工造的那十个元特征。这不是说词特征没用而是它们的贡献被分散了。所以看重要性时我会把词特征和元特征分开统计各自看前二十而不是混在一张表里排。另一个值得做的分析是分组重要性把三万维词特征按词性或者业务含义分组紧迫感类词、金钱类词、链接类词统计每组的累计增益。这个视角比单个词的重要性更能说明模型到底学到了什么也更容易跟业务方沟通。5. 常见问题与排查技巧实录5.1 那些让人怀疑人生的报错稀疏矩阵拼稠密矩阵时报维度不匹配。hstack要求行数一致如果哪一步不小心对元特征数组做了转置或者 reshape 出问题行数就对不上。排查方法很简单拼接前先把两边的shape打印出来一眼就能看出问题。DMatrix 构建时提示不支持某类型。XGBoost 对float32支持最好如果元特征数组里混了object或者int64可能出问题。统一.astype(np.float32)最省心。训练过程中 loss 变成 nan。大概率是特征里混进了无穷大或者极端值。检查一下upper_ratio这类除法特征如果分母是 0 且没做保护就会产出inf。我在build_meta_features里统一用max(len, 1)兜底就是为了防这个。中文标签变成方块。前面说过字体问题。如果你是远程服务器跑图可能连中文字体都没装最省事的办法是把标签换成英文或者直接在服务器上装一个思源黑体。内存爆掉。ngram_range开太大、max_features设太高、或者清洗时忘了截断长文本这三者叠在一起很容易吃掉十几 G 内存。我的经验是五十万条以内的样本、三万维词表、二元组8G 内存能跑超过这个量级就先把max_features降下来。5.2 效果卡住不动时的排查路径分数上不去别急着调参。先按这个顺序查一遍现象可能原因处理方式训练集 PR-AUC 0.99验证集 0.85过拟合加深正则、降max_depth、提高min_child_weight训练集和验证集都很低欠拟合或特征不足加元特征、放宽max_features、提高模型容量五折分数方差极大数据分布不一致或存在重复查去重、查采样时间跨度召回率高但精确率极低阈值偏低或正负严重不平衡提高阈值、加scale_pos_weight后重新校准所有样本都被判为负例学习率过大、正则过强或标签编码错误检查标签是否 0/1、把正则调回默认值重跑验证分数明显高于人工抽检数据泄漏检查 TF-IDF 是否在全量数据上 fit 过还有一个容易被忽略的问题标签噪声。邮件数据集里的标签经常是人工标注或者规则生成的本身就有 3% 到 5% 的错标。如果你的模型分数怎么调都卡在 0.95 附近很可能已经碰到了标签噪声的天花板这时候继续调参是白费力气。检测方法很简单把模型判错的样本挑出来人工看二十条如果有一半你觉得模型判得比标签还合理那说明数据本身有问题该做的是清洗标签而不是优化模型。5.3 我踩过的几个坑第一个坑是在测试集上反复调阈值。最开始我习惯在验证集上找最优阈值然后在测试集上汇报结果隐含地让测试集信息参与了决策。正确做法是切出三份训练、验证调参和选阈值、测试只用来最终评估一次。样本量实在不够时至少要用嵌套交叉验证虽然麻烦但结论可信。第二个坑是只用了一份时间点的数据做随机划分。邮件的分布随时间变化随机划分会让未来数据混进训练集评估分数虚高。如果数据带时间戳用时间切分前 80% 训练、后 20% 验证更贴近真实上线场景分数通常会掉两三个点但那个分数是真的。第三个坑是忽略了推理速度。文本特征三万维、树五百棵单条预测耗时可能到几十毫秒。批量预测还好如果要做实时拦截这个量级就偏慢了。真要做在线服务可以考虑把模型蒸馏成一个浅层模型或者提前把 TF-IDF 的向量化做成流式处理。第四个坑是忘了留一版可复现的记录。TF-IDF 的词表、XGBoost 的参数、阈值、随机种子这四样必须一起存档。我吃过的一次亏是三个月后想复现一个 0.94 的模型结果只记得大概的参数范围重跑出来怎么都是 0.91排查了半天才发现是当时min_df设的 3 而不是默认的 1。现在我固定会把整个配置序列化成一个 json 文件跟模型一起存。import json config { tfidf: {max_features: 30000, ngram_range: [1, 2], min_df: 2, max_df: 0.9, sublinear_tf: True}, xgb: {k: (v if not isinstance(v, float) else round(v, 6)) for k, v in params.items()}, best_iteration: int(bst.best_iteration), best_threshold: float(best_thr), meta_cols: META_COLS, seed: 42 } with open(run_config.json, w, encodingutf-8) as f: json.dump(config, f, ensure_asciiFalse, indent2)最后再分享一个我觉得挺有用的小技巧。评估阶段不要只输出一张图把混淆矩阵、PR 曲线、阈值扫描、特征重要性四张图拼成一张 2×2 的画布保存成一个文件。这样每次跑完实验你只需要打开一张图就能对模型状态有个整体判断比来回翻五个文件高效得多。我在做迭代的时候会把这几十张拼图按时间顺序排在一个文件夹里用文件名带上日期和 PR-AUC 数值几次之后就能明显看出哪个方向的改动是真有效、哪个只是在原地打转。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑