数据模型与 Descriptor:行级评估怎么把分数长到数据上
30 秒导读: Evidently 评估任何数据前,先要回答两个问题:「哪一列是什么」(用
DataDefinition声明列的类型和角色),以及**「我想给每一行算个什么分」**(用Descriptor逐行算出一列新数据)。这一章讲的就是最底层这两层——数据容器Dataset和行级评估器Descriptor——几乎全部代码集中在src/evidently/core/datasets.py。
本章在这一组文档里的位置:上游是 index(Evidently 是什么、全景),下游是 02-metrics-engine(算完的列怎么被 Metric/Report 聚合)和 03-llm-as-judge(用 LLM 当评委的那类 Descriptor)。本章只讲「单行 → 算出新列」这一层和装数据的容器;聚合与判官留给后面两章。
1. 这是什么(零基础也能懂)
一句话定义
DataDefinition= 一张「列说明书」。它不装数据,只声明「Age是数值列、review是文本列、target是分类任务的真值列」。Dataset= 你的pandas.DataFrame加上那张说明书,打包成 Evidently 能用的对象。Descriptor= 一个「行级评估器」:喂给它一个Dataset,它逐行算出一列新数据(比如「这条文本多长」「这条回答是否包含链接」),这列新数据直接长回原Dataset。
解决什么问题 / 给谁用
假设你有一张表,每行是一次「用户问 + 模型答」。你想知道:模型的回答平均多长?有多少条包含链接?有多少条被 LLM 评委判为「答非所问」?
要回答这些,框架必须先知道哪一列是「模型的回答」——不然它没法把「算长度」这个操作落到正确的列上。这就是 DataDefinition 存在的理由:评估不是对着裸 DataFrame 猜,而是先声明语义,再按语义算。
Descriptor 则是「算分」这一步的统一抽象:无论是「数字符数」这种一行 Python 就能算的,还是「调 LLM 判分」这种要发网络请求的,对外都是同一个契约——输入一个 Dataset,输出一列 DatasetColumn。
用起来什么样
下面这段是 Evidently 最典型的一次「加载 → 加分 → 看结果」,直接摘自源码里的 Dataset 文档字符串(core/datasets.py:1211-1226,Dataset):
from evidently import Dataset, DataDefinition
from evidently.descriptors import TextLength
# 1) 把 DataFrame 包成 Dataset,空的 DataDefinition = 让框架自动推断每列类型
dataset = Dataset.from_pandas(source_df, data_definition=DataDefinition())
# 2) 加一个行级评估器:给 "text" 列的每一行算字符数,结果落成新列
dataset.add_descriptors([TextLength(column_name="text")])
# 3) 现在 as_dataframe() 里就多了一列 text_length
dataset.as_dataframe()
一句话直觉
把 Dataset 想成一张会自己长新列的 Excel 表:Descriptor 是你贴上去的一条「公式」,它对每一行求值,算出的结果作为新的一列自动补到表最右边——而且这列的类型(数值/分类)也一并被登记进那张「列说明书」里。
本节到此不碰任何底层实现。下面开始拆开看它怎么转。
2. 顶层全景(这一层大概怎么转)
三个主角
| 部件 | 干什么 | 在哪(core/datasets.py) |
|---|---|---|
DataDefinition | 声明每列的类型和角色、以及分类/ 回归/排序/LLM 等任务配置 | 367(类)、432(构造) |
Dataset / PandasDataset | 装 DataFrame + DataDefinition 的容器;提供 from_pandas/column/as_dataframe/add_descriptor(s) | 1197(抽象基类)、1530(pandas 实现) |
Descriptor | 行级评估器抽象基类;核心方法 generate_data,产出 DatasetColumn | 737(基类)、633(DatasetColumn) |
一次 add_descriptor 的数据流
怎么读这张图: 从上到下是一次「加一个 Descriptor」的完整流向,命中每一步都在改写同一个 PandasDataset。
你调用: dataset.add_descriptor( TextLength("text") )
│
▼
① 校验输入列存在 Descriptor.validate_input(data_definition)
"text" 在列说明书里吗?不在就报错 core/datasets.py:767
│
▼
② 逐行算分 Descriptor.generate_data(dataset, options)
对 "text" 每行求值 → 一列 DatasetColumn core/datasets.py:761
(TextLength 的实现:每行 len(value)) descriptors/_text_length.py:27
│
▼
③ 起个不撞名的列名 _determine_descriptor_column_name(alias, 现有列)
text_length 撞了就变 text_length_1 core/datasets.py:1117
│
▼
④ 把新列长回表 PandasDataset.add_column(name, DatasetColumn)
写进 _data,并按类型登记进 core/datasets.py:1681
numerical_descriptors / categorical_descriptors
│
▼
⑤ 处理附带的 tests descriptor.get_sub_descriptors() → 再递归 add_descriptor
(给这个分数挂条件时才有) core/datasets.py:1705
主线一句话:声明列(DataDefinition)→ 校验 → 逐行算(generate_data)→ 起名 → 写回 Dataset。 下面三节分别把这三个主角拆开。
3. 第一层:DataDefinition —— 先告诉框架「哪列是什么」
本节讲清:为什么评估前非得有这张「列说明书」,它到底登记了哪两类信息。
3.1 两个正交的维度:类型 vs 角色
一列数据有两个独立的属性,DataDefinition 把它们分开管:
- 类型(
ColumnType)——这列的数据长什么样:数值、分类、文本、时间、列表、ID…… 决定「能对它做什么统计」。 - 角色(
ColumnRole)——这列在评估里扮演什么:真值(Target)、模型输出(Output)、特征(Feature)、描述符(Descriptor)、用户/物品 ID……
ColumnRole 是个枚举,列出了 Evidently 认识的所有语义角色(core/datasets.py:45-71,ColumnRole):
| 角色 | 含义 |
|---|---|
Target | 真值 / ground truth |
Output | 模型输出 / 预测 |
Feature | 用于预测的特征 |
Descriptor | 计算出来的描述符列(本章的产物) |
UserId / ItemId | 排序/推荐里的用户、物品 ID |
Input / Context / Example | LLM 场景里的输入、上下文、样例 |
ColumnInfo(core/datasets.py:74-81)则是把「一个类型 + 一个角色」打包在一起的小 dataclass。
3.2 一个 DataDefinition 里装了什么
DataDefinition 是个 pydantic 模型(core/datasets.py:367),字段可粗分三组:
- 按类型分桶的列名清单:
numerical_columns、categorical_columns、text_columns、datetime_columns、list_columns、unknown_columns(core/datasets.py:401-411)。 - 单列语义位:
id_column、timestamp、service_columns(如 trace 链接)(core/datasets.py:395-399)。 - 任务配置:
classification/regression/ranking/llm,以及描述符专用的numerical_descriptors/categorical_descriptors/test_descriptors(core/datasets.py:413-427)。
有了这些,get_column_type 就能反查任意列名的类型——它按「数值→分类→文本→时间→未知→列表→时间戳→ID→特殊列」的顺序逐桶查找,查不到就归为 Unknown(core/datasets.py:531-560,DataDefinition.get_column_type)。这个反查是后面所有统计和 Descriptor 判断类型的地基。
3.3 任务配置类:为什么分类/回归要单独声明
光知道「target 是分类列」还不够——二分类要知道正类是哪个值、概率列在哪;多分类要知道每个类的概率列各是哪列。这些结构化信息由一组 dataclass 承载:
| 任务配置类 | 关键字段 | 位置 |
|---|---|---|
BinaryClassification | target / prediction_labels / prediction_probas / pos_label | core/datasets.py:84 |
MulticlassClassification | target / prediction_labels / prediction_probas(每类一列) | core/datasets.py:155 |
Regression | target / prediction | core/datasets.py:219 |
Recsys | user_id / item_id / target / prediction / recommendations_type | core/datasets.py:242 |
LLMClassification | input / target / predictions / reasoning | core/datasets.py:281 |
有个贴心细节:BinaryClassification 的构造函数在完全不传参时会给一套默认映射(target="target"、prediction_probas="prediction"、pos_label=1);但只要你传了部分参数,就强制要求 target 加上 labels/probas 至少一个,否则直接抛错(core/datasets.py:131-147,BinaryClassification.__init__)。这是「要么全默认、要么说清楚」的防呆设计。
3.4 不想手写?自动推断兜底
多数时候你不必逐列声明。传一个空的 DataDefinition(),PandasDataset 会调 _generate_data_definition 遍历每列、用 infer_column_type 猜类型(core/datasets.py:1631 与 1429)。推断规则本身就是一份可读的启发式清单:
float→ 数值;int且去重后 ≤ 10 个值 → 分类,否则数值(INTEGER_CARDINALITY_LIMIT = 10,core/datasets.py:1426)。- 字符串列:唯一值超过总数一半 → 文本(像自由文本),否则分类(像枚举标签)(
core/datasets.py:1437-1441)。 object列:看首尾元素是str还是list/tuple,分别判成文本/分类或列表(core/datasets.py:1442-1453)。
关键点:即使你显式传了 DataDefinition,只要某个类型桶是 None,构造时也会用推断结果去补齐那个桶(core/datasets.py:1585-1610)——显式声明优先,缺的地方自动兜底。
4. 第二层:Dataset 与 PandasDataset —— 装数据的容器
本节讲清:Dataset 对外暴露哪几个动作,以及 DatasetColumn 这个「带类型的列」为什么重要。
4.1 Dataset 的公开契约
Dataset(core/datasets.py:1197)是抽象基类,定义了一组抽象方法,PandasDataset(core/datasets.py:1530)是唯一的 pandas 实现。你几乎只跟这几个方法打交道: