Skip to content

Commit 811e1ff

Browse files
Merge pull request lightspeed-core#227 from xmican10/LEADS-349-calculate-aggregated-score-from-key-metrics
[LEADS-349] Calculate aggregated score from key metrics
2 parents 8762b7f + 08b29ea commit 811e1ff

12 files changed

Lines changed: 1071 additions & 6 deletions

File tree

config/system.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ api:
104104
# Legacy authentication (fallback when mcp_headers is not configured or disabled)
105105
# Authentication via API_KEY environment variable only for MCP server (without Server name)
106106

107+
# Quality Score Configuration
108+
# Aggregated score from selected metrics for overall system quality assessment
109+
quality_score:
110+
metrics:
111+
- "ragas:faithfulness"
112+
- "ragas:context_precision_with_reference"
113+
- "custom:tool_eval"
114+
- "custom:answer_correctness"
115+
default: true # If true, all metrics in this list get default: true
116+
107117
# Default metrics metadata
108118
metrics_metadata:
109119
# Turn-level metrics metadata

docs/configuration.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,37 @@ metrics_metadata:
335335
description: "How completely the conversation addresses user intentions"
336336
```
337337

338+
## Quality Score
339+
340+
Compute an aggregated quality score from selected metrics using weighted averaging.
341+
342+
| Setting (quality_score.) | Default | Description |
343+
|--------------------------|---------|-------------|
344+
| metrics | required | List of metric identifiers (must be defined in metrics_metadata) |
345+
| default | `false` | If `true`, auto-enable all listed metrics globally |
346+
347+
**Validation**: Metrics must exist in `default_turn_metrics_metadata` or `default_conversation_metrics_metadata`. Invalid metrics fail at config load time.
348+
349+
**Calculation**: Weighted average where each metric's weight = its sample_count / total_samples.
350+
351+
### Example
352+
```yaml
353+
# Define metrics
354+
metrics_metadata:
355+
turn_level:
356+
"ragas:faithfulness":
357+
threshold: 0.7
358+
"ragas:answer_relevancy":
359+
threshold: 0.8
360+
361+
# Configure quality score
362+
quality_score:
363+
metrics:
364+
- "ragas:faithfulness"
365+
- "ragas:answer_relevancy"
366+
default: true # Auto-enable these metrics
367+
```
368+
338369
## Storage
339370
Lightspeed Evaluation can persist results to files and/or databases. The `storage` section configures one or more storage backends.
340371

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""Quality score models for aggregated quality assessment.
2+
3+
Provides Pydantic models for computing and reporting an aggregated quality score
4+
from selected metrics using weighted averaging based on sample sizes.
5+
"""
6+
7+
import logging
8+
from typing import Optional
9+
10+
from pydantic import BaseModel, Field
11+
12+
from lightspeed_evaluation.core.models.summary import MetricStats, ScoreStatistics
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class QualityMetricResult(BaseModel):
18+
"""Quality metric result using composition to add weight to score statistics."""
19+
20+
statistics: ScoreStatistics = Field(
21+
description="Score statistics for this quality metric"
22+
)
23+
weight: float = Field(
24+
default=0.0,
25+
description="Weight proportion (sample_size / total_samples) used in weighted average",
26+
)
27+
28+
29+
class QualityReport(BaseModel):
30+
"""Aggregated quality score from selected metrics."""
31+
32+
quality_score: float = Field(
33+
default=0.0, description="Weighted average of quality score metrics"
34+
)
35+
quality_metrics: dict[str, QualityMetricResult] = Field(
36+
default_factory=dict,
37+
description="Individual metrics used in quality score calculation",
38+
)
39+
extra_metrics: dict[str, ScoreStatistics] = Field(
40+
default_factory=dict,
41+
description="Other evaluated metrics calculated, not used for quality score calculation",
42+
)
43+
warnings: list[str] = Field(
44+
default_factory=list,
45+
description="Warnings about quality metrics configuration or usage",
46+
)
47+
api_latency: float = Field(
48+
default=0.0, description="[Placeholder] Average API response time in seconds"
49+
)
50+
api_tokens: int = Field(
51+
default=0,
52+
description="[Placeholder] Total number of tokens consumed across all API calls",
53+
)
54+
55+
@staticmethod
56+
def create_report(
57+
by_metric: dict[str, MetricStats],
58+
quality_score_metrics: list[str],
59+
) -> Optional["QualityReport"]:
60+
"""Creates a quality report with aggregated quality score from selected metrics.
61+
62+
Separates metrics into quality metrics (used for quality score calculation) and
63+
extra metrics (evaluated but not included in quality score).
64+
65+
Args:
66+
by_metric: Dictionary mapping metric identifiers to their computed statistics.
67+
quality_score_metrics: Metric identifiers to include in quality score calculation.
68+
All specified metrics must exist in by_metric.
69+
70+
Returns:
71+
QualityReport with aggregated quality score and separated quality/extra metrics,
72+
or None if all quality score metrics have zero samples.
73+
74+
Raises:
75+
ValueError: If any quality_score_metrics are not found in by_metric.
76+
"""
77+
warnings: list[str] = []
78+
79+
# Validate all quality score metrics exist in computed metrics (by_metric)
80+
missing_metrics = [m for m in quality_score_metrics if m not in by_metric]
81+
if missing_metrics:
82+
warning_msg = (
83+
"WARNING: "
84+
f"Quality score metrics {missing_metrics} were excluded from "
85+
"quality score computation. "
86+
f"Reason: Not found in evaluation results."
87+
)
88+
warnings.append(warning_msg)
89+
logger.warning(warning_msg)
90+
91+
quality_score_metrics = list(
92+
set(quality_score_metrics) - set(missing_metrics)
93+
)
94+
95+
# Calculate total samples from quality score metrics only
96+
total_samples = 0
97+
for metric_id in quality_score_metrics:
98+
score_stats = by_metric[metric_id].score_statistics
99+
if score_stats is not None:
100+
total_samples += score_stats.count
101+
if total_samples == 0:
102+
logger.warning(
103+
"CRITICAL: Quality score computation failed. "
104+
"All configured quality metrics have zero evaluation results."
105+
)
106+
return None
107+
108+
quality_metrics: dict[str, QualityMetricResult] = {}
109+
extra_metrics: dict[str, ScoreStatistics] = {}
110+
111+
# Separate quality metrics from extra metrics
112+
for metric_id in by_metric:
113+
if metric_id in quality_score_metrics:
114+
score_stats = by_metric[metric_id].score_statistics
115+
116+
# Skip if score_statistics is None
117+
if score_stats is None:
118+
warning_msg = (
119+
f"WARNING: Quality score metric '{metric_id}' "
120+
"was excluded from quality score computation. "
121+
"Reason: Missing score statistics data."
122+
)
123+
warnings.append(warning_msg)
124+
logger.warning(warning_msg)
125+
continue
126+
127+
sample_size = score_stats.count
128+
129+
# Skip metrics with zero samples
130+
if sample_size == 0:
131+
warning_msg = (
132+
f"WARNING: Quality score metric '{metric_id}' "
133+
"was excluded from quality score computation. "
134+
"Reason: Zero evaluation results for this metric."
135+
)
136+
warnings.append(warning_msg)
137+
logger.warning(warning_msg)
138+
continue
139+
140+
weight = sample_size / total_samples
141+
142+
quality_metrics[metric_id] = QualityMetricResult(
143+
statistics=score_stats,
144+
weight=weight,
145+
)
146+
else:
147+
stats = by_metric[metric_id].score_statistics
148+
if stats is not None:
149+
extra_metrics[metric_id] = stats
150+
151+
# Calculate aggregated quality score
152+
aggregated_score = QualityReport._calculate_quality_score(quality_metrics)
153+
154+
return QualityReport(
155+
quality_score=aggregated_score,
156+
quality_metrics=quality_metrics,
157+
extra_metrics=extra_metrics,
158+
warnings=warnings,
159+
)
160+
161+
@staticmethod
162+
def _calculate_quality_score(
163+
quality_metrics: dict[str, QualityMetricResult],
164+
) -> float:
165+
"""Calculate weighted average quality score from quality metrics.
166+
167+
Computes a weighted average where each metric's weight is proportional to its
168+
sample size relative to the total samples across all quality metrics.
169+
170+
Args:
171+
quality_metrics: Dictionary of quality metric results with statistics and
172+
weights. Each metric contains statistics with a mean score and a weight
173+
(sample_size / total_samples).
174+
175+
Returns:
176+
Weighted average quality score computed as sum of (mean * weight) for all metrics.
177+
"""
178+
weighted_sum = 0.0
179+
for metric in quality_metrics.values():
180+
weighted_sum += metric.statistics.mean * metric.weight
181+
return weighted_sum

src/lightspeed_evaluation/core/models/system.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# pylint: disable=too-many-lines
12
"""System configuration models."""
23

34
import os
@@ -804,6 +805,39 @@ def from_metadata(cls, raw: dict[str, Any]) -> "GEvalConfig":
804805
return cls.model_validate(data)
805806

806807

808+
class QualityScoreConfig(BaseModel):
809+
"""Quality score configuration."""
810+
811+
model_config = ConfigDict(extra="forbid")
812+
813+
metrics: list[str] = Field(
814+
default_factory=list,
815+
description="List of metric identifiers to use for quality score computation",
816+
)
817+
default: bool = Field(
818+
default=False,
819+
description="If true, set default: true for all metrics in the list",
820+
)
821+
822+
@field_validator("metrics")
823+
@classmethod
824+
def validate_metrics(cls, v: list[str]) -> list[str]:
825+
"""Ensure metrics list is not empty and contains no duplicates."""
826+
if len(v) == 0:
827+
raise ValueError(
828+
"Quality score metrics list cannot be empty. "
829+
"Either specify at least one metric or "
830+
"remove the quality_score section from configuration."
831+
)
832+
if len(v) != len(set(v)):
833+
duplicates = [m for m in v if v.count(m) > 1]
834+
raise ValueError(
835+
f"Quality score metrics contains duplicates: {set(duplicates)}. "
836+
"Each metric should appear only once."
837+
)
838+
return v
839+
840+
807841
class SystemConfig(BaseModel):
808842
"""System configuration using individual config models."""
809843

@@ -848,6 +882,11 @@ class SystemConfig(BaseModel):
848882
default_factory=VisualizationConfig, description="Visualization configuration"
849883
)
850884

885+
# Quality score configuration
886+
quality_score: Optional[QualityScoreConfig] = Field(
887+
default=None, description="Quality score configuration"
888+
)
889+
851890
# Default metrics metadata from system config
852891
default_turn_metrics_metadata: dict[str, dict[str, Any]] = Field(
853892
default_factory=dict, description="Default turn metrics metadata"
@@ -889,6 +928,33 @@ def validate_default_metrics_metadata_geval(
889928
) from e
890929
return v
891930

931+
@model_validator(mode="after")
932+
def validate_quality_score_metrics(self) -> "SystemConfig":
933+
"""Validate quality_score metrics exist in metrics_metadata.
934+
935+
Raises:
936+
ConfigurationError: When quality_score contains metrics not defined
937+
in turn or conversation level metrics_metadata.
938+
"""
939+
if not self.quality_score:
940+
return self
941+
942+
# Combine all available metrics from both turn and conversation level metadata
943+
all_metrics = set(self.default_turn_metrics_metadata.keys()) | set(
944+
self.default_conversation_metrics_metadata.keys()
945+
)
946+
947+
# Check for invalid metrics
948+
invalid = [m for m in self.quality_score.metrics if m not in all_metrics]
949+
if invalid:
950+
raise ConfigurationError(
951+
f"Invalid quality_score metrics: {invalid}. "
952+
"Must be defined in default_turn_metrics_metadata or "
953+
"default_conversation_metrics_metadata."
954+
)
955+
956+
return self
957+
892958
@property
893959
def turn_level_metric_names(self) -> set[str]:
894960
"""Return turn-level metric names derived from metadata keys."""

0 commit comments

Comments
 (0)