Skip to content

Commit dd910e4

Browse files
ajay-kesavanclaude
andcommitted
refactor(eval): move aggregators to ExactMatch, revert on Binary/Multiclass
Design change per stakeholder decision: aggregators (Precision / Recall / F-score) attach to ExactMatch evaluators, not to the classification evaluators. Binary and Multiclass classifiers stay on their original shipped design (Radu Mocanu's PR #1403) — they will be deleted in a future pass and shouldn't accumulate aggregator plumbing meanwhile. Additionally, ``classes`` is no longer carried on individual aggregator specs — it lives once on the parent evaluator's config, shared across every aggregator on that evaluator. Kills the whole class of "aggregator classes differ from evaluator classes" bugs (silent overwrite, superset validation, disambiguation branches). SDK changes: * ExactMatchEvaluatorConfig gains ``classes: list[str] | None`` and ``aggregators: list[AggregatorSpec] | None`` with a validator that requires classes when aggregators is set. * PrecisionAggregatorSpec / RecallAggregatorSpec / FScoreAggregatorSpec no longer carry ``classes`` — only ``averaging`` and (for fscore) ``f_value``. * BaseDatasetEvaluator / ClassificationDatasetEvaluator / build_dataset_evaluator take classes as an explicit argument passed at construction/dispatch time. * Runtime walks ExactMatchEvaluatorConfig for aggregators (was Binary + Multiclass configs). * Binary + Multiclass evaluator configs and their JSON schemas revert to the shape shipped in origin/main — no aggregators field, no cross-check validator. * Sample projects' evaluator JSON configs drop their aggregator entries; they remain as pure classifier demos. * Tests updated: math tests pass classes to build_dataset_evaluator, round-trip test expects spec without classes, integration tests use ExactMatch as the source evaluator, deleted the Binary/Multiclass aggregator-validator test class. * Regenerated all evaluator_types JSON schemas from pydantic models. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b872c8f commit dd910e4

27 files changed

Lines changed: 628 additions & 807 deletions

‎packages/uipath/examples/dataset_evaluators_demo.py‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,9 @@ def scenario_1_balanced_three_class() -> None:
9191
("reschedule", "book"),
9292
]
9393
spec = PrecisionAggregatorSpec(
94-
classes=["book", "cancel", "reschedule"], averaging="macro"
94+
averaging="macro"
9595
)
96-
evaluator = build_dataset_evaluator(spec, source_evaluator="intent_match")
96+
evaluator = build_dataset_evaluator(spec, classes=["book","cancel","reschedule"], source_evaluator="intent_match")
9797
report(
9898
"Scenario 1 — Balanced 3-class (intent recognition)\n"
9999
" Each class: 2 TP, 1 FP, 1 FN. Symmetric setup → macro = micro = 2/3.",
@@ -114,11 +114,13 @@ def scenario_2_imbalanced_two_class() -> None:
114114
classes = ["positive", "negative"]
115115

116116
macro = build_dataset_evaluator(
117-
PrecisionAggregatorSpec(classes=classes, averaging="macro"),
117+
PrecisionAggregatorSpec(averaging="macro"),
118+
classes=classes,
118119
source_evaluator="positive_match",
119120
)
120121
micro = build_dataset_evaluator(
121-
PrecisionAggregatorSpec(classes=classes, averaging="micro"),
122+
PrecisionAggregatorSpec(averaging="micro"),
123+
classes=classes,
122124
source_evaluator="positive_match",
123125
)
124126
report(
@@ -150,19 +152,23 @@ def scenario_3_precision_vs_recall_vs_f() -> None:
150152

151153
evaluators = {
152154
"Scenario 3a — Precision on a recall-favourable dataset": build_dataset_evaluator(
153-
PrecisionAggregatorSpec(classes=classes, averaging="macro"),
155+
PrecisionAggregatorSpec(averaging="macro"),
156+
classes=classes,
154157
source_evaluator="yes_match",
155158
),
156159
"Scenario 3b — Recall (same data — note 'yes' recall is 1.0)": build_dataset_evaluator(
157-
RecallAggregatorSpec(classes=classes, averaging="macro"),
160+
RecallAggregatorSpec(averaging="macro"),
161+
classes=classes,
158162
source_evaluator="yes_match",
159163
),
160164
"Scenario 3c — F1 (harmonic mean of P and R)": build_dataset_evaluator(
161-
FScoreAggregatorSpec(classes=classes, averaging="macro", f_value=1.0),
165+
FScoreAggregatorSpec(averaging="macro", f_value=1.0),
166+
classes=classes,
162167
source_evaluator="yes_match",
163168
),
164169
"Scenario 3d — F2 (β=2 weighs recall higher — score moves toward recall)": build_dataset_evaluator(
165-
FScoreAggregatorSpec(classes=classes, averaging="macro", f_value=2.0),
170+
FScoreAggregatorSpec(averaging="macro", f_value=2.0),
171+
classes=classes,
166172
source_evaluator="yes_match",
167173
),
168174
}
@@ -181,7 +187,8 @@ def scenario_4_skipped_datapoints() -> None:
181187
EvaluationResultDto(score=0.0, details={"unrelated": "shape"}),
182188
]
183189
evaluator = build_dataset_evaluator(
184-
PrecisionAggregatorSpec(classes=["cat", "dog"], averaging="macro"),
190+
PrecisionAggregatorSpec(averaging="macro"),
191+
classes=["cat", "dog"],
185192
source_evaluator="any_match",
186193
)
187194
report(
@@ -211,7 +218,8 @@ def scenario_5_realistic_intent_classifier() -> None:
211218
results = materialize_pairs(pairs)
212219
classes = ["book", "cancel", "reschedule", "modify"]
213220
macro_f1 = build_dataset_evaluator(
214-
FScoreAggregatorSpec(classes=classes, averaging="macro", f_value=1.0),
221+
FScoreAggregatorSpec(averaging="macro", f_value=1.0),
222+
classes=classes,
215223
source_evaluator="intent_match",
216224
)
217225
report(
Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"version": "1.0",
33
"id": "BinarySpamPrecision",
4-
"description": "Precision on the 'spam' positive class, plus run-level aggregators",
4+
"description": "Precision on the 'spam' positive class",
55
"evaluatorTypeId": "uipath-binary-classification",
66
"evaluatorConfig": {
77
"name": "BinarySpamPrecision",
@@ -11,24 +11,6 @@
1111
"fValue": 1.0,
1212
"defaultEvaluationCriteria": {
1313
"expectedClass": "ham"
14-
},
15-
"aggregators": [
16-
{
17-
"type": "precision",
18-
"classes": ["spam", "ham"],
19-
"averaging": "macro"
20-
},
21-
{
22-
"type": "recall",
23-
"classes": ["spam", "ham"],
24-
"averaging": "macro"
25-
},
26-
{
27-
"type": "fscore",
28-
"classes": ["spam", "ham"],
29-
"averaging": "macro",
30-
"fValue": 1.0
31-
}
32-
]
14+
}
3315
}
3416
}
Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"version": "1.0",
33
"id": "EmailMulticlassFScore",
4-
"description": "Macro-averaged F1 across payments / support / spam, plus run-level aggregators",
4+
"description": "Macro-averaged F1 across payments / support / spam",
55
"evaluatorTypeId": "uipath-multiclass-classification",
66
"evaluatorConfig": {
77
"name": "EmailMulticlassFScore",
@@ -12,24 +12,6 @@
1212
"fValue": 1.0,
1313
"defaultEvaluationCriteria": {
1414
"expectedClass": "support"
15-
},
16-
"aggregators": [
17-
{
18-
"type": "precision",
19-
"classes": ["payments", "support", "spam"],
20-
"averaging": "macro"
21-
},
22-
{
23-
"type": "recall",
24-
"classes": ["payments", "support", "spam"],
25-
"averaging": "macro"
26-
},
27-
{
28-
"type": "fscore",
29-
"classes": ["payments", "support", "spam"],
30-
"averaging": "macro",
31-
"fValue": 1.0
32-
}
33-
]
15+
}
3416
}
3517
}

‎packages/uipath/src/uipath/eval/evaluators/_aggregator_specs.py‎

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
"""Aggregator specs embedded in per-datapoint classification evaluator configs.
22
3-
Each aggregator is a self-contained run-level metric (precision / recall /
4-
f-score) attached to a classification evaluator. Specs do not share any
5-
properties — each variant declares its own ``classes``, ``averaging``, and
6-
(for fscore) ``f_value`` independently. This keeps each aggregator's contract
7-
explicit at the JSON level: nothing is hoisted up to the evaluator and silently
8-
applied to siblings.
3+
Each aggregator is a run-level metric (precision / recall / f-score) attached
4+
to a classification evaluator. Classes are declared once on the parent evaluator
5+
config — every aggregator on the same evaluator operates on the same class
6+
vocabulary, so the field is not repeated per spec. Only the metric-shape fields
7+
(``averaging`` and, for fscore, ``f_value``) live on the spec itself.
98
"""
109

1110
from __future__ import annotations
@@ -26,23 +25,20 @@ class PrecisionAggregatorSpec(_AggregatorSpecBase):
2625
"""Run-level precision aggregator (multiclass, micro or macro averaged)."""
2726

2827
type: Literal["precision"] = "precision"
29-
classes: list[str] = Field(..., min_length=1)
3028
averaging: Literal["macro", "micro"]
3129

3230

3331
class RecallAggregatorSpec(_AggregatorSpecBase):
3432
"""Run-level recall aggregator (multiclass, micro or macro averaged)."""
3533

3634
type: Literal["recall"] = "recall"
37-
classes: list[str] = Field(..., min_length=1)
3835
averaging: Literal["macro", "micro"]
3936

4037

4138
class FScoreAggregatorSpec(_AggregatorSpecBase):
4239
"""Run-level F-beta aggregator (multiclass, micro or macro averaged)."""
4340

4441
type: Literal["fscore"] = "fscore"
45-
classes: list[str] = Field(..., min_length=1)
4642
averaging: Literal["macro", "micro"]
4743
f_value: float = Field(default=1.0, gt=0)
4844

‎packages/uipath/src/uipath/eval/evaluators/base_dataset_evaluator.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,23 @@
3131
class BaseDatasetEvaluator(ABC, Generic[SpecT]):
3232
"""Abstract base for dataset-level evaluators.
3333
34-
Constructed from an :class:`AggregatorSpec` and the name of the source
35-
per-datapoint evaluator whose results this aggregator consumes. The
36-
dataset evaluator's "name" used for result keying is derived from
37-
``"{source_evaluator}.{spec.type}"`` so two aggregators on the same source
38-
don't collide.
34+
Constructed from an :class:`AggregatorSpec`, the class vocabulary of the
35+
parent per-datapoint evaluator, and the source evaluator's name. Classes
36+
live on the evaluator config (not the spec) — every aggregator on the same
37+
evaluator operates on the same vocabulary. The dataset evaluator's "name"
38+
used for result keying is derived from ``"{source_evaluator}.{spec.type}"``
39+
so two aggregators on the same source don't collide.
3940
"""
4041

4142
spec: SpecT
4243
source_evaluator: str
44+
classes: list[str]
4345

44-
def __init__(self, spec: SpecT, source_evaluator: str) -> None:
45-
"""Store the aggregator spec and the source evaluator name."""
46+
def __init__(self, spec: SpecT, source_evaluator: str, classes: list[str]) -> None:
47+
"""Store the aggregator spec, source evaluator name, and shared classes."""
4648
self.spec = spec
4749
self.source_evaluator = source_evaluator
50+
self.classes = classes
4851

4952
@property
5053
def name(self) -> str:

‎packages/uipath/src/uipath/eval/evaluators/binary_classification_evaluator.py‎

Lines changed: 8 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88

99
from typing import Literal
1010

11-
from pydantic import model_validator
12-
1311
from ..models import (
1412
EvaluationResult,
1513
EvaluatorType,
@@ -21,22 +19,12 @@
2119
UiPathEvaluationError,
2220
UiPathEvaluationErrorCategory,
2321
)
24-
from ._aggregator_specs import AggregatorSpec, FScoreAggregatorSpec
2522
from .base_evaluator import BaseEvaluationCriteria, BaseEvaluatorJustification
2623
from .output_evaluator import (
2724
BaseOutputEvaluator,
2825
OutputEvaluatorConfig,
2926
)
3027

31-
# Maps the evaluator-level ``metric_type`` strings to the corresponding
32-
# aggregator-spec ``type`` values. The two spellings differ historically:
33-
# the evaluator uses "f-score" (hyphen), the aggregator uses "fscore".
34-
_METRIC_TYPE_TO_AGGREGATOR_TYPE = {
35-
"precision": "precision",
36-
"recall": "recall",
37-
"f-score": "fscore",
38-
}
39-
4028

4129
class BinaryClassificationEvaluationCriteria(BaseEvaluationCriteria):
4230
"""Per-datapoint criteria: which class this sample should belong to."""
@@ -53,58 +41,6 @@ class BinaryClassificationEvaluatorConfig(
5341
positive_class: str
5442
metric_type: Literal["precision", "recall", "f-score"] = "precision"
5543
f_value: float = 1.0
56-
# Optional run-level aggregators (precision / recall / fscore). Each is a
57-
# self-contained spec carrying its own ``classes``, ``averaging``, and
58-
# (for fscore) ``f_value``. The dataset-evaluator runtime walks this list
59-
# after all per-datapoint evaluators complete and emits one structured
60-
# result per aggregator keyed by ``{evaluator_name}.{aggregator.type}``.
61-
aggregators: list[AggregatorSpec] | None = None
62-
63-
@model_validator(mode="after")
64-
def _validate_aggregators_against_evaluator_config(
65-
self,
66-
) -> "BinaryClassificationEvaluatorConfig":
67-
"""Reject aggregators that are inconsistent with the evaluator's own config.
68-
69-
Two checks:
70-
* ``positive_class`` must appear in every aggregator's ``classes``
71-
list (case-insensitive). Otherwise the per-datapoint headline
72-
and the aggregator's confusion matrix score completely
73-
disjoint label spaces.
74-
* For each aggregator whose ``type`` matches the evaluator-level
75-
``metric_type`` (mapped via :data:`_METRIC_TYPE_TO_AGGREGATOR_TYPE`),
76-
the aggregator's ``f_value`` must match the evaluator's
77-
``f_value``. Otherwise the per-evaluator headline produced via
78-
``reduce_scores`` and the dataset evaluator's per-aggregator
79-
score diverge silently.
80-
"""
81-
if not self.aggregators:
82-
return self
83-
positive_lower = self.positive_class.lower() if self.positive_class else ""
84-
evaluator_aggregator_type = _METRIC_TYPE_TO_AGGREGATOR_TYPE.get(
85-
self.metric_type
86-
)
87-
for spec in self.aggregators:
88-
if positive_lower and positive_lower not in {
89-
c.lower() for c in spec.classes
90-
}:
91-
raise ValueError(
92-
f"Aggregator '{spec.type}' on evaluator '{self.name}' "
93-
f"declares classes={spec.classes!r} but positive_class="
94-
f"{self.positive_class!r} is not in that list. Add the "
95-
"positive class to the aggregator's classes or remove it."
96-
)
97-
if spec.type == evaluator_aggregator_type and isinstance(
98-
spec, FScoreAggregatorSpec
99-
):
100-
if spec.f_value != self.f_value:
101-
raise ValueError(
102-
f"Aggregator 'fscore' on evaluator '{self.name}' has "
103-
f"f_value={spec.f_value} but the evaluator's f_value="
104-
f"{self.f_value}. The per-evaluator headline and the "
105-
"aggregator would compute different F-beta scores."
106-
)
107-
return self
10844

10945

11046
class BinaryClassificationEvaluator(
@@ -162,8 +98,14 @@ def reduce_scores(self, results: list[EvaluationResultDto]) -> float:
16298
tp = fp = fn = 0
16399

164100
for r in results:
165-
details = BaseEvaluatorJustification.try_from(r.details)
166-
if details is None:
101+
if isinstance(r.details, BaseEvaluatorJustification):
102+
details = r.details
103+
elif isinstance(r.details, dict):
104+
try:
105+
details = BaseEvaluatorJustification.model_validate(r.details)
106+
except Exception:
107+
continue
108+
else:
167109
continue
168110
pred = details.actual
169111
exp = details.expected

‎packages/uipath/src/uipath/eval/evaluators/classification_dataset_evaluators.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ class ClassificationDatasetEvaluator(BaseDatasetEvaluator[AggregatorSpec]):
130130

131131
def evaluate(self, results: list[EvaluationResultDto]) -> EvaluationResult:
132132
"""Compute the configured metric report and return the headline as score."""
133-
confusion = _build_confusion(results, self.spec.classes)
133+
confusion = _build_confusion(results, self.classes)
134134
beta_sq = (
135135
self.spec.f_value * self.spec.f_value
136136
if isinstance(self.spec, FScoreAggregatorSpec)

‎packages/uipath/src/uipath/eval/evaluators/dataset_evaluator_factory.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616
def build_dataset_evaluator(
1717
spec: AggregatorSpec,
1818
source_evaluator: str,
19+
classes: list[str],
1920
) -> ClassificationDatasetEvaluator:
2021
"""Build a dataset evaluator instance from an aggregator spec.
2122
2223
Args:
2324
spec: A validated :class:`AggregatorSpec` (precision / recall / fscore).
2425
source_evaluator: Name of the per-datapoint evaluator whose results
2526
this aggregator consumes.
27+
classes: The class vocabulary from the parent evaluator's config. Shared
28+
across all aggregators attached to that evaluator — a spec no longer
29+
carries classes of its own.
2630
"""
27-
return ClassificationDatasetEvaluator(spec, source_evaluator)
31+
return ClassificationDatasetEvaluator(spec, source_evaluator, classes)

‎packages/uipath/src/uipath/eval/evaluators/exact_match_evaluator.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Exact match evaluator for workload outputs."""
22

3+
from pydantic import model_validator
4+
35
from ..models import (
46
EvaluationResult,
57
EvaluatorType,
68
NumericEvaluationResult,
79
WorkloadExecution,
810
)
11+
from ._aggregator_specs import AggregatorSpec
912
from .base_evaluator import BaseEvaluatorJustification
1013
from .output_evaluator import (
1114
OutputEvaluationCriteria,
@@ -20,6 +23,24 @@ class ExactMatchEvaluatorConfig(OutputEvaluatorConfig[OutputEvaluationCriteria])
2023
name: str = "ExactMatchEvaluator"
2124
case_sensitive: bool = False
2225
negated: bool = False
26+
# Optional dataset-level aggregators (Precision / Recall / F-score) computed
27+
# over the per-datapoint match outcomes. When set, ``classes`` must declare
28+
# the label vocabulary the aggregators will bucket predictions and expected
29+
# outputs into — the same vocabulary is shared across every aggregator on
30+
# this evaluator.
31+
classes: list[str] | None = None
32+
aggregators: list[AggregatorSpec] | None = None
33+
34+
@model_validator(mode="after")
35+
def _validate_aggregators_have_classes(self) -> "ExactMatchEvaluatorConfig":
36+
"""Aggregators need a class vocabulary to build a confusion matrix from."""
37+
if self.aggregators and not self.classes:
38+
raise ValueError(
39+
f"ExactMatch evaluator '{self.name}' declares aggregators but no "
40+
"``classes`` list. Set ``classes`` to the label vocabulary the "
41+
"aggregators should compute Precision/Recall/F-score over."
42+
)
43+
return self
2344

2445

2546
class ExactMatchEvaluator(

0 commit comments

Comments
 (0)