Coverage for haystack/evaluation/eval_run_result.py: 62%

93 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import csv 

6from copy import deepcopy 

7from typing import Any, Literal, Union 

8 

9from haystack import logging 

10from haystack.lazy_imports import LazyImport 

11 

12with LazyImport("Run 'pip install pandas'") as pandas_import: 

13 from pandas import DataFrame 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18class EvaluationRunResult: 

19 """ 

20 Contains the inputs and the outputs of an evaluation pipeline and provides methods to inspect them. 

21 """ 

22 

23 def __init__(self, run_name: str, inputs: dict[str, list[Any]], results: dict[str, dict[str, Any]]) -> None: 

24 """ 

25 Initialize a new evaluation run result. 

26 

27 :param run_name: 

28 Name of the evaluation run. 

29 

30 :param inputs: 

31 Dictionary containing the inputs used for the run. Each key is the name of the input and its value is a list 

32 of input values. The length of the lists should be the same. 

33 

34 :param results: 

35 Dictionary containing the results of the evaluators used in the evaluation pipeline. Each key is the name 

36 of the metric and its value is dictionary with the following keys: 

37 - 'score': The aggregated score for the metric. 

38 - 'individual_scores': A list of scores for each input sample. 

39 """ 

40 self.run_name = run_name 

41 self.inputs = deepcopy(inputs) 

42 self.results = deepcopy(results) 

43 

44 if len(inputs) == 0: 

45 raise ValueError("No inputs provided.") 

46 if len({len(lst) for lst in inputs.values()}) != 1: 

47 raise ValueError("Lengths of the inputs should be the same.") 

48 

49 expected_len = len(next(iter(inputs.values()))) 

50 

51 for metric, outputs in results.items(): 

52 if "score" not in outputs: 

53 raise ValueError(f"Aggregate score missing for {metric}.") 

54 if "individual_scores" not in outputs: 

55 raise ValueError(f"Individual scores missing for {metric}.") 

56 

57 if len(outputs["individual_scores"]) != expected_len: 

58 raise ValueError( 

59 f"Length of individual scores for '{metric}' should be the same as the inputs. " 

60 f"Got {len(outputs['individual_scores'])} but expected {expected_len}." 

61 ) 

62 

63 @staticmethod 

64 def _write_to_csv(csv_file: str, data: dict[str, list[Any]]) -> str: 

65 """ 

66 Write data to a CSV file. 

67 

68 :param csv_file: Path to the CSV file to write 

69 :param data: Dictionary containing the data to write 

70 :return: Status message indicating success or failure 

71 """ 

72 list_lengths = [len(value) for value in data.values()] 

73 

74 if len(set(list_lengths)) != 1: 

75 raise ValueError("All lists in the JSON must have the same length") 

76 

77 try: 

78 headers = list(data.keys()) 

79 num_rows = list_lengths[0] 

80 rows = [] 

81 

82 for i in range(num_rows): 

83 row = [data[header][i] for header in headers] 

84 rows.append(row) 

85 

86 with open(csv_file, "w", newline="") as csvfile: 

87 writer = csv.writer(csvfile) 

88 writer.writerow(headers) 

89 writer.writerows(rows) 

90 

91 return f"Data successfully written to {csv_file}" 

92 except PermissionError: 

93 return f"Error: Permission denied when writing to {csv_file}" 

94 except OSError as e: 

95 return f"Error writing to {csv_file}: {str(e)}" 

96 except Exception as e: 

97 return f"Error: {str(e)}" 

98 

99 @staticmethod 

100 def _handle_output( 

101 data: dict[str, list[Any]], output_format: Literal["json", "csv", "df"] = "csv", csv_file: str | None = None 

102 ) -> Union[str, "DataFrame", dict[str, list[Any]]]: 

103 """ 

104 Handles output formatting based on `output_format`. 

105 

106 :returns: DataFrame for 'df', dict for 'json', or confirmation message for 'csv' 

107 """ 

108 if output_format == "json": 

109 return data 

110 

111 if output_format == "df": 

112 pandas_import.check() 

113 return DataFrame(data) 

114 

115 if output_format == "csv": 

116 if not csv_file: 

117 raise ValueError("A file path must be provided in 'csv_file' parameter to save the CSV output.") 

118 return EvaluationRunResult._write_to_csv(csv_file, data) 

119 

120 raise ValueError(f"Invalid output format '{output_format}' provided. Choose from 'json', 'csv', or 'df'.") 

121 

122 def aggregated_report( 

123 self, output_format: Literal["json", "csv", "df"] = "json", csv_file: str | None = None 

124 ) -> Union[dict[str, list[Any]], "DataFrame", str]: 

125 """ 

126 Generates a report with aggregated scores for each metric. 

127 

128 :param output_format: The output format for the report, "json", "csv", or "df", default to "json". 

129 :param csv_file: Filepath to save CSV output if `output_format` is "csv", must be provided. 

130 

131 :returns: 

132 JSON or DataFrame with aggregated scores, in case the output is set to a CSV file, a message confirming the 

133 successful write or an error message. 

134 """ 

135 results = {k: v["score"] for k, v in self.results.items()} 

136 data = {"metrics": list(results.keys()), "score": list(results.values())} 

137 return self._handle_output(data, output_format, csv_file) 

138 

139 def detailed_report( 

140 self, output_format: Literal["json", "csv", "df"] = "json", csv_file: str | None = None 

141 ) -> Union[dict[str, list[Any]], "DataFrame", str]: 

142 """ 

143 Generates a report with detailed scores for each metric. 

144 

145 :param output_format: The output format for the report, "json", "csv", or "df", default to "json". 

146 :param csv_file: Filepath to save CSV output if `output_format` is "csv", must be provided. 

147 

148 :returns: 

149 JSON or DataFrame with the detailed scores, in case the output is set to a CSV file, a message confirming 

150 the successful write or an error message. 

151 """ 

152 

153 combined_data = {col: self.inputs[col] for col in self.inputs} 

154 

155 # enforce columns type consistency 

156 scores_columns = list(self.results.keys()) 

157 for col in scores_columns: 

158 col_values = self.results[col]["individual_scores"] 

159 if any(isinstance(v, float) for v in col_values): 

160 col_values = [float(v) for v in col_values] 

161 combined_data[col] = col_values 

162 

163 return self._handle_output(combined_data, output_format, csv_file) 

164 

165 def comparative_detailed_report( 

166 self, 

167 other: "EvaluationRunResult", 

168 keep_columns: list[str] | None = None, 

169 output_format: Literal["json", "csv", "df"] = "json", 

170 csv_file: str | None = None, 

171 ) -> Union[str, "DataFrame", None]: 

172 """ 

173 Generates a report with detailed scores for each metric from two evaluation runs for comparison. 

174 

175 :param other: Results of another evaluation run to compare with. 

176 :param keep_columns: List of common column names to keep from the inputs of the evaluation runs to compare. 

177 :param output_format: The output format for the report, "json", "csv", or "df", default to "json". 

178 :param csv_file: Filepath to save CSV output if `output_format` is "csv", must be provided. 

179 

180 :returns: 

181 JSON or DataFrame with a comparison of the detailed scores, in case the output is set to a CSV file, 

182 a message confirming the successful write or an error message. 

183 :raises TypeError: If `other` is not an EvaluationRunResult instance, or if the detailed reports are not 

184 dictionaries. 

185 :raises ValueError: If the `other` parameter is missing required attributes. 

186 """ 

187 

188 if not isinstance(other, EvaluationRunResult): 

189 raise TypeError("Comparative scores can only be computed between EvaluationRunResults.") 

190 

191 if not hasattr(other, "run_name") or not hasattr(other, "inputs") or not hasattr(other, "results"): 

192 raise ValueError("The 'other' parameter must have 'run_name', 'inputs', and 'results' attributes.") 

193 

194 if self.run_name == other.run_name: 

195 logger.warning( 

196 "The run names of the two evaluation results are the same ('{run_name}')", run_name=self.run_name 

197 ) 

198 

199 if self.inputs.keys() != other.inputs.keys(): 

200 logger.warning( 

201 "The input columns differ between the results; using the input columns of '{run_name}'", 

202 run_name=self.run_name, 

203 ) 

204 

205 # got both detailed reports 

206 detailed_a = self.detailed_report(output_format="json") 

207 detailed_b = other.detailed_report(output_format="json") 

208 

209 # ensure both detailed reports are in dictionaries format 

210 if not isinstance(detailed_a, dict) or not isinstance(detailed_b, dict): 

211 raise TypeError("Detailed reports must be dictionaries.") 

212 

213 # determine which columns to ignore 

214 if keep_columns is None: 

215 ignore = list(self.inputs.keys()) 

216 else: 

217 ignore = [col for col in list(self.inputs.keys()) if col not in keep_columns] 

218 

219 # filter out ignored columns from pipe_b_dict 

220 filtered_detailed_b = { 

221 f"{other.run_name}_{key}": value for key, value in detailed_b.items() if key not in ignore 

222 } 

223 

224 # rename columns in pipe_a_dict based on ignore list 

225 renamed_detailed_a = { 

226 (key if key in ignore else f"{self.run_name}_{key}"): value for key, value in detailed_a.items() 

227 } 

228 

229 # combine both detailed reports 

230 combined_results = {**renamed_detailed_a, **filtered_detailed_b} 

231 return self._handle_output(combined_results, output_format, csv_file)