zeroshotGPU / zsgdp /verify /table_structure.py
Arjunvir Singh
Initial commit: zeroshotGPU MVP with full eval surface
db06ffa
Raw
History Blame Contribute Delete
8.56 kB
"""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"<tr[^>]*>(.*?)</tr>", 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"<t[hd][^>]*>(.*?)</t[hd]>", 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