parse-bench 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.
- parse_bench/__init__.py +3 -0
- parse_bench/analysis/__init__.py +6 -0
- parse_bench/analysis/aggregation_report.py +582 -0
- parse_bench/analysis/cli.py +472 -0
- parse_bench/analysis/comparison.py +382 -0
- parse_bench/analysis/comparison_core.py +357 -0
- parse_bench/analysis/comparison_report.py +2066 -0
- parse_bench/analysis/detailed_report.py +2254 -0
- parse_bench/analysis/leaderboard_report.py +852 -0
- parse_bench/analysis/metric_definitions.py +771 -0
- parse_bench/cli.py +267 -0
- parse_bench/data/__init__.py +1 -0
- parse_bench/data/cli.py +118 -0
- parse_bench/data/download.py +127 -0
- parse_bench/evaluation/__init__.py +11 -0
- parse_bench/evaluation/cli.py +435 -0
- parse_bench/evaluation/evaluators/__init__.py +17 -0
- parse_bench/evaluation/evaluators/base.py +34 -0
- parse_bench/evaluation/evaluators/extract.py +429 -0
- parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
- parse_bench/evaluation/evaluators/parse.py +1353 -0
- parse_bench/evaluation/evaluators/qa.py +199 -0
- parse_bench/evaluation/layout_adapters/__init__.py +21 -0
- parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
- parse_bench/evaluation/layout_adapters/base.py +105 -0
- parse_bench/evaluation/layout_adapters/registry.py +109 -0
- parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
- parse_bench/evaluation/layout_label_mappers/base.py +66 -0
- parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
- parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
- parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
- parse_bench/evaluation/metric_aggregation.py +56 -0
- parse_bench/evaluation/metrics/__init__.py +5 -0
- parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
- parse_bench/evaluation/metrics/attribution/constants.py +12 -0
- parse_bench/evaluation/metrics/attribution/core.py +1108 -0
- parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
- parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
- parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
- parse_bench/evaluation/metrics/base.py +33 -0
- parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
- parse_bench/evaluation/metrics/extract/__init__.py +29 -0
- parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
- parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
- parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
- parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
- parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
- parse_bench/evaluation/metrics/extract/test_types.py +11 -0
- parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
- parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
- parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
- parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
- parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
- parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
- parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
- parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
- parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
- parse_bench/evaluation/metrics/parse/__init__.py +5 -0
- parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
- parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
- parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
- parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
- parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
- parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
- parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
- parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
- parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
- parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
- parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
- parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
- parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
- parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
- parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
- parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
- parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
- parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
- parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
- parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
- parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
- parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
- parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
- parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
- parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
- parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
- parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
- parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
- parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
- parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
- parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
- parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
- parse_bench/evaluation/metrics/parse/test_types.py +103 -0
- parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
- parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
- parse_bench/evaluation/metrics/parse/utils.py +885 -0
- parse_bench/evaluation/metrics/qa/__init__.py +5 -0
- parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
- parse_bench/evaluation/qa/__init__.py +5 -0
- parse_bench/evaluation/qa/llm_service.py +335 -0
- parse_bench/evaluation/reports/__init__.py +8 -0
- parse_bench/evaluation/reports/csv.py +64 -0
- parse_bench/evaluation/reports/html.py +338 -0
- parse_bench/evaluation/reports/markdown.py +98 -0
- parse_bench/evaluation/reports/rule_csv.py +22 -0
- parse_bench/evaluation/runner.py +1864 -0
- parse_bench/evaluation/stats.py +104 -0
- parse_bench/extensions.py +72 -0
- parse_bench/inference/__init__.py +33 -0
- parse_bench/inference/chunkr_layout_extraction.py +160 -0
- parse_bench/inference/cli.py +484 -0
- parse_bench/inference/layout_extraction.py +422 -0
- parse_bench/inference/pipelines/__init__.py +59 -0
- parse_bench/inference/pipelines/extract.py +39 -0
- parse_bench/inference/pipelines/layout.py +142 -0
- parse_bench/inference/pipelines/parse.py +2603 -0
- parse_bench/inference/pipelines.py +0 -0
- parse_bench/inference/providers/__init__.py +28 -0
- parse_bench/inference/providers/base.py +196 -0
- parse_bench/inference/providers/cancellation.py +137 -0
- parse_bench/inference/providers/extract/__init__.py +22 -0
- parse_bench/inference/providers/extract/citations.py +549 -0
- parse_bench/inference/providers/extract/extend.py +851 -0
- parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
- parse_bench/inference/providers/layoutdet/__init__.py +25 -0
- parse_bench/inference/providers/layoutdet/adapters.py +946 -0
- parse_bench/inference/providers/layoutdet/base.py +203 -0
- parse_bench/inference/providers/layoutdet/chandra.py +449 -0
- parse_bench/inference/providers/layoutdet/docling.py +125 -0
- parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
- parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
- parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
- parse_bench/inference/providers/layoutdet/paddle.py +117 -0
- parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
- parse_bench/inference/providers/layoutdet/surya.py +250 -0
- parse_bench/inference/providers/layoutdet/yolo.py +109 -0
- parse_bench/inference/providers/parse/__init__.py +64 -0
- parse_bench/inference/providers/parse/_docling_common.py +233 -0
- parse_bench/inference/providers/parse/_layout_utils.py +611 -0
- parse_bench/inference/providers/parse/amazon_nova.py +515 -0
- parse_bench/inference/providers/parse/anthropic.py +882 -0
- parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
- parse_bench/inference/providers/parse/chandra2.py +633 -0
- parse_bench/inference/providers/parse/chunkr.py +268 -0
- parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
- parse_bench/inference/providers/parse/datalab.py +370 -0
- parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
- parse_bench/inference/providers/parse/docling.py +281 -0
- parse_bench/inference/providers/parse/docling_serve.py +289 -0
- parse_bench/inference/providers/parse/dots_ocr.py +574 -0
- parse_bench/inference/providers/parse/extend_parse.py +710 -0
- parse_bench/inference/providers/parse/falconocr.py +436 -0
- parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
- parse_bench/inference/providers/parse/gemma4.py +472 -0
- parse_bench/inference/providers/parse/glm_zai.py +229 -0
- parse_bench/inference/providers/parse/google.py +1125 -0
- parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
- parse_bench/inference/providers/parse/google_docai.py +776 -0
- parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
- parse_bench/inference/providers/parse/granite_vision.py +515 -0
- parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
- parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
- parse_bench/inference/providers/parse/landingai.py +452 -0
- parse_bench/inference/providers/parse/liteparse.py +350 -0
- parse_bench/inference/providers/parse/llamaparse.py +677 -0
- parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
- parse_bench/inference/providers/parse/markitdown.py +138 -0
- parse_bench/inference/providers/parse/mineru25.py +405 -0
- parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
- parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
- parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
- parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
- parse_bench/inference/providers/parse/oi_parser.py +222 -0
- parse_bench/inference/providers/parse/openai.py +740 -0
- parse_bench/inference/providers/parse/opendataloader.py +152 -0
- parse_bench/inference/providers/parse/paddleocr.py +624 -0
- parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
- parse_bench/inference/providers/parse/pulse.py +785 -0
- parse_bench/inference/providers/parse/pymupdf.py +207 -0
- parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
- parse_bench/inference/providers/parse/pypdf.py +179 -0
- parse_bench/inference/providers/parse/qwen.py +678 -0
- parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
- parse_bench/inference/providers/parse/reducto.py +546 -0
- parse_bench/inference/providers/parse/surya2.py +372 -0
- parse_bench/inference/providers/parse/tesseract.py +301 -0
- parse_bench/inference/providers/parse/textract.py +694 -0
- parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
- parse_bench/inference/providers/parse/unstructured.py +485 -0
- parse_bench/inference/providers/parse/warp_ingest.py +199 -0
- parse_bench/inference/providers/registry.py +49 -0
- parse_bench/inference/renormalize.py +170 -0
- parse_bench/inference/runner.py +2023 -0
- parse_bench/layout_label_mapping.py +424 -0
- parse_bench/layout_projection.py +179 -0
- parse_bench/pipeline/__init__.py +1 -0
- parse_bench/pipeline/cli.py +549 -0
- parse_bench/schemas/__init__.py +33 -0
- parse_bench/schemas/evaluation.py +93 -0
- parse_bench/schemas/extract_output.py +36 -0
- parse_bench/schemas/layout_detection_output.py +545 -0
- parse_bench/schemas/layout_ontology.py +315 -0
- parse_bench/schemas/metrics.py +69 -0
- parse_bench/schemas/parse_output.py +152 -0
- parse_bench/schemas/pipeline.py +22 -0
- parse_bench/schemas/pipeline_io.py +106 -0
- parse_bench/schemas/product.py +97 -0
- parse_bench/test_cases/__init__.py +25 -0
- parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
- parse_bench/test_cases/extract_field_paths.py +164 -0
- parse_bench/test_cases/layout_attribution_generation.py +287 -0
- parse_bench/test_cases/loader.py +652 -0
- parse_bench/test_cases/parse_rule_schemas.py +1071 -0
- parse_bench/test_cases/rule_filters.py +32 -0
- parse_bench/test_cases/rule_ids.py +107 -0
- parse_bench/test_cases/schema.py +427 -0
- parse_bench/utils/__init__.py +15 -0
- parse_bench/utils/gemini_layout_utils.py +670 -0
- parse_bench/utils/text_aggregation.py +100 -0
- parse_bench-1.0.0.dist-info/METADATA +476 -0
- parse_bench-1.0.0.dist-info/RECORD +227 -0
- parse_bench-1.0.0.dist-info/WHEEL +4 -0
- parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
- parse_bench-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""TEDS (Tree Edit Distance based Similarity) metric for HTML table comparison.
|
|
2
|
+
|
|
3
|
+
This metric computes structural and content similarity between HTML tables
|
|
4
|
+
using the TEDS metric from the PubTabNet paper.
|
|
5
|
+
|
|
6
|
+
Three variants are supported:
|
|
7
|
+
- teds: Full content comparison (structure + Levenshtein on cell text)
|
|
8
|
+
- teds_struct: Structure-only (ignores cell text entirely)
|
|
9
|
+
- teds_struct_bool: Structure + boolean content awareness (penalizes when one
|
|
10
|
+
cell is empty and the other is not, but ignores the actual text)
|
|
11
|
+
|
|
12
|
+
Reference:
|
|
13
|
+
https://github.com/ibm-aur-nlp/PubTabNet/blob/master/src/metric.py
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from collections import deque
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import Levenshtein
|
|
20
|
+
import numpy as np
|
|
21
|
+
from apted import APTED, Config
|
|
22
|
+
from apted.helpers import Tree
|
|
23
|
+
from lxml import etree, html
|
|
24
|
+
|
|
25
|
+
from parse_bench.evaluation.metrics.base import Metric
|
|
26
|
+
from parse_bench.evaluation.metrics.parse import fast_tree_edit
|
|
27
|
+
from parse_bench.evaluation.metrics.parse.table_pairing import assign_tables, page_blocking_active
|
|
28
|
+
from parse_bench.evaluation.metrics.parse.utils import normalize_cell_text
|
|
29
|
+
from parse_bench.schemas.evaluation import MetricValue
|
|
30
|
+
|
|
31
|
+
# =============================================================================
|
|
32
|
+
# Variant names
|
|
33
|
+
# =============================================================================
|
|
34
|
+
|
|
35
|
+
TEDS_CONTENT = "teds"
|
|
36
|
+
TEDS_STRUCT = "teds_struct"
|
|
37
|
+
TEDS_STRUCT_BOOL = "teds_struct_bool"
|
|
38
|
+
ALL_TEDS_VARIANTS = frozenset({TEDS_CONTENT, TEDS_STRUCT, TEDS_STRUCT_BOOL})
|
|
39
|
+
|
|
40
|
+
# =============================================================================
|
|
41
|
+
# TEDS Implementation (adapted from PubTabNet)
|
|
42
|
+
# =============================================================================
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TableTree(Tree):
|
|
46
|
+
"""
|
|
47
|
+
Custom tree node for HTML table elements.
|
|
48
|
+
|
|
49
|
+
Stores tag name, colspan/rowspan for cells, and tokenized content.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
tag: str,
|
|
55
|
+
colspan: int | None = None,
|
|
56
|
+
rowspan: int | None = None,
|
|
57
|
+
content: list[str] | None = None,
|
|
58
|
+
*children: "TableTree",
|
|
59
|
+
):
|
|
60
|
+
self.tag = tag
|
|
61
|
+
self.colspan = colspan
|
|
62
|
+
self.rowspan = rowspan
|
|
63
|
+
self.content = content
|
|
64
|
+
self.children = list(children)
|
|
65
|
+
|
|
66
|
+
def bracket(self) -> str:
|
|
67
|
+
"""Show tree using brackets notation (for debugging)."""
|
|
68
|
+
if self.tag == "td":
|
|
69
|
+
result = f'"tag": {self.tag}, "colspan": {self.colspan}, "rowspan": {self.rowspan}, "text": {self.content}'
|
|
70
|
+
else:
|
|
71
|
+
result = f'"tag": {self.tag}'
|
|
72
|
+
for child in self.children:
|
|
73
|
+
result += child.bracket()
|
|
74
|
+
return "{" + result + "}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ContentConfig(Config):
|
|
78
|
+
"""
|
|
79
|
+
APTED configuration for full TEDS (structure + content).
|
|
80
|
+
|
|
81
|
+
Compares HTML table nodes by:
|
|
82
|
+
- Tag name (must match exactly)
|
|
83
|
+
- Colspan/rowspan attributes (must match exactly)
|
|
84
|
+
- Cell text content (compared using normalized Levenshtein distance)
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def maximum(*sequences: str) -> int:
|
|
89
|
+
"""Get maximum possible value for normalization."""
|
|
90
|
+
return max(map(len, sequences))
|
|
91
|
+
|
|
92
|
+
def normalized_distance(self, *sequences: str) -> float:
|
|
93
|
+
"""Get Levenshtein distance normalized to 0-1 range."""
|
|
94
|
+
if not sequences[0] and not sequences[1]:
|
|
95
|
+
return 0.0
|
|
96
|
+
max_len = self.maximum(*sequences)
|
|
97
|
+
if max_len == 0:
|
|
98
|
+
return 0.0
|
|
99
|
+
return float(Levenshtein.distance(sequences[0], sequences[1])) / max_len
|
|
100
|
+
|
|
101
|
+
def rename(self, node1: TableTree, node2: TableTree) -> float:
|
|
102
|
+
"""
|
|
103
|
+
Compute the cost of renaming node1 to node2.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
0.0 if nodes are identical
|
|
107
|
+
1.0 if tags or spans differ
|
|
108
|
+
Normalized Levenshtein distance for cell content
|
|
109
|
+
"""
|
|
110
|
+
# Tags must match
|
|
111
|
+
if node1.tag != node2.tag:
|
|
112
|
+
return 1.0
|
|
113
|
+
|
|
114
|
+
# For cells, colspan and rowspan must match
|
|
115
|
+
if node1.colspan != node2.colspan or node1.rowspan != node2.rowspan:
|
|
116
|
+
return 1.0
|
|
117
|
+
|
|
118
|
+
# For td cells, compare content
|
|
119
|
+
if node1.tag == "td":
|
|
120
|
+
if node1.content or node2.content:
|
|
121
|
+
s1 = normalize_cell_text("".join(node1.content) if node1.content else "")
|
|
122
|
+
s2 = normalize_cell_text("".join(node2.content) if node2.content else "")
|
|
123
|
+
return self.normalized_distance(s1, s2)
|
|
124
|
+
|
|
125
|
+
return 0.0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class StructConfig(Config):
|
|
129
|
+
"""
|
|
130
|
+
APTED configuration for TEDS-Struct (structure only).
|
|
131
|
+
|
|
132
|
+
Compares HTML table nodes by structure alone:
|
|
133
|
+
- Tag name (must match exactly)
|
|
134
|
+
- Colspan/rowspan attributes (must match exactly)
|
|
135
|
+
- Cell text content is ignored entirely
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def rename(self, node1: TableTree, node2: TableTree) -> float:
|
|
139
|
+
"""
|
|
140
|
+
Compute the cost of renaming node1 to node2 (structure only).
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
0.0 if tag and spans match
|
|
144
|
+
1.0 if tags or spans differ
|
|
145
|
+
"""
|
|
146
|
+
if node1.tag != node2.tag:
|
|
147
|
+
return 1.0
|
|
148
|
+
if node1.colspan != node2.colspan or node1.rowspan != node2.rowspan:
|
|
149
|
+
return 1.0
|
|
150
|
+
return 0.0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class StructBooleanContentConfig(Config):
|
|
154
|
+
"""
|
|
155
|
+
APTED configuration for TEDS-Struct with boolean content awareness.
|
|
156
|
+
|
|
157
|
+
Like StructConfig, but additionally penalizes mismatches in cell
|
|
158
|
+
emptiness: cost is 1.0 if one cell is empty and the other is not,
|
|
159
|
+
0.0 if both are empty or both are non-empty. The actual text content
|
|
160
|
+
is ignored — only its presence or absence matters.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def rename(self, node1: TableTree, node2: TableTree) -> float:
|
|
164
|
+
"""
|
|
165
|
+
Compute the cost of renaming node1 to node2 (structure + boolean content).
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
0.0 if tag/spans match and both cells are empty or both non-empty
|
|
169
|
+
1.0 if tags or spans differ, or one cell is empty and the other isn't
|
|
170
|
+
"""
|
|
171
|
+
if node1.tag != node2.tag:
|
|
172
|
+
return 1.0
|
|
173
|
+
if node1.colspan != node2.colspan or node1.rowspan != node2.rowspan:
|
|
174
|
+
return 1.0
|
|
175
|
+
if node1.tag == "td":
|
|
176
|
+
s1 = normalize_cell_text("".join(node1.content) if node1.content else "")
|
|
177
|
+
s2 = normalize_cell_text("".join(node2.content) if node2.content else "")
|
|
178
|
+
if bool(s1) != bool(s2):
|
|
179
|
+
return 1.0
|
|
180
|
+
return 0.0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# Map variant names to their Config classes
|
|
184
|
+
VARIANT_CONFIGS: dict[str, type[Config]] = {
|
|
185
|
+
TEDS_CONTENT: ContentConfig,
|
|
186
|
+
TEDS_STRUCT: StructConfig,
|
|
187
|
+
TEDS_STRUCT_BOOL: StructBooleanContentConfig,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _edit_distance(tree_pred: "TableTree", tree_true: "TableTree", config: Config) -> float:
|
|
192
|
+
"""Ordered tree-edit distance between two table trees.
|
|
193
|
+
|
|
194
|
+
Prefers the fast Zhang-Shasha implementation in ``fast_tree_edit`` (numba
|
|
195
|
+
JIT when available, pure-Python otherwise). Zhang-Shasha computes the
|
|
196
|
+
*same* optimal ordered tree-edit distance as APTED, so the TEDS score is
|
|
197
|
+
unchanged — this is a speedup, not a re-definition of the metric. Any
|
|
198
|
+
unexpected failure (or ``PARSEBENCH_FAST_TEDS=0``) falls back to APTED so
|
|
199
|
+
the metric never breaks on an edge case the fast path hasn't seen.
|
|
200
|
+
"""
|
|
201
|
+
if fast_tree_edit.FAST_TEDS_ENABLED:
|
|
202
|
+
try:
|
|
203
|
+
return fast_tree_edit.tree_edit_distance(tree_pred, tree_true, config)
|
|
204
|
+
except Exception as e: # pragma: no cover - defensive: never fail scoring
|
|
205
|
+
fast_tree_edit._log_once(f"FALLBACK: fast tree-edit failed -> APTED. cause={e!r}")
|
|
206
|
+
return float(APTED(tree_pred, tree_true, config).compute_edit_distance())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TEDS:
|
|
210
|
+
"""
|
|
211
|
+
Tree Edit Distance based Similarity metric for HTML tables.
|
|
212
|
+
|
|
213
|
+
Computes similarity between two HTML tables using tree edit distance.
|
|
214
|
+
Supports multiple scoring variants in a single call by sharing the
|
|
215
|
+
HTML parsing and tree construction work.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(
|
|
219
|
+
self,
|
|
220
|
+
ignore_nodes: list[str] | None = None,
|
|
221
|
+
variants: set[str] | None = None,
|
|
222
|
+
):
|
|
223
|
+
self.ignore_nodes = ignore_nodes
|
|
224
|
+
self.variants = variants if variants is not None else ALL_TEDS_VARIANTS
|
|
225
|
+
self._tokens: list[str] = []
|
|
226
|
+
|
|
227
|
+
def _tokenize(self, node: Any) -> None:
|
|
228
|
+
"""Tokenize table cell content into a list of tokens."""
|
|
229
|
+
self._tokens.append(f"<{node.tag}>")
|
|
230
|
+
if node.text is not None:
|
|
231
|
+
self._tokens.extend(list(node.text))
|
|
232
|
+
for child in node.getchildren():
|
|
233
|
+
self._tokenize(child)
|
|
234
|
+
if node.tag != "unk":
|
|
235
|
+
self._tokens.append(f"</{node.tag}>")
|
|
236
|
+
if node.tag != "td" and node.tail is not None:
|
|
237
|
+
self._tokens.extend(list(node.tail))
|
|
238
|
+
|
|
239
|
+
def _load_html_tree(self, node: Any, parent: TableTree | None = None) -> TableTree | None:
|
|
240
|
+
"""
|
|
241
|
+
Convert an lxml HTML element to a TableTree for APTED.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
node: lxml element node
|
|
245
|
+
parent: Parent TableTree node (for recursive building)
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Root TableTree node if parent is None, else None
|
|
249
|
+
"""
|
|
250
|
+
if node.tag == "td" or node.tag == "th":
|
|
251
|
+
# For cells, extract content tokens
|
|
252
|
+
self._tokens = []
|
|
253
|
+
self._tokenize(node)
|
|
254
|
+
cell_content = self._tokens[1:-1].copy()
|
|
255
|
+
|
|
256
|
+
new_node = TableTree(
|
|
257
|
+
node.tag,
|
|
258
|
+
int(node.attrib.get("colspan", "1")),
|
|
259
|
+
int(node.attrib.get("rowspan", "1")),
|
|
260
|
+
cell_content,
|
|
261
|
+
*deque(),
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
new_node = TableTree(node.tag, None, None, None, *deque())
|
|
265
|
+
|
|
266
|
+
if parent is not None:
|
|
267
|
+
parent.children.append(new_node)
|
|
268
|
+
|
|
269
|
+
# Recursively process children (but not for cells - their content is tokenized)
|
|
270
|
+
if node.tag not in ("td", "th"):
|
|
271
|
+
for child in node.getchildren():
|
|
272
|
+
self._load_html_tree(child, new_node)
|
|
273
|
+
|
|
274
|
+
if parent is None:
|
|
275
|
+
return new_node
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
def evaluate(self, pred: str, true: str) -> tuple[dict[str, float], int, int]:
|
|
279
|
+
"""
|
|
280
|
+
Compute TEDS scores between predicted and ground truth HTML tables.
|
|
281
|
+
|
|
282
|
+
Parses HTML and builds trees once, then runs APTED for each requested
|
|
283
|
+
variant. APTED does not mutate the TableTree nodes, so the same trees
|
|
284
|
+
are safely reused across calls.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
pred: Predicted HTML table string
|
|
288
|
+
true: Ground truth HTML table string
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Tuple of (scores_dict, gt_nodes, pred_nodes) where scores_dict
|
|
292
|
+
maps variant name to its TEDS score.
|
|
293
|
+
"""
|
|
294
|
+
empty_scores = dict.fromkeys(self.variants, 0.0)
|
|
295
|
+
|
|
296
|
+
if not pred or not true:
|
|
297
|
+
return (empty_scores, 0, 0)
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
parser = html.HTMLParser(remove_comments=True, encoding="utf-8")
|
|
301
|
+
pred_doc = html.fromstring(pred, parser=parser)
|
|
302
|
+
true_doc = html.fromstring(true, parser=parser)
|
|
303
|
+
except Exception:
|
|
304
|
+
return (empty_scores, 0, 0)
|
|
305
|
+
|
|
306
|
+
# Find table elements
|
|
307
|
+
if pred_doc.tag == "table":
|
|
308
|
+
pred_table = pred_doc
|
|
309
|
+
else:
|
|
310
|
+
pred_tables = pred_doc.xpath(".//table")
|
|
311
|
+
if not pred_tables:
|
|
312
|
+
return (empty_scores, 0, 0)
|
|
313
|
+
pred_table = pred_tables[0]
|
|
314
|
+
|
|
315
|
+
if true_doc.tag == "table":
|
|
316
|
+
true_table = true_doc
|
|
317
|
+
else:
|
|
318
|
+
true_tables = true_doc.xpath(".//table")
|
|
319
|
+
if not true_tables:
|
|
320
|
+
return (empty_scores, 0, 0)
|
|
321
|
+
true_table = true_tables[0]
|
|
322
|
+
|
|
323
|
+
# Optionally strip certain nodes
|
|
324
|
+
if self.ignore_nodes:
|
|
325
|
+
etree.strip_tags(pred_table, *self.ignore_nodes)
|
|
326
|
+
etree.strip_tags(true_table, *self.ignore_nodes)
|
|
327
|
+
|
|
328
|
+
# Count nodes for normalization
|
|
329
|
+
n_nodes_pred = len(pred_table.xpath(".//*")) + 1 # +1 for root
|
|
330
|
+
n_nodes_true = len(true_table.xpath(".//*")) + 1
|
|
331
|
+
n_nodes = max(n_nodes_pred, n_nodes_true)
|
|
332
|
+
|
|
333
|
+
if n_nodes == 0:
|
|
334
|
+
return (empty_scores, n_nodes_true, n_nodes_pred)
|
|
335
|
+
|
|
336
|
+
# Compute edit distance for each variant.
|
|
337
|
+
# We rebuild the TableTree for each variant since it's cheap (O(n))
|
|
338
|
+
# and avoids any potential issues with APTED's internal indexing.
|
|
339
|
+
scores: dict[str, float] = {}
|
|
340
|
+
for variant in self.variants:
|
|
341
|
+
tree_pred = self._load_html_tree(pred_table)
|
|
342
|
+
tree_true = self._load_html_tree(true_table)
|
|
343
|
+
# n_nodes > 0 (checked above) guarantees both trees are non-None.
|
|
344
|
+
assert tree_pred is not None and tree_true is not None
|
|
345
|
+
config = VARIANT_CONFIGS[variant]()
|
|
346
|
+
distance = _edit_distance(tree_pred, tree_true, config)
|
|
347
|
+
scores[variant] = max(0.0, 1.0 - (float(distance) / n_nodes))
|
|
348
|
+
|
|
349
|
+
return (scores, n_nodes_true, n_nodes_pred)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def extract_html_tables(content: str) -> list[str]:
|
|
353
|
+
"""
|
|
354
|
+
Extract all HTML tables from markdown content.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
content: Markdown content potentially containing HTML tables
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
List of HTML table strings
|
|
361
|
+
"""
|
|
362
|
+
if not content:
|
|
363
|
+
return []
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
parser = html.HTMLParser(remove_comments=True, encoding="utf-8")
|
|
367
|
+
doc = html.fromstring(content, parser=parser)
|
|
368
|
+
except Exception:
|
|
369
|
+
return []
|
|
370
|
+
|
|
371
|
+
tables = []
|
|
372
|
+
|
|
373
|
+
# If the root is a table, include it
|
|
374
|
+
if doc.tag == "table":
|
|
375
|
+
tables.append(etree.tostring(doc, encoding="unicode"))
|
|
376
|
+
else:
|
|
377
|
+
# Find all nested tables
|
|
378
|
+
for table in doc.xpath(".//table"):
|
|
379
|
+
tables.append(etree.tostring(table, encoding="unicode"))
|
|
380
|
+
|
|
381
|
+
return tables
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# =============================================================================
|
|
385
|
+
# TEDSMetric class
|
|
386
|
+
# =============================================================================
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class TEDSMetric(Metric):
|
|
390
|
+
"""
|
|
391
|
+
TEDS metric for comparing HTML tables in markdown content.
|
|
392
|
+
|
|
393
|
+
Computes Tree Edit Distance based Similarity between expected and actual
|
|
394
|
+
HTML tables. Auto-detects tables in both expected and actual markdown.
|
|
395
|
+
|
|
396
|
+
Supports multiple TEDS variants (content, struct, struct+empty) and
|
|
397
|
+
returns one MetricValue per variant.
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
def __init__(self, variants: set[str] | None = None):
|
|
401
|
+
"""
|
|
402
|
+
Initialize TEDSMetric.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
variants: Set of variant names to compute. Defaults to all variants.
|
|
406
|
+
Valid names: "teds", "teds_struct", "teds_struct_bool".
|
|
407
|
+
"""
|
|
408
|
+
self.variants = variants if variants is not None else set(ALL_TEDS_VARIANTS)
|
|
409
|
+
|
|
410
|
+
@property
|
|
411
|
+
def name(self) -> str:
|
|
412
|
+
"""Return the name of this metric."""
|
|
413
|
+
return "teds"
|
|
414
|
+
|
|
415
|
+
def compute( # type: ignore[override]
|
|
416
|
+
self,
|
|
417
|
+
expected: str,
|
|
418
|
+
actual: str,
|
|
419
|
+
*,
|
|
420
|
+
expected_pages: list[int] | None = None,
|
|
421
|
+
actual_pages: list[int] | None = None,
|
|
422
|
+
**kwargs: Any,
|
|
423
|
+
) -> list[MetricValue]:
|
|
424
|
+
"""
|
|
425
|
+
Compute TEDS scores between expected and actual markdown content.
|
|
426
|
+
|
|
427
|
+
Uses Hungarian algorithm for optimal table matching (based on
|
|
428
|
+
TEDS-Content scores when available, otherwise the first variant).
|
|
429
|
+
Then applies the same matching to all requested variants.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
expected: Expected markdown with HTML tables (ground truth)
|
|
433
|
+
actual: Actual markdown with HTML tables (from inference)
|
|
434
|
+
expected_pages: Optional page number (1-indexed) per GT table, aligned
|
|
435
|
+
with the tables extracted from ``expected``. When supplied with
|
|
436
|
+
``actual_pages`` (and length-consistent), table matching is solved
|
|
437
|
+
per page so a GT table only pairs with a prediction on the same
|
|
438
|
+
page. Omit for the document-global assignment.
|
|
439
|
+
actual_pages: Optional page number per predicted table, aligned with
|
|
440
|
+
the tables extracted from ``actual``.
|
|
441
|
+
kwargs: Additional parameters (not used)
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
List of MetricValues, one per requested variant.
|
|
445
|
+
"""
|
|
446
|
+
# Extract tables from both
|
|
447
|
+
expected_tables = extract_html_tables(expected)
|
|
448
|
+
actual_tables = extract_html_tables(actual)
|
|
449
|
+
|
|
450
|
+
# No tables in expected means we can't compute TEDS
|
|
451
|
+
if not expected_tables:
|
|
452
|
+
return [
|
|
453
|
+
MetricValue(
|
|
454
|
+
metric_name=variant,
|
|
455
|
+
value=0.0,
|
|
456
|
+
metadata={
|
|
457
|
+
"note": "No tables found in expected markdown",
|
|
458
|
+
"tables_found_expected": 0,
|
|
459
|
+
"tables_found_actual": len(actual_tables),
|
|
460
|
+
},
|
|
461
|
+
)
|
|
462
|
+
for variant in sorted(self.variants)
|
|
463
|
+
]
|
|
464
|
+
|
|
465
|
+
# No tables in actual means all tables are missing
|
|
466
|
+
if not actual_tables:
|
|
467
|
+
return [
|
|
468
|
+
MetricValue(
|
|
469
|
+
metric_name=variant,
|
|
470
|
+
value=0.0,
|
|
471
|
+
metadata={
|
|
472
|
+
"note": "No tables found in actual markdown",
|
|
473
|
+
"tables_found_expected": len(expected_tables),
|
|
474
|
+
"tables_found_actual": 0,
|
|
475
|
+
"tables_matched": 0,
|
|
476
|
+
},
|
|
477
|
+
)
|
|
478
|
+
for variant in sorted(self.variants)
|
|
479
|
+
]
|
|
480
|
+
|
|
481
|
+
teds_calculator = TEDS(variants=self.variants)
|
|
482
|
+
n_expected = len(expected_tables)
|
|
483
|
+
n_actual = len(actual_tables)
|
|
484
|
+
|
|
485
|
+
# When per-table page labels are supplied, only same-page pairs are
|
|
486
|
+
# eligible to match — so only those need a (costly) TEDS comparison.
|
|
487
|
+
blocked = page_blocking_active(expected_pages, actual_pages, n_expected, n_actual)
|
|
488
|
+
allowed_pairs = [
|
|
489
|
+
(i, j)
|
|
490
|
+
for i in range(n_expected)
|
|
491
|
+
for j in range(n_actual)
|
|
492
|
+
if not blocked or expected_pages[i] == actual_pages[j] # type: ignore[index]
|
|
493
|
+
]
|
|
494
|
+
total_pairs = len(allowed_pairs)
|
|
495
|
+
|
|
496
|
+
print(
|
|
497
|
+
f" TEDS: comparing {n_expected} expected x {n_actual} actual = {total_pairs} table pair(s)"
|
|
498
|
+
f"{' (same-page only)' if blocked else ''}",
|
|
499
|
+
flush=True,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# Build cost matrix (negative TEDS scores for minimization)
|
|
503
|
+
# Rows: expected tables, Columns: actual tables
|
|
504
|
+
# Also store full results to avoid recomputation later. Disallowed
|
|
505
|
+
# (cross-page) cells keep cost 0.0 and are never read.
|
|
506
|
+
cost_matrix = np.zeros((n_expected, n_actual))
|
|
507
|
+
results_cache: dict[tuple[int, int], tuple[dict[str, float], int, int]] = {}
|
|
508
|
+
|
|
509
|
+
# Use TEDS-Content for matching if available, else first variant
|
|
510
|
+
matching_variant = TEDS_CONTENT if TEDS_CONTENT in self.variants else next(iter(sorted(self.variants)))
|
|
511
|
+
|
|
512
|
+
for pair_idx, (i, j) in enumerate(allowed_pairs, start=1):
|
|
513
|
+
if total_pairs > 1:
|
|
514
|
+
print(f" TEDS: table pair {pair_idx}/{total_pairs}", flush=True)
|
|
515
|
+
scores, gt_nodes, pred_nodes = teds_calculator.evaluate(actual_tables[j], expected_tables[i])
|
|
516
|
+
results_cache[(i, j)] = (scores, gt_nodes, pred_nodes)
|
|
517
|
+
cost_matrix[i, j] = -scores[matching_variant]
|
|
518
|
+
|
|
519
|
+
# Solve assignment (per page when page labels are supplied, else global).
|
|
520
|
+
row_ind, col_ind = assign_tables(cost_matrix, expected_pages, actual_pages)
|
|
521
|
+
|
|
522
|
+
# Build one MetricValue per variant
|
|
523
|
+
metric_values: list[MetricValue] = []
|
|
524
|
+
for variant in sorted(self.variants):
|
|
525
|
+
per_table_scores: list[float] = []
|
|
526
|
+
per_table_details: list[dict[str, Any]] = []
|
|
527
|
+
matched_gt_indices: set[int] = set()
|
|
528
|
+
|
|
529
|
+
for gt_idx, pred_idx in zip(row_ind, col_ind, strict=True):
|
|
530
|
+
gt_idx_int = int(gt_idx)
|
|
531
|
+
pred_idx_int = int(pred_idx)
|
|
532
|
+
scores, gt_nodes, pred_nodes = results_cache[(gt_idx_int, pred_idx_int)]
|
|
533
|
+
score = scores[variant]
|
|
534
|
+
per_table_scores.append(score)
|
|
535
|
+
detail: dict[str, Any] = {
|
|
536
|
+
"gt_table_index": gt_idx_int,
|
|
537
|
+
"pred_table_index": pred_idx_int,
|
|
538
|
+
"score": score,
|
|
539
|
+
"gt_nodes": gt_nodes,
|
|
540
|
+
"pred_nodes": pred_nodes,
|
|
541
|
+
}
|
|
542
|
+
if blocked:
|
|
543
|
+
detail["gt_page"] = expected_pages[gt_idx_int] # type: ignore[index]
|
|
544
|
+
detail["pred_page"] = actual_pages[pred_idx_int] # type: ignore[index]
|
|
545
|
+
per_table_details.append(detail)
|
|
546
|
+
matched_gt_indices.add(gt_idx_int)
|
|
547
|
+
|
|
548
|
+
# If there are unmatched expected tables, count them as 0
|
|
549
|
+
for i in range(n_expected):
|
|
550
|
+
if i not in matched_gt_indices:
|
|
551
|
+
per_table_scores.append(0.0)
|
|
552
|
+
per_table_details.append(
|
|
553
|
+
{
|
|
554
|
+
"gt_table_index": i,
|
|
555
|
+
"pred_table_index": None,
|
|
556
|
+
"score": 0.0,
|
|
557
|
+
"gt_nodes": 0,
|
|
558
|
+
"pred_nodes": 0,
|
|
559
|
+
"note": "No matching table in actual",
|
|
560
|
+
}
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
aggregate_score = sum(per_table_scores) / len(per_table_scores) if per_table_scores else 0.0
|
|
564
|
+
|
|
565
|
+
# Build human-readable detail strings
|
|
566
|
+
details: list[str] = []
|
|
567
|
+
details.append(f"{n_expected} table(s) expected, {n_actual} found, {len(row_ind)} matched")
|
|
568
|
+
for td in per_table_details:
|
|
569
|
+
gi = td["gt_table_index"]
|
|
570
|
+
pi = td.get("pred_table_index")
|
|
571
|
+
if pi is None:
|
|
572
|
+
details.append(f"Table {gi + 1}: no match found in prediction")
|
|
573
|
+
else:
|
|
574
|
+
details.append(
|
|
575
|
+
f"Table {gi + 1}: {variant}={td['score']:.3f}"
|
|
576
|
+
f" (gt_nodes={td['gt_nodes']}, pred_nodes={td['pred_nodes']})"
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
metric_values.append(
|
|
580
|
+
MetricValue(
|
|
581
|
+
metric_name=variant,
|
|
582
|
+
value=aggregate_score,
|
|
583
|
+
metadata={
|
|
584
|
+
"tables_predicted": True,
|
|
585
|
+
"tables_found_expected": n_expected,
|
|
586
|
+
"tables_found_actual": n_actual,
|
|
587
|
+
"tables_matched": len(row_ind),
|
|
588
|
+
"per_table_scores": per_table_scores,
|
|
589
|
+
"per_table_details": per_table_details,
|
|
590
|
+
},
|
|
591
|
+
details=details,
|
|
592
|
+
)
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
variant_str = ", ".join(
|
|
596
|
+
f"{v}={m.value:.4f}" for v, m in zip(sorted(self.variants), metric_values, strict=False)
|
|
597
|
+
)
|
|
598
|
+
print(f" TEDS: done, {variant_str}", flush=True)
|
|
599
|
+
|
|
600
|
+
return metric_values
|