度量与运行引擎
30 秒导读: Evidently 的 v2 引擎把「一个数据集有多好」拆成两件事:Metric(要算什么)和 Test(算出来该不该报警)。这一章讲清楚它工程含量最高的一支——怎么把度量按「结果长什么形状」做成类型体系,怎么用一个自带依赖图和缓存的
Context把整份报告算完,以及怎么给任何度量挂上 pass/fail 条件、让一份Report直接变成一套测试。
本章是 Evidently 系列的第 2 章。第 01 章讲的是行级 Descriptor(给每一行打分);本章讲的是数据集级的度量与整个执行引擎。具体的漂移算法留给第 04 章,LLM 评委留给第 03 章——本章只讲「引擎怎么转」。
1. 这是什么(零基础也能懂)
一句话定义
度量引 擎 = 一台「喂进一两个数据集、吐出一叠带图带结论的指标」的计算机。
你给它一个当前数据集(current),可选再给一个参照数据集(reference,通常是训练集或上周的数据),它算出一堆数字(均值、缺失率、漂移程度、准确率……),每个数字自带一张可视化 widget,还能顺手判一句「这个数字合格吗」。
解决谁的什么问题
假设你上线了一个 ML 模型,每天有新数据流进来。你担心两件事:
- 数据变样了吗?(今天的输入分布和训练时还一样吗?→ 漂移)
- 模型还准吗?(准确率有没有掉?缺失值有没有暴增?)
手写这些检查很烦:要算统计量、要和基线比、要画图、要设阈值报警。度量引擎把这套流程标准化——你只声明「我要看哪些指标、什么条件算不合格」,它负责算、缓存、画图、判定。
用起来什么样
最小的一段真实用法(来自 Report 的类文档,core/report.py:838):
from evidently import Report, Dataset, DataDefinition
from evidently.presets import DataSummaryPreset
# 一份报告 = 一串「要看什么」的声明
report = Report([DataSummaryPreset()])
dataset = Dataset.from_pandas(df, data_definition=DataDefinition())
# run 出一个 Snapshot(结果快照)
snapshot = report.run(dataset, None)
snapshot.save_html("report.html") # 导出成网页
snapshot.dict() # 或导出成 Python 字典 / JSON
想加参照数据做漂移检测,只要多传一个数据集:
from evidently.presets import DataDriftPreset
report = Report([DataDriftPreset()])
snapshot = report.run(current_dataset, reference_dataset) # 两个数据集 → 自动对比
一句话直觉
把
Report当菜单(点了哪些菜),Metric当一道菜的配方,Context当后厨(备料、复用半成品、上菜),Snapshot当端上桌的那一整桌。你只点菜,后厨保证同样的高汤只熬一次。
本节不碰底层。记住四个词:Report(点单)、Metric(配方)、Context(后厨)、Snapshot(成品)。
2. 顶层全景(它大概怎么转)
一张图看懂主线
一次 report.run(current, reference) 的数据流,从左到右:
你写的声明 执行引擎(一次性) 结果
┌───────────┐ run() ┌──────────────────────────┐ ┌────────────┐
│ Report │ ────────▶ │ Snapshot._run_items │ │ Snapshot │
│ [菜单] │ │ 递归展开 metric/container │ ────▶ │ [成品桌] │
│ Preset ×N │ │ │ │ │ ·metrics │
│ Metric ×N │ │ ▼ │ │ ·tests │
└───────────┘ │ Context.calculate_metric│ │ ·widgets │
│ ①查缓存(按 id 去重) │ └────────────┘
│ ②calc.call → calculate │ │
│ ③算完顺手跑 bound tests │ save_html / json / dict
│ ④current+reference 一起出 │ ─────────────────────────▶
└──────────────────────────┘
怎么读这张图: 左边是你声明的「要什么」;中间的 Context 是唯一真正干活的地方,它对每个度量做「先查缓存、没有才算、算完跑测试」三步;右边是可导出的成品。整个过程只发生一次,同一个度量在同一份报告里只会被算一次。
部件一句话职责
| 部件 | 干什么 | 在哪(符号) |
|---|---|---|
Report | 存一串「要算的东西」的声明,提供 run() | core/report.py:821 |
Snapshot(别名 Run) | 一次运行的结果容器,负责展开 + 收集 widget + 导出 | core/report.py:487 |
Context | 真正的执行引擎:持数据集、建依赖图、缓存去重、跑测试 | core/report.py:123 |
Metric | 一个度量的配置(要算什么、参数、挂哪些测试) | core/metric_types.py:1114 |
MetricCalculation | 一个度量的算法(真正 calculate 出结果) | core/metric_types.py:1229 |
MetricResult | 算出来的结果 + widget + 测试结果,按「形状」分子类 | core/metric_types.py:165 |
MetricContainer / Preset | 把「一个声明」展开成「一批 metric」 | core/container.py:25 |
MetricTest / BoundTest | 给结果加 pass/fail 条件,把 Report 变 Test Suite | core/metric_types.py:1008 / :1080 |
主线走一遍(高层)
- 你
Report([...])声明一串 metric 和 preset,调run()(core/report.py:904)。 run把 DataFrame 包成Dataset,建一个Snapshot,调snapshot.run(:569)。Snapshot._run_items(:548)遍历菜单:遇到 preset 就先展开成子 metric 再递归,遇到 metric 就交给Context.calculate_metric(:191)。Context对每个度量查缓存、算、跑绑定的测试,current 和 reference 两份结果一起产出。- 所有 widget 汇总进
Snapshot,可save_html/json/dict导出。
下面逐个机制拆开讲。
3. 机制 A:按「结果形状」类型化的 Metric 结果
它要解决的小问题
不同度量算出来的东西形状不一样:
- 「均值」是一个数;
- 「每个类别的精确率」是一张 标签→数 的表;
- 「缺失值」既要绝对个数又要占比;
- 「误差分布」要均值 + 标准差;
- 「相关矩阵」是一整张 DataFrame。
如果每个度量各自定义返回类型,渲染、取值、加测试的代码就得为每种度量各写一遍。Evidently 的做法是:先把「形状」枚举出来,做成几个固定的结果类型,所有度量都归到这几类里。
六种结果形状
所有结果都继承 MetricResult(core/metric_types.py:165),它统一携带 display_name、widget(可视化)、tests(测试结果)、以及一个「值定位」metric_value_location。子类只是「形状」不同:
| 结果类型 | 形状 | 典型度量 | 定义 |
|---|---|---|---|
SingleValue | 一个标量 | 均值、准确率、行数 | core/metric_types.py:403 |
ByLabelValue | 标签 → 标量 | 每类精确率/召回 | :432 |
ByLabelCountValue | 标签 → (个数, 占比) | 每类唯一值计数 | :480 |
CountValue | (个数, 占比) | 缺失值、重复行 | :572 |
MeanStdValue | (均值, 标准差) | 误差分布 | :614 |
DataframeValue | 一整张 DataFrame | 相关矩阵、明细表 | :656 |
为什么这么切:三个统一带来的好处
① 统一渲染。 有了固定形状,就能写一个「默认画法」总表 get_default_render(:855):SingleValue 画成一个计数器,ByLabelValue 画成一张表,CountValue 画成「个数 + 占比」两个计数器……度量本身不用管画图。带 reference 时另有 get_default_render_ref(:773),自动画成「current | reference」并排对 比。
② 统一取值。 测试要「从结果里取出那个待判的数」。MetricValueLocation.extract_value(:122)用一段 isinstance 分派,把「从 ByLabelValue 里按 label 取、从 CountValue 里按 count/share 取」这类逻辑集中在一处:
# 摘自 extract_value,core/metric_types.py:122 —— 按结果形状取出一个 SingleValue
if isinstance(value, ByLabelValue):
label = self.params().get("label") # 参数里带着要取哪个标签
result = value.get_label_result(label)
...
if isinstance(value, CountValue):
value_type = self.params().get("value_type") # "count" 还是 "share"
return value.get_count() if value_type == "count" else value.get_share()
③ 统一展平。 每种结果都实现 to_simple_dict(如 SingleValue 直接返回值 :412,CountValue 返回 {"count":..., "share":...} :599),于是 itervalues(:277)能把任意结果展平成一串 (key, 数值),导出 JSON 时不用为每种形状特判。
关键细节:结果里藏着「回指自己」的地址
每个结果都带一个 metric_value_location——它记录「我是哪个 metric 配置、要取哪个子值」的坐标。set_metric_location(如 ByLabelValue.set_metric_location :467)会给结果本身、以及每个标签的子值都盖上一个 location。这就是测试后来能精确「从某度量的某标签取一个数来判」的基础,也是把嵌套结果拍平成可寻址叶子的关键。
4. 机制 B:config 与 calculation 的双层设计
它要解决的小问题
一个度量有两副面孔:
- 一副是声明——「我要算列
age的均值」,这是可序列化、可存进 JSON、可从 UI 传来的配置; - 一副是算法——真正拿到数据、跑出均值的 那段计算逻辑,里面可能有 pandas 调用、可能依赖别的度量。
Evidently 把这两副面孔拆成两个类,这是本引擎最核心的设计。
双层结构
声明层(可序列化) 计算层(干活)
┌──────────────────┐ to_calculation() ┌───────────────────────┐
│ Metric │ ────────────────▶ │ MetricCalculation │
│ ·参数字段 │ │ ·calculate(ctx,cur,ref)│
│ ·tests 配置 │ ◀──────────────── │ ·display_name() │
│ ·get_bound_tests │ to_metric() │ ·result(...) 造结果 │
└──────────────────┘ └ ───────────────────────┘
Metric 1114 MetricCalculationBase 924
Metric(配置) | MetricCalculation(计算) | |
|---|---|---|
| 基类 | EvidentlyBaseModel(pydantic,可序列化) | MetricCalculationBase(普通类) |
| 职责 | 存参数、挂测试、给出唯一 id | 拿数据算结果、造 widget |
| 关键方法 | to_calculation() :1137、get_bound_tests() :1211 | call() :954、calculate() :976 |
| 定义处 | core/metric_types.py:1114 | :924 / :1229 |
两层怎么自动配对(一个巧妙处)
你写一个新度量时,只写 MetricCalculation[SingleValue, MyMetric] 这样一个泛型子类,不用手动登记 config↔calculation 的对应。秘密在 MetricCalculation.__init_subclass__(:1234):每定义一个计算子类,它就用 typing_inspect 读出泛型参数里那个 Metric 子类,反手把 config_type.__calculation_type__ = cls 挂上去。于是 Metric.to_calculation()(:1137)能顺着这根线找到自己的算法类。
call → calculate → 自动渲染 widget
真正执行入口是 MetricCalculationBase.call(core/metric_types.py:954),它做三件事:
# 摘自 call,core/metric_types.py:954 —— 度量执行的统一入口
def call(self, context):
result = self.calculate(context, *context._input_data) # ① 子类实现的真算法
if isinstance(result, tuple):
curr_result, ref_result = result # ② 可能同时返回 current+reference
else:
curr_result, ref_result = result, None
if not curr_result.is_widget_set(): # ③ 没自定义 widget 就套默认画法
if ref_result is None:
curr_result.widget = get_default_render(self.display_name(), curr_result)
else:
curr_result.widget = get_default_render_ref(self.display_name(), curr_result, ref_result)
return curr_result, ref_result
重点看第 ③ 步: widget 是「兜底自动生成」的——度量只要产出正确形状的 结果,不写任何渲染代码也能出图;想要特制图表才覆盖 widget。这正是机制 A 的形状分类在这里兑现价值。
各形状还有便捷的 result(...) 工厂帮你造结果,例如 SingleValueCalculation.result(:1319)、CountCalculation.result(:1634)、MeanStdCalculation.result(:1845),造结果时顺手把 metric_value_location 盖好。
5. 机制 C:Context —— 依赖图 + 缓存去重的运行引擎
它要解决的小问题
一份报告里,度量之间常常互相依赖、互相重复:
DataSummaryPreset会为几十列各生成「行数」metric,但「行数」其实全表只有一个值,算几十遍是浪费;MAE的默认测试需要先算一个DummyMAE(基线)当阈值——一个度量依赖另一个度量的结果。
Context(core/report.py:123)就是解决这两件事的中枢:同一个度量只算一次,依赖能被顺藤摸到。
核心:calculate_metric 的四步
# 摘自 calculate_metric,core/report.py:191 —— 引擎的心脏
def calculate_metric(self, calc):
if calc.id not in self._current_graph_level: # ① 在依赖图当前层登记这个度量
self._current_graph_level[calc.id] = {"_self": calc}
prev_level = self._current_graph_level
self._current_graph_level = prev_level[calc.id] # 下钻一层: 它内部再依赖谁,记在它名下
if calc.id not in self._metrics: # ② 按 id 去重:没算过才算
current_result, reference_result = calc.call(self) # 真正执行(见机制 B)
...
self._metrics[calc.id] = current_result # current 存这里
if reference_result is not None:
self._reference_metrics[calc.id] = reference_result # reference 存另一处
test_results = { # ③ 算完顺手跑绑定的测试
tc: tc.run_test(self, calc, current_result)
for tc in calc.to_metric().get_bound_tests(self)
}
if test_results:
current_result.set_tests(list(test_results.values()))
self._current_graph_level = prev_level # ④ 回到上一层
return self._metrics[calc.id]
拆开看这四步:
① 依赖图 _metrics_graph。 这是一棵按「谁在算谁的过程中又要了谁」嵌套的字典树。进入一个度量前把「当前层」下钻到它名下,算它的过程中若它又 calculate_metric 了别的度量,那些就自然记成它的子节点;算完再回退。这样引擎知道整个依赖结构,而顶层节点(_metrics_graph 的第一层 key)就是报告真正要展示的度量。
② 按 id 去重。 calc.id 是度量的 fingerprint(Metric.get_metric_id :1152 返回 get_fingerprint())——相同配置的度量指纹相同。所以「几十列各要一次行数」里,那几十个完全相同的 RowCount() 只有第一个真算,其余全部命中 self._metrics 缓存。去重的粒度是「配置指纹」,不是对象身份。
③ 算完即测。 度量一算完,就立刻取它的 get_bound_tests 跑一遍(下节详述),测试结果直接挂回结果的 .tests。测试不是单独一趟,而是嵌在计算里。
current 与 reference:一次算两份
引擎不开线程「并行」,而是让一次 calculate 同时产出两份结果:calc.call 返回 (current_result, reference_result)(机制 B 里那个 tuple)。current 存进 _metrics,reference 存进独立的 _reference_metrics(:216)。取的时候分别走 get_metric_result(:225)和 get_reference_metric_result(:253);后者若没有参照数据会抛 ReferenceMetricNotFound(:104)。带 reference 的度量因此天然是「并排双列」的。
依赖是怎么「顺藤摸到」的
当一个度量在自己的 calculate 里想要另一个度量的结果,它只需调 context.get_metric_result(otherMetric)(:225)或 context.calculate_metric(...)。因为都走同一套缓存,被依赖的度量算一次后大家共享。机制 F 里 MAE._default_tests 用 context.calculate_metric(DummyMAE().to_calculation()) 拿基线当阈值,就是这条路。
边界提示: 引擎还留了
get_legacy_metric(:269)桥接 v1 老度量——它有自己的_legacy_metrics缓存和_discover_dependencies依赖发现。漂移 preset 的渲染就借道老度量(见机制 E),但那套不在本章主线里。