FDE进化36记-第10记-模型为什么必须可解释、可验证、可监控?
模型给出分数,不等于给出证据。
text
[读者] 正在把风险模型接进 Agent 的 FDE 与算法工程师
[痛点] 分数上了线,解释给不出,漂移看不见,阈值不敢动
[现在读] 判定要进 Agent 证据链,可解释与可监控不再是加分项
[读完] 能交付带 OOT 门禁、漂移告警、SHAP 归因与证据包的可控模型链路
text
[旧方案] 离线训练一次,报个 AUC 就上线,阈值写死在代码里
|
v
[新需求] 判定要进 Agent 证据链,要能解释、能追溯、能回滚
|
v
[冲突] 单一分数没有语义,解释与模型不在同一版本,漂移无人看守
|
v
[后果] 坐席不信、申诉无据、审计不过、模型静默失效
我是老李,做 FDE。前几记我们把全球智能客服的业务链拆开了,把边界划清了,把专家经验萃取成了机器能力,也把规则、小模型、RAG、LLM 的分工定下来。现在这条链上已经跑着不止一个模型:意图路由、投诉风险、坐席优先派单,它们每一个都在对真实客户做判断。
麻烦的地方在于,客服场景的判断从来不是内部指标问题。一个会话被打了 0.87 的高投诉风险,坐席被优先派过去;一个客户被判定要优先转人工。这两个动作一旦发生,就一定会有人问:凭什么?以前这个问题可以糊过去,因为答案只到算法同学那里就终止了。
现在不行了。下游 Agent 要把这个判定当成证据链的一环去引用------它要在给人工的解释里写清楚、要在工单里留痕、要在申诉时被翻出来复核。分数不再是终点了,它变成了一份要被别人引用的证词。
那么,一个分数要满足什么条件,才配被下游引用?
SHAP 归因到底在解释模型,还是在解释业务?
当模型每周都在换版本,我们靠什么保证昨天和今天的判定还能对齐?
01、故事
现场是全球智能客服平台:8 个企业租户,4 条渠道(在线聊天、邮件、电话、社媒),11 种语言,后端接着 CRM、订单、支付、物流、工单和知识库。
投诉风险模型 V1 是两年前训的。每天凌晨跑批,把过去 24 小时的会话特征拼起来,输出一个 0 到 1 的风险分。大于 0.8 的,第二天早上会出现在资深坐席的优先队列最前面,同时给客户打上 "priority" 标记。
任务是:在会话结束后的 T+1 批处理窗口内,给每个会话产出可解释、可追溯、可回滚的投诉风险判定,并把判定依据交给下游 Agent 作为证据链的一环。
V1 交付得很快。离线 AUC 0.86,PR-AUC 也不错,上线报告写了三页。然后就没有然后了------没有漂移监控,没有阈值评审,没有归因输出,模型文件在对象存储里躺了两年没动过。
变化出现在第三个月。某个企业租户的投诉量涨了四成,客服总监在会上直接问:你们的模型不是说能预警吗,为什么一个都没报出来?
我们拉出那段时间的分数分布,发现它几乎没动。模型没坏,它只是不再指向真相了。
分数落下去
疑问浮上来
证据无处寻
争议无人断
02、问题
旧方案失效得很安静。它没有报错,没有超时,没有任何一个监控大盘变红,因为根本就没有大盘。
业务上的影响是三条同时爆发。第一,坐席不信。他们发现优先队列里经常出现明显不该优先的会话,而真正要炸的会话躺在普通队列里。第二,申诉无据。客户投诉被判定为 "高风险" 后反过来质询,我们只能回答 "模型算出来的"。第三,审计不过。合规要查这条判定的依据、依据来自哪一版模型、那一版模型当时的数据分布是什么,我们一条都答不上来。
技术上的表现同样清晰:
- 训练集和验证集来自同一次抽样,分布同源,指标虚高;线上是未来时间窗,租户和语种还在不断增加,没有一个 OOT 门禁。
- 特征没有版本,漂移没有指标,没有阈值,没有告警。
- 阈值 0.8 是拍出来的,写死在批处理脚本里,改一次要发一次版。
- 模型变更没有登记:谁改的、改了什么、旧版能不能回滚,全靠聊天记录。
- 解释能力为零。SHAP 只在算法同学的本机跑过一次,图截了发群里,既不可复现也没进链路。
我把这一篇的完成标准写成了五条可验证的话,后面所有章节都在往这五条上收:
- 每次模型发布必须过三段时间切分:Train、Validation、OOT,OOT 以未来时间窗加未见租户为准,不达标不发布。
- 每个入模特征有漂移指标与阈值,超阈值告警,严重时阻断发布。
- 每条高风险判定产出结构化归因(Top-K 特征、取值、贡献值、方向),可复现。
- 归因作为 Agent 证据链中的一个 evidence item,带模型版本、解释器版本、特征快照 ID。
- 阈值变更走配置化加审批,可回滚、可对比。
旧账算不清
新账不敢记
门前无凭据
事后难复盘
03、原理
要理解这一篇,得先把 SHAP 到底是什么说清楚。
SHAP 来自 Lundberg 和 Lee 在 2017 年 NeurIPS 上提出的统一解释框架(A Unified Approach to Interpreting Model Predictions),它把博弈论里的 Shapley 值搬到模型上:把一次预测值,公平地分摊到各个输入特征头上。它满足三条性质------局部准确性(各特征贡献之和等于该样本预测值与基线期望之差)、缺失性(没参与的特征贡献为零)、一致性(模型变化使某特征边际贡献变大时,其 SHAP 值不会变小)。官方实现在 shap/shap 仓库,TreeExplainer 针对树模型有精确且高效的多项式时间算法。
反直觉判断:SHAP 解释的是模型行为,不是业务因果。
它回答的是 "这个模型为什么在这条样本上给出这个分",而不是 "这个会话为什么真的高风险"。这个区分非常关键。当有人拿着一张 SHAP 瀑布图说 "因为退款金额高所以客户要投诉",他其实只证明了模型学到了这个相关性。业务因果得靠实验、靠领域知识、靠人去确认。把模型解释当因果证据,是这一篇里最贵的一个坑。
反直觉判断:解释分布会先于分数分布发生变化。
直觉上我们觉得漂移是分数先动。但在一段真实的时间里,特征分布一动,模型的输出可能因为特征之间的补偿效应而暂时稳住------A 特征涨了,B 特征跌了,分数看着没变。可 SHAP 归因是逐特征拆开的,它会立刻显出结构变化:原本排名第一的驱动特征掉到第五,原本不重要的语种标记冲进前三。所以归因分布是一个比分数更灵敏的漂移探针。这一点直接决定了第 04 章为什么要把归因和漂移放在同一条链上。
再补两个本篇必须用到的概念。
一是数据漂移与概念漂移的区别。数据漂移是 P(X) 变了,特征分布动了;概念漂移是 P(y|X) 变了,同样的特征对应的结果变了。前者用 PSI、KS、Wasserstein、JS 这类分布距离指标能测;后者测不到,只能靠线上标签回流后的效果指标去发现。多数团队只做前者,然后以为自己在做监控。
二是 OOT 的意义。随机切分假设样本可交换,而客服数据天生带时间序:旺季、大促、版本发布、渠道开通,都会让分布漂移。用未来时间窗做 OOT,本质上是在离线阶段模拟一次真实上线。它比交叉验证更接近生产,也比交叉验证更容易让人失望------这恰恰是它的价值。
解释非因果
只述此模型
漂移先动解
后动分与判
04、架构
把上面这些拼成一条可运行的链路,它的形状是这样的:
text
[输入] 会话结束事件 + 会话/工单/订单/CRM/物流特征快照
|
v
[模块] 特征契约层 → 风险模型 → 解释器 → 漂移监控 → 门禁
|
v
[数据/状态] 模型版本 / 特征快照ID / SHAP归因 / 漂移指标 / 阈值配置
|
v
[处理] 打分 → 阈值判定 → 归因 → 证据封装 → 门禁校验
|
v
[输出] 风险等级 + 归因证据包 + Agent 证据链接口
拆开看每一段的边界。
特征契约层负责列名、列序、类型和默认值。它是整条链里最不起眼也最容易出事的一层------第 06 章会看到,一次列序错位就能让所有归因变成噪声。它的边界是:只做确定性的特征拼装,不做任何模型逻辑。
风险模型是打分器,输出概率。它的边界是:只输出分数,不输出解释,不碰阈值。
解释器是 SHAP,输入是同一份特征矩阵和同一个模型对象,输出逐特征贡献。它的边界是:解释的是这一版模型在这一份快照上的行为,必须和模型版本、快照 ID 绑定。
漂移监控负责比参照分布和当前分布,输出指标和是否越界。它不负责判定,只负责报警和提供门禁依据。
门禁负责在发布前卡住不合格的模型,在线上卡住异常批次。它是唯一有 "否决权" 的模块。
这套结构换来的收益是可追溯性:任何一条判定,向上能追到模型版本和阈值配置,向下能追到特征快照和归因明细。代价是多了一次解释计算和数据落盘------TreeExplainer 对单条样本很快,但存储全量归因是有成本的,所以实际落地时通常只对高风险档位和抽样比例做全量归因。
适用条件也说清楚:这套东西对树模型和线性模型最顺畅。如果是深度模型,得换 DeepExplainer 或 GradientExplainer,计算成本和稳定性都会变差,需按当前官方文档核验具体支持范围。
链上有其位
各守一段责
出的每个分
都要能回看
05、实战一次
下面这个 V1 脚本把三件事串起来:时间切分训练、SHAP 归因、Evidently 漂移报告。它的目标是先跑通,再暴露问题。
环境与依赖:
bash
python -m venv .venv && source .venv/bin/activate
pip install "scikit-learn>=1.3" "shap>=0.44" "pandas>=2.0" "numpy>=1.26" "evidently>=0.4"
注意,Evidently 在 0.6/0.7 之后 API 有调整,Report 和预设的导入路径改为 evidently.Report 与 evidently.presets,具体请按当前官方文档核验;下面用的是 evidently.report.Report + evidently.metric_preset 这一路径。
核心实现:
python
# risk_evidence_v1.py
import json
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score, average_precision_score
import shap
RNG = np.random.default_rng(20240)
N = 6000
FEATURES = [
"msg_cnt", "neg_ratio", "repeat_cnt", "unresolved_h",
"refund_amt", "delay_days", "vip",
"lang_zh", "lang_en", "lang_ja", "lang_es",
"ch_chat", "ch_mail", "ch_phone", "ch_social",
]
def synth() -> pd.DataFrame:
"""合成一份带时间序的客服会话特征与投诉标签。"""
day = RNG.integers(0, 180, N)
lang = RNG.choice(["zh", "en", "ja", "es"], N, p=[0.4, 0.3, 0.2, 0.1])
channel = RNG.choice(["chat", "mail", "phone", "social"], N,
p=[0.5, 0.2, 0.15, 0.15])
msg_cnt = RNG.integers(1, 40, N)
neg_ratio = RNG.beta(2, 5, N)
repeat_cnt = RNG.poisson(1.2, N)
unresolved_h = RNG.gamma(2, 6, N)
refund_amt = RNG.gamma(2, 80, N).round(2)
delay_days = RNG.poisson(0.8, N)
vip = RNG.integers(0, 2, N)
logit = (-3.2 + 2.6 * neg_ratio + 0.35 * repeat_cnt
+ 0.05 * unresolved_h + 0.004 * refund_amt
+ 0.45 * delay_days + 0.55 * vip + 0.03 * msg_cnt)
prob = 1.0 / (1.0 + np.exp(-logit))
label = (RNG.random(N) < prob).astype(int)
df = pd.DataFrame({
"day": day, "msg_cnt": msg_cnt, "neg_ratio": neg_ratio,
"repeat_cnt": repeat_cnt, "unresolved_h": unresolved_h,
"refund_amt": refund_amt, "delay_days": delay_days, "vip": vip,
"label": label,
})
for v in ["zh", "en", "ja", "es"]:
df[f"lang_{v}"] = (lang == v).astype(int)
for v in ["chat", "mail", "phone", "social"]:
df[f"ch_{v}"] = (channel == v).astype(int)
return df
def split_by_time(df: pd.DataFrame):
"""按时间切 Train / Validation / OOT,OOT 是最后 20% 的时间窗。"""
df = df.sort_values("day").reset_index(drop=True)
n = len(df)
a, b = int(n * 0.6), int(n * 0.8)
return df.iloc[:a], df.iloc[a:b], df.iloc[b:]
def build_evidence(x_row, sv_row, base_value, model_version, snapshot_id, top_k=5):
"""把一条样本的 SHAP 归因封装成可被 Agent 引用的证据包。"""
pairs = sorted(
[(f, float(s), float(x_row[f])) for f, s in zip(FEATURES, sv_row)],
key=lambda t: abs(t[1]), reverse=True,
)[:top_k]
return {
"model_version": model_version,
"snapshot_id": snapshot_id,
"base_value": round(float(base_value), 4),
"top_drivers": [
{
"feature": f,
"value": v,
"shap": round(s, 4),
"direction": "increase" if s > 0 else "decrease",
}
for f, s, v in pairs
],
}
def main():
df = synth()
train, valid, oot = split_by_time(df)
model = RandomForestClassifier(
n_estimators=300, max_depth=8, min_samples_leaf=10,
random_state=42, n_jobs=-1,
)
model.fit(train[FEATURES], train["label"])
for name, part in [("valid", valid), ("oot", oot)]:
p = model.predict_proba(part[FEATURES])[:, 1]
print(f"[{name}] auc={roc_auc_score(part['label'], p):.4f} "
f"pr_auc={average_precision_score(part['label'], p):.4f}")
# ---- SHAP 归因 ----
explainer = shap.TreeExplainer(model)
sample = oot[FEATURES].iloc[:200]
sv = explainer.shap_values(sample)
if isinstance(sv, list): # 旧版本二分类返回 list[2]
sv_pos = np.asarray(sv[1])
elif np.asarray(sv).ndim == 3: # 新版本返回 (n, features, classes)
sv_pos = np.asarray(sv)[:, :, 1]
else:
sv_pos = np.asarray(sv)
base = explainer.expected_value
base_value = float(np.asarray(base).ravel()[1] if np.asarray(base).size > 1 else np.asarray(base).ravel()[0])
MODEL_VERSION = "risk-rf-v1.0.0"
SNAPSHOT_ID = "snap-oot-0001"
evidence = []
for i in range(min(5, len(sample))):
evidence.append(build_evidence(
sample.iloc[i], sv_pos[i], base_value, MODEL_VERSION, SNAPSHOT_ID,
))
print(json.dumps(evidence[0], ensure_ascii=False, indent=2))
# ---- 漂移检查 ----
from evidently.report import Report
from evidently.metric_preset import DataDriftPreset
report = Report(metrics=[DataDriftPreset()])
report.run(reference_data=train[FEATURES], current_data=oot[FEATURES])
drift = report.as_dict()
for m in drift["metrics"]:
if m.get("metric") == "DataDriftTable":
print("drift detections:", json.dumps(
m["result"].get("drift_by_columns", {}), ensure_ascii=False)[:400])
if __name__ == "__main__":
main()
启动与请求:
bash
python risk_evidence_v1.py
验证方式:脚本会打印 valid 和 oot 两段的 AUC、PR-AUC,打印第一条会话的证据包 JSON,打印漂移检测结果。
未在当前环境实测,以下为预期结果(示例输出,非真实数据):
text
[valid] auc=0.8420 pr_auc=0.5103
[oot] auc=0.8211 pr_auc=0.4877
json
{
"model_version": "risk-rf-v1.0.0",
"snapshot_id": "snap-oot-0001",
"base_value": 0.1912,
"top_drivers": [
{"feature": "neg_ratio", "value": 0.62, "shap": 0.1134, "direction": "increase"},
{"feature": "repeat_cnt", "value": 3.0, "shap": 0.0512, "direction": "increase"},
{"feature": "delay_days", "value": 2.0, "shap": 0.0388, "direction": "increase"},
{"feature": "unresolved_h", "value": 4.1, "shap": -0.0091, "direction": "decrease"},
{"feature": "lang_ja", "value": 1.0, "shap": 0.0064, "direction": "increase"}
]
}
V1 跑通了,但它留下三个明显的问题:OOT 只报了指标没人卡;漂移报告只打印结果没有阈值动作;归因列序完全依赖 FEATURES 常量和实际 DataFrame 列序的一致性,而这正是第 06 章翻车的地方。
先让链跑通
再让链可信
未证之前言
皆不可交付
06、排查
诊断链一:OOT 指标正常,线上高风险占比却持续上升
现象: 某租户在连续两周里被判定为高风险(分数 ≥ 0.8)的会话占比上升,但 OOT 集上的 AUC 没有异常,离线报告一切正常。
怀疑: 首先怀疑模型坏了。
检查: 对比训练期与线上当期的高风险判定占比、特征分布、以及归因 Top-K 的构成。
证据: 分数 AUC 无法直接测(线上标签回流有延迟),但特征侧非常清楚:lang_ja 这一列在训练集里占比约 10%,在当期线上占比跳到 22%,因为新开通了一个日本渠道。更关键的是,归因结构变了------训练期 Top-K 里从未出现 lang_* 系列,当期却稳定进入前三。
错误尝试: 第一反应是把阈值从 0.8 抬到 0.85,把多出来的高风险会话压回去。这是错的,而且错得很典型。阈值是一个业务动作开关,不是一个修分布的旋钮。分数分布右移的原因是输入分布变了,抬高阈值只会让真正该预警的会话被一起挡在门外,同时把问题从 "模型失准" 掩盖成 "业务口径调整"。它治不了根因,只会延迟暴露时间。
根因: 训练集覆盖的语种集合与线上不一致。模型没有见过足够多的 lang_ja 样本,只能靠语种这枚 one-hot 特征去补偿其他特征的缺失信息,于是它在日语会话上给出系统性偏高的分数。归根到底,这是特征覆盖漂移,不是模型退化。
修复: 把语种维度纳入训练数据的准入门槛;在漂移监控里对每个 one-hot 特征设阈值;对训练集中低频类别做合并或加权重采样;同时给模型加一条前置校验------当输入语种不在训练语种白名单内时,降级到规则路由而不是硬走模型。
诊断链二:SHAP 归因与业务常识完全对不上
现象: 一条明显包含大量负面表达和多次重复来电的会话,SHAP 给出的第一驱动特征却是 ch_social,贡献值还很小。业务同事看完直接说 "这解释还不如不解释"。
怀疑: 先怀疑 SHAP 算错了,接着怀疑 TreeExplainer 的算法有问题。
检查: 用同一个模型、同一条样本,手工重算一遍预测值,再核对 SHAP 值的加和是否等于预测值减基线。
证据: 加和不等于预测值。同时打印 sample.columns.tolist(),发现列序是 msg_cnt, neg_ratio, ...,而 FEATURES 常量里排的是另一套顺序------构造 sample 时用了 dict 转 DataFrame,Python 3.7 之后 dict 保留插入顺序,但拼接、筛选、get_dummies 之后的列序并不由我们完全控制。SHAP 是按位置对齐列的,列序一错,归因就整体错位。加和不等于预测值,就是最硬的证据。
根因: 模型、解释器、特征矩阵三者之间缺少一份显式的特征契约。SHAP 的局部准确性意味着 sum(shap) + base = prediction,这个等式一旦不成立,说明送进解释器的列和模型训练时的列没对齐。工程上这是一个 schema 问题,不是算法问题。
修复: 在特征契约层固定 FEATURES 列表,读数据后统一 df = df[FEATURES],并在每次解释前做一次断言:abs(sum(shap) + base - pred) < 1e-6。断言失败直接抛出,宁可不出证据,也不出假证据。
诊断链三:模型换了版本,同样的会话给出相反的判定
现象: 新版本上线后,同一批历史会话的风险分整体变化,一批原高风险会话掉到低风险。
怀疑: 怀疑新模型训练数据有问题。
检查: 用新旧两个模型对同一份 OOT 快照打分,比较分数差与归因差。
证据: 分数差的分布是双峰的,不是整体平移------说明两版模型对不同子群的敏感度不同。同时旧模型的解释器实例还挂在缓存里,新归因是用旧 explainer 加新模型算出来的,两者对不上。
根因: 模型版本、特征快照版本、解释器版本、阈值版本这四者没有做统一的版本绑定。任一项超前或滞后,产出的证据包就是不可信的。
修复: 把四者打成一个不可变的判定指纹,写进每一条证据包;线上同时保留双版本打分,用影子流量对比一个发布周期后再切主。
先证其不伪
再言其可用
加和不对齐
一切皆虚妄
07、优化
基于第 06 章的三条证据,V2 不是加算法,而是加约束。
根因一:特征覆盖漂移无门禁。修改:把漂移监控从 "打印报告" 升级为门禁。对每个入模特征计算漂移指标(数值特征用 PSI、KS 或 Wasserstein,类别特征用卡方或 JS),设定两级阈值:告警阈值只记录不阻断,阻断阈值直接让发布流水线失败。同时增加一条语种/渠道白名单校验,输入不在白名单内时走降级路径。为什么这样做:因为漂移的本质是模型对输入分布的隐含假设被破坏,而门禁是唯一能在发布前拦住这件事的机制。新行为:发布前必过漂移检查,线上每日跑批比对当期分布与训练参照分布。验证:故意把 OOT 集里 lang_ja 的比例调高到 30%,漂移报告应当对该列报出越界。
根因二:缺少特征契约与解释自检。修改:在特征契约层固化列名与列序,所有数据入口统一按契约重排;在解释器输出后加三道断言------加和一致性、特征数一致性、非零贡献数与模型特征重要性的一致性范围。为什么这样做:SHAP 的可信度完全建立在可验证的数学性质上,一旦列错位,解释看起来还是 "一张漂亮的图",但它已经和模型无关了。新行为:任何一条不满足断言的证据包都不允许落库,直接抛异常并告警。验证:人为打乱一次列序,脚本应当在解释阶段失败,而不是输出一张错误但貌似合理的归因。
根因三:判定指纹缺失。修改:把模型版本、特征快照 ID、解释器版本、阈值配置版本、特征契约哈希合成一个 judgement_fingerprint,写进证据包。历史滚动时保存双版本打分结果,一个发布周期后再切主。为什么这样做:可回滚的前提是能对齐,能对齐的前提是版本被显式记录。新行为:任意一条历史判定,都能被完整还原成一次可复现的计算。验证:拿三个月前的一条判定,按指纹重跑,分数与归因必须逐位一致。
V2 的分数标签定义也跟着改:不再只有 "高风险 / 低风险",而是三档------高、观察、低。落入观察档且归因不稳定的会话,不进优先队列,而是进入每日人工抽样复核池。这一步把 "模型不确定" 这个状态显式地表达出来了,而不是让模型硬选一个答案。
阈值分两档
判决定指纹
存疑不硬判
交人去复核
08、演进
同一份特征快照,在两版链路里的走法完全不同:
text
[同一输入] 会话快照(含未见语种、渠道分布偏移)
|
+--[V1] 直接打分 → 单阈值 0.8 硬切 → 无归因 → 无门禁
| 代价:判定不可解释、漂移静默、模型变更不可对齐
|
+--[V2] 契约校验 → 打分 → 三档判定 → SHAP 归因 → 证据封装 → 指纹绑定
| 代价:多一次解释计算、多一份归因存储、多一道发布门禁
|
[Trade-off]
得到:可解释、可追溯、可回滚、漂移可拦截
失去:单条判定的计算成本上升,发布流程变长,低频语种可能因白名单被降级
适用边界:树模型与线性模型最顺;深度模型需换解释器并重新评估成本;
缺少线上标签回流时,概念漂移仍无法被这套机制直接发现
逐项比较。
正确性上,V1 的分数本身没错,错的是它被当成证据使用;V2 把分数降级为证据的一部分,同时补齐了归因、指纹和漂移上下文,正确性的定义从 "分对不对" 变成了 "判定能不能被复核"。
稳定性上,V1 在分布稳定时很好,一旦分布偏移就会静默失准;V2 用两级漂移阈值加降级路径,让系统在分布变化时仍然给出可预期的行为,而不是给出一个安静的错误答案。
复杂度上,V2 明显更重。多了一个特征契约层、一个解释器、一套漂移指标、一套版本绑定和一条门禁流水线。这些都不是免费午餐,它们需要有人维护阈值、有人看告警、有人在门禁失败时做判断。
成本上,主要增量在三处:解释计算、归因存储、判定指纹带来的双版本打分窗口。工程上通常用 "只对高风险档位全量归因 + 其余抽样" 来控制第一项,用 "归因保留 Top-K 而非全特征" 控制第二项。
适用范围上,V2 的价值在高风险判定会被下游引用时最大。如果某个模型的输出只用于内部排序、没有任何对外解释需求,那这套机制的投入产出比就要重新算。
遗留问题有三个。第一,概念漂移没解决------特征没变但结论变了,这套机制看不见,必须靠标签回流后的效果监控补。第二,SHAP 归因只对单条样本成立,多条会话聚合时的归因语义需要额外定义。第三,证据包本身一旦被 Agent 大量引用,就成了事实上的对外承诺,未来的模型迭代会受到历史解释的约束,这个约束是收益也是负债。
一切皆有价
可控亦不轻
知其所得失
再问值不值
09、洞见
9.1 模型的输出只有进入证据链,才真正完成交付
这一篇最大的转折点,是把风险分从 "一个数" 重新定义为 "一份可被引用的证据"。这两者的工程要求完全不同。一个数只要算出来就行,一份证据必须满足可复现、可追溯、可反驳。它需要模型版本、数据快照、阈值配置、归因明细一起被打包。前面五条完成标准里,真正硬的是第四条------归因必须能作为证据链中的 item 被下游引用。做不到这条,其他四条都只是内部指标。
9.2 反直觉判断:解释系统是比模型更灵敏的漂移探针
第 03 章提过一次,这里用第 06 章的证据再说一遍。诊断链一里,分数 AUC 一切正常,但归因结构已经变了------lang_* 从 Top-K 之外冲进前三。原因在于模型的输出是多个特征加总后的非线性挤压,特征之间会互相补偿;而归因是逐特征拆开的,补偿效应藏不住。所以一个团队如果只监控分数分布,会晚一到两个发布周期才发现问题;如果同时监控归因分布,能更早看到输入结构的变化。这是一个可以直接落地的判断:把归因 Top-K 的构成变化,当成和特征分布同等优先级的监控项。
9.3 反直觉判断:SHAP 的工程价值主要来自它的自检性质,而不是它的图
多数团队引入 SHAP 是为了出一张瀑布图,这其实是它最小的价值。真正有价值的是它自带一个可验证的等式:各特征贡献之和加上基线等于模型输出。这个等式是一把尺子,能量出你的解释器和你的模型之间到底有没有错位。诊断链二里我们靠它抓出了列序问题------如果没有这个等式,那张错位的归因图看起来依然满像回事。所以正确用法是:先拿它当断言,再拿它当说明。
9.4 阈值是业务决策,不是模型参数
V1 把 0.8 写死在脚本里,这件事的技术后果和业务后果是同一个:没有人对 "优先转人工" 这个动作负责。V2 把阈值拆成三档,引入观察档和人工抽样复核池,本质上是把 "不确定" 显式建模出来。工程上的收益是,模型不再被迫在一个它没把握的区间里做硬判断;业务上的收益是,那些边界样本会自动流向人的判断,而不是流向客户。阈值该由业务和算法一起定,并且该有版本、该可回滚。
9.5 监控能覆盖的是相关性的变化,覆盖不了因果的变化
第 06 章的诊断链一解决的是特征漂移,这一类问题用 PSI、KS 就能抓到。但概念漂移------同样的特征分布下,投诉这个结果本身变少了或变多了------这套机制测不到。这不是本次实现的缺陷,而是离线监控的固有边界。承认这条边界,比假装监控大盘全绿就等于系统健康要诚实得多。
解释即断言
归因即尺子
监控有其界
不可强作全
10、系统落地
原来有什么:一台每天跑批的打分脚本,一个躺在对象存储里的模型文件,一份两年前的上线报告,和一套只存在于聊天记录里的变更史。
本篇新增了什么:一条从特征契约到证据包的五段链路;一套 Train/Validation/OOT 的时间切分门禁;一份覆盖数值与类别特征的漂移指标体系及两级阈值;一层 SHAP 归因与三道断言自检;一个把模型版本、快照 ID、解释器版本、阈值版本、契约哈希绑在一起的判定指纹;以及一条从三档判定通往人工抽样复核池的分流路径。
现在能做什么:当某个会话被判为高投诉风险,系统能给出可复现的归因------哪些特征把分数推高、各自贡献多少、方向如何、基于哪一版模型和哪一份数据快照。当某个客户被优先转人工,这个动作背后有一条带指纹的证据可以摆到合规面前。下游 Agent 拿到的不是一个裸分数,而是一个带上下文的 evidence item,它可以在给人工的说明里直接引用、在工单里留痕、在客户申诉时被完整还原。
还缺什么:第一,概念漂移监控缺失,需要打开线上标签回流通道后才能补;第二,归因只做到单条样本粒度,聚合层的归因语义未定义;第三,多租户场景下每家企业可能需要独立的漂移参照分布和独立阈值,目前还是全局一套;第四,证据包还没有统一的检索与生命周期管理,长期会积累成存储与合规负担。
下一步如何演进:把证据包接入 Agent 的证据总线,让每一次判定都能被检索、比对和引用;在多租户维度上做参照分布的分租户化;把阈值配置从代码搬到配置中心,接入审批与灰度。这三步的优先级,取决于哪一类问题先在生产里被翻出来。
旧链只求分
新链要求证
证可追可复
方能承其重
11、小结
text
Q1 → 分数要满足什么才配被下游引用?
必须满足可复现、可追溯、可反驳:绑定模型版本、数据快照、
阈值配置与归因明细,任何一条不能在事后完整还原的判定都不合格。
Q2 → SHAP 解释的是模型还是业务?
解释的是模型在该样本上的行为,满足局部准确性与一致性;
它证明的是模型学到了什么,不证明业务因果,两者不可混用。
Q3 → 模型每周换版本,靠什么对齐判定?
靠判定指纹:模型版本 + 快照 ID + 解释器版本 + 阈值版本 + 契约哈希,
加双版本影子对比与一个发布周期的观察窗口。
状态 → 一条可跑通、带 OOT 门禁与漂移阈值、可产出带指纹 SHAP 证据包、
并能被下游 Agent 作为证据链 item 引用的投诉风险判定链路。
分有其源
判有其据
变有其迹
链有其信
12、作业
12.1 理解题:为什么说用交叉验证代替 OOT 会高估线上效果?
参考答案:交叉验证的切分假设样本可交换,训练与验证来自同一次抽样的同一分布;而客服数据带强时间序,旺季、大促、渠道开通、语种扩张都会造成分布漂移。用未来时间窗做 OOT,本质是在离线阶段模拟一次真实上线,它会把时间带来的分布差异暴露出来,因此通常比随机切分给出更保守、也更接近生产的估计。两者不是谁更准的问题,而是回答的问题不同:交叉验证回答模型在同期数据上能不能学,OOT 回答模型对未来数据能不能用。
12.2 实战题:给第 05 章的脚本加一道解释自检,要求列序错位时立即失败。
参考答案:核心是验证 SHAP 的局部准确性等式。在拿到 sv_pos 与 base_value 后,对每条样本计算 pred = model.predict_proba(sample)[:, 1],然后断言 abs(sv_pos[i].sum() + base_value - pred[i]) < 1e-6。同时前置一层契约校验:读入数据后执行 sample = sample[FEATURES],并断言 sample.shape[1] == len(FEATURES)、sample.columns.tolist() == FEATURES。两道断言任一失败就抛异常中断,绝不输出证据包。这样做的意义是:宁可不出证据,也不出假证据。
12.3 排障题:线上高风险判定占比突然翻倍,但漂移报告全部正常,你怎么查?
参考答案:漂移报告测的是特征分布 P(X),它正常说明输入分布没动,那么问题很可能在 P(y|X),也就是概念漂移或模型自身变更。排查顺序建议是:先核对判定指纹,确认模型版本、阈值配置版本、特征契约哈希最近有没有变更,很多 "模型变差" 其实是阈值被改或契约被重排;再打开标签回流,用最近一个窗口的真实投诉率对比线上判定占比,判断是模型偏了还是业务本身变多了;最后做分群拆解,看是全局翻倍还是某个租户、某个渠道、某个语种翻倍,如果是局部,多半是新接入的数据源口径变化。注意,这类问题不能靠调阈值解决。
12.4 架构判断题:既然 SHAP 计算有成本,能不能只在被投诉质疑时才补算归因?
参考答案:不建议。事后补算的核心风险是数据快照不可复现------特征来自 CRM、订单、物流等多个系统,这些系统的数据是持续更新的,事后重算拿到的往往已经不是当时那份输入,算出来的归因也就无法作为证据。另外,模型可能已经换了版本,旧解释器未必还能还原旧行为。正确做法是把计算成本和证据可信度分开处理:高风险档位全量归因并落库,低风险档位按比例抽样,归因只保留 Top-K 而非全特征,同时把模型文件、特征快照和解释器版本一起持久化。成本要省在采样率上,不能省在可复现性上。
题在纸上解
功在线上练
每一问背后
都是一次取舍
13、思考
回到开篇那个冲突:模型给出分数,不等于给出证据。
这一篇真正在做的事,是把 "模型输出" 从一个数字,改造成一份可以被别人拿去用、也可以被别人拿去质疑的证词。这个转变带来的所有工程动作------时间切分门禁、漂移指标、两级阈值、SHAP 归因、判定指纹------本质上都在回答同一个问题:如果明天有人拿着这条判定来问我凭什么,我能不能在三分钟内完整还原它。
能还原,才算交付。不能还原,就只是一个看起来挺准的数字。
SHAP 不是解释真相的工具,它是一把尺子,量的是模型与自己的一致性。这把尺子最大的用处,是让你在解释看起来还挺合理的时候,仍然能发现它其实是错的。
监控也不是为了证明系统健康,它是为了让你在系统已经不健康的时候,早点知道。任何一套监控都要承认自己的边界:它能看见分布变了,看不见因果变了。
而阈值,永远是一个业务决定,不是模型参数。把它写进代码里,就等于把这个决定藏起来了。
模型越强,越要能被质问。这不是合规要求,这是让一个系统在真实业务里活得久的前提。
分可解可证
漂可察可控
判可追溯源
链可承其重