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

623 lines
21 KiB
Python

import json
import sys
from typing import Any, Literal
from unittest.mock import MagicMock, Mock, patch
import pandas as pd
import pytest
import mlflow
from mlflow.entities.assessment_source import AssessmentSource
from mlflow.entities.span import SpanType
from mlflow.entities.trace import Trace
from mlflow.exceptions import MlflowException
from mlflow.genai import scorer
from mlflow.genai.datasets import EvaluationDataset, create_dataset
from mlflow.genai.evaluation.utils import (
_convert_scorer_to_legacy_metric,
_convert_to_eval_set,
_deserialize_trace_column_if_needed,
validate_tags,
)
from mlflow.genai.scorers.builtin_scorers import RelevanceToQuery
from mlflow.utils.spark_utils import is_spark_connect_mode
from tests.genai.conftest import databricks_only
@pytest.fixture(scope="module")
def spark():
# databricks-agents installs databricks-connect
if is_spark_connect_mode():
pytest.skip("Local Spark Session is not supported when databricks-connect is installed.")
from pyspark.sql import SparkSession
with SparkSession.builder.getOrCreate() as spark:
yield spark
def count_rows(data: Any) -> int:
try:
from mlflow.utils.spark_utils import get_spark_dataframe_type
if isinstance(data, get_spark_dataframe_type()):
return data.count()
except Exception:
pass
if isinstance(data, EvaluationDataset):
data = data.to_df()
return len(data)
@pytest.fixture
def sample_dict_data_single():
return [
{
"inputs": {"question": "What is Spark?"},
"outputs": "actual response for first question",
"expectations": {"expected_response": "expected response for first question"},
"tags": {"sample_tag": "value"},
},
]
@pytest.fixture
def sample_dict_data_multiple():
return [
{
"inputs": {"question": "What is Spark?"},
"outputs": "actual response for first question",
"expectations": {"expected_response": "expected response for first question"},
"tags": {"category": "spark"},
},
{
"inputs": {"question": "How can you minimize data shuffling in Spark?"},
"outputs": "actual response for second question",
"expectations": {"expected_response": "expected response for second question"},
"tags": {"category": "spark", "topic": "optimization"},
},
# Some records might not have expectations or tags
{
"inputs": {"question": "What is MLflow?"},
"outputs": "actual response for third question",
"expectations": {},
"tags": {},
},
]
@pytest.fixture
def sample_dict_data_multiple_with_custom_expectations():
return [
{
"inputs": {"question": "What is Spark?"},
"outputs": "actual response for first question",
"expectations": {
"expected_response": "expected response for first question",
"my_custom_expectation": "custom expectation for the first question",
},
},
{
"inputs": {"question": "How can you minimize data shuffling in Spark?"},
"outputs": "actual response for second question",
"expectations": {
"expected_response": "expected response for second question",
"my_custom_expectation": "custom expectation for the second question",
},
},
# Some records might not have all expectations
{
"inputs": {"question": "What is MLflow?"},
"outputs": "actual response for third question",
"expectations": {
"my_custom_expectation": "custom expectation for the third question",
},
},
]
@pytest.fixture
def sample_pd_data(sample_dict_data_multiple):
"""Returns a pandas DataFrame with sample data"""
return pd.DataFrame(sample_dict_data_multiple)
@pytest.fixture
def sample_spark_data(sample_pd_data, spark):
"""Convert pandas DataFrame to PySpark DataFrame"""
return spark.createDataFrame(sample_pd_data)
@pytest.fixture
def sample_spark_data_with_string_columns(sample_pd_data, spark):
# Cast inputs and expectations columns to string
df = sample_pd_data.copy()
df["inputs"] = df["inputs"].apply(json.dumps)
df["expectations"] = df["expectations"].apply(json.dumps)
return spark.createDataFrame(df)
@pytest.fixture
def sample_evaluation_dataset(sample_dict_data_single):
dataset = create_dataset("test")
dataset.merge_records(sample_dict_data_single)
return dataset
_ALL_DATA_FIXTURES = [
"sample_dict_data_single",
"sample_dict_data_multiple",
"sample_dict_data_multiple_with_custom_expectations",
"sample_pd_data",
"sample_spark_data",
"sample_spark_data_with_string_columns",
"sample_evaluation_dataset",
]
class TestModel:
@mlflow.trace(span_type=SpanType.AGENT)
def predict(self, question: str) -> str:
response = self.call_llm(messages=[{"role": "user", "content": question}])
return response["choices"][0]["message"]["content"]
@mlflow.trace(span_type=SpanType.LLM)
def call_llm(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
return {"choices": [{"message": {"role": "assistant", "content": "I don't know"}}]}
def get_test_traces(type=Literal["pandas", "list"]):
model = TestModel()
model.predict("What is MLflow?")
trace_id = mlflow.get_last_active_trace_id()
# Add assessments. Since log_assessment API is not supported in OSS MLflow yet, we
# need to add it to the trace info manually.
source = AssessmentSource(source_id="test", source_type="HUMAN")
# 1. Expectation with reserved name "expected_response"
mlflow.log_expectation(
trace_id=trace_id,
name="expected_response",
value="expected response for first question",
source=source,
)
# 2. Expectation with reserved name "expected_facts"
mlflow.log_expectation(
trace_id=trace_id,
name="expected_facts",
value=["fact1", "fact2"],
source=source,
)
# 3. Expectation with reserved name "guidelines"
mlflow.log_expectation(
trace_id=trace_id,
name="guidelines",
value=["Be polite", "Be kind"],
source=source,
)
# 4. Expectation with custom name "my_custom_expectation"
mlflow.log_expectation(
trace_id=trace_id,
name="my_custom_expectation",
value="custom expectation for the first question",
source=source,
)
# 5. Non-expectation assessment
mlflow.log_feedback(
trace_id=trace_id,
name="feedback",
value="some feedback",
source=source,
)
traces = mlflow.search_traces(return_type=type, order_by=["timestamp_ms ASC"])
return [{"trace": trace} for trace in traces] if type == "list" else traces
@pytest.mark.parametrize("input_type", ["list", "pandas"])
def test_convert_to_legacy_eval_traces(input_type):
sample_data = get_test_traces(type=input_type)
data = _convert_to_eval_set(sample_data)
assert "trace" in data.columns
# "inputs" column should be derived from the trace
assert "inputs" in data.columns
assert list(data["inputs"]) == [{"question": "What is MLflow?"}]
assert data["expectations"][0] == {
"expected_response": "expected response for first question",
"expected_facts": ["fact1", "fact2"],
"guidelines": ["Be polite", "Be kind"],
"my_custom_expectation": "custom expectation for the first question",
}
# Assessment with type "Feedback" should not be present in the transformed data
assert "feedback" not in data.columns
@pytest.mark.parametrize("data_fixture", _ALL_DATA_FIXTURES)
def test_convert_to_eval_set_has_no_errors(data_fixture, request):
sample_data = request.getfixturevalue(data_fixture)
transformed_data = _convert_to_eval_set(sample_data)
assert "inputs" in transformed_data.columns
assert "outputs" in transformed_data.columns
assert "expectations" in transformed_data.columns
def test_convert_to_eval_set_without_request_and_response():
for _ in range(3):
with mlflow.start_span():
pass
trace_df = mlflow.search_traces()
trace_df = trace_df[["trace"]]
transformed_data = _convert_to_eval_set(trace_df)
assert "inputs" in transformed_data.columns
assert "outputs" in transformed_data.columns
assert transformed_data["inputs"].isna().all()
def test_convert_to_eval_set_with_missing_root_span():
# Create traces
for _ in range(2):
with mlflow.start_span():
pass
trace_df = mlflow.search_traces()
trace_df = trace_df[["trace"]]
# Deserialize the trace from JSON string to Trace object
trace_df["trace"] = trace_df["trace"].apply(
lambda t: Trace.from_json(t) if isinstance(t, str) else t
)
# Mock _get_root_span to return None for the first trace to simulate missing root span
with patch.object(trace_df["trace"].iloc[0].data, "_get_root_span", return_value=None):
transformed_data = _convert_to_eval_set(trace_df)
# Verify inputs and outputs columns exist
assert "inputs" in transformed_data.columns
assert "outputs" in transformed_data.columns
# Verify first trace has None for inputs/outputs (missing root span)
assert transformed_data["inputs"].iloc[0] is None
assert transformed_data["outputs"].iloc[0] is None
# Verify second trace has None for inputs/outputs (normal empty span behavior)
assert transformed_data["inputs"].iloc[1] is None
assert transformed_data["outputs"].iloc[1] is None
def test_convert_to_legacy_eval_raise_for_invalid_json_columns(spark):
# Data with invalid `inputs` column
df = spark.createDataFrame([
{"inputs": "invalid json", "expectations": '{"expected_response": "expected"}'},
{"inputs": "invalid json", "expectations": '{"expected_response": "expected"}'},
])
with pytest.raises(MlflowException, match="Failed to parse `inputs` column."):
_convert_to_eval_set(df)
# Data with invalid `expectations` column
df = spark.createDataFrame([
{
"inputs": '{"question": "What is the capital of France?"}',
"expectations": "invalid expectations",
},
{
"inputs": '{"question": "What is the capital of Germany?"}',
"expectations": "invalid expectations",
},
])
with pytest.raises(MlflowException, match="Failed to parse `expectations` column."):
_convert_to_eval_set(df)
def _trace_test_cases():
data = {
"info": {
"trace_id": "test-trace-id",
"trace_location": {
"type": "MLFLOW_EXPERIMENT",
"mlflow_experiment": {"experiment_id": "0"},
},
"request_time": "2024-01-21T12:00:00Z",
"state": "OK",
"trace_metadata": {},
"tags": {},
"assessments": [],
},
"data": {"spans": []},
}
return [
pytest.param(data, dict, id="dict"),
pytest.param(json.dumps(data), str, id="string"),
pytest.param(Trace.from_dict(data), Trace, id="trace_object"),
]
@pytest.mark.parametrize(("trace_value", "expected_input_type"), _trace_test_cases())
def test_deserialize_trace_column(trace_value, expected_input_type):
df = pd.DataFrame([{"trace": trace_value, "inputs": {"question": "test"}}])
assert isinstance(df["trace"].iloc[0], expected_input_type)
result = _deserialize_trace_column_if_needed(df)
assert isinstance(result["trace"].iloc[0], Trace)
assert result["trace"].iloc[0].info.trace_id == "test-trace-id"
def test_deserialize_trace_column_with_none():
df = pd.DataFrame([{"trace": None, "inputs": {"question": "test"}}])
result = _deserialize_trace_column_if_needed(df)
assert result["trace"].iloc[0] is None
@pytest.mark.parametrize("data_fixture", _ALL_DATA_FIXTURES)
def test_scorer_receives_correct_data(data_fixture, request):
sample_data = request.getfixturevalue(data_fixture)
received_args = []
@scorer
def dummy_scorer(inputs, outputs, expectations):
received_args.append((
inputs["question"],
outputs,
expectations.get("expected_response"),
expectations.get("my_custom_expectation"),
))
return 0
mlflow.genai.evaluate(
data=sample_data,
scorers=[dummy_scorer],
)
all_inputs, all_outputs, all_expectations, all_custom_expectations = zip(*received_args)
row_count = count_rows(sample_data)
expected_inputs = [
"What is Spark?",
"How can you minimize data shuffling in Spark?",
"What is MLflow?",
][:row_count]
expected_outputs = [
"actual response for first question",
"actual response for second question",
"actual response for third question",
][:row_count]
expected_expectations = [
"expected response for first question",
"expected response for second question",
None,
][:row_count]
assert set(all_inputs) == set(expected_inputs)
assert set(all_outputs) == set(expected_outputs)
assert set(all_expectations) == set(expected_expectations)
if data_fixture == "sample_dict_data_multiple_with_custom_expectations":
expected_custom_expectations = [
"custom expectation for the first question",
"custom expectation for the second question",
"custom expectation for the third question",
]
assert set(all_custom_expectations) == set(expected_custom_expectations)
def test_input_is_required_if_trace_is_not_provided():
with patch("mlflow.genai.evaluation.harness.run") as mock_evaluate:
with pytest.raises(MlflowException, match="inputs.*required"):
mlflow.genai.evaluate(
data=pd.DataFrame({"outputs": ["Paris"]}),
scorers=[RelevanceToQuery()],
)
mock_evaluate.assert_not_called()
mlflow.genai.evaluate(
data=pd.DataFrame({
"inputs": [{"question": "What is the capital of France?"}],
"outputs": ["Paris"],
}),
scorers=[RelevanceToQuery()],
)
mock_evaluate.assert_called_once()
def test_input_is_optional_if_trace_is_provided():
with mlflow.start_span() as span:
span.set_inputs({"question": "What is the capital of France?"})
span.set_outputs("Paris")
trace = mlflow.get_trace(span.trace_id)
with patch("mlflow.genai.evaluation.harness.run") as mock_evaluate:
mlflow.genai.evaluate(
data=pd.DataFrame({"trace": [trace]}),
scorers=[RelevanceToQuery()],
)
mock_evaluate.assert_called_once()
@pytest.mark.parametrize("input_type", ["list", "pandas"])
def test_scorer_receives_correct_data_with_trace_data(input_type, monkeypatch: pytest.MonkeyPatch):
sample_data = get_test_traces(type=input_type)
received_args = []
@scorer
def dummy_scorer(inputs, outputs, expectations, trace):
received_args.append((inputs, outputs, expectations, trace))
return 0
# Disable logging traces to MLflow to avoid calling mlflow APIs which need to be mocked
monkeypatch.setenv("AGENT_EVAL_LOG_TRACES_TO_MLFLOW_ENABLED", "false")
mlflow.genai.evaluate(
data=sample_data,
scorers=[dummy_scorer],
)
inputs, outputs, expectations, trace = received_args[0]
assert inputs == {"question": "What is MLflow?"}
assert outputs == "I don't know"
assert expectations == {
"expected_response": "expected response for first question",
"expected_facts": ["fact1", "fact2"],
"guidelines": ["Be polite", "Be kind"],
"my_custom_expectation": "custom expectation for the first question",
}
assert isinstance(trace, Trace)
@pytest.mark.parametrize("data_fixture", _ALL_DATA_FIXTURES)
def test_predict_fn_receives_correct_data(data_fixture, request):
sample_data = request.getfixturevalue(data_fixture)
received_args = []
def predict_fn(question: str):
received_args.append(question)
return question
@scorer
def dummy_scorer(inputs, outputs):
return 0
mlflow.genai.evaluate(
predict_fn=predict_fn,
data=sample_data,
scorers=[dummy_scorer],
)
received_args.pop(0) # Remove the one-time prediction to check if a model is traced
row_count = count_rows(sample_data)
assert len(received_args) == row_count
expected_contents = [
"What is Spark?",
"How can you minimize data shuffling in Spark?",
"What is MLflow?",
][:row_count]
# Using set because eval harness runs predict_fn in parallel
assert set(received_args) == set(expected_contents)
def test_convert_scorer_to_legacy_metric_aggregations_attribute(monkeypatch):
mock_metric_instance = MagicMock()
# NB: Mocking the behavior of databricks-agents, which does not have the aggregations
# argument for the evaluation interface for a metric.
def mock_metric_decorator(**kwargs):
if "aggregations" in kwargs:
raise TypeError("metric() got an unexpected keyword argument 'aggregations'")
assert set(kwargs.keys()) <= {"eval_fn", "name"}
return mock_metric_instance
mock_evals = Mock(metric=mock_metric_decorator)
mock_evals.judges = Mock() # Add the judges submodule to prevent AttributeError
monkeypatch.setitem(sys.modules, "databricks.agents.evals", mock_evals)
monkeypatch.setitem(sys.modules, "databricks.agents.evals.judges", mock_evals.judges)
mock_scorer = Mock()
mock_scorer.name = "test_scorer"
mock_scorer.aggregations = ["mean", "max", "p90"]
mock_scorer.run = Mock(return_value={"score": 1.0})
result = _convert_scorer_to_legacy_metric(mock_scorer)
assert result.aggregations == ["mean", "max", "p90"]
@databricks_only
def test_convert_scorer_to_legacy_metric():
# Test with a built-in scorer
builtin_scorer = RelevanceToQuery()
legacy_metric = _convert_scorer_to_legacy_metric(builtin_scorer)
# Verify the metric has the _is_builtin_scorer attribute set to True
assert hasattr(legacy_metric, "_is_builtin_scorer")
assert legacy_metric._is_builtin_scorer is True
assert legacy_metric.name == builtin_scorer.name
# Test with a custom scorer
@scorer(name="custom_scorer", aggregations=["mean", "max"])
def custom_scorer_func(inputs, outputs=None, expectations=None, **kwargs):
return {"score": 1.0}
custom_scorer_instance = custom_scorer_func
legacy_metric_custom = _convert_scorer_to_legacy_metric(custom_scorer_instance)
# Verify the metric has the _is_builtin_scorer attribute set to False
assert hasattr(legacy_metric_custom, "_is_builtin_scorer")
assert legacy_metric_custom._is_builtin_scorer is False
assert legacy_metric_custom.name == custom_scorer_instance.name
assert legacy_metric_custom.aggregations == custom_scorer_instance.aggregations
@pytest.mark.parametrize(
"aggregations",
[
["mean", "max", "mean", "median", "variance", "p90"],
[sum, max],
],
)
@databricks_only
def test_scorer_pass_through_aggregations(aggregations):
@scorer(name="custom_scorer", aggregations=aggregations)
def custom_scorer_func(outputs):
return {"score": 1.0}
legacy_metric_custom = _convert_scorer_to_legacy_metric(custom_scorer_func)
assert legacy_metric_custom.name == "custom_scorer"
assert legacy_metric_custom.aggregations == aggregations
builtin_scorer = RelevanceToQuery(aggregations=aggregations)
legacy_metric_builtin = _convert_scorer_to_legacy_metric(builtin_scorer)
assert legacy_metric_builtin.name == "relevance_to_query"
assert legacy_metric_builtin.aggregations == builtin_scorer.aggregations
@pytest.mark.parametrize(
"tags",
[
None,
{},
{"key": "value"},
{"env": "test", "model": "v1.0"},
{"key": 123}, # Values can be any type
{"key1": "value1", "key2": None}, # Values can be any type
],
)
def test_validate_tags_valid(tags):
validate_tags(tags)
@pytest.mark.parametrize(
("tags", "expected_error"),
[
("invalid", "Tags must be a dictionary, got str"),
(123, "Tags must be a dictionary, got int"),
([1, 2, 3], "Tags must be a dictionary, got list"),
({123: "value"}, "Invalid tags:\n - Key 123 has type int; expected str."),
(
{"key1": "value1", 123: "value2"},
"Invalid tags:\n - Key 123 has type int; expected str.",
),
(
{123: "value1", 456: "value2"},
(
"Invalid tags:\n - Key 123 has type int; expected str."
"\n - Key 456 has type int; expected str."
),
),
],
)
def test_validate_tags_invalid(tags, expected_error):
with pytest.raises(MlflowException, match=expected_error):
validate_tags(tags)