218 lines
7.5 KiB
Python
218 lines
7.5 KiB
Python
import re
|
|
from unittest import mock
|
|
|
|
import click
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
import mlflow
|
|
from mlflow.cli.eval import evaluate_traces
|
|
from mlflow.entities import Trace, TraceInfo
|
|
from mlflow.genai.scorers.base import scorer
|
|
|
|
|
|
def test_evaluate_traces_with_single_trace_table_output():
|
|
experiment_id = mlflow.create_experiment("test_experiment")
|
|
|
|
mock_trace = mock.Mock(spec=Trace)
|
|
mock_trace.info = mock.Mock(spec=TraceInfo)
|
|
mock_trace.info.trace_id = "tr-test-123"
|
|
mock_trace.info.experiment_id = experiment_id
|
|
|
|
mock_results = mock.Mock()
|
|
mock_results.run_id = "run-eval-456"
|
|
mock_results.result_df = pd.DataFrame([
|
|
{
|
|
"trace_id": "tr-test-123",
|
|
"assessments": [
|
|
{
|
|
"assessment_name": "RelevanceToQuery",
|
|
"feedback": {"value": "yes"},
|
|
"rationale": "The answer is relevant",
|
|
"metadata": {"mlflow.assessment.sourceRunId": "run-eval-456"},
|
|
}
|
|
],
|
|
}
|
|
])
|
|
|
|
with (
|
|
mock.patch(
|
|
"mlflow.cli.eval.MlflowClient.get_trace", return_value=mock_trace
|
|
) as mock_get_trace,
|
|
mock.patch("mlflow.cli.eval.evaluate", return_value=mock_results) as mock_evaluate,
|
|
):
|
|
evaluate_traces(
|
|
experiment_id=experiment_id,
|
|
trace_ids="tr-test-123",
|
|
scorers="RelevanceToQuery",
|
|
output_format="table",
|
|
)
|
|
|
|
mock_get_trace.assert_called_once_with("tr-test-123", display=False)
|
|
|
|
assert mock_evaluate.call_count == 1
|
|
call_args = mock_evaluate.call_args
|
|
assert "data" in call_args.kwargs
|
|
|
|
expected_df = pd.DataFrame([{"trace_id": "tr-test-123", "trace": mock_trace}])
|
|
pd.testing.assert_frame_equal(call_args.kwargs["data"], expected_df)
|
|
|
|
assert "scorers" in call_args.kwargs
|
|
assert len(call_args.kwargs["scorers"]) == 1
|
|
assert call_args.kwargs["scorers"][0].__class__.__name__ == "RelevanceToQuery"
|
|
|
|
|
|
def test_evaluate_traces_with_multiple_traces_json_output():
|
|
experiment = mlflow.create_experiment("test_experiment_multi")
|
|
|
|
mock_trace1 = mock.Mock(spec=Trace)
|
|
mock_trace1.info = mock.Mock(spec=TraceInfo)
|
|
mock_trace1.info.trace_id = "tr-test-1"
|
|
mock_trace1.info.experiment_id = experiment
|
|
|
|
mock_trace2 = mock.Mock(spec=Trace)
|
|
mock_trace2.info = mock.Mock(spec=TraceInfo)
|
|
mock_trace2.info.trace_id = "tr-test-2"
|
|
mock_trace2.info.experiment_id = experiment
|
|
|
|
mock_results = mock.Mock()
|
|
mock_results.run_id = "run-eval-789"
|
|
mock_results.result_df = pd.DataFrame([
|
|
{
|
|
"trace_id": "tr-test-1",
|
|
"assessments": [
|
|
{
|
|
"assessment_name": "Correctness",
|
|
"feedback": {"value": "correct"},
|
|
"rationale": "Content is correct",
|
|
"metadata": {"mlflow.assessment.sourceRunId": "run-eval-789"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"trace_id": "tr-test-2",
|
|
"assessments": [
|
|
{
|
|
"assessment_name": "Correctness",
|
|
"feedback": {"value": "correct"},
|
|
"rationale": "Also correct",
|
|
"metadata": {"mlflow.assessment.sourceRunId": "run-eval-789"},
|
|
}
|
|
],
|
|
},
|
|
])
|
|
|
|
with (
|
|
mock.patch(
|
|
"mlflow.cli.eval.MlflowClient.get_trace",
|
|
side_effect=[mock_trace1, mock_trace2],
|
|
) as mock_get_trace,
|
|
mock.patch("mlflow.cli.eval.evaluate", return_value=mock_results) as mock_evaluate,
|
|
):
|
|
evaluate_traces(
|
|
experiment_id=experiment,
|
|
trace_ids="tr-test-1,tr-test-2",
|
|
scorers="Correctness",
|
|
output_format="json",
|
|
)
|
|
|
|
assert mock_get_trace.call_count == 2
|
|
mock_get_trace.assert_any_call("tr-test-1", display=False)
|
|
mock_get_trace.assert_any_call("tr-test-2", display=False)
|
|
|
|
assert mock_evaluate.call_count == 1
|
|
call_args = mock_evaluate.call_args
|
|
expected_df = pd.DataFrame([
|
|
{"trace_id": "tr-test-1", "trace": mock_trace1},
|
|
{"trace_id": "tr-test-2", "trace": mock_trace2},
|
|
])
|
|
pd.testing.assert_frame_equal(call_args.kwargs["data"], expected_df)
|
|
|
|
|
|
def test_evaluate_traces_with_nonexistent_trace():
|
|
experiment = mlflow.create_experiment("test_experiment_error")
|
|
|
|
with mock.patch("mlflow.cli.eval.MlflowClient.get_trace", return_value=None) as mock_get_trace:
|
|
with pytest.raises(click.UsageError, match="Trace with ID 'tr-nonexistent' not found"):
|
|
evaluate_traces(
|
|
experiment_id=experiment,
|
|
trace_ids="tr-nonexistent",
|
|
scorers="RelevanceToQuery",
|
|
output_format="table",
|
|
)
|
|
|
|
mock_get_trace.assert_called_once_with("tr-nonexistent", display=False)
|
|
|
|
|
|
def test_evaluate_traces_with_trace_from_wrong_experiment():
|
|
experiment1 = mlflow.create_experiment("test_experiment_1")
|
|
experiment2 = mlflow.create_experiment("test_experiment_2")
|
|
|
|
mock_trace = mock.Mock(spec=Trace)
|
|
mock_trace.info = mock.Mock(spec=TraceInfo)
|
|
mock_trace.info.trace_id = "tr-test-123"
|
|
mock_trace.info.experiment_id = experiment2
|
|
|
|
with mock.patch(
|
|
"mlflow.cli.eval.MlflowClient.get_trace", return_value=mock_trace
|
|
) as mock_get_trace:
|
|
with pytest.raises(click.UsageError, match="belongs to experiment"):
|
|
evaluate_traces(
|
|
experiment_id=experiment1,
|
|
trace_ids="tr-test-123",
|
|
scorers="RelevanceToQuery",
|
|
output_format="table",
|
|
)
|
|
|
|
mock_get_trace.assert_called_once_with("tr-test-123", display=False)
|
|
|
|
|
|
def test_evaluate_traces_integration():
|
|
experiment_id = mlflow.create_experiment("test_experiment_integration")
|
|
mlflow.set_experiment(experiment_id=experiment_id)
|
|
|
|
# Create a few real traces with inputs and outputs
|
|
trace_ids = []
|
|
for i in range(3):
|
|
with mlflow.start_span(name=f"test_span_{i}") as span:
|
|
span.set_inputs({"question": f"What is test {i}?"})
|
|
span.set_outputs(f"This is answer {i}")
|
|
trace_ids.append(span.trace_id)
|
|
|
|
# Define a simple code-based scorer inline
|
|
@scorer
|
|
def simple_scorer(outputs):
|
|
"""Extract the digit from the output string and return it as the score"""
|
|
if match := re.search(r"\d+", outputs):
|
|
return float(match.group())
|
|
return 0.0
|
|
|
|
with mock.patch(
|
|
"mlflow.cli.eval.resolve_scorers", return_value=[simple_scorer]
|
|
) as mock_resolve:
|
|
evaluate_traces(
|
|
experiment_id=experiment_id,
|
|
trace_ids=",".join(trace_ids),
|
|
scorers="simple_scorer", # This will be intercepted by our mock
|
|
output_format="table",
|
|
)
|
|
mock_resolve.assert_called_once()
|
|
|
|
# Verify that the evaluation results are as expected
|
|
traces = mlflow.search_traces(locations=[experiment_id], return_type="list")
|
|
assert len(traces) == 3
|
|
|
|
# Sort traces by their outputs to get consistent ordering
|
|
traces = sorted(traces, key=lambda t: t.data.spans[0].outputs)
|
|
|
|
for i, trace in enumerate(traces):
|
|
assessments = trace.info.assessments
|
|
assert len(assessments) > 0
|
|
|
|
scorer_assessments = [a for a in assessments if a.name == "simple_scorer"]
|
|
assert len(scorer_assessments) == 1
|
|
|
|
assessment = scorer_assessments[0]
|
|
# Each trace should have a score equal to its index (0, 1, 2)
|
|
assert assessment.value == float(i)
|