Skip to content
Open
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
ceee927
create base metric class
Feb 17, 2026
46b7456
return pre commit config versions
Feb 17, 2026
8c96457
#233 add new classes
Feb 23, 2026
950964c
#232 fix class
Feb 23, 2026
33624b2
#232 fix class
Feb 24, 2026
1053b57
Merge branch 'improvement/232-create-abstract-class-for-metric' of ht…
Feb 24, 2026
27f7888
#232 add aggregation
Feb 24, 2026
c38331a
#232 fix methods
Feb 24, 2026
901dd5a
#232 fix nits
Feb 24, 2026
c4efd79
improvement/233-create-classes-for-metrics add classes
Mar 3, 2026
800f8c0
improvement/232-create-abstract-class-for-metric fixes
Mar 3, 2026
2014e22
improvement/232-create-abstract-class-for-metric fixes
Mar 3, 2026
363551c
improvement/232-create-abstract-class-for-metric remove agent evaluation
Mar 3, 2026
9ab36e2
Merge branch 'improvement/232-create-abstract-class-for-metric' of ht…
Mar 3, 2026
a253f74
improvement/233-create-classes-for-metrics edit classes
Mar 3, 2026
777e00e
improvement/233-create-classes-for-metrics remove old files
Mar 3, 2026
5a55b43
improvement/233-create-classes-for-metrics remove extra files
Mar 3, 2026
c5bb100
improvement/233-create-classes-for-metrics remove changes
Mar 3, 2026
a1fac9f
improvement/233-create-classes-for-metrics remove changes
Mar 3, 2026
4ed5727
move file
Mar 3, 2026
e6f0ef3
move file
Mar 3, 2026
431ae40
refactoring
Mar 3, 2026
6f74153
add script
Mar 10, 2026
773822d
fixes
Mar 10, 2026
5e1b3a6
fixes
Mar 10, 2026
558b588
made fixes
Mar 10, 2026
d78bda3
new structure
Mar 10, 2026
9146421
remove class from other pr
Mar 10, 2026
fe8ea9c
changes after new structure
Mar 10, 2026
d749156
remove main
Mar 10, 2026
8f333e7
changes
Mar 10, 2026
6e6e8b2
changes
Mar 10, 2026
1c802ff
changes
Mar 10, 2026
8335005
simplyfying
Mar 17, 2026
f006930
remove utility class
Mar 17, 2026
e0f3c56
fix get_recommendation_rounds
Mar 17, 2026
4157fdc
fixes
Mar 17, 2026
3b068b4
resolve issues
Mar 24, 2026
d7efc12
Merge branch 'main' of https://github.com/iai-group/UserSimCRS into i…
Mar 24, 2026
8944621
fixes
Mar 24, 2026
335d3bd
Merge branch 'improvement/234-create-main-evaluation-script' of https…
Mar 24, 2026
98564af
Merge branch 'improvement/233-create-classes-for-metrics' of https://…
Mar 24, 2026
8be81e4
234-create-main-evaluation-script add eval script
Mar 24, 2026
a637a40
Merge branch 'main' into improvement/234-create-main-evaluation-script
NoB0 Apr 13, 2026
b3cd18f
fixes
Apr 14, 2026
938ccac
fix evaluation
Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions config/default/config_evaluation.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
dialogues: data/datasets/moviebot/annotated_dialogues.json
Comment thread
dkkdark marked this conversation as resolved.
metrics:
- satisfaction
- success_rate
- successful_recommendation_round_ratio
- reward_per_dialogue_length
output: data/evaluation/moviebot_non_quality_results.json
Comment thread
dkkdark marked this conversation as resolved.
Outdated

quality_llm_interface:
llm_interface_class_path: "usersimcrs.llm_interfaces.ollama_interface.OllamaLLMInterface"
llm_interface_args:
configuration_path: config/llm_interface/config_ollama_default.yaml
default_response: ""
quality_aspects:
- REC_RELEVANCE
- COM_STYLE
- FLUENCY
- CONV_FLOW
- OVERALL_SAT

user_nlu_config: config/default/config_default.yaml
agent_nlu_config: config/default/config_default.yaml
Comment thread
dkkdark marked this conversation as resolved.
Outdated

recommendation_intent_labels:
- REVEAL
- REVEAL.SIMILAR
- REVEAL.NONE
- REVEAL.REVISE
accept_intent_labels:
- NOTE.ACCEPT
reject_intent_labels:
- NOTE.DISLIKE
351 changes: 351 additions & 0 deletions usersimcrs/run_evaluation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
"""Console application for running evaluation."""

import argparse
import json
import os
from collections import defaultdict
from statistics import mean, stdev
from typing import Any, Dict, List, Mapping, Sequence

import confuse
from dialoguekit.core.intent import Intent
from dialoguekit.nlu.models.satisfaction_classifier import (
SatisfactionClassifierSVM,
)
from dialoguekit.utils.dialogue_reader import json_to_dialogues

from usersimcrs.evaluation.dialogue_annotation import annotate_dialogues
from usersimcrs.evaluation.quality_metric import QualityMetric
from usersimcrs.evaluation.quality_rubrics import QualityRubrics
from usersimcrs.evaluation.reward_per_dialogue_length_metric import (
RewardPerDialogueLengthMetric,
)
from usersimcrs.evaluation.satisfaction_metric import SatisfactionMetric
from usersimcrs.evaluation.success_rate_metric import SuccessRateMetric
from usersimcrs.evaluation.successful_recommendation_round_ratio_metric import (
SuccessfulRecommendationRoundRatioMetric,
)
from usersimcrs.utils.simulation_utils import get_NLU, get_llm_interface

DEFAULT_CONFIG_PATH = "config/default/config_evaluation.yaml"
UTILITY_METRICS = {
"success_rate",
"successful_recommendation_round_ratio",
"reward_per_dialogue_length",
}
SUPPORTED_METRICS = [
"quality",
"satisfaction",
"success_rate",
"successful_recommendation_round_ratio",
"reward_per_dialogue_length",
]


def parse_args() -> argparse.Namespace:
"""Defines accepted arguments and returns the parsed values."""
parser = argparse.ArgumentParser(prog="run_evaluation.py")
parser.add_argument(
"-c",
"--config-file",
help=(
"Path to configuration file to overwrite default values. "
"Defaults to None."
),
)
parser.add_argument("--dialogues", type=str, help="Dialogues JSON file.")
parser.add_argument(
"--metrics",
nargs="+",
choices=SUPPORTED_METRICS,
help="Metrics to compute.",
)
parser.add_argument(
"--output",
type=str,
help="Path to save evaluation results as JSON.",
)
parser.add_argument(
"--quality_aspects",
nargs="+",
help="Quality aspects to evaluate.",
)
parser.add_argument(
"--user_nlu_config",
type=str,
help="User NLU configuration file.",
)
parser.add_argument(
"--agent_nlu_config",
type=str,
help="Agent NLU configuration file.",
)
parser.add_argument(
"--reject_intent_labels",
nargs="+",
help="Intent labels corresponding to rejection.",
)
parser.add_argument(
"--accept_intent_labels",
nargs="+",
help="Intent labels corresponding to acceptance.",
)
parser.add_argument(
"--recommendation_intent_labels",
nargs="+",
help="Intent labels corresponding to recommendation.",
)
parser.add_argument(
"-d",
"--debug",
action="store_const",
const=True,
help="Debug mode.",
)
return parser.parse_args()


def load_config(args: argparse.Namespace) -> confuse.Configuration:
"""Loads config from default file, custom file, and CLI overrides."""
config = confuse.Configuration("usersimcrs")
config.set_file(DEFAULT_CONFIG_PATH)
if args.config_file:
config.set_file(args.config_file)
config.set_args(args, dots=True)
return config


def validate_config(config: confuse.Configuration) -> List[str]:
"""Validates evaluation config and returns quality aspects."""
metrics = config["metrics"].get()
Comment thread
dkkdark marked this conversation as resolved.
Outdated
if "quality" in metrics and "quality_llm_interface" not in config:
raise ValueError("Quality evaluation requires `quality_llm_interface`.")

quality_aspects = config["quality_aspects"].get()
supported_aspects = [aspect.name for aspect in QualityRubrics]
invalid_aspects = [
aspect for aspect in quality_aspects if aspect not in supported_aspects
]
if invalid_aspects:
raise ValueError(
f"Unknown quality aspect(s): {invalid_aspects}. "
f"Supported aspects: {supported_aspects}"
)

if UTILITY_METRICS.intersection(set(metrics)):
if not config["user_nlu_config"].get(None):
raise ValueError(
"`user_nlu_config` is required for utility metrics."
)
if not config["agent_nlu_config"].get(None):
raise ValueError(
"`agent_nlu_config` is required for utility metrics."
)

return quality_aspects


def load_nlu(config_path: str, name: str) -> Any:
"""Loads one NLU component from a config path."""
nlu_config = confuse.Configuration(name)
nlu_config.set_file(config_path)
return get_NLU(nlu_config)
Comment thread
dkkdark marked this conversation as resolved.
Outdated


def annotate_for_utility(
dialogues: List[Any], config: confuse.Configuration, metrics: Sequence[str]
Comment thread
dkkdark marked this conversation as resolved.
Outdated
) -> None:
"""Annotates dialogues when utility metrics are requested."""
if not UTILITY_METRICS.intersection(set(metrics)):
return

user_nlu = load_nlu(
config["user_nlu_config"].get(), "User NLU Configuration"
)
agent_nlu = load_nlu(
config["agent_nlu_config"].get(), "Agent NLU Configuration"
)
annotate_dialogues(dialogues, user_nlu, agent_nlu)
Comment thread
dkkdark marked this conversation as resolved.
Outdated


def get_summary_by_agent(
Comment thread
dkkdark marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can’t skip calling it when there is only one agent because we won’t get a summary for it

dialogues: Sequence[Any], scores: Mapping[str, float]
Comment thread
dkkdark marked this conversation as resolved.
Outdated
) -> Dict[str, Dict[str, float]]:
"""Aggregates metric scores by agent."""
grouped_scores: Dict[str, List[float]] = defaultdict(list)
for dialogue in dialogues:
grouped_scores[dialogue.agent_id].append(
scores[dialogue.conversation_id]
)

return {
agent_id: {
"count": len(agent_scores),
"min": min(agent_scores),
"max": max(agent_scores),
"mean": mean(agent_scores),
"stdev": stdev(agent_scores) if len(agent_scores) > 1 else 0.0,
}
for agent_id, agent_scores in grouped_scores.items()
}


def get_utility_intents(
config: confuse.Configuration,
) -> Dict[str, List[Intent]]:
"""Builds intent lists used by utility metrics."""
return {
"recommendation_intents": [
Intent(label)
for label in config["recommendation_intent_labels"].get()
],
"acceptance_intents": [
Intent(label) for label in config["accept_intent_labels"].get()
],
"rejection_intents": [
Intent(label) for label in config["reject_intent_labels"].get()
],
}


def build_metric_registry(
config: confuse.Configuration, metrics: Sequence[str]
) -> Dict[str, Any]:
"""Builds metric instances."""
registry: Dict[str, Any] = {}
if "quality" in metrics:
registry["quality"] = QualityMetric(
llm_interface=get_llm_interface(
config["quality_llm_interface"].get()
)
)
if "satisfaction" in metrics:
registry["satisfaction"] = SatisfactionMetric(
classifier=SatisfactionClassifierSVM()
)
if "success_rate" in metrics:
registry["success_rate"] = SuccessRateMetric()
if "successful_recommendation_round_ratio" in metrics:
registry[
"successful_recommendation_round_ratio"
] = SuccessfulRecommendationRoundRatioMetric()
if "reward_per_dialogue_length" in metrics:
registry["reward_per_dialogue_length"] = RewardPerDialogueLengthMetric()
return registry


def evaluate_metric(
metric_name: str,
Comment thread
dkkdark marked this conversation as resolved.
Outdated
metric: Any,
Comment thread
dkkdark marked this conversation as resolved.
Outdated
dialogues: List[Any],
Comment thread
dkkdark marked this conversation as resolved.
Outdated
quality_aspects: Sequence[str],
utility_intents: Dict[str, List[Intent]],
) -> Dict[str, Any]:
"""Evaluates one metric and returns serialized results."""
if metric_name == "quality":
return {
"aspects": {
aspect: {
"per_dialogue": scores,
"summary_by_agent": get_summary_by_agent(dialogues, scores),
}
for aspect in quality_aspects
for scores in [
metric.evaluate_dialogues(dialogues, aspect=aspect)
]
}
}

if metric_name in {
"success_rate",
"successful_recommendation_round_ratio",
}:
scores = metric.evaluate_dialogues(dialogues, **utility_intents)
elif metric_name == "reward_per_dialogue_length":
scores = metric.evaluate_dialogues(
dialogues,
acceptance_intents=utility_intents["acceptance_intents"],
)
else:
scores = metric.evaluate_dialogues(dialogues)

return {
"per_dialogue": scores,
"summary_by_agent": get_summary_by_agent(dialogues, scores),
}


def save_results(
config: confuse.Configuration, results: Dict[str, Any]
) -> None:
Comment thread
dkkdark marked this conversation as resolved.
Outdated
"""Writes config dump and evaluation results to disk."""
output_path = config["output"].get()
output_dir = os.path.dirname(output_path)
if output_dir:
os.makedirs(output_dir, exist_ok=True)

output_stem, _ = os.path.splitext(output_path)
with open(f"{output_stem}.meta.yaml", "w") as f:
f.write(config.dump())
Comment thread
dkkdark marked this conversation as resolved.
Outdated

with open(output_path, "w") as f:
json.dump(results, f, indent=2)


def print_summary(results: Mapping[str, Any]) -> None:
Comment thread
dkkdark marked this conversation as resolved.
Outdated
"""Prints a concise terminal summary."""
for metric_name, metric_result in results["metrics"].items():
print(f"Metric: {metric_name}")
if metric_name == "quality":
for aspect_name, aspect_result in metric_result["aspects"].items():
print(f" Aspect: {aspect_name}")
for agent_id, stats in aspect_result[
"summary_by_agent"
].items():
print(
f" Agent: {agent_id} | mean={stats['mean']:.3f} "
f"stdev={stats['stdev']:.3f}"
)
continue

for agent_id, stats in metric_result["summary_by_agent"].items():
print(
f" Agent: {agent_id} | mean={stats['mean']:.3f} "
f"stdev={stats['stdev']:.3f}"
)


def main() -> None:
"""Runs evaluation based on the resolved configuration."""
args = parse_args()
config = load_config(args)

metrics = config["metrics"].get()
quality_aspects = validate_config(config)
dialogues = json_to_dialogues(config["dialogues"].get())
annotate_for_utility(dialogues, config, metrics)

utility_intents = get_utility_intents(config)
metric_registry = build_metric_registry(config, metrics)

results: Dict[str, Any] = {
"dialogues_path": config["dialogues"].get(),
"metrics_requested": metrics,
"metrics": {},
}

for metric_name in metrics:
results["metrics"][metric_name] = evaluate_metric(
metric_name,
metric_registry[metric_name],
dialogues,
quality_aspects,
utility_intents,
)

save_results(config, results)
print_summary(results)


if __name__ == "__main__":
main()
Loading