简介:围绕钓鱼网站与钓鱼邮件识别,这份机器学习项目资料面向网络安全初学者、算法实践者及需要构建邮件过滤原型的开发者,提供从数据预处理、URL与邮件文本特征提取到模型训练评估的完整代码示例。压缩包共14个文件,以10个Python脚本和4个CSV数据表为主,脚本覆盖词向量处理、简单神经网络、TextCNN、CNN-LSTM及LSTM-CNN等多类建模思路,CSV用于承载钓鱼与非钓鱼样本,整体约339KB,便于本地快速运行和对照修改。已有244人学习,适合用来理解TF-IDF与词嵌入编码、正负样本不平衡处理、交叉验证以及召回率、F1分数和AUC-ROC评估等环节,并对比不同网络结构在钓鱼文本分类上的表现。读者可据此搭建可复现的实验流程,积累特征工程与超参数调优经验,也能为浏览器插件或邮件系统的实时检测原型提供参考。
1. 一封“看着很正常”的邮件,是怎么被模型拦下来的
钓鱼邮件识别真正难的地方,是让模型在误报率极低的前提下还能抓住不断变形的新花样,模型精度本身倒是其次。这份「建立识别钓鱼网站(邮件)的机器学习模型.zip」给的是一条完整流水线:URL 结构特征提取、邮件正文 TF-IDF 向量化、逻辑回归与 XGBoost 双模型、按目标精确率反推的阈值调优脚本,再加一个能直接起服务的 Flask 推理接口。它适合两类人:手上已经有一批标注过的邮件或 URL 样本、想在一两天内跑出可用基线的安全方向开发;以及想拿一个“特征工程 → 模型 → 服务”完整闭环练手的算法同学。下面按我实际拆包的顺序讲,从目录结构一路讲到怎么把它改成你自己业务口径的版本。
2. 工程骨架与数据管道:一封可疑邮件如何变成一条特征向量
拿到压缩包先别急着python train.py,先看目录。这类项目最容易翻车的地方不在模型,而在数据管道——特征在训练时怎么算的,线上就必须一模一样地再算一遍,否则模型在本地 AUC 0.99、上线就变成抛硬币。先把骨架摸清楚,后面每一步才知道该改哪个文件。
2.1 目录结构与模块分工
解压后大致是下面这个布局。我把原始包里的空目录和样例数据省掉了,只留结构本身:
phish-detect/ ├── config/ │ └── settings.yaml # 路径、阈值、模型超参集中配置 ├── data/ │ ├── raw/ # 原始导出:emails.csv / urls.csv │ ├── interim/ # 清洗中间态,可随时删 │ └── processed/ # 切分好的 train/valid/test ├── src/ │ ├── data/ │ │ ├── load.py # 读 CSV、统一列名、去重 │ │ └── clean.py # 去 HTML 标签、URL 归一化 │ ├── features/ │ │ ├── url_features.py # URL 结构特征 │ │ ├── text_features.py # 主题 + 正文 TF-IDF │ │ └── build.py # 拼装成特征矩阵 │ ├── models/ │ │ ├── train.py # 训练 + 交叉验证 │ │ ├── evaluate.py # 指标与阈值选择 │ │ └── predict.py # 离线批量打分 │ └── serving/ │ ├── app.py # Flask 接口 │ └── schema.py # 入参校验 ├── notebooks/ # 探索性分析,别把线上逻辑写这里 ├── tests/ ├── requirements.txt └── Makefile这个划分的思路很清晰:data只负责“把脏数据变成干净的 DataFrame”,features只负责“把一条记录变成数值向量”,models只负责“拟合与评估”,serving只负责“收请求、调 featurizer、返回分数”。四层之间靠普通函数调用串起来,没有引入什么编排框架,对中小规模样本量来说这是最省事也最好调的做法。
关键约束是:serving里绝对不允许再写一份特征计算逻辑,必须importfeatures/build.py里的同一个函数。我见过太多项目在app.py里重写了一遍 URL 解析,结果大小写处理不一致,线上指标直接掉十几个点。
2.2 数据字段与标注口径
原始数据通常来自邮件网关导出或者公开语料,字段五花八门。load.py干的第一件事就是把它们对齐成一张固定 schema 的表:
| 字段 | 类型 | 说明 |
|---|---|---|
mail_id | str | 唯一 ID,去重和回溯都靠它 |
received_at | datetime | 收件时间,切分数据集时必须用 |
sender | str | 发件人地址,注意可能被伪造 |
reply_to | str | 回复地址,和 sender 域名不一致是高危信号 |
subject | str | 主题,短文本,单独一路特征 |
body_text | str | 纯文本正文 |
body_html | str | HTML 正文,用来抽链接和表单 |
urls | list[str] | 从 HTML 里抽出来的链接列表 |
label | int | 1 = 钓鱼,0 = 正常 |
标注口径这一块是最容易扯皮的地方。常见做法是三条硬标准:一,链接指向的域名与声称的品牌主体不一致;二,正文包含索取凭据或引导付款的表单;三,发件域名与显示的机构名对不上。只要命中了其中一条,就记为 1。光凭“看着可疑”标注的数据,训出来的模型边界会很模糊,人工复核时会发现正样本里混着一堆营销邮件。
还有一点,label的分布大概率是极度不平衡的,钓鱼占比可能在 1% 到 5% 之间。不要在这个阶段做任何采样,把采样留到训练阶段处理,原始分布要原样保留,否则后续算出来的精确率没有意义。
2.3 URL 特征:把字符串拆成有物理含义的数字
URL 这一路特征是整个项目里性价比最高的部分。钓鱼域名为了伪装,通常在长度、连字符、数字比例、子域层级上会露出马脚。核心代码如下:
# src/features/url_features.py import math, re from collections import Counter from urllib.parse import urlparse # 低信誉后缀表,按你自己拿到的样本调整,不要照抄 SUSPICIOUS_TLDS = {".zip", ".top", ".xyz", ".click", ".link"} def shannon_entropy(s: str) -> float: """字符分布的香农熵,随机生成的域名熵值通常偏高""" if not s: return 0.0 n = len(s) return -sum((c / n) * math.log2(c / n) for c in Counter(s).values()) def url_features(url: str, brand_words: set) -> dict: p = urlparse(url) host = (p.hostname or "").lower() path = (p.path or "").lower() full = url.lower() digits = sum(ch.isdigit() for ch in full) return { "url_len": len(url), "host_len": len(host), "dot_count": host.count("."), # 子域层级,aa.bb.cc.brand.com 很典型 "hyphen_count": host.count("-"), "digit_ratio": digits / max(len(full), 1), "has_ip_host": int(bool(re.fullmatch(r"\d{1,3}(\.\d{1,3}){3}", host))), "has_at": int("@" in full), # 用户信息混淆 "is_https": int(p.scheme == "https"), "path_depth": len([x for x in path.split("/") if x]), "host_entropy": shannon_entropy(host), "suspicious_tld": int(any(host.endswith(t) for t in SUSPICIOUS_TLDS)), "brand_in_sub": int(any(b in host.split(".")[0] for b in brand_words)), "brand_in_path": int(any(b in path for b in brand_words)), }逐项说一下为什么留这几个:
has_ip_host和has_at是经典强信号,正常业务几乎不会用裸 IP 当主机名,也不会在 URL 里塞@。brand_in_sub和brand_in_path需要一张品牌词表,brand_words从配置文件读,别硬编码在代码里——业务方换品牌的时候你不想改代码。host_entropy是很多人会漏掉的:机器批量注册的域名经常是随机字符串,熵值明显高于正常域名,这个特征在提升召回上很有用。
host_len和url_len不要做归一化以外的处理,树模型对单调变换不敏感,保持原值就行。真正需要处理的是缺失值:URL 解析失败时hostname会是空字符串,这时候所有计数类特征应该是 0 而不是 NaN,否则 XGBoost 会把 NaN 当成一个独立的类别走单独分支,线上遇到同样情况时行为会不一致。
一封邮件通常带多个链接。常见做法是对所有链接各算一遍特征,然后取max、mean和“最大风险链接对应的特征向量”三种聚合。取mean会稀释风险——九个正常链接加一个钓鱼链接,均值看起来完全无害;这也是必须保留max的原因。我在build.py里就是这么拼的。
2.4 文本特征:主题和正文分开走两路 TF-IDF
正文这一路负责捕捉“紧急”“账户异常”“限时处理”这类话术。中文语料要用分词,英文语料直接用词级 n-gram 就够。text_features.py里我一般这么搭:
# src/features/text_features.py from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.pipeline import FeatureUnion def build_text_vectorizer() -> FeatureUnion: subject_vec = TfidfVectorizer( analyzer="char_wb", # 中文走字符级,避开分词歧义 ngram_range=(2, 3), min_df=3, max_features=20000, sublinear_tf=True, ) subject_vec.set_params(**{"stop_words": None}) if False else None body_vec = TfidfVectorizer( analyzer="word", ngram_range=(1, 2), min_df=5, max_features=50000, sublinear_tf=True, strip_accents="unicode", ) # 主题和正文并列拼成一块稀疏矩阵 return FeatureUnion([ ("subject", Pipeline([("vec", subject_vec)])), ("body", Pipeline([("vec", body_vec)])), ]), body_vec这里有两处需要重点提醒的细节:
第一,analyzer="char_wb"用在主题上是有意为之。主题往往很短,只有十几个字,中文分词器在这么短的文本上切分质量很差,“账户异常”可能被切成“账户/异常”,也可能被切成“帐/户/异/常”,不同版本的分词器结果还不一样。字符级 n-gram 完全绕开这个问题,代价是特征维度高一些,但有max_features兜底。
第二,FeatureUnion的结构决定了输入必须是字典形式的列({"subject": ..., "body": ...})。如果你直接把整封邮件拼成一个字符串喂进去,主题里那点高密度信号会被正文的几万字稀释掉,召回率会掉得很明显。
max_features这两个数字不要随便调大。TF-IDF 矩阵本身是稀疏的,维度到十万级别时,逻辑回归还能撑住,但模型文件会从几 MB 涨到几百 MB,服务冷启动会变慢。我一般先用max_features=20000跑一版基线,观察min_df调大到 10 之后指标有没有明显下降,没降就说明大部分低频词是噪声,可以放心砍掉。
最后build.py把 URL 特征(稠密)和文本特征(稀疏)用scipy.sparse.hstack横向拼起来,得到一个混合矩阵。逻辑回归吃这个矩阵没问题,XGBoost 需要先转成csr_matrix再喂进去。这一步的顺序必须在训练和推理时完全一致,所以拼装逻辑一定要封成一个函数,两边共用。
3. 建模与调参:逻辑回归打底,XGBoost 收口
模型这一块,我的建议是别一上来就上大模型。这个任务的特征维度高、样本量中等、正负极度不平衡,逻辑回归在线性可分性上已经能吃到 TF-IDF 的大部分红利,而且它的输出分数本身就是概率,做阈值调优时心里有底。XGBoost 的价值在于处理 URL 特征和文本特征之间的非线性交叉,比如“短域名 + 品牌词出现在路径里”这种组合,线性模型学不到。两个一起用,一个保底一个提分,出问题还能互相印证。
3.1 训练脚本骨架与时间切分
# src/models/train.py import joblib, pandas as pd from scipy.sparse import csr_matrix from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import average_precision_score from xgboost import XGBClassifier from src.features.build import build_train_matrix def time_split(df: pd.DataFrame, ratio: float = 0.8): """按收件时间切分,绝不能用随机切分""" df = df.sort_values("received_at").reset_index(drop=True) cut = int(len(df) * ratio) return df.iloc[:cut], df.iloc[cut:] def train_all(cfg): df = build_train_matrix(cfg) # 已经带上 url_/tfidf_ 前缀的矩阵 train, valid = time_split(df, cfg["split"]["ratio"]) X_tr, y_tr = train.drop(columns=["label"]), train["label"] X_va, y_va = valid.drop(columns=["label"]), valid["label"] models = { "lr": LogisticRegression( C=1.0, class_weight="balanced", max_iter=2000, solver="liblinear" ), "xgb": XGBClassifier( n_estimators=400, max_depth=6, learning_rate=0.08, subsample=0.8, colsample_bytree=0.7, scale_pos_weight=float((y_tr == 0).sum() / max((y_tr == 1).sum(), 1)), eval_metric="aucpr", tree_method="hist", n_jobs=4, ), } for name, m in models.items(): m.fit(csr_matrix(X_tr), y_tr) score = m.predict_proba(csr_matrix(X_va))[:, 1] print(name, "PR-AUC =", round(average_precision_score(y_va, score), 4)) joblib.dump(m, f"artifacts/{name}_v1.joblib") return models几个参数值得说明:
scale_pos_weight是正负样本比,XGBoost 用它来放大正样本的损失权重。注意它是拿训练集算的,不是全集,而且每次重训都要重算,别写死在配置里。solver="liblinear"在稀疏矩阵上比lbfgs快得多,几万维的 TF-IDF 特征用它训练通常几十秒能跑完。eval_metric="aucpr"而不是auc,因为在极度不平衡的数据上 ROC-AUC 会给出虚高的分数——负样本太多,假阳性率怎么涨都涨不上去,指标看着漂亮但实际没法用。
time_split这个函数是整个脚本里最不能省的一行。如果按 8:2 随机切分,同一批模板生成的钓鱼邮件可能同时出现在训练集和验证集里,模型实际上是“背答案”,验证集 PR-AUC 到 0.99,上线就崩。这是我在类似项目里踩过最疼的一次坑,血泪经验就是:只要数据带时间戳,就一定按时间切。
3.2 类别不平衡与阈值反推
不平衡数据不要靠过采样解决,SMOTE 在稀疏的 TF-IDF 矩阵上做插值,生成的“合成正样本”在特征空间里几乎没有物理含义,容易把决策边界拉歪。正确做法是保留原始分布,用class_weight或scale_pos_weight调节损失,然后在验证集上反推决策阈值。
# src/models/evaluate.py from sklearn.metrics import precision_recall_curve def pick_threshold(y_true, y_score, min_precision: float = 0.97): """在保证精确率的前提下,取召回率最高的那个阈值""" prec, rec, thr = precision_recall_curve(y_true, y_score) best = None for p, r, t in zip(prec[:-1], rec[:-1], thr): if p >= min_precision and (best is None or r > best[1]): best = (float(t), float(r), float(p)) if best is None: return 0.5, 0.0, 0.0 return best def metrics_at_threshold(y_true, y_score, thr): y_pred = (y_score >= thr).astype(int) tp = int(((y_pred == 1) & (y_true == 1)).sum()) fp = int(((y_pred == 1) & (y_true == 0)).sum()) fn = int(((y_pred == 0) & (y_true == 1)).sum()) return { "precision": tp / max(tp + fp, 1), "recall": tp / max(tp + fn, 1), "fpr_per_10k": fp / max((y_true == 0).sum(), 1) * 10000, }min_precision这个参数不是拍脑袋定的,取决于业务能承受多少误报。邮件网关每天过十万封,如果误报率是 1%,那就是一千封正常邮件被拦,业务方第二天就会找上门。所以这个值一般卡在 0.97 到 0.995 之间。
函数的逻辑是:遍历 PR 曲线上的每一个点,筛出精确率达标的所有点,在里面挑召回率最高的那个,返回对应的阈值。这比直接用 0.5 靠谱得多——模型输出的概率分布是偏的,0.5 往往既不是最优精确率也不是最优召回率。
fpr_per_10k这个指标是我特意加的。汇报的时候说“假阳性率 0.5%”对方没概念,说“每过一万封正常邮件会误拦 X 封”,沟通效率完全不一样。
3.3 模型融合与评估口径
两个模型的融合不要做加权平均,做个简单的软投票就行,权重靠验证集网格搜一下:
def blend(lr_score, xgb_score, w=0.4): """w 是逻辑回归的权重,验证集上搜出来的""" return w * lr_score + (1 - w) * xgb_scorew一般落在 0.3 到 0.5 之间。逻辑回归的分数校准得比较好,XGBoost 分数在两端容易饱和,权重给太高会让中间段区分度变差。
评估这块,除了 PR-AUC,我还会分片看指标:按发件域名的新旧分(出现过的域名 vs 首次出现的域名),按邮件长度分,按是否含 HTML 表单分。首次出现的域名那一档如果召回率明显低于整体,说明模型过度依赖域名历史统计类特征,得往 URL 结构特征那边补。这个分片分析比多看两个小数点有用得多。
另外,验证集上的指标好看不代表能用,一定要留一份完全没碰过的测试集,而且这份测试集最好是最近一周的数据。钓鱼手法的迭代速度很快,两个月前的样本对现在的模型来说参考价值有限。
4. 服务化落地:Flask 推理接口与特征一致性
模型训完躺在artifacts/里是没用的,得包成一个能接请求的服务。这一步最容易出的问题还是特征一致性:训练时算的特征和线上算的特征对不上,模型分数就成了玄学。
4.1 打包成单一 bundle
不要分别 dump 模型和向量器,一定打成一个字典一起存:
# src/models/pack.py import joblib, hashlib, json from datetime import datetime def pack(model, featurizer, threshold, cfg, out_path): bundle = { "model": model, "featurizer": featurizer, # 含 TF-IDF 向量器 + URL 特征函数引用 "threshold": threshold, "feature_version": cfg["features"]["version"], # 例如 "v3" "trained_at": datetime.utcnow().isoformat(), "metrics": cfg["report"], # 验证集指标快照,方便回溯 } joblib.dump(bundle, out_path, compress=3) return out_pathfeature_version这个字段看着不起眼,实际是后悔药。一旦你改了url_features里任何一个特征的算法,或者增删了特征,都必须把版本号往上加一格,并且在服务启动时校验:加载的 bundle 里的feature_version和当前代码里的常量是否一致,不一致直接启动失败。宁可服务起不来,也别让它带着错位的特征静默跑一周。
metrics快照也建议带上。上线三个月后有人质疑模型变差,你可以直接比对当时的验证集指标和现在的线上指标,判断是模型退化还是数据漂移。
4.2 推理接口:入参校验和超时保护
# src/serving/app.py import time, joblib from flask import Flask, request, jsonify from schema import PredictRequest app = Flask(__name__) BUNDLE = joblib.load("artifacts/bundle_v3.joblib") MODEL, FEAT, THR = BUNDLE["model"], BUNDLE["featurizer"], BUNDLE["threshold"] @app.post("/predict") def predict(): t0 = time.time() payload = request.get_json(force=True, silent=True) or {} try: req = PredictRequest(**payload) # 校验字段、长度、URL 数量上限 except Exception as e: return jsonify({"code": 400, "msg": f"bad payload: {e}"}), 400 try: vec = FEAT.transform([req.to_record()]) # 和训练共用同一个 featurizer score = float(MODEL.predict_proba(vec)[0, 1]) except Exception as e: app.logger.exception("infer failed") return jsonify({"code": 500, "msg": "infer failed"}), 500 return jsonify({ "code": 0, "score": round(score, 6), "label": int(score >= THR), "threshold": THR, "feature_version": BUNDLE["feature_version"], "cost_ms": round((time.time() - t0) * 1000, 2), })PredictRequest里必须限制urls的条数,常见做法是卡在 50 条。不卡的话,一封带两千个链接的邮件会让特征提取跑上好几秒,接口直接被拖垮。同理,body_html也要限制长度,超过 200KB 的截断——真有恶意样本靠超大 HTML 做拒绝服务,我见过一次,接口 P99 直接飙到 8 秒。
返回体里带上threshold和feature_version,是为了让调用方能自己判断分数的含义。有的业务方想按更严格的阈值走二次过滤,有了这两个字段就不用回来找你改接口。
启动命令不用 gunicorn 也行,测试环境flask --app app run --port 8000就够;生产环境建议gunicorn -w 4 -b 0.0.0.0:8000 app:app,四个 worker 足够扛住每秒几十次的调用。
4.3 批量扫描与增量回流
单条接口之外,predict.py里的批量入口也很重要。历史邮件回溯、离线样本打分、人工复核队列,都靠它:
# 批量打分,输出带分数的 CSV,供人工复核 python -m src.models.predict \ --input data/raw/emails_2024_w12.csv \ --bundle artifacts/bundle_v3.joblib \ --out data/processed/scored_w12.csv \ --threshold-from-bundle批量脚本里有个容易被忽略的点:分块读取。别一次性pd.read_csv把几十万行全塞进内存再拼 TF-IDF 矩阵,峰值内存能吃掉几十个 G。用chunksize=5000分批处理、分批写盘,代码多几行,但不用半夜起来重启机器。
人工复核的结果要回流到训练集,这是模型能持续变好的唯一途径。回流时记得带上“复核人”和“复核时间”两个字段,后续分析误报集中在哪一类样本上时用得到。
5. 排查清单:五个把我在半夜叫起来的问题
这一节是我自己踩过的,按“现象 → 原因 → 解决”整理。你如果正在跑这个项目,遇到对不上的地方先来这儿对一遍。
5.1 数据与特征层面的坑
现象一:验证集 PR-AUC 0.99,上线一周精确率掉到 0.6。原因是数据泄漏。同一批模板生成的钓鱼邮件在随机切分时同时进了训练集和验证集,模型记住的是模板里的具体字符串。解决办法是把time_split换成按时间切,并且把“同域名、同主题模板”的样本做一次分组去重,确保GroupKFold意义上的隔离。切完指标通常会掉 5 到 10 个点,那才是真实水平。
现象二:线上打分和离线打分对同一封邮件差了 0.3。原因是 URL 归一化不一致。离线时clean.py做了小写化、去掉末尾斜杠、剥离utm_*追踪参数,线上特征函数却直接吃了原始 URL。解决办法是把归一化逻辑下沉到url_features内部,第一步就做,训练和推理走同一行代码。追踪参数一定要剥,否则同一个链接带不同utm_source会算出完全不同的长度和熵值。
现象三:模型对中文邮件几乎全判为正常。原因是分词器版本和训练时不一致,或者TfidfVectorizer在推理时被重新fit了一次。记住铁律:fit_transform只能出现在训练阶段,线上永远是transform,而且这个向量器必须是从 bundle 里加载出来的那个对象,不能重新构造。另外中文语料建议直接上字符级 n-gram,绕开分词器版本漂移这个坑。
5.2 工程与部署层面的坑
现象四:joblib.load报版本不兼容或者KeyError。原因是训练环境和服务环境的 scikit-learn、xgboost 版本不一致,pickle 反序列化时属性对不上。解决办法是把requirements.txt里的版本号全部钉死(用==而不是>=),并且在 CI 里加一步:用服务镜像的依赖去加载模型文件,加载成功才允许发布。别指望靠“应该差不多”糊过去。
现象五:接口偶发 500,日志里是IndexError: index 1 is out of bounds。原因是predict_proba返回的列数取决于模型见过的类别。如果某次重训的训练集里正样本太少或者采样出问题,模型只学到单一类别,输出就只有一列。解决办法是在训练脚本末尾加断言:assert len(model.classes_) == 2,不满足直接报错退出;服务端再加一层兜底,proba.shape[1] == 2才取第 1 列,否则返回 500 并打日志。这类问题不主动拦,就会变成线上随机崩。
还有一类不算 bug 但很烦人的:同形字域名。用形近的西里尔字母替换拉丁字母,肉眼看不出差别,模型如果只吃字符串特征也会被绕过。常见做法是在归一化阶段做一次 IDNA 转 punycode,把非 ASCII 域名统一转成xn--形式的编码,作为额外特征喂给模型,同时命中时直接给一个高基础分。
6. 二次开发:把通用模型改成你业务口径的版本
跑通之后,多数人要做的第一件事就是换成自己的数据。改造有三个方向,按投入产出比排序。
| 改造方向 | 具体动作 | 预期收益 |
|---|---|---|
| 加业务特征 | 在url_features里补发件域名历史信誉、SPF/DKIM 校验结果、附件类型 | 收益最大,通常能提 3~8 个点召回 |
| 换文本表示 | 中文语料换字符级 n-gram,或接入领域词表做加权 | 中等,改善长尾话术识别 |
| 加规则兜底 | 黑白名单、品牌词表硬拦截,放在模型之后 | 立竿见影,但要注意维护成本 |
加特征这一步,我建议先做一个特征有效性验证再动代码。拿验证集算一下每个候选特征和标签的互信息,或者直接训一版带新特征的 XGBoost,看feature_importances_里它排在第几。排不进前 30 的特征,加进去只会增加线上计算耗时,指标一动不动。
改完之后怎么验证“确实变好了”?不要只看总体指标,按下面这个流程走一遍:
# 改造后的对比验证 def compare_runs(base_bundle, new_bundle, valid_df): base_score = base_bundle["featurizer"].transform(valid_df) new_score = new_bundle["featurizer"].transform(valid_df) # 1) 同一阈值下的精确率/召回率对比 # 2) 分片看:首次出现域名、纯文本邮件、带附件邮件 # 3) 看人工复核队列的长度变化,这是最诚实的指标 ...第三步最关键。指标是抽象的,复核队列是具体的。新模型上线后,如果人工复核队列从每天 300 条涨到 800 条,哪怕 PR-AUC 涨了 2 个点,也说明误报变多了,得回头调阈值。反过来,队列缩短而召回没掉,才是真正意义上的改进。
还有一个低成本但效果明显的技巧:把模型输出拆成三档而不是两档。分数高于 0.9 直接拦截,低于 0.2 直接放行,中间的灰区进人工复核。这样阈值调优的压力小很多,业务方也更容易接受——毕竟“可疑但不确定”和“确认是钓鱼”在处置上是两回事。
最后留一句我自己的习惯:每次重训前,我都会先把上一版 bundle 里的metrics快照和新一版的验证集指标摆在一起对比,如果新版的召回涨了但精确率掉了超过 1 个点,不管总体 F1 好不好看,我都会先停下来查清楚是哪一类样本变了。这个动作拦下过好几次“为了指标好看而牺牲误报”的冲动改动。希望帮到你。
本文还有配套的精品资源,点击获取