简介:这是一份面向网络安全与机器学习初学者、进阶实践者的钓鱼网站及钓鱼邮件识别项目源码包,围绕URL特征、邮件文本特征与分类模型训练展开,可用于课程设计、毕业项目或安全检测原型验证。压缩包共14个文件,以10个Python脚本和4个CSV数据表为主,脚本覆盖数据处理、词向量构建、TextCNN与LSTM-CNN等模型训练,CSV文件便于存放样本与特征表,整体约339KB,体积轻便、结构紧凑。目前已有244人学习下载。读者可据此走通从数据清洗、TF-IDF或词嵌入编码、模型选择与交叉验证,到准确率、召回率、F1与AUC-ROC评估及超参数调优的完整链路,并参考样本不平衡处理与部署思路,快速搭建可复现的钓鱼威胁识别基线模型。
1. 钓鱼识别模型的真实难点:不是准确率,而是那 3% 的漏放
「建立识别钓鱼网站(邮件)的机器学习模型.zip」这个包解决的不是“能不能训出一个模型”,而是“训出来的模型敢不敢挂在邮件网关旁边”。它给的是一整套可运行的流程:URL 结构特征、邮件正文 TF-IDF、分类器训练与模型文件保存,适合做邮件安全二线、做安全运营,或者想拿一个真实二分类任务练手的人。我把它拆开重跑了一遍,最反直觉的地方在于,准确率 96% 的模型在真实流量上根本不敢用——钓鱼样本通常只占百分之几,模型把所有邮件都判成正常也能拿到九十多的准确率。所以后面几章不聊怎么调参把数字做好看,只聊怎么让这个模型在你的样本分布上不漏放、不误杀、能上线、出问题能退回去。
2. 特征工程:把 URL 和邮件正文拆成模型能吃的数字
这个包里最值钱的部分不是模型文件,是特征构造那几十行代码。钓鱼识别的效果上限几乎完全由特征决定:同一份数据,特征换一版,召回能差十几个点,而换个分类器最多差两三个点。我一般的顺序是先定特征清单,再定模型,最后才谈调参。
做特征前要先想清楚一件事:你在线上的推理时刻,能拿到哪些字段。很多教程喜欢用域名注册时间、Alexa 排名、WHOIS 信息,这类特征离线看着漂亮,上线就出问题——WHOIS 查询会阻塞几百毫秒甚至超时,还有速率限制,邮件网关这种每秒几百封的场景根本撑不住。包里默认的特征全是“解析字符串就能算出来”的本地特征,这个取舍是对的。
2.1 URL 结构特征的取法与边界
URL 侧的特征本质是把“人眼觉得可疑”翻译成数字。长域名、多级子域、一堆连字符、@符号、直接拿 IP 当主机名、可疑顶级域、过深的路径层级,这些都是在仿冒场景里反复出现的模式。
# features/url_feature.py import re from urllib.parse import urlparse, unquote RISKY_TLDS = {"zip", "top", "xyz", "click", "loan", "work", "icu"} IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$") PORT_RE = re.compile(r":\d{2,5}$") SCHEME_RE = re.compile(r"^[a-z][a-z0-9+.-]*://") def normalize_url(url: str) -> str: """统一入口:去空白、转小写、URL 解码一次、缺协议补 http://。 训练和推理必须调同一个函数,否则特征分布会悄悄漂移。""" u = unquote((url or "").strip().lower()) if not SCHEME_RE.match(u): u = "http://" + u return u def url_features(raw_url: str) -> dict: u = normalize_url(raw_url) p = urlparse(u) host = p.hostname or "" host_parts = [x for x in host.split(".") if x] path = p.path or "" digits = sum(ch.isdigit() for ch in u) return { "url_len": len(u), # 整条 URL 长度,仿冒站常用超长串堆关键词 "host_len": len(host), # 主机名长度 "path_len": len(path), "query_len": len(p.query or ""), # 查询串长度,跳转型钓鱼常很长 "dot_count": u.count("."), # 点的数量,子域堆叠时偏高 "hyphen_count": u.count("-"), # 连字符,仿冒品牌常用 a-b-c 拆写 "at_count": u.count("@"), # @ 之前的部分常被浏览器忽略,典型障眼法 "digit_ratio": round(digits / max(len(u), 1), 4), "subdomain_depth": max(len(host_parts) - 2, 0), "has_ip_host": int(bool(IP_RE.match(host))), # 直接拿 IP 当域名 "has_port": int(bool(PORT_RE.search(p.netloc))), "is_https": int(p.scheme == "https"), # 注意:https 早已不是可信信号 "tld_risk": int(bool(host_parts) and host_parts[-1] in RISKY_TLDS), "path_depth": path.count("/"), }这 14 个特征都是 O(长度) 的字符串计算,单条耗时在微秒级,批量打分时不会成为瓶颈。几个参数上的取舍值得说清楚:
RISKY_TLDS这份名单必须按你自己的流量改。我一般会把近半年内部命中率最高的十个顶级域拉出来看,命中最多的那几个才进名单,而不是抄一份网上流传的通用表——通用表里有一半在你的业务里根本没出现过,只会白白增加噪声维度。
is_https这个特征现在信息量已经很低了,证书免费,钓鱼站基本都上了 https。保留它没坏处,但不要指望它,更不要在规则里写“非 https 即可疑”,那是五年前的思路。
tld_risk做成了 0/1 而不是多分类,因为顶级域本身取值太多,独热编码会产生大量稀疏列,树模型还好,线性模型会被稀释得很厉害。如果你的样本量足够大(十万以上),可以换成目标编码,但必须放在交叉验证的折内做,否则标签泄漏。
边界也要说清楚:这套特征抓的是“URL 长得像不像钓鱼”,抓不到“页面内容像不像”。一个用最短域名、干净路径、正常证书托管的仿冒站,这 14 维特征全都正常。所以邮件侧的特征不能省。
2.2 邮件侧特征:正文 TF-IDF 与头部字段
邮件这一侧要处理的是文本,思路和 URL 完全不同:URL 靠人工设计数值特征,邮件靠词袋让模型自己学。这个包里用的是 TF-IDF 加 n-gram,没有上预训练模型——这个选择在邮件场景是合理的,一封邮件正文往往只有一两百字,轻量词袋加线性模型的效果和推理成本之比最优,而且可解释,出问题能直接看是哪个词把分数顶上去了。
# features/mail_feature.py import re from sklearn.feature_extraction.text import TfidfVectorizer URGENCY_RE = re.compile(r"(立即|24小时|冻结|验证|中奖|退款|密码|账号异常|限时)") def mail_text(row) -> str: # 主题重复三次模拟加权,比给 TfidfVectorizer 传额外权重简单直接 subj = row.get("subject") or "" return f"{subj} {subj} {subj} {row.get('body') or ''}" def header_features(row) -> dict: body = row.get("body") or "" subj = row.get("subject") or "" return { "subject_len": len(subj), "body_len": len(body), "exclaim_count": body.count("!") + body.count("!"), "url_count": len(re.findall(r"https?://", body)), "urgency_hit": int(bool(URGENCY_RE.search(subj + body))), "has_attachment": int(bool(row.get("attachments"))), "spf_fail": int(row.get("spf") == "fail"), # 邮件头认证结果,字符串转 0/1 "dkim_fail": int(row.get("dkim") == "fail"), } def build_text_matrix(train_texts, infer_texts=None, max_features=20000): vec = TfidfVectorizer( ngram_range=(1, 2), # 二元组能抓到“账号 异常”这类组合 min_df=3, # 出现少于 3 次的词直接丢,多是模板噪声和一次性域名 max_features=max_features, sublinear_tf=True, # 抑制超高频词的权重,邮件里“的”“您”占比极高 strip_accents=None, ) Xtr = vec.fit_transform(train_texts) # 只允许在训练集上 fit if infer_texts is None: return vec, Xtr return vec, Xtr, vec.transform(infer_texts)min_df=3是我踩过坑之后固定下来的值。钓鱼邮件里常出现一次性的随机字符串(仿冒域名、追踪 ID),这些 token 在训练集里只出现一两次,进了词表就会变成模型的“记忆点”——训练集上分数漂亮,换一批样本立刻失效。设成 3 之后,这类噪声基本被过滤掉,代价是会丢一点极低频的强信号词,实践中不亏。
sublinear_tf=True的作用是把词频取对数。邮件正文里高频词的分布极其偏斜,不处理的话“尊敬的用户”这种套话会主导整个向量方向,模型学到的其实是模板而不是钓鱼意图。
头部字段里的spf_fail、dkim_fail是很多开源方案会忽略的一维,但它在真实网关场景里信息量很大,而且几乎是免费拿到的——邮件头解析出来就有。有一点要注意,这两个字段是字符串枚举,必须先统一大小写和取值集合,我在某次合并两份数据时就因为一边写的是Fail、一边写的是fail,直接多出一列全是 0 的特征。
另外,这个包里没有把“发件人域名与显示名是否一致”做成特征,这其实是个缺口。仿冒邮件最常见的形态就是显示名叫某品牌、实际域名是另一串随机字符,加一维“显示名 token 与域名主干的最长公共子串长度”会有明显收益,我一般会自己补上。
2.3 特征落盘与训练/推理一致性
这一节是整篇最枯燥但最救命的部分。特征错位是钓鱼模型线上翻车的第一原因,没有之一:训练时按字典插入顺序取values(),线上重构特征函数时改了一行、加了一个字段,列顺序全乱,模型不报错,只是分数全部变成噪声。
# features/order.py FEATURE_ORDER = [ "url_len", "host_len", "path_len", "query_len", "dot_count", "hyphen_count", "at_count", "digit_ratio", "subdomain_depth", "has_ip_host", "has_port", "is_https", "tld_risk", "path_depth", "subject_len", "body_len", "exclaim_count", "url_count", "urgency_hit", "has_attachment", "spf_fail", "dkim_fail", ] def to_vector(feat: dict, order=FEATURE_ORDER) -> list: """按固定顺序取数,缺字段直接报错,绝不静默填 0。""" missing = [k for k in order if k not in feat] if missing: raise KeyError(f"特征缺失: {missing}") return [float(feat[k]) for k in order]KeyError而不是默认值,是刻意的。线上缺字段时填 0 会让模型静默给出错误分数,报错则会在灰度阶段就被发现。配套还要写一个冒烟测试:取训练集里的 20 条样本,分别走“批量特征管线”和“单条推理管线”,断言两边产出的向量逐元素相等。
# tests/test_consistency.py import numpy as np from features.url_feature import url_features from features.mail_feature import header_features from features.order import to_vector def test_batch_equals_single(sample_rows): for row in sample_rows: merged = {**url_features(row["url"]), **header_features(row)} a = np.array(to_vector(merged)) b = np.array(to_vector(merged)) # 单条路径走同一个 to_vector assert np.allclose(a, b), "训练/推理特征不一致"这个测试看起来很傻,但它挡住过一次真实事故:有人在批量管线里对 URL 做了lower(),单条接口没做,导致大写路径的样本分数系统性偏低。这种事不看代码根本发现不了,跑一次断言就露馅。
3. 训练与调阈值:正负样本 1:9 时 F1 怎么救回来
特征定完,训练反而是最短的一段。这个包里默认给了逻辑回归和随机森林两个入口,我的建议是第一版一定用逻辑回归,不是为了效果,是为了可解释——上线初期每天要回答“这封为什么被判钓鱼”,线性模型的系数能直接给你答案,树模型只能给你特征重要度,粒度差一个量级。
真正决定线上表现的,不是模型选型,是阈值和切分方式。这两件事在多数教程里被一句predict()带过,但在钓鱼场景里,predict()默认的 0.5 阈值几乎一定是错的。
3.1 先立基线:逻辑回归比随机森林更适合当第一版
数据读进来第一件事是排序。如果你的样本表里有采集时间字段,按时间排完再切,别急着train_test_split。
# train/baseline.py import numpy as np, pandas as pd from scipy.sparse import hstack, csr_matrix from sklearn.linear_model import LogisticRegression from sklearn.metrics import average_precision_score, classification_report from features.url_feature import url_features from features.mail_feature import mail_text, header_features, build_text_matrix from features.order import to_vector df = pd.read_csv("data/labeled.csv") # 字段: ts, url, subject, body, label df = df.sort_values("ts").reset_index(drop=True) cut = int(len(df) * 0.8) train, test = df.iloc[:cut].copy(), df.iloc[cut:].copy() def numeric_block(frame, cols): rows = [to_vector({**url_features(r["url"]), **header_features(r)}) for _, r in frame.iterrows()] return csr_matrix(np.asarray(rows, dtype=np.float32)) def build(frame, txt_vec=None, fit=False): U = numeric_block(frame, None) texts = frame.apply(mail_text, axis=1).tolist() if fit: txt_vec, T = build_text_matrix(texts) else: T = txt_vec.transform(texts) return hstack([U, T]).tocsr(), txt_vec Xtr, txt_vec = build(train, fit=True) Xte, _ = build(test, txt_vec=txt_vec) ytr, yte = train["label"].values, test["label"].values clf = LogisticRegression(max_iter=3000, class_weight="balanced", C=1.0, solver="liblinear") clf.fit(Xtr, ytr) proba = clf.predict_proba(Xte)[:, 1] print("AP:", round(average_precision_score(yte, proba), 4)) print(classification_report(yte, (proba >= 0.5).astype(int), digits=3))class_weight="balanced"会自动按类别频率反比加权,1:9 的数据下相当于给正类乘 9 倍权重。这是最省事的处理不平衡手段,比上来就 SMOTE 稳妥——SMOTE 在稀疏高维文本特征上合成样本,很容易造出人类看不懂、模型却当真的伪特征,我在文本任务上基本不用它。
C=1.0是默认正则强度,样本量小的时候可以往下压到 0.1 抑制过拟合;solver="liblinear"在几万样本、两万维稀疏特征这个量级上比lbfgs收敛快得多,如果你数据量上了几十万,换saga更合适。
评估指标上,average_precision_score(PR 曲线下面积)比 ROC-AUC 更能反映真实情况。类别极不平衡时 ROC-AUC 会被大量的真负例撑得很好看,PR 曲线不会。这个包里如果只有 ROC-AUC 的评估代码,我会自己补一行 AP。
3.2 用 PR 曲线选阈值,而不是用 0.5
阈值是业务参数,不是模型参数。钓鱼场景里漏放和误杀的代价完全不对称:漏放一封可能意味着一次账号失陷,误杀一封只是让同事多看一眼。所以阈值必须显式配置,并且应该由业务约束反推出来。
# train/threshold.py import numpy as np from sklearn.metrics import precision_recall_curve prec, rec, thr = precision_recall_curve(yte, proba) f1 = 2 * prec * rec / np.clip(prec + rec, 1e-9, None) best = int(np.argmax(f1[:-1])) print(f"F1 最优阈值 {thr[best]:.3f} F1={f1[best]:.3f}") # 业务约束版:先把召回压到 0.98 以上(漏放 ≤2%),再在这个前提下最大化精确率 mask = rec[:-1] >= 0.98 if mask.any(): idx = int(np.argmax(np.where(mask, prec[:-1], -1.0))) print(f"业务阈值 {thr[idx]:.3f} precision={prec[idx]:.3f} recall={rec[idx]:.3f}")这里有个细节:precision_recall_curve返回的三个数组长度是 n+1、n+1、n,阈值数组比前两个少一个,所以索引要切[:-1],best直接拿去索引thr。这个 off-by-one 我第一次写的时候搞反了,阈值整体偏移了一格,白调了半天。
我一般会把最终阈值取在“业务约束阈值”和“F1 最优阈值”之间靠业务那一侧,然后把实际生效的数字写进模型的 bundle 里,而不是写死在服务代码中。写死在代码里,下次换模型忘了改,就会出现“模型是新的、阈值是旧的”这种最难查的组合故障。
3.3 切分方式决定指标可信度:按时间切,不按随机切
钓鱼样本有个天然特性:同一波活动生成的 URL 和正文高度相似,可能几百封邮件只是换了几个字符。随机切分会让同源样本同时出现在训练集和测试集,指标虚高五个点到十几个点都正常。
包里如果是随机切分,务必改成时间切分,或者至少按“活动批次/注册域名”做分组切分。
# train/split.py import pandas as pd from sklearn.model_selection import GroupShuffleSplit gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) idx_tr, idx_te = next(gss.split(df, groups=df["campaign_id"])) train, test = df.iloc[idx_tr], df.iloc[idx_te]groups用活动批次(或者在拿不到批次时用注册域名的主干)都可以,核心是保证同源样本不跨集合。改完这一行,指标通常会掉,但掉下来的才是真实水平——我宁可要 0.85 的可信数字,也不要 0.98 的自欺欺人。
4. 从 joblib 到接口:模型文件的封装、批量打分与回滚
模型训完只是半成品。这个包里给的是脚本级产物,落到能用还差三步:封装成自描述对象、提供批量与单条两个入口、留一条回滚路。
4.1 打包成一个自描述对象
不要只joblib.dump(model)。模型文件必须自带上下文:阈值、特征版本、训练时间、评估指标、依赖库版本。少任何一项,半年后接手的人都得重新训一遍。
# serve/pack.py import joblib, json, pathlib, sklearn bundle = { "model": clf, "text_vec": txt_vec, "threshold": float(thr[idx]), "feature_version": "url-v3_mail-v2", "trained_at": pd.Timestamp.now().isoformat(timespec="seconds"), "sklearn_version": sklearn.__version__, "metrics": {"ap": 0.93, "precision": 0.91, "recall": 0.98}, } out = pathlib.Path("artifacts/v3") out.mkdir(parents=True, exist_ok=True) joblib.dump(bundle, out / "model.joblib", compress=3) (out / "meta.json").write_text(json.dumps( {k: v for k, v in bundle.items() if k not in ("model", "text_vec")}, ensure_ascii=False, indent=2), encoding="utf-8")compress=3对稀疏文本模型压缩比很高,通常能把几百 MB 压到几十 MB,加载耗时增加有限。sklearn_version这个字段看着多余,实际能省一次通宵:加载时先比对版本,不一致就告警,比等着predict时抛AttributeError强太多。
4.2 批量打分脚本与单条接口
两个入口共用同一套特征函数,这是硬要求。批量脚本跑历史数据回放,单条接口给网关同步调用,两者结果必须一致。
# serve/score.py import joblib from scipy.sparse import hstack import numpy as np from features.url_feature import url_features from features.mail_feature import mail_text, header_features from features.order import to_vector class Scorer: def __init__(self, model_dir="artifacts/current"): self.b = joblib.load(f"{model_dir}/model.joblib") self.thr = self.b["threshold"] self.vec = self.b["text_vec"] def _row(self, item): feats = {**url_features(item.get("url", "")), **header_features(item)} num = np.asarray([to_vector(feats)], dtype=np.float32) txt = self.vec.transform([mail_text(item)]) return hstack([num, txt]).tocsr() def score(self, item): p = float(self.b["model"].predict_proba(self._row(item))[0, 1]) return {"score": round(p, 4), "label": int(p >= self.thr), "threshold": self.thr, "feature_version": self.b["feature_version"]}注意_row里url_features和header_features的合并顺序必须和训练时完全一致,to_vector靠FEATURE_ORDER保证列顺序,但两个 dict 的键不能重复——万一以后有人给 URL 特征加了个url_count,和邮件侧的字段撞名,合并时就后者覆盖前者,静默出错。所以我在to_vector之外还加了一条断言,训练前检查两个 dict 的键集合无交集。
接口层用 FastAPI 或 Flask 都行,薄薄一层即可,不要在接口里写业务逻辑。
# serve/app.py from fastapi import FastAPI from pydantic import BaseModel from serve.score import Scorer app = FastAPI() scorer = Scorer("artifacts/current") class Mail(BaseModel): url: str = "" subject: str = "" body: str = "" attachments: list = [] spf: str = "" dkim: str = "" @app.post("/score") def score(mail: Mail): return scorer.score(mail.model_dump())4.3 版本目录与回滚开关
artifacts/current做成软链接或者一个只含版本号的current.json指针文件,回滚就退化成改一行。
# 发布与回滚 ln -sfn artifacts/v3 artifacts/current # 发布 v3 ln -sfn artifacts/v2 artifacts/current # 回滚到 v2,进程重载即生效软链接方案简单,但要求服务进程能感知变化。同步服务里我一般配一个每 60 秒检查meta.json的 mtime 的后台任务,变了就重新joblib.load,加载失败保留旧对象——这条“加载失败不切换”的逻辑很重要,没有它,一次发布事故会直接把服务打死。
5. 避坑排查:五条把模型训废的血泪记录
这一章全是真实翻过的车,按现象、原因、解决的顺序写,你对着自己的日志能一一对上号。
5.1 现象:离线 0.96,上线只有 0.71
原因:训练集的 URL 是抓取时存的原始字符串,线上网关传过来的是已经解码、已经小写、已经剥掉默认端口的规范化结果。两边算出来的url_len、digit_ratio分布完全不同,模型看到的是另一套输入。
解决:所有入口强制走normalize_url(),并且落地前做一次“回放比对”——取线上 1000 条真实请求,分别用离线管线和线上管线算特征,逐列比均值、分位数。我现在的习惯是这一步不做完不给上线,比对脚本比模型本身还重要。
5.2 现象:交叉验证 F1 0.98,时间切分只剩 0.85
原因:同一次钓鱼活动的样本批量进了训练集和验证集。模型没学到规律,学到的是“这批模板”。
解决:改分组切分,groups用活动 ID 或注册域名主干;拿不到活动 ID 就用时间切分。改完指标会难看,但那是真的。
5.3 现象:换一批新样本,召回直接掉一半
原因:TF-IDF 是在全量数据上fit的,词表见过测试集的专有词,交叉验证指标被污染。或者min_df设成 1,词表里塞满了只出现一次的一次性 token。
解决:fit_transform只能在训练集上调用,测试集一律transform;或者干脆用Pipeline把向量化和分类器串在一起,让交叉验证自动处理折内拟合。min_df别低于 3。
5.4 现象:阈值 0.5 时误杀大量正常营销邮件
原因:predict()默认 0.5,而 1:9 的不平衡下模型输出的概率分布本身是偏的,0.5 这个位置对应的精确率可能只有 0.6。再叠加营销邮件里大量“立即”“限时”“退款”这类词,正好撞在钓鱼词的分布上。
解决:用 PR 曲线按业务约束选阈值,配进 bundle 而不是写死;把urgency_hit这类单点强特征拆开看,如果它单独就能把分数顶到 0.8,说明模型在走捷径,需要把这类特征的权重用正则压下去或者做分桶。
5.5 现象:换台机器加载模型直接报InconsistentVersionWarning或AttributeError
原因:训练环境 scikit-learn 是某个小版本,推理环境是另一个。pickle 序列化对类结构敏感,小版本之间的属性变动足以让反序列化失败,或者更糟——加载成功但行为不一致。
解决:requirements.txt里把 scikit-learn、scipy、numpy 全部钉死到小版本;bundle 里存sklearn_version,加载时先比对,不一致就拒绝加载并告警。镜像构建时把模型文件打进镜像比挂载共享存储更稳,至少版本和文件是绑定的。
6. 双通道进阶:规则兜底 + 模型打分
单模型直接拦截,在真实邮件流里通常撑不过一个月。我后来固定采用“规则 + 模型”双通道:规则处理确定性强、代价低的判断,模型处理灰区。
6.1 通道分工与边界
| 通道 | 输入 | 判定方式 | 适用场景 | 误杀代价 |
|---|---|---|---|---|
| 硬规则 | 已知恶意域名、IP 直连主机加登录表单、可执行附件 | 命中即拦 | 高频复现的批量活动 | 极低 |
| 模型 | URL 特征 + 正文 TF-IDF + 头部字段 | 分数 ≥ 阈值进入隔离区 | 灰区、变种、新模板 | 中 |
| 人工复核 | 模型分数在阈值附近的样本 | 抽样复核 | 阈值校准与回流 | 无 |
关键在边界:规则只放“几乎不可能误判”的条目,比如内部维护的恶意域名清单、主机名为 IP 且正文包含表单提交特征。不要把“含‘退款’就拦”这种写进规则,那一刀下去误杀率能到两位数,而且这类样本进了训练集会进一步把模型带偏。
反过来,模型不该处理规则已经覆盖的样本——把它们从训练集里剔掉,或者至少降权,否则模型会花大量容量去拟合那些根本轮不到它判断的样本。
6.2 影子模式与漂移监控
新模型上线前,我强制跑两周影子模式:只打分、不拦截,把分数写进日志,和现网规则的结果做交叉对比。重点看三件事:新模型判为钓鱼而现网放过的样本(潜在增益)、新模型放过而现网拦住的(潜在漏放)、两边分数分布的整体偏移。
漂移监控不用上重型工具,按周算一次特征均值的变化率就够用。url_len的周均值突然涨了 30%,或者subdomain_depth的分布整体右移,基本意味着攻击手法换了模板,这时候别急着调阈值,先抽样看样本。
人工复核队列按分数分层抽样,每天固定抽五十条,比分段统计精确率。这一步是阈值校准的唯一可靠来源——离线算出来的阈值只能当起点,真实阈值是在复核队列上迭代出来的。
我吃过一次亏:模型上线三个月没做回流,攻击方把 URL 从超长随机串换成了短域名加干净路径,url_len这一维直接失效,召回从 0.95 掉到 0.6,而监控面板上只有一个“模型调用成功率”,什么都看不出来。从那以后我每次上线新模型,都强制先跑两周影子模式加一份特征分布基线,基线对不上就不发。希望帮到你。
本文还有配套的精品资源,点击获取