跳到主要内容

度量与运行引擎

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 Suitecore/metric_types.py:1008 / :1080

主线走一遍(高层)

  1. Report([...]) 声明一串 metric 和 preset,调 run()(core/report.py:904)。
  2. run 把 DataFrame 包成 Dataset,建一个 Snapshot,调 snapshot.run(:569)。
  3. Snapshot._run_items(:548)遍历菜单:遇到 preset 就先展开成子 metric 再递归,遇到 metric 就交给 Context.calculate_metric(:191)。
  4. Context 对每个度量查缓存、算、跑绑定的测试,current 和 reference 两份结果一起产出。
  5. 所有 widget 汇总进 Snapshot,可 save_html / json / dict 导出。

下面逐个机制拆开讲。


3. 机制 A:按「结果形状」类型化的 Metric 结果

它要解决的小问题

不同度量算出来的东西形状不一样:

  • 「均值」是一个数;
  • 「每个类别的精确率」是一张 标签→数 的表;
  • 「缺失值」既要绝对个数又要占比;
  • 「误差分布」要均值 + 标准差;
  • 「相关矩阵」是一整张 DataFrame

如果每个度量各自定义返回类型,渲染、取值、加测试的代码就得为每种度量各写一遍。Evidently 的做法是:先把「形状」枚举出来,做成几个固定的结果类型,所有度量都归到这几类里。

六种结果形状

所有结果都继承 MetricResult(core/metric_types.py:165),它统一携带 display_namewidget(可视化)、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() :1137get_bound_tests() :1211call() :954calculate() :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_testscontext.calculate_metric(DummyMAE().to_calculation()) 拿基线当阈值,就是这条路。

边界提示: 引擎还留了 get_legacy_metric(:269)桥接 v1 老度量——它有自己的 _legacy_metrics 缓存和 _discover_dependencies 依赖发现。漂移 preset 的渲染就借道老度量(见机制 E),但那套不在本章主线里。


6. 机制 D:Report → Snapshot → 展开与导出

它要解决的小问题

菜单里既有单个 metric,又有会「炸开成一批 metric」的 preset,还可能层层嵌套。要把这棵树跑完、把每个度量的 widget 按顺序收集起来、最后能导出——这是 Snapshot 的活。

递归展开:_run_items

Snapshot._run_items(core/report.py:548)是把菜单树跑平的递归函数:

# 摘自 _run_items,core/report.py:548 —— 一次遍历,把 metric 和 container 都跑掉
for item in items:
if isinstance(item, MetricContainer): # 是 preset/容器
container_items, container_widgets = self._run_items( # → 先展开成子项,递归
item.metrics(self.context), metric_results)
widget = item.render(self.context, [...]) # → 容器自己再渲染一层
snapshot_items.append(SnapshotItem(None, widget))
else: # 是单个 metric
calc = item.to_calculation()
metric_results[calc.id] = self.context.calculate_metric(calc) # → 交给引擎(机制 C)
widget = metric_results[calc.id].get_widgets()
snapshot_items.append(SnapshotItem(calc.id, widget))

要点: container 走「展开 → 递归 → 容器再 render 一层」,metric 走「转成 calculation → 丢进 Context」。widget 一路 extend 汇总。

三层调用链

Report.run(:904) ─ 包 Dataset、建 Snapshot、填时间戳/元数据
└─ Snapshot.run(:569) ─ init_dataset,调 _run_items
└─ _run_items(:548) ─ 递归展开 metric/container,收集 widget
└─ Context.calculate_metric(:191) ─ 真正算(机制 C)

跑完后,Snapshot.run_metrics_graph 的顶层 key 取出 _top_level_metrics(:579)——这就是「报告真正要展示的那批度量」(去重、去掉纯内部依赖后的结果)。所有度量的测试再汇总成两个测试面板 widget(:582)。

导出的三种形态

方法产出定义
save_html / get_html_str独立或可嵌 iframe 的 HTMLcore/report.py:648 / :588
json / dict精简字典:{"metrics":[...], "tests":[...]}:640 / :769
dumps / dump_dict完整序列化(含 widget、可 load 回来):673 / :681

精简的 dict(:769)只遍历 _top_level_metrics,每个度量调 to_dict(core/metric_types.py:227)——这里又用到机制 A 的 to_simple_dict 把任意形状拍平。完整的 dump_dictSnapshotModel,可 Snapshot.load(:708)读回,这也是把快照「时序化」存进 Workspace 的接口(见第 05 章)。


7. 机制 E:Preset / Container —— 一条声明展开成一批度量

它要解决的小问题

你不想手写「给这 40 列各来一套 min/max/mean/std/缺失率」。Preset 就是「一句声明 → 一批 metric」的展开器。

Container 的契约

所有 preset 继承 MetricContainer(core/container.py:25),契约只有一个抽象方法 generate_metrics(:51):给定 Context(能看到数据集的列),返回一串 metric 或更小的 container。ColumnMetricContainer(:142)是「只针对某一列」的便捷基类。

展开结果会缓存进 Context——metrics()(:62)先查 context.metrics_container(fingerprint),没有才真展开并存回。所以 _run_items 里多次问一个 container 要 metric,只展开一次。

三个真实 preset 各展开成什么

DataSummaryPreset(presets/dataset_stats.py:568)—— 全表体检。 它把自己拆成两个子 container:DatasetStats(数据集级)+ TextEvals(每列级),generate_metrics(:637)返回两者的 metric 之和。其中 DatasetStats.generate_metrics(:444)一口气声明行数、各类型列数、重复行/列、常量列、空行/列、缺失值等十几个 metric:

# 摘自 DatasetStats.generate_metrics,presets/dataset_stats.py:444
return [
RowCount(tests=self._get_tests(self.row_count_tests)),
ColumnCount(tests=self._get_tests(self.column_count_tests)),
DuplicatedRowCount(tests=self._get_tests(self.duplicated_row_count_tests)),
...
DatasetMissingValueCount(
tests=self._get_tests(self.dataset_missing_value_count_tests),
share_tests=self._get_tests(self.dataset_missing_value_share_tests),
),
]

ValueStats(:52)—— 按列类型展开。 generate_metrics(:135)先问 context.column(col).column_type 看列是什么类型,数值列才加 min/max/mean/std/分位数,类别列改加唯一值计数,时间列只加 min/max。同一句声明,落到不同列上展开出的 metric 不同——这是「preset 依赖数据、运行时才展开」的价值。

DataDriftPreset(presets/drift.py:24)—— 每列一个漂移度量。 generate_metrics(:98)返回一个 DriftedColumnsCount 加上「对每一列一个 ValueDrift」。要看哪些列、每列用哪种统计检验、阈值多少,都在这里按列类型算好塞进 ValueDrift 的参数:

# 摘自 DataDriftPreset.generate_metrics,presets/drift.py:128
] + [
ValueDrift(
column=column,
method=self._get_drift_stattest(column, False, col_type, options), # 按列型选检验
threshold=options.get_threshold(column, col_type.value),
)
for column in (self.columns or context.data_definition.get_columns(types))
]

边界: 这里只展示 DataDriftPreset 如何把「一句声明」展开成每列一个 ValueDriftValueDrift 内部到底用哪种统计检验、怎么判分布是否漂移,是第 04 章的内容,本章不展开。


8. 机制 F:Test 条件体系 —— 把 Report 变成 Test Suite

它要解决的小问题

算出一个数只是第一步。真正想要的是「准确率 < 0.8 就报警」。Test 就是给结果加 pass/fail 条件的那层,加上它,一份 Report 就变成一套会判红判绿的 Test Suite。

三层类型:从「条件」到「绑定」到「结果」

用户写的条件 挂到某度量的某个值 跑出来的判定
┌────────────┐ bind_* ┌──────────────┐ run_test ┌────────────────┐
│ MetricTest │ ──────▶ │ BoundTest │ ───────▶ │ MetricTestResult│
│ gt/lt/eq… │ │ ·记住绑哪个 │ │ ·PASS/FAIL/WARN │
│ 阈值+is_crit│ │ metric+子值 │ │ ·description │
└────────────┘ └──────────────┘ └────────────────┘
:1008 :1080 :381
概念是什么定义
MetricTest一个未绑定的条件(如「> 0.8」),知道怎么判但还不知道判谁core/metric_types.py:1008
BoundTest把条件绑到某度量的某个子值上(哪个 fingerprint、哪个 label / count 还是 share):1080
MetricTestResult跑完的结论:状态 + 描述:381

怎么绑:bind_* 家族

MetricTest 有一组 bind_* 方法,对应机制 A 的每种结果形状:bind_single(:1055)绑到单值、bind_by_label(:1063)绑到某标签、bind_count(:1059)绑到 count 还是 share……每个 bind_* 造出对应的 BoundTest 子类(如 SingleValueBoundTest :1254),这些子类的 run_test 负责「从结果里取出该判的那个值,再跑条件」。例如按标签绑定的 ByLabelBoundTest.run_test(:1336)会先 get_label_result(label) 取出那一格再判。

加条件的两种入口

入口一:显式挂 tests。 各形状的 Metric 子类带一个 tests 字段,get_bound_tests 把它们逐个 bind。看 SingleValueMetric.get_bound_tests(core/metric_types.py:1299):

# 摘自 SingleValueMetric.get_bound_tests,core/metric_types.py:1299
def get_bound_tests(self, context):
if self.tests is None and context.configuration.include_tests: # 没显式写 → 走默认测试
return self._get_all_default_tests(context)
fingerprint = self.get_fingerprint()
return [t.bind_single(fingerprint) for t in (self.tests or [])] # 显式写了 → 逐个绑

入口二:include_tests=True 自动生成。 若你没写 tests 但报告开了 include_tests,引擎调 _get_all_default_tests(:1186)——它按有没有参照数据分流:有参照走 _default_tests_with_reference,没有走 _default_tests(:1169/:1177)。度量自己决定「默认该怎么判」。

条件长什么样:gt / lt / eq

用户写条件用 evidently.tests 里的别名函数,如 gt(0.8)lt(dv.value)。它们返回一个 GenericTest(core/tests.py:56)——一个同时装着 metric 版和 descriptor 版实现的统一壳(for_metric :71 / for_descriptor :84 各取一面,这样同一个 gt 既能判度量也能判第 01 章的行级 descriptor)。看 gt(tests/aliases.py:170):

# 摘自 gt,tests/aliases.py:170 —— 一个条件,两副实现
def gt(threshold, *, is_critical=True, ...):
return GenericTest(
test_name="gt",
metric=GreaterThanMetricTest(threshold=threshold, is_critical=is_critical, ...), # 判度量
descriptor=DescriptorTest(condition=GreaterColumnCondition(threshold=threshold), ...), # 判行级
)

真正的判定逻辑在 ComparisonTest.to_test(tests/numerical_tests.py:30):它返回一个函数,取出阈值、和实际值比、产出 TestStatus.SUCCESS/FAILis_critical=False 时失败会被降级成 WARNING(见 MetricTest.run core/metric_types.py:1035)。

reference 数据集自动派生条件(巧妙处)

阈值不一定是死数,可以是「相对参照数据的容忍范围」。传一个 Reference(relative=0.1)(core/tests.py:21),ComparisonTest.get_threshold(tests/numerical_tests.py:48)会去参照数据集取同一度量的值,现算出一个 ApproxValue(参照值, ±10%) 当阈值:

# 摘自 get_threshold,tests/numerical_tests.py:48 —— 阈值从参照数据现推
def get_threshold(self, context, metric_location):
if isinstance(self.threshold, Reference):
if context._input_data[1] is None:
raise ValueError("No Reference dataset provided, but tests contains Reference thresholds")
value = metric_location.value(context, DatasetType.Reference).value # 取参照侧的同度量值
return ApproxValue(value, self.threshold.relative, self.threshold.absolute)
return self.threshold

MAE 的默认测试就是活例子(metrics/regression.py):有参照_default_tests_with_reference(:204)判「MAE 别比参照差过 10%」——eq(Reference(relative=0.1)).bind_mean_std(...);无参照_default_tests(:205)先 context.calculate_metric(DummyMAE()...) 算个基线,再 lt(基线值) 判「至少比瞎猜强」。前者用参照派生阈值,后者用机制 C 的度量依赖派生阈值——两条路都不用你手填数字。

container 层的开关

preset 也尊重这套:MetricContainer._get_tests(core/container.py:123)统一处理三态——显式给了就转换、include_tests=True 就返回 None(让度量走默认测试)、关了就返回 [](彻底不测)。所以 preset 展开出的每个 metric 都能自动带上合理的默认判定。


9. 巧妙之处(可带走的技术)

  • 按「结果形状」而非「度量种类」做类型体系。 度量成百上千,但形状只有六种;把渲染、取值、测试、序列化都挂到形状上,新增度量几乎零样板(core/metric_types.py:165 起)。
  • config 与 calculation 分离 + __init_subclass__ 自动配对。 声明可序列化、算法可含副作用,两者靠泛型参数自动登记(:1234),用户只写一个类。
  • 一个 Context 同时是缓存、依赖图、测试运行器。 按 fingerprint 去重让重复度量白算变共享;嵌套 _metrics_graph 让「度量依赖度量」自然成立(core/report.py:191)。
  • widget 兜底自动生成。 度量给对形状就有图,不写渲染代码(call 的第 ③ 步,:954)。
  • 阈值可从参照数据/其它度量现推。 Reference(relative=...)lt(DummyMAE 的值) 让「合格线」不必写死(tests/numerical_tests.py:48)。

10. 边界与局限

  • 不是并行计算引擎。 current/reference 是「一次 calculate 返回两份」,不是多线程;去重靠缓存,不靠调度。
  • 去重粒度是配置指纹。 参数差一点点指纹就不同,不会被合并——想复用就得保证配置完全一致。
  • 依赖图靠「算的时候顺手记」。 _metrics_graph 是执行副产物(:203),不是预先静态分析出来的;没被实际调到的依赖不会出现在图里。
  • 老度量走独立通道。 漂移/摘要 preset 的部分渲染借道 get_legacy_metric(:269)的 v1 体系,有自己的缓存和依赖发现,和 v2 主线不完全统一。
  • 本章不含算法与 LLM。 具体 stattest 见第 04 章;LLM 评委 metric 见第 03 章;快照时序化/监控见第 05 章。

11. 代码地图(导航索引)

主题文件符号
结果基类(display/widget/tests/location)src/evidently/core/metric_types.pyMetricResult
六种结果形状src/evidently/core/metric_types.pySingleValue / ByLabelValue / ByLabelCountValue / CountValue / MeanStdValue / DataframeValue
从结果里按坐标取值src/evidently/core/metric_types.pyMetricValueLocation.extract_value
默认渲染总表src/evidently/core/metric_types.pyget_default_render / get_default_render_ref
度量执行入口(call→calculate→widget)src/evidently/core/metric_types.pyMetricCalculationBase.call
度量配置基类src/evidently/core/metric_types.pyMetric
config↔calculation 自动配对src/evidently/core/metric_types.pyMetricCalculation.__init_subclass__
各形状默认测试分流src/evidently/core/metric_types.pyMetric._get_all_default_tests / SingleValueMetric.get_bound_tests
未绑定条件 / 绑定 / 结果src/evidently/core/metric_types.pyMetricTest / BoundTest / MetricTestResult
引擎心脏:缓存+依赖图+跑测试src/evidently/core/report.pyContext.calculate_metric
current/reference 取值src/evidently/core/report.pyContext.get_metric_result / get_reference_metric_result
结果容器与递归展开src/evidently/core/report.pySnapshot / Snapshot._run_items
报告入口与导出src/evidently/core/report.pyReport.run / Snapshot.save_html / Snapshot.dict
容器契约与展开缓存src/evidently/core/container.pyMetricContainer.generate_metrics / MetricContainer.metrics
全表体检 presetsrc/evidently/presets/dataset_stats.pyDataSummaryPreset / DatasetStats / ValueStats
漂移 preset 展开src/evidently/presets/drift.pyDataDriftPreset.generate_metrics
统一条件壳(度量+descriptor)src/evidently/core/tests.pyGenericTest
条件别名(gt/lt/eq…)src/evidently/tests/aliases.pygt / lt / eq / gte / lte
比较判定 + reference 派生阈值src/evidently/tests/numerical_tests.pyComparisonTest.to_test / ComparisonTest.get_threshold
reference/依赖派生默认测试(实例)src/evidently/metrics/regression.pyMAE._default_tests_with_reference / MAE._default_tests