This repository was archived by the owner on Jul 17, 2026. It is now read-only.
forked from deepset-ai/haystack
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathllm_evaluator.py
More file actions
366 lines (317 loc) · 15.1 KB
/
Copy pathllm_evaluator.py
File metadata and controls
366 lines (317 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
from typing import Any
from tqdm import tqdm
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.components.builders import PromptBuilder
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.generators.chat.types import ChatGenerator
from haystack.core.serialization import component_to_dict
from haystack.dataclasses.chat_message import ChatMessage
from haystack.utils import deserialize_chatgenerator_inplace, deserialize_type, serialize_type
from haystack.utils.misc import _parse_dict_from_json
logger = logging.getLogger(__name__)
@component
class LLMEvaluator:
"""
Uses an LLM to evaluate inputs based on a prompt containing instructions and examples.
The default API requires an OpenAI API key to be provided as an environment variable "OPENAI_API_KEY".
The inputs are lists that are user-defined depending on the desired metric.
The output is a dictionary with a key `results` containing a list of evaluation results.
Each result is a dictionary with user-defined keys and values of either 0 for FALSE or 1 for TRUE respectively.
Usage example:
```python
from haystack.components.evaluators import LLMEvaluator
evaluator = LLMEvaluator(
instructions="Is this answer problematic for children?",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"}, "outputs": {"score": 1}},
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}},
],
)
predicted_answers = [
"Football is the most popular sport with around 4 billion followers worldwide",
"Python language was created by Guido van Rossum.",
]
results = evaluator.run(predicted_answers=predicted_answers)
print(results)
# {'results': [{'score': 0}, {'score': 0}]}
```
"""
def __init__(
self,
instructions: str,
inputs: list[tuple[str, type[list]]],
outputs: list[str],
examples: list[dict[str, Any]],
progress_bar: bool = True,
*,
raise_on_failure: bool = True,
chat_generator: ChatGenerator | None = None,
) -> None:
"""
Creates an instance of LLMEvaluator.
If no LLM is specified using the `chat_generator` parameter, the component will use OpenAI in JSON mode.
:param instructions:
The prompt instructions to use for evaluation.
Should be a question about the inputs that can be answered with yes or no.
:param inputs:
The inputs that the component expects as incoming connections and that it evaluates.
Each input is a tuple of an input name and input type. Input types must be lists.
:param outputs:
Output names of the evaluation results. They correspond to keys in the output dictionary.
:param examples:
Few-shot examples conforming to the expected input and output format as defined in the `inputs` and
`outputs` parameters.
Each example is a dictionary with keys "inputs" and "outputs"
They contain the input and output as dictionaries respectively.
:param raise_on_failure:
If True, the component will raise an exception on an unsuccessful API call.
:param progress_bar:
Whether to show a progress bar during the evaluation.
:param chat_generator:
a ChatGenerator instance which represents the LLM.
In order for the component to work, the LLM should be configured to return a JSON object. For example,
when using the OpenAIChatGenerator, you should pass `{"response_format": {"type": "json_object"}}` in the
`generation_kwargs`.
"""
self.validate_init_parameters(inputs, outputs, examples)
component.set_input_types(self, **dict(inputs))
self.raise_on_failure = raise_on_failure
self.instructions = instructions
self.inputs = inputs
self.outputs = outputs
self.examples = examples
self.progress_bar = progress_bar
template = self.prepare_template()
self.builder = PromptBuilder(template=template)
if chat_generator is not None:
self._chat_generator = chat_generator
else:
generation_kwargs = {"response_format": {"type": "json_object"}, "seed": 42}
self._chat_generator = OpenAIChatGenerator(generation_kwargs=generation_kwargs)
self._is_warmed_up = False
def warm_up(self) -> None:
"""
Warm up the component by warming up the underlying chat generator.
"""
if not self._is_warmed_up:
if hasattr(self._chat_generator, "warm_up"):
self._chat_generator.warm_up()
self._is_warmed_up = True
@staticmethod
def validate_init_parameters(
inputs: list[tuple[str, type[list]]], outputs: list[str], examples: list[dict[str, Any]]
) -> None:
"""
Validate the init parameters.
:param inputs:
The inputs to validate.
:param outputs:
The outputs to validate.
:param examples:
The examples to validate.
:raises ValueError:
If the inputs are not a list of tuples with a string and a type of list.
If the outputs are not a list of strings.
If the examples are not a list of dictionaries.
If any example does not have keys "inputs" and "outputs" with values that are dictionaries with string keys.
"""
# Validate inputs
if (
not isinstance(inputs, list)
or not all(isinstance(_input, tuple) for _input in inputs)
or not all(isinstance(_input[0], str) and _input[1] is not list and len(_input) == 2 for _input in inputs)
):
msg = (
f"LLM evaluator expects inputs to be a list of tuples. Each tuple must contain an input name and "
f"type of list but received {inputs}."
)
raise ValueError(msg)
# Validate outputs
if not isinstance(outputs, list) or not all(isinstance(output, str) for output in outputs):
msg = f"LLM evaluator expects outputs to be a list of str but received {outputs}."
raise ValueError(msg)
# Validate examples are lists of dicts
if not isinstance(examples, list) or not all(isinstance(example, dict) for example in examples):
msg = f"LLM evaluator expects examples to be a list of dictionaries but received {examples}."
raise ValueError(msg)
# Validate each example
for example in examples:
if (
{"inputs", "outputs"} != example.keys()
or not all(isinstance(example[param], dict) for param in ["inputs", "outputs"])
or not all(isinstance(key, str) for param in ["inputs", "outputs"] for key in example[param])
):
msg = (
f"LLM evaluator expects each example to have keys `inputs` and `outputs` with values that are "
f"dictionaries with str keys but received {example}."
)
raise ValueError(msg)
@component.output_types(results=list[dict[str, Any]], evaluation_statuses=list[str])
def run(self, **inputs: Any) -> dict[str, Any]:
"""
Run the LLM evaluator.
:param inputs:
The input values to evaluate. The keys are the input names and the values are lists of input values.
:returns:
A dictionary with a `results` entry that contains a list of results.
Each result is a dictionary containing the keys as defined in the `outputs` parameter of the LLMEvaluator
and the evaluation results as the values. If an exception occurs for a particular input value, the result
will be `None` for that entry.
If the API is "openai" and the response contains a "meta" key, the metadata from OpenAI will be included
in the output dictionary, under the key "meta".
:raises ValueError:
Only in the case that `raise_on_failure` is set to True and the received inputs are not lists or have
different lengths, or if the output is not a valid JSON or doesn't contain the expected keys.
"""
if not self._is_warmed_up:
self.warm_up()
self.validate_input_parameters(dict(self.inputs), inputs)
# inputs is a dictionary with keys being input names and values being a list of input values
# We need to iterate through the lists in parallel for all keys of the dictionary
input_names, values = inputs.keys(), list(zip(*inputs.values(), strict=True))
list_of_input_names_to_values = [dict(zip(input_names, v, strict=True)) for v in values]
results: list[dict[str, Any] | None] = []
evaluation_statuses: list[str] = []
metadata = []
errors = 0
for input_names_to_values in tqdm(list_of_input_names_to_values, disable=not self.progress_bar):
prompt = self.builder.run(**input_names_to_values)
messages = [ChatMessage.from_user(prompt["prompt"])]
try:
result = self._chat_generator.run(messages=messages)
except Exception as e:
if self.raise_on_failure:
raise ValueError(f"Error while generating response for prompt: {prompt}. Error: {e}") from e
logger.warning("Error while generating response for prompt: {prompt}. Error: {e}", prompt=prompt, e=e)
results.append(None)
evaluation_statuses.append("error")
errors += 1
continue
parsed_result = _parse_dict_from_json(
result["replies"][0].text, expected_keys=self.outputs, raise_on_failure=self.raise_on_failure
)
if parsed_result is None:
results.append(None)
evaluation_statuses.append("error")
errors += 1
else:
results.append(parsed_result)
evaluation_statuses.append("evaluated")
if result["replies"][0].meta:
metadata.append(result["replies"][0].meta)
if errors > 0:
logger.warning(
"LLM evaluator failed for {errors} out of {len(list_of_input_names_to_values)} inputs.",
errors=errors,
len=len(list_of_input_names_to_values),
)
return {"results": results, "meta": metadata or None, "evaluation_statuses": evaluation_statuses}
def prepare_template(self) -> str:
"""
Prepare the prompt template.
Combine instructions, inputs, outputs, and examples into one prompt template with the following format:
Instructions:
`<instructions>`
Generate the response in JSON format with the following keys:
`<list of output keys>`
Consider the instructions and the examples below to determine those values.
Examples:
`<examples>`
Inputs:
`<inputs>`
Outputs:
:returns:
The prompt template.
"""
inputs_section = (
"{" + ", ".join([f'"{input_socket[0]}": {{{{ {input_socket[0]} }}}}' for input_socket in self.inputs]) + "}"
)
examples_section = "\n".join(
[
"Inputs:\n" + json.dumps(example["inputs"]) + "\nOutputs:\n" + json.dumps(example["outputs"])
for example in self.examples
]
)
return (
f"Instructions:\n"
f"{self.instructions}\n\n"
f"Generate the response in JSON format with the following keys:\n"
f"{json.dumps(self.outputs)}\n"
f"Consider the instructions and the examples below to determine those values.\n\n"
f"Examples:\n"
f"{examples_section}\n\n"
f"Inputs:\n"
f"{inputs_section}\n"
f"Outputs:\n"
)
def to_dict(self) -> dict[str, Any]:
"""
Serialize this component to a dictionary.
:returns:
The serialized component as a dictionary.
"""
# Since we cannot currently serialize tuples, convert the inputs to a list.
inputs = [[name, serialize_type(type_)] for name, type_ in self.inputs]
return default_to_dict(
self,
instructions=self.instructions,
inputs=inputs,
outputs=self.outputs,
examples=self.examples,
chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"),
progress_bar=self.progress_bar,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "LLMEvaluator":
"""
Deserialize this component from a dictionary.
:param data:
The dictionary representation of this component.
:returns:
The deserialized component instance.
"""
data["init_parameters"]["inputs"] = [
(name, deserialize_type(type_)) for name, type_ in data["init_parameters"]["inputs"]
]
if data["init_parameters"].get("chat_generator"):
deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator")
return default_from_dict(cls, data)
@staticmethod
def validate_input_parameters(expected: dict[str, Any], received: dict[str, Any]) -> None:
"""
Validate the input parameters.
:param expected:
The expected input parameters.
:param received:
The received input parameters.
:raises ValueError:
If not all expected inputs are present in the received inputs
If the received inputs are not lists or have different lengths
"""
# Validate that all expected inputs are present in the received inputs
for param in expected:
if param not in received:
msg = f"LLM evaluator expected input parameter '{param}' but received only {received.keys()}."
raise ValueError(msg)
# Validate that all received inputs are lists
if not all(isinstance(_input, list) for _input in received.values()):
msg = (
"LLM evaluator expects all input values to be lists but received "
f"{[type(_input) for _input in received.values()]}."
)
raise ValueError(msg)
# Validate that all received inputs are of the same length
inputs = received.values()
length = len(next(iter(inputs)))
if not all(len(_input) == length for _input in inputs):
msg = (
f"LLM evaluator expects all input lists to have the same length but received {inputs} with lengths "
f"{[len(_input) for _input in inputs]}."
)
raise ValueError(msg)