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,719 @@
1
+ #
2
+ # Copyright IBM Corp. 2025 - 2026
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+
6
+ import json
7
+ import pathlib
8
+ from pathlib import Path
9
+ import re
10
+ import matplotlib.pyplot as plt
11
+ from collections import defaultdict
12
+ from statistics import mean
13
+ from tabulate import tabulate
14
+ from text2sql_eval_toolkit.utils import get_benchmarks_info
15
+ from text2sql_eval_toolkit.analysis.error_analysis import (
16
+ export_failed_examples_to_markdown,
17
+ )
18
+ from text2sql_eval_toolkit.logging import get_logger
19
+
20
+
21
+ logger = get_logger(__name__)
22
+ DEFAULT_METRIC = "subset_non_empty_execution_accuracy"
23
+ DEFAULT_PRINT_METRICS = [
24
+ "subset_non_empty_execution_accuracy",
25
+ "execution_accuracy",
26
+ "llm_score",
27
+ "total_tokens",
28
+ "inference_time_ms",
29
+ "execution_time_ms",
30
+ ]
31
+ COUNT_KEYS = ["num_records", "num_evaluated", "sum_total_tokens", "sum_inference_time_ms", "sum_execution_time_ms"]
32
+
33
+
34
+ def prettify(metric_name):
35
+ """Convert snake_case to Title Case."""
36
+ return " ".join(word.capitalize() for word in metric_name.split("_"))
37
+
38
+
39
+ def abbreviate(metric_name):
40
+ """Return an abbreviation only if prettified name is too long."""
41
+ pretty = prettify(metric_name)
42
+ if len(pretty) > 14:
43
+ abbr = "".join(word[0].upper() for word in metric_name.split("_"))
44
+ return abbr, pretty
45
+ return pretty, None
46
+
47
+
48
+ def print_summary_results_by_category(
49
+ records, sort_by=DEFAULT_METRIC, metrics_to_print=None
50
+ ):
51
+ """
52
+ Print evaluation summaries (overall and per category), plus comparison across categories for sort_by metric.
53
+ """
54
+
55
+ if metrics_to_print is None:
56
+ metrics_to_print = DEFAULT_PRINT_METRICS
57
+
58
+ def collect_metrics(records):
59
+ """Aggregate average metrics and record counts per pipeline and per category."""
60
+ category_metrics = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
61
+ all_metrics = defaultdict(lambda: defaultdict(list))
62
+
63
+ for rec in records:
64
+ categories = rec.get("meta", {}).get("categories", [])
65
+ predictions = rec.get("predictions", {})
66
+
67
+ for pipeline, pred_info in predictions.items():
68
+ eval_metrics = pred_info.get("evaluation", {})
69
+ for metric_name, metric_value in eval_metrics.items():
70
+ if isinstance(metric_value, (int, float)):
71
+ all_metrics[pipeline][metric_name].append(metric_value)
72
+ for cat in categories:
73
+ category_metrics[cat][pipeline][metric_name].append(
74
+ metric_value
75
+ )
76
+
77
+ all_metrics[pipeline]["num_records"].append(1)
78
+ for cat in categories:
79
+ category_metrics[cat][pipeline]["num_records"].append(1)
80
+
81
+ def avg_metrics(metrics_dict):
82
+ return {
83
+ pipeline: {
84
+ metric: (sum(values) if metric == "num_records" else mean(values))
85
+ for metric, values in metric_dict.items()
86
+ }
87
+ for pipeline, metric_dict in metrics_dict.items()
88
+ }
89
+
90
+ return avg_metrics(all_metrics), {
91
+ cat: avg_metrics(p) for cat, p in category_metrics.items()
92
+ }
93
+
94
+ def print_abbreviation_legend(name_map):
95
+ if not name_map:
96
+ return
97
+ for abbr, full in name_map.items():
98
+ print(f"- {abbr}: {full}")
99
+ print()
100
+
101
+ def print_table(metrics_dict, label=None):
102
+ if label:
103
+ print(f"\n=== {label} ===")
104
+
105
+ pipelines_sorted = sorted(
106
+ metrics_dict.items(),
107
+ key=lambda x: x[1].get(sort_by, 0) or 0,
108
+ reverse=True,
109
+ )
110
+
111
+ headers = ["Pipeline"]
112
+ abbrev_legend = {}
113
+ column_keys = ["num_records"] + list(metrics_to_print)
114
+ for key in column_keys:
115
+ label, full = abbreviate(key)
116
+ headers.append(label)
117
+ if full:
118
+ abbrev_legend[label] = full
119
+
120
+ print_abbreviation_legend(abbrev_legend)
121
+
122
+ rows = []
123
+ for pipeline, m in pipelines_sorted:
124
+ row = [pipeline]
125
+ row.append(str(int(m.get("num_records", 0))))
126
+ for metric in metrics_to_print:
127
+ val = m.get(metric)
128
+ row.append(f"{val:.3f}" if val is not None else "-")
129
+ rows.append(row)
130
+
131
+ print(
132
+ tabulate(
133
+ rows,
134
+ headers=headers,
135
+ tablefmt="github",
136
+ numalign="right",
137
+ stralign="left",
138
+ )
139
+ )
140
+
141
+ def print_per_pipeline_tables(sort_metric, all_avg, cat_avg):
142
+ """Print one table per pipeline, showing the metric and num_records across categories."""
143
+ print(f"\n=== Per-Pipeline Comparison of `{sort_metric}` Across Categories ===")
144
+
145
+ pipelines = sorted(
146
+ {p for cat_metrics in cat_avg.values() for p in cat_metrics.keys()}
147
+ )
148
+ categories = sorted(cat_avg.keys())
149
+
150
+ for pipeline in pipelines:
151
+ print(f"\n--- Pipeline: `{pipeline}` ---")
152
+ metrics = all_avg.get(pipeline, {})
153
+ score = metrics.get(sort_metric)
154
+ count = int(metrics.get("num_records", 0))
155
+ rows = [
156
+ "All Categories",
157
+ str(count) if count else "-",
158
+ f"{score:.3f}" if score is not None else "-",
159
+ ]
160
+ for cat in categories:
161
+ metrics = cat_avg[cat].get(pipeline, {})
162
+ score = metrics.get(sort_metric)
163
+ count = int(metrics.get("num_records", 0))
164
+ rows.append(
165
+ [
166
+ cat,
167
+ str(count) if count else "-",
168
+ f"{score:.3f}" if score is not None else "-",
169
+ ]
170
+ )
171
+ print(
172
+ tabulate(
173
+ rows,
174
+ headers=["Category", "# Records", prettify(sort_metric)],
175
+ tablefmt="github",
176
+ numalign="right",
177
+ stralign="left",
178
+ )
179
+ )
180
+
181
+ # Aggregate
182
+ all_avg, cat_avg = collect_metrics(records)
183
+
184
+ # Print tables
185
+ print_table(all_avg, label="Overall (All Categories Combined)")
186
+ for cat, metrics in sorted(cat_avg.items()):
187
+ print_table(metrics, label=f"Category: {cat}")
188
+
189
+ # Per-pipeline compact comparison
190
+ print_per_pipeline_tables(sort_by, all_avg, cat_avg)
191
+
192
+
193
+ def get_benchmark_statistics(benchmark_id: str, benchmarks_info: dict, pipeline_metrics: dict) -> dict:
194
+ """
195
+ Extract statistics for a benchmark including:
196
+ - Description from benchmarks.json
197
+ - Database type (sqlite/mysql)
198
+ - Number of records in benchmark data file
199
+ - Number of pipelines with predictions
200
+
201
+ Args:
202
+ benchmark_id: Benchmark identifier
203
+ benchmarks_info: Full benchmark metadata from get_benchmarks_info()
204
+ pipeline_metrics: Evaluation metrics for all pipelines
205
+
206
+ Returns:
207
+ dict with keys: description, db_type, num_records, num_pipelines
208
+ """
209
+ stats = {
210
+ "description": "N/A",
211
+ "db_type": "N/A",
212
+ "num_records": 0,
213
+ "num_pipelines": 0,
214
+ }
215
+
216
+ # Get benchmark metadata
217
+ if benchmark_id in benchmarks_info:
218
+ benchmark_info = benchmarks_info[benchmark_id]
219
+ stats["description"] = benchmark_info.get("description", "N/A")
220
+
221
+ # Extract db_type from db_engine
222
+ db_engine = benchmark_info.get("db_engine", {})
223
+ stats["db_type"] = db_engine.get("db_type", "N/A")
224
+
225
+ # Count records from benchmark data file
226
+ try:
227
+ benchmark_data_path = benchmark_info.get("benchmark_json_path")
228
+ if benchmark_data_path and Path(benchmark_data_path).exists():
229
+ with open(benchmark_data_path, "r") as f:
230
+ data = json.load(f)
231
+ stats["num_records"] = len(data) if isinstance(data, list) else 0
232
+ except Exception as e:
233
+ logger.warning(f"Could not count records for {benchmark_id}: {e}")
234
+
235
+ # Count pipelines (exclude llm_judge_config if present)
236
+ if pipeline_metrics:
237
+ stats["num_pipelines"] = len([k for k in pipeline_metrics.keys() if k != "llm_judge_config"])
238
+
239
+ return stats
240
+
241
+
242
+ def generate_toc_section(results: dict, benchmarks_info: dict, sort_by: str = DEFAULT_METRIC) -> str:
243
+ """
244
+ Generate table of contents with benchmark summaries.
245
+
246
+ Args:
247
+ results: Dict mapping benchmark_id to (eval_path, eval_relpath, metrics)
248
+ benchmarks_info: Full benchmark metadata
249
+ sort_by: Metric used for sorting (for anchor link generation)
250
+
251
+ Returns:
252
+ Markdown string for TOC section
253
+ """
254
+ toc_lines = []
255
+
256
+ # Table header
257
+ toc_lines.append("| Benchmark | Description | DB Type | Records | Pipelines |")
258
+ toc_lines.append("|-----------|-------------|---------|---------|-----------|")
259
+
260
+ # Generate rows for each benchmark
261
+ for benchmark_id, (eval_results_path, eval_results_relpath, pipeline_metrics) in results.items():
262
+ stats = get_benchmark_statistics(benchmark_id, benchmarks_info, pipeline_metrics)
263
+
264
+ # Create anchor link (matches GitHub's automatic heading anchor generation)
265
+ # GitHub converts: lowercase, removes special chars, keeps underscores, replaces spaces with hyphens
266
+ # Format: #benchmark-{benchmark_id}
267
+ anchor = f"#benchmark-{benchmark_id}".lower()
268
+
269
+ # Create table row
270
+ row = [
271
+ f"[{benchmark_id}]({anchor})",
272
+ stats["description"],
273
+ stats["db_type"],
274
+ str(stats["num_records"]),
275
+ str(stats["num_pipelines"]),
276
+ ]
277
+ toc_lines.append("| " + " | ".join(row) + " |")
278
+
279
+ toc_lines.append("\n---\n")
280
+
281
+ return "\n".join(toc_lines)
282
+
283
+
284
+ def collect_results(output_folder: Path, is_test: bool = False):
285
+ benchmarks_info = get_benchmarks_info(is_test=is_test)
286
+ results = {}
287
+ for benchmark_id, benchmark_info in benchmarks_info.items():
288
+ # Skip test benchmarks in production mode, but include all in test mode
289
+ if not is_test and "test" in benchmark_id:
290
+ continue
291
+ eval_summary_path = Path(benchmark_info["eval_summary_path"]).resolve()
292
+ eval_results_path = Path(benchmark_info["eval_results_path"]).resolve()
293
+ eval_results_relpath = eval_results_path.relative_to(output_folder.resolve())
294
+ if eval_results_path.exists():
295
+ with open(eval_summary_path, "r") as f:
296
+ data = json.load(f)
297
+ data.pop("llm_judge_config", None)
298
+ results[benchmark_id] = (eval_results_path, eval_results_relpath, data)
299
+ return results, benchmarks_info
300
+
301
+
302
+ def generate_bar_chart(output_file_path, pipeline_metrics, title, chart_filename):
303
+ pipelines = list(pipeline_metrics.keys())
304
+ subset_scores = [
305
+ pipeline_metrics[m]
306
+ .get("subset_non_empty_execution_accuracy", {})
307
+ .get("average", 0)
308
+ for m in pipelines
309
+ ]
310
+ non_empty_scores = [
311
+ pipeline_metrics[m].get("non_empty_execution_accuracy", {}).get("average", 0)
312
+ for m in pipelines
313
+ ]
314
+
315
+ has_llm_score = any("llm_score" in pipeline_metrics[p] for p in pipelines)
316
+ if has_llm_score:
317
+ llm_scores = [
318
+ pipeline_metrics[p].get("llm_score", {}).get("average", 0)
319
+ for p in pipelines
320
+ ]
321
+
322
+ x = range(len(pipelines))
323
+ width = 0.25 if has_llm_score else 0.35
324
+
325
+ fig, ax = plt.subplots(figsize=(12, 6))
326
+ ax.bar(
327
+ [i - width for i in x],
328
+ subset_scores,
329
+ width,
330
+ label="Subset Non-Empty Exec Acc",
331
+ )
332
+ ax.bar(x, non_empty_scores, width, label="Non-Empty Exec Acc")
333
+
334
+ if has_llm_score:
335
+ ax.bar([i + width for i in x], llm_scores, width, label="LLM Score")
336
+
337
+ ax.set_ylabel("Accuracy")
338
+ ax.set_title(title)
339
+ ax.set_xticks(x)
340
+ ax.set_xticklabels(pipelines, rotation=45, ha="right")
341
+ ax.legend()
342
+ ax.set_ylim(0, 1)
343
+
344
+ charts_dir = Path(output_file_path).parent / "charts"
345
+ pathlib.Path(charts_dir).mkdir(exist_ok=True)
346
+ chart_path = charts_dir / chart_filename
347
+ plt.tight_layout()
348
+ plt.savefig(chart_path)
349
+ plt.close()
350
+ return chart_path
351
+
352
+
353
+ def generate_markdown_table(
354
+ output_file_path,
355
+ benchmark,
356
+ eval_results_path,
357
+ eval_results_relpath,
358
+ pipeline_metrics,
359
+ sort_by,
360
+ ):
361
+ rows = []
362
+ header = [
363
+ "Rank",
364
+ "Model / Pipeline",
365
+ "Execution Acc",
366
+ "Non-Empty Exec Acc",
367
+ "Subset Non-Empty Exec Acc",
368
+ "BIRD Exec Acc",
369
+ "LLM Judge Score",
370
+ "Parsable SQL",
371
+ "SQL Syntactic Match",
372
+ "Eval Err",
373
+ "DF Err",
374
+ "Avg Tokens/Q",
375
+ "Avg Inference (ms)",
376
+ "Avg Execution (ms)",
377
+ "Total Tokens",
378
+ "Total Inference (ms)",
379
+ "Total Execution (ms)",
380
+ "#Records",
381
+ "#Predictions",
382
+ "#Evaluated",
383
+ "#Correct Non-Empty Exec Acc",
384
+ "#Correct Subset Non-Empty Exec Acc",
385
+ "#Correct As Per LLM Judge",
386
+ ]
387
+ metric_keys = {
388
+ "Execution Acc": "execution_accuracy",
389
+ "Non-Empty Exec Acc": "non_empty_execution_accuracy",
390
+ "Subset Non-Empty Exec Acc": "subset_non_empty_execution_accuracy",
391
+ "BIRD Exec Acc": "bird_execution_accuracy",
392
+ "LLM Judge Score": "llm_score",
393
+ "Parsable SQL": "is_sqlparse_parsable",
394
+ "SQL Syntactic Match": "sql_syntactic_equivalence",
395
+ "Eval Err": "eval_error",
396
+ "DF Err": "df_error",
397
+ "Avg Tokens/Q": "total_tokens",
398
+ "Avg Inference (ms)": "inference_time_ms",
399
+ "Avg Execution (ms)": "execution_time_ms",
400
+ }
401
+ count_keys = {
402
+ "Total Tokens": "sum_total_tokens",
403
+ "Total Inference (ms)": "sum_inference_time_ms",
404
+ "Total Execution (ms)": "sum_execution_time_ms",
405
+ "#Records": "num_records",
406
+ "#Predictions": "num_predictions",
407
+ "#Evaluated": "num_evaluated",
408
+ "#Correct Non-Empty Exec Acc": "num_correct_non_empty_execution_accuracy",
409
+ "#Correct Subset Non-Empty Exec Acc": "num_correct_subset_non_empty_execution_accuracy",
410
+ "#Correct As Per LLM Judge": "num_correct_llm",
411
+ }
412
+ if "llm_judge_config" in pipeline_metrics:
413
+ pipeline_metrics.pop("llm_judge_config")
414
+ sorted_pipelines = sorted(
415
+ pipeline_metrics.items(),
416
+ key=lambda x: x[1].get(sort_by, {}).get("average", 0.0),
417
+ reverse=True,
418
+ )
419
+
420
+ for rank, (pipeline, metrics) in enumerate(sorted_pipelines, start=1):
421
+ row = [str(rank), pipeline]
422
+ for label, key in metric_keys.items():
423
+ score = metrics.get(key, {}).get("average", None)
424
+ row.append(f"{score:.2f}" if score is not None else "N/A")
425
+ for label, key in count_keys.items():
426
+ val = metrics.get(key, None)
427
+ row.append(str(val) if val is not None else "N/A")
428
+ rows.append(row)
429
+
430
+ table_md = f"### Benchmark: {benchmark}\n\n"
431
+ table_md += f"_Results sorted by default on `{sort_by}` (higher is better)_\n\n"
432
+ with eval_results_path.open("r") as eval_results_file:
433
+ records = json.load(eval_results_file)
434
+ summary_md_path = eval_results_path.with_name(
435
+ eval_results_path.stem + "_summary.md"
436
+ )
437
+ summary_md_relpath = summary_md_path.relative_to(
438
+ Path(output_file_path).parent.resolve()
439
+ )
440
+ export_summary_results_by_category_to_markdown(records, summary_md_path)
441
+ errors_path = eval_results_path.with_name(eval_results_path.stem + "_errors.md")
442
+ errors_relpath = errors_path.relative_to(
443
+ Path(output_file_path).parent.resolve()
444
+ )
445
+ export_failed_examples_to_markdown(records, errors_path)
446
+ # table_md += f"📄 [View Evaluation Results JSON]({eval_results_relpath})\n\n"
447
+ # table_md += f"📄 [View In-Depth Summary Results Across Categories]({summary_md_relpath}) - [View Examples of Errors for Error Analysis]({errors_relpath}) - [View Full Results JSON]({eval_results_relpath})\n\n"
448
+
449
+ gitignore_path = Path(".gitignore")
450
+
451
+ # Default: include full results
452
+ show_full_results = True
453
+
454
+ if gitignore_path.exists():
455
+ ignored_files = {
456
+ Path(line.strip()).name
457
+ for line in gitignore_path.read_text().splitlines()
458
+ if line.strip() and not line.strip().startswith("#")
459
+ }
460
+ if Path(eval_results_relpath).name in ignored_files:
461
+ show_full_results = False
462
+
463
+ table_md += (
464
+ f"📄 [View In-Depth Summary Results Across Categories]({summary_md_relpath})"
465
+ f" - [View Examples of Errors for Error Analysis]({errors_relpath})"
466
+ )
467
+
468
+ if show_full_results:
469
+ table_md += f" - [View Full Results JSON]({eval_results_relpath})"
470
+
471
+ table_md += "\n\n"
472
+
473
+ table_md += "| " + " | ".join(header) + " |\n"
474
+ table_md += "| " + " | ".join(["---"] * len(header)) + " |\n"
475
+ for row in rows:
476
+ table_md += "| " + " | ".join(row) + " |\n"
477
+ table_md += "\n"
478
+
479
+ chart_filename = summary_md_relpath.stem + ".png"
480
+ chart_path = generate_bar_chart(
481
+ output_file_path,
482
+ pipeline_metrics,
483
+ f"Overall Accuracy - {benchmark}",
484
+ chart_filename,
485
+ )
486
+ chart_rel_path = Path(chart_path).relative_to(Path(output_file_path).parent)
487
+ table_md += f"![Chart for {benchmark}]({chart_rel_path})\n\n"
488
+ # table_md += f'<img src="{chart_rel_path}" alt="Chart for {benchmark}" width="50%"/>\n\n'
489
+
490
+ return table_md
491
+
492
+
493
+ def create_dashboard(output_file_path: str, results, benchmarks_info, sort_by=DEFAULT_METRIC):
494
+ markdown = "# Text-to-SQL Evaluation Results Dashboard\n\n"
495
+
496
+ # Add table of contents
497
+ markdown += generate_toc_section(results, benchmarks_info, sort_by)
498
+
499
+ for benchmark, (
500
+ eval_results_path,
501
+ eval_results_relpath,
502
+ pipeline_metrics,
503
+ ) in results.items():
504
+ markdown += generate_markdown_table(
505
+ output_file_path,
506
+ benchmark,
507
+ eval_results_path,
508
+ eval_results_relpath,
509
+ pipeline_metrics,
510
+ sort_by,
511
+ )
512
+
513
+ return markdown
514
+
515
+
516
+ def export_summary_results_by_category_to_markdown(
517
+ records, category_summary_path, sort_by=DEFAULT_METRIC
518
+ ):
519
+ """
520
+ Creates a markdown file summarizing evaluation results overall and by category.
521
+ """
522
+
523
+ def collect_metrics(records):
524
+ """Aggregate average metrics and record counts per pipeline and per category."""
525
+
526
+ category_metrics = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
527
+ all_metrics = defaultdict(lambda: defaultdict(list))
528
+
529
+ for rec in records:
530
+ categories = rec.get("meta", {}).get("categories", [])
531
+ predictions = rec.get("predictions", {})
532
+
533
+ for pipeline, pred_info in predictions.items():
534
+ eval_metrics = pred_info.get("evaluation", {})
535
+ for metric_name, metric_value in eval_metrics.items():
536
+ if isinstance(metric_value, (int, float)):
537
+ all_metrics[pipeline][metric_name].append(metric_value)
538
+ for cat in categories:
539
+ category_metrics[cat][pipeline][metric_name].append(
540
+ metric_value
541
+ )
542
+
543
+ # Count one record per example
544
+ all_metrics[pipeline]["num_records"].append(1)
545
+ for cat in categories:
546
+ category_metrics[cat][pipeline]["num_records"].append(1)
547
+
548
+ def avg_metrics(metrics_dict):
549
+ """Return dict[pipeline][metric_name] = average OR count"""
550
+ return {
551
+ pipeline: {
552
+ metric: (sum(values) if metric == "num_records" else mean(values))
553
+ for metric, values in metric_dict.items()
554
+ }
555
+ for pipeline, metric_dict in metrics_dict.items()
556
+ }
557
+
558
+ return avg_metrics(all_metrics), {
559
+ cat: avg_metrics(p) for cat, p in category_metrics.items()
560
+ }
561
+
562
+ def generate_table_md(pipeline_metrics, sort_by):
563
+ pipelines_sorted = sorted(
564
+ pipeline_metrics.items(),
565
+ key=lambda x: x[1].get(sort_by, 0) or 0,
566
+ reverse=True,
567
+ )
568
+ headers = [
569
+ "Rank",
570
+ "Pipeline",
571
+ "Records #",
572
+ "Predictions #",
573
+ "Exec Acc",
574
+ "Non-Empty Exec Acc",
575
+ "Subset Non-Empty Exec Acc",
576
+ "BIRD Exec Acc",
577
+ "Parsable SQL",
578
+ "Syntactic Equivalence Score",
579
+ "LLM Score",
580
+ ]
581
+ rows = []
582
+ for rank, (pipeline, m) in enumerate(pipelines_sorted, start=1):
583
+ rows.append(
584
+ [
585
+ rank,
586
+ pipeline,
587
+ f"{(m.get('num_records') or 0)}",
588
+ f"{(m.get('num_predictions') or 0)}",
589
+ f"{(m.get('execution_accuracy') or 0):.2f}",
590
+ f"{(m.get('non_empty_execution_accuracy') or 0):.2f}",
591
+ f"{(m.get('subset_non_empty_execution_accuracy') or 0):.2f}",
592
+ f"{(m.get('bird_execution_accuracy') or 0):.2f}",
593
+ f"{(m.get('is_sqlparse_parsable') or 0):.2f}",
594
+ f"{(m.get('sql_syntactic_equivalence') or 0):.2f}",
595
+ f"{(m.get('llm_score') or 0):.2f}",
596
+ ]
597
+ )
598
+ md = "| " + " | ".join(headers) + " |\n"
599
+ md += "| " + " | ".join(["---"] * len(headers)) + " |\n"
600
+ for row in rows:
601
+ md += "| " + " | ".join(map(str, row)) + " |\n"
602
+ return md
603
+
604
+ def make_safe_filename(s: str, replacement="_"):
605
+ return re.sub(r"[^A-Za-z0-9_.-]", replacement, s)
606
+
607
+ def convert_avg_to_pipeline_metrics(avg_metrics):
608
+ """
609
+ Convert from:
610
+ {pipeline: {metric_name: avg_value}}
611
+ To:
612
+ {pipeline: {metric_name: {"average": avg_value}}}
613
+ so that generate_bar_chart() works without changes.
614
+ """
615
+ out = {}
616
+ for pipeline, metrics in avg_metrics.items():
617
+ out[pipeline] = {}
618
+ for mname, avg_val in metrics.items():
619
+ out[pipeline][mname] = {
620
+ "average": avg_val if avg_val is not None else 0
621
+ }
622
+ return out
623
+
624
+ # --- Aggregate ---
625
+ all_avg, cat_avg = collect_metrics(records)
626
+
627
+ # --- Build markdown ---
628
+ md_lines = ["# Summary Results\n"]
629
+
630
+ # Overall
631
+ md_lines.append("## Overall Average Accuracy Results\n")
632
+ overall_pipeline_metrics = convert_avg_to_pipeline_metrics(all_avg)
633
+
634
+ eval_summary_filename = Path(category_summary_path).stem
635
+ chart_filename = eval_summary_filename + ".png"
636
+ chart_path = generate_bar_chart(
637
+ category_summary_path,
638
+ overall_pipeline_metrics,
639
+ f"Overall Accuracy - {eval_summary_filename}",
640
+ chart_filename,
641
+ )
642
+ md_lines.append(generate_table_md(all_avg, sort_by))
643
+ md_lines.append(
644
+ f"\n![Overall Chart]({Path(chart_path).relative_to(Path(category_summary_path).parent)})\n"
645
+ )
646
+
647
+ # Per category
648
+ for cat, metrics in sorted(cat_avg.items()):
649
+ md_lines.append(f"\n## Category: `{cat}`\n")
650
+ cat_pipeline_metrics = convert_avg_to_pipeline_metrics(metrics)
651
+ category_chart_filename = (
652
+ eval_summary_filename + "-" + make_safe_filename(cat) + ".png"
653
+ )
654
+ chart_path = generate_bar_chart(
655
+ category_summary_path,
656
+ cat_pipeline_metrics,
657
+ f"Accuracy for Category: {cat} - {eval_summary_filename}",
658
+ category_chart_filename,
659
+ )
660
+ md_lines.append(generate_table_md(metrics, sort_by))
661
+ md_lines.append(
662
+ f"\n![Chart for {cat}]({Path(chart_path).relative_to(Path(category_summary_path).parent)})\n"
663
+ )
664
+
665
+ # Per-Pipeline Category Comparison
666
+ md_lines.append("\n# Per-Pipeline Comparison Across Categories\n")
667
+
668
+ SELECTED_METRICS = [
669
+ "execution_accuracy",
670
+ "non_empty_execution_accuracy",
671
+ "subset_non_empty_execution_accuracy",
672
+ "bird_execution_accuracy",
673
+ "llm_score",
674
+ ]
675
+
676
+ def metric_label(name):
677
+ return {
678
+ "execution_accuracy": "Exec Acc",
679
+ "non_empty_execution_accuracy": "Non-Empty Exec Acc",
680
+ "subset_non_empty_execution_accuracy": "Subset Non-Empty Exec Acc",
681
+ "bird_execution_accuracy": "BIRD Exec Acc",
682
+ "llm_score": "LLM Score",
683
+ }.get(name, name)
684
+
685
+ all_pipelines = sorted(
686
+ {p for cat_data in cat_avg.values() for p in cat_data.keys()}
687
+ )
688
+ sorted_categories = sorted(cat_avg.keys())
689
+
690
+ for pipeline in all_pipelines:
691
+ md_lines.append(f"\n### Pipeline: `{pipeline}`")
692
+ headers = ["Category", "# Records", "# Predictions"] + [
693
+ metric_label(m) for m in SELECTED_METRICS
694
+ ]
695
+ md_lines.append("| " + " | ".join(headers) + " |")
696
+ md_lines.append("|" + "|".join(["---"] * len(headers)) + "|")
697
+
698
+ p_metrics = all_avg.get(pipeline, {})
699
+ count = int(p_metrics.get("num_records", 0))
700
+ pred_count = int(p_metrics.get("num_predictions", 0))
701
+ row = ["All Categories", str(count), str(pred_count)]
702
+ for m in SELECTED_METRICS:
703
+ val = p_metrics.get(m)
704
+ row.append(f"{val:.3f}" if val is not None else "-")
705
+ md_lines.append("| " + " | ".join(row) + " |")
706
+ for cat in sorted_categories:
707
+ p_metrics = cat_avg[cat].get(pipeline, {})
708
+ count = int(p_metrics.get("num_records", 0))
709
+ pred_count = int(p_metrics.get("num_predictions", 0))
710
+ row = [cat, str(count), str(pred_count)]
711
+ for m in SELECTED_METRICS:
712
+ val = p_metrics.get(m)
713
+ row.append(f"{val:.3f}" if val is not None else "-")
714
+ md_lines.append("| " + " | ".join(row) + " |")
715
+
716
+ with open(category_summary_path, "w", encoding="utf-8") as f:
717
+ f.write("\n".join(md_lines))
718
+
719
+ logger.info(f"✅ Results summary markdown saved to {category_summary_path}")