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.
Files changed (28) hide show
  1. text2sql_eval_toolkit/__init__.py +107 -0
  2. text2sql_eval_toolkit/analysis/__init__.py +5 -0
  3. text2sql_eval_toolkit/analysis/error_analysis.py +335 -0
  4. text2sql_eval_toolkit/analysis/report_tools.py +719 -0
  5. text2sql_eval_toolkit/config_args.py +65 -0
  6. text2sql_eval_toolkit/data/__init__.py +4 -0
  7. text2sql_eval_toolkit/data/benchmarks.json +69 -0
  8. text2sql_eval_toolkit/data/test-benchmarks.json +69 -0
  9. text2sql_eval_toolkit/env_loader.py +55 -0
  10. text2sql_eval_toolkit/evaluation/__init__.py +26 -0
  11. text2sql_eval_toolkit/evaluation/evaluation_tools.py +759 -0
  12. text2sql_eval_toolkit/evaluation/llm_as_judge.py +90 -0
  13. text2sql_eval_toolkit/execution/__init__.py +5 -0
  14. text2sql_eval_toolkit/execution/execution_tools.py +1448 -0
  15. text2sql_eval_toolkit/execution/replace_select_tool.py +114 -0
  16. text2sql_eval_toolkit/inference/__init__.py +5 -0
  17. text2sql_eval_toolkit/inference/agentic_pipeline.py +2335 -0
  18. text2sql_eval_toolkit/inference/base_pipeline.py +11 -0
  19. text2sql_eval_toolkit/inference/baseline_llm_pipeline.py +372 -0
  20. text2sql_eval_toolkit/inference/inference_tools.py +769 -0
  21. text2sql_eval_toolkit/logging.py +54 -0
  22. text2sql_eval_toolkit/profiling/profiling_tools.py +185 -0
  23. text2sql_eval_toolkit/utils.py +302 -0
  24. text2sql_eval_toolkit-1.0.0.dist-info/METADATA +382 -0
  25. text2sql_eval_toolkit-1.0.0.dist-info/RECORD +28 -0
  26. text2sql_eval_toolkit-1.0.0.dist-info/WHEEL +5 -0
  27. text2sql_eval_toolkit-1.0.0.dist-info/licenses/LICENSE +201 -0
  28. 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
+ )