"""Table structure similarity against ground-truth tables. Definitions (pinned): - A TableRecord is a dict with keys: rows (list[list[str]] of normalized cell strings), page_num (int|None), table_id (str|None), bbox (xyxy|None). - Predictions are matched to truths greedily, page-aware. The match score blends shape similarity (row/col count) with cell-content overlap, so a prediction with the same dimensions and overlapping headers wins over one with extra phantom rows. - For each matched pair we compute: - shape_similarity = 1 - mean(|rows_p - rows_t|/max_rows, |cols_p - cols_t|/max_cols) bounded to [0, 1]. - cell_content_f1 = F1 over the multiset of normalized cell strings. Multiset (not set) so duplicate "0.00" cells in financial tables count. - score = 0.5 * shape_similarity + 0.5 * cell_content_f1. - Document-level aggregates: - mean_table_score = mean(score) over matched pairs. - table_match_rate = matched_pairs / max(len(predictions), len(truths)). - table_count_delta = len(predictions) - len(truths). - Edge cases: empty/empty -> mean_table_score=1.0 (vacuous); one side empty -> 0.0 with all unmatched recorded as fp/fn. """ from __future__ import annotations import re from typing import Any, Iterable TableRecord = dict[str, Any] def compute_table_structure_score( predictions: Iterable[TableRecord], truths: Iterable[TableRecord], ) -> dict[str, Any]: pred_list = [item for item in (_normalize(record) for record in predictions) if item is not None] truth_list = [item for item in (_normalize(record) for record in truths) if item is not None] if not pred_list and not truth_list: return { "prediction_count": 0, "truth_count": 0, "matched_pair_count": 0, "table_match_rate": 1.0, "mean_table_score": 1.0, "mean_shape_similarity": 1.0, "mean_cell_content_f1": 1.0, "table_count_delta": 0, "matches": [], "unmatched_predictions": [], "unmatched_truths": [], } pairs: list[tuple[float, int, int, dict[str, float]]] = [] for pred_index, prediction in enumerate(pred_list): for truth_index, truth in enumerate(truth_list): if prediction["page_num"] is not None and truth["page_num"] is not None: if prediction["page_num"] != truth["page_num"]: continue scores = _score_pair(prediction, truth) if scores["match_score"] <= 0.0: continue pairs.append((scores["match_score"], pred_index, truth_index, scores)) pairs.sort(key=lambda item: item[0], reverse=True) pred_taken = [False] * len(pred_list) truth_taken = [False] * len(truth_list) matches: list[dict[str, Any]] = [] for _match_score, pred_index, truth_index, scores in pairs: if pred_taken[pred_index] or truth_taken[truth_index]: continue pred_taken[pred_index] = True truth_taken[truth_index] = True matches.append( { "prediction_index": pred_index, "truth_index": truth_index, "page_num": pred_list[pred_index]["page_num"] or truth_list[truth_index]["page_num"], "score": scores["score"], "shape_similarity": scores["shape_similarity"], "cell_content_f1": scores["cell_content_f1"], "predicted_shape": [len(pred_list[pred_index]["rows"]), _max_cols(pred_list[pred_index]["rows"])], "truth_shape": [len(truth_list[truth_index]["rows"]), _max_cols(truth_list[truth_index]["rows"])], } ) matched_pair_count = len(matches) denominator = max(len(pred_list), len(truth_list)) mean_table_score = (sum(match["score"] for match in matches) / denominator) if denominator else 1.0 mean_shape = (sum(match["shape_similarity"] for match in matches) / matched_pair_count) if matched_pair_count else 0.0 mean_cell = (sum(match["cell_content_f1"] for match in matches) / matched_pair_count) if matched_pair_count else 0.0 return { "prediction_count": len(pred_list), "truth_count": len(truth_list), "matched_pair_count": matched_pair_count, "table_match_rate": (matched_pair_count / denominator) if denominator else 1.0, "mean_table_score": mean_table_score, "mean_shape_similarity": mean_shape, "mean_cell_content_f1": mean_cell, "table_count_delta": len(pred_list) - len(truth_list), "matches": matches, "unmatched_predictions": [index for index, taken in enumerate(pred_taken) if not taken], "unmatched_truths": [index for index, taken in enumerate(truth_taken) if not taken], } def markdown_to_rows(markdown: str | None) -> list[list[str]]: if not markdown: return [] rows: list[list[str]] = [] for line in markdown.splitlines(): stripped = line.strip() if not (stripped.startswith("|") and stripped.endswith("|")): continue cells = [_normalize_cell(cell) for cell in stripped.strip("|").split("|")] if cells and all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells if cell): continue rows.append(cells) return rows def html_to_rows(html: str | None) -> list[list[str]]: if not html: return [] rows: list[list[str]] = [] for tr_match in re.finditer(r"]*>(.*?)", html, flags=re.IGNORECASE | re.DOTALL): tr_body = tr_match.group(1) cells = [ _normalize_cell(_strip_html(cell.group(1))) for cell in re.finditer(r"]*>(.*?)", tr_body, flags=re.IGNORECASE | re.DOTALL) ] if cells: rows.append(cells) return rows def _strip_html(text: str) -> str: return re.sub(r"<[^>]+>", " ", text) def _normalize_cell(cell: str) -> str: return " ".join(cell.replace("\xa0", " ").split()).strip().lower() def _normalize(record: TableRecord | None) -> TableRecord | None: if not isinstance(record, dict): return None rows = record.get("rows") if rows is None: rows = markdown_to_rows(record.get("markdown")) if not rows: rows = html_to_rows(record.get("html")) rows = [[_normalize_cell(cell) for cell in row] for row in rows if row] if not rows: return None page_num = record.get("page_num") return { "rows": rows, "page_num": int(page_num) if isinstance(page_num, (int, float)) else None, "table_id": record.get("table_id"), "bbox": record.get("bbox"), } def _score_pair(prediction: TableRecord, truth: TableRecord) -> dict[str, float]: shape_similarity = _shape_similarity(prediction["rows"], truth["rows"]) cell_content_f1 = _multiset_f1(_cell_multiset(prediction["rows"]), _cell_multiset(truth["rows"])) score = 0.5 * shape_similarity + 0.5 * cell_content_f1 # Prefer matches that share at least one cell content token to break ties. overlap_bonus = 0.0 if cell_content_f1 > 0: overlap_bonus = 0.01 return { "shape_similarity": shape_similarity, "cell_content_f1": cell_content_f1, "score": score, "match_score": score + overlap_bonus, } def _shape_similarity(a: list[list[str]], b: list[list[str]]) -> float: rows_a = len(a) rows_b = len(b) cols_a = _max_cols(a) cols_b = _max_cols(b) max_rows = max(rows_a, rows_b, 1) max_cols = max(cols_a, cols_b, 1) row_diff = abs(rows_a - rows_b) / max_rows col_diff = abs(cols_a - cols_b) / max_cols return max(0.0, 1.0 - 0.5 * (row_diff + col_diff)) def _max_cols(rows: list[list[str]]) -> int: return max((len(row) for row in rows), default=0) def _cell_multiset(rows: list[list[str]]) -> list[str]: return [cell for row in rows for cell in row if cell] def _multiset_f1(predicted: list[str], truth: list[str]) -> float: if not predicted and not truth: return 1.0 if not predicted or not truth: return 0.0 truth_remaining = list(truth) tp = 0 for cell in predicted: if cell in truth_remaining: truth_remaining.remove(cell) tp += 1 fp = len(predicted) - tp fn = len(truth_remaining) precision = tp / (tp + fp) if (tp + fp) else 0.0 recall = tp / (tp + fn) if (tp + fn) else 0.0 return (2 * precision * recall / (precision + recall)) if (precision + recall) else 0.0