Skip to content

Commit 14af807

Browse files
committed
feat(eval): add zero-shot text classification evaluator
1 parent c0da212 commit 14af807

11 files changed

Lines changed: 937 additions & 0 deletions

File tree

scripts/e2e_eval/testsets/models_with_acc.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,5 +1270,21 @@
12701270
"input_column": "text"
12711271
}
12721272
}
1273+
},
1274+
{
1275+
"hf_id": "MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli",
1276+
"task": "zero-shot-classification",
1277+
"model_type": "deberta-v2",
1278+
"group": "Top200",
1279+
"priority": "P1",
1280+
"dataset_config": {
1281+
"path": "fancyzhx/ag_news",
1282+
"split": "test",
1283+
"metric": "accuracy",
1284+
"columns_mapping": {
1285+
"input_column": "text",
1286+
"label_column": "label"
1287+
}
1288+
}
12731289
}
12741290
]

src/winml/modelkit/datasets/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"sentence-similarity": TextDataset,
4646
"next-sentence-prediction": TextDataset,
4747
"fill-mask": TextDataset,
48+
"zero-shot-classification": TextDataset,
4849
"image-segmentation": ImageSegmentationDataset,
4950
"random": RandomDataset,
5051
# Add more task types as needed

src/winml/modelkit/eval/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .fill_mask_evaluator import WinMLFillMaskEvaluator
1616
from .image_feature_extraction_evaluator import WinMLImageFeatureExtractionEvaluator
1717
from .image_segmentation_evaluator import WinMLImageSegmentationEvaluator
18+
from .metrics.classification import ClassificationMetric
1819
from .metrics.knn_accuracy import KNNAccuracyMetric
1920
from .metrics.mean_average_precision import MAPMetric
2021
from .metrics.mean_iou import IGNORE_INDEX, MeanIoUMetric
@@ -24,10 +25,12 @@
2425
from .question_answering_evaluator import WinMLQuestionAnsweringEvaluator
2526
from .text_classification_evaluator import WinMLTextClassificationEvaluator
2627
from .token_classification_evaluator import WinMLTokenClassificationEvaluator
28+
from .zero_shot_classification_evaluator import WinMLZeroShotClassificationEvaluator
2729

2830

2931
__all__ = [
3032
"IGNORE_INDEX",
33+
"ClassificationMetric",
3134
"EvalResult",
3235
"KNNAccuracyMetric",
3336
"MAPMetric",
@@ -44,5 +47,6 @@
4447
"WinMLQuestionAnsweringEvaluator",
4548
"WinMLTextClassificationEvaluator",
4649
"WinMLTokenClassificationEvaluator",
50+
"WinMLZeroShotClassificationEvaluator",
4751
"evaluate",
4852
]

src/winml/modelkit/eval/evaluate.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .question_answering_evaluator import WinMLQuestionAnsweringEvaluator
2525
from .text_classification_evaluator import WinMLTextClassificationEvaluator
2626
from .token_classification_evaluator import WinMLTokenClassificationEvaluator
27+
from .zero_shot_classification_evaluator import WinMLZeroShotClassificationEvaluator
2728

2829

2930
if TYPE_CHECKING:
@@ -43,6 +44,7 @@
4344
"sentence-similarity": WinMLFeatureExtractionEvaluator,
4445
"image-feature-extraction": WinMLImageFeatureExtractionEvaluator,
4546
"fill-mask": WinMLFillMaskEvaluator,
47+
"zero-shot-classification": WinMLZeroShotClassificationEvaluator,
4648
}
4749

4850
_FE_DEFAULT = DatasetConfig(
@@ -126,6 +128,16 @@
126128
streaming=True,
127129
columns_mapping={"input_column": "text"},
128130
),
131+
"zero-shot-classification": DatasetConfig(
132+
path="fancyzhx/ag_news",
133+
split="test",
134+
samples=100,
135+
shuffle=True,
136+
columns_mapping={
137+
"input_column": "text",
138+
"label_column": "label",
139+
},
140+
),
129141
}
130142

131143

src/winml/modelkit/eval/metrics/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
"""Evaluation metrics."""
77

8+
from .classification import ClassificationMetric
89
from .knn_accuracy import KNNAccuracyMetric
910
from .mean_average_precision import MAPMetric
1011
from .mean_iou import IGNORE_INDEX, MeanIoUMetric
@@ -14,6 +15,7 @@
1415

1516
__all__ = [
1617
"IGNORE_INDEX",
18+
"ClassificationMetric",
1719
"KNNAccuracyMetric",
1820
"MAPMetric",
1921
"MeanIoUMetric",
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License.
4+
# --------------------------------------------------------------------------
5+
6+
"""Classification metrics.
7+
8+
Accuracy and macro-F1 over string labels, for classification evaluators
9+
that do not have an HF evaluate wrapper (e.g. zero-shot-classification).
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import Any
15+
16+
17+
class ClassificationMetric:
18+
"""Accuracy and macro-F1 over string labels."""
19+
20+
def compute(
21+
self,
22+
predictions: list[str],
23+
references: list[str],
24+
labels: list[str],
25+
) -> dict[str, Any]:
26+
"""Compute accuracy and macro-F1.
27+
28+
Args:
29+
predictions: Predicted label strings, one per sample.
30+
references: Ground-truth label strings, one per sample.
31+
labels: Full set of class labels for macro-F1 averaging.
32+
33+
Returns:
34+
Dict with ``accuracy`` and ``f1`` (both floats in [0, 1]).
35+
"""
36+
from sklearn.metrics import accuracy_score, f1_score
37+
38+
if len(predictions) != len(references):
39+
raise ValueError(
40+
f"predictions and references must have the same length, "
41+
f"got {len(predictions)} vs {len(references)}.",
42+
)
43+
if not references:
44+
raise ValueError("references must not be empty.")
45+
if not labels:
46+
raise ValueError("labels must not be empty.")
47+
48+
accuracy = accuracy_score(references, predictions)
49+
macro_f1 = f1_score(
50+
references,
51+
predictions,
52+
labels=labels,
53+
average="macro",
54+
zero_division=0,
55+
)
56+
return {"accuracy": float(accuracy), "f1": float(macro_f1)}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License.
4+
# --------------------------------------------------------------------------
5+
6+
"""Zero-shot classification evaluator for NLI checkpoints.
7+
8+
Computes accuracy and macro-F1 via ClassificationMetric.
9+
HF evaluate library has no zero-shot-classification evaluator, so this
10+
class runs the metric loop manually: each text is scored against every
11+
candidate label as a (premise, hypothesis) NLI pair, and the label with
12+
the top entailment score wins.
13+
14+
Candidate labels come from ``columns_mapping["candidate_labels"]`` if set
15+
(comma-separated), otherwise from ``dataset.features[label_column].names``
16+
when the column is a ``ClassLabel``.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from typing import TYPE_CHECKING, Any
22+
23+
from transformers.pipelines.zero_shot_classification import ZeroShotClassificationPipeline
24+
25+
from .base_evaluator import WinMLEvaluator
26+
27+
28+
if TYPE_CHECKING:
29+
from datasets import Dataset
30+
from transformers.pipelines.base import Pipeline
31+
32+
from ..datasets.config import DatasetConfig
33+
from ..models.winml.base import WinMLPreTrainedModel
34+
from .config import WinMLEvaluationConfig
35+
36+
37+
class _FixedShapeZeroShotPipeline(ZeroShotClassificationPipeline):
38+
"""Pad to ``tokenizer.model_max_length`` for fixed-shape ONNX exports."""
39+
40+
def _parse_and_tokenize(self, sequence_pairs: Any, **kwargs: Any) -> Any:
41+
kwargs["padding"] = "max_length"
42+
kwargs.setdefault("truncation", True)
43+
return super()._parse_and_tokenize(sequence_pairs, **kwargs)
44+
45+
46+
class WinMLZeroShotClassificationEvaluator(WinMLEvaluator):
47+
"""Evaluator for zero-shot text classification using NLI models."""
48+
49+
@classmethod
50+
def schema_info(cls) -> list:
51+
"""Return expected dataset schema for zero-shot classification."""
52+
from .config import SchemaColumn
53+
54+
return [
55+
SchemaColumn("text", "Value(string)", "input_column", description="input text"),
56+
SchemaColumn(
57+
"label",
58+
"ClassLabel",
59+
"label_column",
60+
description="gold label (ClassLabel or string)",
61+
),
62+
SchemaColumn(
63+
"<candidate_labels>",
64+
"comma-separated str",
65+
"candidate_labels",
66+
required=False,
67+
description="override candidate labels (required for non-ClassLabel columns)",
68+
),
69+
SchemaColumn(
70+
"<hypothesis_template>",
71+
"Value(string)",
72+
"hypothesis_template",
73+
required=False,
74+
description='NLI hypothesis template (default: "This example is {}.")',
75+
),
76+
]
77+
78+
def __init__(
79+
self,
80+
config: WinMLEvaluationConfig,
81+
model: WinMLPreTrainedModel,
82+
) -> None:
83+
mapping = config.dataset.columns_mapping
84+
self._input_col = mapping.get("input_column", "text")
85+
self._label_col = mapping.get("label_column", "label")
86+
self._candidate_labels_override = mapping.get("candidate_labels")
87+
self._hypothesis_template = mapping.get("hypothesis_template")
88+
super().__init__(config, model)
89+
90+
def prepare_pipeline(self) -> Pipeline:
91+
"""Create pipeline with fixed-length tokenization for ONNX."""
92+
from transformers import pipeline
93+
94+
io_config = getattr(self.model, "io_config", None) or {}
95+
shapes = io_config.get("input_shapes", [[]])
96+
max_length: int | None = None
97+
if shapes and len(shapes[0]) > 1 and isinstance(shapes[0][1], int):
98+
max_length = shapes[0][1]
99+
100+
pipe = pipeline(
101+
"zero-shot-classification",
102+
model=self.model,
103+
framework="pt",
104+
tokenizer=self.config.model_id,
105+
device="cpu",
106+
pipeline_class=_FixedShapeZeroShotPipeline,
107+
)
108+
109+
if pipe.tokenizer is not None and max_length is not None:
110+
pipe.tokenizer.model_max_length = max_length
111+
112+
# Drop tokenizer keys the ONNX graph does not accept
113+
# (e.g. DeBERTa-v3 MNLI exports omit token_type_ids).
114+
input_names = io_config.get("input_names", [])
115+
if input_names:
116+
filtered = [n for n in pipe.tokenizer.model_input_names if n in input_names]
117+
if filtered:
118+
pipe.tokenizer.model_input_names = filtered
119+
120+
return pipe
121+
122+
def align_labels(
123+
self,
124+
dataset: Dataset,
125+
ds_config: DatasetConfig,
126+
) -> Dataset:
127+
"""Validate input and label columns.
128+
129+
Base-class label alignment is bypassed: NLI ``label2id`` identifies
130+
entailment/neutral/contradiction classes, which are unrelated to the
131+
ground-truth labels used for accuracy.
132+
"""
133+
col_names = set(dataset.column_names)
134+
for col in (self._input_col, self._label_col):
135+
if col not in col_names:
136+
raise ValueError(f"Column '{col}' not found in dataset: {sorted(col_names)}")
137+
return dataset
138+
139+
def _resolve_candidate_labels(self, dataset: Dataset) -> list[str]:
140+
"""Return candidate labels from user override or dataset ``ClassLabel``."""
141+
if self._candidate_labels_override:
142+
labels = [s.strip() for s in self._candidate_labels_override.split(",") if s.strip()]
143+
if not labels:
144+
raise ValueError("candidate_labels override must not be empty.")
145+
return labels
146+
147+
names = getattr(dataset.features.get(self._label_col), "names", None)
148+
if names:
149+
return list(names)
150+
151+
raise ValueError(
152+
f"Column '{self._label_col}' is not a ClassLabel; pass "
153+
f'--column "candidate_labels=a,b,...".',
154+
)
155+
156+
def compute(self) -> dict[str, Any]:
157+
"""Compute accuracy and macro-F1 over all samples."""
158+
from .metrics import ClassificationMetric
159+
160+
candidate_labels = self._resolve_candidate_labels(self.data)
161+
class_names = getattr(self.data.features.get(self._label_col), "names", None)
162+
163+
pipe_kwargs: dict[str, Any] = {"candidate_labels": candidate_labels}
164+
if self._hypothesis_template is not None:
165+
pipe_kwargs["hypothesis_template"] = self._hypothesis_template
166+
167+
predictions: list[str] = []
168+
references: list[str] = []
169+
for sample in self.data:
170+
result = self.pipe(sample[self._input_col], **pipe_kwargs)
171+
predictions.append(result["labels"][0])
172+
raw = sample[self._label_col]
173+
references.append(class_names[int(raw)] if class_names else str(raw))
174+
175+
return ClassificationMetric().compute(predictions, references, candidate_labels)

tests/integration/eval/__init__.py

Whitespace-only changes.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License.
4+
# --------------------------------------------------------------------------
5+
"""End-to-end integration test for zero-shot classification evaluation.
6+
7+
Downloads a small NLI checkpoint and runs the evaluator against a handful
8+
of samples from AG News. Skipped by default via `pytest -m "not slow"`.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import pytest
14+
15+
from winml.modelkit.datasets.config import DatasetConfig
16+
from winml.modelkit.eval import WinMLZeroShotClassificationEvaluator
17+
from winml.modelkit.eval.config import WinMLEvaluationConfig
18+
19+
20+
# Representative NLI checkpoints across the three families listed in issue #325.
21+
_MODEL_IDS = [
22+
"typeform/distilbert-base-uncased-mnli",
23+
"cross-encoder/nli-roberta-base",
24+
"MoritzLaurer/deberta-v3-base-zeroshot-v2.0",
25+
]
26+
27+
28+
@pytest.mark.slow
29+
@pytest.mark.network
30+
@pytest.mark.integration
31+
@pytest.mark.parametrize("model_id", _MODEL_IDS)
32+
def test_zero_shot_classification_end_to_end(model_id: str) -> None:
33+
from transformers import AutoModelForSequenceClassification
34+
35+
model = AutoModelForSequenceClassification.from_pretrained(model_id)
36+
37+
config = WinMLEvaluationConfig(
38+
model_id=model_id,
39+
task="zero-shot-classification",
40+
dataset=DatasetConfig(
41+
path="fancyzhx/ag_news",
42+
split="test",
43+
samples=5,
44+
shuffle=False,
45+
),
46+
)
47+
48+
results = WinMLZeroShotClassificationEvaluator(config, model).compute()
49+
50+
assert "accuracy" in results
51+
assert "f1" in results
52+
assert 0.0 <= results["accuracy"] <= 1.0
53+
assert 0.0 <= results["f1"] <= 1.0

0 commit comments

Comments
 (0)