跳到主要内容

数据模型与 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,产出 DatasetColumn737(基类)、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 / ExampleLLM 场景里的输入、上下文、样例

ColumnInfo(core/datasets.py:74-81)则是把「一个类型 + 一个角色」打包在一起的小 dataclass。

3.2 一个 DataDefinition 里装了什么

DataDefinition 是个 pydantic 模型(core/datasets.py:367),字段可粗分三组:

  1. 按类型分桶的列名清单:numerical_columnscategorical_columnstext_columnsdatetime_columnslist_columnsunknown_columns(core/datasets.py:401-411)。
  2. 单列语义位:id_columntimestampservice_columns(如 trace 链接)(core/datasets.py:395-399)。
  3. 任务配置: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 承载:

任务配置类关键字段位置
BinaryClassificationtarget / prediction_labels / prediction_probas / pos_labelcore/datasets.py:84
MulticlassClassificationtarget / prediction_labels / prediction_probas(每类一列)core/datasets.py:155
Regressiontarget / predictioncore/datasets.py:219
Recsysuser_id / item_id / target / prediction / recommendations_typecore/datasets.py:242
LLMClassificationinput / target / predictions / reasoningcore/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:16311429)。推断规则本身就是一份可读的启发式清单:

  • 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)。这让「列名拼错」在算之前就暴露,而不是算到一半崩。

5.3 四个子类,各解决一类需求

Descriptor 的几个直接子类,构成了一条从「最简单」到「最通用」的谱系:

Descriptor (抽象基类) ──── 契约: generate_data → DatasetColumn

├─ SingleInputDescriptor 只读"一列"的简化基类,固定 list_input_columns=[column]
│ └─ ColumnTest 对一列套条件 → 产出一列布尔"是否通过"

├─ TestSummary 读多列 test 结果 → 汇总成 all/any/count/rate/score

└─ FeatureDescriptor 桥接 legacy 的 GeneratedFeatures,复用老特征工程
子类解决什么位置
SingleInputDescriptor大多数 Descriptor 只读一列;这个基类把 list_input_columns 固定成 [self.column],子类只需写 generate_data793
ColumnTest把一个 ColumnCondition(如「> 100」)套到一列上,逐行产出 True/False 的分类列812
TestSummary多个 test 列横向汇总:每行是否全过 / 有没有失败 / 通过率 / 加权分944
FeatureDescriptor复用 legacy 的 GeneratedFeatures 特征生成器,把老代码接进新 Descriptor 体系1082

5.4 tests:给一个分数挂条件

Descriptor 有个 tests 字段,让你给算出的分数直接挂断言。例如「文本长度 Descriptor」可以挂一条「必须 > 10」。机制是:每个 test 经 get_sub_descriptors() 转成一个 ColumnTest 子 Descriptor(core/datasets.py:783-784,Descriptor.get_sub_descriptors),然后在 add_descriptor 末尾递归地被加进 Dataset(core/datasets.py:1705-1706)。

ColumnTest.generate_data 的实现就是一行 apply(core/datasets.py:836-850,ColumnTest.generate_data):

def generate_data(self, dataset, options):
data = dataset.column(self.column) # 取那一列
res = data.data.apply(self.condition.check) # 逐行判条件 → 布尔
return DatasetColumn(ColumnType.Categorical, res)

ColumnTest 产出的列会被登记进 test_descriptors(core/datasets.py:1700-1703),TestSummary 正是读这些 test_descriptors 来做汇总(core/datasets.py:1008-1012,TestSummary.generate_data)。于是形成一条链:原始列 → Descriptor 算分 → ColumnTest 判条件 → TestSummary 汇总

5.5 FeatureDescriptor:和 legacy 的桥

Evidently 有一大批老的 GeneratedFeatures(legacy 特征工程)。FeatureDescriptor(core/datasets.py:1082)不重写它们,而是在 generate_data 里调 feature.generate_features_renamed(...),把老特征算出的多列包成 {display_name: DatasetColumn} 返回(core/datasets.py:1100-1111,FeatureDescriptor.generate_data)。这是「新抽象吃掉旧实现」的典型适配器,让大量 legacy 能力零改动地在新 Descriptor 体系里复用。


6. 建立直觉:从 TextLength 看一个最简 Descriptor

前面讲了契约,这里用最简单的真实 Descriptor 落地成一张完整的心智图。TextLength(descriptors/_text_length.py:17)是全库最短的 Descriptor 之一,整个类不到 30 行:

class TextLength(Descriptor):
column_name: str
def __init__(self, column_name, alias=None, tests=None):
self.column_name = column_name
super().__init__(alias=alias or "text_length", tests=tests) # 默认列名 text_length

def generate_data(self, dataset, options):
column_items_lengths = dataset.as_dataframe()[self.column_name].apply(_apply)
return DatasetColumn(type=ColumnType.Numerical, data=column_items_lengths) # 数值列

def list_input_columns(self):
return [self.column_name] # 声明我只读这一列 → validate_input 能提前查

对照 5.2 的契约看,它老老实实实现了三件事:

  1. alias:默认输出列叫 text_length(descriptors/_text_length.py:25)。
  2. generate_data:对目标列每行调 _apply(即 len(value),对 None/NaN 返回 0),包成数值 DatasetColumn(descriptors/_text_length.py:27-3037-41)。
  3. list_input_columns:声明只读 column_name 这一列(descriptors/_text_length.py:32-34),于是 validate_input 能在算之前就发现列名写错。

重点看: 它返回的是 ColumnType.NumericalDatasetColumn——所以这列被 add_column 登记进 numerical_descriptors(见 4.3),下游统计和 Metric 就自动把它当数值列处理。整条链在这一个 30 行的类里闭环了。

Descriptor 的家族版图

TextLength 只是最朴素的一个。descriptors/__init__.py 导出的这一众 Descriptor,按算分方式可分几大类(descriptors/__init__.py:33-80):

类别代表 Descriptor算分方式
文本统计TextLengthWordCountSentenceCountOOVWordsPercentage纯本地计算,数字符/词/句
文本匹配ContainsIncludesWordsRegExpExactMatchBeginsWith关键词/正则/前后缀匹配(text_match.py)
结构校验IsValidJSONIsValidPythonIsValidSQLJSONSchemaMatch解析/校验格式合法性
语义/模型SemanticSimilarityBERTScoreHuggingFaceToxicity调本地模型算相似度/毒性
LLM 即评委LLMEvalLLMJudgeCorrectnessLLMEvalPIILLMEval发 prompt 给 LLM 打分 → 详见 03-llm-as-judge
测试/汇总ColumnTestTestSummary套条件、横向汇总 test 结果

关键认知:无论哪一类,对外都是同一个 Descriptor 契约。文本统计一行 Python 出结果,LLM 评委要发一批异步请求,但从 add_descriptor 的视角看它们完全一样——都是「输入 Dataset,输出 DatasetColumn」。这就是这层抽象的价值。


7. 巧妙之处(可借鉴的技术)

  • 类型 + 角色两个维度正交拆开。 不把「这是文本」和「这是模型输出」搅在一起,统计能力(按类型)和评估语义(按角色)各自演化。见 ColumnTypeColumnRole(core/datasets.py:45)。
  • 「显式优先,缺项自动兜底」的合并策略。 你传的 DataDefinition 里哪个类型桶是 None,就用推断结果补哪个,不覆盖你已声明的(core/datasets.py:1585-1610)。既省事又不越权。
  • DatasetColumn 让「数据」永远带着「类型」走。 一列的语义不会在传递中丢失,add_column 才能据此自动登记描述符桶(core/datasets.py:1681)。
  • 算分前先 validate_input 快速失败。 列名拼错在计算前就报出「可用列有 X」,而不是让一次昂贵的(可能是 LLM 的)计算跑到一半崩(core/datasets.py:767)。
  • 列名自动去重。 _determine_descriptor_column_name 让重复 alias 变成 text_length_1,避免默默覆盖已有列(core/datasets.py:1117)。
  • 子测试递归展开。 给 Descriptor 挂 tests,通过 get_sub_descriptors 转成 ColumnTest 再递归 add_descriptor,复用同一条流水线,不为「测试」另造机制(core/datasets.py:7831705)。

8. 边界与局限(这一层刻意不做什么)

  • 只做「行级」。 Descriptor 的契约是「逐行 → 一列」。跨行的聚合(均值、漂移、通过率的分布)不在这层,归 Metric/Report——见 02-metrics-engine
  • 只有 pandas 后端。 Dataset 是抽象的,但 SUPPORTED_FORMATS 和唯一实现都是 PandasDataset(core/datasets.py:1530)。没有 Spark/Polars 的行级实现。
  • get_column_type 缺省即 Unknown 反查不到就归 Unknown(core/datasets.py:560),不会报错——列没被正确声明时,问题可能到统计阶段才浮现。
  • 统计里 missing_values 是占位。 _collect_stats 里缺失值统计直接写 StatCountValue(0, 0)(core/datasets.py:1718),这一层没真正统计缺失。
  • LLM 类 Descriptor 的判官机制不在本章。 它们的 prompt 模板、异步批量引擎属于 03-llm-as-judge

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

全部符号位于 src/evidently/core/datasets.py,除非另注。用符号名 grep 比行号更抗漂移。

主题文件路径符号名
列的语义角色枚举src/evidently/core/datasets.pyColumnRole
类型+角色打包src/evidently/core/datasets.pyColumnInfo
二分类任务配置src/evidently/core/datasets.pyBinaryClassification
多分类任务配置src/evidently/core/datasets.pyMulticlassClassification
回归 / 推荐 / LLM 任务配置src/evidently/core/datasets.pyRegression / Recsys / LLMClassification
列说明书主体src/evidently/core/datasets.pyDataDefinition
列名 → 类型反查src/evidently/core/datasets.pyDataDefinition.get_column_type
自动推断列类型src/evidently/core/datasets.pyinfer_column_type
带类型的列包装src/evidently/core/datasets.pyDatasetColumn
数据容器抽象基类src/evidently/core/datasets.pyDataset
pandas 实现src/evidently/core/datasets.pyPandasDataset
从 DataFrame 造 Datasetsrc/evidently/core/datasets.pyDataset.from_pandas
加描述符 + 写回列src/evidently/core/datasets.pyPandasDataset.add_descriptor / add_column
列名去重src/evidently/core/datasets.py_determine_descriptor_column_name
行级评估器基类src/evidently/core/datasets.pyDescriptor
算分契约src/evidently/core/datasets.pyDescriptor.generate_data
输入列校验src/evidently/core/datasets.pyDescriptor.validate_input
单输入列简化基类src/evidently/core/datasets.pySingleInputDescriptor
条件测试描述符src/evidently/core/datasets.pyColumnTest
测试结果汇总src/evidently/core/datasets.pyTestSummary
桥接 legacy 特征src/evidently/core/datasets.pyFeatureDescriptor
最简 Descriptor 范例src/evidently/descriptors/_text_length.pyTextLength
所有 Descriptor 导出清单src/evidently/descriptors/__init__.py__all__