Files
2026-07-13 13:22:34 +08:00

632 lines
23 KiB
Python

from __future__ import annotations
import json
from enum import Enum
from typing import TYPE_CHECKING, Any
from mlflow.data import Dataset
from mlflow.data.evaluation_dataset_source import EvaluationDatasetSource
from mlflow.data.pyfunc_dataset_mixin import PyFuncConvertibleDatasetMixin
from mlflow.entities._mlflow_object import _MlflowObject
from mlflow.entities.dataset_record import DatasetRecord
from mlflow.entities.dataset_record_source import DatasetRecordSourceType
from mlflow.exceptions import MlflowException
from mlflow.protos.datasets_pb2 import Dataset as ProtoDataset
from mlflow.telemetry.events import DatasetToDataFrameEvent, MergeRecordsEvent
from mlflow.telemetry.track import record_usage_event
from mlflow.tracing.constant import TraceMetadataKey
from mlflow.tracking.context import registry as context_registry
from mlflow.utils.mlflow_tags import MLFLOW_USER
if TYPE_CHECKING:
import pandas as pd
from mlflow.entities.trace import Trace
SESSION_IDENTIFIER_FIELDS = frozenset({"goal"})
SESSION_INPUT_FIELDS = frozenset({"persona", "goal", "context", "simulation_guidelines"})
SESSION_ALLOWED_COLUMNS = SESSION_INPUT_FIELDS | {"expectations", "tags", "source"}
class DatasetGranularity(Enum):
TRACE = "trace"
SESSION = "session"
UNKNOWN = "unknown"
class EvaluationDataset(_MlflowObject, Dataset, PyFuncConvertibleDatasetMixin):
"""
Evaluation dataset for storing inputs and expectations for GenAI evaluation.
This class supports lazy loading of records - when retrieved via get_evaluation_dataset(),
only metadata is loaded. Records are fetched when to_df() or merge_records() is called.
"""
def __init__(
self,
dataset_id: str,
name: str,
digest: str,
created_time: int,
last_update_time: int,
tags: dict[str, Any] | None = None,
schema: str | None = None,
profile: str | None = None,
created_by: str | None = None,
last_updated_by: str | None = None,
):
"""Initialize the EvaluationDataset."""
self.dataset_id = dataset_id
self.created_time = created_time
self.last_update_time = last_update_time
self.tags = tags
self._schema = schema
self._profile = profile
self.created_by = created_by
self.last_updated_by = last_updated_by
self._experiment_ids = None
self._records = None
source = EvaluationDatasetSource(dataset_id=self.dataset_id)
Dataset.__init__(self, source=source, name=name, digest=digest)
def _compute_digest(self) -> str:
"""
Compute digest for the dataset. This is called by Dataset.__init__ if no digest is provided.
Since we always have a digest from the dataclass initialization, this should not be called.
"""
return self.digest
@property
def source(self) -> EvaluationDatasetSource:
"""Override source property to return the correct type."""
return self._source
@property
def schema(self) -> str | None:
"""
Dataset schema information.
"""
return self._schema
@property
def profile(self) -> str | None:
"""
Dataset profile information.
"""
return self._profile
@property
def experiment_ids(self) -> list[str]:
"""
Get associated experiment IDs, loading them if necessary.
This property implements lazy loading - experiment IDs are only fetched from the backend
when accessed for the first time.
"""
if self._experiment_ids is None:
self._load_experiment_ids()
return self._experiment_ids or []
@experiment_ids.setter
def experiment_ids(self, value: list[str]):
"""Set experiment IDs directly."""
self._experiment_ids = value or []
def _load_experiment_ids(self):
"""Load experiment IDs from the backend."""
from mlflow.tracking._tracking_service.utils import _get_store
tracking_store = _get_store()
self._experiment_ids = tracking_store.get_dataset_experiment_ids(self.dataset_id)
@property
def records(self) -> list[DatasetRecord]:
"""
Get dataset records, loading them if necessary.
This property implements lazy loading - records are only fetched from the backend
when accessed for the first time.
"""
if self._records is None:
from mlflow.tracking._tracking_service.utils import _get_store
tracking_store = _get_store()
# For lazy loading, we want all records (no pagination)
self._records, _ = tracking_store._load_dataset_records(
self.dataset_id, max_results=None
)
return self._records or []
def has_records(self) -> bool:
"""Check if dataset records are loaded without triggering a load."""
return self._records is not None
def _process_trace_records(self, traces: list["Trace"]) -> list[dict[str, Any]]:
"""Convert a list of Trace objects to dataset record dictionaries.
Args:
traces: List of Trace objects to convert
Returns:
List of dictionaries with 'inputs', 'expectations', and 'source' fields
"""
from mlflow.entities.trace import Trace
record_dicts = []
for i, trace in enumerate(traces):
if not isinstance(trace, Trace):
raise MlflowException.invalid_parameter_value(
f"Mixed types in trace list. Expected all elements to be Trace objects, "
f"but element at index {i} is {type(trace).__name__}"
)
root_span = trace.data._get_root_span()
inputs = root_span.inputs if root_span and root_span.inputs is not None else {}
outputs = root_span.outputs if root_span and root_span.outputs is not None else None
expectations = {}
expectation_assessments = trace.search_assessments(type="expectation")
for expectation in expectation_assessments:
expectations[expectation.name] = expectation.value
# Preserve session metadata from the original trace
source_data = {"trace_id": trace.info.trace_id}
if session_id := trace.info.trace_metadata.get(TraceMetadataKey.TRACE_SESSION):
source_data["session_id"] = session_id
record_dict = {
"inputs": inputs,
"outputs": outputs,
"expectations": expectations,
"source": {
"source_type": DatasetRecordSourceType.TRACE.value,
"source_data": source_data,
},
}
record_dicts.append(record_dict)
return record_dicts
def _process_dataframe_records(self, df: "pd.DataFrame") -> list[dict[str, Any]]:
"""Process a DataFrame into dataset record dictionaries.
Args:
df: DataFrame to process. Can be either:
- DataFrame from search_traces with 'trace' column containing Trace objects/JSON
- Standard DataFrame with 'inputs', 'expectations' columns
Returns:
List of dictionaries with 'inputs', 'expectations', and optionally 'source' fields
"""
if "trace" in df.columns:
from mlflow.entities.trace import Trace
traces = [
Trace.from_json(trace_item) if isinstance(trace_item, str) else trace_item
for trace_item in df["trace"]
]
return self._process_trace_records(traces)
else:
return df.to_dict("records")
@record_usage_event(MergeRecordsEvent)
def merge_records(
self, records: list[dict[str, Any]] | "pd.DataFrame" | list["Trace"]
) -> "EvaluationDataset":
"""
Merge new records with existing ones.
Args:
records: Records to merge. Can be:
- List of dictionaries with 'inputs' and optionally 'expectations' and 'tags'
- Session format with 'persona', 'goal', 'context' nested inside 'inputs'
- DataFrame from mlflow.search_traces() - automatically parsed and converted
- DataFrame with 'inputs' column and optionally 'expectations' and 'tags' columns
- List of Trace objects
Returns:
Self for method chaining
Example:
.. code-block:: python
# Direct usage with search_traces DataFrame output
traces_df = mlflow.search_traces() # Returns DataFrame by default
dataset.merge_records(traces_df) # No extraction needed
# Or with standard DataFrame
df = pd.DataFrame([{"inputs": {"q": "What?"}, "expectations": {"a": "Answer"}}])
dataset.merge_records(df)
# Session format in inputs
test_cases = [
{
"inputs": {
"persona": "Student",
"goal": "Find articles",
"context": {"student_id": "U1"},
}
},
]
dataset.merge_records(test_cases)
"""
import pandas as pd
from mlflow.entities.trace import Trace
from mlflow.tracking._tracking_service.utils import _get_store, get_tracking_uri
if isinstance(records, pd.DataFrame):
record_dicts = self._process_dataframe_records(records)
elif isinstance(records, list) and records and isinstance(records[0], Trace):
record_dicts = self._process_trace_records(records)
else:
record_dicts = records
self._validate_record_dicts(record_dicts)
self._infer_source_types(record_dicts)
tracking_store = _get_store()
try:
existing_dataset = tracking_store.get_dataset(self.dataset_id)
self._schema = existing_dataset.schema
except Exception as e:
raise MlflowException.invalid_parameter_value(
f"Cannot add records to dataset {self.dataset_id}: Dataset not found. "
f"Please verify the dataset exists and check your tracking URI is set correctly "
f"(currently set to: {get_tracking_uri()})."
) from e
self._validate_schema(record_dicts)
context_tags = context_registry.resolve_tags()
if user_tag := context_tags.get(MLFLOW_USER):
for record in record_dicts:
if "tags" not in record:
record["tags"] = {}
if MLFLOW_USER not in record["tags"]:
record["tags"][MLFLOW_USER] = user_tag
tracking_store.upsert_dataset_records(dataset_id=self.dataset_id, records=record_dicts)
self._records = None
return self
def _validate_record_dicts(self, record_dicts: list[dict[str, Any]]) -> None:
"""Validate that record dictionaries have the required structure.
Args:
record_dicts: List of record dictionaries to validate
Raises:
MlflowException: If records don't have the required structure
"""
for record in record_dicts:
if not isinstance(record, dict):
raise MlflowException.invalid_parameter_value("Each record must be a dictionary")
if "inputs" not in record:
raise MlflowException.invalid_parameter_value(
"Each record must have an 'inputs' field"
)
def _infer_source_types(self, record_dicts: list[dict[str, Any]]) -> None:
"""Infer source types for records without explicit source information.
Simple inference rules:
- Records with expectations -> HUMAN (manual test cases/ground truth)
- Records with inputs but no expectations -> CODE (programmatically generated)
Inference can be overridden by providing explicit source information.
Note that trace inputs (from List[Trace] or pd.DataFrame of Trace data) will
always be inferred as a trace source type when processing trace records.
Args:
record_dicts: List of record dictionaries to process (modified in place)
"""
for record in record_dicts:
if "source" in record:
continue
if "expectations" in record and record["expectations"]:
record["source"] = {
"source_type": DatasetRecordSourceType.HUMAN.value,
"source_data": {},
}
elif "inputs" in record and "expectations" not in record:
record["source"] = {
"source_type": DatasetRecordSourceType.CODE.value,
"source_data": {},
}
def _validate_schema(self, record_dicts: list[dict[str, Any]]) -> None:
"""
Validate schema consistency of new records and compatibility with existing dataset.
Args:
record_dicts: List of normalized record dictionaries
Raises:
MlflowException: If records have invalid schema, inconsistent schemas within batch,
or are incompatible with existing dataset schema
"""
granularity_counts: dict[DatasetGranularity, int] = {}
has_empty_inputs = False
for record in record_dicts:
input_keys = set(record.get("inputs", {}).keys())
if not input_keys:
has_empty_inputs = True
continue
record_type = self._classify_input_fields(input_keys)
if record_type == DatasetGranularity.UNKNOWN:
session_fields = input_keys & SESSION_IDENTIFIER_FIELDS
other_fields = input_keys - SESSION_INPUT_FIELDS
raise MlflowException.invalid_parameter_value(
f"Invalid input schema: cannot mix session fields {list(session_fields)} "
f"with other fields {list(other_fields)}. "
f"Consider placing {list(other_fields)} fields inside 'context'."
)
granularity_counts[record_type] = granularity_counts.get(record_type, 0) + 1
if len(granularity_counts) > 1:
counts_str = ", ".join(
f"{count} records with {granularity.value} granularity"
for granularity, count in granularity_counts.items()
)
raise MlflowException.invalid_parameter_value(
f"All records must use the same granularity. Found {counts_str}."
)
batch_granularity = next(iter(granularity_counts), DatasetGranularity.UNKNOWN)
existing_granularity = self._get_existing_granularity()
if has_empty_inputs and DatasetGranularity.SESSION in {
batch_granularity,
existing_granularity,
}:
raise MlflowException.invalid_parameter_value(
"Empty inputs are not allowed for session records. The 'goal' field is required."
)
if DatasetGranularity.UNKNOWN in {batch_granularity, existing_granularity}:
return
if batch_granularity != existing_granularity:
raise MlflowException.invalid_parameter_value(
f"New records use {batch_granularity.value} granularity, but existing "
f"dataset uses {existing_granularity.value}. Cannot mix granularities."
)
def _get_existing_granularity(self) -> DatasetGranularity:
"""
Get granularity from the dataset's stored schema.
Returns:
DatasetGranularity based on existing records, or UNKNOWN if empty/unparseable
"""
if self._schema is None:
if self.has_records():
return self._classify_input_fields(set(self.records[0].inputs.keys()))
return DatasetGranularity.UNKNOWN
try:
schema = json.loads(self._schema)
input_keys = set(schema.get("inputs", {}).keys())
return self._classify_input_fields(input_keys)
except (json.JSONDecodeError, TypeError):
return DatasetGranularity.UNKNOWN
@staticmethod
def _classify_input_fields(input_keys: set[str]) -> DatasetGranularity:
"""
Classify a set of input field names into a granularity type:
- SESSION: Has 'goal' field, and only session fields (persona, goal, context)
- TRACE: No 'goal' field present
- UNKNOWN: Empty or has 'goal' mixed with non-session fields
Args:
input_keys: Set of field names from a record's inputs
Returns:
DatasetGranularity classification for the input fields
"""
if not input_keys:
return DatasetGranularity.UNKNOWN
has_session_identifier = bool(input_keys & SESSION_IDENTIFIER_FIELDS)
if not has_session_identifier:
return DatasetGranularity.TRACE
if input_keys <= SESSION_INPUT_FIELDS:
return DatasetGranularity.SESSION
return DatasetGranularity.UNKNOWN
def delete_records(self, record_ids: list[str]) -> int:
"""
Delete specific records from the dataset.
Args:
record_ids: List of record IDs to delete.
Returns:
The number of records deleted.
Example:
.. code-block:: python
# Get record IDs to delete
df = dataset.to_df()
record_ids_to_delete = df["dataset_record_id"].tolist()[:2]
# Delete the records
deleted_count = dataset.delete_records(record_ids_to_delete)
print(f"Deleted {deleted_count} records")
"""
from mlflow.tracking._tracking_service.utils import _get_store
tracking_store = _get_store()
deleted_count = tracking_store.delete_dataset_records(
dataset_id=self.dataset_id,
dataset_record_ids=record_ids,
)
self._records = None # Clear cached records
return deleted_count
@record_usage_event(DatasetToDataFrameEvent)
def to_df(self) -> "pd.DataFrame":
"""
Convert dataset records to a pandas DataFrame.
This method triggers lazy loading of records if they haven't been loaded yet.
Returns:
DataFrame with columns for inputs, outputs, expectations, tags, and metadata
"""
import pandas as pd
records = self.records
if not records:
return pd.DataFrame(
columns=[
"inputs",
"outputs",
"expectations",
"tags",
"source_type",
"source_id",
"source",
"created_time",
"dataset_record_id",
]
)
data = [
{
"inputs": record.inputs,
"outputs": record.outputs,
"expectations": record.expectations,
"tags": record.tags,
"source_type": record.source_type,
"source_id": record.source_id,
"source": record.source,
"created_time": record.created_time,
"dataset_record_id": record.dataset_record_id,
}
for record in records
]
return pd.DataFrame(data)
def to_proto(self) -> ProtoDataset:
"""Convert to protobuf representation."""
proto = ProtoDataset()
proto.dataset_id = self.dataset_id
proto.name = self.name
if self.tags is not None:
proto.tags = json.dumps(self.tags)
if self.schema is not None:
proto.schema = self.schema
if self.profile is not None:
proto.profile = self.profile
proto.digest = self.digest
proto.created_time = self.created_time
proto.last_update_time = self.last_update_time
if self.created_by is not None:
proto.created_by = self.created_by
if self.last_updated_by is not None:
proto.last_updated_by = self.last_updated_by
if self._experiment_ids is not None:
proto.experiment_ids.extend(self._experiment_ids)
return proto
@classmethod
def from_proto(cls, proto: ProtoDataset) -> "EvaluationDataset":
"""Create instance from protobuf representation."""
tags = None
if proto.HasField("tags"):
tags = json.loads(proto.tags)
dataset = cls(
dataset_id=proto.dataset_id,
name=proto.name,
digest=proto.digest,
created_time=proto.created_time,
last_update_time=proto.last_update_time,
tags=tags,
schema=proto.schema if proto.HasField("schema") else None,
profile=proto.profile if proto.HasField("profile") else None,
created_by=proto.created_by if proto.HasField("created_by") else None,
last_updated_by=proto.last_updated_by if proto.HasField("last_updated_by") else None,
)
if proto.experiment_ids:
dataset._experiment_ids = list(proto.experiment_ids)
return dataset
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary representation."""
result = super().to_dict()
result.update({
"dataset_id": self.dataset_id,
"tags": self.tags,
"schema": self.schema,
"profile": self.profile,
"created_time": self.created_time,
"last_update_time": self.last_update_time,
"created_by": self.created_by,
"last_updated_by": self.last_updated_by,
"experiment_ids": self.experiment_ids,
})
result["records"] = [record.to_dict() for record in self.records]
return result
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EvaluationDataset":
"""Create instance from dictionary representation."""
if "dataset_id" not in data:
raise ValueError("dataset_id is required")
if "name" not in data:
raise ValueError("name is required")
if "digest" not in data:
raise ValueError("digest is required")
if "created_time" not in data:
raise ValueError("created_time is required")
if "last_update_time" not in data:
raise ValueError("last_update_time is required")
dataset = cls(
dataset_id=data["dataset_id"],
name=data["name"],
digest=data["digest"],
created_time=data["created_time"],
last_update_time=data["last_update_time"],
tags=data.get("tags"),
schema=data.get("schema"),
profile=data.get("profile"),
created_by=data.get("created_by"),
last_updated_by=data.get("last_updated_by"),
)
if "experiment_ids" in data:
dataset._experiment_ids = data["experiment_ids"]
if "records" in data:
dataset._records = [
DatasetRecord.from_dict(record_data) for record_data in data["records"]
]
return dataset