项目文件夹

文件
2026-07-13 13:22:34 +08:00

404 行
15 KiB
Python

from __future__ import annotations
import contextlib
import hashlib
import io
import logging
import os
from collections.abc import Callable
from typing import TYPE_CHECKING, Literal
import mlflow
if TYPE_CHECKING:
from mlflow.genai.datasets import EvaluationDataset
from mlflow.demo.base import (
DEMO_EXPERIMENT_NAME,
BaseDemoGenerator,
DemoFeature,
DemoResult,
)
from mlflow.demo.data import EXPECTED_ANSWERS
from mlflow.demo.generators.traces import DEMO_TRACE_TYPE_TAG, DEMO_VERSION_TAG, TracesDemoGenerator
from mlflow.entities.assessment import AssessmentSource, Expectation, Feedback
from mlflow.entities.trace import Trace
from mlflow.entities.view_type import ViewType
from mlflow.genai.datasets import create_dataset, delete_dataset, search_datasets
from mlflow.genai.scorers import scorer
_logger = logging.getLogger(__name__)
@contextlib.contextmanager
def _suppress_evaluation_output():
"""Suppress tqdm progress bars and evaluation completion messages."""
original_tqdm_disable = os.environ.get("TQDM_DISABLE")
os.environ["TQDM_DISABLE"] = "1"
try:
# Suppress both stdout (evaluation messages) and stderr (tqdm progress bars)
with (
contextlib.redirect_stdout(io.StringIO()),
contextlib.redirect_stderr(io.StringIO()),
):
yield
finally:
if original_tqdm_disable is None:
os.environ.pop("TQDM_DISABLE", None)
else:
os.environ["TQDM_DISABLE"] = original_tqdm_disable
DEMO_DATASET_TRACE_LEVEL_NAME = "demo-trace-level-dataset"
DEMO_DATASET_BASELINE_SESSION_NAME = "demo-baseline-session-dataset"
DEMO_DATASET_IMPROVED_SESSION_NAME = "demo-improved-session-dataset"
def _get_relevance_rationale(is_relevant: bool) -> str:
if is_relevant:
return "The response directly addresses the question with relevant information."
return "The response is not sufficiently relevant to the question asked."
def _get_correctness_rationale(is_correct: bool) -> str:
if is_correct:
return "The response accurately captures the key information from the expected answer."
return (
"The response contains relevant information but differs "
"significantly from the expected answer."
)
def _get_groundedness_rationale(is_grounded: bool) -> str:
if is_grounded:
return "The response is well-grounded in the provided context with clear references."
return "The response includes claims not supported by the provided context."
def _get_safety_rationale(is_safe: bool) -> str:
if is_safe:
return "The response contains no harmful, offensive, or inappropriate content."
return "The response may contain potentially harmful or inappropriate content."
def _create_quality_aware_scorer(
name: str,
baseline_pass_rate: float,
improved_pass_rate: float,
rationale_fn: Callable[[bool], str],
):
"""Create a deterministic scorer that simulates quality-aware evaluation.
The scorer detects response quality based on content characteristics:
- Longer, more detailed responses get evaluated with higher pass rates
- Shorter, less detailed responses get evaluated with lower pass rates
This simulates the real-world scenario where improved model outputs
naturally score better when evaluated by the same scorers.
"""
quality_threshold = 400
@scorer(name=name)
def quality_aware_scorer(inputs, outputs, trace) -> Feedback:
content = str(inputs) + str(outputs)
output_str = str(outputs)
if len(output_str) > quality_threshold:
effective_pass_rate = improved_pass_rate
else:
effective_pass_rate = baseline_pass_rate
# Use content hash for deterministic but varied results
hash_input = f"{content}:{name}"
hash_val = int(hashlib.md5(hash_input.encode(), usedforsecurity=False).hexdigest()[:8], 16)
normalized = hash_val / 0xFFFFFFFF
is_passing = normalized < effective_pass_rate
# Use the trace timestamp so the quality overview chart shows a trend
# across days instead of a single dot at the current time.
trace_timestamp_ms = trace.info.timestamp_ms if trace else None
return Feedback(
value="yes" if is_passing else "no",
rationale=rationale_fn(is_passing),
source=AssessmentSource(
source_type="LLM_JUDGE",
source_id=f"judges/{name}",
),
create_time_ms=trace_timestamp_ms,
last_update_time_ms=trace_timestamp_ms,
)
return quality_aware_scorer
SCORER_PASS_RATES = {
"relevance": {"baseline": 0.65, "improved": 0.92},
"correctness": {"baseline": 0.58, "improved": 0.88},
"groundedness": {"baseline": 0.52, "improved": 0.85},
"safety": {"baseline": 0.95, "improved": 1.0},
}
class EvaluationDemoGenerator(BaseDemoGenerator):
"""Generates demo evaluation data.
Creates:
- Ground truth expectations on all demo traces
- Three datasets and evaluation runs, each in a single mode:
- trace-level-evaluation: non-session traces (v1 + v2 combined)
- baseline-session-evaluation: v1 session traces
- improved-session-evaluation: v2 session traces
Assessment timestamps are spread to match trace timestamps so the
quality overview chart shows a trend across days.
"""
name = DemoFeature.EVALUATION
version = 2
def generate(self) -> DemoResult:
traces_generator = TracesDemoGenerator()
if not traces_generator.is_generated():
traces_generator.generate()
traces_generator.store_version()
experiment = mlflow.get_experiment_by_name(DEMO_EXPERIMENT_NAME)
experiment_id = experiment.experiment_id
# Fetch traces split by session vs non-session
v1_non_session = self._fetch_demo_traces(experiment_id, "v1", session=False)
v2_non_session = self._fetch_demo_traces(experiment_id, "v2", session=False)
v1_session = self._fetch_demo_traces(experiment_id, "v1", session=True)
v2_session = self._fetch_demo_traces(experiment_id, "v2", session=True)
all_traces = v1_non_session + v2_non_session + v1_session + v2_session
self._add_expectations_to_traces(all_traces)
# Re-fetch to include expectations
v1_non_session = self._fetch_demo_traces(experiment_id, "v1", session=False)
v2_non_session = self._fetch_demo_traces(experiment_id, "v2", session=False)
v1_session = self._fetch_demo_traces(experiment_id, "v1", session=True)
v2_session = self._fetch_demo_traces(experiment_id, "v2", session=True)
trace_level_traces = v1_non_session + v2_non_session
# Create datasets
self._create_evaluation_dataset(
trace_level_traces, experiment_id, DEMO_DATASET_TRACE_LEVEL_NAME
)
self._create_evaluation_dataset(
v1_session, experiment_id, DEMO_DATASET_BASELINE_SESSION_NAME
)
self._create_evaluation_dataset(
v2_session, experiment_id, DEMO_DATASET_IMPROVED_SESSION_NAME
)
# Create evaluation runs
trace_level_run_id = self._create_evaluation_run(
traces=trace_level_traces,
experiment_id=experiment_id,
run_name="trace-level-evaluation",
)
baseline_session_run_id = self._create_evaluation_run(
traces=v1_session,
experiment_id=experiment_id,
run_name="baseline-session-evaluation",
)
improved_session_run_id = self._create_evaluation_run(
traces=v2_session,
experiment_id=experiment_id,
run_name="improved-session-evaluation",
)
return DemoResult(
feature=self.name,
entity_ids=[trace_level_run_id, baseline_session_run_id, improved_session_run_id],
navigation_url=f"#/experiments/{experiment_id}/evaluation-runs",
)
def _data_exists(self) -> bool:
experiment = mlflow.get_experiment_by_name(DEMO_EXPERIMENT_NAME)
if experiment is None or experiment.lifecycle_stage != "active":
return False
try:
client = mlflow.MlflowClient()
runs = client.search_runs(
experiment_ids=[experiment.experiment_id],
filter_string="params.demo = 'true'",
max_results=1,
)
return len(runs) > 0
except Exception:
_logger.debug("Failed to check if evaluation demo exists", exc_info=True)
return False
def delete_demo(self) -> None:
experiment = mlflow.get_experiment_by_name(DEMO_EXPERIMENT_NAME)
if experiment is None:
return
try:
client = mlflow.MlflowClient()
runs = client.search_runs(
experiment_ids=[experiment.experiment_id],
filter_string="params.demo = 'true'",
run_view_type=ViewType.ALL,
max_results=100,
)
for run in runs:
try:
if run.info.lifecycle_stage == "deleted":
client.restore_run(run.info.run_id)
client.delete_run(run.info.run_id)
except Exception:
_logger.debug("Failed to delete run %s", run.info.run_id, exc_info=True)
except Exception:
_logger.debug("Failed to delete evaluation demo runs", exc_info=True)
for name in [
DEMO_DATASET_TRACE_LEVEL_NAME,
DEMO_DATASET_BASELINE_SESSION_NAME,
DEMO_DATASET_IMPROVED_SESSION_NAME,
]:
self._delete_demo_dataset(experiment.experiment_id, name)
def _fetch_demo_traces(
self,
experiment_id: str,
version: Literal["v1", "v2"],
session: bool | None = None,
) -> list[Trace]:
filter_parts = [f"metadata.`{DEMO_VERSION_TAG}` = '{version}'"]
operator = "=" if session else "!="
filter_parts.append(f"metadata.`{DEMO_TRACE_TYPE_TAG}` {operator} 'session'")
return mlflow.search_traces(
locations=[experiment_id],
filter_string=" AND ".join(filter_parts),
max_results=100,
return_type="list",
flush=True,
)
def _add_expectations_to_traces(self, traces: list[Trace]) -> int:
expectation_count = 0
for trace in traces:
trace_id = trace.info.trace_id
trace_timestamp_ms = trace.info.timestamp_ms
root_span = next((span for span in trace.data.spans if span.parent_id is None), None)
if root_span is None:
continue
inputs = root_span.inputs or {}
query = inputs.get("query") or inputs.get("message")
if expected_answer := self._find_expected_answer(query):
try:
expectation = Expectation(
name="expected_response",
value=expected_answer,
source=AssessmentSource(
source_type="HUMAN",
source_id="demo_annotator",
),
metadata={"demo": "true"},
trace_id=trace_id,
create_time_ms=trace_timestamp_ms,
last_update_time_ms=trace_timestamp_ms,
)
mlflow.log_assessment(trace_id=trace_id, assessment=expectation)
expectation_count += 1
except Exception:
_logger.debug("Failed to log expectation for trace %s", trace_id, exc_info=True)
return expectation_count
def _find_expected_answer(self, query: str | None) -> str | None:
if not query:
return None
query_lower = query.lower().strip()
if query_lower in EXPECTED_ANSWERS:
return EXPECTED_ANSWERS[query_lower]
for q, answer in EXPECTED_ANSWERS.items():
if q in query_lower or query_lower in q:
return answer
return None
def _create_evaluation_dataset(
self, traces: list[Trace], experiment_id: str, dataset_name: str
) -> "EvaluationDataset":
from mlflow.genai.datasets import get_dataset
dataset = create_dataset(
name=dataset_name,
experiment_id=experiment_id,
tags={"demo": "true", "description": f"Demo evaluation dataset: {dataset_name}"},
)
dataset.merge_records(traces)
return get_dataset(dataset_id=dataset.dataset_id)
def _delete_demo_dataset(self, experiment_id: str, dataset_name: str) -> None:
datasets = search_datasets(
experiment_ids=[experiment_id],
filter_string=f"name = '{dataset_name}'",
max_results=10,
)
for ds in datasets:
try:
delete_dataset(dataset_id=ds.dataset_id)
except Exception:
_logger.debug("Failed to delete dataset %s", ds.dataset_id, exc_info=True)
def _create_evaluation_run(
self,
traces: list[Trace],
experiment_id: str,
run_name: str,
) -> str:
demo_scorers = [
_create_quality_aware_scorer(
name="relevance",
baseline_pass_rate=SCORER_PASS_RATES["relevance"]["baseline"],
improved_pass_rate=SCORER_PASS_RATES["relevance"]["improved"],
rationale_fn=_get_relevance_rationale,
),
_create_quality_aware_scorer(
name="correctness",
baseline_pass_rate=SCORER_PASS_RATES["correctness"]["baseline"],
improved_pass_rate=SCORER_PASS_RATES["correctness"]["improved"],
rationale_fn=_get_correctness_rationale,
),
_create_quality_aware_scorer(
name="groundedness",
baseline_pass_rate=SCORER_PASS_RATES["groundedness"]["baseline"],
improved_pass_rate=SCORER_PASS_RATES["groundedness"]["improved"],
rationale_fn=_get_groundedness_rationale,
),
_create_quality_aware_scorer(
name="safety",
baseline_pass_rate=SCORER_PASS_RATES["safety"]["baseline"],
improved_pass_rate=SCORER_PASS_RATES["safety"]["improved"],
rationale_fn=_get_safety_rationale,
),
]
mlflow.set_experiment(experiment_id=experiment_id)
with _suppress_evaluation_output():
result = mlflow.genai.evaluate(
data=traces,
scorers=demo_scorers,
)
client = mlflow.MlflowClient()
client.set_tag(result.run_id, "mlflow.runName", run_name)
client.log_param(result.run_id, "demo", "true")
return result.run_id