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,1125 @@
|
|
|
1
|
+
"""GriTS (Grid Table Similarity) metric for HTML table comparison.
|
|
2
|
+
|
|
3
|
+
Computes content similarity between HTML tables using a grid-based
|
|
4
|
+
representation. GriTS_Con evaluates tables in their natural matrix form
|
|
5
|
+
via the factored 2D most-similar substructures (2D-MSS) algorithm.
|
|
6
|
+
|
|
7
|
+
Core algorithm adapted from the reference implementation at:
|
|
8
|
+
https://github.com/microsoft/table-transformer/blob/main/src/grits.py
|
|
9
|
+
|
|
10
|
+
Reference paper:
|
|
11
|
+
Smock, Pesala, Abraham. "GriTS: Grid Table Similarity Metric for
|
|
12
|
+
Table Structure Recognition." ICDAR 2023.
|
|
13
|
+
https://arxiv.org/abs/2203.12555
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import itertools
|
|
17
|
+
import os
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from dataclasses import replace
|
|
20
|
+
from difflib import SequenceMatcher
|
|
21
|
+
from functools import lru_cache
|
|
22
|
+
from typing import Any, Literal, overload
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
from lxml import html
|
|
26
|
+
|
|
27
|
+
from parse_bench.evaluation.metrics.base import Metric
|
|
28
|
+
from parse_bench.evaluation.metrics.parse.table_pairing import assign_tables, page_blocking_active
|
|
29
|
+
from parse_bench.evaluation.metrics.parse.table_parsing import (
|
|
30
|
+
_ASCII_TO_SUBSCRIPT,
|
|
31
|
+
_ASCII_TO_SUPERSCRIPT,
|
|
32
|
+
TableData,
|
|
33
|
+
)
|
|
34
|
+
from parse_bench.evaluation.metrics.parse.utils import normalize_cell_text
|
|
35
|
+
from parse_bench.schemas.evaluation import MetricValue
|
|
36
|
+
|
|
37
|
+
# `pairing` schema in MetricValue.metadata (load-bearing for TRM consumption):
|
|
38
|
+
# list[tuple[int, int | None]] of length n_gt
|
|
39
|
+
# (gt_idx, pred_idx) for matched, (gt_idx, None) for unmatched GT.
|
|
40
|
+
|
|
41
|
+
# When one table has many cells AND the row/column counts differ by more
|
|
42
|
+
# than this factor, skip GriTS — the prediction is structurally wrong and
|
|
43
|
+
# the O(R1*C1*R2*C2) algorithm would take minutes for no useful signal.
|
|
44
|
+
DEFAULT_MIN_CELLS_FOR_MISMATCH_SKIP = 2500
|
|
45
|
+
DEFAULT_MAX_DIMENSION_RATIO = 1.5
|
|
46
|
+
DEFAULT_MISMATCH_SKIP_SCORE = 0.0
|
|
47
|
+
|
|
48
|
+
# =============================================================================
|
|
49
|
+
# Core GriTS algorithm (adapted from microsoft/table-transformer)
|
|
50
|
+
# =============================================================================
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_scalar(val: Any) -> bool:
|
|
54
|
+
"""Check if a value is a scalar (unoccupied grid cell), not a bbox list."""
|
|
55
|
+
try:
|
|
56
|
+
len(val)
|
|
57
|
+
return False
|
|
58
|
+
except TypeError:
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _bbox_iou(bbox1: Any, bbox2: Any) -> float:
|
|
63
|
+
"""Compute intersection-over-union of two [x1, y1, x2, y2] bounding boxes.
|
|
64
|
+
|
|
65
|
+
Uses bounding-box union (area of the smallest enclosing rectangle) to
|
|
66
|
+
match the reference GriTS implementation, which uses PyMuPDF
|
|
67
|
+
Rect.include_rect for the union.
|
|
68
|
+
|
|
69
|
+
Handles numpy arrays and scalar 0 (unoccupied grid cells).
|
|
70
|
+
"""
|
|
71
|
+
bbox1_scalar = _is_scalar(bbox1)
|
|
72
|
+
bbox2_scalar = _is_scalar(bbox2)
|
|
73
|
+
|
|
74
|
+
# Both unoccupied → both tables agree "no cell here" → perfect match
|
|
75
|
+
if bbox1_scalar and bbox2_scalar:
|
|
76
|
+
return 1.0
|
|
77
|
+
# One occupied, one not → structural mismatch
|
|
78
|
+
if bbox1_scalar or bbox2_scalar:
|
|
79
|
+
return 0.0
|
|
80
|
+
|
|
81
|
+
if len(bbox1) != 4 or len(bbox2) != 4:
|
|
82
|
+
return 0.0
|
|
83
|
+
|
|
84
|
+
# Intersection rectangle
|
|
85
|
+
x1 = max(bbox1[0], bbox2[0])
|
|
86
|
+
y1 = max(bbox1[1], bbox2[1])
|
|
87
|
+
x2 = min(bbox1[2], bbox2[2])
|
|
88
|
+
y2 = min(bbox1[3], bbox2[3])
|
|
89
|
+
|
|
90
|
+
intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1)
|
|
91
|
+
if intersection == 0.0:
|
|
92
|
+
return 0.0
|
|
93
|
+
|
|
94
|
+
# Bounding-box union (smallest enclosing rectangle)
|
|
95
|
+
union = (max(bbox1[2], bbox2[2]) - min(bbox1[0], bbox2[0])) * (max(bbox1[3], bbox2[3]) - min(bbox1[1], bbox2[1]))
|
|
96
|
+
|
|
97
|
+
if union <= 0:
|
|
98
|
+
return 0.0
|
|
99
|
+
return intersection / union # type: ignore[no-any-return]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@lru_cache(maxsize=200_000)
|
|
103
|
+
def _lcs_similarity_cached(s1: str, s2: str) -> float:
|
|
104
|
+
"""Ratcliff/Obershelp similarity of two strings, memoized.
|
|
105
|
+
|
|
106
|
+
This is the numeric core of ``_lcs_similarity`` — kept byte-for-byte
|
|
107
|
+
identical to the reference implementation (``difflib.SequenceMatcher``'s
|
|
108
|
+
matching-block sum, i.e. ``2*|matches| / (|s1| + |s2|)``), so scores are
|
|
109
|
+
unchanged. It is factored out purely so ``factored_2dmss`` — which calls
|
|
110
|
+
this ``O(R1*C1*R2*C2)`` times per table pair, mostly on *repeated* cell
|
|
111
|
+
strings (empty cells, spanned headers, duplicated values) — pays the
|
|
112
|
+
``SequenceMatcher`` cost only once per distinct ``(s1, s2)`` pair instead
|
|
113
|
+
of once per grid position. Pure function ⇒ the cache is always exact.
|
|
114
|
+
|
|
115
|
+
Measured (``scripts/bench_table_metrics.py``, GriTS-Con, random tables)::
|
|
116
|
+
|
|
117
|
+
size difflib ms memoized ms speedup |Δ score|
|
|
118
|
+
8x5 4.3 1.5 2.9x 0.00e+00
|
|
119
|
+
15x8 38.5 12.7 3.0x 0.00e+00
|
|
120
|
+
25x10 168.7 56.2 3.0x 0.00e+00
|
|
121
|
+
40x12 644.7 243.6 2.6x 0.00e+00
|
|
122
|
+
"""
|
|
123
|
+
if len(s1) == 0 and len(s2) == 0:
|
|
124
|
+
return 1.0
|
|
125
|
+
s = SequenceMatcher(None, s1, s2)
|
|
126
|
+
lcs = "".join([s1[block.a : (block.a + block.size)] for block in s.get_matching_blocks()])
|
|
127
|
+
return 2 * len(lcs) / (len(s1) + len(s2))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _lcs_similarity(string1: Any, string2: Any) -> float:
|
|
131
|
+
"""Compute longest-common-subsequence similarity between two strings.
|
|
132
|
+
|
|
133
|
+
Returns 2*|LCS| / (|s1| + |s2|), ranging from 0.0 (no overlap) to
|
|
134
|
+
1.0 (identical strings). Returns 1.0 when both strings are empty.
|
|
135
|
+
Handles non-string grid values (e.g., scalar 0 for unoccupied cells).
|
|
136
|
+
|
|
137
|
+
Delegates to the memoized :func:`_lcs_similarity_cached` after coercing
|
|
138
|
+
non-string grid values to ``str`` exactly as before, so the result is
|
|
139
|
+
identical while avoiding recomputation for repeated cell pairs.
|
|
140
|
+
"""
|
|
141
|
+
s1 = str(string1) if not isinstance(string1, str) else string1
|
|
142
|
+
s2 = str(string2) if not isinstance(string2, str) else string2
|
|
143
|
+
return _lcs_similarity_cached(s1, s2)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _compute_fscore(num_true_positives: float, num_true: int, num_positives: int) -> tuple[float, float, float]:
|
|
147
|
+
"""Compute F-score, precision, and recall.
|
|
148
|
+
|
|
149
|
+
Conventions (from the reference implementation):
|
|
150
|
+
- precision is 1 when there are no predicted instances
|
|
151
|
+
- recall is 1 when there are no true instances
|
|
152
|
+
- fscore is 0 when recall or precision is 0
|
|
153
|
+
"""
|
|
154
|
+
precision = num_true_positives / num_positives if num_positives > 0 else 1.0
|
|
155
|
+
recall = num_true_positives / num_true if num_true > 0 else 1.0
|
|
156
|
+
|
|
157
|
+
if precision + recall > 0:
|
|
158
|
+
fscore = 2 * precision * recall / (precision + recall)
|
|
159
|
+
else:
|
|
160
|
+
fscore = 0.0
|
|
161
|
+
|
|
162
|
+
return fscore, precision, recall
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _initialize_dp(seq1_len: int, seq2_len: int) -> tuple[np.ndarray, np.ndarray]:
|
|
166
|
+
"""Initialize dynamic programming score and pointer tables."""
|
|
167
|
+
scores = np.zeros((seq1_len + 1, seq2_len + 1))
|
|
168
|
+
pointers = np.zeros((seq1_len + 1, seq2_len + 1))
|
|
169
|
+
|
|
170
|
+
for i in range(1, seq1_len + 1):
|
|
171
|
+
pointers[i, 0] = -1 # up
|
|
172
|
+
for j in range(1, seq2_len + 1):
|
|
173
|
+
pointers[0, j] = 1 # left
|
|
174
|
+
|
|
175
|
+
return scores, pointers
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _traceback(pointers: np.ndarray) -> tuple[list[int], list[int]]:
|
|
179
|
+
"""Traceback through DP pointer table to get aligned indices.
|
|
180
|
+
|
|
181
|
+
Convention: -1 = up, 1 = left, 0 = diagonal (match).
|
|
182
|
+
"""
|
|
183
|
+
i = pointers.shape[0] - 1
|
|
184
|
+
j = pointers.shape[1] - 1
|
|
185
|
+
seq1_indices: list[int] = []
|
|
186
|
+
seq2_indices: list[int] = []
|
|
187
|
+
|
|
188
|
+
while not (i == 0 and j == 0):
|
|
189
|
+
if pointers[i, j] == -1:
|
|
190
|
+
i -= 1
|
|
191
|
+
elif pointers[i, j] == 1:
|
|
192
|
+
j -= 1
|
|
193
|
+
else:
|
|
194
|
+
i -= 1
|
|
195
|
+
j -= 1
|
|
196
|
+
seq1_indices.append(i)
|
|
197
|
+
seq2_indices.append(j)
|
|
198
|
+
|
|
199
|
+
return seq1_indices[::-1], seq2_indices[::-1]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _align_1d(
|
|
203
|
+
sequence1: list[tuple[int, int]],
|
|
204
|
+
sequence2: list[tuple[int, int]],
|
|
205
|
+
reward_lookup: dict[tuple[int, int, int, int], float],
|
|
206
|
+
return_alignment: bool = False,
|
|
207
|
+
) -> float | tuple[list[int], list[int], float]:
|
|
208
|
+
"""1D sequence alignment with pre-computed rewards.
|
|
209
|
+
|
|
210
|
+
Sequences are index tuples into the reward lookup table.
|
|
211
|
+
"""
|
|
212
|
+
seq1_len = len(sequence1)
|
|
213
|
+
seq2_len = len(sequence2)
|
|
214
|
+
scores, pointers = _initialize_dp(seq1_len, seq2_len)
|
|
215
|
+
|
|
216
|
+
for i in range(1, seq1_len + 1):
|
|
217
|
+
for j in range(1, seq2_len + 1):
|
|
218
|
+
reward = reward_lookup[sequence1[i - 1] + sequence2[j - 1]]
|
|
219
|
+
diag = scores[i - 1, j - 1] + reward
|
|
220
|
+
skip_seq2 = scores[i, j - 1]
|
|
221
|
+
skip_seq1 = scores[i - 1, j]
|
|
222
|
+
|
|
223
|
+
best = max(diag, skip_seq1, skip_seq2)
|
|
224
|
+
scores[i, j] = best
|
|
225
|
+
if diag == best:
|
|
226
|
+
pointers[i, j] = 0
|
|
227
|
+
elif skip_seq1 == best:
|
|
228
|
+
pointers[i, j] = -1
|
|
229
|
+
else:
|
|
230
|
+
pointers[i, j] = 1
|
|
231
|
+
|
|
232
|
+
score = float(scores[-1, -1])
|
|
233
|
+
|
|
234
|
+
if not return_alignment:
|
|
235
|
+
return score
|
|
236
|
+
|
|
237
|
+
seq1_indices, seq2_indices = _traceback(pointers)
|
|
238
|
+
return seq1_indices, seq2_indices, score
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _align_2d_outer(
|
|
242
|
+
true_shape: tuple[int, int],
|
|
243
|
+
pred_shape: tuple[int, int],
|
|
244
|
+
reward_lookup: dict[tuple[int, int, int, int], float],
|
|
245
|
+
) -> tuple[list[int], list[int], float]:
|
|
246
|
+
"""2D sequence-of-sequences alignment.
|
|
247
|
+
|
|
248
|
+
Aligns two outer sequences (rows) where match reward between entries
|
|
249
|
+
is their 1D column alignment score.
|
|
250
|
+
"""
|
|
251
|
+
scores, pointers = _initialize_dp(true_shape[0], pred_shape[0])
|
|
252
|
+
|
|
253
|
+
for row_idx in range(1, true_shape[0] + 1):
|
|
254
|
+
for col_idx in range(1, pred_shape[0] + 1):
|
|
255
|
+
reward_result = _align_1d(
|
|
256
|
+
[(row_idx - 1, tcol) for tcol in range(true_shape[1])],
|
|
257
|
+
[(col_idx - 1, prow) for prow in range(pred_shape[1])],
|
|
258
|
+
reward_lookup,
|
|
259
|
+
)
|
|
260
|
+
assert isinstance(reward_result, float)
|
|
261
|
+
reward = reward_result
|
|
262
|
+
diag = scores[row_idx - 1, col_idx - 1] + reward
|
|
263
|
+
same_row = scores[row_idx, col_idx - 1]
|
|
264
|
+
same_col = scores[row_idx - 1, col_idx]
|
|
265
|
+
|
|
266
|
+
best = max(diag, same_col, same_row)
|
|
267
|
+
scores[row_idx, col_idx] = best
|
|
268
|
+
if diag == best:
|
|
269
|
+
pointers[row_idx, col_idx] = 0
|
|
270
|
+
elif same_col == best:
|
|
271
|
+
pointers[row_idx, col_idx] = -1
|
|
272
|
+
else:
|
|
273
|
+
pointers[row_idx, col_idx] = 1
|
|
274
|
+
|
|
275
|
+
score = float(scores[-1, -1])
|
|
276
|
+
true_indices, pred_indices = _traceback(pointers)
|
|
277
|
+
return true_indices, pred_indices, score
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _kernel_mode() -> str:
|
|
281
|
+
"""Select the ``factored_2dmss`` implementation: ``array`` | ``memo`` | ``slow``.
|
|
282
|
+
|
|
283
|
+
``array`` (default) builds the reward as a vectorized numpy tensor over a
|
|
284
|
+
unique-cell-value similarity matrix and runs the alignment DP on Python lists
|
|
285
|
+
— no per-pair LCS redundancy, no R1·C1·R2·C2 dict build, no numpy scalar
|
|
286
|
+
indexing. ``memo`` is the previous fast path (memoized dict build). ``slow`` is
|
|
287
|
+
the original dict path. All three return bit-identical scores; the non-array
|
|
288
|
+
modes exist only for before/after benchmarking.
|
|
289
|
+
|
|
290
|
+
``BENCH_GRITS_KERNEL`` selects explicitly; the legacy boolean
|
|
291
|
+
``BENCH_GRITS_FAST_KERNEL=0`` forces ``slow``.
|
|
292
|
+
"""
|
|
293
|
+
mode = os.environ.get("BENCH_GRITS_KERNEL", "").strip().lower()
|
|
294
|
+
if mode in ("array", "memo", "slow"):
|
|
295
|
+
return mode
|
|
296
|
+
return "slow" if os.environ.get("BENCH_GRITS_FAST_KERNEL", "1") == "0" else "array"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _precompute_rewards(
|
|
300
|
+
true_grid: np.ndarray,
|
|
301
|
+
pred_grid: np.ndarray,
|
|
302
|
+
reward_function: Any,
|
|
303
|
+
memoize: bool,
|
|
304
|
+
) -> tuple[dict[tuple[int, int, int, int], float], dict[tuple[int, int, int, int], float]]:
|
|
305
|
+
"""Original dict reward lookups over every cell pair (``slow`` / ``memo`` modes).
|
|
306
|
+
|
|
307
|
+
The reward depends only on the two cell VALUES, but the grids carry the same
|
|
308
|
+
string in many cells, so the naive ``range(R1·C1·R2·C2)`` loop calls
|
|
309
|
+
``reward_function`` (an LCS over strings) up to 1.7M times per pair with massive
|
|
310
|
+
redundancy. ``memoize`` caches by ``(cell_value, cell_value)`` (a pure-function
|
|
311
|
+
cache → identical rewards). The ``array`` kernel avoids this loop entirely.
|
|
312
|
+
"""
|
|
313
|
+
pre_computed: dict[tuple[int, int, int, int], float] = {}
|
|
314
|
+
transpose_rewards: dict[tuple[int, int, int, int], float] = {}
|
|
315
|
+
product = itertools.product(
|
|
316
|
+
range(true_grid.shape[0]),
|
|
317
|
+
range(true_grid.shape[1]),
|
|
318
|
+
range(pred_grid.shape[0]),
|
|
319
|
+
range(pred_grid.shape[1]),
|
|
320
|
+
)
|
|
321
|
+
if memoize:
|
|
322
|
+
cache: dict[tuple[Any, Any], float] = {}
|
|
323
|
+
for trow, tcol, prow, pcol in product:
|
|
324
|
+
value_key = (true_grid[trow, tcol], pred_grid[prow, pcol])
|
|
325
|
+
reward = cache.get(value_key)
|
|
326
|
+
if reward is None:
|
|
327
|
+
reward = reward_function(*value_key)
|
|
328
|
+
cache[value_key] = reward
|
|
329
|
+
pre_computed[(trow, tcol, prow, pcol)] = reward
|
|
330
|
+
transpose_rewards[(tcol, trow, pcol, prow)] = reward
|
|
331
|
+
else:
|
|
332
|
+
for trow, tcol, prow, pcol in product:
|
|
333
|
+
reward = reward_function(true_grid[trow, tcol], pred_grid[prow, pcol])
|
|
334
|
+
pre_computed[(trow, tcol, prow, pcol)] = reward
|
|
335
|
+
transpose_rewards[(tcol, trow, pcol, prow)] = reward
|
|
336
|
+
return pre_computed, transpose_rewards
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _reward_tensor(
|
|
340
|
+
true_grid: np.ndarray,
|
|
341
|
+
pred_grid: np.ndarray,
|
|
342
|
+
reward_function: Any,
|
|
343
|
+
) -> np.ndarray:
|
|
344
|
+
"""Reward tensor ``R[trow, tcol, prow, pcol] = reward(true[trow,tcol], pred[prow,pcol])``.
|
|
345
|
+
|
|
346
|
+
``reward_function`` is evaluated once per *distinct* (true_value, pred_value)
|
|
347
|
+
pair over a unique-string similarity matrix, then broadcast-gathered into the
|
|
348
|
+
full R1·C1·R2·C2 tensor — no per-cell Python loop, no LCS redundancy. Values
|
|
349
|
+
are the same doubles the dict path would store, so downstream scores are
|
|
350
|
+
bit-identical.
|
|
351
|
+
"""
|
|
352
|
+
true_uids: dict[Any, int] = {}
|
|
353
|
+
pred_uids: dict[Any, int] = {}
|
|
354
|
+
uid_true = np.empty(true_grid.shape, dtype=np.intp)
|
|
355
|
+
uid_pred = np.empty(pred_grid.shape, dtype=np.intp)
|
|
356
|
+
unique_true: list[Any] = []
|
|
357
|
+
unique_pred: list[Any] = []
|
|
358
|
+
for r in range(true_grid.shape[0]):
|
|
359
|
+
for c in range(true_grid.shape[1]):
|
|
360
|
+
value = true_grid[r, c]
|
|
361
|
+
uid = true_uids.get(value)
|
|
362
|
+
if uid is None:
|
|
363
|
+
uid = len(unique_true)
|
|
364
|
+
true_uids[value] = uid
|
|
365
|
+
unique_true.append(value)
|
|
366
|
+
uid_true[r, c] = uid
|
|
367
|
+
for r in range(pred_grid.shape[0]):
|
|
368
|
+
for c in range(pred_grid.shape[1]):
|
|
369
|
+
value = pred_grid[r, c]
|
|
370
|
+
uid = pred_uids.get(value)
|
|
371
|
+
if uid is None:
|
|
372
|
+
uid = len(unique_pred)
|
|
373
|
+
pred_uids[value] = uid
|
|
374
|
+
unique_pred.append(value)
|
|
375
|
+
uid_pred[r, c] = uid
|
|
376
|
+
|
|
377
|
+
sim = np.empty((len(unique_true), len(unique_pred)), dtype=np.float64)
|
|
378
|
+
for a, value_a in enumerate(unique_true):
|
|
379
|
+
for b, value_b in enumerate(unique_pred):
|
|
380
|
+
sim[a, b] = reward_function(value_a, value_b)
|
|
381
|
+
|
|
382
|
+
# Broadcast-gather: (R1,C1,1,1) x (1,1,R2,C2) -> (R1,C1,R2,C2)
|
|
383
|
+
return sim[uid_true[:, :, None, None], uid_pred[None, None, :, :]]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _traceback_list(pointers: list[list[int]]) -> tuple[list[int], list[int]]:
|
|
387
|
+
"""List-based traceback (mirror of :func:`_traceback`). -1=up, 1=left, 0=match."""
|
|
388
|
+
i = len(pointers) - 1
|
|
389
|
+
j = len(pointers[0]) - 1
|
|
390
|
+
seq1_indices: list[int] = []
|
|
391
|
+
seq2_indices: list[int] = []
|
|
392
|
+
while not (i == 0 and j == 0):
|
|
393
|
+
ptr = pointers[i][j]
|
|
394
|
+
if ptr == -1:
|
|
395
|
+
i -= 1
|
|
396
|
+
elif ptr == 1:
|
|
397
|
+
j -= 1
|
|
398
|
+
else:
|
|
399
|
+
i -= 1
|
|
400
|
+
j -= 1
|
|
401
|
+
seq1_indices.append(i)
|
|
402
|
+
seq2_indices.append(j)
|
|
403
|
+
return seq1_indices[::-1], seq2_indices[::-1]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@overload
|
|
407
|
+
def _align_matrix(reward_matrix: list[list[float]], return_alignment: Literal[False] = False) -> float: ...
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@overload
|
|
411
|
+
def _align_matrix(
|
|
412
|
+
reward_matrix: list[list[float]], return_alignment: Literal[True]
|
|
413
|
+
) -> tuple[list[int], list[int], float]: ...
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _align_matrix(
|
|
417
|
+
reward_matrix: list[list[float]],
|
|
418
|
+
return_alignment: bool = False,
|
|
419
|
+
) -> float | tuple[list[int], list[int], float]:
|
|
420
|
+
"""Max-reward monotonic alignment over a dense ``M×N`` reward matrix.
|
|
421
|
+
|
|
422
|
+
Identical DP and tie-breaking to :func:`_align_1d` / :func:`_align_2d_outer`
|
|
423
|
+
(``best = max(diag, up, left)``; tie prefers diag, then up, then left), but on
|
|
424
|
+
plain Python lists — no numpy scalar indexing, no 4-tuple dict keys.
|
|
425
|
+
"""
|
|
426
|
+
m = len(reward_matrix)
|
|
427
|
+
n = len(reward_matrix[0]) if m else 0
|
|
428
|
+
scores = [[0.0] * (n + 1) for _ in range(m + 1)]
|
|
429
|
+
pointers = [[0] * (n + 1) for _ in range(m + 1)]
|
|
430
|
+
for i in range(1, m + 1):
|
|
431
|
+
pointers[i][0] = -1 # up
|
|
432
|
+
for j in range(1, n + 1):
|
|
433
|
+
pointers[0][j] = 1 # left
|
|
434
|
+
|
|
435
|
+
for i in range(1, m + 1):
|
|
436
|
+
reward_row = reward_matrix[i - 1]
|
|
437
|
+
scores_prev = scores[i - 1]
|
|
438
|
+
scores_cur = scores[i]
|
|
439
|
+
pointers_cur = pointers[i]
|
|
440
|
+
for j in range(1, n + 1):
|
|
441
|
+
diag = scores_prev[j - 1] + reward_row[j - 1]
|
|
442
|
+
skip_up = scores_prev[j]
|
|
443
|
+
skip_left = scores_cur[j - 1]
|
|
444
|
+
best = max(diag, skip_up, skip_left)
|
|
445
|
+
scores_cur[j] = best
|
|
446
|
+
if diag == best:
|
|
447
|
+
pointers_cur[j] = 0
|
|
448
|
+
elif skip_up == best:
|
|
449
|
+
pointers_cur[j] = -1
|
|
450
|
+
else:
|
|
451
|
+
pointers_cur[j] = 1
|
|
452
|
+
|
|
453
|
+
score = scores[m][n]
|
|
454
|
+
if not return_alignment:
|
|
455
|
+
return score
|
|
456
|
+
seq1_indices, seq2_indices = _traceback_list(pointers)
|
|
457
|
+
return seq1_indices, seq2_indices, score
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _factored_2dmss_array(
|
|
461
|
+
true_grid: np.ndarray,
|
|
462
|
+
pred_grid: np.ndarray,
|
|
463
|
+
reward_function: Any,
|
|
464
|
+
) -> tuple[float, float, float, float, dict[int, int], dict[int, int]]:
|
|
465
|
+
"""Array-kernel ``factored_2dmss`` — bit-identical to the dict path.
|
|
466
|
+
|
|
467
|
+
Builds the reward tensor once (vectorized), then does the same factored
|
|
468
|
+
row/column alignments. The per-row-pair column score and per-col-pair row
|
|
469
|
+
score matrices (``RR`` / ``CC``) are exactly the inner ``_align_1d`` scores the
|
|
470
|
+
dict path recomputes inside ``_align_2d_outer``. ``positive_match`` is summed in
|
|
471
|
+
the original nested order so floating-point rounding matches to the last bit.
|
|
472
|
+
"""
|
|
473
|
+
reward = _reward_tensor(true_grid, pred_grid, reward_function)
|
|
474
|
+
n_true_rows, n_true_cols, n_pred_rows, n_pred_cols = reward.shape
|
|
475
|
+
|
|
476
|
+
# Row alignment: reward between true row i and pred row j is their column
|
|
477
|
+
# alignment score over reward[i, :, j, :] (C1×C2).
|
|
478
|
+
rr = [[_align_matrix(reward[i, :, j, :].tolist()) for j in range(n_pred_rows)] for i in range(n_true_rows)]
|
|
479
|
+
true_row_nums, pred_row_nums, row_score = _align_matrix(rr, return_alignment=True)
|
|
480
|
+
|
|
481
|
+
# Column alignment: reward between true col i and pred col j is their row
|
|
482
|
+
# alignment score over reward[:, i, :, j] (R1×R2).
|
|
483
|
+
cc = [[_align_matrix(reward[:, i, :, j].tolist()) for j in range(n_pred_cols)] for i in range(n_true_cols)]
|
|
484
|
+
true_col_nums, pred_col_nums, col_score = _align_matrix(cc, return_alignment=True)
|
|
485
|
+
|
|
486
|
+
num_true = n_true_rows * n_true_cols
|
|
487
|
+
num_pos = n_pred_rows * n_pred_cols
|
|
488
|
+
|
|
489
|
+
upper_bound = min(row_score, col_score)
|
|
490
|
+
ub_fscore, _, _ = _compute_fscore(upper_bound, num_true, num_pos)
|
|
491
|
+
|
|
492
|
+
# Sum matched-cell rewards in the same (row-pair outer, col-pair inner) order
|
|
493
|
+
# as the dict path so the float reduction is bit-identical.
|
|
494
|
+
positive_match = 0.0
|
|
495
|
+
for true_row, pred_row in zip(true_row_nums, pred_row_nums, strict=True):
|
|
496
|
+
for true_col, pred_col in zip(true_col_nums, pred_col_nums, strict=True):
|
|
497
|
+
positive_match += float(reward[true_row, true_col, pred_row, pred_col])
|
|
498
|
+
|
|
499
|
+
fscore, precision, recall = _compute_fscore(positive_match, num_true, num_pos)
|
|
500
|
+
row_map = dict(zip(true_row_nums, pred_row_nums, strict=True))
|
|
501
|
+
col_map = dict(zip(true_col_nums, pred_col_nums, strict=True))
|
|
502
|
+
return fscore, precision, recall, ub_fscore, row_map, col_map
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def factored_2dmss(
|
|
506
|
+
true_grid: np.ndarray,
|
|
507
|
+
pred_grid: np.ndarray,
|
|
508
|
+
reward_function: Any,
|
|
509
|
+
) -> tuple[float, float, float, float]:
|
|
510
|
+
"""Factored 2D most-similar substructures (2D-MSS).
|
|
511
|
+
|
|
512
|
+
A polynomial-time heuristic for the NP-hard 2D-MSS problem. Finds
|
|
513
|
+
the substructures of two matrices with the greatest total similarity.
|
|
514
|
+
|
|
515
|
+
Returns (fscore, precision, recall, upper_bound_score).
|
|
516
|
+
"""
|
|
517
|
+
mode = _kernel_mode()
|
|
518
|
+
if mode == "array":
|
|
519
|
+
fscore, precision, recall, ub_fscore, _, _ = _factored_2dmss_array(true_grid, pred_grid, reward_function)
|
|
520
|
+
return fscore, precision, recall, ub_fscore
|
|
521
|
+
|
|
522
|
+
pre_computed, transpose_rewards = _precompute_rewards(
|
|
523
|
+
true_grid, pred_grid, reward_function, memoize=(mode == "memo")
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
num_pos = pred_grid.shape[0] * pred_grid.shape[1]
|
|
527
|
+
num_true = true_grid.shape[0] * true_grid.shape[1]
|
|
528
|
+
|
|
529
|
+
true_row_nums, pred_row_nums, row_score = _align_2d_outer(true_grid.shape[:2], pred_grid.shape[:2], pre_computed)
|
|
530
|
+
|
|
531
|
+
true_col_nums, pred_col_nums, col_score = _align_2d_outer(
|
|
532
|
+
true_grid.shape[:2][::-1],
|
|
533
|
+
pred_grid.shape[:2][::-1],
|
|
534
|
+
transpose_rewards,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
upper_bound = min(row_score, col_score)
|
|
538
|
+
ub_fscore, _, _ = _compute_fscore(upper_bound, num_true, num_pos)
|
|
539
|
+
|
|
540
|
+
positive_match = 0.0
|
|
541
|
+
for true_row, pred_row in zip(true_row_nums, pred_row_nums, strict=False):
|
|
542
|
+
for true_col, pred_col in zip(true_col_nums, pred_col_nums, strict=False):
|
|
543
|
+
positive_match += pre_computed[(true_row, true_col, pred_row, pred_col)]
|
|
544
|
+
|
|
545
|
+
fscore, precision, recall = _compute_fscore(positive_match, num_true, num_pos)
|
|
546
|
+
return fscore, precision, recall, ub_fscore
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def factored_2dmss_with_alignment(
|
|
550
|
+
true_grid: np.ndarray,
|
|
551
|
+
pred_grid: np.ndarray,
|
|
552
|
+
reward_function: Any,
|
|
553
|
+
) -> tuple[float, float, float, float, dict[int, int], dict[int, int]]:
|
|
554
|
+
"""Like factored_2dmss, but also returns row and column alignment maps.
|
|
555
|
+
|
|
556
|
+
Returns (fscore, precision, recall, upper_bound, row_map, col_map)
|
|
557
|
+
where row_map = {true_row: pred_row} and col_map = {true_col: pred_col}.
|
|
558
|
+
"""
|
|
559
|
+
mode = _kernel_mode()
|
|
560
|
+
if mode == "array":
|
|
561
|
+
return _factored_2dmss_array(true_grid, pred_grid, reward_function)
|
|
562
|
+
|
|
563
|
+
pre_computed, transpose_rewards = _precompute_rewards(
|
|
564
|
+
true_grid, pred_grid, reward_function, memoize=(mode == "memo")
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
num_pos = pred_grid.shape[0] * pred_grid.shape[1]
|
|
568
|
+
num_true = true_grid.shape[0] * true_grid.shape[1]
|
|
569
|
+
|
|
570
|
+
true_row_nums, pred_row_nums, row_score = _align_2d_outer(true_grid.shape[:2], pred_grid.shape[:2], pre_computed)
|
|
571
|
+
|
|
572
|
+
true_col_nums, pred_col_nums, col_score = _align_2d_outer(
|
|
573
|
+
true_grid.shape[:2][::-1],
|
|
574
|
+
pred_grid.shape[:2][::-1],
|
|
575
|
+
transpose_rewards,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
row_map = dict(zip(true_row_nums, pred_row_nums, strict=True))
|
|
579
|
+
col_map = dict(zip(true_col_nums, pred_col_nums, strict=True))
|
|
580
|
+
|
|
581
|
+
upper_bound = min(row_score, col_score)
|
|
582
|
+
ub_fscore, _, _ = _compute_fscore(upper_bound, num_true, num_pos)
|
|
583
|
+
|
|
584
|
+
positive_match = 0.0
|
|
585
|
+
for true_row, pred_row in zip(true_row_nums, pred_row_nums, strict=False):
|
|
586
|
+
for true_col, pred_col in zip(true_col_nums, pred_col_nums, strict=False):
|
|
587
|
+
positive_match += pre_computed[(true_row, true_col, pred_row, pred_col)]
|
|
588
|
+
|
|
589
|
+
fscore, precision, recall = _compute_fscore(positive_match, num_true, num_pos)
|
|
590
|
+
return fscore, precision, recall, ub_fscore, row_map, col_map
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
# =============================================================================
|
|
594
|
+
# HTML table parsing
|
|
595
|
+
# =============================================================================
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def html_to_cells(table_html: str) -> list[dict[str, Any]] | None:
|
|
599
|
+
"""Parse an HTML table string into a list of cell dictionaries.
|
|
600
|
+
|
|
601
|
+
Each cell dict has keys: row_nums, column_nums, is_column_header, cell_text.
|
|
602
|
+
Returns None if parsing fails.
|
|
603
|
+
"""
|
|
604
|
+
try:
|
|
605
|
+
parser = html.HTMLParser(remove_comments=True, encoding="utf-8")
|
|
606
|
+
doc = html.fromstring(table_html, parser=parser)
|
|
607
|
+
except Exception:
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
# Find the <table> element (lxml may wrap in <html><body>)
|
|
611
|
+
if doc.tag == "table":
|
|
612
|
+
tree = doc
|
|
613
|
+
else:
|
|
614
|
+
tables = doc.xpath(".//table")
|
|
615
|
+
if not tables:
|
|
616
|
+
return None
|
|
617
|
+
tree = tables[0]
|
|
618
|
+
|
|
619
|
+
table_cells: list[dict[str, Any]] = []
|
|
620
|
+
occupied_columns_by_row: dict[int, set[int]] = defaultdict(set)
|
|
621
|
+
current_row = -1
|
|
622
|
+
|
|
623
|
+
stack: list[tuple[Any, bool]] = [(tree, False)]
|
|
624
|
+
while stack:
|
|
625
|
+
current, in_header = stack.pop()
|
|
626
|
+
|
|
627
|
+
if current.tag == "tr":
|
|
628
|
+
current_row += 1
|
|
629
|
+
|
|
630
|
+
if current.tag in ("td", "th"):
|
|
631
|
+
colspan = int(current.attrib.get("colspan", "1"))
|
|
632
|
+
rowspan = int(current.attrib.get("rowspan", "1"))
|
|
633
|
+
row_nums = list(range(current_row, current_row + rowspan))
|
|
634
|
+
|
|
635
|
+
occupied = occupied_columns_by_row[current_row]
|
|
636
|
+
if occupied:
|
|
637
|
+
max_occ = max(occupied)
|
|
638
|
+
current_column = min(set(range(max_occ + 2)).difference(occupied))
|
|
639
|
+
else:
|
|
640
|
+
current_column = 0
|
|
641
|
+
|
|
642
|
+
column_nums = list(range(current_column, current_column + colspan))
|
|
643
|
+
for rn in row_nums:
|
|
644
|
+
occupied_columns_by_row[rn].update(column_nums)
|
|
645
|
+
|
|
646
|
+
# Convert <sup>/<sub> digit content to Unicode equivalents
|
|
647
|
+
# so that "Name<sup>1</sup>" becomes "Name¹", matching sources
|
|
648
|
+
# that already use Unicode superscripts.
|
|
649
|
+
_map = {"sup": _ASCII_TO_SUPERSCRIPT, "sub": _ASCII_TO_SUBSCRIPT}
|
|
650
|
+
for sup_sub in current.xpath(".//sup | .//sub"):
|
|
651
|
+
char_map = _map[sup_sub.tag]
|
|
652
|
+
converted = "".join(char_map.get(c, "") for c in (sup_sub.text or ""))
|
|
653
|
+
# Replace element with converted text in parent's tree
|
|
654
|
+
prev = sup_sub.getprevious()
|
|
655
|
+
if prev is not None:
|
|
656
|
+
prev.tail = (prev.tail or "") + converted + (sup_sub.tail or "")
|
|
657
|
+
else:
|
|
658
|
+
sup_sub.getparent().text = (sup_sub.getparent().text or "") + converted + (sup_sub.tail or "")
|
|
659
|
+
sup_sub.getparent().remove(sup_sub)
|
|
660
|
+
# Gather text from element and all descendants
|
|
661
|
+
cell_text = normalize_cell_text(" ".join(current.itertext()))
|
|
662
|
+
|
|
663
|
+
table_cells.append(
|
|
664
|
+
{
|
|
665
|
+
"row_nums": row_nums,
|
|
666
|
+
"column_nums": column_nums,
|
|
667
|
+
"is_column_header": current.tag == "th" or in_header,
|
|
668
|
+
"cell_text": cell_text,
|
|
669
|
+
}
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
children = list(current)
|
|
673
|
+
for child in children[::-1]:
|
|
674
|
+
stack.append((child, in_header or current.tag in ("th", "thead")))
|
|
675
|
+
|
|
676
|
+
return table_cells
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def cells_to_grid(cells: list[dict[str, Any]], key: str = "cell_text") -> list[list[Any]]:
|
|
680
|
+
"""Convert cell list to a 2D grid keyed by 'cell_text' or 'bbox'.
|
|
681
|
+
|
|
682
|
+
For GriTS_Con, use key='cell_text'.
|
|
683
|
+
"""
|
|
684
|
+
if not cells:
|
|
685
|
+
return [[]]
|
|
686
|
+
num_rows = max(max(c["row_nums"]) for c in cells) + 1
|
|
687
|
+
num_cols = max(max(c["column_nums"]) for c in cells) + 1
|
|
688
|
+
grid: list[list[Any]] = [[0] * num_cols for _ in range(num_rows)]
|
|
689
|
+
for cell in cells:
|
|
690
|
+
for rn in cell["row_nums"]:
|
|
691
|
+
for cn in cell["column_nums"]:
|
|
692
|
+
grid[rn][cn] = cell[key]
|
|
693
|
+
return grid
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# =============================================================================
|
|
697
|
+
# High-level GriTS computation from HTML
|
|
698
|
+
# =============================================================================
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def grits_con(true_text_grid: np.ndarray, pred_text_grid: np.ndarray) -> tuple[float, float, float, float]:
|
|
702
|
+
"""Compute GriTS_Con (content) from text grids."""
|
|
703
|
+
return factored_2dmss(true_text_grid, pred_text_grid, _lcs_similarity)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def grits_con_with_alignment(
|
|
707
|
+
true_text_grid: np.ndarray, pred_text_grid: np.ndarray
|
|
708
|
+
) -> tuple[float, float, float, float, dict[int, int], dict[int, int]]:
|
|
709
|
+
"""GriTS_Con that also returns row/col alignment maps."""
|
|
710
|
+
return factored_2dmss_with_alignment(true_text_grid, pred_text_grid, _lcs_similarity)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def grits_from_html(
|
|
714
|
+
true_html: str,
|
|
715
|
+
pred_html: str,
|
|
716
|
+
min_cells_for_mismatch_skip: int = DEFAULT_MIN_CELLS_FOR_MISMATCH_SKIP,
|
|
717
|
+
max_dimension_ratio: float = DEFAULT_MAX_DIMENSION_RATIO,
|
|
718
|
+
mismatch_skip_score: float = DEFAULT_MISMATCH_SKIP_SCORE,
|
|
719
|
+
) -> dict[str, Any] | None:
|
|
720
|
+
"""Compute GriTS_Con from two HTML table strings.
|
|
721
|
+
|
|
722
|
+
Args:
|
|
723
|
+
true_html: Ground-truth HTML table string.
|
|
724
|
+
pred_html: Predicted HTML table string.
|
|
725
|
+
|
|
726
|
+
Returns a dict with keys: grits_con and its precision/recall/upper_bound
|
|
727
|
+
variants, plus alignment maps. Returns None if parsing fails.
|
|
728
|
+
"""
|
|
729
|
+
true_cells = html_to_cells(true_html)
|
|
730
|
+
pred_cells = html_to_cells(pred_html)
|
|
731
|
+
|
|
732
|
+
if true_cells is None or pred_cells is None:
|
|
733
|
+
return None
|
|
734
|
+
if not true_cells or not pred_cells:
|
|
735
|
+
return None
|
|
736
|
+
|
|
737
|
+
true_text = np.array(cells_to_grid(true_cells, key="cell_text"), dtype=object)
|
|
738
|
+
pred_text = np.array(cells_to_grid(pred_cells, key="cell_text"), dtype=object)
|
|
739
|
+
|
|
740
|
+
true_rows = max(max(c["row_nums"]) for c in true_cells) + 1
|
|
741
|
+
true_cols = max(max(c["column_nums"]) for c in true_cells) + 1
|
|
742
|
+
pred_rows = max(max(c["row_nums"]) for c in pred_cells) + 1
|
|
743
|
+
pred_cols = max(max(c["column_nums"]) for c in pred_cells) + 1
|
|
744
|
+
|
|
745
|
+
true_cells_count = true_rows * true_cols
|
|
746
|
+
pred_cells_count = pred_rows * pred_cols
|
|
747
|
+
|
|
748
|
+
# Skip when tables are large and dimensions are badly mismatched —
|
|
749
|
+
# the prediction is structurally wrong so GriTS won't be informative,
|
|
750
|
+
# and the O(R1*C1*R2*C2) cost would be extreme.
|
|
751
|
+
larger_cells = max(true_cells_count, pred_cells_count)
|
|
752
|
+
if larger_cells >= min_cells_for_mismatch_skip:
|
|
753
|
+
row_ratio = max(true_rows, pred_rows) / max(min(true_rows, pred_rows), 1)
|
|
754
|
+
col_ratio = max(true_cols, pred_cols) / max(min(true_cols, pred_cols), 1)
|
|
755
|
+
if row_ratio > max_dimension_ratio or col_ratio > max_dimension_ratio:
|
|
756
|
+
print(
|
|
757
|
+
f" GriTS: skipping — large table ({true_rows}x{true_cols} vs "
|
|
758
|
+
f"{pred_rows}x{pred_cols}) with dimension ratio "
|
|
759
|
+
f"{max(row_ratio, col_ratio):.1f}x > {max_dimension_ratio}x threshold, "
|
|
760
|
+
f"scoring {mismatch_skip_score}",
|
|
761
|
+
flush=True,
|
|
762
|
+
)
|
|
763
|
+
s = mismatch_skip_score
|
|
764
|
+
return {
|
|
765
|
+
"grits_con": s,
|
|
766
|
+
"grits_precision_con": s,
|
|
767
|
+
"grits_recall_con": s,
|
|
768
|
+
"grits_con_upper_bound": s,
|
|
769
|
+
"_con_row_alignment": {},
|
|
770
|
+
"_con_col_alignment": {},
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
metrics: dict[str, Any] = {}
|
|
774
|
+
(
|
|
775
|
+
metrics["grits_con"],
|
|
776
|
+
metrics["grits_precision_con"],
|
|
777
|
+
metrics["grits_recall_con"],
|
|
778
|
+
metrics["grits_con_upper_bound"],
|
|
779
|
+
row_map,
|
|
780
|
+
col_map,
|
|
781
|
+
) = grits_con_with_alignment(true_text, pred_text)
|
|
782
|
+
metrics["_con_row_alignment"] = row_map
|
|
783
|
+
metrics["_con_col_alignment"] = col_map
|
|
784
|
+
|
|
785
|
+
return metrics
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _leading_title_band_text(td: TableData) -> str | None:
|
|
789
|
+
"""Normalized text of a leading full-width title band, if the table has one.
|
|
790
|
+
|
|
791
|
+
A title band is row 0 rendered as one colspan cell: after span expansion
|
|
792
|
+
every column of row 0 carries the same non-empty text. Tables with only
|
|
793
|
+
one row, or only one column, are excluded — the first has no body left to
|
|
794
|
+
score once the band is dropped, the second cannot distinguish a band from
|
|
795
|
+
an ordinary cell.
|
|
796
|
+
"""
|
|
797
|
+
n_rows, n_cols = td.data.shape
|
|
798
|
+
if n_rows < 2 or n_cols < 2:
|
|
799
|
+
return None
|
|
800
|
+
texts = {normalize_cell_text(str(td.data[0, c])) for c in range(n_cols)}
|
|
801
|
+
if len(texts) != 1:
|
|
802
|
+
return None
|
|
803
|
+
text = texts.pop()
|
|
804
|
+
return text or None
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _drop_caption_matched_title_band(td: TableData, other_caption: str) -> TableData:
|
|
808
|
+
"""Drop ``td``'s leading title band when ``other_caption`` says the same thing.
|
|
809
|
+
|
|
810
|
+
``<caption>`` is the semantically correct place for a table's title, and a
|
|
811
|
+
prediction that uses it must not be scored as having lost the title row
|
|
812
|
+
that a ground truth spells as a full-width header band (or vice versa).
|
|
813
|
+
The caption never enters either grid — it is not a ``<td>``/``<th>`` — so
|
|
814
|
+
the band on the other side is the only asymmetry, and removing it makes
|
|
815
|
+
the two structures comparable.
|
|
816
|
+
|
|
817
|
+
Guarded by text equality under the ordinary cell normalization, so a band
|
|
818
|
+
that says something the caption does not is left in place and still
|
|
819
|
+
scores. Applied in both directions by the caller, so the treatment is
|
|
820
|
+
symmetric between ground truth and prediction.
|
|
821
|
+
"""
|
|
822
|
+
caption = normalize_cell_text(other_caption)
|
|
823
|
+
if not caption:
|
|
824
|
+
return td
|
|
825
|
+
band = _leading_title_band_text(td)
|
|
826
|
+
if band is None or band != caption:
|
|
827
|
+
return td
|
|
828
|
+
return replace(td, data=td.data[1:, :])
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def grits_con_from_table_data(
|
|
832
|
+
gt_td: TableData,
|
|
833
|
+
pred_td: TableData,
|
|
834
|
+
min_cells_for_mismatch_skip: int = DEFAULT_MIN_CELLS_FOR_MISMATCH_SKIP,
|
|
835
|
+
max_dimension_ratio: float = DEFAULT_MAX_DIMENSION_RATIO,
|
|
836
|
+
mismatch_skip_score: float = DEFAULT_MISMATCH_SKIP_SCORE,
|
|
837
|
+
) -> dict[str, Any] | None:
|
|
838
|
+
"""Compute GriTS_Con from two parsed ``TableData`` objects.
|
|
839
|
+
|
|
840
|
+
Reads the resolved 2D grid from ``td.data`` directly (no HTML re-parsing)
|
|
841
|
+
and applies the upgraded ``normalize_cell_text``. P5 entry point — replaces
|
|
842
|
+
the older ``grits_from_html`` path on the GriTS hot path.
|
|
843
|
+
"""
|
|
844
|
+
if gt_td.data.size == 0 or pred_td.data.size == 0:
|
|
845
|
+
return None
|
|
846
|
+
|
|
847
|
+
# A <caption> on one side and a full-width title band saying the same
|
|
848
|
+
# thing on the other are the same title rendered two ways; neutralize the
|
|
849
|
+
# band so the difference costs nothing. Both directions, so the treatment
|
|
850
|
+
# is symmetric.
|
|
851
|
+
if pred_td.caption:
|
|
852
|
+
gt_td = _drop_caption_matched_title_band(gt_td, pred_td.caption)
|
|
853
|
+
if gt_td.caption:
|
|
854
|
+
pred_td = _drop_caption_matched_title_band(pred_td, gt_td.caption)
|
|
855
|
+
if gt_td.data.size == 0 or pred_td.data.size == 0:
|
|
856
|
+
return None
|
|
857
|
+
|
|
858
|
+
true_rows, true_cols = gt_td.data.shape
|
|
859
|
+
pred_rows, pred_cols = pred_td.data.shape
|
|
860
|
+
|
|
861
|
+
# Skip when tables are large and dimensions are badly mismatched —
|
|
862
|
+
# the prediction is structurally wrong so GriTS won't be informative,
|
|
863
|
+
# and the O(R1*C1*R2*C2) cost would be extreme.
|
|
864
|
+
larger_cells = max(true_rows * true_cols, pred_rows * pred_cols)
|
|
865
|
+
if larger_cells >= min_cells_for_mismatch_skip:
|
|
866
|
+
row_ratio = max(true_rows, pred_rows) / max(min(true_rows, pred_rows), 1)
|
|
867
|
+
col_ratio = max(true_cols, pred_cols) / max(min(true_cols, pred_cols), 1)
|
|
868
|
+
if row_ratio > max_dimension_ratio or col_ratio > max_dimension_ratio:
|
|
869
|
+
print(
|
|
870
|
+
f" GriTS: skipping — large table ({true_rows}x{true_cols} vs "
|
|
871
|
+
f"{pred_rows}x{pred_cols}) with dimension ratio "
|
|
872
|
+
f"{max(row_ratio, col_ratio):.1f}x > {max_dimension_ratio}x threshold, "
|
|
873
|
+
f"scoring {mismatch_skip_score}",
|
|
874
|
+
flush=True,
|
|
875
|
+
)
|
|
876
|
+
s = mismatch_skip_score
|
|
877
|
+
return {
|
|
878
|
+
"grits_con": s,
|
|
879
|
+
"grits_precision_con": s,
|
|
880
|
+
"grits_recall_con": s,
|
|
881
|
+
"grits_con_upper_bound": s,
|
|
882
|
+
"_con_row_alignment": {},
|
|
883
|
+
"_con_col_alignment": {},
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
true_text = np.empty_like(gt_td.data)
|
|
887
|
+
for r in range(true_rows):
|
|
888
|
+
for c in range(true_cols):
|
|
889
|
+
true_text[r, c] = normalize_cell_text(str(gt_td.data[r, c]))
|
|
890
|
+
pred_text = np.empty_like(pred_td.data)
|
|
891
|
+
for r in range(pred_rows):
|
|
892
|
+
for c in range(pred_cols):
|
|
893
|
+
pred_text[r, c] = normalize_cell_text(str(pred_td.data[r, c]))
|
|
894
|
+
|
|
895
|
+
metrics: dict[str, Any] = {}
|
|
896
|
+
(
|
|
897
|
+
metrics["grits_con"],
|
|
898
|
+
metrics["grits_precision_con"],
|
|
899
|
+
metrics["grits_recall_con"],
|
|
900
|
+
metrics["grits_con_upper_bound"],
|
|
901
|
+
row_map,
|
|
902
|
+
col_map,
|
|
903
|
+
) = grits_con_with_alignment(true_text, pred_text)
|
|
904
|
+
metrics["_con_row_alignment"] = row_map
|
|
905
|
+
metrics["_con_col_alignment"] = col_map
|
|
906
|
+
|
|
907
|
+
return metrics
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
# =============================================================================
|
|
911
|
+
# Module-level helper for parallel pairwise computation
|
|
912
|
+
# (must be top-level so ProcessPoolExecutor can pickle it)
|
|
913
|
+
# =============================================================================
|
|
914
|
+
|
|
915
|
+
_ZERO_RESULT: dict[str, Any] = {
|
|
916
|
+
"grits_con": 0.0,
|
|
917
|
+
"grits_precision_con": 0.0,
|
|
918
|
+
"grits_recall_con": 0.0,
|
|
919
|
+
"grits_con_upper_bound": 0.0,
|
|
920
|
+
"_con_row_alignment": {},
|
|
921
|
+
"_con_col_alignment": {},
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
# =============================================================================
|
|
926
|
+
# GriTSMetric class (Metric interface)
|
|
927
|
+
# =============================================================================
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
class GriTSMetric(Metric):
|
|
931
|
+
"""GriTS metric for comparing HTML tables in markdown content.
|
|
932
|
+
|
|
933
|
+
Computes Grid Table Similarity (content / GriTS_Con) between expected
|
|
934
|
+
and actual HTML tables. Uses the Hungarian algorithm for optimal table
|
|
935
|
+
matching when documents contain multiple tables.
|
|
936
|
+
"""
|
|
937
|
+
|
|
938
|
+
@property
|
|
939
|
+
def name(self) -> str:
|
|
940
|
+
"""Return the name of this metric."""
|
|
941
|
+
return "grits"
|
|
942
|
+
|
|
943
|
+
def compute( # type: ignore[override]
|
|
944
|
+
self,
|
|
945
|
+
expected_tables: list[Any],
|
|
946
|
+
actual_tables: list[Any],
|
|
947
|
+
*,
|
|
948
|
+
expected_pages: list[int] | None = None,
|
|
949
|
+
actual_pages: list[int] | None = None,
|
|
950
|
+
**kwargs: Any,
|
|
951
|
+
) -> list[MetricValue]:
|
|
952
|
+
"""Compute GriTS_Con scores between expected and actual table sets.
|
|
953
|
+
|
|
954
|
+
Consumes pre-extracted ``ExtractedTable`` lists from the shared
|
|
955
|
+
``extract_table_pairs`` stage so that GriTS and TRM provably see
|
|
956
|
+
the same tables. The lift is purely "stop calling extract_html_tables
|
|
957
|
+
yourself" — internal scoring (html_to_cells → cells_to_grid → grits_con,
|
|
958
|
+
Hungarian assignment) is unchanged from main.
|
|
959
|
+
|
|
960
|
+
Args:
|
|
961
|
+
expected_tables: Pre-extracted GT tables (``list[ExtractedTable]``).
|
|
962
|
+
actual_tables: Pre-extracted predicted tables (``list[ExtractedTable]``).
|
|
963
|
+
expected_pages: Optional page number (1-indexed) per GT table, aligned
|
|
964
|
+
with ``expected_tables``. When supplied together with
|
|
965
|
+
``actual_pages`` (and length-consistent), the GT->pred assignment
|
|
966
|
+
is solved per page so a GT table only pairs with a prediction on
|
|
967
|
+
the same page. Omit for the document-global assignment.
|
|
968
|
+
actual_pages: Optional page number per predicted table, aligned with
|
|
969
|
+
``actual_tables``.
|
|
970
|
+
kwargs: Additional parameters (not used)
|
|
971
|
+
|
|
972
|
+
Returns:
|
|
973
|
+
List with a single MetricValue for grits_con.
|
|
974
|
+
"""
|
|
975
|
+
# P5: read TableData directly from the ExtractedTable inputs and
|
|
976
|
+
# apply the upgraded normalize_cell_text. The raw_html field is no
|
|
977
|
+
# longer touched on the GriTS hot path.
|
|
978
|
+
expected_td = [et.table_data for et in expected_tables]
|
|
979
|
+
actual_td = [et.table_data for et in actual_tables]
|
|
980
|
+
|
|
981
|
+
shared_meta: dict[str, Any] = {}
|
|
982
|
+
|
|
983
|
+
if not expected_td:
|
|
984
|
+
shared_meta = {
|
|
985
|
+
"note": "No tables found in expected markdown",
|
|
986
|
+
"tables_found_expected": 0,
|
|
987
|
+
"tables_found_actual": len(actual_td),
|
|
988
|
+
"pairing": [],
|
|
989
|
+
}
|
|
990
|
+
return [MetricValue(metric_name="grits_con", value=0.0, metadata=shared_meta)]
|
|
991
|
+
|
|
992
|
+
if not actual_td:
|
|
993
|
+
shared_meta = {
|
|
994
|
+
"note": "No tables found in actual markdown",
|
|
995
|
+
"tables_found_expected": len(expected_td),
|
|
996
|
+
"tables_found_actual": 0,
|
|
997
|
+
"tables_matched": 0,
|
|
998
|
+
"pairing": [(i, None) for i in range(len(expected_td))],
|
|
999
|
+
}
|
|
1000
|
+
return [MetricValue(metric_name="grits_con", value=0.0, metadata=shared_meta)]
|
|
1001
|
+
|
|
1002
|
+
n_expected = len(expected_td)
|
|
1003
|
+
n_actual = len(actual_td)
|
|
1004
|
+
|
|
1005
|
+
# When per-table page labels are supplied, only same-page pairs are
|
|
1006
|
+
# eligible to match — so only those need a GriTS comparison. This both
|
|
1007
|
+
# constrains the assignment to within-page pairs and avoids the O(n_gt *
|
|
1008
|
+
# n_pred) blow-up on multi-page documents.
|
|
1009
|
+
blocked = page_blocking_active(expected_pages, actual_pages, n_expected, n_actual)
|
|
1010
|
+
allowed_pairs = [
|
|
1011
|
+
(i, j)
|
|
1012
|
+
for i in range(n_expected)
|
|
1013
|
+
for j in range(n_actual)
|
|
1014
|
+
if not blocked or expected_pages[i] == actual_pages[j] # type: ignore[index]
|
|
1015
|
+
]
|
|
1016
|
+
total_pairs = len(allowed_pairs)
|
|
1017
|
+
|
|
1018
|
+
print(
|
|
1019
|
+
f" GriTS: comparing {n_expected} expected x {n_actual} actual = {total_pairs} table pair(s)"
|
|
1020
|
+
f"{' (same-page only)' if blocked else ''}",
|
|
1021
|
+
flush=True,
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
# Compute pairwise GriTS scores for the eligible pairs only. Disallowed
|
|
1025
|
+
# (cross-page) cells keep cost 0.0 and are never read — the per-page
|
|
1026
|
+
# assignment only ever selects same-page pairs.
|
|
1027
|
+
results_cache: dict[tuple[int, int], dict[str, Any]] = {}
|
|
1028
|
+
cost_matrix = np.zeros((n_expected, n_actual))
|
|
1029
|
+
|
|
1030
|
+
for pair_idx, (i, j) in enumerate(allowed_pairs, start=1):
|
|
1031
|
+
if total_pairs > 1:
|
|
1032
|
+
print(f" GriTS: table pair {pair_idx}/{total_pairs}", flush=True)
|
|
1033
|
+
maybe_result = grits_con_from_table_data(expected_td[i], actual_td[j])
|
|
1034
|
+
result = maybe_result if maybe_result is not None else dict(_ZERO_RESULT)
|
|
1035
|
+
results_cache[(i, j)] = result
|
|
1036
|
+
cost_matrix[i, j] = -result["grits_con"]
|
|
1037
|
+
|
|
1038
|
+
# Solve assignment (per page when page labels are supplied, else global).
|
|
1039
|
+
row_ind, col_ind = assign_tables(cost_matrix, expected_pages, actual_pages)
|
|
1040
|
+
|
|
1041
|
+
per_table_details: list[dict[str, Any]] = []
|
|
1042
|
+
con_scores: list[float] = []
|
|
1043
|
+
matched_gt: set[int] = set()
|
|
1044
|
+
|
|
1045
|
+
for gt_idx, pred_idx in zip(row_ind, col_ind, strict=True):
|
|
1046
|
+
gi, pi = int(gt_idx), int(pred_idx)
|
|
1047
|
+
result = results_cache[(gi, pi)]
|
|
1048
|
+
con_scores.append(result["grits_con"])
|
|
1049
|
+
detail: dict[str, Any] = {
|
|
1050
|
+
"gt_table_index": gi,
|
|
1051
|
+
"pred_table_index": pi,
|
|
1052
|
+
"grits_con": result["grits_con"],
|
|
1053
|
+
"grits_precision_con": result["grits_precision_con"],
|
|
1054
|
+
"grits_recall_con": result["grits_recall_con"],
|
|
1055
|
+
"_con_row_alignment": result.get("_con_row_alignment", {}),
|
|
1056
|
+
"_con_col_alignment": result.get("_con_col_alignment", {}),
|
|
1057
|
+
}
|
|
1058
|
+
if blocked:
|
|
1059
|
+
detail["gt_page"] = expected_pages[gi] # type: ignore[index]
|
|
1060
|
+
detail["pred_page"] = actual_pages[pi] # type: ignore[index]
|
|
1061
|
+
per_table_details.append(detail)
|
|
1062
|
+
matched_gt.add(gi)
|
|
1063
|
+
|
|
1064
|
+
# Unmatched expected tables score 0
|
|
1065
|
+
for i in range(n_expected):
|
|
1066
|
+
if i not in matched_gt:
|
|
1067
|
+
con_scores.append(0.0)
|
|
1068
|
+
unmatched_detail: dict[str, Any] = {
|
|
1069
|
+
"gt_table_index": i,
|
|
1070
|
+
"pred_table_index": None,
|
|
1071
|
+
"grits_con": 0.0,
|
|
1072
|
+
"note": "No matching table in actual",
|
|
1073
|
+
}
|
|
1074
|
+
if blocked:
|
|
1075
|
+
unmatched_detail["gt_page"] = expected_pages[i] # type: ignore[index]
|
|
1076
|
+
per_table_details.append(unmatched_detail)
|
|
1077
|
+
|
|
1078
|
+
avg_con = sum(con_scores) / len(con_scores) if con_scores else 0.0
|
|
1079
|
+
print(f" GriTS: done, con = {avg_con:.4f}", flush=True)
|
|
1080
|
+
|
|
1081
|
+
# Build the load-bearing pairing key consumed by TRM and the
|
|
1082
|
+
# evaluator's count metrics: list[(gt_idx, pred_idx | None)] of
|
|
1083
|
+
# length n_expected. Unmatched GT tables get None.
|
|
1084
|
+
pairing: list[tuple[int, int | None]] = []
|
|
1085
|
+
for i in range(n_expected):
|
|
1086
|
+
if i in matched_gt:
|
|
1087
|
+
# Find the matched pred index from row_ind/col_ind
|
|
1088
|
+
for gi, pi in zip(row_ind, col_ind, strict=True):
|
|
1089
|
+
if int(gi) == i:
|
|
1090
|
+
pairing.append((i, int(pi)))
|
|
1091
|
+
break
|
|
1092
|
+
else:
|
|
1093
|
+
pairing.append((i, None))
|
|
1094
|
+
|
|
1095
|
+
shared_meta = {
|
|
1096
|
+
"tables_found_expected": n_expected,
|
|
1097
|
+
"tables_found_actual": n_actual,
|
|
1098
|
+
"tables_matched": len(row_ind),
|
|
1099
|
+
"per_table_details": per_table_details,
|
|
1100
|
+
"pairing": pairing,
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
# Build human-readable detail strings
|
|
1104
|
+
details: list[str] = []
|
|
1105
|
+
details.append(f"{n_expected} table(s) expected, {n_actual} found, {len(row_ind)} matched")
|
|
1106
|
+
for td in per_table_details:
|
|
1107
|
+
gt_i: int = td["gt_table_index"]
|
|
1108
|
+
pr_i: int | None = td.get("pred_table_index")
|
|
1109
|
+
if pr_i is None:
|
|
1110
|
+
details.append(f"Table {gt_i + 1}: no match found in prediction")
|
|
1111
|
+
else:
|
|
1112
|
+
details.append(
|
|
1113
|
+
f"Table {gt_i + 1}: con={td['grits_con']:.3f}"
|
|
1114
|
+
f" (precision={td.get('grits_precision_con', 0):.2f},"
|
|
1115
|
+
f" recall={td.get('grits_recall_con', 0):.2f})"
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
return [
|
|
1119
|
+
MetricValue(
|
|
1120
|
+
metric_name="grits_con",
|
|
1121
|
+
value=avg_con,
|
|
1122
|
+
metadata=shared_meta,
|
|
1123
|
+
details=details,
|
|
1124
|
+
),
|
|
1125
|
+
]
|