c56bef871b
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
189 lines
7.8 KiB
Python
189 lines
7.8 KiB
Python
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
from typing import Any
|
|
|
|
from numpy import mean as np_mean
|
|
|
|
from haystack import component, default_from_dict, default_to_dict
|
|
from haystack.lazy_imports import LazyImport
|
|
from haystack.utils import ComponentDevice, expit
|
|
from haystack.utils.auth import Secret
|
|
|
|
with LazyImport(message="Run 'pip install \"sentence-transformers>=5.0.0\"'") as sas_import:
|
|
from sentence_transformers import CrossEncoder, SentenceTransformer, util
|
|
from transformers import AutoConfig
|
|
|
|
|
|
@component
|
|
class SASEvaluator:
|
|
"""
|
|
SASEvaluator computes the Semantic Answer Similarity (SAS) between a list of predictions and a one of ground truths.
|
|
|
|
It's usually used in Retrieval Augmented Generation (RAG) pipelines to evaluate the quality of the generated
|
|
answers. The SAS is computed using a pre-trained model from the Hugging Face model hub. The model can be either a
|
|
Bi-Encoder or a Cross-Encoder. The choice of the model is based on the `model` parameter.
|
|
|
|
Usage example:
|
|
```python
|
|
from haystack.components.evaluators.sas_evaluator import SASEvaluator
|
|
|
|
evaluator = SASEvaluator(model="cross-encoder/ms-marco-MiniLM-L-6-v2")
|
|
ground_truths = [
|
|
"A construction budget of US $2.3 billion",
|
|
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
|
|
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
|
|
]
|
|
predictions = [
|
|
"A construction budget of US $2.3 billion",
|
|
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
|
|
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
|
|
]
|
|
result = evaluator.run(
|
|
ground_truth_answers=ground_truths, predicted_answers=predictions
|
|
)
|
|
|
|
print(result["score"])
|
|
# 0.9999673763910929
|
|
|
|
print(result["individual_scores"])
|
|
# [0.9999765157699585, 0.999968409538269, 0.9999572038650513]
|
|
```
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
model: str = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
|
|
batch_size: int = 32,
|
|
device: ComponentDevice | None = None,
|
|
token: Secret = Secret.from_env_var(["HF_API_TOKEN", "HF_TOKEN"], strict=False),
|
|
) -> None:
|
|
"""
|
|
Creates a new instance of SASEvaluator.
|
|
|
|
:param model:
|
|
SentenceTransformers semantic textual similarity model, should be path or string pointing to a downloadable
|
|
model.
|
|
:param batch_size:
|
|
Number of prediction-label pairs to encode at once.
|
|
:param device:
|
|
The device on which the model is loaded. If `None`, the default device is automatically selected.
|
|
:param token:
|
|
The Hugging Face token for HTTP bearer authorization.
|
|
You can find your HF token in your [account settings](https://huggingface.co/settings/tokens)
|
|
"""
|
|
sas_import.check()
|
|
|
|
self._model = model
|
|
self._batch_size = batch_size
|
|
self._device = device
|
|
self._token = token
|
|
self._similarity_model: SentenceTransformer | CrossEncoder | None = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""
|
|
Serialize this component to a dictionary.
|
|
|
|
:returns:
|
|
The serialized component as a dictionary.
|
|
"""
|
|
return default_to_dict(
|
|
self, model=self._model, batch_size=self._batch_size, device=self._device, token=self._token
|
|
)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> "SASEvaluator":
|
|
"""
|
|
Deserialize this component from a dictionary.
|
|
|
|
:param data:
|
|
The dictionary representation of this component.
|
|
:returns:
|
|
The deserialized component instance.
|
|
"""
|
|
return default_from_dict(cls, data)
|
|
|
|
def warm_up(self) -> None:
|
|
"""
|
|
Initializes the component.
|
|
"""
|
|
if self._similarity_model:
|
|
return
|
|
|
|
token = self._token.resolve_value() if self._token else None
|
|
config = AutoConfig.from_pretrained(self._model, use_auth_token=token)
|
|
cross_encoder_used = False
|
|
if config.architectures:
|
|
cross_encoder_used = any(arch.endswith("ForSequenceClassification") for arch in config.architectures)
|
|
device = ComponentDevice.resolve_device(self._device).to_torch_str()
|
|
# Based on the Model string we can load either Bi-Encoders or Cross Encoders.
|
|
# Similarity computation changes for both approaches
|
|
if cross_encoder_used:
|
|
self._similarity_model = CrossEncoder(self._model, device=device, token=token)
|
|
else:
|
|
self._similarity_model = SentenceTransformer(self._model, device=device, token=token)
|
|
|
|
@component.output_types(score=float, individual_scores=list[float])
|
|
def run(self, ground_truth_answers: list[str], predicted_answers: list[str]) -> dict[str, float | list[float]]:
|
|
"""
|
|
SASEvaluator component run method.
|
|
|
|
Run the SASEvaluator to compute the Semantic Answer Similarity (SAS) between a list of predicted answers
|
|
and a list of ground truth answers. Both must be list of strings of same length.
|
|
|
|
:param ground_truth_answers:
|
|
A list of expected answers for each question.
|
|
:param predicted_answers:
|
|
A list of generated answers for each question.
|
|
:returns:
|
|
A dictionary with the following outputs:
|
|
- `score`: Mean SAS score over all the predictions/ground-truth pairs.
|
|
- `individual_scores`: A list of similarity scores for each prediction/ground-truth pair.
|
|
"""
|
|
if len(ground_truth_answers) != len(predicted_answers):
|
|
raise ValueError("The number of predictions and labels must be the same.")
|
|
|
|
if any(answer is None for answer in predicted_answers):
|
|
raise ValueError("Predicted answers must not contain None values.")
|
|
|
|
if len(predicted_answers) == 0:
|
|
return {"score": 0.0, "individual_scores": [0.0]}
|
|
|
|
if not self._similarity_model:
|
|
self.warm_up()
|
|
|
|
if isinstance(self._similarity_model, CrossEncoder):
|
|
# For Cross Encoders we create a list of pairs of predictions and labels
|
|
sentence_pairs = list(zip(predicted_answers, ground_truth_answers, strict=True))
|
|
similarity_scores = self._similarity_model.predict(
|
|
sentence_pairs, batch_size=self._batch_size, convert_to_numpy=True
|
|
)
|
|
|
|
# All Cross Encoders do not return a set of logits scores that are normalized
|
|
# We normalize scores if they are larger than 1
|
|
if (similarity_scores > 1).any():
|
|
similarity_scores = expit(similarity_scores)
|
|
|
|
# Convert scores to list of floats from numpy array
|
|
similarity_scores = similarity_scores.tolist()
|
|
|
|
elif isinstance(self._similarity_model, SentenceTransformer):
|
|
# For Bi-encoders we create embeddings separately for predictions and labels
|
|
predictions_embeddings = self._similarity_model.encode(
|
|
predicted_answers, batch_size=self._batch_size, convert_to_tensor=True
|
|
)
|
|
label_embeddings = self._similarity_model.encode(
|
|
ground_truth_answers, batch_size=self._batch_size, convert_to_tensor=True
|
|
)
|
|
|
|
# Compute cosine-similarities
|
|
similarity_scores = [
|
|
float(util.cos_sim(pred_embedding, label_embedding).cpu().squeeze().numpy())
|
|
for pred_embedding, label_embedding in zip(predictions_embeddings, label_embeddings, strict=True)
|
|
]
|
|
|
|
sas_score = np_mean(similarity_scores)
|
|
|
|
return {"score": sas_score, "individual_scores": similarity_scores}
|