项目文件夹

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

217 行
8.2 KiB
Python

from __future__ import annotations
import functools
from contextlib import contextmanager, nullcontext
from typing import TYPE_CHECKING, Any, Callable
from pydantic import BaseModel, create_model
from mlflow.entities import Trace
from mlflow.exceptions import MlflowException
from mlflow.genai.scorers import Scorer
from mlflow.genai.scorers.builtin_scorers import BuiltInScorer
from mlflow.genai.scorers.validation import valid_data_for_builtin_scorers
from mlflow.tracking.client import MlflowClient
if TYPE_CHECKING:
import pandas as pd
@contextmanager
def prompt_optimization_autolog(
optimizer_name: str,
num_prompts: int,
num_training_samples: int,
train_data_df: "pd.DataFrame" | None,
):
"""
Context manager for autologging prompt optimization runs.
Args:
optimizer_name: Name of the optimizer being used
num_prompts: Number of prompts being optimized
num_training_samples: Number of training samples
train_data_df: Training data as a pandas DataFrame. If None or empty, it means zero-shot
optimization.
Yields:
Tuple of (run_id, results_dict) where results_dict should be populated with
PromptOptimizerOutput and list of optimized PromptVersion objects
"""
import mlflow.data
active_run = mlflow.active_run()
run_context = mlflow.start_run() if active_run is None else nullcontext(active_run)
with run_context as run:
client = MlflowClient()
run_id = run.info.run_id
mlflow.log_param("optimizer", optimizer_name)
mlflow.log_param("num_prompts", num_prompts)
mlflow.log_param("num_training_samples", num_training_samples)
if train_data_df is not None and not train_data_df.empty:
# Log training dataset as run input if it is provided
dataset = mlflow.data.from_pandas(
train_data_df, source="prompt_optimization_train_data"
)
mlflow.log_input(dataset, context="training")
results = {}
yield results
if "optimized_prompts" in results:
for prompt in results["optimized_prompts"]:
client.link_prompt_version_to_run(run_id=run_id, prompt=prompt)
if "optimizer_output" in results:
output = results["optimizer_output"]
if output.initial_eval_score is not None:
mlflow.log_metric("initial_eval_score", output.initial_eval_score)
if output.final_eval_score is not None:
mlflow.log_metric("final_eval_score", output.final_eval_score)
if output.initial_eval_score_per_scorer:
mlflow.log_metrics({
f"initial_eval_score.{scorer_name}": score
for scorer_name, score in output.initial_eval_score_per_scorer.items()
})
if output.final_eval_score_per_scorer:
mlflow.log_metrics({
f"final_eval_score.{scorer_name}": score
for scorer_name, score in output.final_eval_score_per_scorer.items()
})
def validate_train_data(
train_data: "pd.DataFrame",
scorers: list[Scorer] | None,
predict_fn: Callable[..., Any] | None = None,
) -> None:
"""
Validate that training data has required fields for prompt optimization.
Args:
train_data: Training data as a pandas DataFrame.
scorers: Scorers to validate the training data for. Can be None for zero-shot mode.
predict_fn: The predict function to validate the training data for.
Raises:
MlflowException: If any record is missing required 'inputs' field or it is empty.
"""
for i, record in enumerate(train_data.to_dict("records")):
if "inputs" not in record or not record["inputs"]:
raise MlflowException.invalid_parameter_value(
f"Record {i} is missing required 'inputs' field or it is empty"
)
if scorers is not None:
builtin_scorers = [scorer for scorer in scorers if isinstance(scorer, BuiltInScorer)]
valid_data_for_builtin_scorers(train_data, builtin_scorers, predict_fn)
def infer_type_from_value(value: Any, model_name: str = "Output") -> type:
"""
Infer the type from the value.
Only supports primitive types, lists, and dict and Pydantic models.
"""
if value is None:
return type(None)
elif isinstance(value, (bool, int, float, str)):
return type(value)
elif isinstance(value, list):
if not value:
return list[Any]
element_types = {infer_type_from_value(item) for item in value}
return list[functools.reduce(lambda x, y: x | y, element_types)]
elif isinstance(value, dict):
fields = {k: (infer_type_from_value(v, model_name=k), ...) for k, v in value.items()}
return create_model(model_name, **fields)
elif isinstance(value, BaseModel):
return type(value)
return Any
def create_metric_from_scorers(
scorers: list[Scorer],
objective: Callable[[dict[str, Any]], float] | None = None,
) -> Callable[[Any, Any, dict[str, Any]], tuple[float, dict[str, str], dict[str, float]]]:
"""
Create a metric function from scorers and an optional objective function.
Args:
scorers: List of scorers to evaluate inputs, outputs, and expectations.
objective: Optional function that aggregates scorer outputs into a single score.
Takes a dict mapping scorer names to scores and returns a float.
If None and all scorers return numerical or CategoricalRating values,
uses default aggregation (sum for numerical, conversion for categorical).
Returns:
A callable that takes (inputs, outputs, expectations, trace) and
returns a tuple of (aggregated_score, rationales, individual_scores).
Raises:
MlflowException: If scorers return non-numerical values and no objective is provided.
"""
from mlflow.entities import Feedback
from mlflow.genai.judges import CategoricalRating
def _convert_to_numeric(score: Any) -> float | None:
"""Convert a value to numeric, handling Feedback and primitive types."""
if isinstance(score, Feedback):
score = score.value
if score == CategoricalRating.YES:
return 1.0
elif score == CategoricalRating.NO:
return 0.0
elif isinstance(score, (int, float, bool)):
return float(score)
return None
def metric(
inputs: Any,
outputs: Any,
expectations: dict[str, Any],
trace: Trace | None,
) -> tuple[float, dict[str, str], dict[str, float]]:
scores = {}
rationales = {}
for scorer in scorers:
scores[scorer.name] = scorer.run(
inputs=inputs, outputs=outputs, expectations=expectations, trace=trace
)
for key, score in scores.items():
if isinstance(score, Feedback):
rationales[key] = score.rationale
# Try to convert all scores to numeric
numeric_scores = {}
for name, score in scores.items():
numeric_value = _convert_to_numeric(score)
if numeric_value is not None:
numeric_scores[name] = numeric_value
if objective is not None:
return objective(scores), rationales, numeric_scores
# If all scores were convertible, use sum as default aggregation
if len(numeric_scores) == len(scores):
# We average the scores to get the score between 0 and 1.
aggregated = sum(numeric_scores.values()) / len(numeric_scores)
return aggregated, rationales, numeric_scores
# Otherwise, report error with actual types
non_convertible = {
k: type(v).__name__ for k, v in scores.items() if k not in numeric_scores
}
scorer_details = ", ".join([f"{k} (type: {t})" for k, t in non_convertible.items()])
raise MlflowException(
f"Scorers [{scorer_details}] return non-numerical values that cannot be "
"automatically aggregated. Please provide an `objective` function to aggregate "
"these values into a single score for optimization."
)
return metric