text2sql-eval-toolkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- text2sql_eval_toolkit/__init__.py +107 -0
- text2sql_eval_toolkit/analysis/__init__.py +5 -0
- text2sql_eval_toolkit/analysis/error_analysis.py +335 -0
- text2sql_eval_toolkit/analysis/report_tools.py +719 -0
- text2sql_eval_toolkit/config_args.py +65 -0
- text2sql_eval_toolkit/data/__init__.py +4 -0
- text2sql_eval_toolkit/data/benchmarks.json +69 -0
- text2sql_eval_toolkit/data/test-benchmarks.json +69 -0
- text2sql_eval_toolkit/env_loader.py +55 -0
- text2sql_eval_toolkit/evaluation/__init__.py +26 -0
- text2sql_eval_toolkit/evaluation/evaluation_tools.py +759 -0
- text2sql_eval_toolkit/evaluation/llm_as_judge.py +90 -0
- text2sql_eval_toolkit/execution/__init__.py +5 -0
- text2sql_eval_toolkit/execution/execution_tools.py +1448 -0
- text2sql_eval_toolkit/execution/replace_select_tool.py +114 -0
- text2sql_eval_toolkit/inference/__init__.py +5 -0
- text2sql_eval_toolkit/inference/agentic_pipeline.py +2335 -0
- text2sql_eval_toolkit/inference/base_pipeline.py +11 -0
- text2sql_eval_toolkit/inference/baseline_llm_pipeline.py +372 -0
- text2sql_eval_toolkit/inference/inference_tools.py +769 -0
- text2sql_eval_toolkit/logging.py +54 -0
- text2sql_eval_toolkit/profiling/profiling_tools.py +185 -0
- text2sql_eval_toolkit/utils.py +302 -0
- text2sql_eval_toolkit-1.0.0.dist-info/METADATA +382 -0
- text2sql_eval_toolkit-1.0.0.dist-info/RECORD +28 -0
- text2sql_eval_toolkit-1.0.0.dist-info/WHEEL +5 -0
- text2sql_eval_toolkit-1.0.0.dist-info/licenses/LICENSE +201 -0
- text2sql_eval_toolkit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright IBM Corp. 2025 - 2026
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
#
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tqdm.asyncio import tqdm_asyncio
|
|
11
|
+
from unitxt.text2sql_utils import (
|
|
12
|
+
compare_result_dfs,
|
|
13
|
+
compare_dfs_bird_eval_logic,
|
|
14
|
+
is_sqlglot_parsable,
|
|
15
|
+
is_sqlparse_parsable,
|
|
16
|
+
sqlglot_parsed_queries_equivalent,
|
|
17
|
+
sqlglot_optimized_equivalence,
|
|
18
|
+
sqlparse_queries_equivalent,
|
|
19
|
+
sql_exact_match,
|
|
20
|
+
)
|
|
21
|
+
from text2sql_eval_toolkit.utils import (
|
|
22
|
+
get_benchmark_info,
|
|
23
|
+
parse_dataframe,
|
|
24
|
+
truncate_dataframe,
|
|
25
|
+
get_gt_sqls,
|
|
26
|
+
get_question,
|
|
27
|
+
get_default_eval_filename,
|
|
28
|
+
add_summary_json_suffix,
|
|
29
|
+
add_summary_csv_suffix,
|
|
30
|
+
)
|
|
31
|
+
from text2sql_eval_toolkit.evaluation.llm_as_judge import (
|
|
32
|
+
evaluate_sql_prediction_with_llm,
|
|
33
|
+
load_llm_judge_config,
|
|
34
|
+
)
|
|
35
|
+
from text2sql_eval_toolkit.logging import get_logger
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
logger = get_logger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def evaluate_prediction(record, prediction, llm_judge_config=None, force_rerun_llm_judge=False):
|
|
42
|
+
"""
|
|
43
|
+
Evaluates a predicted SQL query against one or more ground truth SQL queries and their corresponding result dataframes.
|
|
44
|
+
|
|
45
|
+
This function supports multiple ground truth SQLs per record. It iterates through each ground truth SQL and its
|
|
46
|
+
associated result dataframe, comparing them to the predicted SQL and its result dataframe. Evaluation stops early
|
|
47
|
+
if a perfect (subset/super) execution match (subset_non_empty_execution_accuracy == 1) is found.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
record : dict
|
|
52
|
+
A dictionary containing the ground truth SQL(s) and their corresponding result dataframe(s).
|
|
53
|
+
Expected keys:
|
|
54
|
+
- "sql": str or List[str]
|
|
55
|
+
One or more ground truth SQL queries.
|
|
56
|
+
- "gt_df": dict or List[dict]
|
|
57
|
+
One or more serialized dataframes corresponding to the ground truth SQL queries.
|
|
58
|
+
|
|
59
|
+
prediction : dict
|
|
60
|
+
A dictionary containing the predicted SQL and its result dataframe.
|
|
61
|
+
Expected keys:
|
|
62
|
+
- "predicted_sql": str
|
|
63
|
+
The SQL query generated by the model.
|
|
64
|
+
- "predicted_df": dict
|
|
65
|
+
The serialized dataframe resulting from executing the predicted SQL.
|
|
66
|
+
- "sql_execution_error" (optional): str
|
|
67
|
+
An error message if the predicted SQL failed to execute.
|
|
68
|
+
- "evaluation" (optional): dict
|
|
69
|
+
Existing evaluation results. If present and contains valid LLM judge results,
|
|
70
|
+
they will be reused unless force_rerun_llm_judge is True.
|
|
71
|
+
|
|
72
|
+
llm_judge_config : dict, optional
|
|
73
|
+
dictionary config object loaded from the YAML configuration file containing model parameters
|
|
74
|
+
and prompt template for LLM-based evaluation. If not provided, LLM judge will not be used.
|
|
75
|
+
|
|
76
|
+
force_rerun_llm_judge : bool, optional
|
|
77
|
+
If True, forces re-evaluation with LLM judge even if cached results exist.
|
|
78
|
+
If False (default), reuses existing LLM judge results when available.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
result : dict
|
|
83
|
+
A dictionary containing evaluation metrics and flags. Keys include:
|
|
84
|
+
- "execution_accuracy": int
|
|
85
|
+
Whether the predicted result matches the ground truth result exactly.
|
|
86
|
+
- "non_empty_execution_accuracy": int
|
|
87
|
+
Whether the predicted result matches the ground truth result and is non-empty.
|
|
88
|
+
- "subset_non_empty_execution_accuracy": int
|
|
89
|
+
Whether the predicted result is a non-empty subset or superset of the ground truth result.
|
|
90
|
+
- "logic_execution_accuracy": int
|
|
91
|
+
Execution accuracy of SQL logic if record as logic_df
|
|
92
|
+
(result of running query with SELECT clause replaced with gt's SELECT clause).
|
|
93
|
+
- "bird_execution_accuracy": int
|
|
94
|
+
A relaxed match score based on BIRD evaluation logic.
|
|
95
|
+
- "llm_score" (optional): float
|
|
96
|
+
If llm_judge_config is provided, the score using LLM as judge
|
|
97
|
+
- "is_sqlglot_parsable": int
|
|
98
|
+
Whether the predicted SQL is parsable by SQLGlot.
|
|
99
|
+
- "is_sqlparse_parsable": int
|
|
100
|
+
Whether the predicted SQL is parsable by sqlparse.
|
|
101
|
+
- "sqlglot_equivalence": int
|
|
102
|
+
Whether the predicted SQL is equivalent to the ground truth SQL using SQLGlot parsing.
|
|
103
|
+
- "sqlglot_optimized_equivalence": int
|
|
104
|
+
Whether the predicted SQL is equivalent to the ground truth SQL using SQLGlot optimization.
|
|
105
|
+
- "sqlparse_equivalence": int
|
|
106
|
+
Whether the predicted SQL is equivalent to the ground truth SQL using sqlparse.
|
|
107
|
+
- "sql_exact_match": int
|
|
108
|
+
Whether the predicted SQL exactly matches the ground truth SQL string.
|
|
109
|
+
- "sql_syntactic_equivalence": int
|
|
110
|
+
Whether any of the syntactic equivalence checks passed.
|
|
111
|
+
- "df_error": int
|
|
112
|
+
Indicates if there was an error parsing the predicted dataframe.
|
|
113
|
+
- "df_error_message" (optional): str
|
|
114
|
+
Error message if dataframe parsing failed.
|
|
115
|
+
- "eval_error": int
|
|
116
|
+
Indicates if there was an error during evaluation.
|
|
117
|
+
- "eval_error_message" (optional): str
|
|
118
|
+
Error message if evaluation failed.
|
|
119
|
+
- "llm_explanation" (optional): str
|
|
120
|
+
If llm_judge_config is provided, LLM judge explanation of the accuracy of the prediction
|
|
121
|
+
- "gt_sql" (optional): str
|
|
122
|
+
The ground truth SQL query that was used for final evaluation, only present
|
|
123
|
+
if subset_non_empty_execution_accuracy == 1.
|
|
124
|
+
- "gt_df" (optional): DataFrame
|
|
125
|
+
The parsed ground truth dataframe that was used for final evaluation, only present
|
|
126
|
+
if subset_non_empty_execution_accuracy == 1.
|
|
127
|
+
|
|
128
|
+
Notes
|
|
129
|
+
-----
|
|
130
|
+
- If the predicted dataframe cannot be parsed, the function returns early with a dataframe error.
|
|
131
|
+
- If multiple ground truth SQLs are provided, the function evaluates them in order and stops at the first
|
|
132
|
+
one that results in a perfect execution match.
|
|
133
|
+
- The function uses several SQL equivalence and result comparison methods to assess prediction quality.
|
|
134
|
+
- The final result reflects the evaluation against the first ground truth SQL that yields
|
|
135
|
+
subset_non_empty_execution_accuracy == 1, or the last one evaluated if no perfect match is found.
|
|
136
|
+
- The "gt_sql" and "gt_df" fields are only included in the result if a perfect execution match is found.
|
|
137
|
+
- LLM judge caching: If the prediction already has an "evaluation" dict with valid "llm_score" and
|
|
138
|
+
"llm_explanation" fields (and no "llm_judge_error"), those cached results will be reused unless
|
|
139
|
+
force_rerun_llm_judge is True. This significantly improves performance when re-evaluating the same data.
|
|
140
|
+
"""
|
|
141
|
+
result = {}
|
|
142
|
+
|
|
143
|
+
# Check for inference error - skip evaluation if inference failed
|
|
144
|
+
if "inference_error" in prediction:
|
|
145
|
+
return {
|
|
146
|
+
"execution_accuracy": 0,
|
|
147
|
+
"non_empty_execution_accuracy": 0,
|
|
148
|
+
"subset_non_empty_execution_accuracy": 0,
|
|
149
|
+
"logic_execution_accuracy": 0,
|
|
150
|
+
"bird_execution_accuracy": 0,
|
|
151
|
+
"is_sqlglot_parsable": 0,
|
|
152
|
+
"is_sqlparse_parsable": 0,
|
|
153
|
+
"sqlglot_equivalence": 0,
|
|
154
|
+
"sqlglot_optimized_equivalence": 0,
|
|
155
|
+
"sqlparse_equivalence": 0,
|
|
156
|
+
"sql_exact_match": 0,
|
|
157
|
+
"sql_syntactic_equivalence": 0,
|
|
158
|
+
"df_error": 1,
|
|
159
|
+
"df_error_message": f"Inference failed: {prediction['inference_error']}",
|
|
160
|
+
"eval_error": 0,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
pred_df = None
|
|
164
|
+
predicted_sql = prediction["predicted_sql"]
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
pred_df = parse_dataframe(prediction["predicted_df"])
|
|
168
|
+
result["df_error"] = 0
|
|
169
|
+
except Exception as e:
|
|
170
|
+
result["df_error"] = 1
|
|
171
|
+
result["df_error_message"] = prediction.get("sql_execution_error", str(e))
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
gold_sqls = get_gt_sqls(record)
|
|
175
|
+
gold_dfs = record["gt_df"]
|
|
176
|
+
|
|
177
|
+
if not isinstance(gold_dfs, list):
|
|
178
|
+
gold_dfs = [gold_dfs]
|
|
179
|
+
|
|
180
|
+
for gold_sql, gold_df_raw in zip(gold_sqls, gold_dfs):
|
|
181
|
+
gold_df = parse_dataframe(gold_df_raw)
|
|
182
|
+
|
|
183
|
+
match, non_empty_match, subset_match = (
|
|
184
|
+
compare_result_dfs(gold_df, pred_df, gold_sql)
|
|
185
|
+
if gold_sql and pred_df is not None
|
|
186
|
+
else (0, 0, 0)
|
|
187
|
+
)
|
|
188
|
+
bird_match = (
|
|
189
|
+
compare_dfs_bird_eval_logic(gold_df, pred_df)
|
|
190
|
+
if gold_sql and pred_df is not None
|
|
191
|
+
else 0
|
|
192
|
+
)
|
|
193
|
+
logic_match = subset_match
|
|
194
|
+
if logic_match == 0:
|
|
195
|
+
logic_df_raw = prediction.get("logic_df")
|
|
196
|
+
if logic_df_raw is not None:
|
|
197
|
+
logic_df = parse_dataframe(logic_df_raw)
|
|
198
|
+
_, logic_match, _ = (
|
|
199
|
+
compare_result_dfs(gold_df, logic_df, gold_sql)
|
|
200
|
+
if gold_sql and logic_df is not None
|
|
201
|
+
else (0, 0, 0)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
is_glot_parsable = is_sqlglot_parsable(predicted_sql)
|
|
205
|
+
sqlparse_parsable = is_sqlparse_parsable(predicted_sql)
|
|
206
|
+
sqlglot_equivalence_score = (
|
|
207
|
+
sqlglot_parsed_queries_equivalent(predicted_sql, gold_sql)
|
|
208
|
+
if is_glot_parsable
|
|
209
|
+
else 0
|
|
210
|
+
)
|
|
211
|
+
sqlglot_optimized_equivalence_score = (
|
|
212
|
+
sqlglot_optimized_equivalence(predicted_sql, gold_sql)
|
|
213
|
+
if is_glot_parsable
|
|
214
|
+
else 0
|
|
215
|
+
)
|
|
216
|
+
sqlparse_equivalance = (
|
|
217
|
+
sqlparse_queries_equivalent(predicted_sql, gold_sql)
|
|
218
|
+
if sqlparse_parsable
|
|
219
|
+
else 0
|
|
220
|
+
)
|
|
221
|
+
sql_exact_match_score = sql_exact_match(predicted_sql, gold_sql)
|
|
222
|
+
|
|
223
|
+
result.update(
|
|
224
|
+
{
|
|
225
|
+
"execution_accuracy": int(match),
|
|
226
|
+
"non_empty_execution_accuracy": int(non_empty_match),
|
|
227
|
+
"subset_non_empty_execution_accuracy": int(subset_match),
|
|
228
|
+
"logic_execution_accuracy": int(logic_match),
|
|
229
|
+
"bird_execution_accuracy": int(bird_match),
|
|
230
|
+
"is_sqlglot_parsable": int(is_glot_parsable),
|
|
231
|
+
"is_sqlparse_parsable": int(sqlparse_parsable),
|
|
232
|
+
"sqlglot_equivalence": int(sqlglot_equivalence_score),
|
|
233
|
+
"sqlglot_optimized_equivalence": int(
|
|
234
|
+
sqlglot_optimized_equivalence_score
|
|
235
|
+
),
|
|
236
|
+
"sqlparse_equivalence": int(sqlparse_equivalance),
|
|
237
|
+
"sql_exact_match": int(sql_exact_match_score),
|
|
238
|
+
"sql_syntactic_equivalence": int(
|
|
239
|
+
any(
|
|
240
|
+
[
|
|
241
|
+
sqlglot_equivalence_score,
|
|
242
|
+
sqlglot_optimized_equivalence_score,
|
|
243
|
+
sqlparse_equivalance,
|
|
244
|
+
sql_exact_match_score,
|
|
245
|
+
]
|
|
246
|
+
)
|
|
247
|
+
),
|
|
248
|
+
"eval_error": 0,
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
result["df_error"] = result.pop("df_error")
|
|
252
|
+
|
|
253
|
+
# Add token usage metrics from prediction to evaluation result
|
|
254
|
+
token_usage = prediction.get("token_usage")
|
|
255
|
+
if token_usage:
|
|
256
|
+
result["prompt_tokens"] = token_usage.get("prompt_tokens", 0)
|
|
257
|
+
result["completion_tokens"] = token_usage.get("completion_tokens", 0)
|
|
258
|
+
result["total_tokens"] = token_usage.get("total_tokens", 0)
|
|
259
|
+
|
|
260
|
+
# Add timing metrics from prediction to evaluation result
|
|
261
|
+
inference_time = prediction.get("inference_time_ms")
|
|
262
|
+
if inference_time is not None:
|
|
263
|
+
result["inference_time_ms"] = inference_time
|
|
264
|
+
execution_time = prediction.get("execution_time_ms")
|
|
265
|
+
if execution_time is not None:
|
|
266
|
+
result["execution_time_ms"] = execution_time
|
|
267
|
+
|
|
268
|
+
if llm_judge_config:
|
|
269
|
+
try:
|
|
270
|
+
llm_score = None
|
|
271
|
+
llm_explanation = None
|
|
272
|
+
|
|
273
|
+
# Check if we can reuse existing LLM judge results
|
|
274
|
+
use_cached_results = False
|
|
275
|
+
if not force_rerun_llm_judge:
|
|
276
|
+
existing_eval = prediction.get("evaluation", {})
|
|
277
|
+
if (
|
|
278
|
+
"llm_score" in existing_eval
|
|
279
|
+
and "llm_explanation" in existing_eval
|
|
280
|
+
and "llm_judge_error" not in existing_eval
|
|
281
|
+
):
|
|
282
|
+
# Validate that llm_score is a valid number
|
|
283
|
+
try:
|
|
284
|
+
cached_score = float(existing_eval["llm_score"])
|
|
285
|
+
llm_score = cached_score
|
|
286
|
+
llm_explanation = existing_eval["llm_explanation"]
|
|
287
|
+
use_cached_results = True
|
|
288
|
+
logger.info(
|
|
289
|
+
f"Reusing cached LLM judge results (score: {llm_score})"
|
|
290
|
+
)
|
|
291
|
+
except (ValueError, TypeError):
|
|
292
|
+
logger.warning(
|
|
293
|
+
"Invalid cached llm_score, will re-run LLM judge"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
if not use_cached_results:
|
|
297
|
+
if pred_df is None:
|
|
298
|
+
llm_score = 0.0
|
|
299
|
+
llm_explanation = (
|
|
300
|
+
"N/A (did not use LLM due to missing prediction dataframe)"
|
|
301
|
+
)
|
|
302
|
+
elif subset_match:
|
|
303
|
+
llm_score = 1.0
|
|
304
|
+
llm_explanation = "N/A (did not use LLM due to subset match)"
|
|
305
|
+
else:
|
|
306
|
+
question = get_question(record)
|
|
307
|
+
ground_truth_sql = record["sql"]
|
|
308
|
+
ground_truth_df = truncate_dataframe(gold_df)
|
|
309
|
+
predicted_sql = prediction["predicted_sql"]
|
|
310
|
+
predicted_df = truncate_dataframe(pred_df)
|
|
311
|
+
|
|
312
|
+
# Get context for LLM judge
|
|
313
|
+
# For agentic pipelines: use agent_trace (full conversation history)
|
|
314
|
+
# For standard baseline: use prompt
|
|
315
|
+
if "agent_trace" in prediction and prediction["agent_trace"]:
|
|
316
|
+
# Agentic pipeline - use full trace as context
|
|
317
|
+
trace = prediction["agent_trace"]
|
|
318
|
+
trace_text = "Agent Interaction Trace:\n\n"
|
|
319
|
+
for i, interaction in enumerate(trace, 1):
|
|
320
|
+
if interaction is None:
|
|
321
|
+
continue
|
|
322
|
+
trace_text += (
|
|
323
|
+
f"Step {i}: {interaction.get('step', 'unknown')}\n"
|
|
324
|
+
)
|
|
325
|
+
if "messages" in interaction:
|
|
326
|
+
for msg in interaction["messages"]:
|
|
327
|
+
role = msg.get("role", "unknown")
|
|
328
|
+
content = msg.get("content", "")[
|
|
329
|
+
:500
|
|
330
|
+
] # Truncate long content
|
|
331
|
+
trace_text += f" [{role}]: {content}...\n"
|
|
332
|
+
if "response" in interaction:
|
|
333
|
+
trace_text += f" [response]: {interaction['response'][:500]}...\n"
|
|
334
|
+
trace_text += "\n"
|
|
335
|
+
prompt = trace_text
|
|
336
|
+
elif "agent_reasoning" in prediction:
|
|
337
|
+
# Fallback to agent_reasoning if trace not available
|
|
338
|
+
reasoning_list = prediction["agent_reasoning"]
|
|
339
|
+
prompt = "Agent Reasoning:\n" + "\n".join(
|
|
340
|
+
f"- {r}" for r in reasoning_list
|
|
341
|
+
)
|
|
342
|
+
elif "prompt" in prediction:
|
|
343
|
+
# Standard baseline - use prompt
|
|
344
|
+
prompt = prediction["prompt"]
|
|
345
|
+
else:
|
|
346
|
+
# Fallback - construct minimal context
|
|
347
|
+
schema_info = record.get("schema", {})
|
|
348
|
+
db_type = record.get("db_type", "SQL")
|
|
349
|
+
prompt = f"Question: {question}\n\nDatabase Type: {db_type}\n\nSchema: {schema_info}\n\nGenerate SQL to answer the question."
|
|
350
|
+
|
|
351
|
+
llm_as_judge_response = evaluate_sql_prediction_with_llm(
|
|
352
|
+
question,
|
|
353
|
+
ground_truth_sql,
|
|
354
|
+
ground_truth_df,
|
|
355
|
+
predicted_sql,
|
|
356
|
+
predicted_df,
|
|
357
|
+
prompt,
|
|
358
|
+
llm_judge_config,
|
|
359
|
+
)
|
|
360
|
+
llm_score = float(llm_as_judge_response["score"])
|
|
361
|
+
llm_explanation = llm_as_judge_response["explanation"]
|
|
362
|
+
result["llm_score"] = llm_score
|
|
363
|
+
result["llm_explanation"] = llm_explanation
|
|
364
|
+
except Exception as e:
|
|
365
|
+
logger.error(f"LLM judge error: {repr(e)}")
|
|
366
|
+
result["llm_judge_error"] = repr(e)
|
|
367
|
+
|
|
368
|
+
if result["subset_non_empty_execution_accuracy"] == 1:
|
|
369
|
+
result["gt_sql"] = gold_sql
|
|
370
|
+
result["gt_df"] = gold_df_raw
|
|
371
|
+
break
|
|
372
|
+
|
|
373
|
+
except Exception as e:
|
|
374
|
+
result["eval_error"] = 1
|
|
375
|
+
result["eval_error_message"] = repr(e)
|
|
376
|
+
# raise e
|
|
377
|
+
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def compute_summary(metrics_by_model, llm_judge_config, token_usage_by_model=None):
|
|
382
|
+
summary = {}
|
|
383
|
+
for model, records in metrics_by_model.items():
|
|
384
|
+
num_records = len(records)
|
|
385
|
+
num_eval_errors = sum(1 for r in records if "eval_error_message" in r)
|
|
386
|
+
num_df_errors = sum(1 for r in records if "df_error_message" in r)
|
|
387
|
+
# Count records with inference errors (failed to generate SQL)
|
|
388
|
+
num_inference_errors = sum(
|
|
389
|
+
1 for r in records
|
|
390
|
+
if "df_error_message" in r and "Inference failed" in (r.get("df_error_message") or "")
|
|
391
|
+
)
|
|
392
|
+
# Count records with successful predictions (SQL was generated)
|
|
393
|
+
num_predictions = num_records - num_inference_errors
|
|
394
|
+
num_evaluated = num_records - num_eval_errors
|
|
395
|
+
num_correct_non_empty_execution_accuracy = sum(
|
|
396
|
+
r["non_empty_execution_accuracy"]
|
|
397
|
+
for r in records
|
|
398
|
+
if "eval_error_message" not in r
|
|
399
|
+
)
|
|
400
|
+
num_correct_subset_non_empty_execution_accuracy = sum(
|
|
401
|
+
r["subset_non_empty_execution_accuracy"]
|
|
402
|
+
for r in records
|
|
403
|
+
if "eval_error_message" not in r
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
df = None
|
|
407
|
+
if num_evaluated > 0:
|
|
408
|
+
df = pd.DataFrame([r for r in records if "eval_error_message" not in r])
|
|
409
|
+
# Calculate metrics based on num_records (total benchmark size) instead of num_evaluated
|
|
410
|
+
# This ensures that failures to generate predictions or evaluation errors count as 0
|
|
411
|
+
metric_stats = {}
|
|
412
|
+
for metric in df.columns:
|
|
413
|
+
if metric not in [
|
|
414
|
+
"eval_error_message",
|
|
415
|
+
"df_error_message",
|
|
416
|
+
"llm_judge_error",
|
|
417
|
+
"llm_explanation",
|
|
418
|
+
"gt_sql",
|
|
419
|
+
"gt_df",
|
|
420
|
+
]:
|
|
421
|
+
# For accuracy metrics, divide by num_records (not num_evaluated)
|
|
422
|
+
# This penalizes pipelines that fail to generate predictions
|
|
423
|
+
metric_sum = df[metric].sum()
|
|
424
|
+
metric_stats[metric] = {
|
|
425
|
+
"average": metric_sum / num_records, # Changed from df[metric].mean()
|
|
426
|
+
"stddev": df[metric].std()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
# Token metrics are automatically calculated by pandas from the evaluation records
|
|
430
|
+
# The statistics (average, stddev) are already in metric_stats from lines above
|
|
431
|
+
# We just need to add the total sums as separate count metrics
|
|
432
|
+
if "total_tokens" in df.columns:
|
|
433
|
+
metric_stats["sum_total_tokens"] = int(df["total_tokens"].sum())
|
|
434
|
+
metric_stats["sum_prompt_tokens"] = int(df["prompt_tokens"].sum())
|
|
435
|
+
metric_stats["sum_completion_tokens"] = int(df["completion_tokens"].sum())
|
|
436
|
+
|
|
437
|
+
# Timing metrics - add total sums
|
|
438
|
+
if "inference_time_ms" in df.columns:
|
|
439
|
+
metric_stats["sum_inference_time_ms"] = round(df["inference_time_ms"].sum(), 2)
|
|
440
|
+
if "execution_time_ms" in df.columns:
|
|
441
|
+
metric_stats["sum_execution_time_ms"] = round(df["execution_time_ms"].sum(), 2)
|
|
442
|
+
else:
|
|
443
|
+
metric_stats = {}
|
|
444
|
+
|
|
445
|
+
metric_stats["num_records"] = num_records
|
|
446
|
+
metric_stats["num_predictions"] = num_predictions
|
|
447
|
+
metric_stats["num_evaluated"] = num_evaluated
|
|
448
|
+
metric_stats["num_eval_errors"] = num_eval_errors
|
|
449
|
+
metric_stats["num_df_errors"] = num_df_errors
|
|
450
|
+
metric_stats["num_inference_errors"] = num_inference_errors
|
|
451
|
+
metric_stats["num_correct_non_empty_execution_accuracy"] = (
|
|
452
|
+
num_correct_non_empty_execution_accuracy
|
|
453
|
+
)
|
|
454
|
+
metric_stats["num_correct_subset_non_empty_execution_accuracy"] = (
|
|
455
|
+
num_correct_subset_non_empty_execution_accuracy
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
if llm_judge_config:
|
|
459
|
+
metric_stats["num_correct_llm"] = sum(
|
|
460
|
+
1
|
|
461
|
+
for r in records
|
|
462
|
+
if "llm_judge_error" not in r
|
|
463
|
+
and "eval_error_message" not in r
|
|
464
|
+
and r.get("llm_score") == 1
|
|
465
|
+
)
|
|
466
|
+
metric_stats["num_llm_judge_errors"] = sum(
|
|
467
|
+
1 for r in records if "llm_judge_error" in r
|
|
468
|
+
)
|
|
469
|
+
if "llm_judge_config" not in summary:
|
|
470
|
+
summary["llm_judge_config"] = llm_judge_config
|
|
471
|
+
|
|
472
|
+
summary[model] = metric_stats
|
|
473
|
+
|
|
474
|
+
return summary
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def summary_to_df_csv(summary, output_path, use_llm):
|
|
478
|
+
rows = []
|
|
479
|
+
for model, metrics in summary.items():
|
|
480
|
+
if model == "llm_judge_config":
|
|
481
|
+
continue
|
|
482
|
+
row = {
|
|
483
|
+
"Model": model,
|
|
484
|
+
"Total": metrics.get("num_records", 0),
|
|
485
|
+
"Evaluated": metrics.get("num_evaluated", 0),
|
|
486
|
+
"Number of Correct Non-Empty Data Frames": metrics.get(
|
|
487
|
+
"num_correct_non_empty_execution_accuracy"
|
|
488
|
+
),
|
|
489
|
+
"Number of Correct Subset/Superset Non-Empty Data Frames": metrics.get(
|
|
490
|
+
"num_correct_subset_non_empty_execution_accuracy"
|
|
491
|
+
),
|
|
492
|
+
"Number of Correct Results According to LLM Judge": (
|
|
493
|
+
metrics["num_correct_llm"] if use_llm else "N/A"
|
|
494
|
+
),
|
|
495
|
+
"Evaluation Errors": metrics.get("num_eval_errors", 0),
|
|
496
|
+
"Dataframe Errors": metrics.get("num_df_errors", 0),
|
|
497
|
+
"LLM Judge Errors": metrics.get("num_llm_judge_errors", 0),
|
|
498
|
+
"Total Tokens": metrics.get("sum_total_tokens", "N/A"),
|
|
499
|
+
"Avg Tokens/Question": (
|
|
500
|
+
round(metrics.get("total_tokens", {}).get("average", 0), 2)
|
|
501
|
+
if isinstance(metrics.get("total_tokens"), dict)
|
|
502
|
+
else "N/A"
|
|
503
|
+
),
|
|
504
|
+
"Total Prompt Tokens": metrics.get("sum_prompt_tokens", "N/A"),
|
|
505
|
+
"Total Completion Tokens": metrics.get("sum_completion_tokens", "N/A"),
|
|
506
|
+
"Total Inference Time (ms)": metrics.get("sum_inference_time_ms", "N/A"),
|
|
507
|
+
"Avg Inference Time (ms)": (
|
|
508
|
+
round(metrics.get("inference_time_ms", {}).get("average", 0), 2)
|
|
509
|
+
if isinstance(metrics.get("inference_time_ms"), dict)
|
|
510
|
+
else "N/A"
|
|
511
|
+
),
|
|
512
|
+
"Total Execution Time (ms)": metrics.get("sum_execution_time_ms", "N/A"),
|
|
513
|
+
"Avg Execution Time (ms)": (
|
|
514
|
+
round(metrics.get("execution_time_ms", {}).get("average", 0), 2)
|
|
515
|
+
if isinstance(metrics.get("execution_time_ms"), dict)
|
|
516
|
+
else "N/A"
|
|
517
|
+
),
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
for metric, stats in metrics.items():
|
|
521
|
+
if isinstance(stats, dict):
|
|
522
|
+
row[f"{metric}_avg"] = round(stats.get("average", 0), 4)
|
|
523
|
+
row[f"{metric}_std"] = round(stats.get("stddev", 0), 4)
|
|
524
|
+
|
|
525
|
+
rows.append(row)
|
|
526
|
+
|
|
527
|
+
df = pd.DataFrame(rows)
|
|
528
|
+
|
|
529
|
+
sort_col = "subset_non_empty_execution_accuracy_avg"
|
|
530
|
+
if sort_col in df.columns:
|
|
531
|
+
df.sort_values(by=sort_col, ascending=False, inplace=True)
|
|
532
|
+
|
|
533
|
+
df.to_csv(output_path, index=False)
|
|
534
|
+
logger.info(f"\nSummary written to: {output_path}")
|
|
535
|
+
return df
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def print_summary(summary, use_llm):
|
|
539
|
+
print("\n=== Evaluation Summary ===")
|
|
540
|
+
for pipeline, metrics in summary.items():
|
|
541
|
+
if pipeline == "llm_judge_config":
|
|
542
|
+
continue
|
|
543
|
+
print(f"\n: {pipeline}")
|
|
544
|
+
num_records = metrics.get("num_records", 0)
|
|
545
|
+
num_evaluated = metrics.get("num_evaluated", 0)
|
|
546
|
+
num_eval_errors = metrics.get("num_eval_errors", 0)
|
|
547
|
+
num_df_errors = metrics.get("num_df_errors", 0)
|
|
548
|
+
num_correct_non_empty_execution_accuracy = metrics.get(
|
|
549
|
+
"num_correct_non_empty_execution_accuracy"
|
|
550
|
+
)
|
|
551
|
+
num_correct_subset_non_empty_execution_accuracy = metrics.get(
|
|
552
|
+
"num_correct_subset_non_empty_execution_accuracy"
|
|
553
|
+
)
|
|
554
|
+
print(f" Total Records : {num_records}")
|
|
555
|
+
print(f" Successfully Evaluated: {num_evaluated}")
|
|
556
|
+
print(
|
|
557
|
+
f" Number of Correct Non-Empty Data Frames: {num_correct_non_empty_execution_accuracy}"
|
|
558
|
+
)
|
|
559
|
+
print(
|
|
560
|
+
f" Number of Correct Subset/Superset Non-Empty Data Frames: {num_correct_subset_non_empty_execution_accuracy}"
|
|
561
|
+
)
|
|
562
|
+
if use_llm:
|
|
563
|
+
print(
|
|
564
|
+
f" Number of Correct Results According to LLM Judge: {metrics.get('num_correct_llm')}"
|
|
565
|
+
)
|
|
566
|
+
print(
|
|
567
|
+
f" Number of LLM Judge errors: {metrics.get('num_llm_judge_errors')}"
|
|
568
|
+
)
|
|
569
|
+
print(f" Evaluation Errors : {num_eval_errors}")
|
|
570
|
+
print(f" Dataframe Errors : {num_df_errors}")
|
|
571
|
+
|
|
572
|
+
# Print token usage metrics if available
|
|
573
|
+
if "sum_total_tokens" in metrics:
|
|
574
|
+
print(f" Token Usage Metrics:")
|
|
575
|
+
print(f" Total Tokens : {metrics.get('sum_total_tokens', 0):,}")
|
|
576
|
+
total_tokens_stats = metrics.get('total_tokens', {})
|
|
577
|
+
if isinstance(total_tokens_stats, dict):
|
|
578
|
+
avg_val = total_tokens_stats.get('average', 0)
|
|
579
|
+
else:
|
|
580
|
+
avg_val = 0
|
|
581
|
+
print(f" Avg Tokens per Question : {avg_val:.2f}")
|
|
582
|
+
print(f" Total Prompt Tokens : {metrics.get('sum_prompt_tokens', 0):,}")
|
|
583
|
+
print(f" Total Completion Tokens : {metrics.get('sum_completion_tokens', 0):,}")
|
|
584
|
+
|
|
585
|
+
# Print timing metrics if available
|
|
586
|
+
if "sum_inference_time_ms" in metrics or "sum_execution_time_ms" in metrics:
|
|
587
|
+
print(f" Performance Metrics:")
|
|
588
|
+
if "sum_inference_time_ms" in metrics:
|
|
589
|
+
inference_stats = metrics.get('inference_time_ms', {})
|
|
590
|
+
if isinstance(inference_stats, dict):
|
|
591
|
+
avg_inference = inference_stats.get('average', 0)
|
|
592
|
+
else:
|
|
593
|
+
avg_inference = 0
|
|
594
|
+
print(f" Total Inference Time : {metrics.get('sum_inference_time_ms', 0):,.2f} ms")
|
|
595
|
+
print(f" Avg Inference Time per Query : {avg_inference:.2f} ms")
|
|
596
|
+
|
|
597
|
+
if "sum_execution_time_ms" in metrics:
|
|
598
|
+
execution_stats = metrics.get('execution_time_ms', {})
|
|
599
|
+
if isinstance(execution_stats, dict):
|
|
600
|
+
avg_execution = execution_stats.get('average', 0)
|
|
601
|
+
else:
|
|
602
|
+
avg_execution = 0
|
|
603
|
+
print(f" Total Execution Time : {metrics.get('sum_execution_time_ms', 0):,.2f} ms")
|
|
604
|
+
print(f" Avg Execution Time per Query : {avg_execution:.2f} ms")
|
|
605
|
+
|
|
606
|
+
for metric, stats in metrics.items():
|
|
607
|
+
if metric in {
|
|
608
|
+
"num_records",
|
|
609
|
+
"num_predictions",
|
|
610
|
+
"num_evaluated",
|
|
611
|
+
"num_eval_errors",
|
|
612
|
+
"num_df_errors",
|
|
613
|
+
"num_inference_errors",
|
|
614
|
+
"num_correct_non_empty_execution_accuracy",
|
|
615
|
+
"num_correct_subset_non_empty_execution_accuracy",
|
|
616
|
+
"num_correct_llm",
|
|
617
|
+
"num_llm_judge_errors",
|
|
618
|
+
"sum_total_tokens",
|
|
619
|
+
"sum_prompt_tokens",
|
|
620
|
+
"sum_completion_tokens",
|
|
621
|
+
"sum_inference_time_ms",
|
|
622
|
+
"sum_execution_time_ms",
|
|
623
|
+
"inference_time_ms",
|
|
624
|
+
"execution_time_ms",
|
|
625
|
+
}:
|
|
626
|
+
continue
|
|
627
|
+
print(
|
|
628
|
+
f" {metric:<30} Avg: {stats['average']:.4f} StdDev: {stats['stddev']:.4f}"
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
async def async_evaluate_predictions(
|
|
633
|
+
input_file: str,
|
|
634
|
+
output_file: str = None,
|
|
635
|
+
summary_file: str = None,
|
|
636
|
+
csv_summary_file: str = None,
|
|
637
|
+
llm_judge_config: dict = None,
|
|
638
|
+
max_concurrency: int = 16,
|
|
639
|
+
force_rerun_llm_judge: bool = False,
|
|
640
|
+
force_rerun: bool = False,
|
|
641
|
+
):
|
|
642
|
+
output_file = output_file or get_default_eval_filename(input_file)
|
|
643
|
+
summary_file = summary_file or add_summary_json_suffix(output_file)
|
|
644
|
+
csv_summary_file = csv_summary_file or add_summary_csv_suffix(output_file)
|
|
645
|
+
|
|
646
|
+
semaphore = asyncio.Semaphore(max_concurrency)
|
|
647
|
+
|
|
648
|
+
async def worker(record, prediction, llm_judge_config, force_rerun_llm_judge):
|
|
649
|
+
async with semaphore:
|
|
650
|
+
return await asyncio.to_thread(
|
|
651
|
+
evaluate_prediction, record, prediction, llm_judge_config, force_rerun_llm_judge
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
with open(input_file, "r") as f:
|
|
655
|
+
data = json.load(f)
|
|
656
|
+
|
|
657
|
+
# Load existing evaluations from output file if it exists (for caching)
|
|
658
|
+
existing_evaluations = {}
|
|
659
|
+
if not force_rerun and Path(output_file).exists():
|
|
660
|
+
try:
|
|
661
|
+
with open(output_file, "r") as f:
|
|
662
|
+
existing_data = json.load(f)
|
|
663
|
+
for record in existing_data:
|
|
664
|
+
record_id = record.get("id") or record.get("question_id")
|
|
665
|
+
if record_id:
|
|
666
|
+
existing_evaluations[record_id] = record.get("predictions", {})
|
|
667
|
+
except Exception as e:
|
|
668
|
+
logger.warning(f"Could not load existing evaluations from {output_file}: {e}")
|
|
669
|
+
|
|
670
|
+
# Copy existing evaluations to predictions for caching
|
|
671
|
+
if not force_rerun:
|
|
672
|
+
for record in data:
|
|
673
|
+
record_id = record.get("id") or record.get("question_id")
|
|
674
|
+
if record_id and record_id in existing_evaluations:
|
|
675
|
+
predictions = record.get("predictions", {})
|
|
676
|
+
for model_name, prediction in predictions.items():
|
|
677
|
+
if model_name in existing_evaluations[record_id]:
|
|
678
|
+
existing_eval = existing_evaluations[record_id][model_name].get("evaluation", {})
|
|
679
|
+
if existing_eval:
|
|
680
|
+
prediction["evaluation"] = existing_eval
|
|
681
|
+
|
|
682
|
+
tasks = []
|
|
683
|
+
prediction_references = []
|
|
684
|
+
for record in data:
|
|
685
|
+
predictions = record.get("predictions", {})
|
|
686
|
+
for model_name, prediction in predictions.items():
|
|
687
|
+
task = worker(record, prediction, llm_judge_config, force_rerun_llm_judge)
|
|
688
|
+
tasks.append(task)
|
|
689
|
+
prediction_references.append((record, model_name, prediction))
|
|
690
|
+
|
|
691
|
+
evaluations = await tqdm_asyncio.gather(
|
|
692
|
+
*tasks, desc=f"Evaluating (concurrency limit: {max_concurrency})"
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
metrics_by_model = {}
|
|
696
|
+
token_usage_by_model = {}
|
|
697
|
+
for i, evaluation in enumerate(evaluations):
|
|
698
|
+
record, model_name, prediction = prediction_references[i]
|
|
699
|
+
prediction["evaluation"] = evaluation
|
|
700
|
+
|
|
701
|
+
if model_name not in metrics_by_model:
|
|
702
|
+
metrics_by_model[model_name] = []
|
|
703
|
+
token_usage_by_model[model_name] = []
|
|
704
|
+
metrics_by_model[model_name].append(evaluation)
|
|
705
|
+
|
|
706
|
+
# Collect token usage from prediction
|
|
707
|
+
token_usage = prediction.get("token_usage")
|
|
708
|
+
if token_usage:
|
|
709
|
+
token_usage_by_model[model_name].append(token_usage)
|
|
710
|
+
|
|
711
|
+
summary = compute_summary(metrics_by_model, llm_judge_config, token_usage_by_model)
|
|
712
|
+
|
|
713
|
+
with open(output_file, "w") as f:
|
|
714
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
715
|
+
|
|
716
|
+
with open(summary_file, "w") as f:
|
|
717
|
+
json.dump(summary, f, indent=2, ensure_ascii=False)
|
|
718
|
+
|
|
719
|
+
use_llm = True if llm_judge_config is not None else False
|
|
720
|
+
summary_df = summary_to_df_csv(summary, csv_summary_file, use_llm)
|
|
721
|
+
print_summary(summary, use_llm)
|
|
722
|
+
|
|
723
|
+
return data, summary_df
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def evaluate_predictions(
|
|
727
|
+
input_file: str,
|
|
728
|
+
output_file: str = None,
|
|
729
|
+
summary_file: str = None,
|
|
730
|
+
csv_summary_file: str = None,
|
|
731
|
+
use_llm: bool = False,
|
|
732
|
+
llm_judge_config_path: str = None,
|
|
733
|
+
force_rerun_llm_judge: bool = False,
|
|
734
|
+
force_rerun: bool = False,
|
|
735
|
+
):
|
|
736
|
+
llm_judge_config = None
|
|
737
|
+
if use_llm or llm_judge_config_path is not None:
|
|
738
|
+
llm_judge_config = load_llm_judge_config(llm_judge_config_path)
|
|
739
|
+
return asyncio.run(
|
|
740
|
+
async_evaluate_predictions(
|
|
741
|
+
input_file, output_file, summary_file, csv_summary_file, llm_judge_config,
|
|
742
|
+
force_rerun_llm_judge=force_rerun_llm_judge,
|
|
743
|
+
force_rerun=force_rerun
|
|
744
|
+
)
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
# For running from script
|
|
749
|
+
def run_evaluation(
|
|
750
|
+
benchmark_id: str, use_llm: bool = False, llm_judge_config_path: str = None,
|
|
751
|
+
force_rerun_llm_judge: bool = False, force_rerun: bool = False
|
|
752
|
+
):
|
|
753
|
+
benchmark_info = get_benchmark_info(benchmark_id)
|
|
754
|
+
predictions_path = str(Path(benchmark_info["predictions_path"]))
|
|
755
|
+
return evaluate_predictions(
|
|
756
|
+
predictions_path, use_llm=use_llm, llm_judge_config_path=llm_judge_config_path,
|
|
757
|
+
force_rerun_llm_judge=force_rerun_llm_judge or force_rerun,
|
|
758
|
+
force_rerun=force_rerun
|
|
759
|
+
)
|