数据截至 (上游 commit a4aa4c2b37fe)
数据模型与 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 实现。你几乎只跟这几个方法打交道:
| 方法 | 作用 | 位置 |
|---|---|---|
Dataset.from_pandas(df, data_definition, descriptors=…) | 从 DataFrame 造 Dataset;可顺手传一批 descriptors 立即算 | 1242 |
column(name) → DatasetColumn | 取某一列,带类型 | 1625(pandas 实现) |
as_dataframe() → pd.DataFrame | 拿回底层 DataFrame(含已算出的描述符列) | 1622 |
add_descriptor(d) / add_descriptors([...]) | 加一个/一批行级评估器 | 1690 / 1378 |
stats() → DatasetStats | 行数、列数、每列统计摘要 | 1678 |
subdataset(col, label) | 按某列某值过滤出子集(新 Dataset) | 1628 |
注意 from_pandas 的贴心处:它接受一个可选的 descriptors 列表,内部就是「先建 PandasDataset,再 add_descriptors」两步的糖(core/datasets.py:1273-1276,Dataset.from_pandas)。
4.2 DatasetColumn:一列数据 + 它的类型
这是理解整层的关键小类。DatasetColumn(core/datasets.py:633)只做一件事:把一个 pandas.Series 和它的 ColumnType 绑在一起。
# 示意,非源码:DatasetColumn 就是这么朴素的一层包装
class DatasetColumn:
def __init__(self, type, data):
self.type = ColumnType(type) # 数值?分类?文本?
self.data = data # 真正的 pandas.Series
为什么要包这一层?因为**「数据」和「怎么解释这份数据」必须一起走**。Descriptor 算出一列后,不能只丢回一串数字——它得同时说清「这是数值列」还是「这是分类列」,Dataset 才知道往 numerical_descriptors 还是 categorical_descriptors 里登记(见 4.3)。column() 取列时同理,会用 data_definition.get_column_type 现查类型再包成 DatasetColumn(core/datasets.py:1625-1626)。
4.3 add_column:新列怎么被登记
add_descriptor 的最后一步是把算出的 DatasetColumn 写回。看 add_column(core/datasets.py:1681-1688,PandasDataset.add_column)这段真实实现——它不只是 df[key] = data,还顺手更新了列说明书:
def add_column(self, key, data, add_to_descriptor_list=True):
self._dataset_stats.column_count += 1
self._dataset_stats.column_stats[key] = self._collect_stats(data.type, data.data)
self._data[key] = data.data
if add_to_descriptor_list and data.type == ColumnType.Numerical:
self._data_definition.numerical_descriptors.append(key) # 数值描述符登记
if add_to_descriptor_list and data.type == ColumnType.Categorical:
self._data_definition.categorical_descriptors.append(key) # 分类描述符登记
一句话:新列不是凭空贴上去的,它同时被写进数据、更新进统计、登记进 DataDefinition 的描述符桶——所以下游的 Metric 能立刻按类型找到它。这正是标题说的「把分数长到数据上」。
5. 核心机制:Descriptor —— 行级评估器怎么定义
本节是本章的心脏。讲清 Descriptor 这个抽象契约,以及它的几个子类各解决什么。
5.1 它要解决的小问题
「给每一行算个分」听起来简单,但算分方式千差万别:数长度是纯本地计算,判毒性要调模型,匹配关键词要跑正则。Evidently 的做法是用一个抽象基类把所有算分方式收敛成同一个契约,这样 Dataset.add_descriptor 的那套流程(校验→算→写回)对任何 Descriptor 都通用。
5.2 基类契约
Descriptor(core/datasets.py:737)是抽象基类,核心是四件东西:
| 成员 | 是什么 | 位置 |
|---|---|---|
alias 字段 | 输出列的名字(如 "text_length") | 751 |
tests 字段 | 挂在这个分数上的条件测试(见 5.4) | 753 |
generate_data(dataset, options) | 抽象方法:算出一列或多列,返回 DatasetColumn 或 {列名: DatasetColumn} | 761 |
validate_input(data_definition) | 算之前检查所需输入列都在 | 767 |
list_input_columns() / list_output_columns() | 声明这个 Descriptor 读哪些列、产出哪些列 | 780 / 777 |
generate_data 的返回类型很关键:它可以返回单个 DatasetColumn(一进一出),也可以返回一个 {DisplayName: DatasetColumn} 字典(一进多出,比如一个 Descriptor 同时产出 all/any/count 多列)。add_descriptor 会把单列情形统一包成字典再处理(core/datasets.py:1693-1694)。
validate_input 的逻辑也很朴素但有用:拿 list_input_columns() 声明的输入列,逐个检查是否在 data_definition 的所有列里,不在就抛出「列不存在,可用列有 X」的清晰错误(core/datasets.py:767-775,Descriptor.validate_input)。这让「列名拼错」在算之前就暴露,而不是算到一半崩。