资讯动态

钓鱼邮件识别模型流水线:特征工程、LightGBM训练与实时推理落地

发布时间:2026/10/29 18:13:43 来源:尧图企业网站定制
简介面向网络安全初学者、机器学习实践者及课程设计人群这份资源围绕钓鱼网站与钓鱼邮件的识别建模提供从数据处理到模型训练、评估与优化的完整项目素材。压缩包共14个文件约339KB主要由10个Python脚本和4个CSV数据表构成脚本覆盖文本预处理、词向量工具、SimpleNN、TextCNN、LSTM-CNN与CNN-LSTM等模型训练流程数据表承载钓鱼与正常样本及特征。页面显示已有244人学习适合课程实验、毕业设计或安全检测原型搭建时参考。读者可据此梳理URL与邮件文本特征提取、TF-IDF及词嵌入编码、交叉验证与召回率/F1/AUC评估等环节并理解朴素贝叶斯、支持向量机等经典算法与深度模型的取舍对照不同网络结构的实现差异形成可复现的实验基线也能为特征工程、样本不平衡处理与超参数调优提供排错和扩展思路。1. 钓鱼识别模型落地一封邮件进来模型先看哪几眼一封标题写着「薪资调整通知」的邮件落到同事收件箱链接域名是payroll-hr.examplebank.co肉眼几乎看不出问题点进去却是个仿冒登录页。钓鱼网站识别靠正则加黑名单顶不了太久攻击者换域名的速度往往快过你更新规则的速度。这份资源给的是一条完整的机器学习模型流水线从 URL 词法结构、邮件头认证字段里抽特征训练二分类器再把模型封装成能实时返回风险分的接口。它适合想跑通一个真实不平衡分类项目的开发者也适合安全方向的同学拿它当基线往上叠自己的特征和规则。2. 特征工程拆解URL 词法、邮件头字段与 TF-IDF 怎么拼成一张特征表整个项目是「离线训练 在线打分」两段式结构中间靠一个joblib产物解耦。训练侧不关心服务怎么部署服务侧也不关心模型怎么训出来的唯一要保证的是两边算出同一份特征。常见做法是把所有特征抽取函数写成纯函数输入原始字符串、输出 dict不依赖全局状态这样离线批量跑和线上单条跑才是同一份逻辑。2.1 目录结构与数据流先把目录摆清楚后面每一段代码放到哪个位置就一目了然phish-detect/ ├── data/ │ ├── raw/ │ │ ├── mails/ # 原始 .eml一封邮件一个文件 │ │ └── urls.csv # url,label,first_seen 三列 │ ├── interim/ # 解析后的中间表parquet 格式 │ └── processed/ │ ├── train.parquet # 按 first_seen 时间切分的训练集 │ └── test.parquet # 时间上更靠后的验证集 ├── src/ │ ├── features/ │ │ ├── url_feats.py # URL 词法特征纯字符串运算 │ │ ├── mail_feats.py # 邮件头、认证结果、正文特征 │ │ └── build_dataset.py # 两侧特征拼成宽表 │ ├── train.py # 训练入口产出 model.joblib │ ├── evaluate.py # 阈值搜索与指标报告 │ └── serve.py # FastAPI 推理服务 ├── artifacts/ │ ├── model.joblib # pipeline 阈值 特征版本 │ └── metrics.json └── requirements.txtdata/raw是只读的任何时候都不改data/interim和processed全部可以重建删了重跑就行这点在调特征的时候特别重要——你会发现一天里要重跑十几次。artifacts是唯一需要进版本管理的产物目录模型、阈值、特征版本号打包在一起回滚的时候整体替换不要只换模型文件。数据流一共三段解析层把.eml和urls.csv变成结构化行特征层把行变成数值列和文本列模型层把列变成分数。解析层是最容易被低估的一段编码混乱、多段 MIME、内嵌图片都会在这里出问题后面避坑章节会专门说。2.2 URL 词法特征不联网也能算出十几维很多教程一上来就查 WHOIS、查 DNS、查 Alexa 排名我一般不建议把这类特征放在第一版里。它们慢、会失败、有速率限制而且域名注册时间是强时间相关特征训练集里老域名正常的规律换个时间窗口就失效了。第一版先用纯字符串特征把基线跑出来import math import re from urllib.parse import urlparse SUSPICIOUS_TLDS {top, xyz, click, work, loan, zip, mov} SHORTENERS {bit.ly, t.cn, goo.gl, ow.ly} BRANDS (examplebank, exampleshop, examplepay) def shannon_entropy(s: str) - float: if not s: return 0.0 freq {} for ch in s: freq[ch] freq.get(ch, 0) 1 n len(s) return -sum((c / n) * math.log2(c / n) for c in freq.values()) def url_features(url: str) - dict: 纯字符串运算不发任何网络请求单条耗时在微秒级 if not url.startswith((http://, https://)): url http:// url # 补全 scheme否则 urlparse 会把 host 解析成 path p urlparse(url) host (p.hostname or ).lower() path p.path or query p.query or labels host.split(.) if host else [] tld labels[-1] if labels else return { url_len: len(url), host_len: len(host), path_len: len(path), dot_count: url.count(.), hyphen_count: host.count(-), digit_ratio: sum(c.isdigit() for c in host) / max(len(host), 1), has_at: int( in url), is_ip_host: int(bool(re.fullmatch(r\d{1,3}(\.\d{1,3}){3}, host))), subdomain_depth: max(len(labels) - 2, 0), tld_suspicious: int(tld in SUSPICIOUS_TLDS), is_shortener: int(host in SHORTENERS), query_param_count: len([q for q in query.split() if q]), path_depth: len([s for s in path.split(/) if s]), host_entropy: shannon_entropy(host), brand_in_subdomain: int( len(labels) 2 and any(b in labels[:-2] for b in BRANDS) ), }这十几维可以分成四组看。长度类是url_len、host_len、path_len钓鱼链接常靠超长路径塞参数、或者用极短域名规避检测两头都有信号。字符分布类是digit_ratio和host_entropy随机生成的域名熵值明显偏高paypa1-bank-secure.top这种串一眼就能被熵值抓到。结构类是dot_count、subdomain_depth、path_depth、query_param_countlogin.examplebank.com.verify-now.xyz这种把品牌名塞进子域名的做法靠brand_in_subdomain直接命中。语义近似类是has_at、is_ip_host、tld_suspicious用 IP 直连、URL 里带做混淆、挂在廉价 TLD 上都是老套路但至今有效。BRANDS这个列表我一般从自己业务侧的品牌名生成不要硬编码一堆公开大牌否则模型在训练集上学到的是包含某个特定词就危险泛化会很差。shannon_entropy里对每个字符统计频率再算信息熵注意分母用len(s)而不是去重后的长度否则短域名的熵会被系统性高估。2.3 邮件头与正文特征SPF、DKIM 和那个骗人的显示名URL 只能看到一半信息另一半在邮件本身。发件人显示名和真实地址不一致、Reply-To和From域名不同、认证结果缺 SPF 或 DKIM这几条加起来就是很强的信号import re from email import policy from email.parser import BytesParser URGENT_WORDS (urgent, verify, suspend, password, invoice, immediately) def mail_features(raw: bytes) - dict: msg BytesParser(policypolicy.default).parsebytes(raw) frm str(msg.get(From, )) reply_to str(msg.get(Reply-To, )) auth str(msg.get(Authentication-Results, )).lower() subject str(msg.get(Subject, )) # 只取第一个 text/html part附件一律不读避免内存被打爆 html for part in msg.walk(): if part.get_content_type() text/html: try: html part.get_content() except Exception: html break from_domain frm.split()[-1].strip( ).lower() reply_domain reply_to.split()[-1].strip( ).lower() if reply_to else body html.lower() return { subject_len: len(subject), subject_upper_ratio: sum(c.isupper() for c in subject) / max(len(subject), 1), has_reply_to: int(bool(reply_to)), reply_domain_mismatch: int(bool(reply_domain) and reply_domain ! from_domain), spf_pass: int(spfpass in auth), dkim_pass: int(dkimpass in auth), dmarc_pass: int(dmarcpass in auth), has_html: int(bool(html)), form_count: len(re.findall(rform\b, body)), iframe_count: len(re.findall(riframe\b, body)), js_redirect: int(window.location in body), urgent_word_hits: sum(w in (subject body) for w in URGENT_WORDS), anchor_count: len(re.findall(ra\b, body)), text_len: len(body), }几个参数值得单独说。reply_domain_mismatch是这组特征里性价比最高的一个攻击者伪造From容易但很少有人把Reply-To也配成同一个域因为他的目的是把回复引到自己的邮箱。spf_pass、dkim_pass、dmarc_pass从Authentication-Results头里用字符串匹配拿这里要取最后一跳的认证结果中间转发节点会追加自己的记录直接in判断可能被前面的失败记录误导。form_count和iframe_count用正则扫 HTML比引入 BeautifulSoup 快一个数量级代价是对畸形 HTML 不鲁棒——但畸形 HTML 本身也是特征所以问题不大。urgent_word_hits的词典不要写太长超过 30 个词之后增益基本消失反而把text_len这类特征的重要性压下去。2.4 特征拼接与向量化稀疏块和稠密块怎么合并数值列直接标准化域名和标题走 TF-IDF用ColumnTransformer三路并行。域名这一路我强烈建议用char_wb字符级 n-gram因为攻击者最常用的混淆手段就是字符替换paypa1和paypal在词级分词下是两个完全无关的 token在 3~5 字符窗口下却能共享大量 n-gramfrom sklearn.compose import ColumnTransformer from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.impute import SimpleImputer from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler DENSE_COLS [ url_len, host_len, path_len, dot_count, hyphen_count, digit_ratio, has_at, is_ip_host, subdomain_depth, tld_suspicious, is_shortener, query_param_count, path_depth, host_entropy, brand_in_subdomain, subject_len, subject_upper_ratio, has_reply_to, reply_domain_mismatch, spf_pass, dkim_pass, dmarc_pass, has_html, form_count, iframe_count, js_redirect, urgent_word_hits, anchor_count, text_len, ] pre ColumnTransformer( transformers[ (num, Pipeline([ (imp, SimpleImputer(strategymedian)), # 缺失填中位数比填 0 稳 (sc, StandardScaler()), ]), DENSE_COLS), (host_tfidf, TfidfVectorizer( analyzerchar_wb, ngram_range(3, 5), min_df5, max_features20000, sublinear_tfTrue, ), host), (subj_tfidf, TfidfVectorizer( analyzerword, ngram_range(1, 2), min_df3, max_features5000, sublinear_tfTrue, ), subject), ], remainderdrop, sparse_threshold0.3, # 输出稀疏矩阵内存占用差好几倍 )sparse_threshold0.3这个参数很多人不设结果 20 万维的 TF-IDF 被拼成稠密矩阵内存直接翻十倍float32都能吃掉几个 G。sublinear_tfTrue对长文本很重要邮件正文里某个词出现 20 次和出现 1 次不应该有 20 倍权重差。最后强调一句pre这个ColumnTransformer必须放进最终的Pipeline里不能提前fit_transform好再喂给模型。提前转换的话训练集的词表和均值方差会被带到测试集这是最隐蔽的一种泄漏离线指标会好看得离谱。3. 模型训练与评估LightGBM 在不平衡样本上的调参与阈值选择特征表有了训练本身反而不复杂。这个任务的样本量通常在十万到百万级特征维度几万但高度稀疏梯度提升树是稳妥选择训练快、对缺失值天然友好、特征重要性直接能看。真正的难点在两个地方怎么切数据集以及阈值定在哪。3.1 训练脚本骨架从 parquet 到可复现的 pipelineimport joblib import numpy as np import pandas as pd from lightgbm import LGBMClassifier from sklearn.model_selection import GroupShuffleSplit from sklearn.pipeline import Pipeline FEATURE_COLS DENSE_COLS [host, subject] # 文本列也交给 pre 处理 df pd.read_parquet(data/processed/all.parquet) # 关键按可注册域名分组切分同一域名的样本只落在一侧 # 否则同一个钓鱼站的不同路径会同时出现在训练集和测试集指标虚高 gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) tr_idx, te_idx next(gss.split(df, groupsdf[registrable_domain])) tr, te df.iloc[tr_idx], df.iloc[te_idx] clf LGBMClassifier( n_estimators800, learning_rate0.05, num_leaves63, min_child_samples30, # 样本少的叶子不分裂抑制过拟合 subsample0.8, subsample_freq1, colsample_bytree0.8, reg_lambda1.0, n_jobs-1, random_state42, ) pipe Pipeline([(pre, pre), (clf, clf)]) pipe.fit(tr[FEATURE_COLS], tr[label])GroupShuffleSplit的groups传的是「可注册域名」也就是去掉子域之后的examplebank.co这一级不要传完整 host。同一个钓鱼站常挂几十个不同子域名如果按 host 分组a.evil.top在训练集、b.evil.top在测试集模型只要记住evil.top就能全对这属于近重复泄漏。线上是流式的所以更严格的做法是按first_seen做时间切分用前 8 个月训练后 2 个月验证。两种切法我都建议跑一遍分组切分给的是「同分布下的泛化能力」时间切分给的是「未来数据上的衰减速度」后者更接近真实上线表现。如果两者差距超过 10 个点说明特征里有强时间依赖得回头查。3.2 类别不平衡别急着上 SMOTE公开数据集的钓鱼/正常比例往往被整理成 1:1 甚至更平衡线上真实比例可能是 1:500 到 1:2000。直接拿平衡数据训练模型学到的先验是「一半邮件是钓鱼」输出概率天然偏高阈值 0.5 完全不能用。我一般按这个顺序处理先在训练集上保留原始分布用scale_pos_weight把正类权重抬起来如果正类样本绝对数量太少低于 5000再考虑对负类做下采样但下采样后必须重新校准概率。SMOTE 这类插值方法我基本不用——特征里混着几万维的稀疏 TF-IDF 和十几个稠密数值列在欧氏空间里插值出来的样本没有物理意义生成一堆「熵值 4.2 但 TLD 是 .com 且 SPF 通过」的四不像反而把决策边界搅乱。scale_pos_weight的经验取值是负正样本比的平方根比如 1:200 就取 14 左右而不是直接填 200。填满比例会让模型过度偏向正类精确率掉得厉害。3.3 评估与阈值PR 曲线比 ROC 诚实得多不平衡场景下 ROC-AUC 会骗人负类太多假阳性率稍微涨一点点看不出来但业务上就是每天几千封正常邮件被拦。看 PR 曲线和平均精确率AP才有意义from sklearn.metrics import average_precision_score, precision_recall_curve proba pipe.predict_proba(te[FEATURE_COLS])[:, 1] ap average_precision_score(te[label], proba) prec, rec, thr precision_recall_curve(te[label], proba) beta 2.0 # beta2 表示召回的重要度是精确率的 4 倍 f_beta (1 beta**2) * prec * rec / (beta**2 * prec rec 1e-9) best_i int(np.nanargmax(f_beta[:-1])) # 注意 f_beta 比 thr 多一个元素 best_thr float(thr[best_i]) print(fAP{ap:.4f} threshold{best_thr:.3f} fP{prec[best_i]:.3f} R{rec[best_i]:.3f})beta取值完全看业务。钓鱼告警是给安全运营人工复核的漏一个的代价远大于误报一个所以取 2如果模型是直接自动拦截、没有人工兜底就得取 0.5宁可漏也别误杀。这个参数不要拍脑袋跟下游使用方式对齐。precision_recall_curve返回的prec和rec长度是len(thr) 1最后一个点是阈值为 0 的端点做argmax时一定要切掉[:-1]否则索引错位选出来的阈值会偏到奇怪的位置。这个坑我踩过不止一次。实际调完之后我会把几个候选阈值都拉出来对比一遍阈值精确率召回率日均告警量示例适用场景0.300.820.94约 1200只做打标人工全量复核0.550.930.88约 620默认档进告警队列0.800.980.71约 210直接阻断表里的数值是示例真实数据上你自己跑出来是什么就是什么。这张表比任何单个指标都好用因为它直接对应运营工作量。3.4 模型保存与版本别只 dump 一个模型对象artifact { pipeline: pipe, threshold: best_thr, feature_version: 2024-11-01, # 特征定义变更时手动递增 dense_cols: DENSE_COLS, # 线上要按这个顺序组装 DataFrame metrics: {ap: float(ap), precision: float(prec[best_i]), recall: float(rec[best_i])}, } joblib.dump(artifact, artifacts/model.joblib, compress3)只存pipe是够用的但线上排查问题时你会想知道「这个分数是哪个版本的模型、哪个版本的特征算出来的」。feature_version和模型绑在一起存出现线上分数和离线复现对不上时第一件事就是比对两边版本号能省掉大半天。dense_cols也一起存服务侧按这个列表组装列顺序避免因为列顺序错位导致分数全乱——这种 bug 不会有异常抛出只会表现成「模型效果莫名其妙变差」。4. 推理服务工程化把训练好的模型包成低延迟打分接口模型训练完只是半成品。真正决定这套东西能不能用起来的是接口的延迟和稳定性——邮件网关调用你的打分服务超时就是放行放行就意味着漏判。4.1 服务骨架两个接口就够import joblib import pandas as pd from fastapi import FastAPI from pydantic import BaseModel ART joblib.load(artifacts/model.joblib) PIPE ART[pipeline] COLS ART[dense_cols] app FastAPI(titlephish-score) class ScoreIn(BaseModel): url: str subject: str host: str mail_feats: dict {} # 由上游 MTA 预解析好服务端不碰原始 .eml app.post(/v1/score) def score(item: ScoreIn): row {c: 0.0 for c in COLS} row.update(url_features(item.url)) row.update(item.mail_feats) row[host] item.host or row[subject] item.subject df pd.DataFrame([row])[COLS [host, subject]] p float(PIPE.predict_proba(df)[0, 1]) return { score: round(p, 4), decision: block if p ART[threshold] else allow, model_version: ART[feature_version], }核心设计是服务端不解原始邮件。.eml解析放到上游 MTA 的钩子里做服务只接收已经结构化的特征 dict。这样单条请求的耗时稳定在个位数毫秒而且解析异常不会把打分服务打挂——邮件解析器面对畸形输入崩掉是常事。组装 DataFrame 时列顺序必须严格按COLS [host, subject]这也是训练时的顺序。用 dict 直接pd.DataFrame([row])会因为 key 顺序不确定导致列错位虽然 sklearn 现在有特征名校验会报错但报错发生在线上就是一次故障。4.2 特征一致性训练和线上必须是同一个函数这是整个项目里最容易翻车的地方业内叫 training-serving skew。典型表现是离线 AP 0.96上线跑一周发现实际拦截率只有六成查半天发现线上版本的digit_ratio分母用的是 URL 总长度而不是 host 长度。工程上的解法只有一条特征抽取代码只有一份训练脚本和服务都 import 它。不要为了「服务端性能」复制粘贴一份简化版复制那天就是分叉的开始。再加一层保险在 CI 里跑一个 golden test固定若干条样本分别走离线批量路径和服务路径比对分数差# tests/test_consistency.py import json import numpy as np def test_offline_online_parity(client): cases json.load(open(tests/golden_cases.json)) for c in cases: online client.post(/v1/score, jsonc[payload]).json()[score] offline c[expected_score] # 允许 1e-6 的浮点误差超过就是特征逻辑分叉了 assert abs(online - offline) 1e-6, fcase {c[id]} drift: {online} vs {offline}golden_cases.json里存十几条覆盖各种边界的样本空 URL、纯 IP 主机、超长 query、缺Authentication-Results头、非 ASCII 域名。每次改特征逻辑先更新离线期望值再跑这个测试两步都过才允许合并。4.3 外部查询的缓存与超时情报类特征要有边界纯词法特征之外你迟早会想接域名注册时长、历史解析记录这类外部情报。接可以但必须加两道闸TTL 缓存和硬超时。import requests from cachetools import TTLCache _intel_cache TTLCache(maxsize20000, ttl3600) def domain_age_days(host: str, timeout: float 0.8) - float: 查不到就返回 -1让模型按缺失值处理绝不抛异常出去 if host in _intel_cache: return _intel_cache[host] try: r requests.get(http://intel-svc.internal/api/domain, params{h: host}, timeouttimeout) val float(r.json().get(age_days, -1)) except Exception: val -1.0 _intel_cache[host] val return valttl3600的意思是同一域名一小时内只查一次。邮件场景里同一个发件域往往在短时间内批量到达缓存命中率能到 90% 以上这一个参数就能把外部依赖的 QPS 压下来一个数量级。timeout0.8是硬上限且必须是总超时而不是连接超时。上游网关给的整体预算是 100ms 到 2s 不等情报查询吃掉 800ms 已经是极限超过就返回-1降级。返回-1而不是None是因为 LightGBM 对数值列的缺失有原生处理但你得保证离线训练时也把查不到的情况编码成-1两边对齐。4.4 批量离线打分回捞和复盘都靠它线上服务之外还得有个离线批量入口用途有两个一是每天对历史邮件重跑一遍回捞之前漏掉的二是模型迭代时对全量数据打分人工抽查新模型判错的样本。import pandas as pd def batch_score(in_path: str, out_path: str, chunk: int 50000): reader pd.read_parquet(in_path, chunksizechunk) first True for part in reader: proba PIPE.predict_proba(part[COLS [host, subject]])[:, 1] part part[[mail_id, first_seen]].copy() part[score] proba part[decision] (proba ART[threshold]).astype(int) part.to_parquet(out_path, appendTrue, indexFalse) first Falsechunksize设 5 万是内存和吞吐的折中百万级数据大概两三分钟跑完。输出里保留mail_id否则回捞出来的结果没法关联回原始邮件等于白跑。5. 避坑与排查样本泄漏、特征漂移和解析超时这几笔血泪经验5.1 离线指标 0.98上线召回只有三成现象验证集 AP 0.98精确率召回率都漂亮上线一周实际召回不到 0.35人工抽查发现大量漏判。原因特征里有一个domain_in_blacklist而数据集的标签正是用这份黑名单打出来的。模型学到的是「在黑名单里钓鱼」本质是把标签抄了一遍。更隐蔽的是黑名单还会随时间更新上线后新出现的钓鱼域名根本不在名单里。解决把一切由标签派生出来的特征全部删掉包括黑名单命中、历史告警次数、人工标注状态。判断标准很简单——这个特征在样本产生的那一刻是否已经存在还是事后补上去的。事后补的一律不能进模型。删完重训AP 掉到 0.86但这才是真实水平。5.2 单条推理 300msP99 冲到 2 秒现象压测时 P50 还好P99 超过 2 秒上游网关大量超时放行。原因服务端直接接收原始.eml每次请求都完整解析一遍 MIME 树包括内嵌图片和附件同时外部情报查询没设超时某个慢响应就把整个请求拖住。解决解析上移到 MTA 钩子服务只收结构化特征情报查询加TTLCache和 0.8 秒硬超时超时返回-1再加一个预热逻辑服务启动时先用空样本跑一次predict_proba把 LightGBM 的懒加载开销提前吃掉。改完 P99 降到 40ms 以内。5.3 某周误报量突然翻五倍现象模型没改代码没动周一早上开始正常邮件被大量判为钓鱼集中在某几个新上线的营销域名。原因典型特征漂移。这批营销域名是新注册的host_entropy偏高、tld_suspicious命中.top、subdomain_depth也深几个特征组合起来正好落在钓鱼区间。训练集里没有同类样本模型没见过这种「合法但长相可疑」的域名。解决短期加业务白名单兜底让运营能一键放行整个域中期在训练集里补充同类负样本并按周重训长期加特征分布监控对host_entropy、tld_suspicious这类关键特征做 PSI 计算超过阈值报警。没有监控的话这类问题只能靠用户投诉发现。5.4 训练集 1:1线上 1:2000阈值完全不能用现象拿公开数据集训出来的模型在平衡测试集上 F1 0.95接到真实流量上几乎把所有邮件都标成钓鱼。原因训练分布和线上分布的先验差了两个数量级。模型的输出概率是带先验的先验变了概率整体平移。解决训练集保留原始分布靠scale_pos_weight调权重阈值不在平衡测试集上搜而是在按真实比例采样的验证集上搜上线后每周用实际流量回流的一小批标注数据重新校准阈值模型权重先不动。5.5 解析器偶发崩溃进程直接被 OOM 杀掉现象服务跑几天挂一次日志里是内存溢出重启就好。原因个别邮件带了几十兆的 base64 内嵌附件msg.walk()会把所有 part 都展开加上有些邮件编码声明和实际内容不符get_content()抛UnicodeDecodeError直接冒到接口层。解决解析前先判断原始大小超过 10MB 直接拒绝walk()里只处理text/plain和text/html遇到multipart/*之外的附件类型立刻continue所有get_content()包在 try 里解码失败就当空正文处理。这三条加上去之后连续跑了三个月没再挂过。6. 二次开发把二分类输出改成带解释的风控评分拿到这套代码之后最有价值的改造方向不是换更强的模型而是把「是/否」变成「为什么」。安全运营看到 0.87 这个数字是没法决策的但看到「子域名里嵌了品牌名 域名注册不满 7 天 SPF 未通过」就能立刻判断。树模型做这件事成本很低用 SHAP 的TreeExplainer在推理时算单样本贡献即可import numpy as np from shap import TreeExplainer # 全局初始化一次TreeExplainer 对 LightGBM 是零拷贝的 _explainer TreeExplainer(PIPE.named_steps[clf]) def explain_row(df, top_k: int 5): 返回对正类贡献最大的几个特征用于告警详情展示 X PIPE.named_steps[pre].transform(df) sv _explainer.shap_values(X) sv sv[1] if isinstance(sv, list) else sv # 二分类取正类 names PIPE.named_steps[pre].get_feature_names_out() row sv[0] idx np.argsort(row)[::-1][:top_k] return [ {feature: str(names[i]).split(__)[-1], contribution: round(float(row[i]), 4)} for i in idx if row[i] 0 ]get_feature_names_out()返回的名字带前缀num__url_len、host_tfidf__pay展示前切掉前缀更友好。TF-IDF 那几路的特征名是 n-gram 片段直接给运营看会有点怪我一般把host_tfidf__和subj_tfidf__开头的合并成一条「文本相似度贡献」只保留数值列的具体名字。几个可以并行的改造方向按投入产出排改造点改动位置验证方式输出风险分 0-100 分级serve.py增加分数映射表与运营处置策略对齐抽查 200 条SHAP 可解释输出新增explain.py人工核对 top-3 特征是否合理接入域名情报特征新增features/intel_feats.py监控特征覆盖率与延迟主动学习闭环新增反馈表 周度抽样观察新模型 AP 是否持续上升规则兜底层serve.py前置判断用历史误判样本回归分数映射这一步别看简单实际很关键。原始概率不是线性的0.5 到 0.6 之间的业务含义和 0.9 到 0.95 完全不同直接乘 100 会误导人。我的做法是按分位数映射把验证集上所有样本的概率排序取 90 分位映射到 60 分、99 分位映射到 85 分、99.9 分位映射到 95 分中间线性插值。这样分数天然带上了「超过了多少比例的流量」这层含义运营理解起来快很多。规则兜底层也值得加。模型再准也有失效的时候白名单域名、内部发件域、已知合作方这几类直接短路返回根本不用进模型。代价是几行 if收益是每次模型抖动的时候你都有个缓冲区。改造完之后一定要做的一件事是建立回归集把历史上所有被人工确认为误判的样本存下来每次改完模型和规则都跑一遍看这批样本的判定有没有回退。这个集合会越攒越大半年之后就是最值钱的一笔资产。从那以后我每接一个二分类项目都强制先把时间切分、阈值搜索和特征版本号写进训练脚本再谈模型选型——模型可以换这三样缺一个后面全是返工。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑